Implement all core features: Knowledge pipeline, RAG chat, Todos, Habits, Study Cards, Tags, Dashboard
- Google OAuth authentication with callback flow - Knowledge ingest pipeline (TEXT/WEB/YOUTUBE → chunking → categorization → embedding) - OCI GenAI integration (chat, embeddings) with multi-model support - Semantic search via Oracle VECTOR_DISTANCE - RAG-based AI chat with source attribution - Todos with subtasks, filters, and priority levels - Habits with daily check-in, streak tracking, and color customization - Study Cards with SM-2 spaced repetition and LLM auto-generation - Tags system with knowledge item mapping - Dashboard with live data from all modules Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
240
sundol-frontend/src/app/knowledge/add/page.tsx
Normal file
240
sundol-frontend/src/app/knowledge/add/page.tsx
Normal file
@@ -0,0 +1,240 @@
|
||||
"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 [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 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>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user