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.
This commit is contained in:
parent
f6a29db95a
commit
cfc58458a8
21 changed files with 985 additions and 125 deletions
|
|
@ -1,10 +1,10 @@
|
|||
'use client'
|
||||
|
||||
// apps/web/src/components/knowledge/add-knowledge-form.tsx
|
||||
// 텍스트/URL 기반 지식 문서 추가 (MVP — 파일 업로드는 추후)
|
||||
// 제출 시 knowledge_documents + knowledge_chunks 직접 insert
|
||||
// 텍스트/파일(.txt/.md) 기반 지식 문서 추가.
|
||||
// 제출 시 knowledge_documents + knowledge_chunks를 insert한 뒤 embed-chunks Edge Function으로 임베딩한다.
|
||||
|
||||
import { useState } from 'react'
|
||||
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'
|
||||
|
|
@ -12,14 +12,33 @@ 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 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, size: number): string[] {
|
||||
function chunkText(text: string): string[] {
|
||||
const chunks: string[] = []
|
||||
for (let i = 0; i < text.length; i += size) {
|
||||
chunks.push(text.slice(i, i + size))
|
||||
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.filter((c) => c.trim().length > 0)
|
||||
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 {
|
||||
|
|
@ -28,8 +47,32 @@ export function AddKnowledgeForm(): React.ReactElement {
|
|||
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
|
||||
|
|
@ -46,19 +89,19 @@ export function AddKnowledgeForm(): React.ReactElement {
|
|||
return
|
||||
}
|
||||
|
||||
const chunks = chunkText(content, CHUNK_SIZE)
|
||||
const chunks = chunkText(content)
|
||||
|
||||
// 1) 문서 INSERT
|
||||
// 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: null,
|
||||
file_name: fileName,
|
||||
file_type: fileType,
|
||||
chunk_count: chunks.length,
|
||||
indexed: true,
|
||||
indexed_at: new Date().toISOString()
|
||||
indexed: false,
|
||||
indexed_at: null
|
||||
})
|
||||
.select('id')
|
||||
.single()
|
||||
|
|
@ -80,10 +123,51 @@ export function AddKnowledgeForm(): React.ReactElement {
|
|||
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)
|
||||
}
|
||||
|
|
@ -101,6 +185,28 @@ export function AddKnowledgeForm(): React.ReactElement {
|
|||
<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"
|
||||
|
|
@ -124,7 +230,7 @@ export function AddKnowledgeForm(): React.ReactElement {
|
|||
maxRows={16}
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
placeholder="텍스트를 붙여넣기하세요. 800자 단위로 자동 청킹됩니다."
|
||||
placeholder="텍스트를 붙여넣거나 파일을 선택하세요. 줄바꿈 기준 800자 단위로 자동 청킹됩니다."
|
||||
fullWidth
|
||||
disabled={busy}
|
||||
/>
|
||||
|
|
@ -139,7 +245,7 @@ export function AddKnowledgeForm(): React.ReactElement {
|
|||
onClick={() => void handleSubmit()}
|
||||
disabled={busy || !title.trim() || !content.trim()}
|
||||
>
|
||||
저장
|
||||
{busy ? '저장 중...' : '저장'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
|
|
@ -147,6 +253,7 @@ export function AddKnowledgeForm(): React.ReactElement {
|
|||
setOpen(false)
|
||||
setTitle('')
|
||||
setContent('')
|
||||
setFileName(null)
|
||||
setError(null)
|
||||
}}
|
||||
disabled={busy}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue