From e5bf96d5526f31ae0ae3184793ae15b8e947ecf5 Mon Sep 17 00:00:00 2001 From: CharlieHelps Date: Sat, 7 Feb 2026 17:40:39 +0000 Subject: [PATCH 01/11] feat(chat): isolate threads per user --- README.md | 3 +- example.env.local | 2 +- src/app/api/tambo/[...path]/route.ts | 637 ++++++++++++++++++ src/app/chat/chat-client.tsx | 32 + src/app/chat/page.tsx | 61 +- src/app/interactables/page.tsx | 4 +- src/components/tambo/message-suggestions.tsx | 30 +- src/middleware.ts | 2 +- .../migrations/20260207_per_user_threads.sql | 116 ++++ 9 files changed, 827 insertions(+), 60 deletions(-) create mode 100644 src/app/api/tambo/[...path]/route.ts create mode 100644 src/app/chat/chat-client.tsx create mode 100644 supabase/migrations/20260207_per_user_threads.sql diff --git a/README.md b/README.md index e968482..4d207b2 100644 --- a/README.md +++ b/README.md @@ -86,9 +86,10 @@ Make sure in the TamboProvider wrapped around your app: ```tsx ... {children} diff --git a/example.env.local b/example.env.local index 3c921f9..c8e4111 100644 --- a/example.env.local +++ b/example.env.local @@ -1,3 +1,3 @@ -NEXT_PUBLIC_TAMBO_API_KEY=api-key-here +TAMBO_API_KEY=api-key-here NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key diff --git a/src/app/api/tambo/[...path]/route.ts b/src/app/api/tambo/[...path]/route.ts new file mode 100644 index 0000000..a0fbd08 --- /dev/null +++ b/src/app/api/tambo/[...path]/route.ts @@ -0,0 +1,637 @@ +import { createSupabaseServerClient } from "@/lib/supabase/server"; +import { NextResponse } from "next/server"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +const PROJECT_ID = "supabase"; + +type ThreadRow = { + id: string; + created_at: string; + updated_at: string; + name: string | null; + metadata: Record | null; +}; + +type MessageRow = { + id: string; + thread_id: string; + role: "user" | "assistant" | "system" | "tool"; + content: unknown; + component_state: Record | null; + additional_context: Record | null; + component: Record | null; + tool_call_request: Record | null; + tool_calls: unknown[] | null; + tool_call_id: string | null; + parent_message_id: string | null; + reasoning: unknown; + reasoning_duration_ms: number | null; + error: string | null; + is_cancelled: boolean | null; + metadata: Record | null; + created_at: string; +}; + +function getTamboBaseUrl(): string { + return ( + process.env.TAMBO_AI_BASE_URL ?? + process.env.TAMBO_URL ?? + process.env.NEXT_PUBLIC_TAMBO_URL ?? + "https://api.tambo.co" + ); +} + +function getTamboApiKey(): string | null { + return process.env.TAMBO_API_KEY ?? null; +} + +function jsonError(message: string, status: number) { + return NextResponse.json({ error: message }, { status }); +} + +function threadFromRow(row: ThreadRow, userId: string) { + return { + id: row.id, + createdAt: row.created_at, + updatedAt: row.updated_at, + projectId: PROJECT_ID, + name: row.name ?? undefined, + metadata: row.metadata ?? undefined, + contextKey: userId, + generationStage: "IDLE", + statusMessage: "", + }; +} + +function messageFromRow(row: MessageRow) { + return { + id: row.id, + threadId: row.thread_id, + role: row.role, + content: row.content, + createdAt: row.created_at, + componentState: row.component_state ?? {}, + additionalContext: row.additional_context ?? undefined, + component: row.component ?? undefined, + toolCallRequest: row.tool_call_request ?? undefined, + tool_calls: row.tool_calls ?? undefined, + tool_call_id: row.tool_call_id ?? undefined, + parentMessageId: row.parent_message_id ?? undefined, + reasoning: (row.reasoning ?? undefined) as never, + reasoningDurationMS: row.reasoning_duration_ms ?? undefined, + error: row.error ?? undefined, + isCancelled: row.is_cancelled ?? undefined, + metadata: row.metadata ?? undefined, + }; +} + +async function tamboFetch(pathname: string, init: RequestInit) { + const apiKey = getTamboApiKey(); + if (!apiKey) { + throw new Error( + "Missing TAMBO_API_KEY. Set it in .env.local (server-side, not NEXT_PUBLIC).", + ); + } + + const url = new URL(pathname, getTamboBaseUrl()); + const headers = new Headers(init.headers); + headers.set("x-api-key", apiKey); + headers.set("accept", "text/event-stream"); + + return fetch(url, { + ...init, + headers, + }); +} + +function getFirstUserMessageText(messages: Array<{ role: string; content: any }>) { + for (const msg of messages) { + if (msg.role !== "user") continue; + const text = Array.isArray(msg.content) + ? msg.content + .map((part: any) => (part?.type === "text" ? part.text : "")) + .filter(Boolean) + .join(" ") + .trim() + : ""; + if (text) return text; + } + return null; +} + +async function handleThreadsList( + supabase: Awaited>, + userId: string, +) { + const { data, error } = await supabase + .from("threads") + .select("id, created_at, updated_at, name, metadata") + .order("updated_at", { ascending: false }); + + if (error) { + return jsonError(error.message, 500); + } + + const items = (data as unknown as ThreadRow[]).map((row) => + threadFromRow(row, userId), + ); + + return NextResponse.json({ + items, + total: items.length, + count: items.length, + }); +} + +async function handleThreadRetrieve( + supabase: Awaited>, + userId: string, + threadId: string, +) { + const { data: thread, error: threadError } = await supabase + .from("threads") + .select("id, created_at, updated_at, name, metadata") + .eq("id", threadId) + .maybeSingle(); + + if (threadError) return jsonError(threadError.message, 500); + if (!thread) return jsonError("Not found", 404); + + const { data: messages, error: msgError } = await supabase + .from("messages") + .select( + [ + "id", + "thread_id", + "role", + "content", + "component_state", + "additional_context", + "component", + "tool_call_request", + "tool_calls", + "tool_call_id", + "parent_message_id", + "reasoning", + "reasoning_duration_ms", + "error", + "is_cancelled", + "metadata", + "created_at", + ].join(","), + ) + .eq("thread_id", threadId) + .order("created_at", { ascending: true }); + + if (msgError) return jsonError(msgError.message, 500); + + return NextResponse.json({ + ...threadFromRow(thread as unknown as ThreadRow, userId), + messages: (messages as unknown as MessageRow[]).map(messageFromRow), + }); +} + +async function handleThreadUpdate( + request: Request, + supabase: Awaited>, + userId: string, + threadId: string, +) { + const body = (await request.json().catch(() => null)) as + | { name?: string; metadata?: Record } + | null; + + if (!body) return jsonError("Invalid JSON body", 400); + + const update: Record = {}; + if (typeof body.name === "string") update.name = body.name; + if (body.metadata && typeof body.metadata === "object") update.metadata = body.metadata; + + const { error } = await supabase + .from("threads") + .update(update) + .eq("id", threadId); + + if (error) return jsonError(error.message, 500); + + const { data: thread, error: readError } = await supabase + .from("threads") + .select("id, created_at, updated_at, name, metadata") + .eq("id", threadId) + .maybeSingle(); + + if (readError) return jsonError(readError.message, 500); + if (!thread) return jsonError("Not found", 404); + + return NextResponse.json(threadFromRow(thread as unknown as ThreadRow, userId)); +} + +async function handleThreadGenerateName( + supabase: Awaited>, + userId: string, + threadId: string, +) { + const { data: messages, error } = await supabase + .from("messages") + .select("role, content") + .eq("thread_id", threadId) + .order("created_at", { ascending: true }); + + if (error) return jsonError(error.message, 500); + + const seed = getFirstUserMessageText(messages as any); + const name = seed + ? seed.slice(0, 48) + (seed.length > 48 ? "…" : "") + : `Thread ${threadId.slice(0, 8)}`; + + const { error: updateError } = await supabase + .from("threads") + .update({ name }) + .eq("id", threadId); + + if (updateError) return jsonError(updateError.message, 500); + + const { data: thread, error: readError } = await supabase + .from("threads") + .select("id, created_at, updated_at, name, metadata") + .eq("id", threadId) + .maybeSingle(); + + if (readError) return jsonError(readError.message, 500); + if (!thread) return jsonError("Not found", 404); + + return NextResponse.json(threadFromRow(thread as unknown as ThreadRow, userId)); +} + +async function handleThreadCancel() { + return NextResponse.json(true); +} + +async function handleAdvanceStream( + request: Request, + supabase: Awaited>, + userId: string, + threadId: string | null, +) { + const body = (await request.json().catch(() => null)) as any; + if (!body || !body.messageToAppend) { + return jsonError("Invalid JSON body", 400); + } + + const messageToAppend = body.messageToAppend as { + role: "user" | "assistant" | "system" | "tool"; + content: unknown; + additionalContext?: Record; + component?: Record; + toolCallRequest?: Record; + }; + + let persistentThreadId = threadId; + + if (!persistentThreadId) { + const { data: newThread, error: threadError } = await supabase + .from("threads") + .insert({ user_id: userId }) + .select("id") + .single(); + + if (threadError) return jsonError(threadError.message, 500); + persistentThreadId = (newThread as any).id as string; + + const initial = Array.isArray(body.initialMessages) ? body.initialMessages : []; + if (initial.length > 0) { + const initialRows = initial.map((m: any) => ({ + id: crypto.randomUUID(), + thread_id: persistentThreadId, + role: m.role, + content: m.content, + additional_context: m.additionalContext ?? null, + component_state: m.componentState ?? {}, + component: m.component ?? null, + tool_call_request: m.toolCallRequest ?? null, + created_at: m.createdAt ?? new Date().toISOString(), + })); + + const { error: insertError } = await supabase.from("messages").insert(initialRows); + if (insertError) return jsonError(insertError.message, 500); + } + } + + const { data: historyRows, error: historyError } = await supabase + .from("messages") + .select( + [ + "role", + "content", + "additional_context", + "component", + "tool_call_request", + "created_at", + ].join(","), + ) + .eq("thread_id", persistentThreadId) + .order("created_at", { ascending: true }); + + if (historyError) return jsonError(historyError.message, 500); + + const { error: appendError } = await supabase.from("messages").insert({ + id: crypto.randomUUID(), + thread_id: persistentThreadId, + role: messageToAppend.role, + content: messageToAppend.content, + additional_context: messageToAppend.additionalContext ?? null, + component_state: {}, + component: messageToAppend.component ?? null, + tool_call_request: messageToAppend.toolCallRequest ?? null, + }); + + if (appendError) return jsonError(appendError.message, 500); + + const initialMessages = (historyRows as any[]).map((m) => ({ + role: m.role, + content: m.content, + additionalContext: m.additional_context ?? undefined, + component: m.component ?? undefined, + toolCallRequest: m.tool_call_request ?? undefined, + })); + + const computeBody = { + ...body, + contextKey: userId, + initialMessages, + messageToAppend, + clientTools: [], + }; + + const tamboResponse = await tamboFetch("/threads/advancestream", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(computeBody), + signal: request.signal, + }); + + if (!tamboResponse.ok || !tamboResponse.body) { + const text = await tamboResponse.text().catch(() => ""); + return jsonError(text || "Tambo request failed", tamboResponse.status); + } + + const encoder = new TextEncoder(); + const decoder = new TextDecoder(); + + const messageIdMap = new Map(); + const finalMessages = new Map(); + + let didPersist = false; + const persistMessages = async () => { + if (didPersist) return; + didPersist = true; + + if (finalMessages.size > 0) { + const rows = Array.from(finalMessages.values()).map((m) => ({ + id: m.id, + thread_id: persistentThreadId, + role: m.role, + content: m.content, + component_state: m.componentState ?? {}, + additional_context: m.additionalContext ?? null, + component: m.component ?? null, + tool_call_request: m.toolCallRequest ?? null, + tool_calls: m.tool_calls ?? null, + tool_call_id: m.tool_call_id ?? null, + parent_message_id: m.parentMessageId ?? null, + reasoning: m.reasoning ?? null, + reasoning_duration_ms: m.reasoningDurationMS ?? null, + error: m.error ?? null, + is_cancelled: m.isCancelled ?? false, + metadata: m.metadata ?? null, + created_at: m.createdAt ?? new Date().toISOString(), + })); + + await supabase.from("messages").upsert(rows); + } + + await supabase + .from("threads") + .update({ updated_at: new Date().toISOString() }) + .eq("id", persistentThreadId); + }; + + let buffer = ""; + const reader = (tamboResponse.body as ReadableStream).getReader(); + + const stream = new ReadableStream({ + async pull(controller) { + const { done, value } = await reader.read(); + if (done) { + try { + await persistMessages(); + } catch (error) { + console.error("Failed to persist streamed messages:", error); + } + controller.close(); + return; + } + + buffer += decoder.decode(value, { stream: true }); + + while (true) { + const nl = buffer.indexOf("\n"); + if (nl === -1) break; + + const rawLine = buffer.slice(0, nl).trim(); + buffer = buffer.slice(nl + 1); + + if (!rawLine) continue; + if (rawLine === "data: DONE") { + controller.enqueue(encoder.encode("data: DONE\n")); + continue; + } + if (rawLine.startsWith("error: ")) { + controller.enqueue(encoder.encode(`${rawLine}\n`)); + continue; + } + + const jsonStr = rawLine.startsWith("data: ") ? rawLine.slice(6) : rawLine; + if (!jsonStr) continue; + + let chunk: any; + try { + chunk = JSON.parse(jsonStr); + } catch { + continue; + } + + const dto = chunk?.responseMessageDto; + if (dto && typeof dto === "object") { + const originalMessageId = typeof dto.id === "string" ? dto.id : null; + if (originalMessageId) { + const mapped = messageIdMap.get(originalMessageId) ?? crypto.randomUUID(); + messageIdMap.set(originalMessageId, mapped); + dto.id = mapped; + + finalMessages.set(mapped, { + ...dto, + threadId: persistentThreadId, + }); + } + + dto.threadId = persistentThreadId; + } + + const outLine = `data: ${JSON.stringify(chunk)}\n`; + controller.enqueue(encoder.encode(outLine)); + } + }, + + async cancel() { + try { + await persistMessages(); + } catch (error) { + console.error("Failed to persist streamed messages:", error); + } + reader.cancel().catch(() => undefined); + }, + }); + + return new Response(stream, { + status: 200, + headers: { + "content-type": "text/event-stream", + "cache-control": "no-cache, no-transform", + connection: "keep-alive", + }, + }); +} + +async function proxyToTambo(request: Request, path: string[]) { + const apiKey = getTamboApiKey(); + if (!apiKey) { + return jsonError( + "Missing TAMBO_API_KEY. Set it in .env.local (server-side, not NEXT_PUBLIC).", + 500, + ); + } + + const targetUrl = new URL(`/${path.join("/")}`, getTamboBaseUrl()); + const requestUrl = new URL(request.url); + targetUrl.search = requestUrl.search; + + const headers = new Headers(request.headers); + headers.set("x-api-key", apiKey); + headers.delete("host"); + headers.delete("content-length"); + + const response = await fetch(targetUrl, { + method: request.method, + headers, + body: request.body, + redirect: "manual", + }); + + const responseHeaders = new Headers(response.headers); + responseHeaders.delete("content-encoding"); + + return new Response(response.body, { + status: response.status, + headers: responseHeaders, + }); +} + +async function handler( + request: Request, + { params }: { params: Promise<{ path: string[] }> }, +) { + const supabase = await createSupabaseServerClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + + if (!user) { + return jsonError("Unauthorized", 401); + } + + const { path } = await params; + + if (path.length === 1 && path[0] === "projects" && request.method === "GET") { + return NextResponse.json({ + id: PROJECT_ID, + isTokenRequired: false, + name: "IntentOS", + providerType: "llm", + userId: user.id, + }); + } + + if (path[0] === "threads") { + if (path.length >= 3 && path[1] === "project" && request.method === "GET") { + return handleThreadsList(supabase, user.id); + } + + if (path.length === 2 && path[1] === "advancestream" && request.method === "POST") { + return handleAdvanceStream(request, supabase, user.id, null); + } + + if (path.length >= 2) { + const threadId = path[1]; + + if (path.length === 2 && request.method === "GET") { + return handleThreadRetrieve(supabase, user.id, threadId); + } + + if (path.length === 2 && request.method === "PUT") { + return handleThreadUpdate(request, supabase, user.id, threadId); + } + + if (path.length === 3 && path[2] === "generate-name" && request.method === "POST") { + return handleThreadGenerateName(supabase, user.id, threadId); + } + + if (path.length === 3 && path[2] === "cancel" && request.method === "POST") { + return handleThreadCancel(); + } + + if (path.length === 3 && path[2] === "advancestream" && request.method === "POST") { + return handleAdvanceStream(request, supabase, user.id, threadId); + } + } + } + + return proxyToTambo(request, path); +} + +export async function GET( + request: Request, + ctx: { params: Promise<{ path: string[] }> }, +) { + return handler(request, ctx); +} + +export async function POST( + request: Request, + ctx: { params: Promise<{ path: string[] }> }, +) { + return handler(request, ctx); +} + +export async function PUT( + request: Request, + ctx: { params: Promise<{ path: string[] }> }, +) { + return handler(request, ctx); +} + +export async function PATCH( + request: Request, + ctx: { params: Promise<{ path: string[] }> }, +) { + return handler(request, ctx); +} + +export async function DELETE( + request: Request, + ctx: { params: Promise<{ path: string[] }> }, +) { + return handler(request, ctx); +} diff --git a/src/app/chat/chat-client.tsx b/src/app/chat/chat-client.tsx new file mode 100644 index 0000000..845d313 --- /dev/null +++ b/src/app/chat/chat-client.tsx @@ -0,0 +1,32 @@ +"use client"; + +import { MessageThreadFull } from "@/components/tambo/message-thread-full"; +import { useMcpServers } from "@/components/tambo/mcp-config-modal"; +import { GEMINI_INTENT_SYSTEM_PROMPT } from "@/lib/intent/gemini-intent-system-prompt"; +import { components, tools } from "@/lib/tambo"; +import { TamboProvider } from "@tambo-ai/react"; + +export function ChatClient({ userId }: { userId: string }) { + const mcpServers = useMcpServers(); + + return ( + +
+ +
+
+ ); +} diff --git a/src/app/chat/page.tsx b/src/app/chat/page.tsx index 613c176..203206d 100644 --- a/src/app/chat/page.tsx +++ b/src/app/chat/page.tsx @@ -1,27 +1,21 @@ -"use client"; +import { ChatClient } from "@/app/chat/chat-client"; +import { createSupabaseServerClient } from "@/lib/supabase/server"; +import { redirect } from "next/navigation"; -import { MessageThreadFull } from "@/components/tambo/message-thread-full"; -import { useMcpServers } from "@/components/tambo/mcp-config-modal"; -import { GEMINI_INTENT_SYSTEM_PROMPT } from "@/lib/intent/gemini-intent-system-prompt"; -import { components, tools } from "@/lib/tambo"; -import { TamboProvider } from "@tambo-ai/react"; +export default async function ChatPage() { + const supabase = await createSupabaseServerClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); -/** - * 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() { - // Load MCP server configurations - const mcpServers = useMcpServers(); - const apiKey = process.env.NEXT_PUBLIC_TAMBO_API_KEY; + if (!user) { + redirect("/login"); + } + + const hasTamboApiKey = + !!process.env.TAMBO_API_KEY || !!process.env.NEXT_PUBLIC_TAMBO_API_KEY; - if (!apiKey) { + if (!hasTamboApiKey) { return (
@@ -29,32 +23,13 @@ export default function Home() { Missing Tambo API key
- Set NEXT_PUBLIC_TAMBO_API_KEY in - your environment to use the chat. + Set TAMBO_API_KEY in your + environment to use the chat.
); } - return ( - -
- -
-
- ); + return ; } diff --git a/src/app/interactables/page.tsx b/src/app/interactables/page.tsx index 6bf50be..5b004d6 100644 --- a/src/app/interactables/page.tsx +++ b/src/app/interactables/page.tsx @@ -22,10 +22,10 @@ export default function InteractablesPage() { return (
{/* Chat Sidebar */} diff --git a/src/components/tambo/message-suggestions.tsx b/src/components/tambo/message-suggestions.tsx index c2c9584..99474ac 100644 --- a/src/components/tambo/message-suggestions.tsx +++ b/src/components/tambo/message-suggestions.tsx @@ -7,7 +7,7 @@ import type { Suggestion, TamboThread } from "@tambo-ai/react"; import { GenerationStage, useTambo, - useTamboSuggestions, + useTamboThreadInput, } from "@tambo-ai/react"; import { Loader2Icon } from "lucide-react"; import * as React from "react"; @@ -95,24 +95,30 @@ const MessageSuggestions = React.forwardRef< ref, ) => { const { thread } = useTambo(); - const { - suggestions: generatedSuggestions, - selectedSuggestionId, - accept, - generateResult: { isPending: isGenerating, error }, - } = useTamboSuggestions({ maxSuggestions }); - - // Combine initial and generated suggestions, but only use initial ones when thread is empty + const [selectedSuggestionId, setSelectedSuggestionId] = + React.useState(null); + const { setValue: setInputValue } = useTamboThreadInput(); + + const accept = React.useCallback( + async ({ suggestion }: { suggestion: Suggestion }) => { + setInputValue(suggestion.detailedSuggestion); + setSelectedSuggestionId(suggestion.id); + }, + [setInputValue], + ); + + const isGenerating = false; + const error: Error | null = null; + + // Only use pre-seeded suggestions when thread is empty. const suggestions = React.useMemo(() => { // Only use pre-seeded suggestions if thread is empty if (!thread?.messages?.length && initialSuggestions.length > 0) { return initialSuggestions.slice(0, maxSuggestions); } - // Otherwise use generated suggestions - return generatedSuggestions; + return []; }, [ thread?.messages?.length, - generatedSuggestions, initialSuggestions, maxSuggestions, ]); diff --git a/src/middleware.ts b/src/middleware.ts index d2609c8..6bdbc4a 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -33,5 +33,5 @@ export async function middleware(request: NextRequest) { } export const config = { - matcher: ["/login", "/signup"], + matcher: ["/login", "/signup", "/chat/:path*"], }; diff --git a/supabase/migrations/20260207_per_user_threads.sql b/supabase/migrations/20260207_per_user_threads.sql new file mode 100644 index 0000000..fa83927 --- /dev/null +++ b/supabase/migrations/20260207_per_user_threads.sql @@ -0,0 +1,116 @@ +-- Per-user thread isolation (Supabase) + +create extension if not exists "pgcrypto"; + +create table if not exists public.threads ( + id uuid primary key default gen_random_uuid(), + user_id uuid not null references auth.users(id) on delete cascade, + name text, + metadata jsonb, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create table if not exists public.messages ( + id uuid primary key default gen_random_uuid(), + thread_id uuid not null references public.threads(id) on delete cascade, + role text not null, + content jsonb not null, + component_state jsonb not null default '{}'::jsonb, + additional_context jsonb, + component jsonb, + tool_call_request jsonb, + tool_calls jsonb, + tool_call_id text, + parent_message_id uuid, + reasoning jsonb, + reasoning_duration_ms integer, + error text, + is_cancelled boolean not null default false, + metadata jsonb, + created_at timestamptz not null default now() +); + +create index if not exists messages_thread_id_created_at_idx + on public.messages (thread_id, created_at); + +create or replace function public.set_updated_at() +returns trigger +language plpgsql +as $$ +begin + new.updated_at = now(); + return new; +end; +$$; + +drop trigger if exists threads_set_updated_at on public.threads; +create trigger threads_set_updated_at +before update on public.threads +for each row +execute function public.set_updated_at(); + +alter table public.threads enable row level security; +alter table public.messages enable row level security; + +drop policy if exists "Users can read their own threads" on public.threads; +create policy "Users can read their own threads" +on public.threads for select +using (user_id = auth.uid()); + +drop policy if exists "Users can create their own threads" on public.threads; +create policy "Users can create their own threads" +on public.threads for insert +with check (user_id = auth.uid()); + +drop policy if exists "Users can update their own threads" on public.threads; +create policy "Users can update their own threads" +on public.threads for update +using (user_id = auth.uid()) +with check (user_id = auth.uid()); + +drop policy if exists "Users can read messages from their threads" on public.messages; +create policy "Users can read messages from their threads" +on public.messages for select +using ( + exists ( + select 1 from public.threads + where public.threads.id = public.messages.thread_id + and public.threads.user_id = auth.uid() + ) +); + +drop policy if exists "Users can insert messages into their threads" on public.messages; +create policy "Users can insert messages into their threads" +on public.messages for insert +with check ( + exists ( + select 1 from public.threads + where public.threads.id = public.messages.thread_id + and public.threads.user_id = auth.uid() + ) +); + +drop policy if exists "Users can update messages in their threads" on public.messages; +create policy "Users can update messages in their threads" +on public.messages for update +using ( + exists ( + select 1 from public.threads + where public.threads.id = public.messages.thread_id + and public.threads.user_id = auth.uid() + ) +) +with check ( + exists ( + select 1 from public.threads + where public.threads.id = public.messages.thread_id + and public.threads.user_id = auth.uid() + ) +); + +-- One-time legacy cleanup +delete from public.threads where user_id is null; + +delete from public.messages +where thread_id not in (select id from public.threads); From d01690403fb90e14868dbe911b975274a80b085e Mon Sep 17 00:00:00 2001 From: CharlieHelps Date: Sat, 7 Feb 2026 17:45:28 +0000 Subject: [PATCH 02/11] fix(api): validate and persist tambo streams --- src/app/api/tambo/[...path]/route.ts | 54 ++++++++++++++++++++++++---- src/app/chat/page.tsx | 3 +- 2 files changed, 49 insertions(+), 8 deletions(-) diff --git a/src/app/api/tambo/[...path]/route.ts b/src/app/api/tambo/[...path]/route.ts index a0fbd08..ed470f6 100644 --- a/src/app/api/tambo/[...path]/route.ts +++ b/src/app/api/tambo/[...path]/route.ts @@ -107,11 +107,22 @@ async function tamboFetch(pathname: string, init: RequestInit) { } function getFirstUserMessageText(messages: Array<{ role: string; content: any }>) { + type TextPart = { type: "text"; text: string }; + const isTextPart = (value: unknown): value is TextPart => { + return ( + typeof value === "object" && + value !== null && + (value as any).type === "text" && + typeof (value as any).text === "string" + ); + }; + for (const msg of messages) { if (msg.role !== "user") continue; const text = Array.isArray(msg.content) ? msg.content - .map((part: any) => (part?.type === "text" ? part.text : "")) + .filter(isTextPart) + .map((part) => part.text) .filter(Boolean) .join(" ") .trim() @@ -207,7 +218,13 @@ async function handleThreadUpdate( const update: Record = {}; if (typeof body.name === "string") update.name = body.name; - if (body.metadata && typeof body.metadata === "object") update.metadata = body.metadata; + if (body.metadata && typeof body.metadata === "object") { + update.metadata = body.metadata; + } + + if (Object.keys(update).length === 0) { + return jsonError("No valid fields to update", 400); + } const { error } = await supabase .from("threads") @@ -288,6 +305,14 @@ async function handleAdvanceStream( toolCallRequest?: Record; }; + const allowedRoles = new Set(["user", "assistant", "system", "tool"]); + if (!allowedRoles.has(messageToAppend.role)) { + return jsonError("Invalid message role", 400); + } + if (messageToAppend.content == null) { + return jsonError("Message content is required", 400); + } + let persistentThreadId = threadId; if (!persistentThreadId) { @@ -409,13 +434,20 @@ async function handleAdvanceStream( created_at: m.createdAt ?? new Date().toISOString(), })); - await supabase.from("messages").upsert(rows); + const { error } = await supabase.from("messages").upsert(rows); + if (error) { + throw new Error(error.message); + } } - await supabase + const { error: threadUpdateError } = await supabase .from("threads") .update({ updated_at: new Date().toISOString() }) .eq("id", persistentThreadId); + + if (threadUpdateError) { + throw new Error(threadUpdateError.message); + } }; let buffer = ""; @@ -428,7 +460,12 @@ async function handleAdvanceStream( try { await persistMessages(); } catch (error) { - console.error("Failed to persist streamed messages:", error); + console.error("Failed to persist streamed messages", { + error, + userId, + threadId: persistentThreadId, + messageCount: finalMessages.size, + }); } controller.close(); return; @@ -489,7 +526,12 @@ async function handleAdvanceStream( try { await persistMessages(); } catch (error) { - console.error("Failed to persist streamed messages:", error); + console.error("Failed to persist streamed messages", { + error, + userId, + threadId: persistentThreadId, + messageCount: finalMessages.size, + }); } reader.cancel().catch(() => undefined); }, diff --git a/src/app/chat/page.tsx b/src/app/chat/page.tsx index 203206d..8a4486f 100644 --- a/src/app/chat/page.tsx +++ b/src/app/chat/page.tsx @@ -12,8 +12,7 @@ export default async function ChatPage() { redirect("/login"); } - const hasTamboApiKey = - !!process.env.TAMBO_API_KEY || !!process.env.NEXT_PUBLIC_TAMBO_API_KEY; + const hasTamboApiKey = !!process.env.TAMBO_API_KEY; if (!hasTamboApiKey) { return ( From a290ad65885238f1c0695b9d3c9b917d2f9f95cf Mon Sep 17 00:00:00 2001 From: CharlieHelps Date: Sat, 7 Feb 2026 17:49:56 +0000 Subject: [PATCH 03/11] chore(api): harden tambo supabase adapter --- src/app/api/tambo/[...path]/route.ts | 38 +++++++++++++++---- .../migrations/20260207_per_user_threads.sql | 5 +++ 2 files changed, 36 insertions(+), 7 deletions(-) diff --git a/src/app/api/tambo/[...path]/route.ts b/src/app/api/tambo/[...path]/route.ts index ed470f6..b7275f3 100644 --- a/src/app/api/tambo/[...path]/route.ts +++ b/src/app/api/tambo/[...path]/route.ts @@ -87,7 +87,7 @@ function messageFromRow(row: MessageRow) { }; } -async function tamboFetch(pathname: string, init: RequestInit) { +async function tamboSseFetch(pathname: string, init: RequestInit) { const apiKey = getTamboApiKey(); if (!apiKey) { throw new Error( @@ -250,6 +250,15 @@ async function handleThreadGenerateName( userId: string, threadId: string, ) { + const { data: thread, error: threadError } = await supabase + .from("threads") + .select("id") + .eq("id", threadId) + .maybeSingle(); + + if (threadError) return jsonError(threadError.message, 500); + if (!thread) return jsonError("Not found", 404); + const { data: messages, error } = await supabase .from("messages") .select("role, content") @@ -270,16 +279,18 @@ async function handleThreadGenerateName( if (updateError) return jsonError(updateError.message, 500); - const { data: thread, error: readError } = await supabase + const { data: updatedThread, error: readError } = await supabase .from("threads") .select("id, created_at, updated_at, name, metadata") .eq("id", threadId) .maybeSingle(); if (readError) return jsonError(readError.message, 500); - if (!thread) return jsonError("Not found", 404); + if (!updatedThread) return jsonError("Not found", 404); - return NextResponse.json(threadFromRow(thread as unknown as ThreadRow, userId)); + return NextResponse.json( + threadFromRow(updatedThread as unknown as ThreadRow, userId), + ); } async function handleThreadCancel() { @@ -390,7 +401,7 @@ async function handleAdvanceStream( clientTools: [], }; - const tamboResponse = await tamboFetch("/threads/advancestream", { + const tamboResponse = await tamboSseFetch("/threads/advancestream", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(computeBody), @@ -453,12 +464,17 @@ async function handleAdvanceStream( let buffer = ""; const reader = (tamboResponse.body as ReadableStream).getReader(); + let pendingDone = false; + const stream = new ReadableStream({ async pull(controller) { const { done, value } = await reader.read(); if (done) { try { await persistMessages(); + if (pendingDone) { + controller.enqueue(encoder.encode("data: DONE\n")); + } } catch (error) { console.error("Failed to persist streamed messages", { error, @@ -466,6 +482,12 @@ async function handleAdvanceStream( threadId: persistentThreadId, messageCount: finalMessages.size, }); + + controller.enqueue( + encoder.encode( + "error: Failed to persist conversation state, some messages may be missing.\n", + ), + ); } controller.close(); return; @@ -482,7 +504,7 @@ async function handleAdvanceStream( if (!rawLine) continue; if (rawLine === "data: DONE") { - controller.enqueue(encoder.encode("data: DONE\n")); + pendingDone = true; continue; } if (rawLine.startsWith("error: ")) { @@ -565,10 +587,12 @@ async function proxyToTambo(request: Request, path: string[]) { headers.delete("host"); headers.delete("content-length"); + const body = request.body ? request.clone().body : undefined; + const response = await fetch(targetUrl, { method: request.method, headers, - body: request.body, + body, redirect: "manual", }); diff --git a/supabase/migrations/20260207_per_user_threads.sql b/supabase/migrations/20260207_per_user_threads.sql index fa83927..ad41174 100644 --- a/supabase/migrations/20260207_per_user_threads.sql +++ b/supabase/migrations/20260207_per_user_threads.sql @@ -31,6 +31,11 @@ create table if not exists public.messages ( created_at timestamptz not null default now() ); +alter table public.messages drop constraint if exists messages_role_check; +alter table public.messages + add constraint messages_role_check + check (role in ('user', 'assistant', 'system', 'tool')); + create index if not exists messages_thread_id_created_at_idx on public.messages (thread_id, created_at); From 9b5df7fea7f375ff1a4622242e2b89dfc15e6531 Mon Sep 17 00:00:00 2001 From: CharlieHelps Date: Sat, 7 Feb 2026 17:56:03 +0000 Subject: [PATCH 04/11] fix(tambo): harden API proxy and advance stream handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Simplify Tambo base URL resolution to use server-side `TAMBO_URL` only - Centralize missing `TAMBO_API_KEY` error message and reuse in SSE/proxy paths - Remove incorrect `reasoning` cast from message mapping - Strongly type `AdvanceStreamRequestBody` and validate parsed JSON - Preserve and forward `availableComponents`, `forceToolChoice`, and `toolCallCounts` to Tambo - Stop overriding `created_at` when inserting/upserting messages to keep DB timestamps authoritative - Normalize SSE line endings (` ` → ` `) before parsing event lines - Disable auto thread name generation in `ChatClient` to avoid conflicts with server-side naming - Extend middleware matcher to cover `/api/tambo/:path*` so Supabase auth/session middleware runs for Tambo API calls --- src/app/api/tambo/[...path]/route.ts | 59 ++++++++++++++++++---------- src/app/chat/chat-client.tsx | 1 + src/middleware.ts | 2 +- 3 files changed, 40 insertions(+), 22 deletions(-) diff --git a/src/app/api/tambo/[...path]/route.ts b/src/app/api/tambo/[...path]/route.ts index b7275f3..5b7b956 100644 --- a/src/app/api/tambo/[...path]/route.ts +++ b/src/app/api/tambo/[...path]/route.ts @@ -5,6 +5,8 @@ export const runtime = "nodejs"; export const dynamic = "force-dynamic"; const PROJECT_ID = "supabase"; +const MISSING_TAMBO_API_KEY_ERROR = + "Missing TAMBO_API_KEY. Set it in .env.local (server-side, not NEXT_PUBLIC)."; type ThreadRow = { id: string; @@ -35,12 +37,7 @@ type MessageRow = { }; function getTamboBaseUrl(): string { - return ( - process.env.TAMBO_AI_BASE_URL ?? - process.env.TAMBO_URL ?? - process.env.NEXT_PUBLIC_TAMBO_URL ?? - "https://api.tambo.co" - ); + return process.env.TAMBO_URL ?? "https://api.tambo.co"; } function getTamboApiKey(): string | null { @@ -79,7 +76,7 @@ function messageFromRow(row: MessageRow) { tool_calls: row.tool_calls ?? undefined, tool_call_id: row.tool_call_id ?? undefined, parentMessageId: row.parent_message_id ?? undefined, - reasoning: (row.reasoning ?? undefined) as never, + reasoning: row.reasoning ?? undefined, reasoningDurationMS: row.reasoning_duration_ms ?? undefined, error: row.error ?? undefined, isCancelled: row.is_cancelled ?? undefined, @@ -90,9 +87,7 @@ function messageFromRow(row: MessageRow) { async function tamboSseFetch(pathname: string, init: RequestInit) { const apiKey = getTamboApiKey(); if (!apiKey) { - throw new Error( - "Missing TAMBO_API_KEY. Set it in .env.local (server-side, not NEXT_PUBLIC).", - ); + throw new Error(MISSING_TAMBO_API_KEY_ERROR); } const url = new URL(pathname, getTamboBaseUrl()); @@ -303,7 +298,23 @@ async function handleAdvanceStream( userId: string, threadId: string | null, ) { - const body = (await request.json().catch(() => null)) as any; + type AdvanceStreamRequestBody = { + messageToAppend?: { + role: "user" | "assistant" | "system" | "tool"; + content: unknown; + additionalContext?: Record; + component?: Record; + toolCallRequest?: Record; + }; + initialMessages?: unknown; + availableComponents?: unknown; + forceToolChoice?: unknown; + toolCallCounts?: unknown; + }; + + const body = (await request.json().catch(() => null)) as + | AdvanceStreamRequestBody + | null; if (!body || !body.messageToAppend) { return jsonError("Invalid JSON body", 400); } @@ -336,7 +347,9 @@ async function handleAdvanceStream( if (threadError) return jsonError(threadError.message, 500); persistentThreadId = (newThread as any).id as string; - const initial = Array.isArray(body.initialMessages) ? body.initialMessages : []; + const initial = Array.isArray(body.initialMessages) + ? body.initialMessages + : []; if (initial.length > 0) { const initialRows = initial.map((m: any) => ({ id: crypto.randomUUID(), @@ -347,7 +360,6 @@ async function handleAdvanceStream( component_state: m.componentState ?? {}, component: m.component ?? null, tool_call_request: m.toolCallRequest ?? null, - created_at: m.createdAt ?? new Date().toISOString(), })); const { error: insertError } = await supabase.from("messages").insert(initialRows); @@ -393,14 +405,23 @@ async function handleAdvanceStream( toolCallRequest: m.tool_call_request ?? undefined, })); - const computeBody = { - ...body, + const computeBody: Record = { contextKey: userId, initialMessages, messageToAppend, clientTools: [], }; + if (body.availableComponents != null) { + computeBody.availableComponents = body.availableComponents; + } + if (typeof body.forceToolChoice === "string") { + computeBody.forceToolChoice = body.forceToolChoice; + } + if (body.toolCallCounts && typeof body.toolCallCounts === "object") { + computeBody.toolCallCounts = body.toolCallCounts; + } + const tamboResponse = await tamboSseFetch("/threads/advancestream", { method: "POST", headers: { "content-type": "application/json" }, @@ -442,7 +463,6 @@ async function handleAdvanceStream( error: m.error ?? null, is_cancelled: m.isCancelled ?? false, metadata: m.metadata ?? null, - created_at: m.createdAt ?? new Date().toISOString(), })); const { error } = await supabase.from("messages").upsert(rows); @@ -493,7 +513,7 @@ async function handleAdvanceStream( return; } - buffer += decoder.decode(value, { stream: true }); + buffer += decoder.decode(value, { stream: true }).replaceAll("\r\n", "\n"); while (true) { const nl = buffer.indexOf("\n"); @@ -572,10 +592,7 @@ async function handleAdvanceStream( async function proxyToTambo(request: Request, path: string[]) { const apiKey = getTamboApiKey(); if (!apiKey) { - return jsonError( - "Missing TAMBO_API_KEY. Set it in .env.local (server-side, not NEXT_PUBLIC).", - 500, - ); + return jsonError(MISSING_TAMBO_API_KEY_ERROR, 500); } const targetUrl = new URL(`/${path.join("/")}`, getTamboBaseUrl()); diff --git a/src/app/chat/chat-client.tsx b/src/app/chat/chat-client.tsx index 845d313..40deeeb 100644 --- a/src/app/chat/chat-client.tsx +++ b/src/app/chat/chat-client.tsx @@ -14,6 +14,7 @@ export function ChatClient({ userId }: { userId: string }) { apiKey="unused" tamboUrl="/api/tambo" contextKey={userId} + autoGenerateThreadName={false} components={components} tools={tools} mcpServers={mcpServers} diff --git a/src/middleware.ts b/src/middleware.ts index 6bdbc4a..e3e5325 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -33,5 +33,5 @@ export async function middleware(request: NextRequest) { } export const config = { - matcher: ["/login", "/signup", "/chat/:path*"], + matcher: ["/login", "/signup", "/chat/:path*", "/api/tambo/:path*"], }; From 1ad4d6ba18be851766a4ba521870a046adeb1a83 Mon Sep 17 00:00:00 2001 From: CharlieHelps Date: Sat, 7 Feb 2026 18:57:40 +0000 Subject: [PATCH 05/11] fix(chat): enforce per-user thread access --- src/app/api/tambo/[...path]/route.ts | 290 +++++++++++++++++- ...60207_per_user_threads_delete_policies.sql | 20 ++ 2 files changed, 305 insertions(+), 5 deletions(-) create mode 100644 supabase/migrations/20260207_per_user_threads_delete_policies.sql diff --git a/src/app/api/tambo/[...path]/route.ts b/src/app/api/tambo/[...path]/route.ts index 5b7b956..685f6df 100644 --- a/src/app/api/tambo/[...path]/route.ts +++ b/src/app/api/tambo/[...path]/route.ts @@ -134,6 +134,7 @@ async function handleThreadsList( const { data, error } = await supabase .from("threads") .select("id, created_at, updated_at, name, metadata") + .eq("user_id", userId) .order("updated_at", { ascending: false }); if (error) { @@ -160,6 +161,7 @@ async function handleThreadRetrieve( .from("threads") .select("id, created_at, updated_at, name, metadata") .eq("id", threadId) + .eq("user_id", userId) .maybeSingle(); if (threadError) return jsonError(threadError.message, 500); @@ -224,7 +226,8 @@ async function handleThreadUpdate( const { error } = await supabase .from("threads") .update(update) - .eq("id", threadId); + .eq("id", threadId) + .eq("user_id", userId); if (error) return jsonError(error.message, 500); @@ -232,6 +235,7 @@ async function handleThreadUpdate( .from("threads") .select("id, created_at, updated_at, name, metadata") .eq("id", threadId) + .eq("user_id", userId) .maybeSingle(); if (readError) return jsonError(readError.message, 500); @@ -249,6 +253,7 @@ async function handleThreadGenerateName( .from("threads") .select("id") .eq("id", threadId) + .eq("user_id", userId) .maybeSingle(); if (threadError) return jsonError(threadError.message, 500); @@ -270,7 +275,8 @@ async function handleThreadGenerateName( const { error: updateError } = await supabase .from("threads") .update({ name }) - .eq("id", threadId); + .eq("id", threadId) + .eq("user_id", userId); if (updateError) return jsonError(updateError.message, 500); @@ -278,6 +284,7 @@ async function handleThreadGenerateName( .from("threads") .select("id, created_at, updated_at, name, metadata") .eq("id", threadId) + .eq("user_id", userId) .maybeSingle(); if (readError) return jsonError(readError.message, 500); @@ -288,10 +295,240 @@ async function handleThreadGenerateName( ); } -async function handleThreadCancel() { +async function handleThreadCancel( + supabase: Awaited>, + userId: string, + threadId: string, +) { + const { data: thread, error } = await supabase + .from("threads") + .select("id") + .eq("id", threadId) + .eq("user_id", userId) + .maybeSingle(); + + if (error) return jsonError(error.message, 500); + if (!thread) return jsonError("Not found", 404); + return NextResponse.json(true); } +async function handleThreadDelete( + supabase: Awaited>, + userId: string, + threadId: string, +) { + const { error } = await supabase + .from("threads") + .delete() + .eq("id", threadId) + .eq("user_id", userId); + + if (error) return jsonError(error.message, 500); + + return new Response(null, { status: 204 }); +} + +async function handleThreadMessagesList( + supabase: Awaited>, + userId: string, + threadId: string, +) { + const { data: thread, error: threadError } = await supabase + .from("threads") + .select("id") + .eq("id", threadId) + .eq("user_id", userId) + .maybeSingle(); + + if (threadError) return jsonError(threadError.message, 500); + if (!thread) return jsonError("Not found", 404); + + const { data: messages, error } = await supabase + .from("messages") + .select( + [ + "id", + "thread_id", + "role", + "content", + "component_state", + "additional_context", + "component", + "tool_call_request", + "tool_calls", + "tool_call_id", + "parent_message_id", + "reasoning", + "reasoning_duration_ms", + "error", + "is_cancelled", + "metadata", + "created_at", + ].join(","), + ) + .eq("thread_id", threadId) + .order("created_at", { ascending: true }); + + if (error) return jsonError(error.message, 500); + + return NextResponse.json( + (messages as unknown as MessageRow[]).map(messageFromRow), + ); +} + +async function handleThreadMessagesCreate( + request: Request, + supabase: Awaited>, + userId: string, + threadId: string, +) { + const { data: thread, error: threadError } = await supabase + .from("threads") + .select("id") + .eq("id", threadId) + .eq("user_id", userId) + .maybeSingle(); + + if (threadError) return jsonError(threadError.message, 500); + if (!thread) return jsonError("Not found", 404); + + const body = (await request.json().catch(() => null)) as + | { + role?: "user" | "assistant" | "system" | "tool"; + content?: unknown; + additionalContext?: Record; + component?: Record; + componentState?: Record; + toolCallRequest?: Record; + tool_calls?: unknown[]; + tool_call_id?: string; + parentMessageId?: string; + reasoning?: unknown; + reasoningDurationMS?: number; + error?: string; + isCancelled?: boolean; + metadata?: Record; + } + | null; + + if (!body) return jsonError("Invalid JSON body", 400); + if (!body.role) return jsonError("Message role is required", 400); + if (body.content == null) return jsonError("Message content is required", 400); + + const allowedRoles = new Set(["user", "assistant", "system", "tool"]); + if (!allowedRoles.has(body.role)) { + return jsonError("Invalid message role", 400); + } + + const id = crypto.randomUUID(); + const { error } = await supabase.from("messages").insert({ + id, + thread_id: threadId, + role: body.role, + content: body.content, + additional_context: body.additionalContext ?? null, + component_state: body.componentState ?? {}, + component: body.component ?? null, + tool_call_request: body.toolCallRequest ?? null, + tool_calls: body.tool_calls ?? null, + tool_call_id: body.tool_call_id ?? null, + parent_message_id: body.parentMessageId ?? null, + reasoning: body.reasoning ?? null, + reasoning_duration_ms: body.reasoningDurationMS ?? null, + error: body.error ?? null, + is_cancelled: body.isCancelled ?? false, + metadata: body.metadata ?? null, + }); + + if (error) return jsonError(error.message, 500); + + const { error: threadUpdateError } = await supabase + .from("threads") + .update({ updated_at: new Date().toISOString() }) + .eq("id", threadId) + .eq("user_id", userId); + + if (threadUpdateError) return jsonError(threadUpdateError.message, 500); + + return NextResponse.json({ id }); +} + +async function handleThreadMessageUpdateComponentState( + request: Request, + supabase: Awaited>, + userId: string, + threadId: string, + messageId: string, +) { + const { data: thread, error: threadError } = await supabase + .from("threads") + .select("id") + .eq("id", threadId) + .eq("user_id", userId) + .maybeSingle(); + + if (threadError) return jsonError(threadError.message, 500); + if (!thread) return jsonError("Not found", 404); + + const body = (await request.json().catch(() => null)) as + | { state?: Record } + | null; + if (!body || !body.state || typeof body.state !== "object") { + return jsonError("Invalid JSON body", 400); + } + + const { data: message, error: readError } = await supabase + .from("messages") + .select("component_state") + .eq("id", messageId) + .eq("thread_id", threadId) + .maybeSingle(); + + if (readError) return jsonError(readError.message, 500); + if (!message) return jsonError("Not found", 404); + + const current = + message.component_state && typeof message.component_state === "object" + ? (message.component_state as Record) + : {}; + + const next = { ...current, ...body.state }; + + const { data: updated, error } = await supabase + .from("messages") + .update({ component_state: next }) + .eq("id", messageId) + .eq("thread_id", threadId) + .select( + [ + "id", + "thread_id", + "role", + "content", + "component_state", + "additional_context", + "component", + "tool_call_request", + "tool_calls", + "tool_call_id", + "parent_message_id", + "reasoning", + "reasoning_duration_ms", + "error", + "is_cancelled", + "metadata", + "created_at", + ].join(","), + ) + .maybeSingle(); + + if (error) return jsonError(error.message, 500); + if (!updated) return jsonError("Not found", 404); + + return NextResponse.json(messageFromRow(updated as unknown as MessageRow)); +} + async function handleAdvanceStream( request: Request, supabase: Awaited>, @@ -337,6 +574,18 @@ async function handleAdvanceStream( let persistentThreadId = threadId; + if (persistentThreadId) { + const { data: thread, error } = await supabase + .from("threads") + .select("id") + .eq("id", persistentThreadId) + .eq("user_id", userId) + .maybeSingle(); + + if (error) return jsonError(error.message, 500); + if (!thread) return jsonError("Not found", 404); + } + if (!persistentThreadId) { const { data: newThread, error: threadError } = await supabase .from("threads") @@ -474,7 +723,8 @@ async function handleAdvanceStream( const { error: threadUpdateError } = await supabase .from("threads") .update({ updated_at: new Date().toISOString() }) - .eq("id", persistentThreadId); + .eq("id", persistentThreadId) + .eq("user_id", userId); if (threadUpdateError) { throw new Error(threadUpdateError.message); @@ -659,6 +909,30 @@ async function handler( if (path.length >= 2) { const threadId = path[1]; + if (path.length === 3 && path[2] === "messages" && request.method === "GET") { + return handleThreadMessagesList(supabase, user.id, threadId); + } + + if (path.length === 3 && path[2] === "messages" && request.method === "POST") { + return handleThreadMessagesCreate(request, supabase, user.id, threadId); + } + + if ( + path.length === 5 && + path[2] === "messages" && + path[4] === "component-state" && + request.method === "PUT" + ) { + const messageId = path[3]; + return handleThreadMessageUpdateComponentState( + request, + supabase, + user.id, + threadId, + messageId, + ); + } + if (path.length === 2 && request.method === "GET") { return handleThreadRetrieve(supabase, user.id, threadId); } @@ -672,13 +946,19 @@ async function handler( } if (path.length === 3 && path[2] === "cancel" && request.method === "POST") { - return handleThreadCancel(); + return handleThreadCancel(supabase, user.id, threadId); } if (path.length === 3 && path[2] === "advancestream" && request.method === "POST") { return handleAdvanceStream(request, supabase, user.id, threadId); } + + if (path.length === 2 && request.method === "DELETE") { + return handleThreadDelete(supabase, user.id, threadId); + } } + + return jsonError("Not found", 404); } return proxyToTambo(request, path); diff --git a/supabase/migrations/20260207_per_user_threads_delete_policies.sql b/supabase/migrations/20260207_per_user_threads_delete_policies.sql new file mode 100644 index 0000000..7828871 --- /dev/null +++ b/supabase/migrations/20260207_per_user_threads_delete_policies.sql @@ -0,0 +1,20 @@ +-- Add delete policies for per-user thread isolation (Supabase) + +alter table public.threads enable row level security; +alter table public.messages enable row level security; + +drop policy if exists "Users can delete their own threads" on public.threads; +create policy "Users can delete their own threads" +on public.threads for delete +using (user_id = auth.uid()); + +drop policy if exists "Users can delete messages in their threads" on public.messages; +create policy "Users can delete messages in their threads" +on public.messages for delete +using ( + exists ( + select 1 from public.threads + where public.threads.id = public.messages.thread_id + and public.threads.user_id = auth.uid() + ) +); From de2d019fbcfa89e952c47ccbc78858069d9dc36b Mon Sep 17 00:00:00 2001 From: CharlieHelps Date: Sat, 7 Feb 2026 19:00:06 +0000 Subject: [PATCH 06/11] refactor(api): reuse message select columns --- src/app/api/tambo/[...path]/route.ts | 87 ++++++++-------------------- 1 file changed, 24 insertions(+), 63 deletions(-) diff --git a/src/app/api/tambo/[...path]/route.ts b/src/app/api/tambo/[...path]/route.ts index 685f6df..035d4dc 100644 --- a/src/app/api/tambo/[...path]/route.ts +++ b/src/app/api/tambo/[...path]/route.ts @@ -36,6 +36,26 @@ type MessageRow = { created_at: string; }; +const MESSAGE_SELECT_COLUMNS = [ + "id", + "thread_id", + "role", + "content", + "component_state", + "additional_context", + "component", + "tool_call_request", + "tool_calls", + "tool_call_id", + "parent_message_id", + "reasoning", + "reasoning_duration_ms", + "error", + "is_cancelled", + "metadata", + "created_at", +].join(","); + function getTamboBaseUrl(): string { return process.env.TAMBO_URL ?? "https://api.tambo.co"; } @@ -169,27 +189,7 @@ async function handleThreadRetrieve( const { data: messages, error: msgError } = await supabase .from("messages") - .select( - [ - "id", - "thread_id", - "role", - "content", - "component_state", - "additional_context", - "component", - "tool_call_request", - "tool_calls", - "tool_call_id", - "parent_message_id", - "reasoning", - "reasoning_duration_ms", - "error", - "is_cancelled", - "metadata", - "created_at", - ].join(","), - ) + .select(MESSAGE_SELECT_COLUMNS) .eq("thread_id", threadId) .order("created_at", { ascending: true }); @@ -346,27 +346,7 @@ async function handleThreadMessagesList( const { data: messages, error } = await supabase .from("messages") - .select( - [ - "id", - "thread_id", - "role", - "content", - "component_state", - "additional_context", - "component", - "tool_call_request", - "tool_calls", - "tool_call_id", - "parent_message_id", - "reasoning", - "reasoning_duration_ms", - "error", - "is_cancelled", - "metadata", - "created_at", - ].join(","), - ) + .select(MESSAGE_SELECT_COLUMNS) .eq("thread_id", threadId) .order("created_at", { ascending: true }); @@ -493,6 +473,7 @@ async function handleThreadMessageUpdateComponentState( ? (message.component_state as Record) : {}; + // Shallow merge (top-level keys) to match `useTamboComponentState` updates. const next = { ...current, ...body.state }; const { data: updated, error } = await supabase @@ -500,27 +481,7 @@ async function handleThreadMessageUpdateComponentState( .update({ component_state: next }) .eq("id", messageId) .eq("thread_id", threadId) - .select( - [ - "id", - "thread_id", - "role", - "content", - "component_state", - "additional_context", - "component", - "tool_call_request", - "tool_calls", - "tool_call_id", - "parent_message_id", - "reasoning", - "reasoning_duration_ms", - "error", - "is_cancelled", - "metadata", - "created_at", - ].join(","), - ) + .select(MESSAGE_SELECT_COLUMNS) .maybeSingle(); if (error) return jsonError(error.message, 500); From 56735d9d8f872e0d0261cbd48637f4b48c86999e Mon Sep 17 00:00:00 2001 From: CharlieHelps Date: Sat, 7 Feb 2026 19:03:21 +0000 Subject: [PATCH 07/11] chore(rls): ensure full policies --- src/app/api/tambo/[...path]/route.ts | 27 +++++---- ...60207_per_user_threads_delete_policies.sql | 56 +++++++++++++++++++ 2 files changed, 73 insertions(+), 10 deletions(-) diff --git a/src/app/api/tambo/[...path]/route.ts b/src/app/api/tambo/[...path]/route.ts index 035d4dc..c9ef45d 100644 --- a/src/app/api/tambo/[...path]/route.ts +++ b/src/app/api/tambo/[...path]/route.ts @@ -104,6 +104,17 @@ function messageFromRow(row: MessageRow) { }; } +async function selectMessagesForThread( + supabase: Awaited>, + threadId: string, +) { + return supabase + .from("messages") + .select(MESSAGE_SELECT_COLUMNS) + .eq("thread_id", threadId) + .order("created_at", { ascending: true }); +} + async function tamboSseFetch(pathname: string, init: RequestInit) { const apiKey = getTamboApiKey(); if (!apiKey) { @@ -187,11 +198,8 @@ async function handleThreadRetrieve( if (threadError) return jsonError(threadError.message, 500); if (!thread) return jsonError("Not found", 404); - const { data: messages, error: msgError } = await supabase - .from("messages") - .select(MESSAGE_SELECT_COLUMNS) - .eq("thread_id", threadId) - .order("created_at", { ascending: true }); + const { data: messages, error: msgError } = + await selectMessagesForThread(supabase, threadId); if (msgError) return jsonError(msgError.message, 500); @@ -344,11 +352,10 @@ async function handleThreadMessagesList( if (threadError) return jsonError(threadError.message, 500); if (!thread) return jsonError("Not found", 404); - const { data: messages, error } = await supabase - .from("messages") - .select(MESSAGE_SELECT_COLUMNS) - .eq("thread_id", threadId) - .order("created_at", { ascending: true }); + const { data: messages, error } = await selectMessagesForThread( + supabase, + threadId, + ); if (error) return jsonError(error.message, 500); diff --git a/supabase/migrations/20260207_per_user_threads_delete_policies.sql b/supabase/migrations/20260207_per_user_threads_delete_policies.sql index 7828871..82ffa1e 100644 --- a/supabase/migrations/20260207_per_user_threads_delete_policies.sql +++ b/supabase/migrations/20260207_per_user_threads_delete_policies.sql @@ -3,6 +3,22 @@ alter table public.threads enable row level security; alter table public.messages enable row level security; +drop policy if exists "Users can read their own threads" on public.threads; +create policy "Users can read their own threads" +on public.threads for select +using (user_id = auth.uid()); + +drop policy if exists "Users can create their own threads" on public.threads; +create policy "Users can create their own threads" +on public.threads for insert +with check (user_id = auth.uid()); + +drop policy if exists "Users can update their own threads" on public.threads; +create policy "Users can update their own threads" +on public.threads for update +using (user_id = auth.uid()) +with check (user_id = auth.uid()); + drop policy if exists "Users can delete their own threads" on public.threads; create policy "Users can delete their own threads" on public.threads for delete @@ -18,3 +34,43 @@ using ( and public.threads.user_id = auth.uid() ) ); + +drop policy if exists "Users can read messages from their threads" on public.messages; +create policy "Users can read messages from their threads" +on public.messages for select +using ( + exists ( + select 1 from public.threads + where public.threads.id = public.messages.thread_id + and public.threads.user_id = auth.uid() + ) +); + +drop policy if exists "Users can insert messages into their threads" on public.messages; +create policy "Users can insert messages into their threads" +on public.messages for insert +with check ( + exists ( + select 1 from public.threads + where public.threads.id = public.messages.thread_id + and public.threads.user_id = auth.uid() + ) +); + +drop policy if exists "Users can update messages in their threads" on public.messages; +create policy "Users can update messages in their threads" +on public.messages for update +using ( + exists ( + select 1 from public.threads + where public.threads.id = public.messages.thread_id + and public.threads.user_id = auth.uid() + ) +) +with check ( + exists ( + select 1 from public.threads + where public.threads.id = public.messages.thread_id + and public.threads.user_id = auth.uid() + ) +); From 4ef594ad46f840b2aedd181d57ea41cd5db6efcd Mon Sep 17 00:00:00 2001 From: jff2009 Date: Sun, 8 Feb 2026 07:57:36 +0530 Subject: [PATCH 08/11] file --- src/app/chat/chat-client.tsx | 12 +++--- src/app/chat/page.tsx | 37 ++++++++++++++++++- src/app/interactables/page.tsx | 9 ++++- .../tambo/intent-message-renderer.tsx | 2 +- src/components/tambo/message.tsx | 14 ++++++- 5 files changed, 63 insertions(+), 11 deletions(-) diff --git a/src/app/chat/chat-client.tsx b/src/app/chat/chat-client.tsx index 40deeeb..8448eaf 100644 --- a/src/app/chat/chat-client.tsx +++ b/src/app/chat/chat-client.tsx @@ -18,12 +18,12 @@ export function ChatClient({ userId }: { userId: string }) { components={components} tools={tools} mcpServers={mcpServers} - initialMessages={[ - { - role: "system", - content: [{ type: "text", text: GEMINI_INTENT_SYSTEM_PROMPT }], - }, - ]} + // initialMessages={[ + // { + // role: "system", + // content: [{ type: "text", text: GEMINI_INTENT_SYSTEM_PROMPT }], + // }, + // ]} >
diff --git a/src/app/chat/page.tsx b/src/app/chat/page.tsx index 8a4486f..15fea6a 100644 --- a/src/app/chat/page.tsx +++ b/src/app/chat/page.tsx @@ -1,7 +1,28 @@ +"use client"; + +import { MessageThreadFull } from "@/components/tambo/message-thread-full"; +import { useMcpServers } from "@/components/tambo/mcp-config-modal"; +import { components, tools } from "@/lib/tambo"; +import { TamboProvider } from "@tambo-ai/react"; import { ChatClient } from "@/app/chat/chat-client"; import { createSupabaseServerClient } from "@/lib/supabase/server"; import { redirect } from "next/navigation"; +/** + * 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() { + // Load MCP server configurations + const mcpServers = useMcpServers(); + const apiKey = process.env.NEXT_PUBLIC_TAMBO_API_KEY; + export default async function ChatPage() { const supabase = await createSupabaseServerClient(); const { @@ -30,5 +51,19 @@ export default async function ChatPage() { ); } - return ; + // return ( + // + //
+ // + //
+ //
+ // ); + return } diff --git a/src/app/interactables/page.tsx b/src/app/interactables/page.tsx index 5b004d6..8a73488 100644 --- a/src/app/interactables/page.tsx +++ b/src/app/interactables/page.tsx @@ -16,16 +16,21 @@ import { TamboProvider } from "@tambo-ai/react"; import { ChevronLeft, ChevronRight } from "lucide-react"; import { useState } from "react"; import { SettingsPanel } from "./components/settings-panel"; +import { useMcpServers } from "@/components/tambo/mcp-config-modal"; export default function InteractablesPage() { const [isChatOpen, setIsChatOpen] = useState(true); + const mcpServers = useMcpServers(); + const apiKey = process.env.NEXT_PUBLIC_TAMBO_API_KEY; return (
{/* Chat Sidebar */} diff --git a/src/components/tambo/intent-message-renderer.tsx b/src/components/tambo/intent-message-renderer.tsx index 7941762..1215d0f 100644 --- a/src/components/tambo/intent-message-renderer.tsx +++ b/src/components/tambo/intent-message-renderer.tsx @@ -18,7 +18,7 @@ export function IntentMessageRenderer({ if (!intent) { return null; } - +console.log(intent); return (
diff --git a/src/components/tambo/message.tsx b/src/components/tambo/message.tsx index f01cd38..3b9c920 100644 --- a/src/components/tambo/message.tsx +++ b/src/components/tambo/message.tsx @@ -16,6 +16,8 @@ import { Check, ChevronDown, ExternalLink, Loader2, X } from "lucide-react"; import * as React from "react"; import { useState } from "react"; import { Streamdown } from "streamdown"; +import { parseIntentFromMessage } from "@/lib/intent/intent-contract"; + /** * Converts message content to markdown format for rendering with streamdown. @@ -233,6 +235,16 @@ function MessageContentRenderer({ markdownContent: string; markdown: boolean; }) { + const { message, isLoading } = useMessageContext(); +const intent = React.useMemo( + () => parseIntentFromMessage(message), + [message], +); + +if (intent) { + return null; +} + if (!contentToRender) { return Empty message; } @@ -961,7 +973,7 @@ function formatTextContent( )} > - {JSON.stringify(parsed, null, 2)} + {/* {JSON.stringify(parsed, null, 2)} */} ); From 6401e59000ebae1ebd7c30a85b17ab0b0b821ac0 Mon Sep 17 00:00:00 2001 From: jff2009 Date: Sun, 8 Feb 2026 07:58:25 +0530 Subject: [PATCH 09/11] done --- README.md | 447 ++++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 368 insertions(+), 79 deletions(-) diff --git a/README.md b/README.md index 4d207b2..a0dc1d7 100644 --- a/README.md +++ b/README.md @@ -1,132 +1,421 @@ -# Tambo Template +# IndentOS -This is a starter NextJS app with Tambo hooked up to get your AI app development started quickly. +**An AI-powered intent-driven operating system that understands what you want to do and generates intelligent workflows to help you achieve it.** -## Get Started -k -1. Run `npm create-tambo@latest my-tambo-app` for a new project +IndentOS is a next-generation interface built on [Tambo AI](https://tambo.co) that bridges the gap between user intent and action. Instead of navigating through menus and clicking buttons, you simply express your intent in natural language, and IndentOS orchestrates the necessary steps, components, and tools to accomplish your goal. -2. `npm install` +--- -3. `npx tambo init` +## 🌟 What is IndentOS? -- or rename `example.env.local` to `.env.local` and add your tambo API key you can get for free [here](https://tambo.co/dashboard). +IndentOS reimagines how users interact with software by: -4. Run `npm run dev` and go to `localhost:3000` to use the app! +- **Understanding Intent**: Uses advanced AI to comprehend what users actually want to accomplish +- **Generating Workflows**: Automatically creates step-by-step workflows tailored to user goals +- **Dynamic UI Generation**: Renders the right components at the right time based on context +- **Intelligent Tool Orchestration**: Connects to various tools and services to execute tasks +- **Conversational Interface**: Natural language interaction instead of traditional point-and-click -## Customizing +### Key Concepts + +**Intent-Driven Design**: Rather than building fixed UI flows, IndentOS dynamically generates workflows based on user intent. The AI interprets what you want and creates a personalized path to achieve it. + +**Generative UI**: Components are generated on-the-fly by AI based on the conversation context, creating adaptive interfaces that respond to user needs. + +**Tool Integration**: IndentOS can invoke various tools and services (APIs, databases, external systems) to accomplish tasks, making it a powerful orchestration layer. + +--- + +## ✨ Features + +### 🤖 AI-Powered Chat Interface +- **Natural language interaction** with context-aware responses +- **Streaming responses** for real-time feedback +- **Voice input support** with dictation capabilities +- **Thread management** with conversation history +- **File attachments** with drag-and-drop support -### Change what components tambo can control +### 🎯 Intent Workflow System +- **Dynamic workflow generation** based on user goals +- **Task breakdown** with progress tracking +- **Timeline visualization** for multi-step processes +- **Elicitation system** to gather required information -You can see how components are registered with tambo in `src/lib/tambo.ts`: +### 🔧 Extensible Component System +- **Graph visualizations** (bar, line, pie charts) using Recharts +- **Data cards** for multi-select interactions +- **Custom components** registered via `src/lib/tambo.ts` +- **AI-generated UI** that adapts to context -```tsx +### 🛠️ Tool Orchestration +- **Population statistics tools** (example implementation) +- **Extensible tool system** for adding external capabilities +- **Schema-validated inputs/outputs** using Zod +- **MCP (Model Context Protocol)** support for external integrations + +### 🔐 Authentication & User Management +- **Supabase authentication** integration +- **User signup and login** flows +- **Secure session management** + +### 🎨 Modern UI/UX +- **Dark mode support** with Tailwind CSS v4 +- **Responsive design** for all screen sizes +- **Rich text editing** with TipTap +- **Markdown rendering** with code highlighting +- **Smooth animations** using Framer Motion + +--- + +## 🏗️ Technology Stack + +| Layer | Technology | +|-------|-----------| +| **Framework** | Next.js 15 with App Router | +| **UI Library** | React 19.1 | +| **Language** | TypeScript 5 | +| **AI Platform** | Tambo AI SDK (@tambo-ai/react) | +| **Styling** | Tailwind CSS v4 | +| **Authentication** | Supabase | +| **Data Visualization** | Recharts | +| **Rich Text** | TipTap | +| **Validation** | Zod | +| **Animation** | Framer Motion | +| **Icons** | Lucide React | + +--- + +## 🚀 Getting Started + +### Prerequisites + +- Node.js 18+ installed +- npm or yarn package manager +- A Tambo API key ([Get one free here](https://tambo.co/dashboard)) +- Supabase project (optional, for authentication) + +### Installation + +1. **Clone the repository** + ```bash + git clone + cd IndentOS + ``` + +2. **Install dependencies** + ```bash + npm install + ``` + +3. **Set up environment variables** + + Copy `example.env.local` to `.env.local`: + ```bash + cp example.env.local .env.local + ``` + + Then add your API keys to `.env.local`: + ```env + NEXT_PUBLIC_TAMBO_API_KEY=your_tambo_api_key_here + NEXT_PUBLIC_SUPABASE_URL=your_supabase_url_here + NEXT_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key_here + ``` + +4. **Run the development server** + ```bash + npm run dev + ``` + +5. **Open your browser** + + Navigate to `http://localhost:3000` + +--- + +## 📂 Project Structure + +``` +IndentOS/ +├── src/ +│ ├── app/ # Next.js App Router pages +│ │ ├── auth/ # Authentication pages +│ │ ├── chat/ # Chat interface route +│ │ ├── login/ # Login page +│ │ ├── signup/ # Signup page +│ │ ├── interactables/ # Interactive components demo +│ │ ├── layout.tsx # Root layout with TamboProvider +│ │ └── page.tsx # Landing page +│ ├── components/ +│ │ ├── hero/ # Landing page sections +│ │ │ ├── Hero.tsx # Main hero section +│ │ │ ├── AboutSection.tsx +│ │ │ └── FAQSection.tsx +│ │ ├── tambo/ # Tambo AI components +│ │ │ ├── graph.tsx # Chart visualizations +│ │ │ ├── intent-workflow.tsx # Workflow renderer +│ │ │ ├── message-thread-full.tsx # Chat UI +│ │ │ ├── message-input.tsx # Input with file support +│ │ │ ├── text-editor.tsx # Rich text editor +│ │ │ └── mcp-*.tsx # MCP integration components +│ │ ├── ui/ # Reusable UI components +│ │ │ └── card-data.tsx # Data card component +│ │ ├── ApiKeyCheck.tsx # API key validation +│ │ ├── Navbar.tsx # Main navigation +│ │ └── Footer.tsx # Footer component +│ ├── lib/ +│ │ ├── tambo.ts # ⭐ Component & tool registration +│ │ ├── thread-hooks.ts # Thread management hooks +│ │ └── utils.ts # Utility functions +│ ├── services/ +│ │ └── population-stats.ts # Example tool implementation +│ └── middleware.ts # Auth middleware +├── public/ # Static assets +├── .env.local # Environment variables (git-ignored) +├── package.json # Dependencies +├── tailwind.config.ts # Tailwind configuration +└── tsconfig.json # TypeScript configuration +``` + +### Key Files + +| File | Purpose | +|------|---------| +| `src/lib/tambo.ts` | **Central configuration** - Register components and tools here | +| `src/app/layout.tsx` | Root layout with TamboProvider setup | +| `src/app/chat/page.tsx` | Main chat interface | +| `src/components/tambo/intent-workflow.tsx` | Intent workflow renderer | +| `AGENTS.md` | Developer guide for AI assistants | +| `components.md` | Complete component documentation | + +--- + +## 🎯 How It Works + +### 1. Component Registration + +Components are registered in `src/lib/tambo.ts` with Zod schemas that define their props: + +```typescript export const components: TamboComponent[] = [ + { + name: "IntentWorkflow", + description: "A structured intent-driven workflow renderer", + component: IntentWorkflow, + propsSchema: intentWorkflowSchema, + }, { name: "Graph", - description: - "A component that renders various types of charts (bar, line, pie) using Recharts. Supports customizable data visualization with labels, datasets, and styling options.", + description: "Renders charts (bar, line, pie) using Recharts", component: Graph, propsSchema: graphSchema, }, - // Add more components here ]; ``` -You can install the graph component into any project with: +The AI can then dynamically render these components based on conversation context. -```bash -npx tambo add graph -``` +### 2. Tool Registration -The example Graph component demonstrates several key features: +Tools extend what the AI can do by connecting to external capabilities: -- Different prop types (strings, arrays, enums, nested objects) -- Multiple chart types (bar, line, pie) -- Customizable styling (variants, sizes) -- Optional configurations (title, legend, colors) -- Data visualization capabilities - -Update the `components` array with any component(s) you want tambo to be able to use in a response! - -You can find more information about the options [here](https://docs.tambo.co/concepts/generative-interfaces/generative-components) - -### Add tools for tambo to use - -Tools are defined with `inputSchema` and `outputSchema`: - -```tsx +```typescript export const tools: TamboTool[] = [ { name: "globalPopulation", - description: - "A tool to get global population trends with optional year range filtering", + description: "Gets global population trends with year filtering", tool: getGlobalPopulationTrend, inputSchema: z.object({ startYear: z.number().optional(), endYear: z.number().optional(), }), - outputSchema: z.array( - z.object({ - year: z.number(), - population: z.number(), - growthRate: z.number(), - }), - ), + outputSchema: z.array(z.object({ + year: z.number(), + population: z.number(), + growthRate: z.number(), + })), }, ]; ``` -Find more information about tools [here.](https://docs.tambo.co/concepts/tools) - -### The Magic of Tambo Requires the TamboProvider +### 3. TamboProvider Setup -Make sure in the TamboProvider wrapped around your app: +The `TamboProvider` wraps your app and provides AI capabilities: ```tsx -... {children} ``` -In this example we do this in the `Layout.tsx` file, but you can do it anywhere in your app that is a client component. +### 4. Intent Workflow Generation + +When a user expresses intent (e.g., "I want to plan a trip to Japan"), IndentOS: +1. **Analyzes the intent** using AI +2. **Breaks down the goal** into actionable steps +3. **Generates a workflow** with elicitation forms, tasks, and timeline +4. **Renders the UI** dynamically as the user progresses +5. **Orchestrates tools** to fetch data or perform actions + +--- + +## 🔧 Customization + +### Adding a New Component + +1. **Create the component** in `src/components/tambo/`: + ```tsx + import { z } from "zod"; + + export const myComponentSchema = z.object({ + title: z.string(), + data: z.array(z.string()), + }); + + export function MyComponent(props: z.infer) { + return
{/* Your component */}
; + } + ``` + +2. **Register it** in `src/lib/tambo.ts`: + ```tsx + import { MyComponent, myComponentSchema } from "@/components/tambo/my-component"; + + export const components: TamboComponent[] = [ + // ... existing components + { + name: "MyComponent", + description: "What this component does", + component: MyComponent, + propsSchema: myComponentSchema, + }, + ]; + ``` + +### Adding a New Tool + +1. **Implement the tool** in `src/services/`: + ```typescript + export async function myTool(params: { query: string }) { + // Your tool logic + return { result: "data" }; + } + ``` + +2. **Register it** in `src/lib/tambo.ts`: + ```tsx + export const tools: TamboTool[] = [ + // ... existing tools + { + name: "myTool", + description: "What this tool does", + tool: myTool, + inputSchema: z.object({ query: z.string() }), + outputSchema: z.object({ result: z.string() }), + }, + ]; + ``` + +--- + +## 📚 Available Scripts + +| Command | Description | +|---------|-------------| +| `npm run dev` | Start development server at `localhost:3000` | +| `npm run build` | Build production bundle | +| `npm run start` | Start production server | +| `npm run lint` | Run ESLint checks | +| `npm run lint:fix` | Run ESLint with auto-fix | +| `npx tambo init` | Initialize Tambo configuration | +| `npx tambo add ` | Add a pre-built Tambo component | + +--- + +## 🎨 Key Features Deep Dive + +### Intent Workflow Components + +The `IntentWorkflow` component renders structured workflows with: +- **Elicitation forms** to gather user input +- **Task lists** with progress tracking +- **Timeline views** for multi-step processes +- **Dynamic validation** using Zod schemas + +### Chat Interface + +The chat system includes: +- **Streaming responses** with real-time updates +- **Message history** with thread management +- **File attachments** via drag-and-drop +- **Voice input** using speech-to-text +- **MCP integration** for external prompts/resources -### Voice input +### MCP (Model Context Protocol) -The template includes a `DictationButton` component using the `useTamboVoice` hook for speech-to-text input. +Connect to external tools and resources: +- **Prompt insertion** from MCP servers +- **Resource references** with @-mentions +- **Client-side configuration** via modal -### MCP (Model Context Protocol) +--- -The template includes MCP support for connecting to external tools and resources. You can use the MCP hooks from `@tambo-ai/react/mcp`: +## 🌐 Authentication -- `useTamboMcpPromptList` - List available prompts from MCP servers -- `useTamboMcpPrompt` - Get a specific prompt -- `useTamboMcpResourceList` - List available resources +IndentOS uses Supabase for authentication: -See `src/components/tambo/mcp-components.tsx` for example usage. +- **Signup**: `/signup` - Create a new account +- **Login**: `/login` - Sign in to existing account +- **Protected routes**: Middleware handles auth checks +- **Session management**: Automatic token refresh -### Change where component responses are shown +--- -The components used by tambo are shown alongside the message response from tambo within the chat thread, but you can have the result components show wherever you like by accessing the latest thread message's `renderedComponent` field: +## 📖 Documentation -```tsx -const { thread } = useTambo(); -const latestComponent = - thread?.messages[thread.messages.length - 1]?.renderedComponent; - -return ( -
- {latestComponent && ( -
{latestComponent}
- )} -
-); -``` +- **Tambo AI Docs**: [docs.tambo.co](https://docs.tambo.co) +- **Component List**: See `components.md` for all available components +- **Developer Guide**: See `AGENTS.md` for architecture details +- **Tambo Dashboard**: [tambo.co/dashboard](https://tambo.co/dashboard) + +--- + +## 🤝 Contributing + +This is a demonstration project showing the possibilities of intent-driven interfaces. To extend it: + +1. Add new components in `src/components/tambo/` +2. Register them in `src/lib/tambo.ts` +3. Create tools in `src/services/` for external integrations +4. Update documentation in `components.md` + +--- + +## 📝 License + +This project is built using the Tambo template. Check individual dependencies for their licenses. + +--- + +## 🆘 Support + +- **Tambo Documentation**: [docs.tambo.co](https://docs.tambo.co) +- **Tambo Dashboard**: [tambo.co/dashboard](https://tambo.co/dashboard) +- **Next.js Documentation**: [nextjs.org/docs](https://nextjs.org/docs) + +--- + +## 🚀 What's Next? + +IndentOS is a foundation for building intent-driven applications. You can: + +- **Add more workflows** for different use cases (travel planning, task management, etc.) +- **Integrate with external APIs** to expand capabilities +- **Create custom components** for your specific domain +- **Build on the authentication** to add user profiles and data persistence +- **Deploy to production** using Vercel, Netlify, or your preferred host -For more detailed documentation, visit [Tambo's official docs](https://docs.tambo.co). +**Start building the future of human-computer interaction with IndentOS!** 🎉 From a45d6c7090009946315e6556984aca77cf680cd0 Mon Sep 17 00:00:00 2001 From: jff2009 Date: Sun, 8 Feb 2026 08:10:21 +0530 Subject: [PATCH 10/11] file added --- package-lock.json | 299 +++++++++++++++++++++++++++++++++++ package.json | 1 + src/app/chat/chat-client.tsx | 16 +- src/app/chat/page.tsx | 19 --- 4 files changed, 304 insertions(+), 31 deletions(-) diff --git a/package-lock.json b/package-lock.json index bc66753..87d383f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -48,6 +48,7 @@ "eslint": "^9.39.2", "eslint-config-next": "^16.0.4", "postcss": "^8.5.6", + "supabase": "^2.76.3", "tailwind-merge": "^3.4.0", "tailwindcss": "^4", "typescript": "^5" @@ -1075,6 +1076,19 @@ "url": "https://opencollective.com/libvips" } }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -4540,6 +4554,16 @@ "node": ">=0.4.0" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -4934,6 +4958,23 @@ "baseline-browser-mapping": "dist/cli.js" } }, + "node_modules/bin-links": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/bin-links/-/bin-links-6.0.0.tgz", + "integrity": "sha512-X4CiKlcV2GjnCMwnKAfbVWpHa++65th9TuzAEYtZoATiOE2DQKhSp4CJlyLoTqdhBKlXjpXjCTYPNNFS33Fi6w==", + "dev": true, + "license": "ISC", + "dependencies": { + "cmd-shim": "^8.0.0", + "npm-normalize-package-bin": "^5.0.0", + "proc-log": "^6.0.0", + "read-cmd-shim": "^6.0.0", + "write-file-atomic": "^7.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "node_modules/body-parser": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", @@ -5195,6 +5236,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, "node_modules/class-variance-authority": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", @@ -5222,6 +5273,16 @@ "node": ">=6" } }, + "node_modules/cmd-shim": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/cmd-shim/-/cmd-shim-8.0.0.tgz", + "integrity": "sha512-Jk/BK6NCapZ58BKUxlSI+ouKRbjH1NLZCgJkYoab+vEHUY3f6OzpNBN9u7HFSv9J6TRDGs4PLOHezoKGaFRSCA==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "node_modules/color": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", @@ -5530,6 +5591,16 @@ "node": ">=4" } }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/data-view-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", @@ -6728,6 +6799,30 @@ "reusify": "^1.0.4" } }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -6825,6 +6920,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -7396,6 +7504,20 @@ "url": "https://opencollective.com/express" } }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/iceberg-js": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz", @@ -9554,6 +9676,29 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/motion-dom": { "version": "12.23.23", "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.23.23.tgz", @@ -9717,6 +9862,46 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, "node_modules/node-releases": { "version": "2.0.27", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", @@ -9734,6 +9919,16 @@ "node": ">=0.10.0" } }, + "node_modules/npm-normalize-package-bin": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/npm-normalize-package-bin/-/npm-normalize-package-bin-5.0.0.tgz", + "integrity": "sha512-CJi3OS4JLsNMmr2u07OJlhcrPxCeOeP/4xq67aWNai6TNWWbTrlNDgl8NcFKVlcBKp18GPj+EzbNIgrBfZhsag==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -10130,6 +10325,16 @@ "node": ">= 0.8.0" } }, + "node_modules/proc-log": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz", + "integrity": "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -10737,6 +10942,16 @@ } } }, + "node_modules/read-cmd-shim": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/read-cmd-shim/-/read-cmd-shim-6.0.0.tgz", + "integrity": "sha512-1zM5HuOfagXCBWMN83fuFI/x+T/UhZ7k+KIzhrHXcQoeX5+7gmaDYjELQHmmzIodumBHeByBJT4QYS7ufAgs7A==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "node_modules/recharts": { "version": "3.5.0", "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.5.0.tgz", @@ -11432,6 +11647,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/simple-swizzle": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", @@ -11728,6 +11956,26 @@ "tslib": "^2.8.1" } }, + "node_modules/supabase": { + "version": "2.76.3", + "resolved": "https://registry.npmjs.org/supabase/-/supabase-2.76.3.tgz", + "integrity": "sha512-xJLyTiPo0WfBwHvNeLcDhyV+A0qyo/VfzL0lijXbvPL0QCY5+aLoiSwJqLempMynMvhq4Hl9EGL00B2LYmvpmQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bin-links": "^6.0.0", + "https-proxy-agent": "^7.0.2", + "node-fetch": "^3.3.2", + "tar": "7.5.7" + }, + "bin": { + "supabase": "bin/supabase" + }, + "engines": { + "npm": ">=8" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -11796,6 +12044,33 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tar": { + "version": "7.5.7", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.7.tgz", + "integrity": "sha512-fov56fJiRuThVFXD6o6/Q354S7pnWMJIVlDBYijsTNx6jKSE4pvrDTs6lUnmGvNyfJwFQQwWy3owKz1ucIhveQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tar/node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, "node_modules/tiny-invariant": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", @@ -12486,6 +12761,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -12616,6 +12901,20 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/write-file-atomic": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-7.0.0.tgz", + "integrity": "sha512-YnlPC6JqnZl6aO4uRc+dx5PHguiR9S6WeoLtpxNT9wIG+BDya7ZNE1q7KOjVgaA73hKhKLpVPgJ5QA9THQ5BRg==", + "dev": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "node_modules/ws": { "version": "8.19.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", diff --git a/package.json b/package.json index b2f4fb2..7bf17c6 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "eslint": "^9.39.2", "eslint-config-next": "^16.0.4", "postcss": "^8.5.6", + "supabase": "^2.76.3", "tailwind-merge": "^3.4.0", "tailwindcss": "^4", "typescript": "^5" diff --git a/src/app/chat/chat-client.tsx b/src/app/chat/chat-client.tsx index 8448eaf..8c27017 100644 --- a/src/app/chat/chat-client.tsx +++ b/src/app/chat/chat-client.tsx @@ -2,28 +2,20 @@ import { MessageThreadFull } from "@/components/tambo/message-thread-full"; import { useMcpServers } from "@/components/tambo/mcp-config-modal"; -import { GEMINI_INTENT_SYSTEM_PROMPT } from "@/lib/intent/gemini-intent-system-prompt"; import { components, tools } from "@/lib/tambo"; import { TamboProvider } from "@tambo-ai/react"; export function ChatClient({ userId }: { userId: string }) { const mcpServers = useMcpServers(); - + const apiKey = process.env.NEXT_PUBLIC_TAMBO_API_KEY; return (
diff --git a/src/app/chat/page.tsx b/src/app/chat/page.tsx index 15fea6a..0edeff3 100644 --- a/src/app/chat/page.tsx +++ b/src/app/chat/page.tsx @@ -1,28 +1,9 @@ "use client"; -import { MessageThreadFull } from "@/components/tambo/message-thread-full"; -import { useMcpServers } from "@/components/tambo/mcp-config-modal"; -import { components, tools } from "@/lib/tambo"; -import { TamboProvider } from "@tambo-ai/react"; import { ChatClient } from "@/app/chat/chat-client"; import { createSupabaseServerClient } from "@/lib/supabase/server"; import { redirect } from "next/navigation"; -/** - * 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() { - // Load MCP server configurations - const mcpServers = useMcpServers(); - const apiKey = process.env.NEXT_PUBLIC_TAMBO_API_KEY; - export default async function ChatPage() { const supabase = await createSupabaseServerClient(); const { From ab446ca0947e56e06159ca5037e828be05e976a7 Mon Sep 17 00:00:00 2001 From: jff2009 Date: Sun, 8 Feb 2026 08:32:14 +0530 Subject: [PATCH 11/11] file added --- src/app/api/tambo/[...path]/route.ts | 42 +++--- src/app/auth/actions.ts | 2 +- src/app/chat/page.tsx | 6 +- src/app/login/page.tsx | 137 +++++++++---------- src/app/signup/page.tsx | 190 ++++++++++++++------------- src/{lib => app}/supabase/env.ts | 0 src/{lib => app}/supabase/server.ts | 0 src/middleware.ts | 2 +- 8 files changed, 195 insertions(+), 184 deletions(-) rename src/{lib => app}/supabase/env.ts (100%) rename src/{lib => app}/supabase/server.ts (100%) diff --git a/src/app/api/tambo/[...path]/route.ts b/src/app/api/tambo/[...path]/route.ts index c9ef45d..5e459a9 100644 --- a/src/app/api/tambo/[...path]/route.ts +++ b/src/app/api/tambo/[...path]/route.ts @@ -1,4 +1,4 @@ -import { createSupabaseServerClient } from "@/lib/supabase/server"; +import { createSupabaseServerClient } from "@/app/supabase/server"; import { NextResponse } from "next/server"; export const runtime = "nodejs"; @@ -147,11 +147,11 @@ function getFirstUserMessageText(messages: Array<{ role: string; content: any }> if (msg.role !== "user") continue; const text = Array.isArray(msg.content) ? msg.content - .filter(isTextPart) - .map((part) => part.text) - .filter(Boolean) - .join(" ") - .trim() + .filter(isTextPart) + .map((part) => part.text) + .filter(Boolean) + .join(" ") + .trim() : ""; if (text) return text; } @@ -382,21 +382,21 @@ async function handleThreadMessagesCreate( const body = (await request.json().catch(() => null)) as | { - role?: "user" | "assistant" | "system" | "tool"; - content?: unknown; - additionalContext?: Record; - component?: Record; - componentState?: Record; - toolCallRequest?: Record; - tool_calls?: unknown[]; - tool_call_id?: string; - parentMessageId?: string; - reasoning?: unknown; - reasoningDurationMS?: number; - error?: string; - isCancelled?: boolean; - metadata?: Record; - } + role?: "user" | "assistant" | "system" | "tool"; + content?: unknown; + additionalContext?: Record; + component?: Record; + componentState?: Record; + toolCallRequest?: Record; + tool_calls?: unknown[]; + tool_call_id?: string; + parentMessageId?: string; + reasoning?: unknown; + reasoningDurationMS?: number; + error?: string; + isCancelled?: boolean; + metadata?: Record; + } | null; if (!body) return jsonError("Invalid JSON body", 400); diff --git a/src/app/auth/actions.ts b/src/app/auth/actions.ts index 4baea66..39db555 100644 --- a/src/app/auth/actions.ts +++ b/src/app/auth/actions.ts @@ -1,6 +1,6 @@ "use server"; -import { createSupabaseServerActionClient } from "@/lib/supabase/server"; +import { createSupabaseServerActionClient } from "@/app/supabase/server"; import { redirect } from "next/navigation"; import { z } from "zod"; diff --git a/src/app/chat/page.tsx b/src/app/chat/page.tsx index 0edeff3..1d037a7 100644 --- a/src/app/chat/page.tsx +++ b/src/app/chat/page.tsx @@ -1,7 +1,7 @@ -"use client"; +// "use client"; import { ChatClient } from "@/app/chat/chat-client"; -import { createSupabaseServerClient } from "@/lib/supabase/server"; +import { createSupabaseServerClient } from "@/app/supabase/server"; import { redirect } from "next/navigation"; export default async function ChatPage() { @@ -14,7 +14,7 @@ export default async function ChatPage() { redirect("/login"); } - const hasTamboApiKey = !!process.env.TAMBO_API_KEY; + const hasTamboApiKey = !!process.env.NEXT_PUBLIC_TAMBO_API_KEY; if (!hasTamboApiKey) { return ( diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index c6df6b4..1560936 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -1,7 +1,7 @@ import Footer from "@/components/Footer"; import Navbar from "@/components/Navbar"; import { signIn } from "@/app/auth/actions"; -import { createSupabaseServerClient } from "@/lib/supabase/server"; +import { createSupabaseServerClient } from "@/app/supabase/server"; import Link from "next/link"; import { redirect } from "next/navigation"; @@ -30,77 +30,82 @@ export default async function LoginPage({ const message = getFirst(sp.message); return ( -
- -
-

Sign in

-

- Log in with your email and password. -

+
+ - {error && ( -
- {error} -
- )} +
+
+

Sign in

+

+ Log in with your email and password. +

- {!error && message && ( -
- {message} -
- )} + {error && ( +
+ {error} +
+ )} -
-
- - -
+ {!error && message && ( +
+ {message} +
+ )} -
- - -
+ +
+ + +
- -
+ Password + + +
+ + + + +

+ Don't have an account?{" "} + + Create one + +

+
+
+ +
+
-

- Don't have an account?{" "} - - Create one - -

-
-
- ); } diff --git a/src/app/signup/page.tsx b/src/app/signup/page.tsx index e93923e..20aad45 100644 --- a/src/app/signup/page.tsx +++ b/src/app/signup/page.tsx @@ -1,7 +1,7 @@ import Footer from "@/components/Footer"; import Navbar from "@/components/Navbar"; import { signUp } from "@/app/auth/actions"; -import { createSupabaseServerClient } from "@/lib/supabase/server"; +import { createSupabaseServerClient } from "@/app/supabase/server"; import Link from "next/link"; import { redirect } from "next/navigation"; @@ -30,97 +30,103 @@ export default async function SignupPage({ const message = getFirst(sp.message); return ( -
- -
-

Create an account

-

- Sign up with your name, email, and password. -

- - {error && ( -
- {error} -
- )} - - {!error && message && ( -
- {message} -
- )} - -
-
- - -
- -
- - -
- -
- - -

- Minimum 8 characters. -

-
- - -
- -

- Already have an account?{" "} - - Sign in - -

-
-
-
+ Email + + +
+ +
+ + +

+ Minimum 8 characters. +

+
+ + + + +

+ Already have an account?{" "} + + Sign in + +

+
+
+ +