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.
267 lines
8.4 KiB
TypeScript
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>
|
|
)
|
|
}
|