feat(desktop): Premium LLM SSE 스트리밍 — 토큰 단위 실시간 응답

- sse-parser.ts: Anthropic SSE 파서 (content_block_delta.text 추출)
- CloudSyncService.invokeFunctionStream(): raw fetch + SSE ReadableStream
- PremiumLLMService.chatStream(): stream=true, 토큰 단위 yield
- SSE 실패 시 자동 비스트리밍 fallback
- AbortController 연결 (취소 지원)
This commit is contained in:
윤찬 2026-04-12 20:26:17 +09:00
parent 667c09242b
commit 7467bd6ce9
3 changed files with 172 additions and 11 deletions

View file

@ -425,6 +425,62 @@ class CloudSyncService extends EventEmitter {
return { data, error: null }
}
/**
* Edge Function SSE ReadableStream .
* Supabase JS의 functions.invoke() JSON SSE에 .
* raw fetch + auth .
*/
async invokeFunctionStream(
name: string,
body: Record<string, unknown>,
signal?: AbortSignal,
): Promise<{ stream: ReadableStream<Uint8Array> | null; error: { message: string } | null }> {
if (!this._client) {
return { stream: null, error: { message: 'Supabase client not initialized' } }
}
const { data: sessionData } = await this._client.auth.getSession()
const token = sessionData.session?.access_token
if (!token) {
return { stream: null, error: { message: 'No active session — 로그인 필요' } }
}
const url = configGet('supabaseUrl') as string | undefined
const anonKey = configGet('supabaseAnonKey') as string | undefined
if (!url || !anonKey) {
return { stream: null, error: { message: 'Supabase URL/key not configured' } }
}
try {
const response = await fetch(`${url}/functions/v1/${name}`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
apikey: anonKey,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
signal,
})
if (!response.ok) {
const text = await response.text()
return { stream: null, error: { message: `${response.status}: ${text}` } }
}
if (!response.body) {
return { stream: null, error: { message: 'No response body' } }
}
return { stream: response.body, error: null }
} catch (err) {
if ((err as Error).name === 'AbortError') {
return { stream: null, error: { message: 'Request aborted' } }
}
return { stream: null, error: { message: err instanceof Error ? err.message : String(err) } }
}
}
getState(): CloudSyncState {
return {
authenticated: this.isAuthenticated(),

View file

@ -15,6 +15,7 @@ import { getCloudSyncService } from './CloudSyncService'
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
import type { LLMAction } from '@d3ro/core/types'
import { resolveSystemPrompt } from './llm-prompts'
import { parseAnthropicSSE } from '../utils/sse-parser'
const logger = getLogger('PremiumLLMService')
@ -169,8 +170,7 @@ class PremiumLLMService extends EventEmitter {
/**
* (Voice Conversation용) LocalLLMService.chatStream .
* Phase 3.2 MVP: 비스트리밍으로 yield.
* SSE yield로 .
* SSE 스트리밍: llm-proxy에 stream=true로 , Anthropic SSE를 yield.
*/
async *chatStream(
messages: Array<{ role: string; content: string }>,
@ -178,7 +178,6 @@ class PremiumLLMService extends EventEmitter {
): AsyncGenerator<string, string> {
this._ensureAuth()
// Ollama 메시지 형식 → Claude Messages 형식으로 정규화
const claudeMessages: ChatMessage[] = messages
.filter((m) => m.role === 'user' || m.role === 'assistant')
.map((m) => ({ role: m.role as 'user' | 'assistant', content: m.content }))
@ -187,18 +186,47 @@ class PremiumLLMService extends EventEmitter {
messages: claudeMessages,
model: options?.model,
max_tokens: 2048,
stream: false,
stream: true,
}
const response = await this._invokeProxy(body)
const firstBlock = response.content?.[0]
const text = firstBlock?.type === 'text' ? firstBlock.text : ''
this._abortController = new AbortController()
const cloud = getCloudSyncService()
// 단일 chunk yield — 추후 SSE 스트리밍으로 업그레이드 시 여러 번 yield.
if (text.length > 0) {
yield text
const { stream, error } = await cloud.invokeFunctionStream(
'llm-proxy',
body as unknown as Record<string, unknown>,
this._abortController.signal,
)
if (error || !stream) {
const msg = error?.message ?? 'Stream unavailable'
logger.error(`SSE stream failed: ${msg}`)
// SSE 실패 시 비스트리밍 fallback
logger.info('Falling back to non-streaming Premium LLM')
const fallbackBody = { ...body, stream: false }
const response = await this._invokeProxy(fallbackBody)
const firstBlock = response.content?.[0]
const text = firstBlock?.type === 'text' ? firstBlock.text : ''
if (text.length > 0) yield text
return text
}
return text
let accumulated = ''
try {
for await (const token of parseAnthropicSSE(stream)) {
accumulated += token
yield token
}
} catch (err) {
if ((err as Error).name !== 'AbortError') {
logger.warn(`SSE parse error: ${err instanceof Error ? err.message : String(err)}`)
}
} finally {
this._abortController = null
}
return accumulated
}
cancelGeneration(): void {