@sphoro/bugcatch-web-sdk 0.16.0 → 0.17.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 +24 -2
- package/dist/bugcatch.es.js +1402 -1299
- package/dist/bugcatch.es.js.map +1 -1
- package/dist/bugcatch.umd.js +117 -76
- package/dist/bugcatch.umd.js.map +1 -1
- package/package.json +1 -1
- package/src/agent-issues.js +30 -0
- package/src/identity.js +1 -1
- package/src/index.js +32 -2
- package/src/widget.js +90 -1
package/package.json
CHANGED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The predefined issues: questions the project's assistant can answer from the
|
|
3
|
+
* reporter's own account and close, offered in the widget as one-tap choices.
|
|
4
|
+
*
|
|
5
|
+
* The API decides the list (GET /ingest/agent-issues) — it is empty unless the
|
|
6
|
+
* project has the Sphoro Agent answering questions and a data source that can
|
|
7
|
+
* answer them. Every failure is an empty list: the tiles are a shortcut, and a
|
|
8
|
+
* widget that could not load them is still a widget that files reports.
|
|
9
|
+
*
|
|
10
|
+
* The agent ends its answer with SOLVED_QUESTION's wording; the widget draws
|
|
11
|
+
* Yes / No under it (widget.js messageHtml). Keep this pattern in step with
|
|
12
|
+
* agent-issues.ts SOLVED_QUESTION in the API and workers.
|
|
13
|
+
*/
|
|
14
|
+
export const SOLVED_PROMPT = /Did this answer your question\?/i;
|
|
15
|
+
|
|
16
|
+
export async function fetchAgentIssues({ apiBase, apiKey, projectId }) {
|
|
17
|
+
try {
|
|
18
|
+
const res = await fetch(
|
|
19
|
+
`${apiBase}/ingest/agent-issues?projectId=${encodeURIComponent(projectId)}`,
|
|
20
|
+
{ headers: { 'X-BugCatch-Key': apiKey } },
|
|
21
|
+
);
|
|
22
|
+
if (!res.ok) return [];
|
|
23
|
+
const body = await res.json();
|
|
24
|
+
return (Array.isArray(body?.issues) ? body.issues : [])
|
|
25
|
+
.filter((i) => i && typeof i.key === 'string' && typeof i.title === 'string')
|
|
26
|
+
.map((i) => ({ key: i.key, title: i.title, hint: typeof i.hint === 'string' ? i.hint : '' }));
|
|
27
|
+
} catch {
|
|
28
|
+
return [];
|
|
29
|
+
}
|
|
30
|
+
}
|
package/src/identity.js
CHANGED
|
@@ -202,7 +202,7 @@ export const KNOWN_OPTIONS = [
|
|
|
202
202
|
'attachments', 'maxAttachments', 'maxAttachmentBytes', 'maxRecordingMs', 'beforeSend',
|
|
203
203
|
'user', 'context', 'updates', 'updateToast', 'onUpdate', 'voice', 'onCallState',
|
|
204
204
|
'onIncomingCall', 'onReportSubmitted', 'onAssistantPhase', 'onError', 'debug',
|
|
205
|
-
'drafts', 'knownIssues', 'release', 'environment', 'buildId',
|
|
205
|
+
'drafts', 'knownIssues', 'agentIssues', 'release', 'environment', 'buildId',
|
|
206
206
|
];
|
|
207
207
|
|
|
208
208
|
const CALLBACK_OPTIONS = [
|
package/src/index.js
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
import { joinLiveCall, liveCallSupported, openMicrophone } from './live-call.js';
|
|
20
20
|
import { showIncomingCall } from './incoming-call.js';
|
|
21
21
|
import { referenceFields } from './references.js';
|
|
22
|
+
import { fetchAgentIssues } from './agent-issues.js';
|
|
22
23
|
import { normalizeUser, mergeContext, checkOptions, cleanApiBase } from './identity.js';
|
|
23
24
|
|
|
24
25
|
// Replaced at build time from package.json. The guard is for anyone importing
|
|
@@ -500,7 +501,7 @@ class BugCatchClient {
|
|
|
500
501
|
* copies of this would drift, and the half that drifted would be the one that
|
|
501
502
|
* stopped attaching the console.
|
|
502
503
|
*/
|
|
503
|
-
async #payload({ title, description = '', priority = 'MEDIUM', metadata = {}, media = [], references = [], context = null }) {
|
|
504
|
+
async #payload({ title, description = '', priority = 'MEDIUM', metadata = {}, media = [], references = [], context = null, issue = null }) {
|
|
504
505
|
// This report's own context over the screen's, without keeping it.
|
|
505
506
|
const ctx = context ? mergeContext(this.context, context, (m) => this.warn(m.replace('setContext()', 'report({ context })'))) : this.context;
|
|
506
507
|
return {
|
|
@@ -541,6 +542,9 @@ class BugCatchClient {
|
|
|
541
542
|
// description it is being sent anyway. A payload is a claim, and there is
|
|
542
543
|
// no reason to make the join key one when the source text is right here.
|
|
543
544
|
...(references.length ? { references } : {}),
|
|
545
|
+
// A predefined issue the reporter picked (agentIssues()). The API keeps
|
|
546
|
+
// it only if this project offers it.
|
|
547
|
+
...(issue ? { issue } : {}),
|
|
544
548
|
};
|
|
545
549
|
}
|
|
546
550
|
|
|
@@ -1136,7 +1140,7 @@ class BugCatchClient {
|
|
|
1136
1140
|
/** File a bug with everything captured so far. */
|
|
1137
1141
|
async report({
|
|
1138
1142
|
title, description = '', priority = 'MEDIUM', metadata = {}, attachments = [],
|
|
1139
|
-
references = [], context = null,
|
|
1143
|
+
references = [], context = null, issue = null,
|
|
1140
1144
|
} = {}) {
|
|
1141
1145
|
if (!this.started) throw new Error('[BugCatch] call init() first.');
|
|
1142
1146
|
if (typeof title !== 'string' || !title.trim()) throw new Error('[BugCatch] a title is required.');
|
|
@@ -1171,6 +1175,7 @@ class BugCatchClient {
|
|
|
1171
1175
|
let payload = await this.#payload({
|
|
1172
1176
|
title: title.trim(), description: typeof description === 'string' ? description : String(description ?? ''),
|
|
1173
1177
|
priority: String(priority).toUpperCase(), metadata, media, references: refs, context,
|
|
1178
|
+
issue: typeof issue === 'string' && issue ? issue.slice(0, 32) : null,
|
|
1174
1179
|
});
|
|
1175
1180
|
|
|
1176
1181
|
const screened = this.#screen(payload);
|
|
@@ -1291,6 +1296,31 @@ class BugCatchClient {
|
|
|
1291
1296
|
return this.refFields;
|
|
1292
1297
|
}
|
|
1293
1298
|
|
|
1299
|
+
/**
|
|
1300
|
+
* The questions this project's assistant can answer and close by itself —
|
|
1301
|
+
* "When is my next EMI due?", "Is my KYC complete?" — for a form to offer
|
|
1302
|
+
* as one-tap choices.
|
|
1303
|
+
*
|
|
1304
|
+
* const issues = await BugCatch.agentIssues();
|
|
1305
|
+
* // [{ key: 'emi', title: 'When is my next EMI due?', hint: 'The date and the amount' }]
|
|
1306
|
+
* BugCatch.report({ title: issues[0].title, issue: issues[0].key });
|
|
1307
|
+
*
|
|
1308
|
+
* The assistant looks the reporter up (identify() makes that instant),
|
|
1309
|
+
* answers from their record, and asks whether that settled it: yes resolves
|
|
1310
|
+
* the report, no hands it to the team. Empty unless the project has turned
|
|
1311
|
+
* this on and connected a data source that can answer. The widget calls it
|
|
1312
|
+
* itself; cached for the life of the page.
|
|
1313
|
+
*/
|
|
1314
|
+
agentIssues() {
|
|
1315
|
+
if (this.agentIssueList) return this.agentIssueList;
|
|
1316
|
+
this.agentIssueList = fetchAgentIssues({
|
|
1317
|
+
apiBase: this.config.apiBase,
|
|
1318
|
+
apiKey: this.config.apiKey,
|
|
1319
|
+
projectId: this.config.projectId,
|
|
1320
|
+
});
|
|
1321
|
+
return this.agentIssueList;
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1294
1324
|
/**
|
|
1295
1325
|
* Who this browser is signed in as on this project, from storage alone.
|
|
1296
1326
|
*
|
package/src/widget.js
CHANGED
|
@@ -15,6 +15,7 @@ import { draftKey, draftOperation, readDraft } from './drafts.js';
|
|
|
15
15
|
import { readSession } from './session.js';
|
|
16
16
|
import { sendMessage } from './updates.js';
|
|
17
17
|
import { readReferences, validateReference } from './references.js';
|
|
18
|
+
import { SOLVED_PROMPT } from './agent-issues.js';
|
|
18
19
|
|
|
19
20
|
const POSITIONS = {
|
|
20
21
|
'bottom-right': 'bottom:20px;right:20px;',
|
|
@@ -438,6 +439,31 @@ textarea { min-height: 88px; resize: vertical; }
|
|
|
438
439
|
.known .ask button { flex: none; }
|
|
439
440
|
.known .err { margin: 6px 0 0; font-size: 12px; line-height: 1.45; color: var(--bc-danger); }
|
|
440
441
|
|
|
442
|
+
/* ---------- questions the assistant answers by itself ---------- */
|
|
443
|
+
.picks { margin-bottom: 16px; }
|
|
444
|
+
.picks .picks-h {
|
|
445
|
+
margin: 0 0 8px; font-size: 11px; font-weight: 680; letter-spacing: .06em;
|
|
446
|
+
text-transform: uppercase; color: var(--bc-faint);
|
|
447
|
+
}
|
|
448
|
+
.picks .picks-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 6px; }
|
|
449
|
+
.picks .pick {
|
|
450
|
+
display: flex; flex-direction: column; align-items: flex-start; gap: 2px;
|
|
451
|
+
padding: 9px 11px; text-align: left; cursor: pointer; font: inherit;
|
|
452
|
+
color: var(--bc-fg); background: var(--bc-subtle);
|
|
453
|
+
border: 1px solid var(--bc-border); border-radius: var(--bc-r-md);
|
|
454
|
+
transition: background 160ms, border-color 160ms, transform 160ms;
|
|
455
|
+
animation: bc-rise 240ms var(--bc-ease) both;
|
|
456
|
+
}
|
|
457
|
+
.picks .pick:hover { border-color: var(--bc-accent-line); background: var(--bc-accent-soft); }
|
|
458
|
+
.picks .pick[aria-pressed="true"] { border-color: var(--bc-accent); background: var(--bc-accent-soft); }
|
|
459
|
+
.picks .pick .t { font-size: 13px; font-weight: 620; line-height: 1.35; overflow-wrap: anywhere; }
|
|
460
|
+
.picks .pick .h { font-size: var(--bc-fs-xs); line-height: 1.4; color: var(--bc-muted); }
|
|
461
|
+
.picks .picks-note {
|
|
462
|
+
display: flex; gap: 6px; align-items: flex-start; margin: 8px 0 0;
|
|
463
|
+
font-size: 12px; line-height: 1.45; color: var(--bc-muted);
|
|
464
|
+
}
|
|
465
|
+
.picks .picks-note svg { flex: none; margin-top: 2px; color: var(--bc-ok); }
|
|
466
|
+
|
|
441
467
|
/* ---------- who is reporting ----------
|
|
442
468
|
A strip above the form rather than a screen in front of it: where signing in
|
|
443
469
|
is optional this is a line the reporter may ignore, and it should read as
|
|
@@ -1341,6 +1367,9 @@ function openPanel({ root, host, dock, client, onClose }) {
|
|
|
1341
1367
|
<div class="body">
|
|
1342
1368
|
<div class="slot" role="alert"></div>
|
|
1343
1369
|
<div class="whoami" hidden></div>
|
|
1370
|
+
<!-- The questions the assistant can answer from their account and
|
|
1371
|
+
close by itself. Drawn only when the project offers any. -->
|
|
1372
|
+
<div class="picks" hidden></div>
|
|
1344
1373
|
<div class="known" hidden></div>
|
|
1345
1374
|
<div class="compose">
|
|
1346
1375
|
<div class="field">
|
|
@@ -1413,6 +1442,7 @@ function openPanel({ root, host, dock, client, onClose }) {
|
|
|
1413
1442
|
const whoami = $('.whoami');
|
|
1414
1443
|
const refsBox = $('.refs');
|
|
1415
1444
|
const known = $('.known');
|
|
1445
|
+
const picks = $('.picks');
|
|
1416
1446
|
const historyBtn = $('[data-act="history"]');
|
|
1417
1447
|
|
|
1418
1448
|
const draftStatus = document.createElement('div');
|
|
@@ -2075,6 +2105,7 @@ function openPanel({ root, host, dock, client, onClose }) {
|
|
|
2075
2105
|
description: descInput.value,
|
|
2076
2106
|
priority,
|
|
2077
2107
|
references: answeredReference(),
|
|
2108
|
+
issue: pickedIssue?.key || null,
|
|
2078
2109
|
attachments: items
|
|
2079
2110
|
.filter((it) => it.status === 'ready')
|
|
2080
2111
|
.map((it) => ({ type: it.kind, storageKey: it.storageKey, mime: it.mime })),
|
|
@@ -2221,6 +2252,57 @@ function openPanel({ root, host, dock, client, onClose }) {
|
|
|
2221
2252
|
loadKnownIssues();
|
|
2222
2253
|
titleInput.addEventListener('input', () => matchKnown(titleInput.value));
|
|
2223
2254
|
|
|
2255
|
+
/* ---------- questions the assistant answers by itself ---------- */
|
|
2256
|
+
|
|
2257
|
+
// One tap instead of a sentence: the report's title becomes the question,
|
|
2258
|
+
// and the assistant knows exactly what is being asked — it looks the
|
|
2259
|
+
// reporter up, answers from their account, and asks whether that settled
|
|
2260
|
+
// it. Tapping the chosen one again lets it go.
|
|
2261
|
+
let agentIssues = [];
|
|
2262
|
+
let pickedIssue = null;
|
|
2263
|
+
|
|
2264
|
+
function drawPicks() {
|
|
2265
|
+
if (!agentIssues.length) { picks.hidden = true; picks.innerHTML = ''; return; }
|
|
2266
|
+
picks.hidden = false;
|
|
2267
|
+
picks.innerHTML = `
|
|
2268
|
+
<p class="picks-h">Get an answer right away</p>
|
|
2269
|
+
<div class="picks-grid" role="group" aria-label="Common questions">
|
|
2270
|
+
${agentIssues.map((issue) => `
|
|
2271
|
+
<button type="button" class="pick" data-pick="${escapeHtml(issue.key)}"
|
|
2272
|
+
aria-pressed="${pickedIssue?.key === issue.key}">
|
|
2273
|
+
<span class="t">${escapeHtml(issue.title)}</span>
|
|
2274
|
+
${issue.hint ? `<span class="h">${escapeHtml(issue.hint)}</span>` : ''}
|
|
2275
|
+
</button>`).join('')}
|
|
2276
|
+
</div>
|
|
2277
|
+
${pickedIssue ? `<p class="picks-note">${ICONS.check}<span>Our assistant will check your account and reply here. Add anything else below, then send.</span></p>` : ''}`;
|
|
2278
|
+
}
|
|
2279
|
+
|
|
2280
|
+
picks.addEventListener('click', (e) => {
|
|
2281
|
+
const btn = e.target.closest('[data-pick]');
|
|
2282
|
+
if (!btn) return;
|
|
2283
|
+
const issue = agentIssues.find((i) => i.key === btn.dataset.pick);
|
|
2284
|
+
if (!issue) return;
|
|
2285
|
+
const titleWasPick = pickedIssue && titleInput.value.trim() === pickedIssue.title;
|
|
2286
|
+
if (pickedIssue?.key === issue.key) {
|
|
2287
|
+
pickedIssue = null;
|
|
2288
|
+
if (titleWasPick) titleInput.value = '';
|
|
2289
|
+
} else {
|
|
2290
|
+
pickedIssue = issue;
|
|
2291
|
+
// Their own words are never overwritten — only an empty title, or the
|
|
2292
|
+
// one the previous tap put there.
|
|
2293
|
+
if (!titleInput.value.trim() || titleWasPick) titleInput.value = issue.title;
|
|
2294
|
+
}
|
|
2295
|
+
titleInput.dispatchEvent(new Event('input', { bubbles: true }));
|
|
2296
|
+
drawPicks();
|
|
2297
|
+
if (pickedIssue) descInput.focus();
|
|
2298
|
+
});
|
|
2299
|
+
|
|
2300
|
+
if (cfg.agentIssues !== false && typeof client.agentIssues === 'function') {
|
|
2301
|
+
client.agentIssues()
|
|
2302
|
+
.then((rows) => { agentIssues = rows; drawPicks(); })
|
|
2303
|
+
.catch(() => { /* no tiles is the safe answer to every failure */ });
|
|
2304
|
+
}
|
|
2305
|
+
|
|
2224
2306
|
/* ---------- talk to the team ---------- */
|
|
2225
2307
|
|
|
2226
2308
|
// Asked once, when the dialog opens, and the button stays hidden unless the
|
|
@@ -3329,12 +3411,19 @@ function messageHtml(message, state = '') {
|
|
|
3329
3411
|
// confirmMessage). One tap sends it, and what comes back goes to the person
|
|
3330
3412
|
// who now owns the report rather than to another automated round — so the
|
|
3331
3413
|
// wording is "that's right" and "that's not it", not a yes/no question.
|
|
3414
|
+
// The Sphoro Agent's answer ends by asking whether it settled things
|
|
3415
|
+
// (SOLVED_PROMPT). Yes closes the report; No puts it in front of a person.
|
|
3332
3416
|
const confirm = automated && /it goes to the same person/i.test(body)
|
|
3333
3417
|
? `<div class="quick" role="group" aria-label="Answer">
|
|
3334
3418
|
<button type="button" data-quick="Yes, this is the problem">${ICONS.check}That's right</button>
|
|
3335
3419
|
<button type="button" data-quick="No, that is not the problem">That's not it</button>
|
|
3336
3420
|
</div>`
|
|
3337
|
-
:
|
|
3421
|
+
: automated && SOLVED_PROMPT.test(body)
|
|
3422
|
+
? `<div class="quick" role="group" aria-label="Did this answer your question?">
|
|
3423
|
+
<button type="button" data-quick="Yes, that answers it">${ICONS.check}Yes, that answers it</button>
|
|
3424
|
+
<button type="button" data-quick="No, I still need help">No, I still need help</button>
|
|
3425
|
+
</div>`
|
|
3426
|
+
: '';
|
|
3338
3427
|
|
|
3339
3428
|
return `
|
|
3340
3429
|
<div class="msg-row ${mine ? 'mine' : 'theirs'} ${automated ? 'auto' : ''} ${state}">
|