feat: stream Reginald's replies token-by-token
The 26b is slow (~30s/call); the chat now shows the answer forming instead of freezing until it's done. The final prose streams over SSE; tool-calling turns stay structured (no partial tokens), so streaming kicks in for the narration. core (@commitea/core): - chat-client.complete gains an optional onToken — when set, it requests stream:true and parses the OpenAI SSE stream, emitting content deltas and assembling streamed tool-call argument fragments into the final result. - GiteaHttpResponse exposes the optional `body` stream (real fetch has it; stubs don't). agent-loop threads onToken to each completion. app: - model:chat forwards each delta to the renderer (event.sender.send); preload exposes model.onToken(cb) → unsubscribe. useChat accumulates the live stream into a growing bubble (with a cursor), replaced by the authoritative final content when the turn resolves. Unconfigured → scripted reply, unchanged. Verified: 118 core tests green (2 streaming: SSE content deltas + tool-call fragment assembly), desktop typecheck clean, 14 fixture e2e green. Live: a real turn against gemma-4-26b assembles the correct answer via the streaming path (live-reginald green) — the reply now renders token-by-token. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -19,7 +19,7 @@ export interface ChatPanelProps {
|
||||
}
|
||||
|
||||
export function ChatPanel({ onOpenDirectives, offline, onApplyChange }: ChatPanelProps) {
|
||||
const { msgs, thinking, live, model, steps, proposals, send: sendChat, approve, dismiss } = useChat(onApplyChange)
|
||||
const { msgs, thinking, live, model, steps, proposals, streaming, send: sendChat, approve, dismiss } = useChat(onApplyChange)
|
||||
// shorten "google/gemma-4-26b-a4b-qat" → "gemma-4-26b" for the header chip
|
||||
const modelLabel = model ? (model.split('/').pop() ?? model).replace(/-(qat|instruct|it|gguf)$/i, '') : 'gemma-4'
|
||||
const [text, setText] = useState('')
|
||||
@@ -28,7 +28,7 @@ export function ChatPanel({ onOpenDirectives, offline, onApplyChange }: ChatPane
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current
|
||||
if (el) el.scrollTop = el.scrollHeight
|
||||
}, [msgs, thinking])
|
||||
}, [msgs, thinking, streaming])
|
||||
|
||||
const send = () => {
|
||||
const t = text.trim()
|
||||
@@ -98,7 +98,14 @@ export function ChatPanel({ onOpenDirectives, offline, onApplyChange }: ChatPane
|
||||
The model is away from its desk. Reads still work; writes will wait their turn.
|
||||
</div>
|
||||
) : null}
|
||||
{thinking ? <div style={{ font: 'var(--text-agent)', color: 'var(--ink-3)' }}>considering…</div> : null}
|
||||
{streaming ? (
|
||||
<div style={{ font: 'var(--text-agent)', color: 'var(--ink-1)', lineHeight: 1.55 }}>
|
||||
{streaming}
|
||||
<span style={{ opacity: 0.5 }}>▊</span>
|
||||
</div>
|
||||
) : thinking ? (
|
||||
<div style={{ font: 'var(--text-agent)', color: 'var(--ink-3)' }}>considering…</div>
|
||||
) : null}
|
||||
{!thinking && steps.length ? (
|
||||
<div style={{ font: 'var(--text-caption)', color: 'var(--ink-3)', display: 'flex', alignItems: 'center', gap: 5 }}>
|
||||
<Icon name="eye" size={11} /> consulted {Array.from(new Set(steps.map((s) => s.replace('query_project', 'the project').replace('propose_change', 'the labels').replace('record_directive', 'the directive ledger')))).join(', ')}
|
||||
|
||||
2
apps/desktop/src/renderer/src/global.d.ts
vendored
2
apps/desktop/src/renderer/src/global.d.ts
vendored
@@ -68,6 +68,8 @@ export interface ModelBridge {
|
||||
status(): Promise<{ configured: boolean; model: string | null }>
|
||||
chat(messages: ChatMessage[]): Promise<ChatResult>
|
||||
capture(braindump: string): Promise<CaptureResult>
|
||||
/** Subscribe to streamed prose tokens; returns an unsubscribe fn. */
|
||||
onToken(cb: (delta: string) => void): () => void
|
||||
}
|
||||
|
||||
/** The result of reading the directive ledger. */
|
||||
|
||||
@@ -20,6 +20,8 @@ export interface ChatState {
|
||||
steps: string[]
|
||||
/** Changes Reginald has proposed and is awaiting approval on. */
|
||||
proposals: ChangeProposal[]
|
||||
/** The in-flight streamed prose (grows token-by-token) before the turn finalizes. */
|
||||
streaming: string
|
||||
send: (text: string) => void
|
||||
approve: (p: ChangeProposal) => void
|
||||
dismiss: (p: ChangeProposal) => void
|
||||
@@ -41,6 +43,7 @@ export function useChat(onApplyChange?: (change: IssueChange) => Promise<{ ok: b
|
||||
const [model, setModel] = useState<string | null>(null)
|
||||
const [steps, setSteps] = useState<string[]>([])
|
||||
const [proposals, setProposals] = useState<ChangeProposal[]>([])
|
||||
const [streaming, setStreaming] = useState('')
|
||||
const convoRef = useRef(convo)
|
||||
convoRef.current = convo
|
||||
|
||||
@@ -86,10 +89,17 @@ export function useChat(onApplyChange?: (change: IssueChange) => Promise<{ ok: b
|
||||
role: m.from === 'user' ? 'user' : 'assistant',
|
||||
content: m.text,
|
||||
}))
|
||||
setStreaming('')
|
||||
const unsubscribe = window.commitea.model.onToken((delta) => setStreaming((s) => s + delta))
|
||||
const finish = () => {
|
||||
unsubscribe()
|
||||
setThinking(false)
|
||||
setStreaming('')
|
||||
}
|
||||
window.commitea.model
|
||||
.chat(wire)
|
||||
.then((res) => {
|
||||
setThinking(false)
|
||||
finish()
|
||||
if (res.ok) {
|
||||
setSteps(res.steps.map((s) => s.tool))
|
||||
setProposals(res.proposals)
|
||||
@@ -105,7 +115,7 @@ export function useChat(onApplyChange?: (change: IssueChange) => Promise<{ ok: b
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setThinking(false)
|
||||
finish()
|
||||
setConvo((c) => [...c, { from: 'agent', text: 'I could not reach the model.' }])
|
||||
})
|
||||
},
|
||||
@@ -137,5 +147,5 @@ export function useChat(onApplyChange?: (change: IssueChange) => Promise<{ ok: b
|
||||
setConvo((c) => [...c, { from: 'agent', text: `Left #${p.change.issue} as it was.` }])
|
||||
}, [])
|
||||
|
||||
return { msgs: [...seed, ...convo], thinking, live, model, steps, proposals, send, approve, dismiss }
|
||||
return { msgs: [...seed, ...convo], thinking, live, model, steps, proposals, streaming, send, approve, dismiss }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user