@independo/capacitor-inderun 1.0.1-dev.1 → 1.1.0-dev.10
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/Package.swift +19 -2
- package/README.md +190 -13
- package/android/build.gradle.kts +12 -6
- package/android/settings.gradle +7 -0
- package/android/src/main/kotlin/app/independo/inderun/capacitor/IndeRunCapacitorPlugin.kt +154 -0
- package/android/src/main/kotlin/app/independo/inderun/capacitor/IndeRunSerializer.kt +203 -1
- package/android/src/main/kotlin/app/independo/inderun/capacitor/IndeRunStreamRegistry.kt +118 -0
- package/android/src/test/kotlin/app/independo/inderun/capacitor/IndeRunSerializerCapabilitiesTest.kt +253 -0
- package/android/src/test/kotlin/app/independo/inderun/capacitor/IndeRunSerializerStreamTest.kt +226 -0
- package/android/src/test/kotlin/app/independo/inderun/capacitor/IndeRunSerializerTest.kt +79 -0
- package/android/src/test/kotlin/app/independo/inderun/capacitor/IndeRunStreamRegistryTest.kt +163 -0
- package/dist/definitions.d.ts +175 -1
- package/dist/definitions.d.ts.map +1 -1
- package/dist/errors.d.ts +19 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +36 -0
- package/dist/index.d.ts +17 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +44 -13
- package/dist/streaming.d.ts +17 -0
- package/dist/streaming.d.ts.map +1 -0
- package/dist/streaming.js +303 -0
- package/dist/web.d.ts +18 -2
- package/dist/web.d.ts.map +1 -1
- package/dist/web.js +136 -7
- package/ios/Sources/IndeRunCapacitorPlugin/IndeRunCapacitorBridge.swift +181 -0
- package/ios/Sources/IndeRunCapacitorPlugin/IndeRunCapacitorPlugin.swift +93 -3
- package/ios/Sources/IndeRunCapacitorPlugin/IndeRunCapacitorStreamRegistry.swift +121 -0
- package/ios/Tests/IndeRunCapacitorTests/IndeRunCapacitorBridgeTests.swift +234 -0
- package/ios/Tests/IndeRunCapacitorTests/IndeRunCapacitorStreamCodecTests.swift +178 -0
- package/ios/Tests/IndeRunCapacitorTests/IndeRunCapacitorStreamPumpTests.swift +215 -0
- package/ios/Tests/IndeRunCapacitorTests/IndeRunCapacitorStreamRegistryTests.swift +154 -0
- package/package.json +21 -9
package/dist/web.js
CHANGED
|
@@ -1,15 +1,36 @@
|
|
|
1
1
|
import { WebPlugin } from "@capacitor/core";
|
|
2
2
|
import { createIndeRunWeb, createUnavailable, toIndeRunException } from "@independo/inderun-web";
|
|
3
|
+
import { STREAM_ERROR_NAME, STREAM_EVENT_NAME } from "./streaming.js";
|
|
3
4
|
export class IndeRunWeb extends WebPlugin {
|
|
4
5
|
engine = null;
|
|
6
|
+
// Deliberately the bridge's StreamRun, not the web SDK's: the engine's run is
|
|
7
|
+
// assigned straight into this map, so the two shapes staying identical is checked
|
|
8
|
+
// by the compiler rather than by convention.
|
|
9
|
+
activeStreams = new Map();
|
|
10
|
+
startingStreams = new Set();
|
|
11
|
+
pendingCancels = new Map();
|
|
5
12
|
async configure(options) {
|
|
6
|
-
|
|
7
|
-
|
|
13
|
+
// The web SDK requires at least one provider, not specifically the cloud one. Held
|
|
14
|
+
// here as well as there so the failure is an `Unavailable` contract error rather than
|
|
15
|
+
// a raw SDK throw, which is what the facade's callers expect.
|
|
16
|
+
if (!options?.openAI && !options?.systemModel && !options?.onnx) {
|
|
17
|
+
throw createUnavailable("Capacitor web execution requires at least one provider registration. Configure with openAI, systemModel and/or onnx bootstrap options before calling run(request).").toContractError();
|
|
8
18
|
}
|
|
9
19
|
try {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
20
|
+
// The SDK's own option type, not a hand-written copy of it: an upstream rename
|
|
21
|
+
// then fails at the type level instead of silently dropping a field. This is
|
|
22
|
+
// deliberately the *outbound* shape only — `ConfigureOptions` stays a narrower,
|
|
23
|
+
// JSON-serializable subset, because it has to cross the native bridge hop.
|
|
24
|
+
const webOptions = {};
|
|
25
|
+
if (options.openAI !== undefined) {
|
|
26
|
+
webOptions.openAI = compactOpenAIOptions(options.openAI);
|
|
27
|
+
}
|
|
28
|
+
if (options.systemModel !== undefined) {
|
|
29
|
+
webOptions.systemModel = compactSystemModelOptions(options.systemModel);
|
|
30
|
+
}
|
|
31
|
+
if (options.onnx !== undefined) {
|
|
32
|
+
webOptions.onnx = compactOnnxOptions(options.onnx);
|
|
33
|
+
}
|
|
13
34
|
if (options.allowDirectOpenAIEndpoint !== undefined) {
|
|
14
35
|
webOptions.allowDirectOpenAIEndpoint = options.allowDirectOpenAIEndpoint;
|
|
15
36
|
}
|
|
@@ -30,9 +51,95 @@ export class IndeRunWeb extends WebPlugin {
|
|
|
30
51
|
throw toIndeRunException(error).toContractError();
|
|
31
52
|
}
|
|
32
53
|
}
|
|
54
|
+
async checkCapabilities() {
|
|
55
|
+
if (!this.engine) {
|
|
56
|
+
throw createUnavailable("Capacitor IndeRun has not been configured. Configure providers before calling checkCapabilities().").toContractError();
|
|
57
|
+
}
|
|
58
|
+
try {
|
|
59
|
+
// Deliberately uncast: the engine's snapshots are assigned straight into the
|
|
60
|
+
// bridge's own `ProviderCapabilitySnapshot`, so the two declarations staying
|
|
61
|
+
// identical is a compile error rather than a convention. See definitions.ts.
|
|
62
|
+
return { providers: await this.engine.checkCapabilities() };
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
throw toIndeRunException(error).toContractError();
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* The web path deliberately round-trips through `notifyListeners` rather than
|
|
70
|
+
* handing the engine's `StreamRun` straight back.
|
|
71
|
+
*
|
|
72
|
+
* Short-circuiting would require the facade to branch on the platform and would
|
|
73
|
+
* leave two reassembly paths whose equivalence is only a convention — while this
|
|
74
|
+
* way there is exactly one, exercised on every platform. It also equalizes the
|
|
75
|
+
* loss of generator backpressure that the native paths have regardless.
|
|
76
|
+
*/
|
|
77
|
+
async startStream(options) {
|
|
78
|
+
if (!this.engine) {
|
|
79
|
+
throw createUnavailable("Capacitor IndeRun has not been configured. Configure providers before calling stream(request).").toContractError();
|
|
80
|
+
}
|
|
81
|
+
this.startingStreams.add(options.streamId);
|
|
82
|
+
let run;
|
|
83
|
+
try {
|
|
84
|
+
run = await this.engine.stream(options.request);
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
this.startingStreams.delete(options.streamId);
|
|
88
|
+
this.pendingCancels.delete(options.streamId);
|
|
89
|
+
throw toIndeRunException(error).toContractError();
|
|
90
|
+
}
|
|
91
|
+
this.startingStreams.delete(options.streamId);
|
|
92
|
+
this.activeStreams.set(options.streamId, run);
|
|
93
|
+
if (this.pendingCancels.has(options.streamId)) {
|
|
94
|
+
const reason = this.pendingCancels.get(options.streamId);
|
|
95
|
+
this.pendingCancels.delete(options.streamId);
|
|
96
|
+
run.cancel(reason);
|
|
97
|
+
}
|
|
98
|
+
// Pump on a later microtask so this call's handle settles first.
|
|
99
|
+
queueMicrotask(() => void this.pump(options.streamId, run));
|
|
100
|
+
return run.handle;
|
|
101
|
+
}
|
|
102
|
+
async cancelStream(options) {
|
|
103
|
+
const run = this.activeStreams.get(options.streamId);
|
|
104
|
+
if (run !== undefined) {
|
|
105
|
+
// Never abandon the generator: the engine must stay free to emit its one
|
|
106
|
+
// `cancelled` terminal event.
|
|
107
|
+
run.cancel(options.reason);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
if (this.startingStreams.has(options.streamId)) {
|
|
111
|
+
// Cancel raced startStream's route selection; applied once the run attaches.
|
|
112
|
+
this.pendingCancels.set(options.streamId, options.reason);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
// Unknown or already-finished run: cancelling after the terminal is a no-op,
|
|
116
|
+
// not an error.
|
|
117
|
+
}
|
|
118
|
+
async pump(streamId, run) {
|
|
119
|
+
try {
|
|
120
|
+
for await (const event of run.events) {
|
|
121
|
+
this.notifyListeners(STREAM_EVENT_NAME, { streamId, event }, true);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
catch (error) {
|
|
125
|
+
this.notifyListeners(STREAM_ERROR_NAME, { streamId, error: toIndeRunException(error).toContractError() }, true);
|
|
126
|
+
}
|
|
127
|
+
finally {
|
|
128
|
+
this.activeStreams.delete(streamId);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
33
131
|
}
|
|
34
|
-
|
|
35
|
-
|
|
132
|
+
/**
|
|
133
|
+
* Assigns only the fields the caller actually set. `exactOptionalPropertyTypes` makes
|
|
134
|
+
* this incremental style mandatory rather than cosmetic: assigning `undefined` to an
|
|
135
|
+
* optional property is an error, and an explicit `undefined` on the wire is not the same
|
|
136
|
+
* thing as an absent key to the provider that reads it.
|
|
137
|
+
*
|
|
138
|
+
* `id`, `healthCheckTimeoutMs` and `healthCheckCacheMs` are deliberately not bridged —
|
|
139
|
+
* all optional, and the bridge has no reason to expose provider-tuning knobs it cannot
|
|
140
|
+
* also offer on native.
|
|
141
|
+
*/
|
|
142
|
+
function compactOpenAIOptions(openAI) {
|
|
36
143
|
const result = {
|
|
37
144
|
model: openAI.model
|
|
38
145
|
};
|
|
@@ -50,3 +157,25 @@ function compactOpenAIOptions(options) {
|
|
|
50
157
|
}
|
|
51
158
|
return result;
|
|
52
159
|
}
|
|
160
|
+
function compactSystemModelOptions(systemModel) {
|
|
161
|
+
const result = {};
|
|
162
|
+
if (systemModel.id !== undefined) {
|
|
163
|
+
result.id = systemModel.id;
|
|
164
|
+
}
|
|
165
|
+
if (systemModel.timeoutMs !== undefined) {
|
|
166
|
+
result.timeoutMs = systemModel.timeoutMs;
|
|
167
|
+
}
|
|
168
|
+
return result;
|
|
169
|
+
}
|
|
170
|
+
function compactOnnxOptions(onnx) {
|
|
171
|
+
const result = {
|
|
172
|
+
modelPackage: onnx.modelPackage
|
|
173
|
+
};
|
|
174
|
+
if (onnx.id !== undefined) {
|
|
175
|
+
result.id = onnx.id;
|
|
176
|
+
}
|
|
177
|
+
if (onnx.timeoutMs !== undefined) {
|
|
178
|
+
result.timeoutMs = onnx.timeoutMs;
|
|
179
|
+
}
|
|
180
|
+
return result;
|
|
181
|
+
}
|
|
@@ -36,9 +36,49 @@ struct CapacitorRunOptions: Codable {
|
|
|
36
36
|
let allowDirectOpenAIEndpoint: Bool? // web-only, no-op on native
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
/// Unlike `run(request)`, which takes the request at the options root, `startStream`
|
|
40
|
+
/// nests it under `request` so the envelope can also carry the bridge-local
|
|
41
|
+
/// `streamId`. See `StartStreamOptions` in src/definitions.ts.
|
|
42
|
+
struct CapacitorStartStreamOptions: Codable {
|
|
43
|
+
let streamId: String
|
|
44
|
+
let request: TaskRequest
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
struct CapacitorCancelStreamOptions: Codable {
|
|
48
|
+
let streamId: String
|
|
49
|
+
let reason: String?
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/// IndeRunCore's `ProviderCapabilitySnapshot` is `Sendable` but not `Codable`, even
|
|
53
|
+
/// though both of its members are. This mirror exists only to get it through
|
|
54
|
+
/// `JSONEncoder`; see the upstream follow-up asking for `Codable` (and a schema) on
|
|
55
|
+
/// the snapshot itself.
|
|
56
|
+
private struct EncodableCapabilitySnapshot: Encodable {
|
|
57
|
+
let providerId: String
|
|
58
|
+
let descriptor: ProviderDescriptor
|
|
59
|
+
let capabilities: ProviderDynamicCapabilities
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/// A Capacitor plugin method must resolve an object, never a top-level array, so the
|
|
63
|
+
/// snapshots travel wrapped. `IndeRunCapacitor` unwraps them on the JS side.
|
|
64
|
+
private struct CapabilitiesEnvelope: Encodable {
|
|
65
|
+
let providers: [EncodableCapabilitySnapshot]
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/// Called with an already-encoded event/error for one run. The plugin turns these
|
|
69
|
+
/// into `notifyListeners` calls; taking them as closures keeps the pump testable
|
|
70
|
+
/// without Capacitor.
|
|
71
|
+
typealias StreamEventSink = @Sendable (String, JSObject) -> Void
|
|
72
|
+
typealias StreamErrorSink = @Sendable (String, JSObject) -> Void
|
|
73
|
+
|
|
39
74
|
final class IndeRunCapacitorBridge {
|
|
40
75
|
private var configuredRegistry: ProviderRegistry?
|
|
41
76
|
private var configuredHostServices: HostServices?
|
|
77
|
+
let streams = StreamRegistry()
|
|
78
|
+
|
|
79
|
+
deinit {
|
|
80
|
+
streams.cancelAll(reason: "Capacitor bridge deallocated.")
|
|
81
|
+
}
|
|
42
82
|
|
|
43
83
|
func configure(options: JSObject) throws {
|
|
44
84
|
let runOptions = try decodeConfigureOptions(from: options)
|
|
@@ -58,10 +98,125 @@ final class IndeRunCapacitorBridge {
|
|
|
58
98
|
return try encode(result)
|
|
59
99
|
}
|
|
60
100
|
|
|
101
|
+
func checkCapabilities() async throws -> JSObject {
|
|
102
|
+
guard let registry = configuredRegistry, let hostServices = configuredHostServices else {
|
|
103
|
+
throw createUnavailable(message: "Capacitor IndeRun has not been configured. Configure providers before calling checkCapabilities().")
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// IndeRun is a stateless coordinator; new per call is intentional — registry is cached above.
|
|
107
|
+
let engine = IndeRun(registry: registry, hostServices: hostServices)
|
|
108
|
+
return try encode(capabilities: await engine.checkCapabilities())
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
func startStream(
|
|
112
|
+
options: JSObject,
|
|
113
|
+
onEvent: @escaping StreamEventSink,
|
|
114
|
+
onError: @escaping StreamErrorSink
|
|
115
|
+
) async throws -> JSObject {
|
|
116
|
+
guard let registry = configuredRegistry, let hostServices = configuredHostServices else {
|
|
117
|
+
throw createUnavailable(message: "Capacitor IndeRun has not been configured. Configure providers before calling stream(request).")
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
let start = try decodeStartStreamOptions(from: options)
|
|
121
|
+
// Reserved before the engine is reached, so a cancel arriving during route
|
|
122
|
+
// selection is recorded rather than dropped as an unknown id.
|
|
123
|
+
streams.open(streamId: start.streamId)
|
|
124
|
+
|
|
125
|
+
let run: StreamRun
|
|
126
|
+
do {
|
|
127
|
+
// IndeRun is a stateless coordinator; new per call is intentional — registry is cached above.
|
|
128
|
+
let engine = IndeRun(registry: registry, hostServices: hostServices)
|
|
129
|
+
run = try await engine.stream(request: start.request)
|
|
130
|
+
} catch {
|
|
131
|
+
streams.finish(streamId: start.streamId)
|
|
132
|
+
throw error
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if case .cancelRequested(let reason) = streams.attach(streamId: start.streamId, run: run) {
|
|
136
|
+
run.cancel(reason: reason)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
let streamId = start.streamId
|
|
140
|
+
// Weak, so a live run cannot keep the bridge alive past the plugin's deinit;
|
|
141
|
+
// the plugin's teardown cancels these tasks, which then release the bridge.
|
|
142
|
+
let task = Task { [weak self] in
|
|
143
|
+
guard let self else { return }
|
|
144
|
+
await self.pump(streamId: streamId, run: run, onEvent: onEvent, onError: onError)
|
|
145
|
+
}
|
|
146
|
+
streams.store(task: task, for: streamId)
|
|
147
|
+
|
|
148
|
+
return try encode(handle: run.handle)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
func cancelStream(options: JSObject) throws {
|
|
152
|
+
let cancel = try decodeCancelStreamOptions(from: options)
|
|
153
|
+
streams.requestCancel(streamId: cancel.streamId, reason: cancel.reason)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
func teardownStreams() {
|
|
157
|
+
streams.cancelAll(reason: "Capacitor plugin torn down.")
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/// Forwards one run's canonical events to the sink until the stream ends.
|
|
161
|
+
///
|
|
162
|
+
/// A provider failure has already become a terminal `error` event by the time it
|
|
163
|
+
/// reaches here — the engine's Event Gate owns that. Anything thrown out of the
|
|
164
|
+
/// sequence is a failure of this bridge, and is reported as one.
|
|
165
|
+
func pump(
|
|
166
|
+
streamId: String,
|
|
167
|
+
run: StreamRun,
|
|
168
|
+
onEvent: @escaping StreamEventSink,
|
|
169
|
+
onError: @escaping StreamErrorSink
|
|
170
|
+
) async {
|
|
171
|
+
do {
|
|
172
|
+
for try await event in run.events {
|
|
173
|
+
onEvent(streamId, try encode(streamEvent: event))
|
|
174
|
+
}
|
|
175
|
+
} catch is CancellationError {
|
|
176
|
+
// Our own task cancellation, from teardown. The webview is going away and
|
|
177
|
+
// no one is left to receive a terminal event; not a bridge fault.
|
|
178
|
+
} catch {
|
|
179
|
+
let contractError = toIndeRunException(error).toContractError()
|
|
180
|
+
if let encoded = try? encode(error: contractError) {
|
|
181
|
+
onError(streamId, encoded)
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
streams.finish(streamId: streamId)
|
|
185
|
+
}
|
|
186
|
+
|
|
61
187
|
func encode(error: IndeRunError) throws -> JSObject {
|
|
62
188
|
try encodeObject(error)
|
|
63
189
|
}
|
|
64
190
|
|
|
191
|
+
// JSONEncoder omits nil optionals, so "emit only the fields this event actually
|
|
192
|
+
// has" — which is what reconstitutes the right union branch on the JS side —
|
|
193
|
+
// comes for free from the generated Codable conformances.
|
|
194
|
+
func encode(streamEvent: StreamEvent) throws -> JSObject {
|
|
195
|
+
try encodeObject(streamEvent)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
func encode(handle: StreamRunHandle) throws -> JSObject {
|
|
199
|
+
try encodeObject(handle)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/// The descriptor's enums carry explicit raw values (`in_process`, not `inProcess`),
|
|
203
|
+
/// and JSONEncoder omits nil optionals — so an unset `streamingAvailable` arrives as
|
|
204
|
+
/// an absent key rather than a null, which is what lets the JS side read absence as
|
|
205
|
+
/// "inherit `descriptor.supports.streaming`".
|
|
206
|
+
func encode(capabilities: [ProviderCapabilitySnapshot]) throws -> JSObject {
|
|
207
|
+
try encodeObject(
|
|
208
|
+
CapabilitiesEnvelope(
|
|
209
|
+
providers: capabilities.map {
|
|
210
|
+
EncodableCapabilitySnapshot(
|
|
211
|
+
providerId: $0.providerId,
|
|
212
|
+
descriptor: $0.descriptor,
|
|
213
|
+
capabilities: $0.capabilities
|
|
214
|
+
)
|
|
215
|
+
}
|
|
216
|
+
)
|
|
217
|
+
)
|
|
218
|
+
}
|
|
219
|
+
|
|
65
220
|
private func makeRegistry(openAI: OpenAIProviderBootstrapOptions?) throws -> ProviderRegistry {
|
|
66
221
|
let registry = ProviderRegistry()
|
|
67
222
|
try registry.register(AppleFoundationModelsProvider())
|
|
@@ -89,6 +244,16 @@ final class IndeRunCapacitorBridge {
|
|
|
89
244
|
return try JSONDecoder().decode(CapacitorRunOptions.self, from: data)
|
|
90
245
|
}
|
|
91
246
|
|
|
247
|
+
private func decodeStartStreamOptions(from object: JSObject) throws -> CapacitorStartStreamOptions {
|
|
248
|
+
let data = try JSONSerialization.data(withJSONObject: object, options: [])
|
|
249
|
+
return try JSONDecoder().decode(CapacitorStartStreamOptions.self, from: data)
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
private func decodeCancelStreamOptions(from object: JSObject) throws -> CapacitorCancelStreamOptions {
|
|
253
|
+
let data = try JSONSerialization.data(withJSONObject: object, options: [])
|
|
254
|
+
return try JSONDecoder().decode(CapacitorCancelStreamOptions.self, from: data)
|
|
255
|
+
}
|
|
256
|
+
|
|
92
257
|
private func decodeRequest(from object: JSObject) throws -> TaskRequest {
|
|
93
258
|
let data = try JSONSerialization.data(withJSONObject: object, options: [])
|
|
94
259
|
return try JSONDecoder().decode(TaskRequest.self, from: data)
|
|
@@ -101,10 +266,26 @@ final class IndeRunCapacitorBridge {
|
|
|
101
266
|
private func encodeObject<T: Encodable>(_ value: T) throws -> JSObject {
|
|
102
267
|
let data = try JSONEncoder().encode(value)
|
|
103
268
|
let object = try JSONSerialization.jsonObject(with: data, options: [])
|
|
269
|
+
|
|
270
|
+
#if canImport(Capacitor)
|
|
271
|
+
// Capacitor's JSObject is [String: any JSValue], and a plain `as?` cast cannot
|
|
272
|
+
// produce one: JSONSerialization hands back NSDictionary/NSArray values, which do
|
|
273
|
+
// not conform to JSValue, so anything with a nested object or array — a TaskResult's
|
|
274
|
+
// `output`, a stream event's `payload`, the providers array — failed the cast and
|
|
275
|
+
// surfaced as "Capacitor bridge failed to encode a JSON object". JSTypes coerces the
|
|
276
|
+
// tree recursively, which is what the cast was standing in for.
|
|
277
|
+
guard let dictionary = object as? [AnyHashable: Any],
|
|
278
|
+
let coerced = JSTypes.coerceDictionaryToJSObject(dictionary) else {
|
|
279
|
+
throw createInternal(message: "Capacitor bridge failed to encode a JSON object.")
|
|
280
|
+
}
|
|
281
|
+
return coerced
|
|
282
|
+
#else
|
|
283
|
+
// Standalone builds alias JSObject to [String: Any], where the cast is exact.
|
|
104
284
|
guard let dictionary = object as? JSObject else {
|
|
105
285
|
throw createInternal(message: "Capacitor bridge failed to encode a JSON object.")
|
|
106
286
|
}
|
|
107
287
|
return dictionary
|
|
288
|
+
#endif
|
|
108
289
|
}
|
|
109
290
|
|
|
110
291
|
func mapAuthMode(_ value: String?) -> OpenAIAuthMode {
|
|
@@ -9,14 +9,24 @@ public final class IndeRunCapacitorPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
9
9
|
public let jsName = "IndeRunCapacitor"
|
|
10
10
|
public let pluginMethods: [CAPPluginMethod] = [
|
|
11
11
|
CAPPluginMethod(name: "configure", returnType: CAPPluginReturnPromise),
|
|
12
|
-
CAPPluginMethod(name: "run", returnType: CAPPluginReturnPromise)
|
|
12
|
+
CAPPluginMethod(name: "run", returnType: CAPPluginReturnPromise),
|
|
13
|
+
CAPPluginMethod(name: "checkCapabilities", returnType: CAPPluginReturnPromise),
|
|
14
|
+
CAPPluginMethod(name: "startStream", returnType: CAPPluginReturnPromise),
|
|
15
|
+
CAPPluginMethod(name: "cancelStream", returnType: CAPPluginReturnPromise)
|
|
13
16
|
]
|
|
14
17
|
|
|
15
18
|
private let implementation = IndeRunCapacitorBridge()
|
|
16
19
|
|
|
20
|
+
/// CAPPlugin has no teardown hook — `load()` has no counterpart — so deinit is
|
|
21
|
+
/// the only place left to stop runs the webview can no longer receive. The pump
|
|
22
|
+
/// tasks hold `self` weakly precisely so this can run.
|
|
23
|
+
deinit {
|
|
24
|
+
implementation.teardownStreams()
|
|
25
|
+
}
|
|
26
|
+
|
|
17
27
|
@objc func configure(_ call: CAPPluginCall) {
|
|
18
28
|
do {
|
|
19
|
-
try implementation.configure(options: call.
|
|
29
|
+
try implementation.configure(options: call.jsObjectRepresentation)
|
|
20
30
|
call.resolve()
|
|
21
31
|
} catch let error as IndeRunException {
|
|
22
32
|
let contractError = error.toContractError()
|
|
@@ -33,7 +43,7 @@ public final class IndeRunCapacitorPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
33
43
|
@objc func run(_ call: CAPPluginCall) {
|
|
34
44
|
Task {
|
|
35
45
|
do {
|
|
36
|
-
let result = try await implementation.run(requestObject: call.
|
|
46
|
+
let result = try await implementation.run(requestObject: call.jsObjectRepresentation)
|
|
37
47
|
call.resolve(result)
|
|
38
48
|
} catch let error as IndeRunException {
|
|
39
49
|
let contractError = error.toContractError()
|
|
@@ -47,5 +57,85 @@ public final class IndeRunCapacitorPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
47
57
|
}
|
|
48
58
|
}
|
|
49
59
|
}
|
|
60
|
+
|
|
61
|
+
/// Reports every registered provider's static declaration and live availability
|
|
62
|
+
/// without executing a task. Availability changes between calls — a local model can
|
|
63
|
+
/// unload, cloud credentials can expire — so callers must not cache it across a run.
|
|
64
|
+
@objc func checkCapabilities(_ call: CAPPluginCall) {
|
|
65
|
+
Task {
|
|
66
|
+
do {
|
|
67
|
+
call.resolve(try await implementation.checkCapabilities())
|
|
68
|
+
} catch let error as IndeRunException {
|
|
69
|
+
let contractError = error.toContractError()
|
|
70
|
+
let details = try? implementation.encode(error: contractError)
|
|
71
|
+
call.reject(contractError.message, contractError.errorClass.rawValue, error, details)
|
|
72
|
+
} catch {
|
|
73
|
+
let normalized = toIndeRunException(error)
|
|
74
|
+
let contractError = normalized.toContractError()
|
|
75
|
+
let details = try? implementation.encode(error: contractError)
|
|
76
|
+
call.reject(contractError.message, contractError.errorClass.rawValue, normalized, details)
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/// Resolves with the run handle. Only validation and route-selection failures
|
|
82
|
+
/// reject here; a provider failure, a cancellation, or completion all arrive as
|
|
83
|
+
/// the single terminal event on `indeRunStreamEvent`.
|
|
84
|
+
///
|
|
85
|
+
/// `retainUntilConsumed` closes the listener-registration race from the native
|
|
86
|
+
/// side: an event emitted before the JS listener is attached is retained and
|
|
87
|
+
/// replayed rather than lost.
|
|
88
|
+
@objc func startStream(_ call: CAPPluginCall) {
|
|
89
|
+
Task { [weak self] in
|
|
90
|
+
guard let self else { return }
|
|
91
|
+
do {
|
|
92
|
+
let handle = try await self.implementation.startStream(
|
|
93
|
+
options: call.jsObjectRepresentation,
|
|
94
|
+
onEvent: { [weak self] streamId, event in
|
|
95
|
+
self?.notifyListeners(
|
|
96
|
+
"indeRunStreamEvent",
|
|
97
|
+
data: ["streamId": streamId, "event": event],
|
|
98
|
+
retainUntilConsumed: true
|
|
99
|
+
)
|
|
100
|
+
},
|
|
101
|
+
onError: { [weak self] streamId, error in
|
|
102
|
+
self?.notifyListeners(
|
|
103
|
+
"indeRunStreamError",
|
|
104
|
+
data: ["streamId": streamId, "error": error],
|
|
105
|
+
retainUntilConsumed: true
|
|
106
|
+
)
|
|
107
|
+
}
|
|
108
|
+
)
|
|
109
|
+
call.resolve(handle)
|
|
110
|
+
} catch let error as IndeRunException {
|
|
111
|
+
let contractError = error.toContractError()
|
|
112
|
+
let details = try? self.implementation.encode(error: contractError)
|
|
113
|
+
call.reject(contractError.message, contractError.errorClass.rawValue, error, details)
|
|
114
|
+
} catch {
|
|
115
|
+
let normalized = toIndeRunException(error)
|
|
116
|
+
let contractError = normalized.toContractError()
|
|
117
|
+
let details = try? self.implementation.encode(error: contractError)
|
|
118
|
+
call.reject(contractError.message, contractError.errorClass.rawValue, normalized, details)
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/// Resolves for an unknown or already-finished run: cancelling after the terminal
|
|
124
|
+
/// is a no-op by contract, not an error.
|
|
125
|
+
@objc func cancelStream(_ call: CAPPluginCall) {
|
|
126
|
+
do {
|
|
127
|
+
try implementation.cancelStream(options: call.jsObjectRepresentation)
|
|
128
|
+
call.resolve()
|
|
129
|
+
} catch let error as IndeRunException {
|
|
130
|
+
let contractError = error.toContractError()
|
|
131
|
+
let details = try? implementation.encode(error: contractError)
|
|
132
|
+
call.reject(contractError.message, contractError.errorClass.rawValue, error, details)
|
|
133
|
+
} catch {
|
|
134
|
+
let normalized = toIndeRunException(error)
|
|
135
|
+
let contractError = normalized.toContractError()
|
|
136
|
+
let details = try? implementation.encode(error: contractError)
|
|
137
|
+
call.reject(contractError.message, contractError.errorClass.rawValue, normalized, details)
|
|
138
|
+
}
|
|
139
|
+
}
|
|
50
140
|
}
|
|
51
141
|
#endif
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import IndeRunCore
|
|
3
|
+
|
|
4
|
+
/// Tracks the streaming runs the bridge is currently pumping, keyed by the
|
|
5
|
+
/// bridge-local `streamId`.
|
|
6
|
+
///
|
|
7
|
+
/// Two things make this more than a dictionary. A cancel can arrive before the run
|
|
8
|
+
/// it refers to exists — `startStream` has to reach route selection before there is
|
|
9
|
+
/// anything to cancel — so a cancel requested in that window is recorded and applied
|
|
10
|
+
/// on attach. And cancelling a run is *not* cancelling the pump task: the engine
|
|
11
|
+
/// answers a cancel with its one `cancelled` terminal event, which the pump still has
|
|
12
|
+
/// to deliver. Task cancellation is reserved for teardown, where the webview is going
|
|
13
|
+
/// away and nobody is left to receive a terminal.
|
|
14
|
+
///
|
|
15
|
+
/// Deliberately free of any Capacitor dependency, so it is exercised by `swift test`
|
|
16
|
+
/// on macOS where `canImport(Capacitor)` is false.
|
|
17
|
+
final class StreamRegistry {
|
|
18
|
+
enum AttachOutcome: Equatable {
|
|
19
|
+
case attached
|
|
20
|
+
/// A cancel arrived before the run did; the caller must apply it.
|
|
21
|
+
case cancelRequested(reason: String?)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
private struct Entry {
|
|
25
|
+
var run: StreamRun?
|
|
26
|
+
var task: Task<Void, Never>?
|
|
27
|
+
var cancelRequested = false
|
|
28
|
+
var cancelReason: String?
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
private let lock = NSLock()
|
|
32
|
+
private var entries: [String: Entry] = [:]
|
|
33
|
+
|
|
34
|
+
var activeCount: Int {
|
|
35
|
+
lock.lock()
|
|
36
|
+
defer { lock.unlock() }
|
|
37
|
+
return entries.count
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/// Reserves the id so a cancel arriving before the run can be recorded rather
|
|
41
|
+
/// than dropped as unknown.
|
|
42
|
+
func open(streamId: String) {
|
|
43
|
+
lock.lock()
|
|
44
|
+
defer { lock.unlock() }
|
|
45
|
+
if entries[streamId] == nil {
|
|
46
|
+
entries[streamId] = Entry()
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
func attach(streamId: String, run: StreamRun) -> AttachOutcome {
|
|
51
|
+
lock.lock()
|
|
52
|
+
guard var entry = entries[streamId] else {
|
|
53
|
+
// Torn down while the engine was still selecting a route.
|
|
54
|
+
lock.unlock()
|
|
55
|
+
return .cancelRequested(reason: nil)
|
|
56
|
+
}
|
|
57
|
+
if entry.cancelRequested {
|
|
58
|
+
let reason = entry.cancelReason
|
|
59
|
+
lock.unlock()
|
|
60
|
+
return .cancelRequested(reason: reason)
|
|
61
|
+
}
|
|
62
|
+
entry.run = run
|
|
63
|
+
entries[streamId] = entry
|
|
64
|
+
lock.unlock()
|
|
65
|
+
return .attached
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
func store(task: Task<Void, Never>, for streamId: String) {
|
|
69
|
+
lock.lock()
|
|
70
|
+
defer { lock.unlock() }
|
|
71
|
+
guard var entry = entries[streamId] else {
|
|
72
|
+
task.cancel()
|
|
73
|
+
return
|
|
74
|
+
}
|
|
75
|
+
entry.task = task
|
|
76
|
+
entries[streamId] = entry
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/// Cancels the run if it has attached, otherwise records the request for attach.
|
|
80
|
+
/// An unknown id — already finished, or never started — is a silent no-op, which
|
|
81
|
+
/// is what makes cancelling after the terminal harmless.
|
|
82
|
+
func requestCancel(streamId: String, reason: String?) {
|
|
83
|
+
lock.lock()
|
|
84
|
+
guard var entry = entries[streamId] else {
|
|
85
|
+
lock.unlock()
|
|
86
|
+
return
|
|
87
|
+
}
|
|
88
|
+
if entry.cancelRequested {
|
|
89
|
+
lock.unlock()
|
|
90
|
+
return
|
|
91
|
+
}
|
|
92
|
+
entry.cancelRequested = true
|
|
93
|
+
entry.cancelReason = reason
|
|
94
|
+
let run = entry.run
|
|
95
|
+
entries[streamId] = entry
|
|
96
|
+
lock.unlock()
|
|
97
|
+
|
|
98
|
+
// Never under the lock: cancel reaches into engine code.
|
|
99
|
+
run?.cancel(reason: reason)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
func finish(streamId: String) {
|
|
103
|
+
lock.lock()
|
|
104
|
+
defer { lock.unlock() }
|
|
105
|
+
entries.removeValue(forKey: streamId)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/// Teardown. Cancels each run first so providers unwind, then the pump tasks,
|
|
109
|
+
/// which at this point have nobody left to deliver to.
|
|
110
|
+
func cancelAll(reason: String?) {
|
|
111
|
+
lock.lock()
|
|
112
|
+
let draining = entries
|
|
113
|
+
entries.removeAll()
|
|
114
|
+
lock.unlock()
|
|
115
|
+
|
|
116
|
+
for (_, entry) in draining {
|
|
117
|
+
entry.run?.cancel(reason: reason)
|
|
118
|
+
entry.task?.cancel()
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|