feat(ui): render AI messages with streaming markdown

Messages store gains streamingIds set and startStream/appendStream/
endStream actions. Gateway store routes stream action payloads to
these new actions. Chat component switches to StreamingMarkdown
for in-progress messages, providing incremental block-level rendering.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Naiyuan Qing 2026-02-02 17:18:35 +08:00
parent d04bed8175
commit 86d00bb134
3 changed files with 83 additions and 15 deletions

View file

@ -1,5 +1,5 @@
import { create } from "zustand"
import { GatewayClient, type ConnectionState, type DeviceInfo, type SendErrorResponse } from "@multica/sdk"
import { GatewayClient, StreamAction, type ConnectionState, type DeviceInfo, type SendErrorResponse, type StreamPayload } from "@multica/sdk"
import { useMessagesStore } from "./messages"
const DEFAULT_GATEWAY_URL = "http://localhost:3000"
@ -45,6 +45,32 @@ export const useGatewayStore = create<GatewayStore>()((set, get) => ({
})
.onStateChange((connectionState) => set({ connectionState }))
.onMessage((msg) => {
// Handle streaming messages
if (msg.action === StreamAction) {
const payload = msg.payload as StreamPayload
const store = useMessagesStore.getState()
switch (payload.state) {
case "delta": {
const exists = store.messages.some((m) => m.id === payload.streamId)
if (!exists) {
store.startStream(payload.streamId, payload.agentId)
}
if (payload.content) {
store.appendStream(payload.streamId, payload.content)
}
break
}
case "final":
store.endStream(payload.streamId, payload.content ?? "")
break
case "error":
store.endStream(payload.streamId, `[error] ${payload.error}`)
break
}
return
}
// Fallback: complete message handling
const payload = msg.payload as { agentId?: string; content?: string }
if (payload?.agentId && payload?.content) {
useMessagesStore.getState().addAssistantMessage(payload.content, payload.agentId)

View file

@ -10,6 +10,7 @@ export interface Message {
interface MessagesState {
messages: Message[]
streamingIds: Set<string>
}
interface MessagesActions {
@ -18,12 +19,16 @@ interface MessagesActions {
updateMessage: (id: string, content: string) => void
loadMessages: (agentId: string, msgs: Message[]) => void
clearMessages: (agentId?: string) => void
startStream: (streamId: string, agentId: string) => void
appendStream: (streamId: string, content: string) => void
endStream: (streamId: string, content: string) => void
}
export type MessagesStore = MessagesState & MessagesActions
export const useMessagesStore = create<MessagesStore>()((set, get) => ({
messages: [],
streamingIds: new Set<string>(),
addUserMessage: (content, agentId) => {
set((s) => ({
@ -54,4 +59,32 @@ export const useMessagesStore = create<MessagesStore>()((set, get) => ({
messages: agentId ? s.messages.filter((m) => m.agentId !== agentId) : [],
}))
},
startStream: (streamId, agentId) => {
set((s) => {
const ids = new Set(s.streamingIds)
ids.add(streamId)
return {
messages: [...s.messages, { id: streamId, role: "assistant" as const, content: "", agentId }],
streamingIds: ids,
}
})
},
appendStream: (streamId, content) => {
set((s) => ({
messages: s.messages.map((m) => (m.id === streamId ? { ...m, content } : m)),
}))
},
endStream: (streamId, content) => {
set((s) => {
const ids = new Set(s.streamingIds)
ids.delete(streamId)
return {
messages: s.messages.map((m) => (m.id === streamId ? { ...m, content } : m)),
streamingIds: ids,
}
})
},
}))

View file

@ -6,6 +6,7 @@ import { Badge } from "@multica/ui/components/ui/badge";
import { Button } from "@multica/ui/components/ui/button";
import { ChatInput } from "@multica/ui/components/chat-input";
import { MemoizedMarkdown } from "@multica/ui/components/markdown";
import { StreamingMarkdown } from "@multica/ui/components/markdown/StreamingMarkdown";
import { HugeiconsIcon } from "@hugeicons/react";
import { UserIcon, Copy01Icon, CheckmarkCircle02Icon } from "@hugeicons/core-free-icons";
import { toast } from "@multica/ui/components/ui/sonner";
@ -27,6 +28,7 @@ export function Chat() {
const gwState = useGatewayStore((s) => s.connectionState)
const messages = useMessagesStore((s) => s.messages)
const streamingIds = useMessagesStore((s) => s.streamingIds)
const filtered = useMemo(() => messages.filter(m => m.agentId === activeAgentId), [messages, activeAgentId])
const handleSend = useCallback((text: string) => {
@ -99,25 +101,32 @@ export function Chat() {
</div>
) : (
<div className="px-4 py-6 space-y-6 max-w-4xl mx-auto">
{filtered.map((msg) => (
<div
key={msg.id}
className={cn(
"flex",
msg.role === "user" ? "justify-end" : "justify-start"
)}
>
{filtered.map((msg) => {
const isStreaming = streamingIds.has(msg.id)
return (
<div
key={msg.id}
className={cn(
msg.role === "user" ? "bg-muted rounded-md max-w-[60%] p-1 px-2.5" : "w-full p-1 px-2.5"
"flex",
msg.role === "user" ? "justify-end" : "justify-start"
)}
>
<MemoizedMarkdown mode="minimal" id={msg.id}>
{msg.content}
</MemoizedMarkdown>
<div
className={cn(
msg.role === "user" ? "bg-muted rounded-md max-w-[60%] p-1 px-2.5" : "w-full p-1 px-2.5"
)}
>
{isStreaming ? (
<StreamingMarkdown content={msg.content} isStreaming={true} mode="minimal" />
) : (
<MemoizedMarkdown mode="minimal" id={msg.id}>
{msg.content}
</MemoizedMarkdown>
)}
</div>
</div>
</div>
))}
)
})}
</div>
)}
</main>