dsh-anima 0.0.0-stage → 2.2.2

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/lib/patrol.mjs ADDED
@@ -0,0 +1,231 @@
1
+ // lib/patrol.mjs — 巡夜:什么时候醒、醒了要不要说话
2
+ //
3
+ // 用户定的规则(m05776):**有需要汇报的就汇报,没有就不汇报。**
4
+ //
5
+ // 这条规则的作用不是省字,是让「说了」重新有分量。一个每天到点出现、每次都说
6
+ // 「今天没什么」的通知,两周后就会被当成背景噪音;等它真有事,用户已经不看了。
7
+ //
8
+ // 但它有一个必须当场堵住的洞:只报有事,等于「沉默」同时代表两件事 ——
9
+ // (a) 巡过了,没事
10
+ // (b) 巡夜那个东西根本没跑 / 坏了
11
+ // 这正是质检钩子上花了很多功夫解决的同一个问题(checks=0 有两种相反解读)。
12
+ // 堵法:**汇报可以不发,巡夜记录必须留。** 每次到点都写一条 meta.patrol,
13
+ // 于是「没汇报」永远能反查成「真的没事」。
14
+ //
15
+ // 判据只有机器数得出来的三样,不含任何「我觉得」:
16
+ // ① 待审候选里出现了**新的**(不是「有没有候选」,是「有没有新的」)
17
+ // ② 记忆体检的 flagged 里出现了**新的**
18
+ // ③ 这次整理真的动了数据(去重或超限淘汰)
19
+ //
20
+ // 为什么用「有没有新的」而不是「数量 > 0」:如果三条候选挂了一周没人管,
21
+ // 每天报一遍是纯噪音 —— 你早就知道了。
22
+ //
23
+ // 为什么用「有没有新的」而不是「集合变没变」(m06352 用户拍板改成这样):
24
+ // 集合变没变是**双向**的。你裁掉两条候选是好事,但集合变了 ⇒ 也会给你发一条
25
+ // 汇报。于是「我把事情处理完了」这个动作本身会换来一条通知,形状上跟
26
+ // 「又有人说你错了」一模一样。改判据:**增加才出声,减少只记账。**
27
+
28
+ /**
29
+ * 计算这次巡夜的指纹。
30
+ *
31
+ * 只对**集合**做摘要,不对数量做摘要:两条旧的换成两条新的,数量没变但
32
+ * 指纹会变 —— 那是真的有事。反过来,三条挂了七天,指纹一模一样。
33
+ *
34
+ * 指纹本身不再直接决定「要不要说话」(那是 assessPatrol 里比较新旧集合的事),
35
+ * 它的用途是留痕与反查:用户拿它比对两次巡夜,就能看出中间有没有东西动过。
36
+ *
37
+ * @param {object} input
38
+ * @param {string[]} [input.pendingIds] 待审候选 id
39
+ * @param {string[]} [input.flaggedIds] 体检有问题的记忆 id
40
+ * @param {number} [input.removed] 本次整理动过的条数
41
+ * @returns {string}
42
+ */
43
+ export function patrolSignature({ pendingIds = [], flaggedIds = [], removed = 0 } = {}) {
44
+ const norm = (list) => [...new Set((Array.isArray(list) ? list : []).map(String))].sort()
45
+ const pending = norm(pendingIds).join(',')
46
+ const flagged = norm(flaggedIds).join(',')
47
+ // 整理动过数据这件事本身就值得说一次;但它不是一个可以「恢复原状」的集合,
48
+ // 所以折成一个 0/1 位,避免同一次结果被反复重报。
49
+ const touched = removed > 0 ? 1 : 0
50
+ return `p:${pending}|f:${flagged}|t:${touched}`
51
+ }
52
+
53
+ /**
54
+ * 从指纹里解析出上一次的集合。
55
+ *
56
+ * 判据改成「有没有新的」之后,必须知道上次**报过什么**,而 meta 里只存了指纹。
57
+ * 与其加两个字段(旧介质里没有,读出来是 undefined,还得再写一套迁移),
58
+ * 不如从指纹本身解析回来 —— 格式是我们自己定的,解析失败有明确兜底。
59
+ *
60
+ * @param {string} signature
61
+ * @returns {{pending: string[], flagged: string[], touched: number, parsed: boolean}}
62
+ */
63
+ export function parsePatrolSignature(signature) {
64
+ const empty = { pending: [], flagged: [], touched: 0, parsed: false }
65
+ const text = String(signature ?? '')
66
+ if (!text) return { ...empty, parsed: true }
67
+ const m = /^p:([^|]*)\|f:([^|]*)\|t:([01])$/.exec(text)
68
+ if (!m) return empty
69
+ const split = (s) => (s ? s.split(',').filter(Boolean) : [])
70
+ return { pending: split(m[1]), flagged: split(m[2]), touched: Number(m[3]), parsed: true }
71
+ }
72
+
73
+ /**
74
+ * 判断这次巡夜该不该出声。
75
+ *
76
+ * @param {object} input
77
+ * @param {string[]} [input.pendingIds] 待审候选 id(整理后的存活者)
78
+ * @param {string[]} [input.flaggedIds] 体检 flagged 的记忆 id
79
+ * @param {number} [input.removed] 本次整理动过的条数
80
+ * @param {string} [input.previousSignature] 上次巡夜留下的指纹;没跑过就传空
81
+ * @param {string[]} [input.previousPendingIds] 上次的待审集合(给了就不解析指纹)
82
+ * @param {string[]} [input.previousFlaggedIds] 上次的体检集合
83
+ * @returns {{shouldReport: boolean, signature: string, reasons: string[], facts: object,
84
+ * added: {pending: number, flagged: number}, cleared: {pending: number, flagged: number}}}
85
+ */
86
+ export function assessPatrol({
87
+ pendingIds = [],
88
+ flaggedIds = [],
89
+ removed = 0,
90
+ previousSignature = '',
91
+ previousPendingIds,
92
+ previousFlaggedIds
93
+ } = {}) {
94
+ const uniq = (list) => [...new Set((Array.isArray(list) ? list : []).map(String))]
95
+ const pending = uniq(pendingIds)
96
+ const flagged = uniq(flaggedIds)
97
+ const signature = patrolSignature({ pendingIds, flaggedIds, removed })
98
+
99
+ let prevPending
100
+ let prevFlagged
101
+ let prevTouched = 0
102
+ let parseFailed = false
103
+ if (Array.isArray(previousPendingIds) || Array.isArray(previousFlaggedIds)) {
104
+ prevPending = uniq(previousPendingIds ?? [])
105
+ prevFlagged = uniq(previousFlaggedIds ?? [])
106
+ } else {
107
+ const parsed = parsePatrolSignature(previousSignature)
108
+ // 解析失败时当成「没有上次」处理:宁可多报一次,也不能因为读不懂旧记录就
109
+ // 把真事吞掉。吞掉的错误没人看得见,多报一次的代价只是多一句。
110
+ parseFailed = !parsed.parsed
111
+ prevPending = parsed.pending
112
+ prevFlagged = parsed.flagged
113
+ prevTouched = parsed.touched
114
+ }
115
+
116
+ const prevPendingSet = new Set(prevPending)
117
+ const prevFlaggedSet = new Set(prevFlagged)
118
+ const addedPending = pending.filter((id) => !prevPendingSet.has(id))
119
+ const addedFlagged = flagged.filter((id) => !prevFlaggedSet.has(id))
120
+ const clearedPending = prevPending.filter((id) => !pending.includes(id))
121
+ const clearedFlagged = prevFlagged.filter((id) => !flagged.includes(id))
122
+
123
+ const facts = {
124
+ pending: pending.length,
125
+ flagged: flagged.length,
126
+ removed: Number(removed) || 0
127
+ }
128
+ const added = { pending: addedPending.length, flagged: addedFlagged.length }
129
+ const cleared = { pending: clearedPending.length, flagged: clearedFlagged.length }
130
+
131
+ // 只看「增加」和「整理真的动了数据」。减少(你裁决掉了、路径修好了)不算有事:
132
+ // 那是你把问题解决掉的结果,不该再拿它来打扰你一次。它仍然记进 meta,
133
+ // 于是「上周有三条,这周没了」依然可反查 —— 只是不再发一条消息。
134
+ const reasons = []
135
+ if (addedPending.length > 0) reasons.push(`pending:${addedPending.length}`)
136
+ if (addedFlagged.length > 0) reasons.push(`flagged:${addedFlagged.length}`)
137
+ // 整理动过数据只在「上次没动过」时才说一次。否则同一个结果会每晚重报 ——
138
+ // 这正是 m05776「没有就不汇报」要消灭的形状。旧指纹里的 t 位就为这个存在。
139
+ if (facts.removed > 0 && prevTouched === 0) reasons.push(`removed:${facts.removed}`)
140
+
141
+ return {
142
+ shouldReport: reasons.length > 0,
143
+ signature,
144
+ reasons,
145
+ facts,
146
+ added,
147
+ cleared,
148
+ ...(parseFailed ? { parseFailed: true } : {})
149
+ }
150
+ }
151
+
152
+ /** 一次巡夜最多落盘几条体检明细。 */
153
+ export const MAX_FLAGGED_ITEMS = 20
154
+ /** 单条明细里 detail 文本的字符上限(落盘用,不是给人读的全文)。 */
155
+ export const MAX_FLAGGED_DETAIL_CHARS = 200
156
+
157
+ /**
158
+ * 把体检结果压成「可以安全落盘的那一份」。
159
+ *
160
+ * 为什么要压而不是原样存:`audit.flagged` 里每条都带 findings(含 evidence 里的
161
+ * 完整命令与路径)和建议命令,内容随记忆增长,且 store 的 metaSchema 已经把
162
+ * 形状钉死 —— 原样塞进去,读回时会被 zod 剥掉不认识的部分,于是「写了但读不回来」。
163
+ * 这正是 2026-10-24 那个 metaSchema bug 的形状,不能再犯一次。
164
+ *
165
+ * 只留三样:id(能去 `/anima mark` 那条命令)、明细首句(让人认得出是哪条)、
166
+ * 证据首句(证明不是瞎报)。**不留建议命令全文**:它是按 id 现算的,存下来只会过期。
167
+ *
168
+ * @param {Array<{id:string, text?:string, findings?:Array<{detail?:string, evidence?:string}>}>} flagged
169
+ * @returns {{items: Array<{id:string, detail:string, evidence:string, kinds:string[]}>, dropped: number}}
170
+ */
171
+ export function compactFlagged(flagged) {
172
+ const list = Array.isArray(flagged) ? flagged : []
173
+ const items = []
174
+ for (const item of list.slice(0, MAX_FLAGGED_ITEMS)) {
175
+ const id = String(item?.id ?? '').trim()
176
+ if (!id) continue
177
+ const findings = Array.isArray(item?.findings) ? item.findings : []
178
+ const first = findings[0] ?? {}
179
+ const clip = (s) => {
180
+ const text = String(s ?? '')
181
+ return text.length > MAX_FLAGGED_DETAIL_CHARS
182
+ ? `${text.slice(0, MAX_FLAGGED_DETAIL_CHARS)}…`
183
+ : text
184
+ }
185
+ items.push({
186
+ id,
187
+ // detail 缺失时退回记忆正文:宁可少一句解释,也不能留一条只有 id 的孤儿明细
188
+ // —— 那种行在界面上等于「有 1 条有问题,但我不告诉你是什么」。
189
+ detail: clip(first.detail ?? item?.text ?? ''),
190
+ evidence: clip(first.evidence ?? ''),
191
+ kinds: findings.map((f) => String(f?.kind ?? '')).filter(Boolean)
192
+ })
193
+ }
194
+ return { items, dropped: Math.max(0, list.length - items.length) }
195
+ }
196
+
197
+ /**
198
+ * 算出下一次该在什么时候醒。
199
+ *
200
+ * 背景(m06351 实测):原来的写法是「把 delay 压到 6 小时上限,然后每 6 小时
201
+ * 醒一次都判断今天跑过没」。后果不是跑到错的时间点就完事 —— 是**真正决定巡夜
202
+ * 在几点跑的,变成了「今天补跑过没有」,而不是 nightlyHour**。开机补跑一旦被
203
+ * 跳过(存储没就绪),第二天早上第一次醒来就会当场把巡夜跑掉。
204
+ *
205
+ * 改法:**没到目标时刻的醒来,只重新定闹钟,不跑巡夜。** 于是巡夜只可能在两个
206
+ * 时候跑:目标时刻本身,或者机器睡过了目标之后(那是补跑,会记 `nightly-late`)。
207
+ *
208
+ * 这个函数是纯的,所以「到底几点醒」可以被测试锁住,而不是靠读代码想象。
209
+ *
210
+ * @param {object} [input]
211
+ * @param {Date} [input.now] 当前时刻
212
+ * @param {number} [input.hour] 目标小时(0-23)
213
+ * @param {number} [input.maxDelayMs] 单次定时最长挂多久
214
+ * @returns {{delayMs: number, targetAt: number, isTarget: boolean}}
215
+ * `isTarget` 为 true 表示这次醒来就是目标时刻(醒来后该跑巡夜);
216
+ * 为 false 表示只是中途醒来重新定闹钟,不许跑。
217
+ */
218
+ export function planNightlyWake({ now = new Date(), hour = 3, maxDelayMs = 6 * 60 * 60 * 1000 } = {}) {
219
+ const h = Number.isInteger(hour) && hour >= 0 && hour <= 23 ? hour : 3
220
+ const limit = Number(maxDelayMs) > 0 ? Number(maxDelayMs) : 6 * 60 * 60 * 1000
221
+ const base = now instanceof Date ? now : new Date(now)
222
+ const target = new Date(base.getTime())
223
+ target.setHours(h, 0, 0, 0)
224
+ if (target.getTime() <= base.getTime()) target.setDate(target.getDate() + 1)
225
+ const raw = target.getTime() - base.getTime()
226
+ return {
227
+ delayMs: Math.min(raw, limit),
228
+ targetAt: target.getTime(),
229
+ isTarget: raw <= limit
230
+ }
231
+ }
@@ -0,0 +1,310 @@
1
+ // lib/presets.mjs — 人设预设(纯数据 + 纯函数,无外部依赖)
2
+ //
3
+ // 预设 = 一组可整份套用的人设字段快照。用途:用户不必从十几个字段里逐个填,
4
+ // 选一个预设即可得到一套自洽的语气,再在这个基础上微调。
5
+ //
6
+ // 设计约束:
7
+ // ① 预设**只覆盖它声明的字段**;套用时其余字段保持用户当前值不动。
8
+ // 这与工作区覆盖的 field-level merge 是同一套语义,保持一致的心智模型。
9
+ // ② 内置预设不落盘、不可删、不可改名;用户自建预设存全局配置里。
10
+ // ③ 内置预设不与自建预设重名 —— 同名时内置优先(见 resolvePreset)。
11
+ //
12
+ // v3 起的一个定位变化:人设字段全是自由文本了,于是**预设就是模板** ——
13
+ // 内置预设的字段值是一整段现成的话(来自 lib/wording.mjs),点一下把它填进表单,
14
+ // 用户随后可以随意改。它不再是一组「键」,因为键在自由文本模型里没有位置:
15
+ // 用户看不懂 'casual',模型也读不懂。
16
+ //
17
+ // 这条路线的代价是内置预设的 fields 变得很长。这是刻意付的:若改成「存键 + 套用时翻译」,
18
+ // 设置页要显示「点一下会填什么」就必须先拿到翻译结果,于是宿主得多发一份翻译表、
19
+ // 客户端多一层同步;而用户想微调时又会发现表单里出现的和按钮上写的不是同一份东西。
20
+ //
21
+ // 本模块被 index.mjs(host)与 scripts/verify-core.mjs 共用。
22
+
23
+ import { REPLY_LENGTH_WORDING, STYLE_WORDING, TRAIT_WORDING } from './wording.mjs'
24
+ import { FIELD_LIMITS, isPlainObject } from './config.mjs'
25
+
26
+ /** 预设可声明的字段(= 人设字段,不含记忆与工作区开关)。 */
27
+ export const PRESET_FIELDS = [
28
+ 'agentName', 'agentRole', 'addressUserAs', 'userOccupation', 'userBio',
29
+ 'style', 'headingLists', 'emoji', 'tables', 'replyLength', 'language',
30
+ 'customInstructions'
31
+ ]
32
+
33
+ /** 预设名长度上限与条数上限。 */
34
+ export const PRESET_NAME_LIMIT = 40
35
+ export const PRESET_COUNT_LIMIT = 50
36
+
37
+ /**
38
+ * 内置预设。id 固定,重装/升级后仍然稳定,故可安全地写进用户配置或文档。
39
+ * 注意:内置预设刻意**不填** agentName(那是用户自己的助手名字)与用户背景
40
+ * (userOccupation/userBio 描述的是用户本人,插件不该替用户假设)。
41
+ *
42
+ * 三个特质字段(headingLists / emoji / tables)里,取「默认」档的**直接不写这个字段**
43
+ * —— default 档的文案就是空串,写进来也会被 applyPreset 当成「不覆盖」丢掉。
44
+ * 少一个键比多一个空串诚实:读源码的人一眼能看出这个预设没对它提要求。
45
+ */
46
+ export const BUILTIN_PRESETS = [
47
+ {
48
+ id: 'builtin:precise',
49
+ name: '专业严谨',
50
+ description: '结论先行、逻辑清晰、用词准确,不口语化。日常技术工作与文档写作的默认档。',
51
+ fields: {
52
+ style: STYLE_WORDING.professional,
53
+ replyLength: REPLY_LENGTH_WORDING.normal,
54
+ emoji: TRAIT_WORDING.emoji.less
55
+ }
56
+ },
57
+ {
58
+ id: 'builtin:concise',
59
+ name: '极简主义',
60
+ description: '只说结论与关键理由,能省的字都省,不重复你已经知道的东西。',
61
+ fields: {
62
+ style: STYLE_WORDING.efficient,
63
+ replyLength: REPLY_LENGTH_WORDING.concise,
64
+ headingLists: TRAIT_WORDING.headingLists.less,
65
+ emoji: TRAIT_WORDING.emoji.less,
66
+ tables: TRAIT_WORDING.tables.less
67
+ }
68
+ },
69
+ {
70
+ id: 'builtin:teacher',
71
+ name: '教学型讲解',
72
+ description: '从背景讲起、层层递进、多举例,适合学新东西时用。',
73
+ fields: {
74
+ style: STYLE_WORDING.professional,
75
+ replyLength: REPLY_LENGTH_WORDING.detailed,
76
+ headingLists: TRAIT_WORDING.headingLists.more,
77
+ tables: TRAIT_WORDING.tables.more
78
+ }
79
+ },
80
+ {
81
+ id: 'builtin:architect',
82
+ name: '资深架构师',
83
+ description: '关注边界、失败模式与取舍,先问清约束再动手,主动指出设计里的坑。',
84
+ fields: {
85
+ style: STYLE_WORDING.professional,
86
+ replyLength: REPLY_LENGTH_WORDING.detailed,
87
+ headingLists: TRAIT_WORDING.headingLists.more,
88
+ emoji: TRAIT_WORDING.emoji.less,
89
+ tables: TRAIT_WORDING.tables.more,
90
+ customInstructions: [
91
+ '回答设计问题前先确认约束(规模、并发、可维护性要求);',
92
+ '给出方案时同时说明它的失败模式与不适用条件;',
93
+ '发现提议里的隐藏风险要直接指出,不要为了配合而附和。'
94
+ ].join('\n')
95
+ }
96
+ },
97
+ {
98
+ id: 'builtin:reviewer',
99
+ name: '严格代码审查',
100
+ description: '按行审查、只讲问题不讲客套、每条都给出可执行的修法。',
101
+ fields: {
102
+ style: STYLE_WORDING.professional,
103
+ replyLength: REPLY_LENGTH_WORDING.detailed,
104
+ headingLists: TRAIT_WORDING.headingLists.more,
105
+ emoji: TRAIT_WORDING.emoji.less,
106
+ customInstructions: [
107
+ '审查代码时逐条列出问题,注明文件与行号;',
108
+ '每条问题都要给出具体的修改建议,不要只说「这里不好」;',
109
+ '没有问题就明确说没有问题,不要为凑数而挑刺。'
110
+ ].join('\n')
111
+ }
112
+ },
113
+ {
114
+ id: 'builtin:brainstorm',
115
+ name: '头脑风暴伙伴',
116
+ description: '先发散再多给几条路子,允许不成熟的点子,等你选定后再收敛。',
117
+ fields: {
118
+ style: STYLE_WORDING.casual,
119
+ replyLength: REPLY_LENGTH_WORDING.normal,
120
+ tables: TRAIT_WORDING.tables.less,
121
+ customInstructions: [
122
+ '发散阶段尽量多给几个方向,哪怕其中一些还不成熟;',
123
+ '每个方向写一句它的代价,方便我快速筛;',
124
+ '等我明确选定方向后再收敛,不要在发散阶段就替我下结论。'
125
+ ].join('\n')
126
+ }
127
+ }
128
+ ]
129
+
130
+ const text = (v) => (typeof v === 'string' ? v.trim() : '')
131
+
132
+ /**
133
+ * 校验一组预设字段值。
134
+ *
135
+ * v3 起全部字段都是自由文本,所以这里只剩两件事:类型、长度。
136
+ * 长度按 lib/config.mjs 的 FIELD_LIMITS 判 —— 预设最终要写进配置,
137
+ * 若这里放行一段超长文本,套用时会被 sanitizeConfig 整条拒掉,
138
+ * 用户看到的是「点了一下预设,报错说字段超长」,却不知道是哪个预设的锅。
139
+ *
140
+ * @returns {{fields: object, errors: Record<string,string>}}
141
+ */
142
+ export function sanitizePresetFields(raw) {
143
+ const fields = {}
144
+ const errors = {}
145
+ if (!isPlainObject(raw)) {
146
+ return { fields, errors: { _: '预设字段必须是一个 JSON 对象' } }
147
+ }
148
+
149
+ for (const key of PRESET_FIELDS) {
150
+ if (!(key in raw)) continue
151
+ const value = raw[key]
152
+ if (typeof value !== 'string') {
153
+ errors[key] = '必须为字符串'
154
+ continue
155
+ }
156
+ const body = value.trim()
157
+ if (!body) continue // 空 = 该预设不声明这个字段
158
+ const limit = FIELD_LIMITS[key]
159
+ if (limit && body.length > limit) {
160
+ errors[key] = `长度超过上限 ${limit} 字符(当前 ${body.length})`
161
+ continue
162
+ }
163
+ fields[key] = body
164
+ }
165
+
166
+ return { fields, errors }
167
+ }
168
+
169
+ /**
170
+ * 校验一个自建预设。
171
+ * @returns {{preset: object|null, errors: Record<string,string>}}
172
+ */
173
+ export function sanitizeCustomPreset(raw) {
174
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
175
+ return { preset: null, errors: { _: '预设必须是一个 JSON 对象' } }
176
+ }
177
+ const errors = {}
178
+ const name = text(raw.name)
179
+ if (!name) errors.name = '预设名不能为空'
180
+ else if (name.length > PRESET_NAME_LIMIT) errors.name = `预设名超过上限 ${PRESET_NAME_LIMIT} 字符`
181
+ else if (BUILTIN_PRESETS.some((p) => p.name === name)) errors.name = '与内置预设重名,请换一个名字'
182
+
183
+ const { fields, errors: fieldErrors } = sanitizePresetFields(raw.fields ?? {})
184
+ Object.assign(errors, fieldErrors)
185
+
186
+ if (Object.keys(errors).length > 0) return { preset: null, errors }
187
+
188
+ const id = text(raw.id) || `custom:${name}`
189
+ return {
190
+ preset: {
191
+ id,
192
+ name,
193
+ description: text(raw.description),
194
+ fields,
195
+ custom: true,
196
+ createdAt: text(raw.createdAt) || new Date().toISOString()
197
+ },
198
+ errors: {}
199
+ }
200
+ }
201
+
202
+ /**
203
+ * 校验自建预设列表(整表替换语义)。
204
+ * @returns {{presets: Array<object>, errors: Array<object>}}
205
+ */
206
+ export function sanitizeCustomPresets(raw) {
207
+ if (!Array.isArray(raw)) return { presets: [], errors: [{ _: '预设列表必须是数组' }] }
208
+ if (raw.length > PRESET_COUNT_LIMIT) {
209
+ return { presets: [], errors: [{ _: `自建预设最多 ${PRESET_COUNT_LIMIT} 条` }] }
210
+ }
211
+ const presets = []
212
+ const errors = []
213
+ const seen = new Set()
214
+ for (const [index, item] of raw.entries()) {
215
+ const { preset, errors: itemErrors } = sanitizeCustomPreset(item)
216
+ if (!preset) {
217
+ errors.push({ index, ...itemErrors })
218
+ continue
219
+ }
220
+ if (seen.has(preset.name)) {
221
+ errors.push({ index, name: '预设名重复' })
222
+ continue
223
+ }
224
+ seen.add(preset.name)
225
+ presets.push(preset)
226
+ }
227
+ return { presets, errors }
228
+ }
229
+
230
+ /**
231
+ * 内置 + 自建,内置在前。
232
+ *
233
+ * 这里必须**补上 builtin 标志**:BUILTIN_PRESETS 里的六条数据本身没有这个字段
234
+ * (写数据时没必要自我声明),而客户端靠它决定「下拉里标内置还是自建」「要不要
235
+ * 给这条画删除按钮」。少补这一个布尔值,六条内置预设就会全部显示成「自建」、
236
+ * 每条右边多出一个删除按钮,点下去又被后端 400 挡回来(index.mjs 里
237
+ * `id.startsWith('builtin:')` 那段),UI 和文案直接自相矛盾。
238
+ * 自建侧同理补 custom: true,让两边都有明确标志,客户端不用再猜。
239
+ */
240
+ export function listPresets(customPresets) {
241
+ const custom = Array.isArray(customPresets) ? customPresets : []
242
+ return [
243
+ ...BUILTIN_PRESETS.map((preset) => ({ ...preset, builtin: true, custom: false })),
244
+ ...custom.map((preset) => ({ ...preset, builtin: false, custom: true }))
245
+ ]
246
+ }
247
+
248
+ /** 按 id 找,找不到按 name 找;内置优先。 */
249
+ export function resolvePreset(customPresets, key) {
250
+ const target = text(key)
251
+ if (!target) return null
252
+ const all = listPresets(customPresets)
253
+ return all.find((p) => p.id === target) ?? all.find((p) => p.name === target) ?? null
254
+ }
255
+
256
+ /**
257
+ * 套用预设:只覆盖预设声明的字段(空串视为「不覆盖」,与工作区覆盖同语义)。
258
+ * @returns {object} 可直接交给 sanitizeConfig 的补丁
259
+ */
260
+ export function applyPreset(preset) {
261
+ if (!preset || !preset.fields) return {}
262
+ const patch = {}
263
+ for (const [key, value] of Object.entries(preset.fields)) {
264
+ if (!PRESET_FIELDS.includes(key)) continue
265
+ if (typeof value !== 'string' || !value.trim()) continue
266
+ patch[key] = value.trim()
267
+ }
268
+ return patch
269
+ }
270
+
271
+ /**
272
+ * 判断一份配置现在「正在用」哪条预设。
273
+ *
274
+ * 为什么需要:预设是**一次性套用**的 —— 点完就散成一堆字段值,配置里不再留任何
275
+ * 「我来自哪条预设」的痕迹。于是用户下次打开设置页,看到的是哪个名字都对不上的
276
+ * 一堆文字,既不知道自己刚才套过什么,也就无法判断「现在这套还算不算预设」。
277
+ *
278
+ * 判定规则(**按字段逐条比对,而不是记住一个 id**):
279
+ * - 预设声明的每个非空字段,都必须与当前值完全相等;
280
+ * - 预设没声明的字段不参与比对(它本来就没动过那些字段);
281
+ * - 一条都不声明字段的预设视为「不是预设」,不参与匹配;
282
+ * - 多条同时命中时取声明字段最多的那条(更具体的那条才是用户想要的),
283
+ * 数量相同则内置优先(列表顺序)。
284
+ *
285
+ * 这样做的代价是:用户手改一个字段后,匹配自然失效、徽标消失 —— 这是**正确的**。
286
+ * 记 id 的方案做不到这一点,它会一直举着一个「现在用的是 XX 预设」的牌子,
287
+ * 哪怕用户早就把里面的字全改光了。
288
+ *
289
+ * @param {object} config 全局配置(不是合并后的 effective —— 预设只作用于全局层)
290
+ * @param {Array<object>} [presets] 候选预设列表,默认内置 + 配置里的自建
291
+ * @returns {?object} 命中的预设,没命中返回 null
292
+ */
293
+ export function findActivePreset(config, presets) {
294
+ const current = config && typeof config === 'object' ? config : {}
295
+ const candidates = Array.isArray(presets) ? presets : listPresets(current.presets)
296
+ let best = null
297
+ let bestScore = 0
298
+ for (const preset of candidates) {
299
+ const fields = applyPreset(preset)
300
+ const keys = Object.keys(fields)
301
+ if (keys.length === 0) continue
302
+ const matched = keys.every((key) => current[key] === fields[key])
303
+ if (!matched) continue
304
+ if (keys.length > bestScore) {
305
+ best = preset
306
+ bestScore = keys.length
307
+ }
308
+ }
309
+ return best
310
+ }