跳到主要内容

数据截至 (上游 commit 3dcf4cad0124)

骨架:三层进程与可插拔扩展系统

30 秒导读: Jan 是一个能离线跑大模型的桌面聊天客户端。这一章只讲它的静态骨架—— 代码分几个包、怎么打包、扩展要实现什么契约、界面怎么调到 Rust。读完你会知道 "一个新功能该塞进哪一层"。对话怎么流、模型怎么加载、MCP 怎么调用,分别在 020403 讲。


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/coredist/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.tsENTRIES:每项记 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:32BaseExtension 是所有扩展的祖先。 它只强制两个方法:

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)改值并逐个回调 onSettingUpdateextension.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-495onLoad—— 它在 registerSettings 之前getSettings() 读旧值,把废弃的 version_backend 复合字符串拆成 llamacpp_version + llamacpp_backend 两项, 再交给 registerSettings 落盘。顺序要紧:registerSettings 会整份覆写,迁移必须抢在它前面。


4. 十一种扩展类型,六个抽象基类

4.1 类型枚举

ExtensionTypeEnum 列了 11 种(core/src/browser/extension.ts:5-17):

枚举值字符串core 里有抽象基类吗
Assistantassistant有 — extensions/assistant.ts
Conversationalconversational有 — extensions/conversational.ts
Inferenceinference有 — extensions/inference.ts
MCPmcp有 — extensions/mcp.ts
RAGrag有 — extensions/rag.ts
VectorDBvectorDB有 — extensions/vector-db.ts
Modelmodel没有
SystemMonitoringsystemMonitoring没有
HuggingFacehuggingFace没有
Engineengine没有
Hardwarehardware没有

后五个是枚举里的空位——本 commit 下 core/src/browser/extensions/ 里没有对应的 抽象类文件,也没有内置扩展声明这些类型。可以理解为历史残留或预留槽位。

4.2 type() 决定"能不能被按类型找到"

每个抽象基类只干一件加法:重写 type() 返回自己的枚举值,然后列一串 abstract 方法。

基类type() 返回核心抽象方法(节选)
AssistantExtensionAssistantcreateAssistant / deleteAssistant / getAssistants
ConversationalExtensionConversationallistThreads / createThread / listMessages / createMessage / getThreadAssistant
MCPExtensionMCPgetTools / callTool / getConnectedServers / refreshTools / isHealthy
RAGExtensionRAGingestAttachments / parseDocument / embed / callTool
VectorDBExtensionVectorDBcreateCollection / insertChunks / searchCollection(每个都有 ...ForProject 双胞胎)
InferenceExtensionInferenceinference(data)

BaseExtension.type() 的默认实现返回 undefined(extension.ts:74-76), 注释写得很直白:"没有继承任何应用已知的扩展类型"。 下载器扩展就是这种——它直接继承 BaseExtension,永远查不到,只能按名字取。

VectorDBExtension 值得单独看一眼(core/src/browser/extensions/vector-db.ts:47-107): 它的每个方法都有 thread 版和 project 版两套(searchCollection / searchCollectionForProject), 因为附件既可以挂在一次对话上、也可以挂在一个项目上。细节归 05 章


5. 引擎继承链:AIEngine 及其三个后代

5.1 链条长什么样

推理扩展不直接继承 BaseExtension,而是走一条更长的链:

BaseExtension extension.ts:31
│ + onLoad/onUnload/设置系统

AIEngine engines/AIEngine.ts:237
│ + provider 字段 + 模型生命周期(load/unload/chat/list/import…)
│ + onLoad 自动 registerEngine()
├──────────────────────────────┐
▼ │
OAIEngine │ engines/OAIEngine.ts:14
│ + 订阅事件总线、AbortController、headers()
├───────────────┐ │
▼ ▼ ▼
LocalOAIEngine RemoteOAIEngine (内置扩展直接从这里继承)
+ 订阅模型 + apiKey → llamacpp_extension index.ts:357
加载/卸载事件 Bearer 头 mlx_extension index.ts:55

怎么读: 竖线是继承。注意最右那条——本 commit 下的两个内置推理扩展 llamacppmlx 直接继承 AIEngine,跳过了 OAI 三层 (extensions/llamacpp-extension/src/index.ts:421extensions/mlx-extension/src/index.ts:53)。 OAIEngine / LocalOAIEngine / RemoteOAIEngine 在整个仓库里没有任何非测试的子类, 它们目前是给第三方扩展预留的 SDK,不是主干路径。

5.2 每一层加了什么

新增能力关键行
AIEngineabstract provider 字符串;onLoad 里自动 registerEngine();12 个模型管理抽象方法AIEngine.ts:250:244-253
OAIEngineabstract inferenceUrl;onLoad 里订阅 OnMessageSent/OnInferenceStopped;stopInference() 触发 abortOAIEngine.ts:34-40:52-55
LocalOAIEngine再订阅 ModelEvent.OnModelInit/OnModelStop,把事件接到 loadModel/unloadModelLocalOAIEngine.ts:21-26
RemoteOAIEngine一个 apiKey,headers() 同时吐 Authorization: Bearerapi-key 两个头RemoteOAIEngine.ts:19-26

RemoteOAIEngine 同时发两个头是为了一次覆盖 OpenAI 风格和 Azure 风格的鉴权,不用分叉。

AIEngine 的抽象方法把"一个推理后端要会什么"说死了(AIEngine.ts:270-331): get / list / load / unload / chat / delete / update / import / abortImport / getLoadedModels / isToolSupported。其中 isToolSupported 很关键—— 工具路由要先问模型支不支持 function calling,见 03 章; llamacpp 那边的实现有多糙,见 04 章。 只有 pauseImport 给了空实现的默认值(:309),因为不是每个后端的下载都可续传。

5.3 EngineManager:三十行的注册表

core/src/browser/extensions/engines/EngineManager.ts 全文只有 33 行,核心是一个 Map:

register<T extends AIEngine>(engine: T) {
this.engines.set(engine.provider, engine) // EngineManager.ts:14-16
}
static instance(): EngineManager {
return (window.core?.engineManager as EngineManager) ?? new EngineManager()
}

instance() 那行(:30-32)是个软单例:优先拿挂在 window.core 上的那个, 拿不到就 new 一个新的。这让扩展在测试环境或没初始化的窗口里不会直接崩, 代价是可能悄悄注册进一个孤儿实例。

调用链是自动的:扩展的 onLoadAIEngine.onLoad(AIEngine.ts:255)→ registerEngine()EngineManager.instance().register(this)。扩展作者只要写 super.onLoad() 就完成了注册。


6. 运行时注册中心:ExtensionManager

6.1 启动时序

web-app/src/lib/extension.ts:62ExtensionManager 是界面层的扩展宿主。 它被 ExtensionProvider 这个 React 组件驱动:

ExtensionProvider 挂载 providers/ExtensionProvider.tsx:24

├─ 建 window.core = { api, events, extensionManager,
│ engineManager, modelManager } :30-37

├─ 非主窗口?→ 直接结束 :39-42
│ (日志窗、监控窗共用同一份 bundle,再装一遍会
│ 双起 llama-server,而且没有对应的 Tauri 权限)

├─ registerActive() 读清单 → 动态 import → new 实例 :49
├─ load() 并发跑所有 onLoad,报进度 :50

└─ 20 秒看门狗:超时也照样渲染 UI :63-66

两处防御值得记:主窗口闸门(isMainWindow(),ExtensionProvider.tsx:14-20) 和看门狗。加上 load() 内部用 Promise.allSettled 而不是 Promise.all (lib/extension.ts:144-156),失败的扩展只打一条 error,不拖垮别人。 合起来的效果是:一个坏扩展绝不会让你看到白屏

6.2 register 一次,登记两处

这是 ExtensionManager 最该记住的一段(web-app/src/lib/extension.ts:78-99):

register<T extends BaseExtension>(name: string, extension: T) {
this.extensions.set(name, extension) // 按名字
if ('provider' in extension && typeof extension.provider === 'string') {
this.engines.set(extension.provider as unknown as string,
extension as unknown as AIEngine) // 再按 provider
}
}

鸭子类型地探测 provider 字段(不是 instanceof AIEngine),命中就额外登记进 engines 这张表。于是同一个 llamacpp 扩展实例同时躺在两个 Map 里。

为什么不用 instanceof?因为扩展是运行时 import() 进来的独立 bundle, 各自打包了一份 @janhq/core,instanceof 跨 bundle 会失效。看字段是唯一可靠的判据。(inferred)

于是取扩展有三条路,各有各的场合:

方法查什么表复杂度典型调用方
get(type)遍历 extensions.values()type()O(n),取第一个匹配服务层,如 DefaultThreadsServiceConversational
getByName(name)extensions MapO(1)取没有 type() 的扩展,如下载器
getEngine(provider)engines MapO(1)llamacpp / mlx 取推理后端

get(type) 是线性扫描且只返回第一个命中(lib/extension.ts:106-108)—— 言下之意是每种类型只允许一个活跃实现,装两个 conversational 扩展会有一个被静默忽略。

推理扩展没有重写 type(),所以 get(ExtensionTypeEnum.Inference) 找不到它们; 它们只能靠 getEngine('llamacpp') 取到。这不是 bug,是刻意的分流: "哪种能力"用 type 查,"哪个厂商"用 provider 查。

6.3 两种装载方式

activateExtension(lib/extension.ts:204-235)有两条分支:

extension.extensionInstance 存在?
├─ 是 → 直接 register(已经是实例了) 移动端:编译期打包
└─ 否 → convertFileSrc(url) → 动态 import() 桌面端:磁盘上的 js
→ 检查 default 是函数且有 prototype
→ new default(url, name, productName, active, description, version)

convertFileSrc 把本地绝对路径转成 WebView 能 import 的自定义协议 URL, 由 TauriCoreService 代理到 Tauri 官方 API(web-app/src/services/core/tauri.ts:21-28)。 构造参数的顺序必须BaseExtension 的构造函数一致(core/src/browser/extension.ts:54-68)—— 这是个隐式契约,写扩展时别改签名。


7. 平台抽象:ServiceHub

7.1 问题:同一份 React 代码要跑在四个地方

Jan 的界面层要跑在桌面 Tauri、iOS、Android,还有纯浏览器。 "打开一个文件对话框"在这四个环境里的实现完全不同,但调用方(React 组件)不该关心。

ServiceHub 就是那道墙:一个只有 21 个 getter 的接口(web-app/src/services/index.ts:56-79)。

export interface ServiceHub {
theme(): ThemeService
window(): WindowService
mcp(): MCPService
threads(): ThreadsService
... // 共 21 个
}

7.2 挑选逻辑:构造时全给 default,初始化时按平台覆盖

PlatformServiceHub(services/index.ts:81)的做法很朴素:

new PlatformServiceHub()
└─ 21 个字段全部先 = new DefaultXxxService() :82-102

initialize() :108

┌────────┴────────┬──────────────────┐
▼ ▼ ▼
Tauri 桌面 iOS / Android 其它(纯 Web)
:119 :164 什么都不做
│ │ │
动态 import 13 套 动态 import 11 套 保持 default
tauri 实现覆盖 (少 hardware/updater,
core 换成 mobile)

注意是 await import(...) 动态导入,不是顶层 import(services/index.ts:136-149)。 好处是纯 Web 构建里 @tauri-apps/api 那一大坨根本不会进 bundle。

ensureInitialized()(:213-219)守在每个 getter 前面,没初始化就抛异常—— 把"忘了初始化"变成启动即崩,而不是运行到一半拿到空对象。

7.3 为什么有的 service 有 tauri.ts,有的没有

21 个 service 分成两类,这是整节最实用的一张表:

类别数量有哪些为什么
平台绑定型(default.ts + tauri.ts)13app, core, deeplink, dialog, events, hardware, mcp, opener, path, providers, theme, updater, window必须调操作系统或 Tauri 插件,不同平台实现不同
扩展委托型(只有 default.ts)8analytic, assistants, messages, models, projects, rag, threads, uploads逻辑全在扩展里,service 只是转发,平台无关

第二类的 default.ts 通常是几行转发。比如线程服务 (web-app/src/services/threads/default.ts:24-36):

async fetchThreads(): Promise<Thread[]> {
const ext = ExtensionManager.getInstance()
.get<ConversationalExtension>(ExtensionTypeEnum.Conversational)
// 扩展在启动竞态里可能还没注册:抛错让调用方重试,
// 而不是把"还没就绪"当成"没有线程"把列表清空
if (!ext) throw new Error('Conversational extension not available yet')
...

它不认识 Tauri,只认识"某个 conversational 扩展"。换平台时扩展换了,它一行都不用改。 这是 Jan 分层最漂亮的一处:平台差异被压在两个地方——13 个 tauri.ts,和扩展本身。

第一类的 tauri.ts 全部继承 default.ts 再重写,不是并列实现 (services/core/tauri.ts:11 class TauriCoreService extends DefaultCoreService)。 DefaultCoreService.invoke 直接抛 'Core invoke not implemented'(services/core/default.ts:10-13), 是个"响亮失败"的占位。

移动端更进一步:MobileCoreService extends TauriCoreService (services/core/mobile.ts:12),重写扩展相关的方法,把 getActiveExtensions() 改成只返回打包表里带 mobile: true 标记的子集 (无原生插件依赖的扩展,mobile.ts:13-15),installExtension 一律空操作、 uninstallExtension 恒为 false(mobile.ts:17-26)。桌面与移动共用同一张打包表 和 vite alias(web-app/vite.config.ts:64-72);getBundledExtensions 枚举时就地 new 出实例塞进 manifest。这就是 §6.3 里 extensionInstance 那条分支的来源—— 移动端没有可写的扩展目录,扩展在编译期就焊死在 bundle 里。

7.4 平台是怎么判出来的

web-app/src/lib/platform/utils.ts 里三个谓词,靠 vite 的 define 在编译期注入常量 (web-app/vite.config.ts:91-109):

函数判据位置
isPlatformTauri()IS_WEB_APP 为假 window.__TAURI__ / __TAURI_INTERNALS__ 真的存在utils.ts:7-31
isPlatformIOS()编译期常量 IS_IOSutils.ts:33-35
isPlatformAndroid()编译期常量 IS_ANDROIDutils.ts:37-39

isPlatformTauri运行时二次确认是个容易忽略的细节,注释说得很清楚: 开发时可能带着 Tauri 编译标志、却用普通浏览器打开 localhost。 只信编译期常量会让所有 invoke 调用炸掉,所以再摸一次 window 上有没有桥。


8. 前后端桥:界面怎么叫得动 Rust

8.1 通路一:window.core.api → invoke

ExtensionProvider 启动时把 APIs 挂到 window.core.api(ExtensionProvider.tsx:30-32)。 APIs自动生成的——不是手写 200 个方法,而是把一张路由名单 map 成同名函数 (web-app/src/lib/service.ts:40-47):

const command = proxy.route.replace(/([A-Z])/g, '_$1').toLowerCase()
return getServiceHub().core().invoke(command, args)

驼峰转蛇形就是全部的映射规则:getActiveExtensionsget_active_extensions, 正好对上 Rust 的函数名。名单由三段拼成(service.ts:30):

名单来自内容
CoreRoutescore/src/types/api/index.ts:151-158AppRoute + ExtensionRoute + FileSystemRoute + FileManagerRoute
APIRoutes同文件 :158CoreRoutes + NativeRoute
AppRoutesweb-app/src/lib/service.ts:6-27MCP 工具、线程、消息等 21 个(installExtensionschangeAppDataFolder)

非 Tauri 环境下这些函数统一 console.warn 后返回 null(service.ts:111-114)—— 纯 Web 版本安静降级,不崩。

core/src/browser/core.ts 是给扩展用的薄封装,每个函数就是一行透传:

const getJanDataFolderPath = (): Promise<string> =>
globalThis.core.api?.getJanDataFolderPath() // core.ts:8

导出 11 个这样的工具函数(core.ts:103-115)。 诚实提示: 本 commit 下 core.ts 没有 executeOnMain —— core/src/browser/core.test.ts:6 还留着一行 import { executeOnMain } from './core', 但源文件里已经找不到这个符号了,是次陈旧的测试引用。跨进程调用现在只有 window.core.api.* 这一条路,不再有 Electron 时代的 executeOnMain 桥。

8.2 通路二:事件总线(注意有两套)

这里最容易混。 Jan 里有两个不相干的"事件":

名字范围实现用途
events(@janhq/core)进程内,只在 WebView 里EventEmitter,一个 Map<string, Function[]>扩展之间、扩展与 UI 解耦
Tauri 事件跨进程,Rust ↔ WebView@tauri-apps/api/eventemit/listen下载进度、模型忙碌告警等回推

第一套:core/src/browser/events.ts 全文只有 35 行,三个函数全是往 globalThis.core?.events 上转发(events.ts:7:17:27)。真正的实现是 web-app/src/services/events/EventEmitter.ts——一个最朴素的 Map 版发布订阅, 没有优先级、没有 once、没有错误隔离。它由 ExtensionProvider.tsx:34 装配上去。

OAIEngine 用的就是这套:events.on(MessageEvent.OnMessageSent, ...) (OAIEngine.ts:36-39)。注意 ?. 链——globalThis.core 没建好时静默丢事件, 不抛异常(events.ts:8)。

第二套走 EventsService,TauriEventsService 直接包 Tauri 官方 API (web-app/src/services/events/tauri.ts:11-30)。Rust 侧用 app_handle.emit("llamacpp-busy-on-exit", &busy) 这样发(src-tauri/src/lib.rs:165)。

8.3 通路三:Rust 侧的命令注册

Rust 用一个宏把所有命令列在一处(src-tauri/src/lib.rs:27-125):

macro_rules! invoke_commands_with_extras {
($($extra:path),* $(,)?) => {
tauri::generate_handler![
core::filesystem::commands::join_path,
...
$( $extra, )*
]};
}

宏尾巴的 $extra 是为了按平台加料:桌面版塞进更新检查命令 (lib.rs:243-247),移动版塞进 abort_remote_stream(lib.rs:251-254)。 不用宏就得把七十多个命令名抄两遍。

命令按域分组,每组对应 src-tauri/src/core/ 下一个模块:

命令数(约)模块相关章节
文件系统15core::filesystem
应用配置8core::app
扩展3core::extensions本章 §2.3
系统 / CLI11core::system
本地 API 服务3core::server06 章
远端 provider5core::server::remote_provider_commands02 章
MCP12core::mcp03 章
线程 / 消息11core::threads05 章
下载3core::downloads

重活不在命令里,在插件里。 run() 的插件链上挂了五个自研 Tauri 插件 (lib.rs:222-238),挂载条件各不相同:

插件何时挂
tauri_plugin_llamacpp总是lib.rs:222
tauri_plugin_vector_db总是lib.rs:223
tauri_plugin_rag总是lib.rs:224
tauri_plugin_mlx仅 macOS(#[cfg(target_os = "macos")])lib.rs:233
tauri_plugin_hardware非移动端lib.rs:238

源码都在 src-tauri/plugins/。真正 spawn llama-server、管显存的代码在那里, 归 04 章;向量库那侧归 05 章


9. 巧妙之处(可以直接抄走的)

1. 按字段而不是按类型判断身份。 'provider' in extension 一句(web-app/src/lib/extension.ts:83)绕过了 跨 bundle instanceof 失效的经典陷阱。任何插件系统只要允许运行时 import(),都会遇到这个问题。

2. 设置合并的三条不对称规则。 value 旧压新、options 新压旧、recommended 旧压新 (core/src/browser/extension.ts:127-150)。三个字段三种语义,不是懒得统一, 是每个字段的权威来源不同:值属于用户,选项属于运行时探测,推荐值属于硬件检测。

3. 打包表 + 惰性 import,保住旧启动顺序。 扩展改为随 app 打包后,bundled-extensions.tsENTRIES 记的是 load: () => import(...) 而不是已经 import 好的模块——注释明说这是刻意的: 扩展包不进 service-hub 的 bootstrap 依赖图,hub 就绪后才按需加载,与旧版 "先装 hub 后装扩展"的顺序完全一致。

4. 平台排除用编译期死代码消除,而不是运行时 if。 MLX 扩展靠构建常量 IS_MACOS 决定是否进 ENTRIES(bundled-extensions.ts:85-95): 非 macOS 平台连 import() 都不进 bundle 图,而不是打进去再在运行时跳过。 Rust 侧插件 API 包在 Windows/Linux 构建脚本里同样 --exclude(package.json:49-50)。

5. 三重容错保证 UI 一定能渲染。 主窗口闸门 + Promise.allSettled + 20 秒看门狗 (ExtensionProvider.tsx:39-42lib/extension.ts:144ExtensionProvider.tsx:63-66)。 可插拔架构的代价就是第三方代码能挂死宿主,这三道是必需的。

6. 编译期常量 + 运行时探测的双重平台判定。 isPlatformTauri()(lib/platform/utils.ts:17-28)不只信 define 注入的常量, 还摸一次 window.__TAURI__。这解决的是"带着 Tauri 标志构建、却用浏览器打开"的开发场景。


10. 边界与局限

每种扩展类型只能有一个活跃实现。 get(type).find() 取第一个 (web-app/src/lib/extension.ts:107),装第二个同类型扩展会被静默忽略,没有冲突提示。

扩展设置存在 localStorage,不是文件。 换 WebView origin、清浏览器数据都会丢。 llamacpp 扩展为此专门写了 migrateLocalStorageToFile()(extensions/llamacpp-extension/src/index.ts:462) 把自己的配置搬去文件——说明这个存储选择在实践中撞了墙。

OAI 引擎三层目前是空 SDK。 OAIEngine / LocalOAIEngine / RemoteOAIEngine 没有任何非测试的子类,内置扩展全部直接继承 AIEngine。用它们写第三方扩展等于走未验证的路。

EngineManager.instance() 的软单例可能悄悄分叉。 window.core?.engineManager 拿不到时会 new 一个新的(EngineManager.ts:31), 注册进去的引擎谁也查不到,而且不报错。

扩展无沙箱。 扩展现在随 app 打包:getBundledExtensions 枚举时 import() 自己的 dist 包并当场 new 出实例,activateExtension 看到 extensionInstance 直接注册 (web-app/src/lib/extension.ts:204-210);没有预建实例时才回退到 import() 磁盘 js 的老路径(:214)。扩展代码与宿主同权限、共享 window.core;本 commit 下 没有签名校验、没有权限声明。

运行期不可装扩展。 桌面端的 installExtensions/uninstallExtension 也是空操作 (services/core/tauri.ts:36-46),能用的扩展在编译期就定死在打包表里;移动端进一步 只挑带 mobile 标记的子集(services/core/mobile.ts:13-26),目前只有 conversational 一个。

事件总线极简。 EventEmitter 没有 once、没有通配符、handler 抛异常会中断后续 handler (services/events/EventEmitter.ts:36-45 是裸 forEach)。


11. 代码地图

主题文件路径符号名
根 workspace 与构建脚本package.jsonworkspaces, build:extensions, build:tauri, copy:assets:tauri
扩展 workspace 挂根package.jsonworkspaces.packages: ["core","web-app","extensions/*"]
单个扩展的打包发布extensions/llamacpp-extension/package.jsonbuild:publish
扩展打包表与惰性加载web-app/src/services/core/bundled-extensions.tsENTRIES, getBundledExtensions
桌面端扩展枚举(空安装)web-app/src/services/core/tauri.tsTauriCoreService.getActiveExtensions
扩展基类与设置系统core/src/browser/extension.tsBaseExtension, ExtensionTypeEnum, registerSettings, getSetting, updateSettings, onSettingUpdate
扩展抽象接口core/src/browser/extensions/AssistantExtension, ConversationalExtension, MCPExtension, RAGExtension, VectorDBExtension, InferenceExtension
引擎基类core/src/browser/extensions/engines/AIEngine.tsAIEngine, registerEngine, chatCompletionRequest, SessionInfo
引擎继承链core/src/browser/extensions/engines/OAIEngine, LocalOAIEngine, RemoteOAIEngine
引擎注册表core/src/browser/extensions/engines/EngineManager.tsEngineManager, register, instance
前后端桥(扩展侧)core/src/browser/core.tsgetJanDataFolderPath, joinPath, openExternalUrl
事件总线接口core/src/browser/events.tsevents, on, off, emit
事件总线实现web-app/src/services/events/EventEmitter.tsEventEmitter
运行时扩展注册中心web-app/src/lib/extension.tsExtensionManager, register, get, getEngine, activateExtension, registerActive
扩展装载的 React 入口web-app/src/providers/ExtensionProvider.tsxExtensionProvider, isMainWindow, setupExtensions
路由名 → Tauri 命令名web-app/src/lib/service.tsAPIs, Routes, AppRoutes
路由名单来源core/src/types/api/index.tsCoreRoutes, APIRoutes, APIEvents
平台服务中枢web-app/src/services/index.tsServiceHub, PlatformServiceHub, initializeServiceHub
服务中枢的取用web-app/src/hooks/useServiceHub.tsuseServiceHub, getServiceHub, initializeServiceHubStore
平台判定web-app/src/lib/platform/utils.tsisPlatformTauri, isPlatformIOS, isPlatformAndroid, getCurrentPlatform
Tauri / 移动端 core 服务web-app/src/services/core/DefaultCoreService, TauriCoreService, MobileCoreService
Rust 命令注册与插件链src-tauri/src/lib.rsinvoke_commands_with_extras, run, handle_graceful_exit
编译期平台常量web-app/vite.config.tsdefine.IS_WEB_APP, define.IS_IOS, define.IS_ANDROID

下一步: 骨架搭完了,接着看血液怎么流——一次对话从输入框到屏幕上的字, 经过哪些部件,见 02 章:一次对话怎么跑完