handhold-sdk 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +427 -0
- package/dist/d44f1fe8001e32f8e74d.svg +3056 -0
- package/dist/fonts/Phosphor.ttf +0 -0
- package/dist/fonts/Phosphor.woff +0 -0
- package/dist/fonts/Phosphor.woff2 +0 -0
- package/dist/handhold.min.js +1 -0
- package/package.json +114 -0
- package/src/HandHold.js +1599 -0
- package/src/agent/VENDOR.md +203 -0
- package/src/agent/agent/AgentLoop.js +366 -0
- package/src/agent/agent/prompts.js +163 -0
- package/src/agent/agent/tools.js +148 -0
- package/src/agent/controller.js +235 -0
- package/src/agent/dom/actions.js +456 -0
- package/src/agent/dom/domTree.js +1761 -0
- package/src/agent/dom/redact.js +98 -0
- package/src/agent/dom/serializer.js +332 -0
- package/src/agent/dom/utils.js +99 -0
- package/src/agent/index.js +169 -0
- package/src/agent/llm/connector.js +143 -0
- package/src/agent/package.json +3 -0
- package/src/agent/policy/PolicyGuard.js +172 -0
- package/src/agent/site/manifest.js +145 -0
- package/src/agent/ui/ChatWidget.js +219 -0
- package/src/agent-mode/AgentMode.js +429 -0
- package/src/api/APIClient.js +258 -0
- package/src/core/Config.js +207 -0
- package/src/core/UiState.js +83 -0
- package/src/core/defaults.js +20 -0
- package/src/events.js +59 -0
- package/src/index.js +77 -0
- package/src/intent/ActionVerbMap.js +324 -0
- package/src/intent/IntentExtractor.js +317 -0
- package/src/observers/ElementScanner.js +284 -0
- package/src/observers/NavigationObserver.js +182 -0
- package/src/observers/PageObserver.js +293 -0
- package/src/session/SessionManager.js +402 -0
- package/src/session/SessionStorage.js +148 -0
- package/src/ui/HelpWidget.js +1189 -0
- package/src/ui/UICoach.js +1725 -0
- package/src/ui/components/Button.css +137 -0
- package/src/ui/components/Button.js +102 -0
- package/src/ui/widget.css +599 -0
- package/src/utils/Logger.js +132 -0
- package/src/utils/MarkdownRenderer.js +39 -0
- package/src/utils/domUtils.js +350 -0
- package/src/utils/eventBus.js +102 -0
- package/src/utils/index.js +8 -0
- package/src/utils/stringUtils.js +202 -0
- package/types/index.d.ts +538 -0
package/src/HandHold.js
ADDED
|
@@ -0,0 +1,1599 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hand Hold SDK - Main Orchestrator Class
|
|
3
|
+
*
|
|
4
|
+
* Provides context-aware, in-app help by combining:
|
|
5
|
+
* - Intent menu inferred from current page UI
|
|
6
|
+
* - Conversational fallback for free-text questions
|
|
7
|
+
* - Visual UI guidance with element highlighting
|
|
8
|
+
* - Multi-page workflow tracking
|
|
9
|
+
*
|
|
10
|
+
* @version 1.0.0
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import Config from './core/Config.js';
|
|
14
|
+
import { Logger } from './utils/Logger.js';
|
|
15
|
+
import EventBus from './utils/eventBus.js';
|
|
16
|
+
import APIClient from './api/APIClient.js';
|
|
17
|
+
import PageObserver from './observers/PageObserver.js';
|
|
18
|
+
import IntentExtractor from './intent/IntentExtractor.js';
|
|
19
|
+
import SessionManager from './session/SessionManager.js';
|
|
20
|
+
import HelpWidget from './ui/HelpWidget.js';
|
|
21
|
+
import UICoach from './ui/UICoach.js';
|
|
22
|
+
import AgentMode from './agent-mode/AgentMode.js';
|
|
23
|
+
import UiState from './core/UiState.js';
|
|
24
|
+
import EVENTS from './events.js';
|
|
25
|
+
import html2canvas from 'html2canvas';
|
|
26
|
+
|
|
27
|
+
class HandHold {
|
|
28
|
+
constructor() {
|
|
29
|
+
// Core components (initialized on init())
|
|
30
|
+
this.config = null;
|
|
31
|
+
this.logger = null;
|
|
32
|
+
this.apiClient = null;
|
|
33
|
+
this.pageObserver = null;
|
|
34
|
+
this.sessionManager = null;
|
|
35
|
+
this.helpWidget = null;
|
|
36
|
+
this.uiCoach = null;
|
|
37
|
+
this.agentMode = null;
|
|
38
|
+
// Single source of truth for the widget UI (Phase 2.1). Host renderers
|
|
39
|
+
// subscribe via HandHold.subscribe()/getState(); the legacy widget mirrors
|
|
40
|
+
// its view changes into it (HelpWidget._syncState).
|
|
41
|
+
this.uiState = new UiState();
|
|
42
|
+
// Bound so a host can pass them straight to useSyncExternalStore.
|
|
43
|
+
this.subscribe = this.subscribe.bind(this);
|
|
44
|
+
this.getState = this.getState.bind(this);
|
|
45
|
+
|
|
46
|
+
// State
|
|
47
|
+
this.initialized = false;
|
|
48
|
+
this.currentIntents = [];
|
|
49
|
+
this.isUpdating = false;
|
|
50
|
+
this.isMatchingStep = false;
|
|
51
|
+
this.lastStepAdvanceTime = 0;
|
|
52
|
+
|
|
53
|
+
// Host-agnostic step-completion detection. We attach a one-shot interaction
|
|
54
|
+
// listener to the CURRENT step's real target element so a step advances when
|
|
55
|
+
// the user actually performs the action — in ANY host, including one that
|
|
56
|
+
// renders its own UI (and therefore suppresses the SDK's own overlay). This
|
|
57
|
+
// is the single source of auto-advance; the visual highlight no longer
|
|
58
|
+
// carries its own onClick (which only fired when the SDK drew the overlay
|
|
59
|
+
// and skipped the first step entirely).
|
|
60
|
+
this._watchedEl = null;
|
|
61
|
+
this._watchHandlers = null;
|
|
62
|
+
// Timestamp of the last navigation-type step completion. Lets
|
|
63
|
+
// _onNavigationChanged tell an EXPECTED step navigation (clicked the
|
|
64
|
+
// highlighted "Click X" element) from the user wandering off (which prompts
|
|
65
|
+
// to keep/exit the workflow).
|
|
66
|
+
this._stepNavAt = 0;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Initialize the SDK
|
|
71
|
+
* @param {Object} options - Configuration options
|
|
72
|
+
* @param {string} options.apiKey - API key for authentication (required)
|
|
73
|
+
* @param {string} options.baseUrl - Backend API base URL (required)
|
|
74
|
+
* @param {string} [options.theme='light'] - UI theme ('light' or 'dark')
|
|
75
|
+
* @param {string} [options.position='bottom-right'] - Widget position
|
|
76
|
+
* @param {string} [options.primaryColor='#2196F3'] - Primary color
|
|
77
|
+
* @param {number} [options.minHighlightConfidence=0.85] - Min confidence for highlighting
|
|
78
|
+
* @param {number} [options.sessionInactivityTimeout=600000] - Session timeout (ms)
|
|
79
|
+
* @param {boolean} [options.enableMultiPageTracking=true] - Enable multi-page flows
|
|
80
|
+
* @param {boolean} [options.debug=false] - Enable debug logging
|
|
81
|
+
* @returns {Promise<void>}
|
|
82
|
+
*/
|
|
83
|
+
async init(options) {
|
|
84
|
+
if (this.initialized) {
|
|
85
|
+
console.warn('[HandHold] Already initialized. Call destroy() first to reinitialize.');
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
// Initialize configuration
|
|
91
|
+
this.config = new Config(options);
|
|
92
|
+
|
|
93
|
+
// Initialize logger
|
|
94
|
+
this.logger = new Logger({
|
|
95
|
+
prefix: 'HandHold',
|
|
96
|
+
level: this.config.get('logLevel'),
|
|
97
|
+
enabled: this.config.get('debug')
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
this.logger.info('Initializing Hand Hold SDK...');
|
|
101
|
+
|
|
102
|
+
// Initialize API client
|
|
103
|
+
this.apiClient = new APIClient(this.config);
|
|
104
|
+
|
|
105
|
+
// Initialize page observer
|
|
106
|
+
this.pageObserver = new PageObserver(this.config);
|
|
107
|
+
|
|
108
|
+
// Initialize session manager
|
|
109
|
+
this.sessionManager = new SessionManager(this.config);
|
|
110
|
+
|
|
111
|
+
// Initialize UI coach
|
|
112
|
+
this.uiCoach = new UICoach(this.config);
|
|
113
|
+
|
|
114
|
+
// Initialize help widget and wire it to the single UI-state store.
|
|
115
|
+
this.helpWidget = new HelpWidget(this.config);
|
|
116
|
+
this.helpWidget.attachState(this.uiState);
|
|
117
|
+
// Re-broadcast state changes as a single event for any event-based consumer.
|
|
118
|
+
this.uiState.subscribe((snapshot) => EventBus.emit(EVENTS.STATE_CHANGED, snapshot));
|
|
119
|
+
|
|
120
|
+
// Initialize agent mode (H5) — feature-flagged, off by default.
|
|
121
|
+
// "Show me"/"Explain" behavior is untouched when disabled.
|
|
122
|
+
if (this.config.get('agentMode')) {
|
|
123
|
+
this.agentMode = new AgentMode({
|
|
124
|
+
config: this.config,
|
|
125
|
+
eventBus: EventBus,
|
|
126
|
+
sessionManager: this.sessionManager
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Setup event listeners
|
|
131
|
+
this._setupEventListeners();
|
|
132
|
+
|
|
133
|
+
// Start page observation
|
|
134
|
+
this.pageObserver.observe();
|
|
135
|
+
|
|
136
|
+
// Render help widget first (needed for session restoration)
|
|
137
|
+
this.helpWidget.render();
|
|
138
|
+
this.uiCoach.enableSelectionExplain();
|
|
139
|
+
|
|
140
|
+
// Restore agent/guidance state. A host that renders the widget itself
|
|
141
|
+
// (React demo) sets `deferRestore` and calls restoreSession() AFTER it has
|
|
142
|
+
// patched its renderer — otherwise the session-restore "continue?" confirm
|
|
143
|
+
// is drawn by the unpatched (and about-to-be-hidden) legacy widget and
|
|
144
|
+
// init blocks on it (Phase 1.4).
|
|
145
|
+
if (!this.config.get('deferRestore')) {
|
|
146
|
+
await this.restoreSession();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
this.initialized = true;
|
|
150
|
+
this.logger.info('Hand Hold SDK initialized successfully');
|
|
151
|
+
|
|
152
|
+
// Emit ready event
|
|
153
|
+
EventBus.emit('handhold:ready');
|
|
154
|
+
|
|
155
|
+
} catch (error) {
|
|
156
|
+
console.error('[HandHold] Initialization failed:', error);
|
|
157
|
+
throw error;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Restore agent/guidance state after boot. Separated from init() so a host
|
|
163
|
+
* that owns the widget rendering can patch its renderer first (deferRestore),
|
|
164
|
+
* then call this so the confirm is drawn by the host UI, not the legacy widget.
|
|
165
|
+
*
|
|
166
|
+
* Cross-page agent resume (H7) runs BEFORE the "Show me" session restore and
|
|
167
|
+
* takes precedence: if a "Do it for me" run was mid-flight when the page
|
|
168
|
+
* full-reloaded, resume it and SKIP the guidance-session prompt (declining it
|
|
169
|
+
* would end the session and delete the agent checkpoint). The resumed run
|
|
170
|
+
* continues in the background; boot does not block on it.
|
|
171
|
+
* @returns {Promise<void>}
|
|
172
|
+
*/
|
|
173
|
+
async restoreSession() {
|
|
174
|
+
let agentResumed = false;
|
|
175
|
+
if (this.agentMode) {
|
|
176
|
+
try {
|
|
177
|
+
const r = await this.agentMode.resumeIfCheckpointed();
|
|
178
|
+
agentResumed = !!(r && r.resumed);
|
|
179
|
+
} catch (error) {
|
|
180
|
+
this.logger.error('Agent resume failed:', error);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
if (!agentResumed) {
|
|
184
|
+
await this._restoreSession();
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Setup internal event listeners
|
|
190
|
+
* @private
|
|
191
|
+
*/
|
|
192
|
+
_setupEventListeners() {
|
|
193
|
+
// Help button clicked
|
|
194
|
+
EventBus.on('help:open', () => this._onHelpOpen());
|
|
195
|
+
|
|
196
|
+
// Intent selected from menu
|
|
197
|
+
EventBus.on('intent:selected', (intent) => this._onIntentSelected(intent));
|
|
198
|
+
|
|
199
|
+
// Free text query submitted
|
|
200
|
+
EventBus.on('query:submitted', (query) => this._onQuerySubmitted(query));
|
|
201
|
+
|
|
202
|
+
// Navigation changed
|
|
203
|
+
EventBus.on('navigation:changed', (data) => this._onNavigationChanged(data));
|
|
204
|
+
|
|
205
|
+
// Step marked as done
|
|
206
|
+
EventBus.on('step:done', () => this._onStepDone());
|
|
207
|
+
|
|
208
|
+
// Step skipped
|
|
209
|
+
EventBus.on('step:skip', () => this._onStepSkip());
|
|
210
|
+
|
|
211
|
+
// Session close requested (end the workflow)
|
|
212
|
+
EventBus.on('session:close', () => this._onSessionClose());
|
|
213
|
+
|
|
214
|
+
// Minimize requested (hide panel, KEEP the workflow so reopening resumes it)
|
|
215
|
+
EventBus.on(EVENTS.HELP_MINIMIZE, () => this._onMinimize());
|
|
216
|
+
|
|
217
|
+
// Re-show the current step's on-page hint (re-resolve + re-highlight + re-arm)
|
|
218
|
+
EventBus.on(EVENTS.HELP_SHOW_HINT, () => this._showCurrentStep());
|
|
219
|
+
|
|
220
|
+
// DOM changes (debounced by PageObserver)
|
|
221
|
+
EventBus.on('page:dom_changed', () => this._onDomChanged());
|
|
222
|
+
|
|
223
|
+
// Show UI for specific step
|
|
224
|
+
EventBus.on('step:show_ui', (data) => this._onShowUI(data));
|
|
225
|
+
|
|
226
|
+
EventBus.on('selection:explain', (data) => this._onSelectionExplain(data));
|
|
227
|
+
|
|
228
|
+
// Handle highlight request (emitted by HelpWidget when selector is already known)
|
|
229
|
+
EventBus.on('ui:highlight_request', async (data) => {
|
|
230
|
+
const { selector, stepId } = data;
|
|
231
|
+
const session = this.sessionManager.getActiveSession();
|
|
232
|
+
const step = session?.steps?.[stepId];
|
|
233
|
+
|
|
234
|
+
// await the real outcome (Phase 5.3) — resolves false if the element is
|
|
235
|
+
// missing OR detaches before the highlight builds, so the fallback fires.
|
|
236
|
+
// No onClick here: completion detection is owned by the host-agnostic
|
|
237
|
+
// watcher (_watchStepElement) so it works even when a host suppresses the
|
|
238
|
+
// SDK overlay, and clicking never advances twice.
|
|
239
|
+
const found = await this.uiCoach.highlightElement(selector, {
|
|
240
|
+
message: step?.text,
|
|
241
|
+
showArrow: true,
|
|
242
|
+
showTooltip: true,
|
|
243
|
+
showDoneButton: true,
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
if (found) {
|
|
247
|
+
this._watchStepElement(document.querySelector(selector), step);
|
|
248
|
+
} else {
|
|
249
|
+
// If selector is stale and element wasn't found, fall back to API-based matching
|
|
250
|
+
EventBus.emit('step:show_ui', { stepIndex: stepId });
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
// ---- Agent mode (H5): engine events -> widget UI + best-effort spotlight ----
|
|
255
|
+
// AgentMode itself listens for 'agent:start' (emitted by the widget's
|
|
256
|
+
// "Do it for me" button) and 'agent:stop'. These listeners only render.
|
|
257
|
+
EventBus.on('agent:activity', (ev) => {
|
|
258
|
+
if (!this.agentMode) return;
|
|
259
|
+
this.helpWidget.addAgentActivity(ev);
|
|
260
|
+
// Reserve space so the fixed built-in panel doesn't occlude page content
|
|
261
|
+
// from the agent (any site). run_start (fresh) or resuming (after reload).
|
|
262
|
+
if (ev?.type === 'run_start' || ev?.type === 'resuming') {
|
|
263
|
+
this._setAgentReflow(true);
|
|
264
|
+
}
|
|
265
|
+
if (ev?.type === 'executing' && ev.targetSelector) {
|
|
266
|
+
// Spotlight what the agent is about to act on (best-effort: a stale
|
|
267
|
+
// selector just logs a warning inside UICoach).
|
|
268
|
+
this.uiCoach.highlightElement(ev.targetSelector, {
|
|
269
|
+
message: ev.nextGoal || 'Working…',
|
|
270
|
+
showArrow: true,
|
|
271
|
+
showTooltip: Boolean(ev.nextGoal),
|
|
272
|
+
showDoneButton: false
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
EventBus.on('agent:confirm', ({ pending, respond }) => {
|
|
278
|
+
if (!this.agentMode) return;
|
|
279
|
+
this.helpWidget.showAgentConfirm(pending, respond);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
EventBus.on('agent:ask', ({ question, respond }) => {
|
|
283
|
+
if (!this.agentMode) return;
|
|
284
|
+
this.helpWidget.showAgentAsk(question, respond);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
EventBus.on('agent:done', (result) => {
|
|
288
|
+
if (!this.agentMode) return;
|
|
289
|
+
this.uiCoach.clearHighlight();
|
|
290
|
+
this.helpWidget.showAgentDone(result);
|
|
291
|
+
this._setAgentReflow(false);
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Agent-mode reflow (works on ANY site). The built-in widget is a fixed
|
|
297
|
+
* right-side panel; during an agent run it visually OCCLUDES page content, and
|
|
298
|
+
* the agent's DOM engine treats covered elements as not-visible
|
|
299
|
+
* (elementFromPoint), so it can't see/click them. Reserve space on the right
|
|
300
|
+
* (a margin on <html>) so the page reflows left and every element stays
|
|
301
|
+
* visible — released when the run ends.
|
|
302
|
+
*
|
|
303
|
+
* Skipped when the built-in widget isn't visible: a custom host that hides it
|
|
304
|
+
* (e.g. the React demo) owns its own layout and docks its own panel.
|
|
305
|
+
* @param {boolean} active
|
|
306
|
+
* @private
|
|
307
|
+
*/
|
|
308
|
+
_setAgentReflow(active) {
|
|
309
|
+
if (typeof document === 'undefined') return;
|
|
310
|
+
const html = document.documentElement;
|
|
311
|
+
|
|
312
|
+
if (!active) {
|
|
313
|
+
html.style.transition = 'margin-right 0.3s ease';
|
|
314
|
+
html.style.removeProperty('margin-right');
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const container = this.helpWidget?.container;
|
|
319
|
+
if (!container) return;
|
|
320
|
+
// A host that hides the built-in widget handles its own layout.
|
|
321
|
+
const computed = (typeof window !== 'undefined' && window.getComputedStyle)
|
|
322
|
+
? window.getComputedStyle(container)
|
|
323
|
+
: null;
|
|
324
|
+
if (container.style.display === 'none' || (computed && computed.display === 'none')) {
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// Reserve the panel width (.handhold-widget is 380px) + a gap. Measure when
|
|
329
|
+
// possible; fall back to the known CSS width (jsdom returns 0 for layout).
|
|
330
|
+
const panel = container.querySelector('.handhold-widget');
|
|
331
|
+
const measured = panel && panel.getBoundingClientRect ? panel.getBoundingClientRect().width : 0;
|
|
332
|
+
const reserve = Math.ceil(measured || 380) + 40;
|
|
333
|
+
html.style.transition = 'margin-right 0.3s ease';
|
|
334
|
+
html.style.marginRight = `${reserve}px`;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Handle show UI request for a step
|
|
339
|
+
* @private
|
|
340
|
+
* @param {Object} data - { stepIndex }
|
|
341
|
+
*/
|
|
342
|
+
async _onShowUI(data) {
|
|
343
|
+
const { stepIndex } = data;
|
|
344
|
+
this.logger.debug('Show UI requested for step:', stepIndex);
|
|
345
|
+
|
|
346
|
+
const session = this.sessionManager.getActiveSession();
|
|
347
|
+
if (!session) return;
|
|
348
|
+
|
|
349
|
+
const step = session.steps[stepIndex];
|
|
350
|
+
if (!step) return;
|
|
351
|
+
|
|
352
|
+
try {
|
|
353
|
+
this.helpWidget.showLoading();
|
|
354
|
+
|
|
355
|
+
// Scan page
|
|
356
|
+
const pageContext = this.pageObserver.scanPage();
|
|
357
|
+
|
|
358
|
+
// Request matching for this specific step
|
|
359
|
+
const matchResult = await this.apiClient.matchElement({
|
|
360
|
+
step_description: step.text,
|
|
361
|
+
original_selector: step.selector,
|
|
362
|
+
route: pageContext.route,
|
|
363
|
+
ui_elements: pageContext.ui_elements,
|
|
364
|
+
session_id: session.session_id,
|
|
365
|
+
intent: session.intent,
|
|
366
|
+
steps: session.steps,
|
|
367
|
+
current_step_index: stepIndex
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
this.helpWidget.showGuidanceTooltip(session); // Restore view
|
|
371
|
+
|
|
372
|
+
if (matchResult.found && matchResult.confidence >= this.config.get('minHighlightConfidence')) {
|
|
373
|
+
// Update step with new selector
|
|
374
|
+
this.sessionManager.updateStep(stepIndex, {
|
|
375
|
+
selector: matchResult.selector,
|
|
376
|
+
confidence: matchResult.confidence,
|
|
377
|
+
match_type: matchResult.match_type
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
// Highlight (visual only). Detection is owned by the watcher below.
|
|
381
|
+
this.uiCoach.highlightElement(matchResult.selector, {
|
|
382
|
+
message: step.text,
|
|
383
|
+
showArrow: true,
|
|
384
|
+
showTooltip: true,
|
|
385
|
+
showDoneButton: true,
|
|
386
|
+
});
|
|
387
|
+
this._watchStepElement(document.querySelector(matchResult.selector), step);
|
|
388
|
+
|
|
389
|
+
} else {
|
|
390
|
+
this.uiCoach.clearHighlight();
|
|
391
|
+
this._unwatchStepElement();
|
|
392
|
+
this.helpWidget.showError(`Could not find element for: "${step.text}" on this page.`);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
} catch (error) {
|
|
396
|
+
this.logger.error('Error showing UI:', error);
|
|
397
|
+
this.helpWidget.showGuidanceTooltip(session);
|
|
398
|
+
this.helpWidget.showError('Failed to locate element.');
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Handle help button click
|
|
404
|
+
* @private
|
|
405
|
+
*/
|
|
406
|
+
async _onHelpOpen() {
|
|
407
|
+
this.logger.debug('Help opened');
|
|
408
|
+
|
|
409
|
+
// Restore ONLY an active session. A completed/ended session lingering in
|
|
410
|
+
// storage must not reappear as a live workflow (Phase 1.3) — end it and
|
|
411
|
+
// fall through to a fresh intent menu.
|
|
412
|
+
const session = this.sessionManager.getActiveSession();
|
|
413
|
+
if (session && session.status === 'active') {
|
|
414
|
+
// Resume the workflow where it was left off (e.g. after Close = minimize)
|
|
415
|
+
// and re-arm on-page detection + the highlight for the current step.
|
|
416
|
+
this.helpWidget.showGuidanceTooltip(session);
|
|
417
|
+
this._showCurrentStep();
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
if (session) {
|
|
421
|
+
this.sessionManager.endSession('completed');
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
try {
|
|
425
|
+
// Scan current page
|
|
426
|
+
const pageContext = this.pageObserver.scanPage();
|
|
427
|
+
this.logger.debug('Page scanned:', pageContext);
|
|
428
|
+
|
|
429
|
+
// Extract intents (client-side, instant)
|
|
430
|
+
this.currentIntents = IntentExtractor.extract(
|
|
431
|
+
pageContext.ui_elements,
|
|
432
|
+
pageContext.route,
|
|
433
|
+
{ maxIntents: this.config.get('maxIntents') }
|
|
434
|
+
);
|
|
435
|
+
|
|
436
|
+
this.logger.debug('Intents extracted:', this.currentIntents);
|
|
437
|
+
|
|
438
|
+
// Show intent menu
|
|
439
|
+
this.helpWidget.showIntentMenu(this.currentIntents);
|
|
440
|
+
|
|
441
|
+
} catch (error) {
|
|
442
|
+
this.logger.error('Error opening help:', error);
|
|
443
|
+
this.helpWidget.showError('Failed to load help. Please try again.');
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* Handle intent selection
|
|
449
|
+
* @private
|
|
450
|
+
* @param {Object} intent - Selected intent
|
|
451
|
+
*/
|
|
452
|
+
async _onIntentSelected(intent) {
|
|
453
|
+
this.logger.debug('Intent selected:', intent);
|
|
454
|
+
|
|
455
|
+
try {
|
|
456
|
+
this.helpWidget.showLoading();
|
|
457
|
+
|
|
458
|
+
// Get page context and UI elements
|
|
459
|
+
const pageContext = this.pageObserver.scanPage();
|
|
460
|
+
|
|
461
|
+
// Capture screenshot for visual context
|
|
462
|
+
const screenshot = await this._captureScreenshot();
|
|
463
|
+
|
|
464
|
+
// Generate or get session ID
|
|
465
|
+
const sessionId = this.sessionManager.getSessionId();
|
|
466
|
+
|
|
467
|
+
// Request guidance from backend
|
|
468
|
+
const guidance = await this.apiClient.getGuidance({
|
|
469
|
+
intent: intent.key,
|
|
470
|
+
route: pageContext.route,
|
|
471
|
+
page_context: {
|
|
472
|
+
route: pageContext.route,
|
|
473
|
+
title: pageContext.title,
|
|
474
|
+
headings: pageContext.headings || []
|
|
475
|
+
},
|
|
476
|
+
ui_elements: pageContext.ui_elements,
|
|
477
|
+
session_id: sessionId,
|
|
478
|
+
screenshot: screenshot
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
this.logger.debug('Guidance received:', guidance);
|
|
482
|
+
|
|
483
|
+
// Start session
|
|
484
|
+
this.sessionManager.startSession({
|
|
485
|
+
session_id: guidance.session_id, // Use backend-provided session ID
|
|
486
|
+
intent: intent.key,
|
|
487
|
+
intent_label: intent.label,
|
|
488
|
+
steps: guidance.steps,
|
|
489
|
+
original_route: pageContext.route,
|
|
490
|
+
summary: guidance.summary
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
// Show guidance tooltip
|
|
494
|
+
this.helpWidget.showGuidanceTooltip(guidance);
|
|
495
|
+
|
|
496
|
+
// Resolve + highlight + ARM completion detection for the first step. This
|
|
497
|
+
// was previously skipped ("just show the list"), which is why doing the
|
|
498
|
+
// first action never advanced it — nothing was listening on the target
|
|
499
|
+
// element. Now every step, including the first, is watched.
|
|
500
|
+
this._showCurrentStep();
|
|
501
|
+
|
|
502
|
+
} catch (error) {
|
|
503
|
+
this.logger.error('Error getting guidance:', error);
|
|
504
|
+
|
|
505
|
+
if (error.status === 404) {
|
|
506
|
+
this.helpWidget.showError(
|
|
507
|
+
'No help article found for this action. Please contact support.'
|
|
508
|
+
);
|
|
509
|
+
} else {
|
|
510
|
+
this.helpWidget.showError(
|
|
511
|
+
'Failed to load guidance. Please try again or contact support.'
|
|
512
|
+
);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* Handle free text query submission
|
|
519
|
+
* @private
|
|
520
|
+
* @param {string} query - User query
|
|
521
|
+
*/
|
|
522
|
+
async _onQuerySubmitted(query) {
|
|
523
|
+
this.logger.debug('Query submitted:', query);
|
|
524
|
+
|
|
525
|
+
try {
|
|
526
|
+
this.helpWidget.showLoading();
|
|
527
|
+
|
|
528
|
+
const pageContext = this.pageObserver.scanPage();
|
|
529
|
+
|
|
530
|
+
// Get available intents for this page
|
|
531
|
+
const availableIntents = this.currentIntents.length > 0
|
|
532
|
+
? this.currentIntents.map(i => i.key)
|
|
533
|
+
: IntentExtractor.extract(pageContext.ui_elements, pageContext.route)
|
|
534
|
+
.map(i => i.key);
|
|
535
|
+
|
|
536
|
+
// Try to match query to existing intents first (client-side)
|
|
537
|
+
const localMatch = IntentExtractor.matchQueryToIntent(query, this.currentIntents);
|
|
538
|
+
|
|
539
|
+
if (localMatch && localMatch.confidence >= 0.8) {
|
|
540
|
+
// Good local match, use it directly
|
|
541
|
+
this.logger.debug('Local intent match:', localMatch);
|
|
542
|
+
this._onIntentSelected(localMatch);
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// Capture screenshot for visual context
|
|
547
|
+
const screenshot = await this._captureScreenshot();
|
|
548
|
+
|
|
549
|
+
// Use LLM to extract intent
|
|
550
|
+
const result = await this.apiClient.extractIntent({
|
|
551
|
+
user_query: query,
|
|
552
|
+
route: pageContext.route,
|
|
553
|
+
available_intents: availableIntents,
|
|
554
|
+
ui_elements: pageContext.ui_elements, // Pass UI elements for better context matching
|
|
555
|
+
screenshot: screenshot
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
this.logger.debug('Intent extraction result:', result);
|
|
559
|
+
|
|
560
|
+
if (result.intent) {
|
|
561
|
+
// Intent found, get guidance
|
|
562
|
+
const matchedIntent = this.currentIntents.find(i => i.key === result.intent)
|
|
563
|
+
|| { key: result.intent, label: this._formatIntentLabel(result.intent) };
|
|
564
|
+
|
|
565
|
+
this._onIntentSelected(matchedIntent);
|
|
566
|
+
|
|
567
|
+
} else if (result.clarifying_question) {
|
|
568
|
+
// Need clarification
|
|
569
|
+
const suggestedIntents = (result.suggested_intents || [])
|
|
570
|
+
.map(key => this.currentIntents.find(i => i.key === key) || {
|
|
571
|
+
key,
|
|
572
|
+
label: this._formatIntentLabel(key)
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
this.helpWidget.showClarification(
|
|
576
|
+
result.clarifying_question,
|
|
577
|
+
suggestedIntents
|
|
578
|
+
);
|
|
579
|
+
|
|
580
|
+
} else {
|
|
581
|
+
// No intent matched, search KB directly
|
|
582
|
+
await this._handleKBSearch(query);
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
} catch (error) {
|
|
586
|
+
this.logger.error('Error processing query:', error);
|
|
587
|
+
this.helpWidget.showError('Failed to process your question. Please try again.');
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
async _onSelectionExplain(data) {
|
|
592
|
+
const text = (data?.text || '').trim();
|
|
593
|
+
if (!text) return;
|
|
594
|
+
|
|
595
|
+
try {
|
|
596
|
+
// Use UI Coach Modal instead of Help Widget
|
|
597
|
+
this.uiCoach.showExplainLoading(text);
|
|
598
|
+
|
|
599
|
+
const pageContext = this.pageObserver.scanPage();
|
|
600
|
+
|
|
601
|
+
// Use dedicated explain endpoint
|
|
602
|
+
const result = await this.apiClient.explainTerm({
|
|
603
|
+
text: text,
|
|
604
|
+
route: pageContext.route,
|
|
605
|
+
page_title: pageContext.title,
|
|
606
|
+
context: "" // Can add surrounding text if available later
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
this.uiCoach.showExplainModal({
|
|
610
|
+
terms: result.terms,
|
|
611
|
+
summary: result.source === 'kb' ? 'From Knowledge Base' : 'AI Explanation'
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
} catch (error) {
|
|
615
|
+
this.logger.error('Selection explain failed:', error);
|
|
616
|
+
// Fallback to error modal with retry
|
|
617
|
+
this.uiCoach.showExplainError(text, () => {
|
|
618
|
+
this._onSelectionExplain({ text });
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* Handle KB search for unmatched queries
|
|
625
|
+
* @private
|
|
626
|
+
* @param {string} query - Search query
|
|
627
|
+
*/
|
|
628
|
+
async _handleKBSearch(query) {
|
|
629
|
+
try {
|
|
630
|
+
const results = await this.apiClient.searchKB(query, { limit: 3 });
|
|
631
|
+
|
|
632
|
+
if (results.results && results.results.length > 0) {
|
|
633
|
+
const topResult = results.results[0];
|
|
634
|
+
|
|
635
|
+
// Check if article is for a different route
|
|
636
|
+
if (topResult.route_pattern) {
|
|
637
|
+
this.helpWidget.showNavigationSuggestion({
|
|
638
|
+
title: topResult.title,
|
|
639
|
+
route: topResult.route_pattern,
|
|
640
|
+
excerpt: topResult.excerpt
|
|
641
|
+
});
|
|
642
|
+
} else {
|
|
643
|
+
// Show generic KB result
|
|
644
|
+
this.helpWidget.showError(
|
|
645
|
+
`Found help article: "${topResult.title}". Navigate to the relevant page to get step-by-step guidance.`
|
|
646
|
+
);
|
|
647
|
+
}
|
|
648
|
+
} else {
|
|
649
|
+
this.helpWidget.showError(
|
|
650
|
+
'Could not find help for that question. Please contact support.'
|
|
651
|
+
);
|
|
652
|
+
}
|
|
653
|
+
} catch (error) {
|
|
654
|
+
this.logger.error('KB search failed:', error);
|
|
655
|
+
this.helpWidget.showError(
|
|
656
|
+
'Could not search help articles. Please contact support.'
|
|
657
|
+
);
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
/**
|
|
662
|
+
* Handle navigation changes (multi-page flow tracking)
|
|
663
|
+
* @private
|
|
664
|
+
* @param {Object} data - Navigation data
|
|
665
|
+
*/
|
|
666
|
+
async _onNavigationChanged(data) {
|
|
667
|
+
const { route, previousRoute } = data;
|
|
668
|
+
this.logger.debug('Navigation changed:', { route, previousRoute });
|
|
669
|
+
|
|
670
|
+
// Clear any active selection explain button
|
|
671
|
+
this.uiCoach.disableSelectionExplain();
|
|
672
|
+
// Re-enable it after a short delay (to clear state but keep feature active)
|
|
673
|
+
setTimeout(() => this.uiCoach.enableSelectionExplain(), 100);
|
|
674
|
+
|
|
675
|
+
// Invalidate the page-scoped intent cache — it belongs to the previous
|
|
676
|
+
// page. Stale intents corrupt both the local match AND the available_intents
|
|
677
|
+
// sent to the LLM in _onQuerySubmitted (Phase 3.1). Next use re-scans the
|
|
678
|
+
// current page.
|
|
679
|
+
this.currentIntents = [];
|
|
680
|
+
|
|
681
|
+
const session = this.sessionManager.getActiveSession();
|
|
682
|
+
|
|
683
|
+
if (!session) {
|
|
684
|
+
// No active session, nothing to track
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
// Record navigation
|
|
689
|
+
this.sessionManager.recordNavigation(route);
|
|
690
|
+
|
|
691
|
+
// NOTE (Phase 1.5): we intentionally do NOT auto-advance the step on
|
|
692
|
+
// navigation. Real step completion is driven by interaction on the
|
|
693
|
+
// highlighted element (see _watchStepElement).
|
|
694
|
+
|
|
695
|
+
// Decide whether this navigation is PART OF the workflow or the user
|
|
696
|
+
// wandering off. The reliable signal is: did a navigation-type step just
|
|
697
|
+
// complete? When the user clicks the highlighted element for a "Click X"
|
|
698
|
+
// step, the watcher fires step:done (stamping _stepNavAt) and THEN the app
|
|
699
|
+
// navigates — so that route change is expected and must not prompt. Any
|
|
700
|
+
// other navigation (a manual click on an unrelated nav item) is the user
|
|
701
|
+
// leaving, so we ask whether to keep the workflow.
|
|
702
|
+
//
|
|
703
|
+
// The old check used SessionManager.isInSameFlow(route), which keyed off
|
|
704
|
+
// session.original_route — so navigating BACK to the page the assistant was
|
|
705
|
+
// opened on (typically /dashboard) looked "in-flow" and never prompted,
|
|
706
|
+
// even for an unrelated workflow. That was the missed-prompt bug.
|
|
707
|
+
const stepDriven = this._stepNavAt && (Date.now() - this._stepNavAt) < 2000;
|
|
708
|
+
this._stepNavAt = 0; // consume: only the immediately-following nav is exempt
|
|
709
|
+
|
|
710
|
+
if (!stepDriven) {
|
|
711
|
+
const shouldContinue = await this.helpWidget.confirmContinueSession(
|
|
712
|
+
'You’ve left this workflow. Keep the guided workflow running?'
|
|
713
|
+
);
|
|
714
|
+
|
|
715
|
+
if (!shouldContinue) {
|
|
716
|
+
this._onSessionClose();
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
// Kept the workflow after a manual navigation — re-anchor the current
|
|
721
|
+
// step on this page (its target may live here). Best-effort. Step-driven
|
|
722
|
+
// navigations already re-anchor via _onStepDone's post-advance timeout.
|
|
723
|
+
this._showCurrentStep();
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
/**
|
|
728
|
+
* Resolve, highlight, and ARM completion detection for the current step.
|
|
729
|
+
*
|
|
730
|
+
* This is the single entry point that makes a step "live": it re-resolves the
|
|
731
|
+
* step's target element against the CURRENT page when the stored selector was
|
|
732
|
+
* matched on a different route or no longer resolves (fixes the "Next points
|
|
733
|
+
* at the sidebar instead of Add Employee" bug), draws the visual highlight for
|
|
734
|
+
* hosts using the built-in widget, and attaches a host-agnostic interaction
|
|
735
|
+
* watcher so the step advances when the user actually performs the action —
|
|
736
|
+
* regardless of whether the host renders the SDK's own UI.
|
|
737
|
+
* @private
|
|
738
|
+
*/
|
|
739
|
+
async _showCurrentStep() {
|
|
740
|
+
try {
|
|
741
|
+
await this._showCurrentStepInner();
|
|
742
|
+
} catch (error) {
|
|
743
|
+
// A step-resolution/highlight failure must never break the caller
|
|
744
|
+
// (intent-select, resume-on-open, advance). Degrade quietly.
|
|
745
|
+
this.logger?.error?.('Failed to show current step:', error);
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
/** @private */
|
|
750
|
+
async _showCurrentStepInner() {
|
|
751
|
+
const session = this.sessionManager.getActiveSession();
|
|
752
|
+
if (!session || session.status !== 'active') return;
|
|
753
|
+
|
|
754
|
+
const step = this.sessionManager.getCurrentStep();
|
|
755
|
+
if (!step) return;
|
|
756
|
+
|
|
757
|
+
const route = this.pageObserver.getRoute();
|
|
758
|
+
const minConfidence = this.config.get('minHighlightConfidence');
|
|
759
|
+
|
|
760
|
+
// A stored selector is trustworthy only if it was matched on THIS page and
|
|
761
|
+
// still resolves. Selectors from the backend are matched on the page where
|
|
762
|
+
// guidance was generated (session.original_route); reused verbatim on a
|
|
763
|
+
// later page they point at the wrong element.
|
|
764
|
+
const matchedRoute = step.selector_route || session.original_route;
|
|
765
|
+
const selectorUsable = !!step.selector
|
|
766
|
+
&& (step.confidence === undefined || step.confidence >= minConfidence)
|
|
767
|
+
&& matchedRoute === route
|
|
768
|
+
&& this.pageObserver.elementExists(step.selector);
|
|
769
|
+
|
|
770
|
+
let selector = selectorUsable ? step.selector : await this._resolveStepSelector(step, route);
|
|
771
|
+
|
|
772
|
+
const element = selector ? document.querySelector(selector) : null;
|
|
773
|
+
|
|
774
|
+
if (!element) {
|
|
775
|
+
// Couldn't locate the element on this page — show the instruction as text
|
|
776
|
+
// so the user isn't stranded, and stop watching a stale element.
|
|
777
|
+
this._unwatchStepElement();
|
|
778
|
+
this.uiCoach.clearHighlight();
|
|
779
|
+
this.helpWidget.show();
|
|
780
|
+
this.helpWidget.showTextOnlyGuidance(
|
|
781
|
+
session,
|
|
782
|
+
'Look for: ' + step.text
|
|
783
|
+
);
|
|
784
|
+
return;
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
// Visual highlight for built-in-widget hosts. A host that renders its own
|
|
788
|
+
// on-page indicator (showStepHighlight:false) suppresses it to avoid a
|
|
789
|
+
// duplicate — detection below is independent of the visual. No onClick:
|
|
790
|
+
// completion detection is the watcher's job, so it never double-advances.
|
|
791
|
+
if (this.config.get('showStepHighlight')) {
|
|
792
|
+
this.uiCoach.highlightElement(selector, {
|
|
793
|
+
message: step.text,
|
|
794
|
+
showArrow: true,
|
|
795
|
+
showTooltip: true,
|
|
796
|
+
showDoneButton: true,
|
|
797
|
+
showSkipButton: false,
|
|
798
|
+
});
|
|
799
|
+
} else {
|
|
800
|
+
this.uiCoach.clearHighlight();
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
// Arm host-agnostic completion detection on the real element.
|
|
804
|
+
this._watchStepElement(element, step);
|
|
805
|
+
|
|
806
|
+
// Keep the step list in sync (progress/highlight state).
|
|
807
|
+
this.helpWidget.updateTooltip(session);
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
/**
|
|
811
|
+
* Ask the backend to match the current step's described action to an element
|
|
812
|
+
* on the CURRENT page. Updates the step's selector and stamps the route it was
|
|
813
|
+
* matched against (selector_route) so we know when it later goes stale.
|
|
814
|
+
* @private
|
|
815
|
+
* @returns {Promise<string|null>} the matched selector, or null
|
|
816
|
+
*/
|
|
817
|
+
async _resolveStepSelector(step, route) {
|
|
818
|
+
if (this.isMatchingStep) return null;
|
|
819
|
+
const session = this.sessionManager.getActiveSession();
|
|
820
|
+
if (!session) return null;
|
|
821
|
+
|
|
822
|
+
this.isMatchingStep = true;
|
|
823
|
+
try {
|
|
824
|
+
const pageContext = this.pageObserver.scanPage();
|
|
825
|
+
const matchResult = await this.apiClient.matchElement({
|
|
826
|
+
step_description: step.text,
|
|
827
|
+
original_selector: step.selector,
|
|
828
|
+
route,
|
|
829
|
+
ui_elements: pageContext.ui_elements,
|
|
830
|
+
session_id: session.session_id,
|
|
831
|
+
intent: session.intent,
|
|
832
|
+
steps: session.steps,
|
|
833
|
+
current_step_index: session.current_step,
|
|
834
|
+
});
|
|
835
|
+
|
|
836
|
+
this.logger.debug('Element match result:', matchResult);
|
|
837
|
+
|
|
838
|
+
if (matchResult.found && matchResult.confidence >= this.config.get('minHighlightConfidence')) {
|
|
839
|
+
this.sessionManager.updateStep(session.current_step, {
|
|
840
|
+
selector: matchResult.selector,
|
|
841
|
+
confidence: matchResult.confidence,
|
|
842
|
+
match_type: matchResult.match_type,
|
|
843
|
+
selector_route: route, // remember the page this selector is valid for
|
|
844
|
+
});
|
|
845
|
+
return matchResult.selector;
|
|
846
|
+
}
|
|
847
|
+
return null;
|
|
848
|
+
} catch (error) {
|
|
849
|
+
this.logger.error('Error re-matching element:', error);
|
|
850
|
+
return null;
|
|
851
|
+
} finally {
|
|
852
|
+
this.isMatchingStep = false;
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
/**
|
|
857
|
+
* Attach a one-shot completion watcher to the current step's real target
|
|
858
|
+
* element. Clicking a button/link, or typing into / toggling a field, emits
|
|
859
|
+
* step:done. This works in ANY host because it listens on the actual page
|
|
860
|
+
* element, not on the SDK's overlay.
|
|
861
|
+
* @private
|
|
862
|
+
*/
|
|
863
|
+
_watchStepElement(element, step) {
|
|
864
|
+
this._unwatchStepElement();
|
|
865
|
+
if (!element) return;
|
|
866
|
+
|
|
867
|
+
this._watchedEl = element;
|
|
868
|
+
this._watchHandlers = [];
|
|
869
|
+
|
|
870
|
+
const fire = (ev) => {
|
|
871
|
+
// Remove the sibling listeners so the same interaction can't double-fire.
|
|
872
|
+
this._unwatchStepElement();
|
|
873
|
+
// A CLICK on the highlighted element may navigate (a nav link, or a button
|
|
874
|
+
// that routes). Mark it so _onNavigationChanged treats the imminent route
|
|
875
|
+
// change as EXPECTED step progress and doesn't prompt "leave the
|
|
876
|
+
// workflow?". Only real clicks on the highlighted element count — NOT the
|
|
877
|
+
// panel's "Next" button and NOT typing — so a manual navigation to an
|
|
878
|
+
// unrelated page still prompts.
|
|
879
|
+
if (ev && ev.type === 'click') {
|
|
880
|
+
this._stepNavAt = Date.now();
|
|
881
|
+
}
|
|
882
|
+
EventBus.emit(EVENTS.STEP_DONE);
|
|
883
|
+
};
|
|
884
|
+
|
|
885
|
+
const add = (type) => {
|
|
886
|
+
element.addEventListener(type, fire, { once: true });
|
|
887
|
+
this._watchHandlers.push([type, fire]);
|
|
888
|
+
};
|
|
889
|
+
|
|
890
|
+
const tag = element.tagName;
|
|
891
|
+
const isField = tag === 'INPUT' || tag === 'TEXTAREA' || element.isContentEditable;
|
|
892
|
+
if (isField) {
|
|
893
|
+
if (element.type === 'checkbox' || element.type === 'radio') {
|
|
894
|
+
add('change');
|
|
895
|
+
} else {
|
|
896
|
+
add('input');
|
|
897
|
+
}
|
|
898
|
+
} else {
|
|
899
|
+
add('click');
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
/** Detach the current step-completion watcher, if any. @private */
|
|
904
|
+
_unwatchStepElement() {
|
|
905
|
+
if (this._watchedEl && this._watchHandlers) {
|
|
906
|
+
for (const [type, fn] of this._watchHandlers) {
|
|
907
|
+
this._watchedEl.removeEventListener(type, fn);
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
this._watchedEl = null;
|
|
911
|
+
this._watchHandlers = null;
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
/**
|
|
915
|
+
* Back-compat shim: older call sites re-matched the current step on a new
|
|
916
|
+
* page. That responsibility now lives in _showCurrentStep (which also arms
|
|
917
|
+
* detection), so delegate.
|
|
918
|
+
* @private
|
|
919
|
+
*/
|
|
920
|
+
async _rematchCurrentStep() {
|
|
921
|
+
return this._showCurrentStep();
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
/**
|
|
925
|
+
* Handle step marked as done
|
|
926
|
+
* @private
|
|
927
|
+
*/
|
|
928
|
+
_onStepDone() {
|
|
929
|
+
if (this.isUpdating) return;
|
|
930
|
+
|
|
931
|
+
this.logger.debug('Step marked as done');
|
|
932
|
+
this.lastStepAdvanceTime = Date.now();
|
|
933
|
+
|
|
934
|
+
// Check if the current step action likely triggers navigation
|
|
935
|
+
const currentStep = this.sessionManager.getCurrentStep();
|
|
936
|
+
const isNavigationAction = currentStep && (
|
|
937
|
+
currentStep.text.toLowerCase().includes('click') ||
|
|
938
|
+
currentStep.text.toLowerCase().includes('navigate') ||
|
|
939
|
+
currentStep.text.toLowerCase().includes('select') ||
|
|
940
|
+
currentStep.selector
|
|
941
|
+
);
|
|
942
|
+
|
|
943
|
+
// NOTE: _stepNavAt (the "expected step navigation" marker) is set in the
|
|
944
|
+
// interaction watcher on a real CLICK of the highlighted element — NOT here.
|
|
945
|
+
// Advancing via the panel's "Next" button must not exempt a subsequent
|
|
946
|
+
// manual navigation from the "leave the workflow?" prompt.
|
|
947
|
+
|
|
948
|
+
const advanced = this.sessionManager.advanceStep();
|
|
949
|
+
|
|
950
|
+
if (!advanced) {
|
|
951
|
+
// All steps completed
|
|
952
|
+
this._onSessionComplete();
|
|
953
|
+
return;
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
// Update widget
|
|
957
|
+
const session = this.sessionManager.getActiveSession();
|
|
958
|
+
this.helpWidget.updateTooltip(session);
|
|
959
|
+
|
|
960
|
+
// Ensure widget is visible if not minimized
|
|
961
|
+
// We don't force show here if it was explicitly closed, but we should ensure content is updated
|
|
962
|
+
|
|
963
|
+
// If this was likely a navigation action, delay highlighting
|
|
964
|
+
// to give the page time to transition
|
|
965
|
+
if (isNavigationAction) {
|
|
966
|
+
this.logger.debug('Navigation action detected, delaying highlight...');
|
|
967
|
+
|
|
968
|
+
// Set a flag or just use a timeout to retry highlighting
|
|
969
|
+
// Ideally we would wait for page:dom_changed, but a simple timeout is safer fallback
|
|
970
|
+
setTimeout(() => {
|
|
971
|
+
// Re-resolve the next step against whatever page we're now on and arm it.
|
|
972
|
+
this._showCurrentStep();
|
|
973
|
+
}, 1000);
|
|
974
|
+
} else {
|
|
975
|
+
// Immediate highlight for non-navigation steps (e.g. typing)
|
|
976
|
+
this._showCurrentStep();
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
/**
|
|
981
|
+
* Handle step skipped
|
|
982
|
+
* @private
|
|
983
|
+
*/
|
|
984
|
+
_onStepSkip() {
|
|
985
|
+
this.logger.debug('Step skipped');
|
|
986
|
+
|
|
987
|
+
// Same as done, just move to next step
|
|
988
|
+
this._onStepDone();
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
/**
|
|
992
|
+
* Handle session completion
|
|
993
|
+
* @private
|
|
994
|
+
*/
|
|
995
|
+
async _onSessionComplete() {
|
|
996
|
+
if (this.isUpdating) return;
|
|
997
|
+
this.isUpdating = true;
|
|
998
|
+
|
|
999
|
+
this.logger.info('Session completed');
|
|
1000
|
+
|
|
1001
|
+
const session = this.sessionManager.getActiveSession();
|
|
1002
|
+
|
|
1003
|
+
if (session) {
|
|
1004
|
+
try {
|
|
1005
|
+
// Update backend
|
|
1006
|
+
await this.apiClient.updateSession({
|
|
1007
|
+
session_id: session.session_id,
|
|
1008
|
+
status: 'completed',
|
|
1009
|
+
current_step: session.current_step,
|
|
1010
|
+
total_steps: session.total_steps
|
|
1011
|
+
});
|
|
1012
|
+
|
|
1013
|
+
// Update local session to stop polling but keep active
|
|
1014
|
+
this.sessionManager.updateSession({ status: 'completed' });
|
|
1015
|
+
|
|
1016
|
+
} catch (error) {
|
|
1017
|
+
this.logger.error('Failed to update session status:', error);
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
// Clear highlight + stop watching the completed step's element.
|
|
1022
|
+
this.uiCoach.clearHighlight();
|
|
1023
|
+
this._unwatchStepElement();
|
|
1024
|
+
|
|
1025
|
+
// Show the widget (in case it was closed/minimized) so user sees the completion screen
|
|
1026
|
+
this.helpWidget.show();
|
|
1027
|
+
|
|
1028
|
+
// Show completion screen
|
|
1029
|
+
this.helpWidget.showCompletion(session?.intent_label || session?.intent);
|
|
1030
|
+
|
|
1031
|
+
// Emit completion event
|
|
1032
|
+
EventBus.emit('handhold:completed', { intent: session?.intent });
|
|
1033
|
+
|
|
1034
|
+
this.isUpdating = false;
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
/**
|
|
1038
|
+
* Handle session close
|
|
1039
|
+
* @private
|
|
1040
|
+
*/
|
|
1041
|
+
async _onSessionClose() {
|
|
1042
|
+
this.logger.debug('Session close requested');
|
|
1043
|
+
|
|
1044
|
+
const session = this.sessionManager.getActiveSession();
|
|
1045
|
+
|
|
1046
|
+
// Tear down SYNCHRONOUSLY, before any await. A caller often exits then
|
|
1047
|
+
// immediately reopens (handleConfirmExit: exitWorkflow(); openHelp()). If we
|
|
1048
|
+
// awaited the backend update first, endSession() would run later and the
|
|
1049
|
+
// reopen would still observe the session as active and RESUME the workflow
|
|
1050
|
+
// we just exited — the "stale workflow bleeds into the next question" bug.
|
|
1051
|
+
this.uiCoach.clearHighlight();
|
|
1052
|
+
this._unwatchStepElement();
|
|
1053
|
+
this.helpWidget.hide();
|
|
1054
|
+
// Full reset so stale guidance can't bleed into the next question/menu.
|
|
1055
|
+
this.helpWidget.resetView();
|
|
1056
|
+
this.sessionManager.endSession();
|
|
1057
|
+
|
|
1058
|
+
// Emit close event
|
|
1059
|
+
EventBus.emit('handhold:closed', { intent: session?.intent });
|
|
1060
|
+
|
|
1061
|
+
// Best-effort backend update — must not gate the local teardown above.
|
|
1062
|
+
if (session) {
|
|
1063
|
+
try {
|
|
1064
|
+
await this.apiClient.updateSession({
|
|
1065
|
+
session_id: session.session_id,
|
|
1066
|
+
status: 'abandoned',
|
|
1067
|
+
current_step: session.current_step,
|
|
1068
|
+
total_steps: session.total_steps
|
|
1069
|
+
});
|
|
1070
|
+
} catch (error) {
|
|
1071
|
+
this.logger.error('Failed to update session status:', error);
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
/**
|
|
1077
|
+
* Minimize the widget: hide the panel but KEEP the active workflow so
|
|
1078
|
+
* reopening (the trigger button / open()) resumes it where it left off.
|
|
1079
|
+
* This is what Close/✕ should do on an active workflow — ending the session
|
|
1080
|
+
* is reserved for an explicit "Exit workflow". Clears the on-page highlight
|
|
1081
|
+
* and detection while minimized so nothing lingers on the page.
|
|
1082
|
+
* @private
|
|
1083
|
+
*/
|
|
1084
|
+
_onMinimize() {
|
|
1085
|
+
const session = this.sessionManager.getActiveSession();
|
|
1086
|
+
// Nothing to minimize to — behave like a plain close.
|
|
1087
|
+
if (!session || session.status !== 'active') {
|
|
1088
|
+
this._onSessionClose();
|
|
1089
|
+
return;
|
|
1090
|
+
}
|
|
1091
|
+
this.uiCoach.clearHighlight();
|
|
1092
|
+
this._unwatchStepElement();
|
|
1093
|
+
// Pure-visual hide; keeps currentView/currentGuidance so resume works.
|
|
1094
|
+
this.helpWidget.hide();
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
/**
|
|
1098
|
+
* Handle DOM changes
|
|
1099
|
+
* @private
|
|
1100
|
+
*/
|
|
1101
|
+
_onDomChanged() {
|
|
1102
|
+
// 1. If active highlight, verify element still exists
|
|
1103
|
+
if (this.uiCoach.isActive()) {
|
|
1104
|
+
const session = this.sessionManager.getActiveSession();
|
|
1105
|
+
if (session) {
|
|
1106
|
+
const currentStep = this.sessionManager.getCurrentStep();
|
|
1107
|
+
if (currentStep && currentStep.selector) {
|
|
1108
|
+
const exists = this.pageObserver.elementExists(currentStep.selector);
|
|
1109
|
+
if (!exists) {
|
|
1110
|
+
this.logger.debug('Highlighted element no longer exists');
|
|
1111
|
+
this.uiCoach.clearHighlight();
|
|
1112
|
+
this._rematchCurrentStep(this.pageObserver.getRoute());
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
// 2. If session active, check if we need to re-scan page (for navigation/content loading)
|
|
1119
|
+
/*
|
|
1120
|
+
// Disabled per user request to prevent infinite loops and 429s.
|
|
1121
|
+
// User prefers explicit "Show UI" or interaction.
|
|
1122
|
+
const session = this.sessionManager.getActiveSession();
|
|
1123
|
+
if (session && session.status === 'active') {
|
|
1124
|
+
// Use debounced check to avoid spamming API
|
|
1125
|
+
if (this._checkPageTimer) clearTimeout(this._checkPageTimer);
|
|
1126
|
+
|
|
1127
|
+
this._checkPageTimer = setTimeout(() => {
|
|
1128
|
+
this._checkPageStatus();
|
|
1129
|
+
}, 500); // 500ms debounce
|
|
1130
|
+
}
|
|
1131
|
+
*/
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
/**
|
|
1135
|
+
* Check page status and update guidance
|
|
1136
|
+
* @private
|
|
1137
|
+
*/
|
|
1138
|
+
async _checkPageStatus() {
|
|
1139
|
+
const session = this.sessionManager.getActiveSession();
|
|
1140
|
+
if (!session) return;
|
|
1141
|
+
|
|
1142
|
+
try {
|
|
1143
|
+
const pageContext = this.pageObserver.scanPage(true); // Force rescan
|
|
1144
|
+
|
|
1145
|
+
const result = await this.apiClient.checkPageStatus({
|
|
1146
|
+
session_id: session.session_id,
|
|
1147
|
+
route: pageContext.route,
|
|
1148
|
+
ui_elements: pageContext.ui_elements
|
|
1149
|
+
});
|
|
1150
|
+
|
|
1151
|
+
this.logger.debug('Page status check:', result);
|
|
1152
|
+
|
|
1153
|
+
if (result.status === 'active' && result.action === 'highlight') {
|
|
1154
|
+
// Update step info with new selector
|
|
1155
|
+
if (result.step_info) {
|
|
1156
|
+
this.sessionManager.updateStep(session.current_step, {
|
|
1157
|
+
selector: result.selector,
|
|
1158
|
+
confidence: result.step_info.confidence,
|
|
1159
|
+
match_type: result.step_info.match_type
|
|
1160
|
+
});
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
// Highlight element
|
|
1164
|
+
this._highlightCurrentStep();
|
|
1165
|
+
} else if (result.status === 'active' && result.action === 'stay') {
|
|
1166
|
+
// Show fallback message if provided
|
|
1167
|
+
if (result.fallback_message) {
|
|
1168
|
+
this.helpWidget.showTextOnlyGuidance(session, result.fallback_message);
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
} catch (error) {
|
|
1173
|
+
this.logger.error('Failed to check page status:', error);
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
/**
|
|
1178
|
+
* Highlight current step element. Kept for back-compat; the resolve +
|
|
1179
|
+
* highlight + arm-detection logic now lives in _showCurrentStep.
|
|
1180
|
+
* @private
|
|
1181
|
+
*/
|
|
1182
|
+
async _highlightCurrentStep() {
|
|
1183
|
+
return this._showCurrentStep();
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
/**
|
|
1187
|
+
* Restore session after page load
|
|
1188
|
+
* @private
|
|
1189
|
+
*/
|
|
1190
|
+
async _restoreSession() {
|
|
1191
|
+
if (!this.sessionManager.hasActiveSession()) {
|
|
1192
|
+
return;
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
const session = this.sessionManager.getActiveSession();
|
|
1196
|
+
this.logger.debug('Found existing session:', session);
|
|
1197
|
+
|
|
1198
|
+
// Ask user if they want to continue
|
|
1199
|
+
const shouldContinue = await this.helpWidget.confirmContinueSession(
|
|
1200
|
+
'You have an active guidance session. Continue where you left off?'
|
|
1201
|
+
);
|
|
1202
|
+
|
|
1203
|
+
if (!shouldContinue) {
|
|
1204
|
+
this._onSessionClose();
|
|
1205
|
+
return;
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
// Restore session UI
|
|
1209
|
+
try {
|
|
1210
|
+
// Show guidance tooltip
|
|
1211
|
+
this.helpWidget.showGuidanceTooltip({
|
|
1212
|
+
intent: session.intent,
|
|
1213
|
+
summary: session.summary || '',
|
|
1214
|
+
steps: session.steps,
|
|
1215
|
+
current_step: session.current_step,
|
|
1216
|
+
total_steps: session.total_steps
|
|
1217
|
+
});
|
|
1218
|
+
|
|
1219
|
+
// Try to highlight current step
|
|
1220
|
+
await this._rematchCurrentStep(this.pageObserver.getRoute());
|
|
1221
|
+
|
|
1222
|
+
} catch (error) {
|
|
1223
|
+
this.logger.error('Error restoring session:', error);
|
|
1224
|
+
this._onSessionClose();
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
/**
|
|
1229
|
+
* Capture screenshot of the current page
|
|
1230
|
+
* @private
|
|
1231
|
+
* @returns {Promise<string|null>} Base64 encoded screenshot (no prefix)
|
|
1232
|
+
*/
|
|
1233
|
+
async _captureScreenshot() {
|
|
1234
|
+
// Opt-out (default on). On modern-CSS sites (oklch/oklab — Tailwind v4)
|
|
1235
|
+
// html2canvas cannot parse the colors and stalls for seconds before
|
|
1236
|
+
// failing, hanging the widget on every question. Skip it entirely when
|
|
1237
|
+
// disabled; intent extraction works fine from the DOM without it.
|
|
1238
|
+
if (!this.config.get('enableScreenshots')) {
|
|
1239
|
+
return null;
|
|
1240
|
+
}
|
|
1241
|
+
try {
|
|
1242
|
+
// Ensure widget is hidden/minimized during capture if needed,
|
|
1243
|
+
// but ignoreElements handles it better visually.
|
|
1244
|
+
|
|
1245
|
+
const canvas = await html2canvas(document.body, {
|
|
1246
|
+
logging: false,
|
|
1247
|
+
useCORS: true,
|
|
1248
|
+
onclone: (clonedDoc) => {
|
|
1249
|
+
// Fix for html2canvas not supporting modern CSS colors like oklch
|
|
1250
|
+
try {
|
|
1251
|
+
const allElements = clonedDoc.querySelectorAll('*');
|
|
1252
|
+
const canvas = document.createElement('canvas');
|
|
1253
|
+
canvas.width = 1;
|
|
1254
|
+
canvas.height = 1;
|
|
1255
|
+
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
|
1256
|
+
const colorCache = new Map();
|
|
1257
|
+
|
|
1258
|
+
const resolveColor = (color) => {
|
|
1259
|
+
if (!color || typeof color !== 'string') return color;
|
|
1260
|
+
// Check for unsupported modern color functions
|
|
1261
|
+
if (!color.includes('oklch') && !color.includes('oklab') && !color.includes('lab(') && !color.includes('lch(')) return color;
|
|
1262
|
+
|
|
1263
|
+
if (colorCache.has(color)) return colorCache.get(color);
|
|
1264
|
+
|
|
1265
|
+
// Use canvas to convert color to RGBA
|
|
1266
|
+
ctx.clearRect(0, 0, 1, 1);
|
|
1267
|
+
ctx.fillStyle = color;
|
|
1268
|
+
ctx.fillRect(0, 0, 1, 1);
|
|
1269
|
+
const [r, g, b, a] = ctx.getImageData(0, 0, 1, 1).data;
|
|
1270
|
+
const rgba = `rgba(${r}, ${g}, ${b}, ${a / 255})`;
|
|
1271
|
+
colorCache.set(color, rgba);
|
|
1272
|
+
return rgba;
|
|
1273
|
+
};
|
|
1274
|
+
|
|
1275
|
+
allElements.forEach(el => {
|
|
1276
|
+
// Use defaultView from the cloned document (it should be mounted in an iframe)
|
|
1277
|
+
const view = el.ownerDocument.defaultView;
|
|
1278
|
+
if (!view) return;
|
|
1279
|
+
|
|
1280
|
+
const style = view.getComputedStyle(el);
|
|
1281
|
+
|
|
1282
|
+
// 1. Iterate through all computed style properties
|
|
1283
|
+
for (let i = 0; i < style.length; i++) {
|
|
1284
|
+
const prop = style[i];
|
|
1285
|
+
const val = style.getPropertyValue(prop);
|
|
1286
|
+
|
|
1287
|
+
// Check for any unsupported color function (oklch, oklab, lab, lch)
|
|
1288
|
+
if (val && (val.includes('oklch') || val.includes('oklab') || val.includes('lab(') || val.includes('lch('))) {
|
|
1289
|
+
el.style.setProperty(prop, resolveColor(val), 'important');
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
// 2. Also check inline styles specifically
|
|
1294
|
+
if (el.style) {
|
|
1295
|
+
for (let i = 0; i < el.style.length; i++) {
|
|
1296
|
+
const prop = el.style[i];
|
|
1297
|
+
const val = el.style.getPropertyValue(prop);
|
|
1298
|
+
if (val && (val.includes('oklch') || val.includes('oklab') || val.includes('lab(') || val.includes('lch('))) {
|
|
1299
|
+
el.style.setProperty(prop, resolveColor(val), 'important');
|
|
1300
|
+
}
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
});
|
|
1304
|
+
} catch (e) {
|
|
1305
|
+
console.warn('[HandHold] Failed to process colors in screenshot clone:', e);
|
|
1306
|
+
}
|
|
1307
|
+
},
|
|
1308
|
+
ignoreElements: (element) => {
|
|
1309
|
+
// Ignore the help widget and coaching elements
|
|
1310
|
+
return element.id === 'handhold-container' ||
|
|
1311
|
+
element.classList.contains('handhold-widget') ||
|
|
1312
|
+
element.classList.contains('handhold-overlay') ||
|
|
1313
|
+
element.classList.contains('handhold-highlight') ||
|
|
1314
|
+
element.classList.contains('handhold-arrow') ||
|
|
1315
|
+
element.classList.contains('handhold-spotlight') ||
|
|
1316
|
+
element.classList.contains('handhold-step-tooltip');
|
|
1317
|
+
}
|
|
1318
|
+
});
|
|
1319
|
+
|
|
1320
|
+
const dataUrl = canvas.toDataURL('image/png');
|
|
1321
|
+
// Return just the base64 part
|
|
1322
|
+
return dataUrl.split(',')[1];
|
|
1323
|
+
} catch (error) {
|
|
1324
|
+
this.logger.error('Screenshot capture failed:', error);
|
|
1325
|
+
return null;
|
|
1326
|
+
}
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
/**
|
|
1330
|
+
* Format intent key to human-readable label
|
|
1331
|
+
* @private
|
|
1332
|
+
* @param {string} key - Intent key
|
|
1333
|
+
* @returns {string} Formatted label
|
|
1334
|
+
*/
|
|
1335
|
+
_formatIntentLabel(key) {
|
|
1336
|
+
return key
|
|
1337
|
+
.split('_')
|
|
1338
|
+
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
|
1339
|
+
.join(' ');
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
// ==================== Public API ====================
|
|
1343
|
+
|
|
1344
|
+
/**
|
|
1345
|
+
* Open the help widget
|
|
1346
|
+
*/
|
|
1347
|
+
open() {
|
|
1348
|
+
if (!this.initialized) {
|
|
1349
|
+
console.warn('[HandHold] SDK not initialized. Call init() first.');
|
|
1350
|
+
return;
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
this.helpWidget.show();
|
|
1354
|
+
this._onHelpOpen();
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
/**
|
|
1358
|
+
* Close the help widget
|
|
1359
|
+
*/
|
|
1360
|
+
close() {
|
|
1361
|
+
if (!this.initialized) return;
|
|
1362
|
+
|
|
1363
|
+
this._onSessionClose();
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
/**
|
|
1367
|
+
* Toggle the help widget
|
|
1368
|
+
*/
|
|
1369
|
+
toggle() {
|
|
1370
|
+
if (!this.initialized) return;
|
|
1371
|
+
|
|
1372
|
+
this.helpWidget.toggle();
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
/**
|
|
1376
|
+
* Start guidance for a specific intent
|
|
1377
|
+
* @param {string} intentKey - Intent key (e.g., 'approve_payroll')
|
|
1378
|
+
*/
|
|
1379
|
+
async startGuidance(intentKey) {
|
|
1380
|
+
if (!this.initialized) {
|
|
1381
|
+
console.warn('[HandHold] SDK not initialized. Call init() first.');
|
|
1382
|
+
return;
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
// Find intent in current page intents
|
|
1386
|
+
let intent = this.currentIntents.find(i => i.key === intentKey);
|
|
1387
|
+
|
|
1388
|
+
if (!intent) {
|
|
1389
|
+
// Create intent object
|
|
1390
|
+
intent = {
|
|
1391
|
+
key: intentKey,
|
|
1392
|
+
label: this._formatIntentLabel(intentKey),
|
|
1393
|
+
confidence: 1.0
|
|
1394
|
+
};
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
this.helpWidget.show();
|
|
1398
|
+
await this._onIntentSelected(intent);
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
/**
|
|
1402
|
+
* Get current intents for the page
|
|
1403
|
+
* @returns {Array} Array of intent objects
|
|
1404
|
+
*/
|
|
1405
|
+
getIntents() {
|
|
1406
|
+
if (!this.initialized) return [];
|
|
1407
|
+
|
|
1408
|
+
const pageContext = this.pageObserver.scanPage();
|
|
1409
|
+
return IntentExtractor.extract(pageContext.ui_elements, pageContext.route);
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
/**
|
|
1413
|
+
* Check if there's an active guidance session
|
|
1414
|
+
* @returns {boolean}
|
|
1415
|
+
*/
|
|
1416
|
+
hasActiveSession() {
|
|
1417
|
+
if (!this.initialized) return false;
|
|
1418
|
+
|
|
1419
|
+
return this.sessionManager.hasActiveSession();
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
/**
|
|
1423
|
+
* Get the active session
|
|
1424
|
+
* @returns {Object|null} Session object or null
|
|
1425
|
+
*/
|
|
1426
|
+
getActiveSession() {
|
|
1427
|
+
if (!this.initialized) return null;
|
|
1428
|
+
|
|
1429
|
+
return this.sessionManager.getActiveSession();
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
/**
|
|
1433
|
+
* Get session progress
|
|
1434
|
+
* @returns {number} Progress percentage (0-100)
|
|
1435
|
+
*/
|
|
1436
|
+
getProgress() {
|
|
1437
|
+
if (!this.initialized) return 0;
|
|
1438
|
+
|
|
1439
|
+
return this.sessionManager.getProgress();
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
/**
|
|
1443
|
+
* Subscribe to SDK events
|
|
1444
|
+
* @param {string} event - Event name
|
|
1445
|
+
* @param {Function} callback - Callback function
|
|
1446
|
+
* @returns {Function} Unsubscribe function
|
|
1447
|
+
*/
|
|
1448
|
+
on(event, callback) {
|
|
1449
|
+
return EventBus.on(event, callback);
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
/**
|
|
1453
|
+
* Unsubscribe from SDK events
|
|
1454
|
+
* @param {string} event - Event name
|
|
1455
|
+
* @param {Function} callback - Callback function
|
|
1456
|
+
*/
|
|
1457
|
+
off(event, callback) {
|
|
1458
|
+
EventBus.off(event, callback);
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
/**
|
|
1462
|
+
* Destroy the SDK instance
|
|
1463
|
+
*/
|
|
1464
|
+
destroy() {
|
|
1465
|
+
if (!this.initialized) return;
|
|
1466
|
+
|
|
1467
|
+
this.logger.info('Destroying Hand Hold SDK...');
|
|
1468
|
+
|
|
1469
|
+
// Clear any active session without notifying backend
|
|
1470
|
+
if (this.uiCoach) {
|
|
1471
|
+
this.uiCoach.destroy();
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
if (this.helpWidget) {
|
|
1475
|
+
this.helpWidget.destroy();
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
if (this.pageObserver) {
|
|
1479
|
+
this.pageObserver.disconnect();
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
if (this.sessionManager) {
|
|
1483
|
+
this.sessionManager.endSession();
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
// Clear event listeners
|
|
1487
|
+
EventBus.clear();
|
|
1488
|
+
|
|
1489
|
+
// Reset state
|
|
1490
|
+
this.config = null;
|
|
1491
|
+
this.logger = null;
|
|
1492
|
+
this.apiClient = null;
|
|
1493
|
+
this.pageObserver = null;
|
|
1494
|
+
this.sessionManager = null;
|
|
1495
|
+
this.helpWidget = null;
|
|
1496
|
+
this.uiCoach = null;
|
|
1497
|
+
this.currentIntents = [];
|
|
1498
|
+
this.initialized = false;
|
|
1499
|
+
|
|
1500
|
+
console.log('[HandHold] SDK destroyed');
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1503
|
+
/**
|
|
1504
|
+
* Get SDK version
|
|
1505
|
+
* @returns {string} Version string
|
|
1506
|
+
*/
|
|
1507
|
+
getVersion() {
|
|
1508
|
+
return '1.0.0';
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
/**
|
|
1512
|
+
* Check if SDK is initialized
|
|
1513
|
+
* @returns {boolean}
|
|
1514
|
+
*/
|
|
1515
|
+
isInitialized() {
|
|
1516
|
+
return this.initialized;
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
// ============================================
|
|
1520
|
+
// UI state (Phase 2) — single source of truth
|
|
1521
|
+
// ============================================
|
|
1522
|
+
|
|
1523
|
+
/**
|
|
1524
|
+
* Subscribe to UI-state changes. Shaped for React's useSyncExternalStore:
|
|
1525
|
+
* pass HandHold.subscribe and HandHold.getState directly. The listener runs
|
|
1526
|
+
* on every state change.
|
|
1527
|
+
* @param {(snapshot: Object) => void} listener
|
|
1528
|
+
* @returns {() => void} unsubscribe
|
|
1529
|
+
*/
|
|
1530
|
+
subscribe(listener) {
|
|
1531
|
+
return this.uiState.subscribe(listener);
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
/** Current UI-state snapshot (stable reference until it changes). */
|
|
1535
|
+
getState() {
|
|
1536
|
+
return this.uiState.getSnapshot();
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
// ============================================
|
|
1540
|
+
// Commands (Phase 2.2) — the public API a host calls
|
|
1541
|
+
// ============================================
|
|
1542
|
+
// App code should call these instead of emitting raw EventBus events, so the
|
|
1543
|
+
// SDK owns the contract. Internally they drive the same event-based handlers.
|
|
1544
|
+
|
|
1545
|
+
/**
|
|
1546
|
+
* Open the help widget (marks it open, scans the page, shows the menu).
|
|
1547
|
+
* Sets the WIDGET's isOpen — the source _syncState() reflects into UiState —
|
|
1548
|
+
* so the follow-up scan doesn't overwrite isOpen back to false.
|
|
1549
|
+
*/
|
|
1550
|
+
openHelp() {
|
|
1551
|
+
if (this.helpWidget) this.helpWidget.isOpen = true;
|
|
1552
|
+
this.uiState.setState({ isOpen: true });
|
|
1553
|
+
EventBus.emit(EVENTS.HELP_OPEN);
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
/** Submit a free-text question. */
|
|
1557
|
+
submitQuery(query) { EventBus.emit(EVENTS.QUERY_SUBMITTED, query); }
|
|
1558
|
+
|
|
1559
|
+
/** Start a workflow for an intent. @param {{key:string,label?:string}} intent */
|
|
1560
|
+
startIntent(intent) { EventBus.emit(EVENTS.INTENT_SELECTED, intent); }
|
|
1561
|
+
|
|
1562
|
+
/** Mark the current guidance step done (advance). */
|
|
1563
|
+
markStepDone() { EventBus.emit(EVENTS.STEP_DONE); }
|
|
1564
|
+
|
|
1565
|
+
/** Go back a step. */
|
|
1566
|
+
goBackStep(stepIndex) { EventBus.emit(EVENTS.STEP_BACK, stepIndex); }
|
|
1567
|
+
|
|
1568
|
+
/** Answer the session-restore "continue?" confirm. @param {boolean} answer */
|
|
1569
|
+
respondToConfirm(answer) { EventBus.emit(EVENTS.CONFIRM_RESPONSE, answer); }
|
|
1570
|
+
|
|
1571
|
+
/** End / exit the active workflow session. */
|
|
1572
|
+
exitWorkflow() { EventBus.emit(EVENTS.SESSION_CLOSE); }
|
|
1573
|
+
|
|
1574
|
+
/**
|
|
1575
|
+
* Minimize the widget without ending the workflow. Reopening resumes it.
|
|
1576
|
+
* Use this for a Close/✕ affordance; use exitWorkflow() to actually end it.
|
|
1577
|
+
*/
|
|
1578
|
+
minimize() { EventBus.emit(EVENTS.HELP_MINIMIZE); }
|
|
1579
|
+
|
|
1580
|
+
/**
|
|
1581
|
+
* Re-show the current step's on-page hint. Re-resolves the step's target
|
|
1582
|
+
* against the current page and re-anchors the highlight + completion watcher.
|
|
1583
|
+
* Use it for a "Show hint" affordance when the on-page pointer has been
|
|
1584
|
+
* dismissed or has scrolled/re-rendered away.
|
|
1585
|
+
*/
|
|
1586
|
+
showHint() { EventBus.emit(EVENTS.HELP_SHOW_HINT); }
|
|
1587
|
+
|
|
1588
|
+
/** Start a "Do it for me" agent run. @param {Object} plan agent_plan */
|
|
1589
|
+
startAgent(plan) { EventBus.emit(EVENTS.AGENT_START, plan); }
|
|
1590
|
+
|
|
1591
|
+
/** Cooperatively stop the running agent. */
|
|
1592
|
+
stopAgent() { EventBus.emit(EVENTS.AGENT_STOP); }
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
// Create singleton instance
|
|
1596
|
+
const handHoldInstance = new HandHold();
|
|
1597
|
+
|
|
1598
|
+
export default handHoldInstance;
|
|
1599
|
+
export { HandHold };
|