用仓颉写桌面应用的“薄壳框架”cj-tauri ,以及它的 AI 对话客户端 ai-chat
本文基于仓库
https://atomgit.com/qq8864/cj-tauri(框架版本2.0.0,IPC 协议 v1)与其示例examples/ai-chat的源码、README、capabilities 与 UI 实现整理。
一句话概括:前端用任意 Web 技术,后端是仓颉静态编译的可审计二进制,产物几 MB、不带 Chromium, 并且能把同一份业务代码跑到 Windows / Linux / 鸿蒙 PC 三端。
可以在windows系统下开发,一套代码,不同构建,即可运行在windows、linux、和鸿蒙PC。再也不用在网页上找来找去了,体积包10M大小,完全替代臃肿庞大的Cherry Studio啦。

一、cj-tauri 是什么
1.1 定位
cj-tauri 是“仓颉版 Tauri”:把 Tauri 的架构思想(系统 WebView + 原生后端 + IPC + 能力白名单)
用华为仓颉(Cangjie) 重写了一遍,同时把鸿蒙 PC(2in1 桌面形态)作为一等公民。
| 维度 | Tauri 2 | cj-tauri |
|---|---|---|
| 后端语言 | Rust | 仓颉(静态编译,无 VM、无 GC 抖动顾虑) |
| WebView | 系统 WebView | 同策略:WebView2 / WebKitGTK 4.1 / 鸿蒙 ArkWeb |
| 平台 | Win / macOS / Linux / iOS / Android | Windows / Linux / 鸿蒙 PC(macOS 尚未做) |
| 前端 | 任意 Web 技术栈 | 同;create 默认零 Node 内联 HTML,另有 Vue/React 模板 |
| 权限模型 | capabilities(ACL) | capabilities/*.json:命令 / 事件 / 命名权限集 / 路径 scope / 出网主机白名单 |
| 产物 | 单可执行 + 资源 | 单可执行 + 资源;Windows 另有免安装单文件包(4.69–10.3 MB) |
项目地址: https://atomgit.com/qq8864/cj-tauri
1.2 分层架构
前端 (HTML/CSS/JS) ← window.__CJ_TAURI__.invoke / listen / emit(document-start 注入的桥)
│ JSON over postMessage(Linux: WebKitUserContentManager;Windows: chrome.webview.postMessage)
▼
IPC 消息桥(src/ipc_hub.cj) 命令注册 / 分发 / 校验,命令在 worker 线程异步执行
▼
能力安全模型(src/capability.cj + src/scope.cj) 命令、事件、路径、出网四类入口的闸门
▼
内置命令(system:version / ping / echo / devtools)+ 14 个官方插件
▼
WebView 宿主(src/host.cj 抽象 + 三平台实现)
├─ Linux: webkit2gtk-4.1 + native/platforms/linux/bridge_linux.c
├─ Windows: WebView2 + native/platforms/windows/bridge_win.c
└─ 鸿蒙 PC: ArkWeb(ArkUI 组件宿主 + arkweb_proxy.cj,不走 C 桥)

关键设计:判定逻辑只在共享层(capability.cj / scope.cj / urlutil.cj),C 桥与平台代码按“系统边界”对待,不各写一份判定——所以 Windows 与 Linux 的行为差异只剩“条件编译 + C 桥”两处。
1.3 开发一个应用只需三步
// 1. 实现命令
public class GreetCommand <: CommandHandler {
public func handle(cmd: String, args: JsonObject, ipc: IpcContext): JsonValue {
return JsonString("Hello, ${argString(args, "name", "world")}!")
}
}
main(): Int64 {
// 2. 装配:窗口 + 注册命令;3. 启动(能力清单由 run() 自动扫描 capabilities/)
let app = TauriApp()
.window(WindowConfig("hello", 1000, 700))
.register("greet", GreetCommand())
app.run(html)
return 0
}
// 前端:桥由宿主在页面脚本之前注入
window.__CJ_TAURI__.invoke('greet', { name: '仓颉' }).then(d => console.log(d));
window.__CJ_TAURI__.listen('tick', p => console.log(p));
对应 Tauri 的 #[tauri::command] / Builder / capabilities;差别在于三处联动缺一不可:
实现 + register() + 清单 commands 里放行——少注册报 command not registered,少放行报command not allowed,两条都打 stderr。
二、cj-tauri 的优点(附“可被审视”的证据)
2.1 静态编译的后端,没有运行时包袱
仓颉没有 VM,后端就是一份本地机器码;协议层只有两百来行、可逐行审计。
好处是冷启动快、内存曲线平(无 GC 抖动带来的掉帧),并且——依赖面极小:
实测单文件产物的 PE 导入表里只有 KERNEL32 / USER32 / WS2_32 / SHELL32 / ole32 / CRYPT32 …
这些系统 DLL,第三方运行时一栏全是 0,Subsystem = 00000002(GUI 子系统,双击不弹黑窗)。
2.2 零 Chromium 捆绑:几 MB 的 “Electron 替代”
Electron 系应用动辄 100 MB 起,核心成本是把整个 Chromium 打包进去。cj-tauri 走系统 WebView:
| 产物 | 体积 | 说明 |
|---|---|---|
serial-waveform.exe | 4.69 MB | 串口实时波形示波器 |
cj-link.exe | 5.48 MB | 串口 + TCP/UDP 二合一调试助手 |
modbus-poll.exe | 5.59 MB | Modbus TCP/RTU 调试工具 |
markdown-notes.exe | 4.78 MB | Markdown 笔记(含 marked + highlight.js) |
leetcode-gui.exe | 9.79 MB | 仓颉力扣:题库 + 编辑器 + 判题终端 |
ai-chat.exe | 10.3 MB | AI 对话客户端(本文主角) |
代价也明确:依赖宿主系统的 WebView 版本(Windows 需 WebView2 Runtime,Win11 自带;Linux 需libwebkit2gtk-4.1),并且外壳体验受系统 WebView 能力约束。
2.3 能力安全模型:默认拒绝,四类入口各自设闸
威胁模型只有一句话:页面是不可信的。页面能触及的入口被收窄成四类,每类一条独立防线:
| 入口 | 页面能构造什么 | 防线 |
|---|---|---|
invoke 命令 | 任意命令名 + 任意参数 | 命令白名单 + 命令必须已注册 |
emit 事件 | 任意事件名 + 载荷 | 事件白名单(Tauri 2 对事件不做 ACL,这里做) |
| 命令的路径参数 | 任意路径(含 ../ 穿越) | 路径 scope(allow/deny + 规范化) |
| 命令的 URL 参数 | 任意 URL(SSRF) | 主机白名单 scopes.http / scopes.ws,没写 = 全拒 |
几个刻意做“更严”的点:
- 空清单 = 全拒,没有“缺省全开”的形态;
- 集声明 ≠ 放行:插件声明了
fs:readonly权限集,清单不引用就不生效,而且只提示、不静默放行; - 路径 scope 先 deny 后 allow,
*不跨目录分隔符、**跨、?单字符,匹配前先canonicalize
(顺带解软链接),不存在则词法折叠./..; - 运行期加白收口在注册中心:
allowScopePattern只收具体值,出现*/?或制表/换行当场拒
——“一个通配透传进来就等于全放行”,这条不变量应用绕不过,而且加白掀不掉 deny; - 出网口统一收口:
http与ws共用同一份 host 提取(urlutil.cj),拦 userinfo 伪造主机、
非 http(s)/ws(s) 协议、IPv6 字面量单独识别,禁自动重定向,响应体 8 MiB 上限。
这些不是纸面承诺——README 里每条断言都对应 src/tests/ 的真实用例(bash scripts/test.sh 可复跑,
当前 300 余条全绿),例如 testRegistryEmptyDeniesEverything、
testRegisteredPermissionSetNotReferencedStaysDenied、testRuntimeAllowExtendsManifestScope。
2.4 插件体系:一行接入,权限仍归清单
14 个官方插件:fs / dialog / shell / serial / store / clipboard / notification / menu / mqtt / redis /log / http / ws / sqlite。命令自动登记成 <插件名>:<短名>,权限由清单三层把关(命令级白名单 + 命名权限集 + 路径scope),插件本身不掌握放行权。
其中几个是“纯仓颉实现”的:mqtt(MQTT 协议栈)、redis(RESP2 双 socket)、ws(握手复用
stdx 的 WebSocket.upgradeFromClient,不手写协议)、sqlite(仓颉 FFI 直连系统 libsqlite3,不经 C 桥)。插件间的边界也被静态门禁守着:插件包不得互相引用,链接粒度 = 插件包 = 静态库。
2.5 慢命令不冻窗口
通过校验的命令进 worker 线程异步执行,慢命令不阻塞 UI(testDispatchReturnsBeforeSlowCommandFinishes);拒绝与异常都回投前端 + stderr 留痕,没有“无声失败”的通道。相应地,同一命令并发调用不保证顺序,前端按 promise id 匹配——这点从 Tauri 过来的人要留意。
2.6 一份业务代码,多端产物
业务代码(src/main.cj + assets/)→ cj-tauri.toml 清单
→ CLI(create / sync / check / build / dev)
→ targets/desktop(WebKitGTK / WebView2)
→ targets/ohos (ArkWeb,编 hap 装到鸿蒙 PC)
cj-tauri sync ohos 按清单派生骨架,业务包以 path 依赖进去、从不复制。所以 examples/ohos/ohos-music与桌面版 music 页面代码是同一份;鸿蒙 PC(2in1 桌面窗)上窗口尺寸交给系统,标题栏用系统原生装饰。
2.7 宿主能力位是“真能力”,不是画饼
菜单、托盘(Shell_NotifyIconW / libayatana-appindicator3 / X11 grab)、全局快捷键
(RegisterHotKey / X11 grab)、文件拖入、系统通知、同窗内嵌视图(一窗多 WebView)、
附属窗口(owned window,不占任务栏与 Alt-Tab)、最小化到托盘——Windows 与 Linux 双实现,
system:host 如实上报能力位;平台没有的能力(鸿蒙侧多数为 0)如实返回业务错误,不假装成功。
2.8 全链路可验证:每个示例都能无人值守自检
这是 cj-tauri 最有辨识度的工程习惯:桥的 stderr 即时取证 + 页面自检断言回投,每个示例都有一条 probe 路径(CJ_XXX_PROBE=1),断言结果按行回投到日志。
ai-chat 就有 73 处断言、本机实跑 65 条明细 PASS,另有 CJH-GUIDE / CJH-DEPLOY / CJH-PANELS / CJH-CHAT四条独立探针——交付物自带验收脚本。
2.9 脚手架把平台坑踩完了
create / dev / build / run / info / doctor / sync 一套命令,dev 接管 Vite dev server(HMR 在窗口内生效,
Ctrl-C 一并收掉进程树),doctor 逐行给“缺了什么、怎么补”(SDK / stdx / mingw gcc / WebView2 / C 桥),新机器第一件事跑它。npm 分发 cj-tauri(免安装 npx cj-tauri create myapp),另外随版本分发十份静态链接的单文件 Windows 工具(免 SDK、双击即用)。
2.10 生态与场景已经跑出来了
二十多个可运行示例,覆盖业务工具(Modbus 调试、MQTT 调试台、Redis 调试台、串口助手、串口示波器、SQLite 浏览器、HTTP 客户端、Markdown 笔记、力扣 GUI)、能力演示(IPC 性能探针、事件合并探针、多窗口、插件四件套)与真实应用(音乐播放器、观影、打字游戏、cjnote、ai-chat)。
三、ai-chat:一个窗口里的“AI 客户端 + Agent 桌面端 + 九家网页版”
3.1 它解决什么问题
日常用 AI 有三套割裂的入口:命令行里的 agent、各家网页版、以及自己拿 API Key 拼的脚本。
ai-chat 把它们收进同一个窗口,并用一个开关区分“谁来干活”:
- 聊天(直连):页面直接打 OpenAI 兼容的
/chat/completions,流式增量一块块回来渲染成 Markdown。模型选择真的生效(你选谁就是谁)。 - Agent(cjh):接本机的 cjh(同样用仓颉写的 AI agent)。聊天流走 WebSocket 长连接,
会话 / 技能 / MCP / 文件四个面板走它的 REST。
形态选择很克制:不驻留托盘,窗口就是普通窗口,关掉即退出。
上一版每家网页版开一扇独立窗口,被实机反馈否掉——“切站点应当是原地换内容,不是桌面上多一扇窗”,于是改成了同窗标签页(见 §3.5)。
3.2 功能清单
两条管道共用
- 多会话 + 会话搜索(标题与正文都搜)+ 行内重命名;
- 多服务商:自定义 Base URL / API Key / 模型 / system 提示词;
- 流式 Markdown 渲染(标题 / 列表 / 表格 / 引用 / 代码块高亮)+ 代码块一键复制;
- 浅色 / 深色主题、紧凑模式、侧栏收起(
Ctrl+B)、Ctrl+K搜会话、Ctrl+Shift+N新会话; - 输入框自适应高度、流式“吸底跟随”(只在用户本来就在底部时才跟随,往回翻不拽人);
- 日志落盘(
setLogToDefaultFile("ai-chat"):C 桥 + 仓颉侧 + 页面log:write进同一个文件)。
Agent(cjh)模式独有
- 四面板:会话(cjh 自己的历史,带标题与时间)/ 技能 / MCP(只读)/ 文件;
- 工作区切换(“浏览…”走原生目录框
dialog:pickFolder,也能手输绝对路径)+ 面包屑 + 文件预览; - 工具调用的 running / done / error 三态 chip;
- 危险操作审批:cjh 要跑危险工具时弹待批准卡片(工具名 / 入参 / 命中规则 + 与
expires_in_ms
同步的倒计时),“批准 / 拒绝”经同一条 WS 回approval_response;超时或断连一律按“拒绝”收场、
回合继续,页面照它的口径说不假装能续期; - 模型透传:每回合 chat 帧带当前选中的
model/base_url/api_key,只对本回合生效、
不落盘、不粘住; - 结构化收尾:
ack/cancelled/error{code,retryable}各有各的落点,不混成一句“出错了”; - 缺 cjh 时给能走完的引导(§3.4)。
网页版启动台(九家)
DeepSeek / Kimi / 豆包 / 通义千问 / 智谱清言 / 腾讯元宝 / 文心一言 / MiniMax / ChatGPT——
点一张卡片就在当前窗口里开一个标签页加载该站真实地址,登录一次就记住(同一 WebView 用户数据目录),额度走各自的账号。
3.3 工程结构
examples/ai-chat/
├── cjpm.toml # 依赖框架(path ../..)+ 两平台的 link-option(GUI 子系统 / GTK)
├── capabilities/app.json # 能力清单:29 条命令、3 个事件、4 个权限集、三份 scope
├── src/
│ ├── main.cj # 214 行:装配窗口 + 7 个插件 + 注册命令 + 注入端点 + 启动/收尾
│ ├── cjh_host.cj # 33 KB:cjh 生命周期(探活 / 复用 / spawn / 一键部署 / 退出收尾)
│ ├── webtab.cj # 381 行:九家网页版的同窗内嵌视图(LRU 上限 4)
│ ├── allow_host.cj # 10 KB:运行期加白(原生确认框 + allowed-hosts.txt 持久化重放)
│ ├── packed_assets.cj # 空存根:打包器把页面 / 清单 base64 覆盖进来
│ └── packed_resources.cj # decodeBase64 / splitBy / textOrFile / vendorSegments
├── ui/index.html # 3963 行 / 235 KB:页面 + 极简 Markdown 渲染 + 全部业务逻辑
├── ui/vendor/ # highlight.js + 主题(构建期内联,非运行时外链)
└── vendor/cjh/ # 随仓预编译的 cjh v1.5.3(Linux tgz + Windows zip + SHA256SUMS)
main.cj 的装配段几乎就是一份自解释清单:
let app = TauriApp()
.window(WindowConfig("ai-chat, made by cj-tauri", 1320, 860))
.plugin(HttpPlugin()) // 直连管道的出网口(含 http:postStream 流式)
.plugin(LogPlugin()) // 日志落盘
.plugin(StorePlugin()) // 会话 / 服务商配置持久化
.plugin(ClipboardPlugin()) // 代码块复制
.plugin(WsPlugin()) // cjh 通道:ws:frame 回投聊天流
.plugin(DialogPlugin()) // 工作区“浏览…”的原生目录框
.plugin(FsPlugin()) // 文件预览(scope 决定能读哪儿)
.setLogToDefaultFile("ai-chat")
app.register("quit", QuitCommand(app))
app.register("cjh:status", cjhCmds) // 三态:已在跑 / 有路径 / 都没有
app.register("cjh:start", cjhCmds)
app.register("cjh:deploy", cjhCmds) // 一键部署(本地包校验 → 解包 → 拉起)
app.register("app:allowHost", AllowHostCommand(app))
app.register("webtab:list", webTabs) // 网页版五个命令
…
3.4 实现解剖之一:cjh 生命周期与“一键部署”
src/cjh_host.cj 只管生命周期,不参与数据面:
GET /api/health探活——已经在跑就复用(gCjhOwned=false,退出时不动人家的进程);- 没跑且配了
CJ_AICHAT_CJH,才spawn cjh web --port 8765 --bind 127.0.0.1,之后 100ms 一步轮询健康检查,最多 10 秒; - 退出时只收自己拉起的那个。
--bind 127.0.0.1 是刻意收紧的:cjh web 默认绑 0.0.0.0 且不带鉴权 token,跟着默认走等于把一个“能读写工作区、能跑 shell、能替你发消息给 LLM”的 agent 后端摊到整个局域网。
引导卡三态(切 Agent 模式先问 cjh:status):
- 已在跑 → 直接接上,不拉新进程;
- 有路径(上次挑过的 > 环境变量)→
cjh:start {path}当场拉起; - 都没有 → 出引导卡:cjh 是什么、为什么装在本地、按当前平台给该下的产物名与下载页。
为什么必须有“运行期指定路径”这条路:__CJH_API__ 只在 document-start 注入一次,启动那刻 cjh 不在就永远拿不到端点(Windows 双击启动的 GUI 连设环境变量的地方都没有),所以安装结果必须由应用自己接住,不能只提示一句“未连接”。
一键部署(cjh:deploy)把这条路走到底:
vendor/cjh/*.zip|.tar.gz
→ SHA256SUMS 本地校验(对不上就不部署,全程不走网络)
→ 解包(Linux: tar -xzf + chmod;Windows: %SystemRoot%\System32\tar.exe 绝对路径 bsdtar)
→ Windows 额外点名核对 4 个运行时 DLL(libcangjie-runtime / libboundscheck / libssl-3 / libcrypto-3,
它们按名字 LoadLibrary、不在 PE 导入表里,缺一就当场判失败)
→ 拉起(Linux: std.process.launch 能收退出码;Windows: 框架 launchHidden,DETACHED_PROCESS)
→ /api/health 200(10 秒内)
失败时说清楚卡在哪一步:安装包找不到 / 校验失败 / 解包失败 / 部署完 10 秒没就绪——最后一种还会提示“最常见原因是这份二进制与本机不兼容”,并点名 SmartScreen / 杀软 / 缺 VC 运行时(Windows)、glibc(Linux),然后回落到手动选文件。
3.5 实现解剖之二:九家网页版——同窗内嵌视图,不是 iframe
src/webtab.cj 暴露五个命令:webtab:list / show / bounds / hide / close,背后是宿主的内嵌视图能力(WebViewHost.createEmbeddedView):
handle = host.createEmbeddedView(site.url, x, y, w, h) // 真 WebView,不是 iframe
host.setEmbeddedViewBounds(handle, ...) // 页面布局变化时重报矩形(client 坐标)
host.setEmbeddedViewVisible(handle, true/false) // 切 tab = 换谁露出来,不重载
host.destroyEmbeddedView(handle) // 关 tab = 收掉背后的渲染进程
四个关键决策:
- 为什么不是 iframe:iframe 里是第三方上下文,WebView2 默认拦第三方 cookie,登录态存不住;站点跳 OAuth 时多数带
X-Frame-Options,直接白屏,弹窗也被拦。表现是“页面看得见、登录过不去”——比打不开更难查。 - 为什么不是新窗口:独立窗口把任务栏点乱,用户会当成第二个应用。切换应当是原地换内容。
- 上限 4 个视图 + LRU 淘汰:每个视图背后一个渲染进程,九家全开约 300–600 MB;满了就淘汰最久没用过的那个(绝不淘汰本次要显示的),标签条本身可以多于 4 枚,点回去重建视图、登录态仍在。
- 安全边界:外部页面不给桥。能力校验是全局的(
CapabilityRegistry.canInvoke不按窗口分权),
所以平台层建视图时不注入桥、也不注入插件 shim——外部页面摸window.__CJ_TAURI__得到
undefined,也就没有 invoke 宿主命令这条路。命令只认站点 id,页面传不了 URL,否则等于把“加载任意网页”变成一项可被页面使用的宿主能力(可钓鱼 / 可探内网)。
站点表只有一处:
let WEB_TAB_SITES: Array<WebTabSite> = [
WebTabSite("deepseek", "DeepSeek", "https://chat.deepseek.com/", "深度思考 R1 / V3", "#4d6bfe"),
WebTabSite("chatglm", "智谱清言", "https://chatglm.cn/", …),
… // 共九家;加一家只改这张表,前端卡片由 webtab:list 驱动渲染,页面不用动
]
UI 侧还有一条硬约束:视图是宿主原生控件,不参与页面排版,所以应用必须按自己的布局上报矩形(主窗 resize / 侧栏折叠 / DPI 变化都要重报,页面侧节流 80ms),否则它会盖住侧栏或露白边。
3.6 实现解剖之三:两条聊天管道的流式与收尾
管道 A:直连(http:postStream + http:chunk)
// 事件监听先于 invoke 挂上(命令发出后块可能立刻到达)
var off = window.__CJ_TAURI__.listen('http:chunk', function (p) {
if (p.done) { return; } // 收尾用命令 resolve 对账,避免事件与 resolve 竞争
acc += p.delta || '';
setBody(body, sseToText(acc), true);
});
window.__CJ_TAURI__.invoke('http:postStream', {
url: cfg.base, body: payload,
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + cfg.key }
});
三个设计要点:
- 宿主只回投字节,SSE 帧解析在前端(
data:行取 JSON、拼delta、遇[DONE]收)。
宿主不做协议解析,前端换一家兼容格式只需改一个函数; - 画解析后的文本,不画原始字节流:
acc是 SSE 原文,直接渲染会把报文闪现在气泡里(实测踩过);sseToText每次全量重解几十 KB,成本远低于流式等待; - 流式事件用
emitLatest(同帧槽位 + rAF 批刷、尾值必达),所以高频增量不会把页面压死;对照实验在examples/ipc-coalesce:emitLatest × 50合并成个位数次回调。
收尾三件事都不许“静默”:
- 空回复 ≠ 空:一条
data:行都没有时,报错会带上 HTTP 状态与收到的字节数,并点明 “最常见原因:Base URL 不是完整的 chat/completions 端点(DeepSeek 应为https://api.deepseek.com/chat/completions,不带/v1)”;服务端返回 HTML 时再补一句; - 截断标记进正文:
stat.truncated截断时把标记写进消息内容(刷新后仍看得见),不只是 DOM 里一行提示; - 停止生成措辞如实:服务端没有取消通道,所以文案是“本端已断开接收,服务端可能仍在生成”,并把已到文本落地成一条带标注的消息。
管道 B:Agent(ws 插件 + cjh)
function agentChatFrame(text, cjhId) { // 帧里带上当前选中的服务商
var f = { type: 'chat', message: text, session_id: cjhId || '' };
var ap = activeProvider();
if (ap) {
if (ap.model) { f.model = ap.model; }
if (ap.baseUrl) { f.base_url = ap.baseUrl; }
if (ap.apiKey) { f.api_key = ap.apiKey; } // 不同厂家 Key 不通用,只带 model+base_url 会拿错 Key 打别的端点
}
return f;
}
为什么必须带 api_key,注释写得很清楚:只带 model + base_url 时 cjh 会拿它自己的 Key 去打别的端点,失败形状是 provider 报错,“桌面端选的服务商”等于没生效。cjh 侧承诺这把 Key 仅本次请求、不落盘、日志不打印;桌面端的对应承诺是它不进任何日志行,自检断言 cjh-key-not-logged 守着这条。
收帧侧(handleCjhFrame)按类型分流:delta 增量续写气泡、tool_start / tool_result 生成工具 chip、
approval_request 弹审批卡、ack / cancelled / error{code,retryable} 各自如实落地。
两条管道共用同一套气泡、Markdown 渲染、收尾与存档逻辑——所以模式切换看起来是“侧栏改口”,不是换了个应用。
3.7 实现解剖之四:安全在应用层怎么落地
capabilities/app.json 就是这份应用的“对外权限声明”(摘录):
{
"identifier": "app",
"windows": ["main"],
"commands": ["http:get", "http:post", "http:postStream", "ws:connect", "ws:send", "ws:close",
"store:get", "store:set", "fs:readText", "dialog:pickFolder", "clipboard:write",
"cjh:status", "cjh:start", "cjh:deploy", "app:allowHost",
"webtab:list", "webtab:show", "webtab:bounds", "webtab:hide", "webtab:close", …],
"events": ["http:chunk", "ws:frame", "ws:state"],
"permissions": ["http:default", "store:default", "log:default", "ws:default"],
"scopes": {
"http": { "allow": ["api.deepseek.com", "api.openai.com", "open.bigmodel.cn",
"dashscope.aliyuncs.com", "api.moonshot.cn", "*.openai.azure.com",
"127.0.0.1", "localhost"] },
"ws": { "allow": ["127.0.0.1", "localhost"] },
"fs": { "allow": ["$HOME/**"],
"deny": ["**/.ssh/**", "**/.aws/**", "**/.git-credentials", "**/id_rsa*", "**/*.pem"] }
}
}
注意 scopes.fs 用的是“先 deny 后 allow”:即便在 $HOME 之下,.ssh、.aws、id_rsa、*.pem
也读不到——这是 ai-chat 自己的选择(文件面板只做预览,不该碰密钥)。
清单之外的端点怎么放行(这是产品化最见功力的一处):清单是启动时读的静态文件、页面改不了它,所以放行要人点一下。设置里保存服务商时,应用拿 Base URL 的主机名问一次(app:allowHost),不在名单里就弹原生确认框:
- 确认 →
TauriApp.allowScopePattern("http", host)当场叠进注册中心(这一轮立刻可用),并把主机名追加到~/.local/share/cj-ai-chat/allowed-hosts.txt,下次启动在run()之前重放; - 取消 → 什么都不改,请求仍被 scope 挡住(日志一行
scope denied),页面如实提示; - 请求被挡(业务错误含
outside the authorized scope)时,页面直接把确认框推到人面前。
而被注入的页面没法自行加白:确认框是原生的、页面点不了;注册中心只收具体值、拒通配;
清单里的 deny 也掀不掉。
3.8 实现解剖之五:渲染安全与持久化
模型输出是不可信内容,renderMd 的顺序是固定的:
先摘出代码块交 highlight.js → 整体 escHtml → 此后只认受控标记
(行内码 / 粗体 / 斜体 / 标题 / 表格 / 引用 / 列表;链接 href 只许 http(s),其余只留文字)
代码块复制按钮把源码放进 data-raw(转义引号防属性逃逸),复制的不是高亮后的 HTML;
bindCopyButtons 必须跟着每次 innerHTML 重画走——否则流式渲染逐帧重画会把已绑的 onclick 冲掉,表现为“流式收口后点复制没反应”。
持久化走 store 插件(任意 JSON、原子写 tmp + rename):
// store.json 里的一条:服务商配置
{ "providers": [{ "name": "DeepSeek", "baseUrl": "https://api.deepseek.com/chat/completions",
"apiKey": "sk-…", "model": "deepseek-chat" }] }
API Key 存本机 store.json,不入库(.gitignore 里有它),不进日志。
服务商配置还有一路“推给 cjh 基线”(syncProviderToCjh → POST /api/settings),触发点是切到 Agent 模式 / 保存服务商 / 切换服务商 / 每次连上 cjh;载荷只带非空字段(cjh 侧 baseUrl / model 是直接赋值,发空串等于清空配置),apiKey 为空或含掩码(GET /api/settings 返回 sk-e****493a 那种)一律不发——回写掩码等于把真 Key 换成星星。
3.9 分发:一个 exe,零随行文件
bash scripts/pack-win-single.sh examples/ai-chat # → examples/ai-chat/dist-single/ai-chat.exe
产物是一个文件(实测 10 789 376 字节,Subsystem = 0x2 GUI,导入表只有系统 DLL)。
页面、highlight.js 与主题、capabilities/app.json 都 base64 编进二进制,运行时不再读 ui/ 与
capabilities/。三件套(packed_assets.cj / packed_resources.cj / ui/vendor/pack-files.txt)
已在本示例接好,判据固定是“base64 非空即内嵌、否则读盘”,所以仓库内正常构建的行为一点没变。
两条提前知道的事:单文件包不含 cjh 安装包(13 MB 的 zip 没编进 exe),所以引导卡第 1 步在单文件产物里会如实报“找不到 cjh 安装包…”并给出 CJ_AICHAT_CJH_BUNDLE 的用法;双击启动时 C 桥的
dup2 日志落盘不生效(GUI 子系统下 stderr 句柄无效,文件会被建出来但 0 字节),要现场日志就用 ai-chat.exe 2> ai-chat.log 起。
3.10 自检与验收
CJ_AICHAT_PROBE=1 ./target/release/bin/main # 注入页内自检,断言经 report 回投 stderr
CJ_AICHAT_PROBE_DEPLOY=1 … # 另起一轮跑“一键部署”全链路(要真的装一份 cjh)
- 源码里共 73 处断言,本机隔离副本实跑 65 条明细 PASS / 0 FAIL——差额是条件分支(依赖“已配置 cjh / 已配技能”的那几条)没走到,不是漏跑;
- 四条独立探针(不进自检计数):
CJH-GUIDE(引导三态,且不得假装端点已就绪)、CJH-DEPLOY(deploy ok=true reused=false … api=yes+state=running)、CJH-PANELS 4/4、CJH-CHAT deltas=1 done=1 err=0; - 探针要分轮跑的原因也写清楚了:
CJH-DEPLOY要真的装一份 cjh 并起进程,和“自检要求先没有 cjh”的前提互斥。
四、跑起来
# 方式一:npm 脚手架(免安装)
npx cj-tauri create myapp
cd myapp && npx cj-tauri dev # 接管 Vite dev server,窗口内 HMR
# 方式二:直接跑本仓库的 ai-chat(Agent 模式需要仓颉 SDK 1.0.5+ 与 stdx)
cd examples/ai-chat
cjpm build
./target/release/bin/main # 工作目录必须是示例根(capabilities/ 与 ui/ 按相对路径读)
# Agent 模式三种接法
export CJ_AICHAT_CJH=/path/to/cjh # 自己指定
# 或不设:首次切 Agent 会出引导卡,“一键部署并启动”从 vendor/ 里的包解出来(本地 SHA256 校验,不走网络)
# 或:本机 8765 上已经有 cjh 在跑 → 直接复用(退出时不动它)
# 方式三:出 Linux 目录式便携包(包内自带 cjh)
bash ../../scripts/pack-linux.sh && cd dist-linux/ai-chat-linux-x64 && ./run.sh
# 方式四:Windows 单文件
bash scripts/pack-win-single.sh examples/ai-chat # → dist-single/ai-chat.exe(10.3 MB)
新机器第一件事:npx cj-tauri doctor(逐行给“缺了什么、怎么补”)。
五、为什么这套组合值得一看
- “轻”是真轻:4.69–10.3 MB 的桌面应用、系统 DLL 级依赖、双击不弹控制台——这是 Electron
永远给不了的交付形态,Tauri 用 Rust 做到了,cj-tauri 用仓颉 + 鸿蒙一等公民又做了一遍。 - 安全是结构性的,不是补丁:默认拒绝、事件也进白名单、路径 scope 先 deny 后 allow、出网口主机白名单“没写=全拒”、运行期加白拒一切通配——四类入口四条防线,每条都有可复跑的断言。
- “多端单业务代码”落到了工程上:
sync desktop/sync ohos派生骨架、业务包 path 依赖、页面代码与桌面版同一份,鸿蒙 PC 真机闭环(music / movie 两个示例取证)。 - 交付物自带验收:每个示例一条无人值守自检,断言回投 stderr,失败形状也写明“不假装成功”。
- ai-chat 是一个可信的“模板级”应用:它同时消费
http/ws两个出网插件,把SSE 流式、长连接、危险操作审批、模型透传、原生目录框、内嵌多视图、单文件分发、运行期加白这些“真需求”串成了一个能跑、能自检、能量产的实现——比任何 Hello World 都更能说明框架的边界在哪。
如果想继续深挖,推荐按这个顺序读仓库里的文档:
docs/使用文档.md(上手)→ docs/安全模型.md + docs/与Tauri的差异.md(心智模型)→
docs/设计决策记录.md(为什么长这样)→ docs/IPC-通信机制.md(协议与实测性能)→
examples/ai-chat/README.md(本文的实现细节来源)。
更多推荐



所有评论(0)