dsh-vision-router 1.2.0 → 1.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -32,10 +32,13 @@ window.__ModuleLoader__.load({
32
32
  onboardingGuide: '带我设置视觉模型',
33
33
  onboardingLater: '稍后',
34
34
  onboardingClose: '关闭',
35
- guidePromptTitle: '设置视觉模型 · 第 1 步',
35
+ guideStep1Title: '第 1 步 · 会话 / 文字模型',
36
+ guideStep1Body: '在聊天页右下角的模型选择器里选择会话/文字模型。发图片时,请选带「+ 自动识图」的模型组;组里的模型仍负责聊天、思考和调用工具。选好后点击「下一步」。',
37
+ guideStepNext: '下一步',
38
+ guidePromptTitle: '第 2 步 · 视觉模型',
36
39
  guidePromptBody: '请打开 DSH 的「设置 → 插件」。进入插件页后,我会自动展开 Vision Router,并定位到「视觉后端链」。',
37
40
  guidePromptCancel: '结束引导',
38
- guideChainTitle: '第 2 步 · 这里就是视觉模型',
41
+ guideChainTitle: '第 3 步 · 这里就是视觉模型',
39
42
  guideChainBody: '上面的每一行都是你自己的视觉模型,从上到下依次尝试;可以全部留空。内置 OVH 免费链固定在最后自动兜底。这里不会修改聊天页右下角的会话/文字模型。选好后点击页面底部「保存」。',
40
43
  guideDone: '完成引导',
41
44
  pending: '未保存',
@@ -200,10 +203,13 @@ window.__ModuleLoader__.load({
200
203
  onboardingGuide: 'Guide me to vision settings',
201
204
  onboardingLater: 'Later',
202
205
  onboardingClose: 'Close',
203
- guidePromptTitle: 'Set the vision model · Step 1',
206
+ guideStep1Title: 'Step 1 · Session / text model',
207
+ guideStep1Body: 'Choose it from the lower-right chat selector. For image turns, use a group marked “+ Auto Vision”; the model inside still handles chat, reasoning, and tool calls. Click “Next” when done.',
208
+ guideStepNext: 'Next',
209
+ guidePromptTitle: 'Step 2 · Vision model',
204
210
  guidePromptBody: 'Open DSH Settings → Plugins. Once that page is open, I will expand Vision Router and take you to “Vision backend chain”.',
205
211
  guidePromptCancel: 'End guide',
206
- guideChainTitle: 'Step 2 · This is the vision model',
212
+ guideChainTitle: 'Step 3 · This is the vision model',
207
213
  guideChainBody: 'Each row above is one of your own vision models, tried top to bottom; you may leave them all empty. The built-in OVH chain remains the automatic final fallback. This does not change the session/text model in the lower-right chat selector. Click “Save” after choosing.',
208
214
  guideDone: 'Finish guide',
209
215
  pending: 'Unsaved',
@@ -471,7 +477,7 @@ window.__ModuleLoader__.load({
471
477
  '.vr-quickstart-body,.vr-quickstart-live{margin:0;font-size:12px;line-height:1.6}' +
472
478
  '.vr-quickstart-body{color:var(--dsw-alias-label-secondary)}' +
473
479
  '.vr-quickstart-live{color:var(--dsw-alias-brand-primary)}' +
474
- '.vr-field{flex-direction:column;gap:6px;padding:12px 0;display:flex}' +
480
+ '.vr-field{content-visibility:auto;contain-intrinsic-size:auto 96px;flex-direction:column;gap:6px;padding:12px 0;display:flex}' +
475
481
  '.vr-field + .vr-field{border-top:1px solid var(--dsw-alias-border-l2)}' +
476
482
  '.vr-field-head{align-items:center;gap:8px;display:flex}' +
477
483
  '.vr-label{min-width:0;color:var(--dsw-alias-label-primary);flex:1;font-size:13px;font-weight:500;line-height:1.5}' +
@@ -490,7 +496,7 @@ window.__ModuleLoader__.load({
490
496
  '.vr-stealth-notice{color:var(--dsw-alias-label-warning,var(--dsw-alias-label-secondary))}' +
491
497
  '.vr-catalog-error{display:flex;align-items:center;gap:10px;flex-wrap:wrap}' +
492
498
  '.vr-subheader{display:flex;align-items:center;justify-content:space-between;gap:8px;width:100%;padding:8px 0;border:none;background:none;cursor:pointer;font:inherit;color:var(--dsw-alias-label-primary);text-align:left}' +
493
- '.vr-group{border-top:1px solid var(--dsw-alias-border-l2);padding:10px 0 2px;display:flex;flex-direction:column;gap:8px}' +
499
+ '.vr-group{content-visibility:auto;contain-intrinsic-size:auto 96px;border-top:1px solid var(--dsw-alias-border-l2);padding:10px 0 2px;display:flex;flex-direction:column;gap:8px}' +
494
500
  '.vr-group-title{font-size:12px;font-weight:600;color:var(--dsw-alias-label-tertiary);margin:0}' +
495
501
  '.vr-select{flex:1;min-width:0;font:inherit}' +
496
502
  '.vr-invalid{color:var(--dsw-alias-label-error);margin:0;font-size:12px;line-height:1.5}' +
@@ -524,6 +530,8 @@ window.__ModuleLoader__.load({
524
530
  '.vr-guide-prompt{position:fixed;right:20px;bottom:20px;z-index:9999;width:min(360px,calc(100vw - 32px));box-sizing:border-box;border:1px solid var(--dsw-alias-brand-primary);border-radius:12px;background:var(--dsw-alias-bg-layer-2);box-shadow:0 12px 40px #0004;padding:14px;display:flex;flex-direction:column;gap:7px;color:var(--dsw-alias-label-primary)}' +
525
531
  '.vr-guide-prompt-title{font-size:13px;font-weight:700;line-height:1.5}' +
526
532
  '.vr-guide-prompt-body{margin:0;color:var(--dsw-alias-label-secondary);font-size:12px;line-height:1.6}' +
533
+ '.vr-guide-prompt-actions{display:flex;justify-content:flex-end;align-items:center;gap:8px;flex-wrap:wrap}' +
534
+ '.vr-guide-prompt-left{right:auto;left:20px}' +
527
535
  '.vr-guide-target{border:2px solid var(--dsw-alias-brand-primary)!important;border-radius:12px;padding:12px!important;margin:8px -12px!important;background:var(--dsw-alias-bg-module-platform);box-shadow:0 0 0 4px color-mix(in srgb,var(--dsw-alias-brand-primary) 12%,transparent)}' +
528
536
  '.vr-guide-callout{border-radius:9px;background:var(--dsw-alias-bg-layer-3);padding:10px 12px;display:flex;flex-direction:column;align-items:flex-start;gap:5px;margin-bottom:4px}' +
529
537
  '.vr-guide-callout-title{font-size:13px;font-weight:700;color:var(--dsw-alias-brand-primary)}' +
@@ -546,29 +554,38 @@ window.__ModuleLoader__.load({
546
554
  }
547
555
 
548
556
  const ONBOARDING_STORAGE_KEY = 'dsh-vision-router:onboarding:model-guide-v2'
549
- const VISION_GUIDE_STORAGE_KEY = 'dsh-vision-router:guide:vision-backend-v1'
557
+ // The walkthrough has three steps: step1 (the session/text model selector
558
+ // on the chat page), step2 (open Settings → Plugins → Vision Router), and
559
+ // step3 (the highlighted vision chain, rendered by the settings card as a
560
+ // callout). Only step1/step2 need persistence; step3 re-derives from the
561
+ // card being on screen.
562
+ const VISION_GUIDE_STORAGE_KEY = 'dsh-vision-router:guide:vision-backend-v2'
550
563
  const VISION_GUIDE_EVENT = 'dsh-vision-router:vision-settings-guide'
551
564
  let visionGuidePrompt
552
- let visionGuideActiveMemory = false
565
+ let visionGuideStepMemory
553
566
 
554
- function readVisionGuideActive() {
567
+ function readVisionGuideStep() {
555
568
  try {
556
- if (window.localStorage && window.localStorage.getItem(VISION_GUIDE_STORAGE_KEY) === 'active') return true
569
+ const value = window.localStorage && window.localStorage.getItem(VISION_GUIDE_STORAGE_KEY)
570
+ if (value === 'step1' || value === 'step2') return value
557
571
  } catch {
558
572
  // Fall through to page-memory state when storage access is blocked.
559
573
  }
560
- return visionGuideActiveMemory
574
+ return visionGuideStepMemory
561
575
  }
562
- function writeVisionGuideActive(active) {
563
- visionGuideActiveMemory = active === true
576
+ function writeVisionGuideStep(step) {
577
+ visionGuideStepMemory = step === 'step1' || step === 'step2' ? step : undefined
564
578
  try {
565
579
  if (!window.localStorage) return
566
- if (active) window.localStorage.setItem(VISION_GUIDE_STORAGE_KEY, 'active')
580
+ if (visionGuideStepMemory) window.localStorage.setItem(VISION_GUIDE_STORAGE_KEY, visionGuideStepMemory)
567
581
  else window.localStorage.removeItem(VISION_GUIDE_STORAGE_KEY)
568
582
  } catch {
569
583
  // Page-memory state still keeps the guide functional for this load.
570
584
  }
571
585
  }
586
+ function readVisionGuideActive() {
587
+ return readVisionGuideStep() !== undefined
588
+ }
572
589
  function removeVisionGuidePrompt() {
573
590
  if (visionGuidePrompt) visionGuidePrompt.remove()
574
591
  visionGuidePrompt = undefined
@@ -578,46 +595,79 @@ window.__ModuleLoader__.load({
578
595
  const EventCtor = window.Event
579
596
  if (typeof EventCtor === 'function') window.dispatchEvent(new EventCtor(VISION_GUIDE_EVENT))
580
597
  }
598
+ // The settings modal is an app-owned overlay; plugins cannot close it via
599
+ // a public API. Its panel closes on the Escape key through a native
600
+ // document listener, so replaying the guide "leaves the settings" by
601
+ // dispatching the same standard keydown the user would press.
602
+ function closeSettingsShell() {
603
+ if (typeof document === 'undefined' || typeof window === 'undefined') return
604
+ try {
605
+ document.dispatchEvent(
606
+ new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }),
607
+ )
608
+ } catch {
609
+ // KeyboardEvent constructor unavailable: degrade to staying put.
610
+ }
611
+ }
581
612
  function syncVisionGuidePrompt(t) {
582
613
  if (typeof document === 'undefined' || typeof window === 'undefined') return
583
- if (!readVisionGuideActive()) {
614
+ const step = readVisionGuideStep()
615
+ if (step === undefined) {
584
616
  removeVisionGuidePrompt()
585
617
  return
586
618
  }
587
- if (document.querySelector('[data-vr-guide-target="vision-backend"]')) {
619
+ if (step === 'step2' && document.querySelector('[data-vr-guide-target="vision-backend"]')) {
620
+ // Step 2 is done once the card is on screen; the in-card callout
621
+ // (step 3) takes over from here.
588
622
  removeVisionGuidePrompt()
589
623
  return
590
624
  }
591
625
  if (visionGuidePrompt || !document.body) return
592
626
  const prompt = document.createElement('div')
593
- prompt.className = 'vr-guide-prompt'
627
+ // Step 1 talks about the chat page's lower-right model selector, so
628
+ // park the prompt on the LEFT side and keep the selector usable.
629
+ prompt.className = 'vr-guide-prompt' + (step === 'step1' ? ' vr-guide-prompt-left' : '')
594
630
  prompt.setAttribute('role', 'status')
595
631
  const title = document.createElement('div')
596
632
  title.className = 'vr-guide-prompt-title'
597
- title.textContent = t('guidePromptTitle')
633
+ title.textContent = step === 'step1' ? t('guideStep1Title') : t('guidePromptTitle')
598
634
  const body = document.createElement('p')
599
635
  body.className = 'vr-guide-prompt-body'
600
- body.textContent = t('guidePromptBody')
636
+ body.textContent = step === 'step1' ? t('guideStep1Body') : t('guidePromptBody')
637
+ const actions = document.createElement('div')
638
+ actions.className = 'vr-guide-prompt-actions'
601
639
  const cancel = document.createElement('button')
602
640
  cancel.type = 'button'
603
641
  cancel.className = 'vr-btn'
604
642
  cancel.textContent = t('guidePromptCancel')
605
643
  cancel.addEventListener('click', () => {
606
- writeVisionGuideActive(false)
607
- removeVisionGuidePrompt()
608
- notifyVisionGuideChanged()
644
+ finishVisionSettingsGuide()
609
645
  })
610
- prompt.append(title, body, cancel)
646
+ actions.append(cancel)
647
+ if (step === 'step1') {
648
+ const next = document.createElement('button')
649
+ next.type = 'button'
650
+ next.className = 'vr-btn vr-btn-save'
651
+ next.textContent = t('guideStepNext')
652
+ next.addEventListener('click', () => {
653
+ writeVisionGuideStep('step2')
654
+ removeVisionGuidePrompt()
655
+ syncVisionGuidePrompt(t)
656
+ notifyVisionGuideChanged()
657
+ })
658
+ actions.append(next)
659
+ }
660
+ prompt.append(title, body, actions)
611
661
  document.body.appendChild(prompt)
612
662
  visionGuidePrompt = prompt
613
663
  }
614
664
  function startVisionSettingsGuide(t) {
615
- writeVisionGuideActive(true)
665
+ writeVisionGuideStep('step1')
616
666
  syncVisionGuidePrompt(t)
617
667
  notifyVisionGuideChanged()
618
668
  }
619
669
  function finishVisionSettingsGuide() {
620
- writeVisionGuideActive(false)
670
+ writeVisionGuideStep(undefined)
621
671
  removeVisionGuidePrompt()
622
672
  notifyVisionGuideChanged()
623
673
  }
@@ -634,106 +684,114 @@ window.__ModuleLoader__.load({
634
684
  }
635
685
  }
636
686
 
637
- function installOnboarding(t) {
638
- if (typeof document === 'undefined' || typeof window === 'undefined') return
687
+ let onboardingOverlay
688
+ let onboardingKeyDown
689
+
690
+ function rememberOnboardingSeen() {
639
691
  try {
640
- if (window.localStorage && window.localStorage.getItem(ONBOARDING_STORAGE_KEY) === 'seen') return
692
+ if (window.localStorage) window.localStorage.setItem(ONBOARDING_STORAGE_KEY, 'seen')
641
693
  } catch {
642
- // Privacy/storage restrictions should not prevent the guidance itself.
694
+ // Best effort: the dialog can still be dismissed for this page load.
643
695
  }
644
-
645
- let overlay
646
- let timer
647
- const remember = () => {
648
- try {
649
- if (window.localStorage) window.localStorage.setItem(ONBOARDING_STORAGE_KEY, 'seen')
650
- } catch {
651
- // Best effort: the dialog can still be dismissed for this page load.
652
- }
696
+ }
697
+ function dismissOnboarding(remember = true) {
698
+ if (remember) rememberOnboardingSeen()
699
+ if (typeof document !== 'undefined' && onboardingKeyDown) {
700
+ document.removeEventListener('keydown', onboardingKeyDown)
653
701
  }
702
+ onboardingKeyDown = undefined
703
+ if (onboardingOverlay) onboardingOverlay.remove()
704
+ onboardingOverlay = undefined
705
+ }
706
+ function showOnboarding(t) {
707
+ if (typeof document === 'undefined' || !document.body) return
708
+ if (onboardingOverlay) return // Already open: never stack a second dialog.
709
+
654
710
  const onKeyDown = (event) => {
655
- if (event && event.key === 'Escape') dismiss()
656
- }
657
- const dismiss = () => {
658
- remember()
659
- document.removeEventListener('keydown', onKeyDown)
660
- if (overlay) overlay.remove()
661
- overlay = undefined
711
+ if (event && event.key === 'Escape') dismissOnboarding()
662
712
  }
713
+ onboardingKeyDown = onKeyDown
663
714
 
664
- timer = window.setTimeout(() => {
665
- timer = undefined
666
- if (!document.body) return
715
+ const overlay = document.createElement('div')
716
+ overlay.className = 'vr-onboarding-backdrop'
717
+ overlay.setAttribute('role', 'presentation')
667
718
 
668
- overlay = document.createElement('div')
669
- overlay.className = 'vr-onboarding-backdrop'
670
- overlay.setAttribute('role', 'presentation')
719
+ const dialog = document.createElement('div')
720
+ dialog.className = 'vr-onboarding-dialog'
721
+ dialog.setAttribute('role', 'dialog')
722
+ dialog.setAttribute('aria-modal', 'true')
723
+ dialog.setAttribute('aria-labelledby', 'vr-onboarding-title')
671
724
 
672
- const dialog = document.createElement('div')
673
- dialog.className = 'vr-onboarding-dialog'
674
- dialog.setAttribute('role', 'dialog')
675
- dialog.setAttribute('aria-modal', 'true')
676
- dialog.setAttribute('aria-labelledby', 'vr-onboarding-title')
725
+ const title = document.createElement('h2')
726
+ title.id = 'vr-onboarding-title'
727
+ title.className = 'vr-onboarding-title'
728
+ title.textContent = t('onboardingTitle')
677
729
 
678
- const title = document.createElement('h2')
679
- title.id = 'vr-onboarding-title'
680
- title.className = 'vr-onboarding-title'
681
- title.textContent = t('onboardingTitle')
730
+ const body = document.createElement('p')
731
+ body.className = 'vr-onboarding-text'
732
+ body.textContent = t('onboardingBody')
682
733
 
683
- const body = document.createElement('p')
684
- body.className = 'vr-onboarding-text'
685
- body.textContent = t('onboardingBody')
734
+ const steps = document.createElement('div')
735
+ steps.className = 'vr-onboarding-steps'
736
+ for (const index of [1, 2, 3]) {
737
+ const step = document.createElement('div')
738
+ step.className = 'vr-onboarding-step'
739
+ const stepTitle = document.createElement('div')
740
+ stepTitle.className = 'vr-onboarding-step-title'
741
+ stepTitle.textContent = t('onboardingStep' + index + 'Title')
742
+ const stepBody = document.createElement('p')
743
+ stepBody.className = 'vr-onboarding-step-body'
744
+ stepBody.textContent = t('onboardingStep' + index + 'Body')
745
+ step.append(stepTitle, stepBody)
746
+ steps.appendChild(step)
747
+ }
686
748
 
687
- const steps = document.createElement('div')
688
- steps.className = 'vr-onboarding-steps'
689
- for (const index of [1, 2, 3]) {
690
- const step = document.createElement('div')
691
- step.className = 'vr-onboarding-step'
692
- const stepTitle = document.createElement('div')
693
- stepTitle.className = 'vr-onboarding-step-title'
694
- stepTitle.textContent = t('onboardingStep' + index + 'Title')
695
- const stepBody = document.createElement('p')
696
- stepBody.className = 'vr-onboarding-step-body'
697
- stepBody.textContent = t('onboardingStep' + index + 'Body')
698
- step.append(stepTitle, stepBody)
699
- steps.appendChild(step)
700
- }
749
+ const close = document.createElement('button')
750
+ close.type = 'button'
751
+ close.className = 'vr-onboarding-close'
752
+ close.setAttribute('aria-label', t('onboardingClose'))
753
+ close.textContent = '×'
754
+ close.addEventListener('click', dismissOnboarding)
701
755
 
702
- const close = document.createElement('button')
703
- close.type = 'button'
704
- close.className = 'vr-onboarding-close'
705
- close.setAttribute('aria-label', t('onboardingClose'))
706
- close.textContent = '×'
707
- close.addEventListener('click', dismiss)
756
+ const actions = document.createElement('div')
757
+ actions.className = 'vr-onboarding-actions'
758
+ const secondary = document.createElement('button')
759
+ secondary.type = 'button'
760
+ secondary.className = 'vr-onboarding-secondary'
761
+ secondary.textContent = t('onboardingLater')
762
+ secondary.addEventListener('click', dismissOnboarding)
763
+ const primary = document.createElement('button')
764
+ primary.type = 'button'
765
+ primary.className = 'vr-onboarding-primary'
766
+ primary.textContent = t('onboardingGuide')
767
+ primary.addEventListener('click', () => {
768
+ startVisionSettingsGuide(t)
769
+ dismissOnboarding()
770
+ })
771
+ actions.append(secondary, primary)
708
772
 
709
- const actions = document.createElement('div')
710
- actions.className = 'vr-onboarding-actions'
711
- const secondary = document.createElement('button')
712
- secondary.type = 'button'
713
- secondary.className = 'vr-onboarding-secondary'
714
- secondary.textContent = t('onboardingLater')
715
- secondary.addEventListener('click', dismiss)
716
- const primary = document.createElement('button')
717
- primary.type = 'button'
718
- primary.className = 'vr-onboarding-primary'
719
- primary.textContent = t('onboardingGuide')
720
- primary.addEventListener('click', () => {
721
- startVisionSettingsGuide(t)
722
- dismiss()
723
- })
724
- actions.append(secondary, primary)
773
+ dialog.append(title, body, steps, close, actions)
774
+ overlay.appendChild(dialog)
775
+ document.body.appendChild(overlay)
776
+ document.addEventListener('keydown', onKeyDown)
777
+ onboardingOverlay = overlay
778
+ primary.focus()
779
+ }
780
+ function installOnboarding(t) {
781
+ if (typeof document === 'undefined' || typeof window === 'undefined') return
782
+ try {
783
+ if (window.localStorage && window.localStorage.getItem(ONBOARDING_STORAGE_KEY) === 'seen') return
784
+ } catch {
785
+ // Privacy/storage restrictions should not prevent the guidance itself.
786
+ }
725
787
 
726
- dialog.append(title, body, steps, close, actions)
727
- overlay.appendChild(dialog)
728
- document.body.appendChild(overlay)
729
- document.addEventListener('keydown', onKeyDown)
730
- primary.focus()
731
- }, 650)
788
+ const timer = window.setTimeout(() => showOnboarding(t), 650)
732
789
 
733
790
  return () => {
734
791
  if (timer !== undefined) window.clearTimeout(timer)
735
- document.removeEventListener('keydown', onKeyDown)
736
- if (overlay) overlay.remove()
792
+ // Unmounting is not a user choice: drop the dialog without marking
793
+ // the guide seen, so the first-run overview can still auto-open.
794
+ if (onboardingOverlay) dismissOnboarding(false)
737
795
  }
738
796
  }
739
797
 
@@ -922,6 +980,58 @@ window.__ModuleLoader__.load({
922
980
  } catch (error) {
923
981
  renderError = error
924
982
  }
983
+ const h = React.createElement
984
+ // The model catalog can carry hundreds of models per provider (e.g.
985
+ // openrouter), so the option vnode lists are built once per catalog /
986
+ // selection instead of on every render, and the per-provider model
987
+ // lists are cached by the models-array identity.
988
+ const modelOptionCache = React.useRef(new Map())
989
+ const modelOptionsOf = (models) => {
990
+ if (!Array.isArray(models) || models.length === 0) return []
991
+ let list = modelOptionCache.current.get(models)
992
+ if (list === undefined) {
993
+ list = models.map((model) =>
994
+ h('option', { value: model.id, key: model.id },
995
+ (model.name && model.name !== model.id ? model.name + ' (' + model.id + ')' : model.id)),
996
+ )
997
+ modelOptionCache.current.set(models, list)
998
+ }
999
+ return list
1000
+ }
1001
+ const groupOptions = useMemo(
1002
+ () => catalog.groups
1003
+ .filter((group) => group.id !== 'vision-http')
1004
+ .map((group) => h('option', { value: group.id, key: group.id },
1005
+ (group.name && group.name !== group.id ? group.name + ' (' + group.id + ')' : group.id))),
1006
+ [catalog.groups],
1007
+ )
1008
+ const visionGroupOptions = useMemo(
1009
+ () => filterVisionBackendGroups(catalog.groups, visionCaps.capabilities)
1010
+ .map((group) => h('option', { value: group.id, key: group.id },
1011
+ (group.name && group.name !== group.id ? group.name + ' (' + group.id + ')' : group.id))),
1012
+ [catalog.groups, visionCaps.capabilities],
1013
+ )
1014
+ const wrapGroupOptions = useMemo(
1015
+ () => {
1016
+ const chainValue = readValue(snapshot, 'chainRoute')
1017
+ const wrapperValue = readValue(snapshot, 'wrapperRoute')
1018
+ const excluded = new Set([
1019
+ 'vision-http',
1020
+ typeof chainValue === 'string' && chainValue !== '' ? chainValue : 'vision-chain',
1021
+ typeof wrapperValue === 'string' && wrapperValue !== '' ? wrapperValue : 'deepseek-vision',
1022
+ ])
1023
+ return catalog.groups
1024
+ .filter((group) => {
1025
+ if (group.id === 'deepseek-official') return true
1026
+ if (excluded.has(group.id)) return false
1027
+ if (group.id.endsWith('-vision')) return false
1028
+ return true
1029
+ })
1030
+ .map((group) => h('option', { value: group.id, key: group.id },
1031
+ (group.name && group.name !== group.id ? group.name + ' (' + group.id + ')' : group.id)))
1032
+ },
1033
+ [catalog.groups, snapshot],
1034
+ )
925
1035
  if (renderError !== undefined) {
926
1036
  return React.createElement(
927
1037
  'li',
@@ -1172,7 +1282,6 @@ window.__ModuleLoader__.load({
1172
1282
  }
1173
1283
  }
1174
1284
 
1175
- const h = React.createElement
1176
1285
  const overriddenBadge = (key) =>
1177
1286
  userHas(snapshot, key)
1178
1287
  ? h('span', { className: 'vr-badges' },
@@ -1227,43 +1336,10 @@ window.__ModuleLoader__.load({
1227
1336
  )
1228
1337
  }
1229
1338
 
1230
- const groupOptions = catalog.groups.filter((group) => group.id !== 'vision-http').map((group) =>
1231
- h('option', { value: group.id, key: group.id },
1232
- (group.name && group.name !== group.id ? group.name + ' (' + group.id + ')' : group.id)),
1233
- )
1234
- const visionGroupOptions = visionGroups.map((group) =>
1235
- h('option', { value: group.id, key: group.id },
1236
- (group.name && group.name !== group.id ? group.name + ' (' + group.id + ')' : group.id)),
1237
- )
1238
1339
  const modelsOf = (providerId) => {
1239
1340
  const group = catalog.groups.find((entry) => entry.id === providerId)
1240
1341
  return group && Array.isArray(group.models) ? group.models : []
1241
1342
  }
1242
- // The wire catalog carries no per-model modality fields, so the wrappers
1243
- // editor filters by route id: it offers only TEXT routes to wrap and
1244
- // drops the plugin's own image-capable routes (wrapper, twins, the
1245
- // vision-http backend, the vision chain). deepseek-official stays: it is
1246
- // the pre-filled default row, served by the built-in wrapper.
1247
- const wrapExcludedRoutes = () => {
1248
- const chainValue = readValue(snapshot, 'chainRoute')
1249
- const wrapperValue = readValue(snapshot, 'wrapperRoute')
1250
- return new Set([
1251
- 'vision-http',
1252
- typeof chainValue === 'string' && chainValue !== '' ? chainValue : 'vision-chain',
1253
- typeof wrapperValue === 'string' && wrapperValue !== '' ? wrapperValue : 'deepseek-vision',
1254
- ])
1255
- }
1256
- const wrapGroupOptions = catalog.groups
1257
- .filter((group) => {
1258
- if (group.id === 'deepseek-official') return true
1259
- if (wrapExcludedRoutes().has(group.id)) return false
1260
- if (group.id.endsWith('-vision')) return false
1261
- return true
1262
- })
1263
- .map((group) =>
1264
- h('option', { value: group.id, key: group.id },
1265
- (group.name && group.name !== group.id ? group.name + ' (' + group.id + ')' : group.id)),
1266
- )
1267
1343
  const finishGuide = () => {
1268
1344
  finishVisionSettingsGuide()
1269
1345
  setGuideActive(false)
@@ -1318,10 +1394,7 @@ window.__ModuleLoader__.load({
1318
1394
  onChange: (event) => updateChain(index, { provider: row.provider, model: event.target.value }),
1319
1395
  },
1320
1396
  h('option', { value: '' }, visionProviderVisible(row.provider) ? t('selectModel') : t('pickProviderFirst')),
1321
- visionModelsFor(row.provider).map((model) =>
1322
- h('option', { value: model.id, key: model.id },
1323
- (model.name && model.name !== model.id ? model.name + ' (' + model.id + ')' : model.id)),
1324
- ),
1397
+ modelOptionsOf(visionModelsFor(row.provider)),
1325
1398
  ),
1326
1399
  h('button', {
1327
1400
  type: 'button', className: 'vr-reset', disabled: !writable, title: t('removeTitle'),
@@ -1376,10 +1449,7 @@ window.__ModuleLoader__.load({
1376
1449
  onChange: (event) => setPair({ provider: pair.provider, model: event.target.value }),
1377
1450
  },
1378
1451
  h('option', { value: '' }, pair.provider ? t('selectModel') : t('pickProviderFirst')),
1379
- modelsOf(pair.provider).map((model) =>
1380
- h('option', { value: model.id, key: model.id },
1381
- (model.name && model.name !== model.id ? model.name + ' (' + model.id + ')' : model.id)),
1382
- ),
1452
+ modelOptionsOf(modelsOf(pair.provider)),
1383
1453
  ),
1384
1454
  ),
1385
1455
  h('p', { className: 'vr-hint' }, t('textModelHint')),
@@ -1417,10 +1487,7 @@ window.__ModuleLoader__.load({
1417
1487
  onChange: (event) => updateWrap(index, { provider: row.provider, model: event.target.value }),
1418
1488
  },
1419
1489
  h('option', { value: '' }, row.provider ? t('wrapAllModels') : t('pickProviderFirst')),
1420
- modelsOf(row.provider).map((model) =>
1421
- h('option', { value: model.id, key: model.id },
1422
- (model.name && model.name !== model.id ? model.name + ' (' + model.id + ')' : model.id)),
1423
- ),
1490
+ modelOptionsOf(modelsOf(row.provider)),
1424
1491
  ),
1425
1492
  h('button', {
1426
1493
  type: 'button', className: 'vr-reset', disabled: !writable, title: t('removeTitle'),
@@ -1612,7 +1679,15 @@ window.__ModuleLoader__.load({
1612
1679
  h('div', { className: 'vr-quickstart-actions' },
1613
1680
  h('button', {
1614
1681
  type: 'button', className: 'vr-btn',
1615
- onClick: () => startVisionSettingsGuide(t),
1682
+ // Re-viewing the guide replays it from the beginning:
1683
+ // leave the settings modal first (its panel closes on
1684
+ // Escape), show the overview (steps 1-3), and then walk
1685
+ // through step 1 on the chat page — starting in place
1686
+ // would skip the session/text-model step entirely.
1687
+ onClick: () => {
1688
+ closeSettingsShell()
1689
+ showOnboarding(t)
1690
+ },
1616
1691
  }, t('quickStartGuide')),
1617
1692
  ),
1618
1693
  ),
@@ -1725,6 +1800,13 @@ window.__ModuleLoader__.load({
1725
1800
  )
1726
1801
  }
1727
1802
 
1803
+ // Skip re-renders when the app re-renders the settings panel for unrelated
1804
+ // reasons (tab switches, other cards' stores): the card's props come from
1805
+ // a stable inject object, so a shallow memo keeps the heavy field/select
1806
+ // DOM untouched until its own state or the settings scope changes.
1807
+ const VisionRouterCardMemoized =
1808
+ typeof React.memo === 'function' ? React.memo(VisionRouterCard) : VisionRouterCard
1809
+
1728
1810
  const ARTIFACT_TOOL_KEYS = [
1729
1811
  'vision_crop',
1730
1812
  'vision_pixel_diff',
@@ -1914,6 +1996,9 @@ window.__ModuleLoader__.load({
1914
1996
  ctx.effect(installStyles, 'vision-router: card styles')
1915
1997
  ctx.effect(() => installVisionSettingsGuide(t), 'vision-router: model selection guide')
1916
1998
  ctx.effect(() => installOnboarding(t), 'vision-router: first-run onboarding')
1999
+ // A stable props object: the memoized card skips re-renders only when
2000
+ // this identity stays fixed across slot renders.
2001
+ const cardInject = { scope, getConnection, t, locale: ctx.locale }
1917
2002
  ctx.effect(
1918
2003
  () =>
1919
2004
  ctx.slots.inject('settings.plugin.item', function* () {
@@ -1923,9 +2008,9 @@ window.__ModuleLoader__.load({
1923
2008
  id: 'vision-router',
1924
2009
  order: 30,
1925
2010
  label: () => t('nav'),
1926
- inject: () => ({ scope, getConnection, t, locale: ctx.locale }),
2011
+ inject: () => cardInject,
1927
2012
  },
1928
- VisionRouterCard,
2013
+ VisionRouterCardMemoized,
1929
2014
  )
1930
2015
  }),
1931
2016
  'vision-router: settings card',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-vision-router",
3
- "version": "1.2.0",
3
+ "version": "1.2.2",
4
4
  "description": "Eyes for text-only DeepSeek Harness agents: built-in free vision chain (no key) + pixel-level vision tools (Q&A, grounding, crop, pixel diff, colors, OCR, SVG trace, cutout, screenshots). One-command install, no Python, image turns work like ordinary tool-calling turns.",
5
5
  "license": "MIT",
6
6
  "repository": {