@agent-native/core 0.128.1 → 0.128.3
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 +2 -2
- package/corpus/core/CHANGELOG.md +12 -0
- package/corpus/core/package.json +2 -1
- package/corpus/core/src/server/email-template.ts +33 -0
- package/corpus/templates/chat/_gitignore +2 -0
- package/corpus/templates/clips/AGENTS.md +6 -1
- 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/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/toolkit/CHANGELOG.md +6 -0
- package/corpus/toolkit/package.json +1 -1
- package/corpus/toolkit/src/editor/DragHandle.ts +63 -9
- package/dist/collab/struct-routes.d.ts +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/realtime-token.d.ts +1 -1
- package/dist/templates/chat/_gitignore +2 -0
- package/package.json +3 -2
- package/src/server/email-template.ts +33 -0
- package/src/templates/chat/_gitignore +2 -0
- package/corpus/core/src/cli/sync-builder-starter-manifest.ts +0 -631
- package/dist/cli/sync-builder-starter-manifest.d.ts +0 -79
- package/dist/cli/sync-builder-starter-manifest.d.ts.map +0 -1
- package/dist/cli/sync-builder-starter-manifest.js +0 -426
- package/dist/cli/sync-builder-starter-manifest.js.map +0 -1
- package/src/cli/sync-builder-starter-manifest.ts +0 -631
|
@@ -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>
|
|
@@ -32,6 +32,8 @@ import {
|
|
|
32
32
|
IconShare,
|
|
33
33
|
IconSettings,
|
|
34
34
|
IconSearch,
|
|
35
|
+
IconUpload,
|
|
36
|
+
IconLink,
|
|
35
37
|
} from "@tabler/icons-react";
|
|
36
38
|
import { ReactNode, useEffect, useMemo, useState } from "react";
|
|
37
39
|
import { NavLink, useLocation, useParams } from "react-router";
|
|
@@ -384,6 +386,37 @@ export function LibraryLayout({ children }: LibraryLayoutProps) {
|
|
|
384
386
|
</Tooltip>
|
|
385
387
|
</div>
|
|
386
388
|
|
|
389
|
+
<div className="flex flex-col items-center gap-1 px-2">
|
|
390
|
+
<Tooltip>
|
|
391
|
+
<TooltipTrigger asChild>
|
|
392
|
+
<NavLink
|
|
393
|
+
to="/record?autoUpload=1"
|
|
394
|
+
aria-label={t("preRecord.uploadVideo")}
|
|
395
|
+
className="flex h-9 w-9 items-center justify-center rounded-md text-muted-foreground hover:bg-accent/60 hover:text-foreground"
|
|
396
|
+
>
|
|
397
|
+
<IconUpload className="h-4 w-4" />
|
|
398
|
+
</NavLink>
|
|
399
|
+
</TooltipTrigger>
|
|
400
|
+
<TooltipContent side="right">
|
|
401
|
+
{t("preRecord.uploadVideo")}
|
|
402
|
+
</TooltipContent>
|
|
403
|
+
</Tooltip>
|
|
404
|
+
<Tooltip>
|
|
405
|
+
<TooltipTrigger asChild>
|
|
406
|
+
<NavLink
|
|
407
|
+
to="/import"
|
|
408
|
+
aria-label={t("preRecord.importLoom")}
|
|
409
|
+
className="flex h-9 w-9 items-center justify-center rounded-md text-muted-foreground hover:bg-accent/60 hover:text-foreground"
|
|
410
|
+
>
|
|
411
|
+
<IconLink className="h-4 w-4" />
|
|
412
|
+
</NavLink>
|
|
413
|
+
</TooltipTrigger>
|
|
414
|
+
<TooltipContent side="right">
|
|
415
|
+
{t("preRecord.importLoom")}
|
|
416
|
+
</TooltipContent>
|
|
417
|
+
</Tooltip>
|
|
418
|
+
</div>
|
|
419
|
+
|
|
387
420
|
<nav className="mt-3 flex flex-col items-center gap-1 px-2">
|
|
388
421
|
{navItems.map(({ to, label, icon: Icon, match }) => {
|
|
389
422
|
const active = match(location.pathname);
|
|
@@ -417,6 +450,28 @@ export function LibraryLayout({ children }: LibraryLayoutProps) {
|
|
|
417
450
|
{t("navigation.newRecording")}
|
|
418
451
|
</NavLink>
|
|
419
452
|
</Button>
|
|
453
|
+
<Button
|
|
454
|
+
className="mt-1.5 w-full gap-1.5 text-muted-foreground"
|
|
455
|
+
size="sm"
|
|
456
|
+
variant="ghost"
|
|
457
|
+
asChild
|
|
458
|
+
>
|
|
459
|
+
<NavLink to="/record?autoUpload=1">
|
|
460
|
+
<IconUpload className="h-4 w-4" />
|
|
461
|
+
{t("preRecord.uploadVideo")}
|
|
462
|
+
</NavLink>
|
|
463
|
+
</Button>
|
|
464
|
+
<Button
|
|
465
|
+
className="mt-1.5 w-full gap-1.5 text-muted-foreground"
|
|
466
|
+
size="sm"
|
|
467
|
+
variant="ghost"
|
|
468
|
+
asChild
|
|
469
|
+
>
|
|
470
|
+
<NavLink to="/import">
|
|
471
|
+
<IconLink className="h-4 w-4" />
|
|
472
|
+
{t("preRecord.importLoom")}
|
|
473
|
+
</NavLink>
|
|
474
|
+
</Button>
|
|
420
475
|
</div>
|
|
421
476
|
|
|
422
477
|
<nav className="mt-3 space-y-0.5 px-2">
|