@tanstack/ai-client 0.33.2 → 0.35.1
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/chat-client.d.ts +24 -1
- package/dist/esm/chat-client.js +131 -5
- package/dist/esm/chat-client.js.map +1 -1
- package/dist/esm/index.d.ts +3 -3
- package/dist/esm/index.js +2 -2
- package/dist/esm/types.d.ts +84 -7
- package/dist/esm/types.js.map +1 -1
- package/dist/esm/ui/selectors.js +2 -1
- package/dist/esm/ui/selectors.js.map +1 -1
- package/dist/esm/ui/types.d.ts +53 -10
- package/dist/esm/ui.d.ts +1 -1
- package/dist/esm/web-mcp-tools.d.ts +60 -0
- package/dist/esm/web-mcp-tools.js +92 -1
- package/dist/esm/web-mcp-tools.js.map +1 -1
- package/package.json +3 -3
- package/src/chat-client.ts +198 -4
- package/src/index.ts +15 -1
- package/src/types.ts +122 -6
- package/src/ui/selectors.ts +1 -0
- package/src/ui/types.ts +119 -11
- package/src/ui.ts +7 -0
- package/src/web-mcp-tools.ts +181 -0
|
@@ -74,7 +74,98 @@ async function registerWebMCPTools(tools, options) {
|
|
|
74
74
|
throw error;
|
|
75
75
|
}
|
|
76
76
|
}
|
|
77
|
+
function isWebMCPToolReader(value) {
|
|
78
|
+
return value !== null && typeof value === "object" && "getTools" in value && typeof value.getTools === "function" && "executeTool" in value && typeof value.executeTool === "function" && "addEventListener" in value && typeof value.addEventListener === "function" && "removeEventListener" in value && typeof value.removeEventListener === "function";
|
|
79
|
+
}
|
|
80
|
+
function getWebMCPToolReader() {
|
|
81
|
+
if (typeof document === "undefined" || typeof isSecureContext !== "undefined" && !isSecureContext || !("modelContext" in document) || !isWebMCPToolReader(document.modelContext)) return;
|
|
82
|
+
return document.modelContext;
|
|
83
|
+
}
|
|
84
|
+
function parseToolResult(result) {
|
|
85
|
+
try {
|
|
86
|
+
return JSON.parse(result);
|
|
87
|
+
} catch {
|
|
88
|
+
return result;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
async function readWebMCPTools(reader, options) {
|
|
92
|
+
const pageTools = await reader.getTools();
|
|
93
|
+
const names = /* @__PURE__ */ new Set();
|
|
94
|
+
return pageTools.filter((tool) => options?.filter?.(tool) ?? true).map((tool) => {
|
|
95
|
+
if (names.has(tool.name)) throw new Error(`Duplicate WebMCP tool name "${tool.name}". Use a filter or register tools with unique names.`);
|
|
96
|
+
names.add(tool.name);
|
|
97
|
+
return {
|
|
98
|
+
__toolSide: "client",
|
|
99
|
+
name: tool.name,
|
|
100
|
+
description: tool.description,
|
|
101
|
+
inputSchema: tool.inputSchema ?? { type: "object" },
|
|
102
|
+
async execute(input, context) {
|
|
103
|
+
return parseToolResult(await reader.executeTool(tool, input, context?.abortSignal ? { signal: context.abortSignal } : {}));
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Reads the WebMCP tools on the page and returns them as client tools.
|
|
110
|
+
*
|
|
111
|
+
* Pass the result to a chat as `tools`. Each tool runs through the WebMCP
|
|
112
|
+
* `executeTool()` call. Unsupported browsers and server environments return
|
|
113
|
+
* an empty array.
|
|
114
|
+
*
|
|
115
|
+
* @param options - A filter that skips tools.
|
|
116
|
+
*
|
|
117
|
+
* @example
|
|
118
|
+
* ```ts
|
|
119
|
+
* const tools = await getWebMCPTools({
|
|
120
|
+
* filter: (tool) => tool.origin === location.origin,
|
|
121
|
+
* })
|
|
122
|
+
* ```
|
|
123
|
+
*/
|
|
124
|
+
async function getWebMCPTools(options) {
|
|
125
|
+
const reader = getWebMCPToolReader();
|
|
126
|
+
return reader ? readWebMCPTools(reader, options) : [];
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Calls `listener` with the page WebMCP tools now and after each
|
|
130
|
+
* `toolchange` event, until `options.signal` aborts.
|
|
131
|
+
*
|
|
132
|
+
* Unsupported browsers and server environments call `listener` once with an
|
|
133
|
+
* empty array. When a read fails, `options.onError` gets the error and the
|
|
134
|
+
* listener keeps the last list.
|
|
135
|
+
*
|
|
136
|
+
* @param listener - Receives the current client tools.
|
|
137
|
+
* @param options - The subscription signal, a filter, and an error callback.
|
|
138
|
+
*
|
|
139
|
+
* @example
|
|
140
|
+
* ```ts
|
|
141
|
+
* const controller = new AbortController()
|
|
142
|
+
* subscribeWebMCPTools((tools) => client.updateOptions({ tools }), {
|
|
143
|
+
* signal: controller.signal,
|
|
144
|
+
* })
|
|
145
|
+
* ```
|
|
146
|
+
*/
|
|
147
|
+
function subscribeWebMCPTools(listener, options) {
|
|
148
|
+
if (options.signal.aborted) return;
|
|
149
|
+
const reader = getWebMCPToolReader();
|
|
150
|
+
if (!reader) {
|
|
151
|
+
listener([]);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
let latestRead = 0;
|
|
155
|
+
const refresh = () => {
|
|
156
|
+
const read = ++latestRead;
|
|
157
|
+
const isCurrent = () => read === latestRead && !options.signal.aborted;
|
|
158
|
+
readWebMCPTools(reader, options).then((tools) => {
|
|
159
|
+
if (isCurrent()) listener(tools);
|
|
160
|
+
}, (error) => {
|
|
161
|
+
if (isCurrent()) options.onError?.(error);
|
|
162
|
+
});
|
|
163
|
+
};
|
|
164
|
+
reader.addEventListener("toolchange", refresh);
|
|
165
|
+
options.signal.addEventListener("abort", () => reader.removeEventListener("toolchange", refresh), { once: true });
|
|
166
|
+
refresh();
|
|
167
|
+
}
|
|
77
168
|
//#endregion
|
|
78
|
-
export { registerWebMCPTools };
|
|
169
|
+
export { getWebMCPTools, registerWebMCPTools, subscribeWebMCPTools };
|
|
79
170
|
|
|
80
171
|
//# sourceMappingURL=web-mcp-tools.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"web-mcp-tools.js","names":[],"sources":["../../src/web-mcp-tools.ts"],"sourcesContent":["import {\n convertSchemaToJsonSchema,\n validateWithStandardSchema,\n} from '@tanstack/ai/client'\nimport type { AnyClientTool } from '@tanstack/ai/client'\nimport type {\n ClientContextOptionFromTools,\n InferredClientContext,\n} from './types'\n\ninterface WebMCPTool {\n name: string\n title?: string\n description: string\n inputSchema?: object\n annotations?: WebMCPToolAnnotations\n execute: (input: object, options: { signal: AbortSignal }) => Promise<unknown>\n}\n\ninterface WebMCPModelContext {\n registerTool: (\n tool: WebMCPTool,\n options: { signal: AbortSignal },\n ) => Promise<void>\n}\n\n/** WebMCP behavior hints for one registered tool. */\nexport interface WebMCPToolAnnotations {\n /** Indicates that the tool does not modify state. */\n readOnlyHint?: boolean\n /** Indicates that the tool can return content that the application does not trust. */\n untrustedContentHint?: boolean\n}\n\n/** Display and behavior options for one WebMCP tool. */\nexport interface WebMCPToolOptions {\n /** A human-readable title for browser user interfaces. */\n title?: string\n /** Optional behavior hints for browser agents. */\n annotations?: WebMCPToolAnnotations\n}\n\n/** WebMCP options keyed by the inferred names in a client tool list. */\nexport type WebMCPToolOptionsByName<\n TTools extends ReadonlyArray<AnyClientTool>,\n> = Partial<{\n [TName in TTools[number]['name']]: WebMCPToolOptions\n}>\n\n/**\n * Options for {@link registerWebMCPTools}.\n *\n * The signal controls the registration lifetime. Context is required when a\n * client tool declares a required runtime context.\n */\nexport type RegisterWebMCPToolsOptions<\n TTools extends ReadonlyArray<AnyClientTool>,\n TContext = InferredClientContext<TTools>,\n> = {\n /** Removes all tools from this call when the signal aborts. */\n signal: AbortSignal\n /** Per-tool display and behavior options. */\n toolOptions?: WebMCPToolOptionsByName<TTools>\n} & ClientContextOptionFromTools<TTools, TContext>\n\nfunction isWebMCPModelContext(value: unknown): value is WebMCPModelContext {\n return (\n value !== null &&\n typeof value === 'object' &&\n 'registerTool' in value &&\n typeof value.registerTool === 'function'\n )\n}\n\nfunction getToolOptions<TName extends string>(\n toolOptions: Partial<Record<TName, WebMCPToolOptions>> | undefined,\n name: TName,\n) {\n return toolOptions?.[name]\n}\n\nasync function validateSchemaValue(schema: unknown, value: unknown) {\n const result = await validateWithStandardSchema(schema, value)\n if (result.success) {\n return result.data\n }\n\n throw new Error(\n `Validation failed: ${result.issues.map((issue) => issue.message).join(', ')}`,\n )\n}\n\n/**\n * Registers executable TanStack client tools with the browser WebMCP API.\n *\n * Unsupported browsers and server environments resolve without registration.\n * Abort `options.signal` to remove every tool registered by this call.\n *\n * @param tools - The executable client tools to expose through WebMCP.\n * @param options - The registration signal, runtime context, and per-tool options.\n *\n * @example\n * ```ts\n * const controller = new AbortController()\n * await registerWebMCPTools(tools, { signal: controller.signal })\n * controller.abort()\n * ```\n */\nexport async function registerWebMCPTools<\n const TTools extends ReadonlyArray<AnyClientTool>,\n TContext = InferredClientContext<TTools>,\n>(tools: TTools, options: RegisterWebMCPToolsOptions<TTools, TContext>) {\n if (\n typeof document === 'undefined' ||\n (typeof isSecureContext !== 'undefined' && !isSecureContext) ||\n !('modelContext' in document) ||\n !isWebMCPModelContext(document.modelContext)\n ) {\n return\n }\n if (tools.length === 0) {\n return\n }\n\n const names = new Set<string>()\n const webMCPTools = tools.map((tool) => {\n if (!/^[A-Za-z0-9_.-]{1,128}$/.test(tool.name)) {\n throw new Error(\n `WebMCP tool name \"${tool.name}\" must contain 1 to 128 ASCII letters, numbers, underscores, hyphens, or periods.`,\n )\n }\n if (names.has(tool.name)) {\n throw new Error(`Duplicate WebMCP tool name \"${tool.name}\".`)\n }\n if (tool.description.trim() === '') {\n throw new Error(`WebMCP tool \"${tool.name}\" must have a description.`)\n }\n if (typeof tool.execute !== 'function') {\n throw new Error(\n `WebMCP tool \"${tool.name}\" must have an execute handler.`,\n )\n }\n if (tool.needsApproval === true) {\n throw new Error(\n `WebMCP tool \"${tool.name}\" cannot use needsApproval: true.`,\n )\n }\n\n names.add(tool.name)\n const toolOptions = getToolOptions(options.toolOptions, tool.name)\n const inputSchema = tool.inputSchema\n const outputSchema = tool.outputSchema\n const convertedInputSchema = convertSchemaToJsonSchema(inputSchema)\n const inputSchemaType = convertedInputSchema?.type\n const requiresNonObjectInput =\n (typeof inputSchemaType === 'string' && inputSchemaType !== 'object') ||\n (Array.isArray(inputSchemaType) && !inputSchemaType.includes('object'))\n if (requiresNonObjectInput) {\n throw new Error(\n `WebMCP tool \"${tool.name}\" input schema must accept an object.`,\n )\n }\n const execute = tool.execute\n\n return {\n name: tool.name,\n description: tool.description,\n ...(toolOptions?.title !== undefined ? { title: toolOptions.title } : {}),\n ...(convertedInputSchema !== undefined\n ? { inputSchema: convertedInputSchema }\n : {}),\n ...(toolOptions?.annotations !== undefined\n ? { annotations: toolOptions.annotations }\n : {}),\n async execute(\n input: object,\n executionOptions?: { signal?: AbortSignal },\n ) {\n const validatedInput = await validateSchemaValue(inputSchema, input)\n const output = await execute(validatedInput, {\n abortSignal: executionOptions?.signal,\n context: options.context,\n emitCustomEvent() {},\n })\n return validateSchemaValue(outputSchema, output)\n },\n }\n })\n\n const registrationController = new AbortController()\n const abortRegistration = () =>\n registrationController.abort(options.signal.reason)\n\n if (options.signal.aborted) {\n abortRegistration()\n } else {\n options.signal.addEventListener('abort', abortRegistration, { once: true })\n }\n\n try {\n for (const tool of webMCPTools) {\n await document.modelContext.registerTool(tool, {\n signal: registrationController.signal,\n })\n }\n } catch (error) {\n registrationController.abort(error)\n options.signal.removeEventListener('abort', abortRegistration)\n throw error\n }\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"web-mcp-tools.js","names":[],"sources":["../../src/web-mcp-tools.ts"],"sourcesContent":["import {\n convertSchemaToJsonSchema,\n validateWithStandardSchema,\n} from '@tanstack/ai/client'\nimport type { AnyClientTool } from '@tanstack/ai/client'\nimport type {\n ClientContextOptionFromTools,\n InferredClientContext,\n} from './types'\n\ninterface WebMCPTool {\n name: string\n title?: string\n description: string\n inputSchema?: object\n annotations?: WebMCPToolAnnotations\n execute: (input: object, options: { signal: AbortSignal }) => Promise<unknown>\n}\n\ninterface WebMCPModelContext {\n registerTool: (\n tool: WebMCPTool,\n options: { signal: AbortSignal },\n ) => Promise<void>\n}\n\n/** WebMCP behavior hints for one registered tool. */\nexport interface WebMCPToolAnnotations {\n /** Indicates that the tool does not modify state. */\n readOnlyHint?: boolean\n /** Indicates that the tool can return content that the application does not trust. */\n untrustedContentHint?: boolean\n}\n\n/** Display and behavior options for one WebMCP tool. */\nexport interface WebMCPToolOptions {\n /** A human-readable title for browser user interfaces. */\n title?: string\n /** Optional behavior hints for browser agents. */\n annotations?: WebMCPToolAnnotations\n}\n\n/** WebMCP options keyed by the inferred names in a client tool list. */\nexport type WebMCPToolOptionsByName<\n TTools extends ReadonlyArray<AnyClientTool>,\n> = Partial<{\n [TName in TTools[number]['name']]: WebMCPToolOptions\n}>\n\n/**\n * Options for {@link registerWebMCPTools}.\n *\n * The signal controls the registration lifetime. Context is required when a\n * client tool declares a required runtime context.\n */\nexport type RegisterWebMCPToolsOptions<\n TTools extends ReadonlyArray<AnyClientTool>,\n TContext = InferredClientContext<TTools>,\n> = {\n /** Removes all tools from this call when the signal aborts. */\n signal: AbortSignal\n /** Per-tool display and behavior options. */\n toolOptions?: WebMCPToolOptionsByName<TTools>\n} & ClientContextOptionFromTools<TTools, TContext>\n\nfunction isWebMCPModelContext(value: unknown): value is WebMCPModelContext {\n return (\n value !== null &&\n typeof value === 'object' &&\n 'registerTool' in value &&\n typeof value.registerTool === 'function'\n )\n}\n\nfunction getToolOptions<TName extends string>(\n toolOptions: Partial<Record<TName, WebMCPToolOptions>> | undefined,\n name: TName,\n) {\n return toolOptions?.[name]\n}\n\nasync function validateSchemaValue(schema: unknown, value: unknown) {\n const result = await validateWithStandardSchema(schema, value)\n if (result.success) {\n return result.data\n }\n\n throw new Error(\n `Validation failed: ${result.issues.map((issue) => issue.message).join(', ')}`,\n )\n}\n\n/**\n * Registers executable TanStack client tools with the browser WebMCP API.\n *\n * Unsupported browsers and server environments resolve without registration.\n * Abort `options.signal` to remove every tool registered by this call.\n *\n * @param tools - The executable client tools to expose through WebMCP.\n * @param options - The registration signal, runtime context, and per-tool options.\n *\n * @example\n * ```ts\n * const controller = new AbortController()\n * await registerWebMCPTools(tools, { signal: controller.signal })\n * controller.abort()\n * ```\n */\nexport async function registerWebMCPTools<\n const TTools extends ReadonlyArray<AnyClientTool>,\n TContext = InferredClientContext<TTools>,\n>(tools: TTools, options: RegisterWebMCPToolsOptions<TTools, TContext>) {\n if (\n typeof document === 'undefined' ||\n (typeof isSecureContext !== 'undefined' && !isSecureContext) ||\n !('modelContext' in document) ||\n !isWebMCPModelContext(document.modelContext)\n ) {\n return\n }\n if (tools.length === 0) {\n return\n }\n\n const names = new Set<string>()\n const webMCPTools = tools.map((tool) => {\n if (!/^[A-Za-z0-9_.-]{1,128}$/.test(tool.name)) {\n throw new Error(\n `WebMCP tool name \"${tool.name}\" must contain 1 to 128 ASCII letters, numbers, underscores, hyphens, or periods.`,\n )\n }\n if (names.has(tool.name)) {\n throw new Error(`Duplicate WebMCP tool name \"${tool.name}\".`)\n }\n if (tool.description.trim() === '') {\n throw new Error(`WebMCP tool \"${tool.name}\" must have a description.`)\n }\n if (typeof tool.execute !== 'function') {\n throw new Error(\n `WebMCP tool \"${tool.name}\" must have an execute handler.`,\n )\n }\n if (tool.needsApproval === true) {\n throw new Error(\n `WebMCP tool \"${tool.name}\" cannot use needsApproval: true.`,\n )\n }\n\n names.add(tool.name)\n const toolOptions = getToolOptions(options.toolOptions, tool.name)\n const inputSchema = tool.inputSchema\n const outputSchema = tool.outputSchema\n const convertedInputSchema = convertSchemaToJsonSchema(inputSchema)\n const inputSchemaType = convertedInputSchema?.type\n const requiresNonObjectInput =\n (typeof inputSchemaType === 'string' && inputSchemaType !== 'object') ||\n (Array.isArray(inputSchemaType) && !inputSchemaType.includes('object'))\n if (requiresNonObjectInput) {\n throw new Error(\n `WebMCP tool \"${tool.name}\" input schema must accept an object.`,\n )\n }\n const execute = tool.execute\n\n return {\n name: tool.name,\n description: tool.description,\n ...(toolOptions?.title !== undefined ? { title: toolOptions.title } : {}),\n ...(convertedInputSchema !== undefined\n ? { inputSchema: convertedInputSchema }\n : {}),\n ...(toolOptions?.annotations !== undefined\n ? { annotations: toolOptions.annotations }\n : {}),\n async execute(\n input: object,\n executionOptions?: { signal?: AbortSignal },\n ) {\n const validatedInput = await validateSchemaValue(inputSchema, input)\n const output = await execute(validatedInput, {\n abortSignal: executionOptions?.signal,\n context: options.context,\n emitCustomEvent() {},\n })\n return validateSchemaValue(outputSchema, output)\n },\n }\n })\n\n const registrationController = new AbortController()\n const abortRegistration = () =>\n registrationController.abort(options.signal.reason)\n\n if (options.signal.aborted) {\n abortRegistration()\n } else {\n options.signal.addEventListener('abort', abortRegistration, { once: true })\n }\n\n try {\n for (const tool of webMCPTools) {\n await document.modelContext.registerTool(tool, {\n signal: registrationController.signal,\n })\n }\n } catch (error) {\n registrationController.abort(error)\n options.signal.removeEventListener('abort', abortRegistration)\n throw error\n }\n}\n\n/** A tool that a page registered with WebMCP, as `getTools()` returns it. */\nexport interface WebMCPPageTool {\n name: string\n title?: string\n description: string\n /** A JSON Schema object for the tool input. */\n inputSchema?: object\n /** The origin of the document that registered the tool. */\n origin: string\n annotations?: WebMCPToolAnnotations\n}\n\ninterface WebMCPToolReader {\n getTools: () => Promise<Array<WebMCPPageTool>>\n executeTool: (\n tool: WebMCPPageTool,\n input: unknown,\n options: { signal?: AbortSignal },\n ) => Promise<string>\n addEventListener: EventTarget['addEventListener']\n removeEventListener: EventTarget['removeEventListener']\n}\n\n/** Options for {@link getWebMCPTools}. */\nexport interface GetWebMCPToolsOptions {\n /** Return `false` to skip a tool. */\n filter?: (tool: WebMCPPageTool) => boolean\n}\n\n/** Options for {@link subscribeWebMCPTools}. */\nexport interface SubscribeWebMCPToolsOptions extends GetWebMCPToolsOptions {\n /** Stops the subscription when it aborts. */\n signal: AbortSignal\n /** Receives a failure from the WebMCP `getTools()` call. */\n onError?: (error: unknown) => void\n}\n\nfunction isWebMCPToolReader(value: unknown): value is WebMCPToolReader {\n return (\n value !== null &&\n typeof value === 'object' &&\n 'getTools' in value &&\n typeof value.getTools === 'function' &&\n 'executeTool' in value &&\n typeof value.executeTool === 'function' &&\n 'addEventListener' in value &&\n typeof value.addEventListener === 'function' &&\n 'removeEventListener' in value &&\n typeof value.removeEventListener === 'function'\n )\n}\n\nfunction getWebMCPToolReader() {\n if (\n typeof document === 'undefined' ||\n (typeof isSecureContext !== 'undefined' && !isSecureContext) ||\n !('modelContext' in document) ||\n !isWebMCPToolReader(document.modelContext)\n ) {\n return undefined\n }\n return document.modelContext\n}\n\nfunction parseToolResult(result: string): unknown {\n try {\n return JSON.parse(result)\n } catch {\n return result\n }\n}\n\nasync function readWebMCPTools(\n reader: WebMCPToolReader,\n options: GetWebMCPToolsOptions | undefined,\n): Promise<Array<AnyClientTool>> {\n const pageTools = await reader.getTools()\n const names = new Set<string>()\n return pageTools\n .filter((tool) => options?.filter?.(tool) ?? true)\n .map((tool) => {\n if (names.has(tool.name)) {\n throw new Error(\n `Duplicate WebMCP tool name \"${tool.name}\". Use a filter or register tools with unique names.`,\n )\n }\n names.add(tool.name)\n return {\n __toolSide: 'client' as const,\n name: tool.name,\n description: tool.description,\n inputSchema: tool.inputSchema ?? { type: 'object' },\n async execute(input: unknown, context?: { abortSignal?: AbortSignal }) {\n const result = await reader.executeTool(\n tool,\n input,\n context?.abortSignal ? { signal: context.abortSignal } : {},\n )\n return parseToolResult(result)\n },\n }\n })\n}\n\n/**\n * Reads the WebMCP tools on the page and returns them as client tools.\n *\n * Pass the result to a chat as `tools`. Each tool runs through the WebMCP\n * `executeTool()` call. Unsupported browsers and server environments return\n * an empty array.\n *\n * @param options - A filter that skips tools.\n *\n * @example\n * ```ts\n * const tools = await getWebMCPTools({\n * filter: (tool) => tool.origin === location.origin,\n * })\n * ```\n */\nexport async function getWebMCPTools(\n options?: GetWebMCPToolsOptions,\n): Promise<Array<AnyClientTool>> {\n const reader = getWebMCPToolReader()\n return reader ? readWebMCPTools(reader, options) : []\n}\n\n/**\n * Calls `listener` with the page WebMCP tools now and after each\n * `toolchange` event, until `options.signal` aborts.\n *\n * Unsupported browsers and server environments call `listener` once with an\n * empty array. When a read fails, `options.onError` gets the error and the\n * listener keeps the last list.\n *\n * @param listener - Receives the current client tools.\n * @param options - The subscription signal, a filter, and an error callback.\n *\n * @example\n * ```ts\n * const controller = new AbortController()\n * subscribeWebMCPTools((tools) => client.updateOptions({ tools }), {\n * signal: controller.signal,\n * })\n * ```\n */\nexport function subscribeWebMCPTools(\n listener: (tools: Array<AnyClientTool>) => void,\n options: SubscribeWebMCPToolsOptions,\n): void {\n if (options.signal.aborted) return\n const reader = getWebMCPToolReader()\n if (!reader) {\n listener([])\n return\n }\n\n let latestRead = 0\n const refresh = () => {\n const read = ++latestRead\n const isCurrent = () => read === latestRead && !options.signal.aborted\n readWebMCPTools(reader, options).then(\n (tools) => {\n if (isCurrent()) listener(tools)\n },\n (error: unknown) => {\n if (isCurrent()) options.onError?.(error)\n },\n )\n }\n\n // Remove the listener by hand: Zone.js breaks the `signal` listener option.\n reader.addEventListener('toolchange', refresh)\n options.signal.addEventListener(\n 'abort',\n () => reader.removeEventListener('toolchange', refresh),\n { once: true },\n )\n refresh()\n}\n"],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tanstack/ai-client",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.35.1",
|
|
4
4
|
"description": "Framework-agnostic headless client for TanStack AI chat, realtime sessions, streaming transports, and media generations.",
|
|
5
5
|
"author": "Tanner Linsley",
|
|
6
6
|
"license": "MIT",
|
|
@@ -57,8 +57,8 @@
|
|
|
57
57
|
"src"
|
|
58
58
|
],
|
|
59
59
|
"dependencies": {
|
|
60
|
-
"@tanstack/ai": "^0.
|
|
61
|
-
"@tanstack/ai-event-client": "^0.
|
|
60
|
+
"@tanstack/ai": "^0.61.0",
|
|
61
|
+
"@tanstack/ai-event-client": "^0.13.0",
|
|
62
62
|
"@tanstack/ai-utils": "^0.4.1"
|
|
63
63
|
},
|
|
64
64
|
"devDependencies": {
|
package/src/chat-client.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
|
+
EventType,
|
|
2
3
|
StreamProcessor,
|
|
3
4
|
convertSchemaToJsonSchema,
|
|
4
5
|
generateMessageId,
|
|
@@ -72,6 +73,7 @@ import type {
|
|
|
72
73
|
QueueStrategy,
|
|
73
74
|
QueuedMessage,
|
|
74
75
|
SendMessageOptions,
|
|
76
|
+
SubagentHandle,
|
|
75
77
|
ToolCallPart,
|
|
76
78
|
UIMessage,
|
|
77
79
|
WhenBusy,
|
|
@@ -328,13 +330,71 @@ const REJOIN_CONNECT_DEADLINE_MS = 2000
|
|
|
328
330
|
const REJOIN_REBUILD_TRIGGERS = new Set<string>([
|
|
329
331
|
'TEXT_MESSAGE_START',
|
|
330
332
|
'TEXT_MESSAGE_CONTENT',
|
|
333
|
+
'REASONING_MESSAGE_CONTENT',
|
|
331
334
|
'TOOL_CALL_START',
|
|
332
335
|
'MESSAGES_SNAPSHOT',
|
|
336
|
+
// Drop the hydrated card before this chunk creates it again. A subagent
|
|
337
|
+
// turn may have no parent text, so the text triggers arrive too late.
|
|
338
|
+
'SUBAGENT_STARTED',
|
|
333
339
|
])
|
|
334
340
|
|
|
341
|
+
function rebuildsAssistantMessage(chunk: StreamChunk): boolean {
|
|
342
|
+
if (chunk.type === 'REASONING_ENCRYPTED_VALUE') {
|
|
343
|
+
return (
|
|
344
|
+
chunk.subtype === 'message' &&
|
|
345
|
+
typeof chunk.encryptedValue === 'string' &&
|
|
346
|
+
chunk.encryptedValue.length > 0
|
|
347
|
+
)
|
|
348
|
+
}
|
|
349
|
+
if (chunk.type === 'STEP_FINISHED') {
|
|
350
|
+
return 'signature' in chunk && Boolean(chunk.signature)
|
|
351
|
+
}
|
|
352
|
+
return REJOIN_REBUILD_TRIGGERS.has(chunk.type)
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
type SubagentCard = Extract<UIMessage['parts'][number], { type: 'subagent' }>
|
|
356
|
+
|
|
357
|
+
/** Every subagent card in the messages, nested cards included. */
|
|
358
|
+
function collectSubagentParts(
|
|
359
|
+
messages: ReadonlyArray<UIMessage>,
|
|
360
|
+
): Array<SubagentCard> {
|
|
361
|
+
const cards: Array<SubagentCard> = []
|
|
362
|
+
for (const message of messages) {
|
|
363
|
+
for (const part of message.parts) {
|
|
364
|
+
if (part.type !== 'subagent') continue
|
|
365
|
+
cards.push(part)
|
|
366
|
+
cards.push(...collectSubagentParts(part.subagent.messages))
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
return cards
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function readSubagentRunId(chunk: StreamChunk) {
|
|
373
|
+
if ('subagentRunId' in chunk && typeof chunk.subagentRunId === 'string') {
|
|
374
|
+
return chunk.subagentRunId
|
|
375
|
+
}
|
|
376
|
+
return undefined
|
|
377
|
+
}
|
|
378
|
+
|
|
335
379
|
// Cap parent-chain walks so a cyclic replayed parentRunId cannot loop.
|
|
336
380
|
const MAX_RUN_LINEAGE_DEPTH = 64
|
|
337
381
|
|
|
382
|
+
/**
|
|
383
|
+
* A deep copy of plain data. Dates stay Dates. Function-valued properties are
|
|
384
|
+
* dropped.
|
|
385
|
+
*/
|
|
386
|
+
function copyForDevtools<T>(value: T): T
|
|
387
|
+
function copyForDevtools(value: unknown): unknown {
|
|
388
|
+
if (Array.isArray(value)) return value.map(copyForDevtools)
|
|
389
|
+
if (value instanceof Date) return new Date(value.getTime())
|
|
390
|
+
if (value === null || typeof value !== 'object') return value
|
|
391
|
+
const out: Record<string, unknown> = {}
|
|
392
|
+
for (const [key, item] of Object.entries(value)) {
|
|
393
|
+
if (typeof item !== 'function') out[key] = copyForDevtools(item)
|
|
394
|
+
}
|
|
395
|
+
return out
|
|
396
|
+
}
|
|
397
|
+
|
|
338
398
|
export class ChatClient<
|
|
339
399
|
TTools extends ReadonlyArray<AnyClientTool> = any,
|
|
340
400
|
TContext = unknown,
|
|
@@ -342,6 +402,8 @@ export class ChatClient<
|
|
|
342
402
|
any,
|
|
343
403
|
> {
|
|
344
404
|
private readonly processor: StreamProcessor
|
|
405
|
+
private readonly subagentHandles = new Map<string, SubagentHandle>()
|
|
406
|
+
private readonly stoppedSubagentIds = new Set<string>()
|
|
345
407
|
private connection: SubscribeConnectionAdapter
|
|
346
408
|
private uniqueId: string
|
|
347
409
|
private threadId: string
|
|
@@ -459,6 +521,8 @@ export class ChatClient<
|
|
|
459
521
|
private readonly activeRunIds = new Set<string>()
|
|
460
522
|
/** Latched by `dispose()`; stops any late async callback starting new work. */
|
|
461
523
|
private disposed = false
|
|
524
|
+
/** The error a failed mount hydration set, cleared by the next successful one. */
|
|
525
|
+
private hydrationError: Error | undefined
|
|
462
526
|
/** Whether a view is currently watching. See `attach` / `detach`. */
|
|
463
527
|
private tailing = false
|
|
464
528
|
/** Constructor inputs `attach()` needs on every re-attach, not just the first. */
|
|
@@ -669,9 +733,16 @@ export class ChatClient<
|
|
|
669
733
|
: {}),
|
|
670
734
|
...(initialMessages ? { initialMessages } : {}),
|
|
671
735
|
events: {
|
|
672
|
-
onMessagesChange: (messages
|
|
736
|
+
onMessagesChange: (messages) => {
|
|
737
|
+
// Restored or replaced messages bring their own cards. Give each one
|
|
738
|
+
// its live handle before anyone reads the messages.
|
|
739
|
+
this.syncSubagentHandles()
|
|
673
740
|
this.persistor?.notifyMessagesChanged(messages)
|
|
674
741
|
this.callbacksRef.current.onMessagesChange(messages)
|
|
742
|
+
// The bridge only snapshots on status changes, and the panel draws a
|
|
743
|
+
// child card from the snapshot, so without this the card would update
|
|
744
|
+
// only when the parent run ends.
|
|
745
|
+
this.queueDevtoolsSnapshot()
|
|
675
746
|
},
|
|
676
747
|
onStreamStart: () => {
|
|
677
748
|
this.setStatus('streaming')
|
|
@@ -887,6 +958,8 @@ export class ChatClient<
|
|
|
887
958
|
},
|
|
888
959
|
},
|
|
889
960
|
})
|
|
961
|
+
// `initialMessages` do not fire a change event. Give their cards handles.
|
|
962
|
+
this.syncSubagentHandles()
|
|
890
963
|
|
|
891
964
|
this.persistor?.hydrateAsync(persistedState)
|
|
892
965
|
|
|
@@ -1068,7 +1141,10 @@ export class ChatClient<
|
|
|
1068
1141
|
const generation = this.historyGeneration
|
|
1069
1142
|
try {
|
|
1070
1143
|
result = await hydrate(this.threadId, hydrateOptions)
|
|
1071
|
-
} catch {
|
|
1144
|
+
} catch (cause) {
|
|
1145
|
+
// Same staleness guard as the success path below: a failure from an
|
|
1146
|
+
// older attempt must not touch the state of a newer one.
|
|
1147
|
+
if (generation === this.historyGeneration) this.failHydration(cause)
|
|
1072
1148
|
return
|
|
1073
1149
|
}
|
|
1074
1150
|
if (generation !== this.historyGeneration) return
|
|
@@ -1084,6 +1160,13 @@ export class ChatClient<
|
|
|
1084
1160
|
if (this.disposed || !this.tailing) return
|
|
1085
1161
|
// A send may have started while the fetch was in flight — don't stomp it.
|
|
1086
1162
|
if (this.isLoading || this.abortController) return
|
|
1163
|
+
// A retry after a failed load succeeded: drop that failure, but not an
|
|
1164
|
+
// error something else set since.
|
|
1165
|
+
if (this.hydrationError && this.error === this.hydrationError) {
|
|
1166
|
+
this.setError(undefined)
|
|
1167
|
+
this.setStatus('ready')
|
|
1168
|
+
}
|
|
1169
|
+
this.hydrationError = undefined
|
|
1087
1170
|
this.applyHydrationPage(result.page)
|
|
1088
1171
|
if (result.messages.length > 0) {
|
|
1089
1172
|
const windowMessages = normalizeMessagesDates(result.messages)
|
|
@@ -1114,6 +1197,36 @@ export class ChatClient<
|
|
|
1114
1197
|
})()
|
|
1115
1198
|
}
|
|
1116
1199
|
|
|
1200
|
+
/**
|
|
1201
|
+
* Surface a mount-hydration failure (`persistence: true`) on the observable
|
|
1202
|
+
* fields, mirroring `GenerationClient.failHydration`, so "this thread failed
|
|
1203
|
+
* to load" is distinguishable from "this thread has no messages" and the app
|
|
1204
|
+
* can show an error / offer a retry. A genuine miss — the server having no
|
|
1205
|
+
* record for a fresh thread — resolves normally and never reaches here; only a
|
|
1206
|
+
* thrown transport / authorize-gate error does.
|
|
1207
|
+
*
|
|
1208
|
+
* Skipped when the view unmounted (`!tailing`) or a `sendMessage` took
|
|
1209
|
+
* ownership while the hydrate GET was in flight, so a live run's state always
|
|
1210
|
+
* wins over a stale mount-time failure — same guard as the success path above.
|
|
1211
|
+
*/
|
|
1212
|
+
private failHydration(cause: unknown): void {
|
|
1213
|
+
if (this.disposed || !this.tailing) return
|
|
1214
|
+
if (this.isLoading || this.abortController) return
|
|
1215
|
+
const error = cause instanceof Error ? cause : new Error(String(cause))
|
|
1216
|
+
// Mirror the send path: a BYOK key that is missing / locked must still
|
|
1217
|
+
// trigger the key-request flow on thread load, not just be reported.
|
|
1218
|
+
if (error instanceof ByokMissingError) {
|
|
1219
|
+
this.byok?.request(error.provider, 'missing')
|
|
1220
|
+
}
|
|
1221
|
+
if (error instanceof ByokBlockedError && error.reason === 'locked') {
|
|
1222
|
+
this.byok?.request(error.provider, 'locked')
|
|
1223
|
+
}
|
|
1224
|
+
this.hydrationError = error
|
|
1225
|
+
this.setStatus('error')
|
|
1226
|
+
this.setError(error)
|
|
1227
|
+
this.callbacksRef.current.onError(error)
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1117
1230
|
mountDevtools(): void {
|
|
1118
1231
|
this.ensureThreadId()
|
|
1119
1232
|
if (this.devtoolsMounted) {
|
|
@@ -1693,7 +1806,9 @@ export class ChatClient<
|
|
|
1693
1806
|
|
|
1694
1807
|
private getDevtoolsSnapshot(): AIDevtoolsChatSnapshot {
|
|
1695
1808
|
return {
|
|
1696
|
-
|
|
1809
|
+
// The devtools store keeps the objects it gets, and a subagent handle
|
|
1810
|
+
// changes in place. A copy lets the panel see each new status and part.
|
|
1811
|
+
messages: copyForDevtools(this.processor.getMessages()),
|
|
1697
1812
|
status: this.status,
|
|
1698
1813
|
isLoading: this.isLoading,
|
|
1699
1814
|
isSubscribed: this.isSubscribed,
|
|
@@ -1865,7 +1980,7 @@ export class ChatClient<
|
|
|
1865
1980
|
attached = true
|
|
1866
1981
|
clearTimeout(connectTimer)
|
|
1867
1982
|
}
|
|
1868
|
-
if (!rebuilt &&
|
|
1983
|
+
if (!rebuilt && rebuildsAssistantMessage(chunk)) {
|
|
1869
1984
|
rebuilt = true
|
|
1870
1985
|
this.dropTrailingInFlightAssistant()
|
|
1871
1986
|
}
|
|
@@ -1970,7 +2085,18 @@ export class ChatClient<
|
|
|
1970
2085
|
}
|
|
1971
2086
|
this.callbacksRef.current.onChunk(chunk)
|
|
1972
2087
|
this.devtoolsBridge.observeChunk(chunk)
|
|
2088
|
+
const attributedId = readSubagentRunId(chunk)
|
|
2089
|
+
// A resumed child starts again under the same id, so its start lifts the stop.
|
|
2090
|
+
if (attributedId && chunk.type === EventType.SUBAGENT_STARTED) {
|
|
2091
|
+
this.stoppedSubagentIds.delete(attributedId)
|
|
2092
|
+
}
|
|
2093
|
+
if (attributedId && this.stoppedSubagentIds.has(attributedId)) {
|
|
2094
|
+
this.updateRunLifecycle(chunk)
|
|
2095
|
+
this.resolveJoinedRun(chunk)
|
|
2096
|
+
return
|
|
2097
|
+
}
|
|
1973
2098
|
this.processor.processChunk(chunk)
|
|
2099
|
+
this.syncSubagentHandles()
|
|
1974
2100
|
this.updateRunLifecycle(chunk)
|
|
1975
2101
|
this.observeInterruptState(chunk)
|
|
1976
2102
|
// Live path: yield a macrotask so the UI can paint. Skip when the page is
|
|
@@ -2678,6 +2804,7 @@ export class ChatClient<
|
|
|
2678
2804
|
*/
|
|
2679
2805
|
stop(): void {
|
|
2680
2806
|
// Invalidate deferred work from the stopped continuation.
|
|
2807
|
+
// This aborts the local request. A durable server run keeps going.
|
|
2681
2808
|
this.continuationGeneration++
|
|
2682
2809
|
const hadLocalStream = this.abortController !== null
|
|
2683
2810
|
this.cancelInFlightStream({ setReadyStatus: true })
|
|
@@ -2715,6 +2842,7 @@ export class ChatClient<
|
|
|
2715
2842
|
this.resetHistoryPaging()
|
|
2716
2843
|
this.discardPendingSends()
|
|
2717
2844
|
this.persistor?.remove()
|
|
2845
|
+
this.stoppedSubagentIds.clear()
|
|
2718
2846
|
this.lastResume = null
|
|
2719
2847
|
this.interruptManager.reset()
|
|
2720
2848
|
this.pendingResumeThreadId = null
|
|
@@ -2974,6 +3102,72 @@ export class ChatClient<
|
|
|
2974
3102
|
return this.processor.getMessages() as Array<UIMessage<TTools>>
|
|
2975
3103
|
}
|
|
2976
3104
|
|
|
3105
|
+
getSubagents() {
|
|
3106
|
+
return [...this.subagentHandles.values()]
|
|
3107
|
+
}
|
|
3108
|
+
|
|
3109
|
+
private devtoolsSnapshotQueued = false
|
|
3110
|
+
|
|
3111
|
+
/** One devtools snapshot per microtask, however many chunks arrive. */
|
|
3112
|
+
private queueDevtoolsSnapshot(): void {
|
|
3113
|
+
if (this.devtoolsSnapshotQueued) return
|
|
3114
|
+
this.devtoolsSnapshotQueued = true
|
|
3115
|
+
queueMicrotask(() => {
|
|
3116
|
+
this.devtoolsSnapshotQueued = false
|
|
3117
|
+
this.devtoolsBridge.emitSnapshot()
|
|
3118
|
+
})
|
|
3119
|
+
}
|
|
3120
|
+
|
|
3121
|
+
private syncSubagentHandles(): void {
|
|
3122
|
+
// Every card, nested ones included, gets a handle.
|
|
3123
|
+
const cards = collectSubagentParts(this.processor.getMessages())
|
|
3124
|
+
const present = new Set(cards.map((part) => part.subagent.id))
|
|
3125
|
+
// A card that left the messages (clear, reload) loses its handle.
|
|
3126
|
+
for (const id of this.subagentHandles.keys()) {
|
|
3127
|
+
if (!present.has(id)) this.subagentHandles.delete(id)
|
|
3128
|
+
}
|
|
3129
|
+
for (const part of cards) {
|
|
3130
|
+
const id = part.subagent.id
|
|
3131
|
+
const existing = this.subagentHandles.get(id)
|
|
3132
|
+
if (existing) {
|
|
3133
|
+
if (part.subagent === existing) continue
|
|
3134
|
+
// Keep the same live object, with exactly the card's fields. The
|
|
3135
|
+
// wire reads this object, so a field the card dropped goes too.
|
|
3136
|
+
const { stop } = existing
|
|
3137
|
+
for (const key of Object.keys(existing)) {
|
|
3138
|
+
Reflect.deleteProperty(existing, key)
|
|
3139
|
+
}
|
|
3140
|
+
Object.assign(existing, part.subagent, { stop })
|
|
3141
|
+
part.subagent = existing
|
|
3142
|
+
continue
|
|
3143
|
+
}
|
|
3144
|
+
const handle: SubagentHandle = {
|
|
3145
|
+
...part.subagent,
|
|
3146
|
+
stop: () => {
|
|
3147
|
+
this.stopSubagent(id)
|
|
3148
|
+
},
|
|
3149
|
+
}
|
|
3150
|
+
this.subagentHandles.set(id, handle)
|
|
3151
|
+
part.subagent = handle
|
|
3152
|
+
}
|
|
3153
|
+
}
|
|
3154
|
+
|
|
3155
|
+
private stopSubagent(id: string): void {
|
|
3156
|
+
const handle = this.subagentHandles.get(id)
|
|
3157
|
+
if (!handle || handle.status !== 'running') return
|
|
3158
|
+
this.stoppedSubagentIds.add(id)
|
|
3159
|
+
this.processor.processChunk({
|
|
3160
|
+
type: EventType.SUBAGENT_ERROR,
|
|
3161
|
+
subagentRunId: id,
|
|
3162
|
+
message: 'Stopped',
|
|
3163
|
+
timestamp: Date.now(),
|
|
3164
|
+
})
|
|
3165
|
+
this.syncSubagentHandles()
|
|
3166
|
+
handle.status = 'error'
|
|
3167
|
+
handle.error = { message: 'Stopped' }
|
|
3168
|
+
this.abortController?.abort()
|
|
3169
|
+
}
|
|
3170
|
+
|
|
2977
3171
|
/**
|
|
2978
3172
|
* True when the last hydrate or older-page response said more messages exist.
|
|
2979
3173
|
*/
|
package/src/index.ts
CHANGED
|
@@ -14,9 +14,16 @@ export type {
|
|
|
14
14
|
} from './interrupt-manager'
|
|
15
15
|
export { createMcpAppBridge } from './mcp-app-bridge'
|
|
16
16
|
export type { McpAppBridge, CreateMcpAppBridgeOptions } from './mcp-app-bridge'
|
|
17
|
-
export {
|
|
17
|
+
export {
|
|
18
|
+
getWebMCPTools,
|
|
19
|
+
registerWebMCPTools,
|
|
20
|
+
subscribeWebMCPTools,
|
|
21
|
+
} from './web-mcp-tools'
|
|
18
22
|
export type {
|
|
23
|
+
GetWebMCPToolsOptions,
|
|
19
24
|
RegisterWebMCPToolsOptions,
|
|
25
|
+
SubscribeWebMCPToolsOptions,
|
|
26
|
+
WebMCPPageTool,
|
|
20
27
|
WebMCPToolAnnotations,
|
|
21
28
|
WebMCPToolOptions,
|
|
22
29
|
WebMCPToolOptionsByName,
|
|
@@ -33,6 +40,13 @@ export type {
|
|
|
33
40
|
ToolResultPart,
|
|
34
41
|
ThinkingPart,
|
|
35
42
|
StructuredOutputPart,
|
|
43
|
+
SubagentPart,
|
|
44
|
+
SubagentPartOf,
|
|
45
|
+
SubagentHandle,
|
|
46
|
+
SubagentHandleOf,
|
|
47
|
+
SubagentHandles,
|
|
48
|
+
SubagentClientAgent,
|
|
49
|
+
SubagentStatus,
|
|
36
50
|
// Client configuration types
|
|
37
51
|
ChatClientPersistence,
|
|
38
52
|
ChatPersistedState,
|