2e2b0b45fa
Rebuild the dashboard as a Drive-style document management system on top of the existing CaveauAI hybrid RAG pipeline. Backend: - 5 migrations (versions, trash soft-delete, saved searches, categories, audit) - DMS helpers (folder ACL walker, disk storage, audit, version snapshot, XLSX/PPTX/HTML/CSV/MD extractors) - New APIs: folders, document-versions, trash, bulk, preview, saved-searches, categories, diagnostics - Extended APIs: documents (folder_id, soft-delete, ACL filter, sort), upload (9 file types, version-collision detection with replace/new/keep-both, disk persistence), chat-stream (folder scoping + graph related-documents) - 30-day trash purge cron with Qdrant + disk + graph cleanup Frontend: - Drive-style two-pane browser with folder tree, drag-drop, bulk-action bar, right-click context menu, multi-select - New pages: folders (tree + per-folder ACL editor), trash (restore/purge) - Extended pages: upload (folder picker, version-collision modal, 9 file type chips), document (Preview/Versions/Permissions tabs with PDF.js + mammoth.js + audio), index (DMS KPIs + activity feed), settings (live diagnostics ping MariaDB/Qdrant/LiteLLM/FalkorDB/disk), chat (folder scope chips + related-authorities chips) - New CSS (dms.css) + JS bundle (dms.js) exposing window.DBN_DMS - Sidebar nav adds Folders + Trash items All routes return HTTP 200 in local smoke test; all 32 files lint clean. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
279 lines
13 KiB
PHP
279 lines
13 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
require_once __DIR__ . '/../includes/bootstrap.php';
|
|
$dashboardPage = 'chat';
|
|
$dashboardTitle = dbnToolsT('dash_title_chat', dbnToolsCurrentLanguage());
|
|
$dashboardLead = dbnToolsT('dash_lead_chat', dbnToolsCurrentLanguage());
|
|
require_once __DIR__ . '/../includes/layout_dashboard.php';
|
|
?>
|
|
<div class="disclaimer" role="note" style="margin-bottom:1rem;">
|
|
<?= htmlspecialchars(dbnToolsT('dash_chat_disclaimer', $uiLang)) ?>
|
|
</div>
|
|
|
|
<section class="dash-card" style="display:flex; flex-direction:column; min-height:60vh;">
|
|
<div class="dms-filters" style="margin-bottom:8px;">
|
|
<label style="font-size:13px;display:inline-flex;gap:6px;align-items:center;">
|
|
📁 <span>Scope:</span>
|
|
<select id="chatFolderScope">
|
|
<option value="all">All folders (whole tenant)</option>
|
|
<option value="unassigned">Unassigned only</option>
|
|
</select>
|
|
</label>
|
|
<label style="font-size:13px;display:inline-flex;align-items:center;gap:4px;">
|
|
<input type="checkbox" id="chatIncludeSub" checked> Include subfolders
|
|
</label>
|
|
<label style="font-size:13px;display:inline-flex;align-items:center;gap:4px;">
|
|
<input type="checkbox" id="chatIncludeRelated" checked> Show related authorities (graph)
|
|
</label>
|
|
</div>
|
|
|
|
<div id="chatLog" class="chat-log" aria-live="polite">
|
|
<div class="chat-empty" id="chatEmptyMsg"></div>
|
|
</div>
|
|
|
|
<form id="chatForm" class="chat-form" autocomplete="off">
|
|
<textarea id="chatInput" rows="2" required placeholder="<?= htmlspecialchars(dbnToolsT('dash_chat_placeholder', $uiLang)) ?>"></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 I18N = window.DBN_I18N || {};
|
|
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 $empty = document.getElementById('chatEmptyMsg');
|
|
|
|
if ($empty) $empty.textContent = I18N.ask_btn
|
|
? I18N.ask_btn.replace(/^💬\s*/, '')
|
|
: 'Ask your first question. It can be about child welfare, family law, ECHR — anything in your corpus.';
|
|
|
|
const history = [];
|
|
|
|
function safe(s) { return String(s ?? '').replace(/[&<>"]/g, c => ({ '&':'&','<':'<','>':'>','"':'"' }[c])); }
|
|
|
|
function clearEmpty() {
|
|
const empty = $log.querySelector('.chat-empty');
|
|
if (empty) empty.remove();
|
|
}
|
|
function appendUser(text) {
|
|
clearEmpty();
|
|
const wrap = document.createElement('div');
|
|
wrap.className = 'chat-msg chat-msg--user';
|
|
wrap.innerHTML = '<div class="chat-bubble">' + safe(text) + '</div>';
|
|
$log.appendChild(wrap);
|
|
$log.scrollTop = $log.scrollHeight;
|
|
}
|
|
function appendAi() {
|
|
const wrap = document.createElement('div');
|
|
wrap.className = 'chat-msg chat-msg--ai';
|
|
wrap.innerHTML =
|
|
'<div class="chat-bubble"><span class="chat-stream"></span><span class="chat-thinking">' + (I18N.chat_thinking || 'thinking…') + '</span></div>'
|
|
+ '<div class="chat-sources" hidden></div>'
|
|
+ '<div class="chat-actions" hidden>'
|
|
+ '<button class="dash-btn chat-save" type="button">' + (I18N.chat_save || '💾 Save to corpus') + '</button>'
|
|
+ '<button class="dash-btn chat-copy" type="button">' + (I18N.chat_copy || '📋 Copy') + '</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 folderScope = document.getElementById('chatFolderScope');
|
|
const includeSub = document.getElementById('chatIncludeSub');
|
|
const includeRel = document.getElementById('chatIncludeRelated');
|
|
const body = { question, history: history.slice(0, -1) };
|
|
if (folderScope && folderScope.value && folderScope.value !== 'all') {
|
|
body.folder_id = folderScope.value;
|
|
body.include_subfolders = includeSub && includeSub.checked;
|
|
}
|
|
if (includeRel && includeRel.checked) body.include_related = true;
|
|
const resp = await fetch(api + '/chat-stream.php', {
|
|
method: 'POST', credentials: 'same-origin',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(body),
|
|
});
|
|
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 });
|
|
|
|
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 || []);
|
|
renderRelated(aiNode, payload.related_documents || []);
|
|
const chunksTmpl = (I18N.chat_passages_meta || '{n} passages').replace('{n}', payload.chunks_used || 0);
|
|
meta.hidden = false;
|
|
meta.textContent = chunksTmpl + ' · ' + (payload.model || 'auto') + ' · ' + (payload.response_time_ms || 0) + ' ms';
|
|
actions.hidden = false;
|
|
wireActions(aiNode, question, answer);
|
|
} else if (evName === 'fail') {
|
|
thinking.textContent = '❌ ' + (payload.message || 'Error');
|
|
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 renderRelated(node, related) {
|
|
if (!related || !related.length) return;
|
|
let rel = node.querySelector('.chat-related');
|
|
if (!rel) {
|
|
rel = document.createElement('div');
|
|
rel.className = 'chat-related';
|
|
rel.style.cssText = 'display:flex;flex-wrap:wrap;gap:0.35rem;margin-top:0.4rem;';
|
|
const sources = node.querySelector('.chat-sources');
|
|
sources.parentNode.insertBefore(rel, sources.nextSibling);
|
|
}
|
|
const label = '<small style="opacity:.6;width:100%;display:block;">↳ Related authorities (graph):</small>';
|
|
rel.innerHTML = label + related.slice(0, 6).map(r =>
|
|
'<a class="chat-source-chip" href="/dashboard/document.php?id=' + r.doc_id + '" style="background:rgba(184,138,44,0.16);color:#6c5212;border-color:rgba(184,138,44,0.4)">'
|
|
+ safe(r.title || ('doc #' + r.doc_id)) + ' · ' + safe(r.shared) + '⋆</a>'
|
|
).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 = I18N.chat_copied || '✓ Copied';
|
|
setTimeout(() => btn.textContent = orig, 1400);
|
|
});
|
|
});
|
|
node.querySelector('.chat-save').addEventListener('click', () => {
|
|
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(I18N.chat_save_unavail || 'Save dialog unavailable.');
|
|
return;
|
|
}
|
|
titleField.value = question.slice(0, 80);
|
|
tagsField.value = 'chat,answer';
|
|
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'; ?>
|