@fiodos/web-core 0.1.10 → 0.1.12
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/cjs/api/backendClient.js +20 -0
- package/dist/cjs/config/types.d.ts +24 -0
- package/dist/cjs/config/types.js +6 -1
- package/dist/cjs/controller/AgentController.d.ts +105 -2
- package/dist/cjs/controller/AgentController.js +572 -75
- package/dist/cjs/dropin/createFiodosAgent.d.ts +5 -0
- package/dist/cjs/dropin/createFiodosAgent.js +7 -0
- package/dist/cjs/index.d.ts +3 -3
- package/dist/cjs/index.js +2 -1
- package/dist/cjs/orb/mountOrb.d.ts +6 -0
- package/dist/cjs/orb/mountOrb.js +229 -33
- package/dist/cjs/orb/orbView.d.ts +2 -0
- package/dist/cjs/orb/publishedConfig.d.ts +18 -0
- package/dist/cjs/orb/publishedConfig.js +6 -0
- package/dist/cjs/speech/bubbleDictation.d.ts +11 -0
- package/dist/cjs/speech/bubbleDictation.js +88 -0
- package/dist/cjs/ui/messages.d.ts +15 -0
- package/dist/cjs/ui/messages.js +12 -0
- package/dist/cjs/version.d.ts +1 -1
- package/dist/cjs/version.js +1 -1
- package/dist/esm/api/backendClient.js +20 -0
- package/dist/esm/config/types.d.ts +24 -0
- package/dist/esm/config/types.js +5 -0
- package/dist/esm/controller/AgentController.d.ts +105 -2
- package/dist/esm/controller/AgentController.js +574 -77
- package/dist/esm/dropin/createFiodosAgent.d.ts +5 -0
- package/dist/esm/dropin/createFiodosAgent.js +7 -0
- package/dist/esm/index.d.ts +3 -3
- package/dist/esm/index.js +1 -1
- package/dist/esm/orb/mountOrb.d.ts +6 -0
- package/dist/esm/orb/mountOrb.js +230 -34
- package/dist/esm/orb/orbView.d.ts +2 -0
- package/dist/esm/orb/publishedConfig.d.ts +18 -0
- package/dist/esm/orb/publishedConfig.js +6 -0
- package/dist/esm/speech/bubbleDictation.d.ts +11 -0
- package/dist/esm/speech/bubbleDictation.js +84 -0
- package/dist/esm/ui/messages.d.ts +15 -0
- package/dist/esm/ui/messages.js +12 -0
- package/dist/esm/version.d.ts +1 -1
- package/dist/esm/version.js +1 -1
- package/package.json +2 -2
|
@@ -30,6 +30,7 @@ const messages_1 = require("../ui/messages");
|
|
|
30
30
|
function randomSessionId() {
|
|
31
31
|
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
32
32
|
}
|
|
33
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
33
34
|
class AgentController {
|
|
34
35
|
constructor(config) {
|
|
35
36
|
this.enabled = true;
|
|
@@ -42,6 +43,7 @@ class AgentController {
|
|
|
42
43
|
this.consentModalVisible = false;
|
|
43
44
|
this.sessionId = randomSessionId();
|
|
44
45
|
this.sessionStarted = false;
|
|
46
|
+
// Replaced wholesale on identity swaps / explicit resets.
|
|
45
47
|
this.conversation = (0, core_1.createConversationSession)();
|
|
46
48
|
this.pending = null;
|
|
47
49
|
this.confirmationReasked = false;
|
|
@@ -58,6 +60,22 @@ class AgentController {
|
|
|
58
60
|
// audio), skip the extra /preview-tts round-trip and speak via device voice.
|
|
59
61
|
this.serverTtsUnavailable = false;
|
|
60
62
|
this.pendingConsentIntent = null;
|
|
63
|
+
/** Parked chain state (set when a chain pauses on a sensitive action). */
|
|
64
|
+
this.chainState = null;
|
|
65
|
+
/** REAL live activity for the panel (replaces the old canned walk-through). */
|
|
66
|
+
this.liveActivity = null;
|
|
67
|
+
this.chainActive = false;
|
|
68
|
+
/** How many exchanges the bubble currently mounts (grows one page at a time). */
|
|
69
|
+
this.bubbleWindowSize = core_1.BUBBLE_PAGE_EXCHANGES;
|
|
70
|
+
/** Raw dashboard retention (minutes; 0 = never; null = default). Persisted
|
|
71
|
+
* with the session so a restore applies the same expiry rule. */
|
|
72
|
+
this.conversationRetentionMinutes = null;
|
|
73
|
+
/** Persistence scope: internal orbs keep their memory under a separate key
|
|
74
|
+
* so a public orb sharing the same appId can never read it. */
|
|
75
|
+
this.conversationScope = 'public';
|
|
76
|
+
this.removeIdentityListeners = null;
|
|
77
|
+
this.loadingOlderExchanges = false;
|
|
78
|
+
this.loadOlderTimer = null;
|
|
61
79
|
this.listeners = new Set();
|
|
62
80
|
this.unsubscribeSpeech = null;
|
|
63
81
|
// Fail fast on an invalid manifest — same contract as every platform.
|
|
@@ -84,6 +102,13 @@ class AgentController {
|
|
|
84
102
|
isAuthenticated: config.isAuthenticated,
|
|
85
103
|
});
|
|
86
104
|
this.timings = { ...types_1.DEFAULT_AGENT_TIMINGS, ...config.timings };
|
|
105
|
+
// Chaining OFF by default; clamp to the same safe bounds as react/react-native.
|
|
106
|
+
const chainingMerged = { ...types_1.DEFAULT_AGENT_CHAINING, ...config.chaining };
|
|
107
|
+
this.chaining = {
|
|
108
|
+
enabled: chainingMerged.enabled === true,
|
|
109
|
+
maxSteps: Math.max(1, Math.min(6, Math.floor(chainingMerged.maxSteps || 6))),
|
|
110
|
+
dwellMs: Math.max(0, Math.min(3000, Math.floor(chainingMerged.dwellMs ?? 450))),
|
|
111
|
+
};
|
|
87
112
|
this.ttsLocale = config.ttsLocale ?? config.sttLocale;
|
|
88
113
|
this.notifyError =
|
|
89
114
|
config.notifyError ??
|
|
@@ -108,12 +133,84 @@ class AgentController {
|
|
|
108
133
|
});
|
|
109
134
|
// Re-emit combined state whenever the speech machine advances.
|
|
110
135
|
this.unsubscribeSpeech = this.speech.subscribe(() => this.emit());
|
|
136
|
+
this.conversationIdentity = (0, core_1.conversationIdentityKey)(config.getUserId?.());
|
|
137
|
+
// Rehydrate the previous conversation (page reloads): the persisted blob
|
|
138
|
+
// carries its own retention rule, so expiry is decided correctly even
|
|
139
|
+
// before the published config arrives. Best-effort and non-blocking.
|
|
140
|
+
void this.restorePersistedConversation();
|
|
141
|
+
// Logout/login usually happens on another screen: re-check identity when
|
|
142
|
+
// the tab regains focus or becomes visible again, besides before every turn.
|
|
143
|
+
if (typeof window !== 'undefined') {
|
|
144
|
+
const check = () => void this.syncConversationIdentity();
|
|
145
|
+
window.addEventListener('focus', check);
|
|
146
|
+
document.addEventListener('visibilitychange', check);
|
|
147
|
+
this.removeIdentityListeners = () => {
|
|
148
|
+
window.removeEventListener('focus', check);
|
|
149
|
+
document.removeEventListener('visibilitychange', check);
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
// ── Conversation persistence (survives page reloads) ───────────────────────
|
|
154
|
+
async restorePersistedConversation() {
|
|
155
|
+
const identityAtStart = this.conversationIdentity;
|
|
156
|
+
const restored = await (0, core_1.restoreConversationSession)(this.config.storage, this.config.manifest.appId, Date.now(), this.conversationScope, identityAtStart);
|
|
157
|
+
// The identity may have changed again while the read resolved.
|
|
158
|
+
if (!restored || this.conversationIdentity !== identityAtStart)
|
|
159
|
+
return;
|
|
160
|
+
// Never clobber turns the user already made while the restore resolved.
|
|
161
|
+
if (this.conversation.turns.length > 0 || this.conversation.uiArchive?.length)
|
|
162
|
+
return;
|
|
163
|
+
this.conversation.turns = restored.turns;
|
|
164
|
+
this.conversation.summary = restored.summary;
|
|
165
|
+
this.conversation.uiArchive = restored.uiArchive;
|
|
166
|
+
this.conversation.lastActivityAt = restored.lastActivityAt;
|
|
167
|
+
this.emit();
|
|
168
|
+
}
|
|
169
|
+
persistConversation() {
|
|
170
|
+
void (0, core_1.persistConversationSession)(this.config.storage, this.config.manifest.appId, this.conversation, this.conversationRetentionMinutes, this.conversationScope, this.conversationIdentity);
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Re-evaluates the host identity and, when it changed, swaps conversations:
|
|
174
|
+
* the outgoing identity's session is saved under ITS private key (so a user
|
|
175
|
+
* who logs back in recovers it, honouring "never clear"), all in-memory
|
|
176
|
+
* state and UI are wiped, and the incoming identity's own session (usually
|
|
177
|
+
* empty) is restored. An anonymous visitor after a logout always starts
|
|
178
|
+
* clean — the previous user's transcript is never inherited.
|
|
179
|
+
*/
|
|
180
|
+
syncConversationIdentity() {
|
|
181
|
+
const next = (0, core_1.conversationIdentityKey)(this.config.getUserId?.());
|
|
182
|
+
if (next === this.conversationIdentity)
|
|
183
|
+
return false;
|
|
184
|
+
// 1. Park the outgoing identity's session under its own key.
|
|
185
|
+
this.persistConversation();
|
|
186
|
+
// 2. Wipe every trace from memory and the panel.
|
|
187
|
+
this.conversation = (0, core_1.createConversationSession)();
|
|
188
|
+
this.conversationIdentity = next;
|
|
189
|
+
this.lastExchange = null;
|
|
190
|
+
this.emit();
|
|
191
|
+
// 3. Bring in the new identity's own session, if any.
|
|
192
|
+
void this.restorePersistedConversation();
|
|
193
|
+
return true;
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Wipes the CURRENT identity's conversation: live memory, panel and the
|
|
197
|
+
* persisted blob. Hosts can call it from their logout for a hard wipe (by
|
|
198
|
+
* default a logout only parks the conversation under the user's private
|
|
199
|
+
* local key).
|
|
200
|
+
*/
|
|
201
|
+
resetConversation() {
|
|
202
|
+
this.cancelAll();
|
|
203
|
+
this.conversation = (0, core_1.createConversationSession)();
|
|
204
|
+
this.lastExchange = null;
|
|
205
|
+
void (0, core_1.clearPersistedConversation)(this.config.storage, this.config.manifest.appId, this.conversationScope, this.conversationIdentity);
|
|
206
|
+
this.emit();
|
|
111
207
|
}
|
|
112
208
|
// ── Subscription / state ───────────────────────────────────────────────────
|
|
113
209
|
getState() {
|
|
114
210
|
const speech = this.speech.getSnapshot();
|
|
115
211
|
const showWaveform = (this.phase === 'listening' || this.phase === 'confirming') && speech.state === 'recording';
|
|
116
212
|
const showThinking = this.phase === 'thinking' || (this.phase === 'listening' && speech.state === 'transcribing');
|
|
213
|
+
const bubbleWindow = (0, core_1.buildBubbleExchangeWindow)(this.conversation, this.lastExchange, this.bubbleWindowSize, this.conversationOptions);
|
|
117
214
|
return {
|
|
118
215
|
phase: this.phase,
|
|
119
216
|
interimText: speech.interimText,
|
|
@@ -123,12 +220,32 @@ class AgentController {
|
|
|
123
220
|
isBusy: showThinking || this.phase === 'speaking',
|
|
124
221
|
isListening: this.phase === 'listening' && speech.state !== 'idle',
|
|
125
222
|
lastExchange: this.lastExchange,
|
|
223
|
+
recentExchanges: bubbleWindow.exchanges,
|
|
224
|
+
hasOlderExchanges: bubbleWindow.hasOlder,
|
|
225
|
+
loadingOlderExchanges: this.loadingOlderExchanges,
|
|
126
226
|
confirmationMessage: this.confirmationMessage,
|
|
127
227
|
confirmationVoiceHint: this.confirmationVoiceHint,
|
|
128
228
|
consentModalVisible: this.consentModalVisible,
|
|
129
229
|
voiceBlocked: this.voiceBlocked,
|
|
230
|
+
liveActivity: this.liveActivity,
|
|
231
|
+
chainActive: this.chainActive,
|
|
130
232
|
};
|
|
131
233
|
}
|
|
234
|
+
/** Update the REAL live activity and re-render (no-op when unchanged). */
|
|
235
|
+
setActivity(next) {
|
|
236
|
+
const prev = this.liveActivity;
|
|
237
|
+
if (prev === next || (prev && next && prev.kind === next.kind && prev.label === next.label)) {
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
this.liveActivity = next;
|
|
241
|
+
this.emit();
|
|
242
|
+
}
|
|
243
|
+
setChainActive(value) {
|
|
244
|
+
if (this.chainActive === value)
|
|
245
|
+
return;
|
|
246
|
+
this.chainActive = value;
|
|
247
|
+
this.emit();
|
|
248
|
+
}
|
|
132
249
|
/** Whether speech recognition can actually run (API present AND mic not denied). */
|
|
133
250
|
get voiceAvailable() {
|
|
134
251
|
return this.config.voice.isRecognitionAvailable() && !this.voiceBlocked;
|
|
@@ -147,6 +264,53 @@ class AgentController {
|
|
|
147
264
|
this.voiceBlocked = blocked;
|
|
148
265
|
this.emit();
|
|
149
266
|
}
|
|
267
|
+
/**
|
|
268
|
+
* Page one more block of older session messages into the bubble (scroll-to-top).
|
|
269
|
+
* The mount is deferred behind a short loading state so old messages stream in
|
|
270
|
+
* on demand instead of all rendering at once (keeps the open bubble light).
|
|
271
|
+
*/
|
|
272
|
+
loadOlderExchanges() {
|
|
273
|
+
if (this.loadingOlderExchanges)
|
|
274
|
+
return;
|
|
275
|
+
const win = (0, core_1.buildBubbleExchangeWindow)(this.conversation, this.lastExchange, this.bubbleWindowSize, this.conversationOptions);
|
|
276
|
+
if (!win.hasOlder)
|
|
277
|
+
return;
|
|
278
|
+
this.loadingOlderExchanges = true;
|
|
279
|
+
this.emit();
|
|
280
|
+
this.loadOlderTimer = setTimeout(() => {
|
|
281
|
+
this.loadOlderTimer = null;
|
|
282
|
+
this.bubbleWindowSize += core_1.BUBBLE_PAGE_EXCHANGES;
|
|
283
|
+
this.loadingOlderExchanges = false;
|
|
284
|
+
this.emit();
|
|
285
|
+
}, 350);
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* Apply the dashboard's context-retention setting (minutes; 0 = never clear,
|
|
289
|
+
* null/undefined = the 30-minute default). Called when the published config
|
|
290
|
+
* arrives/refreshes, so the developer's choice is live without a reload.
|
|
291
|
+
* `expandedMemory` = INTERNAL orb profile: use the expanded conversation
|
|
292
|
+
* memory window (more literal turns + longer rolling summary per request).
|
|
293
|
+
*/
|
|
294
|
+
setConversationRetention(minutes, expandedMemory = false) {
|
|
295
|
+
this.conversationOptions = (0, core_1.conversationRetentionOptions)(minutes, expandedMemory);
|
|
296
|
+
this.conversationRetentionMinutes = typeof minutes === 'number' ? minutes : null;
|
|
297
|
+
const scope = expandedMemory ? 'internal' : 'public';
|
|
298
|
+
if (scope !== this.conversationScope) {
|
|
299
|
+
this.conversationScope = scope;
|
|
300
|
+
// The published profile arrived after the constructor's restore attempt:
|
|
301
|
+
// retry under the right storage key (guarded — never clobbers live turns).
|
|
302
|
+
void this.restorePersistedConversation();
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
/** Collapse the bubble history back to the initial page (bubble closed). */
|
|
306
|
+
resetBubbleWindow() {
|
|
307
|
+
if (this.loadOlderTimer) {
|
|
308
|
+
clearTimeout(this.loadOlderTimer);
|
|
309
|
+
this.loadOlderTimer = null;
|
|
310
|
+
}
|
|
311
|
+
this.loadingOlderExchanges = false;
|
|
312
|
+
this.bubbleWindowSize = core_1.BUBBLE_PAGE_EXCHANGES;
|
|
313
|
+
}
|
|
150
314
|
subscribe(listener) {
|
|
151
315
|
this.listeners.add(listener);
|
|
152
316
|
return () => {
|
|
@@ -305,6 +469,11 @@ class AgentController {
|
|
|
305
469
|
if (this.phase === 'confirming')
|
|
306
470
|
this.setPhase('idle');
|
|
307
471
|
this.emit();
|
|
472
|
+
// REAL activity: the confirmed action is executing right now.
|
|
473
|
+
this.setActivity({
|
|
474
|
+
kind: 'executing',
|
|
475
|
+
label: this.actionLabel(pending.intent) ?? pending.intent,
|
|
476
|
+
});
|
|
308
477
|
const result = await this.executor.execute(pending.action, pending.context);
|
|
309
478
|
this.telemetry.logEvent({
|
|
310
479
|
eventType: result.success ? 'action_executed' : 'action_failed',
|
|
@@ -318,6 +487,39 @@ class AgentController {
|
|
|
318
487
|
this.emit();
|
|
319
488
|
await this.speakReply(result.message, null);
|
|
320
489
|
}
|
|
490
|
+
// RESUME the autonomous chain if this sensitive action paused one — the
|
|
491
|
+
// human confirmation requirement is fully preserved: the chain only
|
|
492
|
+
// advances past a sensitive step AFTER the user confirmed and it executed.
|
|
493
|
+
const chainState = this.chainState;
|
|
494
|
+
if (chainState) {
|
|
495
|
+
this.chainState = null;
|
|
496
|
+
if (result.success && this.chaining.enabled) {
|
|
497
|
+
this.telemetry.logEvent({
|
|
498
|
+
eventType: 'agent_chain_step',
|
|
499
|
+
intentDetected: pending.intent,
|
|
500
|
+
sessionId: this.sessionId,
|
|
501
|
+
chainStep: chainState.step,
|
|
502
|
+
});
|
|
503
|
+
if (chainState.pendingKey)
|
|
504
|
+
chainState.visitedKeys.add(chainState.pendingKey);
|
|
505
|
+
await this.runChainLoop({
|
|
506
|
+
userMessage: chainState.userMessage,
|
|
507
|
+
step: chainState.step,
|
|
508
|
+
lastStep: chainState.pendingStep ?? chainState.lastStep,
|
|
509
|
+
visitedKeys: chainState.visitedKeys,
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
else if (!result.success) {
|
|
513
|
+
this.telemetry.logEvent({
|
|
514
|
+
eventType: 'agent_chain_aborted',
|
|
515
|
+
sessionId: this.sessionId,
|
|
516
|
+
chainAbortReason: 'error',
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
// The confirmed action (and any resumed chain) finished: clear the live row.
|
|
521
|
+
if (!this.pending)
|
|
522
|
+
this.setActivity(null);
|
|
321
523
|
}
|
|
322
524
|
cancelPendingAction() {
|
|
323
525
|
this.clearConfirmWindow();
|
|
@@ -325,9 +527,20 @@ class AgentController {
|
|
|
325
527
|
this.pending = null;
|
|
326
528
|
this.confirmationMessage = null;
|
|
327
529
|
this.confirmationVoiceHint = null;
|
|
530
|
+
this.setActivity(null);
|
|
328
531
|
if (this.phase === 'confirming')
|
|
329
532
|
this.setPhase('idle');
|
|
330
533
|
this.emit();
|
|
534
|
+
// Declining a sensitive action stops any chain it was part of, cleanly.
|
|
535
|
+
const chainState = this.chainState;
|
|
536
|
+
this.chainState = null;
|
|
537
|
+
if (chainState) {
|
|
538
|
+
this.telemetry.logEvent({
|
|
539
|
+
eventType: 'agent_chain_aborted',
|
|
540
|
+
sessionId: this.sessionId,
|
|
541
|
+
chainAbortReason: 'user_interrupt',
|
|
542
|
+
});
|
|
543
|
+
}
|
|
331
544
|
if (!pending)
|
|
332
545
|
return;
|
|
333
546
|
this.telemetry.logEvent({
|
|
@@ -371,6 +584,286 @@ class AgentController {
|
|
|
371
584
|
this.beginConfirmationVoiceWindow();
|
|
372
585
|
}
|
|
373
586
|
}
|
|
587
|
+
routeLabel(intent) {
|
|
588
|
+
return this.config.manifest.routes.find((r) => r.intent === intent)?.label;
|
|
589
|
+
}
|
|
590
|
+
actionLabel(intent) {
|
|
591
|
+
return this.config.manifest.actions.find((a) => a.intent === intent)?.label;
|
|
592
|
+
}
|
|
593
|
+
/**
|
|
594
|
+
* Resolve ONE backend turn into user-facing reply/audio: run its action (or
|
|
595
|
+
* stage its confirmation), emit telemetry, and report whether the autonomous
|
|
596
|
+
* chain may take another step. Shared by the first turn and every
|
|
597
|
+
* continuation so both follow the exact same security path — disabled
|
|
598
|
+
* capabilities, confidentiality and sensitive confirmations apply per step.
|
|
599
|
+
*/
|
|
600
|
+
async applyOutcome(turn, routeAtSend, snapshotContext, userMessage) {
|
|
601
|
+
let outReply = turn.reply;
|
|
602
|
+
let outAudio = turn.audioBase64;
|
|
603
|
+
if (outReply)
|
|
604
|
+
this.serverTtsUnavailable = !outAudio;
|
|
605
|
+
const decision = await (0, turnEngine_1.decideAction)({
|
|
606
|
+
action: turn.action,
|
|
607
|
+
manifest: this.config.manifest,
|
|
608
|
+
executor: this.executor,
|
|
609
|
+
context: snapshotContext,
|
|
610
|
+
messages: this.messages,
|
|
611
|
+
userMessage,
|
|
612
|
+
});
|
|
613
|
+
let canContinue = false;
|
|
614
|
+
let lastStep;
|
|
615
|
+
let actionKey;
|
|
616
|
+
let hasPending = false;
|
|
617
|
+
let pendingInProgress = false;
|
|
618
|
+
let pendingStep;
|
|
619
|
+
let pendingKey;
|
|
620
|
+
if (decision.kind === 'executed') {
|
|
621
|
+
const r = decision.result;
|
|
622
|
+
// REAL activity for the panel: this action actually just ran — the
|
|
623
|
+
// label comes from the manifest, never invented.
|
|
624
|
+
if (r.success && turn.action?.type === 'navigate') {
|
|
625
|
+
this.setActivity({
|
|
626
|
+
kind: 'navigating',
|
|
627
|
+
label: this.routeLabel(turn.action.intent ?? '') ?? turn.action.intent ?? '',
|
|
628
|
+
});
|
|
629
|
+
}
|
|
630
|
+
else if (r.success && !r.recoverable && turn.action?.type === 'execute') {
|
|
631
|
+
this.setActivity({
|
|
632
|
+
kind: 'executing',
|
|
633
|
+
label: this.actionLabel(turn.action.intent ?? '') ?? turn.action.intent ?? '',
|
|
634
|
+
});
|
|
635
|
+
}
|
|
636
|
+
if (turn.action?.type === 'navigate') {
|
|
637
|
+
this.telemetry.logEvent({
|
|
638
|
+
eventType: r.success ? 'navigation_executed' : 'navigation_failed',
|
|
639
|
+
intentDetected: turn.action.intent,
|
|
640
|
+
sessionId: this.sessionId,
|
|
641
|
+
currentRoute: routeAtSend ?? undefined,
|
|
642
|
+
});
|
|
643
|
+
}
|
|
644
|
+
else if (turn.action?.type === 'execute') {
|
|
645
|
+
this.telemetry.logEvent({
|
|
646
|
+
eventType: r.recoverable
|
|
647
|
+
? 'action_needs_context'
|
|
648
|
+
: r.success
|
|
649
|
+
? 'action_executed'
|
|
650
|
+
: 'action_failed',
|
|
651
|
+
intentDetected: turn.action.intent,
|
|
652
|
+
sessionId: this.sessionId,
|
|
653
|
+
actionExecuted: { intent: turn.action.intent, confirmed: false },
|
|
654
|
+
});
|
|
655
|
+
}
|
|
656
|
+
if (r.recoverable) {
|
|
657
|
+
/* keep the assistant's guiding reply; never speak a refusal. */
|
|
658
|
+
}
|
|
659
|
+
else if (r.message) {
|
|
660
|
+
outReply = r.message;
|
|
661
|
+
outAudio = null;
|
|
662
|
+
}
|
|
663
|
+
// Chain signal: only a cleanly EXECUTED, non-recoverable step may advance,
|
|
664
|
+
// and only when the backend asked to continue (task_status=in_progress).
|
|
665
|
+
if (turn.action &&
|
|
666
|
+
(turn.action.type === 'navigate' || turn.action.type === 'execute') &&
|
|
667
|
+
r.success &&
|
|
668
|
+
!r.recoverable) {
|
|
669
|
+
const intent = turn.action.intent ?? '';
|
|
670
|
+
lastStep = {
|
|
671
|
+
type: turn.action.type,
|
|
672
|
+
intent,
|
|
673
|
+
label: turn.action.type === 'navigate' ? this.routeLabel(intent) : this.actionLabel(intent),
|
|
674
|
+
success: true,
|
|
675
|
+
};
|
|
676
|
+
actionKey = `${turn.action.type}:${intent}`;
|
|
677
|
+
canContinue = turn.taskStatus === 'in_progress';
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
else if (decision.kind === 'precheck-resolved') {
|
|
681
|
+
if (decision.result.message && !decision.result.recoverable) {
|
|
682
|
+
outReply = decision.result.message;
|
|
683
|
+
outAudio = null;
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
else if (decision.kind === 'needs-confirmation') {
|
|
687
|
+
// A sensitive action ALWAYS pauses (chaining or not): stage the
|
|
688
|
+
// confirmation and stop advancing. Resumes after the user confirms.
|
|
689
|
+
this.pending = { ...decision.pending, silent: this.silentTurn };
|
|
690
|
+
this.confirmationReasked = false;
|
|
691
|
+
this.confirmationMessage = decision.pending.question;
|
|
692
|
+
this.confirmationVoiceHint =
|
|
693
|
+
decision.pending.voiceMode === 'strict' &&
|
|
694
|
+
decision.pending.strictPhrase &&
|
|
695
|
+
!this.silentTurn
|
|
696
|
+
? (0, core_1.formatMessage)(this.messages.strictConfirmationModalHint, {
|
|
697
|
+
phrase: decision.pending.strictPhrase,
|
|
698
|
+
})
|
|
699
|
+
: null;
|
|
700
|
+
this.emit();
|
|
701
|
+
this.telemetry.logEvent({
|
|
702
|
+
eventType: 'action_confirmation_requested',
|
|
703
|
+
intentDetected: decision.pending.intent,
|
|
704
|
+
sessionId: this.sessionId,
|
|
705
|
+
});
|
|
706
|
+
// REAL activity: the agent is genuinely waiting for the human now.
|
|
707
|
+
this.setActivity({ kind: 'waiting_confirmation' });
|
|
708
|
+
hasPending = true;
|
|
709
|
+
pendingInProgress = turn.taskStatus === 'in_progress';
|
|
710
|
+
const pIntent = decision.pending.intent || '';
|
|
711
|
+
pendingStep = { type: 'execute', intent: pIntent, label: this.actionLabel(pIntent), success: true };
|
|
712
|
+
pendingKey = `execute:${pIntent}`;
|
|
713
|
+
}
|
|
714
|
+
return {
|
|
715
|
+
reply: outReply,
|
|
716
|
+
audioBase64: outAudio,
|
|
717
|
+
hasPending,
|
|
718
|
+
canContinue,
|
|
719
|
+
lastStep,
|
|
720
|
+
actionKey,
|
|
721
|
+
pendingInProgress,
|
|
722
|
+
pendingStep,
|
|
723
|
+
pendingKey,
|
|
724
|
+
};
|
|
725
|
+
}
|
|
726
|
+
/**
|
|
727
|
+
* The autonomous continuation loop. Extracted so confirmPendingAction can
|
|
728
|
+
* RESUME the SAME chain after a sensitive step is confirmed. Capped at
|
|
729
|
+
* maxSteps, with no-progress detection and sensitive-confirmation pauses.
|
|
730
|
+
*/
|
|
731
|
+
async runChainLoop(state) {
|
|
732
|
+
let step = state.step;
|
|
733
|
+
let lastStep = state.lastStep;
|
|
734
|
+
const visitedKeys = state.visitedKeys;
|
|
735
|
+
// Keep the panel's live row visible for the WHOLE chain (including the
|
|
736
|
+
// dwell between steps), so the real narration never flickers away.
|
|
737
|
+
this.setChainActive(true);
|
|
738
|
+
try {
|
|
739
|
+
while (step < this.chaining.maxSteps) {
|
|
740
|
+
step += 1;
|
|
741
|
+
if (this.chaining.dwellMs > 0)
|
|
742
|
+
await sleep(this.chaining.dwellMs);
|
|
743
|
+
if (this.pending != null)
|
|
744
|
+
return; // paused / interrupted elsewhere
|
|
745
|
+
const routeNow = this.config.navigation.getCurrentRoute();
|
|
746
|
+
const snap = this.screenStore.getSnapshot();
|
|
747
|
+
const rawCtx = (0, core_1.combineContexts)(this.config.getUserContextText?.() ?? null, this.screenStore.getText(), {
|
|
748
|
+
truncationSuffix: this.messages.contextTruncationSuffix,
|
|
749
|
+
maxChars: this.config.contextMaxChars,
|
|
750
|
+
});
|
|
751
|
+
const san = rawCtx ? this.sanitizer(rawCtx).sanitized : undefined;
|
|
752
|
+
const conv = (0, core_1.prepareConversationForTurn)(this.conversation, Date.now(), this.conversationOptions);
|
|
753
|
+
this.setPhase('thinking');
|
|
754
|
+
// Honest state: the model is choosing the next step right now.
|
|
755
|
+
this.setActivity({ kind: 'thinking' });
|
|
756
|
+
const controller = new AbortController();
|
|
757
|
+
this.abort = controller;
|
|
758
|
+
let oc;
|
|
759
|
+
try {
|
|
760
|
+
const turn = await this.config.backend.sendTurn({
|
|
761
|
+
message: state.userMessage,
|
|
762
|
+
language: this.config.locale,
|
|
763
|
+
voice: this.getTtsVoice(),
|
|
764
|
+
screenContext: san,
|
|
765
|
+
currentRoute: routeNow ?? undefined,
|
|
766
|
+
conversationHistory: conv.history,
|
|
767
|
+
conversationSummary: conv.summary ?? undefined,
|
|
768
|
+
sessionId: this.sessionId,
|
|
769
|
+
isContinuation: true,
|
|
770
|
+
chainStep: step,
|
|
771
|
+
lastStep,
|
|
772
|
+
...(0, backendClient_1.buildManifestPayload)(this.config.manifest),
|
|
773
|
+
}, { signal: controller.signal });
|
|
774
|
+
oc = await this.applyOutcome(turn, routeNow, snap?.context ?? {}, state.userMessage);
|
|
775
|
+
}
|
|
776
|
+
catch (e) {
|
|
777
|
+
this.abort = null;
|
|
778
|
+
const interrupted = e instanceof errors_1.AgentApiError && e.code === 'cancelled';
|
|
779
|
+
if (interrupted) {
|
|
780
|
+
if (this.phase === 'thinking')
|
|
781
|
+
this.setPhase('idle');
|
|
782
|
+
}
|
|
783
|
+
else {
|
|
784
|
+
if (e instanceof errors_1.AgentApiError && e.code === 'quota_exceeded') {
|
|
785
|
+
this.config.onQuotaExceeded?.();
|
|
786
|
+
}
|
|
787
|
+
const errMsg = (0, formatTurnError_1.formatAgentTurnError)(e, this.config.locale);
|
|
788
|
+
this.lastExchange = { userText: state.userMessage, replyText: errMsg };
|
|
789
|
+
if (this.phase === 'thinking')
|
|
790
|
+
this.setPhase('idle');
|
|
791
|
+
this.emit();
|
|
792
|
+
this.notifyError(errMsg);
|
|
793
|
+
}
|
|
794
|
+
this.telemetry.logEvent({
|
|
795
|
+
eventType: 'agent_chain_aborted',
|
|
796
|
+
sessionId: this.sessionId,
|
|
797
|
+
chainStep: step,
|
|
798
|
+
chainAbortReason: interrupted ? 'user_interrupt' : 'error',
|
|
799
|
+
});
|
|
800
|
+
return;
|
|
801
|
+
}
|
|
802
|
+
this.abort = null;
|
|
803
|
+
if (oc.reply) {
|
|
804
|
+
this.lastExchange = { userText: state.userMessage, replyText: oc.reply };
|
|
805
|
+
this.emit();
|
|
806
|
+
(0, core_1.recordConversationExchange)(this.conversation, state.userMessage, oc.reply, Date.now(), this.conversationOptions);
|
|
807
|
+
this.persistConversation();
|
|
808
|
+
}
|
|
809
|
+
await this.speakReply(oc.reply, oc.audioBase64);
|
|
810
|
+
this.telemetry.logEvent({
|
|
811
|
+
eventType: 'agent_chain_step',
|
|
812
|
+
intentDetected: oc.lastStep?.intent ?? oc.pendingStep?.intent ?? null,
|
|
813
|
+
sessionId: this.sessionId,
|
|
814
|
+
chainStep: step,
|
|
815
|
+
});
|
|
816
|
+
// Sensitive action → ALWAYS pause for confirmation. Park the chain so
|
|
817
|
+
// confirmPendingAction can resume it (only if the model wants to keep
|
|
818
|
+
// going); a rejection stops it cleanly.
|
|
819
|
+
if (oc.hasPending) {
|
|
820
|
+
this.chainState = oc.pendingInProgress
|
|
821
|
+
? {
|
|
822
|
+
userMessage: state.userMessage,
|
|
823
|
+
step,
|
|
824
|
+
lastStep,
|
|
825
|
+
visitedKeys,
|
|
826
|
+
pendingKey: oc.pendingKey,
|
|
827
|
+
pendingStep: oc.pendingStep,
|
|
828
|
+
}
|
|
829
|
+
: null;
|
|
830
|
+
this.beginConfirmationVoiceWindow();
|
|
831
|
+
return;
|
|
832
|
+
}
|
|
833
|
+
if (this.phase !== 'speaking')
|
|
834
|
+
this.setPhase('idle');
|
|
835
|
+
if (oc.actionKey && visitedKeys.has(oc.actionKey)) {
|
|
836
|
+
this.telemetry.logEvent({
|
|
837
|
+
eventType: 'agent_chain_aborted',
|
|
838
|
+
sessionId: this.sessionId,
|
|
839
|
+
chainStep: step,
|
|
840
|
+
chainAbortReason: 'no_progress',
|
|
841
|
+
});
|
|
842
|
+
return;
|
|
843
|
+
}
|
|
844
|
+
if (oc.actionKey)
|
|
845
|
+
visitedKeys.add(oc.actionKey);
|
|
846
|
+
if (!oc.canContinue) {
|
|
847
|
+
this.telemetry.logEvent({
|
|
848
|
+
eventType: 'agent_chain_completed',
|
|
849
|
+
sessionId: this.sessionId,
|
|
850
|
+
chainStep: step,
|
|
851
|
+
});
|
|
852
|
+
return;
|
|
853
|
+
}
|
|
854
|
+
lastStep = oc.lastStep;
|
|
855
|
+
}
|
|
856
|
+
this.telemetry.logEvent({
|
|
857
|
+
eventType: 'agent_chain_aborted',
|
|
858
|
+
sessionId: this.sessionId,
|
|
859
|
+
chainStep: step,
|
|
860
|
+
chainAbortReason: 'max_steps',
|
|
861
|
+
});
|
|
862
|
+
}
|
|
863
|
+
finally {
|
|
864
|
+
this.setChainActive(false);
|
|
865
|
+
}
|
|
866
|
+
}
|
|
374
867
|
// ── Main turn ────────────────────────────────────────────────────────────────
|
|
375
868
|
async handleTranscript(rawText) {
|
|
376
869
|
const text = rawText.trim();
|
|
@@ -379,7 +872,12 @@ class AgentController {
|
|
|
379
872
|
this.setPhase('idle');
|
|
380
873
|
return;
|
|
381
874
|
}
|
|
875
|
+
// Identity gate: if the host user changed since the last turn (login,
|
|
876
|
+
// logout, account switch), swap conversations BEFORE recording anything.
|
|
877
|
+
this.syncConversationIdentity();
|
|
382
878
|
this.setPhase('thinking');
|
|
879
|
+
// REAL activity: the request is genuinely in flight from this moment.
|
|
880
|
+
this.setActivity({ kind: 'thinking' });
|
|
383
881
|
this.pendingTurnText = text;
|
|
384
882
|
this.lastExchange = { userText: text, replyText: '' };
|
|
385
883
|
this.emit();
|
|
@@ -393,6 +891,7 @@ class AgentController {
|
|
|
393
891
|
: 'The response took too long. Please try again.';
|
|
394
892
|
this.lastExchange = { userText, replyText };
|
|
395
893
|
this.pendingTurnText = null;
|
|
894
|
+
this.setActivity(null);
|
|
396
895
|
this.setPhase('idle');
|
|
397
896
|
this.emit();
|
|
398
897
|
}
|
|
@@ -413,11 +912,10 @@ class AgentController {
|
|
|
413
912
|
maxChars: this.config.contextMaxChars,
|
|
414
913
|
});
|
|
415
914
|
const sanitized = rawContext ? this.sanitizer(rawContext).sanitized : undefined;
|
|
416
|
-
const conv = (0, core_1.prepareConversationForTurn)(this.conversation);
|
|
915
|
+
const conv = (0, core_1.prepareConversationForTurn)(this.conversation, Date.now(), this.conversationOptions);
|
|
417
916
|
const controller = new AbortController();
|
|
418
917
|
this.abort = controller;
|
|
419
|
-
let
|
|
420
|
-
let audioBase64 = null;
|
|
918
|
+
let outcome = null;
|
|
421
919
|
try {
|
|
422
920
|
const turn = await this.config.backend.sendTurn({
|
|
423
921
|
message: text,
|
|
@@ -430,79 +928,12 @@ class AgentController {
|
|
|
430
928
|
sessionId: this.sessionId,
|
|
431
929
|
...(0, backendClient_1.buildManifestPayload)(this.config.manifest),
|
|
432
930
|
}, { signal: controller.signal });
|
|
433
|
-
|
|
434
|
-
audioBase64 = turn.audioBase64;
|
|
435
|
-
// Learn whether this backend ships server-side TTS: a non-empty reply
|
|
436
|
-
// with no audio means it doesn't — later lines then speak instantly.
|
|
437
|
-
if (reply)
|
|
438
|
-
this.serverTtsUnavailable = !audioBase64;
|
|
439
|
-
const decision = await (0, turnEngine_1.decideAction)({
|
|
440
|
-
action: turn.action,
|
|
441
|
-
manifest: this.config.manifest,
|
|
442
|
-
executor: this.executor,
|
|
443
|
-
context: snapshot?.context ?? {},
|
|
444
|
-
messages: this.messages,
|
|
445
|
-
userMessage: text,
|
|
446
|
-
});
|
|
447
|
-
if (decision.kind === 'executed') {
|
|
448
|
-
const r = decision.result;
|
|
449
|
-
if (turn.action?.type === 'navigate') {
|
|
450
|
-
this.telemetry.logEvent({
|
|
451
|
-
eventType: r.success ? 'navigation_executed' : 'navigation_failed',
|
|
452
|
-
intentDetected: turn.action.intent,
|
|
453
|
-
sessionId: this.sessionId,
|
|
454
|
-
currentRoute,
|
|
455
|
-
});
|
|
456
|
-
}
|
|
457
|
-
else if (turn.action?.type === 'execute') {
|
|
458
|
-
this.telemetry.logEvent({
|
|
459
|
-
eventType: r.recoverable
|
|
460
|
-
? 'action_needs_context'
|
|
461
|
-
: r.success
|
|
462
|
-
? 'action_executed'
|
|
463
|
-
: 'action_failed',
|
|
464
|
-
intentDetected: turn.action.intent,
|
|
465
|
-
sessionId: this.sessionId,
|
|
466
|
-
actionExecuted: { intent: turn.action.intent, confirmed: false },
|
|
467
|
-
});
|
|
468
|
-
}
|
|
469
|
-
if (r.recoverable) {
|
|
470
|
-
/* keep the assistant's guiding reply; never speak a refusal. */
|
|
471
|
-
}
|
|
472
|
-
else if (r.message) {
|
|
473
|
-
reply = r.message;
|
|
474
|
-
audioBase64 = null;
|
|
475
|
-
}
|
|
476
|
-
}
|
|
477
|
-
else if (decision.kind === 'precheck-resolved') {
|
|
478
|
-
if (decision.result.message && !decision.result.recoverable) {
|
|
479
|
-
reply = decision.result.message;
|
|
480
|
-
audioBase64 = null;
|
|
481
|
-
}
|
|
482
|
-
}
|
|
483
|
-
else if (decision.kind === 'needs-confirmation') {
|
|
484
|
-
this.pending = { ...decision.pending, silent: this.silentTurn };
|
|
485
|
-
this.confirmationReasked = false;
|
|
486
|
-
this.confirmationMessage = decision.pending.question;
|
|
487
|
-
this.confirmationVoiceHint =
|
|
488
|
-
decision.pending.voiceMode === 'strict' &&
|
|
489
|
-
decision.pending.strictPhrase &&
|
|
490
|
-
!this.silentTurn
|
|
491
|
-
? (0, core_1.formatMessage)(this.messages.strictConfirmationModalHint, {
|
|
492
|
-
phrase: decision.pending.strictPhrase,
|
|
493
|
-
})
|
|
494
|
-
: null;
|
|
495
|
-
this.emit();
|
|
496
|
-
this.telemetry.logEvent({
|
|
497
|
-
eventType: 'action_confirmation_requested',
|
|
498
|
-
intentDetected: decision.pending.intent,
|
|
499
|
-
sessionId: this.sessionId,
|
|
500
|
-
});
|
|
501
|
-
}
|
|
931
|
+
outcome = await this.applyOutcome(turn, currentRoute, snapshot?.context ?? {}, text);
|
|
502
932
|
}
|
|
503
933
|
catch (e) {
|
|
504
934
|
this.clearWatchdog();
|
|
505
935
|
this.abort = null;
|
|
936
|
+
this.setActivity(null);
|
|
506
937
|
if (e instanceof errors_1.AgentApiError && e.code === 'cancelled') {
|
|
507
938
|
if (this.phase === 'thinking')
|
|
508
939
|
this.setPhase('idle');
|
|
@@ -524,10 +955,14 @@ class AgentController {
|
|
|
524
955
|
this.clearWatchdog();
|
|
525
956
|
this.abort = null;
|
|
526
957
|
this.pendingTurnText = null;
|
|
958
|
+
if (!outcome)
|
|
959
|
+
return;
|
|
960
|
+
const reply = outcome.reply;
|
|
527
961
|
if (reply) {
|
|
528
962
|
this.lastExchange = { userText: text, replyText: reply };
|
|
529
963
|
this.emit();
|
|
530
|
-
(0, core_1.recordConversationExchange)(this.conversation, text, reply);
|
|
964
|
+
(0, core_1.recordConversationExchange)(this.conversation, text, reply, Date.now(), this.conversationOptions);
|
|
965
|
+
this.persistConversation();
|
|
531
966
|
}
|
|
532
967
|
else {
|
|
533
968
|
const fallback = this.config.locale.startsWith('es')
|
|
@@ -536,14 +971,53 @@ class AgentController {
|
|
|
536
971
|
this.lastExchange = { userText: text, replyText: fallback };
|
|
537
972
|
this.emit();
|
|
538
973
|
}
|
|
539
|
-
const hasPending =
|
|
540
|
-
await this.speakReply(reply, audioBase64);
|
|
974
|
+
const hasPending = outcome.hasPending;
|
|
975
|
+
await this.speakReply(reply, outcome.audioBase64);
|
|
541
976
|
if (hasPending) {
|
|
542
977
|
this.beginConfirmationVoiceWindow();
|
|
978
|
+
// First step is a sensitive action AND the model wants to keep going:
|
|
979
|
+
// park the chain so it RESUMES after the user confirms.
|
|
980
|
+
if (this.chaining.enabled && outcome.pendingInProgress) {
|
|
981
|
+
this.telemetry.logEvent({
|
|
982
|
+
eventType: 'agent_chain_started',
|
|
983
|
+
sessionId: this.sessionId,
|
|
984
|
+
chainStep: 1,
|
|
985
|
+
});
|
|
986
|
+
this.chainState = {
|
|
987
|
+
userMessage: text,
|
|
988
|
+
step: 1,
|
|
989
|
+
lastStep: outcome.pendingStep,
|
|
990
|
+
visitedKeys: new Set(),
|
|
991
|
+
pendingKey: outcome.pendingKey,
|
|
992
|
+
pendingStep: outcome.pendingStep,
|
|
993
|
+
};
|
|
994
|
+
}
|
|
543
995
|
}
|
|
544
996
|
else if (this.phase !== 'speaking') {
|
|
545
997
|
this.setPhase('idle');
|
|
546
998
|
}
|
|
999
|
+
// Kick off the autonomous chain when the first step executed cleanly and
|
|
1000
|
+
// the backend asked to continue (no sensitive confirmation pending).
|
|
1001
|
+
if (this.chaining.enabled && outcome.canContinue && !hasPending) {
|
|
1002
|
+
this.telemetry.logEvent({
|
|
1003
|
+
eventType: 'agent_chain_started',
|
|
1004
|
+
sessionId: this.sessionId,
|
|
1005
|
+
chainStep: 1,
|
|
1006
|
+
});
|
|
1007
|
+
const visitedKeys = new Set();
|
|
1008
|
+
if (outcome.actionKey)
|
|
1009
|
+
visitedKeys.add(outcome.actionKey);
|
|
1010
|
+
await this.runChainLoop({
|
|
1011
|
+
userMessage: text,
|
|
1012
|
+
step: 1,
|
|
1013
|
+
lastStep: outcome.lastStep,
|
|
1014
|
+
visitedKeys,
|
|
1015
|
+
});
|
|
1016
|
+
}
|
|
1017
|
+
// Final reply is on screen: the live narrative disappears (unless the
|
|
1018
|
+
// agent is genuinely waiting for a confirmation, which stays honest).
|
|
1019
|
+
if (!this.pending)
|
|
1020
|
+
this.setActivity(null);
|
|
547
1021
|
this.config.onTurnCompleted?.();
|
|
548
1022
|
}
|
|
549
1023
|
// ── Speech session wiring ────────────────────────────────────────────────────
|
|
@@ -656,6 +1130,11 @@ class AgentController {
|
|
|
656
1130
|
this.emit();
|
|
657
1131
|
this.telemetry.logEvent({ eventType: 'agent_consent_declined', sessionId: this.sessionId });
|
|
658
1132
|
}
|
|
1133
|
+
/**
|
|
1134
|
+
* Cancel everything in flight. If a first turn was still awaiting its reply,
|
|
1135
|
+
* its message is removed from the panel and returned here so the UI can put
|
|
1136
|
+
* it back into the input for editing; null otherwise.
|
|
1137
|
+
*/
|
|
659
1138
|
cancelAll() {
|
|
660
1139
|
this.abort?.abort();
|
|
661
1140
|
this.abort = null;
|
|
@@ -665,10 +1144,22 @@ class AgentController {
|
|
|
665
1144
|
this.speech.cancel();
|
|
666
1145
|
this.config.voice.stopPlayback();
|
|
667
1146
|
this.pending = null;
|
|
1147
|
+
this.chainState = null;
|
|
668
1148
|
this.confirmationMessage = null;
|
|
669
1149
|
this.confirmationVoiceHint = null;
|
|
1150
|
+
this.liveActivity = null;
|
|
1151
|
+
this.chainActive = false;
|
|
1152
|
+
// A first turn was still in flight: remove its message from the panel and
|
|
1153
|
+
// hand the text back for editing (the turn never completed, so nothing
|
|
1154
|
+
// was recorded in the conversation).
|
|
1155
|
+
const draft = this.pendingTurnText;
|
|
1156
|
+
if (draft) {
|
|
1157
|
+
this.pendingTurnText = null;
|
|
1158
|
+
this.lastExchange = null;
|
|
1159
|
+
}
|
|
670
1160
|
this.setPhase('idle');
|
|
671
1161
|
this.emit();
|
|
1162
|
+
return draft;
|
|
672
1163
|
}
|
|
673
1164
|
/** Best-effort backend warm-up (cold-start mitigation). */
|
|
674
1165
|
warmUp() {
|
|
@@ -676,6 +1167,12 @@ class AgentController {
|
|
|
676
1167
|
}
|
|
677
1168
|
dispose() {
|
|
678
1169
|
this.cancelAll();
|
|
1170
|
+
this.removeIdentityListeners?.();
|
|
1171
|
+
this.removeIdentityListeners = null;
|
|
1172
|
+
if (this.loadOlderTimer) {
|
|
1173
|
+
clearTimeout(this.loadOlderTimer);
|
|
1174
|
+
this.loadOlderTimer = null;
|
|
1175
|
+
}
|
|
679
1176
|
this.unsubscribeSpeech?.();
|
|
680
1177
|
this.unsubscribeSpeech = null;
|
|
681
1178
|
this.speech.dispose();
|