diff --git a/server/internal/api/web/chat.go b/server/internal/api/web/chat.go index 0e46f09..00199f7 100644 --- a/server/internal/api/web/chat.go +++ b/server/internal/api/web/chat.go @@ -337,6 +337,9 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) { streamDone = true continue } + if ch.ReasoningDelta != "" { + emit("reasoning", map[string]any{"type": "reasoning", "content": ch.ReasoningDelta}) + } if ch.Delta != "" { full.WriteString(ch.Delta) emit("delta", map[string]any{"type": "delta", "content": ch.Delta}) diff --git a/server/internal/provider/pioneer.go b/server/internal/provider/pioneer.go index 37fc809..aa3d8a2 100644 --- a/server/internal/provider/pioneer.go +++ b/server/internal/provider/pioneer.go @@ -92,13 +92,14 @@ type ToolResult struct { // Chunk is one decoded SSE event from the provider. type Chunk struct { - Raw []byte // the verbatim JSON for re-emission to clients - Delta string // assistant content delta, if any - ToolCalls []ToolCallDelta // tool call deltas, if any - FinishReason string // "stop", "tool_calls", etc. - Usage *Usage // populated on the final chunk when include_usage is on - Done bool // true once we see "[DONE]" - Extra map[string]any // anything else we care to inspect later + Raw []byte // the verbatim JSON for re-emission to clients + Delta string // assistant content delta, if any + ReasoningDelta string // reasoning/thinking content delta, if any + ToolCalls []ToolCallDelta // tool call deltas, if any + FinishReason string // "stop", "tool_calls", etc. + Usage *Usage // populated on the final chunk when include_usage is on + Done bool // true once we see "[DONE]" + Extra map[string]any // anything else we care to inspect later } // ToolCallDelta represents an incremental tool call from a streaming response. @@ -231,8 +232,9 @@ func (c *Client) StreamChatRaw(ctx context.Context, body []byte) (<-chan Chunk, type rawChunk struct { Choices []struct { Delta struct { - Content string `json:"content"` - ToolCalls []ToolCallDelta `json:"tool_calls,omitempty"` + Content string `json:"content"` + ReasoningContent string `json:"reasoning_content,omitempty"` + ToolCalls []ToolCallDelta `json:"tool_calls,omitempty"` } `json:"delta"` FinishReason *string `json:"finish_reason,omitempty"` } `json:"choices"` @@ -257,6 +259,7 @@ func decodeChunk(payload []byte) (Chunk, error) { c := Chunk{Raw: append([]byte(nil), payload...), Usage: rc.Usage} if len(rc.Choices) > 0 { c.Delta = rc.Choices[0].Delta.Content + c.ReasoningDelta = rc.Choices[0].Delta.ReasoningContent if len(rc.Choices[0].Delta.ToolCalls) > 0 { c.ToolCalls = rc.Choices[0].Delta.ToolCalls } diff --git a/web/src/routes/chat/+page.svelte b/web/src/routes/chat/+page.svelte index 042fc50..4fb9627 100644 --- a/web/src/routes/chat/+page.svelte +++ b/web/src/routes/chat/+page.svelte @@ -9,7 +9,7 @@ import { renderMarkdown, renderStreamingMarkdown } from '$lib/markdown'; import { consume, type StreamEvent } from '$lib/stream'; import ModelPicker from './ModelPicker.svelte'; - import ToolCallBlock from './ToolCallBlock.svelte'; + import ProcessBlock, { type ProcessStep } from './ProcessBlock.svelte'; // ── state ───────────────────────────────────────────────────────────────── @@ -34,11 +34,10 @@ let atBottom = true; let errorMsg = $state(null); - // Tool call tracking during streaming — keyed by tool_call_id. - // Persists past streaming=false so the expandable widget renders on the - // finished assistant message. - let toolCalls = $state>(new Map()); - let toolMsgId = $state(null); + // Ordered process steps (reasoning + tool calls) for the active stream. + // Persists past streaming=false so ProcessBlock stays rendered. + let processSteps = $state([]); + let processMsgId = $state(null); // ── spin cursor (crush-style cycling glyphs) ────────────────────────────── @@ -281,8 +280,8 @@ streamFirstTokenMs = 0; streamElapsedMs = 0; streamLiveTokens = 0; - toolCalls = new Map(); - toolMsgId = null; + processSteps = []; + processMsgId = null; // Seed the placeholder message in Dexie so the spinner appears immediately. const now = Math.floor(Date.now() / 1000); @@ -368,42 +367,43 @@ }); } + if (ev.type === 'reasoning' && ev.content) { + if (!processMsgId) processMsgId = assistantId; + const last = processSteps.at(-1); + if (last?.type === 'reasoning') { + last.content += ev.content as string; + processSteps = [...processSteps]; + } else { + processSteps = [...processSteps, { type: 'reasoning', content: ev.content as string }]; + } + } + if (ev.type === 'tool_call') { - if (!toolMsgId) toolMsgId = assistantId; - const tcId = (ev as any).id as string || uuid(); - toolCalls.set(tcId, { + if (!processMsgId) processMsgId = assistantId; + processSteps = [...processSteps, { + type: 'tool', + id: ((ev as any).id as string) || uuid(), name: ((ev as any).name as string) || 'tool', args: ((ev as any).arguments as string) || '', result: '', done: false - }); - toolCalls = new Map(toolCalls); + }]; } if (ev.type === 'tool_result') { const tcId = ((ev as any).tool_call_id as string) || ''; - const existing = tcId ? toolCalls.get(tcId) : null; - if (existing) { - toolCalls.set(tcId, { ...existing, result: ((ev as any).content as string) || '', done: true }); - } else { - let matched = false; - for (const [id, tc] of toolCalls) { - if (!tc.done) { - toolCalls.set(id, { ...tc, result: ((ev as any).content as string) || '', done: true }); - matched = true; - break; - } - } - if (!matched) { - toolCalls.set(tcId || uuid(), { - name: 'tool', - args: '', - result: ((ev as any).content as string) || '', - done: true - }); - } + const result = ((ev as any).content as string) || ''; + const idx = tcId + ? processSteps.findIndex((s) => s.type === 'tool' && s.id === tcId) + : processSteps.findLastIndex((s) => s.type === 'tool' && !s.done); + if (idx >= 0) { + const step = processSteps[idx] as Extract; + processSteps = [ + ...processSteps.slice(0, idx), + { ...step, result, done: true }, + ...processSteps.slice(idx + 1) + ]; } - toolCalls = new Map(toolCalls); } if (ev.type === 'done' || ev.type === 'error') { @@ -461,8 +461,8 @@ streaming = true; atBottom = true; errorMsg = null; - toolCalls = new Map(); - toolMsgId = null; + processSteps = []; + processMsgId = null; streamStartMs = Date.now(); streamFirstTokenMs = 0; @@ -702,8 +702,8 @@
- {#if msg.id === toolMsgId && toolCalls.size > 0} - + {#if msg.id === processMsgId && processSteps.length > 0} + {/if} {#if msg.id === streamingMsgId} {@html renderStreamingMarkdown(msg.content)} diff --git a/web/src/routes/chat/ProcessBlock.svelte b/web/src/routes/chat/ProcessBlock.svelte new file mode 100644 index 0000000..2a984a2 --- /dev/null +++ b/web/src/routes/chat/ProcessBlock.svelte @@ -0,0 +1,233 @@ + + +
+ + + {#if open} +
+ {#each steps as step, i (i)} + {#if step.type === 'reasoning'} +

{step.content}

+ {:else} +
+
+ {#if step.done} + ✓ + {:else} + + {/if} + {step.name} +
+ {#if step.args} +
+ args +
{step.args}
+
+ {/if} + {#if step.result} +
+ result +
{step.result}
+
+ {/if} +
+ {/if} + {/each} +
+ {/if} +
+ + diff --git a/web/src/routes/chat/ReasoningBlock.svelte b/web/src/routes/chat/ReasoningBlock.svelte new file mode 100644 index 0000000..976d62a --- /dev/null +++ b/web/src/routes/chat/ReasoningBlock.svelte @@ -0,0 +1,129 @@ + + +
+ + + {#if open} +
+
{reasoning}
+
+ {/if} +
+ +