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

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