dsh-vision-router 3.0.0 → 3.0.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.
Files changed (37) hide show
  1. package/README.md +9 -2
  2. package/README.zh.md +7 -2
  3. package/docs/architecture/3.0-r11-adversarial-review.md +11 -5
  4. package/docs/architecture/ci-risk-posture.md +45 -0
  5. package/docs/architecture/dsh-021-alpha1-adaptation.md +46 -0
  6. package/docs/architecture/dsh-support-window.md +8 -0
  7. package/docs/releases/v3.0.1.md +77 -0
  8. package/docs/releases/v3.0.2.md +105 -0
  9. package/index.js +15 -3
  10. package/lib/adapter-ownership.js +51 -0
  11. package/lib/client-presentation-boundary-main.js +113 -50
  12. package/lib/client-presentation-boundary.js +36 -46
  13. package/lib/client.js +2 -1
  14. package/lib/core-primitives.js +29 -1
  15. package/lib/core-vision-surface.js +3 -1
  16. package/lib/desktop-screenshot-tool.js +11 -9
  17. package/lib/diagnostic-redaction.js +61 -12
  18. package/lib/doctor-cli-p0.js +36 -5
  19. package/lib/dsh-support-window.js +4 -1
  20. package/lib/file-logger.js +9 -9
  21. package/lib/image-offload-compat.js +10 -3
  22. package/lib/local-vision-stabilizer.js +10 -7
  23. package/lib/native-image-coexistence.js +11 -1
  24. package/lib/proxy-routing.js +18 -3
  25. package/lib/remote-settings-risk-client-boundary.js +6 -1
  26. package/lib/runtime-composition.js +8 -6
  27. package/lib/session-vision-mode-authority.js +2 -7
  28. package/lib/session-vision-policy.js +9 -4
  29. package/lib/settings-client-017-compat.js +7 -3
  30. package/lib/settings-number-contract.js +3 -1
  31. package/lib/vision-background-benchmark.js +14 -2
  32. package/lib/vision-background-stop-store-core.js +14 -2
  33. package/lib/vision-diagnostics-routes.js +9 -1
  34. package/lib/vision-model-visibility-boundary-main.js +15 -16
  35. package/lib/vision-resilience.js +31 -2
  36. package/lib/vision-tool-runtime-boundary.js +2 -7
  37. package/package.json +2 -2
@@ -432,21 +432,21 @@ export const CLIENT_PRESENTATION_PRELUDE = String.raw`(function(){
432
432
  if (next.zh && typeof next.zh === 'object') {
433
433
  next.zh = Object.assign({}, next.zh, {
434
434
  quickStartTitle: '聊天模型 + 识图模式',
435
- quickStartBody: '先选择你平时使用的聊天模型。需要看图时,点击输入框旁的「识图」;出现 ✓ 表示已开启。开启后会持续生效;在同一模型组内切换到另一个支持识图模式的模型也会保持开启,直到你主动关闭或切到没有对应识图模式的普通模型。',
435
+ quickStartBody: '先选择你平时使用的聊天模型。需要看图时,点击输入框旁的「识图」;它变成选中态(高亮底色)表示已开启。开启后会持续生效;在同一模型组内切换到另一个支持识图模式的模型也会保持开启,直到你主动关闭或切到没有对应识图模式的普通模型。',
436
436
  onboardingStep1Title: '1 · 选择聊天模型并开启识图',
437
- onboardingStep1Body: '先在聊天页右下角选择你平时使用的模型。需要看图时,点击模型选择器左侧的「识图」;出现 ✓ 表示已开启,不需要时再主动关闭。',
437
+ onboardingStep1Body: '先在聊天页右下角选择你平时使用的模型。需要看图时,点击模型选择器左侧的「识图」;它变成选中态(高亮底色)表示已开启,不需要时再主动关闭。',
438
438
  guideStep1Title: '第 1 步 · 选择聊天模型并认识「识图」',
439
- guideStep1Body: '高亮的是聊天模型选择器;它左侧就是「识图」按钮。先选择你平时使用的聊天模型;需要看图时点击「识图」,出现 ✓ 表示已开启。开启后会持续生效;在同一模型组内切换到另一个支持识图模式的模型也会保持开启,直到你主动关闭或切到没有对应识图模式的普通模型。选好后点击「下一步」。'
439
+ guideStep1Body: '高亮的是聊天模型选择器;它左侧就是「识图」按钮。先选择你平时使用的聊天模型;需要看图时点击「识图」,它变成选中态(高亮底色)表示已开启。开启后会持续生效;在同一模型组内切换到另一个支持识图模式的模型也会保持开启,直到你主动关闭或切到没有对应识图模式的普通模型。选好后点击「下一步」。'
440
440
  });
441
441
  }
442
442
  if (next.en && typeof next.en === 'object') {
443
443
  next.en = Object.assign({}, next.en, {
444
444
  quickStartTitle: 'Chat model + Vision mode',
445
- quickStartBody: 'Choose the chat model you normally use first. When you need image understanding, click “Vision” beside the composer; a ✓ means it is on. It stays on when you switch to another Vision-enabled model in the same model group, until you turn it off or choose a normal model with no matching Vision route.',
445
+ quickStartBody: 'Choose the chat model you normally use first. When you need image understanding, click “Vision” beside the composer; the chip lights up in its selected state to mean it is on. It stays on when you switch to another Vision-enabled model in the same model group, until you turn it off or choose a normal model with no matching Vision route.',
446
446
  onboardingStep1Title: '1 · Choose your chat model and enable Vision',
447
- onboardingStep1Body: 'Choose the model you normally use from the lower-right chat selector. When you need image understanding, click “Vision” immediately to the left of the model selector; a ✓ means it is on. Turn it off again when you no longer need it.',
447
+ onboardingStep1Body: 'Choose the model you normally use from the lower-right chat selector. When you need image understanding, click “Vision” immediately to the left of the model selector; the chip lights up in its selected state to mean it is on. Turn it off again when you no longer need it.',
448
448
  guideStep1Title: 'Step 1 · Choose your chat model and find “Vision”',
449
- guideStep1Body: 'The highlighted control is the chat model selector; the “Vision” button is immediately to its left. Choose your normal chat model first, then click “Vision” when you need image understanding. A ✓ means it is on. It stays on when you switch to another Vision-enabled model in the same model group, until you turn it off or choose a normal model with no matching Vision route. Click “Next” when done.'
449
+ guideStep1Body: 'The highlighted control is the chat model selector; the “Vision” button is immediately to its left. Choose your normal chat model first, then click “Vision” when you need image understanding. The chip lights up in its selected state to mean it is on. It stays on when you switch to another Vision-enabled model in the same model group, until you turn it off or choose a normal model with no matching Vision route. Click “Next” when done.'
450
450
  });
451
451
  }
452
452
  return next;
@@ -948,6 +948,89 @@ export const CLIENT_PRESENTATION_PRELUDE = String.raw`(function(){
948
948
  }, 'vision-router: clipboard image paste normalization');
949
949
  }
950
950
 
951
+ // The composer chip chrome follows the shipped mode chips (ui-plan's PlanChip and the
952
+ // access-mode trigger): 28px tall, token radius, no border, token hover fill, the shipped
953
+ // focus ring, and the shipped "selected chip" treatment while Vision is on. The composer row
954
+ // is a size container (InputBar .row), so the label collapses to the 14px glyph below the
955
+ // shipped 460px cut — the same cut the access-mode trigger uses — instead of squeezing the
956
+ // model trigger on narrow windows.
957
+ var VISION_TOGGLE_CSS = '' +
958
+ '.vr-vision-toggle{display:inline-flex;align-items:center;gap:4px;min-width:0;height:28px;' +
959
+ 'padding:0 8px;border:none;border-radius:var(--dsw-radius-sm);background:transparent;' +
960
+ 'color:var(--dsw-alias-label-secondary);font:inherit;font-size:13px;font-weight:500;' +
961
+ 'line-height:20px;cursor:pointer;white-space:nowrap}' +
962
+ '.vr-vision-toggle:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}' +
963
+ '.vr-vision-toggle:focus-visible{outline:var(--dsw-focus-ring-width) solid ' +
964
+ 'var(--dsw-focus-ring-color,var(--dsw-alias-brand-primary));outline-offset:2px}' +
965
+ '.vr-vision-toggle:disabled{cursor:default}' +
966
+ // Dim only the states that read as "cannot be used right now". A disabled chip that is
967
+ // merely waiting for this toggle's own selection keeps full opacity, so the composer does
968
+ // not flash a greyed control while the model swap it owns is still settling.
969
+ '.vr-vision-toggle[data-dimmed="true"]{opacity:.45}' +
970
+ '.vr-vision-toggle[data-active="true"]{color:var(--dsw-alias-label-primary);' +
971
+ 'background:var(--dsw-alias-button-ghost-active-fill);' +
972
+ 'box-shadow:inset 0 0 0 1px var(--dsw-alias-button-ghost-active-border)}' +
973
+ '.vr-vision-toggle[data-active="true"]:hover:not(:disabled){' +
974
+ 'background:var(--dsw-alias-button-ghost-active-hover)}' +
975
+ '.vr-vision-toggle-glyph{display:inline-flex;align-items:center;justify-content:center;' +
976
+ 'flex:0 0 auto;width:14px;height:14px;font-size:13px;line-height:1;color:currentColor}' +
977
+ '.vr-vision-toggle-glyph-part{display:inline-flex;align-items:center;justify-content:center;' +
978
+ 'width:14px;height:14px}' +
979
+ // The check carries the state only while the label is there to explain it. Once the label
980
+ // collapses (the shipped 460px cut below) a bare check means nothing, so the Vision eye —
981
+ // the only thing that still says what the chip does — stays, and the chip's selected chrome
982
+ // marks on/off on its own.
983
+ '.vr-vision-toggle-glyph-check{display:none}' +
984
+ '@container (min-width:460.01px){' +
985
+ '.vr-vision-toggle[data-active="true"] .vr-vision-toggle-glyph-eye{display:none}' +
986
+ '.vr-vision-toggle[data-active="true"] .vr-vision-toggle-glyph-check{display:inline-flex}}' +
987
+ '@container (max-width:460px){.vr-vision-toggle-label{display:none}}';
988
+
989
+ function installVisionToggleStyles() {
990
+ if (typeof document === 'undefined' || !document.head) return undefined;
991
+ var tag = document.createElement('style');
992
+ tag.dataset.plugin = 'dsh-vision-router';
993
+ tag.dataset.pluginCss = 'dsh-vision-router/mode-toggle';
994
+ tag.textContent = VISION_TOGGLE_CSS;
995
+ document.head.appendChild(tag);
996
+ return function(){ tag.remove(); };
997
+ }
998
+
999
+ // A presented image that mounts below the reader is the same as not being shown: the card
1000
+ // renders next to its tool call, and a reader who is at the end of a long conversation has
1001
+ // that call thousands of pixels above them. The card calls this on mount; it only moves the
1002
+ // reader for content that arrived below the viewport — a card that is already visible is left
1003
+ // alone, and so is history above the reader when a session is opened.
1004
+ function focusPresentedCard(node, viewportHeight) {
1005
+ if (!node || typeof node.getBoundingClientRect !== 'function') return false;
1006
+ var rect = node.getBoundingClientRect();
1007
+ if (rect.top <= viewportHeight) return false;
1008
+ var scroller = node.parentElement;
1009
+ var hops = 0;
1010
+ while (scroller && hops < 20) {
1011
+ var style = typeof getComputedStyle === 'function' ? getComputedStyle(scroller) : undefined;
1012
+ var scrollable = /(auto|scroll)/.test(String(style && style.overflowY)) &&
1013
+ scroller.scrollHeight > scroller.clientHeight + 4;
1014
+ if (scrollable) {
1015
+ var box = scroller.getBoundingClientRect();
1016
+ scroller.scrollTop += (rect.top - box.top) - (box.height / 2 - rect.height / 2);
1017
+ return true;
1018
+ }
1019
+ scroller = scroller.parentElement;
1020
+ hops += 1;
1021
+ }
1022
+ return false;
1023
+ }
1024
+
1025
+ try {
1026
+ globalThis.__dvrPresentFocus = function () {
1027
+ if (typeof document === 'undefined') return false;
1028
+ var cards = document.querySelectorAll('[data-dvr-present]');
1029
+ var last = cards[cards.length - 1];
1030
+ return last ? focusPresentedCard(last, globalThis.innerHeight || 0) : false;
1031
+ };
1032
+ } catch (_) {}
1033
+
951
1034
  function installVisionModeToggle(ctx, React, primitives) {
952
1035
  if (!ctx || typeof ctx.inject !== 'function' || !React) return;
953
1036
  var zh = {
@@ -974,6 +1057,9 @@ export const CLIENT_PRESENTATION_PRELUDE = String.raw`(function(){
974
1057
  try {
975
1058
  ctx.effect(function(){ return ctx.locale.register(VISION_MODE_NS, { zh: zh, en: en }); }, 'vision-router: mode toggle locale');
976
1059
  } catch (_) {}
1060
+ try {
1061
+ ctx.effect(function(){ return installVisionToggleStyles(); }, 'vision-router: mode toggle styles');
1062
+ } catch (_) {}
977
1063
 
978
1064
  function FallbackToast(props) {
979
1065
  React.useEffect(function(){
@@ -1004,9 +1090,13 @@ export const CLIENT_PRESENTATION_PRELUDE = String.raw`(function(){
1004
1090
  }
1005
1091
 
1006
1092
  var ToastComponent = primitives && typeof primitives.Toast === 'function' ? primitives.Toast : FallbackToast;
1007
- var WarningIcon = primitives && typeof primitives.IconWarningOutline16 === 'function'
1008
- ? primitives.IconWarningOutline16
1009
- : undefined;
1093
+ // The product icon set names weights, not sizes (IconWarningOutlineRegular); the old
1094
+ // "…16" spelling never existed, so the toast silently fell back to a text glyph.
1095
+ var WarningIcon = primitives && typeof primitives.IconWarningOutlineRegular === 'function'
1096
+ ? primitives.IconWarningOutlineRegular
1097
+ : primitives && typeof primitives.IconWarningOutline16 === 'function'
1098
+ ? primitives.IconWarningOutline16
1099
+ : undefined;
1010
1100
 
1011
1101
  var settings = bindVisionModeSettings(ctx);
1012
1102
  var unavailableSettingsState = { value: undefined };
@@ -1106,29 +1196,6 @@ export const CLIENT_PRESENTATION_PRELUDE = String.raw`(function(){
1106
1196
  : livePair.mode === 'unavailable'
1107
1197
  ? t('unavailable')
1108
1198
  : active ? t('disable') : t('enable');
1109
- var style = {
1110
- appearance: 'none',
1111
- minHeight: 28,
1112
- display: 'inline-flex',
1113
- alignItems: 'center',
1114
- gap: 5,
1115
- padding: '4px 8px',
1116
- borderRadius: 8,
1117
- border: '1px solid ' + (active ? 'var(--dsw-alias-brand-primary)' : 'var(--dsw-alias-border-l2)'),
1118
- background: active
1119
- ? 'color-mix(in srgb, var(--dsw-alias-brand-primary) 14%, transparent)'
1120
- : 'transparent',
1121
- color: active ? 'var(--dsw-alias-brand-primary)' : 'var(--dsw-alias-label-secondary)',
1122
- boxShadow: active ? 'inset 0 0 0 1px var(--dsw-alias-brand-primary)' : 'none',
1123
- font: 'inherit',
1124
- fontSize: 12,
1125
- lineHeight: 1.4,
1126
- fontWeight: active ? 650 : 500,
1127
- cursor: disabled ? 'default' : 'pointer',
1128
- opacity: disabled && !ownSettling && (livePair.mode === 'unavailable' || loading) ? 0.45 : 1,
1129
- whiteSpace: 'nowrap'
1130
- };
1131
-
1132
1199
  function announceRejectedSelection() {
1133
1200
  var latest;
1134
1201
  try {
@@ -1155,7 +1222,9 @@ export const CLIENT_PRESENTATION_PRELUDE = String.raw`(function(){
1155
1222
  'aria-busy': busy || loading,
1156
1223
  title: title,
1157
1224
  disabled: disabled,
1158
- style: style,
1225
+ className: 'vr-vision-toggle',
1226
+ 'data-active': active ? 'true' : 'false',
1227
+ 'data-dimmed': disabled && !ownSettling && (livePair.mode === 'unavailable' || loading) ? 'true' : 'false',
1159
1228
  onClick: function() {
1160
1229
  if (disabled || !pair.target || typeof props.select !== 'function') return;
1161
1230
  var target = pair.target;
@@ -1179,25 +1248,19 @@ export const CLIENT_PRESENTATION_PRELUDE = String.raw`(function(){
1179
1248
  });
1180
1249
  }
1181
1250
  },
1182
- // One fixed 14px leading slot carries the state icon: the eye when the
1183
- // route is ordinary, the check when Vision is on. Appending the check
1184
- // only while active changed the chip width by ~18px and reflowed the
1185
- // composer row; reserving trailing space kept the width but left the
1186
- // inactive chip visually off-centre.
1251
+ // One fixed 14px leading slot carries both glyphs: the Vision eye and the check that
1252
+ // marks the enabled state. The stylesheet shows the check only while the label is
1253
+ // present (the same 460px container cut that collapses the label), because a bare check
1254
+ // tells a reader nothing — once the label is gone the eye, which still says what the
1255
+ // chip does, stays. The slot is a fixed 14px box either way, so the chip width never
1256
+ // changes with the state.
1187
1257
  React.createElement('span', {
1188
1258
  'aria-hidden': 'true',
1189
- style: {
1190
- display: 'inline-flex',
1191
- alignItems: 'center',
1192
- justifyContent: 'center',
1193
- flex: '0 0 auto',
1194
- width: 14,
1195
- height: 14,
1196
- fontSize: 13,
1197
- lineHeight: 1
1198
- }
1199
- }, active ? '✓' : '👁'),
1200
- React.createElement('span', null, t('label'))
1259
+ className: 'vr-vision-toggle-glyph'
1260
+ },
1261
+ React.createElement('span', { className: 'vr-vision-toggle-glyph-part vr-vision-toggle-glyph-eye' }, '👁'),
1262
+ React.createElement('span', { className: 'vr-vision-toggle-glyph-part vr-vision-toggle-glyph-check' }, '✓')),
1263
+ React.createElement('span', { className: 'vr-vision-toggle-label' }, t('label'))
1201
1264
  );
1202
1265
 
1203
1266
  var toastNode = toast
@@ -10,43 +10,32 @@ import { installVisionModelVisibilityBoundary } from './vision-model-visibility-
10
10
 
11
11
  const CLIENT_PRESENTATION_MARK = 'data-vision-router-presentation-boundary'
12
12
 
13
- const VISION_TOGGLE_TEXT_GLYPH = String.raw` // One fixed 14px leading slot carries the state icon: the eye when the
14
- // route is ordinary, the check when Vision is on. Appending the check
15
- // only while active changed the chip width by ~18px and reflowed the
16
- // composer row; reserving trailing space kept the width but left the
17
- // inactive chip visually off-centre.
13
+ const VISION_TOGGLE_TEXT_GLYPH = String.raw` // One fixed 14px leading slot carries both glyphs: the Vision eye and the check that
14
+ // marks the enabled state. The stylesheet shows the check only while the label is
15
+ // present (the same 460px container cut that collapses the label), because a bare check
16
+ // tells a reader nothing — once the label is gone the eye, which still says what the
17
+ // chip does, stays. The slot is a fixed 14px box either way, so the chip width never
18
+ // changes with the state.
18
19
  React.createElement('span', {
19
20
  'aria-hidden': 'true',
20
- style: {
21
- display: 'inline-flex',
22
- alignItems: 'center',
23
- justifyContent: 'center',
24
- flex: '0 0 auto',
25
- width: 14,
26
- height: 14,
27
- fontSize: 13,
28
- lineHeight: 1
29
- }
30
- }, active ? '✓' : '👁'),
31
- React.createElement('span', null, t('label'))`
32
-
33
- const VISION_TOGGLE_FIXED_SVG = String.raw` // One fixed 14px leading slot carries the state icon: the eye when the
34
- // route is ordinary, the check when Vision is on. Appending the check
35
- // only while active changed the chip width by ~18px and reflowed the
36
- // composer row; reserving trailing space kept the width but left the
37
- // inactive chip visually off-centre.
21
+ className: 'vr-vision-toggle-glyph'
22
+ },
23
+ React.createElement('span', { className: 'vr-vision-toggle-glyph-part vr-vision-toggle-glyph-eye' }, '👁'),
24
+ React.createElement('span', { className: 'vr-vision-toggle-glyph-part vr-vision-toggle-glyph-check' }, '✓')),
25
+ React.createElement('span', { className: 'vr-vision-toggle-label' }, t('label'))`
26
+
27
+ const VISION_TOGGLE_FIXED_SVG = String.raw` // One fixed 14px leading slot carries both glyphs: the Vision eye and the check that
28
+ // marks the enabled state. The stylesheet shows the check only while the label is
29
+ // present (the same 460px container cut that collapses the label), because a bare check
30
+ // tells a reader nothing — once the label is gone the eye, which still says what the
31
+ // chip does, stays. The slot is a fixed 14px box either way, so the chip width never
32
+ // changes with the state.
38
33
  React.createElement('span', {
39
34
  'aria-hidden': 'true',
40
- style: {
41
- display: 'inline-flex',
42
- alignItems: 'center',
43
- justifyContent: 'center',
44
- flex: '0 0 auto',
45
- width: 14,
46
- height: 14
47
- }
48
- }, active
49
- ? React.createElement('svg', {
35
+ className: 'vr-vision-toggle-glyph'
36
+ },
37
+ React.createElement('span', { className: 'vr-vision-toggle-glyph-part vr-vision-toggle-glyph-eye' },
38
+ React.createElement('svg', {
50
39
  width: 14,
51
40
  height: 14,
52
41
  viewBox: '0 0 14 14',
@@ -55,13 +44,13 @@ const VISION_TOGGLE_FIXED_SVG = String.raw` // One fixed 14px leading s
55
44
  focusable: 'false',
56
45
  style: { display: 'block', flex: '0 0 auto' }
57
46
  }, React.createElement('path', {
58
- d: 'M2.75 7.15 5.6 10 11.25 4.35',
59
- stroke: 'currentColor',
60
- strokeWidth: 1.5,
61
- strokeLinecap: 'round',
62
- strokeLinejoin: 'round'
63
- }))
64
- : React.createElement('svg', {
47
+ fillRule: 'evenodd',
48
+ clipRule: 'evenodd',
49
+ d: 'M7 2.25c-2.84 0-5.04 1.69-6.25 4.25a1.15 1.15 0 0 0 0 1C1.96 10.06 4.16 11.75 7 11.75s5.04-1.69 6.25-4.25a1.15 1.15 0 0 0 0-1C12.04 3.94 9.84 2.25 7 2.25Zm0 1.25c2.16 0 3.96 1.21 5.05 3.5C10.96 9.29 9.16 10.5 7 10.5S3.04 9.29 1.95 7C3.04 4.71 4.84 3.5 7 3.5Zm0 1.25A2.25 2.25 0 1 0 7 9.25a2.25 2.25 0 0 0 0-4.5Zm0 1.25a1 1 0 1 1 0 2 1 1 0 0 1 0-2Z',
50
+ fill: 'currentColor'
51
+ }))),
52
+ React.createElement('span', { className: 'vr-vision-toggle-glyph-part vr-vision-toggle-glyph-check' },
53
+ React.createElement('svg', {
65
54
  width: 14,
66
55
  height: 14,
67
56
  viewBox: '0 0 14 14',
@@ -70,12 +59,13 @@ const VISION_TOGGLE_FIXED_SVG = String.raw` // One fixed 14px leading s
70
59
  focusable: 'false',
71
60
  style: { display: 'block', flex: '0 0 auto' }
72
61
  }, React.createElement('path', {
73
- fillRule: 'evenodd',
74
- clipRule: 'evenodd',
75
- d: 'M7 2.25c-2.84 0-5.04 1.69-6.25 4.25a1.15 1.15 0 0 0 0 1C1.96 10.06 4.16 11.75 7 11.75s5.04-1.69 6.25-4.25a1.15 1.15 0 0 0 0-1C12.04 3.94 9.84 2.25 7 2.25Zm0 1.25c2.16 0 3.96 1.21 5.05 3.5C10.96 9.29 9.16 10.5 7 10.5S3.04 9.29 1.95 7C3.04 4.71 4.84 3.5 7 3.5Zm0 1.25A2.25 2.25 0 1 0 7 9.25a2.25 2.25 0 0 0 0-4.5Zm0 1.25a1 1 0 1 1 0 2 1 1 0 0 1 0-2Z',
76
- fill: 'currentColor'
77
- }))),
78
- React.createElement('span', null, t('label'))`
62
+ d: 'M2.75 7.15 5.6 10 11.25 4.35',
63
+ stroke: 'currentColor',
64
+ strokeWidth: 1.5,
65
+ strokeLinecap: 'round',
66
+ strokeLinejoin: 'round'
67
+ })))),
68
+ React.createElement('span', { className: 'vr-vision-toggle-label' }, t('label'))`
79
69
 
80
70
  const BITMAP_LIMITS_BASE = String.raw` var maxBitmapSourceBytes = 64 * 1024 * 1024;
81
71
  var maxBitmapDimension = 10000;
package/lib/client.js CHANGED
@@ -4770,6 +4770,7 @@ openLogFolder,
4770
4770
  const imageKeySignature = JSON.stringify(imageKeys)
4771
4771
  const imageOwnerToken = React.useRef({}).current
4772
4772
  const ownedImageKeys = React.useRef(new Set())
4773
+ React.useEffect(() => { if (globalThis.__dvrPresentFocus) globalThis.__dvrPresentFocus() }, [imageKeySignature])
4773
4774
  React.useEffect(() => {
4774
4775
  const current = new Set(imageKeys)
4775
4776
  for (const key of ownedImageKeys.current) {
@@ -4809,7 +4810,7 @@ openLogFolder,
4809
4810
  }
4810
4811
  return React.createElement(
4811
4812
  'div',
4812
- { style: { display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: 6, padding: '4px 0' } },
4813
+ { 'data-dvr-present': 'true', style: { display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: 6, padding: '4px 0' } },
4813
4814
  parsed && typeof parsed.label === 'string' && parsed.label !== 'image'
4814
4815
  ? React.createElement(
4815
4816
  'div',
@@ -161,6 +161,18 @@ export function eventHasImage(event) {
161
161
  return false
162
162
  }
163
163
 
164
+ /**
165
+ * Read one options/argument bag as a record, or `undefined` when the value is not
166
+ * an object at all. Callers write `objectRecord(options) ?? {}`: an explicit
167
+ * `null` (or a primitive) then means "no options" instead of throwing from a
168
+ * parameter destructure or a property read.
169
+ */
170
+ export function objectRecord(value) {
171
+ return value !== null && typeof value === 'object'
172
+ ? value
173
+ : undefined
174
+ }
175
+
164
176
  export function localOnlyVisionEnabled(config = {}) {
165
177
  return !!config && config.localOnlyVision === true
166
178
  }
@@ -324,6 +336,11 @@ export function rewriteToolResultImages(content, replace) {
324
336
 
325
337
  export function renderVisionPresent(value) {
326
338
  const attachment = value.attachment
339
+ // The card renders the image next to the tool call, which can sit far above a reader who is at
340
+ // the end of a long conversation. The reply body is where that reader actually is, and the
341
+ // client rewrites an absolute local image path written in Markdown into a displayable URL, so
342
+ // the result hands back a paste-ready snippet plus the rule that says why it matters.
343
+ const label = String(value.label === undefined ? 'image' : value.label).replace(/[[\]]/g, '')
327
344
  return [
328
345
  {
329
346
  type: 'text',
@@ -335,6 +352,10 @@ export function renderVisionPresent(value) {
335
352
  bytes: value.bytes,
336
353
  safePresentation: true,
337
354
  attachmentId: String(attachment.attachmentId),
355
+ replyImage: `![${label}](<${value.path}>)`,
356
+ replyImageHint:
357
+ 'Also put replyImage in your visible reply: the host renders an absolute image path in a message ' +
358
+ 'at full size where the reader is, while this card only shows a thumbnail that later messages push away.',
338
359
  }),
339
360
  },
340
361
  { type: 'image', attachment },
@@ -1937,7 +1958,11 @@ async function callLmStudioNative(provider, messages, options = {}) {
1937
1958
  ...(typeof options.temperature === 'number' ? { temperature: options.temperature } : {}),
1938
1959
  ...(typeof options.top_p === 'number' ? { top_p: options.top_p } : {}),
1939
1960
  }
1940
- const response = await fetch(`${apiRoot}/api/v1/chat`, {
1961
+ const transport = options.providerTransport
1962
+ const requestFetch = typeof transport?.fetch === 'function'
1963
+ ? (input, init) => transport.fetch(input, init, { providerName: provider.name, allowProxy: true })
1964
+ : fetch
1965
+ const response = await requestFetch(`${apiRoot}/api/v1/chat`, {
1941
1966
  method: 'POST',
1942
1967
  headers: { 'content-type': 'application/json' },
1943
1968
  body: JSON.stringify(body),
@@ -1984,6 +2009,9 @@ export async function callLocalBackend(provider, messages, options = {}) {
1984
2009
  return callLmStudioNative(provider, messages, {
1985
2010
  maxTokens,
1986
2011
  signal: options.signal,
2012
+ // The native LM Studio transport is a provider leg like the openai/anthropic
2013
+ // ones: it must use the Router-owned transport so proxy/proxyHosts apply.
2014
+ providerTransport: options.providerTransport,
1987
2015
  ...(typeof provider.reasoningEffort === 'string' ? { reasoningEffort: provider.reasoningEffort } : {}),
1988
2016
  ...sampling,
1989
2017
  })
@@ -1,4 +1,5 @@
1
1
  import { currentSessionSurfacePolicy } from './session-surface-policy.js'
2
+ import { objectRecord } from './core-primitives.js'
2
3
  import {
3
4
  CORE_VISION_SURFACE_KEYS,
4
5
  projectCoreVisionSurface,
@@ -32,7 +33,8 @@ export function currentCoreVisionSurface(config = {}, options = {}) {
32
33
  * The config source stays live, but schema bootstrap is an internal lifecycle
33
34
  * bit rather than a fake Settings/config value.
34
35
  */
35
- export function createCoreVisionSurfaceRuntime({ config = {} } = {}) {
36
+ export function createCoreVisionSurfaceRuntime(rawOptions) {
37
+ const { config = {} } = objectRecord(rawOptions) ?? {}
36
38
  let schemaBootstrapping = true
37
39
 
38
40
  const current = () => {
@@ -9,6 +9,7 @@ import {
9
9
  callLocalBackend,
10
10
  localDescribePrompt,
11
11
  localProvidersOf,
12
+ objectRecord,
12
13
  toOpenAIContent,
13
14
  } from './core-primitives.js'
14
15
  import { loadSharp } from './sharp-runtime.js'
@@ -170,15 +171,16 @@ async function identifyDesktopCapture(data, {
170
171
  * this module owns only the platform capture and optional local-recognition
171
172
  * implementation behind that existing permission boundary.
172
173
  */
173
- export function createDesktopScreenshotTool({
174
- current,
175
- timeoutMs,
176
- saveArtifact,
177
- stringOutput,
178
- instantLocalStyle,
179
- providerTransport,
180
- captureDesktop = captureDesktopPng,
181
- } = {}) {
174
+ export function createDesktopScreenshotTool(rawOptions) {
175
+ const {
176
+ current,
177
+ timeoutMs,
178
+ saveArtifact,
179
+ stringOutput,
180
+ instantLocalStyle,
181
+ providerTransport,
182
+ captureDesktop = captureDesktopPng,
183
+ } = objectRecord(rawOptions) ?? {}
182
184
  if (
183
185
  typeof current !== 'function' ||
184
186
  typeof timeoutMs !== 'function' ||
@@ -1,26 +1,75 @@
1
1
  const SENSITIVE_QUERY = /^(?:api[_-]?key|key|token|secret|password|authorization|auth|signature|sig)$/i
2
2
 
3
+ /**
4
+ * Credential shapes worth removing from anything that reaches a log, a support
5
+ * report or a model-visible diagnostic.
6
+ *
7
+ * Two callers used to keep their own partial copy of this list, so each one
8
+ * leaked what the other covered (`Authorization: token ghp_…`, `Cookie:`, bare
9
+ * platform tokens, `#token=` fragments). One shared list is the fix: callers may
10
+ * still add their own extra passes, but never their own subset of this one.
11
+ */
12
+ // One spelling for every caller: the log path's own test pins the uppercase
13
+ // marker (`/\[REDACTED/`), the diagnostic path's test is case-insensitive, so
14
+ // uppercase is the only spelling that keeps both existing contracts intact.
15
+ const REDACTED = '[REDACTED]'
16
+ const CREDENTIAL_SHAPE_PATTERNS = [
17
+ // Any Authorization scheme, not only Bearer/Basic (`token`, `ApiKey`, `AWS4-…`),
18
+ // but only the credential itself: a single line may carry safe diagnostics
19
+ // after the header and a support report must keep them. `;` stays inside the value
20
+ // (the log sink has always consumed it) while `,` separates what follows.
21
+ [/\b(Authorization\s*[:=]\s*)(?:[A-Za-z][A-Za-z0-9._-]*\s+)?[^\s,]+/gi, `$1${REDACTED}`],
22
+ [/\b(Proxy-Authorization\s*[:=]\s*)(?:[A-Za-z][A-Za-z0-9._-]*\s+)?[^\s,]+/gi, `$1${REDACTED}`],
23
+ // Cookies are `name=value; name=value` on one line and there is no safe way to
24
+ // guess which pair carries the session, so the whole header value goes.
25
+ [/\b((?:Set-)?Cookie\s*[:=]\s*)[^\r\n]+/gi, `$1${REDACTED}`],
26
+ // URL userinfo (`scheme://user:secret@host`) without needing a URL parser, so
27
+ // the log path — which never parsed URLs — covers it too.
28
+ [/\b([a-z][a-z0-9+.-]*:\/\/)[^/\s@]+@/gi, `$1${REDACTED}@`],
29
+ [/\b(Bearer\s+)[A-Za-z0-9._~+/-]{8,}/gi, `$1${REDACTED}`],
30
+ // Vendor-prefixed keys: OpenAI/Anthropic, GitHub, AWS, Slack, Google, PEM.
31
+ [/\b(sk-(?:proj-|ant-|or-)?)[A-Za-z0-9_-]{8,}/gi, `$1${REDACTED}`],
32
+ [/\b(gh[pousr]_|github_pat_)[A-Za-z0-9_]{8,}/gi, `$1${REDACTED}`],
33
+ [/\b(AKIA|ASIA)[0-9A-Z]{16}\b/g, `${REDACTED}`],
34
+ [/\b(xox[baprs]-)[A-Za-z0-9-]{8,}/gi, `$1${REDACTED}`],
35
+ [/\b(AIza)[A-Za-z0-9_-]{16,}/g, `$1${REDACTED}`],
36
+ // Signed JSON Web Tokens, with or without a leading scheme word.
37
+ [/\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g, REDACTED],
38
+ // An unterminated block (a bounded upstream read truncates before the END marker) must
39
+ // redact to the end of the text: without the `$` alternative its whole body passes through.
40
+ [/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z ]*PRIVATE KEY-----|$)/g, REDACTED],
41
+ // `api_key=…`-style assignments, including `apikey` and `access_token`. `;` stays inside the
42
+ // value for the same reason as above; `&` separates an unrelated parameter a report must keep.
43
+ [/\b(access[_-]?token|api[_-]?key|apikey|token|secret|password|passwd|signature|sig)\s*[:=]\s*([^\s,&"']+)/gi, `$1=${REDACTED}`],
44
+ ]
45
+
46
+ /** Apply only the shared credential-shape passes (no URL parsing, no truncation). */
47
+ export function redactCredentialShapes(text) {
48
+ let out = String(text ?? '')
49
+ for (const [pattern, replacement] of CREDENTIAL_SHAPE_PATTERNS) {
50
+ out = out.replace(pattern, replacement)
51
+ }
52
+ return out
53
+ }
54
+
3
55
  function redactUrl(match) {
4
56
  try {
5
57
  const url = new URL(match)
6
- url.username = url.username ? '[redacted]' : ''
7
- url.password = url.password ? '[redacted]' : ''
58
+ url.username = url.username ? REDACTED : ''
59
+ url.password = url.password ? REDACTED : ''
8
60
  for (const key of [...url.searchParams.keys()]) {
9
- if (SENSITIVE_QUERY.test(key)) url.searchParams.set(key, '[redacted]')
61
+ if (SENSITIVE_QUERY.test(key)) url.searchParams.set(key, REDACTED)
10
62
  }
11
- return url.toString()
63
+ return redactCredentialShapes(url.toString())
12
64
  } catch {
13
- return match
65
+ return redactCredentialShapes(match)
14
66
  }
15
67
  }
16
68
 
17
69
  export function redactDiagnosticText(value, max = 400) {
18
70
  const limit = Math.max(32, Math.min(4000, Math.floor(Number(max) || 400)))
19
- let text = String(value ?? '').replace(/[\u0000-\u001f\u007f-\u009f]+/g, ' ')
20
- text = text.replace(/\bhttps?:\/\/[^\s"'<>]+/gi, redactUrl)
21
- text = text.replace(/\b(Authorization\s*:\s*)(?:Bearer|Basic)\s+[^\s,;]+/gi, '$1[redacted]')
22
- text = text.replace(/\b(Bearer\s+)[A-Za-z0-9._~+\/-]{12,}/gi, '$1[redacted]')
23
- text = text.replace(/\b(sk-(?:proj-)?)[A-Za-z0-9_-]{8,}/gi, '$1[redacted]')
24
- text = text.replace(/\b(api[_-]?key|token|secret|password|authorization|signature)\s*[:=]\s*([^\s,;&]+)/gi, '$1=[redacted]')
25
- return text.slice(0, limit)
71
+ const text = String(value ?? '').replace(/[\u0000-\u001f\u007f-\u009f]+/g, ' ')
72
+ return redactCredentialShapes(
73
+ text.replace(/\bhttps?:\/\/[^\s"'<>]+/gi, redactUrl),
74
+ ).slice(0, limit)
26
75
  }
@@ -8,6 +8,7 @@ import {
8
8
  normalizeDshHostCapabilities,
9
9
  } from './dsh-host-capabilities.js'
10
10
  import { DOCTOR_RESPONSE_MAX_BYTES, readResponseJsonBounded } from './http-body-limit.js'
11
+ import { redactDiagnosticText } from './diagnostic-redaction.js'
11
12
  import {
12
13
  DSH_SUPPORT_WINDOW,
13
14
  DSH_VERIFICATION_EVIDENCE,
@@ -16,6 +17,10 @@ import {
16
17
  } from './dsh-support-window.js'
17
18
 
18
19
  const HOST_CAPABILITIES_PATH = '/_dsh/vision-router/host-capabilities'
20
+ const DOCTOR_PROBE_TIMEOUT_MS = 1500
21
+ // The fence is a backstop, not a competing deadline: it must sit after the signal
22
+ // so a cooperative fetch keeps reporting the abort reason it reports today.
23
+ const DOCTOR_PROBE_FENCE_MS = DOCTOR_PROBE_TIMEOUT_MS + 250
19
24
 
20
25
  function commandOf(argv) {
21
26
  const first = argv.find((value) => typeof value === 'string' && !value.startsWith('-'))
@@ -51,11 +56,34 @@ export async function probeDoctorHostCapabilities({ baseUrl, fetchImpl = globalT
51
56
  if (typeof fetchImpl !== 'function') {
52
57
  return { ok: false, source: 'runtime-unavailable', capabilities: unknownSnapshot() }
53
58
  }
54
- const response = await fetchImpl(normalized, {
55
- method: 'GET',
56
- headers: { accept: 'application/json' },
57
- signal: AbortSignal.timeout(1500),
59
+ // The signal below only bounds a fetch that honours it. A non-cooperative
60
+ // implementation would leave this probe pending forever, so the same inline
61
+ // fence the live-model discovery uses (its own timer + explicit cleanup)
62
+ // bounds the await itself. The signal stays so a cooperative fetch still
63
+ // reports the aborted reason it reports today.
64
+ // The fence deliberately keeps the event loop alive: this is a foreground
65
+ // probe the CLI is waiting on, unlike the background benchmark timers that
66
+ // unref themselves. (Node's own AbortSignal.timeout timer is unref'd, so
67
+ // unref'ing this one too would let the process exit before it fires.)
68
+ let deadlineTimer
69
+ const deadline = new Promise((_, reject) => {
70
+ deadlineTimer = setTimeout(() => {
71
+ reject(new Error(`Doctor Host capability probe exceeded ${DOCTOR_PROBE_TIMEOUT_MS}ms`))
72
+ }, DOCTOR_PROBE_FENCE_MS)
58
73
  })
74
+ let response
75
+ try {
76
+ response = await Promise.race([
77
+ fetchImpl(normalized, {
78
+ method: 'GET',
79
+ headers: { accept: 'application/json' },
80
+ signal: AbortSignal.timeout(DOCTOR_PROBE_TIMEOUT_MS),
81
+ }),
82
+ deadline,
83
+ ])
84
+ } finally {
85
+ clearTimeout(deadlineTimer)
86
+ }
59
87
  if (!response.ok) {
60
88
  return {
61
89
  ok: false,
@@ -73,10 +101,13 @@ export async function probeDoctorHostCapabilities({ baseUrl, fetchImpl = globalT
73
101
  capabilities: normalizeDshHostCapabilities(body?.capabilities),
74
102
  }
75
103
  } catch (error) {
104
+ // A fetch failure can echo the request URL, and a proxy failure can echo the
105
+ // proxy URL with its userinfo; the doctor report is meant to be shareable, so
106
+ // the message is redacted at the source rather than at each print site.
76
107
  return {
77
108
  ok: false,
78
109
  source: 'runtime-unavailable',
79
- error: error instanceof Error ? error.message : String(error),
110
+ error: redactDiagnosticText(error instanceof Error ? error.message : String(error), 200),
80
111
  capabilities: unknownSnapshot(),
81
112
  }
82
113
  }