dsh-sidecard-ask 2.0.1 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +167 -0
- package/README.md +62 -16
- package/client.js +505 -103
- package/index.js +457 -68
- package/package.json +69 -66
package/client.js
CHANGED
|
@@ -35,7 +35,77 @@ window.__ModuleLoader__.load({
|
|
|
35
35
|
// ─────────────────────────────────────────────────────────────────────
|
|
36
36
|
|
|
37
37
|
const PLUGIN_ID = 'dsh-sidecard-ask'
|
|
38
|
-
|
|
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
|
+
})()
|
|
65
|
+
|
|
66
|
+
/** Root-absolute base — the retry target when the prefix guess misses. */
|
|
67
|
+
const API_ROOT = '/sidecard-ask/api'
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The base actually used from now on.
|
|
71
|
+
*
|
|
72
|
+
* `new URL('.', base)` treats the last path segment as a FILE name, so a
|
|
73
|
+
* deployment whose public URL has no trailing slash (`https://host/dsh`)
|
|
74
|
+
* resolves to the document root and loses the prefix. There is no way to
|
|
75
|
+
* tell `/dsh` (directory) from `/dsh` (file) without asking the server, so
|
|
76
|
+
* the first `404` retries once against the root-absolute base: 404 is the
|
|
77
|
+
* only unambiguous signal here — auth answers `401`, a foreign origin
|
|
78
|
+
* `403`, and `404` means the route is not mounted at this path at all.
|
|
79
|
+
*/
|
|
80
|
+
let apiBase = API
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The base to use after one response.
|
|
84
|
+
* @param {number} status - HTTP status of the attempt.
|
|
85
|
+
* @param {string} current - the base that produced it.
|
|
86
|
+
* @returns {string} the base for the next attempt (unchanged unless 404).
|
|
87
|
+
*/
|
|
88
|
+
function apiBaseAfter(status, current) {
|
|
89
|
+
return status === 404 && current !== API_ROOT ? API_ROOT : current
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* `fetch` against the plugin API, retrying once on a 404 with the other
|
|
94
|
+
* base. A 404 body is never consumed here: the caller's own error path
|
|
95
|
+
* reads it (or ignores it), exactly as before.
|
|
96
|
+
* @param {string} path - `/state` or `/ask`, appended to the base.
|
|
97
|
+
* @param {RequestInit} [init] - fetch options (body must be replayable).
|
|
98
|
+
* @returns {Promise<Response>} the response of whichever attempt answered.
|
|
99
|
+
*/
|
|
100
|
+
async function fetchApi(path, init) {
|
|
101
|
+
const attempted = apiBase
|
|
102
|
+
let response = await fetch(`${attempted}${path}`, init)
|
|
103
|
+
if (response.status === 404 && attempted !== API_ROOT) {
|
|
104
|
+
apiBase = apiBaseAfter(response.status, attempted)
|
|
105
|
+
response = await fetch(`${apiBase}${path}`, init)
|
|
106
|
+
}
|
|
107
|
+
return response
|
|
108
|
+
}
|
|
39
109
|
/** Slot ids contributed by this plugin (also the overlay entry ids). */
|
|
40
110
|
const IDS = {
|
|
41
111
|
overlay: 'sidecard-ask',
|
|
@@ -47,7 +117,7 @@ window.__ModuleLoader__.load({
|
|
|
47
117
|
* the Host and the module's `api.version` both read it, so a report can
|
|
48
118
|
* never claim a generation the browser is not actually running.
|
|
49
119
|
*/
|
|
50
|
-
const CLIENT_VERSION = '2.0
|
|
120
|
+
const CLIENT_VERSION = '2.1.0'
|
|
51
121
|
|
|
52
122
|
/**
|
|
53
123
|
* Tab kind served by the DSH native right rail (also its implementation id).
|
|
@@ -77,6 +147,41 @@ window.__ModuleLoader__.load({
|
|
|
77
147
|
*/
|
|
78
148
|
const HISTORY_TURNS = 6
|
|
79
149
|
|
|
150
|
+
// Per-turn wire caps mirroring the Host's prompt-side limits: a long earlier
|
|
151
|
+
// answer must neither bloat the request body past the 1 MiB limit nor push
|
|
152
|
+
// the current question out of the child prompt.
|
|
153
|
+
const HISTORY_TURN_Q_CHARS = 500
|
|
154
|
+
const HISTORY_TURN_A_CHARS = 4000
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Streaming flush cadence.
|
|
158
|
+
*
|
|
159
|
+
* 50 ms is the cadence that reads as "live"; a 32 k+ answer costs 1.2–2.0 ms
|
|
160
|
+
* of markdown parsing per frame (measured, both Chromium 150 and 155), so
|
|
161
|
+
* past that length the cadence doubles — the eye cannot tell, and the work
|
|
162
|
+
* halves.
|
|
163
|
+
*/
|
|
164
|
+
const STREAM_FLUSH_MS = 50
|
|
165
|
+
const STREAM_FLUSH_SLOW_MS = 100
|
|
166
|
+
const STREAM_SLOW_CHARS = 32_000
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* How many cards may stay mounted at once. Past this the oldest finished
|
|
170
|
+
* card is hidden (not deleted): its body unmounts, its data stays, and the
|
|
171
|
+
* durable history still restores it.
|
|
172
|
+
*/
|
|
173
|
+
const MAX_LIVE_CARDS = 20
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* How many characters of history a page load may re-render unfolded.
|
|
177
|
+
*
|
|
178
|
+
* Replay used to restore every saved card `open:true`, bounded only by the
|
|
179
|
+
* card count (default 10) and a 64 k per-field limit — a worst case of
|
|
180
|
+
* ~640 k characters of markdown parsed and mounted before the page is
|
|
181
|
+
* usable. Past this budget the older cards come back collapsed.
|
|
182
|
+
*/
|
|
183
|
+
const REPLAY_OPEN_BUDGET_CHARS = 200_000
|
|
184
|
+
|
|
80
185
|
/**
|
|
81
186
|
* Client-side mirror of the Host `DEFAULT_CONFIG`. The client needs values
|
|
82
187
|
* before `/state` answers (first paint) and when the Host is unreachable;
|
|
@@ -508,6 +613,8 @@ window.__ModuleLoader__.load({
|
|
|
508
613
|
errorNoParentBody: '当前没有活动的会话代理,无法发起独立作答',
|
|
509
614
|
errorAborted: '作答被取消或超时',
|
|
510
615
|
errorUnreachableBody: '宿主接口不可达',
|
|
616
|
+
errorUnauthorizedBody: '需要先通过 DSH 登录(宿主会话未认证)',
|
|
617
|
+
errorForbiddenBody: '请求来源不受信任(Host / Origin 不匹配)',
|
|
511
618
|
errorBadResponseBody: '宿主返回了无法解析的响应',
|
|
512
619
|
errorStreamBrokenBody: '流式连接中断',
|
|
513
620
|
errorStreamEndedBody: '流式连接意外结束',
|
|
@@ -776,6 +883,8 @@ window.__ModuleLoader__.load({
|
|
|
776
883
|
errorNoParentBody: 'No active session agent to answer independently',
|
|
777
884
|
errorAborted: 'The answer was cancelled or timed out',
|
|
778
885
|
errorUnreachableBody: 'The host API is unreachable',
|
|
886
|
+
errorUnauthorizedBody: 'Sign in to DSH first (the host session is not authenticated)',
|
|
887
|
+
errorForbiddenBody: 'Untrusted request origin (Host / Origin mismatch)',
|
|
779
888
|
errorBadResponseBody: 'The host returned an unreadable response',
|
|
780
889
|
errorStreamBrokenBody: 'The stream connection broke',
|
|
781
890
|
errorStreamEndedBody: 'The stream ended without a terminal event',
|
|
@@ -1049,6 +1158,11 @@ window.__ModuleLoader__.load({
|
|
|
1049
1158
|
'no-parent': { key: 'errorNoParentBody' },
|
|
1050
1159
|
aborted: { key: 'errorAborted' },
|
|
1051
1160
|
unreachable: { key: 'errorUnreachableBody' },
|
|
1161
|
+
// The Host now gates these routes on the DSH browser session, so both are
|
|
1162
|
+
// fully known Client-side: "log in" and "untrusted origin" are actionable
|
|
1163
|
+
// in a way a generic unreachable message is not.
|
|
1164
|
+
unauthorized: { key: 'errorUnauthorizedBody' },
|
|
1165
|
+
forbidden: { key: 'errorForbiddenBody' },
|
|
1052
1166
|
'bad-response': { key: 'errorBadResponseBody' },
|
|
1053
1167
|
'stream-broken': { key: 'errorStreamBrokenBody' },
|
|
1054
1168
|
'stream-ended': { key: 'errorStreamEndedBody' },
|
|
@@ -1080,7 +1194,7 @@ window.__ModuleLoader__.load({
|
|
|
1080
1194
|
async function postJson(method, payload) {
|
|
1081
1195
|
let response
|
|
1082
1196
|
try {
|
|
1083
|
-
response = await
|
|
1197
|
+
response = await fetchApi(`/${method}`, {
|
|
1084
1198
|
method: 'POST',
|
|
1085
1199
|
headers: { 'content-type': 'application/json' },
|
|
1086
1200
|
body: JSON.stringify(payload ?? {}),
|
|
@@ -1102,10 +1216,24 @@ window.__ModuleLoader__.load({
|
|
|
1102
1216
|
|
|
1103
1217
|
/** GET the plugin state (`/state` also answers POST, GET keeps it cacheable-free). */
|
|
1104
1218
|
async function getState() {
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1219
|
+
let response
|
|
1220
|
+
try {
|
|
1221
|
+
response = await fetchApi('/state', { method: 'GET' })
|
|
1222
|
+
} catch (error) {
|
|
1223
|
+
throw Object.assign(new Error(String(error?.message ?? error)), { code: 'unreachable' })
|
|
1224
|
+
}
|
|
1225
|
+
let body
|
|
1226
|
+
try {
|
|
1227
|
+
body = await response.json()
|
|
1228
|
+
} catch {
|
|
1229
|
+
throw Object.assign(new Error(`HTTP ${response.status}`), { code: 'bad-response' })
|
|
1230
|
+
}
|
|
1231
|
+
// Keep the Host's own code: a 401 ("log in first") and a 403 ("untrusted
|
|
1232
|
+
// origin") are actionable in a way that a generic bad-response is not.
|
|
1233
|
+
if (body?.ok === true) return body.value
|
|
1234
|
+
const failure = new Error(body?.error?.message ?? `HTTP ${response.status}`)
|
|
1235
|
+
failure.code = body?.error?.code ?? 'bad-response'
|
|
1236
|
+
throw failure
|
|
1109
1237
|
}
|
|
1110
1238
|
|
|
1111
1239
|
/**
|
|
@@ -1118,7 +1246,7 @@ window.__ModuleLoader__.load({
|
|
|
1118
1246
|
async function streamAsk(payload, handlers) {
|
|
1119
1247
|
let response
|
|
1120
1248
|
try {
|
|
1121
|
-
response = await
|
|
1249
|
+
response = await fetchApi('/ask', {
|
|
1122
1250
|
method: 'POST',
|
|
1123
1251
|
headers: { 'content-type': 'application/json', accept: 'text/event-stream' },
|
|
1124
1252
|
body: JSON.stringify(payload),
|
|
@@ -1130,7 +1258,18 @@ window.__ModuleLoader__.load({
|
|
|
1130
1258
|
return
|
|
1131
1259
|
}
|
|
1132
1260
|
if (!response.ok || response.body === null) {
|
|
1133
|
-
|
|
1261
|
+
// Non-200 answers are JSON errors ({ok:false,error:{code,message}}),
|
|
1262
|
+
// not SSE; surface the server's localized code instead of a bare
|
|
1263
|
+
// "HTTP 400".
|
|
1264
|
+
let wire = null
|
|
1265
|
+
try {
|
|
1266
|
+
const rawBody = await response.text()
|
|
1267
|
+
const parsed = rawBody ? JSON.parse(rawBody) : null
|
|
1268
|
+
if (parsed?.error !== null && typeof parsed?.error === 'object') wire = parsed.error
|
|
1269
|
+
} catch { wire = null }
|
|
1270
|
+
handlers.onError(wire && typeof wire.code === 'string'
|
|
1271
|
+
? wire
|
|
1272
|
+
: { code: `http-${response.status}`, message: `HTTP ${response.status}` })
|
|
1134
1273
|
return
|
|
1135
1274
|
}
|
|
1136
1275
|
const reader = response.body.getReader()
|
|
@@ -1257,6 +1396,57 @@ window.__ModuleLoader__.load({
|
|
|
1257
1396
|
return store.state
|
|
1258
1397
|
}
|
|
1259
1398
|
|
|
1399
|
+
/**
|
|
1400
|
+
* Per-card handler bundle with a STABLE identity.
|
|
1401
|
+
*
|
|
1402
|
+
* `store.set` bumps a revision on every streamed delta, and the floating
|
|
1403
|
+
* layer re-renders on each one. Handing `AnswerCard` a fresh arrow per
|
|
1404
|
+
* render makes every prop compare unequal, so `React.memo` could never bail
|
|
1405
|
+
* out. The bundle is therefore created once per card id and reads the card
|
|
1406
|
+
* through a ref at call time — a closure must never capture a stale card,
|
|
1407
|
+
* or "copy" would copy the text from two deltas ago.
|
|
1408
|
+
* @param {object} actions - the plugin's action table (stable for the
|
|
1409
|
+
* lifetime of `apply`).
|
|
1410
|
+
* @returns {(card: object) => object} handler bundle for one card.
|
|
1411
|
+
*/
|
|
1412
|
+
function useCardHandlers(actions) {
|
|
1413
|
+
const latest = React.useRef(new Map())
|
|
1414
|
+
const cache = React.useRef(new Map())
|
|
1415
|
+
return React.useCallback((card) => {
|
|
1416
|
+
if (card === null || card === undefined) return null
|
|
1417
|
+
latest.current.set(card.id, card)
|
|
1418
|
+
let bundle = cache.current.get(card.id)
|
|
1419
|
+
if (bundle === undefined) {
|
|
1420
|
+
// The maps are keyed by card id and live for the whole session, so a
|
|
1421
|
+
// long session would keep one tiny bundle per card ever shown. Past a
|
|
1422
|
+
// generous ceiling both are dropped: the live cards simply get a
|
|
1423
|
+
// fresh (still stable) bundle and re-render once.
|
|
1424
|
+
if (cache.current.size > 64) {
|
|
1425
|
+
cache.current.clear()
|
|
1426
|
+
latest.current.clear()
|
|
1427
|
+
}
|
|
1428
|
+
const current = () => latest.current.get(card.id) ?? card
|
|
1429
|
+
bundle = {
|
|
1430
|
+
onCopy: element => actions.copy(current(), element),
|
|
1431
|
+
onFollowUp: text => actions.followUp(current(), text),
|
|
1432
|
+
onRetry: () => actions.retry(current()),
|
|
1433
|
+
onRetryEdit: text => actions.retryEdit(current(), text),
|
|
1434
|
+
onCancel: () => actions.cancel(current()),
|
|
1435
|
+
onSendToMain: () => { void actions.sendToMain(current()) },
|
|
1436
|
+
onClose: () => actions.close(current()),
|
|
1437
|
+
onLocate: target => actions.locateCard(target ?? current()),
|
|
1438
|
+
onExport: target => actions.exportCard(target ?? current()),
|
|
1439
|
+
onInsert: target => actions.insertAnswer(target ?? current()),
|
|
1440
|
+
onReplace: target => actions.insertAnswer(target ?? current(), { replace: true }),
|
|
1441
|
+
onViewVersion: index => actions.viewVersion(current(), index),
|
|
1442
|
+
onDelete: () => actions.deleteCard(current()),
|
|
1443
|
+
}
|
|
1444
|
+
cache.current.set(card.id, bundle)
|
|
1445
|
+
}
|
|
1446
|
+
return bundle
|
|
1447
|
+
}, [actions])
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1260
1450
|
// ─────────────────────────────────────────────────────────────────────
|
|
1261
1451
|
// §4.5 family singleton
|
|
1262
1452
|
//
|
|
@@ -1364,20 +1554,63 @@ window.__ModuleLoader__.load({
|
|
|
1364
1554
|
return cards.find(card => card.id === id)
|
|
1365
1555
|
}
|
|
1366
1556
|
|
|
1367
|
-
/**
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1557
|
+
/**
|
|
1558
|
+
* Hide the oldest finished cards once a stack exceeds its cap.
|
|
1559
|
+
*
|
|
1560
|
+
* Pure so the rule can be asserted without a renderer: the newest card and
|
|
1561
|
+
* anything still streaming are never hidden, `open:false` cards are skipped
|
|
1562
|
+
* (already hidden), and the input array is not mutated.
|
|
1563
|
+
* @param {object[]} cards - newest first.
|
|
1564
|
+
* @param {number} [cap] - how many may stay visible.
|
|
1565
|
+
* @param {string|null} [keptId] - id that must survive this pass.
|
|
1566
|
+
* @returns {object[]} the same list, with overflow entries closed.
|
|
1567
|
+
*/
|
|
1568
|
+
function capLiveCards(cards, cap = MAX_LIVE_CARDS, keptId = null) {
|
|
1569
|
+
if (!Array.isArray(cards) || cards.length <= cap) return cards
|
|
1570
|
+
const next = [...cards]
|
|
1571
|
+
let excess = next.length - cap
|
|
1572
|
+
for (let index = next.length - 1; index >= 0 && excess > 0; index -= 1) {
|
|
1573
|
+
const item = next[index]
|
|
1574
|
+
if (item === null || item === undefined) continue
|
|
1575
|
+
if (item.id === keptId || item.open === false) continue
|
|
1576
|
+
if (item.status === 'pending' || item.status === 'streaming') continue
|
|
1577
|
+
next[index] = { ...item, open: false }
|
|
1578
|
+
excess -= 1
|
|
1579
|
+
}
|
|
1580
|
+
return next
|
|
1373
1581
|
}
|
|
1374
1582
|
|
|
1375
|
-
/**
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1583
|
+
/**
|
|
1584
|
+
* Whether replayed card `index` comes back unfolded.
|
|
1585
|
+
*
|
|
1586
|
+
* The newest replay always does — a page load that hides everything would
|
|
1587
|
+
* look like data loss — and the rest only while the running total stays
|
|
1588
|
+
* inside the budget.
|
|
1589
|
+
* @param {number} index - 0 = newest.
|
|
1590
|
+
* @param {number} replayedChars - characters replayed so far, inclusive.
|
|
1591
|
+
* @returns {boolean} true when the card should mount unfolded.
|
|
1592
|
+
*/
|
|
1593
|
+
function replayCardOpen(index, replayedChars) {
|
|
1594
|
+
return index === 0 || replayedChars <= REPLAY_OPEN_BUDGET_CHARS
|
|
1595
|
+
}
|
|
1596
|
+
|
|
1597
|
+
/**
|
|
1598
|
+
* Prepend one card, hiding the oldest FINISHED card once the stack grows
|
|
1599
|
+
* past the live cap.
|
|
1600
|
+
*
|
|
1601
|
+
* Cards used to accumulate for the whole session: nothing capped
|
|
1602
|
+
* `store.state.cards`, so a long session kept every answer mounted (each
|
|
1603
|
+
* one a full React subtree). Past the cap the oldest finished card is
|
|
1604
|
+
* hidden — `open:false` keeps its data, unmounts its body, and the durable
|
|
1605
|
+
* history still offers it back. A card that is still streaming is never
|
|
1606
|
+
* hidden: that would orphan its stream.
|
|
1607
|
+
* @param {object} card - the card to put on top.
|
|
1608
|
+
* @param {object} [extra] - additional store fields to commit in the same
|
|
1609
|
+
* update (the carrier's surface label, for instance).
|
|
1610
|
+
*/
|
|
1611
|
+
function pushCard(card, extra = null) {
|
|
1612
|
+
const cards = capLiveCards([card, ...store.state.cards], MAX_LIVE_CARDS, card.id)
|
|
1613
|
+
store.set(extra === null ? { cards } : { ...extra, cards })
|
|
1381
1614
|
}
|
|
1382
1615
|
|
|
1383
1616
|
/** Show a transient toast line. */
|
|
@@ -1531,14 +1764,25 @@ window.__ModuleLoader__.load({
|
|
|
1531
1764
|
* Truncate an over-long selection for an outgoing prompt, keeping head and
|
|
1532
1765
|
* tail. Mirrors the Host's `truncateSelection` (asserted by contract test):
|
|
1533
1766
|
* code points, not UTF-16 units, so a surrogate pair is never split.
|
|
1767
|
+
*
|
|
1768
|
+
* The omission note is charged to the budget, not added on top of it: an
|
|
1769
|
+
* "inclusive cap" that the note itself overflows by ~19 characters is not a
|
|
1770
|
+
* cap, and the Host sizes its prompt budget from what a fixed prompt
|
|
1771
|
+
* leaves over.
|
|
1534
1772
|
* @returns {{text: string, truncated: boolean, droppedChars: number}}
|
|
1535
1773
|
*/
|
|
1536
1774
|
function truncateSelection(text, maxChars) {
|
|
1537
1775
|
const source = typeof text === 'string' ? text : ''
|
|
1538
1776
|
const points = Array.from(source)
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
const
|
|
1777
|
+
const cap = Number.isFinite(maxChars) ? Math.max(0, Math.floor(maxChars)) : points.length
|
|
1778
|
+
if (points.length <= cap) return { text: source, truncated: false, droppedChars: 0 }
|
|
1779
|
+
const noteLength = Array.from(`\n…(已省略中间 ${points.length} 个字符)…\n`).length
|
|
1780
|
+
const room = cap - noteLength
|
|
1781
|
+
if (room < 2) {
|
|
1782
|
+
return { text: points.slice(0, cap).join(''), truncated: true, droppedChars: points.length - cap }
|
|
1783
|
+
}
|
|
1784
|
+
const head = Math.max(1, Math.ceil(room * 0.7))
|
|
1785
|
+
const tail = Math.max(0, room - head)
|
|
1542
1786
|
const dropped = points.length - head - tail
|
|
1543
1787
|
return {
|
|
1544
1788
|
text: `${points.slice(0, head).join('')}\n…(已省略中间 ${dropped} 个字符)…\n${tail > 0 ? points.slice(points.length - tail).join('') : ''}`,
|
|
@@ -2525,14 +2769,24 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
2525
2769
|
return h('style', { 'data-dsa-style': '' }, CSS_TEXT)
|
|
2526
2770
|
}
|
|
2527
2771
|
|
|
2772
|
+
/**
|
|
2773
|
+
* Inline markdown-lite token scanner.
|
|
2774
|
+
*
|
|
2775
|
+
* Hoisted out of {@link inlineNodes} because a long answer calls it once per
|
|
2776
|
+
* line/paragraph on every render, and a fresh RegExp literal per call was
|
|
2777
|
+
* pure allocation on the streaming hot path. `/g` is stateful, so the
|
|
2778
|
+
* scanner resets `lastIndex` before each use (it never recurses).
|
|
2779
|
+
*/
|
|
2780
|
+
const INLINE_TOKEN_RE = /(`[^`]+`|\*\*[^*]+\*\*|\*[^*\n]+\*|\[[^\]\n]+\]\(https?:\/\/[^\s)]+\))/g
|
|
2781
|
+
|
|
2528
2782
|
/** Inline markdown-lite: `code`, **bold**, *italic*, [label](http url) — never innerHTML. */
|
|
2529
2783
|
function inlineNodes(text, key) {
|
|
2530
2784
|
const out = []
|
|
2531
|
-
|
|
2785
|
+
INLINE_TOKEN_RE.lastIndex = 0
|
|
2532
2786
|
let last = 0
|
|
2533
2787
|
let match
|
|
2534
2788
|
let index = 0
|
|
2535
|
-
while ((match =
|
|
2789
|
+
while ((match = INLINE_TOKEN_RE.exec(text)) !== null) {
|
|
2536
2790
|
if (match.index > last) out.push(text.slice(last, match.index))
|
|
2537
2791
|
const token = match[0]
|
|
2538
2792
|
if (token.startsWith('`')) {
|
|
@@ -3873,18 +4127,27 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
3873
4127
|
// context, not the answer, and a long chain-of-thought would otherwise
|
|
3874
4128
|
// bury the answer the user asked for. Collapsed by default, open on
|
|
3875
4129
|
// demand — the text inside still streams live while it is open.
|
|
3876
|
-
const
|
|
4130
|
+
const reasoningText = typeof card.reasoning === 'string' ? card.reasoning : ''
|
|
4131
|
+
// Memoised like the answer below: without this every 50 ms delta of the
|
|
4132
|
+
// ANSWER re-parsed the whole reasoning stream as well, and reasoning is
|
|
4133
|
+
// usually the longer of the two.
|
|
4134
|
+
const reasoningNodes = React.useMemo(() => renderRichText(reasoningText), [reasoningText])
|
|
4135
|
+
const reasoning = reasoningText !== ''
|
|
3877
4136
|
? h('details', { className: 'dsa-details' },
|
|
3878
4137
|
h('summary', null, t('reasoningTitle')),
|
|
3879
|
-
h('div', { className: 'dsa-reasoning' },
|
|
4138
|
+
h('div', { className: 'dsa-reasoning' }, reasoningNodes))
|
|
3880
4139
|
: null
|
|
3881
4140
|
// P2-4: the `<followups>` block is machine-readable scaffolding, never
|
|
3882
4141
|
// prose. While streaming it is hidden from the first unclosed tag (a
|
|
3883
4142
|
// half-written marker must not flash); once terminal the whole block is
|
|
3884
4143
|
// stripped and only the answer is rendered.
|
|
3885
|
-
|
|
4144
|
+
// P6: parseFollowUps/streamVisibleText run over the whole answer on every
|
|
4145
|
+
// 50ms flush; memoize by the text so an unrelated store update does not
|
|
4146
|
+
// re-parse a long answer.
|
|
4147
|
+
const bodyText = React.useMemo(() => card.status === 'streaming'
|
|
3886
4148
|
? streamVisibleText(card.text)
|
|
3887
|
-
: parseFollowUps(card.text).text
|
|
4149
|
+
: parseFollowUps(card.text).text, [card.text, card.status])
|
|
4150
|
+
const answerNodes = React.useMemo(() => renderRichText(bodyText), [bodyText])
|
|
3888
4151
|
// Follow the stream only while the reader sits at the bottom; scrolling
|
|
3889
4152
|
// up to reread an earlier paragraph must win over the auto-scroll. The
|
|
3890
4153
|
// 48px slack keeps single-line deltas from losing the tail.
|
|
@@ -3914,7 +4177,7 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
3914
4177
|
return h('div', null, reasoning, h('div', { className: 'dsa-muted' }, t('answerEmpty')))
|
|
3915
4178
|
}
|
|
3916
4179
|
return h('div', { className: 'dsa-answer', ref: containerRef },
|
|
3917
|
-
reasoning,
|
|
4180
|
+
reasoning, answerNodes)
|
|
3918
4181
|
}
|
|
3919
4182
|
|
|
3920
4183
|
/** Card footer: status, copy, follow-up, retry/edit-retry, close. */
|
|
@@ -4335,6 +4598,19 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
4335
4598
|
: null)
|
|
4336
4599
|
}
|
|
4337
4600
|
|
|
4601
|
+
/**
|
|
4602
|
+
* Memoised {@link AnswerCard}.
|
|
4603
|
+
*
|
|
4604
|
+
* Only the card that actually changed gets a new `card` object
|
|
4605
|
+
* (`patchCard` / the stream flush keep every other card by reference), and
|
|
4606
|
+
* the handler bundle from {@link useCardHandlers} is stable per id — so a
|
|
4607
|
+
* delta on card A no longer re-renders cards B..N. `React.memo` is probed
|
|
4608
|
+
* because the test double (and any trimmed-down React a host might hand a
|
|
4609
|
+
* plugin) does not have to implement it; without it this is the same
|
|
4610
|
+
* component, just un-memoised.
|
|
4611
|
+
*/
|
|
4612
|
+
const MemoAnswerCard = typeof React.memo === 'function' ? React.memo(AnswerCard) : AnswerCard
|
|
4613
|
+
|
|
4338
4614
|
/**
|
|
4339
4615
|
* The flow surface, i.e. the floating layer (A).
|
|
4340
4616
|
*
|
|
@@ -4354,6 +4630,7 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
4354
4630
|
const containerRef = React.useRef(null)
|
|
4355
4631
|
const dockRef = React.useRef(null)
|
|
4356
4632
|
const state = useStoreState()
|
|
4633
|
+
const handlersFor = useCardHandlers(actions)
|
|
4357
4634
|
const [dock, setDock] = React.useState(null)
|
|
4358
4635
|
const visible = cards.filter(card => card.surface === 'flow' && card.open)
|
|
4359
4636
|
const total = visible.length
|
|
@@ -4467,16 +4744,20 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
4467
4744
|
}
|
|
4468
4745
|
if (typeof window.addEventListener === 'function') window.addEventListener('resize', schedule)
|
|
4469
4746
|
// Opening/closing the rail changes no size this effect already watches,
|
|
4470
|
-
// so the DOM itself has to be the trigger.
|
|
4471
|
-
//
|
|
4472
|
-
//
|
|
4747
|
+
// so the DOM itself has to be the trigger. The two data attributes are
|
|
4748
|
+
// the only signals that MEAN "the rail geometry changed". `class` and
|
|
4749
|
+
// `style` used to be in this list too, and a chat pane rewrites both
|
|
4750
|
+
// constantly (hover, scroll state, streaming badges) — every one of
|
|
4751
|
+
// those woke this observer and forced a layout read on the next frame.
|
|
4752
|
+
// Streaming tokens rewrite text nodes, which `childList`-free observing
|
|
4753
|
+
// already ignores.
|
|
4473
4754
|
if (typeof window.MutationObserver === 'function' && document.body !== null && document.body !== undefined) {
|
|
4474
4755
|
try {
|
|
4475
4756
|
mutation = new window.MutationObserver(schedule)
|
|
4476
4757
|
mutation.observe(document.body, {
|
|
4477
4758
|
attributes: true,
|
|
4478
4759
|
subtree: true,
|
|
4479
|
-
attributeFilter: ['data-sidebar-right-open', 'data-dockkit-pane-active'
|
|
4760
|
+
attributeFilter: ['data-sidebar-right-open', 'data-dockkit-pane-active'],
|
|
4480
4761
|
})
|
|
4481
4762
|
} catch { mutation = null }
|
|
4482
4763
|
}
|
|
@@ -4529,24 +4810,14 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
4529
4810
|
: null),
|
|
4530
4811
|
}, children)
|
|
4531
4812
|
|
|
4532
|
-
const cardNodes = visible.map(card => h(
|
|
4813
|
+
const cardNodes = visible.map(card => h(MemoAnswerCard, {
|
|
4533
4814
|
key: card.id,
|
|
4534
4815
|
card,
|
|
4535
4816
|
surfaceLabel: null,
|
|
4536
|
-
onCopy: element => actions.copy(card, element),
|
|
4537
|
-
onFollowUp: text => actions.followUp(card, text),
|
|
4538
|
-
onRetry: () => actions.retry(card),
|
|
4539
|
-
onRetryEdit: text => actions.retryEdit(card, text),
|
|
4540
|
-
onCancel: () => actions.cancel(card),
|
|
4541
|
-
onSendToMain: () => actions.sendToMain(card),
|
|
4542
|
-
onClose: () => actions.close(card),
|
|
4543
|
-
onLocate: target => actions.locateCard(target ?? card),
|
|
4544
|
-
onExport: target => actions.exportCard(target ?? card),
|
|
4545
|
-
onInsert: target => actions.insertAnswer(target ?? card),
|
|
4546
|
-
onReplace: target => actions.insertAnswer(target ?? card, { replace: true }),
|
|
4547
|
-
onViewVersion: index => actions.viewVersion(card, index),
|
|
4548
4817
|
onFloatMode: setFloatMode,
|
|
4549
|
-
|
|
4818
|
+
// Stable per card id (see useCardHandlers), which is what lets
|
|
4819
|
+
// React.memo skip every card that did not change in this tick.
|
|
4820
|
+
...handlersFor(card),
|
|
4550
4821
|
}))
|
|
4551
4822
|
|
|
4552
4823
|
if (mode === 'capsule') {
|
|
@@ -5382,6 +5653,31 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
5382
5653
|
const PERSIST_LEGACY_KEY = 'dsh-sidecard-ask:cards:v1'
|
|
5383
5654
|
const PERSIST_FALLBACK_CARDS = 10
|
|
5384
5655
|
const PERSIST_TEXT_LIMIT = 65536
|
|
5656
|
+
/**
|
|
5657
|
+
* Total character budget for the whole v3 payload (every card, every
|
|
5658
|
+
* field), in UTF-16 code units — what the localStorage quota counts.
|
|
5659
|
+
*
|
|
5660
|
+
* The per-field cap alone allows ~0.9 MB for ONE card (four 64k fields
|
|
5661
|
+
* plus five 64k×2 versions), so the default 10-card history could reach
|
|
5662
|
+
* ~9 MB and blow the usual 5 MB quota on every write, which then fell
|
|
5663
|
+
* into the drop-one-and-retry loop below. 2M units stays under the
|
|
5664
|
+
* quota for mixed CJK/ASCII content and still holds ~20 long answers.
|
|
5665
|
+
*/
|
|
5666
|
+
const PERSIST_TOTAL_CHARS = 2_000_000
|
|
5667
|
+
|
|
5668
|
+
/** Size of one history entry, in UTF-16 code units. */
|
|
5669
|
+
const entryChars = (entry) => {
|
|
5670
|
+
let total = 0
|
|
5671
|
+
for (const key of ['question', 'selected', 'selection', 'text', 'reasoning']) {
|
|
5672
|
+
total += String(entry?.[key] ?? '').length
|
|
5673
|
+
}
|
|
5674
|
+
for (const version of Array.isArray(entry?.versions) ? entry.versions : []) {
|
|
5675
|
+
total += String(version?.question ?? '').length
|
|
5676
|
+
+ String(version?.text ?? '').length
|
|
5677
|
+
+ String(version?.reasoning ?? '').length
|
|
5678
|
+
}
|
|
5679
|
+
return total
|
|
5680
|
+
}
|
|
5385
5681
|
|
|
5386
5682
|
/** Configured durable cap, clamped to the Host's 0–50 range. */
|
|
5387
5683
|
const historyCap = () => {
|
|
@@ -5425,12 +5721,36 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
5425
5721
|
}
|
|
5426
5722
|
|
|
5427
5723
|
function writePersisted(cards) {
|
|
5428
|
-
|
|
5429
|
-
|
|
5430
|
-
|
|
5431
|
-
|
|
5432
|
-
|
|
5724
|
+
let remaining = Array.isArray(cards) ? [...cards] : []
|
|
5725
|
+
// Budget the WHOLE payload first (see PERSIST_TOTAL_CHARS). Trimming
|
|
5726
|
+
// here keeps the common case on the first successful write instead of
|
|
5727
|
+
// walking into the quota branch at all.
|
|
5728
|
+
let total = remaining.reduce((sum, entry) => sum + entryChars(entry), 0)
|
|
5729
|
+
while (remaining.length > 0 && total > PERSIST_TOTAL_CHARS) {
|
|
5730
|
+
total -= entryChars(remaining[remaining.length - 1])
|
|
5731
|
+
remaining.pop()
|
|
5732
|
+
}
|
|
5733
|
+
// Newest first (cards are prepended); on quota, drop the OLDEST (tail)
|
|
5734
|
+
// and retry instead of abandoning every card. An EMPTY array must still
|
|
5735
|
+
// be written: deleting the last card clears the durable set on disk.
|
|
5736
|
+
// Returns what actually landed so the manager mirrors the durable set.
|
|
5737
|
+
let lastError = null
|
|
5738
|
+
for (;;) {
|
|
5739
|
+
try {
|
|
5740
|
+
window.localStorage?.setItem(PERSIST_KEY, JSON.stringify({ version: 3, cards: remaining }))
|
|
5741
|
+
return remaining
|
|
5742
|
+
} catch (error) {
|
|
5743
|
+
lastError = error
|
|
5744
|
+
if (remaining.length === 0) break
|
|
5745
|
+
// Drop HALF, not one: every retry re-serialises the whole payload,
|
|
5746
|
+
// so the old one-at-a-time fallback was O(n²) stringify work on the
|
|
5747
|
+
// main thread — exactly while the UI is finishing a card.
|
|
5748
|
+
remaining = remaining.slice(0, Math.floor(remaining.length / 2))
|
|
5749
|
+
}
|
|
5433
5750
|
}
|
|
5751
|
+
// Quota or privacy mode — the cards stay on screen, just not durable.
|
|
5752
|
+
if (lastError !== null) console.warn(`[${PLUGIN_ID}] card history not persisted:`, lastError)
|
|
5753
|
+
return []
|
|
5434
5754
|
}
|
|
5435
5755
|
|
|
5436
5756
|
/** Recompute the durable set from the live store and write it. */
|
|
@@ -5480,8 +5800,8 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
5480
5800
|
store.set({ history: [] })
|
|
5481
5801
|
return
|
|
5482
5802
|
}
|
|
5483
|
-
writePersisted(finished)
|
|
5484
|
-
store.set({ history:
|
|
5803
|
+
const persisted = writePersisted(finished)
|
|
5804
|
+
store.set({ history: persisted })
|
|
5485
5805
|
}
|
|
5486
5806
|
|
|
5487
5807
|
/** Replay finished cards from a previous page load into the store. */
|
|
@@ -5501,37 +5821,47 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
5501
5821
|
if (cap > 0 && (currentRaw === null || currentRaw === undefined)) {
|
|
5502
5822
|
writePersisted(saved)
|
|
5503
5823
|
}
|
|
5504
|
-
|
|
5505
|
-
|
|
5506
|
-
|
|
5507
|
-
|
|
5508
|
-
|
|
5509
|
-
|
|
5510
|
-
|
|
5511
|
-
|
|
5512
|
-
|
|
5513
|
-
|
|
5514
|
-
|
|
5515
|
-
|
|
5516
|
-
|
|
5517
|
-
|
|
5518
|
-
|
|
5519
|
-
|
|
5520
|
-
|
|
5521
|
-
|
|
5522
|
-
|
|
5523
|
-
|
|
5524
|
-
|
|
5525
|
-
|
|
5526
|
-
|
|
5527
|
-
|
|
5528
|
-
|
|
5529
|
-
|
|
5530
|
-
|
|
5531
|
-
|
|
5532
|
-
|
|
5533
|
-
:
|
|
5534
|
-
|
|
5824
|
+
// Replay budget: the newest card always comes back unfolded, and the
|
|
5825
|
+
// rest stay unfolded only while the running total of replayed
|
|
5826
|
+
// characters stays under the budget. A page load used to mount up to
|
|
5827
|
+
// 10 × 64 k characters of markdown before it was usable; past the
|
|
5828
|
+
// budget the older cards come back collapsed and are parsed when the
|
|
5829
|
+
// user opens them.
|
|
5830
|
+
let replayedChars = 0
|
|
5831
|
+
const replayed = saved.map((entry, index) => {
|
|
5832
|
+
replayedChars += String(entry.text ?? '').length + String(entry.reasoning ?? '').length
|
|
5833
|
+
return {
|
|
5834
|
+
id: entry.id,
|
|
5835
|
+
question: entry.question,
|
|
5836
|
+
selected: String(entry.selected ?? ''),
|
|
5837
|
+
selection: String(entry.selection ?? ''),
|
|
5838
|
+
text: String(entry.text ?? ''),
|
|
5839
|
+
reasoning: String(entry.reasoning ?? ''),
|
|
5840
|
+
status: entry.status === 'stopped' ? 'stopped' : 'done',
|
|
5841
|
+
carrier: 'side',
|
|
5842
|
+
zone: entry.zone,
|
|
5843
|
+
truncated: entry.truncated === true,
|
|
5844
|
+
droppedChars: Number(entry.droppedChars ?? 0),
|
|
5845
|
+
streaming: null,
|
|
5846
|
+
error: null,
|
|
5847
|
+
provider: typeof entry.provider === 'string' && entry.provider !== '' ? entry.provider : null,
|
|
5848
|
+
elapsedMs: Number.isFinite(entry.elapsedMs) ? entry.elapsedMs : null,
|
|
5849
|
+
createdAt: Number.isFinite(entry.createdAt) ? entry.createdAt : 0,
|
|
5850
|
+
// P2-5b: replayed versions stay switchable after a reload.
|
|
5851
|
+
versions: replayVersions(entry.versions),
|
|
5852
|
+
viewingVersion: null,
|
|
5853
|
+
actionId: typeof entry.actionId === 'string' && entry.actionId !== '' ? entry.actionId : null,
|
|
5854
|
+
// A native/better tab cannot survive a reload, so history always
|
|
5855
|
+
// comes back on the flow stack where it is visible immediately.
|
|
5856
|
+
surface: 'flow',
|
|
5857
|
+
open: replayCardOpen(index, replayedChars),
|
|
5858
|
+
history: Array.isArray(entry.history)
|
|
5859
|
+
? entry.history
|
|
5860
|
+
.filter(turn => turn !== null && typeof turn === 'object')
|
|
5861
|
+
.map(turn => ({ question: String(turn.question ?? ''), answer: String(turn.answer ?? '') }))
|
|
5862
|
+
: [],
|
|
5863
|
+
}
|
|
5864
|
+
})
|
|
5535
5865
|
store.set({ cards: [...replayed, ...store.state.cards] })
|
|
5536
5866
|
}
|
|
5537
5867
|
|
|
@@ -5605,7 +5935,7 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
5605
5935
|
if (store.state.cards.some(card => card.id === id)) {
|
|
5606
5936
|
patchCard(id, { surface: 'flow', open: true })
|
|
5607
5937
|
} else {
|
|
5608
|
-
|
|
5938
|
+
pushCard(historyEntryToCard(entry))
|
|
5609
5939
|
}
|
|
5610
5940
|
// A collapsed (capsule) layer would swallow the restored card;
|
|
5611
5941
|
// this is a session-only override, exactly like clicking expand.
|
|
@@ -5734,7 +6064,7 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
5734
6064
|
actionId: payload?.actionId ?? null,
|
|
5735
6065
|
}
|
|
5736
6066
|
if (payload.carrier === 'main') {
|
|
5737
|
-
|
|
6067
|
+
pushCard(card)
|
|
5738
6068
|
try {
|
|
5739
6069
|
const result = await askInMainConversation({
|
|
5740
6070
|
ctx,
|
|
@@ -5760,7 +6090,7 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
5760
6090
|
}
|
|
5761
6091
|
const { adapter, fellBack } = pickSurface(config.sideSurface)
|
|
5762
6092
|
card.surface = adapter.kind
|
|
5763
|
-
|
|
6093
|
+
pushCard(card, { surface: adapter.kind })
|
|
5764
6094
|
if (fellBack) {
|
|
5765
6095
|
store.set({ surfaceNote: t('surfaceFellBack', { name: adapter.label() }) })
|
|
5766
6096
|
toast(t('surfaceUnavailable'))
|
|
@@ -5905,7 +6235,10 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
5905
6235
|
return result
|
|
5906
6236
|
},
|
|
5907
6237
|
followUp(card, text) {
|
|
5908
|
-
const history = [...card.history, {
|
|
6238
|
+
const history = [...card.history, {
|
|
6239
|
+
question: String(card.question ?? '').slice(0, HISTORY_TURN_Q_CHARS),
|
|
6240
|
+
answer: String(card.text ?? '').slice(0, HISTORY_TURN_A_CHARS),
|
|
6241
|
+
}].slice(-HISTORY_TURNS)
|
|
5909
6242
|
const next = patchCard(card.id, {
|
|
5910
6243
|
question: text,
|
|
5911
6244
|
history,
|
|
@@ -6093,31 +6426,65 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
6093
6426
|
const pendingReasonings = new Map()
|
|
6094
6427
|
let deltaFlushTimer = null
|
|
6095
6428
|
|
|
6429
|
+
/**
|
|
6430
|
+
* Commit every buffered chunk for this tick as ONE store update.
|
|
6431
|
+
*
|
|
6432
|
+
* The previous shape published text and reasoning separately, so a
|
|
6433
|
+
* single 50 ms tick could bump the store twice per streaming card —
|
|
6434
|
+
* and every bump re-renders the whole overlay. With the concurrency
|
|
6435
|
+
* cap at 3 that is up to 120 tree renders per second for two string
|
|
6436
|
+
* appends. Cards that saw no chunk keep their identity, so a memoised
|
|
6437
|
+
* card is not touched at all.
|
|
6438
|
+
*/
|
|
6096
6439
|
const flushStreamBuffers = () => {
|
|
6097
6440
|
if (deltaFlushTimer !== null) {
|
|
6098
6441
|
clearTimeout(deltaFlushTimer)
|
|
6099
6442
|
deltaFlushTimer = null
|
|
6100
6443
|
}
|
|
6101
|
-
if (pendingDeltas.size
|
|
6102
|
-
|
|
6103
|
-
pendingDeltas.
|
|
6104
|
-
|
|
6105
|
-
|
|
6106
|
-
|
|
6107
|
-
|
|
6444
|
+
if (pendingDeltas.size === 0 && pendingReasonings.size === 0) return
|
|
6445
|
+
const cards = store.state.cards.map((card) => {
|
|
6446
|
+
const text = pendingDeltas.get(card.id)
|
|
6447
|
+
const reasoning = pendingReasonings.get(card.id)
|
|
6448
|
+
if (text === undefined && reasoning === undefined) return card
|
|
6449
|
+
const next = { ...card }
|
|
6450
|
+
if (text !== undefined) next.text = `${card.text}${text}`
|
|
6451
|
+
if (reasoning !== undefined) next.reasoning = `${card.reasoning}${reasoning}`
|
|
6452
|
+
return next
|
|
6453
|
+
})
|
|
6454
|
+
pendingDeltas.clear()
|
|
6455
|
+
pendingReasonings.clear()
|
|
6456
|
+
store.set({ cards })
|
|
6457
|
+
}
|
|
6458
|
+
|
|
6459
|
+
/**
|
|
6460
|
+
* Milliseconds to wait before the next flush.
|
|
6461
|
+
*
|
|
6462
|
+
* 50 ms is the "feels live" cadence, but the cost of one frame grows
|
|
6463
|
+
* with the answer: `renderRichText` measured 0.2–0.3 ms at 8 k
|
|
6464
|
+
* characters and 1.2–2.0 ms at 64 k. Past 32 k a 100 ms cadence is
|
|
6465
|
+
* indistinguishable to the eye and halves the work.
|
|
6466
|
+
* @returns {number} the delay for the next flush tick.
|
|
6467
|
+
*/
|
|
6468
|
+
const streamFlushDelay = () => {
|
|
6469
|
+
let longest = 0
|
|
6470
|
+
for (const card of store.state.cards) {
|
|
6471
|
+
if (card.status === 'streaming' && typeof card.text === 'string' && card.text.length > longest) {
|
|
6472
|
+
longest = card.text.length
|
|
6473
|
+
}
|
|
6108
6474
|
}
|
|
6475
|
+
return longest > STREAM_SLOW_CHARS ? STREAM_FLUSH_SLOW_MS : STREAM_FLUSH_MS
|
|
6109
6476
|
}
|
|
6110
6477
|
|
|
6111
6478
|
const bufferStreamDelta = (cardId, chunk) => {
|
|
6112
6479
|
if (chunk === '') return
|
|
6113
6480
|
pendingDeltas.set(cardId, (pendingDeltas.get(cardId) ?? '') + chunk)
|
|
6114
|
-
if (deltaFlushTimer === null) deltaFlushTimer = setTimeout(flushStreamBuffers,
|
|
6481
|
+
if (deltaFlushTimer === null) deltaFlushTimer = setTimeout(flushStreamBuffers, streamFlushDelay())
|
|
6115
6482
|
}
|
|
6116
6483
|
|
|
6117
6484
|
const bufferStreamReasoning = (cardId, chunk) => {
|
|
6118
6485
|
if (chunk === '') return
|
|
6119
6486
|
pendingReasonings.set(cardId, (pendingReasonings.get(cardId) ?? '') + chunk)
|
|
6120
|
-
if (deltaFlushTimer === null) deltaFlushTimer = setTimeout(flushStreamBuffers,
|
|
6487
|
+
if (deltaFlushTimer === null) deltaFlushTimer = setTimeout(flushStreamBuffers, streamFlushDelay())
|
|
6121
6488
|
}
|
|
6122
6489
|
|
|
6123
6490
|
/** Stream one side answer into its card. */
|
|
@@ -6146,7 +6513,10 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
6146
6513
|
zone: card.zone,
|
|
6147
6514
|
carrier: 'side',
|
|
6148
6515
|
sessionId: currentSessionId(ctx),
|
|
6149
|
-
history: card.history.slice(-HISTORY_TURNS)
|
|
6516
|
+
history: card.history.slice(-HISTORY_TURNS).map(turn => ({
|
|
6517
|
+
question: String(turn?.question ?? '').slice(0, HISTORY_TURN_Q_CHARS),
|
|
6518
|
+
answer: String(turn?.answer ?? '').slice(0, HISTORY_TURN_A_CHARS),
|
|
6519
|
+
})),
|
|
6150
6520
|
}, {
|
|
6151
6521
|
signal: controller.signal,
|
|
6152
6522
|
onStart: (data) => {
|
|
@@ -6717,7 +7087,7 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
6717
7087
|
}
|
|
6718
7088
|
return h('div', { className: 'dsa-settings', 'data-dsa-root': '', 'data-dsa-rail-host': '', style: { padding: '10px' } },
|
|
6719
7089
|
h(Styles, null),
|
|
6720
|
-
h(
|
|
7090
|
+
h(MemoAnswerCard, {
|
|
6721
7091
|
card,
|
|
6722
7092
|
surfaceLabel: state.surface === 'better-sidebar' ? t('surfaceBetter') : t('surfaceNative'),
|
|
6723
7093
|
onCopy: element => actions.copy(card, element),
|
|
@@ -6789,12 +7159,33 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
6789
7159
|
scheduleReport(0)
|
|
6790
7160
|
}
|
|
6791
7161
|
const legacyTimers = [300, 1200, 3000].map(delay => setTimeout(detectLegacy, delay))
|
|
7162
|
+
// Coalesce the observer: `detectLegacy` runs a whole-document
|
|
7163
|
+
// `querySelectorAll`, and a streaming answer mutates the DOM dozens of
|
|
7164
|
+
// times per second — unthrottled, every one of those mutations became a
|
|
7165
|
+
// full-tree scan for the first six seconds of the page's life. One
|
|
7166
|
+
// trailing scan per 250 ms is plenty for a layer that only has to be
|
|
7167
|
+
// noticed, never measured.
|
|
7168
|
+
let legacyScanTimer = null
|
|
7169
|
+
const queueLegacyScan = () => {
|
|
7170
|
+
if (legacyScanTimer !== null) return
|
|
7171
|
+
legacyScanTimer = setTimeout(() => {
|
|
7172
|
+
legacyScanTimer = null
|
|
7173
|
+
detectLegacy()
|
|
7174
|
+
}, 250)
|
|
7175
|
+
}
|
|
6792
7176
|
if (typeof document !== 'undefined' && typeof MutationObserver === 'function') {
|
|
6793
|
-
legacyObserver = new MutationObserver(
|
|
7177
|
+
legacyObserver = new MutationObserver(queueLegacyScan)
|
|
6794
7178
|
try {
|
|
6795
7179
|
legacyObserver.observe(document.documentElement ?? document, { childList: true, subtree: true })
|
|
6796
7180
|
} catch { /* document without a root yet — timers still cover it */ }
|
|
6797
7181
|
}
|
|
7182
|
+
// After the boot probe window closes with no legacy, stop the
|
|
7183
|
+
// document-wide observer: left attached it would re-scan the whole tree
|
|
7184
|
+
// on every DOM mutation for the page's lifetime.
|
|
7185
|
+
const legacyStopTimer = setTimeout(() => {
|
|
7186
|
+
try { legacyObserver?.disconnect() } catch { /* already gone */ }
|
|
7187
|
+
legacyObserver = null
|
|
7188
|
+
}, 6000)
|
|
6798
7189
|
|
|
6799
7190
|
reelect()
|
|
6800
7191
|
void refreshState().catch(() => { /* the settings page shows the failure */ })
|
|
@@ -6815,6 +7206,8 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
6815
7206
|
if (toastTimer !== null) clearTimeout(toastTimer)
|
|
6816
7207
|
if (reportTimer !== null) clearTimeout(reportTimer)
|
|
6817
7208
|
for (const timer of legacyTimers) clearTimeout(timer)
|
|
7209
|
+
clearTimeout(legacyStopTimer)
|
|
7210
|
+
if (legacyScanTimer !== null) clearTimeout(legacyScanTimer)
|
|
6818
7211
|
try {
|
|
6819
7212
|
if (typeof document !== 'undefined' && document.getElementById) {
|
|
6820
7213
|
document.getElementById('dsa-family-legacy-style')?.remove()
|
|
@@ -6869,6 +7262,15 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
6869
7262
|
CARD_KIND,
|
|
6870
7263
|
CARD_KIND_BETTER,
|
|
6871
7264
|
HISTORY_TURNS,
|
|
7265
|
+
API_ROOT,
|
|
7266
|
+
apiBaseAfter,
|
|
7267
|
+
MAX_LIVE_CARDS,
|
|
7268
|
+
REPLAY_OPEN_BUDGET_CHARS,
|
|
7269
|
+
STREAM_FLUSH_MS,
|
|
7270
|
+
STREAM_FLUSH_SLOW_MS,
|
|
7271
|
+
STREAM_SLOW_CHARS,
|
|
7272
|
+
capLiveCards,
|
|
7273
|
+
replayCardOpen,
|
|
6872
7274
|
estimateLabelWidth,
|
|
6873
7275
|
parseSseBlock,
|
|
6874
7276
|
truncateSelection,
|