omp-mobile 0.4.4 → 0.6.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/NOTICE +3 -0
- package/README.md +5 -3
- package/package.json +3 -2
- package/src/attention.mjs +6 -1
- package/src/omp-extension.mjs +88 -34
- package/src/relay-control.mjs +104 -0
- package/src/relay.mjs +368 -0
- package/src/rpc-session.mjs +4 -1
- package/src/server.mjs +174 -18
- package/src/state.mjs +36 -0
- package/src/transcripts.mjs +1 -1
- package/vendor/LICENSE.dsh-remote +21 -0
- package/vendor/README.md +9 -0
- package/vendor/dsh-remote-host.mjs +20109 -0
package/src/relay.mjs
ADDED
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The daemon as a DSH Remote Host: it registers with a relay server (by
|
|
3
|
+
* default the public https://dsh.r2049.cn of ds-harness-remote) through
|
|
4
|
+
* account-less device pairing, and serves its own HTTP + WebSocket API to
|
|
5
|
+
* paired phones through that relay, end-to-end encrypted (Noise IK, pinned
|
|
6
|
+
* keys). The relay only forwards ciphertext; nothing of DSH runs here.
|
|
7
|
+
*
|
|
8
|
+
* Over the encrypted channel the phone speaks a small tunnel (`createTunnelRouter`):
|
|
9
|
+
* rpc `omp.http.open` {id, method, path, headers?, body? (text)} -> {status, headers}
|
|
10
|
+
* rpc `omp.ws.open` {id, path} -> {}
|
|
11
|
+
* rpc `omp.ws.send` {id, text} -> {}
|
|
12
|
+
* rpc `omp.close` {id} -> {}
|
|
13
|
+
* event `omp.data` {id, text? (a text body or WebSocket message part), data? (base64 of a binary body),
|
|
14
|
+
* more? (the WebSocket message continues), done?, error?}
|
|
15
|
+
* Requests go to the daemon's loopback tunnel listener with its token added,
|
|
16
|
+
* so a paired phone gets exactly what a phone on the tailnet gets.
|
|
17
|
+
*/
|
|
18
|
+
import { request as httpRequest } from "node:http";
|
|
19
|
+
import { StringDecoder } from "node:string_decoder";
|
|
20
|
+
import { WebSocket } from "ws";
|
|
21
|
+
import {
|
|
22
|
+
ConnectionController,
|
|
23
|
+
HostPairing,
|
|
24
|
+
HostServerApi,
|
|
25
|
+
HostServerConnection,
|
|
26
|
+
IdentityStore,
|
|
27
|
+
SafeLogger,
|
|
28
|
+
ServerCredentialStore,
|
|
29
|
+
createEvent,
|
|
30
|
+
createRpcError,
|
|
31
|
+
createRpcResponse,
|
|
32
|
+
} from "../vendor/dsh-remote-host.mjs";
|
|
33
|
+
|
|
34
|
+
/** Marks a request that came through the relay tunnel (set by the tunnel, never by the phone). */
|
|
35
|
+
export const RELAY_HEADER = "x-omp-mobile-relay";
|
|
36
|
+
/** Characters per text part and bytes per base64 part (a multiple of 3): well under the 4 MiB secure message cap. */
|
|
37
|
+
const TEXT_CHUNK_CHARS = 1024 * 1024;
|
|
38
|
+
const BINARY_PART_BYTES = 3 * 256 * 1024;
|
|
39
|
+
/** A binary answer (an image) is sent whole: at most this large. */
|
|
40
|
+
const MAX_BINARY_BODY = 32 * 1024 * 1024;
|
|
41
|
+
const MAX_HANDLES = 64;
|
|
42
|
+
const MAX_REQUEST_BODY = 16 * 1024 * 1024;
|
|
43
|
+
const ID = /^[A-Za-z0-9_-]{1,64}$/;
|
|
44
|
+
const METHODS = new Set(["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE"]);
|
|
45
|
+
/** Headers the phone may pass on; the token and the relay mark are the tunnel's own. */
|
|
46
|
+
const PASSED_HEADERS = new Set(["accept", "content-type", "x-omp-mobile-probe"]);
|
|
47
|
+
|
|
48
|
+
class TunnelError extends Error {
|
|
49
|
+
constructor(code, message) {
|
|
50
|
+
super(message);
|
|
51
|
+
this.code = code;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** A daemon path the phone may ask for: absolute, no scheme or host, and never the local `/relay` control. */
|
|
56
|
+
function tunnelPath(value) {
|
|
57
|
+
if (typeof value !== "string" || !value.startsWith("/") || value.startsWith("//") || value.length > 8192 || /[\s\\]/.test(value)) {
|
|
58
|
+
throw new TunnelError("INVALID_MESSAGE", "Invalid path.");
|
|
59
|
+
}
|
|
60
|
+
const url = new URL(value, "http://tunnel");
|
|
61
|
+
if (url.pathname === "/relay" || url.pathname.startsWith("/relay/")) throw new TunnelError("METHOD_NOT_ALLOWED", "Relay control is local only.");
|
|
62
|
+
return url;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* One phone connection's tunnel to the daemon at 127.0.0.1:`port`.
|
|
67
|
+
* @param {{port: number, token: string}} target
|
|
68
|
+
* @param {(message: object) => Promise<void>} send Encrypted delivery to that phone.
|
|
69
|
+
*/
|
|
70
|
+
export function createTunnelRouter(target, send) {
|
|
71
|
+
/** @type {Map<string, {close(): void, socket?: WebSocket}>} */
|
|
72
|
+
const handles = new Map();
|
|
73
|
+
let out = Promise.resolve();
|
|
74
|
+
/** Events go out in order; a failed send (the phone left) ends the handle. */
|
|
75
|
+
const emit = (data) => {
|
|
76
|
+
out = out.then(() => send(createEvent("omp.data", data))).catch(() => handles.get(data.id)?.close());
|
|
77
|
+
return out;
|
|
78
|
+
};
|
|
79
|
+
const claim = (id) => {
|
|
80
|
+
if (typeof id !== "string" || !ID.test(id)) throw new TunnelError("INVALID_MESSAGE", "Invalid handle id.");
|
|
81
|
+
if (handles.has(id)) throw new TunnelError("REQUEST_CONFLICT", "Handle already open.");
|
|
82
|
+
if (handles.size >= MAX_HANDLES) throw new TunnelError("RATE_LIMITED", "Too many open requests.");
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const openHttp = (params) => {
|
|
86
|
+
claim(params.id);
|
|
87
|
+
const { id } = params;
|
|
88
|
+
const method = typeof params.method === "string" ? params.method.toUpperCase() : "GET";
|
|
89
|
+
if (!METHODS.has(method)) throw new TunnelError("INVALID_MESSAGE", "Invalid method.");
|
|
90
|
+
const url = tunnelPath(params.path);
|
|
91
|
+
const headers = { authorization: `Bearer ${target.token}`, [RELAY_HEADER]: "1" };
|
|
92
|
+
for (const [key, value] of Object.entries(params.headers ?? {})) {
|
|
93
|
+
if (PASSED_HEADERS.has(key.toLowerCase()) && typeof value === "string") headers[key.toLowerCase()] = value;
|
|
94
|
+
}
|
|
95
|
+
const body = typeof params.body === "string" ? Buffer.from(params.body, "utf8") : undefined;
|
|
96
|
+
if (body !== undefined && body.length > MAX_REQUEST_BODY) throw new TunnelError("FRAME_TOO_LARGE", "Request body too large.");
|
|
97
|
+
if (body !== undefined) headers["content-length"] = String(body.length);
|
|
98
|
+
return new Promise((resolveOpen, rejectOpen) => {
|
|
99
|
+
let settled = false;
|
|
100
|
+
const request = httpRequest({ host: "127.0.0.1", port: target.port, method, path: `${url.pathname}${url.search}`, headers, agent: false }, (response) => {
|
|
101
|
+
settled = true;
|
|
102
|
+
const type = String(response.headers["content-type"] ?? "");
|
|
103
|
+
resolveOpen({ status: response.statusCode ?? 502, headers: { "content-type": type } });
|
|
104
|
+
let ended = false;
|
|
105
|
+
// Text (JSON, the `/events` stream) goes as text, whole characters only; anything
|
|
106
|
+
// else (an image from `/blobs`) is collected and sent as base64 in 3-byte-aligned parts,
|
|
107
|
+
// so the phone can join the parts without decoding them.
|
|
108
|
+
const text = /^(text\/|application\/json)/.test(type) ? new StringDecoder("utf8") : undefined;
|
|
109
|
+
const bytes = [];
|
|
110
|
+
let total = 0;
|
|
111
|
+
response.on("data", (chunk) => {
|
|
112
|
+
if (text === undefined) {
|
|
113
|
+
total += chunk.length;
|
|
114
|
+
if (total > MAX_BINARY_BODY) request.destroy();
|
|
115
|
+
else bytes.push(chunk);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
const part = text.write(chunk);
|
|
119
|
+
if (part === "") return;
|
|
120
|
+
response.pause();
|
|
121
|
+
let sent = Promise.resolve();
|
|
122
|
+
for (let at = 0; at < part.length; at += TEXT_CHUNK_CHARS) sent = emit({ id, text: part.slice(at, at + TEXT_CHUNK_CHARS) });
|
|
123
|
+
void sent.then(() => response.resume());
|
|
124
|
+
});
|
|
125
|
+
response.on("end", () => {
|
|
126
|
+
ended = true;
|
|
127
|
+
handles.delete(id);
|
|
128
|
+
const rest = text?.end() ?? "";
|
|
129
|
+
if (rest !== "") void emit({ id, text: rest });
|
|
130
|
+
const all = Buffer.concat(bytes);
|
|
131
|
+
for (let at = 0; at < all.length; at += BINARY_PART_BYTES) void emit({ id, data: all.subarray(at, at + BINARY_PART_BYTES).toString("base64") });
|
|
132
|
+
void emit({ id, done: true });
|
|
133
|
+
});
|
|
134
|
+
response.on("close", () => {
|
|
135
|
+
if (ended || !handles.has(id)) return;
|
|
136
|
+
handles.delete(id);
|
|
137
|
+
void emit({ id, done: true, error: "closed" });
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
handles.set(id, { close: () => { handles.delete(id); request.destroy(); } });
|
|
141
|
+
request.on("error", () => {
|
|
142
|
+
handles.delete(id);
|
|
143
|
+
if (!settled) rejectOpen(new TunnelError("HARNESS_UNAVAILABLE", "The daemon did not answer."));
|
|
144
|
+
});
|
|
145
|
+
request.end(body);
|
|
146
|
+
});
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const openWs = (params) => {
|
|
150
|
+
claim(params.id);
|
|
151
|
+
const { id } = params;
|
|
152
|
+
const url = tunnelPath(params.path);
|
|
153
|
+
url.searchParams.set("token", target.token);
|
|
154
|
+
return new Promise((resolveOpen, rejectOpen) => {
|
|
155
|
+
const socket = new WebSocket(`ws://127.0.0.1:${target.port}${url.pathname}${url.search}`, {
|
|
156
|
+
headers: { [RELAY_HEADER]: "1" },
|
|
157
|
+
perMessageDeflate: false,
|
|
158
|
+
maxPayload: 256 * 1024 * 1024,
|
|
159
|
+
});
|
|
160
|
+
let opened = false;
|
|
161
|
+
handles.set(id, { socket, close: () => { handles.delete(id); socket.terminate(); } });
|
|
162
|
+
socket.on("open", () => {
|
|
163
|
+
opened = true;
|
|
164
|
+
resolveOpen({});
|
|
165
|
+
});
|
|
166
|
+
socket.on("message", (data, binary) => {
|
|
167
|
+
if (binary) return;
|
|
168
|
+
const text = data.toString("utf8");
|
|
169
|
+
socket.pause();
|
|
170
|
+
let sent = Promise.resolve();
|
|
171
|
+
const parts = Math.max(1, Math.ceil(text.length / TEXT_CHUNK_CHARS));
|
|
172
|
+
for (let index = 0; index < parts; index += 1) {
|
|
173
|
+
sent = emit({ id, text: text.slice(index * TEXT_CHUNK_CHARS, (index + 1) * TEXT_CHUNK_CHARS), more: index < parts - 1 });
|
|
174
|
+
}
|
|
175
|
+
void sent.then(() => socket.resume());
|
|
176
|
+
});
|
|
177
|
+
socket.on("close", (code) => {
|
|
178
|
+
const known = handles.delete(id);
|
|
179
|
+
if (!opened) rejectOpen(new TunnelError("HARNESS_UNAVAILABLE", `The daemon refused the socket (${code}).`));
|
|
180
|
+
else if (known) void emit({ id, done: true, code });
|
|
181
|
+
});
|
|
182
|
+
socket.on("error", () => undefined);
|
|
183
|
+
});
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
const call = async (method, params) => {
|
|
187
|
+
if (params === null || typeof params !== "object") throw new TunnelError("INVALID_MESSAGE", "Missing parameters.");
|
|
188
|
+
switch (method) {
|
|
189
|
+
case "omp.http.open":
|
|
190
|
+
return openHttp(params);
|
|
191
|
+
case "omp.ws.open":
|
|
192
|
+
return openWs(params);
|
|
193
|
+
case "omp.ws.send": {
|
|
194
|
+
const socket = handles.get(params.id)?.socket;
|
|
195
|
+
if (socket === undefined || typeof params.text !== "string") throw new TunnelError("CONNECTION_NOT_FOUND", "No such socket.");
|
|
196
|
+
await new Promise((resolveSend, rejectSend) => socket.send(params.text, (error) => (error ? rejectSend(new TunnelError("CONNECTION_FAILED", "Socket closed.")) : resolveSend())));
|
|
197
|
+
return {};
|
|
198
|
+
}
|
|
199
|
+
case "omp.close":
|
|
200
|
+
handles.get(params.id)?.close();
|
|
201
|
+
return {};
|
|
202
|
+
default:
|
|
203
|
+
throw new TunnelError("METHOD_NOT_FOUND", `Unknown method ${String(method)}.`);
|
|
204
|
+
}
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
return {
|
|
208
|
+
async handle(message) {
|
|
209
|
+
if (message.type !== "rpc.request") return createRpcError(message.id, "INVALID_MESSAGE", "Only RPC requests are accepted.");
|
|
210
|
+
const { method, params } = message.payload ?? {};
|
|
211
|
+
try {
|
|
212
|
+
return createRpcResponse(message.id, await call(method, params));
|
|
213
|
+
} catch (error) {
|
|
214
|
+
const code = error instanceof TunnelError ? error.code : "HARNESS_UNAVAILABLE";
|
|
215
|
+
return createRpcError(message.id, code, error instanceof Error ? error.message : String(error));
|
|
216
|
+
}
|
|
217
|
+
},
|
|
218
|
+
closePeerStreams() {
|
|
219
|
+
for (const handle of [...handles.values()]) handle.close();
|
|
220
|
+
},
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* This daemon's Host identity, pairing and relay connection on one server.
|
|
226
|
+
* State (identity key, device tokens, pinned phones, a pairing in progress)
|
|
227
|
+
* lives in `dir`, owner-only.
|
|
228
|
+
*/
|
|
229
|
+
export class RelayHost {
|
|
230
|
+
/**
|
|
231
|
+
* @param {{serverUrl: string, dir: string, name: string, version: string, port: () => number, token: string, log: (line: string) => void}} options
|
|
232
|
+
*/
|
|
233
|
+
constructor(options) {
|
|
234
|
+
this.options = options;
|
|
235
|
+
this.serverUrl = options.serverUrl.replace(/\/+$/, "");
|
|
236
|
+
this.pairing = undefined;
|
|
237
|
+
this.connection = undefined;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async start() {
|
|
241
|
+
const { dir, name, log } = this.options;
|
|
242
|
+
this.identities = new IdentityStore({ directory: dir });
|
|
243
|
+
this.identity = await this.identities.loadOrCreate(name);
|
|
244
|
+
this.api = new HostServerApi(this.serverUrl, new ServerCredentialStore(this.identities.directory));
|
|
245
|
+
this.api.bindIdentity(this.identity);
|
|
246
|
+
const write = (level) => (line) => log(`relay ${level}: ${line}`);
|
|
247
|
+
this.logger = new SafeLogger({ debug() {}, info: write("info"), warn: write("warn"), error: write("error") });
|
|
248
|
+
this.connections = new ConnectionController(
|
|
249
|
+
this.identities,
|
|
250
|
+
(_context, send) => createTunnelRouter({ port: this.options.port(), token: this.options.token }, send),
|
|
251
|
+
this.logger,
|
|
252
|
+
);
|
|
253
|
+
if (await this.api.hasStoredAuthorization()) this.connect();
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Go (back) online with the device tokens a pairing left. */
|
|
257
|
+
connect() {
|
|
258
|
+
if (this.connection !== undefined) {
|
|
259
|
+
this.connection.reconnect();
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
const config = {
|
|
263
|
+
serverUrl: this.serverUrl,
|
|
264
|
+
// No WebRTC on the computer: phone traffic always goes through the relay, still end-to-end encrypted.
|
|
265
|
+
forceRelay: true,
|
|
266
|
+
rotatePinnedPeerKey: false,
|
|
267
|
+
reconnect: { enabled: true, initialDelayMs: 2_000, maxDelayMs: 30_000, jitter: 0.2 },
|
|
268
|
+
};
|
|
269
|
+
this.connection = new HostServerConnection(config, this.identity, this.identities, this.api, this.connections, this.logger);
|
|
270
|
+
this.connection.start();
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** The pairing workflow; its state survives restarts in `dir`. */
|
|
274
|
+
hostPairing() {
|
|
275
|
+
this.pairing ??= new HostPairing(
|
|
276
|
+
this.serverUrl,
|
|
277
|
+
this.identities.directory,
|
|
278
|
+
this.identity,
|
|
279
|
+
{
|
|
280
|
+
deviceId: this.identity.deviceId,
|
|
281
|
+
name: this.identity.name,
|
|
282
|
+
role: "host",
|
|
283
|
+
platform: process.platform,
|
|
284
|
+
identityKey: this.identity.publicKey,
|
|
285
|
+
clientVersion: `omp-mobile/${this.options.version}`,
|
|
286
|
+
},
|
|
287
|
+
async (credentials) => {
|
|
288
|
+
await this.api.adoptPairing(credentials);
|
|
289
|
+
this.connect();
|
|
290
|
+
},
|
|
291
|
+
);
|
|
292
|
+
return this.pairing;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** Start a pairing: the view carries `qrPayload`, the link the phone scans. */
|
|
296
|
+
async pairStart() {
|
|
297
|
+
// A finished pairing is left on disk, and asking the server about it again can fail
|
|
298
|
+
// (MEMBERSHIP_REQUIRED once its phone was revoked): drop it; an open one `start` cancels itself.
|
|
299
|
+
const previous = await this.hostPairing()
|
|
300
|
+
.status()
|
|
301
|
+
.catch(() => undefined);
|
|
302
|
+
if (previous === undefined || !["created", "claimed", "approved"].includes(previous.state)) {
|
|
303
|
+
await this.pairing.discard();
|
|
304
|
+
this.pairing = undefined;
|
|
305
|
+
}
|
|
306
|
+
return this.hostPairing().start();
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** Where the pairing stands (a phone claimed it: `mobile` names it); undefined when none. */
|
|
310
|
+
pairStatus() {
|
|
311
|
+
return this.hostPairing().status();
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** Approve the phone with the 8-digit code it shows. One attempt: a wrong code ends the pairing. */
|
|
315
|
+
pairConfirm(sessionId, code) {
|
|
316
|
+
return this.hostPairing().confirm(sessionId, code);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
pairCancel(sessionId) {
|
|
320
|
+
return this.hostPairing().cancel(sessionId);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** Paired phones, as the server lists them, and whether each is connected now. */
|
|
324
|
+
async phones() {
|
|
325
|
+
if (!(await this.api.hasStoredAuthorization())) return [];
|
|
326
|
+
const connected = new Set(this.connections.peerDeviceIds());
|
|
327
|
+
// `/devices` is for client devices; a host lists its phones through its memberships.
|
|
328
|
+
const { accessToken } = await this.api.authenticate();
|
|
329
|
+
const response = await fetch(`${this.serverUrl}/api/v1/device-memberships?limit=100`, { headers: { authorization: `Bearer ${accessToken}` } });
|
|
330
|
+
if (!response.ok) throw new Error(`The relay refused the phone list (HTTP ${response.status}).`);
|
|
331
|
+
const { items } = await response.json();
|
|
332
|
+
return (Array.isArray(items) ? items : []).map(({ peer }) => ({
|
|
333
|
+
deviceId: peer.deviceId,
|
|
334
|
+
name: peer.name,
|
|
335
|
+
platform: peer.platform,
|
|
336
|
+
connected: connected.has(peer.deviceId),
|
|
337
|
+
}));
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** Revoke one phone: the server ends its membership; its connection closes now. */
|
|
341
|
+
async revoke(deviceId) {
|
|
342
|
+
const { accessToken } = await this.api.authenticate();
|
|
343
|
+
const response = await fetch(`${this.serverUrl}/api/v1/device-memberships/${encodeURIComponent(deviceId)}`, {
|
|
344
|
+
method: "DELETE",
|
|
345
|
+
headers: { authorization: `Bearer ${accessToken}` },
|
|
346
|
+
});
|
|
347
|
+
if (!response.ok && response.status !== 403) throw new Error(`The relay refused the revoke (HTTP ${response.status}).`);
|
|
348
|
+
await this.connections.revoke(deviceId);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
async status() {
|
|
352
|
+
// Before `start` finished there is no identity yet: not paired, not online.
|
|
353
|
+
return {
|
|
354
|
+
server: this.serverUrl,
|
|
355
|
+
deviceId: this.identity?.deviceId ?? null,
|
|
356
|
+
paired: (await this.api?.hasStoredAuthorization()) ?? false,
|
|
357
|
+
online: this.connection?.isOnline() ?? false,
|
|
358
|
+
connected: this.connections?.connectionCount() ?? 0,
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
async close() {
|
|
363
|
+
await this.connection?.stop().catch(() => undefined);
|
|
364
|
+
await this.connections?.close().catch(() => undefined);
|
|
365
|
+
this.pairing?.close();
|
|
366
|
+
this.api?.close();
|
|
367
|
+
}
|
|
368
|
+
}
|
package/src/rpc-session.mjs
CHANGED
|
@@ -400,11 +400,14 @@ export class RpcSession extends EventEmitter {
|
|
|
400
400
|
}
|
|
401
401
|
|
|
402
402
|
/**
|
|
403
|
+
* Send a prompt. While a turn runs it is queued as a steer, as Enter in the
|
|
404
|
+
* terminal does: OMP delivers it at the next step boundary (the queue chips
|
|
405
|
+
* show it meanwhile). Idle, it starts a turn as before.
|
|
403
406
|
* @param {string} text
|
|
404
407
|
* @param {{type: "image", data: string, mimeType: string}[]} [images] OMP `ImageContent` parts sent with the text.
|
|
405
408
|
*/
|
|
406
409
|
async prompt(text, images = []) {
|
|
407
|
-
return this.request("prompt", images.length > 0 ? {
|
|
410
|
+
return this.request("prompt", { message: text, ...(images.length > 0 ? { images } : {}), streamingBehavior: "steer" });
|
|
408
411
|
}
|
|
409
412
|
|
|
410
413
|
async abort() {
|