privateboard 0.1.6 → 0.1.8
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 +700 -17
- package/dist/cli.js.map +1 -1
- package/package.json +4 -2
- package/public/adjourn-overlay.css +194 -0
- package/public/agent-profile.js +14 -0
- package/public/app.js +862 -741
- package/public/bento.html +1396 -0
- package/public/home.html +389 -17
- package/public/index.html +211 -152
- package/public/magazine.html +1266 -0
- package/public/newspaper.html +1770 -0
- package/public/report.html +666 -55
- package/public/typing-sfx.js +158 -0
- package/public/user-settings.css +156 -19
- package/public/user-settings.js +109 -4
package/public/app.js
CHANGED
|
@@ -292,20 +292,14 @@
|
|
|
292
292
|
return;
|
|
293
293
|
}
|
|
294
294
|
|
|
295
|
-
const lang = (this.composerLanguage && this.composerLanguage()) || "en";
|
|
296
295
|
const count = fresh.length;
|
|
297
296
|
const names = fresh.map((m) => m.name).join(", ");
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
: {
|
|
305
|
-
head: `Storage upgraded`,
|
|
306
|
-
body: `${count} new migration${count > 1 ? "s" : ""} applied · your existing rooms, agents, briefs, and settings were preserved.`,
|
|
307
|
-
tooltip: names,
|
|
308
|
-
};
|
|
297
|
+
// System UI · always English (storage-migration banner).
|
|
298
|
+
const copy = {
|
|
299
|
+
head: `Storage upgraded`,
|
|
300
|
+
body: `${count} new migration${count > 1 ? "s" : ""} applied · your existing rooms, agents, briefs, and settings were preserved.`,
|
|
301
|
+
tooltip: names,
|
|
302
|
+
};
|
|
309
303
|
textEl.innerHTML =
|
|
310
304
|
`<span class="sys-notice-strong">${this.escape(copy.head)}</span> · ${this.escape(copy.body)}`;
|
|
311
305
|
banner.title = copy.tooltip;
|
|
@@ -783,6 +777,10 @@
|
|
|
783
777
|
msg.body += data.delta;
|
|
784
778
|
this.updateMessageBodyDom(data.messageId, msg.body, true);
|
|
785
779
|
this.scrollChatToBottom();
|
|
780
|
+
// Typing-SFX presence cue · the module throttles internally,
|
|
781
|
+
// mutes when the tab is backgrounded, and respects the user's
|
|
782
|
+
// toggle in Preference → User. Safe to call on every chunk.
|
|
783
|
+
window.boardroomTypingSfx && window.boardroomTypingSfx.tick();
|
|
786
784
|
});
|
|
787
785
|
|
|
788
786
|
this.sse.addEventListener("message-final", (e) => {
|
|
@@ -914,11 +912,37 @@
|
|
|
914
912
|
}
|
|
915
913
|
} else if (kind === "brief-started") {
|
|
916
914
|
this.markBriefEvent();
|
|
915
|
+
// Mode determines BOTH the seeded stage map and which pip
|
|
916
|
+
// rail renderBriefStages picks. Bento, magazine, and
|
|
917
|
+
// newspaper modes each emit only 2 stage events (extract
|
|
918
|
+
// + write), so seeding the 7-stage layout for a
|
|
919
|
+
// structured-mode brief leaves the 5 middle pips forever
|
|
920
|
+
// pending — the rail then renders with a row of dead
|
|
921
|
+
// black pips between extract and write. Read mode from
|
|
922
|
+
// the payload (server includes it on brief-started for
|
|
923
|
+
// exactly this reason) and seed accordingly.
|
|
924
|
+
const isStructured = this.isStructuredBriefMode(payload.mode);
|
|
925
|
+
const briefMode = isStructured ? payload.mode : "research-note";
|
|
926
|
+
const seededStages = isStructured
|
|
927
|
+
? {
|
|
928
|
+
extract: { status: "pending", detail: "", progress: null, startedAt: null, etaSec: null },
|
|
929
|
+
write: { status: "pending", detail: "", progress: null, startedAt: null, etaSec: null },
|
|
930
|
+
}
|
|
931
|
+
: {
|
|
932
|
+
extract: { status: "pending", detail: "", progress: null, startedAt: null, etaSec: null },
|
|
933
|
+
compose: { status: "pending", detail: "", progress: null, startedAt: null, etaSec: null },
|
|
934
|
+
"scaffold-anchor": { status: "pending", detail: "", progress: null, startedAt: null, etaSec: null },
|
|
935
|
+
"scaffold-findings": { status: "pending", detail: "", progress: null, startedAt: null, etaSec: null },
|
|
936
|
+
"scaffold-cluster": { status: "pending", detail: "", progress: null, startedAt: null, etaSec: null },
|
|
937
|
+
"scaffold-actions": { status: "pending", detail: "", progress: null, startedAt: null, etaSec: null },
|
|
938
|
+
write: { status: "pending", detail: "", progress: null, startedAt: null, etaSec: null },
|
|
939
|
+
};
|
|
917
940
|
const newBrief = {
|
|
918
941
|
id: payload.briefId,
|
|
919
942
|
title: "Generating…",
|
|
920
943
|
bodyMd: "",
|
|
921
944
|
style: payload.style || "mckinsey",
|
|
945
|
+
mode: briefMode,
|
|
922
946
|
// Carry the supplement so the tab strip can render the
|
|
923
947
|
// in-progress brief with its supplement label ("xxx 视角")
|
|
924
948
|
// immediately, instead of waiting for brief-final to
|
|
@@ -933,21 +957,13 @@
|
|
|
933
957
|
language: payload.language === "zh" ? "zh" : "en",
|
|
934
958
|
pipelineStartedAt: Date.now(),
|
|
935
959
|
createdAt: Date.now(),
|
|
936
|
-
// Stage checklist · seeded
|
|
937
|
-
//
|
|
938
|
-
//
|
|
939
|
-
//
|
|
940
|
-
//
|
|
941
|
-
//
|
|
942
|
-
stages:
|
|
943
|
-
extract: { status: "pending", detail: "", progress: null, startedAt: null, etaSec: null },
|
|
944
|
-
compose: { status: "pending", detail: "", progress: null, startedAt: null, etaSec: null },
|
|
945
|
-
"scaffold-anchor": { status: "pending", detail: "", progress: null, startedAt: null, etaSec: null },
|
|
946
|
-
"scaffold-findings": { status: "pending", detail: "", progress: null, startedAt: null, etaSec: null },
|
|
947
|
-
"scaffold-cluster": { status: "pending", detail: "", progress: null, startedAt: null, etaSec: null },
|
|
948
|
-
"scaffold-actions": { status: "pending", detail: "", progress: null, startedAt: null, etaSec: null },
|
|
949
|
-
write: { status: "pending", detail: "", progress: null, startedAt: null, etaSec: null },
|
|
950
|
-
},
|
|
960
|
+
// Stage checklist · seeded according to the brief mode (see
|
|
961
|
+
// briefMode resolution above). brief-stage events flip
|
|
962
|
+
// them active → done as the pipeline progresses.
|
|
963
|
+
// startedAt is captured when each stage first becomes
|
|
964
|
+
// active so the UI can display elapsed time alongside the
|
|
965
|
+
// ETA range.
|
|
966
|
+
stages: seededStages,
|
|
951
967
|
};
|
|
952
968
|
this.currentBrief = newBrief;
|
|
953
969
|
// Insert the in-progress brief into currentBriefs so the tab
|
|
@@ -1078,6 +1094,14 @@
|
|
|
1078
1094
|
}
|
|
1079
1095
|
}
|
|
1080
1096
|
}
|
|
1097
|
+
// No typing-SFX during the brief writer's Stage 3 stream.
|
|
1098
|
+
// The write phase is a long, off-screen streaming pass (the
|
|
1099
|
+
// user is usually reading prior content or doing something
|
|
1100
|
+
// else while the report builds) — a continuous click track
|
|
1101
|
+
// for tens of seconds reads as background noise, not a
|
|
1102
|
+
// presence cue. Director / chair turns still tick because
|
|
1103
|
+
// those land in the active conversation the user is
|
|
1104
|
+
// following.
|
|
1081
1105
|
} else if (kind === "brief-final") {
|
|
1082
1106
|
this.markBriefEvent();
|
|
1083
1107
|
const target = this._briefById(payload.briefId);
|
|
@@ -1482,6 +1506,192 @@
|
|
|
1482
1506
|
return false;
|
|
1483
1507
|
},
|
|
1484
1508
|
|
|
1509
|
+
/** Has the brief got its body content yet? Mode-aware check ·
|
|
1510
|
+
* research-note briefs land their content in `bodyMd`; bento
|
|
1511
|
+
* briefs land theirs in `bodyJson` and leave bodyMd empty. The
|
|
1512
|
+
* card-rendering / placeholder-detection / generating-check
|
|
1513
|
+
* logic uses this everywhere so a bento brief doesn't stay
|
|
1514
|
+
* stuck in "generating…" state after the BentoScaffold has
|
|
1515
|
+
* actually been persisted.
|
|
1516
|
+
*
|
|
1517
|
+
* Without this helper, a successful bento generation followed
|
|
1518
|
+
* by a page refresh hid the View Report button forever (the
|
|
1519
|
+
* card thought it was still generating because bodyMd was empty
|
|
1520
|
+
* — which is the steady-state shape for bento, not an
|
|
1521
|
+
* in-flight signal). */
|
|
1522
|
+
/** True for any structured-output mode — bento, magazine,
|
|
1523
|
+
* newspaper — that persists to body_json instead of body_md and
|
|
1524
|
+
* runs the single-pass chair-LLM pipeline (extract + write only,
|
|
1525
|
+
* no Stage 2/3 scaffold/write). Centralised so future modes
|
|
1526
|
+
* touch one place. */
|
|
1527
|
+
isStructuredBriefMode(mode) {
|
|
1528
|
+
return mode === "bento" || mode === "magazine" || mode === "newspaper";
|
|
1529
|
+
},
|
|
1530
|
+
|
|
1531
|
+
briefHasBody(b) {
|
|
1532
|
+
if (!b) return false;
|
|
1533
|
+
if (this.isStructuredBriefMode(b.mode)) {
|
|
1534
|
+
// Bento + magazine populate bodyJson with the BentoScaffold.
|
|
1535
|
+
// An empty object isn't a body; check for at least a title
|
|
1536
|
+
// field.
|
|
1537
|
+
const j = b.bodyJson;
|
|
1538
|
+
return !!(j && typeof j === "object" && (j.title || j.milestones));
|
|
1539
|
+
}
|
|
1540
|
+
// Default research-note · body lives in markdown.
|
|
1541
|
+
return !!(b.bodyMd && b.bodyMd.trim());
|
|
1542
|
+
},
|
|
1543
|
+
|
|
1544
|
+
/** Build the right viewer URL for a brief based on its mode.
|
|
1545
|
+
* · 'research-note' (default · or unknown) → `/report.html?r=R&b=B`
|
|
1546
|
+
* · 'bento' → `/bento.html?b=B`
|
|
1547
|
+
* · 'magazine' → `/magazine.html?b=B`
|
|
1548
|
+
* · 'newspaper' → `/newspaper.html?b=B`
|
|
1549
|
+
*
|
|
1550
|
+
* Structured renderers don't need the room context · the brief
|
|
1551
|
+
* is self-contained. Used by the View Report button, the
|
|
1552
|
+
* brief-picker popover, the open-report link in the brief card,
|
|
1553
|
+
* and the All Reports page — one helper so a future renderer
|
|
1554
|
+
* route change touches one place. */
|
|
1555
|
+
briefViewerHref(b, roomId) {
|
|
1556
|
+
if (!b || !b.id) return null;
|
|
1557
|
+
const id = encodeURIComponent(b.id);
|
|
1558
|
+
if (b.mode === "bento") return `/bento.html?b=${id}`;
|
|
1559
|
+
if (b.mode === "magazine") return `/magazine.html?b=${id}`;
|
|
1560
|
+
if (b.mode === "newspaper") return `/newspaper.html?b=${id}`;
|
|
1561
|
+
const r = roomId ? encodeURIComponent(roomId) : "";
|
|
1562
|
+
return r ? `/report.html?r=${r}&b=${id}` : `/report.html?b=${id}`;
|
|
1563
|
+
},
|
|
1564
|
+
|
|
1565
|
+
/** Human-readable label for a brief's mode · used in the brief-card
|
|
1566
|
+
* banner so the user sees which renderer the report will use
|
|
1567
|
+
* before they open it.
|
|
1568
|
+
*
|
|
1569
|
+
* IMPORTANT · the user-facing label for `research-note` is
|
|
1570
|
+
* "Report", NOT "Research Note". The internal mode key stays as
|
|
1571
|
+
* `research-note` (storage column, API value, picker option id),
|
|
1572
|
+
* but every user-facing surface — the mode picker (`renderBrief-
|
|
1573
|
+
* ModePicker` line ~1662 in this file), the report viewer's
|
|
1574
|
+
* header, etc. — calls it "Report". Keep this map in sync with
|
|
1575
|
+
* the picker labels rather than inventing a fresh user-facing
|
|
1576
|
+
* vocabulary.
|
|
1577
|
+
*
|
|
1578
|
+
* Legacy rows without an explicit `mode` default to "Report" by
|
|
1579
|
+
* the same backfill the storage layer applies. */
|
|
1580
|
+
briefModeLabel(b) {
|
|
1581
|
+
const mode = (b && b.mode) || "research-note";
|
|
1582
|
+
switch (mode) {
|
|
1583
|
+
case "bento": return "Bento";
|
|
1584
|
+
case "magazine": return "Magazine";
|
|
1585
|
+
case "newspaper": return "Newspaper";
|
|
1586
|
+
default: return "Report";
|
|
1587
|
+
}
|
|
1588
|
+
},
|
|
1589
|
+
|
|
1590
|
+
/** Render the report-mode picker · four icon-tile cards that let
|
|
1591
|
+
* the user pick between research-note, bento, magazine, and
|
|
1592
|
+
* newspaper. Each tile shows a custom monoline SVG that
|
|
1593
|
+
* visually mirrors that mode's layout — research-note as a
|
|
1594
|
+
* memo, bento as a 4-cell grid, magazine as a masthead +
|
|
1595
|
+
* numeral block + small cards, newspaper as a banner + 3-column
|
|
1596
|
+
* text grid. The native radio is visually hidden (kept in the
|
|
1597
|
+
* DOM for a11y / keyboard nav).
|
|
1598
|
+
*
|
|
1599
|
+
* Used by both the adjourn overlay (filing the report at
|
|
1600
|
+
* adjourn-time / generate-brief flow) AND the supplement
|
|
1601
|
+
* overlay (regenerating with an extra perspective). The picker
|
|
1602
|
+
* reads its selected option via `input[name="brief-mode"]:checked`
|
|
1603
|
+
* from inside whatever overlay it lives in · the click-to-toggle
|
|
1604
|
+
* handler scopes to the `.adjourn-mode-options` container so it
|
|
1605
|
+
* works for any overlay embedding the picker. */
|
|
1606
|
+
renderBriefModePicker(defaultMode) {
|
|
1607
|
+
const safe = this.isStructuredBriefMode(defaultMode) ? defaultMode : "research-note";
|
|
1608
|
+
// Icon SVGs · `currentColor` lets each tile pick up its hover /
|
|
1609
|
+
// selected accent from the parent `.adjourn-mode-icon { color }`
|
|
1610
|
+
// rule. Stroke hierarchy is deliberate · main outlines at 1.0,
|
|
1611
|
+
// a single emphasis rule at 1.2 (newspaper headline + magazine
|
|
1612
|
+
// masthead bar), body details at 0.8. Filled blocks soften to
|
|
1613
|
+
// currentColor at 0.5 opacity so the lime selected-state reads
|
|
1614
|
+
// as a tint rather than a slab. The 24-unit viewBox renders at
|
|
1615
|
+
// 36px on screen → 1.5× scale, so stroke-1.0 is a clean 1.5px.
|
|
1616
|
+
const ICONS = {
|
|
1617
|
+
"research-note": `
|
|
1618
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
1619
|
+
<path d="M6 3h9l4 4v14H6z"/>
|
|
1620
|
+
<path d="M15 3v4h4"/>
|
|
1621
|
+
<line x1="9" y1="12" x2="16" y2="12"/>
|
|
1622
|
+
<line x1="9" y1="15" x2="16" y2="15"/>
|
|
1623
|
+
<line x1="9" y1="18" x2="14" y2="18"/>
|
|
1624
|
+
</svg>`,
|
|
1625
|
+
"bento": `
|
|
1626
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
1627
|
+
<rect x="3" y="3" width="9" height="13" rx="1.5"/>
|
|
1628
|
+
<rect x="13" y="3" width="8" height="6" rx="1.5"/>
|
|
1629
|
+
<rect x="13" y="10" width="8" height="6" rx="1.5"/>
|
|
1630
|
+
<rect x="3" y="17" width="18" height="4" rx="1.5"/>
|
|
1631
|
+
</svg>`,
|
|
1632
|
+
"magazine": `
|
|
1633
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
1634
|
+
<line x1="3" y1="4" x2="21" y2="4" stroke-width="1.2"/>
|
|
1635
|
+
<rect x="3" y="7" width="6" height="9" rx="1.2"/>
|
|
1636
|
+
<rect x="11" y="7" width="4.5" height="4" rx="1.2"/>
|
|
1637
|
+
<rect x="16.5" y="7" width="4.5" height="4" rx="1.2"/>
|
|
1638
|
+
<rect x="11" y="12" width="4.5" height="4" rx="1.2"/>
|
|
1639
|
+
<rect x="16.5" y="12" width="4.5" height="4" rx="1.2"/>
|
|
1640
|
+
<rect x="3" y="18" width="18" height="3" rx="1" fill="currentColor" fill-opacity="0.45" stroke="none"/>
|
|
1641
|
+
</svg>`,
|
|
1642
|
+
"newspaper": `
|
|
1643
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
1644
|
+
<rect x="3" y="3" width="18" height="2.5" fill="currentColor" fill-opacity="0.85" stroke="none"/>
|
|
1645
|
+
<line x1="7" y1="8" x2="17" y2="8" stroke-width="1.2"/>
|
|
1646
|
+
<line x1="4" y1="11.5" x2="8" y2="11.5" stroke-width="0.8"/>
|
|
1647
|
+
<line x1="4" y1="13.5" x2="8" y2="13.5" stroke-width="0.8"/>
|
|
1648
|
+
<line x1="4" y1="15.5" x2="6.5" y2="15.5" stroke-width="0.8"/>
|
|
1649
|
+
<line x1="10" y1="11.5" x2="14" y2="11.5" stroke-width="0.8"/>
|
|
1650
|
+
<line x1="10" y1="13.5" x2="14" y2="13.5" stroke-width="0.8"/>
|
|
1651
|
+
<line x1="10" y1="15.5" x2="12.5" y2="15.5" stroke-width="0.8"/>
|
|
1652
|
+
<line x1="16" y1="11.5" x2="20" y2="11.5" stroke-width="0.8"/>
|
|
1653
|
+
<line x1="16" y1="13.5" x2="20" y2="13.5" stroke-width="0.8"/>
|
|
1654
|
+
<line x1="16" y1="15.5" x2="18.5" y2="15.5" stroke-width="0.8"/>
|
|
1655
|
+
<line x1="3" y1="18.5" x2="21" y2="18.5" stroke-width="0.9"/>
|
|
1656
|
+
</svg>`,
|
|
1657
|
+
};
|
|
1658
|
+
const opt = (value, title, deck, primary) => `
|
|
1659
|
+
<label class="adjourn-mode-option${primary ? " adjourn-mode-option-primary" : ""}${safe === value ? " on" : ""}">
|
|
1660
|
+
<input type="radio" name="brief-mode" value="${value}"${safe === value ? " checked" : ""}>
|
|
1661
|
+
<div class="adjourn-mode-icon">${ICONS[value] || ""}</div>
|
|
1662
|
+
<div class="adjourn-mode-body">
|
|
1663
|
+
<div class="adjourn-mode-title">${title}</div>
|
|
1664
|
+
<div class="adjourn-mode-deck">${deck}</div>
|
|
1665
|
+
</div>
|
|
1666
|
+
</label>`;
|
|
1667
|
+
return `
|
|
1668
|
+
<div class="adjourn-mode-picker" data-mode-picker>
|
|
1669
|
+
<div class="adjourn-mode-label">// report format</div>
|
|
1670
|
+
<div class="adjourn-mode-options adjourn-mode-options-4">
|
|
1671
|
+
${opt("research-note", "Report", "Long-form markdown · bottom line, findings, recommendations.", true)}
|
|
1672
|
+
${opt("bento", "Bento", "Single-page poster · 3 milestones + sidebar cards.")}
|
|
1673
|
+
${opt("magazine", "Magazine", "Editorial spread · cover line, 5 cards, dark closer.")}
|
|
1674
|
+
${opt("newspaper", "Newspaper", "Broadsheet · banner masthead, 3-column editorial.")}
|
|
1675
|
+
</div>
|
|
1676
|
+
</div>`;
|
|
1677
|
+
},
|
|
1678
|
+
|
|
1679
|
+
/** Read the user's last-picked report mode from localStorage so the
|
|
1680
|
+
* picker defaults to their previous choice. Falls back to
|
|
1681
|
+
* 'research-note' on first run / private mode. */
|
|
1682
|
+
lastBriefMode() {
|
|
1683
|
+
try {
|
|
1684
|
+
const v = localStorage.getItem("pb.briefMode");
|
|
1685
|
+
return this.isStructuredBriefMode(v) ? v : "research-note";
|
|
1686
|
+
} catch { return "research-note"; }
|
|
1687
|
+
},
|
|
1688
|
+
saveLastBriefMode(mode) {
|
|
1689
|
+
try {
|
|
1690
|
+
const safe = this.isStructuredBriefMode(mode) ? mode : "research-note";
|
|
1691
|
+
localStorage.setItem("pb.briefMode", safe);
|
|
1692
|
+
} catch { /* private-mode etc. — silently ignore */ }
|
|
1693
|
+
},
|
|
1694
|
+
|
|
1485
1695
|
/** Pure cache read · returns true when the local app.keys map
|
|
1486
1696
|
* reports any model provider as configured. Used by the gate
|
|
1487
1697
|
* and (via wrappers) anywhere downstream UI needs the answer
|
|
@@ -1498,24 +1708,15 @@
|
|
|
1498
1708
|
* visual treatment stays consistent. */
|
|
1499
1709
|
openNoKeyModal() {
|
|
1500
1710
|
this.closeNoKeyModal();
|
|
1501
|
-
|
|
1502
|
-
const t =
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
}
|
|
1511
|
-
: {
|
|
1512
|
-
title: "Configure a model API key",
|
|
1513
|
-
deck: "The chair and directors run on a large language model. Configure at least one provider key (Anthropic / OpenAI / Google / xAI / DeepSeek / OpenRouter) before AI features will work.",
|
|
1514
|
-
primary: "[ Open settings ▸ ]",
|
|
1515
|
-
dismiss: "[ Dismiss ]",
|
|
1516
|
-
classification: "● ai · no model key",
|
|
1517
|
-
tag: "▸ Configure API key",
|
|
1518
|
-
};
|
|
1711
|
+
// System UI · always English. No-key modal is app chrome.
|
|
1712
|
+
const t = {
|
|
1713
|
+
title: "Configure a model API key",
|
|
1714
|
+
deck: "The chair and directors run on a large language model. Configure at least one provider key (Anthropic / OpenAI / Google / xAI / DeepSeek / OpenRouter) before AI features will work.",
|
|
1715
|
+
primary: "[ Open settings ▸ ]",
|
|
1716
|
+
dismiss: "[ Dismiss ]",
|
|
1717
|
+
classification: "● ai · no model key",
|
|
1718
|
+
tag: "▸ Configure API key",
|
|
1719
|
+
};
|
|
1519
1720
|
const html = `
|
|
1520
1721
|
<div id="no-key-overlay" class="pc-overlay" data-no-key-overlay>
|
|
1521
1722
|
<div class="pc-modal">
|
|
@@ -1531,7 +1732,7 @@
|
|
|
1531
1732
|
<div class="pc-body">
|
|
1532
1733
|
<button type="button" class="pc-choice primary" data-no-key-open-settings>
|
|
1533
1734
|
<div class="pc-choice-mark">${this.escape(t.primary)}</div>
|
|
1534
|
-
<div class="pc-choice-deck"
|
|
1735
|
+
<div class="pc-choice-deck">Jump to Preferences → API Key. Paste any one provider's key to unlock the room.</div>
|
|
1535
1736
|
</button>
|
|
1536
1737
|
<button type="button" class="pc-choice ghost" data-no-key-dismiss>
|
|
1537
1738
|
<div class="pc-choice-mark">${this.escape(t.dismiss)}</div>
|
|
@@ -1654,6 +1855,9 @@
|
|
|
1654
1855
|
async adjournRoom(opts) {
|
|
1655
1856
|
if (!this.currentRoomId) return;
|
|
1656
1857
|
const skipBrief = !!(opts && opts.skipBrief);
|
|
1858
|
+
const mode = opts && this.isStructuredBriefMode(opts.mode)
|
|
1859
|
+
? opts.mode
|
|
1860
|
+
: "research-note";
|
|
1657
1861
|
// Pre-flight · adjourn-with-brief triggers the brief writer
|
|
1658
1862
|
// pipeline (3 LLM stages). When skipBrief=true the chair just
|
|
1659
1863
|
// posts the no-brief marker without LLM calls, so we let it
|
|
@@ -1664,7 +1868,7 @@
|
|
|
1664
1868
|
{
|
|
1665
1869
|
method: "POST",
|
|
1666
1870
|
headers: { "content-type": "application/json" },
|
|
1667
|
-
body: JSON.stringify(skipBrief ? { skipBrief: true } : {}),
|
|
1871
|
+
body: JSON.stringify(skipBrief ? { skipBrief: true } : { mode }),
|
|
1668
1872
|
},
|
|
1669
1873
|
);
|
|
1670
1874
|
if (!r.ok) {
|
|
@@ -1698,6 +1902,10 @@
|
|
|
1698
1902
|
const confirmTxt = isGen ? "[ Generate ]" : "[ Adjourn & file ]";
|
|
1699
1903
|
const subjectTxt = room.subject || room.name || "—";
|
|
1700
1904
|
const memberCount = (this.currentMembers || []).length;
|
|
1905
|
+
// Default the mode picker to whatever the user picked last. Bento
|
|
1906
|
+
// is opt-in · 'research-note' is the safe initial default for new
|
|
1907
|
+
// users / private-mode browsers (where localStorage is empty).
|
|
1908
|
+
const defaultMode = this.lastBriefMode();
|
|
1701
1909
|
const html = `
|
|
1702
1910
|
<div class="adjourn-overlay" id="adjourn-overlay" role="dialog" aria-modal="true" data-adjourn-mode="${this.escape(mode)}">
|
|
1703
1911
|
<div class="adjourn-backdrop" data-adjourn-close></div>
|
|
@@ -1718,9 +1926,12 @@
|
|
|
1718
1926
|
|
|
1719
1927
|
<div class="adjourn-body">
|
|
1720
1928
|
<div class="adjourn-summary">
|
|
1721
|
-
<div class="adjourn-summary-row">
|
|
1929
|
+
<div class="adjourn-summary-row adjourn-summary-row-subject">
|
|
1722
1930
|
<span class="adjourn-summary-key">// subject</span>
|
|
1723
|
-
<
|
|
1931
|
+
<div class="adjourn-summary-val adjourn-subject-wrap">
|
|
1932
|
+
<span class="adjourn-subject-text is-clamped" data-adjourn-subject>${this.escape(subjectTxt)}</span>
|
|
1933
|
+
<button type="button" class="adjourn-subject-toggle" data-adjourn-subject-toggle hidden>Show more</button>
|
|
1934
|
+
</div>
|
|
1724
1935
|
</div>
|
|
1725
1936
|
<div class="adjourn-summary-row">
|
|
1726
1937
|
<span class="adjourn-summary-key">// authors</span>
|
|
@@ -1732,10 +1943,12 @@
|
|
|
1732
1943
|
</div>
|
|
1733
1944
|
</div>
|
|
1734
1945
|
<p class="adjourn-summary-note">
|
|
1735
|
-
The chair compiles a
|
|
1736
|
-
|
|
1737
|
-
|
|
1946
|
+
The chair compiles a report from the room's transcript. Pick the
|
|
1947
|
+
format below — the room is marked adjourned and the report is
|
|
1948
|
+
filed in the chat once it's ready.
|
|
1738
1949
|
</p>
|
|
1950
|
+
|
|
1951
|
+
${this.renderBriefModePicker(defaultMode)}
|
|
1739
1952
|
</div>
|
|
1740
1953
|
|
|
1741
1954
|
<footer class="adjourn-foot">
|
|
@@ -1756,6 +1969,19 @@
|
|
|
1756
1969
|
wrap.innerHTML = html.trim();
|
|
1757
1970
|
document.body.appendChild(wrap.firstChild);
|
|
1758
1971
|
document.body.style.overflow = "hidden";
|
|
1972
|
+
// Subject clamp · the subject value is clamped to 3 lines by
|
|
1973
|
+
// default (a long room title shouldn't crowd the rest of the
|
|
1974
|
+
// overlay). After mount, measure whether the text actually
|
|
1975
|
+
// overflows the clamp — only then reveal the Show more / less
|
|
1976
|
+
// toggle. rAF lets the browser settle the initial layout so
|
|
1977
|
+
// scrollHeight is meaningful.
|
|
1978
|
+
requestAnimationFrame(() => {
|
|
1979
|
+
const subjEl = document.querySelector("[data-adjourn-subject]");
|
|
1980
|
+
const subjBtn = document.querySelector("[data-adjourn-subject-toggle]");
|
|
1981
|
+
if (subjEl && subjBtn && subjEl.scrollHeight > subjEl.clientHeight + 1) {
|
|
1982
|
+
subjBtn.hidden = false;
|
|
1983
|
+
}
|
|
1984
|
+
});
|
|
1759
1985
|
// Esc closes the overlay. Listener auto-detaches on close so
|
|
1760
1986
|
// we don't accumulate handlers across opens.
|
|
1761
1987
|
this._adjournEsc = (ev) => {
|
|
@@ -1783,30 +2009,26 @@
|
|
|
1783
2009
|
openSupplementOverlay() {
|
|
1784
2010
|
if (!this.currentRoomId || !this.currentBrief) return;
|
|
1785
2011
|
this.closeSupplementOverlay();
|
|
1786
|
-
|
|
1787
|
-
const t =
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
cancel: "[ Cancel ]",
|
|
1807
|
-
confirm: "[ Regenerate ]",
|
|
1808
|
-
confirmBusy: "[ Regenerating… ]",
|
|
1809
|
-
};
|
|
2012
|
+
// System UI · always English. Supplement-overlay chrome.
|
|
2013
|
+
const t = {
|
|
2014
|
+
classify: "report · regenerate with supplement",
|
|
2015
|
+
classifyRight: "// regenerate",
|
|
2016
|
+
title: "Add a perspective",
|
|
2017
|
+
metaPrefix: "// Current report",
|
|
2018
|
+
placeholder: "Describe an angle you want the chair to additionally consider. For example:\n· Bring in a commercial-viability lens\n· Center the experience of women users\n· Stretch the time window to 5 years",
|
|
2019
|
+
hint: "The new perspective will be woven through the existing Findings, Recommendations, and New Questions sections — not added as a separate section.",
|
|
2020
|
+
cancel: "[ Cancel ]",
|
|
2021
|
+
confirm: "[ Regenerate ]",
|
|
2022
|
+
confirmBusy: "[ Regenerating… ]",
|
|
2023
|
+
};
|
|
2024
|
+
// Default the picker to the existing brief's mode · the user's
|
|
2025
|
+
// baseline assumption when regenerating with a supplement is "I
|
|
2026
|
+
// want the same kind of report, just with this extra angle." If
|
|
2027
|
+
// the parent brief's mode is missing for any reason, fall back
|
|
2028
|
+
// to the user's last picked mode (localStorage).
|
|
2029
|
+
const defaultMode = this.isStructuredBriefMode(this.currentBrief?.mode)
|
|
2030
|
+
? this.currentBrief.mode
|
|
2031
|
+
: this.lastBriefMode();
|
|
1810
2032
|
const html = `
|
|
1811
2033
|
<div class="supplement-overlay" id="supplement-overlay" role="dialog" aria-modal="true">
|
|
1812
2034
|
<div class="supplement-backdrop" data-supplement-close></div>
|
|
@@ -1825,6 +2047,7 @@
|
|
|
1825
2047
|
<div class="supplement-body">
|
|
1826
2048
|
<textarea class="supplement-input" data-supplement-input rows="6" placeholder="${this.escape(t.placeholder)}"></textarea>
|
|
1827
2049
|
<p class="supplement-hint">${this.escape(t.hint)}</p>
|
|
2050
|
+
${this.renderBriefModePicker(defaultMode)}
|
|
1828
2051
|
</div>
|
|
1829
2052
|
<footer class="supplement-foot">
|
|
1830
2053
|
<button type="button" class="supplement-cancel" data-supplement-close>${this.escape(t.cancel)}</button>
|
|
@@ -1875,31 +2098,19 @@
|
|
|
1875
2098
|
if (!this.currentRoomId || !this.currentRoom) return;
|
|
1876
2099
|
if (this.currentRoom.status !== "paused") return;
|
|
1877
2100
|
this.closePausedSupplementOverlay();
|
|
1878
|
-
|
|
1879
|
-
const t =
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
: {
|
|
1892
|
-
classify: "room · paused supplement",
|
|
1893
|
-
classifyRight: "// queued first",
|
|
1894
|
-
title: "Add a supplemental input",
|
|
1895
|
-
metaPrefix: "// Current room",
|
|
1896
|
-
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.",
|
|
1897
|
-
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.",
|
|
1898
|
-
cancel: "[ Cancel ]",
|
|
1899
|
-
confirm: "[ Add to chat ]",
|
|
1900
|
-
confirmBusy: "[ Posting… ]",
|
|
1901
|
-
};
|
|
1902
|
-
const subject = (this.currentRoom.subject || "").trim() || (lang === "zh" ? "(无主题)" : "(no subject)");
|
|
2101
|
+
// System UI · always English. Paused-supplement overlay chrome.
|
|
2102
|
+
const t = {
|
|
2103
|
+
classify: "room · paused supplement",
|
|
2104
|
+
classifyRight: "// queued first",
|
|
2105
|
+
title: "Add a supplemental input",
|
|
2106
|
+
metaPrefix: "// Current room",
|
|
2107
|
+
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.",
|
|
2108
|
+
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.",
|
|
2109
|
+
cancel: "[ Cancel ]",
|
|
2110
|
+
confirm: "[ Add to chat ]",
|
|
2111
|
+
confirmBusy: "[ Posting… ]",
|
|
2112
|
+
};
|
|
2113
|
+
const subject = (this.currentRoom.subject || "").trim() || "(no subject)";
|
|
1903
2114
|
const html = `
|
|
1904
2115
|
<div class="supplement-overlay" id="paused-supplement-overlay" role="dialog" aria-modal="true">
|
|
1905
2116
|
<div class="supplement-backdrop" data-paused-supplement-close></div>
|
|
@@ -2019,54 +2230,30 @@
|
|
|
2019
2230
|
if (this.currentRoom.status !== "adjourned") return;
|
|
2020
2231
|
this.closeFollowUpOverlay();
|
|
2021
2232
|
|
|
2022
|
-
|
|
2023
|
-
const t =
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
}
|
|
2047
|
-
: {
|
|
2048
|
-
classify: "follow-up · continuation room",
|
|
2049
|
-
classifyRight: "// continuing",
|
|
2050
|
-
title: "Convene a follow-up",
|
|
2051
|
-
metaPrefix: "// following up",
|
|
2052
|
-
placeholder: "What's the next question to chase, given what the prior session settled?",
|
|
2053
|
-
contextNote: "The prior subject, the filed brief (room's settled judgement), and each director's load-bearing observations are bundled as context for this follow-up — the new cast picks up where the prior session left off rather than starting from scratch.",
|
|
2054
|
-
castLabel: "Directors",
|
|
2055
|
-
castHint: "2–4 recommended",
|
|
2056
|
-
castSame: "Same cast as last session",
|
|
2057
|
-
pickerLabel: "Pick directors",
|
|
2058
|
-
autoLabel: "directors",
|
|
2059
|
-
autoVal: "auto-pick",
|
|
2060
|
-
countersDirectors: (n) => `${n} director${n === 1 ? "" : "s"}`,
|
|
2061
|
-
toneLabel: "Tone",
|
|
2062
|
-
intensityLabel: "Intensity",
|
|
2063
|
-
cancel: "[ Cancel ]",
|
|
2064
|
-
confirm: "[ Convene → ]",
|
|
2065
|
-
confirmBusy: "[ Convening… ]",
|
|
2066
|
-
adjournedAtPrefix: "adjourned",
|
|
2067
|
-
briefsCount: (n) => `${n} ${n === 1 ? "brief" : "briefs"} filed`,
|
|
2068
|
-
noBrief: "no brief filed",
|
|
2069
|
-
};
|
|
2233
|
+
// System UI · always English. Follow-up overlay chrome.
|
|
2234
|
+
const t = {
|
|
2235
|
+
classify: "follow-up · continuation room",
|
|
2236
|
+
classifyRight: "// continuing",
|
|
2237
|
+
title: "Convene a follow-up",
|
|
2238
|
+
metaPrefix: "// following up",
|
|
2239
|
+
placeholder: "What's the next question to chase, given what the prior session settled?",
|
|
2240
|
+
contextNote: "The prior subject, the filed brief (room's settled judgement), and each director's load-bearing observations are bundled as context for this follow-up — the new cast picks up where the prior session left off rather than starting from scratch.",
|
|
2241
|
+
castLabel: "Directors",
|
|
2242
|
+
castHint: "2–4 recommended",
|
|
2243
|
+
castSame: "Same cast as last session",
|
|
2244
|
+
pickerLabel: "Pick directors",
|
|
2245
|
+
autoLabel: "directors",
|
|
2246
|
+
autoVal: "auto-pick",
|
|
2247
|
+
countersDirectors: (n) => `${n} director${n === 1 ? "" : "s"}`,
|
|
2248
|
+
toneLabel: "Tone",
|
|
2249
|
+
intensityLabel: "Intensity",
|
|
2250
|
+
cancel: "[ Cancel ]",
|
|
2251
|
+
confirm: "[ Convene → ]",
|
|
2252
|
+
confirmBusy: "[ Convening… ]",
|
|
2253
|
+
adjournedAtPrefix: "adjourned",
|
|
2254
|
+
briefsCount: (n) => `${n} ${n === 1 ? "brief" : "briefs"} filed`,
|
|
2255
|
+
noBrief: "no brief filed",
|
|
2256
|
+
};
|
|
2070
2257
|
|
|
2071
2258
|
const room = this.currentRoom;
|
|
2072
2259
|
const briefCount = Array.isArray(this.currentBriefs) ? this.currentBriefs.length : 0;
|
|
@@ -2105,7 +2292,6 @@
|
|
|
2105
2292
|
directorIds: [],
|
|
2106
2293
|
autoPick: true,
|
|
2107
2294
|
parentDirectorIds: parentDirectorIds.slice(),
|
|
2108
|
-
lang,
|
|
2109
2295
|
};
|
|
2110
2296
|
|
|
2111
2297
|
const html = `
|
|
@@ -2269,7 +2455,6 @@
|
|
|
2269
2455
|
const btn = document.querySelector("[data-followup-cast-btn]");
|
|
2270
2456
|
if (!btn || !this._followupCastState) return;
|
|
2271
2457
|
const state = this._followupCastState;
|
|
2272
|
-
const lang = state.lang || "en";
|
|
2273
2458
|
|
|
2274
2459
|
btn.classList.remove("cmp-cast-btn-auto");
|
|
2275
2460
|
btn.removeAttribute("data-cast-mode");
|
|
@@ -2286,9 +2471,8 @@
|
|
|
2286
2471
|
const avatars = visible.map((a) =>
|
|
2287
2472
|
`<img class="cmp-cast-av" src="${this.escape(a.avatarPath || "")}" alt="${this.escape(a.name || "")}" title="${this.escape(a.name || "")}">`,
|
|
2288
2473
|
).join("");
|
|
2289
|
-
|
|
2290
|
-
|
|
2291
|
-
: `${parents.length} · same as last`;
|
|
2474
|
+
// System UI · always English (cast button chrome).
|
|
2475
|
+
const count = `${parents.length} · same as last`;
|
|
2292
2476
|
btn.innerHTML =
|
|
2293
2477
|
`<span class="cmp-cast-stack">${avatars}${overflow > 0 ? `<span class="cmp-cast-more">+${overflow}</span>` : ""}</span>` +
|
|
2294
2478
|
`<span class="cmp-cast-count">${this.escape(count)}</span>`;
|
|
@@ -2304,8 +2488,9 @@
|
|
|
2304
2488
|
// Auto-pick · default
|
|
2305
2489
|
btn.classList.add("cmp-cast-btn-auto");
|
|
2306
2490
|
btn.setAttribute("data-cast-mode", "auto");
|
|
2307
|
-
|
|
2308
|
-
const
|
|
2491
|
+
// System UI · always English (cast button chrome).
|
|
2492
|
+
const autoKey = "directors";
|
|
2493
|
+
const autoVal = "auto-pick";
|
|
2309
2494
|
btn.innerHTML =
|
|
2310
2495
|
`<span class="cmp-cast-stack cmp-cast-stack-auto"><span class="cmp-cast-auto-mark">✦</span></span>` +
|
|
2311
2496
|
`<span class="cmp-cast-count cmp-cast-auto-label">` +
|
|
@@ -2320,9 +2505,8 @@
|
|
|
2320
2505
|
const avatars = visible.map((a) =>
|
|
2321
2506
|
`<img class="cmp-cast-av" src="${this.escape(a.avatarPath || "")}" alt="${this.escape(a.name || "")}" title="${this.escape(a.name || "")}">`,
|
|
2322
2507
|
).join("");
|
|
2323
|
-
|
|
2324
|
-
|
|
2325
|
-
: `${picked.length} director${picked.length === 1 ? "" : "s"}`;
|
|
2508
|
+
// System UI · always English (cast button chrome count).
|
|
2509
|
+
const countText = `${picked.length} director${picked.length === 1 ? "" : "s"}`;
|
|
2326
2510
|
btn.setAttribute("data-cast-mode", "manual");
|
|
2327
2511
|
btn.innerHTML =
|
|
2328
2512
|
`<span class="cmp-cast-stack">${avatars}${overflow > 0 ? `<span class="cmp-cast-more">+${overflow}</span>` : ""}<span class="cmp-cast-add" aria-hidden="true">+</span></span>` +
|
|
@@ -2338,14 +2522,12 @@
|
|
|
2338
2522
|
if (!anchorBtn || !this._followupCastState) return;
|
|
2339
2523
|
if (this._followupCastState.sameAsLast) return; // disabled when "same cast" is on
|
|
2340
2524
|
const state = this._followupCastState;
|
|
2341
|
-
const lang = state.lang || "en";
|
|
2342
2525
|
const dirs = (this.agents || [])
|
|
2343
2526
|
.filter((a) => a.roleKind !== "moderator")
|
|
2344
2527
|
.slice()
|
|
2345
2528
|
.sort((a, b) => (a.name || "").localeCompare(b.name || ""));
|
|
2346
|
-
|
|
2347
|
-
|
|
2348
|
-
: { title: "Pick directors", hint: "2-4 recommended", info: "View profile" };
|
|
2529
|
+
// System UI · always English (director picker chrome).
|
|
2530
|
+
const t = { title: "Pick directors", hint: "2-4 recommended", info: "View profile" };
|
|
2349
2531
|
const rows = dirs.map((a) => {
|
|
2350
2532
|
const checked = state.directorIds.includes(a.id);
|
|
2351
2533
|
return `
|
|
@@ -2515,6 +2697,17 @@
|
|
|
2515
2697
|
if (input) input.focus();
|
|
2516
2698
|
return;
|
|
2517
2699
|
}
|
|
2700
|
+
// Read the report-mode picker (research-note / bento). The
|
|
2701
|
+
// overlay defaults the picker to the existing brief's mode, so a
|
|
2702
|
+
// plain "regenerate with this perspective" keeps the same
|
|
2703
|
+
// format. Persisting the choice keeps the picker stable next
|
|
2704
|
+
// time. Server-side the same `/api/rooms/:id/brief` route reads
|
|
2705
|
+
// `mode` regardless of whether the call came from supplement or
|
|
2706
|
+
// generate-brief, so no backend change is needed.
|
|
2707
|
+
const briefModeInput = overlay.querySelector('input[name="brief-mode"]:checked');
|
|
2708
|
+
const v = briefModeInput && briefModeInput.value;
|
|
2709
|
+
const briefMode = this.isStructuredBriefMode(v) ? v : "research-note";
|
|
2710
|
+
this.saveLastBriefMode(briefMode);
|
|
2518
2711
|
// Frontend in-flight guard · without this, a slow server roundtrip
|
|
2519
2712
|
// gives the user time to click confirm twice (or to close+reopen
|
|
2520
2713
|
// the overlay and click again). Each click was firing its own POST,
|
|
@@ -2533,7 +2726,7 @@
|
|
|
2533
2726
|
{
|
|
2534
2727
|
method: "POST",
|
|
2535
2728
|
headers: { "content-type": "application/json" },
|
|
2536
|
-
body: JSON.stringify({ supplement: text }),
|
|
2729
|
+
body: JSON.stringify({ supplement: text, mode: briefMode }),
|
|
2537
2730
|
},
|
|
2538
2731
|
);
|
|
2539
2732
|
if (!r.ok) {
|
|
@@ -2564,7 +2757,7 @@
|
|
|
2564
2757
|
* whether streaming is actually in flight. */
|
|
2565
2758
|
isBriefPlaceholder(brief, room) {
|
|
2566
2759
|
if (!brief) return false;
|
|
2567
|
-
if (
|
|
2760
|
+
if (this.briefHasBody(brief)) return false;
|
|
2568
2761
|
if (!room) return true;
|
|
2569
2762
|
// Title matches the seed (room subject) → never got an updated title.
|
|
2570
2763
|
// Or title is the literal "Generating…" placeholder.
|
|
@@ -2679,23 +2872,14 @@
|
|
|
2679
2872
|
async deleteBriefAt(briefId) {
|
|
2680
2873
|
if (!briefId) return;
|
|
2681
2874
|
const target = (this.currentBriefs || []).find((b) => b.id === briefId);
|
|
2682
|
-
|
|
2683
|
-
//
|
|
2684
|
-
|
|
2685
|
-
|
|
2686
|
-
|
|
2687
|
-
|
|
2688
|
-
|
|
2689
|
-
|
|
2690
|
-
? "这份报告还在生成中。删除会立即停止生成并删除这份报告,此操作不可恢复。"
|
|
2691
|
-
: (target?.supplement
|
|
2692
|
-
? `删除这份"${target.supplement.trim().slice(0, 20)}${target.supplement.length > 20 ? "…" : ""}"补充视角的报告?此操作不可恢复。`
|
|
2693
|
-
: "删除这份报告?此操作不可恢复。"))
|
|
2694
|
-
: (isStillGenerating
|
|
2695
|
-
? "This report is still generating. Deleting will stop the generation and remove the report. This can't be undone."
|
|
2696
|
-
: (target?.supplement
|
|
2697
|
-
? `Delete the "${target.supplement.trim().slice(0, 20)}${target.supplement.length > 20 ? "…" : ""}" version? This can't be undone.`
|
|
2698
|
-
: "Delete this report? This can't be undone."));
|
|
2875
|
+
// System UI · always English. Confirm + alert dialogs are app
|
|
2876
|
+
// chrome and stay fixed-string regardless of brief language.
|
|
2877
|
+
const isStillGenerating = !!(target && !this.briefHasBody(target));
|
|
2878
|
+
const confirmText = isStillGenerating
|
|
2879
|
+
? "This report is still generating. Deleting will stop the generation and remove the report. This can't be undone."
|
|
2880
|
+
: (target?.supplement
|
|
2881
|
+
? `Delete the "${target.supplement.trim().slice(0, 20)}${target.supplement.length > 20 ? "…" : ""}" version? This can't be undone.`
|
|
2882
|
+
: "Delete this report? This can't be undone.");
|
|
2699
2883
|
if (!confirm(confirmText)) return;
|
|
2700
2884
|
try {
|
|
2701
2885
|
const r = await fetch("/api/briefs/" + encodeURIComponent(briefId), { method: "DELETE" });
|
|
@@ -2704,7 +2888,7 @@
|
|
|
2704
2888
|
throw new Error(e.error || ("HTTP " + r.status));
|
|
2705
2889
|
}
|
|
2706
2890
|
} catch (e) {
|
|
2707
|
-
alert(
|
|
2891
|
+
alert("Delete failed: " + (e && e.message ? e.message : e));
|
|
2708
2892
|
return;
|
|
2709
2893
|
}
|
|
2710
2894
|
// Patch local state · remove the deleted brief, refresh active.
|
|
@@ -2792,17 +2976,23 @@
|
|
|
2792
2976
|
const mode = overlay.getAttribute("data-adjourn-mode") || "adjourn";
|
|
2793
2977
|
const isGen = mode === "generate-brief";
|
|
2794
2978
|
const skipPicked = overlay.querySelector(".adjourn-skip-btn.picked") !== null;
|
|
2979
|
+
// Read the report-mode picker (research-note / bento). Persist the
|
|
2980
|
+
// choice so the picker defaults to the same option next time.
|
|
2981
|
+
const briefModeInput = overlay.querySelector('input[name="brief-mode"]:checked');
|
|
2982
|
+
const v = briefModeInput && briefModeInput.value;
|
|
2983
|
+
const briefMode = this.isStructuredBriefMode(v) ? v : "research-note";
|
|
2984
|
+
this.saveLastBriefMode(briefMode);
|
|
2795
2985
|
const btn = overlay.querySelector("[data-adjourn-confirm]");
|
|
2796
2986
|
const origLabel = isGen ? "[ Generate ]" : "[ Adjourn & file ]";
|
|
2797
2987
|
const busyLabel = isGen ? "[ Generating… ]" : "[ Adjourning… ]";
|
|
2798
2988
|
if (btn) { btn.disabled = true; btn.textContent = busyLabel; }
|
|
2799
2989
|
try {
|
|
2800
2990
|
if (isGen) {
|
|
2801
|
-
await this.generateBriefForAdjournedRoom();
|
|
2991
|
+
await this.generateBriefForAdjournedRoom(briefMode);
|
|
2802
2992
|
} else if (skipPicked) {
|
|
2803
2993
|
await this.adjournRoom({ skipBrief: true });
|
|
2804
2994
|
} else {
|
|
2805
|
-
await this.adjournRoom({});
|
|
2995
|
+
await this.adjournRoom({ mode: briefMode });
|
|
2806
2996
|
}
|
|
2807
2997
|
this.closeAdjournOverlay();
|
|
2808
2998
|
} catch (e) {
|
|
@@ -2815,8 +3005,9 @@
|
|
|
2815
3005
|
* whose user originally skipped the brief. Server emits the same
|
|
2816
3006
|
* brief-started / brief-token / brief-final SSE events as a normal
|
|
2817
3007
|
* adjourn, so the existing handlers in connectSSE handle the rest. */
|
|
2818
|
-
async generateBriefForAdjournedRoom() {
|
|
3008
|
+
async generateBriefForAdjournedRoom(mode) {
|
|
2819
3009
|
if (!this.currentRoomId) return;
|
|
3010
|
+
const briefMode = this.isStructuredBriefMode(mode) ? mode : "research-note";
|
|
2820
3011
|
// Pre-flight · the brief writer is a 3-stage LLM pipeline (per-
|
|
2821
3012
|
// director extract → composer → final write). All require a key.
|
|
2822
3013
|
if (!(await this.requireModelKey())) return;
|
|
@@ -2825,7 +3016,7 @@
|
|
|
2825
3016
|
{
|
|
2826
3017
|
method: "POST",
|
|
2827
3018
|
headers: { "content-type": "application/json" },
|
|
2828
|
-
body:
|
|
3019
|
+
body: JSON.stringify({ mode: briefMode }),
|
|
2829
3020
|
},
|
|
2830
3021
|
);
|
|
2831
3022
|
if (!r.ok) {
|
|
@@ -3047,7 +3238,7 @@
|
|
|
3047
3238
|
/** Spin up the countdown if conditions are met; otherwise cancel. */
|
|
3048
3239
|
maybeStartContinueCountdown() {
|
|
3049
3240
|
if (!this.canAutoContinue()) {
|
|
3050
|
-
this.cancelContinueCountdown(
|
|
3241
|
+
this.cancelContinueCountdown();
|
|
3051
3242
|
this.refreshContinueButton();
|
|
3052
3243
|
return;
|
|
3053
3244
|
}
|
|
@@ -3844,14 +4035,11 @@
|
|
|
3844
4035
|
renderSidebarCounts() {
|
|
3845
4036
|
const roomsCount = this.rooms.length;
|
|
3846
4037
|
const agentsCount = this.agents.length;
|
|
3847
|
-
const liveCount = this.rooms.filter((r) => r.status === "live").length;
|
|
3848
4038
|
|
|
3849
4039
|
const r = document.querySelector('[data-sidebar-tab-count="rooms"]');
|
|
3850
4040
|
if (r) r.textContent = String(roomsCount);
|
|
3851
4041
|
const a = document.querySelector('[data-sidebar-tab-count="agents"]');
|
|
3852
4042
|
if (a) a.textContent = String(agentsCount);
|
|
3853
|
-
const sum = document.querySelector("[data-sidebar-summary]");
|
|
3854
|
-
if (sum) sum.textContent = `${liveCount} LIVE / ${agentsCount} AGENTS`;
|
|
3855
4043
|
},
|
|
3856
4044
|
|
|
3857
4045
|
// ── Rendering · main view ─────────────────────────────────
|
|
@@ -3886,7 +4074,8 @@
|
|
|
3886
4074
|
if (existingBanner) existingBanner.remove();
|
|
3887
4075
|
const parent = this.currentParentRef;
|
|
3888
4076
|
if (chat && parent && parent.id) {
|
|
3889
|
-
|
|
4077
|
+
// System UI · always English (banner chrome on the follow-up parent link).
|
|
4078
|
+
const labelText = "// following up";
|
|
3890
4079
|
const subject = (parent.subject || "(no subject)").trim();
|
|
3891
4080
|
const banner = document.createElement("a");
|
|
3892
4081
|
banner.href = "#";
|
|
@@ -3906,9 +4095,8 @@
|
|
|
3906
4095
|
if (existingChildren) existingChildren.remove();
|
|
3907
4096
|
const kids = Array.isArray(this.currentFollowUps) ? this.currentFollowUps : [];
|
|
3908
4097
|
if (briefCard && kids.length > 0) {
|
|
3909
|
-
|
|
3910
|
-
|
|
3911
|
-
: `Follow-up rooms · ${kids.length}`;
|
|
4098
|
+
// System UI · always English (sidebar / brief-card chrome head).
|
|
4099
|
+
const headLabel = `Follow-up rooms · ${kids.length}`;
|
|
3912
4100
|
const block = document.createElement("div");
|
|
3913
4101
|
block.className = "followup-children";
|
|
3914
4102
|
block.innerHTML = [
|
|
@@ -4031,36 +4219,23 @@
|
|
|
4031
4219
|
const briefCard = document.querySelector("[data-brief-card]");
|
|
4032
4220
|
if (!briefCard) return;
|
|
4033
4221
|
|
|
4034
|
-
|
|
4035
|
-
|
|
4036
|
-
|
|
4037
|
-
|
|
4038
|
-
|
|
4039
|
-
|
|
4040
|
-
|
|
4041
|
-
|
|
4042
|
-
|
|
4043
|
-
|
|
4044
|
-
|
|
4045
|
-
|
|
4046
|
-
|
|
4047
|
-
|
|
4048
|
-
|
|
4049
|
-
|
|
4050
|
-
|
|
4051
|
-
head: "// session analytics",
|
|
4052
|
-
stamp: "closed",
|
|
4053
|
-
tokens: "tokens",
|
|
4054
|
-
messages: "msgs",
|
|
4055
|
-
rounds: "rounds",
|
|
4056
|
-
minutes: "min",
|
|
4057
|
-
modelHead: "Model usage",
|
|
4058
|
-
valueHead: "What you valued",
|
|
4059
|
-
valueEmpty: "No ▲ key-point votes, probes, or seconds in this session.",
|
|
4060
|
-
voted: "▲ voted",
|
|
4061
|
-
seconded: "★ seconded",
|
|
4062
|
-
probed: "✎ probed",
|
|
4063
|
-
};
|
|
4222
|
+
// System UI · always English. Session-analytics tile chrome
|
|
4223
|
+
// (banner, metric labels, section heads, chip text) is part of
|
|
4224
|
+
// the app shell and doesn't follow the brief language.
|
|
4225
|
+
const t = {
|
|
4226
|
+
head: "// session analytics",
|
|
4227
|
+
stamp: "closed",
|
|
4228
|
+
tokens: "tokens",
|
|
4229
|
+
messages: "msgs",
|
|
4230
|
+
rounds: "rounds",
|
|
4231
|
+
minutes: "min",
|
|
4232
|
+
modelHead: "Model usage",
|
|
4233
|
+
valueHead: "What you valued",
|
|
4234
|
+
valueEmpty: "No ▲ key-point votes, probes, or seconds in this session.",
|
|
4235
|
+
voted: "▲ voted",
|
|
4236
|
+
seconded: "★ seconded",
|
|
4237
|
+
probed: "✎ probed",
|
|
4238
|
+
};
|
|
4064
4239
|
|
|
4065
4240
|
const fmtTokens = (n) => {
|
|
4066
4241
|
if (!Number.isFinite(n) || n <= 0) return "0";
|
|
@@ -4164,8 +4339,10 @@
|
|
|
4164
4339
|
// analytics tile; capping keeps the section as a tight strip
|
|
4165
4340
|
// and lets the user opt in.
|
|
4166
4341
|
const VALUE_PREVIEW_CAP = 2;
|
|
4167
|
-
|
|
4168
|
-
|
|
4342
|
+
// System UI · always English (the surrounding tile content can
|
|
4343
|
+
// still localize via `t`, but toggle chrome is fixed-string).
|
|
4344
|
+
const moreLabel = (n) => `[ + show ${n} more ]`;
|
|
4345
|
+
const lessLabel = "[ collapse ]";
|
|
4169
4346
|
const upvotedHtml = stats.upvotedPoints.length > 0
|
|
4170
4347
|
? (() => {
|
|
4171
4348
|
const items = stats.upvotedPoints.map((p, i) => {
|
|
@@ -4281,12 +4458,13 @@
|
|
|
4281
4458
|
? this.escape(nextSpeaker.handle.replace(/^\//, ""))
|
|
4282
4459
|
: "";
|
|
4283
4460
|
|
|
4284
|
-
|
|
4285
|
-
|
|
4286
|
-
const
|
|
4287
|
-
const
|
|
4288
|
-
const
|
|
4289
|
-
const
|
|
4461
|
+
// System UI · always English. App chrome (paused bar, action
|
|
4462
|
+
// buttons, status labels) doesn't follow the brief language.
|
|
4463
|
+
const addInputLabel = "[ + Add input ]";
|
|
4464
|
+
const adjournLabel = "[ ▸ Adjourn & File Brief ]";
|
|
4465
|
+
const resumeLabel = "[ ▶ Resume Discussion ]";
|
|
4466
|
+
const pausedLabel = "paused";
|
|
4467
|
+
const nextLabel = "next";
|
|
4290
4468
|
const nextChunk = nextHandle
|
|
4291
4469
|
? ` · ${nextLabel} → <span class="lime">${nextHandle}</span>`
|
|
4292
4470
|
: "";
|
|
@@ -4445,6 +4623,12 @@
|
|
|
4445
4623
|
try { this._reportsLoadObserver.disconnect(); } catch { /* noop */ }
|
|
4446
4624
|
this._reportsLoadObserver = null;
|
|
4447
4625
|
}
|
|
4626
|
+
// The floating sidebar-expand button is gated on `html.no-room`.
|
|
4627
|
+
// Without setting it here, a user who collapses the sidebar
|
|
4628
|
+
// while on All Reports loses access to the expand control —
|
|
4629
|
+
// they have to navigate back to a room view to recover. Same
|
|
4630
|
+
// reasoning for openAllNotes / openAgentProfile.
|
|
4631
|
+
document.documentElement.classList.add("no-room");
|
|
4448
4632
|
// If we're inside a room or on the agent profile, leave them.
|
|
4449
4633
|
if (this.currentRoomId) {
|
|
4450
4634
|
this.disconnectSSE?.();
|
|
@@ -4650,32 +4834,14 @@
|
|
|
4650
4834
|
`;
|
|
4651
4835
|
};
|
|
4652
4836
|
|
|
4653
|
-
//
|
|
4654
|
-
//
|
|
4655
|
-
//
|
|
4656
|
-
//
|
|
4657
|
-
//
|
|
4658
|
-
const groups = [];
|
|
4659
|
-
const yesterdayStart = todayStart - 86400_000;
|
|
4660
|
-
let currentGroup = null;
|
|
4661
|
-
const groupLabelFor = (ts) => {
|
|
4662
|
-
if (ts >= todayStart) return "Today";
|
|
4663
|
-
if (ts >= yesterdayStart) return "Yesterday";
|
|
4664
|
-
if (ts >= weekStart) return "This week";
|
|
4665
|
-
return "Earlier";
|
|
4666
|
-
};
|
|
4667
|
-
for (const b of visibleFiltered) {
|
|
4668
|
-
const label = groupLabelFor(b.createdAt);
|
|
4669
|
-
if (!currentGroup || currentGroup.label !== label) {
|
|
4670
|
-
currentGroup = { label, items: [] };
|
|
4671
|
-
groups.push(currentGroup);
|
|
4672
|
-
}
|
|
4673
|
-
currentGroup.items.push(b);
|
|
4674
|
-
}
|
|
4675
|
-
|
|
4837
|
+
// Flat list under the filter chips · earlier iterations grouped
|
|
4838
|
+
// by Today / Yesterday / This week / Earlier, but the user
|
|
4839
|
+
// wanted a single divider followed by the items. The filter
|
|
4840
|
+
// chips above already disclose the recency dimension; redundant
|
|
4841
|
+
// group headers were just visual chrome.
|
|
4676
4842
|
const filterLabels = { all: "the archive", today: "Today", week: "This week", earlier: "Earlier" };
|
|
4677
4843
|
const filterCopyTitle = filterLabels[activeFilter] || "this window";
|
|
4678
|
-
const groupsHtml =
|
|
4844
|
+
const groupsHtml = visibleFiltered.length === 0
|
|
4679
4845
|
? `
|
|
4680
4846
|
<div class="reports-list-empty">
|
|
4681
4847
|
<!-- Notice text · explains the empty window. -->
|
|
@@ -4700,14 +4866,11 @@
|
|
|
4700
4866
|
` : ""}
|
|
4701
4867
|
</div>
|
|
4702
4868
|
`
|
|
4703
|
-
:
|
|
4704
|
-
<
|
|
4705
|
-
|
|
4706
|
-
|
|
4707
|
-
|
|
4708
|
-
</ul>
|
|
4709
|
-
</div>
|
|
4710
|
-
`).join("");
|
|
4869
|
+
: `
|
|
4870
|
+
<ul class="reports-list">
|
|
4871
|
+
${visibleFiltered.map((b) => this.renderReportItemHtml(b)).join("")}
|
|
4872
|
+
</ul>
|
|
4873
|
+
`;
|
|
4711
4874
|
|
|
4712
4875
|
// Bottom sentinel · IntersectionObserver target. Renders only
|
|
4713
4876
|
// when there's more to load. The "+ N more" hint doubles as a
|
|
@@ -4862,6 +5025,9 @@
|
|
|
4862
5025
|
* Pulls the live list and renders three time-bucket sections
|
|
4863
5026
|
* (Today / This Week / Earlier). */
|
|
4864
5027
|
async openAllNotes() {
|
|
5028
|
+
// Set the no-room flag for the same reason openAllReports does
|
|
5029
|
+
// — the floating sidebar-expand button is gated on this class.
|
|
5030
|
+
document.documentElement.classList.add("no-room");
|
|
4865
5031
|
// Same view-leaving routine as openAllReports.
|
|
4866
5032
|
if (this.currentRoomId) {
|
|
4867
5033
|
this.disconnectSSE?.();
|
|
@@ -4990,9 +5156,30 @@
|
|
|
4990
5156
|
<div class="notes-empty-mark">○</div>
|
|
4991
5157
|
<div class="notes-empty-title">no saved notes yet</div>
|
|
4992
5158
|
<div class="notes-empty-deck">
|
|
4993
|
-
|
|
4994
|
-
|
|
4995
|
-
|
|
5159
|
+
Capture a director's line as a note:
|
|
5160
|
+
</div>
|
|
5161
|
+
<ol class="notes-empty-steps">
|
|
5162
|
+
<li>
|
|
5163
|
+
<span class="notes-empty-step-num">1</span>
|
|
5164
|
+
<span class="notes-empty-step-text">
|
|
5165
|
+
<strong>Open a room.</strong> Any director's reply can be saved.
|
|
5166
|
+
</span>
|
|
5167
|
+
</li>
|
|
5168
|
+
<li>
|
|
5169
|
+
<span class="notes-empty-step-num">2</span>
|
|
5170
|
+
<span class="notes-empty-step-text">
|
|
5171
|
+
<strong>Select a passage.</strong> Drag-highlight the sentence or paragraph you want to keep.
|
|
5172
|
+
</span>
|
|
5173
|
+
</li>
|
|
5174
|
+
<li>
|
|
5175
|
+
<span class="notes-empty-step-num">3</span>
|
|
5176
|
+
<span class="notes-empty-step-text">
|
|
5177
|
+
<strong>Click <span class="kbd">⌖ Save</span></strong> on the floating bar that appears, or press <span class="kbd">S</span>. The excerpt lands here, indexed across all rooms.
|
|
5178
|
+
</span>
|
|
5179
|
+
</li>
|
|
5180
|
+
</ol>
|
|
5181
|
+
<div class="notes-empty-foot">
|
|
5182
|
+
Each saved excerpt links back to the original room + speaker, so you can jump back any time.
|
|
4996
5183
|
</div>
|
|
4997
5184
|
</div>
|
|
4998
5185
|
`;
|
|
@@ -5007,27 +5194,12 @@
|
|
|
5007
5194
|
return true;
|
|
5008
5195
|
});
|
|
5009
5196
|
|
|
5010
|
-
//
|
|
5011
|
-
//
|
|
5012
|
-
//
|
|
5013
|
-
|
|
5014
|
-
if (ts >= todayStart) return "Today";
|
|
5015
|
-
if (ts >= weekStart) return "This week";
|
|
5016
|
-
return "Earlier";
|
|
5017
|
-
};
|
|
5018
|
-
const groups = [];
|
|
5019
|
-
let currentGroup = null;
|
|
5020
|
-
for (const n of filtered) {
|
|
5021
|
-
const label = groupLabelFor(n.createdAt || 0);
|
|
5022
|
-
if (!currentGroup || currentGroup.label !== label) {
|
|
5023
|
-
currentGroup = { label, items: [] };
|
|
5024
|
-
groups.push(currentGroup);
|
|
5025
|
-
}
|
|
5026
|
-
currentGroup.items.push(n);
|
|
5027
|
-
}
|
|
5028
|
-
|
|
5197
|
+
// Flat list under the filter chips. Earlier iterations grouped
|
|
5198
|
+
// by Today / This week / Earlier headers; the user wanted a
|
|
5199
|
+
// single divider followed by the items, since the chips above
|
|
5200
|
+
// already disclose the recency dimension.
|
|
5029
5201
|
const filterLabels = { all: "the archive", today: "Today", week: "This week", earlier: "Earlier" };
|
|
5030
|
-
const groupsHtml =
|
|
5202
|
+
const groupsHtml = filtered.length === 0
|
|
5031
5203
|
? `
|
|
5032
5204
|
<div class="notes-list-empty">
|
|
5033
5205
|
<div class="notes-empty-mark">○</div>
|
|
@@ -5041,17 +5213,11 @@
|
|
|
5041
5213
|
` : ""}
|
|
5042
5214
|
</div>
|
|
5043
5215
|
`
|
|
5044
|
-
:
|
|
5045
|
-
<
|
|
5046
|
-
|
|
5047
|
-
|
|
5048
|
-
|
|
5049
|
-
</div>
|
|
5050
|
-
<ul class="notes-list">
|
|
5051
|
-
${g.items.map((n) => this.renderNoteItemHtml(n)).join("")}
|
|
5052
|
-
</ul>
|
|
5053
|
-
</section>
|
|
5054
|
-
`).join("");
|
|
5216
|
+
: `
|
|
5217
|
+
<ul class="notes-list">
|
|
5218
|
+
${filtered.map((n) => this.renderNoteItemHtml(n)).join("")}
|
|
5219
|
+
</ul>
|
|
5220
|
+
`;
|
|
5055
5221
|
|
|
5056
5222
|
const totalLabel = `${total} ${total === 1 ? "note" : "notes"}`;
|
|
5057
5223
|
const roomLabel = distinctRooms > 0
|
|
@@ -5091,9 +5257,12 @@
|
|
|
5091
5257
|
const author = n.authorName || "Director";
|
|
5092
5258
|
// The jump link uses the room's hash route + the note id as a
|
|
5093
5259
|
// fragment-style query so openRoom can scroll to + flash the
|
|
5094
|
-
// matching span
|
|
5095
|
-
//
|
|
5260
|
+
// matching span. The delete button sits as a SIBLING of the
|
|
5261
|
+
// anchor (inside the .notes-item) so its click never bubbles
|
|
5262
|
+
// through the navigation link — same pattern as session-row
|
|
5263
|
+
// delete in the rooms sidebar.
|
|
5096
5264
|
const href = `#/r/${this.escape(n.roomId)}?note=${this.escape(n.id)}`;
|
|
5265
|
+
const deleteTitle = "Delete this note";
|
|
5097
5266
|
return `
|
|
5098
5267
|
<li class="notes-item" data-note-id="${this.escape(n.id)}">
|
|
5099
5268
|
<a class="notes-item-link" href="${href}" data-note-jump="${this.escape(n.id)}" data-note-room="${this.escape(n.roomId)}">
|
|
@@ -5110,10 +5279,39 @@
|
|
|
5110
5279
|
n.contextAfter ? `<span class="note-context note-context-after">${this.escape(n.contextAfter)}</span>` : ""
|
|
5111
5280
|
}</p>
|
|
5112
5281
|
</a>
|
|
5282
|
+
<button type="button" class="notes-item-delete" data-note-delete title="${this.escape(deleteTitle)}" aria-label="${this.escape(deleteTitle)}">✕</button>
|
|
5113
5283
|
</li>
|
|
5114
5284
|
`;
|
|
5115
5285
|
},
|
|
5116
5286
|
|
|
5287
|
+
/** DELETE /api/notes/:id · drops a saved excerpt from the index.
|
|
5288
|
+
* Confirmation is light because the action is reversible only by
|
|
5289
|
+
* re-saving the same passage; we keep the prompt as a single
|
|
5290
|
+
* click-confirm rather than a full overlay. */
|
|
5291
|
+
async deleteNoteAt(id) {
|
|
5292
|
+
if (!id) return;
|
|
5293
|
+
if (!confirm("Delete this note? You can save it again from the room.")) return;
|
|
5294
|
+
try {
|
|
5295
|
+
const r = await fetch("/api/notes/" + encodeURIComponent(id), { method: "DELETE" });
|
|
5296
|
+
if (!r.ok) {
|
|
5297
|
+
const j = await r.json().catch(() => ({}));
|
|
5298
|
+
alert("Delete failed: " + (j.error || r.statusText));
|
|
5299
|
+
return;
|
|
5300
|
+
}
|
|
5301
|
+
} catch (e) {
|
|
5302
|
+
alert("Delete failed: " + (e && e.message ? e.message : e));
|
|
5303
|
+
return;
|
|
5304
|
+
}
|
|
5305
|
+
// Patch local cache so the immediate re-render reflects the
|
|
5306
|
+
// delete without a refetch round-trip. The sidebar count badge
|
|
5307
|
+
// re-fetches via the /count endpoint.
|
|
5308
|
+
if (Array.isArray(this._notesCache)) {
|
|
5309
|
+
this._notesCache = this._notesCache.filter((n) => n && n.id !== id);
|
|
5310
|
+
this.renderNotesPage(this._notesCache);
|
|
5311
|
+
}
|
|
5312
|
+
this.refreshNotesCount();
|
|
5313
|
+
},
|
|
5314
|
+
|
|
5117
5315
|
/** Refresh the sidebar count badge · called on boot, after
|
|
5118
5316
|
* every successful save (note:created event), and after a
|
|
5119
5317
|
* delete. Hits /api/notes/count which is cheap (one COUNT query),
|
|
@@ -5535,31 +5733,23 @@
|
|
|
5535
5733
|
const userName = (this.prefs?.name || "you").trim() || "you";
|
|
5536
5734
|
const lang = this.composerLanguage();
|
|
5537
5735
|
const greeting = this.composerGreeting(lang, userName);
|
|
5538
|
-
|
|
5539
|
-
|
|
5540
|
-
|
|
5541
|
-
|
|
5542
|
-
|
|
5543
|
-
|
|
5544
|
-
|
|
5545
|
-
|
|
5546
|
-
|
|
5547
|
-
|
|
5548
|
-
|
|
5549
|
-
|
|
5550
|
-
|
|
5551
|
-
:
|
|
5552
|
-
|
|
5553
|
-
|
|
5554
|
-
|
|
5555
|
-
convene: "Convene",
|
|
5556
|
-
tuneLabel: "tune",
|
|
5557
|
-
starterLabel: "starter",
|
|
5558
|
-
starterCaption: "or try a starter",
|
|
5559
|
-
pickerLabel: "Pick directors",
|
|
5560
|
-
directorsLabel: (n) => `${n} director${n === 1 ? "" : "s"}`,
|
|
5561
|
-
directorsAdd: "add",
|
|
5562
|
-
};
|
|
5736
|
+
// System UI · always English. Composer chrome (prompt /
|
|
5737
|
+
// placeholder / button labels) doesn't follow the brief
|
|
5738
|
+
// language. The brief content + chair/director output still
|
|
5739
|
+
// honours the user's input language; only the app shell is
|
|
5740
|
+
// fixed-string.
|
|
5741
|
+
const t = {
|
|
5742
|
+
greet: greeting,
|
|
5743
|
+
prompt: "What's on your mind today?",
|
|
5744
|
+
placeholder: "an idea you're not sure about · a decision you keep avoiding · a thesis you want stress-tested",
|
|
5745
|
+
convene: "Convene",
|
|
5746
|
+
tuneLabel: "tune",
|
|
5747
|
+
starterLabel: "starter",
|
|
5748
|
+
starterCaption: "or try a starter",
|
|
5749
|
+
pickerLabel: "Pick directors",
|
|
5750
|
+
directorsLabel: (n) => `${n} director${n === 1 ? "" : "s"}`,
|
|
5751
|
+
directorsAdd: "add",
|
|
5752
|
+
};
|
|
5563
5753
|
|
|
5564
5754
|
// Cast slot · two visual modes:
|
|
5565
5755
|
// 1. Auto-pick (default · empty manual selection): chip
|
|
@@ -5578,16 +5768,15 @@
|
|
|
5578
5768
|
// Label uses "directors" (not "cast") so the chip's purpose
|
|
5579
5769
|
// is unambiguous: it's the slot that picks the boardroom's
|
|
5580
5770
|
// director agents. Tooltip carries the long-form explanation.
|
|
5581
|
-
|
|
5582
|
-
|
|
5583
|
-
: "Chair picks 3 directors based on your subject when you Convene · click to pick manually";
|
|
5771
|
+
// System UI · always English (auto-pick chip tooltip).
|
|
5772
|
+
const autoTip = "Chair picks 3 directors based on your subject when you Convene · click to pick manually";
|
|
5584
5773
|
castInner = `
|
|
5585
5774
|
<span class="cmp-cast-stack cmp-cast-stack-auto" data-cast-auto title="${this.escape(autoTip)}">
|
|
5586
5775
|
<span class="cmp-cast-auto-mark">✦</span>
|
|
5587
5776
|
</span>
|
|
5588
5777
|
<span class="cmp-cast-count cmp-cast-auto-label" title="${this.escape(autoTip)}">
|
|
5589
|
-
<span class="cmp-cast-auto-key"
|
|
5590
|
-
<span class="cmp-cast-auto-val"
|
|
5778
|
+
<span class="cmp-cast-auto-key">directors</span>
|
|
5779
|
+
<span class="cmp-cast-auto-val">auto-pick</span>
|
|
5591
5780
|
</span>
|
|
5592
5781
|
`;
|
|
5593
5782
|
} else {
|
|
@@ -5598,7 +5787,7 @@
|
|
|
5598
5787
|
`).join("");
|
|
5599
5788
|
const dirCount = dirObjs.length
|
|
5600
5789
|
? `<span class="cmp-cast-count">${this.escape(t.directorsLabel(dirObjs.length))}</span>`
|
|
5601
|
-
: `<span class="cmp-cast-count cmp-cast-empty"
|
|
5790
|
+
: `<span class="cmp-cast-count cmp-cast-empty">no directors</span>`;
|
|
5602
5791
|
castInner = `
|
|
5603
5792
|
<span class="cmp-cast-stack">
|
|
5604
5793
|
${dirAvatars}
|
|
@@ -5612,8 +5801,9 @@
|
|
|
5612
5801
|
// Tune dropdowns · two trigger buttons that open option popovers
|
|
5613
5802
|
// on click. Discoverable (label + value + chevron pattern is
|
|
5614
5803
|
// unmistakeably a select) without taking up visual real estate.
|
|
5615
|
-
|
|
5616
|
-
const
|
|
5804
|
+
// System UI · always English (tune dropdown labels).
|
|
5805
|
+
const toneLbl = "tone";
|
|
5806
|
+
const intensityLbl = "intensity";
|
|
5617
5807
|
|
|
5618
5808
|
// Starter grid · 2-col responsive cards.
|
|
5619
5809
|
const starters = Array.isArray(window.BOARDROOM_STARTERS) ? window.BOARDROOM_STARTERS : [];
|
|
@@ -5679,12 +5869,10 @@
|
|
|
5679
5869
|
},
|
|
5680
5870
|
|
|
5681
5871
|
/** Time-of-day greeting like "// good evening, Kay" / "// 晚上好,Kay". */
|
|
5682
|
-
composerGreeting(
|
|
5872
|
+
composerGreeting(_lang, name) {
|
|
5873
|
+
// System UI · always English. Greeting is part of the composer
|
|
5874
|
+
// chrome; the brief language doesn't change the app's voice.
|
|
5683
5875
|
const h = new Date().getHours();
|
|
5684
|
-
if (lang === "zh") {
|
|
5685
|
-
const part = h < 5 ? "凌晨好" : h < 12 ? "早上好" : h < 14 ? "中午好" : h < 18 ? "下午好" : h < 23 ? "晚上好" : "夜深了";
|
|
5686
|
-
return `// ${part},${name}`;
|
|
5687
|
-
}
|
|
5688
5876
|
const part = h < 5 ? "Up late" : h < 12 ? "Good morning" : h < 18 ? "Good afternoon" : "Good evening";
|
|
5689
5877
|
return `// ${part}, ${name}`;
|
|
5690
5878
|
},
|
|
@@ -5851,7 +6039,7 @@
|
|
|
5851
6039
|
* user can edit before submitting. */
|
|
5852
6040
|
applyAgentStarter(idx) {
|
|
5853
6041
|
const lang = this.composerLanguage();
|
|
5854
|
-
const list =
|
|
6042
|
+
const list = this.AGENT_STARTERS_EN;
|
|
5855
6043
|
const item = list[idx];
|
|
5856
6044
|
if (!item) return;
|
|
5857
6045
|
const ta = document.querySelector("[data-agent-composer-desc]");
|
|
@@ -5878,39 +6066,27 @@
|
|
|
5878
6066
|
{ key: "voice", label: "Picking the model voice", startSec: 28, sub: ["matching depth to role"] },
|
|
5879
6067
|
{ key: "polish", label: "Polishing", startSec: 31, sub: ["clamping lengths", "final tightening"] },
|
|
5880
6068
|
],
|
|
5881
|
-
AGENT_GEN_STAGES_ZH_BASE: [
|
|
5882
|
-
{ key: "lineage", label: "勾画智识画像", startSec: 0, sub: ["梳理思想脉络", "标记反对的传统", "挑出具体引用"] },
|
|
5883
|
-
{ key: "imagine", label: "构思角色", startSec: 8, sub: ["勾画语气", "拟定立场", "确定视角"] },
|
|
5884
|
-
{ key: "name", label: "起名 + handle", startSec: 11, sub: ["试几个短名", "排查 handle 重名"] },
|
|
5885
|
-
{ key: "bio", label: "起草 bio", startSec: 14, sub: ["一两句话", "点明方法"] },
|
|
5886
|
-
{ key: "quote", label: "写一句开场问", startSec: 17, sub: ["这位董事每次会议会先问什么"] },
|
|
5887
|
-
{ key: "instruction", label: "撰写 instruction", startSec: 20, sub: ["脉络 + 概念", "method + 引用集", "语气 + 边界", "失效模式"] },
|
|
5888
|
-
{ key: "voice", label: "挑选模型嗓音", startSec: 28, sub: ["按角色深度匹配 model"] },
|
|
5889
|
-
{ key: "polish", label: "收尾打磨", startSec: 31, sub: ["长度修剪", "最后一遍"] },
|
|
5890
|
-
],
|
|
5891
6069
|
/** Web-search prefix · prepended to the base list when the user
|
|
5892
6070
|
* opted into web search this run. Adds ~5s to the perceived
|
|
5893
|
-
* pipeline; downstream startSecs are shifted in agentGenStagesFor.
|
|
6071
|
+
* pipeline; downstream startSecs are shifted in agentGenStagesFor.
|
|
6072
|
+
* System UI · English-only. */
|
|
5894
6073
|
AGENT_GEN_STAGES_WS_EN: { key: "search", label: "Searching the web for context", startSec: 0, sub: ["refining the query", "scanning Brave results", "distilling 5–6 named sources"] },
|
|
5895
|
-
AGENT_GEN_STAGES_WS_ZH: { key: "search", label: "联网检索领域上下文", startSec: 0, sub: ["精炼查询", "扫描 Brave 结果", "提炼 5–6 条具名来源"] },
|
|
5896
6074
|
|
|
5897
6075
|
/** Build the active stage list for THIS generation. When web search
|
|
5898
|
-
* is on, prepend the search stage and shift everything else later.
|
|
5899
|
-
|
|
5900
|
-
|
|
6076
|
+
* is on, prepend the search stage and shift everything else later.
|
|
6077
|
+
* System UI · English-only regardless of `lang`. */
|
|
6078
|
+
agentGenStagesFor(_lang) {
|
|
6079
|
+
const base = this.AGENT_GEN_STAGES_EN_BASE;
|
|
5901
6080
|
const useWs = !!this._agentGenUsingWebSearch;
|
|
5902
6081
|
if (!useWs) return base;
|
|
5903
|
-
const wsEntry = lang === "zh" ? this.AGENT_GEN_STAGES_WS_ZH : this.AGENT_GEN_STAGES_WS_EN;
|
|
5904
6082
|
const SHIFT = 5;
|
|
5905
6083
|
const shifted = base.map((s) => ({ ...s, startSec: s.startSec + SHIFT }));
|
|
5906
|
-
return [
|
|
6084
|
+
return [this.AGENT_GEN_STAGES_WS_EN, ...shifted];
|
|
5907
6085
|
},
|
|
5908
6086
|
|
|
5909
|
-
/** Back-compat shims · existing references read these names directly.
|
|
5910
|
-
* Resolved at call-time so the web-search variant is picked when
|
|
5911
|
-
* the flag is set. */
|
|
6087
|
+
/** Back-compat shims · existing references read these names directly. */
|
|
5912
6088
|
get AGENT_GEN_STAGES_EN() { return this.agentGenStagesFor("en"); },
|
|
5913
|
-
get AGENT_GEN_STAGES_ZH() { return this.agentGenStagesFor("
|
|
6089
|
+
get AGENT_GEN_STAGES_ZH() { return this.agentGenStagesFor("en"); },
|
|
5914
6090
|
|
|
5915
6091
|
/** Start the stage tick. Called when /generate-spec request fires.
|
|
5916
6092
|
* Idempotent — calling twice is safe. */
|
|
@@ -5925,8 +6101,7 @@
|
|
|
5925
6101
|
return;
|
|
5926
6102
|
}
|
|
5927
6103
|
const elapsed = (Date.now() - this.agentGenStartedAt) / 1000;
|
|
5928
|
-
const
|
|
5929
|
-
const stages = lang === "zh" ? this.AGENT_GEN_STAGES_ZH : this.AGENT_GEN_STAGES_EN;
|
|
6104
|
+
const stages = this.AGENT_GEN_STAGES_EN;
|
|
5930
6105
|
// Find current stage index by elapsed time.
|
|
5931
6106
|
let idx = 0;
|
|
5932
6107
|
for (let i = 0; i < stages.length; i++) {
|
|
@@ -5957,12 +6132,13 @@
|
|
|
5957
6132
|
},
|
|
5958
6133
|
|
|
5959
6134
|
renderAgentGenStagesInner() {
|
|
5960
|
-
|
|
5961
|
-
|
|
6135
|
+
// System UI · always English. Agent-generation stage panel is
|
|
6136
|
+
// app chrome around the LLM call.
|
|
6137
|
+
const stages = this.AGENT_GEN_STAGES_EN;
|
|
5962
6138
|
const active = this.agentGenStageIndex;
|
|
5963
6139
|
const elapsed = Math.max(0, (Date.now() - this.agentGenStartedAt) / 1000);
|
|
5964
|
-
const elapsedLabel =
|
|
5965
|
-
const headerLabel =
|
|
6140
|
+
const elapsedLabel = `${Math.round(elapsed)} s elapsed`;
|
|
6141
|
+
const headerLabel = "Summoning director";
|
|
5966
6142
|
const sigilSvg = this.renderAgentGenSigilSvg(stages, active, elapsed);
|
|
5967
6143
|
// The active stage's headline pulse — lifted out from the list so
|
|
5968
6144
|
// it reads as the focal "what's happening RIGHT NOW" line.
|
|
@@ -5980,7 +6156,7 @@
|
|
|
5980
6156
|
<div class="ag-gen-stage-area">
|
|
5981
6157
|
<div class="ag-gen-sigil" aria-hidden="true">${sigilSvg}</div>
|
|
5982
6158
|
<div class="ag-gen-active-block">
|
|
5983
|
-
<div class="ag-gen-active-kicker">${this.escape(
|
|
6159
|
+
<div class="ag-gen-active-kicker">${this.escape(`step ${active + 1} of ${stages.length}`)}</div>
|
|
5984
6160
|
<div class="ag-gen-active-label">${this.escape(activeStage ? activeStage.label : "")}</div>
|
|
5985
6161
|
${activeSubText ? `<div class="ag-gen-active-sub">${this.escape(activeSubText)}</div>` : ""}
|
|
5986
6162
|
</div>
|
|
@@ -6096,42 +6272,28 @@
|
|
|
6096
6272
|
{ 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." },
|
|
6097
6273
|
{ tag: "phenomenologist", text: "An observer who notices what the room ISN'T saying. Tracks tone, what got skipped, who agreed too fast." },
|
|
6098
6274
|
],
|
|
6099
|
-
|
|
6100
|
-
|
|
6101
|
-
|
|
6102
|
-
|
|
6103
|
-
|
|
6104
|
-
{ tag: "critique-reviewer", text: "一位资深评审,对任何交付物做系统性审稿——每个瑕疵打 blocker / major / minor 严重度,指向具体段落、说出失败机制。不挑出至少一条 major 不会放过。" },
|
|
6105
|
-
{ tag: "phenomenologist", text: "一位观察者,捕捉房间里没说出来的东西:语气、被跳过的话题、太快达成的一致。" },
|
|
6106
|
-
],
|
|
6275
|
+
/** Legacy ZH list · kept as an alias of EN so any external caller
|
|
6276
|
+
* reading the property still resolves. System UI is English-only
|
|
6277
|
+
* per the global rule (the brief language doesn't change app
|
|
6278
|
+
* chrome). New code should reference AGENT_STARTERS_EN directly. */
|
|
6279
|
+
get AGENT_STARTERS_ZH() { return this.AGENT_STARTERS_EN; },
|
|
6107
6280
|
|
|
6108
6281
|
renderAgentComposerHtml() {
|
|
6109
6282
|
const userName = (this.prefs?.name || "you").trim() || "you";
|
|
6110
6283
|
const lang = this.composerLanguage();
|
|
6111
6284
|
const greeting = this.composerGreeting(lang, userName);
|
|
6112
|
-
|
|
6113
|
-
|
|
6114
|
-
|
|
6115
|
-
|
|
6116
|
-
|
|
6117
|
-
|
|
6118
|
-
|
|
6119
|
-
|
|
6120
|
-
|
|
6121
|
-
|
|
6122
|
-
|
|
6123
|
-
|
|
6124
|
-
: {
|
|
6125
|
-
greet: greeting,
|
|
6126
|
-
prompt: "What kind of director do you want?",
|
|
6127
|
-
placeholder: "A few sentences on their role, method, stance. e.g. A seasoned product hand who reasons from the user's moment of friction. Will reject any argument that doesn't name what the user is doing right then.",
|
|
6128
|
-
cta: "Generate",
|
|
6129
|
-
ctaHint: "AI drafts the full spec — you'll edit before saving",
|
|
6130
|
-
manual: "Configure manually",
|
|
6131
|
-
generating: "Generating…",
|
|
6132
|
-
modelLabel: "model",
|
|
6133
|
-
starterCaption: "or start from an archetype",
|
|
6134
|
-
};
|
|
6285
|
+
// System UI · always English (new-agent composer chrome).
|
|
6286
|
+
const t = {
|
|
6287
|
+
greet: greeting,
|
|
6288
|
+
prompt: "What kind of director do you want?",
|
|
6289
|
+
placeholder: "A few sentences on their role, method, stance. e.g. A seasoned product hand who reasons from the user's moment of friction. Will reject any argument that doesn't name what the user is doing right then.",
|
|
6290
|
+
cta: "Generate",
|
|
6291
|
+
ctaHint: "AI drafts the full spec — you'll edit before saving",
|
|
6292
|
+
manual: "Configure manually",
|
|
6293
|
+
generating: "Generating…",
|
|
6294
|
+
modelLabel: "model",
|
|
6295
|
+
starterCaption: "or start from an archetype",
|
|
6296
|
+
};
|
|
6135
6297
|
// If we already have a spec preview, render that instead of the input.
|
|
6136
6298
|
if (this.agentSpec) {
|
|
6137
6299
|
return this.renderAgentSpecPreviewHtml(this.agentSpec, lang);
|
|
@@ -6145,7 +6307,7 @@
|
|
|
6145
6307
|
const generating = this.agentSpecGenerating;
|
|
6146
6308
|
const currentModel = this.loadAgentComposerModel();
|
|
6147
6309
|
const modelDisplay = MODEL_LABELS[currentModel] || currentModel;
|
|
6148
|
-
const starters =
|
|
6310
|
+
const starters = this.AGENT_STARTERS_EN;
|
|
6149
6311
|
const starterCards = starters.map((q, idx) => `
|
|
6150
6312
|
<button type="button" class="cmp-starter" data-agent-starter="${idx}">
|
|
6151
6313
|
<div class="cmp-starter-tag">${this.escape(q.tag)}</div>
|
|
@@ -6181,23 +6343,14 @@
|
|
|
6181
6343
|
// skill row at agent-profile.js:1411.
|
|
6182
6344
|
const configured = this.agentComposerBraveConfigured();
|
|
6183
6345
|
const on = configured && this.loadAgentComposerWebSearch();
|
|
6184
|
-
|
|
6185
|
-
|
|
6186
|
-
: on
|
|
6187
|
-
? (lang === "zh" ? "已开启" : "enabled")
|
|
6188
|
-
: (lang === "zh" ? "已关闭" : "disabled");
|
|
6346
|
+
// System UI · always English (web-search toggle chrome).
|
|
6347
|
+
const stateLabel = !configured ? "needs key" : on ? "enabled" : "disabled";
|
|
6189
6348
|
const titleText = !configured
|
|
6190
|
-
?
|
|
6191
|
-
? "联网搜索需要 Brave Search API key · 点击配置"
|
|
6192
|
-
: "Web search needs a Brave Search API key · click to configure")
|
|
6349
|
+
? "Web search needs a Brave Search API key · click to configure"
|
|
6193
6350
|
: on
|
|
6194
|
-
?
|
|
6195
|
-
|
|
6196
|
-
|
|
6197
|
-
: (lang === "zh"
|
|
6198
|
-
? "生成时不联网 · 点击开启"
|
|
6199
|
-
: "Generation runs offline · click to enable web search");
|
|
6200
|
-
const wsLabel = lang === "zh" ? "联网搜索" : "web search";
|
|
6351
|
+
? "Search the web for real domain references during generation · click to disable"
|
|
6352
|
+
: "Generation runs offline · click to enable web search";
|
|
6353
|
+
const wsLabel = "web search";
|
|
6201
6354
|
const cls = [
|
|
6202
6355
|
"ap-skill-row-toggle",
|
|
6203
6356
|
"cmp-ws-toggle",
|
|
@@ -6243,38 +6396,23 @@
|
|
|
6243
6396
|
},
|
|
6244
6397
|
|
|
6245
6398
|
/** Preview card · all generated fields editable inline. */
|
|
6246
|
-
renderAgentSpecPreviewHtml(spec,
|
|
6247
|
-
|
|
6248
|
-
|
|
6249
|
-
|
|
6250
|
-
|
|
6251
|
-
|
|
6252
|
-
|
|
6253
|
-
|
|
6254
|
-
|
|
6255
|
-
|
|
6256
|
-
|
|
6257
|
-
|
|
6258
|
-
|
|
6259
|
-
|
|
6260
|
-
|
|
6261
|
-
|
|
6262
|
-
|
|
6263
|
-
: {
|
|
6264
|
-
kicker: "// generated director · edit and save",
|
|
6265
|
-
avatar: "Avatar",
|
|
6266
|
-
reroll: "Reroll",
|
|
6267
|
-
name: "Name",
|
|
6268
|
-
handle: "Handle",
|
|
6269
|
-
role: "Role tag",
|
|
6270
|
-
bio: "Bio",
|
|
6271
|
-
quote: "Cover quote",
|
|
6272
|
-
instruction: "Instruction",
|
|
6273
|
-
model: "Model",
|
|
6274
|
-
save: "Save director",
|
|
6275
|
-
discard: "Discard",
|
|
6276
|
-
redo: "Regenerate",
|
|
6277
|
-
};
|
|
6399
|
+
renderAgentSpecPreviewHtml(spec, _lang) {
|
|
6400
|
+
// System UI · always English (agent-spec preview card chrome).
|
|
6401
|
+
const t = {
|
|
6402
|
+
kicker: "// generated director · edit and save",
|
|
6403
|
+
avatar: "Avatar",
|
|
6404
|
+
reroll: "Reroll",
|
|
6405
|
+
name: "Name",
|
|
6406
|
+
handle: "Handle",
|
|
6407
|
+
role: "Role tag",
|
|
6408
|
+
bio: "Bio",
|
|
6409
|
+
quote: "Cover quote",
|
|
6410
|
+
instruction: "Instruction",
|
|
6411
|
+
model: "Model",
|
|
6412
|
+
save: "Save director",
|
|
6413
|
+
discard: "Discard",
|
|
6414
|
+
redo: "Regenerate",
|
|
6415
|
+
};
|
|
6278
6416
|
const seed = this.agentSpecAvatarSeed;
|
|
6279
6417
|
const avatarSvg = (window.AvatarSkill && seed)
|
|
6280
6418
|
? window.AvatarSkill.generate(seed, { size: 96 })
|
|
@@ -6361,25 +6499,16 @@
|
|
|
6361
6499
|
* composer back to its input state). The description text is
|
|
6362
6500
|
* surfaced read-only so the user can copy it before discard. */
|
|
6363
6501
|
renderAgentSpecErrorHtml(err, lang) {
|
|
6364
|
-
|
|
6365
|
-
|
|
6366
|
-
|
|
6367
|
-
|
|
6368
|
-
|
|
6369
|
-
|
|
6370
|
-
|
|
6371
|
-
|
|
6372
|
-
|
|
6373
|
-
|
|
6374
|
-
: {
|
|
6375
|
-
kicker: err.kind === "timeout" ? "// generation timed out" : "// generation failed",
|
|
6376
|
-
title: err.kind === "timeout" ? "Generation didn't complete after 5 minutes" : "Generation failed",
|
|
6377
|
-
hintTimeout: "The model may be slow, the network flaky, or the backend pipeline stalled. Click retry to start a fresh run.",
|
|
6378
|
-
hintFailed: "Check your API key configuration and model reachability. Retry often clears transient failures.",
|
|
6379
|
-
descLabel: "Your description (re-used on retry)",
|
|
6380
|
-
retry: "Retry",
|
|
6381
|
-
discard: "Discard",
|
|
6382
|
-
};
|
|
6502
|
+
// System UI · always English (agent-spec error card chrome).
|
|
6503
|
+
const t = {
|
|
6504
|
+
kicker: err.kind === "timeout" ? "// generation timed out" : "// generation failed",
|
|
6505
|
+
title: err.kind === "timeout" ? "Generation didn't complete after 5 minutes" : "Generation failed",
|
|
6506
|
+
hintTimeout: "The model may be slow, the network flaky, or the backend pipeline stalled. Click retry to start a fresh run.",
|
|
6507
|
+
hintFailed: "Check your API key configuration and model reachability. Retry often clears transient failures.",
|
|
6508
|
+
descLabel: "Your description (re-used on retry)",
|
|
6509
|
+
retry: "Retry",
|
|
6510
|
+
discard: "Discard",
|
|
6511
|
+
};
|
|
6383
6512
|
const desc = this._agentComposerLastDesc || "";
|
|
6384
6513
|
const hint = err.kind === "timeout" ? t.hintTimeout : t.hintFailed;
|
|
6385
6514
|
const detail = err.message ? `<div class="ag-gen-error-detail">${this.escape(err.message)}</div>` : "";
|
|
@@ -6387,7 +6516,7 @@
|
|
|
6387
6516
|
<section class="cmp ag-cmp">
|
|
6388
6517
|
<header class="cmp-hero">
|
|
6389
6518
|
<div class="cmp-greet">${this.escape(this.composerGreeting(lang, (this.prefs?.name || "you").trim() || "you"))}</div>
|
|
6390
|
-
<h1 class="cmp-prompt">${this.escape(
|
|
6519
|
+
<h1 class="cmp-prompt">${this.escape("What kind of director do you want?")}</h1>
|
|
6391
6520
|
</header>
|
|
6392
6521
|
<div class="ag-gen-error-card">
|
|
6393
6522
|
<div class="ag-gen-error-kicker">${this.escape(t.kicker)}</div>
|
|
@@ -6552,13 +6681,11 @@
|
|
|
6552
6681
|
this.agentSpecError = null;
|
|
6553
6682
|
} catch (e) {
|
|
6554
6683
|
const isAbort = (e && (e.name === "AbortError" || /aborted/i.test(String(e.message))));
|
|
6555
|
-
const lang = this.composerLanguage();
|
|
6556
6684
|
if (timedOut || isAbort) {
|
|
6685
|
+
// System UI · always English (error message text).
|
|
6557
6686
|
this.agentSpecError = {
|
|
6558
6687
|
kind: "timeout",
|
|
6559
|
-
message:
|
|
6560
|
-
? "生成超过 5 分钟仍未完成 · 模型回应慢、网络波动,或后端流水线卡住了。"
|
|
6561
|
-
: "Generation took longer than 5 minutes · the model may be slow, the network flaky, or the backend stuck.",
|
|
6688
|
+
message: "Generation took longer than 5 minutes · the model may be slow, the network flaky, or the backend stuck.",
|
|
6562
6689
|
};
|
|
6563
6690
|
} else {
|
|
6564
6691
|
this.agentSpecError = {
|
|
@@ -6722,10 +6849,9 @@
|
|
|
6722
6849
|
// Sort newest-first · the most recently filed brief is the one a
|
|
6723
6850
|
// returning user most likely wants to re-open.
|
|
6724
6851
|
briefs.sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0));
|
|
6725
|
-
|
|
6726
|
-
|
|
6727
|
-
|
|
6728
|
-
: { title: "Open a report", supplementPrefix: "Supplement: ", initial: "Initial", filed: "filed" };
|
|
6852
|
+
// System UI · always English. Brief picker chrome (popover
|
|
6853
|
+
// title, row labels) doesn't follow the brief language.
|
|
6854
|
+
const t = { title: "Open a report", supplementPrefix: "Supplement: ", initial: "Initial", filed: "filed" };
|
|
6729
6855
|
const initialIdx = briefs.length - 1; // oldest is "Initial"
|
|
6730
6856
|
const rows = briefs.map((b, i) => {
|
|
6731
6857
|
// The numbering is stable across renders: oldest = 01, newest
|
|
@@ -6740,11 +6866,11 @@
|
|
|
6740
6866
|
: "";
|
|
6741
6867
|
const subtitle = isInitial ? t.initial : (supplementSnippet ? `${t.supplementPrefix}${supplementSnippet}` : "");
|
|
6742
6868
|
const filedLabel = b.createdAt
|
|
6743
|
-
? new Date(b.createdAt).toLocaleString(
|
|
6869
|
+
? new Date(b.createdAt).toLocaleString(undefined, {
|
|
6744
6870
|
year: "numeric", month: "short", day: "numeric", hour: "2-digit", minute: "2-digit",
|
|
6745
6871
|
})
|
|
6746
6872
|
: "";
|
|
6747
|
-
const href =
|
|
6873
|
+
const href = this.briefViewerHref(b, roomId);
|
|
6748
6874
|
return `
|
|
6749
6875
|
<a class="brief-picker-row" href="${this.escape(href)}" target="_blank" rel="noopener" data-brief-picker-row data-brief-id="${this.escape(b.id)}">
|
|
6750
6876
|
<span class="brief-picker-num">${this.escape(num)}</span>
|
|
@@ -6834,10 +6960,8 @@
|
|
|
6834
6960
|
const dirs = (this.agents || [])
|
|
6835
6961
|
.filter((a) => a.roleKind !== "moderator")
|
|
6836
6962
|
.sort((a, b) => (a.name || "").localeCompare(b.name || ""));
|
|
6837
|
-
|
|
6838
|
-
const t =
|
|
6839
|
-
? { title: "选择董事", hint: "建议 2-4 位", done: "完成", info: "查看资料" }
|
|
6840
|
-
: { title: "Pick directors", hint: "2-4 recommended", done: "Done", info: "View profile" };
|
|
6963
|
+
// System UI · always English (composer director picker chrome).
|
|
6964
|
+
const t = { title: "Pick directors", hint: "2-4 recommended", done: "Done", info: "View profile" };
|
|
6841
6965
|
const rows = dirs.map((a) => {
|
|
6842
6966
|
const checked = state.directorIds.includes(a.id);
|
|
6843
6967
|
const modelLabel = MODEL_LABELS[a.modelV] || a.modelV || "";
|
|
@@ -6974,17 +7098,16 @@
|
|
|
6974
7098
|
const isAutoPick = state.autoPickDirectors === true && dirObjs.length === 0;
|
|
6975
7099
|
btn.classList.toggle("cmp-cast-btn-auto", isAutoPick);
|
|
6976
7100
|
if (isAutoPick) {
|
|
6977
|
-
|
|
6978
|
-
|
|
6979
|
-
: "Chair picks 3 directors based on your subject when you Convene · click to pick manually";
|
|
7101
|
+
// System UI · always English (auto-pick chip tooltip).
|
|
7102
|
+
const autoTip = "Chair picks 3 directors based on your subject when you Convene · click to pick manually";
|
|
6980
7103
|
btn.title = autoTip;
|
|
6981
7104
|
btn.innerHTML = `
|
|
6982
7105
|
<span class="cmp-cast-stack cmp-cast-stack-auto" data-cast-auto>
|
|
6983
7106
|
<span class="cmp-cast-auto-mark">✦</span>
|
|
6984
7107
|
</span>
|
|
6985
7108
|
<span class="cmp-cast-count cmp-cast-auto-label">
|
|
6986
|
-
<span class="cmp-cast-auto-key"
|
|
6987
|
-
<span class="cmp-cast-auto-val"
|
|
7109
|
+
<span class="cmp-cast-auto-key">directors</span>
|
|
7110
|
+
<span class="cmp-cast-auto-val">auto-pick</span>
|
|
6988
7111
|
</span>
|
|
6989
7112
|
`;
|
|
6990
7113
|
return;
|
|
@@ -6992,9 +7115,10 @@
|
|
|
6992
7115
|
const visible = dirObjs.slice(0, 4);
|
|
6993
7116
|
const overflow = Math.max(0, dirObjs.length - 4);
|
|
6994
7117
|
const avs = visible.map((a) => `<img class="cmp-cast-av" src="${this.escape(a.avatarPath)}" alt="${this.escape(a.name)}" title="${this.escape(a.name)}">`).join("");
|
|
7118
|
+
// System UI · always English (cast button count chrome).
|
|
6995
7119
|
const countText = dirObjs.length
|
|
6996
|
-
?
|
|
6997
|
-
:
|
|
7120
|
+
? `${dirObjs.length} director${dirObjs.length === 1 ? "" : "s"}`
|
|
7121
|
+
: "no directors";
|
|
6998
7122
|
const countCls = dirObjs.length ? "cmp-cast-count" : "cmp-cast-count cmp-cast-empty";
|
|
6999
7123
|
btn.innerHTML = `
|
|
7000
7124
|
<span class="cmp-cast-stack">
|
|
@@ -7042,21 +7166,14 @@
|
|
|
7042
7166
|
let opts;
|
|
7043
7167
|
let current;
|
|
7044
7168
|
if (kind === "tone") {
|
|
7045
|
-
|
|
7046
|
-
|
|
7047
|
-
|
|
7048
|
-
|
|
7049
|
-
|
|
7050
|
-
|
|
7051
|
-
|
|
7052
|
-
|
|
7053
|
-
: [
|
|
7054
|
-
{ v: "brainstorm", label: "Brainstorm", hint: "yes-and" },
|
|
7055
|
-
{ v: "constructive", label: "Constructive", hint: "push & sharpen" },
|
|
7056
|
-
{ v: "research", label: "Research", hint: "mine the material" },
|
|
7057
|
-
{ v: "debate", label: "Debate", hint: "find the holes" },
|
|
7058
|
-
{ v: "critique", label: "Critique", hint: "audit the deliverable" },
|
|
7059
|
-
];
|
|
7169
|
+
// System UI · always English (tune dropdown options).
|
|
7170
|
+
opts = [
|
|
7171
|
+
{ v: "brainstorm", label: "Brainstorm", hint: "yes-and" },
|
|
7172
|
+
{ v: "constructive", label: "Constructive", hint: "push & sharpen" },
|
|
7173
|
+
{ v: "research", label: "Research", hint: "mine the material" },
|
|
7174
|
+
{ v: "debate", label: "Debate", hint: "find the holes" },
|
|
7175
|
+
{ v: "critique", label: "Critique", hint: "audit the deliverable" },
|
|
7176
|
+
];
|
|
7060
7177
|
if (followUpScope) {
|
|
7061
7178
|
const valSpan = triggerBtn.querySelector("[data-cmp-dd-value]");
|
|
7062
7179
|
current = valSpan ? (valSpan.textContent || "").trim().toLowerCase() : "";
|
|
@@ -7068,17 +7185,12 @@
|
|
|
7068
7185
|
// the third value (terse) is a cadence dial, not a harshness
|
|
7069
7186
|
// dial. The earlier "no prisoners" / "直击痛点" copy pulled
|
|
7070
7187
|
// users into thinking it controlled tone.
|
|
7071
|
-
|
|
7072
|
-
|
|
7073
|
-
|
|
7074
|
-
|
|
7075
|
-
|
|
7076
|
-
|
|
7077
|
-
: [
|
|
7078
|
-
{ v: "calm", label: "Calm", hint: "let them think" },
|
|
7079
|
-
{ v: "sharp", label: "Sharp", hint: "no hedging" },
|
|
7080
|
-
{ v: "terse", label: "Terse", hint: "telegraphic" },
|
|
7081
|
-
];
|
|
7188
|
+
// System UI · always English (intensity dropdown options).
|
|
7189
|
+
opts = [
|
|
7190
|
+
{ v: "calm", label: "Calm", hint: "let them think" },
|
|
7191
|
+
{ v: "sharp", label: "Sharp", hint: "no hedging" },
|
|
7192
|
+
{ v: "terse", label: "Terse", hint: "telegraphic" },
|
|
7193
|
+
];
|
|
7082
7194
|
if (followUpScope) {
|
|
7083
7195
|
const valSpan = triggerBtn.querySelector("[data-cmp-dd-value]");
|
|
7084
7196
|
current = valSpan ? (valSpan.textContent || "").trim().toLowerCase() : "";
|
|
@@ -7221,7 +7333,7 @@
|
|
|
7221
7333
|
const useAutoPick = state.autoPickDirectors === true && state.directorIds.length === 0;
|
|
7222
7334
|
if (!useAutoPick && !state.directorIds.length) {
|
|
7223
7335
|
const lang = this.composerLanguage();
|
|
7224
|
-
alert(
|
|
7336
|
+
alert("Pick at least one director before convening");
|
|
7225
7337
|
return;
|
|
7226
7338
|
}
|
|
7227
7339
|
const btn = document.querySelector("[data-composer-go]");
|
|
@@ -7428,14 +7540,14 @@
|
|
|
7428
7540
|
// opens the picker.
|
|
7429
7541
|
const briefs = Array.isArray(this.currentBriefs) ? this.currentBriefs : [];
|
|
7430
7542
|
const multi = briefs.length > 1;
|
|
7431
|
-
const directHref =
|
|
7543
|
+
const directHref = this.briefViewerHref(this.currentBrief, r.id) || `/report.html?r=${this.escape(r.id)}`;
|
|
7432
7544
|
if (!multi) {
|
|
7433
7545
|
return `<a href="${directHref}" target="_blank" rel="noopener" class="view-report-btn" data-view-report>[ View Report ]</a>`;
|
|
7434
7546
|
}
|
|
7435
|
-
return `<a href="${directHref}" target="_blank" rel="noopener" class="view-report-btn" data-view-report data-view-report-trigger title="${this.escape(
|
|
7547
|
+
return `<a href="${directHref}" target="_blank" rel="noopener" class="view-report-btn" data-view-report data-view-report-trigger title="${this.escape(`${briefs.length} reports · click to choose`)}">[ View Report <span class="vr-count">· ${briefs.length}</span> ▾ ]</a>`;
|
|
7436
7548
|
})()
|
|
7437
7549
|
: (r.status === "adjourned"
|
|
7438
|
-
? `<a href="#" class="view-report-btn generate-report" data-generate-brief title="${this.escape(
|
|
7550
|
+
? `<a href="#" class="view-report-btn generate-report" data-generate-brief title="${this.escape("File a brief from this session")}"><span class="vr-mark">▸</span> Generate Report</a>`
|
|
7439
7551
|
: "")}
|
|
7440
7552
|
</div>
|
|
7441
7553
|
`;
|
|
@@ -7516,23 +7628,17 @@
|
|
|
7516
7628
|
conveningCardHtml() {
|
|
7517
7629
|
const s = this.conveneState;
|
|
7518
7630
|
if (!s) return "";
|
|
7519
|
-
|
|
7520
|
-
|
|
7631
|
+
// System UI · always English. Convening overlay (analyzing /
|
|
7632
|
+
// seating / preparing labels + decks) is app chrome.
|
|
7521
7633
|
// Stages · auto-picked rooms run all three; manually-cast rooms
|
|
7522
7634
|
// skip "analyzing" + "seating" because the cast is pre-set.
|
|
7523
7635
|
const stageOrder = s.autoPicked
|
|
7524
7636
|
? ["analyzing", "seating", "preparing"]
|
|
7525
7637
|
: ["preparing"];
|
|
7526
7638
|
const STAGE_LABELS = {
|
|
7527
|
-
analyzing:
|
|
7528
|
-
|
|
7529
|
-
|
|
7530
|
-
seating: lang === "zh"
|
|
7531
|
-
? { title: "邀请董事", deck: "依据议题匹配的董事正在入席" }
|
|
7532
|
-
: { title: "Seating directors", deck: "Picking the right perspectives for this question" },
|
|
7533
|
-
preparing: lang === "zh"
|
|
7534
|
-
? { title: "主席组织开场陈词", deck: "主席正在准备介绍发言" }
|
|
7535
|
-
: { title: "Chair preparing remarks", deck: "Drafting the convening speech" },
|
|
7639
|
+
analyzing: { title: "Analyzing topic", deck: "Routing your topic to the right perspectives" },
|
|
7640
|
+
seating: { title: "Seating directors", deck: "Picking the right perspectives for this question" },
|
|
7641
|
+
preparing: { title: "Chair preparing remarks", deck: "Drafting the convening speech" },
|
|
7536
7642
|
};
|
|
7537
7643
|
|
|
7538
7644
|
const currentIdx = stageOrder.indexOf(s.stage);
|
|
@@ -7564,7 +7670,7 @@
|
|
|
7564
7670
|
// already in currentMembers; rendering it here would be redundant).
|
|
7565
7671
|
const seatedRow = (s.autoPicked && s.seated.length > 0) ? `
|
|
7566
7672
|
<div class="conv-seated">
|
|
7567
|
-
<div class="conv-seated-label"
|
|
7673
|
+
<div class="conv-seated-label">seated · ${s.seated.length}</div>
|
|
7568
7674
|
<div class="conv-seated-list">
|
|
7569
7675
|
${s.seated.map((a) => `
|
|
7570
7676
|
<div class="conv-seated-item" data-agent-id="${this.escape(a.id)}">
|
|
@@ -7581,7 +7687,7 @@
|
|
|
7581
7687
|
|
|
7582
7688
|
return `
|
|
7583
7689
|
<article class="convening-card" data-convene-card>
|
|
7584
|
-
<div class="conv-eyebrow"
|
|
7690
|
+
<div class="conv-eyebrow">▸ CONVENING</div>
|
|
7585
7691
|
${s.subject ? `<blockquote class="conv-subject">${this.escape(s.subject)}</blockquote>` : ""}
|
|
7586
7692
|
<ul class="conv-stages">${stagesHtml}</ul>
|
|
7587
7693
|
${seatedRow}
|
|
@@ -7618,11 +7724,9 @@
|
|
|
7618
7724
|
const chat = document.querySelector("[data-chat-messages]");
|
|
7619
7725
|
if (!chat) return;
|
|
7620
7726
|
const existing = chat.querySelector("[data-chair-pending]");
|
|
7621
|
-
|
|
7622
|
-
const chairName = (this.currentChair?.name) ||
|
|
7623
|
-
const labelMap =
|
|
7624
|
-
? { clarify: "正在整理你的问题", "chair-direct": "正在准备回应", "round-end": "正在收束这一轮", convening: "正在召集会议", "next-speaker": "正在判断接下来的发言", chair: "正在准备" }
|
|
7625
|
-
: { clarify: "is reading your question", "chair-direct": "is preparing a response", "round-end": "is wrapping up the round", convening: "is convening the room", "next-speaker": "is reading the room", chair: "is preparing" };
|
|
7727
|
+
// System UI · always English (chair-pending placeholder chrome).
|
|
7728
|
+
const chairName = (this.currentChair?.name) || "Chair";
|
|
7729
|
+
const labelMap = { clarify: "is reading your question", "chair-direct": "is preparing a response", "round-end": "is wrapping up the round", convening: "is convening the room", "next-speaker": "is reading the room", chair: "is preparing" };
|
|
7626
7730
|
const phraseRaw = labelMap[phase] || labelMap.chair;
|
|
7627
7731
|
const phrase = `${chairName} ${phraseRaw}…`;
|
|
7628
7732
|
if (existing) {
|
|
@@ -7635,7 +7739,7 @@
|
|
|
7635
7739
|
node.setAttribute("data-chair-pending", "");
|
|
7636
7740
|
node.innerHTML = `
|
|
7637
7741
|
<div class="cp-rule" aria-hidden="true"></div>
|
|
7638
|
-
<div class="cp-kicker">▸
|
|
7742
|
+
<div class="cp-kicker">▸ chair</div>
|
|
7639
7743
|
<div class="cp-body">
|
|
7640
7744
|
<span class="cp-text">${this.escape(phrase)}</span>
|
|
7641
7745
|
<span class="thinking-dots"><span></span><span></span><span></span></span>
|
|
@@ -7817,7 +7921,7 @@
|
|
|
7817
7921
|
// 中文 copy. Reads from the chair's prompt body which lands in
|
|
7818
7922
|
// the message body when a recommendation is present.
|
|
7819
7923
|
const lang = (promptMsg && promptMsg.body && /[一-鿿]/.test(promptMsg.body)) ? "zh" : "en";
|
|
7820
|
-
const recLabel =
|
|
7924
|
+
const recLabel = "chair recommends";
|
|
7821
7925
|
const recIndicator = recKind
|
|
7822
7926
|
? `
|
|
7823
7927
|
<div class="rp-rec-line rp-rec-line-${this.escape(recKind)}">
|
|
@@ -8045,14 +8149,14 @@
|
|
|
8045
8149
|
const truncated = parentSubject.length > 70
|
|
8046
8150
|
? parentSubject.slice(0, 70) + "…"
|
|
8047
8151
|
: parentSubject;
|
|
8048
|
-
|
|
8049
|
-
const label =
|
|
8050
|
-
const roomTag =
|
|
8152
|
+
// System UI · always English (follow-up origin link chrome).
|
|
8153
|
+
const label = "Following up on";
|
|
8154
|
+
const roomTag = "Room #";
|
|
8051
8155
|
const subjectChunk = truncated
|
|
8052
8156
|
? `<span class="convene-origin-sep">·</span><span class="convene-origin-subject">${this.escape(truncated)}</span>`
|
|
8053
8157
|
: "";
|
|
8054
8158
|
originHtml = `
|
|
8055
|
-
<a class="convene-origin" href="#/r/${this.escape(parentId)}" data-parent-room-id="${this.escape(parentId)}" title="${this.escape(
|
|
8159
|
+
<a class="convene-origin" href="#/r/${this.escape(parentId)}" data-parent-room-id="${this.escape(parentId)}" title="${this.escape("Open the prior session · " + (parentSubject || parentId))}">
|
|
8056
8160
|
<span class="convene-origin-arrow">↩</span>
|
|
8057
8161
|
<span class="convene-origin-label">${this.escape(label)}</span>
|
|
8058
8162
|
<span class="convene-origin-room">${this.escape(roomTag)}${this.escape(String(parentNum))}</span>
|
|
@@ -8065,9 +8169,11 @@
|
|
|
8065
8169
|
parentId ? "convene-opener-followup" : "",
|
|
8066
8170
|
isLongOpener ? "convene-opener-clamped" : "",
|
|
8067
8171
|
].filter(Boolean).join(" ");
|
|
8068
|
-
|
|
8069
|
-
|
|
8070
|
-
|
|
8172
|
+
// System UI · always English. Earlier this followed the brief
|
|
8173
|
+
// language; now fixed-string per the rule that app chrome stays
|
|
8174
|
+
// English regardless of the user's query language.
|
|
8175
|
+
const moreLabel = "Show more ↓";
|
|
8176
|
+
const lessLabel = "Show less ↑";
|
|
8071
8177
|
const toggleHtml = isLongOpener
|
|
8072
8178
|
? `<button type="button" class="convene-toggle" data-convene-toggle data-more="${this.escape(moreLabel)}" data-less="${this.escape(lessLabel)}">${moreLabel}</button>`
|
|
8073
8179
|
: "";
|
|
@@ -8293,7 +8399,7 @@
|
|
|
8293
8399
|
// as a historical marker only.
|
|
8294
8400
|
if (isChair && metaKind === "no-brief") {
|
|
8295
8401
|
const ts = this.timeFmt(m.createdAt);
|
|
8296
|
-
|
|
8402
|
+
// System UI · always English (no-brief card chrome).
|
|
8297
8403
|
const hasBrief = !!this.currentBrief;
|
|
8298
8404
|
const cta = hasBrief
|
|
8299
8405
|
? ""
|
|
@@ -8301,7 +8407,7 @@
|
|
|
8301
8407
|
<div class="nb-actions">
|
|
8302
8408
|
<button type="button" class="nb-cta" data-generate-brief>
|
|
8303
8409
|
<span class="nb-cta-mark">▸</span>
|
|
8304
|
-
<span class="nb-cta-text"
|
|
8410
|
+
<span class="nb-cta-text">Generate report now</span>
|
|
8305
8411
|
</button>
|
|
8306
8412
|
</div>
|
|
8307
8413
|
`;
|
|
@@ -8312,7 +8418,7 @@
|
|
|
8312
8418
|
<span class="nb-eyebrow">adjourned · no brief filed</span>
|
|
8313
8419
|
</span>
|
|
8314
8420
|
<div class="nb-body">
|
|
8315
|
-
<strong>${this.escape(this.prefs?.name || "The chair")}</strong>
|
|
8421
|
+
<strong>${this.escape(this.prefs?.name || "The chair")}</strong> declared no report is needed for this session.
|
|
8316
8422
|
</div>
|
|
8317
8423
|
${cta}
|
|
8318
8424
|
<div class="nb-meta">${this.escape(ts)}</div>
|
|
@@ -8763,31 +8869,17 @@
|
|
|
8763
8869
|
* research note reads "sweet." */
|
|
8764
8870
|
_briefWordCountTip(wc) {
|
|
8765
8871
|
if (!wc) return "";
|
|
8766
|
-
|
|
8767
|
-
const toneLabel =
|
|
8768
|
-
brainstorm: isZh ? "脑暴" : "brainstorm",
|
|
8769
|
-
constructive: isZh ? "构建" : "constructive",
|
|
8770
|
-
debate: isZh ? "辩论" : "debate",
|
|
8771
|
-
research: isZh ? "研究" : "research",
|
|
8772
|
-
critique: isZh ? "评审" : "critique",
|
|
8773
|
-
})[wc.tone] || wc.tone;
|
|
8872
|
+
// System UI · always English (word-count chip tooltip).
|
|
8873
|
+
const toneLabel = wc.tone;
|
|
8774
8874
|
const range = `${wc.bands.sweetLo.toLocaleString("en-US")}-${wc.bands.sweetHi.toLocaleString("en-US")}`;
|
|
8775
|
-
const unit =
|
|
8776
|
-
const copy =
|
|
8777
|
-
|
|
8778
|
-
|
|
8779
|
-
|
|
8780
|
-
|
|
8781
|
-
|
|
8782
|
-
|
|
8783
|
-
}
|
|
8784
|
-
: {
|
|
8785
|
-
"thin": `Lean · ${toneLabel} sweet zone is ${range} ${unit}`,
|
|
8786
|
-
"sweet": `Sweet zone for ${toneLabel} · most read-through-able length`,
|
|
8787
|
-
"dense": `Dense · past the ${toneLabel} sweet zone, still readable`,
|
|
8788
|
-
"long": `Long · approaching "filed instead of read"`,
|
|
8789
|
-
"too-long": `Too long · likely to be skimmed, not read`,
|
|
8790
|
-
};
|
|
8875
|
+
const unit = "words";
|
|
8876
|
+
const copy = {
|
|
8877
|
+
"thin": `Lean · ${toneLabel} sweet zone is ${range} ${unit}`,
|
|
8878
|
+
"sweet": `Sweet zone for ${toneLabel} · most read-through-able length`,
|
|
8879
|
+
"dense": `Dense · past the ${toneLabel} sweet zone, still readable`,
|
|
8880
|
+
"long": `Long · approaching "filed instead of read"`,
|
|
8881
|
+
"too-long": `Too long · likely to be skimmed, not read`,
|
|
8882
|
+
};
|
|
8791
8883
|
return copy[wc.tier] || "";
|
|
8792
8884
|
},
|
|
8793
8885
|
|
|
@@ -8800,9 +8892,7 @@
|
|
|
8800
8892
|
const briefs = Array.isArray(this.currentBriefs) ? this.currentBriefs : [];
|
|
8801
8893
|
if (briefs.length < 2) return "";
|
|
8802
8894
|
const sortedBriefs = briefs.slice().sort((x, y) => (x.createdAt || 0) - (y.createdAt || 0));
|
|
8803
|
-
|
|
8804
|
-
|| (this.currentRoom?.subject && /[一-鿿]/.test(this.currentRoom.subject))
|
|
8805
|
-
? "zh" : "en";
|
|
8895
|
+
// System UI · always English (brief-version tab strip chrome).
|
|
8806
8896
|
return `
|
|
8807
8897
|
<div class="brief-versions">
|
|
8808
8898
|
${sortedBriefs.map((bf, i) => {
|
|
@@ -8811,11 +8901,11 @@
|
|
|
8811
8901
|
const isInitial = i === 0;
|
|
8812
8902
|
const supp = bf.supplement && bf.supplement.trim()
|
|
8813
8903
|
? bf.supplement.trim()
|
|
8814
|
-
: (isInitial ?
|
|
8904
|
+
: (isInitial ? "Initial" : "");
|
|
8815
8905
|
const tooltip = isInitial
|
|
8816
|
-
?
|
|
8817
|
-
:
|
|
8818
|
-
const closeTitle =
|
|
8906
|
+
? `Initial brief · generated from the session`
|
|
8907
|
+
: `Supplement: ${supp || "—"}`;
|
|
8908
|
+
const closeTitle = "Delete this report";
|
|
8819
8909
|
// Errored / interrupted / timed-out tabs get a small
|
|
8820
8910
|
// visual marker so the user can spot which one needs
|
|
8821
8911
|
// attention without entering it. The full retry UI is
|
|
@@ -8829,7 +8919,7 @@
|
|
|
8829
8919
|
<span class="brief-version-num">${num}</span>
|
|
8830
8920
|
${stateMark}
|
|
8831
8921
|
${isInitial
|
|
8832
|
-
? `<span class="brief-version-label"
|
|
8922
|
+
? `<span class="brief-version-label">Initial</span>`
|
|
8833
8923
|
: `<span class="brief-version-label">${this.escape((supp || "").slice(0, 20))}${(supp || "").length > 20 ? "…" : ""}</span>`}
|
|
8834
8924
|
</button>
|
|
8835
8925
|
<button type="button" class="brief-version-close" data-brief-delete data-brief-id="${this.escape(bf.id)}" title="${this.escape(closeTitle)}" aria-label="${this.escape(closeTitle)}">×</button>
|
|
@@ -8892,7 +8982,7 @@
|
|
|
8892
8982
|
// a failed-brief tab becomes unreachable — clicking it just
|
|
8893
8983
|
// re-renders whichever good brief the salvage path picks.
|
|
8894
8984
|
const goodBrief = bypassSalvage ? null : (this.currentBriefs || []).find(
|
|
8895
|
-
(x) => x && x.id !== b.id && !x.error &&
|
|
8985
|
+
(x) => x && x.id !== b.id && !x.error && this.briefHasBody(x),
|
|
8896
8986
|
);
|
|
8897
8987
|
if (goodBrief) {
|
|
8898
8988
|
const failed = b;
|
|
@@ -8902,14 +8992,14 @@
|
|
|
8902
8992
|
finally { this.currentBrief = prevCurrent; }
|
|
8903
8993
|
// Prepend the compact retry banner to the rendered card so
|
|
8904
8994
|
// the existing report stays fully visible below it.
|
|
8905
|
-
|
|
8995
|
+
// System UI · always English (error banner chrome).
|
|
8906
8996
|
const detail = failed.timedOut
|
|
8907
|
-
?
|
|
8997
|
+
? "regeneration timed out"
|
|
8908
8998
|
: failed.interrupted
|
|
8909
|
-
?
|
|
8910
|
-
: (failed.error ||
|
|
8911
|
-
const cta =
|
|
8912
|
-
const dismiss =
|
|
8999
|
+
? "regeneration interrupted"
|
|
9000
|
+
: (failed.error || "regeneration failed");
|
|
9001
|
+
const cta = "Retry";
|
|
9002
|
+
const dismiss = "Dismiss";
|
|
8913
9003
|
card.insertAdjacentHTML("afterbegin", `
|
|
8914
9004
|
<div class="brief-retry-banner" data-brief-retry-banner data-failed-brief-id="${this.escape(failed.id)}">
|
|
8915
9005
|
<span class="brb-mark">⚠</span>
|
|
@@ -8923,59 +9013,36 @@
|
|
|
8923
9013
|
`);
|
|
8924
9014
|
return;
|
|
8925
9015
|
}
|
|
8926
|
-
|
|
9016
|
+
// System UI · always English (full error-card copy).
|
|
8927
9017
|
const copy = b.timedOut
|
|
8928
|
-
?
|
|
8929
|
-
|
|
8930
|
-
|
|
8931
|
-
|
|
8932
|
-
|
|
8933
|
-
|
|
8934
|
-
|
|
8935
|
-
}
|
|
8936
|
-
: {
|
|
8937
|
-
stamp: "timed out",
|
|
8938
|
-
kicker: "// generation timed out",
|
|
8939
|
-
detail: "No completion signal after 8 minutes — the model may be slow, the connection dropped, or the pipeline stalled. Click below to start a fresh run.",
|
|
8940
|
-
hint: "",
|
|
8941
|
-
cta: "Retry",
|
|
8942
|
-
})
|
|
9018
|
+
? {
|
|
9019
|
+
stamp: "timed out",
|
|
9020
|
+
kicker: "// generation timed out",
|
|
9021
|
+
detail: "No completion signal after 8 minutes — the model may be slow, the connection dropped, or the pipeline stalled. Click below to start a fresh run.",
|
|
9022
|
+
hint: "",
|
|
9023
|
+
cta: "Retry",
|
|
9024
|
+
}
|
|
8943
9025
|
: b.interrupted
|
|
8944
|
-
?
|
|
8945
|
-
|
|
8946
|
-
|
|
8947
|
-
|
|
8948
|
-
|
|
8949
|
-
|
|
8950
|
-
|
|
8951
|
-
|
|
8952
|
-
|
|
8953
|
-
|
|
8954
|
-
|
|
8955
|
-
|
|
8956
|
-
|
|
8957
|
-
|
|
8958
|
-
})
|
|
8959
|
-
: (lang === "zh"
|
|
8960
|
-
? {
|
|
8961
|
-
stamp: "failed",
|
|
8962
|
-
kicker: "// 报告生成失败",
|
|
8963
|
-
detail: this.escape(b.error || ""),
|
|
8964
|
-
hint: "Brief writer 需要一个 LLM key(OpenRouter,或 Anthropic / OpenAI / Google / xAI 直连)。在 <strong>Preference → API Key</strong> 中添加后再试。",
|
|
8965
|
-
cta: "重试",
|
|
8966
|
-
}
|
|
8967
|
-
: {
|
|
8968
|
-
stamp: "failed",
|
|
8969
|
-
kicker: "// brief generation failed",
|
|
8970
|
-
detail: this.escape(b.error || ""),
|
|
8971
|
-
hint: "The brief writer needs an LLM key (OpenRouter, or a direct Anthropic / OpenAI / Google / xAI key). Add one in <strong>Preference → API Key</strong> and try again.",
|
|
8972
|
-
cta: "Retry",
|
|
8973
|
-
});
|
|
9026
|
+
? {
|
|
9027
|
+
stamp: "interrupted",
|
|
9028
|
+
kicker: "// generation interrupted",
|
|
9029
|
+
detail: "The previous generation was cut short — likely by a browser refresh or a server restart. Click below to start a fresh report.",
|
|
9030
|
+
hint: "",
|
|
9031
|
+
cta: "Regenerate report",
|
|
9032
|
+
}
|
|
9033
|
+
: {
|
|
9034
|
+
stamp: "failed",
|
|
9035
|
+
kicker: "// brief generation failed",
|
|
9036
|
+
detail: this.escape(b.error || ""),
|
|
9037
|
+
hint: "The brief writer needs an LLM key (OpenRouter, or a direct Anthropic / OpenAI / Google / xAI key). Add one in <strong>Preference → API Key</strong> and try again.",
|
|
9038
|
+
cta: "Retry",
|
|
9039
|
+
};
|
|
8974
9040
|
card.innerHTML = `
|
|
8975
9041
|
<div class="brief-card">
|
|
8976
9042
|
${tabsStripHtml}
|
|
8977
9043
|
<div class="brief-banner">
|
|
8978
9044
|
<span class="brief-banner-tag" style="color: var(--red);">// report</span>
|
|
9045
|
+
<span class="brief-banner-type" style="color: var(--red); border-color: var(--red);">${this.escape(this.briefModeLabel(b))}</span>
|
|
8979
9046
|
<span class="brief-banner-stamp" style="color: var(--red);">${this.escape(copy.stamp)}</span>
|
|
8980
9047
|
</div>
|
|
8981
9048
|
<div class="brief-body brief-body-error">
|
|
@@ -8998,7 +9065,7 @@
|
|
|
8998
9065
|
return;
|
|
8999
9066
|
}
|
|
9000
9067
|
|
|
9001
|
-
const generating = b.isGenerating === true || !b
|
|
9068
|
+
const generating = b.isGenerating === true || !this.briefHasBody(b) || b.title === "Generating…";
|
|
9002
9069
|
const signed = this.currentMembers
|
|
9003
9070
|
.map((a) => `<img src="${this.escape(a.avatarPath)}" alt="${this.escape(a.name)}" title="${this.escape(a.name)}">`)
|
|
9004
9071
|
.join("");
|
|
@@ -9007,12 +9074,12 @@
|
|
|
9007
9074
|
? "GENERATING…"
|
|
9008
9075
|
: "FILED · " + (b.createdAt ? this.timeFmt(b.createdAt) : this.timeFmt(Date.now()));
|
|
9009
9076
|
|
|
9010
|
-
// Open Report
|
|
9011
|
-
//
|
|
9012
|
-
//
|
|
9013
|
-
|
|
9014
|
-
|
|
9015
|
-
|
|
9077
|
+
// Open Report URL · routes to /bento.html for bento briefs and
|
|
9078
|
+
// /report.html for research-note briefs (default). The bento
|
|
9079
|
+
// renderer doesn't need the room id so the URL is shorter for
|
|
9080
|
+
// that mode.
|
|
9081
|
+
const reportHref = this.briefViewerHref(b, this.currentRoomId)
|
|
9082
|
+
|| (this.currentRoomId ? `/report.html?r=${encodeURIComponent(this.currentRoomId)}` : null);
|
|
9016
9083
|
|
|
9017
9084
|
// Tab strip — already computed at the top of renderBrief as
|
|
9018
9085
|
// `tabsStripHtml` so both error and success paths can mount it.
|
|
@@ -9031,6 +9098,7 @@
|
|
|
9031
9098
|
${tabsHtml}
|
|
9032
9099
|
<div class="brief-banner">
|
|
9033
9100
|
<span class="brief-banner-tag">// report</span>
|
|
9101
|
+
<span class="brief-banner-type">${this.escape(this.briefModeLabel(b))}</span>
|
|
9034
9102
|
<span class="brief-banner-stamp">${filedLabel}</span>
|
|
9035
9103
|
</div>
|
|
9036
9104
|
|
|
@@ -9067,11 +9135,11 @@
|
|
|
9067
9135
|
<div class="brief-supplement-row">
|
|
9068
9136
|
<button type="button" class="brief-supplement-btn" data-brief-supplement>
|
|
9069
9137
|
<span class="brief-supplement-mark">+</span>
|
|
9070
|
-
<span class="brief-supplement-label"
|
|
9138
|
+
<span class="brief-supplement-label">Add a perspective · regenerate</span>
|
|
9071
9139
|
</button>
|
|
9072
|
-
<button type="button" class="brief-delete-btn" data-brief-delete data-brief-id="${this.escape(b.id)}" title="
|
|
9140
|
+
<button type="button" class="brief-delete-btn" data-brief-delete data-brief-id="${this.escape(b.id)}" title="Delete this report">
|
|
9073
9141
|
<span class="brief-delete-mark">⌫</span>
|
|
9074
|
-
<span class="brief-delete-label"
|
|
9142
|
+
<span class="brief-delete-label">Delete report</span>
|
|
9075
9143
|
</button>
|
|
9076
9144
|
</div>
|
|
9077
9145
|
` : ""}
|
|
@@ -9149,55 +9217,53 @@
|
|
|
9149
9217
|
"Writing the 3 Headline Findings",
|
|
9150
9218
|
"Drafting Convergence + Divergence sections",
|
|
9151
9219
|
"Composing Recommendations",
|
|
9152
|
-
"Writing the Pre-mortem",
|
|
9153
9220
|
"Surfacing New Questions",
|
|
9154
9221
|
"Drafting the Strategic Planning Assumption",
|
|
9155
9222
|
"Polishing the final pass",
|
|
9156
9223
|
],
|
|
9157
|
-
|
|
9158
|
-
|
|
9159
|
-
|
|
9160
|
-
|
|
9161
|
-
|
|
9162
|
-
"
|
|
9163
|
-
|
|
9164
|
-
|
|
9165
|
-
"
|
|
9166
|
-
"
|
|
9167
|
-
"
|
|
9168
|
-
|
|
9169
|
-
"scaffold-anchor": [
|
|
9170
|
-
"读出 takeaway",
|
|
9171
|
-
"校准 confidence",
|
|
9172
|
-
"落定 working hypothesis",
|
|
9173
|
-
],
|
|
9174
|
-
"scaffold-findings": [
|
|
9175
|
-
"提炼 3 条 headline findings",
|
|
9176
|
-
"复核每条是否撑住 anchor",
|
|
9177
|
-
"如有勉强,3 → 2 收敛",
|
|
9178
|
-
],
|
|
9179
|
-
"scaffold-cluster": [
|
|
9180
|
-
"定位董事们达成共识的地方",
|
|
9181
|
-
"标记核心张力",
|
|
9182
|
-
"辨认未消化的立场",
|
|
9224
|
+
// Bento's `write` stage does different work · one chair-LLM
|
|
9225
|
+
// call that compresses the room into the 8-slot bento layout.
|
|
9226
|
+
// Keyed under `bento-write` and selected at render time when
|
|
9227
|
+
// the brief's mode is bento.
|
|
9228
|
+
"bento-write": [
|
|
9229
|
+
"Compressing to the takeaway · the 1-line title",
|
|
9230
|
+
"Picking the 3 milestones",
|
|
9231
|
+
"Sizing the big-number callouts",
|
|
9232
|
+
"Mapping ranked bars from the room's data",
|
|
9233
|
+
"Distilling the verification signals",
|
|
9234
|
+
"Compressing recommendations into elevator-pitch lines",
|
|
9235
|
+
"Naming the closing flow · before → after",
|
|
9183
9236
|
],
|
|
9184
|
-
|
|
9185
|
-
|
|
9186
|
-
|
|
9187
|
-
|
|
9237
|
+
// Magazine mode · same single-pass chair-LLM call but the
|
|
9238
|
+
// emphasis is on editorial cover-line composition rather
|
|
9239
|
+
// than infographic compression. Keyed under `magazine-write`
|
|
9240
|
+
// and selected at render time when the brief's mode is
|
|
9241
|
+
// magazine.
|
|
9242
|
+
"magazine-write": [
|
|
9243
|
+
"Drafting the cover headline",
|
|
9244
|
+
"Writing the subdeck",
|
|
9245
|
+
"Picking the 5 numbered cards",
|
|
9246
|
+
"Sequencing the 3 setup steps",
|
|
9247
|
+
"Selecting why-this-matters reasons",
|
|
9248
|
+
"Stamping the masthead byline",
|
|
9188
9249
|
],
|
|
9189
|
-
|
|
9190
|
-
|
|
9191
|
-
|
|
9192
|
-
|
|
9193
|
-
|
|
9194
|
-
"
|
|
9195
|
-
"
|
|
9196
|
-
"
|
|
9197
|
-
"
|
|
9198
|
-
"
|
|
9250
|
+
// Newspaper mode · same single-pass chair-LLM call but voice
|
|
9251
|
+
// shifts to broadsheet front-page journalism. Keyed under
|
|
9252
|
+
// `newspaper-write` and selected at render time when the
|
|
9253
|
+
// brief's mode is newspaper.
|
|
9254
|
+
"newspaper-write": [
|
|
9255
|
+
"Setting the banner headline",
|
|
9256
|
+
"Writing the subdeck",
|
|
9257
|
+
"Filing the three column stories",
|
|
9258
|
+
"Drafting the bottom-line callout",
|
|
9259
|
+
"Stacking the more-headings sidebar",
|
|
9260
|
+
"Stamping the masthead date",
|
|
9199
9261
|
],
|
|
9200
9262
|
},
|
|
9263
|
+
// System UI · always English. The `zh` key is kept as an alias
|
|
9264
|
+
// of `en` so any caller indexing BRIEF_SUBSTAGES["zh"] still
|
|
9265
|
+
// resolves; brief language no longer changes the substage copy.
|
|
9266
|
+
get zh() { return this.en; },
|
|
9201
9267
|
},
|
|
9202
9268
|
|
|
9203
9269
|
/** Set up a 1s tick that re-renders the brief stages while at least
|
|
@@ -9213,7 +9279,7 @@
|
|
|
9213
9279
|
}
|
|
9214
9280
|
const stages = b.stages || {};
|
|
9215
9281
|
const anyActive = Object.values(stages).some((s) => s && s.status === "active");
|
|
9216
|
-
const generating = b.isGenerating === true || !b
|
|
9282
|
+
const generating = b.isGenerating === true || !this.briefHasBody(b) || b.title === "Generating…";
|
|
9217
9283
|
if (!anyActive && !generating) {
|
|
9218
9284
|
this.stopBriefStageTick();
|
|
9219
9285
|
return;
|
|
@@ -9264,7 +9330,7 @@
|
|
|
9264
9330
|
if (this._briefStallWatchTimer) return;
|
|
9265
9331
|
const b = this.currentBrief;
|
|
9266
9332
|
if (!b || !b.id || b.error) return;
|
|
9267
|
-
const generating = b.isGenerating === true || !b
|
|
9333
|
+
const generating = b.isGenerating === true || !this.briefHasBody(b) || b.title === "Generating…";
|
|
9268
9334
|
if (!generating) return;
|
|
9269
9335
|
if (!this._lastBriefEventAt) this._lastBriefEventAt = Date.now();
|
|
9270
9336
|
this._lastBriefHealthPollAt = 0;
|
|
@@ -9284,7 +9350,7 @@
|
|
|
9284
9350
|
async tickBriefStallWatch() {
|
|
9285
9351
|
const b = this.currentBrief;
|
|
9286
9352
|
if (!b || b.error) { this.stopBriefStallWatch(); return; }
|
|
9287
|
-
const generating = b.isGenerating === true || !b
|
|
9353
|
+
const generating = b.isGenerating === true || !this.briefHasBody(b) || b.title === "Generating…";
|
|
9288
9354
|
if (!generating) { this.stopBriefStallWatch(); return; }
|
|
9289
9355
|
|
|
9290
9356
|
const now = Date.now();
|
|
@@ -9293,9 +9359,8 @@
|
|
|
9293
9359
|
// Hard ceiling · regardless of server state, flip the card to
|
|
9294
9360
|
// a timed-out error so the user always has a way out.
|
|
9295
9361
|
if (now - startedAt > this.BRIEF_HARD_TIMEOUT_MS) {
|
|
9296
|
-
|
|
9297
|
-
|
|
9298
|
-
: "Brief generation timed out (no completion after 8 minutes).";
|
|
9362
|
+
// System UI · always English (timeout error message).
|
|
9363
|
+
b.error = "Brief generation timed out (no completion after 8 minutes).";
|
|
9299
9364
|
b.timedOut = true;
|
|
9300
9365
|
this.stopBriefStageTick();
|
|
9301
9366
|
this.stopBriefStallWatch();
|
|
@@ -9340,30 +9405,39 @@
|
|
|
9340
9405
|
write: { status: "pending", detail: "", progress: null, startedAt: null },
|
|
9341
9406
|
};
|
|
9342
9407
|
const lang = b.language === "zh" ? "zh" : "en";
|
|
9343
|
-
const chairName = b.chairName || this.currentChair?.name ||
|
|
9408
|
+
const chairName = b.chairName || this.currentChair?.name || "Chair";
|
|
9344
9409
|
|
|
9345
9410
|
const wordCount = b.bodyMd
|
|
9346
9411
|
? (b.bodyMd.trim().match(/\S+/g) || []).length
|
|
9347
9412
|
: 0;
|
|
9348
9413
|
|
|
9349
|
-
// Stage definitions ·
|
|
9350
|
-
//
|
|
9414
|
+
// Stage definitions · mode-aware. The wire format emitted by
|
|
9415
|
+
// emitStage() in src/orchestrator/brief.ts depends on which
|
|
9416
|
+
// pipeline ran:
|
|
9417
|
+
//
|
|
9418
|
+
// research-note (default) · extract → compose → scaffold-
|
|
9419
|
+
// {anchor,findings,cluster,actions} → write (7 stages · the
|
|
9420
|
+
// 4 scaffold sub-stages are driven by JSON-key arrival in
|
|
9421
|
+
// the streaming buffer, see SCAFFOLD_TRIGGERS in brief.ts)
|
|
9351
9422
|
//
|
|
9352
|
-
// extract →
|
|
9423
|
+
// bento + magazine · extract → write (2 stages · runBentoStage
|
|
9424
|
+
// runs ONE chair-LLM call that produces the BentoScaffold;
|
|
9425
|
+
// composer and the scaffold sub-stages are skipped entirely.
|
|
9426
|
+
// Both modes share the same 2-stage rail · only the write
|
|
9427
|
+
// label and renderer differ)
|
|
9353
9428
|
//
|
|
9354
|
-
//
|
|
9355
|
-
//
|
|
9356
|
-
//
|
|
9357
|
-
|
|
9358
|
-
|
|
9429
|
+
// System UI · always English regardless of brief language. The
|
|
9430
|
+
// pipeline labels are the app's voice (chrome around the
|
|
9431
|
+
// generation), not the report content itself.
|
|
9432
|
+
const STRUCTURED_WRITE_LABELS = {
|
|
9433
|
+
bento: "Composing the bento",
|
|
9434
|
+
magazine: "Composing the magazine",
|
|
9435
|
+
newspaper: "Composing the newspaper",
|
|
9436
|
+
};
|
|
9437
|
+
const STAGE_DEFS = this.isStructuredBriefMode(b.mode)
|
|
9359
9438
|
? [
|
|
9360
|
-
{ key: "extract",
|
|
9361
|
-
{ key: "
|
|
9362
|
-
{ key: "scaffold-anchor", label: "敲定核心判断 (anchor)", pipShort: "锚" },
|
|
9363
|
-
{ key: "scaffold-findings", label: "勾勒主张与发现", pipShort: "见" },
|
|
9364
|
-
{ key: "scaffold-cluster", label: "梳理共识与分歧", pipShort: "辨" },
|
|
9365
|
-
{ key: "scaffold-actions", label: "拟动作 · 推演风险", pipShort: "拟" },
|
|
9366
|
-
{ key: "write", label: "撰写最终报告", pipShort: "写" },
|
|
9439
|
+
{ key: "extract", label: "Reading what each director said", pipShort: "read" },
|
|
9440
|
+
{ key: "write", label: STRUCTURED_WRITE_LABELS[b.mode] || "Composing the report", pipShort: "compose" },
|
|
9367
9441
|
]
|
|
9368
9442
|
: [
|
|
9369
9443
|
{ key: "extract", label: "Reading what each director said", pipShort: "read" },
|
|
@@ -9425,9 +9499,21 @@
|
|
|
9425
9499
|
}
|
|
9426
9500
|
|
|
9427
9501
|
// Rotating sub-line · advances every 3s within the active stage.
|
|
9502
|
+
// Bento mode's `write` stage does different work than the
|
|
9503
|
+
// research-note `write` stage — pick its dedicated rotator
|
|
9504
|
+
// (`bento-write`) when active. Falls through to the regular
|
|
9505
|
+
// key for every other stage / mode.
|
|
9428
9506
|
let substageText = "";
|
|
9429
|
-
|
|
9430
|
-
|
|
9507
|
+
// Structured-mode write stages get their own rotator copy
|
|
9508
|
+
// ("bento-write" / "magazine-write" / "newspaper-write") since
|
|
9509
|
+
// the work is different from research-note's chapter-by-chapter
|
|
9510
|
+
// write.
|
|
9511
|
+
let subKey = activeDef.key;
|
|
9512
|
+
if (activeDef.key === "write" && this.isStructuredBriefMode(b.mode)) {
|
|
9513
|
+
subKey = `${b.mode}-write`;
|
|
9514
|
+
}
|
|
9515
|
+
if (activeStatus === "active" && substages[subKey]?.length) {
|
|
9516
|
+
const list = substages[subKey];
|
|
9431
9517
|
substageText = list[Math.floor(activeElapsed / 3) % list.length];
|
|
9432
9518
|
}
|
|
9433
9519
|
|
|
@@ -9437,12 +9523,12 @@
|
|
|
9437
9523
|
if (activeDef.key === "extract" && activeStage.progress?.total) {
|
|
9438
9524
|
const cur = activeStage.progress.current;
|
|
9439
9525
|
const tot = activeStage.progress.total;
|
|
9440
|
-
detailParts.push(
|
|
9526
|
+
detailParts.push(`${cur}/${tot} director${tot === 1 ? "" : "s"}`);
|
|
9441
9527
|
} else if (activeStage.detail) {
|
|
9442
9528
|
detailParts.push(activeStage.detail);
|
|
9443
9529
|
}
|
|
9444
9530
|
if (activeDef.key === "write" && activeStatus === "active" && wordCount > 0) {
|
|
9445
|
-
detailParts.push(
|
|
9531
|
+
detailParts.push(`${wordCount} word${wordCount === 1 ? "" : "s"}`);
|
|
9446
9532
|
}
|
|
9447
9533
|
const detailLine = detailParts.join(" · ");
|
|
9448
9534
|
|
|
@@ -9454,7 +9540,7 @@
|
|
|
9454
9540
|
? `${activeElapsed}s · ~${activeEta[0]}–${activeEta[1]}s`
|
|
9455
9541
|
: `~${activeEta[0]}–${activeEta[1]}s`;
|
|
9456
9542
|
} else {
|
|
9457
|
-
timing =
|
|
9543
|
+
timing = `${activeElapsed}s elapsed`;
|
|
9458
9544
|
}
|
|
9459
9545
|
}
|
|
9460
9546
|
|
|
@@ -9510,19 +9596,15 @@
|
|
|
9510
9596
|
return r === 0 ? `${m}m` : `${m}m ${r}s`;
|
|
9511
9597
|
};
|
|
9512
9598
|
const fmtRange = (lo, hi) => {
|
|
9513
|
-
if (hi < 60) return
|
|
9599
|
+
if (hi < 60) return `~${lo}–${hi}s`;
|
|
9514
9600
|
const loM = Math.max(1, Math.round(lo / 60));
|
|
9515
9601
|
const hiM = Math.max(loM, Math.round(hi / 60));
|
|
9516
|
-
return
|
|
9602
|
+
return `~${loM}-${hiM}m`;
|
|
9517
9603
|
};
|
|
9518
9604
|
|
|
9519
|
-
|
|
9520
|
-
|
|
9521
|
-
|
|
9522
|
-
|
|
9523
|
-
const kickerCore = lang === "zh"
|
|
9524
|
-
? `// ${chairName} 正在整理纪要 · ${totalText}`
|
|
9525
|
-
: `// ${chairName} is preparing the minutes · ${totalText}`;
|
|
9605
|
+
// System UI · always English (brief-progress kicker copy).
|
|
9606
|
+
const totalText = `${fmtSec(totalElapsed)} elapsed · ${fmtRange(totalLo, totalHi)} left`;
|
|
9607
|
+
const kickerCore = `// ${chairName} is preparing the minutes · ${totalText}`;
|
|
9526
9608
|
|
|
9527
9609
|
const metaHtml = (detailLine || timing)
|
|
9528
9610
|
? `<span class="brief-active-meta">` +
|
|
@@ -9554,9 +9636,8 @@
|
|
|
9554
9636
|
this._briefSeenHarvestKeys = this._briefSeenHarvestKeys || {};
|
|
9555
9637
|
const seenH = this._briefSeenHarvestKeys[b.id] = this._briefSeenHarvestKeys[b.id] || new Set();
|
|
9556
9638
|
const harvest = Array.isArray(b.extractHarvest) ? b.extractHarvest : [];
|
|
9557
|
-
|
|
9558
|
-
|
|
9559
|
-
: { claims: "claims", evidence: "evidence", tensions: "tensions", assumptions: "assumptions", risks: "risks", opportunities: "opportunities", actions: "actions", quotes: "quotes", openQuestions: "open-q" };
|
|
9639
|
+
// System UI · always English (signal-kind chip labels).
|
|
9640
|
+
const kindLabels = { claims: "claims", evidence: "evidence", tensions: "tensions", assumptions: "assumptions", risks: "risks", opportunities: "opportunities", actions: "actions", quotes: "quotes", openQuestions: "open-q" };
|
|
9560
9641
|
if (harvest.length) {
|
|
9561
9642
|
const chips = harvest.map((h) => {
|
|
9562
9643
|
const isFresh = !seenH.has(h.directorId);
|
|
@@ -9581,7 +9662,7 @@
|
|
|
9581
9662
|
// Live word count during write — large and visible since it's the
|
|
9582
9663
|
// most engaging signal during the long write stage.
|
|
9583
9664
|
if (writeActive && wordCount > 0) {
|
|
9584
|
-
const w =
|
|
9665
|
+
const w = `${wordCount} word${wordCount === 1 ? "" : "s"} · still writing`;
|
|
9585
9666
|
stats.push(`<span class="brief-stat-fact brief-stat-live">${this.escape(w)}</span>`);
|
|
9586
9667
|
}
|
|
9587
9668
|
const statsHtml = stats.length
|
|
@@ -9902,8 +9983,8 @@
|
|
|
9902
9983
|
genBriefBtn.setAttribute("data-pending", "1");
|
|
9903
9984
|
if ("disabled" in genBriefBtn) genBriefBtn.disabled = true;
|
|
9904
9985
|
|
|
9905
|
-
|
|
9906
|
-
const generatingText =
|
|
9986
|
+
// System UI · always English (generating-button chrome).
|
|
9987
|
+
const generatingText = "generating…";
|
|
9907
9988
|
const originalHtml = genBriefBtn.innerHTML;
|
|
9908
9989
|
|
|
9909
9990
|
// Swap to a "generating…" state. The two button shapes both
|
|
@@ -9926,10 +10007,31 @@
|
|
|
9926
10007
|
genBriefBtn.removeAttribute("data-pending");
|
|
9927
10008
|
if ("disabled" in genBriefBtn) genBriefBtn.disabled = false;
|
|
9928
10009
|
genBriefBtn.innerHTML = originalHtml;
|
|
9929
|
-
alert(
|
|
10010
|
+
alert("Brief generation failed: " + (err && err.message ? err.message : err));
|
|
9930
10011
|
});
|
|
9931
10012
|
return;
|
|
9932
10013
|
}
|
|
10014
|
+
// Brief-mode picker · clicking a label (or its radio) toggles the
|
|
10015
|
+
// .on class on that option AND off on the siblings. Used by both
|
|
10016
|
+
// the adjourn overlay and the supplement overlay. We scope to the
|
|
10017
|
+
// picker's `.adjourn-mode-options` container (set in
|
|
10018
|
+
// renderBriefModePicker) instead of a specific overlay id, so the
|
|
10019
|
+
// same handler works wherever the picker is embedded. The native
|
|
10020
|
+
// radio behaviour handles `:checked` state but we use a manual
|
|
10021
|
+
// class for visual styling (left accent stripe + tint) since we
|
|
10022
|
+
// can't `:has()` reliably on older browsers.
|
|
10023
|
+
const modeOpt = e.target.closest(".adjourn-mode-option");
|
|
10024
|
+
if (modeOpt) {
|
|
10025
|
+
const group = modeOpt.closest(".adjourn-mode-options");
|
|
10026
|
+
if (group) {
|
|
10027
|
+
group.querySelectorAll(".adjourn-mode-option").forEach((el) => el.classList.remove("on"));
|
|
10028
|
+
modeOpt.classList.add("on");
|
|
10029
|
+
const radio = modeOpt.querySelector('input[type="radio"]');
|
|
10030
|
+
if (radio) radio.checked = true;
|
|
10031
|
+
}
|
|
10032
|
+
// Don't preventDefault · let the label's native click behaviour
|
|
10033
|
+
// also tick the radio for keyboard / a11y users.
|
|
10034
|
+
}
|
|
9933
10035
|
// Adjourn overlay · "skip report" footer button — clicking commits
|
|
9934
10036
|
// immediately (mark picked + dispatch + close).
|
|
9935
10037
|
if (e.target.closest("[data-adjourn-skip]")) {
|
|
@@ -9953,6 +10055,18 @@
|
|
|
9953
10055
|
app.closeAdjournOverlay();
|
|
9954
10056
|
return;
|
|
9955
10057
|
}
|
|
10058
|
+
// Adjourn overlay · subject Show more / less toggle. Flips the
|
|
10059
|
+
// `is-clamped` class on the value span and swaps the button label.
|
|
10060
|
+
const subjToggle = e.target.closest("[data-adjourn-subject-toggle]");
|
|
10061
|
+
if (subjToggle) {
|
|
10062
|
+
e.preventDefault();
|
|
10063
|
+
const subjEl = document.querySelector("[data-adjourn-subject]");
|
|
10064
|
+
if (subjEl) {
|
|
10065
|
+
const expanded = subjEl.classList.toggle("is-clamped") === false;
|
|
10066
|
+
subjToggle.textContent = expanded ? "Show less" : "Show more";
|
|
10067
|
+
}
|
|
10068
|
+
return;
|
|
10069
|
+
}
|
|
9956
10070
|
// Brief card · "Add a perspective" → opens the supplement overlay.
|
|
9957
10071
|
if (e.target.closest("[data-brief-supplement]")) {
|
|
9958
10072
|
e.preventDefault();
|
|
@@ -10185,11 +10299,9 @@
|
|
|
10185
10299
|
// map, which user-settings.js patches in place after every
|
|
10186
10300
|
// setProviderKey, so we get fresh truth here.
|
|
10187
10301
|
const configured = !!(app.agentComposerBraveConfigured && app.agentComposerBraveConfigured());
|
|
10188
|
-
const isZh = (app.composerLanguage && app.composerLanguage()) === "zh";
|
|
10189
10302
|
if (!configured) {
|
|
10190
|
-
|
|
10191
|
-
|
|
10192
|
-
: "Web Search needs a Brave Search API key.\n\nBrave Search · ≈ $5 per 1000 queries · privacy-respecting\n\nOpen Preferences to paste your key now?");
|
|
10303
|
+
// System UI · always English (Brave key prompt confirm dialog).
|
|
10304
|
+
const ok = confirm("Web Search needs a Brave Search API key.\n\nBrave Search · ≈ $5 per 1000 queries · privacy-respecting\n\nOpen Preferences to paste your key now?");
|
|
10193
10305
|
if (ok && typeof window.openUserSettings === "function") {
|
|
10194
10306
|
window.openUserSettings({ section: "keys", focusProvider: "brave" });
|
|
10195
10307
|
}
|
|
@@ -10211,19 +10323,14 @@
|
|
|
10211
10323
|
wsToggle.setAttribute("aria-pressed", next ? "true" : "false");
|
|
10212
10324
|
const txt = wsToggle.querySelector(".ap-skill-row-toggle-text");
|
|
10213
10325
|
if (txt) {
|
|
10214
|
-
|
|
10215
|
-
const
|
|
10216
|
-
|
|
10217
|
-
: (isZh ? "已关闭" : "disabled");
|
|
10326
|
+
// System UI · always English (web-search toggle chrome).
|
|
10327
|
+
const wsLabel = "web search";
|
|
10328
|
+
const stateLabel = next ? "enabled" : "disabled";
|
|
10218
10329
|
txt.textContent = `${wsLabel} · ${stateLabel}`;
|
|
10219
10330
|
}
|
|
10220
10331
|
wsToggle.title = next
|
|
10221
|
-
?
|
|
10222
|
-
|
|
10223
|
-
: "Search the web for real domain references during generation · click to disable")
|
|
10224
|
-
: (isZh
|
|
10225
|
-
? "生成时不联网 · 点击开启"
|
|
10226
|
-
: "Generation runs offline · click to enable web search");
|
|
10332
|
+
? "Search the web for real domain references during generation · click to disable"
|
|
10333
|
+
: "Generation runs offline · click to enable web search";
|
|
10227
10334
|
return;
|
|
10228
10335
|
}
|
|
10229
10336
|
// ─── Agent spec preview · field actions
|
|
@@ -10359,6 +10466,20 @@
|
|
|
10359
10466
|
if (id) app.deleteRoom(id);
|
|
10360
10467
|
return;
|
|
10361
10468
|
}
|
|
10469
|
+
// Delete a saved note from the All Notes list. The button is a
|
|
10470
|
+
// sibling of the note's anchor (inside .notes-item) so its click
|
|
10471
|
+
// never bubbles through the navigation link — but we still
|
|
10472
|
+
// preventDefault + stopPropagation defensively to keep the row's
|
|
10473
|
+
// hover/focus state quiet.
|
|
10474
|
+
const noteDel = e.target.closest("[data-note-delete]");
|
|
10475
|
+
if (noteDel) {
|
|
10476
|
+
e.preventDefault();
|
|
10477
|
+
e.stopPropagation();
|
|
10478
|
+
const item = noteDel.closest("[data-note-id]");
|
|
10479
|
+
const id = item?.dataset.noteId;
|
|
10480
|
+
if (id) app.deleteNoteAt(id);
|
|
10481
|
+
return;
|
|
10482
|
+
}
|
|
10362
10483
|
// Delete a custom agent · this used to live as an inline X button
|
|
10363
10484
|
// on the sidebar row. It's been moved into the agent profile's
|
|
10364
10485
|
// ⋯ overflow menu (see agent-profile.js → "delete" menu action),
|