privateboard 0.1.0 → 0.1.2
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 +2060 -183
- 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 +18 -2
- package/public/app.js +513 -26
- package/public/avatar-skill.js +6 -9
- package/public/home.html +1750 -0
- package/public/{prototype-dashboard.html → index.html} +129 -116
- package/public/onboarding.js +4 -4
- package/public/quote-cta.css +225 -0
- package/public/quote-cta.js +355 -0
- package/public/report/spines/a16z-thesis.css +33 -1
- package/public/report/spines/anthropic-essay.css +54 -1
- package/public/report/spines/boardroom-dark.css +18 -2
- package/public/report/spines/gartner-note.css +47 -0
- package/public/report/spines/mckinsey-deck.css +38 -1
- package/public/report/spines/openai-paper.css +37 -1
- package/public/report.html +361 -6
- package/public/room-settings.css +6 -4
- package/public/room-settings.js +8 -5
- package/public/user-settings.css +18 -0
- package/public/user-settings.js +31 -2
package/public/app.js
CHANGED
|
@@ -8,9 +8,9 @@
|
|
|
8
8
|
─ SSE per room: /api/rooms/:id/stream
|
|
9
9
|
─ actions: createRoom · sendMessage · adjournRoom
|
|
10
10
|
|
|
11
|
-
Designed in vanilla JS (no framework) to match the rest of the
|
|
11
|
+
Designed in vanilla JS (no framework) to match the rest of the frontend.
|
|
12
12
|
Renders into named DOM containers; non-list parts (chrome / overlays) keep
|
|
13
|
-
their existing
|
|
13
|
+
their existing handlers.
|
|
14
14
|
*/
|
|
15
15
|
(function () {
|
|
16
16
|
/** Display labels for the registry's modelV ids · used to print
|
|
@@ -68,10 +68,12 @@
|
|
|
68
68
|
"Co-creator. Directors stand with you and push the idea outward — yes-and a contribution, name a concrete adjacent variant (\"what if we instead…\"), borrow pieces from another director's turn into new combinations. May end with one curious question, never a defense-demanding one.",
|
|
69
69
|
constructive:
|
|
70
70
|
"Sympathetic interrogator. They want you to win, but only via the strongest version. Each turn picks ONE load-bearing assumption and proposes the candidate stronger version that would stand. Disagreement is allowed, but every objection comes packaged with a forward path.",
|
|
71
|
+
research:
|
|
72
|
+
"Collaborative inquiry. The room mines the materials in front of it (your brief, web-search results, prior turns) for what's actually there. Each turn must cite a specific source piece, label it OBSERVATION / INFERENCE / SPECULATION, then extract the insight your lens makes salient. Defaults web search ON when a Brave key is configured.",
|
|
71
73
|
debate:
|
|
72
74
|
"Peer reviewer. Each turn opens by steelmanning your strongest claim (\"the strongest read of your point is…\") and only then attacks THAT version — naming a specific risk, demanding evidence, exposing the trade-off you're hiding. Sharp but professional. Skipping the steelman is a protocol violation.",
|
|
73
|
-
|
|
74
|
-
"
|
|
75
|
+
critique:
|
|
76
|
+
"Review board. The room audits a finished deliverable systematically — each turn names the dimension being audited (logic / evidence / scope / risk / etc.), surfaces 2–3 specific flaws labelled BLOCKER · MAJOR · MINOR, points at the load-bearing piece, and indicates the direction a fix would lie. At least one BLOCKER or MAJOR per turn is mandatory.",
|
|
75
77
|
};
|
|
76
78
|
|
|
77
79
|
const app = {
|
|
@@ -147,10 +149,86 @@
|
|
|
147
149
|
this.renderSidebarRooms();
|
|
148
150
|
this.renderSidebarAgents();
|
|
149
151
|
this.renderUserBlock();
|
|
152
|
+
// Show a friendly "storage upgraded" banner if migrations have
|
|
153
|
+
// been applied since the user last opened the app. Fire-and-forget
|
|
154
|
+
// so a slow / failed call doesn't block the dashboard rendering.
|
|
155
|
+
void this.checkMigrationNotice();
|
|
150
156
|
window.addEventListener("hashchange", () => this.handleRoute());
|
|
151
157
|
this.handleRoute();
|
|
152
158
|
},
|
|
153
159
|
|
|
160
|
+
/** Surface a one-line "storage was upgraded" notice when the user
|
|
161
|
+
* opens a build that ran new schema migrations against their
|
|
162
|
+
* existing DB. Compares the latest applied migration in the DB
|
|
163
|
+
* against the last-acknowledged name in localStorage; a fresh-
|
|
164
|
+
* install user sees nothing (no last-seen → first visit → write
|
|
165
|
+
* current latest, no banner). Dismiss writes the latest name so
|
|
166
|
+
* the banner doesn't re-show until truly-new migrations land. */
|
|
167
|
+
async checkMigrationNotice() {
|
|
168
|
+
const banner = document.querySelector("[data-sys-notice]");
|
|
169
|
+
if (!banner) return;
|
|
170
|
+
const textEl = banner.querySelector("[data-sys-notice-text]");
|
|
171
|
+
const closeBtn = banner.querySelector("[data-sys-notice-close]");
|
|
172
|
+
if (!textEl || !closeBtn) return;
|
|
173
|
+
|
|
174
|
+
let migrations = [];
|
|
175
|
+
try {
|
|
176
|
+
const r = await fetch("/api/system/migrations");
|
|
177
|
+
if (!r.ok) return;
|
|
178
|
+
const j = await r.json();
|
|
179
|
+
migrations = Array.isArray(j.migrations) ? j.migrations : [];
|
|
180
|
+
} catch { return; }
|
|
181
|
+
if (migrations.length === 0) return;
|
|
182
|
+
|
|
183
|
+
const latest = migrations[migrations.length - 1].name;
|
|
184
|
+
const KEY = "boardroom.lastSeenMigration";
|
|
185
|
+
let lastSeen = null;
|
|
186
|
+
try { lastSeen = localStorage.getItem(KEY); } catch { /* */ }
|
|
187
|
+
|
|
188
|
+
// Fresh-install (no last-seen recorded) · seed quietly with the
|
|
189
|
+
// current latest, no banner. The user hasn't been here before;
|
|
190
|
+
// showing "storage upgraded" makes no sense on first launch.
|
|
191
|
+
if (!lastSeen) {
|
|
192
|
+
try { localStorage.setItem(KEY, latest); } catch { /* */ }
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
if (lastSeen === latest) return;
|
|
196
|
+
|
|
197
|
+
// Find migrations newer than lastSeen — by index in the list,
|
|
198
|
+
// since order is applied_at ASC.
|
|
199
|
+
const lastIdx = migrations.findIndex((m) => m.name === lastSeen);
|
|
200
|
+
const fresh = lastIdx >= 0 ? migrations.slice(lastIdx + 1) : migrations;
|
|
201
|
+
if (fresh.length === 0) {
|
|
202
|
+
try { localStorage.setItem(KEY, latest); } catch { /* */ }
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const lang = (this.composerLanguage && this.composerLanguage()) || "en";
|
|
207
|
+
const count = fresh.length;
|
|
208
|
+
const names = fresh.map((m) => m.name).join(", ");
|
|
209
|
+
const copy = lang === "zh"
|
|
210
|
+
? {
|
|
211
|
+
head: `存储结构已升级`,
|
|
212
|
+
body: `已应用 ${count} 个新迁移 · 你已有的房间、董事、报告、设置都已保留。`,
|
|
213
|
+
tooltip: names,
|
|
214
|
+
}
|
|
215
|
+
: {
|
|
216
|
+
head: `Storage upgraded`,
|
|
217
|
+
body: `${count} new migration${count > 1 ? "s" : ""} applied · your existing rooms, agents, briefs, and settings were preserved.`,
|
|
218
|
+
tooltip: names,
|
|
219
|
+
};
|
|
220
|
+
textEl.innerHTML =
|
|
221
|
+
`<span class="sys-notice-strong">${this.escape(copy.head)}</span> · ${this.escape(copy.body)}`;
|
|
222
|
+
banner.title = copy.tooltip;
|
|
223
|
+
banner.removeAttribute("hidden");
|
|
224
|
+
|
|
225
|
+
const dismiss = () => {
|
|
226
|
+
try { localStorage.setItem(KEY, latest); } catch { /* */ }
|
|
227
|
+
banner.setAttribute("hidden", "");
|
|
228
|
+
};
|
|
229
|
+
closeBtn.addEventListener("click", dismiss, { once: true });
|
|
230
|
+
},
|
|
231
|
+
|
|
154
232
|
/** Refetch /api/keys and update the local cache. Called by
|
|
155
233
|
* user-settings on close so the requireModelKey gate sees the
|
|
156
234
|
* user's just-configured keys without a full page reload. */
|
|
@@ -562,6 +640,10 @@
|
|
|
562
640
|
document.documentElement.setAttribute("data-status", "live");
|
|
563
641
|
this.renderHeader();
|
|
564
642
|
syncSidebar({ status: "live", pausedAt: null });
|
|
643
|
+
// Drop any paused-supplement overlay · the supplement endpoint
|
|
644
|
+
// 409s once the room is live, so leaving the modal up is just
|
|
645
|
+
// a confusing no-op for the user.
|
|
646
|
+
this.closePausedSupplementOverlay?.();
|
|
565
647
|
} else if (kind === "room-adjourned") {
|
|
566
648
|
const ts = payload.adjournedAt || Date.now();
|
|
567
649
|
if (this.currentRoom) {
|
|
@@ -572,6 +654,7 @@
|
|
|
572
654
|
this.renderHeader();
|
|
573
655
|
syncSidebar({ status: "adjourned", adjournedAt: ts });
|
|
574
656
|
} else if (kind === "brief-started") {
|
|
657
|
+
this.markBriefEvent();
|
|
575
658
|
this.currentBrief = {
|
|
576
659
|
id: payload.briefId,
|
|
577
660
|
title: "Generating…",
|
|
@@ -582,6 +665,7 @@
|
|
|
582
665
|
// language is inferred server-side from the room subject.
|
|
583
666
|
chairName: payload.chairName || (this.currentChair?.name) || "Chair",
|
|
584
667
|
language: payload.language === "zh" ? "zh" : "en",
|
|
668
|
+
pipelineStartedAt: Date.now(),
|
|
585
669
|
// Stage checklist · seeded with all three stages in pending
|
|
586
670
|
// state. brief-stage events flip them active → done as the
|
|
587
671
|
// pipeline progresses. startedAt is captured when each
|
|
@@ -597,10 +681,16 @@
|
|
|
597
681
|
this.renderBrief();
|
|
598
682
|
// Start the per-second tick driving elapsed/substage animation.
|
|
599
683
|
this.ensureBriefStageTick();
|
|
684
|
+
// Heartbeat watcher — surfaces Retry on stall / timeout.
|
|
685
|
+
this.ensureBriefStallWatch();
|
|
600
686
|
// Surface the View Report button + hide the no-brief CTA.
|
|
601
687
|
this.renderHeader();
|
|
602
688
|
this.renderChat();
|
|
689
|
+
// Pull the user's eye onto the freshly-mounted card so the
|
|
690
|
+
// click that triggered generation has visible feedback.
|
|
691
|
+
this.scrollToBriefCard();
|
|
603
692
|
} else if (kind === "brief-stage") {
|
|
693
|
+
this.markBriefEvent();
|
|
604
694
|
if (this.currentBrief) {
|
|
605
695
|
const st = this.currentBrief.stages || (this.currentBrief.stages = {
|
|
606
696
|
extract: { status: "pending", detail: "", progress: null, startedAt: null, etaSec: null },
|
|
@@ -615,6 +705,14 @@
|
|
|
615
705
|
if (newStatus === "active" && st[key].status !== "active") {
|
|
616
706
|
st[key].startedAt = Date.now();
|
|
617
707
|
}
|
|
708
|
+
// Capture finish time when the stage transitions to done
|
|
709
|
+
// so the displayed elapsed freezes at completion. Without
|
|
710
|
+
// this, the per-stage timer kept ticking off Date.now()
|
|
711
|
+
// forever — the user couldn't read each stage's actual
|
|
712
|
+
// duration at a glance.
|
|
713
|
+
if (newStatus === "done" && st[key].status !== "done" && !st[key].finishedAt) {
|
|
714
|
+
st[key].finishedAt = Date.now();
|
|
715
|
+
}
|
|
618
716
|
st[key].status = newStatus;
|
|
619
717
|
st[key].detail = payload.detail || "";
|
|
620
718
|
st[key].progress = payload.progress || null;
|
|
@@ -632,6 +730,7 @@
|
|
|
632
730
|
this.ensureBriefStageTick();
|
|
633
731
|
}
|
|
634
732
|
} else if (kind === "brief-token") {
|
|
733
|
+
this.markBriefEvent();
|
|
635
734
|
// Accumulate the body. Throttle the re-render to once per ~250ms
|
|
636
735
|
// so the writing-stage word count animates without thrashing on
|
|
637
736
|
// every chunk.
|
|
@@ -643,10 +742,12 @@
|
|
|
643
742
|
this.renderBrief();
|
|
644
743
|
}
|
|
645
744
|
} else if (kind === "brief-final") {
|
|
745
|
+
this.markBriefEvent();
|
|
646
746
|
if (this.currentBrief) {
|
|
647
747
|
this.currentBrief.title = payload.title || this.currentBrief.title;
|
|
648
748
|
}
|
|
649
749
|
this.stopBriefStageTick();
|
|
750
|
+
this.stopBriefStallWatch();
|
|
650
751
|
this.renderBrief();
|
|
651
752
|
this.renderHeader();
|
|
652
753
|
// Refresh the FULL brief list so the tab strip picks up the
|
|
@@ -670,8 +771,10 @@
|
|
|
670
771
|
.catch(() => {});
|
|
671
772
|
}
|
|
672
773
|
} else if (kind === "brief-error") {
|
|
774
|
+
this.markBriefEvent();
|
|
673
775
|
if (this.currentBrief) this.currentBrief.error = payload.message;
|
|
674
776
|
this.stopBriefStageTick();
|
|
777
|
+
this.stopBriefStallWatch();
|
|
675
778
|
this.renderBrief();
|
|
676
779
|
} else if (kind === "settings-changed") {
|
|
677
780
|
const ch = payload.changes || {};
|
|
@@ -820,6 +923,9 @@
|
|
|
820
923
|
try { this.sse.close(); } catch (e) { /* */ }
|
|
821
924
|
this.sse = null;
|
|
822
925
|
}
|
|
926
|
+
// Drop the brief watcher · the brief belongs to a room and the
|
|
927
|
+
// watcher would otherwise keep ticking against a stale id.
|
|
928
|
+
this.stopBriefStallWatch();
|
|
823
929
|
},
|
|
824
930
|
|
|
825
931
|
// ── Actions ───────────────────────────────────────────────
|
|
@@ -1346,6 +1452,146 @@
|
|
|
1346
1452
|
}
|
|
1347
1453
|
},
|
|
1348
1454
|
|
|
1455
|
+
/** Paused-supplement overlay · lets the user drop in an extra
|
|
1456
|
+
* thought while the room is paused. The text is posted as a
|
|
1457
|
+
* user message immediately (lands in the chat as the freshest
|
|
1458
|
+
* user input) but the saved director queue is left untouched —
|
|
1459
|
+
* so when they click Resume, the previously-paused director
|
|
1460
|
+
* takes over with the supplement already in their context.
|
|
1461
|
+
* Effectively the supplement plays "first" in the resumed
|
|
1462
|
+
* flow, and the rest of the queue continues in order. Reuses
|
|
1463
|
+
* the existing .supplement-* CSS classes for visual parity. */
|
|
1464
|
+
openPausedSupplementOverlay() {
|
|
1465
|
+
if (!this.currentRoomId || !this.currentRoom) return;
|
|
1466
|
+
if (this.currentRoom.status !== "paused") return;
|
|
1467
|
+
this.closePausedSupplementOverlay();
|
|
1468
|
+
const lang = this.composerLanguage();
|
|
1469
|
+
const t = lang === "zh"
|
|
1470
|
+
? {
|
|
1471
|
+
classify: "room · 暂停时补充",
|
|
1472
|
+
classifyRight: "// queued first",
|
|
1473
|
+
title: "补充一个观点",
|
|
1474
|
+
metaPrefix: "// 当前房间",
|
|
1475
|
+
placeholder: "想补一个观点 · 一个想再追问的细节 · 一个让董事们重新考虑的角度。\n\n会立即作为你的发言进入对话;点击 [ Resume ] 后,董事们会先看到这条再继续。",
|
|
1476
|
+
hint: "暂停期间的补充会以你的身份立即出现在对话里,原本的发言队列不变;恢复后队首董事将带着这条补充开口。",
|
|
1477
|
+
cancel: "[ Cancel ]",
|
|
1478
|
+
confirm: "[ Add to chat ]",
|
|
1479
|
+
confirmBusy: "[ Posting… ]",
|
|
1480
|
+
}
|
|
1481
|
+
: {
|
|
1482
|
+
classify: "room · paused supplement",
|
|
1483
|
+
classifyRight: "// queued first",
|
|
1484
|
+
title: "Add a supplemental input",
|
|
1485
|
+
metaPrefix: "// Current room",
|
|
1486
|
+
placeholder: "Drop in an extra thought, a follow-up question, or an angle you'd like the board to take into account.\n\nIt lands in the chat as your message right now; when you hit [ Resume ], the next director picks up with this in front of them.",
|
|
1487
|
+
hint: "Posted while paused, the supplement lands as your message immediately; the saved speaker queue is untouched. After resume, the next director responds with the supplement first.",
|
|
1488
|
+
cancel: "[ Cancel ]",
|
|
1489
|
+
confirm: "[ Add to chat ]",
|
|
1490
|
+
confirmBusy: "[ Posting… ]",
|
|
1491
|
+
};
|
|
1492
|
+
const subject = (this.currentRoom.subject || "").trim() || (lang === "zh" ? "(无主题)" : "(no subject)");
|
|
1493
|
+
const html = `
|
|
1494
|
+
<div class="supplement-overlay" id="paused-supplement-overlay" role="dialog" aria-modal="true">
|
|
1495
|
+
<div class="supplement-backdrop" data-paused-supplement-close></div>
|
|
1496
|
+
<div class="supplement-modal" role="document">
|
|
1497
|
+
<div class="supplement-classification">
|
|
1498
|
+
<span><span class="dot">●</span> ${this.escape(t.classify)}</span>
|
|
1499
|
+
<span class="right">${this.escape(t.classifyRight)}</span>
|
|
1500
|
+
</div>
|
|
1501
|
+
<header class="supplement-head">
|
|
1502
|
+
<div>
|
|
1503
|
+
<div class="meta">${this.escape(t.metaPrefix)} · <span>${this.escape(subject)}</span></div>
|
|
1504
|
+
<div class="title">${this.escape(t.title)}</div>
|
|
1505
|
+
</div>
|
|
1506
|
+
<button type="button" class="supplement-close" data-paused-supplement-close aria-label="Close">✕</button>
|
|
1507
|
+
</header>
|
|
1508
|
+
<div class="supplement-body">
|
|
1509
|
+
<textarea class="supplement-input" data-paused-supplement-input rows="6" placeholder="${this.escape(t.placeholder)}"></textarea>
|
|
1510
|
+
<p class="supplement-hint">${this.escape(t.hint)}</p>
|
|
1511
|
+
</div>
|
|
1512
|
+
<footer class="supplement-foot">
|
|
1513
|
+
<button type="button" class="supplement-cancel" data-paused-supplement-close>${this.escape(t.cancel)}</button>
|
|
1514
|
+
<button type="button" class="supplement-confirm" data-paused-supplement-confirm data-busy-label="${this.escape(t.confirmBusy)}">${this.escape(t.confirm)}</button>
|
|
1515
|
+
</footer>
|
|
1516
|
+
</div>
|
|
1517
|
+
</div>
|
|
1518
|
+
`;
|
|
1519
|
+
const wrap = document.createElement("div");
|
|
1520
|
+
wrap.innerHTML = html.trim();
|
|
1521
|
+
document.body.appendChild(wrap.firstChild);
|
|
1522
|
+
document.body.style.overflow = "hidden";
|
|
1523
|
+
this._pausedSupplementEsc = (ev) => {
|
|
1524
|
+
if (ev.key === "Escape") {
|
|
1525
|
+
ev.stopImmediatePropagation();
|
|
1526
|
+
this.closePausedSupplementOverlay();
|
|
1527
|
+
}
|
|
1528
|
+
};
|
|
1529
|
+
document.addEventListener("keydown", this._pausedSupplementEsc, true);
|
|
1530
|
+
// Cmd/Ctrl-Enter submits — long-form textarea convention.
|
|
1531
|
+
this._pausedSupplementSubmit = (ev) => {
|
|
1532
|
+
if ((ev.metaKey || ev.ctrlKey) && ev.key === "Enter") {
|
|
1533
|
+
const overlay = document.getElementById("paused-supplement-overlay");
|
|
1534
|
+
if (!overlay) return;
|
|
1535
|
+
ev.preventDefault();
|
|
1536
|
+
this.submitPausedSupplement();
|
|
1537
|
+
}
|
|
1538
|
+
};
|
|
1539
|
+
document.addEventListener("keydown", this._pausedSupplementSubmit, true);
|
|
1540
|
+
setTimeout(() => {
|
|
1541
|
+
const input = document.querySelector("[data-paused-supplement-input]");
|
|
1542
|
+
if (input) input.focus();
|
|
1543
|
+
}, 30);
|
|
1544
|
+
},
|
|
1545
|
+
|
|
1546
|
+
closePausedSupplementOverlay() {
|
|
1547
|
+
const el = document.getElementById("paused-supplement-overlay");
|
|
1548
|
+
if (el) el.remove();
|
|
1549
|
+
document.body.style.overflow = "";
|
|
1550
|
+
if (this._pausedSupplementEsc) {
|
|
1551
|
+
document.removeEventListener("keydown", this._pausedSupplementEsc, true);
|
|
1552
|
+
this._pausedSupplementEsc = null;
|
|
1553
|
+
}
|
|
1554
|
+
if (this._pausedSupplementSubmit) {
|
|
1555
|
+
document.removeEventListener("keydown", this._pausedSupplementSubmit, true);
|
|
1556
|
+
this._pausedSupplementSubmit = null;
|
|
1557
|
+
}
|
|
1558
|
+
},
|
|
1559
|
+
|
|
1560
|
+
async submitPausedSupplement() {
|
|
1561
|
+
const overlay = document.getElementById("paused-supplement-overlay");
|
|
1562
|
+
if (!overlay) return;
|
|
1563
|
+
const input = overlay.querySelector("[data-paused-supplement-input]");
|
|
1564
|
+
const btn = overlay.querySelector("[data-paused-supplement-confirm]");
|
|
1565
|
+
const text = input ? (input.value || "").trim() : "";
|
|
1566
|
+
if (!text) {
|
|
1567
|
+
if (input) input.focus();
|
|
1568
|
+
return;
|
|
1569
|
+
}
|
|
1570
|
+
if (!this.currentRoomId) return;
|
|
1571
|
+
const origLabel = btn ? btn.textContent : "";
|
|
1572
|
+
const busyLabel = btn ? btn.getAttribute("data-busy-label") || origLabel : "";
|
|
1573
|
+
if (btn) { btn.disabled = true; btn.textContent = busyLabel; }
|
|
1574
|
+
try {
|
|
1575
|
+
const r = await fetch(
|
|
1576
|
+
"/api/rooms/" + encodeURIComponent(this.currentRoomId) + "/paused-input",
|
|
1577
|
+
{
|
|
1578
|
+
method: "POST",
|
|
1579
|
+
headers: { "content-type": "application/json" },
|
|
1580
|
+
body: JSON.stringify({ body: text }),
|
|
1581
|
+
},
|
|
1582
|
+
);
|
|
1583
|
+
if (!r.ok) {
|
|
1584
|
+
const e = await r.json().catch(() => ({}));
|
|
1585
|
+
throw new Error(e.error || ("HTTP " + r.status));
|
|
1586
|
+
}
|
|
1587
|
+
// SSE will push the message-appended event; chat updates itself.
|
|
1588
|
+
this.closePausedSupplementOverlay();
|
|
1589
|
+
} catch (e) {
|
|
1590
|
+
if (btn) { btn.disabled = false; btn.textContent = origLabel; }
|
|
1591
|
+
alert("Add input failed: " + (e && e.message ? e.message : e));
|
|
1592
|
+
}
|
|
1593
|
+
},
|
|
1594
|
+
|
|
1349
1595
|
/** Confirm-handler · grabs the textarea, posts to the brief endpoint,
|
|
1350
1596
|
* closes the overlay. Server emits brief-started + brief-* SSE
|
|
1351
1597
|
* events as for a normal generate; the existing handlers replace
|
|
@@ -1433,6 +1679,7 @@
|
|
|
1433
1679
|
// Orphan. Flip into the error UI which now carries a retry button.
|
|
1434
1680
|
brief.error = "interrupted";
|
|
1435
1681
|
brief.interrupted = true;
|
|
1682
|
+
this.stopBriefStallWatch();
|
|
1436
1683
|
this.renderBrief();
|
|
1437
1684
|
return;
|
|
1438
1685
|
}
|
|
@@ -1440,6 +1687,7 @@
|
|
|
1440
1687
|
this.hydrateBriefStagesFromState(brief, j.state);
|
|
1441
1688
|
this.renderBrief();
|
|
1442
1689
|
this.ensureBriefStageTick();
|
|
1690
|
+
this.ensureBriefStallWatch();
|
|
1443
1691
|
}
|
|
1444
1692
|
} catch { /* ignore — leave the loading state */ }
|
|
1445
1693
|
},
|
|
@@ -1544,10 +1792,15 @@
|
|
|
1544
1792
|
if (this.currentBrief) {
|
|
1545
1793
|
this.currentBrief.error = null;
|
|
1546
1794
|
this.currentBrief.interrupted = false;
|
|
1795
|
+
this.currentBrief.timedOut = false;
|
|
1547
1796
|
this.currentBrief.bodyMd = "";
|
|
1548
1797
|
this.currentBrief.title = "Generating…";
|
|
1798
|
+
this.currentBrief.pipelineStartedAt = Date.now();
|
|
1549
1799
|
}
|
|
1800
|
+
this._lastBriefEventAt = Date.now();
|
|
1801
|
+
this._lastBriefHealthPollAt = 0;
|
|
1550
1802
|
this.renderBrief();
|
|
1803
|
+
this.scrollToBriefCard();
|
|
1551
1804
|
} catch (e) {
|
|
1552
1805
|
alert("Regenerate failed: " + (e && e.message ? e.message : e));
|
|
1553
1806
|
}
|
|
@@ -2126,6 +2379,18 @@
|
|
|
2126
2379
|
out.push(`<ul>${items.join("")}</ul>`);
|
|
2127
2380
|
continue;
|
|
2128
2381
|
}
|
|
2382
|
+
// Markdown blockquote · every non-empty line starts with `> `
|
|
2383
|
+
// (escaped from `> `). The whole block becomes one <blockquote>;
|
|
2384
|
+
// styling lives in CSS (.msg-bubble blockquote · designed
|
|
2385
|
+
// quote-card with mono kicker + italic body, no left border).
|
|
2386
|
+
if (lines.every((l) => /^>\s?/.test(l) || l.trim() === "")) {
|
|
2387
|
+
const inner = lines
|
|
2388
|
+
.filter((l) => l.trim())
|
|
2389
|
+
.map((l) => this.inline(l.replace(/^>\s?/, "")))
|
|
2390
|
+
.join("<br>");
|
|
2391
|
+
out.push(`<blockquote class="msg-quote">${inner}</blockquote>`);
|
|
2392
|
+
continue;
|
|
2393
|
+
}
|
|
2129
2394
|
// Otherwise: paragraph (preserve single newlines as <br>).
|
|
2130
2395
|
out.push(`<p>${this.inline(lines.join("<br>"))}</p>`);
|
|
2131
2396
|
}
|
|
@@ -2528,8 +2793,6 @@
|
|
|
2528
2793
|
if (nm) nm.textContent = name;
|
|
2529
2794
|
const mt = document.querySelector("[data-user-meta]");
|
|
2530
2795
|
if (mt) mt.textContent = meta;
|
|
2531
|
-
const menuName = document.querySelector("[data-user-menu-name]");
|
|
2532
|
-
if (menuName) menuName.textContent = name;
|
|
2533
2796
|
},
|
|
2534
2797
|
|
|
2535
2798
|
renderSidebarCounts() {
|
|
@@ -2569,6 +2832,10 @@
|
|
|
2569
2832
|
? this.escape(nextSpeaker.handle.replace(/^\//, ""))
|
|
2570
2833
|
: "—";
|
|
2571
2834
|
|
|
2835
|
+
const lang = this.composerLanguage();
|
|
2836
|
+
const addInputLabel = lang === "zh" ? "[ + 补充观点 ]" : "[ + Add input ]";
|
|
2837
|
+
const adjournLabel = lang === "zh" ? "[ ▸ 结束并存档 ]" : "[ ▸ Adjourn & File Brief ]";
|
|
2838
|
+
const resumeLabel = lang === "zh" ? "[ ▶ 恢复讨论 ]" : "[ ▶ Resume Discussion ]";
|
|
2572
2839
|
bar.innerHTML = `
|
|
2573
2840
|
<div class="paused-bar-text">
|
|
2574
2841
|
<strong>// discussion paused.</strong>
|
|
@@ -2576,8 +2843,9 @@
|
|
|
2576
2843
|
next turn · <span class="lime">${nextHandle}</span>.
|
|
2577
2844
|
</div>
|
|
2578
2845
|
<div class="paused-bar-actions">
|
|
2579
|
-
<a href="#" class="ghost-btn" data-
|
|
2580
|
-
<a href="#" class="
|
|
2846
|
+
<a href="#" class="ghost-btn" data-paused-supplement>${this.escape(addInputLabel)}</a>
|
|
2847
|
+
<a href="#" class="ghost-btn" data-adjourn>${this.escape(adjournLabel)}</a>
|
|
2848
|
+
<a href="#" class="resume-btn-lg" data-resume>${this.escape(resumeLabel)}</a>
|
|
2581
2849
|
</div>
|
|
2582
2850
|
`;
|
|
2583
2851
|
},
|
|
@@ -2603,7 +2871,7 @@
|
|
|
2603
2871
|
this.composerState = {
|
|
2604
2872
|
...this.DEFAULT_COMPOSER,
|
|
2605
2873
|
...(saved || {}),
|
|
2606
|
-
|
|
2874
|
+
subject: (saved && typeof saved.subject === "string") ? saved.subject : "",
|
|
2607
2875
|
};
|
|
2608
2876
|
return this.composerState;
|
|
2609
2877
|
},
|
|
@@ -2611,14 +2879,33 @@
|
|
|
2611
2879
|
saveComposerState() {
|
|
2612
2880
|
if (!this.composerState) return;
|
|
2613
2881
|
try {
|
|
2614
|
-
const { directorIds, mode, intensity, autoPickDirectors } = this.composerState;
|
|
2882
|
+
const { directorIds, mode, intensity, autoPickDirectors, subject } = this.composerState;
|
|
2615
2883
|
localStorage.setItem(
|
|
2616
2884
|
"boardroom.composer",
|
|
2617
|
-
JSON.stringify({ directorIds, mode, intensity, autoPickDirectors }),
|
|
2885
|
+
JSON.stringify({ directorIds, mode, intensity, autoPickDirectors, subject }),
|
|
2618
2886
|
);
|
|
2619
2887
|
} catch { /* ignore */ }
|
|
2620
2888
|
},
|
|
2621
2889
|
|
|
2890
|
+
/** Agent composer draft · the description textarea on "+ New Agent".
|
|
2891
|
+
* Persisted independently of composerState so the two screens don't
|
|
2892
|
+
* share fields (composerState is room-shaped). Survives view
|
|
2893
|
+
* switches and full app reloads; cleared after a successful save. */
|
|
2894
|
+
loadAgentComposerDraft() {
|
|
2895
|
+
try {
|
|
2896
|
+
const raw = localStorage.getItem("boardroom.agent-composer.draft");
|
|
2897
|
+
return typeof raw === "string" ? raw : "";
|
|
2898
|
+
} catch { return ""; }
|
|
2899
|
+
},
|
|
2900
|
+
saveAgentComposerDraft(text) {
|
|
2901
|
+
try { localStorage.setItem("boardroom.agent-composer.draft", String(text || "")); }
|
|
2902
|
+
catch { /* ignore */ }
|
|
2903
|
+
},
|
|
2904
|
+
clearAgentComposerDraft() {
|
|
2905
|
+
try { localStorage.removeItem("boardroom.agent-composer.draft"); }
|
|
2906
|
+
catch { /* ignore */ }
|
|
2907
|
+
},
|
|
2908
|
+
|
|
2622
2909
|
/** Whether the composer is in auto-pick mode for the cast · default
|
|
2623
2910
|
* is true (chair picks 3 directors based on subject when the user
|
|
2624
2911
|
* hits Convene). Flips to false the moment the user manually
|
|
@@ -3167,7 +3454,7 @@
|
|
|
3167
3454
|
</header>
|
|
3168
3455
|
|
|
3169
3456
|
<div class="cmp-input-frame">
|
|
3170
|
-
<textarea class="cmp-input" data-composer-subject rows="1" placeholder="${this.escape(t.placeholder)}"
|
|
3457
|
+
<textarea class="cmp-input" data-composer-subject rows="1" placeholder="${this.escape(t.placeholder)}">${this.escape(state.subject || "")}</textarea>
|
|
3171
3458
|
|
|
3172
3459
|
<div class="cmp-toolbar">
|
|
3173
3460
|
<button type="button" class="cmp-cast-btn${isAutoPick ? " cmp-cast-btn-auto" : ""}" data-composer-dir-pick title="${this.escape(t.pickerLabel)}">
|
|
@@ -3560,7 +3847,7 @@
|
|
|
3560
3847
|
{ tag: "user-empathy", text: "A product hand who reasons from the user's moment of friction. Refuses any argument that doesn't name what the user is doing right then." },
|
|
3561
3848
|
{ tag: "first-principles", text: "A physicist who strips problems to observables and causal chains. Refuses to import assumptions from analogy." },
|
|
3562
3849
|
{ tag: "value-investor", text: "A long-pattern reader who tests every novel idea against thirty years of category history before believing it." },
|
|
3563
|
-
{ tag: "
|
|
3850
|
+
{ tag: "critique-reviewer", text: "A senior critic who audits any deliverable systematically — labels each flaw blocker / major / minor, points at the load-bearing piece, names the mechanism. Won't praise without finding at least one major issue." },
|
|
3564
3851
|
{ tag: "phenomenologist", text: "An observer who notices what the room ISN'T saying. Tracks tone, what got skipped, who agreed too fast." },
|
|
3565
3852
|
],
|
|
3566
3853
|
AGENT_STARTERS_ZH: [
|
|
@@ -3568,7 +3855,7 @@
|
|
|
3568
3855
|
{ tag: "user-empathy", text: "一位从用户摩擦时刻反推的产品老兵,反对任何不说清『用户那一刻在干嘛』的论点。" },
|
|
3569
3856
|
{ tag: "first-principles", text: "一位把问题拆到可观测、因果链上的物理学家,拒绝从类比里搬假设。" },
|
|
3570
3857
|
{ tag: "value-investor", text: "一位用三十年品类史做底的长周期读者,新点子要先和三个老案例对照才相信。" },
|
|
3571
|
-
{ tag: "
|
|
3858
|
+
{ tag: "critique-reviewer", text: "一位资深评审,对任何交付物做系统性审稿——每个瑕疵打 blocker / major / minor 严重度,指向具体段落、说出失败机制。不挑出至少一条 major 不会放过。" },
|
|
3572
3859
|
{ tag: "phenomenologist", text: "一位观察者,捕捉房间里没说出来的东西:语气、被跳过的话题、太快达成的一致。" },
|
|
3573
3860
|
],
|
|
3574
3861
|
|
|
@@ -3622,7 +3909,7 @@
|
|
|
3622
3909
|
</header>
|
|
3623
3910
|
|
|
3624
3911
|
<div class="cmp-input-frame ${generating ? "is-generating" : ""}">
|
|
3625
|
-
<textarea class="cmp-input" data-agent-composer-desc rows="1" placeholder="${this.escape(t.placeholder)}" ${generating ? "disabled" : ""}
|
|
3912
|
+
<textarea class="cmp-input" data-agent-composer-desc rows="1" placeholder="${this.escape(t.placeholder)}" ${generating ? "disabled" : ""}>${this.escape(this.loadAgentComposerDraft())}</textarea>
|
|
3626
3913
|
|
|
3627
3914
|
<div class="cmp-toolbar">
|
|
3628
3915
|
<button type="button" class="cmp-dd" data-cmp-dropdown="agent-model" title="${this.escape(t.modelLabel)}">
|
|
@@ -3984,6 +4271,9 @@
|
|
|
3984
4271
|
await this.refreshAgents?.();
|
|
3985
4272
|
this.agentSpec = null;
|
|
3986
4273
|
this.agentSpecAvatarSeed = null;
|
|
4274
|
+
// Clear the saved description draft now that the agent exists —
|
|
4275
|
+
// a future visit to "+ New Agent" should land on a fresh textarea.
|
|
4276
|
+
this.clearAgentComposerDraft();
|
|
3987
4277
|
this.composerMode = "room";
|
|
3988
4278
|
// POST /api/agents returns the agent record directly (not wrapped).
|
|
3989
4279
|
const newId = j && (j.id || (j.agent && j.agent.id));
|
|
@@ -4199,14 +4489,16 @@
|
|
|
4199
4489
|
? [
|
|
4200
4490
|
{ v: "brainstorm", label: "Brainstorm", hint: "共同发散" },
|
|
4201
4491
|
{ v: "constructive", label: "Constructive", hint: "推一把" },
|
|
4492
|
+
{ v: "research", label: "Research", hint: "梳理材料找洞察" },
|
|
4202
4493
|
{ v: "debate", label: "Debate", hint: "找漏洞" },
|
|
4203
|
-
{ v: "
|
|
4494
|
+
{ v: "critique", label: "Critique", hint: "系统性挑毛病" },
|
|
4204
4495
|
]
|
|
4205
4496
|
: [
|
|
4206
4497
|
{ v: "brainstorm", label: "Brainstorm", hint: "yes-and" },
|
|
4207
4498
|
{ v: "constructive", label: "Constructive", hint: "push & sharpen" },
|
|
4499
|
+
{ v: "research", label: "Research", hint: "mine the material" },
|
|
4208
4500
|
{ v: "debate", label: "Debate", hint: "find the holes" },
|
|
4209
|
-
{ v: "
|
|
4501
|
+
{ v: "critique", label: "Critique", hint: "audit the deliverable" },
|
|
4210
4502
|
];
|
|
4211
4503
|
current = state.mode;
|
|
4212
4504
|
} else if (kind === "intensity") {
|
|
@@ -4371,6 +4663,11 @@
|
|
|
4371
4663
|
intensity: state.intensity,
|
|
4372
4664
|
autoPick: useAutoPick,
|
|
4373
4665
|
});
|
|
4666
|
+
// Clear the saved draft now that the room is convened — next
|
|
4667
|
+
// visit to "+ New Room" should land on a fresh textarea, not
|
|
4668
|
+
// re-show the just-submitted subject.
|
|
4669
|
+
state.subject = "";
|
|
4670
|
+
this.saveComposerState();
|
|
4374
4671
|
} catch (e) {
|
|
4375
4672
|
if (btn) btn.classList.remove("busy");
|
|
4376
4673
|
alert("Couldn't convene: " + (e && e.message ? e.message : e));
|
|
@@ -4391,13 +4688,15 @@
|
|
|
4391
4688
|
if (want.length) state.directorIds = want;
|
|
4392
4689
|
if (q.tone) state.mode = q.tone;
|
|
4393
4690
|
if (q.intensity) state.intensity = q.intensity;
|
|
4691
|
+
// Write the starter text into the persisted draft so it survives
|
|
4692
|
+
// a navigation away and back, just like manual typing does.
|
|
4693
|
+
state.subject = q.text || "";
|
|
4394
4694
|
this.saveComposerState();
|
|
4395
4695
|
// Re-render to reflect the new selections; keep autofocus at end of subject.
|
|
4396
4696
|
this.renderEmptyState();
|
|
4397
4697
|
setTimeout(() => {
|
|
4398
4698
|
const ta = document.querySelector("[data-composer-subject]");
|
|
4399
4699
|
if (ta) {
|
|
4400
|
-
ta.value = q.text || "";
|
|
4401
4700
|
ta.focus();
|
|
4402
4701
|
ta.setSelectionRange(ta.value.length, ta.value.length);
|
|
4403
4702
|
this.autosizeComposerTextarea();
|
|
@@ -5380,8 +5679,14 @@
|
|
|
5380
5679
|
? `<div class="msg-chair-pick" title="Chair picked ${this.escape(name)} for this turn">▸ chair · ${this.escape(chairPick)}</div>`
|
|
5381
5680
|
: "";
|
|
5382
5681
|
|
|
5682
|
+
// data-author-id is only attached for director messages (not user
|
|
5683
|
+
// / not chair) — read by quote-cta.js to credit the director when
|
|
5684
|
+
// the user probes / seconds a passage from this bubble.
|
|
5685
|
+
const authorIdAttr = (!isUser && !isChair && author?.id)
|
|
5686
|
+
? ` data-author-id="${this.escape(author.id)}"`
|
|
5687
|
+
: "";
|
|
5383
5688
|
return `
|
|
5384
|
-
<article class="msg ${baseCls}${stateCls.length ? " " + stateCls.join(" ") : ""}" data-message-id="${this.escape(m.id)}" data-meta-kind="${this.escape(metaKind || "")}">
|
|
5689
|
+
<article class="msg ${baseCls}${stateCls.length ? " " + stateCls.join(" ") : ""}" data-message-id="${this.escape(m.id)}" data-meta-kind="${this.escape(metaKind || "")}"${authorIdAttr}>
|
|
5385
5690
|
${avatarHtml}
|
|
5386
5691
|
<div class="msg-content">
|
|
5387
5692
|
${chairPickKicker}
|
|
@@ -5529,7 +5834,7 @@
|
|
|
5529
5834
|
}
|
|
5530
5835
|
|
|
5531
5836
|
// Update the collapsed summary alongside the expanded list so the
|
|
5532
|
-
// collapsed strip never shows stale
|
|
5837
|
+
// collapsed strip never shows stale text.
|
|
5533
5838
|
this.renderQueueCollapsed(renderItems);
|
|
5534
5839
|
|
|
5535
5840
|
if (renderItems.length === 0) {
|
|
@@ -5611,14 +5916,32 @@
|
|
|
5611
5916
|
card.classList.add("ending-block");
|
|
5612
5917
|
const b = this.currentBrief;
|
|
5613
5918
|
|
|
5614
|
-
// Error path: a compact error card with a retry button.
|
|
5919
|
+
// Error path: a compact error card with a retry button. Three
|
|
5615
5920
|
// sub-cases:
|
|
5921
|
+
// · timedOut (no completion after 5 min wall-clock) → "took
|
|
5922
|
+
// too long" copy with the elapsed-time reason inline
|
|
5616
5923
|
// · interrupted (zombie placeholder from a refresh / restart) →
|
|
5617
5924
|
// specific copy + Regenerate CTA
|
|
5618
5925
|
// · generic LLM failure → original "needs an API key" hint
|
|
5619
5926
|
if (b.error) {
|
|
5620
5927
|
const lang = (b.language === "zh" || (this.currentRoom?.subject && /[一-鿿]/.test(this.currentRoom.subject))) ? "zh" : "en";
|
|
5621
|
-
const copy = b.
|
|
5928
|
+
const copy = b.timedOut
|
|
5929
|
+
? (lang === "zh"
|
|
5930
|
+
? {
|
|
5931
|
+
stamp: "timed out",
|
|
5932
|
+
kicker: "// 报告生成超时",
|
|
5933
|
+
detail: "已超过 5 分钟仍未收到完成信号 · 可能是模型回应过慢、网络中断,或后端流水线卡住了。点击下方按钮重试,或检查 LLM key 与网络后再试。",
|
|
5934
|
+
hint: "",
|
|
5935
|
+
cta: "重试",
|
|
5936
|
+
}
|
|
5937
|
+
: {
|
|
5938
|
+
stamp: "timed out",
|
|
5939
|
+
kicker: "// generation timed out",
|
|
5940
|
+
detail: "No completion signal after 5 minutes — the model may be slow, the connection dropped, or the pipeline stalled. Click below to start a fresh run.",
|
|
5941
|
+
hint: "",
|
|
5942
|
+
cta: "Retry",
|
|
5943
|
+
})
|
|
5944
|
+
: b.interrupted
|
|
5622
5945
|
? (lang === "zh"
|
|
5623
5946
|
? {
|
|
5624
5947
|
stamp: "interrupted",
|
|
@@ -5658,7 +5981,7 @@
|
|
|
5658
5981
|
<div class="brief-body brief-body-error">
|
|
5659
5982
|
<div class="brief-kicker" style="color: var(--red);">${this.escape(copy.kicker)}</div>
|
|
5660
5983
|
<div class="brief-meta-line" style="color: var(--text-soft); text-transform: none; letter-spacing: 0;">
|
|
5661
|
-
${b.interrupted ? this.escape(copy.detail) : copy.detail}
|
|
5984
|
+
${(b.interrupted || b.timedOut) ? this.escape(copy.detail) : copy.detail}
|
|
5662
5985
|
</div>
|
|
5663
5986
|
${copy.hint ? `<div class="brief-meta-line" style="margin-top: 14px; text-transform: none; letter-spacing: 0;">${copy.hint}</div>` : ""}
|
|
5664
5987
|
<div class="brief-error-actions">
|
|
@@ -5726,7 +6049,7 @@
|
|
|
5726
6049
|
` : "";
|
|
5727
6050
|
|
|
5728
6051
|
// Ceremonial wrapper · the deliverable hits the table inside an
|
|
5729
|
-
// ending-block frame
|
|
6052
|
+
// ending-block frame.
|
|
5730
6053
|
card.innerHTML = `
|
|
5731
6054
|
<header class="ending-block-head">
|
|
5732
6055
|
<span class="ending-block-line"></span>
|
|
@@ -5888,6 +6211,84 @@
|
|
|
5888
6211
|
}
|
|
5889
6212
|
},
|
|
5890
6213
|
|
|
6214
|
+
/* ─── Brief stall watcher ─────────────────────────────────────
|
|
6215
|
+
Surfaces the Retry CTA promptly when generation stalls or
|
|
6216
|
+
times out — the user no longer has to leave + re-enter the
|
|
6217
|
+
room to discover a dead pipeline. Two safety nets:
|
|
6218
|
+
|
|
6219
|
+
· Stall poll · if no brief-* SSE event arrives for
|
|
6220
|
+
BRIEF_STALL_POLL_MS, ask /api/briefs/<id>/status. The
|
|
6221
|
+
server flips to !generating + !hasBody when the pipeline
|
|
6222
|
+
crashed mid-flight; checkBriefHealth (re-used) renders
|
|
6223
|
+
that as the existing "interrupted" error.
|
|
6224
|
+
|
|
6225
|
+
· Hard timeout · after BRIEF_HARD_TIMEOUT_MS of total
|
|
6226
|
+
wall-clock with no brief-final, force a `timedOut` error
|
|
6227
|
+
locally so Retry appears regardless of server-side state
|
|
6228
|
+
(covers SSE drops + LLM black-holes alike). */
|
|
6229
|
+
BRIEF_STALL_POLL_MS: 60_000,
|
|
6230
|
+
BRIEF_HARD_TIMEOUT_MS: 5 * 60_000,
|
|
6231
|
+
BRIEF_WATCH_INTERVAL_MS: 10_000,
|
|
6232
|
+
|
|
6233
|
+
markBriefEvent() {
|
|
6234
|
+
this._lastBriefEventAt = Date.now();
|
|
6235
|
+
},
|
|
6236
|
+
|
|
6237
|
+
ensureBriefStallWatch() {
|
|
6238
|
+
if (this._briefStallWatchTimer) return;
|
|
6239
|
+
const b = this.currentBrief;
|
|
6240
|
+
if (!b || !b.id || b.error) return;
|
|
6241
|
+
const generating = !b.bodyMd || b.title === "Generating…";
|
|
6242
|
+
if (!generating) return;
|
|
6243
|
+
if (!this._lastBriefEventAt) this._lastBriefEventAt = Date.now();
|
|
6244
|
+
this._lastBriefHealthPollAt = 0;
|
|
6245
|
+
this._briefStallWatchTimer = setInterval(
|
|
6246
|
+
() => this.tickBriefStallWatch(),
|
|
6247
|
+
this.BRIEF_WATCH_INTERVAL_MS,
|
|
6248
|
+
);
|
|
6249
|
+
},
|
|
6250
|
+
|
|
6251
|
+
stopBriefStallWatch() {
|
|
6252
|
+
if (this._briefStallWatchTimer) {
|
|
6253
|
+
clearInterval(this._briefStallWatchTimer);
|
|
6254
|
+
this._briefStallWatchTimer = null;
|
|
6255
|
+
}
|
|
6256
|
+
},
|
|
6257
|
+
|
|
6258
|
+
async tickBriefStallWatch() {
|
|
6259
|
+
const b = this.currentBrief;
|
|
6260
|
+
if (!b || b.error) { this.stopBriefStallWatch(); return; }
|
|
6261
|
+
const generating = !b.bodyMd || b.title === "Generating…";
|
|
6262
|
+
if (!generating) { this.stopBriefStallWatch(); return; }
|
|
6263
|
+
|
|
6264
|
+
const now = Date.now();
|
|
6265
|
+
const startedAt = b.pipelineStartedAt || this._lastBriefEventAt || now;
|
|
6266
|
+
|
|
6267
|
+
// Hard ceiling · regardless of server state, flip the card to
|
|
6268
|
+
// a timed-out error so the user always has a way out.
|
|
6269
|
+
if (now - startedAt > this.BRIEF_HARD_TIMEOUT_MS) {
|
|
6270
|
+
b.error = b.language === "zh"
|
|
6271
|
+
? "报告生成超时(超过 5 分钟仍未完成)。"
|
|
6272
|
+
: "Brief generation timed out (no completion after 5 minutes).";
|
|
6273
|
+
b.timedOut = true;
|
|
6274
|
+
this.stopBriefStageTick();
|
|
6275
|
+
this.stopBriefStallWatch();
|
|
6276
|
+
this.renderBrief();
|
|
6277
|
+
return;
|
|
6278
|
+
}
|
|
6279
|
+
|
|
6280
|
+
// Soft stall · poll the server at most once per STALL_POLL_MS
|
|
6281
|
+
// while we're not hearing anything. checkBriefHealth flips the
|
|
6282
|
+
// card to "interrupted" if the server has already given up.
|
|
6283
|
+
const lastEvt = this._lastBriefEventAt || startedAt;
|
|
6284
|
+
const elapsedSinceEvt = now - lastEvt;
|
|
6285
|
+
const pollGap = now - (this._lastBriefHealthPollAt || 0);
|
|
6286
|
+
if (elapsedSinceEvt > this.BRIEF_STALL_POLL_MS && pollGap > this.BRIEF_STALL_POLL_MS) {
|
|
6287
|
+
this._lastBriefHealthPollAt = now;
|
|
6288
|
+
await this.checkBriefHealth(b);
|
|
6289
|
+
}
|
|
6290
|
+
},
|
|
6291
|
+
|
|
5891
6292
|
/** Render the 3-stage checklist shown while the brief is generating.
|
|
5892
6293
|
* Each row pulses while active, gets a check when done. The active
|
|
5893
6294
|
* row also shows:
|
|
@@ -5948,7 +6349,12 @@
|
|
|
5948
6349
|
: null;
|
|
5949
6350
|
const eta = serverEta || meta[key]?.eta;
|
|
5950
6351
|
const startedAt = st.startedAt;
|
|
5951
|
-
|
|
6352
|
+
// Done stages freeze at finishedAt so the displayed duration
|
|
6353
|
+
// is the actual time the stage took, not "current time minus
|
|
6354
|
+
// when it started" (which would keep ticking after completion).
|
|
6355
|
+
// Active stages still use Date.now() so the counter animates.
|
|
6356
|
+
const endRef = (status === "done" && st.finishedAt) ? st.finishedAt : Date.now();
|
|
6357
|
+
const elapsedSec = startedAt ? Math.max(0, Math.floor((endRef - startedAt) / 1000)) : 0;
|
|
5952
6358
|
|
|
5953
6359
|
// Detail line · numeric progress (extract counter, write word
|
|
5954
6360
|
// count) takes priority. ETA / elapsed shown in a separate slot.
|
|
@@ -6105,6 +6511,38 @@
|
|
|
6105
6511
|
});
|
|
6106
6512
|
});
|
|
6107
6513
|
},
|
|
6514
|
+
|
|
6515
|
+
/** Bring the brief card into view at the top of the chat panel.
|
|
6516
|
+
* Called whenever the user has just triggered a generation
|
|
6517
|
+
* (Adjourn → file brief, Regenerate, Retry, post-hoc generate)
|
|
6518
|
+
* so they see the "Generating…" state appear immediately —
|
|
6519
|
+
* without this, a user who scrolled up to re-read history sees
|
|
6520
|
+
* no visible response to their click. Smooth-scrolls the .chat
|
|
6521
|
+
* container ONLY (not the page), aligning the card's top a bit
|
|
6522
|
+
* below the chat's top so the stage tracker is fully visible. */
|
|
6523
|
+
scrollToBriefCard() {
|
|
6524
|
+
// Two rAFs · let renderBrief paint + layout settle before measuring.
|
|
6525
|
+
requestAnimationFrame(() => {
|
|
6526
|
+
requestAnimationFrame(() => {
|
|
6527
|
+
const chat = document.querySelector(".chat");
|
|
6528
|
+
const card = document.querySelector("[data-brief-card]");
|
|
6529
|
+
if (!chat || !card) return;
|
|
6530
|
+
// Skip the scroll if the card is already comfortably on
|
|
6531
|
+
// screen — no need to nudge a user who's looking right at it.
|
|
6532
|
+
const cardRect = card.getBoundingClientRect();
|
|
6533
|
+
const chatRect = chat.getBoundingClientRect();
|
|
6534
|
+
const alreadyVisible =
|
|
6535
|
+
cardRect.top >= chatRect.top &&
|
|
6536
|
+
cardRect.top <= chatRect.top + chat.clientHeight * 0.5;
|
|
6537
|
+
if (alreadyVisible) return;
|
|
6538
|
+
const offset = card.offsetTop - chat.offsetTop - 16;
|
|
6539
|
+
chat.scrollTo({ top: Math.max(0, offset), behavior: "smooth" });
|
|
6540
|
+
// Reading the latest content again counts as "following the
|
|
6541
|
+
// feed" for subsequent token-stream auto-scroll decisions.
|
|
6542
|
+
this.chatStuckToBottom = true;
|
|
6543
|
+
});
|
|
6544
|
+
});
|
|
6545
|
+
},
|
|
6108
6546
|
};
|
|
6109
6547
|
|
|
6110
6548
|
// ── DOM-level wiring (delegated; survives re-renders) ──────
|
|
@@ -6155,6 +6593,14 @@
|
|
|
6155
6593
|
app.resumeRoom().catch((err) => alert("Resume failed: " + err.message));
|
|
6156
6594
|
return;
|
|
6157
6595
|
}
|
|
6596
|
+
// Export · adjourned-bar action. Browser handles the download
|
|
6597
|
+
// natively from the route's Content-Disposition header.
|
|
6598
|
+
if (e.target.closest("[data-room-export]")) {
|
|
6599
|
+
e.preventDefault();
|
|
6600
|
+
if (!app.currentRoomId) return;
|
|
6601
|
+
window.location.href = "/api/rooms/" + encodeURIComponent(app.currentRoomId) + "/export.md";
|
|
6602
|
+
return;
|
|
6603
|
+
}
|
|
6158
6604
|
// Generate report (post-hoc) — fires from the no-brief card CTA
|
|
6159
6605
|
// when the user originally skipped the brief but now wants one.
|
|
6160
6606
|
// Reuses the adjourn overlay's gallery in "generate-brief" mode.
|
|
@@ -6240,6 +6686,24 @@
|
|
|
6240
6686
|
app.submitSupplement();
|
|
6241
6687
|
return;
|
|
6242
6688
|
}
|
|
6689
|
+
// Paused-bar · open the supplement overlay (add a thought while paused).
|
|
6690
|
+
if (e.target.closest("[data-paused-supplement]")) {
|
|
6691
|
+
e.preventDefault();
|
|
6692
|
+
app.openPausedSupplementOverlay();
|
|
6693
|
+
return;
|
|
6694
|
+
}
|
|
6695
|
+
// Paused-supplement overlay · close / cancel / backdrop.
|
|
6696
|
+
if (e.target.closest("[data-paused-supplement-close]")) {
|
|
6697
|
+
e.preventDefault();
|
|
6698
|
+
app.closePausedSupplementOverlay();
|
|
6699
|
+
return;
|
|
6700
|
+
}
|
|
6701
|
+
// Paused-supplement overlay · confirm.
|
|
6702
|
+
if (e.target.closest("[data-paused-supplement-confirm]")) {
|
|
6703
|
+
e.preventDefault();
|
|
6704
|
+
app.submitPausedSupplement();
|
|
6705
|
+
return;
|
|
6706
|
+
}
|
|
6243
6707
|
// Continue · resume the directors after a chair-driven round-end.
|
|
6244
6708
|
if (e.target.closest("[data-continue]")) {
|
|
6245
6709
|
e.preventDefault();
|
|
@@ -6507,11 +6971,19 @@
|
|
|
6507
6971
|
e.preventDefault();
|
|
6508
6972
|
app.submitFromComposer(target);
|
|
6509
6973
|
});
|
|
6510
|
-
// Autosize the composer textarea as the user types
|
|
6974
|
+
// Autosize the composer textarea as the user types · also persist
|
|
6975
|
+
// the in-progress draft so switching to another view and coming back
|
|
6976
|
+
// restores the user's text instead of wiping it (each renderEmptyState
|
|
6977
|
+
// rebuilds the textarea node, so the DOM-level value vanishes; the
|
|
6978
|
+
// saved-state path is what survives the re-render).
|
|
6511
6979
|
document.addEventListener("input", (e) => {
|
|
6512
6980
|
if (e.target && e.target.matches && e.target.matches("[data-composer-subject]")) {
|
|
6981
|
+
const state = app.loadComposerState();
|
|
6982
|
+
state.subject = e.target.value;
|
|
6983
|
+
app.saveComposerState();
|
|
6513
6984
|
app.autosizeComposerTextarea();
|
|
6514
6985
|
} else if (e.target && e.target.matches && e.target.matches("[data-agent-composer-desc]")) {
|
|
6986
|
+
app.saveAgentComposerDraft(e.target.value);
|
|
6515
6987
|
app.autosizeAgentComposerTextarea();
|
|
6516
6988
|
}
|
|
6517
6989
|
});
|
|
@@ -6619,6 +7091,21 @@
|
|
|
6619
7091
|
}
|
|
6620
7092
|
});
|
|
6621
7093
|
|
|
7094
|
+
// When the tab becomes visible again, immediately probe a stalled
|
|
7095
|
+
// brief — the user may have switched away during a long generation
|
|
7096
|
+
// and the throttling sleeps held the watch back. The watcher itself
|
|
7097
|
+
// also keeps ticking on its 10s interval as a backstop.
|
|
7098
|
+
document.addEventListener("visibilitychange", () => {
|
|
7099
|
+
if (document.hidden) return;
|
|
7100
|
+
if (app && app.currentBrief && !app.currentBrief.error) {
|
|
7101
|
+
const generating = !app.currentBrief.bodyMd || app.currentBrief.title === "Generating…";
|
|
7102
|
+
if (generating) {
|
|
7103
|
+
app.ensureBriefStallWatch();
|
|
7104
|
+
app.tickBriefStallWatch();
|
|
7105
|
+
}
|
|
7106
|
+
}
|
|
7107
|
+
});
|
|
7108
|
+
|
|
6622
7109
|
window.app = app;
|
|
6623
7110
|
|
|
6624
7111
|
if (document.readyState === "loading") {
|