import { For, createContext, useContext } from 'solid-js' import type { Accessor, Component, Context, JSX } from 'solid-js' import { automaticPartsForMessage, collectInlineToolNames, resolveInterruptComponent, selectChatUI, selectMessageUI, } from '@tanstack/ai-client/ui' import type { ChatUIData, ChatUIHasNamedInterrupts, ChatUIHasNamedTools, ChatUIInterrupt, ChatUIInterruptName, ChatUIInterruptOf, ChatUIInterruptsOf, ChatUINamedInterruptId, ChatUIPartKey, ChatUIPartOf, ChatUISchemaOf, ChatUISelectedPart, ChatUISelectedPartOf, ChatUIToolApproval, ChatUIToolName, ChatUIToolsOf, } from '@tanstack/ai-client/ui' import type { MessagePart, QueuedMessage, ToolCallPart, ToolResultPart, UIMessage, } from '@tanstack/ai-client' import type { UseChatReturn } from '../types' import { defaultChatUIContexts } from './create-ui-contexts' import type { ChatUIContexts } from './create-ui-contexts' export type ChatUIHost = UseChatReturn< ChatUIToolsOf, ChatUISchemaOf, ChatUIInterruptsOf > export type ChatUIQueueItem = QueuedMessage & { cancelQueued: () => void } export type LayoutProps< TOptions, TInput extends Component | undefined = Component>, > = { Messages: Component Interrupts: Component Queue: Component readonly __ui?: TOptions } & (TInput extends Component ? { Input: Component } : {}) export type MessageProps = { message: UIMessage, ChatUIData> Parts: Component } export type InputProps = { readonly __ui?: TOptions } export type QueueProps = { item: ChatUIQueueItem readonly __ui?: TOptions } export type PartProps = { part: ChatUIPartOf } export type ToolProps< TOptions, TName extends ChatUIToolName = ChatUIToolName, > = { part: Extract>, { name: TName }> result?: ToolResultPart interrupt?: ChatUIToolApproval } export type InterruptProps< TOptions, TName extends ChatUIInterruptName = never, > = { interrupt: ChatUIInterruptOf readonly __ui?: TOptions } type GenericInterruptComponents = ChatUIHasNamedInterrupts extends true ? { [K in ChatUINamedInterruptId]: Component< InterruptProps> > } & { fallback?: Component> } : { fallback?: Component> } type ToolApprovalMap = { [K in ChatUIToolName]?: Component< InterruptProps> > } /** The chrome around the message list: `layout`, `message`, and `input`. */ export type ChatUIChromeComponents< TOptions, TInput extends Component> | undefined = Component< InputProps >, > = { layout: Component> message: Component> input?: TInput queue?: Component> } export type ChatUIPartsComponents = { [K in ChatUIPartKey]?: Component> } & { fallback?: Component> } export type ChatUIInterruptsComponents = { tools?: ToolApprovalMap generic: GenericInterruptComponents } export type ChatUIComponents< TOptions, TInput extends Component> | undefined = Component< InputProps >, > = { components: ChatUIChromeComponents partsComponents: ChatUIPartsComponents } & (ChatUIHasNamedTools extends true ? { toolsComponents: { [K in ChatUIToolName]: Component> } } : { toolsComponents?: { [K in ChatUIToolName]?: Component> } }) & (ChatUIHasNamedInterrupts extends true ? { interruptsComponents: ChatUIInterruptsComponents } : { interruptsComponents?: { tools?: ToolApprovalMap generic?: GenericInterruptComponents } }) /** Scoped contexts, for widgets in other files or nested chat trees. */ export type ChatUIContextConfig = { chatContext?: ChatUIContexts['chatContext'] partContext?: ChatUIContexts['partContext'] interruptContext?: ChatUIContexts['interruptContext'] } export type ChatUIFactoryConfig< TOptions, TInput extends Component> | undefined = Component< InputProps >, > = ChatUIComponents & { context?: ChatUIContextConfig } type BoundWidget = Component> type PartMixins = { [K in ChatUIPartKey]?: BoundWidget } & { [K in ChatUIToolName]: BoundWidget } & { Render: BoundWidget } type InterruptMixins = { [K in ChatUINamedInterruptId]: BoundWidget } & { [K in ChatUIToolName]?: BoundWidget } & { fallback?: BoundWidget Render: BoundWidget } // This module ships as source, so it is type-checked against the *consumer's* // tsconfig, which need not include `@types/node`. Declare `process` locally // (same shape as the `src/env.d.ts` the devtools packages use) rather than // reading it off `globalThis`: the literal `process.env.NODE_ENV` is the token // bundlers substitute, so this keeps the branch constant-folded in production. declare const process: { env: { NODE_ENV?: string } } function createWarnOnce() { const seen = new Set() return (key: string, message: string) => { if (process.env.NODE_ENV === 'production') return if (seen.has(key)) return seen.add(key) console.warn(message) } } function readMessages( chat: ChatUIHost, ): ReadonlyArray { return chat.messages() } function readInterrupts( chat: ChatUIHost, ): ReadonlyArray { return chat.interrupts() } function messagesAccessor( chat: ChatUIHost, ): Accessor> { return chat.messages } function isSelectedPart( value: MessagePart | ChatUISelectedPart, ): value is ChatUISelectedPart { return 'key' in value && 'part' in value } function bindMap( map: Record | undefined> | undefined, bind: (component: Component) => BoundWidget, ) { const out: Record = {} for (const [key, component] of Object.entries(map ?? {})) { if (component) out[key] = bind(component) } return out } /** * Bind chat options and UI widgets once at module scope. This matches Form * `createFormHook` and Table `createTableHook`. */ export function createChatUI< const TOptions, TInput extends Component | undefined = | Component>> | undefined, >(options: TOptions, config: ChatUIFactoryConfig, TInput>) { void options const { context: contextOption, components, partsComponents: parts, toolsComponents: tools, interruptsComponents: interrupts, } = config as ChatUIFactoryConfig & { toolsComponents?: Record | undefined> interruptsComponents?: { tools?: Record | undefined> generic?: Record | undefined> } } const { layout: Layout, message: MessageComponent, input: InputComponent, queue: QueueItemComponent, } = components const { chatContext: chatContextOption, partContext: partContextOption, interruptContext: interruptContextOption, } = contextOption ?? {} const warn = createWarnOnce() const ChatContext = (chatContextOption ?? defaultChatUIContexts.chatContext) as Context< ChatUIHost | undefined > const PartContext = (partContextOption ?? defaultChatUIContexts.partContext) as Context< ChatUISelectedPart | undefined > const InterruptContext = (interruptContextOption ?? defaultChatUIContexts.interruptContext) as Context< ChatUIInterrupt | undefined > const inlineToolNames = collectInlineToolNames( interrupts?.tools as Record | undefined, Object.keys(tools ?? {}), ) function useChatContext() { const chat = useContext(ChatContext) if (!chat) { throw new Error( '`useChatContext` must be used within `UI.Provider` or `UI.Chat`.', ) } return chat } function usePartContext() { const selected = useContext(PartContext) if (!selected) { throw new Error( '`usePartContext` must be used within `UI.Part` or an automatic part.', ) } return selected as ChatUISelectedPartOf } function useInterruptContext< TName extends ChatUIInterruptName = ChatUIInterruptName, >() { const interrupt = useContext(InterruptContext) if (!interrupt) { throw new Error( '`useInterruptContext` must be used within `UI.Interrupt`.', ) } return interrupt as ChatUIInterruptOf } function bindPart(Component: Component>) { return function BoundPart() { const selected = usePartContext() return ['part']} /> } } function bindTool(Component: Component>) { return function BoundTool() { const selected = usePartContext() if (selected.key !== 'toolCall') return null return ( ['part']} result={selected.result} interrupt={selected.interrupt as ToolProps['interrupt']} /> ) } } function bindInterrupt(Component: Component>) { return function BoundInterrupt() { const interrupt = useInterruptContext() return ( ['interrupt']} /> ) } } const partMixins = bindMap( parts as Record | undefined>, bindPart, ) const toolMixins = bindMap( tools as Record | undefined>, bindTool, ) const interruptMixins = { ...bindMap( interrupts?.generic as | Record | undefined> | undefined, bindInterrupt, ), ...bindMap( interrupts?.tools as | Record | undefined> | undefined, bindInterrupt, ), } function mixPart(selected: ChatUISelectedPart) { return Object.assign({}, selected, partMixins, toolMixins, { Render: BoundRender, }) as ChatUISelectedPart & PartMixins } function mixInterrupt(interrupt: ChatUIInterrupt) { return Object.assign({}, interrupt, interruptMixins, { Render: BoundInterruptRender, }) as ChatUIInterrupt & InterruptMixins } function Provider(props: { chat: ChatUIHost children?: JSX.Element }) { return ( {props.children} ) } // Backstop for when the conditional `Input` type cannot be inferred (see the // `input` note in docs/ui/solid.md). The type hides `Input` when no `input` // is registered, but inference degrades on some config shapes, so always // supply a component: warn once rather than crash on an undefined element. function MissingInput() { warn( 'input', '[tanstack-ai-ui] Rendered but no `input` component is registered.', ) return null } // Declared once per factory, so these props are stable for the kit's life. const LayoutSlots = { Messages: Messages as Component, Interrupts: Interrupts as Component, Queue: Queue as Component, Input: (InputComponent ?? MissingInput) as Component, } function Chat(props: { chat: ChatUIHost }) { return ( ) } function Queue() { const chat = useChatContext() if (!QueueItemComponent) return null return ( {(item) => ( chat.cancelQueued(item.id), }} /> )} ) } function Messages(props: { children?: ( messages: Accessor>>, ) => JSX.Element }) { const chat = useChatContext() if (props.children) return <>{props.children(messagesAccessor(chat))} return ( {(message) => ( )} ) } // Scoped to one message. `Parts` reads it instead of closing over the // message, so the kit hands out one stable component, matching React. type MessageRenderValue = { message: UIMessage interrupts: ReadonlyArray inlineToolNames: ReadonlyArray } const MessageRenderContext = createContext() function Parts() { const scope = useContext(MessageRenderContext) if (!scope) { throw new Error('`Parts` must be rendered by a `message` component.') } return ( ) } function MessageView(props: { message: UIMessage interrupts: ReadonlyArray inlineToolNames: ReadonlyArray children?: (parts: Array) => JSX.Element }) { const selected = selectMessageUI(props.message, { interrupts: props.interrupts, inlineToolNames: props.inlineToolNames, }) if (props.children) return <>{props.children(selected.parts)} return ( ['message']} Parts={Parts} /> ) } function Message(props: { message: UIMessage children?: (parts: Array) => JSX.Element }) { const chat = useChatContext() return ( ) } function AutomaticParts(props: { message: UIMessage interrupts: ReadonlyArray inlineToolNames: ReadonlyArray }) { const selected = selectMessageUI(props.message, { interrupts: props.interrupts, inlineToolNames: props.inlineToolNames, }) return ( <> {automaticPartsForMessage(selected).map((part) => ( ))} ) } function SelectedPartInner(props: { selected: ChatUISelectedPart }) { if (props.selected.key === 'toolCall') { const name = props.selected.part.name const Tool = tools?.[name as ChatUIToolName] as | Component> | undefined if (!Tool) { warn(`tool:${name}`, `[tanstack-ai-ui] Missing tools.${name} component`) return null } return ( ['part']} result={props.selected.result} interrupt={ (props.selected.key === 'toolCall' ? props.selected.interrupt : undefined) as ToolProps['interrupt'] } /> ) } const PartComponent = (parts[props.selected.key] ?? parts.fallback) as | Component> | undefined if (!PartComponent) { warn( `part:${props.selected.key}`, `[tanstack-ai-ui] Missing parts.${props.selected.key} component`, ) return null } return ( ['part']} /> ) } function SelectedPartView(props: { selected: ChatUISelectedPart }) { return ( ) } function BoundRender() { const selected = usePartContext() return } function Part(props: { part: MessagePart | ChatUISelectedPart children?: (mixed: ChatUISelectedPart & PartMixins) => JSX.Element }) { const chat = useChatContext() const selected = isSelectedPart(props.part) ? props.part : selectMessageUI( { id: 'part', role: 'assistant', parts: [props.part] }, { interrupts: readInterrupts(chat), inlineToolNames: [] }, ).parts[0] if (!selected) return null return ( {props.children ? ( props.children(mixPart(selected)) ) : ( )} ) } function Interrupts(props: { children?: ( interrupts: Accessor>, ) => JSX.Element }) { const chat = useChatContext() const selected = () => selectChatUI({ messages: readMessages(chat), interrupts: readInterrupts(chat), inlineToolNames, }) if (props.children) { return <>{props.children(() => selected().interrupts)} } return ( {(interrupt) => } ) } function InterruptInner(props: { interrupt: ChatUIInterrupt }) { const Component = resolveInterruptComponent(props.interrupt, interrupts) as | Component> | undefined if (!Component) { warn( `interrupt:${props.interrupt.id}`, `[tanstack-ai-ui] Missing interrupt component for ${props.interrupt.kind}`, ) return null } return } function BoundInterruptRender() { const interrupt = useInterruptContext() return } function Interrupt(props: { interrupt: ChatUIInterrupt children?: ( mixed: ChatUIInterrupt & InterruptMixins, ) => JSX.Element }) { return ( {props.children ? ( props.children(mixInterrupt(props.interrupt)) ) : ( )} ) } return { Chat, Provider, Messages, Message, Part, Interrupts, Interrupt, Queue, useChatContext, Input: InputComponent, } }