mirror of
https://github.com/run-llama/chat-ui.git
synced 2026-06-30 21:27:55 -04:00
feat: support Vercel AI SDK 5 (#189)
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
---
|
||||
'@llamaindex/chat-ui': minor
|
||||
'@llamaindex/server': minor
|
||||
'llamaindex-server-examples': patch
|
||||
'web': patch
|
||||
'@llamaindex/chat-ui-docs': patch
|
||||
---
|
||||
|
||||
support vercel ai sdk ver 5
|
||||
@@ -83,7 +83,7 @@ Components are designed to be composable. You can use them as is:
|
||||
|
||||
```tsx
|
||||
import { ChatSection } from '@llamaindex/chat-ui'
|
||||
import { useChat } from 'ai/react'
|
||||
import { useChat } from '@ai-sdk/react'
|
||||
|
||||
const ChatExample = () => {
|
||||
const handler = useChat()
|
||||
@@ -96,7 +96,7 @@ Or you can extend them with your own children components:
|
||||
```tsx
|
||||
import { ChatSection, ChatMessages, ChatInput } from '@llamaindex/chat-ui'
|
||||
import LlamaCloudSelector from './components/LlamaCloudSelector' // your custom component
|
||||
import { useChat } from 'ai/react'
|
||||
import { useChat } from '@ai-sdk/react'
|
||||
|
||||
const ChatExample = () => {
|
||||
const handler = useChat()
|
||||
@@ -158,7 +158,7 @@ Additionally, you can also override each component's styles by setting custom cl
|
||||
|
||||
```tsx
|
||||
import { ChatSection, ChatMessages, ChatInput } from '@llamaindex/chat-ui'
|
||||
import { useChat } from 'ai/react'
|
||||
import { useChat } from '@ai-sdk/react'
|
||||
|
||||
const ChatExample = () => {
|
||||
const handler = useChat()
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { Message, LlamaIndexAdapter, StreamData } from 'ai'
|
||||
import { fakeStreamText, TextChunk, writeStream } from '@/app/utils'
|
||||
import { UIMessage as Message } from '@ai-sdk/react'
|
||||
import {
|
||||
ChatMessage,
|
||||
MessageContentDetail,
|
||||
OpenAI,
|
||||
OpenAIEmbedding,
|
||||
Settings,
|
||||
SimpleChatEngine,
|
||||
} from 'llamaindex'
|
||||
import { NextResponse, type NextRequest } from 'next/server'
|
||||
import { fakeStreamText } from '@/app/utils'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
export const dynamic = 'force-dynamic'
|
||||
@@ -24,13 +24,11 @@ export async function POST(request: NextRequest) {
|
||||
const messages = body.messages
|
||||
const lastMessage = messages[messages.length - 1]
|
||||
|
||||
const vercelStreamData = new StreamData()
|
||||
|
||||
if (!process.env.OPENAI_API_KEY) {
|
||||
// Return fake stream if API key is not set
|
||||
return new Response(fakeStreamText(), {
|
||||
headers: {
|
||||
'Content-Type': 'text/plain',
|
||||
'Content-Type': 'text/event-stream',
|
||||
Connection: 'keep-alive',
|
||||
},
|
||||
})
|
||||
@@ -38,18 +36,49 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
const chatEngine = new SimpleChatEngine()
|
||||
|
||||
const messageContent = (lastMessage.parts[0] as { text: string }).text
|
||||
|
||||
const response = await chatEngine.chat({
|
||||
message: lastMessage.content,
|
||||
chatHistory: messages as ChatMessage[],
|
||||
message: messageContent,
|
||||
chatHistory: messages.map(message => ({
|
||||
role: message.role,
|
||||
content: message.parts as MessageContentDetail[],
|
||||
})),
|
||||
stream: true,
|
||||
})
|
||||
|
||||
return LlamaIndexAdapter.toDataStreamResponse(response, {
|
||||
data: vercelStreamData,
|
||||
callbacks: {
|
||||
onCompletion: async () => {
|
||||
await vercelStreamData.close()
|
||||
},
|
||||
const sseStream = new ReadableStream({
|
||||
async start(controller) {
|
||||
// Generate a unique message id
|
||||
const messageId = crypto.randomUUID()
|
||||
|
||||
// Start the text chunk
|
||||
const startChunk: TextChunk = { id: messageId, type: 'text-start' }
|
||||
writeStream(controller, startChunk)
|
||||
|
||||
// Consume the response and write the chunks to the controller
|
||||
for await (const chunk of response) {
|
||||
writeStream(controller, {
|
||||
id: messageId,
|
||||
type: 'text-delta',
|
||||
delta: chunk.delta,
|
||||
})
|
||||
}
|
||||
|
||||
// End the text chunk
|
||||
const endChunk: TextChunk = { id: messageId, type: 'text-end' }
|
||||
writeStream(controller, endChunk)
|
||||
|
||||
controller.close()
|
||||
},
|
||||
})
|
||||
|
||||
return new Response(sseStream, {
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
headers: {
|
||||
'content-type': 'text/event-stream',
|
||||
connection: 'keep-alive',
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
|
||||
@@ -9,19 +9,35 @@ import {
|
||||
useChatCanvas,
|
||||
} from '@llamaindex/chat-ui'
|
||||
import { DynamicComponent } from '@llamaindex/dynamic-ui'
|
||||
import { Message, useChat } from 'ai/react'
|
||||
import { UIMessage as Message, useChat } from '@ai-sdk/react'
|
||||
|
||||
const initialMessages: Message[] = [
|
||||
{
|
||||
id: 'code-gen1',
|
||||
role: 'user',
|
||||
content: 'Generate a simple calculator',
|
||||
parts: [{ type: 'text', text: 'Generate a simple calculator' }],
|
||||
},
|
||||
{
|
||||
id: 'code-gen2',
|
||||
role: 'assistant',
|
||||
content:
|
||||
'\n```annotation\n{"type":"artifact","data":{"type":"code","created_at":1752124365106,"data":{"language":"typescript","file_name":"calculator.tsx","code":"import React, { useState } from \\"react\\"\\nimport { Button } from \\"@/components/ui/button\\"\\nimport { Card } from \\"@/components/ui/card\\"\\nimport { cn } from \\"@/lib/utils\\"\\n\\nconst buttons = [\\n [\\"7\\", \\"8\\", \\"9\\", \\"/\\"],\\n [\\"4\\", \\"5\\", \\"6\\", \\"*\\"],\\n [\\"1\\", \\"2\\", \\"3\\", \\"-\\"],\\n [\\"0\\", \\"C\\", \\"=\\", \\"+\\"],\\n]\\n\\nexport default function Calculator() {\\n const [input, setInput] = useState<string>(\\"\\")\\n const [result, setResult] = useState<string | null>(null)\\n\\n const handleButtonClick = (value: string) => {\\n if (value === \\"C\\") {\\n setInput(\\"\\")\\n setResult(null)\\n return\\n }\\n if (value === \\"=\\") {\\n try {\\n // eslint-disable-next-line no-eval\\n const evalResult = eval(input)\\n setResult(evalResult.toString())\\n } catch {\\n setResult(\\"Error\\")\\n }\\n return\\n }\\n if (result !== null) {\\n setInput(value.match(/[0-9.]/) ? value : result + value)\\n setResult(null)\\n } else {\\n setInput((prev) => prev + value)\\n }\\n }\\n\\n return (\\n <div className=\\"flex items-center justify-center min-h-screen bg-muted\\">\\n <Card className=\\"w-[320px] p-6 shadow-lg\\">\\n <div className={cn(\\"mb-4 h-16 bg-background rounded flex items-end justify-end px-4 text-2xl font-mono border\\", result && \\"text-muted-foreground\\")}>\\n {result !== null ? result : input || \\"0\\"}\\n </div>\\n <div className=\\"grid grid-cols-4 gap-3\\">\\n {buttons.flat().map((btn, idx) => (\\n <Button\\n key={idx}\\n variant={btn === \\"C\\" ? \\"destructive\\" : btn === \\"=\\" ? \\"default\\" : \\"outline\\"}\\n className={cn(\\n \\"h-12 text-xl\\",\\n btn === \\"=\\" && \\"col-span-1 bg-primary text-primary-foreground\\",\\n btn === \\"C\\" && \\"col-span-1\\"\\n )}\\n onClick={() => handleButtonClick(btn)}\\n >\\n {btn}\\n </Button>\\n ))}\\n </div>\\n </Card>\\n </div>\\n )\\n}"}}}\n```\nHere\'s how the simple calculator works:\n\n- The calculator displays the current input or the result at the top.\n- You can click the number buttons (0-9) and the operators (+, -, *, /) to build your calculation.\n- Pressing the = button evaluates the expression and shows the result.\n- Pressing the C button clears the input and resets the calculator.\n- If you get an error (like dividing by zero or entering an invalid expression), "Error" will be displayed.\n\nYou can further customize the calculator\'s appearance or add more features as needed! If you have any questions about how the code works or want to add more functionality, let me know!',
|
||||
parts: [
|
||||
{
|
||||
type: 'text',
|
||||
text: "Here's the simple calculator:",
|
||||
},
|
||||
{
|
||||
type: 'data-artifact',
|
||||
data: {
|
||||
type: 'code',
|
||||
created_at: 1752124365106,
|
||||
data: {
|
||||
language: 'typescript',
|
||||
file_name: 'calculator.tsx',
|
||||
code: 'import React, { useState } from "react"\nimport { Button } from "@/components/ui/button"\nimport { Card } from "@/components/ui/card"\nimport { cn } from "@/lib/utils"\n\nconst buttons = [\n ["7", "8", "9", "/"],\n ["4", "5", "6", "*"],\n ["1", "2", "3", "-"],\n ["0", "C", "=", "+"],\n]\n\nexport default function Calculator() {\n const [input, setInput] = useState<string>("")\n const [result, setResult] = useState<string | null>(null)\n\n const handleButtonClick = (value: string) => {\n if (value === "C") {\n setInput("")\n setResult(null)\n return\n }\n if (value === "=") {\n try {\n // eslint-disable-next-line no-eval\n const evalResult = eval(input)\n setResult(evalResult.toString())\n } catch {\n setResult("Error")\n }\n return\n }\n if (result !== null) {\n setInput(value.match(/[0-9.]/) ? value : result + value)\n setResult(null)\n } else {\n setInput((prev) => prev + value)\n }\n }\n\n return (\n <div className="flex items-center justify-center min-h-screen bg-muted">\n <Card className="w-[320px] p-6 shadow-lg">\n <div className={cn("mb-4 h-16 bg-background rounded flex items-end justify-end px-4 text-2xl font-mono border", result && "text-muted-foreground")}>\n {result !== null ? result : input || "0"}\n </div>\n <div className="grid grid-cols-4 gap-3">\n {buttons.flat().map((btn, idx) => (\n <Button\n key={idx}\n variant={btn === "C" ? "destructive" : btn === "=" ? "default" : "outline"}\n className={cn(\n "h-12 text-xl",\n btn === "=" && "col-span-1 bg-primary text-primary-foreground",\n btn === "C" && "col-span-1"\n )}\n onClick={() => handleButtonClick(btn)}\n >\n {btn}\n </Button>\n ))}\n </div>\n </Card>\n </div>\n )\n}',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -30,7 +46,7 @@ export default function Page(): JSX.Element {
|
||||
}
|
||||
|
||||
function CustomChat() {
|
||||
const handler = useChat({ initialMessages })
|
||||
const handler = useChat({ messages: initialMessages })
|
||||
|
||||
return (
|
||||
<ChatSection
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
useChatCanvas,
|
||||
useChatUI,
|
||||
} from '@llamaindex/chat-ui'
|
||||
import { Message, useChat } from 'ai/react'
|
||||
import { UIMessage as Message, useChat } from '@ai-sdk/react'
|
||||
import { Image } from 'lucide-react'
|
||||
|
||||
const code = `
|
||||
@@ -25,11 +25,11 @@ import {
|
||||
useChatCanvas,
|
||||
useChatUI,
|
||||
} from '@llamaindex/chat-ui'
|
||||
import { useChat } from 'ai/react'
|
||||
import { useChat } from '@ai-sdk/react'
|
||||
import { Image } from 'lucide-react'
|
||||
|
||||
export function CustomChat() {
|
||||
const handler = useChat({ initialMessages: [] })
|
||||
const handler = useChat()
|
||||
|
||||
return (
|
||||
<ChatSection
|
||||
@@ -103,11 +103,8 @@ function CustomChatMessages() {
|
||||
>
|
||||
<ChatMessage.Avatar />
|
||||
<ChatMessage.Content>
|
||||
<ChatMessage.Content.Markdown
|
||||
annotationRenderers={{
|
||||
artifact: CustomArtifactCard,
|
||||
}}
|
||||
/>
|
||||
<ChatMessage.Part.Markdown />
|
||||
<ChatMessage.Part.Artifact />
|
||||
</ChatMessage.Content>
|
||||
<ChatMessage.Actions />
|
||||
</ChatMessage>
|
||||
@@ -116,32 +113,24 @@ function CustomChatMessages() {
|
||||
</ChatMessages>
|
||||
)
|
||||
}
|
||||
|
||||
// custom artifact card for image artifacts
|
||||
function CustomArtifactCard({ data }: { data: Artifact }) {
|
||||
return (
|
||||
<ChatCanvas.Artifact
|
||||
data={data}
|
||||
getTitle={artifact => (artifact as ImageArtifact).data.caption}
|
||||
iconMap={{ image: Image }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
`
|
||||
|
||||
const initialMessages: Message[] = [
|
||||
{
|
||||
id: '1',
|
||||
role: 'user',
|
||||
content: 'Generate an image of a cat',
|
||||
parts: [{ type: 'text', text: 'Generate an image of a cat' }],
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
role: 'assistant',
|
||||
content:
|
||||
'Here is a cat image named Millie.' +
|
||||
`\n\`\`\`annotation\n${JSON.stringify({
|
||||
type: 'artifact',
|
||||
parts: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Here is a cat image named Millie.',
|
||||
},
|
||||
{
|
||||
type: 'data-artifact',
|
||||
data: {
|
||||
type: 'image',
|
||||
data: {
|
||||
@@ -150,21 +139,24 @@ const initialMessages: Message[] = [
|
||||
},
|
||||
created_at: 1745480281756,
|
||||
},
|
||||
})}
|
||||
\n\`\`\`\n`,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
role: 'user',
|
||||
content: 'Please generate a black cat image',
|
||||
parts: [{ type: 'text', text: 'Please generate a black cat image' }],
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
role: 'assistant',
|
||||
content:
|
||||
'Here is a black cat image named Poppy.' +
|
||||
`\n\`\`\`annotation\n${JSON.stringify({
|
||||
type: 'artifact',
|
||||
parts: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Here is a black cat image named Poppy.',
|
||||
},
|
||||
{
|
||||
type: 'data-artifact',
|
||||
data: {
|
||||
type: 'image',
|
||||
data: {
|
||||
@@ -173,8 +165,8 @@ const initialMessages: Message[] = [
|
||||
},
|
||||
created_at: 1745480281999,
|
||||
},
|
||||
})}
|
||||
\n\`\`\`\n`,
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -184,7 +176,7 @@ export default function Page(): JSX.Element {
|
||||
|
||||
function CustomChat() {
|
||||
const { copyToClipboard, isCopied } = useCopyToClipboard({ timeout: 2000 })
|
||||
const handler = useChat({ initialMessages })
|
||||
const handler = useChat({ messages: initialMessages })
|
||||
|
||||
return (
|
||||
<ChatSection
|
||||
@@ -279,11 +271,8 @@ function CustomChatMessages() {
|
||||
>
|
||||
<ChatMessage.Avatar />
|
||||
<ChatMessage.Content>
|
||||
<ChatMessage.Content.Markdown
|
||||
annotationRenderers={{
|
||||
artifact: CustomArtifactCard,
|
||||
}}
|
||||
/>
|
||||
<ChatMessage.Part.Markdown />
|
||||
<ChatMessage.Part.Artifact />
|
||||
</ChatMessage.Content>
|
||||
<ChatMessage.Actions />
|
||||
</ChatMessage>
|
||||
@@ -292,14 +281,3 @@ function CustomChatMessages() {
|
||||
</ChatMessages>
|
||||
)
|
||||
}
|
||||
|
||||
// custom artifact card for image artifacts
|
||||
function CustomArtifactCard({ data }: { data: Artifact }) {
|
||||
return (
|
||||
<ChatCanvas.Artifact
|
||||
data={data}
|
||||
getTitle={artifact => (artifact as ImageArtifact).data.caption}
|
||||
iconMap={{ image: Image }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -9,7 +9,7 @@ import {
|
||||
useChatUI,
|
||||
useFile,
|
||||
} from '@llamaindex/chat-ui'
|
||||
import { Message, useChat } from 'ai/react'
|
||||
import { UIMessage as Message, useChat } from '@ai-sdk/react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
|
||||
const code = `
|
||||
@@ -21,15 +21,14 @@ import {
|
||||
useChatUI,
|
||||
useFile,
|
||||
} from '@llamaindex/chat-ui'
|
||||
import { useChat } from 'ai/react'
|
||||
import { useChat } from '@ai-sdk/react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
|
||||
export function CustomChat() {
|
||||
const handler = useChat()
|
||||
const { imageUrl, getAnnotations, uploadFile, reset } = useFile({
|
||||
const { image, uploadFile, reset, getAttachments } = useFile({
|
||||
uploadAPI: '/chat/upload',
|
||||
})
|
||||
const annotations = getAnnotations()
|
||||
const handleUpload = async (file: File) => {
|
||||
try {
|
||||
await uploadFile(file)
|
||||
@@ -37,18 +36,22 @@ export function CustomChat() {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
const attachments = getAttachments()
|
||||
return (
|
||||
<ChatSection
|
||||
handler={handler}
|
||||
className="mx-auto h-screen max-w-3xl overflow-hidden"
|
||||
className="h-screen overflow-hidden p-0 md:p-5"
|
||||
>
|
||||
<CustomChatMessages />
|
||||
<ChatInput annotations={annotations} resetUploadedFiles={reset}>
|
||||
<ChatInput
|
||||
attachments={attachments}
|
||||
resetUploadedFiles={reset}
|
||||
>
|
||||
<div>
|
||||
{imageUrl ? (
|
||||
{image ? (
|
||||
<img
|
||||
className="max-h-[100px] object-contain"
|
||||
src={imageUrl}
|
||||
src={image.url}
|
||||
alt="uploaded"
|
||||
/>
|
||||
) : null}
|
||||
@@ -56,6 +59,7 @@ export function CustomChat() {
|
||||
<ChatInput.Form>
|
||||
<ChatInput.Field />
|
||||
<ChatInput.Upload
|
||||
allowedExtensions={['jpg', 'png', 'jpeg']}
|
||||
onUpload={handleUpload}
|
||||
/>
|
||||
<ChatInput.Submit />
|
||||
@@ -66,7 +70,7 @@ export function CustomChat() {
|
||||
}
|
||||
|
||||
function CustomChatMessages() {
|
||||
const { messages, isLoading, append } = useChatUI()
|
||||
const { messages } = useChatUI()
|
||||
return (
|
||||
<ChatMessages>
|
||||
<ChatMessages.List className="px-0 md:px-16">
|
||||
@@ -91,10 +95,9 @@ function CustomChatMessages() {
|
||||
src="/llama.png"
|
||||
/>
|
||||
</ChatMessage.Avatar>
|
||||
<ChatMessage.Content isLoading={isLoading} append={append}>
|
||||
<ChatMessage.Content.Image />
|
||||
<ChatMessage.Content.Markdown />
|
||||
<ChatMessage.Content.DocumentFile />
|
||||
<ChatMessage.Content>
|
||||
<ChatMessage.Part.File />
|
||||
<ChatMessage.Part.Markdown />
|
||||
</ChatMessage.Content>
|
||||
<ChatMessage.Actions />
|
||||
</ChatMessage>
|
||||
@@ -110,18 +113,22 @@ function CustomChatMessages() {
|
||||
const initialMessages: Message[] = [
|
||||
{
|
||||
id: '1',
|
||||
content: 'Generate a logo for LlamaIndex',
|
||||
parts: [{ type: 'text', text: 'Generate a logo for LlamaIndex' }],
|
||||
role: 'user',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
role: 'assistant',
|
||||
content:
|
||||
'Got it! Here is the logo for LlamaIndex. The logo features a friendly llama mascot that represents our AI-powered document indexing and chat capabilities.',
|
||||
annotations: [
|
||||
parts: [
|
||||
{
|
||||
type: 'image',
|
||||
type: 'text',
|
||||
text: 'Got it! Here is the logo for LlamaIndex. The logo features a friendly llama mascot that represents our AI-powered document indexing and chat capabilities.',
|
||||
},
|
||||
{
|
||||
type: 'data-file',
|
||||
data: {
|
||||
filename: 'llama.png',
|
||||
mediaType: 'image/png',
|
||||
url: '/llama.png',
|
||||
},
|
||||
},
|
||||
@@ -130,24 +137,22 @@ const initialMessages: Message[] = [
|
||||
{
|
||||
id: '3',
|
||||
role: 'user',
|
||||
content: 'Show me a pdf file',
|
||||
parts: [{ type: 'text', text: 'Show me a pdf file' }],
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
role: 'assistant',
|
||||
content:
|
||||
'Got it! Here is a sample PDF file that demonstrates PDF handling capabilities. This PDF contains some basic text and formatting examples that you can use to test PDF viewing functionality.',
|
||||
annotations: [
|
||||
parts: [
|
||||
{
|
||||
type: 'document_file',
|
||||
type: 'text',
|
||||
text: 'Got it! Here is a sample PDF file that demonstrates PDF handling capabilities. This PDF contains some basic text and formatting examples that you can use to test PDF viewing functionality.',
|
||||
},
|
||||
{
|
||||
type: 'data-file',
|
||||
data: {
|
||||
files: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'sample.pdf',
|
||||
url: 'https://pdfobject.com/pdf/sample.pdf',
|
||||
},
|
||||
],
|
||||
filename: 'sample.pdf',
|
||||
mediaType: 'application/pdf',
|
||||
url: 'https://pdfobject.com/pdf/sample.pdf',
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -172,11 +177,10 @@ export default function Page(): JSX.Element {
|
||||
}
|
||||
|
||||
function CustomChat() {
|
||||
const handler = useChat({ initialMessages })
|
||||
const { imageUrl, getAnnotations, uploadFile, reset } = useFile({
|
||||
const handler = useChat({ messages: initialMessages })
|
||||
const { image, uploadFile, reset, getAttachments } = useFile({
|
||||
uploadAPI: '/chat/upload',
|
||||
})
|
||||
const annotations = getAnnotations()
|
||||
const handleUpload = async (file: File) => {
|
||||
try {
|
||||
await uploadFile(file)
|
||||
@@ -184,18 +188,19 @@ function CustomChat() {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
const attachments = getAttachments()
|
||||
return (
|
||||
<ChatSection
|
||||
handler={handler}
|
||||
className="h-screen overflow-hidden p-0 md:p-5"
|
||||
>
|
||||
<CustomChatMessages />
|
||||
<ChatInput annotations={annotations} resetUploadedFiles={reset}>
|
||||
<ChatInput attachments={attachments} resetUploadedFiles={reset}>
|
||||
<div>
|
||||
{imageUrl ? (
|
||||
{image ? (
|
||||
<img
|
||||
className="max-h-[100px] object-contain"
|
||||
src={imageUrl}
|
||||
src={image.url}
|
||||
alt="uploaded"
|
||||
/>
|
||||
) : null}
|
||||
@@ -214,7 +219,7 @@ function CustomChat() {
|
||||
}
|
||||
|
||||
function CustomChatMessages() {
|
||||
const { messages, isLoading, append } = useChatUI()
|
||||
const { messages } = useChatUI()
|
||||
return (
|
||||
<ChatMessages>
|
||||
<ChatMessages.List className="px-0 md:px-16">
|
||||
@@ -239,10 +244,9 @@ function CustomChatMessages() {
|
||||
src="/llama.png"
|
||||
/>
|
||||
</ChatMessage.Avatar>
|
||||
<ChatMessage.Content isLoading={isLoading} append={append}>
|
||||
<ChatMessage.Content.Image />
|
||||
<ChatMessage.Content.Markdown />
|
||||
<ChatMessage.Content.DocumentFile />
|
||||
<ChatMessage.Content>
|
||||
<ChatMessage.Part.File />
|
||||
<ChatMessage.Part.Markdown />
|
||||
</ChatMessage.Content>
|
||||
<ChatMessage.Actions />
|
||||
</ChatMessage>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
'use client'
|
||||
|
||||
import { Message, useChat } from 'ai/react'
|
||||
import { UIMessage as Message, useChat } from '@ai-sdk/react'
|
||||
import { ChatSection } from '@llamaindex/chat-ui'
|
||||
import { Code } from '@/components/code'
|
||||
|
||||
const code = `
|
||||
import { ChatSection } from '@llamaindex/chat-ui'
|
||||
import '@llamaindex/chat-ui/styles/markdown.css'
|
||||
import { useChat } from 'ai/react'
|
||||
import { useChat } from '@ai-sdk/react'
|
||||
|
||||
function DemoLatexChat() {
|
||||
const handler = useChat()
|
||||
@@ -18,65 +18,109 @@ function DemoLatexChat() {
|
||||
const initialMessages: Message[] = [
|
||||
{
|
||||
role: 'user',
|
||||
content: 'The product costs $10 and the discount is $5',
|
||||
parts: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'The product costs $10 and the discount is $5',
|
||||
},
|
||||
],
|
||||
id: 'DQXPGjYiCEK1MlXg',
|
||||
},
|
||||
{
|
||||
id: '0wR35AGp8GEDoHZu',
|
||||
role: 'assistant',
|
||||
content:
|
||||
'If the product costs $10 and there is a discount of $5, you can calculate the final price by subtracting the discount from the original price:\n\nFinal Price = Original Price - Discount \nFinal Price = $10 - $5 \nFinal Price = $5\n\nSo, after applying the discount, the product will cost $5.',
|
||||
parts: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'If the product costs $10 and there is a discount of $5, you can calculate the final price by subtracting the discount from the original price:\n\nFinal Price = Original Price - Discount \nFinal Price = $10 - $5 \nFinal Price = $5\n\nSo, after applying the discount, the product will cost $5.',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
role: 'user',
|
||||
content:
|
||||
'Write js code that accept a location and console log Hello from location',
|
||||
parts: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Write js code that accept a location and console log Hello from location',
|
||||
},
|
||||
],
|
||||
id: '2VH8xx07DxwibdFX',
|
||||
},
|
||||
{
|
||||
id: 'Jb1Xs8w8p2RBTdUQ',
|
||||
role: 'assistant',
|
||||
content:
|
||||
'You can create a simple JavaScript function that accepts a location as an argument and logs a message to the console. Here\'s an example of how you can do this:\n\n```javascript\nfunction greetFromLocation(location) {\n console.log(`Hello from ${location}`);\n}\n\n// Example usage:\ngreetFromLocation("New York");\ngreetFromLocation("Tokyo");\ngreetFromLocation("Paris");\n```\n\nIn this code:\n\n- The `greetFromLocation` function takes one parameter, `location`.\n- It uses template literals (the backticks ``) to create a string that includes the location.\n- The `console.log` function is used to print the message to the console.\n\nYou can call the function with different locations to see the output.',
|
||||
parts: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'You can create a simple JavaScript function that accepts a location as an argument and logs a message to the console. Here\'s an example of how you can do this:\n\n```javascript\nfunction greetFromLocation(location) {\n console.log(`Hello from ${location}`);\n}\n\n// Example usage:\ngreetFromLocation("New York");\ngreetFromLocation("Tokyo");\ngreetFromLocation("Paris");\n```\n\nIn this code:\n\n- The `greetFromLocation` function takes one parameter, `location`.\n- It uses template literals (the backticks ``) to create a string that includes the location.\n- The `console.log` function is used to print the message to the console.\n\nYou can call the function with different locations to see the output.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: 'Formula to caculate triangle',
|
||||
parts: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Formula to caculate triangle',
|
||||
},
|
||||
],
|
||||
id: 'G7MEUgkjwqq0RDLk',
|
||||
},
|
||||
{
|
||||
id: 'aonMZaAcoUglAjka',
|
||||
role: 'assistant',
|
||||
content:
|
||||
"To calculate various properties of a triangle, you can use different formulas depending on what you want to find. Here are some common calculations:\n\n1. **Area of a Triangle**:\n - Using base and height: \n \\[\n \\text{Area} = \\frac{1}{2} \\times \\text{base} \\times \\text{height}\n \\]\n - Using Heron's formula (when you know all three sides \\(a\\), \\(b\\), and \\(c\\)):\n \\[\n s = \\frac{a + b + c}{2} \\quad \\text{(semi-perimeter)}\n \\]\n \\[\n \\text{Area} = \\sqrt{s(s-a)(s-b)(s-c)}\n \\]\n\n2. **Perimeter of a Triangle**:\n - If you know the lengths of all three sides \\(a\\), \\(b\\), and \\(c\\):\n \\[\n \\text{Perimeter} = a + b + c\n \\]\n\n3. **Pythagorean Theorem** (for right triangles):\n - If \\(c\\) is the length of the hypotenuse and \\(a\\) and \\(b\\) are the lengths of the other two sides:\n \\[\n c^2 = a^2 + b^2\n \\]\n\n4. **Angles**:\n - To find angles using the sides (Law of Cosines):\n \\[\n c^2 = a^2 + b^2 - 2ab \\cdot \\cos(C)\n \\]\n - Rearranging gives:\n \\[\n \\cos(C) = \\frac{a^2 + b^2 - c^2}{2ab}\n \\]\n\nThese formulas can help you calculate the area, perimeter, and angles of a triangle based on the information you have.",
|
||||
parts: [
|
||||
{
|
||||
type: 'text',
|
||||
text: "To calculate various properties of a triangle, you can use different formulas depending on what you want to find. Here are some common calculations:\n\n1. **Area of a Triangle**:\n - Using base and height: \n \\[\n \\text{Area} = \\frac{1}{2} \\times \\text{base} \\times \\text{height}\n \\]\n - Using Heron's formula (when you know all three sides \\(a\\), \\(b\\), and \\(c\\)):\n \\[\n s = \\frac{a + b + c}{2} \\quad \\text{(semi-perimeter)}\n \\]\n \\[\n \\text{Area} = \\sqrt{s(s-a)(s-b)(s-c)}\n \\]\n\n2. **Perimeter of a Triangle**:\n - If you know the lengths of all three sides \\(a\\), \\(b\\), and \\(c\\):\n \\[\n \\text{Perimeter} = a + b + c\n \\]\n\n3. **Pythagorean Theorem** (for right triangles):\n - If \\(c\\) is the length of the hypotenuse and \\(a\\) and \\(b\\) are the lengths of the other two sides:\n \\[\n c^2 = a^2 + b^2\n \\]\n\n4. **Angles**:\n - To find angles using the sides (Law of Cosines):\n \\[\n c^2 = a^2 + b^2 - 2ab \\cdot \\cos(C)\n \\]\n - Rearranging gives:\n \\[\n \\cos(C) = \\frac{a^2 + b^2 - c^2}{2ab}\n \\]\n\nThese formulas can help you calculate the area, perimeter, and angles of a triangle based on the information you have.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'aonMZaA22oU7lAj222',
|
||||
role: 'user',
|
||||
content: 'Implement calculate triangle area in js',
|
||||
parts: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Implement calculate triangle area in js',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'a2nMZaA22oU7lAj222',
|
||||
role: 'assistant',
|
||||
content:
|
||||
'To calculate the area of a triangle in JavaScript, you can use the formula:\n\n\\[\n\\text{Area} = \\frac{1}{2} \\times \\text{base} \\times \\text{height}\n\\]\n\nHere\'s a simple implementation in JavaScript:\n\n```javascript\nfunction calculateTriangleArea(base, height) {\n if (base <= 0 || height <= 0) {\n throw new Error("Base and height must be positive numbers.");\n }\n return 0.5 * base * height;\n}\n\n// Example usage:\nconst base = 5; // Example base length\nconst height = 10; // Example height length\n\ntry {\n const area = calculateTriangleArea(base, height);\n console.log(`The area of the triangle is: ${area}`);\n} catch (error) {\n console.error(error.message);\n}\n```\n\n### Explanation:\n1. **Function Definition**: The function `calculateTriangleArea` takes two parameters: `base` and `height`.\n2. **Input Validation**: It checks if the base and height are positive numbers. If not, it throws an error.\n3. **Area Calculation**: It calculates the area using the formula and returns the result.\n4. **Example Usage**: The example shows how to call the function and log the result to the console.\n\nYou can modify the `base` and `height` variables to test with different values.',
|
||||
parts: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'To calculate the area of a triangle in JavaScript, you can use the formula:\n\n\\[\n\\text{Area} = \\frac{1}{2} \\times \\text{base} \\times \\text{height}\n\\]\n\nHere\'s a simple implementation in JavaScript:\n\n```javascript\nfunction calculateTriangleArea(base, height) {\n if (base <= 0 || height <= 0) {\n throw new Error("Base and height must be positive numbers.");\n }\n return 0.5 * base * height;\n}\n\n// Example usage:\nconst base = 5; // Example base length\nconst height = 10; // Example height length\n\ntry {\n const area = calculateTriangleArea(base, height);\n console.log(`The area of the triangle is: ${area}`);\n} catch (error) {\n console.error(error.message);\n}\n```\n\n### Explanation:\n1. **Function Definition**: The function `calculateTriangleArea` takes two parameters: `base` and `height`.\n2. **Input Validation**: It checks if the base and height are positive numbers. If not, it throws an error.\n3. **Area Calculation**: It calculates the area using the formula and returns the result.\n4. **Example Usage**: The example shows how to call the function and log the result to the console.\n\nYou can modify the `base` and `height` variables to test with different values.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'aonMZaAcoU7lAjka',
|
||||
role: 'user',
|
||||
content: 'Popupar formulas in Math',
|
||||
parts: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Popupar formulas in Math',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'aonMZaA22oU7lAjka',
|
||||
role: 'assistant',
|
||||
content:
|
||||
"Here are some popular mathematical formulas across various branches of mathematics:\n\n### Algebra\n1. **Quadratic Formula**: \n \\[\n x = \\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a}\n \\]\n (Used to find the roots of a quadratic equation \\( ax^2 + bx + c = 0 \\))\n\n2. **Difference of Squares**: \n \\[\n a^2 - b^2 = (a - b)(a + b)\n \\]\n\n3. **Factoring a Perfect Square**: \n \\[\n a^2 + 2ab + b^2 = (a + b)^2\n \\]\n \\[\n a^2 - 2ab + b^2 = (a - b)^2\n \\]\n\n### Geometry\n1. **Area of a Circle**: \n \\[\n A = \\pi r^2\n \\]\n\n2. **Circumference of a Circle**: \n \\[\n C = 2\\pi r\n \\]\n\n3. **Pythagorean Theorem**: \n \\[\n a^2 + b^2 = c^2\n \\]\n (In a right triangle, where \\( c \\) is the hypotenuse)\n\n4. **Area of a Triangle**: \n \\[\n A = \\frac{1}{2} \\times \\text{base} \\times \\text{height}\n \\]\n\n### Trigonometry\n1. **Sine, Cosine, and Tangent**: \n \\[\n \\sin(\\theta) = \\frac{\\text{opposite}}{\\text{hypotenuse}}, \\quad \\cos(\\theta) = \\frac{\\text{adjacent}}{\\text{hypotenuse}}, \\quad \\tan(\\theta) = \\frac{\\text{opposite}}{\\text{adjacent}}\n \\]\n\n2. **Pythagorean Identity**: \n \\[\n \\sin^2(\\theta) + \\cos^2(\\theta) = 1\n \\]\n\n### Calculus\n1. **Derivative of a Function**: \n \\[\n \\frac{d}{dx}(x^n) = nx^{n-1}\n \\]\n\n2. **Integral of a Function**: \n \\[\n \\int x^n \\, dx = \\frac{x^{n+1}}{n+1} + C \\quad (n \\neq -1)\n \\]\n\n3. **Fundamental Theorem of Calculus**: \n \\[\n \\int_a^b f(x) \\, dx = F(b) - F(a)\n \\]\n (Where \\( F \\) is an antiderivative of \\( f \\))\n\n### Statistics\n1. **Mean**: \n \\[\n \\text{Mean} = \\frac{\\sum_{i=1}^{n} x_i}{n}\n \\]\n\n2. **Variance**: \n \\[\n \\sigma^2 = \\frac{\\sum_{i=1}^{n} (x_i - \\mu)^2}{n}\n \\]\n (Where \\( \\mu \\) is the mean)\n\n3. **Standard Deviation**: \n \\[\n \\sigma = \\sqrt{\\sigma^2}\n \\]\n\n### Probability\n1. **Probability of an Event**: \n \\[\n P(A) = \\frac{\\text{Number of favorable outcomes}}{\\text{Total number of outcomes}}\n \\]\n\n2. **Bayes' Theorem**: \n \\[\n P(A|B) = \\frac{P(B|A)P(A)}{P(B)}\n \\]\n\nThese formulas are foundational in their respective areas and are widely used in various applications of mathematics.",
|
||||
parts: [
|
||||
{
|
||||
type: 'text',
|
||||
text: "Here are some popular mathematical formulas across various branches of mathematics:\n\n### Algebra\n1. **Quadratic Formula**: \n \\[\n x = \\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a}\n \\]\n (Used to find the roots of a quadratic equation \\( ax^2 + bx + c = 0 \\))\n\n2. **Difference of Squares**: \n \\[\n a^2 - b^2 = (a - b)(a + b)\n \\]\n\n3. **Factoring a Perfect Square**: \n \\[\n a^2 + 2ab + b^2 = (a + b)^2\n \\]\n \\[\n a^2 - 2ab + b^2 = (a - b)^2\n \\]\n\n### Geometry\n1. **Area of a Circle**: \n \\[\n A = \\pi r^2\n \\]\n\n2. **Circumference of a Circle**: \n \\[\n C = 2\\pi r\n \\]\n\n3. **Pythagorean Theorem**: \n \\[\n a^2 + b^2 = c^2\n \\]\n (In a right triangle, where \\( c \\) is the hypotenuse)\n\n4. **Area of a Triangle**: \n \\[\n A = \\frac{1}{2} \\times \\text{base} \\times \\text{height}\n \\]\n\n### Trigonometry\n1. **Sine, Cosine, and Tangent**: \n \\[\n \\sin(\\theta) = \\frac{\\text{opposite}}{\\text{hypotenuse}}, \\quad \\cos(\\theta) = \\frac{\\text{adjacent}}{\\text{hypotenuse}}, \\quad \\tan(\\theta) = \\frac{\\text{opposite}}{\\text{adjacent}}\n \\]\n\n2. **Pythagorean Identity**: \n \\[\n \\sin^2(\\theta) + \\cos^2(\\theta) = 1\n \\]\n\n### Calculus\n1. **Derivative of a Function**: \n \\[\n \\frac{d}{dx}(x^n) = nx^{n-1}\n \\]\n\n2. **Integral of a Function**: \n \\[\n \\int x^n \\, dx = \\frac{x^{n+1}}{n+1} + C \\quad (n \\neq -1)\n \\]\n\n3. **Fundamental Theorem of Calculus**: \n \\[\n \\int_a^b f(x) \\, dx = F(b) - F(a)\n \\]\n (Where \\( F \\) is an antiderivative of \\( f \\))\n\n### Statistics\n1. **Mean**: \n \\[\n \\text{Mean} = \\frac{\\sum_{i=1}^{n} x_i}{n}\n \\]\n\n2. **Variance**: \n \\[\n \\sigma^2 = \\frac{\\sum_{i=1}^{n} (x_i - \\mu)^2}{n}\n \\]\n (Where \\( \\mu \\) is the mean)\n\n3. **Standard Deviation**: \n \\[\n \\sigma = \\sqrt{\\sigma^2}\n \\]\n\n### Probability\n1. **Probability of an Event**: \n \\[\n P(A) = \\frac{\\text{Number of favorable outcomes}}{\\text{Total number of outcomes}}\n \\]\n\n2. **Bayes' Theorem**: \n \\[\n P(A|B) = \\frac{P(B|A)P(A)}{P(B)}\n \\]\n\nThese formulas are foundational in their respective areas and are widely used in various applications of mathematics.",
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export default function Page(): JSX.Element {
|
||||
const handler = useChat({ initialMessages })
|
||||
const handler = useChat({ messages: initialMessages })
|
||||
return (
|
||||
<div className="flex gap-10">
|
||||
<div className="hidden w-1/3 justify-center space-y-10 self-center p-10 md:block">
|
||||
|
||||
@@ -7,13 +7,13 @@ import {
|
||||
ChatSection,
|
||||
useChatUI,
|
||||
} from '@llamaindex/chat-ui'
|
||||
import { Message, useChat } from 'ai/react'
|
||||
import { UIMessage as Message, useChat } from '@ai-sdk/react'
|
||||
import { Code } from '@/components/code'
|
||||
import MermaidDiagram from './mermaid-diagram'
|
||||
|
||||
const code = `
|
||||
import { ChatSection, ChatInput, ChatMessage, ChatMessages, useChatUI } from '@llamaindex/chat-ui'
|
||||
import { useChat } from 'ai/react'
|
||||
import { useChat } from '@ai-sdk/react'
|
||||
|
||||
// This demo requires mermaid to be installed in your project:
|
||||
// pnpm add mermaid
|
||||
@@ -53,31 +53,41 @@ const initialMessages: Message[] = [
|
||||
{
|
||||
id: '1',
|
||||
role: 'user',
|
||||
content: 'Show me a system architecture diagram',
|
||||
parts: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Show me a system architecture diagram',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
role: 'assistant',
|
||||
content: [
|
||||
'Here is a system architecture diagram showing how LlamaIndex ChatUI components interact:',
|
||||
'',
|
||||
'```mermaid',
|
||||
'graph TD',
|
||||
' A[User] -->|Input| B[ChatInput]',
|
||||
' B -->|Process| C[ChatSection]',
|
||||
' C -->|Render| D[ChatMessages]',
|
||||
' D -->|Display| E[ChatMessage]',
|
||||
' E -->|Show| F[Content]',
|
||||
' F -->|Render| G[Markdown]',
|
||||
' F -->|Render| H[Images]',
|
||||
' F -->|Render| I[Documents]',
|
||||
' F -->|Render| J[Mermaid]',
|
||||
' style A fill:#f9f,stroke:#333,stroke-width:2px',
|
||||
' style B fill:#bbf,stroke:#333,stroke-width:2px',
|
||||
' style C fill:#dfd,stroke:#333,stroke-width:2px',
|
||||
'```',
|
||||
'',
|
||||
].join('\n'),
|
||||
parts: [
|
||||
{
|
||||
type: 'text',
|
||||
text: [
|
||||
'Here is a system architecture diagram showing how LlamaIndex ChatUI components interact:',
|
||||
'',
|
||||
'```mermaid',
|
||||
'graph TD',
|
||||
' A[User] -->|Input| B[ChatInput]',
|
||||
' B -->|Process| C[ChatSection]',
|
||||
' C -->|Render| D[ChatMessages]',
|
||||
' D -->|Display| E[ChatMessage]',
|
||||
' E -->|Show| F[Content]',
|
||||
' F -->|Render| G[Markdown]',
|
||||
' F -->|Render| H[Images]',
|
||||
' F -->|Render| I[Documents]',
|
||||
' F -->|Render| J[Mermaid]',
|
||||
' style A fill:#f9f,stroke:#333,stroke-width:2px',
|
||||
' style B fill:#bbf,stroke:#333,stroke-width:2px',
|
||||
' style C fill:#dfd,stroke:#333,stroke-width:2px',
|
||||
'```',
|
||||
'',
|
||||
].join('\n'),
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -96,7 +106,7 @@ export default function MermaidDemoPage(): JSX.Element {
|
||||
}
|
||||
|
||||
function MermaidChat() {
|
||||
const handler = useChat({ initialMessages })
|
||||
const handler = useChat({ messages: initialMessages })
|
||||
return (
|
||||
<ChatSection handler={handler} className="h-full">
|
||||
<MermaidChatMessages />
|
||||
@@ -106,7 +116,7 @@ function MermaidChat() {
|
||||
}
|
||||
|
||||
function MermaidChatMessages() {
|
||||
const { messages, isLoading, append } = useChatUI()
|
||||
const { messages } = useChatUI()
|
||||
return (
|
||||
<ChatMessages>
|
||||
<ChatMessages.List>
|
||||
@@ -118,8 +128,8 @@ function MermaidChatMessages() {
|
||||
className="items-start"
|
||||
>
|
||||
<ChatMessage.Avatar />
|
||||
<ChatMessage.Content isLoading={isLoading} append={append}>
|
||||
<ChatMessage.Content.Markdown
|
||||
<ChatMessage.Content>
|
||||
<ChatMessage.Part.Markdown
|
||||
languageRenderers={{ mermaid: MermaidDiagram }}
|
||||
/>
|
||||
</ChatMessage.Content>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
'use client'
|
||||
|
||||
import { useChat } from 'ai/react'
|
||||
import { useChat } from '@ai-sdk/react'
|
||||
import { ChatSection } from '@llamaindex/chat-ui'
|
||||
import { Code } from '@/components/code'
|
||||
|
||||
const code = `
|
||||
import { ChatSection } from '@llamaindex/chat-ui'
|
||||
import { useChat } from 'ai/react'
|
||||
import { useChat } from "@ai-sdk/react";
|
||||
|
||||
function SimpleChat() {
|
||||
const handler = useChat()
|
||||
|
||||
+58
-36
@@ -1,12 +1,36 @@
|
||||
import { faker } from '@faker-js/faker'
|
||||
|
||||
const DATA_PREFIX = 'data: ' // SSE format prefix
|
||||
const TOKEN_DELAY = 30 // 30ms delay between tokens
|
||||
|
||||
export type TextChunk = {
|
||||
type: 'text-delta' | 'text-start' | 'text-end'
|
||||
id: string
|
||||
delta?: string
|
||||
}
|
||||
|
||||
export type DataChunk = {
|
||||
type: `data-${string}` // requires `data-` prefix when sending data parts
|
||||
data: Record<string, any>
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder()
|
||||
|
||||
export const writeStream = (
|
||||
controller: ReadableStreamDefaultController,
|
||||
chunk: TextChunk | DataChunk
|
||||
) => {
|
||||
controller.enqueue(
|
||||
encoder.encode(`${DATA_PREFIX}${JSON.stringify(chunk)}\n\n`)
|
||||
)
|
||||
}
|
||||
|
||||
export const fakeStreamText = ({
|
||||
chunkCount = 10,
|
||||
streamProtocol = 'data',
|
||||
}: {
|
||||
chunkCount?: number
|
||||
streamProtocol?: 'data' | 'text'
|
||||
} = {}) => {
|
||||
// Generate sample text blocks
|
||||
const blocks = [
|
||||
Array.from({ length: chunkCount }, () => ({
|
||||
delay: faker.number.int({ max: 100, min: 30 }),
|
||||
@@ -18,51 +42,49 @@ export const fakeStreamText = ({
|
||||
})),
|
||||
]
|
||||
|
||||
const encoder = new TextEncoder()
|
||||
|
||||
return new ReadableStream({
|
||||
async start(controller) {
|
||||
async function writeTextMessage(content: string) {
|
||||
// Generate a unique message id
|
||||
const messageId = crypto.randomUUID()
|
||||
|
||||
// Start the text chunk
|
||||
const startChunk: TextChunk = { id: messageId, type: 'text-start' }
|
||||
writeStream(controller, startChunk)
|
||||
|
||||
// Stream tokens one by one
|
||||
for (const token of content.split(' ')) {
|
||||
if (token.trim()) {
|
||||
const deltaChunk: TextChunk = {
|
||||
id: messageId,
|
||||
type: 'text-delta',
|
||||
delta: `${token} `,
|
||||
}
|
||||
writeStream(controller, deltaChunk)
|
||||
await new Promise(resolve => setTimeout(resolve, TOKEN_DELAY))
|
||||
}
|
||||
}
|
||||
|
||||
// End the text chunk
|
||||
const endChunk: TextChunk = { id: messageId, type: 'text-end' }
|
||||
writeStream(controller, endChunk)
|
||||
}
|
||||
|
||||
// Stream each block as a separate message
|
||||
for (let i = 0; i < blocks.length; i++) {
|
||||
const block = blocks[i]
|
||||
|
||||
for (const chunk of block) {
|
||||
await new Promise(resolve => setTimeout(resolve, chunk.delay))
|
||||
// Combine all texts in the block into one message
|
||||
const blockText = block.map(chunk => chunk.texts).join('')
|
||||
|
||||
if (streamProtocol === 'text') {
|
||||
controller.enqueue(encoder.encode(chunk.texts))
|
||||
} else {
|
||||
controller.enqueue(
|
||||
encoder.encode(`0:${JSON.stringify(chunk.texts)}\n`)
|
||||
)
|
||||
}
|
||||
}
|
||||
await writeTextMessage(blockText)
|
||||
|
||||
// Add paragraph break between blocks
|
||||
if (i < blocks.length - 1) {
|
||||
if (streamProtocol === 'text') {
|
||||
controller.enqueue(encoder.encode('\n\n'))
|
||||
} else {
|
||||
controller.enqueue(encoder.encode(`0:${JSON.stringify('\n\n')}\n`))
|
||||
}
|
||||
await writeTextMessage('\n\n')
|
||||
}
|
||||
}
|
||||
|
||||
if (streamProtocol === 'data') {
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
`d:${JSON.stringify({
|
||||
finishReason: 'stop',
|
||||
usage: {
|
||||
promptTokens: 0,
|
||||
completionTokens: blocks.reduce(
|
||||
(sum, block) => sum + block.length,
|
||||
0
|
||||
),
|
||||
},
|
||||
})}\n`
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
controller.close()
|
||||
},
|
||||
})
|
||||
|
||||
@@ -18,7 +18,8 @@
|
||||
"@llamaindex/dynamic-ui": "workspace:*",
|
||||
"@radix-ui/react-slot": "^1.0.2",
|
||||
"@radix-ui/react-tabs": "^1.1.1",
|
||||
"ai": "4.0.0",
|
||||
"@ai-sdk/react": "^2.0.4",
|
||||
"ai": "^5.0.4",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
"highlight.js": "^11.10.0",
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
"files": [
|
||||
{
|
||||
"path": "registry/chat/chat.tsx",
|
||||
"content": "'use client'\r\n\r\nimport {\r\n ChatHandler,\r\n ChatSection as ChatSectionUI,\r\n Message,\r\n} from '@llamaindex/chat-ui'\r\n\r\nimport '@llamaindex/chat-ui/styles/markdown.css'\r\nimport '@llamaindex/chat-ui/styles/pdf.css'\r\nimport '@llamaindex/chat-ui/styles/editor.css'\r\nimport { useState } from 'react'\r\n\r\nconst initialMessages: Message[] = [\r\n {\r\n content: 'Write simple Javascript hello world code',\r\n role: 'user',\r\n },\r\n {\r\n role: 'assistant',\r\n content:\r\n 'Got it! Here\\'s the simplest JavaScript code to print \"Hello, World!\" to the console:\\n\\n```javascript\\nconsole.log(\"Hello, World!\");\\n```\\n\\nYou can run this code in any JavaScript environment, such as a web browser\\'s console or a Node.js environment. Just paste the code and execute it to see the output.',\r\n },\r\n {\r\n content: 'Write a simple math equation',\r\n role: 'user',\r\n },\r\n {\r\n role: 'assistant',\r\n content:\r\n \"Let's explore a simple mathematical equation using LaTeX:\\n\\n The quadratic formula is: $$x = \\\\frac{-b \\\\pm \\\\sqrt{b^2 - 4ac}}{2a}$$\\n\\nThis formula helps us solve quadratic equations in the form $ax^2 + bx + c = 0$. The solution gives us the x-values where the parabola intersects the x-axis.\",\r\n },\r\n]\r\n\r\nexport function ChatSection() {\r\n // You can replace the handler with a useChat hook from Vercel AI SDK\r\n const handler = useMockChat(initialMessages)\r\n return (\r\n <div className=\"flex max-h-[80vh] flex-col gap-6 overflow-y-auto\">\r\n <ChatSectionUI handler={handler} />\r\n </div>\r\n )\r\n}\r\n\r\nfunction useMockChat(initMessages: Message[]): ChatHandler {\r\n const [messages, setMessages] = useState<Message[]>(initMessages)\r\n const [input, setInput] = useState('')\r\n const [isLoading, setIsLoading] = useState(false)\r\n\r\n const append = async (message: Message) => {\r\n setIsLoading(true)\r\n\r\n const mockResponse: Message = {\r\n role: 'assistant',\r\n content: '',\r\n }\r\n setMessages(prev => [...prev, message, mockResponse])\r\n\r\n const mockContent =\r\n 'This is a mock response. In a real implementation, this would be replaced with an actual AI response.'\r\n\r\n let streamedContent = ''\r\n const words = mockContent.split(' ')\r\n\r\n for (const word of words) {\r\n await new Promise(resolve => setTimeout(resolve, 100))\r\n streamedContent += (streamedContent ? ' ' : '') + word\r\n setMessages(prev => {\r\n return [\r\n ...prev.slice(0, -1),\r\n {\r\n role: 'assistant',\r\n content: streamedContent,\r\n },\r\n ]\r\n })\r\n }\r\n\r\n setIsLoading(false)\r\n return mockContent\r\n }\r\n\r\n return {\r\n messages,\r\n input,\r\n setInput,\r\n isLoading,\r\n append,\r\n }\r\n}\r\n",
|
||||
"content": "'use client'\n\nimport {\n ChatHandler,\n ChatSection as ChatSectionUI,\n Message,\n} from '@llamaindex/chat-ui'\n\nimport '@llamaindex/chat-ui/styles/markdown.css'\nimport '@llamaindex/chat-ui/styles/pdf.css'\nimport '@llamaindex/chat-ui/styles/editor.css'\nimport { useState } from 'react'\n\nconst initialMessages: Message[] = [\n {\n id: '1',\n parts: [{ type: 'text', text: 'Write simple Javascript hello world code' }],\n role: 'user',\n },\n {\n id: '2',\n role: 'assistant',\n parts: [\n {\n type: 'text',\n text: 'Got it! Here\\'s the simplest JavaScript code to print \"Hello, World!\" to the console:\\n\\n```javascript\\nconsole.log(\"Hello, World!\");\\n```\\n\\nYou can run this code in any JavaScript environment, such as a web browser\\'s console or a Node.js environment. Just paste the code and execute it to see the output.',\n },\n ],\n },\n {\n id: '3',\n parts: [{ type: 'text', text: 'Write a simple math equation' }],\n role: 'user',\n },\n {\n id: '4',\n role: 'assistant',\n parts: [\n {\n type: 'text',\n text: \"Let's explore a simple mathematical equation using LaTeX:\\n\\n The quadratic formula is: $$x = \\\\frac{-b \\\\pm \\\\sqrt{b^2 - 4ac}}{2a}$$\\n\\nThis formula helps us solve quadratic equations in the form $ax^2 + bx + c = 0$. The solution gives us the x-values where the parabola intersects the x-axis.\",\n },\n ],\n },\n]\n\nexport function ChatSection() {\n // You can replace the handler with a useChat hook from Vercel AI SDK\n const handler = useMockChat(initialMessages)\n return (\n <div className=\"flex max-h-[80vh] flex-col gap-6 overflow-y-auto\">\n <ChatSectionUI handler={handler} />\n </div>\n )\n}\n\nfunction useMockChat(initMessages: Message[]): ChatHandler {\n const [messages, setMessages] = useState<Message[]>(initMessages)\n const [status, setStatus] = useState<\n 'streaming' | 'ready' | 'error' | 'submitted'\n >('ready')\n\n const append = async (message: Message) => {\n const mockResponse: Message = {\n id: '5',\n role: 'assistant',\n parts: [{ type: 'text', text: '' }],\n }\n setMessages(prev => [...prev, message, mockResponse])\n\n const mockContent =\n 'This is a mock response. In a real implementation, this would be replaced with an actual AI response.'\n\n let streamedContent = ''\n const words = mockContent.split(' ')\n\n for (const word of words) {\n await new Promise(resolve => setTimeout(resolve, 100))\n streamedContent += (streamedContent ? ' ' : '') + word\n setMessages(prev => {\n return [\n ...prev.slice(0, -1),\n {\n id: '6',\n role: 'assistant',\n parts: [{ type: 'text', text: streamedContent }],\n },\n ]\n })\n }\n\n return mockContent\n }\n\n return {\n messages,\n status,\n sendMessage: async (message: Message) => {\n setStatus('submitted')\n await append(message)\n setStatus('ready')\n },\n }\n}\n",
|
||||
"type": "registry:block"
|
||||
}
|
||||
],
|
||||
|
||||
@@ -13,22 +13,34 @@ import { useState } from 'react'
|
||||
|
||||
const initialMessages: Message[] = [
|
||||
{
|
||||
content: 'Write simple Javascript hello world code',
|
||||
id: '1',
|
||||
parts: [{ type: 'text', text: 'Write simple Javascript hello world code' }],
|
||||
role: 'user',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
role: 'assistant',
|
||||
content:
|
||||
'Got it! Here\'s the simplest JavaScript code to print "Hello, World!" to the console:\n\n```javascript\nconsole.log("Hello, World!");\n```\n\nYou can run this code in any JavaScript environment, such as a web browser\'s console or a Node.js environment. Just paste the code and execute it to see the output.',
|
||||
parts: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Got it! Here\'s the simplest JavaScript code to print "Hello, World!" to the console:\n\n```javascript\nconsole.log("Hello, World!");\n```\n\nYou can run this code in any JavaScript environment, such as a web browser\'s console or a Node.js environment. Just paste the code and execute it to see the output.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
content: 'Write a simple math equation',
|
||||
id: '3',
|
||||
parts: [{ type: 'text', text: 'Write a simple math equation' }],
|
||||
role: 'user',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
role: 'assistant',
|
||||
content:
|
||||
"Let's explore a simple mathematical equation using LaTeX:\n\n The quadratic formula is: $$x = \\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a}$$\n\nThis formula helps us solve quadratic equations in the form $ax^2 + bx + c = 0$. The solution gives us the x-values where the parabola intersects the x-axis.",
|
||||
parts: [
|
||||
{
|
||||
type: 'text',
|
||||
text: "Let's explore a simple mathematical equation using LaTeX:\n\n The quadratic formula is: $$x = \\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a}$$\n\nThis formula helps us solve quadratic equations in the form $ax^2 + bx + c = 0$. The solution gives us the x-values where the parabola intersects the x-axis.",
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -44,15 +56,15 @@ export function ChatSection() {
|
||||
|
||||
function useMockChat(initMessages: Message[]): ChatHandler {
|
||||
const [messages, setMessages] = useState<Message[]>(initMessages)
|
||||
const [input, setInput] = useState('')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [status, setStatus] = useState<
|
||||
'streaming' | 'ready' | 'error' | 'submitted'
|
||||
>('ready')
|
||||
|
||||
const append = async (message: Message) => {
|
||||
setIsLoading(true)
|
||||
|
||||
const mockResponse: Message = {
|
||||
id: '5',
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
parts: [{ type: 'text', text: '' }],
|
||||
}
|
||||
setMessages(prev => [...prev, message, mockResponse])
|
||||
|
||||
@@ -69,22 +81,24 @@ function useMockChat(initMessages: Message[]): ChatHandler {
|
||||
return [
|
||||
...prev.slice(0, -1),
|
||||
{
|
||||
id: '6',
|
||||
role: 'assistant',
|
||||
content: streamedContent,
|
||||
parts: [{ type: 'text', text: streamedContent }],
|
||||
},
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
setIsLoading(false)
|
||||
return mockContent
|
||||
}
|
||||
|
||||
return {
|
||||
messages,
|
||||
input,
|
||||
setInput,
|
||||
isLoading,
|
||||
append,
|
||||
status,
|
||||
sendMessage: async (message: Message) => {
|
||||
setStatus('submitted')
|
||||
await append(message)
|
||||
setStatus('ready')
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,435 +0,0 @@
|
||||
---
|
||||
title: Annotations
|
||||
description: Working with rich content annotations for multimedia and interactive chat experiences
|
||||
---
|
||||
|
||||
Annotations are the key to creating rich, interactive chat experiences beyond simple text. They allow you to embed images, files, sources, events, and custom content types directly into chat messages.
|
||||
|
||||
## Annotation System Overview
|
||||
|
||||
Annotations are structured data attached to messages that widgets can render as rich content. The system supports both built-in annotation types and custom annotations for domain-specific content.
|
||||
|
||||
### Message Structure with Annotations
|
||||
|
||||
```typescript
|
||||
interface Message {
|
||||
id: string
|
||||
role: 'user' | 'assistant' | 'system'
|
||||
content: string
|
||||
annotations?: JSONValue[]
|
||||
}
|
||||
```
|
||||
|
||||
### Built-in Annotation Types
|
||||
|
||||
The library provides several built-in annotation types:
|
||||
|
||||
- **IMAGE** - Image data with URLs
|
||||
- **DOCUMENT_FILE** - File attachments and metadata
|
||||
- **SOURCES** - Citation and source references
|
||||
- **EVENTS** - Process events and function calls
|
||||
- **AGENT_EVENTS** - Agent-specific events with progress
|
||||
- **ARTIFACT** - Interactive code and document artifacts
|
||||
- **SUGGESTED_QUESTIONS** - Follow-up question suggestions
|
||||
|
||||
## Using Annotations
|
||||
|
||||
Annotations automatically render when using the `annotations` property on a message. Here's an example of how to render an image annotation:
|
||||
|
||||
```tsx
|
||||
const handler = useChat({
|
||||
initialMessages: [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: 'Here is an image',
|
||||
annotations: [
|
||||
{
|
||||
type: 'image',
|
||||
data: {
|
||||
url: '/llama.png',
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<ChatSection
|
||||
handler={handler}
|
||||
className="block h-full flex-row gap-4 p-0 md:flex md:p-5"
|
||||
>
|
||||
<ChatMessage message={message}>
|
||||
<ChatMessage.Content>
|
||||
<ChatMessage.Content.Markdown />
|
||||
<ChatMessage.Content.Image />{' '}
|
||||
{/* Automatically renders IMAGE annotations */}
|
||||
</ChatMessage.Content>
|
||||
</ChatMessage>
|
||||
</ChatSection>
|
||||
)
|
||||
```
|
||||
|
||||
In the example above, the `ChatMessage.Content.Image` component automatically renders the image annotation retrieved from the `annotations` property on the message which is retrieved by the `useChatMessage` hook.
|
||||
The annotation is then passed to the `ChatImage` component which renders the image.
|
||||
|
||||
## File Annotations
|
||||
|
||||
Display file attachments with download links and preview capabilities.
|
||||
|
||||
### Document File Annotations
|
||||
|
||||
```typescript
|
||||
const fileAnnotation = {
|
||||
type: 'DOCUMENT_FILE',
|
||||
data: {
|
||||
files: [
|
||||
{
|
||||
id: 'doc1',
|
||||
name: 'quarterly-report.pdf',
|
||||
type: 'application/pdf',
|
||||
url: '/files/quarterly-report.pdf',
|
||||
size: 2048576, // 2MB in bytes
|
||||
metadata: {
|
||||
title: 'Q4 2024 Quarterly Report',
|
||||
author: 'Finance Team',
|
||||
pages: 25,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'doc2',
|
||||
name: 'data-analysis.csv',
|
||||
type: 'text/csv',
|
||||
url: '/files/data-analysis.csv',
|
||||
size: 1024000,
|
||||
metadata: {
|
||||
rows: 5000,
|
||||
columns: 12,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## Source Annotations
|
||||
|
||||
Display citations and source references with document grouping.
|
||||
|
||||
### Creating Source Annotations
|
||||
|
||||
```typescript
|
||||
const sourceAnnotation = {
|
||||
type: 'sources',
|
||||
data: {
|
||||
nodes: [
|
||||
{
|
||||
id: 'source1',
|
||||
url: '/documents/research-paper.pdf',
|
||||
metadata: {
|
||||
title: 'Machine Learning in Healthcare',
|
||||
author: 'Dr. Jane Smith',
|
||||
page_number: 15,
|
||||
section: 'Methodology',
|
||||
published_date: '2024-01-15',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'source2',
|
||||
url: '/documents/clinical-study.pdf',
|
||||
metadata: {
|
||||
title: 'Clinical Trial Results',
|
||||
author: 'Medical Research Institute',
|
||||
page_number: 8,
|
||||
figure: 'Figure 3.2',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### Citation in Content
|
||||
|
||||
Reference sources directly in your content using citation syntax:
|
||||
|
||||
```typescript
|
||||
const content = `
|
||||
Based on recent research [^1], machine learning shows promising results
|
||||
in medical diagnosis. The clinical trial data [^2] supports these findings
|
||||
with a 95% accuracy rate.
|
||||
|
||||
[^1]: Machine Learning in Healthcare, p. 15
|
||||
[^2]: Clinical Trial Results, Figure 3.2
|
||||
`
|
||||
|
||||
return {
|
||||
role: 'assistant',
|
||||
content,
|
||||
annotations: [sourceAnnotation],
|
||||
}
|
||||
```
|
||||
|
||||
## Event Annotations
|
||||
|
||||
Display process events, function calls, and system activities.
|
||||
|
||||
### Basic Events
|
||||
|
||||
```typescript
|
||||
const eventAnnotation = {
|
||||
type: 'events',
|
||||
data: [
|
||||
{
|
||||
type: 'function_call',
|
||||
name: 'search_database',
|
||||
args: {
|
||||
query: 'machine learning papers',
|
||||
limit: 10,
|
||||
},
|
||||
result: 'Found 8 relevant papers',
|
||||
timestamp: '2024-01-15T10:30:00Z',
|
||||
},
|
||||
{
|
||||
type: 'tool_use',
|
||||
name: 'calculate_statistics',
|
||||
args: {
|
||||
dataset: 'user_engagement',
|
||||
},
|
||||
result: {
|
||||
mean: 4.2,
|
||||
median: 4.1,
|
||||
std_dev: 0.8,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
### Agent Events with Progress
|
||||
|
||||
```typescript
|
||||
const agentEventAnnotation = {
|
||||
type: 'agent_events',
|
||||
data: {
|
||||
agent_name: 'Research Assistant',
|
||||
total_steps: 4,
|
||||
current_step: 2,
|
||||
progress: 50,
|
||||
events: [
|
||||
{
|
||||
step: 1,
|
||||
name: 'Search Documents',
|
||||
status: 'completed',
|
||||
result: 'Found 15 relevant documents',
|
||||
},
|
||||
{
|
||||
step: 2,
|
||||
name: 'Analyze Content',
|
||||
status: 'in_progress',
|
||||
progress: 75,
|
||||
},
|
||||
{
|
||||
step: 3,
|
||||
name: 'Generate Summary',
|
||||
status: 'pending',
|
||||
},
|
||||
{
|
||||
step: 4,
|
||||
name: 'Create Recommendations',
|
||||
status: 'pending',
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## Artifact Annotations
|
||||
|
||||
Create interactive code and document artifacts that users can edit.
|
||||
|
||||
### Code Artifacts
|
||||
|
||||
```typescript
|
||||
const codeArtifact = {
|
||||
type: 'artifact',
|
||||
data: {
|
||||
type: 'code',
|
||||
data: {
|
||||
title: 'Data Analysis Script',
|
||||
file_name: 'analyze_data.py',
|
||||
language: 'python',
|
||||
code: `
|
||||
import pandas as pd
|
||||
import matplotlib.pyplot as plt
|
||||
|
||||
def analyze_sales_data(file_path):
|
||||
# Load data
|
||||
df = pd.read_csv(file_path)
|
||||
|
||||
# Calculate monthly totals
|
||||
monthly_sales = df.groupby('month')['sales'].sum()
|
||||
|
||||
# Create visualization
|
||||
plt.figure(figsize=(10, 6))
|
||||
monthly_sales.plot(kind='bar')
|
||||
plt.title('Monthly Sales Analysis')
|
||||
plt.ylabel('Sales ($)')
|
||||
plt.show()
|
||||
|
||||
return monthly_sales
|
||||
|
||||
# Usage
|
||||
sales_data = analyze_sales_data('sales.csv')
|
||||
print(sales_data)
|
||||
`,
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### Document Artifacts
|
||||
|
||||
```typescript
|
||||
const documentArtifact = {
|
||||
type: 'artifact',
|
||||
data: {
|
||||
type: 'document',
|
||||
data: {
|
||||
title: 'Project Proposal',
|
||||
content: `
|
||||
# AI-Powered Analytics Platform
|
||||
|
||||
## Executive Summary
|
||||
|
||||
This proposal outlines the development of an AI-powered analytics platform
|
||||
designed to help businesses make data-driven decisions.
|
||||
|
||||
## Key Features
|
||||
|
||||
- **Real-time Data Processing**: Stream analytics with sub-second latency
|
||||
- **Machine Learning Models**: Automated insight generation
|
||||
- **Interactive Dashboards**: Self-service analytics for business users
|
||||
|
||||
## Implementation Timeline
|
||||
|
||||
### Phase 1 (Months 1-3)
|
||||
- Core platform development
|
||||
- Basic ML model integration
|
||||
|
||||
### Phase 2 (Months 4-6)
|
||||
- Advanced analytics features
|
||||
- Dashboard creation tools
|
||||
|
||||
## Budget Estimate
|
||||
|
||||
Total project cost: $250,000
|
||||
`,
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## Suggested Questions
|
||||
|
||||
Provide interactive follow-up questions to guide the conversation.
|
||||
|
||||
```typescript
|
||||
const suggestedQuestionsAnnotation = {
|
||||
type: 'suggested_questions',
|
||||
data: {
|
||||
questions: [
|
||||
'Can you explain the methodology in more detail?',
|
||||
'What are the potential limitations of this approach?',
|
||||
'How does this compare to traditional methods?',
|
||||
'What would be the next steps for implementation?',
|
||||
],
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## Custom Annotations
|
||||
|
||||
Create domain-specific annotations for specialized content.
|
||||
|
||||
### Weather Widget Example
|
||||
|
||||
```typescript
|
||||
// Define custom annotation type
|
||||
interface WeatherAnnotation {
|
||||
type: 'weather'
|
||||
data: {
|
||||
location: string
|
||||
temperature: number
|
||||
condition: string
|
||||
humidity: number
|
||||
windSpeed: number
|
||||
forecast?: Array<{
|
||||
day: string
|
||||
high: number
|
||||
low: number
|
||||
condition: string
|
||||
}>
|
||||
}
|
||||
}
|
||||
|
||||
// Create annotation
|
||||
const weatherAnnotation: WeatherAnnotation = {
|
||||
type: 'weather',
|
||||
data: {
|
||||
location: 'San Francisco, CA',
|
||||
temperature: 22,
|
||||
condition: 'sunny',
|
||||
humidity: 65,
|
||||
windSpeed: 12,
|
||||
forecast: [
|
||||
{ day: 'Tomorrow', high: 24, low: 18, condition: 'cloudy' },
|
||||
{ day: 'Wednesday', high: 26, low: 20, condition: 'sunny' },
|
||||
],
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### Custom Widget Implementation
|
||||
|
||||
```tsx
|
||||
import { useChatMessage, getAnnotationData } from '@llamaindex/chat-ui'
|
||||
|
||||
interface WeatherData {
|
||||
location: string
|
||||
temperature: number
|
||||
condition: string
|
||||
humidity: number
|
||||
windSpeed: number
|
||||
}
|
||||
|
||||
function WeatherWidget() {
|
||||
const { message } = useChatMessage()
|
||||
|
||||
const weatherData = getAnnotationData<WeatherData>(message, 'weather')
|
||||
|
||||
if (!weatherData?.[0]) return null
|
||||
|
||||
const data = weatherData[0]
|
||||
// Render weather data...
|
||||
}
|
||||
```
|
||||
|
||||
## Annotation Utilities
|
||||
|
||||
### getAnnotationData
|
||||
|
||||
Extract annotation data by type from messages:
|
||||
|
||||
```tsx
|
||||
import { getAnnotationData } from '@llamaindex/chat-ui'
|
||||
|
||||
// Usage
|
||||
return getAnnotationData<WeatherData>(message, 'weather')
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Artifacts](./artifacts.mdx) - Learn about interactive code and document artifacts
|
||||
- [Widgets](./widgets.mdx) - Explore widget implementation details
|
||||
- [Examples](./examples.mdx) - See complete annotation examples
|
||||
- [Customization](./customization.mdx) - Style and customize annotation appearance
|
||||
+35
-44
@@ -28,9 +28,9 @@ Code artifacts provide interactive code editing with full syntax highlighting an
|
||||
### Creating Code Artifacts
|
||||
|
||||
```typescript
|
||||
// Server-side: Create code artifact annotation
|
||||
// Server-side: Create code artifact part
|
||||
const codeArtifact = {
|
||||
type: 'artifact',
|
||||
type: 'data-artifact',
|
||||
data: {
|
||||
type: 'code',
|
||||
data: {
|
||||
@@ -106,7 +106,7 @@ export async function POST(request: Request) {
|
||||
|
||||
// Send code artifact
|
||||
const artifact = {
|
||||
type: 'artifact',
|
||||
type: 'data-artifact',
|
||||
data: {
|
||||
type: 'code',
|
||||
data: {
|
||||
@@ -118,11 +118,8 @@ export async function POST(request: Request) {
|
||||
},
|
||||
}
|
||||
|
||||
// wrap the annotation in a code block with the language key is 'annotation'
|
||||
const codeBlock = `\n\`\`\`annotation\n${JSON.stringify(codeArtifact)}\n\`\`\`\n`
|
||||
|
||||
// send the artifact with the 0: prefix to make it inline
|
||||
controller.enqueue(encoder.encode(`0:${JSON.stringify(codeBlock)}\\n`))
|
||||
// send the artifact with the data: prefix for SSE format
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(artifact)}\\n`))
|
||||
|
||||
// Send follow-up text
|
||||
controller.enqueue(
|
||||
@@ -137,8 +134,8 @@ export async function POST(request: Request) {
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/plain; charset=utf-8',
|
||||
'X-Vercel-AI-Data-Stream': 'v1',
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Connection': 'keep-alive',
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -178,7 +175,7 @@ Document artifacts provide rich text editing with markdown support and real-time
|
||||
|
||||
```typescript
|
||||
const documentArtifact = {
|
||||
type: 'artifact',
|
||||
type: 'data-artifact',
|
||||
data: {
|
||||
type: 'document',
|
||||
data: {
|
||||
@@ -372,7 +369,11 @@ Document artifacts provide:
|
||||
import { ChatSection, ChatCanvas } from '@llamaindex/chat-ui'
|
||||
|
||||
function ChatWithCanvas() {
|
||||
const handler = useChat({ api: '/api/chat' })
|
||||
const handler = useChat({
|
||||
transport: new DefaultChatTransport({
|
||||
api: '/api/chat',
|
||||
}),
|
||||
})
|
||||
|
||||
return (
|
||||
<ChatSection handler={handler} className="flex h-full">
|
||||
@@ -469,7 +470,7 @@ function CustomChat() {
|
||||
}
|
||||
```
|
||||
|
||||
You can also custom ArtifactCard for your artifact type.
|
||||
You can also customize ArtifactCard for your artifact type:
|
||||
|
||||
```tsx
|
||||
import { Image } from 'lucide-react'
|
||||
@@ -484,37 +485,27 @@ function CustomArtifactCard({ data }: { data: Artifact }) {
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// update markdown annotation renderers to use your custom artifact card
|
||||
<ChatMessage.Content>
|
||||
<ChatMessage.Content.Markdown
|
||||
annotationRenderers={{
|
||||
artifact: CustomArtifactCard,
|
||||
}}
|
||||
/>
|
||||
</ChatMessage.Content>
|
||||
```
|
||||
|
||||
To trigger your custom artifact viewer, the AI response should include an annotation with the matching artifact type:
|
||||
To trigger your custom artifact viewer, the AI response should include a part with the matching artifact type:
|
||||
|
||||
```tsx
|
||||
// Example of how to create an artifact in AI response
|
||||
const response = `Here is your image!
|
||||
|
||||
\`\`\`annotation
|
||||
${JSON.stringify({
|
||||
type: 'artifact',
|
||||
data: {
|
||||
type: 'image', // This matches your viewer's check
|
||||
data: {
|
||||
imageUrl: 'https://example.com/image.jpg',
|
||||
caption: 'A beautiful landscape'
|
||||
},
|
||||
created_at: Date.now(),
|
||||
},
|
||||
})}
|
||||
\`\`\`
|
||||
`
|
||||
```ts
|
||||
message = {
|
||||
parts: [
|
||||
{
|
||||
type: 'data-artifact',
|
||||
data: {
|
||||
type: 'code',
|
||||
data: {
|
||||
title: 'Data Visualization Script',
|
||||
file_name: 'visualize_data.py',
|
||||
language: 'python',
|
||||
code: 'import matplotlib.pyplot as plt\n# Code...',
|
||||
},
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
You can create multiple custom artifact viewers for different content types:
|
||||
@@ -536,7 +527,7 @@ For a complete working example of custom artifact viewers, check out the demo im
|
||||
|
||||
- Custom `ImageArtifactViewer` implementation
|
||||
- Integration with existing chat components
|
||||
- Sample messages with artifact annotations
|
||||
- Sample messages with artifact parts
|
||||
- Copy-to-clipboard functionality for the code
|
||||
|
||||
### Canvas Auto-Show
|
||||
@@ -547,7 +538,7 @@ The canvas automatically appears when artifacts are present:
|
||||
// Canvas appears automatically when message contains artifacts
|
||||
<ChatMessage message={messageWithArtifact}>
|
||||
<ChatMessage.Content>
|
||||
<ChatMessage.Content.Markdown />
|
||||
<ChatMessage.Part.Artifact />
|
||||
</ChatMessage.Content>
|
||||
</ChatMessage>
|
||||
```
|
||||
@@ -732,4 +723,4 @@ function CopyArtifact() {
|
||||
- [Examples](./examples.mdx) - See complete artifact implementations
|
||||
- [Customization](./customization.mdx) - Style and customize artifact appearance
|
||||
- [Widgets](./widgets.mdx) - Explore related widget functionality
|
||||
- [Annotations](./annotations.mdx) - Understand the annotation system
|
||||
- [Parts](./parts.mdx) - Understand the message parts system
|
||||
|
||||
@@ -13,10 +13,14 @@ The `ChatSection` is the root component that provides context and layout for all
|
||||
|
||||
```tsx
|
||||
import { ChatSection } from '@llamaindex/chat-ui'
|
||||
import { useChat } from 'ai/react'
|
||||
import { useChat } from '@ai-sdk/react'
|
||||
|
||||
function MyChat() {
|
||||
const handler = useChat({ api: '/api/chat' })
|
||||
const handler = useChat({
|
||||
transport: new DefaultChatTransport({
|
||||
api: '/api/chat',
|
||||
}),
|
||||
})
|
||||
return <ChatSection handler={handler} />
|
||||
}
|
||||
```
|
||||
@@ -120,7 +124,7 @@ Action buttons for the message list:
|
||||
|
||||
```tsx
|
||||
<ChatMessages.Actions>
|
||||
<button onClick={reload}>Reload</button>
|
||||
<button onClick={regenerate}>Regenerate</button>
|
||||
<button onClick={stop}>Stop</button>
|
||||
</ChatMessages.Actions>
|
||||
```
|
||||
@@ -134,7 +138,7 @@ Action buttons for the message list:
|
||||
|
||||
## ChatMessage
|
||||
|
||||
Individual message component with full annotation support and role-based rendering.
|
||||
Individual message component which renders, the avatar, the content and actions of a message.
|
||||
|
||||
### Basic Usage
|
||||
|
||||
@@ -148,7 +152,7 @@ function CustomMessage({ message, isLast }) {
|
||||
<UserAvatar role={message.role} />
|
||||
</ChatMessage.Avatar>
|
||||
<ChatMessage.Content>
|
||||
<ChatMessage.Content.Markdown />
|
||||
<ChatMessage.Part.Markdown />
|
||||
</ChatMessage.Content>
|
||||
<ChatMessage.Actions />
|
||||
</ChatMessage>
|
||||
@@ -167,6 +171,18 @@ interface ChatMessageProps {
|
||||
}
|
||||
```
|
||||
|
||||
### Message Structure
|
||||
|
||||
The Message data structure stores the content of a message in so called parts:
|
||||
|
||||
```typescript
|
||||
interface Message {
|
||||
id: string
|
||||
role: 'user' | 'assistant' | 'system'
|
||||
parts: MessagePart[]
|
||||
}
|
||||
```
|
||||
|
||||
### Sub-components
|
||||
|
||||
#### ChatMessage.Avatar
|
||||
@@ -183,17 +199,16 @@ User/assistant avatar display:
|
||||
|
||||
#### ChatMessage.Content
|
||||
|
||||
Main content area with annotation support:
|
||||
This is the main content area which configures a couple of renders for specific message parts:
|
||||
|
||||
```tsx
|
||||
<ChatMessage.Content isLoading={isLoading} append={append}>
|
||||
<ChatMessage.Content.Markdown />
|
||||
<ChatMessage.Content.Image />
|
||||
<ChatMessage.Content.Source />
|
||||
<ChatMessage.Content.Event />
|
||||
<ChatMessage.Content.AgentEvent />
|
||||
<ChatMessage.Content.DocumentFile />
|
||||
<ChatMessage.Content.SuggestedQuestions />
|
||||
<ChatMessage.Content>
|
||||
<ChatMessage.Part.Markdown />
|
||||
<ChatMessage.Part.Artifact />
|
||||
<ChatMessage.Part.Sources />
|
||||
<ChatMessage.Part.Event />
|
||||
<ChatMessage.Part.File />
|
||||
<ChatMessage.Part.Suggestion />
|
||||
</ChatMessage.Content>
|
||||
```
|
||||
|
||||
@@ -208,18 +223,16 @@ Message-level actions (copy, regenerate, etc.):
|
||||
</ChatMessage.Actions>
|
||||
```
|
||||
|
||||
### Content Types
|
||||
### Message Parts
|
||||
|
||||
The content system supports multiple annotation types:
|
||||
There are different renderers available for each part in the message:
|
||||
|
||||
- **Markdown** - Rich text with LaTeX support
|
||||
- **Image** - Image display with preview
|
||||
- **Artifact** - Interactive code/document editing
|
||||
- **Source** - Citation and source links
|
||||
- **Event** - Process events and status
|
||||
- **AgentEvent** - Agent-specific events with progress
|
||||
- **DocumentFile** - File attachments
|
||||
- **SuggestedQuestions** - Follow-up question suggestions
|
||||
- **TextPart** - Rich text with Markdown and LaTeX support
|
||||
- **ArtifactPart** - Interactive code/document editing
|
||||
- **SourcesPart** - Citation and source links
|
||||
- **EventPart** - Process events and status updates
|
||||
- **FilePart** - File attachments and uploads
|
||||
- **SuggestedQuestionsPart** - Follow-up question suggestions
|
||||
|
||||
## ChatInput
|
||||
|
||||
@@ -364,7 +377,11 @@ function ChatWithCanvas() {
|
||||
|
||||
```tsx
|
||||
function AdvancedChat() {
|
||||
const handler = useChat({ api: '/api/chat' })
|
||||
const handler = useChat({
|
||||
transport: new DefaultChatTransport({
|
||||
api: '/api/chat',
|
||||
}),
|
||||
})
|
||||
|
||||
return (
|
||||
<ChatSection handler={handler} className="flex h-full">
|
||||
@@ -407,16 +424,52 @@ All components have access to chat context through hooks:
|
||||
import { useChatUI, useChatMessage } from '@llamaindex/chat-ui'
|
||||
|
||||
function CustomComponent() {
|
||||
const { messages, isLoading, append } = useChatUI()
|
||||
const { messages, status, sendMessage } = useChatUI()
|
||||
const { message } = useChatMessage() // Only in message context
|
||||
|
||||
// Component logic
|
||||
}
|
||||
```
|
||||
|
||||
## Message Parts System
|
||||
|
||||
Each message can have multiple parts. Parts are rendered in the order they are received.
|
||||
For more information on parts, see [Message Parts](./parts.mdx).
|
||||
|
||||
### Creating Custom Parts
|
||||
|
||||
```tsx
|
||||
import { usePart } from '@llamaindex/chat-ui'
|
||||
|
||||
function CustomPart() {
|
||||
const { data } = usePart()
|
||||
|
||||
return (
|
||||
<div className="custom-part">
|
||||
{/* Custom part rendering */}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Backend Integration
|
||||
|
||||
Parts can be sent from the backend via SSE protocol:
|
||||
|
||||
```typescript
|
||||
// Backend example
|
||||
const parts = [
|
||||
{ type: 'weather', data: weatherInfo },
|
||||
{ type: 'sources', data: sourceNodes }
|
||||
]
|
||||
|
||||
// Send as SSE
|
||||
response.write(`data: ${JSON.stringify({ parts })}\n\n`)
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Widgets](./widgets.mdx) - Learn about specialized content widgets
|
||||
- [Annotations](./annotations.mdx) - Implement rich content support
|
||||
- [Message Parts](./message-parts.mdx) - Implement rich content support with parts
|
||||
- [Hooks](./hooks.mdx) - Understand the hook system
|
||||
- [Customization](./customization.mdx) - Style and theme the components
|
||||
@@ -108,9 +108,9 @@ function CustomMessageLayout() {
|
||||
|
||||
<div className="flex-1">
|
||||
<ChatMessage.Content>
|
||||
<ChatMessage.Content.Markdown />
|
||||
<ChatMessage.Content.Image />
|
||||
<ChatMessage.Content.Source />
|
||||
<ChatMessage.Part.Markdown />
|
||||
<ChatMessage.Part.Image />
|
||||
<ChatMessage.Part.Source />
|
||||
</ChatMessage.Content>
|
||||
|
||||
<ChatMessage.Actions>
|
||||
@@ -168,7 +168,7 @@ function RoleBasedMessage() {
|
||||
<div className={`flex ${styles.container} mb-4`}>
|
||||
<div className={`${styles.bubble} ${styles.maxWidth} p-4`}>
|
||||
<ChatMessage.Content>
|
||||
<ChatMessage.Content.Markdown />
|
||||
<ChatMessage.Part.Markdown />
|
||||
</ChatMessage.Content>
|
||||
</div>
|
||||
</div>
|
||||
@@ -514,7 +514,11 @@ export const useTheme = () => useContext(ThemeContext)
|
||||
```tsx
|
||||
function ThemedChatSection() {
|
||||
const { theme } = useTheme()
|
||||
const handler = useChat({ api: '/api/chat' })
|
||||
const handler = useChat({
|
||||
transport: new DefaultChatTransport({
|
||||
api: '/api/chat',
|
||||
}),
|
||||
})
|
||||
|
||||
const themeClasses = {
|
||||
light: 'bg-white text-gray-900',
|
||||
|
||||
@@ -121,7 +121,7 @@ The `markdown.css` file includes styling for code blocks using [highlight.js](ht
|
||||
|
||||
### 1. Create a Chat API Route
|
||||
|
||||
Set up an API route to handle chat requests. Here's an example using Next.js:
|
||||
Set up an API route to handle chat requests. Here's an example using Next.js with LlamaIndex:
|
||||
|
||||
```typescript
|
||||
// app/api/chat/route.ts
|
||||
@@ -130,13 +130,14 @@ import { NextResponse } from 'next/server'
|
||||
export async function POST(request: Request) {
|
||||
const { messages } = await request.json()
|
||||
|
||||
// Your chat logic here
|
||||
// Your LlamaIndex chat logic here
|
||||
const response = await generateChatResponse(messages)
|
||||
|
||||
// Return streaming response in LlamaIndex format
|
||||
return new Response(response, {
|
||||
headers: {
|
||||
'Content-Type': 'text/plain; charset=utf-8',
|
||||
'X-Vercel-AI-Data-Stream': 'v1',
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Connection': 'keep-alive',
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -144,17 +145,21 @@ export async function POST(request: Request) {
|
||||
|
||||
### 2. Create Your Chat Component
|
||||
|
||||
The easiest way to get started is to connect the whole `ChatSection` component with `useChat` hook from [vercel/ai](https://github.com/vercel/ai):
|
||||
The easiest way to get started is to connect the whole `ChatSection` component with `useChat` hook from `@ai-sdk/react`:
|
||||
|
||||
```tsx
|
||||
'use client'
|
||||
|
||||
import { ChatSection } from '@llamaindex/chat-ui'
|
||||
import { useChat } from 'ai/react'
|
||||
import { useChat } from '@ai-sdk/react'
|
||||
|
||||
export default function Chat() {
|
||||
const handler = useChat({
|
||||
api: '/api/chat',
|
||||
// use transport to specify the chat API endpoint
|
||||
// https://ai-sdk.dev/docs/migration-guides/migration-guide-5-0#chat-transport-architecture
|
||||
transport: new DefaultChatTransport({
|
||||
api: '/api/chat',
|
||||
}),
|
||||
})
|
||||
|
||||
return (
|
||||
@@ -172,7 +177,7 @@ Components are designed to be composable. You can use them as is with the simple
|
||||
```tsx
|
||||
import { ChatSection, ChatMessages, ChatInput } from '@llamaindex/chat-ui'
|
||||
import LlamaCloudSelector from './components/LlamaCloudSelector' // your custom component
|
||||
import { useChat } from 'ai/react'
|
||||
import { useChat } from '@ai-sdk/react'
|
||||
|
||||
const ChatExample = () => {
|
||||
const handler = useChat()
|
||||
@@ -234,7 +239,7 @@ Additionally, you can also override each component's styles by setting custom cl
|
||||
|
||||
```tsx
|
||||
import { ChatSection, ChatMessages, ChatInput } from '@llamaindex/chat-ui'
|
||||
import { useChat } from 'ai/react'
|
||||
import { useChat } from '@ai-sdk/react'
|
||||
|
||||
const ChatExample = () => {
|
||||
const handler = useChat()
|
||||
@@ -269,7 +274,11 @@ import {
|
||||
} from '@llamaindex/chat-ui'
|
||||
|
||||
function CustomChat() {
|
||||
const handler = useChat({ api: '/api/chat' })
|
||||
const handler = useChat({
|
||||
transport: new DefaultChatTransport({
|
||||
api: '/api/chat',
|
||||
}),
|
||||
})
|
||||
|
||||
return (
|
||||
<ChatSection handler={handler} className="flex-row gap-4">
|
||||
@@ -290,7 +299,7 @@ Provide initial context or welcome messages:
|
||||
```tsx
|
||||
const handler = useChat({
|
||||
api: '/api/chat',
|
||||
initialMessages: [
|
||||
messages: [
|
||||
{
|
||||
id: '1',
|
||||
role: 'assistant',
|
||||
@@ -309,7 +318,7 @@ For any language that the LLM generates, you can specify a custom renderer to re
|
||||
Now that you have a basic chat interface running:
|
||||
|
||||
1. **Explore Components** - Learn about [Core Components](./core-components.mdx) for customization
|
||||
2. **Add Rich Content** - Implement [Annotations](./annotations.mdx) for images, files, and sources
|
||||
2. **Add Rich Content** - Implement [Parts](./parts.mdx) for images, files, and sources
|
||||
3. **Enable Artifacts** - Set up [Artifacts](./artifacts.mdx) for interactive code and documents
|
||||
4. **Customize Styling** - Read the [Customization](./customization.mdx) guide for theming
|
||||
|
||||
@@ -323,7 +332,7 @@ Now that you have a basic chat interface running:
|
||||
|
||||
**Build errors**: Check that your bundler supports the package's export conditions.
|
||||
|
||||
**Chat not working**: Verify your API route is returning the correct response format for the Vercel AI SDK.
|
||||
**Chat not working**: Verify your API route is returning the correct response format for the LlamaIndex streaming protocol.
|
||||
|
||||
### Getting Help
|
||||
|
||||
|
||||
+105
-37
@@ -20,9 +20,8 @@ function CustomChatComponent() {
|
||||
input,
|
||||
setInput,
|
||||
isLoading,
|
||||
error,
|
||||
append,
|
||||
reload,
|
||||
sendMessage,
|
||||
regenerate,
|
||||
stop,
|
||||
setMessages,
|
||||
requestData,
|
||||
@@ -30,9 +29,10 @@ function CustomChatComponent() {
|
||||
} = useChatUI()
|
||||
|
||||
const handleSendMessage = async () => {
|
||||
await append({
|
||||
await sendMessage({
|
||||
id: 'user-msg-1',
|
||||
role: 'user',
|
||||
content: input,
|
||||
parts: [{ type: 'text', text: input }],
|
||||
})
|
||||
}
|
||||
|
||||
@@ -48,14 +48,14 @@ function CustomChatComponent() {
|
||||
|
||||
**Returned Properties:**
|
||||
|
||||
- `messages` - Array of chat messages
|
||||
- `messages` - Array of chat messages with parts
|
||||
- `input` - Current input value
|
||||
- `setInput` - Function to update input
|
||||
- `isLoading` - Loading state boolean
|
||||
- `error` - Error object if any
|
||||
- `append` - Function to add message
|
||||
- `reload` - Function to reload last message
|
||||
- `isLoading` - Loading state boolean (computed from status)
|
||||
- `status` - Current chat status ('submitted' | 'streaming' | 'ready' | 'error')
|
||||
- `sendMessage` - Function to send a message
|
||||
- `stop` - Function to stop current generation
|
||||
- `regenerate` - Function to regenerate a message
|
||||
- `setMessages` - Function to update message array
|
||||
- `requestData` - Additional request data
|
||||
- `setRequestData` - Function to update request data
|
||||
@@ -73,11 +73,14 @@ function CustomMessageContent() {
|
||||
return (
|
||||
<div>
|
||||
<p>Role: {message.role}</p>
|
||||
<p>Content: {message.content}</p>
|
||||
<p>Parts: {message.parts.length}</p>
|
||||
<p>Is last message: {isLast ? 'Yes' : 'No'}</p>
|
||||
{message.annotations && (
|
||||
<p>Has annotations: {message.annotations.length}</p>
|
||||
)}
|
||||
|
||||
{message.parts.map((part, index) => (
|
||||
<div key={index}>
|
||||
<p>Part {index + 1}: {part.type}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -85,9 +88,63 @@ function CustomMessageContent() {
|
||||
|
||||
**Returned Properties:**
|
||||
|
||||
- `message` - Current message object
|
||||
- `message` - Current message object with parts array
|
||||
- `isLast` - Boolean indicating if this is the last message
|
||||
|
||||
### usePart
|
||||
|
||||
Access the current message content within part components. This hook provides type-safe access to specific part types in the current message.
|
||||
|
||||
```tsx
|
||||
import { usePart } from '@llamaindex/chat-ui'
|
||||
|
||||
function TextPartComponent() {
|
||||
const textPart = usePart('text')
|
||||
|
||||
if (!textPart) return null
|
||||
|
||||
return <p>{textPart.text}</p>
|
||||
}
|
||||
|
||||
function ArtifactPartComponent() {
|
||||
const artifactPart = usePart('data-artifact')
|
||||
|
||||
if (!artifactPart) return null
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h4>{artifactPart.title}</h4>
|
||||
<p>Type: {artifactPart.data.type}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CustomPartComponent() {
|
||||
const customPart = usePart<CustomPartType>('data-custom-type')
|
||||
|
||||
if (!customPart) return null
|
||||
|
||||
return <CustomRenderer data={customPart.data} />
|
||||
}
|
||||
```
|
||||
|
||||
**Function Overloads:**
|
||||
|
||||
The hook provides automatic type inference for built-in part types:
|
||||
|
||||
- `usePart('text')` → `TextPart | null`
|
||||
- `usePart('data-file')` → `FilePart | null`
|
||||
- `usePart('data-artifact')` → `ArtifactPart | null`
|
||||
- `usePart('data-event')` → `EventPart | null`
|
||||
- `usePart('data-sources')` → `SourcesPart | null`
|
||||
- `usePart('data-suggestion')` → `SuggestionPart | null`
|
||||
|
||||
**Usage Notes:**
|
||||
|
||||
- Must be used within a `ChatPartProvider` context
|
||||
- Returns `null` if the part type doesn't match the current part
|
||||
- For custom part types, use the generic parameter: `usePart<CustomPart>('custom-type')`
|
||||
|
||||
### useChatInput
|
||||
|
||||
Access input form state and handlers.
|
||||
@@ -136,21 +193,34 @@ Access messages list state and handlers.
|
||||
import { useChatMessages } from '@llamaindex/chat-ui'
|
||||
|
||||
function CustomMessageList() {
|
||||
const { messages, isLoading, reload, stop, isEmpty, scrollToBottom } =
|
||||
const { messages, isLoading, regenerate, stop, isEmpty, scrollToBottom } =
|
||||
useChatMessages()
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="messages">
|
||||
{messages.map((msg, i) => (
|
||||
<div key={i}>{msg.content}</div>
|
||||
<div key={i}>
|
||||
<p>Role: {msg.role}</p>
|
||||
<div>
|
||||
{msg.parts.map((part, index) => (
|
||||
<div key={index}>
|
||||
{part.type === 'text' ? (
|
||||
<p>{part.text}</p>
|
||||
) : (
|
||||
<p>{JSON.stringify(part.data)}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{isEmpty && <p>No messages yet</p>}
|
||||
|
||||
<button onClick={reload} disabled={isLoading}>
|
||||
Reload
|
||||
<button onClick={regenerate} disabled={isLoading}>
|
||||
Regenerate
|
||||
</button>
|
||||
<button onClick={stop}>Stop</button>
|
||||
<button onClick={scrollToBottom}>Scroll to Bottom</button>
|
||||
@@ -163,7 +233,7 @@ function CustomMessageList() {
|
||||
|
||||
- `messages` - Array of messages
|
||||
- `isLoading` - Loading state
|
||||
- `reload` - Reload last message
|
||||
- `regenerate` - Regenerate last message
|
||||
- `stop` - Stop generation
|
||||
- `isEmpty` - Boolean if no messages
|
||||
- `scrollToBottom` - Function to scroll to bottom
|
||||
@@ -326,13 +396,11 @@ function useCustomChat() {
|
||||
const messages = useChatMessages()
|
||||
|
||||
const sendMessageWithMetadata = async (content: string, metadata: any) => {
|
||||
await chatUI.append(
|
||||
{
|
||||
role: 'user',
|
||||
content,
|
||||
},
|
||||
{ data: metadata }
|
||||
)
|
||||
await chatUI.sendMessage({
|
||||
id: `user-${Date.now()}`,
|
||||
role: 'user',
|
||||
parts: [{ type: 'text', text: content }]
|
||||
}, { data: metadata })
|
||||
}
|
||||
|
||||
const getLastAssistantMessage = () => {
|
||||
@@ -398,7 +466,7 @@ function SafeHookUsage() {
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Annotations](./annotations.mdx) - Learn how hooks work with annotation data
|
||||
- [Parts](./parts.mdx) - Learn how hooks work with message parts
|
||||
- [Customization](./customization.mdx) - Use hooks for custom styling and behavior
|
||||
- [Examples](./examples.mdx) - See complete examples using hooks
|
||||
- [Core Components](./core-components.mdx) - Understand component-hook relationships
|
||||
@@ -620,10 +688,10 @@ function WorkflowChatApp() {
|
||||
>
|
||||
<ChatMessage.Avatar />
|
||||
<ChatMessage.Content>
|
||||
<ChatMessage.Content.Markdown />
|
||||
<ChatMessage.Content.Source />
|
||||
{/* Custom annotations for UIEvents */}
|
||||
<WeatherAnnotation />
|
||||
<ChatMessage.Part.Markdown />
|
||||
<ChatMessage.Part.Source />
|
||||
{/* Renderer for custom message parts */}
|
||||
<WeatherPart />
|
||||
</ChatMessage.Content>
|
||||
<ChatMessage.Actions />
|
||||
</ChatMessage>
|
||||
@@ -664,7 +732,7 @@ def handle_start_event(ev: StartEvent) -> MyNextEvent:
|
||||
|
||||
#### Built-in Workflow Events
|
||||
|
||||
The hook automatically processes workflow events (using `useWorkflow`) and renders them as annotations in the chat interface, making it easy to build rich conversational experiences with LlamaDeploy workflows.
|
||||
The hook automatically processes workflow events (using `useWorkflow`) and renders them as parts in the chat interface, making it easy to build rich conversational experiences with LlamaDeploy workflows.
|
||||
Your LlamaDeploy workflows can send three main types of events to enhance the chat experience:
|
||||
|
||||
##### 1. SourceNodesEvent - Citations and References
|
||||
@@ -693,7 +761,7 @@ ctx.write_event_to_stream(
|
||||
)
|
||||
```
|
||||
|
||||
> Note: Your `ChatMessage.Content` component needs to have the `ChatMessage.Content.Source` component as a child to display the citations and references (as shown in the example above).
|
||||
> Note: Your `ChatMessage.Content` component needs to have the `ChatMessage.Part.Source` component as a child to display the citations and references (as shown in the example above).
|
||||
|
||||
##### 2. ArtifactEvent - Code and Artifacts
|
||||
|
||||
@@ -720,11 +788,11 @@ ctx.write_event_to_stream(
|
||||
)
|
||||
```
|
||||
|
||||
> Note: Your `ChatMessage.Content` component needs to have the `ChatMessage.Content.Markdown` component as a child to display the artifacts inline in the markdown component (as shown in the example above).
|
||||
> Note: Your `ChatMessage.Content` component needs to have the `ChatMessage.Part.Markdown` component as a child to display the artifacts inline in the markdown component (as shown in the example above).
|
||||
|
||||
##### 3. UIEvent - Custom UI Components
|
||||
|
||||
Send custom data to render it in a specialized UI components. In your workflow code, you can send `UIEvent`s for this - for example to render a weather annotation in the chat interface:
|
||||
Send custom data to render it in a specialized UI components. In your workflow code, you can send `UIEvent`s for this - for example to render a weather part in the chat interface:
|
||||
|
||||
```python
|
||||
from llama_index.core.chat_ui.events import (
|
||||
@@ -752,4 +820,4 @@ ctx.write_event_to_stream(
|
||||
)
|
||||
```
|
||||
|
||||
To render this custom UI component, you need to add it as child to your `ChatMessage.Content` component. The example above will render the [`WeatherAnnotation`](../../examples/llamadeploy/chat/ui/components/custom/custom-weather.tsx) component in the chat interface.
|
||||
To render this custom UI component, you need to add it as child to your `ChatMessage.Content` component. The example above will render the [`WeatherPart`](../../examples/llamadeploy/chat/ui/components/custom/custom-weather.tsx) component in the chat interface.
|
||||
|
||||
@@ -8,7 +8,7 @@ LlamaIndex Chat UI is a comprehensive React component library designed for build
|
||||
## Key Features
|
||||
|
||||
- **Complete Chat Interface** - Full-featured chat components with message history, input, and OpenAI-style canvas
|
||||
- **Rich Annotations** - Support for images, files, sources, events, and custom annotations
|
||||
- **Rich Parts** - Support for images, files, sources, events, and custom parts
|
||||
- **Interactive Artifacts** - Code and document artifacts with editing and version management
|
||||
- **File Upload Support** - Built-in handling for multiple file types (PDF, images, documents)
|
||||
- **Beautiful** - Built on shadcn/ui for beautiful UI
|
||||
@@ -29,7 +29,7 @@ LlamaIndex Chat UI is a comprehensive React component library designed for build
|
||||
The library includes a comprehensive widget system for handling various content types:
|
||||
|
||||
- **Content Widgets** - Markdown, code blocks, image display
|
||||
- **Annotation Widgets** - Sources, events, suggested questions
|
||||
- **Widgets for Part Rendering** - Sources, events, suggested questions
|
||||
- **Interactive Widgets** - File upload, document editing, code editing
|
||||
|
||||
## Getting Started
|
||||
@@ -46,7 +46,7 @@ For more information on configuration, please see detailed guide in [Getting Sta
|
||||
|
||||
```tsx
|
||||
import { ChatSection } from '@llamaindex/chat-ui'
|
||||
import { useChat } from 'ai/react'
|
||||
import { useChat } from '@ai-sdk/react'
|
||||
|
||||
export default function MyChat() {
|
||||
const handler = useChat({
|
||||
@@ -62,7 +62,7 @@ This creates a complete chat interface with:
|
||||
- Message history display
|
||||
- User input with file upload
|
||||
- Loading states and error handling
|
||||
- Support for rich content and annotations
|
||||
- Support for rich content using renderers for message parts
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -71,7 +71,7 @@ The library follows a composable architecture where you can:
|
||||
1. **Use the complete solution** - `ChatSection` provides everything out of the box
|
||||
2. **Compose custom layouts** - Mix and match components for custom designs
|
||||
3. **Extend with widgets** - Add specialized content handling
|
||||
4. **Create custom annotations** - Build domain-specific content types
|
||||
4. **Create custom parts** - Build domain-specific content types
|
||||
|
||||
## Integration
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"core-components",
|
||||
"widgets",
|
||||
"hooks",
|
||||
"annotations",
|
||||
"parts",
|
||||
"artifacts",
|
||||
"customization",
|
||||
"examples"
|
||||
|
||||
@@ -0,0 +1,515 @@
|
||||
---
|
||||
title: Message Parts
|
||||
description: Working with rich content parts for multimedia and interactive chat experiences
|
||||
---
|
||||
|
||||
Message parts are the building blocks for creating rich, interactive chat experiences beyond simple text. They allow you to embed text, files, sources, events, artifacts, and custom content types directly into chat messages as structured components.
|
||||
|
||||
## Parts System Overview
|
||||
|
||||
Chat-UI supports two fundamental types of parts that make up chat messages:
|
||||
|
||||
### 1. Text Parts
|
||||
Text parts contain markdown content that gets rendered as formatted text. They use the `text` type and are the primary way to display textual content.
|
||||
|
||||
### 2. Data Parts
|
||||
Data parts contain structured data for rich interactive components like weather widgets, file attachments, sources, and more.
|
||||
Both built-in and custom parts are both using the `data-` prefix. We're using here the convention from Vercel AI SDK 5 to use the `data-` prefix to detect data parts in messages.
|
||||
|
||||
## Message Structure with Parts
|
||||
|
||||
```typescript
|
||||
interface Message {
|
||||
id: string
|
||||
role: 'user' | 'assistant' | 'system'
|
||||
parts: MessagePart[]
|
||||
}
|
||||
|
||||
// Two types of parts
|
||||
type MessagePart = TextPart | DataPart
|
||||
|
||||
// Text parts for markdown content
|
||||
interface TextPart {
|
||||
type: 'text'
|
||||
text: string
|
||||
}
|
||||
|
||||
// Data parts for rich components
|
||||
interface DataPart {
|
||||
id?: string // if provided, only the last part with same id is kept
|
||||
type: string // should use 'data-' prefix for data parts
|
||||
data?: any
|
||||
}
|
||||
```
|
||||
|
||||
## How Chat-UI Renders Parts
|
||||
|
||||
Parts are automatically rendered when using the `ChatMessage.Content` component. Each part type has a corresponding component that checks if the current part matches its type:
|
||||
|
||||
```tsx
|
||||
<ChatMessage message={message}>
|
||||
<ChatMessage.Content>
|
||||
{/* Built-in part components */}
|
||||
<ChatMessage.Part.Markdown />
|
||||
<ChatMessage.Part.File />
|
||||
<ChatMessage.Part.Event />
|
||||
<ChatMessage.Part.Artifact />
|
||||
<ChatMessage.Part.Source />
|
||||
<ChatMessage.Part.Suggestion />
|
||||
|
||||
{/* Custom part components */}
|
||||
<WeatherPart />
|
||||
<WikiPart />
|
||||
</ChatMessage.Content>
|
||||
</ChatMessage>
|
||||
```
|
||||
|
||||
The rendering system:
|
||||
1. Iterates through each part in `message.parts`
|
||||
2. Provides each part to all child components via `ChatPartProvider`
|
||||
3. Each component uses `usePart(partType)` to check if it should render
|
||||
4. Only the matching component renders, others return `null`
|
||||
|
||||
## Built-in Parts
|
||||
|
||||
Chat-UI provides several built-in part types for common use cases:
|
||||
|
||||
### Text Parts (`text`)
|
||||
Display markdown content with syntax highlighting, links, and formatting.
|
||||
|
||||
```typescript
|
||||
const textPart = {
|
||||
type: 'text',
|
||||
text: `
|
||||
# Heading
|
||||
This is **bold** and *italic* text.
|
||||
|
||||
\`\`\`javascript
|
||||
console.log('Hello, world!')
|
||||
\`\`\`
|
||||
`
|
||||
}
|
||||
```
|
||||
|
||||
### File Parts (`data-file`)
|
||||
Display file attachments with download links and metadata.
|
||||
|
||||
```typescript
|
||||
const filePart = {
|
||||
type: 'data-file',
|
||||
data: {
|
||||
name: 'quarterly-report.pdf',
|
||||
type: 'application/pdf',
|
||||
url: '/files/quarterly-report.pdf',
|
||||
size: 2048576 // bytes
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Source Parts (`data-sources`)
|
||||
Display citations and source references with document grouping.
|
||||
|
||||
```typescript
|
||||
const sourcesPart = {
|
||||
type: 'data-sources',
|
||||
data: {
|
||||
nodes: [
|
||||
{
|
||||
id: 'source1',
|
||||
url: '/documents/research-paper.pdf',
|
||||
metadata: {
|
||||
title: 'Machine Learning in Healthcare',
|
||||
author: 'Dr. Jane Smith',
|
||||
page_number: 15,
|
||||
section: 'Methodology'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Event Parts (`data-event`)
|
||||
Display process events, function calls, and system activities with status updates.
|
||||
|
||||
```typescript
|
||||
const eventPart = {
|
||||
id: 'search_event', // Same ID will update previous event
|
||||
type: 'data-event',
|
||||
data: {
|
||||
title: 'Calling tool `search_database`',
|
||||
status: 'success',
|
||||
data: {
|
||||
query: 'machine learning papers',
|
||||
result: 'Found 8 relevant papers'
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Artifact Parts (`data-artifact`)
|
||||
Create interactive code and document artifacts that users can edit.
|
||||
|
||||
```typescript
|
||||
const artifactPart = {
|
||||
type: 'data-artifact',
|
||||
data: {
|
||||
type: 'code',
|
||||
data: {
|
||||
title: 'Data Analysis Script',
|
||||
file_name: 'analyze_data.py',
|
||||
language: 'python',
|
||||
code: `
|
||||
import pandas as pd
|
||||
import matplotlib.pyplot as plt
|
||||
|
||||
def analyze_sales_data(file_path):
|
||||
df = pd.read_csv(file_path)
|
||||
monthly_sales = df.groupby('month')['sales'].sum()
|
||||
return monthly_sales
|
||||
`
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Suggestion Parts (`data-suggested_questions`)
|
||||
Provide interactive follow-up questions to guide conversation.
|
||||
|
||||
```typescript
|
||||
const suggestionPart = {
|
||||
type: 'data-suggested_questions',
|
||||
data: [
|
||||
'Can you explain the methodology in more detail?',
|
||||
'What are the potential limitations?',
|
||||
'How does this compare to traditional methods?'
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Creating Custom Parts
|
||||
|
||||
Create domain-specific parts for specialized content by implementing a custom render component:
|
||||
|
||||
### 1. Define the Part Type and Data Interface
|
||||
|
||||
```typescript
|
||||
const WeatherPartType = 'data-weather'
|
||||
|
||||
type WeatherData = {
|
||||
location: string
|
||||
temperature: number
|
||||
condition: string
|
||||
humidity: number
|
||||
windSpeed: number
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Create the Component
|
||||
|
||||
```tsx
|
||||
import { usePart } from '@llamaindex/chat-ui'
|
||||
|
||||
export function WeatherPart() {
|
||||
// usePart returns data only if current part matches the type
|
||||
const weatherData = usePart<WeatherData>(WeatherPartType)
|
||||
|
||||
if (!weatherData) return null
|
||||
|
||||
return (
|
||||
<div className="weather-widget">
|
||||
<h3>{weatherData.location}</h3>
|
||||
<div className="temperature">{weatherData.temperature}°C</div>
|
||||
<div className="condition">{weatherData.condition}</div>
|
||||
<div className="details">
|
||||
<span>Humidity: {weatherData.humidity}%</span>
|
||||
<span>Wind: {weatherData.windSpeed} km/h</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Add to Message Rendering
|
||||
|
||||
```tsx
|
||||
<ChatMessage message={message}>
|
||||
<ChatMessage.Content>
|
||||
<ChatMessage.Part.Markdown />
|
||||
<ChatMessage.Part.File />
|
||||
{/* Add your custom component */}
|
||||
<WeatherPart />
|
||||
</ChatMessage.Content>
|
||||
</ChatMessage>
|
||||
```
|
||||
|
||||
## Adding Parts from Backend via SSE Protocol
|
||||
|
||||
Parts are streamed using the **Server-Sent Events (SSE)** protocol, which provides real-time communication between the server and client.
|
||||
Read more about SSE protocol in [Vercel AI SDK 5](https://ai-sdk.dev/docs/migration-guides/migration-guide-5-0#proprietary-protocol---server-sent-events) documentation.
|
||||
|
||||
Here's how the streaming implementation works in the backend:
|
||||
|
||||
### Response Headers
|
||||
|
||||
The server must set specific headers for SSE streaming:
|
||||
|
||||
```typescript
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Connection': 'keep-alive',
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### Stream Format
|
||||
|
||||
Each chunk sent to the client must follow the SSE format with a `data:` prefix:
|
||||
|
||||
```typescript
|
||||
const DATA_PREFIX = 'data: '
|
||||
|
||||
function writeStream(chunk: TextChunk | DataChunk) {
|
||||
controller.enqueue(
|
||||
encoder.encode(`${DATA_PREFIX}${JSON.stringify(chunk)}\n\n`)
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Chunk Types
|
||||
|
||||
The streaming protocol supports two types of chunks:
|
||||
|
||||
#### Text Chunks (for streaming text content)
|
||||
```typescript
|
||||
interface TextChunk {
|
||||
type: 'text-start' | 'text-delta' | 'text-end'
|
||||
id: string
|
||||
delta?: string // only for text-delta
|
||||
}
|
||||
|
||||
// Example sequence:
|
||||
// data: {"type":"text-start","id":"msg-123"}
|
||||
// data: {"type":"text-delta","id":"msg-123","delta":"Hello "}
|
||||
// data: {"type":"text-delta","id":"msg-123","delta":"world!"}
|
||||
// data: {"type":"text-end","id":"msg-123"}
|
||||
```
|
||||
|
||||
#### Data Chunks (for rich components)
|
||||
```typescript
|
||||
interface DataChunk {
|
||||
id?: string // optional - same ID replaces previous parts
|
||||
type: `data-${string}` // requires 'data-' prefix
|
||||
data: Record<string, any>
|
||||
}
|
||||
|
||||
// Example:
|
||||
// data: {"type":"data-weather","data":{"location":"SF","temp":22}}
|
||||
```
|
||||
|
||||
### Implementation Example
|
||||
|
||||
```typescript
|
||||
const fakeChatStream = (parts: (string | MessagePart)[]): ReadableStream => {
|
||||
return new ReadableStream({
|
||||
async start(controller) {
|
||||
const encoder = new TextEncoder()
|
||||
|
||||
function writeStream(chunk: TextChunk | DataChunk) {
|
||||
controller.enqueue(
|
||||
encoder.encode(`${DATA_PREFIX}${JSON.stringify(chunk)}\n\n`)
|
||||
)
|
||||
}
|
||||
|
||||
async function writeText(content: string) {
|
||||
const messageId = crypto.randomUUID()
|
||||
|
||||
// Start text stream
|
||||
writeStream({ id: messageId, type: 'text-start' })
|
||||
|
||||
// Stream tokens
|
||||
for (const token of content.split(' ')) {
|
||||
writeStream({
|
||||
id: messageId,
|
||||
type: 'text-delta',
|
||||
delta: token + ' '
|
||||
})
|
||||
await new Promise(resolve => setTimeout(resolve, 30))
|
||||
}
|
||||
|
||||
// End text stream
|
||||
writeStream({ id: messageId, type: 'text-end' })
|
||||
}
|
||||
|
||||
async function writeData(data: MessagePart) {
|
||||
writeStream({
|
||||
id: data.id,
|
||||
type: `data-${data.type}`,
|
||||
data: data.data
|
||||
})
|
||||
}
|
||||
|
||||
// Stream all parts
|
||||
for (const item of parts) {
|
||||
if (typeof item === 'string') {
|
||||
await writeText(item)
|
||||
} else {
|
||||
await writeData(item)
|
||||
}
|
||||
}
|
||||
|
||||
controller.close()
|
||||
},
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### Important ID Behavior for Data Parts
|
||||
|
||||
When data parts have the same `id`, only the **last** data part with that ID will exist in `message.parts`. This is useful for:
|
||||
|
||||
- **Single data display**: Show only the final result (e.g., hide loading, show final weather data)
|
||||
- **Progressive updates**: Update the same component as new data arrives (e.g., streaming events)
|
||||
|
||||
If you want multiple parts of the same type, **don't provide an ID** or use different IDs.
|
||||
|
||||
Example:
|
||||
|
||||
1. When calling a tool, send an event with tool call information:
|
||||
|
||||
```typescript
|
||||
part1 = {
|
||||
id: 'demo_sample_event_id',
|
||||
type: 'data-event',
|
||||
data: {
|
||||
title: 'Calling tool `get_weather` with input `San Francisco, CA`',
|
||||
status: 'pending',
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
2. When the tool call is completed, send an event with the tool call result. The previous event with the same id will be replaced by the new one.
|
||||
|
||||
```typescript
|
||||
part2 = {
|
||||
id: 'demo_sample_event_id',
|
||||
type: 'data-event',
|
||||
data: {
|
||||
title: 'Calling tool `get_weather` with input `San Francisco, CA`',
|
||||
status: 'pending',
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
When checking `message.parts`, you will only see the last event with the final result.
|
||||
|
||||
|
||||
### Important Notes
|
||||
|
||||
- **SSE Format**: Each message must be prefixed with `data: ` and end with `\n\n`
|
||||
- **JSON Encoding**: All chunks are JSON-encoded objects
|
||||
- **Text Streaming**: Text content requires start/delta/end sequence for proper rendering
|
||||
- **Data Parts**: Must use `data-` prefix in the type field
|
||||
- **ID Behavior**: Same IDs in data parts will replace previous parts with that ID
|
||||
|
||||
|
||||
## Complete Message Example
|
||||
|
||||
```typescript
|
||||
const message = {
|
||||
id: 'msg-123',
|
||||
role: 'assistant',
|
||||
parts: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'I\'ve analyzed your data and here are the results:'
|
||||
},
|
||||
{
|
||||
type: 'data-artifact',
|
||||
data: {
|
||||
type: 'code',
|
||||
data: {
|
||||
title: 'Sales Analysis',
|
||||
file_name: 'analysis.py',
|
||||
language: 'python',
|
||||
code: 'import pandas as pd\n# Analysis code...'
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'data-sources',
|
||||
data: {
|
||||
nodes: [
|
||||
{
|
||||
id: '1',
|
||||
url: '/data/sales.csv',
|
||||
metadata: { title: 'Sales Data Q4 2024' }
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'data-suggested_questions',
|
||||
data: [
|
||||
'Can you explain the quarterly trends?',
|
||||
'What about the seasonal patterns?',
|
||||
'How can we improve performance?'
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Utility Functions
|
||||
|
||||
### usePart Hook
|
||||
|
||||
Extract part data by type within part components:
|
||||
|
||||
```tsx
|
||||
import { usePart } from '@llamaindex/chat-ui'
|
||||
|
||||
function CustomPartComponent() {
|
||||
// Returns data only if current part matches type, null otherwise
|
||||
const weatherData = usePart<WeatherData>('data-weather')
|
||||
const textContent = usePart<string>('text')
|
||||
|
||||
// Component logic...
|
||||
}
|
||||
```
|
||||
|
||||
### getParts Function
|
||||
|
||||
Extract all parts of a specific type from a message:
|
||||
|
||||
```tsx
|
||||
import { getParts } from '@llamaindex/chat-ui'
|
||||
|
||||
// Get all text content from a message
|
||||
const allTextParts = getParts<string>(message, 'text')
|
||||
|
||||
// Get all weather data parts
|
||||
const allWeatherData = getParts<WeatherData>(message, 'data-weather')
|
||||
```
|
||||
|
||||
This function is useful for:
|
||||
- Aggregating data from multiple parts
|
||||
- Building summaries or indexes
|
||||
- Processing historical data
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Use the `data-` prefix** for all custom part types
|
||||
2. **Provide IDs** only when you want parts to replace each other
|
||||
3. **Keep data structures simple** and serializable
|
||||
4. **Handle null cases** in custom components when data doesn't match
|
||||
5. **Mix text and data parts** to create rich, contextual experiences
|
||||
6. **Stream progressively** to improve perceived performance
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Artifacts](./artifacts.mdx) - Learn about interactive code and document artifacts
|
||||
- [Widgets](./widgets.mdx) - Explore widget implementation details
|
||||
- [Examples](./examples.mdx) - See complete implementation examples
|
||||
- [Customization](./customization.mdx) - Style and customize part appearance
|
||||
+41
-103
@@ -3,13 +3,13 @@ title: Widgets
|
||||
description: Comprehensive guide to specialized content widgets for rich chat experiences
|
||||
---
|
||||
|
||||
Widgets are specialized components for displaying and interacting with rich content in chat messages. They provide functionality beyond simple text, enabling multimedia, interactive elements, and custom annotations.
|
||||
Widgets are specialized components for displaying and interacting with rich content in chat messages. They provide functionality beyond simple text, enabling multimedia, interactive elements, and custom parts.
|
||||
|
||||
This section describes how to use them standalone.
|
||||
|
||||
## Content Widgets
|
||||
## ChatUI Widgets
|
||||
|
||||
### Markdown
|
||||
## Markdown
|
||||
|
||||
Renders rich text with LaTeX math support, syntax highlighting, and citations.
|
||||
|
||||
@@ -155,70 +155,26 @@ export default function Home() {
|
||||
- **Image Support** - Embed images
|
||||
- **Live Preview** - Real-time markdown preview
|
||||
|
||||
## Annotation Widgets
|
||||
### ChatFile
|
||||
|
||||
Used for rendering additional rich content in a chat message. See [Annotations](./annotations.mdx) for more information on how to add annotations to a message.
|
||||
|
||||
### ChatImage
|
||||
|
||||
Displays images with preview and zoom functionality.
|
||||
Displays a file attachment like image, pdf, etc.
|
||||
|
||||
```tsx
|
||||
import { ChatImage } from '@llamaindex/chat-ui/widgets'
|
||||
|
||||
function ImageDisplay() {
|
||||
return (
|
||||
<ChatImage
|
||||
data={{
|
||||
url: 'https://example.com/image.jpg',
|
||||
alt: 'Description of the image',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Features:**
|
||||
|
||||
- **Zoom & Pan** - Interactive image viewing
|
||||
- **Lazy Loading** - Performance optimization
|
||||
- **Alt Text** - Accessibility support
|
||||
- **Error Handling** - Graceful fallback for broken images
|
||||
|
||||
### ChatFiles
|
||||
|
||||
Displays file attachments with download and preview.
|
||||
|
||||
```tsx
|
||||
import { ChatFiles } from '@llamaindex/chat-ui/widgets'
|
||||
import { ChatFile } from '@llamaindex/chat-ui/widgets'
|
||||
|
||||
function FileDisplay() {
|
||||
return (
|
||||
<ChatFiles
|
||||
data={{
|
||||
files: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'report.pdf',
|
||||
type: 'application/pdf',
|
||||
url: '/files/report.pdf',
|
||||
size: 1024000,
|
||||
},
|
||||
],
|
||||
<ChatFile
|
||||
file={{
|
||||
filename: 'upload.pdf',
|
||||
mediaType: 'application/pdf',
|
||||
url: 'https://pdfobject.com/pdf/sample.pdf',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Supported File Types:**
|
||||
|
||||
- **PDF** - Inline viewer
|
||||
- **Images** - Thumbnail preview
|
||||
- **Text Files** - Content preview
|
||||
- **CSV** - Data table preview
|
||||
- **Word Documents** - Document preview
|
||||
|
||||
### ChatSources
|
||||
|
||||
Displays source citations with document grouping.
|
||||
@@ -253,52 +209,36 @@ function SourceDisplay() {
|
||||
- **Click to View** - Opens source documents
|
||||
- **Metadata Display** - Shows title, author, date
|
||||
|
||||
### ChatEvents
|
||||
### ChatEvent
|
||||
|
||||
Displays collapsible process events and status updates.
|
||||
Displays collapsible process event with status updates.
|
||||
|
||||
```tsx
|
||||
import { ChatEvents } from '@llamaindex/chat-ui/widgets'
|
||||
import { ChatEvent } from '@llamaindex/chat-ui/widgets'
|
||||
|
||||
function EventDisplay() {
|
||||
// When Event with loading status
|
||||
function SearchDocumentsEvent() {
|
||||
return (
|
||||
<ChatEvents
|
||||
data={[
|
||||
{
|
||||
type: 'function_call',
|
||||
name: 'search_documents',
|
||||
args: { query: 'machine learning' },
|
||||
result: 'Found 15 relevant documents',
|
||||
},
|
||||
]}
|
||||
showLoading={false}
|
||||
<ChatEvent
|
||||
event={{
|
||||
title: 'Searching documents',
|
||||
description: 'Searching documents for machine learning',
|
||||
status: 'pending',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### ChatAgentEvents
|
||||
|
||||
Displays agent-specific events with progress tracking.
|
||||
|
||||
```tsx
|
||||
import { ChatAgentEvents } from '@llamaindex/chat-ui/widgets'
|
||||
|
||||
function AgentEventDisplay() {
|
||||
// When Event with success status
|
||||
function SearchDocumentsResult() {
|
||||
return (
|
||||
<ChatAgentEvents
|
||||
data={{
|
||||
agent_name: 'Research Assistant',
|
||||
progress: 75,
|
||||
current_step: 'Analyzing documents',
|
||||
events: [
|
||||
{ step: 'Searching', status: 'completed' },
|
||||
{ step: 'Analyzing', status: 'in_progress' },
|
||||
{ step: 'Summarizing', status: 'pending' },
|
||||
],
|
||||
<ChatEvent
|
||||
event={{
|
||||
title: 'Searching documents',
|
||||
description: 'Searching documents for machine learning',
|
||||
status: 'success',
|
||||
data: 'Document content...',
|
||||
}}
|
||||
isFinished={true}
|
||||
isLast={false}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -311,7 +251,7 @@ Interactive follow-up question suggestions.
|
||||
```tsx
|
||||
import { SuggestedQuestions } from '@llamaindex/chat-ui/widgets'
|
||||
|
||||
function QuestionSuggestions({ append, requestData }) {
|
||||
function QuestionSuggestions({ regenerate, requestData }) {
|
||||
return (
|
||||
<SuggestedQuestions
|
||||
questions={[
|
||||
@@ -319,7 +259,7 @@ function QuestionSuggestions({ append, requestData }) {
|
||||
'What are the practical applications?',
|
||||
'How does this compare to other approaches?',
|
||||
]}
|
||||
append={append}
|
||||
regenerate={regenerate}
|
||||
requestData={requestData}
|
||||
/>
|
||||
)
|
||||
@@ -350,8 +290,6 @@ function ChatStarters() {
|
||||
}
|
||||
```
|
||||
|
||||
## Utility Widgets
|
||||
|
||||
### FileUploader
|
||||
|
||||
Drag-and-drop file upload with validation.
|
||||
@@ -427,7 +365,7 @@ function DocInfo({ document }) {
|
||||
}
|
||||
```
|
||||
|
||||
### Citation
|
||||
## Citation
|
||||
|
||||
Individual citation component with linking.
|
||||
|
||||
@@ -451,7 +389,7 @@ function CitationLink({ source, index }) {
|
||||
|
||||
### Automatic Rendering
|
||||
|
||||
Annotation widgets render based on message annotations through dedicated annotation components:
|
||||
Other widgets render based on message parts through dedicated components:
|
||||
|
||||
```tsx
|
||||
import { ChatMessage } from '@llamaindex/chat-ui'
|
||||
@@ -460,17 +398,17 @@ function MessageWithWidgets({ message }) {
|
||||
return (
|
||||
<ChatMessage message={message}>
|
||||
<ChatMessage.Content>
|
||||
<ChatMessage.Content.Markdown />
|
||||
<ChatMessage.Content.Image /> {/* Renders ChatImage */}
|
||||
<ChatMessage.Content.Source /> {/* Renders ChatSources */}
|
||||
<ChatMessage.Content.Event /> {/* Renders ChatEvents */}
|
||||
<ChatMessage.Part.Markdown />
|
||||
<ChatMessage.Part.Image /> {/* Renders ChatImage */}
|
||||
<ChatMessage.Part.Source /> {/* Renders ChatSources */}
|
||||
<ChatMessage.Part.Event /> {/* Renders ChatEvents */}
|
||||
</ChatMessage.Content>
|
||||
</ChatMessage>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
The `ChatMessage.Content.*` components internally use the annotation pattern described in [Annotations](./annotations.mdx), extracting data with `getAnnotationData` and passing it to the respective widgets.
|
||||
The `ChatMessage.Part.*` components internally use the parts pattern described in [Parts](./parts.mdx), extracting data with `usePart` and passing it to the respective widgets.
|
||||
|
||||
### Manual Widget Usage
|
||||
|
||||
@@ -506,7 +444,7 @@ function CustomMessageLayout({ message }) {
|
||||
Create custom widgets by following this pattern:
|
||||
|
||||
```tsx
|
||||
interface WeatherData {
|
||||
type WeatherData = {
|
||||
location: string
|
||||
temperature: number
|
||||
condition: string
|
||||
@@ -542,7 +480,7 @@ function App() {
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Annotations](./annotations.mdx) - Learn how to create and send annotation data
|
||||
- [Parts](./parts.mdx) - Learn how to create and send parts
|
||||
- [Artifacts](./artifacts.mdx) - Implement interactive code and document artifacts
|
||||
- [Hooks](./hooks.mdx) - Understand the widget hook system
|
||||
- [Customization](./customization.mdx) - Style and customize widget appearance
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from app.vercel import VercelStreamResponse
|
||||
from .vercel import SSEStreamResponse, get_text
|
||||
|
||||
router = APIRouter(prefix="/chat")
|
||||
|
||||
@@ -10,49 +9,92 @@ router = APIRouter(prefix="/chat")
|
||||
async def chat(request: Request) -> StreamingResponse:
|
||||
data = await request.json()
|
||||
messages = data.get("messages", [])
|
||||
last_message = messages[-1] if messages else {"content": ""}
|
||||
last_message = messages[-1] if messages else {}
|
||||
content = get_text(last_message)
|
||||
|
||||
query_text = f'User query: "{last_message.get("content", "")}".\\n'
|
||||
query_text = f'User query: "{content}".\n'
|
||||
|
||||
sample_text = """
|
||||
Welcome to the demo of @llamaindex/chat-ui. Let me show you the different types of components that can be triggered from the server.
|
||||
# Advanced sample parts matching the Next.js advanced route
|
||||
sample_parts = [
|
||||
"Welcome to the demo of @llamaindex/chat-ui. Let me show you the different types of components that can be triggered from the server.",
|
||||
|
||||
"""
|
||||
### Text Part
|
||||
Text part is used to display text in the chat. It is in markdown format.
|
||||
You can use markdown syntax to format the text. Some examples:
|
||||
|
||||
### Markdown with code block
|
||||
- **bold** -> this is bold text
|
||||
- *italic* -> this is italic text
|
||||
- [link](https://www.google.com) -> this is a link
|
||||
|
||||
You can also display a code block inside markdown.
|
||||
|
||||
```js
|
||||
const a = 1
|
||||
const b = 2
|
||||
const c = a + b
|
||||
console.log(c)
|
||||
```
|
||||
```""",
|
||||
|
||||
### Annotations
|
||||
"""
|
||||
### Parts
|
||||
|
||||
"""
|
||||
|
||||
text_tokens = sample_text.split(' ')
|
||||
|
||||
sample_annotations = [
|
||||
Beside text, you can also display parts in the chat. Parts can be displayed before or after the text.
|
||||
|
||||
**Built-in parts**
|
||||
|
||||
@llamaindex/chat-ui provides some built-in parts for you to use
|
||||
|
||||
- **file** -> display a file with name and url
|
||||
- **event** -> display a event with title, status, and data
|
||||
- **artifact** -> display a code artifact
|
||||
- **sources** -> display a list of sources
|
||||
- **suggested_questions** -> display a list of suggested questions
|
||||
|
||||
**Custom parts**
|
||||
|
||||
You can also create your own custom parts.
|
||||
|
||||
- **weather** -> display a weather card
|
||||
- **wiki** -> display a wiki card
|
||||
""",
|
||||
|
||||
"**file**: Here is the demo of a file part",
|
||||
{
|
||||
"type": "sources",
|
||||
"type": "file",
|
||||
"data": {
|
||||
"nodes": [
|
||||
{"id": "1", "url": "/sample.pdf"},
|
||||
{"id": "2", "url": "/sample.pdf"},
|
||||
],
|
||||
},
|
||||
"filename": "upload.pdf",
|
||||
"mediaType": "application/pdf",
|
||||
"url": "https://pdfobject.com/pdf/sample.pdf"
|
||||
}
|
||||
},
|
||||
|
||||
"**event**: Here is the demo of event parts. The second event part will override the first one because they have the same id",
|
||||
{
|
||||
"id": "demo_sample_event_id",
|
||||
"type": "event",
|
||||
"data": {
|
||||
"title": "Calling tool `get_weather` with input `San Francisco, CA`",
|
||||
"status": "pending"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "artifact",
|
||||
"id": "demo_sample_event_id", # Same id to override previous part
|
||||
"type": "event",
|
||||
"data": {
|
||||
"type": "code",
|
||||
"title": "Got response from tool `get_weather` with input `San Francisco, CA`",
|
||||
"status": "success",
|
||||
"data": {
|
||||
"file_name": "sample.ts",
|
||||
"language": "typescript",
|
||||
"code": 'console.log("Hello, world!");',
|
||||
},
|
||||
},
|
||||
"location": "San Francisco, CA",
|
||||
"temperature": 22,
|
||||
"condition": "sunny",
|
||||
"humidity": 65,
|
||||
"windSpeed": 12
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"**weather**: Here is the demo of a weather part. It is a custom part",
|
||||
{
|
||||
"type": "weather",
|
||||
"data": {
|
||||
@@ -60,15 +102,55 @@ console.log(c)
|
||||
"temperature": 22,
|
||||
"condition": "sunny",
|
||||
"humidity": 65,
|
||||
"windSpeed": 12,
|
||||
},
|
||||
"windSpeed": 12
|
||||
}
|
||||
},
|
||||
]
|
||||
|
||||
events = [
|
||||
query_text,
|
||||
*[f"{token} " for token in text_tokens],
|
||||
*sample_annotations,
|
||||
|
||||
"**wiki**: Here is the demo of a wiki part",
|
||||
{
|
||||
"type": "wiki",
|
||||
"data": {
|
||||
"title": "LlamaIndex",
|
||||
"summary": "LlamaIndex is a framework for building AI applications.",
|
||||
"url": "https://www.llamaindex.ai",
|
||||
"category": "AI",
|
||||
"lastUpdated": "2025-06-02"
|
||||
}
|
||||
},
|
||||
|
||||
"**artifact**: Here is the demo of a artifact part",
|
||||
{
|
||||
"type": "artifact",
|
||||
"data": {
|
||||
"type": "code",
|
||||
"data": {
|
||||
"file_name": "code.py",
|
||||
"code": 'print("Hello, world!")',
|
||||
"language": "python"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"**sources**: Here is the demo of a sources part",
|
||||
{
|
||||
"type": "sources",
|
||||
"data": {
|
||||
"nodes": [
|
||||
{"id": "1", "url": "/sample.pdf"},
|
||||
{"id": "2", "url": "/sample.pdf"}
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
"**suggested_questions**: Here is the demo of a suggested_questions part",
|
||||
{
|
||||
"type": "suggested_questions",
|
||||
"data": [
|
||||
"I think you should go to the beach",
|
||||
"I think you should go to the mountains",
|
||||
"I think you should go to the city"
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
return VercelStreamResponse(events=events)
|
||||
return SSEStreamResponse(parts=sample_parts, query=query_text)
|
||||
|
||||
@@ -1,58 +1,86 @@
|
||||
import asyncio
|
||||
import json
|
||||
from typing import Any, AsyncGenerator, Iterable, Union
|
||||
|
||||
import uuid
|
||||
from typing import Any, AsyncGenerator, Dict, Union
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
DATA_PREFIX = "data: "
|
||||
TOKEN_DELAY = 0.03 # 30ms delay between tokens
|
||||
PART_DELAY = 1.0 # 1s delay between parts
|
||||
|
||||
class VercelStreamResponse(StreamingResponse):
|
||||
|
||||
class SSEStreamResponse(StreamingResponse):
|
||||
"""
|
||||
Converts preprocessed events into Vercel-compatible streaming response format.
|
||||
New SSE format compatible with Vercel/AI SDK 5 useChat
|
||||
"""
|
||||
|
||||
TEXT_PREFIX = "0:"
|
||||
DATA_PREFIX = "8:"
|
||||
ERROR_PREFIX = "3:"
|
||||
def __init__(self, parts: list[Union[str, Dict[str, Any]]], query: str = "", **kwargs):
|
||||
stream = self._create_stream(query, parts)
|
||||
super().__init__(
|
||||
stream,
|
||||
media_type="text/event-stream",
|
||||
headers={"Connection": "keep-alive"},
|
||||
**kwargs
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
events: Iterable[Any],
|
||||
*args: Any,
|
||||
**kwargs: Any,
|
||||
):
|
||||
stream = self._stream_event(events=events)
|
||||
super().__init__(stream, *args, **kwargs)
|
||||
async def _create_stream(self, query: str, parts: list[Union[str, Dict[str, Any]]]) -> AsyncGenerator[str, None]:
|
||||
"""Create SSE stream with new format"""
|
||||
|
||||
async def _stream_event(self, events: Iterable[Any]) -> AsyncGenerator[str, None]:
|
||||
stream_started = False
|
||||
for event in events:
|
||||
if not stream_started:
|
||||
yield self.convert_text("")
|
||||
stream_started = True
|
||||
# Simulate a small delay between events
|
||||
await asyncio.sleep(0.1)
|
||||
if isinstance(event, str):
|
||||
yield self.convert_text(event)
|
||||
elif isinstance(event, dict):
|
||||
yield self.convert_data(event)
|
||||
else:
|
||||
raise ValueError(f"Unknown event type: {type(event)}")
|
||||
async def write_text(content: str) -> AsyncGenerator[str, None]:
|
||||
"""Write text content with token-by-token streaming"""
|
||||
# Generate unique message id
|
||||
message_id = str(uuid.uuid4())
|
||||
|
||||
@classmethod
|
||||
def convert_text(cls, token: str) -> str:
|
||||
"""Convert text event to Vercel format."""
|
||||
# Escape newlines and double quotes to avoid breaking the stream
|
||||
token = json.dumps(token)
|
||||
return f"{cls.TEXT_PREFIX}{token}\n"
|
||||
# Start text chunk
|
||||
start_chunk = {"id": message_id, "type": "text-start"}
|
||||
yield f"{DATA_PREFIX}{json.dumps(start_chunk)}\n\n"
|
||||
|
||||
@classmethod
|
||||
def convert_data(cls, data: Union[dict, str]) -> str:
|
||||
"""Convert data event to Vercel format."""
|
||||
data_str = json.dumps(data) if isinstance(data, dict) else data
|
||||
return f"{cls.DATA_PREFIX}[{data_str}]\n"
|
||||
# Stream tokens
|
||||
for token in content.split(' '):
|
||||
if token: # Skip empty tokens
|
||||
delta_chunk = {
|
||||
"id": message_id,
|
||||
"type": "text-delta",
|
||||
"delta": token + " "
|
||||
}
|
||||
yield f"{DATA_PREFIX}{json.dumps(delta_chunk)}\n\n"
|
||||
await asyncio.sleep(TOKEN_DELAY)
|
||||
|
||||
@classmethod
|
||||
def convert_error(cls, error: str) -> str:
|
||||
"""Convert error event to Vercel format."""
|
||||
error_str = json.dumps(error)
|
||||
return f"{cls.ERROR_PREFIX}{error_str}\n"
|
||||
# End text chunk
|
||||
end_chunk = {"id": message_id, "type": "text-end"}
|
||||
yield f"{DATA_PREFIX}{json.dumps(end_chunk)}\n\n"
|
||||
|
||||
async def write_data(data: Dict[str, Any]) -> AsyncGenerator[str, None]:
|
||||
"""Write data part"""
|
||||
chunk = {
|
||||
"type": f"data-{data['type']}", # Add data- prefix
|
||||
"data": data.get("data", {})
|
||||
}
|
||||
|
||||
# Only include id if it exists
|
||||
if data.get("id"):
|
||||
chunk["id"] = data["id"]
|
||||
|
||||
yield f"{DATA_PREFIX}{json.dumps(chunk)}\n\n"
|
||||
await asyncio.sleep(PART_DELAY)
|
||||
|
||||
# Stream the query first
|
||||
if query:
|
||||
async for chunk in write_text(query):
|
||||
yield chunk
|
||||
|
||||
# Stream all parts
|
||||
for item in parts:
|
||||
if isinstance(item, str):
|
||||
async for chunk in write_text(item):
|
||||
yield chunk
|
||||
elif isinstance(item, dict):
|
||||
async for chunk in write_data(item):
|
||||
yield chunk
|
||||
|
||||
def get_text(message: Any) -> str:
|
||||
return "\n\n".join(
|
||||
part["text"]
|
||||
for part in message["parts"]
|
||||
if part.get("type") == "text" and "text" in part
|
||||
)
|
||||
@@ -244,116 +244,5 @@
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground antialiased;
|
||||
font-feature-settings: 'cv11', 'ss01';
|
||||
font-variation-settings: 'opsz' 32;
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
/* Selection styling */
|
||||
::selection {
|
||||
background: rgba(186, 186, 233, 0.3);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
::-moz-selection {
|
||||
background: rgba(186, 186, 233, 0.3);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/* Utility classes for animations */
|
||||
.animate-fade-in {
|
||||
animation: var(--animate-fade-in);
|
||||
}
|
||||
|
||||
.animate-fade-in-up {
|
||||
animation: var(--animate-fade-in-up);
|
||||
}
|
||||
|
||||
.animate-scale-in {
|
||||
animation: var(--animate-scale-in);
|
||||
}
|
||||
|
||||
.animate-slide-in-right {
|
||||
animation: var(--animate-slide-in-right);
|
||||
}
|
||||
|
||||
.animate-pulse-glow {
|
||||
animation: var(--animate-pulse-glow);
|
||||
}
|
||||
|
||||
.animate-float {
|
||||
animation: var(--animate-float);
|
||||
}
|
||||
|
||||
/* Glass morphism utilities */
|
||||
.glass {
|
||||
background: var(--glass-gradient);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.glass-border {
|
||||
border: 1px solid;
|
||||
border-image: var(--glass-border) 1;
|
||||
}
|
||||
|
||||
/* Shimmer effect */
|
||||
.shimmer {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(255, 255, 255, 0) 0%,
|
||||
rgba(255, 255, 255, 0.1) 50%,
|
||||
rgba(255, 255, 255, 0) 100%
|
||||
);
|
||||
background-size: 1000px 100%;
|
||||
animation: var(--animate-shimmer);
|
||||
}
|
||||
|
||||
/* Text gradient utilities */
|
||||
.text-gradient-purple {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.text-gradient-rainbow {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
#ff006e,
|
||||
#8338ec,
|
||||
#3a86ff,
|
||||
#06ffa5,
|
||||
#ffbe0b,
|
||||
#fb5607
|
||||
);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
background-size: 300% 100%;
|
||||
animation: var(--animate-shimmer);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,8 +8,8 @@ import { Inter } from 'next/font/google'
|
||||
const inter = Inter({ subsets: ['latin'] })
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'LlamaIndex Chat UI - Next.js Example',
|
||||
description: 'A simple Next.js application using @llamaindex/chat-ui',
|
||||
title: 'LlamaIndex Chat UI - FastAPI Example',
|
||||
description: 'A simple interface using @llamaindex/chat-ui',
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
|
||||
@@ -8,14 +8,21 @@ import {
|
||||
ChatSection,
|
||||
useChatUI,
|
||||
} from '@llamaindex/chat-ui'
|
||||
import { Message, useChat } from 'ai/react'
|
||||
import { CustomWeatherAnnotation } from '../components/custom-weather-annotation'
|
||||
import { UIMessage, useChat } from '@ai-sdk/react'
|
||||
import { WeatherPart } from '../components/custom-weather'
|
||||
import { DefaultChatTransport } from 'ai'
|
||||
import { WikiPart } from '../components/custom-wiki'
|
||||
|
||||
const initialMessages: Message[] = [
|
||||
const initialMessages: UIMessage[] = [
|
||||
{
|
||||
id: '1',
|
||||
content: 'Hello! How can I help you today?',
|
||||
role: 'assistant',
|
||||
parts: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Hello! How can I help you today?',
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -39,8 +46,10 @@ export default function Page(): JSX.Element {
|
||||
|
||||
function ChatExample() {
|
||||
const handler = useChat({
|
||||
api: 'http://localhost:8000/api/chat',
|
||||
initialMessages,
|
||||
transport: new DefaultChatTransport({
|
||||
api: 'http://localhost:8000/api/chat',
|
||||
}),
|
||||
messages: initialMessages,
|
||||
})
|
||||
|
||||
return (
|
||||
@@ -69,7 +78,7 @@ function ChatExample() {
|
||||
}
|
||||
|
||||
function CustomChatMessages() {
|
||||
const { messages, isLoading, append } = useChatUI()
|
||||
const { messages } = useChatUI()
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -85,10 +94,15 @@ function CustomChatMessages() {
|
||||
{message.role === 'user' ? 'U' : 'AI'}
|
||||
</div>
|
||||
</ChatMessage.Avatar>
|
||||
<ChatMessage.Content isLoading={isLoading} append={append}>
|
||||
<ChatMessage.Content.Markdown />
|
||||
<ChatMessage.Content.Source />
|
||||
<CustomWeatherAnnotation />
|
||||
<ChatMessage.Content>
|
||||
<ChatMessage.Part.File />
|
||||
<ChatMessage.Part.Event />
|
||||
<ChatMessage.Part.Markdown />
|
||||
<ChatMessage.Part.Artifact />
|
||||
<ChatMessage.Part.Source />
|
||||
<ChatMessage.Part.Suggestion />
|
||||
<WikiPart />
|
||||
<WeatherPart />
|
||||
</ChatMessage.Content>
|
||||
<ChatMessage.Actions />
|
||||
</ChatMessage>
|
||||
|
||||
+25
-23
@@ -1,8 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import { useChatMessage, getAnnotationData } from '@llamaindex/chat-ui'
|
||||
import { usePart } from '@llamaindex/chat-ui'
|
||||
|
||||
interface WeatherData {
|
||||
type WeatherData = {
|
||||
location: string
|
||||
temperature: number
|
||||
condition: string
|
||||
@@ -10,20 +10,36 @@ interface WeatherData {
|
||||
windSpeed: number
|
||||
}
|
||||
|
||||
export function CustomWeatherAnnotation() {
|
||||
const { message } = useChatMessage()
|
||||
const WeatherPartType = 'data-weather'
|
||||
|
||||
const weatherData = getAnnotationData<WeatherData>(message, 'weather')
|
||||
type WeatherPart = {
|
||||
type: typeof WeatherPartType
|
||||
data: WeatherData
|
||||
}
|
||||
|
||||
if (weatherData.length === 0) return null
|
||||
// A custom part component that is used to display weather information in a chat message
|
||||
export function WeatherPart() {
|
||||
const weatherData = usePart<WeatherPart>(WeatherPartType)?.data
|
||||
if (!weatherData) return null
|
||||
return <WeatherCard data={weatherData} />
|
||||
}
|
||||
|
||||
const data = weatherData[0]
|
||||
function WeatherCard({ data }: { data: WeatherData }) {
|
||||
const iconMap: Record<string, string> = {
|
||||
sunny: '☀️',
|
||||
cloudy: '☁️',
|
||||
rainy: '🌧️',
|
||||
snowy: '❄️',
|
||||
stormy: '⛈️',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="my-4 rounded-lg border border-blue-200 bg-blue-50 p-4">
|
||||
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-blue-100">
|
||||
<WeatherIcon condition={data.condition} />
|
||||
<span className="text-2xl">
|
||||
{iconMap[data.condition.toLowerCase()] || '🌤️'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-blue-900">{data.location}</h3>
|
||||
@@ -46,17 +62,3 @@ export function CustomWeatherAnnotation() {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function WeatherIcon({ condition }: { condition: string }) {
|
||||
const iconMap: Record<string, string> = {
|
||||
sunny: '☀️',
|
||||
cloudy: '☁️',
|
||||
rainy: '🌧️',
|
||||
snowy: '❄️',
|
||||
stormy: '⛈️',
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="text-2xl">{iconMap[condition.toLowerCase()] || '🌤️'}</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
'use client'
|
||||
|
||||
import { usePart } from '@llamaindex/chat-ui'
|
||||
|
||||
type WikiData = {
|
||||
title: string
|
||||
summary: string
|
||||
url: string
|
||||
category: string
|
||||
lastUpdated: string
|
||||
}
|
||||
|
||||
const WikiPartType = 'data-wiki'
|
||||
|
||||
type WikiPart = {
|
||||
type: typeof WikiPartType
|
||||
data: WikiData
|
||||
}
|
||||
|
||||
export function WikiPart() {
|
||||
const wikiData = usePart<WikiPart>(WikiPartType)?.data
|
||||
if (!wikiData) return null
|
||||
return <WikiCard data={wikiData} />
|
||||
}
|
||||
|
||||
// A UI widget that displays wiki information, it can be used inline with markdown text
|
||||
function WikiCard({ data }: { data: WikiData }) {
|
||||
const iconMap: Record<string, string> = {
|
||||
science: '🧪',
|
||||
history: '📜',
|
||||
technology: '💻',
|
||||
biology: '🧬',
|
||||
geography: '🌍',
|
||||
literature: '📚',
|
||||
art: '🎨',
|
||||
music: '🎵',
|
||||
}
|
||||
|
||||
const getCategoryColor = (category: string) => {
|
||||
const colors: Record<string, string> = {
|
||||
science: 'from-blue-50 to-blue-100 border-blue-200 text-blue-900',
|
||||
history: 'from-amber-50 to-amber-100 border-amber-200 text-amber-900',
|
||||
technology:
|
||||
'from-purple-50 to-purple-100 border-purple-200 text-purple-900',
|
||||
biology: 'from-green-50 to-green-100 border-green-200 text-green-900',
|
||||
geography: 'from-teal-50 to-teal-100 border-teal-200 text-teal-900',
|
||||
literature:
|
||||
'from-indigo-50 to-indigo-100 border-indigo-200 text-indigo-900',
|
||||
art: 'from-pink-50 to-pink-100 border-pink-200 text-pink-900',
|
||||
music: 'from-violet-50 to-violet-100 border-violet-200 text-violet-900',
|
||||
}
|
||||
return (
|
||||
colors[category.toLowerCase()] ||
|
||||
'from-gray-50 to-gray-100 border-gray-200 text-gray-900'
|
||||
)
|
||||
}
|
||||
|
||||
const categoryColorClass = getCategoryColor(data.category)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`my-6 rounded-xl border bg-gradient-to-br p-6 shadow-sm transition-all duration-200 hover:shadow-md ${categoryColorClass}`}
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex h-14 w-14 flex-shrink-0 items-center justify-center rounded-xl bg-white/50 shadow-sm backdrop-blur-sm">
|
||||
<span className="text-3xl">
|
||||
{iconMap[data.category.toLowerCase()] || '📖'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="mb-2 text-xl font-bold leading-tight">{data.title}</h3>
|
||||
<p className="mb-4 text-base leading-relaxed opacity-80">
|
||||
{data.summary}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap items-center gap-4 text-sm opacity-70">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">📂</span>
|
||||
<span className="capitalize">{data.category}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">📅</span>
|
||||
<span>{data.lastUpdated}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-current/10 mt-4 border-t pt-4">
|
||||
<a
|
||||
href={data.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-white/30 px-4 py-2 text-sm font-medium transition-all duration-200 hover:bg-white/50 hover:shadow-sm"
|
||||
>
|
||||
📖 Read full article
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -11,7 +11,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@llamaindex/chat-ui": "latest",
|
||||
"ai": "^4.3.16",
|
||||
"@ai-sdk/react": "^2.0.4",
|
||||
"@ai-sdk/rsc": "^1.0.4",
|
||||
"ai": "^5.0.4",
|
||||
"next": "^15.3.2",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0"
|
||||
@@ -29,4 +31,4 @@
|
||||
"tailwindcss": "^4.0.7",
|
||||
"typescript": "^5.3.3"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
useChatUI,
|
||||
useChatWorkflow,
|
||||
} from '@llamaindex/chat-ui'
|
||||
import { WeatherAnnotation } from '@/components/custom/custom-weather'
|
||||
import { WeatherPart } from '@/components/custom/custom-weather'
|
||||
import { CLIHumanInput } from '@/components/custom/human-input'
|
||||
import {
|
||||
Select,
|
||||
@@ -105,9 +105,9 @@ function CustomChatMessages({
|
||||
<ChatMessage.Avatar />
|
||||
<ChatMessage.Content isLoading={isLoading} append={append}>
|
||||
<CLIHumanInput resumeWorkflow={resumeWorkflow} />
|
||||
<ChatMessage.Content.Markdown />
|
||||
<WeatherAnnotation />
|
||||
<ChatMessage.Content.Source />
|
||||
<ChatMessage.Part.Markdown />
|
||||
<WeatherPart />
|
||||
<ChatMessage.Part.Source />
|
||||
</ChatMessage.Content>
|
||||
<ChatMessage.Actions />
|
||||
</ChatMessage>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import { useChatMessage, getAnnotationData } from '@llamaindex/chat-ui'
|
||||
import { usePart } from '@llamaindex/chat-ui'
|
||||
|
||||
interface WeatherData {
|
||||
type WeatherData = {
|
||||
location: string
|
||||
temperature: number
|
||||
condition: string
|
||||
@@ -10,14 +10,18 @@ interface WeatherData {
|
||||
windSpeed: number
|
||||
}
|
||||
|
||||
// A custom annotation component that is used to display weather information in a chat message
|
||||
// The weather data is extracted from annotations in the message that has type 'weather'
|
||||
export function WeatherAnnotation() {
|
||||
const { message } = useChatMessage()
|
||||
const weatherData = getAnnotationData<WeatherData>(message, 'weather')
|
||||
const WeatherPartType = 'data-weather'
|
||||
|
||||
if (weatherData.length === 0) return null
|
||||
return <WeatherCard data={weatherData[0]} />
|
||||
type WeatherPart = {
|
||||
type: typeof WeatherPartType
|
||||
data: WeatherData
|
||||
}
|
||||
|
||||
// A custom part component that is used to display weather information in a chat message
|
||||
export function WeatherPart() {
|
||||
const weatherData = usePart<WeatherPart>(WeatherPartType)?.data
|
||||
if (!weatherData) return null
|
||||
return <WeatherCard data={weatherData} />
|
||||
}
|
||||
|
||||
function WeatherCard({ data }: { data: WeatherData }) {
|
||||
@@ -29,13 +33,13 @@ function WeatherCard({ data }: { data: WeatherData }) {
|
||||
stormy: '⛈️',
|
||||
}
|
||||
|
||||
if (!data.location) return null
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-blue-100">
|
||||
<span className="text-2xl">{iconMap[data.condition] || '🌤️'}</span>
|
||||
<span className="text-2xl">
|
||||
{iconMap[data.condition.toLowerCase()] || '🌤️'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-blue-900">{data.location}</h3>
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
"dependencies": {
|
||||
"@llamaindex/chat-ui": "latest",
|
||||
"@radix-ui/react-select": "^2.1.1",
|
||||
"ai": "^4.3.16",
|
||||
"@ai-sdk/react": "^2.0.4",
|
||||
"ai": "^5.0.4",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.453.0",
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@llamaindex/chat-ui": "latest",
|
||||
"ai": "^4.3.16",
|
||||
"@ai-sdk/react": "^2.0.4",
|
||||
"ai": "^5.0.4",
|
||||
"next": "^15.3.2",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0"
|
||||
|
||||
@@ -3,47 +3,27 @@
|
||||
*
|
||||
* This example demonstrates advanced streaming features:
|
||||
* - Text streaming with token-by-token delivery
|
||||
* - Both standard annotations (sent after text) and inline annotations (embedded in text)
|
||||
* - Inline annotations are embedded as special code blocks within the markdown stream
|
||||
* - Both standard annotations (sent after text) and artifacts inlined in the markdown stream
|
||||
* - Multiple annotation types: sources, artifacts, and custom components (wiki)
|
||||
*
|
||||
* Use this example to understand how to mix regular content with interactive
|
||||
* components that appear at specific positions in the chat stream.
|
||||
*/
|
||||
|
||||
import { NextResponse, type NextRequest } from 'next/server'
|
||||
import { NextRequest } from 'next/server'
|
||||
import { chatHandler, MessagePart } from '../handler'
|
||||
|
||||
const TOKEN_DELAY = 30 // 30ms delay between tokens
|
||||
const TEXT_PREFIX = '0:' // vercel ai text prefix
|
||||
const ANNOTATION_PREFIX = '8:' // vercel ai annotation prefix
|
||||
const INLINE_ANNOTATION_KEY = 'annotation' // the language key to detect inline annotation code in markdown
|
||||
const ANNOTATION_DELAY = 1000 // 1 second delay between annotations
|
||||
const SAMPLE_PARTS: (string | MessagePart)[] = [
|
||||
'Welcome to the demo of @llamaindex/chat-ui. Let me show you the different types of components that can be triggered from the server.',
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { messages } = await request.json()
|
||||
const lastMessage = messages[messages.length - 1]
|
||||
|
||||
const stream = fakeChatStream(`User query: "${lastMessage.content}".\n`)
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/plain; charset=utf-8',
|
||||
'X-Vercel-AI-Data-Stream': 'v1',
|
||||
Connection: 'keep-alive',
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
const detail = (error as Error).message
|
||||
return NextResponse.json({ detail }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
const SAMPLE_TEXT = [
|
||||
`
|
||||
Welcome to the demo of @llamaindex/chat-ui. Let me show you the different types of components that can be triggered from the server.
|
||||
### Text Part
|
||||
Text part is used to display text in the chat. It is in markdown format.
|
||||
You can use markdown syntax to format the text. Some examples:
|
||||
|
||||
### Markdown with code block
|
||||
- **bold** -> this is bold text
|
||||
- *italic* -> this is italic text
|
||||
- [link](https://www.google.com) -> this is a link
|
||||
|
||||
You can also display a code block inside markdown.
|
||||
|
||||
\`\`\`js
|
||||
const a = 1
|
||||
@@ -51,63 +31,80 @@ const b = 2
|
||||
const c = a + b
|
||||
console.log(c)
|
||||
\`\`\`
|
||||
|
||||
`,
|
||||
'\n ### Demo inline annotations \n',
|
||||
'Here are some steps to create a simple wiki app: \n',
|
||||
'1. Create package.json file:',
|
||||
|
||||
`
|
||||
### Parts
|
||||
|
||||
Beside text, you can also display parts in the chat. Parts can be displayed before or after the text.
|
||||
|
||||
**Built-in parts**
|
||||
|
||||
@llamaindex/chat-ui provides some built-in parts for you to use
|
||||
|
||||
- **file** -> display a file with name and url
|
||||
- **event** -> display a event with title, status, and data
|
||||
- **artifact** -> display a code artifact
|
||||
- **sources** -> display a list of sources
|
||||
- **suggested_questions** -> display a list of suggested questions
|
||||
|
||||
**Custom parts**
|
||||
|
||||
You can also create your own custom parts.
|
||||
|
||||
- **weather** -> display a weather card
|
||||
- **wiki** -> display a wiki card
|
||||
`,
|
||||
|
||||
'**file**: Here is the demo of a file part',
|
||||
{
|
||||
type: 'artifact',
|
||||
type: 'file',
|
||||
data: {
|
||||
type: 'code',
|
||||
created_at: 1717334400000,
|
||||
filename: 'upload.pdf',
|
||||
mediaType: 'application/pdf',
|
||||
url: 'https://pdfobject.com/pdf/sample.pdf',
|
||||
},
|
||||
},
|
||||
|
||||
'**event**: Here is the demo of event parts. The second event part will override the first one because they have the same id',
|
||||
{
|
||||
id: 'demo_sample_event_id',
|
||||
type: 'event',
|
||||
data: {
|
||||
title: 'Calling tool `get_weather` with input `San Francisco, CA`',
|
||||
status: 'pending',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'demo_sample_event_id', // use the same id to override the previous part
|
||||
type: 'event',
|
||||
data: {
|
||||
title:
|
||||
'Got response from tool `get_weather` with input `San Francisco, CA`',
|
||||
status: 'success',
|
||||
data: {
|
||||
file_name: 'package.json',
|
||||
language: 'json',
|
||||
code: `{
|
||||
"name": "wiki-app",
|
||||
"version": "1.0.0",
|
||||
"description": "Wiki application",
|
||||
"main": "wiki.js",
|
||||
"dependencies": {
|
||||
"axios": "^1.0.0",
|
||||
"wiki-api": "^2.1.0"
|
||||
}
|
||||
}`,
|
||||
location: 'San Francisco, CA',
|
||||
temperature: 22,
|
||||
condition: 'sunny',
|
||||
humidity: 65,
|
||||
windSpeed: 12,
|
||||
},
|
||||
},
|
||||
},
|
||||
'2. Check the wiki fetching script:',
|
||||
|
||||
'**weather**: Here is the demo of a weather part. It is a custom part',
|
||||
{
|
||||
type: 'artifact',
|
||||
type: 'weather',
|
||||
data: {
|
||||
created_at: 1717334500000,
|
||||
type: 'code',
|
||||
data: {
|
||||
file_name: 'wiki.js',
|
||||
language: 'javascript',
|
||||
code: `async function getWiki(search) {
|
||||
const response = await fetch("/api/wiki?search=" + search);
|
||||
const data = await response.json();
|
||||
return data;
|
||||
}`,
|
||||
},
|
||||
location: 'San Francisco, CA',
|
||||
temperature: 22,
|
||||
condition: 'sunny',
|
||||
humidity: 65,
|
||||
windSpeed: 12,
|
||||
},
|
||||
},
|
||||
'3. Run getWiki with the search term:',
|
||||
{
|
||||
type: 'artifact',
|
||||
data: {
|
||||
created_at: 1717334600000,
|
||||
type: 'code',
|
||||
data: {
|
||||
file_name: 'wiki.js',
|
||||
language: 'javascript',
|
||||
code: `getWiki(\`What is \${search}?\`);`,
|
||||
},
|
||||
},
|
||||
},
|
||||
'4. Check the current wiki:',
|
||||
|
||||
'**wiki**: Here is the demo of a wiki part',
|
||||
{
|
||||
type: 'wiki',
|
||||
data: {
|
||||
@@ -118,64 +115,21 @@ console.log(c)
|
||||
lastUpdated: '2025-06-02',
|
||||
},
|
||||
},
|
||||
'#### 🎯 Demo generating a document artifact',
|
||||
|
||||
'**artifact**: Here is the demo of a artifact part',
|
||||
{
|
||||
type: 'artifact',
|
||||
data: {
|
||||
type: 'document',
|
||||
type: 'code',
|
||||
data: {
|
||||
title: 'Sample document',
|
||||
content: `# Getting Started Guide
|
||||
|
||||
## Introduction
|
||||
This comprehensive guide will walk you through everything you need to know to get started with our platform. Whether you're a beginner or an experienced user, you'll find valuable information here.
|
||||
|
||||
## Key Features
|
||||
- **Easy Setup**: Get running in minutes
|
||||
- **Powerful Tools**: Access advanced capabilities
|
||||
- **Great Documentation**: Find answers quickly
|
||||
- **Active Community**: Get help when needed
|
||||
|
||||
## Setup Process
|
||||
1. Install Dependencies
|
||||
First, ensure you have all required dependencies installed on your system.
|
||||
|
||||
2. Configuration
|
||||
Update your configuration files with the necessary settings:
|
||||
- API keys
|
||||
- Environment variables
|
||||
- User preferences
|
||||
|
||||
3. First Steps
|
||||
Begin with basic operations to familiarize yourself with the platform.
|
||||
|
||||
## Best Practices
|
||||
- Always backup your data
|
||||
- Follow security guidelines
|
||||
- Keep your dependencies updated
|
||||
- Document your changes
|
||||
|
||||
## Troubleshooting
|
||||
If you encounter issues, try these steps:
|
||||
1. Check logs for errors
|
||||
2. Verify configurations
|
||||
3. Update to latest version
|
||||
4. Contact support if needed
|
||||
|
||||
## Additional Resources
|
||||
- [Documentation](https://docs.example.com)
|
||||
- [API Reference](https://api.example.com)
|
||||
- [Community Forums](https://community.example.com)
|
||||
|
||||
Feel free to explore and reach out if you need assistance!`,
|
||||
type: 'markdown',
|
||||
file_name: 'code.py',
|
||||
code: 'print("Hello, world!")',
|
||||
language: 'python',
|
||||
},
|
||||
},
|
||||
},
|
||||
'\n\n Please feel free to open the document in the canvas and edit it. The document will be saved as a new version',
|
||||
]
|
||||
|
||||
const SAMPLE_SOURCES = [
|
||||
'**sources**: Here is the demo of a sources part',
|
||||
{
|
||||
type: 'sources',
|
||||
data: {
|
||||
@@ -185,59 +139,18 @@ const SAMPLE_SOURCES = [
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
'**suggested_questions**: Here is the demo of a suggested_questions part',
|
||||
{
|
||||
type: 'suggested_questions',
|
||||
data: [
|
||||
'I think you should go to the beach',
|
||||
'I think you should go to the mountains',
|
||||
'I think you should go to the city',
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const fakeChatStream = (query: string): ReadableStream => {
|
||||
return new ReadableStream({
|
||||
async start(controller) {
|
||||
const encoder = new TextEncoder()
|
||||
controller.enqueue(
|
||||
encoder.encode(`${TEXT_PREFIX}${JSON.stringify(query)}\n`)
|
||||
)
|
||||
|
||||
// insert inline annotations
|
||||
for (const item of SAMPLE_TEXT) {
|
||||
if (typeof item === 'string') {
|
||||
for (const token of item.split(' ')) {
|
||||
await new Promise(resolve => setTimeout(resolve, TOKEN_DELAY))
|
||||
controller.enqueue(
|
||||
encoder.encode(`${TEXT_PREFIX}${JSON.stringify(`${token} `)}\n`)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
await new Promise(resolve => setTimeout(resolve, ANNOTATION_DELAY))
|
||||
// append inline annotation with 0: prefix
|
||||
const annotationCode = toInlineAnnotationCode(item)
|
||||
controller.enqueue(
|
||||
encoder.encode(`${TEXT_PREFIX}${JSON.stringify(annotationCode)}\n`)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// insert sources in fixed positions
|
||||
for (const item of SAMPLE_SOURCES) {
|
||||
controller.enqueue(
|
||||
encoder.encode(`${ANNOTATION_PREFIX}${JSON.stringify([item])}\n`)
|
||||
)
|
||||
}
|
||||
|
||||
controller.close()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* To append inline annotations to the stream, we need to wrap the annotation in a code block with the language key.
|
||||
* The language key is `annotation` and the code block is wrapped in backticks.
|
||||
* The prefix `0:` ensures it will be treated as inline markdown. Example:
|
||||
*
|
||||
* 0:\`\`\`annotation
|
||||
* \{
|
||||
* "type": "artifact",
|
||||
* "data": \{...\}
|
||||
* \}
|
||||
* \`\`\`
|
||||
*/
|
||||
function toInlineAnnotationCode(item: any) {
|
||||
return `\n\`\`\`${INLINE_ANNOTATION_KEY}\n${JSON.stringify(item)}\n\`\`\`\n`
|
||||
export async function POST(request: NextRequest) {
|
||||
return chatHandler(request, SAMPLE_PARTS)
|
||||
}
|
||||
|
||||
@@ -1,40 +1,19 @@
|
||||
/**
|
||||
* This is an example to demo chat-ui with edge runtime, same functionality as chat/route.ts
|
||||
*
|
||||
* This is a simple example demonstrating:
|
||||
* - Text streaming with token-by-token delivery
|
||||
* - Basic markdown content with code blocks
|
||||
* - Custom annotations (weather) sent after text completion
|
||||
* - Standard annotations (sources) sent after text completion
|
||||
* - Standard parts (sources) sent after text completion
|
||||
* - Custom parts (weather) sent after text completion
|
||||
*
|
||||
*/
|
||||
import { NextResponse, type NextRequest } from 'next/server'
|
||||
|
||||
const TOKEN_DELAY = 30 // 30ms delay between tokens
|
||||
const TEXT_PREFIX = '0:' // vercel ai text prefix
|
||||
const ANNOTATION_PREFIX = '8:' // vercel ai annotation prefix
|
||||
import { NextRequest } from 'next/server'
|
||||
import { chatHandler, MessagePart } from '../handler'
|
||||
|
||||
export const runtime = 'edge' // This is the key difference from chat/route.ts
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { messages } = await request.json()
|
||||
const lastMessage = messages[messages.length - 1]
|
||||
|
||||
const stream = fakeChatStream(`User query: "${lastMessage.content}".\n`)
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/plain; charset=utf-8',
|
||||
'X-Vercel-AI-Data-Stream': 'v1',
|
||||
Connection: 'keep-alive',
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
const detail = (error as Error).message
|
||||
return NextResponse.json({ detail }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
const SAMPLE_TEXT = `
|
||||
const SAMPLE_PARTS: (string | MessagePart)[] = [
|
||||
`
|
||||
Welcome to the demo of @llamaindex/chat-ui. Let me show you the different types of components that can be triggered from the server.
|
||||
|
||||
### Markdown with code block
|
||||
@@ -46,10 +25,21 @@ const c = a + b
|
||||
console.log(c)
|
||||
\`\`\`
|
||||
|
||||
### Annotations
|
||||
### Parts:
|
||||
`,
|
||||
|
||||
`
|
||||
const SAMPLE_ANNOTATIONS = [
|
||||
'Let me show the sources (type=sources):',
|
||||
{
|
||||
type: 'sources',
|
||||
data: {
|
||||
nodes: [
|
||||
{ id: '1', url: '/sample.pdf' },
|
||||
{ id: '2', url: '/sample.pdf' },
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
'Let me show a weather card (type=weather):',
|
||||
{
|
||||
type: 'weather',
|
||||
data: {
|
||||
@@ -60,39 +50,10 @@ const SAMPLE_ANNOTATIONS = [
|
||||
windSpeed: 12,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'sources',
|
||||
data: {
|
||||
nodes: [
|
||||
{ id: '1', url: '/sample.pdf' },
|
||||
{ id: '2', url: '/sample.pdf' },
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const fakeChatStream = (query: string): ReadableStream => {
|
||||
return new ReadableStream({
|
||||
async start(controller) {
|
||||
const encoder = new TextEncoder()
|
||||
controller.enqueue(
|
||||
encoder.encode(`${TEXT_PREFIX}${JSON.stringify(query)}\n`)
|
||||
)
|
||||
export const runtime = 'edge' // This is the key difference from chat/route.ts
|
||||
|
||||
for (const token of SAMPLE_TEXT.split(' ')) {
|
||||
await new Promise(resolve => setTimeout(resolve, TOKEN_DELAY))
|
||||
controller.enqueue(
|
||||
encoder.encode(`${TEXT_PREFIX}${JSON.stringify(`${token} `)}\n`)
|
||||
)
|
||||
}
|
||||
|
||||
for (const item of SAMPLE_ANNOTATIONS) {
|
||||
controller.enqueue(
|
||||
encoder.encode(`${ANNOTATION_PREFIX}${JSON.stringify([item])}\n`)
|
||||
)
|
||||
}
|
||||
|
||||
controller.close()
|
||||
},
|
||||
})
|
||||
export async function POST(request: NextRequest) {
|
||||
return chatHandler(request, SAMPLE_PARTS)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import { NextResponse, type NextRequest } from 'next/server'
|
||||
|
||||
const TOKEN_DELAY = 30 // 30ms delay between tokens
|
||||
const PART_DELAY = 1000 // 1s delay between parts
|
||||
const DATA_PREFIX = 'data: ' // use data: prefix for SSE format
|
||||
|
||||
interface TextChunk {
|
||||
type: 'text-delta' | 'text-start' | 'text-end'
|
||||
id: string
|
||||
delta?: string
|
||||
}
|
||||
|
||||
interface DataChunk {
|
||||
id?: string // optional id for data parts. Only the last data part with that id will be shown
|
||||
type: `data-${string}` // requires `data-` prefix when sending data parts
|
||||
data: Record<string, any>
|
||||
}
|
||||
|
||||
interface TextPart {
|
||||
type: 'text'
|
||||
text: string
|
||||
}
|
||||
|
||||
export interface MessagePart {
|
||||
id?: string
|
||||
type: string
|
||||
data?: any
|
||||
}
|
||||
|
||||
export async function chatHandler(
|
||||
request: NextRequest,
|
||||
parts: (string | MessagePart)[]
|
||||
) {
|
||||
try {
|
||||
// extract query from last message
|
||||
const { messages } = await request.json()
|
||||
const query = getText(messages[messages.length - 1]?.parts ?? [])
|
||||
|
||||
// create a stream
|
||||
const stream = fakeChatStream(`User query: "${query}".\n`, parts)
|
||||
|
||||
// return the stream
|
||||
return new Response(stream, {
|
||||
// Set headers for Server-Sent Events (SSE)
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
Connection: 'keep-alive',
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
const detail = (error as Error).message
|
||||
return NextResponse.json({ detail }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
function getText(message: { parts: MessagePart[] }): string {
|
||||
return message.parts
|
||||
.filter((part): part is TextPart => part.type === 'text')
|
||||
.map(part => part.text)
|
||||
.join('\n\n')
|
||||
}
|
||||
|
||||
const fakeChatStream = (
|
||||
query: string,
|
||||
parts: (string | MessagePart)[]
|
||||
): ReadableStream => {
|
||||
return new ReadableStream({
|
||||
async start(controller) {
|
||||
const encoder = new TextEncoder()
|
||||
|
||||
function writeStream(chunk: TextChunk | DataChunk) {
|
||||
controller.enqueue(
|
||||
encoder.encode(`${DATA_PREFIX}${JSON.stringify(chunk)}\n\n`)
|
||||
)
|
||||
}
|
||||
|
||||
async function writeText(content: string) {
|
||||
// init a unique message id
|
||||
const messageId = crypto.randomUUID()
|
||||
|
||||
// important: we need to write the start chunk first
|
||||
const startChunk: TextChunk = { id: messageId, type: 'text-start' }
|
||||
writeStream(startChunk)
|
||||
|
||||
// simulate token-by-token streaming
|
||||
for (const token of content.split(' ')) {
|
||||
const deltaChunk: TextChunk = {
|
||||
id: messageId,
|
||||
type: 'text-delta',
|
||||
delta: token + ' ',
|
||||
}
|
||||
writeStream(deltaChunk)
|
||||
await new Promise(resolve => setTimeout(resolve, TOKEN_DELAY))
|
||||
}
|
||||
|
||||
// important: we need to write the end chunk last
|
||||
const endChunk: TextChunk = { id: messageId, type: 'text-end' }
|
||||
|
||||
writeStream(endChunk)
|
||||
}
|
||||
|
||||
async function writeData(data: {
|
||||
type: string
|
||||
data?: any
|
||||
id?: string
|
||||
}) {
|
||||
const chunk: DataChunk = {
|
||||
id: data.id,
|
||||
type: `data-${data.type}`,
|
||||
data: data.data,
|
||||
}
|
||||
writeStream(chunk)
|
||||
await new Promise(resolve => setTimeout(resolve, PART_DELAY))
|
||||
}
|
||||
|
||||
// show the query message
|
||||
await writeText(query)
|
||||
|
||||
for (const item of parts) {
|
||||
if (typeof item === 'string') {
|
||||
await writeText(item)
|
||||
} else {
|
||||
await writeData(item)
|
||||
}
|
||||
}
|
||||
|
||||
controller.close()
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -4,39 +4,16 @@
|
||||
* This is a simple example demonstrating:
|
||||
* - Text streaming with token-by-token delivery
|
||||
* - Basic markdown content with code blocks
|
||||
* - Custom annotations (weather) sent after text completion
|
||||
* - Standard annotations (sources) sent after text completion
|
||||
* - Standard parts (sources) sent after text completion
|
||||
* - Custom parts (weather) sent after text completion
|
||||
*
|
||||
* Use this example as a starting point for implementing basic chat functionality
|
||||
* with \@llamaindex/chat-ui components.
|
||||
*/
|
||||
import { NextResponse, type NextRequest } from 'next/server'
|
||||
|
||||
const TOKEN_DELAY = 30 // 30ms delay between tokens
|
||||
const TEXT_PREFIX = '0:' // vercel ai text prefix
|
||||
const ANNOTATION_PREFIX = '8:' // vercel ai annotation prefix
|
||||
import { NextRequest } from 'next/server'
|
||||
import { chatHandler, MessagePart } from './handler'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { messages } = await request.json()
|
||||
const lastMessage = messages[messages.length - 1]
|
||||
|
||||
const stream = fakeChatStream(`User query: "${lastMessage.content}".\n`)
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/plain; charset=utf-8',
|
||||
'X-Vercel-AI-Data-Stream': 'v1',
|
||||
Connection: 'keep-alive',
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
const detail = (error as Error).message
|
||||
return NextResponse.json({ detail }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
const SAMPLE_TEXT = `
|
||||
const SAMPLE_PARTS: (string | MessagePart)[] = [
|
||||
`
|
||||
Welcome to the demo of @llamaindex/chat-ui. Let me show you the different types of components that can be triggered from the server.
|
||||
|
||||
### Markdown with code block
|
||||
@@ -48,10 +25,21 @@ const c = a + b
|
||||
console.log(c)
|
||||
\`\`\`
|
||||
|
||||
### Annotations
|
||||
### Parts:
|
||||
`,
|
||||
|
||||
`
|
||||
const SAMPLE_ANNOTATIONS = [
|
||||
'Let me show the sources (type=sources):',
|
||||
{
|
||||
type: 'sources',
|
||||
data: {
|
||||
nodes: [
|
||||
{ id: '1', url: '/sample.pdf' },
|
||||
{ id: '2', url: '/sample.pdf' },
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
'Let me show a weather card (type=weather):',
|
||||
{
|
||||
type: 'weather',
|
||||
data: {
|
||||
@@ -62,39 +50,8 @@ const SAMPLE_ANNOTATIONS = [
|
||||
windSpeed: 12,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'sources',
|
||||
data: {
|
||||
nodes: [
|
||||
{ id: '1', url: '/sample.pdf' },
|
||||
{ id: '2', url: '/sample.pdf' },
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const fakeChatStream = (query: string): ReadableStream => {
|
||||
return new ReadableStream({
|
||||
async start(controller) {
|
||||
const encoder = new TextEncoder()
|
||||
controller.enqueue(
|
||||
encoder.encode(`${TEXT_PREFIX}${JSON.stringify(query)}\n`)
|
||||
)
|
||||
|
||||
for (const token of SAMPLE_TEXT.split(' ')) {
|
||||
await new Promise(resolve => setTimeout(resolve, TOKEN_DELAY))
|
||||
controller.enqueue(
|
||||
encoder.encode(`${TEXT_PREFIX}${JSON.stringify(`${token} `)}\n`)
|
||||
)
|
||||
}
|
||||
|
||||
for (const item of SAMPLE_ANNOTATIONS) {
|
||||
controller.enqueue(
|
||||
encoder.encode(`${ANNOTATION_PREFIX}${JSON.stringify([item])}\n`)
|
||||
)
|
||||
}
|
||||
|
||||
controller.close()
|
||||
},
|
||||
})
|
||||
export async function POST(request: NextRequest) {
|
||||
return chatHandler(request, SAMPLE_PARTS)
|
||||
}
|
||||
|
||||
@@ -8,15 +8,21 @@ import {
|
||||
ChatSection,
|
||||
useChatUI,
|
||||
} from '@llamaindex/chat-ui'
|
||||
import { Message, useChat } from 'ai/react'
|
||||
import { WeatherAnnotation } from '../components/custom-weather'
|
||||
import { WikiCard } from '@/components/custom-wiki'
|
||||
import { UIMessage, useChat } from '@ai-sdk/react'
|
||||
import { WeatherPart } from '../components/custom-weather'
|
||||
import { DefaultChatTransport } from 'ai'
|
||||
import { WikiPart } from '../components/custom-wiki'
|
||||
|
||||
const initialMessages: Message[] = [
|
||||
const initialMessages: UIMessage[] = [
|
||||
{
|
||||
id: '1',
|
||||
content: 'Hello! How can I help you today?',
|
||||
role: 'assistant',
|
||||
parts: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Hello! How can I help you today?',
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -40,15 +46,17 @@ export default function Page(): JSX.Element {
|
||||
|
||||
function ChatExample() {
|
||||
const handler = useChat({
|
||||
api: '/api/chat',
|
||||
transport: new DefaultChatTransport({
|
||||
// uncomment this to try advanced example in app/api/chat/advanced/route.ts
|
||||
api: '/api/chat/advanced',
|
||||
|
||||
// uncomment this to try edge runtime example in app/api/chat/edge/route.ts
|
||||
// api: '/api/chat/edge',
|
||||
// uncomment this to try basic example in app/api/chat/route.ts
|
||||
// api: '/api/chat',
|
||||
|
||||
// uncomment this to try advanced example in app/api/chat/advanced/route.ts
|
||||
// api: '/api/chat/advanced',
|
||||
|
||||
initialMessages,
|
||||
// uncomment this to try edge runtime example in app/api/chat/edge/route.ts
|
||||
// api: '/api/chat/edge',
|
||||
}),
|
||||
messages: initialMessages,
|
||||
})
|
||||
|
||||
return (
|
||||
@@ -77,7 +85,7 @@ function ChatExample() {
|
||||
}
|
||||
|
||||
function CustomChatMessages() {
|
||||
const { messages, isLoading, append } = useChatUI()
|
||||
const { messages } = useChatUI()
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -93,18 +101,15 @@ function CustomChatMessages() {
|
||||
{message.role === 'user' ? 'U' : 'AI'}
|
||||
</div>
|
||||
</ChatMessage.Avatar>
|
||||
<ChatMessage.Content isLoading={isLoading} append={append}>
|
||||
<ChatMessage.Content.Markdown
|
||||
annotationRenderers={{
|
||||
// these annotations are rendered inline with the Markdown text
|
||||
artifact: ChatCanvas.Artifact,
|
||||
wiki: WikiCard,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* annotation components under the Markdown text */}
|
||||
<WeatherAnnotation />
|
||||
<ChatMessage.Content.Source />
|
||||
<ChatMessage.Content>
|
||||
<ChatMessage.Part.File />
|
||||
<ChatMessage.Part.Event />
|
||||
<ChatMessage.Part.Markdown />
|
||||
<ChatMessage.Part.Artifact />
|
||||
<ChatMessage.Part.Source />
|
||||
<ChatMessage.Part.Suggestion />
|
||||
<WikiPart />
|
||||
<WeatherPart />
|
||||
</ChatMessage.Content>
|
||||
<ChatMessage.Actions />
|
||||
</ChatMessage>
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
'use server'
|
||||
|
||||
import { defaultAnnotationRenderers } from '@llamaindex/chat-ui'
|
||||
import { Markdown } from '@llamaindex/chat-ui/widgets'
|
||||
import { createStreamableUI } from 'ai/rsc'
|
||||
import { createStreamableUI } from '@ai-sdk/rsc'
|
||||
import { ReactNode } from 'react'
|
||||
import { MessagePart } from '@llamaindex/chat-ui'
|
||||
import { MessageDisplay } from './display'
|
||||
|
||||
const TOKEN_DELAY = 30
|
||||
const ANNOTATION_DELAY = 300
|
||||
const INLINE_ANNOTATION_KEY = 'annotation'
|
||||
const DELAY = 300
|
||||
|
||||
export async function chatAction(question: string) {
|
||||
const uiStream = createStreamableUI()
|
||||
|
||||
let assistantMsg = ''
|
||||
let parts: MessagePart[] = []
|
||||
|
||||
const responseStream = fakeChatStream(question)
|
||||
responseStream
|
||||
@@ -20,17 +18,11 @@ export async function chatAction(question: string) {
|
||||
new WritableStream({
|
||||
write: (data: any) => {
|
||||
if (typeof data === 'string') {
|
||||
assistantMsg += data
|
||||
parts = parts.concat({ type: 'text', text: data })
|
||||
} else {
|
||||
assistantMsg += toInlineAnnotationCode(data)
|
||||
parts = parts.concat(data)
|
||||
}
|
||||
|
||||
uiStream.update(
|
||||
<Markdown
|
||||
content={assistantMsg}
|
||||
annotationRenderers={defaultAnnotationRenderers}
|
||||
/>
|
||||
)
|
||||
uiStream.update(<MessageDisplay parts={parts} />)
|
||||
},
|
||||
close: () => {
|
||||
uiStream.done()
|
||||
@@ -42,7 +34,7 @@ export async function chatAction(question: string) {
|
||||
return uiStream.value as Promise<ReactNode>
|
||||
}
|
||||
|
||||
const SAMPLE_TEXT = [
|
||||
const SAMPLE_PARTS = [
|
||||
`
|
||||
Welcome to the demo of @llamaindex/chat-ui. Let me show you the different types of components that can be triggered from the server.
|
||||
|
||||
@@ -56,11 +48,11 @@ console.log(c)
|
||||
\`\`\`
|
||||
|
||||
`,
|
||||
'\n ### Demo inline annotations \n',
|
||||
'\n ### Demo parts \n',
|
||||
'Here are some steps to create a simple wiki app: \n',
|
||||
'1. Create package.json file:',
|
||||
{
|
||||
type: 'artifact',
|
||||
type: 'data-artifact',
|
||||
data: {
|
||||
type: 'code',
|
||||
created_at: 1717334400000,
|
||||
@@ -82,7 +74,7 @@ console.log(c)
|
||||
},
|
||||
'2. Check the wiki fetching script:',
|
||||
{
|
||||
type: 'artifact',
|
||||
type: 'data-artifact',
|
||||
data: {
|
||||
created_at: 1717334500000,
|
||||
type: 'code',
|
||||
@@ -99,7 +91,7 @@ console.log(c)
|
||||
},
|
||||
'3. Run getWiki with the search term:',
|
||||
{
|
||||
type: 'artifact',
|
||||
type: 'data-artifact',
|
||||
data: {
|
||||
created_at: 1717334600000,
|
||||
type: 'code',
|
||||
@@ -112,7 +104,7 @@ console.log(c)
|
||||
},
|
||||
'#### 🎯 Demo generating a document artifact',
|
||||
{
|
||||
type: 'artifact',
|
||||
type: 'data-artifact',
|
||||
data: {
|
||||
type: 'document',
|
||||
data: {
|
||||
@@ -172,23 +164,12 @@ function fakeChatStream(question: string): ReadableStream {
|
||||
async start(controller) {
|
||||
controller.enqueue(`User question: ${question}. \n `)
|
||||
|
||||
for (const item of SAMPLE_TEXT) {
|
||||
if (typeof item === 'string') {
|
||||
for (const token of item.split(' ')) {
|
||||
await new Promise(resolve => setTimeout(resolve, TOKEN_DELAY))
|
||||
controller.enqueue(`${token} `)
|
||||
}
|
||||
} else {
|
||||
await new Promise(resolve => setTimeout(resolve, ANNOTATION_DELAY))
|
||||
controller.enqueue(item)
|
||||
}
|
||||
for (const item of SAMPLE_PARTS) {
|
||||
await new Promise(resolve => setTimeout(resolve, DELAY))
|
||||
controller.enqueue(item)
|
||||
}
|
||||
|
||||
controller.close()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function toInlineAnnotationCode(item: any) {
|
||||
return `\n\`\`\`${INLINE_ANNOTATION_KEY}\n${JSON.stringify(item)}\n\`\`\`\n`
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
'use server'
|
||||
|
||||
import { createAI } from 'ai/rsc'
|
||||
import { createAI } from '@ai-sdk/rsc'
|
||||
import { chatAction } from './action'
|
||||
import { Message } from 'ai'
|
||||
import { UIMessage as Message } from 'ai'
|
||||
import { ReactNode } from 'react'
|
||||
|
||||
// define AI state and AI provider for RSC app
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
ArtifactPartUI,
|
||||
ChatPartProvider,
|
||||
MarkdownPartUI,
|
||||
MessagePart,
|
||||
} from '@llamaindex/chat-ui'
|
||||
|
||||
export function MessageDisplay({ parts }: { parts: MessagePart[] }) {
|
||||
return (
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-4">
|
||||
{parts.map((part, index) => (
|
||||
<ChatPartProvider key={index} value={{ part }}>
|
||||
<ArtifactPartUI />
|
||||
<MarkdownPartUI />
|
||||
</ChatPartProvider>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -53,11 +53,7 @@ function CustomChatMessages() {
|
||||
|
||||
const frontendMessages = messages.map(message => ({
|
||||
...message,
|
||||
display: (
|
||||
<ChatMessage.Content>
|
||||
{(message as Message & { display: ReactNode }).display}
|
||||
</ChatMessage.Content>
|
||||
),
|
||||
display: (message as Message & { display: ReactNode }).display,
|
||||
}))
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { generateId, Message } from 'ai'
|
||||
import { useActions, useUIState } from 'ai/rsc'
|
||||
import { generateId, TextPart, UIMessage } from 'ai'
|
||||
import { useActions, useUIState } from '@ai-sdk/rsc'
|
||||
import { useState } from 'react'
|
||||
import { AIProvider } from './ai'
|
||||
import { ChatHandler } from '@llamaindex/chat-ui'
|
||||
@@ -9,52 +9,71 @@ import { ChatHandler } from '@llamaindex/chat-ui'
|
||||
// simple hook to create chat handler from RSC actions
|
||||
// then we can easily use it with @llamaindex/chat-ui
|
||||
export function useChatRSC(): ChatHandler {
|
||||
const [input, setInput] = useState<string>('')
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false)
|
||||
const [status, setStatus] = useState<
|
||||
'submitted' | 'streaming' | 'ready' | 'error'
|
||||
>('ready')
|
||||
const [messages, setMessages] = useUIState<AIProvider>()
|
||||
const { chatAction } = useActions<AIProvider>()
|
||||
|
||||
// similar append function as useChat hook
|
||||
const append = async (message: Omit<Message, 'id'>) => {
|
||||
const newMsg: Message = { ...message, id: generateId() }
|
||||
const append = async (message: Omit<UIMessage, 'id'>) => {
|
||||
const newMsg: UIMessage = { ...message, id: generateId() }
|
||||
|
||||
setIsLoading(true)
|
||||
setStatus('streaming')
|
||||
try {
|
||||
setMessages(prev => [
|
||||
...prev,
|
||||
{
|
||||
...newMsg,
|
||||
display: (
|
||||
<div className="bg-primary text-primary-foreground ml-auto w-fit max-w-[80%] rounded-xl px-3 py-2">
|
||||
{message.content}
|
||||
</div>
|
||||
<>
|
||||
{message.parts.map((part, index) => {
|
||||
if (part.type === 'text') {
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="bg-primary text-primary-foreground ml-auto w-fit max-w-[80%] rounded-xl px-3 py-2"
|
||||
>
|
||||
{part.text}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null
|
||||
})}
|
||||
</>
|
||||
),
|
||||
},
|
||||
])
|
||||
const assistantMsg = await chatAction(newMsg.content)
|
||||
|
||||
const messageContent = newMsg.parts
|
||||
.filter((part): part is TextPart => part.type === 'text')
|
||||
.map(part => part.text)
|
||||
.join('\n\n')
|
||||
|
||||
const assistantMsg = await chatAction(messageContent)
|
||||
setMessages(prev => [
|
||||
...prev,
|
||||
{
|
||||
id: generateId(),
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
parts: [],
|
||||
display: assistantMsg,
|
||||
},
|
||||
])
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
setStatus('error')
|
||||
}
|
||||
setIsLoading(false)
|
||||
setInput('')
|
||||
setStatus('ready')
|
||||
|
||||
return message.content
|
||||
return message
|
||||
}
|
||||
|
||||
return {
|
||||
input,
|
||||
setInput,
|
||||
isLoading,
|
||||
append,
|
||||
sendMessage: async message => {
|
||||
append(message)
|
||||
},
|
||||
status,
|
||||
messages,
|
||||
setMessages: setMessages as ChatHandler['setMessages'],
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import { useChatMessage, getAnnotationData } from '@llamaindex/chat-ui'
|
||||
import { usePart } from '@llamaindex/chat-ui'
|
||||
|
||||
interface WeatherData {
|
||||
type WeatherData = {
|
||||
location: string
|
||||
temperature: number
|
||||
condition: string
|
||||
@@ -10,14 +10,18 @@ interface WeatherData {
|
||||
windSpeed: number
|
||||
}
|
||||
|
||||
// A custom annotation component that is used to display weather information in a chat message
|
||||
// The weather data is extracted from annotations in the message that has type 'weather'
|
||||
export function WeatherAnnotation() {
|
||||
const { message } = useChatMessage()
|
||||
const weatherData = getAnnotationData<WeatherData>(message, 'weather')
|
||||
const WeatherPartType = 'data-weather'
|
||||
|
||||
if (weatherData.length === 0) return null
|
||||
return <WeatherCard data={weatherData[0]} />
|
||||
type WeatherPart = {
|
||||
type: typeof WeatherPartType
|
||||
data: WeatherData
|
||||
}
|
||||
|
||||
// A custom part component that is used to display weather information in a chat message
|
||||
export function WeatherPart() {
|
||||
const weatherData = usePart<WeatherPart>(WeatherPartType)?.data
|
||||
if (!weatherData) return null
|
||||
return <WeatherCard data={weatherData} />
|
||||
}
|
||||
|
||||
function WeatherCard({ data }: { data: WeatherData }) {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
'use client'
|
||||
|
||||
interface WikiData {
|
||||
import { usePart } from '@llamaindex/chat-ui'
|
||||
|
||||
type WikiData = {
|
||||
title: string
|
||||
summary: string
|
||||
url: string
|
||||
@@ -8,8 +10,21 @@ interface WikiData {
|
||||
lastUpdated: string
|
||||
}
|
||||
|
||||
const WikiPartType = 'data-wiki'
|
||||
|
||||
type WikiPart = {
|
||||
type: typeof WikiPartType
|
||||
data: WikiData
|
||||
}
|
||||
|
||||
export function WikiPart() {
|
||||
const wikiData = usePart<WikiPart>(WikiPartType)?.data
|
||||
if (!wikiData) return null
|
||||
return <WikiCard data={wikiData} />
|
||||
}
|
||||
|
||||
// A UI widget that displays wiki information, it can be used inline with markdown text
|
||||
export function WikiCard({ data }: { data: WikiData }) {
|
||||
function WikiCard({ data }: { data: WikiData }) {
|
||||
const iconMap: Record<string, string> = {
|
||||
science: '🧪',
|
||||
history: '📜',
|
||||
|
||||
@@ -11,7 +11,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@llamaindex/chat-ui": "latest",
|
||||
"ai": "^4.3.16",
|
||||
"@ai-sdk/react": "^2.0.4",
|
||||
"@ai-sdk/rsc": "^1.0.4",
|
||||
"ai": "^5.0.4",
|
||||
"next": "^15.3.2",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0"
|
||||
|
||||
@@ -142,10 +142,14 @@ type ChatHandler = {
|
||||
|
||||
```typescript
|
||||
import { ChatSection } from '@llamaindex/chat-ui'
|
||||
import { useChat } from 'ai/react'
|
||||
import { useChat } from '@ai-sdk/react'
|
||||
|
||||
function MyChat() {
|
||||
const chatHandler = useChat({ api: '/api/chat' })
|
||||
const handler = useChat({
|
||||
transport: new DefaultChatTransport({
|
||||
api: '/api/chat',
|
||||
}),
|
||||
})
|
||||
return <ChatSection handler={chatHandler} />
|
||||
}
|
||||
```
|
||||
|
||||
@@ -91,6 +91,7 @@
|
||||
"remark-gfm": "^3.0.1",
|
||||
"remark-math": "^5.1.1",
|
||||
"remark-parse": "^11.0.0",
|
||||
"uuid": "^11.1.0",
|
||||
"unist-util-visit": "^5.0.0",
|
||||
"tailwind-merge": "^2.1.0",
|
||||
"vaul": "^0.9.1"
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import { Message } from '../chat.interface'
|
||||
import { getInlineAnnotations } from './inline'
|
||||
import { isMessageAnnotation, MessageAnnotation } from './types'
|
||||
import { getVercelAnnotations } from './vercel'
|
||||
|
||||
/**
|
||||
* Type for annotation parser functions
|
||||
*/
|
||||
type AnnotationParser = (message: Message) => unknown[]
|
||||
|
||||
/**
|
||||
* Gets all annotation data from a message by type, combining results from multiple parsers
|
||||
* @param message - The message to extract annotations from
|
||||
* @param type - The annotation type to filter by (can be standard or custom)
|
||||
* @param parsers - Array of parser functions to use (defaults to Vercel and inline parsers)
|
||||
* @returns Array of data from annotations of the specified type from all parsers
|
||||
*/
|
||||
export function getAnnotationData<T = unknown>(
|
||||
message: Message,
|
||||
type: string,
|
||||
parsers: AnnotationParser[] = [getVercelAnnotations, getInlineAnnotations]
|
||||
): T[] {
|
||||
const allAnnotations = parsers
|
||||
.flatMap(parser => parser(message))
|
||||
.filter(a => isMessageAnnotation(a)) as MessageAnnotation<T>[]
|
||||
|
||||
return allAnnotations.filter(a => a.type === type).map(a => a.data) as T[]
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export * from './annotations'
|
||||
export * from './types'
|
||||
export * from './inline'
|
||||
@@ -1,84 +0,0 @@
|
||||
import { remark } from 'remark'
|
||||
import remarkParse from 'remark-parse'
|
||||
import { visit } from 'unist-util-visit'
|
||||
import { Message } from '../chat.interface'
|
||||
import { isMessageAnnotation, MessageAnnotation } from './types'
|
||||
|
||||
const INLINE_ANNOTATION_KEY = 'annotation'
|
||||
|
||||
// parse Markdown and extract code blocks
|
||||
function parseMarkdownCodeBlocks(markdown: string) {
|
||||
const markdownCodeBlocks: {
|
||||
language: string | null
|
||||
code: string
|
||||
}[] = []
|
||||
|
||||
// Parse Markdown to AST using remark
|
||||
const processor = remark().use(remarkParse)
|
||||
const ast = processor.parse(markdown)
|
||||
|
||||
// Visit all code nodes in the AST
|
||||
visit(ast, 'code', (node: any) => {
|
||||
markdownCodeBlocks.push({
|
||||
language: node.lang || null, // Language is stored in node.lang
|
||||
code: node.value, // Code content is stored in node.value
|
||||
})
|
||||
})
|
||||
|
||||
return markdownCodeBlocks
|
||||
}
|
||||
|
||||
// extract all inline annotations from markdown
|
||||
export function getInlineAnnotations(message: Message): unknown[] {
|
||||
const codeBlocks = parseMarkdownCodeBlocks(message.content)
|
||||
return codeBlocks
|
||||
.filter(block => block.language === INLINE_ANNOTATION_KEY)
|
||||
.map(block => tryParse(block.code))
|
||||
.filter(Boolean) // filter out null values
|
||||
}
|
||||
|
||||
// convert annotation to inline markdown
|
||||
export function toInlineAnnotation(annotation: MessageAnnotation) {
|
||||
return `\n\`\`\`${INLINE_ANNOTATION_KEY}\n${JSON.stringify(annotation)}\n\`\`\`\n`
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses and validates an inline annotation from a code block
|
||||
* @param language - The language identifier from the markdown code block
|
||||
* @param codeValue - The raw code content from a markdown code block
|
||||
* @returns The parsed annotation if valid, null if not an annotation or invalid
|
||||
*/
|
||||
export function parseInlineAnnotation(
|
||||
language: string,
|
||||
codeValue: string
|
||||
): MessageAnnotation | null {
|
||||
// Check if this is an inline annotation code block
|
||||
if (language !== INLINE_ANNOTATION_KEY) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const annotation = tryParse(codeValue)
|
||||
|
||||
if (annotation === null || !isMessageAnnotation(annotation)) {
|
||||
console.warn(
|
||||
`Invalid inline annotation: ${codeValue}, expected an object`
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
return annotation
|
||||
} catch (error) {
|
||||
console.warn(`Failed to parse inline annotation: ${codeValue}`, error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// try to parse the code value as a JSON object and return null if it fails
|
||||
function tryParse(codeValue: string) {
|
||||
try {
|
||||
return JSON.parse(codeValue)
|
||||
} catch (error) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
export enum MessageAnnotationType {
|
||||
IMAGE = 'image',
|
||||
DOCUMENT_FILE = 'document_file',
|
||||
SOURCES = 'sources',
|
||||
EVENTS = 'events',
|
||||
SUGGESTED_QUESTIONS = 'suggested_questions',
|
||||
AGENT_EVENTS = 'agent',
|
||||
ARTIFACT = 'artifact',
|
||||
}
|
||||
|
||||
export type MessageAnnotation<T = unknown> = {
|
||||
type: string
|
||||
data: T
|
||||
}
|
||||
|
||||
export function isMessageAnnotation(
|
||||
annotation: unknown
|
||||
): annotation is MessageAnnotation {
|
||||
return (
|
||||
annotation !== null &&
|
||||
typeof annotation === 'object' &&
|
||||
'type' in annotation &&
|
||||
'data' in annotation &&
|
||||
typeof (annotation as any).type === 'string'
|
||||
)
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import { Message } from '../chat.interface'
|
||||
|
||||
/**
|
||||
* Gets annotation data directly from a message by type
|
||||
* @param message - The message to extract annotations from
|
||||
* @param type - The annotation type to filter by (can be standard or custom)
|
||||
* @returns Array of data from annotations of the specified type, or null if none found
|
||||
*/
|
||||
export function getVercelAnnotations(message: Message): unknown[] {
|
||||
return message.annotations ?? []
|
||||
}
|
||||
@@ -19,10 +19,12 @@ export function ArtifactCard({
|
||||
data,
|
||||
getTitle = getCardTitle,
|
||||
iconMap = IconMap,
|
||||
className,
|
||||
}: {
|
||||
data: Artifact
|
||||
getTitle?: (data: Artifact) => string
|
||||
iconMap?: Record<Artifact['type'], LucideIcon>
|
||||
className?: string
|
||||
}) {
|
||||
const {
|
||||
openArtifactInCanvas,
|
||||
@@ -41,7 +43,8 @@ export function ArtifactCard({
|
||||
<div
|
||||
className={cn(
|
||||
'border-border hover:border-primary flex w-full max-w-72 cursor-pointer items-center justify-between gap-2 rounded-lg border-2 p-2',
|
||||
isDisplayed && 'border-primary'
|
||||
isDisplayed && 'border-primary',
|
||||
className
|
||||
)}
|
||||
onClick={() => openArtifactInCanvas(data)}
|
||||
>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Message } from '../chat.interface'
|
||||
import { MessageAnnotationType, getAnnotationData } from '../annotations'
|
||||
import { getInlineAnnotations } from '../annotations/inline'
|
||||
import { ArtifactPartType } from '../message-parts/types'
|
||||
import { getParts } from '../message-parts/utils'
|
||||
|
||||
// check if two artifacts are equal by comparing their type and created time
|
||||
export function isEqualArtifact(a: Artifact, b: Artifact) {
|
||||
@@ -15,9 +15,7 @@ export function extractArtifactsFromAllMessages(messages: Message[]) {
|
||||
}
|
||||
|
||||
export function extractArtifactsFromMessage(message: Message): Artifact[] {
|
||||
return getAnnotationData<Artifact>(message, MessageAnnotationType.ARTIFACT, [
|
||||
getInlineAnnotations, // only extract artifacts from inline annotations
|
||||
])
|
||||
return getParts(message, ArtifactPartType).map(part => part.data)
|
||||
}
|
||||
|
||||
export type CodeArtifactError = {
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
} from './artifacts'
|
||||
import { Message } from '../chat.interface'
|
||||
import { useChatUI } from '../chat.context'
|
||||
import { toInlineAnnotation } from '../annotations'
|
||||
import { v4 as uuid } from 'uuid'
|
||||
|
||||
interface ChatCanvasContextType {
|
||||
allArtifacts: Artifact[]
|
||||
@@ -51,7 +51,8 @@ export function ChatCanvasProvider({
|
||||
children: ReactNode
|
||||
autoOpenCanvas?: boolean
|
||||
}) {
|
||||
const { messages, isLoading, append, requestData, setMessages } = useChatUI()
|
||||
const { messages, isLoading, sendMessage, requestData, setMessages } =
|
||||
useChatUI()
|
||||
|
||||
const [isCanvasOpen, setIsCanvasOpen] = useState(false) // whether the canvas is open
|
||||
const [displayedArtifact, setDisplayedArtifact] = useState<Artifact>() // the artifact currently displayed in the canvas
|
||||
@@ -107,19 +108,33 @@ export function ChatCanvasProvider({
|
||||
created_at: Date.now(),
|
||||
}
|
||||
|
||||
const newMessages = [
|
||||
const newMessages: Message[] = [
|
||||
...messages,
|
||||
{
|
||||
id: `restore-msg-${Date.now()}`,
|
||||
role: 'user',
|
||||
content: `Restore to ${artifact.type} version ${getArtifactVersion(artifact).versionNumber}`,
|
||||
parts: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Restore to ${artifact.type} version ${getArtifactVersion(artifact).versionNumber}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: `restore-success-${Date.now()}`,
|
||||
role: 'assistant',
|
||||
content: `Successfully restored to ${artifact.type} version ${getArtifactVersion(artifact).versionNumber}${toInlineAnnotation({ type: 'artifact', data: newArtifact })}`,
|
||||
parts: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Successfully restored to ${artifact.type} version ${getArtifactVersion(artifact).versionNumber}`,
|
||||
},
|
||||
{
|
||||
type: 'data-artifact',
|
||||
data: newArtifact,
|
||||
},
|
||||
],
|
||||
},
|
||||
] as (Message & { id: string })[]
|
||||
]
|
||||
|
||||
setMessages(newMessages)
|
||||
|
||||
@@ -158,17 +173,33 @@ export function ChatCanvasProvider({
|
||||
|
||||
if (!newArtifact) return
|
||||
|
||||
const newMessages = [
|
||||
const newMessages: Message[] = [
|
||||
...messages,
|
||||
{
|
||||
id: uuid(),
|
||||
role: 'user',
|
||||
content: `Update content for ${artifact.type} artifact version ${getArtifactVersion(artifact).versionNumber}`,
|
||||
parts: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Update content for ${artifact.type} artifact version ${getArtifactVersion(artifact).versionNumber}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: uuid(),
|
||||
role: 'assistant',
|
||||
content: `Updated content for ${artifact.type} artifact version ${getArtifactVersion(artifact).versionNumber}${toInlineAnnotation({ type: 'artifact', data: newArtifact })}`,
|
||||
parts: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Updated content for ${artifact.type} artifact version ${getArtifactVersion(artifact).versionNumber}`,
|
||||
},
|
||||
{
|
||||
type: 'data-artifact',
|
||||
data: newArtifact,
|
||||
},
|
||||
],
|
||||
},
|
||||
] as (Message & { id: string })[]
|
||||
]
|
||||
|
||||
setMessages(newMessages)
|
||||
openArtifactInCanvas(newArtifact)
|
||||
@@ -201,12 +232,18 @@ export function ChatCanvasProvider({
|
||||
const fixCodeErrors = (artifact: CodeArtifact) => {
|
||||
const errors = getCodeErrors(artifact)
|
||||
if (errors.length === 0) return
|
||||
append(
|
||||
sendMessage(
|
||||
{
|
||||
id: uuid(),
|
||||
role: 'user',
|
||||
content: `Please fix the following errors: ${errors.join('\n')} happened when running the code.`,
|
||||
parts: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Please fix the following errors: ${errors.join('\n')} happened when running the code.`,
|
||||
},
|
||||
],
|
||||
},
|
||||
{ data: requestData }
|
||||
{ body: requestData }
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
import {
|
||||
ChatAgentEvents,
|
||||
ChatEvents,
|
||||
ChatFiles,
|
||||
ChatImage,
|
||||
ChatSources,
|
||||
EventData,
|
||||
ImageData,
|
||||
DocumentFileData,
|
||||
AgentEventData,
|
||||
SuggestedQuestionsData,
|
||||
SuggestedQuestions,
|
||||
SourceData,
|
||||
SourceNode,
|
||||
} from '../widgets/index.js' // this import needs the file extension as it's importing the widget bundle
|
||||
import { MessageAnnotationType } from './annotations/types.js'
|
||||
import { getAnnotationData } from './annotations/annotations.js'
|
||||
import { useChatMessage } from './chat-message.context.js'
|
||||
import { useChatUI } from './chat.context.js'
|
||||
import { Message } from './chat.interface.js'
|
||||
|
||||
export function EventAnnotations() {
|
||||
const { message, isLast, isLoading } = useChatMessage()
|
||||
const showLoading = (isLast && isLoading) ?? false
|
||||
|
||||
const eventData = getAnnotationData<EventData>(
|
||||
message,
|
||||
MessageAnnotationType.EVENTS
|
||||
)
|
||||
if (eventData.length === 0) return null
|
||||
return <ChatEvents data={eventData} showLoading={showLoading} />
|
||||
}
|
||||
|
||||
export function AgentEventAnnotations() {
|
||||
const { message, isLast } = useChatMessage()
|
||||
|
||||
const agentEventData = getAnnotationData<AgentEventData>(
|
||||
message,
|
||||
MessageAnnotationType.AGENT_EVENTS
|
||||
)
|
||||
if (agentEventData.length === 0) return null
|
||||
return (
|
||||
<ChatAgentEvents
|
||||
data={agentEventData}
|
||||
isFinished={Boolean(message.content)}
|
||||
isLast={isLast}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function ImageAnnotations() {
|
||||
const { message } = useChatMessage()
|
||||
|
||||
const imageData = getAnnotationData<ImageData>(message, 'image')
|
||||
if (imageData.length === 0) return null
|
||||
return <ChatImage data={imageData[0]} />
|
||||
}
|
||||
|
||||
export function DocumentFileAnnotations() {
|
||||
const { message } = useChatMessage()
|
||||
|
||||
const contentFileData = getAnnotationData<DocumentFileData>(
|
||||
message,
|
||||
MessageAnnotationType.DOCUMENT_FILE
|
||||
)
|
||||
if (contentFileData.length === 0) return null
|
||||
|
||||
const alignmentClass = message.role === 'user' ? 'ml-auto' : 'mr-auto'
|
||||
return <ChatFiles data={contentFileData[0]} className={alignmentClass} />
|
||||
}
|
||||
|
||||
function preprocessSourceNodes(nodes: SourceNode[]): SourceNode[] {
|
||||
// Filter source nodes has lower score
|
||||
const processedNodes = nodes.map(node => {
|
||||
// remove trailing slash for node url if exists
|
||||
if (node.url) {
|
||||
node.url = node.url.replace(/\/$/, '')
|
||||
}
|
||||
return node
|
||||
})
|
||||
return processedNodes
|
||||
}
|
||||
|
||||
export function getSourceNodes(message: Message): SourceNode[] {
|
||||
const data = getAnnotationData<SourceData>(
|
||||
message,
|
||||
MessageAnnotationType.SOURCES
|
||||
)
|
||||
return data
|
||||
.map(item => ({
|
||||
...item,
|
||||
nodes: item.nodes ? preprocessSourceNodes(item.nodes) : [],
|
||||
}))
|
||||
.flatMap(item => item.nodes)
|
||||
}
|
||||
|
||||
export function SourceAnnotations() {
|
||||
const { message } = useChatMessage()
|
||||
|
||||
const nodes = getSourceNodes(message)
|
||||
if (nodes.length === 0) return null
|
||||
return <ChatSources data={{ nodes }} />
|
||||
}
|
||||
|
||||
export function SuggestedQuestionsAnnotations() {
|
||||
const { append, requestData } = useChatUI()
|
||||
const { message, isLast } = useChatMessage()
|
||||
if (!isLast || !append) return null
|
||||
|
||||
const suggestedQuestionsData = getAnnotationData<SuggestedQuestionsData>(
|
||||
message,
|
||||
MessageAnnotationType.SUGGESTED_QUESTIONS
|
||||
)
|
||||
if (suggestedQuestionsData.length === 0) return null
|
||||
return (
|
||||
<SuggestedQuestions
|
||||
questions={suggestedQuestionsData[0]}
|
||||
append={append}
|
||||
requestData={requestData}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -6,11 +6,13 @@ import { Textarea } from '../ui/textarea'
|
||||
import { FileUploader } from '../widgets/index.js' // this import needs the file extension as it's importing the widget bundle
|
||||
import { useChatUI } from './chat.context'
|
||||
import { Message } from './chat.interface'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { MessagePart } from './message-parts'
|
||||
|
||||
interface ChatInputProps extends React.PropsWithChildren {
|
||||
className?: string
|
||||
resetUploadedFiles?: () => void
|
||||
annotations?: any
|
||||
attachments?: MessagePart[]
|
||||
}
|
||||
|
||||
interface ChatInputFormProps extends React.PropsWithChildren {
|
||||
@@ -55,21 +57,21 @@ export const useChatInput = () => {
|
||||
}
|
||||
|
||||
function ChatInput(props: ChatInputProps) {
|
||||
const { input, setInput, append, isLoading, requestData } = useChatUI()
|
||||
const { input, setInput, sendMessage, isLoading, requestData } = useChatUI()
|
||||
const isDisabled = isLoading || !input.trim()
|
||||
const [isComposing, setIsComposing] = useState(false)
|
||||
|
||||
const submit = async () => {
|
||||
const newMessage: Omit<Message, 'id'> = {
|
||||
const newMessage: Message = {
|
||||
id: uuidv4(),
|
||||
role: 'user',
|
||||
content: input,
|
||||
annotations: props.annotations,
|
||||
parts: [{ type: 'text', text: input }, ...(props.attachments ?? [])],
|
||||
}
|
||||
|
||||
setInput('') // Clear the input
|
||||
props.resetUploadedFiles?.() // Reset the uploaded files
|
||||
|
||||
await append(newMessage, { data: requestData })
|
||||
await sendMessage(newMessage, { body: requestData })
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { createContext, useContext } from 'react'
|
||||
import { ChatHandler, Message } from './chat.interface'
|
||||
import { Message } from './chat.interface'
|
||||
|
||||
export interface ChatMessageContext {
|
||||
message: Message
|
||||
isLast: boolean
|
||||
isLoading?: boolean
|
||||
append?: ChatHandler['append']
|
||||
}
|
||||
|
||||
export const chatMessageContext = createContext<ChatMessageContext | null>(null)
|
||||
|
||||
@@ -1,82 +1,46 @@
|
||||
import { Bot, Check, Copy, RefreshCw } from 'lucide-react'
|
||||
import { ComponentType, memo, useMemo } from 'react'
|
||||
import { memo } from 'react'
|
||||
import { useCopyToClipboard } from '../hook/use-copy-to-clipboard'
|
||||
import { cn } from '../lib/utils'
|
||||
import { Button } from '../ui/button'
|
||||
import {
|
||||
CitationComponentProps,
|
||||
Markdown,
|
||||
LanguageRendererProps,
|
||||
} from '../widgets/index.js'
|
||||
import {
|
||||
AgentEventAnnotations,
|
||||
DocumentFileAnnotations,
|
||||
EventAnnotations,
|
||||
ImageAnnotations,
|
||||
SourceAnnotations,
|
||||
SuggestedQuestionsAnnotations,
|
||||
getSourceNodes,
|
||||
} from './chat-annotations'
|
||||
import { ChatMessageProvider, useChatMessage } from './chat-message.context.js'
|
||||
import { useChatUI } from './chat.context.js'
|
||||
import { ChatHandler, Message } from './chat.interface'
|
||||
import { defaultAnnotationRenderers } from './chat-renderers.js'
|
||||
import { Message } from './chat.interface'
|
||||
import {
|
||||
ArtifactPartUI,
|
||||
EventPartUI,
|
||||
FilePartUI,
|
||||
MarkdownPartUI,
|
||||
SourcesPartUI,
|
||||
SuggestionPartUI,
|
||||
TextPart,
|
||||
TextPartType,
|
||||
} from './message-parts'
|
||||
import { ChatPartProvider } from './message-parts/context.js'
|
||||
|
||||
interface ChatMessageProps extends React.PropsWithChildren {
|
||||
message: Message
|
||||
isLast: boolean
|
||||
className?: string
|
||||
isLoading?: boolean
|
||||
append?: ChatHandler['append']
|
||||
}
|
||||
|
||||
interface ChatMessageAvatarProps extends React.PropsWithChildren {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export enum ContentPosition {
|
||||
TOP = -9999,
|
||||
CHAT_EVENTS = 0,
|
||||
AFTER_EVENTS = 1,
|
||||
CHAT_AGENT_EVENTS = 2,
|
||||
AFTER_AGENT_EVENTS = 3,
|
||||
CHAT_IMAGE = 4,
|
||||
AFTER_IMAGE = 5,
|
||||
BEFORE_MARKDOWN = 6,
|
||||
MARKDOWN = 7,
|
||||
AFTER_MARKDOWN = 8,
|
||||
CHAT_DOCUMENT_FILES = 9,
|
||||
AFTER_DOCUMENT_FILES = 10,
|
||||
CHAT_SOURCES = 11,
|
||||
AFTER_SOURCES = 12,
|
||||
SUGGESTED_QUESTIONS = 13,
|
||||
AFTER_SUGGESTED_QUESTIONS = 14,
|
||||
BOTTOM = 9999,
|
||||
}
|
||||
|
||||
interface ChatMessageContentProps extends React.PropsWithChildren {
|
||||
className?: string
|
||||
isLoading?: boolean
|
||||
append?: ChatHandler['append']
|
||||
message?: Message // in case you want to customize the message
|
||||
}
|
||||
|
||||
interface ChatMessageActionsProps extends React.PropsWithChildren {
|
||||
className?: string
|
||||
}
|
||||
|
||||
interface ChatMarkdownProps extends React.PropsWithChildren {
|
||||
citationComponent?: ComponentType<CitationComponentProps>
|
||||
className?: string
|
||||
languageRenderers?: Record<string, ComponentType<LanguageRendererProps>>
|
||||
annotationRenderers?: Record<string, ComponentType<{ data: any }>>
|
||||
}
|
||||
|
||||
function ChatMessage(props: ChatMessageProps) {
|
||||
const children = props.children ?? (
|
||||
<>
|
||||
<ChatMessageAvatar />
|
||||
<ChatMessageContent isLoading={props.isLoading} append={props.append} />
|
||||
<ChatMessageContent />
|
||||
<ChatMessageActions />
|
||||
</>
|
||||
)
|
||||
@@ -86,8 +50,6 @@ function ChatMessage(props: ChatMessageProps) {
|
||||
value={{
|
||||
message: props.message,
|
||||
isLast: props.isLast,
|
||||
isLoading: props.isLoading,
|
||||
append: props.append,
|
||||
}}
|
||||
>
|
||||
<div className={cn('group flex gap-4 p-3', props.className)}>
|
||||
@@ -117,65 +79,50 @@ function ChatMessageAvatar(props: ChatMessageAvatarProps) {
|
||||
}
|
||||
|
||||
function ChatMessageContent(props: ChatMessageContentProps) {
|
||||
const { message } = useChatMessage()
|
||||
const children = props.children ?? (
|
||||
<>
|
||||
<EventAnnotations />
|
||||
<AgentEventAnnotations />
|
||||
<ImageAnnotations />
|
||||
<ChatMarkdown />
|
||||
<DocumentFileAnnotations />
|
||||
<SourceAnnotations />
|
||||
<SuggestedQuestionsAnnotations />
|
||||
<FilePartUI />
|
||||
<EventPartUI />
|
||||
<MarkdownPartUI />
|
||||
<ArtifactPartUI />
|
||||
<SourcesPartUI />
|
||||
<SuggestionPartUI />
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className={cn('flex min-w-0 flex-1 flex-col gap-4', props.className)}>
|
||||
{children}
|
||||
{message.parts.map((part, index) => (
|
||||
<ChatPartProvider key={index} value={{ part }}>
|
||||
{children}
|
||||
</ChatPartProvider>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ChatMarkdown(props: ChatMarkdownProps) {
|
||||
const { message } = useChatMessage()
|
||||
|
||||
const nodes = useMemo(() => getSourceNodes(message), [message])
|
||||
|
||||
return (
|
||||
<Markdown
|
||||
content={message.content}
|
||||
sources={{ nodes }}
|
||||
citationComponent={props.citationComponent}
|
||||
languageRenderers={props.languageRenderers}
|
||||
annotationRenderers={
|
||||
props.annotationRenderers ?? defaultAnnotationRenderers
|
||||
}
|
||||
className={cn(
|
||||
{
|
||||
'bg-primary text-primary-foreground ml-auto w-fit max-w-[80%] rounded-xl px-3 py-2':
|
||||
message.role === 'user',
|
||||
},
|
||||
props.className
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ChatMessageActions(props: ChatMessageActionsProps) {
|
||||
const { reload, requestData, isLoading } = useChatUI()
|
||||
const { regenerate, requestData, isLoading } = useChatUI()
|
||||
const { isCopied, copyToClipboard } = useCopyToClipboard({ timeout: 2000 })
|
||||
const { message, isLast } = useChatMessage()
|
||||
|
||||
if (message.role !== 'assistant') return null
|
||||
|
||||
const isLastMessageFromAssistant = message.role === 'assistant' && isLast
|
||||
const showReload = reload && !isLoading && isLastMessageFromAssistant
|
||||
const showReload = regenerate && !isLoading && isLastMessageFromAssistant
|
||||
|
||||
// content to copy is all text parts joined by newlines
|
||||
const messageTextContent = message.parts
|
||||
.filter((part): part is TextPart => part.type === TextPartType)
|
||||
.map(part => part.text)
|
||||
.join('\n\n')
|
||||
|
||||
const children = props.children ?? (
|
||||
<>
|
||||
<Button
|
||||
title="Copy"
|
||||
onClick={() => copyToClipboard(message.content)}
|
||||
onClick={() => copyToClipboard(messageTextContent)}
|
||||
size="icon"
|
||||
variant="outline"
|
||||
className="h-8 w-8"
|
||||
@@ -191,7 +138,7 @@ function ChatMessageActions(props: ChatMessageActionsProps) {
|
||||
title="Regenerate"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => reload?.({ data: requestData })}
|
||||
onClick={() => regenerate?.({ body: requestData })}
|
||||
className="h-8 w-8"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
@@ -206,19 +153,19 @@ function ChatMessageActions(props: ChatMessageActionsProps) {
|
||||
)
|
||||
}
|
||||
|
||||
type ComposibleChatMessageContent = typeof ChatMessageContent & {
|
||||
Event: typeof EventAnnotations
|
||||
AgentEvent: typeof AgentEventAnnotations
|
||||
Image: typeof ImageAnnotations
|
||||
Markdown: typeof ChatMarkdown
|
||||
DocumentFile: typeof DocumentFileAnnotations
|
||||
Source: typeof SourceAnnotations
|
||||
SuggestedQuestions: typeof SuggestedQuestionsAnnotations
|
||||
type ComposibleChatMessagePart = typeof ChatMessageContent & {
|
||||
File: typeof FilePartUI
|
||||
Event: typeof EventPartUI
|
||||
Markdown: typeof MarkdownPartUI
|
||||
Source: typeof SourcesPartUI
|
||||
Suggestion: typeof SuggestionPartUI
|
||||
Artifact: typeof ArtifactPartUI
|
||||
}
|
||||
|
||||
type ComposibleChatMessage = typeof ChatMessage & {
|
||||
Avatar: typeof ChatMessageAvatar
|
||||
Content: ComposibleChatMessageContent
|
||||
Content: ComposibleChatMessagePart
|
||||
Part: ComposibleChatMessagePart
|
||||
Actions: typeof ChatMessageActions
|
||||
}
|
||||
|
||||
@@ -230,16 +177,16 @@ const PrimiviteChatMessage = memo(ChatMessage, (prevProps, nextProps) => {
|
||||
)
|
||||
}) as unknown as ComposibleChatMessage
|
||||
|
||||
PrimiviteChatMessage.Content =
|
||||
ChatMessageContent as ComposibleChatMessageContent
|
||||
PrimiviteChatMessage.Content = ChatMessageContent as ComposibleChatMessagePart
|
||||
|
||||
PrimiviteChatMessage.Content.Event = EventAnnotations
|
||||
PrimiviteChatMessage.Content.AgentEvent = AgentEventAnnotations
|
||||
PrimiviteChatMessage.Content.Image = ImageAnnotations
|
||||
PrimiviteChatMessage.Content.Markdown = ChatMarkdown
|
||||
PrimiviteChatMessage.Content.DocumentFile = DocumentFileAnnotations
|
||||
PrimiviteChatMessage.Content.Source = SourceAnnotations
|
||||
PrimiviteChatMessage.Content.SuggestedQuestions = SuggestedQuestionsAnnotations
|
||||
// create alias Part with available built-in parts
|
||||
PrimiviteChatMessage.Part = ChatMessageContent as ComposibleChatMessagePart
|
||||
PrimiviteChatMessage.Part.Event = EventPartUI
|
||||
PrimiviteChatMessage.Part.File = FilePartUI
|
||||
PrimiviteChatMessage.Part.Markdown = MarkdownPartUI
|
||||
PrimiviteChatMessage.Part.Source = SourcesPartUI
|
||||
PrimiviteChatMessage.Part.Suggestion = SuggestionPartUI
|
||||
PrimiviteChatMessage.Part.Artifact = ArtifactPartUI
|
||||
|
||||
PrimiviteChatMessage.Avatar = ChatMessageAvatar
|
||||
PrimiviteChatMessage.Actions = ChatMessageActions
|
||||
|
||||
@@ -51,13 +51,13 @@ export const useChatMessages = () => {
|
||||
}
|
||||
|
||||
function ChatMessages(props: ChatMessagesProps) {
|
||||
const { messages, reload, stop, isLoading } = useChatUI()
|
||||
const { messages, regenerate, stop, isLoading } = useChatUI()
|
||||
|
||||
const messageLength = messages.length
|
||||
const lastMessage = messages[messageLength - 1]
|
||||
const isLastMessageFromAssistant =
|
||||
messageLength > 0 && lastMessage?.role !== 'user'
|
||||
const showReload = reload && !isLoading && isLastMessageFromAssistant
|
||||
const showReload = regenerate && !isLoading && isLastMessageFromAssistant
|
||||
const showStop = stop && isLoading
|
||||
|
||||
// `isPending` indicate
|
||||
@@ -85,7 +85,7 @@ function ChatMessages(props: ChatMessagesProps) {
|
||||
|
||||
function ChatMessagesList(props: ChatMessagesListProps) {
|
||||
const scrollableChatContainerRef = useRef<HTMLDivElement>(null)
|
||||
const { messages, isLoading, append } = useChatUI()
|
||||
const { messages } = useChatUI()
|
||||
const { lastMessage, messageLength } = useChatMessages()
|
||||
|
||||
const scrollToBottom = () => {
|
||||
@@ -107,8 +107,6 @@ function ChatMessagesList(props: ChatMessagesListProps) {
|
||||
key={index}
|
||||
message={message}
|
||||
isLast={index === messageLength - 1}
|
||||
isLoading={isLoading}
|
||||
append={append}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
@@ -197,7 +195,7 @@ function ChatMessagesLoading(props: ChatMessagesLoadingProps) {
|
||||
}
|
||||
|
||||
function ChatActions(props: ChatActionsProps) {
|
||||
const { reload, stop, requestData } = useChatUI()
|
||||
const { regenerate, stop, requestData } = useChatUI()
|
||||
const { showReload, showStop } = useChatMessages()
|
||||
if (!showStop && !showReload) return null
|
||||
|
||||
@@ -213,7 +211,7 @@ function ChatActions(props: ChatActionsProps) {
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => reload?.({ data: requestData })}
|
||||
onClick={() => regenerate?.({ body: requestData })}
|
||||
>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
Regenerate
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
import { ComponentType } from 'react'
|
||||
import ChatCanvas from './canvas/index.js'
|
||||
|
||||
export const defaultAnnotationRenderers: Record<
|
||||
string,
|
||||
ComponentType<{ data: any }>
|
||||
> = {
|
||||
artifact: ChatCanvas.Artifact,
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useState } from 'react'
|
||||
import { cn } from '../lib/utils'
|
||||
import { ChatCanvasProvider } from './canvas/context'
|
||||
import ChatInput from './chat-input'
|
||||
import ChatMessages from './chat-messages'
|
||||
import { ChatProvider } from './chat.context'
|
||||
import { type ChatHandler } from './chat.interface'
|
||||
import { ChatCanvasProvider } from './canvas/context'
|
||||
|
||||
export interface ChatSectionProps extends React.PropsWithChildren {
|
||||
handler: ChatHandler
|
||||
@@ -17,8 +17,14 @@ export interface ChatSectionProps extends React.PropsWithChildren {
|
||||
|
||||
export default function ChatSection(props: ChatSectionProps) {
|
||||
const { handler, className, autoOpenCanvas = true } = props
|
||||
const [input, setInput] = useState('')
|
||||
const [requestData, setRequestData] = useState<any>()
|
||||
|
||||
// show loading immediately after the user submits the request
|
||||
// then keep loading util streaming is finished
|
||||
const isLoading =
|
||||
handler.status === 'submitted' || handler.status === 'streaming'
|
||||
|
||||
const children = props.children ?? (
|
||||
<>
|
||||
<ChatMessages />
|
||||
@@ -27,7 +33,16 @@ export default function ChatSection(props: ChatSectionProps) {
|
||||
)
|
||||
|
||||
return (
|
||||
<ChatProvider value={{ ...handler, requestData, setRequestData }}>
|
||||
<ChatProvider
|
||||
value={{
|
||||
...handler,
|
||||
input,
|
||||
setInput,
|
||||
requestData,
|
||||
setRequestData,
|
||||
isLoading,
|
||||
}}
|
||||
>
|
||||
<div className={cn('flex h-full w-full flex-col gap-4 p-5', className)}>
|
||||
<ChatCanvasProvider autoOpenCanvas={autoOpenCanvas}>
|
||||
{children}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
type MessageRole = 'system' | 'user' | 'assistant' | 'data'
|
||||
import { MessagePart } from './message-parts/types'
|
||||
|
||||
export type JSONValue =
|
||||
| null
|
||||
@@ -11,29 +11,34 @@ export type JSONValue =
|
||||
| JSONValue[]
|
||||
|
||||
export interface Message {
|
||||
content: string
|
||||
role: MessageRole
|
||||
annotations?: JSONValue[]
|
||||
id: string
|
||||
role: 'system' | 'user' | 'assistant'
|
||||
parts: MessagePart[]
|
||||
}
|
||||
|
||||
export type ChatRequestOptions = {
|
||||
headers?: Record<string, string> | Headers
|
||||
body?: object
|
||||
}
|
||||
|
||||
export type ChatHandler = {
|
||||
input: string
|
||||
setInput: (input: string) => void
|
||||
isLoading: boolean
|
||||
messages: Message[]
|
||||
reload?: (chatRequestOptions?: { data?: any }) => void
|
||||
stop?: () => void
|
||||
append: (
|
||||
message: Message,
|
||||
chatRequestOptions?: { data?: any }
|
||||
) => Promise<string | null | undefined>
|
||||
|
||||
// TODO: (Message & { id: string }) is a quick fix for compatibility with Message from ai/react
|
||||
// We should make Message type in ChatHandler more flexible. Eg: ChatHandler<T extends Message = Message>
|
||||
setMessages?: (messages: (Message & { id: string })[]) => void
|
||||
status: 'submitted' | 'streaming' | 'ready' | 'error'
|
||||
sendMessage: (msg: Message, opts?: ChatRequestOptions) => Promise<void>
|
||||
stop?: () => Promise<void>
|
||||
regenerate?: (opts?: { messageId?: string } & ChatRequestOptions) => void
|
||||
setMessages?: (messages: Message[]) => void
|
||||
}
|
||||
|
||||
export type ChatContext = ChatHandler & {
|
||||
// user input state
|
||||
input: string
|
||||
setInput: (input: string) => void
|
||||
|
||||
// additional data including in the body
|
||||
requestData: any
|
||||
setRequestData: (data: any) => void
|
||||
|
||||
// computed state from status
|
||||
isLoading: boolean
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { createContext, useContext } from 'react'
|
||||
import {
|
||||
ArtifactPart,
|
||||
ArtifactPartType,
|
||||
EventPart,
|
||||
EventPartType,
|
||||
FilePart,
|
||||
FilePartType,
|
||||
MessagePart,
|
||||
SourcesPart,
|
||||
SourcesPartType,
|
||||
SuggestionPart,
|
||||
SuggestionPartType,
|
||||
TextPart,
|
||||
TextPartType,
|
||||
} from './types'
|
||||
|
||||
export interface ChatPartContext {
|
||||
part: MessagePart
|
||||
}
|
||||
|
||||
export const chatPartContext = createContext<ChatPartContext | null>(null)
|
||||
|
||||
export const ChatPartProvider = chatPartContext.Provider
|
||||
|
||||
// Function overloads for automatic type inference
|
||||
export function usePart(type: typeof TextPartType): TextPart | null
|
||||
export function usePart(type: typeof FilePartType): FilePart | null
|
||||
export function usePart(type: typeof ArtifactPartType): ArtifactPart | null
|
||||
export function usePart(type: typeof EventPartType): EventPart | null
|
||||
export function usePart(type: typeof SourcesPartType): SourcesPart | null
|
||||
export function usePart(type: typeof SuggestionPartType): SuggestionPart | null
|
||||
export function usePart<T = MessagePart>(type: string): T | null
|
||||
|
||||
/**
|
||||
* Get the current part. Return null if the input type is not matched with current part type.
|
||||
*
|
||||
* @param type - The part type to match against
|
||||
* @returns The typed part if the type matches, null otherwise
|
||||
*
|
||||
* @example
|
||||
* // Automatically inferred as TextPart | null
|
||||
* const textPart = usePart('text')
|
||||
*
|
||||
* // Automatically inferred as ArtifactPart | null
|
||||
* const artifactPart = usePart('data-artifact')
|
||||
*
|
||||
* // For custom types, specify the generic
|
||||
* const customPart = usePart<CustomPart>('custom-type')
|
||||
*/
|
||||
export function usePart<T = MessagePart>(type: string): T | null {
|
||||
const context = useContext(chatPartContext)
|
||||
if (!context) {
|
||||
throw new Error('usePart must be used within a ChatPartProvider')
|
||||
}
|
||||
|
||||
if (context.part.type !== type) return null // part type not matched
|
||||
return context.part as T // return current part
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export * from './parts/event.js'
|
||||
export * from './parts/file.js'
|
||||
export * from './parts/markdown.js'
|
||||
export * from './parts/sources.js'
|
||||
export * from './parts/suggestion.js'
|
||||
export * from './parts/artifact.js'
|
||||
|
||||
export * from './context.js'
|
||||
export * from './types.js'
|
||||
export * from './utils.js'
|
||||
@@ -0,0 +1,13 @@
|
||||
import { ArtifactCard } from '../../canvas/artifact-card.js'
|
||||
import { usePart } from '../context.js'
|
||||
import { ArtifactPartType } from '../types.js'
|
||||
|
||||
/**
|
||||
* Display an artifact card in the chat message when artifact part is available
|
||||
* @param className - custom styles for the artifact
|
||||
*/
|
||||
export function ArtifactPartUI({ className }: { className?: string }) {
|
||||
const part = usePart(ArtifactPartType)
|
||||
if (!part) return null
|
||||
return <ArtifactCard data={part.data} className={className} />
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { ChatEvent } from '../../../widgets'
|
||||
import { usePart } from '../context.js'
|
||||
import { EventPartType } from '../types.js'
|
||||
|
||||
export interface EventPartProps {
|
||||
className?: string
|
||||
renderData?: (data: ChatEvent['data']) => React.ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Render an event inside a ChatMessage, return null if current part is not event type
|
||||
* This component is useful to show an event from the assistant.
|
||||
* Normally, it will start with "Loading" status and then change to "Success" with a result
|
||||
* @param className - custom styles for the event
|
||||
*/
|
||||
export function EventPartUI({ className, renderData }: EventPartProps) {
|
||||
const part = usePart(EventPartType)
|
||||
if (!part) return null
|
||||
return (
|
||||
<ChatEvent
|
||||
event={part.data}
|
||||
className={className}
|
||||
renderData={renderData}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { cn } from '../../../lib/utils'
|
||||
import { ChatFile } from '../../../widgets/chat-file'
|
||||
import { useChatMessage } from '../../chat-message.context.js'
|
||||
import { usePart } from '../context.js'
|
||||
import { FilePartType } from '../types.js'
|
||||
|
||||
/**
|
||||
* Render a file part inside a ChatMessage, return null if current part is not file type
|
||||
* This component is useful to show an uploaded file from the user or generated file from the assistant
|
||||
* @param className - custom styles for the file
|
||||
*/
|
||||
export function FilePartUI({ className }: { className?: string }) {
|
||||
const { message } = useChatMessage()
|
||||
const file = usePart(FilePartType)
|
||||
if (!file) return null
|
||||
|
||||
const alignmentClass = message.role === 'user' ? 'ml-auto' : 'mr-auto'
|
||||
return <ChatFile file={file.data} className={cn(alignmentClass, className)} />
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { ComponentType } from 'react'
|
||||
import { cn } from '../../../lib/utils.js'
|
||||
import {
|
||||
CitationComponentProps,
|
||||
LanguageRendererProps,
|
||||
Markdown,
|
||||
preprocessSourceNodes,
|
||||
} from '../../../widgets/index.js'
|
||||
import { useChatMessage } from '../../chat-message.context.js'
|
||||
import { SourcesPartType, TextPartType } from '../types.js'
|
||||
import { usePart } from '../context.js'
|
||||
import { getParts } from '../utils.js'
|
||||
|
||||
interface ChatMarkdownProps extends React.PropsWithChildren {
|
||||
citationComponent?: ComponentType<CitationComponentProps>
|
||||
className?: string
|
||||
languageRenderers?: Record<string, ComponentType<LanguageRendererProps>>
|
||||
}
|
||||
|
||||
/**
|
||||
* Render TextPart as a Markdown component.
|
||||
*/
|
||||
export function MarkdownPartUI(props: ChatMarkdownProps) {
|
||||
const { message } = useChatMessage()
|
||||
const markdown = usePart(TextPartType)?.text
|
||||
|
||||
const sourceParts = getParts(message, SourcesPartType)
|
||||
const sources = sourceParts.map(part => part.data)
|
||||
|
||||
const nodes =
|
||||
sources
|
||||
?.map(item => ({
|
||||
...item,
|
||||
nodes: item.nodes ? preprocessSourceNodes(item.nodes) : [],
|
||||
}))
|
||||
.flatMap(item => item.nodes) ?? []
|
||||
|
||||
if (!markdown) return null
|
||||
|
||||
return (
|
||||
<Markdown
|
||||
content={markdown}
|
||||
sources={{ nodes }}
|
||||
citationComponent={props.citationComponent}
|
||||
languageRenderers={props.languageRenderers}
|
||||
className={cn(
|
||||
{
|
||||
'bg-primary text-primary-foreground ml-auto w-fit max-w-[80%] rounded-xl px-3 py-2':
|
||||
message.role === 'user',
|
||||
},
|
||||
props.className
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { ChatSources, preprocessSourceNodes } from '../../../widgets/index.js'
|
||||
import { usePart } from '../context.js'
|
||||
import { SourcesPartType } from '../types.js'
|
||||
|
||||
/**
|
||||
* Render a list of sources inside a ChatMessage, return null if current part is not sources type
|
||||
* This component is useful to show a list of sources from the assistant.
|
||||
* @param className - custom styles for the sources
|
||||
*/
|
||||
export function SourcesPartUI({ className }: { className?: string }) {
|
||||
const sources = usePart(SourcesPartType)?.data
|
||||
const nodes = preprocessSourceNodes(sources?.nodes ?? [])
|
||||
|
||||
if (nodes.length === 0) return null
|
||||
return <ChatSources data={{ nodes }} className={className} />
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { SuggestedQuestions } from '../../../widgets/index.js'
|
||||
import { useChatMessage } from '../../chat-message.context.js'
|
||||
import { useChatUI } from '../../chat.context.js'
|
||||
import { usePart } from '../context.js'
|
||||
import { SuggestionPartType } from '../types.js'
|
||||
|
||||
/**
|
||||
* Render a suggested questions part inside a ChatMessage, return null if current part is not suggested questions type
|
||||
* This component is useful to show a list of suggested questions from the assistant.
|
||||
* @param className - custom styles for the suggested questions
|
||||
*/
|
||||
export function SuggestionPartUI({ className }: { className?: string }) {
|
||||
const { sendMessage, requestData } = useChatUI()
|
||||
const { isLast } = useChatMessage()
|
||||
const suggestedQuestions = usePart(SuggestionPartType)?.data
|
||||
|
||||
if (!isLast || !sendMessage || !suggestedQuestions) return null
|
||||
|
||||
return (
|
||||
<SuggestedQuestions
|
||||
questions={suggestedQuestions}
|
||||
sendMessage={sendMessage}
|
||||
requestData={requestData}
|
||||
className={className}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { type FileData } from '../../widgets/chat-file'
|
||||
import { type Artifact } from '../canvas/artifacts'
|
||||
import { type ChatEvent } from '../../widgets/chat-event'
|
||||
import { type SourceData } from '../../widgets/chat-sources'
|
||||
import { type SuggestedQuestionsData } from '../../widgets/suggested-questions'
|
||||
|
||||
export type MessagePart = TextPart | DataPart | FilePart | AnyPart
|
||||
|
||||
// All ChatUI supported part types
|
||||
export const TextPartType = 'text' as const
|
||||
export const FilePartType = 'data-file' as const
|
||||
export const ArtifactPartType = 'data-artifact' as const
|
||||
export const EventPartType = 'data-event' as const
|
||||
export const SourcesPartType = 'data-sources' as const
|
||||
export const SuggestionPartType = 'data-suggested_questions' as const
|
||||
|
||||
// Text Part: the text content of the message
|
||||
// It will be rendered in Markdown component
|
||||
export type TextPart = {
|
||||
type: typeof TextPartType
|
||||
text: string
|
||||
}
|
||||
|
||||
// Data Parts: data parts are other blocks that we want to display in the message
|
||||
// It can be artifact, event, sources, etc.
|
||||
export type DataPart<T extends `data-${string}` = `data-${string}`, D = any> = {
|
||||
id?: string // if id is provided, only last data part with the same id will be existed in message.parts
|
||||
type: T // `data-` prefix is required for data parts
|
||||
data: D
|
||||
}
|
||||
|
||||
export type FilePart = DataPart<typeof FilePartType, FileData>
|
||||
export type ArtifactPart = DataPart<typeof ArtifactPartType, Artifact>
|
||||
export type EventPart = DataPart<typeof EventPartType, ChatEvent>
|
||||
export type SourcesPart = DataPart<typeof SourcesPartType, SourceData>
|
||||
export type SuggestionPart = DataPart<
|
||||
typeof SuggestionPartType,
|
||||
SuggestedQuestionsData
|
||||
>
|
||||
|
||||
// Any Part: other parts that are not supported by ChatUI
|
||||
// You can still use `usePart` hook to get data and create your own display from these parts
|
||||
// Example: dynamic events (type = 'ui_event') or specific parts from Vercel AI SDK. See more details here:
|
||||
// https://github.com/vercel/ai/blob/7948ec215d21675c1100edf58af8bb03a1f1dbe4/packages/ai/src/ui/ui-messages.ts#L75-L272
|
||||
export type AnyPart<T extends string = any> = {
|
||||
id?: string
|
||||
type: T
|
||||
data?: any
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { Message } from '../chat.interface'
|
||||
import {
|
||||
ArtifactPart,
|
||||
ArtifactPartType,
|
||||
EventPart,
|
||||
EventPartType,
|
||||
FilePart,
|
||||
FilePartType,
|
||||
MessagePart,
|
||||
SourcesPart,
|
||||
SourcesPartType,
|
||||
SuggestionPart,
|
||||
SuggestionPartType,
|
||||
TextPart,
|
||||
TextPartType,
|
||||
} from './types'
|
||||
|
||||
// Function overloads for automatic type inference
|
||||
export function getParts(
|
||||
message: Message,
|
||||
type: typeof TextPartType
|
||||
): TextPart[]
|
||||
export function getParts(
|
||||
message: Message,
|
||||
type: typeof FilePartType
|
||||
): FilePart[]
|
||||
export function getParts(
|
||||
message: Message,
|
||||
type: typeof ArtifactPartType
|
||||
): ArtifactPart[]
|
||||
export function getParts(
|
||||
message: Message,
|
||||
type: typeof EventPartType
|
||||
): EventPart[]
|
||||
export function getParts(
|
||||
message: Message,
|
||||
type: typeof SourcesPartType
|
||||
): SourcesPart[]
|
||||
export function getParts(
|
||||
message: Message,
|
||||
type: typeof SuggestionPartType
|
||||
): SuggestionPart[]
|
||||
export function getParts<T extends MessagePart>(
|
||||
message: Message,
|
||||
type: string
|
||||
): T[]
|
||||
|
||||
/**
|
||||
* Get all parts of a specific type from a message.
|
||||
*
|
||||
* @param message - The message to search in
|
||||
* @param type - The part type to filter by
|
||||
* @returns Array of typed parts matching the specified type
|
||||
*
|
||||
* @example
|
||||
* // Automatically inferred as TextPart[]
|
||||
* const textParts = getParts(message, 'text')
|
||||
*
|
||||
* // Automatically inferred as ArtifactPart[]
|
||||
* const artifactParts = getParts(message, 'data-artifact')
|
||||
*
|
||||
* // For custom types, specify the generic
|
||||
* const customParts = getParts<CustomPart>(message, 'custom-type')
|
||||
*/
|
||||
export function getParts<T = MessagePart>(message: Message, type: string): T[] {
|
||||
return message.parts.filter(part => part.type === type) as T[]
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
import {
|
||||
MessageAnnotation,
|
||||
MessageAnnotationType,
|
||||
toInlineAnnotation,
|
||||
} from '../../chat/annotations'
|
||||
import { JSONValue } from '../../chat/chat.interface'
|
||||
import {
|
||||
ArtifactPartType,
|
||||
EventPartType,
|
||||
SourcesPartType,
|
||||
MessagePart,
|
||||
} from '../../chat/message-parts'
|
||||
import { ChatEvent, SourceNode } from '../../widgets'
|
||||
import { WorkflowEvent, WorkflowEventType } from '../use-workflow'
|
||||
import {
|
||||
AgentStreamEvent,
|
||||
@@ -13,7 +15,6 @@ import {
|
||||
ToolCallResultEvent,
|
||||
UIEvent,
|
||||
} from './types'
|
||||
import { AgentEventData, SourceNode } from '../../widgets'
|
||||
|
||||
/**
|
||||
* Transform a workflow event to message parts
|
||||
@@ -26,23 +27,12 @@ import { AgentEventData, SourceNode } from '../../widgets'
|
||||
export function transformEventToMessageParts(
|
||||
event: WorkflowEvent,
|
||||
fileServerUrl?: string
|
||||
): {
|
||||
delta: string
|
||||
annotations: MessageAnnotation<JSONValue>[]
|
||||
} {
|
||||
): MessagePart[] {
|
||||
if (isAgentStreamEvent(event)) {
|
||||
return { delta: event.data.delta, annotations: [] }
|
||||
return [{ type: 'text', text: event.data.delta }]
|
||||
}
|
||||
|
||||
if (isInlineEvent(event)) {
|
||||
return {
|
||||
delta: toInlineAnnotation(event.data as MessageAnnotation),
|
||||
annotations: [],
|
||||
}
|
||||
}
|
||||
|
||||
const annotations = toVercelAnnotations(event, fileServerUrl)
|
||||
return { delta: '', annotations }
|
||||
return toMessageParts(event, fileServerUrl)
|
||||
}
|
||||
|
||||
function isAgentStreamEvent(event: WorkflowEvent): event is AgentStreamEvent {
|
||||
@@ -54,15 +44,20 @@ function isAgentStreamEvent(event: WorkflowEvent): event is AgentStreamEvent {
|
||||
return event.type === WorkflowEventType.AgentStream.toString() && hasDelta
|
||||
}
|
||||
|
||||
function isInlineEvent(event: WorkflowEvent) {
|
||||
const inlineEventTypes = [WorkflowEventType.ArtifactEvent.toString()]
|
||||
const hasInlineData = typeof event.data === 'object' && event.data !== null
|
||||
|
||||
return inlineEventTypes.includes(event.type) && hasInlineData
|
||||
}
|
||||
|
||||
function toVercelAnnotations(event: WorkflowEvent, fileServerUrl?: string) {
|
||||
function toMessageParts(
|
||||
event: WorkflowEvent,
|
||||
fileServerUrl?: string
|
||||
): MessagePart[] {
|
||||
switch (event.type) {
|
||||
case WorkflowEventType.ArtifactEvent.toString(): {
|
||||
return [
|
||||
{
|
||||
type: ArtifactPartType,
|
||||
data: event.data,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
// convert source nodes event to source nodes annotation
|
||||
case WorkflowEventType.SourceNodesEvent.toString(): {
|
||||
const nodes = (event as SourceNodesEvent).data?.nodes || []
|
||||
@@ -76,7 +71,7 @@ function toVercelAnnotations(event: WorkflowEvent, fileServerUrl?: string) {
|
||||
|
||||
return [
|
||||
{
|
||||
type: MessageAnnotationType.SOURCES,
|
||||
type: SourcesPartType,
|
||||
data: {
|
||||
nodes: nodes.map(rawNode =>
|
||||
convertRawNodeToSourceNode(rawNode, fileServerUrl)
|
||||
@@ -110,11 +105,11 @@ function toVercelAnnotations(event: WorkflowEvent, fileServerUrl?: string) {
|
||||
|
||||
return [
|
||||
{
|
||||
type: MessageAnnotationType.AGENT_EVENTS,
|
||||
type: EventPartType,
|
||||
data: {
|
||||
agent: 'Agent',
|
||||
text: `Calling tool: ${tool_name} with: ${JSON.stringify(tool_kwargs)}`,
|
||||
} as AgentEventData,
|
||||
title: 'Agent',
|
||||
description: `Calling tool: ${tool_name} with: ${JSON.stringify(tool_kwargs)}`,
|
||||
} as ChatEvent,
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -146,7 +141,7 @@ function toVercelAnnotations(event: WorkflowEvent, fileServerUrl?: string) {
|
||||
const rawNodes = raw_output.source_nodes as RawNodeWithScore[]
|
||||
return [
|
||||
{
|
||||
type: MessageAnnotationType.SOURCES,
|
||||
type: SourcesPartType,
|
||||
data: {
|
||||
nodes: rawNodes.map(rawNode =>
|
||||
convertRawNodeToSourceNode(rawNode, fileServerUrl)
|
||||
@@ -170,7 +165,7 @@ function toVercelAnnotations(event: WorkflowEvent, fileServerUrl?: string) {
|
||||
default: {
|
||||
return [
|
||||
{
|
||||
type: event.type,
|
||||
type: `data-${event.type}`,
|
||||
data: event.data as JSONValue,
|
||||
},
|
||||
]
|
||||
|
||||
@@ -1,14 +1,22 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { JSONValue, Message } from '../../chat/chat.interface'
|
||||
import { useWorkflow } from '../use-workflow'
|
||||
import { ChatHandler, Message } from '../../chat/chat.interface'
|
||||
import { RunStatus, useWorkflow } from '../use-workflow'
|
||||
import { transformEventToMessageParts } from './helper'
|
||||
import {
|
||||
ChatEvent,
|
||||
ChatWorkflowHookHandler,
|
||||
ChatWorkflowHookParams,
|
||||
} from './types'
|
||||
import { MessagePart, TextPart, TextPartType } from '../../chat/message-parts'
|
||||
|
||||
const runStatusToChatStatus: Record<RunStatus, ChatHandler['status']> = {
|
||||
idle: 'ready',
|
||||
running: 'streaming',
|
||||
complete: 'submitted',
|
||||
error: 'error',
|
||||
}
|
||||
|
||||
export function useChatWorkflow({
|
||||
deployment,
|
||||
@@ -17,33 +25,30 @@ export function useChatWorkflow({
|
||||
onError,
|
||||
fileServerUrl,
|
||||
}: ChatWorkflowHookParams): ChatWorkflowHookHandler {
|
||||
const [input, setInput] = useState<string>('')
|
||||
const [messages, setMessages] = useState<Message[]>([])
|
||||
|
||||
const updateLastMessage = ({
|
||||
delta = '',
|
||||
annotations = [],
|
||||
}: {
|
||||
delta?: string // render events inline in markdown
|
||||
annotations?: JSONValue[] // render events in annotations components
|
||||
}) => {
|
||||
const updateLastMessage = (parts: MessagePart[]) => {
|
||||
setMessages(prev => {
|
||||
const lastMessage = prev[prev.length - 1]
|
||||
|
||||
// if last message is assistant message, update its content
|
||||
if (lastMessage?.role === 'assistant') {
|
||||
const updatedParts = [...lastMessage.parts, ...parts]
|
||||
|
||||
// Merge adjacent text parts while preserving order
|
||||
const mergedParts = mergeAdjacentTextParts(updatedParts)
|
||||
|
||||
return [
|
||||
...prev.slice(0, -1),
|
||||
{
|
||||
...lastMessage,
|
||||
content: (lastMessage.content || '') + delta,
|
||||
annotations: [...(lastMessage.annotations || []), ...annotations],
|
||||
parts: mergedParts,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
// if last message is user message, add a new assistant message
|
||||
return [...prev, { content: delta, role: 'assistant', annotations }]
|
||||
return [...prev, { role: 'assistant', parts } as Message]
|
||||
})
|
||||
}
|
||||
|
||||
@@ -52,24 +57,24 @@ export function useChatWorkflow({
|
||||
workflow,
|
||||
baseUrl,
|
||||
onData: event => {
|
||||
const { delta, annotations } = transformEventToMessageParts(
|
||||
event,
|
||||
fileServerUrl
|
||||
)
|
||||
updateLastMessage({ delta, annotations })
|
||||
const parts = transformEventToMessageParts(event, fileServerUrl)
|
||||
updateLastMessage(parts)
|
||||
},
|
||||
})
|
||||
|
||||
const append = async (newMessage: Message) => {
|
||||
setMessages(prev => [...prev, newMessage])
|
||||
|
||||
const newMessageContent = getTextMessageContent(newMessage)
|
||||
if (!newMessageContent) return
|
||||
|
||||
try {
|
||||
await start({ user_msg: newMessage.content, chat_history: messages })
|
||||
await start({ user_msg: newMessageContent, chat_history: messages })
|
||||
} catch (error) {
|
||||
onError?.(error)
|
||||
}
|
||||
|
||||
return newMessage.content
|
||||
return newMessageContent
|
||||
}
|
||||
|
||||
const handleStop = async () => {
|
||||
@@ -87,8 +92,14 @@ export function useChatWorkflow({
|
||||
setMessages([...chatHistory, lastUserMessage])
|
||||
|
||||
try {
|
||||
const lastUserMessageContent = lastUserMessage.parts.find(
|
||||
(part): part is TextPart => part.type === TextPartType
|
||||
)?.text
|
||||
|
||||
if (!lastUserMessageContent) return
|
||||
|
||||
await start({
|
||||
user_msg: lastUserMessage.content,
|
||||
user_msg: lastUserMessageContent,
|
||||
chat_history: chatHistory,
|
||||
})
|
||||
} catch (error) {
|
||||
@@ -105,17 +116,54 @@ export function useChatWorkflow({
|
||||
}
|
||||
}
|
||||
|
||||
const isLoading = status === 'running'
|
||||
|
||||
return {
|
||||
input,
|
||||
setInput,
|
||||
isLoading,
|
||||
append,
|
||||
status: runStatusToChatStatus[status || 'idle'],
|
||||
messages,
|
||||
setMessages,
|
||||
stop: handleStop,
|
||||
reload: handleReload,
|
||||
sendMessage: async (message: Message) => {
|
||||
await append(message)
|
||||
},
|
||||
regenerate: async () => {
|
||||
await handleReload()
|
||||
},
|
||||
resume: handleResume,
|
||||
}
|
||||
}
|
||||
|
||||
function getTextMessageContent(message: Message): string {
|
||||
return message.parts
|
||||
.filter((part): part is TextPart => part.type === TextPartType)
|
||||
.map(part => part.text)
|
||||
.join('\n\n')
|
||||
}
|
||||
|
||||
function mergeAdjacentTextParts(parts: MessagePart[]): MessagePart[] {
|
||||
const result: MessagePart[] = []
|
||||
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const currentPart = parts[i]
|
||||
|
||||
if (currentPart.type === TextPartType) {
|
||||
// Collect all consecutive text parts
|
||||
let mergedText = (currentPart as TextPart).text
|
||||
let j = i + 1
|
||||
|
||||
while (j < parts.length && parts[j].type === TextPartType) {
|
||||
mergedText += (parts[j] as TextPart).text
|
||||
j++
|
||||
}
|
||||
|
||||
// Add the merged text part
|
||||
result.push({ type: TextPartType, text: mergedText })
|
||||
|
||||
// Skip the parts we've already processed
|
||||
i = j - 1
|
||||
} else {
|
||||
// Non-text part, add as-is
|
||||
result.push(currentPart)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { FilePart, FilePartType } from '../chat/message-parts'
|
||||
import { DocumentFile } from '../widgets'
|
||||
|
||||
export function useFile({ uploadAPI }: { uploadAPI: string }) {
|
||||
const [imageUrl, setImageUrl] = useState<string | null>(null)
|
||||
const [image, setImage] = useState<{
|
||||
filename: string
|
||||
mediaType: string
|
||||
url: string
|
||||
} | null>(null)
|
||||
const [files, setFiles] = useState<DocumentFile[]>([])
|
||||
|
||||
const addDoc = (file: DocumentFile) => {
|
||||
@@ -21,7 +26,7 @@ export function useFile({ uploadAPI }: { uploadAPI: string }) {
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
imageUrl && setImageUrl(null)
|
||||
image && setImage(null)
|
||||
files.length && setFiles([])
|
||||
}
|
||||
|
||||
@@ -45,21 +50,24 @@ export function useFile({ uploadAPI }: { uploadAPI: string }) {
|
||||
return (await response.json()) as DocumentFile
|
||||
}
|
||||
|
||||
const getAnnotations = () => {
|
||||
const annotations = []
|
||||
if (imageUrl) {
|
||||
annotations.push({
|
||||
type: 'image',
|
||||
data: { url: imageUrl },
|
||||
})
|
||||
const getAttachments = (): FilePart[] => {
|
||||
const parts: FilePart[] = []
|
||||
if (image) {
|
||||
parts.push({ type: FilePartType, data: image })
|
||||
}
|
||||
if (files.length > 0) {
|
||||
annotations.push({
|
||||
type: 'document_file',
|
||||
data: { files },
|
||||
})
|
||||
parts.push(
|
||||
...files.map(file => ({
|
||||
type: FilePartType,
|
||||
data: {
|
||||
filename: file.name,
|
||||
mediaType: file.type,
|
||||
url: file.url,
|
||||
},
|
||||
}))
|
||||
)
|
||||
}
|
||||
return annotations
|
||||
return parts
|
||||
}
|
||||
|
||||
const readContent = async (input: {
|
||||
@@ -83,7 +91,11 @@ export function useFile({ uploadAPI }: { uploadAPI: string }) {
|
||||
const uploadFile = async (file: File, requestParams: any = {}) => {
|
||||
if (file.type.startsWith('image/')) {
|
||||
const base64 = await readContent({ file, asUrl: true })
|
||||
return setImageUrl(base64)
|
||||
return setImage({
|
||||
filename: file.name,
|
||||
mediaType: file.type,
|
||||
url: base64,
|
||||
})
|
||||
}
|
||||
|
||||
// Upload any non-image file as a document
|
||||
@@ -92,12 +104,12 @@ export function useFile({ uploadAPI }: { uploadAPI: string }) {
|
||||
}
|
||||
|
||||
return {
|
||||
imageUrl,
|
||||
setImageUrl,
|
||||
image,
|
||||
setImage,
|
||||
files,
|
||||
removeDoc,
|
||||
reset,
|
||||
getAnnotations,
|
||||
getAttachments,
|
||||
uploadFile,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,14 +2,12 @@
|
||||
|
||||
// Chat components
|
||||
export * from './chat/chat.interface'
|
||||
export * from './chat/annotations'
|
||||
export * from './chat/canvas/artifacts'
|
||||
export { default as ChatSection } from './chat/chat-section'
|
||||
export { default as ChatCanvas } from './chat/canvas'
|
||||
export { default as ChatInput } from './chat/chat-input'
|
||||
export { default as ChatMessages } from './chat/chat-messages'
|
||||
export { default as ChatMessage, ContentPosition } from './chat/chat-message'
|
||||
export { defaultAnnotationRenderers } from './chat/chat-renderers'
|
||||
export { default as ChatMessage } from './chat/chat-message'
|
||||
|
||||
// Context Provider Hooks
|
||||
export { useChatUI } from './chat/chat.context'
|
||||
@@ -17,6 +15,7 @@ export { useChatCanvas } from './chat/canvas/context'
|
||||
export { useChatMessage } from './chat/chat-message.context'
|
||||
export { useChatInput } from './chat/chat-input'
|
||||
export { useChatMessages } from './chat/chat-messages'
|
||||
export * from './chat/message-parts'
|
||||
|
||||
// Custom Hooks
|
||||
export { useFile } from './hook/use-file'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ArtifactPartType, SourcesPartType } from '../../chat/message-parts'
|
||||
import { transformEventToMessageParts } from '../../hook/use-chat-workflow/helper'
|
||||
import { WorkflowEventType, WorkflowEvent } from '../../hook/use-workflow/types'
|
||||
import { MessageAnnotationType } from '../../chat/annotations/types'
|
||||
import { WorkflowEvent, WorkflowEventType } from '../../hook/use-workflow/types'
|
||||
|
||||
describe('useChatWorkflow - transformEventToMessageParts', () => {
|
||||
beforeEach(() => {
|
||||
@@ -19,10 +19,7 @@ describe('useChatWorkflow - transformEventToMessageParts', () => {
|
||||
|
||||
const result = transformEventToMessageParts(event)
|
||||
|
||||
expect(result).toEqual({
|
||||
delta: 'Hello, world!',
|
||||
annotations: [],
|
||||
})
|
||||
expect(result).toEqual([{ type: 'text', text: 'Hello, world!' }])
|
||||
})
|
||||
|
||||
it('should handle empty delta in agent stream events', () => {
|
||||
@@ -35,17 +32,14 @@ describe('useChatWorkflow - transformEventToMessageParts', () => {
|
||||
|
||||
const result = transformEventToMessageParts(event)
|
||||
|
||||
expect(result).toEqual({
|
||||
delta: '',
|
||||
annotations: [],
|
||||
})
|
||||
expect(result).toEqual([{ type: 'text', text: '' }])
|
||||
})
|
||||
})
|
||||
|
||||
describe('ArtifactEvent handling (inline events)', () => {
|
||||
it('should convert artifact events to inline annotations', () => {
|
||||
it('should convert artifact events to message parts', () => {
|
||||
const artifactData = {
|
||||
type: MessageAnnotationType.ARTIFACT,
|
||||
type: ArtifactPartType,
|
||||
data: {
|
||||
title: 'Test Artifact',
|
||||
content: 'Some artifact content',
|
||||
@@ -59,14 +53,12 @@ describe('useChatWorkflow - transformEventToMessageParts', () => {
|
||||
|
||||
const result = transformEventToMessageParts(event)
|
||||
|
||||
expect(result.delta).toContain('```annotation')
|
||||
expect(result.delta).toContain(JSON.stringify(artifactData))
|
||||
expect(result.annotations).toEqual([])
|
||||
expect(result).toEqual([{ type: ArtifactPartType, data: artifactData }])
|
||||
})
|
||||
|
||||
it('should handle artifact events with complex data', () => {
|
||||
const complexArtifactData = {
|
||||
type: MessageAnnotationType.ARTIFACT,
|
||||
type: ArtifactPartType,
|
||||
data: {
|
||||
title: 'Complex Artifact',
|
||||
content: {
|
||||
@@ -87,9 +79,9 @@ describe('useChatWorkflow - transformEventToMessageParts', () => {
|
||||
|
||||
const result = transformEventToMessageParts(event)
|
||||
|
||||
expect(result.delta).toContain('```annotation')
|
||||
expect(result.delta).toContain(JSON.stringify(complexArtifactData))
|
||||
expect(result.annotations).toEqual([])
|
||||
expect(result).toEqual([
|
||||
{ type: ArtifactPartType, data: complexArtifactData },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -127,35 +119,35 @@ describe('useChatWorkflow - transformEventToMessageParts', () => {
|
||||
|
||||
const result = transformEventToMessageParts(event)
|
||||
|
||||
expect(result.delta).toBe('')
|
||||
expect(result.annotations).toHaveLength(1)
|
||||
expect(result.annotations[0]).toEqual({
|
||||
type: MessageAnnotationType.SOURCES,
|
||||
data: {
|
||||
nodes: [
|
||||
{
|
||||
id: 'node-1',
|
||||
metadata: {
|
||||
title: 'Test Document',
|
||||
URL: 'https://example.com/doc1',
|
||||
expect(result).toEqual([
|
||||
{
|
||||
type: SourcesPartType,
|
||||
data: {
|
||||
nodes: [
|
||||
{
|
||||
id: 'node-1',
|
||||
metadata: {
|
||||
title: 'Test Document',
|
||||
URL: 'https://example.com/doc1',
|
||||
},
|
||||
score: 0.95,
|
||||
text: 'This is the content of node 1',
|
||||
url: 'https://example.com/doc1',
|
||||
},
|
||||
score: 0.95,
|
||||
text: 'This is the content of node 1',
|
||||
url: 'https://example.com/doc1',
|
||||
},
|
||||
{
|
||||
id: 'node-2',
|
||||
metadata: {
|
||||
title: 'Another Document',
|
||||
URL: 'https://example.com/doc2',
|
||||
{
|
||||
id: 'node-2',
|
||||
metadata: {
|
||||
title: 'Another Document',
|
||||
URL: 'https://example.com/doc2',
|
||||
},
|
||||
score: 0.87,
|
||||
text: 'This is the content of node 2',
|
||||
url: 'https://example.com/doc2',
|
||||
},
|
||||
score: 0.87,
|
||||
text: 'This is the content of node 2',
|
||||
url: 'https://example.com/doc2',
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('should handle source nodes events with empty nodes array', () => {
|
||||
@@ -172,8 +164,7 @@ describe('useChatWorkflow - transformEventToMessageParts', () => {
|
||||
|
||||
const result = transformEventToMessageParts(event)
|
||||
|
||||
expect(result.delta).toBe('')
|
||||
expect(result.annotations).toEqual([])
|
||||
expect(result).toEqual([])
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('No nodes found in source nodes event')
|
||||
)
|
||||
@@ -202,25 +193,33 @@ describe('useChatWorkflow - transformEventToMessageParts', () => {
|
||||
|
||||
const result = transformEventToMessageParts(event)
|
||||
|
||||
expect(result.annotations[0].data).toHaveProperty('nodes')
|
||||
expect((result.annotations[0].data as any).nodes[0]).toEqual({
|
||||
id: 'node-without-url',
|
||||
metadata: {
|
||||
title: 'Document without URL',
|
||||
expect(result).toEqual([
|
||||
{
|
||||
type: SourcesPartType,
|
||||
data: {
|
||||
nodes: [
|
||||
{
|
||||
id: 'node-without-url',
|
||||
metadata: {
|
||||
title: 'Document without URL',
|
||||
},
|
||||
score: 0.8,
|
||||
text: 'Content without URL',
|
||||
url: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
score: 0.8,
|
||||
text: 'Content without URL',
|
||||
url: '',
|
||||
})
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('UIEvent handling', () => {
|
||||
it('should convert UI events to vercel annotations', () => {
|
||||
it('should convert UI events to message parts', () => {
|
||||
const event: WorkflowEvent = {
|
||||
type: WorkflowEventType.UIEvent.toString(),
|
||||
data: {
|
||||
type: 'weather',
|
||||
type: 'data-weather',
|
||||
data: {
|
||||
location: 'San Francisco',
|
||||
temperature: 22,
|
||||
@@ -233,18 +232,18 @@ describe('useChatWorkflow - transformEventToMessageParts', () => {
|
||||
|
||||
const result = transformEventToMessageParts(event)
|
||||
|
||||
expect(result.delta).toBe('')
|
||||
expect(result.annotations).toHaveLength(1)
|
||||
expect(result.annotations[0]).toEqual({
|
||||
type: 'weather',
|
||||
data: {
|
||||
location: 'San Francisco',
|
||||
temperature: 22,
|
||||
condition: 'sunny',
|
||||
humidity: 50,
|
||||
windSpeed: 10,
|
||||
expect(result).toEqual([
|
||||
{
|
||||
type: 'data-weather',
|
||||
data: {
|
||||
location: 'San Francisco',
|
||||
temperature: 22,
|
||||
condition: 'sunny',
|
||||
humidity: 50,
|
||||
windSpeed: 10,
|
||||
},
|
||||
},
|
||||
})
|
||||
])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,233 +0,0 @@
|
||||
import { icons, LucideIcon } from 'lucide-react'
|
||||
import { useMemo } from 'react'
|
||||
import { Button } from '../ui/button'
|
||||
import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
} from '../ui/drawer'
|
||||
import { Markdown } from './markdown'
|
||||
import { Progress } from '../ui/progress'
|
||||
|
||||
const AgentIcons: Record<string, LucideIcon> = {
|
||||
bot: icons.Bot,
|
||||
researcher: icons.ScanSearch,
|
||||
writer: icons.PenLine,
|
||||
reviewer: icons.MessageCircle,
|
||||
publisher: icons.BookCheck,
|
||||
}
|
||||
|
||||
type StepText = {
|
||||
text: string
|
||||
}
|
||||
|
||||
type StepProgress = {
|
||||
text: string
|
||||
progress: ProgressData
|
||||
}
|
||||
|
||||
type MergedEvent = {
|
||||
agent: string
|
||||
icon: LucideIcon
|
||||
steps: (StepText | StepProgress)[]
|
||||
}
|
||||
|
||||
export type ProgressData = {
|
||||
id: string
|
||||
total: number
|
||||
current: number
|
||||
}
|
||||
|
||||
export type AgentEventData = {
|
||||
agent: string
|
||||
text: string
|
||||
type: 'text' | 'progress'
|
||||
data?: ProgressData
|
||||
}
|
||||
export function ChatAgentEvents({
|
||||
data,
|
||||
isFinished,
|
||||
isLast,
|
||||
}: {
|
||||
data: AgentEventData[]
|
||||
isFinished: boolean
|
||||
isLast: boolean
|
||||
}) {
|
||||
const events = useMemo(() => mergeAdjacentEvents(data), [data])
|
||||
return (
|
||||
<div className="pl-2">
|
||||
<div className="space-y-4 text-sm">
|
||||
{events.map((eventItem, index) => (
|
||||
<AgentEventContent
|
||||
key={index}
|
||||
event={eventItem}
|
||||
isLast={isLast}
|
||||
isFinished={isFinished}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const MAX_TEXT_LENGTH = 150
|
||||
|
||||
function TextContent({ agent, step }: { agent: string; step: StepText }) {
|
||||
const { displayText, showMore } = useMemo(
|
||||
() => ({
|
||||
displayText: step.text.slice(0, MAX_TEXT_LENGTH),
|
||||
showMore: step.text.length > MAX_TEXT_LENGTH,
|
||||
}),
|
||||
[step.text]
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="whitespace-break-spaces">
|
||||
{!showMore && <span>{step.text}</span>}
|
||||
{showMore && (
|
||||
<div>
|
||||
<span>{displayText}...</span>
|
||||
<AgentEventDialog content={step.text} title={`Agent "${agent}"`}>
|
||||
<span className="ml-2 cursor-pointer font-semibold underline">
|
||||
Show more
|
||||
</span>
|
||||
</AgentEventDialog>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ProgressContent({ step }: { step: StepProgress }) {
|
||||
const progressValue =
|
||||
step.progress.total !== 0
|
||||
? Math.round(((step.progress.current + 1) / step.progress.total) * 100)
|
||||
: 0
|
||||
|
||||
return (
|
||||
<div className="mt-2 space-y-2">
|
||||
{step.text && (
|
||||
<p className="text-muted-foreground text-sm">{step.text}</p>
|
||||
)}
|
||||
<Progress value={progressValue} className="h-2 w-full" />
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Processing {step.progress.current + 1} of {step.progress.total} steps...
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AgentEventContent({
|
||||
event,
|
||||
isLast,
|
||||
isFinished,
|
||||
}: {
|
||||
event: MergedEvent
|
||||
isLast: boolean
|
||||
isFinished: boolean
|
||||
}) {
|
||||
const { agent, steps } = event
|
||||
const AgentIcon = event.icon
|
||||
const textSteps = steps.filter(step => !('progress' in step))
|
||||
const progressSteps = steps.filter(
|
||||
step => 'progress' in step
|
||||
) as StepProgress[]
|
||||
// We only show progress at the last step
|
||||
// TODO: once we support steps that work in parallel, we need to update this
|
||||
const lastProgressStep =
|
||||
progressSteps.length > 0
|
||||
? progressSteps[progressSteps.length - 1]
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<div className="fadein-agent flex items-center gap-4 border-b pb-4">
|
||||
<div className="flex w-[100px] flex-col items-center gap-2">
|
||||
<div className="relative">
|
||||
{isLast && !isFinished && (
|
||||
<div className="absolute -right-4 -top-0">
|
||||
<span className="relative flex h-3 w-3">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-sky-400 opacity-75" />
|
||||
<span className="relative inline-flex h-3 w-3 rounded-full bg-sky-500" />
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<AgentIcon />
|
||||
</div>
|
||||
<span className="font-bold">{agent}</span>
|
||||
</div>
|
||||
{textSteps.length > 0 && (
|
||||
<div className="flex-1">
|
||||
<ul className="list-decimal space-y-2">
|
||||
{textSteps.map((step, index) => (
|
||||
<li key={index}>
|
||||
<TextContent agent={agent} step={step} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{lastProgressStep && !isFinished && (
|
||||
<ProgressContent step={lastProgressStep} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type AgentEventDialogProps = {
|
||||
title: string
|
||||
content: string
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
function AgentEventDialog(props: AgentEventDialogProps) {
|
||||
return (
|
||||
<Drawer direction="left">
|
||||
<DrawerTrigger asChild>{props.children}</DrawerTrigger>
|
||||
<DrawerContent className="mt-24 h-full max-h-[96%] w-3/5">
|
||||
<DrawerHeader className="flex justify-between">
|
||||
<div className="space-y-2">
|
||||
<DrawerTitle>{props.title}</DrawerTitle>
|
||||
</div>
|
||||
<DrawerClose asChild>
|
||||
<Button variant="outline">Close</Button>
|
||||
</DrawerClose>
|
||||
</DrawerHeader>
|
||||
<div className="m-4 overflow-auto">
|
||||
<Markdown content={props.content} />
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
||||
function mergeAdjacentEvents(events: AgentEventData[]): MergedEvent[] {
|
||||
const mergedEvents: MergedEvent[] = []
|
||||
|
||||
for (const event of events) {
|
||||
const lastMergedEvent = mergedEvents[mergedEvents.length - 1]
|
||||
|
||||
const eventStep: StepText | StepProgress = event.data
|
||||
? ({
|
||||
text: event.text,
|
||||
progress: event.data,
|
||||
} as StepProgress)
|
||||
: ({
|
||||
text: event.text,
|
||||
} as StepText)
|
||||
|
||||
if (lastMergedEvent && lastMergedEvent.agent === event.agent) {
|
||||
lastMergedEvent.steps.push(eventStep)
|
||||
} else {
|
||||
mergedEvents.push({
|
||||
agent: event.agent,
|
||||
steps: [eventStep],
|
||||
icon: AgentIcons[event.agent.toLowerCase()] ?? icons.Bot,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return mergedEvents
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Loader2,
|
||||
} from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { Button } from '../ui/button'
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '../ui/collapsible'
|
||||
import { cn } from '../lib/utils'
|
||||
|
||||
export type ChatEvent = {
|
||||
title: string
|
||||
description?: string
|
||||
status: 'pending' | 'success' | 'error'
|
||||
data?: any
|
||||
}
|
||||
|
||||
export function ChatEvent({
|
||||
event,
|
||||
className,
|
||||
renderData,
|
||||
}: {
|
||||
event: ChatEvent
|
||||
className?: string
|
||||
renderData?: (data: ChatEvent['data']) => React.ReactNode
|
||||
}) {
|
||||
const [isDataOpen, setIsDataOpen] = useState(false)
|
||||
|
||||
const getStatusIcon = () => {
|
||||
switch (event.status) {
|
||||
case 'pending':
|
||||
return <Loader2 className="h-4 w-4 animate-spin text-yellow-500" />
|
||||
case 'success':
|
||||
return <CheckCircle className="h-4 w-4 text-green-500" />
|
||||
case 'error':
|
||||
return <XCircle className="h-4 w-4 text-red-500" />
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusColor = () => {
|
||||
switch (event.status) {
|
||||
case 'pending':
|
||||
return 'border-yellow-400'
|
||||
case 'success':
|
||||
return 'border-green-400'
|
||||
case 'error':
|
||||
return 'border-red-400'
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('border-l-2 py-2 pl-4', getStatusColor(), className)}>
|
||||
{/* Header with title and status */}
|
||||
<div className="chat-event-header flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<h3 className="text-sm font-medium">{event.title}</h3>
|
||||
{event.description && (
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{event.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="ml-2 flex items-center space-x-1">
|
||||
{getStatusIcon()}
|
||||
<span className="text-xs capitalize">{event.status}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Data section if data exists */}
|
||||
{event.data && (
|
||||
<div className="chat-event-data mt-3">
|
||||
<Collapsible open={isDataOpen} onOpenChange={setIsDataOpen}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-6 px-2 text-xs">
|
||||
{isDataOpen ? (
|
||||
<ChevronDown className="mr-1 h-3 w-3" />
|
||||
) : (
|
||||
<ChevronRight className="mr-1 h-3 w-3" />
|
||||
)}
|
||||
{isDataOpen ? 'Hide data' : 'Show data'}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
{renderData ? (
|
||||
renderData(event.data)
|
||||
) : (
|
||||
<pre className="bg-muted mt-2 max-h-40 overflow-auto rounded p-2 text-xs">
|
||||
{JSON.stringify(event.data, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import { ChevronDown, ChevronRight, Loader2 } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { Button } from '../ui/button'
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '../ui/collapsible'
|
||||
|
||||
export type EventData = {
|
||||
title: string
|
||||
}
|
||||
|
||||
export function ChatEvents({
|
||||
data,
|
||||
showLoading,
|
||||
}: {
|
||||
data: EventData[]
|
||||
showLoading: boolean
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
|
||||
const buttonLabel = isOpen ? 'Hide events' : 'Show events'
|
||||
|
||||
const EventIcon = isOpen ? (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="border-l-2 border-indigo-400 pl-2">
|
||||
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button variant="secondary" className="space-x-2">
|
||||
{showLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
||||
<span>{buttonLabel}</span>
|
||||
{EventIcon}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent asChild>
|
||||
<div className="mt-4 space-y-2 text-sm">
|
||||
{data.map((eventItem, index) => (
|
||||
<div className="whitespace-break-spaces" key={index}>
|
||||
{eventItem.title}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { FileIcon } from 'lucide-react'
|
||||
import { cn } from '../lib/utils'
|
||||
import { DocxIcon } from '../ui/icons/docx'
|
||||
import { PDFIcon } from '../ui/icons/pdf'
|
||||
import { SheetIcon } from '../ui/icons/sheet'
|
||||
import { TxtIcon } from '../ui/icons/txt'
|
||||
|
||||
export type FileData = {
|
||||
filename: string
|
||||
mediaType: string // https://www.iana.org/assignments/media-types/media-types.xhtml
|
||||
url: string // can be a URL to a hosted file or a [Data URL](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/Data_URLs).
|
||||
}
|
||||
|
||||
const FileIconMap: Record<string, React.ReactNode> = {
|
||||
csv: <SheetIcon />,
|
||||
pdf: <PDFIcon />,
|
||||
docx: <DocxIcon />,
|
||||
txt: <TxtIcon />,
|
||||
}
|
||||
|
||||
export function ChatFile({
|
||||
file,
|
||||
className,
|
||||
}: {
|
||||
file: FileData
|
||||
className?: string
|
||||
}) {
|
||||
const isImage = isImageFile(file)
|
||||
const fileExtension = getFileExtension(file.filename)
|
||||
|
||||
const handleClick = () => {
|
||||
if (file.url) {
|
||||
window.open(file.url, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'bg-secondary flex max-w-96 items-center gap-2 rounded-lg px-3 py-2 text-sm',
|
||||
file.url && 'hover:bg-secondary/80 cursor-pointer transition-colors',
|
||||
className
|
||||
)}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<div className="flex h-12 w-12 shrink-0 items-center justify-center overflow-hidden rounded-lg">
|
||||
{file.url && isImage ? (
|
||||
<img
|
||||
src={file.url}
|
||||
alt="uploaded-image"
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-12 w-12 items-center justify-center">
|
||||
{FileIconMap[fileExtension] ?? <FileIcon />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="truncate font-medium">{file.filename}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Helper function to check if file is an image
|
||||
function isImageFile(file: FileData): boolean {
|
||||
return file.mediaType.startsWith('image/')
|
||||
}
|
||||
|
||||
// Helper function to get file extension
|
||||
function getFileExtension(fileName: string): string {
|
||||
return fileName.split('.').pop()?.toLowerCase() ?? ''
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import { DocumentInfo, DocumentFile } from './document-info'
|
||||
import { cn } from '../lib/utils'
|
||||
|
||||
export type DocumentFileData = {
|
||||
files: DocumentFile[]
|
||||
}
|
||||
|
||||
export function ChatFiles({
|
||||
data,
|
||||
className,
|
||||
}: {
|
||||
data: DocumentFileData
|
||||
className?: string
|
||||
}) {
|
||||
if (!data.files.length) return null
|
||||
return (
|
||||
<div className={cn('flex items-center gap-2', className)}>
|
||||
{data.files.map(file => (
|
||||
<DocumentInfo
|
||||
key={file.id}
|
||||
document={{ url: file.url, sources: [] }}
|
||||
className="mb-2 mt-2"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
export type ImageData = {
|
||||
url: string
|
||||
}
|
||||
|
||||
export function ChatImage({ data }: { data: ImageData }) {
|
||||
return (
|
||||
<div className="max-w-[200px] rounded-md shadow-md">
|
||||
<img src={data.url} alt="chat_image" className="h-auto w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,11 +1,30 @@
|
||||
import { useMemo } from 'react'
|
||||
import { Document, DocumentInfo, SourceNode } from './document-info'
|
||||
import { cn } from '../lib/utils'
|
||||
|
||||
export type SourceData = {
|
||||
nodes: SourceNode[]
|
||||
}
|
||||
|
||||
export function ChatSources({ data }: { data: SourceData }) {
|
||||
export function preprocessSourceNodes(nodes: SourceNode[]): SourceNode[] {
|
||||
// Filter source nodes has lower score
|
||||
const processedNodes = nodes.map(node => {
|
||||
// remove trailing slash for node url if exists
|
||||
if (node.url) {
|
||||
node.url = node.url.replace(/\/$/, '')
|
||||
}
|
||||
return node
|
||||
})
|
||||
return processedNodes
|
||||
}
|
||||
|
||||
export function ChatSources({
|
||||
data,
|
||||
className,
|
||||
}: {
|
||||
data: SourceData
|
||||
className?: string
|
||||
}) {
|
||||
const documents: Document[] = useMemo(() => {
|
||||
// group nodes by document (a document must have a URL)
|
||||
const nodesByUrl: Record<string, SourceNode[]> = {}
|
||||
@@ -27,7 +46,7 @@ export function ChatSources({ data }: { data: SourceData }) {
|
||||
if (documents.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className={cn('space-y-2 text-sm', className)}>
|
||||
<div className="text-lg font-semibold">Sources:</div>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{documents.map((document, index) => {
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
'use client'
|
||||
|
||||
// Other useful components
|
||||
export * from './chat-agent-events'
|
||||
export * from './chat-events'
|
||||
export * from './chat-files'
|
||||
export * from './chat-image'
|
||||
export * from './chat-event'
|
||||
export * from './chat-file'
|
||||
export * from './chat-sources'
|
||||
export * from './markdown'
|
||||
export * from './codeblock'
|
||||
|
||||
@@ -8,7 +8,6 @@ import { DocumentInfo } from './document-info'
|
||||
import { SourceData } from './chat-sources'
|
||||
import { Citation, CitationComponentProps } from './citation'
|
||||
import { cn } from '../lib/utils'
|
||||
import { parseInlineAnnotation } from '../chat/annotations/inline'
|
||||
|
||||
const MemoizedReactMarkdown: FC<Options> = memo(
|
||||
ReactMarkdown,
|
||||
@@ -118,7 +117,6 @@ export function Markdown({
|
||||
citationComponent: CitationComponent,
|
||||
className: customClassName,
|
||||
languageRenderers,
|
||||
annotationRenderers,
|
||||
}: {
|
||||
content: string
|
||||
sources?: SourceData
|
||||
@@ -126,7 +124,6 @@ export function Markdown({
|
||||
citationComponent?: ComponentType<CitationComponentProps>
|
||||
className?: string
|
||||
languageRenderers?: Record<string, ComponentType<LanguageRendererProps>>
|
||||
annotationRenderers?: Record<string, ComponentType<{ data: any }>>
|
||||
}) {
|
||||
const processedContent = preprocessContent(content)
|
||||
|
||||
@@ -158,30 +155,6 @@ export function Markdown({
|
||||
const language = (match && match[1]) || ''
|
||||
const codeValue = String(children).replace(/\n$/, '')
|
||||
|
||||
const annotation = parseInlineAnnotation(language, codeValue)
|
||||
|
||||
if (annotation) {
|
||||
// Check if we have a specific renderer for it
|
||||
if (annotationRenderers?.[annotation.type]) {
|
||||
const CustomRenderer = annotationRenderers[annotation.type]
|
||||
return (
|
||||
<div className="custom-renderer my-4">
|
||||
<CustomRenderer data={annotation.data} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// If no custom renderer found, render an error message
|
||||
return (
|
||||
<div className="mb-2 max-w-full overflow-hidden rounded-md border border-red-200 bg-red-50 p-3 dark:border-red-800 dark:bg-red-900/20">
|
||||
<div className="overflow-wrap-anywhere whitespace-pre-wrap break-words text-sm text-red-800 dark:text-red-200">
|
||||
<strong>Annotation Render Error:</strong> No renderer found
|
||||
for annotation type “{annotation.type}”.
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (inline) {
|
||||
return (
|
||||
<code className={className} {...props}>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { ChatHandler } from '../chat/chat.interface'
|
||||
import { ChatContext } from '../chat/chat.interface'
|
||||
import { cn } from '../lib/utils'
|
||||
import { Button } from '../ui/button'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
|
||||
interface StarterQuestionsProps {
|
||||
questions: string[]
|
||||
append: ChatHandler['append']
|
||||
sendMessage: ChatContext['sendMessage']
|
||||
className?: string
|
||||
}
|
||||
|
||||
@@ -16,7 +17,13 @@ export function StarterQuestions(props: StarterQuestionsProps) {
|
||||
<Button
|
||||
key={i}
|
||||
variant="outline"
|
||||
onClick={() => props.append({ role: 'user', content: question })}
|
||||
onClick={() =>
|
||||
props.sendMessage({
|
||||
id: uuidv4(),
|
||||
role: 'user',
|
||||
parts: [{ type: 'text', text: question }],
|
||||
})
|
||||
}
|
||||
className="h-auto whitespace-break-spaces"
|
||||
>
|
||||
{question}
|
||||
|
||||
@@ -1,25 +1,36 @@
|
||||
import { ChatHandler } from '../chat/chat.interface'
|
||||
import { ChatContext } from '../chat/chat.interface'
|
||||
import { cn } from '../lib/utils'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
|
||||
export type SuggestedQuestionsData = string[]
|
||||
|
||||
export function SuggestedQuestions({
|
||||
questions,
|
||||
append,
|
||||
sendMessage,
|
||||
requestData,
|
||||
className,
|
||||
}: {
|
||||
questions: SuggestedQuestionsData
|
||||
append: ChatHandler['append']
|
||||
sendMessage: ChatContext['sendMessage']
|
||||
requestData?: any
|
||||
className?: string
|
||||
}) {
|
||||
const showQuestions = questions.length > 0
|
||||
return (
|
||||
showQuestions && (
|
||||
<div className="flex flex-col space-y-2">
|
||||
<div className={cn('flex flex-col space-y-2', className)}>
|
||||
{questions.map((question, index) => (
|
||||
<a
|
||||
key={index}
|
||||
onClick={() => {
|
||||
append({ role: 'user', content: question }, { data: requestData })
|
||||
sendMessage(
|
||||
{
|
||||
id: uuidv4(),
|
||||
role: 'user',
|
||||
parts: [{ type: 'text', text: question }],
|
||||
},
|
||||
{ body: requestData }
|
||||
)
|
||||
}}
|
||||
className="cursor-pointer text-sm italic hover:underline"
|
||||
>
|
||||
|
||||
@@ -55,5 +55,6 @@ module.exports = {
|
||||
'@next/next/no-img-element': 'off',
|
||||
'react/jsx-sort-props': 'off',
|
||||
'no-template-curly-in-string': 'off',
|
||||
'@typescript-eslint/consistent-type-definitions': 'off',
|
||||
},
|
||||
}
|
||||
|
||||
@@ -140,6 +140,56 @@ export const workflowFactory = async () => {
|
||||
|
||||
To generate sophisticated examples of workflows, you best use the [create-llama](https://github.com/run-llama/create-llama) project.
|
||||
|
||||
## ServerMessage
|
||||
|
||||
The `ServerMessage` class is a central utility for handling messages in the LlamaIndex Server. It provides convenient methods to extract and process different types of content from Vercel AI SDK messages, including artifacts, file attachments, and human responses.
|
||||
|
||||
### Key Features
|
||||
|
||||
- **Extract artifacts**: Get code artifacts, documents, and other generated content
|
||||
- **Access attachments**: Retrieve file attachments from messages
|
||||
- **Convert to LlamaIndex format**: Transform messages for use with LlamaIndex workflows
|
||||
- **Type-safe filtering**: Built-in type guards for different message part types
|
||||
|
||||
### Usage
|
||||
|
||||
Import and create a `ServerMessage` instance:
|
||||
|
||||
```typescript
|
||||
import { toServerMessage } from '@llamaindex/server'
|
||||
import { UIMessage } from '@ai-sdk/react'
|
||||
|
||||
// Convert Vercel AI SDK messages to ServerMessage instances
|
||||
const messages: UIMessage[] = ...
|
||||
const serverMessages = messages.map(toServerMessage)
|
||||
```
|
||||
|
||||
### Getting Artifacts
|
||||
|
||||
```typescript
|
||||
// Get all artifacts from all messages
|
||||
const artifacts = serverMessages.flatMap(message => message.artifacts)
|
||||
|
||||
// Get the last artifact of any type
|
||||
const lastArtifact = artifacts[artifacts.length - 1]
|
||||
|
||||
// Get the last artifact of a specific type
|
||||
const lastCodeArtifact = serverMessage.getLastArtifact('code')
|
||||
const lastDocumentArtifact = serverMessage.getLastArtifact('document')
|
||||
```
|
||||
|
||||
### Getting Attachments
|
||||
|
||||
```typescript
|
||||
const attachments = serverMessage.attachments
|
||||
```
|
||||
|
||||
### Converting to LlamaIndex Format
|
||||
|
||||
```typescript
|
||||
const llamaindexMessage = serverMessage.llamaindexMessage
|
||||
```
|
||||
|
||||
## AI-generated UI Components
|
||||
|
||||
The LlamaIndex server provides support for rendering workflow events using custom UI components, allowing you to extend and customize the chat interface.
|
||||
@@ -151,7 +201,7 @@ To display custom UI components, your workflow needs to emit UI events that have
|
||||
|
||||
```typescript
|
||||
class UIEvent extends WorkflowEvent<{
|
||||
type: 'ui_event'
|
||||
type: 'data-ui_event'
|
||||
data: UIEventData
|
||||
}> {}
|
||||
```
|
||||
@@ -234,19 +284,32 @@ new LlamaIndexServer({
|
||||
}).start()
|
||||
```
|
||||
|
||||
## Sending Artifacts to the UI
|
||||
## Sending Events to the Frontend
|
||||
|
||||
In addition to UI events for custom components, LlamaIndex Server supports a special `ArtifactEvent` to send structured data like generated documents or code snippets to the UI. These artifacts are displayed in a dedicated "Canvas" panel in the chat interface.
|
||||
LlamaIndex Server allows your workflows to send various types of events to the frontend UI. These events can include custom UI components, structured data artifacts, or any other information you want to display to users.
|
||||
|
||||
### Artifact Event Structure
|
||||
### Event Filtering
|
||||
|
||||
To send an artifact, your workflow needs to emit an event with `type: "artifact"`. The `data` payload of this event should include:
|
||||
**Important Note**: The server filters events from the backend to only allow specific types to reach the frontend:
|
||||
|
||||
- **Text parts**: `text-delta`, `text-start`, `text-end` events for streaming text content
|
||||
- **Data parts**: Any event with a type that starts with `data-` (e.g., `data-artifact`, `data-chart`, `data-table`)
|
||||
|
||||
All other events are filtered out to ensure compatibility with the Vercel AI SDK and maintain a stable frontend experience.
|
||||
|
||||
### Sending Artifacts to the UI
|
||||
|
||||
One common use case is sending structured data artifacts like generated documents or code snippets to the UI. These artifacts are displayed in a dedicated "Canvas" panel in the chat interface.
|
||||
|
||||
#### Artifact Event Structure
|
||||
|
||||
To send an artifact, your workflow needs to emit an event with `type: "data-artifact"`. The `data` payload of this event should include:
|
||||
|
||||
- `type`: A string indicating the type of artifact (e.g., `"document"`, `"code"`).
|
||||
- `created_at`: A timestamp (e.g., `Date.now()`) indicating when the artifact was created.
|
||||
- `data`: An object containing the specific details of the artifact. The structure of this object depends on the artifact `type`.
|
||||
|
||||
### Defining and Sending an ArtifactEvent
|
||||
#### Defining and Sending an ArtifactEvent
|
||||
|
||||
First, define your artifact event using `workflowEvent` from `@llamaindex/workflow`:
|
||||
|
||||
@@ -255,7 +318,7 @@ import { workflowEvent } from '@llamaindex/workflow'
|
||||
|
||||
// Example for a document artifact
|
||||
const artifactEvent = workflowEvent<{
|
||||
type: 'artifact' // Must be "artifact"
|
||||
type: 'data-artifact' // Must start with "data-"
|
||||
data: {
|
||||
type: 'document' // Custom type for your artifact (e.g., "document", "code")
|
||||
created_at: number
|
||||
@@ -277,7 +340,7 @@ Then, within your workflow logic, use `sendEvent` (obtained from `getContext()`)
|
||||
|
||||
sendEvent(
|
||||
artifactEvent.with({
|
||||
type: "artifact", // This top-level type must be "artifact"
|
||||
type: "data-artifact", // This top-level type must start with "data-"
|
||||
data: {
|
||||
type: "document", // This is your specific artifact type
|
||||
created_at: Date.now(),
|
||||
@@ -294,6 +357,25 @@ This is a markdown document.",
|
||||
|
||||
This will send the artifact to the LlamaIndex Server UI, where it will be rendered in the [ChatCanvasPanel](/packages/server/next/app/components/ui/chat/canvas/panel.tsx) by a renderer depending on the artifact type. For type `document` this is using the [DocumentArtifactViewer](https://github.com/run-llama/chat-ui/blob/bacb75fc6edceacf742fba18632404a2483b5a81/packages/chat-ui/src/chat/canvas/artifacts/document.tsx#L17).
|
||||
|
||||
### Other Event Types
|
||||
|
||||
You can send any event type that starts with `data-` to create custom UI experiences. For example:
|
||||
|
||||
- `data-file` for file content
|
||||
- `data-image` for image content
|
||||
- `data-*` for your own custom components
|
||||
|
||||
The key requirement is that the event type must start with `data-` to pass through the server's event filter.
|
||||
|
||||
### How Events are Displayed in the UI
|
||||
|
||||
When you send events from your workflow, they flow through the system as follows:
|
||||
|
||||
1. **Workflow Stream**: Your workflow emits events using `sendEvent()`
|
||||
2. **Server Processing**: The server transforms events to Server-Sent Events (SSE) format
|
||||
3. **Frontend Parsing**: Vercel AI SDK parses the stream and converts events back to message parts
|
||||
4. **UI Rendering**: Chat UI renders each part using built-in or custom components
|
||||
|
||||
## Default Endpoints and Features
|
||||
|
||||
### Chat Endpoint
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { artifactEvent, extractLastArtifact } from '@llamaindex/server'
|
||||
import {
|
||||
artifactEvent,
|
||||
getMessageTextContent,
|
||||
toServerMessage,
|
||||
} from '@llamaindex/server'
|
||||
import { ChatMemoryBuffer, MessageContent, Settings } from 'llamaindex'
|
||||
|
||||
import {
|
||||
@@ -11,6 +15,7 @@ import {
|
||||
} from '@llamaindex/workflow'
|
||||
|
||||
import { z } from 'zod'
|
||||
import { UIMessage } from '@ai-sdk/react'
|
||||
|
||||
export const RequirementSchema = z.object({
|
||||
next_step: z.enum(['answering', 'coding']),
|
||||
@@ -22,7 +27,7 @@ export const RequirementSchema = z.object({
|
||||
export type Requirement = z.infer<typeof RequirementSchema>
|
||||
|
||||
export const UIEventSchema = z.object({
|
||||
type: z.literal('ui_event'),
|
||||
type: z.literal('data-ui_event'),
|
||||
data: z.object({
|
||||
state: z
|
||||
.enum(['plan', 'generate', 'completed'])
|
||||
@@ -52,13 +57,17 @@ const synthesizeAnswerEvent = workflowEvent<object>()
|
||||
|
||||
const uiEvent = workflowEvent<UIEvent>()
|
||||
|
||||
export function workflowFactory(reqBody: unknown) {
|
||||
export function workflowFactory(reqBody: { messages: UIMessage[] }) {
|
||||
const llm = Settings.llm
|
||||
|
||||
const serverMessages = reqBody.messages.map(toServerMessage)
|
||||
const artifacts = serverMessages.flatMap(message => message.artifacts)
|
||||
const lastArtifact = artifacts[artifacts.length - 1]
|
||||
|
||||
const { withState, getContext } = createStatefulMiddleware(() => {
|
||||
return {
|
||||
memory: new ChatMemoryBuffer({ llm }),
|
||||
lastArtifact: extractLastArtifact(reqBody),
|
||||
lastArtifact,
|
||||
}
|
||||
})
|
||||
const workflow = withState(createWorkflow())
|
||||
@@ -87,13 +96,13 @@ export function workflowFactory(reqBody: unknown) {
|
||||
const { state } = getContext()
|
||||
sendEvent(
|
||||
uiEvent.with({
|
||||
type: 'ui_event',
|
||||
type: 'data-ui_event',
|
||||
data: {
|
||||
state: 'plan',
|
||||
},
|
||||
})
|
||||
)
|
||||
const user_msg = planData.userInput
|
||||
const user_msg = getMessageTextContent(planData.userInput)
|
||||
const context = planData.context
|
||||
? `## The context is: \n${planData.context}\n`
|
||||
: ''
|
||||
@@ -183,7 +192,7 @@ ${user_msg}
|
||||
|
||||
sendEvent(
|
||||
uiEvent.with({
|
||||
type: 'ui_event',
|
||||
type: 'data-ui_event',
|
||||
data: {
|
||||
state: 'generate',
|
||||
requirement: planData.requirement.requirement,
|
||||
@@ -268,7 +277,7 @@ ${user_msg}
|
||||
// To show the Canvas panel for the artifact
|
||||
sendEvent(
|
||||
artifactEvent.with({
|
||||
type: 'artifact',
|
||||
type: 'data-artifact',
|
||||
data: {
|
||||
type: 'code',
|
||||
created_at: Date.now(),
|
||||
@@ -308,7 +317,7 @@ ${user_msg}
|
||||
|
||||
sendEvent(
|
||||
uiEvent.with({
|
||||
type: 'ui_event',
|
||||
type: 'data-ui_event',
|
||||
data: {
|
||||
state: 'completed',
|
||||
},
|
||||
|
||||
@@ -21,7 +21,7 @@ const CLIHumanInput: FC<{
|
||||
.filter((ev): ev is CLIInputEvent => ev !== null)
|
||||
.at(-1)
|
||||
|
||||
const { append } = useChatUI()
|
||||
const { sendMessage } = useChatUI()
|
||||
const [confirmedValue, setConfirmedValue] = useState<boolean | null>(null)
|
||||
const [editableCommand, setEditableCommand] = useState<string | undefined>(
|
||||
inputEvent?.command
|
||||
@@ -33,35 +33,43 @@ const CLIHumanInput: FC<{
|
||||
}, [inputEvent?.command])
|
||||
|
||||
const handleConfirm = () => {
|
||||
append({
|
||||
content: 'Yes',
|
||||
role: 'user',
|
||||
annotations: [
|
||||
sendMessage({
|
||||
id: crypto.randomUUID(),
|
||||
parts: [
|
||||
{
|
||||
type: 'human_response',
|
||||
type: 'text',
|
||||
text: 'Yes',
|
||||
},
|
||||
{
|
||||
type: 'data-human_response',
|
||||
data: {
|
||||
execute: true,
|
||||
command: editableCommand, // Use editable command
|
||||
},
|
||||
},
|
||||
],
|
||||
role: 'user',
|
||||
})
|
||||
setConfirmedValue(true)
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
append({
|
||||
content: 'No',
|
||||
role: 'user',
|
||||
annotations: [
|
||||
sendMessage({
|
||||
id: crypto.randomUUID(),
|
||||
parts: [
|
||||
{
|
||||
type: 'human_response',
|
||||
type: 'text',
|
||||
text: 'No',
|
||||
},
|
||||
{
|
||||
type: 'data-human_response',
|
||||
data: {
|
||||
execute: false,
|
||||
command: inputEvent?.command,
|
||||
command: editableCommand,
|
||||
},
|
||||
},
|
||||
],
|
||||
role: 'user',
|
||||
})
|
||||
setConfirmedValue(false)
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { humanInputEvent, humanResponseEvent } from '@llamaindex/server'
|
||||
|
||||
export const cliHumanInputEvent = humanInputEvent<{
|
||||
type: 'cli_human_input'
|
||||
type: 'data-cli_human_input'
|
||||
data: { command: string }
|
||||
response: typeof cliHumanResponseEvent
|
||||
}>()
|
||||
|
||||
export const cliHumanResponseEvent = humanResponseEvent<{
|
||||
type: 'human_response'
|
||||
type: 'data-human_response'
|
||||
data: { execute: boolean; command: string }
|
||||
}>()
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { OpenAI } from '@llamaindex/openai'
|
||||
import { toAgentRunEvent, writeResponseToStream } from '@llamaindex/server'
|
||||
import { chatWithTools } from '@llamaindex/tools'
|
||||
import {
|
||||
createWorkflow,
|
||||
@@ -12,6 +11,7 @@ import {
|
||||
import { ChatMessage, Settings, ToolCallLLM } from 'llamaindex'
|
||||
import { cliHumanInputEvent, cliHumanResponseEvent } from './events'
|
||||
import { cliExecutor } from './tools'
|
||||
import { runEvent, streamText } from '@llamaindex/server'
|
||||
|
||||
Settings.llm = new OpenAI({
|
||||
model: 'gpt-4o-mini',
|
||||
@@ -49,7 +49,7 @@ export const workflowFactory = (body: unknown) => {
|
||||
const command = cliExecutorToolCall?.input?.command as string
|
||||
if (command) {
|
||||
return cliHumanInputEvent.with({
|
||||
type: 'cli_human_input',
|
||||
type: 'data-cli_human_input',
|
||||
data: { command },
|
||||
response: cliHumanResponseEvent,
|
||||
})
|
||||
@@ -70,15 +70,29 @@ export const workflowFactory = (body: unknown) => {
|
||||
}
|
||||
|
||||
sendEvent(
|
||||
toAgentRunEvent({
|
||||
agent: 'CLI Executor',
|
||||
text: `Execute the command "${command}" and return the result`,
|
||||
type: 'text',
|
||||
runEvent.with({
|
||||
type: 'data-event',
|
||||
data: {
|
||||
status: 'pending',
|
||||
title: 'Executing command',
|
||||
description: `Executing the command "${command}"`,
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
const result = (await cliExecutor.call({ command })) as string
|
||||
|
||||
sendEvent(
|
||||
runEvent.with({
|
||||
type: 'data-event',
|
||||
data: {
|
||||
status: 'success',
|
||||
title: 'Command executed',
|
||||
description: `Executed the command "${command}" and got the result: ${result}`,
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
return summaryEvent.with(
|
||||
`Executed the command ${command} and got the result: ${result}`
|
||||
)
|
||||
@@ -97,7 +111,7 @@ export const workflowFactory = (body: unknown) => {
|
||||
stream: true,
|
||||
})
|
||||
|
||||
const result = await writeResponseToStream(stream, sendEvent)
|
||||
const result = await streamText(stream, sendEvent)
|
||||
|
||||
return stopAgentEvent.with({ result })
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { extractFileAttachments, getStoredFilePath } from '@llamaindex/server'
|
||||
import { getStoredFilePath, ServerMessage } from '@llamaindex/server'
|
||||
import { agent } from '@llamaindex/workflow'
|
||||
import { type Message } from 'ai'
|
||||
import { type UIMessage as Message } from '@ai-sdk/react'
|
||||
import { tool } from 'llamaindex'
|
||||
import { promises as fsPromises } from 'node:fs'
|
||||
import { z } from 'zod'
|
||||
@@ -8,7 +8,9 @@ import { z } from 'zod'
|
||||
export const workflowFactory = async (reqBody: { messages: Message[] }) => {
|
||||
const { messages } = reqBody
|
||||
// Extract the files from the messages
|
||||
const files = extractFileAttachments(messages)
|
||||
const lastMessage = messages[messages.length - 1]
|
||||
const serverMessage = new ServerMessage(lastMessage)
|
||||
const files = serverMessage.attachments
|
||||
const fileIds = files.map(file => file.id)
|
||||
|
||||
// Define a tool to read the file content using the id
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user