Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
148 changes: 83 additions & 65 deletions apps/apollo-vertex/app/patterns/ai-chat/page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ A composable AI chat UI component for Apollo Vertex. Built with React, TypeScrip
## Features

- **TanStack AI Integration** — Works with `useChat` from `@tanstack/ai-react` and `UIMessage` types
- **Composable** — `AiChat` is the shell, `AiChatMessage` renders messages, you iterate parts and render tools inline
- **Type-Safe Tool Rendering** — Check `part.name` in the parts loop and TypeScript narrows `part.output` automatically
- **One-prop wiring** — Pass `messages` and `status` from `useChat`; the component owns the message loop, scroll, and per-message action wiring
- **Type-Safe Tool Rendering** — Pass a `renderToolPart` callback; TypeScript narrows `part.output` automatically when you check `part.name`
- **AgentHub Adapter** — Built-in adapter for the UiPath AgentHub normalized LLM endpoint (OpenAI + Anthropic models)
- **Conversational Agent Adapter** — Built-in adapter for a deployed UiPath Conversational Agent, with session management
- **Markdown Rendering** — Renders assistant responses with GitHub Flavored Markdown
Expand Down Expand Up @@ -49,7 +49,6 @@ npx shadcn@latest add @uipath/ai-chat
```tsx
import { useChat } from '@tanstack/ai-react';
import { AiChat } from '@/components/ui/ai-chat/components/ai-chat';
import { AiChatMessage } from '@/components/ui/ai-chat/components/ai-chat-message';
import { createAgentHubConnection } from '@/components/ui/ai-chat/adapters/agenthub/adapter';

function BasicChat() {
Expand All @@ -60,39 +59,34 @@ function BasicChat() {
systemPrompt: 'You are a helpful assistant.',
});

const { messages, sendMessage, isLoading, stop, clear, error } = useChat({
const { messages, sendMessage, status, stop, clear, error } = useChat({
connection,
});

return (
<AiChat
messages={messages}
isLoading={isLoading}
status={status}
onSendMessage={(text) => sendMessage(text)}
onStop={stop}
onClearChat={clear}
error={error}
title="AI Assistant"
>
{messages.map((message) => (
<AiChatMessage key={message.id} message={message} />
))}
</AiChat>
/>
);
}
```

## Tool Rendering

Render tool output inline in the chat — just like TanStack AI's own examples. Define tools with `toolDefinition`, pass the input through as output in your client tool, then check `part.name` in the parts loop. TypeScript narrows `part.output` automatically.
Define tools with `toolDefinition`, pass the input through as output in your client tool, then provide a `renderToolPart` callback to `<AiChat>`. The part is already narrowed to tool-call parts; check `part.name` and TypeScript narrows `part.output` to the right tool's output type automatically.

```tsx
import { z } from 'zod';
import { toolDefinition } from '@tanstack/ai';
import { clientTools } from '@tanstack/ai-client';
import { stream, useChat } from '@tanstack/ai-react';
import { useChat } from '@tanstack/ai-react';
import { AiChat } from '@/components/ui/ai-chat/components/ai-chat';
import { AiChatMessage } from '@/components/ui/ai-chat/components/ai-chat-message';

// 1. Define tools — output passes input through for rendering
const showResultsInput = z.object({
Expand All @@ -110,36 +104,60 @@ const showResultsDef = toolDefinition({
const showResults = showResultsDef.client((input) => input);
const toolDefs = clientTools(showResults);

// 2. Wire it up — iterate parts, render tools inline
// 2. Wire it up — return tool output from renderToolPart
function ChatWithTools() {
const { messages, sendMessage, isLoading, stop } = useChat({
const { messages, sendMessage, status, stop } = useChat({
connection,
tools: toolDefs,
});

return (
<AiChat
messages={messages}
isLoading={isLoading}
status={status}
onSendMessage={(text) => sendMessage(text)}
onStop={stop}
>
{messages.map((message) => (
<AiChatMessage key={message.id} message={message}>
{message.parts.map((part) => {
// TypeScript narrows part.output when you check part.name
if (part.type === 'tool-call' && part.name === 'show_results' && part.output) {
return <ResultsTable key={part.id} entity={part.output.entityName} columns={part.output.columns} />;
}
return null;
})}
</AiChatMessage>
))}
</AiChat>
renderToolPart={(part) => {
// TypeScript narrows part.output when you check part.name
if (part.name === 'show_results' && part.output) {
return <ResultsTable entity={part.output.entityName} columns={part.output.columns} />;
}
return null;
}}
/>
);
}
```

`<AiChat>` keys each rendered part by `part.id`, so you don't need to add `key` yourself.

## Message Actions

Each message can show inline actions — copy, thumbs-up/down feedback, regenerate, and edit. Pass the optional callbacks directly to `<AiChat>`; it wires them to the right messages (assistant for feedback/regenerate, user for edit) and the action row appears automatically.

```tsx
const { messages, sendMessage, reload, status, stop } = useChat({ connection });

<AiChat
messages={messages}
status={status}
onSendMessage={(text) => sendMessage(text)}
onStop={stop}
onFeedback={(messageId, type) => {
// type: 'positive' | 'negative' — send to your analytics / feedback endpoint
void recordFeedback({ messageId, type });
}}
getFeedback={(messageId) => feedbackById[messageId] ?? null}
onRegenerate={() => void reload()}
onEditMessage={(_messageId, content) => {
// Re-runs the conversation with the edited user message
void sendMessage(content);
}}
/>
```

Copy is always available and needs no wiring. Feedback and edit only render when their callbacks are supplied.

## AgentHub Adapter

The built-in adapter for the **UiPath AgentHub** normalized LLM endpoint. It converts TanStack AI `UIMessage` arrays to the AgentHub wire format, calls the endpoint, and parses the SSE response back into AG-UI `StreamChunk` events.
Expand Down Expand Up @@ -186,7 +204,7 @@ const connection = createConversationalAgentConnection({
folderId, // number — the folder the agent lives in
});

const { messages, sendMessage, isLoading, stop, clear, error } = useChat({
const { messages, sendMessage, status, stop, clear, error } = useChat({
connection,
});

Expand Down Expand Up @@ -218,7 +236,7 @@ const tableTool = createDataFabricTableTool({
});

// Use dataFabricTableClient in your tools array, tableTool.toolPrompt in your system prompt,
// and tableTool.renderTable(part.output, part.id) in your parts loop.
// and tableTool.renderTable(part.output, part.id) in your renderToolPart callback.
```

### Filter types
Expand Down Expand Up @@ -271,7 +289,7 @@ const distributionTool = createDataFabricDistributionTool({
});

// Use dataFabricDistributionClient in your tools array, distributionTool.toolPrompt in your
// system prompt, and distributionTool.renderDistribution(part.output, part.id) in your parts loop.
// system prompt, and distributionTool.renderDistribution(part.output, part.id) in your renderToolPart callback.
```

### Dimension
Expand Down Expand Up @@ -319,7 +337,7 @@ const barTool = createDataFabricBarTool({
});

// Use dataFabricBarClient in your tools array, barTool.toolPrompt in your
// system prompt, and barTool.renderBar(part.output, part.id) in your parts loop.
// system prompt, and barTool.renderBar(part.output, part.id) in your renderToolPart callback.
```

### When to use bar vs distribution vs line
Expand Down Expand Up @@ -375,7 +393,7 @@ const lineTool = createDataFabricLineTool({
});

// Use dataFabricLineClient in your tools array, lineTool.toolPrompt in your
// system prompt, and lineTool.renderLine(part.output, part.id) in your parts loop.
// system prompt, and lineTool.renderLine(part.output, part.id) in your renderToolPart callback.
```

### When to use line vs distribution
Expand Down Expand Up @@ -408,7 +426,7 @@ const multiLineTool = createDataFabricMultiLineTool({
});

// Use dataFabricMultiLineClient in your tools array, multiLineTool.toolPrompt in your
// system prompt, and multiLineTool.renderMultiLine(part.output, part.id) in your parts loop.
// system prompt, and multiLineTool.renderMultiLine(part.output, part.id) in your renderToolPart callback.
```

### Metrics
Expand Down Expand Up @@ -455,7 +473,7 @@ const kpiTool = createDataFabricKpiTool({
});

// Use dataFabricKpiClient in your tools array, kpiTool.toolPrompt in your
// system prompt, and kpiTool.renderKpi(part.output, part.id) in your parts loop.
// system prompt, and kpiTool.renderKpi(part.output, part.id) in your renderToolPart callback.
```

### When to use KPI vs other chart tools
Expand Down Expand Up @@ -486,7 +504,7 @@ Filter and join schemas are shared with the other Data Fabric tools (including t

## Suggestion Buttons

The `presentChoices` tool renders interactive suggestion buttons. Define the tool with a Zod schema, and render choices inline in the parts loop:
The `presentChoices` tool renders interactive suggestion buttons. Define the tool with a Zod schema, and render choices from `renderToolPart`:

```tsx
import {
Expand All @@ -497,17 +515,20 @@ import {

// Add presentChoicesClient to your tools array and CHOICES_TOOL_PROMPT to your system prompt.

// In your parts loop:
{message.parts.map((part) => {
if (part.type === 'tool-call' && part.name === 'presentChoices' && part.output) {
return (
<div key={part.id}>
{renderChoices(part.output, { onAction: (text) => sendMessage(text) })}
</div>
);
}
return null;
})}
<AiChat
messages={messages}
status={status}
onSendMessage={(text) => sendMessage(text)}
onStop={stop}
renderToolPart={(part) => {
if (part.name === 'presentChoices' && part.output) {
return renderChoices(part.output, {
onAction: (text) => sendMessage(text),
});
}
return null;
}}
/>
```

> **Try it out** — type *"give me some choices"* in the demo above to see suggestion buttons in action.
Expand All @@ -519,33 +540,30 @@ import {

### `<AiChat>`

Chat shell component. Handles layout, scroll, input, loading indicator, suggestions, and errors. Render messages as children.
Chat shell component. Owns the message loop, scroll, input, loading indicator, suggestions, errors, and per-message action wiring. Generic over the connection's tools (`AiChat<TTools>`) — pass `UIMessage<TTools>[]` straight from `useChat` and `renderToolPart` gets typed narrowing on `part.name`/`part.output`.

| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `messages` | `UIMessage[]` | required | Messages from `useChat` |
| `isLoading` | `boolean` | required | Loading state from `useChat` |
| `messages` | `UIMessage<TTools>[]` | required | Messages from `useChat` |
| `status` | `'ready' \| 'submitted' \| 'streaming' \| 'error'` | required | Chat lifecycle state from `useChat` |
| `onSendMessage` | `(content: string) => void` | required | Send handler |
| `onStop` | `() => void` | required | Stop/abort handler |
| `children` | `ReactNode` | — | Message list (typically `messages.map(...)`) |
| `onClearChat` | `() => void` | — | Clear handler |
| `assistantName` | `string` | `"AI Assistant"` | Assistant display name |
| `renderToolPart` | `(part: ToolCallPart<TTools>) => ReactNode` | — | Render tool output for an assistant message. Check `part.name` to narrow `part.output` |
| `onClearChat` | `() => void` | — | Shows a "New conversation" item in the header dropdown when provided |
| `onRetry` | `() => void` | — | Retry handler shown next to the inline error banner |
| `onFeedback` | `(messageId: string, type: 'positive' \| 'negative') => void` | — | Thumbs-up/down callback. Feedback buttons only render when provided |
| `getFeedback` | `(messageId: string) => 'positive' \| 'negative' \| null \| undefined` | — | Resolves the saved feedback for a message — drives the pressed state |
| `onRegenerate` | `() => void` | — | Regenerate the last assistant response |
| `onEditMessage` | `(messageId: string, content: string) => void` | — | Save an edited user message. Edit affordance only renders when provided |
| `assistantName` | `string` | `"AI Assistant"` | Label used for the assistant in copied conversation text |
| `title` | `string` | — | Chat title in the header |
| `header` | `ReactNode` | — | Custom header — replaces the default `<AiChatHeader>` |
| `emptyState` | `ReactNode` | — | Custom empty state |
| `suggestions` | `string[]` | — | Quick-start prompts shown below the input in the empty state |
| `onSuggestionClick` | `(suggestion: string) => void` | — | Called when a suggestion is clicked (defaults to sending it as a message) |
| `placeholder` | `string` | — | Input placeholder |
| `showClearButton` | `boolean` | `true` | Show the clear button |
| `error` | `Error \| null` | — | Inline error banner |

### `<AiChatMessage>`

Renders a single message with avatar, name, markdown text, and children for custom content (tool output).

| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `message` | `UIMessage` | required | The message to render |
| `assistantName` | `string` | `"AI Assistant"` | Assistant display name |
| `children` | `ReactNode` | — | Custom content rendered below the message text (tool output, etc.) |

### `AgentHubAdapterConfig`

Configuration for the AgentHub adapter.
Expand Down
11 changes: 10 additions & 1 deletion apps/apollo-vertex/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"autopilot_empty_description": "Ask me anything about your automation data.",
"asc": "Asc",
"assistant": "Assistant",
"bad_response": "Bad response",
"business_user": "Business user",
"cancel": "Cancel",
"card_component": "Card Component",
Expand All @@ -27,8 +28,11 @@
"close_sidebar": "Close sidebar",
"collapse_tool_calls": "Collapse tool calls",
"copied": "Copied!",
"copy": "Copy",
"copy_code": "Copy code",
"copy_conversation": "Copy conversation",
"copy_conversation_failed": "Couldn't copy conversation",
"copy_failed": "Couldn't copy",
"copy_payment_id": "Copy payment ID",
"custom": "Custom",
"dark": "Dark",
Expand All @@ -37,6 +41,7 @@
"desc": "Desc",
"destructive": "Destructive",
"displays_mobile_sidebar": "Displays the mobile sidebar.",
"edit": "Edit",
"english": "English",
"enter_full_screen": "Enter full screen",
"error": "Error",
Expand All @@ -56,6 +61,7 @@
"go_to_last_page": "Go to last page",
"go_to_next_page": "Go to next page",
"go_to_previous_page": "Go to previous page",
"good_response": "Good response",
"hide": "Hide",
"how_can_i_help_you": "How can I help you?",
"import": "Import",
Expand Down Expand Up @@ -102,6 +108,7 @@
"rows_per_page": "Rows per page",
"rows_selected": "{{selected}} of {{total}} row(s) selected.",
"russian": "Russian",
"save_and_rerun": "Save & re-run",
"scroll_to_bottom": "Scroll to bottom",
"search": "Search...",
"search_frameworks": "Search frameworks",
Expand Down Expand Up @@ -138,6 +145,7 @@
"tool_call": "tool call",
"tool_calls": "tool calls",
"tools_used": "Tools used",
"try_again": "Try again",
"turkish": "Turkish",
"type_a_message": "Type a message...",
"unknown_entity": "Entity \"{{entity}}\" doesn't exist.",
Expand All @@ -153,5 +161,6 @@
"wrong_dimension_type_in_entity": "Field \"{{field}}\" on {{entity}} is {{actual}}; this chart needs a {{expected}} field.",
"wrong_dimension_type_in_joined": "Field \"{{field}}\" is {{actual}}; this chart needs a {{expected}} field.",
"wrong_metric_type_in_entity": "Field \"{{field}}\" on {{entity}} is {{actual}}; {{aggregation}} requires a numeric field.",
"wrong_metric_type_in_joined": "Field \"{{field}}\" is {{actual}}; {{aggregation}} requires a numeric field."
"wrong_metric_type_in_joined": "Field \"{{field}}\" is {{actual}}; {{aggregation}} requires a numeric field.",
"you": "You"
}
Loading
Loading