dsh-conversation-navigator 0.2.9 → 0.2.10

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.en.md CHANGED
@@ -27,6 +27,7 @@ Browser-only (no host behavior), plain JavaScript, zero build step, zero npm dep
27
27
 
28
28
  - **Turn-folded outline**: shows "Turn N + your question" rows by default, long conversations at a glance
29
29
  - **Keyword filter**: click the top-left search icon to reveal the inline input; matches only your questions + the assistant's actual reply text (context, tool calls, commands, compaction and reasoning never match); hits are highlighted and the list text is windowed to the keyword
30
+ - **Jump straight to the hit**: clicking a search result scrolls to where the keyword actually appears in the conversation (not just the top of the message) and marks every occurrence inside that message — a brief flash that eases into a soft persistent tint, so long replies no longer need hunting
30
31
  - **Expand / collapse steps**: the arrow button on the right of each turn row (`▸ N`, rotates to `▾` when expanded) smoothly expands or collapses the turn's step details (assistant replies, tool calls, commands, compaction points, etc.)
31
32
  - **Hover full text**: rest the mouse on a turn row and a bubble shows the user's complete question, no longer truncated to one line
32
33
  - **Full / Hidden / Minimal-left modes**: the header toggle cycles three states —
@@ -101,7 +102,7 @@ Official v0.1.2-rc.1 made a breaking change to the conversation data layer (conv
101
102
 
102
103
  | Host `dsh-client-ui-conversation` | Matching plugin version | npm tag |
103
104
  |---|---|---|
104
- | **≥ 0.1.2-rc.1** (has the `uiConversation` service) | **0.2.6 and later** (currently 0.2.9) | `latest` (default) |
105
+ | **≥ 0.1.2-rc.1** (has the `uiConversation` service) | **0.2.6 and later** (currently 0.2.10) | `latest` (default) |
105
106
  | **≤ 0.1.1-rc.2** (no `uiConversation`, legacy `session.chat` snapshot) | **0.2.5 and earlier** | `legacy` |
106
107
 
107
108
  - New hosts can install the latest normally (`dsh plugin --profile web add dsh-conversation-navigator`).
@@ -122,11 +123,14 @@ example.patch.yml # patch example for manual installs
122
123
 
123
124
  ## Traffic
124
125
 
125
- ![Traffic trend](./docs/traffic/traffic-trend.svg)
126
+ Window **2026-08-17 → 2026-09-20** (refreshed daily by GitHub Actions):
126
127
 
127
- - Refreshed daily by GitHub Actions (top: GitHub unique visitors / unique cloners; bottom: npm daily downloads; orange dashed lines mark releases).
128
- - Details: [traffic-report.md](./docs/traffic/traffic-report.md) | raw archive: [traffic-archive.json](./docs/traffic/traffic-archive.json) | docs & manual refresh: [docs/traffic](./docs/traffic/README.md)
129
- - Why an archive: GitHub's traffic API only keeps a rolling 14-day window, and older data is lost forever — this repo merges every fetch into a durable, append-mostly daily series.
128
+ | npm downloads | GitHub unique visitors | GitHub unique cloners | Stars | Forks |
129
+ | ---: | ---: | ---: | ---: | ---: |
130
+ | 6,206 | 540 | 335 | 23 | 1 |
131
+
132
+ - Details: [traffic-report.md](./docs/traffic/traffic-report.md) | raw archive: [traffic-archive.json](./docs/traffic/traffic-archive.json) | chart & docs: [docs/traffic](./docs/traffic/README.md)
133
+ - Caveats: npm downloads include mirrors, CI and reinstalls, with a 1–2 day settlement lag; GitHub unique visitors / cloners are closer to real user counts.
130
134
 
131
135
  ## License
132
136
 
package/README.md CHANGED
@@ -27,6 +27,7 @@
27
27
 
28
28
  - **按轮折叠大纲**:默认只显示"第 N 轮 + 你的问题"列表,长对话一目了然
29
29
  - **关键词过滤**:点击左上角搜索图标呼出输入框,只按「你的提问 + 助手实际回复文本」过滤(上下文、工具、命令、压缩、推理等不参与匹配),命中词高亮,列表文本自动定位到关键词处
30
+ - **搜索命中直达**:点击命中的搜索结果,直接滚动到关键词在会话正文中出现的位置(不再只对齐消息顶部),并把该条消息内所有命中处标出来——先亮一下,再平滑淡出为常驻淡色,长回复里不用再自己找
30
31
  - **展开/折叠步次**:点击轮次行右侧的箭头按钮(`▸ N`,展开时旋转为 `▾`)平滑展开/折叠该轮的步次明细(助手回复、工具调用、命令、压缩点等)
31
32
  - **悬停查看全文**:鼠标在轮次行停留片刻,气泡显示该轮用户提问的完整原文,不再被单行截断
32
33
  - **显示 / 隐藏 / 极简·左三种形态**:头部切换按钮三态循环——「显示轮次」为经典分组视图(压缩等系统事件与轮次同级加粗展示)→「隐藏轮次」每行统一为轨迹徽标 + 文本(用户 = 业务蓝、助手 = 紫罗兰、压缩 = 中性灰)→「极简·左」锚定对话区左缘(为 dsh-better-sidebar 让出右侧,并与官方右侧轨道零重叠),再回到「显示轮次」;「极简·右」形态自 0.2.6 起暂缓(与官方 TurnNavigator 轨道视觉重叠),代码保留;
@@ -98,7 +99,7 @@ dsh plugin --profile web add ./dsh-conversation-navigator-<version>.tgz
98
99
 
99
100
  | 宿主 `dsh-client-ui-conversation` | 对应插件版本 | npm 标签 |
100
101
  |---|---|---|
101
- | **≥ 0.1.2-rc.1**(有 `uiConversation` 服务) | **0.2.6 及以后**(当前 0.2.9) | `latest`(默认) |
102
+ | **≥ 0.1.2-rc.1**(有 `uiConversation` 服务) | **0.2.6 及以后**(当前 0.2.10) | `latest`(默认) |
102
103
  | **≤ 0.1.1-rc.2**(无 `uiConversation`,旧 `session.chat` 快照) | **0.2.5 及以前** | `legacy` |
103
104
 
104
105
  - 新宿主直接安装最新版即可(`dsh plugin --profile web add dsh-conversation-navigator`)。
@@ -119,11 +120,14 @@ example.patch.yml # 手动安装时的补丁示例
119
120
 
120
121
  ## 流量
121
122
 
122
- ![流量趋势](./docs/traffic/traffic-trend.svg)
123
+ 统计区间 **2026-08-17 → 2026-09-20**(GitHub Actions 每日自动刷新):
123
124
 
124
- - 上图由 GitHub Actions 每日自动刷新(上半区 GitHub 独立访客/独立克隆者,下半区 npm 每日下载,橙色虚线为发版日)。
125
- - 明细报告:[traffic-report.md](./docs/traffic/traffic-report.md)|原始归档:[traffic-archive.json](./docs/traffic/traffic-archive.json)|说明与本地刷新:[docs/traffic](./docs/traffic/README.md)
126
- - 为什么需要归档:GitHub 流量 API 只保留最近 14 天,过期即永久丢失;本仓库把每次抓取合并留存,逐日序列只增不减。
125
+ | npm 下载 | GitHub 独立访客 | GitHub 独立克隆者 | Stars | Forks |
126
+ | ---: | ---: | ---: | ---: | ---: |
127
+ | 6,206 | 540 | 335 | 23 | 1 |
128
+
129
+ - 明细报告:[traffic-report.md](./docs/traffic/traffic-report.md)|原始归档:[traffic-archive.json](./docs/traffic/traffic-archive.json)|趋势图与说明:[docs/traffic](./docs/traffic/README.md)
130
+ - 口径:npm 下载含镜像站、CI 与重复安装,且有 1–2 天结算延迟;GitHub 独立访客 / 独立克隆者更接近真实人数。
127
131
 
128
132
  ## License
129
133
 
package/lib/client.js CHANGED
@@ -232,6 +232,7 @@ window.__ModuleLoader__.load({
232
232
  background: color-mix(in srgb, var(--dsw-alias-state-warn-primary, #f59e0b) 30%, transparent);
233
233
  color: inherit; border-radius: 2px; padding: 0 1px; font-weight: 600;
234
234
  }
235
+
235
236
  .cnvnav-empty { padding: 18px 10px; text-align: center; color: var(--dsw-alias-label-secondary, #888); }
236
237
  /* ---------- 极简模式 ---------- */
237
238
  .cnvnav-mini-wrap {
@@ -911,13 +912,223 @@ window.__ModuleLoader__.load({
911
912
  return null;
912
913
  }
913
914
  let lastActive = null;
914
- function jumpTo(key) {
915
+ /* ---------- 会话正文内的命中定位(点击搜索命中后把关键词标在正文里) ---------- */
916
+ const FIND_STEPS = 28; /* 淡出用的预置层数(层名 cnvnav-find-s0 … s27) */
917
+ const FIND_HOT_PCT = 55; /* 命中瞬间的背景浓度(%) */
918
+ const FIND_DIM_PCT = 22; /* 最终常驻的淡色浓度(%) */
919
+ const FIND_HOLD_MS = 300; /* 最亮保持时长 */
920
+ const FIND_FADE_MS = 900; /* 变淡时长(easeOutCubic 逐帧换层) */
921
+ const FIND_MAX_RANGES = 200;
922
+ const FIND_BLOCK_TAGS = { P: 1, DIV: 1, LI: 1, UL: 1, OL: 1, PRE: 1, BLOCKQUOTE: 1, TD: 1, TH: 1, TR: 1, TABLE: 1, SECTION: 1, ARTICLE: 1, H1: 1, H2: 1, H3: 1, H4: 1, H5: 1, H6: 1 };
923
+ let findRaf = null; /* 淡出动画句柄 */
924
+ let findStep = null; /* 当前注册的层号 */
925
+ let findMark = null; /* 当前高亮的 Highlight 对象(各层共用同一份 Range) */
926
+ function findLayerName(step) {
927
+ return "cnvnav-find-s" + step;
928
+ }
929
+ function findAlphaPct(step) {
930
+ const k = FIND_STEPS <= 1 ? 1 : step / (FIND_STEPS - 1);
931
+ return FIND_HOT_PCT + (FIND_DIM_PCT - FIND_HOT_PCT) * k;
932
+ }
933
+ /* ::highlight 伪元素不支持 transition/animation, 只能预置多层不同浓度逐帧切换 */
934
+ function findFadeCss() {
935
+ let out = "\n/* 会话正文内的命中高亮: 逐帧换层实现平滑淡出(::highlight 不支持 transition) */";
936
+ for (let i = 0; i < FIND_STEPS; i++) {
937
+ out += "\n::highlight(" + findLayerName(i) + ") {\n background: color-mix(in srgb, var(--dsw-alias-state-warn-primary, #f59e0b) " + findAlphaPct(i).toFixed(1) + "%, transparent);\n color: inherit;\n}";
938
+ }
939
+ return out;
940
+ }
941
+ (function injectFindFadeCss() {
942
+ try {
943
+ const el = document.createElement("style");
944
+ el.textContent = findFadeCss();
945
+ document.head.appendChild(el);
946
+ } catch (err) { console.error(err); }
947
+ })();
948
+ /* 模块顶部的 const CSS 是插件自己的样式字符串, 会遮蔽浏览器全局 CSS, 故走 globalThis */
949
+ function highlightApi() {
950
+ try {
951
+ const api = typeof globalThis === "undefined" ? undefined : globalThis.CSS;
952
+ if (api === undefined || api === null || api.highlights === undefined) return null;
953
+ return api.highlights;
954
+ } catch (err) { return null; }
955
+ }
956
+ function highlightCtor() {
957
+ try {
958
+ return typeof globalThis === "undefined" ? undefined : globalThis.Highlight;
959
+ } catch (err) { return undefined; }
960
+ }
961
+ function dropFindLayer() {
962
+ const store = highlightApi();
963
+ if (store !== null && findStep !== null) store.delete(findLayerName(findStep));
964
+ findStep = null;
965
+ }
966
+ function showFindStep(step) {
967
+ const store = highlightApi();
968
+ if (store === null || findMark === null) return;
969
+ const next = Math.max(0, Math.min(FIND_STEPS - 1, step));
970
+ if (next === findStep) return;
971
+ if (findStep !== null) store.delete(findLayerName(findStep));
972
+ store.set(findLayerName(next), findMark);
973
+ findStep = next;
974
+ }
975
+ function stopFindFade() {
976
+ if (findRaf !== null) {
977
+ if (typeof cancelAnimationFrame === "function") cancelAnimationFrame(findRaf);
978
+ findRaf = null;
979
+ }
980
+ }
981
+ function startFindFade() {
982
+ stopFindFade();
983
+ if (typeof requestAnimationFrame !== "function") { showFindStep(FIND_STEPS - 1); return; }
984
+ const now = () => (typeof performance !== "undefined" && typeof performance.now === "function" ? performance.now() : Date.now());
985
+ const started = now();
986
+ const tick = () => {
987
+ const t = now() - started;
988
+ if (t <= FIND_HOLD_MS) showFindStep(0);
989
+ else if (t >= FIND_HOLD_MS + FIND_FADE_MS) { showFindStep(FIND_STEPS - 1); findRaf = null; return; }
990
+ else {
991
+ const u = (t - FIND_HOLD_MS) / FIND_FADE_MS;
992
+ const eased = 1 - Math.pow(1 - u, 3); /* easeOutCubic */
993
+ showFindStep(Math.round(eased * (FIND_STEPS - 1)));
994
+ }
995
+ findRaf = requestAnimationFrame(tick);
996
+ };
997
+ findRaf = requestAnimationFrame(tick);
998
+ }
999
+ function clearFindHighlight() {
1000
+ try {
1001
+ stopFindFade();
1002
+ dropFindLayer();
1003
+ findMark = null;
1004
+ } catch (err) { console.error(err); }
1005
+ }
1006
+ /* 最近的块级祖先: 跨块边界补一个分隔符, 避免两段文字被误判成一个词 */
1007
+ function findBlockOf(node) {
1008
+ let el = node.parentElement;
1009
+ while (el !== null && el !== undefined) {
1010
+ if (FIND_BLOCK_TAGS[el.tagName] === 1) return el;
1011
+ el = el.parentElement;
1012
+ }
1013
+ return null;
1014
+ }
1015
+ /* 收集锚点子树内可见的文本节点(跳过脚本/样式) */
1016
+ function collectFindTextNodes(root) {
1017
+ const out = [];
1018
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
1019
+ acceptNode(node) {
1020
+ const text = node.nodeValue;
1021
+ if (text === null || text === "" || text.trim() === "") return NodeFilter.FILTER_REJECT;
1022
+ const parent = node.parentElement;
1023
+ if (parent === null || parent === undefined) return NodeFilter.FILTER_REJECT;
1024
+ const tag = parent.tagName;
1025
+ if (tag === "SCRIPT" || tag === "STYLE" || tag === "NOSCRIPT" || tag === "TEXTAREA") return NodeFilter.FILTER_REJECT;
1026
+ return NodeFilter.FILTER_ACCEPT;
1027
+ },
1028
+ });
1029
+ for (let node = walker.nextNode(); node !== null; node = walker.nextNode()) out.push(node);
1030
+ return out;
1031
+ }
1032
+ /* 归一化索引: 连续空白折叠成单个空格; map[i] = [文本节点, 节点内偏移] */
1033
+ function buildFindIndex(root) {
1034
+ const nodes = collectFindTextNodes(root);
1035
+ let text = "";
1036
+ const map = [];
1037
+ let prevBlock = null;
1038
+ let pendingSpace = false;
1039
+ for (const node of nodes) {
1040
+ const value = node.nodeValue;
1041
+ const block = findBlockOf(node);
1042
+ if (text !== "" && (block === null || block !== prevBlock)) pendingSpace = true;
1043
+ prevBlock = block;
1044
+ for (let i = 0; i < value.length; i++) {
1045
+ const ch = value.charAt(i);
1046
+ if (ch === " " || ch === "\t" || ch === "\n" || ch === "\r" || ch === "\f" || ch === "\v") { pendingSpace = text !== ""; continue; }
1047
+ if (pendingSpace) { text += " "; map.push([node, i]); pendingSpace = false; }
1048
+ text += ch;
1049
+ map.push([node, i]);
1050
+ }
1051
+ }
1052
+ return { text, map };
1053
+ }
1054
+ function findNeedle(kw) {
1055
+ return String(kw).replace(/\s+/g, " ").trim().toLowerCase();
1056
+ }
1057
+ /* 关键词在 DOM 里的每一处出现 → Range(最多 FIND_MAX_RANGES 处) */
1058
+ function findKeywordRanges(root, needle) {
1059
+ const ranges = [];
1060
+ if (needle === "") return ranges;
1061
+ const index = buildFindIndex(root);
1062
+ if (index.text === "") return ranges;
1063
+ let hay = index.text.toLowerCase();
1064
+ if (hay.length !== index.text.length) hay = index.text; /* 极端 Unicode 大小写长度变化时退回原串 */
1065
+ let from = 0;
1066
+ for (;;) {
1067
+ const at = hay.indexOf(needle, from);
1068
+ if (at === -1) break;
1069
+ const start = index.map[at];
1070
+ const end = index.map[at + needle.length - 1];
1071
+ if (start !== undefined && end !== undefined) {
1072
+ try {
1073
+ const range = document.createRange();
1074
+ range.setStart(start[0], start[1]);
1075
+ range.setEnd(end[0], end[1] + 1);
1076
+ ranges.push(range);
1077
+ } catch (err) { /* 节点已被宿主重渲染替换: 跳过这一处 */ }
1078
+ }
1079
+ from = at + needle.length;
1080
+ if (ranges.length >= FIND_MAX_RANGES) break;
1081
+ }
1082
+ return ranges;
1083
+ }
1084
+ /* 把首个命中滚到视口偏上处(而不是消息顶部) */
1085
+ function scrollRangeIntoView(range) {
1086
+ try {
1087
+ const rect = range.getBoundingClientRect();
1088
+ const scrollport = document.querySelector("[data-conversation-scroll]");
1089
+ if (scrollport !== null && scrollport !== undefined) {
1090
+ const box = scrollport.getBoundingClientRect();
1091
+ const delta = rect.top - box.top - Math.max(72, box.height * 0.32);
1092
+ if (typeof scrollport.scrollBy === "function") scrollport.scrollBy({ top: delta, behavior: "smooth" });
1093
+ else scrollport.scrollTop += delta;
1094
+ return;
1095
+ }
1096
+ const host = range.startContainer;
1097
+ const el = host.nodeType === 1 ? host : host.parentElement;
1098
+ if (el !== null && el !== undefined && typeof el.scrollIntoView === "function") el.scrollIntoView({ behavior: "smooth", block: "center" });
1099
+ } catch (err) { console.error(err); }
1100
+ }
1101
+ /* 正文内定位并高亮: 先最亮, 再 easeOut 逐帧变淡到常驻淡色; 返回是否成功 */
1102
+ function locateInConversation(row, kw) {
1103
+ clearFindHighlight();
1104
+ try {
1105
+ const store = highlightApi();
1106
+ const Ctor = highlightCtor();
1107
+ if (store === null || typeof Ctor !== "function") return false;
1108
+ const needle = findNeedle(kw);
1109
+ const ranges = findKeywordRanges(row, needle);
1110
+ if (ranges.length === 0) return false;
1111
+ findMark = new Ctor(...ranges);
1112
+ showFindStep(0);
1113
+ startFindFade();
1114
+ scrollRangeIntoView(ranges[0]);
1115
+ return true;
1116
+ } catch (err) {
1117
+ console.error(err);
1118
+ clearFindHighlight();
1119
+ return false;
1120
+ }
1121
+ }
1122
+ function jumpTo(key, kw) {
915
1123
  try {
916
1124
  const el = findAnchor(key);
917
1125
  if (el === null) return false;
918
- el.scrollIntoView({ behavior: "smooth", block: "start" });
919
1126
  lastActive = key;
920
1127
  setState({ activeKey: key });
1128
+ /* 搜索命中: 直接定位到关键词所在处并在正文里高亮; 其余情况退回消息顶部 */
1129
+ if (typeof kw === "string" && kw !== "" && locateInConversation(el, kw)) return true;
1130
+ clearFindHighlight();
1131
+ el.scrollIntoView({ behavior: "smooth", block: "start" });
921
1132
  return true;
922
1133
  } catch (err) {
923
1134
  console.error(err);
@@ -1095,6 +1306,7 @@ window.__ModuleLoader__.load({
1095
1306
  }
1096
1307
  function toggleSearch() {
1097
1308
  if (searchOpen) setFilter("");
1309
+ clearFindHighlight();
1098
1310
  setSearchOpen(!searchOpen);
1099
1311
  }
1100
1312
  /* ---------- 拖动定位(仅面板形式, 极简条不参与) ---------- */
@@ -1504,7 +1716,7 @@ window.__ModuleLoader__.load({
1504
1716
  ref: (el) => { itemEls[item.key] = el; },
1505
1717
  className: "cnvnav-item" + (item.key === snap.activeKey ? " cnvnav-item-active" : "") + (staggerMs !== undefined ? " cnvnav-fade-item" : ""),
1506
1718
  style: staggerMs !== undefined ? { animationDelay: staggerMs + "ms" } : undefined,
1507
- onClick: () => jumpTo(item.key),
1719
+ onClick: () => jumpTo(item.key, filtering && hit(item.search) ? kw : null),
1508
1720
  },
1509
1721
  React.createElement("span", { className: "cnvnav-badge cnvnav-badge-" + item.cls }, item.label),
1510
1722
  hasPreview
@@ -1521,6 +1733,8 @@ window.__ModuleLoader__.load({
1521
1733
  const useSnippet = filtering && hit(headSearch);
1522
1734
  const sub = useSnippet ? snippetNodes(headSearch, kw, 5, 55) : g.subtitle;
1523
1735
  const hasSub = sub !== "" && (!Array.isArray(sub) || sub.length > 0);
1736
+ /* 组头自身命中时, 点击同样在正文里定位关键词 */
1737
+ const headKw = filtering && g.headIsUser && g.items.length > 0 && hit(g.items[0].search) ? kw : null;
1524
1738
  /* 标题策略:
1525
1739
  显示轮次: 轮次行 = 加粗「第 N 轮」; 压缩等非轮次行 = 加粗类型标签, 视作轮次同级
1526
1740
  隐藏轮次: 每行统一轨迹徽标 — 轮次头 = 「用户」业务蓝(助手开头则助手紫), 非轮次行 = 各自徽标(压缩=中性灰) */
@@ -1557,7 +1771,7 @@ window.__ModuleLoader__.load({
1557
1771
  React.createElement("button", {
1558
1772
  type: "button",
1559
1773
  className: "cnvnav-group-head",
1560
- onClick: () => jumpTo(g.headKey),
1774
+ onClick: () => jumpTo(g.headKey, headKw),
1561
1775
  },
1562
1776
  titleNode,
1563
1777
  hasSub
@@ -1702,7 +1916,7 @@ window.__ModuleLoader__.load({
1702
1916
  placeholder: "请输入关键词",
1703
1917
  value: filter,
1704
1918
  autoFocus: true,
1705
- onChange: (e) => setFilter(e.target.value),
1919
+ onChange: (e) => { clearFindHighlight(); setFilter(e.target.value); },
1706
1920
  })
1707
1921
  : null,
1708
1922
  searchOpen
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-conversation-navigator",
3
- "version": "0.2.9",
3
+ "version": "0.2.10",
4
4
  "description": "DSH(DeepSeek Harness)会话导航插件:按轮折叠的对话大纲面板,支持节点点击跳转、滚动位置高亮与轨迹视图同款配色。",
5
5
  "keywords": [
6
6
  "dsh",