@hosty-sdk/app 0.37.0 → 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 +26 -0
- 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/ui-frame.js +1 -1
- package/dist/ui-react.js +2 -1
- package/dist/ui-server.js +39 -4
- package/package.json +9 -1
package/README.md
CHANGED
|
@@ -592,6 +592,32 @@ navigation/launcher and disabled tools until they provide a panel host. `panelLa
|
|
|
592
592
|
rendering choice, not a disable policy: explicitly disable local tools or register a renderer.
|
|
593
593
|
See [UI composition and browser acceptance](../../docs/features/sdk-ui-composition/feature.md).
|
|
594
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
|
+
|
|
595
621
|
## Child Core sandbox controls
|
|
596
622
|
|
|
597
623
|
`@hosty-sdk/app/sandbox/react` exports `CoreEnvironmentPanel` for operator-facing child Core
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
import { useEffect, useRef, useState } from "react";
|
|
4
|
+
import { createPortal } from "react-dom";
|
|
5
|
+
import { APP_SESSION_RESTORED, appFetch } from "./browser-auth.js";
|
|
6
|
+
import { SdkChromeStyles } from "./sdk-chrome.js";
|
|
7
|
+
import { blankDraft, deleteDraft, draftKey, makeNote, readDraft, saveDraft, submission } from "./feedback-draft.js";
|
|
8
|
+
import { FeedbackSpeech } from "./feedback-speech.js";
|
|
9
|
+
import { captureStill, cropStill } from "./feedback-capture.js";
|
|
10
|
+
function useBlobUrl(blob) {
|
|
11
|
+
const [url, setUrl] = useState("");
|
|
12
|
+
useEffect(() => { if (!blob) {
|
|
13
|
+
setUrl("");
|
|
14
|
+
return;
|
|
15
|
+
} const value = URL.createObjectURL(blob); setUrl(value); return () => URL.revokeObjectURL(value); }, [blob]);
|
|
16
|
+
return url;
|
|
17
|
+
}
|
|
18
|
+
function ImagePreview({ blob }) { const url = useBlobUrl(blob); return url ? _jsx("img", { src: url, alt: "Included screenshot" }) : null; }
|
|
19
|
+
const rect = (b) => ({ x: b.x, y: b.y, width: b.width, height: b.height });
|
|
20
|
+
const viewport = () => ({ width: innerWidth, height: innerHeight, scale: devicePixelRatio });
|
|
21
|
+
const errorMessage = (e) => e instanceof Error ? e.message : "Feedback could not be saved.";
|
|
22
|
+
export function FeedbackCapture({ active, uiPath = "/api/hosty/ui" }) {
|
|
23
|
+
const [capabilities, setCapabilities] = useState(null);
|
|
24
|
+
const [unavailable, setUnavailable] = useState("");
|
|
25
|
+
const [retry, setRetry] = useState(0);
|
|
26
|
+
useEffect(() => { const refresh = () => setRetry(r => r + 1); window.addEventListener(APP_SESSION_RESTORED, refresh); return () => window.removeEventListener(APP_SESSION_RESTORED, refresh); }, []);
|
|
27
|
+
const path = uiPath.replace(/\/$/, "") + "/feedback";
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
setCapabilities(null);
|
|
30
|
+
setUnavailable("");
|
|
31
|
+
if (!active)
|
|
32
|
+
return;
|
|
33
|
+
const controller = new AbortController();
|
|
34
|
+
void appFetch(path, { signal: controller.signal }).then(async (response) => {
|
|
35
|
+
if (!response.ok)
|
|
36
|
+
throw new Error(response.status === 404 ? "Update Core and this app to enable feedback." : "Feedback is unavailable. Retry when access is restored.");
|
|
37
|
+
const value = await response.json();
|
|
38
|
+
if (value.version !== 1 || !value.userId || !value.appId || !value.installation || !value.coreId)
|
|
39
|
+
throw new Error("Feedback capability response is incompatible.");
|
|
40
|
+
if (!controller.signal.aborted)
|
|
41
|
+
setCapabilities(value);
|
|
42
|
+
}).catch(e => { if (!controller.signal.aborted)
|
|
43
|
+
setUnavailable(errorMessage(e)); });
|
|
44
|
+
return () => controller.abort();
|
|
45
|
+
}, [active, path, retry]);
|
|
46
|
+
if (!active || typeof document === "undefined")
|
|
47
|
+
return null;
|
|
48
|
+
return capabilities ? _jsx(FeedbackEditor, { capabilities: capabilities, path: path }, draftKey(capabilities))
|
|
49
|
+
: unavailable ? createPortal(_jsxs("div", { "data-hosty-feedback": true, "data-hosty-sdk": true, className: "hosty-feedback-launch", children: [_jsx(SdkChromeStyles, {}), _jsx("button", { title: unavailable, onClick: () => setRetry(r => r + 1), children: "Retry feedback" })] }), document.body) : null;
|
|
50
|
+
}
|
|
51
|
+
function FeedbackEditor({ capabilities, path }) {
|
|
52
|
+
const [dictating, setDictating] = useState(false);
|
|
53
|
+
const [reviewTargets, setReviewTargets] = useState([]);
|
|
54
|
+
const key = draftKey(capabilities);
|
|
55
|
+
const [draft, setDraft] = useState(() => blankDraft(key));
|
|
56
|
+
const current = useRef(draft), savedRevision = useRef(0), savedRequestId = useRef(null), queue = useRef(Promise.resolve()), saveFailure = useRef(null);
|
|
57
|
+
const live = useRef(true);
|
|
58
|
+
const [ready, setReady] = useState(false), [open, setOpen] = useState(false), [saving, setSaving] = useState(false);
|
|
59
|
+
const [error, setError] = useState(""), [notice, setNotice] = useState(""), [busy, setBusy] = useState(false);
|
|
60
|
+
const [picking, setPicking] = useState(null);
|
|
61
|
+
const [highlight, setHighlight] = useState(null);
|
|
62
|
+
const [edit, setEdit] = useState(null);
|
|
63
|
+
const [hidden, setHidden] = useState(false), [capturing, setCapturing] = useState(false);
|
|
64
|
+
const [preview, setPreview] = useState(null);
|
|
65
|
+
const elements = useRef(new Map());
|
|
66
|
+
const capture = useRef(null);
|
|
67
|
+
const root = useRef(null);
|
|
68
|
+
useEffect(() => {
|
|
69
|
+
live.current = true;
|
|
70
|
+
void readDraft(key).then(value => {
|
|
71
|
+
if (!live.current)
|
|
72
|
+
return;
|
|
73
|
+
if (value) {
|
|
74
|
+
current.current = value;
|
|
75
|
+
savedRevision.current = value.revision;
|
|
76
|
+
savedRequestId.current = value.requestId;
|
|
77
|
+
setDraft(value);
|
|
78
|
+
setNotice("Saved draft restored.");
|
|
79
|
+
}
|
|
80
|
+
}).catch(e => { if (live.current)
|
|
81
|
+
setError(errorMessage(e)); }).finally(() => { if (live.current)
|
|
82
|
+
setReady(true); });
|
|
83
|
+
return () => { live.current = false; capture.current?.abort(); };
|
|
84
|
+
}, [key]);
|
|
85
|
+
function update(change) {
|
|
86
|
+
const next = { ...current.current, ...change, revision: current.current.revision + 1 };
|
|
87
|
+
current.current = next;
|
|
88
|
+
setDraft(next);
|
|
89
|
+
setSaving(true);
|
|
90
|
+
setNotice("");
|
|
91
|
+
queue.current = queue.current.then(async () => {
|
|
92
|
+
try {
|
|
93
|
+
await saveDraft(next, savedRevision.current, savedRequestId.current);
|
|
94
|
+
savedRevision.current = next.revision;
|
|
95
|
+
savedRequestId.current = next.requestId;
|
|
96
|
+
saveFailure.current = null;
|
|
97
|
+
if (live.current) {
|
|
98
|
+
setError("");
|
|
99
|
+
setSaving(current.current.revision !== next.revision);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
catch (e) {
|
|
103
|
+
saveFailure.current = new Error(errorMessage(e));
|
|
104
|
+
if (live.current) {
|
|
105
|
+
setSaving(false);
|
|
106
|
+
setError("Not saved locally: " + errorMessage(e));
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
async function flush() { await queue.current; if (saveFailure.current)
|
|
112
|
+
throw saveFailure.current; }
|
|
113
|
+
function noteChange(id, change) {
|
|
114
|
+
if (current.current.submitting)
|
|
115
|
+
return;
|
|
116
|
+
update({ annotations: current.current.annotations.map(n => n.id === id ? { ...n, ...change } : n) });
|
|
117
|
+
}
|
|
118
|
+
function add(target, element) {
|
|
119
|
+
if (current.current.annotations.length >= capabilities.maxAnnotations) {
|
|
120
|
+
setError("A report can contain at most 20 annotations.");
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
const note = makeNote(target);
|
|
124
|
+
if (element)
|
|
125
|
+
elements.current.set(note.id, element);
|
|
126
|
+
update({ annotations: [...current.current.annotations, note] });
|
|
127
|
+
setEdit({ id: note.id, where: "anchor" });
|
|
128
|
+
setPicking(null);
|
|
129
|
+
setHighlight(target.bounds ?? null);
|
|
130
|
+
}
|
|
131
|
+
useEffect(() => {
|
|
132
|
+
if (!picking || draft.submitting || !open)
|
|
133
|
+
return;
|
|
134
|
+
let start = null;
|
|
135
|
+
const inside = (event) => event.composedPath().some(node => node instanceof Element && node.hasAttribute("data-hosty-feedback"));
|
|
136
|
+
const move = (event) => {
|
|
137
|
+
if (inside(event))
|
|
138
|
+
return;
|
|
139
|
+
if (picking === "region" && start)
|
|
140
|
+
setHighlight({ x: Math.min(start.x, event.clientX), y: Math.min(start.y, event.clientY),
|
|
141
|
+
width: Math.abs(event.clientX - start.x), height: Math.abs(event.clientY - start.y) });
|
|
142
|
+
else if (picking === "element") {
|
|
143
|
+
const element = document.elementFromPoint(event.clientX, event.clientY);
|
|
144
|
+
if (element)
|
|
145
|
+
setHighlight(rect(element.getBoundingClientRect()));
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
const down = (event) => {
|
|
149
|
+
if (inside(event))
|
|
150
|
+
return;
|
|
151
|
+
event.preventDefault();
|
|
152
|
+
event.stopImmediatePropagation();
|
|
153
|
+
// React may remove the picker effect before the native click follows pointerup.
|
|
154
|
+
const swallow = (click) => { click.preventDefault(); click.stopImmediatePropagation(); cleanup(); };
|
|
155
|
+
const cleanup = () => { document.removeEventListener("click", swallow, true); clearTimeout(timer); };
|
|
156
|
+
const timer = setTimeout(cleanup, 1500);
|
|
157
|
+
document.addEventListener("click", swallow, { capture: true, once: true });
|
|
158
|
+
if (picking === "region") {
|
|
159
|
+
start = { x: event.clientX, y: event.clientY };
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
const element = document.elementFromPoint(event.clientX, event.clientY);
|
|
163
|
+
if (!element)
|
|
164
|
+
return;
|
|
165
|
+
// Never read input values, text content, or arbitrary data attributes into evidence.
|
|
166
|
+
const label = element.getAttribute("aria-label")?.slice(0, 200) || element.tagName.toLowerCase();
|
|
167
|
+
const selector = element.id ? "#" + CSS.escape(element.id) : element.tagName.toLowerCase();
|
|
168
|
+
add({ kind: "element", label, selector, bounds: rect(element.getBoundingClientRect()), viewport: viewport() }, element);
|
|
169
|
+
};
|
|
170
|
+
const up = (event) => {
|
|
171
|
+
if (!start)
|
|
172
|
+
return;
|
|
173
|
+
event.preventDefault();
|
|
174
|
+
event.stopImmediatePropagation();
|
|
175
|
+
const b = { x: Math.min(start.x, event.clientX), y: Math.min(start.y, event.clientY),
|
|
176
|
+
width: Math.abs(event.clientX - start.x), height: Math.abs(event.clientY - start.y) };
|
|
177
|
+
start = null;
|
|
178
|
+
if (b.width > 3 && b.height > 3)
|
|
179
|
+
add({ kind: "region", label: "Selected region", bounds: b, viewport: viewport() });
|
|
180
|
+
};
|
|
181
|
+
const click = (event) => { if (!inside(event)) {
|
|
182
|
+
event.preventDefault();
|
|
183
|
+
event.stopImmediatePropagation();
|
|
184
|
+
} };
|
|
185
|
+
const escape = (event) => { if (event.key === "Escape") {
|
|
186
|
+
setPicking(null);
|
|
187
|
+
setHighlight(null);
|
|
188
|
+
} };
|
|
189
|
+
document.addEventListener("pointermove", move, true);
|
|
190
|
+
document.addEventListener("pointerdown", down, true);
|
|
191
|
+
document.addEventListener("pointerup", up, true);
|
|
192
|
+
document.addEventListener("click", click, true);
|
|
193
|
+
document.addEventListener("keydown", escape, true);
|
|
194
|
+
const old = document.documentElement.style.cursor;
|
|
195
|
+
document.documentElement.style.cursor = "crosshair";
|
|
196
|
+
return () => {
|
|
197
|
+
document.removeEventListener("pointermove", move, true);
|
|
198
|
+
document.removeEventListener("pointerdown", down, true);
|
|
199
|
+
document.removeEventListener("pointerup", up, true);
|
|
200
|
+
document.removeEventListener("click", click, true);
|
|
201
|
+
document.removeEventListener("keydown", escape, true);
|
|
202
|
+
document.documentElement.style.cursor = old;
|
|
203
|
+
};
|
|
204
|
+
}, [picking, draft.submitting, open]);
|
|
205
|
+
const selected = draft.annotations.find(n => n.id === edit?.id);
|
|
206
|
+
useEffect(() => {
|
|
207
|
+
if (picking || !edit || !open)
|
|
208
|
+
return;
|
|
209
|
+
const refresh = () => {
|
|
210
|
+
const element = elements.current.get(edit.id);
|
|
211
|
+
setHighlight(element?.isConnected ? rect(element.getBoundingClientRect()) : current.current.annotations.find(n => n.id === edit.id)?.target?.bounds ?? null);
|
|
212
|
+
};
|
|
213
|
+
refresh();
|
|
214
|
+
window.addEventListener("scroll", refresh, true);
|
|
215
|
+
window.addEventListener("resize", refresh);
|
|
216
|
+
return () => { window.removeEventListener("scroll", refresh, true); window.removeEventListener("resize", refresh); };
|
|
217
|
+
}, [edit, picking, open]);
|
|
218
|
+
async function done() {
|
|
219
|
+
setEdit(null);
|
|
220
|
+
setHighlight(null);
|
|
221
|
+
try {
|
|
222
|
+
await flush();
|
|
223
|
+
}
|
|
224
|
+
catch (e) {
|
|
225
|
+
setError(errorMessage(e));
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
async function screenshot(id) {
|
|
229
|
+
if (capture.current || draft.submitting)
|
|
230
|
+
return;
|
|
231
|
+
const operation = new AbortController();
|
|
232
|
+
capture.current = operation;
|
|
233
|
+
setCapturing(true);
|
|
234
|
+
setError("");
|
|
235
|
+
try {
|
|
236
|
+
const blob = await captureStill(value => { if (root.current)
|
|
237
|
+
root.current.style.visibility = value ? "hidden" : ""; setHidden(value); }, operation.signal);
|
|
238
|
+
if (live.current && !operation.signal.aborted)
|
|
239
|
+
setPreview({ id, blob });
|
|
240
|
+
}
|
|
241
|
+
catch (e) {
|
|
242
|
+
if (live.current && !operation.signal.aborted)
|
|
243
|
+
setError(errorMessage(e));
|
|
244
|
+
}
|
|
245
|
+
finally {
|
|
246
|
+
if (capture.current === operation)
|
|
247
|
+
capture.current = null;
|
|
248
|
+
if (live.current)
|
|
249
|
+
setCapturing(false);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
async function acceptImage(blob) {
|
|
253
|
+
if (!preview)
|
|
254
|
+
return;
|
|
255
|
+
if (blob.size > capabilities.maxImageBytes) {
|
|
256
|
+
setError("Crop a smaller area: each screenshot must fit within 2 MiB.");
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
const total = current.current.annotations.filter(n => n.id !== preview.id).reduce((sum, n) => sum + (n.image?.size ?? 0), 0) + blob.size;
|
|
260
|
+
if (total > capabilities.maxTotalImageBytes) {
|
|
261
|
+
setError("Screenshots may total at most 8 MiB. Remove or crop an existing image.");
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
noteChange(preview.id, { image: blob });
|
|
265
|
+
setPreview(null);
|
|
266
|
+
}
|
|
267
|
+
async function send() {
|
|
268
|
+
if (busy || !current.current.annotations.length || current.current.annotations.some(n => !n.text.trim()))
|
|
269
|
+
return;
|
|
270
|
+
setBusy(true);
|
|
271
|
+
setError("");
|
|
272
|
+
try {
|
|
273
|
+
update({ submitting: true });
|
|
274
|
+
await flush();
|
|
275
|
+
const frozen = current.current;
|
|
276
|
+
const response = await appFetch(path, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(await submission(frozen)) });
|
|
277
|
+
const result = await response.json().catch(() => null);
|
|
278
|
+
if (!response.ok) {
|
|
279
|
+
if ([400, 413, 415].includes(response.status))
|
|
280
|
+
update({ submitting: false, requestId: crypto.randomUUID() });
|
|
281
|
+
throw new Error(result?.message ?? "Submission was not confirmed. Retry the same feedback; your draft is retained.");
|
|
282
|
+
}
|
|
283
|
+
if (!result || !["submitted", "deleted"].includes(result.status ?? ""))
|
|
284
|
+
throw new Error("Submission was not confirmed. Retry the same feedback.");
|
|
285
|
+
await deleteDraft(key, frozen.requestId, frozen.revision);
|
|
286
|
+
const fresh = blankDraft(key);
|
|
287
|
+
savedRevision.current = 0;
|
|
288
|
+
savedRequestId.current = null;
|
|
289
|
+
current.current = fresh;
|
|
290
|
+
setDraft(fresh);
|
|
291
|
+
setReviewTargets([]);
|
|
292
|
+
if (capabilities.administrator && result.status === "submitted" && result.id) {
|
|
293
|
+
void appFetch(path + "/" + encodeURIComponent(result.id) + "/review-targets").then(async (response) => {
|
|
294
|
+
if (!response.ok)
|
|
295
|
+
return;
|
|
296
|
+
const value = await response.json();
|
|
297
|
+
if (live.current)
|
|
298
|
+
setReviewTargets(value.targets ?? []);
|
|
299
|
+
}).catch(() => undefined);
|
|
300
|
+
}
|
|
301
|
+
setEdit(null);
|
|
302
|
+
setHighlight(null);
|
|
303
|
+
setNotice(result.status === "deleted" ? "This feedback was already submitted and subsequently removed with its workspace." : "Feedback submitted.");
|
|
304
|
+
}
|
|
305
|
+
catch (e) {
|
|
306
|
+
setError(errorMessage(e));
|
|
307
|
+
}
|
|
308
|
+
finally {
|
|
309
|
+
if (live.current)
|
|
310
|
+
setBusy(false);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
async function discard() {
|
|
314
|
+
if (!window.confirm(current.current.submitting ? "This submission may already exist. Discard its local retry record? This does not delete a submitted feedback." : "Discard this local feedback draft and its screenshots?"))
|
|
315
|
+
return;
|
|
316
|
+
try {
|
|
317
|
+
await queue.current;
|
|
318
|
+
await deleteDraft(key, current.current.requestId, savedRevision.current);
|
|
319
|
+
const fresh = blankDraft(key);
|
|
320
|
+
savedRevision.current = 0;
|
|
321
|
+
savedRequestId.current = null;
|
|
322
|
+
saveFailure.current = null;
|
|
323
|
+
current.current = fresh;
|
|
324
|
+
setDraft(fresh);
|
|
325
|
+
setEdit(null);
|
|
326
|
+
setHighlight(null);
|
|
327
|
+
setError("");
|
|
328
|
+
setNotice("Draft discarded.");
|
|
329
|
+
}
|
|
330
|
+
catch (e) {
|
|
331
|
+
setError(errorMessage(e));
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
function editor(note) {
|
|
335
|
+
return _jsxs(_Fragment, { children: [_jsxs("label", { children: ["Comment", _jsx("textarea", { "aria-label": "Comment", autoFocus: true, maxLength: 2000, value: note.text, disabled: draft.submitting, onChange: event => noteChange(note.id, { text: event.target.value }), placeholder: "What should change here?" })] }), _jsx(FeedbackSpeech, { path: path, disabled: draft.submitting, onBusyChange: setDictating, onText: text => {
|
|
336
|
+
const old = current.current.annotations.find(n => n.id === note.id);
|
|
337
|
+
if (!old)
|
|
338
|
+
return;
|
|
339
|
+
const next = [old.text, text].filter(Boolean).join(" ");
|
|
340
|
+
if (next.length > 2000) {
|
|
341
|
+
setError("Dictation exceeds the comment limit. Shorten the comment before dictating again.");
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
noteChange(note.id, { text: next });
|
|
345
|
+
} }), _jsxs("div", { className: "hf-actions", children: [_jsx("button", { onClick: () => void screenshot(note.id), disabled: capturing || draft.submitting, children: "Add screenshot" }), _jsx("button", { disabled: dictating, onClick: () => void done(), children: "Done" })] })] });
|
|
346
|
+
}
|
|
347
|
+
const popup = { left: Math.max(12, Math.min(highlight?.x ?? 24, innerWidth - 340)),
|
|
348
|
+
top: Math.max(12, Math.min((highlight?.y ?? 40) + (highlight?.height ?? 0) + 12, innerHeight - 250)) };
|
|
349
|
+
if (!ready)
|
|
350
|
+
return null;
|
|
351
|
+
return createPortal(_jsxs("div", { ref: root, "data-hosty-feedback": true, "data-hosty-sdk": true, style: { visibility: hidden ? "hidden" : undefined }, children: [_jsx(SdkChromeStyles, {}), _jsx("style", { children: feedbackStyles }), !open && _jsxs("button", { className: "hosty-feedback-launch", onClick: () => setOpen(true), children: ["Annotate", draft.annotations.length ? " · " + draft.annotations.length : ""] }), open && _jsxs(_Fragment, { children: [picking && _jsxs("div", { className: "hosty-feedback-launch", role: "status", children: [picking === "element" ? "Select an element" : "Drag a region", " \u00B7 Escape cancels ", _jsx("button", { onClick: () => { setPicking(null); setHighlight(null); }, children: "Cancel selection" })] }), _jsxs("aside", { className: "hf-panel", "aria-label": "Feedback draft", style: { visibility: picking ? "hidden" : undefined,
|
|
352
|
+
...(edit?.where === "anchor" && highlight && highlight.x > innerWidth - 400 ? { left: 12, right: "auto" } : {}) }, children: [_jsxs("header", { children: [_jsxs("div", { children: [_jsx("strong", { children: "Feedback" }), _jsx("small", { children: saving ? "Saving…" : saveFailure.current ? "Not saved locally" : draft.revision ? "Saved locally" : "New report" })] }), _jsx("button", { "aria-label": "Close feedback", onClick: () => { setOpen(false); setPicking(null); capture.current?.abort(); }, children: "\u00D7" })] }), _jsx("p", { children: "Collect related comments, then send them as one feedback." }), _jsxs("div", { className: "hf-actions", children: [_jsx("button", { disabled: draft.submitting, "aria-pressed": picking === "element", onClick: () => { setEdit(null); setPicking("element"); }, children: "Select element" }), _jsx("button", { disabled: draft.submitting, "aria-pressed": picking === "region", onClick: () => { setEdit(null); setPicking("region"); }, children: "Select region" }), _jsx("button", { disabled: draft.submitting, onClick: () => add({ kind: "page", label: "This page", viewport: viewport() }), children: "Page comment" })] }), picking && _jsxs("p", { role: "status", children: [picking === "element" ? "Click an element to annotate it." : "Drag over the area to annotate.", " Escape cancels selection."] }), _jsx("ol", { children: draft.annotations.map((note, index) => _jsxs("li", { children: [_jsxs("div", { className: "hf-note-heading", children: [_jsxs("button", { onClick: () => { setPicking(null); setEdit({ id: note.id, where: "anchor" }); elements.current.get(note.id)?.scrollIntoView({ block: "nearest" }); }, children: [index + 1, ". ", note.target?.label ?? "Annotation"] }), _jsx("button", { disabled: draft.submitting, onClick: () => { update({ annotations: current.current.annotations.filter(n => n.id !== note.id) }); if (edit?.id === note.id)
|
|
353
|
+
setEdit(null); }, children: "Remove" })] }), edit?.id === note.id && edit.where === "side" ? editor(note) : _jsxs(_Fragment, { children: [_jsx("p", { children: note.text || "Add a comment…" }), _jsx("button", { onClick: () => { setPicking(null); setEdit({ id: note.id, where: "side" }); }, children: "Edit" })] }), note.image && _jsxs(_Fragment, { children: [_jsx(ImagePreview, { blob: note.image }), _jsx("button", { disabled: draft.submitting, onClick: () => noteChange(note.id, { image: undefined }), children: "Remove screenshot" })] })] }, note.id)) }), _jsxs("label", { children: ["Overall comment (optional)", _jsx("textarea", { maxLength: 5000, value: draft.comment, disabled: draft.submitting, onChange: event => update({ comment: event.target.value }) })] }), error && _jsx("p", { role: "alert", className: "hf-error", children: error }), notice && _jsx("p", { role: "status", children: notice }), reviewTargets.map(target => _jsx("p", { children: _jsxs("a", { href: target.href, target: "_blank", rel: "noopener noreferrer", children: ["Send to chat in ", target.label] }) }, target.href)), draft.submitting && !busy && _jsx("p", { children: "The submission may already have reached Core. Retry to recover its confirmation." }), _jsxs("footer", { children: [_jsx("button", { onClick: () => void discard(), disabled: busy, children: "Discard draft" }), _jsx("button", { className: "hf-primary", disabled: busy || dictating || capturing || !draft.annotations.length || draft.annotations.some(n => !n.text.trim()), onClick: () => void send(), children: busy ? "Sending…" : draft.submitting ? "Retry submission" : "Submit feedback" })] })] }), highlight && _jsx("div", { className: "hf-highlight", "aria-hidden": true, style: { left: highlight.x, top: highlight.y, width: highlight.width, height: highlight.height } }), selected && edit?.where === "anchor" && !picking && _jsxs("section", { className: "hf-popover", role: "dialog", "aria-label": "Annotation comment", style: popup, children: [_jsx("strong", { children: selected.target?.label ?? "Annotation" }), editor(selected)] }), preview && _jsx(CropPreview, { blob: preview.blob, onCancel: () => setPreview(null), onAccept: acceptImage })] })] }), document.body);
|
|
354
|
+
}
|
|
355
|
+
function CropPreview({ blob, onCancel, onAccept }) {
|
|
356
|
+
const url = useBlobUrl(blob);
|
|
357
|
+
const [crop, setCrop] = useState(null), [busy, setBusy] = useState(false), [error, setError] = useState("");
|
|
358
|
+
const dialog = useRef(null);
|
|
359
|
+
useEffect(() => {
|
|
360
|
+
const node = dialog.current;
|
|
361
|
+
node?.showModal();
|
|
362
|
+
return () => { node?.close(); };
|
|
363
|
+
}, []);
|
|
364
|
+
const start = useRef(null);
|
|
365
|
+
const point = (event) => {
|
|
366
|
+
const bounds = event.currentTarget.getBoundingClientRect();
|
|
367
|
+
return { x: Math.max(0, Math.min(1, (event.clientX - bounds.x) / bounds.width)), y: Math.max(0, Math.min(1, (event.clientY - bounds.y) / bounds.height)) };
|
|
368
|
+
};
|
|
369
|
+
return _jsxs("dialog", { ref: dialog, className: "hf-crop", "aria-label": "Review screenshot", onCancel: event => { event.preventDefault(); if (!busy)
|
|
370
|
+
onCancel(); }, children: [_jsx("h2", { children: "Review screenshot" }), _jsx("p", { children: "Check the selected source. Drag to crop, or adjust the percentages below. Only the approved image is included." }), _jsxs("div", { className: "hf-crop-image", onPointerDown: event => { start.current = point(event); event.currentTarget.setPointerCapture(event.pointerId); }, onPointerMove: event => {
|
|
371
|
+
if (!start.current)
|
|
372
|
+
return;
|
|
373
|
+
const p = point(event);
|
|
374
|
+
setCrop({ x: Math.min(p.x, start.current.x), y: Math.min(p.y, start.current.y),
|
|
375
|
+
width: Math.abs(p.x - start.current.x), height: Math.abs(p.y - start.current.y) });
|
|
376
|
+
}, onPointerUp: () => { start.current = null; }, children: [url && _jsx("img", { src: url, alt: "Captured screen for review", draggable: false }), crop && _jsx("div", { className: "hf-crop-selection", style: { left: crop.x * 100 + "%", top: crop.y * 100 + "%", width: crop.width * 100 + "%", height: crop.height * 100 + "%" } })] }), _jsx("div", { className: "hf-actions", children: ["x", "y", "width", "height"].map(field => _jsxs("label", { children: [field === "x" ? "Left %" : field === "y" ? "Top %" : field === "width" ? "Width %" : "Height %", _jsx("input", { type: "number", min: 0, max: 100, value: Math.round((crop?.[field] ?? (field === "width" || field === "height" ? 1 : 0)) * 100), onChange: event => setCrop({ ...(crop ?? { x: 0, y: 0, width: 1, height: 1 }), [field]: Math.max(0, Math.min(1, Number(event.target.value) / 100)) }) })] }, field)) }), error && _jsx("p", { role: "alert", children: error }), _jsxs("footer", { children: [_jsx("button", { onClick: onCancel, disabled: busy, children: "Cancel" }), _jsx("button", { onClick: () => setCrop(null), children: "Use full image" }), _jsx("button", { className: "hf-primary", disabled: busy || !!crop && (!crop.width || !crop.height), onClick: () => { setBusy(true); void cropStill(blob, crop).then(onAccept).catch(e => setError(errorMessage(e))).finally(() => setBusy(false)); }, children: "Include screenshot" })] })] });
|
|
377
|
+
}
|
|
378
|
+
const feedbackStyles = [
|
|
379
|
+
"[data-hosty-feedback]{font-family:var(--hs-font);font-size:14px;color:#0f1b2d;line-height:1.45}",
|
|
380
|
+
".hosty-feedback-launch{position:fixed;left:16px;bottom:16px;z-index:2147481000;background:#fff;border:1px solid #bdc8d6;border-radius:20px;padding:10px 16px;box-shadow:0 3px 14px #0f1b2d20}",
|
|
381
|
+
"[data-hosty-feedback] button{font:inherit;cursor:pointer;border:1px solid #cbd5e1;border-radius:7px;background:#fff;padding:6px 10px;color:#0f1b2d}",
|
|
382
|
+
"[data-hosty-feedback] button:disabled{opacity:.5;cursor:not-allowed}[data-hosty-feedback] button:focus-visible,[data-hosty-feedback] textarea:focus{outline:2px solid #65a30d;outline-offset:2px}",
|
|
383
|
+
"[data-hosty-feedback] textarea{box-sizing:border-box;display:block;width:100%;min-height:90px;font:inherit;color:#0f1b2d;background:#fff;border:1px solid #cbd5e1;border-radius:7px;padding:8px;resize:vertical;margin:6px 0}",
|
|
384
|
+
"[data-hosty-feedback] .hf-primary{background:#a3e635;border-color:#84cc16}[data-hosty-feedback] .hf-error{color:#b91c1c}",
|
|
385
|
+
".hf-panel{box-sizing:border-box;position:fixed;right:12px;top:12px;bottom:12px;width:min(360px,calc(100vw - 24px));background:#fff;border:1px solid #cbd5e1;border-radius:12px;padding:16px;box-shadow:0 10px 45px #0f1b2d30;overflow:auto;z-index:2147481001}",
|
|
386
|
+
".hf-panel header,.hf-note-heading,.hf-panel footer,.hf-crop footer{display:flex;justify-content:space-between;align-items:center;gap:8px}.hf-panel small{display:block;color:#64748b}.hf-panel ol{padding:0;list-style:none}.hf-panel li{border:1px solid #e2e8f0;border-radius:9px;padding:10px;margin:12px 0}.hf-panel p{white-space:pre-wrap;overflow-wrap:anywhere}.hf-panel img{max-width:100%;border-radius:6px;margin:8px 0}.hf-panel footer{margin-top:14px}",
|
|
387
|
+
".hf-actions{display:flex;flex-wrap:wrap;gap:6px;margin:8px 0}.hf-highlight{position:fixed;border:2px solid #65a30d;box-shadow:0 0 0 2px #ecfccb;pointer-events:none;z-index:2147481000;background:#a3e63518;box-sizing:border-box}",
|
|
388
|
+
".hf-popover>strong{display:block;margin-bottom:6px}.hf-popover{position:fixed;width:min(310px,calc(100vw - 32px));box-sizing:border-box;z-index:2147481002;background:#fff;border:1px solid #84cc16;border-radius:10px;padding:12px;box-shadow:0 8px 28px #0f1b2d35}",
|
|
389
|
+
".hf-backdrop{position:fixed;inset:0;background:#0f1b2d88;display:grid;place-items:center;z-index:2147481003;padding:16px}.hf-crop::backdrop{background:#0f1b2d88}.hf-crop{border:0;color:#0f1b2d;background:#fff;border-radius:12px;padding:20px;width:min(760px,90vw);max-height:90vh;overflow:auto}.hf-crop-image{position:relative;display:table;max-width:100%;touch-action:none}.hf-crop-image img{display:block;max-width:100%;max-height:55vh;user-select:none}.hf-crop-selection{position:absolute;border:2px solid #65a30d;background:#a3e63530;pointer-events:none;box-sizing:border-box}.hf-crop input{display:block;width:70px}"
|
|
390
|
+
].join("\n");
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { ProviderServerOptions } from "@hosty-sdk/app/providers/server";
|
|
2
|
+
import { type FeedbackCapabilities, type FeedbackSubmit, type FeedbackReport, type FeedbackCollection, type FeedbackDispatch, type FeedbackDispatchInput, type FeedbackDelivery, type FeedbackOutcome } from "@hosty-sdk/app/feedback";
|
|
3
|
+
export declare class FeedbackServerClient {
|
|
4
|
+
private readonly appId;
|
|
5
|
+
private readonly core;
|
|
6
|
+
private readonly service;
|
|
7
|
+
constructor(options?: ProviderServerOptions);
|
|
8
|
+
private response;
|
|
9
|
+
private request;
|
|
10
|
+
capabilities(token: string, signal?: AbortSignal): Promise<FeedbackCapabilities>;
|
|
11
|
+
submit(input: FeedbackSubmit, token: string, signal?: AbortSignal): Promise<{
|
|
12
|
+
id: string;
|
|
13
|
+
status: string;
|
|
14
|
+
}>;
|
|
15
|
+
reviewTargets(id: string, token: string, signal?: AbortSignal): Promise<{
|
|
16
|
+
targets: {
|
|
17
|
+
label: string;
|
|
18
|
+
href: string;
|
|
19
|
+
}[];
|
|
20
|
+
}>;
|
|
21
|
+
list(token: string, signal?: AbortSignal): Promise<FeedbackCollection>;
|
|
22
|
+
change(id: string, input: {
|
|
23
|
+
expectedRevision: number;
|
|
24
|
+
workspaceId: string | null;
|
|
25
|
+
disposition?: string | null;
|
|
26
|
+
}, token: string): Promise<FeedbackReport>;
|
|
27
|
+
reserve(input: FeedbackDispatchInput, token: string): Promise<FeedbackDispatch>;
|
|
28
|
+
delivery(id: string, input: FeedbackDelivery, token: string): Promise<FeedbackDispatch>;
|
|
29
|
+
outcomes(id: string, outcomes: FeedbackOutcome[], token: string): Promise<FeedbackDispatch>;
|
|
30
|
+
image(id: string, noteId: string, token: string, signal?: AbortSignal): Promise<Response>;
|
|
31
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { FeedbackError } from "@hosty-sdk/app/feedback";
|
|
2
|
+
export class FeedbackServerClient {
|
|
3
|
+
appId;
|
|
4
|
+
core;
|
|
5
|
+
service;
|
|
6
|
+
constructor(options = {}) {
|
|
7
|
+
this.appId = options.appId ?? process.env.HOSTY_APP_ID ?? "";
|
|
8
|
+
this.core = new URL(options.coreOrigin ?? process.env.HOSTY_CORE_ORIGIN ?? "http://127.0.0.1:7070");
|
|
9
|
+
this.service = options.serviceToken ?? process.env.HOSTY_APP_SERVICE_TOKEN ?? "";
|
|
10
|
+
if (!this.appId || !this.service)
|
|
11
|
+
throw new FeedbackError("hosty_misconfigured", "Hosty app credentials are unavailable.", 503);
|
|
12
|
+
}
|
|
13
|
+
async response(path, token, body, signal) {
|
|
14
|
+
if (!token)
|
|
15
|
+
throw new FeedbackError("identity_required", "Sign in to use feedback.", 401);
|
|
16
|
+
const response = await fetch(new URL("/api/internal/apps/" + encodeURIComponent(this.appId) + "/" + path, this.core), {
|
|
17
|
+
method: body === undefined ? "GET" : "POST", redirect: "error", cache: "no-store",
|
|
18
|
+
signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(30_000)]) : AbortSignal.timeout(30_000),
|
|
19
|
+
headers: { authorization: "Bearer " + this.service, "X-Hosty-User-Token": token, "content-type": "application/json" },
|
|
20
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
21
|
+
});
|
|
22
|
+
if (!response.ok) {
|
|
23
|
+
const error = await response.json().catch(() => null);
|
|
24
|
+
throw new FeedbackError(error?.code ?? "feedback_unavailable", error?.message ?? "Feedback is unavailable. Your draft is retained.", response.status);
|
|
25
|
+
}
|
|
26
|
+
return response;
|
|
27
|
+
}
|
|
28
|
+
async request(path, token, body, signal) {
|
|
29
|
+
return (await this.response(path, token, body, signal)).json();
|
|
30
|
+
}
|
|
31
|
+
capabilities(token, signal) { return this.request("ui/feedback", token, undefined, signal); }
|
|
32
|
+
submit(input, token, signal) { return this.request("ui/feedback", token, input, signal); }
|
|
33
|
+
reviewTargets(id, token, signal) { return this.request("ui/feedback/" + encodeURIComponent(id) + "/review-targets", token, undefined, signal); }
|
|
34
|
+
list(token, signal) { return this.request("feedback", token, undefined, signal); }
|
|
35
|
+
change(id, input, token) {
|
|
36
|
+
return this.request("feedback/" + encodeURIComponent(id), token, input);
|
|
37
|
+
}
|
|
38
|
+
reserve(input, token) { return this.request("feedback/dispatches", token, input); }
|
|
39
|
+
delivery(id, input, token) {
|
|
40
|
+
return this.request("feedback/dispatches/" + encodeURIComponent(id) + "/delivery", token, input);
|
|
41
|
+
}
|
|
42
|
+
outcomes(id, outcomes, token) {
|
|
43
|
+
return this.request("feedback/dispatches/" + encodeURIComponent(id) + "/outcomes", token, { outcomes });
|
|
44
|
+
}
|
|
45
|
+
image(id, noteId, token, signal) {
|
|
46
|
+
return this.response("feedback/" + encodeURIComponent(id) + "/images/" + encodeURIComponent(noteId), token, undefined, signal);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
import { useEffect, useRef, useState } from "react";
|
|
4
|
+
import { appFetch } from "./browser-auth.js";
|
|
5
|
+
import { recordingWave } from "./feedback-audio.js";
|
|
6
|
+
const dispose = (op) => { if (!op)
|
|
7
|
+
return; op.controller.abort(); clearTimeout(op.timer); if (op.recorder?.state === "recording")
|
|
8
|
+
op.recorder.stop(); op.stream?.getTracks().forEach(t => t.stop()); };
|
|
9
|
+
export function FeedbackSpeech({ path, disabled, onText, onBusyChange }) {
|
|
10
|
+
const [providers, setProviders] = useState([]), [choice, setChoice] = useState("");
|
|
11
|
+
const [phase, setPhase] = useState("idle"), [error, setError] = useState("");
|
|
12
|
+
const active = useRef(null), result = useRef(onText);
|
|
13
|
+
result.current = onText;
|
|
14
|
+
const endpoint = path + "/speech";
|
|
15
|
+
useEffect(() => { onBusyChange(phase !== "idle"); return () => onBusyChange(false); }, [phase, onBusyChange]);
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
const abort = new AbortController();
|
|
18
|
+
void appFetch(endpoint, { credentials: "include", signal: abort.signal }).then(async (response) => {
|
|
19
|
+
const value = await response.json();
|
|
20
|
+
if (!response.ok)
|
|
21
|
+
throw new Error(value.message ?? "Speech input is unavailable.");
|
|
22
|
+
if (!abort.signal.aborted) {
|
|
23
|
+
setProviders(value.providers ?? []);
|
|
24
|
+
if (!value.granted)
|
|
25
|
+
setError("Speech input is not enabled for this application.");
|
|
26
|
+
}
|
|
27
|
+
}).catch(e => { if (!abort.signal.aborted)
|
|
28
|
+
setError(e.message); });
|
|
29
|
+
return () => { abort.abort(); dispose(active.current); active.current = null; };
|
|
30
|
+
}, [endpoint]);
|
|
31
|
+
useEffect(() => { if (disabled) {
|
|
32
|
+
dispose(active.current);
|
|
33
|
+
active.current = null;
|
|
34
|
+
setPhase("idle");
|
|
35
|
+
} }, [disabled]);
|
|
36
|
+
const selected = providers.find(p => JSON.stringify([p.appId, p.key]) === choice) ?? (providers.length === 1 ? providers[0] : undefined);
|
|
37
|
+
function cancel() { dispose(active.current); active.current = null; setPhase("idle"); }
|
|
38
|
+
async function start() {
|
|
39
|
+
if (!selected?.available || disabled || active.current)
|
|
40
|
+
return;
|
|
41
|
+
const op = { controller: new AbortController() };
|
|
42
|
+
active.current = op;
|
|
43
|
+
const valid = () => active.current === op && !op.controller.signal.aborted;
|
|
44
|
+
setPhase("starting");
|
|
45
|
+
setError("");
|
|
46
|
+
try {
|
|
47
|
+
if (!navigator.mediaDevices?.getUserMedia || typeof MediaRecorder === "undefined")
|
|
48
|
+
throw new Error("This browser cannot record audio here. Type your comment instead.");
|
|
49
|
+
op.stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
50
|
+
if (!valid()) {
|
|
51
|
+
dispose(op);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
const recorder = new MediaRecorder(op.stream);
|
|
55
|
+
op.recorder = recorder;
|
|
56
|
+
const chunks = [];
|
|
57
|
+
let bytes = 0;
|
|
58
|
+
recorder.ondataavailable = e => { bytes += e.data.size; if (bytes > 16 * 1024 * 1024) {
|
|
59
|
+
cancel();
|
|
60
|
+
setError("Recording is too large. Try a shorter comment.");
|
|
61
|
+
}
|
|
62
|
+
else if (e.data.size)
|
|
63
|
+
chunks.push(e.data); };
|
|
64
|
+
recorder.onerror = () => { if (valid()) {
|
|
65
|
+
cancel();
|
|
66
|
+
setError("Recording failed. Type your comment or try again.");
|
|
67
|
+
} };
|
|
68
|
+
recorder.onstop = () => {
|
|
69
|
+
clearTimeout(op.timer);
|
|
70
|
+
op.stream?.getTracks().forEach(t => t.stop());
|
|
71
|
+
if (!valid())
|
|
72
|
+
return;
|
|
73
|
+
setPhase("transcribing");
|
|
74
|
+
void (async () => {
|
|
75
|
+
try {
|
|
76
|
+
const wave = await recordingWave(new Blob(chunks, { type: recorder.mimeType }));
|
|
77
|
+
if (!valid())
|
|
78
|
+
return;
|
|
79
|
+
const data = new Uint8Array(await wave.arrayBuffer());
|
|
80
|
+
let binary = "";
|
|
81
|
+
for (let at = 0; at < data.length; at += 8192)
|
|
82
|
+
binary += String.fromCharCode(...data.subarray(at, at + 8192));
|
|
83
|
+
const response = await appFetch(endpoint, { method: "POST", credentials: "include", signal: op.controller.signal,
|
|
84
|
+
headers: { "content-type": "application/json" }, body: JSON.stringify({ appId: selected.appId, key: selected.key, audio: btoa(binary) }) });
|
|
85
|
+
const value = await response.json();
|
|
86
|
+
if (!response.ok)
|
|
87
|
+
throw new Error(value.message ?? "Speech recognition failed.");
|
|
88
|
+
if (valid()) {
|
|
89
|
+
if (typeof value.text === "string" && value.text.trim())
|
|
90
|
+
result.current(value.text.trim());
|
|
91
|
+
else
|
|
92
|
+
setError("No speech detected.");
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
catch (e) {
|
|
96
|
+
if (valid())
|
|
97
|
+
setError(e instanceof Error ? e.message : "Speech recognition failed.");
|
|
98
|
+
}
|
|
99
|
+
finally {
|
|
100
|
+
if (active.current === op)
|
|
101
|
+
cancel();
|
|
102
|
+
}
|
|
103
|
+
})();
|
|
104
|
+
};
|
|
105
|
+
recorder.start(1000);
|
|
106
|
+
setPhase("recording");
|
|
107
|
+
op.timer = setTimeout(() => { if (recorder.state === "recording")
|
|
108
|
+
recorder.stop(); }, 60_000);
|
|
109
|
+
}
|
|
110
|
+
catch (e) {
|
|
111
|
+
if (valid()) {
|
|
112
|
+
cancel();
|
|
113
|
+
setError(e instanceof Error ? e.message : "Microphone access failed.");
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return _jsxs("div", { className: "hf-speech", children: [providers.length > 1 && _jsxs("label", { children: ["Speech provider", _jsxs("select", { value: choice, disabled: phase !== "idle" || disabled, onChange: e => setChoice(e.target.value), children: [_jsx("option", { value: "", children: "Choose provider" }), providers.map(p => _jsx("option", { value: JSON.stringify([p.appId, p.key]), disabled: !p.available, children: p.displayName }, JSON.stringify([p.appId, p.key])))] })] }), phase === "idle" ? _jsx("button", { disabled: disabled || !selected?.available, title: error || "Dictate up to one minute", onClick: () => void start(), children: "Dictate" })
|
|
118
|
+
: _jsxs(_Fragment, { children: [_jsx("button", { disabled: phase !== "recording", onClick: () => active.current?.recorder?.stop(), children: phase === "recording" ? "Stop and transcribe" : phase === "starting" ? "Opening microphone…" : "Transcribing…" }), _jsx("button", { onClick: cancel, children: "Cancel dictation" })] }), error && _jsx("small", { role: "status", children: error })] });
|
|
119
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
export type FeedbackRect = {
|
|
2
|
+
x: number;
|
|
3
|
+
y: number;
|
|
4
|
+
width: number;
|
|
5
|
+
height: number;
|
|
6
|
+
};
|
|
7
|
+
export type FeedbackTarget = {
|
|
8
|
+
kind: "element" | "region" | "page";
|
|
9
|
+
label?: string;
|
|
10
|
+
selector?: string;
|
|
11
|
+
bounds?: FeedbackRect;
|
|
12
|
+
viewport?: {
|
|
13
|
+
width: number;
|
|
14
|
+
height: number;
|
|
15
|
+
scale: number;
|
|
16
|
+
};
|
|
17
|
+
};
|
|
18
|
+
export type FeedbackNoteInput = {
|
|
19
|
+
id: string;
|
|
20
|
+
text: string;
|
|
21
|
+
route: string;
|
|
22
|
+
capturedAt: string;
|
|
23
|
+
target?: FeedbackTarget;
|
|
24
|
+
image?: {
|
|
25
|
+
mediaType: string;
|
|
26
|
+
data: string;
|
|
27
|
+
};
|
|
28
|
+
};
|
|
29
|
+
export type FeedbackSubmit = {
|
|
30
|
+
requestId: string;
|
|
31
|
+
comment: string;
|
|
32
|
+
annotations: FeedbackNoteInput[];
|
|
33
|
+
scope?: {
|
|
34
|
+
coreId: string;
|
|
35
|
+
appId: string;
|
|
36
|
+
installation: string;
|
|
37
|
+
userId: string;
|
|
38
|
+
};
|
|
39
|
+
};
|
|
40
|
+
export type FeedbackCapabilities = {
|
|
41
|
+
version: 1;
|
|
42
|
+
coreId: string;
|
|
43
|
+
appId: string;
|
|
44
|
+
installation: string;
|
|
45
|
+
userId: string;
|
|
46
|
+
maxAnnotations: number;
|
|
47
|
+
maxImageBytes: number;
|
|
48
|
+
maxTotalImageBytes: number;
|
|
49
|
+
administrator?: boolean;
|
|
50
|
+
};
|
|
51
|
+
export type FeedbackNote = Omit<FeedbackNoteInput, "image"> & {
|
|
52
|
+
mediaType: string | null;
|
|
53
|
+
};
|
|
54
|
+
export type FeedbackReport = {
|
|
55
|
+
id: string;
|
|
56
|
+
reporterId: string;
|
|
57
|
+
comment: string;
|
|
58
|
+
annotations: FeedbackNote[];
|
|
59
|
+
context: {
|
|
60
|
+
appId: string;
|
|
61
|
+
appName: string;
|
|
62
|
+
installation: string;
|
|
63
|
+
version: string | null;
|
|
64
|
+
observedAt: string;
|
|
65
|
+
executionKind: string;
|
|
66
|
+
workspaceId: string | null;
|
|
67
|
+
sourceRevision: string | null;
|
|
68
|
+
};
|
|
69
|
+
workspaceId: string | null;
|
|
70
|
+
revision: number;
|
|
71
|
+
status: string;
|
|
72
|
+
currentAttemptId: string | null;
|
|
73
|
+
disposition: string | null;
|
|
74
|
+
};
|
|
75
|
+
export type FeedbackSelection = {
|
|
76
|
+
id: string;
|
|
77
|
+
revision: number;
|
|
78
|
+
};
|
|
79
|
+
export type FeedbackDispatchInput = {
|
|
80
|
+
requestId: string;
|
|
81
|
+
sessionId: string;
|
|
82
|
+
workspaceId: string | null;
|
|
83
|
+
reports: FeedbackSelection[];
|
|
84
|
+
instruction: string;
|
|
85
|
+
appContextRevision?: number;
|
|
86
|
+
selectionRevision?: number;
|
|
87
|
+
};
|
|
88
|
+
export type FeedbackOutcome = {
|
|
89
|
+
reportId: string;
|
|
90
|
+
summary: string;
|
|
91
|
+
handledAnnotationIds: string[];
|
|
92
|
+
handled: boolean;
|
|
93
|
+
};
|
|
94
|
+
export type FeedbackDispatch = {
|
|
95
|
+
id: string;
|
|
96
|
+
callerAppId: string;
|
|
97
|
+
callerInstallation: string;
|
|
98
|
+
actorId: string;
|
|
99
|
+
input: FeedbackDispatchInput;
|
|
100
|
+
snapshot: FeedbackReport[];
|
|
101
|
+
createdAt: string;
|
|
102
|
+
state: string;
|
|
103
|
+
messageSequence: number | null;
|
|
104
|
+
workspaceId: string | null;
|
|
105
|
+
outcomes: FeedbackOutcome[] | null;
|
|
106
|
+
error: string | null;
|
|
107
|
+
};
|
|
108
|
+
export type FeedbackCollection = {
|
|
109
|
+
reports: FeedbackReport[];
|
|
110
|
+
dispatches: FeedbackDispatch[];
|
|
111
|
+
};
|
|
112
|
+
export type FeedbackDelivery = {
|
|
113
|
+
state: string;
|
|
114
|
+
messageSequence?: number;
|
|
115
|
+
workspaceId?: string | null;
|
|
116
|
+
error?: string;
|
|
117
|
+
};
|
|
118
|
+
export declare class FeedbackError extends Error {
|
|
119
|
+
readonly code: string;
|
|
120
|
+
readonly status: number;
|
|
121
|
+
constructor(code: string, message: string, status: number);
|
|
122
|
+
}
|
package/dist/ui-frame.js
CHANGED
|
@@ -137,5 +137,5 @@ export function HostyAppFrame(props) {
|
|
|
137
137
|
const insecure = typeof window !== "undefined" && window.location.protocol === "https:" && new URL(surface.origin).protocol === "http:";
|
|
138
138
|
if (insecure)
|
|
139
139
|
return _jsxs("div", { "data-hosty-sdk": true, role: "status", className: "hosty-panel-notice", children: [_jsx(SdkChromeStyles, {}), _jsxs("strong", { children: [title, " needs HTTPS to appear here"] }), _jsx("p", { children: "This app publishes an HTTP address. Configure its public HTTPS origin in Hosty." }), /^(localhost|127\.|\[::1\])/.test(new URL(surface.origin).hostname) && _jsx("p", { children: "This local address is reachable only from the Hosty computer." }), standaloneHref && _jsx("a", { href: standaloneHref, target: "_blank", rel: "noopener noreferrer", children: "Open separately" })] });
|
|
140
|
-
return _jsxs(_Fragment, { children: [review && _jsxs(_Fragment, { children: [_jsx(SdkChromeStyles, {}), _jsx("a", { "data-hosty-sdk": true, className: "hosty-panel-review", href: review, target: "_blank", rel: "noopener noreferrer", children: "Review access in Core" })] }), _jsx("iframe", { ref: ref, className: className, title: title, src: launch, sandbox: frameSandbox(surface.framePolicy), allow: surface.framePolicy.microphone ? "clipboard-write; microphone 'src'" : "clipboard-write", style: { border: 0, height: "100%", width: "100%", colorScheme: resolvedTheme }, onLoad: () => setLoaded(true) })] });
|
|
140
|
+
return _jsxs(_Fragment, { children: [review && _jsxs(_Fragment, { children: [_jsx(SdkChromeStyles, {}), _jsx("a", { "data-hosty-sdk": true, className: "hosty-panel-review", href: review, target: "_blank", rel: "noopener noreferrer", children: "Review access in Core" })] }), _jsx("iframe", { ref: ref, className: className, title: title, src: launch, sandbox: frameSandbox(surface.framePolicy), allow: surface.framePolicy.microphone ? "clipboard-write; display-capture 'src'; microphone 'src'" : "clipboard-write; display-capture 'src'", style: { border: 0, height: "100%", width: "100%", colorScheme: resolvedTheme }, onLoad: () => setLoaded(true) })] });
|
|
141
141
|
}
|
package/dist/ui-react.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
import { FeedbackCapture } from "./feedback-react.js";
|
|
3
4
|
import { SdkChromeStyles, SdkIcon } from "./sdk-chrome.js";
|
|
4
5
|
import { useContext, useEffect, useId, useLayoutEffect, useRef, useState, useSyncExternalStore } from "react";
|
|
5
6
|
import { bindUiClient, ui } from "./ui.js";
|
|
@@ -53,7 +54,7 @@ export function HostyUiRoot({ children, uiPath, panelLayout = "auto", active = t
|
|
|
53
54
|
if (allowed)
|
|
54
55
|
mounted.current = true;
|
|
55
56
|
const content = _jsxs("div", { className: "hosty-app-layout", children: [navigation?.renderer !== "custom" && _jsx(HostyNavigation, {}), _jsxs("div", { className: "hosty-app-content", "data-hosty-content": true, tabIndex: -1, children: [_jsx(HostyToolLauncher, {}), children] })] });
|
|
56
|
-
return _jsx(UiHostContext.Provider, { value: host, children: _jsx(UiCompositionContext.Provider, { value: clientRef.current?.composition ?? null, children: _jsxs(UiFrameLayerContext.Provider, { value: layer, children: [_jsx("style", { children: `.hosty-app-layout{display:flex;width:100%;height:100%;min-width:0;min-height:0}.hosty-app-content{flex:1;min-width:0;min-height:0;overflow:auto;outline:none}` }), _jsx("div", { hidden: !allowed, inert: !allowed, style: { display: allowed ? "contents" : "none" }, children: mounted.current && (panelLayout === "auto" && host ? _jsx(HostyPanelWorkspace, { children: content }) : content) }), !allowed && _jsxs("div", { "data-hosty-sdk": true, role: "status", className: "hosty-panel-notice", children: [_jsx(SdkChromeStyles, {}), environment?.connection === "blocked" ? _jsxs(_Fragment, { children: [_jsx("strong", { children: environment.code === "actor_mismatch" ? "Use the same Hosty account in this app and its host" : "Cannot verify this app’s host" }), _jsx("p", { children: "Check your account and access in Hosty Core, then reconnect. Your existing discussion is unchanged." }), _jsx("button", { type: "button", onClick: () => window.location.reload(), children: "Reconnect" })] }) : "Connecting to the workspace…"] }), host && _jsx(HostyPresentationLayer, { layer: layer, host: host, active: !!allowed })] }) }) });
|
|
57
|
+
return _jsx(UiHostContext.Provider, { value: host, children: _jsx(UiCompositionContext.Provider, { value: clientRef.current?.composition ?? null, children: _jsxs(UiFrameLayerContext.Provider, { value: layer, children: [_jsx("style", { children: `.hosty-app-layout{display:flex;width:100%;height:100%;min-width:0;min-height:0}.hosty-app-content{flex:1;min-width:0;min-height:0;overflow:auto;outline:none}` }), _jsx("div", { hidden: !allowed, inert: !allowed, style: { display: allowed ? "contents" : "none" }, children: mounted.current && (panelLayout === "auto" && host ? _jsx(HostyPanelWorkspace, { children: content }) : content) }), !allowed && _jsxs("div", { "data-hosty-sdk": true, role: "status", className: "hosty-panel-notice", children: [_jsx(SdkChromeStyles, {}), environment?.connection === "blocked" ? _jsxs(_Fragment, { children: [_jsx("strong", { children: environment.code === "actor_mismatch" ? "Use the same Hosty account in this app and its host" : "Cannot verify this app’s host" }), _jsx("p", { children: "Check your account and access in Hosty Core, then reconnect. Your existing discussion is unchanged." }), _jsx("button", { type: "button", onClick: () => window.location.reload(), children: "Reconnect" })] }) : "Connecting to the workspace…"] }), _jsx(FeedbackCapture, { active: active && !!allowed, uiPath: uiPath }), host && _jsx(HostyPresentationLayer, { layer: layer, host: host, active: !!allowed })] }) }) });
|
|
57
58
|
}
|
|
58
59
|
const empty = { bootstrap: null, catalog: [], tools: [], selected: null, expanded: false, width: 360, error: null };
|
|
59
60
|
const noop = () => () => { };
|
package/dist/ui-server.js
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import { FeedbackServerClient } from "@hosty-sdk/app/feedback/server";
|
|
2
|
+
import { FeedbackError } from "@hosty-sdk/app/feedback";
|
|
3
|
+
import { ProviderClient } from "@hosty-sdk/app/providers/server";
|
|
4
|
+
import { ProviderError } from "@hosty-sdk/app/providers";
|
|
1
5
|
import { UiError } from "@hosty-sdk/app/ui/types";
|
|
2
6
|
/** Server transport for generic UI metadata. Credentials never appear in returned metadata. */
|
|
3
7
|
export class UiServerClient {
|
|
@@ -63,7 +67,38 @@ export async function createHostyUiResponse(request, context, options = {}) {
|
|
|
63
67
|
const route = url.pathname.startsWith(prefix + "/") ? url.pathname.slice(prefix.length + 1) : "";
|
|
64
68
|
const client = new UiServerClient(options);
|
|
65
69
|
let result;
|
|
66
|
-
if (request.method === "GET"
|
|
70
|
+
if (/^feedback\/[a-f0-9]{64}\/review-targets$/.test(route) && request.method === "GET") {
|
|
71
|
+
result = await new FeedbackServerClient(options).reviewTargets(route.split("/")[1], context.token, request.signal);
|
|
72
|
+
}
|
|
73
|
+
else if (route === "feedback/speech" && request.method === "GET") {
|
|
74
|
+
const speech = new ProviderClient(options), permissions = await speech.permissions(request.signal);
|
|
75
|
+
const granted = permissions.granted.includes("providers.speech-to-text");
|
|
76
|
+
result = { granted, providers: granted ? await speech.list("speech-to-text", request.signal) : [] };
|
|
77
|
+
}
|
|
78
|
+
else if (route === "feedback/speech" && request.method === "POST") {
|
|
79
|
+
if (!sameOrigin(request) || request.headers.get("content-type")?.split(";", 1)[0]?.trim() !== "application/json")
|
|
80
|
+
throw new UiError("origin_denied", "Dictate from this application page.", 403);
|
|
81
|
+
const speech = new ProviderClient(options), roster = await speech.list("speech-to-text", request.signal);
|
|
82
|
+
const input = JSON.parse(await boundedBody(request, 6 * 1024 * 1024));
|
|
83
|
+
if (!input || typeof input.appId !== "string" || typeof input.key !== "string" || typeof input.audio !== "string")
|
|
84
|
+
throw new UiError("speech_invalid", "Choose a speech provider and a recording.", 400);
|
|
85
|
+
if (!roster.some(p => p.appId === input.appId && p.key === input.key && p.available))
|
|
86
|
+
throw new UiError("speech_unavailable", "The selected speech provider is unavailable.", 409);
|
|
87
|
+
const bytes = Uint8Array.from(Buffer.from(input.audio, "base64"));
|
|
88
|
+
if (bytes.length < 44 || bytes.length > 120 * 16000 * 2 + 4096 || String.fromCharCode(...bytes.subarray(0, 4)) !== "RIFF" || String.fromCharCode(...bytes.subarray(8, 12)) !== "WAVE")
|
|
89
|
+
throw new UiError("speech_invalid", "Use a bounded WAV recording.", 400);
|
|
90
|
+
result = await speech.transcribe(input, new Blob([bytes], { type: "audio/wav" }), { signal: request.signal });
|
|
91
|
+
}
|
|
92
|
+
else if (route === "feedback" && request.method === "GET") {
|
|
93
|
+
result = await new FeedbackServerClient(options).capabilities(context.token, request.signal);
|
|
94
|
+
}
|
|
95
|
+
else if (route === "feedback" && request.method === "POST") {
|
|
96
|
+
if (!sameOrigin(request) || request.headers.get("content-type")?.split(";", 1)[0]?.trim() !== "application/json")
|
|
97
|
+
throw new UiError("origin_denied", "Submit feedback from this application page.", 403);
|
|
98
|
+
const body = await boundedBody(request, 12 * 1024 * 1024);
|
|
99
|
+
result = await new FeedbackServerClient(options).submit(JSON.parse(body), context.token, request.signal);
|
|
100
|
+
}
|
|
101
|
+
else if (request.method === "GET" && route === "bootstrap") {
|
|
67
102
|
if ([...url.searchParams.keys()].some(k => k !== "parentOrigin") || url.searchParams.getAll("parentOrigin").length > 1)
|
|
68
103
|
throw new UiError("ui_request_invalid", "Invalid bootstrap request.", 400);
|
|
69
104
|
result = await client.bootstrap({ parentOrigin: url.searchParams.get("parentOrigin") ?? undefined }, context.token, request.signal);
|
|
@@ -93,12 +128,12 @@ export async function createHostyUiResponse(request, context, options = {}) {
|
|
|
93
128
|
return Response.json(result, { headers: responseHeaders });
|
|
94
129
|
}
|
|
95
130
|
catch (error) {
|
|
96
|
-
const known = error instanceof UiError;
|
|
131
|
+
const known = error instanceof UiError || error instanceof FeedbackError || error instanceof ProviderError;
|
|
97
132
|
return Response.json({ code: known ? error.code : error instanceof SyntaxError ? "ui_request_invalid" : "ui_unavailable",
|
|
98
133
|
message: known ? error.message : "Application panels are unavailable. Retry opening the same target." }, { status: known ? error.status : error instanceof SyntaxError ? 400 : 503, headers: responseHeaders });
|
|
99
134
|
}
|
|
100
135
|
}
|
|
101
|
-
async function boundedBody(request) {
|
|
136
|
+
async function boundedBody(request, limit = 16_384) {
|
|
102
137
|
const reader = request.body?.getReader();
|
|
103
138
|
if (!reader)
|
|
104
139
|
return "";
|
|
@@ -110,7 +145,7 @@ async function boundedBody(request) {
|
|
|
110
145
|
if (done)
|
|
111
146
|
break;
|
|
112
147
|
length += value.byteLength;
|
|
113
|
-
if (length >
|
|
148
|
+
if (length > limit) {
|
|
114
149
|
await reader.cancel();
|
|
115
150
|
throw new UiError("ui_request_invalid", "Panel request is too large.", 413);
|
|
116
151
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hosty-sdk/app",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.38.0",
|
|
4
4
|
"description": "Hosty app auth: session state machine, recovery bridge, Core revalidation, and the embedder responder.",
|
|
5
5
|
"license": "AGPL-3.0-only",
|
|
6
6
|
"type": "module",
|
|
@@ -140,6 +140,14 @@
|
|
|
140
140
|
"./tasks/server": {
|
|
141
141
|
"types": "./dist/tasks-server.d.ts",
|
|
142
142
|
"default": "./dist/tasks-server.js"
|
|
143
|
+
},
|
|
144
|
+
"./feedback": {
|
|
145
|
+
"types": "./dist/feedback-types.d.ts",
|
|
146
|
+
"default": "./dist/feedback-types.js"
|
|
147
|
+
},
|
|
148
|
+
"./feedback/server": {
|
|
149
|
+
"types": "./dist/feedback-server.d.ts",
|
|
150
|
+
"default": "./dist/feedback-server.js"
|
|
143
151
|
}
|
|
144
152
|
},
|
|
145
153
|
"scripts": {
|