react-native-nobodywho 0.1.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/ios/Nobodywho.h CHANGED
@@ -4,7 +4,7 @@
4
4
  #endif
5
5
 
6
6
  #ifdef RCT_NEW_ARCH_ENABLED
7
- #import "RNNativeModuleSpec.h"
7
+ #import "NobodywhoSpec.h"
8
8
 
9
9
  @interface Nobodywho : NSObject <NativeNobodywhoSpec>
10
10
  #else
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-nobodywho",
3
- "version": "0.1.2",
3
+ "version": "0.3.0",
4
4
  "description": "Run LLMs locally with offline inference — React Native bindings",
5
5
  "main": "src/wrapper.ts",
6
6
  "types": "src/wrapper.ts",
@@ -47,5 +47,10 @@
47
47
  },
48
48
  "dependencies": {
49
49
  "uniffi-bindgen-react-native": "0.30.0-1"
50
+ },
51
+ "devDependencies": {
52
+ "@types/jest": "^30.0.0",
53
+ "jest": "^30.3.0",
54
+ "ts-jest": "^29.4.9"
50
55
  }
51
56
  }
package/src/chat.ts CHANGED
@@ -1,10 +1,9 @@
1
1
  import {
2
2
  RustChat,
3
- loadModel,
4
- type ModelInterface,
5
- type SamplerConfigInterface,
6
- type Message,
3
+ SamplerConfig,
7
4
  } from "../generated/ts/nobodywho";
5
+ import { Model } from "./model";
6
+ import { type ChatMessage, fromInternal, toInternal } from "./message";
8
7
  import { TokenStream } from "./streaming";
9
8
  import type { Prompt } from "./prompt";
10
9
  import type { Tool } from "./tool";
@@ -17,7 +16,7 @@ import type { Tool } from "./tool";
17
16
  *
18
17
  * @example
19
18
  * ```typescript
20
- * const model = await loadModel("model.gguf", true);
19
+ * const model = await Model.load({ modelPath: "model.gguf" });
21
20
  * const chat = new Chat({
22
21
  * model,
23
22
  * systemPrompt: "You are a helpful assistant.",
@@ -32,15 +31,15 @@ export class Chat {
32
31
  private readonly _inner: RustChat;
33
32
 
34
33
  constructor(opts: {
35
- model: ModelInterface;
34
+ model: Model;
36
35
  systemPrompt?: string;
37
36
  contextSize?: number;
38
37
  templateVariables?: Map<string, boolean>;
39
38
  tools?: Tool[];
40
- sampler?: SamplerConfigInterface;
39
+ sampler?: SamplerConfig;
41
40
  }) {
42
41
  this._inner = new RustChat(
43
- opts.model,
42
+ opts.model._inner,
44
43
  opts.systemPrompt ?? undefined,
45
44
  opts.contextSize ?? 4096,
46
45
  opts.templateVariables ?? undefined,
@@ -64,18 +63,18 @@ export class Chat {
64
63
  static async fromPath(opts: {
65
64
  modelPath: string;
66
65
  useGpu?: boolean;
67
- imageModelPath?: string;
66
+ projectionModelPath?: string;
68
67
  systemPrompt?: string;
69
68
  contextSize?: number;
70
69
  templateVariables?: Map<string, boolean>;
71
70
  tools?: Tool[];
72
- sampler?: SamplerConfigInterface;
71
+ sampler?: SamplerConfig;
73
72
  }): Promise<Chat> {
74
- const model = await loadModel(
75
- opts.modelPath,
76
- opts.useGpu ?? true,
77
- opts.imageModelPath,
78
- );
73
+ const model = await Model.load({
74
+ modelPath: opts.modelPath,
75
+ useGpu: opts.useGpu,
76
+ projectionModelPath: opts.projectionModelPath,
77
+ });
79
78
  return new Chat({ model, ...opts });
80
79
  }
81
80
 
@@ -109,13 +108,14 @@ export class Chat {
109
108
  }
110
109
 
111
110
  /** Get the current chat history as a list of messages. */
112
- async getChatHistory(): Promise<Message[]> {
113
- return this._inner.getChatHistory();
111
+ async getChatHistory(): Promise<ChatMessage[]> {
112
+ const internal = await this._inner.getChatHistory();
113
+ return internal.map(fromInternal);
114
114
  }
115
115
 
116
116
  /** Set the chat history from a list of messages. */
117
- async setChatHistory(messages: Message[]): Promise<void> {
118
- return this._inner.setChatHistory(messages);
117
+ async setChatHistory(messages: ChatMessage[]): Promise<void> {
118
+ return this._inner.setChatHistory(messages.map(toInternal));
119
119
  }
120
120
 
121
121
  /** Get the current system prompt. */
@@ -144,7 +144,7 @@ export class Chat {
144
144
  }
145
145
 
146
146
  /** Set the sampler configuration. */
147
- async setSamplerConfig(sampler: SamplerConfigInterface): Promise<void> {
147
+ async setSamplerConfig(sampler: SamplerConfig): Promise<void> {
148
148
  return this._inner.setSamplerConfig(sampler);
149
149
  }
150
150
 
@@ -152,4 +152,12 @@ export class Chat {
152
152
  async getSamplerConfigJson(): Promise<string> {
153
153
  return this._inner.getSamplerConfigJson();
154
154
  }
155
+
156
+ /**
157
+ * Immediately free the underlying Rust resources (model context, KV cache, etc.).
158
+ * After calling this, the Chat instance is no longer usable.
159
+ */
160
+ destroy(): void {
161
+ this._inner.uniffiDestroy();
162
+ }
155
163
  }
@@ -0,0 +1,67 @@
1
+ import { RustCrossEncoder } from "../generated/ts/nobodywho";
2
+ import { Model } from "./model";
3
+
4
+ /**
5
+ * A cross-encoder for ranking documents by relevance to a query.
6
+ *
7
+ * Wraps the internal CrossEncoder with an ergonomic API including
8
+ * a `fromPath` factory for one-step model loading.
9
+ *
10
+ * @example
11
+ * ```typescript
12
+ * const crossEncoder = await CrossEncoder.fromPath({
13
+ * modelPath: "crossencoder.gguf",
14
+ * });
15
+ * const scores = await crossEncoder.rank("query", ["doc1", "doc2"]);
16
+ * const sorted = await crossEncoder.rankAndSort("query", ["doc1", "doc2"]);
17
+ * ```
18
+ */
19
+ export class CrossEncoder {
20
+ /** @internal */
21
+ private readonly _inner: RustCrossEncoder;
22
+
23
+ constructor(opts: { model: Model; contextSize?: number }) {
24
+ this._inner = new RustCrossEncoder(
25
+ opts.model._inner,
26
+ opts.contextSize ?? undefined,
27
+ );
28
+ }
29
+
30
+ /**
31
+ * Create a cross-encoder directly from a model path.
32
+ * Loads the model and creates the cross-encoder in one step.
33
+ */
34
+ static async fromPath(opts: {
35
+ modelPath: string;
36
+ useGpu?: boolean;
37
+ contextSize?: number;
38
+ }): Promise<CrossEncoder> {
39
+ const model = await Model.load({ modelPath: opts.modelPath, useGpu: opts.useGpu });
40
+ return new CrossEncoder({ model, ...opts });
41
+ }
42
+
43
+ /** Rank documents by relevance to a query. Returns similarity scores. */
44
+ async rank(query: string, documents: string[]): Promise<number[]> {
45
+ return this._inner.rank(query, documents);
46
+ }
47
+
48
+ /**
49
+ * Rank documents and return them sorted by descending relevance.
50
+ * Returns an array of [document, score] pairs.
51
+ */
52
+ async rankAndSort(
53
+ query: string,
54
+ documents: string[],
55
+ ): Promise<[string, number][]> {
56
+ const json = await this._inner.rankAndSortJson(query, documents);
57
+ return JSON.parse(json);
58
+ }
59
+
60
+ /**
61
+ * Immediately free the underlying Rust resources.
62
+ * After calling this, the CrossEncoder instance is no longer usable.
63
+ */
64
+ destroy(): void {
65
+ this._inner.uniffiDestroy();
66
+ }
67
+ }
package/src/encoder.ts ADDED
@@ -0,0 +1,51 @@
1
+ import { RustEncoder } from "../generated/ts/nobodywho";
2
+ import { Model } from "./model";
3
+
4
+ /**
5
+ * An embedding encoder for generating text embeddings.
6
+ *
7
+ * Wraps the internal Encoder with an ergonomic API including
8
+ * a `fromPath` factory for one-step model loading.
9
+ *
10
+ * @example
11
+ * ```typescript
12
+ * const encoder = await Encoder.fromPath({
13
+ * modelPath: "embeddings.gguf",
14
+ * });
15
+ * const embedding = await encoder.encode("Hello world");
16
+ * ```
17
+ */
18
+ export class Encoder {
19
+ /** @internal */
20
+ private readonly _inner: RustEncoder;
21
+
22
+ constructor(opts: { model: Model; contextSize?: number }) {
23
+ this._inner = new RustEncoder(opts.model._inner, opts.contextSize ?? undefined);
24
+ }
25
+
26
+ /**
27
+ * Create an encoder directly from a model path.
28
+ * Loads the model and creates the encoder in one step.
29
+ */
30
+ static async fromPath(opts: {
31
+ modelPath: string;
32
+ useGpu?: boolean;
33
+ contextSize?: number;
34
+ }): Promise<Encoder> {
35
+ const model = await Model.load({ modelPath: opts.modelPath, useGpu: opts.useGpu });
36
+ return new Encoder({ model, ...opts });
37
+ }
38
+
39
+ /** Encode text into an embedding vector. */
40
+ async encode(text: string): Promise<number[]> {
41
+ return this._inner.encode(text);
42
+ }
43
+
44
+ /**
45
+ * Immediately free the underlying Rust resources.
46
+ * After calling this, the Encoder instance is no longer usable.
47
+ */
48
+ destroy(): void {
49
+ this._inner.uniffiDestroy();
50
+ }
51
+ }
package/src/message.ts ADDED
@@ -0,0 +1,75 @@
1
+ import {
2
+ Message as InternalMessage,
3
+ Message_Tags,
4
+ Role,
5
+ type Asset,
6
+ type ToolCall,
7
+ } from "../generated/ts/nobodywho";
8
+
9
+ /**
10
+ * A chat message. The `role` field combined with the presence of
11
+ * `toolCalls` or `name` determines the message type:
12
+ *
13
+ * - **User/Assistant/System message:** `{ role, content, assets? }`
14
+ * - **Tool call request:** `{ role: Role.Assistant, content, toolCalls }`
15
+ * - **Tool response:** `{ role: Role.Tool, name, content }`
16
+ *
17
+ * @example
18
+ * ```typescript
19
+ * const history = await chat.getChatHistory();
20
+ * for (const msg of history) {
21
+ * if ("toolCalls" in msg) {
22
+ * console.log("Tool calls:", msg.toolCalls);
23
+ * } else if ("name" in msg) {
24
+ * console.log("Tool response:", msg.name, msg.content);
25
+ * } else {
26
+ * console.log(msg.role, msg.content);
27
+ * }
28
+ * }
29
+ * ```
30
+ */
31
+ export type ChatMessage =
32
+ | { role: Role.User | Role.Assistant | Role.System; content: string; assets?: Asset[] }
33
+ | { role: Role.Assistant; content: string; toolCalls: ToolCall[] }
34
+ | { role: Role.Tool; name: string; content: string };
35
+
36
+ /** @internal Convert internal Message to ChatMessage */
37
+ export function fromInternal(msg: InternalMessage): ChatMessage {
38
+ if (msg.tag === Message_Tags.Message) {
39
+ const { role, content, assets } = msg.inner;
40
+ return {
41
+ role: role as Role.User | Role.Assistant | Role.System,
42
+ content,
43
+ ...(assets.length > 0 ? { assets } : {}),
44
+ };
45
+ } else if (msg.tag === Message_Tags.ToolCalls) {
46
+ const { role, content, toolCalls } = msg.inner;
47
+ return { role: role as Role.Assistant, content, toolCalls };
48
+ } else {
49
+ const { role, name, content } = msg.inner;
50
+ return { role: role as Role.Tool, name, content };
51
+ }
52
+ }
53
+
54
+ /** @internal Convert ChatMessage to internal Message */
55
+ export function toInternal(msg: ChatMessage): InternalMessage {
56
+ if ("toolCalls" in msg) {
57
+ return new InternalMessage.ToolCalls({
58
+ role: msg.role,
59
+ content: msg.content,
60
+ toolCalls: msg.toolCalls,
61
+ });
62
+ } else if ("name" in msg) {
63
+ return new InternalMessage.ToolResp({
64
+ role: msg.role,
65
+ name: msg.name,
66
+ content: msg.content,
67
+ });
68
+ } else {
69
+ return new InternalMessage.Message({
70
+ role: msg.role,
71
+ content: msg.content,
72
+ assets: msg.assets ?? [],
73
+ });
74
+ }
75
+ }
package/src/model.ts ADDED
@@ -0,0 +1,63 @@
1
+ import {
2
+ loadModel,
3
+ type RustModelInterface,
4
+ } from "../generated/ts/nobodywho";
5
+
6
+ /**
7
+ * A loaded LLM model that can be shared across Chat, Encoder, and CrossEncoder instances.
8
+ *
9
+ * @example
10
+ * ```typescript
11
+ * // Load from a local file
12
+ * const model = await Model.load({ modelPath: "model.gguf" });
13
+ * const chat = new Chat({ model, systemPrompt: "You are helpful." });
14
+ *
15
+ * // Download from HuggingFace (cached automatically)
16
+ * const model = await Model.load({
17
+ * modelPath: "hf://NobodyWho/Qwen_Qwen3-0.6B-GGUF/Qwen_Qwen3-0.6B-Q4_K_M.gguf",
18
+ * });
19
+ *
20
+ * // With vision support
21
+ * const visionModel = await Model.load({
22
+ * modelPath: "model.gguf",
23
+ * projectionModelPath: "mmproj.gguf",
24
+ * });
25
+ * ```
26
+ */
27
+ export class Model {
28
+ /** @internal */
29
+ readonly _inner: RustModelInterface;
30
+
31
+ /** @internal */
32
+ private constructor(inner: RustModelInterface) {
33
+ this._inner = inner;
34
+ }
35
+
36
+ /**
37
+ * Load a GGUF model from a local path or remote URL.
38
+ *
39
+ * @param opts.modelPath - Path to the GGUF model file, or a `hf://owner/repo/file.gguf` / `https://` URL to download and cache automatically
40
+ * @param opts.useGpu - Use GPU acceleration if available (default: true)
41
+ * @param opts.projectionModelPath - Path to a multimodal projector file for vision models
42
+ */
43
+ static async load(opts: {
44
+ modelPath: string;
45
+ useGpu?: boolean;
46
+ projectionModelPath?: string;
47
+ }): Promise<Model> {
48
+ const inner = await loadModel(
49
+ opts.modelPath,
50
+ opts.useGpu ?? true,
51
+ opts.projectionModelPath,
52
+ );
53
+ return new Model(inner);
54
+ }
55
+
56
+ /**
57
+ * Immediately free the underlying Rust resources (model weights, GPU memory).
58
+ * After calling this, the Model instance is no longer usable.
59
+ */
60
+ destroy(): void {
61
+ (this._inner as any).uniffiDestroy();
62
+ }
63
+ }
@@ -1,5 +1,5 @@
1
- import type { SamplerConfigInterface } from "../generated/ts/nobodywho";
2
1
  import {
2
+ type SamplerConfig,
3
3
  samplerPresetDefault,
4
4
  samplerPresetDry,
5
5
  samplerPresetGrammar,
@@ -23,42 +23,42 @@ export class SamplerPresets {
23
23
  private constructor() {}
24
24
 
25
25
  /** Get the default sampler configuration. */
26
- static default(): SamplerConfigInterface {
26
+ static default(): SamplerConfig {
27
27
  return samplerPresetDefault();
28
28
  }
29
29
 
30
30
  /** Create a sampler with top-k filtering only. */
31
- static topK(topK: number): SamplerConfigInterface {
31
+ static topK(topK: number): SamplerConfig {
32
32
  return samplerPresetTopK(topK);
33
33
  }
34
34
 
35
35
  /** Create a sampler with nucleus (top-p) sampling. */
36
- static topP(topP: number): SamplerConfigInterface {
36
+ static topP(topP: number): SamplerConfig {
37
37
  return samplerPresetTopP(topP);
38
38
  }
39
39
 
40
40
  /** Create a greedy sampler (always picks most probable token). */
41
- static greedy(): SamplerConfigInterface {
41
+ static greedy(): SamplerConfig {
42
42
  return samplerPresetGreedy();
43
43
  }
44
44
 
45
45
  /** Create a sampler with temperature scaling. */
46
- static temperature(temperature: number): SamplerConfigInterface {
46
+ static temperature(temperature: number): SamplerConfig {
47
47
  return samplerPresetTemperature(temperature);
48
48
  }
49
49
 
50
50
  /** Create a DRY sampler preset to reduce repetition. */
51
- static dry(): SamplerConfigInterface {
51
+ static dry(): SamplerConfig {
52
52
  return samplerPresetDry();
53
53
  }
54
54
 
55
55
  /** Create a sampler configured for JSON output generation. */
56
- static json(): SamplerConfigInterface {
56
+ static json(): SamplerConfig {
57
57
  return samplerPresetJson();
58
58
  }
59
59
 
60
60
  /** Create a sampler with a custom grammar constraint. */
61
- static grammar(grammar: string): SamplerConfigInterface {
61
+ static grammar(grammar: string): SamplerConfig {
62
62
  return samplerPresetGrammar(grammar);
63
63
  }
64
64
  }
package/src/tool.ts CHANGED
@@ -5,12 +5,20 @@ import {
5
5
  } from "../generated/ts/nobodywho";
6
6
 
7
7
  /**
8
- * Convert a value from JSON based on the JSON Schema type.
8
+ * Recursively convert a value from JSON based on a JSON Schema type.
9
+ *
10
+ * Supports primitives (`string`, `number`, `integer`, `boolean`),
11
+ * arrays (`{ type: "array", items: ... }`), and nested objects
12
+ * (`{ type: "object", properties: { ... } }`).
9
13
  */
10
14
  function convertValue(
11
15
  value: unknown,
12
16
  schema: Record<string, unknown>,
13
17
  ): unknown {
18
+ if (value === null || value === undefined) {
19
+ return value;
20
+ }
21
+
14
22
  switch (schema.type) {
15
23
  case "integer":
16
24
  case "number":
@@ -19,6 +27,25 @@ function convertValue(
19
27
  return typeof value === "string" ? value === "true" : Boolean(value);
20
28
  case "string":
21
29
  return String(value);
30
+ case "array": {
31
+ if (!Array.isArray(value)) return value;
32
+ const itemSchema = schema.items as Record<string, unknown> | undefined;
33
+ if (!itemSchema) return value;
34
+ return value.map((item) => convertValue(item, itemSchema));
35
+ }
36
+ case "object": {
37
+ if (typeof value !== "object" || Array.isArray(value)) return value;
38
+ const properties = schema.properties as
39
+ | Record<string, Record<string, unknown>>
40
+ | undefined;
41
+ if (!properties) return value;
42
+ const obj = value as Record<string, unknown>;
43
+ const result: Record<string, unknown> = {};
44
+ for (const [key, propSchema] of Object.entries(properties)) {
45
+ result[key] = key in obj ? convertValue(obj[key], propSchema) : undefined;
46
+ }
47
+ return result;
48
+ }
22
49
  default:
23
50
  return value;
24
51
  }
@@ -78,3 +105,6 @@ export class Tool {
78
105
  return this._inner.getSchemaJson();
79
106
  }
80
107
  }
108
+
109
+ /** @internal Exported for testing only. */
110
+ export { convertValue as _convertValue };
package/src/wrapper.ts CHANGED
@@ -2,45 +2,37 @@
2
2
  * NobodyWho — React Native bindings for local LLM inference.
3
3
  *
4
4
  * This is the public entry point. It re-exports the public API:
5
- * - Wrapper classes: Chat, Tool, TokenStream (wrapping internal Rust* types)
6
- * - Direct re-exports: Model, Encoder, CrossEncoder, SamplerBuilder, SamplerConfig, etc.
7
- * - Utilities: SamplerPresets, loadModel, cosineSimilarity
5
+ * - Wrapper classes: Model, Chat, Encoder, CrossEncoder, Tool, TokenStream, Prompt
6
+ * - Direct re-exports: SamplerBuilder, SamplerConfig, Role
7
+ * - Types: ChatMessage, Asset, ToolCall
8
+ * - Utilities: SamplerPresets, cosineSimilarity
8
9
  *
9
10
  * This file is NOT generated — it is safe to edit.
10
11
  */
11
12
 
12
- // Wrapper classes that hide the internal Rust* types.
13
+ // Wrapper classes
14
+ export { Model } from "./model";
13
15
  export { Chat } from "./chat";
16
+ export { Encoder } from "./encoder";
17
+ export { CrossEncoder } from "./cross_encoder";
14
18
  export { Prompt } from "./prompt";
15
19
  export { Tool } from "./tool";
16
20
  export { TokenStream } from "./streaming";
17
21
 
22
+ // Message types
23
+ export type { ChatMessage } from "./message";
24
+
18
25
  // Re-export types that don't need wrapping.
19
26
  export {
20
- Model,
21
- Encoder,
22
- CrossEncoder,
23
27
  SamplerBuilder,
24
28
  SamplerConfig,
25
29
  Role,
26
- Message,
27
- Message_Tags,
28
- PromptPart,
29
- PromptPart_Tags,
30
30
  cosineSimilarity,
31
- loadModel,
32
- uniffiInitAsync,
33
31
  } from "./index";
34
32
 
35
33
  export type {
36
- ModelInterface,
37
- EncoderInterface,
38
- CrossEncoderInterface,
39
- SamplerBuilderInterface,
40
- SamplerConfigInterface,
41
34
  Asset,
42
35
  ToolCall,
43
- ToolParameter,
44
36
  } from "./index";
45
37
 
46
38
  // Ergonomic wrapper additions.