@agent-native/core 0.161.2 → 0.161.4
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/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/slides/actions/list-decks.ts +36 -1
- 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.js +1 -0
- package/dist/agent/run-manager.d.ts +6 -0
- package/dist/agent/run-manager.js +19 -0
- package/dist/agent/run-store.d.ts +5 -5
- package/dist/agent/run-store.js +52 -15
- package/dist/client/AssistantChat.d.ts +1 -0
- package/dist/client/AssistantChat.js +10 -1
- package/dist/collab/struct-routes.d.ts +1 -1
- package/dist/db/client.js +10 -2
- package/dist/db/create-get-db.js +42 -0
- package/dist/file-upload/actions/upload-image.d.ts +1 -1
- 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/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 +3 -3
|
@@ -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
|
/**
|
|
@@ -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)
|
|
@@ -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> {
|
|
@@ -226,6 +226,17 @@ class BuilderEngine {
|
|
|
226
226
|
}
|
|
227
227
|
: {}),
|
|
228
228
|
};
|
|
229
|
+
// Measured once, from the exact string that goes on the wire, and carried
|
|
230
|
+
// on every error stop below. A gateway rejection tells us nothing about
|
|
231
|
+
// what we sent, so without this an oversized or malformed request and a
|
|
232
|
+
// gateway outage are the same capture.
|
|
233
|
+
const payload = JSON.stringify(body);
|
|
234
|
+
const requestShape = {
|
|
235
|
+
model: opts.model,
|
|
236
|
+
payloadBytes: new TextEncoder().encode(payload).length,
|
|
237
|
+
toolCount: cachedTools.length,
|
|
238
|
+
messageCount: cachedMessages.length,
|
|
239
|
+
};
|
|
229
240
|
const gatewayBaseUrl = getBuilderGatewayBaseUrl();
|
|
230
241
|
const gatewayUrl = new URL("messages", gatewayBaseUrl.endsWith("/") ? gatewayBaseUrl : `${gatewayBaseUrl}/`);
|
|
231
242
|
gatewayUrl.searchParams.set("apiKey", spaceId);
|
|
@@ -246,7 +257,7 @@ class BuilderEngine {
|
|
|
246
257
|
...getBuilderGatewayRequestHeaders(),
|
|
247
258
|
...(builderUserId ? { "x-builder-user-id": builderUserId } : {}),
|
|
248
259
|
},
|
|
249
|
-
body:
|
|
260
|
+
body: payload,
|
|
250
261
|
signal: gatewayAbort.signal,
|
|
251
262
|
});
|
|
252
263
|
}
|
|
@@ -265,12 +276,12 @@ class BuilderEngine {
|
|
|
265
276
|
elapsedMs: Date.now() - tStart,
|
|
266
277
|
});
|
|
267
278
|
}
|
|
268
|
-
yield createBuilderGatewayTimeoutStop(err, timedOut, gatewayAbort.effectiveTimeoutMs(), creditsLane);
|
|
279
|
+
yield createBuilderGatewayTimeoutStop(err, timedOut, gatewayAbort.effectiveTimeoutMs(), creditsLane, requestShape);
|
|
269
280
|
return;
|
|
270
281
|
}
|
|
271
282
|
console.log(`[builder-engine] ← ${response.status} ${response.statusText} in ${Date.now() - tStart}ms`);
|
|
272
283
|
if (!response.ok) {
|
|
273
|
-
yield* emitHttpError(response, { creditsLane });
|
|
284
|
+
yield* emitHttpError(response, { creditsLane, requestShape });
|
|
274
285
|
return;
|
|
275
286
|
}
|
|
276
287
|
// A successful gateway call proves the connected credentials are valid
|
|
@@ -302,7 +313,7 @@ class BuilderEngine {
|
|
|
302
313
|
...(isTransientGatewayFailure(error, status)
|
|
303
314
|
? { providerRetryable: true }
|
|
304
315
|
: {}),
|
|
305
|
-
}, creditsLane);
|
|
316
|
+
}, creditsLane, requestShape);
|
|
306
317
|
return;
|
|
307
318
|
}
|
|
308
319
|
const reader = response.body?.getReader();
|
|
@@ -311,7 +322,7 @@ class BuilderEngine {
|
|
|
311
322
|
error: "Builder gateway response has no body",
|
|
312
323
|
errorCode: "builder_gateway_error",
|
|
313
324
|
statusCode: response.status,
|
|
314
|
-
}, creditsLane);
|
|
325
|
+
}, creditsLane, requestShape);
|
|
315
326
|
return;
|
|
316
327
|
}
|
|
317
328
|
yield* parseJsonlStream(reader, opts.model, {
|
|
@@ -322,6 +333,7 @@ class BuilderEngine {
|
|
|
322
333
|
onFirstEvent: gatewayAbort.markFirstEvent,
|
|
323
334
|
gatewayUrl,
|
|
324
335
|
requestStartedAt: tStart,
|
|
336
|
+
requestShape,
|
|
325
337
|
});
|
|
326
338
|
}
|
|
327
339
|
finally {
|
|
@@ -358,7 +370,7 @@ function isTransientGatewayFailure(rawMessage, status) {
|
|
|
358
370
|
* signals downstream may read: keyword coupling to the message turns a retryable
|
|
359
371
|
* throttle into a dead turn on credits sites alone.
|
|
360
372
|
*/
|
|
361
|
-
function gatewayErrorStop(details, creditsLane) {
|
|
373
|
+
function gatewayErrorStop(details, creditsLane, requestShape) {
|
|
362
374
|
const { error, errorCode, upgradeUrl, ...retry } = details;
|
|
363
375
|
return {
|
|
364
376
|
type: "stop",
|
|
@@ -373,6 +385,9 @@ function gatewayErrorStop(details, creditsLane) {
|
|
|
373
385
|
...(isContextOverflowMessage(error) || isContextOverflowCode(errorCode)
|
|
374
386
|
? { contextOverflow: true }
|
|
375
387
|
: {}),
|
|
388
|
+
// Absent before the request is built (missing credentials): a stop with no
|
|
389
|
+
// shape means nothing was sent, not that the payload measured zero.
|
|
390
|
+
...(requestShape ? { requestShape } : {}),
|
|
376
391
|
...retry,
|
|
377
392
|
};
|
|
378
393
|
}
|
|
@@ -394,7 +409,7 @@ async function* emitHttpError(response, opts) {
|
|
|
394
409
|
}
|
|
395
410
|
const code = errBody.code ?? `http_${status}`;
|
|
396
411
|
const message = errBody.message ?? `Builder gateway returned ${status}`;
|
|
397
|
-
const stop = (details) => gatewayErrorStop(details, opts.creditsLane);
|
|
412
|
+
const stop = (details) => gatewayErrorStop(details, opts.creditsLane, opts.requestShape);
|
|
398
413
|
// Belt-and-suspenders: 402 without a structured `credits-limit` code
|
|
399
414
|
// (e.g. bare proxy response) still means quota → show upgrade CTA.
|
|
400
415
|
if (code.startsWith("credits-limit") || status === 402) {
|
|
@@ -543,7 +558,7 @@ async function* parseJsonlStream(reader, model, captureContext = {}) {
|
|
|
543
558
|
errorCode: "http_502",
|
|
544
559
|
statusCode: 502,
|
|
545
560
|
providerRetryable: true,
|
|
546
|
-
}, captureContext.creditsLane);
|
|
561
|
+
}, captureContext.creditsLane, captureContext.requestShape);
|
|
547
562
|
return;
|
|
548
563
|
}
|
|
549
564
|
// Heartbeats are transport-level keepalives, not proof the model is
|
|
@@ -624,7 +639,7 @@ async function* parseJsonlStream(reader, model, captureContext = {}) {
|
|
|
624
639
|
yield* recoverUndeliveredToolCalls();
|
|
625
640
|
yield { type: "assistant-content", parts };
|
|
626
641
|
const reason = event.reason ?? "end_turn";
|
|
627
|
-
const stop = (details) => gatewayErrorStop(details, captureContext.creditsLane);
|
|
642
|
+
const stop = (details) => gatewayErrorStop(details, captureContext.creditsLane, captureContext.requestShape);
|
|
628
643
|
if (reason === "rate_limited") {
|
|
629
644
|
yield stop({
|
|
630
645
|
error: `rate_limit exceeded: ${event.error ?? "upstream provider rate limited"}`,
|
|
@@ -744,7 +759,7 @@ async function* parseJsonlStream(reader, model, captureContext = {}) {
|
|
|
744
759
|
yield gatewayErrorStop({
|
|
745
760
|
error: "Builder gateway stream ended without a stop event",
|
|
746
761
|
errorCode: BUILDER_GATEWAY_STREAM_ENDED_ERROR_CODE,
|
|
747
|
-
}, captureContext.creditsLane);
|
|
762
|
+
}, captureContext.creditsLane, captureContext.requestShape);
|
|
748
763
|
}
|
|
749
764
|
catch (err) {
|
|
750
765
|
const timedOut = captureContext.didGatewayTimeout?.() ?? false;
|
|
@@ -762,7 +777,7 @@ async function* parseJsonlStream(reader, model, captureContext = {}) {
|
|
|
762
777
|
: undefined,
|
|
763
778
|
});
|
|
764
779
|
}
|
|
765
|
-
yield createBuilderGatewayTimeoutStop(err, timedOut, gatewayTimeoutMs, captureContext.creditsLane);
|
|
780
|
+
yield createBuilderGatewayTimeoutStop(err, timedOut, gatewayTimeoutMs, captureContext.creditsLane, captureContext.requestShape);
|
|
766
781
|
}
|
|
767
782
|
finally {
|
|
768
783
|
// Release the reader on every exit path — early returns (invalid JSONL,
|
|
@@ -976,27 +991,27 @@ function normalizeBuilderGatewayFetchError(err, timedOut, timeoutMs) {
|
|
|
976
991
|
* the credits lane run-manager has no text left to classify at persistence time,
|
|
977
992
|
* and a run persisted as `unknown` reads as "do not attempt recovery".
|
|
978
993
|
*/
|
|
979
|
-
function createBuilderGatewayTimeoutStop(err, timedOut, timeoutMs, creditsLane) {
|
|
994
|
+
function createBuilderGatewayTimeoutStop(err, timedOut, timeoutMs, creditsLane, requestShape) {
|
|
980
995
|
const error = normalizeBuilderGatewayFetchError(err, timedOut, timeoutMs);
|
|
981
996
|
if (timedOut) {
|
|
982
997
|
// Deliberately no `providerRetryable`: the timeout spent the whole request
|
|
983
998
|
// budget, so the recovery is a fresh invocation (the client's
|
|
984
999
|
// `builder_gateway_timeout` continuation), never an in-call retry.
|
|
985
|
-
return gatewayErrorStop({ error, errorCode: "builder_gateway_timeout" }, creditsLane);
|
|
1000
|
+
return gatewayErrorStop({ error, errorCode: "builder_gateway_timeout" }, creditsLane, requestShape);
|
|
986
1001
|
}
|
|
987
1002
|
if (isBuilderGatewayNetworkError(err)) {
|
|
988
1003
|
return gatewayErrorStop({
|
|
989
1004
|
error,
|
|
990
1005
|
errorCode: BUILDER_GATEWAY_NETWORK_ERROR_CODE,
|
|
991
1006
|
providerRetryable: true,
|
|
992
|
-
}, creditsLane);
|
|
1007
|
+
}, creditsLane, requestShape);
|
|
993
1008
|
}
|
|
994
1009
|
const errorCode = classifyTerminalErrorCode(error);
|
|
995
1010
|
return gatewayErrorStop({
|
|
996
1011
|
error,
|
|
997
1012
|
...(errorCode ? { errorCode } : {}),
|
|
998
1013
|
...(isTransientGatewayFailure(error) ? { providerRetryable: true } : {}),
|
|
999
|
-
}, creditsLane);
|
|
1014
|
+
}, creditsLane, requestShape);
|
|
1000
1015
|
}
|
|
1001
1016
|
function formatTimeoutMs(timeoutMs) {
|
|
1002
1017
|
if (timeoutMs < 1000)
|