- 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>
277 lines
9.8 KiB
TypeScript
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>
|
|
);
|
|
}
|