@yfwu2020/dsh-selection-explain 0.7.0 → 0.8.3

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.
@@ -0,0 +1,228 @@
1
+ /**
2
+ * 划词解读 · 设置面板的**单一事实来源**。
3
+ *
4
+ * 为什么单独成模块:
5
+ * · 小窗设置页要渲染 20+ 个字段,字段的标题 / 说明 / 单位 / 边界**必须**与 host 的
6
+ * 校验规则是同一份数据 —— 分两处写迟早会漂(UI 说 1~600,host 却按 1~200 截断)。
7
+ * 所以 GET /settings 直接把这份 spec 发给客户端,客户端只负责画,不做业务判断。
8
+ * · 纯数据 + 纯函数,不 import cordis / node,单测可以直接跑(见 scripts/test-settings.mjs)。
9
+ *
10
+ * 分组是**导航结构**,不是装饰:20+ 项平铺会淹没用户,按"用户想去哪类事"分 6 组。
11
+ */
12
+ /**
13
+ * 分组顺序 = 左栏从上到下的顺序。
14
+ * 「模型」放第一:27 项里唯一大多数人有理由改的一类。
15
+ */
16
+ export const SETTINGS_GROUPS = [
17
+ {
18
+ id: 'model', label: '模型',
19
+ sections: [
20
+ { "label": "模型", "keys": ["modelChoice"] },
21
+ { "label": "思考强度", "keys": ["translationReasoningEffort", "reasoningEffort", "chatReasoningEffort"], "note": "关闭思考仍会生成回答。" },
22
+ ],
23
+ items: [
24
+ { "key": "modelChoice", "kind": "model", "label": "模型", "hint": "", "keys": ["provider", "model"] },
25
+ { "key": "translationReasoningEffort", "kind": "seg", "label": "首轮", "hint": "翻译 / 解读 / 注释", "options": ["off", "low", "high", "max"] },
26
+ { "key": "reasoningEffort", "kind": "seg", "label": "次轮", "hint": "点击「展开详解」", "options": ["off", "low", "high", "max"] },
27
+ { "key": "chatReasoningEffort", "kind": "seg", "label": "追问", "hint": "在小窗中继续提问", "options": ["off", "low", "high", "max"] },
28
+ ],
29
+ },
30
+ {
31
+ id: 'read', label: '解读',
32
+ sections: [
33
+ { "label": "文本范围", "keys": ["maxSelectionChars", "maxContextChars"] },
34
+ { "label": "频率与时限", "keys": ["maxRequestsPerMinute", "timeoutMs"] },
35
+ ],
36
+ items: [
37
+ { "key": "maxSelectionChars", "kind": "number", "label": "选中文字上限", "hint": "超出此长度时,不进行解读。", "unit": "字符", "min": 20, "max": 20000, "step": 1 },
38
+ { "key": "maxContextChars", "kind": "number", "label": "局部前后文上限", "hint": "不含会话背景,超出部分截断。", "unit": "字符", "min": 200, "max": 60000, "step": 1 },
39
+ { "key": "maxRequestsPerMinute", "kind": "number", "label": "每分钟请求上限", "hint": "所有阶段合计,超限需稍后重试。", "unit": "次", "min": 1, "max": 600, "step": 1 },
40
+ { "key": "timeoutMs", "kind": "number", "label": "生成时限", "hint": "含检索耗时,超时后停止生成。", "unit": "秒", "min": 5000, "max": 600000, "step": 1000, "scale": 1000 },
41
+ ],
42
+ },
43
+ {
44
+ id: 'ctx', label: '背景',
45
+ sections: [
46
+ { "label": "解读", "keys": ["sessionContext", "sessionContextFastMessages", "sessionContextMaxMessages", "sessionContextMaxChars"] },
47
+ { "label": "引用", "keys": ["quoteContextRounds", "quoteContextMaxCharsPerTurn", "quoteContextMaxChars"], "hint": "将引用所在轮及其上下文随提问一起发给模型。", "note": "一轮包含一次提问及其回答。" },
48
+ ],
49
+ items: [
50
+ { "key": "sessionContext", "kind": "switch", "label": "附带会话消息", "hint": "将选中文字所在消息及之前的消息一并发给模型。" },
51
+ { "key": "sessionContextFastMessages", "kind": "number", "label": "首轮上下文消息数", "hint": "每条提问或回答计一条。", "unit": "条", "min": 1, "max": 200, "step": 1, "enabledBy": "sessionContext" },
52
+ { "key": "sessionContextMaxMessages", "kind": "number", "label": "次轮与追问上下文消息数", "hint": "", "unit": "条", "min": 1, "max": 200, "step": 1, "enabledBy": "sessionContext" },
53
+ { "key": "sessionContextMaxChars", "kind": "number", "label": "上下文总字符上限", "hint": "0 为不限;超出时优先保留近期消息。", "unit": "字符", "min": 0, "max": 2000000, "step": 1000, "enabledBy": "sessionContext" },
54
+ { "key": "quoteContextRounds", "kind": "number", "label": "上下文轮数", "hint": "引用前后各取指定轮数;0 仅取所在轮。", "unit": "轮", "min": 0, "max": 10, "step": 1 },
55
+ { "key": "quoteContextMaxCharsPerTurn", "kind": "number", "label": "单条消息字符上限", "hint": "长消息优先保留引用附近内容。", "unit": "字符", "min": 200, "max": 20000, "step": 100 },
56
+ { "key": "quoteContextMaxChars", "kind": "number", "label": "上下文总字符上限", "hint": "超出时减少上下文,保留引用所在消息。", "unit": "字符", "min": 400, "max": 100000, "step": 100 },
57
+ ],
58
+ },
59
+ {
60
+ id: 'tools', label: '联网',
61
+ sections: [
62
+ { "label": "工具调用", "keys": ["tools", "maxToolRounds", "toolResultMaxChars"] },
63
+ { "label": "工具范围", "keys": ["toolNames", "fallbackToolNames", "toolReadRoot"] },
64
+ ],
65
+ items: [
66
+ { "key": "tools", "kind": "switch", "label": "允许调用工具", "hint": "模型可按需调用下方指定的工具。" },
67
+ { "key": "maxToolRounds", "kind": "number", "label": "调用轮数", "hint": "0 为不调用工具;同一轮可调用多个工具。", "unit": "轮", "min": 0, "max": 8, "step": 1, "enabledBy": "tools" },
68
+ { "key": "toolResultMaxChars", "kind": "number", "label": "单次结果字符上限", "hint": "工具返回的文本超出时截断。", "unit": "字符", "min": 200, "max": 20000, "step": 100, "enabledBy": "tools" },
69
+ { "key": "toolNames", "kind": "text", "label": "允许的工具", "hint": "工具名以逗号分隔。", "placeholder": "advanced_search,platform_search,read,grep,glob", "enabledBy": "tools" },
70
+ { "key": "fallbackToolNames", "kind": "text", "label": "备用搜索工具", "hint": "找不到允许列表中的搜索工具时补充。", "placeholder": "web_search", "enabledBy": "tools" },
71
+ { "key": "toolReadRoot", "kind": "text", "label": "文件读取目录", "hint": "留空取会话工作目录;* 为不限。", "placeholder": "会话工作目录", "enabledBy": "tools" },
72
+ ],
73
+ },
74
+ {
75
+ id: 'ui', label: '界面',
76
+ sections: [
77
+ { "label": "划词入口", "keys": ["bridgeSidebarPreview"] },
78
+ { "label": "悬浮入口", "keys": ["pillEnabled", "pillIdleMs"], "note": "费用胶囊显示时保持展开。" },
79
+ ],
80
+ items: [
81
+ { "key": "bridgeSidebarPreview", "kind": "switch", "label": "侧边栏网页划词", "hint": "选中文字后显示「解读」按钮。" },
82
+ { "key": "pillEnabled", "kind": "switch", "label": "显示状态胶囊", "hint": "显示最近一次解读状态,点击打开或收起小窗。" },
83
+ { "key": "pillIdleMs", "kind": "number", "label": "自动收起时间", "hint": "空闲达到此时长后缩成小球。", "unit": "秒", "min": 2000, "max": 600000, "step": 1000, "scale": 1000, "enabledBy": "pillEnabled" },
84
+ ],
85
+ },
86
+ {
87
+ id: 'data', label: '数据',
88
+ sections: [
89
+ { "label": "历史记录", "keys": ["historyMaxEntries"], "note": "0 为不保存,已有对话保留。超出时移除最久未更新的对话,已升格的保留。" },
90
+ ],
91
+ items: [
92
+ { "key": "historyMaxEntries", "kind": "number", "label": "保留最近对话数量", "hint": "每条包含所选文字、模型回答及后续追问。", "unit": "条", "min": 0, "max": 500, "step": 1 },
93
+ ],
94
+ },
95
+ ];
96
+ /** spec 里的全部条目(含虚拟键)。 */
97
+ export const SETTINGS_ITEMS = SETTINGS_GROUPS.flatMap((group) => group.items);
98
+ /** key → spec 的索引(渲染与校验按 key 找规则)。 */
99
+ export const SETTINGS_BY_KEY = new Map(SETTINGS_ITEMS.map((item) => [item.key, item]));
100
+ /**
101
+ * 真正会写进配置的字段全集。
102
+ *
103
+ * 与「spec 的 key 集合」**不是一回事**:模型下拉的 key 是虚拟的 `modelChoice`,
104
+ * 它写的是 `provider` + `model` 两个真实字段(见 SettingSpec.keys)。
105
+ * 校验、应用覆盖、组装当前值,全都用这一份。
106
+ */
107
+ export const SETTINGS_KEYS = SETTINGS_ITEMS.flatMap((item) => item.keys ?? [item.key]);
108
+ /** 只读的虚拟键(客户端可以渲染,但不能直接 POST 写它)。 */
109
+ export const SETTINGS_VIRTUAL_KEYS = SETTINGS_ITEMS
110
+ .filter((item) => Array.isArray(item.keys))
111
+ .map((item) => item.key);
112
+ /**
113
+ * **配置键** → 声明它的 spec(复合字段按 keys 展开)。
114
+ *
115
+ * 校验走这一份而不是 SETTINGS_BY_KEY:POST 上来的是配置键(`provider` / `model`),
116
+ * 而 spec 里那条的 key 是虚拟的 `modelChoice` —— 只有展开过才找得到规则。
117
+ */
118
+ export const SETTINGS_WRITABLE_BY_KEY = new Map(SETTINGS_ITEMS.flatMap((item) => (item.keys ?? [item.key]).map((key) => [key, item])));
119
+ /**
120
+ * 把一个客户端补丁归一成"可直接写进配置"的对象。
121
+ *
122
+ * 规则:
123
+ * · 不认识的 key 一律拒绝(不能靠 POST 往配置里塞任意字段);
124
+ * · number 做 clamp(超界不报错,夹到边界 —— 用户拖滑块时不该看到红色报错);
125
+ * · seg 只接受 options 里的值;
126
+ * · switch 只接受真正的布尔(字符串 'true' 之类的也别猜,直接拒);
127
+ * · text 去掉首尾空白,长到离谱的直接拒(防止把配置文件写爆)。
128
+ *
129
+ * 有意**不抛异常**:一个键写错不该让整次保存失败。
130
+ */
131
+ export function normalizeSettingsPatch(input) {
132
+ const values = {};
133
+ const rejected = [];
134
+ if (!input || typeof input !== 'object' || Array.isArray(input)) {
135
+ return { ok: false, values, rejected: [{ key: '', reason: '请求体不是对象' }] };
136
+ }
137
+ for (const [key, raw] of Object.entries(input)) {
138
+ const spec = SETTINGS_WRITABLE_BY_KEY.get(key);
139
+ if (!spec) {
140
+ // 虚拟键(如 modelChoice)不直接写配置:客户端要把它展开成 provider + model 再发上来。
141
+ // 单独认一下是为了给一句能照着做的错误,而不是笼统的"未知设置项"。
142
+ const virtual = SETTINGS_BY_KEY.get(key);
143
+ rejected.push({
144
+ key,
145
+ reason: virtual && Array.isArray(virtual.keys)
146
+ ? `该字段由 ${virtual.keys.join(' + ')} 组成,请分别提交`
147
+ : '未知的设置项',
148
+ });
149
+ continue;
150
+ }
151
+ // 复合字段的子键一律按文本校验:目前唯一的复合字段是模型选择,
152
+ // provider 与 model 都是字符串(新增复合字段时在这里补类型判断)。
153
+ if (Array.isArray(spec.keys)) {
154
+ if (typeof raw !== 'string') {
155
+ rejected.push({ key, reason: '需要字符串' });
156
+ continue;
157
+ }
158
+ if (raw.trim().length > 500) {
159
+ rejected.push({ key, reason: '太长(上限 500 字符)' });
160
+ continue;
161
+ }
162
+ values[key] = raw.trim();
163
+ continue;
164
+ }
165
+ if (spec.kind === 'switch') {
166
+ if (typeof raw !== 'boolean') {
167
+ rejected.push({ key, reason: '需要布尔值' });
168
+ continue;
169
+ }
170
+ values[key] = raw;
171
+ continue;
172
+ }
173
+ if (spec.kind === 'number') {
174
+ const num = typeof raw === 'number' ? raw : Number(raw);
175
+ if (!Number.isFinite(num)) {
176
+ rejected.push({ key, reason: '需要数字' });
177
+ continue;
178
+ }
179
+ const min = spec.min ?? -Infinity;
180
+ const max = spec.max ?? Infinity;
181
+ values[key] = Math.min(max, Math.max(min, num));
182
+ continue;
183
+ }
184
+ if (spec.kind === 'seg') {
185
+ if (typeof raw !== 'string' || !(spec.options ?? []).includes(raw)) {
186
+ rejected.push({ key, reason: `可选:${(spec.options ?? []).join(' / ')}` });
187
+ continue;
188
+ }
189
+ values[key] = raw;
190
+ continue;
191
+ }
192
+ // text
193
+ if (typeof raw !== 'string') {
194
+ rejected.push({ key, reason: '需要字符串' });
195
+ continue;
196
+ }
197
+ const text = raw.trim();
198
+ if (text.length > 2000) {
199
+ rejected.push({ key, reason: '太长(上限 2000 字符)' });
200
+ continue;
201
+ }
202
+ values[key] = text;
203
+ }
204
+ return { ok: rejected.length === 0, values, rejected };
205
+ }
206
+ /** 组装 GET /settings 的响应体。 */
207
+ export function buildSettingsPayload(values, defaults) {
208
+ return { groups: SETTINGS_GROUPS, values, defaults };
209
+ }
210
+ /** 旧设置文件的底层配置继续生效,但不再暴露为设置控件或接受 UI 写入。 */
211
+ export const PERSISTED_SETTING_KEYS = ['maxTokens', 'temperature', 'resultCacheTtlMs'];
212
+ export function normalizeStoredSettings(input) {
213
+ const values = normalizeSettingsPatch(input).values;
214
+ if (!input || typeof input !== 'object' || Array.isArray(input))
215
+ return values;
216
+ const raw = input;
217
+ const bounds = { maxTokens: [0, 32000], temperature: [-1, 2], resultCacheTtlMs: [0, 86400000] };
218
+ for (const key of PERSISTED_SETTING_KEYS) {
219
+ const value = raw[key];
220
+ if (typeof value !== 'number' || !Number.isFinite(value))
221
+ continue;
222
+ const [min, max] = bounds[key];
223
+ values[key] = Math.min(max, Math.max(min, value));
224
+ }
225
+ // chatProvider/chatModel 已退役,不迁入统一的 provider/model。
226
+ return values;
227
+ }
228
+ //# sourceMappingURL=settings.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"settings.js","sourceRoot":"","sources":["../src/settings.ts"],"names":[],"mappings":"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"}
@@ -3,16 +3,44 @@ import type { WebServer } from '@deepseek-ai/dsh-host-webserver';
3
3
  import z from '@deepseek-ai/schemastery';
4
4
  /** 插件名(= package.json name,客户端 bundle 的模块 id 也是它)。 */
5
5
  export declare const name = "@yfwu2020/dsh-selection-explain";
6
+ /**
7
+ * 升格时那条「上下文注入」消息的 `source.kind` —— 生产者**自有**的来源标识。
8
+ *
9
+ * 会话格式 v4 起(本机实测内核 0.2.0-rc.2),消息来源必须是生产者自报的 kind;
10
+ * v3 那种 `plugin` 包装(`kind: 'plugin'` 配一个 `plugin` 字段)已经退役,会被
11
+ * v4 准入当场拒掉:
12
+ * SessionFormatError: format v4 message requires a producer-owned source kind
13
+ * 症状极具迷惑性:`↗ 升格` 的 HTTP 请求返回 `ok: true`(那时只写进了内存里的会话面),
14
+ * 随后那轮 opener 运行失败、整个 artifact 一个字都没落盘 —— 新会话只剩表头。
15
+ * 离线测试也照样全绿(校验发生在真实内核里,不在构建期),所以另配了
16
+ * scripts/test-format.mjs 钉住这个 shape。
17
+ *
18
+ * 第三方插件的规范形态是 `plugin:<插件名>`:内核 v3→v4 迁移把那种 `plugin` 包装
19
+ * 重写成 `plugin:<完整插件名>`(`producerKind()` 对不在同名表里的插件就是这么算的),
20
+ * 所以这里写的与迁移结果逐字节一致 —— 新旧会话归因相同,且 v3 内核下同样合法
21
+ * (直写的 kind 迁移时原样保留)。
22
+ */
23
+ export declare const PLUGIN_SOURCE_KIND = "plugin:@yfwu2020/dsh-selection-explain";
6
24
  /** 路由注册与模型调用所需的服务。 */
7
25
  export declare const inject: string[];
8
26
  /** 插件配置。 */
9
27
  export interface Config {
10
- /** 固定 provider 路由;留空 = 跟随默认模型。 */
28
+ /** 固定 provider 路由;留空 = 跟随主会话(用户在 DSH 里选的默认模型)。 */
11
29
  provider: string;
12
- /** 固定 model id;留空 = 跟随默认模型。 */
30
+ /** 固定 model id;留空 = 跟随主会话。 */
13
31
  model: string;
14
- /** 推理档位(off / low / high / max);留空 = 适配器默认。 */
32
+ /**
33
+ * **追问**单独用的 provider;留空 = 跟首轮/次轮同一个(默认如此)。
34
+ *
35
+ * 为什么要能单独设:首轮/次轮要的是"读懂上下文",追问往往是"快速问一句",
36
+ * 两者对模型的要求不同(一个要准、一个要快)。
37
+ */
38
+ chatProvider: string;
39
+ /** **追问**单独用的 model id;留空 = 跟首轮/次轮同一个。 */
40
+ chatModel: string;
41
+ /** **次轮详解**的推理档位(off / low / high / max);留空 = 适配器默认。 */
15
42
  reasoningEffort: string;
43
+ /** **首轮**(翻译 / 解读 / 注释)的推理档位。 */
16
44
  translationReasoningEffort: string;
17
45
  historyMaxEntries: number;
18
46
  /** 采样温度。 */
@@ -57,7 +85,7 @@ export interface Config {
57
85
  toolResultMaxChars: number;
58
86
  /** 文件类工具(read/grep/glob)的读取边界:'' = 会话工作目录,'*' = 不限制,其他 = 该目录 */
59
87
  toolReadRoot: string;
60
- /** 追问(对话小窗)用的推理档位:通常比首次解读轻,响应更快。 */
88
+ /** 追问(对话小窗)用的推理档位:通常比次轮详解轻,响应更快。 */
61
89
  chatReasoningEffort: string;
62
90
  /**
63
91
  * 侧边栏 HTML 预览里的划词桥(默认开)。
@@ -69,13 +97,24 @@ export interface Config {
69
97
  * 关掉 = 侧边栏网页里不弹「解读」按钮。
70
98
  */
71
99
  bridgeSidebarPreview: boolean;
100
+ /** 是否显示右下角悬浮胶囊(最近一次划词的状态入口)。 */
101
+ pillEnabled: boolean;
102
+ /**
103
+ * 静置多久把胶囊收成小球(毫秒)。
104
+ *
105
+ * 只在**没装费用胶囊**时生效 —— 有费用胶囊时两者并排,收球会让它们错位。
106
+ * 默认 10 秒:短于这个数会显得"刚看一眼就没了",长了又失去"收起来不挡视线"的意义。
107
+ */
108
+ pillIdleMs: number;
72
109
  }
73
110
  /** 配置 schema(缺省值即推荐值)。 */
74
111
  export declare const Config: z<Schemastery.ObjectS<{
75
112
  provider: z<string, string>;
76
113
  model: z<string, string>;
114
+ chatProvider: z<string, string>;
115
+ chatModel: z<string, string>;
77
116
  reasoningEffort: z<string, string>;
78
- /** 首轮「翻译」单独一档:这一轮只挑英文片段做翻译,不需要深度推理,默认压低换首字速度。 */
117
+ /** 首轮单独一档:默认关闭思考,优先快速给出翻译、解读或注释。 */
79
118
  translationReasoningEffort: z<string, string>;
80
119
  /**
81
120
  * 小窗对话历史(A′):把整段对话落到插件自己的目录,重划同一个词直接回放。
@@ -131,11 +170,17 @@ export declare const Config: z<Schemastery.ObjectS<{
131
170
  * 侧边栏网页里选中文字不会再弹「解读」。
132
171
  */
133
172
  bridgeSidebarPreview: z<boolean, boolean>;
173
+ /** 悬浮胶囊开关(小窗设置页可改)。 */
174
+ pillEnabled: z<boolean, boolean>;
175
+ /** 静置收球时长(毫秒)——默认 10 秒,见 Config 注释。 */
176
+ pillIdleMs: z<number, number>;
134
177
  }>, Schemastery.ObjectT<{
135
178
  provider: z<string, string>;
136
179
  model: z<string, string>;
180
+ chatProvider: z<string, string>;
181
+ chatModel: z<string, string>;
137
182
  reasoningEffort: z<string, string>;
138
- /** 首轮「翻译」单独一档:这一轮只挑英文片段做翻译,不需要深度推理,默认压低换首字速度。 */
183
+ /** 首轮单独一档:默认关闭思考,优先快速给出翻译、解读或注释。 */
139
184
  translationReasoningEffort: z<string, string>;
140
185
  /**
141
186
  * 小窗对话历史(A′):把整段对话落到插件自己的目录,重划同一个词直接回放。
@@ -191,6 +236,10 @@ export declare const Config: z<Schemastery.ObjectS<{
191
236
  * 侧边栏网页里选中文字不会再弹「解读」。
192
237
  */
193
238
  bridgeSidebarPreview: z<boolean, boolean>;
239
+ /** 悬浮胶囊开关(小窗设置页可改)。 */
240
+ pillEnabled: z<boolean, boolean>;
241
+ /** 静置收球时长(毫秒)——默认 10 秒,见 Config 注释。 */
242
+ pillIdleMs: z<number, number>;
194
243
  }>>;
195
244
  /**
196
245
  * 决定这一轮给模型哪些工具(纯函数,便于单测)。
@@ -0,0 +1,125 @@
1
+ /**
2
+ * 划词解读 · 设置面板的**单一事实来源**。
3
+ *
4
+ * 为什么单独成模块:
5
+ * · 小窗设置页要渲染 20+ 个字段,字段的标题 / 说明 / 单位 / 边界**必须**与 host 的
6
+ * 校验规则是同一份数据 —— 分两处写迟早会漂(UI 说 1~600,host 却按 1~200 截断)。
7
+ * 所以 GET /settings 直接把这份 spec 发给客户端,客户端只负责画,不做业务判断。
8
+ * · 纯数据 + 纯函数,不 import cordis / node,单测可以直接跑(见 scripts/test-settings.mjs)。
9
+ *
10
+ * 分组是**导航结构**,不是装饰:20+ 项平铺会淹没用户,按"用户想去哪类事"分 6 组。
11
+ */
12
+ /** 字段控件类型。 */
13
+ export type SettingKind = 'switch' | 'number' | 'text' | 'seg' | 'model';
14
+ /** 一个设置项的声明。 */
15
+ export interface SettingSpec {
16
+ /** 对应 Config 的字段名(也是 POST body 里的键)。 */
17
+ key: string;
18
+ kind: SettingKind;
19
+ /** 行标题。 */
20
+ label: string;
21
+ /** 一行说明:说清"改了会怎样",包括代价。 */
22
+ hint: string;
23
+ /** 关闭此开关时禁用当前字段,但保留其配置值。 */
24
+ enabledBy?: string;
25
+ /** number 的单位(显示在输入框右侧)。 */
26
+ unit?: string;
27
+ /** number 的边界(与 host 侧 clamp 用同一组值)。 */
28
+ min?: number;
29
+ max?: number;
30
+ /** number 的步进。 */
31
+ step?: number;
32
+ /** seg 的候选项。 */
33
+ options?: string[];
34
+ /** 留空时的语义说明(text 用,放进 placeholder)。 */
35
+ placeholder?: string;
36
+ /**
37
+ * **复合字段**:一个控件写多个 Config 字段(当前只有模型选择 —— 一个下拉同时定 provider 与 model)。
38
+ * 有它就说明 `key` 是个**虚拟键**、本身不写进配置,真正写的是这里列出的字段。
39
+ */
40
+ keys?: string[];
41
+ /**
42
+ * 显示单位与配置单位的**倍率**:显示值 = 配置值 / scale。
43
+ *
44
+ * 配置里时间一律是毫秒(schema 与 yml 的历史值都是毫秒,不动它),
45
+ * 但界面上"秒"更好读(用户提的要求)。所以 min/max 仍然按**配置单位**声明,
46
+ * 由客户端换算出显示用的边界 —— 边界的唯一事实来源始终是 Config schema。
47
+ */
48
+ scale?: number;
49
+ }
50
+ /** 一个分组。 */
51
+ export interface SettingGroup {
52
+ id: string;
53
+ /** 左栏分类名。 */
54
+ label: string;
55
+ items: SettingSpec[];
56
+ sections: Array<{
57
+ label: string;
58
+ keys: string[];
59
+ hint?: string;
60
+ note?: string;
61
+ }>;
62
+ }
63
+ /**
64
+ * 分组顺序 = 左栏从上到下的顺序。
65
+ * 「模型」放第一:27 项里唯一大多数人有理由改的一类。
66
+ */
67
+ export declare const SETTINGS_GROUPS: SettingGroup[];
68
+ /** spec 里的全部条目(含虚拟键)。 */
69
+ export declare const SETTINGS_ITEMS: SettingSpec[];
70
+ /** key → spec 的索引(渲染与校验按 key 找规则)。 */
71
+ export declare const SETTINGS_BY_KEY: Map<string, SettingSpec>;
72
+ /**
73
+ * 真正会写进配置的字段全集。
74
+ *
75
+ * 与「spec 的 key 集合」**不是一回事**:模型下拉的 key 是虚拟的 `modelChoice`,
76
+ * 它写的是 `provider` + `model` 两个真实字段(见 SettingSpec.keys)。
77
+ * 校验、应用覆盖、组装当前值,全都用这一份。
78
+ */
79
+ export declare const SETTINGS_KEYS: string[];
80
+ /** 只读的虚拟键(客户端可以渲染,但不能直接 POST 写它)。 */
81
+ export declare const SETTINGS_VIRTUAL_KEYS: string[];
82
+ /**
83
+ * **配置键** → 声明它的 spec(复合字段按 keys 展开)。
84
+ *
85
+ * 校验走这一份而不是 SETTINGS_BY_KEY:POST 上来的是配置键(`provider` / `model`),
86
+ * 而 spec 里那条的 key 是虚拟的 `modelChoice` —— 只有展开过才找得到规则。
87
+ */
88
+ export declare const SETTINGS_WRITABLE_BY_KEY: Map<string, SettingSpec>;
89
+ /** 校验结果:通过则 values 是**只含合法键**的补丁。 */
90
+ export interface NormalizeResult {
91
+ ok: boolean;
92
+ /** 被接受的键值对(已按 spec 归一)。 */
93
+ values: Record<string, unknown>;
94
+ /** 被拒绝的键及原因(给客户端提示用,不阻断其它键)。 */
95
+ rejected: Array<{
96
+ key: string;
97
+ reason: string;
98
+ }>;
99
+ }
100
+ /**
101
+ * 把一个客户端补丁归一成"可直接写进配置"的对象。
102
+ *
103
+ * 规则:
104
+ * · 不认识的 key 一律拒绝(不能靠 POST 往配置里塞任意字段);
105
+ * · number 做 clamp(超界不报错,夹到边界 —— 用户拖滑块时不该看到红色报错);
106
+ * · seg 只接受 options 里的值;
107
+ * · switch 只接受真正的布尔(字符串 'true' 之类的也别猜,直接拒);
108
+ * · text 去掉首尾空白,长到离谱的直接拒(防止把配置文件写爆)。
109
+ *
110
+ * 有意**不抛异常**:一个键写错不该让整次保存失败。
111
+ */
112
+ export declare function normalizeSettingsPatch(input: unknown): NormalizeResult;
113
+ /** 设置页需要的完整载荷。 */
114
+ export interface SettingsPayload {
115
+ groups: SettingGroup[];
116
+ /** 当前生效值。 */
117
+ values: Record<string, unknown>;
118
+ /** 出厂默认值(「恢复默认」和"是否改过"的判断都靠它)。 */
119
+ defaults: Record<string, unknown>;
120
+ }
121
+ /** 组装 GET /settings 的响应体。 */
122
+ export declare function buildSettingsPayload(values: Record<string, unknown>, defaults: Record<string, unknown>): SettingsPayload;
123
+ /** 旧设置文件的底层配置继续生效,但不再暴露为设置控件或接受 UI 写入。 */
124
+ export declare const PERSISTED_SETTING_KEYS: string[];
125
+ export declare function normalizeStoredSettings(input: unknown): Record<string, unknown>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yfwu2020/dsh-selection-explain",
3
- "version": "0.7.0",
3
+ "version": "0.8.3",
4
4
  "description": "DSH 划词解读:选中文字浮出按钮,点击弹窗流式显示专业中英翻译 + 该文字在当前上下文中的含义详解",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",
@@ -42,9 +42,10 @@
42
42
  "scripts": {
43
43
  "build": "bash scripts/build.sh",
44
44
  "typecheck": "tsc -p tsconfig.json --noEmit",
45
- "test": "node scripts/test-filters.mjs && node scripts/test-prompt.mjs && node scripts/test-bridge.mjs && node scripts/smoke-bridge.mjs && node scripts/smoke-panel.mjs && node scripts/test-client.mjs && node scripts/test-transcript.mjs && node scripts/test-guard.mjs && node scripts/test-speech.mjs && node scripts/smoke-voice.mjs",
45
+ "test": "node scripts/test-filters.mjs && node scripts/test-prompt.mjs && node scripts/test-model-follow.mjs && node scripts/test-bridge.mjs && node scripts/smoke-bridge.mjs && node scripts/smoke-panel.mjs && node scripts/test-client.mjs && node scripts/test-settings-host.mjs && node scripts/test-settings-behavior.mjs && node scripts/test-route-cache.mjs && node scripts/test-transcript.mjs && node scripts/test-guard.mjs && node scripts/test-settings.mjs && node scripts/test-format.mjs && node scripts/test-speech.mjs && node scripts/smoke-voice.mjs",
46
46
  "test:transcript": "node scripts/test-transcript.mjs",
47
47
  "test:guard": "node scripts/test-guard.mjs",
48
+ "test:format": "node scripts/test-format.mjs",
48
49
  "test:bridge": "node scripts/test-bridge.mjs",
49
50
  "test:smoke": "node scripts/smoke-bridge.mjs",
50
51
  "test:panel": "node scripts/smoke-panel.mjs",
@@ -119,7 +119,18 @@ window.fetch = function (input, init) {
119
119
  return Promise.resolve(new Response(JSON.stringify({ ok: false, error: 'position 需要 { right, y }' }), { status: 400, headers: { 'content-type': 'application/json' } }))
120
120
  }
121
121
  if (url.indexOf('/selection-explain/api/models') >= 0) {
122
- return Promise.resolve(new Response(JSON.stringify({ ok: true, current: null, stages: null, models: [] }), { headers: { 'content-type': 'application/json' } }))
122
+ // 三个模型:一个长名字、一个同名 id 挂在另一个 provider 下 —— 给"菜单里给当前在用的
123
+ // 那个打勾"那条用例用。current = 会话在用的那个(跟随态菜单要照它打勾)。
124
+ return Promise.resolve(new Response(JSON.stringify({
125
+ ok: true,
126
+ current: { provider: 'p1', model: 'm-fast' },
127
+ stages: { chat: 'high' },
128
+ models: [
129
+ { provider: 'p1', providerName: 'Provider One', model: 'm-fast', name: 'DeepSeek V4.1 Flash', efforts: [{ id: 'off', name: 'Off' }, { id: 'low', name: 'Low' }, { id: 'high', name: 'High' }, { id: 'max', name: 'Max' }], defaultEffort: 'high' },
130
+ { provider: 'p1', providerName: 'Provider One', model: 'm-strong', name: 'Strong Model With A Very Long Display Name', efforts: [{ id: 'off', name: 'Off' }, { id: 'high', name: 'High' }], defaultEffort: 'high' },
131
+ { provider: 'p2', providerName: 'DeepSeek Account', model: 'm-fast', name: 'Another Provider Same Id', efforts: [], defaultEffort: null },
132
+ ],
133
+ }), { headers: { 'content-type': 'application/json' } }))
123
134
  }
124
135
  if (url.indexOf('/selection-explain/api/analyze') >= 0) {
125
136
  if (body.question) return Promise.resolve(sse([{ type: 'start', provider: 'fixture', model: 'fixture', mode: 'chat' }, { type: 'delta', text: fixture.chat }, { type: 'done', chars: 8 }]))
@@ -1073,6 +1084,40 @@ async function main() {
1073
1084
  await sleep(400)
1074
1085
  }
1075
1086
 
1087
+ // ⑮ 模型菜单:跟随态也要给"当前在用"那个打勾(真浏览器里量几何 —— 桩里量不出换行/溢出)
1088
+ {
1089
+ hook.open('模型菜单勾选', '', '菜单冒烟')
1090
+ await waitFor(function () { return hook.state().phase === 'done' }, 8000)
1091
+ await hook.loadModels(true)
1092
+ hook.openModel()
1093
+ await sleep(120)
1094
+ var menu = hook.modelNodes().menu
1095
+ var rows = Array.prototype.slice.call(menu.querySelectorAll('.dsh-sel-pickerrow'))
1096
+ check('模型菜单列出 3 个模型(含同名 id 的另一 provider)', rows.length === 3, String(rows.length))
1097
+ var onRows = rows.filter(function (r) { return r.getAttribute('data-on') === '1' })
1098
+ check('跟随态:只有当前在用的那一行打勾',
1099
+ onRows.length === 1 && onRows[0].textContent.indexOf('DeepSeek V4.1 Flash') >= 0,
1100
+ onRows.map(function (r) { return r.textContent }).join(' | ') || '没有任何一行打勾')
1101
+ var badge = onRows[0] && onRows[0].querySelector('.dsh-sel-pickerfollow')
1102
+ check('跟随态那一行有「跟随」小标,且小标在菜单里可见(没被挤出边界)',
1103
+ !!badge && badge.offsetWidth > 0 && badge.getBoundingClientRect().right <= menu.getBoundingClientRect().right + 1,
1104
+ badge ? Math.round(badge.getBoundingClientRect().left) + '→' + Math.round(badge.getBoundingClientRect().right) + '(菜单右边界 ' + Math.round(menu.getBoundingClientRect().right) + ')' : '没有小标')
1105
+ var nameSpan = onRows[0] && onRows[0].querySelector('.dsh-sel-pickername')
1106
+ check('名字带省略号、不撑破一行(真实布局下 scrollWidth 不超)',
1107
+ !!nameSpan && nameSpan.scrollWidth >= nameSpan.clientWidth && onRows[0].scrollWidth <= onRows[0].clientWidth + 1,
1108
+ nameSpan ? nameSpan.clientWidth + ' 可见 / ' + nameSpan.scrollWidth + ' 全长;行 ' + onRows[0].clientWidth + '/' + onRows[0].scrollWidth : '没有名字节点')
1109
+ // 长名字那行(没打勾、没小标)也不该被撑破
1110
+ var longRow = rows.filter(function (r) { return r.textContent.indexOf('Strong Model With A Very Long') >= 0 })[0]
1111
+ check('超长名字那一行同样不横向溢出(靠省略号收窄)',
1112
+ !!longRow && longRow.scrollWidth <= longRow.clientWidth + 1,
1113
+ longRow ? longRow.clientWidth + '/' + longRow.scrollWidth : '没找到那一行')
1114
+ var rowBox = onRows[0] ? onRows[0].getBoundingClientRect() : null
1115
+ check('打勾那一行仍是单行(高度不超过 30px,没折成两行)',
1116
+ !!rowBox && rowBox.height <= 30, rowBox ? Math.round(rowBox.height) + 'px' : '没找到那一行')
1117
+ hook.close() // 关窗会顺手收起模型菜单
1118
+ await sleep(30)
1119
+ }
1120
+
1076
1121
  finish()
1077
1122
  }
1078
1123
 
@@ -644,7 +644,9 @@ assert(
644
644
  )
645
645
  assert(
646
646
  '输入框里两句都在:第一句定稿 + 第二句半句预览(用户报的 bug 就是这里断的)',
647
- /你好|语音|测试/.test(afterPause.value) && /帮我|解释|划词|画词/.test(afterPause.value),
647
+ realSpeech
648
+ ? /你好|语音|测试/.test(afterPause.value) && /帮我|解释|划词|画词/.test(afterPause.value)
649
+ : afterPause.value === '夹具转写的一段话 夹具转写的一段话',
648
650
  JSON.stringify(afterPause.value),
649
651
  )
650
652
 
@@ -274,6 +274,10 @@ function makeFrameEnv(htmlText, selected, rect, containerTag, muteDocListeners)
274
274
 
275
275
  const FIXTURE = 'PM: 这周能发布吗? Dev: the migration ran long, so we ship Wednesday.'
276
276
  const RECT = { left: 12, top: 34, right: 190, bottom: 52, width: 178, height: 18 }
277
+ const longSelection = '长'.repeat(4500)
278
+ const longFrame = makeFrameEnv(longSelection, longSelection, RECT)
279
+ assert('帧内不以旧的 4000 字默认限制提前拦截(实际限额由父页面设置判断)',
280
+ longFrame.sent.some(m => m.kind === 'selection' && m.sel.text.length === 4500))
277
281
  const env = makeFrameEnv(FIXTURE, 'the migration ran long', RECT)
278
282
  const first = env.sent[env.sent.length - 1]
279
283
  assert('帧内脚本:注入后立刻上报一次', !!first && first.__dshSel === 1 && first.kind === 'selection')