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
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import cache from '@/utils/cache'
import { recipeRegistry } from '@modular/recipes'
import { getRecipeApiHandler } from '@modular/recipes/server'

function createErrorResponse(message: string, error?: unknown, status = 400): Response {
const errorMessage = error instanceof Error ? `: ${error.message}` : ''
Expand All @@ -20,8 +20,8 @@ export async function POST(req: Request) {
return createErrorResponse('Cannot determine recipe slug', error)
}

const recipe = recipeRegistry[recipeId]
if (!recipe) {
const apiHandler = await getRecipeApiHandler(recipeId)
if (!apiHandler) {
return createErrorResponse(`Recipe not found: ${recipeId}`, undefined, 404)
}

Expand All @@ -38,7 +38,7 @@ export async function POST(req: Request) {
}

try {
const response = recipe.api(req, { apiKey, baseUrl, modelName })
const response = apiHandler(req, { apiKey, baseUrl, modelName })
return response
} catch (error) {
return createErrorResponse(
Expand Down
12 changes: 8 additions & 4 deletions genai-cookbook/apps/cookbook/app/cookbook/[recipe]/code/page.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,22 @@
import { redirect } from 'next/navigation'
import { cookbookRoute } from '@/utils/constants'
import { CodeViewer } from '@/components/CodeViewer'
import { getRecipeSource, recipeRegistry } from '@modular/recipes'
import { getRecipeSource, getRecipeMetadata } from '@modular/recipes/server'

export default async function RecipeCode({ params }: { params: { recipe?: string } }) {
if (!params.recipe) return redirect(cookbookRoute())

const recipe = recipeRegistry[params.recipe]
if (!recipe) return redirect(cookbookRoute())
const metadata = await getRecipeMetadata(params.recipe)
if (!metadata) return redirect(cookbookRoute())

const beCode = await getRecipeSource(params.recipe, 'api')
const feCode = await getRecipeSource(params.recipe, 'ui')

return (
<CodeViewer description={recipe.description} beCode={beCode} feCode={feCode} />
<CodeViewer
description={metadata.description}
beCode={beCode}
feCode={feCode}
/>
)
}
5 changes: 2 additions & 3 deletions genai-cookbook/apps/cookbook/app/cookbook/[recipe]/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,7 @@ import { redirect } from 'next/navigation'
import { Toolbar } from '@/components/Toolbar'
import { cookbookRoute } from '@/utils/constants'
import { appShellContentHeight } from '@/utils/theme'
import { recipeRegistry } from '@modular/recipes'
import { RecipeLayout } from '@modular/recipes'
import { recipeMetadata, RecipeLayout } from '@modular/recipes'

export default function Layout({
children,
Expand All @@ -16,7 +15,7 @@ export default function Layout({
}) {
if (!params.recipe) return redirect(cookbookRoute())

const recipe = recipeRegistry[params.recipe]
const recipe = recipeMetadata[params.recipe]

if (!recipe) return redirect(cookbookRoute())

Expand Down
9 changes: 4 additions & 5 deletions genai-cookbook/apps/cookbook/app/cookbook/[recipe]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,21 @@
import { redirect, usePathname } from 'next/navigation'
import { cookbookRoute } from '@/utils/constants'
import { useCookbook } from '@/context'
import { recipeRegistry } from '@modular/recipes'
import { recipeMetadata, recipeUiRegistry } from '@modular/recipes'

export default function Page({ params }: { params: { recipe?: string } }) {
const pathname = usePathname()
const { selectedEndpoint, selectedModel } = useCookbook()

if (!params.recipe) return redirect(cookbookRoute())

const recipe = recipeRegistry[params.recipe]
const recipe = recipeMetadata[params.recipe]
const RecipeComponent = recipeUiRegistry[params.recipe]

if (!recipe) {
if (!recipe || !RecipeComponent) {
throw new Error(`Unable to load recipe ${params.recipe}`)
}

const RecipeComponent = recipe.ui

return (
<RecipeComponent
endpoint={selectedEndpoint}
Expand Down
6 changes: 4 additions & 2 deletions genai-cookbook/apps/cookbook/components/Navbar.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
'use client'

import Link from 'next/link'
import { AppShell, Group, ScrollArea, Stack, Text } from '@mantine/core'
import { IconChevronRight } from '@tabler/icons-react'
import { cookbookRoute } from '@/utils/constants'
import { iconStroke } from '@/utils/theme'
import { useSelectedLayoutSegment } from 'next/navigation'
import { recipeRegistry } from '@modular/recipes'
import { recipeMetadata } from '@modular/recipes'

export default function Navbar() {
const currentRecipe = useSelectedLayoutSegment()
Expand All @@ -15,7 +17,7 @@ export default function Navbar() {
<Text size="sm" fw="bold" tt="uppercase" c="dimmed">
Recipes
</Text>
{Object.entries(recipeRegistry).map(([slug, recipe]) => {
{Object.entries(recipeMetadata).map(([slug, recipe]) => {
return (
<Group key={slug} justify="space-between" align="center">
<Link href={`${cookbookRoute()}/${slug}`}>
Expand Down
13 changes: 7 additions & 6 deletions genai-cookbook/apps/cookbook/next.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,15 @@ const nextConfig = {
outputFileTracingRoot: path.join(__dirname, '../../'),
// Enable Fast Refresh for external packages
externalDir: true,
turbo: {
// Turbopack resolves workspace packages correctly by default
resolveAlias: {
'@modular/recipes': '../../packages/recipes',
},
},
},
transpilePackages: ['@modular/recipes'],
webpack(config, { dev }) {
if (dev) {
// Avoid PackFile serialization warnings by keeping the cache in-memory during dev
config.cache = { type: 'memory' }
}
return config
},
}

export default nextConfig
2 changes: 1 addition & 1 deletion genai-cookbook/apps/cookbook/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev --turbo",
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
Expand Down
2 changes: 1 addition & 1 deletion genai-cookbook/apps/cookbook/utils/cache.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Endpoint } from '@modular/recipes'
import type { Endpoint } from '@modular/recipes'

// Only store the API key server side
interface EndpointWithApiKey extends Endpoint {
Expand Down
35 changes: 3 additions & 32 deletions genai-cookbook/packages/recipes/index.ts
Original file line number Diff line number Diff line change
@@ -1,35 +1,6 @@
import type { ComponentType } from 'react'
import type { RecipeProps, RecipeContext } from './src/types'

import ImageCaptioningUI from './src/image-captioning/ui'
import ImageCaptioningAPI from './src/image-captioning/api'
import MultiturnChatUI from './src/multiturn-chat/ui'
import MultiturnChatAPI from './src/multiturn-chat/api'
'use client'

export * from './src/components'
export * from './src/types'
export * from './src/utils'

export interface RecipeComponents {
ui: ComponentType<RecipeProps>
api: (req: Request, context: RecipeContext) => Response | Promise<Response>
title: string
description?: string
}

export const recipeRegistry: Record<string, RecipeComponents> = {
'image-captioning': {
ui: ImageCaptioningUI,
api: ImageCaptioningAPI,
title: 'Image Captioning',
description:
"This recipe walks through an end-to-end image captioning workflow that lets you upload pictures, tweak the guiding prompt, and generate natural-language captions through the OpenAI-compatible Vercel AI SDK transport capable of integrating with Modular MAX. The client component manages uploads, gallery state, and Mantine-based UI controls, then forwards the prompt and base64-encoded image to a Next.js API route. That route proxies the request to whichever OpenAI-compatible endpoint you select, using the SDK's chat abstraction to produce a caption and return it to the browser.",
},
'multiturn-chat': {
ui: MultiturnChatUI,
api: MultiturnChatAPI,
title: 'Multi-turn Chat',
description:
"This recipe demonstrates a Mantine-powered chat surface that streams multi-turn conversations through the Vercel AI SDK, letting you toggle between Modular MAX and other OpenAI-compatible endpoints without rewriting UI logic. The page component keeps composer input, scroll-follow behavior, and the live message list in sync while forwarding each prompt to a Next.js API route. That route adapts the chat transcript into the SDK's message format, invokes the selected model via `openai.chat`, and streams tokens back to the browser so replies render fluidly as they arrive.",
},
}
export { recipeMetadata } from './src/registry/metadata'
export { recipeUiRegistry } from './src/registry/ui'
21 changes: 0 additions & 21 deletions genai-cookbook/packages/recipes/node_modules/.bin/next

This file was deleted.

1 change: 0 additions & 1 deletion genai-cookbook/packages/recipes/node_modules/next

This file was deleted.

8 changes: 5 additions & 3 deletions genai-cookbook/packages/recipes/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"main": "./index.ts",
"exports": {
".": "./index.ts",
"./server": "./server.ts",
"./package.json": "./package.json",
"./*": "./src/*"
},
Expand All @@ -18,12 +19,13 @@
"@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",
"streamdown": "^1.3.0"
},
"peerDependencies": {
"react": "^18",
"react-dom": "^18"
},
"devDependencies": {
"@types/node": "^20",
"@types/react": "^18",
Expand Down
24 changes: 24 additions & 0 deletions genai-cookbook/packages/recipes/server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
'use server'

import { getRecipeSource as readRecipeSource } from './src/utils'
import { recipeApiRegistry } from './src/registry/api'
import { recipeMetadata } from './src/registry/metadata'

export async function getRecipeSource(
id: Parameters<typeof readRecipeSource>[0],
file: Parameters<typeof readRecipeSource>[1]
) {
return readRecipeSource(id, file)
}

export async function getRecipeMetadata(slug: string) {
return recipeMetadata[slug]
}

export async function listRecipeMetadata() {
return recipeMetadata
}

export async function getRecipeApiHandler(slug: string) {
return recipeApiRegistry[slug]
}
2 changes: 2 additions & 0 deletions genai-cookbook/packages/recipes/src/image-captioning/ui.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,8 @@ function FileDrop({ onDrop, maxSizeMb, disabled }: FileDropProps) {
multiple={true}
style={centerStyle}
disabled={disabled}
mt="4"
h="100"
p="md"
w="100%"
bd="1px solid var(--mantine-color-default-border)"
Expand Down
14 changes: 14 additions & 0 deletions genai-cookbook/packages/recipes/src/registry/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import type { RecipeContext } from '../types'

import ImageCaptioningAPI from '../image-captioning/api'
import MultiturnChatAPI from '../multiturn-chat/api'

export type RecipeApiHandler = (
req: Request,
context: RecipeContext
) => Response | Promise<Response>

export const recipeApiRegistry: Record<string, RecipeApiHandler> = {
'image-captioning': ImageCaptioningAPI,
'multiturn-chat': MultiturnChatAPI,
}
16 changes: 16 additions & 0 deletions genai-cookbook/packages/recipes/src/registry/metadata.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import type { RecipeMetadata } from '../types'

export const recipeMetadata: Record<string, RecipeMetadata> = {
'image-captioning': {
slug: 'image-captioning',
title: 'Image Captioning',
description:
"This recipe walks through an end-to-end image captioning workflow that lets you upload pictures, tweak the guiding prompt, and generate natural-language captions through the OpenAI-compatible Vercel AI SDK transport capable of integrating with Modular MAX. The client component manages uploads, gallery state, and Mantine-based UI controls, then forwards the prompt and base64-encoded image to a Next.js API route. That route proxies the request to whichever OpenAI-compatible endpoint you select, using the SDK's chat abstraction to produce a caption and return it to the browser.",
},
'multiturn-chat': {
slug: 'multiturn-chat',
title: 'Multi-turn Chat',
description:
"This recipe demonstrates a Mantine-powered chat surface that streams multi-turn conversations through the Vercel AI SDK, letting you toggle between Modular MAX and other OpenAI-compatible endpoints without rewriting UI logic. The page component keeps composer input, scroll-follow behavior, and the live message list in sync while forwarding each prompt to a Next.js API route. That route adapts the chat transcript into the SDK's message format, invokes the selected model via `openai.chat`, and streams tokens back to the browser so replies render fluidly as they arrive.",
},
}
14 changes: 14 additions & 0 deletions genai-cookbook/packages/recipes/src/registry/ui.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
'use client'

import type { ComponentType } from 'react'
import type { RecipeProps } from '../types'

import ImageCaptioningUI from '../image-captioning/ui'
import MultiturnChatUI from '../multiturn-chat/ui'

export type RecipeUiRegistry = Record<string, ComponentType<RecipeProps>>

export const recipeUiRegistry: RecipeUiRegistry = {
'image-captioning': ImageCaptioningUI,
'multiturn-chat': MultiturnChatUI,
}
46 changes: 39 additions & 7 deletions genai-cookbook/packages/recipes/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,26 +3,58 @@
import path from 'path'
import fs from 'fs'
import { createRequire } from 'module'
import { recipeRegistry } from '../index'
import { recipeMetadata } from './registry/metadata'

const require = createRequire(import.meta.url)
const packageRoot = path.dirname(require.resolve('@modular/recipes/package.json'))
const sourceDir = path.join(packageRoot, 'src')
const recipeSourceDir = resolveRecipeSourceDir()

export async function getRecipeSource(
id: string,
file: 'ui' | 'api'
): Promise<string | undefined> {
if (!recipeRegistry[id]) return undefined
// Only resolve files for known recipes, otherwise signal "not found".
if (!recipeMetadata[id]) return undefined
if (file !== 'ui' && file !== 'api') return undefined

const extension = file === 'ui' ? '.tsx' : '.ts'
const filePath = path.join(sourceDir, id, `${file}${extension}`)
const filePath = path.join(recipeSourceDir, id, `${file}${extension}`)

try {
const code = fs.readFileSync(filePath, 'utf8')
const code = await fs.promises.readFile(filePath, 'utf8')
return code
} catch {
return undefined
}
}

function resolveRecipeSourceDir(): string {
// During local development load the sources directly from the workspace.
const workspaceRoot = locateWorkspaceRoot(process.cwd())
if (workspaceRoot) {
const candidate = path.join(workspaceRoot, 'packages', 'recipes', 'src')
if (fs.existsSync(candidate)) {
return candidate
}
}

// Fallback for production / package consumers: use the installed package path.
const requireFromPackage = createRequire(import.meta.url)
const packageRoot = path.dirname(
requireFromPackage.resolve('@modular/recipes/package.json')
)
return path.join(packageRoot, 'src')
}

function locateWorkspaceRoot(startDir: string): string | null {
// Walk up the filesystem until we find pnpm-workspace.yaml, which marks the repo root.
let current = startDir
const { root } = path.parse(current)

while (current && current !== root) {
if (fs.existsSync(path.join(current, 'pnpm-workspace.yaml'))) {
return current
}
current = path.dirname(current)
}

return null
}
Loading