sabela
Sabela 是一个用于 Haskell 的响应式笔记本。其名称源自 Ndebele 语言中意为 响应 的词汇,这正是其核心设计理念。

快速开始
git clone https://github.com/DataHaskell/sabela
cd sabela
cabal update
cabal run
打开 localhost:3000/index.html,然后:
- 打开
examples/CaliforniaHousing.md查看完整的示例,该示例 使用加州住房数据构建 Hasktorch 线性回归模型,或 - 点击左上角的书本图标,查看可直接运行的代码片段。
该文件夹中包含展示不同功能的示例,例如 widgets 以及 Python 集成。
其执行和依赖模型基于 scripths。
教程
1. 安装并运行
git clone https://github.com/DataHaskell/sabela
cd sabela
cabal update
cabal run
然后打开 http://localhost:3000/index.html。默认情况下,文件资源管理器
以当前目录为根目录。
要传递选项,参数顺序为:
sabela [port] [work-dir] [global-file] [packages...]
因此,要在不同的端口上运行,并使用您自己的 notebook 目录:
cabal run sabela -- 8080 ~/notebooks
global-file 默认为 ~/.sabela/global.md(参见 第 6 节]);任何
尾部参数均为要预安装的额外软件包。
2. 笔记本模型
笔记本是一个普通的 Markdown 文件:散文加上带围栏的 Haskell 代码块。
# My first notebook
This is prose.
```haskell
x = 10
```
More prose.
```haskell
print (x + 5)
```
带围栏的代码块作为代码单元格加载,它们之间的文本作为散文单元格加载。保存会将笔记本重新写回为 Markdown,因此可以在 Git 中干净地进行差异比较,并可在浏览器外正常编辑。
3. 你的第一个响应式笔记本
创建一个文件 examples/tutorial.md:
# Sabela basics
```haskell
x = 10
```
```haskell
y = 20
```
```haskell
print (x + y)
```
4. 响应式机制如何工作
当你编辑一个单元格时,Sabela 会使用 ghc-lib-parser 解析每个单元格,
这是一个 GHC 自身解析器的独立副本,无论你的 GHC 版本如何都能正常工作。
从语法树中,它读取每个单元格定义的顶层名称,以及每个单元格使用但未自行绑定的名称。
这提供了一个真实的依赖图,而不是基于文本匹配的猜测。
编辑一个单元格会按依赖顺序重新运行其依赖项,该顺序由拓扑排序计算得出, 而非基于笔记本中的位置,因此每个值都会在读取它的单元格之前重新计算。 有两种情况会被报告而不是执行:
- 重新定义。 第一个定义某个名称的单元格拥有该名称。之后定义相同名称的单元格 会被标记为错误,并指向原始定义,而不是默默地遮蔽它。
- 循环。 如果两个单元格相互依赖,两者都会被报告为循环,而不是陷入死循环。
响应式语义中仍然存在一些缺口,例如类型类定义的变化 目前尚未传播,但常见情况已得到覆盖。
5. 运行纯 Haskell
任何能在 GHCi 中运行的内容都能在单元格中运行:
let triples =
[ (a, b, c)
| c <- [1..20]
, b <- [1..c]
, a <- [1..b]
, a*a + b*b == c*c
]
print triples
6. 在单元格中添加包依赖
笔记本自带其包需求。你在单元格顶部使用
-- cabal: 指令来声明它们:
-- cabal: build-depends: text
import qualified Data.Text as T
import qualified Data.Text.IO as TIO
let msg = T.pack "Hello, Sabela!"
TIO.putStrLn (T.toUpper msg)
扩展也遵循同样的方式:
-- cabal: build-depends: aeson, text, bytestring
-- cabal: default-extensions: DeriveGeneric, OverloadedStrings
指令是按单元格划分的,但它们适用于整个笔记本:Sabela 会将所有单元格中的 -- cabal: 行合并为一个包集合。当该集合发生变化时,它会解析包环境,重启 GHCi,重新注入显示辅助函数,并重新运行需要这些更改的单元格。将主要指令放在顶部附近,可以使环境更易于阅读。
对于希望在每个笔记本中都使用的依赖项,请将相同的指令放入 global.md(默认为 ~/.sabela/global.md)。
编译单元格(-- compile)
与其他笔记本不同,还可以编译包含繁重计算的单元格:
```haskell
-- compile
trainEpoch :: Model -> [Batch] -> (Model, Double)
trainEpoch m bs = ...
```
```haskell
trainEpoch model batches -- 调用原生 -O2 代码
```
- 编译单元格仅包含在模块顶部合法的内容:
imports、类型签名、绑定、
data/class/instance声明。 - 下游单元格直接使用编译后的名称,无需导入。
- 跨单元格的顶层名称重复是一个错误(编译定义 不能被遮蔽),并且编译单元格不能使用在解释 单元格中定义的名称。
-- compile: Training 显式命名生成的模块;共享
名称的单元格合并为一个模块(这也允许它们之间
相互递归),当一个模块使用另一个模块的名称时,模块之间会自动相互导入。使用单元格操作中的 ⚡ 按钮来切换
指令,而无需手动输入。
7. 富输出辅助函数
Sabela 将显示辅助函数注入到会话中,以便单元格可以输出结构化内容而非纯文本:
| 辅助函数 | 输出 |
|---|---|
displayHtml | HTML |
displayMarkdown | Markdown |
displaySvg | SVG |
displayLatex | LaTeX |
displayJson | JSON |
displayImage | base64 图像(接受 MIME 类型和数据) |
普通的 print 显示为文本。富输出必须是单元格打印的唯一内容。
Markdown
displayMarkdown $ unlines
[ "# Analysis Results"
, ""
, "The computation found **42** as the answer."
, ""
, "| Metric | Value |"
, "|--------|-------|"
, "| Speed | Fast |"
, "| Memory | Low |"
]
HTML
displayHtml $ unlines
[ "<h2>Hello from Sabela</h2>"
, "<p>This is <strong>rich HTML</strong> output.</p>"
, "<ul><li>Item one</li><li>Item two</li></ul>"
]
来自 notebook 工作目录的文件通过 /api/asset?path=<path> 提供,并可在 HTML 中使用。
SVG
-- cabal: build-depends: text, granite
{-# LANGUAGE OverloadedStrings #-}
import qualified Data.Text as T
import Granite.Svg
displaySvg $ T.unpack
(bars [("Q1",12),("Q2",18),("Q3",9),("Q4",15)] defPlot { plotTitle = "Sales" })
每个辅助函数都会在其输出前添加一个 MIME 标记,服务器在将结果发送到浏览器之前会将其去除。
8. 交互式小部件
小部件是一种 HTML 控件(滑块、下拉框、复选框、文本框、按钮), 它存在于单元格的输出中,并在你操作它时重新运行该单元格。你无需编写 JavaScript:Sabela 负责渲染控件,并将其值传回 GHCi 以 重新运行单元格。
每个小部件都是一个 Behavior a:一个知道如何渲染自身并
采样其当前值的值。动词 display 同时执行这两项操作:它绘制控件
并返回该值:
c <- display (slider "celsius" (20 :: Int) (-40) 120)
let f = c * 9 `div` 5 + 32
displayHtml $ "<p><b>" ++ show c ++ " °C</b> = " ++ show f ++ " °F</p>"
拖动滑块,单元格会以新的 c 重新运行。任何下游使用 c 的单元格也会重新运行,因为小部件值是一个普通的 Haskell 绑定。
完整集合:
| 小部件 | 返回值 |
|---|---|
slider name def lo hi | 拖动时的数值(防抖) |
dropdown name options def | 选定的 String |
checkbox name def | 一个 Bool |
textInput name def | 当前的 String |
button label name | Maybe ()(点击一次后为 Just ()) |
scatterSelect name points | [Int],套索选中点的索引 |
Behavior 是 Applicative,因此可以将小部件组合为一个值:
area <- display (liftA2 (*) (slider "w" (10 :: Int) 1 100)
(slider "h" (10 :: Int) 1 100))
displayHtml $ "<p>Area: <b>" ++ show area ++ "</b></p>"
用 Lasso 绘制散点图
scatterSelect 会绘制一个 HTML5 canvas 并返回你所圈选点的索引。将这些索引传递给行过滤器,下游单元格将精确显示你所选中的行:
sel <- display (scatterSelect "districts" [(lon, lat) | (lon, lat) <- coords])
sel
9. 在同一笔记本中使用 Python
在单元格的边栏语言下拉菜单中选择 py,该单元格将在持久的 Python REPL 中运行,而不是 GHCi。变量会在笔记本中持续存在,并且相同的 displayHtml / displayMarkdown 辅助函数也可以使用。(你需要在 PATH 上安装 python3。)
def fib(n):
a, b = 0, 1
for _ in range(n):
a, b = b, a + b
return a
print([fib(i) for i in range(10)])
这两种语言运行在独立的进程中,因此它们通过桥接而非共享内存来传递值。从 Haskell 导出:
exportBridge "names" (show names)
并且该值以字符串 _bridge_names 的形式到达 Python:
import ast
names = ast.literal_eval(_bridge_names)
反过来也是如此:exportBridge("result", json.dumps(r)) 在 Python
中表现为 Haskell 中的 _bridge_result,而从 Python 导出会重新运行读取它的
Haskell 单元格。通常的做法是在 Haskell 中使用
DataFrame 加载并类型检查数据,将其作为 CSV 传输,然后让 pandas 或 matplotlib 接管。examples/tutorial-python-integration.md 和 examples/matplotlib-demo.md
对此进行了讲解。
10. 使用 Claude Code 进行结对编程(siza 技能)
Sabela 通过位于 /api/ai/* 的小型 REST API 暴露其笔记本,而 siza 是一个驱动它的 Claude Code 技能。当你在浏览器中打开笔记本,并在第二个终端中安装 siza 后,Claude 可以列出单元格、读取它们、运行它们、提出你在 UI 中批准的编辑,并使用 try 操作针对笔记本上下文和候选依赖项进行非提交式实验。
在 Claude Code 内部安装它:
/plugin marketplace add /path/to/sabela/cli-skill
/plugin install siza
/reload-plugins
然后启动 Sabela(cabal run),打开一个 notebook,并向 Claude 询问诸如
"我的 notebook 里有什么?"、"运行第 3 个单元格并告诉我它输出了什么",或者
"添加一个单元格,绘制中位数收入与房价的关系图。" siza 会自行找到
正在运行的服务器:Sabela 在启动时写入 ~/.local/state/sabela/servers/<port>.json
以便该 skill 可以读取它。
在共享或远程机器上,使用 token 对 bridge 进行访问控制:
SABELA_AI_TOKEN=$(openssl rand -hex 16) cabal run
客户端随后发送 Authorization: Bearer <token>;其余 UI 保持
打开状态。完整详情见 cli-skill/README.md。
与本地模型配对(siza chat)
siza 二进制文件还有一个 chat 子命令,用于通过
本地 Ollama 模型 驱动你的 notebook。
Siza 是一个已集成到 Haskell 包生态系统和
GHC 编译器中的 harness。该 harness 包含钩子,可对代码应用确定性修复,
执行上下文包搜索,并将响应式 notebook 语义嵌入为
LLM 可输出内容的约束。
前提条件:Ollama 正在运行且已拉取模型(例如
ollama pull gpt-oss:20b),并且 Sabela 服务器已启动(cabal run)。然后:
cabal run exe:siza -- chat # discovers the running server
cabal run exe:siza -- chat --model gemma4:latest --url http://localhost:3000
| 标志 | 默认值 | 含义 |
|---|---|---|
--model | gpt-oss:20b | Ollama 模型标签(必须与 ollama list 匹配) |
--url | 已发现的服务器 | 要驱动的 Sabela 服务器 |
--timeout | 300 | 每个请求的墙钟时间上限(秒) |
--max-turns | 40 | 每个请求的最大 harness 轮数 |
--verbose | 关闭 | 流式传输完整审计(系统提示、思考过程、工具 JSON) |
名称解析(查找要导入的库函数)使用本地 Hoogle 缓存。
如果模型报告找不到函数,请从仓库中运行
make search-cache 构建一次缓存。
11. 展示与导出
笔记本不仅用于编辑。Sabela 在 /dashboard 处将同一笔记本作为实时
仪表盘 提供服务,在 /slideshow 处作为 幻灯片 提供服务,并且可以
导出为可交付的文件:
GET /api/export/… | 输出 |
|---|---|
markdown | 以纯 .md 格式的笔记本 |
dashboard / slideshow | 一个内置笔记本的独立 HTML 页面 |
haskell | 一个可运行的 .hs cabal 脚本,裁剪至单元格的依赖项 |
lhs | 文学式 Haskell |
reactive | 一个无头 reactive-banana 程序 |
由于事实来源是 Markdown,最简单的导出方式就是直接保存 该文件。
12. 一个最小化的笔记本
将以下内容放入 examples/minimal.md 中,即可在一个文件中实现响应式、Markdown 输出和 SVG 绘图:
# A tiny Sabela notebook
```haskell
numbers = [1..10]
```
```haskell
squares = map (^ (2 :: Int)) numbers
```
```haskell
print squares
```
```haskell
displayMarkdown $ unlines
[ "# Summary"
, ""
, "Squares for " ++ show (head numbers) ++ " through " ++ show (last numbers) ++ "."
, ""
, "- Count: " ++ show (last numbers)
, "- Max: " ++ show (last squares)
]
```
编辑 numbers,其下方的三个单元格会随之更新。
13. 加州住房示例
examples/CaliforniaHousing.md 是一个关于加州
住房数据集的完整端到端笔记本。它涵盖了:
- 使用
DataFrame加载数据 - 检查行并计算摘要
- 类别频率和直方图
- 工程化派生特征
- 通过 Template Haskell 进行类型化列引用
- 空间结构的散点图
- 与目标变量的相关性
- 训练 Hasktorch 线性回归模型
- 在留出区上评估预测
如果你希望从更小的规模开始,examples/Iris.md 在
经典的 Iris 数据集上训练决策树,而 examples/grammar-of-graphics.md 使用 Granite 的图形语法 API 构建分层图表。
14. 从会话中查找名称
查找面板会查询实时会话以获取补全建议,:info、:type
和 :doc。一旦笔记本加载了其模块并定义了名称,你就可以
从正在运行单元格的同一会话中检查它们,这在你边使用边学习 API 时非常有用。
15. 文件、加载与保存
Sabela 的文件资源管理器以工作目录为根,因此你可以从 UI 中打开 现有的笔记本,创建文件和目录,并将内容保存回磁盘。由于笔记本是纯 Markdown,一种布局如下:
examples/
basics.md
plotting.md
CaliforniaHousing.md
它只是磁盘上的文件,并且能够自然地与 Git 和代码审查配合使用。
16. 错误与调试
当单元格失败时,Sabela 会捕获 GHCi 的 stderr 并将其解析为结构化错误,在 GHCi 提供行和列信息的地方包含这些信息。修复上游损坏的单元格会在下次重新运行时修复其依赖项。
以下几点有所帮助:
- 将 imports 和
-- cabal:指令放在顶部附近 - 为复杂的定义单独创建一个单元格
- 优先使用命名辅助函数而非深度嵌套的单行代码
- 使用
print进行调试,使用display*辅助函数进行展示
17. Sabela 如何执行单元格
Sabela 为每个 notebook 维护一个长生命周期的 GHCi 进程,该进程通过
-ignore-dot-ghci 加上 notebook 元数据中的扩展和包环境启动。要执行一个单元格,它:
- 将源代码解析为脚本片段
- 将其渲染为 GHCi 脚本文本
- 将这些行发送到会话
- 在其后写入一个唯一标记
- 排空 stdout 直到标记出现
- 单独收集 stderr
- 解析出 MIME 标记和错误位置
- 将结果广播到前端
该标记是让一个长生命周期进程服务于整个 notebook 同时保持每个单元格输出独立的技巧。Python 单元格以相同的方式 针对其自身的 REPL 工作。
18. 当前限制
Sabela 是一个年轻的项目,存在一些不足之处。值得了解的约束条件:
- 每种语言在 notebook 中运行于单个会话
- 更改包环境会重启该会话
- Haskell↔Python 桥接以字符串形式传递值,而非活动对象
19. 良好的笔记本风格
在以下情况下,笔记本的阅读体验最佳:
- 将设置放在最前面:指令、扩展、导入和小型辅助函数置于顶部
- 为每个步骤分配独立的单元格:加载、清洗、特征工程、 绘图、建模、解释
- 为中间值命名,而不是将所有内容嵌套在一个表达式中
- 避免在顶层绑定昂贵的值,以降低内存占用
- 将文本作为文档来撰写:解释每个步骤的作用及其 输出的含义,使笔记本既易于阅读又易于运行