@agent-native/core 0.161.2 → 0.161.6
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/templates/analytics/actions/update-dashboard.ts +8 -0
- package/corpus/templates/clips/actions/save-browser-transcript.ts +20 -4
- package/corpus/templates/clips/app/components/meetings/transcript-bubbles.tsx +167 -35
- package/corpus/templates/clips/desktop/src/lib/transcription-capture.ts +8 -1
- package/corpus/templates/clips/desktop/src/lib/transcription-engine.ts +39 -2
- package/corpus/templates/forms/server/lib/public-form-ssr.ts +3 -0
- package/corpus/templates/slides/actions/list-decks.ts +36 -1
- package/corpus/templates/slides/app/components/editor/SlideEditor.tsx +29 -17
- package/corpus/templates/slides/app/context/DeckContext.tsx +17 -6
- package/dist/agent/engine/builder-engine.js +30 -15
- package/dist/agent/engine/types.d.ts +19 -0
- package/dist/agent/engine/types.js +3 -0
- package/dist/agent/production-agent.d.ts +56 -1
- package/dist/agent/production-agent.js +130 -3
- package/dist/agent/run-manager.d.ts +15 -4
- package/dist/agent/run-manager.js +26 -0
- package/dist/agent/run-store.d.ts +5 -5
- package/dist/agent/run-store.js +52 -15
- package/dist/agent/thread-data-builder.js +7 -0
- package/dist/agent/types.d.ts +10 -0
- package/dist/cli/code-agent-connector.js +6 -1
- package/dist/client/AssistantChat.d.ts +1 -0
- package/dist/client/AssistantChat.js +10 -1
- package/dist/client/MultiTabAssistantChat.js +33 -1
- package/dist/client/sse-event-processor.js +11 -7
- package/dist/client/use-chat-threads.js +9 -9
- package/dist/db/client.js +10 -2
- package/dist/db/create-get-db.js +42 -0
- package/dist/observability/routes.d.ts +3 -3
- package/dist/progress/routes.d.ts +1 -1
- package/dist/resources/handlers.d.ts +1 -1
- package/dist/secrets/routes.d.ts +9 -9
- package/dist/server/onboarding-html.js +22 -77
- package/dist/server/poll.d.ts +5 -5
- package/dist/server/poll.js +19 -26
- package/dist/server/realtime-token.d.ts +1 -1
- package/dist/server/transcribe-voice.d.ts +1 -1
- package/dist/shared/auth-copy.d.ts +7 -0
- package/dist/shared/auth-copy.js +77 -0
- package/dist/shared/mcp-embed-headers.js +8 -4
- package/dist/vite/client.js +94 -3
- package/package.json +1 -1
|
@@ -9,6 +9,7 @@ import { z } from "zod";
|
|
|
9
9
|
import { interpolate } from "../app/pages/adhoc/sql-dashboard/interpolate";
|
|
10
10
|
import { dryRunQuery } from "../server/lib/bigquery";
|
|
11
11
|
import { queueDashboardCollabSync } from "../server/lib/dashboard-collab-sync";
|
|
12
|
+
import { serializeProgramDescriptorInput } from "../server/lib/dashboard-panel-query";
|
|
12
13
|
import { validateFirstPartyDashboardTimeScope } from "../server/lib/dashboard-time-scope";
|
|
13
14
|
import {
|
|
14
15
|
upsertDashboard,
|
|
@@ -362,6 +363,13 @@ export function validateDashboardConfig(
|
|
|
362
363
|
if (!isSection && !isExtension && !validSources.has(p.source as string)) {
|
|
363
364
|
return `panel[${i}].source must be 'bigquery', 'ga4', 'amplitude', 'first-party', 'demo', 'prometheus', or 'program' (got '${p.source}'). source selects the backend — put the PromQL/SQL/table name or program descriptor in sql, not here.`;
|
|
364
365
|
}
|
|
366
|
+
if (p.source === "program") {
|
|
367
|
+
try {
|
|
368
|
+
serializeProgramDescriptorInput(p.sql);
|
|
369
|
+
} catch (e: any) {
|
|
370
|
+
return `panel[${i}] "${p.title || p.id}" program descriptor is invalid: ${e?.message ?? e}`;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
365
373
|
if (isExtension) {
|
|
366
374
|
const cfg = p.config as Record<string, unknown> | undefined;
|
|
367
375
|
const extensionId =
|
|
@@ -26,8 +26,19 @@ import { buildCaptionSegmentsFromText } from "../shared/transcript-segments.js";
|
|
|
26
26
|
import { booleanParam } from "./lib/cli-params.js";
|
|
27
27
|
import { isAutoTitleReplaceable } from "./lib/title-source.js";
|
|
28
28
|
|
|
29
|
-
|
|
30
|
-
|
|
29
|
+
// web-speech and macos-native are both mic-only engines — see
|
|
30
|
+
// transcription-engine.ts's file header. When a caller sends fullText with
|
|
31
|
+
// no segments (word-level timings were never captured), there's no
|
|
32
|
+
// per-line source to preserve, but for these two engines there's also no
|
|
33
|
+
// ambiguity: every word came from the mic. Leaving source undefined here
|
|
34
|
+
// falls through to resolveSpeaker's default and renders the whole thing as
|
|
35
|
+
// "Them". Whisper mixes mic + system, so it has no safe single-speaker guess.
|
|
36
|
+
function nativeSegmentsJson(
|
|
37
|
+
fullText: string,
|
|
38
|
+
engineSource?: "web-speech" | "macos-native" | "whisper",
|
|
39
|
+
): string {
|
|
40
|
+
const source = engineSource && engineSource !== "whisper" ? "mic" : undefined;
|
|
41
|
+
return JSON.stringify(buildCaptionSegmentsFromText(fullText, null, source));
|
|
31
42
|
}
|
|
32
43
|
|
|
33
44
|
// Real transcript segments supplied by a caller that already has accurate
|
|
@@ -45,6 +56,11 @@ const segmentSchema = z
|
|
|
45
56
|
text: z.string(),
|
|
46
57
|
// Stream the segment came from; the transcript UI maps mic→"Me", system→"Them".
|
|
47
58
|
source: z.enum(["mic", "system"]).optional(),
|
|
59
|
+
// Diarized speaker for this segment, when the provider identifies one.
|
|
60
|
+
// Declared so zod keeps it: an undeclared key is stripped before the array
|
|
61
|
+
// is serialized, which would drop a provider's speaker labels on save and
|
|
62
|
+
// leave the transcript unable to tell its speakers apart on reload.
|
|
63
|
+
speaker: z.string().nullable().optional(),
|
|
48
64
|
})
|
|
49
65
|
.transform((s) => {
|
|
50
66
|
if (s.endMs < s.startMs) {
|
|
@@ -74,7 +90,7 @@ export default defineAction({
|
|
|
74
90
|
.array(segmentSchema)
|
|
75
91
|
.optional()
|
|
76
92
|
.describe(
|
|
77
|
-
"
|
|
93
|
+
"Transcript segments with per-segment timings (ms) and the `mic`/`system` stream each came from. Stored verbatim when provided, instead of synthesizing timings from fullText. Timings are the engine's own where it reported them; the mic-only engines report none, so callers may send estimates to keep each segment's speaker.",
|
|
78
94
|
),
|
|
79
95
|
overwriteReady: booleanParam
|
|
80
96
|
.default(false)
|
|
@@ -97,7 +113,7 @@ export default defineAction({
|
|
|
97
113
|
const segmentsJson =
|
|
98
114
|
args.segments && args.segments.length > 0
|
|
99
115
|
? JSON.stringify(args.segments)
|
|
100
|
-
: nativeSegmentsJson(fullText);
|
|
116
|
+
: nativeSegmentsJson(fullText, args.source);
|
|
101
117
|
|
|
102
118
|
const [current] = await db
|
|
103
119
|
.select({
|
|
@@ -61,8 +61,99 @@ export interface SpeakerIdentity {
|
|
|
61
61
|
initialsSource: AttendeeStackParticipant | string;
|
|
62
62
|
isOwner: boolean;
|
|
63
63
|
accentClass: string;
|
|
64
|
+
/** The capture could not tell speakers apart, so this transcript names
|
|
65
|
+
* nobody — the row renders without an avatar or label rather than claiming
|
|
66
|
+
* a speaker we cannot know. */
|
|
67
|
+
unattributed?: boolean;
|
|
64
68
|
}
|
|
65
69
|
|
|
70
|
+
/**
|
|
71
|
+
* Whether a transcript carries enough signal to name who said what.
|
|
72
|
+
*
|
|
73
|
+
* A capture that only ever produced one speaker signal cannot distinguish two
|
|
74
|
+
* people: the mic-only fallback engines tag every segment `mic` (the remote
|
|
75
|
+
* side reaches the transcript only as bleed into the same microphone), and
|
|
76
|
+
* cloud transcription of a single mixed track tags nothing at all. Attributing
|
|
77
|
+
* those to the recording owner reads as fact and is wrong for every line the
|
|
78
|
+
* other person spoke — including in the AI summary and action items derived
|
|
79
|
+
* from it. A per-segment `speaker` label from a diarizing provider counts as
|
|
80
|
+
* signal even when `source` is absent.
|
|
81
|
+
*
|
|
82
|
+
* Only meaningful when two people could have spoken; a solo recording that is
|
|
83
|
+
* all mic genuinely is all one person.
|
|
84
|
+
*/
|
|
85
|
+
export function transcriptDistinguishesSpeakers(
|
|
86
|
+
segments: TranscriptSegment[],
|
|
87
|
+
participants: AttendeeStackParticipant[],
|
|
88
|
+
ownerEmail?: string | null,
|
|
89
|
+
): boolean {
|
|
90
|
+
if (countPossibleSpeakers(participants, ownerEmail) < 2) return true;
|
|
91
|
+
const signals = new Set<string>();
|
|
92
|
+
for (const segment of segments) {
|
|
93
|
+
const signal = speakerSignal(segment);
|
|
94
|
+
if (signal) signals.add(signal);
|
|
95
|
+
if (signals.size > 1) return true;
|
|
96
|
+
}
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* What one segment claims about who was speaking, as a comparable key.
|
|
102
|
+
*
|
|
103
|
+
* A generic placeholder is not an identity — it names a side of the
|
|
104
|
+
* conversation, which is what `source` already says. Counting `speaker: "Me"`
|
|
105
|
+
* and a plain `source: "mic"` as two different speakers would mark a mic-only
|
|
106
|
+
* transcript distinguishable and hand the remote side's bleed back to the
|
|
107
|
+
* owner's name, so placeholders resolve to the side they mean instead. A real
|
|
108
|
+
* name wins over `source`, since a diarizing provider knows more than the
|
|
109
|
+
* stream split does; a placeholder yields to it.
|
|
110
|
+
*/
|
|
111
|
+
function speakerSignal(segment: TranscriptSegment): string | null {
|
|
112
|
+
const speaker = segment.speaker?.trim();
|
|
113
|
+
const side = placeholderSide(speaker);
|
|
114
|
+
if (speaker && !side) return `speaker:${normalizeSpeaker(speaker)}`;
|
|
115
|
+
if (segment.source) return `source:${segment.source}`;
|
|
116
|
+
// No placeholder and no source is an absence of information, not a side.
|
|
117
|
+
// Defaulting it to "system" here would make a transcript that mixes tagged
|
|
118
|
+
// and untagged segments look like two speakers.
|
|
119
|
+
return side ? `source:${side}` : null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* How many people could have spoken in this meeting.
|
|
124
|
+
*
|
|
125
|
+
* The participant roster is the calendar attendee list, which routinely omits
|
|
126
|
+
* the recording owner — `create-meeting` deliberately does not synthesize a row
|
|
127
|
+
* for a non-attendee owner, because that table feeds the public share payload.
|
|
128
|
+
* So counting rows alone reads an owner-plus-one-attendee meeting as solo and
|
|
129
|
+
* hands a mic-only transcript back to attribution, which is what labels the
|
|
130
|
+
* remote side's bleed as the owner.
|
|
131
|
+
*
|
|
132
|
+
* A withheld owner (`null`, from the public share page) still counts: it means
|
|
133
|
+
* an owner exists and is not among the participants. An owner we were never
|
|
134
|
+
* told about (`undefined`) also counts, because "we cannot name them" is not
|
|
135
|
+
* the same as "they are not there" — the cost of over-counting is a lost label,
|
|
136
|
+
* and the cost of under-counting is a false one.
|
|
137
|
+
*/
|
|
138
|
+
function countPossibleSpeakers(
|
|
139
|
+
participants: AttendeeStackParticipant[],
|
|
140
|
+
ownerEmail?: string | null,
|
|
141
|
+
): number {
|
|
142
|
+
const ownerInRoster = ownerEmail
|
|
143
|
+
? Boolean(findParticipant(ownerEmail, participants))
|
|
144
|
+
: false;
|
|
145
|
+
return participants.length + (ownerInRoster ? 0 : 1);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const UNATTRIBUTED_SPEAKER: SpeakerIdentity = {
|
|
149
|
+
key: "unattributed",
|
|
150
|
+
label: null,
|
|
151
|
+
initialsSource: "",
|
|
152
|
+
isOwner: false,
|
|
153
|
+
accentClass: "",
|
|
154
|
+
unattributed: true,
|
|
155
|
+
};
|
|
156
|
+
|
|
66
157
|
const SPEAKER_ACCENTS = [
|
|
67
158
|
"bg-accent text-accent-foreground",
|
|
68
159
|
"bg-secondary text-secondary-foreground",
|
|
@@ -75,6 +166,37 @@ function normalizeSpeaker(value: string): string {
|
|
|
75
166
|
return value.trim().toLowerCase().replace(/\s+/g, " ");
|
|
76
167
|
}
|
|
77
168
|
|
|
169
|
+
// Some providers (and our own seed fixture) tag unresolved segments with a
|
|
170
|
+
// literal placeholder word instead of leaving `speaker` blank. These name a
|
|
171
|
+
// side of the conversation rather than a person, so neither the label nor the
|
|
172
|
+
// attribution check may treat one as an identity.
|
|
173
|
+
const GENERIC_MIC_SPEAKER = /^(me|self|you)$/i;
|
|
174
|
+
const GENERIC_SYSTEM_SPEAKER = /^them$/i;
|
|
175
|
+
|
|
176
|
+
/** The side a generic placeholder names, or null when it names a person. */
|
|
177
|
+
function placeholderSide(
|
|
178
|
+
label: string | null | undefined,
|
|
179
|
+
): "mic" | "system" | null {
|
|
180
|
+
const speaker = label?.trim();
|
|
181
|
+
if (!speaker) return null;
|
|
182
|
+
if (GENERIC_MIC_SPEAKER.test(speaker)) return "mic";
|
|
183
|
+
if (GENERIC_SYSTEM_SPEAKER.test(speaker)) return "system";
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Which side of the conversation a segment belongs to.
|
|
189
|
+
*
|
|
190
|
+
* `source` is the real signal, but a segment can arrive with only a
|
|
191
|
+
* placeholder speaker on it. Falling straight through to "system" then drops
|
|
192
|
+
* every "Me" into the remote group, which is how a transcript labelled purely
|
|
193
|
+
* with placeholders renders entirely as "Them" — the original bug. Read the
|
|
194
|
+
* side the placeholder names before defaulting.
|
|
195
|
+
*/
|
|
196
|
+
function segmentSide(segment: TranscriptSegment): "mic" | "system" {
|
|
197
|
+
return segment.source ?? placeholderSide(segment.speaker) ?? "system";
|
|
198
|
+
}
|
|
199
|
+
|
|
78
200
|
// Exported for regression testing — see transcript-bubbles.test.ts. These
|
|
79
201
|
// are pure functions with no dependency on the component itself.
|
|
80
202
|
export function findParticipant(
|
|
@@ -139,22 +261,23 @@ export function resolveSpeaker(
|
|
|
139
261
|
participants: AttendeeStackParticipant[],
|
|
140
262
|
ownerEmail?: string | null,
|
|
141
263
|
): SpeakerIdentity {
|
|
142
|
-
const source = segment
|
|
264
|
+
const source = segmentSide(segment);
|
|
143
265
|
const rawSpeaker = segment.speaker?.trim();
|
|
144
266
|
const participant =
|
|
145
267
|
findParticipant(segment.speaker, participants) ||
|
|
146
268
|
resolveParticipantForSpeaker(source, participants, ownerEmail);
|
|
147
269
|
const participantName = participant?.name?.trim();
|
|
148
270
|
const resolvedLabel = participantName || rawSpeaker;
|
|
149
|
-
//
|
|
150
|
-
//
|
|
151
|
-
//
|
|
152
|
-
//
|
|
271
|
+
// Treat a placeholder as "no label" so the UI falls back to the translated
|
|
272
|
+
// Me/Them string instead of rendering the raw English word verbatim. Only
|
|
273
|
+
// the placeholder matching this segment's own side counts: "Them" on a mic
|
|
274
|
+
// segment is a contradiction, not a placeholder, and keeping it visible is
|
|
275
|
+
// better than silently dropping it.
|
|
153
276
|
const isGenericPlaceholderLabel =
|
|
154
277
|
!!resolvedLabel &&
|
|
155
278
|
(source === "mic"
|
|
156
|
-
?
|
|
157
|
-
:
|
|
279
|
+
? GENERIC_MIC_SPEAKER.test(resolvedLabel)
|
|
280
|
+
: GENERIC_SYSTEM_SPEAKER.test(resolvedLabel));
|
|
158
281
|
const label = isGenericPlaceholderLabel
|
|
159
282
|
? null
|
|
160
283
|
: participantName || rawSpeaker || null;
|
|
@@ -203,9 +326,16 @@ function groupConsecutive(
|
|
|
203
326
|
participants: AttendeeStackParticipant[],
|
|
204
327
|
ownerEmail?: string | null,
|
|
205
328
|
): BubbleGroup[] {
|
|
329
|
+
const attributable = transcriptDistinguishesSpeakers(
|
|
330
|
+
segments,
|
|
331
|
+
participants,
|
|
332
|
+
ownerEmail,
|
|
333
|
+
);
|
|
206
334
|
const groups: BubbleGroup[] = [];
|
|
207
335
|
segments.forEach((seg, index) => {
|
|
208
|
-
const speaker =
|
|
336
|
+
const speaker = attributable
|
|
337
|
+
? resolveSpeaker(seg, participants, ownerEmail)
|
|
338
|
+
: UNATTRIBUTED_SPEAKER;
|
|
209
339
|
const last = groups[groups.length - 1];
|
|
210
340
|
if (last && last.speaker.key === speaker.key) {
|
|
211
341
|
last.segments.push({ seg, index });
|
|
@@ -509,38 +639,40 @@ export function TranscriptBubbles({
|
|
|
509
639
|
key={`${group.speaker.key}:${gi}`}
|
|
510
640
|
className="space-y-0.5"
|
|
511
641
|
>
|
|
512
|
-
|
|
513
|
-
<
|
|
514
|
-
|
|
515
|
-
"size-6 shrink-0",
|
|
516
|
-
group.speaker.accentClass,
|
|
517
|
-
)}
|
|
518
|
-
>
|
|
519
|
-
<AvatarFallback
|
|
642
|
+
{!group.speaker.unattributed && (
|
|
643
|
+
<div className="flex h-6 items-center gap-2">
|
|
644
|
+
<Avatar
|
|
520
645
|
className={cn(
|
|
521
|
-
"
|
|
646
|
+
"size-6 shrink-0",
|
|
522
647
|
group.speaker.accentClass,
|
|
523
648
|
)}
|
|
524
649
|
>
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
650
|
+
<AvatarFallback
|
|
651
|
+
className={cn(
|
|
652
|
+
"text-[9px] font-semibold",
|
|
653
|
+
group.speaker.accentClass,
|
|
654
|
+
)}
|
|
655
|
+
>
|
|
656
|
+
{attendeeInitials(group.speaker.initialsSource)}
|
|
657
|
+
</AvatarFallback>
|
|
658
|
+
</Avatar>
|
|
659
|
+
<div className="flex min-h-6 items-center">
|
|
660
|
+
<span
|
|
661
|
+
className={cn(
|
|
662
|
+
"text-xs font-semibold leading-6",
|
|
663
|
+
group.speaker.isOwner
|
|
664
|
+
? "text-primary"
|
|
665
|
+
: "text-foreground",
|
|
666
|
+
)}
|
|
667
|
+
>
|
|
668
|
+
{group.speaker.label ||
|
|
669
|
+
(group.speaker.isOwner
|
|
670
|
+
? t("transcriptBubbles.me")
|
|
671
|
+
: t("transcriptBubbles.them"))}
|
|
672
|
+
</span>
|
|
673
|
+
</div>
|
|
542
674
|
</div>
|
|
543
|
-
|
|
675
|
+
)}
|
|
544
676
|
<div className="space-y-1">
|
|
545
677
|
{group.segments.map(({ seg, index }) => {
|
|
546
678
|
return (
|
|
@@ -36,7 +36,9 @@ function wait(ms: number): Promise<void> {
|
|
|
36
36
|
export interface CapturedTranscript {
|
|
37
37
|
/** Speaker-labelled text, lines joined by blank lines. */
|
|
38
38
|
text: string;
|
|
39
|
-
/**
|
|
39
|
+
/** Whisper's verbatim timestamps where the engine reported them, else one
|
|
40
|
+
* synthesized segment per line — the mic-only engines report no timings,
|
|
41
|
+
* and a dropped line would lose its speaker along with its text. */
|
|
40
42
|
segments: SourcedTranscriptSegment[];
|
|
41
43
|
/** Source stored with `save-browser-transcript`. */
|
|
42
44
|
source?: "web-speech" | "macos-native" | "whisper";
|
|
@@ -332,9 +334,14 @@ export async function startTranscriptionCapture(
|
|
|
332
334
|
});
|
|
333
335
|
};
|
|
334
336
|
|
|
337
|
+
// `source` reports the engine that actually produced this transcript, not
|
|
338
|
+
// the one we asked for: `startTranscriptionEngine` may have fallen back to
|
|
339
|
+
// mic-only macos-native. Omitting it made the server default to "whisper"
|
|
340
|
+
// and treat a mic-only capture as mixed mic + system audio.
|
|
335
341
|
const captured = (): CapturedTranscript => ({
|
|
336
342
|
text: transcriptFullText(lines),
|
|
337
343
|
segments: transcriptSegments(lines),
|
|
344
|
+
source: engine,
|
|
338
345
|
});
|
|
339
346
|
|
|
340
347
|
let engine: TranscriptionEngine;
|
|
@@ -245,11 +245,48 @@ export function transcriptFullText(lines: TranscriptLine[]): string {
|
|
|
245
245
|
.trim();
|
|
246
246
|
}
|
|
247
247
|
|
|
248
|
-
/**
|
|
248
|
+
/** Rough duration estimate for a line with no verbatim timing, matching the
|
|
249
|
+
* pacing `buildCaptionSegmentsFromText` uses server-side. */
|
|
250
|
+
function estimatedLineDurationMs(text: string): number {
|
|
251
|
+
const words = text.split(/\s+/).filter(Boolean).length || 1;
|
|
252
|
+
return Math.max(900, words * 420);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Flattened verbatim segments, as persisted alongside the text.
|
|
256
|
+
*
|
|
257
|
+
* A line with no verbatim timings still contributes one synthesized segment
|
|
258
|
+
* rather than being dropped: the mic-only fallback engines report no
|
|
259
|
+
* timestamps at all, and dropping the line loses its text and its `source`
|
|
260
|
+
* — and a `source`-less stored segment silently renders as "Them". Those
|
|
261
|
+
* engines report `startMs: null` on *every* line, so a synthesized segment
|
|
262
|
+
* continues from the previous line's end; anchoring each at its own
|
|
263
|
+
* null-coalesced 0 would stack the whole transcript on one instant and break
|
|
264
|
+
* ordering and timestamp seeking. */
|
|
249
265
|
export function transcriptSegments(
|
|
250
266
|
lines: TranscriptLine[],
|
|
251
267
|
): SourcedTranscriptSegment[] {
|
|
252
|
-
|
|
268
|
+
const segments: SourcedTranscriptSegment[] = [];
|
|
269
|
+
let cursorMs = 0;
|
|
270
|
+
for (const line of lines) {
|
|
271
|
+
if (line.segments.length) {
|
|
272
|
+
segments.push(...line.segments);
|
|
273
|
+
cursorMs = line.segments.reduce(
|
|
274
|
+
(latest, segment) => Math.max(latest, segment.endMs),
|
|
275
|
+
cursorMs,
|
|
276
|
+
);
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
// `appendFinalTranscript` is the only path that yields an untimed line and
|
|
280
|
+
// it always pairs `segments: []` with `startMs: null`, so today this reads
|
|
281
|
+
// as `cursorMs`. Honouring a line's own stamp is for the shape the type
|
|
282
|
+
// still permits (see `historyLine` in overlays/live-transcript.tsx), and
|
|
283
|
+
// clamping to the cursor keeps a stale stamp from reordering the transcript.
|
|
284
|
+
const startMs = Math.max(line.startMs ?? cursorMs, cursorMs);
|
|
285
|
+
const endMs = startMs + estimatedLineDurationMs(line.text);
|
|
286
|
+
segments.push({ startMs, endMs, text: line.text, source: line.source });
|
|
287
|
+
cursorMs = endMs;
|
|
288
|
+
}
|
|
289
|
+
return segments;
|
|
253
290
|
}
|
|
254
291
|
|
|
255
292
|
/**
|
|
@@ -601,6 +601,9 @@ function renderFormPage(
|
|
|
601
601
|
if (v) data[f.id] = parseInt(v);
|
|
602
602
|
} else if (f.type === "scale") {
|
|
603
603
|
data[f.id] = parseInt(el.querySelector(".slider").value);
|
|
604
|
+
} else if (f.type === "radio") {
|
|
605
|
+
var checked = el.querySelector('input[type="radio"]:checked');
|
|
606
|
+
if (checked && checked.value) data[f.id] = checked.value;
|
|
604
607
|
} else {
|
|
605
608
|
var input = el.querySelector("input, textarea, select");
|
|
606
609
|
if (input && input.value) data[f.id] = input.value;
|
|
@@ -22,7 +22,9 @@ export default defineAction({
|
|
|
22
22
|
includeSlides: z
|
|
23
23
|
.enum(["true", "false"])
|
|
24
24
|
.optional()
|
|
25
|
-
.describe(
|
|
25
|
+
.describe(
|
|
26
|
+
"Set to 'true' for full frontend deck payloads; omitted returns metadata only",
|
|
27
|
+
),
|
|
26
28
|
light: z
|
|
27
29
|
.enum(["true", "false"])
|
|
28
30
|
.optional()
|
|
@@ -84,6 +86,39 @@ export default defineAction({
|
|
|
84
86
|
return { count: rows.length, decks: rows };
|
|
85
87
|
}
|
|
86
88
|
|
|
89
|
+
if (args.includeSlides !== "true") {
|
|
90
|
+
// The deck body is an opaque JSON blob containing every slide's HTML.
|
|
91
|
+
// Metadata callers must opt into it explicitly; the frontend opens one
|
|
92
|
+
// deck at a time through get-deck instead of downloading every body.
|
|
93
|
+
const rows = await db
|
|
94
|
+
.select({
|
|
95
|
+
id: schema.decks.id,
|
|
96
|
+
title: schema.decks.title,
|
|
97
|
+
ownerEmail: schema.decks.ownerEmail,
|
|
98
|
+
designSystemId: schema.decks.designSystemId,
|
|
99
|
+
createdAt: schema.decks.createdAt,
|
|
100
|
+
updatedAt: schema.decks.updatedAt,
|
|
101
|
+
visibility: schema.decks.visibility,
|
|
102
|
+
})
|
|
103
|
+
.from(schema.decks)
|
|
104
|
+
.where(where)
|
|
105
|
+
.orderBy(desc(schema.decks.updatedAt));
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
count: rows.length,
|
|
109
|
+
decks: rows.map((row) => ({
|
|
110
|
+
id: row.id,
|
|
111
|
+
title: row.title,
|
|
112
|
+
url: getDeckUrl(row.id),
|
|
113
|
+
visibility: row.visibility,
|
|
114
|
+
designSystemId: row.designSystemId ?? null,
|
|
115
|
+
createdByMe: ownerEmail ? row.ownerEmail === ownerEmail : false,
|
|
116
|
+
createdAt: row.createdAt,
|
|
117
|
+
updatedAt: row.updatedAt,
|
|
118
|
+
})),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
87
122
|
const rows = await db
|
|
88
123
|
.select()
|
|
89
124
|
.from(schema.decks)
|
|
@@ -965,11 +965,13 @@ function MultiSelectOutline({
|
|
|
965
965
|
}
|
|
966
966
|
|
|
967
967
|
/** Translucent rectangle drawn while marquee-dragging */
|
|
968
|
+
type MarqueeSelectionRect = { x: number; y: number; w: number; h: number };
|
|
969
|
+
|
|
968
970
|
function MarqueeRect({
|
|
969
971
|
rect,
|
|
970
972
|
viewportRect,
|
|
971
973
|
}: {
|
|
972
|
-
rect:
|
|
974
|
+
rect: MarqueeSelectionRect;
|
|
973
975
|
viewportRect: DOMRect | null;
|
|
974
976
|
}) {
|
|
975
977
|
return (
|
|
@@ -1204,12 +1206,7 @@ export default function SlideEditor({
|
|
|
1204
1206
|
/** Anchor rect for the floating chip (the slide canvas) */
|
|
1205
1207
|
const [chipAnchorRect, setChipAnchorRect] = useState<DOMRect | null>(null);
|
|
1206
1208
|
/** Active marquee rectangle (viewport coords). null = not dragging. */
|
|
1207
|
-
const [marquee, setMarquee] = useState<
|
|
1208
|
-
x: number;
|
|
1209
|
-
y: number;
|
|
1210
|
-
w: number;
|
|
1211
|
-
h: number;
|
|
1212
|
-
} | null>(null);
|
|
1209
|
+
const [marquee, setMarquee] = useState<MarqueeSelectionRect | null>(null);
|
|
1213
1210
|
const [activeAlignmentGuides, setActiveAlignmentGuides] = useState<{
|
|
1214
1211
|
guides: SlideAlignmentGuide[];
|
|
1215
1212
|
viewport: AlignmentGuideViewport;
|
|
@@ -1502,6 +1499,8 @@ export default function SlideEditor({
|
|
|
1502
1499
|
}, [overflowInfo, slide.id, dims.width, dims.height]);
|
|
1503
1500
|
/** Marquee origin (viewport coords). Set on pointerdown. */
|
|
1504
1501
|
const marqueeOriginRef = useRef<{ x: number; y: number } | null>(null);
|
|
1502
|
+
/** Latest marquee geometry, readable by the stable window pointer handlers. */
|
|
1503
|
+
const marqueeRef = useRef<MarqueeSelectionRect | null>(null);
|
|
1505
1504
|
/** Set right before placing a text box so the click event that follows the
|
|
1506
1505
|
* placing pointerdown doesn't fall through to click-to-select/deselect
|
|
1507
1506
|
* logic and steal focus back off the freshly created box. */
|
|
@@ -4432,7 +4431,12 @@ export default function SlideEditor({
|
|
|
4432
4431
|
marqueeOriginRef.current = { x: e.clientX, y: e.clientY };
|
|
4433
4432
|
marqueeAdditiveRef.current = e.shiftKey || e.metaKey || e.ctrlKey;
|
|
4434
4433
|
marqueePrevSelectionRef.current = new Set(multiSelection);
|
|
4435
|
-
|
|
4434
|
+
const initialMarquee = { x: e.clientX, y: e.clientY, w: 0, h: 0 };
|
|
4435
|
+
marqueeRef.current = initialMarquee;
|
|
4436
|
+
setMarquee(initialMarquee);
|
|
4437
|
+
if (e.pointerId >= 0) {
|
|
4438
|
+
e.currentTarget.setPointerCapture(e.pointerId);
|
|
4439
|
+
}
|
|
4436
4440
|
|
|
4437
4441
|
// Clear single-select feedback when starting a marquee on whitespace
|
|
4438
4442
|
// (non-additive). Additive marquee preserves the existing selection.
|
|
@@ -4462,25 +4466,28 @@ export default function SlideEditor({
|
|
|
4462
4466
|
],
|
|
4463
4467
|
);
|
|
4464
4468
|
|
|
4465
|
-
//
|
|
4466
|
-
//
|
|
4469
|
+
// Keep these listeners stable while React re-renders the marquee overlay.
|
|
4470
|
+
// Re-attaching them whenever marquee state changes can lose a fast
|
|
4471
|
+
// pointermove/pointerup between the effect cleanup and re-install.
|
|
4467
4472
|
useEffect(() => {
|
|
4468
|
-
if (!marquee) return;
|
|
4469
4473
|
const onMove = (e: PointerEvent) => {
|
|
4470
4474
|
const origin = marqueeOriginRef.current;
|
|
4471
|
-
if (!origin) return;
|
|
4475
|
+
if (!origin || !marqueeRef.current) return;
|
|
4472
4476
|
const x = Math.min(origin.x, e.clientX);
|
|
4473
4477
|
const y = Math.min(origin.y, e.clientY);
|
|
4474
4478
|
const w = Math.abs(e.clientX - origin.x);
|
|
4475
4479
|
const h = Math.abs(e.clientY - origin.y);
|
|
4476
|
-
|
|
4480
|
+
const nextMarquee = { x, y, w, h };
|
|
4481
|
+
marqueeRef.current = nextMarquee;
|
|
4482
|
+
setMarquee(nextMarquee);
|
|
4477
4483
|
};
|
|
4478
|
-
const
|
|
4484
|
+
const finish = (cancelled: boolean) => {
|
|
4479
4485
|
const origin = marqueeOriginRef.current;
|
|
4480
|
-
const current =
|
|
4486
|
+
const current = marqueeRef.current;
|
|
4481
4487
|
marqueeOriginRef.current = null;
|
|
4488
|
+
marqueeRef.current = null;
|
|
4482
4489
|
setMarquee(null);
|
|
4483
|
-
if (!origin || !current) return;
|
|
4490
|
+
if (cancelled || !origin || !current) return;
|
|
4484
4491
|
|
|
4485
4492
|
const slideContent = getSlideContent();
|
|
4486
4493
|
if (!slideContent) return;
|
|
@@ -4518,13 +4525,18 @@ export default function SlideEditor({
|
|
|
4518
4525
|
|
|
4519
4526
|
applyMultiSelection(hits);
|
|
4520
4527
|
};
|
|
4528
|
+
|
|
4529
|
+
const onUp = () => finish(false);
|
|
4530
|
+
const onCancel = () => finish(true);
|
|
4521
4531
|
window.addEventListener("pointermove", onMove);
|
|
4522
4532
|
window.addEventListener("pointerup", onUp);
|
|
4533
|
+
window.addEventListener("pointercancel", onCancel);
|
|
4523
4534
|
return () => {
|
|
4524
4535
|
window.removeEventListener("pointermove", onMove);
|
|
4525
4536
|
window.removeEventListener("pointerup", onUp);
|
|
4537
|
+
window.removeEventListener("pointercancel", onCancel);
|
|
4526
4538
|
};
|
|
4527
|
-
}, [
|
|
4539
|
+
}, [getSlideContent, applyMultiSelection]);
|
|
4528
4540
|
|
|
4529
4541
|
/** Send the current selection to the agent chat composer */
|
|
4530
4542
|
const sendSelectionToAgent = useCallback(() => {
|
|
@@ -940,7 +940,7 @@ export function deriveInverseOp(
|
|
|
940
940
|
}
|
|
941
941
|
|
|
942
942
|
/**
|
|
943
|
-
* Fetch the deck list. Returns `null` on any failure (network error, non-2xx
|
|
943
|
+
* Fetch the deck metadata list. Returns `null` on any failure (network error, non-2xx
|
|
944
944
|
* response) so callers can distinguish "authoritative empty list" from
|
|
945
945
|
* "couldn't reach the server" — wiping local state on a transient failure
|
|
946
946
|
* kicks the user out of the editor and shows the "Create your first deck"
|
|
@@ -951,7 +951,7 @@ async function fetchDecksFromAPI(): Promise<Deck[] | null> {
|
|
|
951
951
|
try {
|
|
952
952
|
const result = await callAction<DeckListActionResult>(
|
|
953
953
|
"list-decks",
|
|
954
|
-
{
|
|
954
|
+
{ light: "true" },
|
|
955
955
|
{ method: "GET" },
|
|
956
956
|
);
|
|
957
957
|
if (!Array.isArray(result?.decks)) {
|
|
@@ -1076,12 +1076,23 @@ export async function includeOpenDeckIfMissing(
|
|
|
1076
1076
|
async function fetchDecksForCurrentRoute(): Promise<Deck[] | null> {
|
|
1077
1077
|
const currentOpenDeckId = currentOpenDeckIdFromWindow();
|
|
1078
1078
|
const loaded = await fetchDecksFromAPI();
|
|
1079
|
-
if (loaded
|
|
1080
|
-
|
|
1079
|
+
if (loaded === null) {
|
|
1080
|
+
if (!currentOpenDeckId) return null;
|
|
1081
|
+
const directDeck = await fetchDeckFromAPI(currentOpenDeckId);
|
|
1082
|
+
return directDeck ? [directDeck] : null;
|
|
1081
1083
|
}
|
|
1082
|
-
if (!currentOpenDeckId) return
|
|
1084
|
+
if (!currentOpenDeckId) return loaded;
|
|
1085
|
+
|
|
1086
|
+
// The list is intentionally metadata-only. Hydrate just the deck the user
|
|
1087
|
+
// opened so the editor gets full slide content without making the home page
|
|
1088
|
+
// download every deck body.
|
|
1083
1089
|
const directDeck = await fetchDeckFromAPI(currentOpenDeckId);
|
|
1084
|
-
|
|
1090
|
+
if (!directDeck) return loaded;
|
|
1091
|
+
const index = loaded.findIndex((deck) => deck.id === currentOpenDeckId);
|
|
1092
|
+
if (index < 0) return [...loaded, directDeck];
|
|
1093
|
+
const next = [...loaded];
|
|
1094
|
+
next[index] = directDeck;
|
|
1095
|
+
return next;
|
|
1085
1096
|
}
|
|
1086
1097
|
|
|
1087
1098
|
async function deleteDeckFromAPI(id: string): Promise<void> {
|