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

@ -175,7 +175,7 @@ export function ChatPanel(): React.ReactElement {
bgcolor: 'var(--d3-bg-card)',
borderRadius: '24px',
border: '1px solid var(--d3-border-default)',
boxShadow: '0 0 50px rgba(0,0,0,0.5)',
boxShadow: '0 0 50px var(--d3-scrim)',
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
@ -227,8 +227,8 @@ export function ChatPanel(): React.ReactElement {
width: 6,
height: 6,
borderRadius: '50%',
bgcolor: error ? '#f87171' : 'var(--d3-tag-green)',
boxShadow: error ? '0 0 5px #f87171' : '0 0 5px var(--d3-tag-green)',
bgcolor: error ? 'var(--d3-status-danger)' : 'var(--d3-tag-green)',
boxShadow: error ? '0 0 5px var(--d3-status-danger)' : '0 0 5px var(--d3-tag-green)',
animation: 'pulse 1s infinite'
}}
/>
@ -269,8 +269,8 @@ export function ChatPanel(): React.ReactElement {
sx={{
p: 2.5,
borderRadius: msg.role === 'user' ? '18px 18px 4px 18px' : '18px 18px 18px 4px',
bgcolor: msg.role === 'user' ? 'rgba(59,130,246,0.1)' : 'var(--d3-bg-elevated)',
border: msg.role === 'user' ? '1px solid rgba(59,130,246,0.3)' : '1px solid var(--d3-border-default)',
bgcolor: msg.role === 'user' ? 'var(--d3-accent-glow)' : 'var(--d3-bg-elevated)',
border: msg.role === 'user' ? '1px solid var(--d3-accent-glow)' : '1px solid var(--d3-border-default)',
color: msg.role === 'user' ? 'var(--d3-accent-main)' : 'var(--d3-text-secondary)',
fontSize: '14px',
lineHeight: 1.6
@ -349,9 +349,9 @@ export function ChatPanel(): React.ReactElement {
sx={{
width: 36,
height: 36,
bgcolor: 'rgba(59,130,246,0.15)',
bgcolor: 'var(--d3-accent-glow)',
color: 'var(--d3-accent-main)',
'&:hover': { bgcolor: 'var(--d3-accent-main)', color: '#fff' }
'&:hover': { bgcolor: 'var(--d3-accent-main)', color: 'var(--d3-text-inverse)' }
}}
>
<Send size={16} />

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}

View file

@ -209,7 +209,7 @@ export function MicRecorder(): React.ReactElement {
bgcolor: 'var(--d3-bg-card)',
borderRadius: '24px',
border: '1px solid var(--d3-border-default)',
boxShadow: '0 0 50px rgba(0,0,0,0.5)',
boxShadow: '0 0 50px var(--d3-scrim)',
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
@ -282,7 +282,7 @@ export function MicRecorder(): React.ReactElement {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderBottom: '1px solid #1a1a1c',
borderBottom: '1px solid var(--d3-bg-card)',
position: 'relative'
}}
>
@ -304,7 +304,7 @@ export function MicRecorder(): React.ReactElement {
bgcolor: state === 'recording' ? 'var(--d3-accent-main)' : 'var(--d3-border-default)',
borderRadius: '4px',
transition: 'height 80ms ease-out',
boxShadow: state === 'recording' && h > 0.4 ? '0 0 8px rgba(59,130,246,0.5)' : 'none'
boxShadow: state === 'recording' && h > 0.4 ? '0 0 8px var(--d3-accent-glow)' : 'none'
}}
/>
))}
@ -407,7 +407,7 @@ export function MicRecorder(): React.ReactElement {
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
boxShadow: '0 0 20px rgba(59,130,246,0.4)',
boxShadow: '0 0 20px var(--d3-accent-glow)',
transition: 'transform 0.15s ease, background-color 0.15s ease',
'&:hover': { transform: 'scale(1.05)' }
}}

View file

@ -0,0 +1,171 @@
'use client'
// apps/web/src/components/teams/activity-feed.tsx
// 팀 활동/코멘트 피드 — RPC로 작성하고 Realtime INSERT를 구독한다.
import { useEffect, useState } from 'react'
import { Alert, Box, Button, Stack, TextField } from '@mui/material'
import { d3roPalette } from '@d3ro/ui/theme'
import { getSupabaseBrowserClient } from '@/lib/supabase-browser'
export interface TeamActivityItem {
id: string
actor_id: string | null
kind: string
body: string | null
created_at: string
}
interface ActivityFeedProps {
teamId: string
initialActivities: TeamActivityItem[]
memberNames: Record<string, string>
canPost: boolean
}
const KIND_LABELS: Record<string, string> = {
note: '메모',
member_joined: '멤버 합류',
member_left: '멤버 탈퇴',
invite_created: '초대 생성',
meeting_shared: '회의 공유',
document_shared: '문서 공유'
}
function formatTime(value: string): string {
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
return date.toLocaleString('ko-KR')
}
export function TeamActivityFeed({
teamId,
initialActivities,
memberNames,
canPost
}: ActivityFeedProps): React.ReactElement {
const [activities, setActivities] = useState<TeamActivityItem[]>(initialActivities)
const [body, setBody] = useState('')
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
setActivities(initialActivities)
}, [initialActivities])
useEffect(() => {
const supabase = getSupabaseBrowserClient()
const channel = supabase
.channel(`web-team-activity-${teamId}`)
.on(
'postgres_changes',
{ event: 'INSERT', schema: 'public', table: 'team_activities', filter: `team_id=eq.${teamId}` },
(payload) => {
const row = payload.new as TeamActivityItem
setActivities((current) =>
current.some((item) => item.id === row.id) ? current : [row, ...current]
)
}
)
.subscribe()
return () => {
void supabase.removeChannel(channel)
}
}, [teamId])
async function submit(): Promise<void> {
const trimmed = body.trim()
if (!trimmed || busy) return
setBusy(true)
setError(null)
try {
const supabase = getSupabaseBrowserClient()
const { data, error: rpcError } = await supabase.rpc('create_team_activity', {
p_team_id: teamId,
p_kind: 'note',
p_body: trimmed,
p_metadata: {}
})
if (rpcError) {
setError(rpcError.message)
return
}
setBody('')
const created = data as { id?: string; created_at?: string; actor_id?: string } | null
if (created?.id) {
setActivities((current) =>
current.some((item) => item.id === created.id)
? current
: [
{
id: created.id as string,
actor_id: (created.actor_id as string | undefined) ?? null,
kind: 'note',
body: trimmed,
created_at: created.created_at ?? new Date().toISOString()
},
...current
]
)
}
} finally {
setBusy(false)
}
}
return (
<Stack spacing={2}>
{canPost && (
<Stack direction="row" spacing={1} alignItems="flex-start">
<TextField
size="small"
value={body}
onChange={(event) => setBody(event.target.value)}
placeholder="팀에 메모 남기기..."
inputProps={{ maxLength: 2000 }}
fullWidth
multiline
maxRows={4}
disabled={busy}
/>
<Button variant="contained" onClick={() => void submit()} disabled={busy || !body.trim()}>
{busy ? '등록 중' : '등록'}
</Button>
</Stack>
)}
{error && (
<Alert severity="error" variant="outlined">
{error}
</Alert>
)}
{activities.length === 0 ? (
<Box sx={{ color: d3roPalette.text.muted, fontSize: 13 }}>아직 활동이 없습니다.</Box>
) : (
<Stack spacing={1}>
{activities.map((activity) => (
<Box key={activity.id} sx={{ p: 1.5, bgcolor: d3roPalette.bg.inset, borderRadius: 1 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', gap: 2, mb: 0.5 }}>
<Box sx={{ color: d3roPalette.text.primary, fontSize: 12, fontWeight: 500 }}>
{activity.actor_id ? memberNames[activity.actor_id] ?? activity.actor_id.slice(0, 8) : '시스템'}
<Box component="span" sx={{ color: d3roPalette.text.muted, ml: 1, fontWeight: 400 }}>
{KIND_LABELS[activity.kind] ?? activity.kind}
</Box>
</Box>
<Box sx={{ color: d3roPalette.text.muted, fontSize: 11 }}>
{formatTime(activity.created_at)}
</Box>
</Box>
{activity.body && (
<Box sx={{ color: d3roPalette.text.primary, fontSize: 13, whiteSpace: 'pre-wrap' }}>
{activity.body}
</Box>
)}
</Box>
))}
</Stack>
)}
</Stack>
)
}