@runappsai/sdk 0.6.3 → 0.6.5

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.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runappsai/sdk",
3
- "version": "0.6.3",
3
+ "version": "0.6.5",
4
4
  "description": "JavaScript / TypeScript client for the RunApps AI Gateway",
5
5
  "license": "MIT",
6
6
  "author": "RunApps",
@@ -215,6 +215,8 @@ export class ActivityTracker {
215
215
  private onEnd(e: RequestEndEvent): void {
216
216
  const call = this.active.get(e.id);
217
217
  this.active.delete(e.id);
218
+ // File calls only show while in flight: nothing to list or add up.
219
+ if (e.capability === "files") return;
218
220
  this.pushRecent({
219
221
  id: e.id,
220
222
  model: e.model,
@@ -233,6 +235,12 @@ export class ActivityTracker {
233
235
  private onError(e: RequestErrorEvent): void {
234
236
  const call = this.active.get(e.id);
235
237
  this.active.delete(e.id);
238
+ if (e.capability === "files") {
239
+ // Not listed; the LED goes red for a moment unless it was only a
240
+ // missing file, which apps probe for routinely.
241
+ if (e.statusCode !== 404) this.lastErrorAt = Date.now();
242
+ return;
243
+ }
236
244
  this.pushRecent({
237
245
  id: e.id,
238
246
  model: e.model,
@@ -906,6 +906,12 @@ function mountActivityBadge(opts: MountOpts): HTMLButtonElement {
906
906
  "border:1px solid rgba(255,255,255,0.12)",
907
907
  "box-shadow:0 4px 16px rgba(0,0,0,0.15)",
908
908
  "user-select:none",
909
+ "-webkit-user-select:none",
910
+ // Touch: without this the browser claims the first finger move as a
911
+ // page scroll and fires pointercancel, so the badge could not be
912
+ // dragged on phones. No callout either: a long press is a drag.
913
+ "touch-action:none",
914
+ "-webkit-touch-callout:none",
909
915
  "overflow:visible", // popover spills out the top
910
916
  "cursor:pointer",
911
917
  "appearance:none",
@@ -985,12 +991,15 @@ function mountActivityBadge(opts: MountOpts): HTMLButtonElement {
985
991
  const led = document.createElement("span");
986
992
  led.style.cssText = [
987
993
  "position:absolute",
988
- "bottom:-2px;right:-2px",
989
- "width:8px;height:8px;border-radius:50%",
994
+ "bottom:-1px;right:-1px",
995
+ "width:7px;height:7px;border-radius:50%",
990
996
  "background:rgba(255,255,255,0.2)",
991
- "border:2px solid rgba(15,15,20,1)",
997
+ "border:1.5px solid rgba(15,15,20,1)",
992
998
  "box-shadow:0 0 0 0 transparent",
993
- "transition:background-color 150ms ease-out, box-shadow 200ms ease-out",
999
+ // Hidden at rest: it only appears while something is happening
1000
+ // (syncing, offline, an error, the green flash after a sync).
1001
+ "opacity:0",
1002
+ "transition:background-color 150ms ease-out, box-shadow 200ms ease-out, opacity 200ms ease-out",
994
1003
  "pointer-events:none",
995
1004
  ].join(";");
996
1005
 
@@ -1185,7 +1194,11 @@ function mountActivityBadge(opts: MountOpts): HTMLButtonElement {
1185
1194
  // so further moves arrive even when the pointer escapes the
1186
1195
  // badge bounds (fast drags leave the small pill quickly).
1187
1196
  isDragging = true;
1188
- el.setPointerCapture(e.pointerId);
1197
+ try {
1198
+ el.setPointerCapture(e.pointerId);
1199
+ } catch {
1200
+ /* no active pointer of that id (synthetic events, odd WebViews): drag still works while the finger stays on the badge */
1201
+ }
1189
1202
  el.style.cursor = "grabbing";
1190
1203
  // Pre-emptively close any open popover — keeping it pinned to a
1191
1204
  // moving badge looks janky and a drag is clearly a different
@@ -1208,7 +1221,11 @@ function mountActivityBadge(opts: MountOpts): HTMLButtonElement {
1208
1221
  if (!isDragging) return;
1209
1222
  isDragging = false;
1210
1223
  el.style.cursor = "pointer";
1211
- if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId);
1224
+ try {
1225
+ if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId);
1226
+ } catch {
1227
+ /* same */
1228
+ }
1212
1229
  // Persist final position. Re-read the rect so what we store is
1213
1230
  // exactly what's on screen — clampToViewport may have shaved
1214
1231
  // the trailing few pixels.
@@ -1276,6 +1293,7 @@ function mountActivityBadge(opts: MountOpts): HTMLButtonElement {
1276
1293
  // No event bus → static badge. Hide ring + LED.
1277
1294
  ring.style.opacity = "0";
1278
1295
  led.style.background = "rgba(255,255,255,0.2)";
1296
+ led.style.opacity = "0";
1279
1297
  return;
1280
1298
  }
1281
1299
  const snap = tracker.snapshot();
@@ -1383,6 +1401,7 @@ function syncedPing(avatarWrap: HTMLElement, led: HTMLSpanElement): void {
1383
1401
  avatarWrap.appendChild(ping);
1384
1402
  setTimeout(() => ping.remove(), 1000); // in case animationend never fires
1385
1403
  syncFlashUntil = Date.now() + 1200;
1404
+ led.style.opacity = "1";
1386
1405
  led.style.background = "#10b981";
1387
1406
  led.style.boxShadow = "0 0 0 2px rgba(16,185,129,0.35)";
1388
1407
  led.style.animation = "";
@@ -1390,12 +1409,14 @@ function syncedPing(avatarWrap: HTMLElement, led: HTMLSpanElement): void {
1390
1409
  // Back to the resting colour once the flash is over.
1391
1410
  if (Date.now() >= syncFlashUntil) {
1392
1411
  led.style.background = "rgba(255,255,255,0.2)";
1412
+ led.style.opacity = "0";
1393
1413
  led.style.boxShadow = "0 0 0 0 transparent";
1394
1414
  }
1395
1415
  }, 1250);
1396
1416
  }
1397
1417
 
1398
1418
  function updateLED(led: HTMLSpanElement, snap: ActivitySnapshot): void {
1419
+ led.style.opacity = "1"; // the resting branches below hide it again
1399
1420
  switch (snap.status) {
1400
1421
  case "idle": {
1401
1422
  // Nothing in flight: the LED shows the sync instead — blue pulse
@@ -1416,6 +1437,7 @@ function updateLED(led: HTMLSpanElement, snap: ActivitySnapshot): void {
1416
1437
  led.style.animation = "";
1417
1438
  } else {
1418
1439
  led.style.background = "rgba(255,255,255,0.2)";
1440
+ led.style.opacity = "0";
1419
1441
  led.style.boxShadow = "0 0 0 0 transparent";
1420
1442
  led.style.animation = "";
1421
1443
  }
package/src/client.ts CHANGED
@@ -248,7 +248,7 @@ export class RunApps {
248
248
  // `project` also scopes the file URLs (/v1/p/<project>/files/...),
249
249
  // so two apps never issue byte-identical file requests. Omitted →
250
250
  // legacy unscoped /v1/files/... paths.
251
- this.files = new FilesService(transport, options.project);
251
+ this.files = new FilesService(transport, options.project, this.events);
252
252
  this.embeddings = new EmbeddingsService(transport, this.events);
253
253
 
254
254
  // Sync: an app on RunApps gets it unless it says no. It
@@ -259,7 +259,9 @@ export class RunApps {
259
259
  const auth = this.auth;
260
260
  const engine = new SyncEngine(
261
261
  {
262
- remote: new FilesRemote(this.files),
262
+ // Its own file service, without the event bus: sync traffic
263
+ // must not spin the badge every minute.
264
+ remote: new FilesRemote(new FilesService(transport, options.project)),
263
265
  events: this.events,
264
266
  userId: () => auth.ownerId(),
265
267
  onAuthChange: (cb) => auth.onTokenChange(() => cb()),
package/src/events.ts CHANGED
@@ -32,7 +32,10 @@ export type SDKCapability =
32
32
  | "text_to_speech"
33
33
  | "speech_to_text"
34
34
  | "video_generation"
35
- | "computer_use";
35
+ | "computer_use"
36
+ /** A file-space call (`client.files`). Shown on the badge while in
37
+ * flight and on failure, but not listed or charged per call. */
38
+ | "files";
36
39
 
37
40
  /** Fired the moment a request hits the wire. */
38
41
  export interface RequestStartEvent {
package/src/files.ts CHANGED
@@ -8,6 +8,8 @@
8
8
  // token (rrt_*) issued by the grant flow.
9
9
 
10
10
  import type { Transport } from "./transport.js";
11
+ import type { SDKEvents } from "./events.js";
12
+ import { wrapEvents } from "./event-wrap.js";
11
13
 
12
14
  export interface FileObject {
13
15
  path: string;
@@ -121,12 +123,21 @@ export class FilesService {
121
123
  constructor(
122
124
  private readonly transport: Transport,
123
125
  project?: string,
126
+ /** When given, every call emits request:start / end / error so the
127
+ * badge can show it in flight; the sync engine's own file traffic
128
+ * uses a service without one. */
129
+ private readonly events?: SDKEvents,
124
130
  ) {
125
131
  this.base = project
126
132
  ? "/v1/p/" + encodeURIComponent(project) + "/files"
127
133
  : "/v1/files";
128
134
  }
129
135
 
136
+ /** Run one file call, with badge events when the service has a bus. */
137
+ private run<T>(op: string, fn: () => Promise<T>): Promise<T> {
138
+ return this.events ? wrapEvents(this.events, { model: op, capability: "files" }, fn) : fn();
139
+ }
140
+
130
141
  /**
131
142
  * Upload `body` to `path`. Returns the resulting FileObject — its
132
143
  * `url` is the stable public address for that path.
@@ -143,11 +154,11 @@ export class FilesService {
143
154
  const headers: Record<string, string> = {};
144
155
  if (opts?.ifNoneMatch) headers["If-None-Match"] = "*";
145
156
  if (opts?.ifMatch) headers["If-Match"] = `"${opts.ifMatch.replace(/"/g, "")}"`;
146
- return this.transport.putBytes<FileObject>(pathToURL(this.base, path), body, {
157
+ return this.run("files.put", () => this.transport.putBytes<FileObject>(pathToURL(this.base, path), body, {
147
158
  contentType: opts?.contentType,
148
159
  headers: Object.keys(headers).length ? headers : undefined,
149
160
  signal: opts?.signal,
150
- });
161
+ }));
151
162
  }
152
163
 
153
164
  /** Convenience: upload string content with sensible content-type. */
@@ -178,6 +189,10 @@ export class FilesService {
178
189
  * still the version that was read.
179
190
  */
180
191
  async read(path: string, init?: { signal?: AbortSignal; ifNoneMatch?: string }): Promise<FileRead> {
192
+ return this.run("files.read", () => this.readRaw(path, init));
193
+ }
194
+
195
+ private async readRaw(path: string, init?: { signal?: AbortSignal; ifNoneMatch?: string }): Promise<FileRead> {
181
196
  const { data, contentType, etag, notModified } = await this.transport.getRaw(pathToURL(this.base, path), init);
182
197
  return {
183
198
  blob: new Blob([new Uint8Array(data)], { type: contentType || "application/octet-stream" }),
@@ -212,7 +227,7 @@ export class FilesService {
212
227
 
213
228
  /** Stat: HEAD-only object metadata. */
214
229
  async stat(path: string, init?: { signal?: AbortSignal }): Promise<FileObject> {
215
- const { status, headers } = await this.transport.head(pathToURL(this.base, path), init);
230
+ const { status, headers } = await this.run("files.stat", () => this.transport.head(pathToURL(this.base, path), init));
216
231
  if (status === 404) {
217
232
  throw Object.assign(new Error("not found"), { status: 404 });
218
233
  }
@@ -231,13 +246,13 @@ export class FilesService {
231
246
 
232
247
  /** Returns true iff the path resolves to an existing object. */
233
248
  async exists(path: string, init?: { signal?: AbortSignal }): Promise<boolean> {
234
- const { status } = await this.transport.head(pathToURL(this.base, path), init);
249
+ const { status } = await this.run("files.exists", () => this.transport.head(pathToURL(this.base, path), init));
235
250
  return status >= 200 && status < 400;
236
251
  }
237
252
 
238
253
  /** Delete the object at `path`. Idempotent. */
239
254
  async del(path: string, init?: { signal?: AbortSignal }): Promise<void> {
240
- await this.transport.deletePath<{ ok: boolean }>(pathToURL(this.base, path), init);
255
+ await this.run("files.del", () => this.transport.deletePath<{ ok: boolean }>(pathToURL(this.base, path), init));
241
256
  }
242
257
 
243
258
  /** List objects. Pass an empty / omitted prefix for the root. */
@@ -248,10 +263,10 @@ export class FilesService {
248
263
  if (opts?.limit) params.set("limit", String(opts.limit));
249
264
  if (opts?.glob) params.set("glob", opts.glob);
250
265
  const qs = params.toString();
251
- return this.transport.getJSON<FileListResult>(
266
+ return this.run("files.list", () => this.transport.getJSON<FileListResult>(
252
267
  this.base + (qs ? "?" + qs : ""),
253
268
  opts?.signal ? { signal: opts.signal } : undefined,
254
- );
269
+ ));
255
270
  }
256
271
 
257
272
  /**
@@ -268,21 +283,21 @@ export class FilesService {
268
283
  opts: { prefix?: string; glob?: string },
269
284
  init?: { signal?: AbortSignal },
270
285
  ): Promise<{ deleted: number }> {
271
- return this.transport.postJSON<{ deleted: number }>(
286
+ return this.run("files.deleteMany", () => this.transport.postJSON<{ deleted: number }>(
272
287
  this.base + "/delete",
273
288
  { prefix: opts.prefix ?? "", glob: opts.glob ?? "" },
274
289
  init,
275
- );
290
+ ));
276
291
  }
277
292
 
278
293
  /** Atomic rename (copy + delete server-side). */
279
294
  async move(from: string, to: string, init?: { signal?: AbortSignal }): Promise<FileObject> {
280
- return this.transport.postJSON<FileObject>(this.base + "/move", { from, to }, init);
295
+ return this.run("files.move", () => this.transport.postJSON<FileObject>(this.base + "/move", { from, to }, init));
281
296
  }
282
297
 
283
298
  /** Server-side copy. */
284
299
  async copy(from: string, to: string, init?: { signal?: AbortSignal }): Promise<FileObject> {
285
- return this.transport.postJSON<FileObject>(this.base + "/copy", { from, to }, init);
300
+ return this.run("files.copy", () => this.transport.postJSON<FileObject>(this.base + "/copy", { from, to }, init));
286
301
  }
287
302
 
288
303
  /**
@@ -295,7 +310,7 @@ export class FilesService {
295
310
  srcURL: string,
296
311
  opts?: PutFromURLOptions,
297
312
  ): Promise<FileObject> {
298
- return this.transport.postJSON<FileObject>(
313
+ return this.run("files.putFromURL", () => this.transport.postJSON<FileObject>(
299
314
  this.base + "/put-url",
300
315
  {
301
316
  path,
@@ -305,7 +320,7 @@ export class FilesService {
305
320
  if_match: opts?.ifMatch,
306
321
  },
307
322
  opts?.signal ? { signal: opts.signal } : undefined,
308
- );
323
+ ));
309
324
  }
310
325
 
311
326
  /**
@@ -325,6 +340,10 @@ export class FilesService {
325
340
  init?: { signal?: AbortSignal },
326
341
  ): Promise<BatchResult[]> {
327
342
  if (ops.length === 0) return [];
343
+ return this.run("files.batch", () => this.batchRaw(ops, init));
344
+ }
345
+
346
+ private async batchRaw(ops: BatchOp[], init?: { signal?: AbortSignal }): Promise<BatchResult[]> {
328
347
  const chunkSize = 30;
329
348
  if (ops.length <= chunkSize) {
330
349
  const resp = await this.transport.postJSON<{ results: BatchResult[] }>(