dsh-model-groups 0.3.8 → 0.3.10

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-model-groups",
3
- "version": "0.3.8",
3
+ "version": "0.3.10",
4
4
  "description": "在 DSH 网页端按供应商和密钥配置选择模型,并为同一供应商的不同密钥分别设置模型和 API 协议。支持 DSH 0.2.2-rc.1。",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -38,7 +38,9 @@
38
38
  "!scripts/pi-ai-protocol.mjs",
39
39
  "cordis.patch.yml",
40
40
  "README.md",
41
+ "CHANGELOG.md",
41
42
  "docs/maintenance.md",
43
+ "docs/capabilities-beta.md",
42
44
  "docs/images/*.webp",
43
45
  "LICENSE"
44
46
  ],
@@ -0,0 +1,37 @@
1
+ import { applyModelCapabilities, validateCapabilityBatch, applyModelBatchCapabilities, materializeCapabilityEntry, applyCapabilityBatch, mergeCapabilityModels } from '../src/capability-defaults.js';
2
+
3
+ // Embedded into the verified host bundle. These functions modify drafts only.
4
+ export function GroupCapabilityBatch({ entry, onChange, disabled, t }) {
5
+ const migrated = materializeCapabilityEntry(entry);
6
+ const jsx = react_jsx_runtime.jsx, jsxs = react_jsx_runtime.jsxs;
7
+ const css = ModelsSection_module_css_default;
8
+ return jsxs('div', { 'data-group-capabilities': true, className: css.field, children: ['vision', 'reasoning'].map(key => jsxs('div', {
9
+ className: css.modelReasoningHead,
10
+ children: [jsx('span', { className: css.fieldLabel, children: t(key === 'vision' ? 'capabilityVision' : 'capabilityReasoning') }), jsx(_deepseek_ai_dsh_client_ui_primitives.Switch, {
11
+ 'data-capability-batch': key, label: t(key === 'vision' ? 'capabilityVision' : 'capabilityReasoning'), checked: migrated.capabilityBatch?.[key] === true, disabled,
12
+ onChange: enabled => { if (!disabled && typeof enabled === 'boolean') onChange(applyCapabilityBatch(entry, key, enabled)); }
13
+ })]
14
+ }, key)) });
15
+ }
16
+
17
+ export function addSettingsCapabilities(initial, apply) {
18
+ let source = initial;
19
+ const replace = (before, after) => { if (source.split(before).length !== 2) throw new Error('Expected unique capability batch anchor: ' + before.slice(0, 120)); apply(before, after); source = source.replace(before, after); };
20
+ const helpers = [applyModelCapabilities, validateCapabilityBatch, applyModelBatchCapabilities, materializeCapabilityEntry, applyCapabilityBatch, mergeCapabilityModels, GroupCapabilityBatch].map(fn => fn.toString()).join('\n');
21
+ replace('const MULTI_KEY_PROTOCOLS =', helpers + '\n\t\tconst MULTI_KEY_PROTOCOLS =');
22
+ replace('JSON.stringify(entry) !== JSON.stringify(saved)', 'JSON.stringify(materializeCapabilityEntry(entry)) !== JSON.stringify(materializeCapabilityEntry(saved))');
23
+ // Normalization materializes old beta policies when the existing save is used.
24
+ // Rendering retains raw entries for unchanged dirty/saved snapshot comparison.
25
+ replace('return Object.fromEntries(orderedKeyEntries(entries).map(([id, entry]) => {', 'return Object.fromEntries(orderedKeyEntries(entries).map(([id, rawEntry]) => {\n\t\t\t\tconst entry = materializeCapabilityEntry(rawEntry);');
26
+ const groupModel = source.indexOf('models: modelDrafts(entry.models), onChange: (models) => update(id, { models }),');
27
+ const groupStart = source.lastIndexOf('(0, react_jsx_runtime.jsx)(ModelListEditor, {', groupModel);
28
+ if (groupModel < 0 || groupStart < 0) throw new Error('Missing group model editor batch anchor');
29
+ const groupBefore = source.slice(groupStart, groupModel);
30
+ replace(groupBefore + 'models: modelDrafts(entry.models), onChange: (models) => update(id, { models }),',
31
+ '(0, react_jsx_runtime.jsx)(GroupCapabilityBatch, { entry, t, disabled: disabled || anyBusy, onChange: (next) => { if (!disabled && !anyBusy) onChange({ ...entries, [id]: next }); } }), ' + groupBefore + 'models: modelDrafts(materializeCapabilityEntry(entry).models), onChange: (models) => { if (!disabled && !anyBusy) onChange({ ...entries, [id]: mergeCapabilityModels(entry, models) }); },');
32
+ for (const [locale, labels] of Object.entries({ en: { capabilityVision: 'Image input', capabilityReasoning: 'Reasoning effort' }, zh: { capabilityVision: '识图', capabilityReasoning: '思考强度' } })) {
33
+ const text = Object.entries(labels).map(([key, value]) => `\t\t\t${key}: ${JSON.stringify(value)},\n`).join('');
34
+ replace(`const ${locale} = {\n`, `const ${locale} = {\n${text}`);
35
+ }
36
+ return source;
37
+ }
@@ -0,0 +1,49 @@
1
+ import { createSettingsReorder, useSettingsReorder } from './settings-reorder.mjs';
2
+ import { orderedKeyEntries } from '../src/hierarchy.js';
3
+
4
+ export function addSettingsReorder(initial, apply) {
5
+ let current = initial;
6
+ const replace = (before, after) => { apply(before, after); current = current.replace(before, after); };
7
+ const anchor = 'const MULTI_KEY_ENTRY_ID =';
8
+ replace(anchor, `${orderedKeyEntries.toString()}\n\t\t${createSettingsReorder.toString()}\n\t\t${useSettingsReorder.toString()}\n\t\t${anchor}`);
9
+ replace('children: [Object.entries(entries).map(([id, candidate], index) => {', 'children: [orderedKeyEntries(entries).map(([id, candidate], index) => {');
10
+ replace('return Object.fromEntries(Object.entries(entries).map(([id, entry]) => {', 'return Object.fromEntries(orderedKeyEntries(entries).map(([id, entry]) => {');
11
+ replace('const anyBusy = Object.values(busyEntries).some(Boolean);', `const anyBusy = Object.values(busyEntries).some(Boolean);
12
+ \t\t\tconst savedEntryOrder = orderedKeyEntries(props.savedEntries ?? props.persistedEntries ?? {}).map(([id]) => id);
13
+ \t\t\tconst reorder = useSettingsReorder({ disabled: disabled || anyBusy, selector: "[data-key-entry-id]", move: (id, offset) => {
14
+ \t\t\t\tconst ordered = orderedKeyEntries(entries), from = ordered.findIndex(([key]) => key === id), to = from + offset;
15
+ \t\t\t\tif (from < 0 || to < 0 || to >= ordered.length) return;
16
+ \t\t\t\tconst [moved] = ordered.splice(from, 1); ordered.splice(to, 0, moved);
17
+ \t\t\t\tonChange(Object.fromEntries(ordered.map(([key, entry], sortOrder) => [key, { ...entry, sortOrder }])));
18
+ \t\t\t} });`);
19
+ replace('const dirty = multiKeyEntryDirty(entry, props.savedEntries?.[id] ?? props.persistedEntries?.[id], draft);', 'const dirty = savedEntryOrder.indexOf(id) !== index || multiKeyEntryDirty(entry, props.savedEntries?.[id] ?? props.persistedEntries?.[id], draft);');
20
+ const before = 'children: [(0, react_jsx_runtime.jsx)("span", {\n\t\t\t\t\t\t\t\tclassName: ModelsSection_module_css_default["rowName"],';
21
+ replace(before, `children: [(0, react_jsx_runtime.jsx)("button", {
22
+ \t\t\t\t\t\t\t\ttype: "button", className: ModelsSection_module_css_default["iconButton"],
23
+ \t\t\t\t\t\t\t\t"data-key-entry-handle": id, "aria-label": t("multiKeyReorder") + " " + heading,
24
+ \t\t\t\t\t\t\t\t"aria-keyshortcuts": "ArrowUp ArrowDown", "aria-description": t("multiKeyReorderHelp"), title: t("multiKeyReorderHelp"),
25
+ \t\t\t\t\t\t\t\tdisabled: disabled || anyBusy, ...reorder(id),
26
+ \t\t\t\t\t\t\t\tchildren: (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconDragHandleOutlineRegular, { size: 14 })
27
+ \t\t\t\t\t\t\t}), (0, react_jsx_runtime.jsx)("span", {
28
+ \t\t\t\t\t\t\t\tclassName: ModelsSection_module_css_default["rowName"],`);
29
+ // ModelRow keeps the native useModelRows onMove action, moving row-local drafts
30
+ // and stable keys together. The pointer preview never mutates React-owned DOM.
31
+ replace('const preview = (0, _deepseek_ai_dsh_client_ui_primitives.useRowDragPreview)(ModelsSection_module_css_default["modelDragPreview"]);', 'const reorder = useSettingsReorder({ disabled, selector: "[data-row-key]", move: (_key, offset) => props.order.onMove(offset) });');
32
+ const start = current.indexOf('\n\t\t\t\tonDragOver: (event) => {', current.indexOf('function ModelRow(props)'));
33
+ const end = current.indexOf('\n\t\t\t\tchildren:', start);
34
+ if (start < 0 || end < start) throw new Error('Missing model drag row anchor');
35
+ replace(current.slice(start, end), '\n\t\t\t\t// Reordering is handled by the pointer handle below.');
36
+ const dragStart = current.indexOf('\n\t\t\t\t\t\t\t\tdraggable: !disabled,', current.indexOf('function ModelRow(props)'));
37
+ const dragEnd = current.indexOf('\n\t\t\t\t\t\t\t\tchildren:', dragStart);
38
+ if (dragStart < 0 || dragEnd < dragStart) throw new Error('Missing model drag handle anchor');
39
+ replace(current.slice(dragStart, dragEnd), '\n\t\t\t\t\t\t\t\t...reorder(props.rowKey),');
40
+ replace('label: t("reorderModelHelp"),', 'label: t("modelReorderTouchHelp"),');
41
+ replace('"aria-description": t("reorderModelHelp"),', '"aria-description": t("modelReorderTouchHelp"),');
42
+ for (const [locale, values] of Object.entries({
43
+ en: { multiKeyReorder: 'Reorder key group', multiKeyReorderHelp: 'Hold the handle to reorder; or use Arrow Up/Down. Save with the other edits.' },
44
+ zh: { multiKeyReorder: '调整密钥分组顺序', multiKeyReorderHelp: '长按手柄拖动,或用上下方向键调整;与其他改动一起保存。' }
45
+ })) {
46
+ const text = Object.entries({ ...values, modelReorderTouchHelp: values.multiKeyReorderHelp }).map(([key, value]) => `\t\t\t${key}: ${JSON.stringify(value)},\n`).join('');
47
+ replace(`const ${locale} = {\n`, `const ${locale} = {\n${text}`);
48
+ }
49
+ }
@@ -0,0 +1,170 @@
1
+ // Serialized into the hash-verified HOST settings patch by its generator.
2
+ // No configuration or credentials are written here: commit changes one draft.
3
+ export function createSettingsReorder({ handle, row, selector, commit, delay = 400 }) {
4
+ const doc = handle.ownerDocument;
5
+ const win = doc.defaultView;
6
+ const list = row.parentElement;
7
+ const rows = () => [...list.querySelectorAll(selector)].filter(item => item.parentElement === list);
8
+ const original = rows();
9
+ let active = false, stopped = false, timer, frame, target, half;
10
+ let x, y, startX, startY, pointerId;
11
+ const oldOutline = row.style.outline;
12
+ const oldOpacity = row.style.opacity;
13
+ let targetShadow, marker;
14
+ function clearTarget() {
15
+ marker?.remove(); marker = undefined;
16
+ if (target) target.style.boxShadow = targetShadow;
17
+ target = undefined;
18
+ }
19
+ function locate() {
20
+ clearTarget();
21
+ const bounds = list.getBoundingClientRect();
22
+ if (x < bounds.left - 40 || x > bounds.right + 40) return;
23
+ const candidates = rows().filter(item => item !== row && item.getBoundingClientRect().height > 0);
24
+ target = candidates.find(item => y < item.getBoundingClientRect().bottom) ?? candidates.at(-1);
25
+ if (!target) return;
26
+ const rect = target.getBoundingClientRect();
27
+ half = y < rect.top + rect.height / 2 ? 'before' : 'after';
28
+ targetShadow = target.style.boxShadow;
29
+ target.style.boxShadow = `inset 0 ${half === 'before' ? 3 : -3}px 0 #3987ff`;
30
+ // A viewport overlay stays above card borders and does not move the rows.
31
+ const edge = half === 'before' ? rect.top : rect.bottom;
32
+ if (edge < 0 || edge > win.innerHeight) return;
33
+ const visibleEdge = Math.max(4, Math.min(win.innerHeight - 4, edge));
34
+ marker = doc.createElement('div');
35
+ marker.setAttribute('data-settings-reorder-marker', '');
36
+ marker.setAttribute('aria-hidden', 'true');
37
+ Object.assign(marker.style, {
38
+ position: 'fixed', pointerEvents: 'none', zIndex: '2147483647',
39
+ left: `${Math.max(8, bounds.left)}px`, right: `${Math.max(8, win.innerWidth - bounds.right)}px`,
40
+ top: `${visibleEdge - 2}px`, height: '4px', borderRadius: '4px',
41
+ background: '#3987ff', boxShadow: '0 0 0 2px #fff, 0 2px 8px #0008'
42
+ });
43
+ const label = doc.createElement('span');
44
+ label.textContent = doc.documentElement.lang?.startsWith('en') ? 'Release to place here' : '松手后放在这里';
45
+ Object.assign(label.style, {
46
+ position: 'absolute', right: '4px', top: edge < 44 ? '8px' : '-30px',
47
+ padding: '4px 10px', borderRadius: '6px', color: '#fff', background: '#175fc9',
48
+ font: 'bold 13px/20px sans-serif', whiteSpace: 'nowrap', boxShadow: '0 2px 8px #0005'
49
+ });
50
+ marker.appendChild(label); doc.body.appendChild(marker);
51
+ }
52
+ const scrollParent = () => {
53
+ for (let node = list; node && node !== doc.documentElement; node = node.parentElement) {
54
+ if (node.scrollHeight > node.clientHeight && /auto|scroll/.test(win.getComputedStyle(node).overflowY)) return node;
55
+ }
56
+ return doc.scrollingElement;
57
+ };
58
+ function tick() {
59
+ if (!active || stopped) return;
60
+ const scroll = scrollParent();
61
+ if (scroll) {
62
+ const viewport = scroll === doc.scrollingElement;
63
+ const rect = viewport ? { top: 0, bottom: win.innerHeight } : scroll.getBoundingClientRect();
64
+ const top = Math.max(0, rect.top), bottom = Math.min(win.innerHeight, rect.bottom);
65
+ const speed = y < top + 56 ? -Math.min(14, (top + 56 - y) / 3) : y > bottom - 56 ? Math.min(14, (y - bottom + 56) / 3) : 0;
66
+ if (speed) { scroll.scrollTop += speed; locate(); }
67
+ }
68
+ frame = win.requestAnimationFrame(tick);
69
+ }
70
+ function cancel() { finish(false); }
71
+ function move(event) {
72
+ if (event.pointerId !== pointerId) { cancel(); return; }
73
+ x = event.clientX; y = event.clientY;
74
+ if (!active) {
75
+ // A swipe before activation is never interpreted as a reorder.
76
+ if (Math.hypot(x - startX, y - startY) > 10) cancel();
77
+ return;
78
+ }
79
+ event.preventDefault();
80
+ locate();
81
+ }
82
+ function up(event) {
83
+ if (event.pointerId !== pointerId) return;
84
+ if (Number.isFinite(event.clientX)) x = event.clientX;
85
+ if (Number.isFinite(event.clientY)) y = event.clientY;
86
+ if (active) locate();
87
+ finish(true);
88
+ }
89
+ function escape(event) { if (event.key === 'Escape') { event.preventDefault(); cancel(); } }
90
+ function context(event) { event.preventDefault(); }
91
+ function finish(save) {
92
+ if (stopped) return;
93
+ stopped = true;
94
+ win.clearTimeout(timer); win.cancelAnimationFrame(frame);
95
+ doc.removeEventListener('pointermove', move, true);
96
+ doc.removeEventListener('pointerup', up, true);
97
+ doc.removeEventListener('pointercancel', cancel, true);
98
+ doc.removeEventListener('pointerdown', secondPointer, true);
99
+ doc.removeEventListener('keydown', escape, true);
100
+ doc.removeEventListener('contextmenu', context, true);
101
+ win.removeEventListener('blur', cancel);
102
+ doc.removeEventListener('visibilitychange', cancel);
103
+ const destination = target;
104
+ clearTarget(); row.style.outline = oldOutline; row.style.opacity = oldOpacity;
105
+ if (handle.hasPointerCapture?.(pointerId)) handle.releasePointerCapture(pointerId);
106
+ const current = rows();
107
+ // Do not apply an offset to a list changed by another control while held.
108
+ if (!save || !active || !destination || current.length !== original.length || current.some((item, index) => item !== original[index])) return;
109
+ const from = current.indexOf(row), at = current.indexOf(destination);
110
+ const insertion = at + (half === 'after' ? 1 : 0);
111
+ const to = insertion > from ? insertion - 1 : insertion;
112
+ if (from >= 0 && to >= 0 && to !== from) commit(to - from);
113
+ }
114
+ function secondPointer(event) { if (event.pointerId !== pointerId) cancel(); }
115
+ return {
116
+ start(event) {
117
+ if (event.button !== 0 || event.isPrimary === false) return;
118
+ pointerId = event.pointerId; x = startX = event.clientX; y = startY = event.clientY;
119
+ handle.setPointerCapture?.(pointerId);
120
+ doc.addEventListener('pointermove', move, { capture: true, passive: false });
121
+ doc.addEventListener('pointerup', up, true);
122
+ doc.addEventListener('pointercancel', cancel, true);
123
+ doc.addEventListener('pointerdown', secondPointer, true);
124
+ doc.addEventListener('keydown', escape, true);
125
+ doc.addEventListener('contextmenu', context, true);
126
+ win.addEventListener('blur', cancel);
127
+ doc.addEventListener('visibilitychange', cancel);
128
+ timer = win.setTimeout(() => {
129
+ if (stopped || !row.isConnected) { cancel(); return; }
130
+ active = true;
131
+ row.style.outline = '2px solid var(--dsw-alias-label-primary, #679cff)';
132
+ row.style.opacity = '0.65';
133
+ locate(); tick();
134
+ }, delay);
135
+ }, cancel
136
+ };
137
+ }
138
+
139
+ export function useSettingsReorder(config) {
140
+ const latest = (0, react.useRef)(config);
141
+ latest.current = config;
142
+ const gesture = (0, react.useRef)();
143
+ (0, react.useEffect)(() => {
144
+ if (config.disabled) gesture.current?.cancel();
145
+ return () => gesture.current?.cancel();
146
+ }, [config.disabled]);
147
+ return id => ({
148
+ draggable: false,
149
+ style: { minWidth: 44, minHeight: 44, touchAction: 'none', userSelect: 'none', WebkitTouchCallout: 'none', cursor: 'grab', flexShrink: 0 },
150
+ onContextMenu: event => event.preventDefault(),
151
+ onClick: event => { event.preventDefault(); event.stopPropagation(); },
152
+ onPointerDown: event => {
153
+ if (latest.current.disabled || event.button !== 0 || event.isPrimary === false) return;
154
+ gesture.current?.cancel();
155
+ const handle = event.currentTarget;
156
+ const row = handle.closest(latest.current.selector);
157
+ if (!row) return;
158
+ gesture.current = createSettingsReorder({ handle, row, selector: latest.current.selector, commit: offset => {
159
+ if (!latest.current.disabled) latest.current.move(id, offset);
160
+ } });
161
+ gesture.current.start(event);
162
+ },
163
+ onLostPointerCapture: () => gesture.current?.cancel(),
164
+ onKeyDown: event => {
165
+ if (latest.current.disabled || (event.key !== 'ArrowUp' && event.key !== 'ArrowDown')) return;
166
+ event.preventDefault(); event.stopPropagation();
167
+ latest.current.move(id, event.key === 'ArrowUp' ? -1 : 1);
168
+ }
169
+ });
170
+ }
@@ -0,0 +1,93 @@
1
+ /** Resolve group capability policy without mutating the saved upstream descriptor.
2
+ * Self-contained so the verified host generators can serialize this function.
3
+ */
4
+ export function applyModelCapabilities(model, defaults) {
5
+ const object = value => value !== null && typeof value === 'object' && !Array.isArray(value);
6
+ const overrides = model.capabilityOverrides;
7
+ if (defaults !== undefined && (!object(defaults) || Object.keys(defaults).some(key => key !== 'vision' && key !== 'reasoning') || typeof defaults.vision !== 'boolean' || typeof defaults.reasoning !== 'boolean')) {
8
+ throw new TypeError('capabilityDefaults must contain only boolean vision and reasoning');
9
+ }
10
+ if (overrides !== undefined && (!object(overrides) || Object.keys(overrides).some(key => key !== 'vision' && key !== 'reasoning') || Object.values(overrides).some(value => typeof value !== 'boolean' && value !== 'original'))) {
11
+ throw new TypeError('capabilityOverrides must contain only boolean or original vision and reasoning');
12
+ }
13
+ const { capabilityOverrides: ignored, ...effective } = model;
14
+ if (defaults === undefined) return effective;
15
+ const choice = key => overrides?.[key] === 'original' ? undefined : overrides?.[key] ?? defaults[key];
16
+ const vision = choice('vision');
17
+ if (vision !== undefined) {
18
+ const input = Array.isArray(model.input) ? [...model.input] : ['text'];
19
+ effective.input = [...new Set(input.filter(value => vision || value !== 'image'))];
20
+ if (effective.input.length === 0) effective.input.push('text');
21
+ if (vision && !effective.input.includes('image')) effective.input.push('image');
22
+ }
23
+ const reasoning = choice('reasoning');
24
+ if (reasoning === false) effective.reasoningEfforts = false;
25
+ if (reasoning === true) {
26
+ const levels = ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'];
27
+ const efforts = model.reasoningEfforts;
28
+ const valid = object(efforts) && Object.keys(efforts).every(key => levels.includes(key)) && Object.entries(efforts).every(([key, value]) => key === 'off' && value === null || typeof value === 'string' && value.length > 0) && Object.keys(efforts).some(key => key !== 'off');
29
+ effective.reasoningEfforts = valid ? { ...efforts } : { off: null, low: 'low', medium: 'medium', high: 'high' };
30
+ }
31
+ return effective;
32
+ }
33
+
34
+ export function validateCapabilityDefaults(defaults) {
35
+ applyModelCapabilities({}, defaults);
36
+ return defaults;
37
+ }
38
+
39
+ export function validateCapabilityOverrides(overrides) {
40
+ applyModelCapabilities({ capabilityOverrides: overrides });
41
+ return overrides;
42
+ }
43
+
44
+ /** Last batch selections affect newly introduced models, never runtime overrides. */
45
+ export function validateCapabilityBatch(batch) {
46
+ if (batch !== undefined && (batch === null || typeof batch !== 'object' || Array.isArray(batch) || Object.keys(batch).some(key => key !== 'vision' && key !== 'reasoning') || Object.values(batch).some(value => typeof value !== 'boolean'))) {
47
+ throw new TypeError('capabilityBatch must contain only boolean vision and reasoning');
48
+ }
49
+ return batch;
50
+ }
51
+
52
+ export function applyModelBatchCapabilities(model, batch) {
53
+ validateCapabilityBatch(batch);
54
+ const original = applyModelCapabilities(model);
55
+ if (batch === undefined || Object.keys(batch).length === 0) return original;
56
+ return applyModelCapabilities({ ...original, capabilityOverrides: {
57
+ ...(batch.vision === undefined ? { vision: 'original' } : {}),
58
+ ...(batch.reasoning === undefined ? { reasoning: 'original' } : {}),
59
+ } }, { vision: batch.vision ?? false, reasoning: batch.reasoning ?? false });
60
+ }
61
+
62
+ /** Upgrade a beta.1 entry in the draft, preserving its actual capabilities. */
63
+ export function materializeCapabilityEntry(entry) {
64
+ validateCapabilityBatch(entry.capabilityBatch);
65
+ const { capabilityDefaults, ...rest } = entry;
66
+ applyModelCapabilities({}, capabilityDefaults);
67
+ const models = (Array.isArray(entry.models) ? entry.models : []).map(model => applyModelCapabilities(model, capabilityDefaults));
68
+ const capabilityBatch = capabilityDefaults === undefined ? entry.capabilityBatch : { ...capabilityDefaults, ...entry.capabilityBatch };
69
+ return { ...rest, models, ...(capabilityBatch === undefined ? {} : { capabilityBatch }) };
70
+ }
71
+
72
+ export function applyCapabilityBatch(entry, key, enabled) {
73
+ if (!['vision', 'reasoning'].includes(key) || typeof enabled !== 'boolean') throw new TypeError('capability batch needs vision/reasoning and a boolean');
74
+ const migrated = materializeCapabilityEntry(entry);
75
+ return { ...migrated, capabilityBatch: { ...migrated.capabilityBatch, [key]: enabled }, models: migrated.models.map(model => applyModelBatchCapabilities(model, { [key]: enabled })) };
76
+ }
77
+
78
+ /** Existing incoming rows are edits; only genuinely new IDs inherit batch values. */
79
+ export function mergeCapabilityModels(entry, incoming) {
80
+ const migrated = materializeCapabilityEntry(entry);
81
+ const rows = new Map();
82
+ for (const model of migrated.models) {
83
+ if (!rows.has(model.id)) rows.set(model.id, []);
84
+ rows.get(model.id).push(model);
85
+ }
86
+ const grows = incoming.length > migrated.models.length;
87
+ return { ...migrated, models: incoming.map(model => {
88
+ const previous = rows.get(model.id)?.shift();
89
+ const nativeOff = migrated.capabilityBatch?.reasoning !== undefined && previous?.reasoningEfforts !== null && typeof previous?.reasoningEfforts === 'object' && !Array.isArray(previous.reasoningEfforts) && model.reasoningEfforts === undefined;
90
+ const edited = nativeOff ? { ...model, reasoningEfforts: false } : model;
91
+ return !grows || previous !== undefined ? applyModelCapabilities(edited) : applyModelBatchCapabilities(edited, migrated.capabilityBatch);
92
+ }) };
93
+ }
package/src/hierarchy.js CHANGED
@@ -18,6 +18,17 @@ export function decodeKeyModelId(model) {
18
18
  } catch { return undefined; }
19
19
  }
20
20
 
21
+ /** Explicit ranks survive host equality checks, schema parsing, and reloads.
22
+ * Unranked legacy/new entries retain their existing relative order at the end.
23
+ */
24
+ export function orderedKeyEntries(entries) {
25
+ const rank = entry => Number.isSafeInteger(entry?.sortOrder) && entry.sortOrder >= 0 ? entry.sortOrder : Infinity;
26
+ return Object.entries(entries ?? {}).sort(([, left], [, right]) => {
27
+ const a = rank(left), b = rank(right);
28
+ return a === b ? 0 : a < b ? -1 : 1;
29
+ });
30
+ }
31
+
21
32
  export function keyEntryGroupKey(provider, entryId) {
22
33
  return `@key/${encodeURIComponent(provider)}/${encodeURIComponent(entryId)}`;
23
34
  }
@@ -50,7 +61,7 @@ export function buildModelHierarchy(groups, providers, preferences, current = nu
50
61
  }
51
62
  const byEntry = new Map();
52
63
  const configured = profile?.keyEntries && typeof profile.keyEntries === 'object' ? profile.keyEntries : {};
53
- for (const entryId of Object.keys(configured)) {
64
+ for (const [entryId] of orderedKeyEntries(configured)) {
54
65
  if (KEY_ENTRY_ID.test(entryId) && !forbiddenEntry(entryId)) byEntry.set(entryId, []);
55
66
  }
56
67
  for (const model of aliases) {
@@ -64,6 +75,17 @@ export function buildModelHierarchy(groups, providers, preferences, current = nu
64
75
  const children = [...byEntry].filter(([, models]) => models.length > 0).map(([entryId, models]) => {
65
76
  const key = keyEntryGroupKey(group.id, entryId);
66
77
  const active = root.current && selectedEntry === entryId;
78
+ // Catalog refreshes may use a different order from the saved editor rows.
79
+ // Empty lists inherit catalog order; unconfigured models stay stable last.
80
+ const configuredModels = Object.hasOwn(configured, entryId) ? configured[entryId]?.models : undefined;
81
+ if (Array.isArray(configuredModels) && configuredModels.length > 0) {
82
+ const order = new Map();
83
+ for (const model of configuredModels) {
84
+ if (typeof model?.id === 'string' && !order.has(model.id)) order.set(model.id, order.size);
85
+ }
86
+ const rank = model => order.get(decodeKeyModelId(model.id).modelId) ?? order.size;
87
+ models.sort((left, right) => rank(left) - rank(right));
88
+ }
67
89
  return { ...group, key, entryId, name: keyEntryName(providers, group.id, entryId), models, current: active,
68
90
  expanded: Object.hasOwn(preferences, key) ? preferences[key] : active };
69
91
  });
@@ -29,7 +29,7 @@ export function ModelGroupsSettingsPage() {
29
29
  return h('section', { className: 'mg-maintenance', 'aria-label': '模型分组设置', 'aria-busy': state.busy },
30
30
  h('h2', null, '多密钥管理'),
31
31
  h('p', null, '在这里启用、更新或关闭多密钥。如果只想按供应商浏览模型,无需启用多密钥。'),
32
- snapshot?.pluginVersion && h('p', { className: 'mg-maintenance-help' }, `插件 ${snapshot.pluginVersion} · DSH ${snapshot.compatibility?.hostVersion ?? '版本未确认'}`),
32
+ h('p', { className: 'mg-maintenance-help' }, `网页插件 ${MODEL_GROUPS_CLIENT_VERSION} · 后端插件 ${snapshot?.pluginVersion ?? '检查中'} · DSH ${snapshot?.compatibility?.hostVersion ?? '版本未确认'}`),
33
33
  h('section', { className: 'mg-maintenance-card', 'aria-label': '当前状态' },
34
34
  h('div', { className: 'mg-maintenance-row' }, h('h3', null, '当前状态'), h('button', { type: 'button', disabled: state.busy, onClick: refresh }, state.busy ? '处理中…' : '刷新检查')),
35
35
  h('p', { role: 'status', 'aria-live': 'polite', className: 'mg-maintenance-help' }, state.busy ? '正在检查或执行维护,请勿关闭或重复操作。' : snapshot ? statusLabel : '尚无可验证状态,请刷新检查。'),