Skip to Content
โฑ๏ธ Realtime UI (DRAFTS) ๐ŸšงText AI Chat Integration with AI SDK and AI Elements

Text Chat AI Interface

In the previous articles, we set up the back-end and front-end to automatically synchronize state of the components with the back-end data, independent of the data fetching method. Now itโ€™s time to make the UI to be AI-powered, allowing users to interact with the application using natural language.

The approach is simple: weโ€™re going to set up normal LLM text chat via AI SDKย , and adjust it by adding function calling capabilities, where already implemented controller methods are going to be converted into tools via deriveTools function from Vovk.ts.

Back-end Setup

For the back-end setup, we need to create a handler powered by AI SDK as described in the LLM Completions article by adding tools and stopWhen options to the streamText function. The tools are created by passing the controller modules that follow the rules of callable handlers, meaning that the handlers use only vovk property of the request: async ({ vovk }) => UserService.createUser(await vovk.body()) (see API Endpoints page) to be able to be called directly (for SSR/PPR), and to be used by deriveTools function for current context executions.

src/modules/ai/AiSdkController.ts
import { deriveTools, post, prefix, operation, type VovkRequest, } from "vovk"; import { convertToModelMessages, jsonSchema, stepCountIs, streamText, tool, type JSONSchema7, type UIMessage, } from "ai"; import { openai } from "@ai-sdk/openai"; import UserController from "../user/UserController"; import TaskController from "../task/TaskController"; import { sessionGuard } from "@/decorators/sessionGuard"; @prefix("ai-sdk") export default class AiSdkController { @operation({ summary: "Function Calling", description: "Uses [@ai-sdk/openai](https://www.npmjs.com/package/@ai-sdk/openai) and ai packages to call UserController and TaskController functions based on the provided messages.", }) @post("function-calling") @sessionGuard() static async functionCalling(req: VovkRequest<{ messages: UIMessage[] }>) { const { messages } = await req.json(); const { tools } = deriveTools({ modules: { UserController, TaskController, }, }); return streamText({ model: openai("gpt-5"), system: "You execute functions sequentially, one by one.", messages: convertToModelMessages(messages), tools: Object.fromEntries( tools.map(({ name, execute, description, parameters }) => [ name, tool({ execute, description, inputSchema: jsonSchema(parameters), }), ]), ), stopWhen: stepCountIs(16), onError: (e) => console.error("streamText error", e), onFinish: ({ finishReason, toolCalls }) => { if (finishReason === "tool-calls") { console.log("Tool calls finished", toolCalls); } }, }).toUIMessageStreamResponse(); } }

The code above is fetched from GitHub repository.ย 

The resulting endpoint is served at /api/ai-sdk/function-calling.

Front-end Setup

On the front-end weโ€™re going to use AI SDK, represented by aiย , @ai-sdk/reactย  packages but also AI Elementsย  library that provides pre-built React components for building AI-powered user interfaces, built on top of shadcn/uiย .

Weโ€™re going to extend the client-side part described at LLM Completions with two things:

  1. Using AI Elements instead of raw divs in order to not only have better UI but also to see the flow of the executed functions and their results.
  2. Parse the function calling results by using .parse() method described at the State Normalization page.
src/components/ExpandableChatDemo.tsx
'use client'; // ... import { useChat } from '@ai-sdk/react'; import { useState } from 'react'; import { useRegistry } from '@/registry'; import { DefaultChatTransport } from 'ai'; import { Conversation, ConversationContent, ConversationEmptyState } from '@/components/ai-elements/conversation'; import { AiSdkRPC } from 'vovk-client'; import useParseSDKToolCallOutputs from '@/hooks/useParseSDKToolCallOutputs'; export function ExpandableChatDemo() { const [input, setInput] = useState(''); const { messages, sendMessage, status } = useChat({ transport: new DefaultChatTransport({ api: AiSdkRPC.functionCalling.getURL(), // or "/api/ai-sdk/function-calling", }), onToolCall: (toolCall) => { console.log('Tool call initiated:', toolCall); }, }); const handleSubmit = (e: React.FormEvent) => { // ... }; useParseSDKToolCallOutputs(messages); return ( // ... <Conversation> <ConversationContent>{/* ... */}</ConversationContent> </Conversation> // ... ); }

Check the full code for the component hereย 

The key part of the code is the useParseSDKToolCallOutputs hook that extracts the tool call outputs from the assistant messages and passes them to the registryโ€™s parse method, which processes the results and triggers UI updates accordingly. It also ensures that each tool call output is parsed only once by keeping track of the parsed tool call IDs using a Set.

src/hooks/useParseSDKToolCallOutputs.ts
import { useRegistry } from "@/registry"; import { ToolUIPart, UIMessage } from "ai"; import { useEffect, useRef } from "react"; export default function useParseSDKToolCallOutputs(messages: UIMessage[]) { const parsedToolCallIdsSetRef = useRef<Set<string>>(new Set()); useEffect(() => { const partsToParse = messages.flatMap((msg) => msg.parts.filter((part) => { return ( msg.role === "assistant" && part.type.startsWith("tool-") && (part as ToolUIPart).state === "output-available" && "toolCallId" in part && !parsedToolCallIdsSetRef.current.has(part.toolCallId) ); }), ) as ToolUIPart[]; partsToParse.forEach((part) => parsedToolCallIdsSetRef.current.add(part.toolCallId), ); if (partsToParse.length) { useRegistry.getState().parse(partsToParse.map((part) => part.output)); } }, [messages]); }

The code above is fetched from GitHub repository.ย 

Without optimizations, the code can be reduced to this small snippet:

// ... useEffect(() => { useRegistry.getState().parse(messages); }, [messages]); // ...

Thatโ€™s it, now you have a fully functional AI text chat interface that can call your back-end functions and update the UI in based on the results, as the controller methods return the updated data that includes id and entityType fields, but also __isDeleted field for soft deletions.

Last updated on