dsh-plugin-effort-declare 0.1.1 → 0.1.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.
@@ -35,7 +35,7 @@ import { validateSaveDraft, type SchemaOps } from './schema-ops.ts'
35
35
  import type { EffortDeclareKey } from './locales.ts'
36
36
  import css from './effort-declare.module.css'
37
37
 
38
- export type InvalidationSource = 'settings' | 'directory'
38
+ export type InvalidationSource = 'settings' | 'directory' | 'writable'
39
39
 
40
40
  export interface EffortDeclareSectionInjected {
41
41
  api: Pick<IApiClient, 'settings' | 'llm'>
@@ -165,18 +165,20 @@ function RouteCard(props: {
165
165
  draft: RouteDraft
166
166
  formats: readonly string[]
167
167
  writable: boolean
168
+ /** This card is the in-flight mutate — show “Saving…”. */
168
169
  busy: boolean
169
- reloading: boolean
170
+ /** Namespace-level lock: any mutate, read-only, or first load. */
171
+ saveLocked: boolean
170
172
  notice: CardNotice | undefined
171
173
  t: (key: EffortDeclareKey) => string
172
174
  onChange: (draft: RouteDraft) => void
173
175
  onSave: (draft: RouteDraft) => void
174
176
  onCancel: (draft: RouteDraft) => void
175
177
  }): ReactNode {
176
- const { draft, writable, busy, reloading, t, onChange } = props
178
+ const { draft, writable, busy, saveLocked, t, onChange } = props
177
179
  const noModels = draft.models.length === 0
178
180
  const editDisabled = !writable || busy || noModels
179
- const saveLocked = editDisabled || reloading
181
+ const saveDisabled = saveLocked || noModels
180
182
  const formats = thinkingFormatChoices(props.formats, draft.compat.thinkingFormat)
181
183
  const summary = compatSummary(draft.compat)
182
184
  const sameWire = draft.compat.supportsReasoningEffort === false
@@ -302,7 +304,7 @@ function RouteCard(props: {
302
304
  <button
303
305
  type="button"
304
306
  className={css.primaryButton}
305
- disabled={saveLocked || !dirty || clientError !== undefined}
307
+ disabled={saveDisabled || !dirty || clientError !== undefined}
306
308
  onClick={() => { props.onSave(draft) }}
307
309
  >
308
310
  {busy ? t('saving') : t('save')}
@@ -335,7 +337,7 @@ export function EffortDeclareSection(props: EffortDeclareSectionProps): ReactNod
335
337
  return
336
338
  }
337
339
  const generation = nextGeneration(generationRef)
338
- setStatus('loading')
340
+ if (draftsRef.current.length === 0) setStatus('loading')
339
341
  setError('')
340
342
  void loadDrafts(api, describe, schema).then((result) => {
341
343
  if (!generationIsCurrent(generationRef, generation)) return
@@ -370,9 +372,14 @@ export function EffortDeclareSection(props: EffortDeclareSectionProps): ReactNod
370
372
  useEffect(() => {
371
373
  if (props.subscribeInvalidate === undefined) return undefined
372
374
  return props.subscribeInvalidate((source) => {
375
+ if (source === 'writable') {
376
+ const view = describe?.getSnapshot().view
377
+ if (view !== undefined) setWritable(view.writable)
378
+ return
379
+ }
373
380
  if (source === 'settings' || source === 'directory') reload(true)
374
381
  })
375
- }, [props.subscribeInvalidate, reload])
382
+ }, [describe, props.subscribeInvalidate, reload])
376
383
 
377
384
  const patchNotice = (provider: string, notice: CardNotice | undefined): void => {
378
385
  setNotices(current => {
@@ -385,7 +392,10 @@ export function EffortDeclareSection(props: EffortDeclareSectionProps): ReactNod
385
392
 
386
393
  const save = async (draft: RouteDraft): Promise<void> => {
387
394
  if (api === undefined || describe === undefined || schema === undefined) return
388
- if (status === 'loading' || busyRoute !== null) return
395
+ if (status === 'loading' || busyRoute !== null) {
396
+ patchNotice(draft.provider, { kind: 'error', text: t('saveBusy') })
397
+ return
398
+ }
389
399
  const blocking = draft.models
390
400
  .map(row => errorText(modelEffortError(row), t))
391
401
  .find(text => text !== undefined)
@@ -502,7 +512,7 @@ export function EffortDeclareSection(props: EffortDeclareSectionProps): ReactNod
502
512
  formats={formats.length > 0 ? formats : []}
503
513
  writable={writable}
504
514
  busy={busyRoute === draft.provider}
505
- reloading={status === 'loading'}
515
+ saveLocked={!writable || busyRoute !== null || status === 'loading'}
506
516
  notice={notices[draft.provider]}
507
517
  t={t}
508
518
  onChange={(next) => {
@@ -17,8 +17,8 @@ Cross-plugin work uses Cordis services only (`connection`, `settingsScope`, `set
17
17
 
18
18
  | File | Role |
19
19
  | --- | --- |
20
- | [`index.ts`](./index.ts) | Registers zh/en copy, CSS (`ctx.effect` insert/remove), and `settings.section` (id `effort-declare`, order 12). Subscribes in `apply` to the mirror `describe.subscribe`, `settings/document-updated` (`llm-pi-ai` only), `llm/adapters-updated`, and `connection/reset`. `locale: NS` lets the framework inject `t`; `inject` only returns `api` / `describe` / `schema` / `subscribeInvalidate`. |
21
- | [`EffortDeclareSection.tsx`](./EffortDeclareSection.tsx) | Route cards, presets, Off tri-state, advanced protocol switches, save and cancel. |
20
+ | [`index.ts`](./index.ts) | Registers zh/en copy, CSS (`ctx.effect` insert/remove), and `settings.section` (id `effort-declare`, order 12). `describe.subscribe` only syncs `writable` (it fires for every namespace, including this page’s own `acceptView`). Full reloads come from `settings/document-updated` (`llm-pi-ai` only), `llm/adapters-updated`, and `connection/reset`. `locale: NS` lets the framework inject `t`; `inject` only returns `api` / `describe` / `schema` / `subscribeInvalidate`. |
21
+ | [`EffortDeclareSection.tsx`](./EffortDeclareSection.tsx) | Route cards, presets, Off tri-state, advanced protocol switches, save and cancel. Save is exclusive for the whole `llm-pi-ai` namespace; `busy` is “Saving…” on the in-flight card, `saveLocked` disables the others. A successful write folds `acceptView` + `applySaveSuccess` and does not full-reload. |
22
22
  | [`load-drafts.ts`](./load-drafts.ts) | Builds drafts from `llm.providers` + the settings mirror; drafts come from `user`, protocol classification may use `value`. `thinkingFormat` choices come only from the live schema union. |
23
23
  | [`locales.ts`](./locales.ts) | Copy namespace `plugin-effort-declare`. |
24
24
  | [`effort-declare.module.css`](./effort-declare.module.css) | `--dsw-alias-*` tokens only, so dark theme stays correct. |
@@ -17,8 +17,8 @@
17
17
 
18
18
  | 文件 | 说明 |
19
19
  | --- | --- |
20
- | [`index.ts`](./index.ts) | 注册 zh/en 文案、CSS(`ctx.effect` 插入/移除)与 `settings.section`(id `effort-declare`,order 12)。在 `apply` 里订阅镜像 `describe.subscribe`、`settings/document-updated`(仅 `llm-pi-ai`)、`llm/adapters-updated`、`connection/reset`。`locale: NS` 由框架注入 `t`,inject 只传 `api` / `describe` / `schema` / `subscribeInvalidate`。 |
21
- | [`EffortDeclareSection.tsx`](./EffortDeclareSection.tsx) | 路由卡片、预设、Off 三态、高级协议开关、保存与取消。 |
20
+ | [`index.ts`](./index.ts) | 注册 zh/en 文案、CSS(`ctx.effect` 插入/移除)与 `settings.section`(id `effort-declare`,order 12)。`describe.subscribe` 只同步 `writable`(任意 namespace 都会触发,含自己的 `acceptView`)。真正重载来自 `settings/document-updated`(仅 `llm-pi-ai`)、`llm/adapters-updated`、`connection/reset`。`locale: NS` 由框架注入 `t`,inject 只传 `api` / `describe` / `schema` / `subscribeInvalidate`。 |
21
+ | [`EffortDeclareSection.tsx`](./EffortDeclareSection.tsx) | 路由卡片、预设、Off 三态、高级协议开关、保存与取消。保存互斥到整个 `llm-pi-ai` namespace;`busy` 只给正在 mutate 的卡显示「保存中」,其它卡 `saveLocked`。自己写入成功走 `acceptView` + `applySaveSuccess`,不再全量 reload。 |
22
22
  | [`load-drafts.ts`](./load-drafts.ts) | 从 `llm.providers` + 镜像组装草稿;草稿取自 `user`,协议分类可用 `value`。`thinkingFormat` 可选项只来自现场 schema union。 |
23
23
  | [`locales.ts`](./locales.ts) | 文案 namespace `plugin-effort-declare`。 |
24
24
  | [`effort-declare.module.css`](./effort-declare.module.css) | 仅使用 `--dsw-alias-*` 设计令牌,保证暗色主题正确。 |
@@ -66,8 +66,11 @@ export function apply(ctx: ClientContext): void {
66
66
  const emit = (source: InvalidationSource) => {
67
67
  for (const listener of invalidation) listener(source)
68
68
  }
69
+ // Mirror subscribe fires on every namespace (theme, locale, own acceptView).
70
+ // Only fold writable from that; llm-pi-ai document changes come from the
71
+ // Host event below so a dirty card is not treated as a conflict.
69
72
  const disposers = [
70
- describe.subscribe(() => { emit('settings') }),
73
+ describe.subscribe(() => { emit('writable') }),
71
74
  ctx.remote.$on('settings/document-updated', (ns: string) => {
72
75
  if (ns !== LLM_PI_AI_NS) return
73
76
  emit('settings')
@@ -13,6 +13,7 @@ export type EffortDeclareKey =
13
13
  | 'readOnly'
14
14
  | 'save'
15
15
  | 'saving'
16
+ | 'saveBusy'
16
17
  | 'cancel'
17
18
  | 'saved'
18
19
  | 'conflict'
@@ -57,6 +58,7 @@ export const zh: Record<EffortDeclareKey, string> = {
57
58
  readOnly: '当前设置为只读,无法保存。',
58
59
  save: '保存',
59
60
  saving: '保存中…',
61
+ saveBusy: '另有路由正在保存,请稍候。',
60
62
  cancel: '取消',
61
63
  saved: '已保存。对话选择器会按新的能力声明显示 Effort 行。',
62
64
  conflict: '设置已被其他地方改过,请重新加载后再保存。',
@@ -100,6 +102,7 @@ export const en: Record<EffortDeclareKey, string> = {
100
102
  readOnly: 'Settings are read-only; saving is disabled.',
101
103
  save: 'Save',
102
104
  saving: 'Saving…',
105
+ saveBusy: 'Another route is saving. Wait, then save this card.',
103
106
  cancel: 'Cancel',
104
107
  saved: 'Saved. The composer Effort row follows this capability declaration.',
105
108
  conflict: 'Settings changed elsewhere. Reload, then save again.',
@@ -9,7 +9,7 @@
9
9
 
10
10
  Domain logic for the plugin: no React, no Cordis service instances. The settings page and Vitest both depend on these pure functions, so effort semantics can regress without booting DSH.
11
11
 
12
- Canonical level names and `thinkingFormat` values follow `@deepseek-ai/dsh-llm-pi-ai` (0.1.0-rc.8). A whitelist is pinned by tests; UI choices come only from the live schema union.
12
+ Canonical level names and `thinkingFormat` values follow `@deepseek-ai/dsh-llm-pi-ai` (0.1.0-rc.8; 0.1.1-rc.2 uses the same level set). `thinkingFormat` is pinned to the live-schema snapshot in [`tests/fixtures/pi-ai-thinking-format-union.ts`](../../tests/fixtures/pi-ai-thinking-format-union.ts); effort keys stay a locally pinned whitelist. UI choices come only from the live schema union.
13
13
 
14
14
  ## What's in this directory
15
15
 
@@ -19,7 +19,7 @@ Canonical level names and `thinkingFormat` values follow `@deepseek-ai/dsh-llm-p
19
19
  | [`efforts.ts`](./efforts.ts) | `reasoningEfforts` read/write, Off tri-state, validation (empty object / Off-only / unknown keys return an error code, never throw). |
20
20
  | [`presets.ts`](./presets.ts) | DeepSeek, OpenAI, and on/off presets; each states all three dialect keys; spread onto existing model rows and route `compat`. |
21
21
  | [`path-ops.ts`](./path-ops.ts) | Same one-level key diff as the official Models page; `buildSaveOps` uses `settingsPath` and only submits that route’s full `models` table and dirty `compat` keys. |
22
- | [`drafts.ts`](./drafts.ts) | User-layer drafts, dirty aligned with pathOps, post-save revision bump, dirty merge, generation. |
22
+ | [`drafts.ts`](./drafts.ts) | User-layer drafts, dirty aligned with pathOps, post-save revision. On refresh, latest user-layer model membership wins; unsaved `reasoningEfforts` (including a cleared key) overlay by id; `compat` is a per-key 3-way merge. Conflict only when a locally dirty field also moved in originals (revision-only bumps and sibling-card saves do not warn). |
23
23
  | [`paths.ts`](./paths.ts) | Nested reads; clones of objects and model tables (non-object rows skipped). |
24
24
  | [`filter.ts`](./filter.ts) | Which routes are editable: hand-declared `llm-pi-ai` + openai-completions; skip catalog and official DeepSeek. |
25
25
  | [`validate.ts`](./validate.ts) | Per-row `reasoningEfforts` validation. |
@@ -9,7 +9,7 @@
9
9
 
10
10
  本插件的领域逻辑:无 React、无 Cordis 服务实例。设置页与 Vitest 都只依赖这些纯函数,因此档位语义可以在不启动 DSH 的情况下回归。
11
11
 
12
- 规范键名与 `thinkingFormat` 取值对齐 `@deepseek-ai/dsh-llm-pi-ai`(0.1.0-rc.8)。仓库内有测试钉白名单;UI 可选项只来自现场 schema union。
12
+ 规范键名与 `thinkingFormat` 取值对齐 `@deepseek-ai/dsh-llm-pi-ai`(0.1.0-rc.8;0.1.1-rc.2 档位集合相同)。`thinkingFormat` 由 [`tests/fixtures/pi-ai-thinking-format-union.ts`](../../tests/fixtures/pi-ai-thinking-format-union.ts) 钉现场 schema;档位键由测试钉本地白名单。UI 可选项只来自现场 schema union。
13
13
 
14
14
  ## 这个目录里有什么
15
15
 
@@ -19,7 +19,7 @@
19
19
  | [`efforts.ts`](./efforts.ts) | `reasoningEfforts` 读写、Off 三态、校验(空对象 / 只开 Off / 未知键返回错误码,不抛异常)。 |
20
20
  | [`presets.ts`](./presets.ts) | DeepSeek、OpenAI、仅开/关三套预设;每套对三个方言键都表态;spread 到已有模型行与路由 `compat`。 |
21
21
  | [`path-ops.ts`](./path-ops.ts) | 与官方模型页相同的一层键 diff;`buildSaveOps` 使用 `settingsPath`,只提交该路由的 `models` 整表和有差异的 `compat` 键。 |
22
- | [`drafts.ts`](./drafts.ts) | 用户层草稿、dirty 与 pathOps 一致、保存后 revision、脏卡合并、generation。 |
22
+ | [`drafts.ts`](./drafts.ts) | 用户层草稿、dirty 与 pathOps 一致、保存后 revision。刷新时以最新用户层模型名单为成员真理,按 id 贴回未保存的 `reasoningEfforts`(含已清除=删键);`compat` 按键三路合并。冲突仅当本地脏字段的 originals 也变了(只 bump revision 或别的卡保存不报)。 |
23
23
  | [`paths.ts`](./paths.ts) | 嵌套读取、对象与模型表的 clone(非对象行跳过)。 |
24
24
  | [`filter.ts`](./filter.ts) | 哪些路由可编辑:手工 `llm-pi-ai` + openai-completions;排除 catalog 与官方 DeepSeek。 |
25
25
  | [`validate.ts`](./validate.ts) | 对单行 `reasoningEfforts` 调用校验。 |
@@ -3,9 +3,11 @@
3
3
  *
4
4
  * Levels match `@deepseek-ai/dsh-llm-pi-ai` catalog.ts `THINKING_LEVELS`.
5
5
  * Formats match `SUPPORTED_THINKING_FORMATS` in the same file (rc.8).
6
- * Tests pin these lists against llm-pi-ai. The settings page never offers
7
- * the handwritten thinkingFormat list as writable choices — only the live
8
- * schema union, plus a stored value that the union omitted.
6
+ * Tests pin these lists against a checked-in schema fixture
7
+ * (`tests/fixtures/pi-ai-thinking-format-union.ts`) and the local level
8
+ * whitelist. The settings page never offers the handwritten thinkingFormat
9
+ * list as writable choices — only the live schema union, plus a stored
10
+ * value that the union omitted.
9
11
  */
10
12
 
11
13
  /** Selectable reasoning levels, in pi-ai escalation order. */
@@ -22,6 +22,16 @@ export interface RouteDraft {
22
22
  compatPresent: boolean
23
23
  }
24
24
 
25
+ /** JSON-stable equality matching pathOps (key order included). */
26
+ export function sliceEqual(left: unknown, right: unknown): boolean {
27
+ return JSON.stringify(left) === JSON.stringify(right)
28
+ }
29
+
30
+ /** Whether two settings slices differ. */
31
+ export function sliceChanged(before: unknown, after: unknown): boolean {
32
+ return !sliceEqual(before, after)
33
+ }
34
+
25
35
  /** Build a draft from the stored user subtree (never from effective `value`). */
26
36
  export function routeDraftFromUserProfile(args: {
27
37
  provider: string
@@ -89,9 +99,132 @@ export function applySaveSuccess(
89
99
  })
90
100
  }
91
101
 
102
+ function modelRowId(row: Record<string, unknown>): string {
103
+ return String(row.id)
104
+ }
105
+
106
+ function indexById(rows: readonly Record<string, unknown>[]): Map<string, Record<string, unknown>> {
107
+ const map = new Map<string, Record<string, unknown>>()
108
+ for (const row of rows) {
109
+ const id = modelRowId(row)
110
+ if (!map.has(id)) map.set(id, row)
111
+ }
112
+ return map
113
+ }
114
+
115
+ function effortsPresence(row: Record<string, unknown> | undefined): { present: boolean; value: unknown } {
116
+ if (row === undefined || !Object.hasOwn(row, 'reasoningEfforts')) {
117
+ return { present: false, value: undefined }
118
+ }
119
+ return { present: true, value: row.reasoningEfforts }
120
+ }
121
+
122
+ function effortsEqual(
123
+ left: { present: boolean; value: unknown },
124
+ right: { present: boolean; value: unknown },
125
+ ): boolean {
126
+ if (left.present !== right.present) return false
127
+ if (!left.present) return true
128
+ return sliceEqual(left.value, right.value)
129
+ }
130
+
131
+ function overlayLocalEfforts(
132
+ incomingRow: Record<string, unknown>,
133
+ prevRow: Record<string, unknown>,
134
+ ): Record<string, unknown> {
135
+ const next = structuredClone(incomingRow)
136
+ if (Object.hasOwn(prevRow, 'reasoningEfforts')) {
137
+ next.reasoningEfforts = structuredClone(prevRow.reasoningEfforts)
138
+ } else {
139
+ delete next.reasoningEfforts
140
+ }
141
+ return next
142
+ }
143
+
144
+ function objectKeyChanged(
145
+ left: Record<string, unknown>,
146
+ right: Record<string, unknown>,
147
+ key: string,
148
+ ): boolean {
149
+ const leftHas = Object.hasOwn(left, key)
150
+ const rightHas = Object.hasOwn(right, key)
151
+ if (leftHas !== rightHas) return true
152
+ if (!leftHas) return false
153
+ return sliceChanged(left[key], right[key])
154
+ }
155
+
156
+ /**
157
+ * Membership follows the latest user-layer models list (Models page add/delete).
158
+ * Local unsaved `reasoningEfforts` (including a cleared key) overlay by id.
159
+ */
160
+ export function mergeModelsById(args: {
161
+ prevModels: readonly Record<string, unknown>[]
162
+ prevOriginal: readonly Record<string, unknown>[]
163
+ incomingModels: readonly Record<string, unknown>[]
164
+ incomingOriginal: readonly Record<string, unknown>[]
165
+ }): { models: Record<string, unknown>[]; conflicted: boolean } {
166
+ const prevById = indexById(args.prevModels)
167
+ const prevOrigById = indexById(args.prevOriginal)
168
+ const incomingOrigById = indexById(args.incomingOriginal)
169
+ const incomingIds = new Set(args.incomingModels.map(modelRowId))
170
+ let conflicted = false
171
+
172
+ const models = args.incomingModels.map((incomingRow) => {
173
+ const id = modelRowId(incomingRow)
174
+ const prevRow = prevById.get(id)
175
+ if (prevRow === undefined) return structuredClone(incomingRow)
176
+ const prevOrig = prevOrigById.get(id)
177
+ const localDirty = !effortsEqual(effortsPresence(prevRow), effortsPresence(prevOrig))
178
+ if (!localDirty) return structuredClone(incomingRow)
179
+ const incomingOrig = incomingOrigById.get(id)
180
+ if (!effortsEqual(effortsPresence(prevOrig), effortsPresence(incomingOrig))) {
181
+ conflicted = true
182
+ }
183
+ return overlayLocalEfforts(incomingRow, prevRow)
184
+ })
185
+
186
+ for (const [id, prevRow] of prevById) {
187
+ if (incomingIds.has(id)) continue
188
+ const prevOrig = prevOrigById.get(id)
189
+ if (effortsEqual(effortsPresence(prevRow), effortsPresence(prevOrig))) continue
190
+ if (!effortsEqual(effortsPresence(prevOrig), effortsPresence(incomingOrigById.get(id)))) {
191
+ conflicted = true
192
+ }
193
+ }
194
+
195
+ return { models, conflicted }
196
+ }
197
+
92
198
  /**
93
- * Apply a freshly loaded table. Dirty cards keep models/compat; originals and
94
- * revision follow the incoming snapshot so a later save is against the new user layer.
199
+ * Three-way compat merge: locally changed keys stay local; everything else
200
+ * follows incoming. Conflict only when a locally dirty key also moved in originals.
201
+ */
202
+ export function mergeCompat(args: {
203
+ prev: Record<string, unknown>
204
+ prevOriginal: Record<string, unknown>
205
+ incoming: Record<string, unknown>
206
+ incomingOriginal: Record<string, unknown>
207
+ }): { compat: Record<string, unknown>; conflicted: boolean } {
208
+ if (!sliceChanged(args.prev, args.prevOriginal)) {
209
+ return { compat: cloneObject(args.incoming), conflicted: false }
210
+ }
211
+ const compat = cloneObject(args.incoming)
212
+ let conflicted = false
213
+ const keys = new Set([...Object.keys(args.prev), ...Object.keys(args.prevOriginal)])
214
+ for (const key of keys) {
215
+ if (!objectKeyChanged(args.prev, args.prevOriginal, key)) continue
216
+ if (objectKeyChanged(args.prevOriginal, args.incomingOriginal, key)) conflicted = true
217
+ if (Object.hasOwn(args.prev, key)) compat[key] = structuredClone(args.prev[key])
218
+ else delete compat[key]
219
+ }
220
+ return { compat, conflicted }
221
+ }
222
+
223
+ /**
224
+ * Apply a freshly loaded table. Membership and metadata follow incoming;
225
+ * unsaved reasoningEfforts / dirty compat keys overlay by id. Conflict only
226
+ * when a locally dirty field also changed in originals (revision-only bumps
227
+ * and sibling-card saves do not warn).
95
228
  */
96
229
  export function mergeLoadedDrafts(
97
230
  current: readonly RouteDraft[],
@@ -103,13 +236,27 @@ export function mergeLoadedDrafts(
103
236
  const drafts = incoming.map((next) => {
104
237
  const prev = currentByProvider.get(next.provider)
105
238
  if (prev === undefined || !options.preserveDirty || !draftDirty(prev)) return next
106
- conflicted.push(next.provider)
239
+ const modelsMerge = mergeModelsById({
240
+ prevModels: prev.models,
241
+ prevOriginal: prev.originalModels,
242
+ incomingModels: next.models,
243
+ incomingOriginal: next.originalModels,
244
+ })
245
+ const compatMerge = mergeCompat({
246
+ prev: prev.compat,
247
+ prevOriginal: prev.originalCompat,
248
+ incoming: next.compat,
249
+ incomingOriginal: next.originalCompat,
250
+ })
251
+ if (modelsMerge.conflicted || compatMerge.conflicted) conflicted.push(next.provider)
107
252
  return {
108
- ...prev,
253
+ provider: next.provider,
109
254
  displayName: next.displayName,
110
255
  settingsPath: next.settingsPath,
111
256
  revision: next.revision,
257
+ models: modelsMerge.models,
112
258
  originalModels: cloneModels(next.originalModels),
259
+ compat: compatMerge.compat,
113
260
  originalCompat: cloneObject(next.originalCompat),
114
261
  compatPresent: next.compatPresent,
115
262
  }