dsh-select-quote 0.2.0 → 0.3.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.
Files changed (3) hide show
  1. package/README.md +31 -2
  2. package/lib/client.js +412 -134
  3. package/package.json +2 -1
package/README.md CHANGED
@@ -19,11 +19,19 @@
19
19
  | 项 | 值 |
20
20
  |---|---|
21
21
  | dsh | `0.1.7-rc.1`(2026-09-24 实测:划词、批注卡片、正文标记、对话记录渲染全通) |
22
- | profile | web(`dsh web`) |
22
+ | profile | web(`dsh web`)与桌面端(desktop)都可用;桌面端在 0.2.0 及更早版本会楔死渲染进程,本仓库已修复,详见下节 |
23
23
  | 运行时 | 只有浏览器半有业务;Node 半仅用于让包被 Loader 挂载 |
24
24
 
25
25
  实现依赖若干产品内部约定(槽位名、composer DOM、深色主题属性等),升级 dsh 后请先跑 `npm run check`,再照 [`docs/internals.md`](docs/internals.md) 末尾的复核清单人工回归一遍。
26
26
 
27
+ ### 桌面端(desktop profile)
28
+
29
+ 桌面端是 Electron 外壳 + 同一个 Web 客户端,插件声明的 `dsh.client.platform: "web"` 在桌面端同样加载,槽位与 DOM 契约都在,功能本身可用(实测批注随消息送达模型、模型回指渲染为 chip 均正常)。
30
+
31
+ > ⚠️ **0.2.0 及更早版本在桌面端会楔死渲染进程**,见 [#1](https://github.com/latte03/dsh-select-quote/issues/1):App 能启动、界面完整渲染,但整个输入区无法点击,renderer 子进程单核 100% 持续旋转。根因在插件自己的装饰器里——协议头部逐字写出了 `:dsh-annotation{index="N"}`,当文本含有这个字面量而严格正则一条都没匹配上时,装饰器仍然替换了一次内容完全相同的 Text 节点,于是被自己的 `MutationObserver` 反复触发,微任务队列永远排不空,宏任务和定时器再也得不到执行。触发条件是文本内容,与 Electron 或 `dsh-app://` 无关,桌面端只是恰好把那段文本送进了 Text 节点。完整机制与两道防线记录在 [`docs/internals.md`](docs/internals.md) 关键机制 8。
32
+
33
+ > **现在的状态**:修复已进主干,Windows 桌面端 0.2.0-rc.2 实测重新启用插件后批注正常送达、Turn 正常结束、输入框可点击、没有新的崩溃日志。npm 上可安装的仍是 `0.2.0`,也就是带缺陷的那一版,因此桌面端用户在修复版发布之前请在 `dsh.profile.bundles` 中摘除本插件,或改用浏览器访问同一个端口。[#1](https://github.com/latte03/dsh-select-quote/issues/1) 保持 open,等报告人复验后关闭。
34
+
27
35
  ## 安装
28
36
 
29
37
  ### 方式一:从 npm 安装(bundle)
@@ -33,7 +41,7 @@ dsh plugin --profile web add dsh-select-quote
33
41
  dsh --profile web
34
42
  ```
35
43
 
36
- > ⚠️ npm 上目前只有 `0.1.0`,那是旧版「引用块」行为,不含批注卡片、正文标记与模型回指。要用现在这套,请先用方式二,或从仓库目录安装:`dsh plugin --profile web add <本仓库绝对路径>`。
44
+ > npm 上目前最新是 `0.2.0`(2026-09-24 发布):带批注卡片、正文标记与模型回指,但界面只有简体中文,桌面端还会楔死渲染进程(见兼容性一节)。本仓库的 `0.3.0` 修掉了楔死、加了简体中文 / English / Русский 三语,并把对话记录里的批注卡片改到用户气泡内,**还没发布到 npm**。要用 `0.3.0` 的行为,先按方式二从本地 checkout 加载;发布完成后这一节需要更新。仓库里另有 `0.1.0`,那是旧版「引用块」,不要装错。
37
45
 
38
46
  ### 方式二:本地 checkout 加载(`--patch`)
39
47
 
@@ -53,6 +61,27 @@ dsh web --patch "$PWD/dev.patch.yml"
53
61
  4. 正常打字、按 Enter 发送。批注随消息一起交给模型,卡片随之消失
54
62
  5. 模型回答里指向某条批注的位置,会显示成可点的 chip
55
63
 
64
+ ## 语言 / Localization
65
+
66
+ 界面文案支持 **简体中文 / English / Русский**,默认跟随浏览器语言:
67
+
68
+ | 语言 | 默认值 |
69
+ |---|---|
70
+ | `zh` | 原文案,同时作为缺失键的兜底词典 |
71
+ | `en` | 非中文环境 |
72
+ | `ru` | `ru` / `ru-RU` 等标签 |
73
+
74
+ 想固定语言,在 Web GUI 控制台里设置后刷新页面:
75
+
76
+ ```js
77
+ localStorage.setItem('dsq-locale', 'ru') // 'ru' | 'en' | 'zh'
78
+ localStorage.removeItem('dsq-locale') // 恢复跟随浏览器
79
+ ```
80
+
81
+ **刻意不翻译的部分**:`> [选中文本]`、`<response-annotations>`、`:dsh-annotation{index="N"}` 这些是模型与解析器约定的传输格式;`# Response annotations:` 那段是给模型的指令正文,不是界面文案。只有面向人的 chrome 走 `t()`。
82
+
83
+ 新增文案:在 `src/client/i18n.ts` 的三份词典里各加一条,再在组件里 `t('key', { ... })` 调用。带 `{count}` 的键可以写成复数形式对象(中文用 `other`,俄语用 `one` / `few` / `many`)。
84
+
56
85
  ## 已知限制
57
86
 
58
87
  - **待发批注只在内存里**:刷新页面或切换会话后会丢,卡片不持久化(已发送消息里的批注不受影响)。
package/lib/client.js CHANGED
@@ -642,17 +642,19 @@ const CSS = `
642
642
  gap: 6px;
643
643
  }
644
644
 
645
- /* Transcript cards — in-flow Chat node (conversation history), stacked. */
645
+ /* Transcript cards — in-flow rows under the user bubble, side by side. */
646
646
  .dsq_tCardStack {
647
647
  display: flex;
648
- flex-direction: column;
648
+ flex-direction: row;
649
+ flex-wrap: wrap;
650
+ justify-content: flex-end;
649
651
  align-items: stretch;
650
652
  gap: 6px;
651
653
  margin: 4px 0 8px;
652
- /* Match composer summary width; sit on the user-bubble side (right). */
653
- width: 20%;
654
- min-width: 148px;
655
- max-width: 220px;
654
+ /* Cards lay out horizontally and wrap onto the next row when they run out of
655
+ width. Each card carries the selected text plus the user's comment, so they
656
+ stay compact and stay on the user-bubble side (right). */
657
+ max-width: 100%;
656
658
  margin-left: auto;
657
659
  margin-right: 0;
658
660
  }
@@ -729,6 +731,77 @@ const CSS = `
729
731
  white-space: nowrap;
730
732
  }
731
733
 
734
+ /* Transcript card wrapper: the card itself is a click target that scrolls back
735
+ to the marked text. Unlike the composer summary (one collapsed row per
736
+ message) each history card shows its own comment, so the fixed 64px height
737
+ and the single-line clamps are lifted here. */
738
+ .dsq_tCardButton {
739
+ appearance: none;
740
+ display: block;
741
+ /* Compact cards: several fit in one row before wrapping. */
742
+ flex: 0 1 auto;
743
+ width: 230px;
744
+ max-width: 100%;
745
+ padding: 0;
746
+ border: none;
747
+ background: transparent;
748
+ font: inherit;
749
+ text-align: left;
750
+ cursor: pointer;
751
+ border-radius: 14px;
752
+ }
753
+
754
+ .dsq_tCardButton:focus-visible {
755
+ outline: 2px solid var(--dsw-static-blue-500, #3b82f6);
756
+ outline-offset: 2px;
757
+ }
758
+
759
+ .dsq_tCardButton .dsq_tCard {
760
+ height: auto;
761
+ min-height: 0;
762
+ align-items: flex-start;
763
+ width: 100%;
764
+ gap: 8px;
765
+ padding: 8px;
766
+ border-radius: 14px;
767
+ }
768
+
769
+ .dsq_tCardButton .dsq_tCardIcon {
770
+ width: 22px;
771
+ height: 22px;
772
+ border-radius: 8px;
773
+ font-size: 11px;
774
+ }
775
+
776
+ .dsq_tCardButton .dsq_tCardTitle {
777
+ display: -webkit-box;
778
+ -webkit-line-clamp: 2;
779
+ -webkit-box-orient: vertical;
780
+ overflow: hidden;
781
+ white-space: normal;
782
+ overflow-wrap: anywhere;
783
+ font-size: 13px;
784
+ line-height: 18px;
785
+ }
786
+
787
+ .dsq_tCardButton .dsq_tCardSubtitle {
788
+ display: -webkit-box;
789
+ -webkit-line-clamp: 3;
790
+ -webkit-box-orient: vertical;
791
+ overflow: hidden;
792
+ white-space: pre-wrap;
793
+ overflow-wrap: anywhere;
794
+ margin-top: 2px;
795
+ font-size: 12px;
796
+ line-height: 16px;
797
+ }
798
+
799
+ /* A comment is the user's own words — give it more presence than the neutral
800
+ "no comment" placeholder. */
801
+ .dsq_tCardButton .dsq_tCardSubtitle[data-dsq-has-comment='true'] {
802
+ color: var(--dsw-alias-label-secondary, rgba(0, 0, 0, 0.65));
803
+ }
804
+
732
805
  /* Replacement user bubble (quote block stripped from display). */
733
806
  .dsq_userRow {
734
807
  display: flex;
@@ -916,6 +989,7 @@ const styles = {
916
989
  commentInput: "dsq_commentInput",
917
990
  commentActions: "dsq_commentActions",
918
991
  tCardStack: "dsq_tCardStack",
992
+ tCardButton: "dsq_tCardButton",
919
993
  tCard: "dsq_tCard",
920
994
  tCardIcon: "dsq_tCardIcon",
921
995
  tCardBody: "dsq_tCardBody",
@@ -1338,16 +1412,6 @@ function contentBlocksToText(content) {
1338
1412
  return "";
1339
1413
  }).join("");
1340
1414
  }
1341
- /**
1342
- * Best-effort plain text from a durable `user/message` event. A surface event
1343
- * carries its blocks directly on `data.content`; `data.message.content` is only
1344
- * a fallback for the older shape.
1345
- */
1346
- function extractUserText(event) {
1347
- const data = event?.data;
1348
- if (!data) return "";
1349
- return contentBlocksToText(data.content ?? data.message?.content);
1350
- }
1351
1415
  /** Short one-line card title from selected body text. */
1352
1416
  function previewTitle$1(text, max = 48) {
1353
1417
  const oneLine = text.replace(/\s+/g, " ").trim();
@@ -1526,10 +1590,6 @@ function scanAnnotatedMessage(text) {
1526
1590
  rest
1527
1591
  };
1528
1592
  }
1529
- /** Convenience: annotations only. */
1530
- function parseAnnotatedMessage(text) {
1531
- return scanAnnotatedMessage(text).annotations;
1532
- }
1533
1593
  //#endregion
1534
1594
  //#region src/client/state/annotation-store.ts
1535
1595
  /** Per-session pending selection annotations (composer-side store). */
@@ -1704,7 +1764,220 @@ function LucideIcon({ name, size = 15 }) {
1704
1764
  });
1705
1765
  }
1706
1766
  //#endregion
1767
+ //#region src/client/i18n.ts
1768
+ const LOCALES = [
1769
+ "ru",
1770
+ "en",
1771
+ "zh"
1772
+ ];
1773
+ const STORAGE_KEY = "dsq-locale";
1774
+ const MESSAGES = {
1775
+ ru: {
1776
+ "common.selectedText": "Выделенный текст",
1777
+ "common.commentPlaceholder": "Добавить необязательный комментарий…",
1778
+ "common.cancel": "Отмена",
1779
+ "common.save": "Сохранить",
1780
+ "toolbar.aria": "Действия с выделенным текстом",
1781
+ "toolbar.commentHint": "Добавить аннотацию…",
1782
+ "toolbar.copy": "Копировать",
1783
+ "toolbar.copyFailed": "Не удалось скопировать",
1784
+ "toolbar.addToTask": "Добавить к задаче",
1785
+ "toolbar.commentDialogAria": "Добавить необязательный комментарий",
1786
+ "toolbar.skipComment": "Без комментария",
1787
+ "toolbar.addComment": "Добавить комментарий",
1788
+ "card.commentSubtitle": "Комментарий · {comment}",
1789
+ "card.ariaIndexed": "Аннотация {index}",
1790
+ "card.ariaPlain": "Аннотация к выделенному тексту",
1791
+ "card.editComment": "Изменить комментарий",
1792
+ "card.remove": "Удалить аннотацию",
1793
+ "summary.hint": "Наведите, чтобы увидеть аннотации",
1794
+ "summary.dialogAria": "Детали аннотаций: {count}",
1795
+ "summary.edit": "Изменить аннотацию {index}",
1796
+ "summary.remove": "Удалить аннотацию {index}",
1797
+ "summary.userComment": "Комментарий пользователя",
1798
+ "summary.noComment": "Комментарий не добавлен",
1799
+ "summary.aria": {
1800
+ one: "{count} аннотация. Наведите, чтобы увидеть детали.",
1801
+ few: "{count} аннотации. Наведите, чтобы увидеть детали.",
1802
+ many: "{count} аннотаций. Наведите, чтобы увидеть детали."
1803
+ },
1804
+ "summary.title": {
1805
+ one: "{count} аннотация",
1806
+ few: "{count} аннотации",
1807
+ many: "{count} аннотаций"
1808
+ },
1809
+ "summary.removeAll": "Удалить все аннотации",
1810
+ "chip.annotation": "Аннотация {index}"
1811
+ },
1812
+ en: {
1813
+ "common.selectedText": "Selected text",
1814
+ "common.commentPlaceholder": "Add an optional comment…",
1815
+ "common.cancel": "Cancel",
1816
+ "common.save": "Save",
1817
+ "toolbar.aria": "Selection actions",
1818
+ "toolbar.commentHint": "Add annotation…",
1819
+ "toolbar.copy": "Copy",
1820
+ "toolbar.copyFailed": "Copy failed",
1821
+ "toolbar.addToTask": "Add to task",
1822
+ "toolbar.commentDialogAria": "Add an optional comment",
1823
+ "toolbar.skipComment": "Skip comment",
1824
+ "toolbar.addComment": "Add comment",
1825
+ "card.commentSubtitle": "Comment · {comment}",
1826
+ "card.ariaIndexed": "Annotation {index}",
1827
+ "card.ariaPlain": "Selection annotation",
1828
+ "card.editComment": "Edit comment",
1829
+ "card.remove": "Remove annotation",
1830
+ "summary.hint": "Hover to view annotations",
1831
+ "summary.dialogAria": "Annotation details: {count}",
1832
+ "summary.edit": "Edit annotation {index}",
1833
+ "summary.remove": "Remove annotation {index}",
1834
+ "summary.userComment": "User comment",
1835
+ "summary.noComment": "No comment added",
1836
+ "summary.aria": {
1837
+ one: "{count} annotation. Hover to view details.",
1838
+ other: "{count} annotations. Hover to view details."
1839
+ },
1840
+ "summary.title": {
1841
+ one: "{count} annotation",
1842
+ other: "{count} annotations"
1843
+ },
1844
+ "summary.removeAll": "Remove all annotations",
1845
+ "chip.annotation": "Annotation {index}"
1846
+ },
1847
+ zh: {
1848
+ "common.selectedText": "选中的文本",
1849
+ "common.commentPlaceholder": "添加可选评论…",
1850
+ "common.cancel": "取消",
1851
+ "common.save": "保存",
1852
+ "toolbar.aria": "划词操作",
1853
+ "toolbar.commentHint": "添加批注…",
1854
+ "toolbar.copy": "复制",
1855
+ "toolbar.copyFailed": "复制失败",
1856
+ "toolbar.addToTask": "添加到任务",
1857
+ "toolbar.commentDialogAria": "添加可选评论",
1858
+ "toolbar.skipComment": "暂不评论",
1859
+ "toolbar.addComment": "添加评论",
1860
+ "card.commentSubtitle": "评论 · {comment}",
1861
+ "card.ariaIndexed": "批注 {index}",
1862
+ "card.ariaPlain": "划词批注",
1863
+ "card.editComment": "编辑评论",
1864
+ "card.remove": "移除批注",
1865
+ "summary.hint": "悬停查看批注",
1866
+ "summary.dialogAria": "{count} 条划词批注详情",
1867
+ "summary.edit": "编辑批注 {index}",
1868
+ "summary.remove": "移除批注 {index}",
1869
+ "summary.userComment": "用户评论",
1870
+ "summary.noComment": "未添加评论",
1871
+ "summary.aria": { other: "{count} 条划词批注。悬停查看详情。" },
1872
+ "summary.title": { other: "{count} 条批注" },
1873
+ "summary.removeAll": "移除全部划词批注",
1874
+ "chip.annotation": "批注 {index}"
1875
+ }
1876
+ };
1877
+ let cached = null;
1878
+ function matchLocale(tag) {
1879
+ const lower = tag.toLowerCase();
1880
+ for (const locale of LOCALES) if (lower === locale || lower.startsWith(`${locale}-`) || lower.startsWith(`${locale}_`)) return locale;
1881
+ return null;
1882
+ }
1883
+ function localStore() {
1884
+ return globalThis.localStorage;
1885
+ }
1886
+ function readOverride() {
1887
+ try {
1888
+ const raw = localStore()?.getItem(STORAGE_KEY);
1889
+ return raw ? matchLocale(raw) : null;
1890
+ } catch {
1891
+ return null;
1892
+ }
1893
+ }
1894
+ function detectLocale() {
1895
+ const override = readOverride();
1896
+ if (override) return override;
1897
+ const nav = globalThis.navigator;
1898
+ const tags = nav ? [...nav.languages ?? [], nav.language].filter(Boolean) : [];
1899
+ for (const tag of tags) {
1900
+ const hit = matchLocale(tag);
1901
+ if (hit) return hit;
1902
+ }
1903
+ return "en";
1904
+ }
1905
+ /** Active locale (resolved once, then cached). */
1906
+ function getLocale() {
1907
+ cached ??= detectLocale();
1908
+ return cached;
1909
+ }
1910
+ function pluralize(forms, locale, count) {
1911
+ if (locale === "ru") {
1912
+ const mod10 = count % 10;
1913
+ const mod100 = count % 100;
1914
+ if (mod10 === 1 && mod100 !== 11) return forms.one ?? forms.other ?? "";
1915
+ if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) return forms.few ?? forms.other ?? "";
1916
+ return forms.many ?? forms.other ?? "";
1917
+ }
1918
+ if (locale === "en") return (count === 1 ? forms.one : void 0) ?? forms.other ?? "";
1919
+ return forms.other ?? forms.many ?? "";
1920
+ }
1921
+ function interpolate(template, params) {
1922
+ return template.replace(/\{(\w+)\}/g, (whole, name) => name in params ? String(params[name]) : whole);
1923
+ }
1924
+ /**
1925
+ * Translate a key for the active locale.
1926
+ * `{name}` placeholders are filled from `params`; a numeric `count` also
1927
+ * selects the right plural form when the message is a `PluralForms` object.
1928
+ */
1929
+ function t(key, params = {}) {
1930
+ const locale = getLocale();
1931
+ const value = MESSAGES[locale][key] ?? MESSAGES.zh[key] ?? key;
1932
+ return interpolate(typeof value === "string" ? value : pluralize(value, locale, typeof params.count === "number" ? params.count : 0), params);
1933
+ }
1934
+ //#endregion
1707
1935
  //#region src/client/ui/AnnotationCardView.tsx
1936
+ function AnnotationCardView({ title, text, comment, variant, index, onRemove, onEditComment }) {
1937
+ const composer = variant === "composer";
1938
+ const subtitle = comment ? composer ? t("card.commentSubtitle", { comment }) : comment : composer ? t("common.selectedText") : t("summary.noComment");
1939
+ return (0, react_jsx_runtime.jsxs)("div", {
1940
+ className: composer ? styles.card : styles.tCard,
1941
+ role: "group",
1942
+ "aria-label": index ? t("card.ariaIndexed", { index }) : t("card.ariaPlain"),
1943
+ children: [
1944
+ (0, react_jsx_runtime.jsx)("div", {
1945
+ className: composer ? styles.cardIcon : styles.tCardIcon,
1946
+ "aria-hidden": true,
1947
+ children: index ? String(index) : "AI"
1948
+ }),
1949
+ (0, react_jsx_runtime.jsxs)("div", {
1950
+ className: composer ? styles.cardBody : styles.tCardBody,
1951
+ children: [(0, react_jsx_runtime.jsx)("div", {
1952
+ className: composer ? styles.cardTitle : styles.tCardTitle,
1953
+ title: text,
1954
+ children: title
1955
+ }), (0, react_jsx_runtime.jsx)("div", {
1956
+ className: composer ? styles.cardSubtitle : styles.tCardSubtitle,
1957
+ ...comment ? {
1958
+ title: comment,
1959
+ "data-dsq-has-comment": "true"
1960
+ } : {},
1961
+ children: subtitle
1962
+ })]
1963
+ }),
1964
+ composer && onEditComment ? (0, react_jsx_runtime.jsx)("button", {
1965
+ type: "button",
1966
+ className: styles.cardAction,
1967
+ "aria-label": t("card.editComment"),
1968
+ onClick: onEditComment,
1969
+ children: "✎"
1970
+ }) : null,
1971
+ composer && onRemove ? (0, react_jsx_runtime.jsx)("button", {
1972
+ type: "button",
1973
+ className: styles.cardClose,
1974
+ "aria-label": t("card.remove"),
1975
+ onClick: onRemove,
1976
+ children: "×"
1977
+ }) : null
1978
+ ]
1979
+ });
1980
+ }
1708
1981
  /** Grace period so pointer transit between the summary and the panel never closes it. */
1709
1982
  const CLOSE_DELAY = 320;
1710
1983
  /** After a viewport change, wait this long before position-checking the pointer. */
@@ -1720,7 +1993,7 @@ const SETTLE_DELAY = 600;
1720
1993
  * other cancels. Comment editing happens inline in the list — the panel stays
1721
1994
  * put and auto-close is suppressed while an edit is open.
1722
1995
  */
1723
- function AnnotationSummary({ count, items, onRemoveAll, onItemSelect, onItemRemove, onItemEdit, hint = "悬停查看批注" }) {
1996
+ function AnnotationSummary({ count, items, onRemoveAll, onItemSelect, onItemRemove, onItemEdit, hint = t("summary.hint") }) {
1724
1997
  const rootRef = (0, react.useRef)(null);
1725
1998
  const popRef = (0, react.useRef)(null);
1726
1999
  const closeTimerRef = (0, react.useRef)(null);
@@ -1876,7 +2149,7 @@ function AnnotationSummary({ count, items, onRemoveAll, onItemSelect, onItemRemo
1876
2149
  ref: popRef,
1877
2150
  className: `${styles.summaryHover} ${styles.summaryHoverOpen}`,
1878
2151
  role: "dialog",
1879
- "aria-label": `${count} 条划词批注详情`,
2152
+ "aria-label": t("summary.dialogAria", { count }),
1880
2153
  onPointerEnter: openPop,
1881
2154
  onPointerLeave,
1882
2155
  children: (0, react_jsx_runtime.jsx)("div", {
@@ -1895,20 +2168,20 @@ function AnnotationSummary({ count, items, onRemoveAll, onItemSelect, onItemRemo
1895
2168
  }),
1896
2169
  (0, react_jsx_runtime.jsx)("div", {
1897
2170
  className: styles.summaryItemLabel,
1898
- children: "选中的文本"
2171
+ children: t("common.selectedText")
1899
2172
  }),
1900
2173
  !isEditing && (onItemRemove || onItemEdit) ? (0, react_jsx_runtime.jsxs)("div", {
1901
2174
  className: styles.summaryItemActions,
1902
2175
  children: [onItemEdit ? (0, react_jsx_runtime.jsx)("button", {
1903
2176
  type: "button",
1904
2177
  className: styles.summaryItemAction,
1905
- "aria-label": `编辑批注 ${i + 1}`,
2178
+ "aria-label": t("summary.edit", { index: i + 1 }),
1906
2179
  onClick: () => startEdit(i),
1907
2180
  children: (0, react_jsx_runtime.jsx)(LucideIcon, { name: "pencil" })
1908
2181
  }) : null, onItemRemove ? (0, react_jsx_runtime.jsx)("button", {
1909
2182
  type: "button",
1910
2183
  className: styles.summaryItemAction,
1911
- "aria-label": `移除批注 ${i + 1}`,
2184
+ "aria-label": t("summary.remove", { index: i + 1 }),
1912
2185
  onClick: () => onItemRemove(i, item),
1913
2186
  children: (0, react_jsx_runtime.jsx)(LucideIcon, { name: "trash-2" })
1914
2187
  }) : null]
@@ -1923,12 +2196,12 @@ function AnnotationSummary({ count, items, onRemoveAll, onItemSelect, onItemRemo
1923
2196
  }),
1924
2197
  (0, react_jsx_runtime.jsx)("div", {
1925
2198
  className: styles.summaryItemLabel,
1926
- children: "用户评论"
2199
+ children: t("summary.userComment")
1927
2200
  }),
1928
2201
  (0, react_jsx_runtime.jsx)("textarea", {
1929
2202
  ref: editInputRef,
1930
2203
  className: styles.annEditInput,
1931
- placeholder: "添加可选评论…",
2204
+ placeholder: t("common.commentPlaceholder"),
1932
2205
  value: editDraft,
1933
2206
  onChange: (e) => setEditDraft(e.target.value),
1934
2207
  onKeyDown: (e) => {
@@ -1949,12 +2222,12 @@ function AnnotationSummary({ count, items, onRemoveAll, onItemSelect, onItemRemo
1949
2222
  type: "button",
1950
2223
  className: styles.annEditCancel,
1951
2224
  onClick: () => setEditing(null),
1952
- children: "取消"
2225
+ children: t("common.cancel")
1953
2226
  }), (0, react_jsx_runtime.jsx)("button", {
1954
2227
  type: "button",
1955
2228
  className: styles.annEditSave,
1956
2229
  onClick: commitEdit,
1957
- children: "保存"
2230
+ children: t("common.save")
1958
2231
  })]
1959
2232
  })
1960
2233
  ]
@@ -1974,11 +2247,11 @@ function AnnotationSummary({ count, items, onRemoveAll, onItemSelect, onItemRemo
1974
2247
  }),
1975
2248
  (0, react_jsx_runtime.jsx)("div", {
1976
2249
  className: styles.summaryItemLabel,
1977
- children: "用户评论"
2250
+ children: t("summary.userComment")
1978
2251
  }),
1979
2252
  (0, react_jsx_runtime.jsx)("div", {
1980
2253
  className: item.comment ? styles.summaryItemText : `${styles.summaryItemText} ${styles.summaryItemTextEmpty}`,
1981
- children: item.comment || "未添加评论"
2254
+ children: item.comment || t("summary.noComment")
1982
2255
  })
1983
2256
  ]
1984
2257
  })
@@ -1992,7 +2265,7 @@ function AnnotationSummary({ count, items, onRemoveAll, onItemSelect, onItemRemo
1992
2265
  className: styles.summary,
1993
2266
  tabIndex: 0,
1994
2267
  "data-selection-annotation-summary": "true",
1995
- "aria-label": `${count} 条划词批注。悬停查看详情。`,
2268
+ "aria-label": t("summary.aria", { count }),
1996
2269
  "aria-expanded": open,
1997
2270
  onPointerEnter,
1998
2271
  onPointerLeave,
@@ -2009,7 +2282,7 @@ function AnnotationSummary({ count, items, onRemoveAll, onItemSelect, onItemRemo
2009
2282
  className: styles.summaryBody,
2010
2283
  children: [(0, react_jsx_runtime.jsx)("div", {
2011
2284
  className: styles.summaryTitle,
2012
- children: `${count} 条批注`
2285
+ children: t("summary.title", { count })
2013
2286
  }), (0, react_jsx_runtime.jsx)("div", {
2014
2287
  className: styles.summaryHint,
2015
2288
  children: hint
@@ -2018,7 +2291,7 @@ function AnnotationSummary({ count, items, onRemoveAll, onItemSelect, onItemRemo
2018
2291
  onRemoveAll ? (0, react_jsx_runtime.jsx)("button", {
2019
2292
  type: "button",
2020
2293
  className: styles.cardClose,
2021
- "aria-label": "移除全部划词批注",
2294
+ "aria-label": t("summary.removeAll"),
2022
2295
  onClick: onRemoveAll,
2023
2296
  children: (0, react_jsx_runtime.jsx)(LucideIcon, {
2024
2297
  name: "x",
@@ -2310,6 +2583,8 @@ async function copyText(text) {
2310
2583
  }
2311
2584
  //#endregion
2312
2585
  //#region src/client/SelectionToolbar.tsx
2586
+ /** Cap for the auto-growing comment box; beyond this it scrolls internally. */
2587
+ const COMMENT_MAX_HEIGHT = 220;
2313
2588
  function SelectionToolbar({ sessionId }) {
2314
2589
  const toolbarRef = (0, react.useRef)(null);
2315
2590
  const commentWrapRef = (0, react.useRef)(null);
@@ -2376,19 +2651,24 @@ function SelectionToolbar({ sessionId }) {
2376
2651
  if (dragging) return;
2377
2652
  schedule();
2378
2653
  };
2654
+ const onScroll = (event) => {
2655
+ const target = event.target;
2656
+ if (target instanceof Node && (toolbarRef.current?.contains(target) === true || commentWrapRef.current?.contains(target) === true)) return;
2657
+ hide();
2658
+ };
2379
2659
  document.addEventListener("pointerdown", onPointerDown, true);
2380
2660
  document.addEventListener("pointerup", onPointerUp, true);
2381
2661
  document.addEventListener("keyup", schedule, true);
2382
2662
  document.addEventListener("selectionchange", onSelectionChange);
2383
2663
  window.addEventListener("resize", hide);
2384
- document.addEventListener("scroll", hide, true);
2664
+ document.addEventListener("scroll", onScroll, true);
2385
2665
  return () => {
2386
2666
  document.removeEventListener("pointerdown", onPointerDown, true);
2387
2667
  document.removeEventListener("pointerup", onPointerUp, true);
2388
2668
  document.removeEventListener("keyup", schedule, true);
2389
2669
  document.removeEventListener("selectionchange", onSelectionChange);
2390
2670
  window.removeEventListener("resize", hide);
2391
- document.removeEventListener("scroll", hide, true);
2671
+ document.removeEventListener("scroll", onScroll, true);
2392
2672
  };
2393
2673
  }, [hide, refresh]);
2394
2674
  const placeCommentPop = (0, react.useCallback)(() => {
@@ -2415,6 +2695,18 @@ function SelectionToolbar({ sessionId }) {
2415
2695
  commentRef.current?.focus();
2416
2696
  }
2417
2697
  }, [phase, placeCommentPop]);
2698
+ (0, react.useEffect)(() => {
2699
+ if (phase !== "comment") return;
2700
+ const area = commentRef.current;
2701
+ if (!area) return;
2702
+ area.style.height = "auto";
2703
+ area.style.height = `${Math.min(area.scrollHeight, COMMENT_MAX_HEIGHT)}px`;
2704
+ placeCommentPop();
2705
+ }, [
2706
+ phase,
2707
+ comment,
2708
+ placeCommentPop
2709
+ ]);
2418
2710
  const handleToolbarPointerDown = (0, react.useCallback)((event) => {
2419
2711
  event.preventDefault();
2420
2712
  event.stopPropagation();
@@ -2427,7 +2719,7 @@ function SelectionToolbar({ sessionId }) {
2427
2719
  clearNativeSelection();
2428
2720
  hide();
2429
2721
  } catch {
2430
- setStatus("复制失败");
2722
+ setStatus(t("toolbar.copyFailed"));
2431
2723
  }
2432
2724
  }, [hide]);
2433
2725
  const openComment = (0, react.useCallback)(() => {
@@ -2436,7 +2728,7 @@ function SelectionToolbar({ sessionId }) {
2436
2728
  setComment("");
2437
2729
  setStatus(null);
2438
2730
  }, []);
2439
- /** Quick-add without opening the comment step (double-tap / “暂不评论”). */
2731
+ /** Quick-add without opening the comment step (double-tap / “Skip comment”). */
2440
2732
  const commitAnnotation = (0, react.useCallback)((body) => {
2441
2733
  const snapshot = activeRef.current;
2442
2734
  if (!snapshot) {
@@ -2471,7 +2763,7 @@ function SelectionToolbar({ sessionId }) {
2471
2763
  className: styles.toolbar,
2472
2764
  style: toolbarStyle,
2473
2765
  role: "toolbar",
2474
- "aria-label": "划词操作",
2766
+ "aria-label": t("toolbar.aria"),
2475
2767
  "data-dsq-toolbar": "true",
2476
2768
  onPointerDown: handleToolbarPointerDown,
2477
2769
  children: phase === "comment" ? (0, react_jsx_runtime.jsx)("div", {
@@ -2486,7 +2778,7 @@ function SelectionToolbar({ sessionId }) {
2486
2778
  fontSize: 13,
2487
2779
  opacity: .8
2488
2780
  },
2489
- children: "添加批注…"
2781
+ children: t("toolbar.commentHint")
2490
2782
  })
2491
2783
  }) : (0, react_jsx_runtime.jsxs)("div", {
2492
2784
  style: {
@@ -2502,7 +2794,7 @@ function SelectionToolbar({ sessionId }) {
2502
2794
  children: [(0, react_jsx_runtime.jsx)(LucideIcon, {
2503
2795
  name: "copy",
2504
2796
  size: 14
2505
- }), "复制"]
2797
+ }), t("toolbar.copy")]
2506
2798
  }),
2507
2799
  (0, react_jsx_runtime.jsx)("span", {
2508
2800
  className: styles.divider,
@@ -2515,7 +2807,7 @@ function SelectionToolbar({ sessionId }) {
2515
2807
  children: [(0, react_jsx_runtime.jsx)(LucideIcon, {
2516
2808
  name: "plus",
2517
2809
  size: 14
2518
- }), "添加到任务"]
2810
+ }), t("toolbar.addToTask")]
2519
2811
  })
2520
2812
  ]
2521
2813
  })
@@ -2525,12 +2817,12 @@ function SelectionToolbar({ sessionId }) {
2525
2817
  className: styles.commentPop,
2526
2818
  style: void 0,
2527
2819
  role: "dialog",
2528
- "aria-label": "添加可选评论",
2820
+ "aria-label": t("toolbar.commentDialogAria"),
2529
2821
  onPointerDown: handleToolbarPointerDown,
2530
2822
  children: [(0, react_jsx_runtime.jsx)("textarea", {
2531
2823
  ref: commentRef,
2532
2824
  className: styles.commentInput,
2533
- placeholder: "添加可选评论…",
2825
+ placeholder: t("common.commentPlaceholder"),
2534
2826
  value: comment,
2535
2827
  onChange: (e) => setComment(e.target.value),
2536
2828
  onKeyDown: (e) => {
@@ -2550,13 +2842,13 @@ function SelectionToolbar({ sessionId }) {
2550
2842
  type: "button",
2551
2843
  className: styles.button,
2552
2844
  onClick: () => commitAnnotation(void 0),
2553
- children: "暂不评论"
2845
+ children: t("toolbar.skipComment")
2554
2846
  }), (0, react_jsx_runtime.jsx)("button", {
2555
2847
  type: "button",
2556
2848
  className: styles.button,
2557
2849
  disabled: !comment.trim(),
2558
2850
  onClick: () => commitAnnotation(comment),
2559
- children: "添加评论"
2851
+ children: t("toolbar.addComment")
2560
2852
  })]
2561
2853
  })]
2562
2854
  }) : null,
@@ -2581,11 +2873,14 @@ function decorateTextNode(node) {
2581
2873
  const parent = node.parentElement;
2582
2874
  if (!parent || parent.closest(`[data-${DECO}]`)) return;
2583
2875
  if (parent.closest("pre, code, [data-dsq-toolbar]")) return;
2876
+ if (parent.closest("textarea, input, [data-lexical-editor=\"true\"]")) return;
2584
2877
  const frag = document.createDocumentFragment();
2585
2878
  let last = 0;
2879
+ let matched = false;
2586
2880
  const re = new RegExp(ANNOTATION_DIRECTIVE_RE.source, "g");
2587
2881
  let m;
2588
2882
  while ((m = re.exec(value)) !== null) {
2883
+ matched = true;
2589
2884
  if (m.index > last) frag.append(document.createTextNode(value.slice(last, m.index)));
2590
2885
  const index = Number(m[1]);
2591
2886
  const chip = document.createElement("button");
@@ -2593,7 +2888,7 @@ function decorateTextNode(node) {
2593
2888
  chip.className = "dsq_annDirective";
2594
2889
  chip.dataset[DECO] = String(index);
2595
2890
  chip.setAttribute("data-dsq-deco", "true");
2596
- chip.textContent = `批注 ${index}`;
2891
+ chip.textContent = t("chip.annotation", { index });
2597
2892
  chip.addEventListener("click", () => {
2598
2893
  const mark = findAnnotationMarkBefore(index, chip);
2599
2894
  if (mark) flashAnnotationMark(mark);
@@ -2601,6 +2896,7 @@ function decorateTextNode(node) {
2601
2896
  frag.append(chip);
2602
2897
  last = m.index + m[0].length;
2603
2898
  }
2899
+ if (!matched) return;
2604
2900
  if (last < value.length) frag.append(document.createTextNode(value.slice(last)));
2605
2901
  parent.replaceChild(frag, node);
2606
2902
  }
@@ -2635,7 +2931,16 @@ function watchAnnotationDirectives(root) {
2635
2931
  }
2636
2932
  //#endregion
2637
2933
  //#region src/client/transcript/TranscriptQuoteCard.tsx
2638
- /** History-side annotation summary — same chrome as the composer. */
2934
+ /**
2935
+ * History-side cards for one user message's annotations.
2936
+ *
2937
+ * One card per annotation, each showing the selected text **and** the user's
2938
+ * comment. The comment is how the user recognizes their own annotation, so it
2939
+ * belongs on the visible card — not behind a hover panel that has to be
2940
+ * discovered. Clicking a card scrolls back to the marked text in the source
2941
+ * message; the ordinal badge matches the `:dsh-annotation{index="N"}` the model
2942
+ * refers to.
2943
+ */
2639
2944
  function TranscriptQuoteCard({ node }) {
2640
2945
  ensureToolbarStyles();
2641
2946
  const stackRef = (0, react.useRef)(null);
@@ -2652,28 +2957,37 @@ function TranscriptQuoteCard({ node }) {
2652
2957
  endOffset: quote.endOffset
2653
2958
  })));
2654
2959
  }, [quotes]);
2655
- const onItemSelect = (0, react.useCallback)((index, item) => {
2656
- scrollToAnnotation(index + 1, item.text, item.id);
2657
- }, []);
2658
2960
  const markIdOf = (0, react.useCallback)((index) => {
2659
2961
  const ownerKey = stackRef.current?.closest("[data-chat-flow-key]")?.getAttribute("data-chat-flow-key");
2660
2962
  return ownerKey ? `dqsm_${ownerKey}_${index}` : void 0;
2661
2963
  }, []);
2964
+ const onSelect = (0, react.useCallback)((index, text) => {
2965
+ scrollToAnnotation(index + 1, text, markIdOf(index));
2966
+ }, [markIdOf]);
2662
2967
  if (quotes.length === 0) return null;
2663
2968
  return (0, react_jsx_runtime.jsx)("div", {
2664
2969
  ref: stackRef,
2665
2970
  className: styles.tCardStack,
2666
2971
  "data-dsq-deco": "true",
2667
- children: (0, react_jsx_runtime.jsx)(AnnotationSummary, {
2668
- count: quotes.length,
2669
- items: quotes.map((quote, index) => ({
2670
- id: markIdOf(index),
2972
+ children: quotes.map((quote, index) => (0, react_jsx_runtime.jsx)("div", {
2973
+ className: styles.tCardButton,
2974
+ role: "button",
2975
+ tabIndex: 0,
2976
+ "aria-label": quote.title,
2977
+ onClick: () => onSelect(index, quote.text),
2978
+ onKeyDown: (event) => {
2979
+ if (event.key !== "Enter" && event.key !== " ") return;
2980
+ event.preventDefault();
2981
+ onSelect(index, quote.text);
2982
+ },
2983
+ children: (0, react_jsx_runtime.jsx)(AnnotationCardView, {
2984
+ variant: "transcript",
2671
2985
  title: quote.title,
2672
2986
  text: quote.text,
2673
- comment: quote.comment || void 0
2674
- })),
2675
- onItemSelect
2676
- })
2987
+ comment: quote.comment || void 0,
2988
+ index: index + 1
2989
+ })
2990
+ }, `ann-${index}`))
2677
2991
  });
2678
2992
  }
2679
2993
  //#endregion
@@ -2691,101 +3005,69 @@ function contentImages(content) {
2691
3005
  }
2692
3006
  /**
2693
3007
  * Replacement for the built-in `user` Chat node view.
2694
- * Strips the `<response-annotations>` protocol block from the bubble.
3008
+ *
3009
+ * Strips the `<response-annotations>` protocol block from the bubble **and**
3010
+ * renders one card per annotation right under it.
3011
+ *
3012
+ * Why the cards live here instead of on a `select-quote` Chat node:
3013
+ * `conversation.chat.node` is gated by the shipped `TURN_PROCESS_INDEPENDENT_KINDS`
3014
+ * allowlist (system-prompt, user, steering, turn-trigger, turn-process,
3015
+ * turn-error, turn-max-tokens, turn-tail). A node whose kind is not on that list
3016
+ * is classified as a turn-process member and `hidden` inside the collapsed
3017
+ * process disclosure of a completed turn — mounted, then invisible. `user` is on
3018
+ * the list, so rendering the cards inside this component is the only placement
3019
+ * that survives turn folding.
2695
3020
  */
2696
3021
  function UserMessageDisplay({ node, renderMessageImages }) {
2697
3022
  ensureToolbarStyles();
2698
3023
  const raw = contentBlocksToText(node.data?.content);
2699
3024
  const scanned = scanAnnotatedMessage(raw);
2700
3025
  const text = scanned.annotations.length > 0 ? scanned.rest : displayTextWithoutQuote(raw).trim();
3026
+ const quotes = scanned.annotations;
2701
3027
  const images = contentImages(node.data?.content);
2702
- if (!text && images.length === 0) return null;
3028
+ if (!text && images.length === 0 && quotes.length === 0) return null;
2703
3029
  return (0, react_jsx_runtime.jsx)("div", {
2704
3030
  className: styles.userRow,
2705
3031
  children: (0, react_jsx_runtime.jsxs)("div", {
2706
3032
  className: styles.userStack,
2707
- children: [images.length > 0 && renderMessageImages !== void 0 ? (0, react_jsx_runtime.jsx)("div", {
2708
- className: styles.userImages,
2709
- "data-message-attachments": true,
2710
- children: renderMessageImages({
2711
- images,
2712
- align: "end",
2713
- compact: images.length > 1
2714
- })
2715
- }, "images") : null, text ? (0, react_jsx_runtime.jsx)("div", {
2716
- className: styles.userBubble,
2717
- children: text
2718
- }, "bubble") : null]
3033
+ children: [
3034
+ images.length > 0 && renderMessageImages !== void 0 ? (0, react_jsx_runtime.jsx)("div", {
3035
+ className: styles.userImages,
3036
+ "data-message-attachments": true,
3037
+ children: renderMessageImages({
3038
+ images,
3039
+ align: "end",
3040
+ compact: images.length > 1
3041
+ })
3042
+ }, "images") : null,
3043
+ text ? (0, react_jsx_runtime.jsx)("div", {
3044
+ className: styles.userBubble,
3045
+ children: text
3046
+ }, "bubble") : null,
3047
+ quotes.length > 0 ? (0, react_jsx_runtime.jsx)(TranscriptQuoteCard, { node: { data: { quotes } } }, "quotes") : null
3048
+ ]
2719
3049
  })
2720
3050
  });
2721
3051
  }
2722
3052
  //#endregion
2723
- //#region src/client/transcript/transcript-node.ts
2724
- const KIND = "select-quote";
2725
- function createSelectQuoteDefinition() {
2726
- return {
2727
- kind: KIND,
2728
- target: "chat",
2729
- match(event) {
2730
- if (event.type !== "user/message") return null;
2731
- if (parseAnnotatedMessage(extractUserText(event)).length === 0) return null;
2732
- return {
2733
- id: `${KIND}-${event.seq ?? 0}`,
2734
- role: "start"
2735
- };
2736
- },
2737
- start(_context, match) {
2738
- const quotes = parseAnnotatedMessage(extractUserText(match.event));
2739
- if (quotes.length === 0) throw new Error("select-quote: missing annotation payload on start");
2740
- return {
2741
- quotes,
2742
- seq: match.event.seq ?? 0
2743
- };
2744
- },
2745
- update(context) {
2746
- return context.state;
2747
- },
2748
- buildViewNode(context) {
2749
- if (context.state === void 0) return null;
2750
- const anchor = context.start?.event.seq ?? context.matches?.[0]?.event.seq ?? context.state.seq ?? 0;
2751
- const location = context.start?.location ?? context.matches?.[0]?.location ?? { kind: "unresolved" };
2752
- return {
2753
- key: context.key,
2754
- kind: KIND,
2755
- id: context.id,
2756
- target: "chat",
2757
- anchorSeq: anchor,
2758
- location,
2759
- visibility: "visible",
2760
- data: context.state
2761
- };
2762
- }
2763
- };
2764
- }
2765
- function registerSelectQuoteNode(ctx) {
2766
- const uiConversation = ctx.uiConversation;
2767
- if (!uiConversation) {
2768
- console.warn("[dsh-select-quote] uiConversation unavailable; transcript card disabled");
2769
- return;
2770
- }
2771
- ctx.effect(() => uiConversation.events.register(createSelectQuoteDefinition()), "dsh-select-quote: conversation node definition");
2772
- }
2773
- //#endregion
2774
3053
  //#region src/client/index.tsx
2775
3054
  /**
2776
3055
  * Browser half of dsh-select-quote.
2777
3056
  *
2778
3057
  * Selection toolbar is root-scoped (`shell.overlay`) so transcript selections
2779
3058
  * are always observed. Annotation summary stays on the session composer.
3059
+ *
3060
+ * History cards render **inside** the `user` Chat node view, not on a
3061
+ * `select-quote` Chat node. `conversation.chat.node` is gated by the shipped
3062
+ * `TURN_PROCESS_INDEPENDENT_KINDS` allowlist (system-prompt, user, steering,
3063
+ * turn-trigger, turn-process, turn-error, turn-max-tokens, turn-tail): any other
3064
+ * kind is classified as a turn-process member and `hidden` when the completed
3065
+ * turn's process disclosure is folded — mounted, but invisible. `user` is on
3066
+ * that allowlist, so the bubble is the only placement that is reliably visible.
2780
3067
  */
2781
- const inject = [
2782
- "slots",
2783
- "sessions",
2784
- "uiConversation"
2785
- ];
3068
+ const inject = ["slots", "sessions"];
2786
3069
  function apply(ctx) {
2787
3070
  ensureToolbarStyles();
2788
- registerSelectQuoteNode(ctx);
2789
3071
  ctx.effect(() => watchAnnotationDirectives(), "dsh-select-quote: annotation directives");
2790
3072
  ctx.effect(() => watchAnnotationMarks(), "dsh-select-quote: annotation marks");
2791
3073
  ctx.effect(() => {
@@ -2814,10 +3096,6 @@ function apply(ctx) {
2814
3096
  }, SessionBridge);
2815
3097
  });
2816
3098
  ctx.slots.inject("conversation.chat.node", () => {
2817
- ctx.slots.register({
2818
- name: "conversation.chat.node",
2819
- key: "select-quote"
2820
- }, TranscriptQuoteCard);
2821
3099
  ctx.slots.register({
2822
3100
  name: "conversation.chat.node",
2823
3101
  key: "user",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-select-quote",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
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",
@@ -68,6 +68,7 @@
68
68
  "@types/react-dom": "~18.3",
69
69
  "react": "^18.2.0",
70
70
  "react-dom": "^18.2.0",
71
+ "tsdown": "0.23.0",
71
72
  "typescript": "^5.8.0"
72
73
  }
73
74
  }