WebDisplay 挂载¶
WebDisplay 把事件推给浏览器,WebDisplayService 提供承载它(或很多个它)的 HTTP 服务。
两者的关系是「显示层实例」与「服务器」:显示层先构造、可以没有服务;服务可以换、可以多个。
flowchart LR
subgraph S["WebDisplayService :18960"]
CHAT["/chat/ 前端"]
API["/api/sessions"]
DOCS["/docs/ 文档"]
subgraph M1["/session/a/"]
D1["WebDisplay A"]
SRV["srv/<key>/ 临时站点"]
end
subgraph M2["/session/b/"]
D2["WebDisplay B"]
end
end
A1["agent 1"] --> D1
A2["agent 2"] --> D1
A3["agent 3"] --> D2
构造与生命周期¶
from xun import WebDisplay, WebDisplayService
service = WebDisplayService(host="localhost", port=18960, token="secret")
service.mount("a", WebDisplay(expose_files=True)).mount("b", WebDisplay())
service.start(blocking=True)
WebDisplayService(
host="localhost", port=18960, token="", base_path="",
assets_dir=ASSET_DIR / "web", # 前端产物目录
docs_dir=ASSET_DIR / "docs", # 文档目录
session_manager=None, # 动态会话工厂,见下文
)
WebDisplay(expose_files=False, event_buffer_size=5000)
| 成员 | 说明 |
|---|---|
service.mount(path, display, *, name=None) |
把显示层挂到 /session/<path>;链式返回自身 |
service.unmount(path) |
摘掉一个会话(顺带关闭它的临时站点与上下文) |
service.rename(path, name) |
改会话显示名(不是改挂载路径),返回会话信息 |
service.list_sessions() |
列出挂载点、显示名与状态(idle / running / waiting) |
service.start(*, blocking=False) |
非阻塞时起线程并返回该线程;阻塞时调用即服务,永不返回 |
service.stop() |
请求退出并 join(内部超时 5 秒) |
service.port / service.host |
port=0 时读系统实际分配的端口 |
service.access_url(path="") |
该会话带令牌的直达链接(令牌以查询串给出,0.0.0.0 会写成 localhost) |
service.app |
底层 FastAPI 实例,可以自己再挂路由 |
expose_files |
建显示层时开启文件面板:只能浏览所绑定智能体的工作目录,符号链接与路径穿越都会被拒 |
挂载点的约束:路径不能包含 srv 段(与临时站点前缀冲突,见下);同一路径重复挂载报 ValueError;
一个显示层实例只能挂一个位置。挂载点 / 是根会话,其余会话会插在它前面。
令牌¶
token="" 时服务仍会生成随机令牌(token_urlsafe(24)),xuns 的 CLI 直接把它打到访问地址里。
四种携带方式:Authorization: Bearer、Cookie(名为 xun_web_token)、?token= 查询串、登录页表单。
Cookie 只在令牌正确时写入,并且随后由一次 303 重定向把地址栏里的令牌清掉。
两类前缀对 GET / HEAD 免鉴权:
/docs/:随包文档,公开可读。…/srv/:临时站点,URL 里的随机键本身就是凭据(挂载随停止或过期消失)。
其余路径(含 WebSocket)鉴权失败即拒绝;WS 直接以 1008 关闭,页面请求则 303 跳到 /login。
路由¶
| 路径 | 内容 |
|---|---|
/ |
303 跳到 /chat/ |
/chat/ |
Vue 单页应用(前端产物由 assets_dir 提供) |
/docs/ |
随包文档;docs_dir/index.html 不存在时返回 404 |
/session/<挂载点>/… |
该显示层的后端:history、events、choice、clear、download、api/serve/…、WS ws |
/session/<挂载点>/srv/<随机键>/… |
该会话临时托管的工作目录站点 |
/api/sessions(create / remove / rename) |
会话增删改查,仅在给了 session_manager 时开放 |
/login |
令牌登录页 |
base_path 会给以上所有路径加前缀,用于挂在反向代理子路径下。
实时通道¶
浏览器对每个会话开一条 WebSocket,事件由服务端推送;连接时先补发快照(历史 + 待答提示),
增量走 WS,WS 断开时退回事件轮询。每个连接带一个 client_id,
服务端据此跳过自己回声的广播——多标签页里一处确认不会把另一处的提示框关掉。
动态会话¶
session_manager 是一个无参工厂,返回的上下文管理器要 yield 出 (挂载点, 显示层):
from contextlib import contextmanager
from tempfile import TemporaryDirectory
from xun import WebDisplay, WebDisplayService, setup_agent
def new_session():
@contextmanager
def make():
with TemporaryDirectory() as tmp:
display = WebDisplay(expose_files=True)
setup_agent(workdir=tmp, display=display) # 新会话自带一个智能体
yield f"/{uuid4()}", display
return make()
service = WebDisplayService(token="abc", session_manager=new_session)
| 行为 | 结果 |
|---|---|
不给 session_manager |
/api/sessions 的增删改一律 405,界面上的会话管理入口关闭(--no-manage-sessions) |
| 给了 | 界面可以新建会话;删除时先卸载挂载点,再关闭工厂留下的上下文 |
| 只剩一个会话 | 删除返回 409,避免把最后一个会话关掉 |
同一个显示层可以绑多个智能体,界面上按 identifier 区分(各智能体工作目录不同,文件面板随之切换):
display = WebDisplay(expose_files=True)
service = WebDisplayService(token="abc").mount("main", display)
service.start()
for name, workdir in [("coder", ".test"), ("writer", ".tmp")]:
setup_agent(name=name, display=display, workdir=workdir) # 自动绑定到同一会话
临时站点¶
界面上把某个目录“开成网站”时,走的是显示层自带的 serve 接口:
POST /session/<挂载点>/api/serve/<agent_id>/start(body 里给 path),
返回带随机键的 URL 与过期时间,默认 1 小时后自动卸载;
GET …/api/serve/<agent_id> 列出在跑的站点,POST …/api/serve/<agent_id>/<key>/stop 主动停。
路径仍按智能体的工作区规则解析,越界会被拒。
事件缓冲¶
WebDisplay 默认缓冲 5000 条事件(显示层基类默认容量是 0,这里必须显式留,
否则页面刷新后什么都看不到):
d = WebDisplay(expose_files=True).with_buffer_size(2000)
d.events(agent_id) # 回放
d.clear_events(agent_id)
文档为什么不需要令牌
xuns --no-initial-agent 启动后,/docs/ 直接可看——即使完全没有配置模型服务。
这也是文档站必须整站使用相对链接的原因:它要能挂载在任意 URL 前缀下(例如 /docs/)都正常工作。