묶음 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)
188 lines
5.8 KiB
TypeScript
188 lines
5.8 KiB
TypeScript
// server/supabase/functions/llm-proxy/index.ts
|
|
// Anthropic Claude Messages API 프록시.
|
|
// 요청: application/json { messages, system?, max_tokens?, model? }
|
|
// 응답: JSON (non-stream) 또는 SSE (stream=true)
|
|
|
|
import { corsHeaders, handleCorsPreflightRequest } from '../_shared/cors.ts'
|
|
import { requireUser, authErrorResponse, type AuthError } from '../_shared/auth.ts'
|
|
import { checkQuota, consumeQuota, createServiceRoleClient, type Tier } from '../_shared/quota.ts'
|
|
|
|
interface LlmRequest {
|
|
messages: Array<{ role: 'user' | 'assistant'; content: string }>
|
|
system?: string
|
|
max_tokens?: number
|
|
model?: string
|
|
stream?: boolean
|
|
}
|
|
|
|
/** 티어별 허용 모델 */
|
|
const TIER_MODELS: Record<Tier, string[]> = {
|
|
free: ['claude-haiku-4-5-20251001'],
|
|
pro: ['claude-haiku-4-5-20251001', 'claude-sonnet-4-6'],
|
|
team: ['claude-haiku-4-5-20251001', 'claude-sonnet-4-6', 'claude-opus-4-6']
|
|
}
|
|
|
|
const DEFAULT_MODEL: Record<Tier, string> = {
|
|
free: 'claude-haiku-4-5-20251001',
|
|
pro: 'claude-sonnet-4-6',
|
|
team: 'claude-sonnet-4-6'
|
|
}
|
|
|
|
// @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 serviceClient = createServiceRoleClient()
|
|
const quota = await checkQuota(user.id, 'llm_process', serviceClient)
|
|
if (!quota.allowed) {
|
|
return new Response(
|
|
JSON.stringify({
|
|
error: 'quota_exceeded',
|
|
current: quota.current,
|
|
limit: quota.limit,
|
|
tier: quota.tier
|
|
}),
|
|
{
|
|
status: 429,
|
|
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
|
}
|
|
)
|
|
}
|
|
|
|
const body = (await req.json()) as LlmRequest
|
|
|
|
// 모델 선택 + 티어 검증
|
|
const requestedModel = body.model ?? DEFAULT_MODEL[quota.tier]
|
|
if (!TIER_MODELS[quota.tier].includes(requestedModel)) {
|
|
return new Response(
|
|
JSON.stringify({
|
|
error: 'model_not_allowed',
|
|
tier: quota.tier,
|
|
requested: requestedModel,
|
|
allowed: TIER_MODELS[quota.tier]
|
|
}),
|
|
{
|
|
status: 403,
|
|
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
|
}
|
|
)
|
|
}
|
|
|
|
// Anthropic API 호출
|
|
// @ts-expect-error — Deno.env
|
|
const anthropicKey = Deno.env.get('ANTHROPIC_API_KEY') ?? ''
|
|
|
|
await consumeQuota(user.id, 'llm_process', serviceClient, 1)
|
|
|
|
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 }
|
|
}),
|
|
{
|
|
status: 200,
|
|
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
|
}
|
|
)
|
|
}
|
|
|
|
// 실제 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' }
|
|
})
|
|
} 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' }
|
|
})
|
|
}
|
|
})
|