d3ro-voice/apps/web/src/components/knowledge/add-knowledge-form.tsx
Yun Chan cfc58458a8 feat(web): manage dictionaries and teams from the console
The console could not create dictionary entries, and team pages showed a
static member list with no record of who changed what. Dictionary
management, a knowledge upload form, and a team activity feed are now
available, alongside a download center that links the published desktop
installer feed rather than repository-local paths that no deploy ships.

Red-team e2e coverage was added for the account and team flows touched here.
2026-09-16 23:24:27 +09:00

267 lines
8.4 KiB
TypeScript

'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<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
const fileInputRef = useRef<HTMLInputElement | null>(null)
async function handleFile(file: File): Promise<void> {
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<void> {
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 (
<Button variant="outlined" startIcon={<AddIcon />} onClick={() => setOpen(true)}>
</Button>
)
}
return (
<MetalCard sx={{ p: 3, maxWidth: 720 }}>
<Box sx={{ ...typoSx('label'), color: d3roPalette.text.label, mb: 1 }}> </Box>
<Stack spacing={2}>
<Stack direction="row" spacing={1} alignItems="center">
<Button
variant="outlined"
size="small"
disabled={busy}
onClick={() => fileInputRef.current?.click()}
>
(.txt/.md)
</Button>
{fileName && <Box sx={{ ...typoSx('meta'), color: d3roPalette.text.dimLabel }}>{fileName}</Box>}
<input
ref={fileInputRef}
type="file"
accept=".txt,.md,.markdown,text/plain,text/markdown"
hidden
onChange={(event) => {
const file = event.target.files?.[0]
event.target.value = ''
if (file) void handleFile(file)
}}
/>
</Stack>
<TextField
label="제목"
size="small"
value={title}
onChange={(e) => setTitle(e.target.value)}
fullWidth
disabled={busy}
/>
<FormControl size="small">
<InputLabel></InputLabel>
<Select value={fileType} label="타입" onChange={(e) => setFileType(e.target.value as 'txt' | 'md')}>
<MenuItem value="txt">Plain text</MenuItem>
<MenuItem value="md">Markdown</MenuItem>
</Select>
</FormControl>
<TextField
label="본문"
size="small"
multiline
minRows={8}
maxRows={16}
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="텍스트를 붙여넣거나 파일을 선택하세요. 줄바꿈 기준 800자 단위로 자동 청킹됩니다."
fullWidth
disabled={busy}
/>
{error && (
<Alert severity="error" variant="outlined">
{error}
</Alert>
)}
<Stack direction="row" spacing={1}>
<Button
variant="contained"
onClick={() => void handleSubmit()}
disabled={busy || !title.trim() || !content.trim()}
>
{busy ? '저장 중...' : '저장'}
</Button>
<Button
variant="outlined"
onClick={() => {
setOpen(false)
setTitle('')
setContent('')
setFileName(null)
setError(null)
}}
disabled={busy}
>
</Button>
</Stack>
</Stack>
</MetalCard>
)
}