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:
Yun Chan 2026-07-21 12:10:13 +09:00
parent 983c60cda2
commit 8f7d300b89
15 changed files with 762 additions and 40 deletions

View file

@ -5,8 +5,13 @@ import { ipcMain } from 'electron'
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels' import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
import { ErrorCode, ipcSuccess, ipcError } from '@d3ro/core/errors' import { ErrorCode, ipcSuccess, ipcError } from '@d3ro/core/errors'
import { getVoiceConversationService } from '../services/VoiceConversationService' import { getVoiceConversationService } from '../services/VoiceConversationService'
import { getCloudSyncService } from '../services/CloudSyncService'
import { getLogger } from '../services/LoggerService' 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') 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 — 렌더러에서 녹음 종료 버튼 클릭 시 // finishListening — 렌더러에서 녹음 종료 버튼 클릭 시
ipcMain.handle('voiceConversation:finishListening', async () => { ipcMain.handle('voiceConversation:finishListening', async () => {
try { try {

View file

@ -31,6 +31,8 @@ const CONFIG_DEFAULTS: AppConfig = {
// Phase 3.2: 기본값은 'local' — 누구나 로그인 없이 로컬 Ollama로 쓸 수 있는 // Phase 3.2: 기본값은 'local' — 누구나 로그인 없이 로컬 Ollama로 쓸 수 있는
// 엔트리 전략. 사용자가 Settings에서 'premium'으로 전환 시 로그인 + 구독 필요. // 엔트리 전략. 사용자가 Settings에서 'premium'으로 전환 시 로그인 + 구독 필요.
llmBackend: 'local' as const, llmBackend: 'local' as const,
// 라이브 음성 대화 백엔드 — 'realtime'은 로그인+구독 필요 (OpenAI Realtime WebRTC)
conversationBackend: 'local' as const,
defaultLLMAction: 'refine', defaultLLMAction: 'refine',
dictationShortcut: { dictationShortcut: {
keyCode: 0xa5, // Right Alt keyCode: 0xa5, // Right Alt

View file

@ -106,6 +106,8 @@ import type {
ConversationAssistantDelta, ConversationAssistantDelta,
ConversationAssistantMessage, ConversationAssistantMessage,
ConversationError, ConversationError,
RealtimeTokenParams,
RealtimeTokenResult,
// Phase 13.2 // Phase 13.2
RAGDocument, RAGDocument,
RAGQueryParams, RAGQueryParams,
@ -615,6 +617,11 @@ const electronAPI = {
invoke<void>(IPC_CHANNELS.VOICE_CONVERSATION.CLEAR_HISTORY), invoke<void>(IPC_CHANNELS.VOICE_CONVERSATION.CLEAR_HISTORY),
cancelResponse: () => cancelResponse: () =>
invoke<void>(IPC_CHANNELS.VOICE_CONVERSATION.CANCEL_RESPONSE), invoke<void>(IPC_CHANNELS.VOICE_CONVERSATION.CANCEL_RESPONSE),
getRealtimeToken: (params: RealtimeTokenParams) =>
invoke<RealtimeTokenResult>(
IPC_CHANNELS.VOICE_CONVERSATION.GET_REALTIME_TOKEN,
params
),
finishListening: () => finishListening: () =>
invoke<void>('voiceConversation:finishListening'), invoke<void>('voiceConversation:finishListening'),
onStateChanged: (cb: (data: ConversationSessionInfo) => void): Unsubscribe => onStateChanged: (cb: (data: ConversationSessionInfo) => void): Unsubscribe =>

View file

@ -722,6 +722,37 @@ export function SettingsModal({ open, onClose }: SettingsModalProps): React.Reac
: t('settings.backend.localHint')} : t('settings.backend.localHint')}
</Typography> </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' && ( {config.llmBackend !== 'premium' && (
<> <>
<Divider sx={{ borderColor: d3roPalette.border.subtle }} /> <Divider sx={{ borderColor: d3roPalette.border.subtle }} />

View 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,
}
}

View file

@ -12,6 +12,7 @@ import CancelIcon from '@mui/icons-material/Cancel'
import { MetalCard, PhosphorText, Led, PhysicalButton, ScreenPanel, InstrumentPanel } from '@d3ro/ui/components/ds' import { MetalCard, PhosphorText, Led, PhysicalButton, ScreenPanel, InstrumentPanel } from '@d3ro/ui/components/ds'
import { PageHeader } from '../components/shared' import { PageHeader } from '../components/shared'
import { VoiceRecordingPanel } from '../components/voice-conversation/VoiceRecordingPanel' import { VoiceRecordingPanel } from '../components/voice-conversation/VoiceRecordingPanel'
import { useRealtimeConversation } from '../hooks/useRealtimeConversation'
import { isImeComposingEvent } from '../utils/keyboard' import { isImeComposingEvent } from '../utils/keyboard'
import { d3roPalette, d3roFontMono, d3roTypo } from '@d3ro/ui/theme' import { d3roPalette, d3roFontMono, d3roTypo } from '@d3ro/ui/theme'
import { useI18n } from '@d3ro/i18n' import { useI18n } from '@d3ro/i18n'
@ -24,41 +25,68 @@ import type {
export function VoiceConversationPage(): React.ReactElement { export function VoiceConversationPage(): React.ReactElement {
const { t } = useI18n() const { t } = useI18n()
const [state, setState] = useState<ConversationState>('idle') const [localState, setLocalState] = useState<ConversationState>('idle')
const [messages, setMessages] = useState<ConversationMessage[]>([]) const [localMessages, setLocalMessages] = useState<ConversationMessage[]>([])
const [isActive, setIsActive] = useState(false) const [localActive, setLocalActive] = useState(false)
const [streamingText, setStreamingText] = useState('') const [localStreamingText, setLocalStreamingText] = useState('')
const [streamingMsgId, setStreamingMsgId] = useState<string | null>(null) const [localStreamingMsgId, setLocalStreamingMsgId] = useState<string | null>(null)
const [textInput, setTextInput] = useState('') const [textInput, setTextInput] = useState('')
const [errorBanner, setErrorBanner] = useState<ConversationError | null>(null) const [errorBanner, setErrorBanner] = useState<ConversationError | null>(null)
const [backend, setBackend] = useState<'local' | 'realtime'>('local')
const messagesEndRef = useRef<HTMLDivElement>(null) 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(() => { const scrollToBottom = useCallback(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}, []) }, [])
// IPC 이벤트 구독 // IPC 이벤트 구독 (로컬 파이프라인)
useEffect(() => { useEffect(() => {
const unsubState = window.electronAPI.voiceConversation.onStateChanged((info) => { const unsubState = window.electronAPI.voiceConversation.onStateChanged((info) => {
setState(info.state) setLocalState(info.state)
setMessages(info.messages) setLocalMessages(info.messages)
setIsActive(info.isActive) setLocalActive(info.isActive)
}) })
const unsubUser = window.electronAPI.voiceConversation.onUserMessage((msg) => { const unsubUser = window.electronAPI.voiceConversation.onUserMessage((msg) => {
setMessages((prev) => [...prev, msg]) setLocalMessages((prev) => [...prev, msg])
setStreamingText('') setLocalStreamingText('')
setStreamingMsgId(null) setLocalStreamingMsgId(null)
setTimeout(scrollToBottom, 50) setTimeout(scrollToBottom, 50)
}) })
const unsubDelta = window.electronAPI.voiceConversation.onAssistantDelta((data: ConversationAssistantDelta) => { const unsubDelta = window.electronAPI.voiceConversation.onAssistantDelta((data: ConversationAssistantDelta) => {
setStreamingMsgId(data.messageId) setLocalStreamingMsgId(data.messageId)
setStreamingText(data.accumulated) setLocalStreamingText(data.accumulated)
setTimeout(scrollToBottom, 50) setTimeout(scrollToBottom, 50)
}) })
const unsubComplete = window.electronAPI.voiceConversation.onAssistantMessage((msg) => { const unsubComplete = window.electronAPI.voiceConversation.onAssistantMessage((msg) => {
setMessages((prev) => [...prev, { id: msg.messageId, role: 'assistant', content: msg.content, timestamp: Date.now() }]) setLocalMessages((prev) => [...prev, { id: msg.messageId, role: 'assistant', content: msg.content, timestamp: Date.now() }])
setStreamingText('') setLocalStreamingText('')
setStreamingMsgId(null) setLocalStreamingMsgId(null)
setTimeout(scrollToBottom, 50) setTimeout(scrollToBottom, 50)
}) })
const unsubError = window.electronAPI.voiceConversation.onError((err: ConversationError) => { const unsubError = window.electronAPI.voiceConversation.onError((err: ConversationError) => {
@ -69,9 +97,9 @@ export function VoiceConversationPage(): React.ReactElement {
// 초기 상태 로드 // 초기 상태 로드
window.electronAPI.voiceConversation.getState().then((r) => { window.electronAPI.voiceConversation.getState().then((r) => {
if (r.success) { if (r.success) {
setState(r.data.state) setLocalState(r.data.state)
setMessages(r.data.messages) setLocalMessages(r.data.messages)
setIsActive(r.data.isActive) setLocalActive(r.data.isActive)
} }
}) })
@ -84,36 +112,78 @@ export function VoiceConversationPage(): React.ReactElement {
} }
}, [scrollToBottom]) }, [scrollToBottom])
// Realtime 메시지 갱신 시 스크롤
useEffect(() => {
if (isRealtime) setTimeout(scrollToBottom, 50)
}, [isRealtime, realtime.messages, realtime.streamingText, scrollToBottom])
const handleStartSession = useCallback(async () => { 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() await window.electronAPI.voiceConversation.startSession()
}, []) }, [isRealtime, realtime, t])
const handleStopSession = useCallback(async () => { const handleStopSession = useCallback(async () => {
if (isRealtime) {
realtime.stop()
return
}
await window.electronAPI.voiceConversation.stopSession() await window.electronAPI.voiceConversation.stopSession()
}, []) }, [isRealtime, realtime])
const handleFinishListening = useCallback(async () => { const handleFinishListening = useCallback(async () => {
if (isRealtime) return // server VAD가 자동 처리
await window.electronAPI.voiceConversation.finishListening() await window.electronAPI.voiceConversation.finishListening()
}, []) }, [isRealtime])
const handleSendText = useCallback(async () => { const handleSendText = useCallback(async () => {
if (!textInput.trim()) return if (!textInput.trim()) return
const text = textInput.trim() const text = textInput.trim()
setTextInput('') 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) { if (!isActive) {
await window.electronAPI.voiceConversation.startSession() await window.electronAPI.voiceConversation.startSession()
} }
await window.electronAPI.voiceConversation.sendMessage({ text }) await window.electronAPI.voiceConversation.sendMessage({ text })
}, [textInput, isActive]) }, [textInput, isActive, isRealtime, realtime, t])
const handleClearHistory = useCallback(async () => { const handleClearHistory = useCallback(async () => {
if (isRealtime) {
realtime.clearMessages()
return
}
await window.electronAPI.voiceConversation.clearHistory() await window.electronAPI.voiceConversation.clearHistory()
setMessages([]) setLocalMessages([])
}, []) }, [isRealtime, realtime])
const handleCancelResponse = useCallback(async () => { const handleCancelResponse = useCallback(async () => {
if (isRealtime) {
realtime.cancelResponse()
return
}
await window.electronAPI.voiceConversation.cancelResponse() await window.electronAPI.voiceConversation.cancelResponse()
}, []) }, [isRealtime, realtime])
const handleTextKeyDown = useCallback((e: React.KeyboardEvent) => { const handleTextKeyDown = useCallback((e: React.KeyboardEvent) => {
if (isImeComposingEvent(e)) return if (isImeComposingEvent(e)) return
@ -130,6 +200,11 @@ export function VoiceConversationPage(): React.ReactElement {
speaking: t('conversation.speaking'), speaking: t('conversation.speaking'),
} }
const displayStateLabel =
isRealtime && realtime.connectionState === 'connecting'
? t('conversation.realtime.connecting')
: stateLabel[state]
const formatErrorMessage = useCallback((err: ConversationError): string => { const formatErrorMessage = useCallback((err: ConversationError): string => {
// Bug 13의 전형적 STT 메시지는 i18n 키로 매핑 — 그 외는 서비스 원문 + phase label. // Bug 13의 전형적 STT 메시지는 i18n 키로 매핑 — 그 외는 서비스 원문 + phase label.
if (err.phase === 'stt' && err.message.toLowerCase().startsWith('no speech')) { if (err.phase === 'stt' && err.message.toLowerCase().startsWith('no speech')) {
@ -152,9 +227,23 @@ export function VoiceConversationPage(): React.ReactElement {
title={t('conversation.title').toUpperCase()} title={t('conversation.title').toUpperCase()}
action={ action={
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}> <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} /> <Led color={stateLedColor[state]} pulse={state === 'listening' || state === 'thinking'} size={8} />
<PhosphorText variant="label" sx={{ color: d3roPalette.text.dimLabel }}> <PhosphorText variant="label" sx={{ color: d3roPalette.text.dimLabel }}>
{stateLabel[state].toUpperCase()} {displayStateLabel.toUpperCase()}
</PhosphorText> </PhosphorText>
{messages.length > 0 && ( {messages.length > 0 && (
<Tooltip title={t('conversation.clearHistory')}> <Tooltip title={t('conversation.clearHistory')}>
@ -167,8 +256,8 @@ export function VoiceConversationPage(): React.ReactElement {
} }
/> />
{/* 메시지 영역 — state 분기: listening=몰입 패널, 그 외=메시지 리스트 */} {/* 메시지 영역 — 로컬 파이프라인의 listening만 몰입 패널 (Realtime은 항상 채팅 뷰) */}
{state === 'listening' ? ( {!isRealtime && state === 'listening' ? (
<Box <Box
sx={{ sx={{
flex: 1, flex: 1,
@ -292,7 +381,11 @@ export function VoiceConversationPage(): React.ReactElement {
<MicIcon sx={{ fontSize: 20 }} /> <MicIcon sx={{ fontSize: 20 }} />
</PhysicalButton> </PhysicalButton>
) : state === 'listening' ? ( ) : 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 }} /> <StopIcon sx={{ fontSize: 20 }} />
</PhysicalButton> </PhysicalButton>
) : state === 'thinking' || state === 'speaking' ? ( ) : state === 'thinking' || state === 'speaking' ? (
@ -305,8 +398,8 @@ export function VoiceConversationPage(): React.ReactElement {
</PhysicalButton> </PhysicalButton>
)} )}
{/* 텍스트 입력 — listening 시 숨김, thinking/speaking 시 disabled */} {/* 텍스트 입력 — 로컬 listening 시 숨김 (Realtime은 항상 표시), thinking/speaking 시 disabled */}
{state !== 'listening' && ( {(isRealtime || state !== 'listening') && (
<TextField <TextField
value={state === 'thinking' || state === 'speaking' ? '' : textInput} value={state === 'thinking' || state === 'speaking' ? '' : textInput}
onChange={(e) => setTextInput(e.target.value)} onChange={(e) => setTextInput(e.target.value)}
@ -335,8 +428,8 @@ export function VoiceConversationPage(): React.ReactElement {
/> />
)} )}
{/* 전송 버튼 — listening 시 숨김 */} {/* 전송 버튼 — 로컬 listening 시 숨김 */}
{state !== 'listening' && ( {(isRealtime || state !== 'listening') && (
<IconButton <IconButton
onClick={handleSendText} onClick={handleSendText}
disabled={!textInput.trim() || state === 'thinking' || state === 'speaking'} disabled={!textInput.trim() || state === 'thinking' || state === 'speaking'}

View file

@ -22,11 +22,22 @@
- **테스트 수리**: errors.test.ts / VoiceModeService.test.ts의 모노레포 전환 잔재 import(`src/shared/*``@d3ro/core/*`) 수정 → 41/41 통과 - **테스트 수리**: errors.test.ts / VoiceModeService.test.ts의 모노레포 전환 잔재 import(`src/shared/*``@d3ro/core/*`) 수정 → 41/41 통과
- 검증: desktop tsc EXIT 0, vitest 41 passed - 검증: 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/분) - 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 파이프라인 대체 - **서버**: `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.
- 선행 조건: OpenAI API 키 Supabase Secrets 등록, 분 단위 쿼터 정책 설계 - **IPC**: `voiceConversation:getRealtimeToken` (main이 CloudSync invokeFunction 경유), ErrorCode 799 `ConversationRealtimeTokenFailed`
- 참고: Parakeet은 한국어 미지원이라 로컬 STT 대체 후보에서 제외 - **렌더러**: `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) ✅ ## v0.1.0-alpha 배포 전체 흐름 (2026-04-15 night) ✅

View file

@ -137,6 +137,7 @@ export enum ErrorCode {
ConversationNoActiveSession = 796, ConversationNoActiveSession = 796,
ConversationTTSFailed = 797, ConversationTTSFailed = 797,
ConversationLLMFailed = 798, ConversationLLMFailed = 798,
ConversationRealtimeTokenFailed = 799,
// === Phase 13: Local RAG (870-874) === // === Phase 13: Local RAG (870-874) ===
RAGDocumentNotFound = 870, RAGDocumentNotFound = 870,

View file

@ -276,6 +276,8 @@ export const IPC_CHANNELS = {
GET_HISTORY: 'voiceConversation:getHistory', GET_HISTORY: 'voiceConversation:getHistory',
CLEAR_HISTORY: 'voiceConversation:clearHistory', CLEAR_HISTORY: 'voiceConversation:clearHistory',
CANCEL_RESPONSE: 'voiceConversation:cancelResponse', CANCEL_RESPONSE: 'voiceConversation:cancelResponse',
/** OpenAI Realtime ephemeral token 발급 (Supabase realtime-token 경유) */
GET_REALTIME_TOKEN: 'voiceConversation:getRealtimeToken',
// Main → Renderer events // Main → Renderer events
STATE_CHANGED: 'voiceConversation:stateChanged', STATE_CHANGED: 'voiceConversation:stateChanged',
USER_MESSAGE: 'voiceConversation:userMessage', USER_MESSAGE: 'voiceConversation:userMessage',

View file

@ -372,6 +372,12 @@ export interface AppConfig {
* 'premium' PremiumLLMService (Supabase llm-proxy Claude, + ) * 'premium' PremiumLLMService (Supabase llm-proxy Claude, + )
*/ */
llmBackend: 'local' | 'premium' llmBackend: 'local' | 'premium'
/**
* .
* 'local' VoiceConversationService (STTLLMTTS , )
* 'realtime' OpenAI Realtime API (gpt-realtime-2.1, WebRTC , + )
*/
conversationBackend: 'local' | 'realtime'
defaultLLMAction: LLMAction defaultLLMAction: LLMAction
dictationShortcut: HotkeyBinding dictationShortcut: HotkeyBinding
handsFreeShortcut: HotkeyBinding handsFreeShortcut: HotkeyBinding
@ -1179,6 +1185,25 @@ export interface ConversationError {
phase: 'stt' | 'llm' | 'tts' 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) // Phase 13: Local RAG (13.2)
// ============================================================ // ============================================================

View file

@ -133,6 +133,11 @@
"settings.backend.premium": "Premium (Claude AI, Subscription)", "settings.backend.premium": "Premium (Claude AI, Subscription)",
"settings.backend.localHint": "Polish text with local Ollama server. Fully free, no internet needed.", "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.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.llmModel": "LLM Model",
"settings.postProcess": "Voice Post-Processing", "settings.postProcess": "Voice Post-Processing",
"settings.defaultAction": "Default Post-Processing Command", "settings.defaultAction": "Default Post-Processing Command",
@ -407,6 +412,9 @@
"conversation.error.phase.llm": "Response", "conversation.error.phase.llm": "Response",
"conversation.error.phase.tts": "Voice Playback", "conversation.error.phase.tts": "Voice Playback",
"conversation.error.noSpeech": "No speech detected. Check your microphone and try again.", "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", "nav.knowledge": "Knowledge",
"rag.title": "Knowledge Base", "rag.title": "Knowledge Base",
"rag.addDocument": "Add Document", "rag.addDocument": "Add Document",

View file

@ -134,6 +134,11 @@
"settings.backend.premium": "Premium (Claude AI, 구독)", "settings.backend.premium": "Premium (Claude AI, 구독)",
"settings.backend.localHint": "로컬 Ollama 서버로 텍스트를 다듬습니다. 완전 무료, 인터넷 불필요.", "settings.backend.localHint": "로컬 Ollama 서버로 텍스트를 다듬습니다. 완전 무료, 인터넷 불필요.",
"settings.backend.premiumHint": "Anthropic Claude AI로 고품질 다듬기. 로그인 + 구독 필요. 네트워크 실패 시 Local로 자동 전환.", "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.llmModel": "LLM 모델",
"settings.postProcess": "음성 후처리", "settings.postProcess": "음성 후처리",
"settings.defaultAction": "기본 후처리 명령어", "settings.defaultAction": "기본 후처리 명령어",
@ -408,6 +413,9 @@
"conversation.error.phase.llm": "응답 생성", "conversation.error.phase.llm": "응답 생성",
"conversation.error.phase.tts": "음성 재생", "conversation.error.phase.tts": "음성 재생",
"conversation.error.noSpeech": "음성이 감지되지 않았습니다. 마이크를 확인하고 다시 말씀해 주세요.", "conversation.error.noSpeech": "음성이 감지되지 않았습니다. 마이크를 확인하고 다시 말씀해 주세요.",
"conversation.realtime.badge": "LIVE",
"conversation.realtime.connecting": "연결 중",
"conversation.realtime.fallback": "라이브 연결 실패 — 로컬 대화로 전환했습니다.",
"rag.title": "지식 베이스", "rag.title": "지식 베이스",
"rag.addDocument": "문서 추가", "rag.addDocument": "문서 추가",
"rag.documents": "문서", "rag.documents": "문서",

View file

@ -87,6 +87,10 @@ verify_jwt = true
# 2026 sb_publishable_ 키와 Gateway JWT 검증 비호환 — requireUser()에서 직접 인증 # 2026 sb_publishable_ 키와 Gateway JWT 검증 비호환 — requireUser()에서 직접 인증
verify_jwt = false verify_jwt = false
[functions.realtime-token]
# 2026 sb_publishable_ 키와 Gateway JWT 검증 비호환 — requireUser()에서 직접 인증
verify_jwt = false
[functions.stripe-checkout] [functions.stripe-checkout]
verify_jwt = true verify_jwt = true

View file

@ -13,6 +13,7 @@ export type QuotaFeature =
| 'llm_haiku' | 'llm_haiku'
| 'llm_sonnet' | 'llm_sonnet'
| 'llm_opus' | 'llm_opus'
| 'realtime_session'
export type QuotaPeriod = 'daily' | 'weekly' export type QuotaPeriod = 'daily' | 'weekly'
@ -29,18 +30,22 @@ const MODEL_QUOTA: Record<Tier, Record<QuotaFeature, ModelQuota>> = {
llm_haiku: { limit: 250, period: 'weekly' }, llm_haiku: { limit: 250, period: 'weekly' },
llm_sonnet: { limit: 0, period: 'daily' }, // 사용불가 llm_sonnet: { limit: 0, period: 'daily' }, // 사용불가
llm_opus: { limit: 0, period: 'daily' }, // 사용불가 llm_opus: { limit: 0, period: 'daily' }, // 사용불가
realtime_session: { limit: 0, period: 'daily' }, // 사용불가
}, },
pro: { pro: {
stt_transcribe: { limit: -1, period: 'daily' }, stt_transcribe: { limit: -1, period: 'daily' },
llm_haiku: { limit: 1500, period: 'daily' }, llm_haiku: { limit: 1500, period: 'daily' },
llm_sonnet: { limit: 300, period: 'daily' }, llm_sonnet: { limit: 300, period: 'daily' },
llm_opus: { limit: 50, period: 'daily' }, llm_opus: { limit: 50, period: 'daily' },
// 세션 수 기준 (~$0.016/분 mini — 세션당 평균 수 분 가정)
realtime_session: { limit: 30, period: 'daily' },
}, },
pro_plus: { pro_plus: {
stt_transcribe: { limit: -1, period: 'daily' }, stt_transcribe: { limit: -1, period: 'daily' },
llm_haiku: { limit: -1, period: 'daily' }, // 무제한 llm_haiku: { limit: -1, period: 'daily' }, // 무제한
llm_sonnet: { limit: 1500, period: 'daily' }, llm_sonnet: { limit: 1500, period: 'daily' },
llm_opus: { limit: 300, period: 'daily' }, llm_opus: { limit: 300, period: 'daily' },
realtime_session: { limit: 120, period: 'daily' },
}, },
} }

View 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' },
})
}
})