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.
- package/README.md +9 -2
- package/README.zh.md +7 -2
- package/docs/architecture/3.0-r11-adversarial-review.md +11 -5
- package/docs/architecture/ci-risk-posture.md +45 -0
- package/docs/architecture/dsh-021-alpha1-adaptation.md +46 -0
- package/docs/architecture/dsh-support-window.md +8 -0
- package/docs/releases/v3.0.1.md +77 -0
- package/docs/releases/v3.0.2.md +105 -0
- package/index.js +15 -3
- package/lib/adapter-ownership.js +51 -0
- package/lib/client-presentation-boundary-main.js +113 -50
- package/lib/client-presentation-boundary.js +36 -46
- package/lib/client.js +2 -1
- package/lib/core-primitives.js +29 -1
- package/lib/core-vision-surface.js +3 -1
- package/lib/desktop-screenshot-tool.js +11 -9
- package/lib/diagnostic-redaction.js +61 -12
- package/lib/doctor-cli-p0.js +36 -5
- package/lib/dsh-support-window.js +4 -1
- package/lib/file-logger.js +9 -9
- package/lib/image-offload-compat.js +10 -3
- package/lib/local-vision-stabilizer.js +10 -7
- package/lib/native-image-coexistence.js +11 -1
- package/lib/proxy-routing.js +18 -3
- package/lib/remote-settings-risk-client-boundary.js +6 -1
- package/lib/runtime-composition.js +8 -6
- package/lib/session-vision-mode-authority.js +2 -7
- package/lib/session-vision-policy.js +9 -4
- package/lib/settings-client-017-compat.js +7 -3
- package/lib/settings-number-contract.js +3 -1
- package/lib/vision-background-benchmark.js +14 -2
- package/lib/vision-background-stop-store-core.js +14 -2
- package/lib/vision-diagnostics-routes.js +9 -1
- package/lib/vision-model-visibility-boundary-main.js +15 -16
- package/lib/vision-resilience.js +31 -2
- package/lib/vision-tool-runtime-boundary.js +2 -7
- 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;
|
|
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;
|
|
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.
|
|
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
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
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
|
-
|
|
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
|
|
1183
|
-
//
|
|
1184
|
-
//
|
|
1185
|
-
//
|
|
1186
|
-
//
|
|
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
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
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
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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',
|
package/lib/core-primitives.js
CHANGED
|
@@ -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: ``,
|
|
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
|
|
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(
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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 ?
|
|
7
|
-
url.password = url.password ?
|
|
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,
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
}
|
package/lib/doctor-cli-p0.js
CHANGED
|
@@ -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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
}
|