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:
parent
667c09242b
commit
7467bd6ce9
3 changed files with 172 additions and 11 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue