Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

64 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Nexel · webslot

中文 | English

极简交互声明语言:后端写 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)

30 秒上手

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(...)(后端)。
  • 一条链必须有且只有一个 hitfn 返回 {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(占比)、widthheight。 容器直接写关键字,叶子用 .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 SliderFile 待办)
展示 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 只是出生地不同:

场景 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

动态画布 Scene

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 句柄层

dom.py 是 runtime.js 所有操作的 Python 封装,分三组:

fill helpers(fn 返回值构造器)

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"))   # 合并多个

node_* ops(动态加节点,放进 __ops__

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)

wire_* ops(动态连线)

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")])

补丁模式(Patch Mode)

不想用 webslot 接管整个页面?把它当补丁:别人写好的前端加两行,后端 Python 照常写

<!-- 在任意已有页面里放挂载点 -->
<div data-ws-mount></div>
<script src="http://localhost:8070/static/ws-patch.js"></script>

ws-patch.js 会自动:

  1. /api/actions 拉取布局 HTML + action table + 边数据
  2. 把 webslot 生成的 widget HTML 注入到 [data-ws-mount]
  3. 加载 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: *)。


ts_slot — VSCode Extension 导出

把 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(省胶水代码)

本地 fn vs 服务器 fn(call 一步之差)

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 占比,全程零往返)。


自验证可视化 /__inspect

每个 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 即见。


调试 trace

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 + flexapp.client(...) 客户端原语(零往返)、占比 .box(flex=…)
  • Group 变长容器:现场生成表单、hit 时打包任意数量子字段(examples/launcher.py
  • 可视化设计器:把图编辑器改写成 webslot 应用(graph 叶子 + 拖拽/连线原语)→ 拖线接线 → 直接生成 wire
  • ts_slotapp.export_vscode() 一键生成 VSCode extension,零手写 TS
  • qtslot:同一份声明投到 Qt(render.py 是唯一 web 专属层,换投影层即可)
  • 服务端状态轴(挂起):身份 / 定向推送 / Persist / 权限 / 长任务恢复 —— 入场券,非护城河

定位

  • 邻居:Gradio/Streamlit(Python 出 web UI)、Node-RED/ComfyUI(节点连线)。
  • webslot 站的交叉点:写代码即连线 → 同一张表既跑又画又自检 → 还能换投影端。 最稀的一点是自托管、自绘制、自验证——代码和图是同一个东西。

About

Nexel is a minimal Python interaction DSL that turns functions, UI nodes, and data-flow connections into fully working web interfaces with almost zero frontend code.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages