dsh-sidecard-ask 2.0.0 → 2.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (5) hide show
  1. package/CHANGELOG.md +113 -0
  2. package/README.md +17 -12
  3. package/client.js +201 -43
  4. package/index.js +443 -72
  5. package/package.json +4 -1
package/client.js CHANGED
@@ -35,7 +35,33 @@ window.__ModuleLoader__.load({
35
35
  // ─────────────────────────────────────────────────────────────────────
36
36
 
37
37
  const PLUGIN_ID = 'dsh-sidecard-ask'
38
- const API = '/sidecard-ask/api'
38
+ /**
39
+ * Base path of this plugin's Host API.
40
+ *
41
+ * Resolved against `document.baseURI` rather than hard-coded as the
42
+ * root-absolute `/sidecard-ask/api`, which is what the Host's own Client
43
+ * does for its transport base (`dsh-client-connection/lib/client.browser.js`:
44
+ * `const base = baseUrl ?? document.baseURI`). A root-absolute path drops
45
+ * the deployment prefix, so a DSH served under a path prefix
46
+ * (`dsh web --public-url https://host/dsh/`) sent every request to
47
+ * `/sidecard-ask/api/*` and got a 404. The Electron shell also loads the app
48
+ * from `dsh-app://app/`, where only a document-relative path is meaningful.
49
+ *
50
+ * At the document root (both the default Web deployment and the Electron
51
+ * shell) this evaluates to exactly the previous `/sidecard-ask/api`, so no
52
+ * existing deployment changes behaviour. `document.baseURI` already carries
53
+ * the trailing slash the Host normalises its public root to; the explicit
54
+ * `replace` below keeps that true even if a `<base>` without one is used.
55
+ */
56
+ const API = (() => {
57
+ try {
58
+ const base = typeof document === 'undefined' ? undefined : document.baseURI
59
+ if (typeof base !== 'string' || base === '') return '/sidecard-ask/api'
60
+ return `${new URL('.', base).pathname.replace(/\/$/, '')}/sidecard-ask/api`
61
+ } catch {
62
+ return '/sidecard-ask/api'
63
+ }
64
+ })()
39
65
  /** Slot ids contributed by this plugin (also the overlay entry ids). */
40
66
  const IDS = {
41
67
  overlay: 'sidecard-ask',
@@ -47,7 +73,7 @@ window.__ModuleLoader__.load({
47
73
  * the Host and the module's `api.version` both read it, so a report can
48
74
  * never claim a generation the browser is not actually running.
49
75
  */
50
- const CLIENT_VERSION = '2.0.0'
76
+ const CLIENT_VERSION = '2.0.2'
51
77
 
52
78
  /**
53
79
  * Tab kind served by the DSH native right rail (also its implementation id).
@@ -77,6 +103,12 @@ window.__ModuleLoader__.load({
77
103
  */
78
104
  const HISTORY_TURNS = 6
79
105
 
106
+ // Per-turn wire caps mirroring the Host's prompt-side limits: a long earlier
107
+ // answer must neither bloat the request body past the 1 MiB limit nor push
108
+ // the current question out of the child prompt.
109
+ const HISTORY_TURN_Q_CHARS = 500
110
+ const HISTORY_TURN_A_CHARS = 4000
111
+
80
112
  /**
81
113
  * Client-side mirror of the Host `DEFAULT_CONFIG`. The client needs values
82
114
  * before `/state` answers (first paint) and when the Host is unreachable;
@@ -466,8 +498,6 @@ window.__ModuleLoader__.load({
466
498
  zoneCross: '跨区选择',
467
499
  chars: '{n} 字',
468
500
  truncatedBadge: '已截断 {n} 字',
469
- selectionEmpty: '没有选中文本',
470
- selectionTooShort: '选中文本太短,未触发追问',
471
501
  answerTitle: '追问',
472
502
  answerStreaming: '作答中…',
473
503
  answerDone: '已完成',
@@ -510,6 +540,8 @@ window.__ModuleLoader__.load({
510
540
  errorNoParentBody: '当前没有活动的会话代理,无法发起独立作答',
511
541
  errorAborted: '作答被取消或超时',
512
542
  errorUnreachableBody: '宿主接口不可达',
543
+ errorUnauthorizedBody: '需要先通过 DSH 登录(宿主会话未认证)',
544
+ errorForbiddenBody: '请求来源不受信任(Host / Origin 不匹配)',
513
545
  errorBadResponseBody: '宿主返回了无法解析的响应',
514
546
  errorStreamBrokenBody: '流式连接中断',
515
547
  errorStreamEndedBody: '流式连接意外结束',
@@ -539,7 +571,6 @@ window.__ModuleLoader__.load({
539
571
  setFloatMaxWidthHint: '空间不足时自动收窄,不会硬顶',
540
572
  setDockRail: '右侧栏打开时占满它',
541
573
  setDockRailHint: '答案直接铺满右侧栏(读得像栏内的一页),收起为胶囊或关闭即恢复原状;右侧栏不在时不生效',
542
- floatDocked: '已占满右侧栏',
543
574
  floatExpand: '展开侧边卡片',
544
575
  floatCollapse: '收起为胶囊(不遮挡对话)',
545
576
  floatStreaming: '正在作答…',
@@ -565,7 +596,6 @@ window.__ModuleLoader__.load({
565
596
  saved: '已保存',
566
597
  saveFailed: '保存失败:{msg}',
567
598
  reset: '重置为 patch 配置',
568
- resetDone: '已重置',
569
599
  diagnostics: '运行自检',
570
600
  diagTitle: '能力自检',
571
601
  diagHost: '宿主接口',
@@ -591,8 +621,6 @@ window.__ModuleLoader__.load({
591
621
  surfaceUnavailable: '所选侧边承载面不可用',
592
622
  familySuppressed: '检测到同系列更新版本({version})已在本页运行,本实例已自动停用划词触发与快捷键,界面由更新版本接管。建议在插件页卸载本旧版本。',
593
623
  familyLegacyToast: '检测到旧版 dsh-selection-followup 同时启用,已由本版本接管;建议卸载旧包',
594
- familyLegacyDiag: '旧版共载',
595
- familyPeers: '同页实例',
596
624
  retryEdit: '改问',
597
625
  editQuestionPlaceholder: '修改问题后重新提问…',
598
626
  retryAskSubmit: '重新提问',
@@ -688,8 +716,6 @@ window.__ModuleLoader__.load({
688
716
  versionLatest: '返回最新',
689
717
  versionViewing: '正在查看历史版本(只读)',
690
718
  suggestionsLabel: '建议追问',
691
- familyLegacyDiag: '旧版共载',
692
- familyPeers: '同页实例',
693
719
  setFeedback: '反馈',
694
720
  setFeedbackHint: '遇到问题或有建议,在这里直接发邮件给我;邮件会自动附带插件版本与运行环境(不含聊天内容)',
695
721
  feedbackOpen: '反馈问题',
@@ -742,8 +768,6 @@ window.__ModuleLoader__.load({
742
768
  zoneCross: 'Cross-region',
743
769
  chars: '{n} chars',
744
770
  truncatedBadge: 'truncated {n} chars',
745
- selectionEmpty: 'Nothing selected',
746
- selectionTooShort: 'Selection too short to trigger',
747
771
  answerTitle: 'Follow-up',
748
772
  answerStreaming: 'Answering…',
749
773
  answerDone: 'Done',
@@ -786,6 +810,8 @@ window.__ModuleLoader__.load({
786
810
  errorNoParentBody: 'No active session agent to answer independently',
787
811
  errorAborted: 'The answer was cancelled or timed out',
788
812
  errorUnreachableBody: 'The host API is unreachable',
813
+ errorUnauthorizedBody: 'Sign in to DSH first (the host session is not authenticated)',
814
+ errorForbiddenBody: 'Untrusted request origin (Host / Origin mismatch)',
789
815
  errorBadResponseBody: 'The host returned an unreadable response',
790
816
  errorStreamBrokenBody: 'The stream connection broke',
791
817
  errorStreamEndedBody: 'The stream ended without a terminal event',
@@ -815,7 +841,6 @@ window.__ModuleLoader__.load({
815
841
  setFloatMaxWidthHint: 'Narrows automatically when there is not enough room',
816
842
  setDockRail: 'Fill the right rail while it is open',
817
843
  setDockRailHint: 'The answer fills the right rail (it reads as one of its pages) and comes back to normal when collapsed to the capsule or closed; ignored while no rail is on screen',
818
- floatDocked: 'Filled the right rail',
819
844
  floatExpand: 'Expand the side cards',
820
845
  floatCollapse: 'Collapse to a capsule (stops covering the conversation)',
821
846
  floatStreaming: 'Answering…',
@@ -841,7 +866,6 @@ window.__ModuleLoader__.load({
841
866
  saved: 'Saved',
842
867
  saveFailed: 'Save failed: {msg}',
843
868
  reset: 'Reset to patch config',
844
- resetDone: 'Reset',
845
869
  diagnostics: 'Run diagnostics',
846
870
  diagTitle: 'Capability check',
847
871
  diagHost: 'Host API',
@@ -867,8 +891,6 @@ window.__ModuleLoader__.load({
867
891
  surfaceUnavailable: 'The chosen side surface is unavailable',
868
892
  familySuppressed: 'A newer family build ({version}) is already running on this page; this copy has disabled its trigger and shortcut so only one UI answers. Uninstall this older package.',
869
893
  familyLegacyToast: 'Legacy dsh-selection-followup detected alongside this plugin; this build took over. Uninstall the old package.',
870
- familyLegacyDiag: 'Legacy co-load',
871
- familyPeers: 'Copies on page',
872
894
  retryEdit: 'Edit & retry',
873
895
  editQuestionPlaceholder: 'Edit the question, then ask again…',
874
896
  retryAskSubmit: 'Ask again',
@@ -964,8 +986,6 @@ window.__ModuleLoader__.load({
964
986
  versionLatest: 'Back to latest',
965
987
  versionViewing: 'Viewing an older version (read-only)',
966
988
  suggestionsLabel: 'Suggested follow-ups',
967
- familyLegacyDiag: 'Legacy co-load',
968
- familyPeers: 'Copies on page',
969
989
  setFeedback: 'Feedback',
970
990
  setFeedbackHint: 'Report a bug or send a suggestion by mail from here; the plugin version and runtime details are attached automatically (no chat content)',
971
991
  feedbackOpen: 'Send feedback',
@@ -1065,6 +1085,11 @@ window.__ModuleLoader__.load({
1065
1085
  'no-parent': { key: 'errorNoParentBody' },
1066
1086
  aborted: { key: 'errorAborted' },
1067
1087
  unreachable: { key: 'errorUnreachableBody' },
1088
+ // The Host now gates these routes on the DSH browser session, so both are
1089
+ // fully known Client-side: "log in" and "untrusted origin" are actionable
1090
+ // in a way a generic unreachable message is not.
1091
+ unauthorized: { key: 'errorUnauthorizedBody' },
1092
+ forbidden: { key: 'errorForbiddenBody' },
1068
1093
  'bad-response': { key: 'errorBadResponseBody' },
1069
1094
  'stream-broken': { key: 'errorStreamBrokenBody' },
1070
1095
  'stream-ended': { key: 'errorStreamEndedBody' },
@@ -1118,10 +1143,24 @@ window.__ModuleLoader__.load({
1118
1143
 
1119
1144
  /** GET the plugin state (`/state` also answers POST, GET keeps it cacheable-free). */
1120
1145
  async function getState() {
1121
- const response = await fetch(`${API}/state`, { method: 'GET' })
1122
- const body = await response.json()
1123
- if (body?.ok !== true) throw Object.assign(new Error('state failed'), { code: 'bad-response' })
1124
- return body.value
1146
+ let response
1147
+ try {
1148
+ response = await fetch(`${API}/state`, { method: 'GET' })
1149
+ } catch (error) {
1150
+ throw Object.assign(new Error(String(error?.message ?? error)), { code: 'unreachable' })
1151
+ }
1152
+ let body
1153
+ try {
1154
+ body = await response.json()
1155
+ } catch {
1156
+ throw Object.assign(new Error(`HTTP ${response.status}`), { code: 'bad-response' })
1157
+ }
1158
+ // Keep the Host's own code: a 401 ("log in first") and a 403 ("untrusted
1159
+ // origin") are actionable in a way that a generic bad-response is not.
1160
+ if (body?.ok === true) return body.value
1161
+ const failure = new Error(body?.error?.message ?? `HTTP ${response.status}`)
1162
+ failure.code = body?.error?.code ?? 'bad-response'
1163
+ throw failure
1125
1164
  }
1126
1165
 
1127
1166
  /**
@@ -1146,7 +1185,18 @@ window.__ModuleLoader__.load({
1146
1185
  return
1147
1186
  }
1148
1187
  if (!response.ok || response.body === null) {
1149
- handlers.onError({ code: `http-${response.status}`, message: `HTTP ${response.status}` })
1188
+ // Non-200 answers are JSON errors ({ok:false,error:{code,message}}),
1189
+ // not SSE; surface the server's localized code instead of a bare
1190
+ // "HTTP 400".
1191
+ let wire = null
1192
+ try {
1193
+ const rawBody = await response.text()
1194
+ const parsed = rawBody ? JSON.parse(rawBody) : null
1195
+ if (parsed?.error !== null && typeof parsed?.error === 'object') wire = parsed.error
1196
+ } catch { wire = null }
1197
+ handlers.onError(wire && typeof wire.code === 'string'
1198
+ ? wire
1199
+ : { code: `http-${response.status}`, message: `HTTP ${response.status}` })
1150
1200
  return
1151
1201
  }
1152
1202
  const reader = response.body.getReader()
@@ -1547,14 +1597,25 @@ window.__ModuleLoader__.load({
1547
1597
  * Truncate an over-long selection for an outgoing prompt, keeping head and
1548
1598
  * tail. Mirrors the Host's `truncateSelection` (asserted by contract test):
1549
1599
  * code points, not UTF-16 units, so a surrogate pair is never split.
1600
+ *
1601
+ * The omission note is charged to the budget, not added on top of it: an
1602
+ * "inclusive cap" that the note itself overflows by ~19 characters is not a
1603
+ * cap, and the Host sizes its prompt budget from what a fixed prompt
1604
+ * leaves over.
1550
1605
  * @returns {{text: string, truncated: boolean, droppedChars: number}}
1551
1606
  */
1552
1607
  function truncateSelection(text, maxChars) {
1553
1608
  const source = typeof text === 'string' ? text : ''
1554
1609
  const points = Array.from(source)
1555
- if (points.length <= maxChars) return { text: source, truncated: false, droppedChars: 0 }
1556
- const head = Math.max(1, Math.ceil(maxChars * 0.7))
1557
- const tail = Math.max(0, maxChars - head)
1610
+ const cap = Number.isFinite(maxChars) ? Math.max(0, Math.floor(maxChars)) : points.length
1611
+ if (points.length <= cap) return { text: source, truncated: false, droppedChars: 0 }
1612
+ const noteLength = Array.from(`\n…(已省略中间 ${points.length} 个字符)…\n`).length
1613
+ const room = cap - noteLength
1614
+ if (room < 2) {
1615
+ return { text: points.slice(0, cap).join(''), truncated: true, droppedChars: points.length - cap }
1616
+ }
1617
+ const head = Math.max(1, Math.ceil(room * 0.7))
1618
+ const tail = Math.max(0, room - head)
1558
1619
  const dropped = points.length - head - tail
1559
1620
  return {
1560
1621
  text: `${points.slice(0, head).join('')}\n…(已省略中间 ${dropped} 个字符)…\n${tail > 0 ? points.slice(points.length - tail).join('') : ''}`,
@@ -2541,14 +2602,24 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
2541
2602
  return h('style', { 'data-dsa-style': '' }, CSS_TEXT)
2542
2603
  }
2543
2604
 
2605
+ /**
2606
+ * Inline markdown-lite token scanner.
2607
+ *
2608
+ * Hoisted out of {@link inlineNodes} because a long answer calls it once per
2609
+ * line/paragraph on every render, and a fresh RegExp literal per call was
2610
+ * pure allocation on the streaming hot path. `/g` is stateful, so the
2611
+ * scanner resets `lastIndex` before each use (it never recurses).
2612
+ */
2613
+ const INLINE_TOKEN_RE = /(`[^`]+`|\*\*[^*]+\*\*|\*[^*\n]+\*|\[[^\]\n]+\]\(https?:\/\/[^\s)]+\))/g
2614
+
2544
2615
  /** Inline markdown-lite: `code`, **bold**, *italic*, [label](http url) — never innerHTML. */
2545
2616
  function inlineNodes(text, key) {
2546
2617
  const out = []
2547
- const pattern = /(`[^`]+`|\*\*[^*]+\*\*|\*[^*\n]+\*|\[[^\]\n]+\]\(https?:\/\/[^\s)]+\))/g
2618
+ INLINE_TOKEN_RE.lastIndex = 0
2548
2619
  let last = 0
2549
2620
  let match
2550
2621
  let index = 0
2551
- while ((match = pattern.exec(text)) !== null) {
2622
+ while ((match = INLINE_TOKEN_RE.exec(text)) !== null) {
2552
2623
  if (match.index > last) out.push(text.slice(last, match.index))
2553
2624
  const token = match[0]
2554
2625
  if (token.startsWith('`')) {
@@ -3889,18 +3960,27 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
3889
3960
  // context, not the answer, and a long chain-of-thought would otherwise
3890
3961
  // bury the answer the user asked for. Collapsed by default, open on
3891
3962
  // demand — the text inside still streams live while it is open.
3892
- const reasoning = typeof card.reasoning === 'string' && card.reasoning !== ''
3963
+ const reasoningText = typeof card.reasoning === 'string' ? card.reasoning : ''
3964
+ // Memoised like the answer below: without this every 50 ms delta of the
3965
+ // ANSWER re-parsed the whole reasoning stream as well, and reasoning is
3966
+ // usually the longer of the two.
3967
+ const reasoningNodes = React.useMemo(() => renderRichText(reasoningText), [reasoningText])
3968
+ const reasoning = reasoningText !== ''
3893
3969
  ? h('details', { className: 'dsa-details' },
3894
3970
  h('summary', null, t('reasoningTitle')),
3895
- h('div', { className: 'dsa-reasoning' }, renderRichText(card.reasoning)))
3971
+ h('div', { className: 'dsa-reasoning' }, reasoningNodes))
3896
3972
  : null
3897
3973
  // P2-4: the `<followups>` block is machine-readable scaffolding, never
3898
3974
  // prose. While streaming it is hidden from the first unclosed tag (a
3899
3975
  // half-written marker must not flash); once terminal the whole block is
3900
3976
  // stripped and only the answer is rendered.
3901
- const bodyText = card.status === 'streaming'
3977
+ // P6: parseFollowUps/streamVisibleText run over the whole answer on every
3978
+ // 50ms flush; memoize by the text so an unrelated store update does not
3979
+ // re-parse a long answer.
3980
+ const bodyText = React.useMemo(() => card.status === 'streaming'
3902
3981
  ? streamVisibleText(card.text)
3903
- : parseFollowUps(card.text).text
3982
+ : parseFollowUps(card.text).text, [card.text, card.status])
3983
+ const answerNodes = React.useMemo(() => renderRichText(bodyText), [bodyText])
3904
3984
  // Follow the stream only while the reader sits at the bottom; scrolling
3905
3985
  // up to reread an earlier paragraph must win over the auto-scroll. The
3906
3986
  // 48px slack keeps single-line deltas from losing the tail.
@@ -3930,7 +4010,7 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
3930
4010
  return h('div', null, reasoning, h('div', { className: 'dsa-muted' }, t('answerEmpty')))
3931
4011
  }
3932
4012
  return h('div', { className: 'dsa-answer', ref: containerRef },
3933
- reasoning, renderRichText(bodyText))
4013
+ reasoning, answerNodes)
3934
4014
  }
3935
4015
 
3936
4016
  /** Card footer: status, copy, follow-up, retry/edit-retry, close. */
@@ -5398,6 +5478,31 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
5398
5478
  const PERSIST_LEGACY_KEY = 'dsh-sidecard-ask:cards:v1'
5399
5479
  const PERSIST_FALLBACK_CARDS = 10
5400
5480
  const PERSIST_TEXT_LIMIT = 65536
5481
+ /**
5482
+ * Total character budget for the whole v3 payload (every card, every
5483
+ * field), in UTF-16 code units — what the localStorage quota counts.
5484
+ *
5485
+ * The per-field cap alone allows ~0.9 MB for ONE card (four 64k fields
5486
+ * plus five 64k×2 versions), so the default 10-card history could reach
5487
+ * ~9 MB and blow the usual 5 MB quota on every write, which then fell
5488
+ * into the drop-one-and-retry loop below. 2M units stays under the
5489
+ * quota for mixed CJK/ASCII content and still holds ~20 long answers.
5490
+ */
5491
+ const PERSIST_TOTAL_CHARS = 2_000_000
5492
+
5493
+ /** Size of one history entry, in UTF-16 code units. */
5494
+ const entryChars = (entry) => {
5495
+ let total = 0
5496
+ for (const key of ['question', 'selected', 'selection', 'text', 'reasoning']) {
5497
+ total += String(entry?.[key] ?? '').length
5498
+ }
5499
+ for (const version of Array.isArray(entry?.versions) ? entry.versions : []) {
5500
+ total += String(version?.question ?? '').length
5501
+ + String(version?.text ?? '').length
5502
+ + String(version?.reasoning ?? '').length
5503
+ }
5504
+ return total
5505
+ }
5401
5506
 
5402
5507
  /** Configured durable cap, clamped to the Host's 0–50 range. */
5403
5508
  const historyCap = () => {
@@ -5441,12 +5546,36 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
5441
5546
  }
5442
5547
 
5443
5548
  function writePersisted(cards) {
5444
- try {
5445
- window.localStorage?.setItem(PERSIST_KEY, JSON.stringify({ version: 3, cards }))
5446
- } catch (error) {
5447
- // Quota or privacy mode — the cards stay on screen, just not durable.
5448
- console.warn(`[${PLUGIN_ID}] card history not persisted:`, error)
5549
+ let remaining = Array.isArray(cards) ? [...cards] : []
5550
+ // Budget the WHOLE payload first (see PERSIST_TOTAL_CHARS). Trimming
5551
+ // here keeps the common case on the first successful write instead of
5552
+ // walking into the quota branch at all.
5553
+ let total = remaining.reduce((sum, entry) => sum + entryChars(entry), 0)
5554
+ while (remaining.length > 0 && total > PERSIST_TOTAL_CHARS) {
5555
+ total -= entryChars(remaining[remaining.length - 1])
5556
+ remaining.pop()
5557
+ }
5558
+ // Newest first (cards are prepended); on quota, drop the OLDEST (tail)
5559
+ // and retry instead of abandoning every card. An EMPTY array must still
5560
+ // be written: deleting the last card clears the durable set on disk.
5561
+ // Returns what actually landed so the manager mirrors the durable set.
5562
+ let lastError = null
5563
+ for (;;) {
5564
+ try {
5565
+ window.localStorage?.setItem(PERSIST_KEY, JSON.stringify({ version: 3, cards: remaining }))
5566
+ return remaining
5567
+ } catch (error) {
5568
+ lastError = error
5569
+ if (remaining.length === 0) break
5570
+ // Drop HALF, not one: every retry re-serialises the whole payload,
5571
+ // so the old one-at-a-time fallback was O(n²) stringify work on the
5572
+ // main thread — exactly while the UI is finishing a card.
5573
+ remaining = remaining.slice(0, Math.floor(remaining.length / 2))
5574
+ }
5449
5575
  }
5576
+ // Quota or privacy mode — the cards stay on screen, just not durable.
5577
+ if (lastError !== null) console.warn(`[${PLUGIN_ID}] card history not persisted:`, lastError)
5578
+ return []
5450
5579
  }
5451
5580
 
5452
5581
  /** Recompute the durable set from the live store and write it. */
@@ -5496,8 +5625,8 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
5496
5625
  store.set({ history: [] })
5497
5626
  return
5498
5627
  }
5499
- writePersisted(finished)
5500
- store.set({ history: finished })
5628
+ const persisted = writePersisted(finished)
5629
+ store.set({ history: persisted })
5501
5630
  }
5502
5631
 
5503
5632
  /** Replay finished cards from a previous page load into the store. */
@@ -5921,7 +6050,10 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
5921
6050
  return result
5922
6051
  },
5923
6052
  followUp(card, text) {
5924
- const history = [...card.history, { question: card.question, answer: card.text }]
6053
+ const history = [...card.history, {
6054
+ question: String(card.question ?? '').slice(0, HISTORY_TURN_Q_CHARS),
6055
+ answer: String(card.text ?? '').slice(0, HISTORY_TURN_A_CHARS),
6056
+ }].slice(-HISTORY_TURNS)
5925
6057
  const next = patchCard(card.id, {
5926
6058
  question: text,
5927
6059
  history,
@@ -6162,7 +6294,10 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
6162
6294
  zone: card.zone,
6163
6295
  carrier: 'side',
6164
6296
  sessionId: currentSessionId(ctx),
6165
- history: card.history.slice(-HISTORY_TURNS),
6297
+ history: card.history.slice(-HISTORY_TURNS).map(turn => ({
6298
+ question: String(turn?.question ?? '').slice(0, HISTORY_TURN_Q_CHARS),
6299
+ answer: String(turn?.answer ?? '').slice(0, HISTORY_TURN_A_CHARS),
6300
+ })),
6166
6301
  }, {
6167
6302
  signal: controller.signal,
6168
6303
  onStart: (data) => {
@@ -6805,12 +6940,33 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
6805
6940
  scheduleReport(0)
6806
6941
  }
6807
6942
  const legacyTimers = [300, 1200, 3000].map(delay => setTimeout(detectLegacy, delay))
6943
+ // Coalesce the observer: `detectLegacy` runs a whole-document
6944
+ // `querySelectorAll`, and a streaming answer mutates the DOM dozens of
6945
+ // times per second — unthrottled, every one of those mutations became a
6946
+ // full-tree scan for the first six seconds of the page's life. One
6947
+ // trailing scan per 250 ms is plenty for a layer that only has to be
6948
+ // noticed, never measured.
6949
+ let legacyScanTimer = null
6950
+ const queueLegacyScan = () => {
6951
+ if (legacyScanTimer !== null) return
6952
+ legacyScanTimer = setTimeout(() => {
6953
+ legacyScanTimer = null
6954
+ detectLegacy()
6955
+ }, 250)
6956
+ }
6808
6957
  if (typeof document !== 'undefined' && typeof MutationObserver === 'function') {
6809
- legacyObserver = new MutationObserver(() => detectLegacy())
6958
+ legacyObserver = new MutationObserver(queueLegacyScan)
6810
6959
  try {
6811
6960
  legacyObserver.observe(document.documentElement ?? document, { childList: true, subtree: true })
6812
6961
  } catch { /* document without a root yet — timers still cover it */ }
6813
6962
  }
6963
+ // After the boot probe window closes with no legacy, stop the
6964
+ // document-wide observer: left attached it would re-scan the whole tree
6965
+ // on every DOM mutation for the page's lifetime.
6966
+ const legacyStopTimer = setTimeout(() => {
6967
+ try { legacyObserver?.disconnect() } catch { /* already gone */ }
6968
+ legacyObserver = null
6969
+ }, 6000)
6814
6970
 
6815
6971
  reelect()
6816
6972
  void refreshState().catch(() => { /* the settings page shows the failure */ })
@@ -6831,6 +6987,8 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
6831
6987
  if (toastTimer !== null) clearTimeout(toastTimer)
6832
6988
  if (reportTimer !== null) clearTimeout(reportTimer)
6833
6989
  for (const timer of legacyTimers) clearTimeout(timer)
6990
+ clearTimeout(legacyStopTimer)
6991
+ if (legacyScanTimer !== null) clearTimeout(legacyScanTimer)
6834
6992
  try {
6835
6993
  if (typeof document !== 'undefined' && document.getElementById) {
6836
6994
  document.getElementById('dsa-family-legacy-style')?.remove()