Installation
Complete project setup with vue/reka, then install these components:
pnpm exec frontfriend download trace collapsible message bubble button icon -f vue --primitive reka Collect source-url parts after the answer and remove duplicate URLs. If a title is absent, show the hostname. Each link opens in a new tab and includes an accessible notice.
Usage
Complete example
Save as AiSourcesExample.vue.
<script setup lang="ts">
import { Icon } from "@/components/ui/icon";
import { computed, reactive, ref, onScopeDispose } 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 props = withDefaults(defineProps<{ question?: string }>(), {
question: "How do I start an Astro project with pnpm?",
});
const QUESTION = props.question;
const ANSWER =
"Run pnpm create astro@latest and follow the prompts. It scaffolds the project and installs the dependencies.";
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",
},
];
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");
let run = 0;
const timers = new Set<ReturnType<typeof setTimeout>>();
function pause(ms: number) {
return new Promise<void>((resolve) => {
const timer = setTimeout(() => {
timers.delete(timer);
resolve();
}, ms);
timers.add(timer);
});
}
onScopeDispose(() => {
run++;
for (const timer of timers) clearTimeout(timer);
timers.clear();
});
async function sleep(ms: number) {
const id = run;
await pause(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(500))) return;
}
status.value = "ready";
}
</script>
<template>
<div
style="
width: 100%;
max-width: 384px;
min-width: 0;
min-height: 288px;
margin-inline: auto;
display: flex;
flex-direction: column;
gap: 16px;
padding-block: 24px;
font-size: var(--text-sm);
line-height: 1.5;
"
>
<template v-for="message in messages" :key="message.id">
<Message v-if="message.role === 'user'" align="end">
<MessageContent>
<Bubble align="end" variant="muted">
<BubbleContent>{{ props.question }}</BubbleContent>
</Bubble>
</MessageContent>
</Message>
<Message v-else>
<MessageContent style="gap: 16px">
<p v-if="getText(message)">
{{ getText(message) }}
</p>
<Trace v-if="getSources(message).length > 0">
<TraceCollapsible default-open>
<TraceCollapsibleTrigger>
<TraceLine>
<TraceIcon>
<Icon name="Globe" />
</TraceIcon>
<TraceTitle
>Used {{ getSources(message).length }}
{{
getSources(message).length === 1 ? "source" : "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
style="
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
"
>
(opens in a new tab)</span
>
</TraceTitle>
<TraceIcon>
<Icon name="ArrowUpRight" />
</TraceIcon>
</TraceLine>
</a>
</Trace>
</TraceCollapsibleContent>
</TraceCollapsible>
</Trace>
</MessageContent>
</Message>
</template>
<Button
:disabled="busy"
variant="outline"
size="sm"
style="margin-top: auto; align-self: flex-start"
@click="replay"
>
Replay
</Button>
</div>
</template> AI SDK
This optional adapter accepts a UIMessage from your chat client. The complete example needs no AI SDK or provider. Install this extra package only for the adapter:
pnpm add ai
Read source-url parts after the answer, remove duplicate URLs, and use the hostname when the title is absent. Links include the new-tab notice.
Save as AiSourcesAdapter.vue.
<script setup lang="ts">
import type { UIMessage } from "ai";
import { computed } from "vue";
import { Icon } from "@/components/ui/icon";
import {
Trace,
TraceCollapsible,
TraceCollapsibleTrigger,
TraceCollapsibleContent,
TraceLine,
TraceTitle,
TraceIcon,
} from "@/components/ui/trace";
const props = withDefaults(
defineProps<{ message?: UIMessage; streaming?: boolean }>(),
{
streaming: false,
message: () => ({
id: "answer",
role: "assistant",
parts: [
{
type: "source-url",
sourceId: "1",
url: "https://docs.astro.build/en/install-and-setup/",
title: "Install Astro | docs.astro.build",
},
{
type: "source-url",
sourceId: "2",
url: "https://www.npmjs.com/package/create-astro",
title: "create-astro | npmjs.com",
},
{
type: "source-url",
sourceId: "3",
url: "https://docs.astro.build/en/install-and-setup/",
},
{
type: "source-url",
sourceId: "4",
url: "https://pnpm.io/cli/create",
},
],
}),
},
);
const sources = computed(() =>
props.message.parts
.filter((part) => part.type === "source-url")
.filter(
(part, index, all) =>
all.findIndex((other) => other.url === part.url) === index,
),
);
function getHostname(url: string) {
return new URL(url).hostname;
}
</script>
<template>
<div
style="
width: 100%;
max-width: 384px;
margin-inline: auto;
padding-block: 24px;
"
>
<Trace v-if="sources.length > 0">
<TraceCollapsible default-open>
<TraceCollapsibleTrigger>
<TraceLine>
<TraceIcon>
<Icon name="Globe" />
</TraceIcon>
<TraceTitle
>Used {{ sources.length }}
{{ sources.length === 1 ? "source" : "sources" }}</TraceTitle
>
</TraceLine>
</TraceCollapsibleTrigger>
<TraceCollapsibleContent>
<Trace>
<a
v-for="source in sources"
:key="source.url"
:href="source.url"
target="_blank"
rel="noreferrer"
>
<TraceLine>
<TraceTitle>
{{ source.title ?? getHostname(source.url) }}
<span
style="
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
"
>
(opens in a new tab)</span
>
</TraceTitle>
<TraceIcon>
<Icon name="ArrowUpRight" />
</TraceIcon>
</TraceLine>
</a>
</Trace>
</TraceCollapsibleContent>
</TraceCollapsible>
</Trace>
</div>
</template> Components and API
This composition has no separate component API. Use the APIs for its installed components.
| Component | Target reference |
|---|---|
trace | Reka UI API |
collapsible | Reka UI API |
message | Reka UI API |
bubble | Reka UI API |
button | Reka UI API |
icon | Reka UI API |