@agent-native/core 0.134.0 → 0.134.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/corpus/README.md +1 -1
- package/corpus/core/CHANGELOG.md +6 -0
- package/corpus/core/docs/content/template-content-developers.mdx +1 -1
- package/corpus/core/package.json +1 -1
- package/corpus/templates/clips/changelog/2026-07-29-meeting-notes-no-longer-transcribe-the-other-side-twice-when.md +6 -0
- package/corpus/templates/clips/desktop/src/hooks/useMeetingTranscription.ts +37 -48
- package/corpus/templates/clips/desktop/src/lib/transcription-capture.ts +7 -5
- package/corpus/templates/clips/desktop/src/lib/transcription-engine.ts +182 -106
- package/corpus/templates/clips/desktop/src/overlays/live-transcript.tsx +43 -14
- package/corpus/templates/clips/desktop/src/overlays/recording-pill.tsx +3 -3
- package/corpus/templates/clips/desktop/src-tauri/src/echo_guard.rs +443 -0
- package/corpus/templates/clips/desktop/src-tauri/src/lib.rs +1 -0
- package/corpus/templates/clips/desktop/src-tauri/src/whisper_speech.rs +125 -16
- package/corpus/templates/content/.agents/skills/document-editing/references/databases.md +6 -4
- package/corpus/templates/content/actions/_database-membership-lock.ts +25 -0
- package/corpus/templates/content/actions/_database-row-batch.ts +6 -2
- package/corpus/templates/content/actions/_database-source-utils.ts +289 -129
- package/corpus/templates/content/actions/_database-utils.ts +12 -3
- package/corpus/templates/content/actions/add-content-database-source-field-property.ts +39 -21
- package/corpus/templates/content/actions/attach-content-database-source.ts +4 -1
- package/corpus/templates/content/actions/change-content-database-source-role.ts +3 -5
- package/corpus/templates/content/actions/duplicate-document-property.ts +59 -46
- package/corpus/templates/content/actions/execute-builder-source-execution.ts +77 -71
- package/corpus/templates/content/actions/materialize-builder-required-fields.ts +7 -0
- package/corpus/templates/content/actions/remove-database-items.ts +183 -0
- package/corpus/templates/content/actions/set-document-property.ts +57 -39
- package/corpus/templates/content/actions/sync-local-folder-source.ts +7 -0
- package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +471 -329
- package/corpus/templates/content/app/components/editor/database/row-access.ts +45 -0
- package/corpus/templates/content/app/components/editor/database/shared.tsx +141 -38
- package/corpus/templates/content/app/hooks/use-content-database.ts +2 -2
- package/corpus/templates/content/app/i18n-data.ts +130 -0
- package/corpus/templates/content/changelog/2026-07-30-database-rows-can-now-be-removed-without-deleting-their-page.md +6 -0
- package/corpus/templates/content/parity/eval-scenarios.ts +2 -2
- package/corpus/templates/content/parity/matrix.md +1 -1
- package/corpus/templates/content/parity/matrix.ts +4 -5
- package/corpus/templates/content/shared/api.ts +4 -0
- package/corpus/templates/design/.generated/bridge/editor-chrome.generated.ts +26 -11
- package/corpus/templates/design/actions/take-design-screenshot.ts +7 -0
- package/corpus/templates/design/app/components/design/DesignCanvas.tsx +11 -3
- package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +4 -1
- package/corpus/templates/design/app/components/design/bridge/editor-chrome.bridge.ts +54 -17
- package/corpus/templates/design/app/components/design/design-canvas/local-runtime.ts +126 -0
- package/corpus/templates/design/app/components/templates/TemplatePreview.tsx +2 -0
- package/corpus/templates/design/app/pages/Index.tsx +3 -1
- package/corpus/templates/design/app/pages/Present.tsx +2 -1
- package/corpus/templates/design/changelog/2026-07-30-a-broken-inline-script-in-a-generated-screen-is-now-caught-o.md +6 -0
- package/corpus/templates/design/changelog/2026-07-30-design-html-is-now-validated-with-a-spec-grade-html-parser-i.md +6 -0
- package/corpus/templates/design/changelog/2026-07-30-designs-now-load-their-tailwind-and-alpine-runtimes-from-the.md +6 -0
- package/corpus/templates/design/changelog/2026-07-30-designs-with-a-broken-alpine-expression-are-now-caught-on-sa.md +6 -0
- package/corpus/templates/design/changelog/2026-07-30-editing-a-design-no-longer-strips-its-styling-the-canvas-kep.md +6 -0
- package/corpus/templates/design/package.json +4 -0
- package/corpus/templates/design/shared/html-integrity.ts +802 -556
- package/dist/notifications/routes.d.ts +3 -3
- package/docs/content/template-content-developers.mdx +1 -1
- package/package.json +1 -1
- package/corpus/templates/content/actions/delete-database-items.ts +0 -89
package/corpus/README.md
CHANGED
package/corpus/core/CHANGELOG.md
CHANGED
|
@@ -278,7 +278,7 @@ Every operation is a TypeScript file in `templates/content/actions/`, auto-mount
|
|
|
278
278
|
|
|
279
279
|
**Comments** — `list-comments`, `add-comment`, `update-comment`, `delete-comment`.
|
|
280
280
|
|
|
281
|
-
**Databases, properties & views** — `create-content-database`, `create-inline-content-database`, `get-content-database`, `list-content-databases`, `delete-content-database`, `restore-content-database`, `list-trashed-content-databases`, `add-database-item`, `duplicate-database-item`, `duplicate-database-items`, `
|
|
281
|
+
**Databases, properties & views** — `create-content-database`, `create-inline-content-database`, `get-content-database`, `list-content-databases`, `delete-content-database`, `restore-content-database`, `list-trashed-content-databases`, `add-database-item`, `duplicate-database-item`, `duplicate-database-items`, `remove-database-items`, `move-database-item`, `update-content-database-view`, `get-content-database-personal-view`, `update-content-database-personal-view`, `list-document-properties`, `configure-document-property`, `set-document-property`, `duplicate-document-property`, `delete-document-property`, `reorder-document-property`, `submit-content-database-form`.
|
|
282
282
|
|
|
283
283
|
**External database sources** — `attach-content-database-source`, `disconnect-content-database-source`, `get-content-database-source`, `refresh-content-database-source`, `review-content-database-source-change-set`, `bind-content-database-source-field`, `add-content-database-source-field-property`, `suggest-source-join-key`, `list-notion-database-sources`, `list-builder-cms-models`. Builder's write-back path adds `prepare-builder-source-review`, `preview-builder-source-review`, `prepare-builder-source-execution`, `validate-builder-source-execution`, `execute-builder-source-execution`, `execute-builder-source-batch`, `cancel-prepared-builder-source-update`, `stage-builder-revision`, `stage-builder-source-bulk-update`, `materialize-builder-required-fields`, and `process-builder-body-hydration`.
|
|
284
284
|
|
package/corpus/core/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agent-native/core",
|
|
3
|
-
"version": "0.134.
|
|
3
|
+
"version": "0.134.1",
|
|
4
4
|
"description": "Framework for agent-native application development — where AI agents and UI share SQL state, actions, and context",
|
|
5
5
|
"homepage": "https://github.com/BuilderIO/agent-native#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -8,11 +8,14 @@ import {
|
|
|
8
8
|
appendFinalTranscript,
|
|
9
9
|
onFinalTranscript,
|
|
10
10
|
restartTranscriptionEngine,
|
|
11
|
-
speakerFor,
|
|
12
11
|
startTranscriptionEngine,
|
|
13
12
|
stopTranscriptionEngine,
|
|
13
|
+
transcriptFullText,
|
|
14
|
+
transcriptLineFromSegment,
|
|
15
|
+
transcriptSegments,
|
|
14
16
|
type SourcedTranscriptSegment,
|
|
15
17
|
type TranscriptionEngine,
|
|
18
|
+
type TranscriptLine,
|
|
16
19
|
} from "../lib/transcription-engine";
|
|
17
20
|
import { normalizeServerUrl } from "../lib/url";
|
|
18
21
|
|
|
@@ -31,8 +34,7 @@ export interface MeetingTranscriptionPayload {
|
|
|
31
34
|
interface MeetingTranscriptionSession {
|
|
32
35
|
meetingId: string;
|
|
33
36
|
recordingId: string;
|
|
34
|
-
lines:
|
|
35
|
-
segments: SourcedTranscriptSegment[];
|
|
37
|
+
lines: TranscriptLine[];
|
|
36
38
|
unlisten: Array<() => void>;
|
|
37
39
|
flushTimer: ReturnType<typeof setTimeout> | null;
|
|
38
40
|
stopping: boolean;
|
|
@@ -52,6 +54,22 @@ interface MeetingTranscriptionSession {
|
|
|
52
54
|
dirtySeq: number;
|
|
53
55
|
}
|
|
54
56
|
|
|
57
|
+
/** What the pill overlay needs to render a line: text, side, and timestamp.
|
|
58
|
+
* The verbatim segments stay behind in the session. */
|
|
59
|
+
interface PillTranscriptLine {
|
|
60
|
+
text: string;
|
|
61
|
+
source: "mic" | "system";
|
|
62
|
+
startMs?: number;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function pillTranscriptLines(lines: TranscriptLine[]): PillTranscriptLine[] {
|
|
66
|
+
return lines.map((line) => ({
|
|
67
|
+
text: line.text,
|
|
68
|
+
source: line.source,
|
|
69
|
+
startMs: line.startMs ?? undefined,
|
|
70
|
+
}));
|
|
71
|
+
}
|
|
72
|
+
|
|
55
73
|
type CallClipsAction = <T>(
|
|
56
74
|
name: string,
|
|
57
75
|
body: Record<string, unknown>,
|
|
@@ -79,11 +97,7 @@ export function useMeetingTranscription({
|
|
|
79
97
|
const pendingPillInitRef = useRef<{
|
|
80
98
|
meetingId: string;
|
|
81
99
|
initialNotes: string;
|
|
82
|
-
preloadedLines?:
|
|
83
|
-
text: string;
|
|
84
|
-
source: "mic" | "system";
|
|
85
|
-
startMs?: number;
|
|
86
|
-
}>;
|
|
100
|
+
preloadedLines?: PillTranscriptLine[];
|
|
87
101
|
} | null>(null);
|
|
88
102
|
|
|
89
103
|
const normalizedServerUrl = useMemo(
|
|
@@ -119,8 +133,8 @@ export function useMeetingTranscription({
|
|
|
119
133
|
const run = (async () => {
|
|
120
134
|
await callClipsAction("save-browser-transcript", {
|
|
121
135
|
recordingId: session.recordingId,
|
|
122
|
-
fullText: session.lines
|
|
123
|
-
segments: session.
|
|
136
|
+
fullText: transcriptFullText(session.lines),
|
|
137
|
+
segments: transcriptSegments(session.lines),
|
|
124
138
|
source: session.engine,
|
|
125
139
|
overwriteReady: true,
|
|
126
140
|
});
|
|
@@ -314,7 +328,6 @@ export function useMeetingTranscription({
|
|
|
314
328
|
meetingId: resolvedMeetingId,
|
|
315
329
|
recordingId,
|
|
316
330
|
lines: [],
|
|
317
|
-
segments: [],
|
|
318
331
|
unlisten: [],
|
|
319
332
|
flushTimer: null,
|
|
320
333
|
stopping: false,
|
|
@@ -363,13 +376,7 @@ export function useMeetingTranscription({
|
|
|
363
376
|
})),
|
|
364
377
|
}
|
|
365
378
|
: event;
|
|
366
|
-
if (
|
|
367
|
-
appendFinalTranscript(
|
|
368
|
-
timelineEvent,
|
|
369
|
-
session.lines,
|
|
370
|
-
session.segments,
|
|
371
|
-
)
|
|
372
|
-
) {
|
|
379
|
+
if (appendFinalTranscript(timelineEvent, session.lines)) {
|
|
373
380
|
scheduleFlush();
|
|
374
381
|
}
|
|
375
382
|
}),
|
|
@@ -539,11 +546,7 @@ export function useMeetingTranscription({
|
|
|
539
546
|
pendingPillInitRef.current = {
|
|
540
547
|
meetingId: resolvedMeetingId,
|
|
541
548
|
initialNotes,
|
|
542
|
-
preloadedLines: session.
|
|
543
|
-
text: segment.text,
|
|
544
|
-
source: segment.source,
|
|
545
|
-
startMs: segment.startMs,
|
|
546
|
-
})),
|
|
549
|
+
preloadedLines: pillTranscriptLines(session.lines),
|
|
547
550
|
};
|
|
548
551
|
emit("clips:meeting-notes-init", {
|
|
549
552
|
meetingId: resolvedMeetingId,
|
|
@@ -561,25 +564,16 @@ export function useMeetingTranscription({
|
|
|
561
564
|
source?: "mic" | "system";
|
|
562
565
|
}>;
|
|
563
566
|
if (segs.length > 0) {
|
|
564
|
-
const
|
|
565
|
-
(
|
|
567
|
+
const storedLines = segs.map((s) =>
|
|
568
|
+
transcriptLineFromSegment({
|
|
569
|
+
startMs: s.startMs ?? 0,
|
|
570
|
+
endMs: s.endMs ?? 0,
|
|
571
|
+
text: s.text,
|
|
572
|
+
source: s.source ?? "mic",
|
|
573
|
+
}),
|
|
566
574
|
);
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
endMs: s.endMs ?? 0,
|
|
570
|
-
text: s.text,
|
|
571
|
-
source: s.source ?? ("mic" as const),
|
|
572
|
-
}));
|
|
573
|
-
session.lines = [...preloadedLineStrings, ...session.lines];
|
|
574
|
-
session.segments = [
|
|
575
|
-
...preloadedSegments,
|
|
576
|
-
...session.segments,
|
|
577
|
-
];
|
|
578
|
-
const preloadedLines = session.segments.map((s) => ({
|
|
579
|
-
text: s.text,
|
|
580
|
-
source: s.source,
|
|
581
|
-
startMs: s.startMs,
|
|
582
|
-
}));
|
|
575
|
+
session.lines = [...storedLines, ...session.lines];
|
|
576
|
+
const preloadedLines = pillTranscriptLines(session.lines);
|
|
583
577
|
// Store in ref so clips:pill-ready can re-emit if the
|
|
584
578
|
// pill window mounts after this fetch resolves.
|
|
585
579
|
if (
|
|
@@ -648,15 +642,10 @@ export function useMeetingTranscription({
|
|
|
648
642
|
.then((history) => {
|
|
649
643
|
if (sessionRef.current !== session) return;
|
|
650
644
|
const historyLines = history.segments.map(
|
|
651
|
-
|
|
645
|
+
transcriptLineFromSegment,
|
|
652
646
|
);
|
|
653
647
|
session.lines = [...historyLines, ...session.lines];
|
|
654
|
-
|
|
655
|
-
const preloadedLines = session.segments.map((segment) => ({
|
|
656
|
-
text: segment.text,
|
|
657
|
-
source: segment.source,
|
|
658
|
-
startMs: segment.startMs,
|
|
659
|
-
}));
|
|
648
|
+
const preloadedLines = pillTranscriptLines(session.lines);
|
|
660
649
|
if (pendingPillInitRef.current?.meetingId === resolvedMeetingId) {
|
|
661
650
|
pendingPillInitRef.current = {
|
|
662
651
|
...pendingPillInitRef.current,
|
|
@@ -19,7 +19,10 @@ import {
|
|
|
19
19
|
startTranscriptionEngine,
|
|
20
20
|
stopTranscriptionEngine,
|
|
21
21
|
TranscriptionEngine,
|
|
22
|
+
transcriptFullText,
|
|
23
|
+
transcriptSegments,
|
|
22
24
|
type SourcedTranscriptSegment,
|
|
25
|
+
type TranscriptLine,
|
|
23
26
|
} from "./transcription-engine";
|
|
24
27
|
|
|
25
28
|
/** Grace period after stop for whisper to emit any flushed trailing finals. */
|
|
@@ -307,8 +310,7 @@ export async function startTranscriptionCapture(
|
|
|
307
310
|
voiceProcessing?: boolean;
|
|
308
311
|
},
|
|
309
312
|
): Promise<TranscriptionCapture | null> {
|
|
310
|
-
const lines:
|
|
311
|
-
const segments: SourcedTranscriptSegment[] = [];
|
|
313
|
+
const lines: TranscriptLine[] = [];
|
|
312
314
|
let disposed = false;
|
|
313
315
|
let paused = false;
|
|
314
316
|
let desiredPaused = false;
|
|
@@ -331,8 +333,8 @@ export async function startTranscriptionCapture(
|
|
|
331
333
|
};
|
|
332
334
|
|
|
333
335
|
const captured = (): CapturedTranscript => ({
|
|
334
|
-
text: lines
|
|
335
|
-
segments,
|
|
336
|
+
text: transcriptFullText(lines),
|
|
337
|
+
segments: transcriptSegments(lines),
|
|
336
338
|
});
|
|
337
339
|
|
|
338
340
|
let engine: TranscriptionEngine;
|
|
@@ -340,7 +342,7 @@ export async function startTranscriptionCapture(
|
|
|
340
342
|
unlistens.push(
|
|
341
343
|
await onFinalTranscript((event) => {
|
|
342
344
|
if (disposed) return;
|
|
343
|
-
appendFinalTranscript(event, lines
|
|
345
|
+
appendFinalTranscript(event, lines);
|
|
344
346
|
}),
|
|
345
347
|
);
|
|
346
348
|
|
|
@@ -30,9 +30,6 @@ export interface SourcedTranscriptSegment extends TranscriptSegment {
|
|
|
30
30
|
source: TranscriptSource;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
const DUPLICATE_TOKEN_OVERLAP = 0.72;
|
|
34
|
-
const DUPLICATE_TIME_OVERLAP = 0.35;
|
|
35
|
-
|
|
36
33
|
export interface FinalTranscriptEvent {
|
|
37
34
|
/** Raw text (not trimmed); callers decide whether to skip empties. */
|
|
38
35
|
text: string;
|
|
@@ -86,133 +83,212 @@ function transcriptWords(text: string): string[] {
|
|
|
86
83
|
return normalized ? normalized.split(/\s+/) : [];
|
|
87
84
|
}
|
|
88
85
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
// Echo de-duplication
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
//
|
|
90
|
+
// Without headphones the microphone re-records whatever the speakers play, so
|
|
91
|
+
// the remote side can reach the transcript twice: cleanly on the system stream
|
|
92
|
+
// and, whenever the acoustic guard in `echo_guard.rs` was not confident enough
|
|
93
|
+
// to drop it, again on the mic. The leak only ever runs one way, because a call
|
|
94
|
+
// app never plays the local user back. So when both streams carry the same
|
|
95
|
+
// words at the same time, the system copy is the real one and the mic copy is
|
|
96
|
+
// echo — no matter which stream happened to finalize first.
|
|
97
|
+
|
|
98
|
+
/** Share of the *longer* of the two word lists that has to match. Scoring the
|
|
99
|
+
* longer side is what keeps a brief interjection ("yeah, I think so") alive
|
|
100
|
+
* next to a long remote passage that happens to contain those words in order:
|
|
101
|
+
* echo repeats a whole utterance, it does not sprinkle a few words into one. */
|
|
102
|
+
const ECHO_MATCH_RATIO = 0.65;
|
|
103
|
+
/** Fewer matched words than this is coincidence, not evidence. */
|
|
104
|
+
const ECHO_MIN_WORDS = 4;
|
|
105
|
+
/** Words that have to agree before a long in-order run counts as echo on its
|
|
106
|
+
* own. Two people do not independently say eight of the same words, in the
|
|
107
|
+
* same order, at the same moment. */
|
|
108
|
+
const ECHO_LONG_MATCH_WORDS = 8;
|
|
109
|
+
/** How many recently appended lines count as "at the same time".
|
|
110
|
+
*
|
|
111
|
+
* Arrival order, not timestamps: each stream's Whisper timestamps are
|
|
112
|
+
* estimates against its own rolling buffer, and the two streams cut those
|
|
113
|
+
* buffers at their own silences, so the same words routinely carry stamps
|
|
114
|
+
* seconds apart. Both finals still *arrive* within a second or two of each
|
|
115
|
+
* other, because they are transcribed from the same sound. Finalized lines also
|
|
116
|
+
* get a loose timestamp bound below to reject much later deliberate repeats. */
|
|
117
|
+
const ECHO_RECENT_LINES = 6;
|
|
118
|
+
/** Cross-stream timestamps are approximate, but echo finals should still begin
|
|
119
|
+
* within this loose bound of each other. */
|
|
120
|
+
const ECHO_MAX_START_DELTA_MS = 15_000;
|
|
121
|
+
|
|
122
|
+
/** Length of the longest common subsequence of two word lists. Subsequence
|
|
123
|
+
* rather than set intersection because echo repeats the words *in order*,
|
|
124
|
+
* while two people using the same vocabulary do not — and rather than exact
|
|
125
|
+
* equality because Whisper mangles echo with dropped and substituted words. */
|
|
126
|
+
function commonWordRun(left: string[], right: string[]): number {
|
|
127
|
+
let previous = new Array<number>(right.length + 1).fill(0);
|
|
128
|
+
let current = new Array<number>(right.length + 1).fill(0);
|
|
129
|
+
for (const word of left) {
|
|
130
|
+
for (let index = 0; index < right.length; index++) {
|
|
131
|
+
current[index + 1] =
|
|
132
|
+
word === right[index]
|
|
133
|
+
? previous[index] + 1
|
|
134
|
+
: Math.max(current[index], previous[index + 1]);
|
|
135
|
+
}
|
|
136
|
+
[previous, current] = [current, previous];
|
|
137
|
+
}
|
|
138
|
+
return previous[right.length];
|
|
110
139
|
}
|
|
111
140
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
141
|
+
/** Whether `text` heard on the mic is the speakers bleeding back into it.
|
|
142
|
+
*
|
|
143
|
+
* Exported because the live overlay has to make the same call on in-flight
|
|
144
|
+
* partials, which never reach `appendFinalTranscript`.
|
|
145
|
+
*
|
|
146
|
+
* Scored against every contiguous run of the recent system lines, not against
|
|
147
|
+
* all of them glued together: the two streams cut speech at different points,
|
|
148
|
+
* so one mic line can echo a single system line or straddle two, and gluing an
|
|
149
|
+
* unrelated third one in would bury the match. */
|
|
150
|
+
export function isMicEcho(
|
|
151
|
+
text: string,
|
|
152
|
+
lines: TranscriptLine[],
|
|
153
|
+
startMs: number | null = null,
|
|
115
154
|
): boolean {
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
155
|
+
const words = transcriptWords(text);
|
|
156
|
+
if (words.length < ECHO_MIN_WORDS) return false;
|
|
157
|
+
|
|
158
|
+
const nearby = lines
|
|
159
|
+
.slice(-ECHO_RECENT_LINES)
|
|
160
|
+
.filter(
|
|
161
|
+
(line) =>
|
|
162
|
+
line.source === "system" &&
|
|
163
|
+
!line.historical &&
|
|
164
|
+
(startMs === null ||
|
|
165
|
+
line.startMs === null ||
|
|
166
|
+
Math.abs(startMs - line.startMs) <= ECHO_MAX_START_DELTA_MS),
|
|
167
|
+
)
|
|
168
|
+
.map((line) => transcriptWords(line.text));
|
|
169
|
+
|
|
170
|
+
for (let start = 0; start < nearby.length; start++) {
|
|
171
|
+
const run: string[] = [];
|
|
172
|
+
for (let end = start; end < nearby.length; end++) {
|
|
173
|
+
run.push(...nearby[end]);
|
|
174
|
+
const matched = commonWordRun(words, run);
|
|
175
|
+
if (matched < ECHO_MIN_WORDS) continue;
|
|
176
|
+
// Either the two are the same length and mostly agree, or they agree on
|
|
177
|
+
// a stretch long enough that nothing but echo explains it.
|
|
178
|
+
const sameUtterance =
|
|
179
|
+
matched / Math.max(words.length, run.length) >= ECHO_MATCH_RATIO;
|
|
180
|
+
const longRun =
|
|
181
|
+
matched >= ECHO_LONG_MATCH_WORDS &&
|
|
182
|
+
matched / words.length >= ECHO_MATCH_RATIO;
|
|
183
|
+
if (sameUtterance || longRun) return true;
|
|
184
|
+
}
|
|
130
185
|
}
|
|
186
|
+
return false;
|
|
187
|
+
}
|
|
131
188
|
|
|
132
|
-
|
|
133
|
-
|
|
189
|
+
/** Drop the recent mic lines that a just-appended system line exposes as echo. */
|
|
190
|
+
function retractMicEcho(lines: TranscriptLine[]): void {
|
|
191
|
+
const snapshot = [...lines];
|
|
192
|
+
const oldest = Math.max(0, snapshot.length - ECHO_RECENT_LINES);
|
|
193
|
+
const removals: number[] = [];
|
|
194
|
+
for (let index = snapshot.length - 1; index >= oldest; index--) {
|
|
195
|
+
const line = snapshot[index];
|
|
196
|
+
if (line.source !== "mic" || line.historical) continue;
|
|
197
|
+
const evidence = snapshot.slice(index, index + ECHO_RECENT_LINES);
|
|
198
|
+
if (isMicEcho(line.text, evidence, line.startMs)) removals.push(index);
|
|
134
199
|
}
|
|
200
|
+
for (const index of removals) lines.splice(index, 1);
|
|
201
|
+
}
|
|
135
202
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
203
|
+
// ---------------------------------------------------------------------------
|
|
204
|
+
// Transcript lines
|
|
205
|
+
// ---------------------------------------------------------------------------
|
|
206
|
+
|
|
207
|
+
/** One speaker-labelled transcript line. `segments` carries the verbatim
|
|
208
|
+
* Whisper timings behind the line and is empty for the mic-only fallback
|
|
209
|
+
* engines, which report no timestamps. */
|
|
210
|
+
export interface TranscriptLine {
|
|
211
|
+
source: TranscriptSource;
|
|
212
|
+
/** Preloaded lines are display/persistence data, not live echo evidence. */
|
|
213
|
+
historical?: boolean;
|
|
214
|
+
/** Meeting-timeline position, or null from an engine that reports none.
|
|
215
|
+
* Not 0 — the overlay renders a timestamp for every line that has one, and
|
|
216
|
+
* "start of the meeting" is a different claim from "unknown". */
|
|
217
|
+
startMs: number | null;
|
|
218
|
+
text: string;
|
|
219
|
+
segments: SourcedTranscriptSegment[];
|
|
140
220
|
}
|
|
141
221
|
|
|
142
|
-
function
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
222
|
+
function lineFromSegments(
|
|
223
|
+
segments: SourcedTranscriptSegment[],
|
|
224
|
+
): TranscriptLine {
|
|
225
|
+
return {
|
|
226
|
+
source: segments[0].source,
|
|
227
|
+
startMs: segments[0].startMs,
|
|
228
|
+
text: segments.map((segment) => segment.text).join(" "),
|
|
229
|
+
segments,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
149
232
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
233
|
+
/** Rebuild a line from a stored segment, for preloaded transcript history. */
|
|
234
|
+
export function transcriptLineFromSegment(
|
|
235
|
+
segment: SourcedTranscriptSegment,
|
|
236
|
+
): TranscriptLine {
|
|
237
|
+
return { ...lineFromSegments([segment]), historical: true };
|
|
238
|
+
}
|
|
156
239
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
240
|
+
/** Speaker-labelled text, as persisted by `save-browser-transcript`. */
|
|
241
|
+
export function transcriptFullText(lines: TranscriptLine[]): string {
|
|
242
|
+
return lines
|
|
243
|
+
.map((line) => `${speakerFor(line.source)}: ${line.text}`)
|
|
244
|
+
.join("\n\n")
|
|
245
|
+
.trim();
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Flattened verbatim segments, as persisted alongside the text. */
|
|
249
|
+
export function transcriptSegments(
|
|
250
|
+
lines: TranscriptLine[],
|
|
251
|
+
): SourcedTranscriptSegment[] {
|
|
252
|
+
return lines.flatMap((line) => line.segments);
|
|
165
253
|
}
|
|
166
254
|
|
|
167
255
|
/**
|
|
168
|
-
* Fold a final-transcript event into a running transcript
|
|
169
|
-
*
|
|
170
|
-
*
|
|
171
|
-
*
|
|
256
|
+
* Fold a final-transcript event into a running transcript, dropping mic speech
|
|
257
|
+
* that only echoes the system audio and retracting mic lines that a later
|
|
258
|
+
* system line exposes as echo. Mutates `lines` in place; returns whether the
|
|
259
|
+
* transcript changed.
|
|
172
260
|
*/
|
|
173
261
|
export function appendFinalTranscript(
|
|
174
262
|
event: FinalTranscriptEvent,
|
|
175
|
-
lines:
|
|
176
|
-
segments: SourcedTranscriptSegment[],
|
|
263
|
+
lines: TranscriptLine[],
|
|
177
264
|
): boolean {
|
|
178
265
|
const text = event.text.trim();
|
|
179
266
|
if (!text) return false;
|
|
180
267
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
268
|
+
const segments: SourcedTranscriptSegment[] = event.segments
|
|
269
|
+
.map((segment) => ({
|
|
270
|
+
startMs: segment.startMs,
|
|
271
|
+
endMs: segment.endMs,
|
|
272
|
+
text: segment.text?.trim() ?? "",
|
|
273
|
+
source: event.source,
|
|
274
|
+
}))
|
|
275
|
+
.filter((segment) => segment.text.length > 0);
|
|
276
|
+
|
|
277
|
+
// One event is one stream's take on one utterance, so the whole line is the
|
|
278
|
+
// unit that is or is not echo. Engines without timestamps still produce a
|
|
279
|
+
// line, just one carrying no segments behind it.
|
|
280
|
+
const line: TranscriptLine = segments.length
|
|
281
|
+
? lineFromSegments(segments)
|
|
282
|
+
: { source: event.source, startMs: null, text, segments: [] };
|
|
283
|
+
|
|
284
|
+
if (line.source === "mic") {
|
|
285
|
+
if (isMicEcho(line.text, lines, line.startMs)) return false;
|
|
286
|
+
lines.push(line);
|
|
185
287
|
return true;
|
|
186
288
|
}
|
|
187
289
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
if (!segText) return false;
|
|
191
|
-
|
|
192
|
-
return !segments.some((existing) =>
|
|
193
|
-
isDuplicateTranscriptSegment(existing, {
|
|
194
|
-
...segment,
|
|
195
|
-
text: segText,
|
|
196
|
-
source: event.source,
|
|
197
|
-
}),
|
|
198
|
-
);
|
|
199
|
-
});
|
|
200
|
-
|
|
201
|
-
if (uniqueSegments.length === 0) return false;
|
|
202
|
-
|
|
203
|
-
lines.push(
|
|
204
|
-
`${speakerFor(event.source)}: ${uniqueSegments
|
|
205
|
-
.map((segment) => segment.text.trim())
|
|
206
|
-
.join(" ")}`,
|
|
207
|
-
);
|
|
208
|
-
for (const seg of uniqueSegments) {
|
|
209
|
-
segments.push({
|
|
210
|
-
startMs: seg.startMs,
|
|
211
|
-
endMs: seg.endMs,
|
|
212
|
-
text: seg.text.trim(),
|
|
213
|
-
source: event.source,
|
|
214
|
-
});
|
|
215
|
-
}
|
|
290
|
+
lines.push(line);
|
|
291
|
+
retractMicEcho(lines);
|
|
216
292
|
return true;
|
|
217
293
|
}
|
|
218
294
|
|