From f383ad5b74e1ff877c6fa4dfd3864a29b38eefc0 Mon Sep 17 00:00:00 2001 From: davegilligan Date: Sat, 23 May 2026 21:38:04 +0200 Subject: [PATCH] feat: document & audio corpus picker for all tools - Add "Select from My Docs" button to all text tool forms; free-tier users see an upgrade modal, paid (CaveauAI) users get a searchable multi-select modal backed by /api/dashboard/documents.php - Add "Select from My Audio" picker on Transcribe with single-select and a "Save to My Audio" button for persisting uploaded clips - New PHP helpers in bootstrap.php: dbnToolsFetchDocChunks, dbnToolsClientIdFromSession, dbnToolsInjectDocContent - timeline, ask, redact APIs prepend selected document content (fetched from client_chunks SQL) before the textarea text - api/dashboard/audio-upload.php stores audio files on server and creates a client_documents row with source_type='audio' - api/transcribe.php falls back to stored audio via audio_doc_id POST field when no file is uploaded - api/dashboard/documents.php supports ?source_type= filter - tools.js: doc_ids added to JSON payload; stored-audio transcribe path - New assets/css/doc-picker.css, assets/js/doc-picker.js - SQL migration: scripts/sql/audio_docs_column.sql Co-Authored-By: Claude Sonnet 4.6 --- api/ask.php | 2 +- api/dashboard/audio-upload.php | 85 +++++++++ api/dashboard/documents.php | 6 + api/redact.php | 2 +- api/timeline.php | 2 +- api/transcribe.php | 48 ++++- assets/css/doc-picker.css | 272 ++++++++++++++++++++++++++++ assets/js/doc-picker.js | 285 ++++++++++++++++++++++++++++++ assets/js/tools.js | 49 ++++- includes/bootstrap.php | 66 +++++++ includes/layout_footer.php | 33 ++++ includes/tool_form.php | 9 + scripts/sql/audio_docs_column.sql | 4 + transcribe.php | 9 + 14 files changed, 857 insertions(+), 15 deletions(-) create mode 100644 api/dashboard/audio-upload.php create mode 100644 assets/css/doc-picker.css create mode 100644 assets/js/doc-picker.js create mode 100644 scripts/sql/audio_docs_column.sql diff --git a/api/ask.php b/api/ask.php index e91df18..3f5c6ce 100644 --- a/api/ask.php +++ b/api/ask.php @@ -12,6 +12,6 @@ $input = dbnToolsJsonInput(25000); $language = dbnToolsNormalizeLanguage($input['language'] ?? 'en'); dbnToolsWithTelemetry('ask', $language, function () use ($input, $language): array { - $question = dbnToolsString($input, 'question', 4000); + $question = dbnToolsInjectDocContent($input, dbnToolsString($input, 'question', 4000)); return (new DbnLegalToolsService())->ask($question, $language); }); diff --git a/api/dashboard/audio-upload.php b/api/dashboard/audio-upload.php new file mode 100644 index 0000000..b27dafb --- /dev/null +++ b/api/dashboard/audio-upload.php @@ -0,0 +1,85 @@ +getMessage(), $e->status, $e->errorCode); +} +$clientId = (int)$tenant['client_id']; + +if (empty($_FILES['audio']) || $_FILES['audio']['error'] !== UPLOAD_ERR_OK) { + $code = $_FILES['audio']['error'] ?? -1; + $msgs = [ + UPLOAD_ERR_INI_SIZE => 'File exceeds server upload limit.', + UPLOAD_ERR_FORM_SIZE => 'File exceeds form size limit.', + UPLOAD_ERR_PARTIAL => 'File was only partially uploaded.', + UPLOAD_ERR_NO_FILE => 'No audio file received.', + ]; + dbnToolsError($msgs[$code] ?? "Upload error (code {$code}).", 400, 'upload_error'); +} + +$file = $_FILES['audio']; +$maxBytes = 200 * 1024 * 1024; + +if ($file['size'] > $maxBytes) { + dbnToolsError('File too large. Maximum 200 MB.', 413, 'file_too_large'); +} + +$allowedExts = ['mp3', 'wav', 'ogg', 'oga', 'm4a', 'mp4', 'flac', 'webm', 'aac']; +$ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION)); +if (!in_array($ext, $allowedExts, true)) { + dbnToolsError("Unsupported format: .{$ext}. Use MP3, WAV, OGG, M4A, FLAC, or WebM.", 415, 'unsupported_format'); +} + +// Resolve storage directory +$storageRoot = dbnToolsEnv('AUDIO_STORAGE_ROOT', '/home/dobetternorge/audio-uploads'); +$clientDir = rtrim($storageRoot, '/') . '/' . $clientId; +if (!is_dir($clientDir) && !mkdir($clientDir, 0750, true)) { + dbnToolsError('Could not create storage directory.', 500, 'storage_error'); +} + +$uniqueId = bin2hex(random_bytes(12)); +$storagePath = $clientDir . '/' . $uniqueId . '.' . $ext; + +if (!move_uploaded_file($file['tmp_name'], $storagePath)) { + dbnToolsError('Failed to store uploaded file.', 500, 'move_error'); +} + +$title = pathinfo($file['name'], PATHINFO_FILENAME); +$title = preg_replace('/[_\-]+/', ' ', $title); +$title = mb_substr(trim($title), 0, 200) ?: 'Audio ' . date('Y-m-d'); + +$db = dbnToolsDb(); +$db->prepare( + 'INSERT INTO client_documents + (client_id, title, source_type, audio_storage_path, status, file_size_bytes, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, NOW(), NOW())' +)->execute([$clientId, $title, 'audio', $storagePath, 'ready', (int)$file['size']]); + +$docId = (int)$db->lastInsertId(); + +dbnToolsRespond([ + 'ok' => true, + 'document' => [ + 'id' => $docId, + 'title' => $title, + 'file_size_bytes' => (int)$file['size'], + 'audio_storage_path' => $storagePath, + 'source_type' => 'audio', + ], +]); diff --git a/api/dashboard/documents.php b/api/dashboard/documents.php index 0613b4e..fd82423 100644 --- a/api/dashboard/documents.php +++ b/api/dashboard/documents.php @@ -79,6 +79,12 @@ function respondList(PDO $db, int $clientId): void $where[] = 'category = ?'; $params[] = $category; } + $sourceType = trim((string)($_GET['source_type'] ?? '')); + $allowedSourceTypes = ['text', 'audio', 'url', 'tool-output', 'upload']; + if ($sourceType !== '' && in_array($sourceType, $allowedSourceTypes, true)) { + $where[] = 'source_type = ?'; + $params[] = $sourceType; + } $whereSql = 'WHERE ' . implode(' AND ', $where); diff --git a/api/redact.php b/api/redact.php index 948db7e..5f59acc 100644 --- a/api/redact.php +++ b/api/redact.php @@ -11,7 +11,7 @@ if ($ftRemaining >= 0) { header('X-Credits-Remaining: ' . $ftRemaining); } $input = dbnToolsJsonInput(400000); dbnToolsWithTelemetry('redact', '', function () use ($input): array { - $text = dbnToolsString($input, 'text', 128000); + $text = dbnToolsInjectDocContent($input, dbnToolsString($input, 'text', 128000)); $mode = (string)($input['mode'] ?? 'standard'); $region = dbnToolsNormalizeRegion($input['region'] ?? 'nordic'); $language = dbnToolsNormalizeLanguage($input['language'] ?? 'en'); diff --git a/api/timeline.php b/api/timeline.php index a0bb3b8..8eb450d 100644 --- a/api/timeline.php +++ b/api/timeline.php @@ -14,7 +14,7 @@ $input = dbnToolsJsonInput(400000); $language = dbnToolsNormalizeLanguage($input['language'] ?? 'en'); dbnToolsWithTelemetry('timeline', $language, function () use ($input, $language, $ftUid): array { - $text = dbnToolsString($input, 'text', 128000); + $text = dbnToolsInjectDocContent($input, dbnToolsString($input, 'text', 128000)); $validEngines = ['azure_mini', 'azure_full', 'gpu']; $engine = in_array((string)($input['engine'] ?? ''), $validEngines, true) diff --git a/api/transcribe.php b/api/transcribe.php index 202bfd1..7b4b721 100644 --- a/api/transcribe.php +++ b/api/transcribe.php @@ -32,17 +32,47 @@ $postModel = in_array($_POST['post_model'] ?? '', $allowedPostModels, true) ? (string)($_POST['post_model'] ?? '') : ''; -// ── Validate upload ─────────────────────────────────────────────────────────── +// ── Validate upload (or load from stored audio corpus) ──────────────────────── + +$storedAudioTmp = null; if (empty($_FILES['audio']) || $_FILES['audio']['error'] !== UPLOAD_ERR_OK) { - $code = $_FILES['audio']['error'] ?? -1; - $map = [ - UPLOAD_ERR_INI_SIZE => 'File exceeds server upload limit.', - UPLOAD_ERR_FORM_SIZE => 'File exceeds form size limit.', - UPLOAD_ERR_PARTIAL => 'File was only partially uploaded.', - UPLOAD_ERR_NO_FILE => 'No audio file received.', - ]; - dbnToolsError($map[$code] ?? "Upload error (code {$code}).", 400, 'upload_error'); + // Check if the user picked a previously saved audio document + $audioDocId = (int)($_POST['audio_doc_id'] ?? 0); + if ($audioDocId > 0) { + $clientId = dbnToolsClientIdFromSession(); + if ($clientId <= 0) { + dbnToolsError('No audio file received and no valid session for stored audio.', 400, 'upload_error'); + } + $db = dbnToolsDb(); + $row = $db->prepare( + 'SELECT audio_storage_path, title FROM client_documents + WHERE id = ? AND client_id = ? AND source_type = ? AND status = ? LIMIT 1' + ); + $row->execute([$audioDocId, $clientId, 'audio', 'ready']); + $audioRow = $row->fetch(PDO::FETCH_ASSOC); + if (!$audioRow || empty($audioRow['audio_storage_path']) || !is_readable((string)$audioRow['audio_storage_path'])) { + dbnToolsError('Stored audio file not found or not readable.', 404, 'audio_not_found'); + } + // Synthesise a $_FILES-compatible entry pointing at the stored file + $storedAudioTmp = $audioRow['audio_storage_path']; + $_FILES['audio'] = [ + 'name' => basename($storedAudioTmp), + 'tmp_name' => $storedAudioTmp, + 'error' => UPLOAD_ERR_OK, + 'size' => (int)filesize($storedAudioTmp), + 'type' => mime_content_type($storedAudioTmp) ?: 'application/octet-stream', + ]; + } else { + $code = $_FILES['audio']['error'] ?? -1; + $map = [ + UPLOAD_ERR_INI_SIZE => 'File exceeds server upload limit.', + UPLOAD_ERR_FORM_SIZE => 'File exceeds form size limit.', + UPLOAD_ERR_PARTIAL => 'File was only partially uploaded.', + UPLOAD_ERR_NO_FILE => 'No audio file received.', + ]; + dbnToolsError($map[$code] ?? "Upload error (code {$code}).", 400, 'upload_error'); + } } $file = $_FILES['audio']; diff --git a/assets/css/doc-picker.css b/assets/css/doc-picker.css new file mode 100644 index 0000000..1ef3313 --- /dev/null +++ b/assets/css/doc-picker.css @@ -0,0 +1,272 @@ +/* ── Doc / Audio Picker ──────────────────────────────────────────────────── */ + +.doc-picker-section { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.75rem; +} + +.doc-picker-btn { + display: inline-flex; + align-items: center; + gap: 0.45rem; + padding: 0.42rem 0.9rem; + border: 1.5px solid var(--dbn-line, #d0cfc8); + border-radius: 6px; + background: transparent; + color: var(--dbn-text, #16130f); + font-size: 0.84rem; + font-weight: 500; + cursor: pointer; + transition: border-color 0.15s, background 0.15s; + white-space: nowrap; +} +.doc-picker-btn:hover { + border-color: var(--dbn-accent, #00205B); + background: color-mix(in srgb, var(--dbn-accent, #00205B) 5%, transparent); +} +.doc-picker-btn:focus-visible { + outline: 2px solid var(--dbn-accent, #00205B); + outline-offset: 2px; +} +.doc-picker-btn__icon { flex-shrink: 0; } + +/* selected doc chips */ +.doc-picker-chips { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; + flex: 1; + min-width: 0; +} +.doc-chip { + display: inline-flex; + align-items: center; + gap: 0.3rem; + padding: 0.22rem 0.6rem; + background: color-mix(in srgb, var(--dbn-accent, #00205B) 10%, transparent); + border: 1px solid color-mix(in srgb, var(--dbn-accent, #00205B) 30%, transparent); + border-radius: 4px; + font-size: 0.79rem; + color: var(--dbn-text, #16130f); + max-width: 22ch; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.doc-chip__remove { + flex-shrink: 0; + background: none; + border: none; + cursor: pointer; + color: inherit; + opacity: 0.55; + padding: 0; + line-height: 1; + font-size: 1rem; +} +.doc-chip__remove:hover { opacity: 1; } + +/* ── Modal overlay ───────────────────────────────────────────────────────── */ + +.doc-picker-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.45); + z-index: 9000; + display: flex; + align-items: center; + justify-content: center; +} +.doc-picker-backdrop[hidden] { display: none; } + +.doc-picker-dialog { + background: #fff; + border-radius: 12px; + box-shadow: 0 24px 60px rgba(0, 0, 0, 0.22); + width: min(640px, 94vw); + max-height: 80vh; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.doc-picker-dialog__head { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1.1rem 1.25rem 0.75rem; + border-bottom: 1px solid var(--dbn-line, #d0cfc8); + flex-shrink: 0; +} +.doc-picker-dialog__head h3 { + margin: 0; + font-size: 1rem; + font-weight: 600; + color: var(--dbn-text, #16130f); +} +.doc-picker-dialog__close { + background: none; + border: none; + cursor: pointer; + font-size: 1.4rem; + line-height: 1; + color: rgba(22, 19, 15, 0.5); + padding: 0 0.2rem; +} +.doc-picker-dialog__close:hover { color: var(--dbn-text, #16130f); } + +.doc-picker-dialog__search { + margin: 0.75rem 1.25rem 0; + padding: 0.5rem 0.75rem; + border: 1.5px solid var(--dbn-line, #d0cfc8); + border-radius: 6px; + font-size: 0.88rem; + width: calc(100% - 2.5rem); + box-sizing: border-box; +} +.doc-picker-dialog__search:focus { outline: none; border-color: var(--dbn-accent, #00205B); } + +.doc-picker-list { + overflow-y: auto; + flex: 1; + padding: 0.5rem 1.25rem 0.75rem; + margin-top: 0.5rem; +} +.doc-picker-list__empty { + text-align: center; + color: rgba(22, 19, 15, 0.45); + font-size: 0.88rem; + padding: 2rem 0; +} +.doc-picker-list__loading { + text-align: center; + color: rgba(22, 19, 15, 0.45); + font-size: 0.88rem; + padding: 1.5rem 0; +} + +.doc-item { + display: flex; + align-items: flex-start; + gap: 0.65rem; + padding: 0.55rem 0.5rem; + border-radius: 6px; + cursor: pointer; + transition: background 0.1s; +} +.doc-item:hover, .doc-item.is-selected { background: color-mix(in srgb, var(--dbn-accent, #00205B) 7%, transparent); } +.doc-item input[type="checkbox"] { margin-top: 0.1rem; flex-shrink: 0; accent-color: var(--dbn-accent, #00205B); } +.doc-item__title { + font-size: 0.88rem; + font-weight: 500; + color: var(--dbn-text, #16130f); + line-height: 1.4; +} +.doc-item__meta { + font-size: 0.76rem; + color: rgba(22, 19, 15, 0.5); + margin-top: 0.1rem; +} + +.doc-picker-dialog__foot { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.75rem 1.25rem; + border-top: 1px solid var(--dbn-line, #d0cfc8); + flex-shrink: 0; +} +.doc-picker-dialog__count { font-size: 0.84rem; color: rgba(22, 19, 15, 0.55); } +.doc-picker-dialog__confirm { + padding: 0.48rem 1.1rem; + background: var(--dbn-accent, #00205B); + color: #fff; + border: none; + border-radius: 6px; + font-size: 0.88rem; + font-weight: 600; + cursor: pointer; +} +.doc-picker-dialog__confirm:disabled { opacity: 0.45; cursor: not-allowed; } +.doc-picker-dialog__confirm:not(:disabled):hover { background: color-mix(in srgb, var(--dbn-accent, #00205B) 80%, #000); } + +/* ── Upgrade modal ───────────────────────────────────────────────────────── */ + +.doc-picker-upgrade-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.45); + z-index: 9000; + display: flex; + align-items: center; + justify-content: center; +} +.doc-picker-upgrade-backdrop[hidden] { display: none; } + +.doc-picker-upgrade-card { + background: #fff; + border-radius: 12px; + box-shadow: 0 24px 60px rgba(0, 0, 0, 0.22); + width: min(400px, 92vw); + padding: 2rem 1.75rem; + text-align: center; +} +.doc-picker-upgrade-card__icon { + font-size: 2.2rem; + margin-bottom: 0.75rem; + display: block; +} +.doc-picker-upgrade-card h3 { + margin: 0 0 0.5rem; + font-size: 1.1rem; + color: var(--dbn-text, #16130f); +} +.doc-picker-upgrade-card p { + margin: 0 0 1.25rem; + font-size: 0.9rem; + color: rgba(22, 19, 15, 0.65); + line-height: 1.6; +} +.doc-picker-upgrade-card__actions { display: flex; gap: 0.65rem; justify-content: center; } +.doc-picker-upgrade-card__cta { + padding: 0.55rem 1.25rem; + background: var(--dbn-accent, #00205B); + color: #fff; + border: none; + border-radius: 6px; + font-size: 0.9rem; + font-weight: 600; + text-decoration: none; + cursor: pointer; +} +.doc-picker-upgrade-card__cta:hover { background: color-mix(in srgb, var(--dbn-accent, #00205B) 80%, #000); } +.doc-picker-upgrade-card__dismiss { + padding: 0.55rem 1rem; + background: none; + border: 1.5px solid var(--dbn-line, #d0cfc8); + border-radius: 6px; + font-size: 0.9rem; + cursor: pointer; + color: rgba(22, 19, 15, 0.7); +} +.doc-picker-upgrade-card__dismiss:hover { border-color: rgba(22, 19, 15, 0.4); } + +/* ── Audio upload zone inside transcribe picker section ──────────────────── */ + +.audio-corpus-upload { + display: inline-flex; + align-items: center; + gap: 0.45rem; + padding: 0.42rem 0.9rem; + border: 1.5px dashed var(--dbn-line, #d0cfc8); + border-radius: 6px; + background: transparent; + color: rgba(22, 19, 15, 0.55); + font-size: 0.84rem; + cursor: pointer; + transition: border-color 0.15s, color 0.15s; +} +.audio-corpus-upload:hover { border-color: var(--dbn-accent, #00205B); color: var(--dbn-accent, #00205B); } diff --git a/assets/js/doc-picker.js b/assets/js/doc-picker.js new file mode 100644 index 0000000..9b97bc2 --- /dev/null +++ b/assets/js/doc-picker.js @@ -0,0 +1,285 @@ +/** + * doc-picker.js — Document / Audio corpus picker for tool pages. + * + * Wires the "Select from My Docs" and "Select from My Audio" buttons. + * Free-tier (SSO) users see an upgrade modal instead of the picker. + * Paid (CaveauAI) users get a searchable multi-select modal backed by + * /api/dashboard/documents.php?action=list. + */ +(function () { + 'use strict'; + + // ── Tier detection ──────────────────────────────────────────────────────── + // DBN_FREE_TIER_BALANCE is set only for SSO (free) users. + // Paid CaveauAI sessions have it undefined. + function isPaidUser() { + return window.DBN_TOOLS_AUTHENTICATED === true + && typeof window.DBN_FREE_TIER_BALANCE === 'undefined'; + } + + // ── Upgrade modal ───────────────────────────────────────────────────────── + var upgradeBackdrop = document.getElementById('docPickerUpgradeBackdrop'); + + function showUpgradeModal() { + if (upgradeBackdrop) upgradeBackdrop.hidden = false; + } + + if (upgradeBackdrop) { + var upgradeClose = upgradeBackdrop.querySelector('.doc-picker-upgrade-card__dismiss'); + if (upgradeClose) { + upgradeClose.addEventListener('click', function () { + upgradeBackdrop.hidden = true; + }); + } + upgradeBackdrop.addEventListener('click', function (e) { + if (e.target === upgradeBackdrop) upgradeBackdrop.hidden = true; + }); + } + + // ── Shared picker state ─────────────────────────────────────────────────── + var backdrop = document.getElementById('docPickerBackdrop'); + var searchEl = backdrop ? backdrop.querySelector('.doc-picker-dialog__search') : null; + var listEl = backdrop ? backdrop.querySelector('.doc-picker-list') : null; + var countEl = backdrop ? backdrop.querySelector('.doc-picker-dialog__count') : null; + var confirmBtn = backdrop ? backdrop.querySelector('.doc-picker-dialog__confirm') : null; + var titleEl = backdrop ? backdrop.querySelector('.doc-picker-dialog__head h3') : null; + + var _allDocs = []; // full list from API + var _selected = {}; // { id: { id, title } } + var _mode = 'text'; // 'text' or 'audio' + var _onConfirm = null; // callback(selected) + + function openPicker(mode, onConfirm) { + _mode = mode || 'text'; + _onConfirm = onConfirm; + _allDocs = []; + + if (titleEl) { + titleEl.textContent = mode === 'audio' ? 'Select from My Audio' : 'Select from My Docs'; + } + if (searchEl) searchEl.value = ''; + if (listEl) listEl.innerHTML = '

Loading…

'; + if (backdrop) backdrop.hidden = false; + + var url = '/api/dashboard/documents.php?action=list&status=ready&limit=100'; + if (mode === 'audio') url += '&source_type=audio'; + + fetch(url, { credentials: 'same-origin' }) + .then(function (r) { return r.json(); }) + .then(function (data) { + _allDocs = (data.documents || []); + renderList(_allDocs); + }) + .catch(function () { + if (listEl) listEl.innerHTML = '

Could not load documents.

'; + }); + } + + function renderList(docs) { + if (!listEl) return; + if (!docs.length) { + listEl.innerHTML = '

No documents found.

'; + return; + } + var html = docs.map(function (doc) { + var id = doc.id; + var sel = !!_selected[id]; + var meta = []; + if (doc.word_count) meta.push(doc.word_count.toLocaleString() + ' words'); + if (doc.chunk_count) meta.push(doc.chunk_count + ' passages'); + if (doc.created_at) { + try { + meta.push(new Date(doc.created_at.replace(' ', 'T') + 'Z') + .toLocaleDateString(undefined, { dateStyle: 'medium' })); + } catch (_) {} + } + return '
' + + '' + + '
' + + '
' + esc(doc.title || 'Untitled') + '
' + + (meta.length ? '
' + esc(meta.join(' · ')) + '
' : '') + + '
' + + '
'; + }).join(''); + listEl.innerHTML = html; + + listEl.querySelectorAll('.doc-item').forEach(function (el) { + el.addEventListener('click', function () { + var id = parseInt(el.dataset.id, 10); + var doc = _allDocs.find(function (d) { return d.id === id; }); + if (!doc) return; + if (_selected[id]) { + delete _selected[id]; + } else { + if (_mode === 'audio') { + // single-select for audio + _selected = {}; + } + _selected[id] = { id: id, title: doc.title || 'Untitled' }; + } + var cb = el.querySelector('input[type="checkbox"]'); + if (cb) cb.checked = !!_selected[id]; + el.classList.toggle('is-selected', !!_selected[id]); + el.setAttribute('aria-selected', !!_selected[id]); + updateCount(); + }); + }); + updateCount(); + } + + function updateCount() { + var n = Object.keys(_selected).length; + if (countEl) countEl.textContent = n ? n + ' selected' : ''; + if (confirmBtn) confirmBtn.disabled = !n; + } + + function filterList(q) { + if (!q) return renderList(_allDocs); + var lower = q.toLowerCase(); + renderList(_allDocs.filter(function (d) { + return (d.title || '').toLowerCase().includes(lower); + })); + } + + if (searchEl) { + searchEl.addEventListener('input', function () { filterList(searchEl.value.trim()); }); + } + + if (confirmBtn) { + confirmBtn.addEventListener('click', function () { + var sel = Object.values(_selected); + if (backdrop) backdrop.hidden = true; + if (_onConfirm) _onConfirm(sel); + }); + } + + if (backdrop) { + var closeBtn = backdrop.querySelector('.doc-picker-dialog__close'); + if (closeBtn) closeBtn.addEventListener('click', function () { backdrop.hidden = true; }); + backdrop.addEventListener('click', function (e) { + if (e.target === backdrop) backdrop.hidden = true; + }); + document.addEventListener('keydown', function (e) { + if (e.key === 'Escape' && !backdrop.hidden) backdrop.hidden = true; + }); + } + + function esc(s) { + return String(s).replace(/[&<>"]/g, function (c) { + return { '&': '&', '<': '<', '>': '>', '"': '"' }[c]; + }); + } + + // ── Chip rendering ──────────────────────────────────────────────────────── + function renderChips(chipsEl, items, onRemove) { + if (!chipsEl) return; + chipsEl.innerHTML = items.map(function (item) { + return '' + + '' + esc(item.title) + '' + + '' + + ''; + }).join(''); + chipsEl.querySelectorAll('.doc-chip__remove').forEach(function (btn) { + btn.addEventListener('click', function (e) { + e.stopPropagation(); + var id = parseInt(btn.closest('.doc-chip').dataset.id, 10); + if (onRemove) onRemove(id); + }); + }); + } + + // ── Text doc picker wiring ──────────────────────────────────────────────── + var docPickerBtn = document.getElementById('docPickerBtn'); + var docPickerIds = document.getElementById('docPickerIds'); + var docPickerChips = document.getElementById('docPickerChips'); + + var _textSelected = {}; + + function syncTextPicker() { + if (docPickerIds) { + docPickerIds.value = Object.keys(_textSelected).join(','); + } + renderChips(docPickerChips, Object.values(_textSelected), function (id) { + delete _textSelected[id]; + delete _selected[id]; + syncTextPicker(); + }); + } + + if (docPickerBtn) { + docPickerBtn.addEventListener('click', function () { + if (!window.DBN_TOOLS_AUTHENTICATED) return; // shouldn't happen + if (!isPaidUser()) { showUpgradeModal(); return; } + _selected = Object.assign({}, _textSelected); + openPicker('text', function (sel) { + _textSelected = {}; + sel.forEach(function (s) { _textSelected[s.id] = s; }); + syncTextPicker(); + }); + }); + } + + // ── Audio picker wiring ─────────────────────────────────────────────────── + var audioPickerBtn = document.getElementById('audioPickerBtn'); + var audioPickerDocId = document.getElementById('audioPickerDocId'); + var audioPickerChips = document.getElementById('audioPickerChips'); + + var _audioSelected = null; // single item or null + + function syncAudioPicker() { + if (audioPickerDocId) { + audioPickerDocId.value = _audioSelected ? String(_audioSelected.id) : ''; + } + renderChips(audioPickerChips, _audioSelected ? [_audioSelected] : [], function () { + _audioSelected = null; + syncAudioPicker(); + }); + } + + if (audioPickerBtn) { + audioPickerBtn.addEventListener('click', function () { + if (!window.DBN_TOOLS_AUTHENTICATED) return; + if (!isPaidUser()) { showUpgradeModal(); return; } + _selected = _audioSelected ? { [_audioSelected.id]: _audioSelected } : {}; + openPicker('audio', function (sel) { + _audioSelected = sel.length ? sel[0] : null; + syncAudioPicker(); + }); + }); + } + + // ── Audio corpus save button (inside transcribe page) ───────────────────── + // Allows saving an audio file from the upload queue to the corpus. + var audioCorpusSaveBtn = document.getElementById('audioCorpusSaveBtn'); + if (audioCorpusSaveBtn && isPaidUser()) { + audioCorpusSaveBtn.hidden = false; + audioCorpusSaveBtn.addEventListener('click', function () { + var audioInput = document.getElementById('audioInput'); + if (!audioInput || !audioInput.files.length) { + alert('Add an audio file first.'); + return; + } + var file = audioInput.files[0]; + var fd = new FormData(); + fd.append('audio', file, file.name); + audioCorpusSaveBtn.disabled = true; + audioCorpusSaveBtn.textContent = 'Saving…'; + fetch('/api/dashboard/audio-upload.php', { method: 'POST', credentials: 'same-origin', body: fd }) + .then(function (r) { return r.json(); }) + .then(function (data) { + if (!data.ok) throw new Error(data.error || 'Upload failed'); + audioCorpusSaveBtn.textContent = 'Saved ✓'; + setTimeout(function () { + audioCorpusSaveBtn.textContent = 'Save to My Audio'; + audioCorpusSaveBtn.disabled = false; + }, 2500); + }) + .catch(function (err) { + alert('Could not save audio: ' + err.message); + audioCorpusSaveBtn.textContent = 'Save to My Audio'; + audioCorpusSaveBtn.disabled = false; + }); + }); + } + +}()); diff --git a/assets/js/tools.js b/assets/js/tools.js index 3079bf0..1a38beb 100644 --- a/assets/js/tools.js +++ b/assets/js/tools.js @@ -1070,13 +1070,17 @@ async function runTool(event) { const tool = tools[state.activeTool]; const text = els.input.value.trim(); - if (!text) { - els.status.textContent = 'Add text before running the tool.'; - els.input.focus(); + const docIds = (document.getElementById('docPickerIds')?.value || '') + .split(',').map(Number).filter(Boolean); + + if (!text && !docIds.length) { + els.status.textContent = 'Add text or select a document before running the tool.'; + if (!docIds.length) els.input.focus(); return; } const payload = { [tool.payloadKey]: text }; + if (docIds.length) payload.doc_ids = docIds; if (tool.usesLanguage) { payload.language = currentLanguage(); } @@ -1812,6 +1816,45 @@ function showTranscribeProgress(clip, total) { } async function runTranscribe() { + const storedAudioDocId = parseInt(document.getElementById('audioPickerDocId')?.value || '0', 10); + + // Stored audio path — no file in the queue required + if (storedAudioDocId > 0 && !audioQueue.length) { + setBusy(true); + els.status.textContent = 'Transcribing from corpus…'; + try { + const fd = new FormData(); + fd.append('audio_doc_id', String(storedAudioDocId)); + fd.append('language', currentTranscribeLang ? currentTranscribeLang() : 'auto'); + fd.append('task', currentTask ? currentTask() : 'transcribe'); + const vadFilter = document.getElementById('vadFilterCheck')?.checked ?? false; + if (vadFilter) fd.append('vad_filter', '1'); + const initPrompt = (document.getElementById('initPromptInput')?.value || '').trim(); + if (initPrompt) fd.append('initial_prompt', initPrompt); + const whisperModel = document.getElementById('whisperModelSelect')?.value; + if (whisperModel) fd.append('model', whisperModel); + const postModel = document.querySelector('input[name="post_model"]:checked')?.value; + if (postModel) fd.append('post_model', postModel); + const diarize = document.getElementById('diarizeCheck')?.checked ?? false; + if (diarize) { + fd.append('diarize', '1'); + const ns = parseInt(document.getElementById('numSpeakersInput')?.value || '', 10); + if (ns >= 2) fd.append('num_speakers', String(ns)); + } + const resp = await fetch('api/transcribe.php', { method: 'POST', credentials: 'same-origin', body: fd }); + const data = await resp.json().catch(() => ({})); + if (!resp.ok || !data.ok) throw new Error(data.error?.message || 'Transcription failed.'); + if (typeof renderTranscribeResult === 'function') renderTranscribeResult(data); + else renderResults(data); + els.status.textContent = 'Done.'; + } catch (err) { + els.status.textContent = err.message; + } finally { + setBusy(false); + } + return; + } + if (!audioQueue.length) { els.status.textContent = currentUiT('noFileSelected'); return; diff --git a/includes/bootstrap.php b/includes/bootstrap.php index 3fbcc45..1946daa 100644 --- a/includes/bootstrap.php +++ b/includes/bootstrap.php @@ -1110,3 +1110,69 @@ function dbnToolsExtractCheckLegalBasis(string $text): string } return ''; } + +// ── Document picker helpers ─────────────────────────────────────────────────── + +/** Fetch text content of selected client documents as labelled blocks. */ +function dbnToolsFetchDocChunks(array $docIds, int $clientId): string +{ + if (empty($docIds) || $clientId <= 0) { + return ''; + } + $db = dbnToolsDb(); + $placeholders = implode(',', array_fill(0, count($docIds), '?')); + $stmt = $db->prepare( + "SELECT c.content, d.title AS doc_title, c.document_id + FROM client_chunks c + JOIN client_documents d ON d.id = c.document_id + WHERE c.client_id = ? AND c.document_id IN ($placeholders) + AND d.source_type != 'audio' + ORDER BY c.document_id, c.id ASC + LIMIT 500" + ); + $stmt->execute(array_merge([$clientId], $docIds)); + $byDoc = []; + foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) { + $id = (int)$row['document_id']; + $byDoc[$id] ??= ['title' => (string)$row['doc_title'], 'chunks' => []]; + $byDoc[$id]['chunks'][] = (string)$row['content']; + } + $parts = []; + foreach ($byDoc as $doc) { + $parts[] = '=== ' . $doc['title'] . " ===\n" . implode("\n\n", $doc['chunks']); + } + return implode("\n\n---\n\n", $parts); +} + +/** Resolve client_id for the current CaveauAI session; returns 0 for SSO/free-tier users. */ +function dbnToolsClientIdFromSession(): int +{ + try { + $tenant = dbnToolsEnsureDashboardTenant(); + return (int)($tenant['client_id'] ?? 0); + } catch (Throwable) { + return 0; + } +} + +/** + * Inject selected corpus document content into $text if doc_ids are in the request input. + * No-ops silently for free-tier (SSO) users who have no client_documents. + */ +function dbnToolsInjectDocContent(array $input, string $text): string +{ + $raw = $input['doc_ids'] ?? []; + $ids = array_values(array_filter(array_map('intval', is_array($raw) ? $raw : explode(',', (string)$raw)))); + if (empty($ids)) { + return $text; + } + $clientId = dbnToolsClientIdFromSession(); + if ($clientId <= 0) { + return $text; + } + $docText = dbnToolsFetchDocChunks($ids, $clientId); + if ($docText === '') { + return $text; + } + return $docText . ($text !== '' ? "\n\n---\n\n" . $text : ''); +} diff --git a/includes/layout_footer.php b/includes/layout_footer.php index 99e40e8..404bd55 100644 --- a/includes/layout_footer.php +++ b/includes/layout_footer.php @@ -22,11 +22,44 @@ + + + + + + + + diff --git a/includes/tool_form.php b/includes/tool_form.php index 5e7ed86..dc8df04 100644 --- a/includes/tool_form.php +++ b/includes/tool_form.php @@ -72,6 +72,15 @@

Replace a name with a bracketed alias, e.g. “David Jr” → [Junior]

+
+ +
+ +
+ diff --git a/scripts/sql/audio_docs_column.sql b/scripts/sql/audio_docs_column.sql new file mode 100644 index 0000000..0863af0 --- /dev/null +++ b/scripts/sql/audio_docs_column.sql @@ -0,0 +1,4 @@ +-- Add audio file storage path to client_documents +-- Run once on production: mysql dobetternorge < audio_docs_column.sql +ALTER TABLE client_documents + ADD COLUMN audio_storage_path VARCHAR(500) NULL AFTER source_url; diff --git a/transcribe.php b/transcribe.php index dfb6720..622e1e9 100644 --- a/transcribe.php +++ b/transcribe.php @@ -104,6 +104,15 @@ require_once __DIR__ . '/includes/layout.php'; +
+ +
+ +
+