refactor(sdk): unify message types with pi-ai source of truth

Replace hand-written message/content types in @multica/sdk with
`import type` from @mariozechner/pi-ai and @mariozechner/pi-agent-core.
This ensures compile-time correctness and eliminates type drift between
backend and frontend (e.g. "tool_use" vs "toolCall", "tool_result" vs
"toolResult").

- stream.ts: re-export TextContent, ThinkingContent, ToolCall,
  ImageContent from pi-ai; use AgentEvent from pi-agent-core
- rpc.ts: AgentMessageItem = pi-ai Message (no more manual mirroring)
- connection-store.ts: use SDK types instead of inline hand-written ones
- ContentBlock now includes ImageContent to match backend reality

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Naiyuan Qing 2026-02-04 17:13:01 +08:00
parent caee37c631
commit ef4e57ffdd
6 changed files with 224 additions and 99 deletions

View file

@ -17,6 +17,8 @@
"uuid": "^13.0.0"
},
"devDependencies": {
"@mariozechner/pi-agent-core": "^0.50.3",
"@mariozechner/pi-ai": "^0.50.3",
"@types/uuid": "^11.0.0",
"typescript": "^5.9.3"
}

View file

@ -33,8 +33,15 @@ export {
export {
StreamAction,
type StreamPayload,
type StreamEvent,
type StreamMessageEvent,
type StreamToolEvent,
type AgentEvent,
type ContentBlock,
type TextContent,
type ThinkingContent,
type ToolCall,
type ImageContent,
// Backward-compatible aliases
type TextBlock,
type ThinkingBlock,
type ToolCallBlock,
extractTextFromEvent,
} from "./stream";

View file

@ -1,5 +1,7 @@
/** RPC Actions - 请求/响应模式 */
import type { Message } from "@mariozechner/pi-ai";
export const RequestAction = "request" as const;
export const ResponseAction = "response" as const;
@ -65,34 +67,11 @@ export interface GetAgentMessagesParams {
limit?: number;
}
/** Content block types from the agent engine */
export interface TextContentBlock {
type: "text";
text: string;
}
export interface ThinkingContentBlock {
type: "thinking";
thinking: string;
}
export interface ToolCallBlock {
type: "tool_use";
id: string;
name: string;
input: unknown;
}
export interface ImageContentBlock {
type: "image";
url: string;
}
/** Agent message returned by getAgentMessages (mirrors pi-ai Message) */
export type AgentMessageItem =
| { role: "user"; content: string | (TextContentBlock | ImageContentBlock)[]; timestamp: number }
| { role: "assistant"; content: (TextContentBlock | ThinkingContentBlock | ToolCallBlock)[]; timestamp: number }
| { role: "tool_result"; toolCallId: string; content: (TextContentBlock | ImageContentBlock)[]; isError: boolean; timestamp: number }
/**
* Agent message returned by getAgentMessages.
* This is pi-ai's Message type the backend returns it as-is from SessionManager.loadMessages().
*/
export type AgentMessageItem = Message;
/** getAgentMessages - response payload */
export interface GetAgentMessagesResult {

View file

@ -1,49 +1,47 @@
/** Stream Action - 流式消息传输 */
/** Stream Action */
export const StreamAction = "stream" as const;
// --- Content block types (re-exported from pi-ai, the single source of truth) ---
import type {
TextContent,
ThinkingContent,
ToolCall,
ImageContent,
} from "@mariozechner/pi-ai";
import type { AgentEvent } from "@mariozechner/pi-agent-core";
export type { TextContent, ThinkingContent, ToolCall, ImageContent };
export type { AgentEvent };
/** Backward-compatible aliases */
export type TextBlock = TextContent;
export type ThinkingBlock = ThinkingContent;
export type ToolCallBlock = ToolCall;
export type ContentBlock = TextContent | ThinkingContent | ToolCall | ImageContent;
// --- Stream event types ---
/**
* AgentEvent types forwarded by the Hub to frontend clients.
* These mirror the subset of AgentEvent from @mariozechner/pi-agent-core
* that the Hub forwards (filtered at the Hub layer).
* Hub forwards AgentEvent from pi-agent-core as-is.
* StreamPayload wraps it with routing metadata.
*/
export interface StreamMessageEvent {
type: "message_start" | "message_update" | "message_end";
message: {
id?: string;
role: string;
content?: Array<{ type: string; text?: string }>;
};
assistantMessageEvent?: unknown;
}
export interface StreamToolEvent {
type: "tool_execution_start" | "tool_execution_end";
toolCallId: string;
toolName: string;
args?: unknown;
result?: unknown;
isError?: boolean;
}
export type StreamEvent = StreamMessageEvent | StreamToolEvent;
/** 流消息 payload — wraps a raw AgentEvent with stream/agent identifiers */
export interface StreamPayload {
/** 流 ID关联同一个流的所有消息 */
streamId: string;
/** 所属 agent ID */
agentId: string;
/** Raw agent event from the engine */
event: StreamEvent;
event: AgentEvent;
}
/** Extract plain text from an AgentMessage content array */
export function extractTextFromEvent(event: StreamMessageEvent): string {
const content = event.message?.content;
/** Extract plain text from an AgentEvent that carries a message */
export function extractTextFromEvent(event: AgentEvent): string {
if (!("message" in event)) return "";
const msg = event.message;
if (!msg || !("content" in msg)) return "";
const content = msg.content;
if (!Array.isArray(content)) return "";
return content
.filter((c) => c.type === "text")
.filter((c): c is TextContent => c.type === "text")
.map((c) => c.text ?? "")
.join("");
}

View file

@ -19,13 +19,14 @@ import { v7 as uuidv7 } from "uuid"
import {
GatewayClient,
StreamAction,
extractTextFromEvent,
type ConnectionState,
type SendErrorResponse,
type StreamPayload,
type StreamMessageEvent,
type AgentEvent,
type GetAgentMessagesResult,
type ContentBlock,
} from "@multica/sdk"
import { useMessagesStore } from "./messages"
import { useMessagesStore, type Message } from "./messages"
import { clearConnection, type ConnectionInfo } from "./connection"
interface ConnectionStoreState {
@ -104,23 +105,40 @@ function createClient(
switch (event.type) {
case "message_start": {
store.startStream(payload.streamId, payload.agentId)
const text = extractTextFromEvent(event as StreamMessageEvent)
if (text) store.appendStream(payload.streamId, text)
const content = extractContent(event)
if (content.length) store.appendStream(payload.streamId, content)
break
}
case "message_update": {
const text = extractTextFromEvent(event as StreamMessageEvent)
store.appendStream(payload.streamId, text)
const content = extractContent(event)
store.appendStream(payload.streamId, content)
break
}
case "message_end": {
const text = extractTextFromEvent(event as StreamMessageEvent)
store.endStream(payload.streamId, text)
const content = extractContent(event)
const stopReason = "message" in event
? (event.message as { stopReason?: string })?.stopReason
: undefined
store.endStream(payload.streamId, content, stopReason)
break
}
case "tool_execution_start":
case "tool_execution_end":
case "tool_execution_start": {
store.startToolExecution(
payload.agentId,
event.toolCallId,
event.toolName,
event.args,
)
break
}
case "tool_execution_end": {
store.endToolExecution(
event.toolCallId,
event.result,
event.isError,
)
break
}
}
return
}
@ -140,20 +158,41 @@ async function fetchHistory(state: ConnectionStoreState): Promise<void> {
if (!client || !hubId || !agentId) return
try {
const result = await client.request<{
messages: Array<{ role: string; content: unknown }>
total: number
}>(hubId, "getAgentMessages", { agentId, limit: 200 })
const result = await client.request<GetAgentMessagesResult>(
hubId, "getAgentMessages", { agentId, limit: 200 },
)
const messages = result.messages
.filter((m) => m.role === "user" || m.role === "assistant")
.map((m) => ({
id: uuidv7(),
role: m.role as "user" | "assistant",
content: extractText(m.content),
agentId: agentId,
}))
.filter((m) => m.content.length > 0)
// Mirror the backend message array directly
const messages: Message[] = []
for (const m of result.messages) {
if (m.role === "user") {
messages.push({
id: uuidv7(),
role: "user",
content: toContentBlocks(m.content),
agentId,
})
} else if (m.role === "assistant") {
messages.push({
id: uuidv7(),
role: "assistant",
content: toContentBlocks(m.content),
agentId,
stopReason: m.stopReason,
})
} else if (m.role === "toolResult") {
messages.push({
id: uuidv7(),
role: "toolResult",
content: toContentBlocks(m.content),
agentId,
toolCallId: m.toolCallId,
toolName: m.toolName,
toolStatus: m.isError ? "error" : "success",
isError: m.isError,
})
}
}
if (messages.length > 0) {
useMessagesStore.getState().loadMessages(messages)
@ -163,14 +202,22 @@ async function fetchHistory(state: ConnectionStoreState): Promise<void> {
}
}
/** Extract plain text from AgentMessage content (string or content block array) */
function extractText(content: unknown): string {
if (typeof content === "string") return content
if (!Array.isArray(content)) return ""
return content
.filter((c: { type?: string }) => c.type === "text")
.map((c: { text?: string }) => c.text ?? "")
.join("")
/** Convert raw backend content (string or block array) to ContentBlock[] */
function toContentBlocks(content: string | ContentBlock[]): ContentBlock[] {
if (typeof content === "string") {
return content ? [{ type: "text", text: content }] : []
}
if (Array.isArray(content)) return content
return []
}
/** Extract content blocks from an AgentEvent that carries a message */
function extractContent(event: AgentEvent): ContentBlock[] {
if (!("message" in event)) return []
const msg = event.message
if (!msg || !("content" in msg)) return []
const content = msg.content
return Array.isArray(content) ? content as ContentBlock[] : []
}
export const useConnectionStore = create<ConnectionStore>()(