- Accordion
- Alert
- Alert Dialog
- Aspect Ratio
- Attachment
- Avatar
- Badge
- Breadcrumb
- Bubble
- Button
- Button Group
- Calendar
- Card
- Carousel
- Chart
- Checkbox
- Code Block
- Collapsible
- Combobox
- Command
- Context Menu
- Data Table
- Date Picker
- Dialog
- Drawer
- Dropdown Menu
- Empty
- Field
- Form
- Hover Card
- Input
- Input Group
- Input OTP
- Item
- Kbd
- Label
- Marker
- Menubar
- Message
- Message Scroller
- Native Select
- Navigation Menu
- Number Field
- Pagination
- Pin Input
- Popover
- Progress
- Questionnaire
- Radio Group
- Range Calendar
- Resizable
- Scroll Area
- Select
- Separator
- Sheet
- Sidebar
- Skeleton
- Slider
- Sonner
- Spinner
- Stepper
- Switch
- Table
- Tabs
- Tags Input
- Textarea
- Toast
- Toggle
- Toggle Group
- Tooltip
- Trace
- Typography
Run pnpm create astro@latest and follow the prompts. It scaffolds the project and installs the dependencies.
<script setup lang="ts">
import { ArrowUpRightIcon, GlobeIcon } from '@lucide/vue'
import { promiseTimeout, tryOnScopeDispose } from '@vueuse/core'
import { computed, reactive, ref } from 'vue'
import { Bubble, BubbleContent } from '@/components/ui/bubble'
import { Button } from '@/components/ui/button'
import { Message, MessageContent } from '@/components/ui/message'
import {
Trace,
TraceCollapsible,
TraceCollapsibleContent,
TraceCollapsibleTrigger,
TraceIcon,
TraceLine,
TraceTitle,
} from '@/components/ui/trace'
interface SourcePart {
type: 'source-url'
sourceId: string
url: string
title?: string
}
interface TextPart {
type: 'text'
text: string
}
interface ChatMessage {
id: string
role: 'user' | 'assistant'
parts: (SourcePart | TextPart)[]
}
const QUESTION = 'How do I start an Astro project with pnpm?'
const ANSWER = 'Run pnpm create astro@latest and follow the prompts. It scaffolds the project and installs the dependencies.'
// The agent cites the Astro docs twice.
const SOURCES: SourcePart[] = [
{
type: 'source-url',
sourceId: 'source-0',
url: 'https://docs.astro.build/en/install-and-setup/',
title: 'Install Astro | docs.astro.build',
},
{
type: 'source-url',
sourceId: 'source-1',
url: 'https://www.npmjs.com/package/create-astro',
title: 'create-astro | npmjs.com',
},
{
type: 'source-url',
sourceId: 'source-2',
url: 'https://docs.astro.build/en/install-and-setup/',
title: 'Install Astro | docs.astro.build',
},
{
type: 'source-url',
sourceId: 'source-3',
url: 'https://pnpm.io/cli/create',
title: 'pnpm create | pnpm.io',
},
]
function transcript(): ChatMessage[] {
return [
{ id: 'user-0', role: 'user', parts: [{ type: 'text', text: QUESTION }] },
{
id: 'assistant-0',
role: 'assistant',
parts: [{ type: 'text', text: ANSWER }, ...SOURCES],
},
]
}
const messages = ref<ChatMessage[]>(transcript())
const status = ref<'ready' | 'submitted' | 'streaming'>('ready')
const busy = computed(() => status.value !== 'ready')
// Unmounting bumps `run`, so a running script stops at its next wait.
let run = 0
tryOnScopeDispose(() => run++)
async function sleep(ms: number) {
const id = run
await promiseTimeout(ms)
return id === run
}
async function streamInto(target: { text: string }, text: string, delayMs: number) {
for (const chunk of text.match(/\S+\s*/g) ?? []) {
target.text += chunk
if (!(await sleep(delayMs)))
return false
}
return true
}
function getText(message: ChatMessage) {
return message.parts
.filter((part): part is TextPart => part.type === 'text')
.map(part => part.text)
.join('')
}
function getHostname(url: string) {
return new URL(url).hostname
}
function getSources(message: ChatMessage) {
return message.parts
.filter((part): part is SourcePart => part.type === 'source-url')
.filter((part, index, all) => all.findIndex(other => other.url === part.url) === index)
}
async function replay() {
const id = ++run
messages.value = [{ id: `user-${id}`, role: 'user', parts: [{ type: 'text', text: QUESTION }] }]
status.value = 'submitted'
if (!(await sleep(300)))
return
const message = reactive<ChatMessage>({ id: `assistant-${id}`, role: 'assistant', parts: [] })
messages.value.push(message)
status.value = 'streaming'
const text = reactive<TextPart>({ type: 'text', text: '' })
message.parts.push(text)
if (!(await streamInto(text, ANSWER, 20)))
return
for (const source of SOURCES) {
message.parts.push({ ...source })
if (!(await sleep(20)))
return
}
status.value = 'ready'
}
</script>
<template>
<div class="flex min-h-72 w-full max-w-sm flex-col gap-4 py-6 text-sm">
<template v-for="message in messages" :key="message.id">
<Message v-if="message.role === 'user'" align="end">
<MessageContent>
<Bubble align="end" variant="muted">
<BubbleContent>{{ getText(message) }}</BubbleContent>
</Bubble>
</MessageContent>
</Message>
<Message v-else>
<MessageContent class="gap-4">
<p v-if="getText(message)">
{{ getText(message) }}
</p>
<Trace v-if="getSources(message).length > 0">
<TraceCollapsible default-open>
<TraceCollapsibleTrigger>
<TraceLine>
<TraceIcon>
<GlobeIcon />
</TraceIcon>
<TraceTitle>Used {{ getSources(message).length }} sources</TraceTitle>
</TraceLine>
</TraceCollapsibleTrigger>
<TraceCollapsibleContent>
<Trace>
<a
v-for="source in getSources(message)"
:key="source.url"
:href="source.url"
target="_blank"
rel="noreferrer"
>
<TraceLine>
<TraceTitle>
{{ source.title ?? getHostname(source.url) }}
<span class="sr-only"> (opens in a new tab)</span>
</TraceTitle>
<TraceIcon>
<ArrowUpRightIcon />
</TraceIcon>
</TraceLine>
</a>
</Trace>
</TraceCollapsibleContent>
</TraceCollapsible>
</Trace>
</MessageContent>
</Message>
</template>
<Button
v-if="!busy"
variant="outline"
size="sm"
class="mt-auto self-start"
@click="replay"
>
Replay
</Button>
</div>
</template>Sources is a line that lists the links the agent used for a reply. It says how many there are, like "Used 3 sources", and opens into a link for each one.
Sources is built with Trace with a TraceCollapsible for the list.
Installation
Sources is built with the Trace component.
pnpm dlx shadcn-vue@latest add trace
Usage
<script setup lang="ts">
import { ArrowUpRightIcon, GlobeIcon } from '@lucide/vue'
import {
Trace,
TraceCollapsible,
TraceCollapsibleContent,
TraceCollapsibleTrigger,
TraceIcon,
TraceLine,
TraceTitle,
} from '@/components/ui/trace'
function getHostname(url: string) {
return new URL(url).hostname
}
</script>
<template>
<Trace>
<TraceCollapsible>
<TraceCollapsibleTrigger>
<TraceLine>
<TraceIcon>
<GlobeIcon />
</TraceIcon>
<TraceTitle>Used {{ sources.length }} sources</TraceTitle>
</TraceLine>
</TraceCollapsibleTrigger>
<TraceCollapsibleContent>
<Trace>
<a
v-for="source in sources"
:key="source.url"
:href="source.url"
target="_blank"
rel="noreferrer"
>
<TraceLine>
<TraceIcon>
<GlobeIcon />
</TraceIcon>
<TraceTitle>
{{ source.title ?? getHostname(source.url) }}
<span class="sr-only"> (opens in a new tab)</span>
</TraceTitle>
<TraceIcon>
<ArrowUpRightIcon />
</TraceIcon>
</TraceLine>
</a>
</Trace>
</TraceCollapsibleContent>
</TraceCollapsible>
</Trace>
</template>AI SDK
Collect the source-url parts of a message and render them after the text. The agent can cite the same page more than once, so dedupe them by url.
const sources = computed(() =>
props.message.parts
.filter(part => part.type === 'source-url')
.filter(
(part, index, all) =>
all.findIndex(other => other.url === part.url) === index,
),
)title is optional on a source-url part. Fall back to the hostname of the URL.
Accessibility
Each source is a plain link inside the list.
- Links open in a new tab. Add
sr-onlytext, like "(opens in a new tab)", so screen readers announce it. - The arrow icon is decorative. The title is the name of the link.
- The count in the trigger, like "Used 3 sources", tells the person what opens before they open it.
Migrating from AI Elements Vue
If you added Sources from AI Elements Vue, your project has a components/ai-elements/sources/ folder. Paste this prompt into your coding agent to rebuild it on Trace. Every export keeps its name and props, so the code that uses it keeps working.
Migrate components/ai-elements/sources/ from AI Elements Vue to the shadcn-vue Trace component.
Keep every export, with the same name, props and emits, so the code that imports it keeps working. Only change how each part renders.
1. Install Trace: npx shadcn-vue@latest add trace
2. Read https://www.shadcn-vue.com/docs/ai-elements/sources and build Sources the same way.
3. Rebuild each part:
- Sources.vue renders a Trace with a TraceCollapsible.
- SourcesTrigger.vue renders a TraceCollapsibleTrigger with a TraceLine, a TraceIcon and a TraceTitle that uses count, like "Used 3 sources".
- SourcesContent.vue renders a TraceCollapsibleContent with a Trace for the links.
- Source.vue renders a link that wraps a TraceLine, with a TraceIcon and a TraceTitle for its title. Add "(opens in a new tab)" in an sr-only span.
4. Remove the imports the files no longer use.
5. Run vue-tsc and fix any errors.API Reference
See Trace.
Sponsors
Your logo hereshadcn-vue is free and open source. Sponsorship keeps it maintained.
Become a sponsor