mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 18:34:02 +00:00
185 lines
5.5 KiB
JavaScript
185 lines
5.5 KiB
JavaScript
/**
|
|
* Protocol-independent voice lifetime and action dispatch.
|
|
* An adapter receives { emit, runAction, signal } and supplies start, stop,
|
|
* sendText and sendMapEvent. It owns its microphone/connection and wire messages.
|
|
* Events: state, transcript, action-call, progress, action-result, interruption,
|
|
* completion, turn-metrics.
|
|
*/
|
|
export function createVoiceSession({ createAdapter, runner, signal }) {
|
|
if (typeof createAdapter !== 'function' || typeof runner !== 'function')
|
|
throw new TypeError('Voice requires an adapter factory and action runner');
|
|
const lifetime = new AbortController();
|
|
const listeners = new Set();
|
|
const actions = new Set();
|
|
let adapter;
|
|
let state = 'idle';
|
|
let disposed = false;
|
|
let generation = 0;
|
|
let startAttempt = 0;
|
|
|
|
function cancelActions() {
|
|
generation++;
|
|
for (const action of actions) action.abort();
|
|
actions.clear();
|
|
}
|
|
|
|
function emit(event) {
|
|
if (disposed || !event || typeof event.type !== 'string') return;
|
|
if (event.type === 'disposed') {
|
|
dispose(false);
|
|
return;
|
|
}
|
|
if (event.type === 'state') {
|
|
state = event.state;
|
|
if (['idle', 'error', 'connecting'].includes(state)) cancelActions();
|
|
}
|
|
if (event.type === 'interruption') cancelActions();
|
|
for (const listener of [...listeners]) {
|
|
try {
|
|
listener(event);
|
|
} catch {
|
|
/* Observers cannot break a session. */
|
|
}
|
|
}
|
|
}
|
|
|
|
async function runAction(name, args, options = {}) {
|
|
if (
|
|
disposed ||
|
|
lifetime.signal.aborted ||
|
|
['idle', 'error'].includes(state)
|
|
)
|
|
throw abortError();
|
|
const action = new AbortController();
|
|
const epoch = generation;
|
|
actions.add(action);
|
|
const actionSignal = AbortSignal.any([
|
|
lifetime.signal,
|
|
action.signal,
|
|
...(options.signal ? [options.signal] : []),
|
|
]);
|
|
const isCurrent = () =>
|
|
!actionSignal.aborted &&
|
|
epoch === generation &&
|
|
(!options.isCurrent || options.isCurrent());
|
|
const callId = options.callId ?? null;
|
|
try {
|
|
if (!isCurrent()) throw abortError();
|
|
// Calls carry the adapter's call id so observers can match progress
|
|
// and results to the right call when a tool runs twice at once.
|
|
emit({ type: 'action-call', name, callId, arguments: args });
|
|
if (!isCurrent()) throw abortError();
|
|
// Step reports reach observers only while the action is current, so a
|
|
// cancelled tool cannot update the plan or start narration.
|
|
const progress = (update) => {
|
|
if (!isCurrent() || !update?.step) return;
|
|
emit({
|
|
type: 'progress',
|
|
name,
|
|
callId,
|
|
step: update.step,
|
|
label: update.label,
|
|
});
|
|
options.progress?.(update);
|
|
};
|
|
const result = await runner(name, args, {
|
|
...options,
|
|
signal: actionSignal,
|
|
isCurrent,
|
|
progress,
|
|
});
|
|
if (!isCurrent()) throw abortError();
|
|
emit({ type: 'action-result', name, callId, result });
|
|
return result;
|
|
} catch (error) {
|
|
if (isCurrent())
|
|
emit({
|
|
type: 'action-result',
|
|
name,
|
|
callId,
|
|
result: { ok: false, error: error?.message || String(error) },
|
|
});
|
|
throw error;
|
|
} finally {
|
|
actions.delete(action);
|
|
}
|
|
}
|
|
|
|
function dispose(stopAdapter = true) {
|
|
if (disposed) return;
|
|
disposed = true;
|
|
startAttempt++;
|
|
state = 'idle';
|
|
cancelActions();
|
|
lifetime.abort();
|
|
signal?.removeEventListener('abort', abort);
|
|
listeners.clear();
|
|
if (stopAdapter) adapter?.stop({ removeUi: true });
|
|
}
|
|
const abort = () => dispose();
|
|
try {
|
|
adapter = createAdapter({ emit, runAction, signal: lifetime.signal });
|
|
for (const method of ['start', 'stop', 'sendText', 'sendMapEvent']) {
|
|
if (typeof adapter?.[method] !== 'function')
|
|
throw new TypeError('Voice adapter must implement ' + method);
|
|
}
|
|
} catch (error) {
|
|
dispose();
|
|
throw error;
|
|
}
|
|
signal?.addEventListener('abort', abort, { once: true });
|
|
if (signal?.aborted) dispose();
|
|
|
|
return {
|
|
adapter,
|
|
signal: lifetime.signal,
|
|
get state() {
|
|
return state;
|
|
},
|
|
get disposed() {
|
|
return disposed;
|
|
},
|
|
isActive: () => !disposed && !['idle', 'error'].includes(state),
|
|
subscribe(listener) {
|
|
if (disposed) return () => {};
|
|
listeners.add(listener);
|
|
return () => listeners.delete(listener);
|
|
},
|
|
async start(options) {
|
|
if (disposed || !['idle', 'error'].includes(state)) return;
|
|
emit({ type: 'state', state: 'connecting' });
|
|
const attempt = ++startAttempt;
|
|
try {
|
|
await adapter.start(options);
|
|
} catch (error) {
|
|
if (!disposed && attempt === startAttempt) {
|
|
try {
|
|
adapter.stop({ preserveStatus: true });
|
|
} finally {
|
|
emit({ type: 'state', state: 'error', detail: error.message });
|
|
}
|
|
}
|
|
}
|
|
},
|
|
stop(options = {}) {
|
|
if (options.removeUi) return dispose();
|
|
if (disposed) return;
|
|
startAttempt++;
|
|
cancelActions();
|
|
try {
|
|
adapter.stop(options);
|
|
} finally {
|
|
if (!options.preserveStatus)
|
|
emit({ type: 'state', state: 'idle', detail: 'Voice off' });
|
|
}
|
|
},
|
|
sendText: (text) => !disposed && adapter.sendText(text),
|
|
sendMapEvent: (event) => !disposed && adapter.sendMapEvent(event),
|
|
destroy: () => dispose(),
|
|
};
|
|
}
|
|
|
|
function abortError() {
|
|
return new DOMException('Voice action cancelled', 'AbortError');
|
|
}
|