Add My Documents panel to workbench + user-docs API
- api/user-docs.php: GET/DELETE shared dbn_user_docs table (SSO users only) connects to dobetternorge DB via DBN_DB_* env vars - workbench.php: My Documents panel (section 05) for SSO/free-tier users; shows docs uploaded from either AI chat or tools, links to AI Chat for upload - workbench.js: fetch + render doc list, delete with Qdrant cleanup - tools.css: workbench-docs panel + item styles - i18n.php: my_docs_* strings in all 4 languages Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,123 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/user-docs.php — list SSO user's uploaded documents
|
||||||
|
* DELETE /api/user-docs.php?id=X — remove a document
|
||||||
|
*
|
||||||
|
* Only available for SSO users (dbn_tools_sso_uid set in session).
|
||||||
|
* Reads from the shared dobetternorge.dbn_user_docs table, keyed by sso_uid.
|
||||||
|
* Requires DBN_DB_* env vars pointing at the dobetternorge database.
|
||||||
|
*/
|
||||||
|
require_once __DIR__ . '/../includes/bootstrap.php';
|
||||||
|
|
||||||
|
dbnToolsRequireMethod('GET', 'DELETE');
|
||||||
|
|
||||||
|
if (!dbnToolsIsAuthenticated()) {
|
||||||
|
http_response_code(401);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode(['ok' => false, 'error' => 'Not authenticated.']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only SSO users have shared docs
|
||||||
|
$ssoUid = (string)($_SESSION['dbn_tools_sso_uid'] ?? '');
|
||||||
|
if ($ssoUid === '') {
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode(['ok' => true, 'docs' => [], 'reason' => 'sso_only']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|
||||||
|
// ── Connect to the shared dobetternorge DB (dbn_user_docs lives here) ─────────
|
||||||
|
function dbnSharedDb(): ?PDO
|
||||||
|
{
|
||||||
|
static $pdo = null;
|
||||||
|
if ($pdo !== null) return $pdo;
|
||||||
|
$host = dbnToolsEnv('DBN_DB_HOST', dbnToolsEnv('DBNM_DB_HOST', 'localhost'));
|
||||||
|
$name = dbnToolsEnv('DBN_DB_NAME', dbnToolsEnv('DBNM_DB_NAME', 'dobetternorge'));
|
||||||
|
$user = dbnToolsEnv('DBN_DB_USER', dbnToolsEnv('DBNM_DB_USER', 'root'));
|
||||||
|
$pass = dbnToolsEnv('DBN_DB_PASS', dbnToolsEnv('DBNM_DB_PASS', ''));
|
||||||
|
try {
|
||||||
|
$pdo = new PDO("mysql:host={$host};dbname={$name};charset=utf8mb4", $user, $pass, [
|
||||||
|
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||||
|
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||||
|
PDO::ATTR_TIMEOUT => 5,
|
||||||
|
]);
|
||||||
|
return $pdo;
|
||||||
|
} catch (Throwable) { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
$method = $_SERVER['REQUEST_METHOD'];
|
||||||
|
|
||||||
|
// ── DELETE ────────────────────────────────────────────────────────────────────
|
||||||
|
if ($method === 'DELETE') {
|
||||||
|
$docId = trim($_GET['id'] ?? '');
|
||||||
|
if ($docId === '') {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['ok' => false, 'error' => 'Missing id parameter.']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$db = dbnSharedDb();
|
||||||
|
if ($db) {
|
||||||
|
$stmt = $db->prepare('SELECT id FROM dbn_user_docs WHERE id = ? AND user_id = ?');
|
||||||
|
$stmt->execute([$docId, $ssoUid]);
|
||||||
|
if ($stmt->fetch()) {
|
||||||
|
$db->prepare('DELETE FROM dbn_user_docs WHERE id = ? AND user_id = ?')
|
||||||
|
->execute([$docId, $ssoUid]);
|
||||||
|
|
||||||
|
// Delete Qdrant points for this doc
|
||||||
|
$qdrantUrl = 'http://10.0.2.10:6333';
|
||||||
|
$body = [
|
||||||
|
'filter' => [
|
||||||
|
'must' => [
|
||||||
|
['key' => 'doc_id', 'match' => ['value' => $docId]],
|
||||||
|
['key' => 'user_id', 'match' => ['value' => $ssoUid]],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
$ch = curl_init("$qdrantUrl/collections/dbn_user_docs/points/delete");
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_POST => true,
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_TIMEOUT => 10,
|
||||||
|
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
|
||||||
|
CURLOPT_POSTFIELDS => json_encode($body),
|
||||||
|
]);
|
||||||
|
curl_exec($ch);
|
||||||
|
curl_close($ch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
echo json_encode(['ok' => true]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── GET ───────────────────────────────────────────────────────────────────────
|
||||||
|
$db = dbnSharedDb();
|
||||||
|
if (!$db) {
|
||||||
|
echo json_encode(['ok' => true, 'docs' => []]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $db->prepare(
|
||||||
|
'SELECT id, filename, file_type, chunk_count, source, created_at
|
||||||
|
FROM dbn_user_docs
|
||||||
|
WHERE user_id = ? AND status = ?
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 50'
|
||||||
|
);
|
||||||
|
$stmt->execute([$ssoUid, 'ready']);
|
||||||
|
$rows = $stmt->fetchAll();
|
||||||
|
|
||||||
|
$docs = array_map(static fn($r) => [
|
||||||
|
'doc_id' => $r['id'],
|
||||||
|
'filename' => $r['filename'],
|
||||||
|
'file_type' => $r['file_type'],
|
||||||
|
'chunk_count' => (int)$r['chunk_count'],
|
||||||
|
'source' => $r['source'],
|
||||||
|
'created_at' => $r['created_at'],
|
||||||
|
], $rows);
|
||||||
|
|
||||||
|
echo json_encode(['ok' => true, 'docs' => $docs]);
|
||||||
+74
-1
@@ -6196,10 +6196,83 @@ body.lt-landing {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.workbench-panel--flow,
|
.workbench-panel--flow,
|
||||||
.workbench-panel--outputs {
|
.workbench-panel--outputs,
|
||||||
|
.workbench-panel--docs {
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.workbench-docs__desc {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--dbn-text-2, #555);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workbench-docs__list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
min-height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workbench-docs__item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--dbn-line, #e0ddd8);
|
||||||
|
background: rgba(255,255,255,0.9);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workbench-docs__icon { flex-shrink: 0; font-size: 1rem; }
|
||||||
|
|
||||||
|
.workbench-docs__name {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workbench-docs__meta {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--dbn-text-2, #888);
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workbench-docs__remove {
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--dbn-line, #e0ddd8);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 3px 10px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--dbn-text-2, #777);
|
||||||
|
transition: background 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workbench-docs__remove:hover {
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #b91c1c;
|
||||||
|
border-color: #fca5a5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workbench-docs__empty,
|
||||||
|
.workbench-docs__loading {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--dbn-text-2, #888);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workbench-docs__cta {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
.workbench-section-head {
|
.workbench-section-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
|
|||||||
@@ -94,3 +94,80 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}());
|
}());
|
||||||
|
|
||||||
|
// ── My Documents panel ────────────────────────────────────────────────────────
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var panel = document.querySelector('[data-my-docs="true"]');
|
||||||
|
var list = document.getElementById('myDocsList');
|
||||||
|
if (!panel || !list) return;
|
||||||
|
|
||||||
|
var lang = (window.DBN_TOOLS_LANG || 'en');
|
||||||
|
|
||||||
|
var i18n = {
|
||||||
|
empty: { en: 'No documents uploaded yet. Upload PDFs, DOCX, or TXT files in the AI Chat sidebar.', no: 'Ingen dokumenter lastet opp ennå. Last opp PDF, DOCX eller TXT i AI-chattens sidepanel.', uk: 'Документів ще немає. Завантажте файли у бічній панелі AI-чату.', pl: 'Brak dokumentów. Prześlij pliki w panelu bocznym czatu AI.' },
|
||||||
|
remove: { en: 'Remove', no: 'Fjern', uk: 'Видалити', pl: 'Usuń' },
|
||||||
|
ai: { en: 'AI Chat', no: 'AI-chat', uk: 'AI-чат', pl: 'Czat AI' },
|
||||||
|
tools: { en: 'Tools', no: 'Verktøy', uk: 'Інструменти', pl: 'Narzędzia' },
|
||||||
|
error: { en: 'Could not load documents.', no: 'Kunne ikke laste dokumenter.', uk: 'Не вдалося завантажити документи.', pl: 'Nie można załadować dokumentów.' },
|
||||||
|
};
|
||||||
|
|
||||||
|
function t(key) {
|
||||||
|
return (i18n[key] && i18n[key][lang]) || (i18n[key] && i18n[key]['en']) || key;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(str) {
|
||||||
|
if (!str) return '';
|
||||||
|
try { return new Date(str).toLocaleDateString(); } catch (e) { return str; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDocs(docs) {
|
||||||
|
if (!docs.length) {
|
||||||
|
list.innerHTML = '<p class="workbench-docs__empty">' + t('empty') + '</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
list.innerHTML = docs.map(function (doc) {
|
||||||
|
return '<div class="workbench-docs__item" role="listitem" data-doc-id="' + esc(doc.doc_id) + '">'
|
||||||
|
+ '<span class="workbench-docs__icon">📄</span>'
|
||||||
|
+ '<span class="workbench-docs__name" title="' + esc(doc.filename) + '">' + esc(doc.filename) + '</span>'
|
||||||
|
+ '<span class="workbench-docs__meta">' + esc(formatDate(doc.created_at)) + ' · ' + (doc.source === 'ai_chat' ? t('ai') : t('tools')) + '</span>'
|
||||||
|
+ '<button class="workbench-docs__remove" type="button" data-doc-id="' + esc(doc.doc_id) + '" aria-label="' + t('remove') + ' ' + esc(doc.filename) + '">' + t('remove') + '</button>'
|
||||||
|
+ '</div>';
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
list.querySelectorAll('.workbench-docs__remove').forEach(function (btn) {
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
var docId = btn.getAttribute('data-doc-id');
|
||||||
|
if (!docId) return;
|
||||||
|
btn.disabled = true;
|
||||||
|
fetch('/api/user-docs.php?id=' + encodeURIComponent(docId), { method: 'DELETE', credentials: 'include' })
|
||||||
|
.then(function () {
|
||||||
|
var item = list.querySelector('[data-doc-id="' + docId + '"]');
|
||||||
|
if (item) item.remove();
|
||||||
|
if (!list.querySelector('.workbench-docs__item')) {
|
||||||
|
list.innerHTML = '<p class="workbench-docs__empty">' + t('empty') + '</p>';
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function () { btn.disabled = false; });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function esc(str) {
|
||||||
|
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch('/api/user-docs.php', { credentials: 'include' })
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (data) {
|
||||||
|
if (data.ok) {
|
||||||
|
renderDocs(data.docs || []);
|
||||||
|
} else {
|
||||||
|
list.innerHTML = '<p class="workbench-docs__empty">' + t('error') + '</p>';
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
list.innerHTML = '<p class="workbench-docs__empty">' + t('error') + '</p>';
|
||||||
|
});
|
||||||
|
}());
|
||||||
|
|||||||
@@ -127,6 +127,14 @@ function dbnToolsTranslations(): array
|
|||||||
'register_card_note' => 'Free for families navigating Norwegian child-welfare cases.',
|
'register_card_note' => 'Free for families navigating Norwegian child-welfare cases.',
|
||||||
'register_google' => 'Register with Google',
|
'register_google' => 'Register with Google',
|
||||||
'register_email' => 'Register with email',
|
'register_email' => 'Register with email',
|
||||||
|
'my_docs_title' => 'My Documents',
|
||||||
|
'my_docs_desc' => 'Reference documents you uploaded in the AI Chat are available here. Use them across tools or remove them when no longer needed.',
|
||||||
|
'my_docs_upload_cta' => 'Upload documents in AI Chat',
|
||||||
|
'my_docs_empty' => 'No documents uploaded yet. Upload PDFs, DOCX, or TXT files in the AI Chat sidebar.',
|
||||||
|
'my_docs_remove' => 'Remove',
|
||||||
|
'my_docs_source_ai' => 'AI Chat',
|
||||||
|
'my_docs_source_tools' => 'Tools',
|
||||||
|
'loading' => 'Loading…',
|
||||||
],
|
],
|
||||||
'no' => [
|
'no' => [
|
||||||
'meta_title' => 'Do Better Norge - juridiske AI-verktøy',
|
'meta_title' => 'Do Better Norge - juridiske AI-verktøy',
|
||||||
@@ -187,6 +195,14 @@ function dbnToolsTranslations(): array
|
|||||||
'register_card_note' => 'Gratis for familier i norske barnevernssaker.',
|
'register_card_note' => 'Gratis for familier i norske barnevernssaker.',
|
||||||
'register_google' => 'Registrer med Google',
|
'register_google' => 'Registrer med Google',
|
||||||
'register_email' => 'Registrer med e-post',
|
'register_email' => 'Registrer med e-post',
|
||||||
|
'my_docs_title' => 'Mine dokumenter',
|
||||||
|
'my_docs_desc' => 'Referansedokumenter du lastet opp i AI-chatten er tilgjengelige her. Bruk dem på tvers av verktøy eller slett dem når de ikke lenger trengs.',
|
||||||
|
'my_docs_upload_cta' => 'Last opp dokumenter i AI-chat',
|
||||||
|
'my_docs_empty' => 'Ingen dokumenter lastet opp ennå. Last opp PDF, DOCX eller TXT i AI-chattens sidepanel.',
|
||||||
|
'my_docs_remove' => 'Fjern',
|
||||||
|
'my_docs_source_ai' => 'AI-chat',
|
||||||
|
'my_docs_source_tools' => 'Verktøy',
|
||||||
|
'loading' => 'Laster…',
|
||||||
],
|
],
|
||||||
'uk' => [
|
'uk' => [
|
||||||
'meta_title' => 'Do Better Norge - юридичні AI інструменти',
|
'meta_title' => 'Do Better Norge - юридичні AI інструменти',
|
||||||
@@ -247,6 +263,14 @@ function dbnToolsTranslations(): array
|
|||||||
'register_card_note' => 'Безкоштовно для сімей у норвезьких справах із захисту дітей.',
|
'register_card_note' => 'Безкоштовно для сімей у норвезьких справах із захисту дітей.',
|
||||||
'register_google' => 'Зареєструватися через Google',
|
'register_google' => 'Зареєструватися через Google',
|
||||||
'register_email' => 'Зареєструватися через email',
|
'register_email' => 'Зареєструватися через email',
|
||||||
|
'my_docs_title' => 'Мої документи',
|
||||||
|
'my_docs_desc' => 'Довідкові документи, завантажені в AI-чаті, доступні тут.',
|
||||||
|
'my_docs_upload_cta' => 'Завантажити документи в AI-чаті',
|
||||||
|
'my_docs_empty' => 'Документів ще немає. Завантажте PDF, DOCX або TXT у бічній панелі AI-чату.',
|
||||||
|
'my_docs_remove' => 'Видалити',
|
||||||
|
'my_docs_source_ai' => 'AI-чат',
|
||||||
|
'my_docs_source_tools' => 'Інструменти',
|
||||||
|
'loading' => 'Завантаження…',
|
||||||
],
|
],
|
||||||
'pl' => [
|
'pl' => [
|
||||||
'meta_title' => 'Do Better Norge - prawne narzędzia AI',
|
'meta_title' => 'Do Better Norge - prawne narzędzia AI',
|
||||||
@@ -307,6 +331,14 @@ function dbnToolsTranslations(): array
|
|||||||
'register_card_note' => 'Bezpłatnie dla rodzin w norweskich sprawach dotyczących ochrony dzieci.',
|
'register_card_note' => 'Bezpłatnie dla rodzin w norweskich sprawach dotyczących ochrony dzieci.',
|
||||||
'register_google' => 'Zarejestruj przez Google',
|
'register_google' => 'Zarejestruj przez Google',
|
||||||
'register_email' => 'Zarejestruj przez email',
|
'register_email' => 'Zarejestruj przez email',
|
||||||
|
'my_docs_title' => 'Moje dokumenty',
|
||||||
|
'my_docs_desc' => 'Dokumenty referencyjne przesłane w czacie AI są dostępne tutaj.',
|
||||||
|
'my_docs_upload_cta' => 'Prześlij dokumenty w czacie AI',
|
||||||
|
'my_docs_empty' => 'Brak dokumentów. Prześlij pliki PDF, DOCX lub TXT na pasku bocznym czatu AI.',
|
||||||
|
'my_docs_remove' => 'Usuń',
|
||||||
|
'my_docs_source_ai' => 'Czat AI',
|
||||||
|
'my_docs_source_tools' => 'Narzędzia',
|
||||||
|
'loading' => 'Ładowanie…',
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -198,6 +198,24 @@ foreach ($evidenceFields as [$name, $label, $hint]):
|
|||||||
</label>
|
</label>
|
||||||
</section>
|
</section>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<?php if (dbnToolsIsFreeTier()): ?>
|
||||||
|
<section class="workbench-panel workbench-panel--docs" aria-labelledby="workbenchDocsTitle"
|
||||||
|
data-my-docs="true">
|
||||||
|
<div class="workbench-section-head">
|
||||||
|
<p class="workbench-kicker">05</p>
|
||||||
|
<h2 id="workbenchDocsTitle"><?= htmlspecialchars(dbnToolsT('my_docs_title', $uiLang)) ?></h2>
|
||||||
|
</div>
|
||||||
|
<p class="workbench-docs__desc"><?= htmlspecialchars(dbnToolsT('my_docs_desc', $uiLang)) ?></p>
|
||||||
|
<div id="myDocsList" class="workbench-docs__list" role="list" aria-live="polite">
|
||||||
|
<p class="workbench-docs__loading"><?= htmlspecialchars(dbnToolsT('loading', $uiLang)) ?></p>
|
||||||
|
</div>
|
||||||
|
<a class="secondary-button workbench-docs__cta" href="https://ai.dobetternorge.no/" target="_blank" rel="noopener noreferrer">
|
||||||
|
<?= htmlspecialchars(dbnToolsT('my_docs_upload_cta', $uiLang)) ?> ↗
|
||||||
|
</a>
|
||||||
|
</section>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
</main>
|
</main>
|
||||||
<?php require_once __DIR__ . '/includes/footer.php'; ?>
|
<?php require_once __DIR__ . '/includes/footer.php'; ?>
|
||||||
<script src="assets/js/tools.js" defer></script>
|
<script src="assets/js/tools.js" defer></script>
|
||||||
|
|||||||
Reference in New Issue
Block a user