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.
- package/README.md +5 -3
- package/lib/client.js +3370 -85
- package/lib/client.js.map +2 -2
- package/lib/index.js +1167 -266
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/client/index.ts +166 -0
- package/src/client/quickMenuHost.ts +46 -0
- package/src/client/quickMenuStore.ts +260 -0
- package/src/client/state/diffSummary.ts +9 -4
- package/src/client/styles/quickmenu.css +1049 -0
- package/src/client/ui/QuickMenuDialog.tsx +835 -0
- package/src/client/ui/QuickMenuLauncher.tsx +792 -0
- package/src/client/ui/QuickMenuPickers.ts +577 -0
- package/src/index.ts +2 -2
- package/src/paths.ts +10 -0
- package/src/pending/capture.ts +104 -75
- package/src/pending/persist.ts +6 -1
- package/src/pending/store.ts +10 -5
- package/src/pending/vcs.ts +2 -2
- package/src/pendingCompat.ts +3 -8
- package/src/pendingRpc.ts +140 -58
- package/src/quickMenu.ts +269 -0
- package/src/rpc.ts +37 -0
- package/src/shared/pathKey.ts +54 -0
- package/src/shared/quickMenu.ts +1009 -0
- package/src/shared/quickPick.ts +67 -0
- package/src/shared/rpc.ts +10 -0
- package/src/shared/skillCatalog.ts +60 -0
- package/src/skillCatalog.ts +138 -0
|
@@ -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
|