privateboard 0.1.0 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +5281 -989
- package/dist/cli.js.map +1 -1
- package/package.json +1 -1
- package/public/agent-overlay.js +3 -3
- package/public/agent-profile.css +5 -4
- package/public/agent-profile.js +35 -9
- package/public/app.js +4408 -580
- package/public/avatar-skill.js +6 -9
- package/public/home.html +1750 -0
- package/public/{prototype-dashboard.html → index.html} +2426 -321
- package/public/onboarding.js +40 -13
- package/public/quote-cta.css +269 -0
- package/public/quote-cta.js +553 -0
- package/public/report/spines/a16z-thesis.css +234 -87
- package/public/report/spines/anthropic-essay.css +587 -191
- package/public/report/spines/boardroom-dark.css +141 -67
- package/public/report/spines/gartner-note.css +105 -23
- package/public/report/spines/mckinsey-deck.css +102 -15
- package/public/report/spines/openai-paper.css +117 -20
- package/public/report.html +3882 -148
- package/public/room-settings.css +6 -4
- package/public/room-settings.js +19 -13
- package/public/themes.css +15 -2
- package/public/user-settings.css +37 -8
- package/public/user-settings.js +68 -164
|
@@ -0,0 +1,553 @@
|
|
|
1
|
+
/* ═══════════════════════════════════════════
|
|
2
|
+
QUOTE CTA · selection-driven follow-up
|
|
3
|
+
═══════════════════════════════════════════
|
|
4
|
+
When the user selects text inside a director's message bubble,
|
|
5
|
+
a small floating bar appears above the selection with three
|
|
6
|
+
actions:
|
|
7
|
+
|
|
8
|
+
✎ Probe / 追问 → opens an overlay; user types a question;
|
|
9
|
+
submits a user message that quotes the
|
|
10
|
+
selection (markdown blockquote) above the
|
|
11
|
+
question. Routes through the existing send
|
|
12
|
+
path: idle → send, mid-turn → interrupt-or-
|
|
13
|
+
queue choice modal, paused → server queues
|
|
14
|
+
for next round.
|
|
15
|
+
|
|
16
|
+
★ Second / 附议 → one-click; submits the same shape with a
|
|
17
|
+
fixed parliamentary "Seconded." line below
|
|
18
|
+
the quote, signalling the user co-signs the
|
|
19
|
+
director's point. Same routing.
|
|
20
|
+
|
|
21
|
+
⌖ Save / 收藏 → one-click; bookmarks the selection to the
|
|
22
|
+
chairman's notes (POST /api/notes). No room
|
|
23
|
+
message is created — this is a personal
|
|
24
|
+
collection, not a room interaction. Works
|
|
25
|
+
even in adjourned rooms (re-reading a
|
|
26
|
+
finished session is a primary use-case).
|
|
27
|
+
Keyboard shortcut: `S` (when a director
|
|
28
|
+
selection is live).
|
|
29
|
+
|
|
30
|
+
Director scope · selection only counts when both ends sit inside
|
|
31
|
+
the same `article.msg` whose class is neither `user` nor `chair`.
|
|
32
|
+
|
|
33
|
+
Probe / Second ride existing /api/rooms/:id/messages. Save POSTs
|
|
34
|
+
to /api/notes with quote + sentence-based context + char offsets
|
|
35
|
+
(computed against the bubble's textContent so the in-room overlay
|
|
36
|
+
can re-wrap the same span on next render).
|
|
37
|
+
*/
|
|
38
|
+
(function () {
|
|
39
|
+
let cta = null; // floating button bar
|
|
40
|
+
let lastSelection = null; // { text, directorId, directorName } — captured on showCTA
|
|
41
|
+
|
|
42
|
+
// ── Selection scope · is the selection inside a director bubble? ──
|
|
43
|
+
function getDirectorContext() {
|
|
44
|
+
const sel = window.getSelection ? window.getSelection() : null;
|
|
45
|
+
if (!sel || sel.isCollapsed || sel.rangeCount === 0) return null;
|
|
46
|
+
const text = sel.toString().trim();
|
|
47
|
+
if (text.length < 4) return null;
|
|
48
|
+
const range = sel.getRangeAt(0);
|
|
49
|
+
const anchor = sel.anchorNode;
|
|
50
|
+
const focus = sel.focusNode;
|
|
51
|
+
if (!anchor || !focus) return null;
|
|
52
|
+
const elFor = (n) => (n.nodeType === Node.ELEMENT_NODE ? n : n.parentElement);
|
|
53
|
+
const anchorEl = elFor(anchor);
|
|
54
|
+
const focusEl = elFor(focus);
|
|
55
|
+
if (!anchorEl || !focusEl) return null;
|
|
56
|
+
const bubble = anchorEl.closest(".msg-bubble");
|
|
57
|
+
if (!bubble) return null;
|
|
58
|
+
const article = bubble.closest("article.msg");
|
|
59
|
+
if (!article) return null;
|
|
60
|
+
// Reject user / chair messages — feature is director-only.
|
|
61
|
+
if (article.classList.contains("user") || article.classList.contains("chair")) return null;
|
|
62
|
+
// Both ends must be in the same message · cross-message selections
|
|
63
|
+
// make no sense for "quote this passage from director X".
|
|
64
|
+
if (focusEl.closest("article.msg") !== article) return null;
|
|
65
|
+
|
|
66
|
+
// Director identity · the article carries data-author-id for
|
|
67
|
+
// director bubbles (added in app.js messageHtml). Name comes from
|
|
68
|
+
// the visible .msg-name span in the same message header.
|
|
69
|
+
const directorId = article.dataset.authorId || "";
|
|
70
|
+
const messageId = article.dataset.messageId || "";
|
|
71
|
+
const nameEl = article.querySelector(".msg-name");
|
|
72
|
+
const directorName = nameEl ? nameEl.textContent.trim() : "";
|
|
73
|
+
// Adjourned rooms · the room is closed for new replies (Probe /
|
|
74
|
+
// Second are disabled), but the user can still save notes from
|
|
75
|
+
// it — re-reading a finished session is a primary use case.
|
|
76
|
+
const app = window.app;
|
|
77
|
+
const adjourned = !!(app && app.currentRoom && app.currentRoom.status === "adjourned");
|
|
78
|
+
|
|
79
|
+
// Char offsets relative to bubble.textContent · let the in-room
|
|
80
|
+
// overlay (Step 5) wrap the same span on next render. Computed
|
|
81
|
+
// once here so save can fire on either the button click or the
|
|
82
|
+
// `S` keyboard shortcut without re-walking the DOM.
|
|
83
|
+
const offsets = computeOffsets(bubble, range);
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
article, bubble, range, text, messageId,
|
|
87
|
+
directorId, directorName, adjourned,
|
|
88
|
+
charOffsetStart: offsets.start,
|
|
89
|
+
charOffsetEnd: offsets.end,
|
|
90
|
+
bubbleText: offsets.bubbleText,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Compute the char offset of a Range's start / end relative to a
|
|
95
|
+
// container's textContent. Uses Range.toString().length on a
|
|
96
|
+
// synthetic range that spans [container start → selection point],
|
|
97
|
+
// which honours rendered text the same way textContent does (skips
|
|
98
|
+
// markup, preserves visible characters). Returns -1 / -1 if the
|
|
99
|
+
// walk fails (renderer falls back to no overlay).
|
|
100
|
+
function computeOffsets(container, range) {
|
|
101
|
+
const bubbleText = container.textContent || "";
|
|
102
|
+
try {
|
|
103
|
+
const before = document.createRange();
|
|
104
|
+
before.setStart(container, 0);
|
|
105
|
+
before.setEnd(range.startContainer, range.startOffset);
|
|
106
|
+
const start = before.toString().length;
|
|
107
|
+
const inner = document.createRange();
|
|
108
|
+
inner.setStart(range.startContainer, range.startOffset);
|
|
109
|
+
inner.setEnd(range.endContainer, range.endOffset);
|
|
110
|
+
const end = start + inner.toString().length;
|
|
111
|
+
return { start, end, bubbleText };
|
|
112
|
+
} catch {
|
|
113
|
+
return { start: -1, end: -1, bubbleText };
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Sentence-based context expansion · grabs ~1–2 sentences on each
|
|
118
|
+
// side of the quote (capped at MAX_CHARS). Honours both ASCII
|
|
119
|
+
// (.!?) and CJK (。!?) sentence terminators. Falls back to the
|
|
120
|
+
// char cap if no boundary is found within the cap window.
|
|
121
|
+
function expandContext(fullText, quoteStart, quoteEnd) {
|
|
122
|
+
if (!fullText || quoteStart < 0 || quoteEnd < quoteStart) {
|
|
123
|
+
return { before: "", after: "" };
|
|
124
|
+
}
|
|
125
|
+
const MAX_CHARS = 200;
|
|
126
|
+
const SENTENCE_END = /[.!?。!?]/;
|
|
127
|
+
let beforeStart = Math.max(0, quoteStart - MAX_CHARS);
|
|
128
|
+
for (let i = quoteStart - 1; i >= beforeStart; i--) {
|
|
129
|
+
if (SENTENCE_END.test(fullText[i])) {
|
|
130
|
+
beforeStart = Math.min(i + 1, quoteStart);
|
|
131
|
+
break;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
let afterEnd = Math.min(fullText.length, quoteEnd + MAX_CHARS);
|
|
135
|
+
for (let i = quoteEnd; i < afterEnd; i++) {
|
|
136
|
+
if (SENTENCE_END.test(fullText[i])) {
|
|
137
|
+
afterEnd = Math.min(i + 1, fullText.length);
|
|
138
|
+
break;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return {
|
|
142
|
+
before: fullText.slice(beforeStart, quoteStart),
|
|
143
|
+
after: fullText.slice(quoteEnd, afterEnd),
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function lang() {
|
|
148
|
+
try {
|
|
149
|
+
if (window.app && typeof window.app.composerLanguage === "function") {
|
|
150
|
+
return window.app.composerLanguage();
|
|
151
|
+
}
|
|
152
|
+
} catch { /* */ }
|
|
153
|
+
return "en";
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ── Floating CTA bar ─────────────────────────────────────────
|
|
157
|
+
function ensureCTA() {
|
|
158
|
+
if (cta) return cta;
|
|
159
|
+
cta = document.createElement("div");
|
|
160
|
+
cta.className = "qcta";
|
|
161
|
+
cta.setAttribute("role", "toolbar");
|
|
162
|
+
const t = lang() === "zh"
|
|
163
|
+
? { ask: "追问", love: "附议", save: "收藏" }
|
|
164
|
+
: { ask: "Probe", love: "Second", save: "Save" };
|
|
165
|
+
// Inline chat-bubble SVG · uses currentColor so it inherits the
|
|
166
|
+
// hover lime / base text colour like the ★ glyph does.
|
|
167
|
+
const askIcon = `
|
|
168
|
+
<svg viewBox="0 0 14 14" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linejoin="miter" stroke-linecap="square" aria-hidden="true">
|
|
169
|
+
<path d="M2 3 H12 V9 H6.5 L4 11 L4 9 H2 Z"/>
|
|
170
|
+
</svg>
|
|
171
|
+
`;
|
|
172
|
+
// Bookmark glyph · matches the All Notes sidebar entry's icon
|
|
173
|
+
// semantics (this action lands in that view).
|
|
174
|
+
const saveIcon = `
|
|
175
|
+
<svg viewBox="0 0 14 14" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linejoin="miter" stroke-linecap="square" aria-hidden="true">
|
|
176
|
+
<path d="M3.5 1.5 H10.5 V12.5 L7 9.5 L3.5 12.5 Z"/>
|
|
177
|
+
</svg>
|
|
178
|
+
`;
|
|
179
|
+
cta.innerHTML = `
|
|
180
|
+
<button type="button" class="qcta-btn" data-qcta="ask">
|
|
181
|
+
<span class="ico">${askIcon}</span><span>${t.ask}</span>
|
|
182
|
+
</button>
|
|
183
|
+
<button type="button" class="qcta-btn" data-qcta="second">
|
|
184
|
+
<span class="ico">★</span><span>${t.love}</span>
|
|
185
|
+
</button>
|
|
186
|
+
<button type="button" class="qcta-btn qcta-btn-save" data-qcta="save" title="Save to Notes · S">
|
|
187
|
+
<span class="ico">${saveIcon}</span><span>${t.save}</span>
|
|
188
|
+
</button>
|
|
189
|
+
<span class="qcta-hint" data-qcta-hint></span>
|
|
190
|
+
`;
|
|
191
|
+
// Prevent the bar's mousedown from collapsing the selection BEFORE
|
|
192
|
+
// the click reaches us — Chrome/Safari clear the selection on a
|
|
193
|
+
// click outside the active range, which would defeat the bar.
|
|
194
|
+
cta.addEventListener("mousedown", (e) => e.preventDefault());
|
|
195
|
+
cta.addEventListener("click", (e) => {
|
|
196
|
+
const btn = e.target.closest("[data-qcta]");
|
|
197
|
+
if (!btn) return;
|
|
198
|
+
const action = btn.getAttribute("data-qcta");
|
|
199
|
+
// Read-only state (adjourned rooms) blocks Probe / Second since
|
|
200
|
+
// they post messages to a closed room. Save is exempt — the
|
|
201
|
+
// user is bookmarking for personal review, not interacting.
|
|
202
|
+
if (cta.classList.contains("qcta-readonly") && action !== "save") return;
|
|
203
|
+
const sel = lastSelection;
|
|
204
|
+
hideCTA();
|
|
205
|
+
if (!sel || !sel.text) return;
|
|
206
|
+
if (action === "ask") openAskOverlay(sel);
|
|
207
|
+
else if (action === "second") submitSecond(sel);
|
|
208
|
+
else if (action === "save") submitSave(sel);
|
|
209
|
+
});
|
|
210
|
+
document.body.appendChild(cta);
|
|
211
|
+
return cta;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function showCTA(ctx) {
|
|
215
|
+
const bar = ensureCTA();
|
|
216
|
+
lastSelection = {
|
|
217
|
+
text: ctx.text,
|
|
218
|
+
directorId: ctx.directorId,
|
|
219
|
+
directorName: ctx.directorName,
|
|
220
|
+
messageId: ctx.messageId,
|
|
221
|
+
charOffsetStart: ctx.charOffsetStart,
|
|
222
|
+
charOffsetEnd: ctx.charOffsetEnd,
|
|
223
|
+
bubbleText: ctx.bubbleText,
|
|
224
|
+
};
|
|
225
|
+
// Read-only state · adjourned room. Hide Probe / Second (they
|
|
226
|
+
// post to a closed room); Save stays available — review-mode
|
|
227
|
+
// bookmarking is a primary use case for adjourned sessions.
|
|
228
|
+
bar.classList.toggle("qcta-readonly", !!ctx.adjourned);
|
|
229
|
+
const hint = bar.querySelector("[data-qcta-hint]");
|
|
230
|
+
if (hint) {
|
|
231
|
+
hint.textContent = ctx.adjourned
|
|
232
|
+
? (lang() === "zh" ? "// 已结束的房间 · 只读" : "// adjourned · read-only")
|
|
233
|
+
: "";
|
|
234
|
+
}
|
|
235
|
+
const rect = ctx.range.getBoundingClientRect();
|
|
236
|
+
if (rect.width === 0 && rect.height === 0) return;
|
|
237
|
+
// Render first so we can measure width.
|
|
238
|
+
bar.classList.add("open");
|
|
239
|
+
const barWidth = bar.offsetWidth;
|
|
240
|
+
const barHeight = bar.offsetHeight;
|
|
241
|
+
let top = window.scrollY + rect.top - barHeight - 8;
|
|
242
|
+
if (top < window.scrollY + 4) {
|
|
243
|
+
// Not enough room above · drop below the selection.
|
|
244
|
+
top = window.scrollY + rect.bottom + 8;
|
|
245
|
+
}
|
|
246
|
+
let left = window.scrollX + rect.left + rect.width / 2 - barWidth / 2;
|
|
247
|
+
left = Math.max(window.scrollX + 8, Math.min(left, window.scrollX + window.innerWidth - barWidth - 8));
|
|
248
|
+
bar.style.top = top + "px";
|
|
249
|
+
bar.style.left = left + "px";
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function hideCTA() {
|
|
253
|
+
if (cta) cta.classList.remove("open");
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function refresh() {
|
|
257
|
+
// Skip when the ask overlay is open — don't stack a CTA on top
|
|
258
|
+
// of the modal's own selection.
|
|
259
|
+
if (document.getElementById("qask-overlay")) { hideCTA(); return; }
|
|
260
|
+
const ctx = getDirectorContext();
|
|
261
|
+
if (!ctx) { hideCTA(); return; }
|
|
262
|
+
showCTA(ctx);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Show only AFTER the user finishes selecting · hide as soon as a
|
|
266
|
+
// new mousedown begins so the bar doesn't track mid-drag. Skip the
|
|
267
|
+
// mousedown-hide when the click originates inside the CTA itself
|
|
268
|
+
// (otherwise clicking a button hides the bar before the click
|
|
269
|
+
// handler can read the action).
|
|
270
|
+
document.addEventListener("mousedown", (e) => {
|
|
271
|
+
if (cta && cta.contains(e.target)) return;
|
|
272
|
+
hideCTA();
|
|
273
|
+
});
|
|
274
|
+
document.addEventListener("mouseup", () => {
|
|
275
|
+
// Defer one tick so the browser has finished updating the
|
|
276
|
+
// selection before we measure it.
|
|
277
|
+
requestAnimationFrame(refresh);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
// Hide on scroll · the absolute-positioned bar would otherwise
|
|
281
|
+
// float in stale coords as the chat pane scrolls.
|
|
282
|
+
window.addEventListener("scroll", hideCTA, true);
|
|
283
|
+
document.addEventListener("keydown", (e) => {
|
|
284
|
+
if (e.key === "Escape") hideCTA();
|
|
285
|
+
|
|
286
|
+
// `S` shortcut · save current selection to Notes. Only fires
|
|
287
|
+
// when (a) a director-scoped selection is live, (b) no modifier
|
|
288
|
+
// keys are pressed (Cmd/Ctrl/Alt would clobber browser
|
|
289
|
+
// shortcuts), (c) the user isn't typing into an input. Skipping
|
|
290
|
+
// when an input/textarea is focused avoids hijacking the `s`
|
|
291
|
+
// key during composer typing — the qcta bar wouldn't have
|
|
292
|
+
// shown for a non-director selection anyway.
|
|
293
|
+
if ((e.key === "s" || e.key === "S")
|
|
294
|
+
&& !e.metaKey && !e.ctrlKey && !e.altKey
|
|
295
|
+
&& !isEditableTarget(e.target)
|
|
296
|
+
&& lastSelection
|
|
297
|
+
&& lastSelection.text
|
|
298
|
+
&& cta && cta.classList.contains("open")) {
|
|
299
|
+
e.preventDefault();
|
|
300
|
+
const sel = lastSelection;
|
|
301
|
+
hideCTA();
|
|
302
|
+
submitSave(sel);
|
|
303
|
+
}
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
function isEditableTarget(node) {
|
|
307
|
+
if (!node) return false;
|
|
308
|
+
const tag = (node.tagName || "").toLowerCase();
|
|
309
|
+
if (tag === "input" || tag === "textarea") return true;
|
|
310
|
+
if (node.isContentEditable) return true;
|
|
311
|
+
return false;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// ── Ask-follow-up overlay ────────────────────────────────────
|
|
315
|
+
function openAskOverlay(sel) {
|
|
316
|
+
closeAskOverlay();
|
|
317
|
+
const dirName = sel.directorName || "director";
|
|
318
|
+
const t = lang() === "zh"
|
|
319
|
+
? {
|
|
320
|
+
tag: "▸ 追问",
|
|
321
|
+
quoteTag: "// 引自 " + dirName,
|
|
322
|
+
placeholder: "把你想问的写在这里 · 回车发送,Shift+Enter 换行",
|
|
323
|
+
send: "发送",
|
|
324
|
+
cancel: "取消",
|
|
325
|
+
status: "选区追问 · " + dirName,
|
|
326
|
+
}
|
|
327
|
+
: {
|
|
328
|
+
tag: "▸ Probe",
|
|
329
|
+
quoteTag: "// quoting " + dirName,
|
|
330
|
+
placeholder: "Type your follow-up · Enter to send, Shift+Enter for newline",
|
|
331
|
+
send: "Send",
|
|
332
|
+
cancel: "Cancel",
|
|
333
|
+
status: "selection · probing " + dirName,
|
|
334
|
+
};
|
|
335
|
+
const overlay = document.createElement("div");
|
|
336
|
+
overlay.className = "qask-overlay";
|
|
337
|
+
overlay.id = "qask-overlay";
|
|
338
|
+
overlay.innerHTML = `
|
|
339
|
+
<div class="qask-modal" role="dialog" aria-modal="true">
|
|
340
|
+
<div class="qask-classification">
|
|
341
|
+
<span><span class="dot">●</span> ${t.status}</span>
|
|
342
|
+
<span class="right">${t.tag}</span>
|
|
343
|
+
</div>
|
|
344
|
+
<div class="qask-body">
|
|
345
|
+
<div class="qask-quote">
|
|
346
|
+
<div class="qask-quote-tag">${t.quoteTag}</div>
|
|
347
|
+
<div class="qask-quote-body" data-qask-quote></div>
|
|
348
|
+
</div>
|
|
349
|
+
<div class="qask-input-wrap">
|
|
350
|
+
<textarea class="qask-input" data-qask-input rows="3" placeholder="${escapeAttr(t.placeholder)}"></textarea>
|
|
351
|
+
</div>
|
|
352
|
+
</div>
|
|
353
|
+
<div class="qask-foot">
|
|
354
|
+
<button type="button" class="qask-btn" data-qask-cancel>${t.cancel}</button>
|
|
355
|
+
<button type="button" class="qask-btn primary" data-qask-send disabled>${t.send}</button>
|
|
356
|
+
</div>
|
|
357
|
+
</div>
|
|
358
|
+
`;
|
|
359
|
+
document.body.appendChild(overlay);
|
|
360
|
+
overlay.querySelector("[data-qask-quote]").textContent = sel.text;
|
|
361
|
+
const input = overlay.querySelector("[data-qask-input]");
|
|
362
|
+
const sendBtn = overlay.querySelector("[data-qask-send]");
|
|
363
|
+
setTimeout(() => input.focus(), 30);
|
|
364
|
+
input.addEventListener("input", () => {
|
|
365
|
+
sendBtn.disabled = input.value.trim().length === 0;
|
|
366
|
+
});
|
|
367
|
+
input.addEventListener("keydown", (e) => {
|
|
368
|
+
if (e.key === "Enter" && !e.shiftKey && !isImeComposing(e)) {
|
|
369
|
+
e.preventDefault();
|
|
370
|
+
if (!sendBtn.disabled) sendBtn.click();
|
|
371
|
+
} else if (e.key === "Escape") {
|
|
372
|
+
e.preventDefault();
|
|
373
|
+
closeAskOverlay();
|
|
374
|
+
}
|
|
375
|
+
});
|
|
376
|
+
overlay.addEventListener("click", (e) => {
|
|
377
|
+
if (e.target === overlay) closeAskOverlay();
|
|
378
|
+
});
|
|
379
|
+
overlay.querySelector("[data-qask-cancel]").addEventListener("click", closeAskOverlay);
|
|
380
|
+
sendBtn.addEventListener("click", () => {
|
|
381
|
+
const text = input.value.trim();
|
|
382
|
+
if (!text) return;
|
|
383
|
+
closeAskOverlay();
|
|
384
|
+
submitProbe(sel, text);
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function closeAskOverlay() {
|
|
389
|
+
const el = document.getElementById("qask-overlay");
|
|
390
|
+
if (el) el.remove();
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function isImeComposing(e) {
|
|
394
|
+
return !!(e.isComposing || (e.nativeEvent && e.nativeEvent.isComposing) || e.keyCode === 229);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function escapeAttr(s) {
|
|
398
|
+
return String(s).replace(/[&<>"']/g, (c) => ({
|
|
399
|
+
"&": "&", "<": "<", ">": ">", '"': """, "'": "'",
|
|
400
|
+
}[c]));
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// ── Submit ──────────────────────────────────────────────────
|
|
404
|
+
// Quote prefix · markdown blockquote, one "> " per line so multi-
|
|
405
|
+
// line selections stay inside the quote when rendered by app.js's
|
|
406
|
+
// markdown-ish renderBody. The attribution line (`> — @Director`)
|
|
407
|
+
// sits inside the same blockquote so chair / readers see at a
|
|
408
|
+
// glance who the user is quoting.
|
|
409
|
+
function quoteBlock(text, directorName) {
|
|
410
|
+
const lines = text.split(/\r?\n/).map((line) => "> " + line);
|
|
411
|
+
if (directorName) lines.push("> — @" + directorName);
|
|
412
|
+
return lines.join("\n");
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function submitSecond(sel) {
|
|
416
|
+
// Parliamentary acknowledgement · "I second this." · short and
|
|
417
|
+
// ceremonial, matches the boardroom motif. No mentions array — a
|
|
418
|
+
// second is a passive signal, not a question; the room continues
|
|
419
|
+
// its normal cadence rather than forcing the seconded director
|
|
420
|
+
// to immediately speak again.
|
|
421
|
+
const reaction = lang() === "zh" ? "附议。" : "Seconded.";
|
|
422
|
+
const body = quoteBlock(sel.text, sel.directorName) + "\n\n" + reaction;
|
|
423
|
+
routeSend(body, []);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function submitProbe(sel, userText) {
|
|
427
|
+
// Probe targets the quoted director · putting their id first in
|
|
428
|
+
// mentions makes them the forced speaker for the next tick (per
|
|
429
|
+
// tickRoom in src/orchestrator/room.ts), so the user's follow-up
|
|
430
|
+
// gets answered by the right voice, not whoever's next in the
|
|
431
|
+
// round-robin.
|
|
432
|
+
const body = quoteBlock(sel.text, sel.directorName) + "\n\n" + userText;
|
|
433
|
+
const mentions = sel.directorId ? [sel.directorId] : [];
|
|
434
|
+
routeSend(body, mentions);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// ── Save to Notes ─────────────────────────────────────────────
|
|
438
|
+
// POST /api/notes with quote + sentence-based context + char
|
|
439
|
+
// offsets. No room interaction — this is a personal bookmark.
|
|
440
|
+
async function submitSave(sel) {
|
|
441
|
+
const app = window.app;
|
|
442
|
+
const room = app && app.currentRoom;
|
|
443
|
+
if (!room || !room.id) {
|
|
444
|
+
toast(lang() === "zh" ? "无法保存:未打开房间" : "Can't save: no room open", "error");
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
if (!sel.messageId) {
|
|
448
|
+
toast(lang() === "zh" ? "无法保存:未识别原文位置" : "Can't save: source not identified", "error");
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
const ctx = expandContext(
|
|
452
|
+
sel.bubbleText || "",
|
|
453
|
+
typeof sel.charOffsetStart === "number" ? sel.charOffsetStart : -1,
|
|
454
|
+
typeof sel.charOffsetEnd === "number" ? sel.charOffsetEnd : -1,
|
|
455
|
+
);
|
|
456
|
+
const payload = {
|
|
457
|
+
roomId: room.id,
|
|
458
|
+
messageId: sel.messageId,
|
|
459
|
+
quoteText: sel.text,
|
|
460
|
+
contextBefore: ctx.before,
|
|
461
|
+
contextAfter: ctx.after,
|
|
462
|
+
charOffsetStart: sel.charOffsetStart,
|
|
463
|
+
charOffsetEnd: sel.charOffsetEnd,
|
|
464
|
+
authorName: sel.directorName,
|
|
465
|
+
};
|
|
466
|
+
try {
|
|
467
|
+
const res = await fetch("/api/notes", {
|
|
468
|
+
method: "POST",
|
|
469
|
+
headers: { "Content-Type": "application/json" },
|
|
470
|
+
body: JSON.stringify(payload),
|
|
471
|
+
});
|
|
472
|
+
if (!res.ok) {
|
|
473
|
+
const j = await res.json().catch(() => ({}));
|
|
474
|
+
throw new Error(j.error || ("HTTP " + res.status));
|
|
475
|
+
}
|
|
476
|
+
const note = await res.json();
|
|
477
|
+
toast(lang() === "zh" ? "已收藏到笔记" : "Saved to Notes", "ok");
|
|
478
|
+
|
|
479
|
+
// Tell the rest of the app a note was created · sidebar badge
|
|
480
|
+
// refreshes its count, in-room overlay (Step 5) wraps the
|
|
481
|
+
// saved span. Listeners that don't exist yet are no-ops.
|
|
482
|
+
try {
|
|
483
|
+
document.dispatchEvent(new CustomEvent("note:created", { detail: { note } }));
|
|
484
|
+
} catch { /* */ }
|
|
485
|
+
} catch (err) {
|
|
486
|
+
toast(
|
|
487
|
+
(lang() === "zh" ? "保存失败:" : "Save failed: ") + (err && err.message ? err.message : err),
|
|
488
|
+
"error",
|
|
489
|
+
);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// Lightweight toast · the app already has `app.notify(...)` in
|
|
494
|
+
// some paths but not all; using a self-contained one keeps this
|
|
495
|
+
// module independent. Lime for ok, red-tinted for error. Auto-
|
|
496
|
+
// dismisses after 1.8s; click to dismiss early.
|
|
497
|
+
let toastEl = null;
|
|
498
|
+
let toastTimer = null;
|
|
499
|
+
function toast(msg, kind) {
|
|
500
|
+
if (!toastEl) {
|
|
501
|
+
toastEl = document.createElement("div");
|
|
502
|
+
toastEl.className = "qcta-toast";
|
|
503
|
+
toastEl.addEventListener("click", () => toastEl.classList.remove("open"));
|
|
504
|
+
document.body.appendChild(toastEl);
|
|
505
|
+
}
|
|
506
|
+
toastEl.classList.remove("kind-ok", "kind-error");
|
|
507
|
+
toastEl.classList.add("kind-" + (kind === "error" ? "error" : "ok"));
|
|
508
|
+
toastEl.textContent = msg;
|
|
509
|
+
toastEl.classList.add("open");
|
|
510
|
+
if (toastTimer) clearTimeout(toastTimer);
|
|
511
|
+
toastTimer = setTimeout(() => {
|
|
512
|
+
if (toastEl) toastEl.classList.remove("open");
|
|
513
|
+
}, 1800);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/** Routing matrix:
|
|
517
|
+
* paused → auto-resume the room first, then send
|
|
518
|
+
* live + agent mid-turn → open the interrupt-or-queue modal
|
|
519
|
+
* live + idle → send straight through
|
|
520
|
+
*
|
|
521
|
+
* Auto-resume on paused matches the user intent: they wrote a
|
|
522
|
+
* follow-up, they expect it to land. The server rejects POST
|
|
523
|
+
* /messages on paused rooms (409 "room is not live"), so without
|
|
524
|
+
* this the probe button silently fails on every paused room. */
|
|
525
|
+
async function routeSend(body, mentions) {
|
|
526
|
+
const app = window.app;
|
|
527
|
+
if (!app || typeof app.sendMessage !== "function") return;
|
|
528
|
+
const ms = Array.isArray(mentions) ? mentions : [];
|
|
529
|
+
const status = app.currentRoom && app.currentRoom.status;
|
|
530
|
+
try {
|
|
531
|
+
if (status === "paused" && typeof app.resumeRoom === "function") {
|
|
532
|
+
await app.resumeRoom();
|
|
533
|
+
}
|
|
534
|
+
} catch (err) {
|
|
535
|
+
alert("Couldn't resume the room: " + (err && err.message ? err.message : err));
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
if (typeof app.isAgentSpeaking === "function" && app.isAgentSpeaking() && !app.pendingUserMessage) {
|
|
539
|
+
if (typeof app.openSendChoiceModal === "function") {
|
|
540
|
+
// openSendChoiceModal currently doesn't carry a mentions array
|
|
541
|
+
// — the existing modal was built for the plain composer where
|
|
542
|
+
// mentions are inferred from "@" tokens in the text. Our
|
|
543
|
+
// attributed body already contains "@Director" inline, so the
|
|
544
|
+
// server's text-based @mention path will catch it.
|
|
545
|
+
app.openSendChoiceModal(body);
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
app.sendMessage(body, ms).catch((err) => {
|
|
550
|
+
alert("Send failed: " + (err && err.message ? err.message : err));
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
})();
|