diff --git a/apps/desktop/src/main/services/CloudSyncService.ts b/apps/desktop/src/main/services/CloudSyncService.ts index f815c24..f059af9 100644 --- a/apps/desktop/src/main/services/CloudSyncService.ts +++ b/apps/desktop/src/main/services/CloudSyncService.ts @@ -4,7 +4,13 @@ import { EventEmitter } from 'events' import { shell, app, safeStorage } from 'electron' -import { createClient, type SupabaseClient, type Session, type User } from '@supabase/supabase-js' +import { + createClient, + type SupabaseClient, + type Session, + type User, + type RealtimeChannel +} from '@supabase/supabase-js' import { eq, gt } from 'drizzle-orm' import { getLogger } from './LoggerService' import { configGet } from './ConfigService' @@ -53,6 +59,7 @@ class CloudSyncService extends EventEmitter { private _lastSyncAt: number | null = null private _syncing = false private _initialized = false + private _realtimeChannel: RealtimeChannel | null = null /** * 초기화 — Supabase 클라이언트 생성, 저장된 세션 복원. @@ -99,6 +106,13 @@ class CloudSyncService extends EventEmitter { this._lastSyncAt = (configGet('cloudSyncLastAt') as number | undefined) ?? null + // 복원된 세션이 있으면 Realtime 구독 자동 시작 + if (this._session) { + void this.startRealtime().catch((err) => { + logger.warn(`Realtime 자동 시작 실패: ${err instanceof Error ? err.message : String(err)}`) + }) + } + logger.info('CloudSyncService initialized') } @@ -129,6 +143,69 @@ class CloudSyncService extends EventEmitter { return this._lastSyncAt ? new Date(this._lastSyncAt) : null } + // ── Realtime 구독 ────────────────────────────────────── + + /** + * 원격 변경사항을 실시간으로 구독한다. + * meetings / meeting_memos / meeting_documents / history / dictionary에 + * INSERT/UPDATE 이벤트가 오면 pullAll()로 자동 동기화. + * 중복 호출 방지를 위해 기존 채널이 있으면 먼저 해제. + */ + async startRealtime(): Promise { + if (!this._client || !this._session) { + logger.warn('Realtime 시작 불가 — 로그인 필요') + return + } + if (this._realtimeChannel) { + await this._realtimeChannel.unsubscribe() + this._realtimeChannel = null + } + + const userId = this._session.user.id + + // 변경 감지 debounce — 연속 이벤트가 몰릴 때 한 번만 pull + let pullScheduled = false + const schedulePull = (): void => { + if (pullScheduled || this._syncing) return + pullScheduled = true + setTimeout(() => { + pullScheduled = false + void this.pullAll().catch((err) => { + logger.warn(`Realtime 트리거 pull 실패: ${err instanceof Error ? err.message : String(err)}`) + }) + }, 1500) + } + + this._realtimeChannel = this._client + .channel(`cloud-sync:${userId}`) + .on( + 'postgres_changes', + { event: '*', schema: 'public', table: 'meetings', filter: `user_id=eq.${userId}` }, + () => schedulePull() + ) + .on( + 'postgres_changes', + { event: '*', schema: 'public', table: 'history', filter: `user_id=eq.${userId}` }, + () => schedulePull() + ) + .on( + 'postgres_changes', + { event: '*', schema: 'public', table: 'dictionary', filter: `user_id=eq.${userId}` }, + () => schedulePull() + ) + .subscribe((status) => { + logger.info(`Realtime 채널 상태: ${status}`) + }) + } + + async stopRealtime(): Promise { + if (this._realtimeChannel) { + await this._realtimeChannel.unsubscribe() + this._realtimeChannel = null + logger.info('Realtime 채널 종료') + } + } + // ── OAuth 로그인 ─────────────────────────────────────── /** @@ -181,12 +258,19 @@ class CloudSyncService extends EventEmitter { this._saveRefreshToken(data.session.refresh_token) logger.info(`Signed in: ${data.session.user.email ?? data.session.user.id}`) this.emit('auth-changed', { user: data.session.user }) + + // 로그인 직후 Realtime 구독 자동 시작 + void this.startRealtime().catch((err) => { + logger.warn(`Realtime 자동 시작 실패: ${err instanceof Error ? err.message : String(err)}`) + }) } /** - * 로그아웃 — 세션/토큰 모두 폐기. + * 로그아웃 — 세션/토큰/Realtime 모두 폐기. */ async signOut(): Promise { + await this.stopRealtime() + if (this._client && this._session) { try { await this._client.auth.signOut() diff --git a/apps/web/src/app/(app)/actions/page.tsx b/apps/web/src/app/(app)/actions/page.tsx new file mode 100644 index 0000000..6a1392a --- /dev/null +++ b/apps/web/src/app/(app)/actions/page.tsx @@ -0,0 +1,22 @@ +// apps/web/src/app/(app)/actions/page.tsx +// 음성 액션 (텍스트 명령 → LLM 파싱 → 실행) + +import { Box } from '@mui/material' +import { PhosphorText } from '@d3ro/ui/components/ds' +import { d3roPalette } from '@d3ro/ui/theme' +import { ActionRunner } from '@/components/actions/action-runner' + +export default function ActionsPage(): React.ReactElement { + return ( + + + ACTIONS + + + 자연어 명령을 입력하면 LLM이 파싱하여 실행 가능한 액션으로 변환합니다. (V1 + VoiceActionService의 web 포트) + + + + ) +} diff --git a/apps/web/src/app/(app)/knowledge/page.tsx b/apps/web/src/app/(app)/knowledge/page.tsx index d065380..19386e5 100644 --- a/apps/web/src/app/(app)/knowledge/page.tsx +++ b/apps/web/src/app/(app)/knowledge/page.tsx @@ -5,6 +5,7 @@ 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 { KnowledgeSearch } from '@/components/knowledge/knowledge-search' import { getSupabaseServerClient } from '@/lib/supabase-server' interface KnowledgeDoc { @@ -48,6 +49,10 @@ export default async function KnowledgePage(): Promise { + + + + {docs.length === 0 ? ( 아직 지식 문서가 없습니다. 위에서 추가하세요. diff --git a/apps/web/src/app/(app)/meetings/[id]/page.tsx b/apps/web/src/app/(app)/meetings/[id]/page.tsx index 8c9a4dd..7430ac3 100644 --- a/apps/web/src/app/(app)/meetings/[id]/page.tsx +++ b/apps/web/src/app/(app)/meetings/[id]/page.tsx @@ -4,13 +4,15 @@ import { notFound } from 'next/navigation' import { Box, Stack } from '@mui/material' import { MetalCard, PhosphorText } from '@d3ro/ui/components/ds' -import { d3roPalette, typoSx } from '@d3ro/ui/theme' +import { d3roPalette } from '@d3ro/ui/theme' import { getSupabaseServerClient } from '@/lib/supabase-server' import { LiveTranscriptList, type TranscriptRow } from '@/components/meetings/live-transcript-list' import { GenerateDocumentButton } from '@/components/meetings/generate-document-button' +import { DocumentEditor } from '@/components/meetings/document-editor' +import { MemoForm } from '@/components/meetings/memo-form' interface PageProps { params: Promise<{ id: string }> @@ -91,6 +93,7 @@ export default async function MeetingDetailPage({ params }: PageProps): Promise< ))} )} + {/* Documents */} @@ -111,21 +114,16 @@ export default async function MeetingDetailPage({ params }: PageProps): Promise< ) : ( {(documents ?? []).map((doc) => ( - - - {doc.title} - - - {doc.template_type} · {new Date(doc.created_at).toLocaleDateString('ko-KR')} - - + /> ))} )} diff --git a/apps/web/src/components/actions/action-runner.tsx b/apps/web/src/components/actions/action-runner.tsx new file mode 100644 index 0000000..193fcac --- /dev/null +++ b/apps/web/src/components/actions/action-runner.tsx @@ -0,0 +1,264 @@ +'use client' + +// apps/web/src/components/actions/action-runner.tsx +// 텍스트 명령 → LLM 파싱 (function-like JSON) → 동작 시뮬레이션. +// V1 VoiceActionService의 간이 web 포트. + +import { useState } from 'react' +import { Box, Button, TextField, Stack, Alert, CircularProgress, Chip } from '@mui/material' +import PlayArrowIcon from '@mui/icons-material/PlayArrow' +import { MetalCard, PhosphorText } from '@d3ro/ui/components/ds' +import { d3roPalette, typoSx } from '@d3ro/ui/theme' +import { getSupabaseBrowserClient } from '@/lib/supabase-browser' + +type ActionType = + | 'create_meeting' + | 'search_knowledge' + | 'create_memo' + | 'send_team_invite' + | 'unknown' + +interface ActionResult { + type: ActionType + args: Record + rationale: string +} + +const SYSTEM_PROMPT = `당신은 D3RO Voice의 음성 액션 파서입니다. +사용자의 자연어 명령을 다음 JSON 스키마로 변환하세요: + +{ + "type": "create_meeting" | "search_knowledge" | "create_memo" | "send_team_invite" | "unknown", + "args": { ... }, + "rationale": "왜 이 액션을 선택했는지 한 줄" +} + +각 type의 args 스키마: +- create_meeting: { title: string } +- search_knowledge: { query: string } +- create_memo: { meeting_id?: string, content: string } +- send_team_invite: { team_id: string, email: string, role?: "admin" | "member" } +- unknown: {} + +JSON만 출력하세요. 설명 불필요.` + +interface LlmResponse { + content: Array<{ type: string; text: string }> +} + +export function ActionRunner(): React.ReactElement { + const [input, setInput] = useState('') + const [busy, setBusy] = useState(false) + const [error, setError] = useState(null) + const [result, setResult] = useState(null) + const [executed, setExecuted] = useState(null) + + async function handleParse(): Promise { + if (!input.trim()) return + setError(null) + setResult(null) + setExecuted(null) + setBusy(true) + + try { + const supabase = getSupabaseBrowserClient() + const { + data: { session } + } = await supabase.auth.getSession() + if (!session) { + throw new Error('로그인이 필요합니다') + } + + const resp = 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: input.trim() }], + system: SYSTEM_PROMPT, + max_tokens: 512 + }) + } + ) + + if (!resp.ok) { + throw new Error(`LLM 호출 실패: ${resp.status}`) + } + + const data = (await resp.json()) as LlmResponse + const text = data.content?.[0]?.text ?? '' + + // JSON 추출 (LLM 응답이 ```json ... ``` 블록에 싸일 수도) + const jsonMatch = /\{[\s\S]*\}/.exec(text) + if (!jsonMatch) { + throw new Error('LLM 응답에서 JSON을 찾지 못했습니다') + } + + const parsed = JSON.parse(jsonMatch[0]) as ActionResult + setResult(parsed) + } catch (e) { + setError(e instanceof Error ? e.message : 'Unknown error') + } finally { + setBusy(false) + } + } + + async function handleExecute(): Promise { + if (!result) return + setError(null) + setBusy(true) + try { + const supabase = getSupabaseBrowserClient() + const { + data: { user } + } = await supabase.auth.getUser() + if (!user) { + throw new Error('로그인이 필요합니다') + } + + switch (result.type) { + case 'create_meeting': { + const title = (result.args.title as string | undefined) ?? '(제목 없음)' + const { error: err } = await supabase + .from('meetings') + .insert({ user_id: user.id, title, team_id: null, status: 'completed' }) + if (err) throw new Error(err.message) + setExecuted(`회의 "${title}" 생성됨`) + break + } + case 'search_knowledge': { + const query = (result.args.query as string | undefined) ?? '' + setExecuted(`knowledge 검색: "${query}" — /knowledge 페이지에서 결과 확인`) + break + } + case 'create_memo': { + const content = (result.args.content as string | undefined) ?? '' + const meetingId = result.args.meeting_id as string | undefined + if (!meetingId) { + throw new Error('meeting_id가 필요합니다') + } + const { error: err } = await supabase.from('meeting_memos').insert({ + meeting_id: meetingId, + user_id: user.id, + content, + timestamp_ms: 0 + }) + if (err) throw new Error(err.message) + setExecuted(`메모 생성됨: "${content}"`) + break + } + case 'send_team_invite': + setExecuted(`초대는 /teams/[id] 페이지에서 직접 진행하세요 (MVP 안전 장치)`) + break + default: + setExecuted('알 수 없는 액션 — 실행 불가') + } + } catch (e) { + setError(e instanceof Error ? e.message : 'Unknown error') + } finally { + setBusy(false) + } + } + + return ( + + + + COMMAND + + + setInput(e.target.value)} + placeholder="예: '회의록 정리 — 프로젝트 킥오프 생성', '지난주 브레인스토밍 관련 검색'" + disabled={busy} + onKeyDown={(e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault() + void handleParse() + } + }} + /> + + + + + {error && ( + + {error} + + )} + + {result && ( + + + PARSED ACTION + + + + TYPE + + + + ARGS + + {JSON.stringify(result.args, null, 2)} + + + + RATIONALE + {result.rationale} + + + + + )} + + {executed && ( + + {executed} + + )} + + ) +} diff --git a/apps/web/src/components/chat/chat-panel.tsx b/apps/web/src/components/chat/chat-panel.tsx index 2b41ced..61cd1e8 100644 --- a/apps/web/src/components/chat/chat-panel.tsx +++ b/apps/web/src/components/chat/chat-panel.tsx @@ -18,15 +18,6 @@ interface Message { 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([]) const [input, setInput] = useState('') @@ -70,7 +61,8 @@ export function ChatPanel(): React.ReactElement { const payload = { messages: nextMessages.map((m) => ({ role: m.role, content: m.content })), - max_tokens: 1024 + max_tokens: 1024, + stream: true } const response = await fetch( @@ -90,15 +82,54 @@ export function ChatPanel(): React.ReactElement { 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 + if (!response.body) { + throw new Error('응답 body가 없습니다') + } + + // SSE 스트림 파싱 — Anthropic content_block_delta 이벤트의 text_delta 누적 + const assistantId = `a_${Date.now()}` + setMessages((prev) => [...prev, { id: assistantId, role: 'assistant', content: '' }]) + + const reader = response.body.getReader() + const decoder = new TextDecoder() + let buffer = '' + let accumulated = '' + let streamDone = false + + while (!streamDone) { + const { done, value } = await reader.read() + if (done) { + streamDone = true + break + } + + buffer += decoder.decode(value, { stream: true }) + const lines = buffer.split('\n') + buffer = lines.pop() ?? '' + + for (const line of lines) { + const trimmed = line.trim() + if (!trimmed.startsWith('data:')) continue + const data = trimmed.slice(5).trim() + if (data === '[DONE]' || data === '') continue + + try { + const event = JSON.parse(data) as { + type?: string + delta?: { type?: string; text?: string } + } + if (event.type === 'content_block_delta' && event.delta?.type === 'text_delta') { + accumulated += event.delta.text ?? '' + setMessages((prev) => + prev.map((m) => (m.id === assistantId ? { ...m, content: accumulated } : m)) + ) + scrollToBottom() + } + } catch { + // 불완전한 JSON 무시 + } + } } - setMessages((prev) => [...prev, assistantMsg]) - scrollToBottom() } catch (e) { setError(e instanceof Error ? e.message : 'Unknown error') } finally { diff --git a/apps/web/src/components/knowledge/knowledge-search.tsx b/apps/web/src/components/knowledge/knowledge-search.tsx new file mode 100644 index 0000000..43a4da5 --- /dev/null +++ b/apps/web/src/components/knowledge/knowledge-search.tsx @@ -0,0 +1,133 @@ +'use client' + +// apps/web/src/components/knowledge/knowledge-search.tsx +// 지식 베이스 시맨틱 검색 — search-knowledge Edge Function 호출 + +import { useState } from 'react' +import { Box, TextField, Button, Stack, Alert, CircularProgress } from '@mui/material' +import SearchIcon from '@mui/icons-material/Search' +import { MetalCard, PhosphorText } from '@d3ro/ui/components/ds' +import { d3roPalette, typoSx } from '@d3ro/ui/theme' +import { getSupabaseBrowserClient } from '@/lib/supabase-browser' + +interface SearchResult { + id: string + document_id: string + chunk_index: number + content: string + similarity: number +} + +export function KnowledgeSearch(): React.ReactElement { + const [query, setQuery] = useState('') + const [results, setResults] = useState([]) + const [busy, setBusy] = useState(false) + const [error, setError] = useState(null) + + async function handleSearch(): Promise { + if (!query.trim()) return + setError(null) + setBusy(true) + try { + const supabase = getSupabaseBrowserClient() + const { + data: { session } + } = await supabase.auth.getSession() + if (!session) { + setError('로그인이 필요합니다') + return + } + + const response = await fetch( + `${process.env.NEXT_PUBLIC_SUPABASE_URL}/functions/v1/search-knowledge`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${session.access_token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ query: query.trim(), count: 8 }) + } + ) + + if (!response.ok) { + const errData = (await response.json()) as { error?: string; message?: string } + throw new Error(errData.message ?? errData.error ?? `검색 실패: ${response.status}`) + } + + const data = (await response.json()) as { results: SearchResult[] } + setResults(data.results) + } catch (e) { + setError(e instanceof Error ? e.message : 'Unknown error') + } finally { + setBusy(false) + } + } + + return ( + + + setQuery(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault() + void handleSearch() + } + }} + placeholder="지식 베이스에서 검색... (시맨틱)" + disabled={busy} + /> + + + + {error && ( + + {error} + + )} + + {results.length > 0 && ( + + + 검색 결과 ({results.length}) + + {results.map((r) => ( + + + + #{r.chunk_index} + + + {Math.round(r.similarity * 100)}% + + + + {r.content} + + + ))} + + )} + + ) +} diff --git a/apps/web/src/components/layout/sidebar.tsx b/apps/web/src/components/layout/sidebar.tsx index 1d876f3..7a27590 100644 --- a/apps/web/src/components/layout/sidebar.tsx +++ b/apps/web/src/components/layout/sidebar.tsx @@ -10,6 +10,7 @@ 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 AutoAwesomeIcon from '@mui/icons-material/AutoAwesome' import GroupsIcon from '@mui/icons-material/Groups' import PaymentIcon from '@mui/icons-material/Payment' import LogoutIcon from '@mui/icons-material/Logout' @@ -61,6 +62,12 @@ export function Sidebar(): React.ReactElement { label: t('nav.knowledge') ?? 'Knowledge', icon: }, + { + key: 'actions', + path: '/actions', + label: t('nav.actions') ?? 'Actions', + icon: + }, { key: 'teams', path: '/teams', diff --git a/apps/web/src/components/meetings/document-editor.tsx b/apps/web/src/components/meetings/document-editor.tsx new file mode 100644 index 0000000..337a068 --- /dev/null +++ b/apps/web/src/components/meetings/document-editor.tsx @@ -0,0 +1,171 @@ +'use client' + +// apps/web/src/components/meetings/document-editor.tsx +// 회의록 문서 편집 — MarkdownEditor 다이얼로그 +// 클릭 → 다이얼로그 열림 → textarea 편집 → 저장 시 meeting_documents UPDATE + +import { useState } from 'react' +import { useRouter } from 'next/navigation' +import { + Dialog, + DialogTitle, + DialogContent, + DialogActions, + TextField, + Button, + Box, + Alert, + IconButton, + Stack +} from '@mui/material' +import CloseIcon from '@mui/icons-material/Close' +import SaveIcon from '@mui/icons-material/Save' +import DeleteIcon from '@mui/icons-material/Delete' +import { d3roPalette, typoSx } from '@d3ro/ui/theme' +import { getSupabaseBrowserClient } from '@/lib/supabase-browser' + +interface DocumentEditorProps { + doc: { + id: string + title: string + content: string + template_type: string + created_at: string + } +} + +export function DocumentEditor({ doc }: DocumentEditorProps): React.ReactElement { + const router = useRouter() + const [open, setOpen] = useState(false) + const [title, setTitle] = useState(doc.title) + const [content, setContent] = useState(doc.content) + const [busy, setBusy] = useState(false) + const [error, setError] = useState(null) + + async function handleSave(): Promise { + setError(null) + setBusy(true) + try { + const supabase = getSupabaseBrowserClient() + const { error: updateErr } = await supabase + .from('meeting_documents') + .update({ title, content }) + .eq('id', doc.id) + + if (updateErr) { + setError(updateErr.message) + return + } + + setOpen(false) + router.refresh() + } finally { + setBusy(false) + } + } + + async function handleDelete(): Promise { + if (!window.confirm('이 문서를 삭제하시겠습니까?')) return + setError(null) + setBusy(true) + try { + const supabase = getSupabaseBrowserClient() + const { error: deleteErr } = await supabase + .from('meeting_documents') + .delete() + .eq('id', doc.id) + + if (deleteErr) { + setError(deleteErr.message) + return + } + + setOpen(false) + router.refresh() + } finally { + setBusy(false) + } + } + + return ( + <> + setOpen(true)} + sx={{ + p: 2, + bgcolor: d3roPalette.bg.inset, + borderRadius: 1, + cursor: 'pointer', + '&:hover': { bgcolor: d3roPalette.bg.cardHover } + }} + > + {doc.title} + + {doc.template_type} · {new Date(doc.created_at).toLocaleDateString('ko-KR')} + + + + setOpen(false)} maxWidth="md" fullWidth> + + + setTitle(e.target.value)} + variant="standard" + placeholder="제목" + sx={{ flex: 1, mr: 2 }} + /> + setOpen(false)} size="small"> + + + + + + setContent(e.target.value)} + disabled={busy} + placeholder="마크다운으로 편집..." + variant="outlined" + slotProps={{ + input: { + style: { fontFamily: 'monospace', fontSize: 13 } + } + }} + /> + {error && ( + + {error} + + )} + + + + + + + + + + ) +} diff --git a/apps/web/src/components/meetings/memo-form.tsx b/apps/web/src/components/meetings/memo-form.tsx new file mode 100644 index 0000000..8bcbea1 --- /dev/null +++ b/apps/web/src/components/meetings/memo-form.tsx @@ -0,0 +1,97 @@ +'use client' + +// apps/web/src/components/meetings/memo-form.tsx +// 회의 메모 작성 — meeting_memos INSERT + Realtime 구독 유도 + +import { useState } from 'react' +import { useRouter } from 'next/navigation' +import { Box, TextField, Button, Stack, Alert } from '@mui/material' +import NoteAddIcon from '@mui/icons-material/NoteAdd' +import { d3roPalette } from '@d3ro/ui/theme' +import { getSupabaseBrowserClient } from '@/lib/supabase-browser' + +interface MemoFormProps { + meetingId: string + meetingStartedAt: string +} + +export function MemoForm({ meetingId, meetingStartedAt }: MemoFormProps): React.ReactElement { + const router = useRouter() + const [content, setContent] = useState('') + const [busy, setBusy] = useState(false) + const [error, setError] = useState(null) + + async function handleSubmit(): Promise { + if (!content.trim()) return + setError(null) + setBusy(true) + try { + const supabase = getSupabaseBrowserClient() + const { + data: { user } + } = await supabase.auth.getUser() + if (!user) { + setError('로그인이 필요합니다') + return + } + + // 회의 시작 시간 대비 경과 밀리초 + const elapsedMs = Math.max(0, Date.now() - new Date(meetingStartedAt).getTime()) + + const { error: insertErr } = await supabase.from('meeting_memos').insert({ + meeting_id: meetingId, + user_id: user.id, + content: content.trim(), + timestamp_ms: elapsedMs + }) + + if (insertErr) { + setError(insertErr.message) + return + } + + setContent('') + router.refresh() + } finally { + setBusy(false) + } + } + + return ( + + + setContent(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault() + void handleSubmit() + } + }} + placeholder="메모 입력... (Enter 저장)" + disabled={busy} + /> + + + {error && ( + + {error} + + )} + + 회의 시작 후 경과 시간으로 timestamp_ms가 자동 계산됩니다. + + + ) +} diff --git a/apps/web/src/components/record/mic-recorder.tsx b/apps/web/src/components/record/mic-recorder.tsx index 3ce89a6..98f3c67 100644 --- a/apps/web/src/components/record/mic-recorder.tsx +++ b/apps/web/src/components/record/mic-recorder.tsx @@ -176,6 +176,43 @@ export function MicRecorder(): React.ReactElement { const data = (await response.json()) as SttResponse setTranscript(data.transcript) + + // 회의로 저장 + Supabase Storage에 오디오 업로드 + try { + const { + data: { user } + } = await supabase.auth.getUser() + + if (user) { + const audioKey = `${user.id}/${Date.now()}.webm` + const { error: uploadErr } = await supabase.storage + .from('audio') + .upload(audioKey, blob, { contentType: 'audio/webm' }) + + const storageKey: string | null = uploadErr ? null : audioKey + + const { error: insertErr } = await supabase.from('meetings').insert({ + user_id: user.id, + team_id: null, + title: `녹음 ${new Date().toLocaleString('ko-KR')}`, + status: 'completed', + duration_ms: Math.round(data.duration_seconds * 1000), + raw_transcript: data.transcript, + audio_storage_key: storageKey, + stt_model: 'google-stt', + ended_at: new Date().toISOString() + }) + if (insertErr) { + // 회의 저장 실패해도 전사 결과는 유지 + setError(`회의 저장 실패 (전사는 성공): ${insertErr.message}`) + } + } + } catch (saveErr) { + setError( + `저장 중 오류 (전사는 성공): ${saveErr instanceof Error ? saveErr.message : String(saveErr)}` + ) + } + setState('done') } catch (e) { setError(e instanceof Error ? e.message : 'STT 처리 실패') diff --git a/memory/project_status.md b/memory/project_status.md index eea5217..1a59906 100644 --- a/memory/project_status.md +++ b/memory/project_status.md @@ -34,14 +34,22 @@ V2 3차 고도화 (2026-04-10): - [J] ✅ api-client 유닛 테스트 + web E2E 스모크 스캐폴딩 V2 4차 고도화 (2026-04-10): -- [K] ✅ @supabase/ssr 0.5→0.10, supabase-js 2.45→2.103 bump → Database 제네릭 완전 주입, api-client 루트 barrel 복원 -- [L] ✅ V1 Voice Conversation → web /chat (ChatPanel, llm-proxy 호출) -- [M] ✅ V1 RAG → web /knowledge (knowledge_documents/chunks 테이블, 청킹 + tsvector 기반 전문 검색 준비, AddKnowledgeForm) -- [N] ✅ team-invite Resend 이메일 발송 (HTML 템플릿, RESEND_API_KEY 환경변수) -- [O] ✅ mobile Expo Push — push_tokens 테이블, send-push Edge Function (팀 멤버 권한 검사), expo-notifications 등록 로직, app.json plugins -- [P] ✅ meetings/[id] 문서 생성 버튼 — GenerateDocumentButton (minutes/report/idea-note/mindmap 4종), llm-proxy 호출 → meeting_documents INSERT +- [K] ✅ @supabase/ssr 0.5→0.10, supabase-js 2.45→2.103 bump → Database 제네릭 완전 주입 +- [L] ✅ V1 Voice Conversation → web /chat +- [M] ✅ V1 RAG → web /knowledge +- [N] ✅ team-invite Resend 이메일 발송 +- [O] ✅ mobile Expo Push +- [P] ✅ meetings/[id] 문서 생성 버튼 -**다음 사이클**: 사용자 환경 실제 연결(Supabase 배포 + Resend/Stripe 키 + EAS build), pgvector 기반 knowledge 시맨틱 검색, 회의 문서 편집 기능, /chat 스트리밍 전환, V1 VoiceAction web 포팅 +V2 5차 고도화 (2026-04-10): +- [Q] ✅ pgvector + knowledge 시맨틱 검색 — embedding 컬럼(1536차원), ivfflat 인덱스, match_knowledge_chunks RPC, embed-chunks/search-knowledge Edge Functions, /knowledge 검색창 +- [R] ✅ /chat SSE 스트리밍 — llm-proxy가 Anthropic stream 프록시, chat-panel에서 content_block_delta 파싱 후 progressive 렌더링 +- [S] ✅ 회의 문서 편집 DocumentEditor — MUI Dialog + textarea 기반 MarkdownEditor, 저장/삭제 +- [T] ✅ V1 VoiceAction → web /actions — 자연어 명령 LLM 파싱 (create_meeting/search_knowledge/create_memo/send_team_invite), 파싱 결과 확인 후 실행 +- [U] ✅ Desktop CloudSyncService Realtime 구독 + web record 오디오 Storage 업로드 + 회의 메모 작성 UI (MemoForm) +- [V] ✅ 11개 locale에 nav.chat/knowledge/actions 키 추가, ko.json 정리, Sidebar Actions 메뉴 + +**다음 사이클**: 사용자 환경 실제 연결, 회의 상세 편집 고도화(Rich Markdown preview, mermaid 렌더), RAG에서 검색 결과 → /chat으로 연동, VoiceAction 카탈로그 확대, E2E 테스트 실제 실행 (Playwright install) ## V1 완료 페이즈 diff --git a/packages/i18n/src/locales/de.json b/packages/i18n/src/locales/de.json index e6f8961..d11d998 100644 --- a/packages/i18n/src/locales/de.json +++ b/packages/i18n/src/locales/de.json @@ -8,6 +8,9 @@ "nav.settings": "Einstellungen", "nav.meetings": "Meetings", "nav.record": "Aufnehmen", + "nav.chat": "Chat", + "nav.knowledge": "Wissen", + "nav.actions": "Aktionen", "nav.teams": "Teams", "nav.billing": "Abrechnung", "nav.logout": "Abmelden", diff --git a/packages/i18n/src/locales/en.json b/packages/i18n/src/locales/en.json index f6f6208..48ac9c1 100644 --- a/packages/i18n/src/locales/en.json +++ b/packages/i18n/src/locales/en.json @@ -8,6 +8,8 @@ "nav.settings": "Settings", "nav.meetings": "Meetings", "nav.record": "Record", + "nav.chat": "Chat", + "nav.actions": "Actions", "nav.teams": "Teams", "nav.billing": "Billing", "nav.logout": "Logout", diff --git a/packages/i18n/src/locales/es.json b/packages/i18n/src/locales/es.json index 2ee02d3..ec53b5b 100644 --- a/packages/i18n/src/locales/es.json +++ b/packages/i18n/src/locales/es.json @@ -8,6 +8,9 @@ "nav.settings": "Ajustes", "nav.meetings": "Reuniones", "nav.record": "Grabar", + "nav.chat": "Chat", + "nav.knowledge": "Conocimiento", + "nav.actions": "Acciones", "nav.teams": "Equipos", "nav.billing": "Facturación", "nav.logout": "Cerrar sesión", diff --git a/packages/i18n/src/locales/fr.json b/packages/i18n/src/locales/fr.json index 0843a8a..1d3e4c1 100644 --- a/packages/i18n/src/locales/fr.json +++ b/packages/i18n/src/locales/fr.json @@ -8,6 +8,9 @@ "nav.settings": "Paramètres", "nav.meetings": "Réunions", "nav.record": "Enregistrer", + "nav.chat": "Chat", + "nav.knowledge": "Connaissance", + "nav.actions": "Actions", "nav.teams": "Équipes", "nav.billing": "Facturation", "nav.logout": "Déconnexion", diff --git a/packages/i18n/src/locales/ja.json b/packages/i18n/src/locales/ja.json index 089a12e..2f2c24b 100644 --- a/packages/i18n/src/locales/ja.json +++ b/packages/i18n/src/locales/ja.json @@ -8,6 +8,9 @@ "nav.settings": "設定", "nav.meetings": "会議", "nav.record": "録音", + "nav.chat": "チャット", + "nav.knowledge": "知識", + "nav.actions": "アクション", "nav.teams": "チーム", "nav.billing": "支払い", "nav.logout": "ログアウト", diff --git a/packages/i18n/src/locales/ko.json b/packages/i18n/src/locales/ko.json index b7232c2..5e2ee88 100644 --- a/packages/i18n/src/locales/ko.json +++ b/packages/i18n/src/locales/ko.json @@ -9,7 +9,8 @@ "nav.meetings": "회의", "nav.record": "녹음", "nav.chat": "채팅", - "nav.knowledge": "지식", + "nav.knowledge": "지식 베이스", + "nav.actions": "액션", "nav.teams": "팀", "nav.billing": "결제", "nav.logout": "로그아웃", @@ -375,7 +376,6 @@ "conversation.inputPlaceholder": "메시지 입력...", "conversation.end": "종료", "conversation.clearHistory": "대화 초기화", - "nav.knowledge": "지식 베이스", "rag.title": "지식 베이스", "rag.addDocument": "문서 추가", "rag.documents": "문서", @@ -492,4 +492,4 @@ "settings.hfTokenHint": "화자 구분을 위해 HuggingFace 토큰이 필요합니다", "settings.diarization": "화자 구분", "settings.diarizationHint": "녹음 종료 후 화자를 자동으로 구분합니다" -} +} \ No newline at end of file diff --git a/packages/i18n/src/locales/pt.json b/packages/i18n/src/locales/pt.json index ba3779c..305b70a 100644 --- a/packages/i18n/src/locales/pt.json +++ b/packages/i18n/src/locales/pt.json @@ -8,6 +8,9 @@ "nav.settings": "Configurações", "nav.meetings": "Reuniões", "nav.record": "Gravar", + "nav.chat": "Chat", + "nav.knowledge": "Conhecimento", + "nav.actions": "Ações", "nav.teams": "Equipes", "nav.billing": "Faturamento", "nav.logout": "Sair", diff --git a/packages/i18n/src/locales/ru.json b/packages/i18n/src/locales/ru.json index f1e5c74..bdd7f4c 100644 --- a/packages/i18n/src/locales/ru.json +++ b/packages/i18n/src/locales/ru.json @@ -8,6 +8,9 @@ "nav.settings": "Настройки", "nav.meetings": "Встречи", "nav.record": "Запись", + "nav.chat": "Чат", + "nav.knowledge": "База знаний", + "nav.actions": "Действия", "nav.teams": "Команды", "nav.billing": "Оплата", "nav.logout": "Выйти", diff --git a/packages/i18n/src/locales/th.json b/packages/i18n/src/locales/th.json index 9b72893..1a56566 100644 --- a/packages/i18n/src/locales/th.json +++ b/packages/i18n/src/locales/th.json @@ -8,6 +8,9 @@ "nav.settings": "การตั้งค่า", "nav.meetings": "การประชุม", "nav.record": "บันทึกเสียง", + "nav.chat": "แชท", + "nav.knowledge": "ความรู้", + "nav.actions": "การกระทำ", "nav.teams": "ทีม", "nav.billing": "การเรียกเก็บเงิน", "nav.logout": "ออกจากระบบ", diff --git a/packages/i18n/src/locales/vi.json b/packages/i18n/src/locales/vi.json index 1bb97cb..b345bd6 100644 --- a/packages/i18n/src/locales/vi.json +++ b/packages/i18n/src/locales/vi.json @@ -8,6 +8,9 @@ "nav.settings": "Cài đặt", "nav.meetings": "Cuộc họp", "nav.record": "Ghi âm", + "nav.chat": "Trò chuyện", + "nav.knowledge": "Kiến thức", + "nav.actions": "Hành động", "nav.teams": "Nhóm", "nav.billing": "Thanh toán", "nav.logout": "Đăng xuất", diff --git a/packages/i18n/src/locales/zh-TW.json b/packages/i18n/src/locales/zh-TW.json index 366dc8a..63ad8b1 100644 --- a/packages/i18n/src/locales/zh-TW.json +++ b/packages/i18n/src/locales/zh-TW.json @@ -8,6 +8,9 @@ "nav.settings": "設定", "nav.meetings": "會議", "nav.record": "錄音", + "nav.chat": "聊天", + "nav.knowledge": "知識", + "nav.actions": "動作", "nav.teams": "團隊", "nav.billing": "帳單", "nav.logout": "登出", diff --git a/packages/i18n/src/locales/zh.json b/packages/i18n/src/locales/zh.json index 27fc43a..cf2d791 100644 --- a/packages/i18n/src/locales/zh.json +++ b/packages/i18n/src/locales/zh.json @@ -8,6 +8,9 @@ "nav.settings": "设置", "nav.meetings": "会议", "nav.record": "录音", + "nav.chat": "聊天", + "nav.knowledge": "知识", + "nav.actions": "操作", "nav.teams": "团队", "nav.billing": "账单", "nav.logout": "登出", diff --git a/server/supabase/config.toml b/server/supabase/config.toml index af3152c..74ef152 100644 --- a/server/supabase/config.toml +++ b/server/supabase/config.toml @@ -102,5 +102,11 @@ verify_jwt = true [functions.send-push] verify_jwt = true +[functions.embed-chunks] +verify_jwt = true + +[functions.search-knowledge] +verify_jwt = true + [analytics] enabled = false diff --git a/server/supabase/functions/embed-chunks/index.ts b/server/supabase/functions/embed-chunks/index.ts new file mode 100644 index 0000000..d4d4dfc --- /dev/null +++ b/server/supabase/functions/embed-chunks/index.ts @@ -0,0 +1,159 @@ +// server/supabase/functions/embed-chunks/index.ts +// 특정 document의 청크들을 OpenAI 임베딩으로 변환하여 knowledge_chunks.embedding 컬럼 업데이트. +// +// 요청: +// POST { document_id: "uuid" } +// 응답: +// { embedded: number, errors: string[] } +// +// 환경변수: +// OPENAI_API_KEY (text-embedding-3-small 사용) + +import { corsHeaders, handleCorsPreflightRequest } from '../_shared/cors.ts' +import { requireUser, authErrorResponse, type AuthError } from '../_shared/auth.ts' +import { createServiceRoleClient } from '../_shared/quota.ts' + +interface EmbedRequest { + document_id: string +} + +interface OpenAIEmbeddingResponse { + data: Array<{ embedding: number[]; index: number }> + model: string + usage: { prompt_tokens: number; total_tokens: number } +} + +// @ts-expect-error — Deno 런타임 전역 +Deno.serve(async (req: Request) => { + const preflight = handleCorsPreflightRequest(req) + if (preflight) return preflight + + if (req.method !== 'POST') { + return new Response(JSON.stringify({ error: 'Method not allowed' }), { + status: 405, + headers: { ...corsHeaders, 'Content-Type': 'application/json' } + }) + } + + try { + const user = await requireUser(req) + const body = (await req.json()) as EmbedRequest + + if (!body.document_id) { + return new Response(JSON.stringify({ error: 'document_id 필요' }), { + status: 400, + headers: { ...corsHeaders, 'Content-Type': 'application/json' } + }) + } + + // @ts-expect-error — Deno.env + const openaiKey = Deno.env.get('OPENAI_API_KEY') ?? '' + if (!openaiKey) { + return new Response( + JSON.stringify({ error: 'openai_not_configured', message: 'OPENAI_API_KEY 미설정' }), + { status: 503, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } + ) + } + + const serviceClient = createServiceRoleClient() + + // 문서 소유권 확인 + const { data: doc } = await serviceClient + .from('knowledge_documents') + .select('id, user_id') + .eq('id', body.document_id) + .maybeSingle() + + if (!doc || (doc.user_id as string) !== user.id) { + return new Response( + JSON.stringify({ error: 'forbidden' }), + { status: 403, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } + ) + } + + // 임베딩 대상 청크 조회 (embedding IS NULL인 것만) + const { data: chunks, error: chunksErr } = await serviceClient + .from('knowledge_chunks') + .select('id, content') + .eq('document_id', body.document_id) + .is('embedding', null) + + if (chunksErr) { + throw new Error(`청크 조회 실패: ${chunksErr.message}`) + } + if (!chunks || chunks.length === 0) { + return new Response(JSON.stringify({ embedded: 0, errors: [] }), { + status: 200, + headers: { ...corsHeaders, 'Content-Type': 'application/json' } + }) + } + + // OpenAI 임베딩 API 배치 호출 (한 번에 최대 100개) + const errors: string[] = [] + let embedded = 0 + const batchSize = 100 + + for (let i = 0; i < chunks.length; i += batchSize) { + const batch = chunks.slice(i, i + batchSize) as Array<{ id: string; content: string }> + try { + const resp = await fetch('https://api.openai.com/v1/embeddings', { + method: 'POST', + headers: { + Authorization: `Bearer ${openaiKey}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + model: 'text-embedding-3-small', + input: batch.map((c) => c.content), + dimensions: 1536 + }) + }) + + if (!resp.ok) { + const errText = await resp.text() + errors.push(`OpenAI ${resp.status}: ${errText.slice(0, 200)}`) + continue + } + + const data = (await resp.json()) as OpenAIEmbeddingResponse + + // 각 청크에 embedding 업데이트 + for (const item of data.data) { + const chunk = batch[item.index] + const { error: updateErr } = await serviceClient + .from('knowledge_chunks') + .update({ embedding: item.embedding }) + .eq('id', chunk.id) + + if (updateErr) { + errors.push(`chunk ${chunk.id}: ${updateErr.message}`) + } else { + embedded++ + } + } + } catch (e) { + errors.push(e instanceof Error ? e.message : String(e)) + } + } + + // 문서 indexed 플래그 업데이트 + await serviceClient + .from('knowledge_documents') + .update({ indexed: true, indexed_at: new Date().toISOString() }) + .eq('id', body.document_id) + + return new Response(JSON.stringify({ embedded, errors }), { + status: 200, + headers: { ...corsHeaders, 'Content-Type': 'application/json' } + }) + } catch (err) { + if (err && typeof err === 'object' && 'status' in err && 'message' in err) { + return authErrorResponse(err as AuthError, corsHeaders) + } + const message = err instanceof Error ? err.message : 'Unknown error' + return new Response(JSON.stringify({ error: message }), { + status: 500, + headers: { ...corsHeaders, 'Content-Type': 'application/json' } + }) + } +}) diff --git a/server/supabase/functions/llm-proxy/index.ts b/server/supabase/functions/llm-proxy/index.ts index 053d57a..0b9d49b 100644 --- a/server/supabase/functions/llm-proxy/index.ts +++ b/server/supabase/functions/llm-proxy/index.ts @@ -78,50 +78,100 @@ Deno.serve(async (req: Request) => { ) } - // Anthropic API 호출 — placeholder - // - // 실제 구현 시: - // const anthropicKey = Deno.env.get('ANTHROPIC_API_KEY')! - // const resp = await fetch('https://api.anthropic.com/v1/messages', { - // method: 'POST', - // headers: { - // 'Content-Type': 'application/json', - // 'x-api-key': anthropicKey, - // 'anthropic-version': '2023-06-01' - // }, - // body: JSON.stringify({ - // model: requestedModel, - // max_tokens: body.max_tokens ?? 2048, - // system: body.system, - // messages: body.messages, - // stream: body.stream ?? false - // }) - // }) - // if (body.stream) { - // return new Response(resp.body, { - // headers: { ...corsHeaders, 'Content-Type': 'text/event-stream' } - // }) - // } - // const data = await resp.json() - // ... + // Anthropic API 호출 + // @ts-expect-error — Deno.env + const anthropicKey = Deno.env.get('ANTHROPIC_API_KEY') ?? '' await consumeQuota(user.id, 'llm_process', serviceClient, 1) - const placeholder = { - id: `msg_placeholder_${Date.now()}`, - model: requestedModel, - role: 'assistant', - content: [ + if (!anthropicKey) { + // Placeholder 응답 (키 미설정 시) + if (body.stream) { + // 스트리밍 placeholder — SSE로 "설정되지 않음" 메시지 전송 + const encoder = new TextEncoder() + const stream = new ReadableStream({ + start(controller) { + const msg = '[llm-proxy placeholder — ANTHROPIC_API_KEY 미설정]' + for (const ch of msg) { + controller.enqueue( + encoder.encode( + `data: ${JSON.stringify({ type: 'content_block_delta', delta: { type: 'text_delta', text: ch } })}\n\n` + ) + ) + } + controller.enqueue(encoder.encode('data: [DONE]\n\n')) + controller.close() + } + }) + return new Response(stream, { + status: 200, + headers: { + ...corsHeaders, + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive' + } + }) + } + + return new Response( + JSON.stringify({ + id: `msg_placeholder_${Date.now()}`, + model: requestedModel, + role: 'assistant', + content: [ + { + type: 'text', + text: '[llm-proxy placeholder — ANTHROPIC_API_KEY 미설정]' + } + ], + stop_reason: 'end_turn', + usage: { input_tokens: 0, output_tokens: 0 } + }), { - type: 'text', - text: '[llm-proxy placeholder — Anthropic API not yet wired]' + status: 200, + headers: { ...corsHeaders, 'Content-Type': 'application/json' } } - ], - stop_reason: 'end_turn', - usage: { input_tokens: 0, output_tokens: 0 } + ) } - return new Response(JSON.stringify(placeholder), { + // 실제 Anthropic API 호출 + const anthropicResp = await fetch('https://api.anthropic.com/v1/messages', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': anthropicKey, + 'anthropic-version': '2023-06-01' + }, + body: JSON.stringify({ + model: requestedModel, + max_tokens: body.max_tokens ?? 2048, + system: body.system, + messages: body.messages, + stream: body.stream ?? false + }) + }) + + if (!anthropicResp.ok) { + const errText = await anthropicResp.text() + throw new Error(`Anthropic ${anthropicResp.status}: ${errText.slice(0, 500)}`) + } + + if (body.stream && anthropicResp.body) { + // SSE 스트림을 그대로 전달 + return new Response(anthropicResp.body, { + status: 200, + headers: { + ...corsHeaders, + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive' + } + }) + } + + const data = await anthropicResp.json() + return new Response(JSON.stringify(data), { status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }) diff --git a/server/supabase/functions/search-knowledge/index.ts b/server/supabase/functions/search-knowledge/index.ts new file mode 100644 index 0000000..377279e --- /dev/null +++ b/server/supabase/functions/search-knowledge/index.ts @@ -0,0 +1,114 @@ +// server/supabase/functions/search-knowledge/index.ts +// 쿼리 텍스트 → OpenAI 임베딩 → match_knowledge_chunks RPC → 상위 청크 반환. +// +// 요청: +// POST { query: "...", count?: number } +// 응답: +// { results: Array<{ id, document_id, chunk_index, content, similarity }> } + +import { corsHeaders, handleCorsPreflightRequest } from '../_shared/cors.ts' +import { requireUser, authErrorResponse, type AuthError } from '../_shared/auth.ts' + +interface SearchRequest { + query: string + count?: number +} + +// @ts-expect-error — Deno +import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.103.0' + +// @ts-expect-error — Deno 런타임 전역 +Deno.serve(async (req: Request) => { + const preflight = handleCorsPreflightRequest(req) + if (preflight) return preflight + + if (req.method !== 'POST') { + return new Response(JSON.stringify({ error: 'Method not allowed' }), { + status: 405, + headers: { ...corsHeaders, 'Content-Type': 'application/json' } + }) + } + + try { + await requireUser(req) // 인증만 확인. RPC는 auth.uid() 기반 RLS + + const body = (await req.json()) as SearchRequest + if (!body.query || body.query.trim().length === 0) { + return new Response(JSON.stringify({ error: 'query 필요' }), { + status: 400, + headers: { ...corsHeaders, 'Content-Type': 'application/json' } + }) + } + + // @ts-expect-error — Deno.env + const openaiKey = Deno.env.get('OPENAI_API_KEY') ?? '' + if (!openaiKey) { + return new Response( + JSON.stringify({ error: 'openai_not_configured' }), + { status: 503, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } + ) + } + + // 1) 쿼리 임베딩 + const embedResp = await fetch('https://api.openai.com/v1/embeddings', { + method: 'POST', + headers: { + Authorization: `Bearer ${openaiKey}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + model: 'text-embedding-3-small', + input: body.query, + dimensions: 1536 + }) + }) + + if (!embedResp.ok) { + const errText = await embedResp.text() + throw new Error(`임베딩 실패: ${errText.slice(0, 200)}`) + } + + const embedData = (await embedResp.json()) as { + data: Array<{ embedding: number[] }> + } + const queryEmbedding = embedData.data[0]?.embedding + if (!queryEmbedding) { + throw new Error('임베딩 응답이 비어있음') + } + + // 2) RLS 컨텍스트에서 RPC 호출 (authenticated 유저 토큰 사용) + const authHeader = req.headers.get('Authorization') ?? '' + // @ts-expect-error — Deno.env + const supabaseUrl = Deno.env.get('SUPABASE_URL') ?? '' + // @ts-expect-error — Deno.env + const anonKey = Deno.env.get('SUPABASE_ANON_KEY') ?? '' + + const userClient = createClient(supabaseUrl, anonKey, { + global: { headers: { Authorization: authHeader } } + }) + + const { data: matches, error: rpcErr } = await userClient.rpc('match_knowledge_chunks', { + query_embedding: queryEmbedding, + match_count: body.count ?? 5, + similarity_threshold: 0.5 + }) + + if (rpcErr) { + throw new Error(`RPC 실패: ${rpcErr.message}`) + } + + return new Response(JSON.stringify({ results: matches ?? [] }), { + status: 200, + headers: { ...corsHeaders, 'Content-Type': 'application/json' } + }) + } catch (err) { + if (err && typeof err === 'object' && 'status' in err && 'message' in err) { + return authErrorResponse(err as AuthError, corsHeaders) + } + const message = err instanceof Error ? err.message : 'Unknown error' + return new Response(JSON.stringify({ error: message }), { + status: 500, + headers: { ...corsHeaders, 'Content-Type': 'application/json' } + }) + } +}) diff --git a/server/supabase/migrations/20260410000004_pgvector_knowledge.sql b/server/supabase/migrations/20260410000004_pgvector_knowledge.sql new file mode 100644 index 0000000..798fff8 --- /dev/null +++ b/server/supabase/migrations/20260410000004_pgvector_knowledge.sql @@ -0,0 +1,64 @@ +-- ============================================================================ +-- Phase V2-Q: pgvector 활성화 + knowledge 시맨틱 검색 +-- embedding 컬럼 + cosine 유사도 검색 RPC +-- ============================================================================ + +CREATE EXTENSION IF NOT EXISTS vector; + +-- 기존 knowledge_chunks에 embedding 컬럼 추가 (1536 차원 — OpenAI text-embedding-3-small) +ALTER TABLE public.knowledge_chunks + ADD COLUMN embedding vector(1536); + +-- IVFFlat 인덱스 (빠른 근사 최근접) +-- 데이터 삽입 후 `REINDEX TABLE knowledge_chunks;` 권장 +CREATE INDEX idx_knowledge_chunks_embedding + ON public.knowledge_chunks + USING ivfflat (embedding vector_cosine_ops) + WITH (lists = 100); + +-- ============================================================================ +-- match_knowledge_chunks: 쿼리 임베딩과 가장 유사한 청크 반환 +-- ============================================================================ +CREATE OR REPLACE FUNCTION public.match_knowledge_chunks( + query_embedding vector(1536), + match_count integer DEFAULT 5, + similarity_threshold double precision DEFAULT 0.5 +) +RETURNS TABLE ( + id uuid, + document_id uuid, + chunk_index integer, + content text, + similarity double precision +) +LANGUAGE plpgsql +STABLE +AS $$ +BEGIN + RETURN QUERY + SELECT + kc.id, + kc.document_id, + kc.chunk_index, + kc.content, + (1 - (kc.embedding <=> query_embedding))::double precision AS similarity + FROM public.knowledge_chunks kc + INNER JOIN public.knowledge_documents kd ON kd.id = kc.document_id + WHERE + kc.embedding IS NOT NULL + AND ( + kd.user_id = auth.uid() + OR ( + kd.team_id IS NOT NULL + AND kd.team_id IN ( + SELECT team_id FROM public.team_members WHERE user_id = auth.uid() + ) + ) + ) + AND (1 - (kc.embedding <=> query_embedding)) > similarity_threshold + ORDER BY kc.embedding <=> query_embedding + LIMIT match_count; +END; +$$; + +GRANT EXECUTE ON FUNCTION public.match_knowledge_chunks TO authenticated;