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:
parent
1fa24ce3c9
commit
b386733d1e
29 changed files with 1363 additions and 81 deletions
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue