1. 从零搭建 Rust + Slint 桌面 UI 工程:为什么值得折腾
Rust 写桌面界面这件事,过去几年一直有点尴尬。命令行工具写起来很爽,但一到图形界面,要么去绑 GTK、Qt 这些 C/C++ 库,要么用 egui 这类即时模式框架,前者配置繁琐,后者做复杂布局时状态管理容易乱。Slint 这个库出现之后,情况好了不少——它用一套声明式的 DSL 描述界面,Rust 只负责业务逻辑,编译期就把.slint文件转成 Rust 代码,类型安全、启动快、内存占用低,跑在 Windows、Linux、macOS 上都不需要额外装运行时。
这篇教程要解决的问题很具体:一个完全没接触过 Slint 的人,怎么用 Cargo 从空目录开始,搭出一个能跑起来的桌面窗口工程。我会把Cargo.toml、build.rs、main.rs、ui/main.slint四个文件完整写出来,每一步都能复制粘贴。工程跑通之后,再演示怎么把界面里的网络请求 endpoint 指向 TaoToken 的 API 地址,完成一次真实的模型调用验证——这样你拿到的不只是一个空窗口,而是一个能继续往上叠功能的骨架。
适合谁看:有 Rust 基础语法概念(知道cargo new、fn main就行),想给工具加个图形界面,或者单纯想试试 Rust 桌面开发的人。不需要你懂前端,Slint 的 DSL 比 CSS 简单得多。
我试过在一台 8G 内存的老笔记本上编译,首次cargo build大概两分钟,之后增量编译几秒钟,运行起来窗口秒开,内存占用稳定在 20MB 以内。这个体感比 Electron 那套轻太多了。
下面正式开始。整个流程分六块:先讲清楚 Slint 的工程结构长什么样,再装依赖、写配置、写界面、跑起来,然后接 TaoToken 做请求验证,最后把常见的编译报错挨个排一遍。
2. Slint 工程结构长什么样:Cargo.toml 与 build.rs 配置详解
Slint 的工程和普通 Rust 项目最大的区别,是它多了一个「编译期代码生成」的环节。你写的.slint文件不是运行时解析的,而是在cargo build的时候,由slint-build这个 crate 读进去,生成对应的 Rust 模块,再被main.rs引用。理解这一点,后面所有配置就都顺了。
先看目录结构。一个最小可用的 Slint 工程长这样:
ttt/ ├── Cargo.toml ├── build.rs ├── src/ │ └── main.rs └── ui/ └── main.slintui/文件夹放界面描述文件,build.rs和Cargo.toml同级,main.rs在src/下。这个布局不是强制的,但社区示例基本都这么放,跟着走不容易踩路径的坑。
2.1 Cargo.toml 的完整写法
打开Cargo.toml,内容如下:
[package] name = "ttt" version = "0.1.0" edition = "2021" [dependencies] slint = "1.13.1" [build-dependencies] slint-build = "1.13.1"这里有两个关键点。第一,slint放在[dependencies]下,它是运行时库,你的程序最终链接它。第二,slint-build放在[build-dependencies]下,它只在编译阶段用,不会进最终二进制。很多人第一次配的时候把两个都塞进[dependencies],结果编译能过但体积偏大,其实是把编译器也打进去了。
版本号我写的是1.13.1,这是目前比较稳定的一个版本。如果你用的是更新的版本,把两个数字保持一致就行——slint和slint-build的版本必须匹配,否则生成的代码和运行时对不上,会出现一些莫名其妙的类型错误。
edition我建议用2021。原文示例里出现过2024,那个需要较新的 Rust 工具链,如果你本地rustc版本不够,会直接报 edition 不支持。稳妥起见先用 2021,等工程跑通了再升。
2.2 build.rs 的作用与写法
在项目根目录新建build.rs,内容只有三行:
fn main() { slint_build::compile("ui/main.slint").unwrap(); }这个文件是 Cargo 的构建脚本约定,名字必须叫build.rs,位置必须和Cargo.toml同级。slint_build::compile接收一个路径字符串,指向你的.slint入口文件。路径是相对于项目根目录的,所以写"ui/main.slint"而不是"../ui/main.slint"。
这里有个容易忽略的细节:compile的返回值是Result,.unwrap()在编译失败时会让整个cargo build中断,并打印出.slint文件里的语法错误行号。这是好事,别去掉unwrap,否则界面写错了你都不知道。
如果你有多个.slint文件,比如ui/main.slint里import了ui/components.slint,只需要在build.rs里编译入口文件main.slint就行,Slint 编译器会自动处理依赖关系。不需要每个文件都写一行。
2.3 为什么要有 build.rs 这一层
有人会问,能不能不用build.rs,直接在main.rs里用slint::slint!宏内联写界面?可以,原文的「基础界面测试」部分就是这么干的:
slint::slint! { export component MainWindow inherits Window { title: "Main Window"; width: 600px; height: 500px; Text { text: "Hello, world"; color: blue; } } } fn main() { MainWindow::new().unwrap().run().unwrap(); }这种写法适合快速验证,但界面一复杂就难受了:没有语法高亮、没有实时预览、改一行要重新编译整个 Rust 文件。所以正式工程都用build.rs+ 独立.slint文件的方式,配合 VS Code 的 Slint 插件,能边写边看预览。
两种方式生成的代码是一样的,slint::include_modules!()这个宏负责把build.rs生成的模块引入当前作用域。记住这个宏,main.rs里必须写它。
3. 可复制的 Slint 界面与 Rust 主程序配置
配置写完了,接下来是真正让窗口显示出来的部分。这一节给出ui/main.slint和src/main.rs的完整代码,并且把每个语法点拆开讲,方便你改。
3.1 ui/main.slint 的完整内容
在ui/目录下新建main.slint,写入:
import { VerticalBox, LineEdit, Button } from "std-widgets.slint"; export component MainWindow inherits Window { height: 600px; width: 800px; title: "TaoToken Demo"; in-out property <string> username: "please input username"; in-out property <string> status-text: "idle"; VerticalLayout { padding: 20px; spacing: 12px; Text { font-size: 20px; text: "UserName:"; } LineEdit { text <=> root.username; } Text { font-size: 20px; text: "Status:"; } Text { font-size: 16px; text: root.status-text; color: #2b6cb0; } Button { text: "Send Request"; clicked => { root.status-text = "request sent"; } } } }逐段解释。第一行import从std-widgets.slint引入标准控件,VerticalBox其实在这个例子里没用到,可以删掉,但留着不影响编译。export component MainWindow inherits Window定义了一个叫MainWindow的组件,继承自内置的Window,export关键字让它能被 Rust 侧引用。
in-out property <string> username声明了一个双向绑定的字符串属性,初始值是"please input username"。in-out表示这个属性既能从 Rust 读,也能被 UI 写回。LineEdit里的text <=> root.username是双向绑定语法,<=>表示两边同步,用户输入会更新username,Rust 改username也会刷新输入框。
clicked => { root.status-text = "request sent"; }是回调,点击按钮时执行。root指向当前组件的根,也就是MainWindow实例。这里先写个占位逻辑,后面接 TaoToken 的时候会换成真正的网络请求。
3.2 src/main.rs 的完整内容
src/main.rs写:
slint::include_modules!(); fn main() -> Result<(), slint::PlatformError> { let main_window = MainWindow::new()?; main_window.run() }slint::include_modules!()必须放在文件顶部,它展开成build.rs生成的那个模块。MainWindow::new()返回Result,用?传播错误。run()进入事件循环,窗口关闭时返回。
注意main的返回类型是Result<(), slint::PlatformError>,这样?才能用。如果你习惯写fn main(),那就把?换成.unwrap(),效果一样,只是报错信息没那么友好。
3.3 编译运行
在项目根目录执行:
cargo run第一次会下载slint和slint-build的依赖,视网络情况几十秒到几分钟。编译完成后弹出窗口,标题是TaoToken Demo,里面有一个输入框、一段状态文字、一个按钮。点按钮,状态文字从idle变成request sent。
如果窗口没弹出来,先看终端有没有报错。最常见的两类:一是build.rs里路径写错,报failed to read ui/main.slint;二是.slint文件语法错误,报错会带行号,照着改就行。
到这里,一个可运行的 Slint 工程骨架就完成了。接下来把它和 TaoToken 接起来,让按钮真的发一次请求。
4. 把 API endpoint 指向 TaoToken 并验证请求
界面能跑只是第一步,真正有用的是让它能调后端。这一节把上一节的按钮回调改成真实的 HTTP 请求,endpoint 指向 TaoToken 的 API 地址,完成一次模型对话验证。
4.1 添加 HTTP 依赖
Slint 本身不带 HTTP 客户端,需要额外加一个。用reqwest的阻塞版本最省事,不用引入 async 运行时。修改Cargo.toml:
[dependencies] slint = "1.13.1" reqwest = { version = "0.12", features = ["blocking", "json"] } serde_json = "1.0"blocking特性提供同步接口,json特性提供.json()方法。serde_json用来构造和解析 JSON。
4.2 在回调里发起请求
Slint 的回调可以直接绑定到 Rust 闭包。修改main.rs:
slint::include_modules!(); use std::time::Duration; fn main() -> Result<(), slint::PlatformError> { let main_window = MainWindow::new()?; let weak = main_window.as_weak(); main_window.on_send_request(move || { let window = weak.unwrap(); window.set_status_text("requesting...".into()); let client = reqwest::blocking::Client::builder() .timeout(Duration::from_secs(30)) .build() .unwrap(); let body = serde_json::json!({ "model": "gpt-4o-mini", "messages": [ {"role": "user", "content": "用一句话介绍 Rust"} ] }); match client .post("https://taotoken.net/api/v1/chat/completions") .header("Authorization", "Bearer YOUR_API_KEY") .header("Content-Type", "application/json") .json(&body) .send() { Ok(resp) => { let text = resp.text().unwrap_or_else(|_| "read body failed".into()); window.set_status_text(format!("ok: {}", &text[..text.len().min(80)]).into()); } Err(e) => { window.set_status_text(format!("error: {}", e).into()); } } }); main_window.run() }这里有几个关键点。main_window.as_weak()拿到一个弱引用,因为闭包会被存进窗口对象,直接用强引用会造成循环引用。on_send_request是 Slint 根据.slint文件里clicked回调自动生成的方法名——如果你在.slint里写的是callback send-request();,那 Rust 侧就是on_send_request,横线转下划线。
YOUR_API_KEY换成你在 TaoToken 控制台创建的密钥。API 地址用的是https://taotoken.net/api/v1/chat/completions,这是标准的 OpenAI 兼容格式,model字段填你想用的模型 ID。
4.3 对应的 .slint 改动
.slint文件里要把回调声明出来,并绑定到按钮:
export component MainWindow inherits Window { height: 600px; width: 800px; title: "TaoToken Demo"; in-out property <string> status-text: "idle"; callback send-request(); VerticalLayout { padding: 20px; spacing: 12px; Text { font-size: 20px; text: "Status:"; } Text { font-size: 16px; text: root.status-text; color: #2b6cb0; } Button { text: "Send Request"; clicked => { root.send-request(); } } } }callback send-request();声明了一个无参数回调,按钮点击时调用root.send-request(),Rust 侧注册的闭包就会被触发。
4.4 运行验证
cargo run之后点按钮,状态文字先变成requesting...,几秒后变成ok: {...}或者error: ...。如果返回ok,说明整条链路通了:Slint 界面 → Rust 回调 → HTTP 请求 → TaoToken API → 响应回填界面。
这一步验证通过,你的工程就不再是个玩具,而是一个能继续扩展的真实应用骨架。想加多轮对话,就在 Rust 侧维护一个Vec存消息历史;想加流式输出,把reqwest换成支持 SSE 的写法即可。
5. 常见报错排查:401、local proxy failed、reading choices 逐个解决
工程跑起来的过程中,报错基本集中在几个地方。这一节按真实遇到的频率排一下,每个都给出定位方法和修复。
5.1 编译期:failed to read ui/main.slint
error: failed to run custom build command for `ttt` Caused by: failed to read ui/main.slint这是build.rs里的路径和实际文件对不上。检查三件事:build.rs是否在项目根目录、ui/main.slint是否真的存在、路径字符串有没有多写./或../。slint_build::compile的路径是相对项目根目录的,不是相对build.rs的。
5.2 编译期:unknown property 或 unknown callback
error: Unknown property 'status-text' in MainWindow这种是.slint里用了没声明的属性。Slint 是静态类型,所有属性、回调都必须显式声明。检查in-out property和callback那几行,名字要和引用处完全一致,包括横线。Slint 里属性名习惯用横线status-text,Rust 侧自动转成下划线status_text,别在.slint里写成下划线。
5.3 运行期:401 Unauthorized
请求返回 401,说明 API Key 有问题。三种可能:Key 没填、填错、或者Authorization头格式不对。正确格式是Bearer加空格再加 Key,少个空格也会 401。另外确认 Key 是从 TaoToken 控制台的 API Keys 页面创建的,没有多余空格或换行。
5.4 运行期:local proxy failed
error: local proxy failed: connection refused这个报错通常出现在你本地配了 HTTP 代理,但代理进程没起来,或者代理地址写错了。reqwest默认会读环境变量HTTP_PROXY/HTTPS_PROXY。如果你不需要代理,把这两个环境变量清掉再跑:
unset HTTP_PROXY HTTPS_PROXY cargo runWindows 下用set HTTP_PROXY=清空。清掉之后请求直连,报错就消失了。
5.5 运行期:error decoding response body / reading choices
error: error decoding response body: missing field `choices`这个报错的意思是:请求发出去了,也收到了 200,但返回的 JSON 里没有choices字段,解析失败。原因通常是 endpoint 路径不对。比如你写成了https://taotoken.net/api/v1/chat,服务端返回的是一个错误 JSON,里面没有choices。
正确的对话补全路径是https://taotoken.net/api/v1/chat/completions。检查你的 URL 有没有漏掉/v1或者/completions。另外确认请求体里messages字段是数组,每个元素有role和content,格式错了服务端也会返回非预期结构。
5.6 运行期:OAuth 相关报错
如果你在别处见过OAuth token expired这类报错,那是另一套鉴权体系。TaoToken 的 API 用的是 Bearer Key,不涉及 OAuth 流程。如果你代码里混进了 OAuth 的 header 或 token 刷新逻辑,删掉即可。只需要一个Authorization: Bearer <key>头。
5.7 界面卡死
点按钮之后窗口无响应,是因为reqwest::blocking在 UI 线程里同步执行,请求期间事件循环被阻塞。短请求感觉不明显,超过一两秒就会卡。解决办法是把请求放到单独线程,用slint::invoke_from_event_loop回填结果:
let weak = main_window.as_weak(); std::thread::spawn(move || { let result = do_request(); let _ = slint::invoke_from_event_loop(move || { if let Some(w) = weak.upgrade() { w.set_status_text(result.into()); } }); });这样 UI 线程只负责刷新,请求在后台跑,窗口不会卡。
6. 工程跑通之后:把骨架变成你自己的工具
到这里,一个完整的 Rust + Slint 工程已经能跑、能发请求、能显示结果了。剩下的就是往这个骨架上加东西。
如果你要继续做模型对话类工具,下一步是把messages数组做成可变状态,每次请求把历史带上,界面加一个滚动列表显示对话。Slint 的ListView配合for循环可以动态渲染消息,Rust 侧维护Vec<Message>,通过属性绑定同步。
如果你要做的是本地工具加个配置界面,那 TaoToken 这部分可以换成任意 HTTP 接口,reqwest的用法完全一样。Slint 负责界面,Rust 负责逻辑,两者通过属性和回调通信,边界很清晰。
长期写代码或者做 Agent 类项目的话,可以考虑用 Coding Plan 这类按量方案,把模型调用成本控制住。验证模型效果、试不同 prompt 的时候,直接在模型对话页面里试比每次改代码编译快得多。API Key 的管理在控制台里,接入文档里有各语言的完整示例,遇到本文没覆盖的报错可以去翻一下。
最后留一个实用技巧:.slint文件改完之后,如果只想看界面效果不想重新编译整个 Rust 工程,装一个slint-viewer,直接slint-viewer ui/main.slint就能预览,改一行存一次,窗口实时刷新。调布局的时候能省不少时间。