@goodandready/dsh-model-sync 0.3.9 → 0.3.11

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/README.md CHANGED
@@ -107,6 +107,15 @@ dsh plugin --profile web add @goodandready/dsh-model-sync
107
107
 
108
108
  ---
109
109
 
110
+ ## 🛠️ Enhancements & Fixes in v0.3.11
111
+
112
+ * 🗂️ **Streamlined Settings UI Surface**:
113
+ * Removed the redundant top-level settings sidebar section fallback (`settings.section`), keeping configuration strictly inside the standard DSH plugin settings card (`settings.plugin.item`).
114
+ * 🛡️ **Hardened Cordis Service Access**:
115
+ * Service access for `settings` and `llm` is now performed via `ctx.get(...)` with safe fallback to direct property inspection, preventing silent resolution failures on Cordis proxy implementations.
116
+
117
+ ---
118
+
110
119
  ## 🛠️ Enhancements & Fixes in v0.3.8
111
120
 
112
121
  * ⚙️ **Reactive Settings Card Binding (`settingsScope`)**:
@@ -142,6 +151,18 @@ dsh plugin --profile web add @goodandready/dsh-model-sync
142
151
 
143
152
  ---
144
153
 
154
+ ## 🚀 Enhancements in v0.3.10
155
+
156
+ * 📦 **Compacted History Storage**:
157
+ * Stripped non-essential metadata (`description`, `tags`, `aliases`) from catalog snapshots (`before`/`after`) in `settings.yaml`, retaining only essential properties (`id`, `name`, `capabilities`, `pricing`, `contextWindow`, `maxTokens`).
158
+ * Optimized default snapshot retention (`DEFAULT_SNAPSHOT_RETENTION = 3`) to keep configuration files lean while preserving full rollback functionality.
159
+ * ⚡ **Immediate Scheduler First Tick**:
160
+ * Scheduler now triggers an initial discovery run immediately upon startup when enabled without requiring an initial 60-minute wait.
161
+ * 🛡️ **Client-side Policy Regex Validation**:
162
+ * Added instant regex pattern verification in the Web UI before submitting `/policy` requests with localized error feedback.
163
+
164
+ ---
165
+
145
166
  ## 🚀 Enhancements in v0.3.9
146
167
 
147
168
  * 🔒 **Serialized Settings Mutations (`saveConfig`)**:
package/README.ru.md CHANGED
@@ -84,6 +84,15 @@ dsh plugin --profile web add @goodandready/dsh-model-sync
84
84
 
85
85
  ---
86
86
 
87
+ ## 🛠️ Улучшения и исправления в v0.3.11
88
+
89
+ * 🗂️ **Чистый интерфейс настроек (DSH Design Contract)**:
90
+ * Удалена избыточная регистрация fallback-раздела верхнего уровня бокового меню (`settings.section`), настройки доступны только через стандартную карточку в «Настройки → Плагины → Настройки плагинов» (`settings.plugin.item`).
91
+ * 🛡️ **Надёжный доступ к сервисам Cordis**:
92
+ * Доступ к сервисам ядра (`settings`, `llm`) переведён на `ctx.get(...)` с безопасным fallback к прямым свойствам контекста для исключения ошибок проксирования.
93
+
94
+ ---
95
+
87
96
  ## 🛠️ Улучшения и исправления в v0.3.8
88
97
 
89
98
  * ⚙️ **Реактивное управление настройками через `settingsScope`**:
@@ -119,6 +128,18 @@ dsh plugin --profile web add @goodandready/dsh-model-sync
119
128
 
120
129
  ---
121
130
 
131
+ ## 🚀 Новые возможности в v0.3.10
132
+
133
+ * 📦 **Компактизация хранения истории**:
134
+ * Очистка некритичных метаданных (`description`, `tags`, `aliases`) из снимков моделей (`before`/`after`) в `settings.yaml`, с сохранением ключевых свойств (`id`, `name`, `capabilities`, `pricing`, `contextWindow`, `maxTokens`).
135
+ * Оптимизирована глубина хранения снимков (`DEFAULT_SNAPSHOT_RETENTION = 3`) для предотвращения разрастания конфигурационного файла при сохранении возможности безопасного отката.
136
+ * ⚡ **Мгновенный первый запуск планировщика**:
137
+ * Планировщик автоматически запускает первичный синк сразу при старте, если расписание активно и предыдущие запуски отсутствуют.
138
+ * 🛡️ **Клиентская валидация regex-политик**:
139
+ * Добавлена проверка синтаксиса регулярных выражений в интерфейсе настроек перед отправкой запроса на `/policy` с локализованными подсказками.
140
+
141
+ ---
142
+
122
143
  ## 🚀 Новые возможности в v0.3.9
123
144
 
124
145
  * 🔒 **Атомарная очередь сохранения настроек (`saveConfig`)**:
package/README.zh.md CHANGED
@@ -73,3 +73,13 @@ dsh plugin --profile web add @goodandready/dsh-model-sync
73
73
  ## 📄 开源协议
74
74
 
75
75
  MIT © [GooDAnDReaDY](https://github.com/GooDAnDReaDY)
76
+
77
+ ## 🛠️ v0.3.11 改进与修复
78
+
79
+ * 🗂️ **规范化设置界面**:
80
+ * 移除侧边栏顶层多余的 `settings.section` fallback,统一使用标准插件设置卡片 `settings.plugin.item`。
81
+ * 🛡️ **增强 Cordis 服务访问稳定性**:
82
+ * 使用 `ctx.get(...)` 安全获取 `settings` 与 `llm` 服务,避免代理对象属性解析异常。
83
+
84
+ ---
85
+
package/lib/client.js CHANGED
@@ -42,14 +42,13 @@ window.__ModuleLoader__.load({
42
42
  document.head.appendChild(tag)
43
43
  }
44
44
  const en = {
45
- title: 'Model synchronization', showSettings: 'Show settings', hideSettings: 'Hide settings', desc: 'Refresh model catalogs for API-key providers. A dry-run is the default; applying changes is explicit.', loading: 'Loading…', refresh: 'Refresh status', refreshAll: 'Refresh all', run: 'Discover', apply: 'Apply changes', dryRun: 'Preview only (dry-run)', all: 'All API-key providers', ok: 'ready', dormant: 'not configured', running: 'running…', noProviders: 'No API-key providers found.', lastRun: 'Last run', previewTitle: 'Changes preview', previewNone: 'No changes detected.', previewAdded: 'Added', previewRemoved: 'Removed', previewChanged: 'Changed', previewApply: 'Apply preview', previewDismiss: 'Close preview', selectModels: 'Choose models', saveSelection: 'Save and apply selection', selectAtLeastOne: 'Select at least one model or use All models.', cancel: 'Cancel', selected: 'selected', allModels: 'All models', noModels: 'No models in the current catalog.', capabilityFilter: 'Filter by capabilities', capabilityCount: 'shown', noCapabilities: 'No capability metadata in this catalog.', pickerNote: 'The selection is applied to the DSH model picker immediately.', health: 'Check availability', healthOk: 'available', healthError: 'unavailable', healthDormant: 'not configured', policy: 'Manual model selection', savePolicy: 'Save policy', policyInclude: 'Include patterns (one per line)', policyExclude: 'Exclude patterns (one per line)', policyRequire: 'Required capabilities (comma-separated)', policyDeny: 'Denied capabilities (comma-separated)', policyNote: 'Patterns match model id, name, and tags. Apply changes to update the DSH picker.', scheduler: 'Scheduler', nextRun: 'next run', schedulerOff: 'off', schedulerOn: 'on', removeMissing: 'Confirm stale removal', stale: 'stale', deprecated: 'deprecated', history: 'Synchronization history', historyRun: 'Run', diffAdded: 'added', diffRemoved: 'removed', diffRenamed: 'renamed', diffChanged: 'metadata changed', rollback: 'Rollback catalog', rollbackConfirm: 'Rollback this provider catalog to the selected snapshot?', credentials: 'Credential diagnostics', checkCredentials: 'Check credentials', missingCredential: 'not configured', credentialReady: 'configured', report: 'Run report', success: 'success', partial: 'partial success', failure: 'failed', empty: 'no configured providers', notifications: 'Notifications', markRead: 'Mark read', acknowledge: 'Acknowledge', noNotifications: 'No notifications.', selectAllFiltered: 'Select all shown', deselectAllFiltered: 'Deselect all shown', invertFiltered: 'Invert selection', filterContext100k: 'Context ≥ 100k', filterCheap: 'Cheap ≤ $1/1M', showMore: 'Show more (%d)', showingCount: 'Showing %d of %d models', deprecatesIn: 'Deprecates in %dd', cacheHoursAgo: 'cache %dh ago', noHistory: 'No synchronization history.', searchPlaceholder: 'Search id/name/tag', sortName: 'Name A→Z', sortPrice: 'Price ↑', sortContext: 'Context ↓', sortDate: 'Newest ↓', schedulerSettings: 'Scheduler configuration', scheduleEnabled: 'Enable background schedule', intervalMinutes: 'Interval (minutes)', autoApplySchedule: 'Auto-apply changes', saveScheduler: 'Save scheduler', schedulerSaved: 'Saved', scopeUnavailable: 'Settings service unavailable.',
45
+ title: 'Model synchronization', showSettings: 'Show settings', hideSettings: 'Hide settings', desc: 'Refresh model catalogs for API-key providers. A dry-run is the default; applying changes is explicit.', loading: 'Loading…', refresh: 'Refresh status', refreshAll: 'Refresh all', run: 'Discover', apply: 'Apply changes', dryRun: 'Preview only (dry-run)', all: 'All API-key providers', ok: 'ready', dormant: 'not configured', running: 'running…', noProviders: 'No API-key providers found.', lastRun: 'Last run', previewTitle: 'Changes preview', previewNone: 'No changes detected.', previewAdded: 'Added', previewRemoved: 'Removed', previewChanged: 'Changed', previewApply: 'Apply preview', previewDismiss: 'Close preview', selectModels: 'Choose models', saveSelection: 'Save and apply selection', selectAtLeastOne: 'Select at least one model or use All models.', cancel: 'Cancel', selected: 'selected', allModels: 'All models', noModels: 'No models in the current catalog.', capabilityFilter: 'Filter by capabilities', capabilityCount: 'shown', noCapabilities: 'No capability metadata in this catalog.', pickerNote: 'The selection is applied to the DSH model picker immediately.', health: 'Check availability', healthOk: 'available', healthError: 'unavailable', healthDormant: 'not configured', policy: 'Manual model selection', savePolicy: 'Save policy', policyInclude: 'Include patterns (one per line)', policyExclude: 'Exclude patterns (one per line)', policyRequire: 'Required capabilities (comma-separated)', policyDeny: 'Denied capabilities (comma-separated)', policyNote: 'Patterns match model id, name, and tags. Apply changes to update the DSH picker.', scheduler: 'Scheduler', nextRun: 'next run', schedulerOff: 'off', schedulerOn: 'on', removeMissing: 'Confirm stale removal', stale: 'stale', deprecated: 'deprecated', history: 'Synchronization history', historyRun: 'Run', diffAdded: 'added', diffRemoved: 'removed', diffRenamed: 'renamed', diffChanged: 'metadata changed', rollback: 'Rollback catalog', rollbackConfirm: 'Rollback this provider catalog to the selected snapshot?', credentials: 'Credential diagnostics', checkCredentials: 'Check credentials', missingCredential: 'not configured', credentialReady: 'configured', report: 'Run report', success: 'success', partial: 'partial success', failure: 'failed', empty: 'no configured providers', notifications: 'Notifications', markRead: 'Mark read', acknowledge: 'Acknowledge', noNotifications: 'No notifications.', selectAllFiltered: 'Select all shown', deselectAllFiltered: 'Deselect all shown', invertFiltered: 'Invert selection', filterContext100k: 'Context ≥ 100k', filterCheap: 'Cheap ≤ $1/1M', showMore: 'Show more (%d)', showingCount: 'Showing %d of %d models', deprecatesIn: 'Deprecates in %dd', cacheHoursAgo: 'cache %dh ago', noHistory: 'No synchronization history.', searchPlaceholder: 'Search id/name/tag', sortName: 'Name A→Z', sortPrice: 'Price ↑', sortContext: 'Context ↓', sortDate: 'Newest ↓', schedulerSettings: 'Scheduler configuration', scheduleEnabled: 'Enable background schedule', intervalMinutes: 'Interval (minutes)', autoApplySchedule: 'Auto-apply changes', saveScheduler: 'Save scheduler', schedulerSaved: 'Saved', scopeUnavailable: 'Settings service unavailable.', invalidPattern: 'Invalid regex pattern: %s',
46
46
  }
47
47
  const ru = {
48
- title: 'Синхронизация моделей', showSettings: 'Показать настройки', hideSettings: 'Скрыть настройки', desc: 'Обновляет каталоги моделей у провайдеров с API-ключом. По умолчанию только dry-run; применение запускается явно.', loading: 'Загрузка…', refresh: 'Обновить статус', refreshAll: 'Обновить все', run: 'Проверить', apply: 'Применить изменения', dryRun: 'Только просмотр (dry-run)', all: 'Все API-key провайдеры', ok: 'готов', dormant: 'не настроен', running: 'выполняется…', noProviders: 'API-key провайдеры не найдены.', lastRun: 'Последний запуск', previewTitle: 'Предпросмотр изменений', previewNone: 'Изменений не обнаружено.', previewAdded: 'Добавлено', previewRemoved: 'Удалено', previewChanged: 'Изменено', previewApply: 'Применить изменения', previewDismiss: 'Закрыть предпросмотр', selectModels: 'Выбрать модели', saveSelection: 'Сохранить и применить выбор', selectAtLeastOne: 'Выберите хотя бы одну модель или нажмите «Все модели».', cancel: 'Отмена', selected: 'выбрано', allModels: 'Все модели', noModels: 'В текущем каталоге нет моделей.', capabilityFilter: 'Фильтр по возможностям', capabilityCount: 'показано', noCapabilities: 'В этом каталоге нет данных о возможностях.', pickerNote: 'Выбор сразу применяется к пикеру моделей DSH.', health: 'Проверить доступность', healthOk: 'доступен', healthError: 'недоступен', healthDormant: 'не настроен', policy: 'Ручной выбор моделей', savePolicy: 'Сохранить политику', policyInclude: 'Включать (по одному шаблону в строке)', policyExclude: 'Исключать (по одному шаблону в строке)', policyRequire: 'Обязательные возможности (через запятую)', policyDeny: 'Запрещённые возможности (через запятую)', policyNote: 'Шаблоны ищутся в id, названии и тегах модели. Нажмите «Применить», чтобы обновить пикер DSH.', scheduler: 'Планировщик', nextRun: 'следующий запуск', schedulerOff: 'выключен', schedulerOn: 'включён', removeMissing: 'Подтвердить удаление устаревших', stale: 'устарела', deprecated: 'deprecated', history: 'История синхронизаций', historyRun: 'Запуск', diffAdded: 'добавлено', diffRemoved: 'удалено', diffRenamed: 'переименовано', diffChanged: 'изменена метаинформация', rollback: 'Откатить каталог', rollbackConfirm: 'Откатить каталог этого провайдера к выбранному снимку?', credentials: 'Диагностика ключей', checkCredentials: 'Проверить ключи', missingCredential: 'не настроен', credentialReady: 'настроен', report: 'Итог запуска', success: 'успех', partial: 'частичный успех', failure: 'ошибка', empty: 'нет настроенных провайдеров', notifications: 'Уведомления', markRead: 'Прочитано', acknowledge: 'Подтвердить', noNotifications: 'Новых уведомлений нет.', selectAllFiltered: 'Выбрать все показанные', deselectAllFiltered: 'Снять выбор с показанных', invertFiltered: 'Инвертировать выбор', filterContext100k: 'Контекст ≥ 100k', filterCheap: 'Дешевые ≤ $1/1M', showMore: 'Показать ещё (%d)', showingCount: 'Показано %d из %d моделей', deprecatesIn: 'Устареет через %d дн.', cacheHoursAgo: 'кеш %dч назад', noHistory: 'Истории синхронизаций нет.', searchPlaceholder: 'Поиск id/name/tag', sortName: 'Имя A→Z', sortPrice: 'Цена ↑', sortContext: 'Контекст ↓', sortDate: 'Новые ↓', schedulerSettings: 'Настройка планировщика', scheduleEnabled: 'Включить фоновое расписание', intervalMinutes: 'Интервал (минуты)', autoApplySchedule: 'Автоматически применять изменения', saveScheduler: 'Сохранить расписание', schedulerSaved: 'Сохранено', scopeUnavailable: 'Служба настроек недоступна.',
48
+ title: 'Синхронизация моделей', showSettings: 'Показать настройки', hideSettings: 'Скрыть настройки', desc: 'Обновляет каталоги моделей у провайдеров с API-ключом. По умолчанию только dry-run; применение запускается явно.', loading: 'Загрузка…', refresh: 'Обновить статус', refreshAll: 'Обновить все', run: 'Проверить', apply: 'Применить изменения', dryRun: 'Только просмотр (dry-run)', all: 'Все API-key провайдеры', ok: 'готов', dormant: 'не настроен', running: 'выполняется…', noProviders: 'API-key провайдеры не найдены.', lastRun: 'Последний запуск', previewTitle: 'Предпросмотр изменений', previewNone: 'Изменений не обнаружено.', previewAdded: 'Добавлено', previewRemoved: 'Удалено', previewChanged: 'Изменено', previewApply: 'Применить изменения', previewDismiss: 'Закрыть предпросмотр', selectModels: 'Выбрать модели', saveSelection: 'Сохранить и применить выбор', selectAtLeastOne: 'Выберите хотя бы одну модель или нажмите «Все модели».', cancel: 'Отмена', selected: 'выбрано', allModels: 'Все модели', noModels: 'В текущем каталоге нет моделей.', capabilityFilter: 'Фильтр по возможностям', capabilityCount: 'показано', noCapabilities: 'В этом каталоге нет данных о возможностях.', pickerNote: 'Выбор сразу применяется к пикеру моделей DSH.', health: 'Проверить доступность', healthOk: 'доступен', healthError: 'недоступен', healthDormant: 'не настроен', policy: 'Ручной выбор моделей', savePolicy: 'Сохранить политику', policyInclude: 'Включать (по одному шаблону в строке)', policyExclude: 'Исключать (по одному шаблону в строке)', policyRequire: 'Обязательные возможности (через запятую)', policyDeny: 'Запрещённые возможности (через запятую)', policyNote: 'Шаблоны ищутся в id, названии и тегах модели. Нажмите «Применить», чтобы обновить пикер DSH.', scheduler: 'Планировщик', nextRun: 'следующий запуск', schedulerOff: 'выключен', schedulerOn: 'включён', removeMissing: 'Подтвердить удаление устаревших', stale: 'устарела', deprecated: 'deprecated', history: 'История синхронизаций', historyRun: 'Запуск', diffAdded: 'добавлено', diffRemoved: 'удалено', diffRenamed: 'переименовано', diffChanged: 'изменена метаинформация', rollback: 'Откатить каталог', rollbackConfirm: 'Откатить каталог этого провайдера к выбранному снимку?', credentials: 'Диагностика ключей', checkCredentials: 'Проверить ключи', missingCredential: 'не настроен', credentialReady: 'настроен', report: 'Итог запуска', success: 'успех', partial: 'частичный успех', failure: 'ошибка', empty: 'нет настроенных провайдеров', notifications: 'Уведомления', markRead: 'Прочитано', acknowledge: 'Подтвердить', noNotifications: 'Новых уведомлений нет.', selectAllFiltered: 'Выбрать все показанные', deselectAllFiltered: 'Снять выбор с показанных', invertFiltered: 'Инвертировать выбор', filterContext100k: 'Контекст ≥ 100k', filterCheap: 'Дешевые ≤ $1/1M', showMore: 'Показать ещё (%d)', showingCount: 'Показано %d из %d моделей', deprecatesIn: 'Устареет через %d дн.', cacheHoursAgo: 'кеш %dч назад', noHistory: 'Истории синхронизаций нет.', searchPlaceholder: 'Поиск id/name/tag', sortName: 'Имя A→Z', sortPrice: 'Цена ↑', sortContext: 'Контекст ↓', sortDate: 'Новые ↓', schedulerSettings: 'Настройка планировщика', scheduleEnabled: 'Включить фоновое расписание', intervalMinutes: 'Интервал (минуты)', autoApplySchedule: 'Автоматически применять изменения', saveScheduler: 'Сохранить расписание', schedulerSaved: 'Сохранено', scopeUnavailable: 'Служба настроек недоступна.', invalidPattern: 'Некорректный regex-шаблон: %s',
49
49
  }
50
50
  const API = '/dsh-model-sync'
51
51
  const CAPABILITY_LABELS = { vision: { en: 'Vision', ru: 'Зрение' }, tools: { en: 'Tools', ru: 'Инструменты' }, reasoning: { en: 'Reasoning', ru: 'Рассуждение' }, embeddings: { en: 'Embeddings', ru: 'Эмбеддинги' } }
52
- function capabilityKeys(models) { return [...new Set((models ?? []).flatMap((model) => Object.entries(model?.capabilities ?? {}).filter(([, enabled]) => enabled === true).map(([key]) => key)))].sort() }
53
52
  function filterModelsByCapabilities(models, selected) { const required = [...new Set((selected ?? []).filter((key) => typeof key === 'string' && key))]; return (models ?? []).filter((model) => required.every((key) => model?.capabilities?.[key] === true)) }
54
53
  const NS = 'dsh-model-sync'
55
54
  function useLocale(ctx) {
@@ -86,7 +85,7 @@ window.__ModuleLoader__.load({
86
85
  const doTry = (provider, modelId) => { setTryState((s)=>({ ...s, [provider+':'+modelId]: { loading: true } })); fetch(`${API}/try`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ provider, model: modelId }) }).then(async r=>{ const b=await r.json(); if(!r.ok) throw new Error(b?.error?.message||`HTTP ${r.status}`); return b }).then(b=>setTryState((s)=>({ ...s, [provider+':'+modelId]: { ok: b.ok, latency: b.latencyMs, status: b.status, error: b.error } }))).catch(e=>setTryState((s)=>({ ...s, [provider+':'+modelId]: { error: String(e.message||e) } }))) }
87
86
  const load = React.useCallback(() => { setError(''); return fetch(`${API}/status`).then((r) => r.json()).then(setStatus).catch((e) => setError(String(e?.message ?? e))) }, [])
88
87
  const loadCredentials = React.useCallback(() => { setError(''); return fetch(`${API}/credentials`).then(async (r) => { const body = await r.json(); if (!r.ok) throw new Error(body?.error?.message ?? `HTTP ${r.status}`); return body }).then(setCredentialStatus).catch((e) => setError(String(e?.message ?? e))) }, [])
89
- React.useEffect(() => { ensureStyles(); load(); loadCredentials(); const timer = setInterval(load, 15000); return () => clearInterval(timer) }, [load, loadCredentials])
88
+ React.useEffect(() => { ensureStyles(); load(); loadCredentials(); const timer = setInterval(() => { if (typeof document !== 'undefined' && document.hidden) return; load() }, 15000); return () => clearInterval(timer) }, [load, loadCredentials])
90
89
  const execute = (targetProvider, dryRunOverride = dryRun) => { if (!dryRunOverride && removeMissing && !window.confirm(t.removeMissing)) return; setBusy(true); setError(''); fetch(`${API}/run`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ provider: targetProvider || undefined, dryRun: dryRunOverride, removeMissing }) }).then(async (r) => { const body = await r.json(); if (!r.ok) throw new Error(body?.error?.message ?? `HTTP ${r.status}`); return body }).then((body) => { setStatus(body); if (dryRunOverride) setPreview({ ...body, provider: targetProvider || '' }); else setPreview(null) }).catch((e) => setError(String(e?.message ?? e))).finally(() => setBusy(false)) }
91
90
  const checkHealth = (targetProvider) => { setBusy(true); setError(''); fetch(`${API}/health`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(targetProvider ? { provider: targetProvider } : {}) }).then(async (r) => { const body = await r.json(); if (!r.ok) throw new Error(body?.error?.message ?? `HTTP ${r.status}`); return body }).then(setStatus).catch((e) => setError(String(e?.message ?? e))).finally(() => setBusy(false)) }
92
91
  const checkCredentials = (targetProvider) => { setBusy(true); setError(''); fetch(`${API}/credentials/check`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(targetProvider ? { provider: targetProvider } : {}) }).then(async (r) => { const body = await r.json(); if (!r.ok) throw new Error(body?.error?.message ?? `HTTP ${r.status}`); return body }).then((body) => { setCredentialStatus(body.credentials); setStatus((current) => ({ ...(current ?? {}), lastHealth: body.health })) }).catch((e) => setError(String(e?.message ?? e))).finally(() => setBusy(false)) }
@@ -97,7 +96,16 @@ window.__ModuleLoader__.load({
97
96
  const closePolicy = () => { setPolicyProvider(''); setPolicyDraft({ include: '', exclude: '', requireCapabilities: '', denyCapabilities: '' }) }
98
97
  const splitValues = (value) => [...new Set(String(value ?? '').split(/[,\n]+/).map((item) => item.trim()).filter(Boolean))]
99
98
  const capabilityValues = (value) => Object.fromEntries(splitValues(value).map((key) => [key, true]))
100
- const savePolicy = () => { setBusy(true); setError(''); fetch(`${API}/policy`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ provider: policyProvider, include: splitValues(policyDraft.include), exclude: splitValues(policyDraft.exclude), requireCapabilities: capabilityValues(policyDraft.requireCapabilities), denyCapabilities: capabilityValues(policyDraft.denyCapabilities) }) }).then(async (r) => { const body = await r.json(); if (!r.ok) throw new Error(body?.error?.message ?? `HTTP ${r.status}`); return body }).then(() => { closePolicy(); return load() }).catch((e) => setError(String(e?.message ?? e))).finally(() => setBusy(false)) }
99
+ const validatePatterns = (patterns) => { for (const p of patterns) { try { new RegExp(p, 'i') } catch { return p } } return null }
100
+ const savePolicy = () => {
101
+ const includeList = splitValues(policyDraft.include)
102
+ const excludeList = splitValues(policyDraft.exclude)
103
+ const badInclude = validatePatterns(includeList)
104
+ if (badInclude) { setError((t.invalidPattern || 'Invalid regex pattern: %s').replace('%s', badInclude)); return }
105
+ const badExclude = validatePatterns(excludeList)
106
+ if (badExclude) { setError((t.invalidPattern || 'Invalid regex pattern: %s').replace('%s', badExclude)); return }
107
+ setBusy(true); setError(''); fetch(`${API}/policy`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ provider: policyProvider, include: includeList, exclude: excludeList, requireCapabilities: capabilityValues(policyDraft.requireCapabilities), denyCapabilities: capabilityValues(policyDraft.denyCapabilities) }) }).then(async (r) => { const body = await r.json(); if (!r.ok) throw new Error(body?.error?.message ?? `HTTP ${r.status}`); return body }).then(() => { closePolicy(); return load() }).catch((e) => setError(String(e?.message ?? e))).finally(() => setBusy(false))
108
+ }
101
109
  const saveSelection = () => { const row = rows.find((item) => item.provider === selectionProvider); const available = row?.availableModels ?? row?.models ?? []; if (available.length > 0 && selectionDraft.length === 0) { setError(t.selectAtLeastOne); return } const models = selectionDraft.length === available.length ? [] : selectionDraft; setBusy(true); setError(''); fetch(`${API}/selection`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ provider: selectionProvider, models }) }).then(async (r) => { const body = await r.json(); if (!r.ok) throw new Error(body?.error?.message ?? `HTTP ${r.status}`); return body }).then((body) => { setStatus(body); closeSelection() }).catch((e) => setError(String(e?.message ?? e))).finally(() => setBusy(false)) }
102
110
  const rollback = (entry, targetProvider) => { if (!window.confirm(t.rollbackConfirm)) return; setBusy(true); setError(''); fetch(`${API}/history/rollback`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ historyId: entry.id, provider: targetProvider }) }).then(async (r) => { const body = await r.json(); if (!r.ok) throw new Error(body?.error?.message ?? `HTTP ${r.status}`); return body }).then(() => load()).catch((e) => setError(String(e?.message ?? e))).finally(() => setBusy(false)) }
103
111
  const isNewModel = (model, row) => { const added = status?.lastRun?.results?.find(r=>r.provider===row.provider)?.diff?.added||[]; const isAdded = added.some(m=>m.id===model.id); if(!isAdded) return false; const finishedAt = status?.lastRun?.finishedAt; if(!finishedAt) return false; return (Date.now()-new Date(finishedAt).getTime()) < 7*24*60*60*1000; };
@@ -175,7 +183,7 @@ window.__ModuleLoader__.load({
175
183
  function ModelSyncCard({ ctx }) {
176
184
  const locale = useLocale(ctx); const t = locale === 'ru' ? ru : en; const [open, setOpen] = React.useState(false)
177
185
  const [cardStatus, setCardStatus] = React.useState(null)
178
- React.useEffect(() => { ensureStyles(); const load = () => fetch(`${API}/status`).then(r=>r.json()).then(setCardStatus).catch(()=>{}); load(); const id=setInterval(load,15000); return ()=>clearInterval(id)}, [])
186
+ React.useEffect(() => { ensureStyles(); const load = () => { if (typeof document !== 'undefined' && document.hidden) return; fetch(`${API}/status`).then(r=>r.json()).then(setCardStatus).catch(()=>{}) }; load(); if (open) return; const id = setInterval(load, 15000); return () => clearInterval(id) }, [open])
179
187
  const cardBadge = (cardStatus?.lastRun?.results?.reduce((s,r)=>s+(r.diff?.added?.length||0),0) || 0)
180
188
  return React.createElement('li', { className: 'dms-card' + (open ? ' dms-card-open' : '') },
181
189
  React.createElement('button', { type: 'button', className: 'dms-card-header', 'aria-expanded': open, 'aria-label': `${t[open ? 'hideSettings' : 'showSettings']}: ${t.title}`, onClick: () => setOpen((value) => !value) },
@@ -189,15 +197,9 @@ window.__ModuleLoader__.load({
189
197
  try { ctx.locale?.register?.(NS, { en, ru }) } catch {}
190
198
  return () => {}
191
199
  }, 'dsh-model-sync: dictionaries')
192
- const registerPluginCard = () => {
193
- try {
194
- ctx.slots.inject('settings.plugin.item', () => ctx.slots.register({ name: 'settings.plugin.item', key: NS, locale: NS, inject: () => ({ ctx }) }, (props) => React.createElement(ModelSyncCard, { ...props, ctx })))
195
- return true
196
- } catch { return false }
197
- }
198
- if (!registerPluginCard()) {
199
- ctx.slots.inject('settings.section', () => ctx.slots.register({ name: 'settings.section', id: 'dsh-model-sync', order: 25, label: () => 'Model Sync' }, (props) => React.createElement(ModelSyncSection, { ...props, ctx })))
200
- }
200
+ try {
201
+ ctx.slots.inject('settings.plugin.item', () => ctx.slots.register({ name: 'settings.plugin.item', key: NS, locale: NS, inject: () => ({ ctx }) }, (props) => React.createElement(ModelSyncCard, { ...props, ctx })))
202
+ } catch {}
201
203
  }
202
204
  module.exports = { apply, inject: ['slots', 'locale', 'settingsScope'], filterModelsByCapabilities }; return module.exports
203
205
  },
package/lib/history.js CHANGED
@@ -16,11 +16,22 @@ export function historyLimit(value) {
16
16
  }
17
17
 
18
18
  function cloneModel(provider, model) {
19
- return structuredClone(normalizeModels(provider, [model])[0] ?? null)
19
+ const normalized = normalizeModels(provider, [model])[0]
20
+ if (!normalized) return null
21
+ const compact = {
22
+ provider: normalized.provider,
23
+ id: normalized.id,
24
+ name: normalized.name,
25
+ }
26
+ if (normalized.capabilities) compact.capabilities = normalized.capabilities
27
+ if (normalized.pricing) compact.pricing = normalized.pricing
28
+ if (normalized.contextWindow !== undefined) compact.contextWindow = normalized.contextWindow
29
+ if (normalized.maxTokens !== undefined) compact.maxTokens = normalized.maxTokens
30
+ return compact
20
31
  }
21
32
 
22
33
  function catalog(provider, models) {
23
- return normalizeModels(provider, models).slice(0, MAX_HISTORY_MODELS)
34
+ return (normalizeModels(provider, models) ?? []).map((model) => cloneModel(provider, model)).filter(Boolean).slice(0, MAX_HISTORY_MODELS)
24
35
  }
25
36
 
26
37
  function message(value) {
@@ -80,7 +91,7 @@ export function createHistoryEntry(result, { version = 1, maxModels = MAX_HISTOR
80
91
  }
81
92
  }
82
93
 
83
- export const DEFAULT_SNAPSHOT_RETENTION = 5
94
+ export const DEFAULT_SNAPSHOT_RETENTION = 3
84
95
 
85
96
  export function compactHistoryEntry(entry) {
86
97
  if (!entry || typeof entry !== 'object') return entry
package/lib/index.js CHANGED
@@ -137,7 +137,7 @@ export function apply(ctx, config) {
137
137
  try { settings = ctx.get('settings') } catch {}
138
138
  if (!bindSettings(settings, ctx)) {
139
139
  ctx.inject(['settings'], (sctx) => {
140
- bindSettings(sctx.settings, sctx)
140
+ bindSettings((typeof sctx.get === 'function' ? sctx.get('settings') : null) ?? sctx.settings, sctx)
141
141
  })
142
142
  }
143
143
 
@@ -3,13 +3,22 @@ import { listApiKeyProviders } from './inventory.js'
3
3
  export function createProviderService(ctx) {
4
4
  const readSettings = () => {
5
5
  try {
6
- return ctx.get('settings')
6
+ return (typeof ctx.get === 'function' ? (ctx.get('settings') ?? ctx.settings) : ctx.settings)
7
7
  } catch {
8
- return undefined
8
+ return ctx.settings
9
9
  }
10
10
  }
11
11
 
12
- const listProviders = () => listApiKeyProviders(ctx.llm, readSettings())
12
+ const readLlm = () => {
13
+ try {
14
+ const llm = typeof ctx.get === 'function' ? ctx.get('llm') : null
15
+ return (llm && typeof llm === 'object' && (llm.listConfigurableProviders || llm.listProviders)) ? llm : ctx.llm
16
+ } catch {
17
+ return ctx.llm
18
+ }
19
+ }
20
+
21
+ const listProviders = () => listApiKeyProviders(readLlm(), readSettings())
13
22
 
14
23
  return Object.freeze({
15
24
  listProviders,
package/lib/scheduler.js CHANGED
@@ -117,10 +117,16 @@ export function createSyncScheduler(sync, {
117
117
  if (config.enabled === false || config.scheduleEnabled === false) return false
118
118
  const rows = scheduleRows(config)
119
119
  if (inventory(config) !== null && rows.length === 0) return false
120
+ const isFirstRun = state.size === 0 || [...state.values()].every((row) => row.lastRunAt === null)
120
121
  resetStates(config)
121
122
  // One-minute polling is the lower safety bound. Per-provider intervals,
122
123
  // TTLs, and jitter are enforced by tick without multiplying timers.
123
124
  timer = setIntervalFn(() => tick(), 60_000)
125
+ if (isFirstRun) {
126
+ Promise.resolve().then(() => tick()).catch((err) => {
127
+ logger.warn?.("[dsh-model-sync] initial tick failed: " + errorMessage(err))
128
+ })
129
+ }
124
130
  return true
125
131
  }
126
132