diff --git a/.changeset/many-pants-tease.md b/.changeset/many-pants-tease.md new file mode 100644 index 00000000000..ffaa7a7c66b --- /dev/null +++ b/.changeset/many-pants-tease.md @@ -0,0 +1,5 @@ +--- +"thirdweb": patch +--- + +Handle already connected wallets in 1193 provider diff --git a/.changeset/tricky-onions-jam.md b/.changeset/tricky-onions-jam.md new file mode 100644 index 00000000000..c464297e188 --- /dev/null +++ b/.changeset/tricky-onions-jam.md @@ -0,0 +1,5 @@ +--- +"@thirdweb-dev/ai-sdk-provider": minor +--- + +Initial release diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index 6a776d2204b..17484f66109 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -60,7 +60,7 @@ "remark-gfm": "4.0.1", "responsive-rsc": "0.0.7", "server-only": "^0.0.1", - "shiki": "1.27.0", + "shiki": "3.12.0", "sonner": "2.0.6", "spdx-correct": "^3.2.0", "stripe": "17.7.0", diff --git a/apps/nebula/package.json b/apps/nebula/package.json index 9a59c849a5b..e07fc821806 100644 --- a/apps/nebula/package.json +++ b/apps/nebula/package.json @@ -32,7 +32,7 @@ "react-markdown": "10.1.0", "remark-gfm": "4.0.1", "server-only": "^0.0.1", - "shiki": "1.27.0", + "shiki": "3.12.0", "sonner": "2.0.6", "tailwind-merge": "^2.6.0", "tailwindcss-animate": "^1.0.7", diff --git a/apps/playground-web/knip.json b/apps/playground-web/knip.json index 8c166a193b7..d3e4951e0e0 100644 --- a/apps/playground-web/knip.json +++ b/apps/playground-web/knip.json @@ -1,6 +1,6 @@ { "$schema": "https://unpkg.com/knip@5/schema.json", - "ignore": ["src/components/ui/**", "src/app/insight/utils.ts"], + "ignore": ["src/components/**", "src/app/insight/utils.ts"], "ignoreDependencies": [ "server-only", "@workspace/ui", @@ -9,7 +9,8 @@ "prettier", "react-children-utilities", "react-markdown", - "remark-gfm" + "remark-gfm", + "@thirdweb-dev/ai-sdk-provider" ], "next": true, "project": ["src/**"] diff --git a/apps/playground-web/package.json b/apps/playground-web/package.json index 2488b74d5a7..ea4fd2de362 100644 --- a/apps/playground-web/package.json +++ b/apps/playground-web/package.json @@ -1,7 +1,9 @@ { "dependencies": { "@abstract-foundation/agw-react": "^1.6.4", + "@ai-sdk/react": "^2.0.25", "@hookform/resolvers": "^3.9.1", + "@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-checkbox": "^1.3.2", "@radix-ui/react-collapsible": "^1.1.11", "@radix-ui/react-dialog": "1.1.14", @@ -14,8 +16,11 @@ "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-switch": "^1.2.5", "@radix-ui/react-tooltip": "1.2.7", + "@radix-ui/react-use-controllable-state": "^1.2.2", "@tanstack/react-query": "5.81.5", + "@thirdweb-dev/ai-sdk-provider": "workspace:*", "@workspace/ui": "workspace:*", + "ai": "^5.0.25", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "4.1.0", @@ -35,11 +40,13 @@ "react-pick-color": "^2.0.0", "remark-gfm": "4.0.1", "server-only": "^0.0.1", - "shiki": "1.27.0", + "shiki": "3.12.0", "sonner": "2.0.6", + "streamdown": "^1.1.4", "tailwind-merge": "^2.6.0", "thirdweb": "workspace:*", "use-debounce": "^10.0.5", + "use-stick-to-bottom": "^1.1.1", "zod": "3.25.75" }, "devDependencies": { diff --git a/apps/playground-web/src/app/ai/ai-sdk/components/chat-container.tsx b/apps/playground-web/src/app/ai/ai-sdk/components/chat-container.tsx new file mode 100644 index 00000000000..3484a8969db --- /dev/null +++ b/apps/playground-web/src/app/ai/ai-sdk/components/chat-container.tsx @@ -0,0 +1,317 @@ +"use client"; + +import { useChat } from "@ai-sdk/react"; +import type { ThirdwebAiMessage } from "@thirdweb-dev/ai-sdk-provider"; +import { DefaultChatTransport } from "ai"; +import { useMemo, useState } from "react"; +import { defineChain, prepareTransaction } from "thirdweb"; +import { + ConnectButton, + TransactionButton, + useActiveAccount, +} from "thirdweb/react"; +import { + Conversation, + ConversationContent, + ConversationScrollButton, +} from "@/components/conversation"; +import { Message, MessageContent } from "@/components/message"; +import { + PromptInput, + PromptInputSubmit, + PromptInputTextarea, +} from "@/components/prompt-input"; +import { + Reasoning, + ReasoningContent, + ReasoningTrigger, +} from "@/components/reasoning"; +import { Response } from "@/components/response"; +import { Loader } from "../../../../components/loader"; +import { THIRDWEB_CLIENT } from "../../../../lib/client"; + +export function ChatContainer() { + const [sessionId, setSessionId] = useState(""); + + const { messages, sendMessage, status, addToolResult } = + useChat({ + transport: new DefaultChatTransport({ + api: "/api/chat", + }), + onFinish: ({ message }) => { + setSessionId(message.metadata?.session_id ?? ""); + }, + }); + const [input, setInput] = useState(""); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (input.trim()) { + sendMessage( + { text: input }, + { + body: { + sessionId, + }, + }, + ); + setInput(""); + } + }; + + return ( +
+
+ + + {messages.length === 0 && ( +
+ Type a message to start the conversation +
+ )} + {messages.map((message) => ( + + + {message.parts.map((part, i) => { + switch (part.type) { + case "text": + return ( + + {part.text} + + ); + case "reasoning": + return ( + + + + {part.text} + + + ); + case "tool-sign_transaction": + return ( + + ); + case "tool-sign_swap": + console.log("---sign_swap", part); + return ( + + ); + default: + return null; + } + })} + + + ))} + {status === "submitted" && } +
+ +
+ + + setInput(e.currentTarget.value)} + className="pr-12" + /> + + +
+
+ ); +} + +type SignTransactionButtonProps = { + input: + | Extract< + ReturnType< + typeof useChat + >["messages"][number]["parts"][number], + { type: "tool-sign_transaction" } + >["input"] + | undefined; + addToolResult: ReturnType>["addToolResult"]; + toolCallId: string; + sendMessage: ReturnType>["sendMessage"]; + sessionId: string; +}; + +const SignTransactionButton = (props: SignTransactionButtonProps) => { + const { input, addToolResult, toolCallId, sendMessage, sessionId } = props; + const transactionData: { + chain_id: number; + to: string; + data: `0x${string}`; + value: bigint; + } = useMemo(() => { + return { + chain_id: input?.chain_id || 8453, + to: input?.to || "", + data: (input?.data as `0x${string}`) || "0x", + value: input?.value ? BigInt(input.value) : BigInt(0), + }; + }, [input]); + const account = useActiveAccount(); + + if (!account) { + return ; + } + + return ( +
+ + prepareTransaction({ + client: THIRDWEB_CLIENT, + chain: defineChain(transactionData.chain_id), + to: transactionData.to, + data: transactionData.data, + value: transactionData.value, + }) + } + onTransactionSent={(transaction) => { + addToolResult({ + tool: "sign_transaction", + toolCallId, + output: { + transaction_hash: transaction.transactionHash, + chain_id: transaction.chain.id, + }, + }); + sendMessage(undefined, { + body: { + sessionId, + }, + }); + }} + onError={(error) => { + sendMessage( + { text: `Transaction failed: ${error.message}` }, + { + body: { + sessionId, + }, + }, + ); + }} + > + Sign Transaction + +
+ ); +}; + +type SignSwapButtonProps = { + input: + | Extract< + ReturnType< + typeof useChat + >["messages"][number]["parts"][number], + { type: "tool-sign_swap" } + >["input"] + | undefined; + addToolResult: ReturnType>["addToolResult"]; + toolCallId: string; + sendMessage: ReturnType>["sendMessage"]; + sessionId: string; +}; +const SignSwapButton = (props: SignSwapButtonProps) => { + const { input, addToolResult, toolCallId, sendMessage, sessionId } = props; + const transactionData: { + chain_id: number; + to: string; + data: `0x${string}`; + value: bigint; + } = useMemo(() => { + return { + chain_id: input?.transaction?.chain_id || 8453, + to: input?.transaction?.to || "", + data: (input?.transaction?.data as `0x${string}`) || "0x", + value: input?.transaction?.value + ? BigInt(input.transaction.value) + : BigInt(0), + }; + }, [input]); + const account = useActiveAccount(); + + if (!account) { + return ; + } + + return ( +
+ + prepareTransaction({ + client: THIRDWEB_CLIENT, + chain: defineChain(transactionData.chain_id), + to: transactionData.to, + data: transactionData.data, + value: transactionData.value, + }) + } + onTransactionSent={(transaction) => { + addToolResult({ + tool: "sign_swap", + toolCallId, + output: { + transaction_hash: transaction.transactionHash, + chain_id: transaction.chain.id, + }, + }); + sendMessage(undefined, { + body: { + sessionId, + }, + }); + }} + onError={(error) => { + sendMessage( + { text: `Transaction failed: ${error.message}` }, + { + body: { + sessionId, + }, + }, + ); + }} + > + Sign swap + +
+ ); +}; diff --git a/apps/playground-web/src/app/ai/ai-sdk/page.tsx b/apps/playground-web/src/app/ai/ai-sdk/page.tsx new file mode 100644 index 00000000000..b567dcc55e1 --- /dev/null +++ b/apps/playground-web/src/app/ai/ai-sdk/page.tsx @@ -0,0 +1,159 @@ +import { CodeServer } from "@workspace/ui/components/code/code.server"; +import { BotIcon, Code2Icon } from "lucide-react"; +import { CodeExample, TabName } from "@/components/code/code-example"; +import ThirdwebProvider from "@/components/thirdweb-provider"; +import { PageLayout } from "../../../components/blocks/APIHeader"; +import { createMetadata } from "../../../lib/metadata"; +import { ChatContainer } from "./components/chat-container"; + +const title = "AI SDK Integration"; +const description = + "Use the thirdweb blockchain models with the Vercel AI SDK to build AI agents and UIs that can interact with your contracts and wallets."; + +const ogDescription = + "Use the thirdweb blockchain models with the Vercel AI SDK to build AI agents and UIs that can interact with your contracts and wallets."; + +export const metadata = createMetadata({ + title, + description: ogDescription, + image: { + icon: "contract", + title, + }, +}); + +export default function Page() { + return ( + + + +
+ + + + ); +} + +function ServerCodeExample() { + return ( + <> +
+

+ Next.js Server Code Example +

+

+ The server code is responsible for handling the chat requests and + streaming the responses to the client. +

+
+
+
+ + +
+
+ + ); +} + +function ChatExample() { + return ( + ({ + transport: new DefaultChatTransport({ + // see server implementation below + api: '/api/chat', + }), + onFinish: ({ message }) => { + // record session id for continuity + setSessionId(message.metadata?.session_id ?? ''); + }, + }); + + const send = (message: string) => { + sendMessage({ text: message }, { + body: { + // send session id for continuity + sessionId, + }, + }); + } + + return ( + <> + {messages.map(message => ( + + ))} + + + ); +}`} + lang="tsx" + preview={} + /> + ); +} diff --git a/apps/playground-web/src/app/api/chat/route.ts b/apps/playground-web/src/app/api/chat/route.ts new file mode 100644 index 00000000000..3db5feddb26 --- /dev/null +++ b/apps/playground-web/src/app/api/chat/route.ts @@ -0,0 +1,37 @@ +import { createThirdwebAI } from "@thirdweb-dev/ai-sdk-provider"; +import { convertToModelMessages, streamText, type UIMessage } from "ai"; + +// Allow streaming responses up to 5 minutes +export const maxDuration = 300; + +const thirdwebAI = createThirdwebAI({ + baseURL: `https://${process.env.NEXT_PUBLIC_API_URL}`, + secretKey: process.env.THIRDWEB_SECRET_KEY, +}); + +export async function POST(req: Request) { + const body = await req.json(); + const { messages, sessionId }: { messages: UIMessage[]; sessionId: string } = + body; + + const result = streamText({ + model: thirdwebAI.chat({ + context: { + session_id: sessionId, + }, + }), + messages: convertToModelMessages(messages), + tools: thirdwebAI.tools(), + }); + + return result.toUIMessageStreamResponse({ + sendReasoning: true, + messageMetadata({ part }) { + if (part.type === "finish-step") { + return { + session_id: part.response.id, + }; + } + }, + }); +} diff --git a/apps/playground-web/src/app/navLinks.ts b/apps/playground-web/src/app/navLinks.ts index 8f3b021bd3f..081a9c24ddf 100644 --- a/apps/playground-web/src/app/navLinks.ts +++ b/apps/playground-web/src/app/navLinks.ts @@ -20,6 +20,10 @@ const ai: ShadcnSidebarLink = { href: "/ai/chat", label: "Blockchain LLM", }, + { + href: "/ai/ai-sdk", + label: "AI SDK", + }, ], }; diff --git a/apps/playground-web/src/components/code/code-example.tsx b/apps/playground-web/src/components/code/code-example.tsx index 1a196550de5..03c6b0499cf 100644 --- a/apps/playground-web/src/components/code/code-example.tsx +++ b/apps/playground-web/src/components/code/code-example.tsx @@ -55,7 +55,7 @@ export const CodeExample: React.FC = ({ ); }; -function TabName(props: { +export function TabName(props: { name: string; icon: React.FC<{ className: string }>; }) { diff --git a/apps/playground-web/src/components/conversation.tsx b/apps/playground-web/src/components/conversation.tsx new file mode 100644 index 00000000000..4dce45f3e27 --- /dev/null +++ b/apps/playground-web/src/components/conversation.tsx @@ -0,0 +1,62 @@ +"use client"; + +import { ArrowDownIcon } from "lucide-react"; +import type { ComponentProps } from "react"; +import { useCallback } from "react"; +import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +export type ConversationProps = ComponentProps; + +export const Conversation = ({ className, ...props }: ConversationProps) => ( + +); + +export type ConversationContentProps = ComponentProps< + typeof StickToBottom.Content +>; + +export const ConversationContent = ({ + className, + ...props +}: ConversationContentProps) => ( + +); + +export type ConversationScrollButtonProps = ComponentProps; + +export const ConversationScrollButton = ({ + className, + ...props +}: ConversationScrollButtonProps) => { + const { isAtBottom, scrollToBottom } = useStickToBottomContext(); + + const handleScrollToBottom = useCallback(() => { + scrollToBottom(); + }, [scrollToBottom]); + + return ( + !isAtBottom && ( + + ) + ); +}; diff --git a/apps/playground-web/src/components/loader.tsx b/apps/playground-web/src/components/loader.tsx new file mode 100644 index 00000000000..138833c853c --- /dev/null +++ b/apps/playground-web/src/components/loader.tsx @@ -0,0 +1,96 @@ +import type { HTMLAttributes } from "react"; +import { cn } from "@/lib/utils"; + +type LoaderIconProps = { + size?: number; +}; + +const LoaderIcon = ({ size = 16 }: LoaderIconProps) => ( + + Loader + + + + + + + + + + + + + + + + + + +); + +export type LoaderProps = HTMLAttributes & { + size?: number; +}; + +export const Loader = ({ className, size = 16, ...props }: LoaderProps) => ( +
+ +
+); diff --git a/apps/playground-web/src/components/message.tsx b/apps/playground-web/src/components/message.tsx new file mode 100644 index 00000000000..a223578dabd --- /dev/null +++ b/apps/playground-web/src/components/message.tsx @@ -0,0 +1,58 @@ +import type { UIMessage } from "ai"; +import type { ComponentProps, HTMLAttributes } from "react"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { cn } from "@/lib/utils"; + +export type MessageProps = HTMLAttributes & { + from: UIMessage["role"]; +}; + +export const Message = ({ className, from, ...props }: MessageProps) => ( +
div]:max-w-[80%]", + className, + )} + {...props} + /> +); + +export type MessageContentProps = HTMLAttributes; + +export const MessageContent = ({ + children, + className, + ...props +}: MessageContentProps) => ( +
+ {children} +
+); + +export type MessageAvatarProps = ComponentProps & { + src: string; + name?: string; +}; + +export const MessageAvatar = ({ + src, + name, + className, + ...props +}: MessageAvatarProps) => ( + + + {name?.slice(0, 2) || "ME"} + +); diff --git a/apps/playground-web/src/components/prompt-input.tsx b/apps/playground-web/src/components/prompt-input.tsx new file mode 100644 index 00000000000..b78ab4e4b4d --- /dev/null +++ b/apps/playground-web/src/components/prompt-input.tsx @@ -0,0 +1,230 @@ +"use client"; + +import type { ChatStatus } from "ai"; +import { Loader2Icon, SendIcon, SquareIcon, XIcon } from "lucide-react"; +import type { + ComponentProps, + HTMLAttributes, + KeyboardEventHandler, +} from "react"; +import { Children } from "react"; +import { Button } from "@/components/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Textarea } from "@/components/ui/textarea"; +import { cn } from "@/lib/utils"; + +export type PromptInputProps = HTMLAttributes; + +export const PromptInput = ({ className, ...props }: PromptInputProps) => ( +
+); + +export type PromptInputTextareaProps = ComponentProps & { + minHeight?: number; + maxHeight?: number; +}; + +export const PromptInputTextarea = ({ + onChange, + className, + placeholder = "What would you like to know?", + minHeight = 48, + maxHeight = 164, + ...props +}: PromptInputTextareaProps) => { + const handleKeyDown: KeyboardEventHandler = (e) => { + if (e.key === "Enter") { + // Don't submit if IME composition is in progress + if (e.nativeEvent.isComposing) { + return; + } + + if (e.shiftKey) { + // Allow newline + return; + } + + // Submit on Enter (without Shift) + e.preventDefault(); + const form = e.currentTarget.form; + if (form) { + form.requestSubmit(); + } + } + }; + + return ( +