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:
parent
d04bed8175
commit
86d00bb134
3 changed files with 83 additions and 15 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
})
|
||||
},
|
||||
}))
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue