feat(V2-5차): pgvector 시맨틱 검색 + chat 스트리밍 + doc 편집 + VoiceAction + Realtime + storage + 메모

묶음 Q — pgvector + 시맨틱 검색:
- migrations/20260410000004_pgvector_knowledge.sql
  - vector extension, embedding vector(1536) 컬럼
  - ivfflat cosine 인덱스
  - match_knowledge_chunks(query_embedding, match_count, similarity_threshold) RPC
  - RLS: user_id 또는 소속 팀 기준
- functions/embed-chunks: 문서 소유권 확인 후 OpenAI text-embedding-3-small 배치 호출 → knowledge_chunks.embedding 업데이트
- functions/search-knowledge: 쿼리 텍스트 → OpenAI 임베딩 → user 권한 RPC 호출 → 상위 청크 반환
- config.toml에 embed-chunks/search-knowledge 등록
- components/knowledge/knowledge-search.tsx: 검색창 + 결과 카드(유사도 %)
- /knowledge 페이지에 검색 UI 추가

묶음 R — /chat SSE 스트리밍:
- functions/llm-proxy: Anthropic Messages API stream 지원
  - ANTHROPIC_API_KEY 없으면 SSE placeholder 스트림
  - stream=true일 때 response.body 그대로 프록시 (text/event-stream)
  - stream=false는 JSON 응답
- components/chat/chat-panel.tsx:
  - stream=true로 요청
  - ReadableStream 파싱 (SSE: data: {type:content_block_delta, delta:{text_delta}})
  - assistantId 메시지를 progressive 업데이트, scrollToBottom
  - 불필요한 LlmResponse 인터페이스 제거

묶음 S — DocumentEditor:
- components/meetings/document-editor.tsx
  - 문서 박스 클릭 → MUI Dialog (fullWidth, maxWidth md)
  - TextField multiline 20~40 rows, monospace
  - 제목 편집 + 저장/삭제 버튼
- meetings/[id] 페이지 Documents 섹션을 DocumentEditor로 교체 (+ typoSx 미사용 import 제거)

묶음 T — /actions (VoiceAction 이식):
- components/actions/action-runner.tsx
  - SYSTEM_PROMPT로 JSON 스키마 강제 (create_meeting/search_knowledge/create_memo/send_team_invite/unknown)
  - LLM 응답에서 JSON 추출 → 파싱 → 확인 후 실행
  - 각 type별 실행 로직 (meetings/memos INSERT, 검색은 안내만)
- app/(app)/actions/page.tsx + Sidebar Actions 메뉴 + AutoAwesomeIcon

묶음 U — Realtime + Storage + 메모 UI:
- CloudSyncService:
  - RealtimeChannel import 추가
  - startRealtime(): meetings/history/dictionary 변경 구독, debounce 후 pullAll 자동 트리거
  - stopRealtime(), signIn 직후/세션 복원 시 자동 시작, signOut 시 종료
- apps/web/components/meetings/memo-form.tsx
  - TextField + 저장 버튼, 회의 시작 기준 경과 ms 자동 계산
  - Realtime 구독자에게 자동 전파
- meetings/[id] MEMOS 섹션에 MemoForm 렌더
- apps/web/components/record/mic-recorder.tsx
  - STT 성공 후 Supabase Storage 'audio' 버킷에 {user_id}/{ts}.webm 업로드
  - meetings 테이블에 INSERT (raw_transcript, audio_storage_key, duration_ms, ended_at)
  - Storage/meetings 실패는 전사 결과는 유지하며 경고

묶음 V — 11개 locale nav 키:
- en/ja/zh/zh-TW/es/fr/de/pt/ru/vi/th 에 nav.chat/nav.knowledge/nav.actions 추가
- ko.json에 nav.actions 추가
- Sidebar에 Actions 메뉴(AutoAwesomeIcon) 등록

검증:
- desktop typecheck + build OK
- web typecheck + build OK (15 라우트: 기존 14 + /actions)
- api-client test 19 passed
- 회귀 없음

통계:
- 총 Edge Functions 12개 (embed-chunks/search-knowledge 추가)
- 총 SQL 마이그레이션 8개
- 웹 라우트 15개 (accept-invite/actions/billing/chat/dashboard/knowledge/login/meetings/meetings[id]/record/teams/teams[id]/auth-callback/root/_not-found)
This commit is contained in:
yunchan8804 2026-04-10 09:30:41 +09:00
parent 1fa24ce3c9
commit b386733d1e
29 changed files with 1363 additions and 81 deletions

View file

@ -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<void> {
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<void> {
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<void> {
await this.stopRealtime()
if (this._client && this._session) {
try {
await this._client.auth.signOut()

View file

@ -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 (
<Box sx={{ p: 4 }}>
<PhosphorText variant="title" sx={{ mb: 1 }}>
ACTIONS
</PhosphorText>
<Box sx={{ color: d3roPalette.text.muted, fontSize: 13, mb: 4 }}>
LLM이 . (V1
VoiceActionService의 web )
</Box>
<ActionRunner />
</Box>
)
}

View file

@ -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<React.ReactElement> {
<AddKnowledgeForm />
</Box>
<Box sx={{ mb: 4 }}>
<KnowledgeSearch />
</Box>
{docs.length === 0 ? (
<MetalCard sx={{ p: 6, textAlign: 'center', color: d3roPalette.text.muted }}>
. .

View file

@ -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<
))}
</Stack>
)}
<MemoForm meetingId={id} meetingStartedAt={meeting.started_at} />
</MetalCard>
{/* Documents */}
@ -111,21 +114,16 @@ export default async function MeetingDetailPage({ params }: PageProps): Promise<
) : (
<Stack spacing={1}>
{(documents ?? []).map((doc) => (
<Box
<DocumentEditor
key={doc.id}
sx={{
p: 2,
bgcolor: d3roPalette.bg.inset,
borderRadius: 1
doc={{
id: doc.id,
title: doc.title,
content: doc.content,
template_type: doc.template_type,
created_at: doc.created_at
}}
>
<Box sx={{ ...typoSx("body"), color: d3roPalette.text.primary, mb: 0.5 }}>
{doc.title}
</Box>
<Box sx={{ color: d3roPalette.text.label, fontSize: 11 }}>
{doc.template_type} · {new Date(doc.created_at).toLocaleDateString('ko-KR')}
</Box>
</Box>
/>
))}
</Stack>
)}

View file

@ -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<string, unknown>
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<string | null>(null)
const [result, setResult] = useState<ActionResult | null>(null)
const [executed, setExecuted] = useState<string | null>(null)
async function handleParse(): Promise<void> {
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<void> {
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 (
<Stack spacing={3}>
<MetalCard sx={{ p: 3 }}>
<PhosphorText variant="heading" sx={{ mb: 2 }}>
COMMAND
</PhosphorText>
<Stack spacing={2}>
<TextField
fullWidth
multiline
maxRows={4}
size="small"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="예: '회의록 정리 — 프로젝트 킥오프 생성', '지난주 브레인스토밍 관련 검색'"
disabled={busy}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
void handleParse()
}
}}
/>
<Button
variant="contained"
startIcon={busy ? <CircularProgress size={16} color="inherit" /> : <PlayArrowIcon />}
onClick={() => void handleParse()}
disabled={busy || !input.trim()}
>
</Button>
</Stack>
</MetalCard>
{error && (
<Alert severity="error" variant="outlined">
{error}
</Alert>
)}
{result && (
<MetalCard sx={{ p: 3 }}>
<PhosphorText variant="heading" sx={{ mb: 2 }}>
PARSED ACTION
</PhosphorText>
<Stack spacing={2}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Box sx={{ ...typoSx('label'), color: d3roPalette.text.label }}>TYPE</Box>
<Chip
label={result.type}
size="small"
sx={{
bgcolor: d3roPalette.tag.purpleBg,
color: d3roPalette.tag.purple,
fontFamily: 'monospace'
}}
/>
</Box>
<Box>
<Box sx={{ ...typoSx('label'), color: d3roPalette.text.label, mb: 0.5 }}>ARGS</Box>
<Box
component="pre"
sx={{
p: 2,
bgcolor: d3roPalette.bg.inset,
borderRadius: 1,
fontSize: 12,
color: d3roPalette.text.primary,
overflowX: 'auto'
}}
>
{JSON.stringify(result.args, null, 2)}
</Box>
</Box>
<Box>
<Box sx={{ ...typoSx('label'), color: d3roPalette.text.label, mb: 0.5 }}>RATIONALE</Box>
<Box sx={{ color: d3roPalette.text.secondary, fontSize: 13 }}>{result.rationale}</Box>
</Box>
<Button
variant="contained"
color="warning"
onClick={() => void handleExecute()}
disabled={busy || result.type === 'unknown'}
>
</Button>
</Stack>
</MetalCard>
)}
{executed && (
<Alert severity="success" variant="outlined">
{executed}
</Alert>
)}
</Stack>
)
}

View file

@ -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<Message[]>([])
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 {

View file

@ -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<SearchResult[]>([])
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
async function handleSearch(): Promise<void> {
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 (
<Stack spacing={2}>
<Stack direction="row" spacing={1}>
<TextField
fullWidth
size="small"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
void handleSearch()
}
}}
placeholder="지식 베이스에서 검색... (시맨틱)"
disabled={busy}
/>
<Button
variant="contained"
startIcon={busy ? <CircularProgress size={16} color="inherit" /> : <SearchIcon />}
onClick={() => void handleSearch()}
disabled={busy || !query.trim()}
>
</Button>
</Stack>
{error && (
<Alert severity="error" variant="outlined">
{error}
</Alert>
)}
{results.length > 0 && (
<Stack spacing={1.5}>
<PhosphorText variant="label" color="label">
({results.length})
</PhosphorText>
{results.map((r) => (
<MetalCard key={r.id} sx={{ p: 2 }}>
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 1 }}>
<Box sx={{ ...typoSx('label'), color: d3roPalette.text.label }}>
#{r.chunk_index}
</Box>
<Box
sx={{
px: 1,
py: 0.25,
borderRadius: 0.5,
bgcolor: d3roPalette.tag.greenBg,
color: d3roPalette.tag.green,
fontSize: 10
}}
>
{Math.round(r.similarity * 100)}%
</Box>
</Stack>
<Box sx={{ ...typoSx('body'), color: d3roPalette.text.primary, whiteSpace: 'pre-wrap' }}>
{r.content}
</Box>
</MetalCard>
))}
</Stack>
)}
</Stack>
)
}

View file

@ -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: <LibraryBooksIcon />
},
{
key: 'actions',
path: '/actions',
label: t('nav.actions') ?? 'Actions',
icon: <AutoAwesomeIcon />
},
{
key: 'teams',
path: '/teams',

View file

@ -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<string | null>(null)
async function handleSave(): Promise<void> {
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<void> {
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 (
<>
<Box
onClick={() => setOpen(true)}
sx={{
p: 2,
bgcolor: d3roPalette.bg.inset,
borderRadius: 1,
cursor: 'pointer',
'&:hover': { bgcolor: d3roPalette.bg.cardHover }
}}
>
<Box sx={{ ...typoSx('body'), color: d3roPalette.text.primary, mb: 0.5 }}>{doc.title}</Box>
<Box sx={{ color: d3roPalette.text.label, fontSize: 11 }}>
{doc.template_type} · {new Date(doc.created_at).toLocaleDateString('ko-KR')}
</Box>
</Box>
<Dialog open={open} onClose={() => setOpen(false)} maxWidth="md" fullWidth>
<DialogTitle>
<Stack direction="row" alignItems="center" justifyContent="space-between">
<TextField
value={title}
onChange={(e) => setTitle(e.target.value)}
variant="standard"
placeholder="제목"
sx={{ flex: 1, mr: 2 }}
/>
<IconButton onClick={() => setOpen(false)} size="small">
<CloseIcon />
</IconButton>
</Stack>
</DialogTitle>
<DialogContent dividers>
<TextField
fullWidth
multiline
minRows={20}
maxRows={40}
value={content}
onChange={(e) => setContent(e.target.value)}
disabled={busy}
placeholder="마크다운으로 편집..."
variant="outlined"
slotProps={{
input: {
style: { fontFamily: 'monospace', fontSize: 13 }
}
}}
/>
{error && (
<Alert severity="error" variant="outlined" sx={{ mt: 2 }}>
{error}
</Alert>
)}
</DialogContent>
<DialogActions>
<Button
color="error"
startIcon={<DeleteIcon />}
onClick={() => void handleDelete()}
disabled={busy}
>
</Button>
<Box sx={{ flex: 1 }} />
<Button onClick={() => setOpen(false)} disabled={busy}>
</Button>
<Button
variant="contained"
startIcon={<SaveIcon />}
onClick={() => void handleSave()}
disabled={busy}
>
</Button>
</DialogActions>
</Dialog>
</>
)
}

View file

@ -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<string | null>(null)
async function handleSubmit(): Promise<void> {
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 (
<Box sx={{ mt: 2 }}>
<Stack direction="row" spacing={1}>
<TextField
fullWidth
size="small"
value={content}
onChange={(e) => setContent(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
void handleSubmit()
}
}}
placeholder="메모 입력... (Enter 저장)"
disabled={busy}
/>
<Button
variant="outlined"
size="small"
startIcon={<NoteAddIcon />}
onClick={() => void handleSubmit()}
disabled={busy || !content.trim()}
>
</Button>
</Stack>
{error && (
<Alert severity="error" variant="outlined" sx={{ mt: 1, fontSize: 11 }}>
{error}
</Alert>
)}
<Box sx={{ color: d3roPalette.text.muted, fontSize: 10, mt: 0.5 }}>
timestamp_ms가 .
</Box>
</Box>
)
}

View file

@ -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 처리 실패')

View file

@ -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 완료 페이즈

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -8,6 +8,9 @@
"nav.settings": "設定",
"nav.meetings": "会議",
"nav.record": "録音",
"nav.chat": "チャット",
"nav.knowledge": "知識",
"nav.actions": "アクション",
"nav.teams": "チーム",
"nav.billing": "支払い",
"nav.logout": "ログアウト",

View file

@ -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": "녹음 종료 후 화자를 자동으로 구분합니다"
}
}

View file

@ -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",

View file

@ -8,6 +8,9 @@
"nav.settings": "Настройки",
"nav.meetings": "Встречи",
"nav.record": "Запись",
"nav.chat": "Чат",
"nav.knowledge": "База знаний",
"nav.actions": "Действия",
"nav.teams": "Команды",
"nav.billing": "Оплата",
"nav.logout": "Выйти",

View file

@ -8,6 +8,9 @@
"nav.settings": "การตั้งค่า",
"nav.meetings": "การประชุม",
"nav.record": "บันทึกเสียง",
"nav.chat": "แชท",
"nav.knowledge": "ความรู้",
"nav.actions": "การกระทำ",
"nav.teams": "ทีม",
"nav.billing": "การเรียกเก็บเงิน",
"nav.logout": "ออกจากระบบ",

View file

@ -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",

View file

@ -8,6 +8,9 @@
"nav.settings": "設定",
"nav.meetings": "會議",
"nav.record": "錄音",
"nav.chat": "聊天",
"nav.knowledge": "知識",
"nav.actions": "動作",
"nav.teams": "團隊",
"nav.billing": "帳單",
"nav.logout": "登出",

View file

@ -8,6 +8,9 @@
"nav.settings": "设置",
"nav.meetings": "会议",
"nav.record": "录音",
"nav.chat": "聊天",
"nav.knowledge": "知识",
"nav.actions": "操作",
"nav.teams": "团队",
"nav.billing": "账单",
"nav.logout": "登出",

View file

@ -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

View file

@ -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' }
})
}
})

View file

@ -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' }
})

View file

@ -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' }
})
}
})

View file

@ -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;