跳转至

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/)都正常工作。