@tanstack/ai-preact 0.9.14 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/esm/index.d.ts +5 -0
- package/dist/esm/index.js +16 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/mcp-apps.d.ts +25 -0
- package/dist/esm/mcp-apps.js +52 -0
- package/dist/esm/mcp-apps.js.map +1 -0
- package/dist/esm/types.d.ts +106 -0
- package/dist/esm/use-chat.d.ts +4 -0
- package/dist/esm/use-chat.js +224 -0
- package/dist/esm/use-chat.js.map +1 -0
- package/dist/esm/use-mcp-app-bridge.d.ts +25 -0
- package/dist/esm/use-mcp-app-bridge.js +26 -0
- package/dist/esm/use-mcp-app-bridge.js.map +1 -0
- package/package.json +16 -4
- package/src/index.ts +5 -0
- package/src/mcp-apps.tsx +108 -0
- package/src/use-mcp-app-bridge.ts +60 -0
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { useChat } from './use-chat.js';
|
|
2
|
+
export { useMcpAppBridge } from './use-mcp-app-bridge.js';
|
|
3
|
+
export type { UseMcpAppBridgeOptions } from './use-mcp-app-bridge.js';
|
|
4
|
+
export type { UseChatOptions, UseChatReturn, UIMessage, ChatRequestBody, } from './types.js';
|
|
5
|
+
export { fetchServerSentEvents, fetchHttpStream, xhrServerSentEvents, xhrHttpStream, stream, rpcStream, createChatClientOptions, createMcpAppBridge, type McpAppBridge, type CreateMcpAppBridgeOptions, type ConnectionAdapter, type ConnectConnectionAdapter, type SubscribeConnectionAdapter, type RunAgentInputContext, type FetchConnectionOptions, type XhrConnectionOptions, type InferChatMessages, } from '@tanstack/ai-client';
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { useChat } from "./use-chat.js";
|
|
2
|
+
import { useMcpAppBridge } from "./use-mcp-app-bridge.js";
|
|
3
|
+
import { createChatClientOptions, createMcpAppBridge, fetchHttpStream, fetchServerSentEvents, rpcStream, stream, xhrHttpStream, xhrServerSentEvents } from "@tanstack/ai-client";
|
|
4
|
+
export {
|
|
5
|
+
createChatClientOptions,
|
|
6
|
+
createMcpAppBridge,
|
|
7
|
+
fetchHttpStream,
|
|
8
|
+
fetchServerSentEvents,
|
|
9
|
+
rpcStream,
|
|
10
|
+
stream,
|
|
11
|
+
useChat,
|
|
12
|
+
useMcpAppBridge,
|
|
13
|
+
xhrHttpStream,
|
|
14
|
+
xhrServerSentEvents
|
|
15
|
+
};
|
|
16
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sources":[],"sourcesContent":[],"names":[],"mappings":";;;"}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { UIResourcePart } from '@tanstack/ai';
|
|
2
|
+
import { McpAppBridge } from '@tanstack/ai-client';
|
|
3
|
+
export interface MCPAppResourceProps {
|
|
4
|
+
/** The ui-resource part from a UIMessage assistant part. */
|
|
5
|
+
part: UIResourcePart;
|
|
6
|
+
/**
|
|
7
|
+
* Framework-agnostic bridge for tool calls, prompt sending, and link opening.
|
|
8
|
+
* Omit it to render the widget in display-only mode — iframe interactions
|
|
9
|
+
* that would trigger tool calls or prompts are ignored.
|
|
10
|
+
*/
|
|
11
|
+
bridge?: McpAppBridge;
|
|
12
|
+
/** Sandbox iframe configuration — must include the proxy page URL. */
|
|
13
|
+
sandbox: {
|
|
14
|
+
url: URL;
|
|
15
|
+
};
|
|
16
|
+
/** Optional structured arguments forwarded to the guest UI once it's ready. */
|
|
17
|
+
toolInput?: Record<string, unknown>;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Renders an MCP App UI resource inside a sandboxed iframe.
|
|
21
|
+
*
|
|
22
|
+
* Wraps `@mcp-ui/client`'s `AppRenderer` and wires its callbacks to a
|
|
23
|
+
* framework-agnostic {@link McpAppBridge}.
|
|
24
|
+
*/
|
|
25
|
+
export declare function MCPAppResource(props: MCPAppResourceProps): import("preact").JSX.Element;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { jsx } from "preact/jsx-runtime";
|
|
2
|
+
import { AppRenderer } from "@mcp-ui/client";
|
|
3
|
+
function isRecord(value) {
|
|
4
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
5
|
+
}
|
|
6
|
+
function resultToText(result) {
|
|
7
|
+
if (typeof result === "string") return result;
|
|
8
|
+
const stringify = JSON.stringify;
|
|
9
|
+
return stringify(result) ?? "null";
|
|
10
|
+
}
|
|
11
|
+
function MCPAppResource(props) {
|
|
12
|
+
const { part, bridge, sandbox, toolInput } = props;
|
|
13
|
+
const onCallTool = bridge ? async (params) => {
|
|
14
|
+
const result = await bridge.callTool({
|
|
15
|
+
serverId: part.serverId,
|
|
16
|
+
toolName: params.name,
|
|
17
|
+
args: params.arguments
|
|
18
|
+
});
|
|
19
|
+
return {
|
|
20
|
+
content: [
|
|
21
|
+
{
|
|
22
|
+
type: "text",
|
|
23
|
+
text: resultToText(result)
|
|
24
|
+
}
|
|
25
|
+
],
|
|
26
|
+
structuredContent: isRecord(result) ? result : void 0
|
|
27
|
+
};
|
|
28
|
+
} : void 0;
|
|
29
|
+
const onMessage = bridge ? async (params) => {
|
|
30
|
+
const text = params.content.filter((c) => c.type === "text").map((c) => c.text).join("");
|
|
31
|
+
if (text) await bridge.sendPrompt(text);
|
|
32
|
+
return {};
|
|
33
|
+
} : void 0;
|
|
34
|
+
const onOpenLink = bridge ? (params) => Promise.resolve(bridge.openLink(params.url)) : void 0;
|
|
35
|
+
return /* @__PURE__ */ jsx(
|
|
36
|
+
AppRenderer,
|
|
37
|
+
{
|
|
38
|
+
toolName: part.toolName,
|
|
39
|
+
sandbox,
|
|
40
|
+
html: part.resource.text,
|
|
41
|
+
toolResourceUri: part.resource.uri,
|
|
42
|
+
toolInput,
|
|
43
|
+
onCallTool,
|
|
44
|
+
onMessage,
|
|
45
|
+
onOpenLink
|
|
46
|
+
}
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
export {
|
|
50
|
+
MCPAppResource
|
|
51
|
+
};
|
|
52
|
+
//# sourceMappingURL=mcp-apps.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"mcp-apps.js","sources":["../../src/mcp-apps.tsx"],"sourcesContent":["/**\n * MCPAppResource — Preact wrapper for rendering MCP App UI resources.\n *\n * PREACT/COMPAT NOTE:\n * `@mcp-ui/client@7.1.1` is published React-only: `AppRenderer` is a React\n * `forwardRef` component. There is NO dedicated preact entry point and NO web\n * component variant. This wrapper imports `{ AppRenderer }` from\n * `'@mcp-ui/client'` identically to the React wrapper and relies on the\n * *consumer's* `preact/compat` alias (resolving `react` / `react-dom` to\n * `preact/compat`) to render the React component under Preact.\n * Wiring up that alias is the consumer's responsibility and is NOT\n * runtime-verified in this repository.\n */\nimport { AppRenderer } from '@mcp-ui/client'\nimport type { AppRendererProps } from '@mcp-ui/client'\nimport type { UIResourcePart } from '@tanstack/ai'\nimport type { McpAppBridge } from '@tanstack/ai-client'\n\nexport interface MCPAppResourceProps {\n /** The ui-resource part from a UIMessage assistant part. */\n part: UIResourcePart\n /**\n * Framework-agnostic bridge for tool calls, prompt sending, and link opening.\n * Omit it to render the widget in display-only mode — iframe interactions\n * that would trigger tool calls or prompts are ignored.\n */\n bridge?: McpAppBridge\n /** Sandbox iframe configuration — must include the proxy page URL. */\n sandbox: { url: URL }\n /** Optional structured arguments forwarded to the guest UI once it's ready. */\n toolInput?: Record<string, unknown>\n}\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n return typeof value === 'object' && value !== null && !Array.isArray(value)\n}\n\n/**\n * Coalesce an arbitrary bridge result into the `string` required by the\n * `CallToolResult` text content block. `JSON.stringify` is typed to return\n * `string` but actually returns `undefined` for inputs like `undefined` or a\n * function, so we narrow explicitly to avoid `text: undefined`.\n */\nfunction resultToText(result: unknown): string {\n if (typeof result === 'string') return result\n // `JSON.stringify`'s lib signature claims `string`, but it returns\n // `undefined` for `undefined`/function inputs; type the call honestly.\n const stringify: (value: unknown) => string | undefined = JSON.stringify\n return stringify(result) ?? 'null'\n}\n\n/**\n * Renders an MCP App UI resource inside a sandboxed iframe.\n *\n * Wraps `@mcp-ui/client`'s `AppRenderer` and wires its callbacks to a\n * framework-agnostic {@link McpAppBridge}.\n */\nexport function MCPAppResource(props: MCPAppResourceProps) {\n const { part, bridge, sandbox, toolInput } = props\n\n const onCallTool: AppRendererProps['onCallTool'] = bridge\n ? async (params) => {\n const result = await bridge.callTool({\n serverId: part.serverId,\n toolName: params.name,\n args: params.arguments,\n })\n\n return {\n content: [\n {\n type: 'text' as const,\n text: resultToText(result),\n },\n ],\n structuredContent: isRecord(result) ? result : undefined,\n }\n }\n : undefined\n\n const onMessage: AppRendererProps['onMessage'] = bridge\n ? async (params) => {\n const text = params.content\n .filter((c): c is { type: 'text'; text: string } => c.type === 'text')\n .map((c) => c.text)\n .join('')\n if (text) await bridge.sendPrompt(text)\n return {}\n }\n : undefined\n\n const onOpenLink: AppRendererProps['onOpenLink'] = bridge\n ? (params) => Promise.resolve(bridge.openLink(params.url))\n : undefined\n\n return (\n <AppRenderer\n toolName={part.toolName}\n sandbox={sandbox}\n html={part.resource.text}\n toolResourceUri={part.resource.uri}\n toolInput={toolInput}\n onCallTool={onCallTool}\n onMessage={onMessage}\n onOpenLink={onOpenLink}\n />\n )\n}\n"],"names":[],"mappings":";;AAiCA,SAAS,SAAS,OAAkD;AAClE,SAAO,OAAO,UAAU,YAAY,UAAU,QAAQ,CAAC,MAAM,QAAQ,KAAK;AAC5E;AAQA,SAAS,aAAa,QAAyB;AAC7C,MAAI,OAAO,WAAW,SAAU,QAAO;AAGvC,QAAM,YAAoD,KAAK;AAC/D,SAAO,UAAU,MAAM,KAAK;AAC9B;AAQO,SAAS,eAAe,OAA4B;AACzD,QAAM,EAAE,MAAM,QAAQ,SAAS,cAAc;AAE7C,QAAM,aAA6C,SAC/C,OAAO,WAAW;AAChB,UAAM,SAAS,MAAM,OAAO,SAAS;AAAA,MACnC,UAAU,KAAK;AAAA,MACf,UAAU,OAAO;AAAA,MACjB,MAAM,OAAO;AAAA,IAAA,CACd;AAED,WAAO;AAAA,MACL,SAAS;AAAA,QACP;AAAA,UACE,MAAM;AAAA,UACN,MAAM,aAAa,MAAM;AAAA,QAAA;AAAA,MAC3B;AAAA,MAEF,mBAAmB,SAAS,MAAM,IAAI,SAAS;AAAA,IAAA;AAAA,EAEnD,IACA;AAEJ,QAAM,YAA2C,SAC7C,OAAO,WAAW;AAChB,UAAM,OAAO,OAAO,QACjB,OAAO,CAAC,MAA2C,EAAE,SAAS,MAAM,EACpE,IAAI,CAAC,MAAM,EAAE,IAAI,EACjB,KAAK,EAAE;AACV,QAAI,KAAM,OAAM,OAAO,WAAW,IAAI;AACtC,WAAO,CAAA;AAAA,EACT,IACA;AAEJ,QAAM,aAA6C,SAC/C,CAAC,WAAW,QAAQ,QAAQ,OAAO,SAAS,OAAO,GAAG,CAAC,IACvD;AAEJ,SACE;AAAA,IAAC;AAAA,IAAA;AAAA,MACC,UAAU,KAAK;AAAA,MACf;AAAA,MACA,MAAM,KAAK,SAAS;AAAA,MACpB,iBAAiB,KAAK,SAAS;AAAA,MAC/B;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,IAAA;AAAA,EAAA;AAGN;"}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { AnyClientTool, ModelMessage, SchemaInput } from '@tanstack/ai';
|
|
2
|
+
import { AIDevtoolsDisplayOptions, ChatClientOptions, ChatClientState, ChatRequestBody, ClientContextOptionFromTools, ConnectionStatus, DistributedOmit, InferredClientContext, MultimodalContent, UIMessage } from '@tanstack/ai-client';
|
|
3
|
+
export type { ChatRequestBody, MultimodalContent, UIMessage };
|
|
4
|
+
/**
|
|
5
|
+
* Options for the useChat hook.
|
|
6
|
+
*
|
|
7
|
+
* This extends ChatClientOptions but omits the state change callbacks that are
|
|
8
|
+
* managed internally by Preact state:
|
|
9
|
+
* - `onMessagesChange` - Managed by Preact state (exposed as `messages`)
|
|
10
|
+
* - `onLoadingChange` - Managed by Preact state (exposed as `isLoading`)
|
|
11
|
+
* - `onErrorChange` - Managed by Preact state (exposed as `error`)
|
|
12
|
+
* - `onStatusChange` - Managed by Preact state (exposed as `status`)
|
|
13
|
+
*
|
|
14
|
+
* All other callbacks (onResponse, onChunk, onFinish, onError) are
|
|
15
|
+
* passed through to the underlying ChatClient and can be used for side effects.
|
|
16
|
+
*
|
|
17
|
+
* Note: Connection and body changes will recreate the ChatClient instance.
|
|
18
|
+
* To update these options, remount the component or use a key prop.
|
|
19
|
+
*/
|
|
20
|
+
export type UseChatOptions<TTools extends ReadonlyArray<AnyClientTool> = any, TContext = InferredClientContext<TTools>> = DistributedOmit<ChatClientOptions<TTools, TContext>, 'onMessagesChange' | 'onLoadingChange' | 'onErrorChange' | 'onStatusChange' | 'onSubscriptionChange' | 'onConnectionStatusChange' | 'onSessionGeneratingChange' | 'context' | 'devtools'> & {
|
|
21
|
+
live?: boolean;
|
|
22
|
+
/** Display options for TanStack AI Devtools. */
|
|
23
|
+
devtools?: AIDevtoolsDisplayOptions;
|
|
24
|
+
/**
|
|
25
|
+
* Standard-schema-compatible schema used to identify structured-output chat
|
|
26
|
+
* hooks in devtools. Preact currently exposes structured-output parts via
|
|
27
|
+
* `messages`; typed `partial` / `final` sugar is implemented in the other
|
|
28
|
+
* framework adapters.
|
|
29
|
+
*/
|
|
30
|
+
outputSchema?: SchemaInput;
|
|
31
|
+
} & ClientContextOptionFromTools<TTools, TContext>;
|
|
32
|
+
export interface UseChatReturn<TTools extends ReadonlyArray<AnyClientTool> = any> {
|
|
33
|
+
/**
|
|
34
|
+
* Current messages in the conversation
|
|
35
|
+
*/
|
|
36
|
+
messages: Array<UIMessage<TTools>>;
|
|
37
|
+
/**
|
|
38
|
+
* Send a message and get a response.
|
|
39
|
+
* Can be a simple string or multimodal content with images, audio, etc.
|
|
40
|
+
*/
|
|
41
|
+
sendMessage: (content: string | MultimodalContent) => Promise<void>;
|
|
42
|
+
/**
|
|
43
|
+
* Append a message to the conversation
|
|
44
|
+
*/
|
|
45
|
+
append: (message: ModelMessage | UIMessage<TTools>) => Promise<void>;
|
|
46
|
+
/**
|
|
47
|
+
* Add the result of a client-side tool execution
|
|
48
|
+
*/
|
|
49
|
+
addToolResult: (result: {
|
|
50
|
+
toolCallId: string;
|
|
51
|
+
tool: string;
|
|
52
|
+
output: unknown;
|
|
53
|
+
state?: 'output-available' | 'output-error';
|
|
54
|
+
errorText?: string;
|
|
55
|
+
}) => Promise<void>;
|
|
56
|
+
/**
|
|
57
|
+
* Respond to a tool approval request
|
|
58
|
+
*/
|
|
59
|
+
addToolApprovalResponse: (response: {
|
|
60
|
+
id: string;
|
|
61
|
+
approved: boolean;
|
|
62
|
+
}) => Promise<void>;
|
|
63
|
+
/**
|
|
64
|
+
* Reload the last assistant message
|
|
65
|
+
*/
|
|
66
|
+
reload: () => Promise<void>;
|
|
67
|
+
/**
|
|
68
|
+
* Stop the current response generation
|
|
69
|
+
*/
|
|
70
|
+
stop: () => void;
|
|
71
|
+
/**
|
|
72
|
+
* Whether a response is currently being generated
|
|
73
|
+
*/
|
|
74
|
+
isLoading: boolean;
|
|
75
|
+
/**
|
|
76
|
+
* Current error, if any
|
|
77
|
+
*/
|
|
78
|
+
error: Error | undefined;
|
|
79
|
+
/**
|
|
80
|
+
* Set messages manually
|
|
81
|
+
*/
|
|
82
|
+
setMessages: (messages: Array<UIMessage<TTools>>) => void;
|
|
83
|
+
/**
|
|
84
|
+
* Clear all messages
|
|
85
|
+
*/
|
|
86
|
+
clear: () => void;
|
|
87
|
+
/**
|
|
88
|
+
* Current generation status
|
|
89
|
+
*/
|
|
90
|
+
status: ChatClientState;
|
|
91
|
+
/**
|
|
92
|
+
* Whether the subscription loop is currently active
|
|
93
|
+
*/
|
|
94
|
+
isSubscribed: boolean;
|
|
95
|
+
/**
|
|
96
|
+
* Current connection lifecycle status
|
|
97
|
+
*/
|
|
98
|
+
connectionStatus: ConnectionStatus;
|
|
99
|
+
/**
|
|
100
|
+
* Whether the shared session is actively generating.
|
|
101
|
+
* Derived from stream run events (RUN_STARTED / RUN_FINISHED / RUN_ERROR).
|
|
102
|
+
* Unlike `isLoading` (request-local), this reflects shared generation
|
|
103
|
+
* activity visible to all subscribers (e.g. across tabs/devices).
|
|
104
|
+
*/
|
|
105
|
+
sessionGenerating: boolean;
|
|
106
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { InferredClientContext } from '@tanstack/ai-client';
|
|
2
|
+
import { AnyClientTool } from '@tanstack/ai';
|
|
3
|
+
import { UseChatOptions, UseChatReturn } from './types.js';
|
|
4
|
+
export declare function useChat<TTools extends ReadonlyArray<AnyClientTool> = any, TContext = InferredClientContext<TTools>>(options: UseChatOptions<TTools, TContext>): UseChatReturn<TTools>;
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { ChatClient } from "@tanstack/ai-client";
|
|
2
|
+
import { createChatDevtoolsBridge } from "@tanstack/ai-client/devtools";
|
|
3
|
+
import { useId, useState, useRef, useEffect, useMemo, useCallback } from "preact/hooks";
|
|
4
|
+
function useChat(options) {
|
|
5
|
+
const hookId = useId();
|
|
6
|
+
const clientId = options.id || hookId;
|
|
7
|
+
const [messages, setMessages] = useState(
|
|
8
|
+
options.initialMessages || []
|
|
9
|
+
);
|
|
10
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
11
|
+
const [error, setError] = useState(void 0);
|
|
12
|
+
const [status, setStatus] = useState("ready");
|
|
13
|
+
const [isSubscribed, setIsSubscribed] = useState(false);
|
|
14
|
+
const [connectionStatus, setConnectionStatus] = useState("disconnected");
|
|
15
|
+
const [sessionGenerating, setSessionGenerating] = useState(false);
|
|
16
|
+
const messagesRef = useRef(
|
|
17
|
+
options.initialMessages || []
|
|
18
|
+
);
|
|
19
|
+
const isFirstMountRef = useRef(true);
|
|
20
|
+
const activeClientRef = useRef(null);
|
|
21
|
+
const cleanupInvalidationRef = useRef(
|
|
22
|
+
null
|
|
23
|
+
);
|
|
24
|
+
const optionsRef = useRef(options);
|
|
25
|
+
optionsRef.current = options;
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
messagesRef.current = messages;
|
|
28
|
+
}, [messages]);
|
|
29
|
+
const client = useMemo(() => {
|
|
30
|
+
const messagesToUse = options.initialMessages || [];
|
|
31
|
+
isFirstMountRef.current = false;
|
|
32
|
+
const initialOptions = optionsRef.current;
|
|
33
|
+
const transport = initialOptions.connection ? { connection: initialOptions.connection } : { fetcher: initialOptions.fetcher };
|
|
34
|
+
const instance = new ChatClient({
|
|
35
|
+
devtoolsBridgeFactory: createChatDevtoolsBridge,
|
|
36
|
+
...transport,
|
|
37
|
+
id: clientId,
|
|
38
|
+
initialMessages: messagesToUse,
|
|
39
|
+
...initialOptions.body !== void 0 && { body: initialOptions.body },
|
|
40
|
+
...initialOptions.threadId !== void 0 && {
|
|
41
|
+
threadId: initialOptions.threadId
|
|
42
|
+
},
|
|
43
|
+
...initialOptions.forwardedProps !== void 0 && {
|
|
44
|
+
forwardedProps: initialOptions.forwardedProps
|
|
45
|
+
},
|
|
46
|
+
...initialOptions.persistence !== void 0 && {
|
|
47
|
+
persistence: initialOptions.persistence
|
|
48
|
+
},
|
|
49
|
+
...initialOptions.context !== void 0 && {
|
|
50
|
+
context: initialOptions.context
|
|
51
|
+
},
|
|
52
|
+
devtools: {
|
|
53
|
+
...initialOptions.devtools,
|
|
54
|
+
framework: "preact",
|
|
55
|
+
hookName: "useChat",
|
|
56
|
+
outputKind: initialOptions.outputSchema ? "structured" : "chat"
|
|
57
|
+
},
|
|
58
|
+
// Wrap every callback so the latest options are read at call time.
|
|
59
|
+
// Capturing the function reference directly would freeze it to whatever
|
|
60
|
+
// the parent passed on the first render.
|
|
61
|
+
onResponse: (response) => {
|
|
62
|
+
if (activeClientRef.current !== instance) return;
|
|
63
|
+
return optionsRef.current.onResponse?.(response);
|
|
64
|
+
},
|
|
65
|
+
onChunk: (chunk) => {
|
|
66
|
+
if (activeClientRef.current !== instance) return;
|
|
67
|
+
optionsRef.current.onChunk?.(chunk);
|
|
68
|
+
},
|
|
69
|
+
onFinish: (message) => {
|
|
70
|
+
if (activeClientRef.current !== instance) return;
|
|
71
|
+
optionsRef.current.onFinish?.(message);
|
|
72
|
+
},
|
|
73
|
+
onError: (err) => {
|
|
74
|
+
if (activeClientRef.current !== instance) return;
|
|
75
|
+
optionsRef.current.onError?.(err);
|
|
76
|
+
},
|
|
77
|
+
onCustomEvent: (eventType, data, context) => {
|
|
78
|
+
if (activeClientRef.current !== instance) return;
|
|
79
|
+
optionsRef.current.onCustomEvent?.(eventType, data, context);
|
|
80
|
+
},
|
|
81
|
+
...initialOptions.tools !== void 0 && {
|
|
82
|
+
tools: initialOptions.tools
|
|
83
|
+
},
|
|
84
|
+
...options.streamProcessor !== void 0 && {
|
|
85
|
+
streamProcessor: options.streamProcessor
|
|
86
|
+
},
|
|
87
|
+
onMessagesChange: (newMessages) => {
|
|
88
|
+
if (activeClientRef.current !== instance) return;
|
|
89
|
+
setMessages(newMessages);
|
|
90
|
+
},
|
|
91
|
+
onLoadingChange: (newIsLoading) => {
|
|
92
|
+
if (activeClientRef.current !== instance) return;
|
|
93
|
+
setIsLoading(newIsLoading);
|
|
94
|
+
},
|
|
95
|
+
onStatusChange: (newStatus) => {
|
|
96
|
+
if (activeClientRef.current !== instance) return;
|
|
97
|
+
setStatus(newStatus);
|
|
98
|
+
},
|
|
99
|
+
onErrorChange: (newError) => {
|
|
100
|
+
if (activeClientRef.current !== instance) return;
|
|
101
|
+
setError(newError);
|
|
102
|
+
},
|
|
103
|
+
onSubscriptionChange: (nextIsSubscribed) => {
|
|
104
|
+
if (activeClientRef.current !== instance) return;
|
|
105
|
+
setIsSubscribed(nextIsSubscribed);
|
|
106
|
+
},
|
|
107
|
+
onConnectionStatusChange: (nextStatus) => {
|
|
108
|
+
if (activeClientRef.current !== instance) return;
|
|
109
|
+
setConnectionStatus(nextStatus);
|
|
110
|
+
},
|
|
111
|
+
onSessionGeneratingChange: (isGenerating) => {
|
|
112
|
+
if (activeClientRef.current !== instance) return;
|
|
113
|
+
setSessionGenerating(isGenerating);
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
activeClientRef.current = instance;
|
|
117
|
+
return instance;
|
|
118
|
+
}, [clientId]);
|
|
119
|
+
useEffect(() => {
|
|
120
|
+
const clientMessages = client.getMessages();
|
|
121
|
+
if (clientMessages !== messagesRef.current) {
|
|
122
|
+
setMessages(clientMessages);
|
|
123
|
+
}
|
|
124
|
+
}, [client]);
|
|
125
|
+
useEffect(() => {
|
|
126
|
+
client.updateOptions({
|
|
127
|
+
body: options.body,
|
|
128
|
+
...options.forwardedProps !== void 0 && {
|
|
129
|
+
forwardedProps: options.forwardedProps
|
|
130
|
+
},
|
|
131
|
+
context: options.context
|
|
132
|
+
});
|
|
133
|
+
}, [client, options.body, options.forwardedProps, options.context]);
|
|
134
|
+
useEffect(() => {
|
|
135
|
+
if (options.live) {
|
|
136
|
+
client.subscribe();
|
|
137
|
+
} else {
|
|
138
|
+
client.unsubscribe();
|
|
139
|
+
}
|
|
140
|
+
}, [client, options.live]);
|
|
141
|
+
useEffect(() => {
|
|
142
|
+
if (cleanupInvalidationRef.current) {
|
|
143
|
+
clearTimeout(cleanupInvalidationRef.current);
|
|
144
|
+
cleanupInvalidationRef.current = null;
|
|
145
|
+
}
|
|
146
|
+
activeClientRef.current = client;
|
|
147
|
+
client.mountDevtools();
|
|
148
|
+
return () => {
|
|
149
|
+
cleanupInvalidationRef.current = setTimeout(() => {
|
|
150
|
+
if (activeClientRef.current === client) {
|
|
151
|
+
activeClientRef.current = null;
|
|
152
|
+
}
|
|
153
|
+
cleanupInvalidationRef.current = null;
|
|
154
|
+
}, 0);
|
|
155
|
+
if (optionsRef.current.live) {
|
|
156
|
+
client.unsubscribe();
|
|
157
|
+
} else {
|
|
158
|
+
client.stop();
|
|
159
|
+
}
|
|
160
|
+
client.dispose();
|
|
161
|
+
};
|
|
162
|
+
}, [client]);
|
|
163
|
+
const sendMessage = useCallback(
|
|
164
|
+
async (content) => {
|
|
165
|
+
await client.sendMessage(content);
|
|
166
|
+
},
|
|
167
|
+
[client]
|
|
168
|
+
);
|
|
169
|
+
const append = useCallback(
|
|
170
|
+
async (message) => {
|
|
171
|
+
await client.append(message);
|
|
172
|
+
},
|
|
173
|
+
[client]
|
|
174
|
+
);
|
|
175
|
+
const reload = useCallback(async () => {
|
|
176
|
+
await client.reload();
|
|
177
|
+
}, [client]);
|
|
178
|
+
const stop = useCallback(() => {
|
|
179
|
+
client.stop();
|
|
180
|
+
}, [client]);
|
|
181
|
+
const clear = useCallback(() => {
|
|
182
|
+
client.clear();
|
|
183
|
+
}, [client]);
|
|
184
|
+
const setMessagesManually = useCallback(
|
|
185
|
+
(newMessages) => {
|
|
186
|
+
client.setMessagesManually(newMessages);
|
|
187
|
+
},
|
|
188
|
+
[client]
|
|
189
|
+
);
|
|
190
|
+
const addToolResult = useCallback(
|
|
191
|
+
async (result) => {
|
|
192
|
+
await client.addToolResult(result);
|
|
193
|
+
},
|
|
194
|
+
[client]
|
|
195
|
+
);
|
|
196
|
+
const addToolApprovalResponse = useCallback(
|
|
197
|
+
async (response) => {
|
|
198
|
+
await client.addToolApprovalResponse(response);
|
|
199
|
+
},
|
|
200
|
+
[client]
|
|
201
|
+
);
|
|
202
|
+
const renderedMessages = client.getMessages();
|
|
203
|
+
return {
|
|
204
|
+
messages: renderedMessages,
|
|
205
|
+
sendMessage,
|
|
206
|
+
append,
|
|
207
|
+
reload,
|
|
208
|
+
stop,
|
|
209
|
+
isLoading,
|
|
210
|
+
error,
|
|
211
|
+
status,
|
|
212
|
+
isSubscribed,
|
|
213
|
+
connectionStatus,
|
|
214
|
+
sessionGenerating,
|
|
215
|
+
setMessages: setMessagesManually,
|
|
216
|
+
clear,
|
|
217
|
+
addToolResult,
|
|
218
|
+
addToolApprovalResponse
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
export {
|
|
222
|
+
useChat
|
|
223
|
+
};
|
|
224
|
+
//# sourceMappingURL=use-chat.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"use-chat.js","sources":["../../src/use-chat.ts"],"sourcesContent":["import { ChatClient } from '@tanstack/ai-client'\nimport { createChatDevtoolsBridge } from '@tanstack/ai-client/devtools'\nimport {\n useCallback,\n useEffect,\n useId,\n useMemo,\n useRef,\n useState,\n} from 'preact/hooks'\nimport type {\n ChatClientState,\n ConnectionStatus,\n InferredClientContext,\n} from '@tanstack/ai-client'\nimport type { AnyClientTool, ModelMessage } from '@tanstack/ai'\n\nimport type {\n MultimodalContent,\n UIMessage,\n UseChatOptions,\n UseChatReturn,\n} from './types'\n\nexport function useChat<\n TTools extends ReadonlyArray<AnyClientTool> = any,\n TContext = InferredClientContext<TTools>,\n>(options: UseChatOptions<TTools, TContext>): UseChatReturn<TTools> {\n const hookId = useId()\n const clientId = options.id || hookId\n\n const [messages, setMessages] = useState<Array<UIMessage<TTools>>>(\n options.initialMessages || [],\n )\n const [isLoading, setIsLoading] = useState(false)\n const [error, setError] = useState<Error | undefined>(undefined)\n const [status, setStatus] = useState<ChatClientState>('ready')\n const [isSubscribed, setIsSubscribed] = useState(false)\n const [connectionStatus, setConnectionStatus] =\n useState<ConnectionStatus>('disconnected')\n const [sessionGenerating, setSessionGenerating] = useState(false)\n\n // Track current messages in a ref to preserve them when client is recreated\n const messagesRef = useRef<Array<UIMessage<TTools>>>(\n options.initialMessages || [],\n )\n const isFirstMountRef = useRef(true)\n const activeClientRef = useRef<ChatClient | null>(null)\n const cleanupInvalidationRef = useRef<ReturnType<typeof setTimeout> | null>(\n null,\n )\n const optionsRef = useRef<UseChatOptions<TTools, TContext>>(options)\n\n optionsRef.current = options\n\n useEffect(() => {\n messagesRef.current = messages\n }, [messages])\n\n const client = useMemo(() => {\n const messagesToUse = options.initialMessages || []\n isFirstMountRef.current = false\n\n // Build options with conditional spreads for fields whose source\n // type is `T | undefined` but the ChatClient target uses a strict\n // optional (`field?: T`) — `exactOptionalPropertyTypes` rejects\n // assigning `undefined` to those, so we omit the key when absent.\n const initialOptions = optionsRef.current\n const transport = initialOptions.connection\n ? { connection: initialOptions.connection }\n : { fetcher: initialOptions.fetcher }\n\n const instance = new ChatClient<TTools, TContext>({\n devtoolsBridgeFactory: createChatDevtoolsBridge,\n ...transport,\n id: clientId,\n initialMessages: messagesToUse,\n ...(initialOptions.body !== undefined && { body: initialOptions.body }),\n ...(initialOptions.threadId !== undefined && {\n threadId: initialOptions.threadId,\n }),\n ...(initialOptions.forwardedProps !== undefined && {\n forwardedProps: initialOptions.forwardedProps,\n }),\n ...(initialOptions.persistence !== undefined && {\n persistence: initialOptions.persistence,\n }),\n ...(initialOptions.context !== undefined && {\n context: initialOptions.context,\n }),\n devtools: {\n ...initialOptions.devtools,\n framework: 'preact',\n hookName: 'useChat',\n outputKind: initialOptions.outputSchema ? 'structured' : 'chat',\n },\n // Wrap every callback so the latest options are read at call time.\n // Capturing the function reference directly would freeze it to whatever\n // the parent passed on the first render.\n onResponse: (response) => {\n if (activeClientRef.current !== instance) return\n return optionsRef.current.onResponse?.(response)\n },\n onChunk: (chunk) => {\n if (activeClientRef.current !== instance) return\n optionsRef.current.onChunk?.(chunk)\n },\n onFinish: (message) => {\n if (activeClientRef.current !== instance) return\n optionsRef.current.onFinish?.(message)\n },\n onError: (err) => {\n if (activeClientRef.current !== instance) return\n optionsRef.current.onError?.(err)\n },\n onCustomEvent: (eventType, data, context) => {\n if (activeClientRef.current !== instance) return\n optionsRef.current.onCustomEvent?.(eventType, data, context)\n },\n ...(initialOptions.tools !== undefined && {\n tools: initialOptions.tools,\n }),\n ...(options.streamProcessor !== undefined && {\n streamProcessor: options.streamProcessor,\n }),\n onMessagesChange: (newMessages: Array<UIMessage<TTools>>) => {\n if (activeClientRef.current !== instance) return\n setMessages(newMessages)\n },\n onLoadingChange: (newIsLoading: boolean) => {\n if (activeClientRef.current !== instance) return\n setIsLoading(newIsLoading)\n },\n onStatusChange: (newStatus: ChatClientState) => {\n if (activeClientRef.current !== instance) return\n setStatus(newStatus)\n },\n onErrorChange: (newError: Error | undefined) => {\n if (activeClientRef.current !== instance) return\n setError(newError)\n },\n onSubscriptionChange: (nextIsSubscribed: boolean) => {\n if (activeClientRef.current !== instance) return\n setIsSubscribed(nextIsSubscribed)\n },\n onConnectionStatusChange: (nextStatus: ConnectionStatus) => {\n if (activeClientRef.current !== instance) return\n setConnectionStatus(nextStatus)\n },\n onSessionGeneratingChange: (isGenerating: boolean) => {\n if (activeClientRef.current !== instance) return\n setSessionGenerating(isGenerating)\n },\n })\n activeClientRef.current = instance\n return instance\n }, [clientId])\n\n useEffect(() => {\n const clientMessages = client.getMessages()\n if (clientMessages !== messagesRef.current) {\n setMessages(clientMessages)\n }\n }, [client])\n\n // Sync body / forwardedProps changes to the client.\n // Both populate the same wire payload; `forwardedProps` is preferred\n // and `body` is deprecated but still supported.\n useEffect(() => {\n // Conditional spread: `updateOptions` declares strict-optional\n // fields and rejects explicit `undefined` under EOPT.\n client.updateOptions({\n body: options.body,\n ...(options.forwardedProps !== undefined && {\n forwardedProps: options.forwardedProps,\n }),\n context: options.context,\n })\n }, [client, options.body, options.forwardedProps, options.context])\n\n useEffect(() => {\n if (options.live) {\n client.subscribe()\n } else {\n client.unsubscribe()\n }\n }, [client, options.live])\n\n // Cleanup on unmount: stop any in-flight requests\n // Note: We only cleanup when client changes or component unmounts.\n // DO NOT include isLoading in dependencies - that would cause the cleanup\n // to run when isLoading changes, aborting continuation requests.\n useEffect(() => {\n if (cleanupInvalidationRef.current) {\n clearTimeout(cleanupInvalidationRef.current)\n cleanupInvalidationRef.current = null\n }\n activeClientRef.current = client\n client.mountDevtools()\n\n return () => {\n cleanupInvalidationRef.current = setTimeout(() => {\n if (activeClientRef.current === client) {\n activeClientRef.current = null\n }\n cleanupInvalidationRef.current = null\n }, 0)\n // Subscribe/unsubscribe on `options.live` is owned by the dedicated\n // effect above. This cleanup only fires on unmount or client swap,\n // so read `live` through the ref to avoid disposing the client every\n // time `live` toggles.\n if (optionsRef.current.live) {\n client.unsubscribe()\n } else {\n client.stop()\n }\n client.dispose()\n }\n }, [client])\n\n // All callback options are read through optionsRef at call time, so fresh\n // closures from each render are picked up without recreating the client.\n const sendMessage = useCallback(\n async (content: string | MultimodalContent) => {\n await client.sendMessage(content)\n },\n [client],\n )\n\n const append = useCallback(\n async (message: ModelMessage | UIMessage) => {\n await client.append(message)\n },\n [client],\n )\n\n const reload = useCallback(async () => {\n await client.reload()\n }, [client])\n\n const stop = useCallback(() => {\n client.stop()\n }, [client])\n\n const clear = useCallback(() => {\n client.clear()\n }, [client])\n\n const setMessagesManually = useCallback(\n (newMessages: Array<UIMessage<TTools>>) => {\n client.setMessagesManually(newMessages)\n },\n [client],\n )\n\n const addToolResult = useCallback(\n async (result: {\n toolCallId: string\n tool: string\n output: unknown\n state?: 'output-available' | 'output-error'\n errorText?: string\n }) => {\n await client.addToolResult(result)\n },\n [client],\n )\n\n const addToolApprovalResponse = useCallback(\n async (response: { id: string; approved: boolean }) => {\n await client.addToolApprovalResponse(response)\n },\n [client],\n )\n\n const renderedMessages = client.getMessages()\n\n return {\n messages: renderedMessages,\n sendMessage,\n append,\n reload,\n stop,\n isLoading,\n error,\n status,\n isSubscribed,\n connectionStatus,\n sessionGenerating,\n setMessages: setMessagesManually,\n clear,\n addToolResult,\n addToolApprovalResponse,\n }\n}\n"],"names":[],"mappings":";;;AAwBO,SAAS,QAGd,SAAkE;AAClE,QAAM,SAAS,MAAA;AACf,QAAM,WAAW,QAAQ,MAAM;AAE/B,QAAM,CAAC,UAAU,WAAW,IAAI;AAAA,IAC9B,QAAQ,mBAAmB,CAAA;AAAA,EAAC;AAE9B,QAAM,CAAC,WAAW,YAAY,IAAI,SAAS,KAAK;AAChD,QAAM,CAAC,OAAO,QAAQ,IAAI,SAA4B,MAAS;AAC/D,QAAM,CAAC,QAAQ,SAAS,IAAI,SAA0B,OAAO;AAC7D,QAAM,CAAC,cAAc,eAAe,IAAI,SAAS,KAAK;AACtD,QAAM,CAAC,kBAAkB,mBAAmB,IAC1C,SAA2B,cAAc;AAC3C,QAAM,CAAC,mBAAmB,oBAAoB,IAAI,SAAS,KAAK;AAGhE,QAAM,cAAc;AAAA,IAClB,QAAQ,mBAAmB,CAAA;AAAA,EAAC;AAE9B,QAAM,kBAAkB,OAAO,IAAI;AACnC,QAAM,kBAAkB,OAA0B,IAAI;AACtD,QAAM,yBAAyB;AAAA,IAC7B;AAAA,EAAA;AAEF,QAAM,aAAa,OAAyC,OAAO;AAEnE,aAAW,UAAU;AAErB,YAAU,MAAM;AACd,gBAAY,UAAU;AAAA,EACxB,GAAG,CAAC,QAAQ,CAAC;AAEb,QAAM,SAAS,QAAQ,MAAM;AAC3B,UAAM,gBAAgB,QAAQ,mBAAmB,CAAA;AACjD,oBAAgB,UAAU;AAM1B,UAAM,iBAAiB,WAAW;AAClC,UAAM,YAAY,eAAe,aAC7B,EAAE,YAAY,eAAe,eAC7B,EAAE,SAAS,eAAe,QAAA;AAE9B,UAAM,WAAW,IAAI,WAA6B;AAAA,MAChD,uBAAuB;AAAA,MACvB,GAAG;AAAA,MACH,IAAI;AAAA,MACJ,iBAAiB;AAAA,MACjB,GAAI,eAAe,SAAS,UAAa,EAAE,MAAM,eAAe,KAAA;AAAA,MAChE,GAAI,eAAe,aAAa,UAAa;AAAA,QAC3C,UAAU,eAAe;AAAA,MAAA;AAAA,MAE3B,GAAI,eAAe,mBAAmB,UAAa;AAAA,QACjD,gBAAgB,eAAe;AAAA,MAAA;AAAA,MAEjC,GAAI,eAAe,gBAAgB,UAAa;AAAA,QAC9C,aAAa,eAAe;AAAA,MAAA;AAAA,MAE9B,GAAI,eAAe,YAAY,UAAa;AAAA,QAC1C,SAAS,eAAe;AAAA,MAAA;AAAA,MAE1B,UAAU;AAAA,QACR,GAAG,eAAe;AAAA,QAClB,WAAW;AAAA,QACX,UAAU;AAAA,QACV,YAAY,eAAe,eAAe,eAAe;AAAA,MAAA;AAAA;AAAA;AAAA;AAAA,MAK3D,YAAY,CAAC,aAAa;AACxB,YAAI,gBAAgB,YAAY,SAAU;AAC1C,eAAO,WAAW,QAAQ,aAAa,QAAQ;AAAA,MACjD;AAAA,MACA,SAAS,CAAC,UAAU;AAClB,YAAI,gBAAgB,YAAY,SAAU;AAC1C,mBAAW,QAAQ,UAAU,KAAK;AAAA,MACpC;AAAA,MACA,UAAU,CAAC,YAAY;AACrB,YAAI,gBAAgB,YAAY,SAAU;AAC1C,mBAAW,QAAQ,WAAW,OAAO;AAAA,MACvC;AAAA,MACA,SAAS,CAAC,QAAQ;AAChB,YAAI,gBAAgB,YAAY,SAAU;AAC1C,mBAAW,QAAQ,UAAU,GAAG;AAAA,MAClC;AAAA,MACA,eAAe,CAAC,WAAW,MAAM,YAAY;AAC3C,YAAI,gBAAgB,YAAY,SAAU;AAC1C,mBAAW,QAAQ,gBAAgB,WAAW,MAAM,OAAO;AAAA,MAC7D;AAAA,MACA,GAAI,eAAe,UAAU,UAAa;AAAA,QACxC,OAAO,eAAe;AAAA,MAAA;AAAA,MAExB,GAAI,QAAQ,oBAAoB,UAAa;AAAA,QAC3C,iBAAiB,QAAQ;AAAA,MAAA;AAAA,MAE3B,kBAAkB,CAAC,gBAA0C;AAC3D,YAAI,gBAAgB,YAAY,SAAU;AAC1C,oBAAY,WAAW;AAAA,MACzB;AAAA,MACA,iBAAiB,CAAC,iBAA0B;AAC1C,YAAI,gBAAgB,YAAY,SAAU;AAC1C,qBAAa,YAAY;AAAA,MAC3B;AAAA,MACA,gBAAgB,CAAC,cAA+B;AAC9C,YAAI,gBAAgB,YAAY,SAAU;AAC1C,kBAAU,SAAS;AAAA,MACrB;AAAA,MACA,eAAe,CAAC,aAAgC;AAC9C,YAAI,gBAAgB,YAAY,SAAU;AAC1C,iBAAS,QAAQ;AAAA,MACnB;AAAA,MACA,sBAAsB,CAAC,qBAA8B;AACnD,YAAI,gBAAgB,YAAY,SAAU;AAC1C,wBAAgB,gBAAgB;AAAA,MAClC;AAAA,MACA,0BAA0B,CAAC,eAAiC;AAC1D,YAAI,gBAAgB,YAAY,SAAU;AAC1C,4BAAoB,UAAU;AAAA,MAChC;AAAA,MACA,2BAA2B,CAAC,iBAA0B;AACpD,YAAI,gBAAgB,YAAY,SAAU;AAC1C,6BAAqB,YAAY;AAAA,MACnC;AAAA,IAAA,CACD;AACD,oBAAgB,UAAU;AAC1B,WAAO;AAAA,EACT,GAAG,CAAC,QAAQ,CAAC;AAEb,YAAU,MAAM;AACd,UAAM,iBAAiB,OAAO,YAAA;AAC9B,QAAI,mBAAmB,YAAY,SAAS;AAC1C,kBAAY,cAAc;AAAA,IAC5B;AAAA,EACF,GAAG,CAAC,MAAM,CAAC;AAKX,YAAU,MAAM;AAGd,WAAO,cAAc;AAAA,MACnB,MAAM,QAAQ;AAAA,MACd,GAAI,QAAQ,mBAAmB,UAAa;AAAA,QAC1C,gBAAgB,QAAQ;AAAA,MAAA;AAAA,MAE1B,SAAS,QAAQ;AAAA,IAAA,CAClB;AAAA,EACH,GAAG,CAAC,QAAQ,QAAQ,MAAM,QAAQ,gBAAgB,QAAQ,OAAO,CAAC;AAElE,YAAU,MAAM;AACd,QAAI,QAAQ,MAAM;AAChB,aAAO,UAAA;AAAA,IACT,OAAO;AACL,aAAO,YAAA;AAAA,IACT;AAAA,EACF,GAAG,CAAC,QAAQ,QAAQ,IAAI,CAAC;AAMzB,YAAU,MAAM;AACd,QAAI,uBAAuB,SAAS;AAClC,mBAAa,uBAAuB,OAAO;AAC3C,6BAAuB,UAAU;AAAA,IACnC;AACA,oBAAgB,UAAU;AAC1B,WAAO,cAAA;AAEP,WAAO,MAAM;AACX,6BAAuB,UAAU,WAAW,MAAM;AAChD,YAAI,gBAAgB,YAAY,QAAQ;AACtC,0BAAgB,UAAU;AAAA,QAC5B;AACA,+BAAuB,UAAU;AAAA,MACnC,GAAG,CAAC;AAKJ,UAAI,WAAW,QAAQ,MAAM;AAC3B,eAAO,YAAA;AAAA,MACT,OAAO;AACL,eAAO,KAAA;AAAA,MACT;AACA,aAAO,QAAA;AAAA,IACT;AAAA,EACF,GAAG,CAAC,MAAM,CAAC;AAIX,QAAM,cAAc;AAAA,IAClB,OAAO,YAAwC;AAC7C,YAAM,OAAO,YAAY,OAAO;AAAA,IAClC;AAAA,IACA,CAAC,MAAM;AAAA,EAAA;AAGT,QAAM,SAAS;AAAA,IACb,OAAO,YAAsC;AAC3C,YAAM,OAAO,OAAO,OAAO;AAAA,IAC7B;AAAA,IACA,CAAC,MAAM;AAAA,EAAA;AAGT,QAAM,SAAS,YAAY,YAAY;AACrC,UAAM,OAAO,OAAA;AAAA,EACf,GAAG,CAAC,MAAM,CAAC;AAEX,QAAM,OAAO,YAAY,MAAM;AAC7B,WAAO,KAAA;AAAA,EACT,GAAG,CAAC,MAAM,CAAC;AAEX,QAAM,QAAQ,YAAY,MAAM;AAC9B,WAAO,MAAA;AAAA,EACT,GAAG,CAAC,MAAM,CAAC;AAEX,QAAM,sBAAsB;AAAA,IAC1B,CAAC,gBAA0C;AACzC,aAAO,oBAAoB,WAAW;AAAA,IACxC;AAAA,IACA,CAAC,MAAM;AAAA,EAAA;AAGT,QAAM,gBAAgB;AAAA,IACpB,OAAO,WAMD;AACJ,YAAM,OAAO,cAAc,MAAM;AAAA,IACnC;AAAA,IACA,CAAC,MAAM;AAAA,EAAA;AAGT,QAAM,0BAA0B;AAAA,IAC9B,OAAO,aAAgD;AACrD,YAAM,OAAO,wBAAwB,QAAQ;AAAA,IAC/C;AAAA,IACA,CAAC,MAAM;AAAA,EAAA;AAGT,QAAM,mBAAmB,OAAO,YAAA;AAEhC,SAAO;AAAA,IACL,UAAU;AAAA,IACV;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA,aAAa;AAAA,IACb;AAAA,IACA;AAAA,IACA;AAAA,EAAA;AAEJ;"}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { CreateMcpAppBridgeOptions, McpAppBridge } from '@tanstack/ai-client';
|
|
2
|
+
export type UseMcpAppBridgeOptions = CreateMcpAppBridgeOptions;
|
|
3
|
+
/**
|
|
4
|
+
* Preact wrapper around `createMcpAppBridge` that returns a **stable** bridge for
|
|
5
|
+
* the given `threadId`/`callEndpoint`, while always invoking the latest
|
|
6
|
+
* `chat.sendMessage` and `onLink` (kept in refs). This avoids both recreating
|
|
7
|
+
* the bridge on every render and the stale-closure / `exhaustive-deps` dance
|
|
8
|
+
* you'd otherwise write by hand:
|
|
9
|
+
*
|
|
10
|
+
* ```tsx
|
|
11
|
+
* const { sendMessage } = useChat({ threadId, connection })
|
|
12
|
+
* const bridge = useMcpAppBridge({
|
|
13
|
+
* threadId,
|
|
14
|
+
* callEndpoint: '/api/mcp-apps-call',
|
|
15
|
+
* chat: { sendMessage: async (content) => void sendMessage(content) },
|
|
16
|
+
* onLink: (url) => window.open(url, '_blank', 'noopener,noreferrer'),
|
|
17
|
+
* })
|
|
18
|
+
* // pass `bridge` to <MCPAppResource bridge={bridge} … />
|
|
19
|
+
* ```
|
|
20
|
+
*
|
|
21
|
+
* The bridge is recreated only when `threadId`, `callEndpoint`, `fetchImpl`, or
|
|
22
|
+
* the *presence* of `onLink` changes — passing a new inline `onLink`/`sendMessage`
|
|
23
|
+
* each render does not churn it.
|
|
24
|
+
*/
|
|
25
|
+
export declare function useMcpAppBridge(options: UseMcpAppBridgeOptions): McpAppBridge;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { useRef, useMemo } from "preact/hooks";
|
|
2
|
+
import { createMcpAppBridge } from "@tanstack/ai-client";
|
|
3
|
+
function useMcpAppBridge(options) {
|
|
4
|
+
const { threadId, callEndpoint, chat, fetchImpl, onLink } = options;
|
|
5
|
+
const chatRef = useRef(chat);
|
|
6
|
+
chatRef.current = chat;
|
|
7
|
+
const onLinkRef = useRef(onLink);
|
|
8
|
+
onLinkRef.current = onLink;
|
|
9
|
+
const hasOnLink = onLink != null;
|
|
10
|
+
return useMemo(
|
|
11
|
+
() => createMcpAppBridge({
|
|
12
|
+
threadId,
|
|
13
|
+
callEndpoint,
|
|
14
|
+
fetchImpl,
|
|
15
|
+
chat: {
|
|
16
|
+
sendMessage: (content, body) => chatRef.current.sendMessage(content, body)
|
|
17
|
+
},
|
|
18
|
+
onLink: hasOnLink ? (url) => onLinkRef.current?.(url) : void 0
|
|
19
|
+
}),
|
|
20
|
+
[threadId, callEndpoint, fetchImpl, hasOnLink]
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
export {
|
|
24
|
+
useMcpAppBridge
|
|
25
|
+
};
|
|
26
|
+
//# sourceMappingURL=use-mcp-app-bridge.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"use-mcp-app-bridge.js","sources":["../../src/use-mcp-app-bridge.ts"],"sourcesContent":["import { useMemo, useRef } from 'preact/hooks'\nimport { createMcpAppBridge } from '@tanstack/ai-client'\nimport type {\n CreateMcpAppBridgeOptions,\n McpAppBridge,\n} from '@tanstack/ai-client'\n\nexport type UseMcpAppBridgeOptions = CreateMcpAppBridgeOptions\n\n/**\n * Preact wrapper around `createMcpAppBridge` that returns a **stable** bridge for\n * the given `threadId`/`callEndpoint`, while always invoking the latest\n * `chat.sendMessage` and `onLink` (kept in refs). This avoids both recreating\n * the bridge on every render and the stale-closure / `exhaustive-deps` dance\n * you'd otherwise write by hand:\n *\n * ```tsx\n * const { sendMessage } = useChat({ threadId, connection })\n * const bridge = useMcpAppBridge({\n * threadId,\n * callEndpoint: '/api/mcp-apps-call',\n * chat: { sendMessage: async (content) => void sendMessage(content) },\n * onLink: (url) => window.open(url, '_blank', 'noopener,noreferrer'),\n * })\n * // pass `bridge` to <MCPAppResource bridge={bridge} … />\n * ```\n *\n * The bridge is recreated only when `threadId`, `callEndpoint`, `fetchImpl`, or\n * the *presence* of `onLink` changes — passing a new inline `onLink`/`sendMessage`\n * each render does not churn it.\n */\nexport function useMcpAppBridge(options: UseMcpAppBridgeOptions): McpAppBridge {\n const { threadId, callEndpoint, chat, fetchImpl, onLink } = options\n\n // Latest-value refs so the bridge identity stays stable but its callbacks are\n // never stale (the bridge calls `.current` at invocation time, not creation).\n const chatRef = useRef(chat)\n chatRef.current = chat\n const onLinkRef = useRef(onLink)\n onLinkRef.current = onLink\n\n // Whether a link handler was supplied governs the bridge's link behavior\n // (forward vs. display-only warn), so it's part of the bridge's identity.\n const hasOnLink = onLink != null\n\n return useMemo(\n () =>\n createMcpAppBridge({\n threadId,\n callEndpoint,\n fetchImpl,\n chat: {\n sendMessage: (content, body) =>\n chatRef.current.sendMessage(content, body),\n },\n onLink: hasOnLink ? (url) => onLinkRef.current?.(url) : undefined,\n }),\n [threadId, callEndpoint, fetchImpl, hasOnLink],\n )\n}\n"],"names":[],"mappings":";;AA+BO,SAAS,gBAAgB,SAA+C;AAC7E,QAAM,EAAE,UAAU,cAAc,MAAM,WAAW,WAAW;AAI5D,QAAM,UAAU,OAAO,IAAI;AAC3B,UAAQ,UAAU;AAClB,QAAM,YAAY,OAAO,MAAM;AAC/B,YAAU,UAAU;AAIpB,QAAM,YAAY,UAAU;AAE5B,SAAO;AAAA,IACL,MACE,mBAAmB;AAAA,MACjB;AAAA,MACA;AAAA,MACA;AAAA,MACA,MAAM;AAAA,QACJ,aAAa,CAAC,SAAS,SACrB,QAAQ,QAAQ,YAAY,SAAS,IAAI;AAAA,MAAA;AAAA,MAE7C,QAAQ,YAAY,CAAC,QAAQ,UAAU,UAAU,GAAG,IAAI;AAAA,IAAA,CACzD;AAAA,IACH,CAAC,UAAU,cAAc,WAAW,SAAS;AAAA,EAAA;AAEjD;"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tanstack/ai-preact",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "Preact hooks for TanStack AI streaming chat and typed messages.",
|
|
5
5
|
"author": "Tanner Linsley",
|
|
6
6
|
"license": "MIT",
|
|
@@ -24,6 +24,10 @@
|
|
|
24
24
|
".": {
|
|
25
25
|
"types": "./dist/esm/index.d.ts",
|
|
26
26
|
"import": "./dist/esm/index.js"
|
|
27
|
+
},
|
|
28
|
+
"./mcp-apps": {
|
|
29
|
+
"types": "./dist/esm/mcp-apps.d.ts",
|
|
30
|
+
"import": "./dist/esm/mcp-apps.js"
|
|
27
31
|
}
|
|
28
32
|
},
|
|
29
33
|
"files": [
|
|
@@ -42,18 +46,26 @@
|
|
|
42
46
|
"tool-calling"
|
|
43
47
|
],
|
|
44
48
|
"dependencies": {
|
|
45
|
-
"@tanstack/ai-client": "0.
|
|
49
|
+
"@tanstack/ai-client": "0.19.0"
|
|
46
50
|
},
|
|
47
51
|
"devDependencies": {
|
|
52
|
+
"@mcp-ui/client": "^7.1.1",
|
|
48
53
|
"@testing-library/preact": "^3.2.4",
|
|
49
54
|
"@vitest/coverage-v8": "4.0.14",
|
|
50
55
|
"jsdom": "^27.2.0",
|
|
51
56
|
"preact": "^10.26.9",
|
|
52
|
-
"vite": "^7.3.3"
|
|
57
|
+
"vite": "^7.3.3",
|
|
58
|
+
"@tanstack/ai": "0.38.0"
|
|
53
59
|
},
|
|
54
60
|
"peerDependencies": {
|
|
61
|
+
"@mcp-ui/client": "^7",
|
|
55
62
|
"preact": ">=10.11.0",
|
|
56
|
-
"@tanstack/ai": "^0.
|
|
63
|
+
"@tanstack/ai": "^0.38.0"
|
|
64
|
+
},
|
|
65
|
+
"peerDependenciesMeta": {
|
|
66
|
+
"@mcp-ui/client": {
|
|
67
|
+
"optional": true
|
|
68
|
+
}
|
|
57
69
|
},
|
|
58
70
|
"scripts": {
|
|
59
71
|
"clean": "premove ./build ./dist",
|
package/src/index.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
export { useChat } from './use-chat'
|
|
2
|
+
export { useMcpAppBridge } from './use-mcp-app-bridge'
|
|
3
|
+
export type { UseMcpAppBridgeOptions } from './use-mcp-app-bridge'
|
|
2
4
|
export type {
|
|
3
5
|
UseChatOptions,
|
|
4
6
|
UseChatReturn,
|
|
@@ -14,6 +16,9 @@ export {
|
|
|
14
16
|
stream,
|
|
15
17
|
rpcStream,
|
|
16
18
|
createChatClientOptions,
|
|
19
|
+
createMcpAppBridge,
|
|
20
|
+
type McpAppBridge,
|
|
21
|
+
type CreateMcpAppBridgeOptions,
|
|
17
22
|
type ConnectionAdapter,
|
|
18
23
|
type ConnectConnectionAdapter,
|
|
19
24
|
type SubscribeConnectionAdapter,
|
package/src/mcp-apps.tsx
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCPAppResource — Preact wrapper for rendering MCP App UI resources.
|
|
3
|
+
*
|
|
4
|
+
* PREACT/COMPAT NOTE:
|
|
5
|
+
* `@mcp-ui/client@7.1.1` is published React-only: `AppRenderer` is a React
|
|
6
|
+
* `forwardRef` component. There is NO dedicated preact entry point and NO web
|
|
7
|
+
* component variant. This wrapper imports `{ AppRenderer }` from
|
|
8
|
+
* `'@mcp-ui/client'` identically to the React wrapper and relies on the
|
|
9
|
+
* *consumer's* `preact/compat` alias (resolving `react` / `react-dom` to
|
|
10
|
+
* `preact/compat`) to render the React component under Preact.
|
|
11
|
+
* Wiring up that alias is the consumer's responsibility and is NOT
|
|
12
|
+
* runtime-verified in this repository.
|
|
13
|
+
*/
|
|
14
|
+
import { AppRenderer } from '@mcp-ui/client'
|
|
15
|
+
import type { AppRendererProps } from '@mcp-ui/client'
|
|
16
|
+
import type { UIResourcePart } from '@tanstack/ai'
|
|
17
|
+
import type { McpAppBridge } from '@tanstack/ai-client'
|
|
18
|
+
|
|
19
|
+
export interface MCPAppResourceProps {
|
|
20
|
+
/** The ui-resource part from a UIMessage assistant part. */
|
|
21
|
+
part: UIResourcePart
|
|
22
|
+
/**
|
|
23
|
+
* Framework-agnostic bridge for tool calls, prompt sending, and link opening.
|
|
24
|
+
* Omit it to render the widget in display-only mode — iframe interactions
|
|
25
|
+
* that would trigger tool calls or prompts are ignored.
|
|
26
|
+
*/
|
|
27
|
+
bridge?: McpAppBridge
|
|
28
|
+
/** Sandbox iframe configuration — must include the proxy page URL. */
|
|
29
|
+
sandbox: { url: URL }
|
|
30
|
+
/** Optional structured arguments forwarded to the guest UI once it's ready. */
|
|
31
|
+
toolInput?: Record<string, unknown>
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
35
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Coalesce an arbitrary bridge result into the `string` required by the
|
|
40
|
+
* `CallToolResult` text content block. `JSON.stringify` is typed to return
|
|
41
|
+
* `string` but actually returns `undefined` for inputs like `undefined` or a
|
|
42
|
+
* function, so we narrow explicitly to avoid `text: undefined`.
|
|
43
|
+
*/
|
|
44
|
+
function resultToText(result: unknown): string {
|
|
45
|
+
if (typeof result === 'string') return result
|
|
46
|
+
// `JSON.stringify`'s lib signature claims `string`, but it returns
|
|
47
|
+
// `undefined` for `undefined`/function inputs; type the call honestly.
|
|
48
|
+
const stringify: (value: unknown) => string | undefined = JSON.stringify
|
|
49
|
+
return stringify(result) ?? 'null'
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Renders an MCP App UI resource inside a sandboxed iframe.
|
|
54
|
+
*
|
|
55
|
+
* Wraps `@mcp-ui/client`'s `AppRenderer` and wires its callbacks to a
|
|
56
|
+
* framework-agnostic {@link McpAppBridge}.
|
|
57
|
+
*/
|
|
58
|
+
export function MCPAppResource(props: MCPAppResourceProps) {
|
|
59
|
+
const { part, bridge, sandbox, toolInput } = props
|
|
60
|
+
|
|
61
|
+
const onCallTool: AppRendererProps['onCallTool'] = bridge
|
|
62
|
+
? async (params) => {
|
|
63
|
+
const result = await bridge.callTool({
|
|
64
|
+
serverId: part.serverId,
|
|
65
|
+
toolName: params.name,
|
|
66
|
+
args: params.arguments,
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
content: [
|
|
71
|
+
{
|
|
72
|
+
type: 'text' as const,
|
|
73
|
+
text: resultToText(result),
|
|
74
|
+
},
|
|
75
|
+
],
|
|
76
|
+
structuredContent: isRecord(result) ? result : undefined,
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
: undefined
|
|
80
|
+
|
|
81
|
+
const onMessage: AppRendererProps['onMessage'] = bridge
|
|
82
|
+
? async (params) => {
|
|
83
|
+
const text = params.content
|
|
84
|
+
.filter((c): c is { type: 'text'; text: string } => c.type === 'text')
|
|
85
|
+
.map((c) => c.text)
|
|
86
|
+
.join('')
|
|
87
|
+
if (text) await bridge.sendPrompt(text)
|
|
88
|
+
return {}
|
|
89
|
+
}
|
|
90
|
+
: undefined
|
|
91
|
+
|
|
92
|
+
const onOpenLink: AppRendererProps['onOpenLink'] = bridge
|
|
93
|
+
? (params) => Promise.resolve(bridge.openLink(params.url))
|
|
94
|
+
: undefined
|
|
95
|
+
|
|
96
|
+
return (
|
|
97
|
+
<AppRenderer
|
|
98
|
+
toolName={part.toolName}
|
|
99
|
+
sandbox={sandbox}
|
|
100
|
+
html={part.resource.text}
|
|
101
|
+
toolResourceUri={part.resource.uri}
|
|
102
|
+
toolInput={toolInput}
|
|
103
|
+
onCallTool={onCallTool}
|
|
104
|
+
onMessage={onMessage}
|
|
105
|
+
onOpenLink={onOpenLink}
|
|
106
|
+
/>
|
|
107
|
+
)
|
|
108
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { useMemo, useRef } from 'preact/hooks'
|
|
2
|
+
import { createMcpAppBridge } from '@tanstack/ai-client'
|
|
3
|
+
import type {
|
|
4
|
+
CreateMcpAppBridgeOptions,
|
|
5
|
+
McpAppBridge,
|
|
6
|
+
} from '@tanstack/ai-client'
|
|
7
|
+
|
|
8
|
+
export type UseMcpAppBridgeOptions = CreateMcpAppBridgeOptions
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Preact wrapper around `createMcpAppBridge` that returns a **stable** bridge for
|
|
12
|
+
* the given `threadId`/`callEndpoint`, while always invoking the latest
|
|
13
|
+
* `chat.sendMessage` and `onLink` (kept in refs). This avoids both recreating
|
|
14
|
+
* the bridge on every render and the stale-closure / `exhaustive-deps` dance
|
|
15
|
+
* you'd otherwise write by hand:
|
|
16
|
+
*
|
|
17
|
+
* ```tsx
|
|
18
|
+
* const { sendMessage } = useChat({ threadId, connection })
|
|
19
|
+
* const bridge = useMcpAppBridge({
|
|
20
|
+
* threadId,
|
|
21
|
+
* callEndpoint: '/api/mcp-apps-call',
|
|
22
|
+
* chat: { sendMessage: async (content) => void sendMessage(content) },
|
|
23
|
+
* onLink: (url) => window.open(url, '_blank', 'noopener,noreferrer'),
|
|
24
|
+
* })
|
|
25
|
+
* // pass `bridge` to <MCPAppResource bridge={bridge} … />
|
|
26
|
+
* ```
|
|
27
|
+
*
|
|
28
|
+
* The bridge is recreated only when `threadId`, `callEndpoint`, `fetchImpl`, or
|
|
29
|
+
* the *presence* of `onLink` changes — passing a new inline `onLink`/`sendMessage`
|
|
30
|
+
* each render does not churn it.
|
|
31
|
+
*/
|
|
32
|
+
export function useMcpAppBridge(options: UseMcpAppBridgeOptions): McpAppBridge {
|
|
33
|
+
const { threadId, callEndpoint, chat, fetchImpl, onLink } = options
|
|
34
|
+
|
|
35
|
+
// Latest-value refs so the bridge identity stays stable but its callbacks are
|
|
36
|
+
// never stale (the bridge calls `.current` at invocation time, not creation).
|
|
37
|
+
const chatRef = useRef(chat)
|
|
38
|
+
chatRef.current = chat
|
|
39
|
+
const onLinkRef = useRef(onLink)
|
|
40
|
+
onLinkRef.current = onLink
|
|
41
|
+
|
|
42
|
+
// Whether a link handler was supplied governs the bridge's link behavior
|
|
43
|
+
// (forward vs. display-only warn), so it's part of the bridge's identity.
|
|
44
|
+
const hasOnLink = onLink != null
|
|
45
|
+
|
|
46
|
+
return useMemo(
|
|
47
|
+
() =>
|
|
48
|
+
createMcpAppBridge({
|
|
49
|
+
threadId,
|
|
50
|
+
callEndpoint,
|
|
51
|
+
fetchImpl,
|
|
52
|
+
chat: {
|
|
53
|
+
sendMessage: (content, body) =>
|
|
54
|
+
chatRef.current.sendMessage(content, body),
|
|
55
|
+
},
|
|
56
|
+
onLink: hasOnLink ? (url) => onLinkRef.current?.(url) : undefined,
|
|
57
|
+
}),
|
|
58
|
+
[threadId, callEndpoint, fetchImpl, hasOnLink],
|
|
59
|
+
)
|
|
60
|
+
}
|