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
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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)' }
|
||||
}}
|
||||
|
|
|
|||
171
apps/web/src/components/teams/activity-feed.tsx
Normal file
171
apps/web/src/components/teams/activity-feed.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue