Repository: pydantic/pydantic-ai
File: pydantic_ai_slim/pydantic_ai/ui/_web/app.py (Python)

Code before:

def _sanitize_version(version: str) -> str:
    """Sanitize version string for use as filename."""
    return re.sub(r'[^a-zA-Z0-9._-]', '_', version)


async def _get_ui_html(version: str, cdn_url_template: str | None = None) -> bytes:
    """Get UI HTML content from filesystem cache or fetch from CDN.

    Args:
        version: The UI version to fetch.
        cdn_url_template: Optional custom URL or file path for the chat UI.
            Falls back to the default CDN if not provided.
    """
    cdn_url_template = cdn_url_template or CDN_URL_TEMPLATE

    cache_dir = _get_cache_dir()
    cache_file = cache_dir / f'{_sanitize_version(version)}.html'

    if cache_file.exists():
        return cache_file.read_bytes()

    cdn_url = cdn_url_template.format(version=version)

    # Support local file paths
    if cdn_url.startswith(('/', '.', '~')) or (len(cdn_url) > 1 and cdn_url[1] == ':'):
        local_path = Path(cdn_url).expanduser()
        if local_path.is_file():
            return local_path.read_bytes()
        raise FileNotFoundError(f'Local UI file not found: {cdn_url}')

    async with httpx.AsyncClient() as client:
        response = await client.get(cdn_url)
        response.raise_for_status()
        content = response.content

    cache_file.write_bytes(content)
    return content


def create_web_app(
    agent: Agent[AgentDepsT, OutputDataT],
    models: ModelsParam = None,
    builtin_tools: Sequence[AbstractBuiltinTool] | None = None,
    deps: AgentDepsT = None,
    model_settings: ModelSettings | None = None,
    instructions: str | None = None,
    custom_cdn_url: str | None = None,
) -> Starlette:
    """Create a Starlette app that serves a web chat UI for the given agent.


Review comment (suggestion):
I'd also like to support `Path` instances for local paths, and a plain string of HTML if the user has done the downloading already (like I mentioned on the issue!)

Code after:

async def _get_ui_html(html_source: str | Path | None = None) -> bytes:
    """Get UI HTML content from the specified source or default CDN.

    When html_source is provided, it is used directly.
    When html_source is None, fetches from the default CDN.

    Args:
        html_source: Path or URL for the chat UI HTML. Can be:
            - None: Uses the default CDN (cached locally)
            - A Path instance: Reads from the local file
            - A URL (http:// or https://): Fetches from the URL
            - A file path string: Reads from the local file
    """
    # Use default CDN with caching
    if html_source is None:
        cache_dir = _get_cache_dir()
        cache_file = cache_dir / f'{CHAT_UI_VERSION}.html'

        if cache_file.exists():
            return cache_file.read_bytes()

        async with httpx.AsyncClient() as client:
            response = await client.get(DEFAULT_HTML_URL)
            response.raise_for_status()
            content = response.content

        cache_file.write_bytes(content)
        return content

    # Handle Path instances
    if isinstance(html_source, Path):
        html_source = html_source.expanduser()
        if html_source.is_file():
            return html_source.read_bytes()
        raise FileNotFoundError(f'Local UI file not found: {html_source}')

    # Handle URLs with filesystem caching
    if html_source.startswith(('http://', 'https://')):
        cache_dir = _get_cache_dir()
        url_hash = hashlib.sha256(html_source.encode()).hexdigest()[:16]
        cache_file = cache_dir / f'url_{url_hash}.html'

        if cache_file.exists():
            return cache_file.read_bytes()

        async with httpx.AsyncClient() as client:
            response = await client.get(html_source)
            response.raise_for_status()
            content = response.content