Skip to content

Commit 908480b

Browse files
committed
Add agent UI components, workflows and streaming
Expose V0_API_KEY and add v0-sdk dependency. Update chat API to stream via toAISdkFormat and createUIMessageStream, and add a client helper to convert Mastra streams to AI SDK format. Add agent UI components (chain-of-thought, plan, task, queue, confirmation, inline citations, checkpoint) and a Workflows page. Adjust tsconfig types paths and update navbar/footer links
1 parent 6b4bc1b commit 908480b

20 files changed

Lines changed: 1630 additions & 75 deletions

.env.example

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ LANGFUSE_SECRET_KEY="sk-lf-your_secret_key_here"
2828
LANGFUSE_PUBLIC_KEY="pk-lf-your_public_key_here"
2929
LANGFUSE_BASE_URL="https://cloud.langfuse.com"
3030

31+
V0_API_KEY='your_v0'
32+
3133
# Database (Postgres / PgVector)
3234
SUPABASE='postgresql://user:password@localhost:5432/mastra'
3335
DATABASE_URL='postgresql://user:password@localhost:5432/mastra'
@@ -63,4 +65,4 @@ LOG_LEVEL='debug'
6365
NEXT_PUBLIC_MASTRA_API_URL='http://localhost:4111'
6466

6567
# Example placeholders for local testing
66-
LOCAL_DEV='true'
68+
LOCAL_DEV='true'

app/api/chat/route.ts

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,41 @@
11
import { mastra } from "../../../src/mastra";
2+
import { UIMessage, convertToModelMessages } from 'ai';
3+
import { createUIMessageStream, createUIMessageStreamResponse } from "ai";
4+
import { toAISdkFormat } from "@mastra/ai-sdk";
5+
import type { ChunkType, MastraModelOutput } from "@mastra/core/stream";
26

7+
export const maxDuration = 30;
38
export async function POST(req: Request) {
4-
const { messages } = await req.json();
5-
const myAgent = mastra.getAgent("weatherAgent");
6-
const stream = await myAgent.stream(messages, { format: "aisdk" });
9+
const { messages }: {
10+
messages: UIMessage[];
11+
} = await req.json();
12+
const myAgent = mastra.getAgent("weatherAgent");
13+
const stream = await myAgent.stream(messages, { });
14+
const uiMessageStream = createUIMessageStream({
15+
execute: async ({ writer }) => {
16+
const formatted = toAISdkFormat(stream, { from: "agent" })!;
717

8-
return stream.toUIMessageStreamResponse();
9-
}
18+
// If the returned object is an async iterable, use for-await
19+
if (Symbol.asyncIterator in formatted) {
20+
for await (const part of formatted as AsyncIterable<any>) {
21+
writer.write(part);
22+
}
23+
} else if (typeof (formatted as any).getReader === "function") {
24+
// If it's a ReadableStream (browser), read via getReader()
25+
const reader = (formatted as ReadableStream<any>).getReader();
26+
try {
27+
while (true) {
28+
const { done, value } = await reader.read();
29+
if (done) break;
30+
writer.write(value);
31+
}
32+
} finally {
33+
reader.releaseLock?.();
34+
}
35+
}
36+
},
37+
});
38+
return createUIMessageStreamResponse({
39+
stream: uiMessageStream,
40+
});
41+
}
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
"use client"
2+
3+
import {
4+
ChainOfThought,
5+
ChainOfThoughtHeader,
6+
ChainOfThoughtContent,
7+
ChainOfThoughtStep,
8+
ChainOfThoughtSearchResults,
9+
ChainOfThoughtSearchResult,
10+
} from "@/src/components/ai-elements/chain-of-thought"
11+
import { SearchIcon, BrainIcon, CheckIcon, LoaderIcon } from "lucide-react"
12+
13+
export interface ReasoningStep {
14+
id: string
15+
label: string
16+
description?: string
17+
status: "complete" | "active" | "pending"
18+
searchResults?: string[]
19+
}
20+
21+
interface AgentChainOfThoughtProps {
22+
steps: ReasoningStep[]
23+
isStreaming?: boolean
24+
defaultOpen?: boolean
25+
}
26+
27+
export function AgentChainOfThought({
28+
steps,
29+
isStreaming = false,
30+
defaultOpen = true,
31+
}: AgentChainOfThoughtProps) {
32+
if (!steps || steps.length === 0) return null
33+
34+
return (
35+
<ChainOfThought defaultOpen={defaultOpen}>
36+
<ChainOfThoughtHeader>
37+
{isStreaming ? "Thinking..." : "Chain of Thought"}
38+
</ChainOfThoughtHeader>
39+
<ChainOfThoughtContent>
40+
{steps.map((step) => (
41+
<ChainOfThoughtStep
42+
key={step.id}
43+
icon={
44+
step.status === "active"
45+
? LoaderIcon
46+
: step.status === "complete"
47+
? CheckIcon
48+
: BrainIcon
49+
}
50+
label={step.label}
51+
description={step.description}
52+
status={step.status}
53+
>
54+
{step.searchResults && step.searchResults.length > 0 && (
55+
<ChainOfThoughtSearchResults>
56+
{step.searchResults.map((result, i) => (
57+
<ChainOfThoughtSearchResult key={i}>
58+
<SearchIcon className="size-3" />
59+
{result}
60+
</ChainOfThoughtSearchResult>
61+
))}
62+
</ChainOfThoughtSearchResults>
63+
)}
64+
</ChainOfThoughtStep>
65+
))}
66+
</ChainOfThoughtContent>
67+
</ChainOfThought>
68+
)
69+
}
70+
71+
export function parseReasoningToSteps(reasoning: string): ReasoningStep[] {
72+
if (!reasoning) return []
73+
74+
const lines = reasoning.split("\n").filter((line) => line.trim())
75+
const steps: ReasoningStep[] = []
76+
77+
lines.forEach((line, index) => {
78+
const trimmed = line.trim()
79+
if (trimmed.startsWith("-") || trimmed.startsWith("•") || trimmed.match(/^\d+\./)) {
80+
steps.push({
81+
id: `step-${index}`,
82+
label: trimmed.replace(/^[-\d.]+\s*/, ""),
83+
status: "complete",
84+
})
85+
} else if (trimmed.length > 10) {
86+
steps.push({
87+
id: `step-${index}`,
88+
label: trimmed.slice(0, 80) + (trimmed.length > 80 ? "..." : ""),
89+
description: trimmed.length > 80 ? trimmed : undefined,
90+
status: "complete",
91+
})
92+
}
93+
})
94+
95+
return steps.slice(0, 10)
96+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
"use client"
2+
3+
import {
4+
Checkpoint,
5+
CheckpointIcon,
6+
CheckpointTrigger,
7+
} from "@/src/components/ai-elements/checkpoint"
8+
import { BookmarkIcon, RotateCcwIcon } from "lucide-react"
9+
10+
interface AgentCheckpointProps {
11+
messageIndex: number
12+
timestamp?: Date
13+
onRestore: (messageIndex: number) => void
14+
}
15+
16+
export function AgentCheckpoint({
17+
messageIndex,
18+
timestamp,
19+
onRestore,
20+
}: AgentCheckpointProps) {
21+
const label = timestamp
22+
? `Restore to ${timestamp.toLocaleTimeString()}`
23+
: "Restore checkpoint"
24+
25+
return (
26+
<Checkpoint>
27+
<CheckpointIcon>
28+
<BookmarkIcon className="size-4 shrink-0 text-primary" />
29+
</CheckpointIcon>
30+
<CheckpointTrigger
31+
onClick={() => onRestore(messageIndex)}
32+
tooltip={label}
33+
className="gap-1"
34+
>
35+
<RotateCcwIcon className="size-3" />
36+
<span className="text-xs">Restore</span>
37+
</CheckpointTrigger>
38+
</Checkpoint>
39+
)
40+
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
"use client"
2+
3+
import {
4+
Confirmation,
5+
ConfirmationRequest,
6+
ConfirmationAccepted,
7+
ConfirmationRejected,
8+
ConfirmationActions,
9+
ConfirmationAction,
10+
type ConfirmationProps,
11+
} from "@/src/components/ai-elements/confirmation"
12+
import type { ToolUIPart } from "ai"
13+
import { CheckIcon, XIcon, AlertTriangleIcon } from "lucide-react"
14+
15+
interface AgentConfirmationProps {
16+
toolName: string
17+
description: string
18+
approval: ConfirmationProps["approval"]
19+
state: ToolUIPart["state"]
20+
onApprove: (approvalId: string) => void
21+
onReject: (approvalId: string) => void
22+
}
23+
24+
export function AgentConfirmation({
25+
toolName,
26+
description,
27+
approval,
28+
state,
29+
onApprove,
30+
onReject,
31+
}: AgentConfirmationProps) {
32+
if (!approval) return null
33+
34+
return (
35+
<Confirmation approval={approval} state={state}>
36+
<ConfirmationRequest>
37+
<div className="flex items-start gap-2">
38+
<AlertTriangleIcon className="size-4 text-yellow-500 mt-0.5 shrink-0" />
39+
<div>
40+
<p className="font-medium text-sm">
41+
Tool <code className="bg-muted px-1 rounded">{toolName}</code> requires approval
42+
</p>
43+
<p className="text-sm text-muted-foreground mt-1">{description}</p>
44+
</div>
45+
</div>
46+
</ConfirmationRequest>
47+
<ConfirmationAccepted>
48+
<div className="flex items-center gap-2 text-green-600">
49+
<CheckIcon className="size-4" />
50+
<span className="text-sm">Tool execution approved</span>
51+
</div>
52+
</ConfirmationAccepted>
53+
<ConfirmationRejected>
54+
<div className="flex items-center gap-2 text-red-600">
55+
<XIcon className="size-4" />
56+
<span className="text-sm">Tool execution rejected</span>
57+
</div>
58+
</ConfirmationRejected>
59+
<ConfirmationActions>
60+
<ConfirmationAction
61+
variant="outline"
62+
onClick={() => approval?.id && onReject(approval.id)}
63+
>
64+
Reject
65+
</ConfirmationAction>
66+
<ConfirmationAction
67+
variant="default"
68+
onClick={() => approval?.id && onApprove(approval.id)}
69+
>
70+
Approve
71+
</ConfirmationAction>
72+
</ConfirmationActions>
73+
</Confirmation>
74+
)
75+
}
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
"use client"
2+
3+
import {
4+
InlineCitation,
5+
InlineCitationText,
6+
InlineCitationCard,
7+
InlineCitationCardTrigger,
8+
InlineCitationCardBody,
9+
InlineCitationCarousel,
10+
InlineCitationCarouselHeader,
11+
InlineCitationCarouselContent,
12+
InlineCitationCarouselItem,
13+
InlineCitationCarouselPrev,
14+
InlineCitationCarouselNext,
15+
InlineCitationCarouselIndex,
16+
InlineCitationSource,
17+
InlineCitationQuote,
18+
} from "@/src/components/ai-elements/inline-citation"
19+
import type { ReactNode } from "react"
20+
21+
export interface Citation {
22+
id: string
23+
number: string
24+
title: string
25+
url: string
26+
description?: string
27+
quote?: string
28+
}
29+
30+
interface AgentInlineCitationProps {
31+
citations: Citation[]
32+
text: string
33+
}
34+
35+
export function AgentInlineCitation({ citations, text }: AgentInlineCitationProps) {
36+
const citation = citations[0]
37+
if (!citation) return <span>{text}</span>
38+
39+
return (
40+
<InlineCitation>
41+
<InlineCitationText>{text}</InlineCitationText>
42+
<InlineCitationCard>
43+
<InlineCitationCardTrigger sources={citations.map((c) => c.url)} />
44+
<InlineCitationCardBody>
45+
<InlineCitationCarousel>
46+
<InlineCitationCarouselHeader>
47+
<InlineCitationCarouselPrev />
48+
<InlineCitationCarouselNext />
49+
<InlineCitationCarouselIndex />
50+
</InlineCitationCarouselHeader>
51+
<InlineCitationCarouselContent>
52+
{citations.map((c) => (
53+
<InlineCitationCarouselItem key={c.id}>
54+
<InlineCitationSource
55+
title={c.title}
56+
url={c.url}
57+
description={c.description}
58+
/>
59+
{c.quote && (
60+
<InlineCitationQuote>{c.quote}</InlineCitationQuote>
61+
)}
62+
</InlineCitationCarouselItem>
63+
))}
64+
</InlineCitationCarouselContent>
65+
</InlineCitationCarousel>
66+
</InlineCitationCardBody>
67+
</InlineCitationCard>
68+
</InlineCitation>
69+
)
70+
}
71+
72+
export function parseInlineCitations(
73+
content: string,
74+
sources: { url: string; title: string }[]
75+
): ReactNode[] {
76+
if (!sources.length) return [content]
77+
78+
const parts: ReactNode[] = []
79+
const citationRegex = /\[(\d+)\]/g
80+
let lastIndex = 0
81+
let match
82+
83+
while ((match = citationRegex.exec(content)) !== null) {
84+
if (match.index > lastIndex) {
85+
parts.push(content.slice(lastIndex, match.index))
86+
}
87+
88+
const citationNumber = match[1]
89+
const sourceIndex = parseInt(citationNumber, 10) - 1
90+
const source = sources[sourceIndex]
91+
92+
if (source) {
93+
parts.push(
94+
<AgentInlineCitation
95+
key={`citation-${match.index}`}
96+
text={match[0]}
97+
citations={[
98+
{
99+
id: `cite-${sourceIndex}`,
100+
number: citationNumber,
101+
title: source.title,
102+
url: source.url,
103+
},
104+
]}
105+
/>
106+
)
107+
} else {
108+
parts.push(match[0])
109+
}
110+
111+
lastIndex = match.index + match[0].length
112+
}
113+
114+
if (lastIndex < content.length) {
115+
parts.push(content.slice(lastIndex))
116+
}
117+
118+
return parts.length ? parts : [content]
119+
}

0 commit comments

Comments
 (0)