diff --git a/.env.example b/.env.example index 6a40fdd61a..165b88e75c 100644 --- a/.env.example +++ b/.env.example @@ -1,19 +1,84 @@ # Generate a random secret: https://generate-secret.vercel.app/32 or `openssl rand -base64 32` AUTH_SECRET=**** -# The following keys below are automatically created and -# added to your environment when you deploy on vercel +# Google OAuth (for Google sign-in) +GOOGLE_CLIENT_ID=**** +GOOGLE_CLIENT_SECRET=**** -# Get your xAI API Key here for chat and image models: https://console.x.ai/ +# xAI API Key for chat and image models: https://console.x.ai/ XAI_API_KEY=**** -# Instructions to create a Vercel Blob Store here: https://vercel.com/docs/storage/vercel-blob -BLOB_READ_WRITE_TOKEN=**** +# PostgreSQL database - used by both client and Mastra +# Client: Chat history, votes, documents, user data +# Mastra: Agent memory, workflows, traces, participants data +# For local development with Docker postgres container: +DATABASE_URL="postgresql://postgres:postgres@localhost:5432/labs_asp_dev" +# For Docker-to-Docker communication (used inside containers): +# DATABASE_URL="postgresql://postgres:postgres@postgres:5432/labs_asp_dev" -# Instructions to create a PostgreSQL database here: https://vercel.com/docs/storage/vercel-postgres/quickstart -POSTGRES_URL=**** +# OpenAI API Key (primary AI provider) +OPENAI_API_KEY=**** +# Alternative API keys (optional) +ANTHROPIC_API_KEY=**** +GOOGLE_GENERATIVE_AI_API_KEY=**** +EXA_API_KEY=**** -# Instructions to create a Redis store here: -# https://vercel.com/docs/redis -REDIS_URL=**** +# Google Cloud Configuration +GOOGLE_VERTEX_LOCATION=**** +GOOGLE_VERTEX_PROJECT=**** +GOOGLE_APPLICATION_CREDENTIALS=./vertex-ai-credentials.json +GOOGLE_CLOUD_PROJECT=**** + +# Google Cloud Storage for file uploads +GCS_BUCKET_NAME=**** + +# Mastra Backend API for web automation +# MASTRA_SERVER_URL is used by the client to connect to the Mastra backend +# For local development: http://localhost:4111 +# For Docker deployment: http://localhost:4111 (external port mapping) +MASTRA_API_URL=**** +MASTRA_JWT_TOKEN=**** +MASTRA_SERVER_URL=http://localhost:4111 + +# NEXT_PUBLIC_MASTRA_SERVER_URL is required for client-side requests to Mastra +# This MUST be set at build time for Next.js to embed it in the client bundle +# For local development: http://localhost:4111 +# For Docker deployment: http://localhost:4111 (external port mapping) +NEXT_PUBLIC_MASTRA_SERVER_URL=http://localhost:4111 + +# Upstash Redis for shared links +# Create a Redis database at https://console.upstash.com/ +UPSTASH_REDIS_REST_URL=**** +UPSTASH_REDIS_REST_TOKEN=**** + +# Microsoft login +AUTH_MICROSOFT_ENTRA_ID_ID=*** +AUTH_MICROSOFT_ENTRA_ID_SECRET=*** +AUTH_MICROSOFT_ENTRA_ID_ISSUER=https://login.microsoftonline.com/common/v2.0 + +# Cloudflare Verified Bots - Ed25519 private key in PEM format +# Used to sign the /.well-known/http-message-signatures-directory response +CLOUDFLARE_BOT_PRIVATE_KEY=**** + +# Apricot API Information +# Prod and sandbox require separate credentials (different client_id/secret for each) +# The correct credentials are selected based on ENVIRONMENT variable +APRICOT_API_BASE_URL=https://f5r-api.iws.sidekick.solutions/apricot +APRICOT_ORG_ID_SANDBOX=**** +APRICOT_ORG_ID_PROD=**** +# For local dev, use sandbox credentials: +APRICOT_CLIENT_ID=**** +APRICOT_CLIENT_SECRET=**** + +# Feature flag for AI SDK agent vs Mastra +USE_AI_SDK_AGENT=false +NEXT_PUBLIC_USE_AI_SDK_AGENT=false +# Kernel.sh API key for remote browser management (used when USE_AI_SDK_AGENT=true) +KERNEL_API_KEY=**** + +# Feature flag for guest login in preview environments +# When true, enables a guest login form that bypasses OAuth +# Only enabled for preview-pr-* deployments +USE_GUEST_LOGIN=false +NEXT_PUBLIC_USE_GUEST_LOGIN=false diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000000..d3ed4788db --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,37 @@ +version: 2 +updates: + # npm dependencies + - package-ecosystem: "npm" + directory: "/" + target-branch: "labs-asp" + schedule: + interval: "weekly" + day: "monday" + time: "09:00" + open-pull-requests-limit: 5 + groups: + all-dependencies: + patterns: + - "*" + update-types: + - "minor" + - "patch" + labels: + - "dependencies" + commit-message: + prefix: "chore(deps)" + + # GitHub Actions + - package-ecosystem: "github-actions" + directory: "/" + target-branch: "labs-asp" + schedule: + interval: "weekly" + day: "monday" + time: "09:00" + open-pull-requests-limit: 3 + labels: + - "dependencies" + - "ci" + commit-message: + prefix: "chore(ci)" diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000000..84b9969e81 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,29 @@ +## Checklist + +- [ ] Update PR Title to follow this pattern: `[INTENT]:[MESSAGE]` + > The title will become a one-line commit message in the git log, so be as concise and specific as possible -- refer to [How to Write a Git Commit Message](https://cbea.ms/git-commit/). Prepend [Conventional Commit](https://www.conventionalcommits.org/en/v1.0.0/#summary) intent (`fix:`, `feat:`, `chore:`, `ci:`, `docs:`, `style:`, `refactor:`, `perf:`, `test:`). + +## Ticket + +Resolves #{TICKET NUMBER or URL or description} or Adds {new capability or feature} + +## Changes + +> What was added, updated, or removed in this PR. +> Prefer small PRs; try to limit to 300 lines of code changes +> * https://blog.logrocket.com/using-stacked-pull-requests-in-github/ +> * https://opensource.com/article/18/6/anatomy-perfect-pull-request +> * https://developers.google.com/blockly/guides/modify/contribute/write_a_good_pr + +## Testing + +> What was tested? How did you test it? Add unit tests for new functions, integration tests for API/database interactions, and E2E tests for critical user flows. +> * https://martinfowler.com/articles/practical-test-pyramid.html +> * https://blog.logrocket.com/javascript-testing-best-practices/ +> * https://www.testim.io/blog/typescript-unit-testing-101/ + +## Context for reviewers + +> Background context, more in-depth details of the implementation, and anything else you'd like to call out or ask reviewers. +> Add comments to your code under the "Files Changed" tab to explain complex logic or code +> * https://betterprogramming.pub/how-to-make-a-perfect-pull-request-3578fb4c112 \ No newline at end of file diff --git a/.gitignore b/.gitignore index 54b31f9fcf..b0781a91c7 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ node_modules # testing coverage +artifacts/session_* # next.js .next/ @@ -41,3 +42,5 @@ yarn-error.log* /playwright-report/ /blob-report/ /playwright/* +.mastra/ +vertex-ai-credentials.json \ No newline at end of file diff --git a/app/(auth)/api/auth/guest/route.ts b/app/(auth)/api/auth/guest/route.ts index 25af1fa7b7..093b5a01cb 100644 --- a/app/(auth)/api/auth/guest/route.ts +++ b/app/(auth)/api/auth/guest/route.ts @@ -5,7 +5,13 @@ import { NextResponse } from 'next/server'; export async function GET(request: Request) { const { searchParams } = new URL(request.url); - const redirectUrl = searchParams.get('redirectUrl') || '/'; + let redirectUrl = searchParams.get('redirectUrl') || '/'; + + // Fix localhost redirects to use the current host + if (redirectUrl.includes('localhost:3000')) { + const currentUrl = new URL(request.url); + redirectUrl = redirectUrl.replace('http://localhost:3000', `${currentUrl.protocol}//${currentUrl.host}`); + } const token = await getToken({ req: request, diff --git a/app/(auth)/auth.config.ts b/app/(auth)/auth.config.ts index b7d7d50bf1..923f6a744e 100644 --- a/app/(auth)/auth.config.ts +++ b/app/(auth)/auth.config.ts @@ -4,6 +4,7 @@ export const authConfig = { pages: { signIn: '/login', newUser: '/', + error: '/login', }, providers: [ // added later in auth.ts since it requires bcrypt which is only compatible with Node.js diff --git a/app/(auth)/auth.ts b/app/(auth)/auth.ts index 9185248db0..07b7e073ca 100644 --- a/app/(auth)/auth.ts +++ b/app/(auth)/auth.ts @@ -1,12 +1,21 @@ import { compare } from 'bcrypt-ts'; import NextAuth, { type DefaultSession } from 'next-auth'; import Credentials from 'next-auth/providers/credentials'; -import { createGuestUser, getUser } from '@/lib/db/queries'; +import Google from 'next-auth/providers/google'; +import MicrosoftEntraID from "next-auth/providers/microsoft-entra-id"; +import { getUser, upsertOAuthUser, ensureUserExists } from '@/lib/db/queries'; import { authConfig } from './auth.config'; import { DUMMY_PASSWORD } from '@/lib/constants'; import type { DefaultJWT } from 'next-auth/jwt'; -export type UserType = 'guest' | 'regular'; +// Feature flag for guest login in preview environments +const useGuestLogin = process.env.USE_GUEST_LOGIN === 'true'; + +// Fixed guest user for preview environments (using a valid UUID) +const GUEST_USER_ID = '00000000-0000-0000-0000-000000000001'; +const GUEST_USER_EMAIL = 'guest@preview.local'; + +export type UserType = 'regular'; declare module 'next-auth' { interface Session extends DefaultSession { @@ -37,9 +46,38 @@ export const { signOut, } = NextAuth({ ...authConfig, + trustHost: true, providers: [ + Google({ + clientId: process.env.GOOGLE_CLIENT_ID!, + clientSecret: process.env.GOOGLE_CLIENT_SECRET!, + }), + MicrosoftEntraID({ + clientId: process.env.AUTH_MICROSOFT_ENTRA_ID_ID, + clientSecret: process.env.AUTH_MICROSOFT_ENTRA_ID_SECRET, + issuer: process.env.AUTH_MICROSOFT_ENTRA_ID_ISSUER, + authorization: { + params: { + scope: "openid profile email User.Read" + } + }, + profile(profile) { + // Multi-tenant apps don't reliably return email claim, use preferred_username + return { + id: profile.sub, + name: profile.name, + email: profile.preferred_username || profile.email || profile.upn, + image: profile.picture, + type: 'regular' as const, + } + } + }), Credentials({ - credentials: {}, + name: 'credentials', + credentials: { + email: { label: 'Email', type: 'email' }, + password: { label: 'Password', type: 'password' } + }, async authorize({ email, password }: any) { const users = await getUser(email); @@ -62,20 +100,52 @@ export const { return { ...user, type: 'regular' }; }, }), + // Guest provider for preview environments - auto-logs in without credentials Credentials({ id: 'guest', + name: 'guest', credentials: {}, async authorize() { - const [guestUser] = await createGuestUser(); - return { ...guestUser, type: 'guest' }; + if (!useGuestLogin) { + return null; + } + + // Create or get the guest user + const guestUser = await ensureUserExists({ + id: GUEST_USER_ID, + email: GUEST_USER_EMAIL, + }); + + return { ...guestUser, type: 'regular' as const }; }, }), ], callbacks: { - async jwt({ token, user }) { + async signIn({ user, account, profile }) { + // Handle OAuth sign-in with domain validation + if (account?.provider === 'google' || account?.provider === 'microsoft-entra-id') { + const email = user.email?.toLowerCase() || ''; + const allowedDomains = ['@navapbc.com', '@rivco.org', '@navapbc.onmicrosoft.com', '@amplifi.org']; + const isAllowedDomain = allowedDomains.some(domain => email.endsWith(domain)); + + if (!isAllowedDomain) { + return false; + } + + const dbUser = await upsertOAuthUser({ + email: user.email!, + name: user.name, + image: user.image, + }); + // Use the DB-generated user ID, not the OAuth provider's sub claim + user.id = dbUser.id; + } + return true; + }, + async jwt({ token, user, account }) { if (user) { token.id = user.id as string; - token.type = user.type; + token.type = account?.provider === 'google' || account?.provider === 'microsoft-entra-id' ? 'regular' : user.type; } return token; diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx index 33e9e82709..62462cca50 100644 --- a/app/(auth)/login/page.tsx +++ b/app/(auth)/login/page.tsx @@ -1,77 +1,168 @@ 'use client'; -import Link from 'next/link'; -import { useRouter } from 'next/navigation'; -import { useActionState, useEffect, useState } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { useState, useEffect, Suspense } from 'react'; import { toast } from '@/components/toast'; +import { signIn } from 'next-auth/react'; +import { MicrosoftLogo } from '@/components/icons/MicrosoftLogo'; +import { GoogleLogo } from '@/components/icons/GoogleLogo'; -import { AuthForm } from '@/components/auth-form'; -import { SubmitButton } from '@/components/submit-button'; +// Feature flag for guest login in preview environments +const useGuestLogin = process.env.NEXT_PUBLIC_USE_GUEST_LOGIN === 'true'; -import { login, type LoginActionState } from '../actions'; -import { useSession } from 'next-auth/react'; - -export default function Page() { +function ErrorHandler() { const router = useRouter(); + const searchParams = useSearchParams(); - const [email, setEmail] = useState(''); - const [isSuccessful, setIsSuccessful] = useState(false); + useEffect(() => { + const error = searchParams.get('error'); + if (error) { + toast({ + type: 'error', + description: 'Access denied', + }); + // Clear the error from URL without refresh + router.replace('/login', { scroll: false }); + } + }, [searchParams, router]); - const [state, formAction] = useActionState( - login, - { - status: 'idle', - }, - ); + return null; +} + +function LoginContent() { + const router = useRouter(); + const searchParams = useSearchParams(); + const [loadingMethod, setLoadingMethod] = useState<'microsoft' | 'google' | 'guest' | null>(null); + const [hasAutoSignedIn, setHasAutoSignedIn] = useState(false); - const { update: updateSession } = useSession(); + // Use callbackUrl from URL params, default to /home + const callbackUrl = searchParams.get('callbackUrl') || '/home'; + // Auto sign-in as guest when feature flag is enabled useEffect(() => { - if (state.status === 'failed') { + const doGuestSignIn = async () => { + if (useGuestLogin && !hasAutoSignedIn) { + setHasAutoSignedIn(true); + setLoadingMethod('guest'); + + // Use redirect: false to handle redirect manually + const result = await signIn('guest', { redirect: false }); + + if (result?.ok) { + // Manual redirect to ensure we stay on the correct host + router.push(callbackUrl); + } else { + toast({ + type: 'error', + description: 'Failed to sign in as guest', + }); + setLoadingMethod(null); + } + } + }; + + doGuestSignIn(); + }, [callbackUrl, hasAutoSignedIn, router]); + + const handleGoogleLogin = async () => { + setLoadingMethod('google'); + try { + await signIn('google', { callbackUrl }); + } catch (error) { toast({ type: 'error', - description: 'Invalid credentials!', + description: 'Failed to sign in with Google', }); - } else if (state.status === 'invalid_data') { + setLoadingMethod(null); + } + }; + + const handleMicrosoftLogin = async () => { + setLoadingMethod('microsoft'); + try { + await signIn('microsoft-entra-id', { callbackUrl }); + } catch (error) { toast({ type: 'error', - description: 'Failed validating your submission!', + description: 'Failed to sign in with Microsoft', }); - } else if (state.status === 'success') { - setIsSuccessful(true); - updateSession(); - router.refresh(); + setLoadingMethod(null); } - }, [state.status]); - - const handleSubmit = (formData: FormData) => { - setEmail(formData.get('email') as string); - formAction(formData); }; + // Show loading state when auto-signing in as guest + if (useGuestLogin) { + return ( +
+
+
+

+ Welcome +

+

+ Signing you in... +

+
+
+
+ ); + } + return ( -
-
-
-

Sign In

-

- Use your email and password to sign in +

+
+
+

+ Welcome

-
- - Sign in -

- {"Don't have an account? "} - - Sign up - - {' for free.'} +

+ Sign in to access the Form-Filling Assistant

-
+ + {/* Microsoft Login Button */} + + + {/* Google Login Button */} + +
); } + +export default function Page() { + return ( + + + + + ); +} diff --git a/app/(auth)/register/page.tsx b/app/(auth)/register/page.tsx index ab2ee82667..2aa43fbdc7 100644 --- a/app/(auth)/register/page.tsx +++ b/app/(auth)/register/page.tsx @@ -2,31 +2,34 @@ 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 { useActionState, useEffect, useRef, useState } from 'react'; import { toast } from '@/components/toast'; import { useSession } from 'next-auth/react'; +import { register, type RegisterActionState } from '../actions'; + export default function Page() { const router = useRouter(); - const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); const [isSuccessful, setIsSuccessful] = useState(false); + const hasHandledStatus = useRef(null); - const [state, formAction] = useActionState( + const [state, formAction, isPending] = useActionState( register, - { - status: 'idle', - }, + { status: 'idle' } ); const { update: updateSession } = useSession(); useEffect(() => { + // Prevent handling the same status multiple times + if (state.status === 'idle' || hasHandledStatus.current === state.status) { + return; + } + + hasHandledStatus.current = state.status; + if (state.status === 'user_exists') { toast({ type: 'error', description: 'Account already exists!' }); } else if (state.status === 'failed') { @@ -34,44 +37,68 @@ export default function Page() { } else if (state.status === 'invalid_data') { toast({ type: 'error', - description: 'Failed validating your submission!', + description: 'Invalid email or password (min 6 characters)', }); } else if (state.status === 'success') { toast({ type: 'success', description: 'Account created successfully!' }); - setIsSuccessful(true); updateSession(); - router.refresh(); + router.push('/home'); } - }, [state]); + }, [state.status, router, updateSession]); - const handleSubmit = (formData: FormData) => { - setEmail(formData.get('email') as string); + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + const formData = new FormData(); + formData.append('email', email); + formData.append('password', password); formAction(formData); }; return ( -
-
-
-

Sign Up

-

- Create an account with your email and password +

+
+
+

+ Create Account

-
- - Sign Up -

- {'Already have an account? '} - + Sign up with your email and password +

+ +
+ setEmail(e.target.value)} + disabled={isPending || isSuccessful} + className="border border-border rounded-[8px] px-4 py-2 w-full bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring disabled:opacity-50" + /> + setPassword(e.target.value)} + disabled={isPending || isSuccessful} + className="border border-border rounded-[8px] px-4 py-2 w-full bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring disabled:opacity-50" + /> + +
+ +

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

-
+
); diff --git a/app/(chat)/api/chat/[id]/stream/route.ts b/app/(chat)/api/chat/[id]/stream/route.ts index 23e84885e6..58b46432a5 100644 --- a/app/(chat)/api/chat/[id]/stream/route.ts +++ b/app/(chat)/api/chat/[id]/stream/route.ts @@ -34,7 +34,7 @@ export async function GET( return new ChatSDKError('unauthorized:chat').toResponse(); } - let chat: Chat; + let chat: Chat | null; try { chat = await getChatById({ id: chatId }); diff --git a/app/(chat)/api/chat/route.ts b/app/(chat)/api/chat/route.ts index b7ea099a57..a33b5451cf 100644 --- a/app/(chat)/api/chat/route.ts +++ b/app/(chat)/api/chat/route.ts @@ -20,11 +20,12 @@ import { import { convertToUIMessages, generateUUID } from '@/lib/utils'; import { generateTitleFromUserMessage } from '../../actions'; import { createDocument } from '@/lib/ai/tools/create-document'; + import { updateDocument } from '@/lib/ai/tools/update-document'; import { requestSuggestions } from '@/lib/ai/tools/request-suggestions'; import { getWeather } from '@/lib/ai/tools/get-weather'; import { isProductionEnvironment } from '@/lib/constants'; -import { myProvider } from '@/lib/ai/providers'; +import { myProvider, webAutomationModel } from '@/lib/ai/providers'; import { entitlementsByUserType } from '@/lib/ai/entitlements'; import { postRequestBodySchema, type PostRequestBody } from './schema'; import { geolocation } from '@vercel/functions'; @@ -38,7 +39,15 @@ import type { ChatMessage } from '@/lib/types'; import type { ChatModel } from '@/lib/ai/models'; import type { VisibilityType } from '@/components/visibility-selector'; -export const maxDuration = 60; +// AI SDK web automation imports (used when USE_AI_SDK_AGENT=true) +import { apricotTools } from '@/lib/ai/tools/apricot'; +import { createBrowserTool } from '@/lib/ai/tools/browser'; +import { webAutomationSystemPrompt } from '@/lib/ai/prompts/web-automation'; + +// Feature flag for AI SDK agent vs Mastra +const useAiSdkAgent = process.env.USE_AI_SDK_AGENT === 'true'; + +export const maxDuration = 300; // 5 minutes for web automation tasks let globalStreamContext: ResumableStreamContext | null = null; @@ -91,7 +100,7 @@ export async function POST(request: Request) { return new ChatSDKError('unauthorized:chat').toResponse(); } - const userType: UserType = session.user.type; + const userType: UserType = session.user.type ?? 'regular'; const messageCount = await getMessageCountByUserId({ id: session.user.id, @@ -149,13 +158,77 @@ export async function POST(request: Request) { const streamId = generateUUID(); await createStreamId({ streamId, chatId: id }); + // Web automation model handling + if (selectedChatModel === 'web-automation-model') { + // Feature flag: if false, return error so client falls back to mastra-proxy + if (!useAiSdkAgent) { + return new ChatSDKError( + 'bad_request:api', + 'Web automation uses Mastra backend' + ).toResponse(); + } + + // Create session ID for browser isolation + const sessionId = `${id}-${session.user.id}`; + + const stream = createUIMessageStream({ + execute: async ({ writer: dataStream }) => { + const result = streamText({ + model: webAutomationModel, + system: webAutomationSystemPrompt, + messages: await convertToModelMessages(uiMessages), + tools: { + ...apricotTools, + browser: createBrowserTool(sessionId), + }, + stopWhen: stepCountIs(50), + experimental_telemetry: { + isEnabled: isProductionEnvironment, + functionId: 'web-automation-agent', + }, + }); + + result.consumeStream(); + dataStream.merge(result.toUIMessageStream()); + }, + generateId: generateUUID, + onFinish: async ({ messages }) => { + await saveMessages({ + messages: messages.map((message) => ({ + id: message.id, + role: message.role, + parts: message.parts, + createdAt: new Date(), + attachments: [], + chatId: id, + })), + }); + }, + onError: () => { + return 'Oops, an error occurred!'; + }, + }); + + const streamContext = getStreamContext(); + + if (streamContext) { + return new Response( + await streamContext.resumableStream(streamId, () => + stream.pipeThrough(new JsonToSseTransformStream()) + ) + ); + } + return new Response(stream.pipeThrough(new JsonToSseTransformStream())); + } + + // Default handling for other models const stream = createUIMessageStream({ - execute: ({ writer: dataStream }) => { + execute: async ({ writer: dataStream }) => { const result = streamText({ model: myProvider.languageModel(selectedChatModel), system: systemPrompt({ selectedChatModel, requestHints }), - messages: convertToModelMessages(uiMessages), - stopWhen: stepCountIs(5), + messages: await convertToModelMessages(uiMessages), + stopWhen: stepCountIs(50), experimental_activeTools: selectedChatModel === 'chat-model-reasoning' ? [] @@ -222,6 +295,9 @@ export async function POST(request: Request) { if (error instanceof ChatSDKError) { return error.toResponse(); } + + console.error('Unexpected error in chat API:', error); + return new ChatSDKError('internal_server_error:api').toResponse(); } } @@ -241,6 +317,10 @@ export async function DELETE(request: Request) { const chat = await getChatById({ id }); + if (!chat) { + return new ChatSDKError('not_found:chat').toResponse(); + } + if (chat.userId !== session.user.id) { return new ChatSDKError('forbidden:chat').toResponse(); } diff --git a/app/(chat)/api/chat/schema.ts b/app/(chat)/api/chat/schema.ts index 555ef8b95c..c88842d7da 100644 --- a/app/(chat)/api/chat/schema.ts +++ b/app/(chat)/api/chat/schema.ts @@ -21,7 +21,7 @@ export const postRequestBodySchema = z.object({ role: z.enum(['user']), parts: z.array(partSchema), }), - selectedChatModel: z.enum(['chat-model', 'chat-model-reasoning']), + selectedChatModel: z.enum(['chat-model', 'chat-model-reasoning', 'web-automation-model']), selectedVisibilityType: z.enum(['public', 'private']), }); diff --git a/app/(chat)/api/document/route.ts b/app/(chat)/api/document/route.ts index de4183673c..e03f359efb 100644 --- a/app/(chat)/api/document/route.ts +++ b/app/(chat)/api/document/route.ts @@ -4,6 +4,7 @@ import { deleteDocumentsByIdAfterTimestamp, getDocumentsById, saveDocument, + ensureUserExists, } from '@/lib/db/queries'; import { ChatSDKError } from '@/lib/errors'; @@ -56,6 +57,12 @@ export async function POST(request: Request) { return new ChatSDKError('not_found:document').toResponse(); } + // Ensure user exists in database (handles stale sessions) + await ensureUserExists({ + id: session.user.id, + email: session.user.email || '', + }); + const { content, title, diff --git a/app/(chat)/api/files/upload/route.ts b/app/(chat)/api/files/upload/route.ts index 699a4cbef8..e55a732eb0 100644 --- a/app/(chat)/api/files/upload/route.ts +++ b/app/(chat)/api/files/upload/route.ts @@ -1,8 +1,8 @@ -import { put } from '@vercel/blob'; import { NextResponse } from 'next/server'; import { z } from 'zod'; import { auth } from '@/app/(auth)/auth'; +import { uploadFile } from '@/lib/storage/gcs'; // Use Blob instead of File since File is not available in Node.js environment const FileSchema = z.object({ @@ -51,9 +51,7 @@ export async function POST(request: Request) { const fileBuffer = await file.arrayBuffer(); try { - const data = await put(`${filename}`, fileBuffer, { - access: 'public', - }); + const data = await uploadFile(filename, fileBuffer, file.type); return NextResponse.json(data); } catch (error) { diff --git a/app/(chat)/api/history/route.ts b/app/(chat)/api/history/route.ts index e28290c627..f537171638 100644 --- a/app/(chat)/api/history/route.ts +++ b/app/(chat)/api/history/route.ts @@ -1,6 +1,6 @@ import { auth } from '@/app/(auth)/auth'; import type { NextRequest } from 'next/server'; -import { getChatsByUserId } from '@/lib/db/queries'; +import { getChatsByUserId, getMastraThreadsByUserId } from '@/lib/db/queries'; import { ChatSDKError } from '@/lib/errors'; export async function GET(request: NextRequest) { @@ -23,12 +23,30 @@ export async function GET(request: NextRequest) { return new ChatSDKError('unauthorized:chat').toResponse(); } - const chats = await getChatsByUserId({ + // Get chats from both the client's Chat table and Mastra's threads + const { chats: clientChats } = await getChatsByUserId({ id: session.user.id, limit, startingAfter, endingBefore, }); - return Response.json(chats); + const mastraThreads = await getMastraThreadsByUserId({ + id: session.user.id, + limit, + }); + + // Merge and sort by most recent (updatedAt for Mastra threads, createdAt for client chats) + const allChats = [...clientChats, ...mastraThreads] + .sort((a, b) => { + const dateA = 'updatedAt' in a ? a.updatedAt : a.createdAt; + const dateB = 'updatedAt' in b ? b.updatedAt : b.createdAt; + return new Date(dateB).getTime() - new Date(dateA).getTime(); + }) + .slice(0, limit); + + return Response.json({ + chats: allChats, + hasMore: false, // Simplified for now + }); } diff --git a/app/(chat)/api/mastra-proxy/route.ts b/app/(chat)/api/mastra-proxy/route.ts new file mode 100644 index 0000000000..239bcbf149 --- /dev/null +++ b/app/(chat)/api/mastra-proxy/route.ts @@ -0,0 +1,70 @@ +// Server-side proxy to Mastra backend +// Solves CORS issues and allows dynamic backend URL configuration + +export const maxDuration = 300; // 5 minutes for web automation tasks + +export async function POST(request: Request) { + try { + // Get Mastra server URL from environment (runtime, not build-time) + // Use MASTRA_SERVER_URL (not NEXT_PUBLIC_*) because this is server-side only + // NEXT_PUBLIC_* vars are baked in at build time, we need runtime config + const mastraServerUrl = process.env.MASTRA_SERVER_URL || process.env.NEXT_PUBLIC_MASTRA_SERVER_URL; + + if (!mastraServerUrl) { + console.error('NEXT_PUBLIC_MASTRA_SERVER_URL is not set'); + return new Response( + JSON.stringify({ error: 'Mastra backend URL not configured' }), + { status: 500, headers: { 'Content-Type': 'application/json' } } + ); + } + + console.log('Proxying to Mastra at:', mastraServerUrl); + + // Forward the request body to Mastra backend + const body = await request.json(); + + // Check if the request is to stop the chat + if (body.action === 'stopChat') { + // Call the Mastra API to stop the chat with threadId and resourceId + console.log('Stopping chat for thread:', body.threadId, 'and resource:', body.resourceId); + const stopResponse = await fetch(`${mastraServerUrl}/stop-chat`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + threadId: body.threadId, + resourceId: body.resourceId, + }), + }); + + console.log('Stop response:', stopResponse); + + return new Response(stopResponse.body, { + status: stopResponse.status, + headers: stopResponse.headers, + }); + } + + const response = await fetch(`${mastraServerUrl}/chat`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + }); + + // Return the response from Mastra as-is + // This preserves streaming if Mastra is streaming + return new Response(response.body, { + status: response.status, + headers: response.headers, + }); + } catch (error) { + console.error('Error proxying to Mastra:', error); + return new Response( + JSON.stringify({ error: 'Failed to connect to Mastra backend' }), + { status: 500, headers: { 'Content-Type': 'application/json' } } + ); + } +} diff --git a/app/(chat)/chat/[id]/page.tsx b/app/(chat)/chat/[id]/page.tsx index b9dc99d0f2..13af8dacf0 100644 --- a/app/(chat)/chat/[id]/page.tsx +++ b/app/(chat)/chat/[id]/page.tsx @@ -19,13 +19,9 @@ export default async function Page(props: { params: Promise<{ id: string }> }) { const session = await auth(); - if (!session) { - redirect('/api/auth/guest'); - } - if (chat.visibility === 'private') { - if (!session.user) { - return notFound(); + if (!session?.user) { + redirect('/login'); } if (session.user.id !== chat.userId) { diff --git a/app/(chat)/consent/page.tsx b/app/(chat)/consent/page.tsx new file mode 100644 index 0000000000..598a21013d --- /dev/null +++ b/app/(chat)/consent/page.tsx @@ -0,0 +1,24 @@ +'use client'; + +import { ConsentPage } from '@/components/consent-page'; +import { useRouter } from 'next/navigation'; + +export default function Consent() { + const router = useRouter(); + + const handleConsent = () => { + // Set cookie to indicate coming from consent flow + document.cookie = 'from-consent=true; path=/; max-age=60'; // 60 seconds expiry + // Set the model to web-automation-model for browser automation + document.cookie = 'chat-model=web-automation-model; path=/; max-age=60'; // 60 seconds expiry + // Navigate to the main chat page after consent is given + router.push('/'); + }; + + const handleNavigateHome = () => { + // Navigate to home page + router.push('/home'); + }; + + return ; +} diff --git a/app/(chat)/home/page.tsx b/app/(chat)/home/page.tsx new file mode 100644 index 0000000000..c0d9db81aa --- /dev/null +++ b/app/(chat)/home/page.tsx @@ -0,0 +1,128 @@ +'use client'; + +import { useRouter } from 'next/navigation'; +import Image from 'next/image'; + +// Image assets from Figma +const imgImage2 = "/illustration-cropped.png"; + +export default function LandingPage() { + const router = useRouter(); + return ( +
+ {/* Main Content Container */} +
+ {/* Hero Section */} +
+
+ {/* Image Container - On top for mobile, right side for desktop */} +
+
+
+ +
+
+
+ + {/* Content - Below image for mobile, left side for desktop */} +
+
+

+ Welcome! +

+

+ Form-Filling Assistant helps you and your clients complete benefit applications faster. +

+
+ + {/* Start Application Button */} +
+ +
+
+
+
+ + {/* How it works Section */} +
+

+ How it works +

+

+ This tool uses artificial intelligence (AI) to help you complete applications, while you stay in control. +

+ + {/* Step Cards */} +
+ {/* Step 1 */} +
+

+ step 1 +

+

+ Start and autofill +

+

+ AI autofills the application for you, using client data from your case management system. +

+
+ + {/* Step 2 */} +
+

+ step 2 +

+

+ Fill in any gaps +

+

+ You review and complete anything that's missing. The AI only adds what's already in your system. +

+
+ + {/* Step 3 */} +
+

+ step 3 +

+

+ Submit with confidence +

+

+ You submit the application once everything looks right. Nothing is submitted automatically. +

+
+
+
+ + {/* Questions Section */} +
+

+ Questions? +

+

+ Email + + labs@navapbc.com + + with any issues or feedback. +

+
+
+
+ ); +} diff --git a/app/(chat)/layout.tsx b/app/(chat)/layout.tsx index dfd9da2fdf..1d6cb40eb5 100644 --- a/app/(chat)/layout.tsx +++ b/app/(chat)/layout.tsx @@ -1,21 +1,13 @@ -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 { DataStreamProvider } from '@/components/data-stream-provider'; - -export const experimental_ppr = true; - -export default async function Layout({ - children, -}: { - children: React.ReactNode; -}) { - const [session, cookieStore] = await Promise.all([auth(), cookies()]); - const isCollapsed = cookieStore.get('sidebar:state')?.value !== 'true'; +import { cookies } from "next/headers"; +import Script from "next/script"; +import { Suspense } from "react"; +import { AppSidebar } from "@/components/app-sidebar"; +import { DataStreamProvider } from "@/components/data-stream-provider"; +import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"; +import { auth } from "../(auth)/auth"; +import { LayoutHeader } from "@/components/layout-header"; +export default function Layout({ children }: { children: React.ReactNode }) { return ( <>