Add AI translation for comments and video descriptions

Comments get a Translate button that calls the active LLM provider in Admin → AI
settings. Each commenter shows a small country flag inferred from their
video_views + profile_visits history (falls back to users.nationality); that
inferred country drives the language hint passed to the LLM so Portuguese
"Linda, linda, linda" translates to "Beautiful" instead of pass-through.

Description boxes get a line-paired translation grid: a shared Translate button
flips #vdbDescSplit into a two-column CSS grid where every source line sits
next to its translation on the same row. Backend splits by newline and asks
the LLM for a JSON map keyed by index so counts always match.

LlmLyricsService now has translate() + translateLines() with echo-retry (if the
model returns the input unchanged while source != target, retry once with a
stronger directive) and keeps the Ollama model resident via keep_alive=1h.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
ghassanandClaude Opus 4.7 committed 2026-10-02 12:18:20 +03:00
1 parent 20c8fdcdf3
commit 2d33e39b9b
8 files changed
+749 -22

No files matched your search

@@ -7,7 +7,12 @@
$isVideoOwner = Auth::check() && Auth::id() === $video->user_id;
$renderedDescription = \App\Support\HtmlSanitizer::render($video->description ?? '');
$hasDesc = $renderedDescription !== '' || isset($descriptionSlot);
$showBox = $hasDesc || $isVideoOwner;
$llmEnabledVdb = $hasDesc && app(\App\Services\LlmLyricsService::class)->isEnabled();
// Optional pre-rendered block shown above the description inside the About
// panel. Only the `match` type passes it (the bout summary card); when it is
// unset every other type renders exactly as before.
$aboutPrepend = $aboutPrepend ?? null;
$showBox = $hasDesc || $isVideoOwner || $aboutPrepend !== null;
@endphp
@if ($showBox)
<style>
@@ -36,6 +41,27 @@
.vdb-show-more:hover { background:var(--bg-hover, rgba(127,127,127,.12)); }
.vdb-show-more i { font-size:14px; transition:transform .2s ease; }
.vdb-show-more.expanded i { transform:rotate(180deg); }
.vdb-translate-btn { display:inline-flex; align-items:center; gap:6px; background:none; border:1px solid var(--border-color); color:var(--text-primary); font-size:12px; font-weight:600; padding:5px 12px; border-radius:16px; cursor:pointer; margin-top:10px; transition:background .15s, color .15s; font-family:inherit; }
.vdb-translate-btn:hover { background:rgba(255,255,255,.08); }
.vdb-translate-btn.loading { opacity:.6; cursor:wait; }
.vdb-translate-btn.active { color:#3ea6ff; border-color:rgba(62,166,255,.4); }
.vdb-translate-btn svg { width:14px; height:14px; fill:currentColor; }
.vdb-desc-split { display:block; }
/* Line-paired view: each row = one original line + its translation, aligned on
the same baseline so skimming across the divider stays easy. */
.vdb-desc-split.has-translation #vdbDescShort { display:none; }
.vdb-translation-grid { display:grid; grid-template-columns:1fr 1fr; column-gap:16px; row-gap:4px; font-size:14px; line-height:1.6; color:var(--text-primary); word-break:break-word; }
.vdb-translation-meta { grid-column:1 / -1; font-size:11px; color:var(--text-secondary); margin:0 0 4px; text-transform:uppercase; letter-spacing:.4px; }
.vdb-tr-row { display:contents; }
.vdb-tr-cell { padding:4px 0; white-space:pre-wrap; }
.vdb-tr-cell.vdb-tr-orig { padding-right:16px; border-right:1px solid var(--border-color); }
.vdb-tr-cell.vdb-tr-trans { padding-left:4px; }
.vdb-tr-cell.vdb-tr-empty { min-height:1em; }
@media (max-width:768px) {
.vdb-translation-grid { grid-template-columns:1fr; row-gap:2px; }
.vdb-tr-cell.vdb-tr-orig { padding:4px 0 2px; border-right:none; }
.vdb-tr-cell.vdb-tr-trans { padding:0 0 10px 0; color:#9ad1ff; border-bottom:1px dashed var(--border-color); }
}
</style>
<div class="vdb-wrap" id="vdbWrap">
@@ -51,6 +77,9 @@
</div>
<div class="vdb-panel active" id="vdb-about">
@if($aboutPrepend !== null)
{!! $aboutPrepend !!}
@endif
<div class="vdb-meta">
<span><i class="bi bi-eye" style="margin-right:4px;"></i>{{ number_format($video->view_count) }} views</span>
<span>•</span>
@@ -60,11 +89,21 @@
@if(isset($descriptionSlot))
{!! $descriptionSlot !!}
@elseif($renderedDescription !== '')
<div id="vdbDescShort" class="vdb-desc-text vdb-clamp">{!! $renderedDescription !!}</div>
<div id="vdbDescSplit" class="vdb-desc-split">
<div id="vdbDescShort" class="vdb-desc-text vdb-clamp">{!! $renderedDescription !!}</div>
</div>
<button id="vdbShowMore" class="vdb-show-more" style="display:none;" onclick="toggleVdbDesc(this)"><span>Show more</span> <i class="bi bi-chevron-down"></i></button>
@else
<p style="font-size:13px;color:var(--text-secondary);margin:0;">No description added.</p>
@endif
@if($llmEnabledVdb)
<button type="button" class="vdb-translate-btn" id="vdbTranslateBtn"
data-video-key="{{ $video->getRouteKey() }}"
data-csrf="{{ csrf_token() }}">
<svg viewBox="0 0 24 24"><path d="M12.87 15.07l-2.54-2.51.03-.03c1.74-1.94 2.98-4.17 3.71-6.53H17V4h-7V2H8v2H1v1.99h11.17C11.5 7.92 10.44 9.75 9 11.35 8.07 10.32 7.3 9.19 6.69 8h-2c.73 1.63 1.73 3.17 2.98 4.56l-5.09 5.02L4 19l5-5 3.11 3.11.76-2.04zM18.5 10h-2L12 22h2l1.12-3h4.75L21 22h2l-4.5-12zm-2.62 7l1.62-4.33L19.12 17h-3.24z"/></svg>
<span class="vdb-translate-label">Translate</span>
</button>
@endif
</div>
@if($isVideoOwner)
@@ -176,5 +215,92 @@ window._vdbScheduleOverflowCheck = window._vdbScheduleOverflowCheck || function
};
document.addEventListener('DOMContentLoaded', window._vdbScheduleOverflowCheck);
window.addEventListener('load', window._vdbScheduleOverflowCheck);
// ── Description translate ─────────────────────────────
// Lives on the About tab. Falls back to navigator.language for the target.
function _vdbTargetLang() {
return ((navigator.language || 'en').toLowerCase().split('-')[0]) || 'en';
}
window._vdbToggleTranslate = window._vdbToggleTranslate || async function (btn) {
const split = document.getElementById('vdbDescSplit');
const showMore = document.getElementById('vdbShowMore');
const label = btn.querySelector('.vdb-translate-label');
if (!split) return;
let grid = document.getElementById('vdbTranslationGrid');
const showTranslation = () => {
split.classList.add('has-translation');
if (grid) grid.style.display = 'grid';
btn.classList.add('active');
if (label) label.textContent = 'Show original';
if (showMore) showMore.style.display = 'none';
};
const hideTranslation = () => {
split.classList.remove('has-translation');
if (grid) grid.style.display = 'none';
btn.classList.remove('active');
if (label) label.textContent = 'Translate';
if (window._vdbScheduleOverflowCheck) window._vdbScheduleOverflowCheck();
};
if (grid && grid.dataset.loaded === '1') {
split.classList.contains('has-translation') ? hideTranslation() : showTranslation();
return;
}
btn.classList.add('loading');
if (label) label.textContent = 'Translating…';
try {
const r = await fetch('/videos/' + btn.dataset.videoKey + '/description/translate', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': btn.dataset.csrf, 'Accept': 'application/json' },
body: JSON.stringify({ target: _vdbTargetLang() })
});
const d = await r.json();
if (!r.ok || !d.success) throw new Error(d.error || 'Translation failed');
const segs = Array.isArray(d.segments) ? d.segments : [];
if (!segs.length) throw new Error('Translation failed');
// Build the line-paired grid. Each original line and its translation are
// siblings in the same CSS grid, so a short line on one side still shares
// its row with its counterpart on the other side.
if (!grid) {
grid = document.createElement('div');
grid.id = 'vdbTranslationGrid';
grid.className = 'vdb-translation-grid';
split.appendChild(grid);
}
grid.innerHTML = '';
const meta = document.createElement('div');
meta.className = 'vdb-translation-meta';
meta.textContent = 'Translated → ' + (d.target || _vdbTargetLang()).toUpperCase();
grid.appendChild(meta);
segs.forEach(seg => {
const origText = (seg.original ?? '').toString();
const trText = (seg.translated ?? '').toString();
const o = document.createElement('div');
o.className = 'vdb-tr-cell vdb-tr-orig' + (origText.trim() === '' ? ' vdb-tr-empty' : '');
o.textContent = origText;
const t = document.createElement('div');
t.className = 'vdb-tr-cell vdb-tr-trans' + (trText.trim() === '' ? ' vdb-tr-empty' : '');
t.textContent = trText;
grid.appendChild(o);
grid.appendChild(t);
});
grid.dataset.loaded = '1';
showTranslation();
} catch (err) {
if (window.showToast) window.showToast(err.message || 'Translation failed', 'error');
if (label) label.textContent = 'Translate';
} finally {
btn.classList.remove('loading');
}
};
// Delegate so SPA swaps that re-insert #vdbWrap keep working.
document.addEventListener('click', function (e) {
const b = e.target.closest('#vdbTranslateBtn');
if (b) { e.preventDefault(); window._vdbToggleTranslate(b); }
});
</script>
@endif