feat(store,ui): add tool execution lifecycle and ContentBlock rendering

- Message.content is now ContentBlock[] (was string), supporting
  text, thinking, toolCall, and image blocks
- Add toolResult role with toolCallId, toolName, toolStatus, isError
- Add startToolExecution/endToolExecution to MessagesStore
- MessageList renders toolResult messages via ToolCallItem
- Extract text from ContentBlock[] for markdown rendering

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Naiyuan Qing 2026-02-04 17:30:37 +08:00
parent ef4e57ffdd
commit 583242baba
3 changed files with 124 additions and 35 deletions

View file

@ -2,6 +2,6 @@ export { useConnectionStore } from "./connection-store"
export type { ConnectionStore } from "./connection-store"
export { useAutoConnect } from "./use-auto-connect"
export { useMessagesStore } from "./messages"
export type { Message, MessagesStore, SendContext } from "./messages"
export type { Message, MessagesStore, SendContext, ToolStatus } from "./messages"
export { parseConnectionCode, saveConnection, loadConnection, clearConnection } from "./connection"
export type { ConnectionInfo } from "./connection"

View file

@ -1,28 +1,33 @@
/**
* Messages Store - manages chat messages and streaming state for the current Agent
* Messages Store - manages chat messages and streaming state
*
* Responsibilities:
* 1. Store current Agent's chat messages (replaced on Agent switch, not accumulated)
* 2. Manage streaming state (intermediate state while AI replies arrive in chunks)
* 3. Provide sendMessage() as the single entry point for sending messages
* Data model mirrors the backend (pi-ai / pi-agent-core) exactly:
* - UserMessage: { role: "user", content: ContentBlock[] }
* - AssistantMessage: { role: "assistant", content: ContentBlock[] }
* - ToolResultMessage: { role: "toolResult", toolCallId, toolName, content, isError }
*
* Send flow:
* user input sendMessage(text)
* addUserMessage() immediately adds to local state (optimistic update)
* ConnectionStore.send() sends to Gateway Hub Agent
*
* Receive flow (driven by ConnectionStore's onMessage callback):
* Streaming: startStream appendStream (repeated) endStream
* Non-streaming: addAssistantMessage (one-shot)
* Streaming simply updates the content of the current assistant message in-place.
* Tool execution events (start/end) create / update toolResult messages.
*/
import { create } from "zustand"
import { v7 as uuidv7 } from "uuid"
import type { ContentBlock } from "@multica/sdk"
export type ToolStatus = "running" | "success" | "error" | "interrupted"
export interface Message {
id: string
role: "user" | "assistant"
content: string
role: "user" | "assistant" | "toolResult"
content: ContentBlock[]
agentId: string
// AssistantMessage metadata
stopReason?: string
// ToolResult fields (only when role === "toolResult")
toolCallId?: string
toolName?: string
toolArgs?: Record<string, unknown>
toolStatus?: ToolStatus
isError?: boolean
}
/** Parameters needed to route a message through the gateway */
@ -41,13 +46,16 @@ interface MessagesActions {
sendMessage: (text: string, ctx: SendContext) => void
addUserMessage: (content: string, agentId: string) => void
addAssistantMessage: (content: string, agentId: string) => void
updateMessage: (id: string, content: string) => void
// Replace all messages (for Agent switch or loading history)
updateMessage: (id: string, content: ContentBlock[]) => void
loadMessages: (msgs: Message[]) => void
clearMessages: () => void
// Streaming
startStream: (streamId: string, agentId: string) => void
appendStream: (streamId: string, content: string) => void
endStream: (streamId: string, content: string) => void
appendStream: (streamId: string, content: ContentBlock[]) => void
endStream: (streamId: string, content: ContentBlock[], stopReason?: string) => void
// Tool execution lifecycle
startToolExecution: (agentId: string, toolCallId: string, toolName: string, args?: unknown) => void
endToolExecution: (toolCallId: string, result?: unknown, isError?: boolean) => void
}
export type MessagesStore = MessagesState & MessagesActions
@ -56,7 +64,6 @@ export const useMessagesStore = create<MessagesStore>()((set, get) => ({
messages: [],
streamingIds: new Set<string>(),
// Single entry point for sending: optimistic local add, then send via WebSocket
sendMessage: (text, ctx) => {
get().addUserMessage(text, ctx.agentId)
ctx.send(ctx.hubId, "message", { agentId: ctx.agentId, content: text })
@ -64,13 +71,23 @@ export const useMessagesStore = create<MessagesStore>()((set, get) => ({
addUserMessage: (content, agentId) => {
set((s) => ({
messages: [...s.messages, { id: uuidv7(), role: "user", content, agentId }],
messages: [...s.messages, {
id: uuidv7(),
role: "user",
content: [{ type: "text" as const, text: content }],
agentId,
}],
}))
},
addAssistantMessage: (content, agentId) => {
set((s) => ({
messages: [...s.messages, { id: uuidv7(), role: "assistant", content, agentId }],
messages: [...s.messages, {
id: uuidv7(),
role: "assistant",
content: [{ type: "text" as const, text: content }],
agentId,
}],
}))
},
@ -80,7 +97,6 @@ export const useMessagesStore = create<MessagesStore>()((set, get) => ({
}))
},
// Replace all messages (for Agent switch or loading history)
loadMessages: (msgs) => {
set({ messages: msgs })
},
@ -89,35 +105,76 @@ export const useMessagesStore = create<MessagesStore>()((set, get) => ({
set({ messages: [], streamingIds: new Set() })
},
// === The following three methods are called by ConnectionStore's onMessage callback ===
// Stream start: create an empty placeholder message and mark as streaming
// --- Streaming: build assistant message incrementally ---
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 }],
messages: [...s.messages, { id: streamId, role: "assistant" as const, content: [], agentId }],
streamingIds: ids,
}
})
},
// Stream update: replace message content (each update carries the full accumulated text)
// Replace the entire content array with the latest partial snapshot
appendStream: (streamId, content) => {
set((s) => ({
messages: s.messages.map((m) => (m.id === streamId ? { ...m, content } : m)),
}))
},
// Stream end: write final content, remove streaming marker
endStream: (streamId, content) => {
endStream: (streamId, content, stopReason) => {
set((s) => {
const ids = new Set(s.streamingIds)
ids.delete(streamId)
return {
messages: s.messages.map((m) => (m.id === streamId ? { ...m, content } : m)),
messages: s.messages.map((m) => {
if (m.id === streamId) return { ...m, content, stopReason }
// Interrupt any still-running tool executions
if (m.role === "toolResult" && m.toolStatus === "running") {
return { ...m, toolStatus: "interrupted" as ToolStatus }
}
return m
}),
streamingIds: ids,
}
})
},
// --- Tool execution: create / update toolResult messages ---
startToolExecution: (agentId, toolCallId, toolName, args) => {
set((s) => ({
messages: [...s.messages, {
id: uuidv7(),
role: "toolResult" as const,
content: [],
agentId,
toolCallId,
toolName,
toolArgs: args as Record<string, unknown> | undefined,
toolStatus: "running" as ToolStatus,
isError: false,
}],
}))
},
endToolExecution: (toolCallId, result, isError) => {
set((s) => ({
messages: s.messages.map((m) =>
m.role === "toolResult" && m.toolCallId === toolCallId
? {
...m,
toolStatus: (isError ? "error" : "success") as ToolStatus,
isError: isError ?? false,
content: result != null
? [{ type: "text" as const, text: typeof result === "string" ? result : JSON.stringify(result) }]
: [],
}
: m
),
}))
},
}))

View file

@ -2,8 +2,23 @@
import { MemoizedMarkdown } from "@multica/ui/components/markdown";
import { StreamingMarkdown } from "@multica/ui/components/markdown/StreamingMarkdown";
import { ToolCallItem } from "@multica/ui/components/tool-call-item";
import { cn } from "@multica/ui/lib/utils";
import type { Message } from "@multica/store";
import type { ContentBlock } from "@multica/sdk";
/** Extract plain text from ContentBlock[] */
function getTextContent(blocks: ContentBlock[]): string {
return blocks
.filter((b): b is { type: "text"; text: string } => b.type === "text")
.map((b) => b.text)
.join("")
}
/** Check if content has any toolCall blocks */
function getToolCalls(blocks: ContentBlock[]) {
return blocks.filter((b): b is Extract<ContentBlock, { type: "toolCall" }> => b.type === "toolCall")
}
interface MessageListProps {
messages: Message[]
@ -12,9 +27,26 @@ interface MessageListProps {
export function MessageList({ messages, streamingIds }: MessageListProps) {
return (
<div className="relative px-4 py-6 space-y-6 max-w-4xl mx-auto">
<div className="relative px-4 py-6 max-w-4xl mx-auto">
{messages.map((msg) => {
// ToolResult messages → render as tool execution item
if (msg.role === "toolResult") {
return <ToolCallItem key={msg.id} message={msg} />
}
const text = getTextContent(msg.content)
const toolCalls = msg.role === "assistant" ? getToolCalls(msg.content) : []
const isStreaming = streamingIds.has(msg.id)
// Skip empty assistant messages that only contain toolCalls (no text)
// The toolCalls are visible via the subsequent toolResult entries
if (msg.role === "assistant" && !text && toolCalls.length > 0 && !isStreaming) {
return null
}
// Skip completely empty messages
if (!text && !isStreaming) return null
return (
<div
key={msg.id}
@ -25,14 +57,14 @@ export function MessageList({ messages, streamingIds }: MessageListProps) {
>
<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"
msg.role === "user" ? "bg-muted rounded-md max-w-[60%] py-1 px-2.5 my-2" : "w-full py-1 px-2.5 my-1"
)}
>
{isStreaming ? (
<StreamingMarkdown content={msg.content} isStreaming={true} mode="minimal" />
<StreamingMarkdown content={text} isStreaming={true} mode="minimal" />
) : (
<MemoizedMarkdown mode="minimal" id={msg.id}>
{msg.content}
{text}
</MemoizedMarkdown>
)}
</div>