@dxos/plugin-transcription 0.8.1 → 0.8.2-main.12df754

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 (191) hide show
  1. package/dist/lib/browser/{TranscriptContainer-JQ3HFHLQ.mjs → TranscriptContainer-4D4BHE7G.mjs} +3 -2
  2. package/dist/lib/browser/{app-graph-builder-VDQOVX7P.mjs → app-graph-builder-I4SSIQMW.mjs} +18 -41
  3. package/dist/lib/browser/app-graph-builder-I4SSIQMW.mjs.map +7 -0
  4. package/dist/lib/browser/{chunk-YEPT334A.mjs → chunk-5BE6RTNC.mjs} +1 -1
  5. package/dist/lib/browser/chunk-5BE6RTNC.mjs.map +7 -0
  6. package/dist/lib/browser/{chunk-OGNXNGJB.mjs → chunk-5Q3R53U3.mjs} +8 -8
  7. package/dist/lib/browser/chunk-5Q3R53U3.mjs.map +7 -0
  8. package/dist/lib/browser/{chunk-YCZISX63.mjs → chunk-DR3NBV6Q.mjs} +6 -6
  9. package/dist/lib/browser/{chunk-YCZISX63.mjs.map → chunk-DR3NBV6Q.mjs.map} +3 -3
  10. package/dist/lib/browser/{chunk-K7FOYAXO.mjs → chunk-GI5LZ4SV.mjs} +2 -2
  11. package/dist/lib/browser/{chunk-GU2BNYKZ.mjs → chunk-KLD7O5GJ.mjs} +2 -12
  12. package/dist/lib/browser/chunk-KLD7O5GJ.mjs.map +7 -0
  13. package/dist/lib/browser/chunk-RHHJZQPM.mjs +702 -0
  14. package/dist/lib/browser/chunk-RHHJZQPM.mjs.map +7 -0
  15. package/dist/lib/browser/index.mjs +22 -197
  16. package/dist/lib/browser/index.mjs.map +4 -4
  17. package/dist/lib/browser/{intent-resolver-4R66ZCKT.mjs → intent-resolver-ZQQEH27F.mjs} +7 -7
  18. package/dist/lib/browser/intent-resolver-ZQQEH27F.mjs.map +7 -0
  19. package/dist/lib/browser/{meeting-transcription-state-DLFHQK72.mjs → meeting-transcription-state-5D52VTHU.mjs} +4 -4
  20. package/dist/lib/browser/meeting-transcription-state-5D52VTHU.mjs.map +7 -0
  21. package/dist/lib/browser/meta.json +1 -1
  22. package/dist/lib/browser/{react-surface-FQUHU5G7.mjs → react-surface-HETJ5XY6.mjs} +5 -4
  23. package/dist/lib/browser/{transcriber-LGMIWDYI.mjs → transcriber-SMRLU2KY.mjs} +8 -7
  24. package/dist/lib/browser/{transcriber-LGMIWDYI.mjs.map → transcriber-SMRLU2KY.mjs.map} +3 -3
  25. package/dist/lib/browser/types/index.mjs +1 -1
  26. package/dist/lib/node/{TranscriptContainer-QEYTTT2V.cjs → TranscriptContainer-ZWP6UKY6.cjs} +8 -7
  27. package/dist/lib/node/TranscriptContainer-ZWP6UKY6.cjs.map +7 -0
  28. package/dist/lib/node/{app-graph-builder-IFSIVNUR.cjs → app-graph-builder-WWC4WNTG.cjs} +31 -54
  29. package/dist/lib/node/app-graph-builder-WWC4WNTG.cjs.map +7 -0
  30. package/dist/lib/node/{chunk-P4VVD6ZO.cjs → chunk-3QEJ5EBO.cjs} +10 -10
  31. package/dist/lib/node/chunk-3QEJ5EBO.cjs.map +7 -0
  32. package/dist/lib/node/{chunk-CZMLKKWT.cjs → chunk-HYGDHS6X.cjs} +4 -4
  33. package/dist/lib/node/chunk-HYGDHS6X.cjs.map +7 -0
  34. package/dist/lib/node/{chunk-3KHUSQNW.cjs → chunk-J72PUQ3C.cjs} +6 -15
  35. package/dist/lib/node/chunk-J72PUQ3C.cjs.map +7 -0
  36. package/dist/lib/node/chunk-KO6MKCED.cjs +718 -0
  37. package/dist/lib/node/chunk-KO6MKCED.cjs.map +7 -0
  38. package/dist/lib/node/{chunk-FCCJALOT.cjs → chunk-LA73IHCB.cjs} +10 -10
  39. package/dist/lib/node/{chunk-FCCJALOT.cjs.map → chunk-LA73IHCB.cjs.map} +3 -3
  40. package/dist/lib/node/{chunk-LP4FG423.cjs → chunk-WXG4Z2EZ.cjs} +5 -5
  41. package/dist/lib/node/index.cjs +49 -233
  42. package/dist/lib/node/index.cjs.map +4 -4
  43. package/dist/lib/node/{intent-resolver-WMMCOUEH.cjs → intent-resolver-VY3XWYAA.cjs} +11 -10
  44. package/dist/lib/node/intent-resolver-VY3XWYAA.cjs.map +7 -0
  45. package/dist/lib/node/{meeting-transcription-state-URIYILEQ.cjs → meeting-transcription-state-OYWCY5XE.cjs} +7 -7
  46. package/dist/lib/node/meeting-transcription-state-OYWCY5XE.cjs.map +7 -0
  47. package/dist/lib/node/meta.json +1 -1
  48. package/dist/lib/node/{react-surface-WI2MGWCC.cjs → react-surface-M2JQIIDY.cjs} +10 -9
  49. package/dist/lib/node/{react-surface-WI2MGWCC.cjs.map → react-surface-M2JQIIDY.cjs.map} +1 -1
  50. package/dist/lib/node/{transcriber-QG5WPKX4.cjs → transcriber-E6AAB4SU.cjs} +14 -13
  51. package/dist/lib/node/{transcriber-QG5WPKX4.cjs.map → transcriber-E6AAB4SU.cjs.map} +3 -3
  52. package/dist/lib/node/types/index.cjs +7 -7
  53. package/dist/lib/node/types/index.cjs.map +1 -1
  54. package/dist/lib/node-esm/{TranscriptContainer-ZXUCOWGY.mjs → TranscriptContainer-3HJ752CP.mjs} +3 -2
  55. package/dist/lib/node-esm/{app-graph-builder-UL6XLIZY.mjs → app-graph-builder-PPJQAFSI.mjs} +18 -41
  56. package/dist/lib/node-esm/app-graph-builder-PPJQAFSI.mjs.map +7 -0
  57. package/dist/lib/node-esm/{chunk-GIO6J6FE.mjs → chunk-54HDN33F.mjs} +8 -8
  58. package/dist/lib/node-esm/chunk-54HDN33F.mjs.map +7 -0
  59. package/dist/lib/node-esm/{chunk-YQYBLFNI.mjs → chunk-K3R3YK3S.mjs} +6 -6
  60. package/dist/lib/node-esm/{chunk-YQYBLFNI.mjs.map → chunk-K3R3YK3S.mjs.map} +3 -3
  61. package/dist/lib/node-esm/{chunk-HKATMD34.mjs → chunk-QMHMEKB3.mjs} +2 -12
  62. package/dist/lib/node-esm/chunk-QMHMEKB3.mjs.map +7 -0
  63. package/dist/lib/node-esm/chunk-SDMGJZLN.mjs +703 -0
  64. package/dist/lib/node-esm/chunk-SDMGJZLN.mjs.map +7 -0
  65. package/dist/lib/node-esm/{chunk-J4D2RBBH.mjs → chunk-XY22OP6H.mjs} +2 -2
  66. package/dist/lib/node-esm/{chunk-PTZHDZ36.mjs → chunk-YXZOLOFT.mjs} +1 -1
  67. package/dist/lib/node-esm/chunk-YXZOLOFT.mjs.map +7 -0
  68. package/dist/lib/node-esm/index.mjs +22 -197
  69. package/dist/lib/node-esm/index.mjs.map +4 -4
  70. package/dist/lib/node-esm/{intent-resolver-QN2LO5TX.mjs → intent-resolver-FCTNJPWY.mjs} +7 -7
  71. package/dist/lib/node-esm/intent-resolver-FCTNJPWY.mjs.map +7 -0
  72. package/dist/lib/node-esm/{meeting-transcription-state-ZYVRAW6Y.mjs → meeting-transcription-state-XMZJ45P4.mjs} +4 -4
  73. package/dist/lib/node-esm/meeting-transcription-state-XMZJ45P4.mjs.map +7 -0
  74. package/dist/lib/node-esm/meta.json +1 -1
  75. package/dist/lib/node-esm/{react-surface-PCH6NTVB.mjs → react-surface-PXFSIM6V.mjs} +5 -4
  76. package/dist/lib/{browser/react-surface-FQUHU5G7.mjs.map → node-esm/react-surface-PXFSIM6V.mjs.map} +1 -1
  77. package/dist/lib/node-esm/{transcriber-TMPYZ5L4.mjs → transcriber-REAGRCPY.mjs} +8 -7
  78. package/dist/lib/node-esm/{transcriber-TMPYZ5L4.mjs.map → transcriber-REAGRCPY.mjs.map} +3 -3
  79. package/dist/lib/node-esm/types/index.mjs +1 -1
  80. package/dist/types/src/capabilities/app-graph-builder.d.ts.map +1 -1
  81. package/dist/types/src/capabilities/capabilities.d.ts +4 -6
  82. package/dist/types/src/capabilities/capabilities.d.ts.map +1 -1
  83. package/dist/types/src/capabilities/index.d.ts +1 -4
  84. package/dist/types/src/capabilities/index.d.ts.map +1 -1
  85. package/dist/types/src/capabilities/meeting-transcription-state.d.ts +2 -4
  86. package/dist/types/src/capabilities/meeting-transcription-state.d.ts.map +1 -1
  87. package/dist/types/src/capabilities/transcriber.d.ts.map +1 -1
  88. package/dist/types/src/components/Transcript/Transcript.d.ts +12 -4
  89. package/dist/types/src/components/Transcript/Transcript.d.ts.map +1 -1
  90. package/dist/types/src/components/Transcript/Transcript.stories.d.ts +15 -4
  91. package/dist/types/src/components/Transcript/Transcript.stories.d.ts.map +1 -1
  92. package/dist/types/src/components/Transcript/deprecated/Transcript.d.ts +13 -0
  93. package/dist/types/src/components/Transcript/deprecated/Transcript.d.ts.map +1 -0
  94. package/dist/types/src/components/Transcript/testings.d.ts +51 -0
  95. package/dist/types/src/components/Transcript/testings.d.ts.map +1 -0
  96. package/dist/types/src/components/Transcript/transcript-extension.d.ts +19 -0
  97. package/dist/types/src/components/Transcript/transcript-extension.d.ts.map +1 -0
  98. package/dist/types/src/components/TranscriptContainer.d.ts.map +1 -1
  99. package/dist/types/src/components/transcription.stories.d.ts.map +1 -1
  100. package/dist/types/src/entity-extraction/entity-extraction.d.ts +16 -0
  101. package/dist/types/src/entity-extraction/entity-extraction.d.ts.map +1 -0
  102. package/dist/types/src/entity-extraction/entity-extraction.test.d.ts +2 -0
  103. package/dist/types/src/entity-extraction/entity-extraction.test.d.ts.map +1 -0
  104. package/dist/types/src/entity-extraction/index.d.ts +2 -0
  105. package/dist/types/src/entity-extraction/index.d.ts.map +1 -0
  106. package/dist/types/src/hooks/index.d.ts +1 -0
  107. package/dist/types/src/hooks/index.d.ts.map +1 -1
  108. package/dist/types/src/hooks/useQueueModelAdapter.d.ts +7 -0
  109. package/dist/types/src/hooks/useQueueModelAdapter.d.ts.map +1 -0
  110. package/dist/types/src/hooks/useTranscriber.d.ts +1 -1
  111. package/dist/types/src/hooks/useTranscriber.d.ts.map +1 -1
  112. package/dist/types/src/model/index.d.ts +2 -0
  113. package/dist/types/src/model/index.d.ts.map +1 -0
  114. package/dist/types/src/model/model.d.ts +68 -0
  115. package/dist/types/src/model/model.d.ts.map +1 -0
  116. package/dist/types/src/model/model.test.d.ts +2 -0
  117. package/dist/types/src/model/model.test.d.ts.map +1 -0
  118. package/dist/types/src/testing/test-data.d.ts +73 -0
  119. package/dist/types/src/testing/test-data.d.ts.map +1 -0
  120. package/dist/types/src/transcriber/transcriber.browser.test.d.ts +2 -0
  121. package/dist/types/src/transcriber/transcriber.browser.test.d.ts.map +1 -0
  122. package/dist/types/src/types/schema.d.ts +9 -9
  123. package/dist/types/src/util/index.d.ts +0 -1
  124. package/dist/types/src/util/index.d.ts.map +1 -1
  125. package/package.json +47 -39
  126. package/src/capabilities/app-graph-builder.ts +15 -26
  127. package/src/capabilities/capabilities.ts +3 -2
  128. package/src/capabilities/intent-resolver.ts +4 -4
  129. package/src/capabilities/meeting-transcription-state.ts +2 -2
  130. package/src/capabilities/transcriber.ts +2 -1
  131. package/src/components/Transcript/Transcript.stories.tsx +165 -43
  132. package/src/components/Transcript/Transcript.tsx +78 -210
  133. package/src/components/Transcript/deprecated/Transcript.tsx +231 -0
  134. package/src/components/Transcript/testings.ts +182 -0
  135. package/src/components/Transcript/transcript-extension.ts +233 -0
  136. package/src/components/TranscriptContainer.tsx +6 -3
  137. package/src/components/transcription.stories.tsx +97 -48
  138. package/src/entity-extraction/entity-extraction.test.ts +32 -0
  139. package/src/entity-extraction/entity-extraction.ts +94 -0
  140. package/src/entity-extraction/index.ts +5 -0
  141. package/src/entity-extraction/system-prompt.tpl +9 -0
  142. package/src/entity-extraction/types.d.ts +8 -0
  143. package/src/hooks/index.ts +1 -0
  144. package/src/hooks/useQueueModelAdapter.ts +30 -0
  145. package/src/hooks/useTranscriber.ts +3 -2
  146. package/src/model/index.ts +5 -0
  147. package/src/model/model.test.ts +123 -0
  148. package/src/model/model.ts +229 -0
  149. package/src/testing/test-data.ts +314 -0
  150. package/src/transcriber/transcriber.ts +1 -1
  151. package/src/transcriber/transcription-manager.ts +2 -2
  152. package/src/types/schema.ts +7 -7
  153. package/src/types/types.ts +2 -2
  154. package/src/util/index.ts +0 -1
  155. package/dist/lib/browser/app-graph-builder-VDQOVX7P.mjs.map +0 -7
  156. package/dist/lib/browser/chunk-G4B6CPC2.mjs +0 -253
  157. package/dist/lib/browser/chunk-G4B6CPC2.mjs.map +0 -7
  158. package/dist/lib/browser/chunk-GU2BNYKZ.mjs.map +0 -7
  159. package/dist/lib/browser/chunk-OGNXNGJB.mjs.map +0 -7
  160. package/dist/lib/browser/chunk-YEPT334A.mjs.map +0 -7
  161. package/dist/lib/browser/intent-resolver-4R66ZCKT.mjs.map +0 -7
  162. package/dist/lib/browser/meeting-transcription-state-DLFHQK72.mjs.map +0 -7
  163. package/dist/lib/node/TranscriptContainer-QEYTTT2V.cjs.map +0 -7
  164. package/dist/lib/node/app-graph-builder-IFSIVNUR.cjs.map +0 -7
  165. package/dist/lib/node/chunk-3KHUSQNW.cjs.map +0 -7
  166. package/dist/lib/node/chunk-CZMLKKWT.cjs.map +0 -7
  167. package/dist/lib/node/chunk-P4VVD6ZO.cjs.map +0 -7
  168. package/dist/lib/node/chunk-UOFHT4GZ.cjs +0 -280
  169. package/dist/lib/node/chunk-UOFHT4GZ.cjs.map +0 -7
  170. package/dist/lib/node/intent-resolver-WMMCOUEH.cjs.map +0 -7
  171. package/dist/lib/node/meeting-transcription-state-URIYILEQ.cjs.map +0 -7
  172. package/dist/lib/node-esm/app-graph-builder-UL6XLIZY.mjs.map +0 -7
  173. package/dist/lib/node-esm/chunk-6X7X2UZF.mjs +0 -254
  174. package/dist/lib/node-esm/chunk-6X7X2UZF.mjs.map +0 -7
  175. package/dist/lib/node-esm/chunk-GIO6J6FE.mjs.map +0 -7
  176. package/dist/lib/node-esm/chunk-HKATMD34.mjs.map +0 -7
  177. package/dist/lib/node-esm/chunk-PTZHDZ36.mjs.map +0 -7
  178. package/dist/lib/node-esm/intent-resolver-QN2LO5TX.mjs.map +0 -7
  179. package/dist/lib/node-esm/meeting-transcription-state-ZYVRAW6Y.mjs.map +0 -7
  180. package/dist/types/src/transcriber/transcriber.test.d.ts +0 -2
  181. package/dist/types/src/transcriber/transcriber.test.d.ts.map +0 -1
  182. package/dist/types/src/util/random-queue-dxn.d.ts +0 -3
  183. package/dist/types/src/util/random-queue-dxn.d.ts.map +0 -1
  184. package/src/util/random-queue-dxn.ts +0 -9
  185. /package/dist/lib/browser/{TranscriptContainer-JQ3HFHLQ.mjs.map → TranscriptContainer-4D4BHE7G.mjs.map} +0 -0
  186. /package/dist/lib/browser/{chunk-K7FOYAXO.mjs.map → chunk-GI5LZ4SV.mjs.map} +0 -0
  187. /package/dist/lib/{node-esm/react-surface-PCH6NTVB.mjs.map → browser/react-surface-HETJ5XY6.mjs.map} +0 -0
  188. /package/dist/lib/node/{chunk-LP4FG423.cjs.map → chunk-WXG4Z2EZ.cjs.map} +0 -0
  189. /package/dist/lib/node-esm/{TranscriptContainer-ZXUCOWGY.mjs.map → TranscriptContainer-3HJ752CP.mjs.map} +0 -0
  190. /package/dist/lib/node-esm/{chunk-J4D2RBBH.mjs.map → chunk-XY22OP6H.mjs.map} +0 -0
  191. /package/src/transcriber/{transcriber.test.ts → transcriber.browser.test.ts} +0 -0
@@ -0,0 +1,231 @@
1
+ //
2
+ // Copyright 2023 DXOS.org
3
+ //
4
+
5
+ import { intervalToDuration } from 'date-fns/intervalToDuration';
6
+ import { yieldOrContinue } from 'main-thread-scheduling';
7
+ import React, { type FC, useCallback, useEffect, useMemo, useRef, useState, type WheelEvent } from 'react';
8
+ import { type OnResizeCallback, useResizeDetector } from 'react-resize-detector';
9
+
10
+ import { IconButton, type ThemedClassName, useTranslation } from '@dxos/react-ui';
11
+ import { useAttention } from '@dxos/react-ui-attention';
12
+ import {
13
+ type DxGridCellValue,
14
+ type DxGridElement,
15
+ type DxGridAxisMeta,
16
+ type DxGridPlaneCells,
17
+ Grid,
18
+ toPlaneCellIndex,
19
+ } from '@dxos/react-ui-grid';
20
+ import { mx } from '@dxos/react-ui-theme';
21
+
22
+ import { TRANSCRIPTION_PLUGIN } from '../../../meta';
23
+ import { type TranscriptBlock } from '../../../types';
24
+
25
+ // TODO(burdon): Actions (e.g., mark, summarize, translate, label, delete).
26
+
27
+ const lineHeight = 20;
28
+ const cellSpacing = 8 + 2;
29
+ const timestampColumnWidth = 68;
30
+
31
+ const authorClasses = 'font-medium text-base leading-[20px]';
32
+ const timestampClasses = 'text-xs leading-[20px] text-description pie-0 tabular-nums';
33
+ const segmentTextClasses = 'text-sm whitespace-normal hyphens-auto';
34
+ const measureClasses = mx(
35
+ // NOTE(thure): The `inline-start` value must equal `timestampColumnWidth` plus grid’s gap (1px)
36
+ 'absolute inline-start-[69px] inline-end-0 invisible z-[-1] border',
37
+ 'pli-[--dx-grid-cell-padding-inline] plb-[--dx-grid-cell-padding-block] leading-[20px]',
38
+ segmentTextClasses,
39
+ );
40
+
41
+ const rowDefault = {
42
+ grid: { size: lineHeight + cellSpacing },
43
+ };
44
+
45
+ type QueueRows = [number, number][];
46
+
47
+ const mapTranscriptQueue = (blocks?: TranscriptBlock[]): QueueRows => {
48
+ if (!blocks || !Array.isArray(blocks) || blocks.length === 0) {
49
+ return [];
50
+ } else {
51
+ return blocks.flatMap((block, blockIndex) => {
52
+ return [
53
+ [blockIndex, -1],
54
+ ...block.segments.map((segment, segmentIndex) => {
55
+ return [blockIndex, segmentIndex] as [number, number];
56
+ }),
57
+ ];
58
+ });
59
+ }
60
+ };
61
+
62
+ const pad = (n = 0) => String(n).padStart(2, '0');
63
+
64
+ const formatTimestamp = (start: Date, end: Date) => {
65
+ const { hours, minutes, seconds } = intervalToDuration({ start, end });
66
+ return `${pad(hours)}:${pad(minutes)}:${pad(seconds)}`;
67
+ };
68
+
69
+ const measureRows = async (
70
+ host: HTMLDivElement,
71
+ blocks: TranscriptBlock[],
72
+ queueMap: QueueRows,
73
+ signal: AbortSignal,
74
+ ) => {
75
+ const result: DxGridAxisMeta = { grid: {} };
76
+ for (let row = 0; row < queueMap.length; row++) {
77
+ await yieldOrContinue('smooth', signal);
78
+ const [blockIndex, segmentIndex] = queueMap[row];
79
+ if (segmentIndex < 0) {
80
+ result.grid[row] = { size: lineHeight + cellSpacing };
81
+ } else {
82
+ host.textContent = blocks[blockIndex]!.segments[segmentIndex]!.text;
83
+ result.grid[row] = { size: host.offsetHeight };
84
+ }
85
+ }
86
+ return result;
87
+ };
88
+
89
+ export type TranscriptProps = ThemedClassName<{
90
+ blocks?: TranscriptBlock[];
91
+ attendableId?: string;
92
+ ignoreAttention?: boolean;
93
+ }>;
94
+
95
+ /**
96
+ * @@deprecated
97
+ */
98
+ export const Transcript: FC<TranscriptProps> = ({ classNames, blocks, attendableId, ignoreAttention }) => {
99
+ const { t } = useTranslation(TRANSCRIPTION_PLUGIN);
100
+ const [dxGrid, setDxGrid] = useState<DxGridElement | null>(null);
101
+ const [rows, setRows] = useState<DxGridAxisMeta | undefined>(undefined);
102
+ const [columns, setColumns] = useState<DxGridAxisMeta | undefined>(undefined);
103
+
104
+ const queueMap = useMemo(() => mapTranscriptQueue(blocks), [blocks]);
105
+
106
+ const { hasAttention } = useAttention(attendableId);
107
+ const [autoScroll, setAutoScroll] = useState(true);
108
+ const handleWheel = useCallback(
109
+ (event: WheelEvent) => {
110
+ setAutoScroll(false);
111
+ if (!ignoreAttention && !hasAttention) {
112
+ event.stopPropagation();
113
+ }
114
+ },
115
+ [hasAttention, ignoreAttention],
116
+ );
117
+
118
+ const abortControllerRef = useRef<AbortController>();
119
+
120
+ const handleResize = useCallback(
121
+ async ({ entry }: { entry: { target: HTMLDivElement } | null }) => {
122
+ if (entry?.target && Array.isArray(blocks) && blocks[0] && queueMap) {
123
+ abortControllerRef.current?.abort();
124
+ abortControllerRef.current = new AbortController();
125
+ setColumns({ grid: { 0: { size: timestampColumnWidth }, 1: { size: entry.target.offsetWidth } } });
126
+ return measureRows(entry.target, blocks, queueMap, abortControllerRef.current.signal)
127
+ .then(setRows)
128
+ .catch(() => {
129
+ // Aborted mid-measurement by new size.
130
+ });
131
+ }
132
+ },
133
+ [blocks, queueMap],
134
+ );
135
+
136
+ const { width, ref: measureRef } = useResizeDetector({
137
+ onResize: handleResize as OnResizeCallback,
138
+ refreshOptions: { leading: true },
139
+ });
140
+
141
+ useEffect(() => {
142
+ if (queueMap.length !== Object.keys(rows?.grid ?? {}).length) {
143
+ void handleResize({ entry: { target: measureRef.current } }).then(() => {
144
+ if (autoScroll) {
145
+ // TODO(thure): Implement a deterministic way to do this when `rows` has fully settled and grid has a new `maxPosBlock`.
146
+ setTimeout(() => dxGrid?.scrollToEndRow(), 50);
147
+ }
148
+ });
149
+ }
150
+ }, [blocks, queueMap, width, autoScroll]);
151
+
152
+ useEffect(() => {
153
+ if (dxGrid && Array.isArray(blocks) && blocks[0]) {
154
+ const transcriptStart = blocks[0]!.segments[0]!.started;
155
+ dxGrid.getCells = (range, plane) => {
156
+ switch (plane) {
157
+ case 'grid': {
158
+ const cells: DxGridPlaneCells = {};
159
+ for (let row = range.start.row; row <= range.end.row && row < queueMap.length; row++) {
160
+ const [blockIndex, segmentIndex] = queueMap[row];
161
+
162
+ cells[toPlaneCellIndex({ col: 0, row })] = {
163
+ readonly: true,
164
+ value:
165
+ segmentIndex < 0
166
+ ? formatTimestamp(
167
+ new Date(transcriptStart),
168
+ new Date(blocks[blockIndex]!.segments[Math.max(0, segmentIndex)]!.started),
169
+ )
170
+ : '',
171
+ className: timestampClasses,
172
+ } satisfies DxGridCellValue;
173
+
174
+ cells[toPlaneCellIndex({ col: 1, row })] = (
175
+ segmentIndex < 0
176
+ ? {
177
+ readonly: true,
178
+ accessoryHtml: `<span data-hue="${blocks[blockIndex]!.authorHue}" class="dx-text-hue">${blocks[blockIndex]!.authorName}</span>`,
179
+ className: authorClasses,
180
+ }
181
+ : {
182
+ readonly: true,
183
+ value: blocks[blockIndex]!.segments[segmentIndex]!.text,
184
+ className: segmentTextClasses,
185
+ }
186
+ ) satisfies DxGridCellValue;
187
+ }
188
+ return cells;
189
+ }
190
+ default:
191
+ return {};
192
+ }
193
+ };
194
+ }
195
+ }, [dxGrid, blocks]);
196
+
197
+ const handleScrollToEnd = useCallback(() => {
198
+ setAutoScroll(true);
199
+ dxGrid?.scrollToEndRow();
200
+ }, [dxGrid, autoScroll]);
201
+
202
+ return (
203
+ <div role='none' className={mx('relative min-bs-0', classNames)}>
204
+ <Grid.Root id={`${attendableId}--transcript`}>
205
+ <Grid.Content
206
+ limitColumns={2}
207
+ limitRows={queueMap.length}
208
+ columns={columns}
209
+ rows={rows}
210
+ rowDefault={rowDefault}
211
+ onWheel={handleWheel}
212
+ className='[--dx-grid-base:var(--dx-baseSurface)] [--dx-grid-lines:var(--dx-baseSurface)] [&_.dx-grid]:min-bs-0 [&_.dx-grid]:min-is-0 [&_.dx-grid]:select-auto'
213
+ ref={setDxGrid}
214
+ />
215
+ </Grid.Root>
216
+ <div role='none' {...{ inert: '' }} aria-hidden className={measureClasses} ref={measureRef} />
217
+ <IconButton
218
+ icon='ph--arrow-line-down--regular'
219
+ iconOnly
220
+ label={t('scroll to end label')}
221
+ tooltipSide='left'
222
+ data-state={autoScroll ? 'invisible' : 'visible'}
223
+ classNames={[
224
+ 'absolute inline-end-2 block-end-2 opacity-0 pointer-events-none',
225
+ 'data-[state="visible"]:pointer-events-auto data-[state="visible"]:opacity-100 transition-opacity',
226
+ ]}
227
+ onClick={handleScrollToEnd}
228
+ />
229
+ </div>
230
+ );
231
+ };
@@ -0,0 +1,182 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import { useEffect, useMemo, useState } from 'react';
6
+
7
+ import { AIServiceEdgeClient } from '@dxos/assistant';
8
+ import { AI_SERVICE_ENDPOINT } from '@dxos/assistant/testing';
9
+ import { scheduleTaskInterval } from '@dxos/async';
10
+ import { Context } from '@dxos/context';
11
+ import { AST, create, EchoObject, ObjectId, S } from '@dxos/echo-schema';
12
+ import { DXN, QueueSubspaceTags } from '@dxos/keys';
13
+ import { faker } from '@dxos/random';
14
+ import { live, makeRef, useQueue, type Space } from '@dxos/react-client/echo';
15
+ import { SpaceId } from '@dxos/react-client/echo';
16
+ import { hues } from '@dxos/react-ui-theme';
17
+ import { ContactType } from '@dxos/schema';
18
+
19
+ import { processTranscriptBlock } from '../../entity-extraction';
20
+ import * as TestData from '../../testing/test-data';
21
+ import { TranscriptBlock } from '../../types';
22
+
23
+ // TODO(burdon): Reconcile with plugin-markdown. Move to schema-testing.
24
+ export const TestItem = S.Struct({
25
+ title: S.String.annotations({
26
+ [AST.TitleAnnotationId]: 'Title',
27
+ [AST.DescriptionAnnotationId]: 'Product title',
28
+ }),
29
+ description: S.String.annotations({
30
+ [AST.TitleAnnotationId]: 'Description',
31
+ [AST.DescriptionAnnotationId]: 'Product description',
32
+ }),
33
+ }).pipe(EchoObject({ typename: 'dxos.org/type/Test', version: '0.1.0' }));
34
+
35
+ /**
36
+ * Generator of transcript blocks.
37
+ */
38
+ export class BlockBuilder {
39
+ static readonly singleton = new BlockBuilder();
40
+
41
+ users = Array.from({ length: 5 }, () => ({
42
+ authorName: faker.person.fullName(),
43
+ authorHue: faker.helpers.arrayElement(hues),
44
+ }));
45
+
46
+ start = new Date(Date.now() - 24 * 60 * 60 * 10_000);
47
+
48
+ constructor(private readonly _space?: Space) {}
49
+
50
+ createBlock(numSegments = 1): TranscriptBlock {
51
+ return {
52
+ id: ObjectId.random().toString(),
53
+ ...faker.helpers.arrayElement(this.users),
54
+ segments: Array.from({ length: numSegments }).map(() => this.createSegment()),
55
+ };
56
+ }
57
+
58
+ createSegment() {
59
+ let text = faker.lorem.paragraph();
60
+ if (this._space) {
61
+ const label = faker.commerce.productName();
62
+ const obj = this._space.db.add(live(TestItem, { title: label, description: faker.lorem.paragraph() }));
63
+ const dxn = makeRef(obj).dxn.toString();
64
+ const words = text.split(' ');
65
+ words.splice(Math.floor(Math.random() * words.length), 0, `[${label}][${dxn}]`);
66
+ text = words.join(' ');
67
+ }
68
+
69
+ return {
70
+ started: this.next().toISOString(),
71
+ text,
72
+ };
73
+ }
74
+
75
+ next() {
76
+ this.start = new Date(this.start.getTime() + Math.random() * 10_000);
77
+ return this.start;
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Test transcriptionqueue.
83
+ */
84
+ export const useTestTranscriptionQueue = (space: Space | undefined, running = true, interval = 1_000) => {
85
+ const queueDxn = useMemo(
86
+ () => (space ? new DXN(DXN.kind.QUEUE, [QueueSubspaceTags.DATA, space.id, ObjectId.random()]) : undefined),
87
+ [space],
88
+ );
89
+ const queue = useQueue<TranscriptBlock>(queueDxn);
90
+
91
+ const builder = useMemo(() => new BlockBuilder(space), [space]);
92
+ useEffect(() => {
93
+ if (!queue || !running) {
94
+ return;
95
+ }
96
+
97
+ const i = setInterval(() => {
98
+ queue.append([create(TranscriptBlock, builder.createBlock(Math.ceil(Math.random() * 3)))]);
99
+ }, interval);
100
+ return () => clearInterval(i);
101
+ }, [queue, running, interval]);
102
+
103
+ return queue;
104
+ };
105
+
106
+ class EntityExtractionBlockBuilder {
107
+ aiService = new AIServiceEdgeClient({
108
+ endpoint: AI_SERVICE_ENDPOINT.REMOTE,
109
+ });
110
+
111
+ currentBlock: number = 0;
112
+
113
+ seedData(space: Space) {
114
+ space.db.graph.schemaRegistry.addSchema([ContactType]);
115
+ // for (const document of TestData.documents) {
116
+ // const obj = space.db.add(live(Document, document));
117
+ // const dxn = makeRef(obj).dxn.toString();
118
+ // document.dxn = dxn;
119
+ // }
120
+
121
+ for (const contact of Object.values(TestData.contacts)) {
122
+ space.db.add(contact);
123
+ }
124
+ }
125
+
126
+ async createBlock(): Promise<TranscriptBlock> {
127
+ const block = TestData.transcriptBlocks[this.currentBlock];
128
+ this.currentBlock++;
129
+ this.currentBlock = this.currentBlock % TestData.transcriptBlocks.length;
130
+
131
+ const { block: enhancedBlock } = await processTranscriptBlock({
132
+ block,
133
+ aiService: this.aiService,
134
+ context: {
135
+ objects: [...TestData.documents, ...Object.values(TestData.contacts)],
136
+ },
137
+ });
138
+ return enhancedBlock;
139
+ }
140
+ }
141
+
142
+ const randomQueueDXN = (spaceId = SpaceId.random()) =>
143
+ new DXN(DXN.kind.QUEUE, [QueueSubspaceTags.DATA, spaceId, ObjectId.random()]);
144
+
145
+ /**
146
+ * Test transcription queue.
147
+ */
148
+ export const useTestTranscriptionQueueWithEntityExtraction = (
149
+ space: Space | undefined,
150
+ running = true,
151
+ interval = 1_000,
152
+ ) => {
153
+ const queueDxn = useMemo(() => (space ? randomQueueDXN(space.id) : undefined), [space]);
154
+ const queue = useQueue<TranscriptBlock>(queueDxn);
155
+ const [builder] = useState(() => new EntityExtractionBlockBuilder());
156
+
157
+ useEffect(() => {
158
+ if (!queue || !running) {
159
+ return;
160
+ }
161
+
162
+ if (space) {
163
+ builder.seedData(space);
164
+ }
165
+
166
+ const ctx = new Context();
167
+ scheduleTaskInterval(
168
+ ctx,
169
+ async () => {
170
+ const block = await builder.createBlock();
171
+ queue.append([create(TranscriptBlock, block)]);
172
+ },
173
+ interval,
174
+ );
175
+
176
+ return () => {
177
+ void ctx.dispose();
178
+ };
179
+ }, [space, queue, running, interval]);
180
+
181
+ return queue;
182
+ };
@@ -0,0 +1,233 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import { type Extension, RangeSetBuilder } from '@codemirror/state';
6
+ import { EditorView, GutterMarker, ViewPlugin, type ViewUpdate, gutter } from '@codemirror/view';
7
+ import { format } from 'date-fns/format';
8
+ import { intervalToDuration } from 'date-fns/intervalToDuration';
9
+
10
+ import { type CleanupFn, addEventListener, combine } from '@dxos/async';
11
+ import { type RenderCallback } from '@dxos/react-ui-editor';
12
+
13
+ import { DocumentAdapter, type BlockModel } from '../../model';
14
+ import { type TranscriptBlock } from '../../types';
15
+
16
+ /**
17
+ * Data structure that maps Blocks queue to lines with transcript state.
18
+ */
19
+ export type TranscriptOptions = {
20
+ model: BlockModel<TranscriptBlock>;
21
+ started?: Date;
22
+ renderButton?: RenderCallback<{ onClick: () => void }>;
23
+ };
24
+
25
+ /**
26
+ * Scrolling transcript with timestamps.
27
+ */
28
+ export const transcript = ({ model, started, renderButton }: TranscriptOptions): Extension => {
29
+ return [
30
+ // Show timestamps in the gutter.
31
+ gutter({
32
+ class: 'cm-timestamp-gutter',
33
+ lineMarkerChange: (update) => update.docChanged || update.viewportChanged,
34
+ markers: (view) => {
35
+ const start = getStartTime(started, model.blocks[0]);
36
+ const builder = new RangeSetBuilder<GutterMarker>();
37
+ for (const { from, to } of view.visibleRanges) {
38
+ let line = view.state.doc.lineAt(from);
39
+ while (line.from <= to) {
40
+ const timestamp = model.getBlockAtLine(line.number)?.segments[0]?.started;
41
+ if (timestamp) {
42
+ builder.add(line.from, line.from, new TimestampMarker(line.number, new Date(timestamp), start));
43
+ }
44
+
45
+ if (line.to + 1 > view.state.doc.length) {
46
+ break;
47
+ }
48
+
49
+ line = view.state.doc.lineAt(line.to + 1);
50
+ }
51
+ }
52
+
53
+ return builder.finish();
54
+ },
55
+ }),
56
+
57
+ // Listen for model updates.
58
+ ViewPlugin.fromClass(
59
+ class {
60
+ private readonly _controls?: HTMLDivElement;
61
+ private readonly _cleanup: CleanupFn;
62
+ private readonly _adapter: DocumentAdapter;
63
+ private _initialized = false;
64
+
65
+ constructor(view: EditorView) {
66
+ this._adapter = new DocumentAdapter(view);
67
+
68
+ const scroller = view.scrollDOM;
69
+ let isAutoScrolling = false;
70
+ let hasScrolled = false;
71
+
72
+ const scrollToBottom = () => {
73
+ scroller.style.scrollBehavior = 'smooth';
74
+
75
+ // Temporarily hide scrollbar to prevent flicker.
76
+ scroller.classList.add('cm-hide-scrollbar');
77
+ isAutoScrolling = true;
78
+ setTimeout(() => {
79
+ this._controls?.classList.add('opacity-0');
80
+ scroller.classList.remove('cm-hide-scrollbar');
81
+ isAutoScrolling = false;
82
+ }, 500);
83
+
84
+ // Scroll to bottom.
85
+ view.dispatch({
86
+ effects: EditorView.scrollIntoView(view.state.doc.length, { y: 'end' }),
87
+ });
88
+ };
89
+
90
+ // Scroll button.
91
+ if (renderButton) {
92
+ this._controls = document.createElement('div');
93
+ this._controls.classList.add('cm-controls', 'transition-opacity', 'duration-300', 'opacity-0');
94
+ view.dom.appendChild(this._controls);
95
+ renderButton(
96
+ this._controls,
97
+ {
98
+ onClick: () => {
99
+ scrollToBottom();
100
+ },
101
+ },
102
+ view,
103
+ );
104
+ }
105
+
106
+ // Event listeners.
107
+ this._cleanup = combine(
108
+ addEventListener(view.scrollDOM, 'scroll', () => {
109
+ if (!isAutoScrolling) {
110
+ hasScrolled = true;
111
+ this._controls?.classList.remove('opacity-0');
112
+ }
113
+ }),
114
+
115
+ model.update.on(() => {
116
+ // Check if clamped to bottom.
117
+ const autoScroll =
118
+ scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight === 0 || !hasScrolled;
119
+
120
+ // Sync.
121
+ model.sync(this._adapter);
122
+
123
+ // Scroll.
124
+ if (autoScroll) {
125
+ scrollToBottom();
126
+ }
127
+ }),
128
+ );
129
+ }
130
+
131
+ update(update: ViewUpdate) {
132
+ // Initial sync.
133
+ if (!this._initialized) {
134
+ this._initialized = true;
135
+ setTimeout(() => {
136
+ model.sync(this._adapter);
137
+ });
138
+ }
139
+ }
140
+
141
+ destroy() {
142
+ this._controls?.remove();
143
+ this._cleanup();
144
+ }
145
+ },
146
+ ),
147
+
148
+ EditorView.theme({
149
+ '.cm-scroller': {
150
+ overflowY: 'scroll',
151
+ },
152
+ '.cm-hide-scrollbar': {
153
+ scrollbarWidth: 'none',
154
+ '-ms-overflow-style': 'none',
155
+ },
156
+ '.cm-hide-scrollbar::-webkit-scrollbar': {
157
+ display: 'none',
158
+ },
159
+ '.cm-controls': {
160
+ position: 'absolute',
161
+ bottom: '0.5rem',
162
+ right: '0.5rem',
163
+ zIndex: 1000,
164
+ },
165
+ '.cm-line': {
166
+ paddingRight: '1rem',
167
+ },
168
+ '.cm-timestamp-gutter': {
169
+ width: '6rem',
170
+ paddingRight: '1rem',
171
+ },
172
+ '.cm-timestamp-gutter > .cm-gutterElement > div': {
173
+ display: 'inline-flex',
174
+ textAlign: 'right',
175
+ padding: '3px',
176
+ },
177
+ }),
178
+ ];
179
+ };
180
+
181
+ /**
182
+ * Gutter marker that displays a timestamp.
183
+ */
184
+ class TimestampMarker extends GutterMarker {
185
+ constructor(
186
+ readonly _line: number,
187
+ readonly _timestamp: Date,
188
+ readonly _started?: Date,
189
+ ) {
190
+ super();
191
+ }
192
+
193
+ override eq(other: this) {
194
+ return other._timestamp === this._timestamp;
195
+ }
196
+
197
+ override toDOM(view: EditorView) {
198
+ const el = document.createElement('div');
199
+ el.className = 'text-sm text-subdued hover:bg-hoverSurface cursor-pointer';
200
+ el.textContent = formatTimestamp(this._timestamp, this._started);
201
+ // TODO(burdon): Click to bookmark or copy hyperlink.
202
+ el.onclick = () => {
203
+ const pos = view.state.doc.line(this._line).from;
204
+ view.dispatch({
205
+ effects: EditorView.scrollIntoView(pos, { y: 'start' }),
206
+ });
207
+ };
208
+
209
+ return el;
210
+ }
211
+ }
212
+
213
+ const getStartTime = (started?: Date, block?: TranscriptBlock): Date | undefined => {
214
+ if (started) {
215
+ return started;
216
+ }
217
+
218
+ if (block?.segments[0]?.started) {
219
+ return new Date(block.segments[0].started);
220
+ }
221
+
222
+ return undefined;
223
+ };
224
+
225
+ const formatTimestamp = (timestamp: Date, relative?: Date) => {
226
+ if (!relative) {
227
+ return format(timestamp, 'HH:mm:ss');
228
+ } else {
229
+ const pad = (n = 0) => String(n).padStart(2, '0');
230
+ const { hours, minutes, seconds } = intervalToDuration({ start: relative, end: timestamp });
231
+ return `${pad(hours)}:${pad(minutes)}:${pad(seconds)}`;
232
+ }
233
+ };
@@ -4,20 +4,23 @@
4
4
 
5
5
  import React, { type FC } from 'react';
6
6
 
7
- import { fullyQualifiedId } from '@dxos/client/echo';
7
+ import { fullyQualifiedId, getSpace } from '@dxos/client/echo';
8
8
  import { useQueue } from '@dxos/react-client/echo';
9
9
  import { StackItem } from '@dxos/react-ui-stack';
10
10
 
11
- import { Transcript } from './Transcript';
11
+ import { Transcript, renderMarkdown } from './Transcript';
12
+ import { useQueueModelAdapter } from '../hooks';
12
13
  import { type TranscriptBlock, type TranscriptType } from '../types';
13
14
 
14
15
  export const TranscriptionContainer: FC<{ role: string; transcript: TranscriptType }> = ({ role, transcript }) => {
15
16
  const attendableId = fullyQualifiedId(transcript);
17
+ const space = getSpace(transcript);
16
18
  const queue = useQueue<TranscriptBlock>(transcript.queue.dxn, { pollInterval: 1_000 });
19
+ const model = useQueueModelAdapter(renderMarkdown, queue);
17
20
 
18
21
  return (
19
22
  <StackItem.Content role={role} classNames='container-max-width pli-2'>
20
- <Transcript attendableId={attendableId} blocks={queue?.items} />
23
+ <Transcript attendableId={attendableId} space={space} model={model} transcript={transcript} />
21
24
  </StackItem.Content>
22
25
  );
23
26
  };