From b6ad01978d4662562a22d8e260f5c3bcaa263e5b Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Fri, 28 Aug 2026 19:28:37 +0200 Subject: [PATCH 1/9] feat(ui): add createChatHook and move Chat UI onto /ui (#1263) * feat(client): add createChatHook bound useChat factory * feat(ui): align createChatUI with Form and Table factories * feat(ui): move chat UI onto framework /ui subpaths Fold the *-ui packages into @tanstack/ai-react/ui, @tanstack/ai-solid/ui, @tanstack/ai-vue/ui, and @tanstack/ai-svelte/ui. createChatHook({ options, chatComponents }) returns useAppChat (Svelte: createAppChat). useAppChat mixes AppChat onto the instance so screens render . * ci: apply automated fixes --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- .changeset/create-chat-hook.md | 10 + .changeset/typed-headless-chat-ui.md | 8 +- README.md | 2 +- codemods/ag-ui-compliance/transform.ts | 6 +- docs/api/ai-react.md | 21 + docs/api/ai-solid.md | 21 + docs/api/ai-svelte.md | 17 + docs/api/ai-vue.md | 18 + docs/migration/create-ui.md | 83 +-- docs/ui/custom-adapters.md | 2 +- docs/ui/react.md | 145 ++-- docs/ui/solid.md | 43 +- docs/ui/svelte.md | 38 +- docs/ui/vue.md | 43 +- examples/README.md | 2 +- examples/ag-ui/README.md | 2 +- examples/ag-ui/package.json | 1 - examples/ag-ui/src/App.tsx | 97 +-- examples/ts-react-chat/package.json | 1 - examples/ts-react-chat/src/routes/index.tsx | 2 +- .../ts-react-chat/src/routes/mcp-demo.tsx | 2 +- examples/ts-react-chat/src/routes/threads.tsx | 2 +- examples/ts-react-ui-chatbot/README.md | 2 +- examples/ts-react-ui-chatbot/package.json | 1 - .../ts-react-ui-chatbot/src/chat/options.ts | 3 - .../src/chat/ui-context.ts | 4 + .../src/components/chat/book-stay-tool.tsx | 2 +- .../src/components/chat/choose-budget.tsx | 2 +- .../components/chat/confirm-payment-tool.tsx | 2 +- .../components/chat/fallback-interrupt.tsx | 2 +- .../src/components/chat/fallback-part.tsx | 2 +- .../src/components/chat/input.tsx | 4 +- .../src/components/chat/layout.tsx | 6 +- .../src/components/chat/lookup-place-tool.tsx | 2 +- .../src/components/chat/media-parts.tsx | 2 +- .../src/components/chat/message.tsx | 2 +- .../src/components/chat/structured-output.tsx | 2 +- .../src/components/chat/text-part.tsx | 2 +- .../src/components/chat/thinking-part.tsx | 2 +- .../src/components/chat/ui-components.tsx | 62 +- .../ts-react-ui-chatbot/src/routes/index.tsx | 14 +- examples/ts-solid-chat/package.json | 1 - examples/ts-solid-chat/src/routes/index.tsx | 2 +- .../ts-solid-chat/src/routes/solid-ui.tsx | 62 +- examples/ts-solid-chat/vite.config.ts | 2 +- examples/ts-svelte-chat/package.json | 1 - .../src/routes/svelte-ui/+page.svelte | 36 +- examples/ts-vue-chat/package.json | 1 - examples/ts-vue-chat/src/views/VueUIView.vue | 205 +++--- knip.json | 8 +- packages/ai-client/src/ui.ts | 1 + packages/ai-client/src/ui/types.ts | 12 + packages/ai-client/tests/ui-types.test-d.ts | 5 + packages/ai-react-ui/CHANGELOG.md | 442 ------------ packages/ai-react-ui/LICENSE | 21 - packages/ai-react-ui/README.md | 328 --------- packages/ai-react-ui/package.json | 77 -- packages/ai-react-ui/src/index.ts | 73 -- packages/ai-react-ui/tests/create-ui.test.tsx | 337 --------- packages/ai-react-ui/tsconfig.json | 11 - packages/ai-react-ui/vite.config.ts | 36 - packages/ai-react/package.json | 18 +- .../src/chat-ui}/chat-input.tsx | 0 .../src/chat-ui}/chat-message.tsx | 2 +- .../src/chat-ui}/chat-messages.tsx | 2 +- .../src => ai-react/src/chat-ui}/chat.tsx | 11 +- .../ai-react/src/chat-ui/create-chat-hook.tsx | 99 +++ .../src/chat-ui/create-ui-contexts.tsx | 57 ++ .../src/chat-ui}/create-ui.tsx | 345 ++++++--- .../src/chat-ui}/markdown-plugins.ts | 0 .../src/chat-ui}/text-part.tsx | 0 .../src/chat-ui}/thinking-part.tsx | 0 .../src/chat-ui}/tool-approval.tsx | 0 .../src/chat-ui}/tool-result-content.ts | 2 +- packages/ai-react/src/create-chat-hook.ts | 67 ++ packages/ai-react/src/index.ts | 1 + packages/ai-react/src/ui.ts | 41 ++ .../tests/chat-ui}/coexistence.test.ts | 6 +- .../tests/chat-ui}/create-ui-types.test.tsx | 107 ++- .../ai-react/tests/chat-ui/create-ui.test.tsx | 419 +++++++++++ .../tests/chat-ui}/markdown-plugins.test.ts | 2 +- .../tests/chat-ui}/react-dom-server.d.ts | 0 .../chat-ui}/tool-result-content.test.ts | 2 +- .../tests/create-chat-hook-types.test.ts | 38 + .../ai-react/tests/create-chat-hook.test.ts | 54 ++ packages/ai-react/vite.config.ts | 2 +- packages/ai-solid-ui/CHANGELOG.md | 403 ----------- packages/ai-solid-ui/LICENSE | 21 - packages/ai-solid-ui/README.md | 328 --------- packages/ai-solid-ui/package.json | 78 -- packages/ai-solid-ui/src/index.ts | 73 -- packages/ai-solid-ui/tests/create-ui.test.tsx | 74 -- packages/ai-solid-ui/tsconfig.json | 12 - packages/ai-solid-ui/vite.config.ts | 45 -- packages/ai-solid/package.json | 14 +- .../src/chat-ui}/chat-input.tsx | 0 .../src/chat-ui}/chat-message.tsx | 2 +- .../src/chat-ui}/chat-messages.tsx | 2 +- .../src => ai-solid/src/chat-ui}/chat.tsx | 9 +- .../ai-solid/src/chat-ui/create-chat-hook.tsx | 70 ++ .../src/chat-ui/create-ui-contexts.tsx | 52 ++ .../src/chat-ui}/create-ui.tsx | 334 ++++++--- .../src/chat-ui}/markdown-plugins.ts | 0 .../src/chat-ui}/text-part.tsx | 0 .../src/chat-ui}/thinking-part.tsx | 0 .../src/chat-ui}/tool-approval.tsx | 0 packages/ai-solid/src/create-chat-hook.ts | 67 ++ packages/ai-solid/src/index.ts | 1 + packages/ai-solid/src/ui.ts | 37 + .../tests/chat-ui}/coexistence.test.ts | 6 +- .../tests/chat-ui}/create-ui-types.test.tsx | 71 +- .../ai-solid/tests/chat-ui/create-ui.test.tsx | 88 +++ .../tests/chat-ui}/markdown-plugins.test.ts | 2 +- .../tests/create-chat-hook-types.test.ts | 29 + .../ai-solid/tests/create-chat-hook.test.ts | 54 ++ packages/ai-solid/tsconfig.json | 3 +- packages/ai-solid/vitest.config.ts | 2 + packages/ai-svelte-ui/LICENSE | 21 - packages/ai-svelte-ui/README.md | 5 - packages/ai-svelte-ui/package.json | 70 -- packages/ai-svelte-ui/src/index.ts | 20 - packages/ai-svelte-ui/svelte.config.js | 11 - packages/ai-svelte-ui/tsconfig.json | 10 - packages/ai-svelte-ui/vite.config.ts | 37 - packages/ai-svelte/package.json | 5 + .../ai-svelte/src/chat-ui/create-chat-hook.ts | 60 ++ .../src/chat-ui}/create-ui.ts | 17 +- .../src/chat-ui}/selected-part.svelte | 0 .../src/chat-ui}/ui-chat.svelte | 9 +- .../src/chat-ui}/ui-interrupt.svelte | 0 .../src/chat-ui}/ui-interrupts.svelte | 0 .../src/chat-ui}/ui-message.svelte | 0 .../src/chat-ui}/ui-messages.svelte | 0 .../src/chat-ui}/ui-part.svelte | 0 .../src/chat-ui}/ui-provider.svelte | 4 +- packages/ai-svelte/src/create-chat-hook.ts | 67 ++ packages/ai-svelte/src/index.ts | 1 + packages/ai-svelte/src/ui.ts | 22 + .../tests/chat-ui}/create-ui-types.test.ts | 41 +- .../tests/chat-ui}/create-ui.test.ts | 1 + .../tests/chat-ui}/fixtures/automatic.svelte | 2 +- .../tests/chat-ui}/fixtures/empty.svelte | 0 .../tests/chat-ui}/fixtures/fallback.svelte | 0 .../tests/chat-ui}/fixtures/fixture-data.ts | 12 +- .../tests/chat-ui}/fixtures/layout.svelte | 0 .../tests/chat-ui}/fixtures/manual.svelte | 2 +- .../tests/chat-ui}/fixtures/message.svelte | 0 .../tests/chat-ui}/fixtures/weather.svelte | 0 .../tests/create-chat-hook-types.test.ts | 29 + .../ai-svelte/tests/create-chat-hook.test.ts | 53 ++ packages/ai-svelte/tsconfig.json | 3 +- packages/ai-vue-ui/CHANGELOG.md | 677 ------------------ packages/ai-vue-ui/LICENSE | 21 - packages/ai-vue-ui/README.md | 328 --------- packages/ai-vue-ui/package.json | 73 -- packages/ai-vue-ui/src/index.ts | 50 -- packages/ai-vue-ui/tests/create-ui.test.ts | 120 ---- packages/ai-vue-ui/tsconfig.json | 11 - packages/ai-vue-ui/vite.config.ts | 38 - packages/ai-vue/package.json | 17 +- .../src => ai-vue/src/chat-ui}/chat-input.vue | 0 .../src/chat-ui}/chat-message.vue | 0 .../src/chat-ui}/chat-messages.vue | 0 .../src => ai-vue/src/chat-ui}/chat.vue | 4 +- .../ai-vue/src/chat-ui/create-chat-hook.ts | 62 ++ .../src => ai-vue/src/chat-ui}/create-ui.ts | 145 +++- .../src/chat-ui}/markdown-plugins.ts | 0 .../src/chat-ui}/message-part.vue | 0 .../src => ai-vue/src/chat-ui}/text-part.vue | 0 .../src/chat-ui}/thinking-part.vue | 0 .../src/chat-ui}/tool-approval.vue | 0 .../src => ai-vue/src/chat-ui}/types.ts | 3 +- .../src/chat-ui}/use-chat-context.ts | 2 +- packages/ai-vue/src/create-chat-hook.ts | 64 ++ packages/ai-vue/src/index.ts | 1 + packages/ai-vue/src/ui.ts | 42 ++ packages/ai-vue/src/vue-shim.d.ts | 9 + .../tests/chat-ui}/coexistence.test.ts | 6 +- .../tests/chat-ui}/create-ui-types.test.ts | 39 +- .../ai-vue/tests/chat-ui/create-ui.test.ts | 112 +++ .../tests/chat-ui}/markdown-plugins.test.ts | 2 +- .../tests/chat-ui}/test-renderer.ts | 0 .../tests/create-chat-hook-types.test.ts | 29 + .../ai-vue/tests/create-chat-hook.test.ts | 70 ++ packages/ai-vue/tsconfig.json | 3 +- packages/ai-vue/vitest.config.ts | 2 + pnpm-lock.yaml | 227 ++---- scripts/copy-readme.js | 3 - testing/e2e/package.json | 1 - testing/e2e/src/routes/headless-ui.tsx | 12 +- testing/e2e/src/routes/markdown-cjk.tsx | 2 +- testing/panel/package.json | 1 - testing/panel/src/routes/index.tsx | 2 +- .../scripts/assert-bundle-output.ts | 2 +- .../scripts/assert-import-surface.ts | 2 +- 195 files changed, 3356 insertions(+), 4825 deletions(-) create mode 100644 .changeset/create-chat-hook.md create mode 100644 examples/ts-react-ui-chatbot/src/chat/ui-context.ts delete mode 100644 packages/ai-react-ui/CHANGELOG.md delete mode 100644 packages/ai-react-ui/LICENSE delete mode 100644 packages/ai-react-ui/README.md delete mode 100644 packages/ai-react-ui/package.json delete mode 100644 packages/ai-react-ui/src/index.ts delete mode 100644 packages/ai-react-ui/tests/create-ui.test.tsx delete mode 100644 packages/ai-react-ui/tsconfig.json delete mode 100644 packages/ai-react-ui/vite.config.ts rename packages/{ai-react-ui/src => ai-react/src/chat-ui}/chat-input.tsx (100%) rename packages/{ai-react-ui/src => ai-react/src/chat-ui}/chat-message.tsx (99%) rename packages/{ai-react-ui/src => ai-react/src/chat-ui}/chat-messages.tsx (97%) rename packages/{ai-react-ui/src => ai-react/src/chat-ui}/chat.tsx (91%) create mode 100644 packages/ai-react/src/chat-ui/create-chat-hook.tsx create mode 100644 packages/ai-react/src/chat-ui/create-ui-contexts.tsx rename packages/{ai-react-ui/src => ai-react/src/chat-ui}/create-ui.tsx (53%) rename packages/{ai-react-ui/src => ai-react/src/chat-ui}/markdown-plugins.ts (100%) rename packages/{ai-react-ui/src => ai-react/src/chat-ui}/text-part.tsx (100%) rename packages/{ai-react-ui/src => ai-react/src/chat-ui}/thinking-part.tsx (100%) rename packages/{ai-react-ui/src => ai-react/src/chat-ui}/tool-approval.tsx (100%) rename packages/{ai-react-ui/src => ai-react/src/chat-ui}/tool-result-content.ts (95%) create mode 100644 packages/ai-react/src/create-chat-hook.ts create mode 100644 packages/ai-react/src/ui.ts rename packages/{ai-react-ui/tests => ai-react/tests/chat-ui}/coexistence.test.ts (76%) rename packages/{ai-react-ui/tests => ai-react/tests/chat-ui}/create-ui-types.test.tsx (58%) create mode 100644 packages/ai-react/tests/chat-ui/create-ui.test.tsx rename packages/{ai-react-ui/tests => ai-react/tests/chat-ui}/markdown-plugins.test.ts (98%) rename packages/{ai-react-ui/tests => ai-react/tests/chat-ui}/react-dom-server.d.ts (100%) rename packages/{ai-react-ui/tests => ai-react/tests/chat-ui}/tool-result-content.test.ts (94%) create mode 100644 packages/ai-react/tests/create-chat-hook-types.test.ts create mode 100644 packages/ai-react/tests/create-chat-hook.test.ts delete mode 100644 packages/ai-solid-ui/CHANGELOG.md delete mode 100644 packages/ai-solid-ui/LICENSE delete mode 100644 packages/ai-solid-ui/README.md delete mode 100644 packages/ai-solid-ui/package.json delete mode 100644 packages/ai-solid-ui/src/index.ts delete mode 100644 packages/ai-solid-ui/tests/create-ui.test.tsx delete mode 100644 packages/ai-solid-ui/tsconfig.json delete mode 100644 packages/ai-solid-ui/vite.config.ts rename packages/{ai-solid-ui/src => ai-solid/src/chat-ui}/chat-input.tsx (100%) rename packages/{ai-solid-ui/src => ai-solid/src/chat-ui}/chat-message.tsx (99%) rename packages/{ai-solid-ui/src => ai-solid/src/chat-ui}/chat-messages.tsx (98%) rename packages/{ai-solid-ui/src => ai-solid/src/chat-ui}/chat.tsx (95%) create mode 100644 packages/ai-solid/src/chat-ui/create-chat-hook.tsx create mode 100644 packages/ai-solid/src/chat-ui/create-ui-contexts.tsx rename packages/{ai-solid-ui/src => ai-solid/src/chat-ui}/create-ui.tsx (51%) rename packages/{ai-solid-ui/src => ai-solid/src/chat-ui}/markdown-plugins.ts (100%) rename packages/{ai-solid-ui/src => ai-solid/src/chat-ui}/text-part.tsx (100%) rename packages/{ai-solid-ui/src => ai-solid/src/chat-ui}/thinking-part.tsx (100%) rename packages/{ai-solid-ui/src => ai-solid/src/chat-ui}/tool-approval.tsx (100%) create mode 100644 packages/ai-solid/src/create-chat-hook.ts create mode 100644 packages/ai-solid/src/ui.ts rename packages/{ai-solid-ui/tests => ai-solid/tests/chat-ui}/coexistence.test.ts (74%) rename packages/{ai-solid-ui/tests => ai-solid/tests/chat-ui}/create-ui-types.test.tsx (58%) create mode 100644 packages/ai-solid/tests/chat-ui/create-ui.test.tsx rename packages/{ai-solid-ui/tests => ai-solid/tests/chat-ui}/markdown-plugins.test.ts (98%) create mode 100644 packages/ai-solid/tests/create-chat-hook-types.test.ts create mode 100644 packages/ai-solid/tests/create-chat-hook.test.ts delete mode 100644 packages/ai-svelte-ui/LICENSE delete mode 100644 packages/ai-svelte-ui/README.md delete mode 100644 packages/ai-svelte-ui/package.json delete mode 100644 packages/ai-svelte-ui/src/index.ts delete mode 100644 packages/ai-svelte-ui/svelte.config.js delete mode 100644 packages/ai-svelte-ui/tsconfig.json delete mode 100644 packages/ai-svelte-ui/vite.config.ts create mode 100644 packages/ai-svelte/src/chat-ui/create-chat-hook.ts rename packages/{ai-svelte-ui/src => ai-svelte/src/chat-ui}/create-ui.ts (95%) rename packages/{ai-svelte-ui/src => ai-svelte/src/chat-ui}/selected-part.svelte (100%) rename packages/{ai-svelte-ui/src => ai-svelte/src/chat-ui}/ui-chat.svelte (76%) rename packages/{ai-svelte-ui/src => ai-svelte/src/chat-ui}/ui-interrupt.svelte (100%) rename packages/{ai-svelte-ui/src => ai-svelte/src/chat-ui}/ui-interrupts.svelte (100%) rename packages/{ai-svelte-ui/src => ai-svelte/src/chat-ui}/ui-message.svelte (100%) rename packages/{ai-svelte-ui/src => ai-svelte/src/chat-ui}/ui-messages.svelte (100%) rename packages/{ai-svelte-ui/src => ai-svelte/src/chat-ui}/ui-part.svelte (100%) rename packages/{ai-svelte-ui/src => ai-svelte/src/chat-ui}/ui-provider.svelte (83%) create mode 100644 packages/ai-svelte/src/create-chat-hook.ts create mode 100644 packages/ai-svelte/src/ui.ts rename packages/{ai-svelte-ui/tests => ai-svelte/tests/chat-ui}/create-ui-types.test.ts (71%) rename packages/{ai-svelte-ui/tests => ai-svelte/tests/chat-ui}/create-ui.test.ts (92%) rename packages/{ai-svelte-ui/tests => ai-svelte/tests/chat-ui}/fixtures/automatic.svelte (73%) rename packages/{ai-svelte-ui/tests => ai-svelte/tests/chat-ui}/fixtures/empty.svelte (100%) rename packages/{ai-svelte-ui/tests => ai-svelte/tests/chat-ui}/fixtures/fallback.svelte (100%) rename packages/{ai-svelte-ui/tests => ai-svelte/tests/chat-ui}/fixtures/fixture-data.ts (74%) rename packages/{ai-svelte-ui/tests => ai-svelte/tests/chat-ui}/fixtures/layout.svelte (100%) rename packages/{ai-svelte-ui/tests => ai-svelte/tests/chat-ui}/fixtures/manual.svelte (81%) rename packages/{ai-svelte-ui/tests => ai-svelte/tests/chat-ui}/fixtures/message.svelte (100%) rename packages/{ai-svelte-ui/tests => ai-svelte/tests/chat-ui}/fixtures/weather.svelte (100%) create mode 100644 packages/ai-svelte/tests/create-chat-hook-types.test.ts create mode 100644 packages/ai-svelte/tests/create-chat-hook.test.ts delete mode 100644 packages/ai-vue-ui/CHANGELOG.md delete mode 100644 packages/ai-vue-ui/LICENSE delete mode 100644 packages/ai-vue-ui/README.md delete mode 100644 packages/ai-vue-ui/package.json delete mode 100644 packages/ai-vue-ui/src/index.ts delete mode 100644 packages/ai-vue-ui/tests/create-ui.test.ts delete mode 100644 packages/ai-vue-ui/tsconfig.json delete mode 100644 packages/ai-vue-ui/vite.config.ts rename packages/{ai-vue-ui/src => ai-vue/src/chat-ui}/chat-input.vue (100%) rename packages/{ai-vue-ui/src => ai-vue/src/chat-ui}/chat-message.vue (100%) rename packages/{ai-vue-ui/src => ai-vue/src/chat-ui}/chat-messages.vue (100%) rename packages/{ai-vue-ui/src => ai-vue/src/chat-ui}/chat.vue (92%) create mode 100644 packages/ai-vue/src/chat-ui/create-chat-hook.ts rename packages/{ai-vue-ui/src => ai-vue/src/chat-ui}/create-ui.ts (74%) rename packages/{ai-vue-ui/src => ai-vue/src/chat-ui}/markdown-plugins.ts (100%) rename packages/{ai-vue-ui/src => ai-vue/src/chat-ui}/message-part.vue (100%) rename packages/{ai-vue-ui/src => ai-vue/src/chat-ui}/text-part.vue (100%) rename packages/{ai-vue-ui/src => ai-vue/src/chat-ui}/thinking-part.vue (100%) rename packages/{ai-vue-ui/src => ai-vue/src/chat-ui}/tool-approval.vue (100%) rename packages/{ai-vue-ui/src => ai-vue/src/chat-ui}/types.ts (97%) rename packages/{ai-vue-ui/src => ai-vue/src/chat-ui}/use-chat-context.ts (92%) create mode 100644 packages/ai-vue/src/create-chat-hook.ts create mode 100644 packages/ai-vue/src/ui.ts create mode 100644 packages/ai-vue/src/vue-shim.d.ts rename packages/{ai-vue-ui/tests => ai-vue/tests/chat-ui}/coexistence.test.ts (76%) rename packages/{ai-vue-ui/tests => ai-vue/tests/chat-ui}/create-ui-types.test.ts (76%) create mode 100644 packages/ai-vue/tests/chat-ui/create-ui.test.ts rename packages/{ai-vue-ui/tests => ai-vue/tests/chat-ui}/markdown-plugins.test.ts (98%) rename packages/{ai-vue-ui/tests => ai-vue/tests/chat-ui}/test-renderer.ts (100%) create mode 100644 packages/ai-vue/tests/create-chat-hook-types.test.ts create mode 100644 packages/ai-vue/tests/create-chat-hook.test.ts diff --git a/.changeset/create-chat-hook.md b/.changeset/create-chat-hook.md new file mode 100644 index 0000000000..07088e1690 --- /dev/null +++ b/.changeset/create-chat-hook.md @@ -0,0 +1,10 @@ +--- +'@tanstack/ai-react': minor +'@tanstack/ai-solid': minor +'@tanstack/ai-vue': minor +'@tanstack/ai-svelte': minor +--- + +Chat UI now lives on the framework packages at `@tanstack/ai-react/ui`, `@tanstack/ai-solid/ui`, `@tanstack/ai-vue/ui`, and `@tanstack/ai-svelte/ui`. + +`createChatHook({ options, chatComponents })` returns `useAppChat` (Svelte: `createAppChat`) and `useChatContext`. `useAppChat()` mixes `AppChat` onto the instance so you render ``. diff --git a/.changeset/typed-headless-chat-ui.md b/.changeset/typed-headless-chat-ui.md index db5dfc9eec..14c84dab83 100644 --- a/.changeset/typed-headless-chat-ui.md +++ b/.changeset/typed-headless-chat-ui.md @@ -1,9 +1,9 @@ --- '@tanstack/ai-client': minor -'@tanstack/ai-react-ui': minor -'@tanstack/ai-solid-ui': minor -'@tanstack/ai-vue-ui': minor -'@tanstack/ai-svelte-ui': minor +'@tanstack/ai-react': minor +'@tanstack/ai-solid': minor +'@tanstack/ai-vue': minor +'@tanstack/ai-svelte': minor --- Add typed headless `createChatUI()` adapters. Chat options control the types of message parts, tools, structured output, and interrupts. `defineComponents` requires a component for every tool name and every registered interrupt id. `InterruptProps` pins a tool approval or a registered generic interrupt. Old Chat orchestration stays importable and deprecated until 1.0. diff --git a/README.md b/README.md index c9bbb8c10d..333e543630 100644 --- a/README.md +++ b/README.md @@ -234,7 +234,7 @@ on. | [`@tanstack/ai-vue`](https://tanstack.com/ai/latest/docs/api/ai-vue) | Vue composables for chat and generations | | [`@tanstack/ai-svelte`](https://tanstack.com/ai/latest/docs/api/ai-svelte) | Svelte 5 factories for chat and generations | | [`@tanstack/ai-preact`](https://tanstack.com/ai/latest/docs/api/ai-preact) | Preact hooks for chat | -| `@tanstack/ai-react-ui`, `@tanstack/ai-solid-ui`, `@tanstack/ai-vue-ui` | Headless UI components for chat interfaces | +| `@tanstack/ai-react/ui`, `@tanstack/ai-solid/ui`, `@tanstack/ai-vue/ui` | Headless UI components for chat interfaces | ## Advanced Docs diff --git a/codemods/ag-ui-compliance/transform.ts b/codemods/ag-ui-compliance/transform.ts index 37eb154620..d4924f7a90 100644 --- a/codemods/ag-ui-compliance/transform.ts +++ b/codemods/ag-ui-compliance/transform.ts @@ -36,11 +36,11 @@ import type { const FRAMEWORK_USE_CHAT_PACKAGES = new Set([ '@tanstack/ai-react', - '@tanstack/ai-react-ui', + '@tanstack/ai-react/ui', '@tanstack/ai-vue', - '@tanstack/ai-vue-ui', + '@tanstack/ai-vue/ui', '@tanstack/ai-solid', - '@tanstack/ai-solid-ui', + '@tanstack/ai-solid/ui', '@tanstack/ai-preact', ]) diff --git a/docs/api/ai-react.md b/docs/api/ai-react.md index ff4fafe768..7d8870bc1b 100644 --- a/docs/api/ai-react.md +++ b/docs/api/ai-react.md @@ -28,6 +28,27 @@ For a complete native journey, see npm install @tanstack/ai-react ``` +## `createChatHook(options)` + +Bind `chatOptions` once at module scope. Call `useChat()` in the screen to create the instance. Per-call overrides may set `threadId`, `initialMessages`, `live`, and `forwardedProps`. They must not change `tools`, `interrupts`, or `outputSchema`. + +```tsx +import { createChatHook, fetchServerSentEvents } from "@tanstack/ai-react"; + +const chatOptions = { + connection: fetchServerSentEvents("/api/chat"), +}; + +const { useChat } = createChatHook(chatOptions); + +function ChatScreen() { + const chat = useChat({ threadId: "support-1" }); + return null; +} +``` + +`useChat(chatOptions)` from this package still works when you want to pass the full object at the call site. Rename the bound hook if both are in one file: `const { useChat: useSupportChat } = createChatHook(chatOptions)`. + ## `useChat(options?)` Main hook for managing chat state in React with full type safety. diff --git a/docs/api/ai-solid.md b/docs/api/ai-solid.md index 3fd64dfb6a..2bcb687624 100644 --- a/docs/api/ai-solid.md +++ b/docs/api/ai-solid.md @@ -23,6 +23,27 @@ For a typed headless chat UI, see [Solid Chat UI](../ui/solid) and [Migrate to c npm install @tanstack/ai-solid ``` +## `createChatHook(options)` + +Bind `chatOptions` once at module scope. Call `useChat()` in the screen to create the instance. Per-call overrides may set `threadId`, `initialMessages`, `live`, and `forwardedProps`. They must not change `tools`, `interrupts`, or `outputSchema`. + +```tsx +import { createChatHook, fetchServerSentEvents } from "@tanstack/ai-solid"; + +const chatOptions = { + connection: fetchServerSentEvents("/api/chat"), +}; + +const { useChat } = createChatHook(chatOptions); + +function ChatScreen() { + const chat = useChat({ threadId: "support-1" }); + return null; +} +``` + +`useChat(chatOptions)` from this package still works when you want to pass the full object at the call site. Rename the bound primitive if both are in one file: `const { useChat: useSupportChat } = createChatHook(chatOptions)`. + ## `useChat(options?)` Main primitive for managing chat state in SolidJS with full type safety. diff --git a/docs/api/ai-svelte.md b/docs/api/ai-svelte.md index a59e25da52..e0ce54777f 100644 --- a/docs/api/ai-svelte.md +++ b/docs/api/ai-svelte.md @@ -23,6 +23,23 @@ For a typed headless chat UI, see [Svelte Chat UI](../ui/svelte) and [Migrate to npm install @tanstack/ai-svelte ``` +## `createChatHook(options)` + +Bind `chatOptions` once at module scope. Call `createChat()` to create the instance. Per-call overrides may set `threadId`, `initialMessages`, `live`, and `forwardedProps`. They must not change `tools`, `interrupts`, or `outputSchema`. + +```ts +import { createChatHook, fetchServerSentEvents } from "@tanstack/ai-svelte"; + +const chatOptions = { + connection: fetchServerSentEvents("/api/chat"), +}; + +const { createChat } = createChatHook(chatOptions); +const chat = createChat({ threadId: "support-1" }); +``` + +`createChat(chatOptions)` from this package still works when you want to pass the full object at the call site. Rename the bound factory if both are in one file: `const { createChat: createSupportChat } = createChatHook(chatOptions)`. + ## `createChat(options)` Factory function for managing chat state in Svelte 5 with full type safety. diff --git a/docs/api/ai-vue.md b/docs/api/ai-vue.md index cd46cf22e6..a461b4ac3f 100644 --- a/docs/api/ai-vue.md +++ b/docs/api/ai-vue.md @@ -23,6 +23,24 @@ For a typed headless chat UI, see [Vue Chat UI](../ui/vue) and [Migrate to creat npm install @tanstack/ai-vue ``` +## `createChatHook(options)` + +Bind `chatOptions` once at module scope. Call `useChat()` in the screen to create the instance. Per-call overrides may set `threadId`, `initialMessages`, `live`, and `forwardedProps`. They must not change `tools`, `interrupts`, or `outputSchema`. + +```ts +import { createChatHook, fetchServerSentEvents } from "@tanstack/ai-vue"; + +const chatOptions = { + connection: fetchServerSentEvents("/api/chat"), +}; + +const { useChat } = createChatHook(chatOptions); + +const chat = useChat({ threadId: "support-1" }); +``` + +`useChat(chatOptions)` from this package still works when you want to pass the full object at the call site. Rename the bound composable if both are in one file: `const { useChat: useSupportChat } = createChatHook(chatOptions)`. + ## `useChat(options?)` Main composable for managing chat state in Vue with full type safety. diff --git a/docs/migration/create-ui.md b/docs/migration/create-ui.md index 6a6836231f..fcdafb6d88 100644 --- a/docs/migration/create-ui.md +++ b/docs/migration/create-ui.md @@ -29,10 +29,10 @@ The old APIs drop configured types, keep unused properties, use a deprecated app ## Minimum versions -- `@tanstack/ai-react-ui` 0.9.0 -- `@tanstack/ai-solid-ui` 0.8.0 -- `@tanstack/ai-vue-ui` 0.3.0 -- `@tanstack/ai-svelte-ui` 0.2.0 +- `@tanstack/ai-react/ui` 0.9.0 +- `@tanstack/ai-solid/ui` 0.8.0 +- `@tanstack/ai-vue/ui` 0.3.0 +- `@tanstack/ai-svelte/ui` 0.2.0 Old orchestration exports stay importable until each package's `1.0.0`. `TextPart` and `ThinkingPart` stay supported. @@ -40,7 +40,7 @@ Old orchestration exports stay importable until each package's `1.0.0`. `TextPar ```tsx import { fetchServerSentEvents } from '@tanstack/ai-react' -import { Chat, ChatMessages, ChatInput } from '@tanstack/ai-react-ui' +import { Chat, ChatMessages, ChatInput } from '@tanstack/ai-react/ui' const connection = fetchServerSentEvents('/api/chat') @@ -57,57 +57,58 @@ export function OldChat() { ## After ```tsx -import { fetchServerSentEvents, useChat } from '@tanstack/ai-react' -import { createChatUI } from '@tanstack/ai-react-ui' +import { fetchServerSentEvents } from '@tanstack/ai-react' +import { createChatHook } from '@tanstack/ai-react/ui' const chatOptions = { connection: fetchServerSentEvents('/api/chat'), } -const UI = createChatUI(chatOptions) - -const components = UI.defineComponents({ - layout: ({ renderMessages, renderInput }) => ( -
- {renderMessages()} - {renderInput()} -
- ), - message: ({ renderParts }) =>
{renderParts()}
, - input: () => { - const chat = UI.useChat() - return ( -
{ - event.preventDefault() - const field = event.currentTarget.elements.namedItem('message') - if (!(field instanceof HTMLInputElement)) return - const text = field.value.trim() - if (!text) return - field.value = '' - void chat.sendMessage(text) - }} - > - -
- ) +const { useAppChat, useChatContext } = createChatHook({ + options: chatOptions, + chatComponents: { + layout: ({ renderMessages, renderInput }) => ( +
+ {renderMessages()} + {renderInput()} +
+ ), + message: ({ renderParts }) =>
{renderParts()}
, + input: () => { + const chat = useChatContext() + return ( +
{ + event.preventDefault() + const field = event.currentTarget.elements.namedItem('message') + if (!(field instanceof HTMLInputElement)) return + const text = field.value.trim() + if (!text) return + field.value = '' + void chat.sendMessage(text) + }} + > + +
+ ) + }, + parts: { fallback: () => null }, }, - parts: { fallback: () => null }, }) export function NewChat() { - const chat = useChat(chatOptions) - return + const chat = useAppChat({ threadId: 'support-1' }) + return } ``` ## Steps 1. Move `connection`, `tools`, and `interrupts` into a module-level `chatOptions` object. -2. Call `createChatUI(chatOptions)` next to that object. -3. Call `useChat(chatOptions)` in the screen component. -4. Define `layout`, `message`, `parts`, `tools`, and `interrupts` in `defineComponents`. -5. Replace `` with ``. +2. Call `createChatHook({ options: chatOptions, chatComponents: { layout, message, parts, tools, interrupts } })` next to that object. +3. Call the bound `useAppChat` from `createChatHook` in the screen component. +4. Register `layout`, `message`, `parts`, `tools`, and `interrupts` on `chatComponents`. This matches Form and Table. +5. Render ``. ## Gotchas diff --git a/docs/ui/custom-adapters.md b/docs/ui/custom-adapters.md index d51a4e8f60..fce39875d5 100644 --- a/docs/ui/custom-adapters.md +++ b/docs/ui/custom-adapters.md @@ -31,4 +31,4 @@ Call `selectChatUI({ messages, interrupts, inlineToolNames })`. Automatic traver Warn once per missing runtime key in development. Each build tool detects development mode differently, so the adapter prints the warning. -See the [React](./react), [Solid](./solid), [Vue](./vue), and [Svelte](./svelte) adapters for the public names to match: `Chat`, `Provider`, `Messages`, `Message`, `Part`, `Interrupts`, `Interrupt`, and `defineComponents`. +See the [React](./react), [Solid](./solid), [Vue](./vue), and [Svelte](./svelte) adapters for the public names to match: `Chat`, `Provider`, `Messages`, `Message`, `Part`, `Interrupts`, `Interrupt`, `useChatContext`, and `createChatHookContexts`. diff --git a/docs/ui/react.md b/docs/ui/react.md index 2a05727143..9b20959aad 100644 --- a/docs/ui/react.md +++ b/docs/ui/react.md @@ -12,11 +12,13 @@ keywords: - ToolProps --- -Install `@tanstack/ai-react-ui`, then call `createChatUI(chatOptions)` once at module scope. Your app owns `useChat`. The UI only renders. Call `UI.useChat()` inside a mapped component when it needs live chat. That call is the same value you passed into `UI.Chat`. +Install `@tanstack/ai-react`. Import the UI factory from `@tanstack/ai-react/ui`. Call `createChatHook({ options, chatComponents })` once at module scope. This matches Form `createFormHook` and Table `createTableHook`: widgets register on the factory, mix onto Part / Interrupt / Input, and automatic dispatch still walks the message list. + +The factory returns `useAppChat` and `useChatContext`. Call `useAppChat()` in the screen to create the instance. Render ``. Call `useChatContext()` inside a mapped component when it needs live chat. That value is the instance that `chat.AppChat` provides. You supply every visible component. There is no default markup, style, or copy. -`defineComponents` needs a `tools` entry for every tool name in `chatOptions`. It also needs an `interrupts.generic` entry for every interrupt id. `generic.fallback` is optional. +The factory needs a `tools` entry for every tool name in `chatOptions`. It also needs an `interrupts.generic` entry for every interrupt id. `generic.fallback` is optional. ## Server @@ -41,8 +43,8 @@ export async function POST(request: Request) { ## Client ```tsx -import { fetchServerSentEvents, useChat } from '@tanstack/ai-react' -import { createChatUI } from '@tanstack/ai-react-ui' +import { fetchServerSentEvents } from '@tanstack/ai-react' +import { createChatHook } from '@tanstack/ai-react/ui' import { defineInterrupt, toolDefinition } from '@tanstack/ai' import { z } from 'zod' @@ -74,15 +76,15 @@ const chatOptions = { outputSchema: z.object({ answer: z.string() }), } -const UI = createChatUI(chatOptions) - -const components = UI.defineComponents({ +const { useAppChat, useChatContext } = createChatHook({ + options: chatOptions, + chatComponents: { layout: function Layout({ renderMessages, renderInterrupts, renderInput, }) { - const chat = UI.useChat() + const chat = useChatContext() if (chat.error) return

{chat.error.message}

if (chat.isLoading && chat.messages.length === 0) return

Loading

if (chat.messages.length === 0) return

Empty

@@ -98,7 +100,7 @@ const components = UI.defineComponents({ return
{renderParts()}
}, input: function Input() { - const chat = UI.useChat() + const chat = useChatContext() return (
{ @@ -156,11 +158,17 @@ const components = UI.defineComponents({ fallback: ({ interrupt }) =>

{interrupt.reason}

, }, }, + }, }) -export function ChatScreen() { - const chat = useChat(chatOptions) - return +export function Support() { + const chat = useAppChat({ threadId: 'support-1' }) + return +} + +export function Sidebar() { + const chat = useAppChat({ threadId: 'sidebar' }) + return } ``` @@ -174,7 +182,7 @@ Part components work the same way. `PartProps` alrea ```tsx import { fetchServerSentEvents } from '@tanstack/ai-react' -import { createChatUI, type PartProps, type ToolProps } from '@tanstack/ai-react-ui' +import { createChatUI, type PartProps, type ToolProps } from '@tanstack/ai-react/ui' import { toolDefinition } from '@tanstack/ai' import { z } from 'zod' @@ -208,9 +216,7 @@ export function TextPart({ part }: PartProps) { return

{part.content}

} -const UI = createChatUI(chatOptions) - -export const components = UI.defineComponents({ +export const UI = createChatUI(chatOptions, { layout: ({ renderMessages }) => renderMessages(), message: ({ renderParts }) =>
{renderParts()}
, parts: { text: TextPart, fallback: () => null }, @@ -219,7 +225,7 @@ export const components = UI.defineComponents({ ``` 1. Put `chatOptions` in a shared module. -2. Import `ToolProps` or `PartProps` from `@tanstack/ai-react-ui`. +2. Import `ToolProps` or `PartProps` from `@tanstack/ai-react/ui`. 3. Type the component with `ToolProps` or `PartProps`. 4. Pass that component into `tools.getWeather` or `parts.text`. @@ -230,7 +236,7 @@ For an interrupt, use `InterruptProps`. Pass a tool name or a registered interru ```tsx import { fetchServerSentEvents } from '@tanstack/ai-react' -import { createChatUI, type InterruptProps } from '@tanstack/ai-react-ui' +import { createChatUI, type InterruptProps } from '@tanstack/ai-react/ui' import { defineInterrupt } from '@tanstack/ai' import { z } from 'zod' @@ -255,9 +261,7 @@ export function ChoosePlan({ ) } -const UI = createChatUI(chatOptions) - -export const components = UI.defineComponents({ +export const UI = createChatUI(chatOptions, { layout: ({ renderInterrupts }) => renderInterrupts(), message: ({ renderParts }) =>
{renderParts()}
, parts: { fallback: () => null }, @@ -277,48 +281,53 @@ Other prop types from the same package: - `PartProps` with a part key such as `'text'` - `InterruptProps` for tool approvals, registered generic interrupts, and `generic.fallback`. Pass a tool name or interrupt id as the second type argument. -## Read chat from `UI.useChat()` +## Read chat from `useChatContext()` -Mapped components do not receive `chat` as a prop. Call `UI.useChat()` inside a component when it needs live chat. That call opts the component into chat re-renders. Nested children can call it too. +Mapped components do not receive `chat` as a prop. Call `useChatContext()` inside a component when it needs live chat. That call opts the component into chat re-renders. Nested children can call it too. Widgets in other files should call `createChatHookContexts()` first so they do not import the factory result (circular import). ```tsx -import { fetchServerSentEvents, useChat } from '@tanstack/ai-react' -import { createChatUI } from '@tanstack/ai-react-ui' +import { fetchServerSentEvents } from '@tanstack/ai-react' +import { createChatHook } from '@tanstack/ai-react/ui' const chatOptions = { connection: fetchServerSentEvents('/api/chat'), } -const UI = createChatUI(chatOptions) - function StatusLine() { - const chat = UI.useChat() + const chat = useChatContext() if (chat.error) return

{chat.error.message}

if (chat.isLoading) return

Loading

return

{chat.messages.length} messages

} -const components = UI.defineComponents({ - layout: ({ renderMessages, renderInput }) => ( -
- - {renderMessages()} - {renderInput()} -
- ), - message: ({ renderParts }) =>
{renderParts()}
, - parts: { fallback: () => null }, +const { useAppChat, useChatContext } = createChatHook({ + options: chatOptions, + chatComponents: { + layout: ({ renderMessages, renderInput }) => ( +
+ + {renderMessages()} + {renderInput()} +
+ ), + message: ({ renderParts }) =>
{renderParts()}
, + parts: { fallback: () => null }, + }, }) export function ChatScreen() { - const chat = useChat(chatOptions) - return + const chat = useAppChat() + return } ``` -Call `UI.useChat()` only inside `UI.Chat` or `UI.Provider`. A call outside that tree throws. +Call `useChatContext()` only inside `AppChat` or `Provider`. A call outside that tree throws. + +`useAppChat` from `createChatHook` owns the state. `useChatContext()` only reads the instance that `chat.AppChat` provides. -`useChat(chatOptions)` from `@tanstack/ai-react` still owns the state. `UI.useChat()` only reads the instance that you passed into the provider. +When a widget lives in another file, call `createChatHookContexts()` first and pass `chatContext`, `partContext`, and `interruptContext` into `chatComponents`. Then that file can import `useChatContext` from the contexts module. + +Part and interrupt widgets take `part` and `interrupt` as props. Type them with `PartProps`, `ToolProps`, or `InterruptProps`. Do not read those values from context. ## Tool approvals: inline or list @@ -332,7 +341,7 @@ Read `interrupt` on the tool. Render the approval in that same component. Do not ```tsx import { fetchServerSentEvents, useChat } from '@tanstack/ai-react' -import { createChatUI, type ToolProps } from '@tanstack/ai-react-ui' +import { createChatUI, type ToolProps } from '@tanstack/ai-react/ui' import { toolDefinition } from '@tanstack/ai' import { z } from 'zod' @@ -349,8 +358,6 @@ const chatOptions = { tools: [purchaseItem], } -const UI = createChatUI(chatOptions) - function PurchaseItem({ part, interrupt, @@ -367,7 +374,7 @@ function PurchaseItem({ ) } -const components = UI.defineComponents({ +const UI = createChatUI(chatOptions, { layout: ({ renderMessages, renderInterrupts }) => (
{renderMessages()} @@ -383,7 +390,7 @@ const components = UI.defineComponents({ export function InlineApprovalChat() { const chat = useChat(chatOptions) - return + return } ``` @@ -395,7 +402,7 @@ Register the approval under `interrupts.tools`. That component appears in the in ```tsx import { fetchServerSentEvents, useChat } from '@tanstack/ai-react' -import { createChatUI } from '@tanstack/ai-react-ui' +import { createChatUI } from '@tanstack/ai-react/ui' import { toolDefinition } from '@tanstack/ai' import { z } from 'zod' @@ -412,9 +419,7 @@ const chatOptions = { tools: [purchaseItem], } -const UI = createChatUI(chatOptions) - -const components = UI.defineComponents({ +const UI = createChatUI(chatOptions, { layout: ({ renderMessages, renderInterrupts }) => (
{renderMessages()} @@ -439,7 +444,7 @@ const components = UI.defineComponents({ export function ListApprovalChat() { const chat = useChat(chatOptions) - return + return } ``` @@ -454,7 +459,7 @@ Map them under `interrupts.generic`: ```tsx import { fetchServerSentEvents, useChat } from '@tanstack/ai-react' -import { createChatUI } from '@tanstack/ai-react-ui' +import { createChatUI } from '@tanstack/ai-react/ui' import { defineInterrupt } from '@tanstack/ai' import { z } from 'zod' @@ -469,9 +474,7 @@ const chatOptions = { interrupts: [choosePlan], } -const UI = createChatUI(chatOptions) - -const components = UI.defineComponents({ +const UI = createChatUI(chatOptions, { layout: ({ renderInterrupts }) => renderInterrupts(), message: ({ renderParts }) =>
{renderParts()}
, parts: { fallback: () => null }, @@ -494,25 +497,23 @@ const components = UI.defineComponents({ export function GenericInterruptChat() { const chat = useChat(chatOptions) - return + return } ``` -You can mix this map with `interrupts.tools` in the same `defineComponents` call. +You can mix this map with `interrupts.tools` in the same factory call. ## Manual traversal ```tsx import { fetchServerSentEvents, useChat } from '@tanstack/ai-react' -import { createChatUI } from '@tanstack/ai-react-ui' +import { createChatUI } from '@tanstack/ai-react/ui' const chatOptions = { connection: fetchServerSentEvents('/api/chat'), } -const UI = createChatUI(chatOptions) - -const components = UI.defineComponents({ +const UI = createChatUI(chatOptions, { layout: ({ renderMessages }) => renderMessages(), message: ({ renderParts }) =>
{renderParts()}
, parts: { fallback: () => null }, @@ -521,7 +522,7 @@ const components = UI.defineComponents({ export function ManualChat() { const chat = useChat(chatOptions) return ( - + {(messages) => messages.map((message) => ( @@ -542,4 +543,22 @@ export function ManualChat() { Unknown runtime tool names warn once in development and render nothing. Add a `parts.fallback` for unknown part types. +Automatic dispatch is the default. You can also pick a registered widget at the call site, like Form `field.TextField` and Table `cell.TextCell`: + +```tsx + + {(parts) => + parts.map((part, index) => ( + + {(p) => (part.key === 'toolCall' ? : )} + + )) + } + +``` + +`p.getWeather` and `p.text` are the widgets you passed to the factory. `p.Render` walks this one part the automatic way. `UI.Input` is mixed onto the kit when you register `input`. + +If widgets live in other files, call `createChatHookContexts()` first and pass `chatContext`, `partContext`, and `interruptContext` into `chatComponents`. That breaks the circular import, the same way Form uses `createFormHookContexts`. + See also [Solid](./solid), [Vue](./vue), [Svelte](./svelte), and [custom adapters](./custom-adapters). diff --git a/docs/ui/solid.md b/docs/ui/solid.md index b5fcaeb36e..d3308bbee1 100644 --- a/docs/ui/solid.md +++ b/docs/ui/solid.md @@ -11,17 +11,17 @@ keywords: - ToolProps --- -Install `@tanstack/ai-solid-ui`, then call `createChatUI(chatOptions)` once at module scope. Do not destructure reactive props. +Install `@tanstack/ai-solid`. Import the UI factory from `@tanstack/ai-solid/ui`. Call `createChatHook({ options, chatComponents })` once at module scope. Your app calls `useAppChat()` to create the instance. Render ``. Do not destructure reactive props. -`defineComponents` needs a `tools` entry for every tool name in `chatOptions`. It also needs an `interrupts.generic` entry for every interrupt id. `generic.fallback` is optional. +The factory needs a `tools` entry for every tool name in `chatOptions`. It also needs an `interrupts.generic` entry for every interrupt id. `generic.fallback` is optional. Pass widgets in `chatComponents`, the same way Form and Table register components. The server route matches the [React page](./react). Use `gpt-5.6` on the OpenAI text adapter. ## Client ```tsx -import { fetchServerSentEvents, useChat } from '@tanstack/ai-solid' -import { createChatUI } from '@tanstack/ai-solid-ui' +import { fetchServerSentEvents } from '@tanstack/ai-solid' +import { createChatHook } from '@tanstack/ai-solid/ui' import { toolDefinition } from '@tanstack/ai' import { z } from 'zod' @@ -37,9 +37,9 @@ const chatOptions = { tools: [getWeather], } -const UI = createChatUI(chatOptions) - -const components = UI.defineComponents({ +const { useAppChat } = createChatHook({ + options: chatOptions, + chatComponents: { layout: (props) => ( <> {props.renderMessages()} @@ -54,11 +54,12 @@ const components = UI.defineComponents({ tools: { getWeather: (props) => {props.part.input?.city}, }, + }, }) -export function ChatScreen() { - const chat = useChat(chatOptions) - return +export function Support() { + const chat = useAppChat({ threadId: 'support-1' }) + return } ``` @@ -68,7 +69,7 @@ Use `ToolProps` the same way as React. Keep the `props` object so Solid can trac ```tsx import { fetchServerSentEvents } from '@tanstack/ai-solid' -import { createChatUI, type ToolProps } from '@tanstack/ai-solid-ui' +import { createChatUI, type ToolProps } from '@tanstack/ai-solid/ui' import { toolDefinition } from '@tanstack/ai' import { z } from 'zod' @@ -90,9 +91,7 @@ export function WeatherTool( return {props.part.input?.city} } -const UI = createChatUI(chatOptions) - -export const components = UI.defineComponents({ +export const UI = createChatUI(chatOptions, { layout: (props) => props.renderMessages(), message: (props) =>
{props.renderParts()}
, parts: { fallback: () => null }, @@ -104,26 +103,24 @@ Part components use `PartProps`. Then `part` is alre Interrupt components use `InterruptProps`. Then `interrupt.payload` matches the definition. -Mapped components do not receive `chat` as a prop. Call `UI.useChat()` when a component needs live chat. That call opts the component into chat updates. Nested children can call it too. +Mapped components do not receive `chat` as a prop. Call `UI.useChatContext()` when a component needs live chat. That call opts the component into chat updates. Nested children can call it too. -## Read chat from `UI.useChat()` +## Read chat from `UI.useChatContext()` ```tsx import { fetchServerSentEvents, useChat } from '@tanstack/ai-solid' -import { createChatUI } from '@tanstack/ai-solid-ui' +import { createChatUI } from '@tanstack/ai-solid/ui' const chatOptions = { connection: fetchServerSentEvents('/api/chat'), } -const UI = createChatUI(chatOptions) - function StatusLine() { - const chat = UI.useChat() + const chat = UI.useChatContext() return

{chat.messages.length} messages

} -const components = UI.defineComponents({ +const UI = createChatUI(chatOptions, { layout: (props) => ( <> @@ -136,11 +133,11 @@ const components = UI.defineComponents({ export function ChatScreen() { const chat = useChat(chatOptions) - return + return } ``` -Call `UI.useChat()` only inside `UI.Chat` or `UI.Provider`. +Call `UI.useChatContext()` only inside `UI.Chat` or `UI.Provider`. ## Interrupts diff --git a/docs/ui/svelte.md b/docs/ui/svelte.md index 1fea54dca2..7d9da9666d 100644 --- a/docs/ui/svelte.md +++ b/docs/ui/svelte.md @@ -11,9 +11,9 @@ keywords: - ToolProps --- -Install `@tanstack/ai-svelte-ui`. Call `createChatUI(chatOptions)` once. Pass `{ui}`, `{chat}`, and `{components}` into `UIChat`. +Install `@tanstack/ai-svelte`. Import the UI factory from `@tanstack/ai-svelte/ui`. Call `createChatHook({ options, chatComponents })` once. Your app calls `createAppChat()` to create the instance. Pass `{ui}` and `{chat}` into `UIChat`. -`defineComponents` needs a `tools` entry for every tool name in `chatOptions`. It also needs an `interrupts.generic` entry for every interrupt id. `generic.fallback` is optional. +The factory needs a `tools` entry for every tool name in `chatOptions`. It also needs an `interrupts.generic` entry for every interrupt id. `generic.fallback` is optional. Pass widgets in `chatComponents`, the same way Form and Table register components. The server route matches the [React page](./react). Use `gpt-5.6` on the OpenAI text adapter. @@ -21,8 +21,8 @@ The server route matches the [React page](./react). Use `gpt-5.6` on the OpenAI ```svelte - + ``` `Layout.svelte` receives snippets `messages`, `interrupts`, and `input`. `Message.svelte` receives snippet `parts`. A tool with an approval receives prop `interrupt`. @@ -63,7 +65,7 @@ Type the `$props()` of a tool file with `ToolProps`. Share the same `chatOptions ```svelte

{chat.messages.length} messages

``` -`createChat(chatOptions)` owns the state. `ui.useChat()` reads the instance you passed into `UIChat`. A call outside that tree throws. +`createChat(chatOptions)` owns the state. `ui.useChatContext()` reads the instance you passed into `UIChat`. A call outside that tree throws. ## Interrupts diff --git a/docs/ui/vue.md b/docs/ui/vue.md index 0624e31bb6..18442b82b6 100644 --- a/docs/ui/vue.md +++ b/docs/ui/vue.md @@ -11,9 +11,9 @@ keywords: - ToolProps --- -Install `@tanstack/ai-vue-ui`. Call `createChatUI(chatOptions)` once. Pass the descriptor as `ui` into `UIChat`, `UIProvider`, and the other static primitives. +Install `@tanstack/ai-vue`. Import the UI factory from `@tanstack/ai-vue/ui`. Call `createChatHook({ options, chatComponents })` once. Your app calls `useAppChat()` to create the instance. Pass the descriptor as `ui` into `UIChat`, `UIProvider`, and the other static primitives. -`defineComponents` needs a `tools` entry for every tool name in `chatOptions`. It also needs an `interrupts.generic` entry for every interrupt id. `generic.fallback` is optional. +The factory needs a `tools` entry for every tool name in `chatOptions`. It also needs an `interrupts.generic` entry for every interrupt id. `generic.fallback` is optional. Pass widgets in `chatComponents`, the same way Form and Table register components. The server route matches the [React page](./react). Use `gpt-5.6` on the OpenAI text adapter. @@ -21,8 +21,8 @@ The server route matches the [React page](./react). Use `gpt-5.6` on the OpenAI ```ts import { defineComponent, h } from 'vue' -import { fetchServerSentEvents, useChat } from '@tanstack/ai-vue' -import { createChatUI, UIChat } from '@tanstack/ai-vue-ui' +import { fetchServerSentEvents } from '@tanstack/ai-vue' +import { createChatHook, UIChat } from '@tanstack/ai-vue/ui' import { toolDefinition } from '@tanstack/ai' import { z } from 'zod' @@ -38,9 +38,9 @@ const chatOptions = { tools: [getWeather], } -const ui = createChatUI(chatOptions) - -const components = ui.defineComponents({ +const { useAppChat, ui } = createChatHook({ + options: chatOptions, + chatComponents: { layout: defineComponent((_, { slots }) => () => h('div', [slots.messages?.(), slots.interrupts?.(), slots.input?.()]), ), @@ -61,12 +61,13 @@ const components = ui.defineComponents({ }, }), }, + }, }) export default defineComponent({ setup() { - const chat = useChat(chatOptions) - return () => h(UIChat, { ui, chat, components }) + const chat = useAppChat({ threadId: 'support-1' }) + return () => h(UIChat, { ui, chat }) }, }) ``` @@ -80,7 +81,7 @@ Use `ToolProps` on the component props. Share the same `chatOptions` object that ```ts import { defineComponent, h } from 'vue' import { fetchServerSentEvents } from '@tanstack/ai-vue' -import { createChatUI, type ToolProps } from '@tanstack/ai-vue-ui' +import { createChatUI, type ToolProps } from '@tanstack/ai-vue/ui' import { toolDefinition } from '@tanstack/ai' import { z } from 'zod' @@ -102,9 +103,7 @@ export const WeatherTool = defineComponent( }, ) -const ui = createChatUI(chatOptions) - -export const components = ui.defineComponents({ +export const ui = createChatUI(chatOptions, { layout: defineComponent((_, { slots }) => () => h('div', [slots.messages?.(), slots.interrupts?.(), slots.input?.()]), ), @@ -118,26 +117,24 @@ Part components use `PartProps`. Then `part` is alre Interrupt components use `InterruptProps`. Then `interrupt.payload` matches the definition. -Mapped components do not receive `chat` as a prop. Call `ui.useChat()` when a component needs live chat. That call opts the component into chat updates. Nested children can call it too. +Mapped components do not receive `chat` as a prop. Call `ui.useChatContext()` when a component needs live chat. That call opts the component into chat updates. Nested children can call it too. -## Read chat from `ui.useChat()` +## Read chat from `ui.useChatContext()` -Call `ui.useChat()` inside a child of `UIChat` or `UIProvider`. +Call `ui.useChatContext()` inside a child of `UIChat` or `UIProvider`. ```ts import { defineComponent, h } from 'vue' import { fetchServerSentEvents, useChat } from '@tanstack/ai-vue' -import { createChatUI, UIChat } from '@tanstack/ai-vue-ui' +import { createChatUI, UIChat } from '@tanstack/ai-vue/ui' const chatOptions = { connection: fetchServerSentEvents('/api/chat'), } -const ui = createChatUI(chatOptions) - const StatusLine = defineComponent({ setup() { - const chat = ui.useChat() + const chat = ui.useChatContext() return () => { const messages = Array.isArray(chat.messages) ? chat.messages : [] return h('p', String(messages.length) + ' messages') @@ -145,7 +142,7 @@ const StatusLine = defineComponent({ }, }) -const components = ui.defineComponents({ +const ui = createChatUI(chatOptions, { layout: defineComponent((_, { slots }) => () => h('main', [h(StatusLine), slots.messages?.()]), ), @@ -156,12 +153,12 @@ const components = ui.defineComponents({ export default defineComponent({ setup() { const chat = useChat(chatOptions) - return () => h(UIChat, { ui, chat, components }) + return () => h(UIChat, { ui, chat }) }, }) ``` -`useChat(chatOptions)` from `@tanstack/ai-vue` owns the state. `ui.useChat()` reads the instance you passed into `UIChat`. +`useChat(chatOptions)` from `@tanstack/ai-vue` owns the state. `ui.useChatContext()` reads the instance you passed into `UIChat`. ## Interrupts diff --git a/examples/README.md b/examples/README.md index d23370be39..2e03103fdd 100644 --- a/examples/README.md +++ b/examples/README.md @@ -164,7 +164,7 @@ A React SPA that connects to **Go, Rust, PHP, Zig, Bash, and Python chat servers **Tech Stack:** - React + Vite (SPA) -- `@tanstack/ai-react` + `@tanstack/ai-react-ui` +- `@tanstack/ai-react` + `@tanstack/ai-react/ui` - Go chat server (`net/http`, `:8001`) - Rust chat server (Axum, `:8002`) - PHP chat server (built-in server + curl, `:8003`) diff --git a/examples/ag-ui/README.md b/examples/ag-ui/README.md index 770997aaf1..38f4bd949e 100644 --- a/examples/ag-ui/README.md +++ b/examples/ag-ui/README.md @@ -8,7 +8,7 @@ This example shows that any backend can serve `@tanstack/ai-react` clients as lo | Layer | Stack | | ------------- | ------------------------------------------------------------------------ | -| Client | React, Vite, `@tanstack/ai-react`, `@tanstack/ai-react-ui` | +| Client | React, Vite, `@tanstack/ai-react`, `@tanstack/ai-react/ui` | | Go server | `net/http`, hand-rolled AG-UI SSE, OpenAI/Anthropic streaming on `:8001` | | Rust server | Axum, hand-rolled AG-UI SSE, OpenAI/Anthropic streaming on `:8002` | | PHP server | Built-in PHP server + curl, hand-rolled AG-UI SSE on `:8003` | diff --git a/examples/ag-ui/package.json b/examples/ag-ui/package.json index 5be67b3556..3e8c09fcd0 100644 --- a/examples/ag-ui/package.json +++ b/examples/ag-ui/package.json @@ -21,7 +21,6 @@ "dependencies": { "@tanstack/ai-client": "workspace:*", "@tanstack/ai-react": "workspace:*", - "@tanstack/ai-react-ui": "workspace:*", "react": "^19.2.3", "react-dom": "^19.2.3" }, diff --git a/examples/ag-ui/src/App.tsx b/examples/ag-ui/src/App.tsx index 84b0f5180e..3c536849c5 100644 --- a/examples/ag-ui/src/App.tsx +++ b/examples/ag-ui/src/App.tsx @@ -1,24 +1,16 @@ -import { useEffect, useMemo, useState } from 'react' +import { createContext, useContext, useEffect, useMemo, useState } from 'react' import { fetchServerSentEvents, useChat } from '@tanstack/ai-react' import type { ConnectionAdapter } from '@tanstack/ai-react' -import { ChatMessage, createChatUI } from '@tanstack/ai-react-ui' +import { ChatMessage, createChatUI } from '@tanstack/ai-react/ui' -const UI = createChatUI({}) +const LabelsContext = createContext({ placeholder: '', emptyLabel: '' }) -function AgUiChat({ - connection, - placeholder, - emptyLabel, -}: { - connection: ConnectionAdapter - placeholder: string - emptyLabel: string -}) { - const chat = useChat({ connection }) - const [draft, setDraft] = useState('') - const components = UI.defineComponents({ +const UI = createChatUI( + {}, + { layout: ({ renderMessages, renderInput }) => { - const current = UI.useChat() + const current = UI.useChatContext() + const { emptyLabel } = useContext(LabelsContext) return (
{current.error ? ( @@ -37,34 +29,55 @@ function AgUiChat({ ) }, message: ({ message }) => , - input: () => ( - { - event.preventDefault() - const text = draft.trim() - if (!text) return - setDraft('') - void chat.sendMessage(text) - }} - > - setDraft(event.target.value)} - /> - - - ), + setDraft(event.target.value)} + /> + + + ) + }, parts: { fallback: () => null }, - }) - return + }, +) + +function AgUiChat({ + connection, + placeholder, + emptyLabel, +}: { + connection: ConnectionAdapter + placeholder: string + emptyLabel: string +}) { + const chat = useChat({ connection }) + return ( + + + + ) } type Backend = 'go' | 'rust' | 'php' | 'zig' | 'bash' | 'python' diff --git a/examples/ts-react-chat/package.json b/examples/ts-react-chat/package.json index f685519340..18faaadfcf 100644 --- a/examples/ts-react-chat/package.json +++ b/examples/ts-react-chat/package.json @@ -42,7 +42,6 @@ "@tanstack/ai-openrouter": "workspace:*", "@tanstack/ai-persistence": "workspace:*", "@tanstack/ai-react": "workspace:*", - "@tanstack/ai-react-ui": "workspace:*", "@tanstack/ai-sandbox": "workspace:*", "@tanstack/ai-sandbox-daytona": "workspace:*", "@tanstack/ai-sandbox-docker": "workspace:*", diff --git a/examples/ts-react-chat/src/routes/index.tsx b/examples/ts-react-chat/src/routes/index.tsx index c40d1a01f9..9fdca10439 100644 --- a/examples/ts-react-chat/src/routes/index.tsx +++ b/examples/ts-react-chat/src/routes/index.tsx @@ -36,7 +36,7 @@ import { startOpenRouterPkceLogin, } from '@tanstack/ai-openrouter/pkce' import { clientTools } from '@tanstack/ai-client' -import { ThinkingPart } from '@tanstack/ai-react-ui' +import { ThinkingPart } from '@tanstack/ai-react/ui' import type { BoundInterrupts } from '@tanstack/ai-client' import type { UIMessage } from '@tanstack/ai-react' import type { ContentPart } from '@tanstack/ai' diff --git a/examples/ts-react-chat/src/routes/mcp-demo.tsx b/examples/ts-react-chat/src/routes/mcp-demo.tsx index 2bc99b5c6e..d3d196b558 100644 --- a/examples/ts-react-chat/src/routes/mcp-demo.tsx +++ b/examples/ts-react-chat/src/routes/mcp-demo.tsx @@ -7,7 +7,7 @@ import rehypeSanitize from 'rehype-sanitize' import rehypeHighlight from 'rehype-highlight' import remarkGfm from 'remark-gfm' import { fetchServerSentEvents, useChat } from '@tanstack/ai-react' -import { ThinkingPart } from '@tanstack/ai-react-ui' +import { ThinkingPart } from '@tanstack/ai-react/ui' import type { UIMessage } from '@tanstack/ai-react' import { MCP_PROVIDERS, type McpProvider } from '@/lib/mcp-providers' diff --git a/examples/ts-react-chat/src/routes/threads.tsx b/examples/ts-react-chat/src/routes/threads.tsx index 8889cd67fe..766d83b71c 100644 --- a/examples/ts-react-chat/src/routes/threads.tsx +++ b/examples/ts-react-chat/src/routes/threads.tsx @@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react' import { createFileRoute } from '@tanstack/react-router' import { fetchServerSentEvents, useChat } from '@tanstack/ai-react' import { clientTools } from '@tanstack/ai-client' -import { ThinkingPart } from '@tanstack/ai-react-ui' +import { ThinkingPart } from '@tanstack/ai-react/ui' import ReactMarkdown from 'react-markdown' import rehypeRaw from 'rehype-raw' import rehypeSanitize from 'rehype-sanitize' diff --git a/examples/ts-react-ui-chatbot/README.md b/examples/ts-react-ui-chatbot/README.md index b7d7e829c7..b02a26a5e5 100644 --- a/examples/ts-react-ui-chatbot/README.md +++ b/examples/ts-react-ui-chatbot/README.md @@ -1,6 +1,6 @@ # Typed headless chat + shadcn AI components -One-route TanStack Start app. It uses `createChatUI()` from `@tanstack/ai-react-ui` and the chatbot pieces from [shadcn.io/ai/chatbot](https://www.shadcn.io/ai/chatbot): Conversation, Message, Prompt Input, Model Selector, Reasoning, Sources, plus Tool for approvals. +One-route TanStack Start app. It uses `createChatHook()` from `@tanstack/ai-react/ui` and the chatbot pieces from [shadcn.io/ai/chatbot](https://www.shadcn.io/ai/chatbot): Conversation, Message, Prompt Input, Model Selector, Reasoning, Sources, plus Tool for approvals. ```bash pnpm install diff --git a/examples/ts-react-ui-chatbot/package.json b/examples/ts-react-ui-chatbot/package.json index 9508019edc..a33bc233fd 100644 --- a/examples/ts-react-ui-chatbot/package.json +++ b/examples/ts-react-ui-chatbot/package.json @@ -15,7 +15,6 @@ "@tanstack/ai-client": "workspace:*", "@tanstack/ai-openai": "workspace:*", "@tanstack/ai-react": "workspace:*", - "@tanstack/ai-react-ui": "workspace:*", "@tanstack/react-devtools": "^0.9.10", "@tanstack/react-router": "^1.158.4", "@tanstack/react-router-devtools": "^1.158.4", diff --git a/examples/ts-react-ui-chatbot/src/chat/options.ts b/examples/ts-react-ui-chatbot/src/chat/options.ts index 73870c0f50..7a5f8ffcde 100644 --- a/examples/ts-react-ui-chatbot/src/chat/options.ts +++ b/examples/ts-react-ui-chatbot/src/chat/options.ts @@ -1,5 +1,4 @@ import { fetchServerSentEvents } from '@tanstack/ai-react' -import { createChatUI } from '@tanstack/ai-react-ui' import { byok } from './byok' import { chatInterrupts } from './interrupts' import { itinerarySchema } from './schema' @@ -13,5 +12,3 @@ export const chatOptions = { byok, byokProvider: () => 'openai' as const, } - -export const UI = createChatUI(chatOptions) diff --git a/examples/ts-react-ui-chatbot/src/chat/ui-context.ts b/examples/ts-react-ui-chatbot/src/chat/ui-context.ts new file mode 100644 index 0000000000..d9170c09f7 --- /dev/null +++ b/examples/ts-react-ui-chatbot/src/chat/ui-context.ts @@ -0,0 +1,4 @@ +import { createChatHookContexts } from '@tanstack/ai-react/ui' + +export const { chatContext, partContext, interruptContext } = + createChatHookContexts() diff --git a/examples/ts-react-ui-chatbot/src/components/chat/book-stay-tool.tsx b/examples/ts-react-ui-chatbot/src/components/chat/book-stay-tool.tsx index ace01c9535..096e413244 100644 --- a/examples/ts-react-ui-chatbot/src/components/chat/book-stay-tool.tsx +++ b/examples/ts-react-ui-chatbot/src/components/chat/book-stay-tool.tsx @@ -1,4 +1,4 @@ -import type { InterruptProps, ToolProps } from '@tanstack/ai-react-ui' +import type { InterruptProps, ToolProps } from '@tanstack/ai-react/ui' import { Button } from '@/components/ui/button' import { Tool, diff --git a/examples/ts-react-ui-chatbot/src/components/chat/choose-budget.tsx b/examples/ts-react-ui-chatbot/src/components/chat/choose-budget.tsx index 4cdeabbff9..0cc24b8cd2 100644 --- a/examples/ts-react-ui-chatbot/src/components/chat/choose-budget.tsx +++ b/examples/ts-react-ui-chatbot/src/components/chat/choose-budget.tsx @@ -1,4 +1,4 @@ -import type { InterruptProps } from '@tanstack/ai-react-ui' +import type { InterruptProps } from '@tanstack/ai-react/ui' import { Button } from '@/components/ui/button' import type { chatOptions } from '@/chat/options' diff --git a/examples/ts-react-ui-chatbot/src/components/chat/confirm-payment-tool.tsx b/examples/ts-react-ui-chatbot/src/components/chat/confirm-payment-tool.tsx index 240c035860..4d1a6176de 100644 --- a/examples/ts-react-ui-chatbot/src/components/chat/confirm-payment-tool.tsx +++ b/examples/ts-react-ui-chatbot/src/components/chat/confirm-payment-tool.tsx @@ -1,4 +1,4 @@ -import type { InterruptProps, ToolProps } from '@tanstack/ai-react-ui' +import type { InterruptProps, ToolProps } from '@tanstack/ai-react/ui' import { Button } from '@/components/ui/button' import { Tool, diff --git a/examples/ts-react-ui-chatbot/src/components/chat/fallback-interrupt.tsx b/examples/ts-react-ui-chatbot/src/components/chat/fallback-interrupt.tsx index 7548b2f81a..ce8b986256 100644 --- a/examples/ts-react-ui-chatbot/src/components/chat/fallback-interrupt.tsx +++ b/examples/ts-react-ui-chatbot/src/components/chat/fallback-interrupt.tsx @@ -1,4 +1,4 @@ -import type { InterruptProps } from '@tanstack/ai-react-ui' +import type { InterruptProps } from '@tanstack/ai-react/ui' import type { chatOptions } from '@/chat/options' export function FallbackInterrupt({ diff --git a/examples/ts-react-ui-chatbot/src/components/chat/fallback-part.tsx b/examples/ts-react-ui-chatbot/src/components/chat/fallback-part.tsx index 1932e75ef9..2f46e96062 100644 --- a/examples/ts-react-ui-chatbot/src/components/chat/fallback-part.tsx +++ b/examples/ts-react-ui-chatbot/src/components/chat/fallback-part.tsx @@ -1,4 +1,4 @@ -import type { PartProps } from '@tanstack/ai-react-ui' +import type { PartProps } from '@tanstack/ai-react/ui' import type { chatOptions } from '@/chat/options' export function FallbackPart({ part }: PartProps) { diff --git a/examples/ts-react-ui-chatbot/src/components/chat/input.tsx b/examples/ts-react-ui-chatbot/src/components/chat/input.tsx index 19201c9b61..ac65ef3952 100644 --- a/examples/ts-react-ui-chatbot/src/components/chat/input.tsx +++ b/examples/ts-react-ui-chatbot/src/components/chat/input.tsx @@ -13,7 +13,7 @@ import { XIcon, } from 'lucide-react' import { byok } from '@/chat/byok' -import { UI } from '@/chat/options' +import { useChatContext } from '@/chat/ui-context' import { PromptInput, PromptInputButton, @@ -69,7 +69,7 @@ async function fileToDraft(file: File): Promise { } export function ChatPromptInput() { - const chat = UI.useChat() + const chat = useChatContext() const [model, setModel] = useState(selectedModel) const [text, setText] = useState('') const [drafts, setDrafts] = useState>([]) diff --git a/examples/ts-react-ui-chatbot/src/components/chat/layout.tsx b/examples/ts-react-ui-chatbot/src/components/chat/layout.tsx index 1857bd5bc0..8e6071d836 100644 --- a/examples/ts-react-ui-chatbot/src/components/chat/layout.tsx +++ b/examples/ts-react-ui-chatbot/src/components/chat/layout.tsx @@ -1,5 +1,5 @@ import { CompassIcon } from 'lucide-react' -import type { LayoutProps } from '@tanstack/ai-react-ui' +import type { LayoutProps } from '@tanstack/ai-react/ui' import type { ContentPart } from '@tanstack/ai/client' import { Conversation, @@ -8,8 +8,8 @@ import { ConversationScrollButton, } from '@/components/ai/conversation' import { Suggestion, Suggestions } from '@/components/ai/suggestions' -import { UI } from '@/chat/options' import type { chatOptions } from '@/chat/options' +import { useChatContext } from './ui-components' const LISBON_PHOTO = 'https://images.unsplash.com/photo-1555881400-74d7acaacd8b?w=800&q=80' @@ -39,7 +39,7 @@ export function ChatLayout({ renderInterrupts, renderInput, }: LayoutProps) { - const chat = UI.useChat() + const chat = useChatContext() if (chat.error) { return

{chat.error.message}

} diff --git a/examples/ts-react-ui-chatbot/src/components/chat/lookup-place-tool.tsx b/examples/ts-react-ui-chatbot/src/components/chat/lookup-place-tool.tsx index f5c18a0412..1c2ef8d4ad 100644 --- a/examples/ts-react-ui-chatbot/src/components/chat/lookup-place-tool.tsx +++ b/examples/ts-react-ui-chatbot/src/components/chat/lookup-place-tool.tsx @@ -1,4 +1,4 @@ -import type { ToolProps } from '@tanstack/ai-react-ui' +import type { ToolProps } from '@tanstack/ai-react/ui' import { Source, Sources, diff --git a/examples/ts-react-ui-chatbot/src/components/chat/media-parts.tsx b/examples/ts-react-ui-chatbot/src/components/chat/media-parts.tsx index 98f433dcd5..d10a57f218 100644 --- a/examples/ts-react-ui-chatbot/src/components/chat/media-parts.tsx +++ b/examples/ts-react-ui-chatbot/src/components/chat/media-parts.tsx @@ -1,4 +1,4 @@ -import type { PartProps } from '@tanstack/ai-react-ui' +import type { PartProps } from '@tanstack/ai-react/ui' import type { chatOptions } from '@/chat/options' function sourceHref(source: { diff --git a/examples/ts-react-ui-chatbot/src/components/chat/message.tsx b/examples/ts-react-ui-chatbot/src/components/chat/message.tsx index c65bf3c3c1..d32ed35068 100644 --- a/examples/ts-react-ui-chatbot/src/components/chat/message.tsx +++ b/examples/ts-react-ui-chatbot/src/components/chat/message.tsx @@ -1,4 +1,4 @@ -import type { MessageProps } from '@tanstack/ai-react-ui' +import type { MessageProps } from '@tanstack/ai-react/ui' import { Message, MessageAvatar, MessageContent } from '@/components/ai/message' import type { chatOptions } from '@/chat/options' diff --git a/examples/ts-react-ui-chatbot/src/components/chat/structured-output.tsx b/examples/ts-react-ui-chatbot/src/components/chat/structured-output.tsx index 4962289cce..fff90c4e68 100644 --- a/examples/ts-react-ui-chatbot/src/components/chat/structured-output.tsx +++ b/examples/ts-react-ui-chatbot/src/components/chat/structured-output.tsx @@ -1,4 +1,4 @@ -import type { PartProps } from '@tanstack/ai-react-ui' +import type { PartProps } from '@tanstack/ai-react/ui' import type { chatOptions } from '@/chat/options' export function StructuredOutputPart({ diff --git a/examples/ts-react-ui-chatbot/src/components/chat/text-part.tsx b/examples/ts-react-ui-chatbot/src/components/chat/text-part.tsx index ed8b1c5f1c..277c212168 100644 --- a/examples/ts-react-ui-chatbot/src/components/chat/text-part.tsx +++ b/examples/ts-react-ui-chatbot/src/components/chat/text-part.tsx @@ -1,4 +1,4 @@ -import type { PartProps } from '@tanstack/ai-react-ui' +import type { PartProps } from '@tanstack/ai-react/ui' import { MessageResponse } from '@/components/ai/message' import type { chatOptions } from '@/chat/options' diff --git a/examples/ts-react-ui-chatbot/src/components/chat/thinking-part.tsx b/examples/ts-react-ui-chatbot/src/components/chat/thinking-part.tsx index 6fe0d4b86a..39ca921106 100644 --- a/examples/ts-react-ui-chatbot/src/components/chat/thinking-part.tsx +++ b/examples/ts-react-ui-chatbot/src/components/chat/thinking-part.tsx @@ -1,4 +1,4 @@ -import type { PartProps } from '@tanstack/ai-react-ui' +import type { PartProps } from '@tanstack/ai-react/ui' import { Reasoning, ReasoningContent, diff --git a/examples/ts-react-ui-chatbot/src/components/chat/ui-components.tsx b/examples/ts-react-ui-chatbot/src/components/chat/ui-components.tsx index f09b9e1fec..6ba37e2648 100644 --- a/examples/ts-react-ui-chatbot/src/components/chat/ui-components.tsx +++ b/examples/ts-react-ui-chatbot/src/components/chat/ui-components.tsx @@ -1,4 +1,6 @@ -import { UI } from '@/chat/options' +import { createChatHook } from '@tanstack/ai-react/ui' +import { chatOptions } from '@/chat/options' +import { chatContext, interruptContext, partContext } from '@/chat/ui-context' import { BookStayApproval, BookStayTool } from './book-stay-tool' import { ConfirmPaymentTool } from './confirm-payment-tool' import { ChatPromptInput } from './input' @@ -20,34 +22,40 @@ import { StructuredOutputPart } from './structured-output' import { TextPart } from './text-part' import { ThinkingPart } from './thinking-part' -export const components = UI.defineComponents({ - layout: ChatLayout, - message: ChatMessage, - input: ChatPromptInput, - parts: { - text: TextPart, - thinking: ThinkingPart, - structuredOutput: StructuredOutputPart, - image: ImagePart, - audio: AudioPart, - video: VideoPart, - document: DocumentPart, - toolResult: ToolResultPart, - uiResource: UIResourcePart, - fallback: FallbackPart, - }, - tools: { - lookupPlace: LookupPlaceTool, - bookStay: BookStayTool, - confirmPayment: ConfirmPaymentTool, - }, - interrupts: { +export const { useAppChat, useChatContext } = createChatHook({ + options: chatOptions, + chatComponents: { + chatContext, + partContext, + interruptContext, + layout: ChatLayout, + message: ChatMessage, + input: ChatPromptInput, + parts: { + text: TextPart, + thinking: ThinkingPart, + structuredOutput: StructuredOutputPart, + image: ImagePart, + audio: AudioPart, + video: VideoPart, + document: DocumentPart, + toolResult: ToolResultPart, + uiResource: UIResourcePart, + fallback: FallbackPart, + }, tools: { - bookStay: BookStayApproval, + lookupPlace: LookupPlaceTool, + bookStay: BookStayTool, + confirmPayment: ConfirmPaymentTool, }, - generic: { - chooseBudget: ChooseBudget, - fallback: FallbackInterrupt, + interrupts: { + tools: { + bookStay: BookStayApproval, + }, + generic: { + chooseBudget: ChooseBudget, + fallback: FallbackInterrupt, + }, }, }, }) diff --git a/examples/ts-react-ui-chatbot/src/routes/index.tsx b/examples/ts-react-ui-chatbot/src/routes/index.tsx index 4006447d35..073c08c43d 100644 --- a/examples/ts-react-ui-chatbot/src/routes/index.tsx +++ b/examples/ts-react-ui-chatbot/src/routes/index.tsx @@ -1,19 +1,15 @@ import { createFileRoute } from '@tanstack/react-router' -import { useChat } from '@tanstack/ai-react' -import { UI } from '@/chat/options' -import { chatOptions } from '@/chat/options' import { selectedModel } from '@/chat/model' -import { components } from '@/components/chat/ui-components' +import { useAppChat } from '@/components/chat/ui-components' export const Route = createFileRoute('/')({ component: TripDesk, }) function TripDesk() { - const chat = useChat({ - ...chatOptions, - get forwardedProps() { - return { model: selectedModel } + const chat = useAppChat({ + forwardedProps: { + model: selectedModel, }, }) @@ -32,7 +28,7 @@ function TripDesk() { src="/brand/logos/tanstack-landscape-white.svg" /> - +
) } diff --git a/examples/ts-solid-chat/package.json b/examples/ts-solid-chat/package.json index c00979bd29..bccf27dce9 100644 --- a/examples/ts-solid-chat/package.json +++ b/examples/ts-solid-chat/package.json @@ -19,7 +19,6 @@ "@tanstack/ai-ollama": "workspace:*", "@tanstack/ai-openai": "workspace:*", "@tanstack/ai-solid": "workspace:*", - "@tanstack/ai-solid-ui": "workspace:*", "@tanstack/nitro-v2-vite-plugin": "^1.155.0", "@tanstack/router-plugin": "^1.158.4", "@tanstack/solid-ai-devtools": "workspace:*", diff --git a/examples/ts-solid-chat/src/routes/index.tsx b/examples/ts-solid-chat/src/routes/index.tsx index fba21a969d..9023b4d27d 100644 --- a/examples/ts-solid-chat/src/routes/index.tsx +++ b/examples/ts-solid-chat/src/routes/index.tsx @@ -3,7 +3,7 @@ import Send from 'lucide-solid/icons/send' import Square from 'lucide-solid/icons/square' import { fetchServerSentEvents, useChat } from '@tanstack/ai-solid' import { createChatClientOptions } from '@tanstack/ai-client' -import { TextPart, ThinkingPart } from '@tanstack/ai-solid-ui' +import { TextPart, ThinkingPart } from '@tanstack/ai-solid/ui' import { For, createSignal } from 'solid-js' import type { InferChatMessages, UIMessage } from '@tanstack/ai-client' diff --git a/examples/ts-solid-chat/src/routes/solid-ui.tsx b/examples/ts-solid-chat/src/routes/solid-ui.tsx index 017a61e989..5cfc7fffa6 100644 --- a/examples/ts-solid-chat/src/routes/solid-ui.tsx +++ b/examples/ts-solid-chat/src/routes/solid-ui.tsx @@ -1,6 +1,6 @@ import { createFileRoute } from '@tanstack/solid-router' -import { fetchServerSentEvents, useChat } from '@tanstack/ai-solid' -import { createChatUI } from '@tanstack/ai-solid-ui' +import { fetchServerSentEvents } from '@tanstack/ai-solid' +import { createChatHook } from '@tanstack/ai-solid/ui' import { createSignal } from 'solid-js' import { clientTools } from '@/lib/guitar-tools' @@ -9,12 +9,9 @@ const chatOptions = { tools: clientTools, } -const UI = createChatUI(chatOptions) - -function SolidUIPage() { - const chat = useChat(chatOptions) - const [draft, setDraft] = createSignal('') - const components = UI.defineComponents({ +const { useAppChat, useChatContext } = createChatHook({ + options: chatOptions, + chatComponents: { layout: (props) => (
@@ -28,25 +25,29 @@ function SolidUIPage() { {props.renderParts()} ), - input: () => ( -
{ - event.preventDefault() - const text = draft().trim() - if (!text) return - setDraft('') - void chat.sendMessage(text) - }} - > - setDraft(event.currentTarget.value)} - /> -
- ), + input: function Input() { + const chat = useChatContext() + const [draft, setDraft] = createSignal('') + return ( +
{ + event.preventDefault() + const text = draft().trim() + if (!text) return + setDraft('') + void chat.sendMessage(text) + }} + > + setDraft(event.currentTarget.value)} + /> +
+ ) + }, parts: { fallback: (props) => props.part.type === 'text' ?

{props.part.content}

: null, @@ -83,9 +84,12 @@ function SolidUIPage() {

), }, - }) + }, +}) - return +function SolidUIPage() { + const chat = useAppChat() + return } export const Route = createFileRoute('/solid-ui')({ diff --git a/examples/ts-solid-chat/vite.config.ts b/examples/ts-solid-chat/vite.config.ts index b7c77c0506..c248359822 100644 --- a/examples/ts-solid-chat/vite.config.ts +++ b/examples/ts-solid-chat/vite.config.ts @@ -10,7 +10,7 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)) const config = defineConfig({ ssr: { - noExternal: ['@tanstack/ai-solid-ui'], + noExternal: ['@tanstack/ai-solid/ui'], }, resolve: { tsconfigPaths: true, diff --git a/examples/ts-svelte-chat/package.json b/examples/ts-svelte-chat/package.json index 5ce76cb7c7..80a3e08237 100644 --- a/examples/ts-svelte-chat/package.json +++ b/examples/ts-svelte-chat/package.json @@ -20,7 +20,6 @@ "@tanstack/ai-ollama": "workspace:*", "@tanstack/ai-openai": "workspace:*", "@tanstack/ai-svelte": "workspace:*", - "@tanstack/ai-svelte-ui": "workspace:*", "highlight.js": "^11.11.1", "lucide-svelte": "^0.468.0", "marked": "^15.0.6", diff --git a/examples/ts-svelte-chat/src/routes/svelte-ui/+page.svelte b/examples/ts-svelte-chat/src/routes/svelte-ui/+page.svelte index 137b8d005c..e0b067957a 100644 --- a/examples/ts-svelte-chat/src/routes/svelte-ui/+page.svelte +++ b/examples/ts-svelte-chat/src/routes/svelte-ui/+page.svelte @@ -1,10 +1,6 @@ - TanStack AI - Svelte createChatUI + TanStack AI - Svelte createChatHook - + diff --git a/examples/ts-vue-chat/package.json b/examples/ts-vue-chat/package.json index 26964075ea..fdc51a7b56 100644 --- a/examples/ts-vue-chat/package.json +++ b/examples/ts-vue-chat/package.json @@ -18,7 +18,6 @@ "@tanstack/ai-ollama": "workspace:*", "@tanstack/ai-openai": "workspace:*", "@tanstack/ai-vue": "workspace:*", - "@tanstack/ai-vue-ui": "workspace:*", "marked": "^15.0.6", "vue": "^3.5.25", "vue-router": "^4.5.0", diff --git a/examples/ts-vue-chat/src/views/VueUIView.vue b/examples/ts-vue-chat/src/views/VueUIView.vue index face539c0d..667373d0c1 100644 --- a/examples/ts-vue-chat/src/views/VueUIView.vue +++ b/examples/ts-vue-chat/src/views/VueUIView.vue @@ -1,8 +1,8 @@ diff --git a/knip.json b/knip.json index e6abef78b5..66b0d7c6a4 100644 --- a/knip.json +++ b/knip.json @@ -58,17 +58,11 @@ "ignoreDependencies": ["cloudflare"] }, "packages/ai-react": { - "ignoreDependencies": ["@mcp-ui/client"] + "ignoreDependencies": ["@mcp-ui/client", "react-dom"] }, "packages/ai-preact": { "ignoreDependencies": ["@mcp-ui/client"] }, - "packages/ai-react-ui": { - "ignoreDependencies": ["react-dom"] - }, - "packages/ai-vue-ui": { - "ignore": ["src/use-chat-context.ts"] - }, "packages/ai-bedrock": {}, "packages/ai-grok-build": { "ignoreDependencies": ["@tanstack/ai-acp"] diff --git a/packages/ai-client/src/ui.ts b/packages/ai-client/src/ui.ts index d745111870..b7f59e1148 100644 --- a/packages/ai-client/src/ui.ts +++ b/packages/ai-client/src/ui.ts @@ -24,6 +24,7 @@ export type { ChatUISelectedGenericPart, ChatUISelectedMessage, ChatUISelectedPart, + ChatUISelectedPartOf, ChatUISelectedResultPart, ChatUISelectedToolPart, ChatUISelection, diff --git a/packages/ai-client/src/ui/types.ts b/packages/ai-client/src/ui/types.ts index 32aad63230..85f3b58e69 100644 --- a/packages/ai-client/src/ui/types.ts +++ b/packages/ai-client/src/ui/types.ts @@ -206,6 +206,18 @@ export type ChatUISelectedPart = | ChatUISelectedResultPart | ChatUISelectedGenericPart +export type ChatUISelectedPartOf< + TOptions, + TKey extends ChatUIPartKey = ChatUIPartKey, +> = TKey extends 'toolCall' + ? ChatUIToolPart + : TKey extends 'toolResult' + ? ChatUISelectedResultPart + : { + key: TKey + part: ChatUIPartOf + } + export type ChatUISelectedMessage = { message: UIMessage parts: Array diff --git a/packages/ai-client/tests/ui-types.test-d.ts b/packages/ai-client/tests/ui-types.test-d.ts index 87aec41609..0d570e6ee4 100644 --- a/packages/ai-client/tests/ui-types.test-d.ts +++ b/packages/ai-client/tests/ui-types.test-d.ts @@ -3,6 +3,7 @@ import type { ChatUIData, ChatUIInterrupt, ChatUIInterruptOf, + ChatUISelectedPartOf, ChatUIToolPart, RegisteredUIInterrupt, } from '../src/ui' @@ -35,3 +36,7 @@ expectTypeOf().toEqualTypeOf<'purchaseItem'>() type AnyInterrupt = ChatUIInterruptOf expectTypeOf().toEqualTypeOf() + +type TextSelected = ChatUISelectedPartOf +expectTypeOf().toEqualTypeOf<'text'>() +expectTypeOf().toEqualTypeOf() diff --git a/packages/ai-react-ui/CHANGELOG.md b/packages/ai-react-ui/CHANGELOG.md deleted file mode 100644 index 591480e967..0000000000 --- a/packages/ai-react-ui/CHANGELOG.md +++ /dev/null @@ -1,442 +0,0 @@ -# @tanstack/ai-react-ui - -## 0.8.22 - -### Patch Changes - -- Updated dependencies [[`62c19ed`](https://github.com/TanStack/ai/commit/62c19edce7a814d868491ca920003899ec4c486b), [`4e9c5d2`](https://github.com/TanStack/ai/commit/4e9c5d2887dc9a3d64fce6ed424aa68f451e20f6), [`62c19ed`](https://github.com/TanStack/ai/commit/62c19edce7a814d868491ca920003899ec4c486b)]: - - @tanstack/ai-client@0.29.0 - - @tanstack/ai-react@0.22.2 - -## 0.8.21 - -### Patch Changes - -- Updated dependencies [[`ad3840c`](https://github.com/TanStack/ai/commit/ad3840c44bd09860b29cd22f24a0cdce3adaf9dc), [`365efb8`](https://github.com/TanStack/ai/commit/365efb870e5a3fe084d0969f71781ffc772e39a0), [`a955710`](https://github.com/TanStack/ai/commit/a9557101048286c225145bad058ca81ff275b330), [`a74817a`](https://github.com/TanStack/ai/commit/a74817a38e8deb4c7ff86d81067db12befbadf5d)]: - - @tanstack/ai-client@0.28.0 - - @tanstack/ai-react@0.22.1 - -## 0.8.20 - -### Patch Changes - -- Updated dependencies [[`b7ebcb0`](https://github.com/TanStack/ai/commit/b7ebcb0bbe63e425facb5e38f138bd0cd36637dd)]: - - @tanstack/ai-client@0.27.0 - - @tanstack/ai-react@0.22.0 - -## 0.8.19 - -### Patch Changes - -- Updated dependencies [[`1c0415b`](https://github.com/TanStack/ai/commit/1c0415bec4bbefcd3abf784d0209af05aca5db46)]: - - @tanstack/ai-client@0.26.0 - - @tanstack/ai-react@0.21.3 - -## 0.8.18 - -### Patch Changes - -- Updated dependencies [[`5f68cbc`](https://github.com/TanStack/ai/commit/5f68cbccf3621b48dae73cedcb1e59cb4cbe72b4), [`32e62ab`](https://github.com/TanStack/ai/commit/32e62ab8b7dc6a8a13ca3851c8925ab806e08f29)]: - - @tanstack/ai-client@0.25.0 - - @tanstack/ai-react@0.21.0 - -## 0.8.17 - -### Patch Changes - -- Updated dependencies [[`f6b9d89`](https://github.com/TanStack/ai/commit/f6b9d89e7365f0cb30a41cf3a46b7dd237fbc73c), [`ecd12a4`](https://github.com/TanStack/ai/commit/ecd12a408987bc75649c21aada6948282a2a66dd)]: - - @tanstack/ai-client@0.24.0 - - @tanstack/ai-react@0.20.0 - -## 0.8.16 - -### Patch Changes - -- Updated dependencies [[`7499171`](https://github.com/TanStack/ai/commit/74991716aea4d90a5d0363676a1e3349689a48e8)]: - - @tanstack/ai-client@0.23.0 - - @tanstack/ai-react@0.19.0 - -## 0.8.15 - -### Patch Changes - -- Updated dependencies [[`35946e3`](https://github.com/TanStack/ai/commit/35946e3c39fb123c133ebe662f8e2cf0139f2b8c)]: - - @tanstack/ai-client@0.22.0 - - @tanstack/ai-react@0.18.0 - -## 0.8.14 - -### Patch Changes - -- Updated dependencies [[`2665085`](https://github.com/TanStack/ai/commit/2665085970ab4d792778bb2b635ef27fbdcb6be1), [`f830d9e`](https://github.com/TanStack/ai/commit/f830d9e7a41e3554c424c3e41ba847dfd1577589), [`f830d9e`](https://github.com/TanStack/ai/commit/f830d9e7a41e3554c424c3e41ba847dfd1577589)]: - - @tanstack/ai-client@0.21.0 - - @tanstack/ai-react@0.17.0 - -## 0.8.13 - -### Patch Changes - -- Updated dependencies [[`5deda27`](https://github.com/TanStack/ai/commit/5deda27085c8785894a28feb5bb3655dbd8f7e0a)]: - - @tanstack/ai-client@0.20.0 - - @tanstack/ai-react@0.16.4 - -## 0.8.12 - -### Patch Changes - -- Updated dependencies [[`33acdd4`](https://github.com/TanStack/ai/commit/33acdd4df4aef13d594700d9b52087252091bd40), [`c22c663`](https://github.com/TanStack/ai/commit/c22c6632fdca761033cb9c4273bf61fc8ce86662), [`c1a8732`](https://github.com/TanStack/ai/commit/c1a87327b4a3463d37158f32ca90184b5fd092bb)]: - - @tanstack/ai-client@0.19.0 - - @tanstack/ai-react@0.16.0 - -## 0.8.11 - -### Patch Changes - -- [#844](https://github.com/TanStack/ai/pull/844) [`a6cceba`](https://github.com/TanStack/ai/commit/a6cceba4812e7e986183ee856112fcf5f8fa12ff) - Republish all packages with their compiled `dist/` output. - - Releases `0.33.0`–`0.36.0` were published without a `dist/` directory: the - release workflow relied on an Nx-cached `build` whose outputs were not - materialized to disk before `changeset publish` packed the tarballs, and - `files: ["dist"]` silently includes nothing when `dist/` is absent. The - published packages therefore contained only `src/`, so every export - (`./dist/esm/*.js`) resolved to a missing file and the packages were - uninstallable. - - The publish step now runs a fresh, cache-bypassing build of all packages - immediately before publishing, guaranteeing compiled artifacts are present in - every tarball. - -- Updated dependencies [[`a6cceba`](https://github.com/TanStack/ai/commit/a6cceba4812e7e986183ee856112fcf5f8fa12ff)]: - - @tanstack/ai-client@0.18.6 - - @tanstack/ai-react@0.15.15 - -## 0.8.10 - -### Patch Changes - -- [#783](https://github.com/TanStack/ai/pull/783) [`0278a90`](https://github.com/TanStack/ai/commit/0278a900e80ab7203124a74c841222b20b1c3bd3) - Fix `ChatMessage` rendering of multimodal tool results. Tool-result content is `string | Array`, but the renderer previously typed the message part as `any` and passed the raw content straight to React — an array of content-part objects would throw React's "Objects are not valid as a React child". The part is now typed as `UIMessage['parts'][number]`, and array content is flattened to the concatenation of its text parts (non-text parts are skipped) before rendering, both for the built-in renderer and the `toolResultRenderer` prop. - -- Updated dependencies [[`540cbf1`](https://github.com/TanStack/ai/commit/540cbf18a2f7d6c07b44f7f4da0ac3873c0d2581)]: - - @tanstack/ai-client@0.18.3 - - @tanstack/ai-react@0.15.12 - -## 0.8.9 - -### Patch Changes - -- Updated dependencies [[`8fa6cc5`](https://github.com/TanStack/ai/commit/8fa6cc56c5f36e22885c98a511dcceb2bfc0da1f)]: - - @tanstack/ai-client@0.18.0 - - @tanstack/ai-react@0.15.9 - -## 0.8.8 - -### Patch Changes - -- [#769](https://github.com/TanStack/ai/pull/769) [`1d1bb52`](https://github.com/TanStack/ai/commit/1d1bb5219a38d9718cc926148e93fc27d5d2305b) - Add repository metadata (`homepage`, `bugs`, `funding`), fix `repository.directory` to point at each package, and include an MIT `LICENSE` file in every published package. - -- Updated dependencies [[`1d1bb52`](https://github.com/TanStack/ai/commit/1d1bb5219a38d9718cc926148e93fc27d5d2305b)]: - - @tanstack/ai-client@0.17.1 - - @tanstack/ai-react@0.15.6 - -## 0.8.7 - -### Patch Changes - -- Updated dependencies [[`7d44569`](https://github.com/TanStack/ai/commit/7d445693ea079d7a85498a4465179ddd5f548cb0)]: - - @tanstack/ai-client@0.17.0 - - @tanstack/ai-react@0.15.5 - -## 0.8.6 - -### Patch Changes - -- Updated dependencies [[`755e995`](https://github.com/TanStack/ai/commit/755e9953a31e879c4b88df0e7672ce1224886c97)]: - - @tanstack/ai-client@0.16.0 - - @tanstack/ai-react@0.15.0 - -## 0.8.5 - -### Patch Changes - -- Updated dependencies [[`c1ae8b9`](https://github.com/TanStack/ai/commit/c1ae8b94c83d70508975568eb4fc9b45f1af540b), [`8036b50`](https://github.com/TanStack/ai/commit/8036b5054330a180023c6e3225b8d2735a43a919)]: - - @tanstack/ai-client@0.15.0 - - @tanstack/ai-react@0.14.0 - -## 0.8.4 - -### Patch Changes - -- Updated dependencies [[`d5645cf`](https://github.com/TanStack/ai/commit/d5645cfd4d1b9cfc877f7d4d714517e166a99ce3)]: - - @tanstack/ai-client@0.14.0 - - @tanstack/ai-react@0.13.0 - -## 0.8.3 - -### Patch Changes - -- Updated dependencies [[`5634f18`](https://github.com/TanStack/ai/commit/5634f186a4946ca3e1942fbfcbf1291ec9bd9855)]: - - @tanstack/ai-client@0.13.0 - - @tanstack/ai-react@0.12.1 - -## 0.8.2 - -### Patch Changes - -- Updated dependencies [[`ad23da9`](https://github.com/TanStack/ai/commit/ad23da92c279759b3778672dcee3d1616a02994b)]: - - @tanstack/ai-client@0.12.0 - - @tanstack/ai-react@0.12.0 - -## 0.8.1 - -### Patch Changes - -- Refresh package README content and npm metadata for better discoverability. ([#626](https://github.com/TanStack/ai/pull/626)) - -- Updated dependencies [[`ebeb22e`](https://github.com/TanStack/ai/commit/ebeb22ec68f456b09e0181ac6f5d1ac25a0affd2)]: - - @tanstack/ai-client@0.11.6 - - @tanstack/ai-react@0.11.6 - -## 0.8.0 - -### Minor Changes - -- `TextPart` now accepts `remarkPlugins`, `rehypePlugins`, and (React/Solid) ([#599](https://github.com/TanStack/ai/pull/599)) - `components` props, plus a `disableDefaultPlugins` escape hatch. User plugins - merge with the secure defaults — `rehype-sanitize` continues to run last - unless defaults are disabled. - - This fixes [#164](https://github.com/TanStack/ai/issues/164): bold and - emphasis in Japanese, Chinese, and Korean text rendered incorrectly because - of a CommonMark spec defect. Consumers can now drop in - [`remark-cjk-friendly`](https://www.npmjs.com/package/remark-cjk-friendly) - with a single prop: - - ```tsx - import remarkCjkFriendly from 'remark-cjk-friendly' - ; - ``` - - Also fixes a latent bug in `@tanstack/ai-react-ui` where `remark-gfm` was - passed inside the rehype plugin array, silently disabling GFM features - (tables, strikethrough, task lists) in the React `TextPart`. - - `@tanstack/ai-vue-ui` omits the `components` prop because its underlying - renderer (`@crazydos/vue-markdown`) does not expose component overrides; - use that library's slot API for custom rendering. - -### Patch Changes - -- Adopt `@tanstack/eslint-config@0.4.0` and clean up the local override layer. ([#607](https://github.com/TanStack/ai/pull/607)) - - Bump `@tanstack/eslint-config` from `0.3.3` to `0.4.0`. - - Drop dead `pnpm/enforce-catalog` and `pnpm/json-enforce-catalog` disables (upstream removed `eslint-plugin-pnpm` in `0.3.1`). - - Drop the `no-case-declarations: off` override — no current source actually violates it. - - Drop the `no-shadow: off` override — upstream sets it to `warn`, so it surfaces in editors without blocking CI. - - Remove ~25 unnecessary type assertions across the publishable packages that the upgraded `typescript-eslint` now catches via `no-unnecessary-type-assertion`. One deliberately defensive cast in `ag-ui-wire.ts` is preserved with an inline opt-out and a reason comment. - - No public-API or runtime-behavior changes. - -- Updated dependencies [[`a03d12b`](https://github.com/TanStack/ai/commit/a03d12b13ade93f3e262c6ffa996696ce27472ef)]: - - @tanstack/ai-client@0.11.4 - - @tanstack/ai-react@0.11.4 - -## 0.7.2 - -### Patch Changes - -- Tighten TypeScript safety: enable `noImplicitOverride`, ([#579](https://github.com/TanStack/ai/pull/579)) - `noFallthroughCasesInSwitch`, and `useDefineForClassFields` in the - root `tsconfig.json`; add a typed-ESLint block scoped to - `packages/*/src/**` that turns on `no-floating-promises`, - `no-misused-promises`, `await-thenable`, - `switch-exhaustiveness-check`, `consistent-type-exports`, - `prefer-readonly`, and `no-non-null-assertion` (errors), plus - `no-explicit-any` (warning). `@ts-ignore` and `@ts-nocheck` are - disallowed in library source via `@typescript-eslint/ban-ts-comment`, - and `as unknown as ` double-casts are blocked by a - `no-restricted-syntax` rule (escape hatches available with an inline - reason). Two flags from the original five-flag set — - `noPropertyAccessFromIndexSignature` and `exactOptionalPropertyTypes` - — were tried and rolled back: they produced ~500 lines of bracket- - access and conditional-spread churn without catching any real bugs, - and `exactOptionalPropertyTypes` would have forced consumers using - it themselves to deal with our internals' style preferences. - - User-visible API surface is unchanged; this is a hardening pass to - keep streaming/agent-loop correctness and discriminated-union - exhaustiveness honest going forward. See issue #564. - -- Updated dependencies []: - - @tanstack/ai-client@0.11.3 - - @tanstack/ai-react@0.11.3 - -## 0.7.1 - -### Patch Changes - -- Updated dependencies [[`2e0e2eb`](https://github.com/TanStack/ai/commit/2e0e2eb72684aac82e570d57767656e218289b49), [`b05adb1`](https://github.com/TanStack/ai/commit/b05adb1b9ca756edc926c6b86ee6fae37dd966c0)]: - - @tanstack/ai-client@0.11.0 - - @tanstack/ai-react@0.11.0 - -## 0.7.0 - -### Minor Changes - -- **Breaking:** AG-UI client-to-server compliance. ([#511](https://github.com/TanStack/ai/pull/511)) - - `@tanstack/ai-client` now POSTs an AG-UI `RunAgentInput` request body and `@tanstack/ai` server endpoints must use the new `chatParamsFromRequestBody` + `mergeAgentTools` helpers. Upgrade both packages together. - - Highlights: - - **Wire format**: `{threadId, runId, state, messages, tools, context, forwardedProps}` (per AG-UI 0.0.52 `RunAgentInputSchema`) instead of `{messages, data}`. - - **New server helpers** exported from `@tanstack/ai`: `chatParamsFromRequestBody`, `mergeAgentTools`. - - **`chat()` accepts `threadId`, `runId`, `parentRunId`** as optional fields for AG-UI run correlation. - - **`ChatClient` accepts `threadId`** option; auto-generates and persists per session if omitted; fresh `runId` per send. - - **Client tools auto-advertised** to the server via `RunAgentInput.tools`. - - **Foreign AG-UI clients** can hit a TanStack server: `developer` collapses to `system`, `reasoning`/`activity` drop. - - See `docs/migration/ag-ui-compliance.md` for full migration steps. - -### Patch Changes - -- Updated dependencies [[`a9d1916`](https://github.com/TanStack/ai/commit/a9d19165a5028515cf1d091d611c8ac4b5b86099)]: - - @tanstack/ai-client@0.10.0 - - @tanstack/ai-react@0.10.0 - -## 0.6.4 - -### Patch Changes - -- Updated dependencies [[`98979f7`](https://github.com/TanStack/ai/commit/98979f7e72f4b5bfb816fb14b60a12871f8c4bec)]: - - @tanstack/ai-react@0.9.0 - - @tanstack/ai-client@0.9.2 - -## 0.6.3 - -### Patch Changes - -- Updated dependencies [[`b2d3cc1`](https://github.com/TanStack/ai/commit/b2d3cc131a31c54bd1e5841f958fbe333514e508), [`13cceae`](https://github.com/TanStack/ai/commit/13cceaedf64e398ca15b8dbbbfe215329ea26794)]: - - @tanstack/ai-client@0.9.0 - - @tanstack/ai-react@0.8.1 - -## 0.6.2 - -### Patch Changes - -- Updated dependencies [[`54523f5`](https://github.com/TanStack/ai/commit/54523f5e9a9b4d4ea6c49e4551936bc2cc25593a), [`008f015`](https://github.com/TanStack/ai/commit/008f0154f852e7e6734d3e3d35cad47780b52b7a), [`af9eb7b`](https://github.com/TanStack/ai/commit/af9eb7bbb875b23b7e99b2e6b743636daad402d1)]: - - @tanstack/ai-client@0.8.0 - - @tanstack/ai-react@0.8.0 - -## 0.6.1 - -### Patch Changes - -- Updated dependencies [[`86be1c8`](https://github.com/TanStack/ai/commit/86be1c8262bb3176ea786aa0af115b38c3e3f51a)]: - - @tanstack/ai-client@0.7.0 - - @tanstack/ai-react@0.7.0 - -## 0.6.0 - -### Patch Changes - -- Updated dependencies [[`6dfffca`](https://github.com/TanStack/ai/commit/6dfffca99aeac1ada59eb288f8eb09e564d3db1e), [`6dfffca`](https://github.com/TanStack/ai/commit/6dfffca99aeac1ada59eb288f8eb09e564d3db1e)]: - - @tanstack/ai-client@0.6.0 - - @tanstack/ai-react@0.6.4 - -## 0.5.0 - -### Patch Changes - -- Updated dependencies [[`5aa6acc`](https://github.com/TanStack/ai/commit/5aa6acc1a4faea5346f750322e80984abf2d7059), [`1f800aa`](https://github.com/TanStack/ai/commit/1f800aacf57081f37a075bc8d08ff397cb33cbe9)]: - - @tanstack/ai-client@0.5.0 - - @tanstack/ai-react@0.6.0 - -## 0.5.1 - -### Patch Changes - -- Updated dependencies [[`5d98472`](https://github.com/TanStack/ai/commit/5d984722e1f84725e3cfda834fbda3d0341ecedd)]: - - @tanstack/ai-client@0.4.4 - - @tanstack/ai-react@0.5.1 - -## 0.5.0 - -### Patch Changes - -- Updated dependencies [[`0158d14`](https://github.com/TanStack/ai/commit/0158d14df00639ff5325680ae91b7791c189e60f)]: - - @tanstack/ai-react@0.5.0 - - @tanstack/ai-client@0.4.1 - -## 0.4.0 - -### Patch Changes - -- Updated dependencies [[`99ccee5`](https://github.com/TanStack/ai/commit/99ccee5c72df12adc13bede98142c6da84d13cc4), [`230bab6`](https://github.com/TanStack/ai/commit/230bab6417c8ff2c25586a12126c85e27dd7bc15)]: - - @tanstack/ai-client@0.4.0 - - @tanstack/ai-react@0.4.0 - -## 0.3.0 - -### Patch Changes - -- Updated dependencies [[`e52135f`](https://github.com/TanStack/ai/commit/e52135f6ec3285227679411636e208ae84a408d7)]: - - @tanstack/ai-client@0.3.0 - - @tanstack/ai-react@0.3.0 - -## 0.2.1 - -### Patch Changes - -- fix up readmes ([#188](https://github.com/TanStack/ai/pull/188)) - -- Updated dependencies [[`181e0ac`](https://github.com/TanStack/ai/commit/181e0acdfb44b27db6cf871b36593c0f867cadf9)]: - - @tanstack/ai-react@0.2.1 - -## 0.2.0 - -### Patch Changes - -- Updated dependencies [[`c5df33c`](https://github.com/TanStack/ai/commit/c5df33c2d3e72c3332048ffe7c64a553e5ea86fb)]: - - @tanstack/ai-react@0.2.0 - -## 0.1.0 - -### Minor Changes - -- Split up adapters for better tree shaking into separate functionalities ([#137](https://github.com/TanStack/ai/pull/137)) - -### Patch Changes - -- Updated dependencies [[`8d77614`](https://github.com/TanStack/ai/commit/8d776146f94ffd1579e1ab01b26dcb94d1bb3092)]: - - @tanstack/ai-client@0.1.0 - - @tanstack/ai-react@0.1.0 - -## 0.0.3 - -### Patch Changes - -- Updated dependencies []: - - @tanstack/ai-client@0.0.3 - - @tanstack/ai-react@0.0.3 - -## 0.0.2 - -### Patch Changes - -- Updated dependencies [[`a7bd563`](https://github.com/TanStack/ai/commit/a7bd5639eb2fbf1b4169eb307f77149f4a85a915)]: - - @tanstack/ai-client@0.0.2 - - @tanstack/ai-react@0.0.2 - -## 0.0.1 - -### Patch Changes - -- initial release of AI ([#76](https://github.com/TanStack/ai/pull/76)) - -## 0.0.1 - -### Patch Changes - -- Initial release of TanStack AI ([#72](https://github.com/TanStack/ai/pull/72)) - -- Updated dependencies [[`a9b54c2`](https://github.com/TanStack/ai/commit/a9b54c21282d16036a427761e0784b159a6f2d99)]: - - @tanstack/ai-client@0.0.1 - - @tanstack/ai-react@0.0.1 diff --git a/packages/ai-react-ui/LICENSE b/packages/ai-react-ui/LICENSE deleted file mode 100644 index 308cb68dc0..0000000000 --- a/packages/ai-react-ui/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2025 Tanner Linsley - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/packages/ai-react-ui/README.md b/packages/ai-react-ui/README.md deleted file mode 100644 index c9bbb8c10d..0000000000 --- a/packages/ai-react-ui/README.md +++ /dev/null @@ -1,328 +0,0 @@ -
- - - - TanStack AI - -
- -
- - - -
- - - -# TanStack AI - -Type-safe, provider-agnostic TypeScript SDK for building streaming chat, -tool-calling agents, structured outputs, realtime voice, media generation, and -framework-native AI apps. - -TanStack AI is built from composable activities and provider adapters. Use one -provider or switch between many. Import only chat, or add image, audio, video, -speech, transcription, summarization, realtime, Code Mode, devtools, and -framework bindings as your app needs them. - -## Read the docs -> - -## Start Here - -- [Overview](https://tanstack.com/ai/latest/docs/getting-started/overview) - - what TanStack AI is and how the packages fit together. -- [Quick Start: React](https://tanstack.com/ai/latest/docs/getting-started/quick-start) - - add streaming chat to a React app. -- [Quick Start: Vue](https://tanstack.com/ai/latest/docs/getting-started/quick-start-vue) - - build with Vue composables. -- [Quick Start: Svelte](https://tanstack.com/ai/latest/docs/getting-started/quick-start-svelte) - - build with Svelte 5 runes. -- [Quick Start: Server Only](https://tanstack.com/ai/latest/docs/getting-started/quick-start-server) - - use TanStack AI from a server endpoint, script, or backend service. -- [TanStack AI vs Vercel AI SDK](https://tanstack.com/ai/latest/docs/comparison/vercel-ai-sdk) - - compare architecture, feature coverage, and tradeoffs. - -## What You Can Build - -- Streaming chat experiences with typed messages, tool calls, reasoning parts, - and configurable connection adapters. -- Type-safe tools that can run on the server or client from one shared - `toolDefinition()` contract. -- Structured output flows backed by JSON Schema, Zod, ArkType, Valibot, or - plain JSON Schema. -- Multimodal prompts and responses that include text, images, audio, video, and - documents. -- Image, audio, video, speech, transcription, and summarization workflows using - a shared generation client pattern. -- Realtime voice chat with provider adapters for realtime sessions and token - minting. -- Code Mode agents that let an LLM write and execute TypeScript in an isolated - sandbox to orchestrate tools with loops, branches, and parallel calls. -- Devtools and observability pipelines for inspecting messages, tool calls, - stream chunks, errors, usage, and OpenTelemetry traces. -- Framework-native clients for React, Solid, Vue, Svelte, and Preact, plus a - headless client for custom runtimes. - -## Install - -Install the core package and the provider/framework packages your app uses: - -```bash -pnpm add @tanstack/ai @tanstack/ai-openai -``` - -For a React chat UI: - -```bash -pnpm add @tanstack/ai @tanstack/ai-client @tanstack/ai-react @tanstack/ai-openai -``` - -OpenRouter is also a good starting point if you want access to many providers -through one API key: - -```bash -pnpm add @tanstack/ai @tanstack/ai-openrouter -``` - -## Streaming Chat - -```typescript -import { chat, toServerSentEventsResponse } from '@tanstack/ai' -import { openaiText } from '@tanstack/ai-openai' - -export async function POST(request: Request) { - const body = await request.json() - - const stream = chat({ - adapter: openaiText('gpt-5.2'), - messages: body.messages, - }) - - return toServerSentEventsResponse(stream) -} -``` - -Learn more in the -[Chat & Streaming docs](https://tanstack.com/ai/latest/docs/chat/streaming) and -[Connection Adapters docs](https://tanstack.com/ai/latest/docs/chat/connection-adapters). - -## Type-Safe Tools - -Define a tool once, then attach a server or client implementation with the same -input and output types: - -```typescript -import { toolDefinition } from '@tanstack/ai' -import { z } from 'zod' - -const getProducts = toolDefinition({ - name: 'getProducts', - description: 'Search the product catalog', - inputSchema: z.object({ query: z.string() }), - outputSchema: z.array( - z.object({ - id: z.string(), - name: z.string(), - }), - ), -}).server(async ({ query }) => { - return db.products.search(query) -}) -``` - -Learn more in the -[Tools docs](https://tanstack.com/ai/latest/docs/tools/tools), -[Tool Approval Flow docs](https://tanstack.com/ai/latest/docs/tools/tool-approval), -and -[Lazy Tool Discovery docs](https://tanstack.com/ai/latest/docs/tools/lazy-tool-discovery). - -## Structured Outputs - -Use `outputSchema` when you need typed objects instead of freeform text: - -```typescript -import { chat } from '@tanstack/ai' -import { openaiText } from '@tanstack/ai-openai' -import { z } from 'zod' - -const Person = z.object({ - name: z.string(), - age: z.number(), -}) - -const person = await chat({ - adapter: openaiText('gpt-5.2'), - messages: [{ role: 'user', content: 'Ada Lovelace, 36' }], - outputSchema: Person, -}) -``` - -Learn more in the -[Structured Outputs docs](https://tanstack.com/ai/latest/docs/structured-outputs/overview). - -## Media, Realtime, and Code Mode - -- [Generations](https://tanstack.com/ai/latest/docs/media/generations) - one - pattern for image generation, text-to-speech, transcription, summarization, - audio generation, and video generation. -- [Realtime Voice Chat](https://tanstack.com/ai/latest/docs/media/realtime-chat) - - build low-latency realtime voice experiences. -- [Code Mode](https://tanstack.com/ai/latest/docs/code-mode/code-mode) - let - models write and execute TypeScript inside a secure isolate. -- [Code Mode with Snippets](https://tanstack.com/ai/latest/docs/code-mode/code-mode-with-snippets) - - give Code Mode reusable runtime capabilities. - -## Providers - -Official adapters include: - -| Package | Use it for | -| ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------ | -| [`@tanstack/ai-openrouter`](https://tanstack.com/ai/latest/docs/adapters/openrouter) | 300+ models through one OpenRouter API, with per-request cost tracking | -| [`@tanstack/ai-openai`](https://tanstack.com/ai/latest/docs/adapters/openai) | OpenAI chat, image, video, speech, transcription, realtime, and provider tools | -| [`@tanstack/ai-anthropic`](https://tanstack.com/ai/latest/docs/adapters/anthropic) | Anthropic Claude chat, thinking, tools, structured outputs, and Vertex Claude | -| [`@tanstack/ai-gemini`](https://tanstack.com/ai/latest/docs/adapters/gemini) | Google Gemini chat, image, speech, and audio generation | -| [`@tanstack/ai-vertex`](https://tanstack.com/ai/latest/docs/adapters/vertex) | Gemini on Vertex AI with regional endpoints and Google Cloud credentials | -| [`@tanstack/ai-ollama`](https://tanstack.com/ai/latest/docs/adapters/ollama) | Local Ollama models | -| [`@tanstack/ai-grok`](https://tanstack.com/ai/latest/docs/adapters/grok) | xAI Grok chat, images, and realtime | -| [`@tanstack/ai-groq`](https://tanstack.com/ai/latest/docs/adapters/groq) | Groq low-latency inference | -| [`@tanstack/ai-elevenlabs`](https://tanstack.com/ai/latest/docs/adapters/elevenlabs) | ElevenLabs realtime voice, speech, transcription, music, and sound effects | -| [`@tanstack/ai-byteplus`](https://tanstack.com/ai/latest/docs/adapters/byteplus) | BytePlus Seed chat, Seedance video, Seedream image, and Seed Speech TTS/ASR | -| [`@tanstack/ai-fal`](https://tanstack.com/ai/latest/docs/adapters/fal) | fal.ai image, video, audio, speech, and transcription models | - -The adapter system is tree-shakeable by activity. Import `openaiText` for chat, -`openaiImage` for images, `falVideo` for video, `geminiSpeech` for TTS, and so -on. - -## Framework Packages - -| Package | What it provides | -| -------------------------------------------------------------------------- | ------------------------------------------------------------------------ | -| [`@tanstack/ai-client`](https://tanstack.com/ai/latest/docs/api/ai-client) | Headless chat, realtime, and generation clients | -| [`@tanstack/ai-react`](https://tanstack.com/ai/latest/docs/api/ai-react) | React hooks including `useChat`, `useRealtimeChat`, and generation hooks | -| [`@tanstack/ai-solid`](https://tanstack.com/ai/latest/docs/api/ai-solid) | Solid hooks for chat and generations | -| [`@tanstack/ai-vue`](https://tanstack.com/ai/latest/docs/api/ai-vue) | Vue composables for chat and generations | -| [`@tanstack/ai-svelte`](https://tanstack.com/ai/latest/docs/api/ai-svelte) | Svelte 5 factories for chat and generations | -| [`@tanstack/ai-preact`](https://tanstack.com/ai/latest/docs/api/ai-preact) | Preact hooks for chat | -| `@tanstack/ai-react-ui`, `@tanstack/ai-solid-ui`, `@tanstack/ai-vue-ui` | Headless UI components for chat interfaces | - -## Advanced Docs - -- [Middleware](https://tanstack.com/ai/latest/docs/advanced/middleware) - hook - into chat configuration, chunks, tool calls, usage, errors, and structured - outputs. -- [OpenTelemetry](https://tanstack.com/ai/latest/docs/advanced/otel) - emit - vendor-neutral GenAI traces and metrics. -- [Observability](https://tanstack.com/ai/latest/docs/advanced/observability) - - subscribe to typed TanStack AI events. -- [Per-Model Type Safety](https://tanstack.com/ai/latest/docs/advanced/per-model-type-safety) - - narrow model options and content modalities to the selected model. -- [Runtime Adapter Switching](https://tanstack.com/ai/latest/docs/advanced/runtime-adapter-switching) - - switch providers at runtime. -- [Tree-Shaking](https://tanstack.com/ai/latest/docs/advanced/tree-shaking) - - ship only the activities and adapters you use. -- [Agent Skills](https://tanstack.com/ai/latest/docs/getting-started/agent-skills) - - install TanStack AI skills into Claude Code, Cursor, GitHub Copilot, Codex, - and other coding agents with TanStack Intent. - -## Get Involved - -- Read the [docs](https://tanstack.com/ai). -- Participate in [GitHub discussions](https://github.com/TanStack/ai/discussions). -- Chat with the community on [Discord](https://discord.com/invite/WrRKjPJ). -- See [CONTRIBUTING.md](https://github.com/TanStack/ai/blob/main/CONTRIBUTING.md) - for setup instructions. -- [Become a sponsor](https://github.com/sponsors/tannerlinsley/). - -## Partners - - - - - - -
- - - - - CodeRabbit - - - - - - - - Cloudflare - - -
- -
- AI and you? -

- We're looking for TanStack AI partners to join our mission. Partner with us - to push the boundaries of TanStack AI and build amazing things together. -

- LET'S CHAT -
- -## Explore the TanStack Ecosystem - -- TanStack Config - - tooling for JS/TS packages -- TanStack DB - reactive - sync client store -- TanStack Devtools - - unified devtools panel -- TanStack Form - - type-safe form state -- TanStack Pacer - - debouncing, throttling, batching -- TanStack Query - - async state and caching -- TanStack Ranger - - range and slider primitives -- TanStack Router - - type-safe routing, caching, and URL state -- TanStack Start - - full-stack SSR and streaming -- TanStack Store - - reactive data store -- TanStack Table - - headless datagrids -- TanStack Virtual - - virtualized rendering - -...and more at TanStack.com. diff --git a/packages/ai-react-ui/package.json b/packages/ai-react-ui/package.json deleted file mode 100644 index a49b3d1525..0000000000 --- a/packages/ai-react-ui/package.json +++ /dev/null @@ -1,77 +0,0 @@ -{ - "name": "@tanstack/ai-react-ui", - "version": "0.8.22", - "description": "Headless React components for building TanStack AI chat interfaces with streamed message parts.", - "author": "Tanner Linsley", - "license": "MIT", - "homepage": "https://tanstack.com/ai", - "repository": { - "type": "git", - "url": "git+https://github.com/TanStack/ai.git", - "directory": "packages/ai-react-ui" - }, - "bugs": { - "url": "https://github.com/TanStack/ai/issues" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - }, - "module": "./dist/esm/index.js", - "types": "./dist/esm/index.d.ts", - "type": "module", - "exports": { - ".": { - "types": "./dist/esm/index.d.ts", - "import": "./dist/esm/index.js" - } - }, - "files": [ - "dist" - ], - "scripts": { - "build": "vite build", - "clean": "premove ./build ./dist", - "test:build": "publint --strict", - "test:oxlint": "oxlint src --type-aware", - "test:lib": "vitest --passWithNoTests", - "test:lib:dev": "pnpm test:lib --watch", - "test:types": "tsc" - }, - "keywords": [ - "ai", - "ai-sdk", - "typescript", - "tanstack", - "react", - "chat", - "ui", - "headless", - "components", - "streaming", - "generative-ai" - ], - "dependencies": { - "react-markdown": "^10.1.0", - "rehype-highlight": "^7.0.2", - "rehype-raw": "^7.0.0", - "rehype-sanitize": "^6.0.0", - "remark-gfm": "^4.0.1" - }, - "peerDependencies": { - "@tanstack/ai-client": "workspace:^", - "@tanstack/ai-react": "workspace:^", - "@types/react": ">=18.0.0", - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0" - }, - "devDependencies": { - "@tanstack/ai-client": "workspace:*", - "@tanstack/ai-react": "workspace:*", - "@types/react": "^19.2.7", - "@vitest/coverage-v8": "4.1.10", - "react": "^19.2.3", - "react-dom": "^19.2.3", - "vite": "^8.2.1" - } -} diff --git a/packages/ai-react-ui/src/index.ts b/packages/ai-react-ui/src/index.ts deleted file mode 100644 index 7d5633f8bf..0000000000 --- a/packages/ai-react-ui/src/index.ts +++ /dev/null @@ -1,73 +0,0 @@ -/** - * @tanstack/ai-react-ui - * - * Headless React components for building AI chat interfaces. - * - * Features: - * - Parts-based message rendering (text, tool calls, tool results) - * - Native tool approval workflows - * - Client-side tool execution support - * - Streaming support - * - Fully customizable with render props - * - Compound component pattern - * - * @example - * ```tsx - * import { Chat, ChatMessages, ChatInput, ChatMessage } from '@tanstack/ai-react-ui' - * - * - * - * {(message) => } - * - * - * - * ``` - */ - -export { - createChatUI, - type ChatUIComponents, - type ChatUIHost, - type InputProps, - type InterruptProps, - type LayoutProps, - type MessageProps, - type PartProps, - type ToolProps, -} from './create-ui' - -// Main components -export { Chat, useChatContext, type ChatProps } from './chat' -export { ChatMessages, type ChatMessagesProps } from './chat-messages' -export { - ChatMessage, - type ChatMessageProps, - type ToolCallRenderProps, -} from './chat-message' -export { - ChatInput, - type ChatInputProps, - type ChatInputRenderProps, -} from './chat-input' -export { - ToolApproval, - type ToolApprovalProps, - type ToolApprovalRenderProps, -} from './tool-approval' -export { TextPart, type TextPartProps } from './text-part' -export { ThinkingPart, type ThinkingPartProps } from './thinking-part' - -// Re-export hooks from @tanstack/ai-react for convenience -export { useChat } from '@tanstack/ai-react' - -// Re-export types from @tanstack/ai-react -export type { - UIMessage, - MessagePart, - ToolCallPart, - ToolResultPart, - TextPart as TextPartType, - ConnectionAdapter, -} from '@tanstack/ai-client' - -export type { UseChatOptions, UseChatReturn } from '@tanstack/ai-react' diff --git a/packages/ai-react-ui/tests/create-ui.test.tsx b/packages/ai-react-ui/tests/create-ui.test.tsx deleted file mode 100644 index 845cad4e40..0000000000 --- a/packages/ai-react-ui/tests/create-ui.test.tsx +++ /dev/null @@ -1,337 +0,0 @@ -import { renderToStaticMarkup } from 'react-dom/server' -import { describe, expect, it, vi } from 'vitest' -import { createChatUI } from '../src/create-ui' -import type { ChatUIHost } from '../src/create-ui' -import { - chatOptions, - createChatResult, - genericInterrupt, - messageWithToolResults, - orphanResultMessage, - purchaseApprovalInterrupt, - purchaseApprovalMessage, - unboundInterrupt, - unknownToolMessage, -} from '../../ai-client/tests/ui-fixtures' -import type { ToolCallState } from '@tanstack/ai-client' - -function host( - init?: Parameters[0], -): ChatUIHost { - return createChatResult(init ?? {}) as unknown as ChatUIHost< - typeof chatOptions - > -} - -describe('createChatUI', () => { - it('renders automatic and manual trees', () => { - const UI = createChatUI(chatOptions) - const components = UI.defineComponents({ - layout: ({ renderMessages, renderInterrupts }) => ( - <> - {renderMessages()} - {renderInterrupts()} - - ), - message: ({ renderParts }) =>
{renderParts()}
, - parts: { fallback: ({ part }) => {part.type} }, - tools: { - getWeather: ({ part }) => {part.input?.city}, - purchaseItem: () => null, - }, - interrupts: { - generic: { - choosePlan: () => null, - fallback: ({ interrupt }) => {interrupt.reason}, - }, - }, - }) - - const chat = host({ messages: [messageWithToolResults] }) - const automatic = renderToStaticMarkup( - , - ) - expect(automatic).toContain('Paris') - - const manual = renderToStaticMarkup( - - - {(messages) => {messages.length}} - - , - ) - expect(manual).toContain('1') - }) - - it('warns once for a missing runtime key', () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) - const UI = createChatUI(chatOptions) - const chat = host({ messages: [unknownToolMessage] }) - const components = UI.defineComponents({ - layout: ({ renderMessages }) => renderMessages(), - message: ({ renderParts }) => renderParts(), - parts: { fallback: () => null }, - tools: { - getWeather: () => null, - purchaseItem: () => null, - }, - interrupts: { - generic: { choosePlan: () => null, fallback: () => null }, - }, - }) - renderToStaticMarkup() - renderToStaticMarkup() - expect(warn).toHaveBeenCalledTimes(1) - warn.mockRestore() - }) - - it('keeps unmatched tool results and suppresses matched ones', () => { - const UI = createChatUI(chatOptions) - const components = UI.defineComponents({ - layout: ({ renderMessages }) => renderMessages(), - message: ({ renderParts }) => renderParts(), - parts: { - toolResult: ({ part }) => - part.type === 'tool-result' ? {String(part.content)} : null, - fallback: () => null, - }, - tools: { - getWeather: () => weather, - purchaseItem: () => null, - }, - interrupts: { - generic: { choosePlan: () => null, fallback: () => null }, - }, - }) - - const matched = renderToStaticMarkup( - , - ) - expect(matched).toContain('weather') - expect(matched).not.toContain('') - - const unmatched = renderToStaticMarkup( - , - ) - expect(unmatched).toContain('standalone') - }) - - it('puts list approvals in Interrupts when interrupts.tools has the tool', () => { - const UI = createChatUI(chatOptions) - const list = UI.defineComponents({ - layout: ({ renderMessages, renderInterrupts }) => ( - <> - {renderMessages()} - {renderInterrupts()} - - ), - message: ({ renderParts }) => renderParts(), - parts: { fallback: () => null }, - tools: { - getWeather: () => null, - purchaseItem: () =>
tool
, - }, - interrupts: { - tools: { - purchaseItem: () => list-approval, - }, - generic: { choosePlan: () => null, fallback: () => null }, - }, - }) - - const listMarkup = renderToStaticMarkup( - , - ) - expect(listMarkup).toContain('list-approval') - expect(listMarkup).toContain('tool') - }) - - it('lets a tool render its approval from interrupt without a registered interrupt component', () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) - const UI = createChatUI(chatOptions) - const components = UI.defineComponents({ - layout: ({ renderMessages, renderInterrupts }) => ( - <> - {renderMessages()} - {renderInterrupts()} - - ), - message: ({ renderParts }) => renderParts(), - parts: { fallback: () => null }, - tools: { - getWeather: () => null, - purchaseItem: ({ interrupt }) => - interrupt ? {interrupt.toolName} : tool, - }, - interrupts: { - generic: { choosePlan: () => null, fallback: () => list }, - }, - }) - const markup = renderToStaticMarkup( - , - ) - expect(markup).toContain('purchaseItem') - expect(markup).not.toContain('list') - expect( - warn.mock.calls.filter((call) => - String(call[0]).includes('[tanstack-ai-ui]'), - ), - ).toHaveLength(0) - warn.mockRestore() - }) - - it('renders registered generic interrupts and sends the rest to fallback', () => { - const UI = createChatUI(chatOptions) - const components = UI.defineComponents({ - layout: ({ renderInterrupts }) => renderInterrupts(), - message: ({ renderParts }) => renderParts(), - parts: { fallback: () => null }, - tools: { - getWeather: () => null, - purchaseItem: () => null, - }, - interrupts: { - generic: { - choosePlan: () => plan, - fallback: () => fallback, - }, - }, - }) - - const markup = renderToStaticMarkup( - , - ) - expect(markup).toContain('plan') - expect(markup).toContain('fallback') - }) - - it('omits input when no input component exists', () => { - const UI = createChatUI(chatOptions) - const components = UI.defineComponents({ - layout: ({ renderInput }) =>
{renderInput()}
, - message: ({ renderParts }) => renderParts(), - parts: { fallback: () => null }, - tools: { - getWeather: () => null, - purchaseItem: () => null, - }, - interrupts: { - generic: { choosePlan: () => null }, - }, - }) - const markup = renderToStaticMarkup( - , - ) - expect(markup).toBe('
') - }) - - it('reads chat from nested provider context and throws outside a provider', () => { - const UI = createChatUI(chatOptions) - const components = UI.defineComponents({ - layout: () => { - const chat = UI.useChat() - return

{chat.messages.length}

- }, - message: ({ renderParts }) => renderParts(), - parts: { fallback: () => null }, - tools: { - getWeather: () => null, - purchaseItem: () => null, - }, - interrupts: { - generic: { choosePlan: () => null }, - }, - }) - - const inner = host({ messages: [messageWithToolResults] }) - const outer = host({ messages: [] }) - const markup = renderToStaticMarkup( - - - , - ) - expect(markup).toContain('

1

') - - function Broken() { - UI.useChat() - return null - } - expect(() => renderToStaticMarkup()).toThrow( - /UI.Provider or UI.Chat/, - ) - }) - - it('renders a tool component for every ToolCallState', () => { - const states: Array = [ - 'awaiting-input', - 'input-streaming', - 'input-complete', - 'approval-requested', - 'approval-responded', - 'complete', - 'error', - ] - const UI = createChatUI(chatOptions) - const components = UI.defineComponents({ - layout: ({ renderMessages }) => renderMessages(), - message: ({ renderParts }) => renderParts(), - parts: { fallback: () => null }, - tools: { - getWeather: ({ part }) => {part.state}, - purchaseItem: () => null, - }, - interrupts: { - generic: { choosePlan: () => null, fallback: () => null }, - }, - }) - - for (const state of states) { - const markup = renderToStaticMarkup( - , - ) - expect(markup).toContain(`${state}`) - } - }) -}) diff --git a/packages/ai-react-ui/tsconfig.json b/packages/ai-react-ui/tsconfig.json deleted file mode 100644 index d3e610bff5..0000000000 --- a/packages/ai-react-ui/tsconfig.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "./dist", - "jsx": "react-jsx", - "lib": ["ES2022", "DOM"], - "rootDir": "../.." - }, - "include": ["src", "tests"], - "exclude": ["node_modules", "dist"] -} diff --git a/packages/ai-react-ui/vite.config.ts b/packages/ai-react-ui/vite.config.ts deleted file mode 100644 index 782c82ec49..0000000000 --- a/packages/ai-react-ui/vite.config.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { defineConfig, mergeConfig } from 'vitest/config' -import { tanstackViteConfig } from '@tanstack/vite-config' -import packageJson from './package.json' - -const config = defineConfig({ - test: { - name: packageJson.name, - dir: './', - watch: false, - globals: true, - environment: 'node', - include: ['tests/**/*.test.ts', 'tests/**/*.test.tsx'], - coverage: { - provider: 'v8', - reporter: ['text', 'json', 'html', 'lcov'], - exclude: [ - 'node_modules/', - 'dist/', - 'tests/', - '**/*.test.ts', - '**/*.config.ts', - '**/types.ts', - ], - include: ['src/**/*.ts'], - }, - }, -}) - -export default mergeConfig( - config, - tanstackViteConfig({ - entry: ['./src/index.ts'], - srcDir: './src', - cjs: false, - }), -) diff --git a/packages/ai-react/package.json b/packages/ai-react/package.json index abe8955b9b..2f7d2282c3 100644 --- a/packages/ai-react/package.json +++ b/packages/ai-react/package.json @@ -28,6 +28,10 @@ "./mcp-apps": { "types": "./dist/esm/mcp-apps.d.ts", "import": "./dist/esm/mcp-apps.js" + }, + "./ui": { + "types": "./dist/esm/ui.d.ts", + "import": "./dist/esm/ui.js" } }, "files": [ @@ -59,17 +63,26 @@ "media-generation" ], "dependencies": { - "@tanstack/ai-client": "workspace:^" + "@tanstack/ai-client": "workspace:^", + "react-markdown": "^10.1.0", + "rehype-highlight": "^7.0.2", + "rehype-raw": "^7.0.0", + "rehype-sanitize": "^6.0.0", + "remark-gfm": "^4.0.1" }, "peerDependencies": { "@mcp-ui/client": "^7", "@tanstack/ai": "workspace:^", "@types/react": ">=18.0.0", - "react": ">=18.0.0" + "react": ">=18.0.0", + "react-dom": ">=18.0.0" }, "peerDependenciesMeta": { "@mcp-ui/client": { "optional": true + }, + "react-dom": { + "optional": true } }, "devDependencies": { @@ -81,6 +94,7 @@ "@vitest/coverage-v8": "4.1.10", "jsdom": "^27.4.0", "react": "^19.2.3", + "react-dom": "^19.2.3", "vite": "^8.2.1" } } diff --git a/packages/ai-react-ui/src/chat-input.tsx b/packages/ai-react/src/chat-ui/chat-input.tsx similarity index 100% rename from packages/ai-react-ui/src/chat-input.tsx rename to packages/ai-react/src/chat-ui/chat-input.tsx diff --git a/packages/ai-react-ui/src/chat-message.tsx b/packages/ai-react/src/chat-ui/chat-message.tsx similarity index 99% rename from packages/ai-react-ui/src/chat-message.tsx rename to packages/ai-react/src/chat-ui/chat-message.tsx index 8df82253cf..41dd183161 100644 --- a/packages/ai-react-ui/src/chat-message.tsx +++ b/packages/ai-react/src/chat-ui/chat-message.tsx @@ -1,7 +1,7 @@ import { ThinkingPart } from './thinking-part' import { toolResultContentToString } from './tool-result-content' import type { ReactNode } from 'react' -import type { UIMessage } from '@tanstack/ai-react' +import type { UIMessage } from '../types' export interface ToolCallRenderProps { id: string diff --git a/packages/ai-react-ui/src/chat-messages.tsx b/packages/ai-react/src/chat-ui/chat-messages.tsx similarity index 97% rename from packages/ai-react-ui/src/chat-messages.tsx rename to packages/ai-react/src/chat-ui/chat-messages.tsx index e05526442e..f87ea034da 100644 --- a/packages/ai-react-ui/src/chat-messages.tsx +++ b/packages/ai-react/src/chat-ui/chat-messages.tsx @@ -2,7 +2,7 @@ import { useEffect, useRef } from 'react' import { useChatContext } from './chat' import { ChatMessage } from './chat-message' import type { ReactNode } from 'react' -import type { UIMessage } from '@tanstack/ai-react' +import type { UIMessage } from '../types' /** @deprecated Use `createChatUI()` Messages instead. Deprecated in 0.9.0. Removed in 1.0.0. */ export interface ChatMessagesProps { diff --git a/packages/ai-react-ui/src/chat.tsx b/packages/ai-react/src/chat-ui/chat.tsx similarity index 91% rename from packages/ai-react-ui/src/chat.tsx rename to packages/ai-react/src/chat-ui/chat.tsx index 7b78b872af..7d4f8e954d 100644 --- a/packages/ai-react-ui/src/chat.tsx +++ b/packages/ai-react/src/chat-ui/chat.tsx @@ -1,11 +1,8 @@ import { createContext, useContext } from 'react' -import { useChat } from '@tanstack/ai-react' +import type { ConnectionAdapter } from '@tanstack/ai-client' +import { useChat } from '../use-chat' import type { ReactNode } from 'react' -import type { - ConnectionAdapter, - UIMessage, - UseChatReturn, -} from '@tanstack/ai-react' +import type { UIMessage, UseChatReturn } from '../types' /** * Chat context - provides chat state to all child components @@ -16,7 +13,7 @@ const ChatContext = createContext(null) * Hook to access chat context * @throws Error if used outside of Chat component */ -/** @deprecated Use `createChatUI().useChat()` instead. Deprecated in 0.9.0. Removed in 1.0.0. */ +/** @deprecated Use `createChatUI().useChatContext()` instead. Deprecated in 0.9.0. Removed in 1.0.0. */ export function useChatContext() { const context = useContext(ChatContext) if (!context) { diff --git a/packages/ai-react/src/chat-ui/create-chat-hook.tsx b/packages/ai-react/src/chat-ui/create-chat-hook.tsx new file mode 100644 index 0000000000..bdb4aee681 --- /dev/null +++ b/packages/ai-react/src/chat-ui/create-chat-hook.tsx @@ -0,0 +1,99 @@ +import { useMemo, useRef } from 'react' +import type { ComponentType } from 'react' +import type { InferredClientContext } from '@tanstack/ai-client' +import type { + ChatUIInterruptsOf, + ChatUISchemaOf, + ChatUIToolsOf, +} from '@tanstack/ai-client/ui' +import { useChat as useUnboundChat } from '../use-chat' +import type { UseChatOptions } from '../types' +import { createChatUI } from './create-ui' +import type { ChatUIFactoryConfig, ChatUIHost } from './create-ui' + +type HeadlessOptions = UseChatOptions< + ChatUIToolsOf, + ChatUISchemaOf, + InferredClientContext>, + ChatUIInterruptsOf +> + +type ChatInstanceOverrides = { + threadId?: string + live?: boolean + forwardedProps?: Record + body?: Record + initialMessages?: HeadlessOptions['initialMessages'] +} + +/** + * Bind chat options and UI widgets once at module scope. + * + * This matches Form `createFormHook` and Table `createTableHook`. The + * returned `useAppChat` creates a chat instance. `chat.AppChat` renders it. + * `useChatContext` reads that instance. Part and interrupt widgets take + * `part` / `interrupt` as props. Do not read those from context. + * + * Pass instance overrides such as `threadId` into `useAppChat()`. + * + * @example + * ```tsx + * const { useAppChat, useChatContext } = createChatHook({ + * options: chatOptions, + * chatComponents: { + * layout: ChatLayout, + * message: ChatMessage, + * parts: { fallback: FallbackPart }, + * tools: { getWeather: WeatherTool, purchaseItem: PurchaseTool }, + * interrupts: { generic: { choosePlan: ChoosePlan } }, + * }, + * }) + * + * function Support() { + * const chat = useAppChat({ threadId: 'support-1' }) + * return + * } + * ``` + */ +export function createChatHook({ + options, + chatComponents, +}: { + options: TOptions + chatComponents: ChatUIFactoryConfig> +}) { + const ui = createChatUI(options, chatComponents) + + function useAppChat(overrides?: ChatInstanceOverrides) { + const chat = ( + overrides + ? useUnboundChat({ + ...(options as HeadlessOptions), + ...overrides, + }) + : useUnboundChat(options as HeadlessOptions) + ) as ChatUIHost + + // `useChat` returns a fresh object on every render. The mixed Chat + // component must stay stable, or React remounts the tree on each + // update. Read the current instance from a ref. + const chatRef = useRef(chat) + chatRef.current = chat + + const AppChat = useMemo(() => { + function AppChatImpl() { + return + } + return AppChatImpl + }, []) + + return Object.assign(chat, { AppChat }) as ChatUIHost & { + AppChat: ComponentType + } + } + + return { + useAppChat, + useChatContext: ui.useChatContext, + } +} diff --git a/packages/ai-react/src/chat-ui/create-ui-contexts.tsx b/packages/ai-react/src/chat-ui/create-ui-contexts.tsx new file mode 100644 index 0000000000..f0fbf8db4d --- /dev/null +++ b/packages/ai-react/src/chat-ui/create-ui-contexts.tsx @@ -0,0 +1,57 @@ +import { createContext, useContext } from 'react' +import type { Context } from 'react' +import type { + ChatUIInterrupt, + ChatUISelectedPart, +} from '@tanstack/ai-client/ui' +import type { UseChatReturn } from '../types' + +/** + * Scoped chat, part, and interrupt contexts plus matching context hooks. + * Pass these into {@link createChatHook} when widgets live in other files, or + * when two chat trees nest and must not share the default contexts. + */ +export interface ChatUIContexts { + chatContext: Context | null> + partContext: Context + interruptContext: Context + useChatContext: () => UseChatReturn +} + +/** + * Create a fresh set of chat UI contexts. This matches Form + * `createFormHookContexts` and Table `createTableHookContexts`. + * + * Most apps can skip this. `createChatHook` uses shared module contexts by + * default. The hooks on this object are the escape hatch for a widget file + * that cannot import the `createChatHook` result (circular import), or for + * nested chats that need isolated providers. + * + * Prefer the `useChatContext` hook returned from `createChatHook` when you + * can import that result. That hook knows the `chatComponents` types. Part + * and interrupt widgets take `part` / `interrupt` as props. + */ +export function createChatHookContexts(): ChatUIContexts { + const chatContext = createContext | null>(null) + const partContext = createContext(null) + const interruptContext = createContext(null) + + function useChatContext() { + const chat = useContext(chatContext) + if (!chat) { + throw new Error( + '`useChatContext` must be used within `UI.Provider` or `UI.Chat`.', + ) + } + return chat + } + + return { + chatContext, + partContext, + interruptContext, + useChatContext, + } +} + +export const defaultChatUIContexts = createChatHookContexts() diff --git a/packages/ai-react-ui/src/create-ui.tsx b/packages/ai-react/src/chat-ui/create-ui.tsx similarity index 53% rename from packages/ai-react-ui/src/create-ui.tsx rename to packages/ai-react/src/chat-ui/create-ui.tsx index eacbbb76eb..a163af7e48 100644 --- a/packages/ai-react-ui/src/create-ui.tsx +++ b/packages/ai-react/src/chat-ui/create-ui.tsx @@ -1,5 +1,5 @@ -import { createContext, memo, useCallback, useContext, useMemo } from 'react' -import type { ComponentType, ReactNode } from 'react' +import { memo, useCallback, useContext } from 'react' +import type { ComponentType, Context, ReactNode } from 'react' import { automaticPartsForMessage, collectInlineToolNames, @@ -21,6 +21,7 @@ import type { ChatUIPartOf, ChatUISchemaOf, ChatUISelectedPart, + ChatUISelectedPartOf, ChatUIToolApproval, ChatUIToolName, ChatUIToolsOf, @@ -31,7 +32,11 @@ import type { ToolResultPart, UIMessage, } from '@tanstack/ai-client' -import type { UseChatReturn } from '@tanstack/ai-react' +import type { UseChatReturn } from '../types' +import { + defaultChatUIContexts, + type ChatUIContexts, +} from './create-ui-contexts' export type ChatUIHost = UseChatReturn< ChatUIToolsOf, @@ -129,10 +134,29 @@ export type ChatUIComponents = { } }) -type ComponentsValue = { - components: ChatUIComponents - warn: (key: string, message: string) => void - inlineToolNames: ReadonlyArray +export type ChatUIFactoryConfig = ChatUIComponents & { + chatContext?: ChatUIContexts['chatContext'] + partContext?: ChatUIContexts['partContext'] + interruptContext?: ChatUIContexts['interruptContext'] +} + +type BoundWidget = ComponentType> + +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 } function createWarnOnce() { @@ -174,87 +198,178 @@ function selectedPartPropsEqual( return true } -export function createChatUI(options: TOptions) { +function isSelectedPart( + value: MessagePart | ChatUISelectedPart, +): value is ChatUISelectedPart { + return 'key' in value && 'part' in value +} + +function bindMap( + map: Record | undefined> | undefined, + bind: (component: ComponentType) => 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`: widgets register here, mix + * onto Part / Interrupt / the UI kit, and automatic dispatch still walks the + * message list. + * + * `chatOptions` is type-only at runtime. Pass optional `chatContext`, + * `partContext`, and `interruptContext` from {@link createChatHookContexts} + * when widgets live in other files or when nested chats need isolation. + */ +export function createChatUI( + options: TOptions, + config: ChatUIFactoryConfig>, +) { void options + const { + chatContext: chatContextOption, + partContext: partContextOption, + interruptContext: interruptContextOption, + layout: Layout, + message: MessageComponent, + input: InputComponent, + parts, + tools, + interrupts, + } = config const warn = createWarnOnce() - const ChatContext = createContext | null>(null) - const ComponentsContext = createContext | null>( - null, + const ChatContext = (chatContextOption ?? + defaultChatUIContexts.chatContext) as Context | null> + const PartContext = (partContextOption ?? + defaultChatUIContexts.partContext) as Context + const InterruptContext = (interruptContextOption ?? + defaultChatUIContexts.interruptContext) as Context + const inlineToolNames = collectInlineToolNames( + interrupts?.tools as Record | undefined, + Object.keys(tools ?? {}), ) - function useChat() { + function useChatContext() { const chat = useContext(ChatContext) if (!chat) { throw new Error( - 'Chat UI components must be wrapped in UI.Provider or UI.Chat.', + '`useChatContext` must be used within `UI.Provider` or `UI.Chat`.', ) } return chat } - function useComponents() { - const value = useContext(ComponentsContext) - if (!value) { + function usePartContext() { + const selected = useContext(PartContext) + if (!selected) { throw new Error( - 'Chat UI components must be wrapped in UI.Provider or UI.Chat.', + '`usePartContext` must be used within `UI.Part` or an automatic part.', ) } - return value + return selected as ChatUISelectedPartOf } - function defineComponents( - components: ChatUIComponents, - ): ChatUIComponents { - return components + 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 inlineNames(components: ChatUIComponents) { - return collectInlineToolNames( - components.interrupts?.tools as Record | undefined, - Object.keys(components.tools ?? {}), - ) + function bindPart(Component: ComponentType>) { + return function BoundPart() { + const selected = usePartContext() + return ['part']} /> + } + } + + function bindTool(Component: ComponentType>) { + 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: ComponentType>) { + 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({ chat, - components, children, }: { chat: ChatUIHost - components: ChatUIComponents children?: ReactNode }) { - const componentsValue = useMemo( - () => ({ - components, - warn, - inlineToolNames: inlineNames(components), - }), - [components], - ) - return ( - - {children} - - ) + return {children} } - function Chat({ - chat, - components, - }: { - chat: ChatUIHost - components: ChatUIComponents - }) { - const Layout = components.layout + function Chat({ chat }: { chat: ChatUIHost }) { const renderMessages = useCallback(() => , []) const renderInterrupts = useCallback(() => , []) const renderInput = useCallback(() => { - const Input = components.input - return Input ? : null - }, [components.input]) + return InputComponent ? : null + }, []) return ( - + (options: TOptions) { }: { children?: (messages: ChatUIMessages) => ReactNode } = {}) { - const chat = useChat() - const { inlineToolNames } = useComponents() + const chat = useChatContext() const messages = readMessages(chat) const interrupts = readInterrupts(chat) if (children) return <>{children(messages)} @@ -291,7 +405,7 @@ export function createChatUI(options: TOptions) { const MessageView = memo(function MessageView({ message, interrupts, - inlineToolNames, + inlineToolNames: names, children, }: { message: ChatUIMessages[number] @@ -299,19 +413,17 @@ export function createChatUI(options: TOptions) { inlineToolNames: ReadonlyArray children?: (parts: Array) => ReactNode }) { - const { components } = useComponents() const selected = selectMessageUI(message, { interrupts, - inlineToolNames, + inlineToolNames: names, }) if (children) return <>{children(selected.parts)} - const MessageComponent = components.message return ( ( @@ -327,8 +439,7 @@ export function createChatUI(options: TOptions) { message: ChatUIMessages[number] children?: (parts: Array) => ReactNode }) { - const chat = useChat() - const { inlineToolNames } = useComponents() + const chat = useChatContext() return ( (options: TOptions) { const AutomaticParts = memo(function AutomaticParts({ message, interrupts, - inlineToolNames, + inlineToolNames: names, }: { message: ChatUIMessages[number] interrupts: ReadonlyArray @@ -350,7 +461,7 @@ export function createChatUI(options: TOptions) { }) { const selected = selectMessageUI(message, { interrupts, - inlineToolNames, + inlineToolNames: names, }) return ( <> @@ -361,22 +472,18 @@ export function createChatUI(options: TOptions) { ) }) - const SelectedPartView = memo(function SelectedPartView({ + const SelectedPartInner = memo(function SelectedPartInner({ selected, }: { selected: ChatUISelectedPart }) { - const { components, warn: warnMissing } = useComponents() if (selected.key === 'toolCall') { const name = selected.part.name - const Tool = components.tools?.[name as ChatUIToolName] as + const Tool = tools?.[name as ChatUIToolName] as | ComponentType> | undefined if (!Tool) { - warnMissing( - `tool:${name}`, - `[tanstack-ai-ui] Missing tools.${name} component`, - ) + warn(`tool:${name}`, `[tanstack-ai-ui] Missing tools.${name} component`) return null } return ( @@ -388,12 +495,11 @@ export function createChatUI(options: TOptions) { ) } - const PartComponent = (components.parts[selected.key] ?? - components.parts.fallback) as + const PartComponent = (parts[selected.key] ?? parts.fallback) as | ComponentType> | undefined if (!PartComponent) { - warnMissing( + warn( `part:${selected.key}`, `[tanstack-ai-ui] Missing parts.${selected.key} component`, ) @@ -402,14 +508,47 @@ export function createChatUI(options: TOptions) { return ['part']} /> }, selectedPartPropsEqual) - function Part({ part }: { part: MessagePart }) { - const chat = useChat() - const selected = selectMessageUI( - { id: 'part', role: 'assistant', parts: [part] }, - { interrupts: readInterrupts(chat), inlineToolNames: [] }, - ).parts[0] + const SelectedPartView = memo(function SelectedPartView({ + selected, + }: { + selected: ChatUISelectedPart + }) { + return ( + + + + ) + }) + + function BoundRender() { + const selected = usePartContext() + return + } + + function Part({ + part, + children, + }: { + part: MessagePart | ChatUISelectedPart + children?: (mixed: ChatUISelectedPart & PartMixins) => ReactNode + }) { + const chat = useChatContext() + const selected = isSelectedPart(part) + ? part + : selectMessageUI( + { id: 'part', role: 'assistant', parts: [part] }, + { interrupts: readInterrupts(chat), inlineToolNames: [] }, + ).parts[0] if (!selected) return null - return + return ( + + {children ? ( + children(mixPart(selected)) + ) : ( + + )} + + ) } function Interrupts({ @@ -417,8 +556,7 @@ export function createChatUI(options: TOptions) { }: { children?: (interrupts: ReadonlyArray) => ReactNode } = {}) { - const chat = useChat() - const { inlineToolNames } = useComponents() + const chat = useChatContext() const selected = selectChatUI({ messages: readMessages(chat), interrupts: readInterrupts(chat), @@ -434,18 +572,16 @@ export function createChatUI(options: TOptions) { ) } - const Interrupt = memo(function Interrupt({ + const InterruptInner = memo(function InterruptInner({ interrupt, }: { interrupt: ChatUIInterrupt }) { - const { components, warn: warnMissing } = useComponents() - const Component = resolveInterruptComponent( - interrupt, - components.interrupts, - ) as ComponentType> | undefined + const Component = resolveInterruptComponent(interrupt, interrupts) as + | ComponentType> + | undefined if (!Component) { - warnMissing( + warn( `interrupt:${interrupt.id}`, `[tanstack-ai-ui] Missing interrupt component for ${interrupt.kind}`, ) @@ -454,6 +590,29 @@ export function createChatUI(options: TOptions) { return }) + function BoundInterruptRender() { + const interrupt = useInterruptContext() + return + } + + const Interrupt = memo(function Interrupt({ + interrupt, + children, + }: { + interrupt: ChatUIInterrupt + children?: (mixed: ChatUIInterrupt & InterruptMixins) => ReactNode + }) { + return ( + + {children ? ( + children(mixInterrupt(interrupt)) + ) : ( + + )} + + ) + }) + return { Chat, Provider, @@ -462,7 +621,7 @@ export function createChatUI(options: TOptions) { Part, Interrupts, Interrupt, - defineComponents, - useChat, + useChatContext, + Input: InputComponent, } } diff --git a/packages/ai-react-ui/src/markdown-plugins.ts b/packages/ai-react/src/chat-ui/markdown-plugins.ts similarity index 100% rename from packages/ai-react-ui/src/markdown-plugins.ts rename to packages/ai-react/src/chat-ui/markdown-plugins.ts diff --git a/packages/ai-react-ui/src/text-part.tsx b/packages/ai-react/src/chat-ui/text-part.tsx similarity index 100% rename from packages/ai-react-ui/src/text-part.tsx rename to packages/ai-react/src/chat-ui/text-part.tsx diff --git a/packages/ai-react-ui/src/thinking-part.tsx b/packages/ai-react/src/chat-ui/thinking-part.tsx similarity index 100% rename from packages/ai-react-ui/src/thinking-part.tsx rename to packages/ai-react/src/chat-ui/thinking-part.tsx diff --git a/packages/ai-react-ui/src/tool-approval.tsx b/packages/ai-react/src/chat-ui/tool-approval.tsx similarity index 100% rename from packages/ai-react-ui/src/tool-approval.tsx rename to packages/ai-react/src/chat-ui/tool-approval.tsx diff --git a/packages/ai-react-ui/src/tool-result-content.ts b/packages/ai-react/src/chat-ui/tool-result-content.ts similarity index 95% rename from packages/ai-react-ui/src/tool-result-content.ts rename to packages/ai-react/src/chat-ui/tool-result-content.ts index a6d5659f3a..8825b51ad9 100644 --- a/packages/ai-react-ui/src/tool-result-content.ts +++ b/packages/ai-react/src/chat-ui/tool-result-content.ts @@ -1,4 +1,4 @@ -import type { UIMessage } from '@tanstack/ai-react' +import type { UIMessage } from '../types' type ToolResultPart = Extract< UIMessage['parts'][number], diff --git a/packages/ai-react/src/create-chat-hook.ts b/packages/ai-react/src/create-chat-hook.ts new file mode 100644 index 0000000000..f143af1ef1 --- /dev/null +++ b/packages/ai-react/src/create-chat-hook.ts @@ -0,0 +1,67 @@ +import { useChat as useUnboundChat } from './use-chat' +import type { + AnyClientTool, + InterruptDefinition, + SchemaInput, +} from '@tanstack/ai/client' +import type { InferredClientContext } from '@tanstack/ai-client' +import type { UseChatOptions } from './types' + +type ChatHookOverrides< + TTools extends ReadonlyArray, + TSchema extends SchemaInput | undefined, + TContext, + TInterrupts extends ReadonlyArray>, +> = { + threadId?: string + live?: boolean + forwardedProps?: Record + body?: Record + initialMessages?: UseChatOptions< + TTools, + TSchema, + TContext, + TInterrupts + >['initialMessages'] +} + +/** + * Bind chat options once at module scope. The returned `useChat` hook + * creates a chat instance from those options. + * + * Pass per-call overrides for instance keys such as `threadId`, + * `initialMessages`, `live`, and `forwardedProps`. Do not change `tools`, + * `interrupts`, or `outputSchema` here. Those stay on the factory options. + * + * Rename the hook at the call site if you already import `useChat` from + * `@tanstack/ai-react`: `const { useChat: useAppChat } = createChatHook(chatOptions)`. + * + * @example + * ```tsx + * const { useChat } = createChatHook(chatOptions) + * + * function Support() { + * const chat = useChat({ threadId: 'support-1' }) + * return + * } + * ``` + */ +export function createChatHook< + const TTools extends ReadonlyArray = any, + TSchema extends SchemaInput | undefined = undefined, + TContext = InferredClientContext, + const TInterrupts extends ReadonlyArray< + InterruptDefinition + > = readonly [], +>(options: UseChatOptions) { + function useChat( + overrides?: ChatHookOverrides, + ) { + if (!overrides) { + return useUnboundChat(options) + } + return useUnboundChat({ ...options, ...overrides }) + } + + return { useChat } +} diff --git a/packages/ai-react/src/index.ts b/packages/ai-react/src/index.ts index 0ea29a70f3..1ac2e347ab 100644 --- a/packages/ai-react/src/index.ts +++ b/packages/ai-react/src/index.ts @@ -1,4 +1,5 @@ export { useChat } from './use-chat' +export { createChatHook } from './create-chat-hook' export { useByok } from './use-byok' export { useRealtimeChat } from './use-realtime-chat' export { useMcpAppBridge } from './use-mcp-app-bridge' diff --git a/packages/ai-react/src/ui.ts b/packages/ai-react/src/ui.ts new file mode 100644 index 0000000000..8c06d887c8 --- /dev/null +++ b/packages/ai-react/src/ui.ts @@ -0,0 +1,41 @@ +// Barrel entry for the `@tanstack/ai-react/ui` subpath. The JSX +// implementation lives under `./chat-ui`; this `.ts` re-export exists so +// kiira's dist->src resolution (which maps `dist/esm/ui.d.ts` to `src/ui.ts`, +// never a directory index) can type-check docs snippets that import this +// subpath. +export { + createChatUI, + type ChatUIComponents, + type ChatUIFactoryConfig, + type ChatUIHost, + type InputProps, + type InterruptProps, + type LayoutProps, + type MessageProps, + type PartProps, + type ToolProps, +} from './chat-ui/create-ui' +export { createChatHook } from './chat-ui/create-chat-hook' +export { + createChatHookContexts, + type ChatUIContexts, +} from './chat-ui/create-ui-contexts' +export { Chat, useChatContext, type ChatProps } from './chat-ui/chat' +export { ChatMessages, type ChatMessagesProps } from './chat-ui/chat-messages' +export { + ChatMessage, + type ChatMessageProps, + type ToolCallRenderProps, +} from './chat-ui/chat-message' +export { + ChatInput, + type ChatInputProps, + type ChatInputRenderProps, +} from './chat-ui/chat-input' +export { + ToolApproval, + type ToolApprovalProps, + type ToolApprovalRenderProps, +} from './chat-ui/tool-approval' +export { TextPart, type TextPartProps } from './chat-ui/text-part' +export { ThinkingPart, type ThinkingPartProps } from './chat-ui/thinking-part' diff --git a/packages/ai-react-ui/tests/coexistence.test.ts b/packages/ai-react/tests/chat-ui/coexistence.test.ts similarity index 76% rename from packages/ai-react-ui/tests/coexistence.test.ts rename to packages/ai-react/tests/chat-ui/coexistence.test.ts index 4c9c6cb332..bf8d4a50f4 100644 --- a/packages/ai-react-ui/tests/coexistence.test.ts +++ b/packages/ai-react/tests/chat-ui/coexistence.test.ts @@ -7,7 +7,9 @@ import { TextPart, ThinkingPart, createChatUI, -} from '../src' + createChatHook, + createChatHookContexts, +} from '../../src/ui' describe('public coexistence', () => { it('exports old and new APIs before 1.0', () => { @@ -18,5 +20,7 @@ describe('public coexistence', () => { expect(TextPart).toBeDefined() expect(ThinkingPart).toBeDefined() expect(createChatUI).toBeDefined() + expect(createChatHook).toBeDefined() + expect(createChatHookContexts).toBeDefined() }) }) diff --git a/packages/ai-react-ui/tests/create-ui-types.test.tsx b/packages/ai-react/tests/chat-ui/create-ui-types.test.tsx similarity index 58% rename from packages/ai-react-ui/tests/create-ui-types.test.tsx rename to packages/ai-react/tests/chat-ui/create-ui-types.test.tsx index 9dfe8e6644..e80bf3ccf8 100644 --- a/packages/ai-react-ui/tests/create-ui-types.test.tsx +++ b/packages/ai-react/tests/chat-ui/create-ui-types.test.tsx @@ -1,12 +1,15 @@ +import type { ComponentProps } from 'react' import { expectTypeOf, it } from 'vitest' -import { createChatUI } from '../src/create-ui' +import { createChatHook } from '../../src/chat-ui/create-chat-hook' +import { createChatUI } from '../../src/chat-ui/create-ui' import type { + ChatUIFactoryConfig, ChatUIHost, InterruptProps, PartProps, ToolProps, -} from '../src/create-ui' -import { chatOptions } from '../../ai-client/tests/ui-fixtures' +} from '../../src/chat-ui/create-ui' +import { chatOptions } from '../../../ai-client/tests/ui-fixtures' it('types tool and interrupt component props from chatOptions', () => { type TextPartProps = PartProps @@ -47,16 +50,81 @@ it('types tool and interrupt component props from chatOptions', () => { { title: string } | undefined >() - const UI = createChatUI(chatOptions) + const { useAppChat, useChatContext } = createChatHook({ + options: chatOptions, + chatComponents: { + layout: ({ renderMessages }) => { + expectTypeOf(useChatContext()).toEqualTypeOf< + ChatUIHost + >() + return renderMessages() + }, + message: ({ renderParts }) => renderParts(), + parts: { fallback: () => null }, + tools: { + getWeather: () => null, + purchaseItem: () => null, + }, + interrupts: { + generic: { + choosePlan: () => null, + fallback: () => null, + }, + }, + }, + }) + expectTypeOf(useAppChat).not.toBeAny() + expectTypeOf(useChatContext).toBeFunction() + + const checkBoundHook = () => { + const chat = useAppChat() + expectTypeOf(chat).toMatchTypeOf>() + expectTypeOf(chat.sendMessage).toBeFunction() + expectTypeOf(chat.AppChat).not.toBeAny() + expectTypeOf>().not.toHaveProperty( + 'chat', + ) + + const message = chat.messages[0] + if (message?.role === 'assistant') { + for (const part of message.parts) { + if (part.type === 'tool-call') { + expectTypeOf(part.name).toEqualTypeOf<'getWeather' | 'purchaseItem'>() + } + } + } + } + void checkBoundHook + + createChatHook({ + options: chatOptions, + chatComponents: { + layout: () => null, + message: () => null, + parts: { fallback: () => null }, + // @ts-expect-error Every configured tool needs a component. + tools: { + getWeather: () => null, + }, + interrupts: { + generic: { + choosePlan: () => null, + }, + }, + }, + }) - UI.defineComponents({ + const UI = createChatUI(chatOptions, { layout: ({ renderMessages }) => { - expectTypeOf(UI.useChat()).toEqualTypeOf>() - expectTypeOf(UI.useChat().sendMessage).toBeFunction() - expectTypeOf(UI.useChat().queue).toBeArray() + expectTypeOf(UI.useChatContext()).toEqualTypeOf< + ChatUIHost + >() + expectTypeOf(UI.useChatContext().sendMessage).toBeFunction() + expectTypeOf(UI.useChatContext().queue).toBeArray() return renderMessages() }, message: ({ renderParts }) => renderParts(), + input: () => null, parts: { text: ({ part }) => { expectTypeOf(part.type).toEqualTypeOf<'text'>() @@ -101,7 +169,11 @@ it('types tool and interrupt component props from chatOptions', () => { }, }) - UI.defineComponents({ + expectTypeOf(UI.Input).toEqualTypeOf< + ChatUIFactoryConfig['input'] + >() + + createChatUI(chatOptions, { layout: () => null, message: () => null, parts: { fallback: () => null }, @@ -116,7 +188,7 @@ it('types tool and interrupt component props from chatOptions', () => { }, }) - UI.defineComponents({ + createChatUI(chatOptions, { layout: () => null, message: () => null, parts: { fallback: () => null }, @@ -132,10 +204,13 @@ it('types tool and interrupt component props from chatOptions', () => { }, }) - const Untyped = createChatUI({}) - Untyped.defineComponents({ - layout: () => null, - message: () => null, - parts: { fallback: () => null }, - }) + const Untyped = createChatUI( + {}, + { + layout: () => null, + message: () => null, + parts: { fallback: () => null }, + }, + ) + expectTypeOf(Untyped.Chat).toBeFunction() }) diff --git a/packages/ai-react/tests/chat-ui/create-ui.test.tsx b/packages/ai-react/tests/chat-ui/create-ui.test.tsx new file mode 100644 index 0000000000..9025debac4 --- /dev/null +++ b/packages/ai-react/tests/chat-ui/create-ui.test.tsx @@ -0,0 +1,419 @@ +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it, vi } from 'vitest' +import { createChatUI } from '../../src/chat-ui/create-ui' +import { createChatHook } from '../../src/chat-ui/create-chat-hook' +import { createChatHookContexts } from '../../src/chat-ui/create-ui-contexts' +import type { + ChatUIFactoryConfig, + ChatUIHost, +} from '../../src/chat-ui/create-ui' +import { + chatOptions, + createChatResult, + genericInterrupt, + messageWithToolResults, + orphanResultMessage, + purchaseApprovalInterrupt, + purchaseApprovalMessage, + unboundInterrupt, + unknownToolMessage, +} from '../../../ai-client/tests/ui-fixtures' +import type { ToolCallState } from '@tanstack/ai-client' + +function host( + init?: Parameters[0], +): ChatUIHost { + return createChatResult(init ?? {}) as unknown as ChatUIHost< + typeof chatOptions + > +} + +const baseConfig: ChatUIFactoryConfig = { + layout: ({ renderMessages, renderInterrupts, renderInput }) => ( + <> + {renderMessages()} + {renderInterrupts()} + {renderInput()} + + ), + message: ({ renderParts }) =>
{renderParts()}
, + parts: { fallback: ({ part }) => {part.type} }, + tools: { + getWeather: ({ part }) => {part.input?.city}, + purchaseItem: () => null, + }, + interrupts: { + generic: { + choosePlan: () => null, + fallback: ({ interrupt }) => {interrupt.reason}, + }, + }, +} + +function makeUI( + patch?: Partial> & { + parts?: ChatUIFactoryConfig['parts'] + tools?: ChatUIFactoryConfig['tools'] + interrupts?: ChatUIFactoryConfig['interrupts'] + input?: ChatUIFactoryConfig['input'] + layout?: ChatUIFactoryConfig['layout'] + message?: ChatUIFactoryConfig['message'] + }, +) { + return createChatUI(chatOptions, { + ...baseConfig, + ...patch, + parts: { ...baseConfig.parts, ...patch?.parts }, + tools: { ...baseConfig.tools, ...patch?.tools }, + interrupts: { + tools: { + ...baseConfig.interrupts.tools, + ...patch?.interrupts?.tools, + }, + generic: { + ...baseConfig.interrupts.generic, + ...patch?.interrupts?.generic, + }, + }, + }) +} + +describe('createChatHook', () => { + it('mixes AppChat onto the instance from options and chatComponents', () => { + const { useAppChat, useChatContext } = createChatHook({ + options: chatOptions, + chatComponents: baseConfig, + }) + expect(typeof useAppChat).toBe('function') + expect(typeof useChatContext).toBe('function') + + function Screen() { + const chat = useAppChat({ + initialMessages: [messageWithToolResults] as ChatUIHost< + typeof chatOptions + >['messages'], + }) + return + } + + const markup = renderToStaticMarkup() + expect(markup).toContain('Paris') + }) +}) + +describe('createChatUI', () => { + it('renders automatic and manual trees', () => { + const UI = makeUI() + const chat = host({ messages: [messageWithToolResults] }) + const automatic = renderToStaticMarkup() + expect(automatic).toContain('Paris') + + const manual = renderToStaticMarkup( + + + {(messages) => {messages.length}} + + , + ) + expect(manual).toContain('1') + }) + + it('warns once for a missing runtime key', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const UI = makeUI() + const chat = host({ messages: [unknownToolMessage] }) + renderToStaticMarkup() + renderToStaticMarkup() + expect(warn).toHaveBeenCalledTimes(1) + warn.mockRestore() + }) + + it('keeps unmatched tool results and suppresses matched ones', () => { + const UI = makeUI({ + parts: { + toolResult: ({ part }) => + part.type === 'tool-result' ? {String(part.content)} : null, + fallback: () => null, + }, + tools: { + getWeather: () => weather, + purchaseItem: () => null, + }, + }) + + const matched = renderToStaticMarkup( + , + ) + expect(matched).toContain('weather') + expect(matched).not.toContain('') + + const unmatched = renderToStaticMarkup( + , + ) + expect(unmatched).toContain('standalone') + }) + + it('puts list approvals in Interrupts when interrupts.tools has the tool', () => { + const UI = makeUI({ + tools: { + getWeather: () => null, + purchaseItem: () =>
tool
, + }, + interrupts: { + tools: { + purchaseItem: () => list-approval, + }, + generic: { choosePlan: () => null, fallback: () => null }, + }, + }) + + const listMarkup = renderToStaticMarkup( + , + ) + expect(listMarkup).toContain('list-approval') + expect(listMarkup).toContain('tool') + }) + + it('lets a tool render its approval from interrupt without a registered interrupt component', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const UI = makeUI({ + tools: { + getWeather: () => null, + purchaseItem: ({ interrupt }) => + interrupt ? {interrupt.toolName} : tool, + }, + interrupts: { + generic: { choosePlan: () => null, fallback: () => list }, + }, + }) + const markup = renderToStaticMarkup( + , + ) + expect(markup).toContain('purchaseItem') + expect(markup).not.toContain('list') + expect( + warn.mock.calls.filter((call) => + String(call[0]).includes('[tanstack-ai-ui]'), + ), + ).toHaveLength(0) + warn.mockRestore() + }) + + it('renders registered generic interrupts and sends the rest to fallback', () => { + const UI = makeUI({ + layout: ({ renderInterrupts }) => renderInterrupts(), + interrupts: { + generic: { + choosePlan: () => plan, + fallback: () => fallback, + }, + }, + }) + + const markup = renderToStaticMarkup( + , + ) + expect(markup).toContain('plan') + expect(markup).toContain('fallback') + }) + + it('omits input when no input component exists', () => { + const UI = makeUI({ + layout: ({ renderInput }) =>
{renderInput()}
, + }) + const markup = renderToStaticMarkup() + expect(markup).toBe('
') + }) + + it('mixes Input onto the UI kit when one is registered', () => { + const UI = makeUI({ + input: () =>