@agent-native/core 0.68.2 → 0.69.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/docs/content/template-clips.md +12 -7
- package/corpus/core/package.json +1 -1
- package/corpus/core/src/client/AgentPanel.tsx +2 -4
- package/corpus/core/src/extensions/content-patch.ts +12 -2
- package/corpus/core/src/extensions/web-search-tool.ts +45 -4
- package/corpus/core/src/secrets/register-framework-secrets.ts +7 -0
- package/corpus/core/src/server/social-og-image.ts +6 -1
- package/corpus/core/src/vite/client.ts +83 -1
- package/corpus/templates/clips/app/components/recorder/pre-record-panel.tsx +69 -13
- package/corpus/templates/clips/app/components/recorder/recorder-engine.ts +38 -2
- package/corpus/templates/clips/app/lib/capture-install-options.ts +2 -1
- package/corpus/templates/clips/app/lib/recorder-preferences.ts +67 -0
- package/corpus/templates/clips/app/routes/_app.trash.tsx +21 -0
- package/corpus/templates/clips/app/routes/record.tsx +14 -3
- package/corpus/templates/clips/chrome-extension/STORE_DRAFT.md +5 -2
- package/corpus/templates/content/vite.config.ts +37 -1
- package/corpus/templates/content/vitest.config.ts +3 -0
- package/dist/client/AgentPanel.d.ts +1 -1
- package/dist/client/AgentPanel.d.ts.map +1 -1
- package/dist/client/AgentPanel.js +1 -1
- package/dist/client/AgentPanel.js.map +1 -1
- package/dist/extensions/content-patch.d.ts.map +1 -1
- package/dist/extensions/content-patch.js +10 -2
- package/dist/extensions/content-patch.js.map +1 -1
- package/dist/extensions/web-search-tool.d.ts +5 -3
- package/dist/extensions/web-search-tool.d.ts.map +1 -1
- package/dist/extensions/web-search-tool.js +36 -4
- package/dist/extensions/web-search-tool.js.map +1 -1
- package/dist/secrets/register-framework-secrets.d.ts.map +1 -1
- package/dist/secrets/register-framework-secrets.js +6 -0
- package/dist/secrets/register-framework-secrets.js.map +1 -1
- package/dist/server/social-og-image.d.ts.map +1 -1
- package/dist/server/social-og-image.js +5 -1
- package/dist/server/social-og-image.js.map +1 -1
- package/dist/vite/client.d.ts.map +1 -1
- package/dist/vite/client.js +83 -1
- package/dist/vite/client.js.map +1 -1
- package/docs/content/template-clips.md +12 -7
- 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.69.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 530de18: Add Firecrawl as a BYOK backend for the web-search agent tool. When `FIRECRAWL_API_KEY` is configured (via app secrets or environment) the tool routes searches through Firecrawl's `/v2/search` API. It slots into the existing first-configured-wins chain after Brave, Tavily, and Exa, and before Builder-managed search, and is registered as an optional framework secret so it surfaces in every template's settings UI.
|
|
8
|
+
|
|
9
|
+
## 0.68.3
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- 9db3c12: Fix Cloudflare Pages worker bundling for content-heavy templates and add the published Clips Chrome extension URL defaults.
|
|
14
|
+
|
|
3
15
|
## 0.68.2
|
|
4
16
|
|
|
5
17
|
### Patch Changes
|
|
@@ -54,10 +54,13 @@ itself. The Chrome extension is the path for active-tab developer logs and
|
|
|
54
54
|
browser-only repros. In the Clips UI, use the Chrome option for browser logs and
|
|
55
55
|
the desktop app for the most seamless everyday capture path.
|
|
56
56
|
|
|
57
|
+
The Agent-Native Clips Chrome extension listing is
|
|
58
|
+
`https://chromewebstore.google.com/detail/baoipacpchggcdigagnajakiidcgcffn`.
|
|
57
59
|
If you host your own Clips server, keep the Chrome extension option hidden until
|
|
58
|
-
your Web Store listing is live. Set `VITE_CLIPS_CHROME_EXTENSION_ENABLED=1`
|
|
59
|
-
|
|
60
|
-
|
|
60
|
+
your Web Store listing is live. Set `VITE_CLIPS_CHROME_EXTENSION_ENABLED=1`
|
|
61
|
+
after approval to show the extension beside desktop-app download prompts. Set
|
|
62
|
+
`VITE_CLIPS_CHROME_EXTENSION_URL` only if you need to override the default
|
|
63
|
+
listing URL.
|
|
61
64
|
|
|
62
65
|
## Agent-readable clips
|
|
63
66
|
|
|
@@ -165,7 +168,7 @@ Clips is a larger template with a native recorder (it ships a desktop companion
|
|
|
165
168
|
|
|
166
169
|
1. **Video storage (required).** Connect a storage backend through the onboarding wizard. The easiest path is Builder.io (free during beta, one-click). For self-hosted storage, set `S3_ENDPOINT`, `S3_BUCKET`, `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY`, and optionally `S3_REGION` and `S3_PUBLIC_BASE_URL`. Cloudflare R2 and DigitalOcean Spaces use the same env vars with the `R2_*` prefix.
|
|
167
170
|
2. **Google Calendar (optional).** To sync upcoming meetings, connect a Google Calendar account from Settings. The OAuth callback URL in dev is `http://localhost:8094/_agent-native/google/callback`. Set up a Google OAuth client in [Google Cloud Console](https://console.cloud.google.com/) with the Gmail and Google Calendar APIs enabled.
|
|
168
|
-
3. **Screen-capture permissions.** On macOS, grant Screen Recording permission to the browser (or the desktop companion app) in System Settings → Privacy & Security → Screen Recording. Browser recordings can save redacted console and fetch/XHR diagnostics from the recorder page. Once
|
|
171
|
+
3. **Screen-capture permissions.** On macOS, grant Screen Recording permission to the browser (or the desktop companion app) in System Settings → Privacy & Security → Screen Recording. Browser recordings can save redacted console and fetch/XHR diagnostics from the recorder page. Once the Chrome extension listing is available, enable `VITE_CLIPS_CHROME_EXTENSION_ENABLED=1` so users can choose the extension for active-tab browser logs or the desktop app for the smoothest native capture path.
|
|
169
172
|
4. **Slack previews (optional).** Create a Slack app with `links:read`, `links:write`, and `links.embed:write`; subscribe to `link_shared`; add your Clips share domain under **App Unfurl Domains**; set the Request URL to `https://your-clips.example.com/api/slack/unfurl`; and add the OAuth redirect URL `https://your-clips.example.com/api/slack/oauth/callback`. Configure `SLACK_CLIENT_ID`, `SLACK_CLIENT_SECRET`, and `SLACK_SIGNING_SECRET`, then connect workspaces from Clips Settings.
|
|
170
173
|
|
|
171
174
|
### Host your own Clips server
|
|
@@ -209,9 +212,11 @@ tray app at your deployment.
|
|
|
209
212
|
|
|
210
213
|
5. **Enable the Chrome extension after publishing.** Keep
|
|
211
214
|
`VITE_CLIPS_CHROME_EXTENSION_ENABLED` unset until the Chrome Web Store listing
|
|
212
|
-
is approved
|
|
213
|
-
|
|
214
|
-
|
|
215
|
+
is approved. Then set it to `1` to reveal the browser-log option beside the
|
|
216
|
+
desktop app prompts. The default listing URL is
|
|
217
|
+
`https://chromewebstore.google.com/detail/baoipacpchggcdigagnajakiidcgcffn`;
|
|
218
|
+
set `VITE_CLIPS_CHROME_EXTENSION_URL` only if your deployment uses a
|
|
219
|
+
different extension listing.
|
|
215
220
|
|
|
216
221
|
6. **Connect optional integrations.** Google Calendar powers the Meetings tab,
|
|
217
222
|
`GEMINI_API_KEY` or Builder.io Connect powers transcript cleanup and titles,
|
package/corpus/core/package.json
CHANGED
|
@@ -76,10 +76,8 @@ const MultiTabAssistantChatLazy = lazy(() =>
|
|
|
76
76
|
})),
|
|
77
77
|
);
|
|
78
78
|
import type { MultiTabAssistantChatHeaderProps } from "./MultiTabAssistantChat.js";
|
|
79
|
-
import {
|
|
80
|
-
|
|
81
|
-
type AssistantChatProps,
|
|
82
|
-
} from "./AssistantChat.js";
|
|
79
|
+
import type { AssistantChatProps } from "./AssistantChat.js";
|
|
80
|
+
import { assistantUiRecoverableRenderErrorKind } from "./assistant-ui-recovery.js";
|
|
83
81
|
import { useDevMode } from "./use-dev-mode.js";
|
|
84
82
|
import { useScreenRefreshKey } from "./use-db-sync.js";
|
|
85
83
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
@@ -116,13 +116,23 @@ export async function applyExtensionContentUpdate(
|
|
|
116
116
|
export async function formatExtensionHtml(content: string): Promise<string> {
|
|
117
117
|
try {
|
|
118
118
|
const prettier = await import("prettier");
|
|
119
|
-
|
|
119
|
+
const formatted = await prettier.format(content, {
|
|
120
120
|
parser: "html",
|
|
121
121
|
htmlWhitespaceSensitivity: "ignore",
|
|
122
122
|
});
|
|
123
|
+
return typeof formatted === "string" ? formatted : content;
|
|
123
124
|
} catch (err: any) {
|
|
125
|
+
const message = String(err?.message ?? err);
|
|
126
|
+
if (
|
|
127
|
+
message.includes("Cannot find package 'prettier'") ||
|
|
128
|
+
message.includes('Cannot find package "prettier"') ||
|
|
129
|
+
message.includes("Cannot find module 'prettier'") ||
|
|
130
|
+
message.includes('Cannot find module "prettier"')
|
|
131
|
+
) {
|
|
132
|
+
return content;
|
|
133
|
+
}
|
|
124
134
|
throw new Error(
|
|
125
|
-
`Unable to format extension HTML with Prettier: ${
|
|
135
|
+
`Unable to format extension HTML with Prettier: ${message}`,
|
|
126
136
|
);
|
|
127
137
|
}
|
|
128
138
|
}
|
|
@@ -7,13 +7,15 @@
|
|
|
7
7
|
* 1. Brave Search API (BRAVE_SEARCH_API_KEY)
|
|
8
8
|
* 2. Tavily (TAVILY_API_KEY)
|
|
9
9
|
* 3. Exa (EXA_API_KEY)
|
|
10
|
-
* 4.
|
|
10
|
+
* 4. Firecrawl (FIRECRAWL_API_KEY)
|
|
11
|
+
* 5. Builder.io (connected Builder credentials)
|
|
11
12
|
*
|
|
12
13
|
* The first configured backend wins. If none is configured, the tool
|
|
13
14
|
* returns a helpful message telling the user which keys to add.
|
|
14
15
|
*
|
|
15
|
-
* Connect Builder.io or register BRAVE_SEARCH_API_KEY, TAVILY_API_KEY,
|
|
16
|
-
* EXA_API_KEY via app secrets settings or environment
|
|
16
|
+
* Connect Builder.io or register BRAVE_SEARCH_API_KEY, TAVILY_API_KEY,
|
|
17
|
+
* EXA_API_KEY, or FIRECRAWL_API_KEY via app secrets settings or environment
|
|
18
|
+
* variables.
|
|
17
19
|
*/
|
|
18
20
|
|
|
19
21
|
import type { ActionEntry } from "../agent/production-agent.js";
|
|
@@ -222,6 +224,40 @@ async function searchExa(
|
|
|
222
224
|
}));
|
|
223
225
|
}
|
|
224
226
|
|
|
227
|
+
async function searchFirecrawl(
|
|
228
|
+
query: string,
|
|
229
|
+
count: number,
|
|
230
|
+
apiKey: string,
|
|
231
|
+
): Promise<WebSearchResult[]> {
|
|
232
|
+
const res = await fetch("https://api.firecrawl.dev/v2/search", {
|
|
233
|
+
method: "POST",
|
|
234
|
+
headers: {
|
|
235
|
+
"Content-Type": "application/json",
|
|
236
|
+
Authorization: `Bearer ${apiKey}`,
|
|
237
|
+
},
|
|
238
|
+
body: JSON.stringify({
|
|
239
|
+
query,
|
|
240
|
+
limit: count,
|
|
241
|
+
}),
|
|
242
|
+
signal: AbortSignal.timeout(15_000),
|
|
243
|
+
});
|
|
244
|
+
if (!res.ok) {
|
|
245
|
+
throw new Error(`Firecrawl error ${res.status}: ${await res.text()}`);
|
|
246
|
+
}
|
|
247
|
+
// v2 groups results by source ({ data: { web: [...] } }); older shapes
|
|
248
|
+
// return a flat array ({ data: [...] }). Accept both defensively.
|
|
249
|
+
type FirecrawlResult = { title?: string; url?: string; description?: string };
|
|
250
|
+
const data = (await res.json()) as {
|
|
251
|
+
data?: FirecrawlResult[] | { web?: FirecrawlResult[] };
|
|
252
|
+
};
|
|
253
|
+
const results = Array.isArray(data.data) ? data.data : (data.data?.web ?? []);
|
|
254
|
+
return results.map((r) => ({
|
|
255
|
+
title: r.title ?? "",
|
|
256
|
+
url: r.url ?? "",
|
|
257
|
+
snippet: r.description ?? "",
|
|
258
|
+
}));
|
|
259
|
+
}
|
|
260
|
+
|
|
225
261
|
async function searchBuilderManaged(
|
|
226
262
|
query: string,
|
|
227
263
|
count: number,
|
|
@@ -287,7 +323,7 @@ export function createWebSearchToolEntry(
|
|
|
287
323
|
"web-search": {
|
|
288
324
|
tool: {
|
|
289
325
|
description:
|
|
290
|
-
"Search the public web. Use to find API documentation, endpoints, current information, or any topic. Returns ranked results from BYOK providers or a grounded Builder-managed summary. Follow up with web-request or provider-api-docs using responseMode:'markdown' or responseMode:'matches' to fetch clean text, links, or compact snippets from promising URLs. Requires either Connect Builder.io or one of: BRAVE_SEARCH_API_KEY, TAVILY_API_KEY, or
|
|
326
|
+
"Search the public web. Use to find API documentation, endpoints, current information, or any topic. Returns ranked results from BYOK providers or a grounded Builder-managed summary. Follow up with web-request or provider-api-docs using responseMode:'markdown' or responseMode:'matches' to fetch clean text, links, or compact snippets from promising URLs. Requires either Connect Builder.io or one of: BRAVE_SEARCH_API_KEY, TAVILY_API_KEY, EXA_API_KEY, or FIRECRAWL_API_KEY.",
|
|
291
327
|
parameters: {
|
|
292
328
|
type: "object" as const,
|
|
293
329
|
properties: {
|
|
@@ -318,6 +354,7 @@ export function createWebSearchToolEntry(
|
|
|
318
354
|
const braveKey = await resolveSearchKey("BRAVE_SEARCH_API_KEY", opts);
|
|
319
355
|
const tavilyKey = await resolveSearchKey("TAVILY_API_KEY", opts);
|
|
320
356
|
const exaKey = await resolveSearchKey("EXA_API_KEY", opts);
|
|
357
|
+
const firecrawlKey = await resolveSearchKey("FIRECRAWL_API_KEY", opts);
|
|
321
358
|
const builderCredentials = await resolveBuilderSearchCredentials(opts);
|
|
322
359
|
|
|
323
360
|
let results: WebSearchResult[];
|
|
@@ -334,6 +371,9 @@ export function createWebSearchToolEntry(
|
|
|
334
371
|
} else if (exaKey) {
|
|
335
372
|
results = await searchExa(query, count, exaKey);
|
|
336
373
|
backend = "Exa";
|
|
374
|
+
} else if (firecrawlKey) {
|
|
375
|
+
results = await searchFirecrawl(query, count, firecrawlKey);
|
|
376
|
+
backend = "Firecrawl";
|
|
337
377
|
} else if (builderCredentials) {
|
|
338
378
|
managedText = await searchBuilderManaged(
|
|
339
379
|
query,
|
|
@@ -350,6 +390,7 @@ export function createWebSearchToolEntry(
|
|
|
350
390
|
" • BRAVE_SEARCH_API_KEY — https://brave.com/search/api/",
|
|
351
391
|
" • TAVILY_API_KEY — https://tavily.com/",
|
|
352
392
|
" • EXA_API_KEY — https://exa.ai/",
|
|
393
|
+
" • FIRECRAWL_API_KEY — https://firecrawl.dev/",
|
|
353
394
|
].join("\n");
|
|
354
395
|
}
|
|
355
396
|
} catch (err: unknown) {
|
|
@@ -47,6 +47,13 @@ export function registerFrameworkSecrets(): void {
|
|
|
47
47
|
"Enables the web-search agent tool via Exa. Used as fallback when Brave and Tavily are not set and before Builder-managed search.",
|
|
48
48
|
docsUrl: "https://exa.ai/",
|
|
49
49
|
},
|
|
50
|
+
{
|
|
51
|
+
key: "FIRECRAWL_API_KEY",
|
|
52
|
+
label: "Firecrawl API Key",
|
|
53
|
+
description:
|
|
54
|
+
"Enables the web-search agent tool via Firecrawl. Used as fallback when Brave, Tavily, and Exa are not set and before Builder-managed search.",
|
|
55
|
+
docsUrl: "https://firecrawl.dev/",
|
|
56
|
+
},
|
|
50
57
|
];
|
|
51
58
|
|
|
52
59
|
for (const entry of webSearchKeys) {
|
|
@@ -330,7 +330,12 @@ export function renderAgentNativeOgImageSvg(
|
|
|
330
330
|
export async function renderAgentNativeOgImagePng(
|
|
331
331
|
input: AgentNativeOgImageInput = {},
|
|
332
332
|
): Promise<Uint8Array> {
|
|
333
|
-
const
|
|
333
|
+
const overridePackage =
|
|
334
|
+
typeof process !== "undefined"
|
|
335
|
+
? process.env.AGENT_NATIVE_RESVG_PACKAGE
|
|
336
|
+
: undefined;
|
|
337
|
+
const resvgPackage = overridePackage || "@resvg/resvg-js";
|
|
338
|
+
const { Resvg } = await import(/* @vite-ignore */ resvgPackage);
|
|
334
339
|
// Feed resvg the embedded Liberation Sans font explicitly. System fonts can't
|
|
335
340
|
// be relied on: Linux serverless runtimes (Netlify/Lambda) ship neither Arial
|
|
336
341
|
// nor Inter, so without a bundled font every `<text>` rendered blank.
|
|
@@ -1320,6 +1320,87 @@ function ssrStubPlugin(packages: string[]): Plugin | null {
|
|
|
1320
1320
|
if (!packages.length) return null;
|
|
1321
1321
|
const stubbed = new Set(packages);
|
|
1322
1322
|
const STUB_ID = "\0agent-native-ssr-stub";
|
|
1323
|
+
const namedExports = [
|
|
1324
|
+
"ActionBarPrimitive",
|
|
1325
|
+
"AllSelection",
|
|
1326
|
+
"Array",
|
|
1327
|
+
"AssistantRuntimeProvider",
|
|
1328
|
+
"Awareness",
|
|
1329
|
+
"BranchPickerPrimitive",
|
|
1330
|
+
"BubbleMenu",
|
|
1331
|
+
"CodeBlockLowlight",
|
|
1332
|
+
"Collaboration",
|
|
1333
|
+
"CollaborationCaret",
|
|
1334
|
+
"ComposerPrimitive",
|
|
1335
|
+
"CompositeAttachmentAdapter",
|
|
1336
|
+
"DOMParser",
|
|
1337
|
+
"Decoration",
|
|
1338
|
+
"DecorationSet",
|
|
1339
|
+
"Editor",
|
|
1340
|
+
"EditorContent",
|
|
1341
|
+
"Extension",
|
|
1342
|
+
"FitAddon",
|
|
1343
|
+
"Fragment",
|
|
1344
|
+
"Image",
|
|
1345
|
+
"Link",
|
|
1346
|
+
"Map",
|
|
1347
|
+
"Markdown",
|
|
1348
|
+
"Mark",
|
|
1349
|
+
"MessagePrimitive",
|
|
1350
|
+
"Node",
|
|
1351
|
+
"NodeSelection",
|
|
1352
|
+
"NodeViewContent",
|
|
1353
|
+
"NodeViewWrapper",
|
|
1354
|
+
"Placeholder",
|
|
1355
|
+
"Plugin",
|
|
1356
|
+
"PluginKey",
|
|
1357
|
+
"ReactNodeViewRenderer",
|
|
1358
|
+
"Selection",
|
|
1359
|
+
"SimpleImageAttachmentAdapter",
|
|
1360
|
+
"SimpleTextAttachmentAdapter",
|
|
1361
|
+
"StarterKit",
|
|
1362
|
+
"Table",
|
|
1363
|
+
"TableCell",
|
|
1364
|
+
"TableHeader",
|
|
1365
|
+
"TableRow",
|
|
1366
|
+
"TaskItem",
|
|
1367
|
+
"TaskList",
|
|
1368
|
+
"Terminal",
|
|
1369
|
+
"TextSelection",
|
|
1370
|
+
"ThreadPrimitive",
|
|
1371
|
+
"WebLinksAddon",
|
|
1372
|
+
"captureException",
|
|
1373
|
+
"common",
|
|
1374
|
+
"createLowlight",
|
|
1375
|
+
"defaultUrlTransform",
|
|
1376
|
+
"extensions",
|
|
1377
|
+
"findTable",
|
|
1378
|
+
"format",
|
|
1379
|
+
"Doc",
|
|
1380
|
+
"getHTMLFromFragment",
|
|
1381
|
+
"getIsolationScope",
|
|
1382
|
+
"init",
|
|
1383
|
+
"isChangeOrigin",
|
|
1384
|
+
"mergeAttributes",
|
|
1385
|
+
"renderToString",
|
|
1386
|
+
"applyUpdate",
|
|
1387
|
+
"encodeStateVector",
|
|
1388
|
+
"encodeStateAsUpdate",
|
|
1389
|
+
"mergeUpdates",
|
|
1390
|
+
"useAui",
|
|
1391
|
+
"useComposer",
|
|
1392
|
+
"useComposerRuntime",
|
|
1393
|
+
"useCurrentEditor",
|
|
1394
|
+
"useEditor",
|
|
1395
|
+
"useLocalRuntime",
|
|
1396
|
+
"useMessagePartText",
|
|
1397
|
+
"useMessageRuntime",
|
|
1398
|
+
"useThread",
|
|
1399
|
+
"useThreadRuntime",
|
|
1400
|
+
"withScope",
|
|
1401
|
+
"XmlFragment",
|
|
1402
|
+
"XmlText",
|
|
1403
|
+
];
|
|
1323
1404
|
return {
|
|
1324
1405
|
name: "agent-native-ssr-stub-heavy-libs",
|
|
1325
1406
|
enforce: "pre",
|
|
@@ -1345,7 +1426,8 @@ function ssrStubPlugin(packages: string[]): Plugin | null {
|
|
|
1345
1426
|
"return new Proxy(() => {}, handler); " +
|
|
1346
1427
|
"} };" +
|
|
1347
1428
|
"const stub = new Proxy(() => {}, handler);" +
|
|
1348
|
-
"export default stub;"
|
|
1429
|
+
"export default stub;" +
|
|
1430
|
+
namedExports.map((name) => `export const ${name} = stub;`).join("")
|
|
1349
1431
|
);
|
|
1350
1432
|
},
|
|
1351
1433
|
};
|
|
@@ -48,6 +48,10 @@ import {
|
|
|
48
48
|
type RecordingMode,
|
|
49
49
|
} from "./recorder-engine";
|
|
50
50
|
import type { CameraBubbleSize } from "./camera-bubble";
|
|
51
|
+
import {
|
|
52
|
+
loadRecorderPreferences,
|
|
53
|
+
saveRecorderPreferences,
|
|
54
|
+
} from "@/lib/recorder-preferences";
|
|
51
55
|
import { CameraVisualizer, type CameraTestStatus } from "./camera-visualizer";
|
|
52
56
|
import {
|
|
53
57
|
MicrophoneVisualizer,
|
|
@@ -184,11 +188,14 @@ export function PreRecordPanel({
|
|
|
184
188
|
}: PreRecordPanelProps) {
|
|
185
189
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
186
190
|
const loomInputRef = useRef<HTMLInputElement>(null);
|
|
191
|
+
// Saved selections from the last visit. A `?mode=`/`?surface=` deep link
|
|
192
|
+
// (initialMode/initialDisplaySurface) still takes precedence over them.
|
|
193
|
+
const savedPrefs = useMemo(() => loadRecorderPreferences(), []);
|
|
187
194
|
const [mode, setMode] = useState<RecordingMode>(
|
|
188
|
-
() => initialMode ?? "screen+camera",
|
|
195
|
+
() => initialMode ?? savedPrefs.mode ?? "screen+camera",
|
|
189
196
|
);
|
|
190
197
|
const [displaySurface, setDisplaySurface] = useState<DisplaySurface>(
|
|
191
|
-
() => initialDisplaySurface ?? "window",
|
|
198
|
+
() => initialDisplaySurface ?? savedPrefs.displaySurface ?? "window",
|
|
192
199
|
);
|
|
193
200
|
const [sourceOpen, setSourceOpen] = useState(false);
|
|
194
201
|
const [deviceSettingsOpen, setDeviceSettingsOpen] = useState(false);
|
|
@@ -197,8 +204,12 @@ export function PreRecordPanel({
|
|
|
197
204
|
const [loomError, setLoomError] = useState<string | null>(null);
|
|
198
205
|
const [mics, setMics] = useState<MediaDeviceInfo[]>([]);
|
|
199
206
|
const [cameras, setCameras] = useState<MediaDeviceInfo[]>([]);
|
|
200
|
-
const [micId, setMicId] = useState<string>(
|
|
201
|
-
|
|
207
|
+
const [micId, setMicId] = useState<string>(
|
|
208
|
+
() => savedPrefs.micId ?? "default",
|
|
209
|
+
);
|
|
210
|
+
const [cameraId, setCameraId] = useState<string>(
|
|
211
|
+
() => savedPrefs.cameraId ?? "default",
|
|
212
|
+
);
|
|
202
213
|
const [enumError, setEnumError] = useState<string | null>(null);
|
|
203
214
|
const [micAccessStatus, setMicAccessStatus] =
|
|
204
215
|
useState<DeviceAccessStatus>("idle");
|
|
@@ -274,13 +285,56 @@ export function PreRecordPanel({
|
|
|
274
285
|
[mics],
|
|
275
286
|
);
|
|
276
287
|
|
|
288
|
+
// Reset to "default" only once a populated list genuinely excludes the saved
|
|
289
|
+
// device — an empty list means "not enumerated yet", and resetting then would
|
|
290
|
+
// wipe a restored preference before devices load.
|
|
277
291
|
useEffect(() => {
|
|
278
292
|
if (micId === "default" || micId === NO_MIC_DEVICE_ID) return;
|
|
279
|
-
if (!mics.some((mic) => mic.deviceId === micId)) {
|
|
293
|
+
if (mics.length > 0 && !mics.some((mic) => mic.deviceId === micId)) {
|
|
280
294
|
setMicId("default");
|
|
281
295
|
}
|
|
282
296
|
}, [micId, mics]);
|
|
283
297
|
|
|
298
|
+
// Same guard for cameras. Not persisted, so a temporarily missing device
|
|
299
|
+
// doesn't erase the saved choice.
|
|
300
|
+
useEffect(() => {
|
|
301
|
+
if (cameraId === "default" || cameraId === NO_CAMERA_DEVICE_ID) return;
|
|
302
|
+
if (
|
|
303
|
+
cameras.length > 0 &&
|
|
304
|
+
!cameras.some((camera) => camera.deviceId === cameraId)
|
|
305
|
+
) {
|
|
306
|
+
setCameraId("default");
|
|
307
|
+
}
|
|
308
|
+
}, [cameraId, cameras]);
|
|
309
|
+
|
|
310
|
+
// Camera-only mode needs a camera — coerce a restored "off" sentinel to
|
|
311
|
+
// "default" so Start doesn't forward it as an exact deviceId (covers the
|
|
312
|
+
// ?mode=camera deep-link/restore path the mode buttons already handle).
|
|
313
|
+
useEffect(() => {
|
|
314
|
+
if (mode === "camera" && cameraId === NO_CAMERA_DEVICE_ID) {
|
|
315
|
+
setCameraId("default");
|
|
316
|
+
}
|
|
317
|
+
}, [mode, cameraId]);
|
|
318
|
+
|
|
319
|
+
// Persist deliberate picks only (not the resets above), so an unavailable
|
|
320
|
+
// device on load can't clobber the stored preference.
|
|
321
|
+
const chooseMode = useCallback((value: RecordingMode) => {
|
|
322
|
+
setMode(value);
|
|
323
|
+
saveRecorderPreferences({ mode: value });
|
|
324
|
+
}, []);
|
|
325
|
+
const chooseDisplaySurface = useCallback((value: DisplaySurface) => {
|
|
326
|
+
setDisplaySurface(value);
|
|
327
|
+
saveRecorderPreferences({ displaySurface: value });
|
|
328
|
+
}, []);
|
|
329
|
+
const chooseMic = useCallback((value: string) => {
|
|
330
|
+
setMicId(value);
|
|
331
|
+
saveRecorderPreferences({ micId: value });
|
|
332
|
+
}, []);
|
|
333
|
+
const chooseCamera = useCallback((value: string) => {
|
|
334
|
+
setCameraId(value);
|
|
335
|
+
saveRecorderPreferences({ cameraId: value });
|
|
336
|
+
}, []);
|
|
337
|
+
|
|
284
338
|
const requestMicrophoneChoices = useCallback(async () => {
|
|
285
339
|
if (!navigator.mediaDevices?.getUserMedia) {
|
|
286
340
|
setMicAccessStatus("error");
|
|
@@ -376,9 +430,9 @@ export function PreRecordPanel({
|
|
|
376
430
|
void requestMicrophoneChoices();
|
|
377
431
|
return;
|
|
378
432
|
}
|
|
379
|
-
|
|
433
|
+
chooseMic(value);
|
|
380
434
|
},
|
|
381
|
-
[requestMicrophoneChoices],
|
|
435
|
+
[requestMicrophoneChoices, chooseMic],
|
|
382
436
|
);
|
|
383
437
|
|
|
384
438
|
const handleMicSignalChange = useCallback((hasSignal: boolean) => {
|
|
@@ -530,12 +584,12 @@ export function PreRecordPanel({
|
|
|
530
584
|
key={opt.value}
|
|
531
585
|
type="button"
|
|
532
586
|
onClick={() => {
|
|
533
|
-
|
|
587
|
+
chooseMode(opt.value);
|
|
534
588
|
if (
|
|
535
589
|
opt.value === "camera" &&
|
|
536
590
|
cameraId === NO_CAMERA_DEVICE_ID
|
|
537
591
|
) {
|
|
538
|
-
|
|
592
|
+
chooseCamera("default");
|
|
539
593
|
}
|
|
540
594
|
}}
|
|
541
595
|
className={cn(
|
|
@@ -605,7 +659,7 @@ export function PreRecordPanel({
|
|
|
605
659
|
<button
|
|
606
660
|
key={opt.value}
|
|
607
661
|
type="button"
|
|
608
|
-
onClick={() =>
|
|
662
|
+
onClick={() => chooseDisplaySurface(opt.value)}
|
|
609
663
|
className={cn(
|
|
610
664
|
"flex min-h-[76px] flex-col rounded-lg border p-2 text-left transition-colors",
|
|
611
665
|
active
|
|
@@ -703,7 +757,7 @@ export function PreRecordPanel({
|
|
|
703
757
|
<Switch
|
|
704
758
|
checked={audioEnabled}
|
|
705
759
|
onCheckedChange={(checked) =>
|
|
706
|
-
|
|
760
|
+
chooseMic(checked ? "default" : NO_MIC_DEVICE_ID)
|
|
707
761
|
}
|
|
708
762
|
disabled={busy}
|
|
709
763
|
aria-label="Include audio in this recording"
|
|
@@ -740,7 +794,7 @@ export function PreRecordPanel({
|
|
|
740
794
|
</div>
|
|
741
795
|
<Select
|
|
742
796
|
value={cameraId}
|
|
743
|
-
onValueChange={
|
|
797
|
+
onValueChange={chooseCamera}
|
|
744
798
|
disabled={!needsCamera}
|
|
745
799
|
>
|
|
746
800
|
<SelectTrigger className="h-9 min-w-0 flex-1 border-0 bg-transparent px-2 shadow-none hover:bg-muted/45 focus:ring-0 focus:ring-offset-0 disabled:opacity-60">
|
|
@@ -762,7 +816,9 @@ export function PreRecordPanel({
|
|
|
762
816
|
<Switch
|
|
763
817
|
checked={needsCamera}
|
|
764
818
|
onCheckedChange={(checked) =>
|
|
765
|
-
|
|
819
|
+
chooseCamera(
|
|
820
|
+
checked ? "default" : NO_CAMERA_DEVICE_ID,
|
|
821
|
+
)
|
|
766
822
|
}
|
|
767
823
|
disabled={busy}
|
|
768
824
|
aria-label="Include camera in this recording"
|
|
@@ -186,6 +186,17 @@ function errorName(err: unknown): string {
|
|
|
186
186
|
return (err as { name?: string } | null)?.name ?? "";
|
|
187
187
|
}
|
|
188
188
|
|
|
189
|
+
// getUserMedia failed because the requested device is gone (unplugged / stale
|
|
190
|
+
// saved id), not a permission error — recoverable by retrying with the default.
|
|
191
|
+
function isDeviceUnavailableError(err: unknown): boolean {
|
|
192
|
+
const name = errorName(err);
|
|
193
|
+
return (
|
|
194
|
+
name === "OverconstrainedError" ||
|
|
195
|
+
name === "NotFoundError" ||
|
|
196
|
+
name === "DevicesNotFoundError"
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
189
200
|
function errorMessage(err: unknown): string {
|
|
190
201
|
return err instanceof Error ? err.message : String(err || "Unknown error");
|
|
191
202
|
}
|
|
@@ -592,7 +603,20 @@ export class RecorderEngine {
|
|
|
592
603
|
audio: false,
|
|
593
604
|
});
|
|
594
605
|
} catch (err) {
|
|
595
|
-
|
|
606
|
+
// Stale/unplugged cameraDeviceId — retry with the default camera
|
|
607
|
+
// instead of failing the recording.
|
|
608
|
+
if (this.opts.cameraDeviceId && isDeviceUnavailableError(err)) {
|
|
609
|
+
try {
|
|
610
|
+
this.cameraStream = await navigator.mediaDevices.getUserMedia({
|
|
611
|
+
video: true,
|
|
612
|
+
audio: false,
|
|
613
|
+
});
|
|
614
|
+
} catch (retryErr) {
|
|
615
|
+
throw this.friendlyError(retryErr, "camera");
|
|
616
|
+
}
|
|
617
|
+
} else {
|
|
618
|
+
throw this.friendlyError(err, "camera");
|
|
619
|
+
}
|
|
596
620
|
}
|
|
597
621
|
}
|
|
598
622
|
|
|
@@ -603,7 +627,19 @@ export class RecorderEngine {
|
|
|
603
627
|
video: false,
|
|
604
628
|
});
|
|
605
629
|
} catch (err) {
|
|
606
|
-
|
|
630
|
+
// Same stale-device fallback as the camera — retry default mic.
|
|
631
|
+
if (this.opts.micDeviceId && isDeviceUnavailableError(err)) {
|
|
632
|
+
try {
|
|
633
|
+
this.micStream = await navigator.mediaDevices.getUserMedia({
|
|
634
|
+
audio: voiceFocusedAudioConstraints(),
|
|
635
|
+
video: false,
|
|
636
|
+
});
|
|
637
|
+
} catch (retryErr) {
|
|
638
|
+
throw this.friendlyError(retryErr, "microphone");
|
|
639
|
+
}
|
|
640
|
+
} else {
|
|
641
|
+
throw this.friendlyError(err, "microphone");
|
|
642
|
+
}
|
|
607
643
|
}
|
|
608
644
|
}
|
|
609
645
|
|
|
@@ -4,7 +4,8 @@ function isTruthy(value: unknown): boolean {
|
|
|
4
4
|
}
|
|
5
5
|
|
|
6
6
|
const chromeExtensionUrl =
|
|
7
|
-
import.meta.env.VITE_CLIPS_CHROME_EXTENSION_URL?.trim() ??
|
|
7
|
+
import.meta.env.VITE_CLIPS_CHROME_EXTENSION_URL?.trim() ??
|
|
8
|
+
"https://chromewebstore.google.com/detail/baoipacpchggcdigagnajakiidcgcffn";
|
|
8
9
|
|
|
9
10
|
export const clipsChromeExtensionEnabled = isTruthy(
|
|
10
11
|
import.meta.env.VITE_CLIPS_CHROME_EXTENSION_ENABLED,
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { CameraBubbleSize } from "@/components/recorder/camera-bubble";
|
|
2
|
+
import type {
|
|
3
|
+
DisplaySurface,
|
|
4
|
+
RecordingMode,
|
|
5
|
+
} from "@/components/recorder/recorder-engine";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Last-used recorder selections, remembered across `/record` visits via
|
|
9
|
+
* localStorage so the panel doesn't reset to defaults every time. Device ids
|
|
10
|
+
* are best-effort: a remembered mic/camera that no longer exists (machine
|
|
11
|
+
* changed, permission revoked) falls back to "default" in the panel, so we
|
|
12
|
+
* never feed a stale `deviceId: { exact }` to getUserMedia.
|
|
13
|
+
*
|
|
14
|
+
* `?mode=` / `?surface=` URL params still win over a saved preference — those
|
|
15
|
+
* are explicit deep-link intents (e.g. the agent navigating to a specific
|
|
16
|
+
* capture mode).
|
|
17
|
+
*/
|
|
18
|
+
export interface RecorderPreferences {
|
|
19
|
+
mode: RecordingMode;
|
|
20
|
+
displaySurface: DisplaySurface;
|
|
21
|
+
micId: string;
|
|
22
|
+
cameraId: string;
|
|
23
|
+
cameraSize: CameraBubbleSize;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const STORAGE_KEY = "clips:recorder-preferences";
|
|
27
|
+
|
|
28
|
+
const VALID_MODES: RecordingMode[] = ["screen", "camera", "screen+camera"];
|
|
29
|
+
const VALID_SURFACES: DisplaySurface[] = ["monitor", "window", "browser"];
|
|
30
|
+
const VALID_SIZES: CameraBubbleSize[] = ["sm", "md", "lg"];
|
|
31
|
+
|
|
32
|
+
export function loadRecorderPreferences(): Partial<RecorderPreferences> {
|
|
33
|
+
if (typeof window === "undefined") return {};
|
|
34
|
+
try {
|
|
35
|
+
const raw = window.localStorage.getItem(STORAGE_KEY);
|
|
36
|
+
if (!raw) return {};
|
|
37
|
+
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
|
38
|
+
const prefs: Partial<RecorderPreferences> = {};
|
|
39
|
+
if (VALID_MODES.includes(parsed.mode as RecordingMode)) {
|
|
40
|
+
prefs.mode = parsed.mode as RecordingMode;
|
|
41
|
+
}
|
|
42
|
+
if (VALID_SURFACES.includes(parsed.displaySurface as DisplaySurface)) {
|
|
43
|
+
prefs.displaySurface = parsed.displaySurface as DisplaySurface;
|
|
44
|
+
}
|
|
45
|
+
if (typeof parsed.micId === "string") prefs.micId = parsed.micId;
|
|
46
|
+
if (typeof parsed.cameraId === "string") prefs.cameraId = parsed.cameraId;
|
|
47
|
+
if (VALID_SIZES.includes(parsed.cameraSize as CameraBubbleSize)) {
|
|
48
|
+
prefs.cameraSize = parsed.cameraSize as CameraBubbleSize;
|
|
49
|
+
}
|
|
50
|
+
return prefs;
|
|
51
|
+
} catch {
|
|
52
|
+
return {};
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function saveRecorderPreferences(
|
|
57
|
+
patch: Partial<RecorderPreferences>,
|
|
58
|
+
): void {
|
|
59
|
+
if (typeof window === "undefined") return;
|
|
60
|
+
try {
|
|
61
|
+
const next = { ...loadRecorderPreferences(), ...patch };
|
|
62
|
+
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
|
|
63
|
+
} catch {
|
|
64
|
+
// localStorage may be unavailable (private mode, quota) — preferences are
|
|
65
|
+
// a nicety, never block recording on a write failure.
|
|
66
|
+
}
|
|
67
|
+
}
|