nowaikit-utils 1.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.
@@ -0,0 +1,1198 @@
1
+ /**
2
+ * NowAIKit Utils — AI Sidebar
3
+ * BYOK AI assistant for ServiceNow. Context-aware chat with quick actions.
4
+ */
5
+
6
+ // ─── Shared Helpers (from content.js via window globals) ─────────
7
+ function _showToast(msg, type) {
8
+ if (typeof window.showToast === 'function') window.showToast(msg, type);
9
+ }
10
+ function _escapeHtml(str) {
11
+ if (typeof window.escapeHtml === 'function') return window.escapeHtml(str);
12
+ var div = document.createElement('div');
13
+ div.textContent = str;
14
+ return div.innerHTML;
15
+ }
16
+
17
+ // ─── API Key Encryption ─────────────────────────────────────────
18
+ // Encrypts API keys before storing in chrome.storage.local using AES-GCM.
19
+ // Key is derived from the extension ID via PBKDF2 — prevents casual snooping
20
+ // of stored credentials while remaining transparent to the user.
21
+
22
+ async function _deriveEncryptionKey() {
23
+ var keyMaterial = await crypto.subtle.importKey(
24
+ 'raw',
25
+ new TextEncoder().encode(chrome.runtime.id || 'nowaikit-utils'),
26
+ 'PBKDF2',
27
+ false,
28
+ ['deriveKey']
29
+ );
30
+ return crypto.subtle.deriveKey(
31
+ { name: 'PBKDF2', salt: new TextEncoder().encode('nowaikit-utils-v1'), iterations: 100000, hash: 'SHA-256' },
32
+ keyMaterial,
33
+ { name: 'AES-GCM', length: 256 },
34
+ false,
35
+ ['encrypt', 'decrypt']
36
+ );
37
+ }
38
+
39
+ async function encryptApiKey(plaintext) {
40
+ if (!plaintext) return '';
41
+ try {
42
+ var key = await _deriveEncryptionKey();
43
+ var iv = crypto.getRandomValues(new Uint8Array(12));
44
+ var encrypted = await crypto.subtle.encrypt(
45
+ { name: 'AES-GCM', iv: iv },
46
+ key,
47
+ new TextEncoder().encode(plaintext)
48
+ );
49
+ var combined = new Uint8Array(iv.length + encrypted.byteLength);
50
+ combined.set(iv);
51
+ combined.set(new Uint8Array(encrypted), iv.length);
52
+ return 'enc:' + btoa(String.fromCharCode.apply(null, combined));
53
+ } catch (e) {
54
+ // Fallback: return plaintext if crypto unavailable
55
+ return plaintext;
56
+ }
57
+ }
58
+
59
+ async function decryptApiKey(stored) {
60
+ if (!stored) return '';
61
+ // Not encrypted (legacy or fallback)
62
+ if (!stored.startsWith('enc:')) return stored;
63
+ try {
64
+ var key = await _deriveEncryptionKey();
65
+ var combined = Uint8Array.from(atob(stored.slice(4)), function(c) { return c.charCodeAt(0); });
66
+ var iv = combined.slice(0, 12);
67
+ var ciphertext = combined.slice(12);
68
+ var decrypted = await crypto.subtle.decrypt(
69
+ { name: 'AES-GCM', iv: iv },
70
+ key,
71
+ ciphertext
72
+ );
73
+ return new TextDecoder().decode(decrypted);
74
+ } catch (e) {
75
+ // Decryption failed — corrupted or wrong extension context
76
+ return '';
77
+ }
78
+ }
79
+
80
+ // ─── State ──────────────────────────────────────────────────────
81
+ let aiSidebarOpen = false;
82
+ let aiSidebarEl = null;
83
+ let aiConversation = []; // Array of { role: 'user'|'assistant', content: string }
84
+ let aiPort = null; // chrome.runtime port for streaming
85
+ let aiStreaming = false;
86
+
87
+ // ─── System Prompt ──────────────────────────────────────────────
88
+ const SN_SYSTEM_PROMPT = `You are NowAIKit Utils — ServiceNow AI Assistant, an expert ServiceNow platform assistant embedded directly in the user's browser.
89
+
90
+ You have deep expertise in:
91
+ - GlideRecord, GlideAjax, GlideSystem, GlideAggregate, GlideDateTime, GlideQuery APIs
92
+ - Business Rules (before, after, async), Client Scripts (onChange, onLoad, onSubmit, onCellEdit)
93
+ - Script Includes, UI Policies, UI Actions, UI Pages, Processors
94
+ - ACLs, roles, security best practices, and scoped application development
95
+ - Flow Designer, IntegrationHub, Scripted REST APIs
96
+ - Service Portal (sp_widget), Now Experience (UIB), Angular providers
97
+ - CMDB, ITSM, ITOM, CSM, HR, SecOps modules
98
+ - Performance optimization (query efficiency, indexing, caching)
99
+ - Upgrade best practices, technical debt identification
100
+
101
+ Context:
102
+ - You are running inside the user's ServiceNow browser session
103
+ - You can see the current page context (table, record, fields)
104
+ - When the user asks about "this record" or "this table", refer to the context provided
105
+ - Always provide code examples following ServiceNow best practices
106
+ - Use getValue/setValue in scoped apps, prefer GlideQuery where applicable
107
+ - Include null checks, setLimit, and proper error handling in generated code`;
108
+
109
+ // ─── Context Extraction ─────────────────────────────────────────
110
+
111
+ function isBridgeConnected() {
112
+ return typeof getBridgeStatus === 'function' && getBridgeStatus().connected;
113
+ }
114
+
115
+ function getBridgeInfo() {
116
+ if (!isBridgeConnected()) return null;
117
+ return getBridgeStatus();
118
+ }
119
+
120
+ async function gatherPageContext() {
121
+ // Read shared state from content.js (exposed on window.nowaikitState)
122
+ var _ns = window.nowaikitState || {};
123
+
124
+ // Build a context object from the current ServiceNow page
125
+ const ctx = {
126
+ table: _ns.currentTable || '',
127
+ sysId: _ns.currentSysId || '',
128
+ instance: _ns.instanceUrl ? new URL(_ns.instanceUrl).hostname.split('.')[0] : '',
129
+ isForm: _ns.isForm || false,
130
+ isList: _ns.isList || false,
131
+ url: window.location.href,
132
+ fields: {},
133
+ scripts: [],
134
+ schemaInfo: null,
135
+ fullRecord: null,
136
+ };
137
+
138
+ // Gather form field values if on a form
139
+ if (ctx.isForm && typeof g_form !== 'undefined') {
140
+ try {
141
+ // Get all field names from the form
142
+ const formFields = document.querySelectorAll('input[id^="sys_display."], input[name]:not([type="hidden"]), select[name], textarea[name]');
143
+ formFields.forEach(function(el) {
144
+ const name = el.name || el.id.replace('sys_display.', '');
145
+ if (name && !name.startsWith('ni.') && !name.startsWith('sys_display.')) {
146
+ ctx.fields[name] = (el.value || '').substring(0, 500);
147
+ }
148
+ });
149
+ } catch (e) {
150
+ // Silently fail — form may not be fully loaded
151
+ }
152
+ }
153
+
154
+ // Gather script content from any open script editors
155
+ try {
156
+ // CodeMirror instances
157
+ document.querySelectorAll('.CodeMirror').forEach(function(cm) {
158
+ if (cm.CodeMirror) {
159
+ const val = cm.CodeMirror.getValue();
160
+ if (val && val.trim().length > 0) {
161
+ ctx.scripts.push(val.substring(0, 5000));
162
+ }
163
+ }
164
+ });
165
+ // Textarea script fields
166
+ document.querySelectorAll('textarea[id*="script"], textarea[name="script"], textarea[name="condition"]').forEach(function(ta) {
167
+ if (ta.value && ta.value.trim().length > 0 && ctx.scripts.indexOf(ta.value.substring(0, 5000)) === -1) {
168
+ ctx.scripts.push(ta.value.substring(0, 5000));
169
+ }
170
+ });
171
+ } catch (e) {
172
+ // Silently fail
173
+ }
174
+
175
+ // Enrich context with bridge data when available
176
+ if (isBridgeConnected()) {
177
+ // Fetch table schema if we have a table name
178
+ if (ctx.table && typeof bridgeGetTableSchema === 'function') {
179
+ try {
180
+ ctx.schemaInfo = await bridgeGetTableSchema(ctx.table);
181
+ } catch (e) {
182
+ // Bridge call failed — proceed with DOM-only context
183
+ }
184
+ }
185
+
186
+ // Fetch full record data if we have a table and sys_id
187
+ if (ctx.table && ctx.sysId && typeof bridgeGetRecord === 'function') {
188
+ try {
189
+ ctx.fullRecord = await bridgeGetRecord(ctx.table, ctx.sysId);
190
+ } catch (e) {
191
+ // Bridge call failed — proceed with DOM-only context
192
+ }
193
+ }
194
+ }
195
+
196
+ return ctx;
197
+ }
198
+
199
+ function buildContextMessage(ctx) {
200
+ // Build a context string to inject into the conversation
201
+ var parts = [];
202
+ if (ctx.instance) parts.push('Instance: ' + ctx.instance);
203
+ if (ctx.table) parts.push('Table: ' + ctx.table);
204
+ if (ctx.sysId) parts.push('sys_id: ' + ctx.sysId);
205
+ if (ctx.isForm) parts.push('Page type: Form');
206
+ if (ctx.isList) parts.push('Page type: List');
207
+
208
+ // Add table schema info from bridge (enriched context)
209
+ if (ctx.schemaInfo) {
210
+ try {
211
+ var columns = ctx.schemaInfo.columns || ctx.schemaInfo.fields || [];
212
+ var colCount = columns.length;
213
+ var keyColumns = columns.slice(0, 25).map(function(col) {
214
+ var name = col.name || col.element || col.column_label || '';
215
+ var type = col.type || col.internal_type || '';
216
+ return name + (type ? ' (' + type + ')' : '');
217
+ });
218
+ parts.push('Table Schema: ' + ctx.table + ' has ' + colCount + ' columns including: ' + keyColumns.join(', '));
219
+ } catch (e) {
220
+ // Schema data in unexpected format — skip
221
+ }
222
+ }
223
+
224
+ // Add full record data from bridge (enriched context)
225
+ if (ctx.fullRecord) {
226
+ try {
227
+ var recordEntries = Object.entries(ctx.fullRecord);
228
+ if (recordEntries.length > 0) {
229
+ var recordLines = recordEntries
230
+ .filter(function(e) { return e[1] !== null && e[1] !== undefined && String(e[1]).length > 0; })
231
+ .slice(0, 40)
232
+ .map(function(e) { return ' ' + e[0] + ': ' + String(e[1]).substring(0, 500); });
233
+ if (recordLines.length > 0) {
234
+ parts.push('Full Record Data:\n' + recordLines.join('\n'));
235
+ }
236
+ }
237
+ } catch (e) {
238
+ // Record data in unexpected format — skip
239
+ }
240
+ }
241
+
242
+ // Add key field values (limit to avoid context bloat)
243
+ var fieldEntries = Object.entries(ctx.fields);
244
+ if (fieldEntries.length > 0) {
245
+ var fieldLines = fieldEntries
246
+ .filter(function(e) { return e[1] && e[1].length > 0; })
247
+ .slice(0, 20)
248
+ .map(function(e) { return ' ' + e[0] + ': ' + e[1]; });
249
+ if (fieldLines.length > 0) {
250
+ parts.push('Fields:\n' + fieldLines.join('\n'));
251
+ }
252
+ }
253
+
254
+ // Add script content
255
+ if (ctx.scripts.length > 0) {
256
+ ctx.scripts.forEach(function(script, i) {
257
+ parts.push('Script ' + (i + 1) + ':\n```javascript\n' + script + '\n```');
258
+ });
259
+ }
260
+
261
+ return parts.length > 0 ? '[Current Page Context]\n' + parts.join('\n') : '';
262
+ }
263
+
264
+ // ─── Quick Actions ──────────────────────────────────────────────
265
+
266
+ const QUICK_ACTIONS = [
267
+ { id: 'explain', icon: '\u{1F4A1}', label: 'Explain', prompt: 'Explain this script in detail. What does it do, what events trigger it, and are there any issues?' },
268
+ { id: 'summarize', icon: '\u{1F4CB}', label: 'Summarize', prompt: 'Summarize this record. What is it about, what is its current state, and what are the key details?' },
269
+ { id: 'review', icon: '\u{1F50D}', label: 'Review', prompt: 'Review this code for security issues, performance problems, and best practice violations. Provide specific fixes.' },
270
+ { id: 'generate', icon: '\u26A1', label: 'Generate', prompt: null }, // opens sub-menu or input
271
+ { id: 'fix', icon: '\u{1F527}', label: 'Fix Issues', prompt: 'Identify and fix all issues in this script. Return the corrected code with explanations of each change.' },
272
+ { id: 'optimize', icon: '\u{1F680}', label: 'Optimize', prompt: 'Optimize this script for performance. Identify inefficient patterns and provide an optimized version.' },
273
+ { id: 'query', icon: '\u{1F50D}', label: 'Query', prompt: 'Help me write a GlideRecord query for this table. Show me how to query {table} with common filters.' },
274
+ { id: 'instance', icon: '\u{1F4CA}', label: 'Health', prompt: 'Show me the current instance health metrics and any issues.' },
275
+ ];
276
+
277
+ // ─── Sidebar UI ─────────────────────────────────────────────────
278
+
279
+ var aiIsFloating = false;
280
+
281
+ function initAISidebar() {
282
+ // Create the sidebar element but don't show it yet
283
+ if (document.getElementById('nowaikit-ai-sidebar')) return;
284
+
285
+ aiSidebarEl = document.createElement('div');
286
+ aiSidebarEl.id = 'nowaikit-ai-sidebar';
287
+ aiSidebarEl.className = 'nowaikit-ai-sidebar';
288
+ aiSidebarEl.innerHTML = buildSidebarHTML();
289
+
290
+ // Add resize handle on left edge
291
+ var resizeHandle = document.createElement('div');
292
+ resizeHandle.className = 'nowaikit-ai-resize-handle';
293
+ aiSidebarEl.appendChild(resizeHandle);
294
+ initResizeHandle(resizeHandle);
295
+
296
+ document.body.appendChild(aiSidebarEl);
297
+
298
+ // Create the toggle button (floating)
299
+ var toggleBtn = document.createElement('button');
300
+ toggleBtn.id = 'nowaikit-ai-toggle';
301
+ toggleBtn.className = 'nowaikit-ai-toggle';
302
+ toggleBtn.innerHTML = '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>';
303
+ var _mod = (window.nowaikitState || {}).isMac ? 'Cmd' : 'Ctrl';
304
+ toggleBtn.title = 'NowAIKit Utils — AI Assistant (' + _mod + '+Shift+A)';
305
+ toggleBtn.addEventListener('click', toggleAISidebar);
306
+ document.body.appendChild(toggleBtn);
307
+
308
+ // Init floating mode drag
309
+ initFloatingDrag();
310
+
311
+ // Bind events
312
+ bindSidebarEvents();
313
+ }
314
+
315
+ function initResizeHandle(handle) {
316
+ var isResizing = false;
317
+ var startX = 0;
318
+ var startWidth = 0;
319
+
320
+ handle.addEventListener('mousedown', function(e) {
321
+ if (aiIsFloating) return;
322
+ isResizing = true;
323
+ startX = e.clientX;
324
+ startWidth = aiSidebarEl.offsetWidth;
325
+ handle.classList.add('active');
326
+ e.preventDefault();
327
+ });
328
+
329
+ document.addEventListener('mousemove', function(e) {
330
+ if (!isResizing) return;
331
+ var diff = startX - e.clientX;
332
+ var newWidth = Math.max(360, Math.min(startWidth + diff, window.innerWidth * 0.8));
333
+ aiSidebarEl.style.width = newWidth + 'px';
334
+ });
335
+
336
+ document.addEventListener('mouseup', function() {
337
+ if (isResizing) {
338
+ isResizing = false;
339
+ handle.classList.remove('active');
340
+ }
341
+ });
342
+ }
343
+
344
+ function initFloatingDrag() {
345
+ var isDragging = false;
346
+ var dragOffsetX = 0;
347
+ var dragOffsetY = 0;
348
+
349
+ document.addEventListener('mousedown', function(e) {
350
+ if (!aiIsFloating || !aiSidebarEl) return;
351
+ var header = aiSidebarEl.querySelector('.nowaikit-ai-header');
352
+ if (!header || !header.contains(e.target)) return;
353
+ // Don't drag if clicking buttons
354
+ if (e.target.closest('.nowaikit-ai-btn-icon')) return;
355
+ isDragging = true;
356
+ var rect = aiSidebarEl.getBoundingClientRect();
357
+ dragOffsetX = e.clientX - rect.left;
358
+ dragOffsetY = e.clientY - rect.top;
359
+ e.preventDefault();
360
+ });
361
+
362
+ document.addEventListener('mousemove', function(e) {
363
+ if (!isDragging) return;
364
+ var x = Math.max(0, Math.min(e.clientX - dragOffsetX, window.innerWidth - 100));
365
+ var y = Math.max(0, Math.min(e.clientY - dragOffsetY, window.innerHeight - 100));
366
+ aiSidebarEl.style.left = x + 'px';
367
+ aiSidebarEl.style.top = y + 'px';
368
+ aiSidebarEl.style.right = 'auto';
369
+ });
370
+
371
+ document.addEventListener('mouseup', function() {
372
+ isDragging = false;
373
+ });
374
+ }
375
+
376
+ function toggleFloatingMode() {
377
+ if (!aiSidebarEl) return;
378
+ aiIsFloating = !aiIsFloating;
379
+ aiSidebarEl.classList.toggle('floating', aiIsFloating);
380
+
381
+ var floatBtn = document.getElementById('nowaikit-ai-float');
382
+ if (floatBtn) {
383
+ floatBtn.title = aiIsFloating ? 'Dock to side' : 'Float window';
384
+ floatBtn.innerHTML = aiIsFloating
385
+ ? '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="20" height="18" rx="2"/><line x1="15" y1="3" x2="15" y2="21"/></svg>'
386
+ : '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><rect x="7" y="7" width="10" height="10" rx="1" fill="currentColor" opacity="0.15"/></svg>';
387
+ }
388
+
389
+ if (!aiIsFloating) {
390
+ // Reset to docked position
391
+ aiSidebarEl.style.left = '';
392
+ aiSidebarEl.style.top = '';
393
+ aiSidebarEl.style.right = '';
394
+ aiSidebarEl.style.width = '';
395
+ aiSidebarEl.style.height = '';
396
+ }
397
+ }
398
+
399
+ function buildBridgeStatusHTML() {
400
+ var connected = isBridgeConnected();
401
+ var info = connected ? getBridgeInfo() : null;
402
+ var instanceName = (info && info.instanceName) ? info.instanceName : '';
403
+ var dotColor = connected ? '#00C853' : '#9e9e9e';
404
+ var label = connected ? ('Connected to ' + instanceName) : 'Standalone';
405
+ return '<div class="nowaikit-ai-bridge-status" id="nowaikit-ai-bridge-status">' +
406
+ '<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:' + dotColor + ';margin-right:6px;"></span>' +
407
+ '<span style="font-size:11px;opacity:.75;">' + label + '</span>' +
408
+ '</div>';
409
+ }
410
+
411
+ function buildSidebarHTML() {
412
+ return '' +
413
+ '<div class="nowaikit-ai-header">' +
414
+ '<div class="nowaikit-ai-header-left">' +
415
+ '<svg width="22" height="22" viewBox="0 0 512 512" style="flex-shrink:0"><defs><linearGradient id="nkAiHdrGrad" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" stop-color="#00F0C0"/><stop offset="50%" stop-color="#00D4AA"/><stop offset="100%" stop-color="#0F4C81"/></linearGradient></defs><rect width="512" height="512" rx="128" fill="url(#nkAiHdrGrad)"/><g transform="translate(256,256) scale(9.13) translate(-22,-23)"><path d="M5 39V7l15 27V7" stroke="#fff" stroke-width="5.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/><path d="M34 4l2 7 6 2-6 2-2 7-2-7-6-2 6-2z" fill="#fff" opacity="0.9"><animate attributeName="opacity" values="0.9;0.4;0.9" dur="2s" repeatCount="indefinite"/></path><circle cx="34" cy="34" r="4.5" fill="#fff" opacity="0.8"><animate attributeName="opacity" values="0.8;0.4;0.8" dur="2s" repeatCount="indefinite" begin="0.3s"/></circle></g></svg>' +
416
+ '<span class="nowaikit-ai-title"><span class="nk-ai-logo-now">Now</span><span class="nk-ai-logo-ai">AI</span><span class="nk-ai-logo-kit">Kit</span><span class="nk-ai-logo-suffix"> Assistant</span></span>' +
417
+ '</div>' +
418
+ '<div class="nowaikit-ai-header-actions">' +
419
+ '<button class="nowaikit-ai-btn-icon" id="nowaikit-ai-theme" title="Toggle light/dark mode">' +
420
+ '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>' +
421
+ '</button>' +
422
+ '<button class="nowaikit-ai-btn-icon" id="nowaikit-ai-popout" title="Open in new window">' +
423
+ '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>' +
424
+ '</button>' +
425
+ '<button class="nowaikit-ai-btn-icon" id="nowaikit-ai-float" title="Float window">' +
426
+ '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><rect x="7" y="7" width="10" height="10" rx="1" fill="currentColor" opacity="0.15"/></svg>' +
427
+ '</button>' +
428
+ '<button class="nowaikit-ai-btn-icon" id="nowaikit-ai-clear" title="Clear conversation">' +
429
+ '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>' +
430
+ '</button>' +
431
+ '<button class="nowaikit-ai-btn-icon" id="nowaikit-ai-settings" title="AI Settings">' +
432
+ '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>' +
433
+ '</button>' +
434
+ '<button class="nowaikit-ai-btn-icon" id="nowaikit-ai-close" title="Close (Esc)">' +
435
+ '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>' +
436
+ '</button>' +
437
+ '</div>' +
438
+ '</div>' +
439
+ buildBridgeStatusHTML() +
440
+ // Settings panel (hidden by default)
441
+ '<div class="nowaikit-ai-settings-panel" id="nowaikit-ai-settings-panel" style="display:none;">' +
442
+ '<div class="nowaikit-ai-setting-group">' +
443
+ '<label class="nowaikit-ai-label">AI Provider</label>' +
444
+ '<select id="nowaikit-ai-provider" class="nowaikit-ai-select">' +
445
+ '<option value="openai">OpenAI (GPT)</option>' +
446
+ '<option value="anthropic">Anthropic (Claude)</option>' +
447
+ '<option value="google">Google (Gemini)</option>' +
448
+ '<option value="openrouter">OpenRouter</option>' +
449
+ '<option value="ollama">Ollama (Local)</option>' +
450
+ '</select>' +
451
+ '</div>' +
452
+ '<div class="nowaikit-ai-setting-group">' +
453
+ '<label class="nowaikit-ai-label">API Key</label>' +
454
+ '<input type="password" id="nowaikit-ai-apikey" class="nowaikit-ai-input" placeholder="sk-... or paste your API key">' +
455
+ '<button id="nowaikit-ai-key-link" class="nowaikit-ai-key-link" style="background:none;border:none;color:#00D4AA;font-size:11px;cursor:pointer;padding:4px 0;text-decoration:underline;display:block;margin-top:4px;">Get your API key &rarr;</button>' +
456
+ '</div>' +
457
+ '<div class="nowaikit-ai-setting-group">' +
458
+ '<label class="nowaikit-ai-label">Model</label>' +
459
+ '<select id="nowaikit-ai-model" class="nowaikit-ai-select">' +
460
+ '<option value="gpt-5.4">GPT-5.4</option>' +
461
+ '<option value="gpt-5.4-mini">GPT-5.4 Mini</option>' +
462
+ '<option value="claude-sonnet-4-6">Claude Sonnet 4.6</option>' +
463
+ '<option value="claude-opus-4-6">Claude Opus 4.6</option>' +
464
+ '<option value="gemini-3.1-pro-preview">Gemini 3.1 Pro</option>' +
465
+ '<option value="gemini-2.5-flash">Gemini 2.5 Flash</option>' +
466
+ '</select>' +
467
+ '</div>' +
468
+ '<div class="nowaikit-ai-setting-group">' +
469
+ '<label class="nowaikit-ai-label">Ollama URL <span style="font-size:10px;opacity:.6;">(only for Ollama)</span></label>' +
470
+ '<input type="text" id="nowaikit-ai-ollama-url" class="nowaikit-ai-input" placeholder="http://localhost:11434" value="http://localhost:11434">' +
471
+ '</div>' +
472
+ '<button class="nowaikit-ai-save-btn" id="nowaikit-ai-save-settings">Save Settings</button>' +
473
+ '</div>' +
474
+ // Chat messages area
475
+ '<div class="nowaikit-ai-messages" id="nowaikit-ai-messages">' +
476
+ '<div class="nowaikit-ai-welcome">' +
477
+ '<div class="nowaikit-ai-welcome-icon">' +
478
+ '<svg width="32" height="32" viewBox="0 0 512 512"><defs><linearGradient id="naikGrad" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" stop-color="#00F0C0"/><stop offset="50%" stop-color="#00D4AA"/><stop offset="100%" stop-color="#0F4C81"/></linearGradient><linearGradient id="naikShim" x1="0%" y1="0%" x2="100%" y2="0%"><stop offset="0%" stop-color="white" stop-opacity="0"/><stop offset="50%" stop-color="white" stop-opacity="0.08"/><stop offset="100%" stop-color="white" stop-opacity="0"/></linearGradient><clipPath id="naikClip"><rect width="512" height="512" rx="128"/></clipPath></defs><rect width="512" height="512" rx="128" fill="url(#naikGrad)"/><g transform="translate(256,256) scale(9.13) translate(-22,-23)"><path d="M5 39V7l15 27V7" stroke="#fff" stroke-width="5.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/><path d="M34 4l2 7 6 2-6 2-2 7-2-7-6-2 6-2z" fill="#fff" opacity="0.9"><animate attributeName="opacity" values="0.9;0.4;0.9" dur="2s" repeatCount="indefinite"/><animateTransform attributeName="transform" type="scale" values="1;1.15;1" dur="2s" repeatCount="indefinite" additive="sum"/></path><line x1="34" y1="19" x2="34" y2="28.5" stroke="#fff" stroke-width="1.8" opacity="0.5"><animate attributeName="opacity" values="0.5;0.2;0.5" dur="2s" repeatCount="indefinite"/></line><circle cx="34" cy="34" r="4.5" fill="#fff" opacity="0.8"><animate attributeName="opacity" values="0.8;0.4;0.8" dur="2s" repeatCount="indefinite" begin="0.3s"/><animate attributeName="r" values="4.5;5.5;4.5" dur="2s" repeatCount="indefinite" begin="0.3s"/></circle></g><rect x="-512" y="0" width="512" height="512" fill="url(#naikShim)" clip-path="url(#naikClip)"><animateTransform attributeName="transform" type="translate" values="0,0;1024,0" dur="4s" repeatCount="indefinite"/></rect></svg>' +
479
+ '</div>' +
480
+ '<div class="nowaikit-ai-welcome-title"><span class="nk-ai-logo-now">Now</span><span class="nk-ai-logo-ai">AI</span><span class="nk-ai-logo-kit">Kit</span><span class="nk-ai-logo-suffix"> Assistant</span></div>' +
481
+ '<div class="nowaikit-ai-welcome-sub">ServiceNow AI Assistant — Ask anything about this page, generate scripts, review code, or get best practices.</div>' +
482
+ '<div class="nowaikit-ai-welcome-hint">Tip: Configure your API key in Settings (gear icon) to get started.</div>' +
483
+ '</div>' +
484
+ '</div>' +
485
+ // Quick actions
486
+ '<div class="nowaikit-ai-quick-actions" id="nowaikit-ai-quick-actions">' +
487
+ QUICK_ACTIONS.map(function(a) {
488
+ return '<button class="nowaikit-ai-quick-btn" data-action-id="' + a.id + '" title="' + a.label + '">' +
489
+ '<span class="nowaikit-ai-quick-icon">' + a.icon + '</span>' +
490
+ '<span>' + a.label + '</span>' +
491
+ '</button>';
492
+ }).join('') +
493
+ '</div>' +
494
+ // Input area
495
+ '<div class="nowaikit-ai-input-area">' +
496
+ '<div class="nowaikit-ai-input-wrapper">' +
497
+ '<textarea id="nowaikit-ai-input" class="nowaikit-ai-chat-input" placeholder="Ask about this record, script, or table..." rows="1"></textarea>' +
498
+ '<button class="nowaikit-ai-send-btn" id="nowaikit-ai-send" title="Send (Enter)">' +
499
+ '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>' +
500
+ '</button>' +
501
+ '</div>' +
502
+ '<div class="nowaikit-ai-input-hint">Enter to send &middot; Shift+Enter for new line &middot; ' + ((window.nowaikitState || {}).isMac ? 'Cmd' : 'Ctrl') + '+Shift+A to toggle</div>' +
503
+ '</div>';
504
+ }
505
+
506
+ function bindSidebarEvents() {
507
+ // Close button
508
+ document.getElementById('nowaikit-ai-close').addEventListener('click', function() {
509
+ toggleAISidebar();
510
+ });
511
+
512
+ // Theme toggle (dark/light)
513
+ document.getElementById('nowaikit-ai-theme').addEventListener('click', function() {
514
+ toggleAiTheme();
515
+ });
516
+
517
+ // Apply saved theme on load (uses shared theme key)
518
+ chrome.storage.local.get({ nowaikitTheme: 'dark' }, function(result) {
519
+ applyAiTheme(result.nowaikitTheme || 'dark');
520
+ });
521
+
522
+ // Float / Dock toggle
523
+ document.getElementById('nowaikit-ai-float').addEventListener('click', function() {
524
+ toggleFloatingMode();
525
+ });
526
+
527
+ // Pop-out: open AI sidebar in a standalone browser window
528
+ document.getElementById('nowaikit-ai-popout').addEventListener('click', function() {
529
+ var w = 520, h = Math.round(window.innerHeight * 0.75);
530
+ var left = window.screenX + window.innerWidth - w - 40;
531
+ var top = window.screenY + 60;
532
+ window.open(
533
+ chrome.runtime.getURL('ai-window.html'),
534
+ 'nowaikit-ai-window',
535
+ 'width=' + w + ',height=' + h + ',left=' + left + ',top=' + top + ',resizable=yes,scrollbars=no'
536
+ );
537
+ });
538
+
539
+ // Clear conversation
540
+ document.getElementById('nowaikit-ai-clear').addEventListener('click', function() {
541
+ aiConversation = [];
542
+ var msgsEl = document.getElementById('nowaikit-ai-messages');
543
+ msgsEl.innerHTML = buildWelcomeHTML();
544
+ });
545
+
546
+ // Settings toggle
547
+ document.getElementById('nowaikit-ai-settings').addEventListener('click', function() {
548
+ var panel = document.getElementById('nowaikit-ai-settings-panel');
549
+ panel.style.display = panel.style.display === 'none' ? 'block' : 'none';
550
+ });
551
+
552
+ // Save settings (encrypt API key before storage)
553
+ document.getElementById('nowaikit-ai-save-settings').addEventListener('click', async function() {
554
+ var provider = document.getElementById('nowaikit-ai-provider').value;
555
+ var apiKey = document.getElementById('nowaikit-ai-apikey').value;
556
+ var model = document.getElementById('nowaikit-ai-model').value;
557
+ var ollamaUrl = document.getElementById('nowaikit-ai-ollama-url').value;
558
+
559
+ // Encrypt the API key before storing
560
+ var encryptedKey = await encryptApiKey(apiKey);
561
+
562
+ chrome.storage.local.set({
563
+ aiProvider: provider,
564
+ aiApiKey: encryptedKey,
565
+ aiModel: model,
566
+ aiOllamaUrl: ollamaUrl,
567
+ }, function() {
568
+ _showToast('AI settings saved securely', 'success');
569
+ document.getElementById('nowaikit-ai-settings-panel').style.display = 'none';
570
+ });
571
+ });
572
+
573
+ // Load saved settings (decrypt API key from storage)
574
+ chrome.storage.local.get({
575
+ aiProvider: 'openai',
576
+ aiApiKey: '',
577
+ aiModel: 'gpt-5.4',
578
+ aiOllamaUrl: 'http://localhost:11434',
579
+ }, async function(settings) {
580
+ document.getElementById('nowaikit-ai-provider').value = settings.aiProvider;
581
+ // Decrypt the API key for display
582
+ var decryptedKey = await decryptApiKey(settings.aiApiKey);
583
+ document.getElementById('nowaikit-ai-apikey').value = decryptedKey;
584
+ document.getElementById('nowaikit-ai-model').value = settings.aiModel;
585
+ document.getElementById('nowaikit-ai-ollama-url').value = settings.aiOllamaUrl;
586
+ updateModelOptions(settings.aiProvider);
587
+ updateApiKeyLink(settings.aiProvider);
588
+ });
589
+
590
+ // Provider change updates model options and API key link
591
+ document.getElementById('nowaikit-ai-provider').addEventListener('change', function() {
592
+ updateModelOptions(this.value);
593
+ updateApiKeyLink(this.value);
594
+ });
595
+
596
+ // Send message
597
+ document.getElementById('nowaikit-ai-send').addEventListener('click', sendMessage);
598
+
599
+ // Input: Enter to send, Shift+Enter for newline, auto-resize
600
+ var inputEl = document.getElementById('nowaikit-ai-input');
601
+ inputEl.addEventListener('keydown', function(e) {
602
+ if (e.key === 'Enter' && !e.shiftKey) {
603
+ e.preventDefault();
604
+ sendMessage();
605
+ }
606
+ });
607
+ inputEl.addEventListener('input', function() {
608
+ this.style.height = 'auto';
609
+ this.style.height = Math.min(this.scrollHeight, 120) + 'px';
610
+ });
611
+
612
+ // Quick actions
613
+ document.querySelectorAll('.nowaikit-ai-quick-btn').forEach(function(btn) {
614
+ btn.addEventListener('click', function() {
615
+ var actionId = this.dataset.actionId;
616
+ var action = QUICK_ACTIONS.find(function(a) { return a.id === actionId; });
617
+ if (!action) return;
618
+
619
+ if (action.id === 'generate') {
620
+ // Focus input with a prompt prefix
621
+ var inputEl = document.getElementById('nowaikit-ai-input');
622
+ inputEl.value = 'Generate a ';
623
+ inputEl.focus();
624
+ inputEl.style.height = 'auto';
625
+ return;
626
+ }
627
+
628
+ // Handle "Health" quick action — fetch live data from bridge if connected
629
+ if (action.id === 'instance') {
630
+ handleHealthQuickAction();
631
+ return;
632
+ }
633
+
634
+ if (action.prompt) {
635
+ // Replace {table} placeholder with the current table name
636
+ var _ns = window.nowaikitState || {};
637
+ var prompt = action.prompt.replace(/\{table\}/g, _ns.currentTable || 'the current table');
638
+ sendMessageWithText(prompt);
639
+ }
640
+ });
641
+ });
642
+
643
+ // Escape to close
644
+ document.addEventListener('keydown', function(e) {
645
+ if (e.key === 'Escape' && aiSidebarOpen) {
646
+ toggleAISidebar();
647
+ }
648
+ });
649
+ }
650
+
651
+ function buildWelcomeHTML() {
652
+ return '' +
653
+ '<div class="nowaikit-ai-welcome">' +
654
+ '<div class="nowaikit-ai-welcome-icon">' +
655
+ '<svg width="32" height="32" viewBox="0 0 512 512"><defs><linearGradient id="naikGrad2" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" stop-color="#00F0C0"/><stop offset="50%" stop-color="#00D4AA"/><stop offset="100%" stop-color="#0F4C81"/></linearGradient><linearGradient id="naikShim2" x1="0%" y1="0%" x2="100%" y2="0%"><stop offset="0%" stop-color="white" stop-opacity="0"/><stop offset="50%" stop-color="white" stop-opacity="0.08"/><stop offset="100%" stop-color="white" stop-opacity="0"/></linearGradient><clipPath id="naikClip2"><rect width="512" height="512" rx="128"/></clipPath></defs><rect width="512" height="512" rx="128" fill="url(#naikGrad2)"/><g transform="translate(256,256) scale(9.13) translate(-22,-23)"><path d="M5 39V7l15 27V7" stroke="#fff" stroke-width="5.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/><path d="M34 4l2 7 6 2-6 2-2 7-2-7-6-2 6-2z" fill="#fff" opacity="0.9"><animate attributeName="opacity" values="0.9;0.4;0.9" dur="2s" repeatCount="indefinite"/><animateTransform attributeName="transform" type="scale" values="1;1.15;1" dur="2s" repeatCount="indefinite" additive="sum"/></path><line x1="34" y1="19" x2="34" y2="28.5" stroke="#fff" stroke-width="1.8" opacity="0.5"><animate attributeName="opacity" values="0.5;0.2;0.5" dur="2s" repeatCount="indefinite"/></line><circle cx="34" cy="34" r="4.5" fill="#fff" opacity="0.8"><animate attributeName="opacity" values="0.8;0.4;0.8" dur="2s" repeatCount="indefinite" begin="0.3s"/><animate attributeName="r" values="4.5;5.5;4.5" dur="2s" repeatCount="indefinite" begin="0.3s"/></circle></g><rect x="-512" y="0" width="512" height="512" fill="url(#naikShim2)" clip-path="url(#naikClip2)"><animateTransform attributeName="transform" type="translate" values="0,0;1024,0" dur="4s" repeatCount="indefinite"/></rect></svg>' +
656
+ '</div>' +
657
+ '<div class="nowaikit-ai-welcome-title"><span class="nk-ai-logo-now">Now</span><span class="nk-ai-logo-ai">AI</span><span class="nk-ai-logo-kit">Kit</span><span class="nk-ai-logo-suffix"> Assistant</span></div>' +
658
+ '<div class="nowaikit-ai-welcome-sub">ServiceNow AI Assistant — Ask anything about this page, generate scripts, review code, or get best practices.</div>' +
659
+ '</div>';
660
+ }
661
+
662
+ // ─── Model Options ──────────────────────────────────────────────
663
+
664
+ // API key generation links per provider
665
+ var AI_KEY_LINKS = {
666
+ openai: 'https://platform.openai.com/api-keys',
667
+ anthropic: 'https://console.anthropic.com/settings/keys',
668
+ google: 'https://aistudio.google.com/apikey',
669
+ openrouter: 'https://openrouter.ai/keys',
670
+ ollama: '',
671
+ };
672
+
673
+ function updateModelOptions(provider) {
674
+ var modelSelect = document.getElementById('nowaikit-ai-model');
675
+ var models = {
676
+ openai: [
677
+ { value: 'gpt-5.4', label: 'GPT-5.4' },
678
+ { value: 'gpt-5.4-mini', label: 'GPT-5.4 Mini' },
679
+ { value: 'gpt-5.4-nano', label: 'GPT-5.4 Nano' },
680
+ ],
681
+ anthropic: [
682
+ { value: 'claude-sonnet-4-6', label: 'Claude Sonnet 4.6' },
683
+ { value: 'claude-opus-4-6', label: 'Claude Opus 4.6' },
684
+ { value: 'claude-haiku-4-5-20251001', label: 'Claude Haiku 4.5' },
685
+ ],
686
+ google: [
687
+ { value: 'gemini-3.1-pro-preview', label: 'Gemini 3.1 Pro' },
688
+ { value: 'gemini-3-flash-preview', label: 'Gemini 3 Flash' },
689
+ { value: 'gemini-2.5-flash', label: 'Gemini 2.5 Flash' },
690
+ { value: 'gemini-2.5-pro', label: 'Gemini 2.5 Pro' },
691
+ ],
692
+ openrouter: [
693
+ { value: 'openai/gpt-5.4', label: 'GPT-5.4' },
694
+ { value: 'anthropic/claude-sonnet-4-6', label: 'Claude Sonnet 4.6' },
695
+ { value: 'anthropic/claude-opus-4-6', label: 'Claude Opus 4.6' },
696
+ { value: 'google/gemini-3.1-pro-preview', label: 'Gemini 3.1 Pro' },
697
+ { value: 'deepseek/deepseek-v3.2', label: 'DeepSeek V3.2' },
698
+ ],
699
+ ollama: [
700
+ { value: 'qwen3', label: 'Qwen 3' },
701
+ { value: 'qwen3.5', label: 'Qwen 3.5' },
702
+ { value: 'deepseek-r1', label: 'DeepSeek R1' },
703
+ { value: 'llama3.3', label: 'Llama 3.3' },
704
+ ],
705
+ };
706
+
707
+ var opts = models[provider] || models.openai;
708
+ modelSelect.innerHTML = opts.map(function(m) {
709
+ return '<option value="' + m.value + '">' + m.label + '</option>';
710
+ }).join('');
711
+
712
+ // Show/hide Ollama URL field
713
+ var ollamaGroup = document.getElementById('nowaikit-ai-ollama-url').closest('.nowaikit-ai-setting-group');
714
+ if (ollamaGroup) {
715
+ ollamaGroup.style.display = provider === 'ollama' ? 'block' : 'none';
716
+ }
717
+
718
+ // Update API key link
719
+ updateApiKeyLink(provider);
720
+ }
721
+
722
+ function updateApiKeyLink(provider) {
723
+ var linkEl = document.getElementById('nowaikit-ai-key-link');
724
+ if (!linkEl) return;
725
+ var url = AI_KEY_LINKS[provider] || '';
726
+ if (url) {
727
+ linkEl.style.display = 'block';
728
+ linkEl.onclick = function(e) {
729
+ e.preventDefault();
730
+ window.open(url, '_blank');
731
+ };
732
+ } else {
733
+ linkEl.style.display = 'none';
734
+ }
735
+ }
736
+
737
+ // ─── Theme Toggle ───────────────────────────────────────────
738
+
739
+ var aiCurrentTheme = 'dark';
740
+
741
+ function toggleAiTheme() {
742
+ // Use global theme API if available (syncs all extension UI)
743
+ if (typeof window.nowaikitToggleTheme === 'function') {
744
+ var newTheme = window.nowaikitToggleTheme();
745
+ applyAiTheme(newTheme);
746
+ } else {
747
+ var newTheme = aiCurrentTheme === 'dark' ? 'light' : 'dark';
748
+ applyAiTheme(newTheme);
749
+ chrome.storage.local.set({ nowaikitTheme: newTheme });
750
+ }
751
+ }
752
+
753
+ function applyAiTheme(theme) {
754
+ aiCurrentTheme = theme;
755
+ if (!aiSidebarEl) return;
756
+
757
+ aiSidebarEl.classList.remove('nowaikit-ai-dark', 'nowaikit-ai-light');
758
+ aiSidebarEl.classList.add('nowaikit-ai-' + theme);
759
+
760
+ // Also toggle body class for CSS overrides
761
+ document.body.classList.remove('nowaikit-light', 'nowaikit-dark');
762
+ document.body.classList.add(theme === 'light' ? 'nowaikit-light' : 'nowaikit-dark');
763
+
764
+ // Update button icon
765
+ var themeBtn = document.getElementById('nowaikit-ai-theme');
766
+ if (themeBtn) {
767
+ themeBtn.title = theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode';
768
+ themeBtn.innerHTML = theme === 'dark'
769
+ ? '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>'
770
+ : '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>';
771
+ }
772
+ }
773
+
774
+ // ─── Toggle Sidebar ─────────────────────────────────────────────
775
+
776
+ function closeAISidebar() {
777
+ if (!aiSidebarOpen) return;
778
+ aiSidebarOpen = false;
779
+ if (aiSidebarEl) aiSidebarEl.classList.remove('open');
780
+ var toggleBtn = document.getElementById('nowaikit-ai-toggle');
781
+ if (toggleBtn) toggleBtn.classList.remove('active');
782
+ }
783
+
784
+ function toggleAISidebar() {
785
+ aiSidebarOpen = !aiSidebarOpen;
786
+ if (aiSidebarEl) {
787
+ aiSidebarEl.classList.toggle('open', aiSidebarOpen);
788
+ }
789
+ var toggleBtn = document.getElementById('nowaikit-ai-toggle');
790
+ if (toggleBtn) {
791
+ toggleBtn.classList.toggle('active', aiSidebarOpen);
792
+ }
793
+ if (aiSidebarOpen) {
794
+ // Close other panels
795
+ if (typeof window.closeMainPanel === 'function') window.closeMainPanel();
796
+ if (typeof window.closeCodeTemplates === 'function') window.closeCodeTemplates();
797
+ var inputEl = document.getElementById('nowaikit-ai-input');
798
+ if (inputEl) setTimeout(function() { inputEl.focus(); }, 100);
799
+ }
800
+ }
801
+
802
+ window.closeAISidebar = closeAISidebar;
803
+
804
+ // ─── Bridge Status Refresh ───────────────────────────────────────
805
+
806
+ function refreshBridgeStatus() {
807
+ var statusEl = document.getElementById('nowaikit-ai-bridge-status');
808
+ if (!statusEl || !statusEl.parentNode) return;
809
+ // Use DOM replacement instead of outerHTML to avoid HTML injection risks
810
+ var wrapper = document.createElement('div');
811
+ wrapper.innerHTML = buildBridgeStatusHTML();
812
+ var newEl = wrapper.firstChild;
813
+ if (newEl) {
814
+ statusEl.parentNode.replaceChild(newEl, statusEl);
815
+ }
816
+ }
817
+
818
+ // Periodically refresh bridge status (every 10 seconds when sidebar is open)
819
+ setInterval(function() {
820
+ if (aiSidebarOpen) refreshBridgeStatus();
821
+ }, 10000);
822
+
823
+ // ─── Health Quick Action ─────────────────────────────────────────
824
+
825
+ async function handleHealthQuickAction() {
826
+ // If bridge is connected, get real health data without needing an LLM call
827
+ if (isBridgeConnected() && typeof bridgeGetInstanceHealth === 'function') {
828
+ addMessageToUI('user', 'Show instance health metrics');
829
+ aiConversation.push({ role: 'user', content: 'Show instance health metrics' });
830
+
831
+ try {
832
+ var health = await bridgeGetInstanceHealth();
833
+ var msg = formatHealthData(health);
834
+ addMessageToUI('assistant', msg);
835
+ aiConversation.push({ role: 'assistant', content: msg });
836
+ } catch (e) {
837
+ addMessageToUI('assistant', 'Unable to fetch instance health data: ' + (e.message || 'Unknown error. Please check the bridge connection.'));
838
+ aiConversation.push({ role: 'assistant', content: 'Unable to fetch instance health data.' });
839
+ }
840
+ return;
841
+ }
842
+
843
+ // Fallback: send prompt to the LLM
844
+ sendMessageWithText('Show me the current instance health metrics and any issues.');
845
+ }
846
+
847
+ function formatHealthData(health) {
848
+ if (!health) return 'No health data available.';
849
+
850
+ var lines = ['**Instance Health Report**', ''];
851
+
852
+ if (health.instanceName) lines.push('**Instance:** ' + health.instanceName);
853
+ if (health.version) lines.push('**Version:** ' + health.version);
854
+ if (health.buildTag) lines.push('**Build:** ' + health.buildTag);
855
+
856
+ // Node counts / status
857
+ if (health.nodes && Array.isArray(health.nodes)) {
858
+ lines.push('', '**Nodes:**');
859
+ health.nodes.forEach(function(node) {
860
+ var status = node.status || 'unknown';
861
+ var statusIcon = status === 'online' ? '(OK)' : '(!)';
862
+ lines.push('- ' + (node.name || 'Node') + ': ' + status + ' ' + statusIcon);
863
+ });
864
+ }
865
+
866
+ // Performance stats
867
+ if (health.stats) {
868
+ lines.push('', '**Performance:**');
869
+ if (health.stats.transactionCount !== undefined) lines.push('- Transactions: ' + health.stats.transactionCount);
870
+ if (health.stats.avgResponseTime !== undefined) lines.push('- Avg Response Time: ' + health.stats.avgResponseTime + 'ms');
871
+ if (health.stats.slowQueries !== undefined) lines.push('- Slow Queries: ' + health.stats.slowQueries);
872
+ if (health.stats.semaphoreWaits !== undefined) lines.push('- Semaphore Waits: ' + health.stats.semaphoreWaits);
873
+ }
874
+
875
+ // Active users
876
+ if (health.activeUsers !== undefined) {
877
+ lines.push('', '**Active Users:** ' + health.activeUsers);
878
+ }
879
+
880
+ // Issues / warnings
881
+ if (health.issues && Array.isArray(health.issues) && health.issues.length > 0) {
882
+ lines.push('', '**Issues:**');
883
+ health.issues.forEach(function(issue) {
884
+ lines.push('- ' + (issue.severity || 'info').toUpperCase() + ': ' + (issue.message || issue));
885
+ });
886
+ } else if (!health.issues || (Array.isArray(health.issues) && health.issues.length === 0)) {
887
+ lines.push('', '**Issues:** None detected');
888
+ }
889
+
890
+ // If the health object has other top-level keys we haven't handled, show them
891
+ var handledKeys = ['instanceName', 'version', 'buildTag', 'nodes', 'stats', 'activeUsers', 'issues'];
892
+ Object.keys(health).forEach(function(key) {
893
+ if (handledKeys.indexOf(key) === -1 && health[key] !== null && health[key] !== undefined) {
894
+ var val = typeof health[key] === 'object' ? JSON.stringify(health[key]) : String(health[key]);
895
+ lines.push('- **' + key + ':** ' + val);
896
+ }
897
+ });
898
+
899
+ return lines.join('\n');
900
+ }
901
+
902
+ // ─── Send Message ───────────────────────────────────────────────
903
+
904
+ function sendMessage() {
905
+ var inputEl = document.getElementById('nowaikit-ai-input');
906
+ var text = (inputEl.value || '').trim();
907
+ if (!text || aiStreaming) return;
908
+ sendMessageWithText(text);
909
+ inputEl.value = '';
910
+ inputEl.style.height = 'auto';
911
+ }
912
+
913
+ function sendMessageWithText(text) {
914
+ // Check for API key (decrypt from storage)
915
+ chrome.storage.local.get({
916
+ aiProvider: 'openai',
917
+ aiApiKey: '',
918
+ aiModel: 'gpt-5.4',
919
+ aiOllamaUrl: 'http://localhost:11434',
920
+ }, async function(settings) {
921
+ // Decrypt the API key
922
+ var decryptedKey = await decryptApiKey(settings.aiApiKey);
923
+ if (!decryptedKey && settings.aiProvider !== 'ollama') {
924
+ _showToast('Please configure your API key in AI Settings', 'warn');
925
+ document.getElementById('nowaikit-ai-settings-panel').style.display = 'block';
926
+ return;
927
+ }
928
+
929
+ // Add user message to UI
930
+ addMessageToUI('user', text);
931
+ aiConversation.push({ role: 'user', content: text });
932
+
933
+ // Gather context (async — may fetch bridge data)
934
+ var ctx = await gatherPageContext();
935
+ var contextMsg = buildContextMessage(ctx);
936
+
937
+ // Build system prompt — enhance when bridge is connected
938
+ var systemPrompt = SN_SYSTEM_PROMPT;
939
+ if (isBridgeConnected()) {
940
+ var bridgeInfo = getBridgeInfo();
941
+ var instName = (bridgeInfo && bridgeInfo.instanceName) ? bridgeInfo.instanceName : 'unknown';
942
+ var instUrl = (bridgeInfo && bridgeInfo.instanceUrl) ? bridgeInfo.instanceUrl : '';
943
+ systemPrompt += '\n\nYou have access to a live ServiceNow instance (' + instName + ' at ' + instUrl + '). The user\'s current page context includes real-time data from the instance.';
944
+ }
945
+
946
+ // Build messages array for the LLM
947
+ var messages = [{ role: 'system', content: systemPrompt }];
948
+
949
+ // Add context as first user message if available
950
+ if (contextMsg) {
951
+ messages.push({ role: 'user', content: contextMsg });
952
+ messages.push({ role: 'assistant', content: 'I can see the current page context. How can I help?' });
953
+ }
954
+
955
+ // Add conversation history (last 20 messages to stay within limits)
956
+ var historySlice = aiConversation.slice(-20);
957
+ historySlice.forEach(function(msg) {
958
+ messages.push({ role: msg.role, content: msg.content });
959
+ });
960
+
961
+ // Create streaming response UI
962
+ var assistantMsgId = 'ai-msg-' + Date.now();
963
+ addStreamingMessage(assistantMsgId);
964
+ aiStreaming = true;
965
+ updateSendButton(true);
966
+
967
+ // Connect to background for streaming
968
+ aiPort = chrome.runtime.connect({ name: 'nowaikit-ai-stream' });
969
+
970
+ var fullResponse = '';
971
+
972
+ // Handle unexpected port disconnection
973
+ aiPort.onDisconnect.addListener(function() {
974
+ if (aiStreaming) {
975
+ aiStreaming = false;
976
+ updateSendButton(false);
977
+ if (fullResponse) {
978
+ finalizeMessage(assistantMsgId, fullResponse);
979
+ aiConversation.push({ role: 'assistant', content: fullResponse });
980
+ } else {
981
+ updateStreamingMessage(assistantMsgId, 'Connection lost. Please try again.');
982
+ var el = document.getElementById(assistantMsgId);
983
+ if (el) el.classList.add('nowaikit-ai-error');
984
+ }
985
+ }
986
+ aiPort = null;
987
+ });
988
+
989
+ aiPort.onMessage.addListener(function(msg) {
990
+ if (msg.type === 'token') {
991
+ fullResponse += msg.content;
992
+ updateStreamingMessage(assistantMsgId, fullResponse);
993
+ } else if (msg.type === 'done') {
994
+ aiStreaming = false;
995
+ updateSendButton(false);
996
+ finalizeMessage(assistantMsgId, fullResponse);
997
+ aiConversation.push({ role: 'assistant', content: fullResponse });
998
+ aiPort.disconnect();
999
+ aiPort = null;
1000
+ } else if (msg.type === 'error') {
1001
+ aiStreaming = false;
1002
+ updateSendButton(false);
1003
+ updateStreamingMessage(assistantMsgId, 'Error: ' + msg.content);
1004
+ var el = document.getElementById(assistantMsgId);
1005
+ if (el) el.classList.add('nowaikit-ai-error');
1006
+ aiPort.disconnect();
1007
+ aiPort = null;
1008
+ }
1009
+ });
1010
+
1011
+ // Send the request (using decrypted key)
1012
+ aiPort.postMessage({
1013
+ action: 'nowaikit-ai-chat',
1014
+ provider: settings.aiProvider,
1015
+ apiKey: decryptedKey,
1016
+ model: settings.aiModel,
1017
+ ollamaUrl: settings.aiOllamaUrl,
1018
+ messages: messages,
1019
+ });
1020
+ });
1021
+ }
1022
+
1023
+ // ─── UI Helpers ─────────────────────────────────────────────────
1024
+
1025
+ function addMessageToUI(role, content) {
1026
+ var msgsEl = document.getElementById('nowaikit-ai-messages');
1027
+ // Remove welcome message if present
1028
+ var welcome = msgsEl.querySelector('.nowaikit-ai-welcome');
1029
+ if (welcome) welcome.remove();
1030
+
1031
+ var msgEl = document.createElement('div');
1032
+ msgEl.className = 'nowaikit-ai-message nowaikit-ai-message-' + role;
1033
+
1034
+ var avatarHTML = role === 'user'
1035
+ ? '<div class="nowaikit-ai-avatar nowaikit-ai-avatar-user">U</div>'
1036
+ : '<div class="nowaikit-ai-avatar nowaikit-ai-avatar-ai">N</div>';
1037
+
1038
+ msgEl.innerHTML = avatarHTML +
1039
+ '<div class="nowaikit-ai-message-content">' + renderMarkdown(_escapeHtml(content)) + '</div>';
1040
+
1041
+ msgsEl.appendChild(msgEl);
1042
+ msgsEl.scrollTop = msgsEl.scrollHeight;
1043
+ }
1044
+
1045
+ function addStreamingMessage(id) {
1046
+ var msgsEl = document.getElementById('nowaikit-ai-messages');
1047
+ var welcome = msgsEl.querySelector('.nowaikit-ai-welcome');
1048
+ if (welcome) welcome.remove();
1049
+
1050
+ var msgEl = document.createElement('div');
1051
+ msgEl.id = id;
1052
+ msgEl.className = 'nowaikit-ai-message nowaikit-ai-message-assistant';
1053
+ msgEl.innerHTML =
1054
+ '<div class="nowaikit-ai-avatar nowaikit-ai-avatar-ai">N</div>' +
1055
+ '<div class="nowaikit-ai-message-content"><span class="nowaikit-ai-typing">Thinking...</span></div>';
1056
+
1057
+ msgsEl.appendChild(msgEl);
1058
+ msgsEl.scrollTop = msgsEl.scrollHeight;
1059
+ }
1060
+
1061
+ var _streamThrottleTimer = null;
1062
+ var _streamPendingContent = null;
1063
+ var _streamPendingId = null;
1064
+
1065
+ function updateStreamingMessage(id, content) {
1066
+ _streamPendingContent = content;
1067
+ _streamPendingId = id;
1068
+ // Throttle DOM updates to max ~15fps during streaming for performance
1069
+ if (_streamThrottleTimer) return;
1070
+ _streamThrottleTimer = setTimeout(function() {
1071
+ _streamThrottleTimer = null;
1072
+ _flushStreamingMessage();
1073
+ }, 66);
1074
+ }
1075
+
1076
+ function _flushStreamingMessage() {
1077
+ if (!_streamPendingId || _streamPendingContent === null) return;
1078
+ var msgEl = document.getElementById(_streamPendingId);
1079
+ if (!msgEl) return;
1080
+ var contentEl = msgEl.querySelector('.nowaikit-ai-message-content');
1081
+ if (contentEl) {
1082
+ contentEl.innerHTML = renderMarkdown(_escapeHtml(_streamPendingContent)) + '<span class="nowaikit-ai-cursor">|</span>';
1083
+ }
1084
+ var msgsEl = document.getElementById('nowaikit-ai-messages');
1085
+ if (msgsEl) msgsEl.scrollTop = msgsEl.scrollHeight;
1086
+ }
1087
+
1088
+ function finalizeMessage(id, content) {
1089
+ var msgEl = document.getElementById(id);
1090
+ if (!msgEl) return;
1091
+ var contentEl = msgEl.querySelector('.nowaikit-ai-message-content');
1092
+ if (contentEl) {
1093
+ contentEl.innerHTML = renderMarkdown(_escapeHtml(content));
1094
+
1095
+ // Add "Insert into editor" buttons for code blocks
1096
+ contentEl.querySelectorAll('pre code').forEach(function(codeEl) {
1097
+ var insertBtn = document.createElement('button');
1098
+ insertBtn.className = 'nowaikit-ai-insert-btn';
1099
+ insertBtn.textContent = 'Insert into Editor';
1100
+ insertBtn.addEventListener('click', function() {
1101
+ insertCodeIntoEditor(codeEl.textContent);
1102
+ });
1103
+ var copyBtn = document.createElement('button');
1104
+ copyBtn.className = 'nowaikit-ai-copy-code-btn';
1105
+ copyBtn.textContent = 'Copy';
1106
+ copyBtn.addEventListener('click', function() {
1107
+ navigator.clipboard.writeText(codeEl.textContent).then(function() {
1108
+ copyBtn.textContent = 'Copied!';
1109
+ setTimeout(function() { copyBtn.textContent = 'Copy'; }, 1500);
1110
+ }).catch(function() {
1111
+ copyBtn.textContent = 'Failed';
1112
+ setTimeout(function() { copyBtn.textContent = 'Copy'; }, 1500);
1113
+ });
1114
+ });
1115
+ var actionsDiv = document.createElement('div');
1116
+ actionsDiv.className = 'nowaikit-ai-code-actions';
1117
+ actionsDiv.appendChild(copyBtn);
1118
+ actionsDiv.appendChild(insertBtn);
1119
+ codeEl.parentElement.insertBefore(actionsDiv, codeEl.parentElement.firstChild);
1120
+ });
1121
+ }
1122
+ var msgsEl = document.getElementById('nowaikit-ai-messages');
1123
+ msgsEl.scrollTop = msgsEl.scrollHeight;
1124
+ }
1125
+
1126
+ function updateSendButton(streaming) {
1127
+ var sendBtn = document.getElementById('nowaikit-ai-send');
1128
+ if (sendBtn) {
1129
+ sendBtn.disabled = streaming;
1130
+ sendBtn.style.opacity = streaming ? '0.5' : '1';
1131
+ }
1132
+ }
1133
+
1134
+ // ─── Markdown Rendering ─────────────────────────────────────────
1135
+ // Minimal markdown renderer for chat messages
1136
+
1137
+ function renderMarkdown(text) {
1138
+ // Code blocks: ```lang\n...\n```
1139
+ text = text.replace(/```(\w*)\n([\s\S]*?)```/g, function(m, lang, code) {
1140
+ // Sanitize lang to alphanumeric only (prevent class injection)
1141
+ var safeLang = (lang || 'text').replace(/[^a-zA-Z0-9]/g, '');
1142
+ return '<pre><code class="lang-' + safeLang + '">' + code.trim() + '</code></pre>';
1143
+ });
1144
+ // Inline code: `...`
1145
+ text = text.replace(/`([^`]+)`/g, '<code>$1</code>');
1146
+ // Bold: **...**
1147
+ text = text.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
1148
+ // Italic: *...*
1149
+ text = text.replace(/\*([^*]+)\*/g, '<em>$1</em>');
1150
+ // Bullet lists: - item or * item
1151
+ text = text.replace(/^[\-\*] (.+)$/gm, '<li>$1</li>');
1152
+ text = text.replace(/(<li>.*<\/li>)/s, '<ul>$1</ul>');
1153
+ // Numbered lists: 1. item
1154
+ text = text.replace(/^\d+\. (.+)$/gm, '<li>$1</li>');
1155
+ // Headers: ## ... or ### ...
1156
+ text = text.replace(/^### (.+)$/gm, '<h4>$1</h4>');
1157
+ text = text.replace(/^## (.+)$/gm, '<h3>$1</h3>');
1158
+ // Line breaks
1159
+ text = text.replace(/\n/g, '<br>');
1160
+ // Clean up multiple <br> tags
1161
+ text = text.replace(/(<br>){3,}/g, '<br><br>');
1162
+
1163
+ return text;
1164
+ }
1165
+
1166
+ // ─── Write-Back: Insert Code into Editor ────────────────────────
1167
+
1168
+ function insertCodeIntoEditor(code) {
1169
+ // Try CodeMirror first
1170
+ var cmEl = document.querySelector('.CodeMirror');
1171
+ if (cmEl && cmEl.CodeMirror) {
1172
+ var cm = cmEl.CodeMirror;
1173
+ var cursor = cm.getCursor();
1174
+ cm.replaceRange(code, cursor);
1175
+ _showToast('Code inserted into editor', 'success');
1176
+ return;
1177
+ }
1178
+
1179
+ // Try textarea script fields
1180
+ var textareas = document.querySelectorAll('textarea[id*="script"], textarea[name="script"], textarea[name="condition"]');
1181
+ if (textareas.length > 0) {
1182
+ var ta = textareas[0];
1183
+ var start = ta.selectionStart;
1184
+ var end = ta.selectionEnd;
1185
+ ta.value = ta.value.substring(0, start) + code + ta.value.substring(end);
1186
+ ta.selectionStart = ta.selectionEnd = start + code.length;
1187
+ ta.dispatchEvent(new Event('input', { bubbles: true }));
1188
+ _showToast('Code inserted into editor', 'success');
1189
+ return;
1190
+ }
1191
+
1192
+ // Fallback: copy to clipboard
1193
+ navigator.clipboard.writeText(code).then(function() {
1194
+ _showToast('No editor found. Code copied to clipboard.', 'warn');
1195
+ }).catch(function() {
1196
+ _showToast('No editor found and clipboard access denied.', 'warn');
1197
+ });
1198
+ }