feat(V2-4차): Database 제네릭 복원 + /chat + /knowledge + Resend + Push + 문서 생성

묶음 K — Database 제네릭 완전 주입:
- @supabase/ssr 0.5→0.10, @supabase/supabase-js 2.45→2.103 bump
- 버전 정합으로 @supabase/ssr의 Database 제약이 정상 동작
- packages/api-client/src/types.ts:
  - TypedTable<Row, Insert, Update>로 Row & Record<string, unknown> 교차 유지
  - 각 테이블 Insert를 Partial<Row> & Pick<필수필드>로 교체
    (optional 컬럼이 required로 추론되던 문제 해결)
  - push_tokens / team_invites 테이블 타입도 inline 추가
- index.ts: types + client + auth + meetings + history + usage 전체 re-export 복원
- apps/web supabase-browser/server에 <Database> 제네릭 주입

묶음 L — /chat 페이지 (Voice Conversation 이식):
- apps/web/src/components/chat/chat-panel.tsx (client)
  - 메시지 state, user/assistant 말풍선, MetalCard 래핑
  - llm-proxy POST (non-streaming JSON), Anthropic 응답 형식 파싱
  - Enter 전송, Shift+Enter 줄바꿈, 초기화 버튼
- apps/web/src/app/(app)/chat/page.tsx
- Sidebar에 Chat 메뉴 추가 (ChatIcon)
- ko.json nav.chat 키

묶음 M — /knowledge 페이지 (RAG 이식):
- migrations/20260410000002_knowledge_documents.sql
  - knowledge_documents + knowledge_chunks 테이블
  - tsvector 자동 생성 컬럼 + GIN 인덱스 (전문 검색)
  - RLS: 개인/팀 분기 (team_id NULL 가능)
  - pgvector는 주석 처리 (V2-M+1에서 활성화)
- apps/web/src/app/(app)/knowledge/page.tsx — 카드 리스트 + 상태 배지
- components/knowledge/add-knowledge-form.tsx — 제목/타입/본문 입력,
  800자 단위 청킹 후 documents+chunks INSERT
- api-client types.ts에 KnowledgeDocument/Chunk 추가 + Database 등록
- Sidebar Knowledge 메뉴 (LibraryBooksIcon) + ko.json 키

묶음 N — team-invite Resend 이메일:
- functions/team-invite/index.ts에 Resend API 호출 로직 추가
- RESEND_API_KEY 설정 시 HTML 이메일 발송 (팀 이름/초대자/버튼/만료)
- 응답에 email_sent / email_error 포함
- API 키 없으면 기존대로 URL만 반환

묶음 O — Expo Push notification:
- migrations/20260410000003_push_tokens.sql (user_id, token unique, platform)
- functions/send-push/index.ts: 호출자 인증 + 본인/팀 멤버 권한 체크,
  대상 사용자 push_tokens 조회, Expo Push API 배치 호출
- config.toml에 send-push 함수 등록
- apps/mobile/package.json에 expo-device + expo-notifications 추가
- apps/mobile/lib/push.ts: registerPushToken (권한 요청, projectId,
  Android 채널, Supabase upsert)
- auth-context.tsx에서 로그인 직후 자동 등록
- app.json plugins에 expo-notifications

묶음 P — meetings/[id] 문서 생성:
- apps/web/components/meetings/generate-document-button.tsx (client)
  - 4개 템플릿 메뉴 (minutes/report/idea-note/mindmap)
  - 각 템플릿별 systemPrompt 지정
  - llm-proxy 호출 → meeting_documents INSERT
  - latency 측정, prompt_used 기록
- meetings/[id]/page.tsx DOCUMENTS 섹션에 버튼 노출
  (transcript는 edited_transcript ?? raw_transcript 우선)

검증:
- desktop typecheck + build OK
- web typecheck + build OK (14 라우트: 기존 12 + chat + knowledge)
- api-client test 19 passed

통계:
- 총 Edge Functions 10개: stt/llm-proxy, stripe-checkout/portal/webhook,
  team-invite/accept, send-push
- 총 SQL 마이그레이션 7개
- 웹 라우트 14개, 모바일 화면 5개
- 테스트 19개 passed
This commit is contained in:
yunchan8804 2026-04-10 08:51:53 +09:00
parent c167737198
commit 1fa24ce3c9
24 changed files with 1284 additions and 79 deletions

View file

@ -0,0 +1,160 @@
'use client'
// apps/web/src/components/knowledge/add-knowledge-form.tsx
// 텍스트/URL 기반 지식 문서 추가 (MVP — 파일 업로드는 추후)
// 제출 시 knowledge_documents + knowledge_chunks 직접 insert
import { 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 // 문자 단위. 간단한 고정 크기 청킹.
function chunkText(text: string, size: number): string[] {
const chunks: string[] = []
for (let i = 0; i < text.length; i += size) {
chunks.push(text.slice(i, i + size))
}
return chunks.filter((c) => c.trim().length > 0)
}
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 [error, setError] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
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, CHUNK_SIZE)
// 1) 문서 INSERT
const { data: doc, error: docErr } = await supabase
.from('knowledge_documents')
.insert({
user_id: user.id,
title: title.trim(),
file_name: null,
file_type: fileType,
chunk_count: chunks.length,
indexed: true,
indexed_at: new Date().toISOString()
})
.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
}
setTitle('')
setContent('')
setOpen(false)
router.refresh()
} 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}>
<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()}
>
</Button>
<Button
variant="outlined"
onClick={() => {
setOpen(false)
setTitle('')
setContent('')
setError(null)
}}
disabled={busy}
>
</Button>
</Stack>
</Stack>
</MetalCard>
)
}