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:
parent
c167737198
commit
1fa24ce3c9
24 changed files with 1284 additions and 79 deletions
224
apps/web/src/components/chat/chat-panel.tsx
Normal file
224
apps/web/src/components/chat/chat-panel.tsx
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
'use client'
|
||||
|
||||
// apps/web/src/components/chat/chat-panel.tsx
|
||||
// LLM 채팅 패널 — llm-proxy 호출 (SSE 스트리밍 대신 non-streaming JSON 첫 버전)
|
||||
// V1의 VoiceConversationService 흐름을 web에 이식한 MVP
|
||||
|
||||
import { useRef, useState } from 'react'
|
||||
import { Box, Button, TextField, Stack, Alert, CircularProgress } from '@mui/material'
|
||||
import SendIcon from '@mui/icons-material/Send'
|
||||
import DeleteSweepIcon from '@mui/icons-material/DeleteSweep'
|
||||
import { MetalCard, PhosphorText } from '@d3ro/ui/components/ds'
|
||||
import { d3roPalette, typoSx } from '@d3ro/ui/theme'
|
||||
import { getSupabaseBrowserClient, isSupabaseConfigured } from '@/lib/supabase-browser'
|
||||
|
||||
interface Message {
|
||||
id: string
|
||||
role: 'user' | 'assistant'
|
||||
content: string
|
||||
}
|
||||
|
||||
interface LlmResponse {
|
||||
id: string
|
||||
model: string
|
||||
role: string
|
||||
content: Array<{ type: string; text: string }>
|
||||
stop_reason: string
|
||||
usage: { input_tokens: number; output_tokens: number }
|
||||
}
|
||||
|
||||
export function ChatPanel(): React.ReactElement {
|
||||
const [messages, setMessages] = useState<Message[]>([])
|
||||
const [input, setInput] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
function scrollToBottom(): void {
|
||||
requestAnimationFrame(() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function handleSend(): Promise<void> {
|
||||
const text = input.trim()
|
||||
if (!text || busy) return
|
||||
|
||||
const userMsg: Message = { id: `u_${Date.now()}`, role: 'user', content: text }
|
||||
const nextMessages = [...messages, userMsg]
|
||||
setMessages(nextMessages)
|
||||
setInput('')
|
||||
setError(null)
|
||||
setBusy(true)
|
||||
scrollToBottom()
|
||||
|
||||
try {
|
||||
if (!isSupabaseConfigured()) {
|
||||
throw new Error('Supabase가 설정되지 않아 LLM 호출이 불가능합니다')
|
||||
}
|
||||
|
||||
const supabase = getSupabaseBrowserClient()
|
||||
const {
|
||||
data: { session }
|
||||
} = await supabase.auth.getSession()
|
||||
|
||||
if (!session) {
|
||||
throw new Error('로그인이 필요합니다')
|
||||
}
|
||||
|
||||
const payload = {
|
||||
messages: nextMessages.map((m) => ({ role: m.role, content: m.content })),
|
||||
max_tokens: 1024
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/functions/v1/llm-proxy`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${session.access_token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
const errTxt = await response.text()
|
||||
throw new Error(`LLM 호출 실패: ${response.status} ${errTxt}`)
|
||||
}
|
||||
|
||||
const data = (await response.json()) as LlmResponse
|
||||
const assistantText = data.content?.[0]?.text ?? '[응답 없음]'
|
||||
const assistantMsg: Message = {
|
||||
id: data.id,
|
||||
role: 'assistant',
|
||||
content: assistantText
|
||||
}
|
||||
setMessages((prev) => [...prev, assistantMsg])
|
||||
scrollToBottom()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Unknown error')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
function handleClear(): void {
|
||||
setMessages([])
|
||||
setError(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack spacing={2} sx={{ height: 'calc(100vh - 120px)' }}>
|
||||
{/* 메시지 영역 */}
|
||||
<Box
|
||||
ref={scrollRef}
|
||||
sx={{
|
||||
flex: 1,
|
||||
overflowY: 'auto',
|
||||
p: 2,
|
||||
bgcolor: d3roPalette.bg.inset,
|
||||
borderRadius: 2
|
||||
}}
|
||||
>
|
||||
{messages.length === 0 ? (
|
||||
<Box
|
||||
sx={{
|
||||
textAlign: 'center',
|
||||
color: d3roPalette.text.muted,
|
||||
fontSize: 13,
|
||||
mt: 4
|
||||
}}
|
||||
>
|
||||
메시지를 입력해 대화를 시작하세요.
|
||||
</Box>
|
||||
) : (
|
||||
<Stack spacing={2}>
|
||||
{messages.map((msg) => (
|
||||
<Box
|
||||
key={msg.id}
|
||||
sx={{
|
||||
alignSelf: msg.role === 'user' ? 'flex-end' : 'flex-start',
|
||||
maxWidth: '80%'
|
||||
}}
|
||||
>
|
||||
<Box sx={{ ...typoSx('label'), color: d3roPalette.text.label, mb: 0.5 }}>
|
||||
{msg.role === 'user' ? 'YOU' : 'ASSISTANT'}
|
||||
</Box>
|
||||
<MetalCard
|
||||
sx={{
|
||||
p: 2,
|
||||
bgcolor: msg.role === 'user' ? d3roPalette.bg.elevated : d3roPalette.bg.card
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
...typoSx('body'),
|
||||
color: d3roPalette.text.primary,
|
||||
whiteSpace: 'pre-wrap'
|
||||
}}
|
||||
>
|
||||
{msg.content}
|
||||
</Box>
|
||||
</MetalCard>
|
||||
</Box>
|
||||
))}
|
||||
{busy && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<CircularProgress size={16} color="warning" />
|
||||
<PhosphorText variant="label" color="label">
|
||||
응답 생성 중...
|
||||
</PhosphorText>
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" variant="outlined">
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* 입력 영역 */}
|
||||
<Stack direction="row" spacing={1}>
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline
|
||||
maxRows={4}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
void handleSend()
|
||||
}
|
||||
}}
|
||||
placeholder="메시지 입력... (Enter: 전송, Shift+Enter: 줄바꿈)"
|
||||
disabled={busy}
|
||||
size="small"
|
||||
/>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<SendIcon />}
|
||||
onClick={() => void handleSend()}
|
||||
disabled={busy || !input.trim()}
|
||||
>
|
||||
전송
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<DeleteSweepIcon />}
|
||||
onClick={handleClear}
|
||||
disabled={busy || messages.length === 0}
|
||||
>
|
||||
초기화
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue