@loupekit/sdk 0.10.26 → 0.10.27
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.global.js +522 -10
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +522 -10
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -773,6 +773,64 @@ var STYLES = (
|
|
|
773
773
|
object-fit: cover; cursor: pointer; background: var(--bg-2);
|
|
774
774
|
}
|
|
775
775
|
|
|
776
|
+
/* ------------------------------------------------------------- companion chat */
|
|
777
|
+
/* Same inset as the Activity view \u2014 without it the composer and its Send button sit
|
|
778
|
+
flush against the panel's edge, which reads as a clipped button. */
|
|
779
|
+
.chat-view { display: flex; flex-direction: column; gap: 8px; height: 100%; min-height: 0; padding: 10px 12px 16px; }
|
|
780
|
+
.chat-tray {
|
|
781
|
+
display: flex; flex-direction: column; gap: 4px; padding: 8px;
|
|
782
|
+
border: 1px solid var(--line); border-radius: 10px; background: var(--bg-2);
|
|
783
|
+
}
|
|
784
|
+
.tray-head { display: flex; align-items: center; gap: 6px; }
|
|
785
|
+
.tray-title { font-size: 11px; color: var(--muted); }
|
|
786
|
+
.tray-spacer, .chat-spacer { flex: 1; }
|
|
787
|
+
.tray-chip {
|
|
788
|
+
display: flex; align-items: center; gap: 6px; padding: 4px 6px;
|
|
789
|
+
border: 1px solid var(--line); border-radius: 8px; background: var(--bg); font-size: 11px;
|
|
790
|
+
}
|
|
791
|
+
.tray-chip.off { opacity: .5; }
|
|
792
|
+
.tray-thumb { width: 24px; height: 24px; border-radius: 4px; object-fit: cover; flex: none; }
|
|
793
|
+
.tray-kind { font-size: 10px; color: var(--muted); text-transform: uppercase; letter-spacing: .04em; flex: none; }
|
|
794
|
+
.tray-label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: ui-monospace, monospace; }
|
|
795
|
+
.tray-nudge, .tray-x {
|
|
796
|
+
border: 0; background: transparent; color: var(--muted); cursor: pointer;
|
|
797
|
+
font-size: 11px; padding: 1px 4px; border-radius: 4px; line-height: 1.2;
|
|
798
|
+
}
|
|
799
|
+
.tray-nudge:disabled { opacity: .3; cursor: default; }
|
|
800
|
+
.tray-nudge:not(:disabled):hover, .tray-x:hover { color: var(--accent); background: var(--accent-soft); }
|
|
801
|
+
.chat-log { flex: 1; min-height: 0; overflow-y: auto; display: flex; flex-direction: column; gap: 8px; padding: 2px; }
|
|
802
|
+
.chat-empty { color: var(--muted); font-size: 12px; padding: 12px 4px; line-height: 1.5; }
|
|
803
|
+
.chat-msg { padding: 8px 10px; border-radius: 10px; background: var(--bg-2); border: 1px solid var(--line); }
|
|
804
|
+
.chat-msg.mine { background: var(--accent-soft); border-color: var(--accent); }
|
|
805
|
+
.chat-head { display: flex; align-items: baseline; gap: 6px; margin-bottom: 3px; }
|
|
806
|
+
.chat-who { font-size: 11px; }
|
|
807
|
+
.chat-when { font-size: 10px; color: var(--muted); }
|
|
808
|
+
.chat-ctx {
|
|
809
|
+
font-size: 10px; color: var(--accent); border: 1px solid var(--accent);
|
|
810
|
+
border-radius: 999px; padding: 0 5px; margin-left: auto;
|
|
811
|
+
}
|
|
812
|
+
.chat-body { font-size: 12px; white-space: pre-wrap; word-break: break-word; }
|
|
813
|
+
.chat-compose { border-top: 1px solid var(--line); padding-top: 8px; }
|
|
814
|
+
.chat-in {
|
|
815
|
+
width: 100%; resize: vertical; min-height: 44px; padding: 7px 9px;
|
|
816
|
+
border: 1px solid var(--line); border-radius: 9px; background: var(--bg);
|
|
817
|
+
color: var(--ink); font: inherit; font-size: 12.5px;
|
|
818
|
+
}
|
|
819
|
+
.chat-in:focus { outline: none; border-color: var(--accent); }
|
|
820
|
+
.chat-foot { display: flex; align-items: center; gap: 6px; margin-top: 6px; }
|
|
821
|
+
.chat-add, .chat-mic, .chat-send {
|
|
822
|
+
padding: 4px 9px; border: 1px solid var(--line); border-radius: 7px;
|
|
823
|
+
background: var(--bg); color: var(--ink); font-size: 11px; cursor: pointer;
|
|
824
|
+
}
|
|
825
|
+
.chat-add:hover, .chat-mic:hover { border-color: var(--accent); color: var(--accent); }
|
|
826
|
+
.chat-mic.on { border-color: var(--err, #f2555a); color: #f2555a; background: color-mix(in srgb, #f2555a 14%, transparent); }
|
|
827
|
+
.chat-send { background: var(--accent); border-color: var(--accent); color: #fff; font-weight: 600; }
|
|
828
|
+
.chat-send:hover { opacity: .9; }
|
|
829
|
+
.chat-rec { display: inline-flex; align-items: center; gap: 5px; font-size: 11px; color: #f2555a; }
|
|
830
|
+
.chat-rec-dot { width: 7px; height: 7px; border-radius: 50%; background: #f2555a; animation: chatpulse 1.2s ease-in-out infinite; }
|
|
831
|
+
@keyframes chatpulse { 0%, 100% { opacity: 1; } 50% { opacity: .35; } }
|
|
832
|
+
.chat-err { font-size: 11px; color: #f2555a; }
|
|
833
|
+
|
|
776
834
|
/* reactions */
|
|
777
835
|
.rxns { display: flex; flex-wrap: wrap; align-items: center; gap: 4px; margin-top: 6px; position: relative; }
|
|
778
836
|
.rxn {
|
|
@@ -3355,6 +3413,87 @@ function initialsOf(name) {
|
|
|
3355
3413
|
return (words[0].slice(0, 1) + words[words.length - 1].slice(0, 1)).toUpperCase();
|
|
3356
3414
|
}
|
|
3357
3415
|
|
|
3416
|
+
// ../shared/dist/companion-tray.js
|
|
3417
|
+
function emptyTray() {
|
|
3418
|
+
return { items: [] };
|
|
3419
|
+
}
|
|
3420
|
+
function addToTray(state, item) {
|
|
3421
|
+
const full = { include: true, ...item };
|
|
3422
|
+
const at = state.items.findIndex((i) => i.id === full.id);
|
|
3423
|
+
if (at < 0)
|
|
3424
|
+
return { items: [...state.items, full] };
|
|
3425
|
+
const items = [...state.items];
|
|
3426
|
+
items[at] = { ...items[at], ...full, include: items[at].include };
|
|
3427
|
+
return { items };
|
|
3428
|
+
}
|
|
3429
|
+
function removeFromTray(state, id) {
|
|
3430
|
+
return { items: state.items.filter((i) => i.id !== id) };
|
|
3431
|
+
}
|
|
3432
|
+
function toggleInclude(state, id) {
|
|
3433
|
+
return { items: state.items.map((i) => i.id === id ? { ...i, include: !i.include } : i) };
|
|
3434
|
+
}
|
|
3435
|
+
function moveInTray(state, id, to) {
|
|
3436
|
+
const from = state.items.findIndex((i) => i.id === id);
|
|
3437
|
+
if (from < 0)
|
|
3438
|
+
return state;
|
|
3439
|
+
const target = Math.max(0, Math.min(state.items.length - 1, to));
|
|
3440
|
+
if (target === from)
|
|
3441
|
+
return state;
|
|
3442
|
+
const items = [...state.items];
|
|
3443
|
+
const [moved] = items.splice(from, 1);
|
|
3444
|
+
items.splice(target, 0, moved);
|
|
3445
|
+
return { items };
|
|
3446
|
+
}
|
|
3447
|
+
function nudgeInTray(state, id, delta) {
|
|
3448
|
+
const at = state.items.findIndex((i) => i.id === id);
|
|
3449
|
+
if (at < 0)
|
|
3450
|
+
return state;
|
|
3451
|
+
return moveInTray(state, id, at + delta);
|
|
3452
|
+
}
|
|
3453
|
+
function includedItems(state) {
|
|
3454
|
+
return state.items.filter((i) => i.include);
|
|
3455
|
+
}
|
|
3456
|
+
function trayCount(state) {
|
|
3457
|
+
return { total: state.items.length, included: includedItems(state).length };
|
|
3458
|
+
}
|
|
3459
|
+
function clearTray() {
|
|
3460
|
+
return { items: [] };
|
|
3461
|
+
}
|
|
3462
|
+
function trayPayload(state) {
|
|
3463
|
+
return includedItems(state).map((i) => ({
|
|
3464
|
+
kind: i.kind,
|
|
3465
|
+
id: i.ref ?? i.id,
|
|
3466
|
+
url: i.url,
|
|
3467
|
+
label: i.label
|
|
3468
|
+
}));
|
|
3469
|
+
}
|
|
3470
|
+
function traySummary(state) {
|
|
3471
|
+
const { total, included } = trayCount(state);
|
|
3472
|
+
if (!total)
|
|
3473
|
+
return "No context gathered";
|
|
3474
|
+
if (included === total)
|
|
3475
|
+
return `${total} context${total === 1 ? "" : "s"}`;
|
|
3476
|
+
return `${included} of ${total} included`;
|
|
3477
|
+
}
|
|
3478
|
+
function voiceSupport(env) {
|
|
3479
|
+
if (!env.isSecureContext)
|
|
3480
|
+
return "insecure";
|
|
3481
|
+
return env.hasCtor ? "supported" : "unsupported";
|
|
3482
|
+
}
|
|
3483
|
+
function voiceMessage(support) {
|
|
3484
|
+
if (support === "supported")
|
|
3485
|
+
return null;
|
|
3486
|
+
if (support === "insecure")
|
|
3487
|
+
return "Dictation needs a secure page (https, or localhost).";
|
|
3488
|
+
return "This browser cannot dictate \u2014 the Web Speech API is not available.";
|
|
3489
|
+
}
|
|
3490
|
+
function elapsedLabel(ms) {
|
|
3491
|
+
const total = Math.max(0, Math.floor(ms / 1e3));
|
|
3492
|
+
const mm = Math.floor(total / 60);
|
|
3493
|
+
const ss = total % 60;
|
|
3494
|
+
return `${mm}:${String(ss).padStart(2, "0")}`;
|
|
3495
|
+
}
|
|
3496
|
+
|
|
3358
3497
|
// ../shared/dist/index.js
|
|
3359
3498
|
var COMMENT_STAGES = ["queue", "todo", "in_progress", "in_review", "resolved"];
|
|
3360
3499
|
var STAGE_LABELS = {
|
|
@@ -3708,7 +3847,7 @@ var HttpAdapter = class {
|
|
|
3708
3847
|
};
|
|
3709
3848
|
|
|
3710
3849
|
// src/app.ts
|
|
3711
|
-
var SDK_VERSION = true ? "0.10.
|
|
3850
|
+
var SDK_VERSION = true ? "0.10.27" : "dev";
|
|
3712
3851
|
var ACCENTS = [
|
|
3713
3852
|
{ id: "indigo", dark: "#6b73e6", light: "#4a55d6", soft: "rgba(107,115,230,0.12)" },
|
|
3714
3853
|
{ id: "violet", dark: "#a06be6", light: "#7c3fd4", soft: "rgba(160,107,230,0.14)" },
|
|
@@ -3752,6 +3891,10 @@ var TOUR = [
|
|
|
3752
3891
|
}
|
|
3753
3892
|
];
|
|
3754
3893
|
var HINTS = {
|
|
3894
|
+
chat: {
|
|
3895
|
+
title: "Talk to the agent",
|
|
3896
|
+
body: "Gather what you are looking at and send it in one go. The agent gets it on its very next step \u2014 not after it finishes."
|
|
3897
|
+
},
|
|
3755
3898
|
home: { title: "Your triage at a glance", body: "The tiles count this page by default. Switch to All for the whole project, or click a tile to jump straight to that bucket." },
|
|
3756
3899
|
comments: { title: "Pin, note or record", body: "Inspect selects an element, Note comments anywhere on the page, Region screenshots a rectangle, and Record captures video of one." },
|
|
3757
3900
|
activity: { title: "Watch the work happen", body: "Every event the bridge or your app reports lands here, alongside Loupe's own operations. Click a tool chip to filter the feed." }
|
|
@@ -3759,7 +3902,8 @@ var HINTS = {
|
|
|
3759
3902
|
var BUILTIN_TABS = [
|
|
3760
3903
|
{ id: "home", label: "Home" },
|
|
3761
3904
|
{ id: "comments", label: "Comments" },
|
|
3762
|
-
{ id: "activity", label: "Activity" }
|
|
3905
|
+
{ id: "activity", label: "Activity" },
|
|
3906
|
+
{ id: "chat", label: "Chat" }
|
|
3763
3907
|
];
|
|
3764
3908
|
var DOCK_MODES = ["left", "right", "bottom", "float"];
|
|
3765
3909
|
var RECORD_MAX_MS = 2e4;
|
|
@@ -3897,6 +4041,17 @@ var LoupeApp = class {
|
|
|
3897
4041
|
* rather than a `.tab-<id>` selector, so host-registered ids need no CSS.
|
|
3898
4042
|
*/
|
|
3899
4043
|
this.viewEls = /* @__PURE__ */ new Map();
|
|
4044
|
+
/** The gather tray: elements and screenshots collected into one message. */
|
|
4045
|
+
this.chatTray = emptyTray();
|
|
4046
|
+
/** Sent messages and received replies, in one ordered transcript. */
|
|
4047
|
+
this.chatLog = [];
|
|
4048
|
+
this.chatUnread = 0;
|
|
4049
|
+
this.chatSending = false;
|
|
4050
|
+
this.chatError = "";
|
|
4051
|
+
this.voiceOn = false;
|
|
4052
|
+
this.voiceBase = "";
|
|
4053
|
+
this.voiceStart = 0;
|
|
4054
|
+
this.lastReplyId = "";
|
|
3900
4055
|
/** In-app notifications: mentions of you, newest first. */
|
|
3901
4056
|
this.notifications = [];
|
|
3902
4057
|
// ---- inspector ------------------------------------------------------------
|
|
@@ -4151,6 +4306,7 @@ var LoupeApp = class {
|
|
|
4151
4306
|
this.renderHome();
|
|
4152
4307
|
this.startPresence();
|
|
4153
4308
|
this.startLiveThreads();
|
|
4309
|
+
this.startCompanionStream();
|
|
4154
4310
|
void this.loadNotifications();
|
|
4155
4311
|
if (this.scope === "all") void this.loadAllComments();
|
|
4156
4312
|
this.observe();
|
|
@@ -4368,6 +4524,8 @@ var LoupeApp = class {
|
|
|
4368
4524
|
commentsView.append(this.commentsHint, tools, listHead, this.reviewBar, this.listEl, this.buildIntegrations());
|
|
4369
4525
|
const activityView = el("div", "view activity-view");
|
|
4370
4526
|
this.activityEl = activityView;
|
|
4527
|
+
const chatView = el("div", "view chat-view");
|
|
4528
|
+
this.chatEl = chatView;
|
|
4371
4529
|
const customViews = (this.cfg.tabs ?? []).map((t) => {
|
|
4372
4530
|
const view = el("div", "view custom-view");
|
|
4373
4531
|
const slot = el("div", "hint-slot");
|
|
@@ -4399,20 +4557,370 @@ var LoupeApp = class {
|
|
|
4399
4557
|
resize.addEventListener("pointerdown", this.onResizeDown);
|
|
4400
4558
|
this.consentEl = el("div", "consent");
|
|
4401
4559
|
this.consentEl.style.display = "none";
|
|
4402
|
-
dock.append(head, this.minBar, this.consentEl, tabs, homeView, commentsView, activityView, ...customViews, resize);
|
|
4403
|
-
for (const [id, view] of [["home", homeView], ["comments", commentsView], ["activity", activityView]]) {
|
|
4560
|
+
dock.append(head, this.minBar, this.consentEl, tabs, homeView, commentsView, activityView, chatView, ...customViews, resize);
|
|
4561
|
+
for (const [id, view] of [["home", homeView], ["comments", commentsView], ["activity", activityView], ["chat", chatView]]) {
|
|
4404
4562
|
this.viewEls.set(id, view);
|
|
4405
4563
|
}
|
|
4406
4564
|
customViews.forEach((v, i) => this.viewEls.set((this.cfg.tabs ?? [])[i].id, v));
|
|
4407
4565
|
this.buildHomePanel();
|
|
4408
4566
|
this.buildActivityPanel();
|
|
4567
|
+
this.buildChatPanel();
|
|
4409
4568
|
return dock;
|
|
4410
4569
|
}
|
|
4570
|
+
buildChatPanel() {
|
|
4571
|
+
this.chatEl.innerHTML = `<div class="hint-slot" id="loupe-chint"></div><div class="chat-tray" id="loupe-tray"></div><div class="chat-log" id="loupe-chatlog"></div><div class="chat-compose"><textarea class="chat-in" id="loupe-chatin" rows="2" placeholder="Tell the agent what you see\u2026"></textarea><div class="chat-foot"><button class="chat-add" id="loupe-chatadd" title="Add what you have selected">\uFF0B Selection</button><button class="chat-mic" id="loupe-chatmic" title="Dictate">\u{1F399}</button><span class="chat-rec" id="loupe-chatrec" style="display:none"><span class="chat-rec-dot"></span><span id="loupe-chatrectime">0:00</span></span><span class="chat-spacer"></span><span class="chat-err" id="loupe-chaterr"></span><button class="chat-send" id="loupe-chatsend">Send</button></div></div>`;
|
|
4572
|
+
const input = this.chatEl.querySelector("#loupe-chatin");
|
|
4573
|
+
input.addEventListener("click", (e) => e.stopPropagation());
|
|
4574
|
+
input.oninput = () => {
|
|
4575
|
+
this.chatError = "";
|
|
4576
|
+
this.renderChatError();
|
|
4577
|
+
};
|
|
4578
|
+
input.onkeydown = (e) => {
|
|
4579
|
+
if (e.key === "Enter" && !e.shiftKey) {
|
|
4580
|
+
e.preventDefault();
|
|
4581
|
+
void this.sendCompanion();
|
|
4582
|
+
}
|
|
4583
|
+
};
|
|
4584
|
+
this.chatEl.querySelector("#loupe-chatadd").onclick = (e) => {
|
|
4585
|
+
e.stopPropagation();
|
|
4586
|
+
void this.addSelectionToTray();
|
|
4587
|
+
};
|
|
4588
|
+
this.chatEl.querySelector("#loupe-chatsend").onclick = (e) => {
|
|
4589
|
+
e.stopPropagation();
|
|
4590
|
+
void this.sendCompanion();
|
|
4591
|
+
};
|
|
4592
|
+
this.chatEl.querySelector("#loupe-chatmic").onclick = (e) => {
|
|
4593
|
+
e.stopPropagation();
|
|
4594
|
+
this.toggleVoice();
|
|
4595
|
+
};
|
|
4596
|
+
this.renderChatTray();
|
|
4597
|
+
this.renderChatLog();
|
|
4598
|
+
this.updateChatBadge();
|
|
4599
|
+
}
|
|
4600
|
+
/** The Chat tab label carries the unread count, so it is visible from any page. */
|
|
4601
|
+
updateChatBadge() {
|
|
4602
|
+
const tab = this.tabList.findIndex((t) => t.id === "chat");
|
|
4603
|
+
const buttons = this.shadow?.querySelectorAll(".tabs .tab");
|
|
4604
|
+
const el2 = buttons?.[tab];
|
|
4605
|
+
if (!el2) return;
|
|
4606
|
+
el2.textContent = this.chatUnread ? `Chat \xB7 ${this.chatUnread > 9 ? "9+" : this.chatUnread}` : "Chat";
|
|
4607
|
+
}
|
|
4608
|
+
renderChatError() {
|
|
4609
|
+
const box = this.chatEl?.querySelector("#loupe-chaterr");
|
|
4610
|
+
if (box) box.textContent = this.chatError;
|
|
4611
|
+
}
|
|
4612
|
+
renderChatTray() {
|
|
4613
|
+
const box = this.chatEl?.querySelector("#loupe-tray");
|
|
4614
|
+
if (!box) return;
|
|
4615
|
+
box.textContent = "";
|
|
4616
|
+
if (!this.chatTray.items.length) {
|
|
4617
|
+
box.style.display = "none";
|
|
4618
|
+
return;
|
|
4619
|
+
}
|
|
4620
|
+
box.style.display = "";
|
|
4621
|
+
const head = el("div", "tray-head");
|
|
4622
|
+
head.append(
|
|
4623
|
+
el("span", "tray-title", traySummary(this.chatTray)),
|
|
4624
|
+
el("span", "tray-spacer")
|
|
4625
|
+
);
|
|
4626
|
+
const clear = el("button", "tray-x", "Clear");
|
|
4627
|
+
clear.onclick = (e) => {
|
|
4628
|
+
e.stopPropagation();
|
|
4629
|
+
this.chatTray = clearTray();
|
|
4630
|
+
this.renderChatTray();
|
|
4631
|
+
};
|
|
4632
|
+
head.appendChild(clear);
|
|
4633
|
+
box.appendChild(head);
|
|
4634
|
+
this.chatTray.items.forEach((item, i) => {
|
|
4635
|
+
const chip = el("div", "tray-chip" + (item.include ? "" : " off"));
|
|
4636
|
+
const check = el("input");
|
|
4637
|
+
check.type = "checkbox";
|
|
4638
|
+
check.checked = item.include;
|
|
4639
|
+
check.title = "Include in the message";
|
|
4640
|
+
check.onchange = (e) => {
|
|
4641
|
+
e.stopPropagation();
|
|
4642
|
+
this.chatTray = toggleInclude(this.chatTray, item.id);
|
|
4643
|
+
this.renderChatTray();
|
|
4644
|
+
};
|
|
4645
|
+
const label = el("span", "tray-label", item.label);
|
|
4646
|
+
label.title = item.label + (item.url ? ` \u2014 ${item.url}` : "");
|
|
4647
|
+
const up = el("button", "tray-nudge", "\u2191");
|
|
4648
|
+
up.disabled = i === 0;
|
|
4649
|
+
up.onclick = (e) => {
|
|
4650
|
+
e.stopPropagation();
|
|
4651
|
+
this.chatTray = nudgeInTray(this.chatTray, item.id, -1);
|
|
4652
|
+
this.renderChatTray();
|
|
4653
|
+
};
|
|
4654
|
+
const down = el("button", "tray-nudge", "\u2193");
|
|
4655
|
+
down.disabled = i === this.chatTray.items.length - 1;
|
|
4656
|
+
down.onclick = (e) => {
|
|
4657
|
+
e.stopPropagation();
|
|
4658
|
+
this.chatTray = nudgeInTray(this.chatTray, item.id, 1);
|
|
4659
|
+
this.renderChatTray();
|
|
4660
|
+
};
|
|
4661
|
+
const x = el("button", "tray-x", "\u2715");
|
|
4662
|
+
x.title = "Remove";
|
|
4663
|
+
x.onclick = (e) => {
|
|
4664
|
+
e.stopPropagation();
|
|
4665
|
+
this.chatTray = removeFromTray(this.chatTray, item.id);
|
|
4666
|
+
this.renderChatTray();
|
|
4667
|
+
};
|
|
4668
|
+
chip.append(check, item.thumb ? (() => {
|
|
4669
|
+
const img = el("img", "tray-thumb");
|
|
4670
|
+
img.src = item.thumb;
|
|
4671
|
+
img.alt = "";
|
|
4672
|
+
return img;
|
|
4673
|
+
})() : el("span", "tray-kind", item.kind), label, up, down, x);
|
|
4674
|
+
box.appendChild(chip);
|
|
4675
|
+
});
|
|
4676
|
+
}
|
|
4677
|
+
renderChatLog() {
|
|
4678
|
+
const box = this.chatEl?.querySelector("#loupe-chatlog");
|
|
4679
|
+
if (!box) return;
|
|
4680
|
+
box.textContent = "";
|
|
4681
|
+
if (!this.chatLog.length) {
|
|
4682
|
+
box.appendChild(el(
|
|
4683
|
+
"div",
|
|
4684
|
+
"chat-empty",
|
|
4685
|
+
this.cfg.bridge ? "Say what you see while the agent works \u2014 it lands on the agent\u2019s very next step." : "Connect a bridge to talk to the agent. Without one there is nothing to deliver to."
|
|
4686
|
+
));
|
|
4687
|
+
return;
|
|
4688
|
+
}
|
|
4689
|
+
for (const m of this.chatLog) {
|
|
4690
|
+
const row = el("div", "chat-msg" + (m.mine ? " mine" : ""));
|
|
4691
|
+
const head = el("div", "chat-head");
|
|
4692
|
+
head.append(
|
|
4693
|
+
el("b", "chat-who", m.mine ? "You" : m.author ?? "Agent"),
|
|
4694
|
+
el("span", "chat-when", fmtAgo(m.at))
|
|
4695
|
+
);
|
|
4696
|
+
if (m.count) head.appendChild(el("span", "chat-ctx", `${m.count} context${m.count === 1 ? "" : "s"}`));
|
|
4697
|
+
const body = el("div", "chat-body");
|
|
4698
|
+
for (const seg of mentionSegments(m.body, parseMentions(m.body))) {
|
|
4699
|
+
if (seg.mention) body.appendChild(el("span", "mention", seg.text));
|
|
4700
|
+
else body.appendChild(document.createTextNode(seg.text));
|
|
4701
|
+
}
|
|
4702
|
+
row.append(head, body);
|
|
4703
|
+
box.appendChild(row);
|
|
4704
|
+
}
|
|
4705
|
+
box.scrollTop = box.scrollHeight;
|
|
4706
|
+
}
|
|
4707
|
+
/** Pull the current selection off the bridge and into the tray. */
|
|
4708
|
+
async addSelectionToTray() {
|
|
4709
|
+
const base = this.cfg.bridge?.replace(/\/$/, "");
|
|
4710
|
+
if (!base) {
|
|
4711
|
+
this.chatError = "No bridge configured.";
|
|
4712
|
+
this.renderChatError();
|
|
4713
|
+
return;
|
|
4714
|
+
}
|
|
4715
|
+
try {
|
|
4716
|
+
const res = await fetch(`${base}/selection/latest`);
|
|
4717
|
+
if (!res.ok) throw new Error(String(res.status));
|
|
4718
|
+
const { selection } = await res.json();
|
|
4719
|
+
if (!selection) {
|
|
4720
|
+
this.chatError = "Nothing selected yet.";
|
|
4721
|
+
this.renderChatError();
|
|
4722
|
+
return;
|
|
4723
|
+
}
|
|
4724
|
+
this.chatTray = addToTray(this.chatTray, {
|
|
4725
|
+
id: selection.correlationId ?? selection.selector ?? String(selection.at),
|
|
4726
|
+
kind: "element",
|
|
4727
|
+
// The selector is what identifies it; the text makes it recognisable.
|
|
4728
|
+
label: `${selection.selector ?? selection.tag ?? "element"}${selection.text ? ` \u2014 \u201C${String(selection.text).slice(0, 24)}\u201D` : ""}`,
|
|
4729
|
+
url: selection.url,
|
|
4730
|
+
ref: selection.correlationId ?? selection.selector
|
|
4731
|
+
});
|
|
4732
|
+
this.chatError = "";
|
|
4733
|
+
this.renderChatError();
|
|
4734
|
+
this.renderChatTray();
|
|
4735
|
+
} catch (e) {
|
|
4736
|
+
this.chatError = e instanceof Error ? `Could not read the selection: ${e.message}` : "Could not read the selection.";
|
|
4737
|
+
this.renderChatError();
|
|
4738
|
+
}
|
|
4739
|
+
}
|
|
4740
|
+
async sendCompanion() {
|
|
4741
|
+
const input = this.chatEl.querySelector("#loupe-chatin");
|
|
4742
|
+
const body = input.value.trim();
|
|
4743
|
+
const contexts = trayPayload(this.chatTray);
|
|
4744
|
+
if (!body && !contexts.length) return;
|
|
4745
|
+
if (this.chatSending) return;
|
|
4746
|
+
const base = this.cfg.bridge?.replace(/\/$/, "");
|
|
4747
|
+
if (!base) {
|
|
4748
|
+
this.chatError = "No bridge configured \u2014 nothing to deliver to.";
|
|
4749
|
+
this.renderChatError();
|
|
4750
|
+
return;
|
|
4751
|
+
}
|
|
4752
|
+
this.chatSending = true;
|
|
4753
|
+
this.chatError = "";
|
|
4754
|
+
this.renderChatError();
|
|
4755
|
+
const optimistic = {
|
|
4756
|
+
id: `pending-${Date.now().toString(36)}`,
|
|
4757
|
+
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
4758
|
+
mine: true,
|
|
4759
|
+
body: body || "(context only)",
|
|
4760
|
+
count: contexts.length
|
|
4761
|
+
};
|
|
4762
|
+
this.chatLog.push(optimistic);
|
|
4763
|
+
this.renderChatLog();
|
|
4764
|
+
try {
|
|
4765
|
+
const res = await fetch(`${base}/companion`, {
|
|
4766
|
+
method: "POST",
|
|
4767
|
+
headers: { "content-type": "application/json" },
|
|
4768
|
+
body: JSON.stringify({
|
|
4769
|
+
body: body || "(see the gathered context)",
|
|
4770
|
+
author: { id: this.cfg.user.id, name: this.cfg.user.name },
|
|
4771
|
+
contexts,
|
|
4772
|
+
attachments: includedItems(this.chatTray).filter((i) => i.kind === "screenshot" && i.thumb).map((i) => ({ url: i.thumb, kind: "image", name: i.label })),
|
|
4773
|
+
voice: this.voiceBase.length > 0
|
|
4774
|
+
})
|
|
4775
|
+
});
|
|
4776
|
+
if (!res.ok) throw new Error(`the bridge answered ${res.status}`);
|
|
4777
|
+
input.value = "";
|
|
4778
|
+
this.chatTray = clearTray();
|
|
4779
|
+
this.modelVoiceBase("");
|
|
4780
|
+
this.renderChatTray();
|
|
4781
|
+
this.addActivity({ kind: "message.create", label: `Sent a brief to the agent${contexts.length ? ` (${contexts.length} contexts)` : ""}` });
|
|
4782
|
+
} catch (e) {
|
|
4783
|
+
this.chatError = e instanceof Error ? e.message : "Could not send.";
|
|
4784
|
+
this.renderChatError();
|
|
4785
|
+
this.addActivity({ kind: "error", level: "error", label: "Companion message failed to send" });
|
|
4786
|
+
} finally {
|
|
4787
|
+
this.chatSending = false;
|
|
4788
|
+
this.renderChatLog();
|
|
4789
|
+
}
|
|
4790
|
+
}
|
|
4791
|
+
modelVoiceBase(v) {
|
|
4792
|
+
this.voiceBase = v;
|
|
4793
|
+
}
|
|
4411
4794
|
/**
|
|
4412
|
-
*
|
|
4413
|
-
*
|
|
4414
|
-
*
|
|
4795
|
+
* Dictation.
|
|
4796
|
+
*
|
|
4797
|
+
* Two different reasons the control can be unavailable, and they need different
|
|
4798
|
+
* words: an unsupported browser is a fact, while a non-secure page is something the
|
|
4799
|
+
* person can fix. Saying "your browser cannot do this" when the real problem is
|
|
4800
|
+
* `http://` sends them looking in the wrong place.
|
|
4415
4801
|
*/
|
|
4802
|
+
toggleVoice() {
|
|
4803
|
+
const support = voiceSupport({
|
|
4804
|
+
hasCtor: typeof globalThis.SpeechRecognition !== "undefined" || typeof globalThis.webkitSpeechRecognition !== "undefined",
|
|
4805
|
+
isSecureContext: typeof isSecureContext === "boolean" ? isSecureContext : true
|
|
4806
|
+
});
|
|
4807
|
+
if (support !== "supported") {
|
|
4808
|
+
this.chatError = voiceMessage(support) ?? "Dictation is unavailable.";
|
|
4809
|
+
this.renderChatError();
|
|
4810
|
+
return;
|
|
4811
|
+
}
|
|
4812
|
+
if (this.voiceOn) return this.stopChatVoice();
|
|
4813
|
+
const Ctor = globalThis.SpeechRecognition ?? globalThis.webkitSpeechRecognition;
|
|
4814
|
+
const input = this.chatEl.querySelector("#loupe-chatin");
|
|
4815
|
+
try {
|
|
4816
|
+
this.stopChatVoice();
|
|
4817
|
+
const rec = new Ctor();
|
|
4818
|
+
rec.continuous = true;
|
|
4819
|
+
rec.interimResults = true;
|
|
4820
|
+
rec.lang = document.documentElement.lang || "en-US";
|
|
4821
|
+
this.voiceBase = input.value ? `${input.value.trim()} ` : "";
|
|
4822
|
+
rec.onresult = (ev) => {
|
|
4823
|
+
let text = "";
|
|
4824
|
+
for (let i = ev.resultIndex; i < ev.results.length; i++) text += ev.results[i][0].transcript;
|
|
4825
|
+
input.value = `${this.voiceBase}${text}`;
|
|
4826
|
+
};
|
|
4827
|
+
rec.onerror = (ev) => {
|
|
4828
|
+
this.chatError = ev?.error === "not-allowed" ? "Microphone permission was refused." : "Dictation stopped.";
|
|
4829
|
+
this.renderChatError();
|
|
4830
|
+
this.stopChatVoice();
|
|
4831
|
+
};
|
|
4832
|
+
rec.onend = () => {
|
|
4833
|
+
if (this.voiceOn) this.stopChatVoice();
|
|
4834
|
+
};
|
|
4835
|
+
rec.start();
|
|
4836
|
+
this.voice = rec;
|
|
4837
|
+
this.voiceTarget = input;
|
|
4838
|
+
this.voiceOn = true;
|
|
4839
|
+
this.voiceStart = Date.now();
|
|
4840
|
+
const pill = this.chatEl.querySelector("#loupe-chatrec");
|
|
4841
|
+
pill.style.display = "";
|
|
4842
|
+
this.voiceTimer = setInterval(() => {
|
|
4843
|
+
const t = this.chatEl.querySelector("#loupe-chatrectime");
|
|
4844
|
+
if (t) t.textContent = elapsedLabel(Date.now() - this.voiceStart);
|
|
4845
|
+
}, 500);
|
|
4846
|
+
this.chatEl.querySelector("#loupe-chatmic").classList.add("on");
|
|
4847
|
+
this.renderChatTray();
|
|
4848
|
+
} catch (e) {
|
|
4849
|
+
this.chatError = e instanceof Error ? e.message : "Could not start dictation.";
|
|
4850
|
+
this.renderChatError();
|
|
4851
|
+
}
|
|
4852
|
+
}
|
|
4853
|
+
stopChatVoice() {
|
|
4854
|
+
this.voiceOn = false;
|
|
4855
|
+
if (this.voiceTimer) clearInterval(this.voiceTimer);
|
|
4856
|
+
this.voiceTimer = void 0;
|
|
4857
|
+
this.stopVoice();
|
|
4858
|
+
const pill = this.chatEl?.querySelector("#loupe-chatrec");
|
|
4859
|
+
if (pill) pill.style.display = "none";
|
|
4860
|
+
this.chatEl?.querySelector("#loupe-chatmic")?.classList.remove("on");
|
|
4861
|
+
}
|
|
4862
|
+
/**
|
|
4863
|
+
* Follow the bridge for replies.
|
|
4864
|
+
*
|
|
4865
|
+
* With SSE where it works and a poll where it does not, because a reply the person
|
|
4866
|
+
* never sees is the same as no reply — and the panel is the only place it appears.
|
|
4867
|
+
*/
|
|
4868
|
+
startCompanionStream() {
|
|
4869
|
+
const base = this.cfg.bridge?.replace(/\/$/, "");
|
|
4870
|
+
if (!base || this.companionSource) return;
|
|
4871
|
+
if (typeof EventSource === "undefined") return;
|
|
4872
|
+
try {
|
|
4873
|
+
const source = new EventSource(`${base}/events`);
|
|
4874
|
+
source.addEventListener("companion", (ev) => {
|
|
4875
|
+
let parsed;
|
|
4876
|
+
try {
|
|
4877
|
+
parsed = JSON.parse(ev.data);
|
|
4878
|
+
} catch {
|
|
4879
|
+
return;
|
|
4880
|
+
}
|
|
4881
|
+
if (parsed?.eventType === "reply") this.receiveReply(parsed.data);
|
|
4882
|
+
});
|
|
4883
|
+
source.onerror = () => {
|
|
4884
|
+
};
|
|
4885
|
+
this.companionSource = source;
|
|
4886
|
+
this.companionPoll = setInterval(() => void this.pollReplies(), 5e3);
|
|
4887
|
+
} catch {
|
|
4888
|
+
this.companionPoll = setInterval(() => void this.pollReplies(), 4e3);
|
|
4889
|
+
}
|
|
4890
|
+
}
|
|
4891
|
+
async pollReplies() {
|
|
4892
|
+
const base = this.cfg.bridge?.replace(/\/$/, "");
|
|
4893
|
+
if (!base) return;
|
|
4894
|
+
try {
|
|
4895
|
+
const res = await fetch(`${base}/companion?since=${encodeURIComponent(this.lastReplyId)}`);
|
|
4896
|
+
if (!res.ok) return;
|
|
4897
|
+
const { replies } = await res.json();
|
|
4898
|
+
for (const r of replies ?? []) this.receiveReply(r);
|
|
4899
|
+
} catch {
|
|
4900
|
+
}
|
|
4901
|
+
}
|
|
4902
|
+
receiveReply(reply) {
|
|
4903
|
+
if (!reply?.id || !reply.body) return;
|
|
4904
|
+
if (this.chatLog.some((m) => m.id === reply.id)) return;
|
|
4905
|
+
this.lastReplyId = reply.id;
|
|
4906
|
+
this.chatLog.push({ id: reply.id, at: reply.at ?? (/* @__PURE__ */ new Date()).toISOString(), mine: false, body: reply.body });
|
|
4907
|
+
if (this.tab !== "chat" || this.minimized) {
|
|
4908
|
+
this.chatUnread++;
|
|
4909
|
+
this.updateChatBadge();
|
|
4910
|
+
this.desktopNotify("Loupe \u2014 the agent replied", reply.body);
|
|
4911
|
+
}
|
|
4912
|
+
this.addActivity({ kind: "agent.reply", label: "The agent replied" });
|
|
4913
|
+
this.renderChatLog();
|
|
4914
|
+
}
|
|
4915
|
+
/** A desktop notification, when the browser will show one. Never a prompt for it. */
|
|
4916
|
+
desktopNotify(title, body) {
|
|
4917
|
+
try {
|
|
4918
|
+
const N = globalThis.Notification;
|
|
4919
|
+
if (!N || N.permission !== "granted") return;
|
|
4920
|
+
new N(title, { body: String(body).slice(0, 200), tag: "loupe-companion" });
|
|
4921
|
+
} catch {
|
|
4922
|
+
}
|
|
4923
|
+
}
|
|
4416
4924
|
buildActivityPanel() {
|
|
4417
4925
|
this.activityEl.innerHTML = `<div class="hint-slot" id="loupe-ahint"></div><div class="mon-status" id="loupe-mon-status"><span class="mon-dot"></span><span class="mon-status-label"></span><span class="mon-spacer"></span><button class="mon-clear" data-role="mon-clear" title="Clear the feed">Clear</button></div><div class="mon-summary" id="loupe-mon-summary"></div><div class="mon-micro" id="loupe-mon-micro"></div><div class="mon-chips" id="loupe-mon-chips"></div><div class="mon-feed" id="loupe-mon-feed"></div>`;
|
|
4418
4926
|
this.feedEl = this.activityEl.querySelector("#loupe-mon-feed");
|
|
@@ -6753,17 +7261,17 @@ ${c.body}` : c.body)}</div>` + (c.context?.html ? `<pre class="or-code">${escape
|
|
|
6753
7261
|
if (typeof EventSource === "undefined") return;
|
|
6754
7262
|
try {
|
|
6755
7263
|
const source = new EventSource(`${bridge.replace(/\/$/, "")}/thread-updates`);
|
|
6756
|
-
source.
|
|
7264
|
+
source.addEventListener("thread", (ev) => {
|
|
6757
7265
|
let parsed;
|
|
6758
7266
|
try {
|
|
6759
7267
|
parsed = JSON.parse(ev.data);
|
|
6760
7268
|
} catch {
|
|
6761
7269
|
return;
|
|
6762
7270
|
}
|
|
6763
|
-
if (!parsed ||
|
|
7271
|
+
if (!parsed || typeof parsed.threadId !== "string") return;
|
|
6764
7272
|
if (!this.messages.has(parsed.threadId)) return;
|
|
6765
7273
|
void this.refreshMessages(parsed.threadId);
|
|
6766
|
-
};
|
|
7274
|
+
});
|
|
6767
7275
|
source.onerror = () => {
|
|
6768
7276
|
};
|
|
6769
7277
|
this.liveSource = source;
|
|
@@ -6986,6 +7494,10 @@ ${c.body}` : c.body)}</div>` + (c.context?.html ? `<pre class="or-code">${escape
|
|
|
6986
7494
|
destroy() {
|
|
6987
7495
|
this.liveSource?.close();
|
|
6988
7496
|
this.liveSource = void 0;
|
|
7497
|
+
this.companionSource?.close();
|
|
7498
|
+
this.companionSource = void 0;
|
|
7499
|
+
if (this.companionPoll) clearInterval(this.companionPoll);
|
|
7500
|
+
this.stopVoice();
|
|
6989
7501
|
this.stopPresence();
|
|
6990
7502
|
this.stopRecording?.();
|
|
6991
7503
|
this.setMode("off");
|