viberoom 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +661 -0
- package/NOTICE +20 -0
- package/README.md +153 -0
- package/assets/icon-128.png +0 -0
- package/assets/icon-16.png +0 -0
- package/assets/icon-256.png +0 -0
- package/assets/icon-32.png +0 -0
- package/assets/icon-48.png +0 -0
- package/assets/icon-512.png +0 -0
- package/assets/icon-64.png +0 -0
- package/assets/icon-vector.svg +30 -0
- package/assets/icon.icns +0 -0
- package/assets/icon.ico +0 -0
- package/assets/icon.svg +30 -0
- package/assets/vendors/claude.svg +3 -0
- package/assets/vendors/codex.svg +3 -0
- package/assets/vendors/copilot.svg +5 -0
- package/assets/vendors/cursor.svg +3 -0
- package/assets/vendors/gemini.svg +3 -0
- package/assets/vendors/opencode.svg +3 -0
- package/dist/acp-client.js +137 -0
- package/dist/acp-types.js +2 -0
- package/dist/edit.js +34 -0
- package/dist/hub.js +348 -0
- package/dist/icons.js +235 -0
- package/dist/jsonrpc.js +109 -0
- package/dist/launcher.js +161 -0
- package/dist/log.js +35 -0
- package/dist/main.js +389 -0
- package/dist/mcp-skills-server.js +177 -0
- package/dist/open.js +141 -0
- package/dist/persona.js +217 -0
- package/dist/recipes.js +261 -0
- package/dist/room.js +2124 -0
- package/dist/server.js +433 -0
- package/dist/shortcuts.js +176 -0
- package/dist/skills.js +344 -0
- package/dist/tui.js +109 -0
- package/package.json +61 -0
- package/scripts/install.mjs +34 -0
- package/scripts/render-icon.mjs +84 -0
- package/scripts/update.mjs +29 -0
- package/ui/app.css +346 -0
- package/ui/app.js +2834 -0
- package/ui/avatars.js +113 -0
- package/ui/fonts/OFL.txt +93 -0
- package/ui/fonts/nunito-cyrillic-ext.woff2 +0 -0
- package/ui/fonts/nunito-cyrillic.woff2 +0 -0
- package/ui/fonts/nunito-latin-ext.woff2 +0 -0
- package/ui/fonts/nunito-latin.woff2 +0 -0
- package/ui/fonts/nunito-vietnamese.woff2 +0 -0
- package/ui/fonts/nunito.css +6 -0
- package/ui/icons.js +76 -0
- package/ui/index.html +217 -0
- package/ui/manifest.json +14 -0
- package/ui/theme.css +425 -0
package/ui/app.js
ADDED
|
@@ -0,0 +1,2834 @@
|
|
|
1
|
+
// viberoom - Copyright (c) 2026 Todor Rusev - AGPL-3.0-or-later; see LICENSE
|
|
2
|
+
(() => {
|
|
3
|
+
"use strict";
|
|
4
|
+
|
|
5
|
+
const state = {
|
|
6
|
+
settings: null,
|
|
7
|
+
recipes: [],
|
|
8
|
+
roomDefaults: null,
|
|
9
|
+
skills: [],
|
|
10
|
+
version: null,
|
|
11
|
+
rooms: new Map(),
|
|
12
|
+
currentRoomId: null,
|
|
13
|
+
view: "home",
|
|
14
|
+
selection: { kind: "room" },
|
|
15
|
+
detailsOpen: false,
|
|
16
|
+
unread: new Map(),
|
|
17
|
+
search: "",
|
|
18
|
+
roomSearch: "",
|
|
19
|
+
expanded: new Set(),
|
|
20
|
+
skillEditor: null,
|
|
21
|
+
skillsRoom: null,
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const $ = (selector) => document.querySelector(selector);
|
|
25
|
+
const els = {
|
|
26
|
+
app: $("#app"),
|
|
27
|
+
rail: $("#rail"),
|
|
28
|
+
railRoom: $("#rail-room"),
|
|
29
|
+
railRoomLabel: $("#rail-room-label"),
|
|
30
|
+
railUnread: $("#rail-unread"),
|
|
31
|
+
railMe: $("#rail-me"),
|
|
32
|
+
railMeAvatar: $("#rail-me-avatar"),
|
|
33
|
+
railMeLabel: $("#rail-me-label"),
|
|
34
|
+
railToggle: $("#rail-toggle"),
|
|
35
|
+
conn: $("#conn"),
|
|
36
|
+
sideRooms: $("#side-rooms"),
|
|
37
|
+
sideRoom: $("#side-room"),
|
|
38
|
+
roomSearch: $("#room-search"),
|
|
39
|
+
roomList: $("#room-list"),
|
|
40
|
+
backToRooms: $("#back-to-rooms"),
|
|
41
|
+
sideRoomName: $("#side-room-name"),
|
|
42
|
+
sideRoomSub: $("#side-room-sub"),
|
|
43
|
+
roomSettingsBtn: $("#room-settings-btn"),
|
|
44
|
+
inviteBtn: $("#invite-btn"),
|
|
45
|
+
participants: $("#participants"),
|
|
46
|
+
reconnectAllBtn: $("#reconnect-all-btn"),
|
|
47
|
+
focusBtn: $("#focus-btn"),
|
|
48
|
+
homeView: $("#home-view"),
|
|
49
|
+
roomsView: $("#rooms-view"),
|
|
50
|
+
roomsGrid: $("#rooms-grid"),
|
|
51
|
+
roomsSub: $("#rooms-sub"),
|
|
52
|
+
chatView: $("#chat-view"),
|
|
53
|
+
chatRoomName: $("#chat-room-name"),
|
|
54
|
+
chatRoomSub: $("#chat-room-sub"),
|
|
55
|
+
chatInfoBtn: $("#chat-info-btn"),
|
|
56
|
+
search: $("#search"),
|
|
57
|
+
messages: $("#messages"),
|
|
58
|
+
mentionMenu: $("#mention-menu"),
|
|
59
|
+
emojiMenu: $("#emoji-menu"),
|
|
60
|
+
emojiBtn: $("#emoji-btn"),
|
|
61
|
+
sideRoomEmoji: $("#side-room-emoji"),
|
|
62
|
+
composer: $("#composer"),
|
|
63
|
+
input: $("#input"),
|
|
64
|
+
pageView: $("#page-view"),
|
|
65
|
+
pageInner: $("#page-inner"),
|
|
66
|
+
details: $("#details"),
|
|
67
|
+
detailsInner: $("#details-inner"),
|
|
68
|
+
detailsResizer: $("#details-resizer"),
|
|
69
|
+
toasts: $("#toasts"),
|
|
70
|
+
pfDialog: $("#profile-dialog"),
|
|
71
|
+
pfForm: $("#profile-form"),
|
|
72
|
+
pfName: $("#pf-name"),
|
|
73
|
+
pfAvatar: $("#pf-avatar"),
|
|
74
|
+
pfAvatarPicker: $("#pf-avatar-picker"),
|
|
75
|
+
pfDesc: $("#pf-desc"),
|
|
76
|
+
pfError: $("#pf-error"),
|
|
77
|
+
roomDialog: $("#room-dialog"),
|
|
78
|
+
roomForm: $("#room-form"),
|
|
79
|
+
roomName: $("#room-name"),
|
|
80
|
+
roomDir: $("#room-dir"),
|
|
81
|
+
roomError: $("#room-error"),
|
|
82
|
+
dialog: $("#invite-dialog"),
|
|
83
|
+
invForm: $("#invite-form"),
|
|
84
|
+
invType: $("#inv-type"),
|
|
85
|
+
invNote: $("#inv-note"),
|
|
86
|
+
invWhere: $("#inv-where"),
|
|
87
|
+
invStatus: $("#inv-status"),
|
|
88
|
+
invName: $("#inv-name"),
|
|
89
|
+
invAvatar: $("#inv-avatar"),
|
|
90
|
+
invAvatarPicker: $("#inv-avatar-picker"),
|
|
91
|
+
invTagline: $("#inv-tagline"),
|
|
92
|
+
invRole: $("#inv-role"),
|
|
93
|
+
invDelay: $("#inv-delay"),
|
|
94
|
+
invSkills: $("#inv-skills"),
|
|
95
|
+
invGeek: $("#inv-geek"),
|
|
96
|
+
invAgents: $("#inv-agents"),
|
|
97
|
+
invNone: $("#inv-none"),
|
|
98
|
+
invOptions: $("#inv-options"),
|
|
99
|
+
sideRoomsTitle: $("#side-rooms-title"),
|
|
100
|
+
sideToggle: $("#side-toggle"),
|
|
101
|
+
invModel: $("#inv-model"),
|
|
102
|
+
invModelCustom: $("#inv-model-custom"),
|
|
103
|
+
invEffort: $("#inv-effort"),
|
|
104
|
+
invMode: $("#inv-mode"),
|
|
105
|
+
invError: $("#inv-error"),
|
|
106
|
+
invRefresh: $("#inv-refresh"),
|
|
107
|
+
invSubmit: $("#inv-submit"),
|
|
108
|
+
rcDialog: $("#reconnect-dialog"),
|
|
109
|
+
rcForm: $("#reconnect-form"),
|
|
110
|
+
rcIntro: $("#rc-intro"),
|
|
111
|
+
rcReplay: $("#rc-replay"),
|
|
112
|
+
rcTable: $("#rc-table"),
|
|
113
|
+
rcError: $("#rc-error"),
|
|
114
|
+
rcSubmit: $("#rc-submit"),
|
|
115
|
+
eraseDialog: $("#erase-dialog"),
|
|
116
|
+
eraseForm: $("#erase-form"),
|
|
117
|
+
eraseWord: $("#erase-word"),
|
|
118
|
+
eraseError: $("#erase-error"),
|
|
119
|
+
eraseSubmit: $("#erase-submit"),
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const STATUS_LABEL = { starting: "starting…", idle: "ready", queued: "waiting…", thinking: "thinking…", error: "error", offline: "offline", left: "left" };
|
|
123
|
+
const CHAT_EMOJI = ["😀", "😄", "😂", "🙂", "😉", "😍", "🤔", "😎", "🥳", "😅", "😢", "😡", "👍", "👎", "👋", "🙏", "👏", "💪", "🔥", "✨", "🎉", "❤️", "💜", "✅", "❌", "⚠️", "💡", "🚀", "🐛", "🤖", "🤫", "☕"];
|
|
124
|
+
const ROOM_EMOJI = ["🎭", "🚀", "🧪", "🛠️", "🎨", "📚", "🧠", "💬", "🔬", "🎯", "🐙", "☕", "🌈", "🏗️", "🎮", "🔥", "🧩", "📈", "🗺️", "🎧", "🌱", "🏠", "🛸", "🧭"];
|
|
125
|
+
function emojiGrid(list, current, onPick) {
|
|
126
|
+
const wrap = document.createElement("div");
|
|
127
|
+
wrap.className = "avatar-picker";
|
|
128
|
+
const render = (value) => {
|
|
129
|
+
wrap.innerHTML = "";
|
|
130
|
+
if (current !== null && current !== undefined) {
|
|
131
|
+
const none = document.createElement("button");
|
|
132
|
+
none.type = "button";
|
|
133
|
+
none.className = "none" + (!value ? " selected" : "");
|
|
134
|
+
none.textContent = "—";
|
|
135
|
+
none.title = "No emoji";
|
|
136
|
+
none.addEventListener("click", () => {
|
|
137
|
+
onPick("");
|
|
138
|
+
render("");
|
|
139
|
+
});
|
|
140
|
+
wrap.appendChild(none);
|
|
141
|
+
}
|
|
142
|
+
for (const e of list) {
|
|
143
|
+
const b = document.createElement("button");
|
|
144
|
+
b.type = "button";
|
|
145
|
+
b.textContent = e;
|
|
146
|
+
b.className = e === value ? "selected" : "";
|
|
147
|
+
b.addEventListener("click", () => {
|
|
148
|
+
onPick(e);
|
|
149
|
+
render(e);
|
|
150
|
+
});
|
|
151
|
+
wrap.appendChild(b);
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
render(current || "");
|
|
155
|
+
return wrap;
|
|
156
|
+
}
|
|
157
|
+
const CLAMP_CHARS = 700;
|
|
158
|
+
|
|
159
|
+
window.Icons.install();
|
|
160
|
+
const ic = (name, cls) => window.Icons.svg(name, cls);
|
|
161
|
+
document.querySelectorAll("[data-icon]").forEach((el) => (el.innerHTML = ic(el.dataset.icon)));
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
let stallNoticeAt = 0;
|
|
165
|
+
function stallWatch(promise) {
|
|
166
|
+
const timer = setTimeout(() => {
|
|
167
|
+
if (Date.now() - stallNoticeAt > 30000) {
|
|
168
|
+
stallNoticeAt = Date.now();
|
|
169
|
+
toast("Still waiting for the hub… If several viberoom tabs are open, close the others: the browser allows only 6 connections to the hub and each tab keeps one.", "warn");
|
|
170
|
+
}
|
|
171
|
+
}, 8000);
|
|
172
|
+
return promise.finally(() => clearTimeout(timer));
|
|
173
|
+
}
|
|
174
|
+
async function post(path, body) {
|
|
175
|
+
const res = await stallWatch(fetch(path, { method: "POST", headers: { "Content-Type": "application/json; charset=utf-8" }, body: JSON.stringify(body || {}) }));
|
|
176
|
+
const data = await res.json().catch(() => ({}));
|
|
177
|
+
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
|
|
178
|
+
return data;
|
|
179
|
+
}
|
|
180
|
+
async function get(path) {
|
|
181
|
+
const res = await fetch(path);
|
|
182
|
+
const data = await res.json().catch(() => ({}));
|
|
183
|
+
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
|
|
184
|
+
return data;
|
|
185
|
+
}
|
|
186
|
+
const roomApi = (suffix) => `/api/rooms/${encodeURIComponent(state.currentRoomId)}${suffix}`;
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
function esc(value) {
|
|
190
|
+
return String(value).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
|
|
191
|
+
}
|
|
192
|
+
function editingInDetails() {
|
|
193
|
+
const el = document.activeElement;
|
|
194
|
+
return !!el && !!el.closest && (!!el.closest("#details") || !!el.closest("#page-view")) && /^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName);
|
|
195
|
+
}
|
|
196
|
+
function currentRoom() {
|
|
197
|
+
return state.rooms.get(state.currentRoomId) || null;
|
|
198
|
+
}
|
|
199
|
+
function findByName(room, name) {
|
|
200
|
+
const lower = name.toLowerCase();
|
|
201
|
+
return (room ? room.participants : []).find((p) => p.name.toLowerCase() === lower) || null;
|
|
202
|
+
}
|
|
203
|
+
function findById(room, id) {
|
|
204
|
+
return (room ? room.participants : []).find((p) => p.id === id) || null;
|
|
205
|
+
}
|
|
206
|
+
const OPEN_RE = /(?:https?:\/\/|mailto:)[^\s<>"'`]+|(?<![\w:\/.])((?:[A-Za-z]:[\\/]|~[\\/])[^\s<>"'`*?|&]+|\/(?:[\w.@-]+\/)+[\w.@-][^\s<>"'`*?|&]*)/g;
|
|
207
|
+
function linkify(html) {
|
|
208
|
+
return html.replace(OPEN_RE, (m) => {
|
|
209
|
+
const trail = (m.match(/[.,;:!?)\]]+$/) || [""])[0];
|
|
210
|
+
const target = m.slice(0, m.length - trail.length);
|
|
211
|
+
const isUrl = /^(https?:|mailto:)/i.test(target);
|
|
212
|
+
return `<a class="open-link" data-open="${target}" href="#" title="${isUrl ? "Open in your browser" : "Open with the default app"}">${target}</a>${trail}`;
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
function mermaidBlock(code) {
|
|
216
|
+
return `<div class="mermaid-block" data-src="${code}"><div class="mm-out"><pre>${code}</pre></div><pre class="mm-code" hidden>${code}</pre><div class="mm-bar"><button type="button" class="mm-src">source</button></div></div>`;
|
|
217
|
+
}
|
|
218
|
+
function renderText(room, text) {
|
|
219
|
+
let html = esc(text);
|
|
220
|
+
const blocks = [];
|
|
221
|
+
html = html.replace(/```([^\n]*)\n([\s\S]*?)```/g, (m, lang, code) => {
|
|
222
|
+
blocks.push(/^\s*mermaid\b/i.test(lang) ? mermaidBlock(code.trim()) : `<pre>${code}</pre>`);
|
|
223
|
+
return `\u0000${blocks.length - 1}\u0000`;
|
|
224
|
+
});
|
|
225
|
+
html = linkify(html);
|
|
226
|
+
html = html.replace(/`([^`\n]+)`/g, "<code>$1</code>");
|
|
227
|
+
html = html.replace(/\*\*([^*\n]+)\*\*/g, "<strong>$1</strong>");
|
|
228
|
+
html = html.replace(/(?<![\w.\/:])@([\p{L}\p{N}][\p{L}\p{N}_-]*)/gu, (m, name) => {
|
|
229
|
+
const p = findByName(room, name);
|
|
230
|
+
return p ? `<span class="mention" style="color:${p.color}">@${esc(name)}</span>` : m;
|
|
231
|
+
});
|
|
232
|
+
html = html.replace(/\u0000(\d+)\u0000/g, (m, i) => blocks[Number(i)]);
|
|
233
|
+
return html;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
const POP_PALETTE = [
|
|
238
|
+
{ fill: "#e4e6fb", stroke: "#5b5bf0" },
|
|
239
|
+
{ fill: "#d9f7e8", stroke: "#1d8f6a" },
|
|
240
|
+
{ fill: "#fff3cc", stroke: "#b8860b" },
|
|
241
|
+
{ fill: "#ffe4d6", stroke: "#d2691e" },
|
|
242
|
+
{ fill: "#ffe3ec", stroke: "#d6336c" },
|
|
243
|
+
{ fill: "#dcefff", stroke: "#2a6fbf" },
|
|
244
|
+
{ fill: "#f1e3fb", stroke: "#8a3fb8" },
|
|
245
|
+
];
|
|
246
|
+
const DIAGRAM_PRESETS = {
|
|
247
|
+
pop: { label: "Pop", palette: POP_PALETTE, primaryColor: "#e4e6fb", primaryBorderColor: "#5b5bf0", primaryTextColor: "#1c1b33", lineColor: "#8f8fb0", secondaryColor: "#d9f7e8", secondaryBorderColor: "#1d8f6a", tertiaryColor: "#fff3cc", tertiaryBorderColor: "#b8860b", textColor: "#1c1b33", clusterBkg: "#f8f8fd", clusterBorder: "#d9dbf5", edgeLabelBackground: "#ffffff", noteBkgColor: "#fff3cc", noteBorderColor: "#b8860b" },
|
|
248
|
+
lavender: { label: "Lavender", primaryColor: "#ece9ff", primaryBorderColor: "#6d5dfc", primaryTextColor: "#24223d", lineColor: "#5a4be0", secondaryColor: "#e3f8f2", secondaryBorderColor: "#39c6a3", tertiaryColor: "#fff4d6", tertiaryBorderColor: "#f5a524", textColor: "#24223d", clusterBkg: "#f7f6fc", clusterBorder: "#d6d1f5", edgeLabelBackground: "#ffffff" },
|
|
249
|
+
mint: { label: "Mint", primaryColor: "#e3f8f2", primaryBorderColor: "#39c6a3", primaryTextColor: "#0f3d33", lineColor: "#2a9d84", secondaryColor: "#ece9ff", secondaryBorderColor: "#6d5dfc", tertiaryColor: "#fff4d6", tertiaryBorderColor: "#f5a524", textColor: "#1b3a33", clusterBkg: "#f3fbf8", clusterBorder: "#b4ecdc", edgeLabelBackground: "#ffffff" },
|
|
250
|
+
sunset: { label: "Sunset", primaryColor: "#ffe9d6", primaryBorderColor: "#f5a524", primaryTextColor: "#4a2b00", lineColor: "#d97706", secondaryColor: "#ffe9ec", secondaryBorderColor: "#ef5b6b", tertiaryColor: "#ece9ff", tertiaryBorderColor: "#6d5dfc", textColor: "#3b2a1a", clusterBkg: "#fff8f0", clusterBorder: "#fde1c2", edgeLabelBackground: "#ffffff" },
|
|
251
|
+
slate: { label: "Slate", primaryColor: "#e9edf3", primaryBorderColor: "#64748b", primaryTextColor: "#1e293b", lineColor: "#475569", secondaryColor: "#f1f5f9", secondaryBorderColor: "#94a3b8", tertiaryColor: "#e2e8f0", tertiaryBorderColor: "#64748b", textColor: "#1e293b", clusterBkg: "#f8fafc", clusterBorder: "#cbd5e1", edgeLabelBackground: "#ffffff" },
|
|
252
|
+
};
|
|
253
|
+
function diagramSettings(d) {
|
|
254
|
+
return d || (state.settings && state.settings.diagrams) || {};
|
|
255
|
+
}
|
|
256
|
+
function mermaidThemeVariables(d) {
|
|
257
|
+
d = diagramSettings(d);
|
|
258
|
+
const preset = DIAGRAM_PRESETS[d.preset] || DIAGRAM_PRESETS.pop;
|
|
259
|
+
const vars = Object.assign({}, preset, { fontFamily: "Nunito, Segoe UI, system-ui, -apple-system, Roboto, sans-serif", fontSize: "13px" });
|
|
260
|
+
delete vars.label;
|
|
261
|
+
delete vars.palette;
|
|
262
|
+
if (preset.palette) preset.palette.forEach((c, i) => (vars[`pie${i + 1}`] = c.fill));
|
|
263
|
+
if (d.primary) {
|
|
264
|
+
vars.primaryColor = d.primary;
|
|
265
|
+
delete vars.primaryBorderColor;
|
|
266
|
+
delete vars.primaryTextColor;
|
|
267
|
+
}
|
|
268
|
+
return vars;
|
|
269
|
+
}
|
|
270
|
+
function diagramPalette(d) {
|
|
271
|
+
d = diagramSettings(d);
|
|
272
|
+
const preset = DIAGRAM_PRESETS[d.preset] || DIAGRAM_PRESETS.pop;
|
|
273
|
+
return preset.palette && !d.primary ? preset.palette : null;
|
|
274
|
+
}
|
|
275
|
+
const MERMAID_CSS = [
|
|
276
|
+
".node rect, .node .label-container, .node .basic, .cluster rect, rect.actor { rx: 12px; ry: 12px; }",
|
|
277
|
+
".node .label-container, .node .basic, .node rect, .node circle, .node ellipse, rect.actor { stroke-width: 1.8px; filter: drop-shadow(0 2px 0 rgba(28, 27, 51, 0.10)); }",
|
|
278
|
+
".edgePath .path, .flowchart-link, .messageLine0, .messageLine1, .transition, .relation { stroke-width: 2px; }",
|
|
279
|
+
".edgeLabel, .edgeLabel p { font-weight: 700; }",
|
|
280
|
+
".cluster rect { stroke-dasharray: 4 3; stroke-width: 1.5px; }",
|
|
281
|
+
".cluster-label, .cluster-label p { font-weight: 800; }",
|
|
282
|
+
].join(" ");
|
|
283
|
+
function paintDiagram(root, palette) {
|
|
284
|
+
const ink = "#1c1b33";
|
|
285
|
+
const byKey = new Map();
|
|
286
|
+
const pick = (key) => {
|
|
287
|
+
if (!byKey.has(key)) byKey.set(key, palette[byKey.size % palette.length]);
|
|
288
|
+
return byKey.get(key);
|
|
289
|
+
};
|
|
290
|
+
for (const node of root.querySelectorAll("g.node")) {
|
|
291
|
+
const shape = node.querySelector(":scope > .label-container, :scope > .basic, :scope > rect, :scope > polygon, :scope > circle, :scope > ellipse, :scope > path");
|
|
292
|
+
if (!shape) continue;
|
|
293
|
+
const c = pick(node.id || node.getAttribute("data-id") || String(byKey.size));
|
|
294
|
+
shape.style.fill = c.fill;
|
|
295
|
+
shape.style.stroke = c.stroke;
|
|
296
|
+
node.querySelectorAll(".nodeLabel, text").forEach((t) => {
|
|
297
|
+
t.style.color = ink;
|
|
298
|
+
t.style.fill = ink;
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
const actors = new Map();
|
|
302
|
+
for (const rect of root.querySelectorAll("rect.actor")) {
|
|
303
|
+
const name = rect.getAttribute("name") || String(actors.size);
|
|
304
|
+
if (!actors.has(name)) actors.set(name, palette[actors.size % palette.length]);
|
|
305
|
+
rect.style.fill = actors.get(name).fill;
|
|
306
|
+
rect.style.stroke = actors.get(name).stroke;
|
|
307
|
+
}
|
|
308
|
+
root.querySelectorAll("text.actor, text.actor tspan").forEach((t) => (t.style.fill = ink));
|
|
309
|
+
}
|
|
310
|
+
let mermaidLoading = null;
|
|
311
|
+
function loadMermaid() {
|
|
312
|
+
if (window.mermaid) return Promise.resolve(window.mermaid);
|
|
313
|
+
if (!mermaidLoading) {
|
|
314
|
+
mermaidLoading = new Promise((resolve, reject) => {
|
|
315
|
+
const s = document.createElement("script");
|
|
316
|
+
s.src = "/vendor/mermaid.min.js";
|
|
317
|
+
s.onload = () => resolve(window.mermaid);
|
|
318
|
+
s.onerror = () => reject(new Error("could not load Mermaid from the hub"));
|
|
319
|
+
document.head.appendChild(s);
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
return mermaidLoading;
|
|
323
|
+
}
|
|
324
|
+
let mermaidSeq = 0;
|
|
325
|
+
async function renderDiagrams(root, d) {
|
|
326
|
+
const blocks = [...root.querySelectorAll(".mermaid-block:not([data-rendered])")];
|
|
327
|
+
if (!blocks.length) return;
|
|
328
|
+
for (const b of blocks) b.dataset.rendered = "1";
|
|
329
|
+
let mermaid;
|
|
330
|
+
try {
|
|
331
|
+
mermaid = await loadMermaid();
|
|
332
|
+
} catch (e) {
|
|
333
|
+
showError(e);
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
const palette = diagramPalette(d);
|
|
337
|
+
mermaid.initialize({
|
|
338
|
+
startOnLoad: false,
|
|
339
|
+
theme: "base",
|
|
340
|
+
securityLevel: "strict",
|
|
341
|
+
themeVariables: mermaidThemeVariables(d),
|
|
342
|
+
themeCSS: MERMAID_CSS,
|
|
343
|
+
flowchart: { curve: "basis", padding: 14, nodeSpacing: 44, rankSpacing: 52 },
|
|
344
|
+
});
|
|
345
|
+
for (const block of blocks) {
|
|
346
|
+
const out = block.querySelector(".mm-out");
|
|
347
|
+
const src = block.dataset.src || "";
|
|
348
|
+
try {
|
|
349
|
+
const { svg } = await mermaid.render(`mm-${++mermaidSeq}`, src);
|
|
350
|
+
out.innerHTML = svg;
|
|
351
|
+
if (palette) paintDiagram(out, palette);
|
|
352
|
+
block.classList.add("ok");
|
|
353
|
+
block.classList.remove("failed");
|
|
354
|
+
} catch (e) {
|
|
355
|
+
block.classList.add("failed");
|
|
356
|
+
out.innerHTML = `<pre>${esc(src)}</pre><div class="hint error">Mermaid: ${esc(String((e && e.message) || e).split("\n")[0])}</div>`;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
function rerenderDiagrams() {
|
|
361
|
+
document.querySelectorAll(".mermaid-block[data-rendered]:not(.preview)").forEach((b) => {
|
|
362
|
+
delete b.dataset.rendered;
|
|
363
|
+
b.querySelector(".mm-out").innerHTML = `<pre>${esc(b.dataset.src || "")}</pre>`;
|
|
364
|
+
});
|
|
365
|
+
renderDiagrams(document);
|
|
366
|
+
}
|
|
367
|
+
function time(ts) {
|
|
368
|
+
return new Date(ts).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
|
369
|
+
}
|
|
370
|
+
function fullTime(ts) {
|
|
371
|
+
return new Date(ts).toLocaleString([], { weekday: "short", day: "numeric", month: "short", year: "numeric", hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
|
372
|
+
}
|
|
373
|
+
function relTime(ts) {
|
|
374
|
+
if (!ts) return "";
|
|
375
|
+
const d = Date.now() - ts;
|
|
376
|
+
if (d < 60000) return "just now";
|
|
377
|
+
if (d < 3600000) return `${Math.floor(d / 60000)} min ago`;
|
|
378
|
+
if (d < 86400000) return `${Math.floor(d / 3600000)} h ago`;
|
|
379
|
+
return new Date(ts).toLocaleDateString([], { day: "numeric", month: "short" });
|
|
380
|
+
}
|
|
381
|
+
function dayLabel(ts) {
|
|
382
|
+
const d = new Date(ts);
|
|
383
|
+
const today = new Date();
|
|
384
|
+
const yesterday = new Date(today.getTime() - 86400000);
|
|
385
|
+
if (d.toDateString() === today.toDateString()) return "Today";
|
|
386
|
+
if (d.toDateString() === yesterday.toDateString()) return "Yesterday";
|
|
387
|
+
return d.toLocaleDateString([], { weekday: "short", day: "numeric", month: "short" });
|
|
388
|
+
}
|
|
389
|
+
function fmtTokens(n) {
|
|
390
|
+
if (n === undefined || n === null) return "";
|
|
391
|
+
return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n);
|
|
392
|
+
}
|
|
393
|
+
function fmtCost(cost) {
|
|
394
|
+
return cost ? `${cost.amount.toFixed(3)} ${cost.currency}` : "";
|
|
395
|
+
}
|
|
396
|
+
function nearBottom() {
|
|
397
|
+
const el = els.messages;
|
|
398
|
+
return el.scrollHeight - el.scrollTop - el.clientHeight < 80;
|
|
399
|
+
}
|
|
400
|
+
function scrollToBottom() {
|
|
401
|
+
els.messages.scrollTop = els.messages.scrollHeight;
|
|
402
|
+
}
|
|
403
|
+
function avatar(p, size, opts) {
|
|
404
|
+
return window.Avatars.avatarHtml(p, size, Object.assign({ recipes: state.recipes }, opts || {}));
|
|
405
|
+
}
|
|
406
|
+
function meAvatarData() {
|
|
407
|
+
const s = state.settings || {};
|
|
408
|
+
return { name: s.humanName || "You", color: "#1f1d3a", avatar: s.humanAvatar, kind: "human" };
|
|
409
|
+
}
|
|
410
|
+
function toast(text, level) {
|
|
411
|
+
const el = document.createElement("div");
|
|
412
|
+
el.className = `toast ${level || "info"}`;
|
|
413
|
+
el.innerHTML = `${ic(level === "error" ? "close" : level === "warn" ? "alert" : "info")}<span></span>`;
|
|
414
|
+
el.lastChild.textContent = text;
|
|
415
|
+
const leave = () => {
|
|
416
|
+
el.classList.add("leaving");
|
|
417
|
+
setTimeout(() => el.remove(), 220);
|
|
418
|
+
};
|
|
419
|
+
el.addEventListener("click", leave);
|
|
420
|
+
els.toasts.appendChild(el);
|
|
421
|
+
if (level !== "error") setTimeout(leave, 9000);
|
|
422
|
+
while (els.toasts.children.length > 6) els.toasts.firstChild.remove();
|
|
423
|
+
}
|
|
424
|
+
const notice = toast;
|
|
425
|
+
function showError(error) {
|
|
426
|
+
toast(error && error.message ? error.message : String(error), "error");
|
|
427
|
+
}
|
|
428
|
+
function geekTip(text) {
|
|
429
|
+
return `<button type="button" class="geek-tip" title="For geeks">${ic("geek")}for geeks</button><span class="geek-text" hidden>${text}</span>`;
|
|
430
|
+
}
|
|
431
|
+
document.addEventListener("click", (e) => {
|
|
432
|
+
const b = e.target.closest && e.target.closest(".geek-tip");
|
|
433
|
+
if (!b) return;
|
|
434
|
+
e.preventDefault();
|
|
435
|
+
const t = b.nextElementSibling;
|
|
436
|
+
if (!t || !t.classList.contains("geek-text")) return;
|
|
437
|
+
t.hidden = !t.hidden;
|
|
438
|
+
b.classList.toggle("on", !t.hidden);
|
|
439
|
+
});
|
|
440
|
+
function field(label, inputHtml, hint, geekText) {
|
|
441
|
+
return `<label class="field"><span class="label">${label}${geekText ? geekTip(geekText) : ""}</span>${inputHtml}${hint ? `<span class="hint">${hint}</span>` : ""}</label>`;
|
|
442
|
+
}
|
|
443
|
+
function vendorLogo(r) {
|
|
444
|
+
return `<span class="vc-logo">${r.icon ? `<img src="${esc(r.icon)}" alt="" onerror="this.replaceWith(document.createTextNode('${esc(r.vendor[0])}'))">` : esc(r.vendor[0])}</span>`;
|
|
445
|
+
}
|
|
446
|
+
function sectionTitle(iconName, text) {
|
|
447
|
+
return `<h4>${ic(iconName)}${text}</h4>`;
|
|
448
|
+
}
|
|
449
|
+
function saveRow(id) {
|
|
450
|
+
return `<div class="save-row"><button class="btn sm primary save" id="${id}" disabled>Save</button></div>`;
|
|
451
|
+
}
|
|
452
|
+
function geek(id, bodyHtml, hint) {
|
|
453
|
+
return `<details class="geek" id="${id}"><summary>${ic("geek")}for geeks${hint ? `<span class="g-hint">${hint}</span>` : ""}<span class="chev">${ic("down")}</span></summary><div class="geek-body">${bodyHtml}</div></details>`;
|
|
454
|
+
}
|
|
455
|
+
function bindSave(container, button, onSave) {
|
|
456
|
+
if (!container || !button) return;
|
|
457
|
+
const arm = () => {
|
|
458
|
+
button.disabled = false;
|
|
459
|
+
button.classList.remove("saved");
|
|
460
|
+
button.textContent = "Save";
|
|
461
|
+
};
|
|
462
|
+
container.addEventListener("input", arm);
|
|
463
|
+
container.addEventListener("change", arm);
|
|
464
|
+
button.addEventListener("click", async () => {
|
|
465
|
+
button.disabled = true;
|
|
466
|
+
button.classList.add("loading");
|
|
467
|
+
try {
|
|
468
|
+
await onSave();
|
|
469
|
+
button.classList.remove("loading");
|
|
470
|
+
button.classList.add("saved");
|
|
471
|
+
button.innerHTML = `${ic("check")} Saved`;
|
|
472
|
+
} catch (e) {
|
|
473
|
+
button.classList.remove("loading");
|
|
474
|
+
button.disabled = false;
|
|
475
|
+
showError(e);
|
|
476
|
+
}
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
function roomHue(room) {
|
|
480
|
+
let h = 0;
|
|
481
|
+
for (const ch of room.id) h = (h * 31 + ch.charCodeAt(0)) >>> 0;
|
|
482
|
+
return h % 360;
|
|
483
|
+
}
|
|
484
|
+
function roomMark(room, lg) {
|
|
485
|
+
const hue = roomHue(room);
|
|
486
|
+
const emoji = (room.settings && room.settings.emoji) || "";
|
|
487
|
+
if (emoji) return `<span class="room-mark emoji${lg ? " lg" : ""}" style="background:hsl(${hue} 70% 93%)">${esc(emoji)}</span>`;
|
|
488
|
+
const letter = (room.name.trim()[0] || "?").toUpperCase();
|
|
489
|
+
return `<span class="room-mark${lg ? " lg" : ""}" style="background:linear-gradient(135deg, hsl(${hue} 72% 66%), hsl(${(hue + 30) % 360} 68% 52%))">${esc(letter)}</span>`;
|
|
490
|
+
}
|
|
491
|
+
function roomTitle(room) {
|
|
492
|
+
const emoji = (room.settings && room.settings.emoji) || "";
|
|
493
|
+
return emoji ? `${room.name} ${emoji}` : room.name;
|
|
494
|
+
}
|
|
495
|
+
function flash(id) {
|
|
496
|
+
const s = document.getElementById(id);
|
|
497
|
+
if (!s) return;
|
|
498
|
+
s.textContent = "saved";
|
|
499
|
+
setTimeout(() => {
|
|
500
|
+
const again = document.getElementById(id);
|
|
501
|
+
if (again) again.textContent = "";
|
|
502
|
+
}, 2200);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function openDialog(dialog) {
|
|
506
|
+
if (dialog.open) return;
|
|
507
|
+
dialog.classList.remove("closing");
|
|
508
|
+
dialog.showModal();
|
|
509
|
+
}
|
|
510
|
+
function closeDialog(dialog) {
|
|
511
|
+
if (!dialog.open) return;
|
|
512
|
+
dialog.classList.add("closing");
|
|
513
|
+
setTimeout(() => {
|
|
514
|
+
dialog.classList.remove("closing");
|
|
515
|
+
if (dialog.open) dialog.close();
|
|
516
|
+
}, 190);
|
|
517
|
+
}
|
|
518
|
+
function confirmDialog(text, opts) {
|
|
519
|
+
const o = opts || {};
|
|
520
|
+
const dialog = $("#confirm-dialog");
|
|
521
|
+
$("#cf-title").textContent = o.title || "Are you sure?";
|
|
522
|
+
$("#cf-text").textContent = text;
|
|
523
|
+
const ok = $("#cf-ok");
|
|
524
|
+
ok.textContent = o.okLabel || "OK";
|
|
525
|
+
ok.className = `btn ${o.danger ? "danger solid" : "primary"}`;
|
|
526
|
+
return new Promise((resolve) => {
|
|
527
|
+
const done = () => {
|
|
528
|
+
dialog.removeEventListener("close", done);
|
|
529
|
+
resolve(dialog.returnValue === "ok");
|
|
530
|
+
};
|
|
531
|
+
dialog.addEventListener("close", done);
|
|
532
|
+
dialog.returnValue = "";
|
|
533
|
+
openDialog(dialog);
|
|
534
|
+
ok.focus();
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
function remember(key, value) {
|
|
538
|
+
try {
|
|
539
|
+
localStorage.setItem(`viberoom.${key}`, String(value));
|
|
540
|
+
} catch {
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
function recall(key) {
|
|
544
|
+
try {
|
|
545
|
+
return localStorage.getItem(`viberoom.${key}`);
|
|
546
|
+
} catch {
|
|
547
|
+
return null;
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
function setView(view) {
|
|
553
|
+
state.view = view;
|
|
554
|
+
els.app.classList.remove("view-home", "view-rooms", "view-room", "view-skills", "view-settings");
|
|
555
|
+
els.app.classList.add(`view-${view}`);
|
|
556
|
+
els.homeView.hidden = view !== "home";
|
|
557
|
+
els.roomsView.hidden = view !== "rooms";
|
|
558
|
+
els.chatView.hidden = view !== "room";
|
|
559
|
+
els.pageView.hidden = !(view === "skills" || view === "settings");
|
|
560
|
+
els.sideRooms.hidden = view === "room";
|
|
561
|
+
els.sideRoom.hidden = view !== "room";
|
|
562
|
+
if (view !== "room" && state.selection.kind !== "me") closeDetails();
|
|
563
|
+
renderRail();
|
|
564
|
+
if (view === "home") {
|
|
565
|
+
renderSideRooms();
|
|
566
|
+
renderHome();
|
|
567
|
+
} else if (view === "rooms") {
|
|
568
|
+
renderSideRooms();
|
|
569
|
+
renderRoomsGrid();
|
|
570
|
+
} else if (view === "room") {
|
|
571
|
+
renderSideRoom();
|
|
572
|
+
renderChatHead();
|
|
573
|
+
renderMessages();
|
|
574
|
+
} else if (view === "skills") {
|
|
575
|
+
renderSideRooms();
|
|
576
|
+
renderSkillsPage();
|
|
577
|
+
} else if (view === "settings") {
|
|
578
|
+
renderSideRooms();
|
|
579
|
+
renderSettingsPage();
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function selectRoom(id, opts) {
|
|
584
|
+
if (!state.rooms.has(id)) return;
|
|
585
|
+
state.currentRoomId = id;
|
|
586
|
+
state.unread.delete(id);
|
|
587
|
+
state.selection = { kind: "room" };
|
|
588
|
+
remember("room", id);
|
|
589
|
+
setView("room");
|
|
590
|
+
if (!(opts && opts.keepDetails)) closeDetails();
|
|
591
|
+
maybeOfferReconnect();
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
function renderRail() {
|
|
595
|
+
els.rail.querySelectorAll(".rail-item[data-nav]").forEach((b) => {
|
|
596
|
+
const nav = b.dataset.nav;
|
|
597
|
+
const active = nav === state.view || (nav === "me" && state.selection.kind === "me" && state.detailsOpen);
|
|
598
|
+
b.classList.toggle("active", active);
|
|
599
|
+
});
|
|
600
|
+
const room = currentRoom();
|
|
601
|
+
els.railRoom.hidden = !room;
|
|
602
|
+
if (room) els.railRoomLabel.textContent = room.name;
|
|
603
|
+
const unread = [...state.unread.values()].reduce((a, b) => a + b, 0);
|
|
604
|
+
els.railUnread.hidden = !unread;
|
|
605
|
+
els.railUnread.textContent = unread > 99 ? "99+" : String(unread);
|
|
606
|
+
const s = state.settings;
|
|
607
|
+
if (s) {
|
|
608
|
+
els.railMeAvatar.innerHTML = avatar(meAvatarData(), 44, {});
|
|
609
|
+
els.railMeLabel.textContent = s.humanName;
|
|
610
|
+
}
|
|
611
|
+
const open = els.app.classList.contains("rail-open");
|
|
612
|
+
els.railToggle.title = open ? "Collapse the menu" : "Expand the menu";
|
|
613
|
+
els.railToggle.innerHTML = ic(open ? "collapse" : "expand");
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
function setRailOpen(open) {
|
|
617
|
+
els.app.classList.toggle("rail-open", open);
|
|
618
|
+
remember("railOpen", open ? "1" : "0");
|
|
619
|
+
renderRail();
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
function roomStats(room) {
|
|
624
|
+
const agents = room.participants.filter((p) => p.kind === "agent");
|
|
625
|
+
const online = agents.filter((p) => p.status !== "offline" && p.status !== "left").length;
|
|
626
|
+
const chats = room.messages.filter((m) => m.kind === "chat");
|
|
627
|
+
const last = chats.length ? chats[chats.length - 1].ts : room.createdAt;
|
|
628
|
+
const thinking = agents.some((p) => p.status === "thinking");
|
|
629
|
+
return { agents, online, chats, last, thinking, unread: state.unread.get(room.id) || 0 };
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
function sortedRooms() {
|
|
633
|
+
return [...state.rooms.values()].sort((a, b) => roomStats(b).last - roomStats(a).last);
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
function renderSideSkills() {
|
|
637
|
+
els.sideRoomsTitle.textContent = "Skills by room";
|
|
638
|
+
els.roomList.innerHTML = "";
|
|
639
|
+
const q = state.roomSearch.toLowerCase();
|
|
640
|
+
const all = document.createElement("li");
|
|
641
|
+
all.className = state.skillsRoom ? "" : "selected";
|
|
642
|
+
all.innerHTML = `<span class="room-mark" style="background:var(--grad-primary)">${ic("skills")}</span><div class="p-body"><div class="p-name"><span>All skills</span></div><div class="p-preview">${(state.skills || []).length} in the library</div></div>`;
|
|
643
|
+
all.addEventListener("click", () => {
|
|
644
|
+
state.skillsRoom = null;
|
|
645
|
+
renderSideSkills();
|
|
646
|
+
renderSkillsPage();
|
|
647
|
+
});
|
|
648
|
+
els.roomList.appendChild(all);
|
|
649
|
+
for (const room of sortedRooms()) {
|
|
650
|
+
if (q && !room.name.toLowerCase().includes(q)) continue;
|
|
651
|
+
const held = new Set();
|
|
652
|
+
for (const p of room.participants) for (const s of p.skills || []) held.add(s.toLowerCase());
|
|
653
|
+
const li = document.createElement("li");
|
|
654
|
+
li.className = state.skillsRoom === room.id ? "selected" : "";
|
|
655
|
+
const selected = state.skillsRoom === room.id;
|
|
656
|
+
li.innerHTML = `${roomMark(room)}<div class="p-body"><div class="p-name"><span>${esc(room.name)}</span></div><div class="p-preview">${held.size ? `${held.size} skill${held.size === 1 ? "" : "s"} in use` : "no skills attached"}</div></div>${selected ? `<div class="p-actions"><button class="icon-btn sm goto-room" title="Go to the room">${ic("forward")}</button></div>` : ""}`;
|
|
657
|
+
li.addEventListener("click", (e) => {
|
|
658
|
+
if (e.target.closest(".goto-room")) return selectRoom(room.id);
|
|
659
|
+
state.skillsRoom = room.id;
|
|
660
|
+
renderSideSkills();
|
|
661
|
+
renderSkillsPage();
|
|
662
|
+
});
|
|
663
|
+
els.roomList.appendChild(li);
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
function renderSideRooms() {
|
|
668
|
+
if (state.view === "skills") return renderSideSkills();
|
|
669
|
+
els.sideRoomsTitle.textContent = "Rooms";
|
|
670
|
+
els.roomList.innerHTML = "";
|
|
671
|
+
const q = state.roomSearch.toLowerCase();
|
|
672
|
+
for (const room of sortedRooms()) {
|
|
673
|
+
if (q && !room.name.toLowerCase().includes(q) && !(room.settings.topic || "").toLowerCase().includes(q)) continue;
|
|
674
|
+
const st = roomStats(room);
|
|
675
|
+
const li = document.createElement("li");
|
|
676
|
+
li.className = room.id === state.currentRoomId ? "selected" : "";
|
|
677
|
+
const lastChat = st.chats[st.chats.length - 1];
|
|
678
|
+
const preview = lastChat ? `${lastChat.fromName}: ${String(lastChat.text).replace(/\s+/g, " ")}` : room.settings.topic || `${st.agents.length} vibemate${st.agents.length === 1 ? "" : "s"}`;
|
|
679
|
+
li.innerHTML = `
|
|
680
|
+
${roomMark(room)}
|
|
681
|
+
<div class="p-body">
|
|
682
|
+
<div class="p-name"><span>${esc(room.name)}</span>${st.thinking ? '<span class="live-dot" title="a vibemate is replying"></span>' : ""}</div>
|
|
683
|
+
<div class="p-preview">${esc(preview)}</div>
|
|
684
|
+
</div>
|
|
685
|
+
<div class="p-right"><span>${esc(relTime(st.last))}</span>${st.unread ? `<span class="count-pill">${st.unread}</span>` : ""}</div>`;
|
|
686
|
+
li.addEventListener("click", () => selectRoom(room.id));
|
|
687
|
+
els.roomList.appendChild(li);
|
|
688
|
+
}
|
|
689
|
+
if (!els.roomList.children.length) els.roomList.innerHTML = `<li class="hint" style="cursor:default">${q ? "No room matches." : "No rooms yet."}</li>`;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
const FEATURES = [
|
|
694
|
+
{ tone: "lav", emoji: "🏠", title: "Rooms", text: "A room is a folder and a topic. Every vibemate in it works in that folder, and the history stays with the room." },
|
|
695
|
+
{ tone: "mint", emoji: "🤖", title: "Vibemates", text: "Summon any installed coding agent, give it a Vibename, a Vibersona and a Vibeface. Several in one room is the point.", vendors: true },
|
|
696
|
+
{ tone: "warm", emoji: "💬", title: "Talk to all, or to one", text: "Write to the room and everyone answers in turn; @Name one of them and the others just listen. They read each other's replies." },
|
|
697
|
+
{ tone: "peach", emoji: "🧩", title: "Skills", text: "Reusable instructions in your library. Attach them to vibemates, invoke one with /name, or let a vibemate write its own." },
|
|
698
|
+
{ tone: "rose", emoji: "🤫", title: "Hush", text: "Too much at once? One click stops every running reply. The vibemates stay quiet until you write again." },
|
|
699
|
+
{ tone: "sky", emoji: "📊", title: "Links and diagrams", text: "Links and file paths open on your machine with one click. A ```mermaid block in a reply turns into a diagram." },
|
|
700
|
+
];
|
|
701
|
+
const STEPS = [
|
|
702
|
+
{ n: 1, title: "Open a room", text: "Give it a name and a folder. The folder is where the vibemates read and write." },
|
|
703
|
+
{ n: 2, title: "Summon a vibemate", text: "Pick Claude, Codex, Gemini, Cursor, OpenCode or Copilot; name it, give it a face." },
|
|
704
|
+
{ n: 3, title: "Say hello", text: "Enter sends. @Name addresses one vibemate, /skill invokes a skill. Watch them talk." },
|
|
705
|
+
];
|
|
706
|
+
function renderHome() {
|
|
707
|
+
const s = state.settings || {};
|
|
708
|
+
const name = s.humanName || "there";
|
|
709
|
+
const rooms = sortedRooms().slice(0, 4);
|
|
710
|
+
const vendors = state.recipes.filter((r) => !r.unavailableReason);
|
|
711
|
+
const vendorLogos = (vendors.length ? vendors : state.recipes).map((r) => `<span class="home-vendor" title="${esc(r.vendor)}${r.unavailableReason ? " (not installed)" : ""}"${r.unavailableReason ? ' style="opacity:.45"' : ""}>${vendorLogo(r)}</span>`).join("");
|
|
712
|
+
els.homeView.innerHTML = `
|
|
713
|
+
<div class="home-inner">
|
|
714
|
+
<section class="hero">
|
|
715
|
+
<div class="hero-text">
|
|
716
|
+
<div class="hero-eyebrow">viberoom</div>
|
|
717
|
+
<h1>Welcome back, ${esc(name)} 👋</h1>
|
|
718
|
+
<p>One chat, many coding agents. Summon your vibemates into a room, talk to all of them at once, and let them talk to each other.</p>
|
|
719
|
+
<div class="hero-actions">
|
|
720
|
+
<button class="btn cta hero-cta" id="home-open-room">${ic("plus")}Open a room</button>
|
|
721
|
+
<button class="btn hero-ghost" id="home-rooms">${ic("rooms")}Your rooms</button>
|
|
722
|
+
</div>
|
|
723
|
+
</div>
|
|
724
|
+
<div class="hero-art" aria-hidden="true">
|
|
725
|
+
<div class="ha-bubble ha-a"><span class="ha-face">🦊</span><span>Ship the login fix today?</span></div>
|
|
726
|
+
<div class="ha-bubble ha-b"><span class="ha-face">🤖</span><span>On it — tests first.</span></div>
|
|
727
|
+
<div class="ha-bubble ha-c"><span class="ha-face">🐼</span><span>@Nova I'll review your diff.</span></div>
|
|
728
|
+
<div class="ha-spark">✦</div>
|
|
729
|
+
</div>
|
|
730
|
+
</section>
|
|
731
|
+
<section class="home-section">
|
|
732
|
+
<div class="home-head"><h2>${rooms.length ? "Jump back in" : "Your first room"}</h2>${rooms.length ? `<button class="linklike" id="home-all-rooms">All rooms ${ic("forward")}</button>` : ""}</div>
|
|
733
|
+
<div class="home-rooms">
|
|
734
|
+
${rooms
|
|
735
|
+
.map((room) => {
|
|
736
|
+
const st = roomStats(room);
|
|
737
|
+
const last = st.chats[st.chats.length - 1];
|
|
738
|
+
return `<button class="home-room" data-room="${esc(room.id)}">${roomMark(room)}<span class="hr-body"><span class="hr-name">${esc(room.name)}</span><span class="hr-sub">${esc(last ? `${last.fromName}: ${String(last.text).replace(/\s+/g, " ").slice(0, 70)}` : room.settings.topic || `${st.agents.length} vibemate${st.agents.length === 1 ? "" : "s"}`)}</span></span>${st.unread ? `<span class="count-pill">${st.unread}</span>` : `<span class="hr-time">${esc(relTime(st.last))}</span>`}</button>`;
|
|
739
|
+
})
|
|
740
|
+
.join("")}
|
|
741
|
+
${rooms.length ? "" : `<div class="home-room empty-room"><span class="room-mark" style="background:var(--grad-primary)">${ic("plus")}</span><span class="hr-body"><span class="hr-name">No rooms yet</span><span class="hr-sub">Open one, summon a vibemate, say hello.</span></span></div>`}
|
|
742
|
+
</div>
|
|
743
|
+
</section>
|
|
744
|
+
<section class="home-section">
|
|
745
|
+
<div class="home-head"><h2>What you can do here</h2></div>
|
|
746
|
+
<div class="feature-grid">
|
|
747
|
+
${FEATURES.map((f) => `<div class="feature ${f.tone}"><div class="f-emoji">${f.emoji}</div><h3>${esc(f.title)}</h3><p>${esc(f.text)}</p>${f.vendors ? `<div class="home-vendors">${vendorLogos}</div>` : ""}</div>`).join("")}
|
|
748
|
+
</div>
|
|
749
|
+
</section>
|
|
750
|
+
<section class="home-section">
|
|
751
|
+
<div class="home-head"><h2>Three steps to your first conversation</h2></div>
|
|
752
|
+
<div class="steps">
|
|
753
|
+
${STEPS.map((st) => `<div class="step"><div class="step-n">${st.n}</div><div><h3>${esc(st.title)}</h3><p>${esc(st.text)}</p></div></div>`).join("")}
|
|
754
|
+
</div>
|
|
755
|
+
</section>
|
|
756
|
+
<section class="home-section">
|
|
757
|
+
<div class="home-head"><h2>Small things worth knowing</h2></div>
|
|
758
|
+
<div class="tips">
|
|
759
|
+
<span class="tip"><b>@Name</b> addresses one vibemate</span>
|
|
760
|
+
<span class="tip"><b>/name</b> invokes a skill</span>
|
|
761
|
+
<span class="tip"><b>Shift+Enter</b> is a new line</span>
|
|
762
|
+
<span class="tip"><b>Double-click</b> a vibemate to mention it</span>
|
|
763
|
+
<span class="tip"><b>✓✓ seen by</b> shows who has read your last message</span>
|
|
764
|
+
<span class="tip"><b>for geeks</b> hides the technical settings</span>
|
|
765
|
+
<span class="tip">Your face wears the <b>ring</b></span>
|
|
766
|
+
</div>
|
|
767
|
+
</section>
|
|
768
|
+
</div>`;
|
|
769
|
+
$("#home-open-room").addEventListener("click", openRoomDialog);
|
|
770
|
+
$("#home-rooms").addEventListener("click", () => setView("rooms"));
|
|
771
|
+
const all = $("#home-all-rooms");
|
|
772
|
+
if (all) all.addEventListener("click", () => setView("rooms"));
|
|
773
|
+
els.homeView.querySelectorAll(".home-room[data-room]").forEach((b) => b.addEventListener("click", () => selectRoom(b.dataset.room)));
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
|
|
777
|
+
function renderRoomsGrid() {
|
|
778
|
+
if (state.view === "home") return renderHome();
|
|
779
|
+
const grid = els.roomsGrid;
|
|
780
|
+
grid.innerHTML = "";
|
|
781
|
+
const cta = document.createElement("div");
|
|
782
|
+
cta.className = "card cta hover room-card";
|
|
783
|
+
cta.innerHTML = `<div class="plus">${ic("plus")}</div><div>Open a room</div><div class="hint">a space for you and some vibemates</div>`;
|
|
784
|
+
cta.addEventListener("click", openRoomDialog);
|
|
785
|
+
grid.appendChild(cta);
|
|
786
|
+
const rooms = sortedRooms();
|
|
787
|
+
els.roomsSub.textContent = rooms.length ? `${rooms.length} room${rooms.length === 1 ? "" : "s"}. Pick one, or open a new one.` : "No rooms yet. Open one and summon some vibemates.";
|
|
788
|
+
for (const room of rooms) {
|
|
789
|
+
const st = roomStats(room);
|
|
790
|
+
const card = document.createElement("div");
|
|
791
|
+
card.className = `card hover room-card${room.id === state.currentRoomId ? " current" : ""}`;
|
|
792
|
+
const faces = st.agents.slice(0, 5).map((p) => avatar(p, 26, { vendor: true })).join("");
|
|
793
|
+
card.innerHTML = `
|
|
794
|
+
<div class="rc-head"><div class="rc-title">${roomMark(room)}<h3>${esc(room.name)}</h3></div>${st.unread ? `<span class="count-pill">${st.unread}</span>` : st.thinking ? '<span class="live-dot" title="a vibemate is replying"></span>' : ""}</div>
|
|
795
|
+
<div class="rc-topic">${esc(room.settings.topic || (st.chats.length ? String(st.chats[st.chats.length - 1].text).slice(0, 140) : "Nothing said yet."))}</div>
|
|
796
|
+
<div class="avatar-stack">${faces || '<span class="hint">no vibemates yet</span>'}</div>
|
|
797
|
+
<div class="rc-foot"><span>${st.agents.length} vibemate${st.agents.length === 1 ? "" : "s"} · ${st.chats.length} message${st.chats.length === 1 ? "" : "s"}</span><span>${esc(relTime(st.last))}</span></div>`;
|
|
798
|
+
card.addEventListener("click", () => selectRoom(room.id));
|
|
799
|
+
grid.appendChild(card);
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
|
|
804
|
+
function offlineAgents(room) {
|
|
805
|
+
return room ? room.participants.filter((p) => p.kind === "agent" && p.status === "offline") : [];
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
function renderSideRoom() {
|
|
809
|
+
const room = currentRoom();
|
|
810
|
+
if (!room) return;
|
|
811
|
+
const st = roomStats(room);
|
|
812
|
+
els.sideRoomName.textContent = room.name;
|
|
813
|
+
els.sideRoomEmoji.textContent = room.settings.emoji || "";
|
|
814
|
+
els.sideRoomSub.textContent = room.settings.topic || `${st.agents.length} vibemate${st.agents.length === 1 ? "" : "s"}${st.agents.length ? ` · ${st.online} online` : ""}`;
|
|
815
|
+
els.participants.innerHTML = "";
|
|
816
|
+
const ordered = [...room.participants].sort((a, b) => (a.kind === "human" ? -1 : b.kind === "human" ? 1 : 0));
|
|
817
|
+
for (const p of ordered) {
|
|
818
|
+
const li = document.createElement("li");
|
|
819
|
+
const selected = (state.selection.kind === "participant" && state.selection.id === p.id) || (p.kind === "human" && state.selection.kind === "me" && state.detailsOpen);
|
|
820
|
+
const asleep = p.kind === "agent" && (p.status === "offline" || p.status === "left");
|
|
821
|
+
li.className = (p.kind === "human" ? "me" : "") + (selected ? " selected" : "") + (asleep ? " offline" : "");
|
|
822
|
+
const sub = p.kind === "human" ? "you, the human" : [p.tagline ? `"${p.tagline}"` : "", p.agentVendor || p.agentLabel, p.model].filter(Boolean).join(" · ");
|
|
823
|
+
const warn = p.statusDetail && (p.status === "offline" || p.status === "error" || p.failedTurns) ? `<div class="p-warn" title="${esc(p.statusDetail)}">${esc(p.statusDetail)}</div>` : "";
|
|
824
|
+
const status = asleep
|
|
825
|
+
? `<span class="zzz" title="${esc(STATUS_LABEL[p.status] || p.status)}">zzz</span>`
|
|
826
|
+
: p.kind === "agent" && p.status !== "idle" ? `<span class="badge status-${p.status}">${p.status === "thinking" ? '<span class="dot"></span>' : ""}${STATUS_LABEL[p.status] || p.status}</span>` : "";
|
|
827
|
+
li.innerHTML = `
|
|
828
|
+
${avatar(p.kind === "human" ? meAvatarData() : p, 44, { vendor: true, status: p.kind === "agent" })}
|
|
829
|
+
<div class="p-body">
|
|
830
|
+
<div class="p-name"><span>${esc(p.name)}</span>${p.muted ? '<span class="badge muted">muted</span>' : ""}${status}</div>
|
|
831
|
+
<div class="p-sub">${esc(sub)}</div>
|
|
832
|
+
${warn}
|
|
833
|
+
</div>
|
|
834
|
+
<div class="p-actions">
|
|
835
|
+
${p.kind === "agent" && p.status === "thinking" ? `<button class="icon-btn sm stop-btn" title="Stop this reply">${ic("stop")}</button>` : ""}
|
|
836
|
+
${p.kind === "agent" && p.status === "offline" ? `<button class="icon-btn sm reconnect-btn" title="Reconnect">${ic("refresh")}</button>` : ""}
|
|
837
|
+
</div>`;
|
|
838
|
+
li.addEventListener("click", (e) => {
|
|
839
|
+
if (e.target.closest("button")) return;
|
|
840
|
+
if (p.kind === "human") openDetails({ kind: "me" });
|
|
841
|
+
else openDetails({ kind: "participant", id: p.id });
|
|
842
|
+
});
|
|
843
|
+
li.addEventListener("dblclick", () => {
|
|
844
|
+
if (p.kind === "agent") insertMention(p.name);
|
|
845
|
+
});
|
|
846
|
+
const stop = li.querySelector(".stop-btn");
|
|
847
|
+
if (stop) stop.addEventListener("click", () => post(roomApi(`/participants/${encodeURIComponent(p.id)}/cancel`)).catch(showError));
|
|
848
|
+
const rec = li.querySelector(".reconnect-btn");
|
|
849
|
+
if (rec) rec.addEventListener("click", () => openReconnectDialog(room));
|
|
850
|
+
els.participants.appendChild(li);
|
|
851
|
+
}
|
|
852
|
+
renderHushButton(room);
|
|
853
|
+
els.reconnectAllBtn.hidden = offlineAgents(room).length === 0;
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
function renderHushButton(room, busy) {
|
|
857
|
+
const b = els.focusBtn;
|
|
858
|
+
const label = b.querySelector(".label");
|
|
859
|
+
b.classList.toggle("busy", !!busy);
|
|
860
|
+
b.classList.toggle("on", !busy && !!room.focused);
|
|
861
|
+
b.setAttribute("aria-pressed", room.focused ? "true" : "false");
|
|
862
|
+
if (busy) {
|
|
863
|
+
label.textContent = "Hushing…";
|
|
864
|
+
b.title = "Stopping every running reply";
|
|
865
|
+
} else if (room.focused) {
|
|
866
|
+
label.textContent = "Hushed · waiting for you";
|
|
867
|
+
b.title = "The vibemates stay quiet until you write again";
|
|
868
|
+
} else {
|
|
869
|
+
label.textContent = "Hush the room";
|
|
870
|
+
b.title = "Stop every running reply; vibemates stay quiet until you speak again";
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
function insertMention(name) {
|
|
875
|
+
const input = els.input;
|
|
876
|
+
const start = input.selectionStart || input.value.length;
|
|
877
|
+
const before = input.value.slice(0, start);
|
|
878
|
+
const after = input.value.slice(start);
|
|
879
|
+
const prefix = before && !/\s$/.test(before) ? " " : "";
|
|
880
|
+
input.value = `${before}${prefix}@${name} ${after}`;
|
|
881
|
+
input.focus();
|
|
882
|
+
autosize();
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
|
|
886
|
+
function renderChatHead() {
|
|
887
|
+
const room = currentRoom();
|
|
888
|
+
if (!room) {
|
|
889
|
+
els.chatRoomName.textContent = "No room";
|
|
890
|
+
els.chatRoomSub.textContent = "";
|
|
891
|
+
return;
|
|
892
|
+
}
|
|
893
|
+
const st = roomStats(room);
|
|
894
|
+
els.chatRoomName.textContent = roomTitle(room);
|
|
895
|
+
els.chatRoomSub.innerHTML =
|
|
896
|
+
`<span>${st.agents.length} vibemate${st.agents.length === 1 ? "" : "s"}${st.agents.length ? ` · ${st.online} online` : ""}</span>` +
|
|
897
|
+
(room.settings.topic ? `<span>· ${esc(room.settings.topic)}</span>` : "") +
|
|
898
|
+
`<span class="chip dir-chip" title="working directory of the vibemates: ${esc(room.dir)}">${ic("folder")}${esc(room.dir.split(/[\\/]/).filter(Boolean).slice(-1)[0] || room.dir)}</span>`;
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
|
|
902
|
+
function messageMatches(m) {
|
|
903
|
+
if (!state.search) return true;
|
|
904
|
+
const q = state.search.toLowerCase();
|
|
905
|
+
return m.text.toLowerCase().includes(q) || (m.fromName || "").toLowerCase().includes(q);
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
function renderHidden(el, m) {
|
|
909
|
+
const d = m.details || {};
|
|
910
|
+
if (d.skill) {
|
|
911
|
+
el.innerHTML = `
|
|
912
|
+
<details class="hidden-turn">
|
|
913
|
+
<summary>${ic("skills")} hub ↔ ${esc(m.fromName)} · ${esc(m.text)}${d.via ? ` (${esc(d.via)})` : ""}${d.outcome ? ` · <em>${esc(d.outcome)}</em>` : ""}</summary>
|
|
914
|
+
<div class="hidden-body">
|
|
915
|
+
${d.original ? `<div class="hidden-label">Held reply (nobody in the room saw it)</div><div class="hidden-text">${esc(d.original)}</div>` : ""}
|
|
916
|
+
<div class="hidden-label">What happened</div>
|
|
917
|
+
<div>${d.via === "tool" ? "The vibemate called the hub's load_skill tool during its turn and received the skill text as the tool result." : "The vibemate asked for the skill with the marker; the hub attached the skill and re-ran the turn on the same messages."}</div>
|
|
918
|
+
</div>
|
|
919
|
+
</details>`;
|
|
920
|
+
return;
|
|
921
|
+
}
|
|
922
|
+
el.innerHTML = `
|
|
923
|
+
<details class="hidden-turn">
|
|
924
|
+
<summary>${ic("tool")} hub ↔ ${esc(m.fromName)} · ${esc(m.text)}${d.outcome ? ` · <em>${esc(d.outcome)}</em>` : " · <em>waiting for the corrected reply…</em>"}</summary>
|
|
925
|
+
<div class="hidden-body">
|
|
926
|
+
<div class="hidden-label">Held reply (nobody in the room saw it)</div>
|
|
927
|
+
<div class="hidden-text">${esc(d.original || "")}</div>
|
|
928
|
+
<div class="hidden-label">Corrections sent in a hidden turn</div>
|
|
929
|
+
<ul>${(d.corrections || []).map((c) => `<li>${esc(c)}</li>`).join("")}</ul>
|
|
930
|
+
</div>
|
|
931
|
+
</details>`;
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
function messageElement(room, m) {
|
|
935
|
+
const el = document.createElement("div");
|
|
936
|
+
el.dataset.id = m.id;
|
|
937
|
+
el.dataset.seq = m.seq;
|
|
938
|
+
if (m.kind === "hidden") {
|
|
939
|
+
el.className = "msg hidden";
|
|
940
|
+
renderHidden(el, m);
|
|
941
|
+
return el;
|
|
942
|
+
}
|
|
943
|
+
if (m.kind === "system") {
|
|
944
|
+
el.className = "msg system";
|
|
945
|
+
if (m.audience === "agents") {
|
|
946
|
+
el.className = "msg hidden";
|
|
947
|
+
el.innerHTML = `<details class="hidden-turn"><summary>${ic("info")} hub → vibemates · ${esc(m.text.split(":")[0])}</summary><div class="hidden-body"><div class="hidden-label">What the vibemates were told</div><div class="hidden-text">${esc(m.text)}</div></div></details>`;
|
|
948
|
+
return el;
|
|
949
|
+
}
|
|
950
|
+
const hush = /^(Hush|Focus):/.test(m.text);
|
|
951
|
+
const warn = !hush && /could not answer|reported an error|Hop limit|was stopped/.test(m.text);
|
|
952
|
+
el.innerHTML = hush
|
|
953
|
+
? `<div class="focus-pill" title="${esc(fullTime(m.ts))}"><span class="hush-face">🤫</span>${esc(m.text.replace(/^Focus:/, "Hush:"))}</div>`
|
|
954
|
+
: `<div class="sys${warn ? " warn" : ""}" title="${esc(fullTime(m.ts))}">${esc(m.text)}</div>`;
|
|
955
|
+
return el;
|
|
956
|
+
}
|
|
957
|
+
const p = findById(room, m.from) || { name: m.fromName, color: "#9ca3af", kind: m.from === "human" ? "human" : "agent" };
|
|
958
|
+
const mine = m.from === "human";
|
|
959
|
+
el.className = "msg " + (mine ? "mine" : "agent");
|
|
960
|
+
el.innerHTML = `
|
|
961
|
+
${avatar(mine ? Object.assign(meAvatarData(), { color: p.color }) : p, 36, { vendor: true })}
|
|
962
|
+
<div class="bubble-col">
|
|
963
|
+
<div class="head"><span class="name" style="color:${p.color}">${esc(m.fromName)}</span><span class="edited" hidden></span>${mine ? `<button type="button" class="edit-btn" title="Edit this message">${ic("pencil")}</button>` : ""}<span class="time" title="${esc(fullTime(m.ts))}">${time(m.ts)}</span></div>
|
|
964
|
+
<div class="bubble">
|
|
965
|
+
${m.skill ? `<div class="skill-invoke" title="skill invocation: the vibemates that have this skill got its instructions with this message">${ic("skills")} skill <b>${esc(m.skill.name)}</b></div>` : ""}
|
|
966
|
+
<div class="edit-box" hidden></div>
|
|
967
|
+
<details class="thought" hidden><summary>thoughts</summary><div class="thought-text"></div></details>
|
|
968
|
+
<div class="agent-notices"></div>
|
|
969
|
+
<div class="tools"></div>
|
|
970
|
+
<div class="plan" hidden></div>
|
|
971
|
+
<div class="text"></div>
|
|
972
|
+
<button class="more" hidden></button>
|
|
973
|
+
<div class="perms"></div>
|
|
974
|
+
</div>
|
|
975
|
+
<div class="meta"></div>
|
|
976
|
+
</div>`;
|
|
977
|
+
el.querySelector(".more").addEventListener("click", () => {
|
|
978
|
+
if (state.expanded.has(m.id)) state.expanded.delete(m.id);
|
|
979
|
+
else state.expanded.add(m.id);
|
|
980
|
+
updateMessageElement(el, room, m);
|
|
981
|
+
});
|
|
982
|
+
const editBtn = el.querySelector(".edit-btn");
|
|
983
|
+
if (editBtn) editBtn.addEventListener("click", () => openInlineEditor(el, room, m));
|
|
984
|
+
updateMessageElement(el, room, m);
|
|
985
|
+
return el;
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
function updateMessageElement(el, room, m) {
|
|
989
|
+
el.classList.toggle("hidden-by-search", !messageMatches(m));
|
|
990
|
+
const editedEl = el.querySelector(".edited");
|
|
991
|
+
if (editedEl) {
|
|
992
|
+
editedEl.hidden = !m.edited;
|
|
993
|
+
if (m.edited) {
|
|
994
|
+
editedEl.textContent = "edited";
|
|
995
|
+
editedEl.title = `before: ${m.edited.previous}`;
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
if (m.kind === "hidden") {
|
|
999
|
+
const wasOpen = el.querySelector("details")?.open;
|
|
1000
|
+
renderHidden(el, m);
|
|
1001
|
+
if (wasOpen) el.querySelector("details").open = true;
|
|
1002
|
+
return;
|
|
1003
|
+
}
|
|
1004
|
+
if (m.kind === "system") return;
|
|
1005
|
+
const text = el.querySelector(".text");
|
|
1006
|
+
const more = el.querySelector(".more");
|
|
1007
|
+
const long = !m.streaming && m.text.length > CLAMP_CHARS;
|
|
1008
|
+
const expanded = state.expanded.has(m.id);
|
|
1009
|
+
text.innerHTML = renderText(room, m.text) + (m.streaming ? '<span class="caret"></span>' : "");
|
|
1010
|
+
if (!m.streaming) renderDiagrams(text);
|
|
1011
|
+
if (m.streaming && !m.text) text.innerHTML = '<span class="pending">…</span>';
|
|
1012
|
+
text.classList.toggle("clamped", long && !expanded);
|
|
1013
|
+
more.hidden = !long;
|
|
1014
|
+
more.textContent = expanded ? "Show less" : "Show more";
|
|
1015
|
+
const thought = el.querySelector(".thought");
|
|
1016
|
+
if (m.thought) {
|
|
1017
|
+
thought.hidden = false;
|
|
1018
|
+
thought.querySelector(".thought-text").textContent = m.thought;
|
|
1019
|
+
}
|
|
1020
|
+
el.querySelector(".agent-notices").innerHTML = (m.notices || []).map((n) => `<div class="agent-notice">${ic("info")} ${renderText(room, n)}</div>`).join("");
|
|
1021
|
+
const tools = el.querySelector(".tools");
|
|
1022
|
+
tools.innerHTML = "";
|
|
1023
|
+
for (const call of m.toolCalls || []) {
|
|
1024
|
+
const chip = document.createElement("span");
|
|
1025
|
+
chip.className = `chip chip-${call.status || "pending"}`;
|
|
1026
|
+
chip.title = call.rawInput ? JSON.stringify(call.rawInput, null, 1).slice(0, 800) : "";
|
|
1027
|
+
chip.innerHTML = `${ic("tool")}${esc(`${call.title}${call.kind ? ` · ${call.kind}` : ""} · ${call.status || "pending"}`)}`;
|
|
1028
|
+
tools.appendChild(chip);
|
|
1029
|
+
}
|
|
1030
|
+
const plan = el.querySelector(".plan");
|
|
1031
|
+
if (m.plan && m.plan.length) {
|
|
1032
|
+
plan.hidden = false;
|
|
1033
|
+
plan.innerHTML = m.plan.map((e) => `<div class="plan-entry ${e.status}">${esc(e.content)}</div>`).join("");
|
|
1034
|
+
}
|
|
1035
|
+
const meta = el.querySelector(".meta");
|
|
1036
|
+
if (!m.streaming && m.from !== "human") {
|
|
1037
|
+
const parts = [];
|
|
1038
|
+
if (m.stopReason && m.stopReason !== "end_turn") parts.push(`<span>${esc(m.stopReason)}</span>`);
|
|
1039
|
+
if (m.durationMs) parts.push(`<span title="how long the reply took">${ic("clock")} ${(m.durationMs / 1000).toFixed(1)} s</span>`);
|
|
1040
|
+
if (m.usage) parts.push(`<span title="tokens in">${ic("arrow-down")} ${fmtTokens(m.usage.inputTokens)}</span><span title="tokens out">${ic("arrow-up")} ${fmtTokens(m.usage.outputTokens)}</span>${m.usage.cachedWriteTokens ? `<span title="tokens written to the cache">${ic("database")} ${fmtTokens(m.usage.cachedWriteTokens)}</span>` : ""}`);
|
|
1041
|
+
meta.innerHTML = parts.join("<span class=\"sep\">·</span>");
|
|
1042
|
+
} else if (m.from === "human") refreshSeen(room);
|
|
1043
|
+
else meta.textContent = "";
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
function lastHumanMessage(room) {
|
|
1047
|
+
for (let i = room.messages.length - 1; i >= 0; i--) {
|
|
1048
|
+
const m = room.messages[i];
|
|
1049
|
+
if (m.kind === "chat" && m.from === "human") return m;
|
|
1050
|
+
}
|
|
1051
|
+
return null;
|
|
1052
|
+
}
|
|
1053
|
+
function seenHtml(room, m) {
|
|
1054
|
+
const seen = room.participants.filter((p) => p.kind === "agent" && p.lastSeenSeq != null && p.lastSeenSeq >= m.seq);
|
|
1055
|
+
if (!seen.length) return `<span class="ticks">✓</span> sent`;
|
|
1056
|
+
return `<span class="ticks">✓✓</span> seen by ${esc(seen.map((p) => p.name).join(", "))}`;
|
|
1057
|
+
}
|
|
1058
|
+
function refreshSeen(room) {
|
|
1059
|
+
const last = lastHumanMessage(room);
|
|
1060
|
+
for (const el of els.messages.querySelectorAll(".msg.mine")) {
|
|
1061
|
+
const meta = el.querySelector(".meta");
|
|
1062
|
+
if (!meta) continue;
|
|
1063
|
+
const isLast = last && el.dataset.id === last.id;
|
|
1064
|
+
meta.innerHTML = isLast ? seenHtml(room, last) : "";
|
|
1065
|
+
meta.classList.toggle("seen", !!isLast);
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
function visibilityMarkers(room) {
|
|
1070
|
+
const bySeq = new Map();
|
|
1071
|
+
for (const p of room.participants) {
|
|
1072
|
+
if (p.kind !== "agent" || p.sawFromSeq === undefined || p.sawFromSeq === null) continue;
|
|
1073
|
+
if (!bySeq.has(p.sawFromSeq)) bySeq.set(p.sawFromSeq, []);
|
|
1074
|
+
bySeq.get(p.sawFromSeq).push(p);
|
|
1075
|
+
}
|
|
1076
|
+
return bySeq;
|
|
1077
|
+
}
|
|
1078
|
+
function visibilityFingerprint(room) {
|
|
1079
|
+
return [...visibilityMarkers(room).entries()].map(([seq, ps]) => `${seq}:${ps.map((p) => p.id).join(",")}`).sort().join("|");
|
|
1080
|
+
}
|
|
1081
|
+
function dividerElement(agents) {
|
|
1082
|
+
const el = document.createElement("div");
|
|
1083
|
+
el.className = "visibility-divider";
|
|
1084
|
+
const names = agents.map((p) => p.name);
|
|
1085
|
+
const label = names.length === 1 ? `${names[0]} has not seen anything above this line` : `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]} have not seen anything above this line`;
|
|
1086
|
+
el.innerHTML = `<span class="vd-line"></span><span class="vd-label" title="Vibemates know the room only from their own starting point: the join, a replay of the last N messages, or a restored session.">↑ ${esc(label)}</span><span class="vd-line"></span>`;
|
|
1087
|
+
return el;
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
function renderMessages() {
|
|
1091
|
+
const room = currentRoom();
|
|
1092
|
+
els.messages.innerHTML = "";
|
|
1093
|
+
if (!room) return;
|
|
1094
|
+
if (!room.messages.length) {
|
|
1095
|
+
els.messages.innerHTML = `<div class="empty"><div class="art">${ic("chat")}</div><strong>${esc(room.name)}</strong> is quiet.<br>Summon a vibemate from the left, then say hello. Use @Name to address someone; without @ every vibemate hears you.</div>`;
|
|
1096
|
+
return;
|
|
1097
|
+
}
|
|
1098
|
+
const markers = visibilityMarkers(room);
|
|
1099
|
+
const placed = new Set();
|
|
1100
|
+
let lastDay = "";
|
|
1101
|
+
for (const m of room.messages) {
|
|
1102
|
+
const day = dayLabel(m.ts);
|
|
1103
|
+
if (day !== lastDay) {
|
|
1104
|
+
const d = document.createElement("div");
|
|
1105
|
+
d.className = "day";
|
|
1106
|
+
d.textContent = day;
|
|
1107
|
+
d.title = new Date(m.ts).toLocaleDateString([], { weekday: "long", day: "numeric", month: "long", year: "numeric" });
|
|
1108
|
+
els.messages.appendChild(d);
|
|
1109
|
+
lastDay = day;
|
|
1110
|
+
}
|
|
1111
|
+
for (const [seq, agents] of markers) {
|
|
1112
|
+
if (placed.has(seq) || !(m.seq >= seq)) continue;
|
|
1113
|
+
if (m.seq > 0) {
|
|
1114
|
+
placed.add(seq);
|
|
1115
|
+
els.messages.appendChild(dividerElement(agents));
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
els.messages.appendChild(messageElement(room, m));
|
|
1119
|
+
}
|
|
1120
|
+
for (const [seq, agents] of markers) {
|
|
1121
|
+
if (!placed.has(seq)) els.messages.appendChild(dividerElement(agents));
|
|
1122
|
+
}
|
|
1123
|
+
for (const perm of room.permissions) renderPermission(room, perm);
|
|
1124
|
+
refreshSeen(room);
|
|
1125
|
+
scrollToBottom();
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
function upsertMessage(roomId, m) {
|
|
1129
|
+
const room = state.rooms.get(roomId);
|
|
1130
|
+
if (!room) return;
|
|
1131
|
+
const idx = room.messages.findIndex((x) => x.id === m.id);
|
|
1132
|
+
if (idx >= 0) Object.assign(room.messages[idx], m);
|
|
1133
|
+
else room.messages.push(m);
|
|
1134
|
+
const showing = roomId === state.currentRoomId && state.view === "room";
|
|
1135
|
+
if (!showing) {
|
|
1136
|
+
if (idx < 0 && m.kind === "chat" && !m.streaming && m.from !== "human" && roomId !== state.currentRoomId) state.unread.set(roomId, (state.unread.get(roomId) || 0) + 1);
|
|
1137
|
+
if ((state.view === "rooms" || state.view === "home")) {
|
|
1138
|
+
renderSideRooms();
|
|
1139
|
+
renderRoomsGrid();
|
|
1140
|
+
}
|
|
1141
|
+
renderRail();
|
|
1142
|
+
return;
|
|
1143
|
+
}
|
|
1144
|
+
const stick = nearBottom();
|
|
1145
|
+
const existing = els.messages.querySelector(`.msg[data-id="${m.id}"]`);
|
|
1146
|
+
if (existing) updateMessageElement(existing, room, room.messages[idx]);
|
|
1147
|
+
else {
|
|
1148
|
+
const empty = els.messages.querySelector(".empty");
|
|
1149
|
+
if (empty) empty.remove();
|
|
1150
|
+
els.messages.appendChild(messageElement(room, m));
|
|
1151
|
+
if (m.from === "human") refreshSeen(room);
|
|
1152
|
+
}
|
|
1153
|
+
if (stick) scrollToBottom();
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
function removeMessage(roomId, id) {
|
|
1157
|
+
const room = state.rooms.get(roomId);
|
|
1158
|
+
if (!room) return;
|
|
1159
|
+
room.messages = room.messages.filter((m) => m.id !== id);
|
|
1160
|
+
if (roomId !== state.currentRoomId) return;
|
|
1161
|
+
const el = els.messages.querySelector(`.msg[data-id="${id}"]`);
|
|
1162
|
+
if (el) el.remove();
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
function patchMessage(roomId, id, fn) {
|
|
1166
|
+
const room = state.rooms.get(roomId);
|
|
1167
|
+
if (!room) return;
|
|
1168
|
+
const m = room.messages.find((x) => x.id === id);
|
|
1169
|
+
if (!m) return;
|
|
1170
|
+
fn(m);
|
|
1171
|
+
if (roomId !== state.currentRoomId || state.view !== "room") return;
|
|
1172
|
+
const el = els.messages.querySelector(`.msg[data-id="${id}"]`);
|
|
1173
|
+
const stick = nearBottom();
|
|
1174
|
+
if (el) updateMessageElement(el, room, m);
|
|
1175
|
+
if (stick) scrollToBottom();
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
|
|
1179
|
+
function openInlineEditor(el, room, m) {
|
|
1180
|
+
const box = el.querySelector(".edit-box");
|
|
1181
|
+
const text = el.querySelector(".text");
|
|
1182
|
+
if (!box.hidden) return;
|
|
1183
|
+
box.innerHTML = `<textarea class="edit-area" rows="3"></textarea><div class="row-btns"><button type="button" class="btn sm ghost edit-cancel">Cancel</button><button type="button" class="btn sm primary edit-save">Save</button></div>`;
|
|
1184
|
+
const area = box.querySelector(".edit-area");
|
|
1185
|
+
area.value = m.text;
|
|
1186
|
+
box.hidden = false;
|
|
1187
|
+
text.hidden = true;
|
|
1188
|
+
area.focus();
|
|
1189
|
+
area.setSelectionRange(area.value.length, area.value.length);
|
|
1190
|
+
const close = () => {
|
|
1191
|
+
box.hidden = true;
|
|
1192
|
+
box.innerHTML = "";
|
|
1193
|
+
text.hidden = false;
|
|
1194
|
+
};
|
|
1195
|
+
box.querySelector(".edit-cancel").addEventListener("click", close);
|
|
1196
|
+
area.addEventListener("keydown", (event) => {
|
|
1197
|
+
if (event.key === "Escape") close();
|
|
1198
|
+
if (event.key === "Enter" && (event.ctrlKey || event.metaKey)) box.querySelector(".edit-save").click();
|
|
1199
|
+
});
|
|
1200
|
+
box.querySelector(".edit-save").addEventListener("click", async () => {
|
|
1201
|
+
const next = area.value.trim();
|
|
1202
|
+
if (!next || next === m.text) return close();
|
|
1203
|
+
try {
|
|
1204
|
+
const preview = await get(`/api/rooms/${encodeURIComponent(room.id)}/messages/${encodeURIComponent(m.id)}/edit-preview`);
|
|
1205
|
+
close();
|
|
1206
|
+
openEditDialog(room, m, next, preview);
|
|
1207
|
+
} catch (e) {
|
|
1208
|
+
showError(e);
|
|
1209
|
+
}
|
|
1210
|
+
});
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
const editEls = { dialog: $("#edit-dialog"), summary: $("#ed-summary"), rewriteDetail: $("#ed-rewrite-detail"), error: $("#ed-error"), notify: $("#ed-notify"), rewrite: $("#ed-rewrite") };
|
|
1214
|
+
let editRequest = null;
|
|
1215
|
+
|
|
1216
|
+
function openEditDialog(room, m, next, preview) {
|
|
1217
|
+
const nobodySaw = preview.restart.length === 0 && preview.offline.length === 0;
|
|
1218
|
+
if (preview.laterRecords === 0 && nobodySaw) {
|
|
1219
|
+
submitEdit(room, m, next, "notify");
|
|
1220
|
+
return;
|
|
1221
|
+
}
|
|
1222
|
+
const later = preview.laterMessages === 0 ? "No chat messages follow it" : `${preview.laterMessages} chat message${preview.laterMessages > 1 ? "s" : ""} follow${preview.laterMessages > 1 ? "" : "s"} it`;
|
|
1223
|
+
const saw = preview.restart.length ? `Vibemates that already read it: ${preview.restart.join(", ")}.` : "No online vibemate has read it yet.";
|
|
1224
|
+
const off = preview.offline.length ? ` Offline with the old version: ${preview.offline.join(", ")} (a rewrite makes them replay the new history when they reconnect).` : "";
|
|
1225
|
+
editEls.summary.textContent = `${later}. ${saw}${off}`;
|
|
1226
|
+
editEls.rewriteDetail.textContent = preview.laterMessages
|
|
1227
|
+
? `removes the ${preview.laterMessages} later chat message${preview.laterMessages > 1 ? "s" : ""} (and ${preview.laterRecords - preview.laterMessages} room event${preview.laterRecords - preview.laterMessages === 1 ? "" : "s"}).`
|
|
1228
|
+
: "nothing to remove after it.";
|
|
1229
|
+
editEls.error.hidden = true;
|
|
1230
|
+
editEls.rewrite.textContent = preview.restart.length ? `Rewrite from here (restart ${preview.restart.join(", ")})` : "Rewrite from here";
|
|
1231
|
+
editRequest = { room, m, next };
|
|
1232
|
+
openDialog(editEls.dialog);
|
|
1233
|
+
editEls.notify.focus();
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
async function submitEdit(room, m, next, mode) {
|
|
1237
|
+
try {
|
|
1238
|
+
const result = await post(`/api/rooms/${encodeURIComponent(room.id)}/messages/${encodeURIComponent(m.id)}/edit`, { text: next, mode });
|
|
1239
|
+
closeDialog(editEls.dialog);
|
|
1240
|
+
if (mode === "rewrite") toast(`Rewritten from here: ${result.removed} record${result.removed === 1 ? "" : "s"} removed${result.restarted.length ? `; restarted ${result.restarted.join(", ")}` : ""}.`, "info");
|
|
1241
|
+
} catch (e) {
|
|
1242
|
+
if (editEls.dialog.open) {
|
|
1243
|
+
editEls.error.textContent = e.message;
|
|
1244
|
+
editEls.error.hidden = false;
|
|
1245
|
+
} else showError(e);
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
editEls.notify.addEventListener("click", () => editRequest && submitEdit(editRequest.room, editRequest.m, editRequest.next, "notify"));
|
|
1249
|
+
editEls.rewrite.addEventListener("click", () => editRequest && submitEdit(editRequest.room, editRequest.m, editRequest.next, "rewrite"));
|
|
1250
|
+
|
|
1251
|
+
|
|
1252
|
+
function renderPermission(room, perm) {
|
|
1253
|
+
const p = findById(room, perm.participantId);
|
|
1254
|
+
const card = document.createElement("div");
|
|
1255
|
+
card.className = "perm";
|
|
1256
|
+
card.dataset.key = perm.key;
|
|
1257
|
+
const tc = perm.toolCall || {};
|
|
1258
|
+
card.innerHTML = `
|
|
1259
|
+
<div class="perm-title">${ic("lock")} ${esc(p ? p.name : perm.participantId)} asks for permission: <strong>${esc(tc.title || tc.toolCallId || "tool call")}</strong>${tc.kind ? ` <span class="kind">${esc(tc.kind)}</span>` : ""}</div>
|
|
1260
|
+
${tc.rawInput ? `<pre class="perm-input">${esc(JSON.stringify(tc.rawInput, null, 1).slice(0, 1200))}</pre>` : ""}
|
|
1261
|
+
<div class="perm-actions"></div>`;
|
|
1262
|
+
const actions = card.querySelector(".perm-actions");
|
|
1263
|
+
for (const option of perm.options || []) {
|
|
1264
|
+
const btn = document.createElement("button");
|
|
1265
|
+
btn.className = `perm-btn kind-${option.kind}`;
|
|
1266
|
+
btn.textContent = option.name;
|
|
1267
|
+
btn.title = option.kind;
|
|
1268
|
+
btn.addEventListener("click", () => post(roomApi(`/permissions/${encodeURIComponent(perm.key)}`), { optionId: option.optionId }).catch(showError));
|
|
1269
|
+
actions.appendChild(btn);
|
|
1270
|
+
}
|
|
1271
|
+
const cancel = document.createElement("button");
|
|
1272
|
+
cancel.className = "perm-btn";
|
|
1273
|
+
cancel.textContent = "Dismiss (cancelled)";
|
|
1274
|
+
cancel.addEventListener("click", () => post(roomApi(`/permissions/${encodeURIComponent(perm.key)}`), { optionId: null }).catch(showError));
|
|
1275
|
+
actions.appendChild(cancel);
|
|
1276
|
+
const draft = [...room.messages].reverse().find((m) => m.streaming && m.from === perm.participantId);
|
|
1277
|
+
const host = draft ? els.messages.querySelector(`.msg[data-id="${draft.id}"] .perms`) : null;
|
|
1278
|
+
(host || els.messages).appendChild(card);
|
|
1279
|
+
scrollToBottom();
|
|
1280
|
+
}
|
|
1281
|
+
function resolvePermissionCard(key, optionId) {
|
|
1282
|
+
const card = document.querySelector(`.perm[data-key="${key}"]`);
|
|
1283
|
+
if (!card) return;
|
|
1284
|
+
card.classList.add("resolved");
|
|
1285
|
+
card.querySelector(".perm-actions").innerHTML = `<span class="perm-result">${optionId ? `chosen: ${esc(optionId)}` : "dismissed"}</span>`;
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
|
|
1289
|
+
const DETAILS_MIN = 320;
|
|
1290
|
+
const DETAILS_MAX = 760;
|
|
1291
|
+
const DETAILS_DEFAULT = { room: 420, participant: 420, me: 400 };
|
|
1292
|
+
|
|
1293
|
+
function detailsKey() {
|
|
1294
|
+
return state.selection.kind === "participant" ? "participant" : state.selection.kind;
|
|
1295
|
+
}
|
|
1296
|
+
function applyDetailsWidth(px, persist) {
|
|
1297
|
+
const w = Math.max(DETAILS_MIN, Math.min(DETAILS_MAX, Math.round(px)));
|
|
1298
|
+
els.details.style.width = `${w}px`;
|
|
1299
|
+
if (persist) remember(`details.${detailsKey()}`, w);
|
|
1300
|
+
}
|
|
1301
|
+
function fitDetailsWidth() {
|
|
1302
|
+
const key = detailsKey();
|
|
1303
|
+
const saved = Number(recall(`details.${key}`));
|
|
1304
|
+
applyDetailsWidth(saved || DETAILS_DEFAULT[key] || 400, false);
|
|
1305
|
+
}
|
|
1306
|
+
function openDetails(selection) {
|
|
1307
|
+
state.selection = selection;
|
|
1308
|
+
state.detailsOpen = true;
|
|
1309
|
+
els.details.classList.remove("closing");
|
|
1310
|
+
els.details.hidden = false;
|
|
1311
|
+
fitDetailsWidth();
|
|
1312
|
+
renderDetails();
|
|
1313
|
+
if (state.view === "room") renderSideRoom();
|
|
1314
|
+
renderRail();
|
|
1315
|
+
}
|
|
1316
|
+
function closeDetails() {
|
|
1317
|
+
if (!state.detailsOpen) return;
|
|
1318
|
+
state.detailsOpen = false;
|
|
1319
|
+
els.details.classList.add("closing");
|
|
1320
|
+
setTimeout(() => {
|
|
1321
|
+
if (!state.detailsOpen) {
|
|
1322
|
+
els.details.hidden = true;
|
|
1323
|
+
els.details.classList.remove("closing");
|
|
1324
|
+
}
|
|
1325
|
+
}, 190);
|
|
1326
|
+
if (state.selection.kind === "me") state.selection = { kind: "room" };
|
|
1327
|
+
if (state.view === "room") renderSideRoom();
|
|
1328
|
+
renderRail();
|
|
1329
|
+
}
|
|
1330
|
+
function renderDetails() {
|
|
1331
|
+
if (!state.detailsOpen) return;
|
|
1332
|
+
const room = currentRoom();
|
|
1333
|
+
if (state.selection.kind === "me") return renderMePanel(room);
|
|
1334
|
+
if (!room) {
|
|
1335
|
+
els.detailsInner.innerHTML = '<div class="empty">Open a room to begin.</div>';
|
|
1336
|
+
return;
|
|
1337
|
+
}
|
|
1338
|
+
if (state.selection.kind === "participant") {
|
|
1339
|
+
const p = findById(room, state.selection.id);
|
|
1340
|
+
if (p && p.kind === "agent") return renderAgentPanel(room, p);
|
|
1341
|
+
if (p && p.kind === "human") return renderMePanel(room);
|
|
1342
|
+
}
|
|
1343
|
+
renderRoomPanel(room);
|
|
1344
|
+
}
|
|
1345
|
+
function profileHeader(p) {
|
|
1346
|
+
return `${avatar(p, 76, { vendor: true, status: true })}
|
|
1347
|
+
<h3>${esc(p.name)}</h3>
|
|
1348
|
+
<div class="tagline">${esc(p.tagline || "no vibersona")}</div>
|
|
1349
|
+
<div class="badges"><span class="badge">${esc(p.agentVendor || p.agentType || "vibemate")}</span><span class="badge status-${p.status}">${STATUS_LABEL[p.status] || p.status}</span>${p.muted ? '<span class="badge muted">muted</span>' : ""}</div>`;
|
|
1350
|
+
}
|
|
1351
|
+
function refreshDetailsHeader(p) {
|
|
1352
|
+
const header = els.detailsInner.querySelector(".profile");
|
|
1353
|
+
if (header) header.innerHTML = profileHeader(p);
|
|
1354
|
+
}
|
|
1355
|
+
function panelTitle(title, sub) {
|
|
1356
|
+
return `<div class="panel-title"><div><h3>${title}</h3>${sub ? `<div class="hint">${sub}</div>` : ""}</div><button class="icon-btn sm ghost" id="details-close" title="Close">${ic("close")}</button></div>`;
|
|
1357
|
+
}
|
|
1358
|
+
function wireDetailsClose() {
|
|
1359
|
+
const b = $("#details-close");
|
|
1360
|
+
if (b) b.addEventListener("click", closeDetails);
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
function renderAgentPanel(room, p) {
|
|
1364
|
+
const rec = state.recipes.find((r) => r.id === p.agentType);
|
|
1365
|
+
const offline = p.status === "offline";
|
|
1366
|
+
els.detailsInner.innerHTML = `
|
|
1367
|
+
${panelTitle("Vibemate", esc(room.name))}
|
|
1368
|
+
<div class="profile">
|
|
1369
|
+
${profileHeader(p)}
|
|
1370
|
+
</div>
|
|
1371
|
+
<div class="action-row">
|
|
1372
|
+
<button class="action" data-act="mention"><span class="ico">${ic("at")}</span>Mention</button>
|
|
1373
|
+
<button class="action" data-act="${p.muted ? "unmute" : "mute"}"><span class="ico">${ic(p.muted ? "bell" : "bell-off")}</span>${p.muted ? "Unmute" : "Mute"}</button>
|
|
1374
|
+
${offline ? `<button class="action" data-act="reconnect"><span class="ico">${ic("refresh")}</span>Reconnect</button>` : `<button class="action" data-act="cancel" ${p.status !== "thinking" ? "disabled" : ""}><span class="ico">${ic("stop")}</span>Stop</button>`}
|
|
1375
|
+
<button class="action danger" data-act="remove"><span class="ico">${ic("trash")}</span>Remove</button>
|
|
1376
|
+
</div>
|
|
1377
|
+
<div class="section" id="pp-persona">
|
|
1378
|
+
${sectionTitle("user", "Persona")}
|
|
1379
|
+
${field("Vibename", `<input type="text" id="pp-name" maxlength="24" value="${esc(p.name)}">`)}
|
|
1380
|
+
${field("Vibersona", `<input type="text" id="pp-tagline" maxlength="80" value="${esc(p.tagline || "")}" placeholder="a few words under the vibename">`, "Shown under the vibename.", "Everyone in the room sees it: you, and the other vibemates in their roster.")}
|
|
1381
|
+
${field("Vibeface", `<div id="pp-avatar-picker"></div><input type="text" id="pp-avatar" maxlength="8" value="${esc(p.avatar || "")}" placeholder="custom emoji (optional)">`)}
|
|
1382
|
+
${field("Vibio", `<textarea id="pp-role" rows="5" maxlength="4000" placeholder="who it is, how it speaks, what it cares about">${esc(p.role || "")}</textarea>`, "Only this vibemate reads it.", "Reaches the vibemate as refreshed instructions in its brief on its next turn; its memory is kept. The other participants never see it.")}
|
|
1383
|
+
${saveRow("pp-save")}
|
|
1384
|
+
</div>
|
|
1385
|
+
${p.statusDetail && (p.status === "offline" || p.status === "error" || p.failedTurns) ? `<p class="hint" style="color:var(--danger);margin:0 4px 10px">${esc(p.statusDetail)}</p>` : ""}
|
|
1386
|
+
${geek(
|
|
1387
|
+
"pp-geek",
|
|
1388
|
+
`<div class="section" id="pp-skills-section">
|
|
1389
|
+
${sectionTitle("skills", "Skills")}
|
|
1390
|
+
<div class="check-list" id="pp-skills"></div>
|
|
1391
|
+
<p class="hint" style="margin-top:8px">What this vibemate can load on request.${geekTip(`Listed in this vibemate's brief by name and description; the text arrives when you write /name or when the vibemate loads it. ${esc(skillChannelText(p))}`)}</p>
|
|
1392
|
+
${saveRow("pp-skills-save")}
|
|
1393
|
+
</div>
|
|
1394
|
+
<div class="section" id="pp-timing">
|
|
1395
|
+
${sectionTitle("bolt", "Timing")}
|
|
1396
|
+
${field("Reply delay override, seconds", `<input type="number" id="pp-delay" min="0" max="120" step="0.5" value="${p.replyDelay ?? ""}" placeholder="the room's: ${room.settings.replyDelay ?? 4} s">`, `Overrides the room's delay (${room.settings.replyDelay ?? 4} s, used only when two or more vibemates are in) for this vibemate only, even when it is alone. Empty: it follows the room.`, "Before each turn the vibemate waits a random 0–N seconds, so replies cross less often. Messages that arrive during the wait land in its backlog, so it can react to them or stay silent.")}
|
|
1397
|
+
${saveRow("pp-delay-save")}
|
|
1398
|
+
</div>
|
|
1399
|
+
<div class="section">
|
|
1400
|
+
${sectionTitle("link", "Session")}
|
|
1401
|
+
<div id="pp-config"></div>
|
|
1402
|
+
</div>
|
|
1403
|
+
<div class="section">
|
|
1404
|
+
${sectionTitle("info", "Stats")}
|
|
1405
|
+
<div class="kv">
|
|
1406
|
+
<span>Session</span><span>${p.sessionOrigin === "loaded" ? "restored (session/load)" : p.sessionOrigin === "replayed" ? "new, history replayed" : p.status === "offline" ? "offline" : "new"}${p.supportsLoad === false ? " · no session/load" : ""}</span>
|
|
1407
|
+
<span>Turns</span><span>${p.turns}</span>
|
|
1408
|
+
<span>Briefs sent</span><span>${p.briefsSent ?? 0}</span>
|
|
1409
|
+
<span>Referee reminders</span><span>${p.violations ?? 0}</span>
|
|
1410
|
+
<span>Hidden retries</span><span>${p.retries ?? 0}</span>
|
|
1411
|
+
<span>Failed turns</span><span>${p.failedTurns ?? 0}</span>
|
|
1412
|
+
<span>Context</span><span>${p.contextSize ? `${fmtTokens(p.contextUsed)} / ${fmtTokens(p.contextSize)}` : "—"}</span>
|
|
1413
|
+
<span>Cost (estimate)</span><span>${fmtCost(p.cost) || "—"}</span>
|
|
1414
|
+
<span>Adapter</span><span>${esc(rec ? rec.label : p.agentLabel || "")}${p.agentInfo && p.agentInfo.version ? ` ${esc(p.agentInfo.version)}` : ""}</span>
|
|
1415
|
+
</div>
|
|
1416
|
+
</div>`,
|
|
1417
|
+
"skills, timing, session, stats",
|
|
1418
|
+
)}`;
|
|
1419
|
+
wireDetailsClose();
|
|
1420
|
+
els.detailsInner.querySelectorAll(".action").forEach((btn) => {
|
|
1421
|
+
btn.addEventListener("click", async () => {
|
|
1422
|
+
const act = btn.dataset.act;
|
|
1423
|
+
try {
|
|
1424
|
+
if (act === "mention") insertMention(p.name);
|
|
1425
|
+
else if (act === "remove") {
|
|
1426
|
+
if (await confirmDialog(`${p.name} leaves the room and its session is closed. The history stays.`, { title: `Remove ${p.name}?`, okLabel: "Remove", danger: true })) {
|
|
1427
|
+
await post(roomApi(`/participants/${encodeURIComponent(p.id)}/remove`));
|
|
1428
|
+
closeDetails();
|
|
1429
|
+
}
|
|
1430
|
+
} else if (act === "reconnect") openReconnectDialog(room);
|
|
1431
|
+
else await post(roomApi(`/participants/${encodeURIComponent(p.id)}/${act}`));
|
|
1432
|
+
} catch (e) {
|
|
1433
|
+
showError(e);
|
|
1434
|
+
}
|
|
1435
|
+
});
|
|
1436
|
+
});
|
|
1437
|
+
$("#pp-avatar-picker").appendChild(
|
|
1438
|
+
window.Avatars.pickerElement(p.avatar || "", (emoji) => {
|
|
1439
|
+
$("#pp-avatar").value = emoji;
|
|
1440
|
+
$("#pp-avatar").dispatchEvent(new Event("input", { bubbles: true }));
|
|
1441
|
+
}),
|
|
1442
|
+
);
|
|
1443
|
+
renderSkillChecks($("#pp-skills"), p.skills || []);
|
|
1444
|
+
bindSave($("#pp-skills-section"), $("#pp-skills-save"), () => post(roomApi(`/participants/${encodeURIComponent(p.id)}/persona`), { skills: checkedSkills($("#pp-skills")) }));
|
|
1445
|
+
bindSave($("#pp-timing"), $("#pp-delay-save"), () => post(roomApi(`/participants/${encodeURIComponent(p.id)}/persona`), { replyDelay: $("#pp-delay").value === "" ? null : Number($("#pp-delay").value) }));
|
|
1446
|
+
bindSave($("#pp-persona"), $("#pp-save"), () => post(roomApi(`/participants/${encodeURIComponent(p.id)}/persona`), { name: $("#pp-name").value, tagline: $("#pp-tagline").value, role: $("#pp-role").value, avatar: $("#pp-avatar").value }));
|
|
1447
|
+
renderConfig($("#pp-config"), p, offline);
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
function flattenOptions(options) {
|
|
1451
|
+
const out = [];
|
|
1452
|
+
for (const entry of options || []) {
|
|
1453
|
+
if (entry && Array.isArray(entry.options)) out.push(...entry.options);
|
|
1454
|
+
else out.push(entry);
|
|
1455
|
+
}
|
|
1456
|
+
return out;
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
function renderConfig(panel, p, offline) {
|
|
1460
|
+
panel.innerHTML = "";
|
|
1461
|
+
if (offline) {
|
|
1462
|
+
panel.innerHTML = `<p class="hint">Offline. Reconnect to start a new session (${esc([p.launch && p.launch.model, p.launch && p.launch.effort, p.launch && p.launch.mode].filter(Boolean).join(" · ") || "vibemate defaults")}).</p>`;
|
|
1463
|
+
return;
|
|
1464
|
+
}
|
|
1465
|
+
const addSelect = (name, values, current, onChange) => {
|
|
1466
|
+
const label = document.createElement("label");
|
|
1467
|
+
label.className = "row";
|
|
1468
|
+
label.innerHTML = `<span>${esc(name)}</span>`;
|
|
1469
|
+
const select = document.createElement("select");
|
|
1470
|
+
for (const v of values) {
|
|
1471
|
+
const opt = document.createElement("option");
|
|
1472
|
+
opt.value = v.value;
|
|
1473
|
+
opt.textContent = v.name || v.value;
|
|
1474
|
+
if (v.description) opt.title = v.description;
|
|
1475
|
+
if (v.value === current) opt.selected = true;
|
|
1476
|
+
select.appendChild(opt);
|
|
1477
|
+
}
|
|
1478
|
+
select.addEventListener("change", () => onChange(select.value));
|
|
1479
|
+
label.appendChild(select);
|
|
1480
|
+
panel.appendChild(label);
|
|
1481
|
+
};
|
|
1482
|
+
const hasModeOption = (p.configOptions || []).some((o) => o.category === "mode");
|
|
1483
|
+
if (!hasModeOption && p.modes && p.modes.length) {
|
|
1484
|
+
addSelect("Mode", p.modes.map((m) => ({ value: m.id, name: m.name || m.id, description: m.description })), p.mode, (value) => post(roomApi(`/participants/${encodeURIComponent(p.id)}/config`), { configId: "mode", value }).catch(showError));
|
|
1485
|
+
}
|
|
1486
|
+
for (const option of p.configOptions || []) {
|
|
1487
|
+
if (option.type !== "select") continue;
|
|
1488
|
+
addSelect(option.name, flattenOptions(option.options), option.currentValue, (value) => post(roomApi(`/participants/${encodeURIComponent(p.id)}/config`), { configId: option.id, value }).catch(showError));
|
|
1489
|
+
}
|
|
1490
|
+
if (!panel.children.length) panel.innerHTML = '<p class="hint">This vibemate exposes no session options.</p>';
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
function renderMePanel(room) {
|
|
1494
|
+
const s = state.settings || {};
|
|
1495
|
+
const rs = room && state.view === "room" ? room.settings : null;
|
|
1496
|
+
els.detailsInner.innerHTML = `
|
|
1497
|
+
${panelTitle("Your vibe", "how the vibemates know you")}
|
|
1498
|
+
<div class="profile">
|
|
1499
|
+
${avatar(meAvatarData(), 84, {})}
|
|
1500
|
+
<h3>${esc(s.humanName || "")}</h3>
|
|
1501
|
+
<div class="tagline">${esc(s.humanDescription || "no vibe line yet")}</div>
|
|
1502
|
+
</div>
|
|
1503
|
+
<div class="section" id="me-vibe">
|
|
1504
|
+
${sectionTitle("spark", "Vibe")}
|
|
1505
|
+
${field("Vibename", `<input type="text" id="me-name" maxlength="24" value="${esc(s.humanName || "")}">`, "How you appear in every room.")}
|
|
1506
|
+
${field("Vibeface", `<div id="me-avatar-picker"></div><input type="text" id="me-avatar" maxlength="8" value="${esc(s.humanAvatar || "")}" placeholder="custom emoji (optional)">`)}
|
|
1507
|
+
${field("Your vibe line", `<textarea id="me-desc" rows="3" maxlength="200" placeholder="e.g. software engineer, curious about agent protocols; likes short answers">${esc(s.humanDescription || "")}</textarea>`, "A sentence or two about you.", "The vibemates get it in every room's brief, unless a room adds its own line or replaces it (below, when you are in a room).")}
|
|
1508
|
+
${saveRow("me-save")}
|
|
1509
|
+
</div>
|
|
1510
|
+
${
|
|
1511
|
+
rs
|
|
1512
|
+
? `<div class="section" id="me-room">
|
|
1513
|
+
${sectionTitle("chat", "In this room")}
|
|
1514
|
+
${field("What vibemates get about you here", `<select id="hp-mode"><option value="inherit"${rs.humanDescriptionMode === "inherit" ? " selected" : ""}>Your vibe line</option><option value="append"${rs.humanDescriptionMode === "append" ? " selected" : ""}>Your vibe line + this room's</option><option value="override"${rs.humanDescriptionMode === "override" ? " selected" : ""}>Only this room's line</option><option value="none"${rs.humanDescriptionMode === "none" ? " selected" : ""}>Nothing about me in this room</option></select>`)}
|
|
1515
|
+
${field("This room's line about you", `<textarea id="hp-desc" rows="3" maxlength="200" placeholder="e.g. host of this session, product owner">${esc(rs.humanDescription || "")}</textarea>`)}
|
|
1516
|
+
${saveRow("hp-save")}
|
|
1517
|
+
</div>`
|
|
1518
|
+
: ""
|
|
1519
|
+
}
|
|
1520
|
+
<div class="section danger">
|
|
1521
|
+
${sectionTitle("alert", "Danger zone")}
|
|
1522
|
+
<p class="field-note">Erases everything in this viberoom: your vibe, all rooms and their history, vibemate sessions, your skills. Not undoable.</p>
|
|
1523
|
+
<div class="row-btns start"><button class="btn danger sm" id="me-erase">${ic("bolt")}Erase my vibe</button></div>
|
|
1524
|
+
</div>`;
|
|
1525
|
+
wireDetailsClose();
|
|
1526
|
+
$("#me-avatar-picker").appendChild(
|
|
1527
|
+
window.Avatars.pickerElement(s.humanAvatar || "", (emoji) => {
|
|
1528
|
+
$("#me-avatar").value = emoji;
|
|
1529
|
+
$("#me-avatar").dispatchEvent(new Event("input", { bubbles: true }));
|
|
1530
|
+
}),
|
|
1531
|
+
);
|
|
1532
|
+
bindSave($("#me-vibe"), $("#me-save"), () => post("/api/settings", { humanName: $("#me-name").value, humanAvatar: $("#me-avatar").value, humanDescription: $("#me-desc").value }));
|
|
1533
|
+
bindSave($("#me-room"), $("#hp-save"), () => post(roomApi("/settings"), { humanDescriptionMode: $("#hp-mode").value, humanDescription: $("#hp-desc").value }));
|
|
1534
|
+
$("#me-erase").addEventListener("click", openEraseDialog);
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
function renderRoomPanel(room) {
|
|
1538
|
+
const rs = room.settings;
|
|
1539
|
+
const lang = rs.language && rs.language.mode === "fixed" ? rs.language.language : "";
|
|
1540
|
+
els.detailsInner.innerHTML = `
|
|
1541
|
+
${panelTitle("Room settings", esc(room.name))}
|
|
1542
|
+
<div id="rp-form">
|
|
1543
|
+
<div class="section">
|
|
1544
|
+
${sectionTitle("rooms", "Room")}
|
|
1545
|
+
${field("Name", `<input type="text" id="rp-name" maxlength="60" value="${esc(room.name)}">`)}
|
|
1546
|
+
${field("Emoji", `<div id="rp-emoji-picker"></div><input type="text" id="rp-emoji" maxlength="8" value="${esc(rs.emoji || "")}" placeholder="custom emoji (optional)">`, "A face for the room, next to its name.")}
|
|
1547
|
+
${field("Topic", `<input type="text" id="rp-topic" maxlength="2000" value="${esc(rs.topic || "")}" placeholder="what this room is about (optional)">`)}
|
|
1548
|
+
${field("Folder", `<input type="text" id="rp-dir" maxlength="1000" value="${esc(room.dir)}" spellcheck="false">`, "Where the vibemates read and write. Changing it restarts them in the new folder; they replay the last messages.")}
|
|
1549
|
+
<label class="field mention-host"><span class="label">Custom rules${geekTip("References follow renames and note when a participant has left. Rules go into every vibemate's brief as instructions, not as routing.")}</span><textarea id="rp-rules" rows="5" maxlength="4000" placeholder="e.g. Everyone listens to @Pesho, he is the manager. Keep answers under 3 sentences.">${esc(room.customRulesText != null ? room.customRulesText : rs.customRules || "")}</textarea><span class="hint">One rule per line; type @ to reference a participant.</span><div class="mention-menu inline" id="rp-rules-menu" hidden></div></label>
|
|
1550
|
+
${field("Language", `<input type="text" id="rp-lang" value="${esc(lang)}" placeholder="follow the human (default), or e.g. English">`)}
|
|
1551
|
+
</div>
|
|
1552
|
+
<div class="section">
|
|
1553
|
+
${sectionTitle("user", "Turn taking")}
|
|
1554
|
+
${field("Who may speak", `<select id="rp-turns"><option value="one-at-a-time"${rs.turnTaking !== "parallel" ? " selected" : ""}>One vibemate at a time</option><option value="parallel"${rs.turnTaking === "parallel" ? " selected" : ""}>All addressed vibemates at once</option></select>`, null, "One at a time: the others queue and see the earlier replies before they answer; the addressed vibemates go first. All at once: fastest, but replies may cross.")}
|
|
1555
|
+
${field("Reply delay, seconds", `<input type="number" id="rp-delay" min="0" max="120" step="0.5" value="${rs.replyDelay ?? 4}">`, "With two or more vibemates, each waits a random 0–N seconds before it answers, so replies cross less often. A vibemate alone answers at once. A vibemate's own delay (in its panel) always applies.")}
|
|
1556
|
+
<label class="switch"><span class="label">Wait while you are typing${geekTip("A vibemate about to start holds back while you type (a few seconds after your last keystroke). A reply already under way is not interrupted.")}</span><input type="checkbox" id="rp-wait-typing" ${rs.waitWhileHumanTypes !== false ? "checked" : ""}></label>
|
|
1557
|
+
</div>
|
|
1558
|
+
${geek(
|
|
1559
|
+
"rp-geek",
|
|
1560
|
+
`<div class="section">
|
|
1561
|
+
${sectionTitle("clock", "Right now")}
|
|
1562
|
+
<div class="kv">
|
|
1563
|
+
<span>Vibemate-to-vibemate replies since your last message</span><span>${room.hops} / ${room.hopLimit}</span>
|
|
1564
|
+
<span>Hushed</span><span>${room.focused ? "yes" : "no"}</span>
|
|
1565
|
+
<span>Full brief every</span><span>${rs.fullBriefEveryTurns} turns</span>
|
|
1566
|
+
</div>
|
|
1567
|
+
</div>
|
|
1568
|
+
<div class="section">
|
|
1569
|
+
${sectionTitle("chat", "Conversation")}
|
|
1570
|
+
${field("Vibemates' own tools (files, shell, web)", `<select id="rp-tools"><option value="on-request"${rs.tools === "on-request" ? " selected" : ""}>Only when someone explicitly asks</option><option value="never"${rs.tools === "never" ? " selected" : ""}>Never (chat only)</option></select>`, null, "An instruction in every vibemate's brief; the vibemate's mode is the real limit.")}
|
|
1571
|
+
${field("Max sentences per reply", `<input type="number" id="rp-maxlen" min="1" max="100" value="${rs.maxSentences ?? ""}" placeholder="no limit">`)}
|
|
1572
|
+
${field("Hop limit (vibemate-to-vibemate replies per human message)", `<input type="number" id="rp-hops" min="0" max="10000" value="${rs.hopLimit}">`)}
|
|
1573
|
+
</div>
|
|
1574
|
+
<div class="section">
|
|
1575
|
+
${sectionTitle("eye", "Referee")}
|
|
1576
|
+
${field("When a reply breaks a mechanical rule (unknown @, self-@, length)", `<select id="rp-referee"><option value="next-header"${rs.refereeAction !== "retry-hidden" ? " selected" : ""}>Post it; remind the agent in its next header</option><option value="retry-hidden"${rs.refereeAction === "retry-hidden" ? " selected" : ""}>Hold it; ask for a corrected version in a hidden turn</option></select>`)}
|
|
1577
|
+
</div>
|
|
1578
|
+
<div class="section">
|
|
1579
|
+
${sectionTitle("save", "Instruction delivery")}
|
|
1580
|
+
${field("Full brief every N vibemate turns", `<input type="number" id="rp-brief-turns" min="1" max="10000" value="${rs.fullBriefEveryTurns}">`)}
|
|
1581
|
+
${field("…or every N new context tokens", `<input type="number" id="rp-brief-tokens" min="1000" max="10000000" step="1000" value="${rs.fullBriefEveryTokens}">`)}
|
|
1582
|
+
<label class="switch"><span class="label">Repeat core rules in every header</span><input type="checkbox" id="rp-header-rules" ${rs.headerRules ? "checked" : ""}></label>
|
|
1583
|
+
<label class="switch"><span class="label">Show vendor and model to other vibemates</span><input type="checkbox" id="rp-vendor" ${rs.showVendorInRoster ? "checked" : ""}></label>
|
|
1584
|
+
${field("Replay last N chat messages after a reconnect", `<input type="number" id="rp-replay" min="0" max="200" value="${rs.replayAfterRestart}">`)}
|
|
1585
|
+
</div>`,
|
|
1586
|
+
"tools, hops, referee, briefs",
|
|
1587
|
+
)}
|
|
1588
|
+
<div class="save-row" style="margin-top:10px"><span class="hint">The vibemates get the changes on their next turn.</span><button class="btn sm primary save" id="rp-save" disabled>Save</button></div>
|
|
1589
|
+
</div>
|
|
1590
|
+
<div class="section danger" style="margin-top:12px">
|
|
1591
|
+
${sectionTitle("alert", "Danger zone")}
|
|
1592
|
+
<p class="field-note">Closes every vibemate in this room and removes it from the list. The history file stays on disk.</p>
|
|
1593
|
+
<div class="row-btns start"><button class="btn danger sm" id="rp-delete">${ic("trash")}Close this room for good</button></div>
|
|
1594
|
+
</div>`;
|
|
1595
|
+
wireDetailsClose();
|
|
1596
|
+
attachMentions($("#rp-rules"), $("#rp-rules-menu"));
|
|
1597
|
+
$("#rp-emoji-picker").appendChild(
|
|
1598
|
+
emojiGrid(ROOM_EMOJI, rs.emoji || "", (emoji) => {
|
|
1599
|
+
$("#rp-emoji").value = emoji;
|
|
1600
|
+
$("#rp-emoji").dispatchEvent(new Event("input", { bubbles: true }));
|
|
1601
|
+
}),
|
|
1602
|
+
);
|
|
1603
|
+
bindSave($("#rp-form"), $("#rp-save"), async () => {
|
|
1604
|
+
const name = $("#rp-name").value;
|
|
1605
|
+
if (name.trim() !== room.name) await post(roomApi("/rename"), { name });
|
|
1606
|
+
const dir = $("#rp-dir").value.trim();
|
|
1607
|
+
if (dir && dir !== room.dir) await post(roomApi("/dir"), { dir });
|
|
1608
|
+
await post(roomApi("/settings"), {
|
|
1609
|
+
emoji: $("#rp-emoji").value,
|
|
1610
|
+
topic: $("#rp-topic").value,
|
|
1611
|
+
customRules: $("#rp-rules").value,
|
|
1612
|
+
language: $("#rp-lang").value.trim() || "follow-human",
|
|
1613
|
+
tools: $("#rp-tools").value,
|
|
1614
|
+
maxSentences: $("#rp-maxlen").value === "" ? null : Number($("#rp-maxlen").value),
|
|
1615
|
+
hopLimit: Number($("#rp-hops").value),
|
|
1616
|
+
fullBriefEveryTurns: Number($("#rp-brief-turns").value),
|
|
1617
|
+
fullBriefEveryTokens: Number($("#rp-brief-tokens").value),
|
|
1618
|
+
headerRules: $("#rp-header-rules").checked,
|
|
1619
|
+
showVendorInRoster: $("#rp-vendor").checked,
|
|
1620
|
+
replayAfterRestart: Number($("#rp-replay").value),
|
|
1621
|
+
refereeAction: $("#rp-referee").value,
|
|
1622
|
+
turnTaking: $("#rp-turns").value,
|
|
1623
|
+
waitWhileHumanTypes: $("#rp-wait-typing").checked,
|
|
1624
|
+
replyDelay: Number($("#rp-delay").value),
|
|
1625
|
+
});
|
|
1626
|
+
});
|
|
1627
|
+
$("#rp-delete").addEventListener("click", async () => {
|
|
1628
|
+
if (!(await confirmDialog("Every vibemate in it is closed and the room leaves the list. The history file stays on disk.", { title: `Close "${room.name}" for good?`, okLabel: "Close the room", danger: true }))) return;
|
|
1629
|
+
try {
|
|
1630
|
+
await post(roomApi("/delete"));
|
|
1631
|
+
} catch (e) {
|
|
1632
|
+
showError(e);
|
|
1633
|
+
}
|
|
1634
|
+
});
|
|
1635
|
+
}
|
|
1636
|
+
|
|
1637
|
+
|
|
1638
|
+
function renderSettingsPage() {
|
|
1639
|
+
const s = state.settings || { humanName: "", humanDescription: "", humanAvatar: "", roomDefaults: {}, vendorPresets: {} };
|
|
1640
|
+
const d = Object.assign({}, state.roomDefaults || {}, s.roomDefaults || {});
|
|
1641
|
+
const installed = state.recipes.filter((r) => !r.unavailableReason);
|
|
1642
|
+
const missing = state.recipes.filter((r) => r.unavailableReason);
|
|
1643
|
+
const dg = s.diagrams || { preset: "lavender", primary: null };
|
|
1644
|
+
const presets = installed
|
|
1645
|
+
.map((r) => {
|
|
1646
|
+
const v = (s.vendorPresets || {})[r.id] || {};
|
|
1647
|
+
return `<div class="vendor-card">
|
|
1648
|
+
<div class="vc-head"><span class="vc-logo">${r.icon ? `<img src="${esc(r.icon)}" alt="" onerror="this.replaceWith(document.createTextNode('${esc(r.vendor[0])}'))">` : esc(r.vendor[0])}</span><span class="vc-name">${esc(r.vendor)}</span><span class="badge status-idle"><span class="dot"></span>installed</span></div>
|
|
1649
|
+
<div class="vc-fields">
|
|
1650
|
+
${field("Model", `<input type="text" data-vendor="${r.id}" data-key="model" value="${esc(v.model || "")}" placeholder="${esc(r.defaultModel || "vibemate default")}">`)}
|
|
1651
|
+
${field("Effort", `<input type="text" data-vendor="${r.id}" data-key="effort" value="${esc(v.effort || "")}" placeholder="${esc(r.defaultEffort || "vibemate default")}">`)}
|
|
1652
|
+
${field("Mode", `<input type="text" data-vendor="${r.id}" data-key="mode" value="${esc(v.mode || "")}" placeholder="${esc(r.defaultMode || "vibemate default")}">`)}
|
|
1653
|
+
</div>
|
|
1654
|
+
</div>`;
|
|
1655
|
+
})
|
|
1656
|
+
.join("");
|
|
1657
|
+
const logo = (r) => `<span class="vc-logo">${r.icon ? `<img src="${esc(r.icon)}" alt="" onerror="this.replaceWith(document.createTextNode('${esc(r.vendor[0])}'))">` : esc(r.vendor[0])}</span>`;
|
|
1658
|
+
const machine =
|
|
1659
|
+
installed.map((r) => `<div class="vendor-row">${logo(r)}<span class="vc-name">${esc(r.vendor)}<span class="hint" title="${esc(r.installedAt || "")}">${esc(r.installedAt || "bundled")}</span></span><span class="badge status-idle"><span class="dot"></span>installed</span></div>`).join("") +
|
|
1660
|
+
missing.map((r) => `<div class="vendor-row" style="opacity:.75">${logo(r)}<span class="vc-name">${esc(r.vendor)}<span class="hint">${esc(r.installHint || r.unavailableReason || "")}</span></span><span class="badge status-offline">not installed</span></div>`).join("");
|
|
1661
|
+
els.pageInner.innerHTML = `
|
|
1662
|
+
<div class="page-head"><div><h1>Settings</h1><div class="hint">${state.version ? `${esc(state.version.name)} ${esc(state.version.version)} · hub built ${esc(new Date(state.version.build).toLocaleString())}` : "hub build unknown (older hub process; run viberoom again to replace it)"}</div></div><div class="row-btns"><button class="btn primary save" id="sp-save" disabled>Save</button></div></div>
|
|
1663
|
+
<div id="sp-form">
|
|
1664
|
+
<div class="page-cols">
|
|
1665
|
+
<div>
|
|
1666
|
+
<div class="section">
|
|
1667
|
+
${sectionTitle("lock", "Permissions")}
|
|
1668
|
+
<label class="switch"><span class="label">Vibemates act without asking<span class="hint">Off: they ask you before editing files or running commands.</span>${geekTip('New vibemates start in their vendor\'s "act without asking" mode (Claude bypassPermissions, Codex agent-full-access, Gemini yolo, Cursor agent, OpenCode build, Copilot agent + allow_all). Change it per vibemate when summoning one, or later in its panel.')}</span><input type="checkbox" id="sp-bypass" ${s.bypassPermissionsByDefault !== false ? "checked" : ""}></label>
|
|
1669
|
+
</div>
|
|
1670
|
+
<div class="section">
|
|
1671
|
+
${sectionTitle("bolt", "Pace")}
|
|
1672
|
+
${field("Turn taking in new rooms", `<select id="sp-turns"><option value="one-at-a-time"${d.turnTaking !== "parallel" ? " selected" : ""}>One vibemate at a time</option><option value="parallel"${d.turnTaking === "parallel" ? " selected" : ""}>All addressed vibemates at once</option></select>`)}
|
|
1673
|
+
${field("Reply delay in new rooms, seconds", `<input type="number" id="sp-delay" min="0" max="120" step="0.5" value="${d.replyDelay ?? 4}">`, "Used when two or more vibemates share a room; each room can change it; a vibemate can override it in its own panel.", "Before each turn a vibemate waits a random 0–N seconds, so replies cross less often. Messages that arrive meanwhile land in its backlog. A vibemate alone answers at once unless it has its own delay.")}
|
|
1674
|
+
</div>
|
|
1675
|
+
<div class="section" id="sp-editor">
|
|
1676
|
+
${sectionTitle("pencil", "Open files at a line")}
|
|
1677
|
+
<label class="field"><span class="label">A click on a path like main.ts:375 opens the file in${geekTip("Only an editor can jump to a line; the OS default app just opens the file. Auto looks for VS Code, Cursor, Windsurf, Zed, Sublime Text, Notepad++ and the JetBrains IDEs, in that order, on PATH and in their usual folders. Custom: a command with {file}, {line} and {column} placeholders, e.g. code --goto {file}:{line}.")}</span>
|
|
1678
|
+
<div class="chips editor-modes">
|
|
1679
|
+
<button type="button" class="chip-btn${(s.editor || {}).mode === "custom" ? "" : (s.editor || {}).mode === "default-app" ? "" : " on"}" data-mode="auto">Auto <span class="hint" id="sp-editor-auto">…</span></button>
|
|
1680
|
+
<button type="button" class="chip-btn${(s.editor || {}).mode === "default-app" ? " on" : ""}" data-mode="default-app">The default app</button>
|
|
1681
|
+
<button type="button" class="chip-btn${(s.editor || {}).mode === "custom" ? " on" : ""}" data-mode="custom">My own command</button>
|
|
1682
|
+
</div>
|
|
1683
|
+
<input type="hidden" id="sp-editor-mode" value="${esc((s.editor || {}).mode || "auto")}">
|
|
1684
|
+
</label>
|
|
1685
|
+
${field("Command", `<input type="text" id="sp-editor-cmd" maxlength="500" value="${esc((s.editor || {}).command || "")}" placeholder="code --goto {file}:{line}">`, "{file}, {line} and {column} are filled in; quotes group arguments.")}
|
|
1686
|
+
</div>
|
|
1687
|
+
<div class="section" id="sp-diagrams">
|
|
1688
|
+
${sectionTitle("wand", "Diagrams")}
|
|
1689
|
+
<div class="field"><span class="label">Colours of the boxes${geekTip("Vibemates draw diagrams as Mermaid (a ```mermaid block in a message); the room renders them here, with these colours. Mermaid derives the shades of borders and text from the box colour.")}</span>
|
|
1690
|
+
<div class="chips diagram-presets">${Object.entries(DIAGRAM_PRESETS).map(([id, p]) => `<button type="button" class="chip-btn${dg.preset === id ? " on" : ""}" data-preset="${id}"><span class="swatch" style="${p.palette ? `background:linear-gradient(90deg, ${p.palette.map((c) => c.fill).join(", ")});border-color:${p.palette[0].stroke}` : `background:${p.primaryColor};border-color:${p.primaryBorderColor}`}"></span>${p.label}</button>`).join("")}</div>
|
|
1691
|
+
<input type="hidden" id="sp-diagram-preset" value="${esc(dg.preset)}">
|
|
1692
|
+
</div>
|
|
1693
|
+
<label class="switch"><span class="label">My own colour for the boxes</span><input type="checkbox" id="sp-diagram-custom" ${dg.primary ? "checked" : ""}></label>
|
|
1694
|
+
<div class="field row" id="sp-diagram-color-row" ${dg.primary ? "" : "hidden"}><span class="label">Box colour</span><input type="color" id="sp-diagram-color" value="${esc(dg.primary || "#ece9ff")}" style="width:46px;height:30px;padding:2px"></div>
|
|
1695
|
+
${mermaidBlock("graph LR\n A[You] --> B(Vibemate)\n B --> C{Agreed?}\n C -->|yes| D[Done]\n C -->|no| B").replace('class="mermaid-block"', 'class="mermaid-block preview"')}
|
|
1696
|
+
</div>
|
|
1697
|
+
</div>
|
|
1698
|
+
<div>
|
|
1699
|
+
<div class="section">
|
|
1700
|
+
${sectionTitle("spark", "Vibemates on this machine")}
|
|
1701
|
+
${machine || '<p class="hint">No supported vibemate is installed yet.</p>'}
|
|
1702
|
+
</div>
|
|
1703
|
+
</div>
|
|
1704
|
+
</div>
|
|
1705
|
+
${geek(
|
|
1706
|
+
"sp-geek",
|
|
1707
|
+
`<div class="page-cols">
|
|
1708
|
+
<div>
|
|
1709
|
+
<div class="section">
|
|
1710
|
+
${sectionTitle("rooms", "Defaults for new rooms")}
|
|
1711
|
+
<p class="field-note">Every new room starts with these; each room can change them in its own settings.</p>
|
|
1712
|
+
${field("Hop limit", `<input type="number" id="sp-hops" min="0" max="10000" value="${d.hopLimit}">`, "How many vibemate-to-vibemate replies may follow one message of yours before the room waits for you again.")}
|
|
1713
|
+
${field("Full brief every N turns", `<input type="number" id="sp-brief-turns" min="1" max="10000" value="${d.fullBriefEveryTurns}">`, "How often a vibemate gets the whole room brief again instead of the short header.")}
|
|
1714
|
+
${field("Full brief every N tokens", `<input type="number" id="sp-brief-tokens" min="1000" max="10000000" step="1000" value="${d.fullBriefEveryTokens}">`, "…or after this much new context since its last full brief, whichever comes first.")}
|
|
1715
|
+
<label class="switch"><span class="label">Repeat core rules in every header<span class="hint">The short header before each turn repeats the room's core rules (who is here, how to address, how long to write).</span></span><input type="checkbox" id="sp-header-rules" ${d.headerRules ? "checked" : ""}></label>
|
|
1716
|
+
${field("Tools", `<select id="sp-tools"><option value="on-request"${d.tools === "on-request" ? " selected" : ""}>Only when asked</option><option value="never"${d.tools === "never" ? " selected" : ""}>Never</option></select>`, "Whether vibemates may use their own tools (files, shell, web) without being asked to.")}
|
|
1717
|
+
</div>
|
|
1718
|
+
<div class="section">
|
|
1719
|
+
${sectionTitle("skills", "Skills from vibemates")}
|
|
1720
|
+
<label class="switch"><span class="label">Vibemate-created skills need my approval<span class="hint">Off: a skill a vibemate creates is usable at once and shows as "unreviewed" until you open it. On: it stays a draft (not delivered, not attachable) until you approve it under Skills.</span></span><input type="checkbox" id="sp-skill-approval" ${s.agentSkillsNeedApproval ? "checked" : ""}></label>
|
|
1721
|
+
</div>
|
|
1722
|
+
</div>
|
|
1723
|
+
<div>
|
|
1724
|
+
<div class="section">
|
|
1725
|
+
${sectionTitle("settings", "Presets per vibemate")}
|
|
1726
|
+
<p class="hint" style="margin-bottom:10px">Used when you summon one; leave a field empty for the built-in suggestion. The summon dialog always shows what the vibemate really offers. Bypass modes: Claude bypassPermissions, Codex agent-full-access, Gemini yolo, Cursor agent, OpenCode build, Copilot agent + allow_all.</p>
|
|
1727
|
+
${presets || '<p class="hint">No supported vibemate is installed yet.</p>'}
|
|
1728
|
+
</div>
|
|
1729
|
+
</div>
|
|
1730
|
+
</div>`,
|
|
1731
|
+
"room defaults, presets per vibemate, vibemate skills",
|
|
1732
|
+
)}
|
|
1733
|
+
</div>`;
|
|
1734
|
+
const editorSection = $("#sp-editor");
|
|
1735
|
+
const editorCmdRow = $("#sp-editor-cmd").closest(".field");
|
|
1736
|
+
const showEditorCmd = () => (editorCmdRow.hidden = $("#sp-editor-mode").value !== "custom");
|
|
1737
|
+
showEditorCmd();
|
|
1738
|
+
editorSection.querySelectorAll(".editor-modes .chip-btn").forEach((b) =>
|
|
1739
|
+
b.addEventListener("click", () => {
|
|
1740
|
+
editorSection.querySelectorAll(".editor-modes .chip-btn").forEach((x) => x.classList.toggle("on", x === b));
|
|
1741
|
+
const input = $("#sp-editor-mode");
|
|
1742
|
+
input.value = b.dataset.mode;
|
|
1743
|
+
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
1744
|
+
showEditorCmd();
|
|
1745
|
+
}),
|
|
1746
|
+
);
|
|
1747
|
+
get("/api/editor")
|
|
1748
|
+
.then((r) => {
|
|
1749
|
+
const auto = $("#sp-editor-auto");
|
|
1750
|
+
if (auto) auto.textContent = r.editor ? `(${r.editor.label})` : "(none found)";
|
|
1751
|
+
})
|
|
1752
|
+
.catch(() => undefined);
|
|
1753
|
+
const diagramSection = $("#sp-diagrams");
|
|
1754
|
+
const previewTheme = () => ({ preset: $("#sp-diagram-preset").value, primary: $("#sp-diagram-custom").checked ? $("#sp-diagram-color").value : null });
|
|
1755
|
+
const redrawPreview = () => {
|
|
1756
|
+
const block = diagramSection.querySelector(".mermaid-block.preview");
|
|
1757
|
+
delete block.dataset.rendered;
|
|
1758
|
+
block.querySelector(".mm-out").innerHTML = `<pre>${esc(block.dataset.src)}</pre>`;
|
|
1759
|
+
renderDiagrams(diagramSection, previewTheme());
|
|
1760
|
+
};
|
|
1761
|
+
diagramSection.querySelectorAll(".diagram-presets .chip-btn").forEach((b) =>
|
|
1762
|
+
b.addEventListener("click", () => {
|
|
1763
|
+
diagramSection.querySelectorAll(".diagram-presets .chip-btn").forEach((x) => x.classList.toggle("on", x === b));
|
|
1764
|
+
const input = $("#sp-diagram-preset");
|
|
1765
|
+
input.value = b.dataset.preset;
|
|
1766
|
+
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
1767
|
+
redrawPreview();
|
|
1768
|
+
}),
|
|
1769
|
+
);
|
|
1770
|
+
$("#sp-diagram-custom").addEventListener("change", () => {
|
|
1771
|
+
$("#sp-diagram-color-row").hidden = !$("#sp-diagram-custom").checked;
|
|
1772
|
+
redrawPreview();
|
|
1773
|
+
});
|
|
1774
|
+
$("#sp-diagram-color").addEventListener("input", redrawPreview);
|
|
1775
|
+
renderDiagrams(diagramSection, previewTheme());
|
|
1776
|
+
bindSave($("#sp-form"), $("#sp-save"), async () => {
|
|
1777
|
+
const vendorPresets = {};
|
|
1778
|
+
els.pageInner.querySelectorAll("input[data-vendor]").forEach((inp) => {
|
|
1779
|
+
vendorPresets[inp.dataset.vendor] = vendorPresets[inp.dataset.vendor] || { model: null, effort: null, mode: null };
|
|
1780
|
+
vendorPresets[inp.dataset.vendor][inp.dataset.key] = inp.value.trim() || null;
|
|
1781
|
+
});
|
|
1782
|
+
await post("/api/settings", {
|
|
1783
|
+
bypassPermissionsByDefault: $("#sp-bypass").checked,
|
|
1784
|
+
agentSkillsNeedApproval: $("#sp-skill-approval").checked,
|
|
1785
|
+
diagrams: { preset: $("#sp-diagram-preset").value, primary: $("#sp-diagram-custom").checked ? $("#sp-diagram-color").value : null },
|
|
1786
|
+
editor: { mode: $("#sp-editor-mode").value, command: $("#sp-editor-cmd").value },
|
|
1787
|
+
roomDefaults: {
|
|
1788
|
+
turnTaking: $("#sp-turns").value,
|
|
1789
|
+
replyDelay: Number($("#sp-delay").value),
|
|
1790
|
+
hopLimit: Number($("#sp-hops").value),
|
|
1791
|
+
fullBriefEveryTurns: Number($("#sp-brief-turns").value),
|
|
1792
|
+
fullBriefEveryTokens: Number($("#sp-brief-tokens").value),
|
|
1793
|
+
headerRules: $("#sp-header-rules").checked,
|
|
1794
|
+
tools: $("#sp-tools").value,
|
|
1795
|
+
},
|
|
1796
|
+
vendorPresets,
|
|
1797
|
+
});
|
|
1798
|
+
});
|
|
1799
|
+
}
|
|
1800
|
+
|
|
1801
|
+
function skillBadges(sk) {
|
|
1802
|
+
const out = [];
|
|
1803
|
+
if (sk.userInvocable === false) out.push('<span class="badge">vibemate only</span>');
|
|
1804
|
+
if (sk.agentInvocable === false) out.push('<span class="badge">human only</span>');
|
|
1805
|
+
if (sk.author && sk.author !== "human") out.push(`<span class="badge outline">${esc(sk.author === "viberoom" ? "built-in" : `by ${sk.author.replace(/^agent:/, "").replace(/@.*$/, "")} (agent)`)}</span>`);
|
|
1806
|
+
if (sk.draft) out.push('<span class="badge status-queued">draft: awaiting your approval</span>');
|
|
1807
|
+
else if (sk.reviewed === false) out.push('<span class="badge status-thinking">unreviewed</span>');
|
|
1808
|
+
if ((sk.problems || []).length) out.push(`<span class="badge status-error">${esc(sk.problems.join("; "))}</span>`);
|
|
1809
|
+
if ((sk.warnings || []).length) out.push(`<span class="badge status-thinking" title="${esc(sk.warnings.join("; "))}">${sk.warnings.length} warning${sk.warnings.length > 1 ? "s" : ""}</span>`);
|
|
1810
|
+
return out.join(" ");
|
|
1811
|
+
}
|
|
1812
|
+
|
|
1813
|
+
function renderSkillsPage() {
|
|
1814
|
+
const skills = state.skills || [];
|
|
1815
|
+
const ed = state.skillEditor;
|
|
1816
|
+
const editing = ed ? skills.find((sk) => sk.name === ed.name) : null;
|
|
1817
|
+
const room = state.skillsRoom ? state.rooms.get(state.skillsRoom) : null;
|
|
1818
|
+
if (state.skillsRoom && !room) state.skillsRoom = null;
|
|
1819
|
+
const holdersOf = (name) => (room ? skillHolders(room, name) : []);
|
|
1820
|
+
const shown = room ? skills.filter((sk) => holdersOf(sk.name).length) : skills;
|
|
1821
|
+
const item = (sk) => `<li>
|
|
1822
|
+
<div class="sk-main">
|
|
1823
|
+
<div class="sk-head"><b>/${esc(sk.name)}</b>${sk.argumentHint ? ` <span class="hint">${esc(sk.argumentHint)}</span>` : ""}${skillBadges(sk)}</div>
|
|
1824
|
+
<div class="hint">${esc(sk.description)}</div>
|
|
1825
|
+
${room ? `<div class="hint sk-holders">${ic("user")}${esc(holdersOf(sk.name).map((p) => p.name).join(", "))}</div>` : ""}
|
|
1826
|
+
</div>
|
|
1827
|
+
<span class="skill-actions">${sk.draft ? `<button class="btn sm primary" data-approve-skill="${esc(sk.name)}">Approve</button>` : ""}<button class="icon-btn sm ghost" data-edit-skill="${esc(sk.name)}" title="Edit this skill">${ic("pencil")}</button></span>
|
|
1828
|
+
</li>`;
|
|
1829
|
+
const list = shown.length
|
|
1830
|
+
? `<ul class="skill-list">${shown.map(item).join("")}</ul>`
|
|
1831
|
+
: `<p class="hint">${room ? "No vibemate in this room has a skill attached yet. Attach one in a vibemate's panel (for geeks)." : "No skills yet. Create one, or let a vibemate write one."}</p>`;
|
|
1832
|
+
const editor = ed
|
|
1833
|
+
? `<div class="section skill-editor">
|
|
1834
|
+
${sectionTitle("pencil", editing ? `Edit /${esc(ed.name)}` : "New skill")}
|
|
1835
|
+
${field("Name (also the /command)", `<input type="text" id="sk-name" maxlength="32" value="${esc(ed.name || "")}" ${ed.name ? "disabled" : ""} placeholder="letters, digits, _ or -">`)}
|
|
1836
|
+
${field("Description", `<textarea id="sk-desc" rows="2" maxlength="300">${esc(ed.description || "")}</textarea>`, "What it does and when to use it; this is what triggers it.")}
|
|
1837
|
+
${field("Argument hint (optional, shown in the / menu)", `<input type="text" id="sk-hint" maxlength="80" value="${esc(ed.argumentHint || "")}" placeholder="e.g. [PR number]">`)}
|
|
1838
|
+
${field("Instructions", `<textarea id="sk-body" rows="12" maxlength="20000">${esc(ed.body || "")}</textarea>`, "$ARGUMENTS = what follows /name.")}
|
|
1839
|
+
<label class="switch"><span class="label">Human can invoke it with /name</span><input type="checkbox" id="sk-user" ${ed.userInvocable === false ? "" : "checked"}></label>
|
|
1840
|
+
<label class="switch"><span class="label">Vibemates may load it themselves</span><input type="checkbox" id="sk-agent" ${ed.agentInvocable === false ? "" : "checked"}></label>
|
|
1841
|
+
<p class="error" id="sk-error" hidden></p>
|
|
1842
|
+
${editing && editing.author === "viberoom" ? `<p class="field-note">${ic("lock")} Built-in skill: it comes with viberoom, the hub keeps it up to date, and it is read-only. Copy the text into a new skill to make your own version.</p>` : ""}
|
|
1843
|
+
<div class="row-btns">${editing && editing.author !== "viberoom" ? '<button class="btn sm danger" id="sk-delete">Delete</button>' : ""}<span class="saved" id="sk-saved"></span><button class="btn sm ghost" id="sk-cancel">${editing && editing.author === "viberoom" ? "Close" : "Cancel"}</button>${editing && editing.author === "viberoom" ? "" : '<button class="btn sm primary" id="sk-save">Save skill</button>'}</div>
|
|
1844
|
+
</div>`
|
|
1845
|
+
: "";
|
|
1846
|
+
const about = ed
|
|
1847
|
+
? ""
|
|
1848
|
+
: `<p class="hint sk-about">A skill is a folder <code>skills/<name>/SKILL.md</code> in the hub's data folder: a description (what triggers it) and the instructions. Attach skills to vibemates in their panels; invoke one with <code>/name</code> in the composer. Vibemates with the hub's tools can create skills too (they load <code>skill-writer</code> first).</p>`;
|
|
1849
|
+
els.pageInner.innerHTML = `
|
|
1850
|
+
<div class="page-head"><div><h1>${room ? `Skills in ${esc(room.name)}` : "Skills"}</h1><div class="hint">${room ? `${shown.length} of ${skills.length} in the library are attached to a vibemate here` : `${skills.length} skill${skills.length === 1 ? "" : "s"} in the library`}</div></div><div class="row-btns">${room ? `<button class="btn ghost" id="sk-all">${ic("skills")}All skills</button>` : ""}<button class="btn ghost" id="sk-reload" title="Re-read the skills folder">${ic("refresh")}Reload</button><button class="btn primary cta" id="sk-new"><span class="cta-ico">${ic("plus")}</span><span class="label">New skill</span></button></div></div>
|
|
1851
|
+
<div class="${ed ? "page-cols" : ""}"><div>${list}${about}</div>${editor}</div>`;
|
|
1852
|
+
const all = $("#sk-all");
|
|
1853
|
+
if (all)
|
|
1854
|
+
all.addEventListener("click", () => {
|
|
1855
|
+
state.skillsRoom = null;
|
|
1856
|
+
renderSideRooms();
|
|
1857
|
+
renderSkillsPage();
|
|
1858
|
+
});
|
|
1859
|
+
$("#sk-new").addEventListener("click", () => {
|
|
1860
|
+
state.skillEditor = { name: "", description: "", argumentHint: "", body: "", userInvocable: true, agentInvocable: true };
|
|
1861
|
+
renderSkillsPage();
|
|
1862
|
+
const n = $("#sk-name");
|
|
1863
|
+
if (n) n.focus();
|
|
1864
|
+
});
|
|
1865
|
+
$("#sk-reload").addEventListener("click", async () => {
|
|
1866
|
+
try {
|
|
1867
|
+
state.skills = (await get("/api/skills")).skills || [];
|
|
1868
|
+
renderSkillsPage();
|
|
1869
|
+
} catch (e) {
|
|
1870
|
+
showError(e);
|
|
1871
|
+
}
|
|
1872
|
+
});
|
|
1873
|
+
els.pageInner.querySelectorAll("[data-approve-skill]").forEach((btn) => btn.addEventListener("click", () => post(`/api/skills/${encodeURIComponent(btn.dataset.approveSkill)}/approve`).catch(showError)));
|
|
1874
|
+
els.pageInner.querySelectorAll("[data-edit-skill]").forEach((btn) => {
|
|
1875
|
+
btn.addEventListener("click", async () => {
|
|
1876
|
+
try {
|
|
1877
|
+
state.skillEditor = (await get(`/api/skills/${encodeURIComponent(btn.dataset.editSkill)}`)).skill;
|
|
1878
|
+
renderSkillsPage();
|
|
1879
|
+
} catch (e) {
|
|
1880
|
+
showError(e);
|
|
1881
|
+
}
|
|
1882
|
+
});
|
|
1883
|
+
});
|
|
1884
|
+
if (ed) {
|
|
1885
|
+
const skError = (text) => {
|
|
1886
|
+
const e = $("#sk-error");
|
|
1887
|
+
e.textContent = text;
|
|
1888
|
+
e.hidden = !text;
|
|
1889
|
+
};
|
|
1890
|
+
$("#sk-cancel").addEventListener("click", () => {
|
|
1891
|
+
state.skillEditor = null;
|
|
1892
|
+
renderSkillsPage();
|
|
1893
|
+
});
|
|
1894
|
+
const saveBtn = $("#sk-save");
|
|
1895
|
+
if (saveBtn) saveBtn.addEventListener("click", async () => {
|
|
1896
|
+
try {
|
|
1897
|
+
await post("/api/skills", { name: $("#sk-name").value.trim(), description: $("#sk-desc").value, argumentHint: $("#sk-hint").value, body: $("#sk-body").value, userInvocable: $("#sk-user").checked, agentInvocable: $("#sk-agent").checked });
|
|
1898
|
+
state.skillEditor = null;
|
|
1899
|
+
renderSkillsPage();
|
|
1900
|
+
toast("Skill saved.", "info");
|
|
1901
|
+
} catch (e) {
|
|
1902
|
+
skError(e.message);
|
|
1903
|
+
}
|
|
1904
|
+
});
|
|
1905
|
+
if (editing && editing.author === "viberoom") $(".skill-editor").querySelectorAll("input, textarea").forEach((el) => (el.disabled = true));
|
|
1906
|
+
const del = $("#sk-delete");
|
|
1907
|
+
if (del) {
|
|
1908
|
+
del.addEventListener("click", async () => {
|
|
1909
|
+
if (!(await confirmDialog("Its folder is removed; vibemates that had it lose it.", { title: `Delete /${ed.name}?`, okLabel: "Delete", danger: true }))) return;
|
|
1910
|
+
try {
|
|
1911
|
+
await post(`/api/skills/${encodeURIComponent(ed.name)}/delete`);
|
|
1912
|
+
state.skillEditor = null;
|
|
1913
|
+
renderSkillsPage();
|
|
1914
|
+
} catch (e) {
|
|
1915
|
+
skError(e.message);
|
|
1916
|
+
}
|
|
1917
|
+
});
|
|
1918
|
+
}
|
|
1919
|
+
}
|
|
1920
|
+
}
|
|
1921
|
+
|
|
1922
|
+
|
|
1923
|
+
function renderSkillChecks(container, selected) {
|
|
1924
|
+
container.innerHTML = "";
|
|
1925
|
+
const chosen = new Set((selected || []).map((s) => s.toLowerCase()));
|
|
1926
|
+
const known = new Set();
|
|
1927
|
+
for (const s of state.skills || []) {
|
|
1928
|
+
if (s.author === "viberoom") continue;
|
|
1929
|
+
known.add(s.name.toLowerCase());
|
|
1930
|
+
const row = document.createElement("label");
|
|
1931
|
+
row.className = "check-row";
|
|
1932
|
+
const broken = (s.problems && s.problems.length) || s.draft;
|
|
1933
|
+
row.innerHTML = `<input type="checkbox" value="${esc(s.name)}" ${chosen.has(s.name.toLowerCase()) ? "checked" : ""} ${broken ? "disabled" : ""}><span><b>/${esc(s.name)}</b> <span class="hint">${esc(s.description)}</span>${s.draft ? '<span class="badge status-queued">draft</span>' : ""}${s.problems && s.problems.length ? `<span class="badge status-error">${esc(s.problems.join("; "))}</span>` : ""}</span>`;
|
|
1934
|
+
container.appendChild(row);
|
|
1935
|
+
}
|
|
1936
|
+
for (const name of selected || []) {
|
|
1937
|
+
if (known.has(name.toLowerCase())) continue;
|
|
1938
|
+
const row = document.createElement("label");
|
|
1939
|
+
row.className = "check-row";
|
|
1940
|
+
row.innerHTML = `<input type="checkbox" value="${esc(name)}" checked><span><b>/${esc(name)}</b> <span class="badge status-error">missing from the library</span></span>`;
|
|
1941
|
+
container.appendChild(row);
|
|
1942
|
+
}
|
|
1943
|
+
if (!container.children.length) container.innerHTML = '<span class="hint">No skills in the library yet (Skills in the menu).</span>';
|
|
1944
|
+
}
|
|
1945
|
+
function checkedSkills(container) {
|
|
1946
|
+
return [...container.querySelectorAll('input[type="checkbox"]:checked')].map((i) => i.value);
|
|
1947
|
+
}
|
|
1948
|
+
function skillHolders(room, name) {
|
|
1949
|
+
const lower = name.toLowerCase();
|
|
1950
|
+
return room.participants.filter((p) => p.kind === "agent" && (p.skills || []).some((s) => s.toLowerCase() === lower));
|
|
1951
|
+
}
|
|
1952
|
+
function skillChannelText(p) {
|
|
1953
|
+
if (p.status === "offline") return "";
|
|
1954
|
+
if (p.skillChannel === "tool") return "Loads skills through the load_skill tool (the hub's MCP server; no permission prompts).";
|
|
1955
|
+
if (p.skillChannel === "marker") return "Loads skills with the [skill:name] marker in a hidden turn (this vibemate did not take the hub's MCP server).";
|
|
1956
|
+
if (p.skillChannel === "pending") return "Deciding how this session loads skills…";
|
|
1957
|
+
return "";
|
|
1958
|
+
}
|
|
1959
|
+
|
|
1960
|
+
function attachSlashMenu(textarea, menuEl) {
|
|
1961
|
+
const m = { open: false, items: [], index: 0, start: -1 };
|
|
1962
|
+
function context() {
|
|
1963
|
+
const value = textarea.value;
|
|
1964
|
+
const caret = textarea.selectionStart ?? value.length;
|
|
1965
|
+
const before = value.slice(0, caret);
|
|
1966
|
+
const match = before.match(/^((?:@[\p{L}\p{N}_-]+\s+)*)\/([A-Za-z0-9_-]*)$/u);
|
|
1967
|
+
if (!match) return null;
|
|
1968
|
+
return { start: caret - match[2].length - 1, prefix: match[2] };
|
|
1969
|
+
}
|
|
1970
|
+
function close() {
|
|
1971
|
+
if (!m.open) return;
|
|
1972
|
+
m.open = false;
|
|
1973
|
+
menuEl.hidden = true;
|
|
1974
|
+
}
|
|
1975
|
+
function render() {
|
|
1976
|
+
const ctx = context();
|
|
1977
|
+
const room = currentRoom();
|
|
1978
|
+
if (!ctx || !room) return close();
|
|
1979
|
+
const q = ctx.prefix.toLowerCase();
|
|
1980
|
+
const items = (state.skills || []).filter((s) => s.userInvocable !== false && !(s.problems || []).length && !s.draft && s.name.toLowerCase().startsWith(q));
|
|
1981
|
+
if (!items.length) return close();
|
|
1982
|
+
m.open = true;
|
|
1983
|
+
m.items = items;
|
|
1984
|
+
m.start = ctx.start;
|
|
1985
|
+
if (m.index >= items.length) m.index = 0;
|
|
1986
|
+
menuEl.innerHTML = "";
|
|
1987
|
+
items.forEach((s, i) => {
|
|
1988
|
+
const holders = skillHolders(room, s.name);
|
|
1989
|
+
const b = document.createElement("button");
|
|
1990
|
+
b.type = "button";
|
|
1991
|
+
b.className = i === m.index ? "active" : "";
|
|
1992
|
+
b.innerHTML = `<span class="mm-skill">/${esc(s.name)}</span><span class="mm-sub">${esc(s.argumentHint || s.description)}<br>${holders.length ? `${holders.map((p) => esc(p.name)).join(", ")} ${holders.length === 1 ? "has" : "have"} it` : "nobody in this room has it"}</span>`;
|
|
1993
|
+
b.addEventListener("mousedown", (e) => {
|
|
1994
|
+
e.preventDefault();
|
|
1995
|
+
pick(i);
|
|
1996
|
+
});
|
|
1997
|
+
menuEl.appendChild(b);
|
|
1998
|
+
});
|
|
1999
|
+
menuEl.hidden = false;
|
|
2000
|
+
}
|
|
2001
|
+
function pick(i) {
|
|
2002
|
+
const s = m.items[i];
|
|
2003
|
+
if (!s) return close();
|
|
2004
|
+
const value = textarea.value;
|
|
2005
|
+
const caret = textarea.selectionStart ?? value.length;
|
|
2006
|
+
textarea.value = `${value.slice(0, m.start)}/${s.name} ${value.slice(caret)}`;
|
|
2007
|
+
const pos = m.start + s.name.length + 2;
|
|
2008
|
+
textarea.setSelectionRange(pos, pos);
|
|
2009
|
+
close();
|
|
2010
|
+
textarea.focus();
|
|
2011
|
+
autosize();
|
|
2012
|
+
}
|
|
2013
|
+
textarea.addEventListener("input", () => {
|
|
2014
|
+
m.index = 0;
|
|
2015
|
+
render();
|
|
2016
|
+
});
|
|
2017
|
+
textarea.addEventListener("blur", () => setTimeout(close, 150));
|
|
2018
|
+
textarea.addEventListener("keydown", (event) => {
|
|
2019
|
+
if (!m.open) return;
|
|
2020
|
+
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
|
2021
|
+
event.preventDefault();
|
|
2022
|
+
m.index = (m.index + (event.key === "ArrowDown" ? 1 : m.items.length - 1)) % m.items.length;
|
|
2023
|
+
render();
|
|
2024
|
+
} else if (event.key === "Enter" || event.key === "Tab") {
|
|
2025
|
+
event.preventDefault();
|
|
2026
|
+
pick(m.index);
|
|
2027
|
+
} else if (event.key === "Escape") {
|
|
2028
|
+
event.preventDefault();
|
|
2029
|
+
close();
|
|
2030
|
+
}
|
|
2031
|
+
});
|
|
2032
|
+
}
|
|
2033
|
+
|
|
2034
|
+
|
|
2035
|
+
function fillSelect(select, values, defaultValue, emptyLabel) {
|
|
2036
|
+
select.innerHTML = "";
|
|
2037
|
+
const opt = document.createElement("option");
|
|
2038
|
+
opt.value = "";
|
|
2039
|
+
opt.textContent = emptyLabel;
|
|
2040
|
+
select.appendChild(opt);
|
|
2041
|
+
for (const v of values) {
|
|
2042
|
+
const entry = typeof v === "string" ? { value: v, name: v } : v;
|
|
2043
|
+
const o = document.createElement("option");
|
|
2044
|
+
o.value = entry.value;
|
|
2045
|
+
o.textContent = entry.name || entry.value;
|
|
2046
|
+
o.title = [entry.name && entry.name !== entry.value ? entry.value : "", entry.description || ""].filter(Boolean).join(" · ");
|
|
2047
|
+
if (entry.value === defaultValue) o.selected = true;
|
|
2048
|
+
select.appendChild(o);
|
|
2049
|
+
}
|
|
2050
|
+
select.disabled = values.length === 0;
|
|
2051
|
+
renderChips(select);
|
|
2052
|
+
}
|
|
2053
|
+
function renderChips(select) {
|
|
2054
|
+
const box = document.querySelector(`.chips[data-for="${select.id}"]`);
|
|
2055
|
+
if (!box) return;
|
|
2056
|
+
const options = [...select.options];
|
|
2057
|
+
const useSelect = options.length > 9;
|
|
2058
|
+
box.hidden = useSelect;
|
|
2059
|
+
select.hidden = !useSelect;
|
|
2060
|
+
if (useSelect) return;
|
|
2061
|
+
box.innerHTML = "";
|
|
2062
|
+
if (select.disabled) {
|
|
2063
|
+
box.innerHTML = '<span class="hint">nothing to choose here</span>';
|
|
2064
|
+
return;
|
|
2065
|
+
}
|
|
2066
|
+
for (const o of options) {
|
|
2067
|
+
const b = document.createElement("button");
|
|
2068
|
+
b.type = "button";
|
|
2069
|
+
b.className = `chip-btn${o.selected ? " on" : ""}${o.value === "" ? " none" : ""}`;
|
|
2070
|
+
b.textContent = o.textContent;
|
|
2071
|
+
if (o.title) b.title = o.title;
|
|
2072
|
+
b.addEventListener("click", () => {
|
|
2073
|
+
select.value = o.value;
|
|
2074
|
+
select.dispatchEvent(new Event("change", { bubbles: true }));
|
|
2075
|
+
renderChips(select);
|
|
2076
|
+
});
|
|
2077
|
+
box.appendChild(b);
|
|
2078
|
+
}
|
|
2079
|
+
}
|
|
2080
|
+
function presetFor(recipe) {
|
|
2081
|
+
const s = state.settings || {};
|
|
2082
|
+
const v = (s.vendorPresets || {})[recipe.id] || {};
|
|
2083
|
+
const bypass = s.bypassPermissionsByDefault !== false;
|
|
2084
|
+
const defaultMode = bypass ? recipe.bypassMode || recipe.defaultMode : recipe.defaultMode;
|
|
2085
|
+
return { model: v.model || recipe.defaultModel, effort: v.effort || recipe.defaultEffort, mode: v.mode || defaultMode };
|
|
2086
|
+
}
|
|
2087
|
+
let optionsRequest = 0;
|
|
2088
|
+
async function loadAgentOptions(recipe, refresh) {
|
|
2089
|
+
const requestId = ++optionsRequest;
|
|
2090
|
+
els.invStatus.textContent = "Asking the vibemate what it offers…";
|
|
2091
|
+
els.invStatus.className = "hint accent";
|
|
2092
|
+
const preset = presetFor(recipe);
|
|
2093
|
+
try {
|
|
2094
|
+
const info = await get(`/api/recipes/${encodeURIComponent(recipe.id)}/options${refresh ? "?refresh=1" : ""}`);
|
|
2095
|
+
if (requestId !== optionsRequest) return;
|
|
2096
|
+
const byCategory = (category) => info.configOptions.find((o) => o.category === category && o.type === "select");
|
|
2097
|
+
const model = byCategory("model");
|
|
2098
|
+
const effort = byCategory("thought_level");
|
|
2099
|
+
const mode = byCategory("mode");
|
|
2100
|
+
const parts = [];
|
|
2101
|
+
const pick = (values, wanted, current) => (values.some((v) => v.value === wanted) ? wanted : current);
|
|
2102
|
+
if (model) {
|
|
2103
|
+
const values = flattenOptions(model.options);
|
|
2104
|
+
fillSelect(els.invModel, values, pick(values, preset.model, model.currentValue), "vibemate default");
|
|
2105
|
+
parts.push(`${values.length} models`);
|
|
2106
|
+
}
|
|
2107
|
+
if (effort) {
|
|
2108
|
+
const values = flattenOptions(effort.options);
|
|
2109
|
+
fillSelect(els.invEffort, values, pick(values, preset.effort, effort.currentValue), "vibemate default");
|
|
2110
|
+
parts.push(`effort: ${values.length}`);
|
|
2111
|
+
}
|
|
2112
|
+
if (mode) {
|
|
2113
|
+
const values = flattenOptions(mode.options);
|
|
2114
|
+
fillSelect(els.invMode, values, pick(values, preset.mode, mode.currentValue), "vibemate default");
|
|
2115
|
+
parts.push(`modes: ${values.length}`);
|
|
2116
|
+
} else if (info.modes && info.modes.availableModes && info.modes.availableModes.length) {
|
|
2117
|
+
const values = info.modes.availableModes.map((m) => ({ value: m.id, name: m.name, description: m.description }));
|
|
2118
|
+
fillSelect(els.invMode, values, pick(values, preset.mode, info.modes.currentModeId), "vibemate default");
|
|
2119
|
+
parts.push(`modes: ${values.length}`);
|
|
2120
|
+
}
|
|
2121
|
+
els.invModelCustom.hidden = !info.modelAtLaunch;
|
|
2122
|
+
const who = info.agentInfo && info.agentInfo.name ? `${info.agentInfo.name} ${info.agentInfo.version || ""}`.trim() : recipe.vendor;
|
|
2123
|
+
els.invStatus.textContent = parts.length ? `Options from ${who} (${parts.join(", ")}; ${(info.durationMs / 1000).toFixed(1)} s)` : `${who} exposes no config options over ACP${info.modelAtLaunch ? "; the model is a launch flag (built-in list, or type one)" : ""}.`;
|
|
2124
|
+
} catch (error) {
|
|
2125
|
+
if (requestId !== optionsRequest) return;
|
|
2126
|
+
els.invStatus.textContent = `Could not read the vibemate's options (${error.message}); showing the built-in list.`;
|
|
2127
|
+
els.invStatus.className = "hint error";
|
|
2128
|
+
}
|
|
2129
|
+
}
|
|
2130
|
+
function applyRecipe(refresh) {
|
|
2131
|
+
const recipe = state.recipes.find((r) => r.id === els.invType.value);
|
|
2132
|
+
if (!recipe) return;
|
|
2133
|
+
els.invAgents.querySelectorAll(".agent-tile").forEach((b) => b.classList.toggle("selected", b.dataset.agent === recipe.id));
|
|
2134
|
+
const preset = presetFor(recipe);
|
|
2135
|
+
const bypassOn = (state.settings || {}).bypassPermissionsByDefault !== false;
|
|
2136
|
+
els.invNote.textContent = (recipe.unavailableReason ? `${recipe.note} — ${recipe.unavailableReason}` : recipe.note) + (bypassOn && recipe.bypassMode ? ` Mode defaults to "${recipe.bypassMode}" (acts without asking; change it here or in Settings).` : "");
|
|
2137
|
+
els.invSubmit.disabled = !!recipe.unavailableReason;
|
|
2138
|
+
els.invWhere.textContent = recipe.unavailableReason ? `Not installed on this machine. To install: ${recipe.installHint || ""}` : `Found on this machine: ${recipe.installedAt || "bundled"}`;
|
|
2139
|
+
els.invWhere.className = recipe.unavailableReason ? "hint error" : "hint";
|
|
2140
|
+
fillSelect(els.invModel, recipe.modelPresets, preset.model, "vibemate default");
|
|
2141
|
+
fillSelect(els.invEffort, recipe.effortPresets, preset.effort, "vibemate default");
|
|
2142
|
+
fillSelect(els.invMode, recipe.modePresets, preset.mode, "vibemate default");
|
|
2143
|
+
els.invModelCustom.hidden = true;
|
|
2144
|
+
els.invModelCustom.value = "";
|
|
2145
|
+
els.invStatus.textContent = "";
|
|
2146
|
+
if (!recipe.unavailableReason) loadAgentOptions(recipe, refresh);
|
|
2147
|
+
}
|
|
2148
|
+
function openInvite() {
|
|
2149
|
+
if (!currentRoom()) return toast("Open a room first.", "warn");
|
|
2150
|
+
els.invError.hidden = true;
|
|
2151
|
+
els.invType.innerHTML = "";
|
|
2152
|
+
const installed = state.recipes.filter((r) => !r.unavailableReason);
|
|
2153
|
+
for (const r of installed) {
|
|
2154
|
+
const o = document.createElement("option");
|
|
2155
|
+
o.value = r.id;
|
|
2156
|
+
o.textContent = r.label;
|
|
2157
|
+
els.invType.appendChild(o);
|
|
2158
|
+
}
|
|
2159
|
+
els.invAgents.innerHTML = state.recipes
|
|
2160
|
+
.map(
|
|
2161
|
+
(r) =>
|
|
2162
|
+
`<button type="button" class="agent-tile${r.unavailableReason ? " off" : ""}" data-agent="${esc(r.id)}" title="${esc(r.unavailableReason ? `Not installed on this machine. ${r.installHint || ""}` : `Found at ${r.installedAt || "bundled"}`)}"><span class="at-logo">${vendorLogo(r)}</span><span class="at-name">${esc(r.vendor)}</span><span class="at-sub">${r.unavailableReason ? "not installed" : "installed"}</span></button>`,
|
|
2163
|
+
)
|
|
2164
|
+
.join("");
|
|
2165
|
+
els.invAgents.querySelectorAll(".agent-tile:not(.off)").forEach((b) =>
|
|
2166
|
+
b.addEventListener("click", () => {
|
|
2167
|
+
els.invType.value = b.dataset.agent;
|
|
2168
|
+
els.invOptions.hidden = false;
|
|
2169
|
+
els.invSubmit.disabled = false;
|
|
2170
|
+
applyRecipe(false);
|
|
2171
|
+
}),
|
|
2172
|
+
);
|
|
2173
|
+
els.invNone.hidden = installed.length > 0;
|
|
2174
|
+
els.invNone.innerHTML = installed.length
|
|
2175
|
+
? ""
|
|
2176
|
+
: `No supported vibemate is installed on this machine yet. Install one and open this dialog again: ${state.recipes.map((r) => `<b>${esc(r.vendor)}</b> (<code>${esc(r.installHint || "")}</code>)`).join(", ")}.`;
|
|
2177
|
+
els.invSubmit.disabled = true;
|
|
2178
|
+
els.invType.value = "";
|
|
2179
|
+
els.invOptions.hidden = true;
|
|
2180
|
+
els.invWhere.textContent = "";
|
|
2181
|
+
els.invNote.textContent = "";
|
|
2182
|
+
els.invStatus.textContent = "";
|
|
2183
|
+
els.invDelay.value = "";
|
|
2184
|
+
els.invDelay.placeholder = `the room's: ${(currentRoom() || {}).settings?.replyDelay ?? 4} s`;
|
|
2185
|
+
renderSkillChecks(els.invSkills, []);
|
|
2186
|
+
els.invName.value = "";
|
|
2187
|
+
els.invAvatar.value = "";
|
|
2188
|
+
els.invTagline.value = "";
|
|
2189
|
+
els.invRole.value = "";
|
|
2190
|
+
els.invAvatarPicker.innerHTML = "";
|
|
2191
|
+
els.invAvatarPicker.appendChild(window.Avatars.pickerElement("", (emoji) => (els.invAvatar.value = emoji)));
|
|
2192
|
+
els.invGeek.open = false;
|
|
2193
|
+
openDialog(els.dialog);
|
|
2194
|
+
els.invName.focus();
|
|
2195
|
+
}
|
|
2196
|
+
async function submitInvite(event) {
|
|
2197
|
+
event.preventDefault();
|
|
2198
|
+
if (!els.invType.value) {
|
|
2199
|
+
els.invError.textContent = "Pick a vibemate first.";
|
|
2200
|
+
els.invError.hidden = false;
|
|
2201
|
+
return;
|
|
2202
|
+
}
|
|
2203
|
+
els.invSubmit.disabled = true;
|
|
2204
|
+
els.invSubmit.classList.add("loading");
|
|
2205
|
+
els.invError.hidden = true;
|
|
2206
|
+
try {
|
|
2207
|
+
await post(roomApi("/invite"), {
|
|
2208
|
+
agentType: els.invType.value,
|
|
2209
|
+
name: els.invName.value.trim(),
|
|
2210
|
+
avatar: els.invAvatar.value.trim() || null,
|
|
2211
|
+
tagline: els.invTagline.value.trim() || null,
|
|
2212
|
+
role: els.invRole.value.trim() || null,
|
|
2213
|
+
model: els.invModelCustom.value.trim() || els.invModel.value || null,
|
|
2214
|
+
effort: els.invEffort.value || null,
|
|
2215
|
+
mode: els.invMode.value || null,
|
|
2216
|
+
replyDelay: els.invDelay.value === "" ? null : Number(els.invDelay.value),
|
|
2217
|
+
skills: checkedSkills(els.invSkills),
|
|
2218
|
+
});
|
|
2219
|
+
closeDialog(els.dialog);
|
|
2220
|
+
} catch (error) {
|
|
2221
|
+
els.invError.textContent = error.message;
|
|
2222
|
+
els.invError.hidden = false;
|
|
2223
|
+
} finally {
|
|
2224
|
+
els.invSubmit.disabled = false;
|
|
2225
|
+
els.invSubmit.classList.remove("loading");
|
|
2226
|
+
}
|
|
2227
|
+
}
|
|
2228
|
+
|
|
2229
|
+
|
|
2230
|
+
const reconnectPrompted = new Set();
|
|
2231
|
+
function openReconnectDialog(room) {
|
|
2232
|
+
const offline = offlineAgents(room);
|
|
2233
|
+
if (!offline.length) return;
|
|
2234
|
+
reconnectPrompted.add(room.id);
|
|
2235
|
+
els.rcError.hidden = true;
|
|
2236
|
+
els.rcReplay.value = room.settings.replayAfterRestart ?? 10;
|
|
2237
|
+
els.rcForm.querySelector('input[name="rc-mode"][value="replay"]').checked = true;
|
|
2238
|
+
els.rcIntro.textContent = `${offline.length} vibemate${offline.length > 1 ? "s are" : " is"} offline in "${room.name}" (their sessions ended with the previous hub run). Choose how they come back:`;
|
|
2239
|
+
els.rcTable.innerHTML = offline
|
|
2240
|
+
.map(
|
|
2241
|
+
(p) => `<tr data-id="${esc(p.id)}">
|
|
2242
|
+
<td>${avatar(p, 28, { vendor: true })}<span><strong>${esc(p.name)}</strong> <span class="rc-note">${esc(p.tagline || p.agentVendor || "")}</span></span></td>
|
|
2243
|
+
<td class="rc-note">${p.supportsLoad === false ? "no session/load" : p.sessionId ? "stored session available" : "no stored session"}</td>
|
|
2244
|
+
<td><select class="rc-per"><option value="">as above</option><option value="replay">replay</option><option value="load"${p.supportsLoad === false || !p.sessionId ? " disabled" : ""}>full session</option><option value="skip">leave offline</option></select></td>
|
|
2245
|
+
</tr>`,
|
|
2246
|
+
)
|
|
2247
|
+
.join("");
|
|
2248
|
+
openDialog(els.rcDialog);
|
|
2249
|
+
}
|
|
2250
|
+
async function submitReconnect(event) {
|
|
2251
|
+
event.preventDefault();
|
|
2252
|
+
const room = currentRoom();
|
|
2253
|
+
if (!room) return closeDialog(els.rcDialog);
|
|
2254
|
+
const globalMode = els.rcForm.querySelector('input[name="rc-mode"]:checked').value;
|
|
2255
|
+
const replay = Number(els.rcReplay.value);
|
|
2256
|
+
const rows = [...els.rcTable.querySelectorAll("tr")].map((tr) => ({ id: tr.dataset.id, choice: tr.querySelector(".rc-per").value || globalMode }));
|
|
2257
|
+
els.rcSubmit.disabled = true;
|
|
2258
|
+
els.rcSubmit.classList.add("loading");
|
|
2259
|
+
els.rcError.hidden = true;
|
|
2260
|
+
const failures = [];
|
|
2261
|
+
for (const row of rows) {
|
|
2262
|
+
if (row.choice === "skip") continue;
|
|
2263
|
+
try {
|
|
2264
|
+
await post(roomApi(`/participants/${encodeURIComponent(row.id)}/reconnect`), { mode: row.choice, replay });
|
|
2265
|
+
} catch (error) {
|
|
2266
|
+
failures.push(`${row.id}: ${error.message}`);
|
|
2267
|
+
}
|
|
2268
|
+
}
|
|
2269
|
+
els.rcSubmit.disabled = false;
|
|
2270
|
+
els.rcSubmit.classList.remove("loading");
|
|
2271
|
+
if (failures.length) {
|
|
2272
|
+
els.rcError.textContent = failures.join(" · ");
|
|
2273
|
+
els.rcError.hidden = false;
|
|
2274
|
+
return;
|
|
2275
|
+
}
|
|
2276
|
+
closeDialog(els.rcDialog);
|
|
2277
|
+
}
|
|
2278
|
+
function maybeOfferReconnect() {
|
|
2279
|
+
const room = currentRoom();
|
|
2280
|
+
if (!room || state.view !== "room" || reconnectPrompted.has(room.id) || els.rcDialog.open || els.pfDialog.open) return;
|
|
2281
|
+
if (offlineAgents(room).length) openReconnectDialog(room);
|
|
2282
|
+
}
|
|
2283
|
+
|
|
2284
|
+
|
|
2285
|
+
function openRoomDialog() {
|
|
2286
|
+
els.roomError.hidden = true;
|
|
2287
|
+
els.roomName.value = "";
|
|
2288
|
+
els.roomDir.value = "";
|
|
2289
|
+
openDialog(els.roomDialog);
|
|
2290
|
+
els.roomName.focus();
|
|
2291
|
+
}
|
|
2292
|
+
async function submitRoom(event) {
|
|
2293
|
+
event.preventDefault();
|
|
2294
|
+
try {
|
|
2295
|
+
const res = await post("/api/rooms", { name: els.roomName.value, dir: els.roomDir.value.trim() || null });
|
|
2296
|
+
closeDialog(els.roomDialog);
|
|
2297
|
+
if (res.room && res.room.id) {
|
|
2298
|
+
state.rooms.set(res.room.id, res.room);
|
|
2299
|
+
selectRoom(res.room.id);
|
|
2300
|
+
}
|
|
2301
|
+
for (const n of res.notices || []) toast(n, "info");
|
|
2302
|
+
} catch (error) {
|
|
2303
|
+
els.roomError.textContent = error.message;
|
|
2304
|
+
els.roomError.hidden = false;
|
|
2305
|
+
}
|
|
2306
|
+
}
|
|
2307
|
+
|
|
2308
|
+
|
|
2309
|
+
function maybeOfferProfile() {
|
|
2310
|
+
const s = state.settings;
|
|
2311
|
+
if (!s || s.profileCompleted || els.pfDialog.open) return;
|
|
2312
|
+
els.pfName.value = s.humanName && s.humanName !== "Human" ? s.humanName : "";
|
|
2313
|
+
els.pfAvatar.value = s.humanAvatar || "";
|
|
2314
|
+
els.pfDesc.value = s.humanDescription || "";
|
|
2315
|
+
els.pfAvatarPicker.innerHTML = "";
|
|
2316
|
+
els.pfAvatarPicker.appendChild(window.Avatars.pickerElement(s.humanAvatar || "", (emoji) => (els.pfAvatar.value = emoji)));
|
|
2317
|
+
els.pfError.hidden = true;
|
|
2318
|
+
openDialog(els.pfDialog);
|
|
2319
|
+
els.pfName.focus();
|
|
2320
|
+
}
|
|
2321
|
+
els.pfForm.addEventListener("submit", async (event) => {
|
|
2322
|
+
event.preventDefault();
|
|
2323
|
+
try {
|
|
2324
|
+
await post("/api/settings", { humanName: els.pfName.value.trim(), humanAvatar: els.pfAvatar.value.trim(), humanDescription: els.pfDesc.value.trim(), profileCompleted: true });
|
|
2325
|
+
closeDialog(els.pfDialog);
|
|
2326
|
+
toast(`Welcome, ${els.pfName.value.trim()}. Open a room and summon a vibemate.`, "info");
|
|
2327
|
+
} catch (error) {
|
|
2328
|
+
els.pfError.textContent = error.message;
|
|
2329
|
+
els.pfError.hidden = false;
|
|
2330
|
+
}
|
|
2331
|
+
});
|
|
2332
|
+
els.pfDialog.addEventListener("cancel", (event) => event.preventDefault());
|
|
2333
|
+
|
|
2334
|
+
function openEraseDialog() {
|
|
2335
|
+
els.eraseWord.value = "";
|
|
2336
|
+
els.eraseSubmit.disabled = true;
|
|
2337
|
+
els.eraseError.hidden = true;
|
|
2338
|
+
openDialog(els.eraseDialog);
|
|
2339
|
+
els.eraseWord.focus();
|
|
2340
|
+
}
|
|
2341
|
+
els.eraseWord.addEventListener("input", () => (els.eraseSubmit.disabled = els.eraseWord.value.trim().toLowerCase() !== "erase"));
|
|
2342
|
+
els.eraseForm.addEventListener("submit", async (event) => {
|
|
2343
|
+
event.preventDefault();
|
|
2344
|
+
try {
|
|
2345
|
+
els.eraseSubmit.classList.add("loading");
|
|
2346
|
+
await post("/api/profile/erase", { confirm: els.eraseWord.value.trim().toLowerCase() });
|
|
2347
|
+
try {
|
|
2348
|
+
localStorage.clear();
|
|
2349
|
+
} catch {
|
|
2350
|
+
}
|
|
2351
|
+
location.href = "/";
|
|
2352
|
+
} catch (error) {
|
|
2353
|
+
els.eraseSubmit.classList.remove("loading");
|
|
2354
|
+
els.eraseError.textContent = error.message;
|
|
2355
|
+
els.eraseError.hidden = false;
|
|
2356
|
+
}
|
|
2357
|
+
});
|
|
2358
|
+
|
|
2359
|
+
|
|
2360
|
+
function renderAll() {
|
|
2361
|
+
renderRail();
|
|
2362
|
+
if (state.view === "home") {
|
|
2363
|
+
renderSideRooms();
|
|
2364
|
+
renderHome();
|
|
2365
|
+
} else if ((state.view === "rooms" || state.view === "home")) {
|
|
2366
|
+
renderSideRooms();
|
|
2367
|
+
renderRoomsGrid();
|
|
2368
|
+
} else if (state.view === "room") {
|
|
2369
|
+
renderSideRoom();
|
|
2370
|
+
renderChatHead();
|
|
2371
|
+
renderMessages();
|
|
2372
|
+
} else if (state.view === "skills") renderSkillsPage();
|
|
2373
|
+
else if (state.view === "settings") renderSettingsPage();
|
|
2374
|
+
renderDetails();
|
|
2375
|
+
}
|
|
2376
|
+
|
|
2377
|
+
|
|
2378
|
+
function loadSnapshot(snapshot) {
|
|
2379
|
+
state.settings = snapshot.settings;
|
|
2380
|
+
state.version = snapshot.version || null;
|
|
2381
|
+
state.skills = snapshot.skills || [];
|
|
2382
|
+
state.recipes = snapshot.recipes || [];
|
|
2383
|
+
state.roomDefaults = snapshot.roomDefaults || null;
|
|
2384
|
+
state.rooms = new Map((snapshot.rooms || []).map((r) => [r.id, r]));
|
|
2385
|
+
const params = new URLSearchParams(location.search);
|
|
2386
|
+
const wanted = params.get("room");
|
|
2387
|
+
const remembered = recall("room");
|
|
2388
|
+
let openRoom = false;
|
|
2389
|
+
if (wanted && state.rooms.has(wanted)) {
|
|
2390
|
+
state.currentRoomId = wanted;
|
|
2391
|
+
openRoom = true;
|
|
2392
|
+
} else if (!state.rooms.has(state.currentRoomId)) {
|
|
2393
|
+
state.currentRoomId = state.rooms.has(remembered) ? remembered : null;
|
|
2394
|
+
openRoom = !!state.currentRoomId && recall("view") === "room";
|
|
2395
|
+
} else openRoom = state.view === "room";
|
|
2396
|
+
const participant = params.get("participant");
|
|
2397
|
+
if (participant && currentRoom() && findById(currentRoom(), participant)) {
|
|
2398
|
+
state.selection = { kind: "participant", id: participant };
|
|
2399
|
+
state.detailsOpen = true;
|
|
2400
|
+
els.details.hidden = false;
|
|
2401
|
+
}
|
|
2402
|
+
setView(openRoom && state.currentRoomId && (wanted || state.view === "room") ? "room" : state.view === "room" ? "rooms" : state.view);
|
|
2403
|
+
renderDetails();
|
|
2404
|
+
maybeOfferProfile();
|
|
2405
|
+
if (!els.pfDialog.open) maybeOfferReconnect();
|
|
2406
|
+
}
|
|
2407
|
+
|
|
2408
|
+
function onRoomEvent(roomId, event) {
|
|
2409
|
+
const room = state.rooms.get(roomId);
|
|
2410
|
+
if (!room) return;
|
|
2411
|
+
const current = roomId === state.currentRoomId;
|
|
2412
|
+
const showing = current && state.view === "room";
|
|
2413
|
+
switch (event.type) {
|
|
2414
|
+
case "participant": {
|
|
2415
|
+
const before = showing ? visibilityFingerprint(room) : "";
|
|
2416
|
+
const i = room.participants.findIndex((p) => p.id === event.participant.id);
|
|
2417
|
+
if (i >= 0) room.participants[i] = event.participant;
|
|
2418
|
+
else room.participants.push(event.participant);
|
|
2419
|
+
if (showing) {
|
|
2420
|
+
if (visibilityFingerprint(room) !== before) renderMessages();
|
|
2421
|
+
else refreshSeen(room);
|
|
2422
|
+
renderSideRoom();
|
|
2423
|
+
renderChatHead();
|
|
2424
|
+
if (state.detailsOpen && state.selection.kind === "participant" && state.selection.id === event.participant.id) {
|
|
2425
|
+
if (!editingInDetails()) renderDetails();
|
|
2426
|
+
else refreshDetailsHeader(event.participant);
|
|
2427
|
+
}
|
|
2428
|
+
} else if ((state.view === "rooms" || state.view === "home")) {
|
|
2429
|
+
renderSideRooms();
|
|
2430
|
+
renderRoomsGrid();
|
|
2431
|
+
}
|
|
2432
|
+
renderRail();
|
|
2433
|
+
return;
|
|
2434
|
+
}
|
|
2435
|
+
case "participant.removed":
|
|
2436
|
+
room.participants = room.participants.filter((p) => p.id !== event.id);
|
|
2437
|
+
if (state.selection.kind === "participant" && state.selection.id === event.id) closeDetails();
|
|
2438
|
+
if (showing) {
|
|
2439
|
+
renderMessages();
|
|
2440
|
+
renderSideRoom();
|
|
2441
|
+
renderChatHead();
|
|
2442
|
+
} else if ((state.view === "rooms" || state.view === "home")) renderRoomsGrid();
|
|
2443
|
+
return;
|
|
2444
|
+
case "message":
|
|
2445
|
+
upsertMessage(roomId, event.message);
|
|
2446
|
+
return;
|
|
2447
|
+
case "message.removed":
|
|
2448
|
+
removeMessage(roomId, event.id);
|
|
2449
|
+
return;
|
|
2450
|
+
case "messages.truncated":
|
|
2451
|
+
room.messages = room.messages.filter((m) => m.seq <= event.fromSeq);
|
|
2452
|
+
room.permissions = (room.permissions || []).filter((p) => room.messages.some((m) => m.from === p.participantId && m.streaming));
|
|
2453
|
+
if (showing) renderMessages();
|
|
2454
|
+
return;
|
|
2455
|
+
case "chunk":
|
|
2456
|
+
patchMessage(roomId, event.id, (m) => (m.text += event.text));
|
|
2457
|
+
return;
|
|
2458
|
+
case "thought":
|
|
2459
|
+
patchMessage(roomId, event.id, (m) => (m.thought = (m.thought || "") + event.text));
|
|
2460
|
+
return;
|
|
2461
|
+
case "toolcall":
|
|
2462
|
+
patchMessage(roomId, event.id, (m) => {
|
|
2463
|
+
m.toolCalls = m.toolCalls || [];
|
|
2464
|
+
const i = m.toolCalls.findIndex((c) => c.toolCallId === event.toolCall.toolCallId);
|
|
2465
|
+
if (i >= 0) m.toolCalls[i] = event.toolCall;
|
|
2466
|
+
else m.toolCalls.push(event.toolCall);
|
|
2467
|
+
});
|
|
2468
|
+
return;
|
|
2469
|
+
case "plan":
|
|
2470
|
+
patchMessage(roomId, event.id, (m) => (m.plan = event.entries));
|
|
2471
|
+
return;
|
|
2472
|
+
case "permission":
|
|
2473
|
+
room.permissions.push(event.permission);
|
|
2474
|
+
if (showing) renderPermission(room, event.permission);
|
|
2475
|
+
else toast(`${(findById(room, event.permission.participantId) || {}).name || "A vibemate"} in "${room.name}" asks for permission.`, "warn");
|
|
2476
|
+
return;
|
|
2477
|
+
case "permission.resolved":
|
|
2478
|
+
room.permissions = room.permissions.filter((p) => p.key !== event.key);
|
|
2479
|
+
if (showing) resolvePermissionCard(event.key, event.optionId);
|
|
2480
|
+
return;
|
|
2481
|
+
case "room":
|
|
2482
|
+
room.hopLimit = event.hopLimit;
|
|
2483
|
+
room.hops = event.hops;
|
|
2484
|
+
room.settings = event.settings;
|
|
2485
|
+
room.customRulesText = event.customRulesText != null ? event.customRulesText : room.customRulesText;
|
|
2486
|
+
room.focused = event.focused;
|
|
2487
|
+
room.name = event.name;
|
|
2488
|
+
if (event.dir) room.dir = event.dir;
|
|
2489
|
+
if (showing) {
|
|
2490
|
+
renderSideRoom();
|
|
2491
|
+
renderChatHead();
|
|
2492
|
+
if (state.detailsOpen && state.selection.kind === "room" && !editingInDetails()) renderDetails();
|
|
2493
|
+
} else if ((state.view === "rooms" || state.view === "home")) {
|
|
2494
|
+
renderSideRooms();
|
|
2495
|
+
renderRoomsGrid();
|
|
2496
|
+
}
|
|
2497
|
+
renderRail();
|
|
2498
|
+
return;
|
|
2499
|
+
case "notice":
|
|
2500
|
+
if (current) toast(event.text, event.level);
|
|
2501
|
+
return;
|
|
2502
|
+
default:
|
|
2503
|
+
return;
|
|
2504
|
+
}
|
|
2505
|
+
}
|
|
2506
|
+
|
|
2507
|
+
let stream = null;
|
|
2508
|
+
let releaseTimer = null;
|
|
2509
|
+
function connect() {
|
|
2510
|
+
if (stream) return;
|
|
2511
|
+
const es = new EventSource("/events");
|
|
2512
|
+
stream = es;
|
|
2513
|
+
es.onopen = () => els.conn.classList.add("ok");
|
|
2514
|
+
es.onerror = () => els.conn.classList.remove("ok");
|
|
2515
|
+
es.addEventListener("snapshot", (e) => loadSnapshot(JSON.parse(e.data).snapshot));
|
|
2516
|
+
es.addEventListener("room.event", (e) => {
|
|
2517
|
+
const { roomId, event } = JSON.parse(e.data);
|
|
2518
|
+
onRoomEvent(roomId, event);
|
|
2519
|
+
});
|
|
2520
|
+
es.addEventListener("room.created", (e) => {
|
|
2521
|
+
const { room } = JSON.parse(e.data);
|
|
2522
|
+
state.rooms.set(room.id, room);
|
|
2523
|
+
if ((state.view === "rooms" || state.view === "home")) {
|
|
2524
|
+
renderSideRooms();
|
|
2525
|
+
renderRoomsGrid();
|
|
2526
|
+
}
|
|
2527
|
+
renderRail();
|
|
2528
|
+
});
|
|
2529
|
+
es.addEventListener("room.removed", (e) => {
|
|
2530
|
+
const { roomId } = JSON.parse(e.data);
|
|
2531
|
+
state.rooms.delete(roomId);
|
|
2532
|
+
if (state.currentRoomId === roomId) {
|
|
2533
|
+
state.currentRoomId = null;
|
|
2534
|
+
state.selection = { kind: "room" };
|
|
2535
|
+
closeDetails();
|
|
2536
|
+
setView("rooms");
|
|
2537
|
+
} else if ((state.view === "rooms" || state.view === "home")) {
|
|
2538
|
+
renderSideRooms();
|
|
2539
|
+
renderRoomsGrid();
|
|
2540
|
+
}
|
|
2541
|
+
renderRail();
|
|
2542
|
+
});
|
|
2543
|
+
es.addEventListener("skills", (e) => {
|
|
2544
|
+
state.skills = JSON.parse(e.data).skills || [];
|
|
2545
|
+
if (state.view === "skills" && !editingInDetails()) renderSkillsPage();
|
|
2546
|
+
if (state.detailsOpen && !editingInDetails()) renderDetails();
|
|
2547
|
+
});
|
|
2548
|
+
es.addEventListener("settings", (e) => {
|
|
2549
|
+
state.settings = JSON.parse(e.data).settings;
|
|
2550
|
+
rerenderDiagrams();
|
|
2551
|
+
renderRail();
|
|
2552
|
+
if (state.view === "settings" && !editingInDetails()) renderSettingsPage();
|
|
2553
|
+
if (state.detailsOpen && state.selection.kind === "me" && !editingInDetails()) renderDetails();
|
|
2554
|
+
if (state.view === "room") renderSideRoom();
|
|
2555
|
+
});
|
|
2556
|
+
es.addEventListener("reset", () => location.href = "/");
|
|
2557
|
+
}
|
|
2558
|
+
function releaseStream() {
|
|
2559
|
+
if (!stream) return;
|
|
2560
|
+
stream.close();
|
|
2561
|
+
stream = null;
|
|
2562
|
+
els.conn.classList.remove("ok");
|
|
2563
|
+
els.conn.title = "Paused while this tab is in the background; resumes when you come back";
|
|
2564
|
+
}
|
|
2565
|
+
document.addEventListener("visibilitychange", () => {
|
|
2566
|
+
clearTimeout(releaseTimer);
|
|
2567
|
+
if (document.hidden) releaseTimer = setTimeout(releaseStream, 15000);
|
|
2568
|
+
else {
|
|
2569
|
+
els.conn.title = "Connection to the hub";
|
|
2570
|
+
connect();
|
|
2571
|
+
}
|
|
2572
|
+
});
|
|
2573
|
+
|
|
2574
|
+
|
|
2575
|
+
function autosize() {
|
|
2576
|
+
els.input.style.height = "auto";
|
|
2577
|
+
els.input.style.height = Math.min(180, els.input.scrollHeight) + "px";
|
|
2578
|
+
}
|
|
2579
|
+
let typingSentAt = 0;
|
|
2580
|
+
els.input.addEventListener("input", () => {
|
|
2581
|
+
if (!currentRoom() || !els.input.value.trim()) return;
|
|
2582
|
+
const now = Date.now();
|
|
2583
|
+
if (now - typingSentAt < 2000) return;
|
|
2584
|
+
typingSentAt = now;
|
|
2585
|
+
post(roomApi("/typing"), {}).catch(() => undefined);
|
|
2586
|
+
});
|
|
2587
|
+
els.composer.addEventListener("submit", async (event) => {
|
|
2588
|
+
event.preventDefault();
|
|
2589
|
+
const text = els.input.value.trim();
|
|
2590
|
+
if (!text || !currentRoom()) return;
|
|
2591
|
+
els.input.value = "";
|
|
2592
|
+
typingSentAt = 0;
|
|
2593
|
+
autosize();
|
|
2594
|
+
try {
|
|
2595
|
+
await post(roomApi("/send"), { text });
|
|
2596
|
+
} catch (error) {
|
|
2597
|
+
showError(error);
|
|
2598
|
+
els.input.value = text;
|
|
2599
|
+
}
|
|
2600
|
+
});
|
|
2601
|
+
|
|
2602
|
+
function attachMentions(textarea, menuEl, options) {
|
|
2603
|
+
const opts = options || {};
|
|
2604
|
+
const m = { open: false, items: [], index: 0, start: -1 };
|
|
2605
|
+
function context() {
|
|
2606
|
+
const value = textarea.value;
|
|
2607
|
+
const caret = textarea.selectionStart ?? value.length;
|
|
2608
|
+
const before = value.slice(0, caret);
|
|
2609
|
+
const match = before.match(/(^|\s)@([\p{L}\p{N}_-]*)$/u);
|
|
2610
|
+
if (!match) return null;
|
|
2611
|
+
return { start: caret - match[2].length - 1, prefix: match[2] };
|
|
2612
|
+
}
|
|
2613
|
+
function close() {
|
|
2614
|
+
if (!m.open) return;
|
|
2615
|
+
m.open = false;
|
|
2616
|
+
menuEl.hidden = true;
|
|
2617
|
+
}
|
|
2618
|
+
function render() {
|
|
2619
|
+
const ctx = context();
|
|
2620
|
+
const room = currentRoom();
|
|
2621
|
+
if (!ctx || !room) return close();
|
|
2622
|
+
const q = ctx.prefix.toLowerCase();
|
|
2623
|
+
const items = room.participants.filter((p) => (opts.includeHuman || p.id !== "human") && p.name.toLowerCase().startsWith(q));
|
|
2624
|
+
if (!items.length) return close();
|
|
2625
|
+
m.open = true;
|
|
2626
|
+
m.items = items;
|
|
2627
|
+
m.start = ctx.start;
|
|
2628
|
+
if (m.index >= items.length) m.index = 0;
|
|
2629
|
+
menuEl.innerHTML = "";
|
|
2630
|
+
items.forEach((p, i) => {
|
|
2631
|
+
const b = document.createElement("button");
|
|
2632
|
+
b.type = "button";
|
|
2633
|
+
b.className = i === m.index ? "active" : "";
|
|
2634
|
+
b.innerHTML = `${avatar(p, 24, { vendor: true })}<span>${esc(p.name)}</span><span class="mm-sub">${esc(p.kind === "human" ? "you" : p.tagline || p.agentVendor || "")}${p.status === "offline" ? " · offline" : ""}</span>`;
|
|
2635
|
+
b.addEventListener("mousedown", (e) => {
|
|
2636
|
+
e.preventDefault();
|
|
2637
|
+
pick(i);
|
|
2638
|
+
});
|
|
2639
|
+
menuEl.appendChild(b);
|
|
2640
|
+
});
|
|
2641
|
+
menuEl.hidden = false;
|
|
2642
|
+
}
|
|
2643
|
+
function pick(i) {
|
|
2644
|
+
const p = m.items[i];
|
|
2645
|
+
if (!p) return close();
|
|
2646
|
+
const value = textarea.value;
|
|
2647
|
+
const caret = textarea.selectionStart ?? value.length;
|
|
2648
|
+
textarea.value = `${value.slice(0, m.start)}@${p.name} ${value.slice(caret)}`;
|
|
2649
|
+
const pos = m.start + p.name.length + 2;
|
|
2650
|
+
textarea.setSelectionRange(pos, pos);
|
|
2651
|
+
close();
|
|
2652
|
+
textarea.focus();
|
|
2653
|
+
if (opts.onChange) opts.onChange();
|
|
2654
|
+
}
|
|
2655
|
+
textarea.addEventListener("input", () => {
|
|
2656
|
+
m.index = 0;
|
|
2657
|
+
render();
|
|
2658
|
+
if (opts.onChange) opts.onChange();
|
|
2659
|
+
});
|
|
2660
|
+
textarea.addEventListener("blur", () => setTimeout(close, 150));
|
|
2661
|
+
textarea.addEventListener("keydown", (event) => {
|
|
2662
|
+
if (!m.open) return;
|
|
2663
|
+
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
|
2664
|
+
event.preventDefault();
|
|
2665
|
+
m.index = (m.index + (event.key === "ArrowDown" ? 1 : m.items.length - 1)) % m.items.length;
|
|
2666
|
+
render();
|
|
2667
|
+
} else if (event.key === "Enter" || event.key === "Tab") {
|
|
2668
|
+
event.preventDefault();
|
|
2669
|
+
pick(m.index);
|
|
2670
|
+
} else if (event.key === "Escape") {
|
|
2671
|
+
event.preventDefault();
|
|
2672
|
+
close();
|
|
2673
|
+
}
|
|
2674
|
+
});
|
|
2675
|
+
}
|
|
2676
|
+
attachMentions(els.input, els.mentionMenu, { onChange: autosize });
|
|
2677
|
+
attachSlashMenu(els.input, els.mentionMenu);
|
|
2678
|
+
els.input.addEventListener("keydown", (event) => {
|
|
2679
|
+
if (event.defaultPrevented) return;
|
|
2680
|
+
if (event.key === "Enter" && !event.shiftKey) {
|
|
2681
|
+
event.preventDefault();
|
|
2682
|
+
els.composer.requestSubmit();
|
|
2683
|
+
}
|
|
2684
|
+
});
|
|
2685
|
+
|
|
2686
|
+
|
|
2687
|
+
els.detailsResizer.addEventListener("mousedown", (event) => {
|
|
2688
|
+
event.preventDefault();
|
|
2689
|
+
const startX = event.clientX;
|
|
2690
|
+
const startW = els.details.getBoundingClientRect().width;
|
|
2691
|
+
els.app.classList.add("resizing");
|
|
2692
|
+
const move = (e) => applyDetailsWidth(startW + (startX - e.clientX), false);
|
|
2693
|
+
const up = (e) => {
|
|
2694
|
+
els.app.classList.remove("resizing");
|
|
2695
|
+
applyDetailsWidth(startW + (startX - e.clientX), true);
|
|
2696
|
+
window.removeEventListener("mousemove", move);
|
|
2697
|
+
window.removeEventListener("mouseup", up);
|
|
2698
|
+
};
|
|
2699
|
+
window.addEventListener("mousemove", move);
|
|
2700
|
+
window.addEventListener("mouseup", up);
|
|
2701
|
+
});
|
|
2702
|
+
els.detailsResizer.addEventListener("dblclick", () => {
|
|
2703
|
+
const key = detailsKey();
|
|
2704
|
+
remember(`details.${key}`, "");
|
|
2705
|
+
applyDetailsWidth(DETAILS_DEFAULT[key] || 400, false);
|
|
2706
|
+
});
|
|
2707
|
+
|
|
2708
|
+
|
|
2709
|
+
els.rail.querySelectorAll(".rail-item[data-nav]").forEach((b) => {
|
|
2710
|
+
b.addEventListener("click", () => {
|
|
2711
|
+
const nav = b.dataset.nav;
|
|
2712
|
+
if (nav === "me") {
|
|
2713
|
+
if (state.detailsOpen && state.selection.kind === "me") closeDetails();
|
|
2714
|
+
else openDetails({ kind: "me" });
|
|
2715
|
+
return;
|
|
2716
|
+
}
|
|
2717
|
+
if (nav === "room") {
|
|
2718
|
+
if (currentRoom()) selectRoom(state.currentRoomId, { keepDetails: true });
|
|
2719
|
+
return;
|
|
2720
|
+
}
|
|
2721
|
+
setView(nav);
|
|
2722
|
+
remember("view", nav);
|
|
2723
|
+
});
|
|
2724
|
+
});
|
|
2725
|
+
els.railToggle.addEventListener("click", () => setRailOpen(!els.app.classList.contains("rail-open")));
|
|
2726
|
+
function setSideOpen(open) {
|
|
2727
|
+
els.app.classList.toggle("side-collapsed", !open);
|
|
2728
|
+
remember("sideOpen", open ? "1" : "0");
|
|
2729
|
+
els.sideToggle.title = open ? "Fold this column" : "Unfold this column";
|
|
2730
|
+
els.sideToggle.innerHTML = ic(open ? "collapse" : "expand");
|
|
2731
|
+
}
|
|
2732
|
+
els.sideToggle.addEventListener("click", () => setSideOpen(els.app.classList.contains("side-collapsed")));
|
|
2733
|
+
setSideOpen(recall("sideOpen") !== "0");
|
|
2734
|
+
$("#rail-logo").addEventListener("click", () => setView("home"));
|
|
2735
|
+
$("#rail-new-room").addEventListener("click", openRoomDialog);
|
|
2736
|
+
const connLabel = $("#conn-label");
|
|
2737
|
+
new MutationObserver(() => (connLabel.textContent = els.conn.classList.contains("ok") ? "connected" : "reconnecting…")).observe(els.conn, { attributes: true, attributeFilter: ["class"] });
|
|
2738
|
+
if (recall("railOpen") === "1") els.app.classList.add("rail-open");
|
|
2739
|
+
els.backToRooms.addEventListener("click", () => {
|
|
2740
|
+
setView("rooms");
|
|
2741
|
+
remember("view", "rooms");
|
|
2742
|
+
});
|
|
2743
|
+
els.roomSearch.addEventListener("input", () => {
|
|
2744
|
+
state.roomSearch = els.roomSearch.value.trim();
|
|
2745
|
+
renderSideRooms();
|
|
2746
|
+
});
|
|
2747
|
+
els.search.addEventListener("input", () => {
|
|
2748
|
+
state.search = els.search.value.trim();
|
|
2749
|
+
renderMessages();
|
|
2750
|
+
});
|
|
2751
|
+
els.inviteBtn.addEventListener("click", openInvite);
|
|
2752
|
+
els.invType.addEventListener("change", () => applyRecipe(false));
|
|
2753
|
+
els.invRefresh.addEventListener("click", () => applyRecipe(true));
|
|
2754
|
+
els.invForm.addEventListener("submit", submitInvite);
|
|
2755
|
+
els.roomForm.addEventListener("submit", submitRoom);
|
|
2756
|
+
els.reconnectAllBtn.addEventListener("click", () => openReconnectDialog(currentRoom()));
|
|
2757
|
+
els.rcForm.addEventListener("submit", submitReconnect);
|
|
2758
|
+
document.querySelectorAll("[data-close]").forEach((b) => b.addEventListener("click", () => closeDialog(b.closest("dialog"))));
|
|
2759
|
+
els.focusBtn.addEventListener("click", async () => {
|
|
2760
|
+
const room = currentRoom();
|
|
2761
|
+
if (!room || room.focused || els.focusBtn.classList.contains("busy")) return;
|
|
2762
|
+
renderHushButton(room, true);
|
|
2763
|
+
try {
|
|
2764
|
+
await post(roomApi("/focus"));
|
|
2765
|
+
} catch (e) {
|
|
2766
|
+
showError(e);
|
|
2767
|
+
renderHushButton(room);
|
|
2768
|
+
}
|
|
2769
|
+
});
|
|
2770
|
+
function insertAtCaret(text) {
|
|
2771
|
+
const input = els.input;
|
|
2772
|
+
const start = input.selectionStart ?? input.value.length;
|
|
2773
|
+
const end = input.selectionEnd ?? start;
|
|
2774
|
+
input.value = input.value.slice(0, start) + text + input.value.slice(end);
|
|
2775
|
+
const pos = start + text.length;
|
|
2776
|
+
input.setSelectionRange(pos, pos);
|
|
2777
|
+
autosize();
|
|
2778
|
+
}
|
|
2779
|
+
function toggleEmojiMenu(open) {
|
|
2780
|
+
const menu = els.emojiMenu;
|
|
2781
|
+
if (open === undefined) open = menu.hidden;
|
|
2782
|
+
if (!open) {
|
|
2783
|
+
menu.hidden = true;
|
|
2784
|
+
return;
|
|
2785
|
+
}
|
|
2786
|
+
if (!menu.children.length) {
|
|
2787
|
+
menu.appendChild(
|
|
2788
|
+
emojiGrid(CHAT_EMOJI, null, (emoji) => {
|
|
2789
|
+
insertAtCaret(emoji);
|
|
2790
|
+
toggleEmojiMenu(false);
|
|
2791
|
+
els.input.focus();
|
|
2792
|
+
}),
|
|
2793
|
+
);
|
|
2794
|
+
}
|
|
2795
|
+
menu.hidden = false;
|
|
2796
|
+
}
|
|
2797
|
+
els.emojiBtn.addEventListener("click", (e) => {
|
|
2798
|
+
e.stopPropagation();
|
|
2799
|
+
toggleEmojiMenu();
|
|
2800
|
+
});
|
|
2801
|
+
document.addEventListener("click", (e) => {
|
|
2802
|
+
if (!els.emojiMenu.hidden && !(e.target.closest && e.target.closest("#emoji-menu"))) toggleEmojiMenu(false);
|
|
2803
|
+
});
|
|
2804
|
+
document.addEventListener("keydown", (e) => {
|
|
2805
|
+
if (e.key === "Escape" && !els.emojiMenu.hidden) toggleEmojiMenu(false);
|
|
2806
|
+
});
|
|
2807
|
+
document.addEventListener("click", async (e) => {
|
|
2808
|
+
const link = e.target.closest && e.target.closest(".open-link");
|
|
2809
|
+
if (link) {
|
|
2810
|
+
e.preventDefault();
|
|
2811
|
+
try {
|
|
2812
|
+
const r = await post("/api/open", { target: link.dataset.open });
|
|
2813
|
+
if (r.action !== "open-url") toast(r.message, "info");
|
|
2814
|
+
} catch (err) {
|
|
2815
|
+
showError(err);
|
|
2816
|
+
}
|
|
2817
|
+
return;
|
|
2818
|
+
}
|
|
2819
|
+
const src = e.target.closest && e.target.closest(".mm-src");
|
|
2820
|
+
if (src) {
|
|
2821
|
+
const code = src.closest(".mermaid-block").querySelector(".mm-code");
|
|
2822
|
+
code.hidden = !code.hidden;
|
|
2823
|
+
src.textContent = code.hidden ? "source" : "hide source";
|
|
2824
|
+
}
|
|
2825
|
+
});
|
|
2826
|
+
els.roomSettingsBtn.addEventListener("click", () => openDetails({ kind: "room" }));
|
|
2827
|
+
els.chatInfoBtn.addEventListener("click", () => openDetails({ kind: "room" }));
|
|
2828
|
+
document.addEventListener("keydown", (event) => {
|
|
2829
|
+
if (event.key === "Escape" && state.detailsOpen && !document.querySelector("dialog[open]") && !editingInDetails()) closeDetails();
|
|
2830
|
+
});
|
|
2831
|
+
window.addEventListener("beforeunload", () => remember("view", state.view));
|
|
2832
|
+
|
|
2833
|
+
connect();
|
|
2834
|
+
})();
|