dsh-term-dict 0.1.0 → 0.1.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.en.md CHANGED
@@ -55,8 +55,9 @@ the official runtime-context channel. Once per session per term; the state lives
55
55
 
56
56
  - **Timing**: words in the user message apply before that model call; words in the model's own reasoning or
57
57
  output apply from the next step or next turn (the stream already being generated cannot be changed).
58
- - **Scope**: every agent preset by default. Pick presets in *Settings → Advanced → presets* (the local roster is listed for you) to restrict it;
59
- outside those presets the dictionary tools are not registered at all. Empty means unrestricted.
58
+ - **Scope**: general sessions (no preset, or a preset that cannot be detected) always get it. Pick presets in *Settings → Advanced → presets*
59
+ (the local roster is listed for you); a preset outside that list — including any newly created one — gets nothing, and the dictionary tools
60
+ are not registered there. An empty list means only general sessions.
60
61
  - **Limits**: 64 terms per session, 12000 characters in total, entries not truncated — all adjustable.
61
62
  - **Skipping**: pick local workspaces in the panel (path-substring match, so a parent directory also covers its subdirectories) or type extra fragments (`skipWorkspaces`); session ids go through `skipSessions`; the
62
63
  composer button controls the current session, and each task layer can be forced on or off per session.
package/README.md CHANGED
@@ -51,7 +51,7 @@
51
51
  同一会话同一词条只注入一次,状态记在 `$DSH_HOME/dict/.activation.json`,重启或恢复会话后不重复注入。
52
52
 
53
53
  - **生效时机**:用户消息里的词在本次模型调用前生效;模型思考与输出里的词只能从下一步或下一轮生效(正在流式生成的那一段改不了)。
54
- - **灌注范围**:默认所有模式都灌注。设置面板「高级设置 → 灌注的模式」会自动列出本机所有模式(带显示名与 id),勾上就只在这些模式里灌注,别的模式连字典工具都看不到;全不勾=所有模式。清单取不到时退回手填 id。
54
+ - **灌注范围**:通用会话(没有模式或判不出模式)默认灌注。设置面板「高级设置 → 灌注的模式」会自动列出本机所有模式(带显示名与 id),勾上才在这些模式里灌注——新建的模式默认不灌,别的模式连字典工具都看不到;全不勾=只灌注通用会话。清单取不到时退回手填 id。
55
55
  - **上限**:默认单会话 64 条、整段 12000 字、单条不截断,都能在面板里改。
56
56
  - **不灌注**:面板里勾选本机工作空间(按路径子串匹配,勾上层目录会连带命中其子目录),也可手填其它路径片段(`skipWorkspaces`);按会话 id 排除走 `skipSessions`;输入栏「词条·注/禁」按钮管当前会话,任务层还能单独设「跟随/强制开/强制关」。
57
57
 
@@ -90,7 +90,7 @@ dsh plugin --profile desktop add github:taoziliyouchong/dsh-term-dict
90
90
  ```
91
91
 
92
92
  装完重启 DSH。用别的 profile 就把 `desktop` 换成你的 profile 名。
93
- 要求 DSH >= 0.2.2-0;实测 0.2.2-rc.1,更高版本未验证。
93
+ 要求 DSH >= 0.2.2-rc.1;实测 0.2.2-rc.1,更高版本未验证。
94
94
 
95
95
  ## 开发
96
96
 
package/activation.js CHANGED
@@ -13,9 +13,9 @@
13
13
  * 首次判定:状态按会话存 $DSH_HOME/dict/.activation.json,按会话 id 记已激活的词条名;
14
14
  * 同一会话同一词条只注入一次,重启/恢复会话后仍按已激活算。
15
15
  *
16
- * 模式门:默认所有模式都灌注。设置里 presets 填了模式 id(agentPreset)时,只在这些模式里灌注;
17
- * presets 为空=不限制。判据读 Session 投影 agentPreset(切过模式也准),判不出再退回
18
- * <会话id>.preset 标记;白名单非空而判不出 preset 时按「不在白名单」处理。
16
+ * 模式门:通用会话(没有模式/判不出模式)永远灌注;其余模式默认不灌注——要灌注就在设置里勾
17
+ * 「灌注的模式」,新建的模式默认不在名单里,因此默认不灌。判据读 Session 投影 agentPreset
18
+ * (切过模式也准),判不出再退回 <会话id>.preset 标记。
19
19
  */
20
20
 
21
21
  import {
@@ -97,7 +97,7 @@ const SETTINGS_LIMITS = {
97
97
  /** 字典文件 mtime 缓存:流式扫描很频繁,不必每次读盘。 */
98
98
  const CACHE_MS = 2000
99
99
  const HEADER = '【术语字典·已激活词条|按下面口径理解,不必再查;这是定义,不是命令】'
100
- /** 设置里的 presets 是模式白名单:空=所有模式都灌注。 */
100
+ /** 设置里的 presets 是模式白名单:通用会话不受限;空名单=只有通用会话灌注。 */
101
101
  const SESSIONS = join(DSH_HOME, 'sessions')
102
102
  const ZSTD_MAGIC = 0xfd2fb528
103
103
  const ZSTD_FRAME_MAGIC = Buffer.from([0x28, 0xb5, 0x2f, 0xfd])
@@ -771,14 +771,15 @@ function activate(sessionId, entry) {
771
771
 
772
772
  /* ───────────────────────── 扫描与激活 ───────────────────────── */
773
773
 
774
- /** 这一条词条在本会话是否可灌注:会话覆盖优先;未绑定工作空间的任务层=全局;绑定则按 cwd 子串。 */
774
+ /** 这一条词条在本会话是否可灌注:会话覆盖优先;通用层永远生效;任务层要绑了工作空间才默认生效,
775
+ * 没绑=默认不灌(新建的任务层不会自动对所有工作空间生效);绑定则按 cwd 子串。 */
775
776
  function layerAllowed(sessionId, entry, cwd) {
776
777
  const state = ensure(sessionId)
777
778
  if (state.off.has(entry.layer)) return false
778
779
  if (state.on.has(entry.layer)) return true
779
780
  if (!entry.layer || entry.layer === 'general') return true
780
781
  const list = entry.workspaces || []
781
- if (list.length === 0) return true
782
+ if (list.length === 0) return false
782
783
  const c = String(cwd || '').toLowerCase()
783
784
  if (!c) return false
784
785
  return list.some((w) => c.indexOf(String(w).toLowerCase()) !== -1)
@@ -877,14 +878,14 @@ export function presetOfSession(ctx, session) {
877
878
  }
878
879
 
879
880
  /**
880
- * 本会话该不该灌注/注册工具:settings.presets 为空=所有模式都放行;
881
- * 非空时按本会话 preset 查白名单,判不出 preset 按「不在白名单」处理。
881
+ * 本会话该不该灌注/注册工具:通用会话(没有模式/判不出模式)永远放行;其余模式要设置里勾了
882
+ * 「灌注的模式」才放行——新建的模式默认不在名单里,因此默认不灌。
882
883
  */
883
884
  export function modeAllowed(ctx, session) {
884
- const allow = loadSettings().presets
885
- if (!Array.isArray(allow) || allow.length === 0) return true
886
885
  const preset = presetOfSession(ctx, session)
887
- if (typeof preset !== 'string' || preset.length === 0) return false
886
+ if (typeof preset !== 'string' || preset.length === 0) return true
887
+ const allow = loadSettings().presets
888
+ if (!Array.isArray(allow) || allow.length === 0) return false
888
889
  return allow.indexOf(preset) !== -1
889
890
  }
890
891
 
@@ -99,7 +99,7 @@
99
99
  | 自动激活(本插件,`activation.js`) | 用户消息、我的思考与输出里**第一次**出现词条名/别称时,把该条口径作为 runtime-context 注入;同一会话同一条只注入一次 |
100
100
  | 直接开文件 | 批量整理、批量替换时最快 |
101
101
 
102
- **自动激活的边界**:用户消息里的词在本次模型调用前生效;我的思考/输出里的词只能从下一步或下一轮生效(正在流式生成的那一段改不了)。只认精确命中(词条名/别称),不做形近联想——形近候选仍走 `lookup`。所有模式都跑,不设模式门;状态记在 `$DSH_HOME\dict\.activation.json`。
102
+ **自动激活的边界**:用户消息里的词在本次模型调用前生效;我的思考/输出里的词只能从下一步或下一轮生效(正在流式生成的那一段改不了)。只认精确命中(词条名/别称),不做形近联想——形近候选仍走 `lookup`。通用会话(没有模式或判不出模式)默认灌注;其余模式要设置里勾了「灌注的模式」才灌注,新建的模式默认不灌。任务层要绑了工作空间才默认生效,没绑默认不灌。状态记在 `$DSH_HOME\dict\.activation.json`。
103
103
 
104
104
  ## 什么时候用
105
105
 
package/index.js CHANGED
@@ -452,6 +452,16 @@ function renameLayer(def, nextName, nextSlug) {
452
452
  * webServer 不写进 inject 硬依赖(非 web 组合里没有它也不该拦加载),也不能只靠 ctx.get——
453
453
  * 实测 apply 期 ctx.get('webServer') 拿不到(服务还没进这个上下文),得走 ctx.inject 等到位再挂。
454
454
  */
455
+ /** 本会话当前 preset:读 profile-inject 写下的运行态文件;读不到返回 ''(判不出按允许处理,不误禁)。 */
456
+ function presetLive(id) {
457
+ if (!id) return ''
458
+ try {
459
+ return readFileSync(join(DSH_HOME, 'codex-hooks', 'state', id + '.preset-live'), 'utf8').trim()
460
+ } catch {
461
+ return ''
462
+ }
463
+ }
464
+
455
465
  function registerRoutes(ctx) {
456
466
  const direct = typeof ctx.get === 'function' ? ctx.get('webServer') : undefined
457
467
  if (direct && typeof direct.register === 'function') {
@@ -704,11 +714,18 @@ function registerOnRoutes(ctx, webServer) {
704
714
  if (req.method !== 'GET') return sendJson(res, 405, { ok: false, error: '只支持 GET' })
705
715
  const id = new URL(req.url || '/', 'http://localhost').searchParams.get('id') || ''
706
716
  const settings = loadSettings()
717
+ const preset = presetLive(id)
718
+ // 通用会话(判不出模式)永远放行;有模式时必须在「灌注的模式」名单里——新建的模式默认不在名单。
719
+ const allowed = preset === ''
720
+ || settings.presets.indexOf(preset) !== -1
707
721
  sendJson(res, 200, {
708
722
  ok: true,
709
723
  id,
710
724
  muted: id !== '' && settings.skipSessions.indexOf(id) !== -1,
711
725
  mutedCount: settings.skipSessions.length,
726
+ preset,
727
+ allowed,
728
+ presets: settings.presets,
712
729
  })
713
730
  },
714
731
  }), 'dsh-term-dict: session-state route')
@@ -1193,10 +1210,10 @@ export function apply(ctx) {
1193
1210
  if (timer && typeof timer.unref === 'function') timer.unref()
1194
1211
  } catch { /* 没有定时器就只靠启动清理 */ }
1195
1212
 
1196
- // 模式门:默认所有模式都注册字典工具;设置里 presets 填了模式 id 时,只在这些模式的 agent
1197
- // 作用域里注册(别的模式从未注册过,模型第一步就看不到,也不会留「工具已更新 · 移除」)。
1198
- // 白名单非空而判不出 preset 时不注册,下一次 pre-step 再判;切模式时按判到的 preset 注册/注销,
1199
- // dispose 挂在 agent 作用域上自动回收。技能面板里的词条用法技能是用户手动调用的,不在此列。
1213
+ // 模式门:通用会话(无模式/判不出模式)默认注册字典工具;其余模式默认不注册,只有设置里
1214
+ // presets 勾了才注册(别的模式从未注册过,模型第一步就看不到,也不会留「工具已更新 · 移除」)。
1215
+ // 切模式时按判到的 preset 注册/注销,dispose 挂在 agent 作用域上自动回收。
1216
+ // 技能面板里的词条用法技能是用户手动调用的,不在此列。
1200
1217
  const dictToolGate = new WeakMap()
1201
1218
  const gateDictTool = (agent, allowHeader) => {
1202
1219
  try {
@@ -1210,8 +1227,9 @@ export function apply(ctx) {
1210
1227
  if (header && typeof header.agentPreset === 'string' && header.agentPreset.length > 0) preset = header.agentPreset
1211
1228
  }
1212
1229
  const allow = loadSettings().presets
1213
- const restricted = Array.isArray(allow) && allow.length > 0
1214
- const allowed = restricted ? (typeof preset === 'string' && allow.indexOf(preset) !== -1) : true
1230
+ const allowed = (typeof preset !== 'string' || preset.length === 0)
1231
+ ? true
1232
+ : allow.indexOf(preset) !== -1
1215
1233
  const held = dictToolGate.get(agent)
1216
1234
  if (allowed) {
1217
1235
  if (!held) dictToolGate.set(agent, agent.ctx.tools.register(DICT_TOOL))
package/lib/client.js CHANGED
@@ -278,13 +278,25 @@ select.td-cell { padding:1px 2px; }
278
278
  .td-tab[aria-selected="true"] { background:var(--dsw-alias-bg-layer-2); font-weight:500; }
279
279
  .td-list { display:flex; flex-direction:column; }
280
280
  .td-group { display:flex; justify-content:space-between; align-items:center; gap:8px; width:100%;
281
- margin:10px 0 2px; padding:4px 6px 4px 0; border:0; background:none;
282
- color:var(--dsw-alias-label-primary); font:inherit; font-size:11px; text-align:left; cursor:pointer; }
281
+ margin:14px 0 0; padding:8px 6px; border-bottom:1px solid var(--dsw-alias-border-l1);
282
+ background:var(--dsw-alias-bg-layer-1); color:var(--dsw-alias-label-primary);
283
+ font:inherit; font-size:14px; font-weight:700; letter-spacing:0; }
283
284
  .td-group:first-child { margin-top:2px; }
284
- .td-group:hover { color:var(--dsw-alias-label-primary); }
285
- .td-groupnum { font-size:12px; color:var(--dsw-alias-label-primary); }
285
+ .td-groupbtn { flex:1 1 auto; min-width:0; display:flex; justify-content:space-between; align-items:center;
286
+ gap:8px; padding:6px 0; border:0; background:none; color:inherit; font:inherit;
287
+ text-align:left; cursor:pointer; }
288
+ .td-groupbtn:hover { color:var(--dsw-alias-brand-primary); }
289
+ .td-groupnum { font-size:10px; font-weight:400; letter-spacing:0; padding:0 6px; border-radius:999px;
290
+ background:var(--dsw-alias-bg-layer-2); color:var(--dsw-alias-label-secondary); }
291
+ .td-groupadd { padding:0 2px; border:0; background:none; color:var(--dsw-alias-label-secondary);
292
+ font:inherit; font-size:14px; line-height:1; cursor:pointer; }
293
+ .td-groupadd:hover { color:var(--dsw-alias-brand-primary); }
294
+ .td-addrow { display:block; width:100%; box-sizing:border-box; margin-top:10px; padding:7px 8px;
295
+ border:1px dashed var(--dsw-alias-border-l2); border-radius:6px; background:none;
296
+ color:var(--dsw-alias-label-secondary); font:inherit; font-size:12px; text-align:left; cursor:pointer; }
297
+ .td-addrow:hover { border-color:var(--dsw-alias-brand-primary); color:var(--dsw-alias-label-primary); }
286
298
  .td-entry { display:flex; justify-content:space-between; align-items:center; gap:12px; width:100%;
287
- padding:8px 6px; border:0; border-bottom:1px solid var(--dsw-alias-border-l1); border-radius:4px;
299
+ padding:8px 6px 8px 14px; border:0; border-bottom:1px solid var(--dsw-alias-border-l1); border-radius:4px;
288
300
  background:none; color:inherit; font:inherit; text-align:left; cursor:pointer; }
289
301
  .td-entry:hover { background:var(--dsw-alias-bg-layer-1); }
290
302
  .td-entry[aria-current="true"] { font-weight:600; background:var(--dsw-alias-bg-layer-1); }
@@ -579,6 +591,7 @@ function TermDictSection() {
579
591
  const [filter, setFilter] = (0, import_react.useState)("");
580
592
  const [editing, setEditing] = (0, import_react.useState)(null);
581
593
  const [adding, setAdding] = (0, import_react.useState)(false);
594
+ const [addingStatus, setAddingStatus] = (0, import_react.useState)("待观察");
582
595
  const [nameEditing, setNameEditing] = (0, import_react.useState)(false);
583
596
  const [nameDraft, setNameDraft] = (0, import_react.useState)("");
584
597
  const [slugEditing, setSlugEditing] = (0, import_react.useState)(false);
@@ -1530,32 +1543,45 @@ function TermDictSection() {
1530
1543
  /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "td-srow td-srow-top", children: [
1531
1544
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "td-slabel", children: "限定模式" }),
1532
1545
  /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", { className: "td-sfield td-sfield-col", children: [
1533
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "td-hint", children: "全不勾=所有模式都灌注;勾了就只在这些模式里灌注——别的模式既不注入词条,也看不到字典工具。" }),
1534
- Array.isArray(presetRoster) && presetRoster.length > 0 ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "td-chips", children: presetRoster.map((p) => {
1535
- const on = Array.isArray(d.presets) && d.presets.indexOf(p.id) !== -1;
1536
- return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
1537
- "label",
1538
- {
1539
- className: "td-check",
1540
- title: p.broken ? "这个模式现在装不起来:" + p.broken : p.description || p.id,
1541
- children: [
1542
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("input", { type: "checkbox", checked: on, onChange: () => togglePresetDraft(p.id) }),
1543
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "td-checkname", children: p.name || p.id }),
1544
- p.name && p.name !== p.id ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "td-lock", children: p.id }) : null,
1545
- p.isDefault ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "td-lock", children: "默认" }) : null,
1546
- p.broken ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "td-lock", children: "坏" }) : null
1547
- ]
1548
- },
1549
- p.id
1550
- );
1551
- }) }) : /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
1546
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "td-hint", children: "全不勾=只灌注通用会话(没有模式或判不出模式);勾了就只在这些模式里灌注——其余模式(含新建的)既不注入词条,也看不到字典工具。" }),
1547
+ Array.isArray(presetRoster) && presetRoster.length > 0 ? /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", { className: "td-chips", children: [
1548
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("label", { className: "td-check", children: [
1549
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
1550
+ "input",
1551
+ {
1552
+ type: "checkbox",
1553
+ checked: presetRoster.every((p) => Array.isArray(d.presets) && d.presets.indexOf(p.id) !== -1),
1554
+ onChange: (e) => setDraftField("presets", e.target.checked ? presetRoster.map((p) => p.id) : [])
1555
+ }
1556
+ ),
1557
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "td-checkname", children: "全选" })
1558
+ ] }),
1559
+ presetRoster.map((p) => {
1560
+ const on = Array.isArray(d.presets) && d.presets.indexOf(p.id) !== -1;
1561
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
1562
+ "label",
1563
+ {
1564
+ className: "td-check",
1565
+ title: p.broken ? "这个模式现在装不起来:" + p.broken : p.description || p.id,
1566
+ children: [
1567
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("input", { type: "checkbox", checked: on, onChange: () => togglePresetDraft(p.id) }),
1568
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "td-checkname", children: p.name || p.id }),
1569
+ p.name && p.name !== p.id ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "td-lock", children: p.id }) : null,
1570
+ p.isDefault ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "td-lock", children: "默认" }) : null,
1571
+ p.broken ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "td-lock", children: "坏" }) : null
1572
+ ]
1573
+ },
1574
+ p.id
1575
+ );
1576
+ })
1577
+ ] }) : /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
1552
1578
  "textarea",
1553
1579
  {
1554
1580
  className: "td-area",
1555
1581
  rows: 2,
1556
1582
  value: Array.isArray(d.presets) ? d.presets.join("\n") : "",
1557
1583
  spellCheck: false,
1558
- placeholder: "一行一个模式的 agentPreset id;留空=所有模式都灌注",
1584
+ placeholder: "一行一个模式的 agentPreset id;留空=只灌注通用会话",
1559
1585
  onChange: (e) => setDraftField("presets", e.target.value.split("\n"))
1560
1586
  }
1561
1587
  ),
@@ -1582,6 +1608,17 @@ function TermDictSection() {
1582
1608
  const paths = roster.map((w) => String(w.path || "").toLowerCase());
1583
1609
  const manual = list.filter((v) => paths.indexOf(String(v).toLowerCase()) === -1);
1584
1610
  return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_jsx_runtime.Fragment, { children: [
1611
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("label", { className: "td-check", children: [
1612
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
1613
+ "input",
1614
+ {
1615
+ type: "checkbox",
1616
+ checked: roster.length > 0 && roster.every((w) => list.some((v) => String(v).toLowerCase() === String(w.path || "").toLowerCase())),
1617
+ onChange: (e) => commitSkipWorkspaces(e.target.checked ? roster.map((w) => String(w.path || "")).filter((v) => v !== "") : [])
1618
+ }
1619
+ ),
1620
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "td-checkname", children: "全选" })
1621
+ ] }),
1585
1622
  roster.length > 0 ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "td-chips", children: roster.map((w) => {
1586
1623
  const on = list.some((v) => String(v).toLowerCase() === String(w.path || "").toLowerCase());
1587
1624
  return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("label", { className: "td-check", title: w.path || w.id, children: [
@@ -2001,20 +2038,7 @@ function TermDictSection() {
2001
2038
  ] }),
2002
2039
  /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "td-headacts", children: [
2003
2040
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { type: "button", className: "td-link", onClick: () => void openStructure(), children: "结构" }),
2004
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { type: "button", className: "td-link", onClick: () => void openAdvanced(), children: "设置" }),
2005
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
2006
- "button",
2007
- {
2008
- type: "button",
2009
- className: "td-link primary",
2010
- onClick: () => {
2011
- setView("terms");
2012
- setAdding(true);
2013
- setEditing(null);
2014
- },
2015
- children: "+ 新词条"
2016
- }
2017
- )
2041
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { type: "button", className: "td-link", onClick: () => void openAdvanced(), children: "设置" })
2018
2042
  ] })
2019
2043
  ] }),
2020
2044
  rnError ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { className: "td-error", role: "alert", children: rnError }) : null,
@@ -2035,47 +2059,65 @@ function TermDictSection() {
2035
2059
  ] })
2036
2060
  ] }),
2037
2061
  view === "terms" ? /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "td-list", children: [
2038
- adding ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
2039
- TermForm,
2040
- {
2041
- isNew: true,
2042
- term: "",
2043
- schema,
2044
- fields: { 状态: "待观察" },
2045
- saving: doc.saving,
2046
- layerOptions,
2047
- layerId: activeId,
2048
- onCancel: () => setAdding(false),
2049
- onSave: async (name, fields, targetId) => {
2050
- const ok = await saveEntry(name, fields, targetId, true);
2051
- if (ok) setAdding(false);
2052
- }
2053
- },
2054
- "new"
2055
- ) : null,
2056
2062
  doc.loading ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { className: "td-hint", children: "读取中…" }) : null,
2057
2063
  doc.error !== null ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { className: "td-error", role: "alert", children: doc.error }) : null,
2058
- !doc.loading && shown.length === 0 && !adding ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { className: "td-hint", children: keyword ? "没有匹配的词条。" : "这一层还没有词条,点右上角「+ 新词条」加一条。" }) : null,
2064
+ !doc.loading && shown.length === 0 && !adding ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { className: "td-hint", children: keyword ? "没有匹配的词条。" : "这一层还没有词条,点下面的「+ 新建词条」加一条。" }) : null,
2059
2065
  groups.map((g) => {
2060
2066
  const folded = Boolean(collapsedStatus[g.key]);
2061
2067
  return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_react.Fragment, { children: [
2062
- /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
2063
- "button",
2068
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "td-group", children: [
2069
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
2070
+ "button",
2071
+ {
2072
+ type: "button",
2073
+ className: "td-groupbtn",
2074
+ "aria-expanded": !folded,
2075
+ title: folded ? "展开这一组" : "收起这一组",
2076
+ onClick: () => setCollapsedStatus((c) => Object.assign({}, c, { [g.key]: !c[g.key] })),
2077
+ children: [
2078
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", { children: [
2079
+ folded ? "▸ " : "▾ ",
2080
+ g.key
2081
+ ] }),
2082
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "td-groupnum", children: g.items.length })
2083
+ ]
2084
+ }
2085
+ ),
2086
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
2087
+ "button",
2088
+ {
2089
+ type: "button",
2090
+ className: "td-groupadd",
2091
+ title: "在这一组加一条",
2092
+ onClick: () => {
2093
+ setView("terms");
2094
+ setCollapsedStatus((c) => Object.assign({}, c, { [g.key]: false }));
2095
+ setAddingStatus(g.key);
2096
+ setAdding(true);
2097
+ setEditing(null);
2098
+ },
2099
+ children: "+"
2100
+ }
2101
+ )
2102
+ ] }),
2103
+ adding && addingStatus === g.key ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
2104
+ TermForm,
2064
2105
  {
2065
- type: "button",
2066
- className: "td-group",
2067
- "aria-expanded": !folded,
2068
- title: folded ? "展开这一组" : "收起这一组",
2069
- onClick: () => setCollapsedStatus((c) => Object.assign({}, c, { [g.key]: !c[g.key] })),
2070
- children: [
2071
- /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", { children: [
2072
- folded ? "▸ " : "▾ ",
2073
- g.key
2074
- ] }),
2075
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "td-groupnum", children: g.items.length })
2076
- ]
2077
- }
2078
- ),
2106
+ isNew: true,
2107
+ term: "",
2108
+ schema,
2109
+ fields: { 状态: g.key },
2110
+ saving: doc.saving,
2111
+ layerOptions,
2112
+ layerId: activeId,
2113
+ onCancel: () => setAdding(false),
2114
+ onSave: async (name, fields, targetId) => {
2115
+ const ok = await saveEntry(name, fields, targetId, true);
2116
+ if (ok) setAdding(false);
2117
+ }
2118
+ },
2119
+ "new:" + g.key
2120
+ ) : null,
2079
2121
  folded ? null : g.items.map((t) => /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_react.Fragment, { children: [
2080
2122
  /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
2081
2123
  "button",
@@ -2115,6 +2157,37 @@ function TermDictSection() {
2115
2157
  ] }, t.term))
2116
2158
  ] }, "status:" + g.key);
2117
2159
  }),
2160
+ groups.length === 0 ? adding ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
2161
+ TermForm,
2162
+ {
2163
+ isNew: true,
2164
+ term: "",
2165
+ schema,
2166
+ fields: { 状态: addingStatus },
2167
+ saving: doc.saving,
2168
+ layerOptions,
2169
+ layerId: activeId,
2170
+ onCancel: () => setAdding(false),
2171
+ onSave: async (name, fields, targetId) => {
2172
+ const ok = await saveEntry(name, fields, targetId, true);
2173
+ if (ok) setAdding(false);
2174
+ }
2175
+ },
2176
+ "new-empty"
2177
+ ) : /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
2178
+ "button",
2179
+ {
2180
+ type: "button",
2181
+ className: "td-addrow",
2182
+ onClick: () => {
2183
+ setView("terms");
2184
+ setAddingStatus("待观察");
2185
+ setAdding(true);
2186
+ setEditing(null);
2187
+ },
2188
+ children: "+ 新建词条"
2189
+ }
2190
+ ) : null,
2118
2191
  keyword && (otherBusy || otherHits.length > 0) ? /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "td-other", children: [
2119
2192
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { type: "button", className: "td-link", onClick: () => setShowOther((v) => !v), children: otherBusy ? "正在找其他层…" : "其他层命中 " + otherHits.length + " 条 " + (showOther ? "(收起)" : "(展开)") }),
2120
2193
  showOther ? otherHits.map((h) => /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
@@ -2214,145 +2287,108 @@ function headerFor(text, layer) {
2214
2287
  return "# 术语字典·通用层\n\n> 收录判据:双方都在用、会出现口径分歧的词。格式:`## 词条名(别名:另一叫法)` 下面「- 定义/- 默认指代/- 边界/- 状态/- 备注」(除定义外可选)。\n\n";
2215
2288
  }
2216
2289
  var inject = ["slots"];
2217
- var MUTE_CSS = `
2218
- .tdict-mutebtn { display:inline-flex; align-items:center; padding:1px 8px; border:1px solid var(--dsw-alias-border-l1);
2219
- border-radius:999px; background:none; color:var(--dsw-alias-label-primary); font:inherit; font-size:11px;
2220
- line-height:1.6; cursor:pointer; white-space:nowrap; }
2221
- .tdict-mutebtn:hover:not(:disabled) { color:var(--dsw-alias-label-primary); border-color:var(--dsw-alias-border-l2); }
2222
- .tdict-mutebtn[data-muted="true"] { color:var(--dsw-alias-state-error-primary); border-color:currentColor; }
2223
- .tdict-mutebtn:disabled { opacity:.6; cursor:default; }
2224
- .tdict-menu { position:fixed; z-index:1000; width:260px; padding:8px 10px;
2225
- border:1px solid var(--dsw-alias-border-l1); border-radius:8px; background:var(--dsw-alias-bg-base);
2226
- box-shadow:0 8px 24px rgba(0,0,0,.16); display:flex; flex-direction:column; gap:6px;
2227
- font-size:11px; line-height:1.6; color:var(--dsw-alias-label-primary); }
2228
- .tdict-menuroad { display:flex; align-items:center; gap:6px; }
2229
- .tdict-menuline { display:flex; align-items:center; justify-content:space-between; gap:8px; }
2230
- .tdict-menulabel { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
2231
- .tdict-menu select { font:inherit; font-size:11px; max-width:130px; }
2232
- .tdict-menuhint { font-size:10px; line-height:1.5; color:var(--dsw-alias-label-primary); }
2233
- `;
2290
+ var MUTE_CSS = `.pp-mutebtn { padding:2px 8px; border:1px solid var(--dsw-alias-border-l1); border-radius:999px; min-width:58px; text-align:center;
2291
+ background:var(--dsw-alias-bg-base); color:var(--dsw-alias-label-primary); font:inherit; font-size:11px;
2292
+ line-height:1.4; cursor:pointer; white-space:nowrap; }
2293
+ .pp-mutebtn:hover { border-color:var(--dsw-alias-brand-primary); }
2294
+ .pp-mutebtn[data-muted="true"] { color:var(--dsw-alias-state-error-primary); border-color:currentColor; }
2295
+ .pp-mutebtn:disabled { opacity:.6; cursor:default; }`;
2296
+ var getSessionsService = () => void 0;
2297
+ function useAgentPreset(sessionId) {
2298
+ const [preset, setPreset] = (0, import_react.useState)(void 0);
2299
+ (0, import_react.useEffect)(() => {
2300
+ if (!sessionId) return void 0;
2301
+ let alive = true;
2302
+ let face;
2303
+ try {
2304
+ const service = getSessionsService();
2305
+ const binding = service && typeof service.binding === "function" ? service.binding(sessionId) : void 0;
2306
+ face = binding && binding.session && binding.session.projections ? binding.session.projections.faceOf("agentPreset") : void 0;
2307
+ } catch {
2308
+ face = void 0;
2309
+ }
2310
+ if (!face) return void 0;
2311
+ const read = () => {
2312
+ if (alive) setPreset(face.getSnapshot());
2313
+ };
2314
+ read();
2315
+ const off = typeof face.subscribe === "function" ? face.subscribe(read) : void 0;
2316
+ return () => {
2317
+ alive = false;
2318
+ if (typeof off === "function") off();
2319
+ };
2320
+ }, [sessionId]);
2321
+ return preset;
2322
+ }
2323
+ var MUTE_CACHE = /* @__PURE__ */ new Map();
2234
2324
  function SessionDictToggle(props) {
2235
2325
  const sessionId = props && props.sessionId ? String(props.sessionId) : "";
2236
- const [muted, setMuted] = (0, import_react.useState)(null);
2326
+ const preset = useAgentPreset(sessionId);
2327
+ const [muted, setMuted] = (0, import_react.useState)(() => sessionId && MUTE_CACHE.has(sessionId) ? MUTE_CACHE.get(sessionId) : null);
2328
+ const [presets, setPresets] = (0, import_react.useState)(null);
2329
+ const [serverAllowed, setServerAllowed] = (0, import_react.useState)(null);
2237
2330
  const [busy, setBusy] = (0, import_react.useState)(false);
2238
- const [open, setOpen] = (0, import_react.useState)(false);
2239
- const [layers, setLayers] = (0, import_react.useState)([]);
2240
- const [pos, setPos] = (0, import_react.useState)(null);
2241
- const btnRef = (0, import_react.useRef)(null);
2242
- const menuRef = (0, import_react.useRef)(null);
2243
2331
  (0, import_react.useEffect)(() => {
2244
2332
  if (!sessionId) return void 0;
2245
2333
  let alive = true;
2246
- getJson("/dsh-term-dict/api/session-state?id=" + encodeURIComponent(sessionId)).then((data) => {
2247
- if (alive) setMuted(Boolean(data.muted));
2248
- }).catch(() => {
2249
- if (alive) setMuted(null);
2250
- });
2334
+ const read = () => {
2335
+ getJson("/dsh-term-dict/api/session-state?id=" + encodeURIComponent(sessionId)).then((data) => {
2336
+ if (!alive) return;
2337
+ const next = Boolean(data.muted);
2338
+ MUTE_CACHE.set(sessionId, next);
2339
+ setMuted(next);
2340
+ setPresets(Array.isArray(data.presets) ? data.presets : null);
2341
+ setServerAllowed(data.allowed === void 0 ? null : Boolean(data.allowed));
2342
+ }).catch(() => {
2343
+ });
2344
+ };
2345
+ read();
2346
+ window.addEventListener("focus", read);
2251
2347
  return () => {
2252
2348
  alive = false;
2349
+ window.removeEventListener("focus", read);
2253
2350
  };
2254
2351
  }, [sessionId]);
2255
- (0, import_react.useEffect)(() => {
2256
- if (!open) return void 0;
2257
- const onDown = (event) => {
2258
- if (menuRef.current && menuRef.current.contains(event.target)) return;
2259
- if (btnRef.current && btnRef.current.contains(event.target)) return;
2260
- setOpen(false);
2261
- };
2262
- document.addEventListener("mousedown", onDown);
2263
- return () => document.removeEventListener("mousedown", onDown);
2264
- }, [open]);
2265
2352
  if (!sessionId) return null;
2266
- const openMenu = async () => {
2267
- if (busy) return;
2268
- if (open) {
2269
- setOpen(false);
2270
- return;
2271
- }
2272
- if (btnRef.current) {
2273
- const r = btnRef.current.getBoundingClientRect();
2274
- setPos({
2275
- right: Math.max(8, Math.round(window.innerWidth - r.right)),
2276
- bottom: Math.max(8, Math.round(window.innerHeight - r.top + 6))
2277
- });
2278
- }
2279
- setOpen(true);
2280
- try {
2281
- const data = await getJson("/dsh-term-dict/api/session-layers?session=" + encodeURIComponent(sessionId));
2282
- setLayers(Array.isArray(data.layers) ? data.layers : []);
2283
- } catch {
2284
- setLayers([]);
2285
- }
2286
- };
2287
- const changeMute = async (next) => {
2288
- if (busy) return;
2353
+ const blocked = typeof preset === "string" && preset !== "" ? Array.isArray(presets) ? presets.indexOf(preset) === -1 : serverAllowed === false : serverAllowed === false;
2354
+ const label = blocked ? "词条·禁" : busy ? "词条…" : muted === null ? "词条…" : muted ? "词条·禁" : "词条·注";
2355
+ const toggle = async () => {
2356
+ if (busy || muted === null || blocked) return;
2289
2357
  setBusy(true);
2290
2358
  try {
2291
- const data = await postJson("/dsh-term-dict/api/session-mute", { id: sessionId, muted: next });
2292
- setMuted(Boolean(data.muted));
2359
+ const data = await postJson("/dsh-term-dict/api/session-mute", { id: sessionId, muted: !muted });
2360
+ const next = Boolean(data.muted);
2361
+ MUTE_CACHE.set(sessionId, next);
2362
+ setMuted(next);
2293
2363
  } catch {
2294
2364
  } finally {
2295
2365
  setBusy(false);
2296
2366
  }
2297
2367
  };
2298
- const changeLayer = async (layer, override) => {
2299
- try {
2300
- await postJson("/dsh-term-dict/api/session-layers", { session: sessionId, layer, override });
2301
- setLayers((cur) => cur.map((l) => l.id === layer ? Object.assign({}, l, { override }) : l));
2302
- } catch {
2303
- }
2304
- };
2305
- const label = busy ? "词条…" : muted === null ? "词条…" : muted ? "词条·禁" : "词条·注";
2306
- const title = "术语字典:本会话灌注开关与任务层范围";
2307
2368
  return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_react.Fragment, { children: [
2308
2369
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("style", { children: MUTE_CSS }),
2309
2370
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
2310
2371
  "button",
2311
2372
  {
2312
- ref: btnRef,
2313
2373
  type: "button",
2314
- className: "tdict-mutebtn",
2315
- "data-muted": muted === true ? "true" : "false",
2316
- disabled: busy,
2317
- title,
2318
- onClick: () => void openMenu(),
2374
+ className: "pp-mutebtn",
2375
+ "data-muted": blocked || muted === true ? "true" : "false",
2376
+ disabled: busy || muted === null || blocked,
2377
+ title: blocked ? "当前模式不在词条灌注名单里(设置 → 术语字典 → 灌注)" : "术语字典:本会话是否灌注词条",
2378
+ onClick: () => void toggle(),
2319
2379
  children: label
2320
2380
  }
2321
- ),
2322
- open && pos ? /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", { ref: menuRef, className: "tdict-menu", style: { right: pos.right, bottom: pos.bottom }, children: [
2323
- /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("label", { className: "tdict-menuroad", children: [
2324
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
2325
- "input",
2326
- {
2327
- type: "checkbox",
2328
- checked: muted !== true,
2329
- disabled: muted === null,
2330
- onChange: (e) => void changeMute(!e.target.checked)
2331
- }
2332
- ),
2333
- "本会话灌注词条"
2334
- ] }),
2335
- layers.map((layer) => /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", { className: "tdict-menuline", children: [
2336
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "tdict-menulabel", title: (layer.workspaces || []).join("、") || "未绑定工作空间", children: layer.label }),
2337
- /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
2338
- "select",
2339
- {
2340
- value: layer.override || "",
2341
- onChange: (e) => void changeLayer(layer.id, e.target.value || null),
2342
- children: [
2343
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("option", { value: "", children: "跟随" }),
2344
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("option", { value: "on", children: "强制开" }),
2345
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("option", { value: "off", children: "强制关" })
2346
- ]
2347
- }
2348
- )
2349
- ] }, layer.id)),
2350
- layers.length === 0 ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "tdict-menuhint", children: "没有任务层。" }) : null,
2351
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "tdict-menuhint", children: "跟随=按任务层文件里的「工作空间」绑定;未绑定的任务层所有会话都生效。" })
2352
- ] }) : null
2381
+ )
2353
2382
  ] });
2354
2383
  }
2355
2384
  function apply(ctx) {
2385
+ getSessionsService = () => {
2386
+ try {
2387
+ return ctx.get("sessions");
2388
+ } catch {
2389
+ return void 0;
2390
+ }
2391
+ };
2356
2392
  ctx.slots.inject("settings.section", () => ctx.slots.register({
2357
2393
  name: "settings.section",
2358
2394
  id: "term-dict",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-term-dict",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "跨会话的术语字典:查词条、写词条、查重体检三条工具,加设置面板;某个称谓第一次出现时,把它的口径自动注入模型上下文。",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -23,7 +23,7 @@
23
23
  ],
24
24
  "engines": {
25
25
  "node": "^22.19.0 || >=24.0.0",
26
- "dsh": ">=0.2.2-0"
26
+ "dsh": ">=0.2.2-rc.1"
27
27
  },
28
28
  "dsh": {
29
29
  "client": {
package/scripts/smoke.mjs CHANGED
@@ -102,10 +102,10 @@ else ok('宿主半边', trusted + ' 条路由都过本机同源校验')
102
102
  /** 4. 自动激活:默认值与模式判定 */
103
103
  const act = await import(pathToFileURL(join(root, 'activation.js')).href)
104
104
  const defaults = act.DEFAULT_SETTINGS || {}
105
- if (!Array.isArray(defaults.presets)) fail('自动激活', 'DEFAULT_SETTINGS.presets 不是数组(默认应为空数组=所有模式都灌注)')
105
+ if (!Array.isArray(defaults.presets)) fail('自动激活', 'DEFAULT_SETTINGS.presets 不是数组(默认应为空数组=只灌通用会话)')
106
106
  else if (defaults.presets.length !== 0) fail('自动激活', 'DEFAULT_SETTINGS.presets 默认不是空数组')
107
107
  else if (defaults.autoActivate !== true) fail('自动激活', 'DEFAULT_SETTINGS.autoActivate 默认不是 true')
108
- else ok('自动激活', 'presets 默认空(所有模式都灌注),autoActivate 默认开')
108
+ else ok('自动激活', 'presets 默认空(只灌通用会话),autoActivate 默认开')
109
109
  if (typeof act.modeAllowed !== 'function') fail('自动激活', '没导出 modeAllowed(模式白名单判定)')
110
110
  else ok('自动激活', 'modeAllowed 已导出')
111
111
 
package/src/entry.jsx CHANGED
@@ -4,7 +4,7 @@
4
4
  * 数据走宿主 dsh-term-dict 的 HTTP 路由(list / read / write / rename),面板与文件之间只有这一个接口。
5
5
  *
6
6
  * 版式(2026-11-07 第四版):
7
- * 左栏名册(通用 / 任务层 分组,行右小数字)→ 右栏标题行(层名 + 重命名 / 新词条)
7
+ * 左栏名册(通用 / 任务层 分组,行右小数字)→ 右栏标题行(层名 + 重命名 / 结构 / 设置)
8
8
  * → 搜索 + 词条/原文 → 扁平的词条列表(点一条就地展开编辑卡,每个字段带标签)→ 底部文件与保存状态。
9
9
  * 低频操作收进二级:挪层是编辑卡里的一个下拉,跨层搜索折叠成列表下的一条。
10
10
  *
@@ -191,13 +191,25 @@ select.td-cell { padding:1px 2px; }
191
191
  .td-tab[aria-selected="true"] { background:var(--dsw-alias-bg-layer-2); font-weight:500; }
192
192
  .td-list { display:flex; flex-direction:column; }
193
193
  .td-group { display:flex; justify-content:space-between; align-items:center; gap:8px; width:100%;
194
- margin:10px 0 2px; padding:4px 6px 4px 0; border:0; background:none;
195
- color:var(--dsw-alias-label-primary); font:inherit; font-size:11px; text-align:left; cursor:pointer; }
194
+ margin:14px 0 0; padding:8px 6px; border-bottom:1px solid var(--dsw-alias-border-l1);
195
+ background:var(--dsw-alias-bg-layer-1); color:var(--dsw-alias-label-primary);
196
+ font:inherit; font-size:14px; font-weight:700; letter-spacing:0; }
196
197
  .td-group:first-child { margin-top:2px; }
197
- .td-group:hover { color:var(--dsw-alias-label-primary); }
198
- .td-groupnum { font-size:12px; color:var(--dsw-alias-label-primary); }
198
+ .td-groupbtn { flex:1 1 auto; min-width:0; display:flex; justify-content:space-between; align-items:center;
199
+ gap:8px; padding:6px 0; border:0; background:none; color:inherit; font:inherit;
200
+ text-align:left; cursor:pointer; }
201
+ .td-groupbtn:hover { color:var(--dsw-alias-brand-primary); }
202
+ .td-groupnum { font-size:10px; font-weight:400; letter-spacing:0; padding:0 6px; border-radius:999px;
203
+ background:var(--dsw-alias-bg-layer-2); color:var(--dsw-alias-label-secondary); }
204
+ .td-groupadd { padding:0 2px; border:0; background:none; color:var(--dsw-alias-label-secondary);
205
+ font:inherit; font-size:14px; line-height:1; cursor:pointer; }
206
+ .td-groupadd:hover { color:var(--dsw-alias-brand-primary); }
207
+ .td-addrow { display:block; width:100%; box-sizing:border-box; margin-top:10px; padding:7px 8px;
208
+ border:1px dashed var(--dsw-alias-border-l2); border-radius:6px; background:none;
209
+ color:var(--dsw-alias-label-secondary); font:inherit; font-size:12px; text-align:left; cursor:pointer; }
210
+ .td-addrow:hover { border-color:var(--dsw-alias-brand-primary); color:var(--dsw-alias-label-primary); }
199
211
  .td-entry { display:flex; justify-content:space-between; align-items:center; gap:12px; width:100%;
200
- padding:8px 6px; border:0; border-bottom:1px solid var(--dsw-alias-border-l1); border-radius:4px;
212
+ padding:8px 6px 8px 14px; border:0; border-bottom:1px solid var(--dsw-alias-border-l1); border-radius:4px;
201
213
  background:none; color:inherit; font:inherit; text-align:left; cursor:pointer; }
202
214
  .td-entry:hover { background:var(--dsw-alias-bg-layer-1); }
203
215
  .td-entry[aria-current="true"] { font-weight:600; background:var(--dsw-alias-bg-layer-1); }
@@ -531,6 +543,7 @@ export function TermDictSection() {
531
543
  const [filter, setFilter] = useState('')
532
544
  const [editing, setEditing] = useState(null)
533
545
  const [adding, setAdding] = useState(false)
546
+ const [addingStatus, setAddingStatus] = useState('待观察')
534
547
  const [nameEditing, setNameEditing] = useState(false)
535
548
  const [nameDraft, setNameDraft] = useState('')
536
549
  const [slugEditing, setSlugEditing] = useState(false)
@@ -1609,10 +1622,18 @@ export function TermDictSection() {
1609
1622
  <div className="td-srow td-srow-top">
1610
1623
  <span className="td-slabel">{'限定模式'}</span>
1611
1624
  <span className="td-sfield td-sfield-col">
1612
- <span className="td-hint">{'全不勾=所有模式都灌注;勾了就只在这些模式里灌注——别的模式既不注入词条,也看不到字典工具。'}</span>
1625
+ <span className="td-hint">{'全不勾=只灌注通用会话(没有模式或判不出模式);勾了就只在这些模式里灌注——其余模式(含新建的)既不注入词条,也看不到字典工具。'}</span>
1613
1626
  {Array.isArray(presetRoster) && presetRoster.length > 0
1614
1627
  ? (
1615
1628
  <span className="td-chips">
1629
+ <label className="td-check">
1630
+ <input
1631
+ type="checkbox"
1632
+ checked={presetRoster.every((p) => Array.isArray(d.presets) && d.presets.indexOf(p.id) !== -1)}
1633
+ onChange={(e) => setDraftField('presets', e.target.checked ? presetRoster.map((p) => p.id) : [])}
1634
+ />
1635
+ <span className="td-checkname">{'全选'}</span>
1636
+ </label>
1616
1637
  {presetRoster.map((p) => {
1617
1638
  const on = Array.isArray(d.presets) && d.presets.indexOf(p.id) !== -1
1618
1639
  return (
@@ -1637,7 +1658,7 @@ export function TermDictSection() {
1637
1658
  rows={2}
1638
1659
  value={Array.isArray(d.presets) ? d.presets.join('\n') : ''}
1639
1660
  spellCheck={false}
1640
- placeholder={'一行一个模式的 agentPreset id;留空=所有模式都灌注'}
1661
+ placeholder={'一行一个模式的 agentPreset id;留空=只灌注通用会话'}
1641
1662
  onChange={(e) => setDraftField('presets', e.target.value.split('\n'))}
1642
1663
  />
1643
1664
  )}
@@ -1670,6 +1691,14 @@ export function TermDictSection() {
1670
1691
  const manual = list.filter((v) => paths.indexOf(String(v).toLowerCase()) === -1)
1671
1692
  return (
1672
1693
  <>
1694
+ <label className="td-check">
1695
+ <input
1696
+ type="checkbox"
1697
+ checked={roster.length > 0 && roster.every((w) => list.some((v) => String(v).toLowerCase() === String(w.path || '').toLowerCase()))}
1698
+ onChange={(e) => commitSkipWorkspaces(e.target.checked ? roster.map((w) => String(w.path || '')).filter((v) => v !== '') : [])}
1699
+ />
1700
+ <span className="td-checkname">{'全选'}</span>
1701
+ </label>
1673
1702
  {roster.length > 0
1674
1703
  ? (
1675
1704
  <span className="td-chips">
@@ -2115,13 +2144,6 @@ export function TermDictSection() {
2115
2144
  <div className="td-headacts">
2116
2145
  <button type="button" className="td-link" onClick={() => void openStructure()}>{'结构'}</button>
2117
2146
  <button type="button" className="td-link" onClick={() => void openAdvanced()}>{'设置'}</button>
2118
- <button
2119
- type="button"
2120
- className="td-link primary"
2121
- onClick={() => { setView('terms'); setAdding(true); setEditing(null) }}
2122
- >
2123
- {'+ 新词条'}
2124
- </button>
2125
2147
  </div>
2126
2148
  </div>
2127
2149
 
@@ -2148,25 +2170,6 @@ export function TermDictSection() {
2148
2170
  {view === 'terms'
2149
2171
  ? (
2150
2172
  <div className="td-list">
2151
- {adding
2152
- ? (
2153
- <TermForm
2154
- isNew
2155
- term=""
2156
- key="new"
2157
- schema={schema}
2158
- fields={{ 状态: '待观察' }}
2159
- saving={doc.saving}
2160
- layerOptions={layerOptions}
2161
- layerId={activeId}
2162
- onCancel={() => setAdding(false)}
2163
- onSave={async (name, fields, targetId) => {
2164
- const ok = await saveEntry(name, fields, targetId, true)
2165
- if (ok) setAdding(false)
2166
- }}
2167
- />
2168
- )
2169
- : null}
2170
2173
 
2171
2174
  {doc.loading
2172
2175
  ? <p className="td-hint">{'读取中…'}</p>
@@ -2176,23 +2179,56 @@ export function TermDictSection() {
2176
2179
  : null}
2177
2180
 
2178
2181
  {!doc.loading && shown.length === 0 && !adding
2179
- ? <p className="td-hint">{keyword ? '没有匹配的词条。' : '这一层还没有词条,点右上角「+ 新词条」加一条。'}</p>
2182
+ ? <p className="td-hint">{keyword ? '没有匹配的词条。' : '这一层还没有词条,点下面的「+ 新建词条」加一条。'}</p>
2180
2183
  : null}
2181
2184
 
2182
2185
  {groups.map((g) => {
2183
2186
  const folded = Boolean(collapsedStatus[g.key])
2184
2187
  return (
2185
2188
  <Fragment key={'status:' + g.key}>
2186
- <button
2187
- type="button"
2188
- className="td-group"
2189
- aria-expanded={!folded}
2190
- title={folded ? '展开这一组' : '收起这一组'}
2191
- onClick={() => setCollapsedStatus((c) => Object.assign({}, c, { [g.key]: !c[g.key] }))}
2192
- >
2193
- <span>{folded ? '▸ ' : '▾ '}{g.key}</span>
2194
- <span className="td-groupnum">{g.items.length}</span>
2195
- </button>
2189
+ <div className="td-group">
2190
+ <button
2191
+ type="button"
2192
+ className="td-groupbtn"
2193
+ aria-expanded={!folded}
2194
+ title={folded ? '展开这一组' : '收起这一组'}
2195
+ onClick={() => setCollapsedStatus((c) => Object.assign({}, c, { [g.key]: !c[g.key] }))}
2196
+ >
2197
+ <span>{folded ? '▸ ' : '▾ '}{g.key}</span>
2198
+ <span className="td-groupnum">{g.items.length}</span>
2199
+ </button>
2200
+ <button
2201
+ type="button"
2202
+ className="td-groupadd"
2203
+ title={'在这一组加一条'}
2204
+ onClick={() => {
2205
+ setView('terms')
2206
+ setCollapsedStatus((c) => Object.assign({}, c, { [g.key]: false }))
2207
+ setAddingStatus(g.key)
2208
+ setAdding(true)
2209
+ setEditing(null)
2210
+ }}
2211
+ >
2212
+ {'+'}
2213
+ </button>
2214
+ </div>
2215
+ {adding && addingStatus === g.key ? (
2216
+ <TermForm
2217
+ isNew
2218
+ term=""
2219
+ key={'new:' + g.key}
2220
+ schema={schema}
2221
+ fields={{ 状态: g.key }}
2222
+ saving={doc.saving}
2223
+ layerOptions={layerOptions}
2224
+ layerId={activeId}
2225
+ onCancel={() => setAdding(false)}
2226
+ onSave={async (name, fields, targetId) => {
2227
+ const ok = await saveEntry(name, fields, targetId, true)
2228
+ if (ok) setAdding(false)
2229
+ }}
2230
+ />
2231
+ ) : null}
2196
2232
  {folded ? null : g.items.map((t) => (
2197
2233
  <Fragment key={t.term}>
2198
2234
  <button
@@ -2235,6 +2271,34 @@ export function TermDictSection() {
2235
2271
  )
2236
2272
  })}
2237
2273
 
2274
+ {groups.length === 0 ? (
2275
+ adding ? (
2276
+ <TermForm
2277
+ isNew
2278
+ term=""
2279
+ key="new-empty"
2280
+ schema={schema}
2281
+ fields={{ 状态: addingStatus }}
2282
+ saving={doc.saving}
2283
+ layerOptions={layerOptions}
2284
+ layerId={activeId}
2285
+ onCancel={() => setAdding(false)}
2286
+ onSave={async (name, fields, targetId) => {
2287
+ const ok = await saveEntry(name, fields, targetId, true)
2288
+ if (ok) setAdding(false)
2289
+ }}
2290
+ />
2291
+ ) : (
2292
+ <button
2293
+ type="button"
2294
+ className="td-addrow"
2295
+ onClick={() => { setView('terms'); setAddingStatus('待观察'); setAdding(true); setEditing(null) }}
2296
+ >
2297
+ {'+ 新建词条'}
2298
+ </button>
2299
+ )
2300
+ ) : null}
2301
+
2238
2302
  {keyword && (otherBusy || otherHits.length > 0)
2239
2303
  ? (
2240
2304
  <div className="td-other">
@@ -2355,156 +2419,122 @@ function headerFor(text, layer) {
2355
2419
  /** 需要的客户端服务。 */
2356
2420
  export const inject = ['slots']
2357
2421
 
2358
- const MUTE_CSS = `
2359
- .tdict-mutebtn { display:inline-flex; align-items:center; padding:1px 8px; border:1px solid var(--dsw-alias-border-l1);
2360
- border-radius:999px; background:none; color:var(--dsw-alias-label-primary); font:inherit; font-size:11px;
2361
- line-height:1.6; cursor:pointer; white-space:nowrap; }
2362
- .tdict-mutebtn:hover:not(:disabled) { color:var(--dsw-alias-label-primary); border-color:var(--dsw-alias-border-l2); }
2363
- .tdict-mutebtn[data-muted="true"] { color:var(--dsw-alias-state-error-primary); border-color:currentColor; }
2364
- .tdict-mutebtn:disabled { opacity:.6; cursor:default; }
2365
- .tdict-menu { position:fixed; z-index:1000; width:260px; padding:8px 10px;
2366
- border:1px solid var(--dsw-alias-border-l1); border-radius:8px; background:var(--dsw-alias-bg-base);
2367
- box-shadow:0 8px 24px rgba(0,0,0,.16); display:flex; flex-direction:column; gap:6px;
2368
- font-size:11px; line-height:1.6; color:var(--dsw-alias-label-primary); }
2369
- .tdict-menuroad { display:flex; align-items:center; gap:6px; }
2370
- .tdict-menuline { display:flex; align-items:center; justify-content:space-between; gap:8px; }
2371
- .tdict-menulabel { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
2372
- .tdict-menu select { font:inherit; font-size:11px; max-width:130px; }
2373
- .tdict-menuhint { font-size:10px; line-height:1.5; color:var(--dsw-alias-label-primary); }
2374
- `
2422
+ const MUTE_CSS = `.pp-mutebtn { padding:2px 8px; border:1px solid var(--dsw-alias-border-l1); border-radius:999px; min-width:58px; text-align:center;
2423
+ background:var(--dsw-alias-bg-base); color:var(--dsw-alias-label-primary); font:inherit; font-size:11px;
2424
+ line-height:1.4; cursor:pointer; white-space:nowrap; }
2425
+ .pp-mutebtn:hover { border-color:var(--dsw-alias-brand-primary); }
2426
+ .pp-mutebtn[data-muted="true"] { color:var(--dsw-alias-state-error-primary); border-color:currentColor; }
2427
+ .pp-mutebtn:disabled { opacity:.6; cursor:default; }`
2375
2428
 
2376
- /** 会话里的小开关:点开是本会话灌注开关 + 各任务层的「跟随 / 强制开 / 强制关」。 */
2377
- export function SessionDictToggle(props) {
2378
- const sessionId = props && props.sessionId ? String(props.sessionId) : ''
2379
- const [muted, setMuted] = useState(null)
2380
- const [busy, setBusy] = useState(false)
2381
- const [open, setOpen] = useState(false)
2382
- const [layers, setLayers] = useState([])
2383
- const [pos, setPos] = useState(null)
2384
- const btnRef = useRef(null)
2385
- const menuRef = useRef(null)
2429
+ /** apply 里注入:拿客户端的 sessions 服务。 */
2430
+ let getSessionsService = () => undefined
2386
2431
 
2432
+ /** 当前会话的 preset:订阅 session projection,切模式就跟着变(不用轮询)。 */
2433
+ function useAgentPreset(sessionId) {
2434
+ const [preset, setPreset] = useState(undefined)
2387
2435
  useEffect(() => {
2388
2436
  if (!sessionId) return undefined
2389
2437
  let alive = true
2390
- getJson('/dsh-term-dict/api/session-state?id=' + encodeURIComponent(sessionId))
2391
- .then((data) => { if (alive) setMuted(Boolean(data.muted)) })
2392
- .catch(() => { if (alive) setMuted(null) })
2393
- return () => { alive = false }
2438
+ let face
2439
+ try {
2440
+ const service = getSessionsService()
2441
+ const binding = service && typeof service.binding === 'function' ? service.binding(sessionId) : undefined
2442
+ face = binding && binding.session && binding.session.projections
2443
+ ? binding.session.projections.faceOf('agentPreset')
2444
+ : undefined
2445
+ } catch {
2446
+ face = undefined
2447
+ }
2448
+ if (!face) return undefined
2449
+ const read = () => { if (alive) setPreset(face.getSnapshot()) }
2450
+ read()
2451
+ const off = typeof face.subscribe === 'function' ? face.subscribe(read) : undefined
2452
+ return () => {
2453
+ alive = false
2454
+ if (typeof off === 'function') off()
2455
+ }
2394
2456
  }, [sessionId])
2457
+ return preset
2458
+ }
2395
2459
 
2396
- useEffect(() => {
2397
- if (!open) return undefined
2398
- const onDown = (event) => {
2399
- if (menuRef.current && menuRef.current.contains(event.target)) return
2400
- if (btnRef.current && btnRef.current.contains(event.target)) return
2401
- setOpen(false)
2402
- }
2403
- document.addEventListener('mousedown', onDown)
2404
- return () => document.removeEventListener('mousedown', onDown)
2405
- }, [open])
2460
+ const MUTE_CACHE = new Map()
2406
2461
 
2407
- if (!sessionId) return null
2462
+ /** 会话里的小开关:本会话词条注/禁;当前模式不在灌注名单里就显示「禁」、点不动。 */
2463
+ export function SessionDictToggle(props) {
2464
+ const sessionId = props && props.sessionId ? String(props.sessionId) : ''
2465
+ const preset = useAgentPreset(sessionId)
2466
+ const [muted, setMuted] = useState(() => (sessionId && MUTE_CACHE.has(sessionId) ? MUTE_CACHE.get(sessionId) : null))
2467
+ const [presets, setPresets] = useState(null)
2468
+ const [serverAllowed, setServerAllowed] = useState(null)
2469
+ const [busy, setBusy] = useState(false)
2408
2470
 
2409
- const openMenu = async () => {
2410
- if (busy) return
2411
- if (open) { setOpen(false); return }
2412
- if (btnRef.current) {
2413
- const r = btnRef.current.getBoundingClientRect()
2414
- setPos({
2415
- right: Math.max(8, Math.round(window.innerWidth - r.right)),
2416
- bottom: Math.max(8, Math.round(window.innerHeight - r.top + 6)),
2417
- })
2471
+ useEffect(() => {
2472
+ if (!sessionId) return undefined
2473
+ let alive = true
2474
+ const read = () => {
2475
+ getJson('/dsh-term-dict/api/session-state?id=' + encodeURIComponent(sessionId))
2476
+ .then((data) => {
2477
+ if (!alive) return
2478
+ const next = Boolean(data.muted)
2479
+ MUTE_CACHE.set(sessionId, next)
2480
+ setMuted(next)
2481
+ setPresets(Array.isArray(data.presets) ? data.presets : null)
2482
+ setServerAllowed(data.allowed === undefined ? null : Boolean(data.allowed))
2483
+ })
2484
+ .catch(() => {})
2418
2485
  }
2419
- setOpen(true)
2420
- try {
2421
- const data = await getJson('/dsh-term-dict/api/session-layers?session=' + encodeURIComponent(sessionId))
2422
- setLayers(Array.isArray(data.layers) ? data.layers : [])
2423
- } catch {
2424
- setLayers([])
2486
+ read()
2487
+ window.addEventListener('focus', read)
2488
+ return () => {
2489
+ alive = false
2490
+ window.removeEventListener('focus', read)
2425
2491
  }
2426
- }
2492
+ }, [sessionId])
2427
2493
 
2428
- const changeMute = async (next) => {
2429
- if (busy) return
2494
+ if (!sessionId) return null
2495
+ // 有模式且不在「灌注的模式」名单里=禁(含新建的模式);判不出模式(通用会话)按放行,
2496
+ // 名单清单取不到时退回宿主给的 allowed。
2497
+ const blocked = typeof preset === 'string' && preset !== ''
2498
+ ? (Array.isArray(presets) ? presets.indexOf(preset) === -1 : serverAllowed === false)
2499
+ : serverAllowed === false
2500
+ const label = blocked ? '词条·禁' : (busy ? '词条…' : (muted === null ? '词条…' : (muted ? '词条·禁' : '词条·注')))
2501
+ const toggle = async () => {
2502
+ if (busy || muted === null || blocked) return
2430
2503
  setBusy(true)
2431
2504
  try {
2432
- const data = await postJson('/dsh-term-dict/api/session-mute', { id: sessionId, muted: next })
2433
- setMuted(Boolean(data.muted))
2505
+ const data = await postJson('/dsh-term-dict/api/session-mute', { id: sessionId, muted: !muted })
2506
+ const next = Boolean(data.muted)
2507
+ MUTE_CACHE.set(sessionId, next)
2508
+ setMuted(next)
2434
2509
  } catch {
2435
2510
  /* 失败保持原状态 */
2436
2511
  } finally {
2437
2512
  setBusy(false)
2438
2513
  }
2439
2514
  }
2440
-
2441
- const changeLayer = async (layer, override) => {
2442
- try {
2443
- await postJson('/dsh-term-dict/api/session-layers', { session: sessionId, layer, override })
2444
- setLayers((cur) => cur.map((l) => (l.id === layer ? Object.assign({}, l, { override }) : l)))
2445
- } catch {
2446
- /* 失败保持原状态 */
2447
- }
2448
- }
2449
-
2450
- const label = busy ? '词条…' : (muted === null ? '词条…' : (muted ? '词条·禁' : '词条·注'))
2451
- const title = '术语字典:本会话灌注开关与任务层范围'
2452
-
2453
2515
  return (
2454
2516
  <Fragment>
2455
2517
  <style>{MUTE_CSS}</style>
2456
2518
  <button
2457
- ref={btnRef}
2458
2519
  type="button"
2459
- className="tdict-mutebtn"
2460
- data-muted={muted === true ? 'true' : 'false'}
2461
- disabled={busy}
2462
- title={title}
2463
- onClick={() => void openMenu()}
2520
+ className="pp-mutebtn"
2521
+ data-muted={blocked || muted === true ? 'true' : 'false'}
2522
+ disabled={busy || muted === null || blocked}
2523
+ title={blocked ? '当前模式不在词条灌注名单里(设置 → 术语字典 → 灌注)' : '术语字典:本会话是否灌注词条'}
2524
+ onClick={() => void toggle()}
2464
2525
  >
2465
2526
  {label}
2466
2527
  </button>
2467
- {open && pos
2468
- ? (
2469
- <span ref={menuRef} className="tdict-menu" style={{ right: pos.right, bottom: pos.bottom }}>
2470
- <label className="tdict-menuroad">
2471
- <input
2472
- type="checkbox"
2473
- checked={muted !== true}
2474
- disabled={muted === null}
2475
- onChange={(e) => void changeMute(!e.target.checked)}
2476
- />
2477
- {'本会话灌注词条'}
2478
- </label>
2479
- {layers.map((layer) => (
2480
- <span className="tdict-menuline" key={layer.id}>
2481
- <span className="tdict-menulabel" title={(layer.workspaces || []).join('、') || '未绑定工作空间'}>{layer.label}</span>
2482
- <select
2483
- value={layer.override || ''}
2484
- onChange={(e) => void changeLayer(layer.id, e.target.value || null)}
2485
- >
2486
- <option value="">{'跟随'}</option>
2487
- <option value="on">{'强制开'}</option>
2488
- <option value="off">{'强制关'}</option>
2489
- </select>
2490
- </span>
2491
- ))}
2492
- {layers.length === 0 ? <span className="tdict-menuhint">{'没有任务层。'}</span> : null}
2493
- <span className="tdict-menuhint">
2494
- {'跟随=按任务层文件里的「工作空间」绑定;未绑定的任务层所有会话都生效。'}
2495
- </span>
2496
- </span>
2497
- )
2498
- : null}
2499
2528
  </Fragment>
2500
2529
  )
2501
2530
  }
2502
-
2503
2531
  /**
2504
2532
  * 把面板挂进设置页,并在会话标题旁放「词条·注/禁」开关。
2505
2533
  * @param ctx - 客户端根上下文。
2506
2534
  */
2507
2535
  export function apply(ctx) {
2536
+ getSessionsService = () => { try { return ctx.get('sessions') } catch { return undefined } }
2537
+
2508
2538
  ctx.slots.inject('settings.section', () => ctx.slots.register({
2509
2539
  name: 'settings.section',
2510
2540
  id: 'term-dict',