dsh-model-groups 0.3.8 → 0.3.9

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.9",
4
4
  "description": "在 DSH 网页端按供应商和密钥配置选择模型,并为同一供应商的不同密钥分别设置模型和 API 协议。支持 DSH 0.2.2-rc.1。",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -38,6 +38,7 @@
38
38
  "!scripts/pi-ai-protocol.mjs",
39
39
  "cordis.patch.yml",
40
40
  "README.md",
41
+ "CHANGELOG.md",
41
42
  "docs/maintenance.md",
42
43
  "docs/images/*.webp",
43
44
  "LICENSE"
@@ -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
+ }
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 : '尚无可验证状态,请刷新检查。'),