显示层与 Web 服务¶
智能体不直接 print,而是向 DisplayAbstract 广播事件;所有确认请求也经同一层发出。这样同一份执行逻辑可以落到终端、浏览器或完全静默。
调用侧 API(智能体内部使用)¶
| 方法 | 触发的钩子 | 发出的事件 |
|---|---|---|
display_event(payload) |
无 | 任意事件(不限状态) |
info(message) |
before_display_info |
InfoEvent |
warning(message) |
before_display_warning |
WarningEvent |
error(message) |
before_display_error |
ErrorEvent |
get_choice(prompt, choices, ...) |
自动确认下 default 不在选项内时触发 before_display_warning |
ConfirmEvent(source 为 "user" 或 "auto") |
get_confirm(prompt, ..., default=True) |
无 | 同上(选项为 Yes/No) |
需要富文本提示时直接发 display_event(HTMLInfoEvent(html=..., title=...)),不限状态:Web 端渲染 HTML(经 DOMPurify 消毒),控制台把 title 渲染成面板、没有 title 时退化为一条 info,NullDisplay 直接丢弃。
除 display_event 外都要求智能体处于 Init 状态;三个显示钩子可就地改写 args.message,常用于过滤或加前缀(见 扩展系统 的示例)。
get_choice / get_confirm 返回 ChoiceOutcome[T](choice 与 source),它的真值被故意禁止(bool() 抛 TypeError),原因见 工具系统。
事件模型¶
事件为 pydantic 模型,外层统一信封 DisplayEvent:timestamp、name(类名)、agent(AgentInfo:name / identifier / workdir)、payload。
负载类型定义在 display_event.py,由 display_abstract.py 再导出(from xun.display_abstract import ToolCallEvent 仍然可用);事件缓冲与独立 HTML 渲染由 display_store.py 的 DisplayEventStoreMixin 提供。display_event() 先入缓冲、再交给 on_event(),因此任何显示层都自带事件历史。
| 事件 | 关键字段 |
|---|---|
UserMessageEvent |
content、images(kind 为 url 或 base64) |
UserCommandEvent |
name、arguments |
ModelWorkingEvent |
model_call_id、remaining_iterations |
ModelMessageEvent |
model_call_id、content、reasoning、total_tokens |
ToolCallEvent |
tool_call_id、tool_name、args |
ToolResultEvent |
tool_call_id、result |
ShowHistoryEvent / ShowHelpEvent / ShowToolsEvent |
历史、命令表、工具表 |
ShowExtensionsEvent |
extensions:name、description、status、reason(见 扩展系统) |
InfoEvent / WarningEvent / ErrorEvent |
message |
HTMLInfoEvent |
html、title;Web 端渲染富文本,控制台回退到 to_text()(去标签、保留换行),NullDisplay 丢弃 |
ConfirmEvent |
choice、choices、source、prompt、message |
AgentBindEvent / AgentUnbindEvent |
无(绑定/解绑信号) |
AgentRunningStartEvent / AgentRunningEndEvent |
无;运行状态机在 idle ↔ running 跃迁时发出,与 run_start / run_end 钩子同时机,Web 端据此点亮或熄灭「运行中」状态 |
自定义显示层¶
只需实现两个抽象方法:
from xun import Agent, DisplayAbstract, ToolBox
class RawDisplay(DisplayAbstract):
def on_event(self, event):
print(f"{event.name}: {event.payload}")
def get_choice(self, request) -> str:
print(f"[{request.prompt}] {request.choices}")
return input(">>> ")
with Agent(display=RawDisplay(), toolbox=ToolBox().with_defaults()) as agent:
agent.instruct("...").execute()
get_choice 收到 DisplayAbstract.ChoiceRequest:agent_info、prompt、choices、message、title、subtitle、default、allow_extra;返回选中的字符串。
内置实现:
| 实现 | 行为 |
|---|---|
NullDisplay() |
丢弃事件;get_choice 抛 NotImplementedError(开启 auto_confirm 时不会走到这里)。裸 Agent() 的默认值 |
Display() |
控制台输出(rich),setup_agent 与 xun 的默认值 |
WebDisplay(expose_files=False, event_buffer_size=5000) |
Web 后端,保留事件历史供前端回放;超出容量即丢弃最旧的事件 |
Display(event_buffer_size=1000) 与 WebDisplay 都接受这个参数;混入类自带的默认容量是 100,重量级使用者自己调大。
事件缓冲¶
| 方法 | 说明 |
|---|---|
set_event_buffer_size(max_events) |
设定环形缓冲容量,超出即丢弃最旧的事件 |
events(agent_id=None) |
缓冲内的事件,旧的在前;可按智能体过滤 |
clear_events(agent_id=None) |
清空缓冲(全部,或只清某个智能体) |
render_history_as_html(title="Session") |
把缓冲里的事件渲染成一个自包含 HTML 页面,分组方式对齐 Web 端事件流 |
/render 导出的正是这份页面(不是会话消息列表),所以产物里也有工具调用、推理与确认卡片。
终端输入:prompt_toolkit
控制台的输入与确认提示都由 prompt_toolkit 驱动:主提示符支持上下键翻历史与历史搜索、/ 斜杠命令补全(带说明)、@路径 文件引用补全(@dir/ 逐级深入,排序为完全匹配、前缀、包含)。提示文本先由 rich 渲染成 ANSI 再交给 prompt_toolkit,彩色提示不会算错光标宽度;弹确认/选择时会抢占主提示符,答完后原样恢复你写到一半的输入。选择提示用方向键移动、回车确认。
Web 服务编程接口¶
xuns 只是下面这段的封装:
from xun import WebDisplay, WebDisplayService, setup_agent
display = WebDisplay(expose_files=True)
setup_agent(display=display, default_tools=True, workdir=".test")
service = WebDisplayService(host="localhost", port=18960, token="", base_path="")
service.mount("/", display)
service.start(blocking=False) # 打印每个会话的访问 URL
...
service.stop()
token=""时自动生成随机令牌;--token的三种携带方式与路由约定见 xuns · Web 服务。port=0表示随机端口,真实端口写在service.port上。mount()可挂多个显示实例,路径互不重叠;/srv段被保留。- 站点根路径自动重定向到
/chat/;FastAPI 自带的/docs被禁用,/docs/留给打包进来的文档站点。
会话工厂¶
传入 session_manager 后,界面可以自行创建/删除会话。工厂是一个同步上下文管理器,产出 (mount_path, display),finally 负责清理:
from contextlib import contextmanager
from uuid import uuid4
from xun import WebDisplay, WebDisplayService, setup_agent
@contextmanager
def session_manager():
display = WebDisplay(expose_files=True)
agent = setup_agent(display=display, default_tools=True, workdir=".test")
try:
yield f"/{uuid4()}", display
finally:
agent.finalize()
service = WebDisplayService(session_manager=session_manager)
web_session(manage_sessions=True) 提供的就是这套默认工厂(未显式给 workdir 时每个会话分配独立临时目录,给了则所有会话共用它)。
前后端契约¶
下表端点(除标注 {base} 的那一行)都挂在 {base}/session/<mount>/ 下:<mount> 是会话挂载路径,{base} 即 --base-path;前端用 {base}/chat/?session=<mount>&token=… 选定会话。
| 端点 | 说明 |
|---|---|
WS /ws |
主通道:客户端发 message、command、choice、cancel;服务端推送序列化的 DisplayEvent(运行状态即其中的 AgentRunningStartEvent / AgentRunningEndEvent),外加 pending_prompt、prompt_resolved、accepted 三类控制消息 |
GET /api/events |
断线重连时补拉事件 |
GET /api/prompts、POST /api/prompts/{id}/resolve |
待回答的确认请求 |
GET /api/agents、GET /api/running、GET /api/config |
智能体列表、运行状态、是否开放文件接口 |
GET /api/commands/{agent_id}、GET /api/capabilities/{agent_id} |
命令表、模型与能力 |
GET/POST /api/files/*、/api/serve/* |
文件浏览、上传、移动/改名/删除、新建目录(支持一次建多级)、文本读写,以及临时静态托管(仅 expose_files=True 时注册);POST …/write 带上客户端读到的 modified_at 做乐观并发,冲突回 409;GET …/info 会回 is_text;托管请求的 path 为空串即工作目录根 |
{base}/api/sessions(含 /create、/remove、/rename)、{base}/login、{base}/chat |
服务级路由(由 WebDisplayService 而非 WebDisplay 提供):会话列表与增删改名、登录页、前端界面;关闭会话管理时增删改返回 405,删除最后一个会话返回 409 |
WebSocket 消息体:message(client_id?、agent_id、content、最多 8 张图片)、command(client_id?、name、arguments)、choice(prompt_id、value)、cancel(agent_id)。
受理回执与去重¶
message 与 command 可以带上客户端生成的 client_id(最长 128 字符)。服务端处理这条消息后回一句 accepted:
{"type": "accepted", "client_id": "9f1c…"}
- 每个显示实例记住最近 2000 个
client_id,重复出现的client_id不再执行,但仍会补一条accepted(因此重连后重发是安全的)。 - 不带
client_id的消息照旧直接执行,没有回执也没有去重——自己写客户端时可以按需选用。
文档站点
WebDisplayService 会把 src/xun/assets/docs 作为静态站点挂在 {base}/docs/,不需要令牌;这份站点正是本目录用 MkDocs 生成后由 make doc-dist 拷贝进去的产物。