@ephia/dova-sdk 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.
Files changed (247) hide show
  1. package/README.md +89 -0
  2. package/dist/EphiaBinding-BvRmlqqC.d.ts +36 -0
  3. package/dist/EphiaFloatingButton-CxiF86VW.d.ts +65 -0
  4. package/dist/EphiaTextarea-B4_CAVUg.d.ts +183 -0
  5. package/dist/NativeBinding-ChG0GeSz.d.ts +53 -0
  6. package/dist/TargetBinding-BKGQwUMc.d.ts +89 -0
  7. package/dist/TiptapBinding-B-agfV2H.d.ts +45 -0
  8. package/dist/Transport-zdeA4Pou.d.ts +63 -0
  9. package/dist/audio-state-kZ3KSvux.d.ts +39 -0
  10. package/dist/chunk-35AJK2IO.js +1 -0
  11. package/dist/chunk-35AJK2IO.js.map +1 -0
  12. package/dist/chunk-3LXZODL4.js +886 -0
  13. package/dist/chunk-3LXZODL4.js.map +1 -0
  14. package/dist/chunk-5IK5TLSK.js +67 -0
  15. package/dist/chunk-5IK5TLSK.js.map +1 -0
  16. package/dist/chunk-7E43RY75.js +9 -0
  17. package/dist/chunk-7E43RY75.js.map +1 -0
  18. package/dist/chunk-A5UEXJ5R.js +183 -0
  19. package/dist/chunk-A5UEXJ5R.js.map +1 -0
  20. package/dist/chunk-AEE554FT.js +51 -0
  21. package/dist/chunk-AEE554FT.js.map +1 -0
  22. package/dist/chunk-DIEWY3IT.js +1332 -0
  23. package/dist/chunk-DIEWY3IT.js.map +1 -0
  24. package/dist/chunk-EGIAN7FH.js +18 -0
  25. package/dist/chunk-EGIAN7FH.js.map +1 -0
  26. package/dist/chunk-EMOEAPVU.js +486 -0
  27. package/dist/chunk-EMOEAPVU.js.map +1 -0
  28. package/dist/chunk-IDC7FHIZ.js +40 -0
  29. package/dist/chunk-IDC7FHIZ.js.map +1 -0
  30. package/dist/chunk-ITJFN3VM.js +601 -0
  31. package/dist/chunk-ITJFN3VM.js.map +1 -0
  32. package/dist/chunk-K24GNU27.js +22 -0
  33. package/dist/chunk-K24GNU27.js.map +1 -0
  34. package/dist/chunk-LXMCRXXF.js +778 -0
  35. package/dist/chunk-LXMCRXXF.js.map +1 -0
  36. package/dist/chunk-MJCEOOLW.js +122 -0
  37. package/dist/chunk-MJCEOOLW.js.map +1 -0
  38. package/dist/chunk-N7U5M3VZ.js +33 -0
  39. package/dist/chunk-N7U5M3VZ.js.map +1 -0
  40. package/dist/chunk-PSYX674B.js +27 -0
  41. package/dist/chunk-PSYX674B.js.map +1 -0
  42. package/dist/chunk-RFQRV7ML.js +33 -0
  43. package/dist/chunk-RFQRV7ML.js.map +1 -0
  44. package/dist/chunk-THNHRV2B.js +18 -0
  45. package/dist/chunk-THNHRV2B.js.map +1 -0
  46. package/dist/chunk-VSLGR64U.js +62 -0
  47. package/dist/chunk-VSLGR64U.js.map +1 -0
  48. package/dist/chunk-W2ZP674X.js +346 -0
  49. package/dist/chunk-W2ZP674X.js.map +1 -0
  50. package/dist/chunk-YWZUMUYE.js +695 -0
  51. package/dist/chunk-YWZUMUYE.js.map +1 -0
  52. package/dist/client-options-Uo6jXO8k.d.ts +64 -0
  53. package/dist/connection-state-Bk33YprE.d.ts +32 -0
  54. package/dist/core/bindings/index.d.ts +24 -0
  55. package/dist/core/bindings/index.js +1025 -0
  56. package/dist/core/bindings/index.js.map +1 -0
  57. package/dist/core/index.d.ts +383 -0
  58. package/dist/core/index.js +1284 -0
  59. package/dist/core/index.js.map +1 -0
  60. package/dist/createEphiaClient-BhdZ183V.d.ts +69 -0
  61. package/dist/devices/speechmike/index.d.ts +148 -0
  62. package/dist/devices/speechmike/index.js +40 -0
  63. package/dist/devices/speechmike/index.js.map +1 -0
  64. package/dist/headless/index.d.ts +10 -0
  65. package/dist/headless/index.js +25 -0
  66. package/dist/headless/index.js.map +1 -0
  67. package/dist/index.d.ts +18 -0
  68. package/dist/index.js +119 -0
  69. package/dist/index.js.map +1 -0
  70. package/dist/react/index.d.ts +38 -0
  71. package/dist/react/index.js +70 -0
  72. package/dist/react/index.js.map +1 -0
  73. package/dist/rich-editor/index.d.ts +46 -0
  74. package/dist/rich-editor/index.js +13 -0
  75. package/dist/rich-editor/index.js.map +1 -0
  76. package/dist/schema-B2ycPlNB.d.ts +87 -0
  77. package/dist/session-APaXR48R.d.ts +12 -0
  78. package/dist/shared/index.d.ts +16 -0
  79. package/dist/shared/index.js +30 -0
  80. package/dist/shared/index.js.map +1 -0
  81. package/dist/style.css +1093 -0
  82. package/dist/testing/index.d.ts +84 -0
  83. package/dist/testing/index.js +36 -0
  84. package/dist/testing/index.js.map +1 -0
  85. package/dist/types-D5SXPSwR.d.ts +32 -0
  86. package/dist/ui/index.d.ts +30 -0
  87. package/dist/ui/index.js +34 -0
  88. package/dist/ui/index.js.map +1 -0
  89. package/dist/useEphiaSpeechMike-CjD7DWnh.d.ts +64 -0
  90. package/package.json +110 -0
  91. package/src/core/audio/audio-worklet-source.ts +30 -0
  92. package/src/core/audio/index.ts +3 -0
  93. package/src/core/audio/voice-level-meter.test.ts +27 -0
  94. package/src/core/audio/voice-level-meter.ts +270 -0
  95. package/src/core/bindings/EphiaBinding.ts +41 -0
  96. package/src/core/bindings/SegmentBindingBridge.test.ts +422 -0
  97. package/src/core/bindings/SegmentBindingBridge.ts +377 -0
  98. package/src/core/bindings/TargetBinding.ts +142 -0
  99. package/src/core/bindings/adapters/NativeAdapter.test.ts +85 -0
  100. package/src/core/bindings/adapters/NativeAdapter.ts +216 -0
  101. package/src/core/bindings/adapters/ProseMirrorAdapter.ts +231 -0
  102. package/src/core/bindings/adapters/index.ts +2 -0
  103. package/src/core/bindings/binding-factory.ts +78 -0
  104. package/src/core/bindings/detect-editor-type.ts +87 -0
  105. package/src/core/bindings/index.ts +13 -0
  106. package/src/core/bindings/insertion-boundary.test.ts +38 -0
  107. package/src/core/bindings/insertion-boundary.ts +26 -0
  108. package/src/core/bindings/native/NativeBinding.test.ts +277 -0
  109. package/src/core/bindings/native/NativeBinding.ts +239 -0
  110. package/src/core/bindings/resolver.ts +18 -0
  111. package/src/core/bindings/targets/codemirror.binding.ts +293 -0
  112. package/src/core/bindings/targets/contenteditable.binding.ts +452 -0
  113. package/src/core/bindings/targets/index.ts +10 -0
  114. package/src/core/bindings/targets/monaco.binding.ts +315 -0
  115. package/src/core/bindings/targets/tiptap.binding.test.ts +417 -0
  116. package/src/core/bindings/targets/tiptap.binding.ts +1192 -0
  117. package/src/core/bindings/tiptap/TiptapBinding.test.ts +63 -0
  118. package/src/core/bindings/tiptap/TiptapBinding.ts +464 -0
  119. package/src/core/bindings/types.ts +41 -0
  120. package/src/core/client/EphiaAudioClient.ts +654 -0
  121. package/src/core/client/audio-capture.ts +263 -0
  122. package/src/core/client/client-options.ts +39 -0
  123. package/src/core/client/client-state.ts +18 -0
  124. package/src/core/client/constants.ts +23 -0
  125. package/src/core/client/session-api.ts +415 -0
  126. package/src/core/connection/connection-state.ts +78 -0
  127. package/src/core/connection/index.ts +6 -0
  128. package/src/core/index.ts +47 -0
  129. package/src/core/operations/textToDocumentOperations.test.ts +69 -0
  130. package/src/core/operations/textToDocumentOperations.ts +92 -0
  131. package/src/core/runtime/DictationRuntime.test.ts +578 -0
  132. package/src/core/runtime/DictationRuntime.ts +434 -0
  133. package/src/core/runtime/TranscriptApplier.test.ts +355 -0
  134. package/src/core/runtime/TranscriptApplier.ts +229 -0
  135. package/src/core/runtime/index.ts +18 -0
  136. package/src/core/session/index.ts +2 -0
  137. package/src/core/session/session-machine.test.ts +16 -0
  138. package/src/core/session/session-machine.ts +59 -0
  139. package/src/core/targets/EditorContextCollector.ts +71 -0
  140. package/src/core/targets/TargetManager.test.ts +194 -0
  141. package/src/core/targets/TargetManager.ts +194 -0
  142. package/src/core/targets/index.ts +10 -0
  143. package/src/core/text-processing/index.ts +11 -0
  144. package/src/core/text-processing/overlap.test.ts +35 -0
  145. package/src/core/text-processing/overlap.ts +101 -0
  146. package/src/core/text-processing/voice-formatting.normalizer.test.ts +132 -0
  147. package/src/core/text-processing/voice-formatting.normalizer.ts +284 -0
  148. package/src/core/transcript/client-transcript.reducer.ts +366 -0
  149. package/src/core/transcript/client-transcript.state.ts +25 -0
  150. package/src/core/transcript/index.ts +19 -0
  151. package/src/core/transcript/transcript.assembler.test.ts +205 -0
  152. package/src/core/transcript/transcript.assembler.ts +152 -0
  153. package/src/core/transcript/transcript.reducer.test.ts +199 -0
  154. package/src/core/transcript/transcript.reducer.ts +771 -0
  155. package/src/core/transcript/transcript.state.ts +123 -0
  156. package/src/core/transport/LiveKitTransport.publish.test.ts +226 -0
  157. package/src/core/transport/LiveKitTransport.ts +459 -0
  158. package/src/core/transport/MockTransport.ts +231 -0
  159. package/src/core/transport/Transport.ts +82 -0
  160. package/src/debug/sdk-debug-collector.ts +79 -0
  161. package/src/devices/index.ts +2 -0
  162. package/src/devices/speechmike/__tests__/EphiaSpeechMikeProvider.test.tsx +99 -0
  163. package/src/devices/speechmike/__tests__/speechmike-audio-resolver.test.ts +96 -0
  164. package/src/devices/speechmike/__tests__/speechmike-button-router.test.ts +66 -0
  165. package/src/devices/speechmike/__tests__/speechmike-device-manager.test.ts +201 -0
  166. package/src/devices/speechmike/__tests__/speechmike-led-controller.test.ts +68 -0
  167. package/src/devices/speechmike/browser.ts +80 -0
  168. package/src/devices/speechmike/constants.ts +74 -0
  169. package/src/devices/speechmike/dictation-support-loader.ts +81 -0
  170. package/src/devices/speechmike/index.ts +11 -0
  171. package/src/devices/speechmike/react/EphiaSpeechMikeContext.ts +34 -0
  172. package/src/devices/speechmike/react/EphiaSpeechMikeProvider.tsx +287 -0
  173. package/src/devices/speechmike/react/useEphiaSpeechMike.ts +26 -0
  174. package/src/devices/speechmike/speechmike-audio-resolver.ts +58 -0
  175. package/src/devices/speechmike/speechmike-button-router.ts +73 -0
  176. package/src/devices/speechmike/speechmike-device-manager.ts +461 -0
  177. package/src/devices/speechmike/speechmike-led-controller.ts +78 -0
  178. package/src/devices/speechmike/types.ts +96 -0
  179. package/src/dictation_support.d.ts +31 -0
  180. package/src/global.d.ts +10 -0
  181. package/src/headless/createEphiaClient.ts +220 -0
  182. package/src/headless/index.ts +18 -0
  183. package/src/index.ts +89 -0
  184. package/src/react/EphiaAuto.tsx +87 -0
  185. package/src/react/components/EphiaDictationButton.tsx +88 -0
  186. package/src/react/components/EphiaStatusBar.tsx +59 -0
  187. package/src/react/components/EphiaTextarea.tsx +295 -0
  188. package/src/react/ephia-react.css +318 -0
  189. package/src/react/hooks/targets/index.ts +3 -0
  190. package/src/react/hooks/targets/useEphiaCodemirror.ts +35 -0
  191. package/src/react/hooks/targets/useEphiaMonaco.ts +35 -0
  192. package/src/react/hooks/targets/useEphiaTiptap.ts +23 -0
  193. package/src/react/hooks/useEphia.lifecycle.test.tsx +389 -0
  194. package/src/react/hooks/useEphia.ts +367 -0
  195. package/src/react/hooks/useEphiaDiscardTarget.ts +53 -0
  196. package/src/react/hooks/useEphiaServerEvent.ts +33 -0
  197. package/src/react/hooks/useEphiaTarget.ts +47 -0
  198. package/src/react/hooks/useEphiaTranscript.ts +22 -0
  199. package/src/react/index.ts +58 -0
  200. package/src/react/provider/EphiaContext.ts +63 -0
  201. package/src/react/provider/EphiaInternalContext.ts +32 -0
  202. package/src/react/provider/EphiaProvider.tsx +373 -0
  203. package/src/react/registry/binding-factory.ts +7 -0
  204. package/src/react/registry/detect-editor-type.ts +2 -0
  205. package/src/react/registry/events.ts +37 -0
  206. package/src/react/registry/registries/CodeMirrorInstanceRegistry.ts +24 -0
  207. package/src/react/registry/registries/MonacoInstanceRegistry.ts +23 -0
  208. package/src/react/registry/registries/TargetRegistry.ts +327 -0
  209. package/src/react/registry/registries/TiptapInstanceRegistry.ts +43 -0
  210. package/src/react/registry/registries/index.ts +5 -0
  211. package/src/react/store/create-ephia-store.ts +36 -0
  212. package/src/react/store/types.ts +41 -0
  213. package/src/react/utils/flash-range.ts +24 -0
  214. package/src/react/utils/index.ts +1 -0
  215. package/src/rich-editor/adapters/tiptap.test.ts +86 -0
  216. package/src/rich-editor/adapters/tiptap.ts +23 -0
  217. package/src/rich-editor/index.ts +3 -0
  218. package/src/rich-editor/types.ts +24 -0
  219. package/src/rich-editor/use-ephia-rich-editor.test.tsx +202 -0
  220. package/src/rich-editor/use-ephia-rich-editor.ts +47 -0
  221. package/src/shared/config/endpoint.test.ts +45 -0
  222. package/src/shared/config/endpoint.ts +39 -0
  223. package/src/shared/config/schema.ts +32 -0
  224. package/src/shared/effective-text.ts +13 -0
  225. package/src/shared/errors/EphiaSdkError.ts +54 -0
  226. package/src/shared/errors/messages.ts +40 -0
  227. package/src/shared/index.ts +27 -0
  228. package/src/shared/state/audio-state.ts +45 -0
  229. package/src/shared/state/index.ts +2 -0
  230. package/src/shared/store/document-store.ts +32 -0
  231. package/src/shared/store/index.ts +2 -0
  232. package/src/shared/types/editors.ts +28 -0
  233. package/src/shared/types/session.ts +12 -0
  234. package/src/style.css +2 -0
  235. package/src/testing/index.tsx +60 -0
  236. package/src/ui/assets/ephia-logo.svg +4 -0
  237. package/src/ui/components/EphiaLogo.tsx +77 -0
  238. package/src/ui/index.ts +24 -0
  239. package/src/ui/primitives/Button.tsx +53 -0
  240. package/src/ui/primitives/Spinner.tsx +21 -0
  241. package/src/ui/primitives/index.ts +5 -0
  242. package/src/ui/recorder/EphiaFloatingButton.tsx +489 -0
  243. package/src/ui/recorder/MinimalProcessingBars.tsx +122 -0
  244. package/src/ui/recorder/StandardIntensityVisualizer.tsx +148 -0
  245. package/src/ui/recorder/appearance.ts +9 -0
  246. package/src/ui/recorder/index.ts +8 -0
  247. package/src/ui/theme.css +775 -0
@@ -0,0 +1,415 @@
1
+ /**
2
+ * SessionApiClient — HTTP vers le backend Ephia (sessions, tokens, preload).
3
+ *
4
+ * Rôle : createSession, refreshToken, stopBackendSession, preload.
5
+ * Possède : session préchargée et timers de refresh preload.
6
+ * Ne fait pas : transport LiveKit, routing events.
7
+ */
8
+
9
+ import { z } from "zod";
10
+ import type { EphiaSessionOptions } from "../../shared/types/session";
11
+ import { EphiaSdkError } from "../../shared/errors/EphiaSdkError";
12
+ import { resolveEphiaSdkApiUrl } from "../../shared/config/endpoint";
13
+ import type { EphiaStartOptions } from "./client-options";
14
+ import {
15
+ ACTIVE_TOKEN_TTL_MS,
16
+ AUDIO_SESSIONS_PATH,
17
+ PROTOCOL_VERSION,
18
+ SDK_VERSION,
19
+ TOKEN_MAX_AGE_MS,
20
+ } from "./constants";
21
+
22
+ const createSessionResponseSchema = z.object({
23
+ session_id: z.string(),
24
+ room_name: z.string(),
25
+ token: z.string(),
26
+ livekit_url: z.string(),
27
+ protocol_version: z.string().optional(),
28
+ capabilities: z
29
+ .object({
30
+ supports_realtime: z.boolean().optional(),
31
+ })
32
+ .optional(),
33
+ });
34
+
35
+ export type SessionCredentials = {
36
+ sessionId: string;
37
+ roomName: string;
38
+ token: string;
39
+ livekitUrl: string;
40
+ };
41
+
42
+ type PreloadedSession = SessionCredentials & {
43
+ createdAt: number;
44
+ initialTargetId?: string;
45
+ };
46
+
47
+ export type SessionApiClientOptions = {
48
+ apiUrl?: string;
49
+ apiKey?: string;
50
+ bearerToken?: string;
51
+ clientType?: string;
52
+ sessionOptions?: EphiaSessionOptions;
53
+ onPrepareConnection?: (livekitUrl: string, token: string) => void;
54
+ onPreloadComplete?: (data: SessionCredentials) => void;
55
+ onPreloadFailed?: () => void;
56
+ isIdle?: () => boolean;
57
+ };
58
+
59
+ export class SessionApiClient {
60
+ private readonly opts: SessionApiClientOptions;
61
+ private _preloadedSession: PreloadedSession | null = null;
62
+ private _preloadPromise: Promise<void> | null = null;
63
+ private _preloadToken = 0;
64
+ private _preloadRefreshTimer: ReturnType<typeof setTimeout> | null = null;
65
+
66
+ constructor(opts: SessionApiClientOptions) {
67
+ this.opts = opts;
68
+ }
69
+
70
+ get preloadedSession(): PreloadedSession | null {
71
+ return this._preloadedSession;
72
+ }
73
+
74
+ get preloadToken(): number {
75
+ return this._preloadToken;
76
+ }
77
+
78
+ incrementPreloadToken(): void {
79
+ this._preloadToken++;
80
+ }
81
+
82
+ takePreloadedSession(): SessionCredentials | null {
83
+ const s = this._preloadedSession;
84
+ if (!s) return null;
85
+ this._preloadedSession = null;
86
+ return {
87
+ sessionId: s.sessionId,
88
+ roomName: s.roomName,
89
+ token: s.token,
90
+ livekitUrl: s.livekitUrl,
91
+ };
92
+ }
93
+
94
+ discardPreloadedSession(): PreloadedSession | null {
95
+ const s = this._preloadedSession;
96
+ this._preloadedSession = null;
97
+ return s;
98
+ }
99
+
100
+ clearPreloadPromise(): void {
101
+ this._preloadPromise = null;
102
+ }
103
+
104
+ hasPreloadInFlight(): boolean {
105
+ return this._preloadPromise !== null;
106
+ }
107
+
108
+ async awaitPreloadIfPending(signal?: AbortSignal): Promise<void> {
109
+ const preloadPromise = this._preloadPromise;
110
+ if (!preloadPromise) return;
111
+ if (signal?.aborted) {
112
+ throw new EphiaSdkError("client.start_failed", "Start aborted");
113
+ }
114
+ if (!signal) {
115
+ await preloadPromise;
116
+ return;
117
+ }
118
+
119
+ await new Promise<void>((resolve, reject) => {
120
+ const onAbort = () => {
121
+ signal.removeEventListener("abort", onAbort);
122
+ reject(new EphiaSdkError("client.start_failed", "Start aborted"));
123
+ };
124
+ signal.addEventListener("abort", onAbort, { once: true });
125
+ preloadPromise.then(
126
+ () => {
127
+ signal.removeEventListener("abort", onAbort);
128
+ resolve();
129
+ },
130
+ (err) => {
131
+ signal.removeEventListener("abort", onAbort);
132
+ reject(err);
133
+ }
134
+ );
135
+ });
136
+ }
137
+
138
+ cancelTimers(): void {
139
+ if (this._preloadRefreshTimer !== null) {
140
+ clearTimeout(this._preloadRefreshTimer);
141
+ this._preloadRefreshTimer = null;
142
+ }
143
+ }
144
+
145
+ async preload(signal?: AbortSignal): Promise<void>;
146
+ async preload(options?: EphiaStartOptions, signal?: AbortSignal): Promise<void>;
147
+ async preload(
148
+ optionsOrSignal?: EphiaStartOptions | AbortSignal,
149
+ maybeSignal?: AbortSignal
150
+ ): Promise<void> {
151
+ const signal =
152
+ optionsOrSignal && "aborted" in optionsOrSignal ? optionsOrSignal : maybeSignal;
153
+ const startOptions =
154
+ optionsOrSignal && !("aborted" in optionsOrSignal) ? optionsOrSignal : undefined;
155
+ if (this.opts.isIdle && !this.opts.isIdle()) {
156
+ console.info("[EphiaAudioClient] preload: skipped, not idle");
157
+ return;
158
+ }
159
+ if (this._preloadedSession) {
160
+ console.info(
161
+ "[EphiaAudioClient] preload: already preloaded",
162
+ this._preloadedSession.sessionId
163
+ );
164
+ return;
165
+ }
166
+ if (this._preloadPromise) {
167
+ return this._preloadPromise;
168
+ }
169
+
170
+ const token = ++this._preloadToken;
171
+ const preloadPromise = this.createSession(signal, startOptions)
172
+ .then((data) => {
173
+ if (token !== this._preloadToken) {
174
+ void this.stopBackendSession(data.sessionId);
175
+ return;
176
+ }
177
+ this._preloadedSession = {
178
+ ...data,
179
+ createdAt: Date.now(),
180
+ initialTargetId: startOptions?.initialTargetId,
181
+ };
182
+ this.opts.onPrepareConnection?.(data.livekitUrl, data.token);
183
+ this.opts.onPreloadComplete?.(data);
184
+ console.info("[EphiaAudioClient] preload:done", data.sessionId);
185
+ this.schedulePreloadRefresh();
186
+ })
187
+ .catch((err) => {
188
+ if (err instanceof Error && err.name === "AbortError") return;
189
+ console.warn("[EphiaAudioClient] preload:failed", err);
190
+ this.opts.onPreloadFailed?.();
191
+ })
192
+ .finally(() => {
193
+ if (this._preloadPromise === preloadPromise) {
194
+ this._preloadPromise = null;
195
+ }
196
+ });
197
+
198
+ this._preloadPromise = preloadPromise;
199
+ return this._preloadPromise;
200
+ }
201
+
202
+ schedulePreloadRefresh(): void {
203
+ if (this._preloadRefreshTimer !== null) {
204
+ clearTimeout(this._preloadRefreshTimer);
205
+ }
206
+ this._preloadRefreshTimer = setTimeout(() => {
207
+ this._preloadRefreshTimer = null;
208
+ if (this.opts.isIdle?.()) {
209
+ console.info("[EphiaAudioClient] preload: auto-refreshing expired token");
210
+ const orphaned = this.discardPreloadedSession();
211
+ if (orphaned) {
212
+ void this.stopBackendSession(orphaned.sessionId);
213
+ }
214
+ this._preloadPromise = null;
215
+ // Preserve initialTargetId from the previous preloaded session.
216
+ const options = orphaned?.initialTargetId
217
+ ? { initialTargetId: orphaned.initialTargetId }
218
+ : undefined;
219
+ void this.preload(options).catch(() => {});
220
+ }
221
+ }, TOKEN_MAX_AGE_MS * 0.8);
222
+ }
223
+
224
+ /** Refresh token LiveKit — retourne le nouveau token ou null. */
225
+ async refreshToken(sessionId: string): Promise<string | null> {
226
+ const apiUrl = resolveEphiaSdkApiUrl(this.opts.apiUrl);
227
+ const headers = this.authHeaders();
228
+ try {
229
+ const r = await fetch(
230
+ `${apiUrl}${AUDIO_SESSIONS_PATH}/${encodeURIComponent(sessionId)}/token`,
231
+ { headers }
232
+ );
233
+ const data = (await r.json()) as { token?: string };
234
+ if (data.token) {
235
+ console.info("[EphiaAudioClient] token refreshed for next reconnect", sessionId);
236
+ return data.token;
237
+ }
238
+ } catch (err) {
239
+ console.warn("[EphiaAudioClient] token refresh failed (non-fatal)", err);
240
+ }
241
+ return null;
242
+ }
243
+
244
+ get activeTokenRefreshDelayMs(): number {
245
+ return ACTIVE_TOKEN_TTL_MS * 0.8;
246
+ }
247
+
248
+ isPreloadedSessionExpired(): boolean {
249
+ if (!this._preloadedSession) return false;
250
+ return Date.now() - this._preloadedSession.createdAt > TOKEN_MAX_AGE_MS;
251
+ }
252
+
253
+ async createSessionWithRetry(
254
+ signal?: AbortSignal,
255
+ startOptions?: EphiaStartOptions
256
+ ): Promise<SessionCredentials> {
257
+ const maxRetries = 2;
258
+ let lastErr: unknown;
259
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
260
+ if (signal?.aborted) {
261
+ throw new EphiaSdkError("client.start_failed", "Start aborted");
262
+ }
263
+ try {
264
+ return await this.createSession(signal, startOptions);
265
+ } catch (err) {
266
+ lastErr = err;
267
+ if (err instanceof EphiaSdkError) throw err;
268
+ if (signal?.aborted) throw err;
269
+ if (attempt < maxRetries) {
270
+ const delay = 250 * Math.pow(2, attempt) + Math.random() * 100;
271
+ console.warn(
272
+ `[EphiaAudioClient] createSession:retry attempt=${attempt + 1} delay=${Math.round(delay)}ms`,
273
+ err
274
+ );
275
+ await new Promise<void>((resolve) => setTimeout(resolve, delay));
276
+ }
277
+ }
278
+ }
279
+ throw lastErr;
280
+ }
281
+
282
+ async createSession(
283
+ signal?: AbortSignal,
284
+ startOptions?: EphiaStartOptions
285
+ ): Promise<SessionCredentials> {
286
+ const apiUrl = resolveEphiaSdkApiUrl(this.opts.apiUrl);
287
+ const headers: Record<string, string> = {
288
+ "Content-Type": "application/json",
289
+ ...this.authHeaders(),
290
+ };
291
+
292
+ const so = this.opts.sessionOptions;
293
+ const body: Record<string, unknown> = {
294
+ session_mode: "dictation",
295
+ client_type: this.opts.clientType ?? "sdk",
296
+ language: so?.language ?? "fr",
297
+ mode: so?.mode ?? "smart_s",
298
+ debug_chunks: so?.debugChunks ?? false,
299
+ sdk_version: SDK_VERSION,
300
+ protocol_version: String(PROTOCOL_VERSION),
301
+ };
302
+ if (startOptions?.initialTargetId) {
303
+ body.initial_target_id = startOptions.initialTargetId;
304
+ }
305
+
306
+ const response = await fetch(`${apiUrl}${AUDIO_SESSIONS_PATH}`, {
307
+ method: "POST",
308
+ headers,
309
+ body: JSON.stringify(body),
310
+ signal,
311
+ });
312
+
313
+ const rawText = await response.text();
314
+ if (!response.ok) {
315
+ let message = `Failed to create session: ${response.statusText}`;
316
+ try {
317
+ const errBody = JSON.parse(rawText) as {
318
+ detail?:
319
+ | string
320
+ | { message?: string; hint?: string }
321
+ | Array<{
322
+ loc?: Array<string | number>;
323
+ msg?: string;
324
+ type?: string;
325
+ }>;
326
+ };
327
+ const detail = errBody.detail;
328
+ if (typeof detail === "string") {
329
+ message = detail;
330
+ } else if (Array.isArray(detail)) {
331
+ message = detail
332
+ .map((item) => {
333
+ const loc = item.loc?.join(".") ?? "body";
334
+ return `${loc}: ${item.msg ?? item.type ?? "validation error"}`;
335
+ })
336
+ .join(" — ");
337
+ } else if (detail && typeof detail === "object") {
338
+ const parts = [detail.message, detail.hint].filter(Boolean);
339
+ if (parts.length > 0) message = parts.join(" — ");
340
+ }
341
+ } catch {
342
+ if (rawText) message = rawText;
343
+ }
344
+ throw new EphiaSdkError("session.create_failed", message, {
345
+ status: response.status,
346
+ });
347
+ }
348
+
349
+ const parsed = createSessionResponseSchema.safeParse(JSON.parse(rawText) as unknown);
350
+ if (!parsed.success) {
351
+ throw new EphiaSdkError(
352
+ "session.create_failed",
353
+ "Invalid session response from backend",
354
+ { issues: parsed.error.issues }
355
+ );
356
+ }
357
+
358
+ return {
359
+ sessionId: parsed.data.session_id,
360
+ roomName: parsed.data.room_name,
361
+ token: parsed.data.token,
362
+ livekitUrl: parsed.data.livekit_url,
363
+ };
364
+ }
365
+
366
+ async stopBackendSession(sessionId: string): Promise<void> {
367
+ try {
368
+ const apiUrl = resolveEphiaSdkApiUrl(this.opts.apiUrl);
369
+ const headers: Record<string, string> = {
370
+ "Content-Type": "application/json",
371
+ ...this.authHeaders(),
372
+ };
373
+ await fetch(`${apiUrl}${AUDIO_SESSIONS_PATH}/${encodeURIComponent(sessionId)}/stop`, {
374
+ method: "POST",
375
+ headers,
376
+ });
377
+ console.info("[EphiaAudioClient] stopBackendSession:done", sessionId);
378
+ } catch (err) {
379
+ console.warn("[EphiaAudioClient] stopBackendSession:failed", sessionId, err);
380
+ }
381
+ }
382
+
383
+ /** Best-effort stop on tab close (fetch keepalive supports auth headers). */
384
+ stopBackendSessionOnPageHide(sessionId: string): void {
385
+ if (typeof window === "undefined" || !sessionId) return;
386
+ try {
387
+ const apiUrl = resolveEphiaSdkApiUrl(this.opts.apiUrl);
388
+ const headers: Record<string, string> = {
389
+ "Content-Type": "application/json",
390
+ ...this.authHeaders(),
391
+ };
392
+ void fetch(`${apiUrl}${AUDIO_SESSIONS_PATH}/${encodeURIComponent(sessionId)}/stop`, {
393
+ method: "POST",
394
+ headers,
395
+ keepalive: true,
396
+ });
397
+ } catch {
398
+ /* ignore */
399
+ }
400
+ }
401
+
402
+ private authHeaders(): Record<string, string> {
403
+ const headers: Record<string, string> = {};
404
+ if (this.opts.apiKey) {
405
+ headers["X-API-Key"] = this.opts.apiKey;
406
+ } else if (this.opts.bearerToken) {
407
+ headers["Authorization"] = `Bearer ${this.opts.bearerToken}`;
408
+ }
409
+ if (this.opts.clientType) {
410
+ headers["X-Ephia-Client"] = this.opts.clientType;
411
+ headers["X-Ephia-App"] = this.opts.clientType;
412
+ }
413
+ return headers;
414
+ }
415
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * État de connexion transport + qualité.
3
+ *
4
+ * Source de vérité pour les composants UI de connexion.
5
+ * Alimenté par LiveKitTransport via un mapper depuis TransportState.
6
+ */
7
+
8
+ import type { TransportState } from "../transport/Transport";
9
+
10
+ export type EphiaConnectionStatus =
11
+ | "idle"
12
+ | "connecting"
13
+ | "connected"
14
+ | "reconnecting"
15
+ | "reconnected"
16
+ | "disconnected"
17
+ | "error";
18
+
19
+ export type EphiaConnectionQuality = "unknown" | "poor" | "good" | "excellent";
20
+
21
+ export type EphiaConnectionState = {
22
+ status: EphiaConnectionStatus;
23
+ quality: EphiaConnectionQuality;
24
+
25
+ roomName?: string;
26
+ participantIdentity?: string;
27
+
28
+ reconnectCount: number;
29
+ lastConnectedAt?: number;
30
+ lastReconnectedAt?: number;
31
+ lastDisconnectedAt?: number;
32
+
33
+ localAudioPublished: boolean;
34
+ localAudioMuted: boolean;
35
+
36
+ dataChannelReady: boolean;
37
+
38
+ lastError?: {
39
+ code: string;
40
+ message: string;
41
+ };
42
+ };
43
+
44
+ export const initialConnectionState: EphiaConnectionState = {
45
+ status: "idle",
46
+ quality: "unknown",
47
+ localAudioPublished: false,
48
+ localAudioMuted: false,
49
+ reconnectCount: 0,
50
+ dataChannelReady: false,
51
+ };
52
+
53
+ export function mapTransportStateToConnectionState(
54
+ ts: TransportState,
55
+ prev?: EphiaConnectionState
56
+ ): EphiaConnectionState {
57
+ const now = Date.now();
58
+ const next: EphiaConnectionState = {
59
+ status: ts.status,
60
+ quality: ts.connectionQuality ?? "unknown",
61
+ roomName: ts.roomName,
62
+ participantIdentity: ts.participantIdentity,
63
+ reconnectCount: ts.reconnectCount,
64
+ localAudioPublished: ts.localAudioPublished,
65
+ localAudioMuted: ts.localAudioMuted,
66
+ dataChannelReady: ts.status === "connected" || ts.status === "reconnected",
67
+ lastError: ts.lastError
68
+ ? { code: ts.lastError.code, message: ts.lastError.message }
69
+ : undefined,
70
+ lastConnectedAt:
71
+ ts.status === "connected" ? now : prev?.lastConnectedAt,
72
+ lastReconnectedAt:
73
+ ts.status === "reconnected" ? now : prev?.lastReconnectedAt,
74
+ lastDisconnectedAt:
75
+ ts.status === "disconnected" ? now : prev?.lastDisconnectedAt,
76
+ };
77
+ return next;
78
+ }
@@ -0,0 +1,6 @@
1
+ export { initialConnectionState, mapTransportStateToConnectionState } from "./connection-state";
2
+ export type {
3
+ EphiaConnectionQuality,
4
+ EphiaConnectionState,
5
+ EphiaConnectionStatus,
6
+ } from "./connection-state";
@@ -0,0 +1,47 @@
1
+ // Client (types only — EphiaAudioClient is internal)
2
+ export type { EphiaAudioClientOptions, EphiaStartOptions } from "./client/EphiaAudioClient";
3
+ export type { EphiaClientState, EphiaClientStatus } from "./client/client-state";
4
+ export { initialClientState } from "./client/client-state";
5
+
6
+ // Transport
7
+ export type { Transport, TransportState, TransportConnectParams } from "./transport/Transport";
8
+ export { LiveKitTransport } from "./transport/LiveKitTransport";
9
+ export { MockTransport } from "./transport/MockTransport";
10
+
11
+ // Transcript state & reducers (still used by some internal apps)
12
+ export { eventReducer } from "./transcript/client-transcript.reducer";
13
+ export { liveTranscriptReducer } from "./transcript/transcript.reducer";
14
+ export type { LiveTranscriptAction } from "./transcript/transcript.reducer";
15
+ export type { TranscriptState, TranscriptSegment } from "./transcript/client-transcript.state";
16
+ export { initialTranscriptState } from "./transcript/client-transcript.state";
17
+ export type { LiveTranscriptState, LiveTranscriptChunk, LiveTranscriptDiagnostics } from "./transcript/transcript.state";
18
+ export { initialLiveTranscriptState } from "./transcript/transcript.state";
19
+ export { assembleTranscript, assembleCommittedDocument, buildDisplayText } from "./transcript/transcript.assembler";
20
+
21
+ // Runtime (TranscriptApplier is public; DictationRuntime is deprecated but still exported for internal compat)
22
+ export { TranscriptApplier } from "./runtime/TranscriptApplier";
23
+ export type { TranscriptApplierOptions } from "./runtime/TranscriptApplier";
24
+
25
+ // Targets V2
26
+ export {
27
+ EditorContextCollector,
28
+ TargetManager,
29
+ } from "./targets";
30
+ export type {
31
+ EditorContextCollectorOptions,
32
+ EditorContextTarget,
33
+ ManagedTarget,
34
+ TargetManagerOptions,
35
+ } from "./targets";
36
+
37
+ // Operations
38
+ export type { TextToDocumentOperationsOptions } from "./operations/textToDocumentOperations";
39
+ export { textToDocumentOperations, documentOperationsToPlainText } from "./operations/textToDocumentOperations";
40
+
41
+ // Session
42
+ export { transitionSessionStatus, canTransition } from "./session/session-machine";
43
+ export type { EphiaSessionStatus } from "./session/session-machine";
44
+
45
+ // Connection state
46
+ export { mapTransportStateToConnectionState, initialConnectionState } from "./connection/connection-state";
47
+ export type { EphiaConnectionState, EphiaConnectionStatus, EphiaConnectionQuality } from "./connection/connection-state";
@@ -0,0 +1,69 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { DOCUMENT_OPERATION_TYPES, type DocumentOperation } from "ephia-protocol";
3
+ import { documentOperationsToPlainText, textToDocumentOperations } from "./textToDocumentOperations";
4
+
5
+ describe("textToDocumentOperations", () => {
6
+ it("converts a single \\n into a line_break operation", () => {
7
+ const ops = textToDocumentOperations("Indication :\nrésultat normal");
8
+ expect(ops.map((o) => o.type)).toEqual(["insert_text", "line_break", "insert_text"]);
9
+ });
10
+
11
+ it("converts \\n\\n into a paragraph_break operation", () => {
12
+ const ops = textToDocumentOperations("Contexte.\n\nRésultat : pas de foyer.");
13
+ expect(ops.map((o) => o.type)).toEqual(["insert_text", "paragraph_break", "insert_text"]);
14
+ });
15
+
16
+ it("produces a single insert_text for flat text with no breaks", () => {
17
+ const ops = textToDocumentOperations("Pas de saignement.");
18
+ expect(ops).toHaveLength(1);
19
+ expect(ops[0].type).toBe("insert_text");
20
+ });
21
+
22
+ it("applies the given position to every operation", () => {
23
+ const ops = textToDocumentOperations("a\nb", { position: "end" });
24
+ for (const op of ops) {
25
+ expect((op as { position?: unknown }).position).toBe("end");
26
+ }
27
+ });
28
+
29
+ it("defaults position to cursor", () => {
30
+ const ops = textToDocumentOperations("a\nb");
31
+ for (const op of ops) {
32
+ expect((op as { position?: unknown }).position).toBe("cursor");
33
+ }
34
+ });
35
+ });
36
+
37
+ describe("documentOperationsToPlainText", () => {
38
+ it("round-trips text containing line and paragraph breaks", () => {
39
+ const original = "Indication :\nTraumatisme.\n\nRésultat : pas de foyer.";
40
+ const ops = textToDocumentOperations(original);
41
+ expect(documentOperationsToPlainText(ops)).toBe(original);
42
+ });
43
+
44
+ it("round-trips flat text with no breaks", () => {
45
+ const original = "Pas de saignement intra ou extra axial.";
46
+ const ops = textToDocumentOperations(original);
47
+ expect(documentOperationsToPlainText(ops)).toBe(original);
48
+ });
49
+
50
+ it("ignores operations without a plain-text representation", () => {
51
+ const ops: DocumentOperation[] = [
52
+ { id: "1", type: "insert_text", position: "cursor", text: "a" },
53
+ { id: "2", type: "highlight", reason: "x", severity: "info" },
54
+ { id: "3", type: "insert_text", position: "cursor", text: "b" },
55
+ ];
56
+ expect(documentOperationsToPlainText(ops)).toBe("ab");
57
+ });
58
+ });
59
+
60
+ describe("DOCUMENT_OPERATION_TYPES stays in sync with the DocumentOperation union", () => {
61
+ it("includes line_break and paragraph_break", () => {
62
+ expect(DOCUMENT_OPERATION_TYPES).toContain("line_break");
63
+ expect(DOCUMENT_OPERATION_TYPES).toContain("paragraph_break");
64
+ });
65
+
66
+ it("has no duplicate entries", () => {
67
+ expect(new Set(DOCUMENT_OPERATION_TYPES).size).toBe(DOCUMENT_OPERATION_TYPES.length);
68
+ });
69
+ });