diff --git a/examples/chat-groq/.env.example b/examples/chat-groq/.env.example new file mode 100644 index 0000000..6fc5bd7 --- /dev/null +++ b/examples/chat-groq/.env.example @@ -0,0 +1,28 @@ +# AI providers +GROQ_API_KEY= # gsk-xxx... (Get your API key from https://console.groq.com/keys) +OPENAI_API_KEY= # sk-xxx... (Optional, only needed for image generation) +EXA_API_KEY= # To enable web searches + +# Pipedream credentials +# Read more here: https://pipedream.com/docs/connect/mcp/developers/#prerequisites +PIPEDREAM_CLIENT_ID= +PIPEDREAM_CLIENT_SECRET= +PIPEDREAM_PROJECT_ID= # proj_xxxxxxx +PIPEDREAM_PROJECT_ENVIRONMENT= # development | production + +# Chat app configs +DISABLE_AUTH=true # Disable user sign-in (useful when developing locally and getting started) +DISABLE_PERSISTENCE=true # Disable any chat or session storage (useful when developing locally and getting started) +EXTERNAL_USER_ID= # You'll pass this via code in practice, but you can hardcode it here while developing + +# Generate a random secret: https://generate-secret.vercel.app/32 or `openssl rand -base64 32` +AUTH_SECRET= + +# For user sign-in in the chat app using nextauth +# Make sure to also add the appropriate redirect URIs in Google Cloud for your OAuth app +# GOOGLE_CLIENT_ID= # For enabling Google OAuth in the chat app +# GOOGLE_CLIENT_SECRET= # For enabling Google OAuth in the chat app + +# Datadog (Optional) +# NEXT_PUBLIC_DATADOG_APPLICATION_ID= +# NEXT_PUBLIC_DATADOG_CLIENT_TOKEN= \ No newline at end of file diff --git a/examples/chat-groq/.eslintrc.json b/examples/chat-groq/.eslintrc.json new file mode 100644 index 0000000..eef7b07 --- /dev/null +++ b/examples/chat-groq/.eslintrc.json @@ -0,0 +1,22 @@ +{ + "extends": [ + "next/core-web-vitals", + "plugin:import/recommended", + "plugin:import/typescript", + "prettier", + "plugin:tailwindcss/recommended" + ], + "plugins": ["tailwindcss"], + "rules": { + "tailwindcss/no-custom-classname": "off", + "tailwindcss/classnames-order": "off" + }, + "settings": { + "import/resolver": { + "typescript": { + "alwaysTryTypes": true + } + } + }, + "ignorePatterns": ["**/components/ui/**"] +} diff --git a/examples/chat-groq/.github/workflows/lint.yml b/examples/chat-groq/.github/workflows/lint.yml new file mode 100644 index 0000000..deda7f5 --- /dev/null +++ b/examples/chat-groq/.github/workflows/lint.yml @@ -0,0 +1,25 @@ +name: Lint +on: + push: + +jobs: + build: + runs-on: ubuntu-22.04 + strategy: + matrix: + node-version: [20] + steps: + - uses: actions/checkout@v4 + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + version: 9.12.3 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: 'pnpm' + - name: Install dependencies + run: pnpm install + - name: Run lint + run: pnpm lint \ No newline at end of file diff --git a/examples/chat-groq/.github/workflows/playwright.yml b/examples/chat-groq/.github/workflows/playwright.yml new file mode 100644 index 0000000..9bbae17 --- /dev/null +++ b/examples/chat-groq/.github/workflows/playwright.yml @@ -0,0 +1,72 @@ +name: Playwright Tests +on: + push: + branches: [main, master] + pull_request: + branches: [main, master] + +jobs: + test: + timeout-minutes: 30 + runs-on: ubuntu-latest + env: + AUTH_SECRET: ${{ secrets.AUTH_SECRET }} + POSTGRES_URL: ${{ secrets.POSTGRES_URL }} + BLOB_READ_WRITE_TOKEN: ${{ secrets.BLOB_READ_WRITE_TOKEN }} + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - uses: actions/setup-node@v4 + with: + node-version: lts/* + + - name: Install pnpm + uses: pnpm/action-setup@v2 + with: + version: latest + run_install: false + + - name: Get pnpm store directory + id: pnpm-cache + shell: bash + run: | + echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT + + - uses: actions/cache@v3 + with: + path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} + key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store- + + - uses: actions/setup-node@v4 + with: + node-version: lts/* + cache: "pnpm" + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Cache Playwright browsers + uses: actions/cache@v3 + id: playwright-cache + with: + path: ~/.cache/ms-playwright + key: ${{ runner.os }}-playwright-${{ hashFiles('**/pnpm-lock.yaml') }} + + - name: Install Playwright Browsers + if: steps.playwright-cache.outputs.cache-hit != 'true' + run: pnpm exec playwright install --with-deps chromium + + - name: Run Playwright tests + run: pnpm test + + - uses: actions/upload-artifact@v4 + if: always() && !cancelled() + with: + name: playwright-report + path: playwright-report/ + retention-days: 7 diff --git a/examples/chat-groq/.gitignore b/examples/chat-groq/.gitignore new file mode 100644 index 0000000..b9556aa --- /dev/null +++ b/examples/chat-groq/.gitignore @@ -0,0 +1,44 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +node_modules +.pnp +.pnp.js +.pnpm-store + +# testing +coverage + +# next.js +.next/ +out/ +build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# local env files +.env.local +.env.development.local +.env.test.local +.env.production.local + +# turbo +.turbo + +.env +.vercel +.env*.local + +# Playwright +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/* diff --git a/examples/chat-groq/.tool-versions b/examples/chat-groq/.tool-versions new file mode 100644 index 0000000..9f0ea12 --- /dev/null +++ b/examples/chat-groq/.tool-versions @@ -0,0 +1 @@ +nodejs 18.18.0 \ No newline at end of file diff --git a/examples/chat-groq/.vscode/extensions.json b/examples/chat-groq/.vscode/extensions.json new file mode 100644 index 0000000..699ed73 --- /dev/null +++ b/examples/chat-groq/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["biomejs.biome"] +} diff --git a/examples/chat-groq/.vscode/settings.json b/examples/chat-groq/.vscode/settings.json new file mode 100644 index 0000000..fb5a7ca --- /dev/null +++ b/examples/chat-groq/.vscode/settings.json @@ -0,0 +1,17 @@ +{ + "editor.formatOnSave": true, + "[javascript]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[typescript]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[typescriptreact]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "typescript.tsdk": "node_modules/typescript/lib", + "eslint.workingDirectories": [ + { "pattern": "app/*" }, + { "pattern": "packages/*" } + ] +} diff --git a/examples/chat-groq/Dockerfile b/examples/chat-groq/Dockerfile new file mode 100644 index 0000000..182fb4d --- /dev/null +++ b/examples/chat-groq/Dockerfile @@ -0,0 +1,61 @@ +# syntax=docker.io/docker/dockerfile:1 + +FROM node:18-alpine AS base + +# Install dependencies only when needed +FROM base AS deps +# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed. +RUN apk add --no-cache libc6-compat python3 make g++ +WORKDIR /app + +# Install dependencies based on the preferred package manager +COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* .npmrc* ./ +RUN \ + if [ -f yarn.lock ]; then yarn --frozen-lockfile; \ + elif [ -f package-lock.json ]; then npm ci; \ + elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \ + else echo "Lockfile not found." && exit 1; \ + fi + + +# Rebuild the source code only when needed +FROM base AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . + +# Next.js collects completely anonymous telemetry data about general usage. +# Learn more here: https://nextjs.org/telemetry +# Uncomment the following line in case you want to disable telemetry during the build. +# ENV NEXT_TELEMETRY_DISABLED=1 + +RUN corepack enable pnpm && pnpm run build + +# Production image, copy all the files and run next +FROM base AS runner +WORKDIR /app + +ENV NODE_ENV=production +# Uncomment the following line in case you want to disable telemetry during runtime. +# ENV NEXT_TELEMETRY_DISABLED=1 + +RUN addgroup --system --gid 1001 nodejs +RUN adduser --system --uid 1001 nextjs + +COPY --from=builder /app/public ./public + +# Automatically leverage output traces to reduce image size +# https://nextjs.org/docs/advanced-features/output-file-tracing +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static + +USER nextjs + +EXPOSE 3000 + +ENV PORT=3000 + +# server.js is created by next build from the standalone output +# https://nextjs.org/docs/pages/api-reference/config/next-config-js/output +ENV HOSTNAME="0.0.0.0" +CMD ["node", "server.js"] \ No newline at end of file diff --git a/examples/chat-groq/LICENSE b/examples/chat-groq/LICENSE new file mode 100644 index 0000000..695ee2d --- /dev/null +++ b/examples/chat-groq/LICENSE @@ -0,0 +1,13 @@ +Copyright 2024 Vercel, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. \ No newline at end of file diff --git a/examples/chat-groq/README.md b/examples/chat-groq/README.md new file mode 100644 index 0000000..bf29c17 --- /dev/null +++ b/examples/chat-groq/README.md @@ -0,0 +1,95 @@ +## Pipedream Chat - Groq Edition + +A high-performance implementation of Pipedream's MCP server integration powered by Groq's blazing-fast LLMs. This demo provides access to 10,000+ tools across 2,700+ APIs through a conversational interface, leveraging Groq's industry-leading inference speeds. + +> Based on the [Next.js AI Chatbot](https://chat.vercel.ai/) and [Pipedream Chat](https://chat.pipedream.com) + +### Key Features + +- **Groq-powered inference**: Lightning-fast responses using Groq's LLMs including Llama 3.3, DeepSeek R1, and Gemma2 +- **MCP integrations**: Connect to thousands of APIs through Pipedream's MCP server with built-in auth +- **Optimized for tool use**: Leverage Groq's specialized tool-use models for enhanced function calling +- **Tool discovery**: Execute tool calls across different APIs via chat + +> Check out [Pipedream's developer docs](https://pipedream.com/docs/connect/mcp/developers) for the most up to date information. + +### Development Mode + +For local development, you can disable authentication and persistence: + +```bash +# In your .env file +DISABLE_AUTH=true +DISABLE_PERSISTENCE=true +EXTERNAL_USER_ID=your-dev-user-id +``` + +This allows you to test the chat functionality without setting up authentication or database persistence. + +> [!IMPORTANT] +> Treat this project as a reference implementation for integrating MCP servers into AI applications. + +## Model Providers + +This app is optimized for Groq's high-performance models: + +- **DeepSeek R1 Distill Llama 70B**: Advanced reasoning with 128k context +- **Llama 3.3 70B Versatile**: Latest Llama model for versatile tasks +- **Llama 3.1 8B Instant**: Fast, lightweight model for quick responses +- **Llama 3 Groq Tool Use models**: Optimized for function calling (70B and 8B variants) +- **Gemma2 9B IT**: Google's efficient instruction-tuned model + +## Running locally + +You can run this chat app in two ways: + +### Option 1: From the monorepo root (recommended) + +If you're working within the full MCP monorepo: + +```bash +# From the root of the monorepo +cp .env.example .env # Edit with your values, including GROQ_API_KEY +pnpm install +pnpm chat-groq +``` + +This will automatically use the `.env` file from the root directory and start the chat app. + +### Option 2: From this directory + +If you're working directly in the chat example: + +```bash +# From examples/chat directory +cp .env.example .env # Edit with your values +``` + +Then run all required local services: + +```bash +docker compose up -d +``` + +Run migrations: + +```bash +POSTGRES_URL=postgresql://postgres@localhost:5432/postgres pnpm db:migrate +``` + +Then start the app: + +```bash +pnpm install +pnpm chat +``` + +### Configuration + +By default the client will point at https://remote.mcp.pipedream.net. Use the `MCP_SERVER` env var to point to an MCP server running locally: + +```bash +MCP_SERVER=http://localhost:3010 pnpm chat +``` + +Your local app should now be running on [http://localhost:3000](http://localhost:3000/). diff --git a/examples/chat-groq/app/(auth)/actions.ts b/examples/chat-groq/app/(auth)/actions.ts new file mode 100644 index 0000000..690f34f --- /dev/null +++ b/examples/chat-groq/app/(auth)/actions.ts @@ -0,0 +1,88 @@ +'use server'; + +import { z } from 'zod'; + +import { createUser, getUser } from '@/lib/db/queries'; + +import { signIn } from './auth'; + +const authFormSchema = z.object({ + email: z.string().email(), + password: z.string().min(6), +}); + +export interface LoginActionState { + status: 'idle' | 'in_progress' | 'success' | 'failed' | 'invalid_data'; +} + +export const googleLogin = async () => { + await signIn("google", { redirectTo: "/" }) +} + +export const login = async ( + _: LoginActionState, + formData: FormData, +): Promise => { + try { + const validatedData = authFormSchema.parse({ + email: formData.get('email'), + password: formData.get('password'), + }); + + await signIn('credentials', { + email: validatedData.email, + password: validatedData.password, + redirect: false, + }); + + return { status: 'success' }; + } catch (error) { + if (error instanceof z.ZodError) { + return { status: 'invalid_data' }; + } + + return { status: 'failed' }; + } +}; + +export interface RegisterActionState { + status: + | 'idle' + | 'in_progress' + | 'success' + | 'failed' + | 'user_exists' + | 'invalid_data'; +} + +export const register = async ( + _: RegisterActionState, + formData: FormData, +): Promise => { + try { + const validatedData = authFormSchema.parse({ + email: formData.get('email'), + password: formData.get('password'), + }); + + const [user] = await getUser(validatedData.email); + + if (user) { + return { status: 'user_exists' } as RegisterActionState; + } + await createUser(validatedData.email, validatedData.password); + await signIn('credentials', { + email: validatedData.email, + password: validatedData.password, + redirect: false, + }); + + return { status: 'success' }; + } catch (error) { + if (error instanceof z.ZodError) { + return { status: 'invalid_data' }; + } + + return { status: 'failed' }; + } +}; diff --git a/examples/chat-groq/app/(auth)/api/auth/[...nextauth]/route.ts b/examples/chat-groq/app/(auth)/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..ba24234 --- /dev/null +++ b/examples/chat-groq/app/(auth)/api/auth/[...nextauth]/route.ts @@ -0,0 +1 @@ +export { GET, POST } from '@/app/(auth)/auth'; diff --git a/examples/chat-groq/app/(auth)/auth.config.ts b/examples/chat-groq/app/(auth)/auth.config.ts new file mode 100644 index 0000000..3cbf4d1 --- /dev/null +++ b/examples/chat-groq/app/(auth)/auth.config.ts @@ -0,0 +1,49 @@ +import type { NextAuthConfig } from "next-auth" +import { isAuthDisabled } from "@/lib/constants" + +export const authConfig = { + pages: { + signIn: "/api/auth/signin", + }, + session: { + strategy: "jwt" + }, + providers: [ + // added later in auth.ts since it requires bcrypt which is only compatible with Node.js + // while this file is also used in non-Node.js environments + ], + callbacks: { + authorized({ auth, request: { nextUrl } }) { + // If auth is disabled (dev mode), allow all requests + if (isAuthDisabled) { + return true + } + + const isLoggedIn = !!auth?.user + const isOnRegister = nextUrl.pathname.startsWith("/register") + const isOnLogin = nextUrl.pathname.startsWith("/login") + const isAuthRoute = nextUrl.pathname.startsWith("/api/auth") + const isHealthCheck = nextUrl.pathname.startsWith("/healthcheck") + const isApiChat = nextUrl.pathname.startsWith("/api/chat") + + if (isHealthCheck) { + return true + } + + if (isLoggedIn && (isOnLogin || isOnRegister)) { + return Response.redirect(new URL("/", nextUrl as unknown as URL)) + } + + if (isOnRegister || isOnLogin || isAuthRoute) { + return true // Always allow access to register and login pages + } + + // Only require authentication for the chat API endpoint + if (isApiChat) { + return isLoggedIn + } + + return true + }, + }, +} satisfies NextAuthConfig diff --git a/examples/chat-groq/app/(auth)/auth.ts b/examples/chat-groq/app/(auth)/auth.ts new file mode 100644 index 0000000..99ffd0c --- /dev/null +++ b/examples/chat-groq/app/(auth)/auth.ts @@ -0,0 +1,68 @@ +import { compare } from 'bcrypt-ts'; +import NextAuth, { type User, type Session } from 'next-auth'; +import Credentials from 'next-auth/providers/credentials'; +import GoogleProvider from "next-auth/providers/google"; +import { DrizzleAdapter } from "@auth/drizzle-adapter" + +import { db, getUser } from '@/lib/db/queries'; + +import { authConfig } from './auth.config'; +import { accounts, user } from '@/lib/db/schema'; + +interface ExtendedSession extends Session { + user: User; +} + +export const { + handlers: { GET, POST }, + auth, + signIn, + signOut, +} = NextAuth({ + ...authConfig, + + adapter: DrizzleAdapter(db, { + usersTable: user, + accountsTable: accounts, + }), + providers: [ + // Credentials({ + // credentials: {}, + // async authorize({ email, password }: any) { + // const users = await getUser(email); + // if (users.length === 0) return null; + // // biome-ignore lint: Forbidden non-null assertion. + // const passwordsMatch = await compare(password, users[0].password!); + // if (!passwordsMatch) return null; + // return users[0] as any; + // }, + // }), + GoogleProvider({ + allowDangerousEmailAccountLinking: true, + clientId: process.env.GOOGLE_CLIENT_ID, + clientSecret: process.env.GOOGLE_CLIENT_SECRET, + }) + ], + callbacks: { + async jwt({ token, user }) { + if (user) { + token.id = user.id; + } + + return token; + }, + async session({ + session, + token, + }: { + session: ExtendedSession; + token: any; + }) { + if (session.user) { + session.user.id = token.id as string; + } + + return session; + }, + }, +}); diff --git a/examples/chat-groq/app/(auth)/login/page.tsx b/examples/chat-groq/app/(auth)/login/page.tsx new file mode 100644 index 0000000..9c93919 --- /dev/null +++ b/examples/chat-groq/app/(auth)/login/page.tsx @@ -0,0 +1,47 @@ +"use client" + +import { googleLogin } from "../actions" + +export default function Page() { + // const router = useRouter() + + // const [email, setEmail] = useState("") + // const [isSuccessful, setIsSuccessful] = useState(false) + + // const [state, formAction] = useActionState( + // login, + // { + // status: "idle", + // } + // ) + + // useEffect(() => { + // if (state.status === 'failed') { + // toast({ + // type: 'error', + // description: 'Invalid credentials!', + // }); + // } else if (state.status === 'invalid_data') { + // toast({ + // type: 'error', + // description: 'Failed validating your submission!', + // }); + // } else if (state.status === 'success') { + // setIsSuccessful(true); + // router.refresh(); + // } + // }, [state.status, router]); + + // const handleSubmit = (formData: FormData) => { + // setEmail(formData.get('email') as string); + // formAction(formData); + // }; + + return ( +
+
+ +
+
+ ) +} diff --git a/examples/chat-groq/app/(auth)/register/page.tsx b/examples/chat-groq/app/(auth)/register/page.tsx new file mode 100644 index 0000000..224f0c0 --- /dev/null +++ b/examples/chat-groq/app/(auth)/register/page.tsx @@ -0,0 +1,74 @@ +'use client'; + +import Link from 'next/link'; +import { useRouter } from 'next/navigation'; +import { useActionState, useEffect, useState } from 'react'; + +import { AuthForm } from '@/components/auth-form'; +import { SubmitButton } from '@/components/submit-button'; + +import { register, type RegisterActionState } from '../actions'; +import { toast } from '@/components/toast'; + +export default function Page() { + const router = useRouter(); + + const [email, setEmail] = useState(''); + const [isSuccessful, setIsSuccessful] = useState(false); + + const [state, formAction] = useActionState( + register, + { + status: 'idle', + }, + ); + + useEffect(() => { + if (state.status === 'user_exists') { + toast({ type: 'error', description: 'Account already exists!' }); + } else if (state.status === 'failed') { + toast({ type: 'error', description: 'Failed to create account!' }); + } else if (state.status === 'invalid_data') { + toast({ + type: 'error', + description: 'Failed validating your submission!', + }); + } else if (state.status === 'success') { + toast({ type: 'success', description: 'Account created successfully!' }); + + setIsSuccessful(true); + router.refresh(); + } + }, [state, router]); + + const handleSubmit = (formData: FormData) => { + setEmail(formData.get('email') as string); + formAction(formData); + }; + + return ( +
+
+
+

Sign Up

+

+ Create an account with your email and password +

+
+ + Sign Up +

+ {'Already have an account? '} + + Sign in + + {' instead.'} +

+
+
+
+ ); +} diff --git a/examples/chat-groq/app/(chat)/accounts/actions.ts b/examples/chat-groq/app/(chat)/accounts/actions.ts new file mode 100644 index 0000000..6ab1808 --- /dev/null +++ b/examples/chat-groq/app/(chat)/accounts/actions.ts @@ -0,0 +1,60 @@ +"use server"; + +import { getEffectiveSession } from '@/lib/auth-utils'; +import { type Account } from '@pipedream/sdk/server'; +import { pdClient } from '@/lib/pd-backend-client'; + +/** + * Fetches connected accounts for the current authenticated user + * @returns Array of connected accounts + */ +export async function getConnectedAccounts(): Promise { + const session = await getEffectiveSession(); + if (!session?.user?.id) { + return []; + } + + try { + const response = await pdClient().getAccounts({ + external_user_id: session.user.id, + }); + + if (response?.data && Array.isArray(response.data)) { + return response.data; + } + + return []; + } catch (error) { + // Return empty array on error to prevent UI from breaking + return []; + } +} + +/** + * Deletes a connected account by ID + * @param accountId The ID of the account to delete + */ +export async function deleteConnectedAccount(accountId: string): Promise { + const pd = pdClient() + const session = await getEffectiveSession(); + if (!session?.user?.id) { + throw new Error('User not authenticated'); + } + + try { + // Verify the user owns this account before deleting + const accounts = await pd.getAccounts({ + external_user_id: session.user.id, + }); + + const accountBelongsToUser = accounts?.data?.some(account => account.id === accountId); + + if (!accountBelongsToUser) { + throw new Error('Account not found or not owned by user'); + } + + await pd.deleteAccount(accountId); + } catch (error) { + throw new Error('Failed to delete account'); + } +} \ No newline at end of file diff --git a/examples/chat-groq/app/(chat)/accounts/page.tsx b/examples/chat-groq/app/(chat)/accounts/page.tsx new file mode 100644 index 0000000..7d297b0 --- /dev/null +++ b/examples/chat-groq/app/(chat)/accounts/page.tsx @@ -0,0 +1,32 @@ +import { getConnectedAccounts } from './actions'; +import { ConnectedAccounts } from '@/components/connected-accounts'; +import { ChatHeader } from '@/components/chat-header'; +import { getEffectiveSession, shouldPersistData } from '@/lib/auth-utils'; + +export default async function AccountsPage() { + const session = await getEffectiveSession(); + if (!session?.user) { + return
You must be signed in to view this page.
; + } + + const accounts = await getConnectedAccounts(); + + return ( +
+ +
+
+

Connected Accounts

+
+ +
+
+
+
+ ); +} \ No newline at end of file diff --git a/examples/chat-groq/app/(chat)/actions.ts b/examples/chat-groq/app/(chat)/actions.ts new file mode 100644 index 0000000..8e5bd02 --- /dev/null +++ b/examples/chat-groq/app/(chat)/actions.ts @@ -0,0 +1,54 @@ +'use server'; + +import { generateText, Message } from 'ai'; +import { cookies } from 'next/headers'; + +import { + deleteMessagesByChatIdAfterTimestamp, + getMessageById, + updateChatVisiblityById, +} from '@/lib/db/queries'; +import { VisibilityType } from '@/components/visibility-selector'; +import { myProvider } from '@/lib/ai/providers'; + +export async function saveChatModelAsCookie(model: string) { + const cookieStore = await cookies(); + cookieStore.set('chat-model', model); +} + +export async function generateTitleFromUserMessage({ + message, +}: { + message: Message; +}) { + const { text: title } = await generateText({ + model: myProvider.languageModel('title-model'), + system: `\n + - you will generate a short title based on the first message a user begins a conversation with + - ensure it is not more than 80 characters long + - the title should be a summary of the user's message + - do not use quotes or colons`, + prompt: JSON.stringify(message), + }); + + return title; +} + +export async function deleteTrailingMessages({ id }: { id: string }) { + const [message] = await getMessageById({ id }); + + await deleteMessagesByChatIdAfterTimestamp({ + chatId: message.chatId, + timestamp: message.createdAt, + }); +} + +export async function updateChatVisibility({ + chatId, + visibility, +}: { + chatId: string; + visibility: VisibilityType; +}) { + await updateChatVisiblityById({ chatId, visibility }); +} diff --git a/examples/chat-groq/app/(chat)/api/chat/route.ts b/examples/chat-groq/app/(chat)/api/chat/route.ts new file mode 100644 index 0000000..3b6328a --- /dev/null +++ b/examples/chat-groq/app/(chat)/api/chat/route.ts @@ -0,0 +1,250 @@ +import { auth } from "@/app/(auth)/auth" +import { systemPrompt } from "@/lib/ai/prompts" +import { myProvider } from "@/lib/ai/providers" +import { isProductionEnvironment, isAuthDisabled } from "@/lib/constants" +import { + deleteChatById, + getChatById, + saveChat, + saveMessages, +} from "@/lib/db/queries" +import { + generateUUID, + getMostRecentUserMessage, + getTrailingMessageId, +} from "@/lib/utils" +import { getEffectiveSession, shouldPersistData } from "@/lib/auth-utils" +import { MCPSessionManager } from "@/mods/mcp-client" +import { + UIMessage, + appendResponseMessages, + createDataStreamResponse, + smoothStream, +} from "ai" +import { generateTitleFromUserMessage } from "../../actions" +import { streamText } from "./streamText" + +export const maxDuration = 60 + +const MCP_BASE_URL = process.env.MCP_SERVER ? process.env.MCP_SERVER : "https://remote.mcp.pipedream.net" + + +export async function POST(request: Request) { + try { + const { + id, + messages, + selectedChatModel, + }: { + id: string + messages: Array + selectedChatModel: string + } = await request.json() + + const session = await getEffectiveSession() + + // Debug logging for production + console.log('DEBUG: Session details:', { + hasSession: !!session, + hasUser: !!session?.user, + userId: session?.user?.id, + sessionType: session?.constructor?.name || 'unknown', + isAuthDisabled: process.env.DISABLE_AUTH === 'true', + timestamp: new Date().toISOString() + }) + + if (!session || !session.user || !session.user.id) { + console.error('Session validation failed:', { + hasSession: !!session, + hasUser: !!session?.user, + userId: session?.user?.id, + fullSession: session + }) + return new Response(JSON.stringify({ error: "Authentication required", redirectToAuth: true }), { + status: 401, + headers: { + 'Content-Type': 'application/json' + } + }) + } + + const userId = session.user.id + + const userMessage = getMostRecentUserMessage(messages) + + if (!userMessage) { + return new Response("No user message found", { status: 400 }) + } + + // Only check/save chat and messages if persistence is enabled + if (shouldPersistData()) { + const chat = await getChatById({ id }) + + if (!chat) { + const title = await generateTitleFromUserMessage({ + message: userMessage, + }) + + await saveChat({ id, userId, title }) + } else { + if (chat.userId !== userId) { + return new Response("Unauthorized", { status: 401 }) + } + } + + await saveMessages({ + messages: [ + { + chatId: id, + id: userMessage.id, + role: "user", + parts: userMessage.parts, + attachments: userMessage.experimental_attachments ?? [], + createdAt: new Date(), + }, + ], + }) + } + + // get any existing mcp sessions from the mcp server + const mcpSessionUrl = `${MCP_BASE_URL}/v1/${userId}/sessions` + console.log('DEBUG: Fetching MCP sessions from:', mcpSessionUrl) + console.log('DEBUG: Looking for chat ID:', id) + + const mcpSessionsResp = await fetch(mcpSessionUrl) + let sessionId = undefined + + console.log('DEBUG: MCP sessions response:', { + ok: mcpSessionsResp.ok, + status: mcpSessionsResp.status, + statusText: mcpSessionsResp.statusText, + headers: Object.fromEntries(mcpSessionsResp.headers.entries()) + }) + + if (mcpSessionsResp.ok) { + const body = await mcpSessionsResp.json() + console.log('DEBUG: MCP sessions body:', body) + console.log('DEBUG: Looking for body[id]:', body[id]) + console.log('DEBUG: Looking for body.mcpSessions[id]:', body.mcpSessions ? body.mcpSessions[id] : 'mcpSessions not found') + + // Try both formats to see which one works + if (body.mcpSessions && body.mcpSessions[id]) { + sessionId = body.mcpSessions[id] + console.log('DEBUG: Found sessionId in body.mcpSessions[id]:', sessionId) + } else if (body[id]) { + sessionId = body[id] + console.log('DEBUG: Found sessionId in body[id]:', sessionId) + } + } else { + console.error('DEBUG: MCP sessions fetch failed:', await mcpSessionsResp.text()) + } + + console.log('DEBUG: Final sessionId for MCPSessionManager:', sessionId) + const mcpSession = new MCPSessionManager(MCP_BASE_URL, userId, id, sessionId) + + return createDataStreamResponse({ + execute: async (dataStream) => { + const system = systemPrompt({ selectedChatModel }) + await streamText( + { dataStream, userMessage }, + { + model: myProvider.languageModel(selectedChatModel), + system, + messages, + maxSteps: 20, + experimental_transform: smoothStream({ chunking: "word" }), + experimental_generateMessageId: generateUUID, + getTools: () => mcpSession.tools({ useCache: false }), + onFinish: async ({ response }) => { + if (userId && shouldPersistData()) { + try { + const assistantId = getTrailingMessageId({ + messages: response.messages.filter( + (message) => message.role === "assistant" + ), + }) + + if (!assistantId) { + throw new Error("No assistant message found!") + } + + const [, assistantMessage] = appendResponseMessages({ + messages: [userMessage], + responseMessages: response.messages, + }) + + await saveMessages({ + messages: [ + { + id: assistantId, + chatId: id, + role: assistantMessage.role, + parts: assistantMessage.parts, + attachments: + assistantMessage.experimental_attachments ?? [], + createdAt: new Date(), + }, + ], + }) + } catch (error) { + console.error("Failed to save chat") + } + } + }, + experimental_telemetry: { + isEnabled: isProductionEnvironment, + functionId: "stream-text", + }, + } + ) + }, + onError: (error) => { + console.error("Error:", error) + return "Oops, an error occured!" + }, + }) + } catch (error) { + console.error("Chat API Error:", error) + return new Response("An error occurred while processing your request!", { + status: 500, + }) + } +} + +export async function DELETE(request: Request) { + const { searchParams } = new URL(request.url) + const id = searchParams.get("id") + + if (!id) { + return new Response("Not Found", { status: 404 }) + } + + const session = await getEffectiveSession() + + if (!session || !session.user) { + return new Response("Unauthorized", { status: 401 }) + } + + const userId = session.user.id + + // In dev mode without auth, just return success without deleting + if (!shouldPersistData()) { + return new Response("Chat deleted", { status: 200 }) + } + + try { + const chat = await getChatById({ id }) + + if (chat.userId !== userId) { + return new Response("Unauthorized", { status: 401 }) + } + + await deleteChatById({ id }) + + return new Response("Chat deleted", { status: 200 }) + } catch (error) { + return new Response("An error occurred while processing your request!", { + status: 500, + }) + } +} diff --git a/examples/chat-groq/app/(chat)/api/chat/streamText.ts b/examples/chat-groq/app/(chat)/api/chat/streamText.ts new file mode 100644 index 0000000..7f04259 --- /dev/null +++ b/examples/chat-groq/app/(chat)/api/chat/streamText.ts @@ -0,0 +1,95 @@ +import { generateUUID, getTrailingMessageId } from "@/lib/utils" +import { + streamText as _streamText, + appendResponseMessages, + DataStreamWriter, + ToolSet, + UIMessage, + Message, + smoothStream, +} from "ai" + +export const streamText = async ( + { + dataStream, + userMessage, + }: { dataStream: DataStreamWriter; userMessage: UIMessage }, + args: Omit[0], "tools"> & { + getTools: () => Promise + } +) => { + const { + maxSteps = 1, + maxRetries, + messages: _messages, + getTools, + ...rest + } = args + // Convert UI messages to proper Message objects with IDs if needed + let messages = (_messages ?? []).map((msg) => + "id" in msg ? msg : { ...msg, id: generateUUID() } + ) as Message[] + + for (let steps = 0; steps < maxSteps; steps++) { + const cont = await new Promise(async (resolve, reject) => { + const tools = await getTools() + console.log(">> Using tools", Object.keys(tools).join(", ")) + const result = _streamText({ + ...rest, + messages, + tools, + experimental_transform: [ + smoothStream({ + chunking: /\s*\S+\s*/m, + delayInMs: 0 + }) + ], + onFinish: async (event) => { + console.log(">> Finish reason", event.finishReason) + + switch (event.finishReason) { + case "stop": + case "content-filter": + case "error": + resolve(false) + break + case "length": + case "tool-calls": + case "other": + case "unknown": + default: + break + } + + const assistantId = getTrailingMessageId({ + messages: event.response.messages.filter( + (message) => message.role === "assistant" + ), + }) + + if (!assistantId) { + throw new Error("No assistant message found!") + } + + messages = appendResponseMessages({ + messages, + responseMessages: event.response.messages, + }) + await rest.onFinish?.(event) + resolve(true) + }, + }) + + result.consumeStream() + + result.mergeIntoDataStream(dataStream, { + sendReasoning: true, + }) + }) + + if (!cont) { + console.log("Ending loop", steps) + break + } + } +} diff --git a/examples/chat-groq/app/(chat)/api/document/route.ts b/examples/chat-groq/app/(chat)/api/document/route.ts new file mode 100644 index 0000000..942940c --- /dev/null +++ b/examples/chat-groq/app/(chat)/api/document/route.ts @@ -0,0 +1,104 @@ +import { getEffectiveSession } from '@/lib/auth-utils'; +import { ArtifactKind } from '@/components/artifact'; +import { + deleteDocumentsByIdAfterTimestamp, + getDocumentsById, + saveDocument, +} from '@/lib/db/queries'; + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url); + const id = searchParams.get('id'); + + if (!id) { + return new Response('Missing id', { status: 400 }); + } + + const session = await getEffectiveSession(); + + if (!session || !session.user) { + return new Response('Unauthorized', { status: 401 }); + } + + const documents = await getDocumentsById({ id }); + + const [document] = documents; + + if (!document) { + return new Response('Not Found', { status: 404 }); + } + + if (document.userId !== session.user.id) { + return new Response('Unauthorized', { status: 401 }); + } + + return Response.json(documents, { status: 200 }); +} + +export async function POST(request: Request) { + const { searchParams } = new URL(request.url); + const id = searchParams.get('id'); + + if (!id) { + return new Response('Missing id', { status: 400 }); + } + + const session = await getEffectiveSession(); + + if (!session) { + return new Response('Unauthorized', { status: 401 }); + } + + const { + content, + title, + kind, + }: { content: string; title: string; kind: ArtifactKind } = + await request.json(); + + if (session.user?.id) { + const document = await saveDocument({ + id, + content, + title, + kind, + userId: session.user.id, + }); + + return Response.json(document, { status: 200 }); + } + + return new Response('Unauthorized', { status: 401 }); +} + +export async function PATCH(request: Request) { + const { searchParams } = new URL(request.url); + const id = searchParams.get('id'); + + const { timestamp }: { timestamp: string } = await request.json(); + + if (!id) { + return new Response('Missing id', { status: 400 }); + } + + const session = await getEffectiveSession(); + + if (!session || !session.user) { + return new Response('Unauthorized', { status: 401 }); + } + + const documents = await getDocumentsById({ id }); + + const [document] = documents; + + if (document.userId !== session.user.id) { + return new Response('Unauthorized', { status: 401 }); + } + + await deleteDocumentsByIdAfterTimestamp({ + id, + timestamp: new Date(timestamp), + }); + + return new Response('Deleted', { status: 200 }); +} diff --git a/examples/chat-groq/app/(chat)/api/files/upload/route.ts b/examples/chat-groq/app/(chat)/api/files/upload/route.ts new file mode 100644 index 0000000..38bc607 --- /dev/null +++ b/examples/chat-groq/app/(chat)/api/files/upload/route.ts @@ -0,0 +1,68 @@ +import { put } from '@vercel/blob'; +import { NextResponse } from 'next/server'; +import { z } from 'zod'; + +import { getEffectiveSession } from '@/lib/auth-utils'; + +// Use Blob instead of File since File is not available in Node.js environment +const FileSchema = z.object({ + file: z + .instanceof(Blob) + .refine((file) => file.size <= 5 * 1024 * 1024, { + message: 'File size should be less than 5MB', + }) + // Update the file type based on the kind of files you want to accept + .refine((file) => ['image/jpeg', 'image/png'].includes(file.type), { + message: 'File type should be JPEG or PNG', + }), +}); + +export async function POST(request: Request) { + const session = await getEffectiveSession(); + + if (!session) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + if (request.body === null) { + return new Response('Request body is empty', { status: 400 }); + } + + try { + const formData = await request.formData(); + const file = formData.get('file') as Blob; + + if (!file) { + return NextResponse.json({ error: 'No file uploaded' }, { status: 400 }); + } + + const validatedFile = FileSchema.safeParse({ file }); + + if (!validatedFile.success) { + const errorMessage = validatedFile.error.errors + .map((error) => error.message) + .join(', '); + + return NextResponse.json({ error: errorMessage }, { status: 400 }); + } + + // Get filename from formData since Blob doesn't have name property + const filename = (formData.get('file') as File).name; + const fileBuffer = await file.arrayBuffer(); + + try { + const data = await put(`${filename}`, fileBuffer, { + access: 'public', + }); + + return NextResponse.json(data); + } catch (error) { + return NextResponse.json({ error: 'Upload failed' }, { status: 500 }); + } + } catch (error) { + return NextResponse.json( + { error: 'Failed to process request' }, + { status: 500 }, + ); + } +} diff --git a/examples/chat-groq/app/(chat)/api/history/route.ts b/examples/chat-groq/app/(chat)/api/history/route.ts new file mode 100644 index 0000000..eae84c4 --- /dev/null +++ b/examples/chat-groq/app/(chat)/api/history/route.ts @@ -0,0 +1,19 @@ +import { getChatsByUserId } from '@/lib/db/queries'; +import { getEffectiveSession, shouldPersistData } from '@/lib/auth-utils'; + +export async function GET() { + const session = await getEffectiveSession(); + + if (!session || !session.user) { + return Response.json('Unauthorized!', { status: 401 }); + } + + // In dev mode without auth, return empty history + if (!shouldPersistData()) { + return Response.json([]); + } + + // biome-ignore lint: Forbidden non-null assertion. + const chats = await getChatsByUserId({ id: session.user.id! }); + return Response.json(chats); +} diff --git a/examples/chat-groq/app/(chat)/api/suggestions/route.ts b/examples/chat-groq/app/(chat)/api/suggestions/route.ts new file mode 100644 index 0000000..05de549 --- /dev/null +++ b/examples/chat-groq/app/(chat)/api/suggestions/route.ts @@ -0,0 +1,33 @@ +import { getSuggestionsByDocumentId } from '@/lib/db/queries'; +import { getEffectiveSession } from '@/lib/auth-utils'; + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url); + const documentId = searchParams.get('documentId'); + + if (!documentId) { + return new Response('Not Found', { status: 404 }); + } + + const session = await getEffectiveSession(); + + if (!session || !session.user) { + return new Response('Unauthorized', { status: 401 }); + } + + const suggestions = await getSuggestionsByDocumentId({ + documentId, + }); + + const [suggestion] = suggestions; + + if (!suggestion) { + return Response.json([], { status: 200 }); + } + + if (suggestion.userId !== session.user.id) { + return new Response('Unauthorized', { status: 401 }); + } + + return Response.json(suggestions, { status: 200 }); +} diff --git a/examples/chat-groq/app/(chat)/api/vote/route.ts b/examples/chat-groq/app/(chat)/api/vote/route.ts new file mode 100644 index 0000000..175f9e8 --- /dev/null +++ b/examples/chat-groq/app/(chat)/api/vote/route.ts @@ -0,0 +1,78 @@ +import { getChatById, getVotesByChatId, voteMessage } from '@/lib/db/queries'; +import { getEffectiveSession, shouldPersistData } from '@/lib/auth-utils'; + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url); + const chatId = searchParams.get('chatId'); + + if (!chatId) { + return new Response('chatId is required', { status: 400 }); + } + + const session = await getEffectiveSession(); + + if (!session || !session.user) { + return new Response('Unauthorized', { status: 401 }); + } + + // In dev mode without auth, return empty votes + if (!shouldPersistData()) { + return Response.json([], { status: 200 }); + } + + const chat = await getChatById({ id: chatId }); + + if (!chat) { + return new Response('Chat not found', { status: 404 }); + } + + if (chat.userId !== session.user.id) { + return new Response('Unauthorized', { status: 401 }); + } + + const votes = await getVotesByChatId({ id: chatId }); + + return Response.json(votes, { status: 200 }); +} + +export async function PATCH(request: Request) { + const { + chatId, + messageId, + type, + }: { chatId: string; messageId: string; type: 'up' | 'down' } = + await request.json(); + + if (!chatId || !messageId || !type) { + return new Response('messageId and type are required', { status: 400 }); + } + + const session = await getEffectiveSession(); + + if (!session || !session.user) { + return new Response('Unauthorized', { status: 401 }); + } + + // In dev mode without auth, just return success without persisting + if (!shouldPersistData()) { + return new Response('Message voted', { status: 200 }); + } + + const chat = await getChatById({ id: chatId }); + + if (!chat) { + return new Response('Chat not found', { status: 404 }); + } + + if (chat.userId !== session.user.id) { + return new Response('Unauthorized', { status: 401 }); + } + + await voteMessage({ + chatId, + messageId, + type: type, + }); + + return new Response('Message voted', { status: 200 }); +} diff --git a/examples/chat-groq/app/(chat)/chat/[id]/page.tsx b/examples/chat-groq/app/(chat)/chat/[id]/page.tsx new file mode 100644 index 0000000..a2abda5 --- /dev/null +++ b/examples/chat-groq/app/(chat)/chat/[id]/page.tsx @@ -0,0 +1,186 @@ +import { cookies } from 'next/headers'; +import { notFound } from 'next/navigation'; +import type { Metadata } from 'next'; + +import { auth } from '@/app/(auth)/auth'; +import { Chat } from '@/components/chat'; +import { getChatById, getMessagesByChatId } from '@/lib/db/queries'; +import { DataStreamHandler } from '@/components/data-stream-handler'; +import { DEFAULT_CHAT_MODEL } from '@/lib/ai/models'; +import { DBMessage } from '@/lib/db/schema'; +import { Attachment, UIMessage } from 'ai'; +import { BASE_METADATA, BASE_TITLE, isAuthDisabled } from '@/lib/constants'; +import { getEffectiveSession, shouldPersistData } from '@/lib/auth-utils'; +import { hasValidAPIKeys } from '@/lib/ai/api-keys'; + +export async function generateMetadata( + { params }: { params: { id: string } } +): Promise { + const { id } = await params; + + // In dev mode without auth, skip database lookup and return default metadata + if (!shouldPersistData()) { + return BASE_METADATA; + } + + const chat = await getChatById({ id }); + + if (!chat) { + // When chat is not found, fall back to the default metadata + return BASE_METADATA; + } + + // Find the first user message to use as description + const messages = await getMessagesByChatId({ id }); + const firstUserMessage = messages.find(msg => msg.role === 'user'); + const description = firstUserMessage + ? (firstUserMessage.parts.find(part => part.type === 'text')?.text || BASE_TITLE) + : BASE_TITLE; + + // Trim description if too long + const trimmedDescription = description.length > 160 + ? description.substring(0, 157) + '...' + : description; + + const title = `${BASE_TITLE}`; + + // For dynamic routes, we need to use relative URLs, not the hardcoded base URL + return { + ...BASE_METADATA, + // Remove the metadataBase to use the default, which is the current URL + metadataBase: null, + title: title, + description: trimmedDescription, + openGraph: { + ...BASE_METADATA.openGraph, + title: title, + description: trimmedDescription, + // Use relative URLs that will be resolved against the current URL + images: [ + { + url: '/opengraph-image.png', + width: 1200, + height: 630, + alt: 'Pipedream Chat', + } + ], + }, + twitter: { + ...BASE_METADATA.twitter, + title: title, + description: trimmedDescription, + // Use relative URLs that will be resolved against the current URL + images: [ + { + url: '/twitter-image.png', + width: 1200, + height: 630, + alt: 'Pipedream Chat', + } + ], + }, + }; +} + +export default async function Page(props: { params: Promise<{ id: string }> }) { + const params = await props.params; + const { id } = params; + const hasAPIKeys = hasValidAPIKeys(); + + // In dev mode without auth, create a fresh chat with no messages + if (!shouldPersistData()) { + const session = await getEffectiveSession(); + const cookieStore = await cookies(); + const chatModelFromCookie = cookieStore.get('chat-model'); + + return ( + <> + + + + ); + } + + const chat = await getChatById({ id }); + + if (!chat) { + notFound(); + } + + const session = await getEffectiveSession(); + + if (chat.visibility === 'private') { + if (!session || !session.user) { + return notFound(); + } + + if (session.user.id !== chat.userId) { + return notFound(); + } + } + + const messagesFromDb = await getMessagesByChatId({ + id, + }); + + function convertToUIMessages(messages: Array): Array { + return messages.map((message) => { + const textPart = Array.isArray(message.parts) + ? message.parts.find((part: any) => part?.type === 'text' && typeof part.text === 'string') + : undefined; + + // need to fill in content so anthropic doesn't blow up + // example message: all messages must have non-empty content except for the optional final assistant message + const content = textPart?.text ?? ''; + + return { + id: message.id, + parts: message.parts as UIMessage['parts'], + role: message.role as UIMessage['role'], + content, + createdAt: message.createdAt, + experimental_attachments: (message.attachments as Array) ?? [], + }; + }); + } + + const cookieStore = await cookies(); + const chatModelFromCookie = cookieStore.get('chat-model'); + + if (!chatModelFromCookie) { + return ( + <> + + + + ); + } + + return ( + <> + + + + ); +} diff --git a/examples/chat-groq/app/(chat)/layout.tsx b/examples/chat-groq/app/(chat)/layout.tsx new file mode 100644 index 0000000..44c1969 --- /dev/null +++ b/examples/chat-groq/app/(chat)/layout.tsx @@ -0,0 +1,53 @@ +import { cookies } from 'next/headers'; + +import { AppSidebar } from '@/components/app-sidebar'; +import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar'; +import { auth } from '../(auth)/auth'; +import Script from 'next/script'; +import { SessionProvider } from '@/components/session-provider'; +import { SignedOutHeader } from '@/components/signed-out-header'; +import { isAuthDisabled, isPersistenceDisabled } from '@/lib/constants'; +import { createGuestSession } from '@/lib/utils'; + +export const experimental_ppr = true; + +export default async function Layout({ + children, +}: { + children: React.ReactNode; +}) { + const [rawSession, cookieStore] = await Promise.all([auth(), cookies()]); + const isCollapsed = cookieStore.get('sidebar:state')?.value !== 'true'; + + // Use effective session (real or guest based on auth requirement) + const session = isAuthDisabled ? createGuestSession() : rawSession; + const isSignedIn = !!session?.user; + + return ( + <> +