@mevdragon/vidfarm-devcli 0.13.0 → 0.14.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/SKILL.director.md +23 -4
- package/SKILL.platform.md +2 -2
- package/demo/dist/app.css +1 -1
- package/demo/dist/app.js +190 -75
- package/dist/src/account-pages-legacy.js +9 -14
- package/dist/src/app.js +344 -28
- package/dist/src/cli.js +89 -5
- package/dist/src/composition-runtime.js +24 -3
- package/dist/src/devcli/composition-edit.js +176 -8
- package/dist/src/devcli/transitions.js +205 -0
- package/dist/src/editor-chat.js +5 -4
- package/dist/src/frontend/template-editor-chat.js +6 -5
- package/dist/src/homepage.js +2 -48
- package/dist/src/hyperframes/composition.js +183 -2
- package/dist/src/services/hyperframes.js +60 -15
- package/dist/src/services/serverless-records.js +7 -0
- package/dist/src/template-editor-shell.js +87 -48
- package/package.json +1 -1
- package/public/assets/page-runtime-client-app.js +31 -31
|
@@ -6831,7 +6831,9 @@ export function renderLibraryPage(input) {
|
|
|
6831
6831
|
|
|
6832
6832
|
.library-frame { padding-left: 432px; }
|
|
6833
6833
|
.library-body, .library-shell, .library-list-shell { min-height: 0; }
|
|
6834
|
-
|
|
6834
|
+
/* minmax(0,1fr) column: without it the implicit track grows to the
|
|
6835
|
+
toolbar's min-content width and the whole page overflows on phones. */
|
|
6836
|
+
.library-shell { display: grid; grid-template-columns: minmax(0, 1fr); grid-template-rows: auto minmax(0, 1fr); gap: 12px; }
|
|
6835
6837
|
.library-toolbar {
|
|
6836
6838
|
display: grid;
|
|
6837
6839
|
grid-template-columns: auto minmax(200px, 1fr) auto auto;
|
|
@@ -7036,11 +7038,15 @@ export function renderLibraryPage(input) {
|
|
|
7036
7038
|
${renderReadyPostScheduleStyles()}
|
|
7037
7039
|
@media (max-width: 1180px) {
|
|
7038
7040
|
.library-frame { padding-left: 0; }
|
|
7039
|
-
.editor-right-rail { position: relative; width: 100%; height: 560px; margin-top: 14px; border-radius: 24px; overflow: hidden; }
|
|
7040
7041
|
.library-post { grid-template-columns: auto 248px minmax(0, 1fr); }
|
|
7041
7042
|
.library-post-actions { grid-column: 3; justify-content: flex-start; }
|
|
7042
7043
|
.library-post[data-collapsed="true"] .library-post-actions { grid-column: auto; }
|
|
7043
7044
|
}
|
|
7045
|
+
@media (min-width: 1025px) and (max-width: 1180px) {
|
|
7046
|
+
/* Rail stacks inline below the content in this band only; at ≤1024px
|
|
7047
|
+
the shared shell turns the rail into the FAB + popup overlay. */
|
|
7048
|
+
.editor-right-rail { position: relative; width: 100%; height: 560px; margin-top: 14px; border-radius: 24px; overflow: hidden; }
|
|
7049
|
+
}
|
|
7044
7050
|
@media (min-width: 1025px) and (max-width: 1180px) {
|
|
7045
7051
|
/* The chat rail stacks below the frame at 1180px but the shell only unlocks
|
|
7046
7052
|
document scroll at 1024px; unlock it here too so stacked content stays reachable. */
|
|
@@ -7129,18 +7135,7 @@ export function renderLibraryPage(input) {
|
|
|
7129
7135
|
grid-row: 2;
|
|
7130
7136
|
}
|
|
7131
7137
|
.library-frame { padding-left: 0; }
|
|
7132
|
-
.
|
|
7133
|
-
position: fixed !important;
|
|
7134
|
-
inset: 0;
|
|
7135
|
-
z-index: 110;
|
|
7136
|
-
width: 100%;
|
|
7137
|
-
height: 100svh;
|
|
7138
|
-
min-height: 0;
|
|
7139
|
-
margin: 0;
|
|
7140
|
-
border-radius: 0;
|
|
7141
|
-
overflow: visible;
|
|
7142
|
-
pointer-events: none;
|
|
7143
|
-
}
|
|
7138
|
+
/* Rail overlay + FAB + popup at ≤1024px come from the shared shell styles. */
|
|
7144
7139
|
.library-body { padding-top: 12px; }
|
|
7145
7140
|
.library-shell { min-width: 0; }
|
|
7146
7141
|
.library-list-shell { overflow: visible; padding-right: 0; }
|
package/dist/src/app.js
CHANGED
|
@@ -23,7 +23,7 @@ import { writeForkManifest } from "./services/fork-manifest.js";
|
|
|
23
23
|
import { renderHelpPage } from "./help-page.js";
|
|
24
24
|
import { renderHomepage } from "./homepage.js";
|
|
25
25
|
import { COMPOSITION_RUNTIME_SCRIPT_BODY } from "./composition-runtime.js";
|
|
26
|
-
import { CAPTION_ANIM_CSS, CAPTION_STYLE_MARKER, KEN_BURNS_CSS, KEN_BURNS_STYLE_MARKER, TRANSITION_CSS, TRANSITION_STYLE_MARKER } from "./hyperframes/composition.js";
|
|
26
|
+
import { CAPTION_ANIM_CSS, CAPTION_STYLE_MARKER, KEN_BURNS_CSS, KEN_BURNS_STYLE_MARKER, TRANSITION_CSS, TRANSITION_STYLE_MARKER, upgradeLegacyTransitionCss } from "./hyperframes/composition.js";
|
|
27
27
|
import { normalizeCompositionZIndexHtml, sanitizeCompositionHtml } from "./services/composition-sanitize.js";
|
|
28
28
|
import { applyMarkupUsd } from "./services/billing-pricing.js";
|
|
29
29
|
import { primitiveRegistry } from "./primitive-registry.js";
|
|
@@ -54,7 +54,7 @@ import { loadSpeechAudioLocally, MAX_LOCAL_TRANSCRIBE_AUDIO_BYTES } from "./devc
|
|
|
54
54
|
import { DescribeExecutionCommand, SFNClient, StartExecutionCommand } from "@aws-sdk/client-sfn";
|
|
55
55
|
import { clipRecords, makeUserPreset } from "./services/clip-records.js";
|
|
56
56
|
import { matchScenesToClips, searchUserClips, vectorIndex } from "./services/clip-search.js";
|
|
57
|
-
import { BUILTIN_PRESETS, buildEffectiveGuidance, ClipModelClient, detectLocalAgent, effectiveHuntDurationSec, estimateScanCostFromDuration, hasFfmpeg, LocalAgentClipClient, normalizeCropFocus, normalizeHuntSpec, parseClipHuntPrompt, probeVideo, resolveDurationBand, scanVideo } from "./services/clip-curation/index.js";
|
|
57
|
+
import { BUILTIN_PRESETS, buildEffectiveGuidance, ClipModelClient, cosineSimilarity, detectLocalAgent, effectiveHuntDurationSec, estimateScanCostFromDuration, hasFfmpeg, LocalAgentClipClient, normalizeCropFocus, normalizeHuntSpec, parseClipHuntPrompt, probeVideo, resolveDurationBand, scanVideo } from "./services/clip-curation/index.js";
|
|
58
58
|
import { analyzeCastMembers, annotateScenesFromSource, buildSmartDecomposedHtml, HyperframesService, smartDecomposeVideo, stampCompositionIdentity } from "./services/hyperframes.js";
|
|
59
59
|
import { buildPendingSceneAnnotationsState } from "./services/scene-annotations.js";
|
|
60
60
|
import { createPrimitiveJobContext } from "./primitive-context.js";
|
|
@@ -401,7 +401,16 @@ const userAttachmentFinalizeSchema = z.object({
|
|
|
401
401
|
content_type: z.string().trim().min(1).max(255),
|
|
402
402
|
size_bytes: z.coerce.number().int().min(1).max(1024 * 1024 * 1024),
|
|
403
403
|
storage_key: z.string().min(1).max(2000),
|
|
404
|
-
folder_path: z.string().trim().max(500).optional()
|
|
404
|
+
folder_path: z.string().trim().max(500).optional(),
|
|
405
|
+
notes: z.string().trim().max(4000).optional()
|
|
406
|
+
});
|
|
407
|
+
const userAttachmentNotesSchema = z.object({
|
|
408
|
+
notes: z.string().max(4000).nullable().optional()
|
|
409
|
+
});
|
|
410
|
+
const userAttachmentSearchSchema = z.object({
|
|
411
|
+
query: z.string().min(1).max(2000),
|
|
412
|
+
folder_path: z.string().trim().max(500).optional(),
|
|
413
|
+
limit: z.coerce.number().int().min(1).max(100).optional()
|
|
405
414
|
});
|
|
406
415
|
const userFileFolderSchema = z.object({
|
|
407
416
|
folder_path: z.string().trim().min(1).max(500)
|
|
@@ -2917,6 +2926,9 @@ function serializeUserAttachment(c, attachment) {
|
|
|
2917
2926
|
sizeBytes: attachment.sizeBytes,
|
|
2918
2927
|
createdAt: attachment.createdAt,
|
|
2919
2928
|
folderPath: attachment.folderPath ?? "",
|
|
2929
|
+
notes: attachment.notes ?? null,
|
|
2930
|
+
// Flag only — the raw vector never ships to the client (mirrors serializeClip).
|
|
2931
|
+
hasNotesEmbedding: Boolean(attachment.notesEmbedding && attachment.notesEmbedding.length > 0),
|
|
2920
2932
|
viewUrl: attachment.publicUrl || storage.getPublicUrl(attachment.storageKey) || buildUserAttachmentViewUrl(c, attachment.storageKey)
|
|
2921
2933
|
};
|
|
2922
2934
|
}
|
|
@@ -3975,16 +3987,19 @@ function browseFilesTextContentType(fileName) {
|
|
|
3975
3987
|
}
|
|
3976
3988
|
function createBrowseFilesTool(input) {
|
|
3977
3989
|
return tool({
|
|
3978
|
-
description: "Browse AND write the user's My Files filesystem — their personal library of uploaded assets (mp4, mov, webm, png, jpg, jpeg, gif, webp, svg, mp3, wav, m4a, aac, pdf, md, txt, csv, and more) organized into virtual folders. Use action=list to enumerate files and virtual folders so you can discover what assets exist; pass folder_path to scope the listing to one folder (omit or empty for the root). Use action=read to fetch one file by file_id (preferred; copy it from a prior list result) or by file_name: it returns the file's durable view_url plus metadata, and for text files (md, txt, csv, srt, vtt, json) it also returns the full text_content inline so you can read it. For images, video, audio, and PDFs it returns view_url only — reference that URL directly (drop it into composition layers, or pass it into primitive routes like images/edit source_image_url, videos/generate input_references, or videos/download); you cannot read their raw bytes as text. Use action=write to SAVE a text file into My Files — pass file_name (e.g. About.md), content (the full text),
|
|
3990
|
+
description: "Browse AND write the user's My Files filesystem — their personal library of uploaded assets (mp4, mov, webm, png, jpg, jpeg, gif, webp, svg, mp3, wav, m4a, aac, pdf, md, txt, csv, and more) organized into virtual folders. Use action=list to enumerate files and virtual folders so you can discover what assets exist; pass folder_path to scope the listing to one folder (omit or empty for the root). Use action=search to find files by MEANING when you don't know where something lives: pass query (plain language, e.g. 'character reference sheet for the mascot' or 'logo on transparent background') and it matches keywords AND vector-embedded metadata notes across every file's name, folder, and notes — prefer search over paging through list when My Files is large or the user references an asset vaguely. Use action=read to fetch one file by file_id (preferred; copy it from a prior list/search result) or by file_name: it returns the file's durable view_url plus metadata, and for text files (md, txt, csv, srt, vtt, json) it also returns the full text_content inline so you can read it. For images, video, audio, and PDFs it returns view_url only — reference that URL directly (drop it into composition layers, or pass it into primitive routes like images/edit source_image_url, videos/generate input_references, or videos/download); you cannot read their raw bytes as text. Use action=write to SAVE a text file into My Files — pass file_name (e.g. About.md), content (the full text), optional folder_path to namescope it under a product/offer folder, and optional notes. This is how you persist Getting Started context the user can reuse later: a product About.md or Interview.md, and awareness-levels.md / persuasive-angles.md / ad-hooks.md distilled from brainstorm output. write saves text files (md, txt, csv, json, srt, vtt) via content, or IMPORTS any media asset into the library by passing its durable URL as source_url — e.g. save a finished image job's output as character_sprite_card.png so it persists in My Files instead of living only in a job result. write returns the new file_id and view_url. Use action=annotate to set metadata notes on ANY existing file (including binary assets): notes say what the file is, who/what it depicts, and when to use it, and they are vector-embedded so future sessions can search their way back to the asset. Annotate every asset worth finding again — especially character references: keep a character_sprite_card.png plus an about_character.md per recurring character in that character's folder, annotate both, and pass the sprite card's view_url into generation reference inputs (images prompt_attachments / videos input_references) for character consistency. Always list or search before you read so you use a real file_id; never invent file ids or view URLs.",
|
|
3979
3991
|
inputSchema: z.object({
|
|
3980
|
-
action: z.enum(["list", "read", "write"]).describe("list = enumerate files and folders; read = fetch one file's view_url and, for text files, its contents; write = save a text file into My Files."),
|
|
3981
|
-
folder_path: z.string().optional().describe("Folder to scope a list to, the folder of the file to read, or the folder to write into. Omit or leave empty for the root."),
|
|
3982
|
-
file_id: z.string().optional().describe("Attachment id to read, copied from a prior list result. Preferred over file_name."),
|
|
3983
|
-
file_name: z.string().optional().describe("File name to read when you do not have the id, or the name to save under for write (e.g. About.md).
|
|
3984
|
-
content: z.string().optional().describe("For write: the full text content of the file to save (e.g. Markdown for About.md). Required for
|
|
3985
|
-
|
|
3992
|
+
action: z.enum(["list", "read", "write", "annotate", "search"]).describe("list = enumerate files and folders; read = fetch one file's view_url and, for text files, its contents; write = save a text file into My Files; annotate = set the metadata notes on one existing file; search = find files by meaning across names, folders, and notes."),
|
|
3993
|
+
folder_path: z.string().optional().describe("Folder to scope a list or search to, the folder of the file to read, or the folder to write into. Omit or leave empty for the root."),
|
|
3994
|
+
file_id: z.string().optional().describe("Attachment id to read or annotate, copied from a prior list/search result. Preferred over file_name."),
|
|
3995
|
+
file_name: z.string().optional().describe("File name to read/annotate when you do not have the id, or the name to save under for write (e.g. About.md). Matched within folder_path when one is provided."),
|
|
3996
|
+
content: z.string().optional().describe("For write: the full text content of the file to save (e.g. Markdown for About.md). Required for text writes; mutually exclusive with source_url."),
|
|
3997
|
+
source_url: z.string().optional().describe("For write: a durable media URL (from a finished primitive job result or an existing asset) to IMPORT into My Files as file_name — this is how you save a generated character_sprite_card.png or other binary asset into the library. Mutually exclusive with content."),
|
|
3998
|
+
notes: z.string().optional().describe("For annotate (required) or write (optional): metadata notes describing what the file is, who/what it depicts, and when to use it — e.g. 'Sprite card for Zara, our mascot: front/side/back views, teal jacket. Use as the reference image whenever generating Zara.' Notes are vector-embedded so search finds the file by meaning. For annotate, an empty string clears existing notes."),
|
|
3999
|
+
query: z.string().optional().describe("For search: what you're looking for, in plain language. Required for search; ignored otherwise."),
|
|
4000
|
+
explanation: z.string().optional().describe("Short reason for the browse, save, annotation, or search.")
|
|
3986
4001
|
}),
|
|
3987
|
-
execute: async ({ action, folder_path, file_id, file_name, content, explanation }) => {
|
|
4002
|
+
execute: async ({ action, folder_path, file_id, file_name, content, source_url, notes, query, explanation }) => {
|
|
3988
4003
|
const listUrl = new URL(`${USER_PREFIX}/me/attachments`, config.PUBLIC_BASE_URL);
|
|
3989
4004
|
const headers = new Headers();
|
|
3990
4005
|
headers.set("vidfarm-api-key", input.vidfarmApiKey);
|
|
@@ -3997,22 +4012,59 @@ function createBrowseFilesTool(input) {
|
|
|
3997
4012
|
if (action === "write") {
|
|
3998
4013
|
const name = (file_name ?? "").trim();
|
|
3999
4014
|
if (!name) {
|
|
4000
|
-
return { action, explanation: explanation ?? null, status: 400, error: "write requires file_name (e.g. About.md)." };
|
|
4015
|
+
return { action, explanation: explanation ?? null, status: 400, error: "write requires file_name (e.g. About.md, or character_sprite_card.png with source_url)." };
|
|
4001
4016
|
}
|
|
4002
|
-
|
|
4003
|
-
|
|
4017
|
+
const importSrc = (source_url ?? "").trim();
|
|
4018
|
+
let fileBlob;
|
|
4019
|
+
if (importSrc) {
|
|
4020
|
+
// Import a durable media URL (e.g. a finished primitive job's output)
|
|
4021
|
+
// into My Files. The api-key header only travels to our own origin.
|
|
4022
|
+
let sourceUrl;
|
|
4023
|
+
try {
|
|
4024
|
+
sourceUrl = new URL(importSrc, config.PUBLIC_BASE_URL);
|
|
4025
|
+
}
|
|
4026
|
+
catch {
|
|
4027
|
+
return { action, explanation: explanation ?? null, status: 400, error: `source_url is not a valid URL: ${importSrc.slice(0, 200)}` };
|
|
4028
|
+
}
|
|
4029
|
+
const sourceHeaders = new Headers();
|
|
4030
|
+
if (sourceUrl.origin === new URL(config.PUBLIC_BASE_URL).origin) {
|
|
4031
|
+
sourceHeaders.set("vidfarm-api-key", input.vidfarmApiKey);
|
|
4032
|
+
}
|
|
4033
|
+
const sourceResponse = await fetch(sourceUrl, {
|
|
4034
|
+
method: "GET",
|
|
4035
|
+
headers: sourceHeaders,
|
|
4036
|
+
signal: mergeAbortSignals(input.abortSignal, timeoutController.signal)
|
|
4037
|
+
});
|
|
4038
|
+
if (!sourceResponse.ok) {
|
|
4039
|
+
return { action, explanation: explanation ?? null, status: sourceResponse.status, error: `Could not fetch source_url (${sourceResponse.status}). Use a durable URL from a finished job result or an existing asset.` };
|
|
4040
|
+
}
|
|
4041
|
+
const sourceBytes = await sourceResponse.arrayBuffer();
|
|
4042
|
+
if (sourceBytes.byteLength > 50 * 1024 * 1024) {
|
|
4043
|
+
return { action, explanation: explanation ?? null, status: 400, error: "source_url asset is larger than the 50 MB My Files limit." };
|
|
4044
|
+
}
|
|
4045
|
+
const sourceType = (sourceResponse.headers.get("content-type") ?? "").split(";")[0].trim();
|
|
4046
|
+
fileBlob = new Blob([sourceBytes], sourceType ? { type: sourceType } : undefined);
|
|
4004
4047
|
}
|
|
4005
|
-
|
|
4006
|
-
|
|
4048
|
+
else {
|
|
4049
|
+
if (typeof content !== "string") {
|
|
4050
|
+
return { action, explanation: explanation ?? null, status: 400, error: "write requires content (the full text of the file) or source_url (a durable media URL to import)." };
|
|
4051
|
+
}
|
|
4052
|
+
if (!isBrowseFilesTextFile(name, "")) {
|
|
4053
|
+
return { action, explanation: explanation ?? null, status: 400, error: `write with content only supports text files (md, txt, csv, json, srt, vtt). To save media like "${name}", pass its durable URL as source_url instead.` };
|
|
4054
|
+
}
|
|
4055
|
+
fileBlob = new Blob([content], { type: browseFilesTextContentType(name) });
|
|
4007
4056
|
}
|
|
4008
4057
|
const uploadUrl = new URL(`${USER_PREFIX}/me/attachments/upload`, config.PUBLIC_BASE_URL);
|
|
4009
4058
|
const uploadHeaders = new Headers();
|
|
4010
4059
|
uploadHeaders.set("vidfarm-api-key", input.vidfarmApiKey);
|
|
4011
4060
|
const form = new FormData();
|
|
4012
|
-
form.append("file",
|
|
4061
|
+
form.append("file", fileBlob, name);
|
|
4013
4062
|
if (normalizedFolder) {
|
|
4014
4063
|
form.append("folder_path", normalizedFolder);
|
|
4015
4064
|
}
|
|
4065
|
+
if (typeof notes === "string" && notes.trim()) {
|
|
4066
|
+
form.append("notes", notes.trim());
|
|
4067
|
+
}
|
|
4016
4068
|
const writeResponse = await fetch(uploadUrl, {
|
|
4017
4069
|
method: "POST",
|
|
4018
4070
|
headers: uploadHeaders,
|
|
@@ -4047,11 +4099,65 @@ function createBrowseFilesTool(input) {
|
|
|
4047
4099
|
content_type: saved.contentType,
|
|
4048
4100
|
size_bytes: saved.sizeBytes,
|
|
4049
4101
|
folder_path: saved.folderPath ?? "",
|
|
4102
|
+
notes: saved.notes ?? null,
|
|
4050
4103
|
view_url: saved.viewUrl,
|
|
4051
4104
|
created_at: saved.createdAt
|
|
4052
4105
|
}
|
|
4053
4106
|
};
|
|
4054
4107
|
}
|
|
4108
|
+
if (action === "search") {
|
|
4109
|
+
const q = (query ?? "").trim();
|
|
4110
|
+
if (!q) {
|
|
4111
|
+
return { action, explanation: explanation ?? null, status: 400, error: "search requires query (what you're looking for, in plain language)." };
|
|
4112
|
+
}
|
|
4113
|
+
const searchUrl = new URL(`${USER_PREFIX}/me/attachments/search`, config.PUBLIC_BASE_URL);
|
|
4114
|
+
const searchHeaders = new Headers();
|
|
4115
|
+
searchHeaders.set("vidfarm-api-key", input.vidfarmApiKey);
|
|
4116
|
+
searchHeaders.set("Content-Type", "application/json");
|
|
4117
|
+
const searchResponse = await fetch(searchUrl, {
|
|
4118
|
+
method: "POST",
|
|
4119
|
+
headers: searchHeaders,
|
|
4120
|
+
body: JSON.stringify({ query: q, ...(normalizedFolder ? { folder_path: normalizedFolder } : {}), limit: 20 }),
|
|
4121
|
+
signal: mergeAbortSignals(input.abortSignal, timeoutController.signal)
|
|
4122
|
+
});
|
|
4123
|
+
const searchText = await searchResponse.text();
|
|
4124
|
+
let searchJson = null;
|
|
4125
|
+
try {
|
|
4126
|
+
searchJson = searchText ? JSON.parse(searchText) : null;
|
|
4127
|
+
}
|
|
4128
|
+
catch {
|
|
4129
|
+
searchJson = null;
|
|
4130
|
+
}
|
|
4131
|
+
if (!searchResponse.ok || !Array.isArray(searchJson?.results)) {
|
|
4132
|
+
return {
|
|
4133
|
+
action,
|
|
4134
|
+
explanation: explanation ?? null,
|
|
4135
|
+
status: searchResponse.status,
|
|
4136
|
+
error: `My Files search failed (${searchResponse.status}). ${(searchJson?.error ?? searchText).slice(0, 500)}`
|
|
4137
|
+
};
|
|
4138
|
+
}
|
|
4139
|
+
return {
|
|
4140
|
+
action,
|
|
4141
|
+
explanation: explanation ?? null,
|
|
4142
|
+
status: 200,
|
|
4143
|
+
query: q,
|
|
4144
|
+
folder_path: normalizedFolder,
|
|
4145
|
+
semantic: Boolean(searchJson.semantic),
|
|
4146
|
+
result_count: searchJson.results.length,
|
|
4147
|
+
results: searchJson.results.map((file) => ({
|
|
4148
|
+
file_id: file.id,
|
|
4149
|
+
file_name: file.fileName,
|
|
4150
|
+
content_type: file.contentType,
|
|
4151
|
+
size_bytes: file.sizeBytes,
|
|
4152
|
+
folder_path: file.folderPath ?? "",
|
|
4153
|
+
notes: file.notes ?? null,
|
|
4154
|
+
similarity: file.similarity ?? null,
|
|
4155
|
+
keyword_match: Boolean(file.keyword_match),
|
|
4156
|
+
view_url: file.viewUrl,
|
|
4157
|
+
created_at: file.createdAt
|
|
4158
|
+
}))
|
|
4159
|
+
};
|
|
4160
|
+
}
|
|
4055
4161
|
const listResponse = await fetch(listUrl, {
|
|
4056
4162
|
method: "GET",
|
|
4057
4163
|
headers,
|
|
@@ -4086,6 +4192,7 @@ function createBrowseFilesTool(input) {
|
|
|
4086
4192
|
content_type: file.contentType,
|
|
4087
4193
|
size_bytes: file.sizeBytes,
|
|
4088
4194
|
folder_path: file.folderPath ?? "",
|
|
4195
|
+
notes: file.notes ?? null,
|
|
4089
4196
|
view_url: file.viewUrl,
|
|
4090
4197
|
created_at: file.createdAt
|
|
4091
4198
|
}))
|
|
@@ -4104,7 +4211,56 @@ function createBrowseFilesTool(input) {
|
|
|
4104
4211
|
status: 404,
|
|
4105
4212
|
error: file_id || file_name
|
|
4106
4213
|
? "No matching file in My Files. Call action=list first to copy an exact file_id."
|
|
4107
|
-
:
|
|
4214
|
+
: `${action} requires file_id (preferred) or file_name.`
|
|
4215
|
+
};
|
|
4216
|
+
}
|
|
4217
|
+
if (action === "annotate") {
|
|
4218
|
+
if (typeof notes !== "string") {
|
|
4219
|
+
return { action, explanation: explanation ?? null, status: 400, error: "annotate requires notes (the metadata to attach; empty string clears)." };
|
|
4220
|
+
}
|
|
4221
|
+
const annotateUrl = new URL(`${USER_PREFIX}/me/attachments/${encodeURIComponent(match.id)}`, config.PUBLIC_BASE_URL);
|
|
4222
|
+
const annotateHeaders = new Headers();
|
|
4223
|
+
annotateHeaders.set("vidfarm-api-key", input.vidfarmApiKey);
|
|
4224
|
+
annotateHeaders.set("Content-Type", "application/json");
|
|
4225
|
+
const annotateResponse = await fetch(annotateUrl, {
|
|
4226
|
+
method: "PATCH",
|
|
4227
|
+
headers: annotateHeaders,
|
|
4228
|
+
body: JSON.stringify({ notes }),
|
|
4229
|
+
signal: mergeAbortSignals(input.abortSignal, timeoutController.signal)
|
|
4230
|
+
});
|
|
4231
|
+
const annotateText = await annotateResponse.text();
|
|
4232
|
+
let annotateJson = null;
|
|
4233
|
+
try {
|
|
4234
|
+
annotateJson = annotateText ? JSON.parse(annotateText) : null;
|
|
4235
|
+
}
|
|
4236
|
+
catch {
|
|
4237
|
+
annotateJson = null;
|
|
4238
|
+
}
|
|
4239
|
+
if (!annotateResponse.ok || !annotateJson?.attachment) {
|
|
4240
|
+
return {
|
|
4241
|
+
action,
|
|
4242
|
+
explanation: explanation ?? null,
|
|
4243
|
+
status: annotateResponse.status,
|
|
4244
|
+
error: `Could not annotate file (${annotateResponse.status}). ${(annotateJson?.error ?? annotateText).slice(0, 500)}`
|
|
4245
|
+
};
|
|
4246
|
+
}
|
|
4247
|
+
const annotated = annotateJson.attachment;
|
|
4248
|
+
return {
|
|
4249
|
+
action,
|
|
4250
|
+
explanation: explanation ?? null,
|
|
4251
|
+
status: 200,
|
|
4252
|
+
annotated: true,
|
|
4253
|
+
notes_embedded: Boolean(annotateJson.notes_embedded),
|
|
4254
|
+
file: {
|
|
4255
|
+
file_id: annotated.id,
|
|
4256
|
+
file_name: annotated.fileName,
|
|
4257
|
+
content_type: annotated.contentType,
|
|
4258
|
+
size_bytes: annotated.sizeBytes,
|
|
4259
|
+
folder_path: annotated.folderPath ?? "",
|
|
4260
|
+
notes: annotated.notes ?? null,
|
|
4261
|
+
view_url: annotated.viewUrl,
|
|
4262
|
+
created_at: annotated.createdAt
|
|
4263
|
+
}
|
|
4108
4264
|
};
|
|
4109
4265
|
}
|
|
4110
4266
|
const fileInfo = {
|
|
@@ -4113,6 +4269,7 @@ function createBrowseFilesTool(input) {
|
|
|
4113
4269
|
content_type: match.contentType,
|
|
4114
4270
|
size_bytes: match.sizeBytes,
|
|
4115
4271
|
folder_path: match.folderPath ?? "",
|
|
4272
|
+
notes: match.notes ?? null,
|
|
4116
4273
|
view_url: match.viewUrl,
|
|
4117
4274
|
created_at: match.createdAt
|
|
4118
4275
|
};
|
|
@@ -4231,8 +4388,9 @@ function createEditorActionTool() {
|
|
|
4231
4388
|
"replace_composition_html",
|
|
4232
4389
|
"export_composition",
|
|
4233
4390
|
"generate_layer",
|
|
4234
|
-
"set_captions"
|
|
4235
|
-
|
|
4391
|
+
"set_captions",
|
|
4392
|
+
"set_transitions"
|
|
4393
|
+
]).describe("Which mutation to perform. Use generate_layer to AI-generate a video/image and auto-place it on the timeline (fill a gap or replace a scene). Use export_composition to trigger the same Export button available in the editor UI — this queues an AWS Lambda render and the finished MP4 URL will surface in the next editor_context as last_export_url. Use set_captions to lay down a full run of ANIMATED word-by-word captions (TikTok/CapCut style): pass captions[] cues (or text + start/duration to auto-page), plus caption_style; it replaces any existing animated caption layers. To restyle existing captions without changing text/timing, use set_layer_style with caption_* fields on each caption layer. Use set_transitions to configure scene transitions across the WHOLE timeline in one call: transition = the junction preset applied at every cut (every scene clip except each track's first; 'none' clears), transition_intro = the first clip's entrance, transition_outro = the last clip's exit."),
|
|
4236
4394
|
layer_key: z.string().optional().describe("Existing layer key from editor_context (required for remove/set/duplicate/split). MAY be either the element_id (data-hf-id) or the slug (data-hf-slug). For add_layer, optionally provide a stable id (starts with a letter, [A-Za-z0-9_-], <=64 chars) to reuse in later tool calls this turn."),
|
|
4237
4395
|
slug: z.string().optional().describe("snake_case slug for the layer. On add_layer, seeds data-hf-slug. On set_layer_identity, sets it. Slugs give viral DNA a stable handle for the element."),
|
|
4238
4396
|
note: z.string().optional().describe("Human-facing note explaining the layer's role, referenced by viral DNA. On add_layer seeds data-hf-note. On set_layer_identity, sets it. Empty string removes."),
|
|
@@ -4265,8 +4423,12 @@ function createEditorActionTool() {
|
|
|
4265
4423
|
object_position: z.string().optional().describe("CSS object-position for image/video (e.g., center, top, bottom left)."),
|
|
4266
4424
|
ken_burns: z.enum(["zoom-in", "zoom-out", "pan-left", "pan-right", "pan-up", "pan-down", "zoom-in-left", "zoom-in-right", "none"]).optional().describe("Ken Burns motion for still-image layers: a slow pan/zoom spanning the clip's full duration. Apply with set_layer_media on an existing image, or seed it on add_layer/generate_layer when kind/media_type is image. 'none' removes the effect. When the user says 'animate the images' / 'ken burns', apply it to every still image, varying presets across adjacent clips (zoom-in, pan-left, zoom-out, ...) for a documentary feel."),
|
|
4267
4425
|
ken_burns_intensity: z.number().optional().describe("Ken Burns strength: extra zoom / pan travel as a fraction of the frame, 0.04-0.5 (default 0.18; ~0.1 subtle, ~0.3 dramatic). Only meaningful alongside ken_burns."),
|
|
4268
|
-
transition: z.enum(["crossfade", "fade-black", "slide-left", "slide-right", "slide-up", "slide-down", "wipe-left", "wipe-right", "wipe-up", "wipe-down", "zoom-in", "zoom-out", "circle-open", "none"]).optional().describe("Scene transition INTO this clip at its start, from the previous clip on the same track. Set it on the INCOMING clip (the one after the cut) with set_layer_media, or seed it on add_layer/generate_layer. Works on video and image scene clips; 'none' removes it. The previous butt-cut clip is automatically held on screen beneath the incoming one for the transition window — do not retime anything yourself.
|
|
4269
|
-
transition_duration: z.number().optional().describe("Transition length in seconds, 0.1-2.5 (default 0.5; ~0.3 snappy, ~1.0 slow cinematic). Only meaningful alongside transition."),
|
|
4426
|
+
transition: z.enum(["crossfade", "fade-black", "fade-white", "flash", "smoke", "blur", "slide-left", "slide-right", "slide-up", "slide-down", "whip-left", "whip-right", "wipe-left", "wipe-right", "wipe-up", "wipe-down", "zoom-in", "zoom-out", "circle-open", "none"]).optional().describe("Scene transition INTO this clip at its start, from the previous clip on the same track. Set it on the INCOMING clip (the one after the cut) with set_layer_media, or seed it on add_layer/generate_layer. On set_transitions it is the junction preset applied at EVERY cut. Works on video and image scene clips; 'none' removes it. The previous butt-cut clip is automatically held on screen beneath the incoming one for the transition window — do not retime anything yourself. Preset energy: crossfade/fade-black/fade-white/blur = calm, smoke = dreamy, slide/wipe = energetic, whip/flash = fast-paced social, zoom/circle-open = punchy."),
|
|
4427
|
+
transition_duration: z.number().optional().describe("Transition length in seconds, 0.1-2.5 (default 0.5; ~0.3 snappy, ~1.0 slow cinematic). Only meaningful alongside transition/transition_intro."),
|
|
4428
|
+
transition_out: z.enum(["fade", "fade-black", "fade-white", "flash", "smoke", "blur", "slide-left", "slide-right", "slide-up", "slide-down", "wipe-left", "wipe-right", "wipe-up", "wipe-down", "zoom-in", "zoom-out", "circle-close", "none"]).optional().describe("Exit transition OUT of this clip at its end (fade to black, slide away, ...). Set it with set_layer_media on the clip that should animate away, or seed it on add_layer/generate_layer. The animation plays over the clip's last transition_out_duration seconds; no retiming needed. Most cuts only need the incoming clip's transition — use transition_out for ending a video (fade to black), for clips followed by a gap, or for deliberate dip-to-black moments (outgoing fade-black + incoming fade-black)."),
|
|
4429
|
+
transition_out_duration: z.number().optional().describe("Exit transition length in seconds, 0.1-2.5 (default 0.5). Only meaningful alongside transition_out/transition_outro."),
|
|
4430
|
+
transition_intro: z.enum(["crossfade", "fade-black", "fade-white", "flash", "smoke", "blur", "slide-left", "slide-right", "slide-up", "slide-down", "whip-left", "whip-right", "wipe-left", "wipe-right", "wipe-up", "wipe-down", "zoom-in", "zoom-out", "circle-open", "none"]).optional().describe("set_transitions only: entrance for the very FIRST scene clip (fade in from black = fade-black). 'none' clears it."),
|
|
4431
|
+
transition_outro: z.enum(["fade", "fade-black", "fade-white", "flash", "smoke", "blur", "slide-left", "slide-right", "slide-up", "slide-down", "wipe-left", "wipe-right", "wipe-up", "wipe-down", "zoom-in", "zoom-out", "circle-close", "none"]).optional().describe("set_transitions only: exit for the very LAST scene clip (fade to black = fade-black). 'none' clears it."),
|
|
4270
4432
|
caption_style: z.enum(["spotlight", "karaoke", "word-pop", "stack-up", "neon", "bounce"]).optional().describe("Animated caption preset look: spotlight (active word gets a highlight pill, bold outline text), karaoke (words fill with color as spoken), word-pop (one word at a time, punchy scale-in), stack-up (words fade/rise in and stay), neon (active word glow pulse), bounce (active word bounces). Applies animation + colors + background treatment in one go; individual caption_*/color/background/background_style fields override specifics."),
|
|
4271
4433
|
caption_animation: z.enum(["highlight-pop", "karaoke-fill", "word-pop", "cumulative-reveal", "glow-pulse", "bounce-wave", "none"]).optional().describe("Word-by-word caption animation, if you want to set it directly instead of via caption_style. Works on set_captions, add_layer (kind=text), and set_layer_style. 'none' removes the animation and flattens the layer back to static text."),
|
|
4272
4434
|
caption_active_color: z.string().optional().describe("CSS color the active word takes while it is spoken (karaoke fill color, highlight text color, glow color)."),
|
|
@@ -4691,7 +4853,11 @@ function rewriteHyperframesDraftCompositionHtml(html, draftPath) {
|
|
|
4691
4853
|
}
|
|
4692
4854
|
}
|
|
4693
4855
|
// And the scene-transition keyframes: toggling data-transition on a clip
|
|
4694
|
-
// must animate immediately on older stored compositions.
|
|
4856
|
+
// must animate immediately on older stored compositions. Compositions that
|
|
4857
|
+
// embed the V1 block get it swapped for the current one in place; if the
|
|
4858
|
+
// swap misses (drifted block), the marker check below appends V2 after it,
|
|
4859
|
+
// which wins by source order.
|
|
4860
|
+
rewritten = upgradeLegacyTransitionCss(rewritten);
|
|
4695
4861
|
if (!rewritten.includes(TRANSITION_STYLE_MARKER)) {
|
|
4696
4862
|
const transitionTag = `<style data-vf-transitions="true">${TRANSITION_CSS}</style>`;
|
|
4697
4863
|
if (/<\/head>/i.test(rewritten)) {
|
|
@@ -14772,6 +14938,52 @@ app.get(`${USER_PREFIX}/me/generated-media`, async (c) => {
|
|
|
14772
14938
|
next_cursor: page.nextCursor
|
|
14773
14939
|
});
|
|
14774
14940
|
});
|
|
14941
|
+
// Embedder for My Files notes — the same BYOK seam clip search uses. Prefer the
|
|
14942
|
+
// provider matching the embedding model the file library was built with (from
|
|
14943
|
+
// the newest annotated attachment) so query vectors stay cosine-comparable; a
|
|
14944
|
+
// fresh library takes the first embed-capable key (gemini → openai; openrouter
|
|
14945
|
+
// can't embed). Null (no key) fail-softs notes search to keyword-only.
|
|
14946
|
+
async function buildFileNotesEmbedder(customerId) {
|
|
14947
|
+
const attachments = await serverlessRecords.listUserAttachments(customerId);
|
|
14948
|
+
const libModel = attachments.find((a) => a.notesEmbeddingModel && Array.isArray(a.notesEmbedding) && a.notesEmbedding.length > 0)?.notesEmbeddingModel ?? null;
|
|
14949
|
+
const providers = libModel ? [libModel.startsWith("text-embedding") ? "openai" : "gemini"] : ["gemini", "openai"];
|
|
14950
|
+
for (const provider of providers) {
|
|
14951
|
+
const apiKey = await resolveCustomerProviderKey(customerId, provider);
|
|
14952
|
+
if (!apiKey)
|
|
14953
|
+
continue;
|
|
14954
|
+
const client = new ClipModelClient({ provider, apiKey });
|
|
14955
|
+
if (client.canEmbed)
|
|
14956
|
+
return client;
|
|
14957
|
+
}
|
|
14958
|
+
return null;
|
|
14959
|
+
}
|
|
14960
|
+
// What we embed for a My Files entry: folder + file name + the notes, so
|
|
14961
|
+
// "the sprite card for our mascot" is findable from any phrasing. Fail-soft:
|
|
14962
|
+
// no key or an embed error still saves the notes (keyword-searchable).
|
|
14963
|
+
async function embedAttachmentNotes(customerId, input) {
|
|
14964
|
+
if (!input.notes.trim())
|
|
14965
|
+
return null;
|
|
14966
|
+
try {
|
|
14967
|
+
const embedder = await buildFileNotesEmbedder(customerId);
|
|
14968
|
+
if (!embedder?.embeddingModelId)
|
|
14969
|
+
return null;
|
|
14970
|
+
const text = [input.folderPath, input.fileName, input.notes].filter(Boolean).join("\n");
|
|
14971
|
+
const embedding = await embedder.embedQuery(text);
|
|
14972
|
+
return embedding && embedding.length > 0 ? { embedding, model: embedder.embeddingModelId } : null;
|
|
14973
|
+
}
|
|
14974
|
+
catch (error) {
|
|
14975
|
+
devLog("attachment_notes.embed_failed", devErrorFields(error));
|
|
14976
|
+
return null;
|
|
14977
|
+
}
|
|
14978
|
+
}
|
|
14979
|
+
function attachmentKeywordMatch(attachment, q) {
|
|
14980
|
+
const hay = `${attachment.fileName} ${attachment.folderPath ?? ""} ${attachment.notes ?? ""}`.toLowerCase();
|
|
14981
|
+
return q
|
|
14982
|
+
.toLowerCase()
|
|
14983
|
+
.split(/\s+/)
|
|
14984
|
+
.filter(Boolean)
|
|
14985
|
+
.every((term) => hay.includes(term));
|
|
14986
|
+
}
|
|
14775
14987
|
app.get(`${USER_PREFIX}/me/attachments`, async (c) => {
|
|
14776
14988
|
const customer = requireCustomer(c);
|
|
14777
14989
|
const attachments = (await serverlessRecords.listUserAttachments(customer.id)).map((attachment) => serializeUserAttachment(c, attachment));
|
|
@@ -14850,6 +15062,7 @@ app.post(`${USER_PREFIX}/me/attachments/upload`, async (c) => {
|
|
|
14850
15062
|
const fileName = sanitizeFileName(file.name || "upload.bin");
|
|
14851
15063
|
const contentType = file.type || inferAttachmentContentType(fileName);
|
|
14852
15064
|
const folderPath = sanitizeStorageSubpath(String(formData.get("folder_path") ?? ""));
|
|
15065
|
+
const notes = String(formData.get("notes") ?? "").trim().slice(0, 4000) || null;
|
|
14853
15066
|
if (!isAllowedAttachment(fileName, contentType)) {
|
|
14854
15067
|
return c.json({ error: `Unsupported attachment type: ${fileName}` }, 400);
|
|
14855
15068
|
}
|
|
@@ -14861,6 +15074,7 @@ app.post(`${USER_PREFIX}/me/attachments/upload`, async (c) => {
|
|
|
14861
15074
|
const attachmentId = randomUUID();
|
|
14862
15075
|
const storageKey = storage.userAttachmentKey(customer.id, attachmentId, fileName, folderPath);
|
|
14863
15076
|
const stored = await storage.putBuffer(storageKey, buffer, contentType);
|
|
15077
|
+
const embedded = notes ? await embedAttachmentNotes(customer.id, { fileName, folderPath, notes }) : null;
|
|
14864
15078
|
await serverlessRecords.createUserAttachment({
|
|
14865
15079
|
id: attachmentId,
|
|
14866
15080
|
customerId: customer.id,
|
|
@@ -14869,7 +15083,10 @@ app.post(`${USER_PREFIX}/me/attachments/upload`, async (c) => {
|
|
|
14869
15083
|
sizeBytes: buffer.byteLength,
|
|
14870
15084
|
storageKey,
|
|
14871
15085
|
folderPath,
|
|
14872
|
-
publicUrl: stored.url || storage.getPublicUrl(storageKey) || buildUserAttachmentViewUrl(c, storageKey)
|
|
15086
|
+
publicUrl: stored.url || storage.getPublicUrl(storageKey) || buildUserAttachmentViewUrl(c, storageKey),
|
|
15087
|
+
notes,
|
|
15088
|
+
notesEmbedding: embedded?.embedding ?? null,
|
|
15089
|
+
notesEmbeddingModel: embedded?.model ?? null
|
|
14873
15090
|
});
|
|
14874
15091
|
const attachment = await serverlessRecords.getUserAttachment(customer.id, attachmentId);
|
|
14875
15092
|
if (!attachment) {
|
|
@@ -14898,6 +15115,8 @@ app.post(`${USER_PREFIX}/me/attachments`, async (c) => {
|
|
|
14898
15115
|
if (!isAllowedAttachment(fileName, contentType)) {
|
|
14899
15116
|
return c.json({ error: `Unsupported attachment type: ${fileName}` }, 400);
|
|
14900
15117
|
}
|
|
15118
|
+
const notes = body.notes?.trim() || null;
|
|
15119
|
+
const embedded = notes ? await embedAttachmentNotes(customer.id, { fileName, folderPath, notes }) : null;
|
|
14901
15120
|
await serverlessRecords.createUserAttachment({
|
|
14902
15121
|
id: body.attachment_id,
|
|
14903
15122
|
customerId: customer.id,
|
|
@@ -14906,7 +15125,10 @@ app.post(`${USER_PREFIX}/me/attachments`, async (c) => {
|
|
|
14906
15125
|
sizeBytes: body.size_bytes,
|
|
14907
15126
|
storageKey: expectedStorageKey,
|
|
14908
15127
|
folderPath,
|
|
14909
|
-
publicUrl: storage.getPublicUrl(expectedStorageKey) || buildUserAttachmentViewUrl(c, expectedStorageKey)
|
|
15128
|
+
publicUrl: storage.getPublicUrl(expectedStorageKey) || buildUserAttachmentViewUrl(c, expectedStorageKey),
|
|
15129
|
+
notes,
|
|
15130
|
+
notesEmbedding: embedded?.embedding ?? null,
|
|
15131
|
+
notesEmbeddingModel: embedded?.model ?? null
|
|
14910
15132
|
});
|
|
14911
15133
|
const attachment = await serverlessRecords.getUserAttachment(customer.id, body.attachment_id);
|
|
14912
15134
|
if (!attachment) {
|
|
@@ -14940,6 +15162,89 @@ app.patch(`${USER_PREFIX}/me/attachments/folders`, async (c) => {
|
|
|
14940
15162
|
await serverlessRecords.moveUserAttachmentFolder(customer.id, fromFolder, toFolder);
|
|
14941
15163
|
return c.json({ ok: true, from_folder_path: fromFolder, to_folder_path: toFolder });
|
|
14942
15164
|
});
|
|
15165
|
+
// PATCH /me/attachments/:attachmentId — set/clear the metadata notes on one My
|
|
15166
|
+
// Files entry and (re)embed them for vector search. Notes are what make assets
|
|
15167
|
+
// findable by MEANING later — e.g. annotate a character_sprite_card.png with
|
|
15168
|
+
// who the character is so /me/attachments/search finds it from any phrasing.
|
|
15169
|
+
// (Registered after the /folders PATCH so "folders" never matches as an id.)
|
|
15170
|
+
app.patch(`${USER_PREFIX}/me/attachments/:attachmentId`, async (c) => {
|
|
15171
|
+
const customer = requireCustomer(c);
|
|
15172
|
+
try {
|
|
15173
|
+
const body = userAttachmentNotesSchema.parse(await c.req.json());
|
|
15174
|
+
const attachment = await serverlessRecords.getUserAttachment(customer.id, c.req.param("attachmentId"));
|
|
15175
|
+
if (!attachment) {
|
|
15176
|
+
return c.json({ error: "Attachment not found." }, 404);
|
|
15177
|
+
}
|
|
15178
|
+
const notes = (body.notes ?? "").trim() || null;
|
|
15179
|
+
const embedded = notes
|
|
15180
|
+
? await embedAttachmentNotes(customer.id, { fileName: attachment.fileName, folderPath: attachment.folderPath, notes })
|
|
15181
|
+
: null;
|
|
15182
|
+
const updated = await serverlessRecords.updateUserAttachmentNotes(customer.id, attachment.id, {
|
|
15183
|
+
notes,
|
|
15184
|
+
notesEmbedding: embedded?.embedding ?? null,
|
|
15185
|
+
notesEmbeddingModel: embedded?.model ?? null
|
|
15186
|
+
});
|
|
15187
|
+
if (!updated) {
|
|
15188
|
+
return c.json({ error: "Attachment not found." }, 404);
|
|
15189
|
+
}
|
|
15190
|
+
return c.json({ ok: true, attachment: serializeUserAttachment(c, updated), notes_embedded: Boolean(embedded) });
|
|
15191
|
+
}
|
|
15192
|
+
catch (error) {
|
|
15193
|
+
return c.json({ error: error instanceof Error ? error.message : "Unable to update attachment notes." }, 400);
|
|
15194
|
+
}
|
|
15195
|
+
});
|
|
15196
|
+
// POST /me/attachments/search — find My Files entries by meaning, mirroring
|
|
15197
|
+
// /clips/search: keyword filter over name/folder/notes, semantically re-ranked
|
|
15198
|
+
// via the notes embeddings; when nothing keyword-matches, relax to a pure
|
|
15199
|
+
// semantic pass. Embeddings are BYOK — no saved key fail-softs to keyword-only.
|
|
15200
|
+
app.post(`${USER_PREFIX}/me/attachments/search`, async (c) => {
|
|
15201
|
+
const customer = requireCustomer(c);
|
|
15202
|
+
try {
|
|
15203
|
+
const body = userAttachmentSearchSchema.parse(await c.req.json());
|
|
15204
|
+
const query = body.query.trim();
|
|
15205
|
+
if (!query) {
|
|
15206
|
+
return c.json({ error: "Provide a search query." }, 400);
|
|
15207
|
+
}
|
|
15208
|
+
const folderPath = sanitizeStorageSubpath(body.folder_path);
|
|
15209
|
+
const limit = Math.max(1, Math.min(100, body.limit ?? 20));
|
|
15210
|
+
const all = await serverlessRecords.listUserAttachments(customer.id);
|
|
15211
|
+
const scoped = folderPath ? all.filter((a) => (a.folderPath ?? "") === folderPath) : all;
|
|
15212
|
+
let queryEmbedding;
|
|
15213
|
+
try {
|
|
15214
|
+
const embedder = await buildFileNotesEmbedder(customer.id);
|
|
15215
|
+
if (embedder)
|
|
15216
|
+
queryEmbedding = await embedder.embedQuery(query);
|
|
15217
|
+
}
|
|
15218
|
+
catch (error) {
|
|
15219
|
+
devLog("attachment_search.embed_failed", devErrorFields(error));
|
|
15220
|
+
}
|
|
15221
|
+
const keywordIds = new Set(scoped.filter((a) => attachmentKeywordMatch(a, query)).map((a) => a.id));
|
|
15222
|
+
const pool = keywordIds.size > 0 ? scoped.filter((a) => keywordIds.has(a.id)) : scoped;
|
|
15223
|
+
const hits = pool
|
|
15224
|
+
.map((a) => ({
|
|
15225
|
+
attachment: a,
|
|
15226
|
+
keyword: keywordIds.has(a.id),
|
|
15227
|
+
similarity: queryEmbedding && Array.isArray(a.notesEmbedding) && a.notesEmbedding.length > 0
|
|
15228
|
+
? cosineSimilarity(a.notesEmbedding, queryEmbedding)
|
|
15229
|
+
: undefined
|
|
15230
|
+
}))
|
|
15231
|
+
.filter((h) => h.keyword || (h.similarity ?? 0) > 0)
|
|
15232
|
+
.sort((x, y) => ((y.similarity ?? 0) + (y.keyword ? 0.2 : 0)) - ((x.similarity ?? 0) + (x.keyword ? 0.2 : 0)))
|
|
15233
|
+
.slice(0, limit);
|
|
15234
|
+
return c.json({
|
|
15235
|
+
results: hits.map((h) => ({
|
|
15236
|
+
...serializeUserAttachment(c, h.attachment),
|
|
15237
|
+
similarity: h.similarity ?? null,
|
|
15238
|
+
keyword_match: h.keyword
|
|
15239
|
+
})),
|
|
15240
|
+
semantic: Boolean(queryEmbedding),
|
|
15241
|
+
folder_path: folderPath || null
|
|
15242
|
+
});
|
|
15243
|
+
}
|
|
15244
|
+
catch (error) {
|
|
15245
|
+
return c.json({ error: error instanceof Error ? error.message : "My Files search failed." }, 400);
|
|
15246
|
+
}
|
|
15247
|
+
});
|
|
14943
15248
|
app.delete(`${USER_PREFIX}/me/attachments/folders`, async (c) => {
|
|
14944
15249
|
const customer = requireCustomer(c);
|
|
14945
15250
|
const body = userFileFolderSchema.parse(await c.req.json());
|
|
@@ -15519,20 +15824,31 @@ function renderClipsPage(input) {
|
|
|
15519
15824
|
.clips-import-note[data-tone="success"] { color: #3f7d55; }
|
|
15520
15825
|
.clips-import-actions { display: flex; justify-content: flex-end; gap: 10px; }
|
|
15521
15826
|
|
|
15522
|
-
@media (max-width: 1180px) {
|
|
15827
|
+
@media (min-width: 1025px) and (max-width: 1180px) {
|
|
15828
|
+
/* Rail stacks inline below the content in this band only; at ≤1024px
|
|
15829
|
+
the shared shell turns the rail into the FAB + popup overlay. */
|
|
15523
15830
|
.clips-frame { padding-left: 0; }
|
|
15524
15831
|
.editor-right-rail { position: relative; inset: auto; width: 100%; height: 540px; margin-top: 14px; border-radius: 24px; overflow: hidden; }
|
|
15525
15832
|
.editor-chat-panel { border-right: 0; border-radius: 24px; }
|
|
15526
|
-
}
|
|
15527
|
-
@media (min-width: 1025px) and (max-width: 1180px) {
|
|
15528
15833
|
html, body { height: auto; min-height: 100%; overflow: auto; overflow-x: hidden; }
|
|
15529
15834
|
.app-shell { height: auto; min-height: 100svh; }
|
|
15530
15835
|
.page-stack, .frame { height: auto; min-height: 0; overflow: visible; }
|
|
15531
15836
|
.clips-scroll { overflow: visible; padding-right: 0; }
|
|
15532
15837
|
}
|
|
15533
15838
|
@media (max-width: 1024px) {
|
|
15839
|
+
.clips-frame { padding-left: 0; }
|
|
15534
15840
|
.clips-shell { grid-template-rows: auto auto; }
|
|
15535
15841
|
.clips-scroll { overflow: visible; padding-right: 0; }
|
|
15842
|
+
.clips-toolbar-row { gap: 8px; }
|
|
15843
|
+
.clips-search { flex: 1 1 100%; min-width: 0; }
|
|
15844
|
+
.clips-search input { min-width: 0; font-size: 16px; }
|
|
15845
|
+
.clips-import-form input,
|
|
15846
|
+
.clips-import-form textarea,
|
|
15847
|
+
.clips-import-form select { font-size: 16px; }
|
|
15848
|
+
}
|
|
15849
|
+
@media (max-width: 560px) {
|
|
15850
|
+
.clips-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
|
|
15851
|
+
.clip-body { padding: 10px; }
|
|
15536
15852
|
}
|
|
15537
15853
|
`,
|
|
15538
15854
|
body: `
|