diff --git a/.gitignore b/.gitignore index 64e55c7..6728f13 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,4 @@ .DS_Store -package.json -package-lock.json node_modules/ .idea .vscode diff --git a/.mintignore b/.mintignore new file mode 100644 index 0000000..d838da9 --- /dev/null +++ b/.mintignore @@ -0,0 +1 @@ +examples/ diff --git a/examples/vercel-ai-telemetry/.env.example b/examples/vercel-ai-telemetry/.env.example new file mode 100644 index 0000000..5dba720 --- /dev/null +++ b/examples/vercel-ai-telemetry/.env.example @@ -0,0 +1,4 @@ +OPENAI_API_KEY= +OPENAI_MODEL=gpt-5 +PROMPTLAYER_API_KEY= +OTEL_SERVICE_NAME=vercel-chat-app diff --git a/examples/vercel-ai-telemetry/.gitignore b/examples/vercel-ai-telemetry/.gitignore new file mode 100644 index 0000000..7b8da95 --- /dev/null +++ b/examples/vercel-ai-telemetry/.gitignore @@ -0,0 +1,42 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* +!.env.example + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/examples/vercel-ai-telemetry/README.md b/examples/vercel-ai-telemetry/README.md new file mode 100644 index 0000000..ee26ae8 --- /dev/null +++ b/examples/vercel-ai-telemetry/README.md @@ -0,0 +1,68 @@ +# Relay Chat Demo + +A simple Next.js App Router scaffold for a chat app built with the Vercel AI SDK and the direct OpenAI provider. + +## What is included + +- A streaming chat interface powered by `useChat` +- A `ToolLoopAgent` with three mocked server-side tools +- Typed UI messages via `InferAgentUIMessage` +- An API route that streams agent responses with `createAgentUIStreamResponse` + +## Setup + +1. Install dependencies: + +```bash +npm install +``` + +2. Create a local env file from the example: + +```bash +cp .env.example .env.local +``` + +3. Add your OpenAI API key to `.env.local`: + +```bash +OPENAI_API_KEY=your_key_here +``` + +4. Optional: add PromptLayer tracing credentials if you want OpenTelemetry traces exported to PromptLayer: + +```bash +PROMPTLAYER_API_KEY=your_promptlayer_key_here +OTEL_SERVICE_NAME=vercel-chat-app +``` + +5. Start the app: + +```bash +npm run dev +``` + +Open [http://localhost:3000](http://localhost:3000). + +## Model configuration + +The scaffold uses the direct OpenAI provider from `@ai-sdk/openai` and defaults to `gpt-5`, matching the current example on the AI SDK OpenAI provider docs. You can override that with: + +```bash +OPENAI_MODEL=your_preferred_model +``` + +## PromptLayer tracing + +This app can export Vercel AI SDK traces to PromptLayer using OpenTelemetry. + +- `PROMPTLAYER_API_KEY` enables OTLP trace export to PromptLayer +- `OTEL_SERVICE_NAME` optionally overrides the service name shown in traces + +If `PROMPTLAYER_API_KEY` is not set, the app still runs normally and skips PromptLayer export. + +## Useful scripts + +- `npm run dev` +- `npm run lint` +- `npm run typecheck` diff --git a/examples/vercel-ai-telemetry/app/api/chat/route.ts b/examples/vercel-ai-telemetry/app/api/chat/route.ts new file mode 100644 index 0000000..fb3ce61 --- /dev/null +++ b/examples/vercel-ai-telemetry/app/api/chat/route.ts @@ -0,0 +1,20 @@ +import { createAgentUIStreamResponse } from "ai"; +import { chatAgent } from "@/lib/chat-agent"; + +export const maxDuration = 30; + +export async function POST(request: Request) { + if (!process.env.OPENAI_API_KEY) { + return new Response( + "Missing OPENAI_API_KEY. Add it to .env.local before using the chat route.", + { status: 500 }, + ); + } + + const { messages } = await request.json(); + + return createAgentUIStreamResponse({ + agent: chatAgent, + uiMessages: messages, + }); +} diff --git a/examples/vercel-ai-telemetry/app/favicon.ico b/examples/vercel-ai-telemetry/app/favicon.ico new file mode 100644 index 0000000..718d6fe Binary files /dev/null and b/examples/vercel-ai-telemetry/app/favicon.ico differ diff --git a/examples/vercel-ai-telemetry/app/globals.css b/examples/vercel-ai-telemetry/app/globals.css new file mode 100644 index 0000000..1879ee2 --- /dev/null +++ b/examples/vercel-ai-telemetry/app/globals.css @@ -0,0 +1,62 @@ +:root { + --page-background: + radial-gradient(circle at top left, rgba(246, 176, 135, 0.2), transparent 28%), + radial-gradient(circle at bottom right, rgba(45, 156, 139, 0.18), transparent 26%), + #f7f2ea; + --foreground: #15211d; + --muted-foreground: #5e6966; + --panel: rgba(255, 252, 246, 0.84); + --panel-strong: #fffdf8; + --line: rgba(21, 33, 29, 0.08); + --accent: #0f766e; + --accent-strong: #0b5c56; + --accent-soft: rgba(15, 118, 110, 0.12); + --user-bubble: #15211d; + --assistant-bubble: #fff8ef; + --tool-bubble: #f1faf7; + --danger: #a93e2f; + --font-sans: "Avenir Next", "Segoe UI", "Helvetica Neue", Arial, sans-serif; + --font-mono: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; +} + +html { + height: 100%; +} + +html, +body { + max-width: 100vw; + overflow-x: hidden; +} + +body { + min-height: 100%; + display: flex; + flex-direction: column; + color: var(--foreground); + background: var(--page-background); + font-family: var(--font-sans); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +* { + box-sizing: border-box; + padding: 0; + margin: 0; +} + +a { + color: inherit; + text-decoration: none; +} + +button, +input, +textarea { + font: inherit; +} + +::selection { + background: rgba(15, 118, 110, 0.18); +} diff --git a/examples/vercel-ai-telemetry/app/layout.tsx b/examples/vercel-ai-telemetry/app/layout.tsx new file mode 100644 index 0000000..c3aeed2 --- /dev/null +++ b/examples/vercel-ai-telemetry/app/layout.tsx @@ -0,0 +1,19 @@ +import type { Metadata } from "next"; +import "./globals.css"; + +export const metadata: Metadata = { + title: "Relay Chat Demo", + description: "A simple AI SDK chat app with an OpenAI-backed agent and mock tools.", +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + {children} + + ); +} diff --git a/examples/vercel-ai-telemetry/app/page.module.css b/examples/vercel-ai-telemetry/app/page.module.css new file mode 100644 index 0000000..c7be164 --- /dev/null +++ b/examples/vercel-ai-telemetry/app/page.module.css @@ -0,0 +1,401 @@ +.page { + flex: 1; + padding: 32px; +} + +.shell { + display: grid; + grid-template-columns: minmax(280px, 380px) minmax(0, 1fr); + gap: 24px; + min-height: calc(100dvh - 64px); +} + +.sidebar, +.chatPanel { + border: 1px solid var(--line); + background: var(--panel); + backdrop-filter: blur(18px); + box-shadow: 0 24px 90px rgba(21, 33, 29, 0.08); +} + +.sidebar { + display: flex; + flex-direction: column; + gap: 20px; + padding: 28px; + border-radius: 28px; +} + +.eyebrow { + width: fit-content; + padding: 8px 12px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.72); + color: var(--accent-strong); + font-size: 12px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.sidebar h1 { + max-width: 12ch; + font-size: clamp(2.2rem, 4vw, 4rem); + line-height: 0.95; + letter-spacing: -0.06em; +} + +.lead { + color: var(--muted-foreground); + font-size: 1rem; + line-height: 1.7; +} + +.note { + display: grid; + gap: 8px; + padding: 18px; + border-radius: 22px; + background: rgba(255, 255, 255, 0.6); + border: 1px solid rgba(21, 33, 29, 0.06); +} + +.note p, +.chatHeader p, +.emptyState small, +.toolMeta, +.composerHint { + color: var(--muted-foreground); +} + +.note code, +.toolCard code { + padding: 2px 6px; + border-radius: 999px; + background: rgba(21, 33, 29, 0.06); + font-family: var(--font-mono); + font-size: 0.9em; +} + +.starterList { + display: grid; + gap: 12px; + margin-top: auto; +} + +.starter { + display: grid; + gap: 6px; + width: 100%; + padding: 16px 18px; + border: 1px solid rgba(21, 33, 29, 0.08); + border-radius: 18px; + background: var(--panel-strong); + text-align: left; + color: inherit; + cursor: pointer; + transition: + transform 160ms ease, + border-color 160ms ease, + box-shadow 160ms ease; +} + +.starter span { + font-weight: 600; +} + +.starter small { + color: var(--muted-foreground); + line-height: 1.5; +} + +.starter:disabled { + cursor: not-allowed; + opacity: 0.65; +} + +.chatPanel { + display: grid; + grid-template-rows: auto minmax(0, 1fr) auto; + min-height: 0; + border-radius: 32px; + overflow: hidden; +} + +.chatHeader { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 20px; + padding: 24px 24px 18px; + border-bottom: 1px solid var(--line); + background: rgba(255, 255, 255, 0.55); +} + +.chatTitle { + display: block; + margin-bottom: 6px; + font-size: 1.1rem; + font-weight: 700; +} + +.statusBadge { + padding: 9px 12px; + border-radius: 999px; + font-size: 0.82rem; + font-weight: 700; + text-transform: capitalize; +} + +.statusReady { + background: rgba(21, 33, 29, 0.08); +} + +.statusBusy { + background: var(--accent-soft); + color: var(--accent-strong); +} + +.messages { + display: flex; + flex-direction: column; + gap: 16px; + overflow-y: auto; + padding: 24px; +} + +.emptyState { + display: grid; + gap: 10px; + place-items: center; + min-height: 100%; + padding: 40px 24px; + text-align: center; +} + +.emptyState p { + max-width: 28rem; + font-size: 1.15rem; + font-weight: 600; +} + +.message { + display: grid; + gap: 10px; + max-width: 80%; +} + +.userMessage { + justify-self: end; +} + +.assistantMessage { + justify-self: start; +} + +.messageMeta { + padding-inline: 6px; + font-size: 0.75rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--muted-foreground); +} + +.messageBody { + display: grid; + gap: 10px; +} + +.userMessage .messageBody { + padding: 16px 18px; + border-radius: 22px 22px 8px 22px; + background: var(--user-bubble); + color: #fdf9f1; +} + +.assistantMessage .messageBody { + padding: 16px 18px; + border-radius: 22px 22px 22px 8px; + background: var(--assistant-bubble); +} + +.messageText { + line-height: 1.7; +} + +.toolCard { + display: grid; + gap: 8px; + padding: 14px; + border-radius: 18px; + background: var(--tool-bubble); + border: 1px solid rgba(15, 118, 110, 0.14); +} + +.toolLabel { + font-size: 0.78rem; + font-weight: 700; + color: var(--accent-strong); + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.toolList { + display: grid; + gap: 10px; + padding-left: 18px; +} + +.toolList li { + display: grid; + gap: 4px; + line-height: 1.5; +} + +.composer { + display: grid; + gap: 12px; + padding: 18px 24px 24px; + border-top: 1px solid var(--line); + background: rgba(255, 255, 255, 0.64); +} + +.inputFrame { + display: grid; + gap: 10px; +} + +.inputLabel { + font-size: 0.82rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--muted-foreground); +} + +.input { + width: 100%; + min-height: 104px; + resize: vertical; + border: 1px solid rgba(21, 33, 29, 0.1); + border-radius: 22px; + padding: 16px 18px; + background: var(--panel-strong); + color: inherit; + outline: none; + transition: border-color 160ms ease, box-shadow 160ms ease; +} + +.input:focus { + border-color: rgba(15, 118, 110, 0.45); + box-shadow: 0 0 0 4px rgba(15, 118, 110, 0.1); +} + +.composerFooter { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; +} + +.composerHint { + max-width: 34rem; + font-size: 0.92rem; + line-height: 1.6; +} + +.sendButton { + flex-shrink: 0; + border: none; + border-radius: 999px; + padding: 12px 18px; + background: var(--accent); + color: #f7fffc; + font-weight: 700; + cursor: pointer; + transition: transform 160ms ease, background 160ms ease; +} + +.sendButton:disabled, +.starter:disabled { + transform: none; +} + +.sendButton:disabled { + cursor: not-allowed; + opacity: 0.55; +} + +.errorText { + color: var(--danger); + line-height: 1.5; +} + +@media (hover: hover) and (pointer: fine) { + .starter:hover, + .sendButton:hover { + transform: translateY(-1px); + } + + .starter:hover { + border-color: rgba(15, 118, 110, 0.24); + box-shadow: 0 16px 30px rgba(15, 118, 110, 0.08); + } + + .sendButton:hover { + background: var(--accent-strong); + } +} + +@media (max-width: 980px) { + .page { + padding: 18px; + } + + .shell { + grid-template-columns: 1fr; + min-height: auto; + } + + .sidebar { + order: 2; + } + + .chatPanel { + min-height: 72dvh; + } + + .message { + max-width: 100%; + } +} + +@media (max-width: 640px) { + .page { + padding: 12px; + } + + .sidebar, + .chatPanel { + border-radius: 24px; + } + + .sidebar, + .chatHeader, + .messages, + .composer { + padding-left: 16px; + padding-right: 16px; + } + + .composerFooter, + .chatHeader { + flex-direction: column; + align-items: stretch; + } + + .sendButton { + width: 100%; + } +} diff --git a/examples/vercel-ai-telemetry/app/page.tsx b/examples/vercel-ai-telemetry/app/page.tsx new file mode 100644 index 0000000..d787c1f --- /dev/null +++ b/examples/vercel-ai-telemetry/app/page.tsx @@ -0,0 +1,273 @@ +"use client"; + +import { useChat } from "@ai-sdk/react"; +import { DefaultChatTransport } from "ai"; +import { useState } from "react"; +import type { ChatUIMessage } from "@/lib/chat-agent"; +import styles from "./page.module.css"; + +const starterPrompts = [ + { + label: "Track an order", + prompt: "Can you check the status of order 2048 for me?", + }, + { + label: "Search docs", + prompt: "What does your onboarding guide say about first-day setup?", + }, + { + label: "Book a follow-up", + prompt: "Schedule a support callback for Sam about billing questions.", + }, +] as const; + +export default function Home() { + const [input, setInput] = useState(""); + const { messages, sendMessage, status, error } = useChat({ + transport: new DefaultChatTransport({ + api: "/api/chat", + }), + }); + + const isBusy = status === "submitted" || status === "streaming"; + + let helperLabel = "Try one of the prompts to watch the agent use mocked tools."; + + if (status === "submitted") { + helperLabel = "Sending your message..."; + } else if (status === "streaming") { + helperLabel = "The agent is thinking and may call tools."; + } else if (error) { + helperLabel = "The last request failed. Check your env vars and try again."; + } + + function submitMessage(message: string) { + const trimmed = message.trim(); + + if (!trimmed || isBusy) { + return; + } + + void sendMessage({ text: trimmed }); + setInput(""); + } + + return ( +
+
+
+ OpenAI + Vercel AI SDK +

Relay is a starter chat surface with an agent and mock tools.

+

+ The UI streams model output, the agent decides when to call tools, + and each tool returns deterministic fake data so we can shape the + product before connecting real systems. +

+ +
+ Setup +

+ Add OPENAI_API_KEY to .env.local. You + can optionally set OPENAI_MODEL; this demo defaults + to gpt-5 based on the current AI SDK OpenAI provider + docs. +

+
+ +
+ {starterPrompts.map((prompt) => ( + + ))} +
+
+ +
+
+
+ Chat Session +

{helperLabel}

+
+ + {status} + +
+ +
+ {messages.length === 0 ? ( +
+

Start with a prompt that asks for a lookup, doc search, or follow-up.

+ + The agent instructions encourage tool use for those requests, + and the tool responses are mocked for now. + +
+ ) : null} + + {messages.map((message) => ( +
+
+ {message.role === "user" ? "You" : "Relay"} +
+ +
+ {message.parts.map((part, index) => { + const key = `${message.id}-${index}`; + + switch (part.type) { + case "text": + return part.text ? ( +

+ {part.text} +

+ ) : null; + + case "tool-lookupOrderStatus": + return ( +
+ Mock tool · order status + {part.state === "input-streaming" ? ( +

Gathering order details...

+ ) : null} + {part.state === "input-available" ? ( +

Checking order {part.input.orderId}...

+ ) : null} + {part.state === "output-available" ? ( + <> +

+ Order {part.output.orderId} is{" "} + {part.output.status}. +

+

+ ETA: {part.output.eta} · Last update: {part.output.lastUpdate} +

+

{part.output.note}

+ + ) : null} + {part.state === "output-error" ? ( +

{part.errorText}

+ ) : null} +
+ ); + + case "tool-searchKnowledgeBase": + return ( +
+ Mock tool · knowledge search + {part.state === "input-streaming" ? ( +

Scanning the help center...

+ ) : null} + {part.state === "input-available" ? ( +

Searching for {part.input.topic}...

+ ) : null} + {part.state === "output-available" ? ( + <> +

{part.output.summary}

+
    + {part.output.matches.map((match) => ( +
  • + {match.title} + {match.snippet} +
  • + ))} +
+ + ) : null} + {part.state === "output-error" ? ( +

{part.errorText}

+ ) : null} +
+ ); + + case "tool-scheduleFollowUp": + return ( +
+ Mock tool · follow-up booking + {part.state === "input-streaming" ? ( +

Preparing a mock follow-up...

+ ) : null} + {part.state === "input-available" ? ( +

+ Booking a {part.input.channel} follow-up for{" "} + {part.input.customerName}. +

+ ) : null} + {part.state === "output-available" ? ( + <> +

+ Placeholder booking created for {part.output.customerName}. +

+

+ {part.output.channel} · {part.output.scheduledFor} +

+

+ Confirmation: {part.output.confirmationId} +

+ + ) : null} + {part.state === "output-error" ? ( +

{part.errorText}

+ ) : null} +
+ ); + + default: + return null; + } + })} +
+
+ ))} +
+ +
{ + event.preventDefault(); + submitMessage(input); + }} + > +