Files
sundol/sundol-frontend/src/app/knowledge/add/page.tsx
joungmin bb5a601433 Add YouTube transcript auto-fetch button on Knowledge add page
- YouTubeTranscriptService: fetches captions from YouTube page (ko > en > first available)
- GET /api/knowledge/youtube-transcript endpoint
- Frontend: "트랜스크립트 자동 가져오기" button appears when valid YouTube URL entered

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 04:20:13 +00:00

277 lines
9.8 KiB
TypeScript

"use client";
import { useState, useMemo, useEffect } from "react";
import { useRouter } from "next/navigation";
import AuthGuard from "@/components/auth-guard";
import NavBar from "@/components/nav-bar";
import { useApi } from "@/lib/use-api";
type KnowledgeType = "TEXT" | "WEB" | "YOUTUBE";
interface ModelInfo {
id: string;
name: string;
vendor: string;
}
function extractYouTubeVideoId(url: string): string | null {
try {
const u = new URL(url);
if (u.hostname === "youtu.be") return u.pathname.slice(1);
if (u.hostname.includes("youtube.com")) return u.searchParams.get("v");
} catch {
// invalid URL
}
return null;
}
export default function KnowledgeAddPage() {
const { request } = useApi();
const router = useRouter();
const [type, setType] = useState<KnowledgeType>("TEXT");
const [title, setTitle] = useState("");
const [url, setUrl] = useState("");
const [rawText, setRawText] = useState("");
const [modelId, setModelId] = useState("");
const [models, setModels] = useState<ModelInfo[]>([]);
const [submitting, setSubmitting] = useState(false);
const [fetchingTranscript, setFetchingTranscript] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
request<{ models: ModelInfo[]; defaultModel: string; configured: boolean }>({
method: "GET",
url: "/api/models",
}).then((data) => {
setModels(data.models);
setModelId(data.defaultModel);
}).catch((err) => {
console.error("Failed to load models:", err);
});
}, []);
const videoId = useMemo(() => (type === "YOUTUBE" ? extractYouTubeVideoId(url) : null), [type, url]);
const canFetchTranscript = type === "YOUTUBE" && videoId !== null && !fetchingTranscript;
const handleFetchTranscript = async () => {
if (!canFetchTranscript) return;
setError(null);
setFetchingTranscript(true);
try {
const data = await request<{ transcript?: string; error?: string }>({
method: "GET",
url: `/api/knowledge/youtube-transcript?url=${encodeURIComponent(url.trim())}`,
});
if (data.error) {
setError(data.error);
} else if (data.transcript) {
setRawText(data.transcript);
}
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : "트랜스크립트를 가져올 수 없습니다";
setError(msg);
} finally {
setFetchingTranscript(false);
}
};
const canSubmit =
!submitting &&
((type === "TEXT" && rawText.trim().length > 0) ||
(type === "WEB" && url.trim().length > 0) ||
(type === "YOUTUBE" && url.trim().length > 0 && rawText.trim().length > 0));
const handleSubmit = async () => {
setError(null);
setSubmitting(true);
try {
await request({
method: "POST",
url: "/api/knowledge/ingest",
data: {
type,
title: title.trim() || null,
url: type !== "TEXT" ? url.trim() : null,
rawText: type !== "WEB" ? rawText.trim() : null,
modelId: modelId || null,
},
});
router.push("/knowledge");
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : "Failed to submit";
setError(msg);
setSubmitting(false);
}
};
const types: { value: KnowledgeType; label: string }[] = [
{ value: "TEXT", label: "Text" },
{ value: "WEB", label: "Web" },
{ value: "YOUTUBE", label: "YouTube" },
];
// 벤더별 그룹화
const groupedModels = useMemo(() => {
const groups: Record<string, ModelInfo[]> = {};
for (const m of models) {
if (!groups[m.vendor]) groups[m.vendor] = [];
groups[m.vendor].push(m);
}
return groups;
}, [models]);
return (
<AuthGuard>
<NavBar />
<main className="max-w-3xl mx-auto px-4 py-8">
<h1 className="text-2xl font-bold mb-6">Add Knowledge</h1>
{/* Type Tabs */}
<div className="flex gap-2 mb-6">
{types.map((t) => (
<button
key={t.value}
onClick={() => {
setType(t.value);
setUrl("");
setRawText("");
setError(null);
}}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
type === t.value
? "bg-[var(--color-primary)] text-white"
: "bg-[var(--color-bg-card)] text-[var(--color-text-muted)] border border-[var(--color-border)] hover:border-[var(--color-primary)]"
}`}
>
{t.label}
</button>
))}
</div>
<div className="space-y-4">
{/* Title */}
<div>
<label className="block text-sm text-[var(--color-text-muted)] mb-1">
( AI가 )
</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="비워두면 내용 기반으로 자동 생성"
className="w-full px-3 py-2 rounded-lg bg-[var(--color-bg-card)] border border-[var(--color-border)] focus:border-[var(--color-primary)] focus:outline-none"
/>
</div>
{/* URL (WEB / YOUTUBE) */}
{type !== "TEXT" && (
<div>
<label className="block text-sm text-[var(--color-text-muted)] mb-1">URL</label>
<input
type="url"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder={type === "YOUTUBE" ? "https://www.youtube.com/watch?v=..." : "https://example.com/article"}
className="w-full px-3 py-2 rounded-lg bg-[var(--color-bg-card)] border border-[var(--color-border)] focus:border-[var(--color-primary)] focus:outline-none"
/>
</div>
)}
{/* YouTube Embed */}
{type === "YOUTUBE" && videoId && (
<div className="rounded-lg overflow-hidden border border-[var(--color-border)]">
<div className="relative w-full" style={{ paddingBottom: "56.25%" }}>
<iframe
className="absolute inset-0 w-full h-full"
src={`https://www.youtube.com/embed/${videoId}`}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
/>
</div>
</div>
)}
{/* Fetch Transcript Button (YOUTUBE) */}
{type === "YOUTUBE" && videoId && (
<button
onClick={handleFetchTranscript}
disabled={!canFetchTranscript}
className="w-full px-4 py-2 bg-[var(--color-bg-card)] border border-[var(--color-border)] hover:border-[var(--color-primary)] disabled:opacity-40 disabled:cursor-not-allowed rounded-lg transition-colors text-sm font-medium"
>
{fetchingTranscript ? "트랜스크립트 가져오는 중..." : "트랜스크립트 자동 가져오기"}
</button>
)}
{/* Text Input (TEXT / YOUTUBE) */}
{type !== "WEB" && (
<div>
<label className="block text-sm text-[var(--color-text-muted)] mb-1">
{type === "YOUTUBE" ? "Transcript / 내용 붙여넣기" : "텍스트 입력"}
</label>
<textarea
value={rawText}
onChange={(e) => setRawText(e.target.value)}
placeholder={
type === "YOUTUBE"
? "영상의 transcript나 내용을 여기에 붙여넣으세요..."
: "텍스트를 직접 입력하세요..."
}
rows={12}
className="w-full px-3 py-2 rounded-lg bg-[var(--color-bg-card)] border border-[var(--color-border)] focus:border-[var(--color-primary)] focus:outline-none resize-y"
/>
</div>
)}
{/* Model Selection */}
{models.length > 0 && (
<div>
<label className="block text-sm text-[var(--color-text-muted)] mb-1">
AI
</label>
<select
value={modelId}
onChange={(e) => setModelId(e.target.value)}
className="w-full px-3 py-2 rounded-lg bg-[var(--color-bg-card)] border border-[var(--color-border)] focus:border-[var(--color-primary)] focus:outline-none"
>
{Object.entries(groupedModels).map(([vendor, vendorModels]) => (
<optgroup key={vendor} label={vendor}>
{vendorModels.map((m) => (
<option key={m.id} value={m.id}>
{m.name}
</option>
))}
</optgroup>
))}
</select>
</div>
)}
{/* Error */}
{error && (
<p className="text-sm text-red-400">{error}</p>
)}
{/* Actions */}
<div className="flex gap-3 pt-2">
<button
onClick={handleSubmit}
disabled={!canSubmit}
className="px-6 py-2 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] disabled:opacity-40 disabled:cursor-not-allowed rounded-lg transition-colors"
>
{submitting ? "처리 중..." : "추가"}
</button>
<button
onClick={() => router.push("/knowledge")}
className="px-6 py-2 bg-[var(--color-bg-card)] border border-[var(--color-border)] hover:border-[var(--color-primary)] rounded-lg transition-colors"
>
</button>
</div>
</div>
</main>
</AuthGuard>
);
}