feat: add YouTube video and playlist support in document collection with enhanced URL handling

This commit is contained in:
DESKTOP-RTLN3BA\$punk 2026-03-09 16:07:54 -07:00
parent e481415655
commit c6fc4edbc2
10 changed files with 445 additions and 100 deletions

View file

@ -42,6 +42,7 @@ from .search_spaces_routes import router as search_spaces_router
from .slack_add_connector_route import router as slack_add_connector_router from .slack_add_connector_route import router as slack_add_connector_router
from .surfsense_docs_routes import router as surfsense_docs_router from .surfsense_docs_routes import router as surfsense_docs_router
from .teams_add_connector_route import router as teams_add_connector_router from .teams_add_connector_route import router as teams_add_connector_router
from .youtube_routes import router as youtube_router
router = APIRouter() router = APIRouter()
@ -79,3 +80,4 @@ router.include_router(notifications_router) # Notifications with Electric SQL s
router.include_router(composio_router) # Composio OAuth and toolkit management router.include_router(composio_router) # Composio OAuth and toolkit management
router.include_router(public_chat_router) # Public chat sharing and cloning router.include_router(public_chat_router) # Public chat sharing and cloning
router.include_router(incentive_tasks_router) # Incentive tasks for earning free pages router.include_router(incentive_tasks_router) # Incentive tasks for earning free pages
router.include_router(youtube_router) # YouTube playlist resolution

View file

@ -0,0 +1,205 @@
"""YouTube utility routes (playlist resolution)."""
import json
import logging
import re
import aiohttp
from fake_useragent import UserAgent
from fastapi import APIRouter, Depends, HTTPException, Query
from app.db import User
from app.users import current_active_user
from app.utils.proxy_config import get_requests_proxies
router = APIRouter()
logger = logging.getLogger(__name__)
_PLAYLIST_ID_RE = re.compile(r"[?&]list=([\w-]+)")
_INNERTUBE_API_URL = "https://www.youtube.com/youtubei/v1/browse"
_INNERTUBE_CLIENT = {
"clientName": "WEB",
"clientVersion": "2.20240313.05.00",
"hl": "en",
"gl": "US",
}
@router.get("/youtube/playlist-videos")
async def get_playlist_videos(
url: str = Query(..., description="YouTube playlist URL"),
_user: User = Depends(current_active_user),
):
"""Resolve a YouTube playlist URL into individual video URLs."""
match = _PLAYLIST_ID_RE.search(url)
if not match:
raise HTTPException(status_code=400, detail="Invalid YouTube playlist URL")
playlist_id = match.group(1)
try:
video_ids = await _fetch_playlist_via_innertube(playlist_id)
if not video_ids:
video_ids = await _fetch_playlist_via_html(playlist_id)
if not video_ids:
raise HTTPException(
status_code=404,
detail="No videos found in the playlist. It may be private or empty.",
)
video_urls = [
f"https://www.youtube.com/watch?v={vid}" for vid in video_ids
]
return {"video_urls": video_urls, "count": len(video_urls)}
except HTTPException:
raise
except Exception as e:
logger.error("Error resolving playlist %s: %s", url, e)
raise HTTPException(
status_code=500,
detail=f"Failed to resolve playlist: {e!s}",
) from e
async def _fetch_playlist_via_innertube(playlist_id: str) -> list[str]:
"""Fetch playlist videos using YouTube's innertube API (no cookies needed)."""
payload = {
"context": {"client": _INNERTUBE_CLIENT},
"browseId": f"VL{playlist_id}",
}
proxies = get_requests_proxies()
try:
async with aiohttp.ClientSession() as session, session.post(
_INNERTUBE_API_URL,
json=payload,
headers={"Content-Type": "application/json"},
proxy=proxies["http"] if proxies else None,
) as response:
if response.status != 200:
logger.warning(
"Innertube API returned %d for playlist %s",
response.status,
playlist_id,
)
return []
data = await response.json()
return _extract_playlist_video_ids(data)
except Exception as e:
logger.warning("Innertube API failed for playlist %s: %s", playlist_id, e)
return []
async def _fetch_playlist_via_html(playlist_id: str) -> list[str]:
"""Fallback: scrape playlist page HTML with consent cookies set."""
ua = UserAgent()
headers = {
"User-Agent": ua.random,
"Accept-Language": "en-US,en;q=0.9",
}
cookies = {
"CONSENT": "PENDING+999",
"SOCS": "CAISNQgDEitib3FfaWRlbnRpdHlmcm9udGVuZHVpc2VydmVyXzIwMjMwODI5LjA3X3AxGgJlbiADGgYIgOa_pgY",
}
proxies = get_requests_proxies()
playlist_url = f"https://www.youtube.com/playlist?list={playlist_id}"
try:
async with (
aiohttp.ClientSession(cookies=cookies) as session,
session.get(
playlist_url,
headers=headers,
proxy=proxies["http"] if proxies else None,
) as response,
):
if response.status != 200:
logger.warning(
"HTML fallback returned %d for playlist %s",
response.status,
playlist_id,
)
return []
html = await response.text()
yt_data = _extract_yt_initial_data(html)
if not yt_data:
logger.warning(
"Could not find ytInitialData in HTML for playlist %s",
playlist_id,
)
return []
return _extract_playlist_video_ids(yt_data)
except Exception as e:
logger.warning("HTML fallback failed for playlist %s: %s", playlist_id, e)
return []
def _extract_yt_initial_data(html: str) -> dict | None:
"""Extract the ytInitialData JSON object embedded in a YouTube page."""
patterns = [
re.compile(r"var\s+ytInitialData\s*=\s*"),
re.compile(r'window\["ytInitialData"\]\s*=\s*'),
]
start = -1
for pattern in patterns:
match = pattern.search(html)
if match:
start = match.end()
break
if start == -1:
return None
depth = 0
i = start
while i < len(html):
ch = html[i]
if ch == "{":
depth += 1
elif ch == "}":
depth -= 1
if depth == 0:
break
elif ch == '"':
i += 1
while i < len(html) and html[i] != '"':
if html[i] == "\\":
i += 1
i += 1
i += 1
try:
return json.loads(html[start : i + 1])
except (json.JSONDecodeError, IndexError):
return None
def _extract_playlist_video_ids(data: dict) -> list[str]:
"""Walk the data tree and collect videoIds from playlistVideoRenderer nodes."""
video_ids: list[str] = []
seen: set[str] = set()
def _walk(obj: object) -> None:
if isinstance(obj, dict):
if "playlistVideoRenderer" in obj:
vid = obj["playlistVideoRenderer"].get("videoId")
if vid and vid not in seen:
seen.add(vid)
video_ids.append(vid)
else:
for v in obj.values():
_walk(v)
elif isinstance(obj, list):
for item in obj:
_walk(item)
_walk(data)
return video_ids

View file

@ -2,9 +2,9 @@
import { TagInput, type Tag as TagType } from "emblor"; import { TagInput, type Tag as TagType } from "emblor";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { ArrowLeft } from "lucide-react"; import { ArrowLeft, Info } from "lucide-react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { type FC, useState } from "react"; import { type FC, useCallback, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { createDocumentMutationAtom } from "@/atoms/documents/document-mutation.atoms"; import { createDocumentMutationAtom } from "@/atoms/documents/document-mutation.atoms";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@ -12,9 +12,29 @@ import { Label } from "@/components/ui/label";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { EnumConnectorName } from "@/contracts/enums/connector"; import { EnumConnectorName } from "@/contracts/enums/connector";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import { baseApiService } from "@/lib/apis/base-api.service";
const youtubeRegex = const YOUTUBE_VIDEO_URL_RE =
/^(https:\/\/)?(www\.)?(youtube\.com\/watch\?v=|youtu\.be\/)([a-zA-Z0-9_-]{11})$/; /(?:https?:\/\/)?(?:www\.)?(?:youtube\.com\/watch\?[^\s]*v=[\w-]{11}|youtu\.be\/[\w-]{11})[^\s]*/;
const YOUTUBE_PLAYLIST_URL_RE =
/(?:https?:\/\/)?(?:www\.)?youtube\.com\/[^\s]*[?&]list=[\w-]+[^\s]*/;
const YOUTUBE_ANY_URL_RE =
/(?:https?:\/\/)?(?:www\.)?(?:youtube\.com\/(?:watch[^\s]*|playlist[^\s]*)|youtu\.be\/[\w-]+[^\s]*)/gi;
function isYoutubeVideoUrl(url: string): boolean {
return YOUTUBE_VIDEO_URL_RE.test(url.trim());
}
function isYoutubePlaylistUrl(url: string): boolean {
return YOUTUBE_PLAYLIST_URL_RE.test(url.trim());
}
function extractYoutubeUrls(text: string): string[] {
const matches = text.match(YOUTUBE_ANY_URL_RE);
return matches ? [...new Set(matches)] : [];
}
interface YouTubeCrawlerViewProps { interface YouTubeCrawlerViewProps {
searchSpaceId: string; searchSpaceId: string;
@ -26,27 +46,107 @@ export const YouTubeCrawlerView: FC<YouTubeCrawlerViewProps> = ({ searchSpaceId,
const [videoTags, setVideoTags] = useState<TagType[]>([]); const [videoTags, setVideoTags] = useState<TagType[]>([]);
const [activeTagIndex, setActiveTagIndex] = useState<number | null>(null); const [activeTagIndex, setActiveTagIndex] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [isFetchingPlaylist, setIsFetchingPlaylist] = useState(false);
// Use the createDocumentMutationAtom
const [createDocumentMutation] = useAtom(createDocumentMutationAtom); const [createDocumentMutation] = useAtom(createDocumentMutationAtom);
const { mutate: createYouTubeDocument, isPending: isSubmitting } = createDocumentMutation; const { mutate: createYouTubeDocument, isPending: isSubmitting } = createDocumentMutation;
const isValidYoutubeUrl = (url: string): boolean => {
return youtubeRegex.test(url);
};
const extractVideoId = (url: string): string | null => { const extractVideoId = (url: string): string | null => {
const match = url.match(/(?:youtube\.com\/watch\?v=|youtu\.be\/)([a-zA-Z0-9_-]{11})/); const match = url.match(/(?:[?&]v=|youtu\.be\/)([\w-]{11})/);
return match ? match[1] : null; return match ? match[1] : null;
}; };
const resolvePlaylist = useCallback(
async (url: string) => {
setIsFetchingPlaylist(true);
toast(t("resolving_playlist_toast"), {
description: t("resolving_playlist_toast_desc"),
});
try {
const response = (await baseApiService.get(
`/api/v1/youtube/playlist-videos?url=${encodeURIComponent(url)}`
)) as { video_urls: string[]; count: number };
const resolvedUrls: string[] = response.video_urls ?? [];
setVideoTags((prev) => {
const existingTexts = new Set(prev.map((tag) => tag.text));
const newTags = resolvedUrls
.filter((vUrl) => !existingTexts.has(vUrl))
.map((vUrl) => ({
id: `${Date.now()}-${Math.random()}`,
text: vUrl,
}));
return newTags.length > 0 ? [...prev, ...newTags] : prev;
});
toast(t("playlist_resolved_toast"), {
description: t("playlist_resolved_toast_desc", { count: resolvedUrls.length }),
});
} catch (err) {
const message = err instanceof Error ? err.message : t("error_generic");
toast(t("playlist_error_toast"), { description: message });
} finally {
setIsFetchingPlaylist(false);
}
},
[t]
);
const handlePaste = useCallback(
async (e: React.ClipboardEvent<HTMLDivElement>) => {
const text = e.clipboardData.getData("text/plain");
if (!text) return;
const urls = extractYoutubeUrls(text);
if (urls.length === 0) return;
e.preventDefault();
const playlistUrls: string[] = [];
const videoUrls: string[] = [];
for (const url of urls) {
if (isYoutubePlaylistUrl(url)) {
playlistUrls.push(url);
} else if (isYoutubeVideoUrl(url)) {
videoUrls.push(url);
}
}
if (videoUrls.length > 0) {
setVideoTags((prev) => {
const existingTexts = new Set(prev.map((tag) => tag.text));
const newTags = videoUrls
.filter((url) => !existingTexts.has(url.trim()))
.map((url) => ({
id: `${Date.now()}-${Math.random()}`,
text: url.trim(),
}));
if (newTags.length === 0) {
toast(t("duplicate_url_toast"), {
description: t("duplicate_url_toast_desc"),
});
}
return newTags.length > 0 ? [...prev, ...newTags] : prev;
});
}
for (const url of playlistUrls) {
await resolvePlaylist(url);
}
},
[resolvePlaylist, t]
);
const handleSubmit = async () => { const handleSubmit = async () => {
if (videoTags.length === 0) { if (videoTags.length === 0) {
setError(t("error_no_video")); setError(t("error_no_video"));
return; return;
} }
const invalidUrls = videoTags.filter((tag) => !isValidYoutubeUrl(tag.text)); const invalidUrls = videoTags.filter((tag) => !isYoutubeVideoUrl(tag.text));
if (invalidUrls.length > 0) { if (invalidUrls.length > 0) {
setError(t("error_invalid_urls", { urls: invalidUrls.map((tag) => tag.text).join(", ") })); setError(t("error_invalid_urls", { urls: invalidUrls.map((tag) => tag.text).join(", ") }));
return; return;
@ -60,7 +160,6 @@ export const YouTubeCrawlerView: FC<YouTubeCrawlerViewProps> = ({ searchSpaceId,
const videoUrls = videoTags.map((tag) => tag.text); const videoUrls = videoTags.map((tag) => tag.text);
// Use the mutation to create YouTube documents
createYouTubeDocument( createYouTubeDocument(
{ {
document_type: "YOUTUBE_VIDEO", document_type: "YOUTUBE_VIDEO",
@ -86,7 +185,12 @@ export const YouTubeCrawlerView: FC<YouTubeCrawlerViewProps> = ({ searchSpaceId,
}; };
const handleAddTag = (text: string) => { const handleAddTag = (text: string) => {
if (!isValidYoutubeUrl(text)) { if (isYoutubePlaylistUrl(text)) {
resolvePlaylist(text);
return;
}
if (!isYoutubeVideoUrl(text)) {
toast(t("invalid_url_toast"), { toast(t("invalid_url_toast"), {
description: t("invalid_url_toast_desc"), description: t("invalid_url_toast_desc"),
}); });
@ -111,7 +215,7 @@ export const YouTubeCrawlerView: FC<YouTubeCrawlerViewProps> = ({ searchSpaceId,
return ( return (
<div className="flex-1 flex flex-col min-h-0 overflow-hidden"> <div className="flex-1 flex flex-col min-h-0 overflow-hidden">
{/* Header */} {/* Header */}
<div className="flex-shrink-0 px-6 sm:px-12 pt-8 sm:pt-10"> <div className="shrink-0 px-6 sm:px-12 pt-8 sm:pt-10">
<button <button
type="button" type="button"
onClick={onBack} onClick={onBack}
@ -139,31 +243,48 @@ export const YouTubeCrawlerView: FC<YouTubeCrawlerViewProps> = ({ searchSpaceId,
<Label htmlFor="video-input" className="text-sm sm:text-base"> <Label htmlFor="video-input" className="text-sm sm:text-base">
{t("label")} {t("label")}
</Label> </Label>
<TagInput {/* Wrapper intercepts paste events for auto-detection of YouTube URLs */}
id="video-input" <div onPasteCapture={handlePaste}>
tags={videoTags} <TagInput
setTags={setVideoTags} id="video-input"
placeholder={t("placeholder")} tags={videoTags}
onAddTag={handleAddTag} setTags={setVideoTags}
styleClasses={{ placeholder={t("placeholder")}
inlineTagsContainer: onAddTag={handleAddTag}
"border border-slate-400/20 rounded-lg bg-muted/50 shadow-sm shadow-black/5 transition-shadow focus-within:border-slate-400/40 focus-within:outline-none focus-within:ring-[3px] focus-within:ring-ring/20 p-1 gap-1", styleClasses={{
input: inlineTagsContainer:
"w-full min-w-[80px] focus-visible:outline-none shadow-none px-2 h-7 text-foreground/90 placeholder:text-muted-foreground bg-transparent", "border border-slate-400/20 rounded-lg bg-muted/50 shadow-sm shadow-black/5 transition-shadow focus-within:border-slate-400/40 focus-within:outline-none focus-within:ring-[3px] focus-within:ring-ring/20 p-1 gap-1",
tag: { input:
body: "h-7 relative bg-background border border-input hover:bg-background rounded-md font-medium text-xs ps-2 pe-7 flex", "w-full min-w-[80px] focus-visible:outline-none shadow-none px-2 h-7 text-foreground/90 placeholder:text-muted-foreground bg-transparent",
closeButton: tag: {
"absolute -inset-y-px -end-px p-0 rounded-e-lg flex size-7 transition-colors outline-0 focus-visible:outline focus-visible:outline-2 focus-visible:outline-ring/70 text-muted-foreground/80 hover:text-foreground", body: "h-7 relative bg-background border border-input hover:bg-background rounded-md font-medium text-xs ps-2 pe-7 flex",
}, closeButton:
}} "absolute -inset-y-px -end-px p-0 rounded-e-lg flex size-7 transition-colors outline-0 focus-visible:outline focus-visible:outline-2 focus-visible:outline-ring/70 text-muted-foreground/80 hover:text-foreground",
activeTagIndex={activeTagIndex} },
setActiveTagIndex={setActiveTagIndex} }}
/> activeTagIndex={activeTagIndex}
setActiveTagIndex={setActiveTagIndex}
/>
</div>
<p className="text-xs text-muted-foreground mt-1">{t("hint")}</p> <p className="text-xs text-muted-foreground mt-1">{t("hint")}</p>
</div> </div>
{isFetchingPlaylist && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Spinner size="sm" />
<span>{t("resolving_playlist")}</span>
</div>
)}
{error && <div className="text-sm text-red-500 mt-2">{error}</div>} {error && <div className="text-sm text-red-500 mt-2">{error}</div>}
<div className="flex items-start gap-3 rounded-lg border border-blue-200/50 bg-blue-50/50 dark:border-blue-500/20 dark:bg-blue-950/20 p-4 text-sm">
<Info className="size-4 mt-0.5 shrink-0 text-blue-600 dark:text-blue-400" />
<p className="text-muted-foreground">
{t("chat_tip")}
</p>
</div>
<div className="bg-muted/50 rounded-lg p-4 text-sm"> <div className="bg-muted/50 rounded-lg p-4 text-sm">
<h4 className="font-medium mb-2">{t("tips_title")}</h4> <h4 className="font-medium mb-2">{t("tips_title")}</h4>
<ul className="list-disc pl-5 space-y-1 text-muted-foreground"> <ul className="list-disc pl-5 space-y-1 text-muted-foreground">
@ -171,14 +292,15 @@ export const YouTubeCrawlerView: FC<YouTubeCrawlerViewProps> = ({ searchSpaceId,
<li>{t("tip_2")}</li> <li>{t("tip_2")}</li>
<li>{t("tip_3")}</li> <li>{t("tip_3")}</li>
<li>{t("tip_4")}</li> <li>{t("tip_4")}</li>
<li>{t("tip_5")}</li>
</ul> </ul>
</div> </div>
{videoTags.length > 0 && ( {videoTags.length > 0 && videoTags.length <= 3 && (
<div className="mt-4 space-y-2"> <div className="mt-4 space-y-2">
<h4 className="font-medium">{t("preview")}:</h4> <h4 className="font-medium">{t("preview")}:</h4>
<div className="grid grid-cols-1 gap-3"> <div className="grid grid-cols-1 gap-3">
{videoTags.map((tag, _index) => { {videoTags.map((tag) => {
const videoId = extractVideoId(tag.text); const videoId = extractVideoId(tag.text);
return videoId ? ( return videoId ? (
<div <div
@ -203,18 +325,18 @@ export const YouTubeCrawlerView: FC<YouTubeCrawlerViewProps> = ({ searchSpaceId,
</div> </div>
{/* Fixed Footer - Action buttons */} {/* Fixed Footer - Action buttons */}
<div className="flex-shrink-0 flex items-center justify-between px-6 sm:px-12 py-6 bg-muted border-t border-border"> <div className="shrink-0 flex items-center justify-between px-6 sm:px-12 py-6 bg-muted border-t border-border">
<Button <Button
variant="ghost" variant="ghost"
onClick={onBack} onClick={onBack}
disabled={isSubmitting} disabled={isSubmitting || isFetchingPlaylist}
className="text-xs sm:text-sm" className="text-xs sm:text-sm"
> >
{t("cancel")} {t("cancel")}
</Button> </Button>
<Button <Button
onClick={handleSubmit} onClick={handleSubmit}
disabled={isSubmitting || videoTags.length === 0} disabled={isSubmitting || isFetchingPlaylist || videoTags.length === 0}
className="text-xs sm:text-sm min-w-[140px] disabled:opacity-50 disabled:cursor-not-allowed disabled:pointer-events-none" className="text-xs sm:text-sm min-w-[140px] disabled:opacity-50 disabled:cursor-not-allowed disabled:pointer-events-none"
> >
{isSubmitting ? ( {isSubmitting ? (

View file

@ -1,5 +1,4 @@
"use client"; "use client";
import { useFeatureFlagVariantKey } from "@posthog/react";
import { AnimatePresence, motion } from "motion/react"; import { AnimatePresence, motion } from "motion/react";
import Link from "next/link"; import Link from "next/link";
import type React from "react"; import type React from "react";
@ -47,8 +46,6 @@ function useIsDesktop(breakpoint = 1024) {
export function HeroSection() { export function HeroSection() {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const parentRef = useRef<HTMLDivElement>(null); const parentRef = useRef<HTMLDivElement>(null);
const heroVariant = useFeatureFlagVariantKey("notebooklm_superpowers_flag");
const isNotebookLMVariant = heroVariant === "superpowers";
const isDesktop = useIsDesktop(); const isDesktop = useIsDesktop();
return ( return (
@ -99,19 +96,11 @@ export function HeroSection() {
)} )}
<h2 className="relative z-50 mx-auto mb-4 mt-8 max-w-4xl text-balance text-center text-3xl font-semibold tracking-tight text-gray-700 md:text-7xl dark:text-neutral-300"> <h2 className="relative z-50 mx-auto mb-4 mt-8 max-w-4xl text-balance text-center text-3xl font-semibold tracking-tight text-gray-700 md:text-7xl dark:text-neutral-300">
{isNotebookLMVariant ? ( <div className="relative mx-auto inline-block w-max filter-[drop-shadow(0px_1px_3px_rgba(27,37,80,0.14))]">
<div className="relative mx-auto inline-block w-max filter-[drop-shadow(0px_1px_3px_rgba(27,37,80,0.14))]"> <div className="text-black [text-shadow:0_0_rgba(0,0,0,0.1)] dark:text-white">
<div className="text-black [text-shadow:0_0_rgba(0,0,0,0.1)] dark:text-white"> <Balancer>NotebookLM for Teams</Balancer>
<Balancer>NotebookLM with Superpowers</Balancer>
</div>
</div> </div>
) : ( </div>
<div className="relative mx-auto inline-block w-max filter-[drop-shadow(0px_1px_3px_rgba(27,37,80,0.14))]">
<div className="text-black [text-shadow:0_0_rgba(0,0,0,0.1)] dark:text-white">
<Balancer>NotebookLM for Teams</Balancer>
</div>
</div>
)}
</h2> </h2>
<p className="relative z-50 mx-auto mt-4 max-w-lg px-6 text-center text-sm leading-relaxed text-gray-600 sm:text-base sm:leading-relaxed md:max-w-xl md:text-lg md:leading-relaxed dark:text-gray-200"> <p className="relative z-50 mx-auto mt-4 max-w-lg px-6 text-center text-sm leading-relaxed text-gray-600 sm:text-base sm:leading-relaxed md:max-w-xl md:text-lg md:leading-relaxed dark:text-gray-200">
Connect any LLM to your internal knowledge sources and chat with it in real time alongside Connect any LLM to your internal knowledge sources and chat with it in real time alongside
@ -187,20 +176,6 @@ function GetStartedButton() {
); );
} }
function ContactSalesButton() {
return (
<motion.div whileHover={{ scale: 1.02, y: -2 }} whileTap={{ scale: 0.98 }}>
<Link
href="/contact"
//target="_blank"
rel="noopener noreferrer"
className="group relative z-20 flex h-11 w-full cursor-pointer items-center justify-center gap-2 rounded-xl bg-white px-6 py-2.5 text-sm font-semibold text-neutral-700 shadow-lg ring-1 ring-neutral-200/50 transition-shadow duration-300 hover:shadow-xl sm:w-56 dark:bg-neutral-900 dark:text-neutral-200 dark:ring-neutral-700/50"
>
Contact Sales
</Link>
</motion.div>
);
}
const BackgroundGrids = () => { const BackgroundGrids = () => {
return ( return (

View file

@ -131,7 +131,8 @@ export const createDocumentRequest = document
}); });
export const createDocumentResponse = z.object({ export const createDocumentResponse = z.object({
message: z.literal("Documents created successfully"), message: z.literal("Documents queued for background processing"),
status: z.literal("queued"),
}); });
/** /**

View file

@ -433,15 +433,17 @@
}, },
"add_youtube": { "add_youtube": {
"title": "Add YouTube Videos", "title": "Add YouTube Videos",
"subtitle": "Enter YouTube video URLs to add to your document collection", "subtitle": "Add YouTube videos or playlists to your knowledge base",
"label": "Enter YouTube Video URLs", "label": "YouTube Video or Playlist URLs",
"placeholder": "Enter a YouTube URL and press Enter", "placeholder": "Paste YouTube video or playlist URLs here",
"hint": "Add multiple YouTube URLs by pressing Enter after each one", "hint": "Just paste YouTube URLs and they're added automatically. You can also type a URL and press Enter.",
"tips_title": "Tips for adding YouTube videos:", "tips_title": "Tips for adding YouTube videos:",
"tip_1": "Use standard YouTube URLs (youtube.com/watch?v= or youtu.be/)", "tip_1": "Use standard YouTube URLs (youtube.com/watch?v= or youtu.be/)",
"tip_2": "Make sure videos are publicly accessible", "tip_2": "Make sure videos are publicly accessible",
"tip_3": "Supported formats: youtube.com/watch?v=VIDEO_ID or youtu.be/VIDEO_ID", "tip_3": "Supported formats: youtube.com/watch?v=VIDEO_ID or youtu.be/VIDEO_ID",
"tip_4": "Processing may take some time depending on video length", "tip_4": "Processing may take some time depending on video length",
"tip_5": "Paste a playlist URL (youtube.com/playlist?list=...) to add all its videos at once",
"chat_tip": "Want a quick summary without saving to your knowledge base? Just paste YouTube links directly into the chat instead.",
"preview": "Preview", "preview": "Preview",
"cancel": "Cancel", "cancel": "Cancel",
"submit": "Add", "submit": "Add",
@ -456,9 +458,15 @@
"error_toast_desc": "Error processing YouTube videos", "error_toast_desc": "Error processing YouTube videos",
"error_generic": "An error occurred while processing YouTube videos", "error_generic": "An error occurred while processing YouTube videos",
"invalid_url_toast": "Invalid YouTube URL", "invalid_url_toast": "Invalid YouTube URL",
"invalid_url_toast_desc": "Please enter a valid YouTube video URL", "invalid_url_toast_desc": "Please enter a valid YouTube video or playlist URL",
"duplicate_url_toast": "Duplicate URL", "duplicate_url_toast": "Duplicate URL",
"duplicate_url_toast_desc": "This YouTube video has already been added" "duplicate_url_toast_desc": "This YouTube video has already been added",
"resolving_playlist": "Resolving playlist videos...",
"resolving_playlist_toast": "Resolving Playlist",
"resolving_playlist_toast_desc": "Fetching video list from the playlist...",
"playlist_resolved_toast": "Playlist Resolved",
"playlist_resolved_toast_desc": "Added {count} videos from the playlist",
"playlist_error_toast": "Playlist Error"
}, },
"settings": { "settings": {
"title": "Settings", "title": "Settings",

View file

@ -433,15 +433,17 @@
}, },
"add_youtube": { "add_youtube": {
"title": "Agregar videos de YouTube", "title": "Agregar videos de YouTube",
"subtitle": "Ingresa URLs de videos de YouTube para agregar a tu colección de documentos", "subtitle": "Agrega videos o listas de reproducción de YouTube a tu base de conocimiento",
"label": "Ingresa URLs de videos de YouTube", "label": "URLs de videos o listas de reproducción de YouTube",
"placeholder": "Ingresa una URL de YouTube y presiona Enter", "placeholder": "Pega URLs de videos o listas de reproducción de YouTube aquí",
"hint": "Agrega múltiples URLs de YouTube presionando Enter después de cada una", "hint": "Solo pega URLs de YouTube y se agregan automáticamente. También puedes escribir una URL y presionar Enter.",
"tips_title": "Consejos para agregar videos de YouTube:", "tips_title": "Consejos para agregar videos de YouTube:",
"tip_1": "Usa URLs estándar de YouTube (youtube.com/watch?v= o youtu.be/)", "tip_1": "Usa URLs estándar de YouTube (youtube.com/watch?v= o youtu.be/)",
"tip_2": "Asegúrate de que los videos sean accesibles públicamente", "tip_2": "Asegúrate de que los videos sean accesibles públicamente",
"tip_3": "Formatos soportados: youtube.com/watch?v=VIDEO_ID o youtu.be/VIDEO_ID", "tip_3": "Formatos soportados: youtube.com/watch?v=VIDEO_ID o youtu.be/VIDEO_ID",
"tip_4": "El procesamiento puede tomar un tiempo dependiendo de la duración del video", "tip_4": "El procesamiento puede tomar un tiempo dependiendo de la duración del video",
"tip_5": "Pega una URL de lista de reproducción (youtube.com/playlist?list=...) para agregar todos sus videos a la vez",
"chat_tip": "¿Quieres un resumen rápido sin guardarlo en tu base de conocimiento? Solo pega los enlaces de YouTube directamente en el chat.",
"preview": "Vista previa", "preview": "Vista previa",
"cancel": "Cancelar", "cancel": "Cancelar",
"submit": "Agregar", "submit": "Agregar",
@ -456,9 +458,15 @@
"error_toast_desc": "Error al procesar videos de YouTube", "error_toast_desc": "Error al procesar videos de YouTube",
"error_generic": "Ocurrió un error al procesar videos de YouTube", "error_generic": "Ocurrió un error al procesar videos de YouTube",
"invalid_url_toast": "URL de YouTube inválida", "invalid_url_toast": "URL de YouTube inválida",
"invalid_url_toast_desc": "Por favor ingresa una URL válida de video de YouTube", "invalid_url_toast_desc": "Por favor ingresa una URL válida de video o lista de reproducción de YouTube",
"duplicate_url_toast": "URL duplicada", "duplicate_url_toast": "URL duplicada",
"duplicate_url_toast_desc": "Este video de YouTube ya ha sido agregado" "duplicate_url_toast_desc": "Este video de YouTube ya ha sido agregado",
"resolving_playlist": "Cargando videos de la lista de reproducción...",
"resolving_playlist_toast": "Cargando lista de reproducción",
"resolving_playlist_toast_desc": "Obteniendo la lista de videos de la lista de reproducción...",
"playlist_resolved_toast": "Lista de reproducción cargada",
"playlist_resolved_toast_desc": "Se agregaron {count} videos de la lista de reproducción",
"playlist_error_toast": "Error de lista de reproducción"
}, },
"settings": { "settings": {
"title": "Configuración", "title": "Configuración",

View file

@ -433,15 +433,17 @@
}, },
"add_youtube": { "add_youtube": {
"title": "YouTube वीडियो जोड़ें", "title": "YouTube वीडियो जोड़ें",
"subtitle": "अपने दस्तावेज़ संग्रह में जोड़ने के लिए YouTube वीडियो URL दर्ज करें", "subtitle": "अपने ज्ञान आधार में YouTube वीडियो या प्लेलिस्ट जोड़ें",
"label": "YouTube वीडियो URL दर्ज करें", "label": "YouTube वीडियो या प्लेलिस्ट URL",
"placeholder": "YouTube URL दर्ज करें और Enter दबाएं", "placeholder": "YouTube वीडियो या प्लेलिस्ट URL यहां पेस्ट करें",
"hint": "प्रत्येक के बाद Enter दबाकर कई YouTube URL जोड़ें", "hint": "बस YouTube URL पेस्ट करें और वे स्वचालित रूप से जुड़ जाएंगे। आप URL टाइप करके Enter भी दबा सकते हैं।",
"tips_title": "YouTube वीडियो जोड़ने के लिए सुझाव:", "tips_title": "YouTube वीडियो जोड़ने के लिए सुझाव:",
"tip_1": "मानक YouTube URL का उपयोग करें (youtube.com/watch?v= या youtu.be/)", "tip_1": "मानक YouTube URL का उपयोग करें (youtube.com/watch?v= या youtu.be/)",
"tip_2": "सुनिश्चित करें कि वीडियो सार्वजनिक रूप से सुलभ हैं", "tip_2": "सुनिश्चित करें कि वीडियो सार्वजनिक रूप से सुलभ हैं",
"tip_3": "समर्थित प्रारूप: youtube.com/watch?v=VIDEO_ID या youtu.be/VIDEO_ID", "tip_3": "समर्थित प्रारूप: youtube.com/watch?v=VIDEO_ID या youtu.be/VIDEO_ID",
"tip_4": "वीडियो की अवधि के आधार पर प्रोसेसिंग में कुछ समय लग सकता है", "tip_4": "वीडियो की अवधि के आधार पर प्रोसेसिंग में कुछ समय लग सकता है",
"tip_5": "सभी वीडियो एक साथ जोड़ने के लिए प्लेलिस्ट URL (youtube.com/playlist?list=...) पेस्ट करें",
"chat_tip": "ज्ञान आधार में सहेजे बिना त्वरित सारांश चाहिए? इसके बजाय YouTube लिंक सीधे चैट में पेस्ट करें।",
"preview": "पूर्वावलोकन", "preview": "पूर्वावलोकन",
"cancel": "रद्द करें", "cancel": "रद्द करें",
"submit": "जोड़ें", "submit": "जोड़ें",
@ -456,9 +458,15 @@
"error_toast_desc": "YouTube वीडियो प्रोसेस करने में त्रुटि", "error_toast_desc": "YouTube वीडियो प्रोसेस करने में त्रुटि",
"error_generic": "YouTube वीडियो प्रोसेस करते समय त्रुटि हुई", "error_generic": "YouTube वीडियो प्रोसेस करते समय त्रुटि हुई",
"invalid_url_toast": "अमान्य YouTube URL", "invalid_url_toast": "अमान्य YouTube URL",
"invalid_url_toast_desc": "कृपया एक मान्य YouTube वीडियो URL दर्ज करें", "invalid_url_toast_desc": "कृपया एक मान्य YouTube वीडियो या प्लेलिस्ट URL दर्ज करें",
"duplicate_url_toast": "डुप्लिकेट URL", "duplicate_url_toast": "डुप्लिकेट URL",
"duplicate_url_toast_desc": "यह YouTube वीडियो पहले से जोड़ा जा चुका है" "duplicate_url_toast_desc": "यह YouTube वीडियो पहले से जोड़ा जा चुका है",
"resolving_playlist": "प्लेलिस्ट वीडियो लोड हो रहे हैं...",
"resolving_playlist_toast": "प्लेलिस्ट लोड हो रही है",
"resolving_playlist_toast_desc": "प्लेलिस्ट से वीडियो सूची प्राप्त हो रही है...",
"playlist_resolved_toast": "प्लेलिस्ट लोड हो गई",
"playlist_resolved_toast_desc": "प्लेलिस्ट से {count} वीडियो जोड़े गए",
"playlist_error_toast": "प्लेलिस्ट त्रुटि"
}, },
"settings": { "settings": {
"title": "सेटिंग्स", "title": "सेटिंग्स",

View file

@ -433,15 +433,17 @@
}, },
"add_youtube": { "add_youtube": {
"title": "Adicionar vídeos do YouTube", "title": "Adicionar vídeos do YouTube",
"subtitle": "Insira URLs de vídeos do YouTube para adicionar à sua coleção de documentos", "subtitle": "Adicione vídeos ou playlists do YouTube à sua base de conhecimento",
"label": "Insira URLs de vídeos do YouTube", "label": "URLs de vídeos ou playlists do YouTube",
"placeholder": "Insira uma URL do YouTube e pressione Enter", "placeholder": "Cole URLs de vídeos ou playlists do YouTube aqui",
"hint": "Adicione múltiplas URLs do YouTube pressionando Enter após cada uma", "hint": "Basta colar URLs do YouTube e elas são adicionadas automaticamente. Você também pode digitar uma URL e pressionar Enter.",
"tips_title": "Dicas para adicionar vídeos do YouTube:", "tips_title": "Dicas para adicionar vídeos do YouTube:",
"tip_1": "Use URLs padrão do YouTube (youtube.com/watch?v= ou youtu.be/)", "tip_1": "Use URLs padrão do YouTube (youtube.com/watch?v= ou youtu.be/)",
"tip_2": "Certifique-se de que os vídeos sejam acessíveis publicamente", "tip_2": "Certifique-se de que os vídeos sejam acessíveis publicamente",
"tip_3": "Formatos suportados: youtube.com/watch?v=VIDEO_ID ou youtu.be/VIDEO_ID", "tip_3": "Formatos suportados: youtube.com/watch?v=VIDEO_ID ou youtu.be/VIDEO_ID",
"tip_4": "O processamento pode levar algum tempo dependendo da duração do vídeo", "tip_4": "O processamento pode levar algum tempo dependendo da duração do vídeo",
"tip_5": "Cole uma URL de playlist (youtube.com/playlist?list=...) para adicionar todos os vídeos de uma vez",
"chat_tip": "Quer um resumo rápido sem salvar na sua base de conhecimento? Basta colar os links do YouTube diretamente no chat.",
"preview": "Visualizar", "preview": "Visualizar",
"cancel": "Cancelar", "cancel": "Cancelar",
"submit": "Adicionar", "submit": "Adicionar",
@ -456,9 +458,15 @@
"error_toast_desc": "Erro ao processar vídeos do YouTube", "error_toast_desc": "Erro ao processar vídeos do YouTube",
"error_generic": "Ocorreu um erro ao processar vídeos do YouTube", "error_generic": "Ocorreu um erro ao processar vídeos do YouTube",
"invalid_url_toast": "URL do YouTube inválida", "invalid_url_toast": "URL do YouTube inválida",
"invalid_url_toast_desc": "Por favor, insira uma URL válida de vídeo do YouTube", "invalid_url_toast_desc": "Por favor, insira uma URL válida de vídeo ou playlist do YouTube",
"duplicate_url_toast": "URL duplicada", "duplicate_url_toast": "URL duplicada",
"duplicate_url_toast_desc": "Este vídeo do YouTube já foi adicionado" "duplicate_url_toast_desc": "Este vídeo do YouTube já foi adicionado",
"resolving_playlist": "Carregando vídeos da playlist...",
"resolving_playlist_toast": "Carregando playlist",
"resolving_playlist_toast_desc": "Obtendo a lista de vídeos da playlist...",
"playlist_resolved_toast": "Playlist carregada",
"playlist_resolved_toast_desc": "Adicionados {count} vídeos da playlist",
"playlist_error_toast": "Erro na playlist"
}, },
"settings": { "settings": {
"title": "Configurações", "title": "Configurações",

View file

@ -417,18 +417,20 @@
}, },
"add_youtube": { "add_youtube": {
"title": "添加 YouTube 视频", "title": "添加 YouTube 视频",
"subtitle": "输入 YouTube 视频 URL 以添加到您的文档集合", "subtitle": "将 YouTube 视频或播放列表添加到您的知识库",
"label": "输入 YouTube 视频 URL", "label": "YouTube 视频或播放列表 URL",
"placeholder": "输入 YouTube URL 并按 Enter", "placeholder": "在此粘贴 YouTube 视频或播放列表 URL",
"hint": "按 Enter 键添加多个 YouTube URL", "hint": "直接粘贴 YouTube URL 即可自动添加。您也可以输入 URL 后按 Enter。",
"tips_title": "添加 YouTube 视频的提示:", "tips_title": "添加 YouTube 视频的提示:",
"tip_1": "使用标准 YouTube URLyoutube.com/watch?v= 或 youtu.be/", "tip_1": "使用标准 YouTube URLyoutube.com/watch?v= 或 youtu.be/",
"tip_2": "确保视频可公开访问", "tip_2": "确保视频可公开访问",
"tip_3": "支持的格式youtube.com/watch?v=VIDEO_ID 或 youtu.be/VIDEO_ID", "tip_3": "支持的格式youtube.com/watch?v=VIDEO_ID 或 youtu.be/VIDEO_ID",
"tip_4": "处理时间可能会根据视频长度而有所不同", "tip_4": "处理时间可能会根据视频长度而有所不同",
"tip_5": "粘贴播放列表 URLyoutube.com/playlist?list=...)可一次添加所有视频",
"chat_tip": "想要快速摘要而不保存到知识库?直接在聊天中粘贴 YouTube 链接即可。",
"preview": "预览", "preview": "预览",
"cancel": "取消", "cancel": "取消",
"submit": "提交 YouTube 视频", "submit": "添加",
"processing": "处理中...", "processing": "处理中...",
"error_no_video": "请至少添加一个 YouTube 视频 URL", "error_no_video": "请至少添加一个 YouTube 视频 URL",
"error_invalid_urls": "检测到无效的 YouTube URL{urls}", "error_invalid_urls": "检测到无效的 YouTube URL{urls}",
@ -440,9 +442,15 @@
"error_toast_desc": "处理 YouTube 视频时出错", "error_toast_desc": "处理 YouTube 视频时出错",
"error_generic": "处理 YouTube 视频时发生错误", "error_generic": "处理 YouTube 视频时发生错误",
"invalid_url_toast": "无效的 YouTube URL", "invalid_url_toast": "无效的 YouTube URL",
"invalid_url_toast_desc": "请输入有效的 YouTube 视频 URL", "invalid_url_toast_desc": "请输入有效的 YouTube 视频或播放列表 URL",
"duplicate_url_toast": "重复的 URL", "duplicate_url_toast": "重复的 URL",
"duplicate_url_toast_desc": "此 YouTube 视频已添加" "duplicate_url_toast_desc": "此 YouTube 视频已添加",
"resolving_playlist": "正在加载播放列表视频...",
"resolving_playlist_toast": "正在加载播放列表",
"resolving_playlist_toast_desc": "正在从播放列表获取视频列表...",
"playlist_resolved_toast": "播放列表已加载",
"playlist_resolved_toast_desc": "已从播放列表添加 {count} 个视频",
"playlist_error_toast": "播放列表错误"
}, },
"settings": { "settings": {
"title": "设置", "title": "设置",