@agent-native/core 0.109.1 → 0.109.2
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 +7 -0
- package/corpus/core/docs/content/integrations.mdx +87 -253
- package/corpus/core/package.json +1 -1
- package/corpus/core/src/client/blocks/library/diagram.config.ts +10 -1
- package/corpus/core/src/client/blocks/library/diagram.tsx +9 -4
- package/corpus/core/src/deploy/build.ts +63 -3
- package/corpus/templates/clips/.agents/skills/dictate/SKILL.md +28 -5
- package/corpus/templates/clips/.agents/skills/meetings/SKILL.md +7 -0
- package/corpus/templates/clips/.agents/skills/recording/SKILL.md +23 -0
- package/corpus/templates/clips/AGENTS.md +10 -0
- package/corpus/templates/clips/actions/create-dictation.ts +29 -4
- package/corpus/templates/clips/actions/finalize-recording.ts +2 -4
- package/corpus/templates/clips/actions/get-recording-player-data.ts +16 -6
- package/corpus/templates/clips/actions/set-mobile-capture-state.ts +37 -0
- package/corpus/templates/clips/actions/update-dictation.ts +9 -1
- package/corpus/templates/clips/app/routes/r.$recordingId.tsx +13 -2
- package/corpus/templates/clips/app/routes/share.$shareId.tsx +13 -2
- package/corpus/templates/clips/changelog/2026-07-17-capture-dictation-background-meeting-audio-and-camera-videos.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-17-delayed-upload-verification-keeps-local-backups-retryable.md +6 -0
- package/corpus/templates/clips/desktop/src/app.tsx +17 -0
- package/corpus/templates/clips/desktop/src/lib/processing-backup-recovery.ts +19 -0
- package/corpus/templates/clips/desktop/src/lib/recorder.ts +60 -35
- package/corpus/templates/clips/desktop/src/lib/upload-verification.ts +12 -0
- package/corpus/templates/clips/desktop/src-tauri/src/lib.rs +1 -0
- package/corpus/templates/clips/desktop/src-tauri/src/native_screen.rs +14 -0
- package/corpus/templates/clips/server/db/schema.ts +9 -1
- package/corpus/templates/clips/server/lib/builder-media-compression.ts +59 -7
- package/corpus/templates/clips/server/lib/media-verification-state.ts +17 -0
- package/corpus/templates/clips/server/routes/api/public-recording.get.ts +7 -0
- package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/reset-chunks.post.ts +22 -1
- package/corpus/templates/plan/changelog/2026-07-17-diagrams-can-opt-into-a-polished-clean-rendering-mode-withou.md +6 -0
- package/corpus/templates/plan/shared/plan-content.ts +3 -0
- package/dist/client/blocks/library/diagram.config.d.ts +2 -0
- package/dist/client/blocks/library/diagram.config.d.ts.map +1 -1
- package/dist/client/blocks/library/diagram.config.js +8 -1
- package/dist/client/blocks/library/diagram.config.js.map +1 -1
- package/dist/client/blocks/library/diagram.d.ts.map +1 -1
- package/dist/client/blocks/library/diagram.js +9 -4
- package/dist/client/blocks/library/diagram.js.map +1 -1
- package/dist/collab/struct-routes.d.ts +1 -1
- package/dist/deploy/build.d.ts +3 -0
- package/dist/deploy/build.d.ts.map +1 -1
- package/dist/deploy/build.js +45 -1
- package/dist/deploy/build.js.map +1 -1
- package/dist/observability/routes.d.ts +5 -5
- package/dist/progress/routes.d.ts +1 -1
- package/dist/resources/handlers.d.ts +2 -2
- package/dist/secrets/routes.d.ts +9 -9
- package/docs/content/integrations.mdx +87 -253
- package/package.json +1 -1
|
@@ -138,6 +138,14 @@ export const CLOUDFLARE_WORKER_STUB_MODULES: Record<string, string> = {
|
|
|
138
138
|
"export default { Resvg };",
|
|
139
139
|
"",
|
|
140
140
|
].join("\n"),
|
|
141
|
+
playwright: [
|
|
142
|
+
"const unavailable = async () => { throw new Error('playwright unavailable in Cloudflare Pages worker'); };",
|
|
143
|
+
"export const chromium = { launch: unavailable, connect: unavailable, connectOverCDP: unavailable };",
|
|
144
|
+
"export const firefox = { launch: unavailable, connect: unavailable };",
|
|
145
|
+
"export const webkit = { launch: unavailable, connect: unavailable };",
|
|
146
|
+
"export default { chromium, firefox, webkit };",
|
|
147
|
+
"",
|
|
148
|
+
].join("\n"),
|
|
141
149
|
"playwright-core": [
|
|
142
150
|
"const unavailable = async () => { throw new Error('playwright-core unavailable in Cloudflare Pages worker'); };",
|
|
143
151
|
"export const chromium = { launch: unavailable };",
|
|
@@ -187,6 +195,50 @@ export const CLOUDFLARE_WORKER_STUB_MODULES: Record<string, string> = {
|
|
|
187
195
|
"export default async () => ({ elements: [], files: {} });\n",
|
|
188
196
|
};
|
|
189
197
|
|
|
198
|
+
export const CLOUDFLARE_WORKER_STUB_SUBPATH_MODULES: Record<string, string> = {
|
|
199
|
+
"pdf-parse/worker": [
|
|
200
|
+
"const unavailable = async () => { throw new Error('pdf-parse/worker unavailable in Cloudflare Pages worker'); };",
|
|
201
|
+
"export class CanvasFactory {}",
|
|
202
|
+
"export const getData = unavailable;",
|
|
203
|
+
"export default { CanvasFactory, getData };",
|
|
204
|
+
"",
|
|
205
|
+
].join("\n"),
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
export function cloudflareWorkerStubAliasArgs(stubDir: string): string[] {
|
|
209
|
+
const subpathAliases = Object.keys(CLOUDFLARE_WORKER_STUB_SUBPATH_MODULES)
|
|
210
|
+
.sort((a, b) => b.length - a.length)
|
|
211
|
+
.map(
|
|
212
|
+
(mod) =>
|
|
213
|
+
`--alias:${mod}=${path.join(stubDir, `${mod.replace(/\//g, "__")}.js`)}`,
|
|
214
|
+
);
|
|
215
|
+
const packageAliases = Object.keys(CLOUDFLARE_WORKER_STUB_MODULES)
|
|
216
|
+
.sort((a, b) => b.length - a.length)
|
|
217
|
+
.map((mod) => `--alias:${mod}=${path.join(stubDir, mod, "index.js")}`);
|
|
218
|
+
return [...subpathAliases, ...packageAliases];
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export function assertNoCloudflareWorkerStubDynamicImports(
|
|
222
|
+
code: string,
|
|
223
|
+
sourceName: string,
|
|
224
|
+
): void {
|
|
225
|
+
const stubbedModules = [
|
|
226
|
+
...Object.keys(CLOUDFLARE_WORKER_STUB_SUBPATH_MODULES),
|
|
227
|
+
...Object.keys(CLOUDFLARE_WORKER_STUB_MODULES),
|
|
228
|
+
];
|
|
229
|
+
const pattern = stubbedModules
|
|
230
|
+
.sort((a, b) => b.length - a.length)
|
|
231
|
+
.map((moduleName) => moduleName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
|
|
232
|
+
.join("|");
|
|
233
|
+
const unresolvedImport = code.match(
|
|
234
|
+
new RegExp(`\\bimport\\s*\\(\\s*(["'])(${pattern})\\1\\s*\\)`),
|
|
235
|
+
);
|
|
236
|
+
if (!unresolvedImport) return;
|
|
237
|
+
throw new Error(
|
|
238
|
+
`Cloudflare worker output ${sourceName} retained a dynamic import for stubbed module "${unresolvedImport[2]}". Use a literal import so the worker bundler can apply its fail-closed stub.`,
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
|
|
190
242
|
function cloudflareNodeBuiltinStubSource(
|
|
191
243
|
moduleName: string,
|
|
192
244
|
namedExports: string[],
|
|
@@ -1666,9 +1718,15 @@ async function buildCloudflarePages() {
|
|
|
1666
1718
|
JSON.stringify({ name: mod, main: "index.js", type: "module" }),
|
|
1667
1719
|
);
|
|
1668
1720
|
}
|
|
1669
|
-
const
|
|
1670
|
-
|
|
1671
|
-
)
|
|
1721
|
+
for (const [mod, source] of Object.entries(
|
|
1722
|
+
CLOUDFLARE_WORKER_STUB_SUBPATH_MODULES,
|
|
1723
|
+
)) {
|
|
1724
|
+
fs.writeFileSync(
|
|
1725
|
+
path.join(stubDir, `${mod.replace(/\//g, "__")}.js`),
|
|
1726
|
+
source,
|
|
1727
|
+
);
|
|
1728
|
+
}
|
|
1729
|
+
const stubAliases = cloudflareWorkerStubAliasArgs(stubDir);
|
|
1672
1730
|
const nodeBuiltinStubDir = path.join(tmpDir, "node-builtin-stubs");
|
|
1673
1731
|
fs.mkdirSync(nodeBuiltinStubDir, { recursive: true });
|
|
1674
1732
|
const nodeBuiltinStubAliases: string[] = [];
|
|
@@ -1850,6 +1908,8 @@ async function buildCloudflarePages() {
|
|
|
1850
1908
|
);
|
|
1851
1909
|
}
|
|
1852
1910
|
|
|
1911
|
+
assertNoCloudflareWorkerStubDynamicImports(code, jsFile);
|
|
1912
|
+
|
|
1853
1913
|
fs.writeFileSync(jsFile, code);
|
|
1854
1914
|
}
|
|
1855
1915
|
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: dictate
|
|
3
3
|
description: >-
|
|
4
|
-
The Dictate tab in Clips — press-and-hold dictation
|
|
5
|
-
AI cleanup, and
|
|
6
|
-
past dictations, polishing dictation text, or wiring
|
|
4
|
+
The Dictate tab in Clips — press-and-hold desktop dictation, mobile voice
|
|
5
|
+
dictation, history, AI cleanup, and native capture hand-offs. Use when
|
|
6
|
+
listing past dictations, polishing dictation text, or wiring native capture.
|
|
7
7
|
---
|
|
8
8
|
|
|
9
9
|
# Dictate
|
|
@@ -25,7 +25,9 @@ The press-and-hold UX intentionally mirrors **Wispr Flow**. See `templates/clips
|
|
|
25
25
|
|
|
26
26
|
## Data model touched
|
|
27
27
|
|
|
28
|
-
- **`dictations`** — `id`, `fullText`, `cleanedText`, `durationMs`, `audioUrl`,
|
|
28
|
+
- **`dictations`** — `id`, `fullText`, `cleanedText`, `durationMs`, `audioUrl`,
|
|
29
|
+
`source` (desktop shortcuts, `manual`, or `mobile`), `createdAt`,
|
|
30
|
+
`ownableColumns()`.
|
|
29
31
|
|
|
30
32
|
`fullText` is the raw native transcript captured on-device (macOS Speech / Web Speech). `cleanedText` is the optional AI-polished version produced by the shared `cleanup-transcript` pipeline. UI shows `cleanedText ?? fullText`.
|
|
31
33
|
|
|
@@ -81,7 +83,28 @@ The press-and-hold flow is **owned by the desktop app** (`src-tauri/`). On Hold-
|
|
|
81
83
|
3. The dictation row is created via the framework HTTP layer — the agent does not start/stop dictations.
|
|
82
84
|
4. AI cleanup is a background pass via `cleanup-dictation`, not in the hot path — never block paste-on-release on a network round-trip.
|
|
83
85
|
|
|
84
|
-
Agents must **never** wire dictation start/stop server-side.
|
|
86
|
+
Agents must **never** wire dictation start/stop server-side. Desktop key
|
|
87
|
+
listeners and the mobile capture UI own those user gestures.
|
|
88
|
+
|
|
89
|
+
## Mobile dictation
|
|
90
|
+
|
|
91
|
+
The Agent Native iOS/Android app exposes Dictate from native Home, deep links,
|
|
92
|
+
and OS quick actions. Mobile is click-to-toggle rather than hold-to-talk:
|
|
93
|
+
|
|
94
|
+
1. `expo-audio` records mic-only M4A and persists it under the app documents
|
|
95
|
+
directory before any network request.
|
|
96
|
+
2. A durable capture-queue row protects the audio across interruptions, app
|
|
97
|
+
restarts, and upload failures.
|
|
98
|
+
3. The named mobile voice client posts multipart audio to the authenticated
|
|
99
|
+
`/_agent-native/transcribe-voice` route. Provider keys never enter the app.
|
|
100
|
+
4. The cleaned transcript is editable, copied to the OS clipboard, and saved
|
|
101
|
+
with `create-dictation --source=mobile`; edits use `update-dictation`.
|
|
102
|
+
5. The audio also syncs to Clips through the resumable recording upload path so
|
|
103
|
+
a failed transcription never destroys the captured speech.
|
|
104
|
+
|
|
105
|
+
Do not auto-send mobile dictation to an agent or another app. iOS/Android quick
|
|
106
|
+
actions open capture; clipboard is the cross-app fallback until platform
|
|
107
|
+
keyboard/accessibility insertion is enabled.
|
|
85
108
|
|
|
86
109
|
## UI conventions (don't break)
|
|
87
110
|
|
|
@@ -47,6 +47,13 @@ Meeting capture records **two streams** and tags transcript segments by source:
|
|
|
47
47
|
|
|
48
48
|
Each transcript segment carries a `source: "mic" | "system"` tag, which we use to attribute action items to the right attendee. This is why **per-attendee action items only work reliably with mic + system capture** — mic-only recordings make remote attendees silent. Document this caveat whenever you surface action items.
|
|
49
49
|
|
|
50
|
+
The iOS/Android companion is intentionally different: mobile background meeting
|
|
51
|
+
capture is microphone-only because the operating systems do not expose another
|
|
52
|
+
phone app's Zoom/Meet/Teams audio. It is appropriate for in-person rooms and for
|
|
53
|
+
capturing audio played from a separate device. Mobile recordings still receive
|
|
54
|
+
Clips transcription and summaries, but do not promise desktop-quality remote
|
|
55
|
+
speaker attribution or per-attendee action items.
|
|
56
|
+
|
|
50
57
|
## Bidirectional recording ↔ meeting link
|
|
51
58
|
|
|
52
59
|
A meeting can have an associated recording, and a recording can be linked back to a meeting:
|
|
@@ -38,6 +38,27 @@ Some recordings are linked to a meeting — when `meeting_id` is non-null on the
|
|
|
38
38
|
`processing` to `ready`. Do not wait for the upload/finalize response before
|
|
39
39
|
opening the page.
|
|
40
40
|
|
|
41
|
+
## Mobile companion lifecycle
|
|
42
|
+
|
|
43
|
+
The Agent Native mobile app uses the same recording rows and binary upload
|
|
44
|
+
routes with native capture primitives:
|
|
45
|
+
|
|
46
|
+
1. Camera video/import uses `expo-camera` / the system photo picker; meeting
|
|
47
|
+
audio uses `expo-audio` with background recording enabled.
|
|
48
|
+
2. The native file is copied into the documents directory and a typed
|
|
49
|
+
AsyncStorage capture job is written before upload starts.
|
|
50
|
+
3. `create-recording` receives a stable client-generated id,
|
|
51
|
+
`sourceAppName: "Agent Native Mobile"`, and the container MIME type.
|
|
52
|
+
4. Upload reads at most 3 MiB through an Expo FileHandle and persists the next
|
|
53
|
+
chunk index after every acknowledged POST. The 4 MiB server cap still
|
|
54
|
+
applies.
|
|
55
|
+
5. Foreground/resume reconciliation polls `/api/uploads/:id/status`; retryable
|
|
56
|
+
failures back off and never discard the local file. Completion can post a
|
|
57
|
+
local notification and the recording appears in Clips everywhere.
|
|
58
|
+
|
|
59
|
+
Mobile audio M4A is an MP4 container and uses the recording route's MP4 MIME
|
|
60
|
+
alias. The bytes are not converted or loaded whole into JavaScript memory.
|
|
61
|
+
|
|
41
62
|
## Seekable playback (don't ship raw MediaRecorder output)
|
|
42
63
|
|
|
43
64
|
Raw `MediaRecorder` files are not friendly to progressive HTTP playback, which
|
|
@@ -191,6 +212,8 @@ export function useRecorder() {
|
|
|
191
212
|
- **Never** start a `MediaRecorder` without a user gesture (or a user-initiated `record-intent`).
|
|
192
213
|
- **Never** re-prompt for permissions on pause/resume — reuse the stream.
|
|
193
214
|
- **Never** fire the upload from the main thread if the chunks are large — prefer a web worker for anything longer than ~60s.
|
|
215
|
+
- **Never** read a complete mobile recording into JavaScript memory. Use bounded
|
|
216
|
+
FileHandle reads and checkpoint every acknowledged chunk.
|
|
194
217
|
- The `recordings` row must exist **before** the first chunk is sent.
|
|
195
218
|
- On every lifecycle change, write `navigation` → `{ view: "record" }` → `{ view: "recording", recordingId }` so the agent can see what's happening.
|
|
196
219
|
- All AI generated during/after recording goes through the agent chat — see `ai-video-tools`.
|
|
@@ -51,6 +51,16 @@ Detailed media, meeting, dictation, editing, and sharing rules live in
|
|
|
51
51
|
`previewTruncated` is true, it may end mid-sentence and does not show where
|
|
52
52
|
transcription ended. Call `get-recording-player-data` before judging
|
|
53
53
|
completeness or quoting the full transcript.
|
|
54
|
+
- Mobile captures originate in the Agent Native iOS/Android app. The phone
|
|
55
|
+
persists each audio/video file before network work, creates the recording
|
|
56
|
+
with `sourceAppName: "Agent Native Mobile"`, and resumes bounded chunk
|
|
57
|
+
uploads from its durable queue. Do not ask users to keep a capture screen
|
|
58
|
+
open or re-record after a transient upload failure; reconnect Clips and retry
|
|
59
|
+
the saved job from mobile Home.
|
|
60
|
+
- Mobile meeting capture is microphone-only. It can record an in-person room or
|
|
61
|
+
audio playing from another device, but iOS and Android do not expose another
|
|
62
|
+
phone app's Zoom/Meet/Teams call audio to this flow. Do not claim mobile
|
|
63
|
+
attendee attribution has the desktop mic-plus-system-audio fidelity.
|
|
54
64
|
- Dictation cleanup, Clip title/cleanup, and meeting summaries should pass
|
|
55
65
|
bounded `voiceContext` to the shared cleanup/transcription path when active
|
|
56
66
|
app context, learned vocabulary, user notes, or AGENTS.md preferences are
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Create a dictation row from browser or
|
|
2
|
+
* Create a dictation row from browser, desktop, or mobile voice capture.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import { defineAction } from "@agent-native/core";
|
|
6
6
|
import { writeAppState } from "@agent-native/core/application-state";
|
|
7
|
+
import { and, eq } from "drizzle-orm";
|
|
7
8
|
import { z } from "zod";
|
|
8
9
|
|
|
9
10
|
import { getDb, schema } from "../server/db/index.js";
|
|
@@ -11,18 +12,28 @@ import {
|
|
|
11
12
|
getActiveOrganizationId,
|
|
12
13
|
getCurrentOwnerEmail,
|
|
13
14
|
nanoid,
|
|
15
|
+
ownerEmailMatches,
|
|
14
16
|
} from "../server/lib/recordings.js";
|
|
15
17
|
|
|
16
18
|
export default defineAction({
|
|
17
19
|
description:
|
|
18
|
-
"Create a dictation history row from captured voice text. Used by browser
|
|
20
|
+
"Create a dictation history row from captured voice text. Used by browser, desktop, and mobile voice capture.",
|
|
19
21
|
schema: z.object({
|
|
22
|
+
id: z.string().min(1).optional().describe("Stable client-generated id"),
|
|
20
23
|
fullText: z.string().min(1).describe("Raw dictation text"),
|
|
21
24
|
cleanedText: z.string().nullable().optional(),
|
|
22
25
|
durationMs: z.coerce.number().int().min(0).optional(),
|
|
23
26
|
audioUrl: z.string().url().nullable().optional(),
|
|
24
27
|
source: z
|
|
25
|
-
.enum([
|
|
28
|
+
.enum([
|
|
29
|
+
"fn-hold",
|
|
30
|
+
"cmd-shift-space",
|
|
31
|
+
"manual",
|
|
32
|
+
"mobile",
|
|
33
|
+
"other",
|
|
34
|
+
"fn",
|
|
35
|
+
"custom",
|
|
36
|
+
])
|
|
26
37
|
.default("manual"),
|
|
27
38
|
targetApp: z.string().nullable().optional(),
|
|
28
39
|
startedAt: z.string().datetime().optional(),
|
|
@@ -32,10 +43,24 @@ export default defineAction({
|
|
|
32
43
|
const ownerEmail = getCurrentOwnerEmail();
|
|
33
44
|
const orgId = await getActiveOrganizationId().catch(() => null);
|
|
34
45
|
const now = new Date().toISOString();
|
|
35
|
-
const id = nanoid();
|
|
46
|
+
const id = args.id ?? nanoid();
|
|
36
47
|
const fullText = args.fullText.trim();
|
|
37
48
|
if (!fullText) throw new Error("Dictation text is required");
|
|
38
49
|
|
|
50
|
+
if (args.id) {
|
|
51
|
+
const [existing] = await db
|
|
52
|
+
.select()
|
|
53
|
+
.from(schema.dictations)
|
|
54
|
+
.where(
|
|
55
|
+
and(
|
|
56
|
+
eq(schema.dictations.id, id),
|
|
57
|
+
ownerEmailMatches(schema.dictations.ownerEmail, ownerEmail),
|
|
58
|
+
),
|
|
59
|
+
)
|
|
60
|
+
.limit(1);
|
|
61
|
+
if (existing) return existing;
|
|
62
|
+
}
|
|
63
|
+
|
|
39
64
|
await db.insert(schema.dictations).values({
|
|
40
65
|
id,
|
|
41
66
|
fullText,
|
|
@@ -204,9 +204,7 @@ async function verifyServedMediaUrl(videoUrl: string): Promise<number | null> {
|
|
|
204
204
|
const statusOk = response.status === 200 || response.status === 206;
|
|
205
205
|
if (statusOk) {
|
|
206
206
|
const servedBytes = servedMediaSizeBytes(response);
|
|
207
|
-
if (
|
|
208
|
-
lastFailure = "Stored media byte count could not be verified";
|
|
209
|
-
} else if (await responseHasReadableMediaBytes(response)) {
|
|
207
|
+
if (await responseHasReadableMediaBytes(response)) {
|
|
210
208
|
// Builder stable video URLs intentionally replace the source object
|
|
211
209
|
// with a smaller compressed generation at the same URL. A positive,
|
|
212
210
|
// readable object is the invariant here; source-byte equality is
|
|
@@ -1670,7 +1668,7 @@ export default defineAction({
|
|
|
1670
1668
|
servedBytes = await verifyServedMediaUrl(upload.url);
|
|
1671
1669
|
} catch (err) {
|
|
1672
1670
|
const failureReason = err instanceof Error ? err.message : String(err);
|
|
1673
|
-
return leaveRecordingProcessingForMediaVerification({
|
|
1671
|
+
return await leaveRecordingProcessingForMediaVerification({
|
|
1674
1672
|
id,
|
|
1675
1673
|
ownerEmail,
|
|
1676
1674
|
failureReason,
|
|
@@ -29,6 +29,7 @@ import { and, asc, eq, or, sql } from "drizzle-orm";
|
|
|
29
29
|
import { z } from "zod";
|
|
30
30
|
|
|
31
31
|
import { getDb, schema } from "../server/db/index.js";
|
|
32
|
+
import { isMediaVerificationPending } from "../server/lib/media-verification-state.js";
|
|
32
33
|
import { resolvePlayerVideoUrl } from "../server/lib/player-video-url.js";
|
|
33
34
|
import {
|
|
34
35
|
canOpenDirectRecordingPage,
|
|
@@ -178,12 +179,20 @@ export default defineAction({
|
|
|
178
179
|
access.role === "owner" ||
|
|
179
180
|
access.role === "admin" ||
|
|
180
181
|
access.role === "editor";
|
|
181
|
-
const [cleanupStateRaw, builderCreditsRaw] =
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
182
|
+
const [cleanupStateRaw, builderCreditsRaw, verificationPending] =
|
|
183
|
+
await Promise.all([
|
|
184
|
+
readAppState(`transcript-cleanup-${args.recordingId}`).catch(
|
|
185
|
+
() => null,
|
|
186
|
+
),
|
|
187
|
+
canEditRecording
|
|
188
|
+
? readAppState(CLIPS_BUILDER_CREDITS_STATE_KEY).catch(() => null)
|
|
189
|
+
: Promise.resolve(null),
|
|
190
|
+
isMediaVerificationPending({
|
|
191
|
+
ownerEmail: rec.ownerEmail,
|
|
192
|
+
recordingId: args.recordingId,
|
|
193
|
+
recordingStatus: rec.status,
|
|
194
|
+
}),
|
|
195
|
+
]);
|
|
187
196
|
const cleanupState =
|
|
188
197
|
cleanupStateRaw && typeof cleanupStateRaw === "object"
|
|
189
198
|
? (cleanupStateRaw as Record<string, unknown>)
|
|
@@ -326,6 +335,7 @@ export default defineAction({
|
|
|
326
335
|
hasAudio: Boolean(rec.hasAudio),
|
|
327
336
|
hasCamera: Boolean(rec.hasCamera),
|
|
328
337
|
status: rec.status,
|
|
338
|
+
verificationPending,
|
|
329
339
|
uploadProgress: rec.uploadProgress,
|
|
330
340
|
failureReason: rec.failureReason,
|
|
331
341
|
// Don't leak the password to clients (especially to MCP hosts that
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { defineAction } from "@agent-native/core/action";
|
|
2
|
+
import { writeAppState } from "@agent-native/core/application-state";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
|
|
5
|
+
export const MOBILE_CAPTURE_STATE_KEY = "mobile-capture-state";
|
|
6
|
+
|
|
7
|
+
const mobileCaptureStateSchema = z
|
|
8
|
+
.object({
|
|
9
|
+
view: z.enum(["home", "dictate", "meeting", "video"]),
|
|
10
|
+
phase: z.enum([
|
|
11
|
+
"idle",
|
|
12
|
+
"ready",
|
|
13
|
+
"recording",
|
|
14
|
+
"paused",
|
|
15
|
+
"saving",
|
|
16
|
+
"processing",
|
|
17
|
+
"review",
|
|
18
|
+
"error",
|
|
19
|
+
]),
|
|
20
|
+
captureId: z.string().trim().min(1).max(200).optional(),
|
|
21
|
+
})
|
|
22
|
+
.strict();
|
|
23
|
+
|
|
24
|
+
export default defineAction({
|
|
25
|
+
description:
|
|
26
|
+
"Record the privacy-safe view and phase of the Agent Native mobile capture companion.",
|
|
27
|
+
agentTool: false,
|
|
28
|
+
schema: mobileCaptureStateSchema,
|
|
29
|
+
run: async (args) => {
|
|
30
|
+
const state = {
|
|
31
|
+
...args,
|
|
32
|
+
updatedAt: new Date().toISOString(),
|
|
33
|
+
};
|
|
34
|
+
await writeAppState(MOBILE_CAPTURE_STATE_KEY, state);
|
|
35
|
+
return state;
|
|
36
|
+
},
|
|
37
|
+
});
|
|
@@ -20,7 +20,15 @@ export default defineAction({
|
|
|
20
20
|
fullText: z.string().optional(),
|
|
21
21
|
cleanedText: z.string().nullable().optional(),
|
|
22
22
|
source: z
|
|
23
|
-
.enum([
|
|
23
|
+
.enum([
|
|
24
|
+
"fn-hold",
|
|
25
|
+
"cmd-shift-space",
|
|
26
|
+
"manual",
|
|
27
|
+
"mobile",
|
|
28
|
+
"other",
|
|
29
|
+
"fn",
|
|
30
|
+
"custom",
|
|
31
|
+
])
|
|
24
32
|
.optional(),
|
|
25
33
|
targetApp: z.string().nullable().optional(),
|
|
26
34
|
}),
|
|
@@ -376,6 +376,7 @@ export default function RecordingPage() {
|
|
|
376
376
|
}, [recordingId, playerDataForbidden, navigate]);
|
|
377
377
|
|
|
378
378
|
const recording = playerDataQ.data?.recording;
|
|
379
|
+
const verificationPending = recording?.verificationPending === true;
|
|
379
380
|
const role = playerDataQ.data?.role as
|
|
380
381
|
| "owner"
|
|
381
382
|
| "admin"
|
|
@@ -781,13 +782,22 @@ export default function RecordingPage() {
|
|
|
781
782
|
setProcessingTimeout(false);
|
|
782
783
|
return;
|
|
783
784
|
}
|
|
785
|
+
if (verificationPending) {
|
|
786
|
+
setProcessingTimeout(false);
|
|
787
|
+
return;
|
|
788
|
+
}
|
|
784
789
|
const timeoutMs =
|
|
785
790
|
recording.status === "processing"
|
|
786
791
|
? PROCESSING_STUCK_TIMEOUT_MS
|
|
787
792
|
: UPLOAD_STUCK_TIMEOUT_MS;
|
|
788
793
|
const handle = setTimeout(() => setProcessingTimeout(true), timeoutMs);
|
|
789
794
|
return () => clearTimeout(handle);
|
|
790
|
-
}, [
|
|
795
|
+
}, [
|
|
796
|
+
recording?.status,
|
|
797
|
+
recording?.videoUrl,
|
|
798
|
+
recordingId,
|
|
799
|
+
verificationPending,
|
|
800
|
+
]);
|
|
791
801
|
|
|
792
802
|
usePlayerShortcuts({ playerRef, chapters });
|
|
793
803
|
|
|
@@ -873,7 +883,8 @@ export default function RecordingPage() {
|
|
|
873
883
|
isNativeSaveFailureReason(rawFailureReason);
|
|
874
884
|
// Give a long-running desktop save an actionable recovery state without
|
|
875
885
|
// claiming the upload failed while its bounded final request is still live.
|
|
876
|
-
const stuckFailure =
|
|
886
|
+
const stuckFailure =
|
|
887
|
+
!explicitFailure && !verificationPending && processingTimeout;
|
|
877
888
|
const isFailure = explicitFailure || waitingForStorage || nativeSaveFailed;
|
|
878
889
|
const showRecoveryState = isFailure || stuckFailure;
|
|
879
890
|
const displayReason = explicitFailure
|
|
@@ -478,6 +478,7 @@ export default function ShareRoute() {
|
|
|
478
478
|
});
|
|
479
479
|
|
|
480
480
|
const recording = dataQ.data?.data?.recording;
|
|
481
|
+
const verificationPending = recording?.verificationPending === true;
|
|
481
482
|
const comments = dataQ.data?.data?.comments ?? [];
|
|
482
483
|
const reactions = dataQ.data?.data?.reactions ?? [];
|
|
483
484
|
const chapters = dataQ.data?.data?.chapters ?? [];
|
|
@@ -560,6 +561,10 @@ export default function ShareRoute() {
|
|
|
560
561
|
setProcessingTimeout(false);
|
|
561
562
|
return;
|
|
562
563
|
}
|
|
564
|
+
if (verificationPending) {
|
|
565
|
+
setProcessingTimeout(false);
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
563
568
|
|
|
564
569
|
const timeoutMs =
|
|
565
570
|
recording.status === "processing"
|
|
@@ -567,7 +572,12 @@ export default function ShareRoute() {
|
|
|
567
572
|
: UPLOAD_STUCK_TIMEOUT_MS;
|
|
568
573
|
const handle = setTimeout(() => setProcessingTimeout(true), timeoutMs);
|
|
569
574
|
return () => clearTimeout(handle);
|
|
570
|
-
}, [
|
|
575
|
+
}, [
|
|
576
|
+
recording?.id,
|
|
577
|
+
recording?.status,
|
|
578
|
+
recording?.videoUrl,
|
|
579
|
+
verificationPending,
|
|
580
|
+
]);
|
|
571
581
|
|
|
572
582
|
usePlayerShortcuts({ playerRef });
|
|
573
583
|
|
|
@@ -712,7 +722,8 @@ export default function ShareRoute() {
|
|
|
712
722
|
const storageSetupFailure = isStorageSetupFailureReason(rawFailureReason);
|
|
713
723
|
const loomStorageSetupFailure =
|
|
714
724
|
storageSetupFailure && isLoomRecordingSource(recording);
|
|
715
|
-
const stuckFailure =
|
|
725
|
+
const stuckFailure =
|
|
726
|
+
!explicitFailure && !verificationPending && processingTimeout;
|
|
716
727
|
const isFailure = explicitFailure || storageSetupFailure || stuckFailure;
|
|
717
728
|
const canManageStorage = viewerCanEdit;
|
|
718
729
|
const signInHref = buildSignInHref(`/r/${recording.id}`);
|
|
@@ -1835,6 +1835,23 @@ export function App() {
|
|
|
1835
1835
|
);
|
|
1836
1836
|
}, []);
|
|
1837
1837
|
|
|
1838
|
+
useEffect(() => {
|
|
1839
|
+
let unlisten: (() => void) | null = null;
|
|
1840
|
+
let cancelled = false;
|
|
1841
|
+
listen("clips:pending-uploads-changed", () => {
|
|
1842
|
+
void loadPendingUploads();
|
|
1843
|
+
})
|
|
1844
|
+
.then((stop) => {
|
|
1845
|
+
if (cancelled) stop();
|
|
1846
|
+
else unlisten = stop;
|
|
1847
|
+
})
|
|
1848
|
+
.catch(() => {});
|
|
1849
|
+
return () => {
|
|
1850
|
+
cancelled = true;
|
|
1851
|
+
unlisten?.();
|
|
1852
|
+
};
|
|
1853
|
+
}, [loadPendingUploads]);
|
|
1854
|
+
|
|
1838
1855
|
useEffect(() => {
|
|
1839
1856
|
if (popoverView === "meetings" && popoverVisible) {
|
|
1840
1857
|
void fetchUpcomingMeetings();
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export async function reconcileProcessingBackup(args: {
|
|
2
|
+
waitForReady: () => Promise<{ status?: string } | null>;
|
|
3
|
+
onReady: () => Promise<unknown>;
|
|
4
|
+
onUnresolved: () => Promise<unknown>;
|
|
5
|
+
onPollError?: (error: unknown) => void;
|
|
6
|
+
}): Promise<"ready" | "unresolved"> {
|
|
7
|
+
try {
|
|
8
|
+
const recovered = await args.waitForReady();
|
|
9
|
+
if (recovered?.status === "ready") {
|
|
10
|
+
await args.onReady();
|
|
11
|
+
return "ready";
|
|
12
|
+
}
|
|
13
|
+
} catch (error) {
|
|
14
|
+
args.onPollError?.(error);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
await args.onUnresolved();
|
|
18
|
+
return "unresolved";
|
|
19
|
+
}
|