Files
wehub-resource-sync e30e75b5d4
Changesets / Create Version PR (push) Has been cancelled
Deploy Shadcn Registry / Deploy Production (push) Has been cancelled
Template Metrics / LOC + Bundle Size (push) Has been cancelled
Code Quality / Oxlint + Oxfmt (push) Has been cancelled
Code Quality / Template Sync (push) Has been cancelled
Code Quality / Build Changed Packages (push) Has been cancelled
Code Quality / Test Changed Packages (push) Has been cancelled
Deploy Expo Example / Deploy Production (push) Has been cancelled
Deploy Ink Example / Deploy Production (push) Has been cancelled
Python Tests / pytest (assistant-stream, 3.10) (push) Has been cancelled
Python Tests / pytest (assistant-stream, 3.12) (push) Has been cancelled
Python Tests / pytest (assistant-ui-sync-server-api, 3.10) (push) Has been cancelled
Python Tests / pytest (assistant-ui-sync-server-api, 3.12) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:40:13 +08:00

205 lines
5.7 KiB
TypeScript

import type { ThreadMessageLike, AppendMessage } from "@assistant-ui/react";
import {
AssistantRuntimeProvider,
useExternalStoreRuntime,
} from "@assistant-ui/react";
import { createParser, type EventSourceMessage } from "eventsource-parser";
import { useState, useCallback } from "react";
// SSE event types from server
type SSEEvent =
| { type: "text"; content: string }
| { type: "tool_call"; id: string; name: string; arguments: string }
| { type: "tool_result"; id: string; result: string };
// Parse SSE stream using eventsource-parser
async function* parseSSEStream(
response: Response,
): AsyncGenerator<SSEEvent, void, unknown> {
const reader = response.body?.getReader();
if (!reader) throw new Error("No reader available");
const decoder = new TextDecoder();
const events: SSEEvent[] = [];
let done = false;
const parser = createParser({
onEvent: (event: EventSourceMessage) => {
if (event.data === "[DONE]") {
done = true;
return;
}
try {
events.push(JSON.parse(event.data) as SSEEvent);
} catch {
// Skip invalid JSON
}
},
});
while (!done) {
const result = await reader.read();
if (result.done) break;
parser.feed(decoder.decode(result.value, { stream: true }));
while (events.length > 0) {
yield events.shift()!;
}
}
// Yield any remaining events
while (events.length > 0) {
yield events.shift()!;
}
}
// Extract content part type from ThreadMessageLike
type ContentPart = Exclude<ThreadMessageLike["content"], string>[number];
// Helper to update assistant message content
const updateAssistantContent = (
messages: readonly ThreadMessageLike[],
updater: (content: ContentPart[]) => ContentPart[],
): ThreadMessageLike[] => {
const newMessages = [...messages];
const lastMsg = newMessages[newMessages.length - 1];
if (lastMsg?.role === "assistant") {
const content = Array.isArray(lastMsg.content)
? [...lastMsg.content]
: [{ type: "text" as const, text: lastMsg.content as string }];
newMessages[newMessages.length - 1] = {
...lastMsg,
content: updater(content),
};
} else {
newMessages.push({ role: "assistant", content: updater([]) });
}
return newMessages;
};
export function MyRuntimeProvider({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
const [messages, setMessages] = useState<readonly ThreadMessageLike[]>([]);
const [isRunning, setIsRunning] = useState(false);
const onNew = useCallback(
async (message: AppendMessage) => {
if (message.content[0]?.type !== "text")
throw new Error("Only text content is supported");
const userMessage: ThreadMessageLike = {
role: "user",
content: [{ type: "text", text: message.content[0].text }],
};
const newMessages = [...messages, userMessage];
setMessages(newMessages);
setIsRunning(true);
try {
const response = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
messages: newMessages.map((m) => ({
role: m.role,
content:
typeof m.content === "string"
? m.content
: ((m.content[0] as { text?: string })?.text ?? ""),
})),
}),
});
if (!response.ok) throw new Error("Failed to fetch response");
let textContent = "";
for await (const event of parseSSEStream(response)) {
switch (event.type) {
case "text":
textContent += event.content;
setMessages((prev) =>
updateAssistantContent(prev, (content) => {
const idx = content.findIndex((p) => p.type === "text");
const textPart = { type: "text" as const, text: textContent };
if (idx >= 0) {
content[idx] = textPart;
} else {
content.push(textPart);
}
return content;
}),
);
break;
case "tool_call":
setMessages((prev) =>
updateAssistantContent(prev, (content) => [
...content,
{
type: "tool-call" as const,
toolCallId: event.id,
toolName: event.name,
args: JSON.parse(event.arguments),
argsText: event.arguments,
},
]),
);
break;
case "tool_result":
setMessages((prev) =>
updateAssistantContent(prev, (content) =>
content.map((part) =>
part.type === "tool-call" && part.toolCallId === event.id
? { ...part, result: JSON.parse(event.result) }
: part,
),
),
);
break;
}
}
} catch (error) {
console.error("Error:", error);
setMessages((prev) => [
...prev,
{
role: "assistant",
content: [
{
type: "text",
text: "Sorry, an error occurred. Please try again.",
},
],
},
]);
} finally {
setIsRunning(false);
}
},
[messages],
);
const runtime = useExternalStoreRuntime<ThreadMessageLike>({
messages,
setMessages,
onNew,
convertMessage: (m) => m,
isRunning,
});
return (
<AssistantRuntimeProvider runtime={runtime}>
{children}
</AssistantRuntimeProvider>
);
}