feat(dashboard): add corpus dashboard at /dashboard/
Full private corpus dashboard for tools.dobetternorge.no users — each SSO
account gets an auto-provisioned CaveauAI tenant (clients row, corpus) on
first visit. Includes upload (file/paste/URL), RAG chat with SSE streaming
and citation chips, document CRUD, FalkorDB graph relations tab, and
improved save-from-tool flow with tag/preview support.
- dashboard/{index,documents,document,upload,chat,settings}.php
- api/dashboard/{corpus-init,documents,upload,ingest-status,chat-stream,
save-from-tool,graph}.php
- includes/{CorpusProvision,layout_dashboard,layout_dashboard_footer}.php
- assets/css/dashboard.css assets/js/corpus-save.js (routing upgrade)
- includes/{bootstrap,layout}.php extended for dashboard provisioning
Migration 141 (clients.dbn_sso_uid + import_method enum) applied on chloe.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,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;
|
||||
Reference in New Issue
Block a user