@agent-native/core 0.102.0 → 0.102.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 +2 -2
- package/corpus/core/CHANGELOG.md +15 -0
- package/corpus/core/package.json +1 -1
- package/corpus/core/src/client/AgentPanel.tsx +7 -0
- package/corpus/core/src/client/AssistantChat.tsx +7 -0
- package/corpus/core/src/client/agent-chat-adapter.ts +7 -1
- package/corpus/core/src/client/agent-page/AgentTabsPage.tsx +1 -1
- package/corpus/core/src/client/chat/message-components.tsx +17 -3
- package/corpus/core/src/client/composer/ComposerPlusMenu.tsx +96 -6
- package/corpus/core/src/client/composer/asset-picker-url.ts +80 -0
- package/corpus/core/src/client/error-format.ts +24 -0
- package/corpus/core/src/client/resources/McpIntegrationDialog.tsx +8 -1
- package/corpus/templates/analytics/changelog/2026-07-15-fixed-the-agent-discarding-a-successful-dashboard-edit-and-r.md +6 -0
- package/corpus/templates/analytics/server/lib/real-data-actions.ts +30 -1
- package/corpus/templates/analytics/server/plugins/agent-chat.ts +15 -0
- package/corpus/templates/assets/app/routes/library.tsx +69 -0
- package/corpus/templates/assets/changelog/2026-07-15-fixed-top-level-image-picker-selections-returning-to-the-com.md +6 -0
- package/corpus/templates/clips/actions/disconnect-calendar.ts +57 -9
- package/corpus/templates/clips/actions/generate-workflow.ts +102 -59
- package/corpus/templates/clips/actions/get-recording-player-data.ts +64 -1
- package/corpus/templates/clips/actions/regenerate-summary.ts +7 -0
- package/corpus/templates/clips/actions/regenerate-title.ts +29 -11
- package/corpus/templates/clips/app/components/editor/editor-layout.tsx +22 -11
- package/corpus/templates/clips/app/components/editor/thumbnail-picker.tsx +87 -9
- package/corpus/templates/clips/app/components/editor/trim-handles.tsx +15 -0
- package/corpus/templates/clips/app/components/editor/waveform.tsx +21 -8
- package/corpus/templates/clips/app/components/library/folder-tree.tsx +52 -0
- package/corpus/templates/clips/app/components/player/settings-panel.tsx +86 -46
- package/corpus/templates/clips/app/components/player/share-dialog.tsx +43 -37
- package/corpus/templates/clips/app/components/player/video-player.tsx +45 -1
- package/corpus/templates/clips/app/i18n/ar-SA.ts +2 -0
- package/corpus/templates/clips/app/i18n/de-DE.ts +2 -0
- package/corpus/templates/clips/app/i18n/en-US.ts +2 -0
- package/corpus/templates/clips/app/i18n/es-ES.ts +2 -0
- package/corpus/templates/clips/app/i18n/fr-FR.ts +2 -0
- package/corpus/templates/clips/app/i18n/hi-IN.ts +2 -0
- package/corpus/templates/clips/app/i18n/ja-JP.ts +2 -0
- package/corpus/templates/clips/app/i18n/ko-KR.ts +2 -0
- package/corpus/templates/clips/app/i18n/pt-BR.ts +2 -0
- package/corpus/templates/clips/app/i18n/zh-CN.ts +2 -0
- package/corpus/templates/clips/app/i18n/zh-TW.ts +2 -0
- package/corpus/templates/clips/app/lib/timestamp-mapping.ts +14 -0
- package/corpus/templates/clips/app/routes/_app.library._index.tsx +1 -2
- package/corpus/templates/clips/app/routes/_index.tsx +1 -2
- package/corpus/templates/clips/app/routes/r.$recordingId.tsx +71 -31
- package/corpus/templates/clips/app/routes/share.$shareId.tsx +4 -1
- package/corpus/templates/clips/changelog/2026-07-15-ai-workflow-generation-now-stays-visible-avoids-duplicate-re.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-15-clip-playback-and-cta-controls-remain-interactive-through-ed.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-15-clip-titles-no-longer-describe-the-product-as-a-loom-alterna.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-15-description-regeneration-now-opens-the-agent-chat-so-progres.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-15-direct-recording-urls-now-respect-share-link-and-password-ac.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-15-disconnecting-a-calendar-now-clears-unrecorded-synced-meetin.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-15-folders-now-expose-rename-and-delete-actions-directly-in-the.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-15-recording-pages-now-explain-sign-in-access-and-avoid-showing.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-15-the-clips-extension-quick-actions-toolbar-can-be-moved-out-o.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-15-the-editor-now-skips-cut-sections-during-preview-and-keeps-t.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-15-thumbnail-capture-now-updates-previews-while-scrubbing-and-l.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-15-windows-permission-links-open-the-matching-settings-page.md +6 -0
- package/corpus/templates/clips/chrome-extension/src/content-script.ts +117 -2
- package/corpus/templates/clips/chrome-extension/src/overlay.ts +27 -0
- package/corpus/templates/clips/desktop/src-tauri/tauri.conf.json +1 -1
- package/corpus/templates/clips/server/lib/calendar-event-meetings.ts +139 -101
- package/corpus/templates/clips/server/lib/recording-page-access.ts +35 -0
- package/corpus/templates/content/app/components/editor/DescriptionField.tsx +10 -2
- package/corpus/templates/content/changelog/2026-07-15-page-and-database-descriptions-now-use-a-clean-single-line-f.md +6 -0
- package/corpus/templates/design/app/components/design/design-canvas/external-preview.ts +7 -3
- package/corpus/templates/design/changelog/2026-07-15-prototype-print-and-download-buttons-now-work-in-preview.md +6 -0
- package/corpus/templates/slides/actions/extract-pdf.ts +1 -1
- package/corpus/templates/slides/actions/import-file.ts +4 -1
- package/corpus/templates/slides/app/components/editor/ImageGenPanel.tsx +1 -1
- package/corpus/templates/slides/app/components/editor/ImageSearchPanel.tsx +9 -12
- package/corpus/templates/slides/app/components/editor/SlideEditor.tsx +29 -21
- package/corpus/templates/slides/app/i18n/ar-SA.ts +1 -0
- package/corpus/templates/slides/app/i18n/de-DE.ts +1 -0
- package/corpus/templates/slides/app/i18n/en-US.ts +1 -0
- package/corpus/templates/slides/app/i18n/es-ES.ts +1 -0
- package/corpus/templates/slides/app/i18n/fr-FR.ts +1 -0
- package/corpus/templates/slides/app/i18n/hi-IN.ts +1 -0
- package/corpus/templates/slides/app/i18n/ja-JP.ts +1 -0
- package/corpus/templates/slides/app/i18n/ko-KR.ts +1 -0
- package/corpus/templates/slides/app/i18n/pt-BR.ts +1 -0
- package/corpus/templates/slides/app/i18n/zh-CN.ts +1 -0
- package/corpus/templates/slides/app/i18n/zh-TW.ts +1 -0
- package/corpus/templates/slides/changelog/2026-07-15-image-search-and-generation-controls-now-use-the-registered-.md +6 -0
- package/corpus/templates/slides/changelog/2026-07-15-pdf-deck-references-can-be-imported-for-slide-inspiration-ag.md +6 -0
- package/corpus/templates/slides/changelog/2026-07-15-stable-style-inspector-dock.md +6 -0
- package/dist/client/AgentPanel.d.ts.map +1 -1
- package/dist/client/AgentPanel.js +5 -0
- package/dist/client/AgentPanel.js.map +1 -1
- package/dist/client/AssistantChat.d.ts.map +1 -1
- package/dist/client/AssistantChat.js +7 -0
- package/dist/client/AssistantChat.js.map +1 -1
- package/dist/client/agent-chat-adapter.d.ts.map +1 -1
- package/dist/client/agent-chat-adapter.js +8 -1
- package/dist/client/agent-chat-adapter.js.map +1 -1
- package/dist/client/agent-page/AgentTabsPage.js +1 -1
- package/dist/client/agent-page/AgentTabsPage.js.map +1 -1
- package/dist/client/chat/message-components.d.ts +4 -0
- package/dist/client/chat/message-components.d.ts.map +1 -1
- package/dist/client/chat/message-components.js +10 -3
- package/dist/client/chat/message-components.js.map +1 -1
- package/dist/client/composer/ComposerPlusMenu.d.ts.map +1 -1
- package/dist/client/composer/ComposerPlusMenu.js +58 -6
- package/dist/client/composer/ComposerPlusMenu.js.map +1 -1
- package/dist/client/composer/asset-picker-url.d.ts +16 -0
- package/dist/client/composer/asset-picker-url.d.ts.map +1 -0
- package/dist/client/composer/asset-picker-url.js +64 -0
- package/dist/client/composer/asset-picker-url.js.map +1 -0
- package/dist/client/error-format.d.ts.map +1 -1
- package/dist/client/error-format.js +17 -0
- package/dist/client/error-format.js.map +1 -1
- package/dist/client/resources/McpIntegrationDialog.d.ts.map +1 -1
- package/dist/client/resources/McpIntegrationDialog.js +6 -1
- package/dist/client/resources/McpIntegrationDialog.js.map +1 -1
- package/dist/collab/routes.d.ts +1 -1
- package/dist/observability/routes.d.ts +1 -1
- package/dist/provider-api/corpus-jobs.d.ts +2 -2
- package/package.json +1 -1
|
@@ -13,10 +13,11 @@ import { writeAppState } from "@agent-native/core/application-state";
|
|
|
13
13
|
import { deleteAppSecret, readAppSecret } from "@agent-native/core/secrets";
|
|
14
14
|
import { getRequestUserEmail } from "@agent-native/core/server/request-context";
|
|
15
15
|
import { assertAccess } from "@agent-native/core/sharing";
|
|
16
|
-
import { eq } from "drizzle-orm";
|
|
16
|
+
import { and, eq, inArray, isNull, or } from "drizzle-orm";
|
|
17
17
|
import { z } from "zod";
|
|
18
18
|
|
|
19
19
|
import { getDb, schema } from "../server/db/index.js";
|
|
20
|
+
import { lockCalendarAccount } from "../server/lib/calendar-event-meetings.js";
|
|
20
21
|
import { revokeToken } from "../server/lib/google-calendar-client.js";
|
|
21
22
|
|
|
22
23
|
export default defineAction({
|
|
@@ -90,15 +91,62 @@ export default defineAction({
|
|
|
90
91
|
}).catch(() => {});
|
|
91
92
|
}
|
|
92
93
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
.
|
|
96
|
-
|
|
94
|
+
await db.transaction(async (tx) => {
|
|
95
|
+
// Materialization takes this same account-row write lock before it
|
|
96
|
+
// snapshots/claims an event and inserts a meeting. Taking it before
|
|
97
|
+
// this cleanup snapshot prevents a new unrecorded meeting from being
|
|
98
|
+
// inserted after the rows below have been inspected.
|
|
99
|
+
if (!(await lockCalendarAccount(tx, args.id, account.ownerEmail))) {
|
|
100
|
+
throw new Error(`Calendar account not found: ${args.id}`);
|
|
101
|
+
}
|
|
97
102
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
.
|
|
101
|
-
|
|
103
|
+
// Preserve recorded meetings, but hide unrecorded calendar placeholders
|
|
104
|
+
// tied to this account. Without this cleanup, those materialized rows
|
|
105
|
+
// stay visible after the account and its event cache are removed.
|
|
106
|
+
const syncedEvents = await tx
|
|
107
|
+
.select({
|
|
108
|
+
id: schema.calendarEvents.id,
|
|
109
|
+
meetingId: schema.calendarEvents.meetingId,
|
|
110
|
+
})
|
|
111
|
+
.from(schema.calendarEvents)
|
|
112
|
+
.where(eq(schema.calendarEvents.calendarAccountId, args.id));
|
|
113
|
+
const syncedCalendarEventIds = syncedEvents.map((event) => event.id);
|
|
114
|
+
const syncedMeetingIds = syncedEvents
|
|
115
|
+
.map((event) => event.meetingId)
|
|
116
|
+
.filter((meetingId): meetingId is string => Boolean(meetingId));
|
|
117
|
+
if (syncedCalendarEventIds.length > 0 || syncedMeetingIds.length > 0) {
|
|
118
|
+
await tx
|
|
119
|
+
.update(schema.meetings)
|
|
120
|
+
.set({ trashedAt: new Date().toISOString() })
|
|
121
|
+
.where(
|
|
122
|
+
and(
|
|
123
|
+
or(
|
|
124
|
+
syncedMeetingIds.length > 0
|
|
125
|
+
? inArray(schema.meetings.id, syncedMeetingIds)
|
|
126
|
+
: undefined,
|
|
127
|
+
syncedCalendarEventIds.length > 0
|
|
128
|
+
? inArray(
|
|
129
|
+
schema.meetings.calendarEventId,
|
|
130
|
+
syncedCalendarEventIds,
|
|
131
|
+
)
|
|
132
|
+
: undefined,
|
|
133
|
+
)!,
|
|
134
|
+
isNull(schema.meetings.recordingId),
|
|
135
|
+
isNull(schema.meetings.trashedAt),
|
|
136
|
+
),
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Drop the synced events for this account so the meetings tab clears.
|
|
141
|
+
await tx
|
|
142
|
+
.delete(schema.calendarEvents)
|
|
143
|
+
.where(eq(schema.calendarEvents.calendarAccountId, args.id));
|
|
144
|
+
|
|
145
|
+
// Drop the account row itself.
|
|
146
|
+
await tx
|
|
147
|
+
.delete(schema.calendarAccounts)
|
|
148
|
+
.where(eq(schema.calendarAccounts.id, args.id));
|
|
149
|
+
});
|
|
102
150
|
|
|
103
151
|
await writeAppState("refresh-signal", { ts: Date.now() });
|
|
104
152
|
return { id: args.id, disconnected: true };
|
|
@@ -15,7 +15,10 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import { defineAction } from "@agent-native/core";
|
|
18
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
readAppState,
|
|
20
|
+
writeAppState,
|
|
21
|
+
} from "@agent-native/core/application-state";
|
|
19
22
|
import { assertAccess } from "@agent-native/core/sharing";
|
|
20
23
|
import { eq } from "drizzle-orm";
|
|
21
24
|
import { z } from "zod";
|
|
@@ -51,6 +54,11 @@ Output markdown.`,
|
|
|
51
54
|
Keep it concise, warm, and professional.`,
|
|
52
55
|
} as const;
|
|
53
56
|
|
|
57
|
+
// Actions for the same recording can arrive concurrently when the player and
|
|
58
|
+
// agent both retry a request. Keep the read-check-write/enqueue sequence
|
|
59
|
+
// single-flight within this runtime so only one request can claim generation.
|
|
60
|
+
const workflowGenerationLocks = new Set<string>();
|
|
61
|
+
|
|
54
62
|
export default defineAction({
|
|
55
63
|
description:
|
|
56
64
|
"Ask the agent to generate a structured workflow doc (pr/sop/ticket/email) from this recording's transcript (and the full video when Include full video is enabled). The agent writes the result to clips-workflow-<recordingId> in application_state.",
|
|
@@ -61,71 +69,106 @@ export default defineAction({
|
|
|
61
69
|
run: async (args) => {
|
|
62
70
|
await assertAccess("recording", args.recordingId, "viewer");
|
|
63
71
|
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
72
|
+
const stateKey = `clips-workflow-${args.recordingId}`;
|
|
73
|
+
if (workflowGenerationLocks.has(stateKey)) {
|
|
74
|
+
return {
|
|
75
|
+
queued: false,
|
|
76
|
+
duplicate: true,
|
|
77
|
+
recordingId: args.recordingId,
|
|
78
|
+
kind: args.kind,
|
|
79
|
+
stateKey,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
workflowGenerationLocks.add(stateKey);
|
|
71
83
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
84
|
+
try {
|
|
85
|
+
const db = getDb();
|
|
86
|
+
const [rec] = await db
|
|
87
|
+
.select()
|
|
88
|
+
.from(schema.recordings)
|
|
89
|
+
.where(eq(schema.recordings.id, args.recordingId))
|
|
90
|
+
.limit(1);
|
|
91
|
+
if (!rec) throw new Error(`Recording not found: ${args.recordingId}`);
|
|
77
92
|
|
|
78
|
-
|
|
79
|
-
|
|
93
|
+
const [transcript] = await db
|
|
94
|
+
.select()
|
|
95
|
+
.from(schema.recordingTranscripts)
|
|
96
|
+
.where(eq(schema.recordingTranscripts.recordingId, args.recordingId))
|
|
97
|
+
.limit(1);
|
|
80
98
|
|
|
81
|
-
|
|
82
|
-
// a loading state immediately.
|
|
83
|
-
await writeAppState(stateKey, {
|
|
84
|
-
kind: args.kind,
|
|
85
|
-
status: "generating",
|
|
86
|
-
recordingId: args.recordingId,
|
|
87
|
-
requestedAt: new Date().toISOString(),
|
|
88
|
-
} as any);
|
|
99
|
+
const includeFullVideoInAi = await readIncludeFullVideoInAi();
|
|
89
100
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
101
|
+
const existing = await readAppState(stateKey).catch(() => null);
|
|
102
|
+
if (existing?.status === "generating") {
|
|
103
|
+
const requestedAt = Date.parse(String(existing.requestedAt ?? ""));
|
|
104
|
+
const isRecent =
|
|
105
|
+
!Number.isFinite(requestedAt) ||
|
|
106
|
+
Date.now() - requestedAt < 10 * 60_000;
|
|
107
|
+
if (isRecent) {
|
|
108
|
+
return {
|
|
109
|
+
queued: false,
|
|
110
|
+
duplicate: true,
|
|
111
|
+
recordingId: args.recordingId,
|
|
112
|
+
kind: existing.kind ?? args.kind,
|
|
113
|
+
stateKey,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
}
|
|
97
117
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
args.recordingId
|
|
118
|
+
// Seed the output state with a "generating" placeholder so the UI can show
|
|
119
|
+
// a loading state immediately.
|
|
120
|
+
await writeAppState(stateKey, {
|
|
121
|
+
kind: args.kind,
|
|
122
|
+
status: "generating",
|
|
123
|
+
recordingId: args.recordingId,
|
|
124
|
+
requestedAt: new Date().toISOString(),
|
|
125
|
+
} as any);
|
|
126
|
+
|
|
127
|
+
const baseMessage =
|
|
128
|
+
`Generate a ${args.kind.toUpperCase()} workflow document from recording ${args.recordingId} ` +
|
|
129
|
+
`(title: "${rec.title}"). Read the transcript from this request's context. ` +
|
|
130
|
+
`${KIND_PROMPTS[args.kind]} ` +
|
|
131
|
+
`Then write the final markdown to application_state key "${stateKey}" as ` +
|
|
132
|
+
`\`{ kind: "${args.kind}", status: "ready", content: "...", recordingId: "${args.recordingId}" }\`. ` +
|
|
133
|
+
`Finish by replying in chat with the same generated markdown so the user can read it immediately.`;
|
|
134
|
+
|
|
135
|
+
const request = {
|
|
136
|
+
kind: "generate-workflow" as const,
|
|
137
|
+
workflowKind: args.kind,
|
|
138
|
+
recordingId: args.recordingId,
|
|
139
|
+
requestedAt: new Date().toISOString(),
|
|
140
|
+
recordingTitle: rec.title,
|
|
141
|
+
recordingDescription: rec.description,
|
|
142
|
+
transcriptStatus: transcript?.status ?? "pending",
|
|
143
|
+
transcriptText: transcript?.fullText ?? "",
|
|
144
|
+
stateKey,
|
|
145
|
+
instructions: KIND_PROMPTS[args.kind],
|
|
113
146
|
includeFullVideoInAi,
|
|
114
|
-
|
|
115
|
-
|
|
147
|
+
message: withFullVideoAiInstructions(
|
|
148
|
+
baseMessage,
|
|
149
|
+
args.recordingId,
|
|
150
|
+
includeFullVideoInAi,
|
|
151
|
+
),
|
|
152
|
+
};
|
|
116
153
|
|
|
117
|
-
|
|
118
|
-
|
|
154
|
+
await writeAppState(
|
|
155
|
+
`clips-ai-request-${args.recordingId}`,
|
|
156
|
+
request as any,
|
|
157
|
+
);
|
|
158
|
+
await writeAppState("refresh-signal", { ts: Date.now() });
|
|
119
159
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
160
|
+
console.log(
|
|
161
|
+
`Delegation queued: generate-workflow (${args.kind}) for ${args.recordingId}`,
|
|
162
|
+
);
|
|
163
|
+
return {
|
|
164
|
+
queued: true,
|
|
165
|
+
recordingId: args.recordingId,
|
|
166
|
+
kind: args.kind,
|
|
167
|
+
stateKey,
|
|
168
|
+
includeFullVideoInAi,
|
|
169
|
+
};
|
|
170
|
+
} finally {
|
|
171
|
+
workflowGenerationLocks.delete(stateKey);
|
|
172
|
+
}
|
|
130
173
|
},
|
|
131
174
|
});
|
|
@@ -20,12 +20,20 @@
|
|
|
20
20
|
import { defineAction, embedApp } from "@agent-native/core";
|
|
21
21
|
import { readAppState } from "@agent-native/core/application-state";
|
|
22
22
|
import { buildDeepLink } from "@agent-native/core/server";
|
|
23
|
+
import {
|
|
24
|
+
getRequestOrgId,
|
|
25
|
+
getRequestUserEmail,
|
|
26
|
+
} from "@agent-native/core/server/request-context";
|
|
23
27
|
import { resolveAccess, ForbiddenError } from "@agent-native/core/sharing";
|
|
24
|
-
import { asc, eq } from "drizzle-orm";
|
|
28
|
+
import { and, asc, eq, or, sql } from "drizzle-orm";
|
|
25
29
|
import { z } from "zod";
|
|
26
30
|
|
|
27
31
|
import { getDb, schema } from "../server/db/index.js";
|
|
28
32
|
import { resolvePlayerVideoUrl } from "../server/lib/player-video-url.js";
|
|
33
|
+
import {
|
|
34
|
+
canOpenDirectRecordingPage,
|
|
35
|
+
isRecordingExpired,
|
|
36
|
+
} from "../server/lib/recording-page-access.js";
|
|
29
37
|
import { parseSpaceIds } from "../server/lib/recordings.js";
|
|
30
38
|
import { parseBrowserDiagnosticsRow } from "../shared/browser-diagnostics.js";
|
|
31
39
|
import {
|
|
@@ -106,6 +114,61 @@ export default defineAction({
|
|
|
106
114
|
const db = getDb();
|
|
107
115
|
const rec: any = access.resource;
|
|
108
116
|
|
|
117
|
+
if (isRecordingExpired(rec.expiresAt)) {
|
|
118
|
+
throw new ForbiddenError("Recording has expired");
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
let hasExplicitShare = access.role === "owner";
|
|
122
|
+
if (rec.visibility === "public" && access.role !== "owner") {
|
|
123
|
+
const userEmail = getRequestUserEmail()?.trim().toLowerCase();
|
|
124
|
+
const orgId = getRequestOrgId();
|
|
125
|
+
const principals = [];
|
|
126
|
+
if (userEmail) {
|
|
127
|
+
principals.push(
|
|
128
|
+
and(
|
|
129
|
+
eq(schema.recordingShares.principalType, "user"),
|
|
130
|
+
sql`lower(${schema.recordingShares.principalId}) = ${userEmail}`,
|
|
131
|
+
),
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
if (orgId) {
|
|
135
|
+
principals.push(
|
|
136
|
+
and(
|
|
137
|
+
eq(schema.recordingShares.principalType, "org"),
|
|
138
|
+
eq(schema.recordingShares.principalId, orgId),
|
|
139
|
+
),
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
if (principals.length > 0) {
|
|
143
|
+
const [share] = await db
|
|
144
|
+
.select({ id: schema.recordingShares.id })
|
|
145
|
+
.from(schema.recordingShares)
|
|
146
|
+
.where(
|
|
147
|
+
and(
|
|
148
|
+
eq(schema.recordingShares.resourceId, args.recordingId),
|
|
149
|
+
or(...principals),
|
|
150
|
+
),
|
|
151
|
+
)
|
|
152
|
+
.limit(1);
|
|
153
|
+
hasExplicitShare = Boolean(share);
|
|
154
|
+
} else {
|
|
155
|
+
hasExplicitShare = false;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (
|
|
160
|
+
!canOpenDirectRecordingPage({
|
|
161
|
+
role: access.role,
|
|
162
|
+
visibility: rec.visibility,
|
|
163
|
+
hasPassword: Boolean(rec.password),
|
|
164
|
+
hasExplicitShare,
|
|
165
|
+
})
|
|
166
|
+
) {
|
|
167
|
+
throw new ForbiddenError(
|
|
168
|
+
"Open this recording from its share link instead of the direct recording URL",
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
109
172
|
const [transcript] = await db
|
|
110
173
|
.select()
|
|
111
174
|
.from(schema.recordingTranscripts)
|
|
@@ -22,6 +22,12 @@ export default defineAction({
|
|
|
22
22
|
"Ask the agent to regenerate this recording's description/summary based on its transcript (and the full video when Include full video is enabled).",
|
|
23
23
|
schema: z.object({
|
|
24
24
|
recordingId: z.string().describe("Recording ID"),
|
|
25
|
+
openInChat: z
|
|
26
|
+
.boolean()
|
|
27
|
+
.optional()
|
|
28
|
+
.describe(
|
|
29
|
+
"When true, focus the queued generation request in the agent chat instead of keeping it hidden.",
|
|
30
|
+
),
|
|
25
31
|
}),
|
|
26
32
|
run: async (args) => {
|
|
27
33
|
await assertAccess("recording", args.recordingId, "editor");
|
|
@@ -74,6 +80,7 @@ export default defineAction({
|
|
|
74
80
|
transcriptStatus: transcript?.status ?? "pending",
|
|
75
81
|
transcriptText: transcript?.fullText ?? "",
|
|
76
82
|
includeFullVideoInAi,
|
|
83
|
+
openInChat: args.openInChat === true,
|
|
77
84
|
message: withFullVideoAiInstructions(
|
|
78
85
|
baseMessage,
|
|
79
86
|
args.recordingId,
|
|
@@ -59,7 +59,9 @@ function buildTitleContext({
|
|
|
59
59
|
}): string | undefined {
|
|
60
60
|
const parts: string[] = [];
|
|
61
61
|
if (currentTitle && !isDefaultTitle(currentTitle)) {
|
|
62
|
-
parts.push(
|
|
62
|
+
parts.push(
|
|
63
|
+
`Current title: ${currentTitle}\nWhen regenerating, do not return this title verbatim; choose a more specific alternative when the transcript supports one.`,
|
|
64
|
+
);
|
|
63
65
|
}
|
|
64
66
|
if (agentsContext) parts.push(agentsContext);
|
|
65
67
|
return parts.length > 0 ? parts.join("\n\n") : undefined;
|
|
@@ -237,9 +239,14 @@ export default defineAction({
|
|
|
237
239
|
|
|
238
240
|
if (!fresh) throw new Error(`Recording not found: ${args.recordingId}`);
|
|
239
241
|
|
|
242
|
+
const titleMatchesCurrent =
|
|
243
|
+
fresh.title?.trim().toLocaleLowerCase() ===
|
|
244
|
+
generatedTitle.trim().toLocaleLowerCase();
|
|
245
|
+
|
|
240
246
|
if (
|
|
241
|
-
|
|
242
|
-
fresh.title
|
|
247
|
+
!titleMatchesCurrent &&
|
|
248
|
+
(isAutoTitleReplaceable(fresh.title, fresh.titleSource) ||
|
|
249
|
+
fresh.title === rec.title)
|
|
243
250
|
) {
|
|
244
251
|
await db
|
|
245
252
|
.update(schema.recordings)
|
|
@@ -272,12 +279,18 @@ export default defineAction({
|
|
|
272
279
|
};
|
|
273
280
|
}
|
|
274
281
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
282
|
+
if (!titleMatchesCurrent) {
|
|
283
|
+
return {
|
|
284
|
+
updated: false,
|
|
285
|
+
skipped: true,
|
|
286
|
+
reason: "Recording title changed before generation completed",
|
|
287
|
+
recordingId: args.recordingId,
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
console.warn(
|
|
292
|
+
`[clips] AI title regeneration returned the existing title for ${args.recordingId}; queueing a refinement instead`,
|
|
293
|
+
);
|
|
281
294
|
}
|
|
282
295
|
} catch (err) {
|
|
283
296
|
builderCreditsPaused = isBuilderCreditsExhaustedMessage(
|
|
@@ -302,9 +315,14 @@ export default defineAction({
|
|
|
302
315
|
|
|
303
316
|
if (!fresh) throw new Error(`Recording not found: ${args.recordingId}`);
|
|
304
317
|
|
|
318
|
+
const titleMatchesCurrent =
|
|
319
|
+
fresh.title?.trim().toLocaleLowerCase() ===
|
|
320
|
+
fallbackTitle.trim().toLocaleLowerCase();
|
|
321
|
+
|
|
305
322
|
if (
|
|
306
|
-
|
|
307
|
-
fresh.title
|
|
323
|
+
!titleMatchesCurrent &&
|
|
324
|
+
(isAutoTitleReplaceable(fresh.title, fresh.titleSource) ||
|
|
325
|
+
fresh.title === rec.title)
|
|
308
326
|
) {
|
|
309
327
|
await db
|
|
310
328
|
.update(schema.recordings)
|
|
@@ -63,6 +63,7 @@ import {
|
|
|
63
63
|
parseEdits,
|
|
64
64
|
getExcludedRanges,
|
|
65
65
|
formatMs,
|
|
66
|
+
skipExcludedRange,
|
|
66
67
|
type EditsJson,
|
|
67
68
|
} from "@/lib/timestamp-mapping";
|
|
68
69
|
import { cn } from "@/lib/utils";
|
|
@@ -435,10 +436,15 @@ export function EditorLayout({ recordingId, className }: EditorLayoutProps) {
|
|
|
435
436
|
useEffect(() => {
|
|
436
437
|
const v = videoRef.current;
|
|
437
438
|
if (!v) return;
|
|
438
|
-
const onTime = () =>
|
|
439
|
+
const onTime = () => {
|
|
440
|
+
const rawMs = v.currentTime * 1000;
|
|
441
|
+
const visibleMs = skipExcludedRange(rawMs, excludedRanges, durationMs);
|
|
442
|
+
if (visibleMs !== rawMs) v.currentTime = visibleMs / 1000;
|
|
443
|
+
setPlayheadMs(visibleMs);
|
|
444
|
+
};
|
|
439
445
|
v.addEventListener("timeupdate", onTime);
|
|
440
446
|
return () => v.removeEventListener("timeupdate", onTime);
|
|
441
|
-
}, [videoUrl]);
|
|
447
|
+
}, [durationMs, excludedRanges, videoUrl]);
|
|
442
448
|
|
|
443
449
|
// Expose the in-editor state so the agent can read "the user is editing and scrubbed to X".
|
|
444
450
|
useEffect(() => {
|
|
@@ -510,11 +516,15 @@ export function EditorLayout({ recordingId, className }: EditorLayoutProps) {
|
|
|
510
516
|
[recordingId, trim],
|
|
511
517
|
);
|
|
512
518
|
|
|
513
|
-
const seek = useCallback(
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
519
|
+
const seek = useCallback(
|
|
520
|
+
(ms: number) => {
|
|
521
|
+
const visibleMs = skipExcludedRange(ms, excludedRanges, durationMs);
|
|
522
|
+
const v = videoRef.current;
|
|
523
|
+
if (v) v.currentTime = visibleMs / 1000;
|
|
524
|
+
setPlayheadMs(visibleMs);
|
|
525
|
+
},
|
|
526
|
+
[durationMs, excludedRanges],
|
|
527
|
+
);
|
|
518
528
|
|
|
519
529
|
// --- keyboard shortcuts -------------------------------------------------
|
|
520
530
|
useEffect(() => {
|
|
@@ -585,8 +595,8 @@ export function EditorLayout({ recordingId, className }: EditorLayoutProps) {
|
|
|
585
595
|
|
|
586
596
|
// Default selection window so the TrimHandles have something to render.
|
|
587
597
|
const effectiveSelection = selectionRange ?? {
|
|
588
|
-
startMs:
|
|
589
|
-
endMs:
|
|
598
|
+
startMs: 0,
|
|
599
|
+
endMs: durationMs || 1_000,
|
|
590
600
|
};
|
|
591
601
|
|
|
592
602
|
if (playerDataQuery.isLoading) {
|
|
@@ -684,7 +694,8 @@ export function EditorLayout({ recordingId, className }: EditorLayoutProps) {
|
|
|
684
694
|
playheadMs={playheadMs}
|
|
685
695
|
durationMs={durationMs}
|
|
686
696
|
excludedRanges={excludedRanges}
|
|
687
|
-
selectionRange={
|
|
697
|
+
selectionRange={effectiveSelection}
|
|
698
|
+
splitPoints={splitPoints}
|
|
688
699
|
activityRanges={transcriptSegments}
|
|
689
700
|
onSeek={seek}
|
|
690
701
|
scrollLeft={scrollLeft}
|
|
@@ -707,6 +718,7 @@ export function EditorLayout({ recordingId, className }: EditorLayoutProps) {
|
|
|
707
718
|
value={effectiveSelection}
|
|
708
719
|
onChange={setSelectionRange}
|
|
709
720
|
durationMs={durationMs}
|
|
721
|
+
splitPoints={splitPoints}
|
|
710
722
|
/>
|
|
711
723
|
</div>
|
|
712
724
|
</div>
|
|
@@ -727,7 +739,6 @@ export function EditorLayout({ recordingId, className }: EditorLayoutProps) {
|
|
|
727
739
|
durationMs={durationMs}
|
|
728
740
|
playheadMs={playheadMs}
|
|
729
741
|
chapters={chapters}
|
|
730
|
-
excludedRanges={excludedRanges}
|
|
731
742
|
splitPoints={splitPoints}
|
|
732
743
|
onSeek={seek}
|
|
733
744
|
onClickChapter={(c) => seek(c.startMs)}
|