Files
elizaos--eliza/plugins/plugin-native-bun-runtime/ios/Sources/ElizaBunRuntimePlugin/FullBunEngineHost.swift
T
wehub-resource-sync 426e9eeabd
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
ci / test (push) Has been cancelled
ci / lint-and-format (push) Has been cancelled
ci / build (push) Has been cancelled
ci / dev-startup (push) Has been cancelled
gitleaks / gitleaks (push) Has been cancelled
Markdown Links / Relative Markdown Links (push) Has been cancelled
Quality (Extended) / Homepage Build (PR smoke) (push) Has been cancelled
Quality (Extended) / Comment-only diff guard (push) Has been cancelled
Quality (Extended) / Format + Type Safety Ratchet (push) Has been cancelled
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Has been cancelled
Quality (Extended) / Develop Gate (lint) (push) Has been cancelled
Chat shell gestures / Chat shell gesture + parity e2e (push) Has been cancelled
Cloud Gateway Discord / Test (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Has been cancelled
Build Agent Image / build-and-push (push) Has been cancelled
Dev Smoke / bun run dev onboarding chat (push) Has been cancelled
Dev Smoke / Vite HMR dependency-level smoke (push) Has been cancelled
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Has been cancelled
Publish @elizaos/example-code / check_npm (push) Has been cancelled
Publish @elizaos/example-code / publish_npm (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / verify_version (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / publish_npm (push) Has been cancelled
Sandbox Live Smoke / Sandbox live smoke (push) Has been cancelled
Snap Build & Test / Build Snap (amd64) (push) Has been cancelled
Snap Build & Test / Build Snap (arm64) (push) Has been cancelled
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Has been cancelled
Cloud Gateway Webhook / Test (push) Has been cancelled
Cloud Tests / lint-and-types (push) Has been cancelled
Cloud Tests / unit-tests (push) Has been cancelled
Cloud Tests / integration-tests (push) Has been cancelled
Cloud Tests / e2e-tests (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Apps Worker (Product 2) / Determine environment (push) Has been cancelled
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Has been cancelled
Deploy Eliza Provisioning Worker / Determine environment (push) Has been cancelled
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Has been cancelled
Dev Smoke / Classify changed paths (push) Has been cancelled
supply-chain / sbom (push) Has been cancelled
supply-chain / vulnerability-scan (push) Has been cancelled
Build, Push & Deploy to Phala Cloud / build-and-push (push) Has been cancelled
Test Packaging / Validate Packaging Configs (push) Has been cancelled
Test Packaging / Build & Test PyPI Package (push) Has been cancelled
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Has been cancelled
Test Packaging / Pack & Test JS Tarballs (push) Has been cancelled
UI Fixture E2E / ui-fixture-e2e (push) Has been cancelled
UI Fixture E2E / fixture-e2e (push) Has been cancelled
UI Story Gate / story-gate (push) Has been cancelled
vault-ci / test (macos-latest) (push) Has been cancelled
vault-ci / test (ubuntu-latest) (push) Has been cancelled
vault-ci / test (windows-latest) (push) Has been cancelled
vault-ci / app-core wiring tests (push) Has been cancelled
verify-patches / verify patches/CHECKSUMS.sha256 (push) Has been cancelled
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Has been cancelled
Voice Benchmark Smoke / voice bench smoke summary (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/app-core test bun run --cwd packages/elizaos test bun run --cwd packages/cloud/shared test], app-and-cli) (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/scenario-runner test bun run --cwd packages/vault test bun run --cwd packages/security test bun run --cwd plugins/plugin-coding-tools test], framework-packages) (push) Has been cancelled
Windows CI / windows ([bun run --cwd plugins/plugin-elizacloud test bun run --cwd plugins/plugin-discord test bun run --cwd plugins/plugin-anthropic test bun run --cwd plugins/plugin-openai test bun run --cwd plugins/plugin-app-control test bun run --cwd plugins/pl… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run build --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/agent --concurrency=4 node packages/scripts/run-bash-linux-only.mjs scripts/verify-riscv64-buildpaths.sh node packages/scripts/run… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run typecheck --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/cloud-shared --concurrency=4 bun run --cwd packages/core test bun run --cwd packages/shared test], core-runtime, 75) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:43:05 +08:00

793 lines
33 KiB
Swift

import Foundation
import Darwin
#if ELIZA_IOS_FULL_BUN_ENGINE
import ElizaBunEngine
#endif
private let fullBunHostCallCallback: @convention(c) (
UnsafePointer<CChar>?,
UnsafePointer<CChar>?,
Int32
) -> UnsafeMutablePointer<CChar>? = { methodPtr, payloadPtr, timeoutMs in
let method = methodPtr.map { String(cString: $0) } ?? ""
let payloadJson = payloadPtr.map { String(cString: $0) } ?? "null"
let response = FullBunEngineHost.shared.handleHostCall(
method: method,
payloadJson: payloadJson,
timeoutMs: timeoutMs
)
return strdup(response)
}
/// Host for the real Bun iOS engine framework.
///
/// Full-engine/App Store builds link `ElizaBunEngine.xcframework` directly so
/// the shipped app does not import dynamic loader APIs. Compatibility builds
/// keep the optional loader path only in DEBUG development builds without
/// embedding the full engine framework. Release builds without the direct-link
/// flag fail closed.
///
/// IPC security model (full-Bun path):
/// - Transport: NDJSON over anonymous stdio pipes. No TCP port is opened by the
/// native side. `bun_start(...)` receives the read end of the parent's stdin
/// pipe and the write end of its stdout pipe; no other network socket is
/// created by the shim.
/// - Input validation: the C shim (`eliza_bun_engine_shim.c`) validates all
/// NDJSON frames before dispatch: `id` must be numeric, `method` must be a
/// JSON string, `payload` is extracted as a bounded value field. The
/// `ELIZA_MAX_PROTOCOL_LINE_BYTES` (16 MiB) cap prevents unbounded reads.
/// - Host-call allowlist: only `llama_hardware_info`, `llama_load_model`,
/// `llama_generate`, `llama_free`, `llama_cancel`, `eliza_tts_synthesize`,
/// `eliza_asr_transcribe`, `keep_awake_set`, `stream_emit`,
/// `bg_download_start`, `bg_download_status`, and `bg_download_cancel` are
/// dispatched by `handleHostCall`. All other method names return
/// `{"ok":false,"error":"..."}`.
/// - `http_fetch` (JSContext compat path): loopback/local-agent URLs are
/// rejected at `HTTPBridge.isLocalLoopback` before a URLRequest is created.
/// External fetches go through URLSession with the standard iOS ATS policy.
/// - `http_request` IPC (both paths): the path must begin with `/` and must not
/// contain `://`. Validated in `MobileAgentBridgePlugin.proxyHttpRequest` and
/// enforced by the Bun bridge contract at the agent layer.
/// - Filesystem (JSContext compat path): `FSBridge` does not restrict paths
/// beyond what the iOS app sandbox enforces. The agent bundle is signed and
/// staged inside the app bundle; paths visible to the JSContext are limited to
/// the app container by the OS.
/// - ABI version check: `load()` verifies the framework reports ABI version "3"
/// before accepting any other symbols. A version mismatch is a hard error.
final class FullBunEngineHost {
static let shared = FullBunEngineHost()
private static let expectedAbiVersion = "3"
private typealias AbiVersionFn = @convention(c) () -> UnsafePointer<CChar>?
private typealias LastErrorFn = @convention(c) () -> UnsafePointer<CChar>?
private typealias HostCallbackFn = @convention(c) (
UnsafePointer<CChar>?,
UnsafePointer<CChar>?,
Int32
) -> UnsafeMutablePointer<CChar>?
private typealias SetHostCallbackFn = @convention(c) (HostCallbackFn?) -> Int32
private typealias StartFn = @convention(c) (
UnsafePointer<CChar>,
UnsafePointer<CChar>,
UnsafePointer<CChar>,
UnsafePointer<CChar>
) -> Int32
private typealias StopFn = @convention(c) () -> Int32
private typealias IsRunningFn = @convention(c) () -> Int32
private typealias CallFn = @convention(c) (
UnsafePointer<CChar>,
UnsafePointer<CChar>
) -> UnsafeMutablePointer<CChar>?
private typealias FreeFn = @convention(c) (UnsafeMutableRawPointer?) -> Void
private var loaded = false
#if !ELIZA_IOS_FULL_BUN_ENGINE && DEBUG
private var handle: UnsafeMutableRawPointer?
#endif
private var abiVersionFn: AbiVersionFn?
private var lastErrorFn: LastErrorFn?
private var setHostCallbackFn: SetHostCallbackFn?
private var startFn: StartFn?
private var stopFn: StopFn?
private var isRunningFn: IsRunningFn?
private var callFn: CallFn?
private var freeFn: FreeFn?
private var running = false
/// Delivers one `agentStream*` event to the WebView. Set by the plugin so
/// the `stream_emit` host-call (fired per token while `http_request_stream`
/// is in flight) reaches `CAPPlugin.notifyListeners` (#12354). Nil until the
/// plugin wires it; a stream emit with no sink is a no-op envelope.
var streamEventSink: ((_ eventName: String, _ data: [String: Any]) -> Void)?
private init() {}
var isAvailable: Bool {
do {
try load()
return true
} catch {
return false
}
}
var abiVersion: String {
guard let abi = abiVersionFn?() else { return "unknown" }
return String(cString: abi)
}
var isRunning: Bool {
do {
try load()
let engineRunning = isRunningFn?() == 1
if !engineRunning { running = false }
return engineRunning
} catch {
running = false
return false
}
}
func start(
bundlePath: String,
argv: [String],
env: [String: String],
appSupportDir: String
) throws {
let startedAt = Date()
NSLog("[FullBunEngineHost] start requested bundle=\(bundlePath) appSupport=\(appSupportDir) argv=\(argv) envKeys=\(env.keys.sorted())")
try load()
if running {
if isRunningFn?() == 1 { return }
running = false
}
guard let startFn else {
throw makeError("ElizaBunEngine missing start symbol")
}
let argvJson = try encodeJSON(argv)
let envJson = try encodeJSON(env)
let code = bundlePath.withCString { bundlePtr in
argvJson.withCString { argvPtr in
envJson.withCString { envPtr in
appSupportDir.withCString { supportPtr in
startFn(bundlePtr, argvPtr, envPtr, supportPtr)
}
}
}
}
guard code == 0 else {
let detail = lastError()
let durationMs = Int(Date().timeIntervalSince(startedAt) * 1000)
NSLog("[FullBunEngineHost] start failed code=\(code) durationMs=\(durationMs) detail=\(detail)")
throw makeError(
"ElizaBunEngine start failed with code \(code)" +
(detail.isEmpty ? "" : ": \(detail)")
)
}
running = true
let durationMs = Int(Date().timeIntervalSince(startedAt) * 1000)
NSLog("[FullBunEngineHost] start succeeded abi=\(abiVersion) durationMs=\(durationMs)")
}
func stop() {
_ = stopFn?()
running = false
}
func call(method: String, payload: Any?) throws -> Any? {
try load()
guard let callFn else {
throw makeError("ElizaBunEngine missing call symbol")
}
let previousError = lastError()
let payloadJson = try encodeJSON(payload ?? NSNull())
let resultPtr = method.withCString { methodPtr in
payloadJson.withCString { payloadPtr in
callFn(methodPtr, payloadPtr)
}
}
guard let resultPtr else {
NSLog("[FullBunEngineHost] call returned null method=\(method) lastError=\(lastError())")
throw makeError("ElizaBunEngine call returned null for \(method)")
}
defer { freeFn?(UnsafeMutableRawPointer(resultPtr)) }
let resultJson = String(cString: resultPtr)
guard let data = resultJson.data(using: .utf8) else {
throw makeError("ElizaBunEngine returned non-UTF8 payload")
}
let decoded = try JSONSerialization.jsonObject(with: data)
if let dict = decoded as? [String: Any],
let ok = dict["ok"] as? Bool,
ok == false {
let message = dict["error"] as? String ?? "unknown full Bun engine error"
let currentError = lastError()
let diagnostic = !currentError.isEmpty && currentError != message
? currentError
: previousError
let detail = diagnostic.isEmpty || diagnostic == message
? ""
: " (engine error: \(diagnostic))"
NSLog("[FullBunEngineHost] call failed method=\(method) error=\(message) diagnostic=\(diagnostic)")
throw makeError("\(message)\(detail)")
}
if let dict = decoded as? [String: Any],
let ok = dict["ok"] as? Bool,
ok == true {
return dict["result"] ?? NSNull()
}
return decoded
}
private func load() throws {
if loaded { return }
NSLog("[FullBunEngineHost] loading ElizaBunEngine")
#if ELIZA_IOS_FULL_BUN_ENGINE
let loadedAbiVersionFn: AbiVersionFn = {
eliza_bun_engine_abi_version()
}
let loadedLastErrorFn: LastErrorFn = {
eliza_bun_engine_last_error()
}
let loadedSetHostCallbackFn: SetHostCallbackFn = { callback in
eliza_bun_engine_set_host_callback(callback)
}
let loadedStartFn: StartFn = { bundlePath, argvJson, envJson, appSupportDir in
eliza_bun_engine_start(bundlePath, argvJson, envJson, appSupportDir)
}
let loadedStopFn: StopFn = {
eliza_bun_engine_stop()
}
let loadedIsRunningFn: IsRunningFn = {
eliza_bun_engine_is_running()
}
let loadedCallFn: CallFn = { method, payload in
eliza_bun_engine_call(method, payload)
}
let loadedFreeFn: FreeFn = { ptr in
eliza_bun_engine_free(ptr)
}
try installLoadedEngineSymbols(
abiVersionFn: loadedAbiVersionFn,
lastErrorFn: loadedLastErrorFn,
setHostCallbackFn: loadedSetHostCallbackFn,
startFn: loadedStartFn,
stopFn: loadedStopFn,
isRunningFn: loadedIsRunningFn,
callFn: loadedCallFn,
freeFn: loadedFreeFn
)
#elseif DEBUG
let binaryPath = try locateFrameworkBinary()
guard let openedHandle = dlopen(binaryPath, RTLD_NOW | RTLD_LOCAL) else {
throw makeError(String(cString: dlerror()))
}
do {
let loadedAbiVersionFn: AbiVersionFn = try symbol(
"eliza_bun_engine_abi_version",
in: openedHandle
)
let loadedLastErrorFn: LastErrorFn = try symbol(
"eliza_bun_engine_last_error",
in: openedHandle
)
let loadedSetHostCallbackFn: SetHostCallbackFn = try symbol(
"eliza_bun_engine_set_host_callback",
in: openedHandle
)
let loadedStartFn: StartFn = try symbol("eliza_bun_engine_start", in: openedHandle)
let loadedStopFn: StopFn = try symbol("eliza_bun_engine_stop", in: openedHandle)
let loadedIsRunningFn: IsRunningFn = try symbol(
"eliza_bun_engine_is_running",
in: openedHandle
)
let loadedCallFn: CallFn = try symbol("eliza_bun_engine_call", in: openedHandle)
let loadedFreeFn: FreeFn = try symbol("eliza_bun_engine_free", in: openedHandle)
try installLoadedEngineSymbols(
abiVersionFn: loadedAbiVersionFn,
lastErrorFn: loadedLastErrorFn,
setHostCallbackFn: loadedSetHostCallbackFn,
startFn: loadedStartFn,
stopFn: loadedStopFn,
isRunningFn: loadedIsRunningFn,
callFn: loadedCallFn,
freeFn: loadedFreeFn
)
self.handle = openedHandle
} catch {
_ = dlclose(openedHandle)
throw error
}
#else
throw makeError(
"ElizaBunEngine direct-link symbols are not compiled into this release build"
)
#endif
}
private func installLoadedEngineSymbols(
abiVersionFn loadedAbiVersionFn: AbiVersionFn,
lastErrorFn loadedLastErrorFn: LastErrorFn,
setHostCallbackFn loadedSetHostCallbackFn: SetHostCallbackFn,
startFn loadedStartFn: StartFn,
stopFn loadedStopFn: StopFn,
isRunningFn loadedIsRunningFn: IsRunningFn,
callFn loadedCallFn: CallFn,
freeFn loadedFreeFn: FreeFn
) throws {
guard let abiPointer = loadedAbiVersionFn() else {
throw makeError("ElizaBunEngine ABI version returned null")
}
let loadedAbiVersion = String(cString: abiPointer)
guard loadedAbiVersion == Self.expectedAbiVersion else {
throw makeError(
"ElizaBunEngine ABI mismatch: expected \(Self.expectedAbiVersion), got \(loadedAbiVersion)"
)
}
let callbackCode = loadedSetHostCallbackFn(fullBunHostCallCallback)
guard callbackCode == 0 else {
throw makeError("ElizaBunEngine failed to install host callback: \(callbackCode)")
}
self.abiVersionFn = loadedAbiVersionFn
self.lastErrorFn = loadedLastErrorFn
self.setHostCallbackFn = loadedSetHostCallbackFn
self.startFn = loadedStartFn
self.stopFn = loadedStopFn
self.isRunningFn = loadedIsRunningFn
self.callFn = loadedCallFn
self.freeFn = loadedFreeFn
self.loaded = true
NSLog("[FullBunEngineHost] loaded ElizaBunEngine abi=\(loadedAbiVersion)")
}
#if !ELIZA_IOS_FULL_BUN_ENGINE && DEBUG
private func locateFrameworkBinary() throws -> String {
let relative = "ElizaBunEngine.framework/ElizaBunEngine"
let candidates = [
Bundle.main.privateFrameworksURL?.appendingPathComponent(relative).path,
Bundle.main.bundleURL.appendingPathComponent("Frameworks").appendingPathComponent(relative).path,
Bundle.main.url(
forResource: "ElizaBunEngine",
withExtension: nil,
subdirectory: "Frameworks/ElizaBunEngine.framework"
)?.path,
].compactMap { $0 }
for candidate in candidates where FileManager.default.fileExists(atPath: candidate) {
return candidate
}
throw makeError("ElizaBunEngine.framework is not embedded in the app bundle")
}
private func symbol<T>(_ name: String, in handle: UnsafeMutableRawPointer) throws -> T {
guard let pointer = dlsym(handle, name) else {
throw makeError("ElizaBunEngine missing symbol \(name)")
}
return unsafeBitCast(pointer, to: T.self)
}
#endif
private func encodeJSON(_ value: Any) throws -> String {
let data = try JSONSerialization.data(withJSONObject: value)
return String(data: data, encoding: .utf8) ?? "{}"
}
private func lastError() -> String {
guard let pointer = lastErrorFn?() else { return "" }
return String(cString: pointer)
}
private func makeError(_ message: String) -> NSError {
NSError(
domain: "ElizaBunEngine",
code: -1,
userInfo: [NSLocalizedDescriptionKey: message]
)
}
fileprivate func handleHostCall(
method: String,
payloadJson: String,
timeoutMs: Int32
) -> String {
_ = timeoutMs
NSLog("[FullBunEngineHost] host call method=\(method) payloadBytes=\(payloadJson.lengthOfBytes(using: .utf8)) timeoutMs=\(timeoutMs)")
do {
let payload = try decodeHostPayload(payloadJson)
switch method {
case "llama_hardware_info":
return encodeHostEnvelope(ok: true, result: LlamaBridgeImpl.shared.hardwareInfo().asDict())
case "llama_load_model":
return try handleLoadModel(payload)
case "llama_generate":
return try handleGenerate(payload)
case "llama_free":
return handleFree(payload)
case "llama_cancel":
return handleCancel(payload)
case "eliza_tts_synthesize":
return handleTtsSynthesize(payload)
case "eliza_asr_transcribe":
return handleAsrTranscribe(payload)
case "keep_awake_set":
// Hold the iOS idle timer open while an in-process model
// download is active so auto-lock cannot suspend the runtime
// mid-transfer (#11841). Reference-counted natively.
let enabled = boolValue(payload, "enabled") ?? false
KeepAwakeBridge.shared.setEnabled(enabled)
return encodeHostEnvelope(ok: true, result: ["enabled": NSNumber(value: enabled)])
case "stream_emit":
// One chat-stream event (response head / token chunk / complete)
// fired by the bridge's `http_request_stream` handler while it
// runs. Forward it to the WebView as the matching `agentStream*`
// Capacitor event so the TS adapter reconstructs a live
// ReadableStream (#12354).
return handleStreamEmit(payload)
case "bg_download_start":
// Route the large on-device model file through a native
// background URLSession so the ~5 GB transfer survives the app
// backgrounding / the device locking (#11841). The downloader
// polls `bg_download_status` for progress + completion.
guard let id = stringValue(payload, "id"), !id.isEmpty,
let url = stringValue(payload, "url"), !url.isEmpty,
let destPath = stringValue(payload, "destPath"), !destPath.isEmpty else {
return encodeHostEnvelope(
ok: false,
error: "bg_download_start requires id, url, and destPath"
)
}
let headers = stringMapValue(payload, "headers") ?? [:]
let total = int64Value(payload, "expectedTotalBytes") ?? 0
return encodeHostEnvelope(ok: true, result: BackgroundDownloadBridge.shared.start(
id: id,
urlString: url,
headers: headers,
destPath: destPath,
expectedTotalBytes: total
))
case "bg_download_status":
guard let id = stringValue(payload, "id"), !id.isEmpty else {
return encodeHostEnvelope(ok: false, error: "bg_download_status requires id")
}
return encodeHostEnvelope(ok: true, result: BackgroundDownloadBridge.shared.status(id: id))
case "bg_download_cancel":
guard let id = stringValue(payload, "id"), !id.isEmpty else {
return encodeHostEnvelope(ok: false, error: "bg_download_cancel requires id")
}
return encodeHostEnvelope(ok: true, result: BackgroundDownloadBridge.shared.cancel(id: id))
default:
return encodeHostEnvelope(
ok: false,
error: "Unknown native host call method: \(method)"
)
}
} catch {
return encodeHostEnvelope(ok: false, error: error.localizedDescription)
}
}
private func handleLoadModel(_ payload: [String: Any]) throws -> String {
guard let path = stringValue(payload, "path") ?? stringValue(payload, "modelPath"),
!path.isEmpty else {
return encodeHostEnvelope(ok: false, error: "llama_load_model requires path")
}
let contextSize = uint32Value(payload, "context_size")
?? uint32Value(payload, "contextSize")
?? 4096
let useGPU = boolValue(payload, "use_gpu")
?? boolValue(payload, "useGpu")
?? true
let threads = int32Value(payload, "threads")
?? int32Value(payload, "maxThreads")
let result = LlamaBridgeImpl.shared.loadModel(
path: path,
contextSize: contextSize,
useGPU: useGPU,
threads: threads
)
if let error = result.error {
return encodeHostEnvelope(ok: false, error: error)
}
guard let contextId = result.contextId else {
return encodeHostEnvelope(ok: false, error: "llama_load_model returned no context_id")
}
return encodeHostEnvelope(ok: true, result: [
"context_id": NSNumber(value: contextId),
"contextId": NSNumber(value: contextId),
"modelPath": path,
"contextSize": NSNumber(value: contextSize),
"useGpu": NSNumber(value: useGPU),
])
}
private func handleGenerate(_ payload: [String: Any]) throws -> String {
guard let contextId = int64Value(payload, "context_id")
?? int64Value(payload, "contextId") else {
return encodeHostEnvelope(ok: false, error: "llama_generate requires context_id")
}
guard let prompt = stringValue(payload, "prompt"), !prompt.isEmpty else {
return encodeHostEnvelope(ok: false, error: "llama_generate requires prompt")
}
let maxTokens = int32Value(payload, "max_tokens")
?? int32Value(payload, "maxTokens")
?? 256
let temperature = floatValue(payload, "temperature") ?? 0.7
let topP = floatValue(payload, "top_p") ?? floatValue(payload, "topP") ?? 0.95
let topK = int32Value(payload, "top_k") ?? int32Value(payload, "topK") ?? 40
let stopSequences = stringArrayValue(payload, "stop")
?? stringArrayValue(payload, "stopSequences")
?? []
let generate = {
LlamaBridgeImpl.shared.generate(
contextId: contextId,
prompt: prompt,
maxTokens: maxTokens,
temperature: temperature,
topP: topP,
topK: topK,
stopSequences: stopSequences
)
}
let result: LlamaGenerateResult
if let queue = LlamaBridgeImpl.shared.workQueue(for: contextId) {
result = queue.sync(execute: generate)
} else {
result = generate()
}
if let error = result.error {
return encodeHostEnvelope(ok: false, error: error)
}
return encodeHostEnvelope(ok: true, result: [
"text": result.text,
"promptTokens": NSNumber(value: result.promptTokens),
"outputTokens": NSNumber(value: result.outputTokens),
"durationMs": NSNumber(value: result.durationMs),
])
}
private func handleFree(_ payload: [String: Any]) -> String {
guard let contextId = int64Value(payload, "context_id")
?? int64Value(payload, "contextId") else {
return encodeHostEnvelope(ok: true, result: ["freed": false])
}
LlamaBridgeImpl.shared.free(contextId: contextId)
return encodeHostEnvelope(ok: true, result: [
"freed": true,
"context_id": NSNumber(value: contextId),
])
}
private func handleCancel(_ payload: [String: Any]) -> String {
guard let contextId = int64Value(payload, "context_id")
?? int64Value(payload, "contextId") else {
return encodeHostEnvelope(ok: true, result: ["cancelled": false])
}
LlamaBridgeImpl.shared.cancel(contextId: contextId)
return encodeHostEnvelope(ok: true, result: [
"cancelled": true,
"context_id": NSNumber(value: contextId),
])
}
/// Translate one `stream_emit` frame into the matching `agentStream*`
/// Capacitor event and hand it to the WebView. The frame `kind` mirrors the
/// Android streaming contract so `createNativeStreamingResponse` consumes it
/// unmodified: `response` → `agentStreamResponse`, `chunk` →
/// `agentStreamChunk`, `complete` → `agentStreamComplete` (#12354).
private func handleStreamEmit(_ payload: [String: Any]) -> String {
guard let streamId = stringValue(payload, "streamId"), !streamId.isEmpty else {
return encodeHostEnvelope(ok: false, error: "stream_emit requires streamId")
}
guard let kind = stringValue(payload, "kind") else {
return encodeHostEnvelope(ok: false, error: "stream_emit requires kind")
}
guard let sink = streamEventSink else {
// No WebView listener is wired (e.g. a smoke harness). Acknowledge so
// the bridge keeps streaming rather than tearing the turn down.
return encodeHostEnvelope(ok: true, result: ["delivered": false])
}
switch kind {
case "response":
var data: [String: Any] = [
"streamId": streamId,
"status": NSNumber(value: intValue(payload, "status") ?? 200),
]
if let statusText = stringValue(payload, "statusText") {
data["statusText"] = statusText
}
if let headers = stringMapValue(payload, "headers") {
data["headers"] = headers
}
sink("agentStreamResponse", data)
case "chunk":
guard let dataBase64 = stringValue(payload, "dataBase64") else {
return encodeHostEnvelope(ok: false, error: "stream_emit chunk requires dataBase64")
}
sink("agentStreamChunk", ["streamId": streamId, "dataBase64": dataBase64])
case "complete":
var data: [String: Any] = ["streamId": streamId]
if let error = stringValue(payload, "error") {
data["error"] = error
}
sink("agentStreamComplete", data)
default:
return encodeHostEnvelope(ok: false, error: "stream_emit unknown kind: \(kind)")
}
return encodeHostEnvelope(ok: true, result: ["delivered": true])
}
private func handleTtsSynthesize(_ payload: [String: Any]) -> String {
guard let bundleDir = stringValue(payload, "bundleDir")
?? stringValue(payload, "bundle_dir"),
!bundleDir.isEmpty else {
return encodeHostEnvelope(ok: false, error: "eliza_tts_synthesize requires bundleDir")
}
guard let text = stringValue(payload, "text"), !text.isEmpty else {
return encodeHostEnvelope(ok: false, error: "eliza_tts_synthesize requires text")
}
let speakerPresetId = stringValue(payload, "speakerPresetId")
?? stringValue(payload, "speaker_preset_id")
?? stringValue(payload, "voice")
?? stringValue(payload, "voiceId")
let maxSamples = intValue(payload, "maxSamples")
?? intValue(payload, "max_samples")
?? 24_000 * 60
let result = LlamaBridgeImpl.shared.synthesizeSpeech(
bundleDir: bundleDir,
text: text,
speakerPresetId: speakerPresetId,
maxSamples: maxSamples
)
if let error = result.error {
return encodeHostEnvelope(ok: false, error: error)
}
var payload: [String: Any] = [
"contentType": result.contentType,
"sampleRate": NSNumber(value: result.sampleRate),
"samples": NSNumber(value: result.samples),
"durationMs": NSNumber(value: result.durationMs),
]
if let audioFilePath = result.audioFilePath, !audioFilePath.isEmpty {
payload["audioFilePath"] = audioFilePath
} else {
payload["audioBase64"] = result.audioBase64
}
return encodeHostEnvelope(ok: true, result: payload)
}
/// Wire format: `pcm` is mono fp32 audio in [-1, 1] carried as a JSON number
/// array (no base64). `bridge.ts` encodes the same way. `sampleRate` is the
/// source rate in Hz; the inference slice resamples internally as needed.
private func handleAsrTranscribe(_ payload: [String: Any]) -> String {
guard let bundleDir = stringValue(payload, "bundleDir")
?? stringValue(payload, "bundle_dir"),
!bundleDir.isEmpty else {
return encodeHostEnvelope(ok: false, error: "eliza_asr_transcribe requires bundleDir")
}
guard let pcm = floatArrayValue(payload, "pcm"), !pcm.isEmpty else {
return encodeHostEnvelope(ok: false, error: "eliza_asr_transcribe requires pcm")
}
let sampleRate = intValue(payload, "sampleRate")
?? intValue(payload, "sample_rate")
?? 16_000
let result = LlamaBridgeImpl.shared.transcribeSpeech(
bundleDir: bundleDir,
pcm: pcm,
sampleRate: sampleRate
)
if let error = result.error {
return encodeHostEnvelope(ok: false, error: error)
}
return encodeHostEnvelope(ok: true, result: [
"text": result.text,
"durationMs": NSNumber(value: result.durationMs),
])
}
private func decodeHostPayload(_ json: String) throws -> [String: Any] {
guard let data = json.data(using: .utf8) else { return [:] }
let value = try JSONSerialization.jsonObject(with: data)
return value as? [String: Any] ?? [:]
}
private func encodeHostEnvelope(
ok: Bool,
result: Any? = nil,
error: String? = nil
) -> String {
var object: [String: Any] = ["ok": ok]
if let result {
object["result"] = result
} else if ok {
object["result"] = NSNull()
}
if let error {
object["error"] = error
}
guard JSONSerialization.isValidJSONObject(object),
let data = try? JSONSerialization.data(withJSONObject: object),
let json = String(data: data, encoding: .utf8) else {
let fallback = (error ?? "Failed to encode native host response")
.replacingOccurrences(of: "\"", with: "\\\"")
return "{\"ok\":false,\"error\":\"\(fallback)\"}"
}
return json
}
private func stringValue(_ payload: [String: Any], _ key: String) -> String? {
payload[key] as? String
}
private func boolValue(_ payload: [String: Any], _ key: String) -> Bool? {
if let value = payload[key] as? Bool { return value }
if let value = payload[key] as? NSNumber { return value.boolValue }
if let value = payload[key] as? String {
if value == "true" || value == "1" { return true }
if value == "false" || value == "0" { return false }
}
return nil
}
private func int32Value(_ payload: [String: Any], _ key: String) -> Int32? {
if let value = payload[key] as? NSNumber { return value.int32Value }
if let value = payload[key] as? String, let parsed = Int32(value) { return parsed }
return nil
}
private func intValue(_ payload: [String: Any], _ key: String) -> Int? {
if let value = payload[key] as? NSNumber { return value.intValue }
if let value = payload[key] as? String, let parsed = Int(value) { return parsed }
return nil
}
private func int64Value(_ payload: [String: Any], _ key: String) -> Int64? {
if let value = payload[key] as? NSNumber { return value.int64Value }
if let value = payload[key] as? String, let parsed = Int64(value) { return parsed }
return nil
}
private func uint32Value(_ payload: [String: Any], _ key: String) -> UInt32? {
if let value = payload[key] as? NSNumber { return value.uint32Value }
if let value = payload[key] as? String, let parsed = UInt32(value) { return parsed }
return nil
}
private func floatValue(_ payload: [String: Any], _ key: String) -> Float? {
if let value = payload[key] as? NSNumber { return value.floatValue }
if let value = payload[key] as? String, let parsed = Float(value) { return parsed }
return nil
}
private func stringMapValue(_ payload: [String: Any], _ key: String) -> [String: String]? {
guard let raw = payload[key] as? [String: Any] else { return nil }
var out: [String: String] = [:]
for (mapKey, value) in raw {
if let string = value as? String {
out[mapKey] = string
} else if let number = value as? NSNumber {
out[mapKey] = number.stringValue
}
}
return out
}
private func stringArrayValue(_ payload: [String: Any], _ key: String) -> [String]? {
if let values = payload[key] as? [String] { return values }
if let values = payload[key] as? [Any] {
return values.compactMap { $0 as? String }
}
return nil
}
private func floatArrayValue(_ payload: [String: Any], _ key: String) -> [Float]? {
if let values = payload[key] as? [NSNumber] {
return values.map { $0.floatValue }
}
if let values = payload[key] as? [Any] {
return values.compactMap { ($0 as? NSNumber)?.floatValue }
}
return nil
}
}