跳转至

Web 服务端

Web 包基于 FastAPI。create_app() 构建 ASGI 应用,所有服务选项都在这里配置; serve() 负责用 uvicorn 运行它。

pip install "lovia[web]"
from lovia import Agent
from lovia.web import create_app, serve

agent = Agent(name="assistant", model="<model>")
serve(create_app(agent, db_path="lovia.db"), host="127.0.0.1", port=8000)

create_app() 与 serve()

serve(target, *, host="127.0.0.1", port=8000, **uvicorn_kwargs) 运行由 create_app() 构建的应用;直接传入 Agent(或 {name: agent} 映射)时,则按默认选项构建应用, serve(agent) 是最快的启动方式。log_level、ssl_certfile、workers、root_path (部署在代理后面时)等选项会原样传给 uvicorn。改用其他 ASGI 服务器时, 不必经过 serve()。

create_app(agent_or_agents, ...) 的选项:

选项 默认值 说明
agent_or_agents 必填 一个 Agent 或 {name: agent} 映射
db_path / store / session ./.lovia/<agent>.db Transcript 和聊天元数据存储
max_turns / budget / retry / context_policy — 应用于每个托管 Run 的设置
tracer None 托管 Run 的 Span 记录器
titles / title_model True / Agent 模型 在后台生成对话标题
followups / followup_model False / Agent 模型 回答结束后生成追问建议(见下文)
approval_timeout None 超过指定秒数后自动拒绝未处理的审批
max_background_runs 8 并发托管 Run;达到上限后,新请求返回 429
ui True 设为 False 时只提供 API;传入 ChatUI 可定制内置页面(见下文)
cors_origins None 允许跨域访问的浏览器 Origin;不设置时不发送 CORS 响应头
token / auth None 使用 Bearer token 保护业务 API,或传入自定义 FastAPI 依赖(见下文)
lifespan None 你自己资源的生命周期,写法同 FastAPI(lifespan=...)(见下文)
run_deps None 用页面上下文和请求中的身份,为每次运行构造 RunContext.deps(见运行依赖)
title "lovia" 页面标题,也是 API 报告的标题

create_app 自己占用了应用的 FastAPI lifespan,用来启停调度器、收尾仍在运行的 Run。 工具要用的资源(数据库连接池、HTTP 客户端)想随应用一起打开和关闭,就把你自己的 lifespan 传给 lifespan=。它包在 lovia 的外层:资源在第一个 Run 之前就绪,并且等运行中的 Run 都收尾之后才关闭。你 yield 的 state 会照常出现在 request.state 上。

from contextlib import asynccontextmanager

@asynccontextmanager
async def lifespan(app):
    await db.connect()
    yield
    await db.close()

app = create_app(agent, lifespan=lifespan)

ChatUI 的选项(用于 ui=ChatUI(...)):

选项 默认值 说明
empty_title / empty_description lovia 默认文案(随用户语言) 空白聊天页的文案;说明可以是若干短句组成的列表
empty_examples () 空白聊天页上的示例问题;点击后填入输入框,但不会自动发送
login_url None 使用 auth= 时未登录用户的跳转地址(见认证)
assets None 你的 UI 目录:index.html 覆盖模板、样式表、图片、脚本(见定制页面)
scripts () 页面启动前导入的 JS 模块,例如组件渲染器(见扩展脚本)
styles () 在内置样式表之后加载的样式表
logo / favicon None 替换侧栏标题的图片 / 标签页图标
messages {} 按语言提供的界面文案:覆盖内置文案、新增语言、给自己模板里的元素用(见文案与语言)
default_lang None 用户还没在设置里选过语言时使用的语言;None 表示跟随浏览器
from lovia.web import ChatUI, create_app

app = create_app(
    agent,
    title="Acme",
    ui=ChatUI(empty_title="问问 Acme", empty_examples=["总结今天的工单"]),
)

Transcript、聊天元数据和 Run 检查点共用同一个 SQLite 文件,并以 WAL 模式打开, 使界面的读取不必排在检查点写入之后。已有的数据库会在首次打开时完成迁移。 <name>.db-wal 和 <name>.db-shm 两个伴随文件只在有连接打开时存在,最后一个连接 关闭时会把 WAL 折回主库 —— 因此从运行中的服务器复制需要三个文件都带上,而已停止的 服务器仍然只留下一个文件。若文件系统不支持共享内存(部分网络挂载),SQLite 会拒绝 WAL 并记录一条警告,同时保留原有的 journal 模式;也可以直接传 store=ChatStore.sqlite(path, wal=False) 显式关闭。

端点契约与 ChatStore 接口见 HTTP API。

定制页面

把自己的文件放进一个目录,用 ChatUI(assets=...) 指向它。lovia web init-ui ui 可以生成一个起步 目录:列出全部 block 的 index.html、列出全部设计变量及默认值的 theme.css,以及带类型检查的 extension.js——内容都是注释掉的,生成后页面不会有任何变化。

ui/
├── index.html    # 可选:继承内置页面,填写其中的 block
├── theme.css
├── logo.svg
├── favicon.png
└── acme.js
from lovia.web import ChatUI, create_app

app = create_app(
    agent,
    title="Acme",
    ui=ChatUI(
        assets="ui",
        styles=["theme.css"],
        logo="logo.svg",
        favicon="favicon.png",
        scripts=["acme.js"],
    ),
)

lovia 把这个目录挂在 <app>/ui/ 下。除模板(*.html)和以点开头的文件外,目录里的 文件都是公开的;浏览器每次加载都会重新验证,所以改完文件刷新即可看到,不用重启服务。 scripts、styles、logo 和 favicon 里的每一项可以是:

  • assets 里的文件:"theme.css";
  • 应用上的路径:"/brand/theme.css",用于你自己提供的文件;部署在 路径前缀下时依然有效;
  • URL:"https://cdn.example.com/theme.css"。

除了这些文件,在内置页面的基础上扩展还靠下面几种公开接口,各管一件事:

接口 用途
模板 block 在固定位置放入自己的内容:链接、输入框旁的控件、公告
设计变量 颜色、字体、圆角、间距
data-part 属性 在自己的 CSS 里定位某个区域:隐藏它,或改它的样式
data-i18n key 文案与语言
window.lovia 你的脚本;调用 API

其余一切——CSS 类名、元素 id、JavaScript 模块——都属于内部实现,可能随版本变化。

两个可运行的示例把这些串了起来:examples/32_web_branding.py 给页面换上品牌(logo、设计变量、 block、文案、新增语言),examples/33_web_extensions.py 通过 window.lovia 扩展页面行为 (工具视图、事件、页面动作)。

模板 block

assets 里的 index.html 会替换内置页面。继承内置页面,只填写需要的 block 即可:

{# ui/index.html #}
{% extends "lovia/index.html" %}

{% block sidebar_nav %}
<a href="/help" data-i18n="acme.help">Help center</a>
{% endblock %}

{% block welcome %}
{{ super() }}
<p class="acme-notice">今晚 22:00 例行维护。</p>
{% endblock %}

{% block sidebar_footer %}
<a class="signout" href="/logout">退出登录</a>
{% endblock %}
Block 渲染位置
head <head> 末尾:meta 标签、预加载
sidebar_header 侧栏顶部;它包住了标题(或 logo),填写后两者都会被替换,比如放一个 logo 再配上名字
sidebar_nav 侧栏导航末尾、"设置"下方:你自己的链接
sidebar_footer 侧栏底部:用户菜单、退出登录链接
topbar_actions 顶栏按钮组的最左侧
composer_actions 输入框内、文本框之后:模式切换、选择器
welcome 空白聊天页的内容;{{ super() }} 保留默认的标题、说明和示例问题。点"新对话"时页面会恢复它
body_end 应用脚本之后:你自己的脚本

sidebar_nav 里的链接按内置导航项的样式绘制,指向当前视图的那一个显示为选中。 在模板里引用 assets 中的其他文件,用 {{ url_for('ui-assets', path='banner.png').path }},它会带上路径前缀。

样式

内置的 styles.css 放在名为 lovia 的 cascade layer 里。你自己的样式表(不放进任何 layer)中的规则总会压过内置规则,不管选择器的优先级如何,不需要 !important。

更换主题时,覆盖 styles.css 开头的设计变量。两个主题各有一套取值,所以要分别设置; 直接写在 :root 上的变量对两个主题都生效。--lv-accent-hover、--lv-accent-soft、 --lv-accent-text 由 --lv-accent 推导,换品牌色通常只需设 --lv-accent,需要时再单独覆盖:

/* ui/theme.css */
[data-theme="light"] { --lv-accent: #6d28d9; }
[data-theme="dark"]  { --lv-accent: #a78bfa; }
:root { --lv-sans: "Inter", system-ui, sans-serif; --lv-radius: 8px; }
变量 作用
--lv-bg、--lv-surface、--lv-surface-2、--lv-surface-3、--lv-border、--lv-border-strong 画布、面板、卡片、悬停态及其边框
--lv-sidebar-surface 侧栏背景
--lv-ink、--lv-ink-2、--lv-muted、--lv-muted-2 文字,由深到浅
--lv-accent、--lv-accent-hover、--lv-accent-soft、--lv-accent-text 品牌色及其衍生色(后三个默认由 --lv-accent 推导)
--lv-user-surface、--lv-user-border 用户消息气泡
--lv-chart-1 … --lv-chart-8 图表系列颜色
--lv-warn、--lv-danger、--lv-ok 状态色
--lv-sans、--lv-mono 字体
--lv-radius-xs … --lv-radius-xl 圆角
--lv-sidebar-w、--lv-content-w 侧栏宽度、对话区的阅读宽度

要定位某个区域,就选它的 data-part:

[data-part="export"] { display: none; }                                   /* 去掉导出菜单 */
[data-part="tool"][data-tool="read_process_output"] { display: none; }   /* 隐藏某个工具的卡片 */
[data-part="turn"][data-role="user"] .lv-bubble { border-radius: 4px; }

页面自己的类名都以 lv- 开头、变量都以 --lv- 开头,所以扩展里的工具类框架(比如 Tailwind 的 .table、--radius-sm)不会误改页面样式。类名本身仍是内部实现,定位区域请用 data-part。

区域 data-part
侧栏 sidebar、sidebar-header、agent-switcher、sidebar-nav、new-chat、memory、schedules、settings、sessions-label、sessions-search、sessions
顶栏 topbar、chat-title、topbar-actions、tasks、export、files-toggle
视图 view(打开的视图画在这个容器里)
对话区 transcript、welcome、todo-panel、turn(带 data-role="user" 或 "assistant")、tool(带 data-tool="<工具名>" 和 data-status:running、awaiting(等待用户,比如在提问),之后是 done 或 error)、activity(连续两个及以上的思考 / 工具步骤折叠成的一行摘要;回复正文开始后自动收起)、message-actions(一条消息的操作;每个按钮带 data-action="<id>")
输入框 composer、attach、context-ring
其他 files-panel;设置面板里的 language(语言那一行)

隐藏某个工具的卡片不会连带隐藏它的审批请求:审批卡片是单独渲染的。

文案与语言

界面自带英文和中文。ChatUI(messages=...) 可以修改文案、新增语言,也可以给自己模板里的 元素提供文案:

ui = ChatUI(
    default_lang="zh",
    messages={
        "en": {"composer.placeholder": "Ask Acme anything…", "acme.help": "Help center"},
        "zh": {"composer.placeholder": "问问 Acme…", "acme.help": "帮助中心"},
        "ja": {"composer.placeholder": "何でも聞いてください…", "nav.newChat": "新しいチャット"},
    },
)
  • key 见 lovia/web/static/js/i18n.js(nav.newChat、composer.placeholder 等)。如果在界面 自带的前缀下写了一个界面没有的 key(拼错了,或者新版本改了名),浏览器 console 里会有警告。
  • 界面没带的语言(上例的 ja)会出现在设置的语言列表里;缺少的文案回退到英文。
  • 自己前缀下的 key(acme.help)通过 data-i18n(文本)、data-i18n-title、 data-i18n-aria 和 data-i18n-placeholder 给模板里的元素提供文案。当前语言没有对应 文案时,元素保留自己原本的文字。
  • default_lang 在用户于设置里选择语言之前生效,选"自动"即回到它。想让用户固定使用一种 语言,就设置它并隐藏语言选项:[data-part="language"] { display: none; }。

扩展脚本

ChatUI(scripts=[...]) 列出的 ES 模块会在页面启动之前导入,所以它们注册的内容(比如 组件渲染器、工具视图)在首次渲染时就已就位。脚本通过 window.lovia 和页面打交道,这是页面唯一公开的脚本接口;其余模块都属于内部实现。

window.lovia 说明
version 提供页面的 lovia 版本,用于特性检测
ready 应用启动结束时兑现的 promise:页面已就绪,首次的对话列表请求和 URL 指定的对话都已加载完(或失败),URL 指定的视图已打开。页面发现用户未登录时一直挂起
state 冻结的快照:sessionId(新对话为 null)、agent、theme、lang、busy(是否有运行正在流式输出)、view(打开的视图,没有时为 null)。需要最新值时重新读取
on(event, fn) 监听事件;返回一个取消监听的函数
send(text) 以用户身份发送 text;有运行在进行时,会排队到下一轮
setDraft(text) 把 text 放进输入框,由用户修改后发送
newChat() / openSession(id) 打开一个新对话 / 打开对话 id,并关闭视图
toast(text, { type }) 短暂提示;type 为 "info"(默认)或 "error"
fetch(path, init) 用来请求应用路由(你自己的,或 HTTP API 的)的 fetch:path 以 / 开头,会加上路径前缀;遇到 401 时走页面的登录处理
url(path) 应用内路径对应的 URL,已加上路径前缀,用于 <img src>、EventSource、链接
t(key, params) 当前语言下的界面文案,包括你自己的 key
registerRenderer(kind, renderer) / getRenderer(kind) UI 组件
registerToolView(name, view) 工具视图
registerMessageAction(id, action) / refreshMessageActions() 消息操作
registerView(id, view) / openView(target) / closeView() 视图
provideContext(key, fn) 把 key 加进运行开始时的上下文,发送时读取 fn() 的值,见运行依赖

所有 register* 调用和 provideContext 都会返回一个撤销函数,用法和 on 一样。撤销后,会恢复同一个 key 下被它替换掉的那一项(比如你包装过的内置渲染器、更早注册的视图);不管按什么顺序撤销,结果都对。

// ui/acme.js
const { lovia } = window;

lovia.ready.then(() => {
  console.info(`Acme extensions on lovia ${lovia.version}`);
});

// 模板在 composer_actions block 里放的按钮。
document.getElementById('acme-summarize')?.addEventListener('click', () => {
  lovia.send(lovia.t('acme.summarizePrompt'));
});

不要在脚本顶层 await lovia.ready:页面要等你的脚本加载完才启动,两边会互相等待,直到下面说的 5 秒上限。加载失败的脚本会弹出错误提示,页面不带它照常启动。5 秒后仍在加载的脚本不再阻塞启动, 它会继续加载,加载完再注册。脚本是你自己的代码,拥有页面的全部权限,所以第三方代码请放进 assets 自托管,不要直接引用别人的 CDN。

想要编辑器补全和类型检查,可以引用 lovia 自带的类型声明(路径在你的环境的 site-packages 下):

// @ts-check
/// <reference path="…/site-packages/lovia/web/static/lovia.d.ts" />

事件

事件描述的是屏幕上这个页面的动静。抛出异常的监听器会被记录下来,不影响其他监听器。

事件 payload 触发时机
session { sessionId } 屏幕上换了一个对话(null 表示新对话);新对话首次保存时也会触发
agent { agent } 用户切换了 agent
run-start { sessionId } 一次运行开始在对话里流式输出:用户发送,或刷新后重新接上
run-end { sessionId, status, usage } 对话不再流式输出这次运行。status:done(已回答)、error、cancelled(用户停止)或 detached(用户切换了对话或连接断开,服务端可能还在运行)。usage 是已回答运行的 token 用量,否则为 null
theme { theme } 浅色或深色主题生效
view { view } 打开了一个视图(如 'exec/perf'),或回到了对话(null)
lovia.on('run-end', ({ status, usage }) => {
  if (status === 'done') analytics.track('reply', { tokens: usage?.total_tokens });
});

工具视图

每次工具调用都显示为一张卡片:一行写着名字和参数,展开后是参数和结果。registerToolView 可以改变某个工具卡片的呈现方式——适合你无法控制的工具(比如 MCP 服务器),也适合面向终端用户的 部署:order_status(id: …) 对他们没有意义。lovia 自带的工具已经这样显示——「读取文件 a.md」 「$ ls -la」「搜索网页 “…”」——你注册的视图(包括 '*' 兜底)优先于它们:

// 代替 name(args) 的一行摘要,作用于所有没有专属视图的工具;返回 null 交给下一层
// (lovia 自带工具有自己的一行),最后才是 name(args)。
const labels = { order_status: '正在查询订单' };
lovia.registerToolView('*', { summary: (tool) => labels[tool.name] ?? null });

lovia.registerToolView('web_search', {
  summary: (tool) =>
    tool.status === 'done' ? `已搜索「${tool.args?.query}」` : `正在搜索「${tool.args?.query}」…`,
  // 绘制卡片主体;`tool` 每次变化都会重新调用。
  render(el, tool) {
    if (tool.status !== 'done') return;
    const list = document.createElement('ol');
    for (const hit of JSON.parse(tool.result).results ?? []) {
      const link = document.createElement('a');
      link.href = hit.url;
      link.target = '_blank';
      link.textContent = hit.title; // 不要用 innerHTML:工具输出不可信
      const item = document.createElement('li');
      item.append(link);
      list.append(item);
    }
    el.replaceChildren(list);
  },
});
view 字段 说明
summary(tool) 代替 name(args) 的一行摘要;返回 null 或 undefined 时依次交给 '*' 视图、lovia 自带的一行、name(args)。鼠标悬停时显示它代表的调用
render(el, tool, ctx) 把卡片主体画进 el。约定和组件渲染器相同(ctx、可选的清理函数),原始结果仍可通过 "Source" 开关查看
snapshot(tool, ctx) 用于对话 HTML 导出的静态 HTML
  • tool 是这次调用的冻结快照:id、name、args(已解析;超过 256 KB 或无法解析时没有)、 status(running、awaiting 等待用户、done、error)、result(结束后的输出文本)、 isError、images。它每次变化,视图的函数都会重新调用。
  • 工具名的专属视图优先于 '*' 视图,而且逐个字段回退:只有 summary 的视图仍会用 '*' 视图的 render。
  • 结果本身是 UI 组件的,仍按那个组件绘制;视图的 summary 照样生效。
  • 隐藏某个工具的卡片是 CSS 的事([data-part="tool"][data-tool="…"],见样式), 不归视图管。
  • 以 lovia. 开头的 kind 归 lovia 自己使用,不要在这些名字下注册渲染器。

消息操作

registerMessageAction(id, action) 在消息底部的复制按钮旁加一个按钮:给回答评分、举报、存到笔记…… lovia 不保存这些数据,由操作去调用你自己的路由:

const ratings = new Map(); // ref → 'up' | 'down',屏幕上这个对话的评分

lovia.registerMessageAction('rate-up', {
  label: '回答得好',
  icon: '👍',
  when: (msg) => msg.role === 'assistant' && msg.ref !== null,
  pressed: (msg) => ratings.get(msg.ref) === 'up',
  async run(msg) {
    const res = await lovia.fetch('/feedback', {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify({ session_id: msg.sessionId, ref: msg.ref, rating: 'up' }),
    });
    if (!res.ok) throw new Error('评分保存失败'); // 以提示框显示
    ratings.set(msg.ref, 'up');
    lovia.refreshMessageActions(); // 画成按下状态
  },
});
action 字段 说明
label 悬停提示和无障碍名称
icon 内置图标名、SVG 标记,或 emoji 之类的文字;不提供时显示 label
when(msg) 这条消息是否显示该操作;不提供时所有消息都显示
pressed(msg) 按钮是否画成按下状态(aria-pressed),比如已经给过的评分
run(msg) 点击时调用;可以是 async,结束前按钮处于禁用状态。抛出的错误以提示框显示
  • msg 是冻结的快照:sessionId、role(user 或 assistant)、text(Markdown,即复制得到的 内容)和 ref。
  • ref 标识一条用户消息,形如 "<run_id>:<n>";回复沿用它所回答的那条消息的 ref,所以 (sessionId, ref) 就是你为某条消息保存数据时的键。它在刷新后不变,也绝不会指向别的消息:回退会 让一条消息的 ref 永久作废,修改后的问题、重新生成的回答都会拿到新的 ref,旧评分不会落到新内容上。
  • 服务端给消息命名之前,ref 为 null:运行结束时命名;运行失败或被停止的,在下次加载这个对话时 命名(对话存储不保存 checkpoint 时,所有运行都是如此)。用 when: (msg) => msg.ref !== null 可以让操作等到那时再出现;不需要 ref 的操作(引用、翻译)立即显示。
  • 操作只显示在已经定稿的消息上:还在流式输出的回复、还在排队的消息都没有。
  • when 和 pressed 在绘制消息底部时调用。它们依赖的状态变化后(保存了评分、为刚打开的对话取回了 评分),调用 lovia.refreshMessageActions()。用同一个 id 再次注册会替换原来的操作;调用 registerMessageAction 返回的函数会移除它,并恢复被它替换的那个操作。

完整示例见 examples/35_web_feedback.py:回答上的 👍 / 👎 由示例自己的路由存进 SQLite,另有报表 页面和一个"引用"操作。

视图

视图是你自己的页面,显示在主区域、替代聊天:仪表盘、配置表单、记录列表……注册之后放一个链接, 普通链接就能打开它:

{% block sidebar_nav %}
<a href="?view=dashboard">仪表盘</a>
{% endblock %}
lovia.registerView('dashboard', {
  title: '仪表盘',
  async render(el, ctx) {
    const res = await lovia.fetch('/acme/stats');
    el.replaceChildren(drawStats(await res.json(), { tab: ctx.path }));
    const off = lovia.on('theme', redrawCharts);
    return off; // 视图关闭或重新绘制前调用
  },
});
view 字段 说明
title 视图打开时顶栏显示的标题
render(el, ctx) 把视图画进 el,每次打开、子路径每次变化时调用。可以是 async,可以返回清理函数。ctx.path 是 ?view=<id>/<path> 里 id 之后的部分(没有时为 '')
  • 视图盖在对话上面,而不是离开对话。 对话仍是当前对话(state.sessionId),运行照常流式 输出;视图的状态和对话一起放在 URL 里:?session=…&view=dashboard。
  • 链接。 普通点击指向 ?view=<id>[/<path>] 的链接,就在原地打开视图;中键或 ⌘/Ctrl 点击 会在新标签页打开,新页面启动后直接显示该视图。?view=(空值)回到对话。请写成上面的相对形式, 这样部署在路径前缀下也成立。消息里的链接不会被拦截:模型输出不能驱动导航。
  • 回到对话。 点击任意对话(包括当前对话)、开始新对话、lovia.openSession、lovia.newChat 和 lovia.closeView() 都会关闭视图。lovia.send 不会:消息照常发到下面的对话。视图打开期间 如果对话开始等用户(审批、提问),页面会提示。在手机上,打开视图或对话都会收起侧栏抽屉。
  • 显示什么。 顶栏保留,显示视图的标题和你在 topbar_actions 里放的内容;属于对话的部分会隐藏。 视图画在 [data-part="view"] 里,可以给它加样式。sidebar_nav 里指向当前视图的链接带有 aria-current="page"(按 id 比较,所以打开 exec/perf 时 ?view=exec 仍显示选中)。
  • 生命周期。 每次绘制前先执行上一次的清理函数。render 抛错时视图里显示一条提示;异步 render 完成时视图已被切走,就立即调用它返回的清理函数。用同一个 id 再次注册,正打开的视图 会重新绘制;调用 registerView 返回的函数会移除它,正打开的视图随之换成被它替换的那个,没有则关闭。 切换主题不会重新绘制视图(会丢掉表单输入和滚动位置),需要跟随 主题时监听 theme 事件。
  • openView(target) 遇到没有注册的 id 会抛错;链接指向这样的 id、或启动时 URL 里是这样的 id, 则只显示一条提示。id 只能用 a-z、0-9 和 -,以 lovia 开头的留给 lovia 自己。

用组件框架绘制

render(el, ctx) 交给你一个元素、收回一个清理函数,所以任何能挂载到元素上的库都可以用来画视图, 而且依然不需要构建。把库的 ES module 文件放进 assets 目录,在 head block 里用 import map 映射它们的模块名,然后在脚本里 import。以 Preact 和 htm (在普通 JS 里写类似 JSX 的模板)为例:

{% block head %}
<script type="importmap">
{ "imports": {
  "preact": "{{ url_for('ui-assets', path='vendor/preact.js').path }}",
  "preact/hooks": "{{ url_for('ui-assets', path='vendor/preact-hooks.js').path }}",
  "htm": "{{ url_for('ui-assets', path='vendor/htm.js').path }}",
  "htm/preact": "{{ url_for('ui-assets', path='vendor/htm-preact.js').path }}"
} }
</script>
{% endblock %}
import { html, render as mount } from 'htm/preact';
import { useState } from 'preact/hooks';

function Stats({ stats }) {
  const [tab, setTab] = useState('day');
  return html`…`;
}

lovia.registerView('dashboard', {
  title: '仪表盘',
  async render(el) {
    const res = await lovia.fetch('/acme/stats');
    mount(html`<${Stats} stats=${await res.json()} />`, el);
    return () => mount(null, el); // 卸载:组件里的 effect 随之清理
  },
});
  • url_for('ui-assets', …) 让这些 URL 在路径前缀下也正确。
  • 在 render 里加载数据再传下去:加载失败时视图照常显示提示,和不用框架时一样。
  • 框架只管 el:页面其他部分通过 window.lovia 操作,不要渲染进 lovia 的元素里。
  • Lit、Vue 的 vue.esm-browser.prod.js 用法相同。需要编译器的框架(Svelte、JSX)也可以用, 只要你自己构建,再把产物列进 scripts。
  • 视图可以用 Tailwind(或基于它的 shadcn/ui)写样式:lovia 的 lv- 类名和变量不会与它的工具类、 主题变量撞名。不要引入它的全局重置 Preflight(会改掉整个页面的样式),需要时把重置限定在视图元素内。

examples/33_web_extensions.py 的文档视图就是这样画的。

UI 组件

工具结果可以渲染成 UI(可排序的表格、指标卡片等),而不只是文本。工具返回 ui_result(kind, props),前端由注册给该 kind 的渲染器把它画在工具卡片里;实时对话和重新 打开会话时都一样。

from lovia import tool
from lovia.web import ui_result

@tool
def top_products(n: int = 10) -> str:
    """畅销商品,以表格形式展示给用户。"""
    rows = query_top_products(n)
    return ui_result("table", {"columns": ["sku", "units"], "rows": rows})

内置了三种 kind:table(上例,见表格)、chart(见图表), 以及在沙箱中运行的页面 html(见页面)。 上面表格里的数字直接来自查询,模型不需要把它们写出来。

这个信封就是工具的输出,所以模型同样能看到 props。如果调用参数本身就是要展示的数据(纯展示型 工具),就用 from_args=True(或某个字段名)代替 props,数据就不会再回传一遍。大块数据可以用 put_ui_data 挡在模型上下文之外。信封要控制在 Agent 的 max_tool_output_chars 之内(默认 20 万字符):超长的输出会被截断,JSON 随之损坏,卡片就只能按文本显示。

自定义的 kind(比如 return ui_result("kpis", {"items": [...]}))需要你写一个渲染器:一个通过 ChatUI(scripts=[...]) 加载的 JS 模块:

// ui/kpis.js
window.lovia.registerRenderer('kpis', {
  render(el, props, ctx) {
    for (const item of props.items) {
      const card = document.createElement('div');
      card.className = 'kpi';
      card.textContent = `${item.label}: ${item.value}`; // 不要用 innerHTML
      el.append(card);
    }
    // 可以返回一个清理函数(释放 observer、定时器、图表实例等)。
  },
});
app = create_app(agent, ui=ChatUI(assets="ui", scripts=["kpis.js"]))

渲染器收到三个参数:要绘制的元素、props 和一个上下文:

ctx 字段 说明
theme / lang 'light' 或 'dark';'en' 或 'zh'。切换主题时,组件会先清理再重新渲染
basePath 挂载前缀,用于请求你自己的接口
sessionId / callId 组件所属的会话和工具调用
live 结果刚刚到达时为 true,重新打开聊天回放时为 false,可用于把开销大的操作推迟到用户点击之后
send(text) 以用户身份发送一条消息,例如"下钻 3 月"按钮。只在真实的点击或按键中生效,其他情况返回 false

render 可以是 async 函数。渲染出错时卡片会提示,原始输出仍可通过卡片上的"查看原始数据"按钮 查看。某个 kind 还没有渲染器时先显示提示,等渲染器注册后自动补上,所以加载顺序无所谓。重复 注册同一个 kind 会替换原来的渲染器。

导出的 HTML 是不含脚本的独立文件,render 在那里用不上。可以再提供一个 snapshot(props, ctx),把组件返回成静态 HTML(同步或 async 均可;ctx 相同,只是没有 send),导出时会对它做净化。没有 snapshot 的组件,导出时显示原始输出。内置表格会导出成 包含全部行的普通表格,图表导出成 SVG 图片。

window.lovia.registerRenderer('kpis', {
  render(el, props, ctx) { /* … */ },
  snapshot(props) {
    const esc = (s) => String(s).replace(/[&<>"]/g, (c) => `&#${c.charCodeAt(0)};`);
    return props.items.map((i) => `<p><b>${esc(i.label)}</b>: ${esc(i.value)}</p>`).join('');
  },
});

props 不可信

props 可能来自模型、工具、MCP 服务器或远程内容。请用 textContent 输出(或用页面上的 DOMPurify 过滤),不要当作 HTML 插入。

注册内置的 kind 会替换它:比如注册你自己的 chart 渲染器,就能换一个图表库来画图。 examples/31_web_components.py 把内置的 table 换成了自己的版本,给每一行加了一个"追问" 按钮,通过 ctx.send 发出后续问题。

大块数据

信封是模型上下文的一部分,每一轮都会带上。结果太大时(比如一万行的查询结果),可以把完整 props 随聊天存起来,信封里只放一份预览:

from lovia import tool
from lovia.web import put_ui_data, ui_result

@tool
async def run_query(sql: str) -> str:
    """执行只读查询,并以表格展示结果。"""
    columns, rows = await db.fetch(sql)
    ref = await put_ui_data({"columns": columns, "rows": rows})
    preview = {"columns": columns, "rows": rows[:20], "row_count": len(rows)}
    return ui_result("table", preview, data=ref)

模型只看得到预览,所以要在预览里放上它谈论那些没看到的行时需要的信息,比如总行数或合计。 页面会通过 GET /api/sessions/{id}/ui-data/{ref} 取回完整 props(与其他 API 使用同样的 鉴权),再交给渲染器。内置的和你自己的 kind 都能这样用,渲染器无需任何改动;导出的 HTML 也会画出完整数据。

  • 数据与所属聊天同生命周期:删除聊天时一并删除。数据不在了,卡片会先显示提示,再显示预览 (预览为 None 时只显示提示)。
  • 在 lovia web 之外运行时(比如脚本或评测),put_ui_data 返回 None,信封就只是预览。
  • 单份数据上限为 8 MiB 的 JSON。超出时抛出工具错误,模型可以据此缩小查询范围。

包装已有的渲染器

window.lovia.getRenderer(kind) 返回当前注册给 kind 的渲染器,即它的 { render, snapshot }(没有则为 undefined),于是一个渲染器可以建立在另一个之上。内置渲染器 在你的脚本运行之前就已注册。例如,你自己定义的一个精简 kind 可以交给内置的 chart 来画,实时 对话和导出中都能显示:

const chart = window.lovia.getRenderer('chart');
// {label, points: [[x, y], ...]} → 一个 ECharts option。
const option = (p) => ({
  title: { text: p.label },
  dataset: { source: p.points },
  xAxis: { type: 'category' },
  yAxis: {},
  series: [{ type: 'line' }],
});

window.lovia.registerRenderer('trend', {
  render: (el, props, ctx) => chart.render(el, option(props), ctx),
  snapshot: (props, ctx) => chart.snapshot(option(props), ctx),
});

拿到的是调用那一刻注册的渲染器,所以包装渲染器也可以替换它所包装的 kind(比如围绕内置的 chart 重新注册 chart),而不会调用到自己。

追问建议

内置建议器会在 Run 成功产出回答后单独调用一次模型;这次请求不会写入主对话的 Transcript,也不会延迟原回答。 建议器只会把最近一组用户问题和模型回答发给追问模型,过长内容会先截断。

create_app(agent, followups=True, followup_model="<small-model>")

create_app() 默认关闭该功能,因为每次生成建议都可能增加一次模型调用。 followup_model 可改用比 Agent 主模型更便宜的模型;不设置时沿用 Agent 的模型。 lovia web CLI 默认开启,可通过 --no-followups 或 LOVIA_FOLLOWUPS=0 关闭, 也可以把辅助任务角色指派给一个更便宜的模型。

如果建议来自 FAQ、向量库或自定义提示词,可以把异步函数直接传给 followups, 替换内置建议器:

from lovia.web import FollowupRequest, create_app, generate_followups

async def pricing_only(request: FollowupRequest) -> list[str]:
    return await generate_followups(
        request, model="<model>", instructions="只提关于价格的问题。"
    )

create_app(agent, followups=pricing_only)

自定义建议器会收到 FollowupRequest,其中包含 session_id、Agent 的注册名称,以及由 Transcript 转换而来的聊天消息列表 messages。返回字符串序列即可;返回 [] 时不显示 建议。建议器抛出异常时,服务端会记录日志并返回空列表,不影响正常对话。

认证

serve() 绑定回环地址时默认不要求凭据。绑定非回环地址时,直接传入的 Agent 会自动获得 一个生成的 token,启动时打印一次,同时给出可直接打开的 /?token=... UI 链接; 而既未设置 token 也未设置 auth 的应用会被拒绝运行。因此,通过 serve() 启动的业务 API 不会在非回环地址上匿名开放。

serve(create_app(agent, token="s3cret"), host="0.0.0.0")   # 固定 token
serve(agent, host="0.0.0.0")                                # 自动生成并打印

token 会保护 build_api_router 注册的业务路由。/healthz、/api/docs、 /api/openapi.json、UI 页面和静态资源默认不要求认证。客户端可以通过以下方式提交 token:

  • 普通 API 请求和聊天 SSE:发送 Authorization: Bearer <token>。聊天流由 fetch 读取,可以携带请求头。
  • 内置 UI:将 token 保存到 cookie。/api/events 使用 EventSource,无法自定义 请求头,因此会通过 cookie 认证;<img> 预览和下载链接也使用同一 cookie。UI 可从 /?token=... 链接读取 token,也会在收到 401 后提示输入。

如需基于会话的认证、OAuth 或用户级身份,可以传入任意 FastAPI 依赖来替换内置检查; 保护范围仍是同一组路由:

async def my_auth(request: Request) -> None:
    if not valid(request):
        raise HTTPException(status_code=401)

serve(create_app(agent, auth=my_auth), host="0.0.0.0")

它保护的范围和 token 完全相同,由此带来三点影响:

  • 内置 UI 需要 cookie。 EventSource、<img> 预览和下载链接都无法携带自定义请求头, 只读取 Authorization 的依赖会把它们挡在外面,因此还要接受你的会话 cookie。
  • 自己添加的路由需要自己保护。 挂载时加上 dependencies=[Depends(my_auth)]。 UI 页面和 /api/docs 仍然公开;如需保护整个站点,请使用中间件或反向代理。
  • 跨域前端使用 cookie 认证时,需要允许携带凭据的 CORS,而 cors_origins 不会开启这一项 (见下文)。

跨域场景下,请不要设置 cors_origins,自行添加中间件:

from fastapi.middleware.cors import CORSMiddleware

app = create_app(agent, auth=my_auth)
app.add_middleware(
    CORSMiddleware,
    allow_origins=["https://chat.example.com"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

如果前后端属于不同的站点(而不只是同一站点的不同子域名),cookie 必须设为 SameSite=None,此时需要自行防范 CSRF:取消运行、立即执行定时任务、上传文件等接口是 无请求体或 multipart 的 POST,属于不触发 CORS 预检的“简单请求”。内置 token cookie 使用 SameSite=Strict,不受影响。

登录跳转。 内置页面无法显示你的登录表单,需要告诉它登录页在哪里。设置 ui=ChatUI(login_url="/login?next={next}") 后,只要 API 请求被你的依赖以 401 拒绝,浏览器 就会跳转到该地址,其中 {next} 会替换为当前页面经过 URL 编码的路径(含查询参数)。未设置 login_url 时,页面只提示用户未登录。短时间内多个 401 只会触发一次处理;内置 token 校验 返回的 401(错误码 server_token)仍然弹出 token 输入框。

create_app() 本身默认不启用认证;交给其他 ASGI 服务器运行时,请自行传入 token 或 auth。serve() 只检查由 create_app() 构建的应用;自行挂载 build_api_router 的应用 沿用自身的中间件,按原样运行。

运行依赖

工具常常需要知道是谁在提问,以及用户在页面上选了什么——比如输入框旁下拉框里选的知识库。 create_app(run_deps=fn) 用这两样东西为每次运行构造 RunContext.deps:

from dataclasses import dataclass

from fastapi import HTTPException, Request
from lovia import RunContext, tool
from lovia.web import RunTrigger, create_app


async def auth(request: Request) -> None:
    request.state.user = await users.from_session_cookie(request)  # 你的身份体系


@dataclass
class Deps:
    user: str
    kb: str


def run_deps(trigger: RunTrigger) -> Deps:
    user = trigger.request.state.user if trigger.request else "scheduler"
    kb = trigger.client_context.get("kb", "docs")
    if kb not in KNOWLEDGE_BASES:
        raise HTTPException(400, f"unknown knowledge base {kb!r}")
    return Deps(user=user, kb=kb)


@tool
async def search(ctx: RunContext[Deps], query: str) -> str:
    """在用户的知识库里搜索。"""
    return await KNOWLEDGE_BASES[ctx.deps.kb].search(query, user=ctx.deps.user)


app = create_app(agent, auth=auth, run_deps=run_deps)

页面这一侧由扩展脚本通过 lovia.provideContext(key, fn) 提供;每次发送消息(或新建定时任务) 时都会读取一次 fn():

const kb = document.getElementById('acme-kb'); // composer_actions block 里的 <select>
window.lovia.provideContext('kb', () => kb.value);

run_deps 每次运行调用一次,可以是同步或异步函数,返回值就是这次运行的 ctx.deps,自动 续跑的每一段共用它。它在写入这次运行的任何数据之前执行,所以抛出 HTTPException 会拒绝这次运行, 且不留下任何痕迹:聊天消息会收到这个错误,不会新建对话,中断的运行仍可恢复;定时任务的这次触发 记为一次失败的运行,然后按计划等下一次。请让它尽快返回:每条消息都要等它返回后才开始运行。 trigger 包含:

字段 说明
client_context 页面提供的上下文(provideContext 的各个 key):UI 状态。它由浏览器控制,绝不要据此判断身份或权限。 注意不是 request.client,后者是客户端的网络地址
request 发起这次运行的请求,auth= 已经执行过:身份在这里(request.state)。没有人发起的运行为 None
source 运行的来源:user(一条消息,或重连时恢复的运行)、schedule:<id>、subagent:<session>、subagent-report:<task>
resumed 是否是重连时从 checkpoint 恢复的运行
session_id、agent 所属对话,以及即将运行的 agent

每次运行都有客户端上下文,包括没有人发起的那些:

  • 重启后恢复。 checkpoint 不保存上下文,所以服务端把这次运行的 client_context 存在它的 checkpoint 指针旁边,运行结束时清掉;恢复时取回它,request 是重连的那个请求。
  • 定时任务。 定时任务相当于一条延后发送的消息:在定时任务对话框里创建的,保存创建那一刻 页面的上下文;agent 用 schedule_run 创建的,保存它所在运行的上下文。每次触发都用它, request=None。
  • 子 agent 任务及其汇报使用所属对话最近一次的上下文,request=None。

客户端上下文编码成 JSON 后上限 64 KB。不设置 run_deps 时,运行没有 deps(和 core 的 Runner 一样);如果请求带了客户端上下文,服务端会记一条警告日志。

部署在路径前缀下

反向代理把应用发布在 /lovia/ 下、转发前剥掉该前缀时,需要把前缀告诉 uvicorn:

location /lovia/ {
    proxy_pass http://127.0.0.1:8000/;   # 末尾的斜杠会剥掉 /lovia
}
serve(app, root_path="/lovia")           # 或:lovia web --root-path /lovia

页面从每个请求中取得前缀,因此静态资源、API 请求、事件流和 token cookie 都限定在该前缀下; 同一主机上的两个实例各自保存 token,互不覆盖。静态资源地址不含协议和主机名,终止 TLS 的 代理也不会让它们变成被拦截的 http:// 请求。服务端本身仍在根路径响应,请通过代理打开页面。

如果要把 create_app() 应用放进已有的 FastAPI 应用,可以直接挂载 (见 HTTP API),前缀的处理方式相同。

托管 Run 生命周期

聊天 Run(流式或阻塞式)都由服务端后台任务托管。SSE 连接断开后,Run 仍会继续执行,客户端 可以稍后重连;阻塞式 /api/chat 的客户端断开后,Run 同样会执行完毕并写入聊天。

  • 用户取消:把已完成 Turn 写入 Session,移除悬空 Tool 调用并清理 Checkpoint。
  • 服务端关闭:协作式取消 Run,但保留 Checkpoint,以便部署后重新连接并恢复。
  • 容量:由 max_background_runs 限制;满载时新请求返回 HTTP 429。
  • 阻塞式 /api/chat:等待 Run 结束并返回结果(详见)。 由于没有客户端可询问,需要审批的 Tool 调用交给 Agent 的 approval_handler,否则拒绝。

正在运行的 Run、审批状态和 SSE 订阅都保存在进程内,因此只能使用一个 Worker。SQLite 数据 可以跨重启保留,但持久化存储无法让这些进程内状态支持多 Worker。

定时任务

Web 包会持久化定时任务,支持三种触发方式:

触发器 值
at ISO-8601 时间戳或 Unix 时间戳
every 秒数间隔
cron Cron 表达式,按服务器本地时间匹配;lovia[web] 已包含 croniter

Scheduling(store) 提供需要审批的 schedule_run 工具。模型可以建议创建定时任务, 但只有用户批准工具调用后才会保存。continue_session=True 会把结果追加到同一对话; 如果该对话正有 Run 在执行,定时指令会直接注入当前 Run;删除该对话时,定时任务也会一并删除。设置 continue_session=False 后,每次触发都会创建新的 Session;如果上一次定时 Run 尚未结束, 本次触发会直接跳过,不会排队。服务停机期间错过多个触发时刻时,恢复后只补发一次。

重复任务可以设置自然语言停止条件 until,例如“每分钟检查日志,直到出现 ready”。 每次运行完成任务后,模型都会收到检查该条件的指令;如果条件满足,它会调用 cancel_schedule 停用任务。使用 until 时还必须设置以下至少一项硬性上限,以免模型 没有识别出条件已经满足:

  • max_fires:最多触发指定次数;
  • expires_at:到期后不再触发。

插件还提供无需审批的 list_schedules 和 cancel_schedule。取消只会停用任务,不会删除记录。 停止条件通常由无人值守的定时运行自行处理,而这类运行中的 审批请求会被自动拒绝,因此 cancel_schedule 不能依赖审批。

如果工作要立即开始、但放在单独的对话里,插件还提供 start_session(instruction, title=None)。 它新建一个顶层对话,立即用当前对话的 agent 执行 instruction,沿用发起调用的那次 Run 的上下文, 并返回 {session_id, title, status}。新对话会链接回发起它的对话(origin.kind == "spawn"), 但不归属于它:删除任一对话都不影响另一个,新对话的回答也不会回报给原对话。这个工具无需审批, 因为新对话立即可见,且受 max_background_runs 限制;达到上限时调用失败,不会留下空对话。

安全检查

  • 个人使用时保持 host="127.0.0.1"。serve() 在非回环地址上从不匿名开放 API; 请妥善保管 token,将其视同密码。
  • 面向不可信用户时,应限制或关闭可写 Workspace。任何持有 token 的人都可以让 Agent 修改文件或执行 Shell 命令。
  • 设置 approval_timeout,避免无人处理的弹窗长期占用容量。
  • 只使用一个 Worker,并备份 SQLite 数据库。
  • 多用户部署还需要 TLS、按用户认证(auth=)和限流。共享 token 只适合单用户场景。
  • 聊天页不设置 Content-Security-Policy,所以模板 block 和扩展脚本可以嵌入其他站点的 iframe。 lovia 渲染的不可信内容(html 页面、文件面板的 HTML 预览)运行在自带防护的框架里:不透明源, 也不能把自己导航到别处。你自己设置的页面策略(在反向代理或中间件里)会被这些 srcdoc 框架 继承;页面依赖内联脚本,所以 script-src 必须允许内联;frame-src 不影响 srcdoc 框架。

生产使用前请阅读完整的生产部署指南。

延伸阅读