scribe-widget 1.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.
- package/README.md +230 -0
- package/dist/scribe-widget.es.js +64563 -0
- package/dist/scribe-widget.umd.js +80 -0
- package/index.html +205 -0
- package/package.json +26 -0
- package/src/App.tsx +114 -0
- package/src/components/ConfigState.tsx +68 -0
- package/src/components/ErrorState.tsx +20 -0
- package/src/components/FloatingPanel.tsx +83 -0
- package/src/components/Icons.tsx +43 -0
- package/src/components/IdleState.tsx +24 -0
- package/src/components/PermissionState.tsx +22 -0
- package/src/components/ProcessingState.tsx +8 -0
- package/src/components/RecordingState.tsx +48 -0
- package/src/components/ResultsState.tsx +27 -0
- package/src/hooks/useScribeSession.ts +227 -0
- package/src/index.tsx +123 -0
- package/src/styles/widget.css +495 -0
- package/src/types.ts +14 -0
- package/src/vite-env.d.ts +6 -0
- package/tsconfig.json +21 -0
- package/vite.config.ts +27 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { GetSessionStatusResponse } from 'med-scribe-alliance-ts-sdk';
|
|
2
|
+
|
|
3
|
+
interface ResultsStateProps {
|
|
4
|
+
result: GetSessionStatusResponse;
|
|
5
|
+
onNewRecording: () => void;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function ResultsState({ result, onNewRecording }: ResultsStateProps) {
|
|
9
|
+
return (
|
|
10
|
+
<div className="results-state">
|
|
11
|
+
<div className="results-header">
|
|
12
|
+
<span className="results-title">Recording Complete</span>
|
|
13
|
+
<button className="new-recording-btn" onClick={onNewRecording}>
|
|
14
|
+
New Recording
|
|
15
|
+
</button>
|
|
16
|
+
</div>
|
|
17
|
+
<div className="results-content">
|
|
18
|
+
<div className="transcript-section">
|
|
19
|
+
<div className="section-title">Transcript</div>
|
|
20
|
+
<div className="transcript-text">
|
|
21
|
+
{result.transcript || 'No transcript available.'}
|
|
22
|
+
</div>
|
|
23
|
+
</div>
|
|
24
|
+
</div>
|
|
25
|
+
</div>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { useState, useRef, useCallback, useEffect } from 'react';
|
|
2
|
+
import { ScribeClient, GetSessionStatusResponse } from 'med-scribe-alliance-ts-sdk';
|
|
3
|
+
import { WidgetState, ScribeWidgetConfig } from '../types';
|
|
4
|
+
|
|
5
|
+
interface UseScribeSessionReturn {
|
|
6
|
+
state: WidgetState;
|
|
7
|
+
elapsedTime: number;
|
|
8
|
+
result: GetSessionStatusResponse | null;
|
|
9
|
+
errorMessage: string;
|
|
10
|
+
startRecording: () => Promise<void>;
|
|
11
|
+
pauseRecording: () => void;
|
|
12
|
+
resumeRecording: () => void;
|
|
13
|
+
stopRecording: () => Promise<void>;
|
|
14
|
+
reset: () => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function useScribeSession(config: ScribeWidgetConfig): UseScribeSessionReturn {
|
|
18
|
+
const [state, setState] = useState<WidgetState>('idle');
|
|
19
|
+
const [elapsedTime, setElapsedTime] = useState(0);
|
|
20
|
+
const [result, setResult] = useState<GetSessionStatusResponse | null>(null);
|
|
21
|
+
const [errorMessage, setErrorMessage] = useState('');
|
|
22
|
+
|
|
23
|
+
const clientRef = useRef<ScribeClient | null>(null);
|
|
24
|
+
const timerRef = useRef<number | null>(null);
|
|
25
|
+
const startTimeRef = useRef<number>(0);
|
|
26
|
+
const pausedTimeRef = useRef<number>(0);
|
|
27
|
+
|
|
28
|
+
const log = useCallback((...args: unknown[]) => {
|
|
29
|
+
if (config.debug) {
|
|
30
|
+
console.log('[EkaScribe]', ...args);
|
|
31
|
+
}
|
|
32
|
+
}, [config.debug]);
|
|
33
|
+
|
|
34
|
+
// Initialize SDK client only when baseUrl is provided
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
if (!config.baseUrl) {
|
|
37
|
+
log('Skipping SDK init - no baseUrl provided');
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const initClient = async () => {
|
|
42
|
+
try {
|
|
43
|
+
clientRef.current = new ScribeClient({
|
|
44
|
+
apiKey: config.apiKey,
|
|
45
|
+
baseUrl: config.baseUrl,
|
|
46
|
+
debug: config.debug,
|
|
47
|
+
});
|
|
48
|
+
await clientRef.current.init();
|
|
49
|
+
log('SDK initialized');
|
|
50
|
+
} catch (error) {
|
|
51
|
+
log('Failed to initialize SDK', error);
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
initClient();
|
|
56
|
+
|
|
57
|
+
return () => {
|
|
58
|
+
if (timerRef.current) {
|
|
59
|
+
clearInterval(timerRef.current);
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
}, [config.apiKey, config.baseUrl, config.debug, log]);
|
|
63
|
+
|
|
64
|
+
const startTimer = useCallback(() => {
|
|
65
|
+
timerRef.current = window.setInterval(() => {
|
|
66
|
+
const elapsed = Math.floor((Date.now() - startTimeRef.current) / 1000);
|
|
67
|
+
setElapsedTime(elapsed);
|
|
68
|
+
}, 1000);
|
|
69
|
+
}, []);
|
|
70
|
+
|
|
71
|
+
const stopTimer = useCallback(() => {
|
|
72
|
+
if (timerRef.current) {
|
|
73
|
+
clearInterval(timerRef.current);
|
|
74
|
+
timerRef.current = null;
|
|
75
|
+
}
|
|
76
|
+
}, []);
|
|
77
|
+
|
|
78
|
+
const checkMicrophonePermission = async (): Promise<PermissionState> => {
|
|
79
|
+
try {
|
|
80
|
+
const result = await navigator.permissions.query({ name: 'microphone' as PermissionName });
|
|
81
|
+
return result.state;
|
|
82
|
+
} catch {
|
|
83
|
+
return 'prompt';
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const requestMicrophonePermission = async (): Promise<boolean> => {
|
|
88
|
+
try {
|
|
89
|
+
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
90
|
+
stream.getTracks().forEach(track => track.stop());
|
|
91
|
+
return true;
|
|
92
|
+
} catch (error) {
|
|
93
|
+
log('Microphone permission denied', error);
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const showError = useCallback((message: string) => {
|
|
99
|
+
setErrorMessage(message);
|
|
100
|
+
setState('error');
|
|
101
|
+
}, []);
|
|
102
|
+
|
|
103
|
+
const startRecording = useCallback(async () => {
|
|
104
|
+
const permission = await checkMicrophonePermission();
|
|
105
|
+
|
|
106
|
+
if (permission === 'denied') {
|
|
107
|
+
showError('Microphone access is blocked. Please enable it in your browser settings.');
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (permission === 'prompt') {
|
|
112
|
+
setState('permission');
|
|
113
|
+
const granted = await requestMicrophonePermission();
|
|
114
|
+
if (!granted) {
|
|
115
|
+
showError('Microphone access is required to record.');
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (!clientRef.current) {
|
|
121
|
+
showError('SDK not initialized. Please try again.');
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
try {
|
|
126
|
+
setState('recording');
|
|
127
|
+
|
|
128
|
+
await clientRef.current.startRecording({
|
|
129
|
+
templates: config.templates || ['soap'],
|
|
130
|
+
languageHint: config.languageHint,
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
startTimeRef.current = Date.now();
|
|
134
|
+
pausedTimeRef.current = 0;
|
|
135
|
+
setElapsedTime(0);
|
|
136
|
+
startTimer();
|
|
137
|
+
|
|
138
|
+
log('Recording started');
|
|
139
|
+
} catch (error) {
|
|
140
|
+
log('Failed to start recording', error);
|
|
141
|
+
showError('Failed to start recording. Please try again.');
|
|
142
|
+
}
|
|
143
|
+
}, [config.templates, config.languageHint, log, showError, startTimer]);
|
|
144
|
+
|
|
145
|
+
const pauseRecording = useCallback(() => {
|
|
146
|
+
if (!clientRef.current) return;
|
|
147
|
+
|
|
148
|
+
clientRef.current.pauseRecording();
|
|
149
|
+
setState('paused');
|
|
150
|
+
pausedTimeRef.current = Date.now();
|
|
151
|
+
stopTimer();
|
|
152
|
+
|
|
153
|
+
log('Recording paused');
|
|
154
|
+
}, [log, stopTimer]);
|
|
155
|
+
|
|
156
|
+
const resumeRecording = useCallback(() => {
|
|
157
|
+
if (!clientRef.current) return;
|
|
158
|
+
|
|
159
|
+
clientRef.current.resumeRecording();
|
|
160
|
+
setState('recording');
|
|
161
|
+
|
|
162
|
+
if (pausedTimeRef.current) {
|
|
163
|
+
startTimeRef.current += Date.now() - pausedTimeRef.current;
|
|
164
|
+
pausedTimeRef.current = 0;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
startTimer();
|
|
168
|
+
|
|
169
|
+
log('Recording resumed');
|
|
170
|
+
}, [log, startTimer]);
|
|
171
|
+
|
|
172
|
+
const stopRecording = useCallback(async () => {
|
|
173
|
+
if (!clientRef.current) return;
|
|
174
|
+
|
|
175
|
+
try {
|
|
176
|
+
stopTimer();
|
|
177
|
+
setState('processing');
|
|
178
|
+
|
|
179
|
+
log('Stopping recording...');
|
|
180
|
+
const endResponse = await clientRef.current.endRecording();
|
|
181
|
+
log('Recording ended', endResponse);
|
|
182
|
+
|
|
183
|
+
log('Polling for completion...');
|
|
184
|
+
const finalResult = await clientRef.current.pollForCompletion(undefined, {
|
|
185
|
+
maxAttempts: 60,
|
|
186
|
+
intervalMs: 2000,
|
|
187
|
+
onProgress: (status) => {
|
|
188
|
+
log('Status update:', status.status);
|
|
189
|
+
},
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
setResult(finalResult);
|
|
193
|
+
log('Final result:', finalResult);
|
|
194
|
+
|
|
195
|
+
setState('results');
|
|
196
|
+
|
|
197
|
+
if (config.onResult) {
|
|
198
|
+
config.onResult(finalResult);
|
|
199
|
+
}
|
|
200
|
+
} catch (error) {
|
|
201
|
+
log('Failed to stop recording', error);
|
|
202
|
+
showError('Failed to process recording. Please try again.');
|
|
203
|
+
if (config.onError && error instanceof Error) {
|
|
204
|
+
config.onError(error);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}, [config, log, showError, stopTimer]);
|
|
208
|
+
|
|
209
|
+
const reset = useCallback(() => {
|
|
210
|
+
setResult(null);
|
|
211
|
+
setErrorMessage('');
|
|
212
|
+
setElapsedTime(0);
|
|
213
|
+
setState('idle');
|
|
214
|
+
}, []);
|
|
215
|
+
|
|
216
|
+
return {
|
|
217
|
+
state,
|
|
218
|
+
elapsedTime,
|
|
219
|
+
result,
|
|
220
|
+
errorMessage,
|
|
221
|
+
startRecording,
|
|
222
|
+
pauseRecording,
|
|
223
|
+
resumeRecording,
|
|
224
|
+
stopRecording,
|
|
225
|
+
reset,
|
|
226
|
+
};
|
|
227
|
+
}
|
package/src/index.tsx
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { createRoot, Root } from 'react-dom/client';
|
|
2
|
+
import { App } from './App';
|
|
3
|
+
import { ScribeWidgetConfig } from './types';
|
|
4
|
+
import widgetStyles from './styles/widget.css?inline';
|
|
5
|
+
|
|
6
|
+
export type { ScribeWidgetConfig, WidgetState } from './types';
|
|
7
|
+
|
|
8
|
+
class ScribeWidget {
|
|
9
|
+
private container: HTMLDivElement;
|
|
10
|
+
private shadowRoot: ShadowRoot;
|
|
11
|
+
private root: Root | null = null;
|
|
12
|
+
private config: ScribeWidgetConfig;
|
|
13
|
+
private visible: boolean = true;
|
|
14
|
+
|
|
15
|
+
constructor(config: ScribeWidgetConfig) {
|
|
16
|
+
this.config = {
|
|
17
|
+
templates: ['soap'],
|
|
18
|
+
languageHint: ['en'],
|
|
19
|
+
position: { bottom: 20, right: 20 },
|
|
20
|
+
...config,
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
// Create container element
|
|
24
|
+
this.container = document.createElement('div');
|
|
25
|
+
this.container.id = 'eka-scribe-widget';
|
|
26
|
+
|
|
27
|
+
// Attach shadow DOM
|
|
28
|
+
this.shadowRoot = this.container.attachShadow({ mode: 'closed' });
|
|
29
|
+
|
|
30
|
+
// Inject styles
|
|
31
|
+
const styleEl = document.createElement('style');
|
|
32
|
+
styleEl.textContent = widgetStyles;
|
|
33
|
+
this.shadowRoot.appendChild(styleEl);
|
|
34
|
+
|
|
35
|
+
// Create React mount point
|
|
36
|
+
const mountPoint = document.createElement('div');
|
|
37
|
+
mountPoint.id = 'eka-scribe-root';
|
|
38
|
+
this.shadowRoot.appendChild(mountPoint);
|
|
39
|
+
|
|
40
|
+
// Create React root
|
|
41
|
+
this.root = createRoot(mountPoint);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
private render(): void {
|
|
45
|
+
if (!this.root || !this.visible) return;
|
|
46
|
+
|
|
47
|
+
this.root.render(
|
|
48
|
+
<App
|
|
49
|
+
config={this.config}
|
|
50
|
+
onClose={() => this.hide()}
|
|
51
|
+
/>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
public mount(target?: HTMLElement | string): void {
|
|
56
|
+
const targetEl = typeof target === 'string'
|
|
57
|
+
? document.querySelector(target)
|
|
58
|
+
: target || document.body;
|
|
59
|
+
|
|
60
|
+
if (targetEl) {
|
|
61
|
+
targetEl.appendChild(this.container);
|
|
62
|
+
this.render();
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
public unmount(): void {
|
|
67
|
+
if (this.root) {
|
|
68
|
+
this.root.unmount();
|
|
69
|
+
this.root = null;
|
|
70
|
+
}
|
|
71
|
+
this.container.remove();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
public show(): void {
|
|
75
|
+
this.visible = true;
|
|
76
|
+
this.container.style.display = 'block';
|
|
77
|
+
this.render();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
public hide(): void {
|
|
81
|
+
this.visible = false;
|
|
82
|
+
this.container.style.display = 'none';
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
public isVisible(): boolean {
|
|
86
|
+
return this.visible;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Global initialization function for script tag usage
|
|
91
|
+
let widgetInstance: ScribeWidget | null = null;
|
|
92
|
+
|
|
93
|
+
function initEkaScribe(config: ScribeWidgetConfig): ScribeWidget {
|
|
94
|
+
if (widgetInstance) {
|
|
95
|
+
widgetInstance.unmount();
|
|
96
|
+
}
|
|
97
|
+
widgetInstance = new ScribeWidget(config);
|
|
98
|
+
widgetInstance.mount();
|
|
99
|
+
return widgetInstance;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function getEkaScribe(): ScribeWidget | null {
|
|
103
|
+
return widgetInstance;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Expose to window for script tag usage
|
|
107
|
+
if (typeof window !== 'undefined') {
|
|
108
|
+
(window as Window & { EkaScribe?: unknown }).EkaScribe = {
|
|
109
|
+
init: initEkaScribe,
|
|
110
|
+
getInstance: getEkaScribe,
|
|
111
|
+
Widget: ScribeWidget,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Named exports for ES module usage
|
|
116
|
+
export { ScribeWidget, initEkaScribe as init, getEkaScribe as getInstance };
|
|
117
|
+
|
|
118
|
+
// Default export for module usage
|
|
119
|
+
export default {
|
|
120
|
+
init: initEkaScribe,
|
|
121
|
+
getInstance: getEkaScribe,
|
|
122
|
+
Widget: ScribeWidget,
|
|
123
|
+
};
|