diff --git a/internal/llm/client_completions.go b/internal/llm/client_completions.go index 9dbb42a..8e3e3af 100644 --- a/internal/llm/client_completions.go +++ b/internal/llm/client_completions.go @@ -177,18 +177,21 @@ func (c *Client) completions( // StreamEvent wraps a message or delta produced during streaming. type StreamEvent struct { - // Type identifies the event kind: delta, done, or message. + // Type identifies the event kind: delta, reasoning_delta, done, or message. Type string // Delta is a text fragment from the assistant response. Delta string + // ReasoningDelta is a reasoning text fragment from the assistant response. + ReasoningDelta string // Message is the complete chat completion message. Message Message } const ( - streamEventDelta = "delta" - streamEventDone = "done" - streamEventMessage = "message" + streamEventDelta = "delta" + streamEventReasoningDelta = "reasoning_delta" + streamEventDone = "done" + streamEventMessage = "message" ) // CompletionsStream sends messages to the LLM using the specified model and @@ -403,6 +406,10 @@ func (c *Client) completionsStream(ctx context.Context, messages []Message, mode acc.reasoning.WriteString( delta.ReasoningContent, ) + events <- StreamEvent{ + Type: streamEventReasoningDelta, + ReasoningDelta: delta.ReasoningContent, + } } // Accumulate tool call deltas by index. for _, tc := range delta.ToolCalls { diff --git a/internal/service/service.go b/internal/service/service.go index ea4fbe8..80086b0 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -585,6 +585,8 @@ type StreamMessage struct { Error string `json:"error,omitempty"` // Delta is a text fragment from the assistant response. Delta string `json:"delta,omitempty"` + // ReasoningDelta is a reasoning text fragment from the assistant response. + ReasoningDelta string `json:"reasoning_delta,omitempty"` // Message is the chat completion message. Message *llm.Message `json:"message,omitempty"` // Provider is the LLM provider used for the response. @@ -709,9 +711,10 @@ func (svc *Service) chatStream(w http.ResponseWriter, r *http.Request) { // Consume events and send as SSE. for evt := range events { msg := StreamMessage{ - Delta: evt.Delta, - Provider: provider, - Model: model, + Delta: evt.Delta, + ReasoningDelta: evt.ReasoningDelta, + Provider: provider, + Model: model, } if evt.Message.Role != "" { msg.Message = &evt.Message diff --git a/internal/service/static/main.js b/internal/service/static/main.js index a426743..f801a44 100644 --- a/internal/service/static/main.js +++ b/internal/service/static/main.js @@ -5,6 +5,7 @@ const PROVIDER_KEY = 'odidere_provider'; const STORAGE_KEY = 'odidere_history'; const STREAM_ENDPOINT = '/v1/chat/voice/stream'; const StreamEventDelta = 'delta'; +const StreamEventReasoningDelta = 'reasoning_delta'; const StreamEventDone = 'done'; const StreamEventMessage = 'message'; const SYSTEM_MESSAGE_KEY = 'odidere_system_message'; @@ -1583,7 +1584,10 @@ class Odidere { continue; } - if (eventType === StreamEventDelta) { + if ( + eventType === StreamEventDelta || + eventType === StreamEventReasoningDelta + ) { if (!streamingMessage) { const message = { role: 'assistant', @@ -1598,8 +1602,21 @@ class Odidere { streamingMessage = { message: appended[0], $el }; } - streamingMessage.message.content[0].text += event.delta || ''; - this.#appendStreamingDelta(streamingMessage.$el, event.delta || ''); + if (eventType === StreamEventDelta) { + streamingMessage.message.content[0].text += event.delta || ''; + this.#appendStreamingDelta( + streamingMessage.$el, + event.delta || '', + ); + } else { + streamingMessage.message.reasoning_content = `${ + streamingMessage.message.reasoning_content || '' + }${event.reasoning_delta || ''}`; + this.#appendStreamingReasoningDelta( + streamingMessage.$el, + event.reasoning_delta || '', + ); + } continue; } @@ -2263,6 +2280,24 @@ class Odidere { this.#scrollToBottom(); } + /** + * #appendStreamingReasoningDelta appends reasoning text to a streaming + * assistant message. + * @param {HTMLElement} $msg + * @param {string} delta + */ + #appendStreamingReasoningDelta($msg, delta) { + let $reasoning = $msg.querySelector('.collapsible--reasoning'); + if (!$reasoning) { + $reasoning = this.#createCollapsibleBlock('reasoning', 'Reasoning', ''); + const $content = $msg.querySelector('.message__content'); + $msg.querySelector('.message__body').insertBefore($reasoning, $content); + } + + $reasoning.querySelector('pre').textContent += delta; + this.#scrollToBottom(); + } + /** * #finalizeStreamingAssistantMessage replaces a streaming placeholder with * a fully bound assistant message.