Skip to content
Merged
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ export default function Chat() {
>
<TamboMcpProvider mcpServers={mcpServers}>
<div className="flex h-full">
<MessageThreadFull contextKey="tambo-template" />
<MessageThreadFull />
<div className="hidden md:block w-[60%]">
<InteractableTabs interactableId="Tabs" />
<InteractableCanvasDetails interactableId="CanvasDetails" />
Expand Down
15 changes: 14 additions & 1 deletion next.config.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,20 @@
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
/* config options here */
// Run ESLint separately via `npm run lint` (avoids deprecated next lint)
eslint: {
ignoreDuringBuilds: true,
},
// Stub optional peer deps from @standard-community/standard-json
webpack: (config) => {
config.resolve.alias = {
...config.resolve.alias,
effect: false,
sury: false,
"@valibot/to-json-schema": false,
};
return config;
},
};

export default nextConfig;
602 changes: 320 additions & 282 deletions package-lock.json

Large diffs are not rendered by default.

3 changes: 1 addition & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,7 @@
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-tooltip": "1.2.8",
"@tailwindcss/oxide": "^4.1.18",
"@tambo-ai/react": "^0.69.1",
"@tambo-ai/typescript-sdk": "^0.84.0",
"@tambo-ai/react": "^1.0.0-rc.4",
"@tiptap/extension-document": "^3.12.1",
"@tiptap/extension-hard-break": "^3.16.0",
"@tiptap/extension-mention": "^3.12.1",
Expand Down
53 changes: 5 additions & 48 deletions src/app/chat/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,70 +4,27 @@ import { MessageThreadFull } from "@/components/tambo/message-thread-full";
import ComponentsCanvas from "@/components/ui/components-canvas";
import { InteractableCanvasDetails } from "@/components/ui/interactable-canvas-details";
import { InteractableTabs } from "@/components/ui/interactable-tabs";
import { useAnonymousUserKey } from "@/lib/use-anonymous-user-key";
import { components, tools } from "@/lib/tambo";
import { TamboProvider } from "@tambo-ai/react";
import { TamboMcpProvider } from "@tambo-ai/react/mcp";
import { useSyncExternalStore } from "react";

const STORAGE_KEY = "tambo-demo-context-key";

function getContextKey(): string {
let key = localStorage.getItem(STORAGE_KEY);
if (!key) {
key = crypto.randomUUID();
localStorage.setItem(STORAGE_KEY, key);
}
return key;
}

function subscribe(callback: () => void): () => void {
window.addEventListener("storage", callback);
return () => window.removeEventListener("storage", callback);
}

/**
* Gets or creates a unique context key for thread isolation.
*
* NOTE: For production, use `userToken` prop instead of `contextKey`.
* The userToken integrates with your auth provider (e.g., Better Auth, Clerk)
* for proper user isolation with token refresh handling.
*
* Example:
* const userToken = useUserToken(); // from your auth provider
* <TamboProvider userToken={userToken} ... />
*/
function useContextKey(): string | null {
return useSyncExternalStore(subscribe, getContextKey, () => null);
}

/**
* Home page component that renders the Tambo chat interface.
*
* @remarks
* The `NEXT_PUBLIC_TAMBO_URL` environment variable specifies the URL of the Tambo server.
* You do not need to set it if you are using the default Tambo server.
* It is only required if you are running the API server locally.
*
* @see {@link https://github.com/tambo-ai/tambo/blob/main/CONTRIBUTING.md} for instructions on running the API server locally.
*/
export default function Home() {
const mcpServers = useMcpServers();
const contextKey = useContextKey();
const userKey = useAnonymousUserKey();

// Wait for contextKey to be loaded from localStorage
if (!contextKey) {
return null;
}
// You can customize default suggestions via MessageThreadFull internals

return (
<div className="h-screen flex flex-col overflow-hidden relative">
<TamboProvider
apiKey={process.env.NEXT_PUBLIC_TAMBO_API_KEY!}
tamboUrl={process.env.NEXT_PUBLIC_TAMBO_URL!}
// For production, use userToken with your auth provider instead. See: https://docs.tambo.co/concepts/user-authentication
userKey={userKey}
components={components}
tools={tools}
mcpServers={mcpServers}
contextKey={contextKey}
>
<TamboMcpProvider>
<div className="flex h-full overflow-hidden">
Expand Down
10 changes: 10 additions & 0 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,16 @@ body {
"Segoe UI Emoji";
}

/* TipTap editor placeholder - required for @tiptap/extension-placeholder to display */
.tiptap p.is-editor-empty:first-child::before {
content: attr(data-placeholder);
float: left;
color: hsl(var(--muted-foreground));
opacity: 0.5;
pointer-events: none;
height: 0;
}

@layer base {
:root {
--card: 0 0% 100%;
Expand Down
8 changes: 4 additions & 4 deletions src/components/tambo/dictation-button.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Tooltip } from "@/components/tambo/suggestions-tooltip";
import { Tooltip } from "@/components/tambo/message-suggestions";
import { useTamboThreadInput, useTamboVoice } from "@tambo-ai/react";
import { Loader2Icon, Mic, Square } from "lucide-react";
import React, { useEffect, useRef } from "react";
Expand All @@ -15,7 +15,7 @@ export default function DictationButton() {
transcript,
transcriptionError,
} = useTamboVoice();
const { value, setValue } = useTamboThreadInput();
const { setValue } = useTamboThreadInput();
const lastProcessedTranscriptRef = useRef<string>("");

const handleStartRecording = () => {
Expand All @@ -30,9 +30,9 @@ export default function DictationButton() {
useEffect(() => {
if (transcript && transcript !== lastProcessedTranscriptRef.current) {
lastProcessedTranscriptRef.current = transcript;
setValue(value + " " + transcript);
setValue((prev) => prev + " " + transcript);
}
}, [transcript, value, setValue]);
}, [transcript, setValue]);

if (isTranscribing) {
return (
Expand Down
13 changes: 5 additions & 8 deletions src/components/tambo/graph.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -196,15 +196,12 @@ export const Graph = React.forwardRef<HTMLDivElement, GraphProps>(
{ className, variant, size, data, title, showLegend = true, ...props },
ref,
) => {
// Use larger size for pie charts by default to give them more room
const effectiveSize = size ?? (data?.type === "pie" ? "lg" : "default");

// If no data received yet, show loading
if (!data) {
return (
<div
ref={ref}
className={cn(graphVariants({ variant, size: effectiveSize }), className)}
className={cn(graphVariants({ variant, size }), className)}
{...props}
>
<div className="p-4 h-full flex items-center justify-center">
Expand Down Expand Up @@ -235,7 +232,7 @@ export const Graph = React.forwardRef<HTMLDivElement, GraphProps>(
return (
<div
ref={ref}
className={cn(graphVariants({ variant, size: effectiveSize }), className)}
className={cn(graphVariants({ variant, size }), className)}
{...props}
>
<div className="p-4 h-full flex items-center justify-center">
Expand All @@ -260,7 +257,7 @@ export const Graph = React.forwardRef<HTMLDivElement, GraphProps>(
return (
<div
ref={ref}
className={cn(graphVariants({ variant, size: effectiveSize }), className)}
className={cn(graphVariants({ variant, size }), className)}
{...props}
>
<div className="p-4 h-full flex items-center justify-center">
Expand Down Expand Up @@ -468,10 +465,10 @@ export const Graph = React.forwardRef<HTMLDivElement, GraphProps>(
};

return (
<GraphErrorBoundary className={className} variant={variant} size={effectiveSize}>
<GraphErrorBoundary className={className} variant={variant} size={size}>
<div
ref={ref}
className={cn(graphVariants({ variant, size: effectiveSize }), className)}
className={cn(graphVariants({ variant, size }), className)}
{...props}
>
<div className="p-4 h-full">
Expand Down
132 changes: 113 additions & 19 deletions src/components/tambo/mcp-components.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,74 @@
import {
Tooltip,
TooltipProvider,
} from "@/components/tambo/suggestions-tooltip";
} from "@/components/tambo/message-suggestions";
import { cn } from "@/lib/utils";
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
import {
useTamboMcpPrompt,
useTamboMcpPromptList,
useTamboMcpResourceList,
} from "@tambo-ai/react/mcp";
import { AtSign, FileText, Search } from "lucide-react";
import { AlertCircle, AtSign, FileText, Search } from "lucide-react";
import * as React from "react";

/**
* Represents a single message content item from an MCP prompt.
*/
interface PromptMessageContent {
type?: string;
text?: string;
}

/**
* Represents a single message from an MCP prompt.
*/
interface PromptMessage {
content?: PromptMessageContent;
}

/**
* Validates that prompt data has a valid messages array structure.
* @param promptData - The prompt data to validate
* @returns true if the prompt data has valid messages, false otherwise
*/
function isValidPromptData(
promptData: unknown,
): promptData is { messages: PromptMessage[] } {
if (!promptData || typeof promptData !== "object") {
return false;
}

const data = promptData as { messages?: unknown };
if (!Array.isArray(data.messages)) {
return false;
}

return true;
}

/**
* Safely extracts text content from prompt messages.
* Handles malformed or missing content gracefully.
* @param messages - Array of prompt messages
* @returns Extracted text content joined by newlines
*/
function extractPromptText(messages: PromptMessage[]): string {
return messages
.map((msg) => {
// Safely access nested properties
if (
msg?.content?.type === "text" &&
typeof msg.content.text === "string"
) {
return msg.content.text;
}
return "";
})
.filter(Boolean)
.join("\n");
}

/**
* Props for the McpPromptButton component.
*/
Expand Down Expand Up @@ -45,21 +102,32 @@ export const McpPromptButton = React.forwardRef<
const [selectedPromptName, setSelectedPromptName] = React.useState<
string | null
>(null);
const { data: promptData } = useTamboMcpPrompt(selectedPromptName ?? "");
const [promptError, setPromptError] = React.useState<string | null>(null);
const { data: promptData, error: fetchError } = useTamboMcpPrompt(
selectedPromptName ?? "",
);

// When prompt data is fetched, insert it into the input
// When prompt data is fetched, validate and insert it into the input
React.useEffect(() => {
if (promptData && selectedPromptName) {
// Extract the text from the prompt messages
const promptText = promptData.messages
.map((msg) => {
if (msg.content.type === "text") {
return msg.content.text;
}
return "";
})
.filter(Boolean)
.join("\n");
if (selectedPromptName && promptData) {
// Validate prompt data structure
if (!isValidPromptData(promptData)) {
setPromptError("Invalid prompt format received");
setSelectedPromptName(null);
return;
}

// Extract text with safe access
const promptText = extractPromptText(promptData.messages);

if (!promptText) {
setPromptError("Prompt contains no text content");
setSelectedPromptName(null);
return;
}

// Clear any previous errors
setPromptError(null);

// Insert the prompt text, appending to existing value if any
const newValue = value ? `${value}\n\n${promptText}` : promptText;
Expand All @@ -70,6 +138,22 @@ export const McpPromptButton = React.forwardRef<
}
}, [promptData, selectedPromptName, onInsertText, value]);

// Handle fetch errors
React.useEffect(() => {
if (fetchError) {
setPromptError("Failed to load prompt");
setSelectedPromptName(null);
}
}, [fetchError]);

// Clear error after a delay
React.useEffect(() => {
if (promptError) {
const timer = setTimeout(() => setPromptError(null), 3000);
return () => clearTimeout(timer);
}
}, [promptError]);

// Only show button if prompts are available (hide during loading and when no prompts)
if (!promptList || promptList.length === 0) {
return null;
Expand All @@ -83,21 +167,31 @@ export const McpPromptButton = React.forwardRef<
return (
<TooltipProvider>
<Tooltip
content="Insert MCP Prompt"
content={promptError ?? "Insert MCP Prompt"}
side="top"
className="bg-muted text-foreground"
className={cn(
"bg-muted text-foreground",
promptError && "bg-destructive text-destructive-foreground",
)}
>
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<button
ref={ref}
type="button"
className={buttonClasses}
className={cn(
buttonClasses,
promptError && "border-destructive text-destructive",
)}
aria-label="Insert MCP Prompt"
data-slot="mcp-prompt-button"
{...props}
>
<FileText className="w-4 h-4" />
{promptError ? (
<AlertCircle className="w-4 h-4" />
) : (
<FileText className="w-4 h-4" />
)}
</button>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
Expand Down
Loading