dsh-session-title-gen 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/CHANGELOG.md CHANGED
@@ -5,6 +5,13 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.1.1] - 2026-09-19
9
+
10
+ ### 变更
11
+
12
+ - docs(session-title-gen): 在 README 接入真实设置页效果图
13
+ - feat(session-title-gen): 新增设置页面板,8 项配置可视化编辑(#385)
14
+
8
15
  ## [0.1.0] - 2026-09-06
9
16
 
10
17
  ### Added
package/README.md CHANGED
@@ -14,6 +14,7 @@
14
14
  - **重启后标题保留**:经 DSH 核心 `session/title` 事件(log-backed)写入,DSH 原生会话列表与 observability 面板均显示新标题。
15
15
  - **失败静默回退**:LLM 不可用 / 超时 / 输出为空时不阻塞会话,核心标题机制照常工作。
16
16
  - **与核心标题机制协作**:不注册标题 provider,直接 append `session/title` 事件覆盖;核心生成的非结构化标题(fallback / provider)会触发本插件重新生成;用户手动重命名的标题(source: user)不被覆盖。
17
+ - **设置页可视化配置**:「设置 → 插件 → 会话标题生成」可视化编辑全部 8 项配置,保存写回 profile 的 `cordis.patch.yml` 并**热生效**(不必重启),无需手写 yml。
17
18
 
18
19
  ## 配置
19
20
 
@@ -35,6 +36,23 @@
35
36
  template: '[{workspace}] {description}'
36
37
  ```
37
38
 
39
+ ## 设置页
40
+
41
+ ![设置 → 插件 → 会话标题生成:8 项配置可视化编辑](./assets/settings-panel.png)
42
+
43
+ 「设置 → 插件 → 会话标题生成」提供 8 项配置的可视化编辑(不写 yml 也能改):
44
+
45
+ | 分组 | 项 |
46
+ | ---- | ------------------------------------------------------------------------- |
47
+ | 开关 | `enabled` |
48
+ | 文本 | `template`(多行模板)、`provider`、`model`(后两者留空 = 跟随本次会话) |
49
+ | 数字 | `maxTitleBytes`、`maxInputBytes`、`maxOutputTokens`、`timeoutMs`(正整数) |
50
+
51
+ - 保存走 `PUT /session-title-gen/api/config`:**先合并该行已有键再写回** profile 的 `cordis.patch.yml`——不会删掉你在同一行手写的其它配置键,也不会动别的插件行。
52
+ - 保存**立即生效**:模板 / 上限改动对后续会话生效;关闭 `enabled` 后立刻停止监听会话事件(不必重启 DSH)。
53
+ - 非法值只忽略该字段并回退默认值(同一次提交里的其它合法字段照常保存),保存后表单回填**实际生效值**,不会出现「看着存了、其实没生效」。
54
+ - 禁用状态下设置页依然可打开——否则没有任何入口把插件重新打开。
55
+
38
56
  ## 安装
39
57
 
40
58
  ```bash
Binary file
package/cordis.patch.yml CHANGED
@@ -1,6 +1,12 @@
1
1
  # dsh-session-title-gen bundle patch: inserts the plugin row into the profile
2
2
  # roster. The row mounts the server half (session/event listener + LLM title
3
- # generation + session/title append). Install with:
3
+ # generation + session/title append) and the client half (the
4
+ # 「设置 → 插件 → 会话标题生成」settings tab for the 8 config keys below).
5
+ #
6
+ # 配置项(8 项)既可在 profile 的 cordis.patch.yml 的 config 下手写,也可在设置页
7
+ # 可视化编辑:设置页保存走 PUT /session-title-gen/api/config,写回**本行**(行 id
8
+ # `session-title-gen`)的 config 并热生效;写回前先合并本行已有键,不会删掉你手写的其它键。
9
+ # Install with:
4
10
  #
5
11
  # git clone https://github.com/baosfeng/my-dsh-plugins.git
6
12
  # dsh plugin --profile web add link:<仓库路径>/plugins/dsh-session-title-gen
package/lib/client.js ADDED
@@ -0,0 +1,413 @@
1
+ /**
2
+ * dsh-session-title-gen — client half (settings tab, issue #385). SOURCE TEMPLATE.
3
+ *
4
+ * 提供「设置 → 插件 → 会话标题生成」设置页签:可视化编辑 8 项配置
5
+ * (enabled / template / provider / model / maxTitleBytes / maxInputBytes /
6
+ * maxOutputTokens / timeoutMs),保存经 PUT 到插件配置端点,由 host 半写回
7
+ * profile 的 cordis.patch.yml 并热生效(详见 src/config-routes.ts)。
8
+ *
9
+ * 本插件的标题生成逻辑全在 server 端,client 半没有其它职责。
10
+ *
11
+ * BUILD NOTE: 本文件是源码模板(骨架)。scripts/build.mjs 先 tsc 编译
12
+ * src/client/index.ts → lib/.client-build/index.js(CommonJS 单文件),再把编译产物、
13
+ * src/client 的 part 片段(strings.ts / settings.ts 的产物)与 dsh-shared/client-parts
14
+ * 的样式注入件依次注入下方占位符,写出 lib/client.js —— DSH 实际提供的产物(单一
15
+ * __ModuleLoader__ bundle,无相对路径 require)。产物必须提交(CI 只跑
16
+ * node --check + 测试,不跑构建)。
17
+ */
18
+ window.__ModuleLoader__.load({
19
+ id: 'dsh-session-title-gen',
20
+ factory: (require) => {
21
+ var module = { exports: {} }
22
+ var exports = module.exports
23
+ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
24
+ // useState / useEffect 供设置页 part 使用(模板静态分析看不到 part 内容,故在此一并解构)
25
+ const { createElement, useState, useEffect } = require('react')
26
+
27
+ // ── 共享样式注入(dsh-shared/client-parts,issue #186 P2)──────────
28
+ // ── shared plugin stylesheet injection (dsh-shared/client-parts) ──
29
+ // 单一来源(issue #186 P2):把「注入 <style data-<plugin>="styles"> 并随 fiber
30
+ // teardown 卸载」这段逐字相同的样板从渲染插件收口到这里。当前调用方:
31
+ // dsh-md-render(parts/apply.ts)/ dsh-mermaid-render(client/index.ts)/
32
+ // dsh-think-zh-expand(client/index.ts)——各自 scripts/build.mjs 在构建期把本
33
+ // 文件拼进 __ModuleLoader__ factory 作用域(构建时源文件,不经过 require 解析)。
34
+ //
35
+ // 为什么「无条件、最先注入、不进早退分支」:样式若挂在某个服务判空之后,
36
+ // HMR / 服务缺省时样式就丢了(dsh-file-activity 踩坑,见三处调用点的原注释)。
37
+ /**
38
+ * 注入插件样式表,随 ctx fiber 卸载(HMR/禁用无残留)。
39
+ *
40
+ * @param {{ effect: (fn: () => void | (() => void), label?: string) => void }} ctx cordis client ctx
41
+ * @param {string} attr 标识属性名(如 'data-dsh-md-render';值固定为 'styles')
42
+ * @param {string} css 样式表文本
43
+ * @param {string} label effect 标签(如 'dsh-md-render: styles',HMR/调试定位用)
44
+ * @returns {void}
45
+ */
46
+ function installStyles(ctx, attr, css, label) {
47
+ ctx.effect(() => {
48
+ if (typeof document === 'undefined' || document === null || typeof document.head === 'undefined') return () => {}
49
+ const style = document.createElement('style')
50
+ style.setAttribute(attr, 'styles')
51
+ style.textContent = css
52
+ document.head.appendChild(style)
53
+ return () => {
54
+ if (style.parentNode) style.parentNode.removeChild(style)
55
+ }
56
+ }, label)
57
+ }
58
+
59
+
60
+ // ── 设置页文案(src/client/strings.ts 产物,issue #385)────────────
61
+ "use strict";
62
+ // ── 设置页文案与本地化(issue #385):按当前语言返回**单语** ──────────────
63
+ // 判据沿用本仓库设置页惯例(docs/UI规范.md「文案与国际化」):读到 <html lang>
64
+ // (宿主 locale 写入)时优先它,避免「浏览器英文 + 宿主中文」错配;取不到再回退
65
+ // navigator.language 前缀判 zh,try/catch 兜底英文。
66
+ //
67
+ // **文案一律写成惰性函数**(宿主靠重注册跟随语言切换),且**每种语言只出一份**——
68
+ // 「中文 (English)」并排塞进同一段会让设置行视觉臃肿,是本仓库已纠正的写法。
69
+ // 页签名与英文文案都避开 dsh-think-zh-expand 中文化词表的全等键(该插件会全局改写
70
+ // 宿主渲染出的英文串,如 Session log → 会话日志)。
71
+ //
72
+ // 本文件是 part 片段(无 import/export):由 scripts/build.mjs 注入
73
+ // lib/client.src.js 的文案占位符,与设置页视图件共享 __ModuleLoader__ factory
74
+ // 作用域(类型来自 globals.d.ts)。
75
+ /** 页签 id:必须全局唯一——复用宿主已发出的 id 会**顶掉**对方那一格(静默故障)。 */
76
+ const SESSION_TITLE_SETTINGS_TAB_ID = 'session-title-gen-settings';
77
+ /** 配置端点(与 host 半 src/config-routes.ts 的 CONFIG_ROUTE_PREFIX + /config 一致)。 */
78
+ const SESSION_TITLE_SETTINGS_API = '/session-title-gen/api/config';
79
+ /** 当前界面语言是否为中文:宿主 locale(<html lang>)优先,回退 navigator.language。 */
80
+ function sessionTitleIsZh() {
81
+ try {
82
+ const lang = document.documentElement?.getAttribute?.('lang');
83
+ if (typeof lang === 'string' && lang !== '')
84
+ return lang.toLowerCase().startsWith('zh');
85
+ }
86
+ catch {
87
+ // 极简宿主 / 测试桩没有 documentElement:回退 navigator.language
88
+ }
89
+ try {
90
+ return (navigator.language || 'en').toLowerCase().startsWith('zh');
91
+ }
92
+ catch {
93
+ return false;
94
+ }
95
+ }
96
+ /** 按语言二选一(单语;每次调用重新判定,不缓存)。 */
97
+ function sessionTitleText(zh, en) {
98
+ return sessionTitleIsZh() ? zh : en;
99
+ }
100
+ /** 设置页文案(页签 / 状态提示 / 每个字段的标题与说明)。 */
101
+ const SESSION_TITLE_SETTINGS_STRINGS = {
102
+ tabLabel: () => sessionTitleText('会话标题生成', 'Session titles'),
103
+ loading: () => sessionTitleText('加载中…', 'Loading…'),
104
+ loadFailed: () => sessionTitleText('配置加载失败', 'Failed to load settings'),
105
+ retry: () => sessionTitleText('重试', 'Retry'),
106
+ save: () => sessionTitleText('保存', 'Save'),
107
+ saved: () => sessionTitleText('已保存', 'Saved'),
108
+ saveFailed: () => sessionTitleText('保存失败', 'Save failed'),
109
+ errorRouteMissing: () => sessionTitleText('服务端插件未加载:' +
110
+ SESSION_TITLE_SETTINGS_API +
111
+ ' 不存在(请确认已安装并启用 dsh-session-title-gen 后重启 DSH)', 'Server plugin not loaded: ' +
112
+ SESSION_TITLE_SETTINGS_API +
113
+ ' is missing (install and enable dsh-session-title-gen, then restart DSH)'),
114
+ errorForbidden: () => sessionTitleText('请求被安全围栏拒绝(403):请检查网络/代理设置', 'Rejected by the security fence (403): check network/proxy settings'),
115
+ errorNetwork: () => sessionTitleText('网络错误或响应异常:请检查 DSH 服务是否正常运行', 'Network error or unexpected response: check that the DSH server is running'),
116
+ fields: {
117
+ enabled: {
118
+ label: () => sessionTitleText('启用自动标题', 'Auto title generation'),
119
+ hint: () => sessionTitleText('关闭后不再自动生成会话标题', 'When off, no titles are generated automatically'),
120
+ },
121
+ template: {
122
+ label: () => sessionTitleText('标题模板', 'Title template'),
123
+ hint: () => sessionTitleText('{workspace} 表示归属、{description} 表示描述', '{workspace} is the project, {description} the summary'),
124
+ },
125
+ provider: {
126
+ label: () => sessionTitleText('模型提供方', 'Provider'),
127
+ hint: () => sessionTitleText('留空则跟随当前会话', 'Empty follows the session'),
128
+ },
129
+ model: {
130
+ label: () => sessionTitleText('模型', 'Model'),
131
+ hint: () => sessionTitleText('留空则跟随当前会话', 'Empty follows the session'),
132
+ },
133
+ maxTitleBytes: {
134
+ label: () => sessionTitleText('标题长度上限', 'Max title bytes'),
135
+ hint: () => sessionTitleText('超出部分会被截断', 'Longer titles get truncated'),
136
+ },
137
+ maxInputBytes: {
138
+ label: () => sessionTitleText('输入长度上限', 'Max input bytes'),
139
+ hint: () => sessionTitleText('送入模型的最大输入长度', 'Largest input sent to the model'),
140
+ },
141
+ maxOutputTokens: {
142
+ label: () => sessionTitleText('输出长度上限', 'Max output tokens'),
143
+ hint: () => sessionTitleText('生成标题的最大输出长度', 'Largest output for the title'),
144
+ },
145
+ timeoutMs: {
146
+ label: () => sessionTitleText('超时时间', 'Timeout (ms)'),
147
+ hint: () => sessionTitleText('单次生成的最长等待时间', 'Longest wait for one generation'),
148
+ },
149
+ },
150
+ };
151
+
152
+
153
+ // ── 设置页视图与注册(src/client/settings.ts 产物,issue #385)──────
154
+ "use strict";
155
+ // ── 设置页视图与页签注册(issue #385):会话标题生成的 8 项配置 ───────────
156
+ // 官方 slots 扩展点:设置 → 插件 → 会话标题生成。字段与 host 半一一对应
157
+ // (enabled / template / provider / model / maxTitleBytes / maxInputBytes /
158
+ // maxOutputTokens / timeoutMs),语义与默认值口径以 src/config.ts 为唯一来源:
159
+ // 非法值只影响该字段(host 回退默认并把规整后的值回给本页回填)。
160
+ // 保存走 PUT 到插件配置端点 → host 半写回 profile patch(持久化)+ 更新内存生效值
161
+ // (保存即生效,不等 patch 热重载、不必重启 DSH)。
162
+ //
163
+ // 本文件是 part 片段:无 import/export,与 index.ts 的 tsc 产物、strings.ts 的文案件、
164
+ // dsh-shared client-parts 共享 __ModuleLoader__ factory 作用域(类型来自 globals.d.ts),
165
+ // 由 scripts/build.mjs 注入 lib/client.src.js 的设置页占位符(见该文件与 build.mjs 的
166
+ // 设置页占位符常量;此处刻意不写出该占位符字面量,否则产物里会出现第二个同形字面量)。
167
+ /** 设置页样式前缀(UI 规范:`dsh-<插件名>-`)。 */
168
+ const SESSION_TITLE_SETTINGS_CLASS = 'dsh-session-title-gen-settings';
169
+ /** 设置页样式:只用宿主语义变量(--dsw-*),跟随深浅主题,不硬编码色值。 */
170
+ const SESSION_TITLE_SETTINGS_STYLES = `
171
+ .${SESSION_TITLE_SETTINGS_CLASS}{display:flex;flex-direction:column;gap:8px;padding:12px}
172
+ .${SESSION_TITLE_SETTINGS_CLASS}-row{display:flex;align-items:flex-start;justify-content:space-between;gap:10px;padding:8px 10px;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:var(--dsw-alias-bg-layer-2)}
173
+ .${SESSION_TITLE_SETTINGS_CLASS}-info{display:flex;flex-direction:column;gap:2px;min-width:0;flex:auto}
174
+ .${SESSION_TITLE_SETTINGS_CLASS}-label{font:var(--dsw-font-xs-strong-13);color:var(--dsw-alias-label-primary)}
175
+ .${SESSION_TITLE_SETTINGS_CLASS}-hint{font:var(--dsw-font-xxs-12);color:var(--dsw-alias-label-tertiary);line-height:1.5}
176
+ .${SESSION_TITLE_SETTINGS_CLASS}-input{flex:none;width:190px;max-width:46%;box-sizing:border-box;padding:4px 8px;border-radius:6px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);font:var(--dsw-font-xxs-12)}
177
+ .${SESSION_TITLE_SETTINGS_CLASS}-input:focus{outline:none;border-color:var(--dsw-alias-accent)}
178
+ .${SESSION_TITLE_SETTINGS_CLASS}-input-multiline{width:100%;max-width:none;min-height:48px;resize:vertical;line-height:1.5}
179
+ .${SESSION_TITLE_SETTINGS_CLASS}-toggle{flex:none;width:34px;height:20px;border-radius:10px;border:1px solid var(--dsw-alias-border-l2);background:color-mix(in srgb, var(--dsw-alias-label-tertiary) 30%, transparent);position:relative;cursor:pointer;transition:background var(--ds-transition-duration-slow) var(--ds-ease-in-out),border-color var(--ds-transition-duration-slow) var(--ds-ease-in-out)}
180
+ .${SESSION_TITLE_SETTINGS_CLASS}-toggle[data-on="true"]{background:var(--dsw-alias-state-success-primary);border-color:transparent}
181
+ .${SESSION_TITLE_SETTINGS_CLASS}-toggle::after{content:"";position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;background:var(--dsw-alias-label-primary);transition:transform var(--ds-transition-duration-slow) var(--ds-ease-in-out),background var(--ds-transition-duration-slow) var(--ds-ease-in-out)}
182
+ .${SESSION_TITLE_SETTINGS_CLASS}-toggle[data-on="true"]::after{transform:translateX(12px);background:var(--dsw-alias-label-primary-foreground)}
183
+ .${SESSION_TITLE_SETTINGS_CLASS}-actions{display:flex;align-items:center;gap:8px;padding-top:2px}
184
+ .${SESSION_TITLE_SETTINGS_CLASS}-btn{height:28px;padding:0 14px;border-radius:6px;cursor:pointer;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-interactive-bg);color:var(--dsw-alias-label-primary);font:var(--dsw-font-xxs-12)}
185
+ .${SESSION_TITLE_SETTINGS_CLASS}-btn:hover{background:var(--dsw-alias-interactive-bg-hover)}
186
+ .${SESSION_TITLE_SETTINGS_CLASS}-status{font:var(--dsw-font-xxs-12);color:var(--dsw-alias-label-tertiary)}
187
+ .${SESSION_TITLE_SETTINGS_CLASS}-saved{font:var(--dsw-font-xxs-12);color:var(--dsw-alias-state-success-primary)}
188
+ .${SESSION_TITLE_SETTINGS_CLASS}-error{font:var(--dsw-font-xxs-12);color:var(--dsw-alias-state-error-primary)}
189
+ `;
190
+ // ── 表单状态 ─────────────────────────────────────────────────────────
191
+ /** 文本项(template 多行,provider / model 单行)。 */
192
+ const SESSION_TITLE_TEXT_FIELDS = ['template', 'provider', 'model'];
193
+ /** 数字项(host 侧只认正整数,非法值回退默认)。 */
194
+ const SESSION_TITLE_NUMBER_FIELDS = ['maxTitleBytes', 'maxInputBytes', 'maxOutputTokens', 'timeoutMs'];
195
+ /** 设置页暴露的字段顺序(enabled 开关在最前)。 */
196
+ const SESSION_TITLE_FIELDS = ['enabled', ...SESSION_TITLE_TEXT_FIELDS, ...SESSION_TITLE_NUMBER_FIELDS];
197
+ /** 首屏(配置未到达前)的表单初值:与 host 半 src/config.ts 的 DEFAULT_SETTINGS 同口径。 */
198
+ const SESSION_TITLE_FALLBACK_FORM = {
199
+ enabled: true,
200
+ template: '[{workspace}] {description}',
201
+ provider: '',
202
+ model: '',
203
+ maxTitleBytes: '80',
204
+ maxInputBytes: '4096',
205
+ maxOutputTokens: '64',
206
+ timeoutMs: '30000',
207
+ };
208
+ /** 数字文本规整:数字原样,其余回退 fallback。 */
209
+ function sessionTitleNumberText(value, fallback) {
210
+ return typeof value === 'number' && Number.isFinite(value) ? String(value) : fallback;
211
+ }
212
+ /** 配置快照(GET / PUT 响应)→ 表单值:逐字段规整,缺失 / 非法回退初值。 */
213
+ function sessionTitleFormOf(value) {
214
+ const raw = (value ?? {});
215
+ return {
216
+ enabled: typeof raw.enabled === 'boolean' ? raw.enabled : SESSION_TITLE_FALLBACK_FORM.enabled,
217
+ template: typeof raw.template === 'string' && raw.template !== '' ? raw.template : SESSION_TITLE_FALLBACK_FORM.template,
218
+ provider: typeof raw.provider === 'string' ? raw.provider : '',
219
+ model: typeof raw.model === 'string' ? raw.model : '',
220
+ maxTitleBytes: sessionTitleNumberText(raw.maxTitleBytes, SESSION_TITLE_FALLBACK_FORM.maxTitleBytes),
221
+ maxInputBytes: sessionTitleNumberText(raw.maxInputBytes, SESSION_TITLE_FALLBACK_FORM.maxInputBytes),
222
+ maxOutputTokens: sessionTitleNumberText(raw.maxOutputTokens, SESSION_TITLE_FALLBACK_FORM.maxOutputTokens),
223
+ timeoutMs: sessionTitleNumberText(raw.timeoutMs, SESSION_TITLE_FALLBACK_FORM.timeoutMs),
224
+ };
225
+ }
226
+ /** 表单值 → PUT payload:数字项非正数 / 非数字 / 空提交 null(host 按非法值回退默认)。 */
227
+ function sessionTitleNumberOrNull(raw) {
228
+ const value = Number(raw);
229
+ return raw.trim() !== '' && Number.isFinite(value) ? value : null;
230
+ }
231
+ function sessionTitlePayloadOf(form) {
232
+ return {
233
+ enabled: form.enabled,
234
+ template: form.template,
235
+ provider: form.provider,
236
+ model: form.model,
237
+ maxTitleBytes: sessionTitleNumberOrNull(form.maxTitleBytes),
238
+ maxInputBytes: sessionTitleNumberOrNull(form.maxInputBytes),
239
+ maxOutputTokens: sessionTitleNumberOrNull(form.maxOutputTokens),
240
+ timeoutMs: sessionTitleNumberOrNull(form.timeoutMs),
241
+ };
242
+ }
243
+ /** 设置行(左:标题 + 说明;右:控件)。 */
244
+ function SessionTitleSettingsRow(props) {
245
+ return createElement('div', { className: SESSION_TITLE_SETTINGS_CLASS + '-row' }, createElement('div', { className: SESSION_TITLE_SETTINGS_CLASS + '-info' }, createElement('div', { className: SESSION_TITLE_SETTINGS_CLASS + '-label' }, props.label), createElement('div', { className: SESSION_TITLE_SETTINGS_CLASS + '-hint' }, props.hint)), props.control);
246
+ }
247
+ /** 布尔项控件:button role=switch(UI 规范禁止原生 checkbox)。 */
248
+ function sessionTitleToggle(form, onChange) {
249
+ const on = form.enabled;
250
+ return createElement('button', {
251
+ type: 'button',
252
+ className: SESSION_TITLE_SETTINGS_CLASS + '-toggle',
253
+ 'data-field': 'enabled',
254
+ 'data-on': String(on),
255
+ role: 'switch',
256
+ 'aria-checked': String(on),
257
+ onClick: () => onChange('enabled', !on),
258
+ });
259
+ }
260
+ /** 文本 / 数字项控件(template 用多行输入)。 */
261
+ function sessionTitleInput(field, form, onChange) {
262
+ const multiline = field === 'template';
263
+ const numeric = SESSION_TITLE_NUMBER_FIELDS.includes(field);
264
+ const className = multiline
265
+ ? SESSION_TITLE_SETTINGS_CLASS + '-input ' + SESSION_TITLE_SETTINGS_CLASS + '-input-multiline'
266
+ : SESSION_TITLE_SETTINGS_CLASS + '-input';
267
+ return createElement(multiline ? 'textarea' : 'input', {
268
+ className,
269
+ 'data-field': field,
270
+ type: numeric ? 'number' : 'text',
271
+ value: String(form[field]),
272
+ onChange: (event) => onChange(field, String(event?.target?.value ?? '')),
273
+ });
274
+ }
275
+ /** 8 项配置行(顺序固定:开关 → 3 文本 → 4 数字)。 */
276
+ function sessionTitleFieldRows(form, onChange) {
277
+ return SESSION_TITLE_FIELDS.map((field) => createElement(SessionTitleSettingsRow, {
278
+ key: field,
279
+ label: SESSION_TITLE_SETTINGS_STRINGS.fields[field].label(),
280
+ hint: SESSION_TITLE_SETTINGS_STRINGS.fields[field].hint(),
281
+ control: field === 'enabled' ? sessionTitleToggle(form, onChange) : sessionTitleInput(field, form, onChange),
282
+ }));
283
+ }
284
+ /** 加载失败提示:区分 404(服务端插件未加载)/ 403(安全围栏)/ 网络异常。 */
285
+ function sessionTitleErrorHint(errorKind) {
286
+ if (errorKind === 'http:404')
287
+ return SESSION_TITLE_SETTINGS_STRINGS.errorRouteMissing();
288
+ if (errorKind === 'http:403')
289
+ return SESSION_TITLE_SETTINGS_STRINGS.errorForbidden();
290
+ return SESSION_TITLE_SETTINGS_STRINGS.errorNetwork();
291
+ }
292
+ /** 配置加载失败视图:失败原因(http 状态 / 网络)+ 针对性提示 + 重试。 */
293
+ function SessionTitleSettingsLoadError(props) {
294
+ const cls = SESSION_TITLE_SETTINGS_CLASS;
295
+ return createElement('div', { className: cls }, createElement('div', { className: cls + '-error' }, SESSION_TITLE_SETTINGS_STRINGS.loadFailed()), createElement('div', { className: cls + '-status' }, sessionTitleErrorHint(props.errorKind)), createElement('div', { className: cls + '-actions' }, createElement('button', { type: 'button', className: cls + '-btn', onClick: props.onRetry }, SESSION_TITLE_SETTINGS_STRINGS.retry())));
296
+ }
297
+ /** 拉取当前配置并回填表单(成功 / 失败都落到状态上,不静默)。 */
298
+ function loadSessionTitleSettings(apply, setLoading, setErrorKind) {
299
+ setLoading(true);
300
+ setErrorKind('');
301
+ fetch(SESSION_TITLE_SETTINGS_API)
302
+ .then((res) => {
303
+ if (!res.ok)
304
+ throw Object.assign(new Error('HTTP ' + res.status), { status: res.status });
305
+ return res.json();
306
+ })
307
+ .then((body) => {
308
+ if (body === null || body.ok !== true)
309
+ throw new Error('bad config response');
310
+ apply(sessionTitleFormOf(body.value));
311
+ setLoading(false);
312
+ })
313
+ .catch((err) => {
314
+ setLoading(false);
315
+ // 404 = 路由未注册(服务端插件未加载),403 = 安全围栏拒绝,其余为网络/响应异常。
316
+ setErrorKind(typeof err?.status === 'number' ? 'http:' + err.status : 'network');
317
+ });
318
+ }
319
+ /** 保存表单(PUT 完整 8 项);成功用 host 规整后的值回填 + 提示,失败提示不静默。 */
320
+ function saveSessionTitleSettings(form, apply, setSaved, setFailed) {
321
+ setSaved(false);
322
+ setFailed(false);
323
+ fetch(SESSION_TITLE_SETTINGS_API, {
324
+ method: 'PUT',
325
+ headers: { 'content-type': 'application/json' },
326
+ body: JSON.stringify(sessionTitlePayloadOf(form)),
327
+ })
328
+ .then((res) => res.json())
329
+ .then((body) => {
330
+ if (body === null || body.ok !== true)
331
+ throw new Error('save failed');
332
+ // host 已把非法字段回退为默认值:用响应回填,用户看到的是**实际生效值**。
333
+ apply(sessionTitleFormOf(body.value));
334
+ setSaved(true);
335
+ })
336
+ .catch(() => setFailed(true));
337
+ }
338
+ /** 设置页主视图:加载当前配置 → 编辑 8 项 → 保存(PUT 配置端点)。 */
339
+ function SessionTitleSettingsView() {
340
+ const [form, setForm] = useState(SESSION_TITLE_FALLBACK_FORM);
341
+ const [loading, setLoading] = useState(true);
342
+ const [errorKind, setErrorKind] = useState('');
343
+ const [saved, setSaved] = useState(false);
344
+ const [failed, setFailed] = useState(false);
345
+ const cls = SESSION_TITLE_SETTINGS_CLASS;
346
+ const load = () => loadSessionTitleSettings(setForm, setLoading, setErrorKind);
347
+ useEffect(() => {
348
+ load();
349
+ }, []);
350
+ if (loading) {
351
+ return createElement('div', { className: cls }, createElement('div', { className: cls + '-status' }, SESSION_TITLE_SETTINGS_STRINGS.loading()));
352
+ }
353
+ if (errorKind !== '')
354
+ return createElement(SessionTitleSettingsLoadError, { errorKind, onRetry: load });
355
+ // 函数式更新:同一批里连续改多个字段(宿主可能合并渲染)不会用陈旧闭包互相覆盖。
356
+ const onChange = (field, value) => setForm((prev) => ({ ...prev, [field]: value }));
357
+ return createElement('div', { className: cls }, sessionTitleFieldRows(form, onChange), createElement('div', { className: cls + '-actions' }, createElement('button', {
358
+ type: 'button',
359
+ className: cls + '-btn',
360
+ onClick: () => saveSessionTitleSettings(form, setForm, setSaved, setFailed),
361
+ }, SESSION_TITLE_SETTINGS_STRINGS.save()), saved ? createElement('span', { className: cls + '-saved' }, SESSION_TITLE_SETTINGS_STRINGS.saved()) : null, failed ? createElement('span', { className: cls + '-error' }, SESSION_TITLE_SETTINGS_STRINGS.saveFailed()) : null));
362
+ }
363
+ /**
364
+ * 注册设置页签。两处刻意的写法:
365
+ * - `ctx.get('slots', false)`:**必须传 strict=false**——cordis 的
366
+ * `ctx.get(name, strict = true)` 在服务提供者 fiber 尚未 active(首屏)时返回
367
+ * undefined,页签会消失到下次 HMR;只有 strict=false 才拿得到实例。
368
+ * - 服务缺失(精简上下文 / 老宿主)时静默跳过:设置页是增强,不能因为拿不到
369
+ * slots 就让整个 client 挂掉。
370
+ */
371
+ function attachSettingsTab(ctx) {
372
+ // 样式注入走共享实现,位置在任何早退分支之前(服务判空 / HMR 时样式不会丢)。
373
+ installStyles(ctx, 'data-dsh-session-title-gen-settings', SESSION_TITLE_SETTINGS_STYLES, 'dsh-session-title-gen: settings styles');
374
+ const slots = typeof ctx.get === 'function' ? ctx.get('slots', false) : undefined;
375
+ if (slots === undefined || slots === null)
376
+ return;
377
+ ctx.effect(() => {
378
+ slots.inject('settings.plugins.tab', () => slots.register({
379
+ name: 'settings.plugins.tab',
380
+ id: SESSION_TITLE_SETTINGS_TAB_ID,
381
+ order: 93,
382
+ // 惰性:宿主靠重注册跟随语言切换,这里每次取都按当前宿主/浏览器语言判定。
383
+ label: () => SESSION_TITLE_SETTINGS_STRINGS.tabLabel(),
384
+ }, SessionTitleSettingsView));
385
+ return undefined;
386
+ }, 'dsh-session-title-gen: settings tab registration');
387
+ }
388
+
389
+
390
+ // ── Client bundle(编译自 src/client/index.ts)──────────────────
391
+ "use strict";
392
+ // ── dsh-session-title-gen — client 半入口(设置页签,issue #385)──────────
393
+ // 本插件是纯 server 形态(标题生成在主进程完成),client 半只承担一件事:
394
+ // 在「设置 → 插件」注册「会话标题生成」设置页,让 8 项配置可可视化编辑
395
+ // (此前只能手写 cordis.patch.yml)。
396
+ //
397
+ // 视图、文案与注册逻辑在 settings.ts / strings.ts(构建期由 build.mjs 注入同一
398
+ // factory 作用域;本文件只做入口)。产物 lib/client.js 必须提交(CI 只跑
399
+ // node --check + 测试,不跑构建)。
400
+ // 注:编译产物内联进 factory 作用域后,module.exports 已在模板中声明。
401
+ // 此处直接使用 module.exports(模板顶部已声明 var module = { exports: {} })。
402
+ const _exports = module.exports;
403
+ _exports.inject = ['slots'];
404
+ _exports.apply = function apply(ctx) {
405
+ // 设置页签(issue #385):注册「设置 → 插件 → 会话标题生成」。
406
+ // 拿不到 slots(精简上下文 / 老宿主)时静默降级,见 settings.ts 的 attachSettingsTab。
407
+ attachSettingsTab(ctx);
408
+ };
409
+
410
+
411
+ return module.exports
412
+ },
413
+ })
@@ -0,0 +1,41 @@
1
+ /**
2
+ * dsh-session-title-gen — client half (settings tab, issue #385). SOURCE TEMPLATE.
3
+ *
4
+ * 提供「设置 → 插件 → 会话标题生成」设置页签:可视化编辑 8 项配置
5
+ * (enabled / template / provider / model / maxTitleBytes / maxInputBytes /
6
+ * maxOutputTokens / timeoutMs),保存经 PUT 到插件配置端点,由 host 半写回
7
+ * profile 的 cordis.patch.yml 并热生效(详见 src/config-routes.ts)。
8
+ *
9
+ * 本插件的标题生成逻辑全在 server 端,client 半没有其它职责。
10
+ *
11
+ * BUILD NOTE: 本文件是源码模板(骨架)。scripts/build.mjs 先 tsc 编译
12
+ * src/client/index.ts → lib/.client-build/index.js(CommonJS 单文件),再把编译产物、
13
+ * src/client 的 part 片段(strings.ts / settings.ts 的产物)与 dsh-shared/client-parts
14
+ * 的样式注入件依次注入下方占位符,写出 lib/client.js —— DSH 实际提供的产物(单一
15
+ * __ModuleLoader__ bundle,无相对路径 require)。产物必须提交(CI 只跑
16
+ * node --check + 测试,不跑构建)。
17
+ */
18
+ window.__ModuleLoader__.load({
19
+ id: 'dsh-session-title-gen',
20
+ factory: (require) => {
21
+ var module = { exports: {} }
22
+ var exports = module.exports
23
+ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
24
+ // useState / useEffect 供设置页 part 使用(模板静态分析看不到 part 内容,故在此一并解构)
25
+ const { createElement, useState, useEffect } = require('react')
26
+
27
+ // ── 共享样式注入(dsh-shared/client-parts,issue #186 P2)──────────
28
+ /*__PART_STYLE_TAG__*/
29
+
30
+ // ── 设置页文案(src/client/strings.ts 产物,issue #385)────────────
31
+ /*__PART_STRINGS__*/
32
+
33
+ // ── 设置页视图与注册(src/client/settings.ts 产物,issue #385)──────
34
+ /*__PART_SETTINGS__*/
35
+
36
+ // ── Client bundle(编译自 src/client/index.ts)──────────────────
37
+ /*__CLIENT_BUNDLE__*/
38
+
39
+ return module.exports
40
+ },
41
+ })
@@ -0,0 +1,140 @@
1
+ /**
2
+ * dsh-session-title-gen — 设置页配置端点与路由注册(issue #385)。
3
+ *
4
+ * 「设置 → 插件 → 会话标题生成」的保存链路:client PUT `<prefix>/config` → 本模块校验
5
+ * → 写回 profile 层 patch 文件(持久化)→ 更新内存生效值并通知 apply 侧**热切换**
6
+ * (保存即生效,不必等 watchUserPatches 热重载、不必重启 DSH)。
7
+ *
8
+ * 写回复用 dsh-shared 的配置原语(currentProfile / patchFileOf / extractConfig /
9
+ * writePatchConfig),并**先合并该行已有键再写**:`writePatchConfig` 的语义是
10
+ * 「删除同 id 旧条目 → 追加新条目」,直接写会把用户手写的其它键(如 `disabled: true`)
11
+ * 一起抹掉(数据破坏,issue #385 验收项「写回不删掉用户手写的其它键」)。
12
+ *
13
+ * 注册契约(真实环境「设置页恒报配置加载失败」的修复依据):
14
+ * 1. 服务经 `ctx.inject(['webServer'], cb)` **局部等待**,不用 `ctx.get('webServer')`
15
+ * 一次性取值 —— cordis 的 get 带严格就绪检查且**没有重试**,webServer 晚于本插件
16
+ * 就绪时永久错过,路由从未注册 → client 端 404。
17
+ * 2. 注册承载在**常驻 root**(`ctx.root ?? ctx`)的 inject 子 fiber 上 —— profile 插件
18
+ * 自身 fiber 在 apply 结束后被 loader 回收,挂在它上面的 `ctx.effect` 会一并注销
19
+ * (路由同样消失 → 404)。
20
+ * 3. 顶层 `inject` **不**声明 webServer:那会让整个插件在无 webServer 的 profile
21
+ * (tui / headless)里 fiber PENDING、apply 根本不执行 —— 标题生成是主功能,不能陪葬。
22
+ */
23
+ import { readFile } from 'node:fs/promises';
24
+ import { currentProfile, extractConfig, isTrustedApiRequest, patchFileOf, readJsonBody, writeJson, writePatchConfig, } from 'dsh-shared';
25
+ import { mergePatchEntries, normalizeConfigPatch } from './config.js';
26
+ /** 配置读写路由前缀(client 端 GET/PUT `<prefix>/config`)。 */
27
+ export const CONFIG_ROUTE_PREFIX = '/session-title-gen/api';
28
+ /** 写回行 id:必须与 plugins/dsh-session-title-gen/cordis.patch.yml 的插件行 id 一致
29
+ * (loader 按行 id 匹配配置,id 不符会新增孤儿行、原行配置不变)。 */
30
+ export const CONFIG_ROW_ID = 'session-title-gen';
31
+ /** 读取 patch 文件中该行已有的 config 块(文件不存在 / 解析失败 → 空对象)。 */
32
+ async function readExistingConfig(file) {
33
+ try {
34
+ return extractConfig(await readFile(file, 'utf8'), CONFIG_ROW_ID) ?? {};
35
+ }
36
+ catch {
37
+ // 首次保存(文件还不存在)或文件不可读:按空配置合并,写入侧会创建目录。
38
+ return {};
39
+ }
40
+ }
41
+ /** 写回 profile 层 patch 文件:合并已有键后整体重写(保留用户手写的其它配置)。 */
42
+ export async function persistSettingsConfig(next) {
43
+ const file = patchFileOf(currentProfile());
44
+ await writePatchConfig(file, CONFIG_ROW_ID, mergePatchEntries(await readExistingConfig(file), next));
45
+ }
46
+ /** PUT 处理:校验 → 落盘(失败 500、内存不动)→ 回规整后的完整 8 项。 */
47
+ async function handleConfigPut(request, response, read, write) {
48
+ let next;
49
+ try {
50
+ next = normalizeConfigPatch(await readJsonBody(request), read());
51
+ }
52
+ catch {
53
+ next = undefined;
54
+ }
55
+ if (next === undefined) {
56
+ writeJson(response, 400, { ok: false, error: { message: 'invalid config' } });
57
+ return;
58
+ }
59
+ try {
60
+ await write(next);
61
+ }
62
+ catch (error) {
63
+ // 落盘失败绝不当成成功:client 侧据此提示「保存失败」,内存生效值保持原样。
64
+ writeJson(response, 500, { ok: false, error: { message: `config write failed: ${String(error)}` } });
65
+ return;
66
+ }
67
+ writeJson(response, 200, { ok: true, value: next });
68
+ }
69
+ /** 构造读写配置的 handler:fence → GET/PUT `<prefix>/config` → 403/404 兜底。 */
70
+ export function createConfigHandler(read, write) {
71
+ return async (request, response) => {
72
+ // loopback 信任围栏与 /api 网关同一契约(仅本机可访问;trustedHosts 本插件不使用)。
73
+ if (!isTrustedApiRequest(request, [])) {
74
+ writeJson(response, 403, { ok: false, error: { code: 'forbidden', message: 'forbidden' } });
75
+ return;
76
+ }
77
+ const pathname = new URL(request.url ?? '/', 'http://dsh.internal').pathname;
78
+ if (pathname === `${CONFIG_ROUTE_PREFIX}/config`) {
79
+ if (request.method === 'GET') {
80
+ writeJson(response, 200, { ok: true, value: read() });
81
+ return;
82
+ }
83
+ if (request.method === 'PUT') {
84
+ await handleConfigPut(request, response, read, write);
85
+ return;
86
+ }
87
+ }
88
+ writeJson(response, 404, { ok: false, error: { message: 'unknown dsh-session-title-gen API method' } });
89
+ };
90
+ }
91
+ /** 已注册配置路由的 disposer(以常驻 root ctx 为键,用于重复 apply 去重)。 */
92
+ const routeDisposers = new WeakMap();
93
+ /** 在 inject 子 scope 上挂载配置路由;注册失败只告警,绝不让插件 fatal。 */
94
+ function mountConfigRoute(hostCtx, scope, state, onSaved) {
95
+ // loader 会多次 apply 同一插件:root 常驻意味着上一轮注册不会自动消失,而宿主
96
+ // WebServer.register 对重复 (kind, path) 直接抛错 → 先撤上一轮再注册。
97
+ routeDisposers.get(hostCtx)?.();
98
+ scope.effect(() => {
99
+ const webServer = scope.webServer;
100
+ try {
101
+ const dispose = webServer?.register({
102
+ kind: 'prefix',
103
+ path: CONFIG_ROUTE_PREFIX,
104
+ handler: createConfigHandler(() => state.current, async (next) => {
105
+ await persistSettingsConfig(next);
106
+ state.current = next;
107
+ onSaved(next);
108
+ }),
109
+ });
110
+ if (dispose === undefined)
111
+ return undefined;
112
+ routeDisposers.set(hostCtx, dispose);
113
+ return () => {
114
+ if (routeDisposers.get(hostCtx) === dispose)
115
+ routeDisposers.delete(hostCtx);
116
+ dispose();
117
+ };
118
+ }
119
+ catch (error) {
120
+ // 宿主拒绝注册(同 path 已被占用等):降级为「无配置路由」,不冒泡成 fatal。
121
+ scope.logger?.warn(`[dsh-session-title-gen] 配置路由注册被宿主拒绝:${String(error)}`);
122
+ return undefined;
123
+ }
124
+ }, 'dsh-session-title-gen: config route');
125
+ }
126
+ /** 注册配置读写路由(契约见文件头);无 webServer 的 profile 下静默不注册。 */
127
+ export function registerConfigRoute(ctx, state, onSaved) {
128
+ const hostCtx = ctx.root ?? ctx;
129
+ try {
130
+ if (typeof hostCtx.inject !== 'function') {
131
+ ctx.logger?.warn('[dsh-session-title-gen] ctx.inject 不可用,配置路由未注册(设置页不可用)');
132
+ return;
133
+ }
134
+ hostCtx.inject(['webServer'], (scope) => mountConfigRoute(hostCtx, scope, state, onSaved));
135
+ }
136
+ catch (error) {
137
+ // inactive ctx 上建 inject 子 fiber 可能抛错:降级为「无配置路由」,不 fatal。
138
+ ctx.logger?.warn(`[dsh-session-title-gen] webServer 局部注入失败,配置路由未注册:${String(error)}`);
139
+ }
140
+ }
package/lib/config.js ADDED
@@ -0,0 +1,128 @@
1
+ import { DEFAULT_TEMPLATE } from './title.js';
2
+ /** 8 项配置的默认值(唯一来源:apply 回退 / 设置页回填 / PUT 非法字段回退共用)。 */
3
+ export const DEFAULT_SETTINGS = {
4
+ enabled: true,
5
+ template: DEFAULT_TEMPLATE,
6
+ provider: '',
7
+ model: '',
8
+ maxTitleBytes: 80,
9
+ maxInputBytes: 4096,
10
+ maxOutputTokens: 64,
11
+ timeoutMs: 30000,
12
+ };
13
+ /** 设置页暴露的 8 个字段名(GET 契约的键集合;PUT 至少含其一才算一次可应用的提交)。 */
14
+ export const SETTINGS_FIELDS = [
15
+ 'enabled',
16
+ 'template',
17
+ 'provider',
18
+ 'model',
19
+ 'maxTitleBytes',
20
+ 'maxInputBytes',
21
+ 'maxOutputTokens',
22
+ 'timeoutMs',
23
+ ];
24
+ /** 布尔字段规整:只认布尔,其余(含字符串 "false")回退 fallback。 */
25
+ function normalizeBool(value, fallback) {
26
+ return typeof value === 'boolean' ? value : fallback;
27
+ }
28
+ /** 非空字符串规整:非字符串 / 空串回退 fallback(template 用)。 */
29
+ function normalizeString(value, fallback) {
30
+ return typeof value === 'string' && value !== '' ? value : fallback;
31
+ }
32
+ /** provider/model 规整:字符串(含空串 = 跟随会话)生效,其余回退 ''。 */
33
+ function normalizeFollow(value, fallback) {
34
+ return typeof value === 'string' ? value.trim() : fallback;
35
+ }
36
+ /** 数字字段规整:只有正整数生效(0 / 负数 / NaN / 字符串一律回退默认)。 */
37
+ function normalizePositiveInt(value, fallback) {
38
+ return typeof value === 'number' && Number.isInteger(value) && value > 0 ? value : fallback;
39
+ }
40
+ /**
41
+ * 单个字段取值:payload 里**有**该键 → 规整(非法值回退 fallback);**没有**该键 → 保留
42
+ * 当前生效值。这样「非法字段只忽略该字段」与「部分提交不清空其它字段」同时成立。
43
+ */
44
+ function pick(raw, key, current, fallback, normalize) {
45
+ return key in raw ? normalize(raw[key], fallback) : current;
46
+ }
47
+ /** 应用层配置 → 生效值(8 项全必填;缺省 / 非法一律回退 {@link DEFAULT_SETTINGS})。 */
48
+ export function resolveConfig(config) {
49
+ const source = config ?? {};
50
+ return {
51
+ enabled: source.enabled !== false,
52
+ template: normalizeString(source.template, DEFAULT_SETTINGS.template),
53
+ provider: normalizeFollow(source.provider, DEFAULT_SETTINGS.provider),
54
+ model: normalizeFollow(source.model, DEFAULT_SETTINGS.model),
55
+ maxTitleBytes: normalizePositiveInt(source.maxTitleBytes, DEFAULT_SETTINGS.maxTitleBytes),
56
+ maxInputBytes: normalizePositiveInt(source.maxInputBytes, DEFAULT_SETTINGS.maxInputBytes),
57
+ maxOutputTokens: normalizePositiveInt(source.maxOutputTokens, DEFAULT_SETTINGS.maxOutputTokens),
58
+ timeoutMs: normalizePositiveInt(source.timeoutMs, DEFAULT_SETTINGS.timeoutMs),
59
+ };
60
+ }
61
+ /** payload 是否至少包含一个 8 项字段(`{}` / 空 body 不算一次可应用的提交 → 400)。 */
62
+ function hasSettingsField(raw) {
63
+ return SETTINGS_FIELDS.some((field) => field in raw);
64
+ }
65
+ /**
66
+ * PUT payload → 生效值:非对象(null / 数组 / 标量)或**不含任何 8 项字段**(空 body /
67
+ * `{}`)返回 undefined,调用方回 400 且**不落盘**;对象内**非法字段只忽略该字段并回退
68
+ * 默认**(不整单拒绝——设置页一次提交 8 项,因一项非法而丢掉其余 7 项合法修改,用户会
69
+ * 误以为保存无效)。
70
+ */
71
+ export function normalizeConfigPatch(payload, current) {
72
+ if (payload === null || typeof payload !== 'object' || Array.isArray(payload))
73
+ return undefined;
74
+ const raw = payload;
75
+ if (!hasSettingsField(raw))
76
+ return undefined;
77
+ return {
78
+ enabled: pick(raw, 'enabled', current.enabled, DEFAULT_SETTINGS.enabled, normalizeBool),
79
+ template: pick(raw, 'template', current.template, DEFAULT_SETTINGS.template, normalizeString),
80
+ provider: pick(raw, 'provider', current.provider, DEFAULT_SETTINGS.provider, normalizeFollow),
81
+ model: pick(raw, 'model', current.model, DEFAULT_SETTINGS.model, normalizeFollow),
82
+ maxTitleBytes: pick(raw, 'maxTitleBytes', current.maxTitleBytes, DEFAULT_SETTINGS.maxTitleBytes, normalizePositiveInt),
83
+ maxInputBytes: pick(raw, 'maxInputBytes', current.maxInputBytes, DEFAULT_SETTINGS.maxInputBytes, normalizePositiveInt),
84
+ maxOutputTokens: pick(raw, 'maxOutputTokens', current.maxOutputTokens, DEFAULT_SETTINGS.maxOutputTokens, normalizePositiveInt),
85
+ timeoutMs: pick(raw, 'timeoutMs', current.timeoutMs, DEFAULT_SETTINGS.timeoutMs, normalizePositiveInt),
86
+ };
87
+ }
88
+ /** 生效值 → 标题生成配置:空串 provider/model 转 undefined(走会话请求路由)。 */
89
+ export function toTitleConfig(settings) {
90
+ return {
91
+ template: settings.template,
92
+ provider: settings.provider === '' ? undefined : settings.provider,
93
+ model: settings.model === '' ? undefined : settings.model,
94
+ maxTitleBytes: settings.maxTitleBytes,
95
+ maxInputBytes: settings.maxInputBytes,
96
+ maxOutputTokens: settings.maxOutputTokens,
97
+ timeoutMs: settings.timeoutMs,
98
+ };
99
+ }
100
+ /** 落盘形状:8 项中空串 provider/model **不写键**(保持 patch 文件干净,读回时空串)。 */
101
+ function patchEntries(next) {
102
+ const entries = {
103
+ enabled: next.enabled,
104
+ template: next.template,
105
+ maxTitleBytes: next.maxTitleBytes,
106
+ maxInputBytes: next.maxInputBytes,
107
+ maxOutputTokens: next.maxOutputTokens,
108
+ timeoutMs: next.timeoutMs,
109
+ };
110
+ if (next.provider !== '')
111
+ entries.provider = next.provider;
112
+ if (next.model !== '')
113
+ entries.model = next.model;
114
+ return entries;
115
+ }
116
+ /**
117
+ * 合并落盘条目:保留该行**用户手写的其它键**(如 `disabled`、私有实验字段),只覆盖本次
118
+ * 提交的 8 项;provider/model 置空则删除该键(否则旧值留在文件里被读回,形成「设置页显示空、
119
+ * 实际仍生效」的幽灵配置)。调用方负责先读该行已有 config(见 config-routes.ts)。
120
+ */
121
+ export function mergePatchEntries(existing, next) {
122
+ const merged = { ...existing };
123
+ if (next.provider === '')
124
+ delete merged.provider;
125
+ if (next.model === '')
126
+ delete merged.model;
127
+ return { ...merged, ...patchEntries(next) };
128
+ }
package/lib/host.js CHANGED
@@ -21,13 +21,30 @@ const rootListeners = new WeakMap();
21
21
  * Register the session/event listener where the host actually dispatches it.
22
22
  * @param listenCtx - the context to register on (root when available).
23
23
  * @param handler - listener receiving (session, event).
24
- * @returns the registered context (for diagnostics/tests).
24
+ * @returns disposer removing exactly this registration (idempotent).
25
25
  */
26
26
  export function listenSessionEvents(listenCtx, handler) {
27
- rootListeners.get(listenCtx)?.();
27
+ stopSessionEvents(listenCtx);
28
28
  // { global: true } 跳过 cordis 的 scope 过滤(@deepseek-ai/dsh-scope 的 carrier
29
29
  // filter);缺了它,打有 scope tag 的监听器会被静默排除。
30
- rootListeners.set(listenCtx, listenCtx.on('session/event', handler, { global: true }));
30
+ const dispose = listenCtx.on('session/event', handler, { global: true });
31
+ const wrapped = () => {
32
+ if (rootListeners.get(listenCtx) === wrapped)
33
+ rootListeners.delete(listenCtx);
34
+ dispose();
35
+ };
36
+ rootListeners.set(listenCtx, wrapped);
37
+ return wrapped;
38
+ }
39
+ /**
40
+ * Remove the session/event listener registered by {@link listenSessionEvents}.
41
+ *
42
+ * 热生效用(issue #385):设置页把 `enabled` 关掉时立刻停止监听(零开销),
43
+ * 重新打开时再挂回来 —— 不必重启 DSH。
44
+ * @param listenCtx - the context the listener was registered on.
45
+ */
46
+ export function stopSessionEvents(listenCtx) {
47
+ rootListeners.get(listenCtx)?.();
31
48
  }
32
49
  /**
33
50
  * Resolve the llm service from a listener-safe source.
package/lib/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * dsh-session-title-gen — structured session title generation (issue #160).
2
+ * dsh-session-title-gen — structured session title generation (issue #160)
3
3
  *
4
4
  * 监听会话首条人类消息,用 LLM 生成类似 git commit 的结构化标题
5
5
  * (先归属后描述,如 `[my-dsh-plugins] 修复 #143 记忆页签崩溃`),经核心
@@ -15,37 +15,45 @@
15
15
  * 宿主契约适配见 host.ts(issue #232:profile 插件的 events 实例隔离、
16
16
  * Session.events 私有化、插件 ctx 在监听器执行时已 inactive),
17
17
  * 单会话生成流程见 generate.ts。
18
+ *
19
+ * 配置面(issue #385):8 项配置经「设置 → 插件 → 会话标题生成」可视化编辑,
20
+ * 保存写回 profile 的 cordis.patch.yml 并**热生效** —— 配置端点与注册契约见
21
+ * config-routes.ts,默认值与规整口径见 config.ts。
18
22
  */
23
+ import { resolveConfig, toTitleConfig } from './config.js';
24
+ import { registerConfigRoute } from './config-routes.js';
19
25
  import { generateSessionTitle, isUserMessage, shouldSkip } from './generate.js';
20
- import { createWarn, listenSessionEvents, selectLlm } from './host.js';
21
- import { DEFAULT_TEMPLATE } from './title.js';
26
+ import { createWarn, listenSessionEvents, selectLlm, stopSessionEvents } from './host.js';
22
27
  export const name = 'dsh-session-title-gen';
23
28
  export const inject = ['llm'];
24
- /** 默认配置(可被 cordis.patch.yml config 覆盖)。 */
25
- const DEFAULTS = {
26
- enabled: true,
27
- template: DEFAULT_TEMPLATE,
28
- provider: undefined,
29
- model: undefined,
30
- maxTitleBytes: 80,
31
- maxInputBytes: 4096,
32
- maxOutputTokens: 64,
33
- timeoutMs: 30000,
34
- };
35
29
  export function apply(ctx, config) {
36
- const cfg = resolveConfig(config);
37
- if (!cfg.enabled)
38
- return;
39
- const state = new Map();
30
+ // 生效值容器:设置页保存后原地更新(热生效),生成逻辑每次读最新值。
31
+ const state = { current: resolveConfig(config) };
32
+ const generating = new Map();
40
33
  // 注册点与服务都必须按宿主 root 规则取(详见 host.ts):会话事件只在 root 的
41
34
  // events 实例上派发,而插件 ctx 在监听器执行时已 inactive,动态取服务会抛错。
42
35
  const listenCtx = ctx.root ?? ctx;
43
36
  const llm = selectLlm(listenCtx, ctx.llm);
44
37
  const warn = createWarn(ctx.logger);
45
- listenSessionEvents(listenCtx, onSessionEvent);
38
+ let listening = false;
39
+ /** 挂载 / 卸载 session/event 监听(设置页保存 enabled 后立即热切换)。 */
40
+ function syncListeners(enabled) {
41
+ if (enabled === listening)
42
+ return;
43
+ listening = enabled;
44
+ if (enabled)
45
+ listenSessionEvents(listenCtx, onSessionEvent);
46
+ else
47
+ stopSessionEvents(listenCtx);
48
+ }
49
+ // 配置路由**无条件注册**(含禁用状态):否则用户在设置页里没有入口把插件重新打开;
50
+ // enabled=false 只影响标题生成,不影响配置面。
51
+ registerConfigRoute(ctx, state, (next) => syncListeners(next.enabled));
52
+ syncListeners(state.current.enabled);
46
53
  ctx.effect(() => () => {
47
- state.clear();
54
+ generating.clear();
48
55
  }, 'dsh-session-title-gen: state lifecycle');
56
+ ctx.logger?.info(`[dsh-session-title-gen] 结构化会话标题${state.current.enabled ? '已启用' : '已禁用(可在「设置 → 插件」中开启)'}`);
49
57
  /** session/event 监听器:人类消息或标题事件触发结构化标题生成。 */
50
58
  function onSessionEvent(session, event) {
51
59
  const sess = session;
@@ -65,35 +73,17 @@ export function apply(ctx, config) {
65
73
  return;
66
74
  if (shouldSkip(session))
67
75
  return;
68
- if (state.get(session.id)?.generating)
76
+ if (state.current.enabled === false)
77
+ return;
78
+ if (generating.get(session.id)?.generating)
69
79
  return;
70
- const promise = generateSessionTitle({ session, llm, warn, config: cfg });
71
- state.set(session.id, { generating: promise });
80
+ const promise = generateSessionTitle({ session, llm, warn, config: toTitleConfig(state.current) });
81
+ generating.set(session.id, { generating: promise });
72
82
  try {
73
83
  await promise;
74
84
  }
75
85
  finally {
76
- state.delete(session.id);
86
+ generating.delete(session.id);
77
87
  }
78
88
  }
79
89
  }
80
- /** 配置解析:缺省值 + 类型护栏。 */
81
- function resolveConfig(config) {
82
- const candidate = config ?? {};
83
- return {
84
- enabled: candidate.enabled !== false,
85
- template: nonEmptyString(candidate.template, DEFAULTS.template),
86
- provider: nonEmptyString(candidate.provider, DEFAULTS.provider),
87
- model: nonEmptyString(candidate.model, DEFAULTS.model),
88
- maxTitleBytes: positiveInt(candidate.maxTitleBytes, DEFAULTS.maxTitleBytes),
89
- maxInputBytes: positiveInt(candidate.maxInputBytes, DEFAULTS.maxInputBytes),
90
- maxOutputTokens: positiveInt(candidate.maxOutputTokens, DEFAULTS.maxOutputTokens),
91
- timeoutMs: positiveInt(candidate.timeoutMs, DEFAULTS.timeoutMs),
92
- };
93
- }
94
- function nonEmptyString(value, fallback) {
95
- return typeof value === 'string' && value !== '' ? value : fallback;
96
- }
97
- function positiveInt(value, fallback) {
98
- return Number.isInteger(value) && value > 0 ? value : fallback;
99
- }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-session-title-gen",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "DSH 会话标题自动生成插件:监听会话首条人类消息,用 LLM 生成类似 git commit 的结构化标题(先标明归属工作区,再写简要描述),经核心 session/title 事件写入(重启保留),失败自动回退核心标题机制。DSH web plugin: structured session titles like git commit messages (workspace first, then description), written through the core session/title event.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -12,15 +12,18 @@
12
12
  "node": ">=22"
13
13
  },
14
14
  "scripts": {
15
- "build": "npx tsc -p tsconfig.json",
16
- "typecheck": "npx tsc --noEmit -p tsconfig.json",
15
+ "build": "npx tsc -p tsconfig.json && node scripts/build.mjs",
16
+ "typecheck": "npx tsc --noEmit -p tsconfig.json && npx tsc --noEmit -p tsconfig.client.json",
17
17
  "test": "npx vitest run --coverage && cucumber-js test/features --import \"test/features/steps/*.mjs\""
18
18
  },
19
19
  "exports": {
20
20
  ".": {
21
21
  "default": "./lib/index.js"
22
22
  },
23
- "./package.json": "./package.json"
23
+ "./package.json": "./package.json",
24
+ "./client": {
25
+ "default": "./lib/client.js"
26
+ }
24
27
  },
25
28
  "files": [
26
29
  "lib",
@@ -33,15 +36,22 @@
33
36
  "dsh": {
34
37
  "bundle": {
35
38
  "patch": "./cordis.patch.yml"
39
+ },
40
+ "client": {
41
+ "platform": "web"
36
42
  }
37
43
  },
38
44
  "peerDependencies": {
39
45
  "cordis": "^4.0.0-rc.10",
40
- "dsh-shared": "^0.1.4"
46
+ "dsh-shared": "^0.1.4",
47
+ "react": "^18.2.0 || ^19.3.0"
41
48
  },
42
49
  "peerDependenciesMeta": {
43
50
  "cordis": {
44
51
  "optional": true
52
+ },
53
+ "react": {
54
+ "optional": true
45
55
  }
46
56
  },
47
57
  "license": "MIT"