Jan Assistant 扩展开发指南:用 TypeScript 构建、打包并测试你自己的 Jan 扩展
本文以 Jan 仓库中的 assistant-extension 模板 为核心,结合 core 包中的扩展基类、扩展源码 与 单元测试,完整讲解如何用 TypeScript 创建、打包、安装和演进一个 Jan 扩展:从 package.json 元数据定义、rolldown 构建产物到 file:// 虚拟文件系统中的助手数据迁移机制,读者可据此独立开发自己的 Jan 扩展。
一、模板定位:assistant-extension 在 Jan 仓库中的角色
Jan 是运行在本地的开源 AI 聊天应用,其功能通过“扩展(Extension)”机制进行模块化拆分:助手管理、对话编排、推理后端、模型下载、RAG、向量库等能力都实现为独立扩展,由 @janhq/core 包提供统一的事件、文件系统和类型系统。
extensions/assistant-extension 目录既是 Jan 内置的默认 AI 助手实现,也被官方 README 明确定位为一个可直接 fork 使用的扩展脚手架模板。README 开篇即说明:
Use this template to bootstrap the creation of a TypeScript Jan extension.
因此,围绕该目录学习,既能掌握“如何写一个新扩展”,又能顺带读懂 Jan 默认助手(默认系统提示词、默认采样参数、助手持久化与数据迁移)的完整实现。
二、创建你自己的扩展:模板使用与初始环境搭建
README 给出了标准的模板使用流程:
- 点击仓库顶部的 Use this template 按钮;
- 选择 Create a new repository;
- 为新的仓库选择 owner 与名称;
- 点击 Create repository;
- 克隆你的新仓库到本地。
环境要求
模板 README 明确要求一个较新的 Node.js 环境:20.x 或更高版本;如果在使用 nodenv / nvm 这类版本管理器,可以在仓库根目录按 package.json 中指定的版本安装对应 Node。
需要说明的一个仓库事实是:当前 package.json 中声明了 "packageManager": "yarn@4.5.3",且依赖使用了 workspace:* 协议("@janhq/core": "workspace:*"),这意味着在 Jan monorepo 内部开发时应使用 Yarn 4 工作区;而在 fork 出的独立模板仓库中按 README 使用 npm install 同样可行(独立仓库中 @janhq/core 会解析为 npm 发布版本)。
依赖安装、打包与产物检查
README 描述的三步工作流,以及当前仓库中对应的真实脚本:
# 1. 安装依赖
npm install
# 2. 打包 TypeScript(README 中的命令;当前仓库脚本名为 build)
npm run bundle # 对应当前 package.json 中的 "build": "rolldown -c rolldown.config.mjs"
# 3. 检查产物:扩展目录中会出现 .tgz 文件
对照当前 package.json 的 scripts,可确认模板命令与仓库实际脚本的对应关系:
{
"build": "rolldown -c rolldown.config.mjs",
"build:publish": "rimraf *.tgz --glob || true && yarn build && npm pack && cpx *.tgz ../../pre-install",
"test": "vitest run"
}
即:build 完成 rolldown 打包;build:publish 在清理旧产物后执行 build,再用 npm pack 生成 .tgz 安装包并复制到仓库的 pre-install 目录(随 Jan 应用预装);test 运行 vitest 测试。README 中提到的 “tgz 产物”即由 npm pack 产生。
三、扩展元数据:package.json 字段逐项解读
README 指出:package.json 定义了扩展的名称、主入口、描述和版本等元数据,fork 模板后必须更新其中的 name 与 description。以当前模板文件为参照,各关键字段含义如下:
| 字段 | 当前值 | 作用 |
|---|---|---|
name |
@janhq/assistant-extension |
扩展包名,是 Jan 识别扩展的唯一标识 |
productName |
Jan Assistant |
展示在产品界面中的名称 |
version |
1.0.2 |
扩展版本号 |
main |
dist/index.js |
扩展主入口(打包产物路径) |
node |
dist/node/index.js |
节点侧入口(如存在独立后端逻辑) |
author / license |
Jan <service@jan.ai> / AGPL-3.0 |
作者与协议信息 |
dependencies |
@janhq/core |
唯一运行时依赖:Jan 扩展核心包 |
files |
dist/*, package.json, README.md |
发布进 .tgz 包的文件白名单 |
installConfig.hoistingLimits |
workspaces |
在 monorepo 中避免依赖被提升(hoisting)到工作区外 |
这些字段并非摆设:rolldown 构建配置会直接读取它们(见下一节),core 包的 BaseExtension 构造参数 name / productName / url / active / description / version(见 extension.ts)也与元数据一一对应。
四、构建管线:从 src/index.ts 到 dist/index.js
rolldown.config.mjs 完整展示了扩展的打包逻辑,值得逐行理解:
import { defineConfig } from 'rolldown'
import pkgJson from './package.json' with { type: 'json' }
export default defineConfig([
{
input: 'src/index.ts',
output: {
format: 'esm',
file: 'dist/index.js', // 即 package.json 中的 main 字段
},
platform: 'browser',
define: {
NODE: JSON.stringify(`${pkgJson.name}/${pkgJson.node}`),
VERSION: JSON.stringify(pkgJson.version),
},
}
])
要点:
- 入口是
src/index.ts,输出为 ESM 格式单文件dist/index.js,正好落在package.json的main声明位置; platform: 'browser'表明扩展代码运行在浏览器/前端运行时环境中;define在编译期注入了两个全局常量NODE与VERSION,其值来自package.json的name/node/version字段。这与 src/@types/global.d.ts 中的声明相呼应:
declare const NODE: string
declare const VERSION: string
也就是说,扩展代码在运行时可以直接读取自身的包名与版本号,无需额外配置。
tsconfig.json 则规定了编译口径:target: es2016、module: ES6、declaration: true(声明文件输出到 dist/types)、sourceMap: true,与 ESM 打包目标保持一致。
五、扩展代码骨架:继承 AssistantExtension 与生命周期
模板 README 对扩展代码有两点核心提示:大部分 Jan 扩展函数都是异步处理的,扩展函数会返回 Promise<any>;事件订阅的典型写法如下(摘自 README):
import { events, MessageEvent, MessageRequest } from '@janhq/core'
function onStart(): Promise<any> {
return events.on(MessageEvent.OnMessageSent, (data: MessageRequest) =>
this.inference(data)
)
}
在 core 包中,扩展体系以抽象类层次组织:
- BaseExtension:所有扩展的基类,定义了
name、url、active、description、version等属性,以及两个必须实现的生命周期钩子onLoad()/onUnload(),还提供了registerModels、registerSettings等通用能力; - AssistantExtension:助手类型扩展的抽象中间层,声明
type()返回ExtensionTypeEnum.Assistant,并要求实现三个抽象方法:
export abstract class AssistantExtension extends BaseExtension implements AssistantInterface {
type(): ExtensionTypeEnum | undefined {
return ExtensionTypeEnum.Assistant
}
abstract createAssistant(assistant: Assistant): Promise<void>
abstract deleteAssistant(assistant: Assistant): Promise<void>
abstract getAssistants(): Promise<Assistant[]>
}
Assistant 的数据形状定义在 core/src/types/assistant/assistantEntity.ts:包含 avatar、id、object、created_at、name、description、model、instructions、tools、file_ids、metadata 等字段,并配有逐字段注释,是编写助手相关扩展时最核心的类型契约。
模板 README 指向的 Jan Extension Core 模块文档,在本仓库中即 core/README.md。
六、默认助手实现解析:onLoad、持久化与种子数据
extensions/assistant-extension/src/index.ts 中的 JanAssistantExtension 是模板的参考实现,onLoad()(L17-L39)展示了扩展加载时应当完成的标准初始化序列:
async onLoad() {
if (!(await fs.existsSync('file://assistants'))) {
await fs.mkdir('file://assistants')
}
// Run migrations if needed
await this.runMigrations()
const assistants = await this.readAssistantsFromDisk()
if (assistants.length === 0) {
const assistantWithParams = {
...this.defaultAssistant,
parameters: {
temperature: 0.7,
top_k: 20,
top_p: 0.8,
repeat_penalty: 1.12,
},
}
await this.createAssistant(assistantWithParams as Assistant)
}
}
这里体现了 Jan 扩展编程模型的三个关键特征:
- 虚拟文件系统:一切持久化都通过
file://前缀路径进行(如file://assistants、file://assistants/<id>/assistant.json),由@janhq/core导出的fs模块统一抽象,屏蔽了不同平台的真实磁盘差异。这是一个写自定义扩展时必须记住的约定——不要直接使用 Node 的fs模块。 - 幂等初始化:先确保目录存在,再执行迁移,最后仅在磁盘为空时写入种子数据,避免覆盖用户已自定义的助手(对应测试用例 “does not overwrite an existing persisted assistant on load”)。
- 种子参数:默认助手
Jan(id: 'jan'、avatar: '👋'、model: '*'表示适配所有已安装模型)附带默认采样参数temperature: 0.7 / top_k: 20 / top_p: 0.8 / repeat_penalty: 1.12,其instructions是一段要求“按用户语言回复、逐步推理、作为专业工具调用者分析信息缺口”的系统提示词,并带有{{current_date}}日期占位符;tools中默认挂了一个 禁用状态的retrieval工具,附带top_k: 2、chunk_size: 1024、chunk_overlap: 64的 RAG 检索配置与检索提示词模板(L333-L351)。
CRUD 方法本身也非常短小,是“最小可运行扩展”的范例:
createAssistant(L281-L292):确保file://assistants/<id>/目录存在后,把助手序列化为缩进 JSON 写入assistant.json;deleteAssistant(L294-L303):存在即删除assistant.json,不存在则为空操作(no-op);getAssistants(L275-L279):优先读取磁盘数据;磁盘为空时回退到内置的defaultAssistant,保证上层调用总能拿到至少一个可用助手。私有方法readAssistantsFromDisk还会跳过缺少assistant.json的目录以及 JSON 解析失败的损坏文件,只记录错误而不中断整体加载。
七、数据迁移机制:版本化 .migration_version 与三级迁移
JanAssistantExtension 内置了一套值得借鉴的轻量数据迁移方案(L41-L95):
- 迁移版本记录在
file://assistants/.migration_version文件中,当前版本常量CURRENT_MIGRATION_VERSION = 3; getCurrentMigrationVersion()读取该文件,文件缺失或内容无法解析(parseInt得到NaN)时一律按 版本 0 处理,从而保证迁移一定会补跑;runMigrations()按currentVersion < N的条件逐档执行迁移,每完成一档立即写回版本号:
| 版本 | 迁移内容 |
|---|---|
| v1 | 将旧版指令前缀 You are a helpful AI assistant. 改写为 You are Jan, a helpful AI assistant.,并保留后续自定义内容(用 startsWith + 字符串截取实现) |
| v2 | 将旧前缀助手整体改写为新版默认指令(含工具调用分析流程、{{current_date}} 占位符),并补齐默认采样参数 |
| v3 | 仅当助手指令与 v2 写入的默认文本逐字完全一致时,剥离身份前缀段落,恢复为纯默认指令;用户自定义提示词不受影响 |
迁移逻辑刻意保守:每一档都先做字符串精确匹配再改写,且失败时只 logger.error 而不抛出,确保单个助手损坏不会阻塞整个扩展启动。这种“版本号文件 + 条件式补跑 + 精确匹配保护用户数据”的模式,可直接移植到任何需要持久化状态演进的 Jan 扩展中。
八、测试实践:用内存文件系统验证扩展逻辑
模板自带 src/index.test.ts,展示了官方推荐的扩展测试方式:用 vitest 对 @janhq/core 的 fs 进行 mock,以两个内存容器模拟虚拟文件系统——
let files: Map<string, string> // 路径 -> 文件内容
let dirs: Set<string> // 已存在的目录
existsSync / mkdir / writeFileSync / readFileSync / rm / readdirSync 全部落到 Map/Set 上(L12-L45),使得测试完全不依赖真实磁盘。测试覆盖的断言点恰好对应第六、七节的所有行为:
getAssistants:目录不存在、目录为空时均回退默认助手(id: 'jan');能并行读取多个助手;跳过无assistant.json的孤儿目录与 JSON 损坏的条目;createAssistant:自动建目录并写入格式化 JSON(断言输出含\n缩进);目录已存在时不再调用mkdir;deleteAssistant:存在时删除文件,不存在时fs.rm根本不被调用;onLoad:自动创建file://assistants目录、写入迁移版本'3'、种子助手携带正确的默认参数、且不覆盖已持久化的自定义助手;- 迁移:v1 精确替换前缀且保留尾部自定义文本(
'You are a helpful AI assistant. Be concise.'→'You are Jan, a helpful AI assistant. Be concise.');v2 写入参数、v3 剥掉身份前缀;已经是版本 3 时不重复执行迁移;版本文件内容为'garbage'时按 0 处理并补跑。
运行方式即 npm test(对应 "test": "vitest run"),测试环境由 vitest.config.ts 与 src/test/setup.ts 配置。
九、动手清单:从模板到自定义扩展
综合 README 与源码,把模板改造为自己的扩展可以按以下清单执行:
- 改名与元数据:更新 package.json 的
name、productName、description、author,并提升version; - 替换源码:
src/是扩展的心脏,README 明确允许整体替换。自定义扩展类应继承 core 中与你目标能力对应的抽象基类(如AssistantExtension),实现其全部抽象方法,并在onLoad()中完成初始化、onUnload()中做清理; - 遵循异步约定:所有扩展函数按异步风格编写,返回
Promise;需要响应消息等应用事件时,使用@janhq/core的events.on(...)订阅; - 持久化走 file:// 协议:使用 core 导出的
fs与joinPath管理数据目录,避免直接操作宿主磁盘; - 构建与验证:执行
npm install后运行build(即 README 所述的打包步骤),确认dist/index.js生成;用npm pack(或仓库内的build:publish)生成.tgz产物; - 回归测试:参照 src/index.test.ts 的内存 fs mock 手法为你的存储与迁移逻辑补测试,再执行
npm test。
以上流程均以当前仓库的实际文件为准:模板文档见 extensions/assistant-extension/README.md,构建与类型配置见 rolldown.config.mjs、tsconfig.json,扩展契约见 core/src/browser/extension.ts 与 core/src/browser/extensions/assistant.ts,助手类型契约见 core/src/types/assistant/assistantEntity.ts。掌握这套“元数据 + 打包 + 生命周期 + 虚拟文件系统持久化 + 版本化迁移”的组合模式,即可在 Jan 生态中开发并分发自己的功能扩展。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00