AI图鉴

AI图鉴首页 › AI 资讯 › 10 MB 的桌面 AI 客户端:用仓颉 + 系统 WebView 替代 Ele

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

来源:掘金 · 发布时间:10-25 · 由 AI图鉴每小时自动聚合

可以在windows系统下开发,一套代码,不同构建,即可运行在windows、linux、和鸿蒙PC。再也不用在网页上找来找去了,体积包10M大小,完全替代臃肿庞大的Cherry Studio啦。 一

本文基于仓库 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 桌面形态)作为一等公民。

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

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:

代价也明确:依赖宿主系统的 WebView 版本(Windows 需 WebView2 Runtime,Win11 自带;Linux 需libwebkit2gtk-4.1),并且外壳体验受系统 WebView 能力约束。

2.3 能力安全模型:默认拒绝,四类入口各自设闸

威胁模型只有一句话:页面是不可信的。页面能触及的入口被收窄成四类,每类一条独立防线:

几个刻意做“更严”的点:

- 空清单 = 全拒,没有“缺省全开”的形态;

- 集声明 ≠ 放行:插件声明了 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.j

本文由 AI图鉴自动抓取并翻译,版权归原作者所有;如需查看原文请点下方链接。

相关 AI 工具

相关免费羊毛

同源资讯

阅读原文(juejin.cn)

标题、摘要与正文均来自该信源的公开页面,版权归原作者所有;本站自动聚合与翻译,仅作出处跳转。

← 返回 AI 资讯列表AI工具图鉴首页

返回 AI图鉴首页 · 提交工具 · 商务合作 · 关于本站 · 591 款 AI 工具分类导航 · 每日 AI 热门项目与动态