@dxos/plugin-transcription 0.8.0 → 0.8.1-main.013e445

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 (152) hide show
  1. package/dist/lib/browser/{TranscriptContainer-SX6PNFEC.mjs → TranscriptContainer-TJXP6ICP.mjs} +3 -3
  2. package/dist/lib/browser/app-graph-builder-ZIIDYMHP.mjs +39 -0
  3. package/dist/lib/browser/app-graph-builder-ZIIDYMHP.mjs.map +7 -0
  4. package/dist/lib/browser/{chunk-AS364WZZ.mjs → chunk-3NWYXRZS.mjs} +2 -2
  5. package/dist/lib/browser/chunk-MD4NQO6W.mjs +252 -0
  6. package/dist/lib/browser/chunk-MD4NQO6W.mjs.map +7 -0
  7. package/dist/lib/browser/chunk-N3VKF2J3.mjs +415 -0
  8. package/dist/lib/browser/chunk-N3VKF2J3.mjs.map +7 -0
  9. package/dist/lib/browser/chunk-YEVVZZTC.mjs +19 -0
  10. package/dist/lib/browser/chunk-YEVVZZTC.mjs.map +7 -0
  11. package/dist/lib/browser/{chunk-QIHUPVQD.mjs → chunk-YRW4MNRE.mjs} +23 -24
  12. package/dist/lib/browser/chunk-YRW4MNRE.mjs.map +7 -0
  13. package/dist/lib/browser/index.mjs +203 -366
  14. package/dist/lib/browser/index.mjs.map +4 -4
  15. package/dist/lib/browser/intent-resolver-AC6LFBE4.mjs +73 -0
  16. package/dist/lib/browser/intent-resolver-AC6LFBE4.mjs.map +7 -0
  17. package/dist/lib/browser/meta.json +1 -1
  18. package/dist/lib/browser/{react-surface-FOEWLLTZ.mjs → react-surface-OM2EECBI.mjs} +15 -8
  19. package/dist/lib/browser/{react-surface-FOEWLLTZ.mjs.map → react-surface-OM2EECBI.mjs.map} +2 -2
  20. package/dist/lib/browser/types/index.mjs +2 -1
  21. package/dist/lib/node/{TranscriptContainer-HU763SN3.cjs → TranscriptContainer-KNTN76RZ.cjs} +8 -8
  22. package/dist/lib/node/{TranscriptContainer-HU763SN3.cjs.map → TranscriptContainer-KNTN76RZ.cjs.map} +2 -2
  23. package/dist/lib/node/app-graph-builder-CJSNQYJZ.cjs +52 -0
  24. package/dist/lib/node/app-graph-builder-CJSNQYJZ.cjs.map +7 -0
  25. package/dist/lib/node/chunk-EKSRBQX7.cjs +437 -0
  26. package/dist/lib/node/chunk-EKSRBQX7.cjs.map +7 -0
  27. package/dist/lib/node/{chunk-QWXLQPP5.cjs → chunk-I6CUIAQH.cjs} +5 -5
  28. package/dist/lib/node/{app-graph-builder-YFBX3OTP.cjs → chunk-LEHTJHKP.cjs} +23 -7
  29. package/dist/lib/node/chunk-LEHTJHKP.cjs.map +7 -0
  30. package/dist/lib/node/chunk-T4W4AKGM.cjs +279 -0
  31. package/dist/lib/node/chunk-T4W4AKGM.cjs.map +7 -0
  32. package/dist/lib/node/{chunk-UIHEGU6Q.cjs → chunk-ZAE2IOZC.cjs} +25 -27
  33. package/dist/lib/node/chunk-ZAE2IOZC.cjs.map +7 -0
  34. package/dist/lib/node/index.cjs +220 -378
  35. package/dist/lib/node/index.cjs.map +4 -4
  36. package/dist/lib/node/intent-resolver-ZSQ2MHJJ.cjs +84 -0
  37. package/dist/lib/node/intent-resolver-ZSQ2MHJJ.cjs.map +7 -0
  38. package/dist/lib/node/meta.json +1 -1
  39. package/dist/lib/node/{react-surface-AGGGDOUC.cjs → react-surface-ZENSCF6S.cjs} +18 -12
  40. package/dist/lib/node/{react-surface-AGGGDOUC.cjs.map → react-surface-ZENSCF6S.cjs.map} +2 -2
  41. package/dist/lib/node/types/index.cjs +8 -7
  42. package/dist/lib/node/types/index.cjs.map +2 -2
  43. package/dist/lib/node-esm/{TranscriptContainer-DVBXQIQ4.mjs → TranscriptContainer-MOWZQSEO.mjs} +3 -3
  44. package/dist/lib/node-esm/app-graph-builder-ARTLU4U5.mjs +40 -0
  45. package/dist/lib/node-esm/app-graph-builder-ARTLU4U5.mjs.map +7 -0
  46. package/dist/lib/node-esm/chunk-4A6BWPRB.mjs +253 -0
  47. package/dist/lib/node-esm/chunk-4A6BWPRB.mjs.map +7 -0
  48. package/dist/lib/node-esm/{chunk-KEG3ME4O.mjs → chunk-DLXORY7Y.mjs} +22 -24
  49. package/dist/lib/node-esm/chunk-DLXORY7Y.mjs.map +7 -0
  50. package/dist/lib/node-esm/{chunk-WEZGRUG6.mjs → chunk-NFPDIQYG.mjs} +2 -2
  51. package/dist/lib/node-esm/chunk-SB5TLXBU.mjs +21 -0
  52. package/dist/lib/node-esm/chunk-SB5TLXBU.mjs.map +7 -0
  53. package/dist/lib/node-esm/chunk-SWV74BYU.mjs +416 -0
  54. package/dist/lib/node-esm/chunk-SWV74BYU.mjs.map +7 -0
  55. package/dist/lib/node-esm/index.mjs +203 -366
  56. package/dist/lib/node-esm/index.mjs.map +4 -4
  57. package/dist/lib/node-esm/intent-resolver-XU4BLUSF.mjs +74 -0
  58. package/dist/lib/node-esm/intent-resolver-XU4BLUSF.mjs.map +7 -0
  59. package/dist/lib/node-esm/meta.json +1 -1
  60. package/dist/lib/node-esm/{react-surface-6AYPLJ2B.mjs → react-surface-5CBEXLXJ.mjs} +15 -8
  61. package/dist/lib/node-esm/{react-surface-6AYPLJ2B.mjs.map → react-surface-5CBEXLXJ.mjs.map} +2 -2
  62. package/dist/lib/node-esm/types/index.mjs +2 -1
  63. package/dist/types/src/TranscriptionPlugin.d.ts.map +1 -1
  64. package/dist/types/src/capabilities/app-graph-builder.d.ts +1 -0
  65. package/dist/types/src/capabilities/app-graph-builder.d.ts.map +1 -1
  66. package/dist/types/src/capabilities/index.d.ts.map +1 -1
  67. package/dist/types/src/capabilities/intent-resolver.d.ts.map +1 -1
  68. package/dist/types/src/components/Transcript/Transcript.d.ts +2 -0
  69. package/dist/types/src/components/Transcript/Transcript.d.ts.map +1 -1
  70. package/dist/types/src/components/Transcript/Transcript.stories.d.ts.map +1 -1
  71. package/dist/types/src/components/TranscriptContainer.d.ts +1 -0
  72. package/dist/types/src/components/TranscriptContainer.d.ts.map +1 -1
  73. package/dist/types/src/components/index.d.ts +1 -0
  74. package/dist/types/src/components/index.d.ts.map +1 -1
  75. package/dist/types/src/components/transcription.stories.d.ts +3 -2
  76. package/dist/types/src/components/transcription.stories.d.ts.map +1 -1
  77. package/dist/types/src/hooks/index.d.ts +0 -1
  78. package/dist/types/src/hooks/index.d.ts.map +1 -1
  79. package/dist/types/src/index.d.ts +1 -0
  80. package/dist/types/src/index.d.ts.map +1 -1
  81. package/dist/types/src/meta.d.ts +2 -9
  82. package/dist/types/src/meta.d.ts.map +1 -1
  83. package/dist/types/src/transcriber/index.d.ts +1 -0
  84. package/dist/types/src/transcriber/index.d.ts.map +1 -1
  85. package/dist/types/src/transcriber/media-stream-recorder.d.ts +1 -0
  86. package/dist/types/src/transcriber/media-stream-recorder.d.ts.map +1 -1
  87. package/dist/types/src/transcriber/summarize.d.ts +5 -0
  88. package/dist/types/src/transcriber/summarize.d.ts.map +1 -0
  89. package/dist/types/src/transcription-manager.d.ts +39 -0
  90. package/dist/types/src/transcription-manager.d.ts.map +1 -0
  91. package/dist/types/src/translations.d.ts +5 -6
  92. package/dist/types/src/translations.d.ts.map +1 -1
  93. package/dist/types/src/types/schema.d.ts +9 -15
  94. package/dist/types/src/types/schema.d.ts.map +1 -1
  95. package/dist/types/src/types/types.d.ts +13 -6
  96. package/dist/types/src/types/types.d.ts.map +1 -1
  97. package/dist/types/src/util/random-queue-dxn.d.ts +2 -2
  98. package/dist/types/src/util/random-queue-dxn.d.ts.map +1 -1
  99. package/package.json +40 -37
  100. package/src/TranscriptionPlugin.tsx +1 -2
  101. package/src/capabilities/app-graph-builder.ts +25 -2
  102. package/src/capabilities/intent-resolver.ts +24 -2
  103. package/src/capabilities/react-surface.tsx +2 -2
  104. package/src/components/Transcript/Transcript.stories.tsx +46 -27
  105. package/src/components/Transcript/Transcript.tsx +205 -49
  106. package/src/components/TranscriptContainer.tsx +12 -147
  107. package/src/components/transcription.stories.tsx +11 -13
  108. package/src/hooks/index.ts +0 -1
  109. package/src/index.ts +1 -0
  110. package/src/meta.ts +2 -2
  111. package/src/transcriber/index.ts +1 -0
  112. package/src/transcriber/media-stream-recorder.ts +4 -0
  113. package/src/transcriber/summarize.ts +77 -0
  114. package/src/transcription-manager.ts +183 -0
  115. package/src/translations.ts +2 -2
  116. package/src/types/schema.ts +5 -3
  117. package/src/types/types.ts +12 -2
  118. package/src/util/random-queue-dxn.ts +2 -2
  119. package/dist/lib/browser/app-graph-builder-ZSSJIFY7.mjs +0 -7
  120. package/dist/lib/browser/app-graph-builder-ZSSJIFY7.mjs.map +0 -7
  121. package/dist/lib/browser/chunk-QIHUPVQD.mjs.map +0 -7
  122. package/dist/lib/browser/chunk-R4HHS6FA.mjs +0 -241
  123. package/dist/lib/browser/chunk-R4HHS6FA.mjs.map +0 -7
  124. package/dist/lib/browser/chunk-ZMES3U2D.mjs +0 -78
  125. package/dist/lib/browser/chunk-ZMES3U2D.mjs.map +0 -7
  126. package/dist/lib/browser/intent-resolver-EAR2TFK2.mjs +0 -32
  127. package/dist/lib/browser/intent-resolver-EAR2TFK2.mjs.map +0 -7
  128. package/dist/lib/node/app-graph-builder-YFBX3OTP.cjs.map +0 -7
  129. package/dist/lib/node/chunk-JBN3P6ZF.cjs +0 -97
  130. package/dist/lib/node/chunk-JBN3P6ZF.cjs.map +0 -7
  131. package/dist/lib/node/chunk-UIHEGU6Q.cjs.map +0 -7
  132. package/dist/lib/node/chunk-YV74Z74I.cjs +0 -267
  133. package/dist/lib/node/chunk-YV74Z74I.cjs.map +0 -7
  134. package/dist/lib/node/intent-resolver-3KJZGGUQ.cjs +0 -44
  135. package/dist/lib/node/intent-resolver-3KJZGGUQ.cjs.map +0 -7
  136. package/dist/lib/node-esm/app-graph-builder-77GQG6UF.mjs +0 -9
  137. package/dist/lib/node-esm/app-graph-builder-77GQG6UF.mjs.map +0 -7
  138. package/dist/lib/node-esm/chunk-36EQXBWR.mjs +0 -242
  139. package/dist/lib/node-esm/chunk-36EQXBWR.mjs.map +0 -7
  140. package/dist/lib/node-esm/chunk-KEG3ME4O.mjs.map +0 -7
  141. package/dist/lib/node-esm/chunk-XYU6B73V.mjs +0 -80
  142. package/dist/lib/node-esm/chunk-XYU6B73V.mjs.map +0 -7
  143. package/dist/lib/node-esm/intent-resolver-UGLYRD4X.mjs +0 -33
  144. package/dist/lib/node-esm/intent-resolver-UGLYRD4X.mjs.map +0 -7
  145. package/dist/types/src/hooks/useIsSpeaking.d.ts +0 -2
  146. package/dist/types/src/hooks/useIsSpeaking.d.ts.map +0 -1
  147. package/src/hooks/useIsSpeaking.ts +0 -77
  148. /package/dist/lib/browser/{TranscriptContainer-SX6PNFEC.mjs.map → TranscriptContainer-TJXP6ICP.mjs.map} +0 -0
  149. /package/dist/lib/browser/{chunk-AS364WZZ.mjs.map → chunk-3NWYXRZS.mjs.map} +0 -0
  150. /package/dist/lib/node/{chunk-QWXLQPP5.cjs.map → chunk-I6CUIAQH.cjs.map} +0 -0
  151. /package/dist/lib/node-esm/{TranscriptContainer-DVBXQIQ4.mjs.map → TranscriptContainer-MOWZQSEO.mjs.map} +0 -0
  152. /package/dist/lib/node-esm/{chunk-WEZGRUG6.mjs.map → chunk-NFPDIQYG.mjs.map} +0 -0
@@ -5,31 +5,58 @@
5
5
  import '@dxos-theme';
6
6
 
7
7
  import { type StoryObj, type Meta } from '@storybook/react';
8
- import React from 'react';
8
+ import React, { useEffect, useState } from 'react';
9
9
 
10
10
  import { faker } from '@dxos/random';
11
- import { ScrollContainer } from '@dxos/react-ui-components';
11
+ import { hues } from '@dxos/react-ui-theme';
12
12
  import { withTheme, withLayout } from '@dxos/storybook-utils';
13
13
 
14
14
  import { Transcript } from './Transcript';
15
15
  import translations from '../../translations';
16
16
 
17
+ let start = new Date(Date.now() - 24 * 60 * 60 * 10_000);
18
+ const next = () => {
19
+ start = new Date(start.getTime() + Math.random() * 10_000);
20
+ return start;
21
+ };
22
+
23
+ const users = Array.from({ length: 5 }, () => ({
24
+ authorName: faker.person.fullName(),
25
+ authorHue: faker.helpers.arrayElement(hues),
26
+ }));
27
+
28
+ const createBlock = () => {
29
+ const author = faker.helpers.arrayElement(users);
30
+ return {
31
+ id: faker.string.uuid(),
32
+ ...author,
33
+ segments: Array.from({ length: 1 + Math.floor(Math.random() * 2) }, () => ({
34
+ started: next(),
35
+ text: faker.lorem.paragraph(),
36
+ })),
37
+ };
38
+ };
39
+
17
40
  const meta: Meta<typeof Transcript> = {
18
41
  title: 'plugins/plugin-transcription/Transcript',
19
42
  component: Transcript,
20
- render: (args) => (
21
- <div className='flex w-[30rem] p-2'>
22
- <ScrollContainer>
23
- <Transcript {...args} />
24
- </ScrollContainer>
25
- </div>
26
- ),
43
+ render: ({ blocks: initialBlocks = [], ...args }) => {
44
+ const [blocks, setBlocks] = useState(initialBlocks);
45
+ useEffect(() => {
46
+ const i = setInterval(() => {
47
+ setBlocks((blocks) => [...blocks, createBlock()]);
48
+ }, 1_000);
49
+
50
+ return () => clearInterval(i);
51
+ }, []);
52
+
53
+ return <Transcript {...args} blocks={blocks} />;
54
+ },
27
55
  decorators: [
28
56
  withTheme,
29
57
  withLayout({
30
58
  tooltips: true,
31
59
  fullscreen: true,
32
- classNames: 'justify-center',
33
60
  }),
34
61
  ],
35
62
  parameters: {
@@ -41,25 +68,17 @@ export default meta;
41
68
 
42
69
  type Story = StoryObj<typeof Transcript>;
43
70
 
44
- let start = new Date();
45
- const next = () => {
46
- start = new Date(start.getTime() - Math.random() * 30_000);
47
- return start;
48
- };
49
-
50
- const names = Array.from({ length: 3 }, () => faker.person.fullName());
51
-
52
71
  export const Default: Story = {
53
72
  args: {
54
- blocks: Array.from({ length: 6 + Math.floor(Math.random()) * 15 }, () => ({
55
- id: faker.string.uuid(),
56
- author: faker.helpers.arrayElement(names),
57
- segments: Array.from({ length: 1 + Math.floor(Math.random() * 3) }, () => ({
58
- started: next(),
59
- text: faker.lorem.paragraph(),
60
- })),
61
- })).reverse(),
73
+ ignoreAttention: true,
74
+ attendableId: 'story',
75
+ blocks: Array.from({ length: 10 }, createBlock),
62
76
  },
63
77
  };
64
78
 
65
- export const Empty: Story = {};
79
+ export const Empty: Story = {
80
+ args: {
81
+ ignoreAttention: true,
82
+ attendableId: 'story',
83
+ },
84
+ };
@@ -2,68 +2,224 @@
2
2
  // Copyright 2023 DXOS.org
3
3
  //
4
4
 
5
- import { formatDistanceToNow } from 'date-fns/formatDistanceToNow';
6
- import React, { type FC } from 'react';
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';
7
9
 
8
- import { Icon, IconButton, useTranslation } from '@dxos/react-ui';
10
+ import { IconButton, 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';
9
21
 
10
22
  import { TRANSCRIPTION_PLUGIN } from '../../meta';
11
23
  import { type TranscriptBlock } from '../../types';
12
24
 
13
- // TODO(burdon): react-ui-list.
14
25
  // TODO(burdon): Actions (e.g., mark, summarize, translate, label, delete).
15
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
+
16
41
  export type TranscriptProps = {
17
42
  blocks?: TranscriptBlock[];
43
+ attendableId?: string;
44
+ ignoreAttention?: boolean;
45
+ };
46
+
47
+ const rowDefault = {
48
+ grid: { size: lineHeight + cellSpacing },
49
+ };
50
+
51
+ type QueueRows = [number, number][];
52
+
53
+ const mapTranscriptQueue = (blocks?: TranscriptBlock[]): QueueRows => {
54
+ if (!blocks || !Array.isArray(blocks) || blocks.length === 0) {
55
+ return [];
56
+ } else {
57
+ return blocks.flatMap((block, blockIndex) => {
58
+ return [
59
+ [blockIndex, -1],
60
+ ...block.segments.map((segment, segmentIndex) => {
61
+ return [blockIndex, segmentIndex] as [number, number];
62
+ }),
63
+ ];
64
+ });
65
+ }
66
+ };
67
+
68
+ const pad = (n = 0) => String(n).padStart(2, '0');
69
+
70
+ const formatTimestamp = (start: Date, end: Date) => {
71
+ const { hours, minutes, seconds } = intervalToDuration({ start, end });
72
+ return `${pad(hours)}:${pad(minutes)}:${pad(seconds)}`;
73
+ };
74
+
75
+ const measureRows = async (
76
+ host: HTMLDivElement,
77
+ blocks: TranscriptBlock[],
78
+ queueMap: QueueRows,
79
+ signal: AbortSignal,
80
+ ) => {
81
+ const result: DxGridAxisMeta = { grid: {} };
82
+ for (let row = 0; row < queueMap.length; row++) {
83
+ await yieldOrContinue('smooth', signal);
84
+ const [blockIndex, segmentIndex] = queueMap[row];
85
+ if (segmentIndex < 0) {
86
+ result.grid[row] = { size: lineHeight + cellSpacing };
87
+ } else {
88
+ host.textContent = blocks[blockIndex]!.segments[segmentIndex]!.text;
89
+ result.grid[row] = { size: host.offsetHeight };
90
+ }
91
+ }
92
+ return result;
18
93
  };
19
94
 
20
- export const Transcript: FC<TranscriptProps> = ({ blocks }) => {
95
+ export const Transcript: FC<TranscriptProps> = ({ blocks, attendableId, ignoreAttention }) => {
21
96
  const { t } = useTranslation(TRANSCRIPTION_PLUGIN);
22
- const hoverButton = 'p-1 min-bs-1 transition-opacity duration-250 opacity-10 group-hover:opacity-100';
97
+ const { hasAttention } = useAttention(attendableId);
98
+ const [dxGrid, setDxGrid] = useState<DxGridElement | null>(null);
99
+ const [rows, setRows] = useState<DxGridAxisMeta | undefined>(undefined);
100
+ const [columns, setColumns] = useState<DxGridAxisMeta | undefined>(undefined);
101
+ const [autoScroll, setAutoScroll] = useState(true);
102
+
103
+ const queueMap = useMemo(() => mapTranscriptQueue(blocks), [blocks]);
104
+
105
+ const handleWheel = useCallback(
106
+ (event: WheelEvent) => {
107
+ setAutoScroll(false);
108
+ if (!ignoreAttention && !hasAttention) {
109
+ event.stopPropagation();
110
+ }
111
+ },
112
+ [hasAttention, ignoreAttention],
113
+ );
114
+
115
+ const abortControllerRef = useRef<AbortController>();
116
+
117
+ const handleResize = useCallback(
118
+ async ({ entry }: { entry: { target: HTMLDivElement } | null }) => {
119
+ if (entry?.target && Array.isArray(blocks) && blocks[0] && queueMap) {
120
+ abortControllerRef.current?.abort();
121
+ abortControllerRef.current = new AbortController();
122
+ setColumns({ grid: { 0: { size: timestampColumnWidth }, 1: { size: entry.target.offsetWidth } } });
123
+ return measureRows(entry.target, blocks, queueMap, abortControllerRef.current.signal)
124
+ .then(setRows)
125
+ .catch(() => {
126
+ // Aborted mid-measurement by new size.
127
+ });
128
+ }
129
+ },
130
+ [blocks, queueMap],
131
+ );
132
+
133
+ const { width, ref: measureRef } = useResizeDetector({
134
+ onResize: handleResize as OnResizeCallback,
135
+ refreshOptions: { leading: true },
136
+ });
137
+
138
+ useEffect(() => {
139
+ if (queueMap.length !== Object.keys(rows?.grid ?? {}).length) {
140
+ void handleResize({ entry: { target: measureRef.current } }).then(() => {
141
+ if (autoScroll) {
142
+ // TODO(thure): Implement a deterministic way to do this when `rows` has fully settled and grid has a new `maxPosBlock`.
143
+ setTimeout(() => dxGrid?.scrollToEndRow(), 50);
144
+ }
145
+ });
146
+ }
147
+ }, [blocks, queueMap, width, autoScroll]);
148
+
149
+ useEffect(() => {
150
+ if (dxGrid && Array.isArray(blocks) && blocks[0]) {
151
+ const transcriptStart = blocks[0]!.segments[0]!.started;
152
+ dxGrid.getCells = (range, plane) => {
153
+ switch (plane) {
154
+ case 'grid': {
155
+ const cells: DxGridPlaneCells = {};
156
+ for (let row = range.start.row; row <= range.end.row && row < queueMap.length; row++) {
157
+ const [blockIndex, segmentIndex] = queueMap[row];
158
+
159
+ cells[toPlaneCellIndex({ col: 0, row })] = {
160
+ readonly: true,
161
+ value:
162
+ segmentIndex < 0
163
+ ? formatTimestamp(transcriptStart, blocks[blockIndex]!.segments[Math.max(0, segmentIndex)]!.started)
164
+ : '',
165
+ className: timestampClasses,
166
+ } satisfies DxGridCellValue;
167
+
168
+ cells[toPlaneCellIndex({ col: 1, row })] = (
169
+ segmentIndex < 0
170
+ ? {
171
+ readonly: true,
172
+ accessoryHtml: `<span data-hue="${blocks[blockIndex]!.authorHue}" class="dx-text-hue">${blocks[blockIndex]!.authorName}</span>`,
173
+ className: authorClasses,
174
+ }
175
+ : {
176
+ readonly: true,
177
+ value: blocks[blockIndex]!.segments[segmentIndex]!.text,
178
+ className: segmentTextClasses,
179
+ }
180
+ ) satisfies DxGridCellValue;
181
+ }
182
+ return cells;
183
+ }
184
+ default:
185
+ return {};
186
+ }
187
+ };
188
+ }
189
+ }, [dxGrid, blocks]);
190
+
191
+ const handleScrollToEnd = useCallback(() => {
192
+ setAutoScroll(true);
193
+ dxGrid?.scrollToEndRow();
194
+ }, [dxGrid, autoScroll]);
23
195
 
24
196
  return (
25
- <div role='list' className='flex flex-col grow gap-2 m-1'>
26
- {blocks?.map((block) => (
27
- <div
28
- role='listitem'
29
- key={block.id}
30
- className='flex flex-col py-1 border border-transparent hover:border-separator rounded'
31
- >
32
- <div className='group flex items-center px-2'>
33
- <Icon icon='ph--user--regular' />
34
- <div className='px-2 text-sm text-subdued'>{block.author}</div>
35
- <div className='grow' />
36
- <IconButton
37
- disabled
38
- icon='ph--x--regular'
39
- label={t('delete button')}
40
- iconOnly
41
- size={4}
42
- classNames={hoverButton}
43
- />
44
- </div>
45
-
46
- {block.segments.map((segment, i) => (
47
- <div key={i} className='group flex flex-col'>
48
- <div className='px-2'>{segment.text}</div>
49
- <div className='flex gap-1 items-center justify-between px-2'>
50
- <div className='grow' />
51
- <div className='truncate text-xs text-subdued'>
52
- {formatDistanceToNow(segment.started, { addSuffix: true })}
53
- </div>
54
- <IconButton
55
- disabled
56
- icon='ph--bookmark-simple--regular'
57
- label={t('bookmark button')}
58
- iconOnly
59
- size={4}
60
- classNames={hoverButton}
61
- />
62
- </div>
63
- </div>
64
- ))}
65
- </div>
66
- ))}
67
- </div>
197
+ <>
198
+ <Grid.Root id={`${attendableId}--transcript`}>
199
+ <Grid.Content
200
+ limitColumns={2}
201
+ limitRows={queueMap.length}
202
+ columns={columns}
203
+ rows={rows}
204
+ rowDefault={rowDefault}
205
+ onWheel={handleWheel}
206
+ 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'
207
+ ref={setDxGrid}
208
+ />
209
+ </Grid.Root>
210
+ <div role='none' {...{ inert: '' }} aria-hidden className={measureClasses} ref={measureRef} />
211
+ <IconButton
212
+ icon='ph--arrow-line-down--regular'
213
+ iconOnly
214
+ label={t('scroll to end label')}
215
+ tooltipSide='left'
216
+ data-state={autoScroll ? 'invisible' : 'visible'}
217
+ classNames={[
218
+ 'absolute inline-end-2 block-end-2 opacity-0 pointer-events-none',
219
+ 'data-[state="visible"]:pointer-events-auto data-[state="visible"]:opacity-100 transition-opacity',
220
+ ]}
221
+ onClick={handleScrollToEnd}
222
+ />
223
+ </>
68
224
  );
69
225
  };
@@ -2,166 +2,31 @@
2
2
  // Copyright 2024 DXOS.org
3
3
  //
4
4
 
5
- import { pipe } from 'effect';
6
- import React, { useCallback, useMemo, useState, type FC } from 'react';
5
+ import React, { type FC, Fragment } from 'react';
7
6
 
8
- import { chain, createIntent, LayoutAction, useIntentDispatcher } from '@dxos/app-framework';
9
- import { Message } from '@dxos/artifact';
10
- import { AIServiceClientImpl, DEFAULT_LLM_MODEL, MixedStreamParser, type AIServiceClient } from '@dxos/assistant';
11
- import { create, getSpace, makeRef } from '@dxos/client/echo';
12
- import { QueueImpl } from '@dxos/echo-db';
13
- import { createStatic, isInstanceOf } from '@dxos/echo-schema';
14
- import { invariant } from '@dxos/invariant';
7
+ import { fullyQualifiedId } from '@dxos/client/echo';
15
8
  import { DXN } from '@dxos/keys';
16
- import { log } from '@dxos/log';
17
- import { DocumentType } from '@dxos/plugin-markdown/types';
18
- import { CollectionType, SpaceAction } from '@dxos/plugin-space/types';
19
- import { useConfig } from '@dxos/react-client';
20
- import { useEdgeClient, useQueue, type EdgeHttpClient } from '@dxos/react-edge-client';
21
- import { IconButton, Toolbar, useTranslation } from '@dxos/react-ui';
22
- import { ScrollContainer } from '@dxos/react-ui-components';
9
+ import { useQueue } from '@dxos/react-client/echo';
23
10
  import { StackItem } from '@dxos/react-ui-stack';
24
- import { TextType } from '@dxos/schema';
25
11
 
26
12
  import { Transcript } from './Transcript';
27
- import { TRANSCRIPTION_PLUGIN } from '../meta';
28
- import { TranscriptBlock, type TranscriptType } from '../types';
13
+ import { type TranscriptBlock, type TranscriptType } from '../types';
29
14
 
30
- export const TranscriptionContainer: FC<{ transcript: TranscriptType }> = ({ transcript }) => {
31
- const { t } = useTranslation(TRANSCRIPTION_PLUGIN);
32
- const edge = useEdgeClient();
33
- const aiService = useAiServiceClient();
34
- const { dispatchPromise: dispatch } = useIntentDispatcher();
15
+ export const TranscriptionContainer: FC<{ transcript: TranscriptType; role: string }> = ({ transcript, role }) => {
16
+ const attendableId = fullyQualifiedId(transcript);
35
17
 
36
- const queue = useQueue<TranscriptBlock>(edge, transcript.queue ? DXN.parse(transcript.queue) : undefined, {
18
+ const queue = useQueue<TranscriptBlock>(transcript.queue ? DXN.parse(transcript.queue) : undefined, {
37
19
  pollInterval: 1_000,
38
20
  });
39
21
 
40
- // TODO(dmaretskyi): Pending state and errors should be handled by the framework!!!
41
- const [isSummarizing, setIsSummarizing] = useState(false);
42
- const handleSummarize = useCallback(async () => {
43
- setIsSummarizing(true);
44
- try {
45
- const document = await summarizeTranscript(edge, aiService, transcript);
46
- const space = getSpace(transcript);
47
- const target = space?.properties[CollectionType.typename]?.target;
48
- await dispatch(
49
- pipe(
50
- createIntent(SpaceAction.AddObject, { object: document, target }),
51
- chain(LayoutAction.Open, { part: 'main' }),
52
- ),
53
- );
54
- } finally {
55
- setIsSummarizing(false);
56
- }
57
- }, [transcript, edge]);
22
+ const Root = role === 'article' ? StackItem.Content : Fragment;
23
+ const rootProps = role === 'article' ? { toolbar: false } : {};
58
24
 
59
- // TODO(dmaretskyi): Move action to menu.
60
25
  return (
61
- <StackItem.Content toolbar={true}>
62
- <StackItem.Heading>
63
- <Toolbar.Root classNames='flex gap-2'>
64
- <IconButton
65
- icon='ph--subtitles--regular'
66
- iconOnly
67
- size={5}
68
- disabled={isSummarizing}
69
- onClick={handleSummarize}
70
- label={t('summary button')}
71
- />
72
- {isSummarizing && <div className='text-sm'>{t('summarizing label')}</div>}
73
- </Toolbar.Root>
74
- </StackItem.Heading>
75
- <ScrollContainer>
76
- <Transcript blocks={queue?.items} />
77
- </ScrollContainer>
78
- </StackItem.Content>
26
+ <Root {...(rootProps as any)}>
27
+ <Transcript blocks={queue?.items} attendableId={attendableId} />
28
+ </Root>
79
29
  );
80
30
  };
81
31
 
82
32
  export default TranscriptionContainer;
83
-
84
- // TODO(dmaretskyi): Extract to the new file once transcript refactoring has settled.
85
- const summarizeTranscript = async (
86
- edgeClient: EdgeHttpClient,
87
- aiService: AIServiceClient,
88
- transcript: TranscriptType,
89
- ) => {
90
- invariant(transcript.queue, 'No queue found for transcript');
91
-
92
- const queue = new QueueImpl(edgeClient, DXN.parse(transcript.queue));
93
- await queue.refresh();
94
- const content = queue.items
95
- .filter((block) => isInstanceOf(TranscriptBlock, block))
96
- .map((block) => `${block.author}: ${block.segments.map((segment) => segment.text).join('\n')}`)
97
- .join('\n\n');
98
-
99
- const parser = new MixedStreamParser();
100
-
101
- log.info('summarizing transcript', { blockCount: queue.items.length });
102
- const output = await parser.parse(
103
- await aiService.exec({
104
- model: DEFAULT_LLM_MODEL,
105
- systemPrompt: SUMMARIZE_PROMPT,
106
- history: [createStatic(Message, { role: 'user', content: [{ type: 'text', text: content }] })],
107
- }),
108
- );
109
-
110
- log.info('transcript summary', { output });
111
- invariant(output[0].content[0].type === 'text', 'Expected text content');
112
- const summary = output[0].content[0].text;
113
-
114
- // TODO(dmaretskyi): .started is missing.
115
- const name = `Summary ${(transcript.started ?? new Date()).toLocaleString('en-US', {
116
- year: 'numeric',
117
- month: 'long',
118
- day: 'numeric',
119
- hour: '2-digit',
120
- minute: '2-digit',
121
- })}`;
122
-
123
- return create(DocumentType, {
124
- name,
125
- content: makeRef(create(TextType, { content: summary })),
126
- threads: [],
127
- });
128
- };
129
-
130
- // TODO(dmaretskyi): Add example to set consistent structure for the summary.
131
- const SUMMARIZE_PROMPT = `
132
- # Goal
133
- Create a markdown summary of the transcript provided.
134
-
135
- # Formatting
136
- - Format the summary as a markdown document without extra comments like "Here is the summary of the transcript:".
137
- - Use markdown formatting for headings and bullet points.
138
- - Format the summary as a list of key points and takeaways.
139
- - All names of people should be in bold.
140
-
141
- # Note Taking
142
- - Correlate items in the summary with the person of origin to build a coherent narrative.
143
- - Include short quotes verbatim where appropriate. Especially when concerned with design decisions and problem descriptions.
144
-
145
- # Tasks
146
- At the end of the summary include tasks.
147
- Extract only the tasks that are:
148
- - Directly actionable
149
- - Clearly assigned to a person or team (or can easily be inferred)
150
- - Strongly implied by the conversation and/or user note (no speculative tasks)
151
- - Specific enough that someone reading them would know exactly what to do next
152
-
153
- Format all tasks as markdown checkboxes using the syntax:
154
- - [ ] Task description
155
-
156
- Additional information can be included (indented).
157
-
158
- If no actionable tasks are found, omit this tasks section.
159
- `;
160
-
161
- // TODO(dmaretskyi): Extract?
162
- // Also this conflicts with plugin automation providing the ai client via a capability. Need to reconcile.
163
- const useAiServiceClient = (): AIServiceClient => {
164
- const config = useConfig();
165
- const endpoint = config.values.runtime?.services?.ai?.server ?? 'http://localhost:8788'; // TOOD(burdon): Standardize consts.
166
- return useMemo(() => new AIServiceClientImpl({ endpoint }), [endpoint]);
167
- };
@@ -4,7 +4,7 @@
4
4
 
5
5
  import '@dxos-theme';
6
6
 
7
- import { type StoryObj, type Meta } from '@storybook/react';
7
+ import { type Meta, type StoryObj } from '@storybook/react';
8
8
  import React, {
9
9
  type Dispatch,
10
10
  type FC,
@@ -20,19 +20,19 @@ import { createStatic } from '@dxos/echo-schema';
20
20
  import { type DXN } from '@dxos/keys';
21
21
  import { log } from '@dxos/log';
22
22
  import { Config } from '@dxos/react-client';
23
+ import { useQueue } from '@dxos/react-client/echo';
23
24
  import { withClientProvider } from '@dxos/react-client/testing';
24
- import { useEdgeClient, useQueue } from '@dxos/react-edge-client';
25
25
  import { IconButton, Toolbar } from '@dxos/react-ui';
26
26
  import { ScrollContainer } from '@dxos/react-ui-components';
27
- import { withTheme, withLayout } from '@dxos/storybook-utils';
27
+ import { withLayout, withTheme } from '@dxos/storybook-utils';
28
28
 
29
29
  import { Transcript } from './Transcript';
30
- import { useTranscriber, useAudioFile, useAudioTrack } from '../hooks';
30
+ import { useAudioFile, useAudioTrack, useTranscriber } from '../hooks';
31
31
  import { type TranscriberParams } from '../transcriber';
32
32
  import { TranscriptBlock } from '../types';
33
33
  import { randomQueueDxn } from '../util';
34
34
 
35
- const UX: FC<{
35
+ const TranscriptionStory: FC<{
36
36
  playing: boolean;
37
37
  setPlaying: Dispatch<SetStateAction<boolean>>;
38
38
  blocks?: TranscriptBlock[];
@@ -48,7 +48,7 @@ const UX: FC<{
48
48
  />
49
49
  </Toolbar.Root>
50
50
  <ScrollContainer>
51
- <Transcript blocks={blocks} />
51
+ <Transcript blocks={blocks} attendableId='story' />
52
52
  </ScrollContainer>
53
53
  </div>
54
54
  );
@@ -62,8 +62,7 @@ const Microphone = () => {
62
62
 
63
63
  // Queue.
64
64
  const queueDxn = useMemo(() => randomQueueDxn(), []);
65
- const echoClient = useEdgeClient();
66
- const queue = useQueue<TranscriptBlock>(echoClient, queueDxn, { pollInterval: 500 });
65
+ const queue = useQueue<TranscriptBlock>(queueDxn, { pollInterval: 500 });
67
66
 
68
67
  // Transcriber.
69
68
  const handleSegments = useCallback<TranscriberParams['onSegments']>(
@@ -90,7 +89,7 @@ const Microphone = () => {
90
89
  }
91
90
  }, [transcriber, playing, transcriber?.isOpen]);
92
91
 
93
- return <UX playing={playing} setPlaying={setPlaying} blocks={queue?.items} />;
92
+ return <TranscriptionStory playing={playing} setPlaying={setPlaying} blocks={queue?.items} />;
94
93
  };
95
94
 
96
95
  const AudioFile = ({ queueDxn, audioUrl }: { queueDxn: DXN; audioUrl: string; transcriptUrl: string }) => {
@@ -119,11 +118,10 @@ const AudioFile = ({ queueDxn, audioUrl }: { queueDxn: DXN; audioUrl: string; tr
119
118
  }, [audio, playing]);
120
119
 
121
120
  // Transcriber.
122
- const echoClient = useEdgeClient();
123
- const queue = useQueue<TranscriptBlock>(echoClient, queueDxn, { pollInterval: 500 });
121
+ const queue = useQueue<TranscriptBlock>(queueDxn, { pollInterval: 500 });
124
122
  const handleSegments = useCallback<TranscriberParams['onSegments']>(
125
123
  async (segments) => {
126
- const block = createStatic(TranscriptBlock, { author: 'test', segments });
124
+ const block = createStatic(TranscriptBlock, { authorName: 'test', authorHue: 'cyan', segments });
127
125
  queue?.append([block]);
128
126
  },
129
127
  [queue],
@@ -153,7 +151,7 @@ const AudioFile = ({ queueDxn, audioUrl }: { queueDxn: DXN; audioUrl: string; tr
153
151
  }
154
152
  }, [transcriber, track?.readyState, transcriber?.isOpen]);
155
153
 
156
- return <UX playing={playing} setPlaying={setPlaying} blocks={queue?.items} />;
154
+ return <TranscriptionStory playing={playing} setPlaying={setPlaying} blocks={queue?.items} />;
157
155
  };
158
156
 
159
157
  const meta: Meta<typeof AudioFile> = {
@@ -4,6 +4,5 @@
4
4
 
5
5
  export * from './useAudioFile';
6
6
  export * from './useAudioTrack';
7
- export * from './useIsSpeaking';
8
7
  export * from './useTranscriber';
9
8
  export * from './useVoiceInput';
package/src/index.ts CHANGED
@@ -7,5 +7,6 @@ export * from './components';
7
7
  export * from './hooks';
8
8
  export * from './meta';
9
9
  export * from './transcriber';
10
+ export * from './transcription-manager';
10
11
 
11
12
  export * from './TranscriptionPlugin';
package/src/meta.ts CHANGED
@@ -6,7 +6,7 @@ import { type PluginMeta } from '@dxos/app-framework';
6
6
 
7
7
  export const TRANSCRIPTION_PLUGIN = 'dxos.org/plugin/transcription';
8
8
 
9
- export const meta = {
9
+ export const meta: PluginMeta = {
10
10
  id: TRANSCRIPTION_PLUGIN,
11
11
  name: 'Transcription',
12
12
  description: 'Voice transcription.',
@@ -14,4 +14,4 @@ export const meta = {
14
14
  source: 'https://github.com/dxos/dxos/tree/main/packages/plugins/experimental/plugin-transcription',
15
15
  tags: ['experimental'],
16
16
  screenshots: [],
17
- } satisfies PluginMeta;
17
+ };
@@ -4,4 +4,5 @@
4
4
 
5
5
  export * from './audio-recorder';
6
6
  export * from './media-stream-recorder';
7
+ export * from './summarize';
7
8
  export * from './transcriber';