跳转至内容

Web 界面

本演练讲解位于 Python 零售分析 API 前端的静态浏览器客户端。您将了解 FastAPI 如何提供文件、原生 JavaScript 如何将聊天响应流式传输到消息列表、本地设置如何持久保存,以及模型选择器如何与实时模型列表保持一致。

应用结构与端口

Python UI 由 app/static/index.htmlapp/static/app.js 中的静态 HTML 和原生 JavaScript 实现,通过 FastAPI 服务,而非 Blazor WebAssembly。

无构建步骤且无需下载 WebAssembly 运行时。该设计放弃了组件模型、编译时 UI 类型安全以及生成的客户端代码。

单个服务器在端口 5070 上同时提供 API 和 UI 服务。这与 .NET 路径不同:.NET 中 API 运行在端口 5050,而 Blazor UI 运行在端口 5051。由于浏览器请求同源,常规 UI 路径无需跨域请求。

⚠️ 端口号的选择并非随意。Chrome、Edge 和 Firefox 会直接阻止端口 5060(SIP),因此在该端口上运行的 UI 会报错 ERR_UNSAFE_PORT,尽管 curl 命令成功。在选择浏览器需加载的端口时,请避免使用 5060、5061 和 6000。

在空白状态下,页面会显示欢迎标题和五个建议的零售问题:

零售分析助手的空白状态 UI:页眉包含模型下拉列表、清除按钮和主题切换按钮;页面中央显示欢迎标题、五个建议项和消息输入框。

FastAPI 静态挂载

app/main.py 首先注册 API 路由器:

app.include_router(chat.router)
app.include_router(transactions.router)
app.include_router(segments.router)

随后将其挂载至 /

# Mounted last so it does not shadow the /api routes above.
app.mount("/", StaticFiles(directory=STATIC_DIR, html=True), name="static")

⚠️ 顺序至关重要。StaticFiles 挂载在 /,因此若在路由器之前挂载它,将导致 /api/... 请求被覆盖。

index.html

index.html 包含整个页面框架:标题栏、模型选择器、清除按钮、主题切换器、消息容器、欢迎面板、建议区域、文本输入框和发送按钮。

<h1>📊 Retail Analytics Assistant</h1>
<span class="badge">Copilot SDK Demo · Python</span>

页面从 CDN 加载 markedhighlight.js,用于渲染 Markdown 和突出显示语法。样式表为 app/static/app.css,该文件是从 Blazor 项目复制而来,已移除仅适用于 Blazor 的规则,确保两个界面外观一致。

页面状态与本地存储

app.js 与 Blazor Home.razor 页面保持相同状态:

let messages = [];
let selectedModel = 'claude-haiku-4.5';
let isDark = true;
let isStreaming = false;

它持久化消息、选定模型和主题,使用与 Blazor 客户端相同的键:

const STORAGE_KEYS = {
    messages: 'chat_messages',
    model: 'selected_model',
    theme: 'theme',
};

loadState() 在页面加载时恢复这些值;setTheme() 更新根元素的类、highlight.js 主题以及 localStorage 的值。

模型选择器

模型选择器通过请求 GET /api/chat/models 获取数据:

const res = await fetch('/api/chat/models');

API 返回一个 JSON 列表,包含 {id, name, description}。UI 将该列表折叠为 Blazor ChatService 所需的 id → 标签字典结构:

const list = await res.json();
if (Array.isArray(list) && list.length > 0) {
    models = Object.fromEntries(
        list.filter((m) => m.id).map((m) => [m.id, m.name || m.id])
    );
}

如果 API 无法访问,页面将回退到包含六个模型的静态清单(claude-haiku-4.5gpt-4.1gpt-5claude-sonnet-4.5claude-opus-4.5gemini-2.5-pro)。当 localStorage 中保存了过期模型时,若可用则替换为claude-haiku-4.5,否则使用第一个实时可用模型。

流式渲染

sendMessage() 会追加用户消息、添加一个空的助手消息占位项,并将请求发送到 SSE 端点:

const res = await fetch('/api/chat/stream', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ prompt, model: selectedModel }),
});

⚠️ 该字段名至关重要。 早期版本发送了{ message: prompt, ... }ChatRequest 声明了prompt,而Pydantic会忽略未知字段而非拒绝它们——因此请求返回200状态码,流式传输真实响应,且输入的提示内容被静默丢弃。未发生错误;模型仅对空问题作出回应。

这正是客户端/服务器边界使用宽松解析器的风险所在,也是 tests/test_chat_contract.py 会断言 app.js 发送的字段与 ChatRequest 读取的字段一致。

响应体通过fetch()res.body.getReader()TextDecoder 读取:

const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';

每次读取都会被解码、按行分割,且保留不完整的末尾行以供下一次读取使用:

buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() ?? '';

解析器会查找data:帧,将[DONE]识别为完成标记,并仅追加content的值。当服务器在[DONE]后关闭响应时,读取循环将退出。

if (!line.startsWith('data: ')) continue;
const payload = line.slice(6);
if (payload === '[DONE]') continue;

渲染节流

UI 每 50 毫秒重绘一次,约等于每秒 20 帧:

const timer = setInterval(() => {
    if (!needsRender) return;
    needsRender = false;
    messages[messages.length - 1].content = content;
    renderMessages();
}, 50);

这是 Blazor 客户端渲染定时器的直接类比。快速模型可生成数百个增量;为每个词元进行重绘会浪费浏览器资源,并降低滚动稳定性。

输入、建议与主题

单击按钮或按下 Enter(不同时按 Shift)即可发送输入;流式传输期间输入会被禁用,响应完成后将恢复焦点。建议项使用与键入内容相同的发送路径,例如 Who are our highest spending customers?Predict which segment customer C002 belongs to。主题按钮用于切换深色/浅色模式,并切换当前启用的 highlight.js 样式表。

通过上述路径渲染完成的交互:

聊天界面显示已完成的对话:用户提问‘列举三个零售KPI,每行一个。’,助手回复了一个编号的Markdown列表,包含转化率、平均订单价值(AOV)和客户留存率。