Meet Frontfriend.Your brand. Your rules. Your agents, connected.Explore what’s new

Sources

A collapsible list of the unique sources used for an answer.

Interactive preview

The shared preview uses React Base. The source below uses Reka UI. Each example runs with local data.

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.

ComponentTarget 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