diff --git a/apps/apollo-vertex/app/patterns/ai-chat/page.mdx b/apps/apollo-vertex/app/patterns/ai-chat/page.mdx index f963d74f7..5b3dbd2ee 100644 --- a/apps/apollo-vertex/app/patterns/ai-chat/page.mdx +++ b/apps/apollo-vertex/app/patterns/ai-chat/page.mdx @@ -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 @@ -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() { @@ -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 ( sendMessage(text)} onStop={stop} onClearChat={clear} error={error} title="AI Assistant" - > - {messages.map((message) => ( - - ))} - + /> ); } ``` ## 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 ``. 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({ @@ -110,9 +104,9 @@ 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, }); @@ -120,26 +114,50 @@ function ChatWithTools() { return ( sendMessage(text)} onStop={stop} - > - {messages.map((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 ; - } - return null; - })} - - ))} - + renderToolPart={(part) => { + // TypeScript narrows part.output when you check part.name + if (part.name === 'show_results' && part.output) { + return ; + } + return null; + }} + /> ); } ``` +`` 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 ``; 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 }); + + 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. @@ -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, }); @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 { @@ -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 ( -
- {renderChoices(part.output, { onAction: (text) => sendMessage(text) })} -
- ); - } - return null; -})} + 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. @@ -519,33 +540,30 @@ import { ### `` -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`) — pass `UIMessage[]` 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[]` | 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) => 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 `` | | `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 | -### `` - -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. diff --git a/apps/apollo-vertex/locales/en.json b/apps/apollo-vertex/locales/en.json index f311150b7..00a45f8d7 100644 --- a/apps/apollo-vertex/locales/en.json +++ b/apps/apollo-vertex/locales/en.json @@ -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", @@ -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", @@ -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", @@ -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", @@ -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", @@ -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.", @@ -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" } diff --git a/apps/apollo-vertex/registry.json b/apps/apollo-vertex/registry.json index 18e83cabb..809b058fe 100644 --- a/apps/apollo-vertex/registry.json +++ b/apps/apollo-vertex/registry.json @@ -180,6 +180,7 @@ "ai-chat-ring": "oklch(0.64 0.115 208)", "ai-chat-muted": "oklch(0.955 0.006 260)", "ai-chat-muted-foreground": "oklch(0.50 0.020 260)", + "ai-chat-brand-gradient": "linear-gradient(97.73deg, #5D4ED0 8.79%, #1076A0 91.48%)", "ai-gradient": "linear-gradient(100.64deg, rgb(238, 224, 255) 8.29%, rgb(207, 217, 255) 88.73%)", "ai-gradient-strong": "linear-gradient(97.73deg, #6C5AEF 8.79%, #69C7DD 91.48%)", "ai-gradient-start": "#6C5AEF", @@ -268,6 +269,7 @@ "ai-chat-ring": "oklch(0.69 0.112 207)", "ai-chat-muted": "oklch(0.26 0.030 258)", "ai-chat-muted-foreground": "oklch(0.65 0.020 258)", + "ai-chat-brand-gradient": "linear-gradient(97.73deg, #9485F5 8.79%, #69C7DD 91.48%)", "ai-gradient": "linear-gradient(98.69deg, rgb(108, 90, 239) 8.79%, rgb(105, 199, 221) 91.48%)", "ai-gradient-strong": "linear-gradient(97.73deg, #6C5AEF 8.79%, #69C7DD 91.48%)", "ai-gradient-start": "#6C5AEF", @@ -309,6 +311,7 @@ "description": "A composable AI chat UI component for TanStack AI with AgentHub adapter, markdown rendering, suggestion buttons, and error display", "dependencies": [ "@mantine/hooks@^9.0.0", + "@radix-ui/react-tooltip@^1.2.8", "@tanstack/ai@0.14.0", "@tanstack/ai-client@0.8.0", "@tanstack/ai-react@0.8.0", @@ -328,8 +331,10 @@ ], "registryDependencies": [ "@uipath/alert-dialog", + "@uipath/button", "@uipath/card", "@uipath/dropdown-menu", + "@uipath/textarea", "@uipath/tooltip" ], "files": [ @@ -389,6 +394,11 @@ "type": "registry:lib", "target": "components/ui/ai-chat/hooks/use-sticky-scroll.ts" }, + { + "path": "registry/ai-chat/animations.ts", + "type": "registry:lib", + "target": "components/ui/ai-chat/animations.ts" + }, { "path": "registry/ai-chat/components/ai-chat.tsx", "type": "registry:ui", @@ -429,6 +439,21 @@ "type": "registry:ui", "target": "components/ui/ai-chat/components/ai-chat-suggestions.tsx" }, + { + "path": "registry/ai-chat/types.ts", + "type": "registry:lib", + "target": "components/ui/ai-chat/types.ts" + }, + { + "path": "registry/ai-chat/components/ai-chat-header.tsx", + "type": "registry:ui", + "target": "components/ui/ai-chat/components/ai-chat-header.tsx" + }, + { + "path": "registry/ai-chat/components/ai-chat-message-actions.tsx", + "type": "registry:ui", + "target": "components/ui/ai-chat/components/ai-chat-message-actions.tsx" + }, { "path": "registry/ai-chat/components/ai-chat-thinking.tsx", "type": "registry:ui", diff --git a/apps/apollo-vertex/registry/ai-chat/animations.ts b/apps/apollo-vertex/registry/ai-chat/animations.ts new file mode 100644 index 000000000..52f1e24a9 --- /dev/null +++ b/apps/apollo-vertex/registry/ai-chat/animations.ts @@ -0,0 +1,3 @@ +export const ENTRANCE_EASE = [0.22, 1, 0.36, 1] as const; +export const ENTRANCE_INITIAL = { opacity: 0, y: 8 }; +export const ENTRANCE_ANIMATE = { opacity: 1, y: 0 }; diff --git a/apps/apollo-vertex/registry/ai-chat/components/ai-chat-code-block.tsx b/apps/apollo-vertex/registry/ai-chat/components/ai-chat-code-block.tsx index 197d5b786..4469261e3 100644 --- a/apps/apollo-vertex/registry/ai-chat/components/ai-chat-code-block.tsx +++ b/apps/apollo-vertex/registry/ai-chat/components/ai-chat-code-block.tsx @@ -57,6 +57,7 @@ const DARK_HLJS_STYLE = ` } `; +import { useClipboard } from "@mantine/hooks"; import hljs from "highlight.js/lib/core"; import bash from "highlight.js/lib/languages/bash"; import css from "highlight.js/lib/languages/css"; @@ -67,12 +68,14 @@ import sql from "highlight.js/lib/languages/sql"; import typescript from "highlight.js/lib/languages/typescript"; import xml from "highlight.js/lib/languages/xml"; import { Check, Copy } from "lucide-react"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useRef } from "react"; +import { useTranslation } from "react-i18next"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; hljs.registerLanguage("javascript", javascript); hljs.registerLanguage("js", javascript); @@ -91,16 +94,14 @@ hljs.registerLanguage("html", xml); hljs.registerLanguage("xml", xml); hljs.registerLanguage("sql", sql); -const COPY_LABEL = "Copy code"; -const COPIED_LABEL = "Copied!"; - interface AiChatCodeBlockProps { children: string; language?: string; } export function AiChatCodeBlock({ children, language }: AiChatCodeBlockProps) { - const [copied, setCopied] = useState(false); + const { t } = useTranslation(); + const { copied, error, copy } = useClipboard({ timeout: 2000 }); const codeRef = useRef(null); const highlightedHtml = @@ -114,13 +115,11 @@ export function AiChatCodeBlock({ children, language }: AiChatCodeBlockProps) { } }, [highlightedHtml]); - const handleCopy = async () => { - await navigator.clipboard.writeText(children); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - }; - - const copyLabel = copied ? COPIED_LABEL : COPY_LABEL; + const copyLabel = copied + ? t("copied") + : error + ? t("copy_failed") + : t("copy_code"); return ( <> @@ -136,10 +135,13 @@ export function AiChatCodeBlock({ children, language }: AiChatCodeBlockProps) { + + + copy(conversationText)}> + {copyLabel} + + {onClearChat && ( + + {t("new_conversation")} + + )} + + + + + + {t("new_conversation_confirm_title")} + + + {t("new_conversation_confirm_description")} + + + + {t("cancel")} + onClearChat?.()}> + {t("new_conversation")} + + + + + )} + + ); +} diff --git a/apps/apollo-vertex/registry/ai-chat/components/ai-chat-loading.tsx b/apps/apollo-vertex/registry/ai-chat/components/ai-chat-loading.tsx index bdb01cb18..2cc83693c 100644 --- a/apps/apollo-vertex/registry/ai-chat/components/ai-chat-loading.tsx +++ b/apps/apollo-vertex/registry/ai-chat/components/ai-chat-loading.tsx @@ -3,10 +3,13 @@ import { motion, useReducedMotion } from "framer-motion"; import type { CSSProperties } from "react"; import { useTranslation } from "react-i18next"; +import { + ENTRANCE_ANIMATE, + ENTRANCE_EASE, + ENTRANCE_INITIAL, +} from "../animations"; import { AiChatThinking } from "./ai-chat-thinking"; -// Quartic ease-out — same curve used inside AiChatThinking for consistency -const ENTRANCE_EASE = [0.22, 1, 0.36, 1] as const; const ENTRANCE_DURATION = 0.5; // Text appears after the icon's morph completes (FORWARD_DURATION in AiChatThinking is 0.8s) plus a small gap const TEXT_DELAY = 0.9; @@ -49,8 +52,8 @@ export function AiChatLoading() { role="status" aria-label={thinkingLabel} className="flex justify-start py-2" - initial={{ opacity: 0, y: 8 }} - animate={{ opacity: 1, y: 0 }} + initial={ENTRANCE_INITIAL} + animate={ENTRANCE_ANIMATE} transition={{ duration: ENTRANCE_DURATION, ease: ENTRANCE_EASE }} > {!prefersReducedMotion && ( diff --git a/apps/apollo-vertex/registry/ai-chat/components/ai-chat-message-actions.tsx b/apps/apollo-vertex/registry/ai-chat/components/ai-chat-message-actions.tsx new file mode 100644 index 000000000..b5b1ccf5a --- /dev/null +++ b/apps/apollo-vertex/registry/ai-chat/components/ai-chat-message-actions.tsx @@ -0,0 +1,159 @@ +"use client"; + +import { useClipboard } from "@mantine/hooks"; +import * as TooltipPrimitive from "@radix-ui/react-tooltip"; +import { + Check, + Copy, + Pencil, + RefreshCw, + ThumbsDown, + ThumbsUp, +} from "lucide-react"; +import type { ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import { Button } from "@/components/ui/button"; +import { + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; +import type { MessageFeedbackType } from "../types"; + +interface IconActionButtonProps { + icon: ReactNode; + label: string; + onClick: () => void; + pressed?: boolean; +} + +function IconActionButton({ + icon, + label, + onClick, + pressed, +}: IconActionButtonProps) { + return ( + + + + + {label} + + ); +} + +interface CopyToClipboardButtonProps { + value: string; +} + +function CopyToClipboardButton({ value }: CopyToClipboardButtonProps) { + const { t } = useTranslation(); + const { copied, error, copy } = useClipboard({ timeout: 2000 }); + + const label = copied ? t("copied") : error ? t("copy_failed") : t("copy"); + + return ( +