10 MB 的桌面 AI 客户端:用仓颉 + 系统 WebView 替代 Electron

本文基于仓库 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)

项目地址: atomgit.com/qq8864/cj-t...

1.2 分层架构

bash 复制代码
前端 (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 开发一个应用只需三步

ts 复制代码
// 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
}
js 复制代码
// 前端:桥由宿主在页面脚本之前注入
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 一份业务代码,多端产物

bash 复制代码
业务代码(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 工程结构

bash 复制代码
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 的装配段几乎就是一份自解释清单:

ts 复制代码
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 只管生命周期,不参与数据面:

  1. GET /api/health 探活------已经在跑就复用 (gCjhOwned=false,退出时不动人家的进程);
  2. 没跑且配了 CJ_AICHAT_CJH,才 spawn cjh web --port 8765 --bind 127.0.0.1,之后 100ms 一步轮询健康检查,最多 10 秒;
  3. 退出时只收自己拉起的那个。

--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)把这条路走到底:

bash 复制代码
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):

ts 复制代码
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 = 收掉背后的渲染进程

四个关键决策:

  1. 为什么不是 iframe :iframe 里是第三方上下文,WebView2 默认拦第三方 cookie,登录态存不住;站点跳 OAuth 时多数带 X-Frame-Options,直接白屏,弹窗也被拦。表现是"页面看得见、登录过不去"------比打不开更难查。
  2. 为什么不是新窗口:独立窗口把任务栏点乱,用户会当成第二个应用。切换应当是原地换内容。
  3. 上限 4 个视图 + LRU 淘汰 :每个视图背后一个渲染进程,九家全开约 300--600 MB;满了就淘汰最久没用过的那个(绝不淘汰本次要显示的),标签条本身可以多于 4 枚,点回去重建视图、登录态仍在。
  4. 安全边界:外部页面不给桥 。能力校验是全局的(CapabilityRegistry.canInvoke 不按窗口分权), 所以平台层建视图时不注入桥、也不注入插件 shim ------外部页面摸 window.__CJ_TAURI__ 得到 undefined,也就没有 invoke 宿主命令这条路。命令只认站点 id,页面传不了 URL,否则等于把"加载任意网页"变成一项可被页面使用的宿主能力(可钓鱼 / 可探内网)。

站点表只有一处:

ts 复制代码
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)

js 复制代码
// 事件监听先于 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)

js 复制代码
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 就是这份应用的"对外权限声明"(摘录):

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 的顺序是固定的:

scss 复制代码
先摘出代码块交 highlight.js → 整体 escHtml → 此后只认受控标记
(行内码 / 粗体 / 斜体 / 标题 / 表格 / 引用 / 列表;链接 href 只许 http(s),其余只留文字)

代码块复制按钮把源码 放进 data-raw(转义引号防属性逃逸),复制的不是高亮后的 HTML; bindCopyButtons 必须跟着每次 innerHTML 重画走------否则流式渲染逐帧重画会把已绑的 onclick 冲掉,表现为"流式收口后点复制没反应"。

持久化走 store 插件(任意 JSON、原子写 tmp + rename):

json 复制代码
// 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 复制代码
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 自检与验收

bash 复制代码
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"的前提互斥。

四、跑起来

bash 复制代码
# 方式一: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(逐行给"缺了什么、怎么补")。


五、为什么这套组合值得一看

  1. "轻"是真轻 :4.69--10.3 MB 的桌面应用、系统 DLL 级依赖、双击不弹控制台------这是 Electron 永远给不了的交付形态,Tauri 用 Rust 做到了,cj-tauri 用仓颉 + 鸿蒙一等公民又做了一遍。
  2. 安全是结构性的,不是补丁:默认拒绝、事件也进白名单、路径 scope 先 deny 后 allow、出网口主机白名单"没写=全拒"、运行期加白拒一切通配------四类入口四条防线,每条都有可复跑的断言。
  3. "多端单业务代码"落到了工程上 :sync desktop / sync ohos 派生骨架、业务包 path 依赖、页面代码与桌面版同一份,鸿蒙 PC 真机闭环(music / movie 两个示例取证)。
  4. 交付物自带验收:每个示例一条无人值守自检,断言回投 stderr,失败形状也写明"不假装成功"。
  5. ai-chat 是一个可信的"模板级"应用 :它同时消费 http / ws 两个出网插件,把SSE 流式、长连接、危险操作审批、模型透传、原生目录框、内嵌多视图、单文件分发、运行期加白这些"真需求"串成了一个能跑、能自检、能量产的实现------比任何 Hello World 都更能说明框架的边界在哪。

如果想继续深挖,推荐按这个顺序读仓库里的文档: docs/使用文档.md(上手)→ docs/安全模型.md + docs/与Tauri的差异.md(心智模型)→ docs/设计决策记录.md(为什么长这样)→ docs/IPC-通信机制.md(协议与实测性能)→ examples/ai-chat/README.md(本文的实现细节来源)。

相关推荐
修炼的dance2 小时前
白底图存JPG,sharp开mozjpeg背景杂点少近三成
前端
anOnion2 小时前
构建无障碍组件之Grid Pattern
前端·html·交互设计
乔旭伟3 小时前
Vben3 中后台表格封装实战:useVbenVxeGrid 的全局收敛与七个坑
前端·anti-design-vue·1024程序员节
鶴哥只手遮天3 小时前
jQuery 4.0 源码架构与工程实践-第 10 章 尺寸、几何与显隐
前端·javascript·jquery
Mia_墨黎3 小时前
2026 Windows 11 终端美化完全指南
前端·后端
用户1880687493523 小时前
戴着 Rokid 接成语:用 AIUI 做一局有提示、有复盘的“接着说”
前端
Sakura_j_h_3 小时前
Three.js 单文件离线机械臂工作台:爆炸图、透明外壳与剖面
前端·机械臂·webgl·three.js·1024程序员节·esbuild
SZYSK5 小时前
鸿蒙人脸识别算法放在终端里:和外挂授权 SDK 差在排障路径
物联网·算法·华为·harmonyos
计算机魔术师5 小时前
管谷歌的不是Pichai一个人,是一整层AI智能体
前端