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
|
|
@ -27,7 +27,18 @@
|
|||
"backgroundColor": "#19191b"
|
||||
}
|
||||
},
|
||||
"plugins": ["expo-router", "expo-secure-store", "expo-av"],
|
||||
"plugins": [
|
||||
"expo-router",
|
||||
"expo-secure-store",
|
||||
"expo-av",
|
||||
[
|
||||
"expo-notifications",
|
||||
{
|
||||
"icon": "./assets/icon.png",
|
||||
"color": "#f25b29"
|
||||
}
|
||||
]
|
||||
],
|
||||
"experiments": {
|
||||
"typedRoutes": true
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
// apps/mobile/lib/auth-context.tsx
|
||||
// Supabase 세션 Context
|
||||
// Supabase 세션 Context + 로그인 시 Expo Push 토큰 자동 등록
|
||||
|
||||
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'
|
||||
import type { Session, User } from '@supabase/supabase-js'
|
||||
import { supabase } from './supabase'
|
||||
import { registerPushToken } from './push'
|
||||
|
||||
interface AuthContextValue {
|
||||
session: Session | null
|
||||
|
|
@ -26,6 +27,9 @@ export function AuthProvider({ children }: { children: ReactNode }): React.React
|
|||
.getSession()
|
||||
.then(({ data }) => {
|
||||
setSession(data.session)
|
||||
if (data.session?.user.id) {
|
||||
void registerPushToken(data.session.user.id)
|
||||
}
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
|
||||
|
|
@ -33,6 +37,9 @@ export function AuthProvider({ children }: { children: ReactNode }): React.React
|
|||
data: { subscription }
|
||||
} = supabase.auth.onAuthStateChange((_event, newSession) => {
|
||||
setSession(newSession)
|
||||
if (newSession?.user.id) {
|
||||
void registerPushToken(newSession.user.id)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
|
|
|
|||
77
apps/mobile/lib/push.ts
Normal file
77
apps/mobile/lib/push.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
// apps/mobile/lib/push.ts
|
||||
// Expo Push token 등록 — 앱 진입 시 호출하면 Supabase push_tokens 테이블에 upsert.
|
||||
|
||||
import * as Notifications from 'expo-notifications'
|
||||
import * as Device from 'expo-device'
|
||||
import { Platform } from 'react-native'
|
||||
import Constants from 'expo-constants'
|
||||
import { supabase } from './supabase'
|
||||
|
||||
Notifications.setNotificationHandler({
|
||||
handleNotification: async () => ({
|
||||
shouldShowAlert: true,
|
||||
shouldPlaySound: true,
|
||||
shouldSetBadge: false,
|
||||
shouldShowBanner: true,
|
||||
shouldShowList: true
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Expo push token을 받아 Supabase push_tokens 테이블에 upsert.
|
||||
* 시뮬레이터/에뮬레이터에서는 token이 발급되지 않으므로 조용히 return.
|
||||
*/
|
||||
export async function registerPushToken(userId: string): Promise<void> {
|
||||
if (!Device.isDevice) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// 권한 요청
|
||||
const { status: existingStatus } = await Notifications.getPermissionsAsync()
|
||||
let finalStatus = existingStatus
|
||||
if (existingStatus !== 'granted') {
|
||||
const { status } = await Notifications.requestPermissionsAsync()
|
||||
finalStatus = status
|
||||
}
|
||||
if (finalStatus !== 'granted') {
|
||||
return
|
||||
}
|
||||
|
||||
// EAS projectId는 app.json/eas.json에서
|
||||
const projectId =
|
||||
(Constants.expoConfig?.extra?.eas?.projectId as string | undefined) ??
|
||||
(Constants.easConfig?.projectId as string | undefined)
|
||||
|
||||
const tokenData = await Notifications.getExpoPushTokenAsync(projectId ? { projectId } : undefined)
|
||||
const token = tokenData.data
|
||||
|
||||
if (!token) return
|
||||
|
||||
const platform: 'ios' | 'android' | 'web' =
|
||||
Platform.OS === 'ios' ? 'ios' : Platform.OS === 'android' ? 'android' : 'web'
|
||||
|
||||
// Android 채널 설정
|
||||
if (Platform.OS === 'android') {
|
||||
await Notifications.setNotificationChannelAsync('default', {
|
||||
name: 'default',
|
||||
importance: Notifications.AndroidImportance.MAX,
|
||||
vibrationPattern: [0, 250, 250, 250],
|
||||
lightColor: '#f25b29'
|
||||
})
|
||||
}
|
||||
|
||||
// Supabase upsert
|
||||
await supabase.from('push_tokens').upsert(
|
||||
{
|
||||
user_id: userId,
|
||||
token,
|
||||
platform,
|
||||
device_name: Device.deviceName ?? null
|
||||
},
|
||||
{ onConflict: 'token' }
|
||||
)
|
||||
} catch {
|
||||
// push 등록 실패는 앱 진행 차단하지 않음
|
||||
}
|
||||
}
|
||||
|
|
@ -17,7 +17,9 @@
|
|||
"expo": "~51.0.0",
|
||||
"expo-av": "~14.0.0",
|
||||
"expo-constants": "~16.0.0",
|
||||
"expo-device": "~6.0.0",
|
||||
"expo-linking": "~6.3.0",
|
||||
"expo-notifications": "~0.28.0",
|
||||
"expo-router": "~3.5.0",
|
||||
"expo-secure-store": "~13.0.0",
|
||||
"expo-status-bar": "~1.12.0",
|
||||
|
|
|
|||
|
|
@ -23,8 +23,8 @@
|
|||
"@mui/icons-material": "^7.0.0",
|
||||
"@mui/material": "^7.0.0",
|
||||
"@mui/material-nextjs": "^7.0.0",
|
||||
"@supabase/ssr": "^0.5.0",
|
||||
"@supabase/supabase-js": "^2.45.0",
|
||||
"@supabase/ssr": "^0.10.0",
|
||||
"@supabase/supabase-js": "^2.103.0",
|
||||
"next": "^15.0.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
|
|
|
|||
17
apps/web/src/app/(app)/chat/page.tsx
Normal file
17
apps/web/src/app/(app)/chat/page.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
// apps/web/src/app/(app)/chat/page.tsx
|
||||
// AI 채팅 페이지 — auth/Sidebar는 (app)/layout.tsx가 제공
|
||||
|
||||
import { Box } from '@mui/material'
|
||||
import { PhosphorText } from '@d3ro/ui/components/ds'
|
||||
import { ChatPanel } from '@/components/chat/chat-panel'
|
||||
|
||||
export default function ChatPage(): React.ReactElement {
|
||||
return (
|
||||
<Box sx={{ p: 4 }}>
|
||||
<PhosphorText variant="title" sx={{ mb: 3 }}>
|
||||
CHAT
|
||||
</PhosphorText>
|
||||
<ChatPanel />
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
91
apps/web/src/app/(app)/knowledge/page.tsx
Normal file
91
apps/web/src/app/(app)/knowledge/page.tsx
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
// apps/web/src/app/(app)/knowledge/page.tsx
|
||||
// 지식 베이스 — RAG 문서 리스트 + 추가
|
||||
|
||||
import { Box, Stack } from '@mui/material'
|
||||
import { MetalCard, PhosphorText } from '@d3ro/ui/components/ds'
|
||||
import { d3roPalette, typoSx } from '@d3ro/ui/theme'
|
||||
import { AddKnowledgeForm } from '@/components/knowledge/add-knowledge-form'
|
||||
import { getSupabaseServerClient } from '@/lib/supabase-server'
|
||||
|
||||
interface KnowledgeDoc {
|
||||
id: string
|
||||
title: string
|
||||
file_name: string | null
|
||||
file_type: string | null
|
||||
chunk_count: number
|
||||
indexed: boolean
|
||||
created_at: string
|
||||
}
|
||||
|
||||
async function loadDocs(): Promise<KnowledgeDoc[]> {
|
||||
try {
|
||||
const supabase = await getSupabaseServerClient()
|
||||
const { data } = await supabase
|
||||
.from('knowledge_documents')
|
||||
.select('id, title, file_name, file_type, chunk_count, indexed, created_at')
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(100)
|
||||
return (data ?? []) as KnowledgeDoc[]
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export default async function KnowledgePage(): Promise<React.ReactElement> {
|
||||
const docs = await loadDocs()
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 4 }}>
|
||||
<PhosphorText variant="title" sx={{ mb: 1 }}>
|
||||
KNOWLEDGE
|
||||
</PhosphorText>
|
||||
<Box sx={{ color: d3roPalette.text.muted, fontSize: 13, mb: 4 }}>
|
||||
지식 베이스에 문서를 추가하면 AI 채팅/회의록 생성에 활용됩니다. (임베딩 기반 시맨틱
|
||||
검색은 V2-M+1에서 추가 예정)
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<AddKnowledgeForm />
|
||||
</Box>
|
||||
|
||||
{docs.length === 0 ? (
|
||||
<MetalCard sx={{ p: 6, textAlign: 'center', color: d3roPalette.text.muted }}>
|
||||
아직 지식 문서가 없습니다. 위에서 추가하세요.
|
||||
</MetalCard>
|
||||
) : (
|
||||
<Stack spacing={2}>
|
||||
{docs.map((doc) => (
|
||||
<MetalCard key={doc.id} sx={{ p: 3 }}>
|
||||
<Stack direction="row" alignItems="center" justifyContent="space-between">
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Box sx={{ ...typoSx('body'), color: d3roPalette.text.primary, mb: 0.5 }}>
|
||||
{doc.title}
|
||||
</Box>
|
||||
<Box sx={{ color: d3roPalette.text.muted, fontSize: 11 }}>
|
||||
{doc.file_type?.toUpperCase() ?? '—'} · {doc.chunk_count} chunks ·{' '}
|
||||
{new Date(doc.created_at).toLocaleDateString('ko-KR')}
|
||||
</Box>
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
ml: 2,
|
||||
px: 1.5,
|
||||
py: 0.5,
|
||||
borderRadius: 1,
|
||||
fontSize: 10,
|
||||
letterSpacing: '1px',
|
||||
textTransform: 'uppercase',
|
||||
bgcolor: doc.indexed ? d3roPalette.tag.greenBg : d3roPalette.tag.orangeBg,
|
||||
color: doc.indexed ? d3roPalette.tag.green : d3roPalette.tag.orange
|
||||
}}
|
||||
>
|
||||
{doc.indexed ? 'INDEXED' : 'PENDING'}
|
||||
</Box>
|
||||
</Stack>
|
||||
</MetalCard>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
|
@ -10,6 +10,7 @@ import {
|
|||
LiveTranscriptList,
|
||||
type TranscriptRow
|
||||
} from '@/components/meetings/live-transcript-list'
|
||||
import { GenerateDocumentButton } from '@/components/meetings/generate-document-button'
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>
|
||||
|
|
@ -94,9 +95,15 @@ export default async function MeetingDetailPage({ params }: PageProps): Promise<
|
|||
|
||||
{/* Documents */}
|
||||
<MetalCard sx={{ p: 3 }}>
|
||||
<PhosphorText variant="heading" sx={{ mb: 2 }}>
|
||||
DOCUMENTS
|
||||
</PhosphorText>
|
||||
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mb: 2 }}>
|
||||
<PhosphorText variant="heading">DOCUMENTS</PhosphorText>
|
||||
<GenerateDocumentButton
|
||||
meetingId={id}
|
||||
transcript={
|
||||
meeting.edited_transcript ?? meeting.raw_transcript ?? ''
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
{(documents ?? []).length === 0 ? (
|
||||
<Box sx={{ color: d3roPalette.text.muted, fontSize: 13 }}>
|
||||
생성된 문서가 없습니다.
|
||||
|
|
|
|||
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>
|
||||
)
|
||||
}
|
||||
160
apps/web/src/components/knowledge/add-knowledge-form.tsx
Normal file
160
apps/web/src/components/knowledge/add-knowledge-form.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -8,6 +8,8 @@ import { Box, List, ListItem, ListItemButton, ListItemIcon, ListItemText } from
|
|||
import DashboardIcon from '@mui/icons-material/Dashboard'
|
||||
import MeetingRoomIcon from '@mui/icons-material/MeetingRoom'
|
||||
import MicIcon from '@mui/icons-material/Mic'
|
||||
import ChatIcon from '@mui/icons-material/Chat'
|
||||
import LibraryBooksIcon from '@mui/icons-material/LibraryBooks'
|
||||
import GroupsIcon from '@mui/icons-material/Groups'
|
||||
import PaymentIcon from '@mui/icons-material/Payment'
|
||||
import LogoutIcon from '@mui/icons-material/Logout'
|
||||
|
|
@ -47,6 +49,18 @@ export function Sidebar(): React.ReactElement {
|
|||
label: t('nav.record') ?? 'Record',
|
||||
icon: <MicIcon />
|
||||
},
|
||||
{
|
||||
key: 'chat',
|
||||
path: '/chat',
|
||||
label: t('nav.chat') ?? 'Chat',
|
||||
icon: <ChatIcon />
|
||||
},
|
||||
{
|
||||
key: 'knowledge',
|
||||
path: '/knowledge',
|
||||
label: t('nav.knowledge') ?? 'Knowledge',
|
||||
icon: <LibraryBooksIcon />
|
||||
},
|
||||
{
|
||||
key: 'teams',
|
||||
path: '/teams',
|
||||
|
|
|
|||
183
apps/web/src/components/meetings/generate-document-button.tsx
Normal file
183
apps/web/src/components/meetings/generate-document-button.tsx
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
'use client'
|
||||
|
||||
// apps/web/src/components/meetings/generate-document-button.tsx
|
||||
// 회의록에서 AI 문서 생성 — llm-proxy 호출 → meeting_documents INSERT
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import {
|
||||
Button,
|
||||
Menu,
|
||||
MenuItem,
|
||||
CircularProgress,
|
||||
Alert,
|
||||
Box
|
||||
} from '@mui/material'
|
||||
import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome'
|
||||
import { getSupabaseBrowserClient } from '@/lib/supabase-browser'
|
||||
|
||||
type TemplateType = 'minutes' | 'report' | 'idea-note' | 'mindmap'
|
||||
|
||||
interface TemplateDef {
|
||||
key: TemplateType
|
||||
label: string
|
||||
systemPrompt: string
|
||||
title: string
|
||||
}
|
||||
|
||||
const TEMPLATES: TemplateDef[] = [
|
||||
{
|
||||
key: 'minutes',
|
||||
label: '회의록',
|
||||
title: '회의록',
|
||||
systemPrompt:
|
||||
'다음 회의 전사를 바탕으로 정돈된 회의록을 마크다운으로 작성하세요. 섹션: 요약 / 주요 결정사항 / 액션 아이템 / 논의 상세.'
|
||||
},
|
||||
{
|
||||
key: 'report',
|
||||
label: '리포트',
|
||||
title: '리포트',
|
||||
systemPrompt:
|
||||
'다음 회의 전사를 바탕으로 격식 있는 리포트를 마크다운으로 작성하세요. 섹션: 개요 / 배경 / 주요 내용 / 결론 / 권고사항.'
|
||||
},
|
||||
{
|
||||
key: 'idea-note',
|
||||
label: '아이디어 노트',
|
||||
title: '아이디어 노트',
|
||||
systemPrompt:
|
||||
'다음 회의 전사에서 아이디어와 인사이트를 추출해 마크다운으로 정리하세요. 각 아이디어에 맥락/가능성/다음 단계를 포함.'
|
||||
},
|
||||
{
|
||||
key: 'mindmap',
|
||||
label: '마인드맵',
|
||||
title: '마인드맵',
|
||||
systemPrompt:
|
||||
'다음 회의 전사의 핵심 주제와 하위 토픽을 마크다운 아웃라인 (마인드맵 구조)으로 작성하세요.'
|
||||
}
|
||||
]
|
||||
|
||||
interface GenerateDocumentButtonProps {
|
||||
meetingId: string
|
||||
transcript: string
|
||||
}
|
||||
|
||||
interface LlmResponse {
|
||||
id: string
|
||||
model: string
|
||||
content: Array<{ type: string; text: string }>
|
||||
}
|
||||
|
||||
export function GenerateDocumentButton({
|
||||
meetingId,
|
||||
transcript
|
||||
}: GenerateDocumentButtonProps): React.ReactElement {
|
||||
const router = useRouter()
|
||||
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
async function handleGenerate(tpl: TemplateDef): Promise<void> {
|
||||
setAnchorEl(null)
|
||||
setError(null)
|
||||
setBusy(true)
|
||||
|
||||
try {
|
||||
const supabase = getSupabaseBrowserClient()
|
||||
const {
|
||||
data: { session }
|
||||
} = await supabase.auth.getSession()
|
||||
if (!session) {
|
||||
setError('로그인이 필요합니다')
|
||||
return
|
||||
}
|
||||
|
||||
if (!transcript || transcript.trim().length === 0) {
|
||||
setError('전사 내용이 없습니다. 먼저 회의를 녹음하세요.')
|
||||
return
|
||||
}
|
||||
|
||||
// llm-proxy 호출
|
||||
const startedAt = Date.now()
|
||||
const llmResp = 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({
|
||||
messages: [{ role: 'user', content: transcript }],
|
||||
system: tpl.systemPrompt,
|
||||
max_tokens: 4096
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
if (!llmResp.ok) {
|
||||
const errTxt = await llmResp.text()
|
||||
throw new Error(`LLM 호출 실패: ${llmResp.status} ${errTxt}`)
|
||||
}
|
||||
|
||||
const llmData = (await llmResp.json()) as LlmResponse
|
||||
const content = llmData.content?.[0]?.text ?? ''
|
||||
const latency = Date.now() - startedAt
|
||||
|
||||
// meeting_documents INSERT
|
||||
const {
|
||||
data: { user }
|
||||
} = await supabase.auth.getUser()
|
||||
if (!user) {
|
||||
setError('로그인이 필요합니다')
|
||||
return
|
||||
}
|
||||
|
||||
const { error: insertErr } = await supabase.from('meeting_documents').insert({
|
||||
meeting_id: meetingId,
|
||||
user_id: user.id,
|
||||
template_type: tpl.key,
|
||||
title: tpl.title,
|
||||
content,
|
||||
prompt_used: tpl.systemPrompt,
|
||||
llm_model: llmData.model,
|
||||
llm_latency_ms: latency
|
||||
})
|
||||
|
||||
if (insertErr) {
|
||||
setError(`문서 저장 실패: ${insertErr.message}`)
|
||||
return
|
||||
}
|
||||
|
||||
router.refresh()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Unknown error')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={busy ? <CircularProgress size={16} color="inherit" /> : <AutoAwesomeIcon />}
|
||||
onClick={(e) => setAnchorEl(e.currentTarget)}
|
||||
disabled={busy}
|
||||
>
|
||||
{busy ? '생성 중...' : 'AI 문서 생성'}
|
||||
</Button>
|
||||
<Menu anchorEl={anchorEl} open={Boolean(anchorEl)} onClose={() => setAnchorEl(null)}>
|
||||
{TEMPLATES.map((tpl) => (
|
||||
<MenuItem key={tpl.key} onClick={() => void handleGenerate(tpl)}>
|
||||
{tpl.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
{error && (
|
||||
<Alert severity="error" variant="outlined" sx={{ mt: 1, fontSize: 11 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
|
@ -4,21 +4,20 @@
|
|||
'use client'
|
||||
|
||||
// apps/web/src/lib/supabase-browser.ts
|
||||
// V2-3 MVP: Database 제네릭 없이 동작. @supabase/ssr와 supabase-js의 내부 타입
|
||||
// 경로 불일치(@supabase/supabase-js/dist/module/lib/types)로 인해 제네릭 주입 불가.
|
||||
// 다음 사이클에서 supabase-js/ssr 버전 정합 맞춘 후 Database 제네릭 복원 예정.
|
||||
// Database 제네릭 주입 (@supabase/ssr 0.10 + supabase-js 2.103 정합)
|
||||
|
||||
import { createBrowserClient } from '@supabase/ssr'
|
||||
import type { Database } from '@d3ro/api-client'
|
||||
|
||||
let cachedClient: ReturnType<typeof createBrowserClient> | null = null
|
||||
let cachedClient: ReturnType<typeof createBrowserClient<Database>> | null = null
|
||||
|
||||
export function getSupabaseBrowserClient(): ReturnType<typeof createBrowserClient> {
|
||||
export function getSupabaseBrowserClient(): ReturnType<typeof createBrowserClient<Database>> {
|
||||
if (cachedClient) return cachedClient
|
||||
|
||||
const url = process.env.NEXT_PUBLIC_SUPABASE_URL ?? 'https://placeholder.supabase.co'
|
||||
const key = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ?? 'placeholder-anon-key'
|
||||
|
||||
cachedClient = createBrowserClient(url, key)
|
||||
cachedClient = createBrowserClient<Database>(url, key)
|
||||
return cachedClient
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,17 +1,18 @@
|
|||
// apps/web/src/lib/supabase-server.ts
|
||||
// RSC/route handler용 Supabase 클라이언트 (쿠키 기반 세션).
|
||||
// Database 제네릭은 @supabase/ssr 내부 경로 이슈로 현재 미적용 (다음 사이클 복원).
|
||||
// Database 제네릭 주입 (@supabase/ssr 0.10 + supabase-js 2.103 정합).
|
||||
|
||||
import { cookies } from 'next/headers'
|
||||
import { createServerClient, type CookieOptions } from '@supabase/ssr'
|
||||
import type { Database } from '@d3ro/api-client'
|
||||
|
||||
export async function getSupabaseServerClient(): Promise<ReturnType<typeof createServerClient>> {
|
||||
export async function getSupabaseServerClient(): Promise<ReturnType<typeof createServerClient<Database>>> {
|
||||
const cookieStore = await cookies()
|
||||
|
||||
const url = process.env.NEXT_PUBLIC_SUPABASE_URL ?? 'https://placeholder.supabase.co'
|
||||
const key = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ?? 'placeholder-anon-key'
|
||||
|
||||
return createServerClient(url, key, {
|
||||
return createServerClient<Database>(url, key, {
|
||||
cookies: {
|
||||
getAll() {
|
||||
return cookieStore.getAll()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue