10.7k

Sources

PreviousNext

Displays the links the agent used for a reply.

How do I start an Astro project with pnpm?

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-only text, 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.