@agent-native/core 0.128.0 → 0.128.2
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/core/CHANGELOG.md +27 -0
- package/corpus/core/package.json +3 -1
- package/corpus/core/src/server/builder-design-systems.ts +299 -82
- package/corpus/core/src/server/email-template.ts +33 -0
- package/corpus/core/src/server/index.ts +8 -0
- package/corpus/templates/analytics/_gitignore +2 -0
- package/corpus/templates/assets/_gitignore +2 -0
- package/corpus/templates/calendar/_gitignore +2 -0
- package/corpus/templates/chat/_gitignore +2 -0
- package/corpus/templates/clips/AGENTS.md +6 -1
- package/corpus/templates/clips/_gitignore +2 -0
- package/corpus/templates/clips/actions/complete-transactional-email-summary.ts +56 -0
- package/corpus/templates/clips/actions/import-loom-recording.ts +258 -76
- package/corpus/templates/clips/actions/lib/direct-video.ts +96 -0
- package/corpus/templates/clips/actions/lib/loom-import-job.ts +53 -2
- package/corpus/templates/clips/actions/lib/loom-video.ts +2 -62
- package/corpus/templates/clips/actions/lib/video-download-limits.ts +62 -0
- package/corpus/templates/clips/actions/list-transactional-email-ai-requests.ts +249 -0
- package/corpus/templates/clips/app/components/library/library-grid.tsx +65 -0
- package/corpus/templates/clips/app/components/library/library-layout.tsx +55 -0
- package/corpus/templates/clips/app/components/recorder/pre-record-panel.tsx +28 -126
- package/corpus/templates/clips/app/global.css +23 -0
- package/corpus/templates/clips/app/hooks/use-transactional-email-bridge.ts +98 -0
- package/corpus/templates/clips/app/i18n/en-US.ts +20 -0
- package/corpus/templates/clips/app/routes/_app.tsx +2 -0
- package/corpus/templates/clips/app/routes/import.tsx +396 -0
- package/corpus/templates/clips/app/routes/r.$recordingId.tsx +8 -6
- package/corpus/templates/clips/app/routes/record.tsx +13 -71
- package/corpus/templates/clips/changelog/2026-07-25-two-clip-emails-now-include-a-concise-summary-generated-secu.md +6 -0
- package/corpus/templates/clips/data/clips-transactional-emails/config.json +3 -0
- package/corpus/templates/clips/server/jobs/transactional-emails.ts +1034 -0
- package/corpus/templates/clips/server/lib/transactional-email-store.ts +865 -0
- package/corpus/templates/clips/server/lib/transactional-email-templates.ts +270 -0
- package/corpus/templates/clips/server/plugins/calendar-jobs.ts +2 -0
- package/corpus/templates/clips/server/routes/api/view-event.post.ts +59 -2
- package/corpus/templates/content/.agents/skills/document-editing/references/databases.md +8 -2
- package/corpus/templates/content/AGENTS.md +8 -0
- package/corpus/templates/content/_gitignore +2 -0
- package/corpus/templates/content/actions/_content-database-personal-view.ts +28 -3
- package/corpus/templates/content/actions/_property-utils.ts +32 -2
- package/corpus/templates/content/actions/configure-document-property.ts +12 -2
- package/corpus/templates/content/actions/delete-document-property.ts +13 -3
- package/corpus/templates/content/actions/duplicate-document-property.ts +13 -3
- package/corpus/templates/content/actions/list-content-spaces.ts +5 -2
- package/corpus/templates/content/actions/list-document-properties.ts +12 -3
- package/corpus/templates/content/actions/move-database-item.ts +74 -55
- package/corpus/templates/content/actions/reorder-document-property.ts +20 -4
- package/corpus/templates/content/actions/set-document-property.ts +10 -1
- package/corpus/templates/content/actions/update-content-database-personal-view.ts +7 -0
- package/corpus/templates/content/app/components/editor/DocumentBlockFields.tsx +31 -21
- package/corpus/templates/content/app/components/editor/DocumentEditor.tsx +30 -3
- package/corpus/templates/content/app/components/editor/DocumentInfoPanel.tsx +8 -1
- package/corpus/templates/content/app/components/editor/DocumentProperties.tsx +72 -20
- package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +79 -10
- package/corpus/templates/content/app/components/editor/database/TimelineView.tsx +8 -1
- package/corpus/templates/content/app/components/editor/database/settings.tsx +6 -0
- package/corpus/templates/content/app/components/editor/database/sidebar.tsx +246 -26
- package/corpus/templates/content/app/components/sidebar/DocumentSidebar.tsx +491 -197
- package/corpus/templates/content/app/components/sidebar/DocumentTreeItem.tsx +3 -3
- package/corpus/templates/content/app/components/sidebar/sidebar-reorder.tsx +409 -0
- package/corpus/templates/content/app/hooks/use-content-database.ts +117 -3
- package/corpus/templates/content/app/hooks/use-content-spaces.ts +1 -0
- package/corpus/templates/content/app/hooks/use-document-properties.ts +62 -18
- package/corpus/templates/content/app/hooks/use-documents.ts +13 -5
- package/corpus/templates/content/app/i18n-data.ts +261 -3
- package/corpus/templates/content/app/routes/_app.page.$id.tsx +9 -2
- package/corpus/templates/content/changelog/2026-07-24-database-page-properties-keep-their-context.md +6 -0
- package/corpus/templates/content/changelog/2026-07-26-sidebar-references-can-now-be-personally-reordered.md +6 -0
- package/corpus/templates/content/docs/solutions/2026-07-24-database-property-context-switch-diagnosis.md +249 -0
- package/corpus/templates/content/docs/solutions/content-sidebar-order-shape.md +389 -0
- package/corpus/templates/content/shared/api.ts +28 -0
- package/corpus/templates/design/_gitignore +2 -0
- package/corpus/templates/design/app/i18n-data.ts +10 -0
- package/corpus/templates/design/app/lib/builder-design-system-upload.ts +280 -0
- package/corpus/templates/design/app/pages/DesignSystemSetup.tsx +90 -54
- package/corpus/templates/design/changelog/2026-07-28-figma-fig-uploads-now-show-indexing-progress-and-an-open-in-.md +6 -0
- package/corpus/templates/design/server/handlers/design-system-decode-job-status.ts +49 -0
- package/corpus/templates/design/server/handlers/design-system-upload-start.ts +87 -0
- package/corpus/templates/design/server/handlers/index-design-system-sources.ts +82 -0
- package/corpus/templates/design/server/routes/api/design-system-decode-job-status.get.ts +1 -0
- package/corpus/templates/design/server/routes/api/design-system-upload-start.post.ts +1 -0
- package/corpus/templates/design/server/routes/api/index-design-system-sources.post.ts +1 -0
- package/corpus/templates/dispatch/_gitignore +2 -0
- package/corpus/templates/forms/_gitignore +2 -0
- package/corpus/templates/macros/_gitignore +2 -0
- package/corpus/templates/mail/_gitignore +2 -0
- package/corpus/templates/plan/_gitignore +2 -0
- package/corpus/templates/slides/_gitignore +2 -0
- package/corpus/templates/slides/app/components/design-system/DesignSystemSetup.tsx +126 -53
- package/corpus/templates/slides/app/components/design-system/builder-design-system-upload.ts +271 -0
- package/corpus/templates/slides/app/components/design-system/builder-index-response.ts +1 -1
- package/corpus/templates/slides/app/i18n/en-US.ts +1 -0
- package/corpus/templates/slides/server/handlers/design-system-decode-job-status.ts +49 -0
- package/corpus/templates/slides/server/handlers/design-system-upload-start.ts +87 -0
- package/corpus/templates/slides/server/handlers/index-design-system-sources.ts +79 -0
- package/corpus/templates/slides/server/routes/api/design-system-decode-job-status.get.ts +1 -0
- package/corpus/templates/slides/server/routes/api/design-system-upload-start.post.ts +1 -0
- package/corpus/templates/slides/server/routes/api/index-design-system-sources.post.ts +1 -0
- package/corpus/templates/tasks/_gitignore +2 -0
- package/corpus/toolkit/CHANGELOG.md +6 -0
- package/corpus/toolkit/package.json +1 -1
- package/corpus/toolkit/src/editor/DragHandle.ts +63 -9
- package/dist/collab/routes.d.ts +1 -1
- package/dist/collab/struct-routes.d.ts +1 -1
- package/dist/observability/routes.d.ts +3 -3
- package/dist/progress/routes.d.ts +1 -1
- package/dist/provider-api/actions/custom-provider-registration.d.ts +10 -10
- package/dist/provider-api/actions/provider-api.d.ts +8 -8
- package/dist/server/builder-design-systems.d.ts +49 -0
- package/dist/server/builder-design-systems.d.ts.map +1 -1
- package/dist/server/builder-design-systems.js +221 -69
- package/dist/server/builder-design-systems.js.map +1 -1
- package/dist/server/email-template.d.ts +7 -0
- package/dist/server/email-template.d.ts.map +1 -1
- package/dist/server/email-template.js +24 -0
- package/dist/server/email-template.js.map +1 -1
- package/dist/server/index.d.ts +1 -1
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +1 -1
- package/dist/server/index.js.map +1 -1
- package/dist/server/transcribe-voice.d.ts +1 -1
- package/dist/templates/chat/_gitignore +2 -0
- package/package.json +5 -3
- package/src/server/builder-design-systems.ts +299 -82
- package/src/server/email-template.ts +33 -0
- package/src/server/index.ts +8 -0
- package/src/templates/chat/_gitignore +2 -0
- package/corpus/templates/design/server/handlers/index-design-system-with-builder.ts +0 -151
- package/corpus/templates/design/server/routes/api/index-design-system-with-builder.post.ts +0 -1
- package/corpus/templates/slides/server/handlers/index-design-system-with-builder.ts +0 -119
- package/corpus/templates/slides/server/routes/api/index-design-system-with-builder.post.ts +0 -1
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { ssrfSafeFetch } from "@agent-native/core/extensions/url-safety";
|
|
2
|
+
|
|
3
|
+
import { readResponseBytesWithLimit } from "./video-download-limits.js";
|
|
4
|
+
|
|
5
|
+
const DIRECT_VIDEO_DOWNLOAD_TIMEOUT_MS = 120_000;
|
|
6
|
+
const DIRECT_VIDEO_USER_AGENT =
|
|
7
|
+
"Mozilla/5.0 (compatible; AgentNativeClips/1.0; +https://agent-native.com)";
|
|
8
|
+
|
|
9
|
+
export type DirectVideoDownload = {
|
|
10
|
+
bytes: Uint8Array;
|
|
11
|
+
mimeType: string;
|
|
12
|
+
sizeBytes: number;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const VIDEO_EXTENSION_MIME: Record<string, string> = {
|
|
16
|
+
".mp4": "video/mp4",
|
|
17
|
+
".webm": "video/webm",
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
function guessMimeTypeFromUrl(sourceUrl: string): string | null {
|
|
21
|
+
let path: string;
|
|
22
|
+
try {
|
|
23
|
+
path = new URL(sourceUrl).pathname.toLowerCase();
|
|
24
|
+
} catch {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
for (const [ext, mime] of Object.entries(VIDEO_EXTENSION_MIME)) {
|
|
28
|
+
if (path.endsWith(ext)) return mime;
|
|
29
|
+
}
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function normalizeDirectVideoMimeType(
|
|
34
|
+
headerValue: string | null,
|
|
35
|
+
sourceUrl: string,
|
|
36
|
+
): string | null {
|
|
37
|
+
const mimeType =
|
|
38
|
+
(headerValue ?? "").split(";")[0]?.trim().toLowerCase() ?? "";
|
|
39
|
+
if (mimeType === "video/mp4" || mimeType === "video/webm") {
|
|
40
|
+
return mimeType;
|
|
41
|
+
}
|
|
42
|
+
if (!mimeType || mimeType === "application/octet-stream") {
|
|
43
|
+
return guessMimeTypeFromUrl(sourceUrl);
|
|
44
|
+
}
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Any non-Loom `https://`/`http://` URL is a candidate direct video link;
|
|
49
|
+
* the real check happens after fetching, against the response content type. */
|
|
50
|
+
export function isCandidateDirectVideoUrl(value: string): boolean {
|
|
51
|
+
try {
|
|
52
|
+
const parsed = new URL(value.trim());
|
|
53
|
+
return parsed.protocol === "https:" || parsed.protocol === "http:";
|
|
54
|
+
} catch {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function downloadDirectVideo(
|
|
60
|
+
url: string,
|
|
61
|
+
): Promise<DirectVideoDownload> {
|
|
62
|
+
const response = await ssrfSafeFetch(
|
|
63
|
+
url,
|
|
64
|
+
{
|
|
65
|
+
headers: {
|
|
66
|
+
Accept: "video/mp4,video/webm,video/*;q=0.9,*/*;q=0.1",
|
|
67
|
+
"User-Agent": DIRECT_VIDEO_USER_AGENT,
|
|
68
|
+
},
|
|
69
|
+
signal: AbortSignal.timeout(DIRECT_VIDEO_DOWNLOAD_TIMEOUT_MS),
|
|
70
|
+
},
|
|
71
|
+
{ maxRedirects: 3 },
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
if (!response.ok) {
|
|
75
|
+
throw new Error(
|
|
76
|
+
`Could not download that link (${response.status} ${response.statusText}). Make sure the URL is public and points directly to a video file.`,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const mimeType = normalizeDirectVideoMimeType(
|
|
81
|
+
response.headers.get("content-type"),
|
|
82
|
+
url,
|
|
83
|
+
);
|
|
84
|
+
if (!mimeType) {
|
|
85
|
+
throw new Error(
|
|
86
|
+
"That link doesn't point to a video file Clips can import. Paste a Loom link, or a direct link to an MP4/WebM file.",
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const bytes = await readResponseBytesWithLimit(response);
|
|
91
|
+
if (bytes.byteLength <= 0) {
|
|
92
|
+
throw new Error("That link returned an empty file.");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return { bytes, mimeType, sizeBytes: bytes.byteLength };
|
|
96
|
+
}
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { writeAppState } from "@agent-native/core/application-state";
|
|
2
2
|
import { uploadFile } from "@agent-native/core/file-upload";
|
|
3
|
-
import { and, eq } from "drizzle-orm";
|
|
3
|
+
import { and, asc, eq, gte, inArray } from "drizzle-orm";
|
|
4
4
|
|
|
5
5
|
import { getDb, schema } from "../../server/db/index.js";
|
|
6
6
|
import { queueBuilderMediaCompression } from "../../server/lib/builder-media-compression.js";
|
|
7
|
+
import { ownerEmailMatches } from "../../server/lib/recordings.js";
|
|
8
|
+
import { transactionalEmailStore } from "../../server/lib/transactional-email-store.js";
|
|
7
9
|
import {
|
|
8
10
|
extractLoomVideoId,
|
|
9
11
|
normalizeLoomShareUrl,
|
|
@@ -19,6 +21,35 @@ export type LoomImportJobResult = {
|
|
|
19
21
|
failureReason?: string;
|
|
20
22
|
};
|
|
21
23
|
|
|
24
|
+
export async function enqueueFirstImportEmailIfEligible(
|
|
25
|
+
input: { recordingId: string; ownerEmail: string; createdAt: string },
|
|
26
|
+
db: ReturnType<typeof getDb> = getDb(),
|
|
27
|
+
): Promise<void> {
|
|
28
|
+
const { enabledAt } = await transactionalEmailStore.ensureEnabledAt();
|
|
29
|
+
if (input.createdAt < enabledAt) return;
|
|
30
|
+
|
|
31
|
+
const [firstReadyImport] = await db
|
|
32
|
+
.select({ id: schema.recordings.id })
|
|
33
|
+
.from(schema.recordings)
|
|
34
|
+
.where(
|
|
35
|
+
and(
|
|
36
|
+
ownerEmailMatches(schema.recordings.ownerEmail, input.ownerEmail),
|
|
37
|
+
eq(schema.recordings.status, "ready"),
|
|
38
|
+
inArray(schema.recordings.sourceAppName, ["Loom", "Video link"]),
|
|
39
|
+
gte(schema.recordings.createdAt, enabledAt),
|
|
40
|
+
),
|
|
41
|
+
)
|
|
42
|
+
.orderBy(asc(schema.recordings.createdAt), asc(schema.recordings.id))
|
|
43
|
+
.limit(1);
|
|
44
|
+
if (firstReadyImport?.id !== input.recordingId) return;
|
|
45
|
+
|
|
46
|
+
await transactionalEmailStore.enqueueOrConvergeFirstImport(
|
|
47
|
+
input.ownerEmail,
|
|
48
|
+
input.recordingId,
|
|
49
|
+
input.ownerEmail,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
22
53
|
export async function failLoomImport(
|
|
23
54
|
recordingId: string,
|
|
24
55
|
failureReason: string,
|
|
@@ -84,9 +115,15 @@ export async function runLoomImportJob({
|
|
|
84
115
|
durationMs: schema.recordings.durationMs,
|
|
85
116
|
sourceWindowTitle: schema.recordings.sourceWindowTitle,
|
|
86
117
|
loomImportClaimId: schema.recordings.loomImportClaimId,
|
|
118
|
+
createdAt: schema.recordings.createdAt,
|
|
87
119
|
})
|
|
88
120
|
.from(schema.recordings)
|
|
89
|
-
.where(
|
|
121
|
+
.where(
|
|
122
|
+
and(
|
|
123
|
+
eq(schema.recordings.id, recordingId),
|
|
124
|
+
ownerEmailMatches(schema.recordings.ownerEmail, ownerEmail),
|
|
125
|
+
),
|
|
126
|
+
);
|
|
90
127
|
|
|
91
128
|
const shareUrl = normalizeLoomShareUrl(recording?.sourceWindowTitle ?? "");
|
|
92
129
|
const loomId = shareUrl ? extractLoomVideoId(shareUrl) : null;
|
|
@@ -144,6 +181,7 @@ export async function runLoomImportJob({
|
|
|
144
181
|
.where(
|
|
145
182
|
and(
|
|
146
183
|
eq(schema.recordings.id, recordingId),
|
|
184
|
+
ownerEmailMatches(schema.recordings.ownerEmail, ownerEmail),
|
|
147
185
|
eq(schema.recordings.loomImportClaimId, claimId),
|
|
148
186
|
),
|
|
149
187
|
)
|
|
@@ -216,6 +254,18 @@ export async function runLoomImportJob({
|
|
|
216
254
|
});
|
|
217
255
|
}
|
|
218
256
|
|
|
257
|
+
try {
|
|
258
|
+
await enqueueFirstImportEmailIfEligible(
|
|
259
|
+
{ recordingId, ownerEmail, createdAt: recording.createdAt },
|
|
260
|
+
db,
|
|
261
|
+
);
|
|
262
|
+
} catch (err) {
|
|
263
|
+
console.warn("[clips] First-import email enqueue failed", {
|
|
264
|
+
recordingId,
|
|
265
|
+
error: err instanceof Error ? err.message : String(err),
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
|
|
219
269
|
try {
|
|
220
270
|
await writeAppState(`recording-upload-${recordingId}`, {
|
|
221
271
|
recordingId,
|
|
@@ -239,6 +289,7 @@ export async function runLoomImportJob({
|
|
|
239
289
|
.where(
|
|
240
290
|
and(
|
|
241
291
|
eq(schema.recordings.id, recordingId),
|
|
292
|
+
ownerEmailMatches(schema.recordings.ownerEmail, ownerEmail),
|
|
242
293
|
eq(schema.recordings.loomImportClaimId, claimId),
|
|
243
294
|
),
|
|
244
295
|
);
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { ssrfSafeFetch } from "@agent-native/core/extensions/url-safety";
|
|
2
|
-
import { MAX_UPLOAD_BYTES } from "@shared/upload-limits.js";
|
|
3
2
|
import { z } from "zod";
|
|
4
3
|
|
|
4
|
+
import { readResponseBytesWithLimit } from "./video-download-limits.js";
|
|
5
|
+
|
|
5
6
|
const LOOM_DOWNLOAD_TIMEOUT_MS = 120_000;
|
|
6
7
|
const LOOM_VIDEO_USER_AGENT =
|
|
7
8
|
"Mozilla/5.0 (compatible; AgentNativeClips/1.0; +https://agent-native.com)";
|
|
@@ -29,29 +30,6 @@ export class LoomVideoUnavailableError extends Error {
|
|
|
29
30
|
}
|
|
30
31
|
}
|
|
31
32
|
|
|
32
|
-
function formatBytes(bytes: number): string {
|
|
33
|
-
if (bytes >= 1024 * 1024) {
|
|
34
|
-
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
35
|
-
}
|
|
36
|
-
if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
37
|
-
return `${bytes} bytes`;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
function assertUploadSize(sizeBytes: number | null | undefined): void {
|
|
41
|
-
if (!Number.isFinite(sizeBytes ?? NaN) || (sizeBytes ?? 0) <= 0) return;
|
|
42
|
-
if ((sizeBytes ?? 0) > MAX_UPLOAD_BYTES) {
|
|
43
|
-
throw new Error(
|
|
44
|
-
`Loom video is too large to import (${formatBytes(sizeBytes ?? 0)}, max ${formatBytes(MAX_UPLOAD_BYTES)}). Download a shorter or compressed copy and upload it directly.`,
|
|
45
|
-
);
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
function parseContentLength(value: string | null): number | null {
|
|
50
|
-
if (!value) return null;
|
|
51
|
-
const parsed = Number(value);
|
|
52
|
-
return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : null;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
33
|
function safeLoomDownloadUrl(value: string): string | null {
|
|
56
34
|
try {
|
|
57
35
|
const parsed = new URL(value);
|
|
@@ -65,44 +43,6 @@ function safeLoomDownloadUrl(value: string): string | null {
|
|
|
65
43
|
}
|
|
66
44
|
}
|
|
67
45
|
|
|
68
|
-
async function readResponseBytesWithLimit(
|
|
69
|
-
response: Response,
|
|
70
|
-
): Promise<Uint8Array> {
|
|
71
|
-
const contentLength = parseContentLength(
|
|
72
|
-
response.headers.get("content-length"),
|
|
73
|
-
);
|
|
74
|
-
assertUploadSize(contentLength);
|
|
75
|
-
|
|
76
|
-
if (!response.body) {
|
|
77
|
-
const arrayBuffer = await response.arrayBuffer();
|
|
78
|
-
assertUploadSize(arrayBuffer.byteLength);
|
|
79
|
-
return new Uint8Array(arrayBuffer);
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
const reader = response.body.getReader();
|
|
83
|
-
const chunks: Buffer[] = [];
|
|
84
|
-
let totalBytes = 0;
|
|
85
|
-
|
|
86
|
-
try {
|
|
87
|
-
while (true) {
|
|
88
|
-
const { done, value } = await reader.read();
|
|
89
|
-
if (done) break;
|
|
90
|
-
if (!value) continue;
|
|
91
|
-
|
|
92
|
-
totalBytes += value.byteLength;
|
|
93
|
-
assertUploadSize(totalBytes);
|
|
94
|
-
chunks.push(
|
|
95
|
-
Buffer.from(value.buffer, value.byteOffset, value.byteLength),
|
|
96
|
-
);
|
|
97
|
-
}
|
|
98
|
-
} finally {
|
|
99
|
-
reader.releaseLock();
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
const buffer = Buffer.concat(chunks, totalBytes);
|
|
103
|
-
return new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength);
|
|
104
|
-
}
|
|
105
|
-
|
|
106
46
|
function normalizeVideoMimeType(value: string | null): string {
|
|
107
47
|
const mimeType = (value ?? "").split(";")[0]?.trim().toLowerCase() ?? "";
|
|
108
48
|
if (!mimeType || mimeType === "application/octet-stream") return "video/mp4";
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { MAX_UPLOAD_BYTES } from "@shared/upload-limits.js";
|
|
2
|
+
|
|
3
|
+
export function formatBytes(bytes: number): string {
|
|
4
|
+
if (bytes >= 1024 * 1024) {
|
|
5
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
6
|
+
}
|
|
7
|
+
if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
8
|
+
return `${bytes} bytes`;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function assertUploadSize(sizeBytes: number | null | undefined): void {
|
|
12
|
+
if (!Number.isFinite(sizeBytes ?? NaN) || (sizeBytes ?? 0) <= 0) return;
|
|
13
|
+
if ((sizeBytes ?? 0) > MAX_UPLOAD_BYTES) {
|
|
14
|
+
throw new Error(
|
|
15
|
+
`Video is too large to import (${formatBytes(sizeBytes ?? 0)}, max ${formatBytes(MAX_UPLOAD_BYTES)}). Download a shorter or compressed copy and upload it directly.`,
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function parseContentLength(value: string | null): number | null {
|
|
21
|
+
if (!value) return null;
|
|
22
|
+
const parsed = Number(value);
|
|
23
|
+
return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function readResponseBytesWithLimit(
|
|
27
|
+
response: Response,
|
|
28
|
+
): Promise<Uint8Array> {
|
|
29
|
+
const contentLength = parseContentLength(
|
|
30
|
+
response.headers.get("content-length"),
|
|
31
|
+
);
|
|
32
|
+
assertUploadSize(contentLength);
|
|
33
|
+
|
|
34
|
+
if (!response.body) {
|
|
35
|
+
const arrayBuffer = await response.arrayBuffer();
|
|
36
|
+
assertUploadSize(arrayBuffer.byteLength);
|
|
37
|
+
return new Uint8Array(arrayBuffer);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const reader = response.body.getReader();
|
|
41
|
+
const chunks: Buffer[] = [];
|
|
42
|
+
let totalBytes = 0;
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
while (true) {
|
|
46
|
+
const { done, value } = await reader.read();
|
|
47
|
+
if (done) break;
|
|
48
|
+
if (!value) continue;
|
|
49
|
+
|
|
50
|
+
totalBytes += value.byteLength;
|
|
51
|
+
assertUploadSize(totalBytes);
|
|
52
|
+
chunks.push(
|
|
53
|
+
Buffer.from(value.buffer, value.byteOffset, value.byteLength),
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
} finally {
|
|
57
|
+
reader.releaseLock();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const buffer = Buffer.concat(chunks, totalBytes);
|
|
61
|
+
return new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength);
|
|
62
|
+
}
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import { defineAction } from "@agent-native/core/action";
|
|
2
|
+
import { accessFilter, resolveAccess } from "@agent-native/core/sharing";
|
|
3
|
+
import { and, eq, gte, inArray } from "drizzle-orm";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
|
|
6
|
+
import { getDb, schema } from "../server/db/index.js";
|
|
7
|
+
import {
|
|
8
|
+
getCurrentOwnerEmail,
|
|
9
|
+
ownerEmailMatches,
|
|
10
|
+
} from "../server/lib/recordings.js";
|
|
11
|
+
import {
|
|
12
|
+
AI_DISPATCH_STALE_MS,
|
|
13
|
+
transactionalEmailStore,
|
|
14
|
+
type TransactionalEmailJob,
|
|
15
|
+
} from "../server/lib/transactional-email-store.js";
|
|
16
|
+
|
|
17
|
+
const MAX_CLAIMS = 10;
|
|
18
|
+
const MAX_TITLE_LENGTH = 200;
|
|
19
|
+
const MAX_DESCRIPTION_LENGTH = 600;
|
|
20
|
+
export const MAX_TRANSCRIPT_EXCERPT_LENGTH = 1_200;
|
|
21
|
+
|
|
22
|
+
export type TransactionalEmailContextPacket = {
|
|
23
|
+
recordingId: string;
|
|
24
|
+
title: string;
|
|
25
|
+
description: string;
|
|
26
|
+
senderEmail: string;
|
|
27
|
+
transcriptExcerpt: string;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type ClaimedTransactionalEmailAiRequest = {
|
|
31
|
+
jobId: string;
|
|
32
|
+
logicalKey: string;
|
|
33
|
+
contextPackets: [
|
|
34
|
+
TransactionalEmailContextPacket,
|
|
35
|
+
TransactionalEmailContextPacket,
|
|
36
|
+
];
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
function normalizeEmail(value: string | null | undefined): string {
|
|
40
|
+
return value?.trim().toLowerCase() ?? "";
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function boundedText(value: string | null | undefined, limit: number): string {
|
|
44
|
+
return (value ?? "").replace(/\s+/g, " ").trim().slice(0, limit);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function claimantMayClaim(
|
|
48
|
+
job: TransactionalEmailJob,
|
|
49
|
+
claimantEmail: string,
|
|
50
|
+
): Promise<boolean> {
|
|
51
|
+
if (normalizeEmail(job.recipient) === claimantEmail) return true;
|
|
52
|
+
if (normalizeEmail(job.requestedBy) !== claimantEmail) return false;
|
|
53
|
+
|
|
54
|
+
const access = await Promise.all(
|
|
55
|
+
job.recordingIds.map((recordingId) =>
|
|
56
|
+
resolveAccess("recording", recordingId),
|
|
57
|
+
),
|
|
58
|
+
);
|
|
59
|
+
if (!access.every(Boolean)) return false;
|
|
60
|
+
|
|
61
|
+
const db = getDb();
|
|
62
|
+
const [directShares, countedViews] = await Promise.all([
|
|
63
|
+
db
|
|
64
|
+
.select({ recordingId: schema.recordingShares.resourceId })
|
|
65
|
+
.from(schema.recordingShares)
|
|
66
|
+
.where(
|
|
67
|
+
and(
|
|
68
|
+
eq(schema.recordingShares.principalType, "user"),
|
|
69
|
+
ownerEmailMatches(schema.recordingShares.principalId, claimantEmail),
|
|
70
|
+
inArray(schema.recordingShares.resourceId, job.recordingIds),
|
|
71
|
+
),
|
|
72
|
+
),
|
|
73
|
+
db
|
|
74
|
+
.select({ recordingId: schema.recordingViewers.recordingId })
|
|
75
|
+
.from(schema.recordingViewers)
|
|
76
|
+
.where(
|
|
77
|
+
and(
|
|
78
|
+
ownerEmailMatches(schema.recordingViewers.viewerEmail, claimantEmail),
|
|
79
|
+
eq(schema.recordingViewers.countedView, true),
|
|
80
|
+
inArray(schema.recordingViewers.recordingId, job.recordingIds),
|
|
81
|
+
),
|
|
82
|
+
),
|
|
83
|
+
]);
|
|
84
|
+
const directlyRelatedIds = new Set([
|
|
85
|
+
...access.flatMap((entry, index) =>
|
|
86
|
+
entry?.role === "owner" ? [job.recordingIds[index]] : [],
|
|
87
|
+
),
|
|
88
|
+
...directShares.map((share) => share.recordingId),
|
|
89
|
+
...countedViews.map((view) => view.recordingId),
|
|
90
|
+
]);
|
|
91
|
+
return job.recordingIds.every((recordingId) =>
|
|
92
|
+
directlyRelatedIds.has(recordingId),
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function loadContextPackets(
|
|
97
|
+
job: TransactionalEmailJob,
|
|
98
|
+
enabledAt: string,
|
|
99
|
+
): Promise<
|
|
100
|
+
[TransactionalEmailContextPacket, TransactionalEmailContextPacket] | null
|
|
101
|
+
> {
|
|
102
|
+
if (job.type !== "two-clips" || job.recordingIds.length !== 2) return null;
|
|
103
|
+
|
|
104
|
+
const db = getDb();
|
|
105
|
+
const [recordings, transcripts, shares] = await Promise.all([
|
|
106
|
+
db
|
|
107
|
+
.select({
|
|
108
|
+
id: schema.recordings.id,
|
|
109
|
+
title: schema.recordings.title,
|
|
110
|
+
description: schema.recordings.description,
|
|
111
|
+
})
|
|
112
|
+
.from(schema.recordings)
|
|
113
|
+
.where(
|
|
114
|
+
and(
|
|
115
|
+
inArray(schema.recordings.id, job.recordingIds),
|
|
116
|
+
accessFilter(schema.recordings, schema.recordingShares),
|
|
117
|
+
),
|
|
118
|
+
),
|
|
119
|
+
db
|
|
120
|
+
.select({
|
|
121
|
+
recordingId: schema.recordingTranscripts.recordingId,
|
|
122
|
+
fullText: schema.recordingTranscripts.fullText,
|
|
123
|
+
})
|
|
124
|
+
.from(schema.recordingTranscripts)
|
|
125
|
+
.where(
|
|
126
|
+
inArray(schema.recordingTranscripts.recordingId, job.recordingIds),
|
|
127
|
+
),
|
|
128
|
+
db
|
|
129
|
+
.select({
|
|
130
|
+
id: schema.recordingShares.id,
|
|
131
|
+
recordingId: schema.recordingShares.resourceId,
|
|
132
|
+
principalId: schema.recordingShares.principalId,
|
|
133
|
+
createdBy: schema.recordingShares.createdBy,
|
|
134
|
+
createdAt: schema.recordingShares.createdAt,
|
|
135
|
+
})
|
|
136
|
+
.from(schema.recordingShares)
|
|
137
|
+
.where(
|
|
138
|
+
and(
|
|
139
|
+
eq(schema.recordingShares.principalType, "user"),
|
|
140
|
+
inArray(schema.recordingShares.resourceId, job.recordingIds),
|
|
141
|
+
gte(schema.recordingShares.createdAt, enabledAt),
|
|
142
|
+
),
|
|
143
|
+
),
|
|
144
|
+
]);
|
|
145
|
+
|
|
146
|
+
const recordingById = new Map(recordings.map((row) => [row.id, row]));
|
|
147
|
+
const transcriptById = new Map(
|
|
148
|
+
transcripts.map((row) => [row.recordingId, row.fullText]),
|
|
149
|
+
);
|
|
150
|
+
const recipient = normalizeEmail(job.recipient);
|
|
151
|
+
const senderByRecordingId = new Map<string, string>();
|
|
152
|
+
for (const share of shares
|
|
153
|
+
.filter(
|
|
154
|
+
(row) =>
|
|
155
|
+
normalizeEmail(row.principalId) === recipient &&
|
|
156
|
+
row.createdAt >= enabledAt,
|
|
157
|
+
)
|
|
158
|
+
.sort(
|
|
159
|
+
(left, right) =>
|
|
160
|
+
left.createdAt.localeCompare(right.createdAt) ||
|
|
161
|
+
left.id.localeCompare(right.id),
|
|
162
|
+
)) {
|
|
163
|
+
if (!senderByRecordingId.has(share.recordingId)) {
|
|
164
|
+
senderByRecordingId.set(
|
|
165
|
+
share.recordingId,
|
|
166
|
+
normalizeEmail(share.createdBy),
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const packets = job.recordingIds.map((recordingId) => {
|
|
172
|
+
const recording = recordingById.get(recordingId);
|
|
173
|
+
const senderEmail = senderByRecordingId.get(recordingId);
|
|
174
|
+
if (!recording || !senderEmail) return null;
|
|
175
|
+
return {
|
|
176
|
+
recordingId,
|
|
177
|
+
title: boundedText(recording.title, MAX_TITLE_LENGTH),
|
|
178
|
+
description: boundedText(recording.description, MAX_DESCRIPTION_LENGTH),
|
|
179
|
+
senderEmail,
|
|
180
|
+
transcriptExcerpt: boundedText(
|
|
181
|
+
transcriptById.get(recordingId),
|
|
182
|
+
MAX_TRANSCRIPT_EXCERPT_LENGTH,
|
|
183
|
+
),
|
|
184
|
+
};
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
return packets.length === 2 && packets[0] && packets[1]
|
|
188
|
+
? [packets[0], packets[1]]
|
|
189
|
+
: null;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export async function claimTransactionalEmailAiRequests(
|
|
193
|
+
claimantEmail: string,
|
|
194
|
+
limit = MAX_CLAIMS,
|
|
195
|
+
): Promise<{ requests: ClaimedTransactionalEmailAiRequest[] }> {
|
|
196
|
+
const claimant = normalizeEmail(claimantEmail);
|
|
197
|
+
const claimLimit = Math.min(Math.max(limit, 1), MAX_CLAIMS);
|
|
198
|
+
const config = await transactionalEmailStore.readConfig();
|
|
199
|
+
if (!config) return { requests: [] };
|
|
200
|
+
const staleBefore = new Date(Date.now() - AI_DISPATCH_STALE_MS);
|
|
201
|
+
const candidates = (await transactionalEmailStore.listJobs()).filter(
|
|
202
|
+
(job) =>
|
|
203
|
+
job.type === "two-clips" &&
|
|
204
|
+
(job.state === "awaiting_ai" ||
|
|
205
|
+
(job.state === "ai_dispatched" &&
|
|
206
|
+
Date.parse(job.aiDispatchedAt ?? job.updatedAt) <=
|
|
207
|
+
staleBefore.getTime())) &&
|
|
208
|
+
job.recordingIds.length === 2,
|
|
209
|
+
);
|
|
210
|
+
const requests: ClaimedTransactionalEmailAiRequest[] = [];
|
|
211
|
+
|
|
212
|
+
for (const candidate of candidates) {
|
|
213
|
+
if (requests.length >= claimLimit) break;
|
|
214
|
+
if (!(await claimantMayClaim(candidate, claimant))) continue;
|
|
215
|
+
const contextPackets = await loadContextPackets(
|
|
216
|
+
candidate,
|
|
217
|
+
config.enabledAt,
|
|
218
|
+
);
|
|
219
|
+
if (!contextPackets) continue;
|
|
220
|
+
const claimed =
|
|
221
|
+
candidate.state === "awaiting_ai"
|
|
222
|
+
? await transactionalEmailStore.claimAwaitingAi(
|
|
223
|
+
candidate.logicalKey,
|
|
224
|
+
claimant,
|
|
225
|
+
)
|
|
226
|
+
: await transactionalEmailStore.reclaimStaleAiDispatch(
|
|
227
|
+
candidate.logicalKey,
|
|
228
|
+
claimant,
|
|
229
|
+
staleBefore,
|
|
230
|
+
);
|
|
231
|
+
if (!claimed) continue;
|
|
232
|
+
requests.push({
|
|
233
|
+
jobId: claimed.logicalKey,
|
|
234
|
+
logicalKey: claimed.logicalKey,
|
|
235
|
+
contextPackets,
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return { requests };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export default defineAction({
|
|
243
|
+
description:
|
|
244
|
+
"Claim bounded two-Clip transactional email summary work for the signed-in Clips UI.",
|
|
245
|
+
schema: z.object({}),
|
|
246
|
+
http: { method: "GET" },
|
|
247
|
+
agentTool: false,
|
|
248
|
+
run: async () => claimTransactionalEmailAiRequests(getCurrentOwnerEmail()),
|
|
249
|
+
});
|
|
@@ -7,8 +7,12 @@ import {
|
|
|
7
7
|
IconAlertTriangle,
|
|
8
8
|
IconChevronLeft,
|
|
9
9
|
IconChevronRight,
|
|
10
|
+
IconPlayerRecord,
|
|
11
|
+
IconUpload,
|
|
12
|
+
IconLink,
|
|
10
13
|
} from "@tabler/icons-react";
|
|
11
14
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
15
|
+
import { NavLink } from "react-router";
|
|
12
16
|
import { toast } from "sonner";
|
|
13
17
|
|
|
14
18
|
import { CreateFolderDialog } from "@/components/library/create-folder-dialog";
|
|
@@ -59,6 +63,64 @@ function Skeleton() {
|
|
|
59
63
|
);
|
|
60
64
|
}
|
|
61
65
|
|
|
66
|
+
function NewRecordingTile({
|
|
67
|
+
spaceId,
|
|
68
|
+
folderId,
|
|
69
|
+
}: {
|
|
70
|
+
spaceId?: string | null;
|
|
71
|
+
folderId?: string | null;
|
|
72
|
+
}) {
|
|
73
|
+
const t = useT();
|
|
74
|
+
const recordHref = useMemo(() => {
|
|
75
|
+
const params = new URLSearchParams();
|
|
76
|
+
if (spaceId) params.set("spaceId", spaceId);
|
|
77
|
+
if (folderId) params.set("folderId", folderId);
|
|
78
|
+
const qs = params.toString();
|
|
79
|
+
return qs ? `/record?${qs}` : "/record";
|
|
80
|
+
}, [spaceId, folderId]);
|
|
81
|
+
const uploadHref = useMemo(() => {
|
|
82
|
+
const params = new URLSearchParams();
|
|
83
|
+
if (spaceId) params.set("spaceId", spaceId);
|
|
84
|
+
if (folderId) params.set("folderId", folderId);
|
|
85
|
+
params.set("autoUpload", "1");
|
|
86
|
+
return `/record?${params.toString()}`;
|
|
87
|
+
}, [spaceId, folderId]);
|
|
88
|
+
const importHref = useMemo(() => {
|
|
89
|
+
const params = new URLSearchParams();
|
|
90
|
+
if (spaceId) params.set("spaceId", spaceId);
|
|
91
|
+
if (folderId) params.set("folderId", folderId);
|
|
92
|
+
const qs = params.toString();
|
|
93
|
+
return qs ? `/import?${qs}` : "/import";
|
|
94
|
+
}, [spaceId, folderId]);
|
|
95
|
+
|
|
96
|
+
return (
|
|
97
|
+
<div className="flex flex-col items-center justify-center gap-3 rounded-lg border border-dashed border-border bg-muted/20 p-6 text-center transition-colors hover:border-primary/40 hover:bg-muted/40">
|
|
98
|
+
<Button className="w-full max-w-[180px] gap-1.5" size="sm" asChild>
|
|
99
|
+
<NavLink to={recordHref}>
|
|
100
|
+
<IconPlayerRecord className="h-4 w-4" />
|
|
101
|
+
{t("navigation.newRecording")}
|
|
102
|
+
</NavLink>
|
|
103
|
+
</Button>
|
|
104
|
+
<div className="flex items-center gap-3">
|
|
105
|
+
<NavLink
|
|
106
|
+
to={uploadHref}
|
|
107
|
+
className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-foreground"
|
|
108
|
+
>
|
|
109
|
+
<IconUpload className="h-3.5 w-3.5" />
|
|
110
|
+
{t("preRecord.uploadVideo")}
|
|
111
|
+
</NavLink>
|
|
112
|
+
<NavLink
|
|
113
|
+
to={importHref}
|
|
114
|
+
className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-foreground"
|
|
115
|
+
>
|
|
116
|
+
<IconLink className="h-3.5 w-3.5" />
|
|
117
|
+
{t("preRecord.importLoom")}
|
|
118
|
+
</NavLink>
|
|
119
|
+
</div>
|
|
120
|
+
</div>
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
62
124
|
const PAGE_SIZE = 100;
|
|
63
125
|
|
|
64
126
|
interface FolderTargetRow {
|
|
@@ -491,6 +553,9 @@ export function LibraryGrid({
|
|
|
491
553
|
readOnly={!canManageRecordings}
|
|
492
554
|
/>
|
|
493
555
|
))}
|
|
556
|
+
{view === "library" && page === totalPages && (
|
|
557
|
+
<NewRecordingTile spaceId={spaceId} folderId={folderId} />
|
|
558
|
+
)}
|
|
494
559
|
</div>
|
|
495
560
|
)}
|
|
496
561
|
</div>
|