数据截至 (上游 commit 3dcf4cad0124)
骨架:三层进程与可插拔扩展系统
30 秒导读: Jan 是一个能离线跑大模型的桌面聊天客户端。这一章只讲它的静态骨架—— 代码分几个包、怎么打包、扩展要实现什么契约、界面怎么调到 Rust。读完你会知道 "一个新功能该塞进哪一层"。对话怎么流、模型怎么加载、MCP 怎么调用,分别在 02、04、03 讲。
1. 全景:三层进程
Jan 不是一个进程,是三层。先看这张图,后面所有内容都挂在它上面。
用户点一下「发送」
│
┌───────────────────────────▼─────────────────────────────────┐
│ ① 界面层 web-app/ (React + Vite,跑在 WebView 里) │
│ 聊天 UI · 扩展宿主 · ServiceHub · 状态管理 │
└───────────────────────────┬─────────────────────────────────┘
│ invoke("命令名", 参数)
│ ←── Tauri 事件回传
┌───────────────────────────▼─────────────────────────────────┐
│ ② 内核层 src-tauri/ (Rust 主进程) │
│ 读写文件 · 线程存 储 · MCP 客户端 · 本地 API 服务 · 下载器 │
└───────────────────────────┬─────────────────────────────────┘
│ spawn 子进程 / HTTP
┌───────────────────────────▼─────────────────────────────────┐
│ ③ 外部进程 llama-server · MCP server · 远端模型 API │
└─────────────────────────────────────────────────────────────┘
怎么读这张图: 从上往下是一次请求的下沉方向;层与层之间只有两种通信手段——
界面调 Rust 用 invoke,Rust 通知界面用事件。
三层各自的家在哪:
| 层 | 干什么 | 代码位置 | 语言 |
|---|---|---|---|
| ① 界面 | 渲染 UI、装载扩展、决定调哪个平台实现 | web-app/src/ | TypeScript / React |
| ② 内核 | 一切需要操作系统权限的活 | src-tauri/src/ | Rust |
| ③ 外部 | 真正吃显存的推理进程、第三方工具进程 | 运行时下载/配置 | — |
还有一个横跨的第四块:扩展(extensions/)。它是 TypeScript,但跑在界面层,
只是被单独打包、单独安装,像浏览器插件一样可插拔。整章后半段都在讲它。
别把「三层」和「四个包」混成一件事。 本章说的三层是进程与权限边界的分层;
仓库里的代码包是另一套分法,共四套:core/(扩展 SDK)、web-app/(界面)、
extensions/(可插拔扩展)、src-tauri/(Rust 内核)。前三套全都运行在界面层这一个进程里,
其中 core/ 连运行时实体都不是,只是一个被 import 的库。所以"扩展"在进程视角是界面层的一部分,
在打包视角才是独立的一块。下一节按包讲仓库结构,§7 之后再回到进程分层。
2. 仓库长什么样:一个 monorepo,三套 workspace
2.1 三套 workspace,不是一套
根 package.json 声明了一套 workspace,把三块都收进来:
"workspaces": { "packages": ["core", "web-app", "extensions/*"] }
出处 package.json:5-10。(旧版 extensions/ 是一套独立的 yarn workspace,
有自己的 extensions/package.json 声明 "packages": ["**"]——那份文件已随
"扩展随 app 打包"的重构移除,现在扩展直接挂在根 workspace 下。)
虽然同属一个 workspace,三者的产物形态仍然不同:
| workspace | 包名 | 产物 | 谁消费 |
|---|---|---|---|
core/ | @janhq/core | dist/index.js + 类型声明 | web-app 和所有扩展 import 它 |
web-app/ | @janhq/web-app | 一堆静态资源,由 Tauri 打进 App | 最终用户 |
extensions/* | @janhq/llamacpp-extension 等 7 个 | 各自的 dist/index.js(自包含 ESM 包) | vite alias 进 web bundle,运行时按需 import() |
扩展曾是独立 npm tarball(为"独立安装、独立升级");改为随包分发后仍保留各扩展的
node_modules 边界——根和每个扩展都写了
"installConfig": { "hoistingLimits": "workspaces" }(根 package.json:68-69,
扩展如 extensions/llamacpp-extension/package.json:47-48),
明确禁止跨 workspace 提升。
2.2 构建流水线:扩展打成自包含 ESM,直接进 web bundle
(旧版流水线是"扩展 npm pack 成 .tgz → Rust 首启解包安装",已废弃;现在的形态如下。)
yarn build:extensions package.json:51
├─ 先 build @janhq/core (扩展依赖它的类型和基类)
└─ yarn workspaces foreach --include '@janhq/*-extension' run build
每个扩展 rolldown 打成自包含的 dist/index.js(ESM)
│
yarn build:web / build:tauri vite 把各扩展 dist 经 alias 织进前端 bundle
└─ web-app/vite.config.ts:64-72 '@janhq/xxx-extension' → ../extensions/xxx/dist/index.js
运行时 bundled-extensions.ts 再对每个扩展做惰性 import()
扩展产物不再是 .tgz,而是自包含的 ESM dist/index.js。vite 把 @janhq/*-extension
统一 alias 到各自的 dist(web-app/vite.config.ts:64-72),并把这些包排除出
dep 预优化(optimizeDeps.exclude,vite.config.ts:75-91,注释解释:中途重打包会
打断 service hub 的动态 import,冷启动报"Importing a module script failed")。
(各扩展 package.json 里还留着旧发布用的 build:publish 脚本,但主流水线已不再用它。)
平台差异靠 run-script-os 挑子脚本:Rust 侧插件 API 包在 Windows / Linux 上
--exclude @janhq/tauri-plugin-mlx-api(package.json:49-50),前端侧 MLX 扩展
则由编译期常量 IS_MACOS 做死代码消除(bundled-extensions.ts:85-95)——非 macOS
平台连 import() 都不进 bundle 图。
2.3 启动时:扩展已在 bundle 里,枚举即激活
旧版"Rust 首启解 .tgz、写 extensions.json 清单"的安装步骤已整体移除
(setup.rs 里不再有 install_extensions/extract_extension_manifest);
现在扩展的"清单"是前端里一张编译期的打包表——web-app/src/services/core/bundled-extensions.ts
的 ENTRIES:每项记 load: () => import('@janhq/xxx-extension') 加 name/version/description,
macOS 专属的 MLX 在表外按 IS_MACOS 追加(bundled-extensions.ts:85-95)。
getBundledExtensions()(bundled-extensions.ts:87-113)枚举时才惰性 import 并当场
new Ctor('built-in', ...) 预建实例(manifest 的 url 恒为 'built-in')。注释明说:
这刻意保持了旧版"先 hub 就绪、后加载扩展"的启动顺序,扩展包不进 service-hub 的
bootstrap 依赖图。桌面与移动端共用这套打包表;手机上只挑带 mobile: true 标记的子集
(没有原生插件依赖的扩展),详见 §7.3。
3. 扩展契约:BaseExtension 是什么
3.1 一句话:扩展就是一个默认导出的类
core/src/browser/extension.ts:32 的 BaseExtension 是所有扩展的祖先。
它只强制两个方法:
abstract onLoad(): void // extension.ts:82
abstract onUnload(): void // extension.ts:88
一个最小扩展长这样:
// 示意,非源码
import { BaseExtension } from '@janhq/core'
export default class HelloExtension extends BaseExtension {
async onLoad() { // 应用启动时调一次
await this.registerSettings(SETTINGS) // 声明我有哪些可调项
this.token = await this.getSetting('token', '')
}
async onUnload() {} // 应用退出时调一次
}
真实的最小例子是下载器:extensions/download-extension/src/index.ts:23-31,
onLoad 里就干了注册设置 + 读一个 HF token 两件事。
必须默认导出一个类,因为宿主是靠 extensionClass.default 反射实例化的
(web-app/src/lib/extension.ts:214-232),后面 §6 细讲。
3.2 设置系统:声明式 + localStorage + 三条合并规则
扩展不写设置界面,只声明自己有哪些配置项,UI 由宿主统一渲染。四个方法配套:
| 方法 | 干什么 | 位置 |
|---|---|---|
registerSettings(settings) | 声明配置项,与已存的旧值合并后落盘 | extension.ts:114 |
getSettings() | 从 localStorage 读回整份 | extension.ts:187 |
getSetting(key, default) | 读单个值,没有就用默认值 | extension.ts:163 |
updateSettings(props) | 改值并逐个回调 onSettingUpdate | extension.ts:206 |
onSettingUpdate(key, value) | 扩展自己重写,响应用户改设置 | extension.ts:170 |
存储位置是 localStorage,key 就是扩展名(extension.ts:151、:191)。
不是文件、不是数据库——所以设置随 WebView 的 origin 走。
难点在合并。 新版扩展带来新的设置定义(SETTINGS 常量),但用户上次调过的值必须留住。
registerSettings 因此有三条不对称的合并规则(extension.ts:126-149):
| 字段 | 规则 | 为什么 |
|---|---|---|
value | 旧值优先,旧值没有才用新默认值 | 用户改过的不能被升级抹掉 |
options | 新的非空就用新的,新的为空才回 退旧的 | 下拉项(如可用后端列表)是运行时探测的,可能还没填 |
recommended | 旧值只要非空非 undefined 就压过新值 | 推荐值是运行时按硬件算出来的,比编译期默认更准 |
options 那条还带一个兜底校验:如果保留下来的 value 不在新的 options 里,
就强制回落到 options[0](extension.ts:138-140)。这防的是"上次选的后端这次不支持了"。
真实用例看 extensions/llamacpp-extension/src/index.ts:460-495 的 onLoad——
它在 registerSettings 之前先 getSettings() 读旧值,把废弃的
version_backend 复合字符串拆成 llamacpp_version + llamacpp_backend 两项,
再交给 registerSettings 落盘。顺序要紧:registerSettings 会整份覆写,迁移必须抢在它前面。
4. 十一种扩展类型,六个抽象基类
4.1 类型枚举
ExtensionTypeEnum 列了 11 种(core/src/browser/extension.ts:5-17):
| 枚举值 | 字符串 | core 里有抽象基类吗 |
|---|---|---|
Assistant | assistant | 有 — extensions/assistant.ts |
Conversational | conversational | 有 — extensions/conversational.ts |
Inference | inference | 有 — extensions/inference.ts |
MCP | mcp | 有 — extensions/mcp.ts |
RAG | rag | 有 — extensions/rag.ts |
VectorDB | vectorDB | 有 — extensions/vector-db.ts |
Model | model | 没有 |
SystemMonitoring | systemMonitoring | 没有 |
HuggingFace | huggingFace | 没有 |
Engine | engine | 没有 |
Hardware | hardware | 没有 |
后五个是枚举里的空位——本 commit 下 core/src/browser/extensions/ 里没有对应的
抽象类文件,也没有内置扩展声明这些类型。可以理解为历史残留或预留槽位。
4.2 type() 决定"能不能被按类型找到"
每个抽象基类只干一件加法:重写 type() 返回自己的枚举值,然后列一串 abstract 方法。
| 基类 | type() 返回 |
|---|