@nextpa/chat-core 0.1.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/LICENSE +21 -0
- package/README.md +53 -0
- package/dist/constants/defaults.d.ts +6 -0
- package/dist/constants/defaults.js +69 -0
- package/dist/core/chat-manager.d.ts +17 -0
- package/dist/core/chat-manager.js +39 -0
- package/dist/core/config-manager.d.ts +14 -0
- package/dist/core/config-manager.js +75 -0
- package/dist/core/connection-manager.d.ts +68 -0
- package/dist/core/connection-manager.js +273 -0
- package/dist/core/connection-retry-handler.d.ts +11 -0
- package/dist/core/connection-retry-handler.js +33 -0
- package/dist/core/consent-manager.d.ts +15 -0
- package/dist/core/consent-manager.js +40 -0
- package/dist/core/conversation-persistence.d.ts +23 -0
- package/dist/core/conversation-persistence.js +109 -0
- package/dist/core/event-emitter.d.ts +7 -0
- package/dist/core/event-emitter.js +27 -0
- package/dist/core/greeting-gate.d.ts +16 -0
- package/dist/core/greeting-gate.js +53 -0
- package/dist/core/index.d.ts +9 -0
- package/dist/core/index.js +7 -0
- package/dist/core/link-target.d.ts +15 -0
- package/dist/core/link-target.js +64 -0
- package/dist/core/local-storage-persistence.d.ts +11 -0
- package/dist/core/local-storage-persistence.js +111 -0
- package/dist/core/navigation-manager.d.ts +24 -0
- package/dist/core/navigation-manager.js +132 -0
- package/dist/core/popover-manager.d.ts +14 -0
- package/dist/core/popover-manager.js +36 -0
- package/dist/core/session-beacon.d.ts +11 -0
- package/dist/core/session-beacon.js +60 -0
- package/dist/core/text-connection-manager.d.ts +58 -0
- package/dist/core/text-connection-manager.js +200 -0
- package/dist/core/text-mode-manager.d.ts +50 -0
- package/dist/core/text-mode-manager.js +154 -0
- package/dist/core/text-types.d.ts +20 -0
- package/dist/core/text-types.js +1 -0
- package/dist/core/text-widget-controller.d.ts +47 -0
- package/dist/core/text-widget-controller.js +278 -0
- package/dist/core/theme-manager.d.ts +10 -0
- package/dist/core/theme-manager.js +25 -0
- package/dist/core/types.d.ts +50 -0
- package/dist/core/types.js +1 -0
- package/dist/core/voice-state-manager.d.ts +85 -0
- package/dist/core/voice-state-manager.js +317 -0
- package/dist/core/widget-controller.d.ts +58 -0
- package/dist/core/widget-controller.js +533 -0
- package/dist/core/widget-core.d.ts +51 -0
- package/dist/core/widget-core.js +160 -0
- package/dist/google.d.ts +1 -0
- package/dist/google.js +12 -0
- package/dist/index.d.ts +37 -0
- package/dist/index.js +40 -0
- package/dist/openai.d.ts +1 -0
- package/dist/openai.js +7 -0
- package/dist/services/api.d.ts +114 -0
- package/dist/services/api.js +336 -0
- package/dist/services/index.d.ts +3 -0
- package/dist/services/index.js +3 -0
- package/dist/services/page-interaction/constants.d.ts +1 -0
- package/dist/services/page-interaction/constants.js +1 -0
- package/dist/services/page-interaction/content-extractor.d.ts +2 -0
- package/dist/services/page-interaction/content-extractor.js +104 -0
- package/dist/services/page-interaction/deep-query.d.ts +8 -0
- package/dist/services/page-interaction/deep-query.js +44 -0
- package/dist/services/page-interaction/dom-executor.d.ts +7 -0
- package/dist/services/page-interaction/dom-executor.js +172 -0
- package/dist/services/page-interaction/dom-extractor.d.ts +8 -0
- package/dist/services/page-interaction/dom-extractor.js +252 -0
- package/dist/services/page-interaction/extension-bridge.d.ts +10 -0
- package/dist/services/page-interaction/extension-bridge.js +86 -0
- package/dist/services/page-interaction/iframe-navigation-manager.d.ts +28 -0
- package/dist/services/page-interaction/iframe-navigation-manager.js +154 -0
- package/dist/services/page-interaction/index.d.ts +5 -0
- package/dist/services/page-interaction/index.js +4 -0
- package/dist/services/page-interaction/page-interaction-handler.d.ts +21 -0
- package/dist/services/page-interaction/page-interaction-handler.js +129 -0
- package/dist/services/page-interaction/selector-generator.d.ts +1 -0
- package/dist/services/page-interaction/selector-generator.js +48 -0
- package/dist/services/page-interaction/types.d.ts +75 -0
- package/dist/services/page-interaction/types.js +1 -0
- package/dist/services/realtime/google/google-audio.d.ts +44 -0
- package/dist/services/realtime/google/google-audio.js +410 -0
- package/dist/services/realtime/google/google-provider.d.ts +47 -0
- package/dist/services/realtime/google/google-provider.js +465 -0
- package/dist/services/realtime/google/google-tool-mapper.d.ts +3 -0
- package/dist/services/realtime/google/google-tool-mapper.js +70 -0
- package/dist/services/realtime/google/index.d.ts +1 -0
- package/dist/services/realtime/google/index.js +1 -0
- package/dist/services/realtime/openai/index.d.ts +1 -0
- package/dist/services/realtime/openai/index.js +1 -0
- package/dist/services/realtime/openai/openai-audio-monitor.d.ts +1 -0
- package/dist/services/realtime/openai/openai-audio-monitor.js +55 -0
- package/dist/services/realtime/openai/openai-provider.d.ts +50 -0
- package/dist/services/realtime/openai/openai-provider.js +579 -0
- package/dist/services/realtime/openai/openai-tool-mapper.d.ts +15 -0
- package/dist/services/realtime/openai/openai-tool-mapper.js +14 -0
- package/dist/services/realtime/provider-factory.d.ts +4 -0
- package/dist/services/realtime/provider-factory.js +12 -0
- package/dist/services/realtime/types.d.ts +78 -0
- package/dist/services/realtime/types.js +1 -0
- package/dist/services/realtime/vertexai/index.d.ts +1 -0
- package/dist/services/realtime/vertexai/index.js +1 -0
- package/dist/services/realtime/vertexai/vertexai-provider.d.ts +53 -0
- package/dist/services/realtime/vertexai/vertexai-provider.js +561 -0
- package/dist/services/realtime/voice-session-tracker.d.ts +10 -0
- package/dist/services/realtime/voice-session-tracker.js +62 -0
- package/dist/services/signalr.d.ts +37 -0
- package/dist/services/signalr.js +145 -0
- package/dist/services/thinking-audio-pulse.d.ts +2 -0
- package/dist/services/thinking-audio-pulse.js +72 -0
- package/dist/services/thinking-audio.d.ts +2 -0
- package/dist/services/thinking-audio.js +105 -0
- package/dist/services/voice-health-watchdog.d.ts +42 -0
- package/dist/services/voice-health-watchdog.js +90 -0
- package/dist/services/webrtc-event-handler.d.ts +4 -0
- package/dist/services/webrtc-event-handler.js +42 -0
- package/dist/services/webrtc-greeting.d.ts +3 -0
- package/dist/services/webrtc-greeting.js +62 -0
- package/dist/services/webrtc-microphone.d.ts +15 -0
- package/dist/services/webrtc-microphone.js +64 -0
- package/dist/services/webrtc.d.ts +44 -0
- package/dist/services/webrtc.js +196 -0
- package/dist/types/index.d.ts +151 -0
- package/dist/types/index.js +1 -0
- package/dist/utils/audio-context.d.ts +11 -0
- package/dist/utils/audio-context.js +33 -0
- package/dist/utils/connect-chime.d.ts +11 -0
- package/dist/utils/connect-chime.js +230 -0
- package/dist/utils/consent.d.ts +4 -0
- package/dist/utils/consent.js +30 -0
- package/dist/utils/language-detection.d.ts +1 -0
- package/dist/utils/language-detection.js +17 -0
- package/dist/utils/logger.d.ts +13 -0
- package/dist/utils/logger.js +40 -0
- package/dist/utils/mic-level-analyser.d.ts +13 -0
- package/dist/utils/mic-level-analyser.js +50 -0
- package/dist/utils/proactive-events.d.ts +5 -0
- package/dist/utils/proactive-events.js +1 -0
- package/dist/utils/styles.d.ts +4 -0
- package/dist/utils/styles.js +35 -0
- package/dist/utils/theme-detection.d.ts +3 -0
- package/dist/utils/theme-detection.js +121 -0
- package/dist/utils/tool-status.d.ts +10 -0
- package/dist/utils/tool-status.js +61 -0
- package/dist/utils/ui-sounds.d.ts +3 -0
- package/dist/utils/ui-sounds.js +57 -0
- package/package.json +56 -0
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
import { logger } from '../utils/logger';
|
|
2
|
+
import { detectHostLanguage } from '../utils/language-detection';
|
|
3
|
+
// Dev fallback only; the host app passes the real base URL to the constructor.
|
|
4
|
+
const API_BASE_URL = 'https://localhost:6101';
|
|
5
|
+
export class WidgetApiClient {
|
|
6
|
+
static CLIENT_ID_STORAGE_KEY = 'nextpa_widget_client_id';
|
|
7
|
+
baseUrl;
|
|
8
|
+
apiKey;
|
|
9
|
+
tokenProvider;
|
|
10
|
+
clientId;
|
|
11
|
+
hostLanguage;
|
|
12
|
+
bearerToken = null;
|
|
13
|
+
tokenExpiresAt = null;
|
|
14
|
+
tokenClaims = {};
|
|
15
|
+
source;
|
|
16
|
+
constructor(options) {
|
|
17
|
+
if (!options.apiKey && !options.tokenProvider) {
|
|
18
|
+
throw new Error('WidgetApiClient requires an apiKey (public widget key exchange) or a custom tokenProvider.');
|
|
19
|
+
}
|
|
20
|
+
this.apiKey = options.apiKey ?? null;
|
|
21
|
+
this.baseUrl = options.baseUrl || API_BASE_URL;
|
|
22
|
+
this.clientId = this.getOrCreateClientId();
|
|
23
|
+
this.hostLanguage = detectHostLanguage();
|
|
24
|
+
this.source = options.source ?? null;
|
|
25
|
+
this.tokenProvider = options.tokenProvider ?? (() => this.exchangeApiKey());
|
|
26
|
+
}
|
|
27
|
+
getOrCreateClientId() {
|
|
28
|
+
try {
|
|
29
|
+
const stored = localStorage.getItem(WidgetApiClient.CLIENT_ID_STORAGE_KEY);
|
|
30
|
+
if (stored) {
|
|
31
|
+
logger.debug('[API] Using stored ClientId:', stored);
|
|
32
|
+
return stored;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
// localStorage not available
|
|
37
|
+
}
|
|
38
|
+
const newClientId = crypto.randomUUID();
|
|
39
|
+
logger.debug('[API] Generated new ClientId:', newClientId);
|
|
40
|
+
try {
|
|
41
|
+
localStorage.setItem(WidgetApiClient.CLIENT_ID_STORAGE_KEY, newClientId);
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
// localStorage not available
|
|
45
|
+
}
|
|
46
|
+
return newClientId;
|
|
47
|
+
}
|
|
48
|
+
getClientId() {
|
|
49
|
+
return this.clientId;
|
|
50
|
+
}
|
|
51
|
+
// Start a brand-new conversation identity. Used on reset so the next conversation is keyed by a
|
|
52
|
+
// fresh clientId — immune to a late async write resurrecting the previous conversation's memory.
|
|
53
|
+
rotateClientId() {
|
|
54
|
+
this.clientId = crypto.randomUUID();
|
|
55
|
+
try {
|
|
56
|
+
localStorage.setItem(WidgetApiClient.CLIENT_ID_STORAGE_KEY, this.clientId);
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
// localStorage not available
|
|
60
|
+
}
|
|
61
|
+
logger.debug('[API] Rotated ClientId:', this.clientId);
|
|
62
|
+
return this.clientId;
|
|
63
|
+
}
|
|
64
|
+
getTokenClaims() {
|
|
65
|
+
return this.tokenClaims;
|
|
66
|
+
}
|
|
67
|
+
async authenticate() {
|
|
68
|
+
const result = await this.tokenProvider({ baseUrl: this.baseUrl, clientId: this.clientId });
|
|
69
|
+
this.bearerToken = result.token;
|
|
70
|
+
this.tokenExpiresAt = new Date(result.expiresAt);
|
|
71
|
+
this.tokenClaims = this.parseTokenClaims(result.token);
|
|
72
|
+
await this.validateClientId();
|
|
73
|
+
return result;
|
|
74
|
+
}
|
|
75
|
+
exchangeApiKey() {
|
|
76
|
+
return this.requestWithApiKey('/api/widget/authenticate', {
|
|
77
|
+
method: 'POST',
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
async validateClientId() {
|
|
81
|
+
try {
|
|
82
|
+
const response = await this.requestWithBearer('/api/widget/validate-client', {
|
|
83
|
+
method: 'POST',
|
|
84
|
+
headers: { 'Content-Type': 'application/json' },
|
|
85
|
+
body: JSON.stringify({ clientId: this.clientId }),
|
|
86
|
+
});
|
|
87
|
+
if (!response.valid) {
|
|
88
|
+
logger.debug('[API] ClientId invalid for current assistant, using new:', response.clientId);
|
|
89
|
+
this.clientId = response.clientId;
|
|
90
|
+
try {
|
|
91
|
+
localStorage.setItem(WidgetApiClient.CLIENT_ID_STORAGE_KEY, response.clientId);
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
// localStorage not available
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
logger.debug('[API] Client validation failed, keeping current clientId');
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
parseTokenClaims(token) {
|
|
103
|
+
try {
|
|
104
|
+
const parts = token.split('.');
|
|
105
|
+
if (parts.length !== 3)
|
|
106
|
+
return {};
|
|
107
|
+
const payload = JSON.parse(atob(parts[1]));
|
|
108
|
+
return {
|
|
109
|
+
assistantId: payload.assistant_id,
|
|
110
|
+
tenantId: payload['stratara:tenant_id'],
|
|
111
|
+
userId: payload.sub,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
logger.debug('[API] Failed to parse token claims');
|
|
116
|
+
return {};
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
isAuthenticated() {
|
|
120
|
+
if (!this.bearerToken || !this.tokenExpiresAt) {
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
return this.tokenExpiresAt.getTime() > Date.now() + 60000;
|
|
124
|
+
}
|
|
125
|
+
getBearerToken() {
|
|
126
|
+
return this.bearerToken;
|
|
127
|
+
}
|
|
128
|
+
getBaseUrl() {
|
|
129
|
+
return this.baseUrl;
|
|
130
|
+
}
|
|
131
|
+
async getWidgetConfig() {
|
|
132
|
+
await this.ensureAuthenticated();
|
|
133
|
+
return this.requestWithBearer('/api/widget/config');
|
|
134
|
+
}
|
|
135
|
+
async generatePrompt(pageUrl, pageTitle) {
|
|
136
|
+
await this.ensureAuthenticated();
|
|
137
|
+
const params = new URLSearchParams();
|
|
138
|
+
if (pageUrl)
|
|
139
|
+
params.set('pageUrl', pageUrl);
|
|
140
|
+
if (pageTitle)
|
|
141
|
+
params.set('pageTitle', pageTitle);
|
|
142
|
+
const query = params.toString();
|
|
143
|
+
const endpoint = query ? `/api/widget/prompt?${query}` : '/api/widget/prompt';
|
|
144
|
+
const response = await this.requestWithBearer(endpoint);
|
|
145
|
+
return response.prompt;
|
|
146
|
+
}
|
|
147
|
+
async getEphemeralToken() {
|
|
148
|
+
await this.ensureAuthenticated();
|
|
149
|
+
return this.requestWithBearer('/api/widget/token');
|
|
150
|
+
}
|
|
151
|
+
async getTools() {
|
|
152
|
+
await this.ensureAuthenticated();
|
|
153
|
+
return this.requestWithBearer('/api/widget/tools');
|
|
154
|
+
}
|
|
155
|
+
async executeTool(name, jsonArguments, signal) {
|
|
156
|
+
await this.ensureAuthenticated();
|
|
157
|
+
const response = await this.requestWithBearer('/api/widget/tools/execute', {
|
|
158
|
+
method: 'POST',
|
|
159
|
+
body: JSON.stringify({ name, arguments: jsonArguments }),
|
|
160
|
+
signal,
|
|
161
|
+
});
|
|
162
|
+
return response.result;
|
|
163
|
+
}
|
|
164
|
+
async transcribeAudio(audio, signal) {
|
|
165
|
+
await this.ensureAuthenticated();
|
|
166
|
+
const oggOrWebm = audio.type.includes('ogg') ? 'ogg' : 'webm';
|
|
167
|
+
const extension = audio.type.includes('mp4') ? 'mp4' : oggOrWebm;
|
|
168
|
+
const formData = new FormData();
|
|
169
|
+
formData.append('audio', audio, `dictation.${extension}`);
|
|
170
|
+
const response = await this.requestWithBearer('/api/widget/transcribe', {
|
|
171
|
+
method: 'POST',
|
|
172
|
+
body: formData,
|
|
173
|
+
signal,
|
|
174
|
+
});
|
|
175
|
+
return response.text;
|
|
176
|
+
}
|
|
177
|
+
async addConversationMessage(params) {
|
|
178
|
+
await this.ensureAuthenticated();
|
|
179
|
+
await this.requestWithBearer('/api/widget/conversation/message', {
|
|
180
|
+
method: 'POST',
|
|
181
|
+
body: JSON.stringify(params),
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
async startSession() {
|
|
185
|
+
await this.ensureAuthenticated();
|
|
186
|
+
await this.requestWithBearer('/api/widget/conversation/start-session', {
|
|
187
|
+
method: 'POST',
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
async getConversationHistory() {
|
|
191
|
+
await this.ensureAuthenticated();
|
|
192
|
+
const response = await this.requestWithBearer('/api/widget/conversation/history');
|
|
193
|
+
return response.entries;
|
|
194
|
+
}
|
|
195
|
+
async submitFeedback(messageId, rating) {
|
|
196
|
+
await this.ensureAuthenticated();
|
|
197
|
+
await this.requestWithBearer('/api/widget/conversation/feedback', {
|
|
198
|
+
method: 'POST',
|
|
199
|
+
body: JSON.stringify({ messageId, rating }),
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
async submitSessionFeedback(rating) {
|
|
203
|
+
await this.ensureAuthenticated();
|
|
204
|
+
await this.requestWithBearer('/api/widget/conversation/session-feedback', {
|
|
205
|
+
method: 'POST',
|
|
206
|
+
body: JSON.stringify({ rating }),
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
async endConversationSession(userFeedback, trimFarewell = false) {
|
|
210
|
+
await this.ensureAuthenticated();
|
|
211
|
+
await this.requestWithBearer('/api/widget/conversation/end', {
|
|
212
|
+
method: 'POST',
|
|
213
|
+
body: JSON.stringify({ userFeedback, trimFarewell }),
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
async resetConversation() {
|
|
217
|
+
await this.ensureAuthenticated();
|
|
218
|
+
await this.requestWithBearer('/api/widget/conversation/reset', {
|
|
219
|
+
method: 'POST',
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
async startVoiceSession(sessionId) {
|
|
223
|
+
await this.ensureAuthenticated();
|
|
224
|
+
await this.requestWithBearer('/api/widget/voice/start', {
|
|
225
|
+
method: 'POST',
|
|
226
|
+
body: JSON.stringify({ sessionId }),
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
async sendVoiceHeartbeat(sessionId) {
|
|
230
|
+
await this.ensureAuthenticated();
|
|
231
|
+
await this.requestWithBearer('/api/widget/voice/heartbeat', {
|
|
232
|
+
method: 'POST',
|
|
233
|
+
body: JSON.stringify({ sessionId }),
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
async endVoiceSession(sessionId) {
|
|
237
|
+
await this.ensureAuthenticated();
|
|
238
|
+
await this.requestWithBearer('/api/widget/voice/end', {
|
|
239
|
+
method: 'POST',
|
|
240
|
+
body: JSON.stringify({ sessionId }),
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
sendVoiceSessionBeacon(sessionId) {
|
|
244
|
+
const url = `${this.baseUrl}/api/widget/voice/end-beacon`;
|
|
245
|
+
// tenantId is no longer sent — the server derives it from the VoiceSession aggregate.
|
|
246
|
+
const payload = JSON.stringify({ sessionId });
|
|
247
|
+
fetch(url, {
|
|
248
|
+
method: 'POST',
|
|
249
|
+
headers: { 'Content-Type': 'application/json' },
|
|
250
|
+
body: payload,
|
|
251
|
+
keepalive: true,
|
|
252
|
+
}).catch(() => {
|
|
253
|
+
const blob = new Blob([payload], { type: 'application/json' });
|
|
254
|
+
navigator.sendBeacon(url, blob);
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
async sendChatMessage(message, pageUrl, pageTitle, isGreeting = false) {
|
|
258
|
+
await this.ensureAuthenticated();
|
|
259
|
+
await this.requestWithBearer('/api/widget/chat', {
|
|
260
|
+
method: 'POST',
|
|
261
|
+
body: JSON.stringify({ message, pageUrl, pageTitle, isGreeting }),
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
async ensureAuthenticated() {
|
|
265
|
+
if (!this.isAuthenticated()) {
|
|
266
|
+
await this.authenticate();
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
async requestWithApiKey(endpoint, options = {}) {
|
|
270
|
+
const apiKey = this.apiKey;
|
|
271
|
+
if (!apiKey) {
|
|
272
|
+
throw new Error('No apiKey configured — supply an apiKey or use a custom tokenProvider.');
|
|
273
|
+
}
|
|
274
|
+
return this.request(endpoint, {
|
|
275
|
+
...options,
|
|
276
|
+
headers: {
|
|
277
|
+
'X-Widget-Api-Key': apiKey,
|
|
278
|
+
...(this.source ? { 'X-Widget-Source': this.source } : {}),
|
|
279
|
+
...options.headers,
|
|
280
|
+
},
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
async requestWithBearer(endpoint, options = {}) {
|
|
284
|
+
if (!this.bearerToken) {
|
|
285
|
+
throw new Error('Not authenticated. Call authenticate() first.');
|
|
286
|
+
}
|
|
287
|
+
const headers = {
|
|
288
|
+
'Authorization': `Bearer ${this.bearerToken}`,
|
|
289
|
+
'X-Client-Id': this.clientId,
|
|
290
|
+
...(this.hostLanguage ? { 'X-Widget-Language': this.hostLanguage } : {}),
|
|
291
|
+
...options.headers,
|
|
292
|
+
};
|
|
293
|
+
return this.request(endpoint, {
|
|
294
|
+
...options,
|
|
295
|
+
headers,
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
async request(endpoint, options = {}) {
|
|
299
|
+
const cleanBaseUrl = this.baseUrl.endsWith('/') ? this.baseUrl.slice(0, -1) : this.baseUrl;
|
|
300
|
+
const cleanEndpoint = endpoint.startsWith('/') ? endpoint : `/${endpoint}`;
|
|
301
|
+
const url = `${cleanBaseUrl}${cleanEndpoint}`;
|
|
302
|
+
const method = options.method || 'GET';
|
|
303
|
+
logger.debug(`[API] ${method} ${url}`);
|
|
304
|
+
const headers = {
|
|
305
|
+
...options.headers,
|
|
306
|
+
};
|
|
307
|
+
// FormData sets its own multipart Content-Type (with boundary) — forcing
|
|
308
|
+
// application/json here would corrupt the upload.
|
|
309
|
+
if (method !== 'GET' && method !== 'HEAD' && !(options.body instanceof FormData)) {
|
|
310
|
+
headers['Content-Type'] = 'application/json';
|
|
311
|
+
}
|
|
312
|
+
try {
|
|
313
|
+
const response = await fetch(url, {
|
|
314
|
+
...options,
|
|
315
|
+
headers,
|
|
316
|
+
});
|
|
317
|
+
logger.debug(`[API] Response: ${response.status} ${response.statusText}`);
|
|
318
|
+
if (!response.ok) {
|
|
319
|
+
const error = await response.json().catch(() => ({
|
|
320
|
+
message: `HTTP error ${response.status}`,
|
|
321
|
+
}));
|
|
322
|
+
throw new Error(error.message);
|
|
323
|
+
}
|
|
324
|
+
const contentLength = response.headers.get('content-length');
|
|
325
|
+
const contentType = response.headers.get('content-type');
|
|
326
|
+
if (contentLength === '0' || !contentType?.includes('application/json')) {
|
|
327
|
+
return undefined;
|
|
328
|
+
}
|
|
329
|
+
return response.json();
|
|
330
|
+
}
|
|
331
|
+
catch (err) {
|
|
332
|
+
logger.error(`[API] Fetch error:`, err);
|
|
333
|
+
throw err;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const NEXTPA_IFRAME_NAME = "nextpa-nav-iframe";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const NEXTPA_IFRAME_NAME = 'nextpa-nav-iframe';
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { queryAllDeep } from './deep-query';
|
|
2
|
+
const CONTENT_ROOT_SELECTOR = 'main, [role="main"], article';
|
|
3
|
+
const SKIP_SELECTOR = 'nav, footer, aside, script, style, noscript, template, [aria-hidden="true"], [hidden]';
|
|
4
|
+
const CONTENT_SELECTOR = 'h1, h2, h3, h4, h5, h6, p, li, blockquote, pre, figcaption, dt, dd, td, th, summary';
|
|
5
|
+
const HEADING_TAGS = new Set(['H1', 'H2', 'H3', 'H4', 'H5', 'H6']);
|
|
6
|
+
const MAX_CONTENT_LENGTH = 8000;
|
|
7
|
+
const TRUNCATION_MARKER = '\n\n[content truncated]';
|
|
8
|
+
const EMBEDDED_FRAME_MARKER = '[embedded content]';
|
|
9
|
+
const UNREADABLE_FRAME_MARKER = '[an embedded frame from another site is on this page and cannot be read]';
|
|
10
|
+
export function extractPageContent(doc = document, win = globalThis) {
|
|
11
|
+
const lines = [...collectDocumentLines(doc, win), ...collectFrameLines(doc, win)];
|
|
12
|
+
const fullText = lines.join('\n').trim();
|
|
13
|
+
const truncated = fullText.length > MAX_CONTENT_LENGTH;
|
|
14
|
+
const content = truncated
|
|
15
|
+
? fullText.slice(0, MAX_CONTENT_LENGTH).trimEnd() + TRUNCATION_MARKER
|
|
16
|
+
: fullText;
|
|
17
|
+
return {
|
|
18
|
+
url: win.location.href,
|
|
19
|
+
title: doc.title || null,
|
|
20
|
+
content,
|
|
21
|
+
truncated,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
function collectDocumentLines(doc, win) {
|
|
25
|
+
const root = doc.querySelector(CONTENT_ROOT_SELECTOR) ?? doc.body ?? doc.documentElement;
|
|
26
|
+
return root ? collectContentLines(root, win) : [];
|
|
27
|
+
}
|
|
28
|
+
// A frame from another site is unreadable by design, and saying so is better than the assistant
|
|
29
|
+
// reporting that it cannot see what the visitor is looking at. One note is enough per page.
|
|
30
|
+
function collectFrameLines(doc, win) {
|
|
31
|
+
const lines = [];
|
|
32
|
+
let unreadableNoted = false;
|
|
33
|
+
for (const frame of queryAllDeep(doc, 'iframe')) {
|
|
34
|
+
if (frame.closest(SKIP_SELECTOR))
|
|
35
|
+
continue;
|
|
36
|
+
const frameDoc = documentOf(frame);
|
|
37
|
+
if (!frameDoc) {
|
|
38
|
+
if (!unreadableNoted) {
|
|
39
|
+
lines.push(UNREADABLE_FRAME_MARKER);
|
|
40
|
+
unreadableNoted = true;
|
|
41
|
+
}
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
const inner = collectDocumentLines(frameDoc, frame.contentWindow ?? win);
|
|
45
|
+
if (inner.length === 0)
|
|
46
|
+
continue;
|
|
47
|
+
lines.push(EMBEDDED_FRAME_MARKER, ...inner);
|
|
48
|
+
}
|
|
49
|
+
return lines;
|
|
50
|
+
}
|
|
51
|
+
function documentOf(frame) {
|
|
52
|
+
try {
|
|
53
|
+
return frame.contentDocument;
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
function collectContentLines(root, win) {
|
|
60
|
+
const lines = [];
|
|
61
|
+
for (const element of queryAllDeep(root, CONTENT_SELECTOR)) {
|
|
62
|
+
if (element.closest(SKIP_SELECTOR))
|
|
63
|
+
continue;
|
|
64
|
+
if (hasContentAncestor(element, root))
|
|
65
|
+
continue;
|
|
66
|
+
if (isHidden(element, win))
|
|
67
|
+
continue;
|
|
68
|
+
const text = renderedText(element);
|
|
69
|
+
if (!text)
|
|
70
|
+
continue;
|
|
71
|
+
lines.push(formatLine(element, text));
|
|
72
|
+
}
|
|
73
|
+
return lines;
|
|
74
|
+
}
|
|
75
|
+
function hasContentAncestor(element, root) {
|
|
76
|
+
let parent = element.parentElement;
|
|
77
|
+
while (parent && parent !== root) {
|
|
78
|
+
if (parent.matches(CONTENT_SELECTOR))
|
|
79
|
+
return true;
|
|
80
|
+
parent = parent.parentElement;
|
|
81
|
+
}
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
function formatLine(element, text) {
|
|
85
|
+
if (HEADING_TAGS.has(element.tagName)) {
|
|
86
|
+
return '#'.repeat(Number(element.tagName.charAt(1))) + ' ' + text;
|
|
87
|
+
}
|
|
88
|
+
if (element.tagName === 'LI') {
|
|
89
|
+
return '- ' + text;
|
|
90
|
+
}
|
|
91
|
+
return text;
|
|
92
|
+
}
|
|
93
|
+
function renderedText(element) {
|
|
94
|
+
const text = element.innerText ?? element.textContent ?? '';
|
|
95
|
+
return text.replace(/\s+/g, ' ').trim();
|
|
96
|
+
}
|
|
97
|
+
function isHidden(element, win) {
|
|
98
|
+
if (element.hasAttribute('hidden'))
|
|
99
|
+
return true;
|
|
100
|
+
if (element.getAttribute('aria-hidden') === 'true')
|
|
101
|
+
return true;
|
|
102
|
+
const style = win.getComputedStyle(element);
|
|
103
|
+
return style.display === 'none' || style.visibility === 'hidden';
|
|
104
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Like `querySelectorAll`, but descends into open shadow roots. A closed shadow root stays
|
|
3
|
+
* invisible — nothing can reach it, which is the boundary the page-interaction capability
|
|
4
|
+
* describes. Light DOM comes first, shadow content after the host it belongs to.
|
|
5
|
+
*/
|
|
6
|
+
export declare function queryAllDeep<T extends Element>(root: ParentNode, selector: string): T[];
|
|
7
|
+
/** The single-element form, used to resolve a selector back to an element for an action. */
|
|
8
|
+
export declare function querySelectorDeep<T extends Element>(root: ParentNode, selector: string): T | null;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// The assistant's own surface. Reading it back would feed the assistant its own words, and
|
|
2
|
+
// offering its own controls as page controls would let it click itself.
|
|
3
|
+
const ASSISTANT_HOST_SELECTOR = 'nextpa-widget, [data-nextpa-widget]';
|
|
4
|
+
/**
|
|
5
|
+
* Like `querySelectorAll`, but descends into open shadow roots. A closed shadow root stays
|
|
6
|
+
* invisible — nothing can reach it, which is the boundary the page-interaction capability
|
|
7
|
+
* describes. Light DOM comes first, shadow content after the host it belongs to.
|
|
8
|
+
*/
|
|
9
|
+
export function queryAllDeep(root, selector) {
|
|
10
|
+
const found = [];
|
|
11
|
+
collectDeep(root, selector, found);
|
|
12
|
+
return found;
|
|
13
|
+
}
|
|
14
|
+
/** The single-element form, used to resolve a selector back to an element for an action. */
|
|
15
|
+
export function querySelectorDeep(root, selector) {
|
|
16
|
+
const direct = root.querySelector(selector);
|
|
17
|
+
if (direct)
|
|
18
|
+
return direct;
|
|
19
|
+
for (const shadow of shadowRootsOf(root)) {
|
|
20
|
+
const match = querySelectorDeep(shadow, selector);
|
|
21
|
+
if (match)
|
|
22
|
+
return match;
|
|
23
|
+
}
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
function collectDeep(root, selector, into) {
|
|
27
|
+
for (const element of Array.from(root.querySelectorAll(selector))) {
|
|
28
|
+
into.push(element);
|
|
29
|
+
}
|
|
30
|
+
for (const shadow of shadowRootsOf(root)) {
|
|
31
|
+
collectDeep(shadow, selector, into);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
function shadowRootsOf(root) {
|
|
35
|
+
const roots = [];
|
|
36
|
+
for (const host of Array.from(root.querySelectorAll('*'))) {
|
|
37
|
+
if (!host.shadowRoot)
|
|
38
|
+
continue;
|
|
39
|
+
if (host.matches(ASSISTANT_HOST_SELECTOR))
|
|
40
|
+
continue;
|
|
41
|
+
roots.push(host.shadowRoot);
|
|
42
|
+
}
|
|
43
|
+
return roots;
|
|
44
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { ActionResult } from './types';
|
|
2
|
+
export declare function fillField(selector: string, value: string, doc?: Document): ActionResult;
|
|
3
|
+
export declare function fillFields(fields: Record<string, string>, doc?: Document): ActionResult;
|
|
4
|
+
export declare function clickElement(selector: string, doc?: Document): ActionResult;
|
|
5
|
+
export declare function scrollToElement(selector: string, doc?: Document): ActionResult;
|
|
6
|
+
export declare function highlightElement(selector: string, color?: string, durationMs?: number, doc?: Document): ActionResult;
|
|
7
|
+
export declare function waitForElement(selector: string, timeoutMs?: number, doc?: Document): Promise<ActionResult>;
|