@hosty-sdk/app 0.35.1 → 0.38.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/README.md +63 -6
- package/dist/feedback-audio.d.ts +3 -0
- package/dist/feedback-audio.js +40 -0
- package/dist/feedback-capture.d.ts +3 -0
- package/dist/feedback-capture.js +80 -0
- package/dist/feedback-draft.d.ts +20 -0
- package/dist/feedback-draft.js +104 -0
- package/dist/feedback-react.d.ts +4 -0
- package/dist/feedback-react.js +390 -0
- package/dist/feedback-server.d.ts +31 -0
- package/dist/feedback-server.js +48 -0
- package/dist/feedback-speech.d.ts +6 -0
- package/dist/feedback-speech.js +119 -0
- package/dist/feedback-types.d.ts +122 -0
- package/dist/feedback-types.js +9 -0
- package/dist/index.d.ts +0 -5
- package/dist/index.js +0 -21
- package/dist/overlay-react.js +3 -2
- package/dist/permissions-react.js +1 -3
- package/dist/permissions.js +3 -3
- package/dist/providers-server.d.ts +2 -0
- package/dist/providers-server.js +13 -0
- package/dist/providers.d.ts +2 -2
- package/dist/sandbox-server.d.ts +1 -115
- package/dist/sandbox-server.js +0 -85
- package/dist/server.d.ts +0 -3
- package/dist/server.js +3 -15
- package/dist/session-server.d.ts +0 -2
- package/dist/session-server.js +4 -9
- package/dist/tasks-server.d.ts +33 -0
- package/dist/tasks-server.js +57 -0
- package/dist/tasks.d.ts +148 -0
- package/dist/tasks.js +2 -0
- package/dist/ui-frame.js +1 -1
- package/dist/ui-react.js +2 -1
- package/dist/ui-server.d.ts +0 -2
- package/dist/ui-server.js +39 -6
- package/package.json +17 -1
- package/dist/sandbox-identity.d.ts +0 -10
- package/dist/sandbox-identity.js +0 -1
package/README.md
CHANGED
|
@@ -298,6 +298,30 @@ PCM16 WAV. Browser microphone consent is separate from Core permission.
|
|
|
298
298
|
credentials and the acting user's access. It does not expose general agent execution.
|
|
299
299
|
See [Provider consumption](../../docs/features/provider-consumption/feature.md) for the full contract.
|
|
300
300
|
|
|
301
|
+
## Tasks And Execution Providers
|
|
302
|
+
|
|
303
|
+
Core 0.159.0 and SDK 0.36.0 support independent `tasks` and `task-executor` categories. Request
|
|
304
|
+
`providers.tasks` or `providers.task-executor`, select an app/installation/key explicitly and use
|
|
305
|
+
server-only clients. Models, credentials and Harness profile internals are not shared task fields.
|
|
306
|
+
|
|
307
|
+
```ts
|
|
308
|
+
import { ProviderClient } from "@hosty-sdk/app/providers/server";
|
|
309
|
+
import { TaskClient, TaskExecutorClient } from "@hosty-sdk/app/tasks/server";
|
|
310
|
+
const providers = new ProviderClient();
|
|
311
|
+
const tasks = new TaskClient(providers, selectedTaskProvider, getCurrentUserToken);
|
|
312
|
+
const draft = await tasks.create({ requestId, input: {
|
|
313
|
+
title: "Summarize the meeting", markdown: "List decisions and next steps.",
|
|
314
|
+
assignee: { kind: "app", executor: selectedExecutor, optionId }, resources: [],
|
|
315
|
+
} });
|
|
316
|
+
await tasks.action(draft.ref, { requestId: readyRequestId, expectedRevision: draft.revision, action: "ready" });
|
|
317
|
+
const executions = new TaskExecutorClient(providers, selectedExecutor, getCurrentUserToken);
|
|
318
|
+
const receipt = await executions.get(executionId);
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
Retain each UUID and exact body after a timeout. Clients do not retry mutations implicitly. A task
|
|
322
|
+
provider persists execution intent before PUT; completed model output alone is not a task result.
|
|
323
|
+
See [Basic Tasks](../../docs/features/basic-tasks/feature.md) for the protocol and authority boundaries.
|
|
324
|
+
|
|
301
325
|
### Assistant MCP credentials
|
|
302
326
|
|
|
303
327
|
MCP handlers use `introspectMcpToken` from `@hosty-sdk/app/scoped-token` for online validation of
|
|
@@ -568,16 +592,49 @@ navigation/launcher and disabled tools until they provide a panel host. `panelLa
|
|
|
568
592
|
rendering choice, not a disable policy: explicitly disable local tools or register a renderer.
|
|
569
593
|
See [UI composition and browser acceptance](../../docs/features/sdk-ui-composition/feature.md).
|
|
570
594
|
|
|
595
|
+
## Annotated feedback (Core 0.165.0 / SDK 0.38.0)
|
|
596
|
+
|
|
597
|
+
`HostyUiRoot` includes an authenticated Annotate entry through the existing UI server adapter.
|
|
598
|
+
Users can select an element/region or add a page comment, enter text beside it, edit that same note
|
|
599
|
+
in the side panel and submit several annotations as one report. IndexedDB preserves text and image
|
|
600
|
+
blobs by Core/app/installation/user. Storage failure is visible; confirmed submission clears the
|
|
601
|
+
draft, while an uncertain response retains its exact UUID for retry.
|
|
602
|
+
|
|
603
|
+
Screenshots use the standard Screen Capture API chooser, still-image preview/crop and explicit
|
|
604
|
+
inclusion. Shared SDK frames delegate `display-capture` to their source; browser/webview support and
|
|
605
|
+
parent policy still apply. Text feedback works when capture is unsupported or denied. Optional
|
|
606
|
+
dictation requires an approved `providers.speech-to-text` permission and discovered provider.
|
|
607
|
+
|
|
608
|
+
Own-app intake needs app access, not a management grant. Administrator clients can use
|
|
609
|
+
`FeedbackServerClient` from `@hosty-sdk/app/feedback/server` with the reviewed `apps.feedback.manage`
|
|
610
|
+
permission and the current app-user token; DTOs are exported from `@hosty-sdk/app/feedback`.
|
|
611
|
+
These server methods expose fixed Core contracts, never a browser-accessible arbitrary proxy.
|
|
612
|
+
|
|
613
|
+
A feedback-review client can mark its declared navigation item with `feedbackReview: true`. Core
|
|
614
|
+
resolves its own endpoint/path through normal app launch with a `feedback=<report-id>` query. The
|
|
615
|
+
client opens a review, not an automatic dispatch. The declaration grants no authority. Ordinary
|
|
616
|
+
reporters get immediate submission confirmation but no processing inbox or notifications.
|
|
617
|
+
|
|
618
|
+
See [App Feedback Inbox](../../docs/features/app-feedback-inbox/feature.md) for limits, access,
|
|
619
|
+
manual chat dispatch, exact-prompt outcomes and workspace deletion retention.
|
|
620
|
+
|
|
571
621
|
## Child Core sandbox controls
|
|
572
622
|
|
|
573
623
|
`@hosty-sdk/app/sandbox/react` exports `CoreEnvironmentPanel` for operator-facing child Core
|
|
574
624
|
management. Supply an authenticated `EnvironmentRequest` transport, a tab-local replay storage key
|
|
575
|
-
and optionally a workspace with its active worktrees.
|
|
576
|
-
|
|
577
|
-
|
|
625
|
+
and optionally a workspace with its active worktrees. It supports create/start, current/source
|
|
626
|
+
Core selection, normal test-account setup, app status, preview and confirmed deletion. Shell has
|
|
627
|
+
its own combined Core/environment rows and current-mode creation dialog. The component does not grant authority: Core independently checks the optional
|
|
578
628
|
`core.environments.inspect/operate` grants, administrator identity and workspace source access.
|
|
579
629
|
|
|
580
|
-
`@hosty-sdk/app/sandbox/server` provides
|
|
581
|
-
child Core environments. Caller-owned `core.environments.read/manage` authority remains distinct
|
|
630
|
+
`@hosty-sdk/app/sandbox/server` provides CoreEnvironmentClient for child Core environments. Caller-owned `core.environments.read/manage` authority remains distinct
|
|
582
631
|
from the operator panel's cross-caller permissions. See the
|
|
583
|
-
[
|
|
632
|
+
[environment contract](../../docs/features/app-sandbox-runtimes/feature.md) for lifecycle and cleanup rules.
|
|
633
|
+
|
|
634
|
+
Version 0.37 removes SandboxClient, SandboxAction, the single-app Sandbox* request/view types,
|
|
635
|
+
SandboxIdentity/SandboxTarget, readSandboxIdentity and readSandboxTarget. No relay identity or
|
|
636
|
+
HOSTY_SANDBOX_* protocol is supported. Use CoreEnvironmentClient plus ordinary child app/user APIs;
|
|
637
|
+
apps run with the same normal identity and app-owned roles as any other Core installation. Existing
|
|
638
|
+
core.environments.* grants require their ordinary review; removed apps.sandboxes.* grants confer
|
|
639
|
+
no replacement authority. The /sandbox/server and /sandbox/react module paths remain for the
|
|
640
|
+
retained child-environment API.
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/** Interoperable speech v1 baseline: mono PCM16 WAV at 16 kHz. */
|
|
2
|
+
export function pcmWave(samples) {
|
|
3
|
+
if (!samples.length || samples.length > 120 * 16000)
|
|
4
|
+
throw new Error("Record between a moment and 120 seconds of audio.");
|
|
5
|
+
const bytes = new ArrayBuffer(44 + samples.length * 2), view = new DataView(bytes);
|
|
6
|
+
const text = (at, value) => { for (let i = 0; i < value.length; i++)
|
|
7
|
+
view.setUint8(at + i, value.charCodeAt(i)); };
|
|
8
|
+
text(0, "RIFF");
|
|
9
|
+
view.setUint32(4, bytes.byteLength - 8, true);
|
|
10
|
+
text(8, "WAVE");
|
|
11
|
+
text(12, "fmt ");
|
|
12
|
+
view.setUint32(16, 16, true);
|
|
13
|
+
view.setUint16(20, 1, true);
|
|
14
|
+
view.setUint16(22, 1, true);
|
|
15
|
+
view.setUint32(24, 16000, true);
|
|
16
|
+
view.setUint32(28, 32000, true);
|
|
17
|
+
view.setUint16(32, 2, true);
|
|
18
|
+
view.setUint16(34, 16, true);
|
|
19
|
+
text(36, "data");
|
|
20
|
+
view.setUint32(40, samples.length * 2, true);
|
|
21
|
+
samples.forEach((sample, i) => { const value = Math.max(-1, Math.min(1, sample)); view.setInt16(44 + i * 2, Math.round(value * (value < 0 ? 32768 : 32767)), true); });
|
|
22
|
+
return new Blob([bytes], { type: "audio/wav" });
|
|
23
|
+
}
|
|
24
|
+
export async function recordingWave(recording) {
|
|
25
|
+
const context = new AudioContext();
|
|
26
|
+
try {
|
|
27
|
+
const decoded = await context.decodeAudioData(await recording.arrayBuffer());
|
|
28
|
+
if (decoded.duration > 120.1)
|
|
29
|
+
throw new Error("Recordings may be up to 120 seconds.");
|
|
30
|
+
const offline = new OfflineAudioContext(1, Math.min(120 * 16000, Math.ceil(decoded.duration * 16000)), 16000);
|
|
31
|
+
const source = offline.createBufferSource();
|
|
32
|
+
source.buffer = decoded;
|
|
33
|
+
source.connect(offline.destination);
|
|
34
|
+
source.start();
|
|
35
|
+
return pcmWave((await offline.startRendering()).getChannelData(0));
|
|
36
|
+
}
|
|
37
|
+
finally {
|
|
38
|
+
await context.close();
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
export async function captureStill(hide, signal) {
|
|
2
|
+
if (!navigator.mediaDevices?.getDisplayMedia)
|
|
3
|
+
throw new Error("Screen capture is unavailable in this browser. You can still send comments.");
|
|
4
|
+
// Offer the application tab while retaining the browser-owned chooser and permission.
|
|
5
|
+
const options = {
|
|
6
|
+
video: true, audio: false, preferCurrentTab: true, selfBrowserSurface: "include",
|
|
7
|
+
};
|
|
8
|
+
const stream = await navigator.mediaDevices.getDisplayMedia(options);
|
|
9
|
+
const video = document.createElement("video");
|
|
10
|
+
const stop = () => { stream.getTracks().forEach(t => t.stop()); };
|
|
11
|
+
signal.addEventListener("abort", stop, { once: true });
|
|
12
|
+
try {
|
|
13
|
+
signal.throwIfAborted();
|
|
14
|
+
hide(true);
|
|
15
|
+
// Let the hidden overlay reach the compositor before consuming the live stream.
|
|
16
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
17
|
+
signal.throwIfAborted();
|
|
18
|
+
video.muted = true;
|
|
19
|
+
video.playsInline = true;
|
|
20
|
+
await new Promise((resolve, reject) => {
|
|
21
|
+
let frame, fallback;
|
|
22
|
+
const cleanup = () => {
|
|
23
|
+
clearTimeout(timeout);
|
|
24
|
+
clearTimeout(fallback);
|
|
25
|
+
signal.removeEventListener("abort", abort);
|
|
26
|
+
if (frame !== undefined)
|
|
27
|
+
video.cancelVideoFrameCallback(frame);
|
|
28
|
+
};
|
|
29
|
+
const fail = (error) => { cleanup(); reject(error); };
|
|
30
|
+
const abort = () => fail(signal.reason);
|
|
31
|
+
const ready = () => { cleanup(); resolve(); };
|
|
32
|
+
const timeout = setTimeout(() => fail(new Error("The capture source did not produce a frame.")), 5000);
|
|
33
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
34
|
+
// Register before play: static tab capture may deliver only one frame until pixels change.
|
|
35
|
+
if ("requestVideoFrameCallback" in video)
|
|
36
|
+
frame = video.requestVideoFrameCallback(ready);
|
|
37
|
+
video.srcObject = stream;
|
|
38
|
+
void video.play().then(() => {
|
|
39
|
+
if (!("requestVideoFrameCallback" in video))
|
|
40
|
+
fallback = setTimeout(ready, 150);
|
|
41
|
+
}, fail);
|
|
42
|
+
});
|
|
43
|
+
signal.throwIfAborted();
|
|
44
|
+
if (!video.videoWidth || !video.videoHeight)
|
|
45
|
+
throw new Error("The selected capture surface has no image.");
|
|
46
|
+
const scale = Math.min(1, 2560 / Math.max(video.videoWidth, video.videoHeight));
|
|
47
|
+
const canvas = document.createElement("canvas");
|
|
48
|
+
canvas.width = Math.round(video.videoWidth * scale);
|
|
49
|
+
canvas.height = Math.round(video.videoHeight * scale);
|
|
50
|
+
canvas.getContext("2d").drawImage(video, 0, 0, canvas.width, canvas.height);
|
|
51
|
+
return await canvasBlob(canvas);
|
|
52
|
+
}
|
|
53
|
+
finally {
|
|
54
|
+
stop();
|
|
55
|
+
signal.removeEventListener("abort", stop);
|
|
56
|
+
video.srcObject = null;
|
|
57
|
+
hide(false);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
export async function cropStill(blob, crop) {
|
|
61
|
+
if (!crop)
|
|
62
|
+
return blob;
|
|
63
|
+
const image = await createImageBitmap(blob);
|
|
64
|
+
try {
|
|
65
|
+
const x = Math.max(0, Math.min(image.width - 1, Math.floor(crop.x * image.width))), y = Math.max(0, Math.min(image.height - 1, Math.floor(crop.y * image.height)));
|
|
66
|
+
const width = Math.max(1, Math.min(image.width - x, Math.round(crop.width * image.width)));
|
|
67
|
+
const height = Math.max(1, Math.min(image.height - y, Math.round(crop.height * image.height)));
|
|
68
|
+
const canvas = document.createElement("canvas");
|
|
69
|
+
canvas.width = width;
|
|
70
|
+
canvas.height = height;
|
|
71
|
+
canvas.getContext("2d").drawImage(image, x, y, width, height, 0, 0, width, height);
|
|
72
|
+
return await canvasBlob(canvas);
|
|
73
|
+
}
|
|
74
|
+
finally {
|
|
75
|
+
image.close();
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
function canvasBlob(canvas) {
|
|
79
|
+
return new Promise((resolve, reject) => canvas.toBlob(blob => blob ? resolve(blob) : reject(new Error("Could not encode the screenshot.")), "image/png"));
|
|
80
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { FeedbackCapabilities, FeedbackNoteInput, FeedbackSubmit, FeedbackTarget } from "./feedback-types.js";
|
|
2
|
+
export type DraftNote = Omit<FeedbackNoteInput, "image"> & {
|
|
3
|
+
image?: Blob;
|
|
4
|
+
};
|
|
5
|
+
export type FeedbackDraft = {
|
|
6
|
+
key: string;
|
|
7
|
+
revision: number;
|
|
8
|
+
requestId: string;
|
|
9
|
+
comment: string;
|
|
10
|
+
annotations: DraftNote[];
|
|
11
|
+
submitting: boolean;
|
|
12
|
+
};
|
|
13
|
+
export declare function draftKey(capabilities: FeedbackCapabilities): string;
|
|
14
|
+
export declare function readDraft(key: string): Promise<FeedbackDraft | null>;
|
|
15
|
+
/** Read-check-write is one transaction: an older tab cannot overwrite a newer draft. */
|
|
16
|
+
export declare function saveDraft(draft: FeedbackDraft, expectedRevision: number, expectedRequestId: string | null): Promise<void>;
|
|
17
|
+
export declare function deleteDraft(key: string, requestId: string, expectedRevision: number): Promise<void>;
|
|
18
|
+
export declare function blankDraft(key: string): FeedbackDraft;
|
|
19
|
+
export declare function makeNote(target: FeedbackTarget): DraftNote;
|
|
20
|
+
export declare function submission(draft: FeedbackDraft): Promise<FeedbackSubmit>;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
const database = "hosty-feedback-v1";
|
|
2
|
+
export function draftKey(capabilities) {
|
|
3
|
+
return JSON.stringify([capabilities.coreId, capabilities.appId, capabilities.installation, capabilities.userId]);
|
|
4
|
+
}
|
|
5
|
+
function open() {
|
|
6
|
+
return new Promise((resolve, reject) => {
|
|
7
|
+
if (!globalThis.indexedDB) {
|
|
8
|
+
reject(new Error("Local draft storage is unavailable."));
|
|
9
|
+
return;
|
|
10
|
+
}
|
|
11
|
+
const request = indexedDB.open(database, 1);
|
|
12
|
+
request.onupgradeneeded = () => { request.result.createObjectStore("drafts", { keyPath: "key" }); };
|
|
13
|
+
request.onsuccess = () => resolve(request.result);
|
|
14
|
+
request.onerror = () => reject(request.error ?? new Error("Could not open draft storage."));
|
|
15
|
+
request.onblocked = () => reject(new Error("Close older feedback tabs to update draft storage."));
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
export async function readDraft(key) {
|
|
19
|
+
const db = await open();
|
|
20
|
+
try {
|
|
21
|
+
return await new Promise((resolve, reject) => {
|
|
22
|
+
const tx = db.transaction("drafts", "readonly");
|
|
23
|
+
const request = tx.objectStore("drafts").get(key);
|
|
24
|
+
let value = null;
|
|
25
|
+
request.onsuccess = () => { value = request.result ?? null; };
|
|
26
|
+
tx.oncomplete = () => resolve(value);
|
|
27
|
+
tx.onerror = () => reject(tx.error);
|
|
28
|
+
tx.onabort = () => reject(tx.error);
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
finally {
|
|
32
|
+
db.close();
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/** Read-check-write is one transaction: an older tab cannot overwrite a newer draft. */
|
|
36
|
+
export async function saveDraft(draft, expectedRevision, expectedRequestId) {
|
|
37
|
+
const db = await open();
|
|
38
|
+
try {
|
|
39
|
+
await new Promise((resolve, reject) => {
|
|
40
|
+
const tx = db.transaction("drafts", "readwrite");
|
|
41
|
+
const store = tx.objectStore("drafts");
|
|
42
|
+
const read = store.get(draft.key);
|
|
43
|
+
let conflict = false;
|
|
44
|
+
read.onsuccess = () => {
|
|
45
|
+
if ((read.result?.revision ?? 0) !== expectedRevision || (read.result?.requestId ?? null) !== expectedRequestId) {
|
|
46
|
+
conflict = true;
|
|
47
|
+
tx.abort();
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
store.put(draft);
|
|
51
|
+
};
|
|
52
|
+
tx.oncomplete = () => resolve();
|
|
53
|
+
tx.onabort = tx.onerror = () => reject(conflict ? new Error("This draft changed in another tab. Reload to restore its latest saved version.") : tx.error ?? new Error("Draft could not be saved locally."));
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
finally {
|
|
57
|
+
db.close();
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
export async function deleteDraft(key, requestId, expectedRevision) {
|
|
61
|
+
const db = await open();
|
|
62
|
+
try {
|
|
63
|
+
await new Promise((resolve, reject) => {
|
|
64
|
+
const tx = db.transaction("drafts", "readwrite");
|
|
65
|
+
const store = tx.objectStore("drafts");
|
|
66
|
+
const read = store.get(key);
|
|
67
|
+
let conflict = false;
|
|
68
|
+
read.onsuccess = () => {
|
|
69
|
+
if (!read.result)
|
|
70
|
+
return;
|
|
71
|
+
if (read.result.requestId !== requestId || read.result.revision !== expectedRevision) {
|
|
72
|
+
conflict = true;
|
|
73
|
+
tx.abort();
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
store.delete(key);
|
|
77
|
+
};
|
|
78
|
+
tx.oncomplete = () => resolve();
|
|
79
|
+
tx.onabort = tx.onerror = () => reject(conflict ? new Error("This draft changed in another tab. Reload before discarding its saved content.") : tx.error ?? new Error("Draft could not be removed locally."));
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
finally {
|
|
83
|
+
db.close();
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
export function blankDraft(key) {
|
|
87
|
+
return { key, revision: 0, requestId: crypto.randomUUID(), comment: "", annotations: [], submitting: false };
|
|
88
|
+
}
|
|
89
|
+
export function makeNote(target) {
|
|
90
|
+
return { id: crypto.randomUUID(), text: "", route: location.pathname, capturedAt: new Date().toISOString(), target };
|
|
91
|
+
}
|
|
92
|
+
export async function submission(draft) {
|
|
93
|
+
const [coreId, appId, installation, userId] = JSON.parse(draft.key);
|
|
94
|
+
return { scope: { coreId: coreId, appId: appId, installation: installation, userId: userId }, requestId: draft.requestId, comment: draft.comment, annotations: await Promise.all(draft.annotations.map(async (note) => {
|
|
95
|
+
const { image, ...data } = note;
|
|
96
|
+
if (!image)
|
|
97
|
+
return data;
|
|
98
|
+
const bytes = new Uint8Array(await image.arrayBuffer());
|
|
99
|
+
let binary = "";
|
|
100
|
+
for (let i = 0; i < bytes.length; i += 8192)
|
|
101
|
+
binary += String.fromCharCode(...bytes.subarray(i, i + 8192));
|
|
102
|
+
return { ...data, image: { mediaType: image.type, data: btoa(binary) } };
|
|
103
|
+
})) };
|
|
104
|
+
}
|