@agent-native/core 0.81.3 → 0.83.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 +12 -0
- package/corpus/core/package.json +1 -1
- package/corpus/core/src/client/CommandMenu.tsx +34 -12
- 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/templates/analytics/actions/hubspot-deals.ts +64 -4
- 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/.env.local.example +6 -0
- package/corpus/templates/content/AGENTS.md +10 -1
- package/corpus/templates/content/actions/_builder-cms-read-client.ts +14 -1
- package/corpus/templates/content/actions/_builder-cms-source-adapter.ts +43 -0
- package/corpus/templates/content/actions/_builder-cms-write-adapter.ts +79 -7
- package/corpus/templates/content/actions/_database-source-utils.ts +987 -22
- package/corpus/templates/content/actions/_database-utils.ts +41 -0
- package/corpus/templates/content/actions/_local-file-documents.ts +74 -1
- package/corpus/templates/content/actions/attach-content-database-source.ts +33 -8
- package/corpus/templates/content/actions/change-content-database-source-role.ts +14 -0
- package/corpus/templates/content/actions/disconnect-content-database-source.ts +3 -0
- package/corpus/templates/content/actions/execute-builder-source-execution.ts +71 -3
- package/corpus/templates/content/actions/list-content-databases.ts +41 -33
- package/corpus/templates/content/actions/process-builder-body-hydration.ts +47 -0
- package/corpus/templates/content/actions/update-document.ts +93 -0
- package/corpus/templates/content/app/blocks/SourceComponentBlock.tsx +277 -0
- package/corpus/templates/content/app/blocks/contentBlockRegistry.tsx +2 -0
- package/corpus/templates/content/app/components/editor/DocumentEditor.tsx +105 -17
- package/corpus/templates/content/app/components/editor/VisualEditor.tsx +5 -1
- package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +92 -0
- package/corpus/templates/content/app/components/editor/database-sources/BuilderSourceReviewDialog.tsx +5 -3
- package/corpus/templates/content/app/components/editor/extensions/registryBlocks.ts +41 -0
- package/corpus/templates/content/app/components/editor/registrySlashItems.ts +4 -0
- 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/global.css +10 -3
- package/corpus/templates/content/app/hooks/use-content-database.ts +24 -0
- package/corpus/templates/content/app/hooks/use-documents.ts +37 -0
- package/corpus/templates/content/app/i18n/zh-TW.ts +24 -0
- package/corpus/templates/content/app/i18n-data.ts +212 -0
- package/corpus/templates/content/app/lib/content-command-search.ts +64 -0
- package/corpus/templates/content/app/root.tsx +236 -8
- package/corpus/templates/content/changelog/2026-06-29-builder-cms-sources-now-sync-article-bodies-through-content.md +6 -0
- package/corpus/templates/content/changelog/2026-06-30-builder-article-media-now-render-as-images-and-embeds-when-r.md +6 -0
- package/corpus/templates/content/changelog/2026-06-30-builder-source-components-now-render-as-preserved-preview-bl.md +6 -0
- package/corpus/templates/content/changelog/2026-06-30-cmd-k-search-now-opens-from-the-editor-and-finds-real-content.md +6 -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/content/package.json +3 -1
- package/corpus/templates/content/scripts/check-native-deps.mjs +57 -0
- package/corpus/templates/content/scripts/dev-database.mjs +83 -0
- package/corpus/templates/content/scripts/seed-demo-user.mjs +107 -0
- package/corpus/templates/content/server/db/schema.ts +27 -0
- package/corpus/templates/content/server/plugins/db.ts +33 -0
- package/corpus/templates/content/shared/api.ts +45 -0
- package/corpus/templates/content/shared/builder-mdx.ts +905 -5
- package/corpus/templates/content/shared/nfm-registry.ts +2 -0
- package/corpus/templates/content/shared/source-component-block.ts +141 -0
- package/corpus/templates/design/app/components/design/DesignCanvas.tsx +5 -1
- package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +229 -131
- package/corpus/templates/design/app/components/design/bridge/editor-chrome.bridge.ts +103 -6
- package/corpus/templates/design/app/hooks/useDesignHotkeys.ts +6 -3
- package/corpus/templates/design/app/pages/DesignEditor.tsx +382 -72
- package/corpus/templates/design/changelog/2026-06-30-canvas-editing-now-keeps-undo-redo-and-cross-screen-layer-mo.md +6 -0
- package/dist/client/CommandMenu.d.ts +6 -2
- package/dist/client/CommandMenu.d.ts.map +1 -1
- package/dist/client/CommandMenu.js +25 -14
- package/dist/client/CommandMenu.js.map +1 -1
- package/dist/collab/awareness.d.ts +2 -2
- package/dist/collab/awareness.d.ts.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/notifications/routes.d.ts +3 -3
- package/dist/progress/routes.d.ts +1 -1
- package/dist/resources/handlers.d.ts +3 -3
- package/dist/server/transcribe-voice.d.ts +1 -1
- package/package.json +1 -1
package/corpus/README.md
CHANGED
package/corpus/core/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# @agent-native/core
|
|
2
2
|
|
|
3
|
+
## 0.83.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 1a8d939: Add dynamic command menu results and an opt-in contenteditable Cmd+K shortcut path for editor-backed apps.
|
|
8
|
+
|
|
9
|
+
## 0.82.0
|
|
10
|
+
|
|
11
|
+
### Minor Changes
|
|
12
|
+
|
|
13
|
+
- fe9fd99: Add optional `resumable` capability to `FileUploadProvider` for streaming uploads. Providers that implement `startSession`, `relayChunk`, and `completeSession` can receive video chunks during recording instead of waiting for a fully assembled file after stop. The Builder.io provider implements this via the GCS resumable upload protocol. Also exports `ResumableUploadSession` and `ResumableChunkResult` types.
|
|
14
|
+
|
|
3
15
|
## 0.81.3
|
|
4
16
|
|
|
5
17
|
### Patch Changes
|
package/corpus/core/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agent-native/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.83.0",
|
|
4
4
|
"description": "Framework for agent-native application development — where AI agents and UI share SQL state, actions, and context",
|
|
5
5
|
"homepage": "https://github.com/BuilderIO/agent-native#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -261,6 +261,8 @@ export interface CommandMenuProps {
|
|
|
261
261
|
open: boolean;
|
|
262
262
|
onOpenChange: (open: boolean) => void;
|
|
263
263
|
children: ReactNode;
|
|
264
|
+
/** Render app-specific dynamic results from the current search value. */
|
|
265
|
+
renderResults?: (search: string) => ReactNode;
|
|
264
266
|
/** Placeholder text for the search input */
|
|
265
267
|
placeholder?: string;
|
|
266
268
|
/** Text shown when no results match (before showing agent fallback) */
|
|
@@ -291,6 +293,7 @@ export function CommandMenu({
|
|
|
291
293
|
open,
|
|
292
294
|
onOpenChange,
|
|
293
295
|
children,
|
|
296
|
+
renderResults,
|
|
294
297
|
placeholder = "Type a command or ask AI...",
|
|
295
298
|
emptyText: _emptyText = "No commands found.",
|
|
296
299
|
showAgentFallback = true,
|
|
@@ -325,8 +328,15 @@ export function CommandMenu({
|
|
|
325
328
|
setTimeout(() => setChangelogOpen(true), 50);
|
|
326
329
|
}, [onOpenChange, markChangelogSeen]);
|
|
327
330
|
|
|
328
|
-
// Focus input when opening
|
|
331
|
+
// Focus input when opening; clear search while closed so reopen never renders
|
|
332
|
+
// dynamic results for the previous query.
|
|
329
333
|
useEffect(() => {
|
|
334
|
+
if (!open) {
|
|
335
|
+
setSearch("");
|
|
336
|
+
setSelectedIndex(0);
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
|
|
330
340
|
if (open) {
|
|
331
341
|
setSearch("");
|
|
332
342
|
setSelectedIndex(0);
|
|
@@ -497,6 +507,8 @@ export function CommandMenu({
|
|
|
497
507
|
React.isValidElement(child) &&
|
|
498
508
|
(child.type === CommandGroup || child.type === CommandDocsGroup),
|
|
499
509
|
);
|
|
510
|
+
const dynamicResults = open ? renderResults?.(search) : null;
|
|
511
|
+
const hasDynamicResults = Boolean(dynamicResults);
|
|
500
512
|
|
|
501
513
|
return (
|
|
502
514
|
<>
|
|
@@ -528,6 +540,7 @@ export function CommandMenu({
|
|
|
528
540
|
|
|
529
541
|
{/* Command list */}
|
|
530
542
|
<div className="max-h-[300px] overflow-y-auto overflow-x-hidden">
|
|
543
|
+
{dynamicResults}
|
|
531
544
|
{hasResults && filteredChildren}
|
|
532
545
|
|
|
533
546
|
{/* What's new — built-in changelog entry */}
|
|
@@ -567,7 +580,9 @@ export function CommandMenu({
|
|
|
567
580
|
{/* Ask AI — always visible at the bottom */}
|
|
568
581
|
{showAgentFallback && (
|
|
569
582
|
<>
|
|
570
|
-
{(hasResults || showChangelogRow) &&
|
|
583
|
+
{(hasResults || showChangelogRow || hasDynamicResults) && (
|
|
584
|
+
<CommandSeparator />
|
|
585
|
+
)}
|
|
571
586
|
<div className="p-1">
|
|
572
587
|
<div
|
|
573
588
|
className="relative flex cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-2 text-sm outline-none"
|
|
@@ -658,16 +673,21 @@ CommandMenu.Separator = CommandSeparator;
|
|
|
658
673
|
/**
|
|
659
674
|
* Hook to handle Cmd+K (or Ctrl+K) to open the command menu
|
|
660
675
|
*/
|
|
661
|
-
export function useCommandMenuShortcut(
|
|
676
|
+
export function useCommandMenuShortcut(
|
|
677
|
+
onOpen: () => void,
|
|
678
|
+
options: { allowContentEditable?: boolean } = {},
|
|
679
|
+
) {
|
|
662
680
|
useEffect(() => {
|
|
663
681
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
664
|
-
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
|
|
665
|
-
// Don't trigger if user is typing in
|
|
666
|
-
const target = e.target
|
|
682
|
+
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
|
|
683
|
+
// Don't trigger if user is typing in a native form control.
|
|
684
|
+
const target = e.target instanceof HTMLElement ? e.target : null;
|
|
685
|
+
const isContentEditable = target?.isContentEditable;
|
|
667
686
|
if (
|
|
668
|
-
target
|
|
669
|
-
target
|
|
670
|
-
target
|
|
687
|
+
target?.tagName === "INPUT" ||
|
|
688
|
+
target?.tagName === "TEXTAREA" ||
|
|
689
|
+
target?.tagName === "SELECT" ||
|
|
690
|
+
(!options.allowContentEditable && isContentEditable)
|
|
671
691
|
) {
|
|
672
692
|
return;
|
|
673
693
|
}
|
|
@@ -675,9 +695,11 @@ export function useCommandMenuShortcut(onOpen: () => void) {
|
|
|
675
695
|
onOpen();
|
|
676
696
|
}
|
|
677
697
|
};
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
698
|
+
const useCapture = Boolean(options.allowContentEditable);
|
|
699
|
+
document.addEventListener("keydown", handleKeyDown, useCapture);
|
|
700
|
+
return () =>
|
|
701
|
+
document.removeEventListener("keydown", handleKeyDown, useCapture);
|
|
702
|
+
}, [onOpen, options.allowContentEditable]);
|
|
681
703
|
}
|
|
682
704
|
|
|
683
705
|
export type {
|
|
@@ -2,6 +2,8 @@ import type {
|
|
|
2
2
|
FileUploadProvider,
|
|
3
3
|
FileUploadInput,
|
|
4
4
|
FileUploadResult,
|
|
5
|
+
ResumableUploadSession,
|
|
6
|
+
ResumableChunkResult,
|
|
5
7
|
} from "./types.js";
|
|
6
8
|
|
|
7
9
|
const DEFAULT_BUILDER_APP_HOST = "https://builder.io";
|
|
@@ -46,8 +48,6 @@ async function uploadLargeFileViaSignedUrl(
|
|
|
46
48
|
bareMimeType: string,
|
|
47
49
|
bytes: Uint8Array,
|
|
48
50
|
): Promise<FileUploadResult> {
|
|
49
|
-
const host = builderUploadHost();
|
|
50
|
-
const authHeader = { Authorization: `Bearer ${privateKey}` };
|
|
51
51
|
const name = input.filename ?? "upload";
|
|
52
52
|
const mb = (bytes.byteLength / (1024 * 1024)).toFixed(1);
|
|
53
53
|
|
|
@@ -57,32 +57,12 @@ async function uploadLargeFileViaSignedUrl(
|
|
|
57
57
|
|
|
58
58
|
// Step 1 — request a signed URL.
|
|
59
59
|
console.log(`[builder-upload] step 1: requesting signed URL`);
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
body: JSON.stringify({
|
|
66
|
-
fileName: name,
|
|
67
|
-
contentType: bareMimeType,
|
|
68
|
-
size: bytes.byteLength,
|
|
69
|
-
}),
|
|
70
|
-
},
|
|
60
|
+
const { uploadUrl, assetId, requiredHeaders } = await requestBuilderSignedUrl(
|
|
61
|
+
privateKey,
|
|
62
|
+
name,
|
|
63
|
+
bareMimeType,
|
|
64
|
+
bytes.byteLength,
|
|
71
65
|
);
|
|
72
|
-
await assertOk(step1Res, "Builder.io signed-URL request failed");
|
|
73
|
-
|
|
74
|
-
const step1Json = (await step1Res.json()) as {
|
|
75
|
-
uploadUrl?: string;
|
|
76
|
-
assetId?: string;
|
|
77
|
-
expiresAt?: string;
|
|
78
|
-
requiredHeaders?: Record<string, string>;
|
|
79
|
-
};
|
|
80
|
-
const { uploadUrl, assetId, requiredHeaders } = step1Json;
|
|
81
|
-
if (!uploadUrl || !assetId || !requiredHeaders) {
|
|
82
|
-
throw new Error(
|
|
83
|
-
`Builder.io signed-URL response missing required fields: ${JSON.stringify(Object.keys(step1Json))}`,
|
|
84
|
-
);
|
|
85
|
-
}
|
|
86
66
|
console.log(`[builder-upload] step 1 ok: assetId=${assetId}`);
|
|
87
67
|
|
|
88
68
|
// Step 2 — PUT bytes directly to GCS. Only requiredHeaders; no Authorization
|
|
@@ -102,21 +82,80 @@ async function uploadLargeFileViaSignedUrl(
|
|
|
102
82
|
console.log(
|
|
103
83
|
`[builder-upload] step 3: registering asset - ${assetId}, ${input.filename}`,
|
|
104
84
|
);
|
|
105
|
-
const
|
|
85
|
+
const { url, id } = await completeBuilderUpload(
|
|
86
|
+
privateKey,
|
|
87
|
+
assetId,
|
|
88
|
+
input.filename,
|
|
89
|
+
);
|
|
90
|
+
console.log(`[builder-upload] done [${assetId}]: ${url}`);
|
|
91
|
+
return { url, id, provider: "builder" };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function requestBuilderSignedUrl(
|
|
95
|
+
privateKey: string,
|
|
96
|
+
filename: string,
|
|
97
|
+
mimeType: string,
|
|
98
|
+
size: number,
|
|
99
|
+
resumable = false,
|
|
100
|
+
): Promise<{
|
|
101
|
+
uploadUrl: string;
|
|
102
|
+
assetId: string;
|
|
103
|
+
requiredHeaders: Record<string, string>;
|
|
104
|
+
}> {
|
|
105
|
+
const host = builderUploadHost();
|
|
106
|
+
const url = new URL("/api/v1/upload/signed-url", host);
|
|
107
|
+
const res = await fetchWithTimeout(url.toString(), {
|
|
108
|
+
method: "POST",
|
|
109
|
+
headers: {
|
|
110
|
+
Authorization: `Bearer ${privateKey}`,
|
|
111
|
+
"Content-Type": "application/json",
|
|
112
|
+
},
|
|
113
|
+
body: JSON.stringify({
|
|
114
|
+
fileName: filename,
|
|
115
|
+
contentType: mimeType,
|
|
116
|
+
size,
|
|
117
|
+
resumable,
|
|
118
|
+
}),
|
|
119
|
+
});
|
|
120
|
+
await assertOk(res, "Builder.io signed-URL request failed");
|
|
121
|
+
const json = (await res.json()) as {
|
|
122
|
+
uploadUrl?: string;
|
|
123
|
+
assetId?: string;
|
|
124
|
+
requiredHeaders?: Record<string, string>;
|
|
125
|
+
};
|
|
126
|
+
if (!json.uploadUrl || !json.assetId || !json.requiredHeaders) {
|
|
127
|
+
throw new Error(
|
|
128
|
+
`Builder.io signed-URL response missing required fields: ${JSON.stringify(Object.keys(json))}`,
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
return {
|
|
132
|
+
uploadUrl: json.uploadUrl,
|
|
133
|
+
assetId: json.assetId,
|
|
134
|
+
requiredHeaders: json.requiredHeaders,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function completeBuilderUpload(
|
|
139
|
+
privateKey: string,
|
|
140
|
+
assetId: string,
|
|
141
|
+
filename: string | undefined,
|
|
142
|
+
): Promise<{ url: string; id?: string }> {
|
|
143
|
+
const host = builderUploadHost();
|
|
144
|
+
const res = await fetchWithTimeout(
|
|
106
145
|
new URL("/api/v1/upload/complete", host).toString(),
|
|
107
146
|
{
|
|
108
147
|
method: "POST",
|
|
109
|
-
headers: {
|
|
110
|
-
|
|
148
|
+
headers: {
|
|
149
|
+
Authorization: `Bearer ${privateKey}`,
|
|
150
|
+
"Content-Type": "application/json",
|
|
151
|
+
},
|
|
152
|
+
body: JSON.stringify({ assetId, name: filename }),
|
|
111
153
|
},
|
|
112
154
|
);
|
|
113
|
-
await assertOk(
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
console.log(`[builder-upload] done [${assetId}]: ${url}`);
|
|
119
|
-
return { url, id, provider: "builder" };
|
|
155
|
+
await assertOk(res, "Builder.io upload complete failed");
|
|
156
|
+
const json = (await res.json()) as { url?: string; id?: string };
|
|
157
|
+
if (!json.url) throw new Error("Builder.io upload/complete returned no URL");
|
|
158
|
+
return { url: json.url, id: json.id };
|
|
120
159
|
}
|
|
121
160
|
|
|
122
161
|
// Retry transient 5xx once with backoff. Builder.io's upload service
|
|
@@ -221,4 +260,127 @@ export const builderFileUploadProvider: FileUploadProvider = {
|
|
|
221
260
|
console.log(`[builder-upload] done: ${json.url}`);
|
|
222
261
|
return { url: json.url, id: json.id, provider: "builder" };
|
|
223
262
|
},
|
|
263
|
+
|
|
264
|
+
resumable: {
|
|
265
|
+
async startSession(filename, mimeType, maxBytes) {
|
|
266
|
+
const { resolveBuilderPrivateKey } =
|
|
267
|
+
await import("../server/credential-provider.js");
|
|
268
|
+
const privateKey = await resolveBuilderPrivateKey();
|
|
269
|
+
if (!privateKey) throw new Error("BUILDER_PRIVATE_KEY is not set");
|
|
270
|
+
|
|
271
|
+
console.log(
|
|
272
|
+
`[builder-resumable] starting session: ${filename} ${mimeType} ${maxBytes} bytes`,
|
|
273
|
+
);
|
|
274
|
+
const { uploadUrl, assetId, requiredHeaders } =
|
|
275
|
+
await requestBuilderSignedUrl(
|
|
276
|
+
privateKey,
|
|
277
|
+
filename,
|
|
278
|
+
mimeType,
|
|
279
|
+
maxBytes,
|
|
280
|
+
true,
|
|
281
|
+
);
|
|
282
|
+
console.log(`[builder-resumable] session step 1 ok: assetId=${assetId}`);
|
|
283
|
+
|
|
284
|
+
const initHeaders: Record<string, string> = {
|
|
285
|
+
"Content-Type": mimeType,
|
|
286
|
+
"x-goog-resumable": "start",
|
|
287
|
+
};
|
|
288
|
+
const contentLengthRange =
|
|
289
|
+
requiredHeaders?.["x-goog-content-length-range"];
|
|
290
|
+
if (contentLengthRange)
|
|
291
|
+
initHeaders["x-goog-content-length-range"] = contentLengthRange;
|
|
292
|
+
|
|
293
|
+
console.log(`[builder-resumable] session step 2: initiating GCS session`);
|
|
294
|
+
const initRes = await fetchWithTimeout(uploadUrl, {
|
|
295
|
+
method: "POST",
|
|
296
|
+
headers: initHeaders,
|
|
297
|
+
body: new Uint8Array(0),
|
|
298
|
+
});
|
|
299
|
+
if (!initRes.ok) {
|
|
300
|
+
const body = await initRes.text().catch(() => "");
|
|
301
|
+
throw new Error(
|
|
302
|
+
`GCS resumable session initiation failed (${initRes.status}): ${body}`,
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
const sessionUri = initRes.headers.get("location");
|
|
306
|
+
if (!sessionUri)
|
|
307
|
+
throw new Error(
|
|
308
|
+
"GCS did not return a Location header for the resumable session",
|
|
309
|
+
);
|
|
310
|
+
|
|
311
|
+
console.log(`[builder-resumable] session ready: assetId=${assetId}`);
|
|
312
|
+
return {
|
|
313
|
+
sessionId: sessionUri,
|
|
314
|
+
meta: { assetId, filename, mimeType },
|
|
315
|
+
} satisfies ResumableUploadSession;
|
|
316
|
+
},
|
|
317
|
+
|
|
318
|
+
async relayChunk(session, contentRange, bytes, options) {
|
|
319
|
+
const sessionUri = session.sessionId;
|
|
320
|
+
const MAX_ATTEMPTS = 4;
|
|
321
|
+
const RETRYABLE = new Set([408, 429, 500, 502, 503, 504]);
|
|
322
|
+
const delayMs = (attempt: number) =>
|
|
323
|
+
Math.min(2000, 300 * 2 ** (attempt - 1));
|
|
324
|
+
|
|
325
|
+
let lastError: unknown = null;
|
|
326
|
+
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
|
327
|
+
try {
|
|
328
|
+
const headers: Record<string, string> = {
|
|
329
|
+
"Content-Range": contentRange,
|
|
330
|
+
};
|
|
331
|
+
if (options?.mimeType) headers["Content-Type"] = options.mimeType;
|
|
332
|
+
const res = await fetch(sessionUri, {
|
|
333
|
+
method: "PUT",
|
|
334
|
+
headers,
|
|
335
|
+
body: bytes as unknown as BodyInit,
|
|
336
|
+
});
|
|
337
|
+
if (res.status === 308 || res.ok)
|
|
338
|
+
return {
|
|
339
|
+
ok: true,
|
|
340
|
+
status: res.status,
|
|
341
|
+
} satisfies ResumableChunkResult;
|
|
342
|
+
if (RETRYABLE.has(res.status) && attempt < MAX_ATTEMPTS) {
|
|
343
|
+
await res.text().catch(() => "");
|
|
344
|
+
console.warn(
|
|
345
|
+
`[builder-resumable] transient ${res.status} on attempt ${attempt}, retrying`,
|
|
346
|
+
);
|
|
347
|
+
await new Promise((r) => setTimeout(r, delayMs(attempt)));
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
return {
|
|
351
|
+
ok: false,
|
|
352
|
+
status: res.status,
|
|
353
|
+
} satisfies ResumableChunkResult;
|
|
354
|
+
} catch (err) {
|
|
355
|
+
lastError = err;
|
|
356
|
+
if (attempt >= MAX_ATTEMPTS) break;
|
|
357
|
+
console.warn(
|
|
358
|
+
`[builder-resumable] network error on attempt ${attempt}:`,
|
|
359
|
+
err instanceof Error ? err.message : String(err),
|
|
360
|
+
);
|
|
361
|
+
await new Promise((r) => setTimeout(r, delayMs(attempt)));
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
throw lastError instanceof Error
|
|
365
|
+
? lastError
|
|
366
|
+
: new Error("GCS PUT failed after retries");
|
|
367
|
+
},
|
|
368
|
+
|
|
369
|
+
async completeSession(session, filename) {
|
|
370
|
+
const { resolveBuilderPrivateKey } =
|
|
371
|
+
await import("../server/credential-provider.js");
|
|
372
|
+
const privateKey = await resolveBuilderPrivateKey();
|
|
373
|
+
if (!privateKey) throw new Error("BUILDER_PRIVATE_KEY is not set");
|
|
374
|
+
|
|
375
|
+
const assetId = session.meta.assetId as string;
|
|
376
|
+
console.log(`[builder-resumable] completing upload: assetId=${assetId}`);
|
|
377
|
+
const { url } = await completeBuilderUpload(
|
|
378
|
+
privateKey,
|
|
379
|
+
assetId,
|
|
380
|
+
filename,
|
|
381
|
+
);
|
|
382
|
+
console.log(`[builder-resumable] upload complete: ${url}`);
|
|
383
|
+
return url;
|
|
384
|
+
},
|
|
385
|
+
},
|
|
224
386
|
};
|
|
@@ -27,6 +27,22 @@ export interface FileUploadResult {
|
|
|
27
27
|
provider: string;
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
/** Opaque session handle returned by {@link FileUploadProvider.resumable.startSession}.
|
|
31
|
+
* `sessionId` is provider-specific (GCS Location URI, S3 UploadId, etc.).
|
|
32
|
+
* `meta` holds any provider state needed for subsequent relay and complete calls. */
|
|
33
|
+
export interface ResumableUploadSession {
|
|
34
|
+
sessionId: string;
|
|
35
|
+
meta: Record<string, unknown>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface ResumableChunkResult {
|
|
39
|
+
ok: boolean;
|
|
40
|
+
status: number;
|
|
41
|
+
/** Providers that need per-chunk state (e.g. S3 ETags) return updated meta
|
|
42
|
+
* here; the chunk route merges it back into the stored session. */
|
|
43
|
+
updatedMeta?: Record<string, unknown>;
|
|
44
|
+
}
|
|
45
|
+
|
|
30
46
|
export interface FileUploadProvider {
|
|
31
47
|
/** Unique id, e.g. "builder", "s3". */
|
|
32
48
|
id: string;
|
|
@@ -41,4 +57,26 @@ export interface FileUploadProvider {
|
|
|
41
57
|
isConfiguredForRequest?: () => Promise<boolean>;
|
|
42
58
|
/** Upload a file and return a URL. Throw on failure. */
|
|
43
59
|
upload: (input: FileUploadInput) => Promise<FileUploadResult>;
|
|
60
|
+
/**
|
|
61
|
+
* Optional resumable/streaming upload capability.
|
|
62
|
+
* When present, create-recording will initialise a session and stream chunks
|
|
63
|
+
* during recording instead of assembling the full blob after stop().
|
|
64
|
+
*/
|
|
65
|
+
resumable?: {
|
|
66
|
+
startSession(
|
|
67
|
+
filename: string,
|
|
68
|
+
mimeType: string,
|
|
69
|
+
maxBytes: number,
|
|
70
|
+
): Promise<ResumableUploadSession>;
|
|
71
|
+
relayChunk(
|
|
72
|
+
session: ResumableUploadSession,
|
|
73
|
+
contentRange: string,
|
|
74
|
+
bytes: Uint8Array,
|
|
75
|
+
options?: { mimeType?: string },
|
|
76
|
+
): Promise<ResumableChunkResult>;
|
|
77
|
+
completeSession(
|
|
78
|
+
session: ResumableUploadSession,
|
|
79
|
+
filename: string,
|
|
80
|
+
): Promise<string>;
|
|
81
|
+
};
|
|
44
82
|
}
|
|
@@ -264,6 +264,12 @@ function hasStructuredFilters(filters: ReturnType<typeof buildFilterSummary>) {
|
|
|
264
264
|
function buildGuidance(options: {
|
|
265
265
|
query: string | undefined;
|
|
266
266
|
structuredFilters: boolean;
|
|
267
|
+
truncated: boolean;
|
|
268
|
+
hasMore: boolean;
|
|
269
|
+
total: number;
|
|
270
|
+
returned: number;
|
|
271
|
+
offset: number;
|
|
272
|
+
limit: number;
|
|
267
273
|
}) {
|
|
268
274
|
const guidance: string[] = [];
|
|
269
275
|
|
|
@@ -285,6 +291,15 @@ function buildGuidance(options: {
|
|
|
285
291
|
);
|
|
286
292
|
}
|
|
287
293
|
|
|
294
|
+
if (options.truncated) {
|
|
295
|
+
const more = options.hasMore
|
|
296
|
+
? `Fetch the next page with offset ${options.offset + options.returned}.`
|
|
297
|
+
: "This is the last page of the cohort.";
|
|
298
|
+
guidance.push(
|
|
299
|
+
`Returned ${options.returned} of ${options.total} matching deals (limit ${options.limit}, offset ${options.offset}). This is a partial slice — do NOT treat it as the full cohort. Use total for counts/aggregates. ${more} Narrow the filters or, for exhaustive cohort analysis, use provider-api-request with provider = hubspot and stageAs.`,
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
|
|
288
303
|
return guidance.join(" ");
|
|
289
304
|
}
|
|
290
305
|
|
|
@@ -293,7 +308,7 @@ export default defineAction({
|
|
|
293
308
|
// reusable across continuation retries (no re-fetch on resume).
|
|
294
309
|
readOnly: true,
|
|
295
310
|
description:
|
|
296
|
-
"Get HubSpot deals with normalized stage, pipeline, owner, forecast, and NBM fields. This is a bounded deal analytics shortcut, not the full HubSpot capability surface. Use query for a specific customer/deal/account deep dive. For cohorts like products field = Publish, closed-won, pipeline = New Business, or close date in a range, use the structured product, pipeline, closedStatus, closedDateFrom, and closedDateTo filters instead of query when the answer is the deal list itself. If the cohort feeds a cross-source join, transcript/message/ticket search, exhaustive absence check, or downstream code/corpus workflow, prefer provider-api-catalog/provider-api-request with provider = hubspot and stageAs so the cohort is available as a staged dataset. For non-deal CRM records use hubspot-records; for arbitrary HubSpot endpoints, filters, associations, batch APIs, or payloads use provider-api-catalog/provider-api-docs/provider-api-request with provider = hubspot.",
|
|
311
|
+
"Get HubSpot deals with normalized stage, pipeline, owner, forecast, and NBM fields. This is a bounded deal analytics shortcut, not the full HubSpot capability surface. Use query for a specific customer/deal/account deep dive. For cohorts like products field = Publish, closed-won, pipeline = New Business, or close date in a range, use the structured product, pipeline, closedStatus, closedDateFrom, and closedDateTo filters instead of query when the answer is the deal list itself. If the cohort feeds a cross-source join, transcript/message/ticket search, exhaustive absence check, or downstream code/corpus workflow, prefer provider-api-catalog/provider-api-request with provider = hubspot and stageAs so the cohort is available as a staged dataset. Both paths are bounded: at most limit deals are returned (default 25, max 100). The structured-filter path returns total as the true matched count and a truncated flag; page with offset (or narrow filters) instead of expecting the whole cohort in one call, since a full enriched cohort can be several MB and overruns extension and context budgets. For non-deal CRM records use hubspot-records; for arbitrary HubSpot endpoints, filters, associations, batch APIs, or payloads use provider-api-catalog/provider-api-docs/provider-api-request with provider = hubspot.",
|
|
297
312
|
schema: z.object({
|
|
298
313
|
properties: StringListSchema.describe(
|
|
299
314
|
"Optional comma-separated extra HubSpot deal property names to include.",
|
|
@@ -344,7 +359,17 @@ export default defineAction({
|
|
|
344
359
|
.min(1)
|
|
345
360
|
.max(100)
|
|
346
361
|
.default(25)
|
|
347
|
-
.describe(
|
|
362
|
+
.describe(
|
|
363
|
+
"Maximum deals to return. Applies to BOTH full-text query results and structured-filter cohorts. The structured-filter path returns at most this many enriched deals (use total for the true matched count and offset to page).",
|
|
364
|
+
),
|
|
365
|
+
offset: z.coerce
|
|
366
|
+
.number()
|
|
367
|
+
.int()
|
|
368
|
+
.min(0)
|
|
369
|
+
.default(0)
|
|
370
|
+
.describe(
|
|
371
|
+
"Number of structured-filter results to skip before returning limit deals. Use for paging through a large cohort; ignored when query is provided.",
|
|
372
|
+
),
|
|
348
373
|
after: z
|
|
349
374
|
.string()
|
|
350
375
|
.optional()
|
|
@@ -362,6 +387,7 @@ export default defineAction({
|
|
|
362
387
|
closedDateTo,
|
|
363
388
|
query,
|
|
364
389
|
limit = 25,
|
|
390
|
+
offset = 0,
|
|
365
391
|
after,
|
|
366
392
|
}) => {
|
|
367
393
|
const trimmedQuery = query?.trim();
|
|
@@ -414,7 +440,7 @@ export default defineAction({
|
|
|
414
440
|
closedDateTo: closedDateTo?.trim(),
|
|
415
441
|
});
|
|
416
442
|
const structuredFilters = hasStructuredFilters(filters);
|
|
417
|
-
const
|
|
443
|
+
const matchedDeals = rawDeals
|
|
418
444
|
.filter((d) => visibleIds.has(String(d.properties.pipeline)))
|
|
419
445
|
.map((deal) => enrichDeal(deal, lookups, owners))
|
|
420
446
|
.filter((deal) => {
|
|
@@ -435,18 +461,52 @@ export default defineAction({
|
|
|
435
461
|
return matchesDateRange(deal, fromMs, toMs);
|
|
436
462
|
});
|
|
437
463
|
|
|
464
|
+
// The full-text query path is already bounded by HubSpot's own `limit`.
|
|
465
|
+
// EVERY non-query call (with or without structured filters) scans the whole
|
|
466
|
+
// visible deal set, so bound the returned cohort here. This is deliberately
|
|
467
|
+
// NOT gated on `structuredFilters`: the unfiltered `hubspot-deals({})` call
|
|
468
|
+
// is the LARGEST payload of all (every visible deal), so it must be bounded
|
|
469
|
+
// too — a full enriched cohort can be multiple MB, which overruns the
|
|
470
|
+
// extension iframe bridge and the agent context. We
|
|
471
|
+
// keep `total` as the true matched count and signal partial slices via
|
|
472
|
+
// `truncated` so callers never mistake a page for the full cohort.
|
|
473
|
+
// `truncated` means "this response is only part of the cohort" (returned <
|
|
474
|
+
// total) and stays true on the LAST page of a paginated read too; pagination
|
|
475
|
+
// (is there a next page) is reported separately via `hasMore` / `nextOffset`.
|
|
476
|
+
const matchedTotal = matchedDeals.length;
|
|
477
|
+
const deals = trimmedQuery
|
|
478
|
+
? matchedDeals
|
|
479
|
+
: matchedDeals.slice(offset, offset + limit);
|
|
480
|
+
const truncated = !trimmedQuery && deals.length < matchedTotal;
|
|
481
|
+
const hasMore = !trimmedQuery && offset + deals.length < matchedTotal;
|
|
482
|
+
|
|
438
483
|
return {
|
|
439
484
|
deals,
|
|
440
485
|
stageLabels: lookups.stageLabels,
|
|
441
486
|
pipelineLabels: lookups.pipelineLabels,
|
|
442
|
-
total:
|
|
487
|
+
total: matchedTotal,
|
|
443
488
|
count: deals.length,
|
|
444
489
|
query: trimmedQuery || null,
|
|
445
490
|
filters,
|
|
446
491
|
nextAfter: Array.isArray(dealResult) ? null : dealResult.nextAfter,
|
|
492
|
+
...(trimmedQuery
|
|
493
|
+
? {}
|
|
494
|
+
: {
|
|
495
|
+
limit,
|
|
496
|
+
offset,
|
|
497
|
+
truncated,
|
|
498
|
+
hasMore,
|
|
499
|
+
nextOffset: hasMore ? offset + deals.length : null,
|
|
500
|
+
}),
|
|
447
501
|
guidance: buildGuidance({
|
|
448
502
|
query: trimmedQuery,
|
|
449
503
|
structuredFilters,
|
|
504
|
+
truncated,
|
|
505
|
+
hasMore,
|
|
506
|
+
total: matchedTotal,
|
|
507
|
+
returned: deals.length,
|
|
508
|
+
offset,
|
|
509
|
+
limit,
|
|
450
510
|
}),
|
|
451
511
|
...(Array.isArray(dealResult)
|
|
452
512
|
? {}
|
|
@@ -11,6 +11,9 @@
|
|
|
11
11
|
|
|
12
12
|
import { defineAction } from "@agent-native/core";
|
|
13
13
|
import { writeAppState } from "@agent-native/core/application-state";
|
|
14
|
+
import { getActiveFileUploadProviderForRequest } from "@agent-native/core/file-upload";
|
|
15
|
+
import type { UploadMode } from "@shared/recording-core.js";
|
|
16
|
+
import { MAX_UPLOAD_BYTES } from "@shared/upload-limits.js";
|
|
14
17
|
|
|
15
18
|
import { getDb, schema } from "../server/db/index.js";
|
|
16
19
|
import {
|
|
@@ -19,6 +22,7 @@ import {
|
|
|
19
22
|
requireOrganizationAccess,
|
|
20
23
|
stringifySpaceIds,
|
|
21
24
|
} from "../server/lib/recordings.js";
|
|
25
|
+
import { setResumableSession } from "../server/lib/resumable-session.js";
|
|
22
26
|
import { createRecordingSchema } from "./lib/create-recording-schema.js";
|
|
23
27
|
import { DEFAULT_RECORDING_TITLE } from "./lib/title-source.js";
|
|
24
28
|
|
|
@@ -76,6 +80,45 @@ export default defineAction({
|
|
|
76
80
|
|
|
77
81
|
console.log(`Created recording "${title}" (${id})`);
|
|
78
82
|
|
|
83
|
+
// Initialize a resumable upload session so chunks are streamed to the
|
|
84
|
+
// provider during recording (no post-stop assembly). Falls back gracefully
|
|
85
|
+
// to the SQL chunk path when no provider supports resumable uploads or the
|
|
86
|
+
// init fails.
|
|
87
|
+
let uploadMode: UploadMode = "buffered";
|
|
88
|
+
const uploadProvider = await getActiveFileUploadProviderForRequest();
|
|
89
|
+
if (args.requestStreaming && uploadProvider?.resumable) {
|
|
90
|
+
try {
|
|
91
|
+
const recordingMimeType =
|
|
92
|
+
args.mimeType?.split(";")[0]?.trim() || "video/webm";
|
|
93
|
+
const ext = /mp4|quicktime/i.test(recordingMimeType) ? "mp4" : "webm";
|
|
94
|
+
const filename = `${id}.${ext}`;
|
|
95
|
+
console.log(
|
|
96
|
+
`[create-recording] starting resumable session: provider=${uploadProvider.id} mimeType=${recordingMimeType}`,
|
|
97
|
+
);
|
|
98
|
+
const session = await uploadProvider.resumable.startSession(
|
|
99
|
+
filename,
|
|
100
|
+
recordingMimeType,
|
|
101
|
+
MAX_UPLOAD_BYTES,
|
|
102
|
+
);
|
|
103
|
+
await setResumableSession(id, {
|
|
104
|
+
providerId: uploadProvider.id,
|
|
105
|
+
sessionId: session.sessionId,
|
|
106
|
+
meta: session.meta,
|
|
107
|
+
bytesUploaded: 0,
|
|
108
|
+
lastCommittedIndex: -1,
|
|
109
|
+
});
|
|
110
|
+
uploadMode = "streaming";
|
|
111
|
+
console.log(
|
|
112
|
+
`[create-recording] resumable session ready for ${id}: provider=${uploadProvider.id}`,
|
|
113
|
+
);
|
|
114
|
+
} catch (err) {
|
|
115
|
+
console.warn(
|
|
116
|
+
`[create-recording] resumable session init failed, falling back to buffered:`,
|
|
117
|
+
err instanceof Error ? err.message : String(err),
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
79
122
|
return {
|
|
80
123
|
id,
|
|
81
124
|
organizationId,
|
|
@@ -84,6 +127,7 @@ export default defineAction({
|
|
|
84
127
|
abortUrl: `/api/uploads/${id}/abort`,
|
|
85
128
|
// Frontend substitutes {index}/{total}/{isFinal}
|
|
86
129
|
uploadChunkUrlTemplate: `/api/uploads/${id}/chunk?index={index}&total={total}&isFinal={isFinal}`,
|
|
130
|
+
uploadMode,
|
|
87
131
|
};
|
|
88
132
|
},
|
|
89
133
|
});
|