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
13 changes: 10 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ The shell is composed as sidebar → `AppHeader` → conversation → `ChatCompo

**Model & Tool Selection:**

- Dynamic model/tool configuration fetched from `/api/configure`
- Dynamic model/tool configuration fetched from the configured API path (`/api/configure` by default)
- Models and available builtin tools configured per-model
- Tools toggled via checkboxes in prompt toolbar

Expand All @@ -103,13 +103,19 @@ The shell is composed as sidebar → `AppHeader` → conversation → `ChatCompo

### Backend Integration

**Endpoints:**
**Default endpoints:**

- `GET /api/configure`: Returns available models and builtin tools (camelCase)
- `POST /api/chat`: Handles chat messages via `VercelAIAdapter`
- Accepts `model` and `builtinTools` in request body extra data
- Streams responses using SSE

Set `window.PYDANTIC_AI_CHAT_CONFIG` before the UI module executes to override paths at runtime.

- Use `basePath` to control conversation navigation.
- Use `apiPath` as the complete same-origin directory containing `configure` and `chat`.
- Keep `apiPath` independent of `basePath`; its default is `/api/`.

**Token usage:**

The UI shows per-reply and per-conversation token counts, read from `UIMessage.metadata.usage` on assistant messages:
Expand Down Expand Up @@ -150,7 +156,8 @@ Two normalizations are part of vendoring itself, not local modifications: files

- **TypeScript paths**: `@/*` maps to `./src/*`
- **Vite base URL**: CDN path for production (`jsdelivr.net/npm/@pydantic/pydantic-ai-chat/dist/`)
- **Dev proxy**: `/api` proxied to `localhost:8000`
- **Runtime paths**: `window.PYDANTIC_AI_CHAT_CONFIG` supplies independent `basePath` and `apiPath` values
- **Dev proxy**: `/api` proxied to `localhost:38001`
- **Package**: Published as `@pydantic/pydantic-ai-chat` (public npm package)

## Tech Stack
Expand Down
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,23 @@ Built with [Vercel AI SDK](https://sdk.vercel.ai/) and designed to work with Pyd
- Dark/light theme support
- Mobile-responsive sidebar

## Hosting below a path prefix

Set the startup configuration before the UI's module script executes when the app is served below a same-origin path prefix:

```html
<script>
window.PYDANTIC_AI_CHAT_CONFIG = {
basePath: '/chat/',
apiPath: '/chat/api/',
}
</script>
```

`basePath` controls conversation URLs and browser navigation. `apiPath` is the complete directory containing the `configure` and `chat` endpoints; it is independent of `basePath`, so a UI below `/chat/` may still use `/api/`. Both values accept same-origin paths and are normalized with a trailing slash.

Without startup configuration, `basePath` uses the normalized Vite base (offline and CDN builds use `/`) and `apiPath` uses `/api/`. This configuration also works when a server injects the script into the single-file offline artifact at request time; the artifact does not need to be rebuilt.

## Development

```sh
Expand Down
11 changes: 7 additions & 4 deletions playwright.offline.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ process.env.no_proxy = process.env.NO_PROXY

const TEST_SERVER_PORT = 38788
const TEST_UI_PORT = 54322
const TEST_BASE_PATH = '/demo/'
const TEST_API_PATH = '/demo/api/'

export default defineConfig({
testDir: 'tests/e2e/offline',
Expand All @@ -36,7 +38,7 @@ export default defineConfig({
},
],
use: {
baseURL: `http://127.0.0.1:${TEST_UI_PORT}`,
baseURL: `http://127.0.0.1:${TEST_UI_PORT}${TEST_BASE_PATH}`,
trace: 'retain-on-failure',
},
webServer: [
Expand All @@ -52,10 +54,11 @@ export default defineConfig({
// command into the readiness wait.
command:
`BACKEND_PORT=${TEST_SERVER_PORT} ` +
`API_PROXY_PATH=${TEST_API_PATH.slice(0, -1)} ` +
`pnpm exec vite preview --outDir offline --port ${TEST_UI_PORT} --host 127.0.0.1 --strictPort`,
// Probe the proxied API rather than `/`: the readiness fetch would otherwise pull the
// whole 15MB artifact, and this also confirms the /api proxy is wired up.
url: `http://127.0.0.1:${TEST_UI_PORT}/api/configure`,
// Probe the prefixed API rather than the UI: the readiness fetch would otherwise pull
// the whole 15MB artifact, and this also confirms the rewrite to /api is wired up.
url: `http://127.0.0.1:${TEST_UI_PORT}${TEST_API_PATH}configure`,
reuseExistingServer: !process.env.CI,
timeout: 30_000,
},
Expand Down
3 changes: 2 additions & 1 deletion src/Chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ import { Part } from './Part'
import type { ThinkingEffort } from '@/lib/generated/thinking-effort.gen'
import type { ConversationEntry } from './types'
import { readEffort, writeEffort } from '@/lib/effort'
import { fetchConfig } from '@/lib/config'
import { fetchConfig, startupConfig } from '@/lib/config'
import { resolveSelectedModel } from '@/lib/models'
import { toolNameOfPart } from '@/lib/tool-filters'
import { COMPLETE_TOOL_STATES, groupParts, type PartRun } from '@/lib/tool-grouping'
Expand Down Expand Up @@ -61,6 +61,7 @@ const ChatInner = () => {
const [transport] = useState(
() =>
new DefaultChatTransport({
api: `${startupConfig.apiPath}chat`,
body: () => ({ model: modelRef.current, builtinTools: enabledToolsRef.current, effort: effortRef.current }),
}),
)
Expand Down
5 changes: 3 additions & 2 deletions src/lib/base-path.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
const RAW = import.meta.env.BASE_URL
const BASE = RAW.startsWith('http') ? '/' : RAW.endsWith('/') ? RAW : RAW + '/'
import { startupConfig } from '@/lib/config'

const BASE = startupConfig.basePath

export function stripBasePath(pathname: string): string {
if (BASE === '/') return pathname
Expand Down
52 changes: 51 additions & 1 deletion src/lib/config.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,61 @@
import { isRecord } from '@/lib/is-record'
import type { BuiltinTool, ModelConfig } from '@/types'

export interface StartupConfig {
basePath?: string
apiPath?: string
}

export interface ResolvedStartupConfig {
readonly basePath: string
readonly apiPath: string
}

export interface RemoteConfig {
models: ModelConfig[]
builtinTools: BuiltinTool[]
}

const PATH_ORIGIN = 'https://pydantic-ai-chat.invalid'

/** Normalize a same-origin directory path for safe suffix concatenation. */
export function normalizeDirectoryPath(path: string, name: string): string {
const url = new URL(path, PATH_ORIGIN)
if (url.origin !== PATH_ORIGIN || url.search || url.hash) {
throw new TypeError(`${name} must be a same-origin path without a query or fragment`)
}
return url.pathname.endsWith('/') ? url.pathname : `${url.pathname}/`
}

function defaultBasePath(viteBase: string): string {
const url = new URL(viteBase || '/', PATH_ORIGIN)
return url.origin === PATH_ORIGIN ? normalizeDirectoryPath(url.pathname, 'Vite base') : '/'
}

function configuredPath(config: unknown, key: keyof StartupConfig): string | undefined {
if (config === undefined) return undefined
if (!isRecord(config)) throw new TypeError('PYDANTIC_AI_CHAT_CONFIG must be an object')
const value = config[key]
if (value !== undefined && typeof value !== 'string') {
throw new TypeError(`PYDANTIC_AI_CHAT_CONFIG.${key} must be a string`)
}
return value
}

export function resolveStartupConfig(
config: unknown = typeof window === 'undefined' ? undefined : window.PYDANTIC_AI_CHAT_CONFIG,
viteBase: string = import.meta.env.BASE_URL,
): ResolvedStartupConfig {
const basePath = configuredPath(config, 'basePath')
const apiPath = configuredPath(config, 'apiPath')
return Object.freeze({
basePath: basePath === undefined ? defaultBasePath(viteBase) : normalizeDirectoryPath(basePath, 'basePath'),
apiPath: apiPath === undefined ? '/api/' : normalizeDirectoryPath(apiPath, 'apiPath'),
})
}

export const startupConfig = resolveStartupConfig()

function isModelConfig(value: unknown): value is ModelConfig {
if (!isRecord(value)) return false
return (
Expand Down Expand Up @@ -50,7 +100,7 @@ function isRemoteConfig(value: unknown): value is RemoteConfig {
* the chat down instead.
*/
export async function fetchConfig(): Promise<RemoteConfig> {
const res = await fetch('/api/configure')
const res = await fetch(`${startupConfig.apiPath}configure`)
if (!res.ok) {
throw new Error(`Configuration request failed with ${String(res.status)}`)
}
Expand Down
4 changes: 4 additions & 0 deletions src/vite-env.d.ts
Original file line number Diff line number Diff line change
@@ -1 +1,5 @@
/// <reference types="vite/client" />

interface Window {
PYDANTIC_AI_CHAT_CONFIG?: import('./lib/config').StartupConfig
}
8 changes: 6 additions & 2 deletions tests/e2e/conversation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,12 @@ export async function sendMessage(page: Page, model: string, message: string) {
await input.press('Enter')
}

export async function waitForPersisted(page: Page, minMessages = 2, timeoutMs = 10_000) {
export async function waitForPersisted(
page: Page,
minMessages = 2,
timeoutMs = 10_000,
conversationId = new URL(page.url()).pathname,
) {
// Messages are persisted to IndexedDB (db `chat-storage`, store `messages`)
// throttled at 500ms (see src/lib/chat-db.ts). Poll inside the page until the
// record has at least `minMessages` entries AND the last (assistant) message
Expand All @@ -46,7 +51,6 @@ export async function waitForPersisted(page: Page, minMessages = 2, timeoutMs =
// Implemented as a single page.evaluate with a JS poll loop rather than
// page.waitForFunction(): the latter does not reliably re-evaluate a
// Promise-returning callback that opens IDB on each iteration.
const conversationId = new URL(page.url()).pathname
await page.evaluate(
async ({ id, min, timeout }) => {
/* global indexedDB */
Expand Down
59 changes: 56 additions & 3 deletions tests/e2e/offline/offline-artifact.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { test, expect } from '@playwright/test'
import type { Page } from '@playwright/test'
import { sendMessage } from '../conversation'
import { sendMessage, waitForPersisted } from '../conversation'

const BASE_PATH = '/demo/'
const API_PATH = '/demo/api/'

// The bug (pydantic-ai#5318): the CDN build bakes an absolute jsdelivr base into
// index.html *and* into the runtime chunk loader, so a self-hosted copy silently reaches
Expand All @@ -24,12 +27,32 @@ async function blockExternalRequests(page: Page): Promise<string[]> {
return attempted
}

async function configurePage(page: Page, apiPath: string): Promise<void> {
await page.addInitScript(
({ basePath, apiPath }) => {
window.PYDANTIC_AI_CHAT_CONFIG = { basePath, apiPath }
},
{ basePath: BASE_PATH, apiPath },
)
}

test.describe('offline artifact', () => {
test('renders code blocks and math with no external requests', async ({ page }) => {
test('works below a configured prefix with no external requests', async ({ page }) => {
await configurePage(page, API_PATH)
const attempted = await blockExternalRequests(page)
const configureRequest = page.waitForRequest(
(request) => new URL(request.url()).pathname === `${API_PATH}configure`,
)

await page.goto('/')
await page.goto(BASE_PATH)
await configureRequest

const chatRequest = page.waitForRequest((request) => new URL(request.url()).pathname === `${API_PATH}chat`)
await sendMessage(page, 'markdown', 'Show me markdown')
await chatRequest

const conversationPath = new URL(page.url()).pathname
expect(conversationPath).toMatch(/^\/demo\/[\w-]+$/)

// A fenced code block resolves a shiki language grammar through a dynamic import. In
// the CDN build that import is fetched from jsdelivr at runtime; here it must already
Expand All @@ -41,7 +64,37 @@ test.describe('offline artifact', () => {
// markdown would still read `$$`.
await expect(page.getByText('E=mc2').first()).toBeVisible()

const conversationId = conversationPath.slice('/demo'.length)
await waitForPersisted(page, 2, 10_000, conversationId)

const conversationLink = page.getByRole('link', { name: /Show me markdown/ })
await expect(conversationLink).toHaveAttribute('href', conversationPath)
await page.getByRole('link', { name: 'New conversation' }).click()
await expect(page).toHaveURL(new RegExp(`${BASE_PATH}$`))
await conversationLink.click()
await expect(page).toHaveURL(new RegExp(`${conversationPath}$`))

await page.reload()
await expect(page.getByText('def greet():')).toBeVisible()

// The real assertion for the fonts: anything still living on the CDN shows up here.
expect(attempted).toEqual([])
})

test('keeps the API directory independent from the navigation prefix', async ({ page }) => {
await configurePage(page, '/api/')
const attempted = await blockExternalRequests(page)
const configureRequest = page.waitForRequest((request) => new URL(request.url()).pathname === '/api/configure')

await page.goto(BASE_PATH)
await configureRequest

const chatRequest = page.waitForRequest((request) => new URL(request.url()).pathname === '/api/chat')
await sendMessage(page, 'text', 'Hello')
await chatRequest

await expect(page).toHaveURL(/^http:\/\/127\.0\.0\.1:\d+\/demo\/[\w-]+$/)
await expect(page.getByText('Hello from the test server')).toBeVisible()
expect(attempted).toEqual([])
})
})
41 changes: 40 additions & 1 deletion tests/headless/config.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { afterEach, describe, expect, it, vi } from 'vitest'

import { fetchConfig } from '../../src/lib/config'
import { fetchConfig, normalizeDirectoryPath, resolveStartupConfig } from '../../src/lib/config'

const respond = (body: unknown, init: { status?: number } = {}) => {
const status = init.status ?? 200
Expand All @@ -20,6 +20,7 @@ describe('fetchConfig', () => {
respond(config)

await expect(fetchConfig()).resolves.toEqual(config)
expect(fetch).toHaveBeenCalledWith('/api/configure')
})

it('rejects an error response instead of storing it as configuration', async () => {
Expand Down Expand Up @@ -51,3 +52,41 @@ describe('fetchConfig', () => {
await expect(fetchConfig()).rejects.toThrow(/models and builtin tools/)
})
})

describe('startup configuration', () => {
it('normalizes configured directories independently', () => {
expect(resolveStartupConfig({ basePath: '/demo', apiPath: 'services/chat' }, '/ignored/')).toEqual({
basePath: '/demo/',
apiPath: '/services/chat/',
})
expect(resolveStartupConfig({ basePath: '/demo' }, '/build/')).toEqual({
basePath: '/demo/',
apiPath: '/api/',
})
expect(resolveStartupConfig({ apiPath: '/demo/api' }, '/build/')).toEqual({
basePath: '/build/',
apiPath: '/demo/api/',
})
})

it('defaults to the normalized Vite base and root API directory', () => {
expect(resolveStartupConfig(undefined, '/docs')).toEqual({ basePath: '/docs/', apiPath: '/api/' })
expect(resolveStartupConfig(undefined, './')).toEqual({ basePath: '/', apiPath: '/api/' })
expect(resolveStartupConfig(undefined, 'https://cdn.example.com/package/')).toEqual({
basePath: '/',
apiPath: '/api/',
})
})

it('rejects origins, query strings, and fragments', () => {
expect(() => normalizeDirectoryPath('https://example.com/demo/', 'basePath')).toThrow(/same-origin path/)
expect(() => normalizeDirectoryPath('//example.com/demo/', 'basePath')).toThrow(/same-origin path/)
expect(() => normalizeDirectoryPath('/demo/?tenant=one', 'basePath')).toThrow(/query or fragment/)
expect(() => normalizeDirectoryPath('/demo/#chat', 'basePath')).toThrow(/query or fragment/)
})

it('rejects malformed startup configuration', () => {
expect(() => resolveStartupConfig('demo', '/')).toThrow(/must be an object/)
expect(() => resolveStartupConfig({ apiPath: 7 }, '/')).toThrow(/apiPath must be a string/)
})
})
13 changes: 10 additions & 3 deletions vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,19 @@ import tsconfigPaths from 'vite-tsconfig-paths'

// 8000 is quite common for backend, avoid the clash
const BACKEND_DEV_SERVER_PORT = process.env.BACKEND_PORT ?? 38001
const API_PROXY_PATH = process.env.API_PROXY_PATH ?? '/api'

const API_PROXY = {
'/api': {
function apiProxy(pathPrefix: string) {
return {
target: `http://localhost:${BACKEND_DEV_SERVER_PORT}/`,
changeOrigin: true,
},
rewrite: (path: string) => `/api${path.slice(pathPrefix.length)}`,
}
}

const API_PROXY = {
'/api': apiProxy('/api'),
...(API_PROXY_PATH === '/api' ? {} : { [API_PROXY_PATH]: apiProxy(API_PROXY_PATH) }),
}

const FAVICON_MIME_TYPES: Record<string, string | undefined> = {
Expand Down
Loading