@absolutejs/voice 0.0.3 → 0.0.4

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 CHANGED
@@ -51,6 +51,56 @@ const app = new Elysia()
51
51
 
52
52
  `createVoiceMemoryStore()` is dev-only. Real deployments should provide a shared store backed by Redis, Postgres, or equivalent.
53
53
 
54
+ ## HTMX
55
+
56
+ Voice now mirrors the AI plugin's HTMX pattern with plugin-owned renderers and a plugin-owned fragment route.
57
+
58
+ ```ts
59
+ import { voice, createVoiceMemoryStore } from '@absolutejs/voice';
60
+
61
+ app.use(
62
+ voice({
63
+ path: '/voice/intake',
64
+ htmx: {
65
+ render: {
66
+ result: ({ result }) =>
67
+ result
68
+ ? `<pre>${JSON.stringify(result, null, 2)}</pre>`
69
+ : '<p>No structured result yet.</p>'
70
+ }
71
+ },
72
+ onComplete: async () => {},
73
+ onTurn: async ({ turn }) => ({
74
+ assistantText: `You said: ${turn.text}`
75
+ }),
76
+ session: createVoiceMemoryStore(),
77
+ stt: deepgram({
78
+ apiKey: process.env.DEEPGRAM_API_KEY!,
79
+ model: 'nova-3'
80
+ })
81
+ })
82
+ );
83
+ ```
84
+
85
+ The plugin exposes `GET /voice/intake/htmx/session?sessionId=...` by default. That route returns HTMX out-of-band fragments for:
86
+
87
+ - metrics
88
+ - status
89
+ - committed turns
90
+ - assistant replies
91
+ - structured result
92
+
93
+ On the client, bind the browser voice stream to a hidden HTMX refresh element:
94
+
95
+ ```ts
96
+ import { bindVoiceHTMX, createVoiceStream } from '@absolutejs/voice/client';
97
+
98
+ const voice = createVoiceStream('/voice/intake');
99
+ bindVoiceHTMX(voice, { element: '#voice-htmx-sync' });
100
+ ```
101
+
102
+ That keeps HTMX pages declarative without inventing custom fragment endpoints for core voice session UI.
103
+
54
104
  ## Adapter Contract
55
105
 
56
106
  Adapters normalize vendor behavior into a core event model so the plugin never branches on vendor names.
@@ -8,7 +8,7 @@ export declare class VoiceStreamService {
8
8
  isConnected: import("@angular/core").Signal<boolean>;
9
9
  partial: import("@angular/core").Signal<string>;
10
10
  sendAudio: (audio: Uint8Array | ArrayBuffer) => void;
11
- sessionId: import("@angular/core").Signal<string>;
11
+ sessionId: import("@angular/core").Signal<string | null>;
12
12
  status: import("@angular/core").Signal<import("..").VoiceSessionStatus | "idle">;
13
13
  turns: import("@angular/core").Signal<VoiceTurnRecord<TResult>[]>;
14
14
  };
@@ -1,14 +1,2 @@
1
- import type { VoiceConnectionOptions } from '../types';
2
- export declare const createVoiceStream: <TResult = unknown>(path: string, options?: VoiceConnectionOptions) => {
3
- close(): void;
4
- endTurn(): void;
5
- readonly error: string | null;
6
- readonly isConnected: boolean;
7
- readonly partial: string;
8
- readonly sessionId: string;
9
- readonly status: import("..").VoiceSessionStatus | "idle";
10
- readonly turns: import("..").VoiceTurnRecord<TResult>[];
11
- readonly assistantTexts: string[];
12
- sendAudio(audio: Uint8Array | ArrayBuffer): void;
13
- subscribe(subscriber: () => void): () => void;
14
- };
1
+ import type { VoiceConnectionOptions, VoiceStream } from '../types';
2
+ export declare const createVoiceStream: <TResult = unknown>(path: string, options?: VoiceConnectionOptions) => VoiceStream<TResult>;
@@ -0,0 +1,2 @@
1
+ import type { VoiceHTMXBindingOptions, VoiceStream } from '../types';
2
+ export declare const bindVoiceHTMX: <TResult = unknown>(stream: VoiceStream<TResult>, options: VoiceHTMXBindingOptions) => () => void;
@@ -1,3 +1,4 @@
1
1
  export { createVoiceConnection } from './connection';
2
2
  export { createVoiceStream } from './createVoiceStream';
3
+ export { bindVoiceHTMX } from './htmx';
3
4
  export { createMicrophoneCapture } from './microphone';
@@ -447,6 +447,53 @@ var createVoiceStream = (path, options = {}) => {
447
447
  }
448
448
  };
449
449
  };
450
+ // src/client/htmx.ts
451
+ var DEFAULT_EVENT_NAME = "voice-refresh";
452
+ var DEFAULT_QUERY_PARAM = "sessionId";
453
+ var resolveElement = (input) => {
454
+ if (typeof input !== "string") {
455
+ return input;
456
+ }
457
+ return document.querySelector(input);
458
+ };
459
+ var buildRoute = (element, route, queryParam, sessionId) => {
460
+ const baseRoute = route ?? element.getAttribute("hx-get") ?? "";
461
+ if (!baseRoute) {
462
+ return "";
463
+ }
464
+ const url = new URL(baseRoute, window.location.origin);
465
+ if (sessionId) {
466
+ url.searchParams.set(queryParam, sessionId);
467
+ } else {
468
+ url.searchParams.delete(queryParam);
469
+ }
470
+ return `${url.pathname}${url.search}${url.hash}`;
471
+ };
472
+ var bindVoiceHTMX = (stream, options) => {
473
+ if (typeof window === "undefined" || typeof document === "undefined") {
474
+ return () => {};
475
+ }
476
+ const element = resolveElement(options.element);
477
+ if (!element) {
478
+ return () => {};
479
+ }
480
+ const eventName = options.eventName ?? DEFAULT_EVENT_NAME;
481
+ const queryParam = options.sessionQueryParam ?? DEFAULT_QUERY_PARAM;
482
+ const sync = () => {
483
+ const htmxWindow = window;
484
+ const nextRoute = buildRoute(element, options.route, queryParam, stream.sessionId);
485
+ if (nextRoute) {
486
+ element.setAttribute("hx-get", nextRoute);
487
+ }
488
+ htmxWindow.htmx?.process?.(element);
489
+ htmxWindow.htmx?.trigger?.(element, eventName);
490
+ };
491
+ const unsubscribe = stream.subscribe(sync);
492
+ sync();
493
+ return () => {
494
+ unsubscribe();
495
+ };
496
+ };
450
497
  // src/client/microphone.ts
451
498
  var clampSample = (value) => Math.max(-1, Math.min(1, value));
452
499
  var floatTo16BitPCM = (input) => {
@@ -517,5 +564,6 @@ var createMicrophoneCapture = (options) => {
517
564
  export {
518
565
  createVoiceStream,
519
566
  createVoiceConnection,
520
- createMicrophoneCapture
567
+ createMicrophoneCapture,
568
+ bindVoiceHTMX
521
569
  };
package/dist/htmx.d.ts ADDED
@@ -0,0 +1,6 @@
1
+ import type { VoiceHTMXRenderConfig, VoiceHTMXRenderInput, VoiceHTMXTargets, VoiceSessionRecord } from './types';
2
+ type ResolvedVoiceHTMXRenderConfig<TSession extends VoiceSessionRecord = VoiceSessionRecord, TResult = unknown> = Required<VoiceHTMXRenderConfig<TSession, TResult>>;
3
+ export declare const resolveVoiceHTMXTargets: (custom?: Partial<VoiceHTMXTargets>) => VoiceHTMXTargets;
4
+ export declare const resolveVoiceHTMXRenderers: <TSession extends VoiceSessionRecord = VoiceSessionRecord, TResult = unknown>(custom?: VoiceHTMXRenderConfig<TSession, TResult>) => ResolvedVoiceHTMXRenderConfig<TSession, TResult>;
5
+ export declare const buildVoiceHTMXResponse: <TSession extends VoiceSessionRecord = VoiceSessionRecord, TResult = unknown>(input: VoiceHTMXRenderInput<TResult, TSession>, renderers: ResolvedVoiceHTMXRenderConfig<TSession, TResult>, targets: VoiceHTMXTargets) => string;
6
+ export {};
package/dist/index.js CHANGED
@@ -72,6 +72,124 @@ var __decorateElement = (array, flags, name, decorators, target, extra) => {
72
72
  // src/plugin.ts
73
73
  import { Elysia } from "elysia";
74
74
 
75
+ // src/htmx.ts
76
+ var DEFAULT_HTMX_TARGETS = {
77
+ assistant: "voice-htmx-assistant",
78
+ metrics: "voice-htmx-metrics",
79
+ result: "voice-htmx-result",
80
+ status: "voice-htmx-status",
81
+ turns: "voice-htmx-turns"
82
+ };
83
+ var escapeHtml = (text) => text.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&#39;");
84
+ var stringifyResult = (result) => {
85
+ if (result === undefined) {
86
+ return "";
87
+ }
88
+ if (typeof result === "string") {
89
+ return result;
90
+ }
91
+ try {
92
+ return JSON.stringify(result, null, 2);
93
+ } catch {
94
+ return String(result);
95
+ }
96
+ };
97
+ var defaultEmptyState = (kind) => {
98
+ switch (kind) {
99
+ case "assistant":
100
+ return '<p class="empty-copy">No assistant messages yet.</p>';
101
+ case "metrics":
102
+ return '<p class="empty-copy">No active voice session yet.</p>';
103
+ case "result":
104
+ return '<p class="empty-copy">No structured result yet.</p>';
105
+ case "status":
106
+ return '<p class="empty-copy">Voice session is idle.</p>';
107
+ case "turns":
108
+ return '<p class="empty-copy">No turns committed yet.</p>';
109
+ }
110
+ };
111
+ var defaultMetrics = (input) => {
112
+ if (!input.sessionId) {
113
+ return defaultEmptyState("metrics");
114
+ }
115
+ return [
116
+ '<div class="voice-metric">',
117
+ '<span class="voice-metric-label">Session</span>',
118
+ `<span class="voice-metric-value">${escapeHtml(input.sessionId)}</span>`,
119
+ "</div>",
120
+ '<div class="voice-metric">',
121
+ '<span class="voice-metric-label">Status</span>',
122
+ `<span class="voice-metric-value">${escapeHtml(input.status)}</span>`,
123
+ "</div>",
124
+ '<div class="voice-metric">',
125
+ '<span class="voice-metric-label">Committed turns</span>',
126
+ `<span class="voice-metric-value">${String(input.turnCount)}</span>`,
127
+ "</div>"
128
+ ].join("");
129
+ };
130
+ var defaultStatus = (input) => [
131
+ '<div class="status-row">',
132
+ '<span class="label">Voice status</span>',
133
+ `<span class="value">${escapeHtml(input.status)}</span>`,
134
+ "</div>",
135
+ '<div class="status-row">',
136
+ '<span class="label">Partial transcript</span>',
137
+ `<span class="value">${escapeHtml(input.partial || "No live partial")}</span>`,
138
+ "</div>"
139
+ ].join("");
140
+ var renderTurn = (turn) => [
141
+ '<article class="voice-turn">',
142
+ '<div class="voice-turn-header">',
143
+ `<strong>${escapeHtml(turn.text)}</strong>`,
144
+ `<span>${new Date(turn.committedAt).toLocaleString("en-US", {
145
+ dateStyle: "medium",
146
+ timeStyle: "short"
147
+ })}</span>`,
148
+ "</div>",
149
+ turn.assistantText ? [
150
+ '<div class="voice-assistant-label">Assistant</div>',
151
+ `<p class="voice-turn-text">${escapeHtml(turn.assistantText)}</p>`
152
+ ].join("") : "",
153
+ "</article>"
154
+ ].join("");
155
+ var defaultTurns = (input) => input.turns.length === 0 ? defaultEmptyState("turns") : input.turns.map((turn) => renderTurn(turn)).join("");
156
+ var defaultAssistant = (input) => input.assistantTexts.length === 0 ? defaultEmptyState("assistant") : input.assistantTexts.map((text, index) => [
157
+ '<article class="voice-assistant-item">',
158
+ `<div class="voice-assistant-label">Reply ${String(index + 1)}</div>`,
159
+ `<p class="voice-turn-text">${escapeHtml(text)}</p>`,
160
+ "</article>"
161
+ ].join("")).join("");
162
+ var defaultResult = (input) => {
163
+ if (input.result === undefined) {
164
+ return defaultEmptyState("result");
165
+ }
166
+ return [
167
+ '<pre class="voice-code"><code>',
168
+ escapeHtml(stringifyResult(input.result)),
169
+ "</code></pre>"
170
+ ].join("");
171
+ };
172
+ var resolveVoiceHTMXTargets = (custom) => ({
173
+ ...DEFAULT_HTMX_TARGETS,
174
+ ...custom
175
+ });
176
+ var resolveVoiceHTMXRenderers = (custom) => ({
177
+ assistant: custom?.assistant ?? defaultAssistant,
178
+ emptyState: custom?.emptyState ?? defaultEmptyState,
179
+ metrics: custom?.metrics ?? defaultMetrics,
180
+ result: custom?.result ?? defaultResult,
181
+ status: custom?.status ?? defaultStatus,
182
+ turns: custom?.turns ?? defaultTurns
183
+ });
184
+ var renderOob = (id, html) => `<div id="${escapeHtml(id)}" hx-swap-oob="innerHTML">${html}</div>`;
185
+ var buildVoiceHTMXResponse = (input, renderers, targets) => [
186
+ renderOob(targets.metrics, renderers.metrics(input)),
187
+ renderOob(targets.status, renderers.status(input)),
188
+ renderOob(targets.turns, renderers.turns(input)),
189
+ renderOob(targets.assistant, renderers.assistant(input)),
190
+ renderOob(targets.result, renderers.result(input))
191
+ ].join("");
192
+
75
193
  // src/logger.ts
76
194
  var noop = () => {};
77
195
  var createNoopLogger = () => ({
@@ -560,6 +678,33 @@ var voice = (config) => {
560
678
  logger: resolveLogger(config.logger),
561
679
  socketSessions: new WeakMap
562
680
  };
681
+ const htmxConfig = typeof config.htmx === "object" ? config.htmx : undefined;
682
+ const htmxRoute = htmxConfig?.route ?? `${config.path}/htmx/session`;
683
+ const htmxRenderers = resolveVoiceHTMXRenderers(htmxConfig?.render);
684
+ const htmxTargets = resolveVoiceHTMXTargets(htmxConfig?.targets);
685
+ const htmxRoutes = () => {
686
+ if (!config.htmx) {
687
+ return new Elysia;
688
+ }
689
+ return new Elysia().get(htmxRoute, async ({ query }) => {
690
+ const sessionId = typeof query.sessionId === "string" && query.sessionId.trim() ? query.sessionId.trim() : undefined;
691
+ const session = sessionId ? await config.session.get(sessionId) : undefined;
692
+ const result = session?.turns.toReversed().find((turn) => turn.result !== undefined)?.result;
693
+ const turns = session?.turns ?? [];
694
+ return new Response(buildVoiceHTMXResponse({
695
+ assistantTexts: session?.turns.flatMap((turn) => turn.assistantText ? [turn.assistantText] : []) ?? [],
696
+ partial: session?.currentTurn.partialText ?? "",
697
+ result,
698
+ session,
699
+ sessionId,
700
+ status: session?.status ?? "idle",
701
+ turnCount: turns.length,
702
+ turns
703
+ }, htmxRenderers, htmxTargets), {
704
+ headers: { "Content-Type": "text/html; charset=utf-8" }
705
+ });
706
+ });
707
+ };
563
708
  return new Elysia({ name: "absolutejs-voice" }).ws(config.path, {
564
709
  close: async (ws, code, reason) => {
565
710
  const sessionId = runtime.socketSessions.get(ws);
@@ -661,7 +806,7 @@ var voice = (config) => {
661
806
  runtime.activeSessions.set(sessionId, session);
662
807
  await session.connect(createSocketAdapter(ws));
663
808
  }
664
- });
809
+ }).use(htmxRoutes());
665
810
  };
666
811
  // src/memoryStore.ts
667
812
  var createVoiceMemoryStore = () => {
package/dist/plugin.d.ts CHANGED
@@ -25,7 +25,29 @@ export declare const voice: <TContext = unknown, TSession extends VoiceSessionRe
25
25
  response: {};
26
26
  };
27
27
  };
28
- }, {
28
+ } | ({
29
+ [x: string]: {
30
+ subscribe: {
31
+ body: unknown;
32
+ params: {};
33
+ query: unknown;
34
+ headers: unknown;
35
+ response: {};
36
+ };
37
+ };
38
+ } & {
39
+ [x: string]: {
40
+ get: {
41
+ body: unknown;
42
+ params: {};
43
+ query: unknown;
44
+ headers: unknown;
45
+ response: {
46
+ 200: Response;
47
+ };
48
+ };
49
+ };
50
+ }), {
29
51
  derive: {};
30
52
  resolve: {};
31
53
  schema: {};
@@ -37,4 +59,10 @@ export declare const voice: <TContext = unknown, TSession extends VoiceSessionRe
37
59
  schema: {};
38
60
  standaloneSchema: {};
39
61
  response: {};
62
+ } & {
63
+ derive: {};
64
+ resolve: {};
65
+ schema: {};
66
+ standaloneSchema: {};
67
+ response: {};
40
68
  }>;
@@ -7,7 +7,7 @@ export declare const useVoiceStream: <TResult = unknown>(path: string, options?:
7
7
  error: string | null;
8
8
  isConnected: boolean;
9
9
  partial: string;
10
- sessionId: string;
10
+ sessionId: string | null;
11
11
  status: import("..").VoiceSessionStatus | "idle";
12
12
  turns: import("..").VoiceTurnRecord<TResult>[];
13
13
  };
@@ -1,14 +1,2 @@
1
1
  import type { VoiceConnectionOptions } from '../types';
2
- export declare const createVoiceStream: <TResult = unknown>(path: string, options?: VoiceConnectionOptions) => {
3
- close(): void;
4
- endTurn(): void;
5
- readonly error: string | null;
6
- readonly isConnected: boolean;
7
- readonly partial: string;
8
- readonly sessionId: string;
9
- readonly status: import("..").VoiceSessionStatus | "idle";
10
- readonly turns: import("..").VoiceTurnRecord<TResult>[];
11
- readonly assistantTexts: string[];
12
- sendAudio(audio: Uint8Array | ArrayBuffer): void;
13
- subscribe(subscriber: () => void): () => void;
14
- };
2
+ export declare const createVoiceStream: <TResult = unknown>(path: string, options?: VoiceConnectionOptions) => import("..").VoiceStream<TResult>;
package/dist/types.d.ts CHANGED
@@ -208,6 +208,7 @@ export type VoicePluginConfig<TContext = unknown, TSession extends VoiceSessionR
208
208
  silenceMs?: number;
209
209
  };
210
210
  logger?: VoiceLogger;
211
+ htmx?: boolean | VoiceHTMXConfig<TSession, TResult>;
211
212
  } & VoiceRouteConfig<TContext, TSession, TResult>;
212
213
  export type CreateVoiceSessionOptions<TContext = unknown, TSession extends VoiceSessionRecord = VoiceSessionRecord, TResult = unknown> = {
213
214
  id: string;
@@ -280,6 +281,36 @@ export type VoiceConnectionOptions = {
280
281
  pingInterval?: number;
281
282
  sessionId?: string;
282
283
  };
284
+ export type VoiceHTMXRenderInput<TResult = unknown, TSession extends VoiceSessionRecord = VoiceSessionRecord> = {
285
+ assistantTexts: string[];
286
+ partial: string;
287
+ result?: TResult;
288
+ session?: TSession;
289
+ sessionId?: string;
290
+ status: VoiceSessionStatus | 'idle';
291
+ turnCount: number;
292
+ turns: VoiceTurnRecord<TResult>[];
293
+ };
294
+ export type VoiceHTMXRenderConfig<TSession extends VoiceSessionRecord = VoiceSessionRecord, TResult = unknown> = {
295
+ metrics?: (input: VoiceHTMXRenderInput<TResult, TSession>) => string;
296
+ status?: (input: VoiceHTMXRenderInput<TResult, TSession>) => string;
297
+ turns?: (input: VoiceHTMXRenderInput<TResult, TSession>) => string;
298
+ assistant?: (input: VoiceHTMXRenderInput<TResult, TSession>) => string;
299
+ result?: (input: VoiceHTMXRenderInput<TResult, TSession>) => string;
300
+ emptyState?: (kind: keyof VoiceHTMXTargets, input: VoiceHTMXRenderInput<TResult, TSession>) => string;
301
+ };
302
+ export type VoiceHTMXTargets = {
303
+ assistant: string;
304
+ metrics: string;
305
+ result: string;
306
+ status: string;
307
+ turns: string;
308
+ };
309
+ export type VoiceHTMXConfig<TSession extends VoiceSessionRecord = VoiceSessionRecord, TResult = unknown> = {
310
+ render?: VoiceHTMXRenderConfig<TSession, TResult>;
311
+ route?: string;
312
+ targets?: Partial<VoiceHTMXTargets>;
313
+ };
283
314
  export type VoiceStreamState<TResult = unknown> = {
284
315
  sessionId: string | null;
285
316
  status: VoiceSessionStatus | 'idle';
@@ -289,6 +320,25 @@ export type VoiceStreamState<TResult = unknown> = {
289
320
  error: string | null;
290
321
  isConnected: boolean;
291
322
  };
323
+ export type VoiceStream<TResult = unknown> = {
324
+ close: () => void;
325
+ endTurn: () => void;
326
+ error: string | null;
327
+ isConnected: boolean;
328
+ partial: string;
329
+ sendAudio: (audio: Uint8Array | ArrayBuffer) => void;
330
+ sessionId: string | null;
331
+ status: VoiceSessionStatus | 'idle';
332
+ subscribe: (subscriber: () => void) => () => void;
333
+ turns: VoiceTurnRecord<TResult>[];
334
+ assistantTexts: string[];
335
+ };
336
+ export type VoiceHTMXBindingOptions = {
337
+ element: Element | string;
338
+ eventName?: string;
339
+ route?: string;
340
+ sessionQueryParam?: string;
341
+ };
292
342
  export type VoiceStoreAction<TResult = unknown> = {
293
343
  type: 'session';
294
344
  sessionId: string;
@@ -7,7 +7,7 @@ export declare const useVoiceStream: <TResult = unknown>(path: string, options?:
7
7
  isConnected: import("vue").Ref<boolean, boolean>;
8
8
  partial: import("vue").Ref<string, string>;
9
9
  sendAudio: (audio: Uint8Array | ArrayBuffer) => void;
10
- sessionId: import("vue").Ref<string, string>;
10
+ sessionId: import("vue").Ref<string | null, string | null>;
11
11
  status: import("vue").Ref<import("..").VoiceSessionStatus | "idle", import("..").VoiceSessionStatus | "idle">;
12
12
  turns: import("vue").ShallowRef<VoiceTurnRecord<TResult>[], VoiceTurnRecord<TResult>[]>;
13
13
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@absolutejs/voice",
3
- "version": "0.0.3",
3
+ "version": "0.0.4",
4
4
  "description": "Voice primitives and Elysia plugin for AbsoluteJS",
5
5
  "repository": {
6
6
  "type": "git",