@vortexm/vjt 0.1.14 → 0.1.16

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/dist/index.js CHANGED
@@ -3865,7 +3865,7 @@ var NetworkRuntime = class {
3865
3865
  this.rerenderRoot = options.rerenderRoot;
3866
3866
  }
3867
3867
  connectSseStreams() {
3868
- if (typeof window === "undefined" || typeof EventSource === "undefined" || this.eventSources.length > 0) {
3868
+ if (typeof window === "undefined" || this.eventSources.length > 0) {
3869
3869
  return;
3870
3870
  }
3871
3871
  for (const config of this.sseConfigs) {
@@ -3877,26 +3877,10 @@ var NetworkRuntime = class {
3877
3877
  logRuntimeError("sseConfig", new Error("Blocked unsafe SSE url"), { url: config.url });
3878
3878
  continue;
3879
3879
  }
3880
- const source = new EventSource(safeUrl);
3881
- this.eventSources.push(source);
3882
- for (const eventDefinition of config.events ?? []) {
3883
- if (!eventDefinition?.name) {
3884
- continue;
3885
- }
3886
- source.addEventListener(eventDefinition.name, (event) => {
3887
- void this.handleSseEvent(eventDefinition, event).catch((error) => {
3888
- logRuntimeError("handleSseEvent", error, {
3889
- eventName: eventDefinition.name,
3890
- url: safeUrl
3891
- });
3892
- });
3893
- });
3880
+ const connection = this.createSseConnection(config, safeUrl);
3881
+ if (connection) {
3882
+ this.eventSources.push(connection);
3894
3883
  }
3895
- source.onerror = (event) => {
3896
- logRuntimeError("sseConnection", event, {
3897
- url: config.url
3898
- });
3899
- };
3900
3884
  }
3901
3885
  }
3902
3886
  async executeRequest(requestName, currentValue) {
@@ -4099,21 +4083,297 @@ var NetworkRuntime = class {
4099
4083
  return this.stringifyPrimitive(value);
4100
4084
  }
4101
4085
  }
4102
- async handleSseEvent(eventDefinition, event) {
4086
+ createSseConnection(config, safeUrl) {
4087
+ if (typeof fetch === "function" && typeof AbortController !== "undefined" && typeof TextDecoder !== "undefined" && typeof ReadableStream !== "undefined") {
4088
+ return this.createFetchSseConnection(config, safeUrl);
4089
+ }
4090
+ if (typeof EventSource !== "undefined") {
4091
+ return this.createNativeSseConnection(config, safeUrl);
4092
+ }
4093
+ logRuntimeError("sseConnection", new Error("SSE is not supported in this browser"), {
4094
+ url: safeUrl
4095
+ });
4096
+ return null;
4097
+ }
4098
+ createNativeSseConnection(config, safeUrl) {
4099
+ const source = new EventSource(safeUrl);
4100
+ source.onopen = () => {
4101
+ logRuntimeDebug(this.debugLogging, "sse-open", {
4102
+ url: safeUrl,
4103
+ transport: "event-source"
4104
+ });
4105
+ };
4106
+ source.onmessage = (event) => {
4107
+ const payload = typeof event.data === "string" ? event.data : "";
4108
+ logRuntimeDebug(this.debugLogging, "sse-message", {
4109
+ url: safeUrl,
4110
+ transport: "event-source",
4111
+ eventName: "message",
4112
+ payloadLength: payload.length
4113
+ });
4114
+ };
4115
+ for (const eventDefinition of config.events ?? []) {
4116
+ if (!eventDefinition?.name) {
4117
+ continue;
4118
+ }
4119
+ source.addEventListener(eventDefinition.name, (event) => {
4120
+ void this.handleSseEvent(eventDefinition, event.data ?? "", safeUrl).catch((error) => {
4121
+ logRuntimeError("handleSseEvent", error, {
4122
+ eventName: eventDefinition.name,
4123
+ url: safeUrl
4124
+ });
4125
+ });
4126
+ });
4127
+ }
4128
+ source.onerror = (event) => {
4129
+ logRuntimeError("sseConnection", event, {
4130
+ url: config.url,
4131
+ transport: "event-source"
4132
+ });
4133
+ };
4134
+ logRuntimeDebug(this.debugLogging, "sse-connect", {
4135
+ url: safeUrl,
4136
+ transport: "event-source"
4137
+ });
4138
+ return {
4139
+ close: () => {
4140
+ source.close();
4141
+ }
4142
+ };
4143
+ }
4144
+ createFetchSseConnection(config, safeUrl) {
4145
+ let closed = false;
4146
+ let reconnectDelayMs = 2e3;
4147
+ let reconnectTimeoutId = null;
4148
+ let activeAbortController = null;
4149
+ let lastEventId = "";
4150
+ let connectionAttempt = 0;
4151
+ const clearReconnectTimeout = () => {
4152
+ if (reconnectTimeoutId !== null && typeof window !== "undefined") {
4153
+ window.clearTimeout(reconnectTimeoutId);
4154
+ }
4155
+ reconnectTimeoutId = null;
4156
+ };
4157
+ const scheduleReconnect = (reason) => {
4158
+ if (closed || typeof window === "undefined") {
4159
+ return;
4160
+ }
4161
+ logRuntimeDebug(this.debugLogging, "sse-reconnect-scheduled", {
4162
+ url: safeUrl,
4163
+ transport: "fetch",
4164
+ delayMs: reconnectDelayMs,
4165
+ reason,
4166
+ lastEventId
4167
+ });
4168
+ clearReconnectTimeout();
4169
+ reconnectTimeoutId = window.setTimeout(() => {
4170
+ reconnectTimeoutId = null;
4171
+ void connectLoop().catch((error) => {
4172
+ logRuntimeError("sseConnection", error, {
4173
+ url: safeUrl,
4174
+ transport: "fetch"
4175
+ });
4176
+ });
4177
+ }, reconnectDelayMs);
4178
+ };
4179
+ const flushSseChunk = (eventName, dataLines) => {
4180
+ if (!dataLines.length) {
4181
+ return;
4182
+ }
4183
+ const payload = dataLines.join("\n");
4184
+ const matchingDefinitions = (config.events ?? []).filter((definition) => definition?.name === eventName);
4185
+ logRuntimeDebug(this.debugLogging, "sse-dispatch", {
4186
+ url: safeUrl,
4187
+ transport: "fetch",
4188
+ eventName,
4189
+ payloadLength: payload.length,
4190
+ matchedHandlers: matchingDefinitions.length,
4191
+ lastEventId
4192
+ });
4193
+ if (!matchingDefinitions.length) {
4194
+ return;
4195
+ }
4196
+ for (const eventDefinition of matchingDefinitions) {
4197
+ void this.handleSseEvent(eventDefinition, payload, safeUrl).catch((error) => {
4198
+ logRuntimeError("handleSseEvent", error, {
4199
+ eventName,
4200
+ url: safeUrl
4201
+ });
4202
+ });
4203
+ }
4204
+ };
4205
+ const processSseText = (chunk) => {
4206
+ let eventName = "message";
4207
+ let dataLines = [];
4208
+ const normalized = chunk.replaceAll("\r\n", "\n").replaceAll("\r", "\n");
4209
+ const lines = normalized.split("\n");
4210
+ for (const line of lines) {
4211
+ if (!line) {
4212
+ flushSseChunk(eventName, dataLines);
4213
+ eventName = "message";
4214
+ dataLines = [];
4215
+ continue;
4216
+ }
4217
+ if (line.startsWith(":")) {
4218
+ continue;
4219
+ }
4220
+ const separatorIndex = line.indexOf(":");
4221
+ const field = separatorIndex >= 0 ? line.slice(0, separatorIndex) : line;
4222
+ let value = separatorIndex >= 0 ? line.slice(separatorIndex + 1) : "";
4223
+ if (value.startsWith(" ")) {
4224
+ value = value.slice(1);
4225
+ }
4226
+ switch (field) {
4227
+ case "event":
4228
+ eventName = value || "message";
4229
+ break;
4230
+ case "data":
4231
+ dataLines.push(value);
4232
+ break;
4233
+ case "id":
4234
+ if (!value.includes("\0")) {
4235
+ lastEventId = value;
4236
+ }
4237
+ break;
4238
+ case "retry": {
4239
+ const parsedDelay = Number.parseInt(value, 10);
4240
+ if (Number.isFinite(parsedDelay) && parsedDelay >= 0) {
4241
+ reconnectDelayMs = parsedDelay;
4242
+ }
4243
+ break;
4244
+ }
4245
+ default:
4246
+ break;
4247
+ }
4248
+ }
4249
+ flushSseChunk(eventName, dataLines);
4250
+ };
4251
+ const findEventBoundary = (value) => value.search(/\r?\n\r?\n/);
4252
+ const connectLoop = async () => {
4253
+ if (closed) {
4254
+ return;
4255
+ }
4256
+ connectionAttempt += 1;
4257
+ activeAbortController = new AbortController();
4258
+ let reconnectReason = "stream-ended";
4259
+ try {
4260
+ logRuntimeDebug(this.debugLogging, "sse-connect-attempt", {
4261
+ url: safeUrl,
4262
+ transport: "fetch",
4263
+ attempt: connectionAttempt,
4264
+ lastEventId
4265
+ });
4266
+ const headers = {
4267
+ Accept: "text/event-stream"
4268
+ };
4269
+ if (lastEventId) {
4270
+ headers["Last-Event-ID"] = lastEventId;
4271
+ }
4272
+ const response = await fetch(safeUrl, {
4273
+ method: "GET",
4274
+ headers,
4275
+ cache: "no-store",
4276
+ credentials: "same-origin",
4277
+ signal: activeAbortController.signal
4278
+ });
4279
+ if (!response.ok) {
4280
+ throw new Error(`SSE connection failed: ${response.status} ${response.statusText}`);
4281
+ }
4282
+ const contentType = response.headers.get("content-type") ?? "";
4283
+ if (!contentType.toLowerCase().includes("text/event-stream")) {
4284
+ throw new Error(`SSE response has unexpected content-type: ${contentType || "<empty>"}`);
4285
+ }
4286
+ logRuntimeDebug(this.debugLogging, "sse-open", {
4287
+ url: safeUrl,
4288
+ transport: "fetch",
4289
+ status: response.status,
4290
+ contentType,
4291
+ attempt: connectionAttempt,
4292
+ lastEventId,
4293
+ requestUsesLastEventIdHeader: Boolean(lastEventId)
4294
+ });
4295
+ if (!response.body || typeof response.body.getReader !== "function") {
4296
+ throw new Error("SSE streaming is not available in fetch response body");
4297
+ }
4298
+ const reader = response.body.getReader();
4299
+ const decoder = new TextDecoder();
4300
+ let buffer = "";
4301
+ let receivedChunkCount = 0;
4302
+ let receivedByteCount = 0;
4303
+ while (!closed) {
4304
+ const { done, value } = await reader.read();
4305
+ if (done) {
4306
+ break;
4307
+ }
4308
+ receivedChunkCount += 1;
4309
+ receivedByteCount += value?.byteLength ?? 0;
4310
+ buffer += decoder.decode(value, { stream: true });
4311
+ logRuntimeDebug(this.debugLogging, "sse-chunk", {
4312
+ url: safeUrl,
4313
+ transport: "fetch",
4314
+ chunkIndex: receivedChunkCount,
4315
+ chunkBytes: value?.byteLength ?? 0,
4316
+ totalBytes: receivedByteCount,
4317
+ bufferedChars: buffer.length
4318
+ });
4319
+ let boundaryIndex = findEventBoundary(buffer);
4320
+ while (boundaryIndex >= 0) {
4321
+ const rawChunk = buffer.slice(0, boundaryIndex);
4322
+ processSseText(rawChunk);
4323
+ buffer = buffer.slice(boundaryIndex).replace(/^\r?\n\r?\n/, "");
4324
+ boundaryIndex = findEventBoundary(buffer);
4325
+ }
4326
+ }
4327
+ buffer += decoder.decode();
4328
+ if (buffer.trim()) {
4329
+ processSseText(buffer);
4330
+ }
4331
+ } catch (error) {
4332
+ if (closed || error instanceof DOMException && error.name === "AbortError") {
4333
+ return;
4334
+ }
4335
+ reconnectReason = error instanceof Error ? error.message : "stream-error";
4336
+ logRuntimeError("sseConnection", error, {
4337
+ url: safeUrl,
4338
+ transport: "fetch"
4339
+ });
4340
+ } finally {
4341
+ activeAbortController = null;
4342
+ }
4343
+ scheduleReconnect(reconnectReason);
4344
+ };
4345
+ const connection = {
4346
+ close: () => {
4347
+ closed = true;
4348
+ clearReconnectTimeout();
4349
+ activeAbortController?.abort();
4350
+ activeAbortController = null;
4351
+ }
4352
+ };
4353
+ void connectLoop().catch((error) => {
4354
+ logRuntimeError("sseConnection", error, {
4355
+ url: safeUrl,
4356
+ transport: "fetch"
4357
+ });
4358
+ });
4359
+ return connection;
4360
+ }
4361
+ async handleSseEvent(eventDefinition, rawData, url) {
4103
4362
  let parsedMessage = {};
4104
4363
  try {
4105
- parsedMessage = event.data ? JSON.parse(event.data) : {};
4364
+ parsedMessage = rawData ? JSON.parse(rawData) : {};
4106
4365
  } catch {
4107
- parsedMessage = { value: event.data ?? "" };
4366
+ parsedMessage = { value: rawData ?? "" };
4108
4367
  }
4109
4368
  const message = eventDefinition.message ? sanitizeSchemaValue(eventDefinition.message, parsedMessage, `sse.${eventDefinition.name}`) : (() => {
4110
4369
  throw new Error(`SSE event ${eventDefinition.name} must define message schema`);
4111
4370
  })();
4112
4371
  logRuntimeDebug(this.debugLogging, "sse", {
4113
4372
  eventName: eventDefinition.name,
4114
- raw: event.data,
4373
+ raw: rawData,
4115
4374
  parsedMessage,
4116
- message
4375
+ message,
4376
+ url
4117
4377
  });
4118
4378
  if (!eventDefinition.onEvent?.length) {
4119
4379
  return;
@@ -4479,6 +4739,10 @@ var ReferenceRuntime = class {
4479
4739
  return readPath(state, ["value"]);
4480
4740
  case "isHidden":
4481
4741
  return state.isHidden ?? true;
4742
+ case "isScrolledToTop":
4743
+ return state.isScrolledToTop ?? true;
4744
+ case "isScrolledToBottom":
4745
+ return state.isScrolledToBottom ?? true;
4482
4746
  case "url":
4483
4747
  return resolveInlineI18nValue(this.host, state.url ?? "");
4484
4748
  case "base64":
@@ -5869,8 +6133,9 @@ var MAX_RECORDING_MS = 10 * 60 * 1e3;
5869
6133
  var MAX_LISTENING_SILENCE_MS = 60 * 60 * 1e3;
5870
6134
  var SILENCE_STOP_MS = 2e3;
5871
6135
  var DEFAULT_SPEECH_THRESHOLD = 0.035;
5872
- var LISTENING_PRE_ROLL_MS = 300;
5873
- var LISTENING_RECORDER_TIMESLICE_MS = 100;
6136
+ var LISTENING_TARGET_PRE_ROLL_MS = 300;
6137
+ var LISTENING_SEGMENT_MS = 1e3;
6138
+ var LISTENING_SEGMENT_STEP_MS = 500;
5874
6139
  var RECORDING_MIME_CANDIDATES = [
5875
6140
  "audio/webm;codecs=opus",
5876
6141
  "audio/webm",
@@ -5951,10 +6216,10 @@ var VoiceRuntime = class {
5951
6216
  listenAnalyser = null;
5952
6217
  listenSource = null;
5953
6218
  listenFrameId = null;
5954
- listeningRecorder = null;
5955
- listeningRecorderMimeType = "audio/webm";
5956
- listeningPreRollChunks = [];
5957
- listeningCaptureChunks = [];
6219
+ listeningIdleSessions = [];
6220
+ listeningLaneTimeoutIds = [];
6221
+ listeningLaneIntervalIds = [];
6222
+ listeningPromotedSession = null;
5958
6223
  listeningCaptureActive = false;
5959
6224
  lastSpeechAt = 0;
5960
6225
  listeningStartedAt = 0;
@@ -6076,14 +6341,16 @@ var VoiceRuntime = class {
6076
6341
  analyser.fftSize = 2048;
6077
6342
  const source = context.createMediaStreamSource(stream);
6078
6343
  source.connect(analyser);
6079
- this.startListeningRecorder(stream);
6080
6344
  this.listening = true;
6081
6345
  this.listenContext = context;
6082
6346
  this.listenAnalyser = analyser;
6083
6347
  this.listenSource = source;
6348
+ this.listeningIdleSessions = [];
6349
+ this.listeningPromotedSession = null;
6084
6350
  this.lastSpeechAt = 0;
6085
6351
  this.listeningStartedAt = performance.now();
6086
6352
  this.speechEventTriggered = false;
6353
+ this.startListeningRecorderSchedule(stream);
6087
6354
  const sampleBuffer = new Uint8Array(analyser.fftSize);
6088
6355
  const step = async () => {
6089
6356
  if (!this.listening || !this.listenAnalyser) {
@@ -6147,9 +6414,8 @@ var VoiceRuntime = class {
6147
6414
  if (this.listeningCaptureActive) {
6148
6415
  await this.stopListeningCapture();
6149
6416
  }
6150
- this.stopListeningRecorder();
6151
- this.listeningPreRollChunks = [];
6152
- this.listeningCaptureChunks = [];
6417
+ this.clearListeningRecorderSchedule();
6418
+ await this.discardListeningIdleSessions();
6153
6419
  this.listeningCaptureActive = false;
6154
6420
  this.recordingStartedFromListening = false;
6155
6421
  this.releaseInputStreamIfIdle();
@@ -6231,7 +6497,6 @@ var VoiceRuntime = class {
6231
6497
  async handleRecordingError(error) {
6232
6498
  logRuntimeError("voice.recording", error);
6233
6499
  this.listeningCaptureActive = false;
6234
- this.listeningCaptureChunks = [];
6235
6500
  this.recordingStartedFromListening = false;
6236
6501
  await this.triggerSystemEvent("onRecordingError", this.normalizeErrorMessage(error));
6237
6502
  }
@@ -6277,89 +6542,194 @@ var VoiceRuntime = class {
6277
6542
  this.mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true });
6278
6543
  return this.mediaStream;
6279
6544
  }
6280
- startListeningRecorder(stream) {
6281
- if (this.listeningRecorder && this.listeningRecorder.state !== "inactive") {
6282
- return;
6545
+ startListeningRecorderSchedule(stream) {
6546
+ this.clearListeningRecorderSchedule();
6547
+ this.launchListeningRecorder(stream);
6548
+ const delayedStart = window.setTimeout(() => {
6549
+ if (!this.listening || this.listeningCaptureActive) {
6550
+ return;
6551
+ }
6552
+ this.launchListeningRecorder(stream);
6553
+ const laneB = window.setInterval(() => {
6554
+ if (!this.listening || this.listeningCaptureActive) {
6555
+ return;
6556
+ }
6557
+ this.launchListeningRecorder(stream);
6558
+ }, LISTENING_SEGMENT_MS);
6559
+ this.listeningLaneIntervalIds.push(laneB);
6560
+ }, LISTENING_SEGMENT_STEP_MS);
6561
+ this.listeningLaneTimeoutIds.push(delayedStart);
6562
+ const laneA = window.setInterval(() => {
6563
+ if (!this.listening || this.listeningCaptureActive) {
6564
+ return;
6565
+ }
6566
+ this.launchListeningRecorder(stream);
6567
+ }, LISTENING_SEGMENT_MS);
6568
+ this.listeningLaneIntervalIds.push(laneA);
6569
+ }
6570
+ clearListeningRecorderSchedule() {
6571
+ for (const timeoutId of this.listeningLaneTimeoutIds) {
6572
+ window.clearTimeout(timeoutId);
6573
+ }
6574
+ this.listeningLaneTimeoutIds = [];
6575
+ for (const intervalId of this.listeningLaneIntervalIds) {
6576
+ window.clearInterval(intervalId);
6283
6577
  }
6578
+ this.listeningLaneIntervalIds = [];
6579
+ }
6580
+ launchListeningRecorder(stream) {
6581
+ const session = this.createListeningRecorderSession(stream);
6582
+ this.listeningIdleSessions.push(session);
6583
+ session.recorder.start();
6584
+ session.stopTimeoutId = window.setTimeout(() => {
6585
+ if (!session.promoted && session.recorder.state !== "inactive") {
6586
+ session.recorder.stop();
6587
+ }
6588
+ }, LISTENING_SEGMENT_MS);
6589
+ }
6590
+ createListeningRecorderSession(stream) {
6284
6591
  if (typeof MediaRecorder === "undefined") {
6285
6592
  throw new Error("MediaRecorder is not supported in this browser");
6286
6593
  }
6287
6594
  const preferredMimeType = this.getPreferredRecordingMimeType();
6288
6595
  const options = preferredMimeType ? { mimeType: preferredMimeType } : void 0;
6289
6596
  const recorder = options ? new MediaRecorder(stream, options) : new MediaRecorder(stream);
6290
- this.listeningRecorder = recorder;
6291
- this.listeningRecorderMimeType = recorder.mimeType || preferredMimeType || "audio/webm";
6292
- this.listeningPreRollChunks = [];
6293
- this.listeningCaptureChunks = [];
6597
+ let resolveStopped = () => {
6598
+ };
6599
+ const stopped = new Promise((resolve) => {
6600
+ resolveStopped = resolve;
6601
+ });
6602
+ const session = {
6603
+ recorder,
6604
+ mimeType: recorder.mimeType || preferredMimeType || "audio/webm",
6605
+ startedAt: performance.now(),
6606
+ chunks: [],
6607
+ stopTimeoutId: null,
6608
+ promoted: false,
6609
+ discard: false,
6610
+ stopped,
6611
+ resolveStopped
6612
+ };
6294
6613
  recorder.ondataavailable = (event) => {
6295
- if (!event.data || event.data.size === 0) {
6296
- return;
6297
- }
6298
- const timestamp = performance.now();
6299
- if (this.listeningCaptureActive) {
6300
- this.listeningCaptureChunks.push(event.data);
6301
- return;
6302
- }
6303
- this.listeningPreRollChunks.push({ data: event.data, timestamp });
6304
- const cutoff = timestamp - LISTENING_PRE_ROLL_MS;
6305
- while (this.listeningPreRollChunks.length > 0 && this.listeningPreRollChunks[0].timestamp < cutoff) {
6306
- this.listeningPreRollChunks.shift();
6614
+ if (event.data && event.data.size > 0) {
6615
+ session.chunks.push(event.data);
6307
6616
  }
6308
6617
  };
6309
6618
  recorder.onerror = (event) => {
6310
- void this.handleRecordingError(event.error ?? new Error("Unknown listening recorder error"));
6619
+ void this.handleRecordingError(event.error ?? new Error("Unknown listening recording error"));
6311
6620
  };
6312
- recorder.start(LISTENING_RECORDER_TIMESLICE_MS);
6621
+ recorder.onstop = () => {
6622
+ void this.handleListeningRecorderStop(session);
6623
+ };
6624
+ return session;
6313
6625
  }
6314
- stopListeningRecorder() {
6315
- if (!this.listeningRecorder) {
6626
+ beginListeningCapture() {
6627
+ const winner = this.selectListeningRecorderWinner();
6628
+ if (!winner) {
6316
6629
  return;
6317
6630
  }
6318
- const recorder = this.listeningRecorder;
6319
- this.listeningRecorder = null;
6320
- recorder.ondataavailable = null;
6321
- recorder.onerror = null;
6322
- if (recorder.state !== "inactive") {
6323
- recorder.stop();
6324
- }
6325
- }
6326
- beginListeningCapture() {
6327
6631
  this.listeningCaptureActive = true;
6328
6632
  this.recordingStartedFromListening = true;
6329
- this.listeningCaptureChunks = this.listeningPreRollChunks.map((chunk) => chunk.data);
6330
- this.listeningPreRollChunks = [];
6633
+ this.listeningPromotedSession = winner;
6634
+ winner.promoted = true;
6635
+ winner.discard = false;
6636
+ if (winner.stopTimeoutId !== null) {
6637
+ window.clearTimeout(winner.stopTimeoutId);
6638
+ winner.stopTimeoutId = null;
6639
+ }
6640
+ this.clearListeningRecorderSchedule();
6641
+ for (const session of this.listeningIdleSessions) {
6642
+ if (session === winner) {
6643
+ continue;
6644
+ }
6645
+ void this.discardListeningSession(session);
6646
+ }
6647
+ const preRollMs = Math.max(0, Math.round(performance.now() - winner.startedAt));
6331
6648
  logRuntimeDebug(this.debugLogging, "voice-recording-started", {
6332
- mimeType: this.listeningRecorderMimeType,
6649
+ mimeType: winner.mimeType,
6333
6650
  fromListening: true,
6334
- preRollMs: LISTENING_PRE_ROLL_MS,
6335
- preRollChunks: this.listeningCaptureChunks.length
6651
+ preRollMs
6336
6652
  });
6337
6653
  void this.triggerSystemEvent("onRecordingStarted", null);
6338
6654
  }
6655
+ selectListeningRecorderWinner() {
6656
+ const activeSessions = this.listeningIdleSessions.filter((session) => session.recorder.state !== "inactive");
6657
+ if (activeSessions.length === 0) {
6658
+ return null;
6659
+ }
6660
+ const targetStartedAt = performance.now() - LISTENING_TARGET_PRE_ROLL_MS;
6661
+ const suitable = activeSessions.filter((session) => session.startedAt <= targetStartedAt).sort((left, right) => right.startedAt - left.startedAt);
6662
+ if (suitable.length > 0) {
6663
+ return suitable[0];
6664
+ }
6665
+ return activeSessions.sort((left, right) => left.startedAt - right.startedAt)[0] ?? null;
6666
+ }
6339
6667
  async stopListeningCapture() {
6340
- if (!this.listeningCaptureActive) {
6668
+ if (!this.listeningCaptureActive || !this.listeningPromotedSession) {
6341
6669
  return;
6342
6670
  }
6671
+ const session = this.listeningPromotedSession;
6343
6672
  this.listeningCaptureActive = false;
6344
- const chunks = [...this.listeningCaptureChunks];
6345
- this.listeningCaptureChunks = [];
6673
+ this.listeningPromotedSession = null;
6674
+ if (session.recorder.state !== "inactive") {
6675
+ session.recorder.stop();
6676
+ }
6677
+ await session.stopped;
6678
+ }
6679
+ async discardListeningSession(session) {
6680
+ session.discard = true;
6681
+ if (session.stopTimeoutId !== null) {
6682
+ window.clearTimeout(session.stopTimeoutId);
6683
+ session.stopTimeoutId = null;
6684
+ }
6685
+ if (session.recorder.state !== "inactive") {
6686
+ session.recorder.stop();
6687
+ }
6688
+ await session.stopped;
6689
+ }
6690
+ async discardListeningIdleSessions() {
6691
+ const sessions = [...this.listeningIdleSessions];
6692
+ for (const session of sessions) {
6693
+ if (session === this.listeningPromotedSession) {
6694
+ continue;
6695
+ }
6696
+ await this.discardListeningSession(session);
6697
+ }
6698
+ this.listeningIdleSessions = this.listeningPromotedSession ? [this.listeningPromotedSession] : [];
6699
+ }
6700
+ async handleListeningRecorderStop(session) {
6701
+ if (session.stopTimeoutId !== null) {
6702
+ window.clearTimeout(session.stopTimeoutId);
6703
+ session.stopTimeoutId = null;
6704
+ }
6705
+ this.listeningIdleSessions = this.listeningIdleSessions.filter((entry) => entry !== session);
6346
6706
  try {
6347
- const blob = new Blob(chunks, { type: this.listeningRecorderMimeType });
6707
+ if (session.discard || !session.promoted) {
6708
+ return;
6709
+ }
6710
+ const blob = new Blob(session.chunks, { type: session.mimeType });
6348
6711
  const audioData = await blobToBase64(blob);
6349
6712
  logRuntimeDebug(this.debugLogging, "voice-recording-stopped", {
6350
- mimeType: this.listeningRecorderMimeType,
6713
+ mimeType: session.mimeType,
6351
6714
  size: blob.size,
6352
- fromListening: true,
6353
- preRollMs: LISTENING_PRE_ROLL_MS
6715
+ fromListening: true
6354
6716
  });
6355
6717
  await this.triggerSystemEvent("onRecordingStopped", {
6356
6718
  audioData,
6357
- mimeType: this.listeningRecorderMimeType
6719
+ mimeType: session.mimeType
6358
6720
  });
6359
6721
  } catch (error) {
6360
6722
  await this.handleRecordingError(error);
6361
6723
  } finally {
6362
- this.recordingStartedFromListening = false;
6724
+ session.resolveStopped();
6725
+ if (session.promoted) {
6726
+ this.recordingStartedFromListening = false;
6727
+ if (this.listening && this.mediaStream?.active) {
6728
+ this.listeningStartedAt = performance.now();
6729
+ this.speechEventTriggered = false;
6730
+ this.startListeningRecorderSchedule(this.mediaStream);
6731
+ }
6732
+ }
6363
6733
  }
6364
6734
  }
6365
6735
  clearRecordingTimeout() {
@@ -6372,9 +6742,6 @@ var VoiceRuntime = class {
6372
6742
  if (this.listening) {
6373
6743
  return;
6374
6744
  }
6375
- if (this.listeningRecorder && this.listeningRecorder.state !== "inactive") {
6376
- return;
6377
- }
6378
6745
  if (this.mediaRecorder && this.mediaRecorder.state !== "inactive") {
6379
6746
  return;
6380
6747
  }
@@ -7617,6 +7984,19 @@ function isGeneratedElementId(value) {
7617
7984
  function isStepperEdit(node) {
7618
7985
  return node.type === "number" || node.type === "float";
7619
7986
  }
7987
+ function isScrollableWidgetNode(node) {
7988
+ switch (node.widget) {
7989
+ case "list":
7990
+ case "grid-view":
7991
+ case "listbox":
7992
+ case "combobox":
7993
+ return true;
7994
+ case "container-layout":
7995
+ return normalizeBoolean(node.verticallyScrollable, false);
7996
+ default:
7997
+ return false;
7998
+ }
7999
+ }
7620
8000
  function getDateFormat(node) {
7621
8001
  return node.format === "yyyy-MM-dd" ? "yyyy-MM-dd" : DEFAULT_DATE_FORMAT;
7622
8002
  }
@@ -7953,6 +8333,7 @@ var RuntimeRenderer = class {
7953
8333
  this.attachInputBehavior(this.root);
7954
8334
  this.attachWidgetEvents(this.root);
7955
8335
  restoreElementState(this.root, preservedState, this.stateByKey);
8336
+ this.syncAllScrollStateFlags();
7956
8337
  this.applyPendingScrollAction();
7957
8338
  this.applyPendingCursorAction();
7958
8339
  this.activeContextMenu = adjustContextMenuPosition(this.root, this.activeContextMenu);
@@ -8571,7 +8952,9 @@ var RuntimeRenderer = class {
8571
8952
  enabled: normalizeBoolean(node.enabled, true),
8572
8953
  definitionSignature: buildDefinitionSignature(node.elements ?? []),
8573
8954
  elements: deepClone(node.elements ?? []),
8574
- selected: toFiniteNumber3(node.item) ?? toFiniteNumber3(node.selected) ?? -1
8955
+ selected: toFiniteNumber3(node.item) ?? toFiniteNumber3(node.selected) ?? -1,
8956
+ isScrolledToTop: true,
8957
+ isScrolledToBottom: true
8575
8958
  };
8576
8959
  break;
8577
8960
  case "listbox":
@@ -8583,7 +8966,9 @@ var RuntimeRenderer = class {
8583
8966
  enabled: normalizeBoolean(node.enabled, true),
8584
8967
  definitionSignature: buildDefinitionSignature(node.elements ?? []),
8585
8968
  listboxElements: deepClone(node.elements ?? []),
8586
- selected: toFiniteNumber3(node.selected) ?? 0
8969
+ selected: toFiniteNumber3(node.selected) ?? 0,
8970
+ isScrolledToTop: true,
8971
+ isScrolledToBottom: true
8587
8972
  };
8588
8973
  break;
8589
8974
  case "combobox":
@@ -8595,7 +8980,9 @@ var RuntimeRenderer = class {
8595
8980
  enabled: normalizeBoolean(node.enabled, true),
8596
8981
  definitionSignature: buildDefinitionSignature(node.elements ?? []),
8597
8982
  comboboxElements: deepClone(node.elements ?? []),
8598
- selected: toFiniteNumber3(node.selected) ?? 0
8983
+ selected: toFiniteNumber3(node.selected) ?? 0,
8984
+ isScrolledToTop: true,
8985
+ isScrolledToBottom: true
8599
8986
  };
8600
8987
  break;
8601
8988
  case "radio-group":
@@ -8647,7 +9034,9 @@ var RuntimeRenderer = class {
8647
9034
  widget: node.widget,
8648
9035
  id: stateId,
8649
9036
  name: node.name,
8650
- enabled: normalizeBoolean(node.enabled, true)
9037
+ enabled: normalizeBoolean(node.enabled, true),
9038
+ isScrolledToTop: isScrollableWidgetNode(node) ? true : void 0,
9039
+ isScrolledToBottom: isScrollableWidgetNode(node) ? true : void 0
8651
9040
  };
8652
9041
  break;
8653
9042
  }
@@ -9104,6 +9493,19 @@ var RuntimeRenderer = class {
9104
9493
  redispatchUnderlyingClick: (x, y) => this.redispatchUnderlyingClick(x, y),
9105
9494
  runActions: (actions, inputValue, context) => this.actionRuntime.runActions(actions, inputValue, context)
9106
9495
  });
9496
+ for (const element of Array.from(root.querySelectorAll("[data-widget-key]"))) {
9497
+ const key = element.dataset.widgetKey;
9498
+ if (!key) {
9499
+ continue;
9500
+ }
9501
+ const node = this.nodeByKey.get(key);
9502
+ if (!node || !isScrollableWidgetNode(node)) {
9503
+ continue;
9504
+ }
9505
+ element.addEventListener("scroll", () => {
9506
+ this.syncScrollStateForElement(element, key);
9507
+ }, { passive: true });
9508
+ }
9107
9509
  }
9108
9510
  updateOwningListElementDescriptor(element, mutate) {
9109
9511
  const listElement = element.closest(".vjt-list-element");
@@ -9422,6 +9824,7 @@ var RuntimeRenderer = class {
9422
9824
  }
9423
9825
  if (typeof window === "undefined") {
9424
9826
  initialElement.scrollTop = Math.max(0, Math.min(targetTop, getMaxScrollTop(initialElement)));
9827
+ this.syncScrollStateForReference(reference, initialElement);
9425
9828
  return;
9426
9829
  }
9427
9830
  if (this.activeScrollAnimationFrameId !== null) {
@@ -9433,6 +9836,7 @@ var RuntimeRenderer = class {
9433
9836
  const delta = clampedTargetTop - startTop;
9434
9837
  if (Math.abs(delta) < 1) {
9435
9838
  initialElement.scrollTop = clampedTargetTop;
9839
+ this.syncScrollStateForReference(reference, initialElement);
9436
9840
  return;
9437
9841
  }
9438
9842
  const durationMs = 300;
@@ -9448,10 +9852,12 @@ var RuntimeRenderer = class {
9448
9852
  const progress = Math.min(1, elapsed / durationMs);
9449
9853
  const liveTargetTop = Math.max(0, Math.min(clampedTargetTop, getMaxScrollTop(liveElement)));
9450
9854
  liveElement.scrollTop = startTop + (liveTargetTop - startTop) * easeInOutCubic(progress);
9855
+ this.syncScrollStateForReference(reference, liveElement);
9451
9856
  if (progress < 1) {
9452
9857
  this.activeScrollAnimationFrameId = window.requestAnimationFrame(tick);
9453
9858
  } else {
9454
9859
  liveElement.scrollTop = liveTargetTop;
9860
+ this.syncScrollStateForReference(reference, liveElement);
9455
9861
  this.activeScrollAnimationFrameId = null;
9456
9862
  }
9457
9863
  };
@@ -9505,6 +9911,48 @@ var RuntimeRenderer = class {
9505
9911
  }
9506
9912
  return null;
9507
9913
  }
9914
+ syncAllScrollStateFlags() {
9915
+ if (!(this.root instanceof HTMLElement)) {
9916
+ return;
9917
+ }
9918
+ for (const element of Array.from(this.root.querySelectorAll("[data-widget-key]"))) {
9919
+ const key = element.dataset.widgetKey;
9920
+ if (!key) {
9921
+ continue;
9922
+ }
9923
+ const node = this.nodeByKey.get(key);
9924
+ if (!node || !isScrollableWidgetNode(node)) {
9925
+ continue;
9926
+ }
9927
+ this.syncScrollStateForElement(element, key);
9928
+ }
9929
+ }
9930
+ syncScrollStateForReference(reference, element) {
9931
+ const widgetId = reference.split(".")[0]?.trim();
9932
+ if (!widgetId) {
9933
+ return;
9934
+ }
9935
+ const targetElement = element ?? this.findScrollableWidgetElement(reference);
9936
+ if (!(targetElement instanceof HTMLElement)) {
9937
+ return;
9938
+ }
9939
+ const key = targetElement.dataset.widgetKey ?? widgetId;
9940
+ this.syncScrollStateForElement(targetElement, key);
9941
+ }
9942
+ syncScrollStateForElement(element, key) {
9943
+ const state = this.stateByKey.get(key) ?? this.stateById.get(key);
9944
+ if (!state) {
9945
+ return;
9946
+ }
9947
+ const maxScrollTop = Math.max(0, element.scrollHeight - element.clientHeight);
9948
+ if (maxScrollTop <= 1) {
9949
+ state.isScrolledToTop = true;
9950
+ state.isScrolledToBottom = true;
9951
+ return;
9952
+ }
9953
+ state.isScrolledToTop = element.scrollTop <= 1;
9954
+ state.isScrolledToBottom = element.scrollTop >= maxScrollTop - 1;
9955
+ }
9508
9956
  toScrollableIndex(value) {
9509
9957
  if (typeof value === "number" && Number.isFinite(value)) {
9510
9958
  return Math.max(0, Math.trunc(value));
@@ -1,4 +1,4 @@
1
- import type { ActionDefinition, PrimitiveRequestType, RequestMapInput, RequestSchema, SseConfig } from './types.js';
1
+ import type { ActionDefinition, ClosableRuntimeResource, PrimitiveRequestType, RequestMapInput, RequestSchema, SseConfig } from './types.js';
2
2
  type ActionRunner = (actions: ActionDefinition[], inputValue: unknown, context: {
3
3
  currentValue: unknown;
4
4
  responseValue?: unknown;
@@ -8,7 +8,7 @@ type NetworkRuntimeOptions = {
8
8
  requestsMap: RequestMapInput;
9
9
  sseConfigs: SseConfig[];
10
10
  backendUrl?: string;
11
- eventSources: EventSource[];
11
+ eventSources: ClosableRuntimeResource[];
12
12
  runActions: ActionRunner;
13
13
  rerenderRoot: () => Promise<void>;
14
14
  };
@@ -28,6 +28,9 @@ export declare class NetworkRuntime {
28
28
  private createRequestError;
29
29
  private toRequestErrorPayload;
30
30
  coercePrimitiveSchemaValue(type: PrimitiveRequestType, value: unknown): unknown;
31
+ private createSseConnection;
32
+ private createNativeSseConnection;
33
+ private createFetchSseConnection;
31
34
  private handleSseEvent;
32
35
  }
33
36
  export {};
@@ -6,6 +6,9 @@ export type HeadingTag = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
6
6
  export type WidgetEventName = 'onClick' | 'onUserValueChange' | 'onRefresh' | 'onEnter' | 'onShiftEnter' | 'onControlEnter' | 'onPaste';
7
7
  export type SystemEventName = 'onBeforeRender' | 'onAfterRender' | 'onBeforeNavigate' | 'onAfterNavigate' | 'onLayoutSwitchToMobile' | 'onLayoutSwitchToDesktop' | 'onSpeechDetected' | 'onRecordingStarted' | 'onRecordingStopped' | 'onRecordingError' | 'onListeningError' | 'onListeringError' | 'onPlayFinished' | 'onPlayingStopped';
8
8
  export type PrimitiveRequestType = 'int' | 'float' | 'boolean' | 'string';
9
+ export type ClosableRuntimeResource = {
10
+ close: () => void;
11
+ };
9
12
  export type RouteDefinition = {
10
13
  path: string;
11
14
  ui: string;
@@ -128,6 +131,8 @@ export type WidgetState = {
128
131
  modalHeight?: number;
129
132
  activeTab?: number;
130
133
  isHidden?: boolean;
134
+ isScrolledToTop?: boolean;
135
+ isScrolledToBottom?: boolean;
131
136
  };
132
137
  export type RuntimeSnapshot = {
133
138
  stateByKey: Array<[string, WidgetState]>;
@@ -16,10 +16,10 @@ export declare class VoiceRuntime {
16
16
  private listenAnalyser;
17
17
  private listenSource;
18
18
  private listenFrameId;
19
- private listeningRecorder;
20
- private listeningRecorderMimeType;
21
- private listeningPreRollChunks;
22
- private listeningCaptureChunks;
19
+ private listeningIdleSessions;
20
+ private listeningLaneTimeoutIds;
21
+ private listeningLaneIntervalIds;
22
+ private listeningPromotedSession;
23
23
  private listeningCaptureActive;
24
24
  private lastSpeechAt;
25
25
  private listeningStartedAt;
@@ -44,10 +44,16 @@ export declare class VoiceRuntime {
44
44
  private normalizePlayablePayload;
45
45
  private getPreferredRecordingMimeType;
46
46
  private ensureInputStream;
47
- private startListeningRecorder;
48
- private stopListeningRecorder;
47
+ private startListeningRecorderSchedule;
48
+ private clearListeningRecorderSchedule;
49
+ private launchListeningRecorder;
50
+ private createListeningRecorderSession;
49
51
  private beginListeningCapture;
52
+ private selectListeningRecorderWinner;
50
53
  private stopListeningCapture;
54
+ private discardListeningSession;
55
+ private discardListeningIdleSessions;
56
+ private handleListeningRecorderStop;
51
57
  private clearRecordingTimeout;
52
58
  private releaseInputStreamIfIdle;
53
59
  private stopStreamTracks;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vortexm/vjt",
3
- "version": "0.1.14",
3
+ "version": "0.1.16",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",