dsh-select-quote 0.2.0 → 0.3.1
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 +57 -30
- package/lib/client.js +476 -131
- package/package.json +5 -2
package/README.md
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
# dsh-select-quote
|
|
2
2
|
|
|
3
|
-
[DeepSeek Harness](https://deepseek-harness.github.io/deepseek-harness/guide/quickstart)
|
|
3
|
+
[DeepSeek Harness](https://deepseek-harness.github.io/deepseek-harness/guide/quickstart) 的客户端插件:在对话里**划词加批注**——选中的原文连同你的评论一起交给模型,并让它在回答里明确指出说的是哪一段。
|
|
4
4
|
|
|
5
5
|
- **划词工具条**:选中对话区任意文本,浮出「复制 / 添加到任务」;拖拽结束才出现,不挡操作
|
|
6
|
-
-
|
|
7
|
-
- **正文标记**:被划中的文字在原消息里留下虚线下划线 +
|
|
8
|
-
-
|
|
9
|
-
-
|
|
6
|
+
- **批注卡片**:添加后输入框内出现摘要卡片(可多条),草稿里不会出现任何协议文本
|
|
7
|
+
- **正文标记**:被划中的文字在原消息里留下虚线下划线 + 序号角标,点卡片条目会滚回那段文字
|
|
8
|
+
- **模型回指**:模型回答里指向某条批注的位置,渲染成可点击的 chip
|
|
9
|
+
- **对话记录**:已发送消息里的批注渲染成卡片,用户气泡只剩你打的问题
|
|
10
|
+
- **界面语言**:跟随 dsh 自己的语言设置,简体中文 / English / Русский
|
|
10
11
|
|
|
11
12
|

|
|
12
13
|
|
|
@@ -14,59 +15,85 @@
|
|
|
14
15
|
|
|
15
16
|
---
|
|
16
17
|
|
|
17
|
-
## 兼容性
|
|
18
|
-
|
|
19
|
-
| 项 | 值 |
|
|
20
|
-
|---|---|
|
|
21
|
-
| dsh | `0.1.7-rc.1`(2026-09-24 实测:划词、批注卡片、正文标记、对话记录渲染全通) |
|
|
22
|
-
| profile | web(`dsh web`) |
|
|
23
|
-
| 运行时 | 只有浏览器半有业务;Node 半仅用于让包被 Loader 挂载 |
|
|
24
|
-
|
|
25
|
-
实现依赖若干产品内部约定(槽位名、composer DOM、深色主题属性等),升级 dsh 后请先跑 `npm run check`,再照 [`docs/internals.md`](docs/internals.md) 末尾的复核清单人工回归一遍。
|
|
26
|
-
|
|
27
18
|
## 安装
|
|
28
19
|
|
|
29
|
-
|
|
20
|
+
网页版:
|
|
30
21
|
|
|
31
22
|
```bash
|
|
32
23
|
dsh plugin --profile web add dsh-select-quote
|
|
33
24
|
dsh --profile web
|
|
34
25
|
```
|
|
35
26
|
|
|
36
|
-
|
|
27
|
+
桌面版:在应用内输入`dsh-select-quote`安装,或者插件页搜索 `dsh-select-quote` 安装,重启客户端生效。
|
|
37
28
|
|
|
38
|
-
|
|
29
|
+
改了代码想立刻验证,用本地 checkout 加载:
|
|
39
30
|
|
|
40
31
|
```bash
|
|
41
|
-
git clone
|
|
32
|
+
git clone https://github.com/latte03/dsh-select-quote && cd dsh-select-quote
|
|
42
33
|
npm install && npm run build
|
|
43
34
|
dsh web --patch "$PWD/dev.patch.yml"
|
|
44
35
|
```
|
|
45
36
|
|
|
46
|
-
打开 `http://127.0.0.1:3080`,在对话消息里划词即可看到工具条。`dev.patch.yml`
|
|
37
|
+
打开 `http://127.0.0.1:3080`,在对话消息里划词即可看到工具条。`dev.patch.yml` 里的插件路径必须是绝对路径,换机器或换目录时改那一行;该文件不随 npm 包发布。
|
|
47
38
|
|
|
48
39
|
## 用法
|
|
49
40
|
|
|
50
|
-
1.
|
|
51
|
-
2.
|
|
52
|
-
3.
|
|
53
|
-
4.
|
|
54
|
-
5.
|
|
41
|
+
1. 在对话区选中一段文本,点「添加到任务」
|
|
42
|
+
2. 在弹出的评论框里写一句想说的话(可留空,只划不评),`⌘/Ctrl + Enter` 确认
|
|
43
|
+
3. 输入框上方出现批注卡片:悬停展开列表,可逐条编辑、删除或一键清空,点条目跳回正文对应文字
|
|
44
|
+
4. 正常打字发送,批注随消息一起交给模型,卡片随之消失
|
|
45
|
+
5. 模型回答里引用某条批注的位置显示为可点击 chip
|
|
46
|
+
|
|
47
|
+
## 语言
|
|
48
|
+
|
|
49
|
+
界面语言由 dsh 决定,插件不再自己判断浏览器语言。在 **设置 → 通用 → 语言** 里切换。
|
|
50
|
+
|
|
55
51
|
|
|
56
52
|
## 已知限制
|
|
57
53
|
|
|
58
|
-
-
|
|
59
|
-
- **占用了用户气泡的渲染**:本插件以最低优先级替换内置 `user`
|
|
54
|
+
- **待发批注只在内存里**:刷新页面或切换会话后会丢,卡片不持久化;已发送消息里的批注不受影响。
|
|
55
|
+
- **占用了用户气泡的渲染**:本插件以最低优先级替换内置 `user` 节点。其它插件若替换同一个键(例如 `dsh-easyrewrite` 用 `-1`),会被本插件屏蔽。
|
|
60
56
|
- **卡片宽度有限**:输入框内每条摘要最宽约为输入框的 1/4,超长标题走省略号。
|
|
61
|
-
-
|
|
57
|
+
- **划词只在对话区生效**:不介入编辑器、终端等面板。
|
|
58
|
+
|
|
59
|
+
## 常见问题
|
|
60
|
+
|
|
61
|
+
**升级 dsh 之后功能不对?** 先 `npm run check`;产物没问题就对照 [`docs/internals.md`](docs/internals.md) 的「依赖的产品内部约定」逐条复核,那里列了所有产品内部名。
|
|
62
|
+
|
|
63
|
+
**划词没有工具条?** 确认选区在对话区而不是编辑器里;刷新页面(样式与观察者是幂等注入的);宿主 boot 日志里应出现 `dsh-select-quote`。
|
|
64
|
+
|
|
65
|
+
**输入框里出现了协议原文?** 发送前的注入没生效,一般是 composer 结构变了——检查 `[data-composer-card]` 是否还在。
|
|
66
|
+
|
|
67
|
+
**对话记录里没有批注卡片?** 卡片渲染在用户气泡内部,若 `user` 节点被别的接管者赢走,或那条消息里确实没有批注块,都不会出现。
|
|
68
|
+
|
|
69
|
+
**界面语言不跟着 dsh 变?** 文案取自宿主的语言服务;宿主没有该服务或语言未注册时回退到 English。此时确认 dsh 版本带 `dsh-client-locale`,以及设置里确有语言选项。
|
|
70
|
+
|
|
71
|
+
**改了 CSS 不生效?** 样式只注入一次,刷新页面。
|
|
62
72
|
|
|
63
73
|
## 开发
|
|
64
74
|
|
|
65
75
|
```bash
|
|
66
|
-
npm
|
|
76
|
+
npm install
|
|
77
|
+
npm run check # typecheck + build + verify
|
|
78
|
+
npm run watch # 增量重建
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
`npm run verify` 无头执行产物,断言 8 项贡献都注册上,并检查三份词典的键与 `{placeholder}` 在每种语言里都齐备(复数消息必须有 `.other` 兜底)。
|
|
82
|
+
|
|
83
|
+
发布:先把源码与产物 `lib/` 一起提交,再跑
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
npm run release # check → 追加 CHANGELOG.md → 提升版本号并打 tag
|
|
67
87
|
```
|
|
68
88
|
|
|
69
|
-
|
|
89
|
+
## 贡献
|
|
90
|
+
|
|
91
|
+
fork 后开分支,提交前跑 `npm run check`。一个 PR 只讲一件事,功能与文档分开,方便追溯。PR 里写清复现方式(环境、dsh 版本、操作步骤)。
|
|
92
|
+
|
|
93
|
+
## 致谢
|
|
94
|
+
|
|
95
|
+
- [@imallakov](https://github.com/imallakov):多语言文案、跨平台构建、装饰器楔死修复与批注卡片归属(PR #2 / #3 / #4)
|
|
96
|
+
- [@EliteOtaku](https://github.com/EliteOtaku):桌面端渲染进程楔死的定位报告与 CDP 探针方法([#1](https://github.com/latte03/dsh-select-quote/issues/1))
|
|
70
97
|
|
|
71
98
|
## License
|
|
72
99
|
|
package/lib/client.js
CHANGED
|
@@ -642,17 +642,19 @@ const CSS = `
|
|
|
642
642
|
gap: 6px;
|
|
643
643
|
}
|
|
644
644
|
|
|
645
|
-
/* Transcript cards — in-flow
|
|
645
|
+
/* Transcript cards — in-flow rows under the user bubble, side by side. */
|
|
646
646
|
.dsq_tCardStack {
|
|
647
647
|
display: flex;
|
|
648
|
-
flex-direction:
|
|
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
|
-
/*
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
max-width:
|
|
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,249 @@ function LucideIcon({ name, size = 15 }) {
|
|
|
1704
1764
|
});
|
|
1705
1765
|
}
|
|
1706
1766
|
//#endregion
|
|
1767
|
+
//#region src/client/i18n.ts
|
|
1768
|
+
/**
|
|
1769
|
+
* Copy for dsh-select-quote, served through the host locale service.
|
|
1770
|
+
*
|
|
1771
|
+
* The active language is owned by `@deepseek-ai/dsh-client-locale` (Settings →
|
|
1772
|
+
* General → Language, persisted as `locale.preference`). This module owns only
|
|
1773
|
+
* the dictionaries and the plural form selection that the host's plain-template
|
|
1774
|
+
* lookup cannot express; it never resolves a language on its own.
|
|
1775
|
+
*
|
|
1776
|
+
* NOT localized on purpose:
|
|
1777
|
+
* - wire markers (`> [选中文本]`, `<response-annotations>`,
|
|
1778
|
+
* `:dsh-annotation{index="N"}`) — they are the message format the model
|
|
1779
|
+
* and the transcript parser agree on;
|
|
1780
|
+
* - the `# Response annotations:` header — model-facing instruction prose,
|
|
1781
|
+
* not UI text.
|
|
1782
|
+
*/
|
|
1783
|
+
const LOCALE_NS = "select-quote";
|
|
1784
|
+
const zh = {
|
|
1785
|
+
"common.selectedText": "选中的文本",
|
|
1786
|
+
"common.commentPlaceholder": "添加可选评论…",
|
|
1787
|
+
"common.cancel": "取消",
|
|
1788
|
+
"common.save": "保存",
|
|
1789
|
+
"toolbar.aria": "划词操作",
|
|
1790
|
+
"toolbar.commentHint": "添加批注…",
|
|
1791
|
+
"toolbar.copy": "复制",
|
|
1792
|
+
"toolbar.copyFailed": "复制失败",
|
|
1793
|
+
"toolbar.addToTask": "添加到任务",
|
|
1794
|
+
"toolbar.commentDialogAria": "添加可选评论",
|
|
1795
|
+
"toolbar.skipComment": "暂不评论",
|
|
1796
|
+
"toolbar.addComment": "添加评论",
|
|
1797
|
+
"card.commentSubtitle": "评论 · {comment}",
|
|
1798
|
+
"card.ariaIndexed": "批注 {index}",
|
|
1799
|
+
"card.ariaPlain": "划词批注",
|
|
1800
|
+
"card.editComment": "编辑评论",
|
|
1801
|
+
"card.remove": "移除批注",
|
|
1802
|
+
"summary.hint": "悬停查看批注",
|
|
1803
|
+
"summary.dialogAria": "{count} 条划词批注详情",
|
|
1804
|
+
"summary.edit": "编辑批注 {index}",
|
|
1805
|
+
"summary.remove": "移除批注 {index}",
|
|
1806
|
+
"summary.userComment": "用户评论",
|
|
1807
|
+
"summary.noComment": "未添加评论",
|
|
1808
|
+
"summary.aria.other": "{count} 条划词批注。悬停查看详情。",
|
|
1809
|
+
"summary.title.other": "{count} 条批注",
|
|
1810
|
+
"summary.removeAll": "移除全部划词批注",
|
|
1811
|
+
"chip.annotation": "批注 {index}"
|
|
1812
|
+
};
|
|
1813
|
+
const en = {
|
|
1814
|
+
"common.selectedText": "Selected text",
|
|
1815
|
+
"common.commentPlaceholder": "Add an optional comment…",
|
|
1816
|
+
"common.cancel": "Cancel",
|
|
1817
|
+
"common.save": "Save",
|
|
1818
|
+
"toolbar.aria": "Selection actions",
|
|
1819
|
+
"toolbar.commentHint": "Add annotation…",
|
|
1820
|
+
"toolbar.copy": "Copy",
|
|
1821
|
+
"toolbar.copyFailed": "Copy failed",
|
|
1822
|
+
"toolbar.addToTask": "Add to task",
|
|
1823
|
+
"toolbar.commentDialogAria": "Add an optional comment",
|
|
1824
|
+
"toolbar.skipComment": "Skip comment",
|
|
1825
|
+
"toolbar.addComment": "Add comment",
|
|
1826
|
+
"card.commentSubtitle": "Comment · {comment}",
|
|
1827
|
+
"card.ariaIndexed": "Annotation {index}",
|
|
1828
|
+
"card.ariaPlain": "Selection annotation",
|
|
1829
|
+
"card.editComment": "Edit comment",
|
|
1830
|
+
"card.remove": "Remove annotation",
|
|
1831
|
+
"summary.hint": "Hover to view annotations",
|
|
1832
|
+
"summary.dialogAria": "Annotation details: {count}",
|
|
1833
|
+
"summary.edit": "Edit annotation {index}",
|
|
1834
|
+
"summary.remove": "Remove annotation {index}",
|
|
1835
|
+
"summary.userComment": "User comment",
|
|
1836
|
+
"summary.noComment": "No comment added",
|
|
1837
|
+
"summary.aria.one": "{count} annotation. Hover to view details.",
|
|
1838
|
+
"summary.aria.other": "{count} annotations. Hover to view details.",
|
|
1839
|
+
"summary.title.one": "{count} annotation",
|
|
1840
|
+
"summary.title.other": "{count} annotations",
|
|
1841
|
+
"summary.removeAll": "Remove all annotations",
|
|
1842
|
+
"chip.annotation": "Annotation {index}"
|
|
1843
|
+
};
|
|
1844
|
+
/**
|
|
1845
|
+
* Russian is not a shipped host locale: it is added through
|
|
1846
|
+
* `ctx.locale.addLanguage()` and its dictionary through the three-argument
|
|
1847
|
+
* `register()`. Its fallback chain ends at `en`, as the host requires.
|
|
1848
|
+
*/
|
|
1849
|
+
const ru = {
|
|
1850
|
+
"common.selectedText": "Выделенный текст",
|
|
1851
|
+
"common.commentPlaceholder": "Добавить необязательный комментарий…",
|
|
1852
|
+
"common.cancel": "Отмена",
|
|
1853
|
+
"common.save": "Сохранить",
|
|
1854
|
+
"toolbar.aria": "Действия с выделенным текстом",
|
|
1855
|
+
"toolbar.commentHint": "Добавить аннотацию…",
|
|
1856
|
+
"toolbar.copy": "Копировать",
|
|
1857
|
+
"toolbar.copyFailed": "Не удалось скопировать",
|
|
1858
|
+
"toolbar.addToTask": "Добавить к задаче",
|
|
1859
|
+
"toolbar.commentDialogAria": "Добавить необязательный комментарий",
|
|
1860
|
+
"toolbar.skipComment": "Без комментария",
|
|
1861
|
+
"toolbar.addComment": "Добавить комментарий",
|
|
1862
|
+
"card.commentSubtitle": "Комментарий · {comment}",
|
|
1863
|
+
"card.ariaIndexed": "Аннотация {index}",
|
|
1864
|
+
"card.ariaPlain": "Аннотация к выделенному тексту",
|
|
1865
|
+
"card.editComment": "Изменить комментарий",
|
|
1866
|
+
"card.remove": "Удалить аннотацию",
|
|
1867
|
+
"summary.hint": "Наведите, чтобы увидеть аннотации",
|
|
1868
|
+
"summary.dialogAria": "Детали аннотаций: {count}",
|
|
1869
|
+
"summary.edit": "Изменить аннотацию {index}",
|
|
1870
|
+
"summary.remove": "Удалить аннотацию {index}",
|
|
1871
|
+
"summary.userComment": "Комментарий пользователя",
|
|
1872
|
+
"summary.noComment": "Комментарий не добавлен",
|
|
1873
|
+
"summary.aria.one": "{count} аннотация. Наведите, чтобы увидеть детали.",
|
|
1874
|
+
"summary.aria.few": "{count} аннотации. Наведите, чтобы увидеть детали.",
|
|
1875
|
+
"summary.aria.many": "{count} аннотаций. Наведите, чтобы увидеть детали.",
|
|
1876
|
+
"summary.aria.other": "{count} аннотаций. Наведите, чтобы увидеть детали.",
|
|
1877
|
+
"summary.title.one": "{count} аннотация",
|
|
1878
|
+
"summary.title.few": "{count} аннотации",
|
|
1879
|
+
"summary.title.many": "{count} аннотаций",
|
|
1880
|
+
"summary.title.other": "{count} аннотаций",
|
|
1881
|
+
"summary.removeAll": "Удалить все аннотации",
|
|
1882
|
+
"chip.annotation": "Аннотация {index}"
|
|
1883
|
+
};
|
|
1884
|
+
/** Both shipped host locales, required together by the typed `register()`. */
|
|
1885
|
+
const SHIPPED_DICTS = Object.freeze({
|
|
1886
|
+
zh,
|
|
1887
|
+
en
|
|
1888
|
+
});
|
|
1889
|
+
/** Dictionary for the language registered through {@link LANGUAGE_RU}. */
|
|
1890
|
+
const DICTIONARIES = Object.freeze({
|
|
1891
|
+
zh,
|
|
1892
|
+
en,
|
|
1893
|
+
ru
|
|
1894
|
+
});
|
|
1895
|
+
const LANGUAGE_RU = Object.freeze({
|
|
1896
|
+
id: "ru",
|
|
1897
|
+
label: "Русский",
|
|
1898
|
+
fallback: "en"
|
|
1899
|
+
});
|
|
1900
|
+
let api;
|
|
1901
|
+
/**
|
|
1902
|
+
* Install the host-bound translation entry point. Called once from `apply()`
|
|
1903
|
+
* with `ctx.locale.bind(LOCALE_NS)` and the live snapshot accessors.
|
|
1904
|
+
*/
|
|
1905
|
+
function bindLocale(next) {
|
|
1906
|
+
api = next;
|
|
1907
|
+
}
|
|
1908
|
+
/** Active language id, or `en` before the host service is bound. */
|
|
1909
|
+
function activeLocale() {
|
|
1910
|
+
return api?.getActive() ?? "en";
|
|
1911
|
+
}
|
|
1912
|
+
function pluralForm(count) {
|
|
1913
|
+
const locale = activeLocale();
|
|
1914
|
+
if (locale === "ru") {
|
|
1915
|
+
const mod10 = count % 10;
|
|
1916
|
+
const mod100 = count % 100;
|
|
1917
|
+
if (mod10 === 1 && mod100 !== 11) return "one";
|
|
1918
|
+
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) return "few";
|
|
1919
|
+
return "many";
|
|
1920
|
+
}
|
|
1921
|
+
if (locale === "en") return count === 1 ? "one" : "other";
|
|
1922
|
+
return "other";
|
|
1923
|
+
}
|
|
1924
|
+
/**
|
|
1925
|
+
* Translate a `select-quote` key. A numeric `count` selects a plural form and
|
|
1926
|
+
* the matching `key.form` entry is used, falling back to `key.other`.
|
|
1927
|
+
*/
|
|
1928
|
+
function t(key, params = {}) {
|
|
1929
|
+
if (!api) return key;
|
|
1930
|
+
if (typeof params.count === "number") {
|
|
1931
|
+
const form = pluralForm(params.count);
|
|
1932
|
+
const exact = api.translate(`${key}.${form}`, params);
|
|
1933
|
+
if (exact !== `${key}.${form}`) return exact;
|
|
1934
|
+
const other = api.translate(`${key}.other`, params);
|
|
1935
|
+
if (other !== `${key}.other`) return other;
|
|
1936
|
+
}
|
|
1937
|
+
return api.translate(key, params);
|
|
1938
|
+
}
|
|
1939
|
+
/**
|
|
1940
|
+
* Subscribe to language or dictionary changes. Components go through
|
|
1941
|
+
* `useLocale()` instead; this is the non-React entry point (DOM decorations).
|
|
1942
|
+
* Subscribing before the host service is bound is a no-op, so callers subscribe
|
|
1943
|
+
* from `apply()`, after `bindLocale()`.
|
|
1944
|
+
*/
|
|
1945
|
+
function subscribeLocale(listener) {
|
|
1946
|
+
return api ? api.subscribe(listener) : () => {};
|
|
1947
|
+
}
|
|
1948
|
+
/** Monotonic host revision counter, bumped on locale and dictionary changes. */
|
|
1949
|
+
function localeRevision() {
|
|
1950
|
+
return api?.getRevision() ?? 0;
|
|
1951
|
+
}
|
|
1952
|
+
//#endregion
|
|
1953
|
+
//#region src/client/useLocale.ts
|
|
1954
|
+
/**
|
|
1955
|
+
* Re-render the calling component when the host language changes, so slot copy
|
|
1956
|
+
* switches without a page reload. Subscriptions happen after mount, by which
|
|
1957
|
+
* time `apply()` has bound the locale service.
|
|
1958
|
+
*/
|
|
1959
|
+
function useLocale() {
|
|
1960
|
+
(0, react.useSyncExternalStore)(subscribeLocale, localeRevision, localeRevision);
|
|
1961
|
+
}
|
|
1962
|
+
//#endregion
|
|
1707
1963
|
//#region src/client/ui/AnnotationCardView.tsx
|
|
1964
|
+
function AnnotationCardView({ title, text, comment, variant, index, onRemove, onEditComment }) {
|
|
1965
|
+
useLocale();
|
|
1966
|
+
const composer = variant === "composer";
|
|
1967
|
+
const subtitle = comment ? composer ? t("card.commentSubtitle", { comment }) : comment : composer ? t("common.selectedText") : t("summary.noComment");
|
|
1968
|
+
return (0, react_jsx_runtime.jsxs)("div", {
|
|
1969
|
+
className: composer ? styles.card : styles.tCard,
|
|
1970
|
+
role: "group",
|
|
1971
|
+
"aria-label": index ? t("card.ariaIndexed", { index }) : t("card.ariaPlain"),
|
|
1972
|
+
children: [
|
|
1973
|
+
(0, react_jsx_runtime.jsx)("div", {
|
|
1974
|
+
className: composer ? styles.cardIcon : styles.tCardIcon,
|
|
1975
|
+
"aria-hidden": true,
|
|
1976
|
+
children: index ? String(index) : "AI"
|
|
1977
|
+
}),
|
|
1978
|
+
(0, react_jsx_runtime.jsxs)("div", {
|
|
1979
|
+
className: composer ? styles.cardBody : styles.tCardBody,
|
|
1980
|
+
children: [(0, react_jsx_runtime.jsx)("div", {
|
|
1981
|
+
className: composer ? styles.cardTitle : styles.tCardTitle,
|
|
1982
|
+
title: text,
|
|
1983
|
+
children: title
|
|
1984
|
+
}), (0, react_jsx_runtime.jsx)("div", {
|
|
1985
|
+
className: composer ? styles.cardSubtitle : styles.tCardSubtitle,
|
|
1986
|
+
...comment ? {
|
|
1987
|
+
title: comment,
|
|
1988
|
+
"data-dsq-has-comment": "true"
|
|
1989
|
+
} : {},
|
|
1990
|
+
children: subtitle
|
|
1991
|
+
})]
|
|
1992
|
+
}),
|
|
1993
|
+
composer && onEditComment ? (0, react_jsx_runtime.jsx)("button", {
|
|
1994
|
+
type: "button",
|
|
1995
|
+
className: styles.cardAction,
|
|
1996
|
+
"aria-label": t("card.editComment"),
|
|
1997
|
+
onClick: onEditComment,
|
|
1998
|
+
children: "✎"
|
|
1999
|
+
}) : null,
|
|
2000
|
+
composer && onRemove ? (0, react_jsx_runtime.jsx)("button", {
|
|
2001
|
+
type: "button",
|
|
2002
|
+
className: styles.cardClose,
|
|
2003
|
+
"aria-label": t("card.remove"),
|
|
2004
|
+
onClick: onRemove,
|
|
2005
|
+
children: "×"
|
|
2006
|
+
}) : null
|
|
2007
|
+
]
|
|
2008
|
+
});
|
|
2009
|
+
}
|
|
1708
2010
|
/** Grace period so pointer transit between the summary and the panel never closes it. */
|
|
1709
2011
|
const CLOSE_DELAY = 320;
|
|
1710
2012
|
/** After a viewport change, wait this long before position-checking the pointer. */
|
|
@@ -1720,7 +2022,8 @@ const SETTLE_DELAY = 600;
|
|
|
1720
2022
|
* other cancels. Comment editing happens inline in the list — the panel stays
|
|
1721
2023
|
* put and auto-close is suppressed while an edit is open.
|
|
1722
2024
|
*/
|
|
1723
|
-
function AnnotationSummary({ count, items, onRemoveAll, onItemSelect, onItemRemove, onItemEdit
|
|
2025
|
+
function AnnotationSummary({ count, items, onRemoveAll, onItemSelect, onItemRemove, onItemEdit }) {
|
|
2026
|
+
useLocale();
|
|
1724
2027
|
const rootRef = (0, react.useRef)(null);
|
|
1725
2028
|
const popRef = (0, react.useRef)(null);
|
|
1726
2029
|
const closeTimerRef = (0, react.useRef)(null);
|
|
@@ -1876,7 +2179,7 @@ function AnnotationSummary({ count, items, onRemoveAll, onItemSelect, onItemRemo
|
|
|
1876
2179
|
ref: popRef,
|
|
1877
2180
|
className: `${styles.summaryHover} ${styles.summaryHoverOpen}`,
|
|
1878
2181
|
role: "dialog",
|
|
1879
|
-
"aria-label":
|
|
2182
|
+
"aria-label": t("summary.dialogAria", { count }),
|
|
1880
2183
|
onPointerEnter: openPop,
|
|
1881
2184
|
onPointerLeave,
|
|
1882
2185
|
children: (0, react_jsx_runtime.jsx)("div", {
|
|
@@ -1895,20 +2198,20 @@ function AnnotationSummary({ count, items, onRemoveAll, onItemSelect, onItemRemo
|
|
|
1895
2198
|
}),
|
|
1896
2199
|
(0, react_jsx_runtime.jsx)("div", {
|
|
1897
2200
|
className: styles.summaryItemLabel,
|
|
1898
|
-
children: "
|
|
2201
|
+
children: t("common.selectedText")
|
|
1899
2202
|
}),
|
|
1900
2203
|
!isEditing && (onItemRemove || onItemEdit) ? (0, react_jsx_runtime.jsxs)("div", {
|
|
1901
2204
|
className: styles.summaryItemActions,
|
|
1902
2205
|
children: [onItemEdit ? (0, react_jsx_runtime.jsx)("button", {
|
|
1903
2206
|
type: "button",
|
|
1904
2207
|
className: styles.summaryItemAction,
|
|
1905
|
-
"aria-label":
|
|
2208
|
+
"aria-label": t("summary.edit", { index: i + 1 }),
|
|
1906
2209
|
onClick: () => startEdit(i),
|
|
1907
2210
|
children: (0, react_jsx_runtime.jsx)(LucideIcon, { name: "pencil" })
|
|
1908
2211
|
}) : null, onItemRemove ? (0, react_jsx_runtime.jsx)("button", {
|
|
1909
2212
|
type: "button",
|
|
1910
2213
|
className: styles.summaryItemAction,
|
|
1911
|
-
"aria-label":
|
|
2214
|
+
"aria-label": t("summary.remove", { index: i + 1 }),
|
|
1912
2215
|
onClick: () => onItemRemove(i, item),
|
|
1913
2216
|
children: (0, react_jsx_runtime.jsx)(LucideIcon, { name: "trash-2" })
|
|
1914
2217
|
}) : null]
|
|
@@ -1923,12 +2226,12 @@ function AnnotationSummary({ count, items, onRemoveAll, onItemSelect, onItemRemo
|
|
|
1923
2226
|
}),
|
|
1924
2227
|
(0, react_jsx_runtime.jsx)("div", {
|
|
1925
2228
|
className: styles.summaryItemLabel,
|
|
1926
|
-
children: "
|
|
2229
|
+
children: t("summary.userComment")
|
|
1927
2230
|
}),
|
|
1928
2231
|
(0, react_jsx_runtime.jsx)("textarea", {
|
|
1929
2232
|
ref: editInputRef,
|
|
1930
2233
|
className: styles.annEditInput,
|
|
1931
|
-
placeholder: "
|
|
2234
|
+
placeholder: t("common.commentPlaceholder"),
|
|
1932
2235
|
value: editDraft,
|
|
1933
2236
|
onChange: (e) => setEditDraft(e.target.value),
|
|
1934
2237
|
onKeyDown: (e) => {
|
|
@@ -1949,12 +2252,12 @@ function AnnotationSummary({ count, items, onRemoveAll, onItemSelect, onItemRemo
|
|
|
1949
2252
|
type: "button",
|
|
1950
2253
|
className: styles.annEditCancel,
|
|
1951
2254
|
onClick: () => setEditing(null),
|
|
1952
|
-
children: "
|
|
2255
|
+
children: t("common.cancel")
|
|
1953
2256
|
}), (0, react_jsx_runtime.jsx)("button", {
|
|
1954
2257
|
type: "button",
|
|
1955
2258
|
className: styles.annEditSave,
|
|
1956
2259
|
onClick: commitEdit,
|
|
1957
|
-
children: "
|
|
2260
|
+
children: t("common.save")
|
|
1958
2261
|
})]
|
|
1959
2262
|
})
|
|
1960
2263
|
]
|
|
@@ -1974,11 +2277,11 @@ function AnnotationSummary({ count, items, onRemoveAll, onItemSelect, onItemRemo
|
|
|
1974
2277
|
}),
|
|
1975
2278
|
(0, react_jsx_runtime.jsx)("div", {
|
|
1976
2279
|
className: styles.summaryItemLabel,
|
|
1977
|
-
children: "
|
|
2280
|
+
children: t("summary.userComment")
|
|
1978
2281
|
}),
|
|
1979
2282
|
(0, react_jsx_runtime.jsx)("div", {
|
|
1980
2283
|
className: item.comment ? styles.summaryItemText : `${styles.summaryItemText} ${styles.summaryItemTextEmpty}`,
|
|
1981
|
-
children: item.comment || "
|
|
2284
|
+
children: item.comment || t("summary.noComment")
|
|
1982
2285
|
})
|
|
1983
2286
|
]
|
|
1984
2287
|
})
|
|
@@ -1992,7 +2295,7 @@ function AnnotationSummary({ count, items, onRemoveAll, onItemSelect, onItemRemo
|
|
|
1992
2295
|
className: styles.summary,
|
|
1993
2296
|
tabIndex: 0,
|
|
1994
2297
|
"data-selection-annotation-summary": "true",
|
|
1995
|
-
"aria-label":
|
|
2298
|
+
"aria-label": t("summary.aria", { count }),
|
|
1996
2299
|
"aria-expanded": open,
|
|
1997
2300
|
onPointerEnter,
|
|
1998
2301
|
onPointerLeave,
|
|
@@ -2009,16 +2312,16 @@ function AnnotationSummary({ count, items, onRemoveAll, onItemSelect, onItemRemo
|
|
|
2009
2312
|
className: styles.summaryBody,
|
|
2010
2313
|
children: [(0, react_jsx_runtime.jsx)("div", {
|
|
2011
2314
|
className: styles.summaryTitle,
|
|
2012
|
-
children:
|
|
2315
|
+
children: t("summary.title", { count })
|
|
2013
2316
|
}), (0, react_jsx_runtime.jsx)("div", {
|
|
2014
2317
|
className: styles.summaryHint,
|
|
2015
|
-
children: hint
|
|
2318
|
+
children: t("summary.hint")
|
|
2016
2319
|
})]
|
|
2017
2320
|
}),
|
|
2018
2321
|
onRemoveAll ? (0, react_jsx_runtime.jsx)("button", {
|
|
2019
2322
|
type: "button",
|
|
2020
2323
|
className: styles.cardClose,
|
|
2021
|
-
"aria-label": "
|
|
2324
|
+
"aria-label": t("summary.removeAll"),
|
|
2022
2325
|
onClick: onRemoveAll,
|
|
2023
2326
|
children: (0, react_jsx_runtime.jsx)(LucideIcon, {
|
|
2024
2327
|
name: "x",
|
|
@@ -2310,7 +2613,10 @@ async function copyText(text) {
|
|
|
2310
2613
|
}
|
|
2311
2614
|
//#endregion
|
|
2312
2615
|
//#region src/client/SelectionToolbar.tsx
|
|
2616
|
+
/** Cap for the auto-growing comment box; beyond this it scrolls internally. */
|
|
2617
|
+
const COMMENT_MAX_HEIGHT = 220;
|
|
2313
2618
|
function SelectionToolbar({ sessionId }) {
|
|
2619
|
+
useLocale();
|
|
2314
2620
|
const toolbarRef = (0, react.useRef)(null);
|
|
2315
2621
|
const commentWrapRef = (0, react.useRef)(null);
|
|
2316
2622
|
const commentRef = (0, react.useRef)(null);
|
|
@@ -2376,19 +2682,24 @@ function SelectionToolbar({ sessionId }) {
|
|
|
2376
2682
|
if (dragging) return;
|
|
2377
2683
|
schedule();
|
|
2378
2684
|
};
|
|
2685
|
+
const onScroll = (event) => {
|
|
2686
|
+
const target = event.target;
|
|
2687
|
+
if (target instanceof Node && (toolbarRef.current?.contains(target) === true || commentWrapRef.current?.contains(target) === true)) return;
|
|
2688
|
+
hide();
|
|
2689
|
+
};
|
|
2379
2690
|
document.addEventListener("pointerdown", onPointerDown, true);
|
|
2380
2691
|
document.addEventListener("pointerup", onPointerUp, true);
|
|
2381
2692
|
document.addEventListener("keyup", schedule, true);
|
|
2382
2693
|
document.addEventListener("selectionchange", onSelectionChange);
|
|
2383
2694
|
window.addEventListener("resize", hide);
|
|
2384
|
-
document.addEventListener("scroll",
|
|
2695
|
+
document.addEventListener("scroll", onScroll, true);
|
|
2385
2696
|
return () => {
|
|
2386
2697
|
document.removeEventListener("pointerdown", onPointerDown, true);
|
|
2387
2698
|
document.removeEventListener("pointerup", onPointerUp, true);
|
|
2388
2699
|
document.removeEventListener("keyup", schedule, true);
|
|
2389
2700
|
document.removeEventListener("selectionchange", onSelectionChange);
|
|
2390
2701
|
window.removeEventListener("resize", hide);
|
|
2391
|
-
document.removeEventListener("scroll",
|
|
2702
|
+
document.removeEventListener("scroll", onScroll, true);
|
|
2392
2703
|
};
|
|
2393
2704
|
}, [hide, refresh]);
|
|
2394
2705
|
const placeCommentPop = (0, react.useCallback)(() => {
|
|
@@ -2415,6 +2726,18 @@ function SelectionToolbar({ sessionId }) {
|
|
|
2415
2726
|
commentRef.current?.focus();
|
|
2416
2727
|
}
|
|
2417
2728
|
}, [phase, placeCommentPop]);
|
|
2729
|
+
(0, react.useEffect)(() => {
|
|
2730
|
+
if (phase !== "comment") return;
|
|
2731
|
+
const area = commentRef.current;
|
|
2732
|
+
if (!area) return;
|
|
2733
|
+
area.style.height = "auto";
|
|
2734
|
+
area.style.height = `${Math.min(area.scrollHeight, COMMENT_MAX_HEIGHT)}px`;
|
|
2735
|
+
placeCommentPop();
|
|
2736
|
+
}, [
|
|
2737
|
+
phase,
|
|
2738
|
+
comment,
|
|
2739
|
+
placeCommentPop
|
|
2740
|
+
]);
|
|
2418
2741
|
const handleToolbarPointerDown = (0, react.useCallback)((event) => {
|
|
2419
2742
|
event.preventDefault();
|
|
2420
2743
|
event.stopPropagation();
|
|
@@ -2427,7 +2750,7 @@ function SelectionToolbar({ sessionId }) {
|
|
|
2427
2750
|
clearNativeSelection();
|
|
2428
2751
|
hide();
|
|
2429
2752
|
} catch {
|
|
2430
|
-
setStatus("
|
|
2753
|
+
setStatus(t("toolbar.copyFailed"));
|
|
2431
2754
|
}
|
|
2432
2755
|
}, [hide]);
|
|
2433
2756
|
const openComment = (0, react.useCallback)(() => {
|
|
@@ -2436,7 +2759,7 @@ function SelectionToolbar({ sessionId }) {
|
|
|
2436
2759
|
setComment("");
|
|
2437
2760
|
setStatus(null);
|
|
2438
2761
|
}, []);
|
|
2439
|
-
/** Quick-add without opening the comment step (double-tap /
|
|
2762
|
+
/** Quick-add without opening the comment step (double-tap / “Skip comment”). */
|
|
2440
2763
|
const commitAnnotation = (0, react.useCallback)((body) => {
|
|
2441
2764
|
const snapshot = activeRef.current;
|
|
2442
2765
|
if (!snapshot) {
|
|
@@ -2471,7 +2794,7 @@ function SelectionToolbar({ sessionId }) {
|
|
|
2471
2794
|
className: styles.toolbar,
|
|
2472
2795
|
style: toolbarStyle,
|
|
2473
2796
|
role: "toolbar",
|
|
2474
|
-
"aria-label": "
|
|
2797
|
+
"aria-label": t("toolbar.aria"),
|
|
2475
2798
|
"data-dsq-toolbar": "true",
|
|
2476
2799
|
onPointerDown: handleToolbarPointerDown,
|
|
2477
2800
|
children: phase === "comment" ? (0, react_jsx_runtime.jsx)("div", {
|
|
@@ -2486,7 +2809,7 @@ function SelectionToolbar({ sessionId }) {
|
|
|
2486
2809
|
fontSize: 13,
|
|
2487
2810
|
opacity: .8
|
|
2488
2811
|
},
|
|
2489
|
-
children: "
|
|
2812
|
+
children: t("toolbar.commentHint")
|
|
2490
2813
|
})
|
|
2491
2814
|
}) : (0, react_jsx_runtime.jsxs)("div", {
|
|
2492
2815
|
style: {
|
|
@@ -2502,7 +2825,7 @@ function SelectionToolbar({ sessionId }) {
|
|
|
2502
2825
|
children: [(0, react_jsx_runtime.jsx)(LucideIcon, {
|
|
2503
2826
|
name: "copy",
|
|
2504
2827
|
size: 14
|
|
2505
|
-
}), "
|
|
2828
|
+
}), t("toolbar.copy")]
|
|
2506
2829
|
}),
|
|
2507
2830
|
(0, react_jsx_runtime.jsx)("span", {
|
|
2508
2831
|
className: styles.divider,
|
|
@@ -2515,7 +2838,7 @@ function SelectionToolbar({ sessionId }) {
|
|
|
2515
2838
|
children: [(0, react_jsx_runtime.jsx)(LucideIcon, {
|
|
2516
2839
|
name: "plus",
|
|
2517
2840
|
size: 14
|
|
2518
|
-
}), "
|
|
2841
|
+
}), t("toolbar.addToTask")]
|
|
2519
2842
|
})
|
|
2520
2843
|
]
|
|
2521
2844
|
})
|
|
@@ -2525,12 +2848,12 @@ function SelectionToolbar({ sessionId }) {
|
|
|
2525
2848
|
className: styles.commentPop,
|
|
2526
2849
|
style: void 0,
|
|
2527
2850
|
role: "dialog",
|
|
2528
|
-
"aria-label": "
|
|
2851
|
+
"aria-label": t("toolbar.commentDialogAria"),
|
|
2529
2852
|
onPointerDown: handleToolbarPointerDown,
|
|
2530
2853
|
children: [(0, react_jsx_runtime.jsx)("textarea", {
|
|
2531
2854
|
ref: commentRef,
|
|
2532
2855
|
className: styles.commentInput,
|
|
2533
|
-
placeholder: "
|
|
2856
|
+
placeholder: t("common.commentPlaceholder"),
|
|
2534
2857
|
value: comment,
|
|
2535
2858
|
onChange: (e) => setComment(e.target.value),
|
|
2536
2859
|
onKeyDown: (e) => {
|
|
@@ -2550,13 +2873,13 @@ function SelectionToolbar({ sessionId }) {
|
|
|
2550
2873
|
type: "button",
|
|
2551
2874
|
className: styles.button,
|
|
2552
2875
|
onClick: () => commitAnnotation(void 0),
|
|
2553
|
-
children: "
|
|
2876
|
+
children: t("toolbar.skipComment")
|
|
2554
2877
|
}), (0, react_jsx_runtime.jsx)("button", {
|
|
2555
2878
|
type: "button",
|
|
2556
2879
|
className: styles.button,
|
|
2557
2880
|
disabled: !comment.trim(),
|
|
2558
2881
|
onClick: () => commitAnnotation(comment),
|
|
2559
|
-
children: "
|
|
2882
|
+
children: t("toolbar.addComment")
|
|
2560
2883
|
})]
|
|
2561
2884
|
})]
|
|
2562
2885
|
}) : null,
|
|
@@ -2581,11 +2904,14 @@ function decorateTextNode(node) {
|
|
|
2581
2904
|
const parent = node.parentElement;
|
|
2582
2905
|
if (!parent || parent.closest(`[data-${DECO}]`)) return;
|
|
2583
2906
|
if (parent.closest("pre, code, [data-dsq-toolbar]")) return;
|
|
2907
|
+
if (parent.closest("textarea, input, [data-lexical-editor=\"true\"]")) return;
|
|
2584
2908
|
const frag = document.createDocumentFragment();
|
|
2585
2909
|
let last = 0;
|
|
2910
|
+
let matched = false;
|
|
2586
2911
|
const re = new RegExp(ANNOTATION_DIRECTIVE_RE.source, "g");
|
|
2587
2912
|
let m;
|
|
2588
2913
|
while ((m = re.exec(value)) !== null) {
|
|
2914
|
+
matched = true;
|
|
2589
2915
|
if (m.index > last) frag.append(document.createTextNode(value.slice(last, m.index)));
|
|
2590
2916
|
const index = Number(m[1]);
|
|
2591
2917
|
const chip = document.createElement("button");
|
|
@@ -2593,7 +2919,7 @@ function decorateTextNode(node) {
|
|
|
2593
2919
|
chip.className = "dsq_annDirective";
|
|
2594
2920
|
chip.dataset[DECO] = String(index);
|
|
2595
2921
|
chip.setAttribute("data-dsq-deco", "true");
|
|
2596
|
-
chip.textContent =
|
|
2922
|
+
chip.textContent = t("chip.annotation", { index });
|
|
2597
2923
|
chip.addEventListener("click", () => {
|
|
2598
2924
|
const mark = findAnnotationMarkBefore(index, chip);
|
|
2599
2925
|
if (mark) flashAnnotationMark(mark);
|
|
@@ -2601,6 +2927,7 @@ function decorateTextNode(node) {
|
|
|
2601
2927
|
frag.append(chip);
|
|
2602
2928
|
last = m.index + m[0].length;
|
|
2603
2929
|
}
|
|
2930
|
+
if (!matched) return;
|
|
2604
2931
|
if (last < value.length) frag.append(document.createTextNode(value.slice(last)));
|
|
2605
2932
|
parent.replaceChild(frag, node);
|
|
2606
2933
|
}
|
|
@@ -2617,6 +2944,22 @@ function decorateAnnotationDirectives(root) {
|
|
|
2617
2944
|
}
|
|
2618
2945
|
for (const node of hits) decorateTextNode(node);
|
|
2619
2946
|
}
|
|
2947
|
+
/**
|
|
2948
|
+
* Re-label chips that are already on screen after the language changed.
|
|
2949
|
+
*
|
|
2950
|
+
* Chip text is plain DOM, not React state, so a switch would otherwise leave
|
|
2951
|
+
* old-language chips next to new-language popovers. Writing `textContent` here
|
|
2952
|
+
* is safe for the observer: the new text node never contains the directive
|
|
2953
|
+
* literal, so `decorateTextNode()` returns before it can touch the DOM.
|
|
2954
|
+
*/
|
|
2955
|
+
function refreshDirectiveChips(root) {
|
|
2956
|
+
const el = root ?? (typeof document === "undefined" ? void 0 : document.body);
|
|
2957
|
+
if (!el) return;
|
|
2958
|
+
for (const chip of el.querySelectorAll(`[data-${DECO}]`)) {
|
|
2959
|
+
const index = Number(chip.dataset[DECO]);
|
|
2960
|
+
if (Number.isFinite(index)) chip.textContent = t("chip.annotation", { index });
|
|
2961
|
+
}
|
|
2962
|
+
}
|
|
2620
2963
|
/** Observe chat DOM and decorate new directive text. Returns disposer. */
|
|
2621
2964
|
function watchAnnotationDirectives(root) {
|
|
2622
2965
|
const el = root ?? (typeof document === "undefined" ? void 0 : document.body);
|
|
@@ -2635,7 +2978,16 @@ function watchAnnotationDirectives(root) {
|
|
|
2635
2978
|
}
|
|
2636
2979
|
//#endregion
|
|
2637
2980
|
//#region src/client/transcript/TranscriptQuoteCard.tsx
|
|
2638
|
-
/**
|
|
2981
|
+
/**
|
|
2982
|
+
* History-side cards for one user message's annotations.
|
|
2983
|
+
*
|
|
2984
|
+
* One card per annotation, each showing the selected text **and** the user's
|
|
2985
|
+
* comment. The comment is how the user recognizes their own annotation, so it
|
|
2986
|
+
* belongs on the visible card — not behind a hover panel that has to be
|
|
2987
|
+
* discovered. Clicking a card scrolls back to the marked text in the source
|
|
2988
|
+
* message; the ordinal badge matches the `:dsh-annotation{index="N"}` the model
|
|
2989
|
+
* refers to.
|
|
2990
|
+
*/
|
|
2639
2991
|
function TranscriptQuoteCard({ node }) {
|
|
2640
2992
|
ensureToolbarStyles();
|
|
2641
2993
|
const stackRef = (0, react.useRef)(null);
|
|
@@ -2652,28 +3004,37 @@ function TranscriptQuoteCard({ node }) {
|
|
|
2652
3004
|
endOffset: quote.endOffset
|
|
2653
3005
|
})));
|
|
2654
3006
|
}, [quotes]);
|
|
2655
|
-
const onItemSelect = (0, react.useCallback)((index, item) => {
|
|
2656
|
-
scrollToAnnotation(index + 1, item.text, item.id);
|
|
2657
|
-
}, []);
|
|
2658
3007
|
const markIdOf = (0, react.useCallback)((index) => {
|
|
2659
3008
|
const ownerKey = stackRef.current?.closest("[data-chat-flow-key]")?.getAttribute("data-chat-flow-key");
|
|
2660
3009
|
return ownerKey ? `dqsm_${ownerKey}_${index}` : void 0;
|
|
2661
3010
|
}, []);
|
|
3011
|
+
const onSelect = (0, react.useCallback)((index, text) => {
|
|
3012
|
+
scrollToAnnotation(index + 1, text, markIdOf(index));
|
|
3013
|
+
}, [markIdOf]);
|
|
2662
3014
|
if (quotes.length === 0) return null;
|
|
2663
3015
|
return (0, react_jsx_runtime.jsx)("div", {
|
|
2664
3016
|
ref: stackRef,
|
|
2665
3017
|
className: styles.tCardStack,
|
|
2666
3018
|
"data-dsq-deco": "true",
|
|
2667
|
-
children: (0, react_jsx_runtime.jsx)(
|
|
2668
|
-
|
|
2669
|
-
|
|
2670
|
-
|
|
3019
|
+
children: quotes.map((quote, index) => (0, react_jsx_runtime.jsx)("div", {
|
|
3020
|
+
className: styles.tCardButton,
|
|
3021
|
+
role: "button",
|
|
3022
|
+
tabIndex: 0,
|
|
3023
|
+
"aria-label": quote.title,
|
|
3024
|
+
onClick: () => onSelect(index, quote.text),
|
|
3025
|
+
onKeyDown: (event) => {
|
|
3026
|
+
if (event.key !== "Enter" && event.key !== " ") return;
|
|
3027
|
+
event.preventDefault();
|
|
3028
|
+
onSelect(index, quote.text);
|
|
3029
|
+
},
|
|
3030
|
+
children: (0, react_jsx_runtime.jsx)(AnnotationCardView, {
|
|
3031
|
+
variant: "transcript",
|
|
2671
3032
|
title: quote.title,
|
|
2672
3033
|
text: quote.text,
|
|
2673
|
-
comment: quote.comment || void 0
|
|
2674
|
-
|
|
2675
|
-
|
|
2676
|
-
})
|
|
3034
|
+
comment: quote.comment || void 0,
|
|
3035
|
+
index: index + 1
|
|
3036
|
+
})
|
|
3037
|
+
}, `ann-${index}`))
|
|
2677
3038
|
});
|
|
2678
3039
|
}
|
|
2679
3040
|
//#endregion
|
|
@@ -2691,101 +3052,89 @@ function contentImages(content) {
|
|
|
2691
3052
|
}
|
|
2692
3053
|
/**
|
|
2693
3054
|
* Replacement for the built-in `user` Chat node view.
|
|
2694
|
-
*
|
|
3055
|
+
*
|
|
3056
|
+
* Strips the `<response-annotations>` protocol block from the bubble **and**
|
|
3057
|
+
* renders one card per annotation right under it.
|
|
3058
|
+
*
|
|
3059
|
+
* Why the cards live here instead of on a `select-quote` Chat node:
|
|
3060
|
+
* `conversation.chat.node` is gated by the shipped `TURN_PROCESS_INDEPENDENT_KINDS`
|
|
3061
|
+
* allowlist (system-prompt, user, steering, turn-trigger, turn-process,
|
|
3062
|
+
* turn-error, turn-max-tokens, turn-tail). A node whose kind is not on that list
|
|
3063
|
+
* is classified as a turn-process member and `hidden` inside the collapsed
|
|
3064
|
+
* process disclosure of a completed turn — mounted, then invisible. `user` is on
|
|
3065
|
+
* the list, so rendering the cards inside this component is the only placement
|
|
3066
|
+
* that survives turn folding.
|
|
2695
3067
|
*/
|
|
2696
3068
|
function UserMessageDisplay({ node, renderMessageImages }) {
|
|
2697
3069
|
ensureToolbarStyles();
|
|
2698
3070
|
const raw = contentBlocksToText(node.data?.content);
|
|
2699
3071
|
const scanned = scanAnnotatedMessage(raw);
|
|
2700
3072
|
const text = scanned.annotations.length > 0 ? scanned.rest : displayTextWithoutQuote(raw).trim();
|
|
3073
|
+
const quotes = scanned.annotations;
|
|
2701
3074
|
const images = contentImages(node.data?.content);
|
|
2702
|
-
if (!text && images.length === 0) return null;
|
|
3075
|
+
if (!text && images.length === 0 && quotes.length === 0) return null;
|
|
2703
3076
|
return (0, react_jsx_runtime.jsx)("div", {
|
|
2704
3077
|
className: styles.userRow,
|
|
2705
3078
|
children: (0, react_jsx_runtime.jsxs)("div", {
|
|
2706
3079
|
className: styles.userStack,
|
|
2707
|
-
children: [
|
|
2708
|
-
|
|
2709
|
-
|
|
2710
|
-
|
|
2711
|
-
|
|
2712
|
-
|
|
2713
|
-
|
|
2714
|
-
|
|
2715
|
-
|
|
2716
|
-
|
|
2717
|
-
|
|
2718
|
-
|
|
3080
|
+
children: [
|
|
3081
|
+
images.length > 0 && renderMessageImages !== void 0 ? (0, react_jsx_runtime.jsx)("div", {
|
|
3082
|
+
className: styles.userImages,
|
|
3083
|
+
"data-message-attachments": true,
|
|
3084
|
+
children: renderMessageImages({
|
|
3085
|
+
images,
|
|
3086
|
+
align: "end",
|
|
3087
|
+
compact: images.length > 1
|
|
3088
|
+
})
|
|
3089
|
+
}, "images") : null,
|
|
3090
|
+
text ? (0, react_jsx_runtime.jsx)("div", {
|
|
3091
|
+
className: styles.userBubble,
|
|
3092
|
+
children: text
|
|
3093
|
+
}, "bubble") : null,
|
|
3094
|
+
quotes.length > 0 ? (0, react_jsx_runtime.jsx)(TranscriptQuoteCard, { node: { data: { quotes } } }, "quotes") : null
|
|
3095
|
+
]
|
|
2719
3096
|
})
|
|
2720
3097
|
});
|
|
2721
3098
|
}
|
|
2722
3099
|
//#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
3100
|
//#region src/client/index.tsx
|
|
2775
3101
|
/**
|
|
2776
3102
|
* Browser half of dsh-select-quote.
|
|
2777
3103
|
*
|
|
2778
3104
|
* Selection toolbar is root-scoped (`shell.overlay`) so transcript selections
|
|
2779
3105
|
* are always observed. Annotation summary stays on the session composer.
|
|
3106
|
+
*
|
|
3107
|
+
* History cards render **inside** the `user` Chat node view, not on a
|
|
3108
|
+
* `select-quote` Chat node. `conversation.chat.node` is gated by the shipped
|
|
3109
|
+
* `TURN_PROCESS_INDEPENDENT_KINDS` allowlist (system-prompt, user, steering,
|
|
3110
|
+
* turn-trigger, turn-process, turn-error, turn-max-tokens, turn-tail): any other
|
|
3111
|
+
* kind is classified as a turn-process member and `hidden` when the completed
|
|
3112
|
+
* turn's process disclosure is folded — mounted, but invisible. `user` is on
|
|
3113
|
+
* that allowlist, so the bubble is the only placement that is reliably visible.
|
|
3114
|
+
*
|
|
3115
|
+
* Language is the host's decision, never this plugin's: dictionaries go to
|
|
3116
|
+
* `ctx.locale`, and `t()` resolves against whatever language the user picked in
|
|
3117
|
+
* Settings. Self-detecting from `navigator` would disagree with the host on the
|
|
3118
|
+
* desktop shell, where Electron pins the document language (`--lang`) to its own
|
|
3119
|
+
* value while the host preference comes through `__DSH_LOCALE__`.
|
|
2780
3120
|
*/
|
|
2781
3121
|
const inject = [
|
|
2782
3122
|
"slots",
|
|
2783
3123
|
"sessions",
|
|
2784
|
-
"
|
|
3124
|
+
"locale"
|
|
2785
3125
|
];
|
|
2786
3126
|
function apply(ctx) {
|
|
2787
3127
|
ensureToolbarStyles();
|
|
2788
|
-
|
|
3128
|
+
ctx.effect(() => ctx.locale.addLanguage(LANGUAGE_RU), "dsh-select-quote: ru language");
|
|
3129
|
+
ctx.effect(() => ctx.locale.register(LOCALE_NS, SHIPPED_DICTS), "dsh-select-quote: dictionaries (zh, en)");
|
|
3130
|
+
ctx.effect(() => ctx.locale.register(LOCALE_NS, LANGUAGE_RU.id, DICTIONARIES.ru), "dsh-select-quote: dictionary (ru)");
|
|
3131
|
+
bindLocale({
|
|
3132
|
+
translate: ctx.locale.bind(LOCALE_NS),
|
|
3133
|
+
getActive: () => ctx.locale.getLocale().active,
|
|
3134
|
+
getRevision: () => ctx.locale.getSnapshot().revision,
|
|
3135
|
+
subscribe: (listener) => ctx.locale.subscribe(listener)
|
|
3136
|
+
});
|
|
3137
|
+
ctx.effect(() => subscribeLocale(() => refreshDirectiveChips()), "dsh-select-quote: chip language");
|
|
2789
3138
|
ctx.effect(() => watchAnnotationDirectives(), "dsh-select-quote: annotation directives");
|
|
2790
3139
|
ctx.effect(() => watchAnnotationMarks(), "dsh-select-quote: annotation marks");
|
|
2791
3140
|
ctx.effect(() => {
|
|
@@ -2814,10 +3163,6 @@ function apply(ctx) {
|
|
|
2814
3163
|
}, SessionBridge);
|
|
2815
3164
|
});
|
|
2816
3165
|
ctx.slots.inject("conversation.chat.node", () => {
|
|
2817
|
-
ctx.slots.register({
|
|
2818
|
-
name: "conversation.chat.node",
|
|
2819
|
-
key: "select-quote"
|
|
2820
|
-
}, TranscriptQuoteCard);
|
|
2821
3166
|
ctx.slots.register({
|
|
2822
3167
|
name: "conversation.chat.node",
|
|
2823
3168
|
key: "user",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-select-quote",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
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",
|
|
@@ -39,7 +39,8 @@
|
|
|
39
39
|
"watch": "node scripts/build.mjs --watch",
|
|
40
40
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
41
41
|
"verify": "node scripts/verify-bundle.mjs",
|
|
42
|
-
"check": "npm run typecheck && npm run build && npm run verify"
|
|
42
|
+
"check": "npm run typecheck && npm run build && npm run verify",
|
|
43
|
+
"release": "npm run check && commit-and-tag-version"
|
|
43
44
|
},
|
|
44
45
|
"dsh": {
|
|
45
46
|
"client": {
|
|
@@ -66,8 +67,10 @@
|
|
|
66
67
|
"devDependencies": {
|
|
67
68
|
"@types/react": "~18.3.1",
|
|
68
69
|
"@types/react-dom": "~18.3",
|
|
70
|
+
"commit-and-tag-version": "^13.2.2",
|
|
69
71
|
"react": "^18.2.0",
|
|
70
72
|
"react-dom": "^18.2.0",
|
|
73
|
+
"tsdown": "0.23.0",
|
|
71
74
|
"typescript": "^5.8.0"
|
|
72
75
|
}
|
|
73
76
|
}
|