'use client' // apps/web/src/components/knowledge/add-knowledge-form.tsx // 텍스트/파일(.txt/.md) 기반 지식 문서 추가. // 제출 시 knowledge_documents + knowledge_chunks를 insert한 뒤 embed-chunks Edge Function으로 임베딩한다. import { useRef, useState } from 'react' import { useRouter } from 'next/navigation' import { Box, Button, TextField, Stack, Alert, MenuItem, Select, FormControl, InputLabel } from '@mui/material' import AddIcon from '@mui/icons-material/Add' import { MetalCard } from '@d3ro/ui/components/ds' import { d3roPalette, typoSx } from '@d3ro/ui/theme' import { getSupabaseBrowserClient } from '@/lib/supabase-browser' const CHUNK_SIZE = 800 const MIN_CHUNK_BOUNDARY = 480 const MAX_CONTENT_CHARS = 250_000 const MAX_FILE_BYTES = 1_048_576 function chunkText(text: string): string[] { const chunks: string[] = [] let offset = 0 while (offset < text.length) { const hardEnd = Math.min(offset + CHUNK_SIZE, text.length) let end = hardEnd if (hardEnd < text.length) { const boundary = text.lastIndexOf('\n', hardEnd) if (boundary > offset + MIN_CHUNK_BOUNDARY) end = boundary } const chunk = text.slice(offset, end).trim() if (chunk.length > 0) chunks.push(chunk) offset = end } return chunks } function classifyKnowledgeFile(fileName: string): 'txt' | 'md' | null { const lower = fileName.toLowerCase() if (lower.endsWith('.md') || lower.endsWith('.markdown')) return 'md' if (lower.endsWith('.txt')) return 'txt' return null } export function AddKnowledgeForm(): React.ReactElement { const router = useRouter() const [open, setOpen] = useState(false) const [title, setTitle] = useState('') const [content, setContent] = useState('') const [fileType, setFileType] = useState<'txt' | 'md'>('txt') const [fileName, setFileName] = useState(null) const [error, setError] = useState(null) const [busy, setBusy] = useState(false) const fileInputRef = useRef(null) async function handleFile(file: File): Promise { const type = classifyKnowledgeFile(file.name) if (!type) { setError('txt 또는 md 파일만 지원합니다.') return } if (file.size > MAX_FILE_BYTES) { setError('파일은 1MB 이하여야 합니다.') return } const text = await file.text() if (text.length > MAX_CONTENT_CHARS) { setError(`본문은 ${MAX_CONTENT_CHARS.toLocaleString()}자 이하여야 합니다.`) return } setError(null) setTitle(file.name.replace(/\.[^.]+$/, '')) setFileType(type) setFileName(file.name) setContent(text) } async function handleSubmit(): Promise { if (!title.trim() || !content.trim()) return setError(null) setBusy(true) try { const supabase = getSupabaseBrowserClient() const { data: { user } } = await supabase.auth.getUser() if (!user) { setError('로그인이 필요합니다') return } const chunks = chunkText(content) // 1) 문서 INSERT (indexed=false — 임베딩 성공 후에만 true) const { data: doc, error: docErr } = await supabase .from('knowledge_documents') .insert({ user_id: user.id, title: title.trim(), file_name: fileName, file_type: fileType, chunk_count: chunks.length, indexed: false, indexed_at: null }) .select('id') .single() if (docErr || !doc) { setError(docErr?.message ?? '문서 생성 실패') return } // 2) 청크 INSERT (배치) const chunkRows = chunks.map((c, i) => ({ document_id: doc.id, chunk_index: i, content: c })) const { error: chunkErr } = await supabase.from('knowledge_chunks').insert(chunkRows) if (chunkErr) { setError(`청크 저장 실패: ${chunkErr.message}`) return } // 3) 임베딩 생성 (Edge Function, 실패 시 indexed=false 유지) const { data: { session } } = await supabase.auth.getSession() if (!session) { setError('세션이 만료되었습니다. 다시 로그인해 주세요.') return } const baseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL if (!baseUrl) { setError('Supabase URL이 구성되지 않았습니다.') return } let indexError: string | null = null try { const response = await fetch(`${baseUrl}/functions/v1/embed-chunks`, { method: 'POST', headers: { Authorization: `Bearer ${session.access_token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ document_id: doc.id }) }) const payload = (await response.json().catch(() => ({}))) as { error?: string message?: string indexed?: boolean } if (!response.ok || payload.indexed !== true) { indexError = payload.error ?? payload.message ?? `인덱싱 실패 (${response.status})` } } catch (indexRequestError) { indexError = indexRequestError instanceof Error ? indexRequestError.message : '인덱싱 요청 실패' } setTitle('') setContent('') setFileName(null) setOpen(false) router.refresh() if (indexError) { // 문서는 저장됐지만 임베딩이 실패한 경우 사용자에게 알린다. window.alert(`문서는 저장되었지만 인덱싱에 실패했습니다: ${indexError}\n목록에서 다시 시도할 수 있습니다.`) } } finally { setBusy(false) } } if (!open) { return ( ) } return ( 새 지식 문서 {fileName && {fileName}} { const file = event.target.files?.[0] event.target.value = '' if (file) void handleFile(file) }} /> setTitle(e.target.value)} fullWidth disabled={busy} /> 타입 setContent(e.target.value)} placeholder="텍스트를 붙여넣거나 파일을 선택하세요. 줄바꿈 기준 800자 단위로 자동 청킹됩니다." fullWidth disabled={busy} /> {error && ( {error} )} ) }