A modern research assistant interface built with Next.js, React, TypeScript, TailwindCSS, and NVIDIA KUI Foundations.
The AI-Q Blueprint UI provides an accessible, feature-rich frontend for the AI-Q backend. It features:
- Next.js with App Router and Turbopack
- React with TypeScript (strict mode)
- KUI Foundations NVIDIA design components
- TailwindCSS for layout utilities
- Adapter-based architecture for clean separation of concerns
- Optional OAuth authentication (disabled by default)
- Node.js
- npm
- AI-Q Blueprint running (default:
http://localhost:8000)
npm installReview the .env config in the project root to ensure values are set correctly for local development.
Key variables for local development:
# Backend URL (must match where your backend is running)
BACKEND_URL=http://localhost:8000
# Skip authentication for local development (uses Default User)
REQUIRE_AUTH=false
# File upload settings (should match backend limits)
FILE_UPLOAD_ACCEPTED_TYPES=.pdf,.txt,.md,.docx,.pptx
FILE_UPLOAD_MAX_SIZE_MB=100
FILE_UPLOAD_MAX_FILE_COUNT=10
FILE_EXPIRATION_CHECK_INTERVAL_HOURS=24See .env.example for the full list of available frontend variables including authentication and file upload configuration.
Start e2e (from monorepo root)
cd ../../
./scripts/start_e2e.shNOTE: For UI development it may be more useful to use
./scripts/start_server_in_debug_mode.shwithnpm run devin separate terminals.
When running the backend with ./scripts/start_server_in_debug_mode.sh and the UI with npm run dev in a separate terminal, load the root env file in the UI terminal first:
set -a; source ../../deploy/.env; set +a
npm run devURLs:
- Frontend: http://localhost:3000
- Backend: http://localhost:8000
| Script | Description |
|---|---|
npm run dev |
Start gateway + Next.js dev server (with HMR) |
npm run build |
Build for production |
npm run start |
Start production server (gateway with WebSocket proxy) |
npm run lint |
Run ESLint |
npm run lint:fix |
Run ESLint with auto-fix |
npm run format |
Format code with Prettier |
npm run type-check |
Run TypeScript type checking |
npm run test |
Run tests once (Vitest) |
npm run test:watch |
Run tests in watch mode |
npm run test:ci |
Run tests with coverage |
src/
├── adapters/ # External interface boundaries
│ ├── api/ # Backend API clients (chat, documents, websocket, deep-research)
│ ├── auth/ # NextAuth configuration, session, and types
│ ├── datadog/ # Real User Monitoring integration (optional)
│ └── ui/ # KUI component re-exports, icons, logo
├── app/ # Next.js App Router pages and API routes
│ ├── api/ # Route handlers (auth, chat, health, proxy, jobs)
│ └── auth/ # Sign-in and error pages
├── features/ # Business logic modules
│ ├── chat/ # Chat functionality (components, hooks, store, types)
│ ├── documents/ # File upload, validation, and persistence
│ └── layout/ # App layout components (panels, tabs, navigation)
├── hooks/ # Shared React hooks (PDF download, session URL)
├── lib/ # Utilities (PDF generation)
├── mocks/ # MSW mock handlers and database for testing
├── pages/ # API routes (PDF generation)
├── shared/ # Shared components, config, context, hooks, and utilities
│ ├── components/ # MarkdownRenderer, StarfieldAnimation
│ ├── config/ # File upload configuration
│ ├── context/ # AppConfigContext
│ ├── hooks/ # Backend health check hook
│ └── utils/ # Shared utilities (time formatting)
├── styles/ # KUI-generated CSS and safelist
├── test-utils/ # Test helper utilities
└── utils/ # General utilities (markdown download)
The UI acts as a gateway/proxy between the browser and backend:
- All HTTP API requests go through Next.js API routes (
/api/*) - WebSocket connections are proxied through the custom server (
/websocket) - Backend URL is runtime configurable via
BACKEND_URLenvironment variable
This architecture ensures the backend doesn't need public exposure - only the UI container needs ingress. See the Docker Deployment section for details.
The AI-Q UI uses localStorage to persist chat sessions across page refreshes. To prevent quota exceeded errors and ensure optimal performance, the app implements automatic storage management.
- localStorage Quota: ~5MB (browser-dependent)
- Warning Threshold: 4MB (80% of quota)
- Target After Cleanup: <3MB (60% of quota)
Sessions are stored with optimized data to minimize storage usage:
Stored (Essential for UI):
- Session metadata (id, title, timestamps)
- Message content and timestamps
- Thinking steps (for ChatThinking display)
- Plan messages (cannot be refetched from backend)
- Job IDs for deep research restoration
Not Stored (Fetched from backend on demand):
- Report content (loaded via API)
- Citations, tasks, tool calls (replayed from SSE stream)
- Agent traces and file artifacts
When creating a new session, if storage exceeds 4MB:
- Auto-cleanup triggers - Deletes oldest sessions (by
updatedAttimestamp) - Current session protected - Never deletes the active session
- Stops at 3MB - Cleanup continues until storage is healthy
- Console warnings - Logs deleted sessions for debugging
To manually clear sessions:
- Open SessionsPanel (left sidebar)
- Click "Delete All Sessions" button
- Or delete individual sessions one at a time
When you reopen a session after a page refresh:
- ChatArea - Displays immediately (messages, thinking steps loaded from localStorage)
- PlanTab - Displays immediately (plan messages loaded from localStorage)
- Report/Tasks/Citations tabs - Shows loading spinner, then fetches data from backend
The lazy loading is automatic and seamless - you don't need to do anything special.
From the UI directory (frontends/ui/):
docker build -t aiq-blueprint-ui:latest .Without authentication (default):
docker run -p 3000:3000 \
-e BACKEND_URL=http://localhost:8000 \
-e REQUIRE_AUTH=false \
aiq-blueprint-ui:latestWith OAuth authentication:
docker run -p 3000:3000 \
-e BACKEND_URL=http://localhost:8000 \
-e REQUIRE_AUTH=true \
-e NEXTAUTH_SECRET=$(openssl rand -base64 32) \
-e NEXTAUTH_URL=https://your-domain.com \
-e OAUTH_CLIENT_ID=your-client-id \
-e OAUTH_CLIENT_SECRET=your-client-secret \
-e OAUTH_ISSUER=https://your-oidc-provider.com \
aiq-blueprint-ui:latestservices:
frontend:
image: aiq-blueprint-ui:latest
environment:
# Backend
- BACKEND_URL=http://backend:8000
# Authentication (auth is disabled by default)
- REQUIRE_AUTH=${REQUIRE_AUTH:-false}
- NEXTAUTH_SECRET=${NEXTAUTH_SECRET}
- NEXTAUTH_URL=${NEXTAUTH_URL:-http://localhost:3000}
# OAuth (required when REQUIRE_AUTH=true)
- OAUTH_CLIENT_ID=${OAUTH_CLIENT_ID}
- OAUTH_CLIENT_SECRET=${OAUTH_CLIENT_SECRET}
- OAUTH_ISSUER=${OAUTH_ISSUER}
ports:
- "3000:3000"
depends_on:
- backendWhen running in Docker and connecting to services on the host machine:
- macOS/Windows: Use
host.docker.internal - Linux: Use
--network=hostor configure Docker networking
# Connect to backend running on host
docker run -p 3000:3000 \
-e BACKEND_URL=http://host.docker.internal:8000 \
-e REQUIRE_AUTH=false \
aiq-blueprint-ui:latestWhen using docker-compose or custom networks, use service names:
-e BACKEND_URL=http://backend:8000The container includes a health check that polls the root endpoint:
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3
CMD curl -f http://localhost:3000/ || exit 1
All environment variables are runtime configurable - no container rebuild needed.
| Variable | Default | Description |
|---|---|---|
BACKEND_URL |
http://localhost:8000 |
Backend API URL |
| Variable | Default | Description |
|---|---|---|
REQUIRE_AUTH |
false |
Set to true to require OAuth login |
NEXTAUTH_SECRET |
- | Session encryption secret (required if auth enabled) |
NEXTAUTH_URL |
- | Public URL where app is hosted (required if auth enabled) |
SESSION_MAX_AGE_HOURS |
24 |
Session and idToken cookie lifetime in hours |
TOKEN_REFRESH_BUFFER_MINUTES |
5 |
Minutes before token expiry to trigger refresh (set to 30 for long-running jobs) |
Cookie Security:
NEXTAUTH_URLdetermines cookie security:
http://...-> non-secure cookies (local dev over HTTP)https://...-> secure cookies (production over HTTPS)
Provider-specific env vars depend on your provider implementation. See src/adapters/auth/providers/auth-example.ts for a template/checklist and the Authentication section for setup steps.
The UI supports two communication patterns with the backend:
OpenAI-compatible chat completions via /chat/stream:
import { streamChat } from '@/adapters/api'
await streamChat(
{ messages, sessionId },
{
onChunk: (content) => console.log(content),
onComplete: () => console.log('Done'),
onError: (error) => console.error(error),
}
)Custom protocol for real-time agent communication:
import { createWebSocketClient } from '@/adapters/api'
const ws = createWebSocketClient({
sessionId: 'abc123',
callbacks: {
onAgentText: (content, isFinal) => {},
onStatus: (status, message) => {},
onToolCall: (name, input, output) => {},
onError: (code, message) => {},
},
})
ws.connect()
ws.sendMessage('Hello!')Authentication is disabled by default. All users are assigned a "Default User" identity with no login required. The auth system uses a plugin architecture where src/adapters/auth/providers/index.ts is the sole file that controls whether auth is enabled and which provider is active.
src/adapters/auth/
├── providers/
│ ├── types.ts # AuthProviderConfig interface (contract)
│ ├── index.ts # SWAP-POINT: returns null (disabled) or a real provider
│ └── auth-example.ts # Provider template/checklist (not imported by default)
├── config.ts # NextAuth config (provider-agnostic, never needs editing)
├── session.ts # useAuth() hook (provider-agnostic)
├── types.ts # NextAuth type extensions
└── index.ts # Re-exports
providers/index.tsexportsgetAuthProviderConfig()which returns the active provider configuration. By default it returns{ provider: null }(auth disabled).config.tsimports fromproviders/index.tsand wires the provider into NextAuth. It never needs to be edited when adding a new provider.session.tsprovides theuseAuth()hook that components use. It readsauthProviderIdfromAppConfigand adapts dynamically.
Every auth provider must conform to the AuthProviderConfig interface defined in providers/types.ts:
interface AuthProviderConfig {
provider: Record<string, unknown> | null // NextAuth-compatible provider object, or null
providerId: string // ID used in signIn(providerId) -- must match provider.id
refreshToken: (refreshToken: string) => Promise<TokenRefreshResult>
}
interface TokenRefreshResult {
access_token: string
id_token?: string
expires_in: number
refresh_token?: string
}To enable OAuth/OIDC authentication, follow these steps:
Create a new file in src/adapters/auth/providers/ (e.g. my-sso.ts). See auth-example.ts in the same directory for a template/checklist. Your file should export:
- A NextAuth-compatible provider object (OAuth/OIDC config)
- A token refresh function matching the
TokenRefreshResultreturn type
Example minimal provider:
// src/adapters/auth/providers/my-sso.ts
import type { TokenRefreshResult } from './types'
export const MySSOProvider = {
id: 'my-sso',
name: 'My SSO',
type: 'oauth' as const,
wellKnown: `${process.env.MY_SSO_ISSUER}/.well-known/openid-configuration`,
authorization: {
params: { scope: 'openid profile email', response_type: 'code' },
},
clientId: process.env.MY_SSO_CLIENT_ID,
clientSecret: process.env.MY_SSO_CLIENT_SECRET || '',
checks: ['pkce', 'state'] as ('pkce' | 'state' | 'nonce')[],
idToken: true,
profile(profile: { sub: string; email: string; name: string; picture?: string }) {
return { id: profile.sub, email: profile.email, name: profile.name, image: profile.picture }
},
}
export const refreshMySSOToken = async (refreshToken: string): Promise<TokenRefreshResult> => {
const response = await fetch(process.env.MY_SSO_TOKEN_URL!, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: refreshToken,
client_id: process.env.MY_SSO_CLIENT_ID || '',
}),
})
const tokens = await response.json()
if (!response.ok) throw tokens
return tokens
}Replace the default getAuthProviderConfig() to return your provider:
// src/adapters/auth/providers/index.ts
import type { AuthProviderConfig } from './types'
import { MySSOProvider, refreshMySSOToken } from './my-sso'
export type { AuthProviderConfig, TokenRefreshResult } from './types'
export const getAuthProviderConfig = (): AuthProviderConfig => ({
provider: MySSOProvider,
providerId: 'my-sso',
refreshToken: refreshMySSOToken,
})REQUIRE_AUTH=true
NEXTAUTH_SECRET=<generate-with-openssl-rand-base64-32>
NEXTAUTH_URL=http://localhost:3000
# Provider-specific (names depend on your provider file)
MY_SSO_ISSUER=https://sso.example.com
MY_SSO_CLIENT_ID=<your-client-id>
MY_SSO_CLIENT_SECRET=<your-client-secret>
MY_SSO_TOKEN_URL=https://sso.example.com/tokenThat's it. No other files need to change -- config.ts, session.ts, proxy.ts, and all components automatically adapt to the new provider via getAuthProviderConfig().
To disable auth (the default), ensure providers/index.ts returns { provider: null } and either unset REQUIRE_AUTH or set REQUIRE_AUTH=false. The app will use a "Default User" identity with no login required.
import { useAuth } from '@/adapters/auth'
const MyComponent = () => {
const { user, isAuthenticated, isLoading, idToken, signIn, signOut } = useAuth()
if (isLoading) return <Spinner />
if (!isAuthenticated) return <Button onClick={signIn}>Sign In</Button>
return <Text>Welcome, {user?.name}</Text>
}When auth is disabled, useAuth() returns isAuthenticated: true with a default user -- no sign-in flow is triggered.
- Create a directory under
src/features/[feature-name]/ - Add subdirectories:
components/,hooks/ - Create
types.tsfor feature-specific types - Create
store.tsfor Zustand state (if needed)
- Add Zod schema in
src/adapters/api/schemas.ts - Create client function in appropriate adapter file
- Export from
src/adapters/api/index.ts
Features should never import external packages directly. All external calls go through adapters:
// Correct
import { Button, Flex, Text } from '@/adapters/ui'
import { streamChat } from '@/adapters/api'
import { useSession } from '@/adapters/auth'
// Wrong
import { Button } from '@nvidia/foundations-react-core'
import { signIn } from 'next-auth/react'This project uses KUI Foundations for styling:
- Use KUI component props for visual styling (
kind,size, etc.) - Use Tailwind only for layout (
flex,grid,mt-4,px-6) - Never override KUI colors with Tailwind
- Dark mode is handled automatically by ThemeProvider
// Correct
<Flex className="mt-4 px-6">
<Button kind="primary" size="medium">Submit</Button>
</Flex>
// Wrong
<Button className="bg-blue-500 text-white">Submit</Button>The project uses Vitest with Testing Library and MSW (Mock Service Worker):
# Run tests once
npm run test
# Run tests in watch mode
npm run test:watch
# Run tests with coverage
npm run test:ci- Vitest -- Test runner with coverage via
@vitest/coverage-v8 - Testing Library --
@testing-library/reactand@testing-library/user-eventfor component testing - MSW -- Mock Service Worker for API mocking in tests (handlers in
src/mocks/) - happy-dom -- DOM environment for tests
Test utilities are in src/test-utils/ and MSW mock handlers/database are in src/mocks/.
- Verify backend is running:
curl http://localhost:8000/docs - Check
BACKEND_URLin.env.local - Check browser console for CORS errors
Kill existing processes:
lsof -ti :8000 | xargs kill -9 # Backend
lsof -ti :3000 | xargs kill -9 # Frontend- Use
host.docker.internalinstead oflocalhostto reach host machine services - Ensure backend is bound to
0.0.0.0, not just127.0.0.1 - Check Docker network configuration if using docker-compose