@loupekit/sdk 0.10.26 → 0.10.27-next.74
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.global.js
CHANGED
|
@@ -807,6 +807,64 @@ var Loupe = (() => {
|
|
|
807
807
|
object-fit: cover; cursor: pointer; background: var(--bg-2);
|
|
808
808
|
}
|
|
809
809
|
|
|
810
|
+
/* ------------------------------------------------------------- companion chat */
|
|
811
|
+
/* Same inset as the Activity view \u2014 without it the composer and its Send button sit
|
|
812
|
+
flush against the panel's edge, which reads as a clipped button. */
|
|
813
|
+
.chat-view { display: flex; flex-direction: column; gap: 8px; height: 100%; min-height: 0; padding: 10px 12px 16px; }
|
|
814
|
+
.chat-tray {
|
|
815
|
+
display: flex; flex-direction: column; gap: 4px; padding: 8px;
|
|
816
|
+
border: 1px solid var(--line); border-radius: 10px; background: var(--bg-2);
|
|
817
|
+
}
|
|
818
|
+
.tray-head { display: flex; align-items: center; gap: 6px; }
|
|
819
|
+
.tray-title { font-size: 11px; color: var(--muted); }
|
|
820
|
+
.tray-spacer, .chat-spacer { flex: 1; }
|
|
821
|
+
.tray-chip {
|
|
822
|
+
display: flex; align-items: center; gap: 6px; padding: 4px 6px;
|
|
823
|
+
border: 1px solid var(--line); border-radius: 8px; background: var(--bg); font-size: 11px;
|
|
824
|
+
}
|
|
825
|
+
.tray-chip.off { opacity: .5; }
|
|
826
|
+
.tray-thumb { width: 24px; height: 24px; border-radius: 4px; object-fit: cover; flex: none; }
|
|
827
|
+
.tray-kind { font-size: 10px; color: var(--muted); text-transform: uppercase; letter-spacing: .04em; flex: none; }
|
|
828
|
+
.tray-label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: ui-monospace, monospace; }
|
|
829
|
+
.tray-nudge, .tray-x {
|
|
830
|
+
border: 0; background: transparent; color: var(--muted); cursor: pointer;
|
|
831
|
+
font-size: 11px; padding: 1px 4px; border-radius: 4px; line-height: 1.2;
|
|
832
|
+
}
|
|
833
|
+
.tray-nudge:disabled { opacity: .3; cursor: default; }
|
|
834
|
+
.tray-nudge:not(:disabled):hover, .tray-x:hover { color: var(--accent); background: var(--accent-soft); }
|
|
835
|
+
.chat-log { flex: 1; min-height: 0; overflow-y: auto; display: flex; flex-direction: column; gap: 8px; padding: 2px; }
|
|
836
|
+
.chat-empty { color: var(--muted); font-size: 12px; padding: 12px 4px; line-height: 1.5; }
|
|
837
|
+
.chat-msg { padding: 8px 10px; border-radius: 10px; background: var(--bg-2); border: 1px solid var(--line); }
|
|
838
|
+
.chat-msg.mine { background: var(--accent-soft); border-color: var(--accent); }
|
|
839
|
+
.chat-head { display: flex; align-items: baseline; gap: 6px; margin-bottom: 3px; }
|
|
840
|
+
.chat-who { font-size: 11px; }
|
|
841
|
+
.chat-when { font-size: 10px; color: var(--muted); }
|
|
842
|
+
.chat-ctx {
|
|
843
|
+
font-size: 10px; color: var(--accent); border: 1px solid var(--accent);
|
|
844
|
+
border-radius: 999px; padding: 0 5px; margin-left: auto;
|
|
845
|
+
}
|
|
846
|
+
.chat-body { font-size: 12px; white-space: pre-wrap; word-break: break-word; }
|
|
847
|
+
.chat-compose { border-top: 1px solid var(--line); padding-top: 8px; }
|
|
848
|
+
.chat-in {
|
|
849
|
+
width: 100%; resize: vertical; min-height: 44px; padding: 7px 9px;
|
|
850
|
+
border: 1px solid var(--line); border-radius: 9px; background: var(--bg);
|
|
851
|
+
color: var(--ink); font: inherit; font-size: 12.5px;
|
|
852
|
+
}
|
|
853
|
+
.chat-in:focus { outline: none; border-color: var(--accent); }
|
|
854
|
+
.chat-foot { display: flex; align-items: center; gap: 6px; margin-top: 6px; }
|
|
855
|
+
.chat-add, .chat-mic, .chat-send {
|
|
856
|
+
padding: 4px 9px; border: 1px solid var(--line); border-radius: 7px;
|
|
857
|
+
background: var(--bg); color: var(--ink); font-size: 11px; cursor: pointer;
|
|
858
|
+
}
|
|
859
|
+
.chat-add:hover, .chat-mic:hover { border-color: var(--accent); color: var(--accent); }
|
|
860
|
+
.chat-mic.on { border-color: var(--err, #f2555a); color: #f2555a; background: color-mix(in srgb, #f2555a 14%, transparent); }
|
|
861
|
+
.chat-send { background: var(--accent); border-color: var(--accent); color: #fff; font-weight: 600; }
|
|
862
|
+
.chat-send:hover { opacity: .9; }
|
|
863
|
+
.chat-rec { display: inline-flex; align-items: center; gap: 5px; font-size: 11px; color: #f2555a; }
|
|
864
|
+
.chat-rec-dot { width: 7px; height: 7px; border-radius: 50%; background: #f2555a; animation: chatpulse 1.2s ease-in-out infinite; }
|
|
865
|
+
@keyframes chatpulse { 0%, 100% { opacity: 1; } 50% { opacity: .35; } }
|
|
866
|
+
.chat-err { font-size: 11px; color: #f2555a; }
|
|
867
|
+
|
|
810
868
|
/* reactions */
|
|
811
869
|
.rxns { display: flex; flex-wrap: wrap; align-items: center; gap: 4px; margin-top: 6px; position: relative; }
|
|
812
870
|
.rxn {
|
|
@@ -3389,6 +3447,87 @@ var Loupe = (() => {
|
|
|
3389
3447
|
return (words[0].slice(0, 1) + words[words.length - 1].slice(0, 1)).toUpperCase();
|
|
3390
3448
|
}
|
|
3391
3449
|
|
|
3450
|
+
// ../shared/dist/companion-tray.js
|
|
3451
|
+
function emptyTray() {
|
|
3452
|
+
return { items: [] };
|
|
3453
|
+
}
|
|
3454
|
+
function addToTray(state, item) {
|
|
3455
|
+
const full = { include: true, ...item };
|
|
3456
|
+
const at = state.items.findIndex((i) => i.id === full.id);
|
|
3457
|
+
if (at < 0)
|
|
3458
|
+
return { items: [...state.items, full] };
|
|
3459
|
+
const items = [...state.items];
|
|
3460
|
+
items[at] = { ...items[at], ...full, include: items[at].include };
|
|
3461
|
+
return { items };
|
|
3462
|
+
}
|
|
3463
|
+
function removeFromTray(state, id) {
|
|
3464
|
+
return { items: state.items.filter((i) => i.id !== id) };
|
|
3465
|
+
}
|
|
3466
|
+
function toggleInclude(state, id) {
|
|
3467
|
+
return { items: state.items.map((i) => i.id === id ? { ...i, include: !i.include } : i) };
|
|
3468
|
+
}
|
|
3469
|
+
function moveInTray(state, id, to) {
|
|
3470
|
+
const from = state.items.findIndex((i) => i.id === id);
|
|
3471
|
+
if (from < 0)
|
|
3472
|
+
return state;
|
|
3473
|
+
const target = Math.max(0, Math.min(state.items.length - 1, to));
|
|
3474
|
+
if (target === from)
|
|
3475
|
+
return state;
|
|
3476
|
+
const items = [...state.items];
|
|
3477
|
+
const [moved] = items.splice(from, 1);
|
|
3478
|
+
items.splice(target, 0, moved);
|
|
3479
|
+
return { items };
|
|
3480
|
+
}
|
|
3481
|
+
function nudgeInTray(state, id, delta) {
|
|
3482
|
+
const at = state.items.findIndex((i) => i.id === id);
|
|
3483
|
+
if (at < 0)
|
|
3484
|
+
return state;
|
|
3485
|
+
return moveInTray(state, id, at + delta);
|
|
3486
|
+
}
|
|
3487
|
+
function includedItems(state) {
|
|
3488
|
+
return state.items.filter((i) => i.include);
|
|
3489
|
+
}
|
|
3490
|
+
function trayCount(state) {
|
|
3491
|
+
return { total: state.items.length, included: includedItems(state).length };
|
|
3492
|
+
}
|
|
3493
|
+
function clearTray() {
|
|
3494
|
+
return { items: [] };
|
|
3495
|
+
}
|
|
3496
|
+
function trayPayload(state) {
|
|
3497
|
+
return includedItems(state).map((i) => ({
|
|
3498
|
+
kind: i.kind,
|
|
3499
|
+
id: i.ref ?? i.id,
|
|
3500
|
+
url: i.url,
|
|
3501
|
+
label: i.label
|
|
3502
|
+
}));
|
|
3503
|
+
}
|
|
3504
|
+
function traySummary(state) {
|
|
3505
|
+
const { total, included } = trayCount(state);
|
|
3506
|
+
if (!total)
|
|
3507
|
+
return "No context gathered";
|
|
3508
|
+
if (included === total)
|
|
3509
|
+
return `${total} context${total === 1 ? "" : "s"}`;
|
|
3510
|
+
return `${included} of ${total} included`;
|
|
3511
|
+
}
|
|
3512
|
+
function voiceSupport(env) {
|
|
3513
|
+
if (!env.isSecureContext)
|
|
3514
|
+
return "insecure";
|
|
3515
|
+
return env.hasCtor ? "supported" : "unsupported";
|
|
3516
|
+
}
|
|
3517
|
+
function voiceMessage(support) {
|
|
3518
|
+
if (support === "supported")
|
|
3519
|
+
return null;
|
|
3520
|
+
if (support === "insecure")
|
|
3521
|
+
return "Dictation needs a secure page (https, or localhost).";
|
|
3522
|
+
return "This browser cannot dictate \u2014 the Web Speech API is not available.";
|
|
3523
|
+
}
|
|
3524
|
+
function elapsedLabel(ms) {
|
|
3525
|
+
const total = Math.max(0, Math.floor(ms / 1e3));
|
|
3526
|
+
const mm = Math.floor(total / 60);
|
|
3527
|
+
const ss = total % 60;
|
|
3528
|
+
return `${mm}:${String(ss).padStart(2, "0")}`;
|
|
3529
|
+
}
|
|
3530
|
+
|
|
3392
3531
|
// ../shared/dist/index.js
|
|
3393
3532
|
var COMMENT_STAGES = ["queue", "todo", "in_progress", "in_review", "resolved"];
|
|
3394
3533
|
var STAGE_LABELS = {
|
|
@@ -3742,7 +3881,7 @@ var Loupe = (() => {
|
|
|
3742
3881
|
};
|
|
3743
3882
|
|
|
3744
3883
|
// src/app.ts
|
|
3745
|
-
var SDK_VERSION = true ? "0.10.
|
|
3884
|
+
var SDK_VERSION = true ? "0.10.27" : "dev";
|
|
3746
3885
|
var ACCENTS = [
|
|
3747
3886
|
{ id: "indigo", dark: "#6b73e6", light: "#4a55d6", soft: "rgba(107,115,230,0.12)" },
|
|
3748
3887
|
{ id: "violet", dark: "#a06be6", light: "#7c3fd4", soft: "rgba(160,107,230,0.14)" },
|
|
@@ -3786,6 +3925,10 @@ var Loupe = (() => {
|
|
|
3786
3925
|
}
|
|
3787
3926
|
];
|
|
3788
3927
|
var HINTS = {
|
|
3928
|
+
chat: {
|
|
3929
|
+
title: "Talk to the agent",
|
|
3930
|
+
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."
|
|
3931
|
+
},
|
|
3789
3932
|
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." },
|
|
3790
3933
|
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." },
|
|
3791
3934
|
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." }
|
|
@@ -3793,7 +3936,8 @@ var Loupe = (() => {
|
|
|
3793
3936
|
var BUILTIN_TABS = [
|
|
3794
3937
|
{ id: "home", label: "Home" },
|
|
3795
3938
|
{ id: "comments", label: "Comments" },
|
|
3796
|
-
{ id: "activity", label: "Activity" }
|
|
3939
|
+
{ id: "activity", label: "Activity" },
|
|
3940
|
+
{ id: "chat", label: "Chat" }
|
|
3797
3941
|
];
|
|
3798
3942
|
var DOCK_MODES = ["left", "right", "bottom", "float"];
|
|
3799
3943
|
var RECORD_MAX_MS = 2e4;
|
|
@@ -3931,6 +4075,17 @@ var Loupe = (() => {
|
|
|
3931
4075
|
* rather than a `.tab-<id>` selector, so host-registered ids need no CSS.
|
|
3932
4076
|
*/
|
|
3933
4077
|
this.viewEls = /* @__PURE__ */ new Map();
|
|
4078
|
+
/** The gather tray: elements and screenshots collected into one message. */
|
|
4079
|
+
this.chatTray = emptyTray();
|
|
4080
|
+
/** Sent messages and received replies, in one ordered transcript. */
|
|
4081
|
+
this.chatLog = [];
|
|
4082
|
+
this.chatUnread = 0;
|
|
4083
|
+
this.chatSending = false;
|
|
4084
|
+
this.chatError = "";
|
|
4085
|
+
this.voiceOn = false;
|
|
4086
|
+
this.voiceBase = "";
|
|
4087
|
+
this.voiceStart = 0;
|
|
4088
|
+
this.lastReplyId = "";
|
|
3934
4089
|
/** In-app notifications: mentions of you, newest first. */
|
|
3935
4090
|
this.notifications = [];
|
|
3936
4091
|
// ---- inspector ------------------------------------------------------------
|
|
@@ -4185,6 +4340,7 @@ var Loupe = (() => {
|
|
|
4185
4340
|
this.renderHome();
|
|
4186
4341
|
this.startPresence();
|
|
4187
4342
|
this.startLiveThreads();
|
|
4343
|
+
this.startCompanionStream();
|
|
4188
4344
|
void this.loadNotifications();
|
|
4189
4345
|
if (this.scope === "all") void this.loadAllComments();
|
|
4190
4346
|
this.observe();
|
|
@@ -4402,6 +4558,8 @@ var Loupe = (() => {
|
|
|
4402
4558
|
commentsView.append(this.commentsHint, tools, listHead, this.reviewBar, this.listEl, this.buildIntegrations());
|
|
4403
4559
|
const activityView = el("div", "view activity-view");
|
|
4404
4560
|
this.activityEl = activityView;
|
|
4561
|
+
const chatView = el("div", "view chat-view");
|
|
4562
|
+
this.chatEl = chatView;
|
|
4405
4563
|
const customViews = (this.cfg.tabs ?? []).map((t) => {
|
|
4406
4564
|
const view = el("div", "view custom-view");
|
|
4407
4565
|
const slot = el("div", "hint-slot");
|
|
@@ -4433,20 +4591,370 @@ var Loupe = (() => {
|
|
|
4433
4591
|
resize.addEventListener("pointerdown", this.onResizeDown);
|
|
4434
4592
|
this.consentEl = el("div", "consent");
|
|
4435
4593
|
this.consentEl.style.display = "none";
|
|
4436
|
-
dock.append(head, this.minBar, this.consentEl, tabs, homeView, commentsView, activityView, ...customViews, resize);
|
|
4437
|
-
for (const [id, view] of [["home", homeView], ["comments", commentsView], ["activity", activityView]]) {
|
|
4594
|
+
dock.append(head, this.minBar, this.consentEl, tabs, homeView, commentsView, activityView, chatView, ...customViews, resize);
|
|
4595
|
+
for (const [id, view] of [["home", homeView], ["comments", commentsView], ["activity", activityView], ["chat", chatView]]) {
|
|
4438
4596
|
this.viewEls.set(id, view);
|
|
4439
4597
|
}
|
|
4440
4598
|
customViews.forEach((v, i) => this.viewEls.set((this.cfg.tabs ?? [])[i].id, v));
|
|
4441
4599
|
this.buildHomePanel();
|
|
4442
4600
|
this.buildActivityPanel();
|
|
4601
|
+
this.buildChatPanel();
|
|
4443
4602
|
return dock;
|
|
4444
4603
|
}
|
|
4604
|
+
buildChatPanel() {
|
|
4605
|
+
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>`;
|
|
4606
|
+
const input = this.chatEl.querySelector("#loupe-chatin");
|
|
4607
|
+
input.addEventListener("click", (e) => e.stopPropagation());
|
|
4608
|
+
input.oninput = () => {
|
|
4609
|
+
this.chatError = "";
|
|
4610
|
+
this.renderChatError();
|
|
4611
|
+
};
|
|
4612
|
+
input.onkeydown = (e) => {
|
|
4613
|
+
if (e.key === "Enter" && !e.shiftKey) {
|
|
4614
|
+
e.preventDefault();
|
|
4615
|
+
void this.sendCompanion();
|
|
4616
|
+
}
|
|
4617
|
+
};
|
|
4618
|
+
this.chatEl.querySelector("#loupe-chatadd").onclick = (e) => {
|
|
4619
|
+
e.stopPropagation();
|
|
4620
|
+
void this.addSelectionToTray();
|
|
4621
|
+
};
|
|
4622
|
+
this.chatEl.querySelector("#loupe-chatsend").onclick = (e) => {
|
|
4623
|
+
e.stopPropagation();
|
|
4624
|
+
void this.sendCompanion();
|
|
4625
|
+
};
|
|
4626
|
+
this.chatEl.querySelector("#loupe-chatmic").onclick = (e) => {
|
|
4627
|
+
e.stopPropagation();
|
|
4628
|
+
this.toggleVoice();
|
|
4629
|
+
};
|
|
4630
|
+
this.renderChatTray();
|
|
4631
|
+
this.renderChatLog();
|
|
4632
|
+
this.updateChatBadge();
|
|
4633
|
+
}
|
|
4634
|
+
/** The Chat tab label carries the unread count, so it is visible from any page. */
|
|
4635
|
+
updateChatBadge() {
|
|
4636
|
+
const tab = this.tabList.findIndex((t) => t.id === "chat");
|
|
4637
|
+
const buttons = this.shadow?.querySelectorAll(".tabs .tab");
|
|
4638
|
+
const el2 = buttons?.[tab];
|
|
4639
|
+
if (!el2) return;
|
|
4640
|
+
el2.textContent = this.chatUnread ? `Chat \xB7 ${this.chatUnread > 9 ? "9+" : this.chatUnread}` : "Chat";
|
|
4641
|
+
}
|
|
4642
|
+
renderChatError() {
|
|
4643
|
+
const box = this.chatEl?.querySelector("#loupe-chaterr");
|
|
4644
|
+
if (box) box.textContent = this.chatError;
|
|
4645
|
+
}
|
|
4646
|
+
renderChatTray() {
|
|
4647
|
+
const box = this.chatEl?.querySelector("#loupe-tray");
|
|
4648
|
+
if (!box) return;
|
|
4649
|
+
box.textContent = "";
|
|
4650
|
+
if (!this.chatTray.items.length) {
|
|
4651
|
+
box.style.display = "none";
|
|
4652
|
+
return;
|
|
4653
|
+
}
|
|
4654
|
+
box.style.display = "";
|
|
4655
|
+
const head = el("div", "tray-head");
|
|
4656
|
+
head.append(
|
|
4657
|
+
el("span", "tray-title", traySummary(this.chatTray)),
|
|
4658
|
+
el("span", "tray-spacer")
|
|
4659
|
+
);
|
|
4660
|
+
const clear = el("button", "tray-x", "Clear");
|
|
4661
|
+
clear.onclick = (e) => {
|
|
4662
|
+
e.stopPropagation();
|
|
4663
|
+
this.chatTray = clearTray();
|
|
4664
|
+
this.renderChatTray();
|
|
4665
|
+
};
|
|
4666
|
+
head.appendChild(clear);
|
|
4667
|
+
box.appendChild(head);
|
|
4668
|
+
this.chatTray.items.forEach((item, i) => {
|
|
4669
|
+
const chip = el("div", "tray-chip" + (item.include ? "" : " off"));
|
|
4670
|
+
const check = el("input");
|
|
4671
|
+
check.type = "checkbox";
|
|
4672
|
+
check.checked = item.include;
|
|
4673
|
+
check.title = "Include in the message";
|
|
4674
|
+
check.onchange = (e) => {
|
|
4675
|
+
e.stopPropagation();
|
|
4676
|
+
this.chatTray = toggleInclude(this.chatTray, item.id);
|
|
4677
|
+
this.renderChatTray();
|
|
4678
|
+
};
|
|
4679
|
+
const label = el("span", "tray-label", item.label);
|
|
4680
|
+
label.title = item.label + (item.url ? ` \u2014 ${item.url}` : "");
|
|
4681
|
+
const up = el("button", "tray-nudge", "\u2191");
|
|
4682
|
+
up.disabled = i === 0;
|
|
4683
|
+
up.onclick = (e) => {
|
|
4684
|
+
e.stopPropagation();
|
|
4685
|
+
this.chatTray = nudgeInTray(this.chatTray, item.id, -1);
|
|
4686
|
+
this.renderChatTray();
|
|
4687
|
+
};
|
|
4688
|
+
const down = el("button", "tray-nudge", "\u2193");
|
|
4689
|
+
down.disabled = i === this.chatTray.items.length - 1;
|
|
4690
|
+
down.onclick = (e) => {
|
|
4691
|
+
e.stopPropagation();
|
|
4692
|
+
this.chatTray = nudgeInTray(this.chatTray, item.id, 1);
|
|
4693
|
+
this.renderChatTray();
|
|
4694
|
+
};
|
|
4695
|
+
const x = el("button", "tray-x", "\u2715");
|
|
4696
|
+
x.title = "Remove";
|
|
4697
|
+
x.onclick = (e) => {
|
|
4698
|
+
e.stopPropagation();
|
|
4699
|
+
this.chatTray = removeFromTray(this.chatTray, item.id);
|
|
4700
|
+
this.renderChatTray();
|
|
4701
|
+
};
|
|
4702
|
+
chip.append(check, item.thumb ? (() => {
|
|
4703
|
+
const img = el("img", "tray-thumb");
|
|
4704
|
+
img.src = item.thumb;
|
|
4705
|
+
img.alt = "";
|
|
4706
|
+
return img;
|
|
4707
|
+
})() : el("span", "tray-kind", item.kind), label, up, down, x);
|
|
4708
|
+
box.appendChild(chip);
|
|
4709
|
+
});
|
|
4710
|
+
}
|
|
4711
|
+
renderChatLog() {
|
|
4712
|
+
const box = this.chatEl?.querySelector("#loupe-chatlog");
|
|
4713
|
+
if (!box) return;
|
|
4714
|
+
box.textContent = "";
|
|
4715
|
+
if (!this.chatLog.length) {
|
|
4716
|
+
box.appendChild(el(
|
|
4717
|
+
"div",
|
|
4718
|
+
"chat-empty",
|
|
4719
|
+
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."
|
|
4720
|
+
));
|
|
4721
|
+
return;
|
|
4722
|
+
}
|
|
4723
|
+
for (const m of this.chatLog) {
|
|
4724
|
+
const row = el("div", "chat-msg" + (m.mine ? " mine" : ""));
|
|
4725
|
+
const head = el("div", "chat-head");
|
|
4726
|
+
head.append(
|
|
4727
|
+
el("b", "chat-who", m.mine ? "You" : m.author ?? "Agent"),
|
|
4728
|
+
el("span", "chat-when", fmtAgo(m.at))
|
|
4729
|
+
);
|
|
4730
|
+
if (m.count) head.appendChild(el("span", "chat-ctx", `${m.count} context${m.count === 1 ? "" : "s"}`));
|
|
4731
|
+
const body = el("div", "chat-body");
|
|
4732
|
+
for (const seg of mentionSegments(m.body, parseMentions(m.body))) {
|
|
4733
|
+
if (seg.mention) body.appendChild(el("span", "mention", seg.text));
|
|
4734
|
+
else body.appendChild(document.createTextNode(seg.text));
|
|
4735
|
+
}
|
|
4736
|
+
row.append(head, body);
|
|
4737
|
+
box.appendChild(row);
|
|
4738
|
+
}
|
|
4739
|
+
box.scrollTop = box.scrollHeight;
|
|
4740
|
+
}
|
|
4741
|
+
/** Pull the current selection off the bridge and into the tray. */
|
|
4742
|
+
async addSelectionToTray() {
|
|
4743
|
+
const base = this.cfg.bridge?.replace(/\/$/, "");
|
|
4744
|
+
if (!base) {
|
|
4745
|
+
this.chatError = "No bridge configured.";
|
|
4746
|
+
this.renderChatError();
|
|
4747
|
+
return;
|
|
4748
|
+
}
|
|
4749
|
+
try {
|
|
4750
|
+
const res = await fetch(`${base}/selection/latest`);
|
|
4751
|
+
if (!res.ok) throw new Error(String(res.status));
|
|
4752
|
+
const { selection } = await res.json();
|
|
4753
|
+
if (!selection) {
|
|
4754
|
+
this.chatError = "Nothing selected yet.";
|
|
4755
|
+
this.renderChatError();
|
|
4756
|
+
return;
|
|
4757
|
+
}
|
|
4758
|
+
this.chatTray = addToTray(this.chatTray, {
|
|
4759
|
+
id: selection.correlationId ?? selection.selector ?? String(selection.at),
|
|
4760
|
+
kind: "element",
|
|
4761
|
+
// The selector is what identifies it; the text makes it recognisable.
|
|
4762
|
+
label: `${selection.selector ?? selection.tag ?? "element"}${selection.text ? ` \u2014 \u201C${String(selection.text).slice(0, 24)}\u201D` : ""}`,
|
|
4763
|
+
url: selection.url,
|
|
4764
|
+
ref: selection.correlationId ?? selection.selector
|
|
4765
|
+
});
|
|
4766
|
+
this.chatError = "";
|
|
4767
|
+
this.renderChatError();
|
|
4768
|
+
this.renderChatTray();
|
|
4769
|
+
} catch (e) {
|
|
4770
|
+
this.chatError = e instanceof Error ? `Could not read the selection: ${e.message}` : "Could not read the selection.";
|
|
4771
|
+
this.renderChatError();
|
|
4772
|
+
}
|
|
4773
|
+
}
|
|
4774
|
+
async sendCompanion() {
|
|
4775
|
+
const input = this.chatEl.querySelector("#loupe-chatin");
|
|
4776
|
+
const body = input.value.trim();
|
|
4777
|
+
const contexts = trayPayload(this.chatTray);
|
|
4778
|
+
if (!body && !contexts.length) return;
|
|
4779
|
+
if (this.chatSending) return;
|
|
4780
|
+
const base = this.cfg.bridge?.replace(/\/$/, "");
|
|
4781
|
+
if (!base) {
|
|
4782
|
+
this.chatError = "No bridge configured \u2014 nothing to deliver to.";
|
|
4783
|
+
this.renderChatError();
|
|
4784
|
+
return;
|
|
4785
|
+
}
|
|
4786
|
+
this.chatSending = true;
|
|
4787
|
+
this.chatError = "";
|
|
4788
|
+
this.renderChatError();
|
|
4789
|
+
const optimistic = {
|
|
4790
|
+
id: `pending-${Date.now().toString(36)}`,
|
|
4791
|
+
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
4792
|
+
mine: true,
|
|
4793
|
+
body: body || "(context only)",
|
|
4794
|
+
count: contexts.length
|
|
4795
|
+
};
|
|
4796
|
+
this.chatLog.push(optimistic);
|
|
4797
|
+
this.renderChatLog();
|
|
4798
|
+
try {
|
|
4799
|
+
const res = await fetch(`${base}/companion`, {
|
|
4800
|
+
method: "POST",
|
|
4801
|
+
headers: { "content-type": "application/json" },
|
|
4802
|
+
body: JSON.stringify({
|
|
4803
|
+
body: body || "(see the gathered context)",
|
|
4804
|
+
author: { id: this.cfg.user.id, name: this.cfg.user.name },
|
|
4805
|
+
contexts,
|
|
4806
|
+
attachments: includedItems(this.chatTray).filter((i) => i.kind === "screenshot" && i.thumb).map((i) => ({ url: i.thumb, kind: "image", name: i.label })),
|
|
4807
|
+
voice: this.voiceBase.length > 0
|
|
4808
|
+
})
|
|
4809
|
+
});
|
|
4810
|
+
if (!res.ok) throw new Error(`the bridge answered ${res.status}`);
|
|
4811
|
+
input.value = "";
|
|
4812
|
+
this.chatTray = clearTray();
|
|
4813
|
+
this.modelVoiceBase("");
|
|
4814
|
+
this.renderChatTray();
|
|
4815
|
+
this.addActivity({ kind: "message.create", label: `Sent a brief to the agent${contexts.length ? ` (${contexts.length} contexts)` : ""}` });
|
|
4816
|
+
} catch (e) {
|
|
4817
|
+
this.chatError = e instanceof Error ? e.message : "Could not send.";
|
|
4818
|
+
this.renderChatError();
|
|
4819
|
+
this.addActivity({ kind: "error", level: "error", label: "Companion message failed to send" });
|
|
4820
|
+
} finally {
|
|
4821
|
+
this.chatSending = false;
|
|
4822
|
+
this.renderChatLog();
|
|
4823
|
+
}
|
|
4824
|
+
}
|
|
4825
|
+
modelVoiceBase(v) {
|
|
4826
|
+
this.voiceBase = v;
|
|
4827
|
+
}
|
|
4445
4828
|
/**
|
|
4446
|
-
*
|
|
4447
|
-
*
|
|
4448
|
-
*
|
|
4829
|
+
* Dictation.
|
|
4830
|
+
*
|
|
4831
|
+
* Two different reasons the control can be unavailable, and they need different
|
|
4832
|
+
* words: an unsupported browser is a fact, while a non-secure page is something the
|
|
4833
|
+
* person can fix. Saying "your browser cannot do this" when the real problem is
|
|
4834
|
+
* `http://` sends them looking in the wrong place.
|
|
4449
4835
|
*/
|
|
4836
|
+
toggleVoice() {
|
|
4837
|
+
const support = voiceSupport({
|
|
4838
|
+
hasCtor: typeof globalThis.SpeechRecognition !== "undefined" || typeof globalThis.webkitSpeechRecognition !== "undefined",
|
|
4839
|
+
isSecureContext: typeof isSecureContext === "boolean" ? isSecureContext : true
|
|
4840
|
+
});
|
|
4841
|
+
if (support !== "supported") {
|
|
4842
|
+
this.chatError = voiceMessage(support) ?? "Dictation is unavailable.";
|
|
4843
|
+
this.renderChatError();
|
|
4844
|
+
return;
|
|
4845
|
+
}
|
|
4846
|
+
if (this.voiceOn) return this.stopChatVoice();
|
|
4847
|
+
const Ctor = globalThis.SpeechRecognition ?? globalThis.webkitSpeechRecognition;
|
|
4848
|
+
const input = this.chatEl.querySelector("#loupe-chatin");
|
|
4849
|
+
try {
|
|
4850
|
+
this.stopChatVoice();
|
|
4851
|
+
const rec = new Ctor();
|
|
4852
|
+
rec.continuous = true;
|
|
4853
|
+
rec.interimResults = true;
|
|
4854
|
+
rec.lang = document.documentElement.lang || "en-US";
|
|
4855
|
+
this.voiceBase = input.value ? `${input.value.trim()} ` : "";
|
|
4856
|
+
rec.onresult = (ev) => {
|
|
4857
|
+
let text = "";
|
|
4858
|
+
for (let i = ev.resultIndex; i < ev.results.length; i++) text += ev.results[i][0].transcript;
|
|
4859
|
+
input.value = `${this.voiceBase}${text}`;
|
|
4860
|
+
};
|
|
4861
|
+
rec.onerror = (ev) => {
|
|
4862
|
+
this.chatError = ev?.error === "not-allowed" ? "Microphone permission was refused." : "Dictation stopped.";
|
|
4863
|
+
this.renderChatError();
|
|
4864
|
+
this.stopChatVoice();
|
|
4865
|
+
};
|
|
4866
|
+
rec.onend = () => {
|
|
4867
|
+
if (this.voiceOn) this.stopChatVoice();
|
|
4868
|
+
};
|
|
4869
|
+
rec.start();
|
|
4870
|
+
this.voice = rec;
|
|
4871
|
+
this.voiceTarget = input;
|
|
4872
|
+
this.voiceOn = true;
|
|
4873
|
+
this.voiceStart = Date.now();
|
|
4874
|
+
const pill = this.chatEl.querySelector("#loupe-chatrec");
|
|
4875
|
+
pill.style.display = "";
|
|
4876
|
+
this.voiceTimer = setInterval(() => {
|
|
4877
|
+
const t = this.chatEl.querySelector("#loupe-chatrectime");
|
|
4878
|
+
if (t) t.textContent = elapsedLabel(Date.now() - this.voiceStart);
|
|
4879
|
+
}, 500);
|
|
4880
|
+
this.chatEl.querySelector("#loupe-chatmic").classList.add("on");
|
|
4881
|
+
this.renderChatTray();
|
|
4882
|
+
} catch (e) {
|
|
4883
|
+
this.chatError = e instanceof Error ? e.message : "Could not start dictation.";
|
|
4884
|
+
this.renderChatError();
|
|
4885
|
+
}
|
|
4886
|
+
}
|
|
4887
|
+
stopChatVoice() {
|
|
4888
|
+
this.voiceOn = false;
|
|
4889
|
+
if (this.voiceTimer) clearInterval(this.voiceTimer);
|
|
4890
|
+
this.voiceTimer = void 0;
|
|
4891
|
+
this.stopVoice();
|
|
4892
|
+
const pill = this.chatEl?.querySelector("#loupe-chatrec");
|
|
4893
|
+
if (pill) pill.style.display = "none";
|
|
4894
|
+
this.chatEl?.querySelector("#loupe-chatmic")?.classList.remove("on");
|
|
4895
|
+
}
|
|
4896
|
+
/**
|
|
4897
|
+
* Follow the bridge for replies.
|
|
4898
|
+
*
|
|
4899
|
+
* With SSE where it works and a poll where it does not, because a reply the person
|
|
4900
|
+
* never sees is the same as no reply — and the panel is the only place it appears.
|
|
4901
|
+
*/
|
|
4902
|
+
startCompanionStream() {
|
|
4903
|
+
const base = this.cfg.bridge?.replace(/\/$/, "");
|
|
4904
|
+
if (!base || this.companionSource) return;
|
|
4905
|
+
if (typeof EventSource === "undefined") return;
|
|
4906
|
+
try {
|
|
4907
|
+
const source = new EventSource(`${base}/events`);
|
|
4908
|
+
source.addEventListener("companion", (ev) => {
|
|
4909
|
+
let parsed;
|
|
4910
|
+
try {
|
|
4911
|
+
parsed = JSON.parse(ev.data);
|
|
4912
|
+
} catch {
|
|
4913
|
+
return;
|
|
4914
|
+
}
|
|
4915
|
+
if (parsed?.eventType === "reply") this.receiveReply(parsed.data);
|
|
4916
|
+
});
|
|
4917
|
+
source.onerror = () => {
|
|
4918
|
+
};
|
|
4919
|
+
this.companionSource = source;
|
|
4920
|
+
this.companionPoll = setInterval(() => void this.pollReplies(), 5e3);
|
|
4921
|
+
} catch {
|
|
4922
|
+
this.companionPoll = setInterval(() => void this.pollReplies(), 4e3);
|
|
4923
|
+
}
|
|
4924
|
+
}
|
|
4925
|
+
async pollReplies() {
|
|
4926
|
+
const base = this.cfg.bridge?.replace(/\/$/, "");
|
|
4927
|
+
if (!base) return;
|
|
4928
|
+
try {
|
|
4929
|
+
const res = await fetch(`${base}/companion?since=${encodeURIComponent(this.lastReplyId)}`);
|
|
4930
|
+
if (!res.ok) return;
|
|
4931
|
+
const { replies } = await res.json();
|
|
4932
|
+
for (const r of replies ?? []) this.receiveReply(r);
|
|
4933
|
+
} catch {
|
|
4934
|
+
}
|
|
4935
|
+
}
|
|
4936
|
+
receiveReply(reply) {
|
|
4937
|
+
if (!reply?.id || !reply.body) return;
|
|
4938
|
+
if (this.chatLog.some((m) => m.id === reply.id)) return;
|
|
4939
|
+
this.lastReplyId = reply.id;
|
|
4940
|
+
this.chatLog.push({ id: reply.id, at: reply.at ?? (/* @__PURE__ */ new Date()).toISOString(), mine: false, body: reply.body });
|
|
4941
|
+
if (this.tab !== "chat" || this.minimized) {
|
|
4942
|
+
this.chatUnread++;
|
|
4943
|
+
this.updateChatBadge();
|
|
4944
|
+
this.desktopNotify("Loupe \u2014 the agent replied", reply.body);
|
|
4945
|
+
}
|
|
4946
|
+
this.addActivity({ kind: "agent.reply", label: "The agent replied" });
|
|
4947
|
+
this.renderChatLog();
|
|
4948
|
+
}
|
|
4949
|
+
/** A desktop notification, when the browser will show one. Never a prompt for it. */
|
|
4950
|
+
desktopNotify(title, body) {
|
|
4951
|
+
try {
|
|
4952
|
+
const N = globalThis.Notification;
|
|
4953
|
+
if (!N || N.permission !== "granted") return;
|
|
4954
|
+
new N(title, { body: String(body).slice(0, 200), tag: "loupe-companion" });
|
|
4955
|
+
} catch {
|
|
4956
|
+
}
|
|
4957
|
+
}
|
|
4450
4958
|
buildActivityPanel() {
|
|
4451
4959
|
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>`;
|
|
4452
4960
|
this.feedEl = this.activityEl.querySelector("#loupe-mon-feed");
|
|
@@ -6787,17 +7295,17 @@ ${c.body}` : c.body)}</div>` + (c.context?.html ? `<pre class="or-code">${escape
|
|
|
6787
7295
|
if (typeof EventSource === "undefined") return;
|
|
6788
7296
|
try {
|
|
6789
7297
|
const source = new EventSource(`${bridge.replace(/\/$/, "")}/thread-updates`);
|
|
6790
|
-
source.
|
|
7298
|
+
source.addEventListener("thread", (ev) => {
|
|
6791
7299
|
let parsed;
|
|
6792
7300
|
try {
|
|
6793
7301
|
parsed = JSON.parse(ev.data);
|
|
6794
7302
|
} catch {
|
|
6795
7303
|
return;
|
|
6796
7304
|
}
|
|
6797
|
-
if (!parsed ||
|
|
7305
|
+
if (!parsed || typeof parsed.threadId !== "string") return;
|
|
6798
7306
|
if (!this.messages.has(parsed.threadId)) return;
|
|
6799
7307
|
void this.refreshMessages(parsed.threadId);
|
|
6800
|
-
};
|
|
7308
|
+
});
|
|
6801
7309
|
source.onerror = () => {
|
|
6802
7310
|
};
|
|
6803
7311
|
this.liveSource = source;
|
|
@@ -7020,6 +7528,10 @@ ${c.body}` : c.body)}</div>` + (c.context?.html ? `<pre class="or-code">${escape
|
|
|
7020
7528
|
destroy() {
|
|
7021
7529
|
this.liveSource?.close();
|
|
7022
7530
|
this.liveSource = void 0;
|
|
7531
|
+
this.companionSource?.close();
|
|
7532
|
+
this.companionSource = void 0;
|
|
7533
|
+
if (this.companionPoll) clearInterval(this.companionPoll);
|
|
7534
|
+
this.stopVoice();
|
|
7023
7535
|
this.stopPresence();
|
|
7024
7536
|
this.stopRecording?.();
|
|
7025
7537
|
this.setMode("off");
|