Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 14 additions & 11 deletions genai-cookbook/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Modular GenAI Cookbook
# Modular Agentic Cookbook

The GenAI Cookbook is collection of recipes demonstrating how to build modern fullstack web apps using Modular MAX, Next.js, and the Vercel AI SDK. Unlike other recipes in the MAX Recipes repo—which are Python-based—the GenAI Cookbook is written exclusively in TypeScript, providing production-ready patterns for building interactive AI experiences. Each recipe demonstrates an end-to-end workflow with both frontend and backend implementations, including detailed code comments.
The Agentic Cookbook is collection of recipes demonstrating how to build modern fullstack web apps using Modular MAX, Next.js, and the Vercel AI SDK. Unlike other recipes in the MAX Recipes repo—which are Python-based—the Agentic Cookbook is written exclusively in TypeScript, providing production-ready patterns for building interactive AI experiences. Each recipe demonstrates an end-to-end workflow with both frontend and backend implementations, including detailed code comments.

<img src="https://github.com/user-attachments/assets/e2302038-a950-41a8-acec-47c0d9c09ed6" />

Expand Down Expand Up @@ -92,7 +92,7 @@ Create an intelligent image captioning system that generates natural language de

## Architecture

The GenAI Cookbook follows a modern fullstack architecture optimized for AI applications, organized as a pnpm workspace monorepo:
The Agentic Cookbook follows a modern fullstack architecture optimized for AI applications, organized as a pnpm workspace monorepo:

```
genai-cookbook/
Expand Down Expand Up @@ -185,7 +185,7 @@ To use the cookbook with MAX:

## Running with Docker

The GenAI Cookbook can be run entirely within a Docker container, including the MAX model server and web application. The container uses the universal MAX image with the nightly build, supporting both NVIDIA and AMD GPUs.
The Agentic Cookbook can be run entirely within a Docker container, including the MAX model server and web application. The container uses the universal MAX image with the nightly build, supporting both NVIDIA and AMD GPUs.

### Building the Container

Expand All @@ -202,23 +202,25 @@ docker build --ulimit nofile=65535:65535 -t max-cookbook:latest .
You can customize the Docker build using these arguments to reduce container size:

- **MAX_GPU**: Selects the base image (default: `universal`)
- `universal` → `modular/max-full` (larger, supports all GPU types)
- `amd` → `modular/max-amd` (smaller, AMD-specific)
- `nvidia` → `modular/max-nvidia-full` (smaller, NVIDIA-specific)
- `universal` → `modular/max-full` (larger, supports all GPU types)
- `amd` → `modular/max-amd` (smaller, AMD-specific)
- `nvidia` → `modular/max-nvidia-full` (smaller, NVIDIA-specific)

- **MAX_TAG**: Selects the image version (default: `latest`)
- `latest` → Latest stable release
- `nightly` → Nightly development builds
- Specific versions (e.g., `25.7.0`)
- `latest` → Latest stable release
- `nightly` → Nightly development builds
- Specific versions (e.g., `25.7.0`)

**Examples:**

Build smaller AMD-specific container:

```bash
docker build --build-arg MAX_GPU=amd --ulimit nofile=65535:65535 -t max-cookbook:amd .
```

Build smaller NVIDIA-specific container with nightly builds:

```bash
docker build --build-arg MAX_GPU=nvidia --build-arg MAX_TAG=nightly --ulimit nofile=65535:65535 -t max-cookbook:nvidia-nightly .
```
Expand Down Expand Up @@ -256,8 +258,9 @@ docker run \
```

**Configuration:**

- **Port 8000**: MAX model serving endpoint
- **Port 3000**: GenAI Cookbook web application
- **Port 3000**: Agentic Cookbook web application
- **HF_TOKEN**: Your HuggingFace token for downloading models
- **MAX_MODEL**: The model to serve (e.g., `google/gemma-3-27b-it`)
- **Volume mount**: Caches downloaded models in `~/.cache/huggingface`
Expand Down
2 changes: 1 addition & 1 deletion genai-cookbook/apps/cookbook/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { CookbookProvider } from '@/context'
import { endpointsRoute } from '@/utils/constants'

export const metadata: Metadata = {
title: 'Modular GenAI Cookbook',
title: 'Modular Agentic Cookbook',
}

export default function RootLayout({ children }: { children: React.ReactNode }) {
Expand Down
2 changes: 1 addition & 1 deletion genai-cookbook/apps/cookbook/components/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export default function Header({
</ActionIcon>
</Group>
<Title style={{ fontWeight: 'normal' }} order={5}>
<Link href={cookbookRoute()}>Modular GenAI Cookbook</Link>
<Link href={cookbookRoute()}>Modular Agentic Cookbook</Link>
</Title>
<ThemeToggle stroke={iconStroke} />
</Flex>
Expand Down
5 changes: 3 additions & 2 deletions genai-cookbook/apps/cookbook/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,20 +11,21 @@
"format:check": "prettier --check ."
},
"dependencies": {
"@modular/recipes": "workspace:*",
"@ai-sdk/openai": "^2.0.23",
"@ai-sdk/react": "^2.0.30",
"@mantine/core": "^7.17.8",
"@mantine/dropzone": "^7.17.8",
"@mantine/hooks": "^7.17.8",
"@modular/recipes": "workspace:*",
"@tabler/icons-react": "^3.34.1",
"ai": "^5.0.28",
"nanoid": "^5.1.5",
"next": "^14",
"openai": "^5.20.2",
"react": "^18",
"react-dom": "^18",
"react-syntax-highlighter": "^15.6.6"
"react-syntax-highlighter": "^15.6.6",
"streamdown": "^1.3.0"
},
"devDependencies": {
"@types/node": "^20",
Expand Down
22 changes: 11 additions & 11 deletions genai-cookbook/metadata.yaml
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
version: 1.0
long_title: "Collection of recipes featuring MAX, Next.js, and Vercel AI SDK"
short_title: "Modular GenAI Cookbook"
author: "Bill Welense"
author_image: "author/billw.jpg"
author_url: "https://www.linkedin.com/in/welense/"
github_repo: "https://github.com/modular/max-recipes/tree/main/genai-cookbook"
date: "22-09-2025"
difficulty: "beginner"
long_title: 'Collection of recipes featuring MAX, Next.js, and Vercel AI SDK'
short_title: 'Modular Agentic Cookbook'
author: 'Bill Welense'
author_image: 'author/billw.jpg'
author_url: 'https://www.linkedin.com/in/welense/'
github_repo: 'https://github.com/modular/max-recipes/tree/main/genai-cookbook'
date: '22-09-2025'
difficulty: 'beginner'
tags:
- max-serve
- gui
- max-serve
- gui

tasks:
- pnpm dev
- pnpm dev
3 changes: 2 additions & 1 deletion genai-cookbook/packages/recipes/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@
"next": "^14",
"openai": "^5.20.2",
"react": "^18",
"react-dom": "^18"
"react-dom": "^18",
"streamdown": "^1.3.0"
},
"devDependencies": {
"@types/node": "^20",
Expand Down
12 changes: 0 additions & 12 deletions genai-cookbook/packages/recipes/src/multiturn-chat/ui.module.css

This file was deleted.

116 changes: 59 additions & 57 deletions genai-cookbook/packages/recipes/src/multiturn-chat/ui.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,8 @@
import { useEffect, useRef, useState } from 'react'
import { DefaultChatTransport } from 'ai'
import { useChat } from '@ai-sdk/react'
import { Box, ScrollArea } from '@mantine/core'
import { Box, Paper, ScrollArea, Space, Stack, Text } from '@mantine/core'
import { RecipeProps } from '../types'
import styles from './ui.module.css'

// ============================================================================
// Chat surface component
Expand Down Expand Up @@ -61,30 +60,54 @@ export default function Recipe({ endpoint, model, pathname }: RecipeProps) {
const [followStream, setFollowStream] = useState(true)
const viewportRef = useRef<HTMLDivElement>(null)
const bottomRef = useRef<HTMLDivElement>(null)
const isAutoScrolling = useRef(false)

// Whenever a new message arrives, keep the latest tokens in view unless
// we've intentionally scrolled upward to review earlier context.
useEffect(() => {
if (!followStream) return

// Mark that we're about to programmatically scroll
isAutoScrolling.current = true
bottomRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' })

// Clear the flag after scroll animation starts (~100ms is enough)
const timer = setTimeout(() => {
isAutoScrolling.current = false
}, 100)

return () => clearTimeout(timer)
}, [messages, followStream])

return (
<>
<Box style={{ flex: 1, minHeight: 0 }}>
<ScrollArea
// Mantine exposes scroll info through a forwarded ref so we can detect manual scrolling.
// Mantine exposes scroll info through a forwarded ref
// so we can detect manual scrolling.
h="100%"
type="auto"
viewportRef={viewportRef}
onScrollPositionChange={() => {
const el = viewportRef.current
if (!el) return

const distanceToBottom =
el.scrollHeight - el.scrollTop - el.clientHeight
const nearBottomThreshold = 4 // px
const nearBottomThreshold = 20 // px
const nearBottom = distanceToBottom <= nearBottomThreshold

// If user has clearly scrolled away (>50px from bottom),
// they want to stop following - even during auto-scroll
if (isAutoScrolling.current && distanceToBottom > 50) {
isAutoScrolling.current = false
setFollowStream(false)
return
}

// Don't interfere with programmatic auto-scroll when close to bottom
if (isAutoScrolling.current) return

// Pause auto-follow when the user scrolls up to read older content.
setFollowStream(nearBottom)
}}
Expand All @@ -105,7 +128,7 @@ export default function Recipe({ endpoint, model, pathname }: RecipeProps) {
}

// ============================================================================
// Message panel types and components
// Message panel
// ============================================================================

/*
Expand All @@ -116,6 +139,7 @@ export default function Recipe({ endpoint, model, pathname }: RecipeProps) {
*/
import type { UIMessage } from 'ai'
import type { RefObject } from 'react'
import { Streamdown } from 'streamdown'

/**
* Shared props for our message history block.
Expand All @@ -127,62 +151,40 @@ interface MessagesPanelProps {
}

/**
* Lists every chat exchange and injects a fade-in animation for readability.
* Displays chat messages using Streamdown, a part of the Vercel AI SDK.
*/
function MessagesPanel({ messages, bottomRef }: MessagesPanelProps) {
return (
<dl>
{messages.map((m) => (
// Pair the speaker label with the text body for each message in order.
<div key={m.id} className="pb-4">
<MessageRole message={m} />
<MessageContent message={m} />
</div>
<Stack align="flex-start" justify="flex-start" gap="sm">
{messages.map((message) => (
// The outer loop maps each message from the user or assistant
<Box key={message.id} w="100%">
<Text fw="bold" tt="capitalize">
{message.role}
</Text>
<Paper>
{message.parts
// The inner loop maps each message part, with support
// for streaming responses from the LLM
.filter((part) => part.type === 'text')
.map((part, index) => (
<Streamdown
controls={false}
shikiTheme={[
'material-theme-lighter',
'material-theme-darker',
]}
key={index}
>
{part.text}
</Streamdown>
))}
</Paper>
<Space h="xs" />
</Box>
))}
<div ref={bottomRef} />
</dl>
)
}

/** Keeps type information consistent across role and content renderers. */
interface MessageContentProps {
message: UIMessage
}

/**
* Displays who said the message (user vs. assistant) with quick capitalization.
*/
function MessageRole({ message }: MessageContentProps) {
return (
<dt className={`${styles.messageFade} font-bold`}>
{/* GenAI roles come through lowercase, so we prettify them for the UI. */}
{message.role.charAt(0).toUpperCase() + message.role.slice(1)}
</dt>
)
}

/**
* Streams message text with simple formatting that respects whitespace.
*/
function MessageContent({ message }: MessageContentProps) {
return (
<dd>
<pre
style={{ fontFamily: 'inherit' }}
className="whitespace-pre-wrap break-words"
>
{/* Only render text parts so other message types (like tool calls) can be added later. */}
{message.parts
.filter((p) => p.type === 'text')
.map((p, i) =>
'text' in p ? (
<span key={i} className={styles.messageFade}>
{p.text}
</span>
) : null
)}
</pre>
</dd>
<Box ref={bottomRef} />
</Stack>
)
}

Expand Down
Loading