dsh-vscode-mode 0.22.0 → 0.23.0

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,1009 @@
1
+ /**
2
+ * dsh-vscode-mode 快捷菜单共享契约与纯逻辑。
3
+ *
4
+ * host 与 client 共用:只放类型与纯函数,禁 node/react 导入(与 shared/rules.ts 同约束)。
5
+ * 覆盖四块:文档归一(含损坏/越界兜底)、树层级与工作区可见性、绑定校验、消息合成、几何布局。
6
+ * 作者 ddj 2026年10月30号
7
+ */
8
+ import type { RuleInfo, RuleProject, RuleScope } from './rules.js'
9
+
10
+ //#region 类型
11
+
12
+ /** 快捷菜单项类型:组(可含子项)或按钮(可发送)。 */
13
+ export type QuickItemType = 'group' | 'button'
14
+
15
+ /** 规则注入方式:引用(只给名字与路径)或内联(把正文嵌进消息)。 */
16
+ export type RuleInjectMode = 'reference' | 'inline'
17
+
18
+ /** 点击按钮后的动作:直接发送或只填入输入框。 */
19
+ export type ClickAction = 'send' | 'fill'
20
+
21
+ /** 一条绑定到按钮的规则引用(与 rules.* RPC 的作用域/文件契约对齐)。 */
22
+ export interface RuleBinding {
23
+ /** 用户规则或项目规则。 */
24
+ scope: RuleScope
25
+ /** 项目规则所属工作区绝对路径(用户规则不设置)。 */
26
+ workspacePath?: string
27
+ /** 用户规则来源(当前 DSH_HOME 或旧版 ~/.dsh),用于同名文件消歧。 */
28
+ origin?: 'current' | 'legacy'
29
+ /** 规则文件名(含 .mdc 后缀)。 */
30
+ file: string
31
+ }
32
+
33
+ /** 组与按钮共有的字段。 */
34
+ export interface QuickItemBase {
35
+ /** 稳定 id(host 首次保存时生成;跨改名/移动不变)。 */
36
+ id: string
37
+ /** 项类型。 */
38
+ type: QuickItemType
39
+ /** 显示名(≤40 字)。 */
40
+ name: string
41
+ /** 同级显示优先级(升序,0..999)。 */
42
+ order: number
43
+ /** 归属组 id;null 表示顶层(不可感知的 root 组)。 */
44
+ parentId: string | null
45
+ /** 限定工作区绝对路径列表;null 表示全部工作区显示。 */
46
+ workspaces: string[] | null
47
+ }
48
+
49
+ /** 组:只做容器,不含发送内容。 */
50
+ export interface QuickGroup extends QuickItemBase {
51
+ type: 'group'
52
+ }
53
+
54
+ /** 按钮:带预填提示词与绑定。 */
55
+ export interface QuickButton extends QuickItemBase {
56
+ type: 'button'
57
+ /** 预填提示词(≤8KB,支持 {{workspace}} {{selection}} {{date}} {{rules}} {{skills}})。 */
58
+ prompt: string
59
+ /** 绑定技能名(kebab-case,作为 /name 文本 token 发进消息)。 */
60
+ skills: string[]
61
+ /** 绑定规则列表。 */
62
+ rules: RuleBinding[]
63
+ /** 规则注入方式。 */
64
+ ruleMode: RuleInjectMode
65
+ /** 点击动作覆盖(缺省继承文档级 clickAction)。 */
66
+ clickAction?: ClickAction
67
+ }
68
+
69
+ /** 菜单项(判别联合)。 */
70
+ export type QuickMenuItem = QuickGroup | QuickButton
71
+
72
+ /** 持久化文档(host 侧 quick-menu.json 的载荷)。 */
73
+ export interface QuickMenuDoc {
74
+ /** 结构版本,用于迁移。 */
75
+ version: number
76
+ /** 乐观并发版本号(每次成功写入 +1)。 */
77
+ revision: number
78
+ /** 最后写入时间(ISO 字符串)。 */
79
+ updatedAt: string
80
+ /** 悬浮按钮总开关。 */
81
+ enabled: boolean
82
+ /** 文档级点击动作。 */
83
+ clickAction: ClickAction
84
+ /** 最大嵌套层数(1..5,默认 3;root 列的直接子项为第 1 层)。 */
85
+ maxDepth: number
86
+ /** 悬浮按钮位置(null 表示沿用输入框左上角锚点)。 */
87
+ launcher: { x: number | null; y: number | null }
88
+ /** 全部菜单项(扁平存储,父子关系由 parentId 表达)。 */
89
+ items: QuickMenuItem[]
90
+ }
91
+
92
+ /** 矩形(浮层内坐标,left/top 为相对定位点)。 */
93
+ export interface QuickRect {
94
+ left: number
95
+ top: number
96
+ width: number
97
+ height: number
98
+ }
99
+
100
+ /** 尺寸。 */
101
+ export interface QuickSize {
102
+ width: number
103
+ height: number
104
+ }
105
+
106
+ /** 绑定状态:ok 就绪 / warn 可发送但会跳过或需注意 / error 失效 / unknown 未校验。 */
107
+ export interface BindingStatus {
108
+ state: 'ok' | 'warn' | 'error' | 'unknown'
109
+ /** 面向用户的原因文案(ok 时省略)。 */
110
+ reason?: string
111
+ }
112
+
113
+ /** 规则列表快照(rules.list 的响应子集)。 */
114
+ export interface RuleListSnapshot {
115
+ user: RuleInfo[]
116
+ projects: RuleProject[]
117
+ }
118
+
119
+ /** 校验上下文:缺字段表示「尚未加载」,一律按未校验处理而非报错。 */
120
+ export interface ValidateContext {
121
+ /** 当前会话工作区路径(项目规则匹配与可见性判断用)。 */
122
+ cwd?: string
123
+ rules?: RuleListSnapshot
124
+ /** 当前会话可见技能名;null 表示无法枚举(无活跃会话)。 */
125
+ skillNames?: string[] | null
126
+ }
127
+
128
+ /** 消息合成上下文(在校验上下文之上补充占位符与内联正文)。 */
129
+ export interface ComposeContext extends ValidateContext {
130
+ /** 规则内联正文,键为 bindingKey()。 */
131
+ inlineContents?: Record<string, string>
132
+ /** 用户框选文本({{selection}})。 */
133
+ selection?: string
134
+ /** 日期文本({{date}}),缺省由调用方传入。 */
135
+ date?: string
136
+ }
137
+
138
+ /** 单项状态聚合(失效原因汇总给菜单行/弹窗展示)。 */
139
+ export interface ItemStatus {
140
+ errors: string[]
141
+ warnings: string[]
142
+ }
143
+
144
+ //#endregion
145
+
146
+ //#region 常量
147
+
148
+ /** 文档结构版本。 */
149
+ export const QUICK_MENU_VERSION = 1
150
+ /** 菜单项数量上限。 */
151
+ export const QUICK_MENU_MAX_ITEMS = 200
152
+ /** 嵌套层数上限。 */
153
+ export const QUICK_MENU_MAX_DEPTH = 5
154
+ /** 嵌套层数下限。 */
155
+ export const QUICK_MENU_MIN_DEPTH = 1
156
+ /** 默认嵌套层数。 */
157
+ export const QUICK_MENU_DEFAULT_DEPTH = 3
158
+ /** 名称长度上限。 */
159
+ export const QUICK_MENU_NAME_CAP = 40
160
+ /** 提示词长度上限。 */
161
+ export const QUICK_MENU_PROMPT_CAP = 8192
162
+ /** 单条规则内联正文上限。 */
163
+ export const QUICK_MENU_RULE_CAP = 8192
164
+ /** 文档大小上限(字节)。 */
165
+ export const QUICK_MENU_BYTES_CAP = 256 * 1024
166
+ /** 优先级上限。 */
167
+ export const QUICK_MENU_ORDER_CAP = 999
168
+ /** 单项可绑定技能数上限。 */
169
+ export const QUICK_MENU_SKILL_CAP = 24
170
+ /** 单项可绑定规则数上限。 */
171
+ export const QUICK_MENU_RULE_LIMIT = 24
172
+ /** 技能名合法形态。 */
173
+ export const QUICK_MENU_SKILL_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
174
+ /** 未命名项兜底名。 */
175
+ const FALLBACK_NAME = '未命名'
176
+
177
+ //#endregion
178
+
179
+ //#region 基础工具
180
+
181
+ /**
182
+ * 数值夹取(NaN/非有限回落到 fallback)。
183
+ * @author ddj 2026年10月30号
184
+ * @param value 原始值
185
+ * @param min 下限
186
+ * @param max 上限
187
+ * @param fallback 非法值兜底
188
+ * @returns 夹取后的数值
189
+ */
190
+ function clampNumber(value: unknown, min: number, max: number, fallback: number): number {
191
+ const number = typeof value === 'number' && Number.isFinite(value) ? value : fallback
192
+ if (number < min) return min
193
+ if (number > max) return max
194
+ return number
195
+ }
196
+
197
+ /**
198
+ * 取对象形态(非对象/数组返回 null)。
199
+ * @author ddj 2026年10月30号
200
+ * @param value 原始值
201
+ * @returns 普通对象或 null
202
+ */
203
+ function asRecord(value: unknown): Record<string, unknown> | null {
204
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return null
205
+ return value as Record<string, unknown>
206
+ }
207
+
208
+ /**
209
+ * 取字符串(非字符串回落空串)。
210
+ * @author ddj 2026年10月30号
211
+ * @param value 原始值
212
+ * @returns 字符串
213
+ */
214
+ function asString(value: unknown): string {
215
+ return typeof value === 'string' ? value : ''
216
+ }
217
+
218
+ /**
219
+ * 文本截断(超长补省略号)。
220
+ * @author ddj 2026年10月30号
221
+ * @param text 原文
222
+ * @param cap 上限字符数
223
+ * @returns 截断后文本
224
+ */
225
+ function cutText(text: string, cap: number): string {
226
+ if (text.length <= cap) return text
227
+ return text.slice(0, Math.max(0, cap - 1)) + '…'
228
+ }
229
+
230
+ //#endregion
231
+
232
+ //#region 路径与工作区
233
+
234
+ /**
235
+ * 归一化工作区路径(统一分隔符、去尾分隔符、win32 转小写),用于比较而非展示。
236
+ * @author ddj 2026年10月30号
237
+ * @param path 原始路径
238
+ * @returns 归一化路径
239
+ */
240
+ export function normalizePath(path: string): string {
241
+ const unified = path.trim().replace(/\//g, '\\').replace(/\\+$/, '')
242
+ if (unified === '') return ''
243
+ // Windows 工作区路径大小写不敏感:统一小写,避免同一目录两种写法不匹配
244
+ return unified.toLowerCase()
245
+ }
246
+
247
+ /**
248
+ * 判断一条限定工作区列表是否覆盖当前工作区(含子目录,按路径边界匹配)。
249
+ * @author ddj 2026年10月30号
250
+ * @param paths 限定列表;null 表示全部工作区
251
+ * @param cwd 当前工作区路径;空串表示未知(视为可见)
252
+ * @returns 是否可见
253
+ */
254
+ export function matchWorkspace(paths: string[] | null, cwd?: string): boolean {
255
+ if (paths === null || paths === undefined) return true
256
+ if (paths.length === 0) return true
257
+ const normalizedCwd = normalizePath(cwd ?? '')
258
+ if (normalizedCwd === '') return true
259
+ return paths.some((path) => {
260
+ const normalized = normalizePath(path)
261
+ if (normalized === '') return false
262
+ // 目录边界匹配:C:\a 不应命中 C:\ab
263
+ return normalizedCwd === normalized || normalizedCwd.startsWith(normalized + '\\')
264
+ })
265
+ }
266
+
267
+ //#endregion
268
+
269
+ //#region 文档构造与归一
270
+
271
+ /**
272
+ * 默认文档(首次打开菜单时的初始状态)。
273
+ * @author ddj 2026年10月30号
274
+ * @returns 默认文档
275
+ */
276
+ export function defaultDoc(): QuickMenuDoc {
277
+ return {
278
+ version: QUICK_MENU_VERSION,
279
+ revision: 0,
280
+ updatedAt: '',
281
+ enabled: true,
282
+ clickAction: 'send',
283
+ maxDepth: QUICK_MENU_DEFAULT_DEPTH,
284
+ launcher: { x: null, y: null },
285
+ items: [],
286
+ }
287
+ }
288
+
289
+ /**
290
+ * 生成新菜单项 id(类型前缀 + 时间 + 随机,跨会话不重复)。
291
+ * @author ddj 2026年10月30号
292
+ * @param type 项类型
293
+ * @returns 新 id
294
+ */
295
+ export function newQuickId(type: QuickItemType): string {
296
+ const prefix = type === 'group' ? 'g' : 'b'
297
+ const random = Math.random().toString(36).slice(2, 8)
298
+ return prefix + '_' + Date.now().toString(36) + random
299
+ }
300
+
301
+ /**
302
+ * 归一化限定工作区列表(去空、去重、归一化;空结果回落到 null=全部)。
303
+ * @author ddj 2026年10月30号
304
+ * @param raw 原始值
305
+ * @returns 限定列表或 null
306
+ */
307
+ function normalizeWorkspaces(raw: unknown): string[] | null {
308
+ if (!Array.isArray(raw)) return null
309
+ const out: string[] = []
310
+ for (const entry of raw) {
311
+ if (typeof entry !== 'string') continue
312
+ const normalized = normalizePath(entry)
313
+ if (normalized === '') continue
314
+ if (out.includes(normalized)) continue
315
+ out.push(normalized)
316
+ }
317
+ // 传空数组时视为「未限定」,避免误配导致整组消失
318
+ return out.length > 0 ? out : null
319
+ }
320
+
321
+ /**
322
+ * 绑定规则的稳定键(校验、去重、内联正文映射共用)。
323
+ * @author ddj 2026年10月30号
324
+ * @param binding 规则绑定
325
+ * @returns 键
326
+ */
327
+ export function bindingKey(binding: RuleBinding): string {
328
+ const workspace = normalizePath(binding.workspacePath ?? '')
329
+ return binding.scope + '|' + workspace + '|' + binding.file
330
+ }
331
+
332
+ /**
333
+ * 归一化单条规则绑定(scope/file 必填,项目规则必须带工作区)。
334
+ * @author ddj 2026年10月30号
335
+ * @param raw 原始值
336
+ * @returns 绑定或 null
337
+ */
338
+ function normalizeRule(raw: unknown): RuleBinding | null {
339
+ const record = asRecord(raw)
340
+ if (!record) return null
341
+ const file = asString(record.file).trim()
342
+ if (file === '') return null
343
+ const scope: RuleScope = record.scope === 'project' ? 'project' : 'user'
344
+ const binding: RuleBinding = { scope, file }
345
+ if (scope === 'project') {
346
+ const workspacePath = asString(record.workspacePath).trim()
347
+ if (workspacePath === '') return null
348
+ binding.workspacePath = workspacePath
349
+ }
350
+ if (record.origin === 'current' || record.origin === 'legacy') binding.origin = record.origin
351
+ return binding
352
+ }
353
+
354
+ /**
355
+ * 归一化规则绑定列表(去重 + 上限)。
356
+ * @author ddj 2026年10月30号
357
+ * @param raw 原始值
358
+ * @returns 绑定列表
359
+ */
360
+ function normalizeRules(raw: unknown): RuleBinding[] {
361
+ if (!Array.isArray(raw)) return []
362
+ const out: RuleBinding[] = []
363
+ for (const entry of raw) {
364
+ const binding = normalizeRule(entry)
365
+ if (!binding) continue
366
+ if (out.some((existing) => bindingKey(existing) === bindingKey(binding))) continue
367
+ out.push(binding)
368
+ }
369
+ return out.slice(0, QUICK_MENU_RULE_LIMIT)
370
+ }
371
+
372
+ /**
373
+ * 归一化技能名列表(只保留 kebab-case,去重 + 上限)。
374
+ * @author ddj 2026年10月30号
375
+ * @param raw 原始值
376
+ * @returns 技能名列表
377
+ */
378
+ function normalizeSkills(raw: unknown): string[] {
379
+ if (!Array.isArray(raw)) return []
380
+ const out: string[] = []
381
+ for (const entry of raw) {
382
+ if (typeof entry !== 'string') continue
383
+ const name = entry.trim()
384
+ if (name === '' || !QUICK_MENU_SKILL_RE.test(name)) continue
385
+ if (out.includes(name)) continue
386
+ out.push(name)
387
+ }
388
+ return out.slice(0, QUICK_MENU_SKILL_CAP)
389
+ }
390
+
391
+ /**
392
+ * 归一化单个菜单项(非法类型/缺 id 返回 null)。
393
+ * @author ddj 2026年10月30号
394
+ * @param raw 原始值
395
+ * @returns 菜单项或 null
396
+ */
397
+ function normalizeItem(raw: unknown): QuickMenuItem | null {
398
+ const record = asRecord(raw)
399
+ if (!record) return null
400
+ const type: QuickItemType = record.type === 'group' ? 'group' : 'button'
401
+ const id = asString(record.id).trim()
402
+ if (id === '') return null
403
+ const name = cutText(asString(record.name).trim() || FALLBACK_NAME, QUICK_MENU_NAME_CAP)
404
+ const order = clampNumber(record.order, 0, QUICK_MENU_ORDER_CAP, 100)
405
+ const rawParent = record.parentId
406
+ const parentId = typeof rawParent === 'string' && rawParent.trim() !== '' ? rawParent.trim() : null
407
+ const base: QuickItemBase = { id, type, name, order, parentId, workspaces: normalizeWorkspaces(record.workspaces) }
408
+ if (type === 'group') return { ...base, type: 'group' }
409
+ const button: QuickButton = {
410
+ ...base,
411
+ type: 'button',
412
+ prompt: cutText(asString(record.prompt), QUICK_MENU_PROMPT_CAP),
413
+ skills: normalizeSkills(record.skills),
414
+ rules: normalizeRules(record.rules),
415
+ ruleMode: record.ruleMode === 'inline' ? 'inline' : 'reference',
416
+ }
417
+ if (record.clickAction === 'send' || record.clickAction === 'fill') button.clickAction = record.clickAction
418
+ return button
419
+ }
420
+
421
+ /**
422
+ * 判断从起点上溯的父链是否会回到目标项(环检测;超深链也按非法处理)。
423
+ * @author ddj 2026年10月30号
424
+ * @param byId id → 项
425
+ * @param startId 起点父 id
426
+ * @param targetId 目标项 id
427
+ * @returns 是否成环
428
+ */
429
+ function chainHas(byId: Map<string, QuickMenuItem>, startId: string | null, targetId: string): boolean {
430
+ let current = startId
431
+ let guard = 0
432
+ while (current !== null) {
433
+ if (current === targetId) return true
434
+ if (guard > QUICK_MENU_MAX_DEPTH + 2) return true
435
+ const parent = byId.get(current)
436
+ if (!parent) return false
437
+ current = parent.parentId
438
+ guard += 1
439
+ }
440
+ return false
441
+ }
442
+
443
+ /**
444
+ * 修补父链问题:父不存在/父是按钮/自指/形成环时把子项上抬到最近合法祖组或顶层。
445
+ * @author ddj 2026年10月30号
446
+ * @param items 归一化后的项
447
+ * @returns 修补后的项
448
+ */
449
+ function repairParents(items: QuickMenuItem[]): QuickMenuItem[] {
450
+ const byId = new Map<string, QuickMenuItem>()
451
+ for (const item of items) byId.set(item.id, item)
452
+ for (const item of items) {
453
+ // 先跳过按钮祖先,找到最近的合法组
454
+ let resolved = item.parentId
455
+ let guard = 0
456
+ while (resolved !== null && guard <= QUICK_MENU_MAX_DEPTH + 2) {
457
+ const parent = byId.get(resolved)
458
+ if (!parent) { resolved = null; break }
459
+ if (parent.type === 'group') break
460
+ resolved = parent.parentId
461
+ guard += 1
462
+ }
463
+ if (resolved !== null && chainHas(byId, resolved, item.id)) resolved = null
464
+ item.parentId = resolved
465
+ }
466
+ return items
467
+ }
468
+
469
+ /**
470
+ * 按父级分组重排优先级(order 归一为 10/20/30…,保证展示稳定且无并列)。
471
+ * @author ddj 2026年10月30号
472
+ * @param items 菜单项
473
+ * @returns 同一数组(就地写入 order)
474
+ */
475
+ function normalizeOrder(items: QuickMenuItem[]): QuickMenuItem[] {
476
+ const groups = new Map<string, QuickMenuItem[]>()
477
+ for (const item of items) {
478
+ const key = item.parentId ?? ''
479
+ const bucket = groups.get(key)
480
+ if (bucket) bucket.push(item)
481
+ else groups.set(key, [item])
482
+ }
483
+ for (const bucket of groups.values()) {
484
+ bucket.sort((left, right) => {
485
+ if (left.order !== right.order) return left.order - right.order
486
+ if (left.name !== right.name) return left.name < right.name ? -1 : 1
487
+ return left.id < right.id ? -1 : 1
488
+ })
489
+ bucket.forEach((item, index) => { item.order = (index + 1) * 10 })
490
+ }
491
+ return items
492
+ }
493
+
494
+ /**
495
+ * 归一化整份文档(任何脏数据都收敛到可用形态,绝不抛错)。
496
+ * @author ddj 2026年10月30号
497
+ * @param raw 原始值(JSON.parse 结果或任意 unknown)
498
+ * @returns 合法文档
499
+ */
500
+ export function normalizeDoc(raw: unknown): QuickMenuDoc {
501
+ const fallback = defaultDoc()
502
+ const record = asRecord(raw)
503
+ if (!record) return fallback
504
+ const rawItems = Array.isArray(record.items) ? record.items : []
505
+ const seen = new Set<string>()
506
+ const items: QuickMenuItem[] = []
507
+ for (const entry of rawItems) {
508
+ const item = normalizeItem(entry)
509
+ if (!item || seen.has(item.id)) continue
510
+ seen.add(item.id)
511
+ items.push(item)
512
+ if (items.length >= QUICK_MENU_MAX_ITEMS) break
513
+ }
514
+ const x = typeof record.launcher === 'object' && record.launcher !== null ? asRecord(record.launcher) : null
515
+ const doc: QuickMenuDoc = {
516
+ version: QUICK_MENU_VERSION,
517
+ revision: Math.floor(clampNumber(record.revision, 0, Number.MAX_SAFE_INTEGER, 0)),
518
+ updatedAt: cutText(asString(record.updatedAt), 40),
519
+ enabled: typeof record.enabled === 'boolean' ? record.enabled : true,
520
+ clickAction: record.clickAction === 'fill' ? 'fill' : 'send',
521
+ maxDepth: Math.round(clampNumber(record.maxDepth, QUICK_MENU_MIN_DEPTH, QUICK_MENU_MAX_DEPTH, QUICK_MENU_DEFAULT_DEPTH)),
522
+ launcher: {
523
+ x: x && typeof x.x === 'number' && Number.isFinite(x.x) ? x.x : null,
524
+ y: x && typeof x.y === 'number' && Number.isFinite(x.y) ? x.y : null,
525
+ },
526
+ items: normalizeOrder(repairParents(items)),
527
+ }
528
+ return doc
529
+ }
530
+
531
+ /**
532
+ * 结构迁移入口(当前只有 v1;后续版本在此收敛旧结构)。
533
+ * @author ddj 2026年10月30号
534
+ * @param raw 原始值
535
+ * @returns 合法文档
536
+ */
537
+ export function migrateDoc(raw: unknown): QuickMenuDoc {
538
+ return normalizeDoc(raw)
539
+ }
540
+
541
+ /**
542
+ * 序列化文档(host 落盘与导入导出共用)。
543
+ * @author ddj 2026年10月30号
544
+ * @param doc 文档
545
+ * @returns JSON 文本
546
+ */
547
+ export function serializeDoc(doc: QuickMenuDoc): string {
548
+ return JSON.stringify(normalizeDoc(doc), null, 2)
549
+ }
550
+
551
+ /**
552
+ * 解析文档文本(解析失败返回默认文档并给出告警文案)。
553
+ * @author ddj 2026年10月30号
554
+ * @param text 文件内容
555
+ * @returns 文档 + 可选告警
556
+ */
557
+ export function parseDoc(text: string): { doc: QuickMenuDoc; warning?: string } {
558
+ try {
559
+ return { doc: migrateDoc(JSON.parse(text)) }
560
+ } catch (error) {
561
+ const reason = error instanceof Error ? error.message : String(error)
562
+ return { doc: defaultDoc(), warning: '快捷菜单配置解析失败(' + reason + '),已回落默认配置' }
563
+ }
564
+ }
565
+
566
+ //#endregion
567
+
568
+ //#region 层级与可见性
569
+
570
+ /**
571
+ * 计算项所在层级(root 的直接子项为第 1 层;孤儿按第 1 层)。
572
+ * @author ddj 2026年10月30号
573
+ * @param doc 文档
574
+ * @param id 项 id
575
+ * @returns 层级(找不到返回 0)
576
+ */
577
+ export function depthOf(doc: QuickMenuDoc, id: string): number {
578
+ const byId = new Map<string, QuickMenuItem>()
579
+ for (const item of doc.items) byId.set(item.id, item)
580
+ let current = byId.get(id)
581
+ if (!current) return 0
582
+ let depth = 1
583
+ let guard = 0
584
+ while (current.parentId !== null && guard < QUICK_MENU_MAX_DEPTH + 1) {
585
+ const parent = byId.get(current.parentId)
586
+ if (!parent) break
587
+ depth += 1
588
+ current = parent
589
+ guard += 1
590
+ }
591
+ return depth
592
+ }
593
+
594
+ /**
595
+ * 判断能否在指定父级下新增子项(达到最大嵌套层数时禁止)。
596
+ * @author ddj 2026年10月30号
597
+ * @param doc 文档
598
+ * @param parentId 父组 id(null 为顶层)
599
+ * @returns 是否可新增
600
+ */
601
+ export function canCreateAt(doc: QuickMenuDoc, parentId: string | null): boolean {
602
+ if (parentId === null) return doc.maxDepth >= QUICK_MENU_MIN_DEPTH
603
+ const depth = depthOf(doc, parentId)
604
+ if (depth <= 0) return false
605
+ return depth + 1 <= doc.maxDepth
606
+ }
607
+
608
+ /**
609
+ * 取某组下可见的子项(按优先级排序;cwd 为空时不做工作区过滤)。
610
+ * @author ddj 2026年10月30号
611
+ * @param doc 文档
612
+ * @param parentId 父组 id(null 为顶层)
613
+ * @param cwd 当前工作区路径
614
+ * @returns 可见子项
615
+ */
616
+ export function childrenOf(doc: QuickMenuDoc, parentId: string | null, cwd?: string): QuickMenuItem[] {
617
+ return doc.items
618
+ .filter((item) => item.parentId === parentId)
619
+ .filter((item) => itemVisibleIn(doc, item, cwd))
620
+ .sort((left, right) => {
621
+ if (left.order !== right.order) return left.order - right.order
622
+ return left.name < right.name ? -1 : 1
623
+ })
624
+ }
625
+
626
+ /**
627
+ * 计算项的有效工作区限定(沿祖先链最近的显式限定生效)。
628
+ * @author ddj 2026年10月30号
629
+ * @param doc 文档
630
+ * @param item 菜单项
631
+ * @returns 限定列表或 null
632
+ */
633
+ export function inheritWorkspaces(doc: QuickMenuDoc, item: QuickMenuItem): string[] | null {
634
+ const byId = new Map<string, QuickMenuItem>()
635
+ for (const entry of doc.items) byId.set(entry.id, entry)
636
+ let current: QuickMenuItem | undefined = item
637
+ let guard = 0
638
+ while (current && guard < QUICK_MENU_MAX_DEPTH + 2) {
639
+ if (current.workspaces !== null) return current.workspaces
640
+ current = current.parentId === null ? undefined : byId.get(current.parentId)
641
+ guard += 1
642
+ }
643
+ return null
644
+ }
645
+
646
+ /**
647
+ * 判断项在当前工作区是否可见(继承父组限定)。
648
+ * @author ddj 2026年10月30号
649
+ * @param doc 文档
650
+ * @param item 菜单项
651
+ * @param cwd 当前工作区路径
652
+ * @returns 是否可见
653
+ */
654
+ export function itemVisibleIn(doc: QuickMenuDoc, item: QuickMenuItem, cwd?: string): boolean {
655
+ return matchWorkspace(inheritWorkspaces(doc, item), cwd)
656
+ }
657
+
658
+ /**
659
+ * 收集某组的所有后代 id(防止把组设为自己的子组)。
660
+ * @author ddj 2026年10月30号
661
+ * @param doc 文档
662
+ * @param id 组 id
663
+ * @returns 后代 id 列表(含自身)
664
+ */
665
+ export function descendantIds(doc: QuickMenuDoc, id: string): string[] {
666
+ const out: string[] = [id]
667
+ let index = 0
668
+ while (index < out.length) {
669
+ const current = out[index]
670
+ index += 1
671
+ for (const item of doc.items) {
672
+ if (item.parentId !== current) continue
673
+ if (out.includes(item.id)) continue
674
+ out.push(item.id)
675
+ }
676
+ }
677
+ return out
678
+ }
679
+
680
+ //#endregion
681
+
682
+ //#region 绑定校验
683
+
684
+ /**
685
+ * 在校验上下文里定位规则文件(用户规则按 origin 消歧、项目规则按工作区定位)。
686
+ * @author ddj 2026年10月30号
687
+ * @param binding 规则绑定
688
+ * @param snapshot 规则列表快照
689
+ * @returns 命中信息:规则元数据 / 工作区目录缺失 / 未找到
690
+ */
691
+ function lookupRule(binding: RuleBinding, snapshot: RuleListSnapshot): { info?: RuleInfo; missingDir?: string } {
692
+ if (binding.scope === 'user') {
693
+ const hit = snapshot.user.find((rule) => {
694
+ if (rule.file !== binding.file) return false
695
+ if (binding.origin && rule.origin !== binding.origin) return false
696
+ return true
697
+ })
698
+ return hit ? { info: hit } : {}
699
+ }
700
+ const wanted = normalizePath(binding.workspacePath ?? '')
701
+ const project = snapshot.projects.find((entry) => normalizePath(entry.workspacePath) === wanted)
702
+ if (!project) return {}
703
+ if (project.missingDir) return { missingDir: project.workspacePath }
704
+ const hit = project.rules.find((rule) => rule.file === binding.file)
705
+ return hit ? { info: hit } : {}
706
+ }
707
+
708
+ /**
709
+ * 校验一条规则绑定(文件缺失/停用/自身报错/跨工作区)。
710
+ * @author ddj 2026年10月30号
711
+ * @param binding 规则绑定
712
+ * @param ctx 校验上下文
713
+ * @returns 绑定状态
714
+ */
715
+ export function validateRule(binding: RuleBinding, ctx: ValidateContext): BindingStatus {
716
+ if (!ctx.rules) return { state: 'unknown', reason: '未校验(规则列表未加载)' }
717
+ const found = lookupRule(binding, ctx.rules)
718
+ if (found.missingDir) {
719
+ return { state: 'error', reason: '工作区规则目录不存在:' + found.missingDir + '\\.dsh\\rules' }
720
+ }
721
+ if (!found.info) {
722
+ return { state: 'error', reason: '规则文件不存在或已移除:' + binding.file }
723
+ }
724
+ if (found.info.error) return { state: 'error', reason: '规则解析失败:' + found.info.error }
725
+ if (!found.info.enabled) return { state: 'warn', reason: '规则已停用,不会生效:' + binding.file }
726
+ if (binding.scope === 'project') {
727
+ const wanted = normalizePath(binding.workspacePath ?? '')
728
+ const current = normalizePath(ctx.cwd ?? '')
729
+ if (current !== '' && wanted !== current) {
730
+ return { state: 'warn', reason: '规则来自其他工作区,发送时会跳过:' + binding.file }
731
+ }
732
+ }
733
+ return { state: 'ok' }
734
+ }
735
+
736
+ /**
737
+ * 校验一条技能绑定(技能按会话枚举,不可见不等同于已删除)。
738
+ * @author ddj 2026年10月30号
739
+ * @param name 技能名
740
+ * @param ctx 校验上下文
741
+ * @returns 绑定状态
742
+ */
743
+ export function validateSkill(name: string, ctx: ValidateContext): BindingStatus {
744
+ if (ctx.skillNames === undefined || ctx.skillNames === null) {
745
+ return { state: 'unknown', reason: '未校验(当前没有活跃会话)' }
746
+ }
747
+ if (ctx.skillNames.includes(name)) return { state: 'ok' }
748
+ return { state: 'warn', reason: '当前会话不可见(可能已移除或未被该会话加载):' + name }
749
+ }
750
+
751
+ /**
752
+ * 聚合单个菜单项的绑定状态(组无绑定,恒为空)。
753
+ * @author ddj 2026年10月30号
754
+ * @param item 菜单项
755
+ * @param ctx 校验上下文
756
+ * @returns 失效与告警原因
757
+ */
758
+ export function itemStatus(item: QuickMenuItem, ctx: ValidateContext): ItemStatus {
759
+ const status: ItemStatus = { errors: [], warnings: [] }
760
+ if (item.type !== 'button') return status
761
+ if (item.prompt.trim() === '') status.warnings.push('提示词为空,只发送绑定的技能与规则')
762
+ for (const binding of item.rules) {
763
+ const result = validateRule(binding, ctx)
764
+ if (result.state === 'error') status.errors.push(result.reason ?? '规则失效')
765
+ else if (result.state === 'warn') status.warnings.push(result.reason ?? '规则需注意')
766
+ }
767
+ for (const name of item.skills) {
768
+ const result = validateSkill(name, ctx)
769
+ if (result.state === 'error') status.errors.push(result.reason ?? '技能失效')
770
+ else if (result.state === 'warn') status.warnings.push(result.reason ?? '技能需注意')
771
+ }
772
+ return status
773
+ }
774
+
775
+ /**
776
+ * 校验编辑中的草稿(保存前拦截明显错误)。
777
+ * @author ddj 2026年10月30号
778
+ * @param draft 草稿
779
+ * @param doc 当前文档(用于兄弟重名与父链判断)
780
+ * @returns 错误与告警
781
+ */
782
+ export function validateItem(draft: QuickMenuItem, doc: QuickMenuDoc): ItemStatus {
783
+ const status: ItemStatus = { errors: [], warnings: [] }
784
+ const name = draft.name.trim()
785
+ if (name === '') status.errors.push('名称不能为空')
786
+ if (name.length > QUICK_MENU_NAME_CAP) status.errors.push('名称过长(上限 ' + QUICK_MENU_NAME_CAP + ' 字)')
787
+ if (draft.order < 0 || draft.order > QUICK_MENU_ORDER_CAP) status.errors.push('优先级需在 0 到 ' + QUICK_MENU_ORDER_CAP + ' 之间')
788
+ if (draft.parentId !== null) {
789
+ const parent = doc.items.find((item) => item.id === draft.parentId)
790
+ if (!parent) status.errors.push('归属组不存在')
791
+ else if (parent.type !== 'group') status.errors.push('只能归属到组')
792
+ else if (descendantIds(doc, draft.id).includes(parent.id)) status.errors.push('不能把组归属到自己的子组')
793
+ // 编辑既有项时按移动后的实际层级判断,避免把它自己的原位判成越限
794
+ else if (depthOf(doc, parent.id) + 1 > doc.maxDepth) status.errors.push('已达最大嵌套层数(上限 ' + doc.maxDepth + ')')
795
+ }
796
+ const siblings = doc.items.filter((item) => item.parentId === draft.parentId && item.id !== draft.id)
797
+ if (name !== '' && siblings.some((item) => item.name === name)) status.warnings.push('同级已有同名项')
798
+ if (draft.type === 'button') {
799
+ if (draft.prompt.length > QUICK_MENU_PROMPT_CAP) status.errors.push('提示词过长(上限 ' + QUICK_MENU_PROMPT_CAP + ' 字符)')
800
+ if (draft.prompt.trim() === '' && draft.skills.length === 0 && draft.rules.length === 0) {
801
+ status.errors.push('提示词与绑定都为空,点击后没有内容可发送')
802
+ }
803
+ for (const name of draft.skills) {
804
+ if (!QUICK_MENU_SKILL_RE.test(name)) status.errors.push('技能名不合法:' + name)
805
+ }
806
+ for (const binding of draft.rules) {
807
+ if (binding.scope === 'project' && !binding.workspacePath) status.errors.push('项目规则缺少工作区:' + binding.file)
808
+ }
809
+ }
810
+ return status
811
+ }
812
+
813
+ //#endregion
814
+
815
+ //#region 消息合成
816
+
817
+ /**
818
+ * 挑选可用技能(不可见技能不进消息,并留下告警)。
819
+ * @author ddj 2026年10月30号
820
+ * @param item 按钮
821
+ * @param ctx 合成上下文
822
+ * @param warnings 告警收集器
823
+ * @returns 实际发送的技能名
824
+ */
825
+ function pickSkills(item: QuickButton, ctx: ComposeContext, warnings: string[]): string[] {
826
+ const out: string[] = []
827
+ for (const name of item.skills) {
828
+ const result = validateSkill(name, ctx)
829
+ if (result.state === 'error' || result.state === 'warn') {
830
+ warnings.push(result.reason ?? ('技能不可用:' + name))
831
+ continue
832
+ }
833
+ out.push(name)
834
+ }
835
+ return out
836
+ }
837
+
838
+ /**
839
+ * 挑选可用规则(失效/停用/跨工作区规则不注入,并留下告警)。
840
+ * @author ddj 2026年10月30号
841
+ * @param item 按钮
842
+ * @param ctx 合成上下文
843
+ * @param warnings 告警收集器
844
+ * @returns 发送的规则绑定与元数据
845
+ */
846
+ function pickRules(item: QuickButton, ctx: ComposeContext, warnings: string[]): Array<{ info: RuleInfo }> {
847
+ const out: Array<{ info: RuleInfo }> = []
848
+ for (const binding of item.rules) {
849
+ const result = validateRule(binding, ctx)
850
+ if (result.state !== 'ok') {
851
+ warnings.push(result.reason ?? ('规则不可用:' + binding.file))
852
+ continue
853
+ }
854
+ const found = ctx.rules ? lookupRule(binding, ctx.rules) : {}
855
+ if (found.info) out.push({ info: found.info })
856
+ }
857
+ return out
858
+ }
859
+
860
+ /**
861
+ * 生成规则引用段(只给名字与绝对路径,由模型按需读取)。
862
+ * @author ddj 2026年10月30号
863
+ * @param rules 规则元数据
864
+ * @returns 引用段落(无规则返回空串)
865
+ */
866
+ function buildRuleRefs(rules: Array<{ info: RuleInfo }>): string {
867
+ if (rules.length === 0) return ''
868
+ const lines = rules.map((rule) => '- ' + rule.info.file + '(绝对路径:' + rule.info.absPath + ')')
869
+ return '---\n引用的规则:\n' + lines.join('\n') + '\n(需要时按需读取上述规则文件)'
870
+ }
871
+
872
+ /**
873
+ * 生成规则内联段(缺正文或超长时截断并告警)。
874
+ * @author ddj 2026年10月30号
875
+ * @param item 按钮
876
+ * @param rules 规则元数据
877
+ * @param ctx 合成上下文
878
+ * @param warnings 告警收集器
879
+ * @returns 内联段落(无内容返回空串)
880
+ */
881
+ function buildInline(item: QuickButton, rules: Array<{ info: RuleInfo }>, ctx: ComposeContext, warnings: string[]): string {
882
+ if (rules.length === 0) return ''
883
+ const contents = ctx.inlineContents ?? {}
884
+ const blocks: string[] = []
885
+ for (const rule of rules) {
886
+ const key = bindingKey({ scope: rule.info.scope, file: rule.info.file, workspacePath: rule.info.scope === 'project' ? ctx.cwd : undefined })
887
+ const raw = contents[key]
888
+ if (typeof raw !== 'string' || raw.trim() === '') {
889
+ warnings.push('未能读取规则正文,已跳过内联:' + rule.info.file)
890
+ continue
891
+ }
892
+ blocks.push('### ' + rule.info.file + '\n' + cutText(raw, QUICK_MENU_RULE_CAP))
893
+ }
894
+ if (blocks.length === 0) return ''
895
+ return '## 已绑定规则(节选)\n\n' + blocks.join('\n\n')
896
+ }
897
+
898
+ /**
899
+ * 替换提示词占位符(未知占位符原样保留)。
900
+ * @author ddj 2026年10月30号
901
+ * @param prompt 原文
902
+ * @param values 占位符取值
903
+ * @returns 替换结果
904
+ */
905
+ function fillPrompt(prompt: string, values: Record<string, string>): string {
906
+ let out = prompt
907
+ for (const [key, value] of Object.entries(values)) {
908
+ out = out.split('{{' + key + '}}').join(value)
909
+ }
910
+ return out.trim()
911
+ }
912
+
913
+ /**
914
+ * 合成发送文本:提示词 + 技能 token + 规则引用/内联(自动剔失效绑定)。
915
+ * @author ddj 2026年10月30号
916
+ * @param item 按钮
917
+ * @param ctx 合成上下文
918
+ * @returns 文本与告警
919
+ */
920
+ export function composePrompt(item: QuickButton, ctx: ComposeContext): { text: string; warnings: string[] } {
921
+ const warnings: string[] = []
922
+ const skills = pickSkills(item, ctx, warnings)
923
+ const rules = pickRules(item, ctx, warnings)
924
+ const tokens = skills.map((name) => '/' + name).join(' ')
925
+ const promptText = fillPrompt(item.prompt, {
926
+ workspace: ctx.cwd ?? '',
927
+ selection: ctx.selection ?? '',
928
+ date: ctx.date ?? '',
929
+ rules: rules.map((rule) => rule.info.file).join('、'),
930
+ skills: tokens,
931
+ })
932
+ const blocks: string[] = []
933
+ if (promptText !== '') blocks.push(promptText)
934
+ // 技能 token 必须位于行首或空白之后才会被识别,故单独成行
935
+ if (tokens !== '') blocks.push(tokens)
936
+ if (item.ruleMode === 'reference') {
937
+ const refs = buildRuleRefs(rules)
938
+ if (refs !== '') blocks.push(refs)
939
+ } else {
940
+ const inline = buildInline(item, rules, ctx, warnings)
941
+ if (inline !== '') blocks.push(inline)
942
+ }
943
+ return { text: blocks.join('\n\n').trim(), warnings }
944
+ }
945
+
946
+ //#endregion
947
+
948
+ //#region 几何布局
949
+
950
+ /**
951
+ * 计算悬浮按钮默认位置(输入框左上角上方让出一个按钮高度)。
952
+ * @author ddj 2026年10月30号
953
+ * @param seat 输入框锚点矩形(浮层坐标系;缺失时回落左上角)
954
+ * @param layer 浮层矩形
955
+ * @param size 按钮尺寸
956
+ * @param margin 边距
957
+ * @returns 位置
958
+ */
959
+ export function anchorLauncher(seat: QuickRect | null, layer: QuickRect, size: QuickSize, margin = 8): { left: number; top: number } {
960
+ const maxLeft = Math.max(margin, layer.width - size.width - margin)
961
+ const maxTop = Math.max(margin, layer.height - size.height - margin)
962
+ if (!seat) return { left: clampNumber(margin, margin, maxLeft, margin), top: clampNumber(margin, margin, maxTop, margin) }
963
+ const left = seat.left - layer.left
964
+ const top = seat.top - layer.top - size.height - margin
965
+ return { left: clampNumber(left, margin, maxLeft, margin), top: clampNumber(top, margin, maxTop, margin) }
966
+ }
967
+
968
+ /**
969
+ * 计算级联子列位置(右侧放不下则翻到左侧,纵向夹取进视口)。
970
+ * @author ddj 2026年10月30号
971
+ * @param anchor 父列或父行矩形
972
+ * @param size 子列尺寸
973
+ * @param viewport 浮层尺寸
974
+ * @param gap 列间距
975
+ * @returns 子列矩形
976
+ */
977
+ export function placeColumn(anchor: QuickRect, size: QuickSize, viewport: QuickSize, gap = 6): QuickRect {
978
+ const margin = 8
979
+ const maxLeft = Math.max(margin, viewport.width - size.width - margin)
980
+ const maxTop = Math.max(margin, viewport.height - size.height - margin)
981
+ const rightLeft = anchor.left + anchor.width + gap
982
+ const flip = rightLeft + size.width > viewport.width - margin
983
+ const left = flip ? anchor.left - size.width - gap : rightLeft
984
+ const top = clampNumber(anchor.top, margin, maxTop, margin)
985
+ return { left: clampNumber(left, margin, maxLeft, margin), top, width: size.width, height: size.height }
986
+ }
987
+
988
+ /**
989
+ * 计算 SAO 风格居中突出缩放(离列中心越远越小越淡)。
990
+ *
991
+ * 幅度按「列内可用宽度」定:列 220px、左右内边距 7px + 1px 边框,行宽 204px,
992
+ * 中心行放到 1.03(约 210px)刚好还在列内、不裁字;再大就会顶出滚动区。
993
+ * 曲线用 smoothstep,让中间三四行连成一片「实」的区域,两端快速退淡,
994
+ * 与 CSS 的上下虚化叠出「居中突出」的体积感。
995
+ * @author ddj 2026年10月30号
996
+ * @param index 行下标
997
+ * @param count 行数
998
+ * @returns 缩放与透明度
999
+ */
1000
+ export function centerScale(index: number, count: number): { scale: number; opacity: number } {
1001
+ if (count <= 1) return { scale: 1, opacity: 1 }
1002
+ const center = (count - 1) / 2
1003
+ const distance = Math.abs(index - center) / Math.max(center, 0.5)
1004
+ const ratio = Math.min(1, Math.max(0, distance))
1005
+ const eased = ratio * ratio * (3 - 2 * ratio)
1006
+ return { scale: 1.03 - 0.075 * eased, opacity: 1 - 0.35 * eased }
1007
+ }
1008
+
1009
+ //#endregion