Files
daveadmin d156f8cf6b feat(tools): persona selector across standalone tools + dashboard chat
Wire the legal-domain persona picker into corpus, deep-research, korrespond and
the dashboard chat. Each endpoint reads the chosen profile, resolves its packages
against client 57, and scopes retrieval via package_ids (falling back to family
when omitted). New dashboard tenants now subscribe to all DBN domain packages so
persona switching survives the subscription intersection.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 23:03:31 +02:00

308 lines
14 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>Domain:</span>
<select id="chatPersona" aria-label="Legal domain persona">
<option value="">Default (family law)</option>
</select>
</label>
<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 => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;' }[c])); }
// Populate the legal-domain persona picker (personas.php lives at the site root).
(async function loadPersonas() {
const sel = document.getElementById('chatPersona');
if (!sel) return;
try {
const resp = await fetch('/api/personas.php', { credentials: 'same-origin' });
if (!resp.ok) return;
const data = await resp.json();
const personas = (data && data.personas) || [];
if (!personas.length) return;
sel.innerHTML = '';
personas.forEach(p => {
const opt = document.createElement('option');
opt.value = p.slug;
opt.textContent = p.name;
sel.appendChild(opt);
});
if (data.default_persona) sel.value = data.default_persona;
} catch (_) { /* keep the default option */ }
})();
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 persona = document.getElementById('chatPersona');
const body = { question, history: history.slice(0, -1) };
if (persona && persona.value) body.profile = persona.value;
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'; ?>