@tanstack/ai-solid 0.19.4 → 0.21.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.
@@ -0,0 +1,48 @@
1
+ import remarkGfm from 'remark-gfm'
2
+ import rehypeRaw from 'rehype-raw'
3
+ import rehypeSanitize from 'rehype-sanitize'
4
+ import rehypeHighlight from 'rehype-highlight'
5
+ import type { SolidMarkdownOptions } from 'solid-markdown'
6
+
7
+ export type PluggableList = SolidMarkdownOptions['remarkPlugins']
8
+
9
+ export const DEFAULT_REMARK_PLUGINS: PluggableList = [remarkGfm]
10
+ export const DEFAULT_REHYPE_PLUGINS_BEFORE_USER: PluggableList = [
11
+ rehypeRaw,
12
+ rehypeHighlight,
13
+ ]
14
+ export const DEFAULT_REHYPE_PLUGINS_AFTER_USER: PluggableList = [rehypeSanitize]
15
+
16
+ export interface ResolveMarkdownPluginsOptions {
17
+ remarkPlugins?: PluggableList
18
+ rehypePlugins?: PluggableList
19
+ disableDefaultPlugins?: boolean
20
+ }
21
+
22
+ export interface ResolvedMarkdownPlugins {
23
+ remarkPlugins: PluggableList
24
+ rehypePlugins: PluggableList
25
+ }
26
+
27
+ export function resolveMarkdownPlugins(
28
+ options: ResolveMarkdownPluginsOptions,
29
+ ): ResolvedMarkdownPlugins {
30
+ const userRemark = options.remarkPlugins ?? []
31
+ const userRehype = options.rehypePlugins ?? []
32
+
33
+ if (options.disableDefaultPlugins) {
34
+ return {
35
+ remarkPlugins: [...userRemark],
36
+ rehypePlugins: [...userRehype],
37
+ }
38
+ }
39
+
40
+ return {
41
+ remarkPlugins: [...DEFAULT_REMARK_PLUGINS, ...userRemark],
42
+ rehypePlugins: [
43
+ ...DEFAULT_REHYPE_PLUGINS_BEFORE_USER,
44
+ ...userRehype,
45
+ ...DEFAULT_REHYPE_PLUGINS_AFTER_USER,
46
+ ],
47
+ }
48
+ }
@@ -0,0 +1,71 @@
1
+ import { SolidMarkdown } from 'solid-markdown'
2
+ import { resolveMarkdownPlugins } from './markdown-plugins'
3
+ import type { SolidMarkdownComponents } from 'solid-markdown'
4
+ import type { PluggableList } from './markdown-plugins'
5
+
6
+ export interface TextPartProps {
7
+ /** The text content to render */
8
+ content: string
9
+ /** The role of the message (user, assistant, or system) - optional for standalone use */
10
+ role?: 'user' | 'assistant' | 'system'
11
+ /** Base class applied to all text parts */
12
+ class?: string
13
+ /** Additional class for user messages */
14
+ userClass?: string
15
+ /** Additional class for assistant messages (also used for system messages) */
16
+ assistantClass?: string
17
+ /** Additional remark plugins, appended after the defaults. */
18
+ remarkPlugins?: PluggableList
19
+ /**
20
+ * Additional rehype plugins. Inserted before the trailing
21
+ * `rehypeSanitize` so sanitization always runs last.
22
+ */
23
+ rehypePlugins?: PluggableList
24
+ /** solid-markdown `components` overrides. */
25
+ components?: SolidMarkdownComponents
26
+ /**
27
+ * Drop the built-in plugin defaults entirely. Disables the XSS
28
+ * sanitizer; the caller becomes responsible for sanitization.
29
+ */
30
+ disableDefaultPlugins?: boolean
31
+ }
32
+
33
+ /**
34
+ * TextPart component - renders markdown text with syntax highlighting.
35
+ *
36
+ * @example Add a markdown plugin (e.g. CJK bold/emphasis support)
37
+ * ```tsx
38
+ * import remarkCjkFriendly from 'remark-cjk-friendly'
39
+ *
40
+ * <TextPart content={content} remarkPlugins={[remarkCjkFriendly]} />
41
+ * ```
42
+ */
43
+ export function TextPart(props: TextPartProps) {
44
+ const roleClass = () =>
45
+ props.role === 'user'
46
+ ? (props.userClass ?? '')
47
+ : props.role === 'assistant'
48
+ ? (props.assistantClass ?? '')
49
+ : ''
50
+ const combinedClass = () =>
51
+ [props.class ?? '', roleClass()].filter(Boolean).join(' ')
52
+
53
+ const resolved = () =>
54
+ resolveMarkdownPlugins({
55
+ remarkPlugins: props.remarkPlugins,
56
+ rehypePlugins: props.rehypePlugins,
57
+ disableDefaultPlugins: props.disableDefaultPlugins,
58
+ })
59
+
60
+ return (
61
+ <div class={combinedClass() || undefined}>
62
+ <SolidMarkdown
63
+ remarkPlugins={resolved().remarkPlugins}
64
+ rehypePlugins={resolved().rehypePlugins}
65
+ components={props.components}
66
+ >
67
+ {props.content}
68
+ </SolidMarkdown>
69
+ </div>
70
+ )
71
+ }
@@ -0,0 +1,79 @@
1
+ import { Show, createEffect, createSignal } from 'solid-js'
2
+
3
+ export interface ThinkingPartProps {
4
+ /** The thinking content to render */
5
+ content: string
6
+ /** Base class applied to thinking parts */
7
+ class?: string
8
+ /** Whether thinking is complete (has text content after) */
9
+ isComplete?: boolean
10
+ }
11
+
12
+ /**
13
+ * ThinkingPart component - renders thinking/reasoning content
14
+ *
15
+ * This component displays the model's internal reasoning process,
16
+ * typically shown in a collapsed or expandable format to distinguish
17
+ * it from the final response. It automatically collapses when thinking
18
+ * is complete.
19
+ *
20
+ * @example Standalone usage
21
+ * ```tsx
22
+ * <ThinkingPart
23
+ * content="Let me think about this step by step..."
24
+ * class="p-4 rounded bg-gray-100"
25
+ * />
26
+ * ```
27
+ *
28
+ * @example Usage in partRenderers
29
+ * ```tsx
30
+ * <ChatMessage
31
+ * message={message}
32
+ * partRenderers={{
33
+ * thinking: ({ content, isComplete }) => (
34
+ * <ThinkingPart
35
+ * content={content}
36
+ * isComplete={isComplete}
37
+ * class="px-5 py-3 rounded-2xl bg-gray-800/50"
38
+ * />
39
+ * )
40
+ * }}
41
+ * />
42
+ * ```
43
+ */
44
+ export function ThinkingPart(props: ThinkingPartProps) {
45
+ const [isCollapsed, setIsCollapsed] = createSignal(false)
46
+
47
+ // Auto-collapse when thinking completes
48
+ createEffect(() => {
49
+ if (props.isComplete) {
50
+ setIsCollapsed(true)
51
+ }
52
+ })
53
+
54
+ return (
55
+ <div
56
+ class={props.class || undefined}
57
+ data-part-type="thinking"
58
+ data-part-content
59
+ >
60
+ <button
61
+ onClick={() => setIsCollapsed(!isCollapsed())}
62
+ class="flex items-center gap-2 text-sm text-gray-400 hover:text-gray-300 transition-colors mb-2"
63
+ aria-expanded={!isCollapsed()}
64
+ aria-label={isCollapsed() ? 'Expand thinking' : 'Collapse thinking'}
65
+ >
66
+ <span class="text-xs">{isCollapsed() ? '▶' : '▼'}</span>
67
+ <span class="italic">💭 Thinking...</span>
68
+ <Show when={props.isComplete}>
69
+ <span class="text-xs text-gray-500">(complete)</span>
70
+ </Show>
71
+ </button>
72
+ <Show when={!isCollapsed()}>
73
+ <div class="text-gray-300 whitespace-pre-wrap font-mono text-sm">
74
+ {props.content}
75
+ </div>
76
+ </Show>
77
+ </div>
78
+ )
79
+ }
@@ -0,0 +1,121 @@
1
+ import { useChatContext } from './chat'
2
+ import type { JSX } from 'solid-js'
3
+
4
+ /** @deprecated Use `createChatUI()` interrupt components with `chat.interrupts`. Deprecated in 0.8.0. Removed in 1.0.0. */
5
+ export interface ToolApprovalProps {
6
+ /** Tool call ID */
7
+ toolCallId: string
8
+ /** Tool name */
9
+ toolName: string
10
+ /** Parsed tool arguments/input */
11
+ input: any
12
+ /** Approval metadata */
13
+ approval: {
14
+ id: string
15
+ needsApproval: boolean
16
+ approved?: boolean
17
+ }
18
+ /** CSS class name */
19
+ class?: string
20
+ /** Custom render prop */
21
+ children?: (props: ToolApprovalRenderProps) => JSX.Element
22
+ }
23
+
24
+ export interface ToolApprovalRenderProps {
25
+ /** Tool name */
26
+ toolName: string
27
+ /** Parsed input */
28
+ input: any
29
+ /** Approve the tool call */
30
+ onApprove: () => void
31
+ /** Deny the tool call */
32
+ onDeny: () => void
33
+ /** Whether user has responded */
34
+ hasResponded: boolean
35
+ /** User's decision (if responded) */
36
+ approved?: boolean
37
+ }
38
+
39
+ /**
40
+ * @deprecated Use `createChatUI()` interrupt components with `chat.interrupts`. Deprecated in 0.8.0. Removed in 1.0.0.
41
+ *
42
+ * Tool approval component - renders approve/deny buttons for tools that need approval
43
+ *
44
+ * @example
45
+ * ```tsx
46
+ * {part.approval && (
47
+ * <ToolApproval
48
+ * toolCallId={part.id}
49
+ * toolName={part.name}
50
+ * input={JSON.parse(part.arguments)}
51
+ * approval={part.approval}
52
+ * />
53
+ * )}
54
+ * ```
55
+ */
56
+ export function ToolApproval(props: ToolApprovalProps) {
57
+ const { addToolApprovalResponse } = useChatContext()
58
+
59
+ const handleApprove = () => {
60
+ void addToolApprovalResponse({
61
+ id: props.approval.id,
62
+ approved: true,
63
+ })
64
+ }
65
+
66
+ const handleDeny = () => {
67
+ void addToolApprovalResponse({
68
+ id: props.approval.id,
69
+ approved: false,
70
+ })
71
+ }
72
+
73
+ const hasResponded = () => props.approval.approved !== undefined
74
+
75
+ const renderProps = (): ToolApprovalRenderProps => ({
76
+ toolName: props.toolName,
77
+ input: props.input,
78
+ onApprove: handleApprove,
79
+ onDeny: handleDeny,
80
+ hasResponded: hasResponded(),
81
+ approved: props.approval.approved,
82
+ })
83
+
84
+ // Render prop pattern
85
+ if (props.children) {
86
+ return <>{props.children(renderProps())}</>
87
+ }
88
+
89
+ // Already responded - show decision
90
+ if (hasResponded()) {
91
+ return (
92
+ <div
93
+ class={props.class}
94
+ data-tool-approval
95
+ data-approval-status={props.approval.approved ? 'approved' : 'denied'}
96
+ >
97
+ {props.approval.approved ? '✓ Approved' : '✗ Denied'}
98
+ </div>
99
+ )
100
+ }
101
+
102
+ // Default approval UI
103
+ return (
104
+ <div class={props.class} data-tool-approval data-approval-status="pending">
105
+ <div data-approval-header>
106
+ <strong>{props.toolName}</strong> requires approval
107
+ </div>
108
+ <div data-approval-input>
109
+ <pre>{JSON.stringify(props.input, null, 2)}</pre>
110
+ </div>
111
+ <div data-approval-actions>
112
+ <button onClick={handleApprove} data-approval-approve>
113
+ Approve
114
+ </button>
115
+ <button onClick={handleDeny} data-approval-deny>
116
+ Deny
117
+ </button>
118
+ </div>
119
+ </div>
120
+ )
121
+ }
@@ -0,0 +1,67 @@
1
+ import { useChat as useUnboundChat } from './use-chat'
2
+ import type {
3
+ AnyClientTool,
4
+ InterruptDefinition,
5
+ SchemaInput,
6
+ } from '@tanstack/ai'
7
+ import type { InferredClientContext } from '@tanstack/ai-client'
8
+ import type { UseChatOptions } from './types'
9
+
10
+ type ChatHookOverrides<
11
+ TTools extends ReadonlyArray<AnyClientTool>,
12
+ TSchema extends SchemaInput | undefined,
13
+ TContext,
14
+ TInterrupts extends ReadonlyArray<InterruptDefinition<any, any, any, any>>,
15
+ > = {
16
+ threadId?: string
17
+ live?: boolean
18
+ forwardedProps?: Record<string, any>
19
+ body?: Record<string, any>
20
+ initialMessages?: UseChatOptions<
21
+ TTools,
22
+ TSchema,
23
+ TContext,
24
+ TInterrupts
25
+ >['initialMessages']
26
+ }
27
+
28
+ /**
29
+ * Bind chat options once at module scope. The returned `useChat` primitive
30
+ * creates a chat instance from those options.
31
+ *
32
+ * Pass per-call overrides for instance keys such as `threadId`,
33
+ * `initialMessages`, `live`, and `forwardedProps`. Do not change `tools`,
34
+ * `interrupts`, or `outputSchema` here. Those stay on the factory options.
35
+ *
36
+ * Rename the primitive at the call site if you already import `useChat` from
37
+ * `@tanstack/ai-solid`: `const { useChat: useAppChat } = createChatHook(chatOptions)`.
38
+ *
39
+ * @example
40
+ * ```tsx
41
+ * const { useChat } = createChatHook(chatOptions)
42
+ *
43
+ * function Support() {
44
+ * const chat = useChat({ threadId: 'support-1' })
45
+ * return <UI.Chat chat={chat} components={components} />
46
+ * }
47
+ * ```
48
+ */
49
+ export function createChatHook<
50
+ const TTools extends ReadonlyArray<AnyClientTool> = any,
51
+ TSchema extends SchemaInput | undefined = undefined,
52
+ TContext = InferredClientContext<TTools>,
53
+ const TInterrupts extends ReadonlyArray<
54
+ InterruptDefinition<any, any, any, any>
55
+ > = readonly [],
56
+ >(options: UseChatOptions<TTools, TSchema, TContext, TInterrupts>) {
57
+ function useChat(
58
+ overrides?: ChatHookOverrides<TTools, TSchema, TContext, TInterrupts>,
59
+ ) {
60
+ if (!overrides) {
61
+ return useUnboundChat(options)
62
+ }
63
+ return useUnboundChat({ ...options, ...overrides })
64
+ }
65
+
66
+ return { useChat }
67
+ }
package/src/index.ts CHANGED
@@ -1,5 +1,8 @@
1
1
  export { useChat } from './use-chat'
2
+ export { createChatHook } from './create-chat-hook'
2
3
  export { useByok } from './use-byok'
4
+ export { useWebMCPTools } from './use-web-mcp-tools'
5
+ export type { UseWebMCPToolsOptions } from './use-web-mcp-tools'
3
6
  export type {
4
7
  DeepPartial,
5
8
  UseChatOptions,
package/src/ui.ts ADDED
@@ -0,0 +1,39 @@
1
+ // Barrel entry for the `@tanstack/ai-solid/ui` subpath.
2
+ export {
3
+ createChatUI,
4
+ type ChatUIComponents,
5
+ type ChatUIFactoryConfig,
6
+ type ChatUIHost,
7
+ type ChatUIQueueItem,
8
+ type InputProps,
9
+ type InterruptProps,
10
+ type LayoutProps,
11
+ type MessageProps,
12
+ type PartProps,
13
+ type QueueProps,
14
+ type ToolProps,
15
+ } from './chat-ui/create-ui'
16
+ export { createChatHook } from './chat-ui/create-chat-hook'
17
+ export {
18
+ createChatHookContexts,
19
+ type ChatUIContexts,
20
+ } from './chat-ui/create-ui-contexts'
21
+ export { Chat, useChatContext, type ChatProps } from './chat-ui/chat'
22
+ export { ChatMessages, type ChatMessagesProps } from './chat-ui/chat-messages'
23
+ export {
24
+ ChatMessage,
25
+ type ChatMessageProps,
26
+ type ToolCallRenderProps,
27
+ } from './chat-ui/chat-message'
28
+ export {
29
+ ChatInput,
30
+ type ChatInputProps,
31
+ type ChatInputRenderProps,
32
+ } from './chat-ui/chat-input'
33
+ export {
34
+ ToolApproval,
35
+ type ToolApprovalProps,
36
+ type ToolApprovalRenderProps,
37
+ } from './chat-ui/tool-approval'
38
+ export { TextPart, type TextPartProps } from './chat-ui/text-part'
39
+ export { ThinkingPart, type ThinkingPartProps } from './chat-ui/thinking-part'
@@ -0,0 +1,55 @@
1
+ import { onCleanup } from 'solid-js'
2
+ import { registerWebMCPTools } from '@tanstack/ai-client'
3
+ import type {
4
+ AnyClientTool,
5
+ InferredClientContext,
6
+ RegisterWebMCPToolsOptions,
7
+ } from '@tanstack/ai-client'
8
+
9
+ /** Options for {@link useWebMCPTools}. */
10
+ export type UseWebMCPToolsOptions<
11
+ TTools extends ReadonlyArray<AnyClientTool>,
12
+ TContext = InferredClientContext<TTools>,
13
+ > = Omit<RegisterWebMCPToolsOptions<TTools, TContext>, 'signal'> & {
14
+ /** Receives an asynchronous registration error. */
15
+ onError?: (error: unknown) => void
16
+ }
17
+
18
+ type UseWebMCPToolsArguments<
19
+ TTools extends ReadonlyArray<AnyClientTool>,
20
+ TContext,
21
+ > =
22
+ {} extends UseWebMCPToolsOptions<TTools, TContext>
23
+ ? [options?: UseWebMCPToolsOptions<TTools, TContext>]
24
+ : [options: UseWebMCPToolsOptions<TTools, TContext>]
25
+
26
+ /**
27
+ * Registers executable client tools with WebMCP for the current Solid owner.
28
+ *
29
+ * The owner cleanup removes every tool registered by this call.
30
+ *
31
+ * @param tools - The executable client tools to expose through WebMCP.
32
+ * @param options - Runtime context, per-tool options, and error handling.
33
+ *
34
+ * @example
35
+ * ```ts
36
+ * useWebMCPTools([searchProducts])
37
+ * ```
38
+ */
39
+ export function useWebMCPTools<
40
+ const TTools extends ReadonlyArray<AnyClientTool>,
41
+ TContext = InferredClientContext<TTools>,
42
+ >(tools: TTools, ...[options]: UseWebMCPToolsArguments<TTools, TContext>) {
43
+ const controller = new AbortController()
44
+
45
+ void registerWebMCPTools(tools, {
46
+ ...options,
47
+ signal: controller.signal,
48
+ }).catch((error) => {
49
+ if (!controller.signal.aborted) {
50
+ options?.onError?.(error)
51
+ }
52
+ })
53
+
54
+ onCleanup(() => controller.abort())
55
+ }