diff --git a/CLAUDE.md b/CLAUDE.md index e5e14da4..873234c0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 @@ -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: @@ -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 diff --git a/README.md b/README.md index c7e27c91..5133167b 100644 --- a/README.md +++ b/README.md @@ -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 + +``` + +`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 diff --git a/playwright.offline.config.ts b/playwright.offline.config.ts index b043fc1c..de982ef6 100644 --- a/playwright.offline.config.ts +++ b/playwright.offline.config.ts @@ -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', @@ -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: [ @@ -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, }, diff --git a/src/Chat.tsx b/src/Chat.tsx index 1e8d8513..a0de7d67 100644 --- a/src/Chat.tsx +++ b/src/Chat.tsx @@ -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' @@ -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 }), }), ) diff --git a/src/lib/base-path.ts b/src/lib/base-path.ts index eb80660b..cff91fc6 100644 --- a/src/lib/base-path.ts +++ b/src/lib/base-path.ts @@ -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 diff --git a/src/lib/config.ts b/src/lib/config.ts index f0fe3a09..ccd3ba26 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -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 ( @@ -50,7 +100,7 @@ function isRemoteConfig(value: unknown): value is RemoteConfig { * the chat down instead. */ export async function fetchConfig(): Promise { - 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)}`) } diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 11f02fe2..a33d22be 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -1 +1,5 @@ /// + +interface Window { + PYDANTIC_AI_CHAT_CONFIG?: import('./lib/config').StartupConfig +} diff --git a/tests/e2e/conversation.ts b/tests/e2e/conversation.ts index 1a366049..515b2cef 100644 --- a/tests/e2e/conversation.ts +++ b/tests/e2e/conversation.ts @@ -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 @@ -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 */ diff --git a/tests/e2e/offline/offline-artifact.spec.ts b/tests/e2e/offline/offline-artifact.spec.ts index 4007cf1d..03f99757 100644 --- a/tests/e2e/offline/offline-artifact.spec.ts +++ b/tests/e2e/offline/offline-artifact.spec.ts @@ -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 @@ -24,12 +27,32 @@ async function blockExternalRequests(page: Page): Promise { return attempted } +async function configurePage(page: Page, apiPath: string): Promise { + 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 @@ -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([]) + }) }) diff --git a/tests/headless/config.test.ts b/tests/headless/config.test.ts index 1986f7e4..dded6c18 100644 --- a/tests/headless/config.test.ts +++ b/tests/headless/config.test.ts @@ -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 @@ -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 () => { @@ -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/) + }) +}) diff --git a/vite.config.ts b/vite.config.ts index b6df3b25..40188b51 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -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 = {