Skip to content
This repository was archived by the owner on Aug 11, 2026. It is now read-only.
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 28 additions & 1 deletion Examples/EmoWasmExample/browser.html
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,34 @@
"imports": {
"@bjorn3/browser_wasi_shim": "/packages/emo-node/node_modules/@bjorn3/browser_wasi_shim/dist/index.js",
"@desert-ant-labs/emo": "/packages/emo-node/browser.js",
"#platform": "/packages/emo-node/platform-browser.js",
"@desert-ant-labs/core": "/packages/emo-node/node_modules/@desert-ant-labs/core/index.js",
"@litertjs/core": "/packages/emo-node/node_modules/@litertjs/core/dist/index.js",
"@litertjs/wasm-utils": "/packages/emo-node/node_modules/@litertjs/wasm-utils/dist/index.js"
}
}
</script>
<script>
// Enable DAL HTTP request logging (must run before modules load).
globalThis.__dalHttpDebug = true;
globalThis.__dalAppId = "ai.desertant.wasm.browser";
</script>
<script type="module">
import * as litert from "@litertjs/core";

// A self-provided, stable per-device id. Multi-tenant WebAssembly hosts pass
// their own device id per call; the core binds it into the InferenceContext
// for that call, so usage is attributed to this device (safe under
// concurrency). Here we persist one in localStorage across reloads.
function selfProvidedDeviceId() {
let id = localStorage.getItem("emoDeviceId");
if (!id) {
id = crypto.randomUUID();
localStorage.setItem("emoDeviceId", id);
}
return id;
}

async function run() {
const { Emo } = await import("@desert-ant-labs/emo");
// Emo downloads, verifies, and caches the model from the Hub; LiteRT.js
Expand All @@ -25,7 +45,14 @@
});

const start = performance.now();
const suggestions = await emo.suggestions("Pay my bills", { limit: 3 });
const suggestions = await emo.suggestions("Pay my bills", {
limit: 3,
deviceId: selfProvidedDeviceId,
});
// Force the usage telemetry POST out now and await it before continuing.
if (globalThis.__EmoExports?.flushTelemetry) {
await globalThis.__EmoExports.flushTelemetry();
}
return { suggestions, ms: Math.round(performance.now() - start) };
}

Expand Down
10 changes: 8 additions & 2 deletions Examples/EmoWasmExample/main.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,20 @@
// server-side - no browser, no LiteRT.js needed. (The browser example,
// browser.html / `npm run browser-example`, exercises the WebAssembly +
// LiteRT.js path instead.)
import { Emo } from "@desert-ant-labs/emo";
// Enable DAL HTTP request logging. Set before importing Emo: static imports
// are hoisted, so we use a dynamic import below to guarantee ordering.
globalThis.__dalHttpDebug = true;
const { Emo } = await import("@desert-ant-labs/emo");

// Emo downloads, verifies (SHA-256), and caches the model from the Hub, then
// runs inference through the native core. First run fetches; later runs cache.
const emo = await Emo.load({});

const start = Date.now();
const suggestions = await emo.suggestions("Pay my bills", { limit: 3 });
// `deviceId` attributes usage to a specific end-user device on multi-tenant
// hosts (a string, or a zero-arg function returning one). Omit for the host
// device. Collected per call, so it is safe under concurrency.
const suggestions = await emo.suggestions("Pay my bills", { limit: 3, deviceId: "user-42" });
console.log("suggestions:", suggestions.map((s) => s.emoji).join(" "));
console.log(JSON.stringify(suggestions, null, 2));
console.log(`(${Date.now() - start} ms)`);
Expand Down
1 change: 0 additions & 1 deletion Examples/EmoWasmExample/node_modules

This file was deleted.

42 changes: 21 additions & 21 deletions Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ let jsDependencies: [Package.Dependency] = noJavaScriptKit ? [] : [
let packageDependencies: [Package.Dependency] = [
// Reusable cross-platform primitives (JSON, ModelStore, TextNormalization,
// Inference, FFIBuffer, HostBridge, PlatformSupport, ModelResources).
.package(url: "https://github.com/Desert-Ant-Labs/desert-ant-core.git", from: "0.5.3"),
.package(url: "https://github.com/Desert-Ant-Labs/desert-ant-core.git", revision: "a967f68"),
] + jsDependencies

let wasmProducts: [Product] = noJavaScriptKit ? [] : [
Expand Down Expand Up @@ -111,6 +111,7 @@ let androidTarget: Target = .target(
dependencies: [
"Emo",
.product(name: "FFIBuffer", package: "desert-ant-core"),
.product(name: "Inference", package: "desert-ant-core"),
.product(name: "HostBridge", package: "desert-ant-core", condition: .when(platforms: [.android])),
.product(name: "ModelStore", package: "desert-ant-core", condition: .when(platforms: [.android])),
.product(name: "PlatformSupport", package: "desert-ant-core"),
Expand Down
16 changes: 12 additions & 4 deletions Sources/Emo/ModelLoading.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,14 @@ import EmoCoreMLResources
import EmoTFLiteResources
#endif

/// This SDK's usage identity, attached to every emitted telemetry body's `sdk`
/// field so usage attributes to Emo (not the underlying desert-ant-core).
let emoSDKInfo = SDKInfo(name: "Emo", version: emoSDKVersion)

/// The Emo SDK version. Keep in sync with the package version (single-sourced
/// from packages/emo-node/package.json; see `mise run set-version`).
let emoSDKVersion = "0.7.0"

/// The model's file names and per-platform artifacts, in one place.
enum EmoModel {
static let meta = "emo_meta.json"
Expand Down Expand Up @@ -45,7 +53,7 @@ public struct ModelAssets: Sendable {
self.init(
metaJSON: metaJSON,
tokenizer: tokenizerBytes,
session: try inferenceSession(modelBytes: modelBytes))
session: try inferenceSession(modelBytes: modelBytes, sdk: emoSDKInfo))
}

/// Bindings entry point: load the artifact from a file path (the Node
Expand All @@ -58,7 +66,7 @@ public struct ModelAssets: Sendable {
self.init(
metaJSON: metaJSON,
tokenizer: tokenizerBytes,
session: try inferenceSession(modelPath: modelPath))
session: try inferenceSession(modelPath: modelPath, sdk: emoSDKInfo))
}

/// Bindings entry point: build from an already-constructed session (e.g. the
Expand All @@ -76,7 +84,7 @@ public struct ModelAssets: Sendable {
ModelAssets(
metaJSON: try files.readString(EmoModel.meta),
tokenizer: try files.read(EmoModel.tokenizer),
session: try await files.inferenceSession(model: EmoModel.artifact, hostGlobal: "__EmoHost"))
session: try await files.inferenceSession(model: EmoModel.artifact, hostGlobal: "__EmoHost", sdk: emoSDKInfo))
}
}

Expand Down Expand Up @@ -204,7 +212,7 @@ extension ModelAssets {
return ModelAssets(
metaJSON: try resources.readString(EmoModel.meta),
tokenizer: try resources.read(EmoModel.tokenizer),
session: try inferenceSession(modelPath: try resources.path(EmoModel.artifact)))
session: try inferenceSession(modelPath: try resources.path(EmoModel.artifact), sdk: emoSDKInfo))
} catch {
throw EmoError.modelNotFound
}
Expand Down
37 changes: 36 additions & 1 deletion Sources/EmoAndroid/CABI.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
#if !os(WASI)
@_spi(EmoBindings) import Emo
import FFIBuffer
import Inference // InferenceContext.withCallGroup(id:), for grouped runs
import PlatformSupport

// C ABI over the Emo core, called by the Swift JNI entry points in
Expand All @@ -14,6 +15,7 @@ import PlatformSupport
// emo_is_downloaded(handle) -> 0/1
// emo_download(handle) -> 0/-1 (blocks)
// emo_run(handle, textUTF8, limit, skinTone) -> buffer | NULL
// emo_run_grouped(handle, textUTF8, limit, skinTone, groupId)-> buffer | NULL
// emo_destroy(handle)
// emo_string_free(ptr)
//
Expand Down Expand Up @@ -115,12 +117,45 @@ public func emo_download(_ handle: UnsafeMutableRawPointer?) -> Int32 {
public func emo_run(
_ handle: UnsafeMutableRawPointer?, _ text: UnsafePointer<CChar>?,
_ limit: Int32, _ skinToneRaw: Int32
) -> UnsafeMutablePointer<CChar>? {
runSuggestions(handle, text, limit, skinToneRaw, groupId: nil)
}

/// Like `emo_run`, but attributes usage to the shared call group named `groupId`
/// (reused across SDKs, via desert-ant-core): every run sharing the id bills as
/// one call, and to a specific end-user `deviceId` (multi-tenant hosts serving
/// many users). Both are optional — pass NULL to omit either. Used by the Node
/// build; each call binds its own device, safe under concurrency. Release the
/// call group with `dal_call_group_end`. (Android calls plain `emo_run` and
/// resolves its own device via the host bridge, so it needs no `deviceId` here.)
@_cdecl("emo_run_grouped")
public func emo_run_grouped(
_ handle: UnsafeMutableRawPointer?, _ text: UnsafePointer<CChar>?,
_ limit: Int32, _ skinToneRaw: Int32,
_ groupId: UnsafePointer<CChar>?, _ deviceId: UnsafePointer<CChar>?
) -> UnsafeMutablePointer<CChar>? {
runSuggestions(
handle, text, limit, skinToneRaw,
groupId: groupId.map { String(cString: $0) },
deviceId: deviceId.map { String(cString: $0) })
}

/// Shared body for `emo_run`/`emo_run_grouped`: run and encode suggestions,
/// binding the shared call group `groupId` and the end-user `deviceId` into the
/// InferenceContext when set.
private func runSuggestions(
_ handle: UnsafeMutableRawPointer?, _ text: UnsafePointer<CChar>?,
_ limit: Int32, _ skinToneRaw: Int32, groupId: String?, deviceId: String? = nil
) -> UnsafeMutablePointer<CChar>? {
guard let emo = emo(handle), let text else { return nil }
let phrase = String(cString: text)
let tone = skinTone(skinToneRaw)
let payload: [UInt8]? = blockingValue {
let suggestions = (try? await emo.suggestions(for: phrase, limit: Int(limit), skinTone: tone)) ?? []
let suggestions = await InferenceContext.$deviceId.withValue(deviceId) {
await InferenceContext.withCallGroup(id: groupId) {
(try? await emo.suggestions(for: phrase, limit: Int(limit), skinTone: tone)) ?? []
}
}
var w = FFIWriter()
w.u32(suggestions.count)
for s in suggestions {
Expand Down
40 changes: 35 additions & 5 deletions Sources/EmoWeb/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import Inference
import JavaScriptEventLoop
import JavaScriptKit
import Usage
@_spi(EmoBindings) import Emo

// WebAssembly entry point. Mirrors the iOS/Swift SDK (suggestion only). The JS
Expand All @@ -10,11 +11,15 @@ import JavaScriptKit
// start, the module exposes:
//
// globalThis.__EmoExports = {
// load(cacheRoot, directory?, onProgress?) -> Promise<boolean>,
// loadBundled(metaJSON, tokenizerBytes) -> Promise<boolean>,
// suggest(text, limit?, skinTone?) -> Promise<[{emoji, confidence}]>,
// load(cacheRoot, directory?, onProgress?) -> Promise<boolean>,
// loadBundled(metaJSON, tokenizerBytes, modelBytes) -> Promise<boolean>,
// suggest(text, limit?, skinTone?, deviceId?) -> Promise<[{emoji, confidence}]>,
// }
//
// `deviceId` may be a string or a zero-arg function returning one; it is
// collected per call and bound to that call's task tree (safe for concurrent
// multi-tenant hosts), attributing usage to a specific end-user device.
//
// `skinTone` is a number: 0 default, 1 light, 2 mediumLight, 3 medium,
// 4 mediumDark, 5 dark. `packages/emo-node` wraps this in the public typed API;
// nothing else should touch these globals.
Expand Down Expand Up @@ -48,14 +53,26 @@ private func encode(_ suggestions: [EmoSuggestion]) -> JSValue {
return .object(arr)
}

private func collectDeviceId(_ value: JSValue?) -> String? {
guard let value else { return nil }
if let string = value.string, !string.isEmpty { return string }
if let getter = value.function, let string = getter().string, !string.isEmpty { return string }
return nil
}

let suggestFn = JSClosure { args in
let text = args.first?.string ?? ""
let limit = args.count > 1 ? Int(args[1].number ?? 3) : 3
let tone = skinTone(args.count > 2 ? args[2].number : nil)
// Collect the per-call device id now (before any await), then bind it to
// this call's task tree so concurrent calls stay isolated.
let deviceId = collectDeviceId(args.count > 3 ? args[3] : nil)
return JSPromise { resolve in
Task {
do {
let suggestions = try await instance().suggestions(for: text, limit: limit, skinTone: tone)
let suggestions = try await InferenceContext.$deviceId.withValue(deviceId) {
try await instance().suggestions(for: text, limit: limit, skinTone: tone)
}
resolve(.success(encode(suggestions)))
} catch {
resolve(.failure(.string(String(describing: error))))
Expand Down Expand Up @@ -108,7 +125,7 @@ let loadBundledFn = JSClosure { args in
guard let metaJSON, let tokenizer else { throw EmoError.modelNotFound }
let assets = try ModelAssets(
metaJSON: metaJSON, tokenizer: tokenizer,
session: JSInferenceSession(hostGlobal: "__EmoHost"))
session: inferenceSession(hostGlobal: "__EmoHost"))
let emo = Emo(assets: assets)
try await emo.waitUntilLoaded()
suggester = emo
Expand All @@ -120,9 +137,22 @@ let loadBundledFn = JSClosure { args in
}.jsValue
}

// flushTelemetry(): force any tracked session to emit now (bypassing the 3s
// debounce + re-emit window) and await the send, so the usage POST actually
// goes out before the caller continues. Requires `globalThis.__dalHttpDebug`.
let flushTelemetryFn = JSClosure { _ in
JSPromise { resolve in
Task {
await TelemetryDebug.shared.flushAndWait()
resolve(.success(.boolean(true)))
}
}.jsValue
}

let exports = JSObject.global.Object.function!.new()
exports.load = .object(loadFn)
exports.loadBundled = .object(loadBundledFn)
exports.suggest = .object(suggestFn)
exports.flushTelemetry = .object(flushTelemetryFn)
JSObject.global.__EmoExports = .object(exports)
#endif
Loading
Loading