极简交互声明语言:后端写 Python 函数 + 声明节点和连线,前端只写极少代码就能实现简单 UI。 不是普通 UI 库,是一门很小的交互 DSL —— 节点定义能力,连线定义流向,函数定义处理, runtime 负责投影成界面和交互。零第三方依赖(纯 stdlib)。
Nexel 是 webslot 的下一个名字 —— 同一套引擎,同一组接线纪律,只是项目换了名:
webslot/目录里的 Python 引擎、运行时、inspect 工具都没变- API 向后兼容,老 demo(
simple_demo/multi_route_demo/route.py等)照跑 - 新增
app.route():一张 App 多页面,URL 跳转支持 - 仓库从
lo2589/EasyUI迁移到lo2589/Nexel
一切都是一条闭环链 hit → fn → fill。剖开看,每条链同时含两个面:
- XOY 面(布局):节点画在哪 —— 一棵
children嵌套的 dict。 - XOZ 面(链路):信号怎么走 —— 编译成一张 action table。
两面只靠同一套 id 缝合。容器对信号透明(嵌套再深,链路还是平的)。 所有交互(对话 / Tab / 侧边栏 / 通知 / 流式)都是这同一个模型。
a.read → c.trigger → dd.process → b.write
(reads) (hit) (fn) (writes)
from webslot import App, Input, Output, Button, Col
app = App()
a = Input("a", placeholder="输入消息")
b = Output("b")
c = Button("c", label="发送")
app.layout(Col(a, c, b)) # 布局:竖排三个
a >> c >> app.fn("dd") >> b # 连线:点 c,读 a,调 dd,填 b
@app.fn("dd")
def dd(params):
return {"b": params["a"] + " 处理完了"}
app.run(port=8000)浏览器打开 http://127.0.0.1:8000/:输入 → 点发送 → 结果填进 b。后端只写了一个 dd。
运行自带示例(无需安装,从本目录跑):
python examples/minimal.py # 对话
python examples/instant.py # 本地 fn:实时镜像 + 弹层开关 + flex 占比(零往返)
python examples/demo.py # 对话 + 后端推送时钟
python examples/dataflow.py # 可点击数据流线:点线触发 DOM -> DOM
python examples/stream.py # 流式打字机
python examples/route.py # switch 值分发(一次往返多路由)
python examples/gallery.py # 所有组件总览
python examples/launcher.py # 输入任意命令行 -> 现场生成参数表单 -> 运行/恢复默认只用一个运算符 >>。hit(触发点)由 Button 自动识别,非按钮节点用 hit(x) 标记。
a >> c >> fn("dd") >> b # c 是 Button,自动是 hit
(a, d) >> c >> fn("dd") >> b # 多输入用 tuple
c >> fn("dd") >> b # 无输入
a >> hit(slider) >> fn("dd") >> b # 非按钮节点:hit() 标记| 写法 | 含义 |
|---|---|
X >> hit |
X 是 hit 的输入(触发时被读);(a, d) >> hit 多输入 |
hit >> fn(...) |
hit 触发后端函数 |
fn >> out |
结果填到输出节点 |
- hit =
Button(自动)/hit(任意节点)(显式,hit(button)也行、与裸写等价)/app.signal(...)(后端)。 - 一条链必须有且只有一个 hit。
fn返回{node_id: value}决定填谁, 天然支持扇出(一次填多个)和条件分支(只返回该填的 key)。
三种写法 desugar 成同一个布局 dict(dict 是标准形):
[a, b, c] # 扁平 list = 横排
[[x], [y]] # list 套 list = 纵排
Row(a, b, c, gap=8) # 显式容器,带属性
Col(x, y, padding=16, scroll="y")容器属性:dir / gap / padding / justify / align / width / height / scroll。
2D 靠嵌套,一个容器只有一个主轴。
占比 / 尺寸:任意节点(容器或叶子)都能设 flex(占比)、width、height。
容器直接写关键字,叶子用 .box() 链式设:
Row(x.box(flex=1), y.box(flex=2), z.box(flex=1)) # 宽度比 1:2:1
Col(a, b).box(width=320) # 容器也能 .box()
Input("q").box(flex=3)| 类 | 组件 |
|---|---|
| 触发 | Button Link |
| 输入 | Input Textarea Select Checkbox Radio Slider(File 待办) |
| 展示 | Text Output Image Progress Table |
| 可视化原语 | Box Edge DataFlow |
| 容器 | Row Col Container Modal(弹层,默认隐藏) |
| 变长输入容器 | Group(见下) |
所有输入组件接线写法都一样(多输入用 tuple):
(name, lang, agree, level) >> go >> app.fn("submit") >> out固定内容直接写进节点,不必走 fn:Output("out", value="…")、Table(columns=…, rows=…)、Select(options=…)。
Group:字段数量不固定时用它。 普通输入节点在声明时就要有固定 id,
reads 列表在启动时写死;但表单是"现场生成"的场景(比如根据一条命令行解析出
几个 flag 就现场造几个输入框)没法提前知道有几个字段。Group 容器只声明一次
(启动时是空壳),内容用 fill_html 随时替换;被 hit 读取时,现场扫描它当前
所有带 data-ws-field 标记的子节点,打包成 {子id: 值} 一起送出——不管此刻里面
有 1 个还是 30 个:
from webslot import Group, group_field_html, fill_html
fields = Group("fields") # 启动时声明一次,空的
fields >> run_btn >> app.fn("run") >> out
@app.fn("generate")
def generate(p):
markup = "".join(
group_field_html(f"f_{k}", f"--{k}", v) for k, v in flags.items()
)
return fill_html("fields", markup) # 换一批 flags 再调一次即可,不用重启
@app.fn("run")
def run(p):
values = p["fields"] # {"f_lr": "0.001", "f_epochs": "10", ...}
...完整可跑例子:examples/launcher.py。
铁律不变,hit 只是出生地不同:
| 场景 | hit | fn | 传输 |
|---|---|---|---|
| 普通对话 | 前端 DOM | return 一次 |
POST /api/call/{fn} |
| 实时推送 / 通知 | 后端 Signal |
return(每次 emit) |
SSE /api/stream |
| 流式 | 前端 DOM | yield 多次 |
POST 触发 + SSE 推 |
# 后端 hit:服务器主动推
app.signal("tick") >> app.fn("show_time") >> clock
app.emit("tick", {"now": "12:00"}) # 点火,经 SSE 推给所有在线 client
# 流式:fn 用 yield,前端那条 wire 一字不改
@app.fn("reply")
def reply(p):
s = ""
for ch in "逐字回复":
s += ch
yield {"b": s} # 每帧推一次 fill用 DynamicDom / DynamicWire / Scene 在画布上渲染任意图结构。不要写裸 op dict。
from webslot import DynamicDom, DynamicWire, Scene, SceneCanvas
cur_scene = [Scene()]
def build_scene(nodes, edges):
doms = [DynamicDom(f"dom_{n['id']}", text=n['label'],
x=n['x'], y=n['y'], w=160, h=44,
ports=["l", "r"], attrs={"data-node-id": n['id']})
for n in nodes]
wires = [DynamicWire(f"w_{e['from']}_{e['to']}", f"dom_{e['from']}", f"dom_{e['to']}",
from_anchor="r", to_anchor="l", mode="bezier")
for e in edges]
old = cur_scene[0]; cur_scene[0] = Scene(doms, wires)
return cur_scene[0].diff_patch(old) # 只推变更
@app.fn("refresh")
def refresh(p):
return build_scene(my_nodes, my_edges)点击画布节点触发 fn(SceneCanvas):
canvas = SceneCanvas() # 不进 layout,只用于连线
canvas >> app.fn("on_click") # 点击带 data-node-id 的盒子时触发
# fn 收到 params["__node_id__"] = 被点击节点的 id前端自动行为(无需写 JS):
- 滚轮缩放(对准光标,0.1×–8×)
- 拖拽空白区域平移
mode="bezier"→ S 曲线边;默认直角折线
| API | 说明 |
|---|---|
DynamicDom(id, text, x, y, w, h, ports, attrs) |
单个画布盒子 |
DynamicWire(id, from_id, to_id, from_anchor, to_anchor, mode, color, label) |
连线 |
Scene(doms, wires) |
一帧画布状态 |
scene.diff_patch(old, **fills) |
增量 ops + 普通 fill 合并为一个返回值 |
SceneCanvas(node_id="__ws_runtime_scene__") |
画布点击触发器 |
dom.py 是 runtime.js 所有操作的 Python 封装,分三组:
from webslot import fill_text, fill_html, fill_value, fill_show, fill_hide, fill_merge
return fill_text("status", "完成") # 等价 {"status": "完成"}
return fill_html("panel", "<b>结果</b>") # {"panel": {"html": ...}}
return fill_show("panel") # {"panel": {"visible": True}}
return fill_hide("panel")
return fill_value("slider", 42) # input/select/slider 的值
return fill_checked("cb", True) # checkbox
return fill_merge(fill_text("a","ok"), fill_show("b")) # 合并多个from webslot import patch, node_box, node_output, node_progress, del_dom
@app.fn("update")
def _(p):
return patch([
node_box("n1", "状态", x=40, y=0, w=160, color="#22c55e"),
node_output("n2", x=40, y=60, w=240),
node_progress("bar", x=40, y=120, w=200, value=75),
], status="已更新")完整 node_* 清单:node_box / input / output / button / text / select / textarea / checkbox / radio / slider / link / image / progress / table / row / col / modal
group_field_html(id, label, value, kind="text"|"checkbox") 是 Group 专用的字段拼接器
(带 data-ws-field 标记,见上方"变长输入容器"一节),不进 __ops__,直接拼进 fill_html 的字符串里。
删/改/移:del_dom(id) / update_dom(id, **spec) / move_dom(id, x, y)
from webslot import wire_plain, wire_flow, del_wire
patch([wire_plain("w1", "a", "b", from_anchor="r", to_anchor="l")])
patch([wire_flow("w2", "src", "dst", trigger="click", mode="append")])
patch([del_wire("w1")])不想用 webslot 接管整个页面?把它当补丁:别人写好的前端加两行,后端 Python 照常写。
<!-- 在任意已有页面里放挂载点 -->
<div data-ws-mount></div>
<script src="http://localhost:8070/static/ws-patch.js"></script>ws-patch.js 会自动:
- 从
/api/actions拉取布局 HTML + action table + 边数据 - 把 webslot 生成的 widget HTML 注入到
[data-ws-mount] - 加载 runtime.js,绑定事件
Python 端完全不变:
app = App()
btn = Button("go", "运行")
out = Output("result")
app.layout(Col(btn, out))
btn >> app.fn("run") >> out
@app.fn("run")
def run(p):
return {"result": "done"}
app.run(port=8070)对方前端无需改框架代码。只有一个 [data-ws-mount] 时会自动使用它;如果页面上有多个
挂载点,必须明确选择一个,避免复制布局后产生重复 DOM ID:
<div id="task-patch" data-ws-mount></div>
<script src="http://localhost:8070/static/ws-patch.js" data-ws-target="#task-patch"></script>也可以把 script 紧跟在目标 [data-ws-mount] 后面。加载 payload 或 runtime 失败时,
loader 会在宿主页面内显示错误提示。
跨域已自动处理(CORS Access-Control-Allow-Origin: *)。
把 webslot 应用打包成 VSCode 插件,Python 生成全部 TypeScript/JS 胶水代码,用户只写 Python。
from ts_slot import TsSlotApp, Input, Output, Button, Col, fill_text
app = TsSlotApp()
a = Input("a", placeholder="输入")
b = Output("b")
c = Button("c", "发送")
app.layout(Col(a, c, b))
a >> c >> app.fn("dd") >> b
@app.fn("dd")
def dd(p):
return fill_text("b", p["a"] + " ✓")
app.export_vscode("./my-ext", command_id="myExt.show", ext_name="My App")运行这段 Python → 生成 my-ext/ → VSCode F5 或安装 → 点命令 → 自动 spawn Python。
传输分两种:普通(panel)模式 spawn 本地 HTTP server;export_vscode(..., sidebar=True)
生成常驻侧边栏视图,webview ↔ extension host ↔ Python 全程 stdio JSONL
(导出自带 server/ts_slot_stdio.py,不占端口),fn 异常经 ws_error 一路回到页面 toast。
额外可用的 VSCode 原生 op:
from ts_slot import vscode_notify, vscode_open_file, vscode_open_url
return vscode_notify("处理完成")
return vscode_notify("出错了", level="error")
return vscode_open_file("/path/to/file.py")
return vscode_open_url("https://example.com")| webslot | ts_slot | |
|---|---|---|
| 最后一步 | app.run(port=8000) |
app.export_vscode("./dir") |
| 运行时 | 浏览器 | VSCode webview |
| 手写 TS | 否 | 否(生成纯 JS) |
| 加速 | — | 约 10-20x(省胶水代码) |
hit → fn → fill 拆成 4 个原子操作:bind(绑监听)/ collect(收 reads)/ call(喂 fn 拿 patch)/ fill(写回 DOM)。
服务器 fn 和本地 fn 只差 call 这一步——一个跨网络、一个在浏览器里同步跑,入参/出参完全一样:
call 怎么走 |
适合 | |
|---|---|---|
app.fn(...) 服务器 fn |
POST /api/call/{fn} → Python |
要 Python 逻辑 / 落库 / 调模型 |
app.client(...) 本地 fn |
浏览器里跑客户端原语,零往返 | 实时镜像 / 弹层开关等纯前端状态变换 |
hit(a) >> app.client("set") >> b # 打字即变,不发任何请求
open_btn >> app.client("toggle") >> panel # 弹层就地开合,零往返内置客户端原语(后端开发者不写 JS,按名引用):
| 原语 | 作用 |
|---|---|
set |
把源值写进 writes(有 reads 用首个,没有就读 hit 自己的值)—— 实时镜像 |
toggle |
翻转 writes 的显隐 |
show / hide |
显示 / 隐藏 writes |
为什么这就够了:状态分三层 —— 瞬时 UI(拖拽 / hover,不存)、前端工作图(位置 / 编辑中的值,浏览器)、
后端语义图(节点 / 连线 / fn,Python)。本地 fn 改前两层、服务器 fn 改第三层。
高频交互(拖拽、实时)走本地、不打爆后端;要持久化时再发一条服务器 fn 提交。模型还是同一个 hit → fn → fill。
示例:python examples/instant.py(实时镜像 + 弹层开关 + flex 占比,全程零往返)。
每个 app 自带一张"自画像":把它的布局 + 连线画成两列图(前端节点一列、后端 fn 一列),
每条 wire 画成 reads → hit → fn → writes 的回路 —— 那个数据环。
- 100% 用 webslot 自己画:盒子是
Box节点,连线是Edge节点。 - 同一张 action table,两个消费者:runtime 读它执行,inspector 读它画。图和执行同源。
- 断链自暴露:连线指向不存在的节点 → 画不出 +
console.warn。validator 不用写,画一遍就验了。
打开 http://127.0.0.1:8000/__inspect 即见。
app = App(debug=True)开 debug 后,每条链按 ABCD 四层打固定格式日志(默认静默):
浏览器 console: webslot ▸ A go:click
webslot ▸ B reads {name:"Lo", …}
webslot ▸ C POST /api/call/submit
webslot ▸ fill {out:"…", bar:50}
服务端: webslot ▸ D submit({…}) -> {out:"…", bar:50}
A DOM 触发 / B 收集 / C 过桥 / D 后端 —— tool call trace 白送。
webslot/
__init__.py 公开 API
node.py Node 基类 + 所有叶子 + `>>` 运算符 + .box()
wire.py Chain / Signal / FnRef / ClientFnRef / Switch,编译成 action table
layout.py Row/Col/Modal + list→dict desugar
render.py 布局 dict → HTML(含 flex/尺寸注入)
app.py layout / fn / client / signal / switch / emit / run
bridge.py http.server:/ , /__inspect , /static , /api/stream(SSE) , /api/call/{fn}
validator.py 断链检查(启动 fail-fast + 自画像标红)
codegen.py XOZ + 布局 → webslot 源码(graph→python)
inspect.py 用 Box/Edge 拼自画像(吃自己狗粮)
static/runtime.js 固定解释器:bind/collect/call(POST 或本地)/fill/draw
examples/ minimal / instant / demo / dataflow / stream / route / forms / gallery / confirm / broken / launcher
doc/plan.md 完整设计文档 + Roadmap
ts_slot/ VSCode extension 导出层(TsSlotApp + export_vscode + vscode_ops)
Roadmap(详见 doc/plan.md)
- ✅ MVP / 后端 hit / 流式 / 自验证 inspector / 组件集 / debug trace
- ✅ validator:断链 / 输出没接收 → 自画像标红 + 启动 fail-fast
- ✅ switch:值分发,一次往返多路由(
examples/route.py) - ✅ codegen:XOZ + 布局 → webslot 源码(graph→python,无损往返)
- ✅ 本地 fn + flex:
app.client(...)客户端原语(零往返)、占比.box(flex=…) - ✅ Group 变长容器:现场生成表单、hit 时打包任意数量子字段(
examples/launcher.py) - ◇ 可视化设计器:把图编辑器改写成 webslot 应用(graph 叶子 + 拖拽/连线原语)→ 拖线接线 → 直接生成 wire
- ✅ ts_slot:
app.export_vscode()一键生成 VSCode extension,零手写 TS - ◇ qtslot:同一份声明投到 Qt(
render.py是唯一 web 专属层,换投影层即可) - ◇ 服务端状态轴(挂起):身份 / 定向推送 / Persist / 权限 / 长任务恢复 —— 入场券,非护城河
- 邻居:Gradio/Streamlit(Python 出 web UI)、Node-RED/ComfyUI(节点连线)。
- webslot 站的交叉点:写代码即连线 → 同一张表既跑又画又自检 → 还能换投影端。 最稀的一点是自托管、自绘制、自验证——代码和图是同一个东西。