Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,10 @@ NEXT_PUBLIC_AI_METRICS_SERVER_URL=https://leaderboard-api.livepeer.cloud

# Optional dev overrides (e.g. Graph Studio sandbox; leave empty in prod)
NEXT_PUBLIC_SUBGRAPH_ENDPOINT=

# AI Chat (optional — chat feature degrades gracefully without these)
GOOGLE_GENERATIVE_AI_API_KEY=
UPSTASH_REDIS_REST_URL=
UPSTASH_REDIS_REST_TOKEN=
UPSTASH_VECTOR_REST_URL=
UPSTASH_VECTOR_REST_TOKEN=
93 changes: 93 additions & 0 deletions components/AiChat/ChatInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { Box, Flex } from "@livepeer/design-system";
import React, { useCallback, useRef } from "react";

export default function ChatInput({
input,
setInput,
onSubmit,
isLoading,
}: {
input: string;
setInput: (value: string) => void;
onSubmit: () => void;
isLoading: boolean;
}) {
const inputRef = useRef<HTMLTextAreaElement>(null);

const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
if (input.trim() && !isLoading) {
onSubmit();
}
}
},
[input, isLoading, onSubmit]
);

return (
<Flex
css={{
padding: "$2 $3",
borderTop: "1px solid $neutral6",
gap: "$2",
alignItems: "flex-end",
}}
>
<Box
as="textarea"
ref={inputRef}
value={input}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) =>
setInput(e.target.value)
}
onKeyDown={handleKeyDown}
placeholder="Ask about Livepeer..."
rows={1}
css={{
flex: 1,
resize: "none",
border: "1px solid $neutral6",
borderRadius: "$2",
padding: "$2",
fontSize: "$2",
fontFamily: "inherit",
backgroundColor: "$neutral2",
color: "$hiContrast",
outline: "none",
maxHeight: 80,
"&:focus": {
borderColor: "$primary9",
},
"&::placeholder": {
color: "$neutral9",
},
}}
/>
<Box
as="button"
onClick={() => {
if (input.trim() && !isLoading) onSubmit();
}}
disabled={!input.trim() || isLoading}
css={{
padding: "$2 $3",
borderRadius: "$2",
border: "none",
backgroundColor: isLoading || !input.trim() ? "$neutral6" : "$primary9",
color: "white",
cursor: isLoading || !input.trim() ? "not-allowed" : "pointer",
fontSize: "$2",
fontWeight: 600,
flexShrink: 0,
"&:hover:not(:disabled)": {
backgroundColor: "$primary10",
},
}}
>
{isLoading ? "..." : "Send"}
</Box>
</Flex>
);
}
150 changes: 150 additions & 0 deletions components/AiChat/ChatPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import { useChat } from "@ai-sdk/react";
import { Box, Flex, Text } from "@livepeer/design-system";
import { Cross2Icon } from "@modulz/radix-icons";
import { DefaultChatTransport } from "ai";
import { useCallback, useMemo, useState } from "react";

import ChatInput from "./ChatInput";
import MessageThread from "./MessageThread";
import SuggestedQuestions from "./SuggestedQuestions";

export default function ChatPanel({ onClose }: { onClose: () => void }) {
const transport = useMemo(
() => new DefaultChatTransport({ api: "/api/ai/chat" }),
[]
);

const { messages, sendMessage, status, error } = useChat({ transport });

// Manage input state locally since v6 useChat doesn't provide it
const [input, setInput] = useState("");

const isLoading = status === "submitted" || status === "streaming";

const onSelectSuggestion = useCallback(
(question: string) => {
sendMessage({ text: question });
},
[sendMessage]
);

const onSubmitInput = useCallback(() => {
if (input.trim()) {
sendMessage({ text: input.trim() });
setInput("");
}
}, [input, sendMessage]);

return (
<Box
css={{
position: "fixed",
bottom: 80,
right: 20,
width: 400,
height: 600,
maxHeight: "calc(100vh - 100px)",
borderRadius: "$4",
backgroundColor: "$loContrast",
border: "1px solid $neutral6",
boxShadow:
"0 20px 60px rgba(0,0,0,0.3), 0 0 0 1px rgba(0,0,0,0.05)",
display: "flex",
flexDirection: "column",
overflow: "hidden",
zIndex: 1000,
"@media (max-width: 480px)": {
position: "fixed",
top: 0,
left: 0,
right: 0,
bottom: 0,
width: "100%",
height: "100%",
maxHeight: "100%",
borderRadius: 0,
},
}}
>
{/* Header */}
<Flex
css={{
padding: "$3",
borderBottom: "1px solid $neutral6",
alignItems: "center",
justifyContent: "space-between",
backgroundColor: "$neutral2",
flexShrink: 0,
}}
>
<Text size="3" css={{ fontWeight: 600 }}>
Livepeer AI Assistant
</Text>
<Box
as="button"
onClick={onClose}
css={{
background: "none",
border: "none",
cursor: "pointer",
color: "$neutral11",
padding: "$1",
borderRadius: "$2",
display: "flex",
alignItems: "center",
justifyContent: "center",
"&:hover": {
backgroundColor: "$neutral4",
},
}}
>
<Cross2Icon />
</Box>
</Flex>

{/* Messages or Suggestions */}
{messages.length === 0 ? (
<Box css={{ flex: 1, overflowY: "auto" }}>
<Box css={{ padding: "$4", textAlign: "center" }}>
<Text
size="3"
css={{ fontWeight: 600, display: "block", marginBottom: "$1" }}
>
Ask me anything about Livepeer
</Text>
<Text variant="neutral" size="2">
I can look up orchestrators, delegators, protocol stats, and more.
</Text>
</Box>
<SuggestedQuestions onSelect={onSelectSuggestion} />
</Box>
) : (
<MessageThread messages={messages} isLoading={isLoading} />
)}

{/* Error display */}
{error && (
<Box
css={{
padding: "$2 $3",
backgroundColor: "$red3",
borderTop: "1px solid $red6",
flexShrink: 0,
}}
>
<Text size="1" css={{ color: "$red11" }}>
{error.message ?? "Something went wrong. Please try again."}
</Text>
</Box>
)}

{/* Input */}
<ChatInput
input={input}
setInput={setInput}
onSubmit={onSubmitInput}
isLoading={isLoading}
/>
</Box>
);
}
131 changes: 131 additions & 0 deletions components/AiChat/MessageBubble.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import { Box, Text } from "@livepeer/design-system";
import type { UIMessage } from "ai";

import ChartRenderer from "./renderers/ChartRenderer";
import StatsCard from "./renderers/StatsCard";
import TableRenderer from "./renderers/TableRenderer";

type ToolResult = {
type: "table" | "stats" | "chart" | "error";
title?: string;
message?: string;
[key: string]: unknown;
};

function ToolResultRenderer({ result }: { result: ToolResult }) {
if (result.type === "error") {
return (
<Box
css={{
padding: "$2 $3",
borderRadius: "$2",
backgroundColor: "$red3",
border: "1px solid $red6",
marginTop: "$1",
}}
>
<Text size="2" css={{ color: "$red11" }}>
{result.message ?? "An error occurred"}
</Text>
</Box>
);
}

if (result.type === "table") {
return <TableRenderer data={result as never} />;
}

if (result.type === "stats") {
return <StatsCard data={result as never} />;
}

if (result.type === "chart") {
return <ChartRenderer data={result as never} />;
}

return null;
}

export default function MessageBubble({ message }: { message: UIMessage }) {
const isUser = message.role === "user";

// Extract text content from parts
const textContent = message.parts
.filter((p): p is { type: "text"; text: string } => p.type === "text")
.map((p) => p.text)
.join("");

// Extract tool invocations from parts
const toolParts = message.parts.filter(
(p) =>
p.type.startsWith("tool-") || p.type === "dynamic-tool"
);

return (
<Box
css={{
display: "flex",
flexDirection: "column",
alignItems: isUser ? "flex-end" : "flex-start",
marginBottom: "$2",
}}
>
{textContent && (
<Box
css={{
maxWidth: isUser ? "80%" : "100%",
padding: "$2 $3",
borderRadius: "$3",
backgroundColor: isUser ? "$primary4" : "$neutral3",
border: `1px solid ${isUser ? "$primary6" : "$neutral6"}`,
}}
>
<Text
size="2"
css={{
lineHeight: 1.5,
whiteSpace: "pre-wrap",
wordBreak: "break-word",
}}
>
{textContent}
</Text>
</Box>
)}
{toolParts.map((part) => {
const toolPart = part as {
type: string;
toolCallId: string;
state: string;
result?: unknown;
};
if (toolPart.state === "result" && toolPart.result) {
return (
<Box key={toolPart.toolCallId} css={{ width: "100%" }}>
<ToolResultRenderer result={toolPart.result as ToolResult} />
</Box>
);
}
if (toolPart.state === "call" || toolPart.state === "partial-call") {
return (
<Box
key={toolPart.toolCallId}
css={{
padding: "$2 $3",
marginTop: "$1",
borderRadius: "$2",
backgroundColor: "$neutral2",
border: "1px solid $neutral5",
}}
>
<Text variant="neutral" size="1">
Fetching data...
</Text>
</Box>
);
}
return null;
})}
</Box>
);
}
Loading
Loading