@mp-consulting/homebridge-daikin-cloud 1.6.2 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +34 -0
- package/homebridge-ui/assistant.js +62 -0
- package/homebridge-ui/public/index.html +32 -0
- package/homebridge-ui/public/lib/ai.css +738 -0
- package/homebridge-ui/public/lib/kit.css +703 -1
- package/homebridge-ui/public/lib/kit.js +618 -1
- package/homebridge-ui/public/script.js +286 -6
- package/homebridge-ui/server.js +6 -1
- package/package.json +8 -3
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/* @mp-consulting/homebridge-ui-kit v1.
|
|
1
|
+
/* @mp-consulting/homebridge-ui-kit v1.2.0
|
|
2
2
|
Brand design system for Homebridge plugins
|
|
3
3
|
https://github.com/mp-consulting/homebridge-ui-kit */
|
|
4
4
|
|
|
@@ -172,6 +172,623 @@
|
|
|
172
172
|
|
|
173
173
|
};
|
|
174
174
|
|
|
175
|
+
// ── Assistant (AI) ─────────────────────────────────────────────────────────
|
|
176
|
+
// Talks to the plugin's server-side routes registered by
|
|
177
|
+
// @mp-consulting/homebridge-ai-core/plugin (`registerAiRoutes`) through the global
|
|
178
|
+
// `homebridge` object from @homebridge/plugin-ui-utils.
|
|
179
|
+
|
|
180
|
+
// Generic four-point sparkle (not a product logo).
|
|
181
|
+
var SPARKLE = '<svg class="mp-ai-icon" viewBox="0 0 16 16" aria-hidden="true" focusable="false">'
|
|
182
|
+
+ '<path d="M8 0c.4 3.9 2.1 5.6 6 6-3.9.4-5.6 2.1-6 6-.4-3.9-2.1-5.6-6-6 3.9-.4 5.6-2.1 6-6z"/>'
|
|
183
|
+
+ '<path d="M13.5 10.5c.15 1.5.85 2.2 2.5 2.5-1.65.3-2.35 1-2.5 2.5-.15-1.5-.85-2.2-2.5-2.5 1.65-.3 2.35-1 2.5-2.5z"/>'
|
|
184
|
+
+ '</svg>';
|
|
185
|
+
var CARET = '<span class="mp-ai-caret" aria-hidden="true"></span>';
|
|
186
|
+
var DEFAULT_NOTE = 'Generated by the Assistant. Check it before acting on it.';
|
|
187
|
+
var DEFAULT_ERROR = 'The Assistant could not answer. Please try again.';
|
|
188
|
+
var LCS_LIMIT = 4000000; // cells; larger diffs fall back to remove-all/add-all
|
|
189
|
+
|
|
190
|
+
var aiSeq = 0;
|
|
191
|
+
var uid = 0;
|
|
192
|
+
|
|
193
|
+
function hbClient() {
|
|
194
|
+
return global.homebridge || (typeof homebridge !== 'undefined' ? homebridge : undefined);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function newRequestId() {
|
|
198
|
+
aiSeq += 1;
|
|
199
|
+
return 'mpai-' + Date.now().toString(36) + '-' + aiSeq.toString(36) + '-'
|
|
200
|
+
+ Math.random().toString(36).slice(2, 8);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function eventData(ev) {
|
|
204
|
+
if (ev && ev.data !== undefined) { return ev.data; }
|
|
205
|
+
if (ev && ev.detail !== undefined) { return ev.detail; }
|
|
206
|
+
return ev;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function errorMessage(err, fallback) {
|
|
210
|
+
if (typeof err === 'string' && err) { return err; }
|
|
211
|
+
return (err && err.message) || fallback;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Sends a request to an /ai/* route with a generated requestId and, while it
|
|
216
|
+
* runs, forwards matching `ai:chunk` / `ai:done` / `ai:error` events to the
|
|
217
|
+
* onChunk / onDone / onError callbacks. Resolves with the route's result.
|
|
218
|
+
*/
|
|
219
|
+
function aiRequest(path, body, opts) {
|
|
220
|
+
opts = opts || {};
|
|
221
|
+
var hb = hbClient();
|
|
222
|
+
if (!hb || typeof hb.request !== 'function') {
|
|
223
|
+
return Promise.reject(new Error('MpKit.ai needs the Homebridge plugin UI (window.homebridge)'));
|
|
224
|
+
}
|
|
225
|
+
var requestId = newRequestId();
|
|
226
|
+
var payload = {};
|
|
227
|
+
Object.keys(body || {}).forEach(function (k) { payload[k] = body[k]; });
|
|
228
|
+
payload.requestId = requestId;
|
|
229
|
+
|
|
230
|
+
var listeners = [];
|
|
231
|
+
if (typeof hb.addEventListener === 'function') {
|
|
232
|
+
var on = function (type, fn) {
|
|
233
|
+
var handler = function (ev) {
|
|
234
|
+
var data = eventData(ev);
|
|
235
|
+
if (data && data.requestId === requestId) { fn(data); }
|
|
236
|
+
};
|
|
237
|
+
hb.addEventListener(type, handler);
|
|
238
|
+
listeners.push([type, handler]);
|
|
239
|
+
};
|
|
240
|
+
if (typeof opts.onChunk === 'function') {
|
|
241
|
+
on('ai:chunk', function (d) {
|
|
242
|
+
if (typeof d.delta === 'string' && d.delta) { opts.onChunk(d.delta, d); }
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
if (typeof opts.onDone === 'function') {
|
|
246
|
+
on('ai:done', function (d) { opts.onDone(d); });
|
|
247
|
+
}
|
|
248
|
+
if (typeof opts.onError === 'function') {
|
|
249
|
+
on('ai:error', function (d) { opts.onError(d.message || DEFAULT_ERROR, d); });
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
function cleanup() {
|
|
253
|
+
if (typeof hb.removeEventListener !== 'function') { return; }
|
|
254
|
+
listeners.forEach(function (l) { hb.removeEventListener(l[0], l[1]); });
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
var pending;
|
|
258
|
+
try {
|
|
259
|
+
pending = Promise.resolve(hb.request(path, payload));
|
|
260
|
+
} catch (err) {
|
|
261
|
+
pending = Promise.reject(err);
|
|
262
|
+
}
|
|
263
|
+
return pending.then(function (result) {
|
|
264
|
+
cleanup();
|
|
265
|
+
return result;
|
|
266
|
+
}, function (err) {
|
|
267
|
+
cleanup();
|
|
268
|
+
throw err;
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// ── Safe markdown subset ──
|
|
273
|
+
// Paragraphs, **bold**, `inline code`, fenced code blocks, bullet and
|
|
274
|
+
// numbered lists, and #-headings (rendered as bold paragraphs). Every piece of
|
|
275
|
+
// text is HTML-escaped before any tag is added, so model output can never
|
|
276
|
+
// inject markup. Links are left as plain text.
|
|
277
|
+
|
|
278
|
+
function inlineMarkdown(text) {
|
|
279
|
+
return String(text).split(/(`[^`\n]+`)/).map(function (part, i) {
|
|
280
|
+
if (i % 2 === 1) { return '<code>' + escapeHtml(part.slice(1, -1)) + '</code>'; }
|
|
281
|
+
return escapeHtml(part).replace(/\*\*([^*\n]+?)\*\*|__([^_\n]+?)__/g, function (m, a, b) {
|
|
282
|
+
return '<strong>' + (a || b) + '</strong>';
|
|
283
|
+
});
|
|
284
|
+
}).join('');
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
var UL_ITEM = /^\s*[-*+]\s+(.*)$/;
|
|
288
|
+
var OL_ITEM = /^\s*\d+[.)]\s+(.*)$/;
|
|
289
|
+
var HEADING = /^\s*#{1,6}\s+(.*)$/;
|
|
290
|
+
var FENCE = /^\s*(```|~~~)/;
|
|
291
|
+
|
|
292
|
+
function markdown(text) {
|
|
293
|
+
var lines = String(text == null ? '' : text).replace(/\r\n?/g, '\n').split('\n');
|
|
294
|
+
var out = [];
|
|
295
|
+
var para = [];
|
|
296
|
+
var i = 0;
|
|
297
|
+
function flush() {
|
|
298
|
+
if (para.length) {
|
|
299
|
+
out.push('<p>' + para.map(inlineMarkdown).join('<br>') + '</p>');
|
|
300
|
+
para = [];
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
while (i < lines.length) {
|
|
304
|
+
var line = lines[i];
|
|
305
|
+
var fence = FENCE.exec(line);
|
|
306
|
+
if (fence) {
|
|
307
|
+
flush();
|
|
308
|
+
var code = [];
|
|
309
|
+
i += 1;
|
|
310
|
+
while (i < lines.length && lines[i].trim().indexOf(fence[1]) !== 0) {
|
|
311
|
+
code.push(lines[i]);
|
|
312
|
+
i += 1;
|
|
313
|
+
}
|
|
314
|
+
i += 1; // closing fence (absent while a block is still streaming)
|
|
315
|
+
out.push('<pre><code>' + escapeHtml(code.join('\n')) + '</code></pre>');
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
var listRe = UL_ITEM.test(line) ? UL_ITEM : (OL_ITEM.test(line) ? OL_ITEM : null);
|
|
319
|
+
if (listRe) {
|
|
320
|
+
flush();
|
|
321
|
+
var tag = listRe === UL_ITEM ? 'ul' : 'ol';
|
|
322
|
+
var items = [];
|
|
323
|
+
while (i < lines.length && listRe.test(lines[i])) {
|
|
324
|
+
items.push('<li>' + inlineMarkdown(listRe.exec(lines[i])[1]) + '</li>');
|
|
325
|
+
i += 1;
|
|
326
|
+
}
|
|
327
|
+
out.push('<' + tag + '>' + items.join('') + '</' + tag + '>');
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
var heading = HEADING.exec(line);
|
|
331
|
+
if (heading) {
|
|
332
|
+
flush();
|
|
333
|
+
out.push('<p><strong>' + inlineMarkdown(heading[1]) + '</strong></p>');
|
|
334
|
+
} else if (!line.trim()) {
|
|
335
|
+
flush();
|
|
336
|
+
} else {
|
|
337
|
+
para.push(line);
|
|
338
|
+
}
|
|
339
|
+
i += 1;
|
|
340
|
+
}
|
|
341
|
+
flush();
|
|
342
|
+
return out.join('');
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** Puts the streaming caret at the end of the last paragraph or list item. */
|
|
346
|
+
function withCaret(html) {
|
|
347
|
+
if (/<\/p>$/.test(html)) { return html.slice(0, -4) + CARET + '</p>'; }
|
|
348
|
+
if (/<\/li><\/[ou]l>$/.test(html)) { return html.slice(0, -10) + CARET + html.slice(-10); }
|
|
349
|
+
return html + CARET;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// ── Line diff (longest common subsequence) ──
|
|
353
|
+
|
|
354
|
+
function toLines(value) {
|
|
355
|
+
if (value == null) { return []; }
|
|
356
|
+
var text = typeof value === 'string' ? value : JSON.stringify(value, null, 2);
|
|
357
|
+
text = text.replace(/\r\n?/g, '\n');
|
|
358
|
+
if (text === '') { return []; }
|
|
359
|
+
if (text.charAt(text.length - 1) === '\n') { text = text.slice(0, -1); }
|
|
360
|
+
return text.split('\n');
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function diffLines(before, after) {
|
|
364
|
+
var a = toLines(before);
|
|
365
|
+
var b = toLines(after);
|
|
366
|
+
var out = [];
|
|
367
|
+
var start = 0;
|
|
368
|
+
while (start < a.length && start < b.length && a[start] === b[start]) {
|
|
369
|
+
out.push({ type: 'same', text: a[start] });
|
|
370
|
+
start += 1;
|
|
371
|
+
}
|
|
372
|
+
var endA = a.length;
|
|
373
|
+
var endB = b.length;
|
|
374
|
+
while (endA > start && endB > start && a[endA - 1] === b[endB - 1]) {
|
|
375
|
+
endA -= 1;
|
|
376
|
+
endB -= 1;
|
|
377
|
+
}
|
|
378
|
+
var x = a.slice(start, endA);
|
|
379
|
+
var y = b.slice(start, endB);
|
|
380
|
+
var n = x.length;
|
|
381
|
+
var m = y.length;
|
|
382
|
+
var i;
|
|
383
|
+
var j;
|
|
384
|
+
if (n * m > LCS_LIMIT) {
|
|
385
|
+
x.forEach(function (t) { out.push({ type: 'del', text: t }); });
|
|
386
|
+
y.forEach(function (t) { out.push({ type: 'add', text: t }); });
|
|
387
|
+
} else {
|
|
388
|
+
var w = m + 1;
|
|
389
|
+
var dp = new Int32Array((n + 1) * w);
|
|
390
|
+
for (i = n - 1; i >= 0; i -= 1) {
|
|
391
|
+
for (j = m - 1; j >= 0; j -= 1) {
|
|
392
|
+
dp[i * w + j] = x[i] === y[j]
|
|
393
|
+
? dp[(i + 1) * w + j + 1] + 1
|
|
394
|
+
: Math.max(dp[(i + 1) * w + j], dp[i * w + j + 1]);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
i = 0;
|
|
398
|
+
j = 0;
|
|
399
|
+
while (i < n && j < m) {
|
|
400
|
+
if (x[i] === y[j]) {
|
|
401
|
+
out.push({ type: 'same', text: x[i] });
|
|
402
|
+
i += 1;
|
|
403
|
+
j += 1;
|
|
404
|
+
} else if (dp[(i + 1) * w + j] >= dp[i * w + j + 1]) {
|
|
405
|
+
out.push({ type: 'del', text: x[i] });
|
|
406
|
+
i += 1;
|
|
407
|
+
} else {
|
|
408
|
+
out.push({ type: 'add', text: y[j] });
|
|
409
|
+
j += 1;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
for (; i < n; i += 1) { out.push({ type: 'del', text: x[i] }); }
|
|
413
|
+
for (; j < m; j += 1) { out.push({ type: 'add', text: y[j] }); }
|
|
414
|
+
}
|
|
415
|
+
for (i = endA; i < a.length; i += 1) { out.push({ type: 'same', text: a[i] }); }
|
|
416
|
+
return out;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
var DIFF_SIGN = { add: '+', del: '−', same: ' ' };
|
|
420
|
+
var DIFF_SR = { add: 'Added: ', del: 'Removed: ', same: '' };
|
|
421
|
+
|
|
422
|
+
function diffLineHtml(line) {
|
|
423
|
+
return '<span class="mp-ai-diff-line is-' + line.type + '">'
|
|
424
|
+
+ '<span class="mp-ai-diff-sign" aria-hidden="true">' + DIFF_SIGN[line.type] + '</span>'
|
|
425
|
+
+ (DIFF_SR[line.type] ? '<span class="mp-ai-sr-only">' + DIFF_SR[line.type] + '</span>' : '')
|
|
426
|
+
+ escapeHtml(line.text) + '</span>';
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/** Replaces a previous listener of the same kind that MpKit.ai bound to el. */
|
|
430
|
+
function bind(el, type, fn) {
|
|
431
|
+
var store = el.__mpAiListeners || (el.__mpAiListeners = {});
|
|
432
|
+
if (store[type]) { el.removeEventListener(type, store[type]); }
|
|
433
|
+
store[type] = fn;
|
|
434
|
+
el.addEventListener(type, fn);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function actionTarget(ev) {
|
|
438
|
+
var t = ev && ev.target;
|
|
439
|
+
return t && typeof t.closest === 'function' ? t.closest('[data-mp-ai-action]') : null;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function badgeHtml(label) {
|
|
443
|
+
return '<span class="mp-ai-badge">' + SPARKLE + escapeHtml(label || 'Assistant') + '</span>';
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function thinkingHtml(label) {
|
|
447
|
+
return '<span class="mp-ai-thinking" role="status">' + escapeHtml(label || 'Thinking…') + '</span>';
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
var edgeEl = null;
|
|
451
|
+
|
|
452
|
+
var ai = {
|
|
453
|
+
|
|
454
|
+
/** GET-style status of the Assistant: { enabled, provider, model, capabilities }. */
|
|
455
|
+
status: function () {
|
|
456
|
+
var hb = hbClient();
|
|
457
|
+
if (!hb || typeof hb.request !== 'function') {
|
|
458
|
+
return Promise.reject(new Error('MpKit.ai needs the Homebridge plugin UI (window.homebridge)'));
|
|
459
|
+
}
|
|
460
|
+
return Promise.resolve(hb.request('/ai/status'));
|
|
461
|
+
},
|
|
462
|
+
|
|
463
|
+
/** Explains a device/plugin error. body: { error, context?, device? } → { text, usage } */
|
|
464
|
+
explain: function (body, opts) { return aiRequest('/ai/explain', body, opts); },
|
|
465
|
+
|
|
466
|
+
/** Free-form question. body: { prompt, context? } → { text, usage } */
|
|
467
|
+
ask: function (body, opts) { return aiRequest('/ai/ask', body, opts); },
|
|
468
|
+
|
|
469
|
+
/** Generates plugin config. body: { schema, request, current? } → { config, explanation, usage } */
|
|
470
|
+
config: function (body, opts) { return aiRequest('/ai/config', body, opts); },
|
|
471
|
+
|
|
472
|
+
markdown: markdown,
|
|
473
|
+
diffLines: diffLines,
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* Returns HTML for an Assistant button (sparkle icon + escaped label).
|
|
477
|
+
* opts: { label = 'Ask Assistant', id, className, size: 'sm' | 'lg', title }
|
|
478
|
+
*/
|
|
479
|
+
renderButton: function (opts) {
|
|
480
|
+
opts = opts || {};
|
|
481
|
+
var cls = 'mp-ai-button'
|
|
482
|
+
+ (opts.size === 'sm' || opts.size === 'lg' ? ' mp-ai-button-' + opts.size : '')
|
|
483
|
+
+ (opts.className ? ' ' + escapeHtml(opts.className) : '');
|
|
484
|
+
return '<button type="button" class="' + cls + '"'
|
|
485
|
+
+ (opts.id ? ' id="' + escapeHtml(opts.id) + '"' : '')
|
|
486
|
+
+ (opts.title ? ' title="' + escapeHtml(opts.title) + '"' : '')
|
|
487
|
+
+ '>' + SPARKLE + '<span>' + escapeHtml(opts.label || 'Ask Assistant') + '</span></button>';
|
|
488
|
+
},
|
|
489
|
+
|
|
490
|
+
/** Returns HTML for a small "Assistant" badge. */
|
|
491
|
+
renderBadge: badgeHtml,
|
|
492
|
+
|
|
493
|
+
/** Returns HTML for a pulsing "Thinking…" indicator (a status live region). */
|
|
494
|
+
renderThinking: thinkingHtml,
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* Renders an answer panel into el and returns a controller for streaming:
|
|
498
|
+
* const answer = MpKit.ai.renderAnswer(el);
|
|
499
|
+
* const res = await MpKit.ai.ask({ prompt }, { onChunk: answer.append });
|
|
500
|
+
* answer.done(res.text);
|
|
501
|
+
* opts: { label = 'Assistant', title, text, streaming = true, note }
|
|
502
|
+
* The text is rendered as escaped, safe markdown.
|
|
503
|
+
*/
|
|
504
|
+
renderAnswer: function (el, opts) {
|
|
505
|
+
opts = opts || {};
|
|
506
|
+
var note = opts.note === undefined ? DEFAULT_NOTE : opts.note;
|
|
507
|
+
el.innerHTML = '<section class="mp-ai-panel" aria-label="' + escapeHtml(opts.label || 'Assistant') + ' answer">'
|
|
508
|
+
+ '<div class="mp-ai-panel-header">' + badgeHtml(opts.label)
|
|
509
|
+
+ (opts.title ? '<p class="mp-ai-panel-title">' + escapeHtml(opts.title) + '</p>' : '')
|
|
510
|
+
+ '</div>'
|
|
511
|
+
+ '<div class="mp-ai-panel-body mp-ai-prose"></div>'
|
|
512
|
+
+ '<p class="mp-ai-error" role="alert" hidden></p>'
|
|
513
|
+
+ (note ? '<p class="mp-ai-panel-note">' + escapeHtml(note) + '</p>' : '')
|
|
514
|
+
+ '</section>';
|
|
515
|
+
var panel = el.querySelector('.mp-ai-panel');
|
|
516
|
+
var body = el.querySelector('.mp-ai-panel-body');
|
|
517
|
+
var errorEl = el.querySelector('.mp-ai-error');
|
|
518
|
+
var text = opts.text == null ? '' : String(opts.text);
|
|
519
|
+
var streaming = opts.streaming !== false;
|
|
520
|
+
|
|
521
|
+
function paint() {
|
|
522
|
+
var html = markdown(text);
|
|
523
|
+
if (streaming) { html = text ? withCaret(html) : thinkingHtml(); }
|
|
524
|
+
body.innerHTML = html;
|
|
525
|
+
panel.classList.toggle('is-streaming', streaming);
|
|
526
|
+
panel.setAttribute('aria-busy', streaming ? 'true' : 'false');
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
var controller = {
|
|
530
|
+
append: function (delta) {
|
|
531
|
+
text += delta == null ? '' : String(delta);
|
|
532
|
+
paint();
|
|
533
|
+
return controller;
|
|
534
|
+
},
|
|
535
|
+
setText: function (value) {
|
|
536
|
+
text = value == null ? '' : String(value);
|
|
537
|
+
paint();
|
|
538
|
+
return controller;
|
|
539
|
+
},
|
|
540
|
+
done: function (result) {
|
|
541
|
+
var final = result && typeof result === 'object' ? result.text : result;
|
|
542
|
+
if (typeof final === 'string' && final) { text = final; }
|
|
543
|
+
streaming = false;
|
|
544
|
+
paint();
|
|
545
|
+
return controller;
|
|
546
|
+
},
|
|
547
|
+
error: function (err) {
|
|
548
|
+
streaming = false;
|
|
549
|
+
paint();
|
|
550
|
+
errorEl.textContent = errorMessage(err, DEFAULT_ERROR);
|
|
551
|
+
errorEl.hidden = false;
|
|
552
|
+
return controller;
|
|
553
|
+
},
|
|
554
|
+
getText: function () { return text; },
|
|
555
|
+
};
|
|
556
|
+
paint();
|
|
557
|
+
return controller;
|
|
558
|
+
},
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* Renders a line diff with Apply / Reject buttons into el.
|
|
562
|
+
* before/after may be strings or JSON-serialisable values (pretty-printed).
|
|
563
|
+
* onApply / onReject receive { before, after, lines }; they may return a
|
|
564
|
+
* promise — if it rejects, the buttons are re-enabled and the error shown.
|
|
565
|
+
* opts: { before, after, onApply, onReject, title, applyLabel, rejectLabel }
|
|
566
|
+
* Returns { lines, added, removed }.
|
|
567
|
+
*/
|
|
568
|
+
renderDiff: function (el, opts) {
|
|
569
|
+
opts = opts || {};
|
|
570
|
+
var lines = diffLines(opts.before, opts.after);
|
|
571
|
+
var added = lines.filter(function (l) { return l.type === 'add'; }).length;
|
|
572
|
+
var removed = lines.filter(function (l) { return l.type === 'del'; }).length;
|
|
573
|
+
var hasActions = typeof opts.onApply === 'function' || typeof opts.onReject === 'function';
|
|
574
|
+
var title = opts.title || 'Suggested changes';
|
|
575
|
+
|
|
576
|
+
el.innerHTML = '<div class="mp-ai-diff">'
|
|
577
|
+
+ '<div class="mp-ai-diff-header">' + badgeHtml(opts.label)
|
|
578
|
+
+ '<span class="mp-ai-diff-title">' + escapeHtml(title) + '</span>'
|
|
579
|
+
+ '<span class="mp-ai-diff-stats">'
|
|
580
|
+
+ '<span class="mp-ai-diff-stat-add">+' + added + '<span class="mp-ai-sr-only"> added</span></span>'
|
|
581
|
+
+ '<span class="mp-ai-diff-stat-del">−' + removed + '<span class="mp-ai-sr-only"> removed</span></span>'
|
|
582
|
+
+ '</span></div>'
|
|
583
|
+
+ '<pre class="mp-ai-diff-body" tabindex="0" aria-label="' + escapeHtml(title) + '"><code>'
|
|
584
|
+
+ (added || removed ? lines.map(diffLineHtml).join('') : '<span class="mp-ai-diff-line">No changes</span>')
|
|
585
|
+
+ '</code></pre>'
|
|
586
|
+
+ (hasActions
|
|
587
|
+
? '<div class="mp-ai-diff-actions">'
|
|
588
|
+
+ '<span class="mp-ai-diff-status" role="status"></span>'
|
|
589
|
+
+ '<button type="button" class="mp-ai-action" data-mp-ai-action="reject">'
|
|
590
|
+
+ escapeHtml(opts.rejectLabel || 'Reject') + '</button>'
|
|
591
|
+
+ '<button type="button" class="mp-ai-action mp-ai-action-primary" data-mp-ai-action="apply">'
|
|
592
|
+
+ escapeHtml(opts.applyLabel || 'Apply') + '</button>'
|
|
593
|
+
+ '</div>'
|
|
594
|
+
: '')
|
|
595
|
+
+ '</div>';
|
|
596
|
+
|
|
597
|
+
var result = { lines: lines, added: added, removed: removed };
|
|
598
|
+
if (!hasActions) { return result; }
|
|
599
|
+
|
|
600
|
+
var root = el.querySelector('.mp-ai-diff');
|
|
601
|
+
var statusEl = el.querySelector('.mp-ai-diff-status');
|
|
602
|
+
var applyBtn = el.querySelector('[data-mp-ai-action="apply"]');
|
|
603
|
+
var rejectBtn = el.querySelector('[data-mp-ai-action="reject"]');
|
|
604
|
+
var settled = false;
|
|
605
|
+
var busy = false;
|
|
606
|
+
|
|
607
|
+
function setDisabled(disabled) {
|
|
608
|
+
applyBtn.disabled = disabled;
|
|
609
|
+
rejectBtn.disabled = disabled;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
bind(el, 'click', function (ev) {
|
|
613
|
+
var btn = actionTarget(ev);
|
|
614
|
+
if (!btn || settled || busy) { return; }
|
|
615
|
+
var action = btn.getAttribute('data-mp-ai-action');
|
|
616
|
+
if (action !== 'apply' && action !== 'reject') { return; }
|
|
617
|
+
var cb = action === 'apply' ? opts.onApply : opts.onReject;
|
|
618
|
+
busy = true;
|
|
619
|
+
setDisabled(true);
|
|
620
|
+
statusEl.textContent = '';
|
|
621
|
+
Promise.resolve()
|
|
622
|
+
.then(function () {
|
|
623
|
+
return typeof cb === 'function'
|
|
624
|
+
? cb({ before: opts.before, after: opts.after, lines: lines })
|
|
625
|
+
: undefined;
|
|
626
|
+
})
|
|
627
|
+
.then(function () {
|
|
628
|
+
busy = false;
|
|
629
|
+
settled = true;
|
|
630
|
+
root.classList.add(action === 'apply' ? 'is-applied' : 'is-rejected');
|
|
631
|
+
statusEl.textContent = action === 'apply' ? 'Applied' : 'Rejected';
|
|
632
|
+
}, function (err) {
|
|
633
|
+
busy = false;
|
|
634
|
+
setDisabled(false);
|
|
635
|
+
statusEl.textContent = errorMessage(err, 'Something went wrong');
|
|
636
|
+
});
|
|
637
|
+
});
|
|
638
|
+
return result;
|
|
639
|
+
},
|
|
640
|
+
|
|
641
|
+
/**
|
|
642
|
+
* Renders a chat (log + input) into el. Each message is sent with
|
|
643
|
+
* MpKit.ai.ask({ prompt, context }) and streamed into the log; the context is
|
|
644
|
+
* opts.context plus the recent conversation. Pass opts.onSend(prompt,
|
|
645
|
+
* { onChunk, history }) to use another transport; it may resolve with a
|
|
646
|
+
* string or { text }.
|
|
647
|
+
* opts: { label = 'Assistant', context, placeholder, emptyText, onSend }
|
|
648
|
+
* Returns { send(text?), clear(), messages() }.
|
|
649
|
+
*/
|
|
650
|
+
renderChat: function (el, opts) {
|
|
651
|
+
opts = opts || {};
|
|
652
|
+
uid += 1;
|
|
653
|
+
var inputId = 'mp-ai-chat-input-' + uid;
|
|
654
|
+
el.innerHTML = '<div class="mp-ai-chat">'
|
|
655
|
+
+ '<div class="mp-ai-chat-header">' + badgeHtml(opts.label) + '</div>'
|
|
656
|
+
+ '<div class="mp-ai-chat-log" role="log" aria-live="polite" aria-label="'
|
|
657
|
+
+ escapeHtml(opts.label || 'Assistant') + ' conversation"></div>'
|
|
658
|
+
+ '<form class="mp-ai-chat-form">'
|
|
659
|
+
+ '<label class="mp-ai-sr-only" for="' + inputId + '">Message</label>'
|
|
660
|
+
+ '<textarea class="mp-ai-chat-input" id="' + inputId + '" rows="1" placeholder="'
|
|
661
|
+
+ escapeHtml(opts.placeholder || 'Ask the Assistant…') + '"></textarea>'
|
|
662
|
+
+ '<button type="submit" class="mp-ai-button">' + SPARKLE + '<span>Send</span></button>'
|
|
663
|
+
+ '</form></div>';
|
|
664
|
+
|
|
665
|
+
var log = el.querySelector('.mp-ai-chat-log');
|
|
666
|
+
var input = el.querySelector('.mp-ai-chat-input');
|
|
667
|
+
var sendBtn = el.querySelector('.mp-ai-chat-form .mp-ai-button');
|
|
668
|
+
var messages = [];
|
|
669
|
+
var busy = false;
|
|
670
|
+
|
|
671
|
+
function messageHtml(m) {
|
|
672
|
+
if (m.role === 'user') {
|
|
673
|
+
return '<div class="mp-ai-chat-msg is-user"><div class="mp-ai-chat-bubble">'
|
|
674
|
+
+ '<span class="mp-ai-sr-only">You: </span>'
|
|
675
|
+
+ escapeHtml(m.text).replace(/\n/g, '<br>') + '</div></div>';
|
|
676
|
+
}
|
|
677
|
+
var html = markdown(m.text);
|
|
678
|
+
if (m.streaming) { html = m.text ? withCaret(html) : thinkingHtml(); }
|
|
679
|
+
if (m.error) { html += '<p class="mp-ai-error" role="alert">' + escapeHtml(m.error) + '</p>'; }
|
|
680
|
+
return '<div class="mp-ai-chat-msg is-assistant"><div class="mp-ai-chat-bubble mp-ai-prose">'
|
|
681
|
+
+ '<span class="mp-ai-sr-only">' + escapeHtml(opts.label || 'Assistant') + ': </span>'
|
|
682
|
+
+ html + '</div></div>';
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
function paint() {
|
|
686
|
+
log.innerHTML = messages.length
|
|
687
|
+
? messages.map(messageHtml).join('')
|
|
688
|
+
: '<p class="mp-ai-chat-empty">' + escapeHtml(opts.emptyText || 'Ask the Assistant a question.') + '</p>';
|
|
689
|
+
log.setAttribute('aria-busy', busy ? 'true' : 'false');
|
|
690
|
+
log.scrollTop = log.scrollHeight;
|
|
691
|
+
sendBtn.disabled = busy;
|
|
692
|
+
sendBtn.setAttribute('aria-busy', busy ? 'true' : 'false');
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
function defaultSend(prompt, ctx) {
|
|
696
|
+
var transcript = ctx.history.slice(-10).map(function (m) {
|
|
697
|
+
return (m.role === 'user' ? 'User' : 'Assistant') + ': ' + m.text;
|
|
698
|
+
}).join('\n');
|
|
699
|
+
var context = [opts.context, transcript ? 'Conversation so far:\n' + transcript : '']
|
|
700
|
+
.filter(Boolean).join('\n\n');
|
|
701
|
+
var body = { prompt: prompt };
|
|
702
|
+
if (context) { body.context = context; }
|
|
703
|
+
return ai.ask(body, { onChunk: ctx.onChunk });
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
function send(text) {
|
|
707
|
+
var prompt = String(text == null ? input.value : text).trim();
|
|
708
|
+
if (!prompt || busy) { return Promise.resolve(null); }
|
|
709
|
+
if (text == null) { input.value = ''; }
|
|
710
|
+
var history = messages.filter(function (m) { return !m.error; }).map(function (m) {
|
|
711
|
+
return { role: m.role, text: m.text };
|
|
712
|
+
});
|
|
713
|
+
var reply = { role: 'assistant', text: '', streaming: true };
|
|
714
|
+
messages.push({ role: 'user', text: prompt }, reply);
|
|
715
|
+
busy = true;
|
|
716
|
+
paint();
|
|
717
|
+
var handler = typeof opts.onSend === 'function' ? opts.onSend : defaultSend;
|
|
718
|
+
return Promise.resolve()
|
|
719
|
+
.then(function () {
|
|
720
|
+
return handler(prompt, {
|
|
721
|
+
history: history,
|
|
722
|
+
onChunk: function (delta) {
|
|
723
|
+
reply.text += delta == null ? '' : String(delta);
|
|
724
|
+
paint();
|
|
725
|
+
},
|
|
726
|
+
});
|
|
727
|
+
})
|
|
728
|
+
.then(function (res) {
|
|
729
|
+
var final = res && typeof res === 'object' ? res.text : res;
|
|
730
|
+
if (typeof final === 'string' && final) { reply.text = final; }
|
|
731
|
+
}, function (err) {
|
|
732
|
+
reply.error = errorMessage(err, DEFAULT_ERROR);
|
|
733
|
+
})
|
|
734
|
+
.then(function () {
|
|
735
|
+
reply.streaming = false;
|
|
736
|
+
busy = false;
|
|
737
|
+
paint();
|
|
738
|
+
return reply.error ? null : reply.text;
|
|
739
|
+
});
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
bind(el, 'submit', function (ev) {
|
|
743
|
+
ev.preventDefault();
|
|
744
|
+
send();
|
|
745
|
+
});
|
|
746
|
+
bind(el, 'keydown', function (ev) {
|
|
747
|
+
var t = ev.target;
|
|
748
|
+
if (ev.key === 'Enter' && !ev.shiftKey && !ev.isComposing && t === input) {
|
|
749
|
+
ev.preventDefault();
|
|
750
|
+
send();
|
|
751
|
+
}
|
|
752
|
+
});
|
|
753
|
+
paint();
|
|
754
|
+
|
|
755
|
+
return {
|
|
756
|
+
send: send,
|
|
757
|
+
clear: function () {
|
|
758
|
+
messages = [];
|
|
759
|
+
paint();
|
|
760
|
+
},
|
|
761
|
+
messages: function () {
|
|
762
|
+
return messages.map(function (m) {
|
|
763
|
+
return { role: m.role, text: m.text, error: m.error };
|
|
764
|
+
});
|
|
765
|
+
},
|
|
766
|
+
};
|
|
767
|
+
},
|
|
768
|
+
|
|
769
|
+
/**
|
|
770
|
+
* Shows (default) or hides a fixed glow around the page edges, e.g. while
|
|
771
|
+
* the Assistant works. Returns the element.
|
|
772
|
+
*/
|
|
773
|
+
edgeGlow: function (show) {
|
|
774
|
+
if (show === false) {
|
|
775
|
+
if (edgeEl) { edgeEl.hidden = true; }
|
|
776
|
+
return edgeEl;
|
|
777
|
+
}
|
|
778
|
+
if (!edgeEl) {
|
|
779
|
+
edgeEl = document.createElement('div');
|
|
780
|
+
edgeEl.className = 'mp-ai-edge-glow';
|
|
781
|
+
edgeEl.setAttribute('aria-hidden', 'true');
|
|
782
|
+
document.body.appendChild(edgeEl);
|
|
783
|
+
}
|
|
784
|
+
edgeEl.hidden = false;
|
|
785
|
+
return edgeEl;
|
|
786
|
+
},
|
|
787
|
+
|
|
788
|
+
};
|
|
789
|
+
|
|
790
|
+
MpKit.ai = ai;
|
|
791
|
+
|
|
175
792
|
global.MpKit = MpKit;
|
|
176
793
|
|
|
177
794
|
})(typeof window !== 'undefined' ? window : globalThis);
|