dsh-select-quote 0.3.2 → 0.3.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/lib/client.js +334 -28
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -51,7 +51,7 @@ dsh web --patch "$PWD/dev.patch.yml"
|
|
|
51
51
|
|
|
52
52
|
## 已知限制
|
|
53
53
|
|
|
54
|
-
-
|
|
54
|
+
- **待发批注按会话持久化**:刷新页面、重启客户端后回到原会话,未发送的批注会原样回来(和草稿同一套机制);保留最近 20 个会话、7 天。发送即清除,切换会话跟着切,hero 里换工作区等于换会话,批注不跟着搬。
|
|
55
55
|
- **占用了用户气泡的渲染**:本插件以最低优先级替换内置 `user` 节点。其它插件若替换同一个键(例如 `dsh-easyrewrite` 用 `-1`),会被本插件屏蔽。
|
|
56
56
|
- **卡片宽度有限**:输入框内每条摘要最宽约为输入框的 1/4,超长标题走省略号。
|
|
57
57
|
- **划词只在对话区生效**:不介入编辑器、终端等面板。
|
|
@@ -92,7 +92,7 @@ pnpm run sync:desktop -- --watch # 常驻:lib/ 一变就推
|
|
|
92
92
|
pnpm dev:desktop # 增量重建 + 推送,一条命令
|
|
93
93
|
```
|
|
94
94
|
|
|
95
|
-
目标路径按 `$DSH_HOME`(默认 `~/.dsh`)与 profile 名推导,profile 用 `--profile <name>` 或 `$DSH_PROFILE` 指定,默认 `desktop
|
|
95
|
+
目标路径按 `$DSH_HOME`(默认 `~/.dsh`)与 profile 名推导,profile 用 `--profile <name>` 或 `$DSH_PROFILE` 指定,默认 `desktop`。字节没变就不写,避免空重载。模块级重载会重置内存状态,但未发送的批注已按会话持久化,重新挂载后会自己回来。
|
|
96
96
|
|
|
97
97
|
发布:先把源码与产物 `lib/` 一起提交,再跑
|
|
98
98
|
|
package/lib/client.js
CHANGED
|
@@ -975,9 +975,17 @@ function collectCleanText(root) {
|
|
|
975
975
|
return entries;
|
|
976
976
|
}
|
|
977
977
|
function flowItemOf(node) {
|
|
978
|
-
const item = (node instanceof Element ? node : node
|
|
978
|
+
const item = (node instanceof Element ? node : node?.parentElement)?.closest(FLOW_ITEM);
|
|
979
979
|
return item instanceof HTMLElement ? item : null;
|
|
980
980
|
}
|
|
981
|
+
/**
|
|
982
|
+
* True when the node lives inside a conversation message. Selections in the
|
|
983
|
+
* sidebar, settings, model picker or any other app chrome are not quotes, so
|
|
984
|
+
* the toolbar must stay away from them.
|
|
985
|
+
*/
|
|
986
|
+
function isInsideTranscript(node) {
|
|
987
|
+
return flowItemOf(node) !== null;
|
|
988
|
+
}
|
|
981
989
|
function pointOffset(entries, container, offset) {
|
|
982
990
|
for (const entry of entries) if (entry.node === container) return entry.start + Math.min(offset, entry.end - entry.start);
|
|
983
991
|
if (!(container instanceof Element)) return null;
|
|
@@ -1317,22 +1325,6 @@ function findElementByText(text) {
|
|
|
1317
1325
|
}
|
|
1318
1326
|
return best;
|
|
1319
1327
|
}
|
|
1320
|
-
//#endregion
|
|
1321
|
-
//#region src/client/runtime.ts
|
|
1322
|
-
/**
|
|
1323
|
-
* Shared runtime handles across slot scopes.
|
|
1324
|
-
* The selection toolbar mounts at root (`shell.overlay`) while the annotation
|
|
1325
|
-
* panel is session-scoped — pending annotations use one shared store key so
|
|
1326
|
-
* both sides always read the same list.
|
|
1327
|
-
*/
|
|
1328
|
-
const runtime = {};
|
|
1329
|
-
function setRuntimeSession(sessionId) {
|
|
1330
|
-
if (sessionId) runtime.sessionId = sessionId;
|
|
1331
|
-
}
|
|
1332
|
-
/** Store partition for pending annotations (single active composer). */
|
|
1333
|
-
function annotationStoreKey() {
|
|
1334
|
-
return "pending";
|
|
1335
|
-
}
|
|
1336
1328
|
const DRAFT_MARKER_RE = /\u200B/g;
|
|
1337
1329
|
/** Plain text from Chat user-node content blocks (string or block array). */
|
|
1338
1330
|
function contentBlocksToText(content) {
|
|
@@ -1525,11 +1517,27 @@ function scanAnnotatedMessage(text) {
|
|
|
1525
1517
|
};
|
|
1526
1518
|
}
|
|
1527
1519
|
//#endregion
|
|
1520
|
+
//#region src/client/runtime.ts
|
|
1521
|
+
/**
|
|
1522
|
+
* Shared runtime handles across slot scopes.
|
|
1523
|
+
*
|
|
1524
|
+
* The selection toolbar mounts at root (`shell.overlay`) while the annotation
|
|
1525
|
+
* panel is session-scoped, so both sides read one partition keyed by the
|
|
1526
|
+
* session the host minted for the active composer.
|
|
1527
|
+
*/
|
|
1528
|
+
const runtime = {};
|
|
1529
|
+
function setRuntimeSession(sessionId) {
|
|
1530
|
+
if (sessionId) runtime.sessionId = sessionId;
|
|
1531
|
+
}
|
|
1532
|
+
//#endregion
|
|
1528
1533
|
//#region src/client/state/annotation-store.ts
|
|
1529
1534
|
/** Per-session pending selection annotations (composer-side store). */
|
|
1530
1535
|
const EMPTY$1 = [];
|
|
1536
|
+
/** Bucket for annotations added before a session id is known (never persisted). */
|
|
1537
|
+
const MEMORY_KEY = "pending";
|
|
1531
1538
|
const bySession = /* @__PURE__ */ new Map();
|
|
1532
1539
|
const listeners = /* @__PURE__ */ new Set();
|
|
1540
|
+
let persistence = null;
|
|
1533
1541
|
function notify() {
|
|
1534
1542
|
for (const listener of [...listeners]) listener();
|
|
1535
1543
|
}
|
|
@@ -1539,12 +1547,25 @@ function subscribeAnnotations(listener) {
|
|
|
1539
1547
|
listeners.delete(listener);
|
|
1540
1548
|
};
|
|
1541
1549
|
}
|
|
1542
|
-
|
|
1543
|
-
|
|
1550
|
+
/**
|
|
1551
|
+
* Store partition: the live session when one is bound, otherwise a memory-only
|
|
1552
|
+
* bucket. The host always mints a session before the composer accepts text, so
|
|
1553
|
+
* the bucket is the "cannot happen" path and never reaches storage.
|
|
1554
|
+
*/
|
|
1555
|
+
function partition(sessionId) {
|
|
1556
|
+
return sessionId ?? runtime.sessionId ?? MEMORY_KEY;
|
|
1557
|
+
}
|
|
1558
|
+
/** Attach the persistence port (null detaches it). */
|
|
1559
|
+
function setAnnotationPersistence(port) {
|
|
1560
|
+
persistence = port;
|
|
1561
|
+
}
|
|
1562
|
+
function persist(partitionKey) {
|
|
1563
|
+
if (!persistence || partitionKey === MEMORY_KEY) return;
|
|
1564
|
+
persistence.save(partitionKey, bySession.get(partitionKey) ?? EMPTY$1);
|
|
1544
1565
|
}
|
|
1545
1566
|
/** Append one selection; an identical text+comment pair is reused. */
|
|
1546
1567
|
function saveAnnotation(sessionId, input) {
|
|
1547
|
-
const k =
|
|
1568
|
+
const k = partition(sessionId);
|
|
1548
1569
|
const comment = input.comment?.trim() ?? "";
|
|
1549
1570
|
const current = bySession.get(k) ?? EMPTY$1;
|
|
1550
1571
|
const duplicate = current.find((item) => item.text === input.text && (item.comment ?? "") === comment);
|
|
@@ -1556,14 +1577,15 @@ function saveAnnotation(sessionId, input) {
|
|
|
1556
1577
|
sources: input.sources?.length ? input.sources : [{ text: input.text }]
|
|
1557
1578
|
};
|
|
1558
1579
|
bySession.set(k, [...current, annotation]);
|
|
1580
|
+
persist(k);
|
|
1559
1581
|
notify();
|
|
1560
1582
|
return annotation;
|
|
1561
1583
|
}
|
|
1562
1584
|
function getAnnotations(sessionId) {
|
|
1563
|
-
return bySession.get(
|
|
1585
|
+
return bySession.get(partition(sessionId)) ?? EMPTY$1;
|
|
1564
1586
|
}
|
|
1565
1587
|
function updateAnnotationComment(sessionId, id, comment) {
|
|
1566
|
-
const k =
|
|
1588
|
+
const k = partition(sessionId);
|
|
1567
1589
|
const current = bySession.get(k);
|
|
1568
1590
|
if (!current) return;
|
|
1569
1591
|
const next = current.map((item) => {
|
|
@@ -1578,20 +1600,59 @@ function updateAnnotationComment(sessionId, id, comment) {
|
|
|
1578
1600
|
};
|
|
1579
1601
|
});
|
|
1580
1602
|
bySession.set(k, next);
|
|
1603
|
+
persist(k);
|
|
1581
1604
|
notify();
|
|
1582
1605
|
}
|
|
1583
1606
|
function removeAnnotation(sessionId, id) {
|
|
1584
|
-
const k =
|
|
1607
|
+
const k = partition(sessionId);
|
|
1585
1608
|
const current = bySession.get(k);
|
|
1586
1609
|
if (!current) return;
|
|
1587
1610
|
const next = current.filter((item) => item.id !== id);
|
|
1588
1611
|
if (next.length === current.length) return;
|
|
1589
1612
|
if (next.length === 0) bySession.delete(k);
|
|
1590
1613
|
else bySession.set(k, next);
|
|
1614
|
+
if (next.length === 0 && k !== MEMORY_KEY) persistence?.clear(k);
|
|
1615
|
+
else persist(k);
|
|
1591
1616
|
notify();
|
|
1592
1617
|
}
|
|
1593
1618
|
function clearAnnotations(sessionId) {
|
|
1594
|
-
|
|
1619
|
+
const k = partition(sessionId);
|
|
1620
|
+
if (!bySession.delete(k)) return;
|
|
1621
|
+
if (k !== MEMORY_KEY) persistence?.clear(k);
|
|
1622
|
+
notify();
|
|
1623
|
+
}
|
|
1624
|
+
/**
|
|
1625
|
+
* Bind the active session and adopt whatever it left unsent.
|
|
1626
|
+
*
|
|
1627
|
+
* Called from render-phase bridges, so adoption is deferred to a microtask:
|
|
1628
|
+
* the resulting notify() must not land inside another component's render.
|
|
1629
|
+
* Re-binding the same session is a no-op.
|
|
1630
|
+
*/
|
|
1631
|
+
function bindAnnotationSession(sessionId) {
|
|
1632
|
+
if (!sessionId || sessionId === runtime.sessionId) return;
|
|
1633
|
+
setRuntimeSession(sessionId);
|
|
1634
|
+
queueMicrotask(() => adoptPersistedAnnotations(sessionId));
|
|
1635
|
+
}
|
|
1636
|
+
/**
|
|
1637
|
+
* Adopt persisted annotations for a session.
|
|
1638
|
+
*
|
|
1639
|
+
* In-memory entries win on conflict — they can only be newer, because anything
|
|
1640
|
+
* added after the bind is persisted immediately. Annotations parked in the
|
|
1641
|
+
* memory-only bucket (added before the bind) move over as well.
|
|
1642
|
+
*/
|
|
1643
|
+
function adoptPersistedAnnotations(sessionId) {
|
|
1644
|
+
if (!persistence) return;
|
|
1645
|
+
const stored = persistence.load(sessionId);
|
|
1646
|
+
const current = bySession.get(sessionId) ?? EMPTY$1;
|
|
1647
|
+
const orphaned = bySession.get(MEMORY_KEY) ?? EMPTY$1;
|
|
1648
|
+
const merged = /* @__PURE__ */ new Map();
|
|
1649
|
+
for (const item of stored) merged.set(item.id, item);
|
|
1650
|
+
for (const item of [...current, ...orphaned]) merged.set(item.id, item);
|
|
1651
|
+
const next = [...merged.values()];
|
|
1652
|
+
if (next.length === current.length && next.every((item, i) => item.id === current[i]?.id) && orphaned.length === 0) return;
|
|
1653
|
+
bySession.set(sessionId, next);
|
|
1654
|
+
if (orphaned.length > 0) bySession.delete(MEMORY_KEY);
|
|
1655
|
+
notify();
|
|
1595
1656
|
}
|
|
1596
1657
|
function annotationTitle(annotation) {
|
|
1597
1658
|
return previewTitle(annotation.text);
|
|
@@ -2347,6 +2408,31 @@ function useSendIntercept({ itemCount, draftRef, setDraft, onFolded }) {
|
|
|
2347
2408
|
//#region src/client/composer/AnnotationPanel.tsx
|
|
2348
2409
|
const EMPTY = [];
|
|
2349
2410
|
/**
|
|
2411
|
+
* True while an IME composition is in flight.
|
|
2412
|
+
*
|
|
2413
|
+
* Composition events bubble, so a capture listener on the document sees them
|
|
2414
|
+
* wherever the composer lives. Anything that writes the draft must check this:
|
|
2415
|
+
* replacing editor content mid-composition drops the composing text.
|
|
2416
|
+
*/
|
|
2417
|
+
function useComposing() {
|
|
2418
|
+
const composing = (0, react.useRef)(false);
|
|
2419
|
+
(0, react.useEffect)(() => {
|
|
2420
|
+
const onStart = () => {
|
|
2421
|
+
composing.current = true;
|
|
2422
|
+
};
|
|
2423
|
+
const onEnd = () => {
|
|
2424
|
+
composing.current = false;
|
|
2425
|
+
};
|
|
2426
|
+
document.addEventListener("compositionstart", onStart, true);
|
|
2427
|
+
document.addEventListener("compositionend", onEnd, true);
|
|
2428
|
+
return () => {
|
|
2429
|
+
document.removeEventListener("compositionstart", onStart, true);
|
|
2430
|
+
document.removeEventListener("compositionend", onEnd, true);
|
|
2431
|
+
};
|
|
2432
|
+
}, []);
|
|
2433
|
+
return composing;
|
|
2434
|
+
}
|
|
2435
|
+
/**
|
|
2350
2436
|
* Composer annotation summary (`conversation.input.overlay`).
|
|
2351
2437
|
*
|
|
2352
2438
|
* Pending selections never enter the editor as text: the summary card is the
|
|
@@ -2360,6 +2446,7 @@ function AnnotationPanel({ useInput, inputActions, sessionId }) {
|
|
|
2360
2446
|
const draftRef = (0, react.useRef)(draft);
|
|
2361
2447
|
const actionsRef = (0, react.useRef)(inputActions);
|
|
2362
2448
|
const sessionRef = (0, react.useRef)(sessionId);
|
|
2449
|
+
const composing = useComposing();
|
|
2363
2450
|
draftRef.current = draft;
|
|
2364
2451
|
actionsRef.current = inputActions;
|
|
2365
2452
|
sessionRef.current = sessionId;
|
|
@@ -2370,7 +2457,7 @@ function AnnotationPanel({ useInput, inputActions, sessionId }) {
|
|
|
2370
2457
|
setItems(getAnnotations());
|
|
2371
2458
|
}, []);
|
|
2372
2459
|
(0, react.useEffect)(() => {
|
|
2373
|
-
|
|
2460
|
+
bindAnnotationSession(sessionId);
|
|
2374
2461
|
}, [sessionId]);
|
|
2375
2462
|
(0, react.useEffect)(() => {
|
|
2376
2463
|
ensureToolbarStyles();
|
|
@@ -2406,6 +2493,19 @@ function AnnotationPanel({ useInput, inputActions, sessionId }) {
|
|
|
2406
2493
|
setDraft,
|
|
2407
2494
|
onFolded: () => setItems(EMPTY)
|
|
2408
2495
|
});
|
|
2496
|
+
(0, react.useEffect)(() => {
|
|
2497
|
+
if (composing.current) return;
|
|
2498
|
+
if (items.length === 0) {
|
|
2499
|
+
if (draft.includes("")) setDraft(stripDraftMarker(draft));
|
|
2500
|
+
return;
|
|
2501
|
+
}
|
|
2502
|
+
if (!draft.includes("") && draft.trim() === "") setDraft(withDraftMarker(draft));
|
|
2503
|
+
}, [
|
|
2504
|
+
items,
|
|
2505
|
+
draft,
|
|
2506
|
+
setDraft,
|
|
2507
|
+
composing
|
|
2508
|
+
]);
|
|
2409
2509
|
const onRemoveAll = (0, react.useCallback)(() => {
|
|
2410
2510
|
clearAnnotations();
|
|
2411
2511
|
setItems(EMPTY);
|
|
@@ -2505,7 +2605,8 @@ function toolbarPosition(rect) {
|
|
|
2505
2605
|
};
|
|
2506
2606
|
}
|
|
2507
2607
|
/**
|
|
2508
|
-
* Read the live selection when it is a non-empty range
|
|
2608
|
+
* Read the live selection when it is a non-empty range inside a conversation
|
|
2609
|
+
* message and outside the composer / our own chrome.
|
|
2509
2610
|
*/
|
|
2510
2611
|
function readSelectionSnapshot(toolbarRoot) {
|
|
2511
2612
|
const selection = window.getSelection();
|
|
@@ -2514,6 +2615,7 @@ function readSelectionSnapshot(toolbarRoot) {
|
|
|
2514
2615
|
if (!anchorNode || !focusNode) return null;
|
|
2515
2616
|
if (isInsideComposerInput(anchorNode) || isInsideComposerInput(focusNode)) return null;
|
|
2516
2617
|
if (toolbarRoot?.contains(anchorNode) || toolbarRoot?.contains(focusNode)) return null;
|
|
2618
|
+
if (!isInsideTranscript(anchorNode) || !isInsideTranscript(focusNode)) return null;
|
|
2517
2619
|
const text = selection.toString().replace(/\r\n/g, "\n").trim();
|
|
2518
2620
|
if (!text) return null;
|
|
2519
2621
|
const rect = getRangeFirstLineRect(selection.getRangeAt(0));
|
|
@@ -2562,7 +2664,7 @@ function SelectionToolbar({ sessionId }) {
|
|
|
2562
2664
|
activeRef.current = active;
|
|
2563
2665
|
(0, react.useEffect)(() => {
|
|
2564
2666
|
ensureToolbarStyles();
|
|
2565
|
-
if (sessionId)
|
|
2667
|
+
if (sessionId) bindAnnotationSession(sessionId);
|
|
2566
2668
|
}, [sessionId]);
|
|
2567
2669
|
const hide = (0, react.useCallback)(() => {
|
|
2568
2670
|
setActive(null);
|
|
@@ -3033,6 +3135,204 @@ function UserMessageDisplay({ node, renderMessageImages }) {
|
|
|
3033
3135
|
});
|
|
3034
3136
|
}
|
|
3035
3137
|
//#endregion
|
|
3138
|
+
//#region src/client/state/annotation-persistence.ts
|
|
3139
|
+
/** localStorage prefix; a session id is appended as `.<sessionId>`. */
|
|
3140
|
+
const ANNOTATION_KEY_PREFIX = "dsh.select-quote.annotations";
|
|
3141
|
+
const PAYLOAD_VERSION = 1;
|
|
3142
|
+
function keyForSession(sessionId) {
|
|
3143
|
+
return `${ANNOTATION_KEY_PREFIX}.${sessionId}`;
|
|
3144
|
+
}
|
|
3145
|
+
function sessionIdOfKey(key) {
|
|
3146
|
+
const prefix = `${ANNOTATION_KEY_PREFIX}.`;
|
|
3147
|
+
return key.startsWith(prefix) ? key.slice(prefix.length) : null;
|
|
3148
|
+
}
|
|
3149
|
+
function isAnnotation(value) {
|
|
3150
|
+
if (!value || typeof value !== "object") return false;
|
|
3151
|
+
const item = value;
|
|
3152
|
+
return typeof item.id === "string" && typeof item.text === "string" && Array.isArray(item.sources);
|
|
3153
|
+
}
|
|
3154
|
+
/** Tolerant read: a corrupt or foreign payload degrades to "nothing stored". */
|
|
3155
|
+
function itemsOf(value) {
|
|
3156
|
+
if (!value || typeof value !== "object") return [];
|
|
3157
|
+
const payload = value;
|
|
3158
|
+
if (!Array.isArray(payload.items)) return [];
|
|
3159
|
+
return payload.items.filter(isAnnotation);
|
|
3160
|
+
}
|
|
3161
|
+
function encode(items, now) {
|
|
3162
|
+
return JSON.stringify({
|
|
3163
|
+
v: PAYLOAD_VERSION,
|
|
3164
|
+
updatedAt: now,
|
|
3165
|
+
items
|
|
3166
|
+
});
|
|
3167
|
+
}
|
|
3168
|
+
function decode(raw) {
|
|
3169
|
+
if (raw === null) return [];
|
|
3170
|
+
try {
|
|
3171
|
+
return itemsOf(JSON.parse(raw));
|
|
3172
|
+
} catch {
|
|
3173
|
+
return [];
|
|
3174
|
+
}
|
|
3175
|
+
}
|
|
3176
|
+
/** One warn per session, never a throw: a full quota must not break typing. */
|
|
3177
|
+
let quotaWarned = false;
|
|
3178
|
+
function warnQuota() {
|
|
3179
|
+
if (quotaWarned) return;
|
|
3180
|
+
quotaWarned = true;
|
|
3181
|
+
console.warn("dsh-select-quote: annotation persistence disabled (storage write failed)");
|
|
3182
|
+
}
|
|
3183
|
+
/**
|
|
3184
|
+
* Direct localStorage backend (the documented fallback). Writes are wrapped so
|
|
3185
|
+
* a quota failure degrades to memory-only instead of breaking the composer.
|
|
3186
|
+
*/
|
|
3187
|
+
function createStoragePersistence(storage) {
|
|
3188
|
+
let degraded = false;
|
|
3189
|
+
return {
|
|
3190
|
+
load: (sessionId) => decode(storage.getItem(keyForSession(sessionId))),
|
|
3191
|
+
save: (sessionId, items) => {
|
|
3192
|
+
if (degraded) return;
|
|
3193
|
+
try {
|
|
3194
|
+
storage.setItem(keyForSession(sessionId), encode(items, Date.now()));
|
|
3195
|
+
} catch {
|
|
3196
|
+
degraded = true;
|
|
3197
|
+
warnQuota();
|
|
3198
|
+
}
|
|
3199
|
+
},
|
|
3200
|
+
clear: (sessionId) => {
|
|
3201
|
+
try {
|
|
3202
|
+
storage.removeItem(keyForSession(sessionId));
|
|
3203
|
+
} catch {}
|
|
3204
|
+
},
|
|
3205
|
+
prune: (now) => pruneSessions(storage, now)
|
|
3206
|
+
};
|
|
3207
|
+
}
|
|
3208
|
+
/**
|
|
3209
|
+
* Host store backend: the same `defineStore` the composer draft uses, so the
|
|
3210
|
+
* write path, per-session keying and failure semantics are the host's.
|
|
3211
|
+
*/
|
|
3212
|
+
function createHostStorePersistence(defineStore, storage) {
|
|
3213
|
+
const handle = defineStore({
|
|
3214
|
+
init: () => ({
|
|
3215
|
+
v: PAYLOAD_VERSION,
|
|
3216
|
+
updatedAt: 0,
|
|
3217
|
+
items: []
|
|
3218
|
+
}),
|
|
3219
|
+
persist: ANNOTATION_KEY_PREFIX,
|
|
3220
|
+
actions: { replace(draft, items, now) {
|
|
3221
|
+
draft.items = [...items];
|
|
3222
|
+
draft.updatedAt = now;
|
|
3223
|
+
} }
|
|
3224
|
+
});
|
|
3225
|
+
const instances = /* @__PURE__ */ new Map();
|
|
3226
|
+
const instanceOf = (sessionId) => {
|
|
3227
|
+
const cached = instances.get(sessionId);
|
|
3228
|
+
if (cached) return cached;
|
|
3229
|
+
const instance = handle.create(sessionId);
|
|
3230
|
+
instances.set(sessionId, instance);
|
|
3231
|
+
return instance;
|
|
3232
|
+
};
|
|
3233
|
+
return {
|
|
3234
|
+
load: (sessionId) => itemsOf(instanceOf(sessionId).getSnapshot()),
|
|
3235
|
+
save: (sessionId, items) => instanceOf(sessionId).actions.replace(items, Date.now()),
|
|
3236
|
+
clear: (sessionId) => {
|
|
3237
|
+
instanceOf(sessionId).clearPersisted();
|
|
3238
|
+
instances.delete(sessionId);
|
|
3239
|
+
},
|
|
3240
|
+
prune: (now) => {
|
|
3241
|
+
const removed = pruneSessions(storage, now);
|
|
3242
|
+
for (const sessionId of removed) instances.delete(sessionId);
|
|
3243
|
+
return removed;
|
|
3244
|
+
}
|
|
3245
|
+
};
|
|
3246
|
+
}
|
|
3247
|
+
/** Retention: newest `maxSessions` within `maxAgeMs`, by payload timestamp. */
|
|
3248
|
+
function pruneSessions(storage, now = Date.now(), options = {}) {
|
|
3249
|
+
const maxSessions = options.maxSessions ?? 20;
|
|
3250
|
+
const maxAgeMs = options.maxAgeMs ?? 6048e5;
|
|
3251
|
+
const rows = [];
|
|
3252
|
+
for (const key of storage.keys()) {
|
|
3253
|
+
const sessionId = sessionIdOfKey(key);
|
|
3254
|
+
if (sessionId === null) continue;
|
|
3255
|
+
const raw = storage.getItem(key);
|
|
3256
|
+
let updatedAt = 0;
|
|
3257
|
+
try {
|
|
3258
|
+
const parsed = JSON.parse(raw ?? "null");
|
|
3259
|
+
if (parsed && typeof parsed.updatedAt === "number") updatedAt = parsed.updatedAt;
|
|
3260
|
+
} catch {
|
|
3261
|
+
updatedAt = 0;
|
|
3262
|
+
}
|
|
3263
|
+
rows.push({
|
|
3264
|
+
sessionId,
|
|
3265
|
+
updatedAt
|
|
3266
|
+
});
|
|
3267
|
+
}
|
|
3268
|
+
const removed = /* @__PURE__ */ new Set();
|
|
3269
|
+
for (const row of rows) if (now - row.updatedAt > maxAgeMs) removed.add(row.sessionId);
|
|
3270
|
+
rows.sort((a, b) => b.updatedAt - a.updatedAt);
|
|
3271
|
+
for (const row of rows.slice(maxSessions)) removed.add(row.sessionId);
|
|
3272
|
+
for (const sessionId of removed) storage.removeItem(keyForSession(sessionId));
|
|
3273
|
+
return [...removed];
|
|
3274
|
+
}
|
|
3275
|
+
function createNoopPersistence() {
|
|
3276
|
+
return {
|
|
3277
|
+
load: () => [],
|
|
3278
|
+
save: () => {},
|
|
3279
|
+
clear: () => {},
|
|
3280
|
+
prune: () => []
|
|
3281
|
+
};
|
|
3282
|
+
}
|
|
3283
|
+
//#endregion
|
|
3284
|
+
//#region src/client/state/annotation-storage.ts
|
|
3285
|
+
/**
|
|
3286
|
+
* Backend selection for annotation persistence.
|
|
3287
|
+
*
|
|
3288
|
+
* Kept apart from `annotation-persistence.ts` because reaching the host's
|
|
3289
|
+
* client-store needs a guarded `require`, and that is the one thing the pure
|
|
3290
|
+
* module cannot do — verify-bundle imports the pure module straight into Node,
|
|
3291
|
+
* where a `declare` statement is not valid JavaScript.
|
|
3292
|
+
*
|
|
3293
|
+
* Preference order: the host store (same engine and keying as the composer
|
|
3294
|
+
* draft), plain localStorage, and a no-op when the environment has no storage
|
|
3295
|
+
* at all.
|
|
3296
|
+
*/
|
|
3297
|
+
/** The host package is a platform seed word; older hosts may not carry it. */
|
|
3298
|
+
const HOST_STORE_MODULE = "@deepseek-ai/dsh-client-store";
|
|
3299
|
+
function hostDefineStore() {
|
|
3300
|
+
try {
|
|
3301
|
+
const loaded = require(HOST_STORE_MODULE);
|
|
3302
|
+
return typeof loaded?.defineStore === "function" ? loaded.defineStore : null;
|
|
3303
|
+
} catch {
|
|
3304
|
+
return null;
|
|
3305
|
+
}
|
|
3306
|
+
}
|
|
3307
|
+
function browserStorage() {
|
|
3308
|
+
try {
|
|
3309
|
+
const storage = globalThis.localStorage;
|
|
3310
|
+
if (!storage) return null;
|
|
3311
|
+
return {
|
|
3312
|
+
getItem: (key) => storage.getItem(key),
|
|
3313
|
+
setItem: (key, value) => storage.setItem(key, value),
|
|
3314
|
+
removeItem: (key) => storage.removeItem(key),
|
|
3315
|
+
keys: () => {
|
|
3316
|
+
const out = [];
|
|
3317
|
+
for (let i = 0; i < storage.length; i += 1) {
|
|
3318
|
+
const key = storage.key(i);
|
|
3319
|
+
if (key !== null) out.push(key);
|
|
3320
|
+
}
|
|
3321
|
+
return out;
|
|
3322
|
+
}
|
|
3323
|
+
};
|
|
3324
|
+
} catch {
|
|
3325
|
+
return null;
|
|
3326
|
+
}
|
|
3327
|
+
}
|
|
3328
|
+
function createAnnotationPersistence() {
|
|
3329
|
+
const storage = browserStorage();
|
|
3330
|
+
const defineStore = hostDefineStore();
|
|
3331
|
+
if (storage && defineStore) return createHostStorePersistence(defineStore, storage);
|
|
3332
|
+
if (storage) return createStoragePersistence(storage);
|
|
3333
|
+
return createNoopPersistence();
|
|
3334
|
+
}
|
|
3335
|
+
//#endregion
|
|
3036
3336
|
//#region src/client/index.tsx
|
|
3037
3337
|
/**
|
|
3038
3338
|
* Browser half of dsh-select-quote.
|
|
@@ -3077,6 +3377,12 @@ function apply(ctx) {
|
|
|
3077
3377
|
syncPendingAnnotationJobs(getAnnotations());
|
|
3078
3378
|
return subscribeAnnotations(() => syncPendingAnnotationJobs(getAnnotations()));
|
|
3079
3379
|
}, "dsh-select-quote: pending annotation marks");
|
|
3380
|
+
ctx.effect(() => {
|
|
3381
|
+
const port = createAnnotationPersistence();
|
|
3382
|
+
setAnnotationPersistence(port);
|
|
3383
|
+
port.prune();
|
|
3384
|
+
return () => setAnnotationPersistence(null);
|
|
3385
|
+
}, "dsh-select-quote: annotation persistence");
|
|
3080
3386
|
ctx.slots.inject("shell.overlay", () => {
|
|
3081
3387
|
ctx.slots.register({
|
|
3082
3388
|
name: "shell.overlay",
|
|
@@ -3108,7 +3414,7 @@ function apply(ctx) {
|
|
|
3108
3414
|
}
|
|
3109
3415
|
/** Invisible session-scoped bridge that publishes sessionId for the root toolbar. */
|
|
3110
3416
|
function SessionBridge({ sessionId }) {
|
|
3111
|
-
|
|
3417
|
+
bindAnnotationSession(sessionId);
|
|
3112
3418
|
return null;
|
|
3113
3419
|
}
|
|
3114
3420
|
//#endregion
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-select-quote",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.3",
|
|
4
4
|
"description": "DeepSeek Harness web plugin: annotate text selected in the conversation — the selection rides along with your next message, is marked in the source text, and the model points back at it.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"deepseek",
|