feat(conversation): OpenAI gpt-realtime-2.1 라이브 음성 대화 Premium 백엔드
- realtime-token Edge Function: 티어 검증 + realtime_session 쿼터 + ephemeral key 발급 - useRealtimeConversation 훅: WebRTC 직결 (마이크 → OpenAI, 오디오 자동 재생) - VoiceConversationPage: conversationBackend 분기 + 연결 실패 시 로컬 fallback - AppConfig.conversationBackend + SettingsModal 음성 대화 엔진 선택 - ErrorCode 799 ConversationRealtimeTokenFailed, i18n ko/en
This commit is contained in:
parent
983c60cda2
commit
8f7d300b89
15 changed files with 762 additions and 40 deletions
|
|
@ -5,8 +5,13 @@ import { ipcMain } from 'electron'
|
|||
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
|
||||
import { ErrorCode, ipcSuccess, ipcError } from '@d3ro/core/errors'
|
||||
import { getVoiceConversationService } from '../services/VoiceConversationService'
|
||||
import { getCloudSyncService } from '../services/CloudSyncService'
|
||||
import { getLogger } from '../services/LoggerService'
|
||||
import type { ConversationSendParams } from '@d3ro/core/types'
|
||||
import type {
|
||||
ConversationSendParams,
|
||||
RealtimeTokenParams,
|
||||
RealtimeTokenResult,
|
||||
} from '@d3ro/core/types'
|
||||
|
||||
const logger = getLogger('voice-conversation-handlers')
|
||||
|
||||
|
|
@ -84,6 +89,47 @@ export function registerVoiceConversationHandlers(): void {
|
|||
}
|
||||
})
|
||||
|
||||
// OpenAI Realtime ephemeral token — 렌더러가 WebRTC 직결에 사용
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.VOICE_CONVERSATION.GET_REALTIME_TOKEN,
|
||||
async (_event, params: RealtimeTokenParams) => {
|
||||
try {
|
||||
const { data, error } = await getCloudSyncService().invokeFunction(
|
||||
'realtime-token',
|
||||
{ ...params },
|
||||
)
|
||||
if (error) {
|
||||
return ipcError(ErrorCode.ConversationRealtimeTokenFailed, error.message)
|
||||
}
|
||||
const raw = data as {
|
||||
value?: string
|
||||
expires_at?: number
|
||||
model?: string
|
||||
tier?: string
|
||||
error?: string
|
||||
message?: string
|
||||
}
|
||||
if (!raw.value) {
|
||||
return ipcError(
|
||||
ErrorCode.ConversationRealtimeTokenFailed,
|
||||
raw.error ?? raw.message ?? 'No token in response',
|
||||
)
|
||||
}
|
||||
const result: RealtimeTokenResult = {
|
||||
value: raw.value,
|
||||
expiresAt: raw.expires_at ?? 0,
|
||||
model: raw.model ?? 'unknown',
|
||||
tier: (raw.tier as RealtimeTokenResult['tier']) ?? 'free',
|
||||
}
|
||||
return ipcSuccess(result)
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
logger.error('Realtime token request failed:', msg)
|
||||
return ipcError(ErrorCode.ConversationRealtimeTokenFailed, msg)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// finishListening — 렌더러에서 녹음 종료 버튼 클릭 시
|
||||
ipcMain.handle('voiceConversation:finishListening', async () => {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -31,6 +31,8 @@ const CONFIG_DEFAULTS: AppConfig = {
|
|||
// Phase 3.2: 기본값은 'local' — 누구나 로그인 없이 로컬 Ollama로 쓸 수 있는
|
||||
// 엔트리 전략. 사용자가 Settings에서 'premium'으로 전환 시 로그인 + 구독 필요.
|
||||
llmBackend: 'local' as const,
|
||||
// 라이브 음성 대화 백엔드 — 'realtime'은 로그인+구독 필요 (OpenAI Realtime WebRTC)
|
||||
conversationBackend: 'local' as const,
|
||||
defaultLLMAction: 'refine',
|
||||
dictationShortcut: {
|
||||
keyCode: 0xa5, // Right Alt
|
||||
|
|
|
|||
|
|
@ -106,6 +106,8 @@ import type {
|
|||
ConversationAssistantDelta,
|
||||
ConversationAssistantMessage,
|
||||
ConversationError,
|
||||
RealtimeTokenParams,
|
||||
RealtimeTokenResult,
|
||||
// Phase 13.2
|
||||
RAGDocument,
|
||||
RAGQueryParams,
|
||||
|
|
@ -615,6 +617,11 @@ const electronAPI = {
|
|||
invoke<void>(IPC_CHANNELS.VOICE_CONVERSATION.CLEAR_HISTORY),
|
||||
cancelResponse: () =>
|
||||
invoke<void>(IPC_CHANNELS.VOICE_CONVERSATION.CANCEL_RESPONSE),
|
||||
getRealtimeToken: (params: RealtimeTokenParams) =>
|
||||
invoke<RealtimeTokenResult>(
|
||||
IPC_CHANNELS.VOICE_CONVERSATION.GET_REALTIME_TOKEN,
|
||||
params
|
||||
),
|
||||
finishListening: () =>
|
||||
invoke<void>('voiceConversation:finishListening'),
|
||||
onStateChanged: (cb: (data: ConversationSessionInfo) => void): Unsubscribe =>
|
||||
|
|
|
|||
|
|
@ -722,6 +722,37 @@ export function SettingsModal({ open, onClose }: SettingsModalProps): React.Reac
|
|||
: t('settings.backend.localHint')}
|
||||
</Typography>
|
||||
|
||||
<Divider sx={{ borderColor: d3roPalette.border.subtle }} />
|
||||
|
||||
<Typography variant="overline" sx={{ color: d3roPalette.text.label, letterSpacing: '1.5px' }}>
|
||||
{t('settings.conversationBackend')}
|
||||
</Typography>
|
||||
|
||||
<FormControl size="small">
|
||||
<InputLabel>{t('settings.conversationBackend')}</InputLabel>
|
||||
<Select
|
||||
label={t('settings.conversationBackend')}
|
||||
value={config.conversationBackend ?? 'local'}
|
||||
onChange={(e) => {
|
||||
const next = e.target.value
|
||||
updateConfig('conversationBackend', next)
|
||||
if (next === 'realtime') {
|
||||
// Realtime은 로그인+구독 필요 — 라이선스 모달로 현재 티어 안내
|
||||
window.dispatchEvent(new Event('d3ro:open-license-modal'))
|
||||
}
|
||||
}}
|
||||
>
|
||||
<MenuItem value="local">{t('settings.convBackend.local')}</MenuItem>
|
||||
<MenuItem value="realtime">{t('settings.convBackend.realtime')}</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<Typography variant="body2" sx={{ color: d3roPalette.text.inactive, fontSize: '11px' }}>
|
||||
{config.conversationBackend === 'realtime'
|
||||
? t('settings.convBackend.realtimeHint')
|
||||
: t('settings.convBackend.localHint')}
|
||||
</Typography>
|
||||
|
||||
{config.llmBackend !== 'premium' && (
|
||||
<>
|
||||
<Divider sx={{ borderColor: d3roPalette.border.subtle }} />
|
||||
|
|
|
|||
306
apps/desktop/src/renderer/hooks/useRealtimeConversation.ts
Normal file
306
apps/desktop/src/renderer/hooks/useRealtimeConversation.ts
Normal file
|
|
@ -0,0 +1,306 @@
|
|||
// src/renderer/hooks/useRealtimeConversation.ts
|
||||
// OpenAI Realtime API (gpt-realtime-2.1) 라이브 음성 대화 훅.
|
||||
// Supabase realtime-token(IPC 경유)으로 ephemeral key를 받아
|
||||
// 렌더러가 OpenAI와 직접 WebRTC 연결한다 (마이크 캡처 + 오디오 재생 자동).
|
||||
//
|
||||
// 이벤트 매핑 (data channel 'oai-events'):
|
||||
// response.created → thinking
|
||||
// response.(output_)audio_transcript.delta → speaking + 스트리밍 텍스트
|
||||
// conversation.item.input_audio_transcription.completed → 유저 메시지
|
||||
// response.done → 어시스턴트 메시지 확정 + listening 복귀
|
||||
|
||||
import { useState, useRef, useCallback, useEffect } from 'react'
|
||||
import type { ConversationState, ConversationMessage } from '@d3ro/core/types'
|
||||
|
||||
const REALTIME_CALLS_URL = 'https://api.openai.com/v1/realtime/calls'
|
||||
const DATA_CHANNEL_NAME = 'oai-events'
|
||||
|
||||
export type RealtimeConnectionState = 'idle' | 'connecting' | 'live' | 'error'
|
||||
|
||||
interface RealtimeServerEvent {
|
||||
type: string
|
||||
delta?: string
|
||||
transcript?: string
|
||||
response?: { id?: string }
|
||||
item_id?: string
|
||||
error?: { message?: string }
|
||||
}
|
||||
|
||||
export interface UseRealtimeConversationResult {
|
||||
connectionState: RealtimeConnectionState
|
||||
conversationState: ConversationState
|
||||
messages: ConversationMessage[]
|
||||
isActive: boolean
|
||||
streamingText: string
|
||||
streamingMsgId: string | null
|
||||
error: string | null
|
||||
/** 연결 시작. 실패 시 false (로컬 파이프라인 fallback 유도) */
|
||||
start: () => Promise<boolean>
|
||||
stop: () => void
|
||||
sendText: (text: string) => void
|
||||
cancelResponse: () => void
|
||||
clearMessages: () => void
|
||||
}
|
||||
|
||||
let messageSeq = 0
|
||||
function nextMessageId(prefix: string): string {
|
||||
messageSeq += 1
|
||||
return `rt-${prefix}-${Date.now()}-${messageSeq}`
|
||||
}
|
||||
|
||||
export function useRealtimeConversation(): UseRealtimeConversationResult {
|
||||
const [connectionState, setConnectionState] = useState<RealtimeConnectionState>('idle')
|
||||
const [conversationState, setConversationState] = useState<ConversationState>('idle')
|
||||
const [messages, setMessages] = useState<ConversationMessage[]>([])
|
||||
const [streamingText, setStreamingText] = useState('')
|
||||
const [streamingMsgId, setStreamingMsgId] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const pcRef = useRef<RTCPeerConnection | null>(null)
|
||||
const dcRef = useRef<RTCDataChannel | null>(null)
|
||||
const micStreamRef = useRef<MediaStream | null>(null)
|
||||
const audioElRef = useRef<HTMLAudioElement | null>(null)
|
||||
const assistantBufferRef = useRef('')
|
||||
const assistantMsgIdRef = useRef<string | null>(null)
|
||||
|
||||
const cleanup = useCallback(() => {
|
||||
dcRef.current?.close()
|
||||
dcRef.current = null
|
||||
pcRef.current?.close()
|
||||
pcRef.current = null
|
||||
micStreamRef.current?.getTracks().forEach((track) => track.stop())
|
||||
micStreamRef.current = null
|
||||
if (audioElRef.current) {
|
||||
audioElRef.current.srcObject = null
|
||||
audioElRef.current.remove()
|
||||
audioElRef.current = null
|
||||
}
|
||||
assistantBufferRef.current = ''
|
||||
assistantMsgIdRef.current = null
|
||||
}, [])
|
||||
|
||||
// 언마운트 시 연결 정리
|
||||
useEffect(() => cleanup, [cleanup])
|
||||
|
||||
const flushAssistantMessage = useCallback(() => {
|
||||
const content = assistantBufferRef.current.trim()
|
||||
const msgId = assistantMsgIdRef.current
|
||||
assistantBufferRef.current = ''
|
||||
assistantMsgIdRef.current = null
|
||||
setStreamingText('')
|
||||
setStreamingMsgId(null)
|
||||
if (content && msgId) {
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{ id: msgId, role: 'assistant', content, timestamp: Date.now() },
|
||||
])
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleServerEvent = useCallback(
|
||||
(event: RealtimeServerEvent) => {
|
||||
switch (event.type) {
|
||||
case 'response.created': {
|
||||
assistantBufferRef.current = ''
|
||||
assistantMsgIdRef.current = nextMessageId('assistant')
|
||||
setConversationState('thinking')
|
||||
break
|
||||
}
|
||||
|
||||
// GA/신규 이벤트명 모두 수용
|
||||
case 'response.output_audio_transcript.delta':
|
||||
case 'response.audio_transcript.delta':
|
||||
case 'response.output_text.delta':
|
||||
case 'response.text.delta': {
|
||||
if (event.delta) {
|
||||
assistantBufferRef.current += event.delta
|
||||
setStreamingMsgId(assistantMsgIdRef.current)
|
||||
setStreamingText(assistantBufferRef.current)
|
||||
setConversationState('speaking')
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'conversation.item.input_audio_transcription.completed': {
|
||||
const transcript = (event.transcript ?? '').trim()
|
||||
if (transcript) {
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: nextMessageId('user'),
|
||||
role: 'user',
|
||||
content: transcript,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
])
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'response.done': {
|
||||
flushAssistantMessage()
|
||||
setConversationState('listening')
|
||||
break
|
||||
}
|
||||
|
||||
case 'error': {
|
||||
setError(event.error?.message ?? 'Realtime error')
|
||||
break
|
||||
}
|
||||
|
||||
default:
|
||||
break
|
||||
}
|
||||
},
|
||||
[flushAssistantMessage],
|
||||
)
|
||||
|
||||
const start = useCallback(async (): Promise<boolean> => {
|
||||
if (pcRef.current) return true
|
||||
|
||||
setError(null)
|
||||
setConnectionState('connecting')
|
||||
|
||||
try {
|
||||
// 1) ephemeral token (Supabase realtime-token → OpenAI client_secrets)
|
||||
const tokenResult = await window.electronAPI.voiceConversation.getRealtimeToken({})
|
||||
if (!tokenResult.success) {
|
||||
throw new Error(tokenResult.error?.message ?? 'token request failed')
|
||||
}
|
||||
const { value: ephemeralKey, model } = tokenResult.data
|
||||
|
||||
// 2) 마이크 + peer connection
|
||||
const micStream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
||||
micStreamRef.current = micStream
|
||||
|
||||
const pc = new RTCPeerConnection()
|
||||
pcRef.current = pc
|
||||
const micTrack = micStream.getAudioTracks()[0]
|
||||
if (micTrack) pc.addTrack(micTrack, micStream)
|
||||
|
||||
// 3) 원격 오디오 재생
|
||||
const audioEl = document.createElement('audio')
|
||||
audioEl.autoplay = true
|
||||
audioElRef.current = audioEl
|
||||
pc.ontrack = (e) => {
|
||||
audioEl.srcObject = e.streams[0] ?? null
|
||||
}
|
||||
|
||||
pc.onconnectionstatechange = () => {
|
||||
const cs = pc.connectionState
|
||||
if (cs === 'failed' || cs === 'disconnected' || cs === 'closed') {
|
||||
setConnectionState((prev) => (prev === 'live' ? 'error' : prev))
|
||||
}
|
||||
}
|
||||
|
||||
// 4) 이벤트 데이터 채널
|
||||
const dc = pc.createDataChannel(DATA_CHANNEL_NAME)
|
||||
dcRef.current = dc
|
||||
dc.addEventListener('message', (e: MessageEvent) => {
|
||||
try {
|
||||
handleServerEvent(JSON.parse(e.data as string) as RealtimeServerEvent)
|
||||
} catch {
|
||||
// 파싱 불가 이벤트 무시
|
||||
}
|
||||
})
|
||||
dc.addEventListener('open', () => {
|
||||
// 유저 발화 전사 활성화 — 미지원 모델이면 error 이벤트만 오고 대화는 유지됨
|
||||
dc.send(
|
||||
JSON.stringify({
|
||||
type: 'session.update',
|
||||
session: {
|
||||
type: 'realtime',
|
||||
audio: {
|
||||
input: { transcription: { model: 'whisper-1' } },
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
// 5) SDP 교환
|
||||
const offer = await pc.createOffer()
|
||||
await pc.setLocalDescription(offer)
|
||||
|
||||
const sdpResponse = await fetch(`${REALTIME_CALLS_URL}?model=${encodeURIComponent(model)}`, {
|
||||
method: 'POST',
|
||||
body: offer.sdp,
|
||||
headers: {
|
||||
Authorization: `Bearer ${ephemeralKey}`,
|
||||
'Content-Type': 'application/sdp',
|
||||
},
|
||||
})
|
||||
if (!sdpResponse.ok) {
|
||||
const text = await sdpResponse.text()
|
||||
throw new Error(`SDP exchange failed (${sdpResponse.status}): ${text.slice(0, 200)}`)
|
||||
}
|
||||
|
||||
const answerSdp = await sdpResponse.text()
|
||||
await pc.setRemoteDescription({ type: 'answer', sdp: answerSdp })
|
||||
|
||||
setConnectionState('live')
|
||||
setConversationState('listening')
|
||||
return true
|
||||
} catch (err) {
|
||||
cleanup()
|
||||
setConnectionState('error')
|
||||
setConversationState('idle')
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
return false
|
||||
}
|
||||
}, [cleanup, handleServerEvent])
|
||||
|
||||
const stop = useCallback(() => {
|
||||
flushAssistantMessage()
|
||||
cleanup()
|
||||
setConnectionState('idle')
|
||||
setConversationState('idle')
|
||||
}, [cleanup, flushAssistantMessage])
|
||||
|
||||
const sendText = useCallback((text: string) => {
|
||||
const dc = dcRef.current
|
||||
if (!dc || dc.readyState !== 'open') return
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{ id: nextMessageId('user'), role: 'user', content: text, timestamp: Date.now() },
|
||||
])
|
||||
dc.send(
|
||||
JSON.stringify({
|
||||
type: 'conversation.item.create',
|
||||
item: {
|
||||
type: 'message',
|
||||
role: 'user',
|
||||
content: [{ type: 'input_text', text }],
|
||||
},
|
||||
}),
|
||||
)
|
||||
dc.send(JSON.stringify({ type: 'response.create' }))
|
||||
}, [])
|
||||
|
||||
const cancelResponse = useCallback(() => {
|
||||
const dc = dcRef.current
|
||||
if (!dc || dc.readyState !== 'open') return
|
||||
dc.send(JSON.stringify({ type: 'response.cancel' }))
|
||||
flushAssistantMessage()
|
||||
setConversationState('listening')
|
||||
}, [flushAssistantMessage])
|
||||
|
||||
const clearMessages = useCallback(() => {
|
||||
setMessages([])
|
||||
}, [])
|
||||
|
||||
return {
|
||||
connectionState,
|
||||
conversationState,
|
||||
messages,
|
||||
isActive: connectionState === 'connecting' || connectionState === 'live',
|
||||
streamingText,
|
||||
streamingMsgId,
|
||||
error,
|
||||
start,
|
||||
stop,
|
||||
sendText,
|
||||
cancelResponse,
|
||||
clearMessages,
|
||||
}
|
||||
}
|
||||
|
|
@ -12,6 +12,7 @@ import CancelIcon from '@mui/icons-material/Cancel'
|
|||
import { MetalCard, PhosphorText, Led, PhysicalButton, ScreenPanel, InstrumentPanel } from '@d3ro/ui/components/ds'
|
||||
import { PageHeader } from '../components/shared'
|
||||
import { VoiceRecordingPanel } from '../components/voice-conversation/VoiceRecordingPanel'
|
||||
import { useRealtimeConversation } from '../hooks/useRealtimeConversation'
|
||||
import { isImeComposingEvent } from '../utils/keyboard'
|
||||
import { d3roPalette, d3roFontMono, d3roTypo } from '@d3ro/ui/theme'
|
||||
import { useI18n } from '@d3ro/i18n'
|
||||
|
|
@ -24,41 +25,68 @@ import type {
|
|||
|
||||
export function VoiceConversationPage(): React.ReactElement {
|
||||
const { t } = useI18n()
|
||||
const [state, setState] = useState<ConversationState>('idle')
|
||||
const [messages, setMessages] = useState<ConversationMessage[]>([])
|
||||
const [isActive, setIsActive] = useState(false)
|
||||
const [streamingText, setStreamingText] = useState('')
|
||||
const [streamingMsgId, setStreamingMsgId] = useState<string | null>(null)
|
||||
const [localState, setLocalState] = useState<ConversationState>('idle')
|
||||
const [localMessages, setLocalMessages] = useState<ConversationMessage[]>([])
|
||||
const [localActive, setLocalActive] = useState(false)
|
||||
const [localStreamingText, setLocalStreamingText] = useState('')
|
||||
const [localStreamingMsgId, setLocalStreamingMsgId] = useState<string | null>(null)
|
||||
const [textInput, setTextInput] = useState('')
|
||||
const [errorBanner, setErrorBanner] = useState<ConversationError | null>(null)
|
||||
const [backend, setBackend] = useState<'local' | 'realtime'>('local')
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const realtime = useRealtimeConversation()
|
||||
const isRealtime = backend === 'realtime'
|
||||
|
||||
// 표시용 상태 — 백엔드에 따라 로컬 파이프라인(IPC) vs Realtime 훅
|
||||
const state = isRealtime ? realtime.conversationState : localState
|
||||
const messages = isRealtime ? realtime.messages : localMessages
|
||||
const isActive = isRealtime ? realtime.isActive : localActive
|
||||
const streamingText = isRealtime ? realtime.streamingText : localStreamingText
|
||||
const streamingMsgId = isRealtime ? realtime.streamingMsgId : localStreamingMsgId
|
||||
|
||||
// 설정에서 대화 백엔드 로드
|
||||
useEffect(() => {
|
||||
window.electronAPI.config.getAll().then((r) => {
|
||||
if (r.success && r.data.conversationBackend === 'realtime') {
|
||||
setBackend('realtime')
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
// Realtime 에러 → 배너
|
||||
useEffect(() => {
|
||||
if (realtime.error) {
|
||||
setErrorBanner({ phase: 'llm', message: realtime.error })
|
||||
}
|
||||
}, [realtime.error])
|
||||
|
||||
const scrollToBottom = useCallback(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
}, [])
|
||||
|
||||
// IPC 이벤트 구독
|
||||
// IPC 이벤트 구독 (로컬 파이프라인)
|
||||
useEffect(() => {
|
||||
const unsubState = window.electronAPI.voiceConversation.onStateChanged((info) => {
|
||||
setState(info.state)
|
||||
setMessages(info.messages)
|
||||
setIsActive(info.isActive)
|
||||
setLocalState(info.state)
|
||||
setLocalMessages(info.messages)
|
||||
setLocalActive(info.isActive)
|
||||
})
|
||||
const unsubUser = window.electronAPI.voiceConversation.onUserMessage((msg) => {
|
||||
setMessages((prev) => [...prev, msg])
|
||||
setStreamingText('')
|
||||
setStreamingMsgId(null)
|
||||
setLocalMessages((prev) => [...prev, msg])
|
||||
setLocalStreamingText('')
|
||||
setLocalStreamingMsgId(null)
|
||||
setTimeout(scrollToBottom, 50)
|
||||
})
|
||||
const unsubDelta = window.electronAPI.voiceConversation.onAssistantDelta((data: ConversationAssistantDelta) => {
|
||||
setStreamingMsgId(data.messageId)
|
||||
setStreamingText(data.accumulated)
|
||||
setLocalStreamingMsgId(data.messageId)
|
||||
setLocalStreamingText(data.accumulated)
|
||||
setTimeout(scrollToBottom, 50)
|
||||
})
|
||||
const unsubComplete = window.electronAPI.voiceConversation.onAssistantMessage((msg) => {
|
||||
setMessages((prev) => [...prev, { id: msg.messageId, role: 'assistant', content: msg.content, timestamp: Date.now() }])
|
||||
setStreamingText('')
|
||||
setStreamingMsgId(null)
|
||||
setLocalMessages((prev) => [...prev, { id: msg.messageId, role: 'assistant', content: msg.content, timestamp: Date.now() }])
|
||||
setLocalStreamingText('')
|
||||
setLocalStreamingMsgId(null)
|
||||
setTimeout(scrollToBottom, 50)
|
||||
})
|
||||
const unsubError = window.electronAPI.voiceConversation.onError((err: ConversationError) => {
|
||||
|
|
@ -69,9 +97,9 @@ export function VoiceConversationPage(): React.ReactElement {
|
|||
// 초기 상태 로드
|
||||
window.electronAPI.voiceConversation.getState().then((r) => {
|
||||
if (r.success) {
|
||||
setState(r.data.state)
|
||||
setMessages(r.data.messages)
|
||||
setIsActive(r.data.isActive)
|
||||
setLocalState(r.data.state)
|
||||
setLocalMessages(r.data.messages)
|
||||
setLocalActive(r.data.isActive)
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -84,36 +112,78 @@ export function VoiceConversationPage(): React.ReactElement {
|
|||
}
|
||||
}, [scrollToBottom])
|
||||
|
||||
// Realtime 메시지 갱신 시 스크롤
|
||||
useEffect(() => {
|
||||
if (isRealtime) setTimeout(scrollToBottom, 50)
|
||||
}, [isRealtime, realtime.messages, realtime.streamingText, scrollToBottom])
|
||||
|
||||
const handleStartSession = useCallback(async () => {
|
||||
if (isRealtime) {
|
||||
const ok = await realtime.start()
|
||||
if (!ok) {
|
||||
// Realtime 연결 실패 → 로컬 파이프라인 자동 fallback
|
||||
setBackend('local')
|
||||
setErrorBanner({ phase: 'llm', message: t('conversation.realtime.fallback') })
|
||||
await window.electronAPI.voiceConversation.startSession()
|
||||
}, [])
|
||||
}
|
||||
return
|
||||
}
|
||||
await window.electronAPI.voiceConversation.startSession()
|
||||
}, [isRealtime, realtime, t])
|
||||
|
||||
const handleStopSession = useCallback(async () => {
|
||||
if (isRealtime) {
|
||||
realtime.stop()
|
||||
return
|
||||
}
|
||||
await window.electronAPI.voiceConversation.stopSession()
|
||||
}, [])
|
||||
}, [isRealtime, realtime])
|
||||
|
||||
const handleFinishListening = useCallback(async () => {
|
||||
if (isRealtime) return // server VAD가 자동 처리
|
||||
await window.electronAPI.voiceConversation.finishListening()
|
||||
}, [])
|
||||
}, [isRealtime])
|
||||
|
||||
const handleSendText = useCallback(async () => {
|
||||
if (!textInput.trim()) return
|
||||
const text = textInput.trim()
|
||||
setTextInput('')
|
||||
if (isRealtime) {
|
||||
if (!realtime.isActive) {
|
||||
const ok = await realtime.start()
|
||||
if (!ok) {
|
||||
setBackend('local')
|
||||
setErrorBanner({ phase: 'llm', message: t('conversation.realtime.fallback') })
|
||||
await window.electronAPI.voiceConversation.startSession()
|
||||
await window.electronAPI.voiceConversation.sendMessage({ text })
|
||||
return
|
||||
}
|
||||
}
|
||||
realtime.sendText(text)
|
||||
return
|
||||
}
|
||||
if (!isActive) {
|
||||
await window.electronAPI.voiceConversation.startSession()
|
||||
}
|
||||
await window.electronAPI.voiceConversation.sendMessage({ text })
|
||||
}, [textInput, isActive])
|
||||
}, [textInput, isActive, isRealtime, realtime, t])
|
||||
|
||||
const handleClearHistory = useCallback(async () => {
|
||||
if (isRealtime) {
|
||||
realtime.clearMessages()
|
||||
return
|
||||
}
|
||||
await window.electronAPI.voiceConversation.clearHistory()
|
||||
setMessages([])
|
||||
}, [])
|
||||
setLocalMessages([])
|
||||
}, [isRealtime, realtime])
|
||||
|
||||
const handleCancelResponse = useCallback(async () => {
|
||||
if (isRealtime) {
|
||||
realtime.cancelResponse()
|
||||
return
|
||||
}
|
||||
await window.electronAPI.voiceConversation.cancelResponse()
|
||||
}, [])
|
||||
}, [isRealtime, realtime])
|
||||
|
||||
const handleTextKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (isImeComposingEvent(e)) return
|
||||
|
|
@ -130,6 +200,11 @@ export function VoiceConversationPage(): React.ReactElement {
|
|||
speaking: t('conversation.speaking'),
|
||||
}
|
||||
|
||||
const displayStateLabel =
|
||||
isRealtime && realtime.connectionState === 'connecting'
|
||||
? t('conversation.realtime.connecting')
|
||||
: stateLabel[state]
|
||||
|
||||
const formatErrorMessage = useCallback((err: ConversationError): string => {
|
||||
// Bug 13의 전형적 STT 메시지는 i18n 키로 매핑 — 그 외는 서비스 원문 + phase label.
|
||||
if (err.phase === 'stt' && err.message.toLowerCase().startsWith('no speech')) {
|
||||
|
|
@ -152,9 +227,23 @@ export function VoiceConversationPage(): React.ReactElement {
|
|||
title={t('conversation.title').toUpperCase()}
|
||||
action={
|
||||
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
|
||||
{isRealtime && (
|
||||
<PhosphorText
|
||||
variant="micro"
|
||||
sx={{
|
||||
color: d3roPalette.accent.amber,
|
||||
border: `1px solid ${d3roPalette.accent.amber}`,
|
||||
borderRadius: '3px',
|
||||
px: 0.5,
|
||||
lineHeight: 1.6,
|
||||
}}
|
||||
>
|
||||
{t('conversation.realtime.badge')}
|
||||
</PhosphorText>
|
||||
)}
|
||||
<Led color={stateLedColor[state]} pulse={state === 'listening' || state === 'thinking'} size={8} />
|
||||
<PhosphorText variant="label" sx={{ color: d3roPalette.text.dimLabel }}>
|
||||
{stateLabel[state].toUpperCase()}
|
||||
{displayStateLabel.toUpperCase()}
|
||||
</PhosphorText>
|
||||
{messages.length > 0 && (
|
||||
<Tooltip title={t('conversation.clearHistory')}>
|
||||
|
|
@ -167,8 +256,8 @@ export function VoiceConversationPage(): React.ReactElement {
|
|||
}
|
||||
/>
|
||||
|
||||
{/* 메시지 영역 — state 분기: listening=몰입 패널, 그 외=메시지 리스트 */}
|
||||
{state === 'listening' ? (
|
||||
{/* 메시지 영역 — 로컬 파이프라인의 listening만 몰입 패널 (Realtime은 항상 채팅 뷰) */}
|
||||
{!isRealtime && state === 'listening' ? (
|
||||
<Box
|
||||
sx={{
|
||||
flex: 1,
|
||||
|
|
@ -292,7 +381,11 @@ export function VoiceConversationPage(): React.ReactElement {
|
|||
<MicIcon sx={{ fontSize: 20 }} />
|
||||
</PhysicalButton>
|
||||
) : state === 'listening' ? (
|
||||
<PhysicalButton selected onClick={handleFinishListening} sx={{ minWidth: 48, px: 2 }}>
|
||||
<PhysicalButton
|
||||
selected
|
||||
onClick={isRealtime ? handleStopSession : handleFinishListening}
|
||||
sx={{ minWidth: 48, px: 2 }}
|
||||
>
|
||||
<StopIcon sx={{ fontSize: 20 }} />
|
||||
</PhysicalButton>
|
||||
) : state === 'thinking' || state === 'speaking' ? (
|
||||
|
|
@ -305,8 +398,8 @@ export function VoiceConversationPage(): React.ReactElement {
|
|||
</PhysicalButton>
|
||||
)}
|
||||
|
||||
{/* 텍스트 입력 — listening 시 숨김, thinking/speaking 시 disabled */}
|
||||
{state !== 'listening' && (
|
||||
{/* 텍스트 입력 — 로컬 listening 시 숨김 (Realtime은 항상 표시), thinking/speaking 시 disabled */}
|
||||
{(isRealtime || state !== 'listening') && (
|
||||
<TextField
|
||||
value={state === 'thinking' || state === 'speaking' ? '' : textInput}
|
||||
onChange={(e) => setTextInput(e.target.value)}
|
||||
|
|
@ -335,8 +428,8 @@ export function VoiceConversationPage(): React.ReactElement {
|
|||
/>
|
||||
)}
|
||||
|
||||
{/* 전송 버튼 — listening 시 숨김 */}
|
||||
{state !== 'listening' && (
|
||||
{/* 전송 버튼 — 로컬 listening 시 숨김 */}
|
||||
{(isRealtime || state !== 'listening') && (
|
||||
<IconButton
|
||||
onClick={handleSendText}
|
||||
disabled={!textInput.trim() || state === 'thinking' || state === 'speaking'}
|
||||
|
|
|
|||
|
|
@ -22,11 +22,22 @@
|
|||
- **테스트 수리**: errors.test.ts / VoiceModeService.test.ts의 모노레포 전환 잔재 import(`src/shared/*` → `@d3ro/core/*`) 수정 → 41/41 통과
|
||||
- 검증: desktop tsc EXIT 0, vitest 41 passed
|
||||
|
||||
### A 예정: gpt-realtime-2.1 라이브 음성 대화 (Premium)
|
||||
### A 구현 완료(코드): gpt-realtime-2.1 라이브 음성 대화 (Premium) ✅
|
||||
- 2026-05 출시 OpenAI Realtime 계열: gpt-realtime-2/2.1(~$0.05/분), 2.1-mini(~$0.016/분), GPT-Realtime-Whisper($0.017/분), GPT-Realtime-Translate($0.034/분)
|
||||
- 아키텍처: Supabase Edge Function이 ephemeral token 발급(`POST /v1/realtime/client_secrets`) → 렌더러가 OpenAI와 직접 WebRTC → STT→LLM→TTS 파이프라인 대체
|
||||
- 선행 조건: OpenAI API 키 Supabase Secrets 등록, 분 단위 쿼터 정책 설계
|
||||
- 참고: Parakeet은 한국어 미지원이라 로컬 STT 대체 후보에서 제외
|
||||
- **서버**: `realtime-token` Edge Function 신규 — requireUser → 티어 검증(free 차단, pro=2.1-mini, pro_plus=2.1) → `realtime_session` 쿼터(일간 pro 30/pro+ 120 세션) 소비 → OpenAI `POST /v1/realtime/client_secrets` → ephemeral key 반환. quota.ts에 `realtime_session` 키 추가. config.toml verify_jwt=false.
|
||||
- **IPC**: `voiceConversation:getRealtimeToken` (main이 CloudSync invokeFunction 경유), ErrorCode 799 `ConversationRealtimeTokenFailed`
|
||||
- **렌더러**: `useRealtimeConversation` 훅 — getUserMedia → RTCPeerConnection → SDP를 `POST api.openai.com/v1/realtime/calls`(Bearer ek_) → data channel 'oai-events' 이벤트 매핑(response.created=thinking, *audio_transcript.delta=speaking+스트리밍, input_audio_transcription.completed=유저 메시지, response.done=확정+listening). dc open 시 session.update로 whisper-1 입력 전사 활성화(실패해도 대화 유지).
|
||||
- **VoiceConversationPage**: `conversationBackend` 설정 분기 — realtime이면 훅 사용, 연결 실패 시 로컬 파이프라인 자동 fallback + 배너. LIVE 배지, 텍스트 입력 상시 표시.
|
||||
- **설정**: AppConfig.conversationBackend('local'|'realtime', 기본 local), SettingsModal LLM 탭에 "음성 대화 엔진" 선택 + realtime 선택 시 라이선스 모달
|
||||
- **i18n**: settings.convBackend.* 5키 + conversation.realtime.* 3키 (ko/en, 나머지는 en fallback)
|
||||
- 검증: desktop tsc EXIT 0, vitest 41/41. deno check의 @ts-expect-error 에러는 기존 llm-proxy와 동일(관행)
|
||||
|
||||
#### A 남은 사용자 액션 (배포/E2E)
|
||||
1. `supabase secrets set OPENAI_API_KEY=sk-...`
|
||||
2. `supabase functions deploy realtime-token`
|
||||
3. Pro 계정으로 로그인 → 설정 → 음성 대화 엔진 = Live → 대화 페이지에서 마이크 시작 → 실측
|
||||
4. (선택) 이벤트명이 2.1 API와 다르면 훅의 handleServerEvent 케이스 보강
|
||||
5. (후속) 분 단위 정밀 쿼터 (세션 수 → 분 집계), settings.convBackend i18n 10개 locale 확장
|
||||
|
||||
## v0.1.0-alpha 배포 전체 흐름 (2026-04-15 night) ✅
|
||||
|
||||
|
|
|
|||
|
|
@ -137,6 +137,7 @@ export enum ErrorCode {
|
|||
ConversationNoActiveSession = 796,
|
||||
ConversationTTSFailed = 797,
|
||||
ConversationLLMFailed = 798,
|
||||
ConversationRealtimeTokenFailed = 799,
|
||||
|
||||
// === Phase 13: Local RAG (870-874) ===
|
||||
RAGDocumentNotFound = 870,
|
||||
|
|
|
|||
|
|
@ -276,6 +276,8 @@ export const IPC_CHANNELS = {
|
|||
GET_HISTORY: 'voiceConversation:getHistory',
|
||||
CLEAR_HISTORY: 'voiceConversation:clearHistory',
|
||||
CANCEL_RESPONSE: 'voiceConversation:cancelResponse',
|
||||
/** OpenAI Realtime ephemeral token 발급 (Supabase realtime-token 경유) */
|
||||
GET_REALTIME_TOKEN: 'voiceConversation:getRealtimeToken',
|
||||
// Main → Renderer events
|
||||
STATE_CHANGED: 'voiceConversation:stateChanged',
|
||||
USER_MESSAGE: 'voiceConversation:userMessage',
|
||||
|
|
|
|||
|
|
@ -372,6 +372,12 @@ export interface AppConfig {
|
|||
* 'premium' — PremiumLLMService (Supabase llm-proxy → Claude, 로그인+구독 필요)
|
||||
*/
|
||||
llmBackend: 'local' | 'premium'
|
||||
/**
|
||||
* 음성 대화 백엔드 선택.
|
||||
* 'local' — VoiceConversationService (STT→LLM→TTS 파이프라인, 기본값)
|
||||
* 'realtime' — OpenAI Realtime API (gpt-realtime-2.1, WebRTC 직결, 로그인+구독 필요)
|
||||
*/
|
||||
conversationBackend: 'local' | 'realtime'
|
||||
defaultLLMAction: LLMAction
|
||||
dictationShortcut: HotkeyBinding
|
||||
handsFreeShortcut: HotkeyBinding
|
||||
|
|
@ -1179,6 +1185,25 @@ export interface ConversationError {
|
|||
phase: 'stt' | 'llm' | 'tts'
|
||||
}
|
||||
|
||||
// ── OpenAI Realtime (라이브 음성 대화) ──
|
||||
|
||||
export interface RealtimeTokenParams {
|
||||
/** 미지정 시 서버가 티어 기본 모델 선택 (pro=mini, pro_plus=full) */
|
||||
model?: string
|
||||
voice?: string
|
||||
/** 시스템 프롬프트 (서버에서 2000자 제한) */
|
||||
instructions?: string
|
||||
}
|
||||
|
||||
export interface RealtimeTokenResult {
|
||||
/** ephemeral client secret (ek_...) — WebRTC 연결에 사용 */
|
||||
value: string
|
||||
/** epoch seconds */
|
||||
expiresAt: number
|
||||
model: string
|
||||
tier: 'free' | 'pro' | 'pro_plus'
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Phase 13: Local RAG (13.2)
|
||||
// ============================================================
|
||||
|
|
|
|||
|
|
@ -133,6 +133,11 @@
|
|||
"settings.backend.premium": "Premium (Claude AI, Subscription)",
|
||||
"settings.backend.localHint": "Polish text with local Ollama server. Fully free, no internet needed.",
|
||||
"settings.backend.premiumHint": "High-quality polishing with Anthropic Claude AI. Login + subscription required. Auto-fallback to Local on network failure.",
|
||||
"settings.conversationBackend": "Voice Chat Engine",
|
||||
"settings.convBackend.local": "Local (STT→LLM→TTS, Free)",
|
||||
"settings.convBackend.realtime": "Live (OpenAI gpt-realtime, Subscription)",
|
||||
"settings.convBackend.localHint": "Converse via the local pipeline. Fully free, no internet needed.",
|
||||
"settings.convBackend.realtimeHint": "Real-time voice conversation with OpenAI Realtime models. Sub-second responses + interruption support. Login + Pro subscription required. Auto-fallback to Local on failure.",
|
||||
"settings.llmModel": "LLM Model",
|
||||
"settings.postProcess": "Voice Post-Processing",
|
||||
"settings.defaultAction": "Default Post-Processing Command",
|
||||
|
|
@ -407,6 +412,9 @@
|
|||
"conversation.error.phase.llm": "Response",
|
||||
"conversation.error.phase.tts": "Voice Playback",
|
||||
"conversation.error.noSpeech": "No speech detected. Check your microphone and try again.",
|
||||
"conversation.realtime.badge": "LIVE",
|
||||
"conversation.realtime.connecting": "Connecting",
|
||||
"conversation.realtime.fallback": "Live connection failed — switched to local conversation.",
|
||||
"nav.knowledge": "Knowledge",
|
||||
"rag.title": "Knowledge Base",
|
||||
"rag.addDocument": "Add Document",
|
||||
|
|
|
|||
|
|
@ -134,6 +134,11 @@
|
|||
"settings.backend.premium": "Premium (Claude AI, 구독)",
|
||||
"settings.backend.localHint": "로컬 Ollama 서버로 텍스트를 다듬습니다. 완전 무료, 인터넷 불필요.",
|
||||
"settings.backend.premiumHint": "Anthropic Claude AI로 고품질 다듬기. 로그인 + 구독 필요. 네트워크 실패 시 Local로 자동 전환.",
|
||||
"settings.conversationBackend": "음성 대화 엔진",
|
||||
"settings.convBackend.local": "Local (STT→LLM→TTS, 무료)",
|
||||
"settings.convBackend.realtime": "Live (OpenAI gpt-realtime, 구독)",
|
||||
"settings.convBackend.localHint": "로컬 파이프라인으로 대화합니다. 완전 무료, 인터넷 불필요.",
|
||||
"settings.convBackend.realtimeHint": "OpenAI Realtime 모델과 실시간 음성 대화. 1초 미만 응답 + 말 끊기 지원. 로그인 + Pro 구독 필요. 연결 실패 시 Local로 자동 전환.",
|
||||
"settings.llmModel": "LLM 모델",
|
||||
"settings.postProcess": "음성 후처리",
|
||||
"settings.defaultAction": "기본 후처리 명령어",
|
||||
|
|
@ -408,6 +413,9 @@
|
|||
"conversation.error.phase.llm": "응답 생성",
|
||||
"conversation.error.phase.tts": "음성 재생",
|
||||
"conversation.error.noSpeech": "음성이 감지되지 않았습니다. 마이크를 확인하고 다시 말씀해 주세요.",
|
||||
"conversation.realtime.badge": "LIVE",
|
||||
"conversation.realtime.connecting": "연결 중",
|
||||
"conversation.realtime.fallback": "라이브 연결 실패 — 로컬 대화로 전환했습니다.",
|
||||
"rag.title": "지식 베이스",
|
||||
"rag.addDocument": "문서 추가",
|
||||
"rag.documents": "문서",
|
||||
|
|
|
|||
|
|
@ -87,6 +87,10 @@ verify_jwt = true
|
|||
# 2026 sb_publishable_ 키와 Gateway JWT 검증 비호환 — requireUser()에서 직접 인증
|
||||
verify_jwt = false
|
||||
|
||||
[functions.realtime-token]
|
||||
# 2026 sb_publishable_ 키와 Gateway JWT 검증 비호환 — requireUser()에서 직접 인증
|
||||
verify_jwt = false
|
||||
|
||||
[functions.stripe-checkout]
|
||||
verify_jwt = true
|
||||
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ export type QuotaFeature =
|
|||
| 'llm_haiku'
|
||||
| 'llm_sonnet'
|
||||
| 'llm_opus'
|
||||
| 'realtime_session'
|
||||
|
||||
export type QuotaPeriod = 'daily' | 'weekly'
|
||||
|
||||
|
|
@ -29,18 +30,22 @@ const MODEL_QUOTA: Record<Tier, Record<QuotaFeature, ModelQuota>> = {
|
|||
llm_haiku: { limit: 250, period: 'weekly' },
|
||||
llm_sonnet: { limit: 0, period: 'daily' }, // 사용불가
|
||||
llm_opus: { limit: 0, period: 'daily' }, // 사용불가
|
||||
realtime_session: { limit: 0, period: 'daily' }, // 사용불가
|
||||
},
|
||||
pro: {
|
||||
stt_transcribe: { limit: -1, period: 'daily' },
|
||||
llm_haiku: { limit: 1500, period: 'daily' },
|
||||
llm_sonnet: { limit: 300, period: 'daily' },
|
||||
llm_opus: { limit: 50, period: 'daily' },
|
||||
// 세션 수 기준 (~$0.016/분 mini — 세션당 평균 수 분 가정)
|
||||
realtime_session: { limit: 30, period: 'daily' },
|
||||
},
|
||||
pro_plus: {
|
||||
stt_transcribe: { limit: -1, period: 'daily' },
|
||||
llm_haiku: { limit: -1, period: 'daily' }, // 무제한
|
||||
llm_sonnet: { limit: 1500, period: 'daily' },
|
||||
llm_opus: { limit: 300, period: 'daily' },
|
||||
realtime_session: { limit: 120, period: 'daily' },
|
||||
},
|
||||
}
|
||||
|
||||
|
|
|
|||
173
server/supabase/functions/realtime-token/index.ts
Normal file
173
server/supabase/functions/realtime-token/index.ts
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
// server/supabase/functions/realtime-token/index.ts
|
||||
// OpenAI Realtime API ephemeral token 발급.
|
||||
// 렌더러가 이 토큰으로 OpenAI와 직접 WebRTC 연결한다 (서버 키 비노출).
|
||||
// 요청: application/json { model?, voice?, instructions? }
|
||||
// 응답: OpenAI client_secrets 응답 그대로 + { model, tier }
|
||||
|
||||
import { corsHeaders, handleCorsPreflightRequest } from '../_shared/cors.ts'
|
||||
import { requireUser, authErrorResponse, type AuthError } from '../_shared/auth.ts'
|
||||
import {
|
||||
checkQuota,
|
||||
consumeQuota,
|
||||
createServiceRoleClient,
|
||||
getQuotaPolicy,
|
||||
type Tier,
|
||||
} from '../_shared/quota.ts'
|
||||
|
||||
interface RealtimeTokenRequest {
|
||||
model?: string
|
||||
voice?: string
|
||||
instructions?: string
|
||||
}
|
||||
|
||||
/** 티어별 허용 Realtime 모델 — free 차단, pro는 mini만, pro_plus는 풀 모델까지 */
|
||||
const TIER_MODELS: Record<Tier, string[]> = {
|
||||
free: [],
|
||||
pro: ['gpt-realtime-2.1-mini'],
|
||||
pro_plus: ['gpt-realtime-2.1', 'gpt-realtime-2.1-mini'],
|
||||
}
|
||||
|
||||
const DEFAULT_MODEL: Record<Tier, string | null> = {
|
||||
free: null,
|
||||
pro: 'gpt-realtime-2.1-mini',
|
||||
pro_plus: 'gpt-realtime-2.1',
|
||||
}
|
||||
|
||||
const DEFAULT_VOICE = 'marin'
|
||||
const MAX_INSTRUCTIONS_LENGTH = 2000
|
||||
|
||||
// @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 body = (await req.json().catch(() => ({}))) as RealtimeTokenRequest
|
||||
|
||||
// 1단계: 티어 + 세션 쿼터 확인
|
||||
const quota = await checkQuota(user.id, 'realtime_session', serviceClient)
|
||||
const tier = quota.tier
|
||||
|
||||
const requestedModel = body.model ?? DEFAULT_MODEL[tier]
|
||||
if (!requestedModel || !TIER_MODELS[tier].includes(requestedModel)) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: tier === 'free' ? 'tier_not_allowed' : 'model_not_allowed',
|
||||
tier,
|
||||
requested: body.model ?? null,
|
||||
allowed: TIER_MODELS[tier],
|
||||
}),
|
||||
{
|
||||
status: 403,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (!quota.allowed) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: 'quota_exceeded',
|
||||
current: quota.current,
|
||||
limit: quota.limit,
|
||||
period: quota.period,
|
||||
tier,
|
||||
overage_credits: quota.overageCredits,
|
||||
}),
|
||||
{
|
||||
status: 429,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// 2단계: 쿼터 소비 (세션 시작 = 1회)
|
||||
const policy = getQuotaPolicy(tier, 'realtime_session')
|
||||
const consume = await consumeQuota(user.id, 'realtime_session', serviceClient, policy.limit)
|
||||
if (!consume.allowed) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: 'quota_exceeded',
|
||||
current: consume.current,
|
||||
limit: consume.limit,
|
||||
tier,
|
||||
overage_credits: consume.overageCredits,
|
||||
}),
|
||||
{
|
||||
status: 429,
|
||||
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: 'not_configured', message: 'OPENAI_API_KEY 미설정' }),
|
||||
{
|
||||
status: 503,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// 3단계: OpenAI ephemeral client secret 발급
|
||||
const instructions =
|
||||
typeof body.instructions === 'string'
|
||||
? body.instructions.slice(0, MAX_INSTRUCTIONS_LENGTH)
|
||||
: undefined
|
||||
|
||||
const openaiResp = await fetch('https://api.openai.com/v1/realtime/client_secrets', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${openaiKey}`,
|
||||
'OpenAI-Safety-Identifier': user.id,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
session: {
|
||||
type: 'realtime',
|
||||
model: requestedModel,
|
||||
...(instructions ? { instructions } : {}),
|
||||
audio: {
|
||||
output: { voice: body.voice ?? DEFAULT_VOICE },
|
||||
},
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
if (!openaiResp.ok) {
|
||||
const errText = await openaiResp.text()
|
||||
throw new Error(`OpenAI ${openaiResp.status}: ${errText.slice(0, 500)}`)
|
||||
}
|
||||
|
||||
const data = await openaiResp.json()
|
||||
return new Response(
|
||||
JSON.stringify({ ...data, model: requestedModel, tier }),
|
||||
{
|
||||
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' },
|
||||
})
|
||||
}
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue