Skip to content

Plugin Development zh

Dev edited this page Sep 7, 2026 · 3 revisions

English · 中文

写一个 nanopi 插件

对应 v0.12.0(3a762b1)。本页的构建命令都在 Linux + wasmtime 19 上实跑过。 接口定义以仓库 wit/nanopi-extension.wit 为准;若与本页不符,以仓库为准。


心智模型

插件是一个独立的 WASM component,不是链接 nanopi 的 crate。它不 use nanopi::...,唯一的依赖通常只有 serde_json。

宿主和插件的全部约定就是 wit/nanopi-extension.wit,它声明了三个 world:

world extension {
    import host-log:               func(level: u8, message: string);
    import host-fs-read:           func(path: string) -> string;
    import host-http-get:          func(url: string) -> string;
    import host-store-get:         func(key: string) -> string;
    import host-store-set:         func(key: string, value: string) -> string;
    import host-notify:            func(text: string) -> string;
    import host-set-context:       func(text: string) -> string;
    import host-call-tool:         func(name: string, args-json: string) -> string;
    import host-send-user-message: func(text: string) -> string;

    export list-tools:   func() -> string;
    export execute-tool: func(name: string, args-json: string) -> string;
}

world extension-commands {
    include extension;
    export list-commands:   func() -> string;
    export execute-command: func(name: string, args: string) -> string;
}

world extension-events {
    include extension-commands;
    export list-events:  func() -> string;
    export handle-event: func(event: string, payload-json: string) -> string;
}
  • list-tools 在加载时调用一次,返回工具的 JSON 数组(含 JSON Schema)。不导出它的 component 会被判定为"不是 nanopi extension"。
  • execute-tool 每次工具调用一次,返回 {"content": "...", "is_error": false}。
  • 除 host-log 和 host-notify 之外的每个导入,如果用户没在该插件的 [[extensions]] 条目里授权,调用一律被拒。拒绝是带内的 error: 前缀字符串,不是 trap。完整语义见 Plugin-Capabilities-zh。

选一个 world,选够用的那个

三个 world 是一条线性阶梯,不是格:

extension  ⊂  extension-commands  ⊂  extension-events

WIT 表达不了"可选导出"。 只要 world 声明了某个导出而 module 没实现,wasm-tools component new 就失败——所以选了更高的 world,就必须把下面各层的导出全部实现,哪怕你根本不用。一个只想收事件的插件得把 list-commands 写成返回 "[]",execute-command 写成返回一个 error;两行 stub,这是不想"今天四个 world、下个特性八个"所付的代价。

宿主比工具链宽松:list-commands、execute-command 和 list-events 在宿主侧都是可选解析的,所以按旧 world 编译的插件照样能加载。逼你写 stub 的是 guest 侧的构建。(一个例外:如果 list-events 报了事件而没有 handle-event,那是硬性加载失败——既然申请了投递,就没有合理的兜底。)

导入是反方向的,根本不需要新 world。 九个导入全在 world extension 上、向上继承;按旧 WIT 编译的 guest 压根不引用新导入。这就是 v0.11 时期的插件二进制今天照样能加载的原因。

生命周期事件只能观察。 handle-event 的返回值被丢弃——插件拦不住工具调用,也改不了 prompt,而且这是写在类型里的,不是运行时检查。要否决什么,那是 生命周期 hooks:由用户自己配置的 shell 机制。插件侧细节见 插件事件订阅。


步骤 1:建项目

mkdir -p my-plugin/src && cd my-plugin

Cargo.toml——关键是 crate-type = ["cdylib"],这是 wasm32 目标产出 .wasm 的前提:

[package]
name = "my-plugin"
version = "0.1.0"
edition = "2021"
publish = false

[lib]
crate-type = ["cdylib"]

[dependencies]
serde_json = { version = "1.0", default-features = false, features = ["alloc"] }

[profile.release]
opt-level = "z"
lto = true
codegen-units = 1
strip = true
panic = "abort"

步骤 2:src/lib.rs

下面是完整可编译的模板。BOILERPLATE 段原样抄,YOUR TOOLS 段是你的。

#![no_std]

extern crate alloc;

use alloc::format;
use alloc::string::{String, ToString};
use core::alloc::{GlobalAlloc, Layout};

// ═══ BOILERPLATE — 原样抄 ═══════════════════════════════════════════

// no_std 下 wasm32 不链接 libc,但 rustc 仍会 emit memcmp(字符串比较用)。
#[no_mangle]
pub unsafe extern "C" fn memcmp(a: *const u8, b: *const u8, n: usize) -> i32 {
    for i in 0..n {
        let (x, y) = (*a.add(i), *b.add(i));
        if x != y {
            return x as i32 - y as i32;
        }
    }
    0
}

// no_std 仍需要堆给 String/Vec。wasm32 单线程,bump 分配器足够且模块小。
const ARENA_SIZE: usize = 1 << 20; // 1 MiB
static mut ARENA: [u8; ARENA_SIZE] = [0; ARENA_SIZE];
static mut OFFSET: usize = 0;

struct BumpAlloc;

unsafe impl GlobalAlloc for BumpAlloc {
    unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
        let align = layout.align();
        let start = (OFFSET + align - 1) & !(align - 1);
        let end = start + layout.size();
        if end > ARENA_SIZE {
            return core::ptr::null_mut();
        }
        OFFSET = end;
        core::ptr::addr_of_mut!(ARENA).cast::<u8>().add(start)
    }
    unsafe fn dealloc(&self, _ptr: *mut u8, _layout: Layout) {}
}

#[global_allocator]
static ALLOC: BumpAlloc = BumpAlloc;

// 宿主导入。注意 ret_area 是尾部出参 —— 见下方「ABI 陷阱」。
// 只声明你真的会调的;九个导入里剩下的见下方「其余宿主导入」。
#[link(wasm_import_module = "$root")]
extern "C" {
    #[link_name = "host-log"]
    fn host_log_raw(level: u32, ptr: *const u8, len: usize);
    #[link_name = "host-fs-read"]
    fn host_fs_read_raw(ptr: *const u8, len: usize, ret_area: *mut u8);
    #[link_name = "host-http-get"]
    fn host_http_get_raw(ptr: *const u8, len: usize, ret_area: *mut u8);
}

unsafe fn host_log(level: u32, msg: &str) {
    host_log_raw(level, msg.as_ptr(), msg.len());
}

unsafe fn call_host_str(
    f: unsafe extern "C" fn(*const u8, usize, *mut u8),
    arg: &str,
) -> String {
    let ret_area = ALLOC.alloc(Layout::from_size_align_unchecked(8, 4));
    f(arg.as_ptr(), arg.len(), ret_area);
    let ptr = ret_area.cast::<u32>().read() as *const u8;
    let len = ret_area.cast::<u32>().add(1).read() as usize;
    read_string(ptr, len)
}

#[allow(dead_code)]
unsafe fn host_fs_read(path: &str) -> String {
    call_host_str(host_fs_read_raw, path)
}

unsafe fn host_http_get(url: &str) -> String {
    call_host_str(host_http_get_raw, url)
}

#[panic_handler]
fn panic(_: &core::panic::PanicInfo) -> ! {
    core::arch::wasm32::unreachable()
}

// 宿主往我们内存里塞字符串时调用。canonical ABI 要求。
#[no_mangle]
pub unsafe extern "C" fn cabi_realloc(
    _old_ptr: *mut u8,
    _old_len: usize,
    align: usize,
    new_len: usize,
) -> *mut u8 {
    if new_len == 0 {
        return align as *mut u8;
    }
    ALLOC.alloc(Layout::from_size_align_unchecked(new_len, align))
}

// 导出返回字符串的约定:返回指向 (ptr,len) 对的指针。
unsafe fn string_result(s: String) -> *mut u8 {
    let bytes = s.into_bytes();
    let len = bytes.len();
    let ptr = if len == 0 {
        1 as *mut u8 // 0 长度用非空悬垂指针即可
    } else {
        let p = ALLOC.alloc(Layout::from_size_align_unchecked(len, 1));
        core::ptr::copy_nonoverlapping(bytes.as_ptr(), p, len);
        p
    };
    core::mem::forget(bytes);
    let ret_area = ALLOC.alloc(Layout::from_size_align_unchecked(8, 4));
    ret_area.cast::<u32>().write(ptr as u32);
    ret_area.cast::<u32>().add(1).write(len as u32);
    ret_area
}

unsafe fn read_string(ptr: *const u8, len: usize) -> String {
    let slice = core::slice::from_raw_parts(ptr, len);
    String::from_utf8_unchecked(slice.to_vec()) // WIT string 保证是合法 UTF-8
}

fn ok(content: String) -> String {
    serde_json::json!({ "content": content, "is_error": false }).to_string()
}

fn err(content: String) -> String {
    serde_json::json!({ "content": content, "is_error": true }).to_string()
}

// 导出名必须是 WIT 原名(带连字符),Rust 标识符写不出,故用 export_name。
#[export_name = "list-tools"]
pub unsafe extern "C" fn list_tools() -> *mut u8 {
    string_result(TOOL_SPECS.to_string())
}

#[export_name = "execute-tool"]
pub unsafe extern "C" fn execute_tool(
    name_ptr: *const u8,
    name_len: usize,
    args_ptr: *const u8,
    args_len: usize,
) -> *mut u8 {
    // 先读入参再分配别的:入参就住在 arena 里。
    let name = read_string(name_ptr, name_len);
    let args_json = read_string(args_ptr, args_len);
    string_result(dispatch(&name, &args_json))
}

// ═══ YOUR TOOLS — 以下是你的部分 ═══════════════════════════════════

const TOOL_SPECS: &str = r#"[
  {
    "name": "greet",
    "description": "Greet someone by name. Use when the user asks for a greeting.",
    "parameters": {
      "type": "object",
      "properties": {
        "name": { "type": "string", "description": "Who to greet." }
      },
      "required": ["name"]
    }
  },
  {
    "name": "fetch_head",
    "description": "Fetch a URL and return the first 200 characters of the response body. Requires allow_network = true and the host in url_allowlist on this plugin's [[extensions]] entry.",
    "parameters": {
      "type": "object",
      "properties": {
        "url": { "type": "string", "description": "The http/https URL to fetch." }
      },
      "required": ["url"]
    }
  }
]"#;

fn dispatch(name: &str, args_json: &str) -> String {
    let args: serde_json::Value = match serde_json::from_str(args_json) {
        Ok(v) => v,
        Err(e) => return err(format!("bad arguments JSON: {e}")),
    };

    match name {
        "greet" => {
            let who = match args.get("name").and_then(|v| v.as_str()) {
                Some(s) => s,
                None => return err("missing required argument: name".to_string()),
            };
            ok(format!("Hello, {who}!"))
        }

        "fetch_head" => {
            let url = match args.get("url").and_then(|v| v.as_str()) {
                Some(s) => s,
                None => return err("missing required argument: url".to_string()),
            };
            unsafe { host_log(1, "fetch_head: calling host-http-get") };
            let body = unsafe { host_http_get(url) };

            // 宿主用 `error: ` 前缀在带内返回拒绝/失败,透传给模型。
            if body.starts_with("error: ") {
                return err(body);
            }
            let head: String = body.chars().take(200).collect();
            ok(format!("{} bytes; first 200 chars:\n{head}", body.len()))
        }

        other => err(format!("unknown tool: {other}")),
    }
}

ABI 陷阱(唯一真正难的地方)

import 和 export 返回字符串的方式不对称:

方向 约定
import 返回 string 尾部多一个 ret_area: *mut u8 出参
export 返回 string 返回一个指向 (ptr, len) 对的指针

搞反了不会在运行时报错,而是在 wasm-tools component new 阶段报类型不匹配。看到那个错先回来查这里。

第二个坑:导出名必须是 WIT 里的带连字符原名。wasm-tools component embed 找的是 list-tools,不是 list_tools,而 Rust 标识符不能带连字符——所以用 #[export_name = "list-tools"],不是 #[no_mangle]。


其余宿主导入

上面的模板只声明了九个里的三个。剩下六个就两种形状——一个字符串进、或两个字符串进,都是一个字符串出:

#[link(wasm_import_module = "$root")]
extern "C" {
    #[link_name = "host-store-get"]
    fn host_store_get_raw(ptr: *const u8, len: usize, ret_area: *mut u8);
    #[link_name = "host-notify"]
    fn host_notify_raw(ptr: *const u8, len: usize, ret_area: *mut u8);
    #[link_name = "host-set-context"]
    fn host_set_context_raw(ptr: *const u8, len: usize, ret_area: *mut u8);
    #[link_name = "host-send-user-message"]
    fn host_send_user_message_raw(ptr: *const u8, len: usize, ret_area: *mut u8);

    // 两个 string 参数会摊平成两组 (ptr, len),再跟 ret_area。
    #[link_name = "host-store-set"]
    fn host_store_set_raw(
        k_ptr: *const u8, k_len: usize,
        v_ptr: *const u8, v_len: usize,
        ret_area: *mut u8,
    );
    #[link_name = "host-call-tool"]
    fn host_call_tool_raw(
        n_ptr: *const u8, n_len: usize,
        a_ptr: *const u8, a_len: usize,
        ret_area: *mut u8,
    );
}

unsafe fn call_host_str2(
    f: unsafe extern "C" fn(*const u8, usize, *const u8, usize, *mut u8),
    a: &str,
    b: &str,
) -> String {
    let ret_area = ALLOC.alloc(Layout::from_size_align_unchecked(8, 4));
    f(a.as_ptr(), a.len(), b.as_ptr(), b.len(), ret_area);
    let ptr = ret_area.cast::<u32>().read() as *const u8;
    let len = ret_area.cast::<u32>().add(1).read() as usize;
    read_string(ptr, len)
}

这些函数返回的要么是有用的字符串,要么是 error: 前缀的字符串——没授权时的拒绝也走这条路。所以永远先看前缀,别默认成功。唯一值得记住的例外是 host-store-get:key 不存在时读到的是 "",不是 error。

签名写错的话,你会在 wasm-tools component new 这一步发现,而不是运行时——和上面的 ABI 陷阱一样。

选高层 world 时要写的 stub

选了 extension-commands 或 extension-events,下面各层的导出都必须存在。如果你不注册任何命令:

#[export_name = "list-commands"]
pub unsafe extern "C" fn list_commands() -> *mut u8 {
    string_result("[]".to_string())
}

#[export_name = "execute-command"]
pub unsafe extern "C" fn execute_command(
    _n_ptr: *const u8, _n_len: usize,
    _a_ptr: *const u8, _a_len: usize,
) -> *mut u8 {
    string_result(r#"{"error":"this plugin registers no commands"}"#.to_string())
}

execute-command 的返回是单键 action 对象,三者之一:{"print": …}、{"send_user_message": …}、{"error": …}。


步骤 3:构建(三步,缺一不可)

必须从 nanopi 仓库根目录跑,因为 wit/ 是仓库相对路径:

cd /path/to/nanopi

# 1. 编译成 wasm module
cargo build --manifest-path /path/to/my-plugin/Cargo.toml \
  --target wasm32-wasip1 --release

# 2. 把 WIT world 嵌进去 —— 不能跳过
wasm-tools component embed wit/ \
  /path/to/my-plugin/target/wasm32-wasip1/release/my_plugin.wasm \
  -o /tmp/embedded.wasm --world extension
#    ... 也可以是 --world extension-commands / --world extension-events,
#    但那样下面各层的导出必须全部存在(见上文)

# 3. module → component
wasm-tools component new /tmp/embedded.wasm \
  -o my-plugin.component.wasm

第 2 步跳过的后果:component new 会产出一个 world 为空的 component,宿主找不到 list-tools,报"不是 nanopi extension"。这是最常见的失败。

不要 cd 进插件目录再构建。 embed wit/ 解析的是仓库根的 wit/,混着来会静默嵌入一个过时的 module——能构建、能加载,但缺你刚加的 import,而且没有任何错误指向原因。

验证 world

wasm-tools component wit my-plugin.component.wasm
world root {
  import host-log: func(level: u8, message: string);
  import host-http-get: func(url: string) -> string;
  export list-tools: func() -> string;
  export execute-tool: func(name: string, args-json: string) -> string;
}

注意上面没有 host-fs-read——模板里声明了但没实际调用,LTO 把它消掉了。只有真正用到的 import 才会进 world,这是预期行为,不是 bug。

依赖下载失败时

在 GFW 后面且没配 cargo 镜像时,新项目解析 crates.io index 会超时。两个办法:

# A. 复用本地缓存(nanopi 已经依赖 serde_json)
cargo build ... --offline

# B. 配镜像,一劳永逸 —— ~/.cargo/config.toml
[source.crates-io]
replace-with = 'rsproxy-sparse'
[source.rsproxy-sparse]
registry = "sparse+https://rsproxy.cn/index/"

wasm32-wasip1 目标若 rustup target add 失败(部分镜像不带这个组件),检查它是否已在 sysroot:

ls "$(rustc --print sysroot)/lib/rustlib/" | grep wasm

存在就能直接 --target wasm32-wasip1 构建,即使 rustup target list --installed 不显示它。


步骤 4:装到 nanopi

# ~/.nanopi/config.toml 或 <项目>/.nanopi/config.toml
[[extensions]]
path = "/绝对路径/my-plugin.component.wasm"
allow_network = true
url_allowlist = ["127.0.0.1", "api.github.com"]

用绝对路径。 path 里的 ~ 展开的是 $HOME,不认 NANOPI_HOME——设了 NANOPI_HOME 却写 ~/... 会解析到 $HOME 下,得到 skipping extension (not found)。

启动后看到这个就说明接上了:

nanopi: registered extension tool "greet"
nanopi: registered extension tool "fetch_head"

看不到?按顺序排查:宿主是否 --features wasm 构建 → 路径是否绝对且存在 → wasm-tools component wit 是否显示 list-tools。

配置的层级和合并规则见 Configuration-zh,能力门见 Plugin-Capabilities-zh。


写工具的经验

description 就是 prompt。 模型靠这段文字决定要不要调你的工具,写含糊了工具就永远不被调用。值得像写 API 文档一样认真写,并在描述里点明前置条件(比如"需要 allow_network"),这样模型被拒绝时能理解原因而不是反复重试。

能力门的错误要透传,别吞掉。 宿主用 error: 前缀在带内返回拒绝:

if body.starts_with("error: ") {
    return err(body);   // 让模型知道是权限问题,而不是"失败了"
}

别在 guest 里 panic。 panic 会 trap,宿主只能报给模型"工具调用失败",不带任何可操作信息。用 is_error: true + 一句人话。

arena 是 1 MiB 且不回收。 execute-tool 开头不能 reset arena(入参就住在里面),所以单次调用的总分配要留在 1 MiB 内。宿主侧 host-http-get 的响应体上限正是按这个尺寸设的。


下一步

Clone this wiki locally