From 2d33e39b9b664ce171765bcb6abc4fe82401564f Mon Sep 17 00:00:00 2001 From: ghassan Date: Fri, 2 Oct 2026 12:18:20 +0300 Subject: [PATCH] Add AI translation for comments and video descriptions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- app/Data/CountryLanguages.php | 50 +++++ app/Http/Controllers/CommentController.php | 57 ++++- app/Http/Controllers/VideoController.php | 49 ++++- app/Models/User.php | 100 +++++++++ app/Services/LlmLyricsService.php | 206 ++++++++++++++++-- .../views/components/video-comments.blade.php | 177 ++++++++++++++- .../videos/partials/description-box.blade.php | 130 ++++++++++- routes/web.php | 2 + 8 files changed, 749 insertions(+), 22 deletions(-) create mode 100644 app/Data/CountryLanguages.php diff --git a/app/Data/CountryLanguages.php b/app/Data/CountryLanguages.php new file mode 100644 index 0000000..2226469 --- /dev/null +++ b/app/Data/CountryLanguages.php @@ -0,0 +1,50 @@ + 'ar', 'AF' => 'ps', 'AL' => 'sq', 'AM' => 'hy', 'AO' => 'pt', + 'AR' => 'es', 'AT' => 'de', 'AU' => 'en', 'AZ' => 'az', + 'BA' => 'bs', 'BD' => 'bn', 'BE' => 'nl', 'BG' => 'bg', 'BH' => 'ar', + 'BO' => 'es', 'BR' => 'pt', 'BY' => 'be', + 'CA' => 'en', 'CH' => 'de', 'CL' => 'es', 'CN' => 'zh', 'CO' => 'es', + 'CR' => 'es', 'CU' => 'es', 'CZ' => 'cs', + 'DE' => 'de', 'DK' => 'da', 'DO' => 'es', 'DZ' => 'ar', + 'EC' => 'es', 'EE' => 'et', 'EG' => 'ar', 'ES' => 'es', 'ET' => 'am', + 'FI' => 'fi', 'FR' => 'fr', + 'GB' => 'en', 'GE' => 'ka', 'GR' => 'el', 'GT' => 'es', + 'HK' => 'zh', 'HN' => 'es', 'HR' => 'hr', 'HU' => 'hu', + 'ID' => 'id', 'IE' => 'en', 'IL' => 'he', 'IN' => 'hi', 'IQ' => 'ar', + 'IR' => 'fa', 'IS' => 'is', 'IT' => 'it', + 'JO' => 'ar', 'JP' => 'ja', + 'KE' => 'sw', 'KG' => 'ky', 'KH' => 'km', 'KR' => 'ko', 'KW' => 'ar', + 'KZ' => 'kk', + 'LA' => 'lo', 'LB' => 'ar', 'LK' => 'si', 'LT' => 'lt', 'LU' => 'fr', + 'LV' => 'lv', 'LY' => 'ar', + 'MA' => 'ar', 'MD' => 'ro', 'ME' => 'sr', 'MK' => 'mk', 'MM' => 'my', + 'MN' => 'mn', 'MT' => 'mt', 'MX' => 'es', 'MY' => 'ms', + 'NG' => 'en', 'NI' => 'es', 'NL' => 'nl', 'NO' => 'no', 'NP' => 'ne', + 'NZ' => 'en', + 'OM' => 'ar', + 'PA' => 'es', 'PE' => 'es', 'PH' => 'tl', 'PK' => 'ur', 'PL' => 'pl', + 'PR' => 'es', 'PS' => 'ar', 'PT' => 'pt', 'PY' => 'es', + 'QA' => 'ar', + 'RO' => 'ro', 'RS' => 'sr', 'RU' => 'ru', + 'SA' => 'ar', 'SD' => 'ar', 'SE' => 'sv', 'SG' => 'en', 'SI' => 'sl', + 'SK' => 'sk', 'SO' => 'so', 'SV' => 'es', 'SY' => 'ar', + 'TH' => 'th', 'TJ' => 'tg', 'TN' => 'ar', 'TR' => 'tr', 'TW' => 'zh', + 'TZ' => 'sw', + 'UA' => 'uk', 'UG' => 'en', 'US' => 'en', 'UY' => 'es', 'UZ' => 'uz', + 'VE' => 'es', 'VN' => 'vi', + 'YE' => 'ar', + 'ZA' => 'en', 'ZW' => 'en', + ]; + + public static function primary(?string $iso2): ?string + { + if (! $iso2) return null; + return self::MAP[strtoupper($iso2)] ?? null; + } +} diff --git a/app/Http/Controllers/CommentController.php b/app/Http/Controllers/CommentController.php index 9036739..9a2ff69 100644 --- a/app/Http/Controllers/CommentController.php +++ b/app/Http/Controllers/CommentController.php @@ -8,6 +8,7 @@ use App\Models\Video; use App\Notifications\NewCommentLikeNotification; use App\Notifications\NewCommentNotification; use App\Notifications\NewReplyNotification; +use App\Services\LlmLyricsService; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; @@ -15,7 +16,7 @@ class CommentController extends Controller { public function __construct() { - $this->middleware('auth')->except(['index']); + $this->middleware('auth')->except(['index', 'translate']); } public function index(Video $video) @@ -34,6 +35,29 @@ class CommentController extends Controller $commenter = Auth::user(); + // Idempotency guard: a client that retries (duplicate listeners after an + // SPA swap, fast double-click, a flaky connection) can POST the identical + // body twice. Treat an identical comment from the same user to the same + // video within 5 seconds as the same submission and return the first one. + $existing = $video->comments() + ->where('user_id', $commenter->id) + ->where('body', $request->body) + ->where(function ($q) use ($request) { + $request->parent_id + ? $q->where('parent_id', $request->parent_id) + : $q->whereNull('parent_id'); + }) + ->where('created_at', '>=', now()->subSeconds(5)) + ->latest('id') + ->first(); + + if ($existing) { + return response()->json([ + 'success' => true, + 'comment' => $existing->load('user:id,name,avatar'), + ]); + } + $comment = $video->comments()->create([ 'user_id' => $commenter->id, 'body' => $request->body, @@ -89,6 +113,37 @@ class CommentController extends Controller return response()->json(['success' => true]); } + public function translate(Request $request, Comment $comment, LlmLyricsService $llm) + { + if (! $llm->isEnabled()) { + return response()->json(['error' => 'Translation is not configured'], 503); + } + + $target = (string) $request->input('target', 'en'); + + // Derive a source-language hint from the comment author's most-frequent + // access country (video_views + profile_visits, falling back to their + // nationality setting). Never trust the client for this — it's used + // to disambiguate scripts the LLM might otherwise misidentify. + $topCountries = \App\Models\User::topCountriesFor([$comment->user_id]); + $sourceCountry = $topCountries[$comment->user_id] ?? null; + $sourceLang = \App\Data\CountryLanguages::primary($sourceCountry); + + $translated = $llm->translate((string) $comment->body, $target, $sourceLang); + + if ($translated === '') { + return response()->json(['error' => 'Translation failed'], 502); + } + + return response()->json([ + 'success' => true, + 'translated' => $translated, + 'target' => $target, + 'source_hint' => $sourceLang, + 'author_country'=> $sourceCountry, + ]); + } + public function like(Comment $comment) { $userId = Auth::id(); diff --git a/app/Http/Controllers/VideoController.php b/app/Http/Controllers/VideoController.php index a8b6021..dc54846 100644 --- a/app/Http/Controllers/VideoController.php +++ b/app/Http/Controllers/VideoController.php @@ -26,7 +26,7 @@ class VideoController extends Controller { public function __construct() { - $this->middleware('auth')->except(['index', 'show', 'search', 'stream', 'hls', 'trending', 'shorts', 'download', 'downloadMp3', 'recordShare', 'ogImage', 'vsPreview', 'accessShare', 'showByToken', 'recommendations', 'slideshowProgress', 'playerData', 'streamAudioTrack', 'lyricsProgress']); + $this->middleware('auth')->except(['index', 'show', 'search', 'stream', 'hls', 'trending', 'shorts', 'download', 'downloadMp3', 'recordShare', 'ogImage', 'vsPreview', 'accessShare', 'showByToken', 'recommendations', 'slideshowProgress', 'playerData', 'streamAudioTrack', 'lyricsProgress', 'translateDescription']); } public function index() @@ -913,6 +913,53 @@ class VideoController extends Controller * mirror + the NAS copy and removes any in-flight progress / temp files, * so the next Generate produces a fresh result. */ + public function translateDescription(Request $request, Video $video, \App\Services\LlmLyricsService $llm) + { + if (! $llm->isEnabled()) { + return response()->json(['error' => 'Translation is not configured'], 503); + } + + $html = (string) $video->description; + if (trim($html) === '') { + return response()->json(['error' => 'Nothing to translate'], 400); + } + // Flatten the rich description → plain text, keeping paragraph breaks + // so the client can render original and translation line-by-line. + $plain = trim(html_entity_decode(strip_tags( + preg_replace(['/<\/p>/i', '//i'], ["\n", "\n"], $html) + ), ENT_QUOTES | ENT_HTML5, 'UTF-8')); + $lines = preg_split('/\R/u', $plain); + + $target = (string) $request->input('target', 'en'); + + // Source language inferred from the owner's access-history country, + // falling back to users.nationality. + $topCountries = \App\Models\User::topCountriesFor([$video->user_id]); + $sourceCountry = $topCountries[$video->user_id] ?? null; + $sourceLang = \App\Data\CountryLanguages::primary($sourceCountry); + + $translatedLines = $llm->translateLines($lines, $target, $sourceLang); + if (! $translatedLines || count($translatedLines) !== count($lines)) { + return response()->json(['error' => 'Translation failed'], 502); + } + + $segments = []; + foreach ($lines as $i => $src) { + $segments[] = [ + 'original' => (string) $src, + 'translated' => (string) ($translatedLines[$i] ?? ''), + ]; + } + + return response()->json([ + 'success' => true, + 'segments' => $segments, + 'target' => $target, + 'source_hint' => $sourceLang, + 'author_country' => $sourceCountry, + ]); + } + public function deleteLyrics(Request $request, Video $video) { if (Auth::id() !== $video->user_id) { diff --git a/app/Models/User.php b/app/Models/User.php index 1814ca3..16375ab 100755 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -7,6 +7,8 @@ use Illuminate\Contracts\Auth\MustVerifyEmail; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; +use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Schema; use Illuminate\Support\Str; use Laravel\Sanctum\HasApiTokens; @@ -56,8 +58,57 @@ class User extends Authenticatable implements MustVerifyEmail 'password' => 'hashed', 'two_factor_enabled' => 'boolean', 'notification_preferences' => 'array', + // TAKEONE account link. The token is a live credential for that + // platform, so it is encrypted at rest and never sent back to a browser. + 'takeone_token' => 'encrypted', + 'takeone_connected_at' => 'datetime', ]; + /** + * Can this user run TAKEONE lookups? + * + * A super admin always can: the platform holds a TAKEONE super-admin token + * on their behalf, so they are connected with full reach without ever + * pasting anything. Everyone else links their own account, and sees only + * what that account may see. + */ + public function takeoneLinked(): bool + { + return $this->takeoneToken() !== null; + } + + /** True when the connection is the platform's, not one this user pasted. */ + public function takeoneManagedLink(): bool + { + $own = $this->takeone_token; + + return ($own === null || $own === '') + && $this->isSuperAdmin() + && ! empty(config('services.takeone.admin_token')); + } + + /** + * The token to talk to TAKEONE with. A personally linked account always + * wins, so a super admin who deliberately links their own identity is not + * silently upgraded back to the platform credential. + */ + public function takeoneToken(): ?string + { + $own = $this->takeone_token; + + if ($own !== null && $own !== '') { + return $own; + } + + if ($this->isSuperAdmin()) { + $platform = config('services.takeone.admin_token'); + + return ! empty($platform) ? $platform : null; + } + + return null; + } + // Defaults for each preference key (true = on, false = off) private const NOTIF_DEFAULTS = [ 'notif_new_comment' => true, @@ -105,6 +156,55 @@ class User extends Authenticatable implements MustVerifyEmail }); } + /** + * Resolve the most-frequent ISO2 country for each user id, aggregating + * video_views + profile_visits and falling back to users.nationality. + * Returns [user_id => 'BH', …]; keys without data are omitted. + */ + public static function topCountriesFor(array $userIds): array + { + $userIds = array_values(array_unique(array_filter($userIds, 'is_numeric'))); + if (! $userIds) return []; + + $tally = []; + $rows = DB::table('video_views') + ->selectRaw('user_id, country, COUNT(*) as c') + ->whereIn('user_id', $userIds) + ->whereNotNull('country')->where('country', '!=', '') + ->groupBy('user_id', 'country')->get(); + foreach ($rows as $r) { + $cc = strtoupper($r->country); + $tally[(int) $r->user_id][$cc] = ($tally[(int) $r->user_id][$cc] ?? 0) + (int) $r->c; + } + if (Schema::hasTable('profile_visits')) { + $rows = DB::table('profile_visits') + ->selectRaw('visitor_user_id as user_id, country, COUNT(*) as c') + ->whereIn('visitor_user_id', $userIds) + ->whereNotNull('country')->where('country', '!=', '') + ->groupBy('visitor_user_id', 'country')->get(); + foreach ($rows as $r) { + $cc = strtoupper($r->country); + $tally[(int) $r->user_id][$cc] = ($tally[(int) $r->user_id][$cc] ?? 0) + (int) $r->c; + } + } + + $out = []; + foreach ($tally as $uid => $counts) { + arsort($counts); + $out[$uid] = array_key_first($counts); + } + + // Fallback to users.nationality for anyone still unresolved. + $missing = array_values(array_diff($userIds, array_keys($out))); + if ($missing) { + $nats = static::whereIn('id', $missing) + ->whereNotNull('nationality')->where('nationality', '!=', '') + ->pluck('nationality', 'id'); + foreach ($nats as $uid => $cc) $out[(int) $uid] = strtoupper($cc); + } + return $out; + } + public static function generateUniqueUsername(string $name): string { $base = substr(Str::slug(trim($name)), 0, 18); diff --git a/app/Services/LlmLyricsService.php b/app/Services/LlmLyricsService.php index 704a400..320f629 100644 --- a/app/Services/LlmLyricsService.php +++ b/app/Services/LlmLyricsService.php @@ -191,16 +191,176 @@ class LlmLyricsService } + /** + * Translate arbitrary text (e.g. a user comment) to a target language. + * Returns the translated string, or '' on any failure / when disabled. + */ + public function translate(string $text, string $targetLang = 'en', ?string $sourceLangHint = null): string + { + $text = trim($text); + if ($text === '' || ! $this->isEnabled()) return ''; + + $provider = $this->activeProvider(); + $targetLang = preg_replace('/[^a-zA-Z-]/', '', $targetLang) ?: 'en'; + $sourceLangHint = $sourceLangHint ? (preg_replace('/[^a-zA-Z-]/', '', $sourceLangHint) ?: null) : null; + $cacheKey = 'llm_translate_v4:' . ($provider['id'] ?? '') . ':' . $targetLang . ':' . ($sourceLangHint ?? '-') . ':' . sha1($text); + $cached = Cache::get($cacheKey); + if (is_string($cached) && $cached !== '') return $cached; + + $hint = $sourceLangHint + ? "The author's written language is \"{$sourceLangHint}\" (ISO-639). Assume the text is in that language unless it is OBVIOUSLY in a different language (e.g. uses a different script).\n" + : ''; + + $prompt = "Translate the following text into the language with ISO-639 code \"{$targetLang}\".\n" + . $hint + . "Rules:\n" + . " - Output ONLY the translated text. No prose, no quotes, no language label, no notes.\n" + . " - Translate EVERY word of the source language, including short exclamations,\n" + . " repeated words, and words that happen to also look like a name in the target\n" + . " language. Example: Portuguese \"Linda, linda, linda\" → \"Beautiful, beautiful,\n" + . " beautiful\" (NOT the English name \"Linda\"). Spanish \"hola\" → \"hello\".\n" + . " - A word is a proper noun ONLY if clearly a person/place/brand name in context\n" + . " (e.g. \"My name is Linda\"). If it stands alone or is repeated as an exclamation,\n" + . " it is almost certainly a regular word — translate it.\n" + . " - If the text is already in the target language, repeat it unchanged.\n" + . " - Preserve emojis, @mentions (e.g. @alice), hashtags, URLs, and timestamp tokens\n" + . " like @1:23 or @01:23-02:45 EXACTLY as written.\n" + . " - Keep the same tone and line breaks.\n\n" + . "TEXT:\n" . $text; + + // Translation is deterministic — temp 0. If the model echoes the input + // (common when a word happens to also exist as a name in the target + // language, e.g. Portuguese "Linda" → English "Linda"), retry once + // with a stronger directive. If the retry also echoes, we return it. + // Comments cap at 1000 chars → 400 tokens is plenty of headroom and + // keeps the model from drifting into long completions on tiny inputs. + $budget = max(128, min(1024, (int) ceil(mb_strlen($text) * 1.5) + 64)); + $raw = $this->postProcess($this->call($prompt, $budget, 0.0)); + $needsRetry = $sourceLangHint + && $sourceLangHint !== $targetLang + && self::looksLikeEcho($text, $raw); + if ($needsRetry) { + $retryPrompt = $prompt + . "\n\nIMPORTANT: Your previous attempt was identical to the source. The source is\n" + . "DEFINITELY in \"{$sourceLangHint}\", not in \"{$targetLang}\". Translate every word."; + $retry = $this->postProcess($this->call($retryPrompt, $budget, 0.0)); + if ($retry !== '' && ! self::looksLikeEcho($text, $retry)) $raw = $retry; + } + if ($raw === '') return ''; + + Cache::put($cacheKey, $raw, now()->addDays(30)); + return $raw; + } + + private function postProcess(string $raw): string + { + $raw = trim($raw); + $raw = trim(preg_replace('/^```[a-zA-Z]*\s*|\s*```$/m', '', $raw)); + return $raw; + } + + /** Normalised equality: ignore case, whitespace, punctuation, emojis. */ + private static function looksLikeEcho(string $src, string $out): bool + { + if ($out === '') return false; + $norm = fn (string $s) => mb_strtolower(preg_replace('/[^\p{L}\p{N}]+/u', '', $s) ?? ''); + $a = $norm($src); $b = $norm($out); + return $a !== '' && $a === $b; + } + + /** + * Translate a sequence of lines, preserving the one-to-one mapping so the + * caller can render them side-by-side with the originals. Empty entries + * are kept as empty strings. Returns an array of the same length as $lines + * (or [] on any failure / when disabled). + */ + public function translateLines(array $lines, string $targetLang = 'en', ?string $sourceLangHint = null): array + { + if (! $lines || ! $this->isEnabled()) return []; + $targetLang = preg_replace('/[^a-zA-Z-]/', '', $targetLang) ?: 'en'; + $sourceLangHint = $sourceLangHint ? (preg_replace('/[^a-zA-Z-]/', '', $sourceLangHint) ?: null) : null; + + $provider = $this->activeProvider(); + $cacheKey = 'llm_translate_lines_v2:' . ($provider['id'] ?? '') . ':' . $targetLang . ':' . ($sourceLangHint ?? '-') . ':' . sha1(json_encode($lines)); + $cached = Cache::get($cacheKey); + if (is_array($cached) && count($cached) === count($lines)) return $cached; + + // Index non-empty lines so empty separators are preserved verbatim. + $toTranslate = []; + foreach ($lines as $i => $l) { + if (trim((string) $l) !== '') $toTranslate[$i] = (string) $l; + } + if (! $toTranslate) { + Cache::put($cacheKey, $lines, now()->addDays(30)); + return $lines; + } + + $numbered = []; + foreach ($toTranslate as $i => $l) $numbered[] = $i . "\t" . $l; + + $hint = $sourceLangHint + ? "The author's written language is \"{$sourceLangHint}\" (ISO-639). Assume the lines are in that language unless they are OBVIOUSLY in a different language (different script).\n" + : ''; + + $prompt = "Translate each of the following numbered lines into the language with ISO-639 code \"{$targetLang}\".\n" + . $hint + . "Rules:\n" + . " - Output a JSON object mapping each original index (as string) to its translated line.\n" + . " - Keep ONE output line per input line — do NOT merge, split, re-order, or drop any.\n" + . " - Translate every word including short exclamations and repeated words that happen\n" + . " to look like a name in the target language (Portuguese \"Linda\" → \"Beautiful\").\n" + . " - If a line is already in the target language, repeat it unchanged.\n" + . " - Preserve emojis, @mentions, hashtags, URLs, and timestamp tokens like @1:23 EXACTLY.\n" + . " - Respond with ONLY the JSON object. No prose, no markdown, no code fence.\n\n" + . "LINES (index TAB text):\n" . implode("\n", $numbered); + + $raw = $this->postProcess($this->call($prompt, 8192, 0.0)); + $obj = json_decode($raw, true); + if (! is_array($obj)) return []; + + $out = $lines; + $echoCount = 0; + $translatedCount = 0; + foreach ($toTranslate as $i => $src) { + $v = $obj[(string) $i] ?? ($obj[$i] ?? null); + if (is_string($v) && trim($v) !== '') { + $out[$i] = $v; + $translatedCount++; + if ($sourceLangHint && $sourceLangHint !== $targetLang && self::looksLikeEcho($src, $v)) { + $echoCount++; + } + } + } + // If > 30% of the translated lines just echo the source, nudge the model. + if ($sourceLangHint && $sourceLangHint !== $targetLang + && $translatedCount > 0 && $echoCount / $translatedCount > 0.3) { + $retryPrompt = $prompt . "\n\nIMPORTANT: Your previous attempt echoed the source for many lines. " + . "The source is DEFINITELY in \"{$sourceLangHint}\", not in \"{$targetLang}\". Translate every word."; + $raw2 = $this->postProcess($this->call($retryPrompt, 8192, 0.0)); + $obj2 = json_decode($raw2, true); + if (is_array($obj2)) { + foreach ($toTranslate as $i => $src) { + $v = $obj2[(string) $i] ?? ($obj2[$i] ?? null); + if (is_string($v) && trim($v) !== '' && ! self::looksLikeEcho($src, $v)) { + $out[$i] = $v; + } + } + } + } + Cache::put($cacheKey, $out, now()->addDays(30)); + return $out; + } + /** Dispatch to the active provider's adapter. */ - private function call(string $prompt, int $maxTokens): string + private function call(string $prompt, int $maxTokens, float $temperature = 0.2): string { $p = $this->activeProvider(); if (! $p) return ''; try { return match ($p['kind']) { - 'anthropic' => $this->callAnthropic($p, $prompt, $maxTokens), - 'openai' => $this->callOpenAI($p, $prompt, $maxTokens), - default => $this->callOllama($p, $prompt, $maxTokens), + 'anthropic' => $this->callAnthropic($p, $prompt, $maxTokens, $temperature), + 'openai' => $this->callOpenAI($p, $prompt, $maxTokens, $temperature), + default => $this->callOllama($p, $prompt, $maxTokens, $temperature), }; } catch (\Throwable $e) { Log::error('LLM call failed: ' . $e->getMessage(), ['provider' => $p['name'] ?? '?']); @@ -208,24 +368,37 @@ class LlmLyricsService } } - private function callOllama(array $p, string $prompt, int $maxTokens): string + private function callOllama(array $p, string $prompt, int $maxTokens, float $temperature = 0.2): string { $endpoint = rtrim((string) ($p['endpoint'] ?? 'http://localhost:11434'), '/'); + // Qwen3-style "thinking" models respect /no_think to skip the hidden + // reasoning phase. Without it they can exhaust num_predict inside + // the thinking block and emit empty content. + $taggedPrompt = $prompt . "\n\n/no_think"; $resp = Http::timeout(180)->acceptJson()->post($endpoint . '/api/chat', [ - 'model' => $p['model'], - 'messages' => [['role' => 'user', 'content' => $prompt]], - 'stream' => false, - 'options' => ['num_predict' => $maxTokens, 'temperature' => 0.2], + 'model' => $p['model'], + 'messages' => [['role' => 'user', 'content' => $taggedPrompt]], + 'stream' => false, + 'think' => false, + // Keep the model resident for an hour so follow-up requests skip + // the cold-start reload (default is 5 minutes). Dramatically + // reduces per-click latency for comment/description translation. + 'keep_alive' => '1h', + 'options' => ['num_predict' => $maxTokens, 'temperature' => $temperature], ]); if (! $resp->successful()) { Log::warning('Ollama API error', ['status' => $resp->status(), 'body' => substr($resp->body(), 0, 500)]); return ''; } $j = $resp->json(); - return (string) ($j['message']['content'] ?? ''); + $content = (string) ($j['message']['content'] ?? ''); + // Some thinking models still ignore the flag and leak … + // tags into content. Strip them. + $content = preg_replace('/.*?<\/think>\s*/is', '', $content) ?? $content; + return trim($content); } - private function callAnthropic(array $p, string $prompt, int $maxTokens): string + private function callAnthropic(array $p, string $prompt, int $maxTokens, float $temperature = 0.2): string { $endpoint = rtrim((string) ($p['endpoint'] ?? 'https://api.anthropic.com'), '/'); $resp = Http::timeout(120)->withHeaders([ @@ -233,9 +406,10 @@ class LlmLyricsService 'anthropic-version' => '2023-06-01', 'content-type' => 'application/json', ])->post($endpoint . '/v1/messages', [ - 'model' => $p['model'], - 'max_tokens' => $maxTokens, - 'messages' => [['role' => 'user', 'content' => $prompt]], + 'model' => $p['model'], + 'max_tokens' => $maxTokens, + 'temperature' => $temperature, + 'messages' => [['role' => 'user', 'content' => $prompt]], ]); if (! $resp->successful()) { Log::warning('Anthropic API error', ['status' => $resp->status(), 'body' => substr($resp->body(), 0, 500)]); @@ -245,7 +419,7 @@ class LlmLyricsService return (string) ($j['content'][0]['text'] ?? ''); } - private function callOpenAI(array $p, string $prompt, int $maxTokens): string + private function callOpenAI(array $p, string $prompt, int $maxTokens, float $temperature = 0.2): string { $endpoint = rtrim((string) ($p['endpoint'] ?? 'https://api.openai.com'), '/'); $resp = Http::timeout(120)->withToken((string) $p['api_key']) @@ -254,7 +428,7 @@ class LlmLyricsService 'model' => $p['model'], 'messages' => [['role' => 'user', 'content' => $prompt]], 'max_tokens' => $maxTokens, - 'temperature' => 0.2, + 'temperature' => $temperature, ]); if (! $resp->successful()) { Log::warning('OpenAI API error', ['status' => $resp->status(), 'body' => substr($resp->body(), 0, 500)]); diff --git a/resources/views/components/video-comments.blade.php b/resources/views/components/video-comments.blade.php index 15c69e4..8319552 100644 --- a/resources/views/components/video-comments.blade.php +++ b/resources/views/components/video-comments.blade.php @@ -4,6 +4,12 @@ use App\Models\CommentLike; $currentUserId = Auth::id() ?? 0; + $llmEnabled = app(\App\Services\LlmLyricsService::class)->isEnabled(); + + // Flag per commenter: most-frequent access country, falling back to the + // user's nationality. Batched so the comments list stays a single query. + $commenterIds = $video->comments()->pluck('user_id')->filter()->unique()->values()->all(); + $commenterCountry = \App\Models\User::topCountriesFor($commenterIds); $topComments = $video->comments()->whereNull('parent_id')->with('user', 'replies.user')->latest()->get(); $commentCount = $topComments->sum(fn($c) => 1 + $c->replies->count()); @@ -89,11 +95,16 @@
+ @php $cFlag = $commenterCountry[$comment->user_id] ?? null; @endphp + @if($cFlag)@endif {{ $comment->user->name ?? 'User' }} {{ ytcTime($comment->created_at) }}
{{ $comment->body }}
+ @if($llmEnabled) + + @endif {{-- Edit form --}} @if($isOwn) @@ -120,6 +131,12 @@ @auth @endauth + @if($llmEnabled) + + @endif @if($isOwn)
+ @if($llmEnabled) + + @endif @if($replyIsOwn)
` : ''; const replyBtn = YTC.userId ? `` : ''; + const translateBtn = YTC.llmEnabled ? ` + ` : ''; + const translationPanel = YTC.llmEnabled ? `` : ''; + const flagEl = YTC.meCountry ? `` : ''; const div = document.createElement('div'); div.className = 'ytc-comment' + (isReply ? ' ytc-reply' : ''); @@ -1143,10 +1313,12 @@ function buildCommentEl(c, isReply) {
+ ${flagEl} ${name} ${time}
${body}
+ ${translationPanel} ${editForm}
${replyBtn} + ${translateBtn} ${moreMenu}
${replyForm} diff --git a/resources/views/videos/partials/description-box.blade.php b/resources/views/videos/partials/description-box.blade.php index fe70426..8db546d 100644 --- a/resources/views/videos/partials/description-box.blade.php +++ b/resources/views/videos/partials/description-box.blade.php @@ -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)
@@ -51,6 +77,9 @@
+ @if($aboutPrepend !== null) + {!! $aboutPrepend !!} + @endif
{{ number_format($video->view_count) }} views • @@ -60,11 +89,21 @@ @if(isset($descriptionSlot)) {!! $descriptionSlot !!} @elseif($renderedDescription !== '') -
{!! $renderedDescription !!}
+
+
{!! $renderedDescription !!}
+
@else

No description added.

@endif + @if($llmEnabledVdb) + + @endif
@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); } +}); @endif diff --git a/routes/web.php b/routes/web.php index 0b31791..708605c 100644 --- a/routes/web.php +++ b/routes/web.php @@ -58,6 +58,7 @@ Route::get('/videos/{video}/lyrics/progress', [VideoController::class, 'lyricsPr Route::post('/videos/{video}/lyrics/save', [VideoController::class, 'saveLyrics'])->name('videos.lyrics.save')->middleware(['auth', 'throttle:30,1']); Route::delete('/videos/{video}/lyrics', [VideoController::class, 'deleteLyrics'])->name('videos.lyrics.delete')->middleware(['auth', 'throttle:20,1']); Route::post('/videos/{video}/share', [VideoController::class, 'recordShare'])->name('videos.recordShare'); +Route::post('/videos/{video}/description/translate', [VideoController::class, 'translateDescription'])->name('videos.description.translate')->middleware('throttle:30,1'); Route::post('/videos/{video}/share/email', [VideoController::class, 'shareByEmail'])->name('videos.shareEmail')->middleware(['auth', 'throttle:10,1']); Route::post('/videos/{video}/share/members', [VideoController::class, 'shareWithMembers'])->name('videos.shareMembers')->middleware(['auth', 'throttle:20,1']); Route::get('/videos/{video}/og-image', [VideoController::class, 'ogImage'])->name('videos.ogImage'); @@ -137,6 +138,7 @@ Route::post('/profile-visits/{user}', [UserController::class, 'recordProfileVisi // Comment routes Route::get('/videos/{video}/comments', [CommentController::class, 'index'])->name('comments.index'); +Route::post('/comments/{comment}/translate', [CommentController::class, 'translate'])->name('comments.translate'); Route::middleware(['auth', 'verified'])->group(function () { Route::post('/videos/{video}/comments', [CommentController::class, 'store'])->name('comments.store'); Route::put('/comments/{comment}', [CommentController::class, 'update'])->name('comments.update');