@agent-native/core 0.81.2 → 0.82.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/corpus/README.md +1 -1
- package/corpus/core/CHANGELOG.md +25 -0
- package/corpus/core/package.json +1 -1
- package/corpus/core/src/client/frame.ts +55 -0
- package/corpus/core/src/file-upload/builder.ts +199 -37
- package/corpus/core/src/file-upload/index.ts +2 -0
- package/corpus/core/src/file-upload/types.ts +38 -0
- package/corpus/core/src/mcp/build-server.ts +30 -4
- package/corpus/core/src/mcp/embed-app.ts +33 -18
- package/corpus/templates/clips/actions/create-recording.ts +44 -0
- package/corpus/templates/clips/actions/finalize-recording.ts +198 -88
- package/corpus/templates/clips/actions/lib/create-recording-schema.ts +12 -0
- package/corpus/templates/clips/app/components/recorder/recorder-engine.ts +83 -29
- package/corpus/templates/clips/app/routes/record.tsx +12 -1
- package/corpus/templates/clips/server/lib/resumable-session.ts +39 -0
- package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/abort.post.ts +2 -0
- package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/chunk.post.ts +186 -29
- package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/reset-chunks.post.ts +4 -0
- package/corpus/templates/clips/shared/recording-core.ts +5 -0
- package/corpus/templates/content/app/components/editor/DocumentEditor.tsx +63 -16
- package/corpus/templates/content/app/components/sidebar/DocumentSidebar.tsx +28 -33
- package/corpus/templates/content/app/components/sidebar/document-sidebar-sections.ts +47 -0
- package/corpus/templates/content/app/hooks/use-documents.ts +37 -0
- package/corpus/templates/content/changelog/2026-06-30-sidebar-favorites-now-keep-long-titles-tidy-and-stay-in-sync.md +6 -0
- package/corpus/templates/design/app/components/design/DesignCanvas.tsx +8 -1
- package/corpus/templates/design/app/components/design/LayersPanel.tsx +11 -2
- package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +89 -16
- package/corpus/templates/design/app/components/design/bridge/editor-chrome.bridge.ts +21 -14
- package/corpus/templates/design/app/pages/DesignEditor.tsx +557 -211
- package/corpus/templates/design/changelog/2026-06-30-design-studio-canvas-layers-can-be-copied-pasted-and-reparen.md +6 -0
- package/corpus/templates/design/e2e/global-setup.ts +3 -1
- package/corpus/templates/design/e2e/helpers.ts +4 -1
- package/corpus/templates/design/playwright.config.ts +4 -1
- package/corpus/templates/plan/actions/consume-plan-feedback.ts +1 -9
- package/corpus/templates/plan/actions/convert-visual-plan-to-prototype.ts +1 -1
- package/corpus/templates/plan/actions/create-plan-design.ts +1 -1
- package/corpus/templates/plan/actions/create-prototype-plan.ts +1 -1
- package/corpus/templates/plan/actions/create-ui-plan.ts +1 -1
- package/corpus/templates/plan/actions/create-visual-plan.ts +36 -19
- package/corpus/templates/plan/actions/create-visual-questions.ts +1 -1
- package/corpus/templates/plan/actions/create-visual-recap.ts +1 -1
- package/corpus/templates/plan/actions/delete-plan-comment.ts +1 -9
- package/corpus/templates/plan/actions/delete-visual-plan.ts +1 -9
- package/corpus/templates/plan/actions/get-plan-feedback.ts +1 -9
- package/corpus/templates/plan/actions/get-visual-plan.ts +1 -9
- package/corpus/templates/plan/actions/import-visual-plan-source.ts +1 -1
- package/corpus/templates/plan/actions/list-visual-plans.ts +1 -9
- package/corpus/templates/plan/actions/reply-to-plan-comment.ts +1 -9
- package/corpus/templates/plan/actions/resolve-plan-comment.ts +1 -9
- package/corpus/templates/plan/actions/update-visual-plan.ts +1 -1
- package/corpus/templates/plan/actions/visual-answer.ts +1 -1
- package/corpus/templates/plan/app/components/layout/Layout.tsx +15 -0
- package/corpus/templates/plan/app/entry.client.tsx +10 -0
- package/corpus/templates/plan/app/global.css +28 -0
- package/corpus/templates/plan/shared/plan-content.ts +10 -6
- package/dist/client/frame.d.ts.map +1 -1
- package/dist/client/frame.js +59 -0
- package/dist/client/frame.js.map +1 -1
- package/dist/collab/routes.d.ts +1 -1
- package/dist/file-upload/builder.d.ts.map +1 -1
- package/dist/file-upload/builder.js +137 -25
- package/dist/file-upload/builder.js.map +1 -1
- package/dist/file-upload/index.d.ts +1 -1
- package/dist/file-upload/index.d.ts.map +1 -1
- package/dist/file-upload/index.js.map +1 -1
- package/dist/file-upload/types.d.ts +26 -0
- package/dist/file-upload/types.d.ts.map +1 -1
- package/dist/file-upload/types.js.map +1 -1
- package/dist/mcp/build-server.d.ts.map +1 -1
- package/dist/mcp/build-server.js +31 -4
- package/dist/mcp/build-server.js.map +1 -1
- package/dist/mcp/embed-app.d.ts.map +1 -1
- package/dist/mcp/embed-app.js +33 -18
- package/dist/mcp/embed-app.js.map +1 -1
- package/dist/notifications/routes.d.ts +3 -3
- package/dist/observability/routes.d.ts +3 -3
- package/dist/progress/routes.d.ts +1 -1
- package/dist/resources/handlers.d.ts +3 -3
- package/dist/server/agent-engine-api-key-route.d.ts +1 -1
- package/dist/server/transcribe-voice.d.ts +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import {
|
|
2
|
+
deleteAppState,
|
|
3
|
+
readAppState,
|
|
4
|
+
writeAppState,
|
|
5
|
+
} from "@agent-native/core/application-state";
|
|
6
|
+
|
|
7
|
+
export interface StoredResumableSession {
|
|
8
|
+
providerId: string;
|
|
9
|
+
sessionId: string;
|
|
10
|
+
meta: Record<string, unknown>;
|
|
11
|
+
bytesUploaded: number;
|
|
12
|
+
lastCommittedIndex?: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const key = (recordingId: string) => `resumable-session-${recordingId}`;
|
|
16
|
+
|
|
17
|
+
export async function getResumableSession(
|
|
18
|
+
recordingId: string,
|
|
19
|
+
): Promise<StoredResumableSession | null> {
|
|
20
|
+
const raw = await readAppState(key(recordingId));
|
|
21
|
+
if (!raw || typeof raw !== "object") return null;
|
|
22
|
+
return raw as unknown as StoredResumableSession;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function setResumableSession(
|
|
26
|
+
recordingId: string,
|
|
27
|
+
session: StoredResumableSession,
|
|
28
|
+
): Promise<void> {
|
|
29
|
+
await writeAppState(
|
|
30
|
+
key(recordingId),
|
|
31
|
+
session as unknown as Record<string, unknown>,
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function deleteResumableSession(
|
|
36
|
+
recordingId: string,
|
|
37
|
+
): Promise<void> {
|
|
38
|
+
await deleteAppState(key(recordingId));
|
|
39
|
+
}
|
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
getEventOwnerContext,
|
|
25
25
|
ownerEmailMatches,
|
|
26
26
|
} from "../../../../lib/recordings.js";
|
|
27
|
+
import { deleteResumableSession } from "../../../../lib/resumable-session.js";
|
|
27
28
|
|
|
28
29
|
export default defineEventHandler(async (event: H3Event) => {
|
|
29
30
|
const recordingId = getRouterParam(event, "recordingId");
|
|
@@ -70,6 +71,7 @@ export default defineEventHandler(async (event: H3Event) => {
|
|
|
70
71
|
const cleared = await deleteAppStateByPrefix(
|
|
71
72
|
`recording-chunks-${recordingId}-`,
|
|
72
73
|
);
|
|
74
|
+
await deleteResumableSession(recordingId).catch(() => {});
|
|
73
75
|
|
|
74
76
|
const now = new Date().toISOString();
|
|
75
77
|
await db
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
readAppState,
|
|
18
18
|
writeAppState,
|
|
19
19
|
} from "@agent-native/core/application-state";
|
|
20
|
+
import { getActiveFileUploadProvider } from "@agent-native/core/file-upload";
|
|
20
21
|
import { runWithRequestContext } from "@agent-native/core/server";
|
|
21
22
|
import { normalizeChunkUploadNumber } from "@shared/recording-core.js";
|
|
22
23
|
import { MAX_UPLOAD_BYTES as MAX_RECORDING_UPLOAD_BYTES } from "@shared/upload-limits.js";
|
|
@@ -40,12 +41,23 @@ import {
|
|
|
40
41
|
getEventOwnerContext,
|
|
41
42
|
ownerEmailMatches,
|
|
42
43
|
} from "../../../../lib/recordings.js";
|
|
44
|
+
import {
|
|
45
|
+
getResumableSession,
|
|
46
|
+
setResumableSession,
|
|
47
|
+
type StoredResumableSession,
|
|
48
|
+
} from "../../../../lib/resumable-session.js";
|
|
43
49
|
import {
|
|
44
50
|
shouldRejectVideoUploadWithoutStorage,
|
|
45
51
|
STORAGE_SETUP_REQUIRED_REASON,
|
|
46
52
|
} from "../../../../lib/video-storage.js";
|
|
53
|
+
|
|
47
54
|
const RECORDING_TOO_LARGE_REASON = `Recording exceeds the ${Math.round(MAX_RECORDING_UPLOAD_BYTES / (1024 * 1024))} MB size limit. Please record a shorter clip.`;
|
|
48
55
|
|
|
56
|
+
// Netlify functions have a 6 MB buffered request cap, but binary requests
|
|
57
|
+
// are base64 encoded by the gateway and effectively cap out around 4.5 MB.
|
|
58
|
+
// Keep our own cap lower so dev/local failures match production.
|
|
59
|
+
const MAX_CHUNK_BYTES = 4 * 1024 * 1024;
|
|
60
|
+
|
|
49
61
|
const ALLOWED_RECORDING_MIME_TYPES = new Set([
|
|
50
62
|
"video/webm",
|
|
51
63
|
"video/mp4",
|
|
@@ -115,10 +127,6 @@ export default defineEventHandler(async (event: H3Event) => {
|
|
|
115
127
|
throw createError({ statusCode: 400, message: "Invalid chunk index" });
|
|
116
128
|
}
|
|
117
129
|
|
|
118
|
-
// Netlify functions have a 6 MB buffered request cap, but binary requests
|
|
119
|
-
// are base64 encoded by the gateway and effectively cap out around 4.5 MB.
|
|
120
|
-
// Keep our own cap lower so dev/local failures match production.
|
|
121
|
-
const MAX_CHUNK_BYTES = 4 * 1024 * 1024;
|
|
122
130
|
const contentLength = Number(getHeader(event, "content-length") || 0);
|
|
123
131
|
if (contentLength > MAX_CHUNK_BYTES) {
|
|
124
132
|
setResponseStatus(event, 413);
|
|
@@ -164,6 +172,20 @@ export default defineEventHandler(async (event: H3Event) => {
|
|
|
164
172
|
throw createError({ statusCode: 404, message: "Recording not found" });
|
|
165
173
|
}
|
|
166
174
|
|
|
175
|
+
// Resumable streaming path — forward chunks directly to the provider.
|
|
176
|
+
const resumableSession = await getResumableSession(recordingId);
|
|
177
|
+
if (resumableSession) {
|
|
178
|
+
return handleResumableChunk(
|
|
179
|
+
event,
|
|
180
|
+
resumableSession,
|
|
181
|
+
recordingId,
|
|
182
|
+
index,
|
|
183
|
+
isFinal,
|
|
184
|
+
mimeType,
|
|
185
|
+
query,
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
167
189
|
const failedUploadResponse = (reason: string, bytes?: number) => {
|
|
168
190
|
setResponseStatus(
|
|
169
191
|
event,
|
|
@@ -183,6 +205,13 @@ export default defineEventHandler(async (event: H3Event) => {
|
|
|
183
205
|
);
|
|
184
206
|
}
|
|
185
207
|
|
|
208
|
+
// Already finalized — retried final chunk after session was deleted. Skip
|
|
209
|
+
// buffered path writes so recording-upload-* state stays correct.
|
|
210
|
+
if (existing.status === "ready") {
|
|
211
|
+
return { ok: true, finalized: true };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Store chunks in application_state, assemble on finalize.
|
|
186
215
|
if (await shouldRejectVideoUploadWithoutStorage()) {
|
|
187
216
|
const now = new Date().toISOString();
|
|
188
217
|
await db
|
|
@@ -362,10 +391,7 @@ export default defineEventHandler(async (event: H3Event) => {
|
|
|
362
391
|
|
|
363
392
|
await db
|
|
364
393
|
.update(schema.recordings)
|
|
365
|
-
.set({
|
|
366
|
-
uploadProgress: progress,
|
|
367
|
-
updatedAt: new Date().toISOString(),
|
|
368
|
-
})
|
|
394
|
+
.set({ uploadProgress: progress, updatedAt: new Date().toISOString() })
|
|
369
395
|
.where(
|
|
370
396
|
and(
|
|
371
397
|
eq(schema.recordings.id, recordingId),
|
|
@@ -399,21 +425,9 @@ export default defineEventHandler(async (event: H3Event) => {
|
|
|
399
425
|
if (failedResponse) return failedResponse;
|
|
400
426
|
debugLog("[chunk] isFinal — invoking finalize", { recordingId });
|
|
401
427
|
try {
|
|
402
|
-
const result = await finalizeRecording.run(
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
width: normalizeChunkUploadNumber(query.width),
|
|
406
|
-
height: normalizeChunkUploadNumber(query.height),
|
|
407
|
-
hasAudio:
|
|
408
|
-
query.hasAudio === undefined
|
|
409
|
-
? undefined
|
|
410
|
-
: query.hasAudio === "1" || query.hasAudio === "true",
|
|
411
|
-
hasCamera:
|
|
412
|
-
query.hasCamera === undefined
|
|
413
|
-
? undefined
|
|
414
|
-
: query.hasCamera === "1" || query.hasCamera === "true",
|
|
415
|
-
mimeType,
|
|
416
|
-
});
|
|
428
|
+
const result = await finalizeRecording.run(
|
|
429
|
+
buildFinalizeArgs(recordingId, mimeType, query),
|
|
430
|
+
);
|
|
417
431
|
debugLog("[chunk] finalize ok", {
|
|
418
432
|
recordingId,
|
|
419
433
|
videoUrl: (result as any)?.videoUrl,
|
|
@@ -510,11 +524,154 @@ export default defineEventHandler(async (event: H3Event) => {
|
|
|
510
524
|
}
|
|
511
525
|
}
|
|
512
526
|
|
|
513
|
-
return {
|
|
514
|
-
ok: true,
|
|
515
|
-
finalized: false,
|
|
516
|
-
index,
|
|
517
|
-
bytes: bytes.byteLength,
|
|
518
|
-
};
|
|
527
|
+
return { ok: true, finalized: false, index, bytes: bytes.byteLength };
|
|
519
528
|
});
|
|
520
529
|
});
|
|
530
|
+
|
|
531
|
+
function buildFinalizeArgs(
|
|
532
|
+
recordingId: string,
|
|
533
|
+
mimeType: string,
|
|
534
|
+
query: Record<string, unknown>,
|
|
535
|
+
) {
|
|
536
|
+
return {
|
|
537
|
+
id: recordingId,
|
|
538
|
+
durationMs: normalizeChunkUploadNumber(query.durationMs),
|
|
539
|
+
width: normalizeChunkUploadNumber(query.width),
|
|
540
|
+
height: normalizeChunkUploadNumber(query.height),
|
|
541
|
+
hasAudio:
|
|
542
|
+
query.hasAudio === undefined
|
|
543
|
+
? undefined
|
|
544
|
+
: query.hasAudio === "1" || query.hasAudio === "true",
|
|
545
|
+
hasCamera:
|
|
546
|
+
query.hasCamera === undefined
|
|
547
|
+
? undefined
|
|
548
|
+
: query.hasCamera === "1" || query.hasCamera === "true",
|
|
549
|
+
mimeType,
|
|
550
|
+
};
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// Resumable streaming path: each chunk is forwarded directly to the upload
|
|
554
|
+
// provider. Always returns a response — never falls through to the buffered path.
|
|
555
|
+
async function handleResumableChunk(
|
|
556
|
+
event: H3Event,
|
|
557
|
+
session: StoredResumableSession,
|
|
558
|
+
recordingId: string,
|
|
559
|
+
index: number,
|
|
560
|
+
isFinal: boolean,
|
|
561
|
+
mimeType: string,
|
|
562
|
+
query: Record<string, unknown>,
|
|
563
|
+
) {
|
|
564
|
+
const uploadProvider = getActiveFileUploadProvider();
|
|
565
|
+
console.log(
|
|
566
|
+
`[resumable-chunk-${recordingId}] resumable session exists - bytesUploaded=${session.bytesUploaded} index=${index} isFinal=${isFinal}`,
|
|
567
|
+
);
|
|
568
|
+
|
|
569
|
+
const raw = await readRawBody(event, false);
|
|
570
|
+
const bytes: Uint8Array = raw ?? new Uint8Array(0);
|
|
571
|
+
|
|
572
|
+
if (!isFinal && bytes.byteLength === 0) {
|
|
573
|
+
throw createError({ statusCode: 400, message: "Empty chunk body" });
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
if (isFinal && bytes.byteLength === 0) {
|
|
577
|
+
// 0-byte sentinel from the recorder after stop(). All data chunks have
|
|
578
|
+
// already been PUT to the provider; send Content-Range: bytes */<total>
|
|
579
|
+
// to close the session before handing off to finalize-recording.
|
|
580
|
+
const closeRes = await uploadProvider!.resumable!.relayChunk(
|
|
581
|
+
{ sessionId: session.sessionId, meta: session.meta },
|
|
582
|
+
`bytes */${session.bytesUploaded}`,
|
|
583
|
+
new Uint8Array(0),
|
|
584
|
+
);
|
|
585
|
+
if (!closeRes.ok) {
|
|
586
|
+
console.error(
|
|
587
|
+
`[resumable-chunk-${recordingId}] session close failed (${closeRes.status})`,
|
|
588
|
+
);
|
|
589
|
+
setResponseStatus(event, 502);
|
|
590
|
+
return {
|
|
591
|
+
ok: false,
|
|
592
|
+
error: `Resumable session close failed (${closeRes.status})`,
|
|
593
|
+
};
|
|
594
|
+
}
|
|
595
|
+
} else {
|
|
596
|
+
// Idempotent replay guard: a client retry (after a lost response) can
|
|
597
|
+
// re-send a chunk we already committed. Re-PUTing it at the new offset
|
|
598
|
+
// would corrupt the file — detect the duplicate by index and skip the PUT.
|
|
599
|
+
// Chunks are strictly sequential, so any index <= last committed is a replay.
|
|
600
|
+
// A replayed non-final is acked here; a replayed final falls through to
|
|
601
|
+
// finalize, which is idempotent.
|
|
602
|
+
const isReplay = index <= (session.lastCommittedIndex ?? -1);
|
|
603
|
+
if (isReplay) {
|
|
604
|
+
console.warn(
|
|
605
|
+
`[resumable-chunk-${recordingId}] duplicate chunk ${index}, acking without re-upload`,
|
|
606
|
+
);
|
|
607
|
+
if (!isFinal) {
|
|
608
|
+
return {
|
|
609
|
+
ok: true,
|
|
610
|
+
finalized: false,
|
|
611
|
+
index,
|
|
612
|
+
bytes: bytes.byteLength,
|
|
613
|
+
duplicate: true,
|
|
614
|
+
};
|
|
615
|
+
}
|
|
616
|
+
} else {
|
|
617
|
+
// Forward the data chunk to the provider and advance offsets only after
|
|
618
|
+
// the provider confirms receipt (308 Resume Incomplete for non-final, 2xx for final).
|
|
619
|
+
const start = session.bytesUploaded;
|
|
620
|
+
const end = start + bytes.byteLength - 1;
|
|
621
|
+
const contentRange = isFinal
|
|
622
|
+
? `bytes ${start}-${end}/${start + bytes.byteLength}`
|
|
623
|
+
: `bytes ${start}-${end}/*`;
|
|
624
|
+
|
|
625
|
+
const putT0 = Date.now();
|
|
626
|
+
const putResult = await uploadProvider!.resumable!.relayChunk(
|
|
627
|
+
{ sessionId: session.sessionId, meta: session.meta },
|
|
628
|
+
contentRange,
|
|
629
|
+
bytes,
|
|
630
|
+
{ mimeType: mimeType.split(";")[0].trim() },
|
|
631
|
+
);
|
|
632
|
+
console.log(
|
|
633
|
+
`[resumable-chunk-${recordingId}] PUT ${Date.now() - putT0}ms status=${putResult.status} range="${contentRange}"`,
|
|
634
|
+
);
|
|
635
|
+
|
|
636
|
+
const resultOk = isFinal
|
|
637
|
+
? putResult.ok && putResult.status !== 308
|
|
638
|
+
: putResult.ok;
|
|
639
|
+
if (!resultOk) {
|
|
640
|
+
setResponseStatus(event, 502);
|
|
641
|
+
return {
|
|
642
|
+
ok: false,
|
|
643
|
+
error: `Chunk upload failed (${putResult.status})`,
|
|
644
|
+
};
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
await setResumableSession(recordingId, {
|
|
648
|
+
...session,
|
|
649
|
+
...(putResult.updatedMeta
|
|
650
|
+
? { meta: { ...session.meta, ...putResult.updatedMeta } }
|
|
651
|
+
: {}),
|
|
652
|
+
bytesUploaded: start + bytes.byteLength,
|
|
653
|
+
lastCommittedIndex: index,
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
if (!isFinal) {
|
|
657
|
+
return { ok: true, finalized: false, index, bytes: bytes.byteLength };
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
// isFinal — delegate to finalize-recording, which reads the resumable
|
|
663
|
+
// session and calls provider.resumable.completeSession.
|
|
664
|
+
try {
|
|
665
|
+
const result = await finalizeRecording.run(
|
|
666
|
+
buildFinalizeArgs(recordingId, mimeType, query),
|
|
667
|
+
);
|
|
668
|
+
return { ok: true, finalized: true, ...result };
|
|
669
|
+
} catch (err) {
|
|
670
|
+
console.error(`[resumable-chunk-${recordingId}] finalize failed:`, err);
|
|
671
|
+
setResponseStatus(event, 500);
|
|
672
|
+
return {
|
|
673
|
+
ok: false,
|
|
674
|
+
error: err instanceof Error ? err.message : "Finalize failed",
|
|
675
|
+
};
|
|
676
|
+
}
|
|
677
|
+
}
|
|
@@ -44,6 +44,7 @@ import {
|
|
|
44
44
|
getEventOwnerContext,
|
|
45
45
|
ownerEmailMatches,
|
|
46
46
|
} from "../../../../lib/recordings.js";
|
|
47
|
+
import { deleteResumableSession } from "../../../../lib/resumable-session.js";
|
|
47
48
|
|
|
48
49
|
interface CompressionMeta {
|
|
49
50
|
originalBytes?: number;
|
|
@@ -112,6 +113,9 @@ export default defineEventHandler(async (event: H3Event) => {
|
|
|
112
113
|
const cleared = await deleteAppStateByPrefix(
|
|
113
114
|
`recording-chunks-${recordingId}-`,
|
|
114
115
|
);
|
|
116
|
+
// Clear any stale resumable session so a buffered retry does not
|
|
117
|
+
// accidentally route through handleResumableChunk with stale offsets.
|
|
118
|
+
await deleteResumableSession(recordingId).catch(() => {});
|
|
115
119
|
|
|
116
120
|
// Reset the per-recording upload progress so the UI poller sees the
|
|
117
121
|
// re-upload restart from 0 and doesn't briefly show "100% then
|
|
@@ -46,6 +46,11 @@ export function pickMimeType(): string {
|
|
|
46
46
|
return "";
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
/** How the client delivers recorded data to the server.
|
|
50
|
+
* - `"streaming"` — chunks are flushed to GCS during recording via a resumable session
|
|
51
|
+
* - `"buffered"` — full blob assembled after stop() and uploaded in slices */
|
|
52
|
+
export type UploadMode = "streaming" | "buffered";
|
|
53
|
+
|
|
49
54
|
/** Query params understood by the chunk-upload route
|
|
50
55
|
* (`/api/uploads/:id/chunk`). This is the on-the-wire contract — the route in
|
|
51
56
|
* `server/routes/api/uploads/[recordingId]/chunk.post.ts` reads exactly these. */
|
|
@@ -22,7 +22,7 @@ import {
|
|
|
22
22
|
useRef,
|
|
23
23
|
useState,
|
|
24
24
|
} from "react";
|
|
25
|
-
import type { ClipboardEvent } from "react";
|
|
25
|
+
import type { ClipboardEvent, MutableRefObject } from "react";
|
|
26
26
|
import { useNavigate } from "react-router";
|
|
27
27
|
import { toast } from "sonner";
|
|
28
28
|
|
|
@@ -69,6 +69,50 @@ interface DocumentEditorProps {
|
|
|
69
69
|
documentId: string;
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
+
type FieldSaveWatermark = { title: string; updatedAt: string | null };
|
|
73
|
+
type ContentSaveWatermark = { content: string; updatedAt: string | null };
|
|
74
|
+
|
|
75
|
+
function adoptConfirmedSaveWatermarks({
|
|
76
|
+
saved,
|
|
77
|
+
savedAt,
|
|
78
|
+
title,
|
|
79
|
+
content,
|
|
80
|
+
updates,
|
|
81
|
+
lastSavedTitleRef,
|
|
82
|
+
lastSavedContentRef,
|
|
83
|
+
}: {
|
|
84
|
+
saved: Document | undefined;
|
|
85
|
+
savedAt: string;
|
|
86
|
+
title: string;
|
|
87
|
+
content: string;
|
|
88
|
+
updates: Record<string, string>;
|
|
89
|
+
lastSavedTitleRef: MutableRefObject<FieldSaveWatermark>;
|
|
90
|
+
lastSavedContentRef: MutableRefObject<ContentSaveWatermark>;
|
|
91
|
+
}) {
|
|
92
|
+
if (updates.title !== undefined) {
|
|
93
|
+
lastSavedTitleRef.current = { title, updatedAt: savedAt };
|
|
94
|
+
} else if (
|
|
95
|
+
updates.content !== undefined &&
|
|
96
|
+
saved?.title === lastSavedTitleRef.current.title
|
|
97
|
+
) {
|
|
98
|
+
lastSavedTitleRef.current = {
|
|
99
|
+
...lastSavedTitleRef.current,
|
|
100
|
+
updatedAt: savedAt,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
if (updates.content !== undefined) {
|
|
104
|
+
lastSavedContentRef.current = { content, updatedAt: savedAt };
|
|
105
|
+
} else if (
|
|
106
|
+
updates.title !== undefined &&
|
|
107
|
+
saved?.content === lastSavedContentRef.current.content
|
|
108
|
+
) {
|
|
109
|
+
lastSavedContentRef.current = {
|
|
110
|
+
...lastSavedContentRef.current,
|
|
111
|
+
updatedAt: savedAt,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
72
116
|
function DocumentEditorSkeleton() {
|
|
73
117
|
return (
|
|
74
118
|
<div className="flex min-h-0 flex-1 flex-col bg-background">
|
|
@@ -686,12 +730,15 @@ function DocumentEditorBody({ documentId, document }: DocumentEditorBodyProps) {
|
|
|
686
730
|
const saved = await persistDocumentUpdates(updates, options);
|
|
687
731
|
// Adopt the server updatedAt per saved field.
|
|
688
732
|
const savedAt = saved?.updatedAt ?? new Date().toISOString();
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
733
|
+
adoptConfirmedSaveWatermarks({
|
|
734
|
+
saved,
|
|
735
|
+
savedAt,
|
|
736
|
+
title,
|
|
737
|
+
content,
|
|
738
|
+
updates,
|
|
739
|
+
lastSavedTitleRef,
|
|
740
|
+
lastSavedContentRef,
|
|
741
|
+
});
|
|
695
742
|
|
|
696
743
|
// Push-on-save: when auto-sync is on, trigger a Notion push
|
|
697
744
|
// immediately after the save lands in SQL. This eliminates the
|
|
@@ -818,15 +865,15 @@ function DocumentEditorBody({ documentId, document }: DocumentEditorBodyProps) {
|
|
|
818
865
|
if (Object.keys(updates).length > 0) {
|
|
819
866
|
const saved = await persistDocumentUpdates(updates);
|
|
820
867
|
const savedAt = saved?.updatedAt ?? new Date().toISOString();
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
}
|
|
868
|
+
adoptConfirmedSaveWatermarks({
|
|
869
|
+
saved,
|
|
870
|
+
savedAt,
|
|
871
|
+
title,
|
|
872
|
+
content,
|
|
873
|
+
updates,
|
|
874
|
+
lastSavedTitleRef,
|
|
875
|
+
lastSavedContentRef,
|
|
876
|
+
});
|
|
830
877
|
}
|
|
831
878
|
} finally {
|
|
832
879
|
// Acknowledge the flush even if nothing changed — the SQL row is
|
|
@@ -59,6 +59,10 @@ import {
|
|
|
59
59
|
} from "@/hooks/use-documents";
|
|
60
60
|
import { cn } from "@/lib/utils";
|
|
61
61
|
|
|
62
|
+
import {
|
|
63
|
+
getDocumentSidebarSections,
|
|
64
|
+
isDirectLocalDocument,
|
|
65
|
+
} from "./document-sidebar-sections";
|
|
62
66
|
import { DocumentSidebarIcon, DocumentTreeItem } from "./DocumentTreeItem";
|
|
63
67
|
import { NotionButton } from "./NotionButton";
|
|
64
68
|
|
|
@@ -114,22 +118,6 @@ function collectDocumentSubtreeIds(documents: Document[], rootId: string) {
|
|
|
114
118
|
return deletedIds;
|
|
115
119
|
}
|
|
116
120
|
|
|
117
|
-
function isDirectLocalDocument(document: Pick<Document, "id" | "source">) {
|
|
118
|
-
return (
|
|
119
|
-
document.source?.mode === "local-files" &&
|
|
120
|
-
(document.id.startsWith("local-file:") ||
|
|
121
|
-
document.id.startsWith("local-folder:"))
|
|
122
|
-
);
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
function isImportedLocalSourceDocument(
|
|
126
|
-
document: Pick<Document, "id" | "source">,
|
|
127
|
-
) {
|
|
128
|
-
return (
|
|
129
|
-
document.source?.mode === "local-files" && !isDirectLocalDocument(document)
|
|
130
|
-
);
|
|
131
|
-
}
|
|
132
|
-
|
|
133
121
|
type SidebarSectionId =
|
|
134
122
|
| "local-files"
|
|
135
123
|
| "shared-copies"
|
|
@@ -210,24 +198,23 @@ export function DocumentSidebar({
|
|
|
210
198
|
);
|
|
211
199
|
|
|
212
200
|
const treeDocuments = filterDocumentTreeDocuments(documents);
|
|
213
|
-
const
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
(document) => !isImportedLocalSourceDocument(document),
|
|
221
|
-
);
|
|
201
|
+
const {
|
|
202
|
+
localFileMode,
|
|
203
|
+
localSourceDocuments,
|
|
204
|
+
databaseDocuments,
|
|
205
|
+
favorites,
|
|
206
|
+
showFavorites,
|
|
207
|
+
} = getDocumentSidebarSections(documents, treeDocuments);
|
|
222
208
|
const localFileTree = buildDocumentTree(localSourceDocuments);
|
|
223
209
|
const databaseTree = buildDocumentTree(databaseDocuments);
|
|
224
210
|
const privateTree = databaseTree.filter((node) => node.visibility !== "org");
|
|
225
211
|
const organizationTree = databaseTree.filter(
|
|
226
212
|
(node) => node.visibility === "org",
|
|
227
213
|
);
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
214
|
+
// Match the tree rows' right-side inset so favorite titles clip inside the
|
|
215
|
+
// visible sidebar instead of widening the scroll surface.
|
|
216
|
+
const favoriteRowWidth =
|
|
217
|
+
width === undefined ? undefined : Math.max(224, width - 8);
|
|
231
218
|
const activeDocument = activeDocumentId
|
|
232
219
|
? documents.find((doc) => doc.id === activeDocumentId)
|
|
233
220
|
: null;
|
|
@@ -878,21 +865,29 @@ export function DocumentSidebar({
|
|
|
878
865
|
) : (
|
|
879
866
|
<>
|
|
880
867
|
{/* Favorites */}
|
|
881
|
-
{
|
|
882
|
-
<div className="mb-2">
|
|
883
|
-
<div className="px-3 py-1.5 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground
|
|
868
|
+
{showFavorites && (
|
|
869
|
+
<div className="mb-2 min-w-0">
|
|
870
|
+
<div className="flex min-w-0 items-center gap-1 px-3 py-1.5 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
|
884
871
|
<IconStar size={10} />
|
|
885
|
-
|
|
872
|
+
<span className="min-w-0 flex-1 truncate">
|
|
873
|
+
{t("sidebar.favorites")}
|
|
874
|
+
</span>
|
|
886
875
|
</div>
|
|
887
876
|
{favorites.map((doc) => (
|
|
888
877
|
<button
|
|
889
878
|
key={doc.id}
|
|
890
879
|
className={cn(
|
|
891
|
-
"w-full
|
|
880
|
+
"flex w-full min-w-0 items-center gap-2 rounded-md px-4 py-[5px] text-start text-sm",
|
|
892
881
|
doc.id === activeDocumentId
|
|
893
882
|
? "bg-accent text-accent-foreground"
|
|
894
883
|
: "text-muted-foreground hover:bg-accent/50 hover:text-foreground",
|
|
895
884
|
)}
|
|
885
|
+
style={{
|
|
886
|
+
width:
|
|
887
|
+
favoriteRowWidth === undefined
|
|
888
|
+
? undefined
|
|
889
|
+
: `${favoriteRowWidth}px`,
|
|
890
|
+
}}
|
|
896
891
|
onClick={() => {
|
|
897
892
|
navigateToDocument(doc.id);
|
|
898
893
|
onNavigate?.();
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { Document } from "@shared/api";
|
|
2
|
+
|
|
3
|
+
export function isDirectLocalDocument(
|
|
4
|
+
document: Pick<Document, "id" | "source">,
|
|
5
|
+
) {
|
|
6
|
+
return (
|
|
7
|
+
document.source?.mode === "local-files" &&
|
|
8
|
+
(document.id.startsWith("local-file:") ||
|
|
9
|
+
document.id.startsWith("local-folder:"))
|
|
10
|
+
);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function isImportedLocalSourceDocument(
|
|
14
|
+
document: Pick<Document, "id" | "source">,
|
|
15
|
+
) {
|
|
16
|
+
return (
|
|
17
|
+
document.source?.mode === "local-files" && !isDirectLocalDocument(document)
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function getDocumentSidebarSections(
|
|
22
|
+
documents: Document[],
|
|
23
|
+
treeDocuments: Document[] = documents,
|
|
24
|
+
) {
|
|
25
|
+
const localFileMode = documents.some(isDirectLocalDocument);
|
|
26
|
+
const localSourceDocuments = localFileMode
|
|
27
|
+
? treeDocuments.filter(isDirectLocalDocument)
|
|
28
|
+
: treeDocuments.filter(isImportedLocalSourceDocument);
|
|
29
|
+
const databaseDocuments = localFileMode
|
|
30
|
+
? treeDocuments.filter((document) => !isDirectLocalDocument(document))
|
|
31
|
+
: treeDocuments.filter(
|
|
32
|
+
(document) => !isImportedLocalSourceDocument(document),
|
|
33
|
+
);
|
|
34
|
+
const favorites = documents.filter(
|
|
35
|
+
(document) =>
|
|
36
|
+
document.isFavorite &&
|
|
37
|
+
(localFileMode || !isImportedLocalSourceDocument(document)),
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
localFileMode,
|
|
42
|
+
localSourceDocuments,
|
|
43
|
+
databaseDocuments,
|
|
44
|
+
favorites,
|
|
45
|
+
showFavorites: favorites.length > 0,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
@@ -12,6 +12,36 @@ import { toast } from "sonner";
|
|
|
12
12
|
|
|
13
13
|
import { useRestoreContentDatabase } from "./use-content-database";
|
|
14
14
|
|
|
15
|
+
const LIST_DOCUMENTS_QUERY_KEY = ["action", "list-documents", undefined];
|
|
16
|
+
|
|
17
|
+
export function mergeDocumentIntoDocumentCache(
|
|
18
|
+
old: unknown,
|
|
19
|
+
document: Document,
|
|
20
|
+
) {
|
|
21
|
+
return old && typeof old === "object" ? { ...old, ...document } : document;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function mergeDocumentIntoListDocumentsCache(
|
|
25
|
+
old: unknown,
|
|
26
|
+
document: Document,
|
|
27
|
+
) {
|
|
28
|
+
if (Array.isArray(old)) {
|
|
29
|
+
return old.map((item: Document) =>
|
|
30
|
+
item.id === document.id ? { ...item, ...document } : item,
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (!old || typeof old !== "object") return old;
|
|
35
|
+
const cached = old as { documents?: unknown };
|
|
36
|
+
if (!Array.isArray(cached.documents)) return old;
|
|
37
|
+
|
|
38
|
+
const nextDocuments = cached.documents.map((item: Document) =>
|
|
39
|
+
item.id === document.id ? { ...item, ...document } : item,
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
return { ...(old as object), documents: nextDocuments };
|
|
43
|
+
}
|
|
44
|
+
|
|
15
45
|
export function useDocuments() {
|
|
16
46
|
return useActionQuery<Document[]>("list-documents", undefined, {
|
|
17
47
|
select: (data: any) => {
|
|
@@ -42,6 +72,13 @@ export function useUpdateDocument() {
|
|
|
42
72
|
DocumentUpdateRequest & { id: string }
|
|
43
73
|
>("update-document", {
|
|
44
74
|
onSuccess: (data, variables) => {
|
|
75
|
+
queryClient.setQueryData(
|
|
76
|
+
["action", "get-document", { id: variables.id }],
|
|
77
|
+
(old: unknown) => mergeDocumentIntoDocumentCache(old, data),
|
|
78
|
+
);
|
|
79
|
+
queryClient.setQueryData(LIST_DOCUMENTS_QUERY_KEY, (old: unknown) =>
|
|
80
|
+
mergeDocumentIntoListDocumentsCache(old, data),
|
|
81
|
+
);
|
|
45
82
|
queryClient.invalidateQueries({
|
|
46
83
|
queryKey: ["action", "get-document", { id: variables.id }],
|
|
47
84
|
});
|