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:
2026-05-23 17:15:40 +02:00
parent 83fc71414f
commit 06d01a3bce
20 changed files with 2632 additions and 28 deletions
+110
View File
@@ -0,0 +1,110 @@
<?php
/**
* POST /api/dashboard/chat-stream.php (SSE)
*
* Streams a RAG chat answer using the user's private corpus + the dobetter
* legal package. Each output token is delivered as an SSE event named "token".
* On completion, sources, chunks_used, model, and elapsed_ms are sent as a
* "done" event. Errors are sent as a "fail" event.
*
* Request body (JSON):
* {
* "question": "Hva sier barnevernloven § 4-12?",
* "history": [{role:"user"|"assistant", content:"..."}], // optional, capped at 8
* "category": "barnevern" (optional),
* "language": "no" | "en" (optional, default no)
* }
*/
declare(strict_types=1);
require_once dirname(__DIR__, 2) . '/includes/bootstrap.php';
dbnToolsRequireMethod('POST');
dbnToolsRequireAuth();
try {
$tenant = dbnToolsEnsureDashboardTenant();
} catch (DbnToolsHttpException $e) {
dbnToolsError($e->getMessage(), $e->status, $e->errorCode);
}
$clientId = (int)$tenant['client_id'];
$input = dbnToolsJsonInput(80_000);
$question = trim((string)($input['question'] ?? ''));
if ($question === '') {
dbnToolsError('question is required.', 400, 'missing_question');
}
if (mb_strlen($question, 'UTF-8') > 4000) {
dbnToolsError('question is too long (max 4000 chars).', 422, 'question_too_long');
}
$history = is_array($input['history'] ?? null) ? $input['history'] : [];
$history = array_slice($history, -8);
$history = array_values(array_filter($history, fn($m) => is_array($m)
&& in_array($m['role'] ?? '', ['user', 'assistant'], true)
&& is_string($m['content'] ?? null)));
$category = trim((string)($input['category'] ?? '')) ?: null;
$language = in_array($input['language'] ?? 'no', ['no', 'en'], true) ? $input['language'] : 'no';
// SSE setup
header('Content-Type: text/event-stream');
header('Cache-Control: no-cache, no-transform');
header('X-Accel-Buffering: no');
@ini_set('output_buffering', 'off');
@ini_set('zlib.output_compression', '0');
while (ob_get_level() > 0) ob_end_flush();
ob_implicit_flush(true);
function sseEmit(string $event, array $data): void {
echo "event: {$event}\n";
echo 'data: ' . json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n\n";
if (function_exists('flush')) @flush();
}
dbnToolsBootCaveau();
try {
$rag = new ClientRagPipeline($clientId);
$options = [
'conversation_history' => $history,
'language' => $language,
'user_id' => (int)($tenant['client_user_id'] ?? 0),
'user_role' => 'owner',
];
$result = $rag->askStreaming(
$question,
null, // model: let pipeline choose default
$category,
$options,
function (string $chunk): void {
if ($chunk !== '') sseEmit('token', ['t' => $chunk]);
}
);
$sources = [];
foreach (($result['fullChunks'] ?? $result['chunks'] ?? []) as $c) {
if (!is_array($c)) continue;
$sources[] = [
'document_id' => (int)($c['document_id'] ?? 0),
'title' => (string)($c['title'] ?? ''),
'section' => (string)($c['section_title'] ?? $c['section'] ?? ''),
'source_url' => (string)($c['source_url'] ?? ''),
'score' => isset($c['score']) ? (float)$c['score'] : null,
];
}
sseEmit('done', [
'ok' => true,
'chunks_used' => (int)($result['chunks_used'] ?? count($sources)),
'model' => (string)($result['model'] ?? ''),
'response_time_ms'=> (int)($result['response_time_ms'] ?? 0),
'sources' => $sources,
]);
} catch (Throwable $e) {
sseEmit('fail', ['ok' => false, 'message' => $e->getMessage()]);
}
exit;
+38
View File
@@ -0,0 +1,38 @@
<?php
/**
* GET /api/dashboard/corpus-init.php
*
* Idempotent: ensures the current session has a CaveauAI client tenant +
* default corpus, lazy-creating both on first hit. Safe to call on every
* dashboard page load (results are session-cached).
*
* Response:
* {
* "ok": true,
* "client_id": 102,
* "client_user_id": 257,
* "corpus_id": 18,
* "created": false
* }
*/
declare(strict_types=1);
require_once dirname(__DIR__, 2) . '/includes/bootstrap.php';
dbnToolsRequireMethod('GET');
dbnToolsRequireAuth();
try {
$tenant = dbnToolsEnsureDashboardTenant();
} catch (DbnToolsHttpException $e) {
dbnToolsError($e->getMessage(), $e->status, $e->errorCode, $e->extra);
}
dbnToolsRespond([
'ok' => true,
'client_id' => (int)$tenant['client_id'],
'client_user_id' => (int)$tenant['client_user_id'],
'corpus_id' => (int)$tenant['corpus_id'],
'created' => (bool)($tenant['created'] ?? false),
]);
+249
View File
@@ -0,0 +1,249 @@
<?php
/**
* /api/dashboard/documents.php — CRUD for the current user's CaveauAI documents.
*
* GET ?action=list&offset=0&limit=20&q=&status=&category=
* → { ok, total, documents: [...] }
* GET ?action=get&id=123
* → { ok, document: {...} }
* POST ?action=update body: { id, title?, category?, tags?, language?, author? }
* → { ok, document: {...} }
* POST ?action=delete body: { ids: [1,2,3] }
* → { ok, deleted: N }
*
* All filtered by client_id from the dashboard session — no cross-tenant access possible.
*/
declare(strict_types=1);
require_once dirname(__DIR__, 2) . '/includes/bootstrap.php';
dbnToolsRequireAuth();
try {
$tenant = dbnToolsEnsureDashboardTenant();
} catch (DbnToolsHttpException $e) {
dbnToolsError($e->getMessage(), $e->status, $e->errorCode);
}
$clientId = (int)$tenant['client_id'];
$method = strtoupper((string)($_SERVER['REQUEST_METHOD'] ?? 'GET'));
$action = (string)($_GET['action'] ?? ($method === 'POST' ? '' : 'list'));
$db = dbnToolsDb();
switch ($action) {
case 'list':
dbnToolsRequireMethod('GET');
respondList($db, $clientId);
break;
case 'get':
dbnToolsRequireMethod('GET');
respondGet($db, $clientId);
break;
case 'update':
dbnToolsRequireMethod('POST');
respondUpdate($db, $clientId);
break;
case 'delete':
dbnToolsRequireMethod('POST');
respondDelete($db, $clientId);
break;
default:
dbnToolsError('Unknown action.', 400, 'unknown_action');
}
function respondList(PDO $db, int $clientId): void
{
$offset = max(0, (int)($_GET['offset'] ?? 0));
$limit = max(1, min(100, (int)($_GET['limit'] ?? 20)));
$q = trim((string)($_GET['q'] ?? ''));
$status = trim((string)($_GET['status'] ?? ''));
$category = trim((string)($_GET['category'] ?? ''));
$where = ['client_id = ?'];
$params = [$clientId];
if ($q !== '') {
$where[] = '(title LIKE ? OR tags LIKE ?)';
$like = '%' . str_replace(['%', '_'], ['\%', '\_'], $q) . '%';
$params[] = $like;
$params[] = $like;
}
$allowedStatus = ['pending', 'processing', 'ready', 'error'];
if ($status !== '' && in_array($status, $allowedStatus, true)) {
$where[] = 'status = ?';
$params[] = $status;
}
if ($category !== '') {
$where[] = 'category = ?';
$params[] = $category;
}
$whereSql = 'WHERE ' . implode(' AND ', $where);
$countStmt = $db->prepare("SELECT COUNT(*) FROM client_documents {$whereSql}");
$countStmt->execute($params);
$total = (int)$countStmt->fetchColumn();
$sql = "SELECT id, title, source_type, language, category, tags, author,
source_tool, import_method, status, word_count, chunk_count,
file_size_bytes, source_url, error_message,
created_at, updated_at
FROM client_documents
{$whereSql}
ORDER BY id DESC
LIMIT {$limit} OFFSET {$offset}";
$stmt = $db->prepare($sql);
$stmt->execute($params);
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
dbnToolsRespond([
'ok' => true,
'total' => $total,
'offset' => $offset,
'limit' => $limit,
'documents' => array_map('shapeDoc', $rows),
]);
}
function respondGet(PDO $db, int $clientId): void
{
$id = (int)($_GET['id'] ?? 0);
if ($id <= 0) {
dbnToolsError('id is required.', 400, 'missing_id');
}
$stmt = $db->prepare(
'SELECT * FROM client_documents WHERE id = ? AND client_id = ? LIMIT 1'
);
$stmt->execute([$id, $clientId]);
$doc = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$doc) {
dbnToolsError('Document not found.', 404, 'not_found');
}
$chunks = $db->prepare(
'SELECT id, content, section_title
FROM client_chunks
WHERE client_id = ? AND document_id = ?
ORDER BY id ASC
LIMIT 200'
);
try {
$chunks->execute([$clientId, $id]);
$chunkRows = $chunks->fetchAll(PDO::FETCH_ASSOC);
} catch (Throwable $e) {
$chunkRows = [];
}
dbnToolsRespond([
'ok' => true,
'document' => shapeDoc($doc) + ['content' => (string)$doc['content']],
'chunks' => $chunkRows,
]);
}
function respondUpdate(PDO $db, int $clientId): void
{
$input = dbnToolsJsonInput(20_000);
$id = (int)($input['id'] ?? 0);
if ($id <= 0) {
dbnToolsError('id is required.', 400, 'missing_id');
}
$fields = [];
$params = [];
$allowed = [
'title' => ['VARCHAR', 500],
'category' => ['VARCHAR', 50],
'tags' => ['VARCHAR', 500],
'language' => ['VARCHAR', 10],
'author' => ['VARCHAR', 200],
];
foreach ($allowed as $col => [$kind, $max]) {
if (!array_key_exists($col, $input)) {
continue;
}
$val = trim((string)$input[$col]);
if (mb_strlen($val, 'UTF-8') > $max) {
dbnToolsError("Field {$col} exceeds {$max} chars.", 422, 'field_too_long');
}
$fields[] = "{$col} = ?";
$params[] = $val !== '' ? $val : null;
}
if (!$fields) {
dbnToolsError('No editable fields supplied.', 400, 'no_fields');
}
$params[] = $id;
$params[] = $clientId;
$stmt = $db->prepare(
'UPDATE client_documents SET ' . implode(', ', $fields)
. ', updated_at = NOW() WHERE id = ? AND client_id = ?'
);
$stmt->execute($params);
$stmt = $db->prepare('SELECT * FROM client_documents WHERE id = ? AND client_id = ? LIMIT 1');
$stmt->execute([$id, $clientId]);
$doc = $stmt->fetch(PDO::FETCH_ASSOC);
dbnToolsRespond(['ok' => true, 'document' => shapeDoc($doc ?: [])]);
}
function respondDelete(PDO $db, int $clientId): void
{
$input = dbnToolsJsonInput(50_000);
$ids = $input['ids'] ?? [];
if (!is_array($ids) || !$ids) {
dbnToolsError('ids array is required.', 400, 'missing_ids');
}
$ids = array_values(array_unique(array_map('intval', $ids)));
$ids = array_filter($ids, fn($v) => $v > 0);
if (!$ids) {
dbnToolsError('No valid ids.', 400, 'invalid_ids');
}
if (count($ids) > 200) {
dbnToolsError('Cannot delete more than 200 documents at once.', 422, 'too_many');
}
$placeholders = implode(',', array_fill(0, count($ids), '?'));
$stmt = $db->prepare(
"DELETE FROM client_documents
WHERE client_id = ? AND id IN ({$placeholders})"
);
$stmt->execute(array_merge([$clientId], $ids));
try {
$chunks = $db->prepare(
"DELETE FROM client_chunks WHERE client_id = ? AND document_id IN ({$placeholders})"
);
$chunks->execute(array_merge([$clientId], $ids));
} catch (Throwable $e) {
// table may be filtered to client_id only; non-fatal
}
dbnToolsRespond(['ok' => true, 'deleted' => $stmt->rowCount()]);
}
function shapeDoc(array $row): array
{
return [
'id' => (int)($row['id'] ?? 0),
'title' => (string)($row['title'] ?? ''),
'source_type' => (string)($row['source_type'] ?? ''),
'language' => (string)($row['language'] ?? ''),
'category' => (string)($row['category'] ?? ''),
'tags' => (string)($row['tags'] ?? ''),
'author' => $row['author'] ?? null,
'source_url' => $row['source_url'] ?? null,
'source_tool' => $row['source_tool'] ?? null,
'import_method' => (string)($row['import_method'] ?? ''),
'status' => (string)($row['status'] ?? ''),
'word_count' => (int)($row['word_count'] ?? 0),
'chunk_count' => (int)($row['chunk_count'] ?? 0),
'file_size_bytes'=> (int)($row['file_size_bytes'] ?? 0),
'error_message' => $row['error_message'] ?? null,
'created_at' => (string)($row['created_at'] ?? ''),
'updated_at' => (string)($row['updated_at'] ?? ''),
];
}
+81
View File
@@ -0,0 +1,81 @@
<?php
/**
* GET /api/dashboard/graph.php?action=cites|cited_by|implements|chain&doc_id=N&limit=20&depth=2
*
* Wraps ai-portal/lib/ai/LegalGraphAgent for the dashboard. Reads the FalkorDB
* `bnl_legal` graph on Colin (10.0.2.10:6379). Public graph metadata — no
* sensitive content — but we still gate on dashboard auth to avoid being a
* generic open proxy.
*
* Response shape mirrors ai-portal/api/graph-search.php:
* { ok, action, doc_id, count, results: [ {rel_type, doc_id, title, ...}, ...] }
*/
declare(strict_types=1);
require_once dirname(__DIR__, 2) . '/includes/bootstrap.php';
dbnToolsRequireMethod('GET');
dbnToolsRequireAuth();
// Don't require dashboard provisioning here — graph is public metadata.
$action = trim((string)($_GET['action'] ?? ''));
$docId = (int)($_GET['doc_id'] ?? 0);
$limit = max(1, min(100, (int)($_GET['limit'] ?? 20)));
$depth = max(1, min(3, (int)($_GET['depth'] ?? 2)));
$validActions = ['cites', 'cited_by', 'implements', 'chain'];
if (!in_array($action, $validActions, true)) {
dbnToolsError(
'action must be one of: ' . implode(', ', $validActions),
400, 'invalid_action', ['actions' => $validActions]
);
}
if ($docId <= 0) {
dbnToolsError('doc_id must be a positive integer.', 400, 'missing_doc_id');
}
$root = dbnToolsAiPortalRoot();
$graphFile = $root . '/lib/ai/GraphClient.php';
$agentFile = $root . '/lib/ai/LegalGraphAgent.php';
if (!is_file($graphFile) || !is_file($agentFile)) {
dbnToolsError('Graph backend not installed.', 503, 'graph_unavailable');
}
require_once $graphFile;
require_once $agentFile;
try {
$config = file_exists('/etc/bnl/config.php') ? include '/etc/bnl/config.php' : [];
$host = (string)($config['falkordb']['host'] ?? dbnToolsEnv('DBN_FALKORDB_HOST', '10.0.2.10'));
$port = (int) ($config['falkordb']['port'] ?? (int)dbnToolsEnv('DBN_FALKORDB_PORT', '6379'));
$pass = (string)($config['falkordb']['password'] ?? dbnToolsEnv('DBN_FALKORDB_PASSWORD', ''));
$client = new GraphClient($host, $port, $pass);
$agent = new LegalGraphAgent($client);
$results = match ($action) {
'cites' => $agent->cites($docId, $limit),
'cited_by' => $agent->citedBy($docId, $limit),
'implements' => $agent->implements($docId, $limit),
'chain' => $agent->chain($docId, $depth),
};
} catch (Throwable $e) {
dbnToolsRespond([
'ok' => true,
'action' => $action,
'doc_id' => $docId,
'count' => 0,
'results' => [],
'warning' => 'Graph backend unavailable: ' . $e->getMessage(),
]);
}
dbnToolsRespond([
'ok' => true,
'action' => $action,
'doc_id' => $docId,
'count' => count($results),
'results' => $results,
]);
+53
View File
@@ -0,0 +1,53 @@
<?php
/**
* GET /api/dashboard/ingest-status.php?ids=1,2,3
*
* Returns per-doc status for polling during URL ingest (background) or to
* surface error messages after a failed sync upload.
*
* Response:
* { ok, statuses: [ {id, status, chunk_count, error_message}, ... ] }
*/
declare(strict_types=1);
require_once dirname(__DIR__, 2) . '/includes/bootstrap.php';
dbnToolsRequireMethod('GET');
dbnToolsRequireAuth();
try {
$tenant = dbnToolsEnsureDashboardTenant();
} catch (DbnToolsHttpException $e) {
dbnToolsError($e->getMessage(), $e->status, $e->errorCode);
}
$clientId = (int)$tenant['client_id'];
$raw = (string)($_GET['ids'] ?? '');
$ids = array_values(array_filter(
array_map('intval', explode(',', $raw)),
fn($v) => $v > 0
));
if (!$ids) {
dbnToolsRespond(['ok' => true, 'statuses' => []]);
}
$ids = array_slice($ids, 0, 100);
$db = dbnToolsDb();
$placeholders = implode(',', array_fill(0, count($ids), '?'));
$sql = "SELECT id, status, chunk_count, error_message
FROM client_documents
WHERE client_id = ? AND id IN ({$placeholders})";
$stmt = $db->prepare($sql);
$stmt->execute(array_merge([$clientId], $ids));
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
dbnToolsRespond([
'ok' => true,
'statuses' => array_map(fn($r) => [
'id' => (int)$r['id'],
'status' => (string)$r['status'],
'chunk_count' => (int)$r['chunk_count'],
'error_message' => $r['error_message'] ?? null,
], $rows),
]);
+136
View File
@@ -0,0 +1,136 @@
<?php
/**
* POST /api/dashboard/save-from-tool.php
*
* Improved successor to /api/save-to-corpus.php — adds:
* - tags as either CSV string or array
* - source_tool slug recorded as import provenance
* - chat-answer kind (records import_method='chat_answer')
* - preview flag: if true, returns the proposed chunks WITHOUT persisting (dry-run)
*
* Request body (JSON, max 2 MB):
* title: string (required)
* content: string (required, min 30 chars)
* source_tool: string (optional slug; default 'dashboard-save')
* tags: string[] | string CSV (optional, max 20 tags, 32 chars each)
* category: string (optional; default 'tool-output')
* language: string (optional; default 'no')
* author: string (optional)
* kind: 'tool_output'|'chat_answer'|'manual' (default 'tool_output')
* preview: bool (optional; if true, return chunk preview without saving)
*
* Response (saved):
* { ok, document_id, chunks, status }
* Response (preview):
* { ok, preview:true, chunks: [...], word_count }
*/
declare(strict_types=1);
require_once dirname(__DIR__, 2) . '/includes/bootstrap.php';
dbnToolsRequireMethod('POST');
dbnToolsRequireAuth();
try {
$tenant = dbnToolsEnsureDashboardTenant();
} catch (DbnToolsHttpException $e) {
dbnToolsError($e->getMessage(), $e->status, $e->errorCode);
}
$clientId = (int)$tenant['client_id'];
$corpusId = (int)$tenant['corpus_id'];
$input = dbnToolsJsonInput(2_000_000);
$title = trim((string)($input['title'] ?? ''));
if ($title === '') dbnToolsError('title is required.', 400, 'missing_title');
if (mb_strlen($title, 'UTF-8') > 500) dbnToolsError('title too long (max 500).', 422, 'title_too_long');
$content = trim((string)($input['content'] ?? ''));
if (mb_strlen($content, 'UTF-8') < 30) dbnToolsError('content too short (min 30 chars).', 400, 'content_too_short');
if (mb_strlen($content, 'UTF-8') > 1_900_000) dbnToolsError('content exceeds 2 MB.', 422, 'content_too_large');
$sourceTool = trim((string)($input['source_tool'] ?? 'dashboard-save'));
$sourceTool = substr(preg_replace('/[^a-z0-9\-_]/', '', strtolower($sourceTool)) ?: 'dashboard-save', 0, 64);
$rawTags = $input['tags'] ?? '';
$tagList = is_array($rawTags)
? array_map('strval', $rawTags)
: array_map('trim', explode(',', (string)$rawTags));
$tagList = array_values(array_filter(array_map(fn($t) => substr(trim($t), 0, 32), $tagList)));
$tagList = array_slice($tagList, 0, 20);
$tagsCsv = implode(',', $tagList);
$category = strtolower(trim((string)($input['category'] ?? 'tool-output')));
$category = substr(preg_replace('/[^a-z0-9\-_]/', '', $category) ?: 'tool-output', 0, 50);
$language = trim((string)($input['language'] ?? 'no')) ?: 'no';
$author = trim((string)($input['author'] ?? '')) ?: null;
$kind = (string)($input['kind'] ?? 'tool_output');
$importMethod = match ($kind) {
'chat_answer' => 'chat_answer',
'manual' => 'manual',
default => 'tool_output',
};
$preview = !empty($input['preview']);
$wordCount = str_word_count($content);
dbnToolsBootCaveau();
try {
if ($preview) {
require_once dbnToolsAiPortalRoot() . '/lib/ai/TextChunker.php';
$chunker = new TextChunker();
$chunks = $chunker->chunk($content);
$sample = array_slice($chunks, 0, 8);
dbnToolsRespond([
'ok' => true,
'preview' => true,
'word_count' => $wordCount,
'chunks' => array_map(fn($c) => [
'section_title' => (string)($c['section_title'] ?? ''),
'word_count' => (int)str_word_count((string)($c['content'] ?? '')),
'snippet' => mb_substr((string)($c['content'] ?? ''), 0, 240, 'UTF-8'),
], $sample),
'total_chunks' => count($chunks),
]);
}
$db = getDb();
$ins = $db->prepare("
INSERT INTO client_documents
(client_id, corpus_id, title, source_type, content, category, language,
tags, author, import_method, source_tool, word_count, status)
VALUES (?, ?, ?, 'text', ?, ?, ?, ?, ?, ?, ?, ?, 'pending')
");
$ins->execute([
$clientId, $corpusId, $title, $content, $category, $language,
$tagsCsv, $author, $importMethod, $sourceTool, $wordCount,
]);
$docId = (int)$db->lastInsertId();
$rag = new ClientRagPipeline($clientId);
$chunks = $rag->ingestDocument($docId);
dbnToolsRespond([
'ok' => true,
'document_id' => $docId,
'chunks' => (int)$chunks,
'status' => 'ready',
], 201);
} catch (Throwable $e) {
if (isset($docId)) {
try {
$db->prepare("UPDATE client_documents SET status='error', error_message=? WHERE id=?")
->execute([substr($e->getMessage(), 0, 1000), $docId]);
} catch (Throwable $ignored) { /* non-fatal */ }
dbnToolsError(
'Saved to corpus but indexing failed: ' . $e->getMessage(),
500, 'index_failed',
['document_id' => $docId]
);
}
dbnToolsError('Save failed: ' . $e->getMessage(), 500, 'save_failed');
}
+241
View File
@@ -0,0 +1,241 @@
<?php
/**
* POST /api/dashboard/upload.php
*
* Three input modes:
* - multipart/form-data with `file` field (PDF/DOCX/TXT, <= 8 MB)
* - JSON body { "kind":"text", "title":..., "content":..., "category"?, "tags"?, "author"?, "language"? }
* - JSON body { "kind":"url", "title":..., "url":... } (fetched via ClientUniversalScraper; queued)
*
* For file + text: writes pending row, runs ClientRagPipeline::ingestDocument() synchronously,
* returns { ok, document_id, chunks, status }
* For url: writes pending row, returns immediately with status:'pending' — a separate cron job
* (run_client_one.php on the ai-portal) does the ingest.
*
* If file text extraction yields less than 200 chars, attempts OCR via `tesseract` shell util.
*/
declare(strict_types=1);
require_once dirname(__DIR__, 2) . '/includes/bootstrap.php';
dbnToolsRequireMethod('POST');
dbnToolsRequireAuth();
try {
$tenant = dbnToolsEnsureDashboardTenant();
} catch (DbnToolsHttpException $e) {
dbnToolsError($e->getMessage(), $e->status, $e->errorCode);
}
$clientId = (int)$tenant['client_id'];
$corpusId = (int)$tenant['corpus_id'];
dbnToolsBootCaveau();
$db = getDb();
$contentType = (string)($_SERVER['CONTENT_TYPE'] ?? '');
$isMultipart = stripos($contentType, 'multipart/form-data') === 0;
try {
if ($isMultipart) {
$result = handleFileUpload($db, $clientId, $corpusId);
} else {
$input = dbnToolsJsonInput(2_500_000);
$kind = (string)($input['kind'] ?? 'text');
$result = match ($kind) {
'text' => handleTextPaste($db, $clientId, $corpusId, $input),
'url' => handleUrlImport($db, $clientId, $corpusId, $input),
default => dbnToolsError('Unknown kind: ' . $kind, 400, 'unknown_kind'),
};
}
} catch (DbnToolsHttpException $e) {
dbnToolsError($e->getMessage(), $e->status, $e->errorCode, $e->extra);
} catch (Throwable $e) {
dbnToolsError('Upload failed: ' . $e->getMessage(), 500, 'upload_failed');
}
dbnToolsRespond($result, 201);
function handleFileUpload(PDO $db, int $clientId, int $corpusId): array
{
if (empty($_FILES['file'])) {
dbnToolsError('No file uploaded.', 400, 'missing_file');
}
$extract = dbnToolsExtractUploadedFile($_FILES['file']);
$text = (string)$extract['text'];
$filename = (string)$extract['filename'];
$ext = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
$sourceType = match ($ext) {
'pdf' => 'pdf',
'docx' => 'docx',
default => 'text',
};
if (mb_strlen($text, 'UTF-8') < 200 && $ext === 'pdf') {
$ocrText = tryOcrPdf((string)($_FILES['file']['tmp_name'] ?? ''));
if ($ocrText !== null && mb_strlen($ocrText, 'UTF-8') > mb_strlen($text, 'UTF-8')) {
$text = $ocrText;
$importMethod = 'ocr_scan';
}
}
$importMethod = $importMethod ?? 'dbn_upload';
$title = trim((string)($_POST['title'] ?? '')) ?: pathinfo($filename, PATHINFO_FILENAME);
$category = sanitizeCategory((string)($_POST['category'] ?? 'uncategorized'));
$tags = sanitizeTagsCsv((string)($_POST['tags'] ?? ''));
$author = trim((string)($_POST['author'] ?? '')) ?: null;
$language = trim((string)($_POST['language'] ?? 'no')) ?: 'no';
return persistAndIngest($db, $clientId, $corpusId, [
'title' => $title,
'source_type' => $sourceType,
'content' => $text,
'category' => $category,
'tags' => $tags,
'author' => $author,
'language' => $language,
'import_method' => $importMethod,
'original_filename' => $filename,
'file_size_bytes' => (int)($_FILES['file']['size'] ?? 0),
'source_tool' => 'dashboard-upload',
]);
}
function handleTextPaste(PDO $db, int $clientId, int $corpusId, array $input): array
{
$title = trim((string)($input['title'] ?? ''));
$content = trim((string)($input['content'] ?? ''));
if ($title === '') dbnToolsError('title is required.', 400, 'missing_title');
if (mb_strlen($content, 'UTF-8') < 30) dbnToolsError('content too short (min 30 chars).', 400, 'content_too_short');
if (mb_strlen($content, 'UTF-8') > 2_000_000) dbnToolsError('content exceeds 2 MB.', 400, 'content_too_large');
return persistAndIngest($db, $clientId, $corpusId, [
'title' => $title,
'source_type' => 'text',
'content' => $content,
'category' => sanitizeCategory((string)($input['category'] ?? 'uncategorized')),
'tags' => sanitizeTagsCsv((string)($input['tags'] ?? '')),
'author' => trim((string)($input['author'] ?? '')) ?: null,
'language' => trim((string)($input['language'] ?? 'no')) ?: 'no',
'import_method' => 'manual',
'source_tool' => 'dashboard-paste',
]);
}
function handleUrlImport(PDO $db, int $clientId, int $corpusId, array $input): array
{
$url = trim((string)($input['url'] ?? ''));
$title = trim((string)($input['title'] ?? ''));
if ($url === '' || !filter_var($url, FILTER_VALIDATE_URL, FILTER_FLAG_SCHEME_REQUIRED)) {
dbnToolsError('Valid URL is required.', 400, 'invalid_url');
}
$scheme = strtolower((string)parse_url($url, PHP_URL_SCHEME));
if (!in_array($scheme, ['http', 'https'], true)) {
dbnToolsError('URL must use http or https.', 400, 'invalid_scheme');
}
if ($title === '') $title = $url;
$stmt = $db->prepare("
INSERT INTO client_documents
(client_id, corpus_id, title, source_type, source_url, content,
category, tags, language, import_method, source_tool, status)
VALUES (?, ?, ?, 'url', ?, '', ?, ?, ?, 'url', 'dashboard-url', 'pending')
");
$stmt->execute([
$clientId, $corpusId, $title, $url,
sanitizeCategory((string)($input['category'] ?? 'uncategorized')),
sanitizeTagsCsv((string)($input['tags'] ?? '')),
trim((string)($input['language'] ?? 'no')) ?: 'no',
]);
return [
'ok' => true,
'document_id' => (int)$db->lastInsertId(),
'status' => 'pending',
'chunks' => 0,
'note' => 'URL queued for background ingest.',
];
}
function persistAndIngest(PDO $db, int $clientId, int $corpusId, array $doc): array
{
$wordCount = str_word_count($doc['content']);
$stmt = $db->prepare("
INSERT INTO client_documents
(client_id, corpus_id, title, source_type, original_filename, file_size_bytes,
content, category, tags, author, language,
import_method, source_tool, word_count, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending')
");
$stmt->execute([
$clientId,
$corpusId,
$doc['title'],
$doc['source_type'],
$doc['original_filename'] ?? null,
$doc['file_size_bytes'] ?? 0,
$doc['content'],
$doc['category'],
$doc['tags'],
$doc['author'] ?? null,
$doc['language'],
$doc['import_method'],
$doc['source_tool'],
$wordCount,
]);
$docId = (int)$db->lastInsertId();
try {
$rag = new ClientRagPipeline($clientId);
$chunks = $rag->ingestDocument($docId);
return [
'ok' => true,
'document_id' => $docId,
'chunks' => (int)$chunks,
'status' => 'ready',
'word_count' => $wordCount,
];
} catch (Throwable $e) {
$db->prepare("UPDATE client_documents SET status='error', error_message=? WHERE id=?")
->execute([substr($e->getMessage(), 0, 1000), $docId]);
return [
'ok' => false,
'document_id' => $docId,
'status' => 'error',
'error' => ['code' => 'index_failed', 'message' => 'Saved, but indexing failed: ' . $e->getMessage()],
];
}
}
function sanitizeCategory(string $cat): string
{
$cat = strtolower(trim($cat));
$cat = preg_replace('/[^a-z0-9\-_]/', '', $cat) ?: 'uncategorized';
return substr($cat, 0, 50);
}
function sanitizeTagsCsv(string $raw): string
{
$tags = array_filter(array_map('trim', explode(',', $raw)));
$tags = array_values(array_slice(array_map(fn($t) => substr($t, 0, 32), $tags), 0, 20));
return implode(',', $tags);
}
function tryOcrPdf(string $tmpPath): ?string
{
if ($tmpPath === '' || !is_readable($tmpPath)) return null;
if (!function_exists('shell_exec')) return null;
$check = @shell_exec('command -v tesseract 2>/dev/null');
if (!$check) return null;
$out = trim((string)@shell_exec(
'pdftoppm -r 200 ' . escapeshellarg($tmpPath) . ' - -png 2>/dev/null | '
. 'tesseract -l nor+eng stdin stdout 2>/dev/null'
));
return $out !== '' ? $out : null;
}
+173
View File
@@ -0,0 +1,173 @@
/* dashboard.css — Norwegian-legal twin of CaveauAI for tools.dobetternorge.no/dashboard/
*
* Pairs with tools.css design tokens (--dbn-paper, --dbn-blue, --dbn-red, --dbn-line).
* Keeps brand: paper + navy + burgundy. Adds gold accent for graph edges.
*/
:root {
--dash-gold: #b88a2c;
--dash-radius: 12px;
--dash-radius-sm: 8px;
--dash-shadow: 0 1px 0 rgba(22, 19, 15, 0.04), 0 12px 32px -22px rgba(0, 32, 91, 0.18);
--dash-row-hover: rgba(0, 32, 91, 0.05);
}
body[data-dashboard-page] {
background: var(--dbn-paper, #f6f2ea);
color: var(--dbn-ink, #16130f);
font-family: "IBM Plex Sans", "Inter", system-ui, sans-serif;
margin: 0;
min-height: 100vh;
}
.dash-shell { max-width: 1320px; margin: 0 auto; padding: 1.25rem clamp(1rem, 4vw, 2.25rem) 4rem; }
/* ── Topbar ───────────────────────────────────────────────────────────── */
.dash-topbar {
display: flex; align-items: center; justify-content: space-between;
padding: 0.75rem 0 1.25rem;
border-bottom: 1px solid var(--dbn-line, rgba(22, 19, 15, 0.16));
}
.dash-brand { display: flex; gap: 0.75rem; align-items: center; text-decoration: none; color: inherit; }
.dash-brand__mark {
width: 38px; height: 38px; border-radius: 10px;
background: var(--dbn-blue, #00205b); color: #fff;
display: grid; place-items: center; font-size: 1.1rem;
}
.dash-brand__text strong { font-family: "Crimson Pro", "Georgia", serif; font-size: 1.15rem; display: block; }
.dash-brand__text small { color: rgba(22, 19, 15, 0.55); font-size: 0.75rem; letter-spacing: 0.05em; text-transform: uppercase; }
.dash-topbar__link { color: var(--dbn-blue, #00205b); text-decoration: none; font-size: 0.9rem; }
.dash-topbar__link:hover { text-decoration: underline; }
/* ── Layout (sidebar + main) ──────────────────────────────────────────── */
.dash-layout { display: grid; grid-template-columns: 220px 1fr; gap: 1.75rem; padding-top: 1.5rem; }
@media (max-width: 880px) { .dash-layout { grid-template-columns: 1fr; } }
.dash-sidebar { display: flex; flex-direction: column; gap: 0.25rem; }
.dash-sidebar__item {
display: block; padding: 0.7rem 0.85rem; border-radius: var(--dash-radius-sm);
text-decoration: none; color: var(--dbn-ink); transition: background 120ms;
border: 1px solid transparent;
}
.dash-sidebar__item:hover { background: var(--dash-row-hover); }
.dash-sidebar__item.is-active {
background: rgba(186, 12, 47, 0.08);
border-color: rgba(186, 12, 47, 0.18);
}
.dash-sidebar__item strong { display: block; font-size: 0.95rem; font-weight: 600; }
.dash-sidebar__item small { display: block; color: rgba(22, 19, 15, 0.55); font-size: 0.72rem; letter-spacing: 0.04em; text-transform: uppercase; }
/* ── Main column ──────────────────────────────────────────────────────── */
.dash-main { min-width: 0; }
.dash-main__head { margin-bottom: 1.5rem; }
.dash-main__head h1 {
font-family: "Crimson Pro", "Georgia", serif;
font-size: clamp(1.6rem, 2.4vw, 2.1rem);
margin: 0 0 0.35rem;
color: var(--dbn-blue, #00205b);
}
.dash-main__lead { color: rgba(22, 19, 15, 0.7); margin: 0; max-width: 64ch; }
/* ── KPI tiles ────────────────────────────────────────────────────────── */
.dash-kpis { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 1rem; margin-bottom: 2rem; }
.dash-kpi {
background: #fff; padding: 1rem 1.1rem; border-radius: var(--dash-radius);
border: 1px solid var(--dbn-line);
box-shadow: var(--dash-shadow);
}
.dash-kpi__label { font-size: 0.72rem; color: rgba(22, 19, 15, 0.55); text-transform: uppercase; letter-spacing: 0.06em; margin: 0 0 0.4rem; }
.dash-kpi__value { font-family: "Crimson Pro", "Georgia", serif; font-size: 1.85rem; font-weight: 600; color: var(--dbn-blue); margin: 0; line-height: 1; }
.dash-kpi__hint { font-size: 0.78rem; color: rgba(22, 19, 15, 0.55); margin: 0.3rem 0 0; }
/* ── Card ────────────────────────────────────────────────────────────── */
.dash-card {
background: #fff; border-radius: var(--dash-radius); border: 1px solid var(--dbn-line);
box-shadow: var(--dash-shadow); padding: 1.25rem 1.4rem; margin-bottom: 1.5rem;
}
.dash-card__head { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 1rem; gap: 1rem; }
.dash-card__head h2 { font-family: "Crimson Pro", serif; font-size: 1.2rem; margin: 0; color: var(--dbn-blue); }
.dash-card__actions { display: flex; gap: 0.5rem; }
/* ── Buttons ─────────────────────────────────────────────────────────── */
.dash-btn {
display: inline-flex; align-items: center; gap: 0.4rem;
padding: 0.55rem 0.95rem; border-radius: var(--dash-radius-sm);
border: 1px solid var(--dbn-line); background: #fff; color: var(--dbn-ink);
font: inherit; cursor: pointer; text-decoration: none;
transition: border-color 120ms, background 120ms;
}
.dash-btn:hover { border-color: var(--dbn-blue); }
.dash-btn--primary { background: var(--dbn-blue); color: #fff; border-color: var(--dbn-blue); }
.dash-btn--primary:hover { background: #001543; }
.dash-btn--danger { background: #fff; color: var(--dbn-red, #ba0c2f); border-color: rgba(186, 12, 47, 0.4); }
.dash-btn--danger:hover { background: rgba(186, 12, 47, 0.07); }
/* ── Documents table ─────────────────────────────────────────────────── */
.dash-doctable { width: 100%; border-collapse: collapse; font-size: 0.93rem; }
.dash-doctable th, .dash-doctable td { padding: 0.7rem 0.85rem; text-align: left; border-bottom: 1px solid var(--dbn-line); }
.dash-doctable th { font-weight: 600; color: rgba(22, 19, 15, 0.6); font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.04em; }
.dash-doctable tbody tr { cursor: pointer; transition: background 100ms; }
.dash-doctable tbody tr:hover { background: var(--dash-row-hover); }
.dash-doctable__title { font-weight: 600; color: var(--dbn-blue); }
.dash-doctable__meta { color: rgba(22, 19, 15, 0.55); font-size: 0.78rem; }
/* ── Status pills ────────────────────────────────────────────────────── */
.dash-status { display: inline-block; padding: 0.18rem 0.55rem; border-radius: 999px; font-size: 0.72rem; letter-spacing: 0.04em; text-transform: uppercase; }
.dash-status--ready { background: rgba(15, 118, 110, 0.12); color: #0f766e; }
.dash-status--pending { background: rgba(184, 138, 44, 0.16); color: #8b6d18; }
.dash-status--processing { background: rgba(0, 32, 91, 0.10); color: var(--dbn-blue); }
.dash-status--error { background: rgba(186, 12, 47, 0.12); color: var(--dbn-red); }
/* ── Filters bar ─────────────────────────────────────────────────────── */
.dash-filters { display: flex; gap: 0.5rem; flex-wrap: wrap; margin-bottom: 1rem; }
.dash-filters input, .dash-filters select {
padding: 0.5rem 0.7rem; border: 1px solid var(--dbn-line); border-radius: var(--dash-radius-sm);
font: inherit; background: #fff;
}
.dash-filters input { min-width: 280px; }
/* ── Empty / loading states ──────────────────────────────────────────── */
.dash-empty { text-align: center; padding: 3rem 1rem; color: rgba(22, 19, 15, 0.55); }
.dash-empty__icon { font-size: 2.5rem; opacity: 0.4; display: block; margin-bottom: 0.5rem; }
.dash-loading { padding: 2rem 1rem; text-align: center; color: rgba(22, 19, 15, 0.5); font-style: italic; }
.dash-error { padding: 1rem; border-radius: var(--dash-radius-sm); background: rgba(186, 12, 47, 0.07); color: var(--dbn-red); border: 1px solid rgba(186, 12, 47, 0.2); }
/* ── Document view ───────────────────────────────────────────────────── */
.dash-doc-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 1.5rem; margin-bottom: 1rem; }
.dash-doc-head h2 { font-family: "Crimson Pro", serif; font-size: 1.5rem; margin: 0; color: var(--dbn-blue); }
.dash-doc-meta { color: rgba(22, 19, 15, 0.55); font-size: 0.85rem; margin: 0.3rem 0 0; }
.dash-tabs { display: flex; gap: 0.25rem; border-bottom: 1px solid var(--dbn-line); margin-bottom: 1rem; }
.dash-tab {
padding: 0.6rem 1rem; background: none; border: none; cursor: pointer;
color: rgba(22, 19, 15, 0.6); border-bottom: 2px solid transparent; margin-bottom: -1px;
font: inherit;
}
.dash-tab:hover { color: var(--dbn-blue); }
.dash-tab.is-active { color: var(--dbn-blue); border-bottom-color: var(--dbn-red); }
.dash-tab-panel { display: none; }
.dash-tab-panel.is-active { display: block; }
.dash-preview {
background: #fcfaf5; padding: 1.25rem 1.5rem; border-radius: var(--dash-radius-sm);
font-family: "Crimson Pro", serif; line-height: 1.65; white-space: pre-wrap; word-wrap: break-word;
max-height: 60vh; overflow-y: auto;
}
.dash-chunk {
background: #fff; padding: 0.85rem 1rem; border-radius: var(--dash-radius-sm);
border: 1px solid var(--dbn-line); margin-bottom: 0.75rem; font-size: 0.9rem;
}
.dash-chunk__section { font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.05em; color: rgba(22, 19, 15, 0.5); margin: 0 0 0.4rem; }
/* ── Related (graph edges) ───────────────────────────────────────────── */
.dash-related { display: flex; flex-direction: column; gap: 0.5rem; }
.dash-related__edge {
display: flex; align-items: center; gap: 0.75rem; padding: 0.6rem 0.85rem;
border-radius: var(--dash-radius-sm); border: 1px solid var(--dbn-line);
border-left: 3px solid var(--dash-gold);
}
.dash-related__rel { font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--dash-gold); font-weight: 600; min-width: 90px; }
.dash-related__title { flex: 1; color: var(--dbn-blue); font-weight: 500; }
/* ── Pagination ──────────────────────────────────────────────────────── */
.dash-pager { display: flex; justify-content: space-between; align-items: center; margin-top: 1rem; color: rgba(22, 19, 15, 0.55); font-size: 0.85rem; }
.dash-pager__actions { display: flex; gap: 0.5rem; }
+84 -28
View File
@@ -1,31 +1,64 @@
/** /**
* corpus-save.js — "Save to corpus" shared handler for all DBN tool pages. * corpus-save.js — Shared "Save to corpus" handler.
* *
* Buttons that trigger a save must have: * Two trigger paths:
* class="js-save-corpus" *
* data-content-id="<id of element containing the text to save>" * 1. Tool pages — buttons with class="js-save-corpus":
* data-tool="<source_tool slug, e.g. korrespond>" * data-content-id="<id of element with text>"
* data-suggested-title="<pre-filled title string>" (optional) * data-tool="<source_tool slug, e.g. korrespond>"
* data-suggested-title="<pre-filled title>" (optional)
*
* 2. Dashboard chat — page code calls:
* dialog.dataset.pendingContent = "...";
* dialog.dataset.pendingTool = "dashboard-chat"; (optional)
* dialog.showModal();
* Title and tags are populated by the caller before showModal().
*
* Endpoint resolution: if window.DBN_DASHBOARD is present (dashboard pages),
* POST to /api/dashboard/save-from-tool.php; otherwise fall back to the
* legacy /api/save-to-corpus.php so existing tools keep working.
*/ */
(function () { (function () {
'use strict'; 'use strict';
const dlg = document.getElementById('save-corpus-dialog'); const dlg = document.getElementById('save-corpus-dialog');
const form = document.getElementById('save-corpus-form'); const form = document.getElementById('save-corpus-form');
const titleIn = document.getElementById('save-corpus-title'); const titleIn = document.getElementById('save-corpus-title');
const tagsIn = document.getElementById('save-corpus-tags'); const tagsIn = document.getElementById('save-corpus-tags');
const cancelBtn = document.getElementById('save-corpus-cancel'); const cancelBtn = document.getElementById('save-corpus-cancel');
if (!dlg || !form) return; // dialog not present (e.g. not logged in) if (!dlg || !form) return;
cancelBtn?.addEventListener('click', () => dlg.close()); cancelBtn?.addEventListener('click', () => dlg.close());
let _pendingBtn = null; let _pendingBtn = null;
let _pendingContent = '';
let _pendingTool = '';
// Delegated click — catches buttons added dynamically by tool JS function endpoint() {
return window.DBN_DASHBOARD
? '/api/dashboard/save-from-tool.php'
: '/api/save-to-corpus.php';
}
function bodyFor(kind, payload) {
if (window.DBN_DASHBOARD) {
return JSON.stringify({
title: payload.title,
content: payload.content,
source_tool: payload.tool || 'dashboard-save',
tags: payload.tags,
kind,
});
}
return JSON.stringify({
title: payload.title,
content: payload.content,
source_tool: payload.tool || '',
tags: payload.tags,
});
}
// ── Path 1: legacy tool buttons (.js-save-corpus) ─────────────────────
document.addEventListener('click', (e) => { document.addEventListener('click', (e) => {
const btn = e.target.closest('.js-save-corpus'); const btn = e.target.closest('.js-save-corpus');
if (!btn) return; if (!btn) return;
@@ -40,27 +73,29 @@
return; return;
} }
_pendingBtn = btn; _pendingBtn = btn;
_pendingContent = content; dlg.dataset.pendingContent = content;
_pendingTool = btn.dataset.tool ?? ''; dlg.dataset.pendingTool = btn.dataset.tool || '';
dlg.dataset.pendingKind = 'tool_output';
titleIn.value = btn.dataset.suggestedTitle ?? ''; titleIn.value = btn.dataset.suggestedTitle || '';
tagsIn.value = ''; tagsIn.value = '';
dlg.showModal(); dlg.showModal();
titleIn.focus(); titleIn.focus();
titleIn.select(); titleIn.select();
}); });
// Form submit inside dialog // ── Submit dialog (both paths) ────────────────────────────────────────
form.addEventListener('submit', async (e) => { form.addEventListener('submit', async (e) => {
e.preventDefault(); e.preventDefault();
dlg.close(); dlg.close();
const btn = _pendingBtn; const btn = _pendingBtn;
const content = _pendingContent; const content = dlg.dataset.pendingContent || '';
const tool = dlg.dataset.pendingTool || '';
const kind = dlg.dataset.pendingKind || 'tool_output';
const title = titleIn.value.trim(); const title = titleIn.value.trim();
const tags = tagsIn.value.trim(); const tags = tagsIn.value.trim();
const tool = _pendingTool;
if (!title || !content) return; if (!title || !content) return;
@@ -70,25 +105,30 @@
} }
try { try {
const resp = await fetch('api/save-to-corpus.php', { const resp = await fetch(endpoint(), {
method: 'POST', method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, content, source_tool: tool, tags }), body: bodyFor(kind, { title, content, tool, tags }),
}); });
const data = await resp.json().catch(() => ({})); const data = await resp.json().catch(() => ({}));
if (resp.ok && data.ok) { if (resp.ok && data.ok) {
if (btn) { if (btn) {
btn.textContent = '✓ Saved to corpus'; btn.textContent = '✓ Saved to corpus';
btn.classList.add('js-save-corpus--saved'); btn.classList.add('js-save-corpus--saved');
} else {
// Path 2 (no button): show a fleeting toast
showToast('Lagret i korpus — ' + (data.chunks || 0) + ' passasjer');
} }
} else { } else {
const msg = data.error ?? `Error ${resp.status}`; const msg = (data.error && data.error.message) || data.error || ('Error ' + resp.status);
if (btn) { if (btn) {
btn.textContent = 'Save failed'; btn.textContent = 'Save failed';
btn.disabled = false; btn.disabled = false;
btn.title = msg; btn.title = msg;
} else {
showToast('Lagring feilet: ' + msg, true);
} }
console.error('[corpus-save] Save failed:', msg); console.error('[corpus-save] Save failed:', msg);
} }
@@ -96,11 +136,27 @@
if (btn) { if (btn) {
btn.textContent = 'Network error'; btn.textContent = 'Network error';
btn.disabled = false; btn.disabled = false;
} else {
showToast('Nettverksfeil', true);
} }
console.error('[corpus-save] Network error:', err); console.error('[corpus-save] Network error:', err);
} }
_pendingBtn = null; _pendingBtn = null;
_pendingContent = ''; delete dlg.dataset.pendingContent;
delete dlg.dataset.pendingTool;
delete dlg.dataset.pendingKind;
}); });
function showToast(msg, isError) {
const t = document.createElement('div');
t.textContent = msg;
t.style.cssText =
'position:fixed;bottom:1.5rem;left:50%;transform:translateX(-50%);' +
'padding:0.65rem 1rem;border-radius:10px;font:inherit;font-size:0.9rem;' +
'z-index:99999;color:#fff;background:' + (isError ? '#ba0c2f' : '#00205b') + ';' +
'box-shadow:0 8px 24px rgba(0,0,0,0.25);';
document.body.appendChild(t);
setTimeout(() => t.remove(), 3500);
}
}()); }());
+233
View File
@@ -0,0 +1,233 @@
<?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 => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;' }[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'; ?>
+204
View File
@@ -0,0 +1,204 @@
<?php
declare(strict_types=1);
$dashboardPage = 'documents';
$dashboardTitle = 'Dokument';
$dashboardLead = '';
require_once __DIR__ . '/../includes/layout_dashboard.php';
$docId = (int)($_GET['id'] ?? 0);
?>
<div id="docViewRoot" data-doc-id="<?= $docId ?>">
<p class="dash-loading">Laster dokument…</p>
</div>
<script>
(function () {
'use strict';
const root = document.getElementById('docViewRoot');
const docId = parseInt(root.dataset.docId, 10);
const api = window.DBN_DASHBOARD.apiBase;
if (!docId) {
root.innerHTML = '<div class="dash-error">Mangler dokument-id.</div>';
return;
}
function safe(s) { return String(s ?? '').replace(/[&<>"]/g, c => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;' }[c])); }
function fmtDate(s) {
if (!s) return '—';
try { return new Date(s.replace(' ', 'T') + 'Z').toLocaleString('nb-NO', { dateStyle:'medium', timeStyle:'short' }); }
catch (_) { return s; }
}
function fmtNum(n) { return n == null ? '—' : Number(n).toLocaleString('nb-NO'); }
function statusPill(status) {
const cls = { ready:'dash-status--ready', pending:'dash-status--pending', processing:'dash-status--processing', error:'dash-status--error' }[status] || 'dash-status--pending';
const lbl = { ready:'Klar', pending:'Venter', processing:'Behandler', error:'Feil' }[status] || status;
return '<span class="dash-status ' + cls + '">' + lbl + '</span>';
}
fetch(api + '/documents.php?action=get&id=' + docId, { credentials: 'same-origin' })
.then(r => r.json())
.then(data => {
if (!data.ok) throw new Error(data.error?.message || 'Dokument ikke funnet');
render(data.document, data.chunks || []);
})
.catch(err => {
root.innerHTML = '<div class="dash-error">' + safe(err.message)
+ '</div><p><a href="/dashboard/documents.php" class="dash-btn">← Tilbake</a></p>';
});
function render(doc, chunks) {
const html =
'<section class="dash-card">'
+ '<div class="dash-doc-head">'
+ '<div>'
+ '<h2>' + safe(doc.title) + '</h2>'
+ '<p class="dash-doc-meta">' + statusPill(doc.status)
+ ' · ' + fmtNum(doc.word_count) + ' ord'
+ ' · ' + fmtNum(doc.chunk_count) + ' passasjer'
+ ' · lagt til ' + fmtDate(doc.created_at) + '</p>'
+ (doc.tags ? '<p class="dash-doc-meta">Tagger: ' + safe(doc.tags) + '</p>' : '')
+ (doc.source_url ? '<p class="dash-doc-meta"><a href="' + safe(doc.source_url) + '" target="_blank" rel="noopener">Original kilde ↗</a></p>' : '')
+ '</div>'
+ '<div>'
+ '<a href="/dashboard/documents.php" class="dash-btn">← Tilbake</a> '
+ '<button class="dash-btn dash-btn--danger" id="docDelete">Slett</button>'
+ '</div>'
+ '</div>'
+ '<nav class="dash-tabs" role="tablist">'
+ '<button class="dash-tab is-active" data-tab="preview" role="tab">Forhåndsvisning</button>'
+ '<button class="dash-tab" data-tab="chunks" role="tab">Passasjer (' + fmtNum(doc.chunk_count) + ')</button>'
+ '<button class="dash-tab" data-tab="related" role="tab">Relatert</button>'
+ '<button class="dash-tab" data-tab="edit" role="tab">Rediger</button>'
+ '</nav>'
+ '<div class="dash-tab-panel is-active" data-panel="preview">'
+ '<div class="dash-preview">' + safe(doc.content || '(tom)') + '</div>'
+ '</div>'
+ '<div class="dash-tab-panel" data-panel="chunks">'
+ (chunks.length
? chunks.map(c =>
'<article class="dash-chunk">'
+ (c.section_title ? '<p class="dash-chunk__section">' + safe(c.section_title) + '</p>' : '')
+ safe(c.content) + '</article>').join('')
: '<div class="dash-empty">Ingen passasjer indeksert ennå.</div>')
+ '</div>'
+ '<div class="dash-tab-panel" data-panel="related">'
+ '<div class="dash-loading" id="relatedLoading">Laster relaterte autoriteter fra graphen…</div>'
+ '<div class="dash-related" id="relatedList" hidden></div>'
+ '</div>'
+ '<div class="dash-tab-panel" data-panel="edit">'
+ '<form id="docEditForm" style="display:grid; gap:0.85rem; max-width:560px;">'
+ '<label>Tittel<input name="title" value="' + safe(doc.title) + '" style="width:100%;padding:0.5rem;border:1px solid var(--dbn-line);border-radius:8px;"></label>'
+ '<label>Kategori<input name="category" value="' + safe(doc.category || '') + '" style="width:100%;padding:0.5rem;border:1px solid var(--dbn-line);border-radius:8px;"></label>'
+ '<label>Tagger (komma-separert)<input name="tags" value="' + safe(doc.tags || '') + '" style="width:100%;padding:0.5rem;border:1px solid var(--dbn-line);border-radius:8px;"></label>'
+ '<label>Språk<input name="language" value="' + safe(doc.language || 'no') + '" maxlength="10" style="width:120px;padding:0.5rem;border:1px solid var(--dbn-line);border-radius:8px;"></label>'
+ '<label>Forfatter<input name="author" value="' + safe(doc.author || '') + '" style="width:100%;padding:0.5rem;border:1px solid var(--dbn-line);border-radius:8px;"></label>'
+ '<button type="submit" class="dash-btn dash-btn--primary" style="justify-self:start;">Lagre endringer</button>'
+ '<span id="docEditStatus" style="color:rgba(22,19,15,0.6);font-size:0.85rem;"></span>'
+ '</form>'
+ '</div>'
+ '</section>';
root.innerHTML = html;
wireTabs();
wireDelete();
wireEdit();
}
function wireTabs() {
const tabs = root.querySelectorAll('.dash-tab');
const panels = root.querySelectorAll('.dash-tab-panel');
tabs.forEach(t => t.addEventListener('click', () => {
tabs.forEach(x => x.classList.remove('is-active'));
panels.forEach(p => p.classList.remove('is-active'));
t.classList.add('is-active');
const panel = root.querySelector('[data-panel="' + t.dataset.tab + '"]');
if (panel) panel.classList.add('is-active');
if (t.dataset.tab === 'related') loadRelated();
}));
}
let relatedLoaded = false;
function loadRelated() {
if (relatedLoaded) return;
relatedLoaded = true;
const list = document.getElementById('relatedList');
const loading = document.getElementById('relatedLoading');
fetch(api + '/graph.php?action=cites&doc_id=' + docId, { credentials: 'same-origin' })
.then(r => r.json())
.then(data => {
loading.hidden = true;
list.hidden = false;
const items = data.results || [];
if (!items.length) {
list.innerHTML = '<div class="dash-empty">Dokumentet har ingen kjente siteringer i graf-databasen ennå.</div>';
return;
}
list.innerHTML = items.map(it =>
'<div class="dash-related__edge">'
+ '<span class="dash-related__rel">' + safe(it.rel_type || '—') + '</span>'
+ '<span class="dash-related__title">' + safe(it.title || it.ref || 'Ukjent') + '</span>'
+ '</div>').join('');
})
.catch(_ => {
loading.hidden = true;
list.hidden = false;
list.innerHTML = '<div class="dash-empty">Graf-databasen er ikke tilgjengelig akkurat nå.</div>';
});
}
function wireDelete() {
const btn = document.getElementById('docDelete');
if (!btn) return;
btn.addEventListener('click', () => {
if (!confirm('Slette dette dokumentet permanent?')) return;
btn.disabled = true;
fetch(api + '/documents.php?action=delete', {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids: [docId] }),
})
.then(r => r.json())
.then(data => {
if (!data.ok) throw new Error(data.error?.message || 'Slett feilet');
location.href = '/dashboard/documents.php';
})
.catch(err => { alert('Feil: ' + err.message); btn.disabled = false; });
});
}
function wireEdit() {
const form = document.getElementById('docEditForm');
const status = document.getElementById('docEditStatus');
if (!form) return;
form.addEventListener('submit', (e) => {
e.preventDefault();
const payload = { id: docId };
['title', 'category', 'tags', 'language', 'author'].forEach(name => {
const el = form.elements[name];
if (el) payload[name] = el.value;
});
status.textContent = 'Lagrer…';
fetch(api + '/documents.php?action=update', {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
.then(r => r.json())
.then(data => {
if (!data.ok) throw new Error(data.error?.message || 'Lagring feilet');
status.textContent = 'Lagret ' + new Date().toLocaleTimeString('nb-NO');
})
.catch(err => status.textContent = 'Feil: ' + err.message);
});
}
})();
</script>
<?php require_once __DIR__ . '/../includes/layout_dashboard_footer.php'; ?>
+189
View File
@@ -0,0 +1,189 @@
<?php
declare(strict_types=1);
$dashboardPage = 'documents';
$dashboardTitle = 'Dokumenter';
$dashboardLead = 'Alle dokumenter i din private korpus. Klikk for å åpne, eller velg flere for bulk-handlinger.';
require_once __DIR__ . '/../includes/layout_dashboard.php';
?>
<section class="dash-card">
<div class="dash-filters">
<input type="search" id="docFilterQ" placeholder="Søk i titler eller tagger…" autocomplete="off">
<select id="docFilterStatus">
<option value="">Alle statuser</option>
<option value="ready">Klar</option>
<option value="pending">Venter</option>
<option value="processing">Behandler</option>
<option value="error">Feil</option>
</select>
<button id="docBulkDelete" class="dash-btn dash-btn--danger" disabled>Slett valgte</button>
<a href="/dashboard/upload.php" class="dash-btn dash-btn--primary" style="margin-left: auto;">+ Last opp</a>
</div>
<div id="docListWrap"><p class="dash-loading">Laster dokumenter…</p></div>
<div class="dash-pager" id="docPager" hidden>
<span id="docPagerLabel"></span>
<div class="dash-pager__actions">
<button class="dash-btn" id="docPagerPrev">← Forrige</button>
<button class="dash-btn" id="docPagerNext">Neste →</button>
</div>
</div>
</section>
<script>
(function () {
'use strict';
const api = window.DBN_DASHBOARD.apiBase;
const PAGE = 25;
const state = { offset: 0, total: 0, selected: new Set(), q: '', status: '' };
const $wrap = document.getElementById('docListWrap');
const $pager = document.getElementById('docPager');
const $pl = document.getElementById('docPagerLabel');
const $prev = document.getElementById('docPagerPrev');
const $next = document.getElementById('docPagerNext');
const $bulk = document.getElementById('docBulkDelete');
const $fq = document.getElementById('docFilterQ');
const $fs = document.getElementById('docFilterStatus');
function safe(s) { return String(s ?? '').replace(/[&<>"]/g, c => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;' }[c])); }
function fmtDate(s) {
if (!s) return '—';
try { return new Date(s.replace(' ', 'T') + 'Z').toLocaleDateString('nb-NO', { day:'numeric', month:'short', year:'numeric' }); }
catch (_) { return s; }
}
function fmtNum(n) { return n == null ? '—' : Number(n).toLocaleString('nb-NO'); }
function statusPill(status) {
const cls = { ready:'dash-status--ready', pending:'dash-status--pending', processing:'dash-status--processing', error:'dash-status--error' }[status] || 'dash-status--pending';
const lbl = { ready:'Klar', pending:'Venter', processing:'Behandler', error:'Feil' }[status] || status;
return '<span class="dash-status ' + cls + '">' + lbl + '</span>';
}
function load() {
const qs = new URLSearchParams({ action:'list', offset:String(state.offset), limit:String(PAGE) });
if (state.q) qs.set('q', state.q);
if (state.status) qs.set('status', state.status);
$wrap.innerHTML = '<p class="dash-loading">Laster dokumenter…</p>';
fetch(api + '/documents.php?' + qs, { credentials:'same-origin' })
.then(r => r.json())
.then(data => {
if (!data.ok) throw new Error(data.error?.message || 'Failed');
state.total = data.total;
render(data.documents || []);
})
.catch(err => {
$wrap.innerHTML = '<div class="dash-error">' + safe(err.message) + '</div>';
});
}
function render(docs) {
if (!docs.length) {
$wrap.innerHTML = '<div class="dash-empty"><span class="dash-empty__icon">📭</span>'
+ (state.q || state.status ? 'Ingen treff for valgt filter.'
: 'Ingen dokumenter ennå. <a href="/dashboard/upload.php">Last opp ditt første</a>.')
+ '</div>';
$pager.hidden = true;
return;
}
const table = document.createElement('table');
table.className = 'dash-doctable';
table.innerHTML =
'<thead><tr>'
+ '<th style="width:36px;"><input type="checkbox" id="docSelectAll"></th>'
+ '<th>Tittel</th><th>Kategori</th><th>Status</th>'
+ '<th>Passasjer</th><th>Lagt til</th>'
+ '</tr></thead>';
const tbody = document.createElement('tbody');
docs.forEach(doc => {
const tr = document.createElement('tr');
tr.dataset.id = String(doc.id);
tr.innerHTML =
'<td><input type="checkbox" class="doc-check" value="' + doc.id + '"' + (state.selected.has(doc.id) ? ' checked' : '') + '></td>'
+ '<td><span class="dash-doctable__title">' + safe(doc.title) + '</span>'
+ (doc.source_tool ? '<div class="dash-doctable__meta">via ' + safe(doc.source_tool) + (doc.tags ? ' · ' + safe(doc.tags) : '') + '</div>' : (doc.tags ? '<div class="dash-doctable__meta">' + safe(doc.tags) + '</div>' : ''))
+ '</td>'
+ '<td>' + safe(doc.category || '—') + '</td>'
+ '<td>' + statusPill(doc.status) + '</td>'
+ '<td>' + fmtNum(doc.chunk_count) + '</td>'
+ '<td>' + fmtDate(doc.created_at) + '</td>';
tr.addEventListener('click', (e) => {
if (e.target.matches('input[type="checkbox"]')) return;
location.href = '/dashboard/document.php?id=' + doc.id;
});
tbody.appendChild(tr);
});
table.appendChild(tbody);
$wrap.innerHTML = '';
$wrap.appendChild(table);
// Wire selection
const all = document.getElementById('docSelectAll');
all.addEventListener('change', () => {
tbody.querySelectorAll('.doc-check').forEach(c => {
c.checked = all.checked;
const id = parseInt(c.value, 10);
if (all.checked) state.selected.add(id); else state.selected.delete(id);
});
updateBulk();
});
tbody.querySelectorAll('.doc-check').forEach(c => {
c.addEventListener('change', (e) => {
const id = parseInt(e.target.value, 10);
if (e.target.checked) state.selected.add(id); else state.selected.delete(id);
updateBulk();
});
});
const from = state.offset + 1;
const to = Math.min(state.offset + docs.length, state.total);
$pl.textContent = 'Viser ' + from + '' + to + ' av ' + state.total;
$prev.disabled = state.offset === 0;
$next.disabled = state.offset + PAGE >= state.total;
$pager.hidden = false;
}
function updateBulk() {
$bulk.disabled = state.selected.size === 0;
$bulk.textContent = state.selected.size > 0 ? 'Slett valgte (' + state.selected.size + ')' : 'Slett valgte';
}
$prev.addEventListener('click', () => { state.offset = Math.max(0, state.offset - PAGE); load(); });
$next.addEventListener('click', () => { state.offset += PAGE; load(); });
let filterTimer = null;
$fq.addEventListener('input', () => {
clearTimeout(filterTimer);
filterTimer = setTimeout(() => { state.q = $fq.value.trim(); state.offset = 0; load(); }, 250);
});
$fs.addEventListener('change', () => { state.status = $fs.value; state.offset = 0; load(); });
$bulk.addEventListener('click', () => {
if (!state.selected.size) return;
if (!confirm('Slette ' + state.selected.size + ' dokumenter? Dette kan ikke angres.')) return;
const ids = Array.from(state.selected);
$bulk.disabled = true;
fetch(api + '/documents.php?action=delete', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids }),
})
.then(r => r.json())
.then(data => {
if (!data.ok) throw new Error(data.error?.message || 'Slett feilet');
state.selected.clear();
updateBulk();
load();
})
.catch(err => alert('Feil: ' + err.message));
});
load();
})();
</script>
<?php require_once __DIR__ . '/../includes/layout_dashboard_footer.php'; ?>
+128
View File
@@ -0,0 +1,128 @@
<?php
declare(strict_types=1);
$dashboardPage = 'index';
$dashboardTitle = 'Min korpus';
$dashboardLead = 'Privat juridisk kunnskapsbase. Last opp, organiser, spør — alt holdes til din konto.';
require_once __DIR__ . '/../includes/layout_dashboard.php';
?>
<section class="dash-kpis" id="dashKpis" aria-label="Corpus statistics">
<div class="dash-kpi">
<p class="dash-kpi__label">Dokumenter</p>
<p class="dash-kpi__value" data-kpi="documents">—</p>
<p class="dash-kpi__hint">av kvote</p>
</div>
<div class="dash-kpi">
<p class="dash-kpi__label">Passasjer indeksert</p>
<p class="dash-kpi__value" data-kpi="chunks">—</p>
<p class="dash-kpi__hint">søkbare biter</p>
</div>
<div class="dash-kpi">
<p class="dash-kpi__label">Klare</p>
<p class="dash-kpi__value" data-kpi="ready">—</p>
<p class="dash-kpi__hint">av totalt</p>
</div>
<div class="dash-kpi">
<p class="dash-kpi__label">Siste opplasting</p>
<p class="dash-kpi__value" data-kpi="last_upload" style="font-size: 1.05rem;">—</p>
<p class="dash-kpi__hint">dato</p>
</div>
</section>
<section class="dash-card">
<div class="dash-card__head">
<h2>Kom i gang</h2>
</div>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 1rem;">
<a class="dash-btn dash-btn--primary" href="/dashboard/upload.php">📥 Last opp dokumenter</a>
<a class="dash-btn" href="/dashboard/chat.php">💬 Still et juridisk spørsmål</a>
<a class="dash-btn" href="/dashboard/documents.php">📚 Bla gjennom korpus</a>
</div>
</section>
<section class="dash-card">
<div class="dash-card__head">
<h2>Nylig aktivitet</h2>
<div class="dash-card__actions">
<a href="/dashboard/documents.php" class="dash-btn">Se alle →</a>
</div>
</div>
<div id="dashRecent" class="dash-loading">Laster…</div>
</section>
<script>
(function () {
'use strict';
const api = window.DBN_DASHBOARD.apiBase;
function fmtDate(s) {
if (!s) return '—';
try {
return new Date(s.replace(' ', 'T') + 'Z').toLocaleDateString('nb-NO',
{ day: 'numeric', month: 'short', year: 'numeric' });
} catch (_) { return s; }
}
function fmtNum(n) { return n == null ? '—' : Number(n).toLocaleString('nb-NO'); }
function statusPill(status) {
const cls = {
ready: 'dash-status--ready',
pending: 'dash-status--pending',
processing: 'dash-status--processing',
error: 'dash-status--error',
}[status] || 'dash-status--pending';
const label = { ready: 'Klar', pending: 'Venter', processing: 'Behandler', error: 'Feil' }[status] || status;
return '<span class="dash-status ' + cls + '">' + label + '</span>';
}
fetch(api + '/documents.php?action=list&limit=100', { credentials: 'same-origin' })
.then(r => r.json())
.then(data => {
if (!data.ok) throw new Error(data.error?.message || 'Failed');
const docs = data.documents || [];
const total = data.total;
const chunks = docs.reduce((s, d) => s + (d.chunk_count || 0), 0);
const ready = docs.filter(d => d.status === 'ready').length;
const last = docs[0] ? docs[0].created_at : null;
document.querySelector('[data-kpi="documents"]').textContent = fmtNum(total);
document.querySelector('[data-kpi="chunks"]').textContent = fmtNum(chunks);
document.querySelector('[data-kpi="ready"]').textContent = ready + ' / ' + docs.length;
document.querySelector('[data-kpi="last_upload"]').textContent = fmtDate(last);
const recent = document.getElementById('dashRecent');
if (!docs.length) {
recent.className = 'dash-empty';
recent.innerHTML = '<span class="dash-empty__icon">📭</span>Ingen dokumenter ennå. '
+ '<a href="/dashboard/upload.php">Last opp ditt første</a>.';
return;
}
recent.className = '';
recent.innerHTML = '';
const table = document.createElement('table');
table.className = 'dash-doctable';
table.innerHTML = '<thead><tr><th>Tittel</th><th>Status</th><th>Passasjer</th><th>Lagt til</th></tr></thead>';
const tbody = document.createElement('tbody');
docs.slice(0, 8).forEach(doc => {
const tr = document.createElement('tr');
tr.addEventListener('click', () => location.href = '/dashboard/document.php?id=' + doc.id);
const safe = (s) => String(s ?? '').replace(/[&<>"]/g, c => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;' }[c]));
tr.innerHTML =
'<td><span class="dash-doctable__title">' + safe(doc.title) + '</span>'
+ (doc.source_tool ? '<div class="dash-doctable__meta">via ' + safe(doc.source_tool) + '</div>' : '') + '</td>'
+ '<td>' + statusPill(doc.status) + '</td>'
+ '<td>' + fmtNum(doc.chunk_count) + '</td>'
+ '<td>' + fmtDate(doc.created_at) + '</td>';
tbody.appendChild(tr);
});
table.appendChild(tbody);
recent.appendChild(table);
})
.catch(err => {
const recent = document.getElementById('dashRecent');
recent.className = 'dash-error';
recent.textContent = 'Kunne ikke laste: ' + err.message;
});
})();
</script>
<?php require_once __DIR__ . '/../includes/layout_dashboard_footer.php'; ?>
+65
View File
@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
$dashboardPage = 'settings';
$dashboardTitle = 'Innstillinger';
$dashboardLead = 'Innstillinger for ditt private korpus.';
require_once __DIR__ . '/../includes/layout_dashboard.php';
?>
<section class="dash-card">
<div class="dash-card__head">
<h2>Konto</h2>
</div>
<dl style="display:grid; grid-template-columns: max-content 1fr; gap: 0.4rem 1.5rem; font-size:0.92rem;">
<dt style="color:rgba(22,19,15,0.55);">Klient-ID</dt>
<dd><code id="setClientId">—</code></dd>
<dt style="color:rgba(22,19,15,0.55);">Korpus-ID</dt>
<dd><code id="setCorpusId">—</code></dd>
<dt style="color:rgba(22,19,15,0.55);">Bruker-ID</dt>
<dd><code id="setUserId">—</code></dd>
</dl>
</section>
<section class="dash-card">
<div class="dash-card__head">
<h2>RAG-pipeline</h2>
</div>
<dl style="display:grid; grid-template-columns: max-content 1fr; gap: 0.4rem 1.5rem; font-size:0.92rem;">
<dt style="color:rgba(22,19,15,0.55);">Chunking</dt>
<dd>600 ord pr. passasje, 75 ords overlapp, heading-aware</dd>
<dt style="color:rgba(22,19,15,0.55);">Embedding-modell</dt>
<dd><code>nomic-embed-text</code> (768-dim) via LiteLLM på Colin</dd>
<dt style="color:rgba(22,19,15,0.55);">Vector DB</dt>
<dd><code>bnl_client_chunks</code> i Qdrant (Colin Docker)</dd>
<dt style="color:rgba(22,19,15,0.55);">Søkemetode</dt>
<dd>Hybrid (vector + keyword), reciprocal rank fusion, private boost 1.5×</dd>
<dt style="color:rgba(22,19,15,0.55);">Graf-database</dt>
<dd><code>bnl_legal</code> i FalkorDB (Colin) — siterings-edges</dd>
</dl>
</section>
<section class="dash-card">
<div class="dash-card__head">
<h2>Personvern</h2>
</div>
<p style="margin-top:0; max-width:64ch; line-height:1.6;">
Alt du laster opp eller lagrer her holdes til din konto. Andre brukere kan ikke se eller søke i dine dokumenter.
Felles-pakken <code>family-legal</code> (~220K passasjer av norsk lovverk og rettspraksis) er delt og brukes
for å berike svar med autoritative kilder, men du eier alt du selv legger inn.
</p>
<p style="max-width:64ch; line-height:1.6;">
Slett enkelt-dokumenter fra <a href="/dashboard/documents.php">Dokumenter</a>. Trenger du å slette hele
kontoen, kontakt support.
</p>
</section>
<script>
(function () {
'use strict';
const d = window.DBN_DASHBOARD || {};
document.getElementById('setClientId').textContent = d.clientId || '—';
document.getElementById('setCorpusId').textContent = d.corpusId || '—';
document.getElementById('setUserId').textContent = d.clientUserId || '—';
})();
</script>
<?php require_once __DIR__ . '/../includes/layout_dashboard_footer.php'; ?>
+221
View File
@@ -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 => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;' }[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'; ?>
+249
View File
@@ -0,0 +1,249 @@
<?php
declare(strict_types=1);
/**
* CorpusProvision — idempotent per-SSO-user tenant provisioning for /dashboard/.
*
* The dashboard treats every SSO user as their own CaveauAI client tenant so
* that the existing client_id-based row filtering is enough to keep one user's
* documents from leaking to another. Provisioning is lazy: the first time the
* SSO user hits the dashboard, we create:
*
* - one row in `clients` (slug `dbn-user-<sso_uid>`, plan `sandbox`,
* dbn_sso_uid = sso_uid, fresh api_key hash)
* - one row in `client_users` (role owner, random password hash — login is
* always via SSO bridge, never password)
* - one row in `client_corpora` (slug `default`, is_default=1)
*
* CaveauAI sessions (which already have client_id + client_user_id set on the
* tools session) are returned as-is without touching the DB.
*
* Output shape:
* [
* 'client_id' => int,
* 'client_user_id' => int,
* 'corpus_id' => int,
* 'created' => bool, // true the first time, false on every call after
* ]
*/
final class CorpusProvision
{
public static function ensureForSsoUser(int $ssoUid, string $email, string $displayName): array
{
if ($ssoUid <= 0) {
throw new DbnToolsHttpException('SSO user id is required.', 400, 'missing_sso_uid');
}
if ($email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
throw new DbnToolsHttpException('A valid email is required.', 400, 'invalid_email');
}
dbnToolsBootCaveau();
$db = getDb();
$existing = self::lookupBySso($db, $ssoUid);
if ($existing !== null) {
$corpusId = self::ensureDefaultCorpus($db, $existing['client_id'], $email);
return [
'client_id' => $existing['client_id'],
'client_user_id' => $existing['client_user_id'],
'corpus_id' => $corpusId,
'created' => false,
];
}
$db->beginTransaction();
try {
$clientId = self::createClient($db, $ssoUid, $email, $displayName);
$clientUserId = self::createOwnerUser($db, $clientId, $email, $displayName);
$corpusId = self::ensureDefaultCorpus($db, $clientId, $email);
self::subscribeIncludedPackages($db, $clientId);
$db->commit();
} catch (Throwable $e) {
$db->rollBack();
throw new DbnToolsHttpException(
'Could not provision dashboard tenant: ' . $e->getMessage(),
500,
'provision_failed'
);
}
return [
'client_id' => $clientId,
'client_user_id' => $clientUserId,
'corpus_id' => $corpusId,
'created' => true,
];
}
public static function ensureForCaveauSession(int $clientId, string $email): array
{
if ($clientId <= 0) {
throw new DbnToolsHttpException('Caveau client_id is required.', 400, 'missing_client_id');
}
dbnToolsBootCaveau();
$db = getDb();
$corpusId = self::ensureDefaultCorpus($db, $clientId, $email);
return [
'client_id' => $clientId,
'client_user_id' => 0,
'corpus_id' => $corpusId,
'created' => false,
];
}
private static function lookupBySso(PDO $db, int $ssoUid): ?array
{
$stmt = $db->prepare('SELECT id FROM clients WHERE dbn_sso_uid = ? LIMIT 1');
$stmt->execute([$ssoUid]);
$clientId = (int)($stmt->fetchColumn() ?: 0);
if ($clientId === 0) {
return null;
}
$stmt = $db->prepare(
"SELECT id FROM client_users
WHERE client_id = ? AND role = 'owner' AND is_active = 1
ORDER BY id ASC LIMIT 1"
);
$stmt->execute([$clientId]);
$userId = (int)($stmt->fetchColumn() ?: 0);
return ['client_id' => $clientId, 'client_user_id' => $userId];
}
private static function createClient(PDO $db, int $ssoUid, string $email, string $displayName): int
{
require_once dbnToolsAiPortalRoot() . '/platform/includes/client_auth.php';
$apiKey = generateApiKey();
$slug = self::uniqueSlug($db, 'dbn-user-' . $ssoUid);
$name = $displayName !== '' ? $displayName : ('Dashboard ' . $email);
$stmt = $db->prepare("
INSERT INTO clients (
dbn_sso_uid, slug, name, contact_email, country, timezone,
plan, embedding_tier,
api_key_hash, api_key_prefix,
monthly_query_limit, monthly_document_limit, monthly_user_limit,
is_active, plan_source, subscription_status
) VALUES (
?, ?, ?, ?, 'NO', 'Europe/Oslo',
'sandbox', 'standard',
?, ?,
500, 50, 1,
1, 'signup', 'none'
)
");
$stmt->execute([
$ssoUid,
$slug,
$name,
$email,
$apiKey['hash'],
$apiKey['prefix'],
]);
return (int)$db->lastInsertId();
}
private static function createOwnerUser(PDO $db, int $clientId, string $email, string $displayName): int
{
$stmt = $db->prepare('SELECT id, client_id FROM client_users WHERE email = ? LIMIT 1');
$stmt->execute([$email]);
$existing = $stmt->fetch(PDO::FETCH_ASSOC);
if ($existing && (int)$existing['client_id'] === $clientId) {
return (int)$existing['id'];
}
if ($existing) {
throw new RuntimeException("Email {$email} already belongs to another workspace.");
}
$usernameBase = preg_replace('/[^a-z0-9._-]+/', '', strtolower(strstr($email, '@', true) ?: 'owner'));
$usernameBase = $usernameBase !== '' ? $usernameBase : 'owner';
$username = self::uniqueUsername($db, $usernameBase);
$stmt = $db->prepare("
INSERT INTO client_users
(client_id, username, email, display_name, password_hash, role, is_active)
VALUES (?, ?, ?, ?, ?, 'owner', 1)
");
$stmt->execute([
$clientId,
$username,
$email,
$displayName !== '' ? $displayName : null,
password_hash(bin2hex(random_bytes(16)), PASSWORD_DEFAULT),
]);
return (int)$db->lastInsertId();
}
private static function ensureDefaultCorpus(PDO $db, int $clientId, string $email): int
{
$stmt = $db->prepare(
'SELECT id FROM client_corpora
WHERE client_id = ? AND is_default = 1
ORDER BY id ASC LIMIT 1'
);
$stmt->execute([$clientId]);
$id = (int)($stmt->fetchColumn() ?: 0);
if ($id > 0) {
return $id;
}
$stmt = $db->prepare("
INSERT INTO client_corpora (client_id, name, slug, description, is_default)
VALUES (?, ?, 'default', ?, 1)
");
$stmt->execute([
$clientId,
'Min korpus',
'Default personal corpus for ' . $email,
]);
return (int)$db->lastInsertId();
}
private static function subscribeIncludedPackages(PDO $db, int $clientId): void
{
$packageSlug = dbnToolsRequiredPackageSlug();
$stmt = $db->prepare('SELECT id FROM corpus_packages WHERE slug = ? AND is_active = 1 LIMIT 1');
$stmt->execute([$packageSlug]);
$packageId = (int)($stmt->fetchColumn() ?: 0);
if ($packageId === 0) {
return;
}
$db->prepare(
"INSERT IGNORE INTO client_corpus_subscriptions
(client_id, package_id, is_active, source, subscribed_at)
VALUES (?, ?, 1, 'dbn_dashboard', NOW())"
)->execute([$clientId, $packageId]);
}
private static function uniqueSlug(PDO $db, string $base): string
{
$base = preg_replace('/[^a-z0-9-]+/', '-', strtolower($base)) ?: 'dbn-user';
$base = trim($base, '-');
$stmt = $db->prepare('SELECT id FROM clients WHERE slug = ? LIMIT 1');
$slug = $base;
$suffix = 2;
while (true) {
$stmt->execute([$slug]);
if (!$stmt->fetch(PDO::FETCH_ASSOC)) {
return $slug;
}
$slug = $base . '-' . $suffix;
$suffix++;
}
}
private static function uniqueUsername(PDO $db, string $base): string
{
$stmt = $db->prepare('SELECT id FROM client_users WHERE username = ? LIMIT 1');
$username = $base;
$suffix = 2;
while (true) {
$stmt->execute([$username]);
if (!$stmt->fetch(PDO::FETCH_ASSOC)) {
return $username;
}
$username = $base . $suffix;
$suffix++;
}
}
}
+44
View File
@@ -365,6 +365,50 @@ function dbnToolsBootCaveau(): void
$booted = true; $booted = true;
} }
/**
* Resolve (or lazily provision) the dashboard tenant for the current session.
*
* - CaveauAI sessions return the existing client_id/client_user_id and ensure a
* default corpus exists.
* - SSO sessions promote the user to their own CaveauAI client tenant on first
* call (via CorpusProvision), then cache the result on the session.
*
* Returns ['client_id', 'client_user_id', 'corpus_id', 'created'].
* Throws DbnToolsHttpException on auth/provisioning failure.
*/
function dbnToolsEnsureDashboardTenant(): array
{
if (!dbnToolsIsAuthenticated()) {
throw new DbnToolsHttpException('Dashboard requires an authenticated session.', 401, 'session_required');
}
$cached = $_SESSION['dbn_tools_dashboard_tenant'] ?? null;
if (is_array($cached) && !empty($cached['client_id']) && !empty($cached['corpus_id'])) {
return $cached + ['created' => false];
}
require_once __DIR__ . '/CorpusProvision.php';
if (dbnToolsIsFreeTier()) {
$ssoUid = (int)($_SESSION['dbn_tools_sso_uid'] ?? 0);
$email = (string)($_SESSION['dbn_tools_sso_email'] ?? $_SESSION['dbn_tools_user_email'] ?? '');
$displayName = (string)($_SESSION['dbn_tools_sso_name'] ?? $_SESSION['dbn_tools_user_name'] ?? '');
$tenant = CorpusProvision::ensureForSsoUser($ssoUid, $email, $displayName);
} else {
$clientId = (int)($_SESSION['dbn_tools_client_id'] ?? 0);
$email = (string)($_SESSION['dbn_tools_user_email'] ?? '');
$tenant = CorpusProvision::ensureForCaveauSession($clientId, $email);
$tenant['client_user_id'] = (int)($_SESSION['dbn_tools_user_id'] ?? $tenant['client_user_id']);
}
$_SESSION['dbn_tools_dashboard_tenant'] = [
'client_id' => (int)$tenant['client_id'],
'client_user_id' => (int)$tenant['client_user_id'],
'corpus_id' => (int)$tenant['corpus_id'],
];
return $tenant;
}
function dbnToolsDb(): PDO function dbnToolsDb(): PDO
{ {
dbnToolsBootCaveau(); dbnToolsBootCaveau();
+1
View File
@@ -67,6 +67,7 @@ window.DBN_FREE_TIER_BALANCE = <?= $layoutFreeTierBalance ?>;
<a href="<?= htmlspecialchars($langPath . '?lang=' . $langCode) ?>" class="<?= $langCode === $uiLang ? 'is-active' : '' ?>"><?= htmlspecialchars(dbnToolsLanguageLabel($langCode)) ?></a> <a href="<?= htmlspecialchars($langPath . '?lang=' . $langCode) ?>" class="<?= $langCode === $uiLang ? 'is-active' : '' ?>"><?= htmlspecialchars(dbnToolsLanguageLabel($langCode)) ?></a>
<?php endforeach; ?> <?php endforeach; ?>
</nav> </nav>
<a href="/dashboard/" class="secondary-button" style="text-decoration:none;">📚 Min korpus</a>
<span id="healthPill" class="status-pill"><?= htmlspecialchars(dbnToolsT('session_active', $uiLang)) ?></span> <span id="healthPill" class="status-pill"><?= htmlspecialchars(dbnToolsT('session_active', $uiLang)) ?></span>
<button id="healthButton" class="secondary-button" type="button"><?= htmlspecialchars(dbnToolsT('health', $uiLang)) ?></button> <button id="healthButton" class="secondary-button" type="button"><?= htmlspecialchars(dbnToolsT('health', $uiLang)) ?></button>
</div> </div>
+102
View File
@@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
/**
* Dashboard chrome (minimal). Used by /dashboard/* pages.
*
* Page contract:
* $dashboardPage string — slug for active-state ('index'|'documents'|'document'|'upload'|'chat'|'settings')
* $dashboardTitle string — H1 for the content area
* $dashboardLead string? — optional sub-title sentence
* $extraScripts string[]?— optional extra script srcs (defer-loaded)
*
* Lazy-provisions the tenant on first hit; exposes ids to JS as window.DBN_DASHBOARD.
*/
require_once __DIR__ . '/bootstrap.php';
if (!dbnToolsIsAuthenticated()) {
$return = urlencode($_SERVER['REQUEST_URI'] ?? '/dashboard/');
header('Location: /?return=' . $return);
exit;
}
try {
$dashboardTenant = dbnToolsEnsureDashboardTenant();
} catch (DbnToolsHttpException $e) {
http_response_code($e->status);
echo '<!doctype html><meta charset="utf-8"><title>Dashboard unavailable</title>'
. '<p style="font-family:sans-serif;max-width:540px;margin:4rem auto;">'
. htmlspecialchars($e->getMessage())
. ' <a href="/dashboard/">Try again</a></p>';
exit;
}
$uiLang = dbnToolsCurrentLanguage();
$dashboardPage = $dashboardPage ?? 'index';
$dashboardTitle = $dashboardTitle ?? 'Dashboard';
$dashboardLead = $dashboardLead ?? '';
$langPath = strtok((string)($_SERVER['REQUEST_URI'] ?? '/dashboard/'), '?') ?: '/dashboard/';
$dashboardNav = [
'index' => ['url' => '/dashboard/', 'label' => 'Oversikt', 'sub' => 'Overview'],
'documents' => ['url' => '/dashboard/documents.php', 'label' => 'Dokumenter', 'sub' => 'Documents'],
'upload' => ['url' => '/dashboard/upload.php', 'label' => 'Last opp', 'sub' => 'Upload'],
'chat' => ['url' => '/dashboard/chat.php', 'label' => 'Spør', 'sub' => 'Ask'],
'settings' => ['url' => '/dashboard/settings.php', 'label' => 'Innstillinger', 'sub' => 'Settings'],
];
?>
<!doctype html>
<html lang="<?= htmlspecialchars($uiLang) ?>">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= htmlspecialchars($dashboardTitle) ?> · Min korpus · Do Better Norge</title>
<link rel="stylesheet" href="/assets/css/tools.css">
<link rel="stylesheet" href="/assets/css/dashboard.css">
</head>
<body data-authenticated="true" data-dashboard-page="<?= htmlspecialchars($dashboardPage) ?>">
<script>
window.DBN_TOOLS_AUTHENTICATED = true;
window.DBN_TOOLS_LANG = <?= json_encode($uiLang, JSON_UNESCAPED_UNICODE) ?>;
window.DBN_DASHBOARD = {
clientId: <?= (int)$dashboardTenant['client_id'] ?>,
clientUserId: <?= (int)$dashboardTenant['client_user_id'] ?>,
corpusId: <?= (int)$dashboardTenant['corpus_id'] ?>,
apiBase: '/api/dashboard'
};
</script>
<div class="dash-shell">
<header class="dash-topbar" role="banner">
<a class="dash-brand" href="/dashboard/">
<span class="dash-brand__mark">⚖</span>
<span class="dash-brand__text">
<strong>Min korpus</strong>
<small>Do Better Norge</small>
</span>
</a>
<nav class="dash-topbar__tools" aria-label="Tools">
<a href="/dashboard.php" class="dash-topbar__link">← Tilbake til verktøy</a>
</nav>
</header>
<div class="dash-layout">
<nav class="dash-sidebar" aria-label="Dashboard sections">
<?php foreach ($dashboardNav as $slug => $item): ?>
<a href="<?= htmlspecialchars($item['url']) ?>"
class="dash-sidebar__item<?= $slug === $dashboardPage ? ' is-active' : '' ?>"
<?= $slug === $dashboardPage ? 'aria-current="page"' : '' ?>>
<strong><?= htmlspecialchars($item['label']) ?></strong>
<small><?= htmlspecialchars($item['sub']) ?></small>
</a>
<?php endforeach; ?>
</nav>
<main class="dash-main" id="dashMain">
<header class="dash-main__head">
<h1><?= htmlspecialchars($dashboardTitle) ?></h1>
<?php if ($dashboardLead !== ''): ?>
<p class="dash-main__lead"><?= htmlspecialchars($dashboardLead) ?></p>
<?php endif; ?>
</header>
<div class="dash-main__body">
+31
View File
@@ -0,0 +1,31 @@
</div><!-- /dash-main__body -->
</main><!-- /dash-main -->
</div><!-- /dash-layout -->
</div><!-- /dash-shell -->
<script src="/assets/js/tools.js" defer></script>
<?php if (!empty($extraScripts) && is_array($extraScripts)): foreach ($extraScripts as $extraScript): ?>
<script src="<?= htmlspecialchars((string)$extraScript) ?>" defer></script>
<?php endforeach; endif; ?>
<script src="/assets/js/corpus-save.js" defer></script>
<dialog id="save-corpus-dialog" class="save-corpus-dialog">
<form method="dialog" id="save-corpus-form">
<h3>Save to corpus</h3>
<p class="save-corpus-hint">This will be indexed and searchable in your private corpus.</p>
<label>
<span>Title <span aria-hidden="true">*</span></span>
<input id="save-corpus-title" type="text" required autocomplete="off">
</label>
<label>
<span>Tags <span class="save-corpus-optional">(comma-separated)</span></span>
<input id="save-corpus-tags" type="text" placeholder="e.g. barnevern, 2024, kjennelse">
</label>
<menu>
<button type="submit" class="btn-primary">Save</button>
<button type="button" id="save-corpus-cancel">Cancel</button>
</menu>
</form>
</dialog>
</body>
</html>