@spektra-cloudevents/application-copilot-widget 1.0.0 → 1.2.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/dist/index.cjs CHANGED
@@ -77,7 +77,7 @@ var widgetStyles = `
77
77
  /* Panel */
78
78
  .acw-panel {
79
79
  position: fixed; right: 24px; bottom: 96px; z-index: 2147483000;
80
- width: 410px; max-width: calc(100vw - 48px); height: 580px; max-height: calc(100vh - 130px);
80
+ width: 480px; max-width: calc(100vw - 48px); height: 660px; max-height: calc(100vh - 120px);
81
81
  display: none; flex-direction: column;
82
82
  background: var(--acw-bg); color: var(--acw-fg);
83
83
  border: 1px solid var(--acw-border); border-radius: 16px; overflow: hidden;
@@ -135,6 +135,26 @@ var widgetStyles = `
135
135
  .acw-input:focus { outline: 2px solid var(--acw-accent); outline-offset: 0; }
136
136
  .acw-send { border: none; border-radius: 10px; padding: 0 16px; background: var(--acw-accent); color: #fff; cursor: pointer; font-size: 14px; font-family: inherit; }
137
137
  .acw-send:hover { background: var(--acw-accent-dark); }
138
+
139
+ /* Support-ticket draft card */
140
+ .acw-ticket { background: var(--acw-bg); border: 1px solid var(--acw-border); border-radius: 12px; padding: 14px; margin: 12px 0; border-left: 4px solid var(--acw-accent); }
141
+ .acw-ticket-title { font-size: 15px; font-weight: 600; color: var(--acw-fg); margin: 0 0 4px; }
142
+ .acw-ticket-sub { font-size: 12px; color: var(--acw-muted); margin: 0 0 10px; }
143
+ .acw-ticket-label { display: block; font-size: 12px; font-weight: 600; color: var(--acw-muted); margin: 12px 0 5px; }
144
+ .acw-ticket-input, .acw-ticket-desc {
145
+ width: 100%; box-sizing: border-box; font-family: inherit; font-size: 13px;
146
+ padding: 10px 12px; border: 1px solid var(--acw-border); border-radius: 10px;
147
+ color: var(--acw-fg); background: var(--acw-bg);
148
+ }
149
+ .acw-ticket-desc { resize: vertical; min-height: 170px; line-height: 1.5; }
150
+ .acw-ticket-input:focus, .acw-ticket-desc:focus { outline: 2px solid var(--acw-accent); outline-offset: 0; }
151
+ .acw-ticket-actions { display: flex; gap: 10px; margin-top: 14px; }
152
+ .acw-btn-primary { border: none; border-radius: 10px; padding: 10px 20px; cursor: pointer; background: var(--acw-accent); color: #fff; font-size: 14px; font-weight: 600; font-family: inherit; }
153
+ .acw-btn-primary:hover { background: var(--acw-accent-dark); }
154
+ .acw-btn-ghost { border: 1px solid var(--acw-border); border-radius: 10px; padding: 10px 16px; cursor: pointer; background: var(--acw-bg); color: var(--acw-fg); font-size: 14px; font-family: inherit; }
155
+ .acw-btn-ghost:hover { background: #f0eefe; }
156
+ .acw-link-btn { margin-top: 8px; border: none; background: transparent; color: var(--acw-accent); font-size: 12px; font-family: inherit; cursor: pointer; padding: 0; text-decoration: underline; }
157
+ .acw-link-btn:hover { color: var(--acw-accent-dark); }
138
158
  `;
139
159
 
140
160
  // src/widget.ts
@@ -146,10 +166,15 @@ var PROGRESS_LABELS = {
146
166
  };
147
167
  var CopilotWidget = class {
148
168
  constructor() {
169
+ this.lastQuestion = "";
149
170
  this.mode = "explain";
150
171
  }
151
172
  init(config) {
152
173
  this.config = config;
174
+ try {
175
+ console.log("[ApplicationCopilot] widget loaded \u2014 support-ticket build v2");
176
+ } catch {
177
+ }
153
178
  this.mount();
154
179
  this.connectSignalR().catch(() => {
155
180
  });
@@ -161,6 +186,17 @@ var CopilotWidget = class {
161
186
  close() {
162
187
  this.panel?.classList.remove("acw-open");
163
188
  }
189
+ /** Show the launcher (widget host). No-op before init(). */
190
+ show() {
191
+ if (this.host) this.host.style.display = "";
192
+ }
193
+ /** Hide the launcher and close the panel. Used to gate the widget by route. */
194
+ hide() {
195
+ if (this.host) {
196
+ this.host.style.display = "none";
197
+ }
198
+ this.close();
199
+ }
164
200
  setContext(context) {
165
201
  this.contextOverride = context;
166
202
  }
@@ -168,6 +204,25 @@ var CopilotWidget = class {
168
204
  this.recentError = errorContext;
169
205
  this.open();
170
206
  }
207
+ /** Proactive nudge: show + open the panel with a message and an optional one-click suggested question. */
208
+ notify(text, suggestion) {
209
+ this.show();
210
+ this.open();
211
+ if (!this.body) return;
212
+ const el = document.createElement("div");
213
+ el.className = "acw-msg acw-assistant acw-nudge";
214
+ el.innerHTML = `<div class="acw-reason">${escapeHtml(text)}</div>`;
215
+ if (suggestion) {
216
+ const btn = document.createElement("button");
217
+ btn.type = "button";
218
+ btn.className = "acw-chip";
219
+ btn.textContent = suggestion;
220
+ btn.addEventListener("click", () => this.ask(suggestion));
221
+ el.appendChild(btn);
222
+ }
223
+ this.body.appendChild(el);
224
+ this.body.scrollTop = this.body.scrollHeight;
225
+ }
171
226
  ask(question) {
172
227
  if (this.mode === "intake") {
173
228
  void this.runIntakeAsk(question);
@@ -194,27 +249,79 @@ var CopilotWidget = class {
194
249
  wrap.querySelector(".acw-launch").addEventListener("click", () => this.panel?.classList.contains("acw-open") ? this.close() : this.open());
195
250
  const panel = document.createElement("div");
196
251
  panel.className = "acw-panel";
252
+ const uploadIcon = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>';
197
253
  panel.innerHTML = `
198
254
  <div class="acw-header">
199
255
  ${sparkle}
200
256
  <span class="acw-title">Application Copilot</span>
257
+ <button class="acw-kb" type="button" aria-label="Add a document to the knowledge base" title="Add to knowledge base (admins)" style="background:none;border:none;color:inherit;cursor:pointer;padding:2px 6px;display:inline-flex;align-items:center;">${uploadIcon}</button>
201
258
  <button class="acw-close" type="button" aria-label="Close">&times;</button>
202
259
  </div>
203
260
  <div class="acw-body"></div>
204
261
  <div class="acw-footer">
205
262
  <input class="acw-input" type="text" placeholder="Ask a question\u2026" />
206
263
  <button class="acw-send" type="button">Send</button>
207
- </div>`;
264
+ </div>
265
+ <input class="acw-file" type="file" accept=".pdf,.docx,.xlsx,.xlsm,.txt,.md,.markdown,.csv" style="display:none" />`;
208
266
  this.root.appendChild(panel);
209
267
  this.panel = panel;
210
268
  this.body = panel.querySelector(".acw-body");
211
269
  this.input = panel.querySelector(".acw-input");
270
+ this.fileInput = panel.querySelector(".acw-file");
212
271
  panel.querySelector(".acw-close").addEventListener("click", () => this.close());
213
272
  panel.querySelector(".acw-send").addEventListener("click", () => this.submitFromInput());
273
+ panel.querySelector(".acw-kb").addEventListener("click", () => this.promptUpload());
274
+ this.fileInput.addEventListener("change", () => {
275
+ const f = this.fileInput?.files?.[0];
276
+ if (f) {
277
+ void this.uploadDocument(f);
278
+ if (this.fileInput) this.fileInput.value = "";
279
+ }
280
+ });
214
281
  this.input.addEventListener("keydown", (e) => {
215
282
  if (e.key === "Enter") this.submitFromInput();
216
283
  });
217
284
  }
285
+ /** Open the file picker to add a document to the knowledge base (server enforces admin-only). */
286
+ promptUpload() {
287
+ this.open();
288
+ this.fileInput?.click();
289
+ }
290
+ async uploadDocument(file) {
291
+ this.open();
292
+ this.addMessage(`Uploading \u201C${file.name}\u201D to the knowledge base\u2026`, "user");
293
+ this.setProgress("Extracting and indexing\u2026");
294
+ try {
295
+ const form = new FormData();
296
+ form.append("file", file, file.name);
297
+ form.append("tenantUniqueName", this.config.tenantUniqueName);
298
+ form.append("environment", this.config.environment);
299
+ const h = {};
300
+ const token = this.config.getAuthToken?.();
301
+ const email = typeof this.config.devUserEmail === "function" ? this.config.devUserEmail() : this.config.devUserEmail;
302
+ if (token) h["Authorization"] = `Bearer ${token}`;
303
+ else if (email) h["X-User-Email"] = email;
304
+ const res = await fetch(`${this.config.apiBaseUrl}/api/knowledge/upload`, { method: "POST", headers: h, body: form });
305
+ this.setProgress("");
306
+ if (res.status === 403) {
307
+ this.renderAnswer({ status: "Forbidden", primaryReason: "Only Spektra Admins can add to the knowledge base." });
308
+ return;
309
+ }
310
+ if (!res.ok) {
311
+ this.renderAnswer({ status: "RequestFailed", primaryReason: `Upload failed (${res.status}).` });
312
+ return;
313
+ }
314
+ const data = await res.json();
315
+ if (data?.status === "Indexed") {
316
+ this.renderAnswer({ status: "Answered", disabled: false, primaryReason: `Added \u201C${data.title}\u201D to the knowledge base (${data.chunks} chunk${data.chunks === 1 ? "" : "s"}). I can now use it to answer questions.` });
317
+ } else {
318
+ this.renderAnswer({ status: "RequestFailed", primaryReason: data?.reason || "Could not add the document." });
319
+ }
320
+ } catch {
321
+ this.setProgress("");
322
+ this.renderAnswer({ status: "RequestFailed", primaryReason: "Could not reach the copilot service." });
323
+ }
324
+ }
218
325
  submitFromInput() {
219
326
  const q = this.input?.value.trim();
220
327
  if (q) {
@@ -341,8 +448,115 @@ var CopilotWidget = class {
341
448
  el.innerHTML = `<div class="acw-reason">${escapeHtml(answer.primaryReason || "")}</div>` + (answer.verifiedRoleLabel ? `<div class="acw-role">Verified role: ${escapeHtml(answer.verifiedRoleLabel)}</div>` : "") + (sourceLabel ? `<div class="acw-role">Source: ${sourceLabel}</div>` : "") + (evidence ? `<div class="acw-evidence">Evidence: ${evidence}</div>` : "");
342
449
  }
343
450
  this.body.appendChild(el);
451
+ const role = (answer.verifiedRoleLabel || "").toLowerCase();
452
+ const eligibleRole = role === "requestor" || role === "approver";
453
+ const looksBlocked = /lock(ed)?|can only be (changed|edited)|not editable|isn'?t permitted|not permitted|can'?t (change|edit|update)|cannot (change|edit|update)|read-only|don'?t have access|not allowed/i.test(answer.primaryReason || "");
454
+ const offerTicket = answer.supportTicketEligible === true || (answer.disabled === true || looksBlocked) && eligibleRole;
455
+ if (!isFailure && offerTicket) {
456
+ const btn = document.createElement("button");
457
+ btn.type = "button";
458
+ btn.className = "acw-chip";
459
+ btn.textContent = "Raise a support ticket";
460
+ btn.addEventListener("click", () => this.openTicketDraft(answer));
461
+ el.appendChild(btn);
462
+ }
344
463
  this.body.scrollTop = this.body.scrollHeight;
345
464
  }
465
+ currentRequestId() {
466
+ const ctx = this.currentContext();
467
+ const explicit = ctx.page?.requestId || ctx.page?.RequestId;
468
+ if (explicit) return String(explicit);
469
+ const route = ctx.page?.route || (typeof window !== "undefined" ? window.location.pathname : "");
470
+ const ms = /MS[A-Z0-9]{6,}/i.exec(route || "");
471
+ if (ms) return ms[0];
472
+ const guid = /[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}/.exec(route || "");
473
+ return guid ? guid[0] : void 0;
474
+ }
475
+ /** Build a pre-filled, editable ticket draft and let the user review + Send (they trigger the API call). */
476
+ openTicketDraft(answer) {
477
+ const fromAnswer = /MS[A-Z0-9]{6,}/i.exec(answer.primaryReason || "");
478
+ const requestId = fromAnswer ? fromAnswer[0] : this.currentRequestId();
479
+ const role = answer.verifiedRoleLabel || "";
480
+ const reason = answer.primaryReason || "I am blocked from completing an action.";
481
+ const subject = requestId ? `[Cloud Events] Help needed on request ${requestId}` : "[Cloud Events] Help needed with an event request";
482
+ const description = `Raised from the Application Copilot.
483
+
484
+ ` + (role ? `Role: ${role}
485
+ ` : "") + `Request Id: ${requestId || "(not on a specific request)"}
486
+ ` + (this.lastQuestion ? `Question: ${this.lastQuestion}
487
+ ` : "") + `
488
+ What the copilot reported:
489
+ ${reason}`;
490
+ const card = document.createElement("div");
491
+ card.className = "acw-ticket";
492
+ card.innerHTML = `<div class="acw-ticket-title">Raise a support ticket</div><div class="acw-ticket-sub">Review the details below and click Send. It goes to the support team as a Zoho Desk ticket.</div><label class="acw-ticket-label">Subject</label><input class="acw-ticket-subject acw-ticket-input" type="text" /><label class="acw-ticket-label">Description</label><textarea class="acw-ticket-desc" rows="8"></textarea><div class="acw-ticket-actions"><button class="acw-ticket-send acw-btn-primary" type="button">Send ticket</button><button class="acw-ticket-cancel acw-btn-ghost" type="button">Cancel</button></div>`;
493
+ card.querySelector(".acw-ticket-subject").value = subject;
494
+ card.querySelector(".acw-ticket-desc").value = description;
495
+ card.querySelector(".acw-ticket-cancel").addEventListener("click", () => card.remove());
496
+ card.querySelector(".acw-ticket-send").addEventListener("click", () => {
497
+ const subj = card.querySelector(".acw-ticket-subject").value.trim();
498
+ const desc = card.querySelector(".acw-ticket-desc").value.trim();
499
+ card.remove();
500
+ void this.submitTicket(subj, desc, requestId);
501
+ });
502
+ this.body.appendChild(card);
503
+ this.body.scrollTop = this.body.scrollHeight;
504
+ }
505
+ async submitTicket(subject, description, requestId) {
506
+ this.setProgress("Sending your support ticket\u2026");
507
+ try {
508
+ const sessionId = await this.ensureSession();
509
+ const res = await fetch(`${this.config.apiBaseUrl}/api/sessions/${sessionId}/support-ticket`, {
510
+ method: "POST",
511
+ headers: this.headers(),
512
+ body: JSON.stringify({
513
+ tenantUniqueName: this.config.tenantUniqueName,
514
+ environment: this.config.environment,
515
+ requestId,
516
+ subject,
517
+ description
518
+ })
519
+ });
520
+ this.setProgress("");
521
+ if (res.status === 403) {
522
+ this.renderAnswer({ status: "Forbidden", primaryReason: "Support tickets are available for Requestor/Approver roles." });
523
+ return;
524
+ }
525
+ if (!res.ok) {
526
+ this.renderAnswer({ status: "RequestFailed", primaryReason: `Could not send the ticket (${res.status}).` });
527
+ return;
528
+ }
529
+ const data = await res.json();
530
+ if (data?.status === "Created") {
531
+ const num = data.ticketNumber ? `#${data.ticketNumber}` : "";
532
+ const el = document.createElement("div");
533
+ el.className = "acw-answer acw-okstate";
534
+ el.innerHTML = `<div class="acw-reason">Support ticket ${escapeHtml(num)} has been created. The support team will follow up.</div>`;
535
+ if (data.webUrl || data.id || this.config.onOpenTicket) {
536
+ const open = document.createElement("button");
537
+ open.type = "button";
538
+ open.className = "acw-link-btn";
539
+ open.textContent = "Open the ticket";
540
+ open.addEventListener("click", () => {
541
+ const ticket = { id: data.id, number: data.ticketNumber, webUrl: data.webUrl };
542
+ if (this.config.onOpenTicket) {
543
+ this.config.onOpenTicket(ticket);
544
+ } else if (data.webUrl) {
545
+ window.open(data.webUrl, "_blank", "noopener");
546
+ }
547
+ });
548
+ el.appendChild(open);
549
+ }
550
+ this.body.appendChild(el);
551
+ this.body.scrollTop = this.body.scrollHeight;
552
+ } else {
553
+ this.renderAnswer({ status: "RequestFailed", primaryReason: data?.reason || "Could not create the ticket." });
554
+ }
555
+ } catch {
556
+ this.setProgress("");
557
+ this.renderAnswer({ status: "RequestFailed", primaryReason: "Could not reach the copilot service." });
558
+ }
559
+ }
346
560
  // ---- networking ----
347
561
  headers() {
348
562
  const h = { "Content-Type": "application/json" };
@@ -372,6 +586,7 @@ var CopilotWidget = class {
372
586
  }
373
587
  async runAsk(question) {
374
588
  this.open();
589
+ this.lastQuestion = question;
375
590
  this.addMessage(question, "user");
376
591
  this.setProgress("Reading your question\u2026");
377
592
  try {
@@ -389,7 +604,8 @@ var CopilotWidget = class {
389
604
  this.renderAnswer(await res.json());
390
605
  } catch (err) {
391
606
  this.setProgress("");
392
- this.renderAnswer({ status: "RequestFailed", primaryReason: "Could not reach the copilot service." });
607
+ const msg = err?.message ? String(err.message) : "Could not reach the copilot service.";
608
+ this.renderAnswer({ status: "RequestFailed", primaryReason: msg });
393
609
  }
394
610
  }
395
611
  async connectSignalR() {
@@ -415,6 +631,10 @@ var ApplicationCopilotWidget = {
415
631
  init: (config) => instance.init(config),
416
632
  open: () => instance.open(),
417
633
  close: () => instance.close(),
634
+ show: () => instance.show(),
635
+ hide: () => instance.hide(),
636
+ notify: (text, suggestion) => instance.notify(text, suggestion),
637
+ promptUpload: () => instance.promptUpload(),
418
638
  ask: (question) => instance.ask(question),
419
639
  setContext: (context) => instance.setContext(context),
420
640
  captureError: (errorContext) => instance.captureError(errorContext)
package/dist/index.d.cts CHANGED
@@ -46,6 +46,12 @@ interface WidgetConfig {
46
46
  buttonText?: string;
47
47
  /** Host navigation hook (e.g. Angular router). If omitted, the widget falls back to window.location. */
48
48
  onNavigate?: (path: string) => void;
49
+ /** Host hook to open a created support ticket (e.g. the embedded Zoho ASAP widget). If omitted, the widget opens webUrl in a new tab. */
50
+ onOpenTicket?: (ticket: {
51
+ id?: string;
52
+ number?: string;
53
+ webUrl?: string;
54
+ }) => void;
49
55
  }
50
56
  interface CopilotAnswer {
51
57
  status: string;
@@ -70,6 +76,10 @@ declare const ApplicationCopilotWidget: {
70
76
  init: (config: WidgetConfig) => void;
71
77
  open: () => void;
72
78
  close: () => void;
79
+ show: () => void;
80
+ hide: () => void;
81
+ notify: (text: string, suggestion?: string) => void;
82
+ promptUpload: () => void;
73
83
  ask: (question: string) => void;
74
84
  setContext: (context: ApplicationContext) => void;
75
85
  captureError: (errorContext: ApplicationContext["recentError"]) => void;
package/dist/index.d.ts CHANGED
@@ -46,6 +46,12 @@ interface WidgetConfig {
46
46
  buttonText?: string;
47
47
  /** Host navigation hook (e.g. Angular router). If omitted, the widget falls back to window.location. */
48
48
  onNavigate?: (path: string) => void;
49
+ /** Host hook to open a created support ticket (e.g. the embedded Zoho ASAP widget). If omitted, the widget opens webUrl in a new tab. */
50
+ onOpenTicket?: (ticket: {
51
+ id?: string;
52
+ number?: string;
53
+ webUrl?: string;
54
+ }) => void;
49
55
  }
50
56
  interface CopilotAnswer {
51
57
  status: string;
@@ -70,6 +76,10 @@ declare const ApplicationCopilotWidget: {
70
76
  init: (config: WidgetConfig) => void;
71
77
  open: () => void;
72
78
  close: () => void;
79
+ show: () => void;
80
+ hide: () => void;
81
+ notify: (text: string, suggestion?: string) => void;
82
+ promptUpload: () => void;
73
83
  ask: (question: string) => void;
74
84
  setContext: (context: ApplicationContext) => void;
75
85
  captureError: (errorContext: ApplicationContext["recentError"]) => void;
package/dist/index.js CHANGED
@@ -40,7 +40,7 @@ var widgetStyles = `
40
40
  /* Panel */
41
41
  .acw-panel {
42
42
  position: fixed; right: 24px; bottom: 96px; z-index: 2147483000;
43
- width: 410px; max-width: calc(100vw - 48px); height: 580px; max-height: calc(100vh - 130px);
43
+ width: 480px; max-width: calc(100vw - 48px); height: 660px; max-height: calc(100vh - 120px);
44
44
  display: none; flex-direction: column;
45
45
  background: var(--acw-bg); color: var(--acw-fg);
46
46
  border: 1px solid var(--acw-border); border-radius: 16px; overflow: hidden;
@@ -98,6 +98,26 @@ var widgetStyles = `
98
98
  .acw-input:focus { outline: 2px solid var(--acw-accent); outline-offset: 0; }
99
99
  .acw-send { border: none; border-radius: 10px; padding: 0 16px; background: var(--acw-accent); color: #fff; cursor: pointer; font-size: 14px; font-family: inherit; }
100
100
  .acw-send:hover { background: var(--acw-accent-dark); }
101
+
102
+ /* Support-ticket draft card */
103
+ .acw-ticket { background: var(--acw-bg); border: 1px solid var(--acw-border); border-radius: 12px; padding: 14px; margin: 12px 0; border-left: 4px solid var(--acw-accent); }
104
+ .acw-ticket-title { font-size: 15px; font-weight: 600; color: var(--acw-fg); margin: 0 0 4px; }
105
+ .acw-ticket-sub { font-size: 12px; color: var(--acw-muted); margin: 0 0 10px; }
106
+ .acw-ticket-label { display: block; font-size: 12px; font-weight: 600; color: var(--acw-muted); margin: 12px 0 5px; }
107
+ .acw-ticket-input, .acw-ticket-desc {
108
+ width: 100%; box-sizing: border-box; font-family: inherit; font-size: 13px;
109
+ padding: 10px 12px; border: 1px solid var(--acw-border); border-radius: 10px;
110
+ color: var(--acw-fg); background: var(--acw-bg);
111
+ }
112
+ .acw-ticket-desc { resize: vertical; min-height: 170px; line-height: 1.5; }
113
+ .acw-ticket-input:focus, .acw-ticket-desc:focus { outline: 2px solid var(--acw-accent); outline-offset: 0; }
114
+ .acw-ticket-actions { display: flex; gap: 10px; margin-top: 14px; }
115
+ .acw-btn-primary { border: none; border-radius: 10px; padding: 10px 20px; cursor: pointer; background: var(--acw-accent); color: #fff; font-size: 14px; font-weight: 600; font-family: inherit; }
116
+ .acw-btn-primary:hover { background: var(--acw-accent-dark); }
117
+ .acw-btn-ghost { border: 1px solid var(--acw-border); border-radius: 10px; padding: 10px 16px; cursor: pointer; background: var(--acw-bg); color: var(--acw-fg); font-size: 14px; font-family: inherit; }
118
+ .acw-btn-ghost:hover { background: #f0eefe; }
119
+ .acw-link-btn { margin-top: 8px; border: none; background: transparent; color: var(--acw-accent); font-size: 12px; font-family: inherit; cursor: pointer; padding: 0; text-decoration: underline; }
120
+ .acw-link-btn:hover { color: var(--acw-accent-dark); }
101
121
  `;
102
122
 
103
123
  // src/widget.ts
@@ -109,10 +129,15 @@ var PROGRESS_LABELS = {
109
129
  };
110
130
  var CopilotWidget = class {
111
131
  constructor() {
132
+ this.lastQuestion = "";
112
133
  this.mode = "explain";
113
134
  }
114
135
  init(config) {
115
136
  this.config = config;
137
+ try {
138
+ console.log("[ApplicationCopilot] widget loaded \u2014 support-ticket build v2");
139
+ } catch {
140
+ }
116
141
  this.mount();
117
142
  this.connectSignalR().catch(() => {
118
143
  });
@@ -124,6 +149,17 @@ var CopilotWidget = class {
124
149
  close() {
125
150
  this.panel?.classList.remove("acw-open");
126
151
  }
152
+ /** Show the launcher (widget host). No-op before init(). */
153
+ show() {
154
+ if (this.host) this.host.style.display = "";
155
+ }
156
+ /** Hide the launcher and close the panel. Used to gate the widget by route. */
157
+ hide() {
158
+ if (this.host) {
159
+ this.host.style.display = "none";
160
+ }
161
+ this.close();
162
+ }
127
163
  setContext(context) {
128
164
  this.contextOverride = context;
129
165
  }
@@ -131,6 +167,25 @@ var CopilotWidget = class {
131
167
  this.recentError = errorContext;
132
168
  this.open();
133
169
  }
170
+ /** Proactive nudge: show + open the panel with a message and an optional one-click suggested question. */
171
+ notify(text, suggestion) {
172
+ this.show();
173
+ this.open();
174
+ if (!this.body) return;
175
+ const el = document.createElement("div");
176
+ el.className = "acw-msg acw-assistant acw-nudge";
177
+ el.innerHTML = `<div class="acw-reason">${escapeHtml(text)}</div>`;
178
+ if (suggestion) {
179
+ const btn = document.createElement("button");
180
+ btn.type = "button";
181
+ btn.className = "acw-chip";
182
+ btn.textContent = suggestion;
183
+ btn.addEventListener("click", () => this.ask(suggestion));
184
+ el.appendChild(btn);
185
+ }
186
+ this.body.appendChild(el);
187
+ this.body.scrollTop = this.body.scrollHeight;
188
+ }
134
189
  ask(question) {
135
190
  if (this.mode === "intake") {
136
191
  void this.runIntakeAsk(question);
@@ -157,27 +212,79 @@ var CopilotWidget = class {
157
212
  wrap.querySelector(".acw-launch").addEventListener("click", () => this.panel?.classList.contains("acw-open") ? this.close() : this.open());
158
213
  const panel = document.createElement("div");
159
214
  panel.className = "acw-panel";
215
+ const uploadIcon = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>';
160
216
  panel.innerHTML = `
161
217
  <div class="acw-header">
162
218
  ${sparkle}
163
219
  <span class="acw-title">Application Copilot</span>
220
+ <button class="acw-kb" type="button" aria-label="Add a document to the knowledge base" title="Add to knowledge base (admins)" style="background:none;border:none;color:inherit;cursor:pointer;padding:2px 6px;display:inline-flex;align-items:center;">${uploadIcon}</button>
164
221
  <button class="acw-close" type="button" aria-label="Close">&times;</button>
165
222
  </div>
166
223
  <div class="acw-body"></div>
167
224
  <div class="acw-footer">
168
225
  <input class="acw-input" type="text" placeholder="Ask a question\u2026" />
169
226
  <button class="acw-send" type="button">Send</button>
170
- </div>`;
227
+ </div>
228
+ <input class="acw-file" type="file" accept=".pdf,.docx,.xlsx,.xlsm,.txt,.md,.markdown,.csv" style="display:none" />`;
171
229
  this.root.appendChild(panel);
172
230
  this.panel = panel;
173
231
  this.body = panel.querySelector(".acw-body");
174
232
  this.input = panel.querySelector(".acw-input");
233
+ this.fileInput = panel.querySelector(".acw-file");
175
234
  panel.querySelector(".acw-close").addEventListener("click", () => this.close());
176
235
  panel.querySelector(".acw-send").addEventListener("click", () => this.submitFromInput());
236
+ panel.querySelector(".acw-kb").addEventListener("click", () => this.promptUpload());
237
+ this.fileInput.addEventListener("change", () => {
238
+ const f = this.fileInput?.files?.[0];
239
+ if (f) {
240
+ void this.uploadDocument(f);
241
+ if (this.fileInput) this.fileInput.value = "";
242
+ }
243
+ });
177
244
  this.input.addEventListener("keydown", (e) => {
178
245
  if (e.key === "Enter") this.submitFromInput();
179
246
  });
180
247
  }
248
+ /** Open the file picker to add a document to the knowledge base (server enforces admin-only). */
249
+ promptUpload() {
250
+ this.open();
251
+ this.fileInput?.click();
252
+ }
253
+ async uploadDocument(file) {
254
+ this.open();
255
+ this.addMessage(`Uploading \u201C${file.name}\u201D to the knowledge base\u2026`, "user");
256
+ this.setProgress("Extracting and indexing\u2026");
257
+ try {
258
+ const form = new FormData();
259
+ form.append("file", file, file.name);
260
+ form.append("tenantUniqueName", this.config.tenantUniqueName);
261
+ form.append("environment", this.config.environment);
262
+ const h = {};
263
+ const token = this.config.getAuthToken?.();
264
+ const email = typeof this.config.devUserEmail === "function" ? this.config.devUserEmail() : this.config.devUserEmail;
265
+ if (token) h["Authorization"] = `Bearer ${token}`;
266
+ else if (email) h["X-User-Email"] = email;
267
+ const res = await fetch(`${this.config.apiBaseUrl}/api/knowledge/upload`, { method: "POST", headers: h, body: form });
268
+ this.setProgress("");
269
+ if (res.status === 403) {
270
+ this.renderAnswer({ status: "Forbidden", primaryReason: "Only Spektra Admins can add to the knowledge base." });
271
+ return;
272
+ }
273
+ if (!res.ok) {
274
+ this.renderAnswer({ status: "RequestFailed", primaryReason: `Upload failed (${res.status}).` });
275
+ return;
276
+ }
277
+ const data = await res.json();
278
+ if (data?.status === "Indexed") {
279
+ this.renderAnswer({ status: "Answered", disabled: false, primaryReason: `Added \u201C${data.title}\u201D to the knowledge base (${data.chunks} chunk${data.chunks === 1 ? "" : "s"}). I can now use it to answer questions.` });
280
+ } else {
281
+ this.renderAnswer({ status: "RequestFailed", primaryReason: data?.reason || "Could not add the document." });
282
+ }
283
+ } catch {
284
+ this.setProgress("");
285
+ this.renderAnswer({ status: "RequestFailed", primaryReason: "Could not reach the copilot service." });
286
+ }
287
+ }
181
288
  submitFromInput() {
182
289
  const q = this.input?.value.trim();
183
290
  if (q) {
@@ -304,8 +411,115 @@ var CopilotWidget = class {
304
411
  el.innerHTML = `<div class="acw-reason">${escapeHtml(answer.primaryReason || "")}</div>` + (answer.verifiedRoleLabel ? `<div class="acw-role">Verified role: ${escapeHtml(answer.verifiedRoleLabel)}</div>` : "") + (sourceLabel ? `<div class="acw-role">Source: ${sourceLabel}</div>` : "") + (evidence ? `<div class="acw-evidence">Evidence: ${evidence}</div>` : "");
305
412
  }
306
413
  this.body.appendChild(el);
414
+ const role = (answer.verifiedRoleLabel || "").toLowerCase();
415
+ const eligibleRole = role === "requestor" || role === "approver";
416
+ const looksBlocked = /lock(ed)?|can only be (changed|edited)|not editable|isn'?t permitted|not permitted|can'?t (change|edit|update)|cannot (change|edit|update)|read-only|don'?t have access|not allowed/i.test(answer.primaryReason || "");
417
+ const offerTicket = answer.supportTicketEligible === true || (answer.disabled === true || looksBlocked) && eligibleRole;
418
+ if (!isFailure && offerTicket) {
419
+ const btn = document.createElement("button");
420
+ btn.type = "button";
421
+ btn.className = "acw-chip";
422
+ btn.textContent = "Raise a support ticket";
423
+ btn.addEventListener("click", () => this.openTicketDraft(answer));
424
+ el.appendChild(btn);
425
+ }
307
426
  this.body.scrollTop = this.body.scrollHeight;
308
427
  }
428
+ currentRequestId() {
429
+ const ctx = this.currentContext();
430
+ const explicit = ctx.page?.requestId || ctx.page?.RequestId;
431
+ if (explicit) return String(explicit);
432
+ const route = ctx.page?.route || (typeof window !== "undefined" ? window.location.pathname : "");
433
+ const ms = /MS[A-Z0-9]{6,}/i.exec(route || "");
434
+ if (ms) return ms[0];
435
+ const guid = /[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}/.exec(route || "");
436
+ return guid ? guid[0] : void 0;
437
+ }
438
+ /** Build a pre-filled, editable ticket draft and let the user review + Send (they trigger the API call). */
439
+ openTicketDraft(answer) {
440
+ const fromAnswer = /MS[A-Z0-9]{6,}/i.exec(answer.primaryReason || "");
441
+ const requestId = fromAnswer ? fromAnswer[0] : this.currentRequestId();
442
+ const role = answer.verifiedRoleLabel || "";
443
+ const reason = answer.primaryReason || "I am blocked from completing an action.";
444
+ const subject = requestId ? `[Cloud Events] Help needed on request ${requestId}` : "[Cloud Events] Help needed with an event request";
445
+ const description = `Raised from the Application Copilot.
446
+
447
+ ` + (role ? `Role: ${role}
448
+ ` : "") + `Request Id: ${requestId || "(not on a specific request)"}
449
+ ` + (this.lastQuestion ? `Question: ${this.lastQuestion}
450
+ ` : "") + `
451
+ What the copilot reported:
452
+ ${reason}`;
453
+ const card = document.createElement("div");
454
+ card.className = "acw-ticket";
455
+ card.innerHTML = `<div class="acw-ticket-title">Raise a support ticket</div><div class="acw-ticket-sub">Review the details below and click Send. It goes to the support team as a Zoho Desk ticket.</div><label class="acw-ticket-label">Subject</label><input class="acw-ticket-subject acw-ticket-input" type="text" /><label class="acw-ticket-label">Description</label><textarea class="acw-ticket-desc" rows="8"></textarea><div class="acw-ticket-actions"><button class="acw-ticket-send acw-btn-primary" type="button">Send ticket</button><button class="acw-ticket-cancel acw-btn-ghost" type="button">Cancel</button></div>`;
456
+ card.querySelector(".acw-ticket-subject").value = subject;
457
+ card.querySelector(".acw-ticket-desc").value = description;
458
+ card.querySelector(".acw-ticket-cancel").addEventListener("click", () => card.remove());
459
+ card.querySelector(".acw-ticket-send").addEventListener("click", () => {
460
+ const subj = card.querySelector(".acw-ticket-subject").value.trim();
461
+ const desc = card.querySelector(".acw-ticket-desc").value.trim();
462
+ card.remove();
463
+ void this.submitTicket(subj, desc, requestId);
464
+ });
465
+ this.body.appendChild(card);
466
+ this.body.scrollTop = this.body.scrollHeight;
467
+ }
468
+ async submitTicket(subject, description, requestId) {
469
+ this.setProgress("Sending your support ticket\u2026");
470
+ try {
471
+ const sessionId = await this.ensureSession();
472
+ const res = await fetch(`${this.config.apiBaseUrl}/api/sessions/${sessionId}/support-ticket`, {
473
+ method: "POST",
474
+ headers: this.headers(),
475
+ body: JSON.stringify({
476
+ tenantUniqueName: this.config.tenantUniqueName,
477
+ environment: this.config.environment,
478
+ requestId,
479
+ subject,
480
+ description
481
+ })
482
+ });
483
+ this.setProgress("");
484
+ if (res.status === 403) {
485
+ this.renderAnswer({ status: "Forbidden", primaryReason: "Support tickets are available for Requestor/Approver roles." });
486
+ return;
487
+ }
488
+ if (!res.ok) {
489
+ this.renderAnswer({ status: "RequestFailed", primaryReason: `Could not send the ticket (${res.status}).` });
490
+ return;
491
+ }
492
+ const data = await res.json();
493
+ if (data?.status === "Created") {
494
+ const num = data.ticketNumber ? `#${data.ticketNumber}` : "";
495
+ const el = document.createElement("div");
496
+ el.className = "acw-answer acw-okstate";
497
+ el.innerHTML = `<div class="acw-reason">Support ticket ${escapeHtml(num)} has been created. The support team will follow up.</div>`;
498
+ if (data.webUrl || data.id || this.config.onOpenTicket) {
499
+ const open = document.createElement("button");
500
+ open.type = "button";
501
+ open.className = "acw-link-btn";
502
+ open.textContent = "Open the ticket";
503
+ open.addEventListener("click", () => {
504
+ const ticket = { id: data.id, number: data.ticketNumber, webUrl: data.webUrl };
505
+ if (this.config.onOpenTicket) {
506
+ this.config.onOpenTicket(ticket);
507
+ } else if (data.webUrl) {
508
+ window.open(data.webUrl, "_blank", "noopener");
509
+ }
510
+ });
511
+ el.appendChild(open);
512
+ }
513
+ this.body.appendChild(el);
514
+ this.body.scrollTop = this.body.scrollHeight;
515
+ } else {
516
+ this.renderAnswer({ status: "RequestFailed", primaryReason: data?.reason || "Could not create the ticket." });
517
+ }
518
+ } catch {
519
+ this.setProgress("");
520
+ this.renderAnswer({ status: "RequestFailed", primaryReason: "Could not reach the copilot service." });
521
+ }
522
+ }
309
523
  // ---- networking ----
310
524
  headers() {
311
525
  const h = { "Content-Type": "application/json" };
@@ -335,6 +549,7 @@ var CopilotWidget = class {
335
549
  }
336
550
  async runAsk(question) {
337
551
  this.open();
552
+ this.lastQuestion = question;
338
553
  this.addMessage(question, "user");
339
554
  this.setProgress("Reading your question\u2026");
340
555
  try {
@@ -352,7 +567,8 @@ var CopilotWidget = class {
352
567
  this.renderAnswer(await res.json());
353
568
  } catch (err) {
354
569
  this.setProgress("");
355
- this.renderAnswer({ status: "RequestFailed", primaryReason: "Could not reach the copilot service." });
570
+ const msg = err?.message ? String(err.message) : "Could not reach the copilot service.";
571
+ this.renderAnswer({ status: "RequestFailed", primaryReason: msg });
356
572
  }
357
573
  }
358
574
  async connectSignalR() {
@@ -378,6 +594,10 @@ var ApplicationCopilotWidget = {
378
594
  init: (config) => instance.init(config),
379
595
  open: () => instance.open(),
380
596
  close: () => instance.close(),
597
+ show: () => instance.show(),
598
+ hide: () => instance.hide(),
599
+ notify: (text, suggestion) => instance.notify(text, suggestion),
600
+ promptUpload: () => instance.promptUpload(),
381
601
  ask: (question) => instance.ask(question),
382
602
  setContext: (context) => instance.setContext(context),
383
603
  captureError: (errorContext) => instance.captureError(errorContext)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spektra-cloudevents/application-copilot-widget",
3
- "version": "1.0.0",
3
+ "version": "1.2.0",
4
4
  "description": "Spektra Application Copilot widget — framework-agnostic and CSS-library agnostic via Shadow DOM isolation.",
5
5
  "type": "module",
6
6
  "publishConfig": {