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:
parent
ef4e57ffdd
commit
583242baba
3 changed files with 124 additions and 35 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
),
|
||||
}))
|
||||
},
|
||||
}))
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue