@e-llm-studio/feedback-flow 0.0.0-stage → 0.1.31
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 -2
- package/dist/FeedbackErrorBoundary.d.ts +17 -0
- package/dist/FeedbackErrorBoundary.js +26 -0
- package/dist/FeedbackExperience.d.ts +27 -0
- package/dist/FeedbackExperience.js +54 -0
- package/dist/FeedbackModal.d.ts +27 -0
- package/dist/FeedbackModal.js +659 -0
- package/dist/FeedbackPrompt.d.ts +20 -0
- package/dist/FeedbackPrompt.js +43 -0
- package/dist/FeedbackScreenshotThumbnail.d.ts +9 -0
- package/dist/FeedbackScreenshotThumbnail.js +33 -0
- package/dist/StarRating.d.ts +17 -0
- package/dist/StarRating.js +16 -0
- package/dist/featureAvailability.d.ts +21 -0
- package/dist/featureAvailability.js +104 -0
- package/dist/feedback.css +1078 -0
- package/dist/feedbackApi.d.ts +70 -0
- package/dist/feedbackApi.js +164 -0
- package/dist/feedbackTheme.d.ts +4 -0
- package/dist/feedbackTheme.js +22 -0
- package/dist/feedbackUpload.d.ts +50 -0
- package/dist/feedbackUpload.js +190 -0
- package/dist/httpFeedbackApi.d.ts +15 -0
- package/dist/httpFeedbackApi.js +390 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +31 -0
- package/dist/tracking.d.ts +3 -0
- package/dist/tracking.js +22 -0
- package/dist/types.d.ts +276 -0
- package/dist/types.js +152 -0
- package/dist/useFeedbackFlow.d.ts +32 -0
- package/dist/useFeedbackFlow.js +137 -0
- package/dist/useFeedbackPersistence.d.ts +12 -0
- package/dist/useFeedbackPersistence.js +130 -0
- package/package.json +34 -3
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { FeedbackMetadata, FeedbackSeverity, FeedbackSubmission } from "./types";
|
|
2
|
+
export interface FeedbackLookup {
|
|
3
|
+
query_id: string;
|
|
4
|
+
is_ai_query?: boolean;
|
|
5
|
+
session_id?: string;
|
|
6
|
+
request_id?: string;
|
|
7
|
+
source_request_id?: string;
|
|
8
|
+
feature_id: string;
|
|
9
|
+
user_email?: string;
|
|
10
|
+
}
|
|
11
|
+
export interface FeedbackRecord {
|
|
12
|
+
feedback_form: {
|
|
13
|
+
feedback_id: string;
|
|
14
|
+
query_id: string;
|
|
15
|
+
feature_id: string;
|
|
16
|
+
user_email: string;
|
|
17
|
+
rating: number;
|
|
18
|
+
trace_id?: string;
|
|
19
|
+
session_id?: string;
|
|
20
|
+
request_id?: string;
|
|
21
|
+
metadata: FeedbackMetadata;
|
|
22
|
+
created_at: string;
|
|
23
|
+
};
|
|
24
|
+
positive_feedback: Array<{
|
|
25
|
+
feedback_id: string;
|
|
26
|
+
positive_id: string;
|
|
27
|
+
description: string;
|
|
28
|
+
time_saved_mins?: number;
|
|
29
|
+
metadata: Record<string, unknown>;
|
|
30
|
+
created_at: string;
|
|
31
|
+
}>;
|
|
32
|
+
gaps: Array<{
|
|
33
|
+
feedback_id: string;
|
|
34
|
+
negative_id: string;
|
|
35
|
+
description: string;
|
|
36
|
+
severity?: FeedbackSeverity;
|
|
37
|
+
metadata: Record<string, unknown>;
|
|
38
|
+
created_at: string;
|
|
39
|
+
}>;
|
|
40
|
+
feedback_attachment: Array<{
|
|
41
|
+
attachment_id: string;
|
|
42
|
+
negative_id?: string;
|
|
43
|
+
positive_id?: string;
|
|
44
|
+
gcs_url: string;
|
|
45
|
+
metadata: {
|
|
46
|
+
file_name: string;
|
|
47
|
+
mime_type?: string;
|
|
48
|
+
size?: number;
|
|
49
|
+
last_modified?: number;
|
|
50
|
+
public_url?: string;
|
|
51
|
+
};
|
|
52
|
+
created_at: string;
|
|
53
|
+
}>;
|
|
54
|
+
}
|
|
55
|
+
export interface FeedbackApi {
|
|
56
|
+
/** The same operation creates a new feedback record or updates an existing one. */
|
|
57
|
+
upsertFeedback(record: FeedbackRecord): Promise<FeedbackRecord>;
|
|
58
|
+
getFeedback(lookup: FeedbackLookup): Promise<FeedbackRecord | null>;
|
|
59
|
+
uploadAttachment?: (file: File) => Promise<string | {
|
|
60
|
+
gcsUrl: string;
|
|
61
|
+
publicUrl?: string;
|
|
62
|
+
}>;
|
|
63
|
+
getAttachmentPreviewUrl?: (gcsUrl: string, forceRefresh?: boolean) => Promise<string>;
|
|
64
|
+
}
|
|
65
|
+
export declare const mockFeedbackApi: FeedbackApi;
|
|
66
|
+
export declare function feedbackLookup(submission: Pick<FeedbackSubmission, "requestId" | "featureId" | "metadata">): FeedbackLookup;
|
|
67
|
+
/** Maps UI answers to the API's form, positive, gaps and attachment groups. */
|
|
68
|
+
export declare function toFeedbackRecord(submission: FeedbackSubmission, previous?: FeedbackRecord | null): FeedbackRecord;
|
|
69
|
+
/** Restores the saved answers for Review; mock attachment URLs are display references, not uploads. */
|
|
70
|
+
export declare function fromFeedbackRecord(record: FeedbackRecord): FeedbackSubmission;
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.mockFeedbackApi = void 0;
|
|
4
|
+
exports.feedbackLookup = feedbackLookup;
|
|
5
|
+
exports.toFeedbackRecord = toFeedbackRecord;
|
|
6
|
+
exports.fromFeedbackRecord = fromFeedbackRecord;
|
|
7
|
+
const STORAGE_KEY = "@e-llm-studio/feedback-flow/mock-api/v1";
|
|
8
|
+
let memoryRecords = [];
|
|
9
|
+
const clone = (value) => JSON.parse(JSON.stringify(value));
|
|
10
|
+
const newId = () => globalThis.crypto?.randomUUID?.() ?? `mock-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
11
|
+
function readRecords() {
|
|
12
|
+
try {
|
|
13
|
+
const stored = globalThis.localStorage?.getItem(STORAGE_KEY);
|
|
14
|
+
return stored ? JSON.parse(stored) : memoryRecords;
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return memoryRecords;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
function writeRecords(records) {
|
|
21
|
+
memoryRecords = records;
|
|
22
|
+
try {
|
|
23
|
+
globalThis.localStorage?.setItem(STORAGE_KEY, JSON.stringify(records));
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
// Private browsing / SSR still use the in-memory mock.
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
function matches(record, lookup) {
|
|
30
|
+
const form = record.feedback_form;
|
|
31
|
+
return form.query_id === lookup.query_id && form.feature_id === lookup.feature_id && form.user_email === (lookup.user_email ?? "");
|
|
32
|
+
}
|
|
33
|
+
exports.mockFeedbackApi = {
|
|
34
|
+
async upsertFeedback(record) {
|
|
35
|
+
const records = readRecords();
|
|
36
|
+
const index = records.findIndex((item) => item.feedback_form.feedback_id === record.feedback_form.feedback_id || matches(item, record.feedback_form));
|
|
37
|
+
const next = clone(record);
|
|
38
|
+
if (index >= 0)
|
|
39
|
+
records[index] = next;
|
|
40
|
+
else
|
|
41
|
+
records.push(next);
|
|
42
|
+
writeRecords(records);
|
|
43
|
+
return clone(next);
|
|
44
|
+
},
|
|
45
|
+
async getFeedback(lookup) {
|
|
46
|
+
const match = readRecords().find((record) => matches(record, lookup));
|
|
47
|
+
return match ? clone(match) : null;
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
function feedbackLookup(submission) {
|
|
51
|
+
return {
|
|
52
|
+
query_id: submission.metadata?.queryId ?? submission.requestId,
|
|
53
|
+
is_ai_query: Boolean(submission.metadata?.sessionId),
|
|
54
|
+
session_id: submission.metadata?.sessionId,
|
|
55
|
+
request_id: submission.metadata?.requestId,
|
|
56
|
+
source_request_id: submission.requestId,
|
|
57
|
+
feature_id: submission.featureId,
|
|
58
|
+
user_email: String(submission.metadata?.userEmail ?? submission.metadata?.owner ?? ""),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
/** Maps UI answers to the API's form, positive, gaps and attachment groups. */
|
|
62
|
+
function toFeedbackRecord(submission, previous) {
|
|
63
|
+
const now = new Date().toISOString();
|
|
64
|
+
const feedbackId = previous?.feedback_form.feedback_id ?? newId();
|
|
65
|
+
const metadata = submission.metadata ?? {};
|
|
66
|
+
const existingPositive = previous?.positive_feedback[0];
|
|
67
|
+
const hasPositive = Boolean(submission.mostUseful);
|
|
68
|
+
const positiveId = existingPositive?.positive_id ?? newId();
|
|
69
|
+
const positive_feedback = hasPositive
|
|
70
|
+
? [
|
|
71
|
+
{
|
|
72
|
+
feedback_id: feedbackId,
|
|
73
|
+
positive_id: positiveId,
|
|
74
|
+
description: submission.mostUseful ?? "",
|
|
75
|
+
time_saved_mins: submission.timeSavedMinutes,
|
|
76
|
+
metadata: {
|
|
77
|
+
useful_detail_shown: submission.usefulDetailShown ?? false,
|
|
78
|
+
},
|
|
79
|
+
created_at: existingPositive?.created_at ?? now,
|
|
80
|
+
},
|
|
81
|
+
]
|
|
82
|
+
: [];
|
|
83
|
+
const gaps = submission.gaps.map((gap, index) => ({
|
|
84
|
+
feedback_id: feedbackId,
|
|
85
|
+
negative_id: gap.negativeId ?? (submission.gaps.some((other, otherIndex) => otherIndex !== index &&
|
|
86
|
+
other.negativeId === previous?.gaps[index]?.negative_id)
|
|
87
|
+
? undefined : previous?.gaps[index]?.negative_id) ?? newId(),
|
|
88
|
+
description: gap.description,
|
|
89
|
+
severity: gap.severity,
|
|
90
|
+
metadata: { detail_requested: gap.detailRequested ?? false },
|
|
91
|
+
created_at: previous?.gaps[index]?.created_at ?? now,
|
|
92
|
+
}));
|
|
93
|
+
const feedback_attachment = submission.gaps.flatMap((gap, index) => gap.screenshots.map((screenshot) => {
|
|
94
|
+
const saved = "attachmentId" in screenshot ? screenshot : null;
|
|
95
|
+
return {
|
|
96
|
+
attachment_id: saved?.attachmentId ?? newId(),
|
|
97
|
+
negative_id: gaps[index].negative_id,
|
|
98
|
+
gcs_url: saved?.gcsUrl ?? `mock://feedback-attachments/${feedbackId}/${encodeURIComponent(screenshot.name)}`,
|
|
99
|
+
metadata: {
|
|
100
|
+
file_name: screenshot.name,
|
|
101
|
+
mime_type: screenshot.type,
|
|
102
|
+
size: screenshot.size,
|
|
103
|
+
last_modified: screenshot.lastModified,
|
|
104
|
+
...(saved?.publicUrl ? { public_url: saved.publicUrl } : {}),
|
|
105
|
+
},
|
|
106
|
+
created_at: previous?.feedback_attachment.find((item) => item.attachment_id === saved?.attachmentId)?.created_at ?? now,
|
|
107
|
+
};
|
|
108
|
+
}));
|
|
109
|
+
return {
|
|
110
|
+
feedback_form: {
|
|
111
|
+
feedback_id: feedbackId,
|
|
112
|
+
...feedbackLookup(submission),
|
|
113
|
+
user_email: feedbackLookup(submission).user_email ?? "",
|
|
114
|
+
rating: submission.rating,
|
|
115
|
+
trace_id: metadata.traceId,
|
|
116
|
+
session_id: metadata.sessionId,
|
|
117
|
+
request_id: metadata.requestId,
|
|
118
|
+
metadata: {
|
|
119
|
+
...metadata,
|
|
120
|
+
...(submission.timeSavedMinutes !== undefined && !hasPositive
|
|
121
|
+
? { draft_time_saved_mins: submission.timeSavedMinutes } : {}),
|
|
122
|
+
source_request_id: submission.requestId,
|
|
123
|
+
},
|
|
124
|
+
created_at: previous?.feedback_form.created_at ?? now,
|
|
125
|
+
},
|
|
126
|
+
positive_feedback,
|
|
127
|
+
gaps,
|
|
128
|
+
feedback_attachment,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
/** Restores the saved answers for Review; mock attachment URLs are display references, not uploads. */
|
|
132
|
+
function fromFeedbackRecord(record) {
|
|
133
|
+
const positive = record.positive_feedback[0];
|
|
134
|
+
return {
|
|
135
|
+
requestId: typeof record.feedback_form.metadata.source_request_id === "string"
|
|
136
|
+
? record.feedback_form.metadata.source_request_id : record.feedback_form.query_id,
|
|
137
|
+
featureId: record.feedback_form.feature_id,
|
|
138
|
+
rating: record.feedback_form.rating,
|
|
139
|
+
timeSavedMinutes: positive?.time_saved_mins ??
|
|
140
|
+
(typeof record.feedback_form.metadata.draft_time_saved_mins === "number"
|
|
141
|
+
? record.feedback_form.metadata.draft_time_saved_mins : undefined),
|
|
142
|
+
mostUseful: positive?.description,
|
|
143
|
+
usefulMoreDetail: typeof positive?.metadata.useful_more_detail === "string" ? positive.metadata.useful_more_detail : undefined,
|
|
144
|
+
usefulDetailShown: positive?.metadata.useful_detail_shown === true,
|
|
145
|
+
gaps: record.gaps.map((gap) => ({
|
|
146
|
+
negativeId: gap.negative_id,
|
|
147
|
+
description: gap.description,
|
|
148
|
+
severity: gap.severity,
|
|
149
|
+
detailRequested: gap.metadata.detail_requested === true,
|
|
150
|
+
screenshots: record.feedback_attachment
|
|
151
|
+
.filter((item) => item.negative_id === gap.negative_id)
|
|
152
|
+
.map((item) => ({
|
|
153
|
+
attachmentId: item.attachment_id,
|
|
154
|
+
gcsUrl: item.gcs_url,
|
|
155
|
+
publicUrl: item.metadata.public_url,
|
|
156
|
+
name: item.metadata.file_name,
|
|
157
|
+
type: item.metadata.mime_type,
|
|
158
|
+
size: item.metadata.size,
|
|
159
|
+
lastModified: item.metadata.last_modified,
|
|
160
|
+
})),
|
|
161
|
+
})),
|
|
162
|
+
metadata: record.feedback_form.metadata,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { CSSProperties } from 'react';
|
|
2
|
+
import { defaultFeedbackTheme, createFeedbackTheme, type FeedbackTheme } from './types';
|
|
3
|
+
export { defaultFeedbackTheme, createFeedbackTheme };
|
|
4
|
+
export declare function feedbackThemeStyle(theme?: FeedbackTheme): CSSProperties;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createFeedbackTheme = exports.defaultFeedbackTheme = void 0;
|
|
4
|
+
exports.feedbackThemeStyle = feedbackThemeStyle;
|
|
5
|
+
const types_1 = require("./types");
|
|
6
|
+
Object.defineProperty(exports, "defaultFeedbackTheme", { enumerable: true, get: function () { return types_1.defaultFeedbackTheme; } });
|
|
7
|
+
Object.defineProperty(exports, "createFeedbackTheme", { enumerable: true, get: function () { return types_1.createFeedbackTheme; } });
|
|
8
|
+
function feedbackThemeStyle(theme) {
|
|
9
|
+
const merged = (0, types_1.createFeedbackTheme)(theme);
|
|
10
|
+
return {
|
|
11
|
+
'--cw-feedback-accent': merged.accent ?? types_1.defaultFeedbackTheme.accent,
|
|
12
|
+
'--cw-feedback-text': merged.text ?? types_1.defaultFeedbackTheme.text,
|
|
13
|
+
'--cw-feedback-muted': merged.muted ?? types_1.defaultFeedbackTheme.muted,
|
|
14
|
+
'--cw-feedback-border': merged.border ?? types_1.defaultFeedbackTheme.border,
|
|
15
|
+
'--cw-feedback-surface': merged.surface ?? types_1.defaultFeedbackTheme.surface,
|
|
16
|
+
'--cw-feedback-gap-surface': merged.gapSurface ?? types_1.defaultFeedbackTheme.gapSurface,
|
|
17
|
+
'--cw-feedback-success': merged.success ?? types_1.defaultFeedbackTheme.success,
|
|
18
|
+
'--cw-feedback-star-empty': merged.starEmpty ?? types_1.defaultFeedbackTheme.starEmpty,
|
|
19
|
+
'--cw-feedback-tooltip-bg': merged.tooltipBackground ?? types_1.defaultFeedbackTheme.tooltipBackground,
|
|
20
|
+
'--cw-feedback-tooltip-text': merged.tooltipText ?? types_1.defaultFeedbackTheme.tooltipText,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
export interface FeedbackUploadOptions {
|
|
2
|
+
/** CW API origin, for example https://cw.example.com. */
|
|
3
|
+
baseUrl: string;
|
|
4
|
+
/** Only needed by the legacy signed-URL helpers. */
|
|
5
|
+
projectId?: string | (() => string | null | undefined);
|
|
6
|
+
getAuthToken?: () => string | null | undefined;
|
|
7
|
+
fetcher?: typeof fetch;
|
|
8
|
+
artifactUserEmail?: string | null;
|
|
9
|
+
artifactUserName?: string | null;
|
|
10
|
+
}
|
|
11
|
+
export interface SignedUploadFile {
|
|
12
|
+
filename: string;
|
|
13
|
+
status: string;
|
|
14
|
+
signed_url: string;
|
|
15
|
+
content_type: string;
|
|
16
|
+
public_url: string;
|
|
17
|
+
}
|
|
18
|
+
export interface DirectFeedbackUploadResponse {
|
|
19
|
+
message: string;
|
|
20
|
+
filename: string;
|
|
21
|
+
content_type: string;
|
|
22
|
+
size_formatted: string;
|
|
23
|
+
gs_url: string;
|
|
24
|
+
public_url: string;
|
|
25
|
+
}
|
|
26
|
+
/** Upload one screenshot to CW's direct multipart endpoint. The caller sends gs_url in feedback. */
|
|
27
|
+
export declare function uploadFeedbackScreenshotDirect(file: File, options: FeedbackUploadOptions): Promise<DirectFeedbackUploadResponse>;
|
|
28
|
+
/** Public URL fallback for integrations without a signed-read endpoint. */
|
|
29
|
+
export declare function feedbackScreenshotPublicUrl(gcsUrl: string): string | null;
|
|
30
|
+
/** Resolve a private GCS screenshot through CW's signed-read endpoint. */
|
|
31
|
+
export declare function getFeedbackScreenshotSignedUrl(gcsUrl: string, options: FeedbackUploadOptions, forceRefresh?: boolean): Promise<string>;
|
|
32
|
+
export declare function createFeedbackScreenshotUrlResolver(options: FeedbackUploadOptions): (gcsUrl: string, forceRefresh?: boolean) => Promise<string>;
|
|
33
|
+
/** Request CW's upload URLs; accepts its direct or responseData-wrapped JSON shape. */
|
|
34
|
+
export declare function generateSignedUrlForUpload(projectId: string, fileNames: string[], options: FeedbackUploadOptions): Promise<SignedUploadFile[]>;
|
|
35
|
+
export interface ManualFeedbackUploadOptions extends FeedbackUploadOptions {
|
|
36
|
+
files: FileList | File[];
|
|
37
|
+
isAI?: boolean;
|
|
38
|
+
ignoreConfirmCall?: boolean;
|
|
39
|
+
autoIngest?: boolean;
|
|
40
|
+
}
|
|
41
|
+
/** Upload bytes to each signed URL, then confirm the files with CW. */
|
|
42
|
+
export declare function manualUploadFilesToGCS(options: ManualFeedbackUploadOptions): Promise<Array<{
|
|
43
|
+
filename: string;
|
|
44
|
+
url: string;
|
|
45
|
+
}>>;
|
|
46
|
+
/** Single-file adapter consumed by the feedback form. */
|
|
47
|
+
export declare function createFeedbackScreenshotUploader(options: FeedbackUploadOptions): (file: File) => Promise<{
|
|
48
|
+
gcsUrl: string;
|
|
49
|
+
publicUrl?: string;
|
|
50
|
+
}>;
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.uploadFeedbackScreenshotDirect = uploadFeedbackScreenshotDirect;
|
|
4
|
+
exports.feedbackScreenshotPublicUrl = feedbackScreenshotPublicUrl;
|
|
5
|
+
exports.getFeedbackScreenshotSignedUrl = getFeedbackScreenshotSignedUrl;
|
|
6
|
+
exports.createFeedbackScreenshotUrlResolver = createFeedbackScreenshotUrlResolver;
|
|
7
|
+
exports.generateSignedUrlForUpload = generateSignedUrlForUpload;
|
|
8
|
+
exports.manualUploadFilesToGCS = manualUploadFilesToGCS;
|
|
9
|
+
exports.createFeedbackScreenshotUploader = createFeedbackScreenshotUploader;
|
|
10
|
+
function unwrapResponse(value) {
|
|
11
|
+
if (!value || typeof value !== "object")
|
|
12
|
+
throw new Error("Invalid CW upload response");
|
|
13
|
+
const data = value;
|
|
14
|
+
if (data.error)
|
|
15
|
+
throw new Error("CW upload API returned an error");
|
|
16
|
+
return data.responseData && typeof data.responseData === "object" ? data.responseData : data;
|
|
17
|
+
}
|
|
18
|
+
function authHeaders(options) {
|
|
19
|
+
const token = options.getAuthToken?.();
|
|
20
|
+
return token ? { Authorization: `Bearer ${token}` } : {};
|
|
21
|
+
}
|
|
22
|
+
/** Upload one screenshot to CW's direct multipart endpoint. The caller sends gs_url in feedback. */
|
|
23
|
+
async function uploadFeedbackScreenshotDirect(file, options) {
|
|
24
|
+
const baseUrl = options.baseUrl.replace(/\/+$/, "");
|
|
25
|
+
if (!baseUrl)
|
|
26
|
+
throw new Error("A CW API base URL is required to upload a feedback screenshot");
|
|
27
|
+
const endpoint = `${baseUrl.endsWith("/backend") ? baseUrl : `${baseUrl}/backend`}/gcs/direct-gcs-upload`;
|
|
28
|
+
const form = new FormData();
|
|
29
|
+
form.append("file", file, file.name);
|
|
30
|
+
const response = await (options.fetcher ?? fetch)(endpoint, {
|
|
31
|
+
method: "POST",
|
|
32
|
+
headers: { accept: "*/*", ...authHeaders(options) },
|
|
33
|
+
body: form,
|
|
34
|
+
});
|
|
35
|
+
if (!response.ok)
|
|
36
|
+
throw new Error(`Feedback screenshot upload failed (${response.status})`);
|
|
37
|
+
const data = unwrapResponse(await response.json());
|
|
38
|
+
if (typeof data.gs_url !== "string" || !data.gs_url.startsWith("gs://")) {
|
|
39
|
+
throw new Error("CW did not return a valid gs_url for the feedback screenshot");
|
|
40
|
+
}
|
|
41
|
+
return data;
|
|
42
|
+
}
|
|
43
|
+
/** Public URL fallback for integrations without a signed-read endpoint. */
|
|
44
|
+
function feedbackScreenshotPublicUrl(gcsUrl) {
|
|
45
|
+
if (/^https:\/\//i.test(gcsUrl))
|
|
46
|
+
return gcsUrl;
|
|
47
|
+
if (!gcsUrl.startsWith("gs://"))
|
|
48
|
+
return null;
|
|
49
|
+
const path = gcsUrl.slice(5).split("/").map(encodeURIComponent).join("/");
|
|
50
|
+
return `https://storage.googleapis.com/${path}`;
|
|
51
|
+
}
|
|
52
|
+
const SIGNED_URL_EXPIRY_BUFFER_MS = 60000;
|
|
53
|
+
const SIGNED_URL_FALLBACK_TTL_MS = 5 * 60000;
|
|
54
|
+
const signedReadCache = new Map();
|
|
55
|
+
const signedReadInFlight = new Map();
|
|
56
|
+
function signedReadExpiry(url) {
|
|
57
|
+
try {
|
|
58
|
+
const params = new URL(url).searchParams;
|
|
59
|
+
const date = params.get("X-Goog-Date");
|
|
60
|
+
const seconds = Number(params.get("X-Goog-Expires"));
|
|
61
|
+
if (date && /^\d{8}T\d{6}Z$/.test(date) && Number.isFinite(seconds) && seconds > 0) {
|
|
62
|
+
const start = Date.UTC(Number(date.slice(0, 4)), Number(date.slice(4, 6)) - 1, Number(date.slice(6, 8)), Number(date.slice(9, 11)), Number(date.slice(11, 13)), Number(date.slice(13, 15)));
|
|
63
|
+
return start + seconds * 1000 - SIGNED_URL_EXPIRY_BUFFER_MS;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
// Unknown signed URL formats get a short in-memory cache only.
|
|
68
|
+
}
|
|
69
|
+
return Date.now() + SIGNED_URL_FALLBACK_TTL_MS;
|
|
70
|
+
}
|
|
71
|
+
/** Resolve a private GCS screenshot through CW's signed-read endpoint. */
|
|
72
|
+
async function getFeedbackScreenshotSignedUrl(gcsUrl, options, forceRefresh = false) {
|
|
73
|
+
const baseUrl = options.baseUrl.replace(/\/+$/, "");
|
|
74
|
+
if (!baseUrl)
|
|
75
|
+
throw new Error("A CW API base URL is required to preview a feedback screenshot");
|
|
76
|
+
const token = options.getAuthToken?.() ?? "";
|
|
77
|
+
const key = JSON.stringify([baseUrl, token, gcsUrl]);
|
|
78
|
+
const cached = signedReadCache.get(key);
|
|
79
|
+
if (!forceRefresh && cached && cached.expiresAt > Date.now())
|
|
80
|
+
return cached.url;
|
|
81
|
+
if (cached)
|
|
82
|
+
signedReadCache.delete(key);
|
|
83
|
+
const pending = signedReadInFlight.get(key);
|
|
84
|
+
if (pending)
|
|
85
|
+
return pending;
|
|
86
|
+
const request = (async () => {
|
|
87
|
+
const endpoint = `${baseUrl.endsWith("/backend") ? baseUrl : `${baseUrl}/backend`}/gcs/get-signed-url/`;
|
|
88
|
+
const response = await (options.fetcher ?? fetch)(endpoint, {
|
|
89
|
+
method: "POST",
|
|
90
|
+
headers: { accept: "application/json", "Content-Type": "application/json", ...(token ? { Authorization: `Bearer ${token}` } : {}) },
|
|
91
|
+
body: JSON.stringify({ gsutil_url: gcsUrl.startsWith("gs://") ? gcsUrl : `gs://${gcsUrl}` }),
|
|
92
|
+
});
|
|
93
|
+
if (!response.ok)
|
|
94
|
+
throw new Error(`Could not get screenshot preview URL (${response.status})`);
|
|
95
|
+
const data = unwrapResponse(await response.json());
|
|
96
|
+
if (typeof data.signed_url !== "string" || !data.signed_url) {
|
|
97
|
+
throw new Error("CW did not return a signed screenshot URL");
|
|
98
|
+
}
|
|
99
|
+
const expiresAt = signedReadExpiry(data.signed_url);
|
|
100
|
+
if (expiresAt > Date.now()) {
|
|
101
|
+
for (const [entryKey, entry] of signedReadCache) {
|
|
102
|
+
if (entry.expiresAt <= Date.now())
|
|
103
|
+
signedReadCache.delete(entryKey);
|
|
104
|
+
}
|
|
105
|
+
if (signedReadCache.size >= 200)
|
|
106
|
+
signedReadCache.delete(signedReadCache.keys().next().value);
|
|
107
|
+
signedReadCache.set(key, { url: data.signed_url, expiresAt });
|
|
108
|
+
}
|
|
109
|
+
return data.signed_url;
|
|
110
|
+
})();
|
|
111
|
+
signedReadInFlight.set(key, request);
|
|
112
|
+
try {
|
|
113
|
+
return await request;
|
|
114
|
+
}
|
|
115
|
+
finally {
|
|
116
|
+
if (signedReadInFlight.get(key) === request)
|
|
117
|
+
signedReadInFlight.delete(key);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
function createFeedbackScreenshotUrlResolver(options) {
|
|
121
|
+
return (gcsUrl, forceRefresh) => getFeedbackScreenshotSignedUrl(gcsUrl, options, forceRefresh);
|
|
122
|
+
}
|
|
123
|
+
/** Request CW's upload URLs; accepts its direct or responseData-wrapped JSON shape. */
|
|
124
|
+
async function generateSignedUrlForUpload(projectId, fileNames, options) {
|
|
125
|
+
const fetcher = options.fetcher ?? fetch;
|
|
126
|
+
const response = await fetcher(`${options.baseUrl.replace(/\/$/, "")}/gcs/generate-upload-signed-urls`, {
|
|
127
|
+
method: "POST",
|
|
128
|
+
headers: { "Content-Type": "application/json", ...authHeaders(options) },
|
|
129
|
+
body: JSON.stringify({ project_id: projectId, filenames: fileNames }),
|
|
130
|
+
});
|
|
131
|
+
if (!response.ok)
|
|
132
|
+
throw new Error(`Could not get screenshot signed URL (${response.status})`);
|
|
133
|
+
const data = unwrapResponse(await response.json());
|
|
134
|
+
if (!Array.isArray(data.files))
|
|
135
|
+
throw new Error("CW did not return screenshot upload URLs");
|
|
136
|
+
return data.files;
|
|
137
|
+
}
|
|
138
|
+
/** Upload bytes to each signed URL, then confirm the files with CW. */
|
|
139
|
+
async function manualUploadFilesToGCS(options) {
|
|
140
|
+
const projectId = typeof options.projectId === "function" ? options.projectId() : options.projectId;
|
|
141
|
+
if (!projectId)
|
|
142
|
+
throw new Error("A project ID is required to upload a feedback screenshot");
|
|
143
|
+
const files = Array.from(options.files);
|
|
144
|
+
const namedFiles = files.map((file) => ({
|
|
145
|
+
file,
|
|
146
|
+
filename: `feedback-${globalThis.crypto?.randomUUID?.() ?? Date.now()}-${file.name}`,
|
|
147
|
+
}));
|
|
148
|
+
const signedFiles = await generateSignedUrlForUpload(projectId, namedFiles.map((item) => item.filename), options);
|
|
149
|
+
const fetcher = options.fetcher ?? fetch;
|
|
150
|
+
const uploaded = await Promise.all(signedFiles.map(async (signed) => {
|
|
151
|
+
if (signed.status === "exists")
|
|
152
|
+
throw new Error(`Feedback screenshot already exists: ${signed.filename}`);
|
|
153
|
+
const file = namedFiles.find((item) => item.filename === signed.filename)?.file;
|
|
154
|
+
if (!file || !signed.signed_url || !signed.public_url)
|
|
155
|
+
throw new Error("CW did not return an available signed screenshot URL");
|
|
156
|
+
const upload = await fetcher(signed.signed_url, {
|
|
157
|
+
method: "PUT",
|
|
158
|
+
headers: { "Content-Type": signed.content_type || file.type || "application/octet-stream" },
|
|
159
|
+
body: file,
|
|
160
|
+
});
|
|
161
|
+
if (!upload.ok)
|
|
162
|
+
throw new Error(`Feedback screenshot upload failed (${upload.status})`);
|
|
163
|
+
return { filename: signed.filename, url: signed.public_url };
|
|
164
|
+
}));
|
|
165
|
+
if (options.ignoreConfirmCall)
|
|
166
|
+
return uploaded;
|
|
167
|
+
const suffix = options.autoIngest ? "?auto_ingest=true" : "";
|
|
168
|
+
const confirm = await fetcher(`${options.baseUrl.replace(/\/$/, "")}/gcs/confirm-upload${suffix}`, {
|
|
169
|
+
method: "POST",
|
|
170
|
+
headers: { "Content-Type": "application/json", ...authHeaders(options) },
|
|
171
|
+
body: JSON.stringify({
|
|
172
|
+
project_id: projectId,
|
|
173
|
+
files: uploaded,
|
|
174
|
+
isAI: options.isAI ?? false,
|
|
175
|
+
artifactUserName: options.artifactUserName ?? null,
|
|
176
|
+
artifactUserEmail: options.artifactUserEmail ?? null,
|
|
177
|
+
}),
|
|
178
|
+
});
|
|
179
|
+
if (!confirm.ok)
|
|
180
|
+
throw new Error(`Feedback screenshot confirmation failed (${confirm.status})`);
|
|
181
|
+
unwrapResponse(await confirm.json());
|
|
182
|
+
return uploaded;
|
|
183
|
+
}
|
|
184
|
+
/** Single-file adapter consumed by the feedback form. */
|
|
185
|
+
function createFeedbackScreenshotUploader(options) {
|
|
186
|
+
return async (file) => {
|
|
187
|
+
const uploaded = await uploadFeedbackScreenshotDirect(file, options);
|
|
188
|
+
return { gcsUrl: uploaded.gs_url, publicUrl: uploaded.public_url };
|
|
189
|
+
};
|
|
190
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { FeedbackApi } from "./feedbackApi";
|
|
2
|
+
export interface HttpFeedbackApiOptions {
|
|
3
|
+
baseUrl: string;
|
|
4
|
+
/** Optional origin for GET when reads and writes are served by different deployments. */
|
|
5
|
+
readBaseUrl?: string;
|
|
6
|
+
getAuthToken?: () => string | null | undefined;
|
|
7
|
+
uploadAttachment?: (file: File) => Promise<string | {
|
|
8
|
+
gcsUrl: string;
|
|
9
|
+
publicUrl?: string;
|
|
10
|
+
}>;
|
|
11
|
+
getAttachmentPreviewUrl?: (gcsUrl: string, forceRefresh?: boolean) => Promise<string>;
|
|
12
|
+
fetcher?: typeof fetch;
|
|
13
|
+
}
|
|
14
|
+
/** GET restores saved feedback; POST creates and PUT updates it. */
|
|
15
|
+
export declare function createHttpFeedbackApi(options: HttpFeedbackApiOptions): FeedbackApi;
|