@lunora/angular 1.0.0-alpha.1 → 1.0.0-alpha.10
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/LICENSE.md +6 -0
- package/README.md +2 -0
- package/dist/index.d.mts +1176 -4
- package/dist/index.d.ts +1176 -4
- package/dist/index.mjs +15 -1
- package/dist/packem_shared/agent-DDKvrG4u.mjs +31 -0
- package/dist/packem_shared/agentChat-DDZlxK1v.mjs +111 -0
- package/dist/packem_shared/agentState-C8GWf3t3.mjs +10 -0
- package/dist/packem_shared/agentToolEvents-sXgYggvi.mjs +59 -0
- package/dist/packem_shared/auth-Df9N87Z4.mjs +27 -0
- package/dist/packem_shared/flag-CGBo90HJ.mjs +70 -0
- package/dist/packem_shared/hydratePreloaded-DIpD1cAM.mjs +33 -0
- package/dist/packem_shared/infiniteQuery-nboKfr5E.mjs +221 -0
- package/dist/packem_shared/liveQuery-DVxKidjM.mjs +32 -0
- package/dist/packem_shared/mutator-BHL8bakL.mjs +19 -0
- package/dist/packem_shared/platform-Dg8Bppgq.mjs +14 -0
- package/dist/packem_shared/presence-BTuq19dS.mjs +77 -0
- package/dist/packem_shared/rateLimit-I4kRT9qV.mjs +58 -0
- package/dist/packem_shared/stream-PL64AghO.mjs +47 -0
- package/dist/packem_shared/subscription-oZ-WTmpp.mjs +39 -0
- package/dist/packem_shared/voiceAgent-DwbrDnB9.mjs +401 -0
- package/package.json +4 -3
- package/dist/packem_shared/liveQuery-D5VQBOgf.mjs +0 -28
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { inject, DestroyRef, signal } from '@angular/core';
|
|
2
|
+
import { resolveLunoraClient } from './LUNORA_CLIENT-DHUfNu9x.mjs';
|
|
3
|
+
import { s as shouldOpenSubscription, r as runOutsideAngular } from './platform-Dg8Bppgq.mjs';
|
|
4
|
+
|
|
5
|
+
const randomSessionId = (prefix = "sess") => {
|
|
6
|
+
if (typeof crypto !== "undefined") {
|
|
7
|
+
if (typeof crypto.randomUUID === "function") {
|
|
8
|
+
return crypto.randomUUID();
|
|
9
|
+
}
|
|
10
|
+
if (typeof crypto.getRandomValues === "function") {
|
|
11
|
+
const bytes = crypto.getRandomValues(new Uint8Array(16));
|
|
12
|
+
return `${prefix}-${Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("")}`;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
return `${prefix}-${Date.now().toString(36)}`;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const DEFAULT_INTERVAL_MS = 1e4;
|
|
19
|
+
const presence = (roomId, options) => {
|
|
20
|
+
const client = resolveLunoraClient(options.client);
|
|
21
|
+
const fromInjectionContext = options.destroyRef === void 0;
|
|
22
|
+
const destroyRef = options.destroyRef ?? inject(DestroyRef);
|
|
23
|
+
const { heartbeat, listPresent, shardKey } = options;
|
|
24
|
+
const intervalMs = options.intervalMs ?? DEFAULT_INTERVAL_MS;
|
|
25
|
+
const sessionId = options.sessionId ?? randomSessionId();
|
|
26
|
+
const present = signal(void 0);
|
|
27
|
+
if (!Number.isFinite(intervalMs) || intervalMs <= 0) {
|
|
28
|
+
throw new RangeError(`presence intervalMs must be a positive number, got ${String(intervalMs)}`);
|
|
29
|
+
}
|
|
30
|
+
let latestData = options.data;
|
|
31
|
+
const sendHeartbeat = () => {
|
|
32
|
+
const args = { roomId, sessionId };
|
|
33
|
+
if (latestData !== void 0) {
|
|
34
|
+
args.data = latestData;
|
|
35
|
+
}
|
|
36
|
+
client.mutation(heartbeat, args, { shardKey }).catch(() => void 0);
|
|
37
|
+
};
|
|
38
|
+
const setData = (next) => {
|
|
39
|
+
latestData = next;
|
|
40
|
+
sendHeartbeat();
|
|
41
|
+
};
|
|
42
|
+
if (shouldOpenSubscription(fromInjectionContext)) {
|
|
43
|
+
const releaseConnectionContext = client.acquireConnectionContext({ roomId, sessionId }, { shardKey });
|
|
44
|
+
sendHeartbeat();
|
|
45
|
+
const onVisible = () => {
|
|
46
|
+
if (typeof document !== "undefined" && document.visibilityState === "visible") {
|
|
47
|
+
sendHeartbeat();
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
const intervalHandle = runOutsideAngular(fromInjectionContext, () => {
|
|
51
|
+
if (typeof document !== "undefined") {
|
|
52
|
+
document.addEventListener("visibilitychange", onVisible);
|
|
53
|
+
}
|
|
54
|
+
return setInterval(sendHeartbeat, intervalMs);
|
|
55
|
+
});
|
|
56
|
+
const listArgs = { roomId };
|
|
57
|
+
const unsubscribe = client.subscribe(
|
|
58
|
+
listPresent,
|
|
59
|
+
listArgs,
|
|
60
|
+
(value) => {
|
|
61
|
+
present.set(value);
|
|
62
|
+
},
|
|
63
|
+
{ shardKey }
|
|
64
|
+
);
|
|
65
|
+
destroyRef.onDestroy(() => {
|
|
66
|
+
clearInterval(intervalHandle);
|
|
67
|
+
if (typeof document !== "undefined") {
|
|
68
|
+
document.removeEventListener("visibilitychange", onVisible);
|
|
69
|
+
}
|
|
70
|
+
releaseConnectionContext();
|
|
71
|
+
unsubscribe();
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
return { present: present.asReadonly(), sessionId, setData };
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export { presence };
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { inject, DestroyRef, signal, computed } from '@angular/core';
|
|
2
|
+
import { evaluate } from '@lunora/ratelimit';
|
|
3
|
+
|
|
4
|
+
const rateLimit = (config, options = {}) => {
|
|
5
|
+
const destroyRef = options.destroyRef ?? inject(DestroyRef);
|
|
6
|
+
const now = options.now ?? Date.now;
|
|
7
|
+
const tickMs = options.tickMs ?? 1e3;
|
|
8
|
+
let value;
|
|
9
|
+
const computeStatus = () => evaluate(config, value, { consume: false, count: 1, now: now(), reserve: false }).status;
|
|
10
|
+
const status = signal(computeStatus());
|
|
11
|
+
const bump = () => {
|
|
12
|
+
status.set(computeStatus());
|
|
13
|
+
};
|
|
14
|
+
let intervalHandle;
|
|
15
|
+
const stopInterval = () => {
|
|
16
|
+
if (intervalHandle !== void 0) {
|
|
17
|
+
clearInterval(intervalHandle);
|
|
18
|
+
intervalHandle = void 0;
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
const startIntervalIfThrottled = () => {
|
|
22
|
+
if (status().ok || intervalHandle !== void 0) {
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
intervalHandle = setInterval(() => {
|
|
26
|
+
bump();
|
|
27
|
+
if (status().ok) {
|
|
28
|
+
stopInterval();
|
|
29
|
+
}
|
|
30
|
+
}, tickMs);
|
|
31
|
+
};
|
|
32
|
+
startIntervalIfThrottled();
|
|
33
|
+
destroyRef.onDestroy(() => {
|
|
34
|
+
stopInterval();
|
|
35
|
+
});
|
|
36
|
+
return {
|
|
37
|
+
check: (count = 1) => evaluate(config, value, { consume: false, count, now: now(), reserve: false }).status.ok,
|
|
38
|
+
consume: (count = 1) => {
|
|
39
|
+
const result = evaluate(config, value, { consume: true, count, now: now(), reserve: false });
|
|
40
|
+
if (result.value !== void 0) {
|
|
41
|
+
value = result.value;
|
|
42
|
+
}
|
|
43
|
+
bump();
|
|
44
|
+
startIntervalIfThrottled();
|
|
45
|
+
return result.status;
|
|
46
|
+
},
|
|
47
|
+
disabled: computed(() => !status().ok),
|
|
48
|
+
ok: computed(() => status().ok),
|
|
49
|
+
reset: () => {
|
|
50
|
+
value = void 0;
|
|
51
|
+
stopInterval();
|
|
52
|
+
bump();
|
|
53
|
+
},
|
|
54
|
+
retryAfter: computed(() => status().retryAfter)
|
|
55
|
+
};
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export { rateLimit };
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { inject, DestroyRef, signal } from '@angular/core';
|
|
2
|
+
import { resolveLunoraClient } from './LUNORA_CLIENT-DHUfNu9x.mjs';
|
|
3
|
+
|
|
4
|
+
const stream = (reference, args, options = {}) => {
|
|
5
|
+
const client = resolveLunoraClient(options.client);
|
|
6
|
+
const destroyRef = options.destroyRef ?? inject(DestroyRef);
|
|
7
|
+
const chunks = signal([]);
|
|
8
|
+
const error = signal(void 0);
|
|
9
|
+
const status = signal("idle");
|
|
10
|
+
let active = true;
|
|
11
|
+
let cancelIterable;
|
|
12
|
+
const cancel = () => {
|
|
13
|
+
active = false;
|
|
14
|
+
cancelIterable?.();
|
|
15
|
+
};
|
|
16
|
+
if (args !== "skip") {
|
|
17
|
+
status.set("streaming");
|
|
18
|
+
const iterable = client.stream(reference, args, { maxBuffer: options.maxBuffer, shardKey: options.shardKey });
|
|
19
|
+
cancelIterable = () => {
|
|
20
|
+
iterable.cancel();
|
|
21
|
+
};
|
|
22
|
+
(async () => {
|
|
23
|
+
try {
|
|
24
|
+
for await (const chunk of iterable) {
|
|
25
|
+
if (!active) {
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
chunks.update((current) => [...current, chunk]);
|
|
29
|
+
}
|
|
30
|
+
if (active) {
|
|
31
|
+
status.set("complete");
|
|
32
|
+
}
|
|
33
|
+
} catch (streamError) {
|
|
34
|
+
if (!active) {
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
error.set(streamError instanceof Error ? streamError : new Error(String(streamError)));
|
|
38
|
+
status.set("error");
|
|
39
|
+
}
|
|
40
|
+
})().catch(() => {
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
destroyRef.onDestroy(cancel);
|
|
44
|
+
return { cancel, chunks: chunks.asReadonly(), error: error.asReadonly(), status: status.asReadonly() };
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export { stream };
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { inject, DestroyRef, signal } from '@angular/core';
|
|
2
|
+
import { createQuerySubscription } from '@lunora/client/query';
|
|
3
|
+
import { resolveLunoraClient } from './LUNORA_CLIENT-DHUfNu9x.mjs';
|
|
4
|
+
import { s as shouldOpenSubscription } from './platform-Dg8Bppgq.mjs';
|
|
5
|
+
|
|
6
|
+
const subscription = (reference, args, options = {}) => {
|
|
7
|
+
const client = resolveLunoraClient(options.client);
|
|
8
|
+
const fromInjectionContext = options.destroyRef === void 0;
|
|
9
|
+
const destroyRef = options.destroyRef ?? inject(DestroyRef);
|
|
10
|
+
const data = signal(void 0);
|
|
11
|
+
const error = signal(void 0);
|
|
12
|
+
if (args !== "skip" && shouldOpenSubscription(fromInjectionContext)) {
|
|
13
|
+
const userOnError = options.onError;
|
|
14
|
+
const unsubscribe = createQuerySubscription(
|
|
15
|
+
client,
|
|
16
|
+
reference,
|
|
17
|
+
args,
|
|
18
|
+
{
|
|
19
|
+
onData: (next) => {
|
|
20
|
+
data.set(next);
|
|
21
|
+
error.set(void 0);
|
|
22
|
+
},
|
|
23
|
+
onError: (error_) => {
|
|
24
|
+
error.set(error_);
|
|
25
|
+
data.set(void 0);
|
|
26
|
+
userOnError?.(error_);
|
|
27
|
+
},
|
|
28
|
+
onReset: () => {
|
|
29
|
+
data.set(void 0);
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
{ shardKey: options.shardKey }
|
|
33
|
+
);
|
|
34
|
+
destroyRef.onDestroy(unsubscribe);
|
|
35
|
+
}
|
|
36
|
+
return { data: data.asReadonly(), error: error.asReadonly() };
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export { subscription };
|
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
import { inject, DestroyRef, signal } from '@angular/core';
|
|
2
|
+
import { resolveLunoraClient } from './LUNORA_CLIENT-DHUfNu9x.mjs';
|
|
3
|
+
|
|
4
|
+
const TARGET_SAMPLE_RATE = 16e3;
|
|
5
|
+
const blockRms = (samples) => {
|
|
6
|
+
if (samples.length === 0) {
|
|
7
|
+
return 0;
|
|
8
|
+
}
|
|
9
|
+
let sum = 0;
|
|
10
|
+
for (const sample of samples) {
|
|
11
|
+
sum += sample * sample;
|
|
12
|
+
}
|
|
13
|
+
return Math.sqrt(sum / samples.length);
|
|
14
|
+
};
|
|
15
|
+
const toPcm16 = (samples, inputSampleRate) => {
|
|
16
|
+
const ratio = inputSampleRate / TARGET_SAMPLE_RATE;
|
|
17
|
+
const outLength = ratio > 1 ? Math.floor(samples.length / ratio) : samples.length;
|
|
18
|
+
const buffer = new ArrayBuffer(outLength * 2);
|
|
19
|
+
const view = new DataView(buffer);
|
|
20
|
+
for (let index = 0; index < outLength; index += 1) {
|
|
21
|
+
const sample = samples[Math.floor(index * ratio)] ?? 0;
|
|
22
|
+
const clamped = Math.max(-1, Math.min(1, sample));
|
|
23
|
+
view.setInt16(index * 2, clamped < 0 ? clamped * 32768 : clamped * 32767, true);
|
|
24
|
+
}
|
|
25
|
+
return new Uint8Array(buffer);
|
|
26
|
+
};
|
|
27
|
+
const createBrowserMicrophone = async (config) => {
|
|
28
|
+
const media = globalThis;
|
|
29
|
+
const getUserMedia = media.navigator?.mediaDevices?.getUserMedia.bind(media.navigator.mediaDevices);
|
|
30
|
+
const AudioContextClass = media.AudioContext ?? media.webkitAudioContext;
|
|
31
|
+
if (!getUserMedia || !AudioContextClass) {
|
|
32
|
+
throw new Error("voiceAgent: microphone capture requires getUserMedia + AudioContext (no browser audio available)");
|
|
33
|
+
}
|
|
34
|
+
const stream = await getUserMedia({ audio: { channelCount: 1, echoCancellation: true, noiseSuppression: true } });
|
|
35
|
+
const context = new AudioContextClass();
|
|
36
|
+
const source = context.createMediaStreamSource(stream);
|
|
37
|
+
const processor = context.createScriptProcessor(4096, 1, 1);
|
|
38
|
+
let muted = false;
|
|
39
|
+
let sawSpeech = false;
|
|
40
|
+
let silentFor = 0;
|
|
41
|
+
let loudChunks = 0;
|
|
42
|
+
processor.onaudioprocess = (event) => {
|
|
43
|
+
const samples = event.inputBuffer.getChannelData(0);
|
|
44
|
+
const rms = muted ? 0 : blockRms(samples);
|
|
45
|
+
config.onLevel(rms);
|
|
46
|
+
if (muted) {
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
config.onAudio(toPcm16(samples, context.sampleRate));
|
|
50
|
+
if (config.isSpeaking()) {
|
|
51
|
+
loudChunks = rms >= config.interruptThreshold ? loudChunks + 1 : 0;
|
|
52
|
+
if (loudChunks >= config.interruptChunks) {
|
|
53
|
+
loudChunks = 0;
|
|
54
|
+
config.onInterrupt();
|
|
55
|
+
}
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
loudChunks = 0;
|
|
59
|
+
const chunkMs = samples.length / context.sampleRate * 1e3;
|
|
60
|
+
if (rms >= config.silenceThreshold) {
|
|
61
|
+
sawSpeech = true;
|
|
62
|
+
silentFor = 0;
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
if (sawSpeech) {
|
|
66
|
+
silentFor += chunkMs;
|
|
67
|
+
if (silentFor >= config.silenceDurationMs) {
|
|
68
|
+
sawSpeech = false;
|
|
69
|
+
silentFor = 0;
|
|
70
|
+
config.onSilence();
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
source.connect(processor);
|
|
75
|
+
processor.connect(context.destination);
|
|
76
|
+
return {
|
|
77
|
+
setMuted: (next) => {
|
|
78
|
+
muted = next;
|
|
79
|
+
},
|
|
80
|
+
stop: () => {
|
|
81
|
+
processor.disconnect();
|
|
82
|
+
source.disconnect();
|
|
83
|
+
for (const track of stream.getTracks()) {
|
|
84
|
+
track.stop();
|
|
85
|
+
}
|
|
86
|
+
void context.close();
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
};
|
|
90
|
+
const createBrowserSpeaker = () => {
|
|
91
|
+
const media = globalThis;
|
|
92
|
+
const AudioContextClass = media.AudioContext ?? media.webkitAudioContext;
|
|
93
|
+
if (!AudioContextClass) {
|
|
94
|
+
throw new Error("voiceAgent: audio playback requires AudioContext (no browser audio available)");
|
|
95
|
+
}
|
|
96
|
+
const context = new AudioContextClass();
|
|
97
|
+
const sources = /* @__PURE__ */ new Set();
|
|
98
|
+
let playHead = 0;
|
|
99
|
+
let chain = Promise.resolve();
|
|
100
|
+
let generation = 0;
|
|
101
|
+
const scheduleChunk = async (bytes, scheduledGeneration) => {
|
|
102
|
+
if (scheduledGeneration !== generation) {
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
let decoded;
|
|
106
|
+
try {
|
|
107
|
+
decoded = await context.decodeAudioData(bytes.buffer);
|
|
108
|
+
} catch {
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
if (scheduledGeneration !== generation) {
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
const node = context.createBufferSource();
|
|
115
|
+
node.buffer = decoded;
|
|
116
|
+
node.connect(context.destination);
|
|
117
|
+
const startAt = Math.max(context.currentTime, playHead);
|
|
118
|
+
node.start(startAt);
|
|
119
|
+
playHead = startAt + decoded.duration;
|
|
120
|
+
sources.add(node);
|
|
121
|
+
node.onended = () => {
|
|
122
|
+
sources.delete(node);
|
|
123
|
+
};
|
|
124
|
+
};
|
|
125
|
+
const enqueue = (audio) => {
|
|
126
|
+
const bytes = Uint8Array.from(audio);
|
|
127
|
+
const scheduledGeneration = generation;
|
|
128
|
+
chain = chain.then(() => scheduleChunk(bytes, scheduledGeneration));
|
|
129
|
+
};
|
|
130
|
+
const interrupt = () => {
|
|
131
|
+
generation += 1;
|
|
132
|
+
for (const node of sources) {
|
|
133
|
+
try {
|
|
134
|
+
node.stop();
|
|
135
|
+
} catch {
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
sources.clear();
|
|
139
|
+
playHead = context.currentTime;
|
|
140
|
+
};
|
|
141
|
+
return {
|
|
142
|
+
enqueue,
|
|
143
|
+
interrupt,
|
|
144
|
+
stop: () => {
|
|
145
|
+
interrupt();
|
|
146
|
+
void context.close();
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
const WS_OPEN = 1;
|
|
152
|
+
const DEFAULT_SILENCE_THRESHOLD = 0.01;
|
|
153
|
+
const DEFAULT_SILENCE_DURATION_MS = 1200;
|
|
154
|
+
const DEFAULT_INTERRUPT_THRESHOLD = 0.15;
|
|
155
|
+
const DEFAULT_INTERRUPT_CHUNKS = 3;
|
|
156
|
+
const deriveWebSocketUrl = (url) => {
|
|
157
|
+
if (url.startsWith("https://")) {
|
|
158
|
+
return `wss://${url.slice("https://".length)}`;
|
|
159
|
+
}
|
|
160
|
+
if (url.startsWith("http://")) {
|
|
161
|
+
return `ws://${url.slice("http://".length)}`;
|
|
162
|
+
}
|
|
163
|
+
return url;
|
|
164
|
+
};
|
|
165
|
+
const agentNameFromReference = (voice) => {
|
|
166
|
+
const reference = voice["__lunoraRef"];
|
|
167
|
+
const withoutNamespace = reference.startsWith("agents:") ? reference.slice("agents:".length) : reference;
|
|
168
|
+
return withoutNamespace.endsWith("Voice") ? withoutNamespace.slice(0, -"Voice".length) : withoutNamespace;
|
|
169
|
+
};
|
|
170
|
+
const voiceSocketUrl = (baseUrl, agent, threadKey) => {
|
|
171
|
+
const base = deriveWebSocketUrl(baseUrl);
|
|
172
|
+
const trimmed = base.endsWith("/") ? base.slice(0, -1) : base;
|
|
173
|
+
const search = new URLSearchParams({ threadKey });
|
|
174
|
+
return `${trimmed}/_lunora/voice/${encodeURIComponent(agent)}?${search.toString()}`;
|
|
175
|
+
};
|
|
176
|
+
const voiceAgent = (options) => {
|
|
177
|
+
const {
|
|
178
|
+
createMicrophone = createBrowserMicrophone,
|
|
179
|
+
createSpeaker = createBrowserSpeaker,
|
|
180
|
+
createSocket,
|
|
181
|
+
interruptChunks = DEFAULT_INTERRUPT_CHUNKS,
|
|
182
|
+
interruptThreshold = DEFAULT_INTERRUPT_THRESHOLD,
|
|
183
|
+
silenceDurationMs = DEFAULT_SILENCE_DURATION_MS,
|
|
184
|
+
silenceThreshold = DEFAULT_SILENCE_THRESHOLD,
|
|
185
|
+
threadKey,
|
|
186
|
+
voice
|
|
187
|
+
} = options;
|
|
188
|
+
const client = resolveLunoraClient(options.client);
|
|
189
|
+
const destroyRef = options.destroyRef ?? inject(DestroyRef);
|
|
190
|
+
const status = signal("idle");
|
|
191
|
+
const connected = signal(false);
|
|
192
|
+
const transcript = signal("");
|
|
193
|
+
const interimTranscript = signal("");
|
|
194
|
+
const audioLevel = signal(0);
|
|
195
|
+
const isMuted = signal(false);
|
|
196
|
+
const error = signal(void 0);
|
|
197
|
+
let current;
|
|
198
|
+
let starting = false;
|
|
199
|
+
const sendFrame = (frame) => {
|
|
200
|
+
const socket = current?.socket;
|
|
201
|
+
if (socket?.readyState === WS_OPEN) {
|
|
202
|
+
socket.send(JSON.stringify(frame));
|
|
203
|
+
return true;
|
|
204
|
+
}
|
|
205
|
+
return false;
|
|
206
|
+
};
|
|
207
|
+
const teardown = () => {
|
|
208
|
+
const connection = current;
|
|
209
|
+
current = void 0;
|
|
210
|
+
if (connection) {
|
|
211
|
+
connection.microphone?.stop();
|
|
212
|
+
connection.speaker?.stop();
|
|
213
|
+
try {
|
|
214
|
+
connection.socket.close();
|
|
215
|
+
} catch {
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
starting = false;
|
|
219
|
+
connected.set(false);
|
|
220
|
+
status.set("idle");
|
|
221
|
+
audioLevel.set(0);
|
|
222
|
+
};
|
|
223
|
+
const endCall = () => {
|
|
224
|
+
teardown();
|
|
225
|
+
};
|
|
226
|
+
const handleServerFrame = (frame) => {
|
|
227
|
+
const connection = current;
|
|
228
|
+
switch (frame.type) {
|
|
229
|
+
case "assistant_delta": {
|
|
230
|
+
if (connection) {
|
|
231
|
+
connection.speaking = true;
|
|
232
|
+
}
|
|
233
|
+
status.set("speaking");
|
|
234
|
+
interimTranscript.update((current_) => current_ + frame.text);
|
|
235
|
+
break;
|
|
236
|
+
}
|
|
237
|
+
case "assistant_done": {
|
|
238
|
+
if (connection) {
|
|
239
|
+
connection.speaking = false;
|
|
240
|
+
}
|
|
241
|
+
interimTranscript.set(frame.text);
|
|
242
|
+
status.set("listening");
|
|
243
|
+
break;
|
|
244
|
+
}
|
|
245
|
+
case "error": {
|
|
246
|
+
if (connection) {
|
|
247
|
+
connection.speaking = false;
|
|
248
|
+
}
|
|
249
|
+
error.set(new Error(frame.message));
|
|
250
|
+
status.set("listening");
|
|
251
|
+
break;
|
|
252
|
+
}
|
|
253
|
+
case "interrupted": {
|
|
254
|
+
if (connection) {
|
|
255
|
+
connection.speaking = false;
|
|
256
|
+
connection.suppressAudio = false;
|
|
257
|
+
}
|
|
258
|
+
connection?.speaker?.interrupt();
|
|
259
|
+
status.set("listening");
|
|
260
|
+
break;
|
|
261
|
+
}
|
|
262
|
+
case "ready": {
|
|
263
|
+
if (connection) {
|
|
264
|
+
connection.audioFormat = frame.audioFormat;
|
|
265
|
+
connection.suppressAudio = false;
|
|
266
|
+
}
|
|
267
|
+
connected.set(true);
|
|
268
|
+
status.set("listening");
|
|
269
|
+
break;
|
|
270
|
+
}
|
|
271
|
+
case "user_transcript": {
|
|
272
|
+
if (connection) {
|
|
273
|
+
connection.suppressAudio = false;
|
|
274
|
+
}
|
|
275
|
+
transcript.set(frame.text);
|
|
276
|
+
interimTranscript.set("");
|
|
277
|
+
status.set("thinking");
|
|
278
|
+
break;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
};
|
|
282
|
+
const handleAudioChunk = (audio) => {
|
|
283
|
+
const connection = current;
|
|
284
|
+
if (!connection || connection.suppressAudio) {
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
connection.speaker ??= createSpeaker({ audioFormat: connection.audioFormat });
|
|
288
|
+
connection.speaking = true;
|
|
289
|
+
status.set("speaking");
|
|
290
|
+
connection.speaker.enqueue(audio);
|
|
291
|
+
};
|
|
292
|
+
const startCall = async () => {
|
|
293
|
+
if (current || starting) {
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
starting = true;
|
|
297
|
+
error.set(void 0);
|
|
298
|
+
transcript.set("");
|
|
299
|
+
interimTranscript.set("");
|
|
300
|
+
try {
|
|
301
|
+
const url = voiceSocketUrl(client.url, agentNameFromReference(voice), threadKey);
|
|
302
|
+
const openSocket = createSocket ?? ((target) => new globalThis.WebSocket(target));
|
|
303
|
+
const socket = openSocket(url);
|
|
304
|
+
socket.binaryType = "arraybuffer";
|
|
305
|
+
const connection = {
|
|
306
|
+
audioFormat: "mp3",
|
|
307
|
+
microphone: void 0,
|
|
308
|
+
socket,
|
|
309
|
+
speaker: void 0,
|
|
310
|
+
speaking: false,
|
|
311
|
+
suppressAudio: false
|
|
312
|
+
};
|
|
313
|
+
current = connection;
|
|
314
|
+
socket.onmessage = (event) => {
|
|
315
|
+
if (typeof event.data === "string") {
|
|
316
|
+
try {
|
|
317
|
+
handleServerFrame(JSON.parse(event.data));
|
|
318
|
+
} catch {
|
|
319
|
+
}
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
handleAudioChunk(new Uint8Array(event.data));
|
|
323
|
+
};
|
|
324
|
+
socket.onerror = () => {
|
|
325
|
+
error.set(new Error("voiceAgent: voice socket error"));
|
|
326
|
+
};
|
|
327
|
+
socket.onclose = () => {
|
|
328
|
+
if (current === connection) {
|
|
329
|
+
teardown();
|
|
330
|
+
}
|
|
331
|
+
};
|
|
332
|
+
const microphone = await createMicrophone({
|
|
333
|
+
interruptChunks,
|
|
334
|
+
interruptThreshold,
|
|
335
|
+
isSpeaking: () => current?.speaking ?? false,
|
|
336
|
+
onAudio: (pcm) => {
|
|
337
|
+
if (socket.readyState === WS_OPEN) {
|
|
338
|
+
socket.send(pcm);
|
|
339
|
+
}
|
|
340
|
+
},
|
|
341
|
+
onInterrupt: () => {
|
|
342
|
+
sendFrame({ type: "interrupt" });
|
|
343
|
+
current?.speaker?.interrupt();
|
|
344
|
+
if (current) {
|
|
345
|
+
current.speaking = false;
|
|
346
|
+
current.suppressAudio = true;
|
|
347
|
+
}
|
|
348
|
+
status.set("listening");
|
|
349
|
+
},
|
|
350
|
+
onLevel: (rms) => {
|
|
351
|
+
audioLevel.set(rms);
|
|
352
|
+
},
|
|
353
|
+
onSilence: () => {
|
|
354
|
+
sendFrame({ type: "commit" });
|
|
355
|
+
status.set("thinking");
|
|
356
|
+
},
|
|
357
|
+
silenceDurationMs,
|
|
358
|
+
silenceThreshold
|
|
359
|
+
});
|
|
360
|
+
if (current === connection) {
|
|
361
|
+
connection.microphone = microphone;
|
|
362
|
+
isMuted.set(false);
|
|
363
|
+
status.set("listening");
|
|
364
|
+
} else {
|
|
365
|
+
microphone.stop();
|
|
366
|
+
}
|
|
367
|
+
} catch (error_) {
|
|
368
|
+
error.set(error_ instanceof Error ? error_ : new Error(String(error_)));
|
|
369
|
+
teardown();
|
|
370
|
+
} finally {
|
|
371
|
+
starting = false;
|
|
372
|
+
}
|
|
373
|
+
};
|
|
374
|
+
const toggleMute = () => {
|
|
375
|
+
const next = !isMuted();
|
|
376
|
+
current?.microphone?.setMuted(next);
|
|
377
|
+
isMuted.set(next);
|
|
378
|
+
return next;
|
|
379
|
+
};
|
|
380
|
+
const sendText = (text) => {
|
|
381
|
+
if (sendFrame({ text, type: "text" })) {
|
|
382
|
+
status.set("thinking");
|
|
383
|
+
}
|
|
384
|
+
};
|
|
385
|
+
destroyRef.onDestroy(teardown);
|
|
386
|
+
return {
|
|
387
|
+
audioLevel: audioLevel.asReadonly(),
|
|
388
|
+
connected: connected.asReadonly(),
|
|
389
|
+
endCall,
|
|
390
|
+
error: error.asReadonly(),
|
|
391
|
+
interimTranscript: interimTranscript.asReadonly(),
|
|
392
|
+
isMuted: isMuted.asReadonly(),
|
|
393
|
+
sendText,
|
|
394
|
+
startCall,
|
|
395
|
+
status: status.asReadonly(),
|
|
396
|
+
toggleMute,
|
|
397
|
+
transcript: transcript.asReadonly()
|
|
398
|
+
};
|
|
399
|
+
};
|
|
400
|
+
|
|
401
|
+
export { voiceAgent };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lunora/angular",
|
|
3
|
-
"version": "1.0.0-alpha.
|
|
3
|
+
"version": "1.0.0-alpha.10",
|
|
4
4
|
"description": "Angular reactive adapter for Lunora — signal-based live queries and mutations",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"angular",
|
|
@@ -45,10 +45,11 @@
|
|
|
45
45
|
"access": "public"
|
|
46
46
|
},
|
|
47
47
|
"dependencies": {
|
|
48
|
-
"@lunora/client": "1.0.0-alpha.
|
|
48
|
+
"@lunora/client": "1.0.0-alpha.24",
|
|
49
|
+
"@lunora/ratelimit": "1.0.0-alpha.8"
|
|
49
50
|
},
|
|
50
51
|
"peerDependencies": {
|
|
51
|
-
"@angular/core": "^19.2.0 || ^20.0.0"
|
|
52
|
+
"@angular/core": "^19.2.0 || ^20.0.0 || ^21.0.0 || ^22.0.0"
|
|
52
53
|
},
|
|
53
54
|
"peerDependenciesMeta": {
|
|
54
55
|
"@angular/core": {
|
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
import { inject, DestroyRef, signal } from '@angular/core';
|
|
2
|
-
import { createQuerySubscription } from '@lunora/client/query';
|
|
3
|
-
import { resolveLunoraClient } from './LUNORA_CLIENT-DHUfNu9x.mjs';
|
|
4
|
-
|
|
5
|
-
const liveQuery = (reference, args, options = {}) => {
|
|
6
|
-
const client = resolveLunoraClient(options.client);
|
|
7
|
-
const destroyRef = options.destroyRef ?? inject(DestroyRef);
|
|
8
|
-
const value = signal(void 0);
|
|
9
|
-
const unsubscribe = createQuerySubscription(
|
|
10
|
-
client,
|
|
11
|
-
reference,
|
|
12
|
-
args,
|
|
13
|
-
{
|
|
14
|
-
onData: (next) => {
|
|
15
|
-
value.set(next);
|
|
16
|
-
},
|
|
17
|
-
onError: options.onError,
|
|
18
|
-
onReset: () => {
|
|
19
|
-
value.set(void 0);
|
|
20
|
-
}
|
|
21
|
-
},
|
|
22
|
-
{ shardKey: options.shardKey }
|
|
23
|
-
);
|
|
24
|
-
destroyRef.onDestroy(unsubscribe);
|
|
25
|
-
return value.asReadonly();
|
|
26
|
-
};
|
|
27
|
-
|
|
28
|
-
export { liveQuery };
|