@agent-native/core 0.175.4 → 0.176.0-nightly-20260826210104
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/templates/clips/.agents/skills/ai-video-tools/SKILL.md +8 -7
- package/corpus/templates/clips/.agents/skills/recording/SKILL.md +9 -8
- package/corpus/templates/clips/actions/import-loom-recording.ts +1 -1
- package/corpus/templates/clips/actions/lib/loom-import-job.ts +52 -35
- package/corpus/templates/clips/actions/request-transcript.ts +6 -183
- package/corpus/templates/clips/app/components/clips-avatar.tsx +46 -0
- package/corpus/templates/clips/app/components/library/recording-card.tsx +8 -7
- package/corpus/templates/clips/app/components/library/space-card.tsx +8 -5
- package/corpus/templates/clips/app/components/meetings/attendee-stack.tsx +7 -8
- package/corpus/templates/clips/app/components/meetings/transcript-bubbles.tsx +19 -12
- package/corpus/templates/clips/app/components/player/transcript-panel.tsx +128 -94
- package/corpus/templates/clips/app/components/workspace/members-list.tsx +8 -6
- package/corpus/templates/clips/app/components/workspace/notifications-list.tsx +8 -6
- package/corpus/templates/clips/app/components/workspace/top-creators-table.tsx +8 -6
- package/corpus/templates/clips/app/routes/_app.meetings.$meetingId.tsx +8 -7
- package/corpus/templates/clips/app/routes/_app.settings._index.tsx +0 -26
- package/corpus/templates/clips/app/routes/r.$recordingId.tsx +1 -4
- package/corpus/templates/clips/changelog/2026-08-26-detected-calls-no-longer-duplicate-meetings-or-strand-the-prompt.md +6 -0
- package/corpus/templates/clips/desktop/src/overlays/meeting-notification.tsx +39 -22
- package/corpus/templates/clips/desktop/src-tauri/src/adhoc_meetings_watcher.rs +1765 -219
- package/corpus/templates/clips/desktop/src-tauri/src/lib.rs +4 -0
- package/corpus/templates/clips/desktop/src-tauri/src/meetings_watcher.rs +45 -30
- package/corpus/templates/clips/desktop/src-tauri/src/notifications.rs +91 -0
- package/corpus/templates/clips/server/routes/_agent-native/google/callback.get.ts +16 -0
- package/corpus/templates/clips/shared/clips-ai-prefs.ts +0 -1
- package/corpus/templates/content/app/components/editor/CommentsSidebar.tsx +44 -22
- package/corpus/templates/content/app/components/ui/avatar.tsx +1 -0
- package/corpus/templates/design/app/components/visual-editor/ReviewCanvasPins.tsx +4 -1
- package/corpus/templates/mail/actions/get-email.ts +68 -21
- package/corpus/templates/mail/actions/get-thread.ts +92 -51
- package/corpus/templates/mail/app/hooks/use-emails.ts +7 -4
- package/corpus/templates/mail/server/lib/mail-connector-catalog.ts +7 -4
- package/corpus/templates/plan/app/pages/PlansPage.tsx +23 -15
- package/corpus/templates/slides/app/components/comments/SlideCommentsPanel.tsx +18 -7
- package/corpus/templates/slides/app/components/ui/avatar.tsx +1 -0
- package/corpus/templates/tasks/DEVELOPING.md +8 -0
- package/corpus/templates/tasks/drizzle.config.ts +3 -1
- package/corpus/templates/tasks/drizzle.local.config.ts +6 -0
- package/corpus/templates/tasks/package.json +2 -1
- package/corpus/templates/tasks/server/db/migrations/meta/0000_snapshot.json +234 -0
- package/corpus/templates/tasks/server/db/migrations/meta/_journal.json +13 -0
- package/corpus/templates/tasks/server/db/migrations.ts +110 -0
- package/corpus/templates/tasks/server/plugins/db.ts +3 -87
- package/dist/client/review/ReviewThreadPanel.js +4 -2
- package/dist/client/use-action.d.ts +2 -0
- package/dist/client/use-action.js +4 -2
- package/dist/collab/client.js +20 -5
- package/dist/collab/presence.d.ts +3 -1
- package/dist/collab/presence.js +48 -45
- package/dist/db/drizzle-config.d.ts +7 -0
- package/dist/db/drizzle-config.js +35 -9
- package/dist/db/drizzle-migrations.d.ts +28 -0
- package/dist/db/drizzle-migrations.js +81 -0
- package/dist/db/index.d.ts +1 -1
- package/dist/db/migrations.d.ts +13 -1
- package/dist/db/migrations.js +60 -31
- package/dist/db/schema.js +4 -1
- package/dist/deploy/build.d.ts +6 -0
- package/dist/deploy/build.js +41 -0
- package/dist/observability/routes.d.ts +3 -3
- package/dist/provider-api/actions/custom-provider-registration.d.ts +11 -11
- package/dist/provider-api/actions/provider-api.d.ts +9 -9
- package/dist/secrets/routes.d.ts +9 -9
- package/dist/server/agent-engine-api-key-route.d.ts +1 -1
- package/dist/server/auth.js +3 -2
- package/dist/server/builder-browser.d.ts +0 -4
- package/dist/server/builder-browser.js +23 -8
- package/dist/server/google-oauth.d.ts +18 -0
- package/dist/server/google-oauth.js +3 -3
- package/dist/server/short-lived-token.d.ts +1 -1
- package/dist/server/short-lived-token.js +1 -1
- package/dist/shared/google-profile-image.d.ts +2 -0
- package/dist/shared/google-profile-image.js +24 -0
- package/dist/shared/index.d.ts +1 -0
- package/dist/shared/index.js +1 -0
- package/docs/content/creating-templates.mdx +10 -1
- package/docs/content/server-database.mdx +46 -2
- package/docs/content/server-plugins.mdx +39 -3
- package/docs/content/webmcp.mdx +2 -0
- package/package.json +3 -2
package/corpus/README.md
CHANGED
|
@@ -141,18 +141,19 @@ removing overlapping duplicate speech and low-speech Whisper hallucinations. Kee
|
|
|
141
141
|
system audio enabled when the meeting audio comes from another app.
|
|
142
142
|
|
|
143
143
|
Native transcript first is a hard rule, not a preference: cloud transcription is
|
|
144
|
-
fallback-only for Clips recordings.
|
|
145
|
-
|
|
146
|
-
transcript behind failed metadata work, and keep heuristic titles
|
|
147
|
-
until the agent refinement lands.
|
|
144
|
+
fallback-only for Clips recordings. Recording transcripts keep the native text as
|
|
145
|
+
captured; `request-transcript` does not run automatic cleanup. Never hide a
|
|
146
|
+
usable native transcript behind failed metadata work, and keep heuristic titles
|
|
147
|
+
replaceable until the agent refinement lands. The standalone `cleanup-transcript`
|
|
148
|
+
action remains available for explicit dictation, meeting, and agent workflows.
|
|
148
149
|
|
|
149
150
|
Clips never routes recording/meeting audio to OpenAI for transcription. (Groq's endpoint is OpenAI-_compatible_ in request shape only — the audio goes to Groq, not OpenAI.)
|
|
150
151
|
|
|
151
152
|
If no native transcript exists and no cloud fallback is available (no Builder connection and no Groq key), the action writes `status="failed"` so the UI can show a friendly prompt.
|
|
152
153
|
|
|
153
|
-
When a transcript becomes ready, `request-transcript` must await
|
|
154
|
-
|
|
155
|
-
|
|
154
|
+
When a transcript becomes ready, `request-transcript` must await the metadata
|
|
155
|
+
handoff before its durable post-finalize worker returns. Do not leave
|
|
156
|
+
title/summary work as an unawaited promise in that worker:
|
|
156
157
|
serverless runtimes may freeze it immediately. A local heuristic title uses
|
|
157
158
|
`titleSource: "context"` so it remains a temporary UI fallback until the
|
|
158
159
|
`generate-metadata` agent request replaces it with a transcript-backed title.
|
|
@@ -15,7 +15,7 @@ Reach for this skill any time you touch the recorder: the record button, the in-
|
|
|
15
15
|
|
|
16
16
|
## Data model touched
|
|
17
17
|
|
|
18
|
-
- **`recordings`** — the row gets created as soon as the user presses Record or imports a source. Native/file recordings transition `uploading` → `processing` → `ready` (or `failed`). `videoUrl`, `durationMs`, `videoSizeBytes`, `width`, `height`, `hasAudio`, `hasCamera` are populated as the upload streams in. Loom imports use `import-loom-recording` and
|
|
18
|
+
- **`recordings`** — the row gets created as soon as the user presses Record or imports a source. Native/file recordings transition `uploading` → `processing` → `ready` (or `failed`). `videoUrl`, `durationMs`, `videoSizeBytes`, `width`, `height`, `hasAudio`, `hasCamera` are populated as the upload streams in. Loom imports use `import-loom-recording` and become ready with either a Clips-hosted MP4 or a Loom embed when MP4 export is unavailable.
|
|
19
19
|
- **`application_state.record-intent`** — the agent writes this when it wants to start a recording. The UI reads and clears it, then prompts for permission.
|
|
20
20
|
- **`application_state.navigation`** — set to `{ view: "record" }` while the recorder is active.
|
|
21
21
|
|
|
@@ -122,13 +122,14 @@ segments; never store Loom's signed CDN URLs.
|
|
|
122
122
|
When Loom exposes a downloadable public MP4, `import-loom-recording` downloads
|
|
123
123
|
it, reuploads the bytes to Clips storage, and creates a ready, playable
|
|
124
124
|
Clips-hosted recording, importing Loom's public transcript when the share page
|
|
125
|
-
exposes one. If Loom does not expose
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
Loom iframe and the native Clips editor
|
|
130
|
-
|
|
131
|
-
transcription
|
|
125
|
+
exposes one. If Loom allows public playback but does not expose an MP4, the
|
|
126
|
+
action keeps a ready, embed-backed recording and still imports the transcript
|
|
127
|
+
when available.
|
|
128
|
+
|
|
129
|
+
Embed-backed Loom imports render a Loom iframe and hide the native Clips editor.
|
|
130
|
+
Reuploaded Loom imports support Clips-native trimming, exports, frame extraction,
|
|
131
|
+
and upload-based transcription; ask the user to upload the original video file
|
|
132
|
+
when those capabilities are needed for an embed-backed import.
|
|
132
133
|
|
|
133
134
|
## Browser diagnostics and recorder install options
|
|
134
135
|
|
|
@@ -145,7 +145,7 @@ async function fetchLoomOembed(shareUrl: string) {
|
|
|
145
145
|
|
|
146
146
|
export default defineAction({
|
|
147
147
|
description:
|
|
148
|
-
"Import a public Loom share URL, or a direct link to a video file, into Clips as a playable recording. Loom links create the recording immediately and download/reupload Loom's public MP4
|
|
148
|
+
"Import a public Loom share URL, or a direct link to a video file, into Clips as a playable recording. Loom links create the recording immediately and download/reupload Loom's public MP4 when available, or keep a playable Loom embed when Loom allows playback but not MP4 export; public transcripts are imported in the background. Other direct video links (e.g. an MP4/WebM/MOV hosted by another screen recorder) are downloaded and reuploaded synchronously without transcript metadata — use request-transcript afterward. If storage is not connected, creates a waiting recording that can be retried after storage setup.",
|
|
149
149
|
schema: ImportLoomRecordingSchema,
|
|
150
150
|
run: async (args, actionContext) => {
|
|
151
151
|
const loomId = extractLoomVideoId(args.url);
|
|
@@ -8,13 +8,14 @@ import { ownerEmailMatches } from "../../server/lib/recordings.js";
|
|
|
8
8
|
import { transactionalEmailStore } from "../../server/lib/transactional-email-store.js";
|
|
9
9
|
import {
|
|
10
10
|
extractLoomVideoId,
|
|
11
|
+
loomEmbedUrlForId,
|
|
11
12
|
normalizeLoomShareUrl,
|
|
12
13
|
} from "../../shared/loom.js";
|
|
13
14
|
import {
|
|
14
15
|
fetchLoomTranscript,
|
|
15
16
|
loomTranscriptUnavailableMessage,
|
|
16
17
|
} from "./loom-transcript.js";
|
|
17
|
-
import { downloadLoomVideo } from "./loom-video.js";
|
|
18
|
+
import { downloadLoomVideo, LoomVideoUnavailableError } from "./loom-video.js";
|
|
18
19
|
|
|
19
20
|
export type LoomImportJobResult = {
|
|
20
21
|
status: "ready" | "failed";
|
|
@@ -147,7 +148,7 @@ export async function runLoomImportJob({
|
|
|
147
148
|
|
|
148
149
|
console.log("[loom-import] job started", { recordingId, claimId });
|
|
149
150
|
|
|
150
|
-
let media: Awaited<ReturnType<typeof downloadLoomVideo
|
|
151
|
+
let media: Awaited<ReturnType<typeof downloadLoomVideo>> | null = null;
|
|
151
152
|
try {
|
|
152
153
|
media = await downloadLoomVideo({ loomId, shareUrl });
|
|
153
154
|
console.log("[loom-import] download complete", {
|
|
@@ -156,40 +157,54 @@ export async function runLoomImportJob({
|
|
|
156
157
|
mimeType: media.mimeType,
|
|
157
158
|
});
|
|
158
159
|
} catch (err) {
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
160
|
+
// Loom's public player can work even when the viewer's role cannot export MP4.
|
|
161
|
+
if (err instanceof LoomVideoUnavailableError) {
|
|
162
|
+
console.warn("[loom-import] MP4 unavailable; keeping Loom embed", {
|
|
163
|
+
recordingId,
|
|
164
|
+
loomId,
|
|
165
|
+
});
|
|
166
|
+
} else {
|
|
167
|
+
return failLoomImport(
|
|
168
|
+
recordingId,
|
|
169
|
+
err instanceof Error ? err.message : String(err),
|
|
170
|
+
claimId,
|
|
171
|
+
);
|
|
172
|
+
}
|
|
164
173
|
}
|
|
165
174
|
|
|
166
175
|
try {
|
|
167
|
-
const upload =
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
+
const upload = media
|
|
177
|
+
? await uploadFile({
|
|
178
|
+
data: media.bytes,
|
|
179
|
+
filename: `${recordingId}.mp4`,
|
|
180
|
+
mimeType: media.mimeType,
|
|
181
|
+
ownerEmail,
|
|
182
|
+
stableUrl: true,
|
|
183
|
+
recordAsset: false,
|
|
184
|
+
})
|
|
185
|
+
: null;
|
|
186
|
+
if (media && !upload?.url) {
|
|
176
187
|
return failLoomImport(
|
|
177
188
|
recordingId,
|
|
178
189
|
"File upload returned no URL. Check your storage provider configuration.",
|
|
179
190
|
claimId,
|
|
180
191
|
);
|
|
181
192
|
}
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
193
|
+
|
|
194
|
+
const videoUrl = upload?.url ?? loomEmbedUrlForId(loomId);
|
|
195
|
+
if (upload) {
|
|
196
|
+
console.log("[loom-import] reupload complete", {
|
|
197
|
+
recordingId,
|
|
198
|
+
videoUrl: upload.url,
|
|
199
|
+
});
|
|
200
|
+
}
|
|
186
201
|
|
|
187
202
|
const now = new Date().toISOString();
|
|
188
203
|
const [mediaReady] = await db
|
|
189
204
|
.update(schema.recordings)
|
|
190
205
|
.set({
|
|
191
|
-
videoUrl
|
|
192
|
-
videoSizeBytes: media
|
|
206
|
+
videoUrl,
|
|
207
|
+
videoSizeBytes: media?.sizeBytes ?? 0,
|
|
193
208
|
status: "ready",
|
|
194
209
|
failureReason: null,
|
|
195
210
|
updatedAt: now,
|
|
@@ -213,20 +228,22 @@ export async function runLoomImportJob({
|
|
|
213
228
|
}
|
|
214
229
|
console.log("[loom-import] recording ready", { recordingId });
|
|
215
230
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
ownerEmail,
|
|
219
|
-
videoUrl: upload.url,
|
|
220
|
-
mimeType: media.mimeType,
|
|
221
|
-
providerId: upload.provider,
|
|
222
|
-
assetDbId: upload.id,
|
|
223
|
-
sourceSizeBytes: media.sizeBytes,
|
|
224
|
-
}).catch((err) => {
|
|
225
|
-
console.warn("[clips] Loom media compression queue failed", {
|
|
231
|
+
if (media && upload) {
|
|
232
|
+
void queueBuilderMediaCompression({
|
|
226
233
|
recordingId,
|
|
227
|
-
|
|
234
|
+
ownerEmail,
|
|
235
|
+
videoUrl: upload.url,
|
|
236
|
+
mimeType: media.mimeType,
|
|
237
|
+
providerId: upload.provider,
|
|
238
|
+
assetDbId: upload.id,
|
|
239
|
+
sourceSizeBytes: media.sizeBytes,
|
|
240
|
+
}).catch((err) => {
|
|
241
|
+
console.warn("[clips] Loom media compression queue failed", {
|
|
242
|
+
recordingId,
|
|
243
|
+
error: err instanceof Error ? err.message : String(err),
|
|
244
|
+
});
|
|
228
245
|
});
|
|
229
|
-
}
|
|
246
|
+
}
|
|
230
247
|
|
|
231
248
|
try {
|
|
232
249
|
let transcript: Awaited<ReturnType<typeof fetchLoomTranscript>> = null;
|
|
@@ -291,7 +308,7 @@ export async function runLoomImportJob({
|
|
|
291
308
|
recordingId,
|
|
292
309
|
status: "ready",
|
|
293
310
|
progress: 100,
|
|
294
|
-
videoUrl
|
|
311
|
+
videoUrl,
|
|
295
312
|
updatedAt: now,
|
|
296
313
|
});
|
|
297
314
|
await writeAppState("refresh-signal", { ts: Date.now() });
|
|
@@ -38,8 +38,6 @@ import {
|
|
|
38
38
|
} from "@agent-native/core/application-state";
|
|
39
39
|
import { ssrfSafeFetch } from "@agent-native/core/extensions/url-safety";
|
|
40
40
|
import { resolveHasBuilderPrivateKey } from "@agent-native/core/server";
|
|
41
|
-
import { getRequestUserEmail } from "@agent-native/core/server/request-context";
|
|
42
|
-
import { getSetting, getUserSetting } from "@agent-native/core/settings";
|
|
43
41
|
import { assertAccess } from "@agent-native/core/sharing";
|
|
44
42
|
import { transcribeWithBuilder } from "@agent-native/core/transcription/builder";
|
|
45
43
|
import { and, eq } from "drizzle-orm";
|
|
@@ -66,8 +64,6 @@ import {
|
|
|
66
64
|
type TranscriptSegment,
|
|
67
65
|
} from "../shared/transcript-segments.js";
|
|
68
66
|
import { PENDING_TRANSCRIPT_HEARTBEAT_MS } from "../shared/transcript-status.js";
|
|
69
|
-
import cleanupTranscript from "./cleanup-transcript.js";
|
|
70
|
-
import { loadAgentsMdContext } from "./lib/agents-md-context.js";
|
|
71
67
|
import {
|
|
72
68
|
AudioOnlyExtractionError,
|
|
73
69
|
assertAudioHasAudibleSignal,
|
|
@@ -107,7 +103,6 @@ type RecordingMediaRow = {
|
|
|
107
103
|
const BUILDER_GEMINI_TRANSCRIPTION_MODEL = "gemini-3-1-flash-lite";
|
|
108
104
|
const SPEECH_ONLY_TRANSCRIPTION_INSTRUCTIONS =
|
|
109
105
|
"Auto-detect the spoken language from the audio. Transcribe only words spoken in the audio, in the same language they were spoken. Do not translate. Do not infer language from screen text, filenames, account settings, browser locale, or these instructions. Do not describe screen activity, UI changes, silence, music, or non-speech sounds. Return an empty transcript when there are no spoken words.";
|
|
110
|
-
const CLIPS_USER_PREFS_KEY = "clips-user-prefs";
|
|
111
106
|
const RECENT_PENDING_TRANSCRIPT_MS = 2 * 60 * 1000;
|
|
112
107
|
const BUILDER_TRANSCRIPTION_MIN_TIMEOUT_MS = 45_000;
|
|
113
108
|
const BUILDER_TRANSCRIPTION_MAX_TIMEOUT_MS = 65_000;
|
|
@@ -856,27 +851,11 @@ async function failAudioOnlyPreparation({
|
|
|
856
851
|
throw new Error(reason);
|
|
857
852
|
}
|
|
858
853
|
|
|
859
|
-
async function transcriptCleanupEnabled(): Promise<boolean> {
|
|
860
|
-
const userEmail = getRequestUserEmail();
|
|
861
|
-
if (userEmail) {
|
|
862
|
-
const userSettings = await getUserSetting(
|
|
863
|
-
userEmail,
|
|
864
|
-
CLIPS_USER_PREFS_KEY,
|
|
865
|
-
).catch(() => null);
|
|
866
|
-
if (userSettings && "transcriptCleanupEnabled" in userSettings) {
|
|
867
|
-
return userSettings.transcriptCleanupEnabled !== false;
|
|
868
|
-
}
|
|
869
|
-
}
|
|
870
|
-
|
|
871
|
-
const settings = await getSetting(CLIPS_USER_PREFS_KEY).catch(() => null);
|
|
872
|
-
return settings?.transcriptCleanupEnabled !== false;
|
|
873
|
-
}
|
|
874
|
-
|
|
875
854
|
/**
|
|
876
855
|
* Read the language already detected/stored on this recording's transcript row.
|
|
877
|
-
*
|
|
878
|
-
*
|
|
879
|
-
*
|
|
856
|
+
* Renormalization must preserve a detected non-English language rather than
|
|
857
|
+
* clobbering it back to "en". Falls back to "en" only when no row (or no
|
|
858
|
+
* language) exists yet.
|
|
880
859
|
*/
|
|
881
860
|
async function resolveStoredLanguage(
|
|
882
861
|
db: ReturnType<typeof getDb>,
|
|
@@ -890,140 +869,6 @@ async function resolveStoredLanguage(
|
|
|
890
869
|
return row?.language?.trim() || "en";
|
|
891
870
|
}
|
|
892
871
|
|
|
893
|
-
async function cleanupNativeTranscript({
|
|
894
|
-
db,
|
|
895
|
-
recordingId,
|
|
896
|
-
ownerEmail,
|
|
897
|
-
fullText,
|
|
898
|
-
durationMs,
|
|
899
|
-
segmentsJson,
|
|
900
|
-
}: {
|
|
901
|
-
db: ReturnType<typeof getDb>;
|
|
902
|
-
recordingId: string;
|
|
903
|
-
ownerEmail: string;
|
|
904
|
-
fullText: string;
|
|
905
|
-
durationMs: number | null | undefined;
|
|
906
|
-
segmentsJson?: string | null;
|
|
907
|
-
}): Promise<{ cleaned: boolean; provider?: string }> {
|
|
908
|
-
const sourceText = fullText.trim();
|
|
909
|
-
if (!sourceText) return { cleaned: false };
|
|
910
|
-
|
|
911
|
-
if (!(await transcriptCleanupEnabled())) {
|
|
912
|
-
await writeTranscriptCleanupState(recordingId, {
|
|
913
|
-
status: "disabled",
|
|
914
|
-
});
|
|
915
|
-
return { cleaned: false };
|
|
916
|
-
}
|
|
917
|
-
|
|
918
|
-
await writeTranscriptCleanupState(recordingId, {
|
|
919
|
-
status: "running",
|
|
920
|
-
provider: BUILDER_GEMINI_TRANSCRIPTION_MODEL,
|
|
921
|
-
startedAt: new Date().toISOString(),
|
|
922
|
-
});
|
|
923
|
-
|
|
924
|
-
try {
|
|
925
|
-
const agentsContext = await loadAgentsMdContext({
|
|
926
|
-
ownerEmail,
|
|
927
|
-
purpose: "cleanup",
|
|
928
|
-
});
|
|
929
|
-
const result = await cleanupTranscript.run({
|
|
930
|
-
transcript: sourceText,
|
|
931
|
-
task: "cleanup",
|
|
932
|
-
context: agentsContext,
|
|
933
|
-
});
|
|
934
|
-
const cleanedText = result.cleanedText?.trim();
|
|
935
|
-
if (!cleanedText || cleanedText === sourceText) {
|
|
936
|
-
await writeTranscriptCleanupState(recordingId, {
|
|
937
|
-
status: "unchanged",
|
|
938
|
-
provider: result.provider,
|
|
939
|
-
});
|
|
940
|
-
return { cleaned: false, provider: result.provider };
|
|
941
|
-
}
|
|
942
|
-
if (!isSafeTranscriptCleanupReplacement(sourceText, cleanedText)) {
|
|
943
|
-
await writeTranscriptCleanupState(recordingId, {
|
|
944
|
-
status: "failed",
|
|
945
|
-
provider: result.provider,
|
|
946
|
-
failureReason:
|
|
947
|
-
"Cleanup output was incomplete; the original transcript was kept.",
|
|
948
|
-
sourceChars: sourceText.length,
|
|
949
|
-
cleanedChars: cleanedText.length,
|
|
950
|
-
});
|
|
951
|
-
return { cleaned: false, provider: result.provider };
|
|
952
|
-
}
|
|
953
|
-
|
|
954
|
-
const cleanupSegmentsJson = resolveCleanupSegmentsJson(
|
|
955
|
-
segmentsJson,
|
|
956
|
-
cleanedText,
|
|
957
|
-
durationMs,
|
|
958
|
-
);
|
|
959
|
-
if (!cleanupSegmentsJson) {
|
|
960
|
-
await writeTranscriptCleanupState(recordingId, {
|
|
961
|
-
status: "unchanged",
|
|
962
|
-
provider: result.provider,
|
|
963
|
-
failureReason:
|
|
964
|
-
"Cleanup output could not be aligned with measured speaker cues; the original transcript was kept.",
|
|
965
|
-
});
|
|
966
|
-
return { cleaned: false, provider: result.provider };
|
|
967
|
-
}
|
|
968
|
-
|
|
969
|
-
const now = new Date().toISOString();
|
|
970
|
-
const language = await resolveStoredLanguage(db, recordingId);
|
|
971
|
-
const updated = await db
|
|
972
|
-
.update(schema.recordingTranscripts)
|
|
973
|
-
.set({
|
|
974
|
-
ownerEmail,
|
|
975
|
-
status: "ready",
|
|
976
|
-
failureReason: null,
|
|
977
|
-
language,
|
|
978
|
-
segmentsJson: cleanupSegmentsJson,
|
|
979
|
-
fullText: cleanedText,
|
|
980
|
-
retryCount: 0,
|
|
981
|
-
updatedAt: now,
|
|
982
|
-
})
|
|
983
|
-
.where(
|
|
984
|
-
and(
|
|
985
|
-
eq(schema.recordingTranscripts.recordingId, recordingId),
|
|
986
|
-
eq(schema.recordingTranscripts.status, "ready"),
|
|
987
|
-
eq(schema.recordingTranscripts.fullText, sourceText),
|
|
988
|
-
),
|
|
989
|
-
)
|
|
990
|
-
.returning({ recordingId: schema.recordingTranscripts.recordingId });
|
|
991
|
-
if (!updated.length) {
|
|
992
|
-
await writeTranscriptCleanupState(recordingId, {
|
|
993
|
-
status: "failed",
|
|
994
|
-
provider: result.provider,
|
|
995
|
-
failureReason:
|
|
996
|
-
"Transcript changed during cleanup; the newer transcript was kept.",
|
|
997
|
-
});
|
|
998
|
-
return { cleaned: false, provider: result.provider };
|
|
999
|
-
}
|
|
1000
|
-
await writeTranscriptCleanupState(recordingId, {
|
|
1001
|
-
status: "ready",
|
|
1002
|
-
provider: result.provider,
|
|
1003
|
-
});
|
|
1004
|
-
|
|
1005
|
-
return { cleaned: true, provider: result.provider };
|
|
1006
|
-
} catch (err) {
|
|
1007
|
-
const details = serializeError(err);
|
|
1008
|
-
console.warn(
|
|
1009
|
-
`[clips] native transcript cleanup skipped for ${recordingId}: ${summarizeError(err)}`,
|
|
1010
|
-
);
|
|
1011
|
-
if (verboseTranscriptErrors()) {
|
|
1012
|
-
console.warn(
|
|
1013
|
-
"[clips] native transcript cleanup error details",
|
|
1014
|
-
serializeError(err, { includeStack: true }),
|
|
1015
|
-
);
|
|
1016
|
-
}
|
|
1017
|
-
await writeTranscriptCleanupState(recordingId, {
|
|
1018
|
-
status: "failed",
|
|
1019
|
-
provider: BUILDER_GEMINI_TRANSCRIPTION_MODEL,
|
|
1020
|
-
failureReason: (err as Error)?.message ?? String(err),
|
|
1021
|
-
details,
|
|
1022
|
-
});
|
|
1023
|
-
return { cleaned: false };
|
|
1024
|
-
}
|
|
1025
|
-
}
|
|
1026
|
-
|
|
1027
872
|
async function generateRecordingMetadata({
|
|
1028
873
|
recordingId,
|
|
1029
874
|
title,
|
|
@@ -1120,21 +965,6 @@ async function completeReadyTranscript({
|
|
|
1120
965
|
}
|
|
1121
966
|
}
|
|
1122
967
|
|
|
1123
|
-
const cleanupPromise = cleanupNativeTranscript({
|
|
1124
|
-
db,
|
|
1125
|
-
recordingId,
|
|
1126
|
-
ownerEmail,
|
|
1127
|
-
fullText,
|
|
1128
|
-
durationMs: recForTitle?.durationMs,
|
|
1129
|
-
segmentsJson,
|
|
1130
|
-
}).catch((err) => {
|
|
1131
|
-
console.warn(
|
|
1132
|
-
`[clips] native transcript cleanup failed for ${recordingId}:`,
|
|
1133
|
-
(err as Error)?.message ?? String(err),
|
|
1134
|
-
);
|
|
1135
|
-
return { cleaned: false };
|
|
1136
|
-
});
|
|
1137
|
-
|
|
1138
968
|
const metadataPromise = recForTitle
|
|
1139
969
|
? generateRecordingMetadata({
|
|
1140
970
|
recordingId,
|
|
@@ -1151,12 +981,7 @@ async function completeReadyTranscript({
|
|
|
1151
981
|
})
|
|
1152
982
|
: Promise.resolve({ titleQueued: false, summaryQueued: false });
|
|
1153
983
|
|
|
1154
|
-
|
|
1155
|
-
// alive without serially stacking two model-call timeouts.
|
|
1156
|
-
const [cleanupResult, metadataResult] = await Promise.all([
|
|
1157
|
-
cleanupPromise,
|
|
1158
|
-
metadataPromise,
|
|
1159
|
-
]);
|
|
984
|
+
const metadataResult = await metadataPromise;
|
|
1160
985
|
|
|
1161
986
|
if (!recForTitle) {
|
|
1162
987
|
console.warn(
|
|
@@ -1175,9 +1000,7 @@ async function completeReadyTranscript({
|
|
|
1175
1000
|
);
|
|
1176
1001
|
}
|
|
1177
1002
|
|
|
1178
|
-
// Wake the player polling
|
|
1179
|
-
// (`transcript-cleanup-${recordingId}`) before its next 2s tick lands —
|
|
1180
|
-
// otherwise the "Cleaning up…" badge can lag for one full poll interval.
|
|
1003
|
+
// Wake the player polling after transcript metadata generation completes.
|
|
1181
1004
|
await writeAppState("refresh-signal", { ts: Date.now() });
|
|
1182
1005
|
await finalizeEndedMeetingsForRecording(db, recordingId);
|
|
1183
1006
|
await queueBrainExport(recordingId);
|
|
@@ -1185,7 +1008,7 @@ async function completeReadyTranscript({
|
|
|
1185
1008
|
return {
|
|
1186
1009
|
recordingId,
|
|
1187
1010
|
status: "ready",
|
|
1188
|
-
cleaned:
|
|
1011
|
+
cleaned: false,
|
|
1189
1012
|
provider: segmentsJson && segmentsJson !== "[]" ? "existing" : "native",
|
|
1190
1013
|
cleanupQueued: false,
|
|
1191
1014
|
titleQueued: metadataResult.titleQueued,
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import {
|
|
2
|
+
forwardRef,
|
|
3
|
+
useCallback,
|
|
4
|
+
type ComponentPropsWithoutRef,
|
|
5
|
+
type ReactNode,
|
|
6
|
+
} from "react";
|
|
7
|
+
|
|
8
|
+
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
|
9
|
+
import { useVisibleAvatarUrl } from "@/lib/use-visible-avatar-url";
|
|
10
|
+
|
|
11
|
+
interface ClipsAvatarProps extends Omit<
|
|
12
|
+
ComponentPropsWithoutRef<typeof Avatar>,
|
|
13
|
+
"children"
|
|
14
|
+
> {
|
|
15
|
+
email: string | null | undefined;
|
|
16
|
+
alt: string;
|
|
17
|
+
fallback: ReactNode;
|
|
18
|
+
fallbackClassName?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const ClipsAvatar = forwardRef<HTMLSpanElement, ClipsAvatarProps>(
|
|
22
|
+
function ClipsAvatar(
|
|
23
|
+
{ email, alt, fallback, fallbackClassName, ...avatarProps },
|
|
24
|
+
forwardedRef,
|
|
25
|
+
) {
|
|
26
|
+
const { avatarRef, avatarUrl } = useVisibleAvatarUrl(email);
|
|
27
|
+
const setAvatarRef = useCallback(
|
|
28
|
+
(element: HTMLSpanElement | null) => {
|
|
29
|
+
avatarRef.current = element;
|
|
30
|
+
if (typeof forwardedRef === "function") forwardedRef(element);
|
|
31
|
+
else if (forwardedRef) forwardedRef.current = element;
|
|
32
|
+
},
|
|
33
|
+
[avatarRef, forwardedRef],
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<Avatar {...avatarProps} ref={setAvatarRef}>
|
|
38
|
+
{avatarUrl ? <AvatarImage src={avatarUrl} alt={alt} /> : null}
|
|
39
|
+
<AvatarFallback className={fallbackClassName}>
|
|
40
|
+
{fallback}
|
|
41
|
+
</AvatarFallback>
|
|
42
|
+
</Avatar>
|
|
43
|
+
);
|
|
44
|
+
},
|
|
45
|
+
);
|
|
46
|
+
ClipsAvatar.displayName = "ClipsAvatar";
|
|
@@ -17,9 +17,9 @@ import {
|
|
|
17
17
|
import { useCallback, useMemo, useRef, useState } from "react";
|
|
18
18
|
import { Link } from "react-router";
|
|
19
19
|
|
|
20
|
+
import { ClipsAvatar } from "@/components/clips-avatar";
|
|
20
21
|
import { AgentViewCount } from "@/components/player/recording-views-badge";
|
|
21
22
|
import { ViewedByPopover } from "@/components/sharing/viewed-by-popover";
|
|
22
|
-
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
|
23
23
|
import { Checkbox } from "@/components/ui/checkbox";
|
|
24
24
|
import {
|
|
25
25
|
DropdownMenu,
|
|
@@ -340,12 +340,13 @@ export function RecordingCard({
|
|
|
340
340
|
</div>
|
|
341
341
|
)}
|
|
342
342
|
<div className="mt-1 flex min-w-0 items-center gap-1.5 text-[11px] text-muted-foreground">
|
|
343
|
-
<
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
343
|
+
<ClipsAvatar
|
|
344
|
+
email={recording.ownerEmail}
|
|
345
|
+
alt={recording.ownerEmail}
|
|
346
|
+
fallback={ownerInitials}
|
|
347
|
+
className="h-4 w-4 shrink-0"
|
|
348
|
+
fallbackClassName="bg-primary/15 text-[8px] text-primary"
|
|
349
|
+
/>
|
|
349
350
|
<span className="min-w-0 truncate">{recording.ownerEmail}</span>
|
|
350
351
|
<span aria-hidden>•</span>
|
|
351
352
|
<span className="shrink-0">{relative}</span>
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
import { useState } from "react";
|
|
10
10
|
import { Link } from "react-router";
|
|
11
11
|
|
|
12
|
+
import { ClipsAvatar } from "@/components/clips-avatar";
|
|
12
13
|
import {
|
|
13
14
|
ContextMenu,
|
|
14
15
|
ContextMenuContent,
|
|
@@ -104,13 +105,15 @@ export function SpaceCard({
|
|
|
104
105
|
.slice(0, 2)
|
|
105
106
|
.toUpperCase();
|
|
106
107
|
return (
|
|
107
|
-
<
|
|
108
|
+
<ClipsAvatar
|
|
108
109
|
key={email}
|
|
110
|
+
email={email}
|
|
111
|
+
alt={email}
|
|
112
|
+
fallback={initials}
|
|
113
|
+
className="h-5 w-5 border border-background"
|
|
114
|
+
fallbackClassName="bg-primary/15 text-[9px] font-medium text-primary"
|
|
109
115
|
title={email}
|
|
110
|
-
|
|
111
|
-
>
|
|
112
|
-
{initials}
|
|
113
|
-
</div>
|
|
116
|
+
/>
|
|
114
117
|
);
|
|
115
118
|
})}
|
|
116
119
|
{members.length > 5 && (
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* gets a shadcn Tooltip showing name/email. Used on both list cards and
|
|
6
6
|
* detail headers.
|
|
7
7
|
*/
|
|
8
|
-
import {
|
|
8
|
+
import { ClipsAvatar } from "@/components/clips-avatar";
|
|
9
9
|
import {
|
|
10
10
|
Tooltip,
|
|
11
11
|
TooltipContent,
|
|
@@ -58,14 +58,13 @@ export function AttendeeStack({
|
|
|
58
58
|
{visible.map((p, i) => (
|
|
59
59
|
<Tooltip key={`${p.email}-${i}`}>
|
|
60
60
|
<TooltipTrigger asChild>
|
|
61
|
-
<
|
|
61
|
+
<ClipsAvatar
|
|
62
|
+
email={p.email}
|
|
63
|
+
alt={p.name || p.email}
|
|
64
|
+
fallback={attendeeInitials(p)}
|
|
65
|
+
fallbackClassName="font-medium"
|
|
62
66
|
className={`${sizeClass} ring-2 ring-background cursor-default`}
|
|
63
|
-
|
|
64
|
-
<AvatarImage alt={p.name || p.email} />
|
|
65
|
-
<AvatarFallback className="font-medium">
|
|
66
|
-
{attendeeInitials(p)}
|
|
67
|
-
</AvatarFallback>
|
|
68
|
-
</Avatar>
|
|
67
|
+
/>
|
|
69
68
|
</TooltipTrigger>
|
|
70
69
|
<TooltipContent side="top" className="text-xs">
|
|
71
70
|
<div className="font-medium">{p.name || p.email}</div>
|