@dxos/react-ui-transcription 0.0.0

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.
Files changed (84) hide show
  1. package/LICENSE +105 -0
  2. package/dist/lib/browser/index.mjs +1002 -0
  3. package/dist/lib/browser/index.mjs.map +7 -0
  4. package/dist/lib/browser/meta.json +1 -0
  5. package/dist/lib/node-esm/index.mjs +1004 -0
  6. package/dist/lib/node-esm/index.mjs.map +7 -0
  7. package/dist/lib/node-esm/meta.json +1 -0
  8. package/dist/types/src/capture/create-transcriber.d.ts +18 -0
  9. package/dist/types/src/capture/create-transcriber.d.ts.map +1 -0
  10. package/dist/types/src/capture/index.d.ts +3 -0
  11. package/dist/types/src/capture/index.d.ts.map +1 -0
  12. package/dist/types/src/capture/media-stream-recorder.d.ts +30 -0
  13. package/dist/types/src/capture/media-stream-recorder.d.ts.map +1 -0
  14. package/dist/types/src/components/MicSettings/MicSettings.d.ts +25 -0
  15. package/dist/types/src/components/MicSettings/MicSettings.d.ts.map +1 -0
  16. package/dist/types/src/components/MicSettings/index.d.ts +2 -0
  17. package/dist/types/src/components/MicSettings/index.d.ts.map +1 -0
  18. package/dist/types/src/components/PipelineStatus/PipelineStatus.d.ts +20 -0
  19. package/dist/types/src/components/PipelineStatus/PipelineStatus.d.ts.map +1 -0
  20. package/dist/types/src/components/PipelineStatus/index.d.ts +2 -0
  21. package/dist/types/src/components/PipelineStatus/index.d.ts.map +1 -0
  22. package/dist/types/src/components/Transcription/Transcription.d.ts +11 -0
  23. package/dist/types/src/components/Transcription/Transcription.d.ts.map +1 -0
  24. package/dist/types/src/components/Transcription/index.d.ts +3 -0
  25. package/dist/types/src/components/Transcription/index.d.ts.map +1 -0
  26. package/dist/types/src/components/Transcription/transcription-extension.d.ts +15 -0
  27. package/dist/types/src/components/Transcription/transcription-extension.d.ts.map +1 -0
  28. package/dist/types/src/components/index.d.ts +4 -0
  29. package/dist/types/src/components/index.d.ts.map +1 -0
  30. package/dist/types/src/hooks/index.d.ts +8 -0
  31. package/dist/types/src/hooks/index.d.ts.map +1 -0
  32. package/dist/types/src/hooks/useAudioFile.d.ts +7 -0
  33. package/dist/types/src/hooks/useAudioFile.d.ts.map +1 -0
  34. package/dist/types/src/hooks/useAudioTrack.d.ts +6 -0
  35. package/dist/types/src/hooks/useAudioTrack.d.ts.map +1 -0
  36. package/dist/types/src/hooks/useFeedModelAdapter.d.ts +12 -0
  37. package/dist/types/src/hooks/useFeedModelAdapter.d.ts.map +1 -0
  38. package/dist/types/src/hooks/useIsSpeaking.d.ts +5 -0
  39. package/dist/types/src/hooks/useIsSpeaking.d.ts.map +1 -0
  40. package/dist/types/src/hooks/useRecordingPipeline.d.ts +40 -0
  41. package/dist/types/src/hooks/useRecordingPipeline.d.ts.map +1 -0
  42. package/dist/types/src/hooks/useSpeechRecognition.d.ts +9 -0
  43. package/dist/types/src/hooks/useSpeechRecognition.d.ts.map +1 -0
  44. package/dist/types/src/hooks/useTranscriber.d.ts +10 -0
  45. package/dist/types/src/hooks/useTranscriber.d.ts.map +1 -0
  46. package/dist/types/src/index.d.ts +7 -0
  47. package/dist/types/src/index.d.ts.map +1 -0
  48. package/dist/types/src/model/index.d.ts +2 -0
  49. package/dist/types/src/model/index.d.ts.map +1 -0
  50. package/dist/types/src/model/transcript-model.d.ts +68 -0
  51. package/dist/types/src/model/transcript-model.d.ts.map +1 -0
  52. package/dist/types/src/types.d.ts +3 -0
  53. package/dist/types/src/types.d.ts.map +1 -0
  54. package/dist/types/src/util/index.d.ts +2 -0
  55. package/dist/types/src/util/index.d.ts.map +1 -0
  56. package/dist/types/src/util/render-byline.d.ts +4 -0
  57. package/dist/types/src/util/render-byline.d.ts.map +1 -0
  58. package/dist/types/tsconfig.tsbuildinfo +1 -0
  59. package/package.json +69 -0
  60. package/src/capture/create-transcriber.ts +49 -0
  61. package/src/capture/index.ts +6 -0
  62. package/src/capture/media-stream-recorder.ts +137 -0
  63. package/src/components/MicSettings/MicSettings.tsx +126 -0
  64. package/src/components/MicSettings/index.ts +5 -0
  65. package/src/components/PipelineStatus/PipelineStatus.tsx +71 -0
  66. package/src/components/PipelineStatus/index.ts +5 -0
  67. package/src/components/Transcription/Transcription.tsx +67 -0
  68. package/src/components/Transcription/index.ts +6 -0
  69. package/src/components/Transcription/transcription-extension.ts +203 -0
  70. package/src/components/index.ts +7 -0
  71. package/src/hooks/index.ts +11 -0
  72. package/src/hooks/useAudioFile.ts +88 -0
  73. package/src/hooks/useAudioTrack.ts +56 -0
  74. package/src/hooks/useFeedModelAdapter.ts +46 -0
  75. package/src/hooks/useIsSpeaking.ts +42 -0
  76. package/src/hooks/useRecordingPipeline.ts +166 -0
  77. package/src/hooks/useSpeechRecognition.ts +56 -0
  78. package/src/hooks/useTranscriber.ts +45 -0
  79. package/src/index.ts +10 -0
  80. package/src/model/index.ts +5 -0
  81. package/src/model/transcript-model.ts +229 -0
  82. package/src/types.ts +6 -0
  83. package/src/util/index.ts +5 -0
  84. package/src/util/render-byline.ts +32 -0
package/package.json ADDED
@@ -0,0 +1,69 @@
1
+ {
2
+ "name": "@dxos/react-ui-transcription",
3
+ "version": "0.0.0",
4
+ "description": "Browser audio capture, transcription hooks, and UI for the transcription pipeline.",
5
+ "homepage": "https://dxos.org",
6
+ "bugs": "https://github.com/dxos/dxos/issues",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/dxos/dxos"
10
+ },
11
+ "license": "FSL-1.1-Apache-2.0",
12
+ "author": "DXOS.org",
13
+ "type": "module",
14
+ "exports": {
15
+ ".": {
16
+ "source": "./src/index.ts",
17
+ "types": "./dist/types/src/index.d.ts",
18
+ "browser": "./dist/lib/browser/index.mjs",
19
+ "node": "./dist/lib/node-esm/index.mjs"
20
+ }
21
+ },
22
+ "types": "dist/types/src/index.d.ts",
23
+ "files": [
24
+ "dist",
25
+ "src"
26
+ ],
27
+ "dependencies": {
28
+ "@codemirror/state": "catalog:",
29
+ "@codemirror/view": "catalog:",
30
+ "@dxos/async": "workspace:*",
31
+ "@dxos/av": "workspace:*",
32
+ "@dxos/context": "workspace:*",
33
+ "@dxos/echo": "workspace:*",
34
+ "@dxos/invariant": "workspace:*",
35
+ "@dxos/log": "workspace:*",
36
+ "@dxos/react-client": "workspace:*",
37
+ "@dxos/react-ui": "workspace:*",
38
+ "@dxos/react-ui-editor": "workspace:*",
39
+ "@dxos/tracing": "workspace:*",
40
+ "@dxos/transcription-pipeline": "workspace:*",
41
+ "@dxos/types": "workspace:*",
42
+ "@dxos/ui-editor": "workspace:*",
43
+ "@dxos/ui-theme": "workspace:*",
44
+ "@dxos/ui-types": "workspace:*",
45
+ "@dxos/util": "workspace:*",
46
+ "date-fns": "catalog:",
47
+ "extendable-media-recorder": "catalog:",
48
+ "extendable-media-recorder-wav-encoder": "catalog:",
49
+ "wavefile": "catalog:"
50
+ },
51
+ "devDependencies": {
52
+ "@types/react": "catalog:",
53
+ "@types/react-dom": "catalog:",
54
+ "effect": "catalog:",
55
+ "react": "catalog:",
56
+ "react-dom": "catalog:",
57
+ "vite": "catalog:"
58
+ },
59
+ "peerDependencies": {
60
+ "@dxos/react-ui": "workspace:*",
61
+ "@dxos/ui-theme": "workspace:*",
62
+ "effect": "catalog:",
63
+ "react": "catalog:",
64
+ "react-dom": "catalog:"
65
+ },
66
+ "publishConfig": {
67
+ "access": "public"
68
+ }
69
+ }
@@ -0,0 +1,49 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import { type TranscribeConfig, type TranscribeFn, Transcriber } from '@dxos/transcription-pipeline';
6
+ import { type ContentBlock } from '@dxos/types';
7
+
8
+ import { MediaStreamRecorder } from './media-stream-recorder';
9
+
10
+ // Recorder chunk interval (ms).
11
+ const RECORD_INTERVAL = 200;
12
+ // Chunks retained before the user starts speaking, to catch the onset of speech.
13
+ const PREFIXED_CHUNKS_AMOUNT = 10;
14
+ // Chunks transcribed automatically; combined should stay under ~25MB or Whisper fails.
15
+ const TRANSCRIBE_AFTER_CHUNKS_AMOUNT = 50;
16
+
17
+ export type CreateTranscriberOptions = {
18
+ audioStreamTrack: MediaStreamTrack;
19
+ onSegments: (segments: ContentBlock.Transcript[]) => Promise<void>;
20
+ transcriberConfig?: Partial<TranscribeConfig>;
21
+ recorderConfig?: { interval?: number };
22
+ transcribe?: TranscribeFn;
23
+ };
24
+
25
+ /**
26
+ * Builds a {@link Transcriber} backed by a browser {@link MediaStreamRecorder} for the given track —
27
+ * the low-level audio→text construction, co-located with the recorder so it can be tested outside the
28
+ * plugin. Consumed directly via the {@link useTranscriber} hook.
29
+ */
30
+ export const createTranscriber = ({
31
+ audioStreamTrack,
32
+ onSegments,
33
+ transcriberConfig,
34
+ recorderConfig,
35
+ transcribe,
36
+ }: CreateTranscriberOptions): Transcriber =>
37
+ new Transcriber({
38
+ config: {
39
+ transcribeAfterChunksAmount: TRANSCRIBE_AFTER_CHUNKS_AMOUNT,
40
+ prefixBufferChunksAmount: PREFIXED_CHUNKS_AMOUNT,
41
+ ...transcriberConfig,
42
+ },
43
+ recorder: new MediaStreamRecorder({
44
+ mediaStreamTrack: audioStreamTrack,
45
+ config: { interval: RECORD_INTERVAL, ...recorderConfig },
46
+ }),
47
+ onSegments,
48
+ transcribe,
49
+ });
@@ -0,0 +1,6 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ export * from './create-transcriber';
6
+ export * from './media-stream-recorder';
@@ -0,0 +1,137 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import { type IBlobEvent, type IMediaRecorder } from 'extendable-media-recorder';
6
+ import { WaveFile } from 'wavefile';
7
+
8
+ import { synchronized } from '@dxos/async';
9
+ import { invariant } from '@dxos/invariant';
10
+ import { log } from '@dxos/log';
11
+ import { trace } from '@dxos/tracing';
12
+ import { type AudioChunk, type AudioRecorder, type WavConfig } from '@dxos/transcription-pipeline';
13
+
14
+ // `extendable-media-recorder` references browser-only globals (Worker/AudioWorklet) at module load,
15
+ // so it is imported lazily — keeping this package importable in non-browser contexts (node tests,
16
+ // Cloudflare Workers) where the recorder is never started.
17
+ let MediaRecorderClass: typeof import('extendable-media-recorder').MediaRecorder | undefined;
18
+ let initializingPromise: Promise<void> | undefined;
19
+
20
+ const initializeExtendableMediaRecorder = async () => {
21
+ const { MediaRecorder, register } = await import('extendable-media-recorder');
22
+ const { connect } = await import('extendable-media-recorder-wav-encoder');
23
+ await register(await connect());
24
+ MediaRecorderClass = MediaRecorder;
25
+ };
26
+
27
+ export type MediaStreamRecorderProps = {
28
+ mediaStreamTrack: MediaStreamTrack;
29
+ config: {
30
+ interval: number;
31
+ };
32
+ };
33
+
34
+ /**
35
+ * Recorder that uses the MediaContext API and AudioNode API to record audio.
36
+ *
37
+ * It records MediaStream using https://www.npmjs.com/package/extendable-media-recorder.
38
+ */
39
+ @trace.resource()
40
+ export class MediaStreamRecorder implements AudioRecorder {
41
+ private readonly _mediaStreamTrack: MediaStreamTrack;
42
+ /**
43
+ * Default MediaRecorder implementation do not support wav encoding.
44
+ */
45
+ private readonly _config: MediaStreamRecorderProps['config'];
46
+ private _mediaRecorder?: IMediaRecorder = undefined;
47
+ private _header?: Uint8Array = undefined;
48
+ private _onChunk?: (chunk: AudioChunk) => void;
49
+
50
+ constructor({ mediaStreamTrack, config }: MediaStreamRecorderProps) {
51
+ this._mediaStreamTrack = mediaStreamTrack;
52
+ this._config = config;
53
+ }
54
+
55
+ get wavConfig(): WavConfig {
56
+ const settings = this._mediaStreamTrack.getSettings();
57
+ return {
58
+ channels: settings.channelCount ?? 1,
59
+ sampleRate: settings.sampleRate ?? 16000,
60
+ bitDepthCode: settings.sampleSize ? String(settings.sampleSize) : '16',
61
+ };
62
+ }
63
+
64
+ get mediaStreamTrack() {
65
+ return this._mediaStreamTrack;
66
+ }
67
+
68
+ setOnChunk(onChunk: (chunk: AudioChunk) => void): void {
69
+ this._onChunk = onChunk;
70
+ }
71
+
72
+ // `@synchronized` serializes start/stop (they share the instance lock) so a rapid stop→start cycle
73
+ // (e.g. React StrictMode double-mount) cannot initiate a new WAV encoding before the previous one
74
+ // has flushed — which throws "Another request was made to initiate an encoding" and kills capture.
75
+ @synchronized
76
+ async start(): Promise<void> {
77
+ await (initializingPromise ??= initializeExtendableMediaRecorder()).catch((err) =>
78
+ log.info('initializeExtendableMediaRecorder', { err }),
79
+ );
80
+
81
+ if (this._mediaRecorder?.state === 'recording') {
82
+ return;
83
+ }
84
+ invariant(this._onChunk, 'MediaStreamRecorder: onChunk is not set');
85
+ invariant(MediaRecorderClass, 'MediaStreamRecorder: extendable-media-recorder not initialized');
86
+ // Fresh recorder per session so a previously-stopped (encoder-flushed) instance is never reused.
87
+ const stream = new MediaStream([this._mediaStreamTrack]);
88
+ const recorder = new MediaRecorderClass(stream, { mimeType: 'audio/wav' });
89
+ recorder.ondataavailable = (event) => this._ondataavailable(event);
90
+ this._mediaRecorder = recorder;
91
+ recorder.start(this._config.interval);
92
+ }
93
+
94
+ @synchronized
95
+ async stop(): Promise<void> {
96
+ const recorder = this._mediaRecorder;
97
+ if (!recorder || recorder.state !== 'recording') {
98
+ return;
99
+ }
100
+ // Await the actual stop (final dataavailable + encoder flush) before releasing the recorder, so a
101
+ // subsequent start() does not collide with an in-progress encoding.
102
+ await new Promise<void>((resolve) => {
103
+ recorder.addEventListener('stop', () => resolve(), { once: true });
104
+ recorder.stop();
105
+ });
106
+ this._mediaRecorder = undefined;
107
+ }
108
+
109
+ @synchronized
110
+ private async _ondataavailable(event: IBlobEvent): Promise<void> {
111
+ const blob = event.data;
112
+ const uint8Array = new Uint8Array(await blob.arrayBuffer());
113
+
114
+ // First chunk from the MediaRecorder has a header.
115
+ const isHeader =
116
+ uint8Array[0] === 0x52 && // R
117
+ uint8Array[1] === 0x49 && // I
118
+ uint8Array[2] === 0x46 && // F
119
+ uint8Array[3] === 0x46; // F
120
+ let wav: WaveFile;
121
+
122
+ if (isHeader) {
123
+ wav = new WaveFile(uint8Array);
124
+ this._header = uint8Array.slice(0, 44);
125
+ } else if (this._header) {
126
+ wav = new WaveFile(new Uint8Array([...this._header, ...uint8Array]));
127
+ } else {
128
+ log.warn('MediaStreamRecorder: no header');
129
+ return;
130
+ }
131
+
132
+ this._onChunk!({
133
+ timestamp: Date.now(),
134
+ data: wav.getSamples(),
135
+ });
136
+ }
137
+ }
@@ -0,0 +1,126 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import React from 'react';
6
+
7
+ import { DropdownMenu, Icon, IconButton, useTranslation } from '@dxos/react-ui';
8
+
9
+ /** Recording trigger mode: toggle on/off, or hold-to-record (push-to-talk). */
10
+ export type RecordMode = 'toggle' | 'hold';
11
+
12
+ export type AudioInputDevice = {
13
+ deviceId: string;
14
+ label: string;
15
+ };
16
+
17
+ export type MicSettingsProps = {
18
+ /** Translation namespace for the option labels (owned by the consuming plugin). */
19
+ translationNs: string;
20
+ recordMode: RecordMode;
21
+ entityExtraction: boolean;
22
+ devices: AudioInputDevice[];
23
+ /** Empty string selects the system default device. */
24
+ selectedDeviceId: string;
25
+ onRecordModeChange: (mode: RecordMode) => void;
26
+ onEntityExtractionChange: (value: boolean) => void;
27
+ onSelectDevice: (deviceId: string) => void;
28
+ };
29
+
30
+ /**
31
+ * Dropdown of recording options for the mic: record mode (toggle/push-to-talk), the input device,
32
+ * and whether entity extraction is applied. Presentational — state lives in the consumer.
33
+ */
34
+ export const MicSettings = ({
35
+ translationNs,
36
+ recordMode,
37
+ entityExtraction,
38
+ devices,
39
+ selectedDeviceId,
40
+ onRecordModeChange,
41
+ onEntityExtractionChange,
42
+ onSelectDevice,
43
+ }: MicSettingsProps) => {
44
+ const { t } = useTranslation(translationNs);
45
+
46
+ return (
47
+ <DropdownMenu.Root>
48
+ <DropdownMenu.Trigger asChild>
49
+ <IconButton
50
+ icon='ph--caret-down--regular'
51
+ iconOnly
52
+ label={t('recording-options.label')}
53
+ variant='ghost'
54
+ // Half-width slim caret abutting the mic (split-control affordance).
55
+ classNames='w-4'
56
+ data-testid='transcription.record.options'
57
+ />
58
+ </DropdownMenu.Trigger>
59
+ <DropdownMenu.Portal>
60
+ <DropdownMenu.Content>
61
+ <DropdownMenu.Viewport>
62
+ <DropdownMenu.GroupLabel>{t('record-mode.label')}</DropdownMenu.GroupLabel>
63
+ <SelectableItem
64
+ label={t('record-mode.toggle.label')}
65
+ selected={recordMode === 'toggle'}
66
+ onSelect={() => onRecordModeChange('toggle')}
67
+ />
68
+ <SelectableItem
69
+ label={t('record-mode.hold.label')}
70
+ selected={recordMode === 'hold'}
71
+ onSelect={() => onRecordModeChange('hold')}
72
+ />
73
+
74
+ <DropdownMenu.Separator />
75
+
76
+ <DropdownMenu.GroupLabel>{t('audio-device.label')}</DropdownMenu.GroupLabel>
77
+ <SelectableItem
78
+ label={t('audio-device.default.label')}
79
+ selected={selectedDeviceId === ''}
80
+ onSelect={() => onSelectDevice('')}
81
+ />
82
+ {devices.map((device) => (
83
+ <SelectableItem
84
+ key={device.deviceId}
85
+ label={device.label}
86
+ selected={selectedDeviceId === device.deviceId}
87
+ onSelect={() => onSelectDevice(device.deviceId)}
88
+ />
89
+ ))}
90
+
91
+ <DropdownMenu.Separator />
92
+
93
+ <DropdownMenu.CheckboxItem
94
+ checked={entityExtraction}
95
+ onCheckedChange={onEntityExtractionChange}
96
+ classNames='gap-2'
97
+ data-testid='transcription.entity-extraction'
98
+ >
99
+ <span className='grow truncate'>{t('settings.entity-extraction.label')}</span>
100
+ <DropdownMenu.ItemIndicator asChild>
101
+ <Icon icon='ph--check--regular' size={4} />
102
+ </DropdownMenu.ItemIndicator>
103
+ </DropdownMenu.CheckboxItem>
104
+ </DropdownMenu.Viewport>
105
+ <DropdownMenu.Arrow />
106
+ </DropdownMenu.Content>
107
+ </DropdownMenu.Portal>
108
+ </DropdownMenu.Root>
109
+ );
110
+ };
111
+
112
+ type SelectableItemProps = {
113
+ label: string;
114
+ selected: boolean;
115
+ onSelect: () => void;
116
+ };
117
+
118
+ // The design system's `DropdownMenu.RadioItem` renders a plain item (no radio semantics), so
119
+ // single-select is modelled with a plain item and an explicit trailing check. `onSelect` (not
120
+ // `onClick`) so keyboard activation works.
121
+ const SelectableItem = ({ label, selected, onSelect }: SelectableItemProps) => (
122
+ <DropdownMenu.Item classNames='gap-2' onSelect={onSelect}>
123
+ <span className='grow truncate'>{label}</span>
124
+ {selected && <Icon icon='ph--check--regular' size={4} />}
125
+ </DropdownMenu.Item>
126
+ );
@@ -0,0 +1,5 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ export * from './MicSettings';
@@ -0,0 +1,71 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import React, { Fragment } from 'react';
6
+
7
+ import { type TelemetryEvent } from '@dxos/transcription-pipeline';
8
+
9
+ import { type PipelinePhase } from '../../types';
10
+
11
+ export type PipelineStatusProps = {
12
+ /** Live lifecycle phase published by the transcription driver. */
13
+ phase?: PipelinePhase;
14
+ /** Ordered stage ids being run (e.g. correct → extract → summarize). */
15
+ stages?: readonly string[];
16
+ /** Per-stage telemetry emitted by `PipelineRuntime`. */
17
+ telemetry?: readonly TelemetryEvent[];
18
+ /** Cumulative summary produced by the summarization stage. */
19
+ summary?: string;
20
+ };
21
+
22
+ /**
23
+ * Read-only status panel reflecting the transcription/pipeline state: mic + pipeline phase, the
24
+ * active stage list, per-stage telemetry, and the running summary. Driven by the `PipelineStatus`
25
+ * capability (phase) and `PipelineRuntime` telemetry.
26
+ */
27
+ export const PipelineStatus = ({ phase = 'idle', stages, telemetry, summary }: PipelineStatusProps) => (
28
+ <div className='flex flex-col gap-3 overflow-y-auto p-2 text-sm'>
29
+ <Section title='Status'>
30
+ <div className='grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 text-description'>
31
+ <span>Mic</span>
32
+ <span>{phase === 'recording' ? '● on' : '○ off'}</span>
33
+ <span>Pipeline</span>
34
+ <span>{phase}</span>
35
+ </div>
36
+ </Section>
37
+
38
+ {stages && (
39
+ <Section title='Stages'>
40
+ <div className='text-description'>{stages.join(' → ') || '(none)'}</div>
41
+ </Section>
42
+ )}
43
+
44
+ {telemetry && telemetry.length > 0 && (
45
+ <Section title='Telemetry'>
46
+ <div className='grid w-fit grid-cols-[1fr_auto_auto] gap-x-4 gap-y-1'>
47
+ {telemetry.map((event, index) => (
48
+ <Fragment key={index}>
49
+ <span>{event.stageId}</span>
50
+ <span className='text-description'>{event.outcome}</span>
51
+ <span className='text-right tabular-nums text-description'>{event.durationMs}ms</span>
52
+ </Fragment>
53
+ ))}
54
+ </div>
55
+ </Section>
56
+ )}
57
+
58
+ {summary && (
59
+ <Section title='Summary'>
60
+ <p className='text-description'>{summary}</p>
61
+ </Section>
62
+ )}
63
+ </div>
64
+ );
65
+
66
+ const Section = ({ title, children }: { title: string; children: React.ReactNode }) => (
67
+ <div>
68
+ <h3 className='mb-1 font-medium'>{title}</h3>
69
+ {children}
70
+ </div>
71
+ );
@@ -0,0 +1,5 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ export * from './PipelineStatus';
@@ -0,0 +1,67 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import React from 'react';
6
+
7
+ import { composable, composableProps, useThemeContext } from '@dxos/react-ui';
8
+ import { useTextEditor } from '@dxos/react-ui-editor';
9
+ import { type Message, type Transcript } from '@dxos/types';
10
+ import {
11
+ AnchorWidget,
12
+ type XmlWidgetProps,
13
+ type XmlWidgetRegistry,
14
+ createBasicExtensions,
15
+ createMarkdownExtensions,
16
+ createThemeExtensions,
17
+ decorateMarkdown,
18
+ documentSlots,
19
+ scroller,
20
+ xmlTags,
21
+ } from '@dxos/ui-editor';
22
+
23
+ import { type TranscriptModel } from '../../model';
24
+ import { transcription } from './transcription-extension';
25
+
26
+ const inlinePreviewRegistry: XmlWidgetRegistry = {
27
+ 'link-preview': {
28
+ block: false,
29
+ urlSchemes: ['dxn:', 'echo:'],
30
+ factory: ({ label, dxn }: XmlWidgetProps<{ label: string; dxn: string }>) =>
31
+ typeof label === 'string' && typeof dxn === 'string' ? new AnchorWidget(label, dxn) : null,
32
+ },
33
+ };
34
+
35
+ export type TranscriptionProps = {
36
+ transcript?: Transcript.Transcript;
37
+ model: TranscriptModel<Message.Message>;
38
+ };
39
+
40
+ // TODO(burdon): Rename Transcript.
41
+ export const Transcription = composable<HTMLDivElement, TranscriptionProps>(
42
+ ({ transcript: object, model, children, ...props }, forwardedRef) => {
43
+ const { themeMode } = useThemeContext();
44
+ const { parentRef } = useTextEditor(() => {
45
+ return {
46
+ extensions: [
47
+ createBasicExtensions({ readOnly: true, lineWrapping: true, search: true }),
48
+ createThemeExtensions({ themeMode, slots: documentSlots }),
49
+ createMarkdownExtensions(),
50
+ // xmlTags() handles dxn:/echo: links via url-scheme widgets; skip here to avoid double-processing.
51
+ decorateMarkdown({ skip: ({ url }) => url.startsWith('dxn:') || url.startsWith('echo:') }),
52
+ xmlTags({ registry: inlinePreviewRegistry }),
53
+ transcription({ model, started: object?.started ? new Date(object.started) : undefined }),
54
+ scroller(),
55
+ ],
56
+ };
57
+ }, [model, themeMode]);
58
+
59
+ return (
60
+ <div
61
+ {...composableProps(props, { classNames: 'dx-container' })}
62
+ data-popover-collision-boundary={true}
63
+ ref={parentRef}
64
+ />
65
+ );
66
+ },
67
+ );
@@ -0,0 +1,6 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ export * from './Transcription';
6
+ export * from './transcription-extension';