@agent-native/core 0.168.4 → 0.168.6
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/corpus/README.md +1 -1
- package/corpus/templates/slides/app/components/editor/PromptDialog.tsx +147 -24
- package/corpus/templates/slides/server/handlers/uploads-chunked.ts +317 -0
- package/corpus/templates/slides/server/handlers/uploads.ts +3 -4
- package/corpus/templates/slides/server/lib/chunked-upload-session.ts +54 -0
- package/corpus/templates/slides/server/routes/api/uploads-chunked/[sessionId]/chunk.post.ts +1 -0
- package/corpus/templates/slides/server/routes/api/uploads-chunked/start.post.ts +1 -0
- package/corpus/templates/slides/shared/upload-types.ts +1 -0
- package/dist/client/AgentPanel.d.ts +3 -1
- package/dist/client/AgentPanel.js +14 -10
- package/dist/collab/awareness.d.ts +2 -2
- package/dist/file-upload/builder.js +24 -0
- package/dist/file-upload/index.d.ts +2 -2
- package/dist/file-upload/index.js +1 -1
- package/dist/file-upload/registry.d.ts +2 -1
- package/dist/file-upload/registry.js +8 -0
- package/dist/file-upload/s3.js +59 -1
- package/dist/file-upload/types.d.ts +6 -0
- package/dist/notifications/routes.d.ts +3 -3
- package/dist/private-blob/registry.d.ts +0 -5
- package/dist/private-blob/registry.js +25 -18
- package/dist/progress/routes.d.ts +1 -1
- package/dist/resources/handlers.d.ts +2 -2
- package/package.json +3 -3
package/corpus/README.md
CHANGED
|
@@ -14,7 +14,10 @@ import { useState, useEffect, useCallback, useRef } from "react";
|
|
|
14
14
|
import { createPortal } from "react-dom";
|
|
15
15
|
import { toast } from "sonner";
|
|
16
16
|
|
|
17
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
MAX_REFERENCE_FILE_BYTES,
|
|
19
|
+
MAX_REFERENCE_FILES,
|
|
20
|
+
} from "../../../shared/upload-types";
|
|
18
21
|
import { Button } from "../ui/button";
|
|
19
22
|
import { Input } from "../ui/input";
|
|
20
23
|
import { GoogleDocImportHint } from "./GoogleDocImportHint";
|
|
@@ -29,39 +32,159 @@ export interface UploadedFile {
|
|
|
29
32
|
size: number;
|
|
30
33
|
}
|
|
31
34
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
)
|
|
35
|
-
|
|
35
|
+
// Netlify functions cap request bodies well under what a real PPTX/PDF
|
|
36
|
+
// needs, so any file above this size streams through the chunked upload
|
|
37
|
+
// endpoints (sub-4 MB slices, reassembled server-side) instead of one
|
|
38
|
+
// multipart POST.
|
|
39
|
+
const CHUNK_UPLOAD_THRESHOLD_BYTES = 4 * 1024 * 1024;
|
|
40
|
+
const CHUNK_SIZE_BYTES = 4 * 1024 * 1024;
|
|
41
|
+
|
|
42
|
+
async function readUploadJson(response: Response): Promise<unknown> {
|
|
43
|
+
try {
|
|
44
|
+
return await response.json();
|
|
45
|
+
} catch (error) {
|
|
46
|
+
throw new Error(`Upload returned invalid JSON (${response.status})`, {
|
|
47
|
+
cause: error,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function extractErrorMessage(data: unknown): string | null {
|
|
53
|
+
if (
|
|
54
|
+
data &&
|
|
55
|
+
typeof data === "object" &&
|
|
56
|
+
"error" in data &&
|
|
57
|
+
typeof (data as { error: unknown }).error === "string" &&
|
|
58
|
+
(data as { error: string }).error.trim()
|
|
59
|
+
) {
|
|
60
|
+
return (data as { error: string }).error;
|
|
61
|
+
}
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function uploadFilesMultipart(files: File[]): Promise<UploadedFile[]> {
|
|
36
66
|
const formData = new FormData();
|
|
37
67
|
files.forEach((file) => formData.append("files", file));
|
|
38
|
-
ensureEmbedAuthFetchInterceptor();
|
|
39
68
|
const response = await fetch(`${appBasePath()}/api/uploads`, {
|
|
40
69
|
method: "POST",
|
|
41
70
|
body: formData,
|
|
42
71
|
credentials: "include",
|
|
43
72
|
});
|
|
73
|
+
const data = await readUploadJson(response);
|
|
44
74
|
if (!response.ok) {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
75
|
+
throw new Error(
|
|
76
|
+
extractErrorMessage(data) || `Upload failed (${response.status})`,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
if (!Array.isArray(data)) {
|
|
80
|
+
throw new Error("Upload failed: invalid response");
|
|
81
|
+
}
|
|
82
|
+
return data as UploadedFile[];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function uploadFileChunked(file: File): Promise<UploadedFile> {
|
|
86
|
+
const startResponse = await fetch(
|
|
87
|
+
`${appBasePath()}/api/uploads-chunked/start`,
|
|
88
|
+
{
|
|
89
|
+
method: "POST",
|
|
90
|
+
headers: { "Content-Type": "application/json" },
|
|
91
|
+
credentials: "include",
|
|
92
|
+
body: JSON.stringify({
|
|
93
|
+
filename: file.name,
|
|
94
|
+
mimetype: file.type || "application/octet-stream",
|
|
95
|
+
declaredSize: file.size,
|
|
96
|
+
}),
|
|
97
|
+
},
|
|
98
|
+
);
|
|
99
|
+
const startData = await readUploadJson(startResponse);
|
|
100
|
+
if (!startResponse.ok) {
|
|
101
|
+
throw new Error(
|
|
102
|
+
extractErrorMessage(startData) ||
|
|
103
|
+
`Upload failed (${startResponse.status})`,
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
if (
|
|
107
|
+
startData &&
|
|
108
|
+
typeof startData === "object" &&
|
|
109
|
+
(startData as { uploadMode?: unknown }).uploadMode === "multipart"
|
|
110
|
+
) {
|
|
111
|
+
const [uploaded] = await uploadFilesMultipart([file]);
|
|
112
|
+
if (!uploaded) throw new Error("Upload failed: no file returned");
|
|
113
|
+
return uploaded;
|
|
114
|
+
}
|
|
115
|
+
const sessionId =
|
|
116
|
+
startData && typeof startData === "object"
|
|
117
|
+
? (startData as { sessionId?: unknown }).sessionId
|
|
118
|
+
: undefined;
|
|
119
|
+
if (typeof sessionId !== "string" || !sessionId) {
|
|
120
|
+
throw new Error("Upload failed: session ID missing");
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const totalChunks = Math.max(1, Math.ceil(file.size / CHUNK_SIZE_BYTES));
|
|
124
|
+
for (let index = 0; index < totalChunks; index++) {
|
|
125
|
+
const start = index * CHUNK_SIZE_BYTES;
|
|
126
|
+
const end = Math.min(start + CHUNK_SIZE_BYTES, file.size);
|
|
127
|
+
const isFinal = index === totalChunks - 1;
|
|
128
|
+
const chunkResponse = await fetch(
|
|
129
|
+
`${appBasePath()}/api/uploads-chunked/${sessionId}/chunk?index=${index}&isFinal=${
|
|
130
|
+
isFinal ? "1" : "0"
|
|
131
|
+
}`,
|
|
132
|
+
{
|
|
133
|
+
method: "POST",
|
|
134
|
+
credentials: "include",
|
|
135
|
+
headers: { "Content-Type": "application/octet-stream" },
|
|
136
|
+
body: file.slice(start, end),
|
|
137
|
+
},
|
|
138
|
+
);
|
|
139
|
+
const chunkData = await readUploadJson(chunkResponse);
|
|
140
|
+
if (!chunkResponse.ok) {
|
|
141
|
+
throw new Error(
|
|
142
|
+
extractErrorMessage(chunkData) ||
|
|
143
|
+
`Upload failed (${chunkResponse.status})`,
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
if (isFinal) {
|
|
147
|
+
const result = Array.isArray(chunkData)
|
|
148
|
+
? (chunkData[0] as UploadedFile)
|
|
149
|
+
: undefined;
|
|
150
|
+
if (!result) throw new Error("Upload failed: no file returned");
|
|
151
|
+
return result;
|
|
61
152
|
}
|
|
62
|
-
throw new Error(message);
|
|
63
153
|
}
|
|
64
|
-
|
|
154
|
+
throw new Error("Upload failed: no final chunk response");
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export async function uploadPromptFiles(
|
|
158
|
+
files: File[],
|
|
159
|
+
): Promise<UploadedFile[]> {
|
|
160
|
+
if (files.length === 0) return [];
|
|
161
|
+
if (files.length > MAX_REFERENCE_FILES) {
|
|
162
|
+
throw new Error(`Too many files (max ${MAX_REFERENCE_FILES})`);
|
|
163
|
+
}
|
|
164
|
+
ensureEmbedAuthFetchInterceptor();
|
|
165
|
+
const smallIndices = files.flatMap((file, index) =>
|
|
166
|
+
file.size <= CHUNK_UPLOAD_THRESHOLD_BYTES ? [index] : [],
|
|
167
|
+
);
|
|
168
|
+
const largeIndices = files.flatMap((file, index) =>
|
|
169
|
+
file.size > CHUNK_UPLOAD_THRESHOLD_BYTES ? [index] : [],
|
|
170
|
+
);
|
|
171
|
+
const [smallUploads, largeUploads] = await Promise.all([
|
|
172
|
+
smallIndices.length > 0
|
|
173
|
+
? uploadFilesMultipart(smallIndices.map((index) => files[index]))
|
|
174
|
+
: [],
|
|
175
|
+
Promise.all(largeIndices.map((index) => uploadFileChunked(files[index]))),
|
|
176
|
+
]);
|
|
177
|
+
if (smallUploads.length !== smallIndices.length) {
|
|
178
|
+
throw new Error("Upload failed: response file count did not match request");
|
|
179
|
+
}
|
|
180
|
+
const uploads = new Array<UploadedFile>(files.length);
|
|
181
|
+
smallIndices.forEach((fileIndex, resultIndex) => {
|
|
182
|
+
uploads[fileIndex] = smallUploads[resultIndex];
|
|
183
|
+
});
|
|
184
|
+
largeIndices.forEach((fileIndex, resultIndex) => {
|
|
185
|
+
uploads[fileIndex] = largeUploads[resultIndex];
|
|
186
|
+
});
|
|
187
|
+
return uploads;
|
|
65
188
|
}
|
|
66
189
|
|
|
67
190
|
/**
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
import {
|
|
2
|
+
deletePrivateBlob,
|
|
3
|
+
putPrivateBlob,
|
|
4
|
+
readPrivateBlob,
|
|
5
|
+
} from "@agent-native/core/private-blob";
|
|
6
|
+
import {
|
|
7
|
+
defineEventHandler,
|
|
8
|
+
getHeader,
|
|
9
|
+
getQuery,
|
|
10
|
+
getRouterParam,
|
|
11
|
+
readBody,
|
|
12
|
+
readRawBody,
|
|
13
|
+
setResponseStatus,
|
|
14
|
+
} from "h3";
|
|
15
|
+
import { nanoid } from "nanoid";
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
createChunkedUploadSession,
|
|
19
|
+
deleteChunkedUploadSession,
|
|
20
|
+
getChunkedUploadSession,
|
|
21
|
+
listChunkedUploadSessions,
|
|
22
|
+
type ChunkedUploadSession,
|
|
23
|
+
} from "../lib/chunked-upload-session.js";
|
|
24
|
+
import { isHostedSlidesRuntime } from "../lib/tenant-files.js";
|
|
25
|
+
import {
|
|
26
|
+
resolveSlidesRequestAuth,
|
|
27
|
+
withSlidesRequestContext,
|
|
28
|
+
} from "./request-auth-context.js";
|
|
29
|
+
import { maxReferenceFileBytes, saveUploadedReferenceFile } from "./uploads.js";
|
|
30
|
+
|
|
31
|
+
const MAX_CHUNK_BYTES = 4 * 1024 * 1024;
|
|
32
|
+
const MAX_CHUNKS = 128;
|
|
33
|
+
const SESSION_TTL_MS = 60 * 60 * 1000;
|
|
34
|
+
|
|
35
|
+
interface StartBody {
|
|
36
|
+
filename?: unknown;
|
|
37
|
+
mimetype?: unknown;
|
|
38
|
+
declaredSize?: unknown;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function deleteChunk(handle: ChunkedUploadSession["chunks"][string]) {
|
|
42
|
+
return (await deletePrivateBlob(handle)).deleted;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function cleanupChunks(session: ChunkedUploadSession): Promise<boolean> {
|
|
46
|
+
const results = await Promise.all(
|
|
47
|
+
Object.values(session.chunks).map(deleteChunk),
|
|
48
|
+
);
|
|
49
|
+
return results.every(Boolean);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function discardSession(
|
|
53
|
+
sessionId: string,
|
|
54
|
+
session: ChunkedUploadSession,
|
|
55
|
+
): Promise<boolean> {
|
|
56
|
+
const cleaned = await cleanupChunks(session);
|
|
57
|
+
if (cleaned) await deleteChunkedUploadSession(sessionId);
|
|
58
|
+
return cleaned;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function reapExpiredChunkedUploads(): Promise<void> {
|
|
62
|
+
const now = Date.now();
|
|
63
|
+
const sessions = await listChunkedUploadSessions();
|
|
64
|
+
await Promise.all(
|
|
65
|
+
sessions.map(async ({ sessionId, session }) => {
|
|
66
|
+
const expiresAt = Date.parse(session.expiresAt);
|
|
67
|
+
if (Number.isFinite(expiresAt) && expiresAt > now) return;
|
|
68
|
+
try {
|
|
69
|
+
const cleaned = await discardSession(sessionId, session);
|
|
70
|
+
if (!cleaned) {
|
|
71
|
+
console.warn("[slides-upload] expired session cleanup incomplete", {
|
|
72
|
+
sessionId,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
} catch (error) {
|
|
76
|
+
console.warn("[slides-upload] expired session cleanup failed", {
|
|
77
|
+
sessionId,
|
|
78
|
+
error: error instanceof Error ? error.message : String(error),
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
}),
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function cleanupCommittedSession(
|
|
86
|
+
sessionId: string,
|
|
87
|
+
session: ChunkedUploadSession,
|
|
88
|
+
): Promise<void> {
|
|
89
|
+
try {
|
|
90
|
+
const cleaned = await discardSession(sessionId, session);
|
|
91
|
+
if (!cleaned) {
|
|
92
|
+
console.warn("[slides-upload] committed session cleanup incomplete", {
|
|
93
|
+
sessionId,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
} catch (error) {
|
|
97
|
+
console.warn("[slides-upload] committed session cleanup failed", {
|
|
98
|
+
sessionId,
|
|
99
|
+
error: error instanceof Error ? error.message : String(error),
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export const startChunkedUpload = defineEventHandler(async (event) => {
|
|
105
|
+
const auth = await resolveSlidesRequestAuth(event);
|
|
106
|
+
if (!auth.ok) {
|
|
107
|
+
setResponseStatus(event, auth.statusCode);
|
|
108
|
+
return { error: auth.error };
|
|
109
|
+
}
|
|
110
|
+
const authContext = auth.context;
|
|
111
|
+
if (!authContext.email) {
|
|
112
|
+
setResponseStatus(event, 401);
|
|
113
|
+
return { error: "Unauthorized" };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return withSlidesRequestContext(
|
|
117
|
+
event,
|
|
118
|
+
async () => {
|
|
119
|
+
if (!isHostedSlidesRuntime()) {
|
|
120
|
+
return { uploadMode: "multipart" as const };
|
|
121
|
+
}
|
|
122
|
+
await reapExpiredChunkedUploads();
|
|
123
|
+
const body = (await readBody(event).catch(
|
|
124
|
+
() => null,
|
|
125
|
+
)) as StartBody | null;
|
|
126
|
+
const filename =
|
|
127
|
+
typeof body?.filename === "string" ? body.filename.trim() : "";
|
|
128
|
+
const mimetype =
|
|
129
|
+
typeof body?.mimetype === "string" && body.mimetype.trim()
|
|
130
|
+
? body.mimetype.trim()
|
|
131
|
+
: "application/octet-stream";
|
|
132
|
+
const declaredSize = Number(body?.declaredSize);
|
|
133
|
+
if (!filename) {
|
|
134
|
+
setResponseStatus(event, 400);
|
|
135
|
+
return { error: "filename is required" };
|
|
136
|
+
}
|
|
137
|
+
if (!Number.isSafeInteger(declaredSize) || declaredSize <= 0) {
|
|
138
|
+
setResponseStatus(event, 400);
|
|
139
|
+
return { error: "declaredSize must be a positive integer" };
|
|
140
|
+
}
|
|
141
|
+
const limit = maxReferenceFileBytes(filename);
|
|
142
|
+
if (declaredSize > limit) {
|
|
143
|
+
setResponseStatus(event, 413);
|
|
144
|
+
return {
|
|
145
|
+
error: `File too large (max ${Math.round(limit / 1024 / 1024)} MB)`,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const sessionId = nanoid();
|
|
150
|
+
const now = Date.now();
|
|
151
|
+
await createChunkedUploadSession(sessionId, {
|
|
152
|
+
filename,
|
|
153
|
+
mimeType: mimetype,
|
|
154
|
+
declaredSize,
|
|
155
|
+
chunks: {},
|
|
156
|
+
chunkSizes: {},
|
|
157
|
+
createdAt: new Date(now).toISOString(),
|
|
158
|
+
expiresAt: new Date(now + SESSION_TTL_MS).toISOString(),
|
|
159
|
+
});
|
|
160
|
+
return { sessionId, maxChunkBytes: MAX_CHUNK_BYTES };
|
|
161
|
+
},
|
|
162
|
+
authContext,
|
|
163
|
+
);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
export const uploadChunkedChunk = defineEventHandler(async (event) => {
|
|
167
|
+
const auth = await resolveSlidesRequestAuth(event);
|
|
168
|
+
if (!auth.ok) {
|
|
169
|
+
setResponseStatus(event, auth.statusCode);
|
|
170
|
+
return { error: auth.error };
|
|
171
|
+
}
|
|
172
|
+
const authContext = auth.context;
|
|
173
|
+
const email = authContext.email;
|
|
174
|
+
if (!email) {
|
|
175
|
+
setResponseStatus(event, 401);
|
|
176
|
+
return { error: "Unauthorized" };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return withSlidesRequestContext(
|
|
180
|
+
event,
|
|
181
|
+
async ({ orgId }) => {
|
|
182
|
+
const sessionId = getRouterParam(event, "sessionId");
|
|
183
|
+
if (!sessionId) {
|
|
184
|
+
setResponseStatus(event, 400);
|
|
185
|
+
return { error: "Missing sessionId" };
|
|
186
|
+
}
|
|
187
|
+
const session = await getChunkedUploadSession(sessionId);
|
|
188
|
+
if (!session) {
|
|
189
|
+
setResponseStatus(event, 404);
|
|
190
|
+
return { error: "Upload session not found or expired" };
|
|
191
|
+
}
|
|
192
|
+
if (Date.parse(session.expiresAt) <= Date.now()) {
|
|
193
|
+
await discardSession(sessionId, session);
|
|
194
|
+
setResponseStatus(event, 410);
|
|
195
|
+
return { error: "Upload session expired" };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const query = getQuery(event);
|
|
199
|
+
const index = Number(query.index ?? 0);
|
|
200
|
+
const isFinal = query.isFinal === "1" || query.isFinal === "true";
|
|
201
|
+
if (!Number.isInteger(index) || index < 0 || index >= MAX_CHUNKS) {
|
|
202
|
+
setResponseStatus(event, 400);
|
|
203
|
+
return { error: "Invalid chunk index" };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const contentLengthHeader = getHeader(event, "content-length");
|
|
207
|
+
if (!contentLengthHeader || !/^\d+$/.test(contentLengthHeader)) {
|
|
208
|
+
setResponseStatus(event, 411);
|
|
209
|
+
return { error: "Valid Content-Length header required" };
|
|
210
|
+
}
|
|
211
|
+
const contentLength = Number(contentLengthHeader);
|
|
212
|
+
if (contentLength <= 0) {
|
|
213
|
+
setResponseStatus(event, 400);
|
|
214
|
+
return { error: "Empty chunk body" };
|
|
215
|
+
}
|
|
216
|
+
if (contentLength > MAX_CHUNK_BYTES) {
|
|
217
|
+
setResponseStatus(event, 413);
|
|
218
|
+
return { error: "Chunk too large" };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const chunkKey = String(index);
|
|
222
|
+
const previousSize = session.chunkSizes[chunkKey] ?? 0;
|
|
223
|
+
const receivedBefore = Object.values(session.chunkSizes).reduce(
|
|
224
|
+
(total, size) => total + size,
|
|
225
|
+
0,
|
|
226
|
+
);
|
|
227
|
+
const nextSize = receivedBefore - previousSize + contentLength;
|
|
228
|
+
const fileLimit = maxReferenceFileBytes(session.filename);
|
|
229
|
+
if (nextSize > session.declaredSize || nextSize > fileLimit) {
|
|
230
|
+
await discardSession(sessionId, session);
|
|
231
|
+
setResponseStatus(event, 413);
|
|
232
|
+
return { error: "Uploaded bytes exceed the declared file size" };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const raw = await readRawBody(event, false);
|
|
236
|
+
const bytes = raw ?? new Uint8Array(0);
|
|
237
|
+
if (bytes.byteLength !== contentLength) {
|
|
238
|
+
setResponseStatus(event, 400);
|
|
239
|
+
return { error: "Chunk size does not match Content-Length" };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const previousHandle = session.chunks[chunkKey];
|
|
243
|
+
if (previousHandle && !(await deleteChunk(previousHandle))) {
|
|
244
|
+
setResponseStatus(event, 503);
|
|
245
|
+
return { error: "Could not replace the previously uploaded chunk" };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const handle = await putPrivateBlob({
|
|
249
|
+
data: bytes,
|
|
250
|
+
filename: `${sessionId}-${index}`,
|
|
251
|
+
mimeType: "application/octet-stream",
|
|
252
|
+
ownerEmail: email,
|
|
253
|
+
});
|
|
254
|
+
if (!handle) {
|
|
255
|
+
setResponseStatus(event, 503);
|
|
256
|
+
return { error: "Upload storage is not available" };
|
|
257
|
+
}
|
|
258
|
+
session.chunks[chunkKey] = handle;
|
|
259
|
+
session.chunkSizes[chunkKey] = bytes.byteLength;
|
|
260
|
+
await createChunkedUploadSession(sessionId, session);
|
|
261
|
+
|
|
262
|
+
if (!isFinal) return { ok: true };
|
|
263
|
+
|
|
264
|
+
const orderedIndices = Object.keys(session.chunks)
|
|
265
|
+
.map(Number)
|
|
266
|
+
.sort((a, b) => a - b);
|
|
267
|
+
const missing = orderedIndices.some((value, i) => value !== i);
|
|
268
|
+
const receivedSize = Object.values(session.chunkSizes).reduce(
|
|
269
|
+
(total, size) => total + size,
|
|
270
|
+
0,
|
|
271
|
+
);
|
|
272
|
+
if (
|
|
273
|
+
missing ||
|
|
274
|
+
orderedIndices.length === 0 ||
|
|
275
|
+
receivedSize !== session.declaredSize
|
|
276
|
+
) {
|
|
277
|
+
await discardSession(sessionId, session);
|
|
278
|
+
setResponseStatus(event, 400);
|
|
279
|
+
return { error: "Upload is incomplete or has an invalid size" };
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
let result;
|
|
283
|
+
try {
|
|
284
|
+
const parts = await Promise.all(
|
|
285
|
+
orderedIndices.map(async (chunkIndex) => {
|
|
286
|
+
const chunkHandle = session.chunks[String(chunkIndex)];
|
|
287
|
+
const read = await readPrivateBlob(chunkHandle);
|
|
288
|
+
return Buffer.from(read.data);
|
|
289
|
+
}),
|
|
290
|
+
);
|
|
291
|
+
const combined = Buffer.concat(parts);
|
|
292
|
+
if (combined.byteLength !== session.declaredSize) {
|
|
293
|
+
throw new Error("Assembled upload size does not match declaredSize");
|
|
294
|
+
}
|
|
295
|
+
result = await saveUploadedReferenceFile({
|
|
296
|
+
email,
|
|
297
|
+
orgId,
|
|
298
|
+
originalName: session.filename,
|
|
299
|
+
data: combined,
|
|
300
|
+
type: session.mimeType,
|
|
301
|
+
});
|
|
302
|
+
} catch (err) {
|
|
303
|
+
await discardSession(sessionId, session);
|
|
304
|
+
const statusCode =
|
|
305
|
+
typeof (err as { statusCode?: unknown })?.statusCode === "number"
|
|
306
|
+
? (err as { statusCode: number }).statusCode
|
|
307
|
+
: 400;
|
|
308
|
+
setResponseStatus(event, statusCode);
|
|
309
|
+
return { error: err instanceof Error ? err.message : "Invalid upload" };
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
await cleanupCommittedSession(sessionId, session);
|
|
313
|
+
return [result];
|
|
314
|
+
},
|
|
315
|
+
authContext,
|
|
316
|
+
);
|
|
317
|
+
});
|
|
@@ -11,6 +11,7 @@ import { nanoid } from "nanoid";
|
|
|
11
11
|
import {
|
|
12
12
|
MAX_FIG_REFERENCE_FILE_BYTES,
|
|
13
13
|
MAX_REFERENCE_FILE_BYTES,
|
|
14
|
+
MAX_REFERENCE_FILES,
|
|
14
15
|
SLIDES_REFERENCE_FILE_ERROR_LABEL,
|
|
15
16
|
isSlidesReferenceFileExtension,
|
|
16
17
|
} from "../../shared/upload-types.js";
|
|
@@ -237,11 +238,9 @@ export const uploadFiles = defineEventHandler(async (event) => {
|
|
|
237
238
|
return { error: "No files uploaded" };
|
|
238
239
|
}
|
|
239
240
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
if (fileParts.length > MAX_FILES) {
|
|
241
|
+
if (fileParts.length > MAX_REFERENCE_FILES) {
|
|
243
242
|
setResponseStatus(event, 413);
|
|
244
|
-
return { error: `Too many files (max ${
|
|
243
|
+
return { error: `Too many files (max ${MAX_REFERENCE_FILES})` };
|
|
245
244
|
}
|
|
246
245
|
|
|
247
246
|
const oversized = fileParts.find(
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import {
|
|
2
|
+
deleteAppState,
|
|
3
|
+
listAppState,
|
|
4
|
+
readAppState,
|
|
5
|
+
writeAppState,
|
|
6
|
+
} from "@agent-native/core/application-state";
|
|
7
|
+
import type { PrivateBlobHandle } from "@agent-native/core/private-blob";
|
|
8
|
+
|
|
9
|
+
export interface ChunkedUploadSession {
|
|
10
|
+
filename: string;
|
|
11
|
+
mimeType: string;
|
|
12
|
+
declaredSize: number;
|
|
13
|
+
chunks: Record<string, PrivateBlobHandle>;
|
|
14
|
+
chunkSizes: Record<string, number>;
|
|
15
|
+
createdAt: string;
|
|
16
|
+
expiresAt: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const PREFIX = "slides-upload-chunks-";
|
|
20
|
+
const key = (sessionId: string) => `${PREFIX}${sessionId}`;
|
|
21
|
+
|
|
22
|
+
export async function createChunkedUploadSession(
|
|
23
|
+
sessionId: string,
|
|
24
|
+
session: ChunkedUploadSession,
|
|
25
|
+
): Promise<void> {
|
|
26
|
+
await writeAppState(
|
|
27
|
+
key(sessionId),
|
|
28
|
+
session as unknown as Record<string, unknown>,
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function getChunkedUploadSession(
|
|
33
|
+
sessionId: string,
|
|
34
|
+
): Promise<ChunkedUploadSession | null> {
|
|
35
|
+
const raw = await readAppState(key(sessionId));
|
|
36
|
+
if (!raw || typeof raw !== "object") return null;
|
|
37
|
+
return raw as unknown as ChunkedUploadSession;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function listChunkedUploadSessions(): Promise<
|
|
41
|
+
Array<{ sessionId: string; session: ChunkedUploadSession }>
|
|
42
|
+
> {
|
|
43
|
+
const entries = await listAppState(PREFIX);
|
|
44
|
+
return entries.map(({ key: entryKey, value }) => ({
|
|
45
|
+
sessionId: entryKey.slice(PREFIX.length),
|
|
46
|
+
session: value as unknown as ChunkedUploadSession,
|
|
47
|
+
}));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function deleteChunkedUploadSession(
|
|
51
|
+
sessionId: string,
|
|
52
|
+
): Promise<void> {
|
|
53
|
+
await deleteAppState(key(sessionId));
|
|
54
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { uploadChunkedChunk as default } from "../../../../handlers/uploads-chunked";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { startChunkedUpload as default } from "../../../handlers/uploads-chunked";
|
|
@@ -25,6 +25,7 @@ export const SLIDES_REFERENCE_FILE_LABEL =
|
|
|
25
25
|
export const SLIDES_REFERENCE_FILE_ERROR_LABEL =
|
|
26
26
|
"pptx, docx, pdf, fig, text, Markdown, JSON, CSV, and images including SVG";
|
|
27
27
|
|
|
28
|
+
export const MAX_REFERENCE_FILES = 20;
|
|
28
29
|
export const MAX_REFERENCE_FILE_BYTES = 50 * 1024 * 1024;
|
|
29
30
|
export const MAX_FIG_REFERENCE_FILE_BYTES = 200 * 1024 * 1024;
|
|
30
31
|
|
|
@@ -161,6 +161,8 @@ export declare function shouldAllowAgentChatSurfaceSettingsMode(mode: AgentChatS
|
|
|
161
161
|
export declare function AgentChatSurface({ mode, className, defaultMode, showHeader, showTabBar, isFullscreen, style, chatViewTransition, showPageNewChatButton, ...props }: AgentChatSurfaceProps): React.JSX.Element;
|
|
162
162
|
export interface AgentSidebarProps {
|
|
163
163
|
children: React.ReactNode;
|
|
164
|
+
/** Keep the app surface mounted while temporarily disabling the chat panel. */
|
|
165
|
+
enabled?: boolean;
|
|
164
166
|
/** Placeholder text for the empty chat state */
|
|
165
167
|
emptyStateText?: string;
|
|
166
168
|
/** Suggestion prompts shown when no messages */
|
|
@@ -260,7 +262,7 @@ export interface AgentSidebarProps {
|
|
|
260
262
|
* Wraps app content with a toggleable agent sidebar.
|
|
261
263
|
* Use AgentToggleButton in your header to open/close it.
|
|
262
264
|
*/
|
|
263
|
-
export declare function AgentSidebar({ children, emptyStateText, suggestions, dynamicSuggestions, composerToolbarSlot, composerSlot, onComposerTextChange, imageModelMenu, availableAgents, availableModels, modelListLoading, selectedAgent, onAgentChange, onConnectLocalRuntime, onConnectProvider, runtime, adapterReloadKey, threadFooterSlot, defaultSidebarWidth, sidebarWidth, position, defaultOpen, animateMobile, animateDesktop, chatViewTransition, chatViewTransitionHandoff, storageKey, openStorageKey, apiUrl, agentChatSurface, desktopIdentityUnauthenticated, desktopIdentityAuthenticated, showTabBar, suppressInlineOpenApp, composerPlaceholder, openOnChatRunning, onFullscreenRequest, scope, isolateHistoryByScope, showScopeBadge, browserTabId, threadUrlSync, agentPageHref, suppressFirstRunOnboarding, }: AgentSidebarProps): React.JSX.Element;
|
|
265
|
+
export declare function AgentSidebar({ children, enabled, emptyStateText, suggestions, dynamicSuggestions, composerToolbarSlot, composerSlot, onComposerTextChange, imageModelMenu, availableAgents, availableModels, modelListLoading, selectedAgent, onAgentChange, onConnectLocalRuntime, onConnectProvider, runtime, adapterReloadKey, threadFooterSlot, defaultSidebarWidth, sidebarWidth, position, defaultOpen, animateMobile, animateDesktop, chatViewTransition, chatViewTransitionHandoff, storageKey, openStorageKey, apiUrl, agentChatSurface, desktopIdentityUnauthenticated, desktopIdentityAuthenticated, showTabBar, suppressInlineOpenApp, composerPlaceholder, openOnChatRunning, onFullscreenRequest, scope, isolateHistoryByScope, showScopeBadge, browserTabId, threadUrlSync, agentPageHref, suppressFirstRunOnboarding, }: AgentSidebarProps): React.JSX.Element;
|
|
264
266
|
/**
|
|
265
267
|
* Focus the agent chat composer input.
|
|
266
268
|
* Opens the sidebar if closed, then focuses the text input.
|
|
@@ -1418,7 +1418,7 @@ export function AgentChatSurface({ mode = "panel", className, defaultMode = "cha
|
|
|
1418
1418
|
* Wraps app content with a toggleable agent sidebar.
|
|
1419
1419
|
* Use AgentToggleButton in your header to open/close it.
|
|
1420
1420
|
*/
|
|
1421
|
-
export function AgentSidebar({ children, emptyStateText = "How can I help you?", suggestions, dynamicSuggestions, composerToolbarSlot, composerSlot, onComposerTextChange, imageModelMenu, availableAgents, availableModels, modelListLoading, selectedAgent, onAgentChange, onConnectLocalRuntime, onConnectProvider, runtime, adapterReloadKey, threadFooterSlot, defaultSidebarWidth, sidebarWidth, position = "right", defaultOpen = false, animateMobile = true, animateDesktop = true, chatViewTransition = false, chatViewTransitionHandoff = false, storageKey, openStorageKey, apiUrl, agentChatSurface, desktopIdentityUnauthenticated, desktopIdentityAuthenticated, showTabBar = true, suppressInlineOpenApp, composerPlaceholder, openOnChatRunning = false, onFullscreenRequest, scope, isolateHistoryByScope = false, showScopeBadge, browserTabId, threadUrlSync, agentPageHref, suppressFirstRunOnboarding = false, }) {
|
|
1421
|
+
export function AgentSidebar({ children, enabled = true, emptyStateText = "How can I help you?", suggestions, dynamicSuggestions, composerToolbarSlot, composerSlot, onComposerTextChange, imageModelMenu, availableAgents, availableModels, modelListLoading, selectedAgent, onAgentChange, onConnectLocalRuntime, onConnectProvider, runtime, adapterReloadKey, threadFooterSlot, defaultSidebarWidth, sidebarWidth, position = "right", defaultOpen = false, animateMobile = true, animateDesktop = true, chatViewTransition = false, chatViewTransitionHandoff = false, storageKey, openStorageKey, apiUrl, agentChatSurface, desktopIdentityUnauthenticated, desktopIdentityAuthenticated, showTabBar = true, suppressInlineOpenApp, composerPlaceholder, openOnChatRunning = false, onFullscreenRequest, scope, isolateHistoryByScope = false, showScopeBadge, browserTabId, threadUrlSync, agentPageHref, suppressFirstRunOnboarding = false, }) {
|
|
1422
1422
|
const staticHostedHarnessEnabled = isHostedHarnessConfigured(injectedAgentNativeConfig().harness);
|
|
1423
1423
|
const hostedHarnessQuery = useActionQuery("get-hosted-harness-config", undefined, { enabled: staticHostedHarnessEnabled });
|
|
1424
1424
|
const hostedHarnessStatus = hostedHarnessQuery.data;
|
|
@@ -1596,7 +1596,8 @@ export function AgentSidebar({ children, emptyStateText = "How can I help you?",
|
|
|
1596
1596
|
const [hasFrameSidebarState, setHasFrameSidebarState] = useState(false);
|
|
1597
1597
|
const [backgroundPanelActive, setBackgroundPanelActive] = useState(false);
|
|
1598
1598
|
const [runningTabIds, setRunningTabIds] = useState(() => new Set());
|
|
1599
|
-
const shouldMountPanel =
|
|
1599
|
+
const shouldMountPanel = enabled &&
|
|
1600
|
+
!isPerAppChatHosted &&
|
|
1600
1601
|
!presentationMode &&
|
|
1601
1602
|
(!frameCodeMode || !shouldParentFrameOwnAgentPanel()) &&
|
|
1602
1603
|
(open || backgroundPanelActive || runningTabIds.size > 0);
|
|
@@ -1612,9 +1613,10 @@ export function AgentSidebar({ children, emptyStateText = "How can I help you?",
|
|
|
1612
1613
|
if (frameOwned && !hasFrameSidebarState && !isPerAppChatHosted)
|
|
1613
1614
|
return;
|
|
1614
1615
|
dispatchAgentSidebarStateChange({
|
|
1615
|
-
open:
|
|
1616
|
-
|
|
1617
|
-
|
|
1616
|
+
open: enabled &&
|
|
1617
|
+
(isPerAppChatHosted
|
|
1618
|
+
? perAppChatState.open
|
|
1619
|
+
: !presentationMode && (frameOwned ? frameSidebarOpen : open)),
|
|
1618
1620
|
source: frameOwned ? "frame" : "app",
|
|
1619
1621
|
mode: frameOwned ? "code" : "app",
|
|
1620
1622
|
});
|
|
@@ -1626,6 +1628,7 @@ export function AgentSidebar({ children, emptyStateText = "How can I help you?",
|
|
|
1626
1628
|
hasFrameSidebarState,
|
|
1627
1629
|
isPerAppChatHosted,
|
|
1628
1630
|
perAppChatState.open,
|
|
1631
|
+
enabled,
|
|
1629
1632
|
]);
|
|
1630
1633
|
useEffect(() => {
|
|
1631
1634
|
if (!isPerAppChatSidebar)
|
|
@@ -1633,7 +1636,7 @@ export function AgentSidebar({ children, emptyStateText = "How can I help you?",
|
|
|
1633
1636
|
const frameOwned = frameCodeMode && shouldParentFrameOwnAgentPanel();
|
|
1634
1637
|
if (frameOwned && !hasFrameSidebarState)
|
|
1635
1638
|
return;
|
|
1636
|
-
const openState = !presentationMode && (frameOwned ? frameSidebarOpen : open);
|
|
1639
|
+
const openState = enabled && !presentationMode && (frameOwned ? frameSidebarOpen : open);
|
|
1637
1640
|
const message = buildAppChatSidebarStateMessage(openState);
|
|
1638
1641
|
window.dispatchEvent(new CustomEvent(APP_CHAT_SIDEBAR_STATE_EVENT, {
|
|
1639
1642
|
detail: message.data,
|
|
@@ -1656,6 +1659,7 @@ export function AgentSidebar({ children, emptyStateText = "How can I help you?",
|
|
|
1656
1659
|
isPerAppChatSidebar,
|
|
1657
1660
|
open,
|
|
1658
1661
|
presentationMode,
|
|
1662
|
+
enabled,
|
|
1659
1663
|
]);
|
|
1660
1664
|
useEffect(() => {
|
|
1661
1665
|
const preparePanel = () => setBackgroundPanelActive(true);
|
|
@@ -1968,10 +1972,9 @@ export function AgentSidebar({ children, emptyStateText = "How can I help you?",
|
|
|
1968
1972
|
}
|
|
1969
1973
|
};
|
|
1970
1974
|
}, [shouldMountPanel, sidebarAnimationEnabled]);
|
|
1971
|
-
const shouldRenderPanel =
|
|
1972
|
-
? renderAnimatedPanel
|
|
1973
|
-
|
|
1974
|
-
const panelOpen = open && shouldMountPanel;
|
|
1975
|
+
const shouldRenderPanel = enabled &&
|
|
1976
|
+
(sidebarAnimationEnabled ? renderAnimatedPanel : shouldMountPanel);
|
|
1977
|
+
const panelOpen = enabled && open && shouldMountPanel;
|
|
1975
1978
|
const panelLayout = isMobile
|
|
1976
1979
|
? "mobile"
|
|
1977
1980
|
: wideDrawerEnabled
|
|
@@ -2064,6 +2067,7 @@ export function AgentSidebar({ children, emptyStateText = "How can I help you?",
|
|
|
2064
2067
|
return (_jsx(AgentSidebarOnboardingContext.Provider, { value: true, children: _jsxs(RealtimeVoiceModeProvider, { browserTabId: browserTabId, children: [showFirstRunOnboarding && (_jsx(Suspense, { fallback: null, children: _jsx(FirstRunOnboarding, {}) })), _jsxs("div", { className: "agent-sidebar-shell flex min-w-0 flex-1 h-screen overflow-hidden", "data-agent-sidebar-position": effectivePosition, "data-agent-native-hosted-harness-ui": hostedHarnessUi ? "desktop" : undefined, "data-agent-native-hosted-chat": isPerAppChatHosted ? "true" : undefined, "data-agent-sidebar-resizing": isResizing ? "true" : undefined, children: [isMobile &&
|
|
2065
2068
|
!isPerAppChatHosted &&
|
|
2066
2069
|
!presentationMode &&
|
|
2070
|
+
enabled &&
|
|
2067
2071
|
(mobileAnimationEnabled ? shouldRenderPanel : open) && (_jsx("div", { className: cn("agent-sidebar-backdrop fixed inset-0 bg-foreground/40", mobileAnimationEnabled && !panelOpen && "pointer-events-none"), "data-agent-sidebar-animation": mobileAnimationEnabled ? "mobile" : undefined, "data-agent-sidebar-state": panelOpen ? "open" : "closed", style: { zIndex: SIDEBAR_OVERLAY_Z_INDEX - 1 }, onClick: () => setOpenPersisted(false) })), shouldMountPanel ? _jsx(URLSync, { browserTabId: browserTabId }) : null, isResizing ? (_jsx("div", { "aria-hidden": "true", className: "agent-sidebar-resize-overlay" })) : null, isLeft && !presentationMode ? sidebar : null, isLeft && !presentationMode ? drawerPlaceholder : null, _jsx("div", { className: "agent-sidebar-main-surface flex flex-1 flex-col overflow-auto min-w-0", "data-agent-sidebar-main-position": position, "data-agent-sidebar-main-state": !isMobile && !presentationMode && panelOpen ? "open" : "closed", "data-agent-sidebar-resizing": isResizing ? "true" : undefined, children: _jsx(ScreenRefreshBoundary, { children: children }) }), !isLeft && !presentationMode ? drawerPlaceholder : null, !isLeft && !presentationMode ? sidebar : null] })] }) }));
|
|
2068
2072
|
}
|
|
2069
2073
|
/**
|
|
@@ -62,11 +62,11 @@ export declare const postAwareness: import("h3").EventHandlerWithFetch<import("h
|
|
|
62
62
|
error: string;
|
|
63
63
|
states?: undefined;
|
|
64
64
|
} | {
|
|
65
|
-
error?: undefined;
|
|
66
65
|
states: {
|
|
67
66
|
clientId: number;
|
|
68
67
|
state: string;
|
|
69
68
|
}[];
|
|
69
|
+
error?: undefined;
|
|
70
70
|
}>>;
|
|
71
71
|
/**
|
|
72
72
|
* GET /_agent-native/collab/:docId/users
|
|
@@ -77,9 +77,9 @@ export declare const getActiveUsers: import("h3").EventHandlerWithFetch<import("
|
|
|
77
77
|
error: string;
|
|
78
78
|
users?: undefined;
|
|
79
79
|
} | {
|
|
80
|
-
error?: undefined;
|
|
81
80
|
users: {
|
|
82
81
|
clientId: number;
|
|
83
82
|
lastSeen: number;
|
|
84
83
|
}[];
|
|
84
|
+
error?: undefined;
|
|
85
85
|
}>>;
|
|
@@ -199,6 +199,30 @@ export const builderFileUploadProvider = {
|
|
|
199
199
|
console.log(`[builder-upload] done: ${json.url}`);
|
|
200
200
|
return { url: json.url, id: json.id, provider: "builder" };
|
|
201
201
|
},
|
|
202
|
+
delete: async ({ url }) => {
|
|
203
|
+
const assetUrl = new URL(url);
|
|
204
|
+
if (assetUrl.hostname !== "cdn.builder.io")
|
|
205
|
+
return false;
|
|
206
|
+
assetUrl.search = "";
|
|
207
|
+
assetUrl.hash = "";
|
|
208
|
+
const { resolveBuilderCredentials } = await import("../server/credential-provider.js");
|
|
209
|
+
const credentials = await resolveBuilderCredentials();
|
|
210
|
+
if (!credentials.privateKey || !credentials.publicKey)
|
|
211
|
+
return false;
|
|
212
|
+
const deleteUrl = new URL("/api/v1/assets/by-url", "https://cdn.builder.io");
|
|
213
|
+
deleteUrl.searchParams.set("url", assetUrl.toString());
|
|
214
|
+
deleteUrl.searchParams.set("apiKey", credentials.publicKey);
|
|
215
|
+
const response = await fetchWithTimeout(deleteUrl.toString(), {
|
|
216
|
+
method: "DELETE",
|
|
217
|
+
headers: { Authorization: `Bearer ${credentials.privateKey}` },
|
|
218
|
+
});
|
|
219
|
+
if (response.ok)
|
|
220
|
+
return true;
|
|
221
|
+
if (response.status === 404)
|
|
222
|
+
return false;
|
|
223
|
+
await assertOk(response, "Builder.io asset delete failed");
|
|
224
|
+
return false;
|
|
225
|
+
},
|
|
202
226
|
resumable: {
|
|
203
227
|
async startSession(filename, mimeType, maxBytes) {
|
|
204
228
|
const { resolveBuilderPrivateKey } = await import("../server/credential-provider.js");
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type { FileUploadInput, FileUploadProvider, FileUploadResult, ResumableUploadSession, ResumableChunkResult, } from "./types.js";
|
|
2
|
-
export { registerFileUploadProvider, unregisterFileUploadProvider, listFileUploadProviders, getActiveFileUploadProvider, getActiveFileUploadProviderForRequest, uploadFile, } from "./registry.js";
|
|
1
|
+
export type { FileUploadDeleteInput, FileUploadInput, FileUploadProvider, FileUploadResult, ResumableUploadSession, ResumableChunkResult, } from "./types.js";
|
|
2
|
+
export { registerFileUploadProvider, unregisterFileUploadProvider, listFileUploadProviders, getActiveFileUploadProvider, getActiveFileUploadProviderForRequest, deleteUploadedFile, uploadFile, } from "./registry.js";
|
|
3
3
|
export { builderFileUploadProvider } from "./builder.js";
|
|
4
4
|
export { preUploadImageAttachments, preUploadAttachments, isFileUploadProviderConfigured, type PreUploadAttachmentsResult, type PreUploadedImageAttachment, type PreUploadedFileAttachment, } from "./pre-upload-attachments.js";
|
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
export { registerFileUploadProvider, unregisterFileUploadProvider, listFileUploadProviders, getActiveFileUploadProvider, getActiveFileUploadProviderForRequest, uploadFile, } from "./registry.js";
|
|
1
|
+
export { registerFileUploadProvider, unregisterFileUploadProvider, listFileUploadProviders, getActiveFileUploadProvider, getActiveFileUploadProviderForRequest, deleteUploadedFile, uploadFile, } from "./registry.js";
|
|
2
2
|
export { builderFileUploadProvider } from "./builder.js";
|
|
3
3
|
export { preUploadImageAttachments, preUploadAttachments, isFileUploadProviderConfigured, } from "./pre-upload-attachments.js";
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { FileUploadInput, FileUploadProvider, FileUploadResult } from "./types.js";
|
|
1
|
+
import type { FileUploadDeleteInput, FileUploadInput, FileUploadProvider, FileUploadResult } from "./types.js";
|
|
2
2
|
/**
|
|
3
3
|
* Register a file upload provider. Call from a server plugin or app
|
|
4
4
|
* bootstrap. Idempotent per id — later calls with the same id replace.
|
|
@@ -19,4 +19,5 @@ export declare function getActiveFileUploadProviderForRequest(): Promise<FileUpl
|
|
|
19
19
|
* configured. `null` is an explicit storage-setup state: callers must not
|
|
20
20
|
* turn the input into a base64 SQL fallback.
|
|
21
21
|
*/
|
|
22
|
+
export declare function deleteUploadedFile(providerId: string, input: FileUploadDeleteInput): Promise<boolean>;
|
|
22
23
|
export declare function uploadFile(input: FileUploadInput): Promise<FileUploadResult | null>;
|
|
@@ -59,6 +59,14 @@ export async function getActiveFileUploadProviderForRequest() {
|
|
|
59
59
|
* configured. `null` is an explicit storage-setup state: callers must not
|
|
60
60
|
* turn the input into a base64 SQL fallback.
|
|
61
61
|
*/
|
|
62
|
+
export async function deleteUploadedFile(providerId, input) {
|
|
63
|
+
const provider = providerId === builderFileUploadProvider.id
|
|
64
|
+
? builderFileUploadProvider
|
|
65
|
+
: providers.get(providerId);
|
|
66
|
+
if (!provider?.delete)
|
|
67
|
+
return false;
|
|
68
|
+
return provider.delete(input);
|
|
69
|
+
}
|
|
62
70
|
export async function uploadFile(input) {
|
|
63
71
|
const provider = await getActiveFileUploadProviderForRequest();
|
|
64
72
|
// User-registered providers (S3, etc.) may be configured by sync runtime
|
package/dist/file-upload/s3.js
CHANGED
|
@@ -146,6 +146,56 @@ async function putObject(config, key, data, contentType) {
|
|
|
146
146
|
}
|
|
147
147
|
return `${config.publicBaseUrl}/${key.split("/").map(encodePathSegment).join("/")}`;
|
|
148
148
|
}
|
|
149
|
+
async function deleteObject(config, key) {
|
|
150
|
+
const now = new Date();
|
|
151
|
+
const amzDate = now
|
|
152
|
+
.toISOString()
|
|
153
|
+
.replace(/[:-]|\.\d{3}/g, "")
|
|
154
|
+
.slice(0, 15) + "Z";
|
|
155
|
+
const dateStamp = amzDate.slice(0, 8);
|
|
156
|
+
const credentialScope = `${dateStamp}/${config.region}/s3/aws4_request`;
|
|
157
|
+
const host = new URL(config.endpoint).host;
|
|
158
|
+
const canonicalUri = objectPath(config, key);
|
|
159
|
+
const payloadHash = await sha256(new Uint8Array(0));
|
|
160
|
+
const headers = {
|
|
161
|
+
host,
|
|
162
|
+
"x-amz-content-sha256": payloadHash,
|
|
163
|
+
"x-amz-date": amzDate,
|
|
164
|
+
};
|
|
165
|
+
const signedHeaderKeys = Object.keys(headers).sort();
|
|
166
|
+
const signedHeaders = signedHeaderKeys.join(";");
|
|
167
|
+
const canonicalHeaders = signedHeaderKeys
|
|
168
|
+
.map((header) => `${header}:${headers[header]}`)
|
|
169
|
+
.join("\n") + "\n";
|
|
170
|
+
const canonicalRequest = [
|
|
171
|
+
"DELETE",
|
|
172
|
+
canonicalUri,
|
|
173
|
+
"",
|
|
174
|
+
canonicalHeaders,
|
|
175
|
+
signedHeaders,
|
|
176
|
+
payloadHash,
|
|
177
|
+
].join("\n");
|
|
178
|
+
const requestHash = await sha256(new TextEncoder().encode(canonicalRequest));
|
|
179
|
+
const stringToSign = [
|
|
180
|
+
"AWS4-HMAC-SHA256",
|
|
181
|
+
amzDate,
|
|
182
|
+
credentialScope,
|
|
183
|
+
requestHash,
|
|
184
|
+
].join("\n");
|
|
185
|
+
const signature = toHex(await hmac(await signingKey(config.secretAccessKey, dateStamp, config.region), stringToSign));
|
|
186
|
+
const authorization = `AWS4-HMAC-SHA256 Credential=${config.accessKeyId}/${credentialScope}, ` +
|
|
187
|
+
`SignedHeaders=${signedHeaders}, Signature=${signature}`;
|
|
188
|
+
const response = await fetch(`${config.endpoint}${canonicalUri}`, {
|
|
189
|
+
method: "DELETE",
|
|
190
|
+
headers: { ...headers, Authorization: authorization },
|
|
191
|
+
});
|
|
192
|
+
if (response.ok)
|
|
193
|
+
return true;
|
|
194
|
+
if (response.status === 404)
|
|
195
|
+
return false;
|
|
196
|
+
const detail = await response.text();
|
|
197
|
+
throw new Error(`S3 DeleteObject failed (${response.status}): ${detail || response.statusText}`);
|
|
198
|
+
}
|
|
149
199
|
function safeFilename(filename) {
|
|
150
200
|
const basename = filename?.split(/[\\/]/).pop()?.trim() || "attachment";
|
|
151
201
|
return (basename.replace(/[^a-zA-Z0-9._-]/g, "_").slice(0, 160) || "attachment");
|
|
@@ -163,6 +213,14 @@ export const s3FileUploadProvider = {
|
|
|
163
213
|
const key = `uploads/${Date.now()}-${Math.random().toString(36).slice(2, 10)}-${safeFilename(filename)}`;
|
|
164
214
|
const bytes = data instanceof Uint8Array ? data : new Uint8Array(data);
|
|
165
215
|
const url = await putObject(config, key, bytes, mimeType || "application/octet-stream");
|
|
166
|
-
return { url, provider: "s3" };
|
|
216
|
+
return { url, id: key, provider: "s3" };
|
|
217
|
+
},
|
|
218
|
+
delete: async ({ id }) => {
|
|
219
|
+
if (!id)
|
|
220
|
+
return false;
|
|
221
|
+
const config = await readRequestConfig();
|
|
222
|
+
if (!config)
|
|
223
|
+
return false;
|
|
224
|
+
return deleteObject(config, id);
|
|
167
225
|
},
|
|
168
226
|
};
|
|
@@ -28,6 +28,10 @@ export interface FileUploadResult {
|
|
|
28
28
|
/** The provider that handled the upload. */
|
|
29
29
|
provider: string;
|
|
30
30
|
}
|
|
31
|
+
export interface FileUploadDeleteInput {
|
|
32
|
+
url: string;
|
|
33
|
+
id?: string;
|
|
34
|
+
}
|
|
31
35
|
/** Opaque session handle returned by {@link FileUploadProvider.resumable.startSession}.
|
|
32
36
|
* `sessionId` is provider-specific (GCS Location URI, S3 UploadId, etc.).
|
|
33
37
|
* `meta` holds any provider state needed for subsequent relay and complete calls. */
|
|
@@ -56,6 +60,8 @@ export interface FileUploadProvider {
|
|
|
56
60
|
isConfiguredForRequest?: () => Promise<boolean>;
|
|
57
61
|
/** Upload a file and return a URL. Throw on failure. */
|
|
58
62
|
upload: (input: FileUploadInput) => Promise<FileUploadResult>;
|
|
63
|
+
/** Delete a previously uploaded file when the provider supports it. */
|
|
64
|
+
delete?: (input: FileUploadDeleteInput) => Promise<boolean>;
|
|
59
65
|
/**
|
|
60
66
|
* Optional resumable/streaming upload capability.
|
|
61
67
|
* When present, create-recording will initialise a session and stream chunks
|
|
@@ -11,14 +11,14 @@
|
|
|
11
11
|
* DELETE /_agent-native/notifications/:id — delete
|
|
12
12
|
*/
|
|
13
13
|
export declare function createNotificationsHandler(): import("h3").EventHandlerWithFetch<import("h3").EventHandlerRequest, Promise<"" | import("./types.js").Notification[] | {
|
|
14
|
-
error?: undefined;
|
|
15
14
|
count: number;
|
|
16
15
|
updated?: undefined;
|
|
16
|
+
error?: undefined;
|
|
17
17
|
ok?: undefined;
|
|
18
18
|
} | {
|
|
19
|
-
error?: undefined;
|
|
20
19
|
count?: undefined;
|
|
21
20
|
updated: number;
|
|
21
|
+
error?: undefined;
|
|
22
22
|
ok?: undefined;
|
|
23
23
|
} | {
|
|
24
24
|
count?: undefined;
|
|
@@ -26,8 +26,8 @@ export declare function createNotificationsHandler(): import("h3").EventHandlerW
|
|
|
26
26
|
error: string;
|
|
27
27
|
ok?: undefined;
|
|
28
28
|
} | {
|
|
29
|
-
error?: undefined;
|
|
30
29
|
count?: undefined;
|
|
31
30
|
updated?: undefined;
|
|
32
31
|
ok: boolean;
|
|
32
|
+
error?: undefined;
|
|
33
33
|
}>>;
|
|
@@ -3,11 +3,6 @@ export declare function registerPrivateBlobProvider(provider: PrivateBlobProvide
|
|
|
3
3
|
export declare function unregisterPrivateBlobProvider(id: string): void;
|
|
4
4
|
export declare function listPrivateBlobProviders(): PrivateBlobProvider[];
|
|
5
5
|
export declare function getActivePrivateBlobProvider(): PrivateBlobProvider | null;
|
|
6
|
-
/**
|
|
7
|
-
* @deprecated Use `defineAppConfig({ privateBlob: { publicUploadFallback } })`
|
|
8
|
-
* instead. This writes the same value into the deprecated layer of the config
|
|
9
|
-
* ladder, so an explicit `defineAppConfig` call now wins over it.
|
|
10
|
-
*/
|
|
11
6
|
export declare function setPrivateBlobPublicUploadFallbackEnabled(enabled: boolean): void;
|
|
12
7
|
export declare function putPrivateBlob(input: PrivateBlobPutInput): Promise<PrivateBlobHandle | null>;
|
|
13
8
|
export declare function readPrivateBlob(handle: PrivateBlobHandle): Promise<PrivateBlobReadResult>;
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
|
|
2
2
|
import { getAppConfig } from "../app-config/index.js";
|
|
3
|
-
import {
|
|
4
|
-
import { uploadFile } from "../file-upload/index.js";
|
|
3
|
+
import { deleteUploadedFile, uploadFile } from "../file-upload/index.js";
|
|
5
4
|
import { decryptSecretValue, encryptSecretValue, getSecretEncryptionKey, } from "../secrets/crypto.js";
|
|
6
5
|
const PUBLIC_UPLOAD_HANDLE_PREFIX = "public-upload:v1:";
|
|
7
6
|
const PUBLIC_UPLOAD_READ_RETRY_DELAYS_MS = [100, 250, 500];
|
|
8
7
|
const globals = globalThis;
|
|
9
8
|
const providers = (globals.__agentNativePrivateBlobProviders ??= new Map());
|
|
9
|
+
const publicUploadFallbackRef = (globals.__agentNativePrivateBlobPublicUploadFallback ??= {
|
|
10
|
+
enabled: true,
|
|
11
|
+
});
|
|
10
12
|
function toBytes(data) {
|
|
11
13
|
return data instanceof Uint8Array ? data : new Uint8Array(data);
|
|
12
14
|
}
|
|
@@ -170,13 +172,16 @@ export function listPrivateBlobProviders() {
|
|
|
170
172
|
return [...providers.values()];
|
|
171
173
|
}
|
|
172
174
|
export function getActivePrivateBlobProvider() {
|
|
173
|
-
const
|
|
174
|
-
if (
|
|
175
|
-
const
|
|
176
|
-
if (!
|
|
177
|
-
throw new Error(`Private blob
|
|
175
|
+
const selectedId = getAppConfig().privateBlob.provider;
|
|
176
|
+
if (selectedId) {
|
|
177
|
+
const selected = providers.get(selectedId);
|
|
178
|
+
if (!selected) {
|
|
179
|
+
throw new Error(`Private blob config selects '${selectedId}', but no provider with that id is registered`);
|
|
178
180
|
}
|
|
179
|
-
|
|
181
|
+
if (!selected.isConfigured()) {
|
|
182
|
+
throw new Error(`Private blob provider '${selectedId}' is selected but not configured`);
|
|
183
|
+
}
|
|
184
|
+
return selected;
|
|
180
185
|
}
|
|
181
186
|
for (const provider of providers.values()) {
|
|
182
187
|
if (provider.isConfigured())
|
|
@@ -184,20 +189,15 @@ export function getActivePrivateBlobProvider() {
|
|
|
184
189
|
}
|
|
185
190
|
return null;
|
|
186
191
|
}
|
|
187
|
-
/**
|
|
188
|
-
* @deprecated Use `defineAppConfig({ privateBlob: { publicUploadFallback } })`
|
|
189
|
-
* instead. This writes the same value into the deprecated layer of the config
|
|
190
|
-
* ladder, so an explicit `defineAppConfig` call now wins over it.
|
|
191
|
-
*/
|
|
192
192
|
export function setPrivateBlobPublicUploadFallbackEnabled(enabled) {
|
|
193
|
-
|
|
194
|
-
privateBlob: { publicUploadFallback: enabled },
|
|
195
|
-
});
|
|
193
|
+
publicUploadFallbackRef.enabled = enabled;
|
|
196
194
|
}
|
|
197
195
|
export async function putPrivateBlob(input) {
|
|
198
196
|
const provider = getActivePrivateBlobProvider();
|
|
199
197
|
if (provider)
|
|
200
198
|
return provider.put(input);
|
|
199
|
+
if (!publicUploadFallbackRef.enabled)
|
|
200
|
+
return null;
|
|
201
201
|
if (!getAppConfig().privateBlob.publicUploadFallback)
|
|
202
202
|
return null;
|
|
203
203
|
return putViaEncryptedPublicUpload(input);
|
|
@@ -216,10 +216,17 @@ export async function deletePrivateBlob(handle) {
|
|
|
216
216
|
if (provider)
|
|
217
217
|
return provider.delete(handle);
|
|
218
218
|
if (isPublicUploadFallbackHandle(handle)) {
|
|
219
|
+
const descriptor = decodePublicUploadDescriptor(handle.id);
|
|
220
|
+
const deleted = await deleteUploadedFile(descriptor.uploadProvider, {
|
|
221
|
+
url: descriptor.url,
|
|
222
|
+
id: descriptor.uploadId,
|
|
223
|
+
});
|
|
219
224
|
return {
|
|
220
|
-
deleted
|
|
225
|
+
deleted,
|
|
221
226
|
provider: handle.provider,
|
|
222
|
-
|
|
227
|
+
...(deleted
|
|
228
|
+
? {}
|
|
229
|
+
: { reason: "backing upload provider could not delete the asset" }),
|
|
223
230
|
};
|
|
224
231
|
}
|
|
225
232
|
throw new Error(`No private blob provider registered for ${handle.provider}`);
|
|
@@ -51,8 +51,8 @@ export declare function handleDeleteResource(event: any): Promise<{
|
|
|
51
51
|
error: string;
|
|
52
52
|
ok?: undefined;
|
|
53
53
|
} | {
|
|
54
|
-
error?: undefined;
|
|
55
54
|
ok: boolean;
|
|
55
|
+
error?: undefined;
|
|
56
56
|
}>;
|
|
57
57
|
/** POST /_agent-native/resources/upload — upload a file as a resource */
|
|
58
58
|
export declare function handleUploadResource(event: any): Promise<import("./store.js").Resource | {
|
|
@@ -73,10 +73,10 @@ export declare function handleUploadResource(event: any): Promise<import("./stor
|
|
|
73
73
|
runId: string | null;
|
|
74
74
|
expiresAt: number | null;
|
|
75
75
|
metadata: string | null;
|
|
76
|
-
error?: undefined;
|
|
77
76
|
url: string;
|
|
78
77
|
provider: string;
|
|
79
78
|
storageSetupRequired?: undefined;
|
|
79
|
+
error?: undefined;
|
|
80
80
|
} | {
|
|
81
81
|
error: string;
|
|
82
82
|
storageSetupRequired: boolean;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agent-native/core",
|
|
3
|
-
"version": "0.168.
|
|
3
|
+
"version": "0.168.6",
|
|
4
4
|
"description": "Framework for agent-native application development — where AI agents and UI share SQL state, actions, and context",
|
|
5
5
|
"homepage": "https://github.com/BuilderIO/agent-native#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -428,8 +428,8 @@
|
|
|
428
428
|
"y-protocols": "^1.0.7",
|
|
429
429
|
"yjs": "^13.6.32",
|
|
430
430
|
"zod": "^4.3.6",
|
|
431
|
-
"@agent-native/
|
|
432
|
-
"@agent-native/
|
|
431
|
+
"@agent-native/recap-cli": "0.5.7",
|
|
432
|
+
"@agent-native/toolkit": "^0.16.9"
|
|
433
433
|
},
|
|
434
434
|
"devDependencies": {
|
|
435
435
|
"@ai-sdk/anthropic": "^3.0.71",
|