From 86d00bb1342a334ffd05a56718a33355e81f0b18 Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Mon, 2 Feb 2026 17:18:35 +0800 Subject: [PATCH] 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 --- packages/store/src/gateway.ts | 28 +++++++++++++++++++++- packages/store/src/messages.ts | 33 +++++++++++++++++++++++++ packages/ui/src/components/chat.tsx | 37 ++++++++++++++++++----------- 3 files changed, 83 insertions(+), 15 deletions(-) diff --git a/packages/store/src/gateway.ts b/packages/store/src/gateway.ts index fb70f6c0..5682b70a 100644 --- a/packages/store/src/gateway.ts +++ b/packages/store/src/gateway.ts @@ -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()((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) diff --git a/packages/store/src/messages.ts b/packages/store/src/messages.ts index f2df5f2e..a25625d9 100644 --- a/packages/store/src/messages.ts +++ b/packages/store/src/messages.ts @@ -10,6 +10,7 @@ export interface Message { interface MessagesState { messages: Message[] + streamingIds: Set } 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()((set, get) => ({ messages: [], + streamingIds: new Set(), addUserMessage: (content, agentId) => { set((s) => ({ @@ -54,4 +59,32 @@ export const useMessagesStore = create()((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, + } + }) + }, })) diff --git a/packages/ui/src/components/chat.tsx b/packages/ui/src/components/chat.tsx index 53476365..4363c7c0 100644 --- a/packages/ui/src/components/chat.tsx +++ b/packages/ui/src/components/chat.tsx @@ -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() { ) : (
- {filtered.map((msg) => ( -
+ {filtered.map((msg) => { + const isStreaming = streamingIds.has(msg.id) + return (
- - {msg.content} - +
+ {isStreaming ? ( + + ) : ( + + {msg.content} + + )} +
-
- ))} + ) + })}
)}