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:
Yun Chan 2026-09-16 23:24:27 +09:00
parent f6a29db95a
commit cfc58458a8
21 changed files with 985 additions and 125 deletions

View file

@ -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}