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 'Loading…
+Select documents from your uploaded corpus and feed them directly into any tool. Available on Plus and Pro plans.
+