Web 界面¶
本演练讲解位于 Python 零售分析 API 前端的静态浏览器客户端。您将了解 FastAPI 如何提供文件、原生 JavaScript 如何将聊天响应流式传输到消息列表、本地设置如何持久保存,以及模型选择器如何与实时模型列表保持一致。
应用结构与端口¶
Python UI 由 app/static/index.html 和 app/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。
在空白状态下,页面会显示欢迎标题和五个建议的零售问题:

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 包含整个页面框架:标题栏、模型选择器、清除按钮、主题切换器、消息容器、欢迎面板、建议区域、文本输入框和发送按钮。
页面从 CDN 加载 marked 和 highlight.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 客户端相同的键:
loadState() 在页面加载时恢复这些值;setTheme() 更新根元素的类、highlight.js 主题以及 localStorage 的值。
模型选择器¶
模型选择器通过请求 GET /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.5、gpt-4.1、gpt-5、claude-sonnet-4.5、claude-opus-4.5、gemini-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 读取:
每次读取都会被解码、按行分割,且保留不完整的末尾行以供下一次读取使用:
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 样式表。
通过上述路径渲染完成的交互:
