dsh-plugin-effort-declare 0.1.1 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CONTRIBUTING.en.md +5 -1
- package/CONTRIBUTING.md +5 -1
- package/INSTALL.en.md +1 -1
- package/INSTALL.md +1 -1
- package/README.en.md +8 -7
- package/README.md +8 -7
- package/lib/client.js +389 -65
- package/lib/client.js.map +1 -1
- package/lib/types/client/EffortDeclareSection.d.ts +12 -2
- package/lib/types/client/EffortDeclareSection.d.ts.map +1 -1
- package/lib/types/client/build-info.d.ts +2 -0
- package/lib/types/client/build-info.d.ts.map +1 -0
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/load-drafts.d.ts +36 -6
- package/lib/types/client/load-drafts.d.ts.map +1 -1
- package/lib/types/client/locales.d.ts +1 -1
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/core/attribution.d.ts +13 -0
- package/lib/types/core/attribution.d.ts.map +1 -0
- package/lib/types/core/catalog.d.ts +5 -3
- package/lib/types/core/catalog.d.ts.map +1 -1
- package/lib/types/core/drafts.d.ts +34 -2
- package/lib/types/core/drafts.d.ts.map +1 -1
- package/lib/types/core/efforts.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/README.en.md +1 -1
- package/src/README.md +1 -1
- package/src/client/EffortDeclareSection.tsx +158 -46
- package/src/client/README.en.md +5 -3
- package/src/client/README.md +5 -3
- package/src/client/build-info.ts +12 -0
- package/src/client/effort-declare.module.css +8 -0
- package/src/client/globals.d.ts +5 -0
- package/src/client/index.ts +14 -9
- package/src/client/load-drafts.ts +124 -8
- package/src/client/locales.ts +3 -0
- package/src/core/README.en.md +4 -3
- package/src/core/README.md +4 -3
- package/src/core/attribution.ts +24 -0
- package/src/core/catalog.ts +5 -3
- package/src/core/drafts.ts +151 -4
- package/src/core/efforts.ts +4 -1
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Load editable route drafts from llm.providers + the llm-pi-ai namespace.
|
|
3
3
|
* Drafts come from the user layer; route protocol classification may use effective value.
|
|
4
|
+
*
|
|
5
|
+
* First paint uses `ensure()` (idle-only). Refresh never calls `ensure()`:
|
|
6
|
+
* wait until the mirror subscribe shows a namespace revision at least as new
|
|
7
|
+
* as the Host event, then `getSnapshot()`. Own mutate echoes are identified
|
|
8
|
+
* by revision (including older delayed echoes), not ignored as a generic
|
|
9
|
+
* document-updated.
|
|
4
10
|
*/
|
|
5
11
|
import type { IApiClient, SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client'
|
|
6
12
|
import type { SettingsDescribeFace } from '@deepseek-ai/dsh-client-ui-settings/client'
|
|
@@ -25,19 +31,115 @@ export interface LoadDraftsResult {
|
|
|
25
31
|
error?: string
|
|
26
32
|
}
|
|
27
33
|
|
|
34
|
+
export type LoadDraftsMode = 'ensure' | 'snapshot'
|
|
35
|
+
|
|
36
|
+
type MirrorSnapshot = ReturnType<SettingsDescribeFace['getSnapshot']>
|
|
37
|
+
type MirrorDescribe = Pick<SettingsDescribeFace, 'getSnapshot' | 'subscribe'>
|
|
38
|
+
|
|
39
|
+
/** Namespace revision on a describe snapshot, if that row exists. */
|
|
40
|
+
export function namespaceRevision(snapshot: MirrorSnapshot, ns: string): number | undefined {
|
|
41
|
+
return snapshot.view?.namespaces.find(view => view.ns === ns)?.revision
|
|
42
|
+
}
|
|
43
|
+
|
|
28
44
|
/**
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
* `formats` is the live schema union only. Empty means the dropdown has no
|
|
33
|
-
* writable choices (stored values stay visible via `thinkingFormatChoices`).
|
|
45
|
+
* True when `incoming` is the Host echo of a mutate this page already folded,
|
|
46
|
+
* or an older revision the snapshot has already passed. `echoed` is undefined
|
|
47
|
+
* until the first successful write.
|
|
34
48
|
*/
|
|
35
|
-
export
|
|
49
|
+
export function isOwnDocumentEcho(echoed: number | undefined, incoming: number): boolean {
|
|
50
|
+
return echoed !== undefined && incoming <= echoed
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* After a preserve-dirty reload: conflicted cards get a conflict notice;
|
|
55
|
+
* live cards drop leftover conflict/error; saved notices stay; gone cards drop.
|
|
56
|
+
*/
|
|
57
|
+
export function foldReloadNotices<T extends { kind: string }>(
|
|
58
|
+
current: Record<string, T>,
|
|
59
|
+
args: {
|
|
60
|
+
conflicted: readonly string[]
|
|
61
|
+
conflictNotice: T
|
|
62
|
+
liveProviders: readonly string[]
|
|
63
|
+
},
|
|
64
|
+
): Record<string, T> {
|
|
65
|
+
const live = new Set(args.liveProviders)
|
|
66
|
+
const conflicted = new Set(args.conflicted)
|
|
67
|
+
const next: Record<string, T> = {}
|
|
68
|
+
for (const [provider, notice] of Object.entries(current)) {
|
|
69
|
+
if (!live.has(provider)) continue
|
|
70
|
+
if (conflicted.has(provider)) continue
|
|
71
|
+
if (notice.kind === 'conflict' || notice.kind === 'error') continue
|
|
72
|
+
next[provider] = notice
|
|
73
|
+
}
|
|
74
|
+
for (const provider of args.conflicted) {
|
|
75
|
+
next[provider] = args.conflictNotice
|
|
76
|
+
}
|
|
77
|
+
return next
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function waitUntil(
|
|
81
|
+
describe: MirrorDescribe,
|
|
82
|
+
predicate: () => boolean,
|
|
83
|
+
signal?: AbortSignal,
|
|
84
|
+
): Promise<boolean> {
|
|
85
|
+
if (signal?.aborted) return Promise.resolve(false)
|
|
86
|
+
if (predicate()) return Promise.resolve(true)
|
|
87
|
+
return new Promise((resolve) => {
|
|
88
|
+
let settled = false
|
|
89
|
+
const finish = (ok: boolean) => {
|
|
90
|
+
if (settled) return
|
|
91
|
+
settled = true
|
|
92
|
+
stop()
|
|
93
|
+
signal?.removeEventListener('abort', onAbort)
|
|
94
|
+
resolve(ok)
|
|
95
|
+
}
|
|
96
|
+
const onAbort = () => { finish(false) }
|
|
97
|
+
const stop = describe.subscribe(() => {
|
|
98
|
+
if (predicate()) finish(true)
|
|
99
|
+
})
|
|
100
|
+
signal?.addEventListener('abort', onAbort)
|
|
101
|
+
if (predicate()) finish(true)
|
|
102
|
+
else if (signal?.aborted) finish(false)
|
|
103
|
+
})
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Resolve when the mirror's namespace revision is at least `revision`, or abort. */
|
|
107
|
+
export async function waitForNamespaceRevision(
|
|
108
|
+
describe: MirrorDescribe,
|
|
109
|
+
ns: string,
|
|
110
|
+
revision: number,
|
|
111
|
+
signal?: AbortSignal,
|
|
112
|
+
): Promise<'matched' | 'aborted'> {
|
|
113
|
+
const matched = await waitUntil(
|
|
114
|
+
describe,
|
|
115
|
+
() => {
|
|
116
|
+
const current = namespaceRevision(describe.getSnapshot(), ns)
|
|
117
|
+
return current !== undefined && current >= revision
|
|
118
|
+
},
|
|
119
|
+
signal,
|
|
120
|
+
)
|
|
121
|
+
return matched ? 'matched' : 'aborted'
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Resolve when the namespace revision differs from `previous`, or abort. */
|
|
125
|
+
export async function waitForNamespaceRevisionChange(
|
|
126
|
+
describe: MirrorDescribe,
|
|
127
|
+
ns: string,
|
|
128
|
+
previous: number,
|
|
129
|
+
signal?: AbortSignal,
|
|
130
|
+
): Promise<'changed' | 'aborted'> {
|
|
131
|
+
const changed = await waitUntil(describe, () => {
|
|
132
|
+
const current = namespaceRevision(describe.getSnapshot(), ns)
|
|
133
|
+
return current !== undefined && current !== previous
|
|
134
|
+
}, signal)
|
|
135
|
+
return changed ? 'changed' : 'aborted'
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function assembleDrafts(
|
|
36
139
|
api: Pick<IApiClient, 'llm'>,
|
|
37
|
-
describe: Pick<SettingsDescribeFace, '
|
|
140
|
+
describe: Pick<SettingsDescribeFace, 'getSnapshot'>,
|
|
38
141
|
schema: SchemaOps,
|
|
39
142
|
): Promise<LoadDraftsResult> {
|
|
40
|
-
await describe.ensure()
|
|
41
143
|
const mirrored = describe.getSnapshot()
|
|
42
144
|
if (mirrored.view === undefined) {
|
|
43
145
|
return { writable: false, formats: [], drafts: [], error: mirrored.error ?? undefined }
|
|
@@ -83,3 +185,17 @@ export async function loadDrafts(
|
|
|
83
185
|
}
|
|
84
186
|
return { writable: mirrored.view.writable, formats, drafts }
|
|
85
187
|
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* `ensure`: first paint / idle recovery (official ensure only reads from idle).
|
|
191
|
+
* `snapshot`: refresh after the mirror revision already moved — do not ensure.
|
|
192
|
+
*/
|
|
193
|
+
export async function loadDrafts(
|
|
194
|
+
api: Pick<IApiClient, 'llm'>,
|
|
195
|
+
describe: Pick<SettingsDescribeFace, 'ensure' | 'getSnapshot'>,
|
|
196
|
+
schema: SchemaOps,
|
|
197
|
+
mode: LoadDraftsMode = 'ensure',
|
|
198
|
+
): Promise<LoadDraftsResult> {
|
|
199
|
+
if (mode === 'ensure') await describe.ensure()
|
|
200
|
+
return assembleDrafts(api, describe, schema)
|
|
201
|
+
}
|
package/src/client/locales.ts
CHANGED
|
@@ -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.',
|
package/src/core/README.en.md
CHANGED
|
@@ -9,19 +9,20 @@
|
|
|
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).
|
|
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
|
|
|
16
16
|
| File | Role |
|
|
17
17
|
| --- | --- |
|
|
18
18
|
| [`catalog.ts`](./catalog.ts) | Canonical level order, `thinkingFormat` fallback list, `llm-pi-ai` / DeepSeek constants. |
|
|
19
|
-
| [`efforts.ts`](./efforts.ts) | `reasoningEfforts` read/write, Off tri-state, validation (empty object / Off-only / unknown keys return an error code, never throw). |
|
|
19
|
+
| [`efforts.ts`](./efforts.ts) | `reasoningEfforts` read/write, Off tri-state (value mode stores `trim()`), 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
|
|
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. |
|
|
26
|
+
| [`attribution.ts`](./attribution.ts) | Settings footer line: `version © year Stardust`. Start year is 2026; end year is the UTC year at pack time, not the user’s clock when DSH starts. |
|
|
26
27
|
|
|
27
28
|
An **absent** field means default-off. Do not encode “undeclared” as `reasoningEfforts: false` — in official semantics `false` strips reasoning from a catalog model.
|
package/src/core/README.md
CHANGED
|
@@ -9,19 +9,20 @@
|
|
|
9
9
|
|
|
10
10
|
本插件的领域逻辑:无 React、无 Cordis 服务实例。设置页与 Vitest 都只依赖这些纯函数,因此档位语义可以在不启动 DSH 的情况下回归。
|
|
11
11
|
|
|
12
|
-
规范键名与 `thinkingFormat` 取值对齐 `@deepseek-ai/dsh-llm-pi-ai`(0.1.0-rc.8
|
|
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
|
|
|
16
16
|
| 文件 | 说明 |
|
|
17
17
|
| --- | --- |
|
|
18
18
|
| [`catalog.ts`](./catalog.ts) | 规范档位顺序、`thinkingFormat` 回退列表、`llm-pi-ai` / DeepSeek 相关常量。 |
|
|
19
|
-
| [`efforts.ts`](./efforts.ts) | `reasoningEfforts` 读写、Off
|
|
19
|
+
| [`efforts.ts`](./efforts.ts) | `reasoningEfforts` 读写、Off 三态(value 模式写入 `trim()` 后的字符串)、校验(空对象 / 只开 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
|
|
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` 调用校验。 |
|
|
26
|
+
| [`attribution.ts`](./attribution.ts) | 设置页页脚:`version © 年 Stardust`。首年写死 2026,结束年由打包时的 UTC 年注入,不是用户打开 DSH 的日期。 |
|
|
26
27
|
|
|
27
28
|
**缺席字段**表示默认关闭。不要把「未声明」写成 `reasoningEfforts: false`——`false` 在官方语义里是从 catalog 模型上剥掉推理。
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin footer attribution. Version and end-year are frozen into the client
|
|
3
|
+
* bundle at pack time; this module only formats the line.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** First publication year (LICENSE). Not the user's wall clock. */
|
|
7
|
+
export const COPYRIGHT_FROM = 2026
|
|
8
|
+
|
|
9
|
+
export const COPYRIGHT_HOLDER = 'Stardust'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* `0.1.2 © 2026 Stardust` or `0.1.2 © 2026–2027 Stardust`.
|
|
13
|
+
* Throws if version is empty or `to < from` — a bad stamp must not render.
|
|
14
|
+
*/
|
|
15
|
+
export function formatAttribution(version: string, from: number, to: number): string {
|
|
16
|
+
if (version.trim() === '') {
|
|
17
|
+
throw new Error('plugin version must be a non-empty string')
|
|
18
|
+
}
|
|
19
|
+
if (!Number.isInteger(from) || !Number.isInteger(to) || to < from) {
|
|
20
|
+
throw new Error(`invalid copyright range: ${String(from)}\u2013${String(to)}`)
|
|
21
|
+
}
|
|
22
|
+
const years = to === from ? String(from) : `${String(from)}\u2013${String(to)}`
|
|
23
|
+
return `${version} \u00a9 ${years} ${COPYRIGHT_HOLDER}`
|
|
24
|
+
}
|
package/src/core/catalog.ts
CHANGED
|
@@ -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
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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. */
|
package/src/core/drafts.ts
CHANGED
|
@@ -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
|
-
*
|
|
94
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/core/efforts.ts
CHANGED
|
@@ -26,7 +26,10 @@ export function writeOff(
|
|
|
26
26
|
const next: ReasoningEfforts = { ...efforts }
|
|
27
27
|
delete next.off
|
|
28
28
|
if (mode === 'empty') next.off = null
|
|
29
|
-
else if (mode === 'value')
|
|
29
|
+
else if (mode === 'value') {
|
|
30
|
+
const trimmed = value.trim()
|
|
31
|
+
next.off = trimmed.length > 0 ? trimmed : 'none'
|
|
32
|
+
}
|
|
30
33
|
return next
|
|
31
34
|
}
|
|
32
35
|
|