Files
dobetternorge-tools/dashboard/chat.php
T
daveadmin a9e64b65ce i18n all /dashboard/ corpus pages for en/no/uk/pl
- Add require_once bootstrap.php to all 6 dashboard page files so
  dbnToolsT() is available before layout_dashboard.php is included
- Add dash_upload_category_lbl key to no/uk/pl sections of i18n.php
  (was only in English); Kategori/Категорія/Kategoria
- Fix broken ternary on upload.php Category label — replace with
  dbnToolsT('dash_upload_category_lbl', $uiLang)
- layout_dashboard.php outputs window.DBN_I18N with all js_* keys
  so dashboard JS reads locale-aware strings from PHP translations

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 20:10:57 +02:00

236 lines
10 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 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])); }
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 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 });
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 || []);
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 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'; ?>