@xlight-oss/visionary-dsh 0.7.1 → 0.7.3

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.
@@ -35,11 +35,11 @@ export function matchesRoute(provider, model, routes) {
35
35
  * untouched.
36
36
  *
37
37
  * The ORIGINAL method reference is kept separate from the patch so capability
38
- * sensing (`nativeImageCapable`) and the imageRouting consultation are never
39
- * poisoned by it — the patch is strictly an admission-release lever, and
40
- * without this separation every bridge route would read as "natively image
41
- * capable" and the rewrite would never run (images would hit the pi-ai second
42
- * gate and fail with UNSUPPORTED_CONTENT).
38
+ * sensing (`nativeImageCapable`) is never poisoned by it — the patch is
39
+ * strictly an admission-release lever, and without this separation every
40
+ * bridge route would read as "natively image capable" and the rewrite would
41
+ * never run (images would hit the pi-ai second gate and fail with
42
+ * UNSUPPORTED_CONTENT).
43
43
  *
44
44
  * Lifecycle: `install()` registers the patch and returns a disposer restoring
45
45
  * the original (re-assign, guarded so it never clobbers a later patch by
@@ -47,8 +47,23 @@ export function matchesRoute(provider, model, routes) {
47
47
  * unload/HMR reload always restores the original — otherwise a leftover patch
48
48
  * would be captured as the "original" by the next apply and poison capability
49
49
  * sensing forever.
50
+ *
51
+ * Feature detection (design D7): a host whose llm service has no
52
+ * `resolveModelInfo` cannot be bridged at all. Instead of throwing inside
53
+ * `apply` (which would kill the whole plugin row), the patch reports
54
+ * `available: false` and the caller skips the bridge.
50
55
  */
51
- export function makeModelInfoPatch({ llm, isEnabled, routeMatch = matchesRoute }) {
56
+ export function makeModelInfoPatch({ llm, isEnabled, routeMatch = matchesRoute, logger }) {
57
+ if (typeof llm?.resolveModelInfo !== "function") {
58
+ logger?.warn?.(
59
+ "[visionary-image-bridge] ctx.llm.resolveModelInfo is not a function on this host; image bridging stays disabled"
60
+ );
61
+ return {
62
+ available: false,
63
+ original: undefined,
64
+ install: () => () => {},
65
+ };
66
+ }
52
67
  const original = llm.resolveModelInfo.bind(llm);
53
68
  const patched = async (provider, model, signal) => {
54
69
  const info = await original(provider, model, signal);
@@ -59,6 +74,7 @@ export function makeModelInfoPatch({ llm, isEnabled, routeMatch = matchesRoute }
59
74
  return { ...info, inputModalities: [...info.inputModalities, "image"] };
60
75
  };
61
76
  return {
77
+ available: true,
62
78
  /** The unpatched method, for capability sensing and consultations. */
63
79
  original,
64
80
  /** Install the patch; returns the disposer that restores the original. */
@@ -17,20 +17,18 @@
17
17
  // fires and the model only ever receives text. The rewrite acts on the
18
18
  // request snapshot only — session logs / UI transcript keep the original
19
19
  // images.
20
- // 3. forward compat provides `ctx.imageRouting` (the community
21
- // consultation contract) ONLY when the host does not already provide it;
22
- // when the host provides it natively, the resolveModelInfo patch is not
23
- // installed and the native hook handles admission.
20
+ // 3. settingsthe bridge config lives in its own namespace
21
+ // (`visionary-image-bridge`) via `installSection` + the native card.
24
22
  //
25
23
  // The bridge never changes the `deepseek_vision` tool contract, never touches
26
24
  // session logs, and with `enabled: false` restores the host's original
27
25
  // behavior (text-only models reject images again).
28
26
  //
29
27
  // Design source: openspec/changes/visionary-image-bridge (D2 admission patch,
30
- // D3 persistence, D4 llm/stream rewrite, D5 imageRouting, D6 settings, D7 TTL).
28
+ // D3 persistence, D4 llm/stream rewrite, D6 settings, D7 TTL);
29
+ // openspec/changes/adapt-dsh-0-1-5 (D2 installSection, D7 bridge route).
31
30
 
32
31
  import z from "@deepseek-ai/schemastery";
33
- import { installSettingsSection, settingsNamespace } from "@deepseek-ai/dsh-settings";
34
32
  import { ImagePersistence } from "./persistence.mjs";
35
33
  import { makeModelInfoPatch, makeStreamListener, matchesRoute } from "./core.mjs";
36
34
  // Same-package internal reuse (exported from the tools plugin row): the
@@ -53,8 +51,10 @@ export const DEFAULT_PROMPT_TEMPLATE = [
53
51
  /** Degradation placeholder when an image cannot be read or persisted. */
54
52
  export const IMAGE_PLACEHOLDER = "用户粘贴的图片处理失败,无法分析。";
55
53
 
56
- /** Settings namespace (panel section key in $DSH_HOME/settings.yaml). */
57
- export const SETTINGS_NAMESPACE = settingsNamespace("visionary-image-bridge");
54
+ /** Settings namespace (panel card key + `$DSH_HOME/settings.yaml` section).
55
+ * Literal string: DSH validates namespaces as literal types since
56
+ * 0.1.2-alpha.2 and no longer ships the `settingsNamespace()` helper. */
57
+ export const SETTINGS_NAMESPACE = "visionary-image-bridge";
58
58
 
59
59
  export const Config = z.object({
60
60
  enabled: z.boolean().default(true).description(
@@ -124,40 +124,21 @@ export function apply(ctx, config) {
124
124
  logger: ctx.logger,
125
125
  });
126
126
 
127
- // Forward compat (design D5): when the host natively provides imageRouting,
128
- // it handles admission we neither register a duplicate service (cordis
129
- // throws on that, failing the plugin row) nor install the resolveModelInfo
130
- // patch. The llm/stream rewrite stays in both shapes: it self-adapts via the
131
- // per-request native capability check.
132
- const hostProvidesImageRouting = ctx.get("imageRouting") !== undefined;
133
-
127
+ // Admission release (design D2 of the original change; route decision in
128
+ // adapt-dsh-0-1-5 D7): the web host's gate hardcodes
129
+ // MODEL_DOES_NOT_SUPPORT_IMAGES and reads only `llm.resolveModelInfo`, with
130
+ // no waterfall to hook. The bridge therefore releases admission by wrapping
131
+ // that one read, then rewrites image blocks to text at `llm/stream`. The
132
+ // community alternative (registering a synthetic provider route that
133
+ // advertises `image`) cannot take over an existing provider id and forces
134
+ // the user to re-select a wrapped model, so it is not used here.
134
135
  const patch = makeModelInfoPatch({
135
136
  llm: ctx.llm,
136
137
  isEnabled: () => runtime.enabled,
137
138
  routeMatch: (provider, model) => matchesRoute(provider, model, runtime.routes),
139
+ logger: ctx.logger,
138
140
  });
139
141
 
140
- if (!hostProvidesImageRouting) {
141
- // Admission release + restore-on-unload (design D2). The disposer restores
142
- // the original method, so an HMR reload never captures the leftover patch
143
- // as the "original" and poisons capability sensing.
144
- ctx.effect(patch.install, "visionary-image-bridge: resolveModelInfo patch");
145
-
146
- // Community-shaped consultation (design D5): the web host calls
147
- // resolveFallback(agent, current) when the selected model rejects image
148
- // input and routes the request through the returned selection. This bridge
149
- // never switches models — returning `current` means "keep this route, the
150
- // bridge admits and rewrites images at the stream boundary".
151
- ctx.provide("imageRouting", {
152
- resolveFallback: async (_agent, current) => {
153
- if (!runtime.enabled) return undefined;
154
- if (!current) return undefined;
155
- if (!matchesRoute(current.provider, current.model, runtime.routes)) return undefined;
156
- return current;
157
- },
158
- });
159
- }
160
-
161
142
  const rewrittenBatches = new WeakSet();
162
143
 
163
144
  // Deterministic-mode analysis hook (design D6): image -> text via the same
@@ -185,58 +166,83 @@ export function apply(ctx, config) {
185
166
  );
186
167
  };
187
168
 
188
- // The unified rewrite point (design D4): every model request passes this
189
- // waterfall, so one listener covers user pastes, read_image tool results,
190
- // any tool-result image, and replay. prepend: true puts it outside the host
191
- // llm-invariant; global: true makes it fire for the root event scope like
192
- // the official dsh-session-title listener.
193
- ctx.on(
194
- "llm/stream",
195
- makeStreamListener({
196
- llm: ctx.llm,
197
- originalResolveModelInfo: patch.original,
198
- getRuntime: () => runtime,
199
- persistence,
200
- rewrittenBatches,
201
- logger: ctx.logger,
202
- analyzeImage,
203
- }),
204
- { global: true, prepend: true },
205
- );
169
+ if (!patch.available) {
170
+ // Feature detection (task 2.6): a host without the readable method cannot
171
+ // be bridged. Keep the plugin row alive with the bridge disabled instead
172
+ // of throwing out of `apply`; the patch already logged the reason.
173
+ ctx.logger?.warn?.(
174
+ "[visionary-image-bridge] bridging disabled: the host llm service exposes no resolveModelInfo()"
175
+ );
176
+ } else {
177
+ // Restore-on-unload (design D2): the disposer restores the original method,
178
+ // so an HMR reload never captures the leftover patch as the "original" and
179
+ // poisons capability sensing.
180
+ ctx.effect(patch.install, "visionary-image-bridge: resolveModelInfo patch");
181
+
182
+ // The unified rewrite point (design D4): every model request passes this
183
+ // waterfall, so one listener covers user pastes, read_image tool results,
184
+ // any tool-result image, and replay. prepend: true puts it outside the host
185
+ // llm-invariant; global: true makes it fire for the root event scope like
186
+ // the official dsh-session-title listener.
187
+ ctx.on(
188
+ "llm/stream",
189
+ makeStreamListener({
190
+ llm: ctx.llm,
191
+ originalResolveModelInfo: patch.original,
192
+ getRuntime: () => runtime,
193
+ persistence,
194
+ rewrittenBatches,
195
+ logger: ctx.logger,
196
+ analyzeImage,
197
+ }),
198
+ { global: true, prepend: true },
199
+ );
200
+ }
206
201
 
207
202
  // Settings section (design D6): settings panel + settings.yaml, hot reload.
208
- installSettingsSection(ctx, SETTINGS_NAMESPACE, Config, config, {
209
- setSource: (thunk) => {
210
- source = thunk;
211
- },
212
- onChange: () => {
213
- const next = { ...source() };
214
- // cleanPasted 是一次性触发器:切为 true 即触发清理,并把运行态复位为
215
- // false(不再视为常态配置),再回写 settings 文档复位持久化值,避免
216
- // 每次启动/切换都重复全量清理(design:打开一次触发一次)。
217
- const triggered = next.cleanPasted === true;
218
- if (triggered) next.cleanPasted = false;
219
- runtime = next;
220
- validateConfig(runtime); // belt-and-braces; validate hook already rejects bad writes
221
- if (triggered) {
222
- persistence
223
- .cleanup({ all: true })
224
- .then((removed) => {
225
- ctx.logger.info(`[visionary-image-bridge] cleaned ${removed} pasted file(s)`);
226
- })
227
- .catch((err) => {
228
- ctx.logger.warn(`[visionary-image-bridge] cleanPasted cleanup failed: ${err?.message ?? err}`);
229
- });
230
- const settings = ctx.get?.("settings");
231
- if (settings && typeof settings.update === "function") {
232
- settings.update(SETTINGS_NAMESPACE, { cleanPasted: false }).catch(() => {
233
- // best-effort reset; runtime is already flipped, panel stays truthful
234
- });
203
+ // The service is optional: inject waits for it, and installSection falls back
204
+ // to the composition entry when the provider detaches (adapt-dsh-0-1-5 D2).
205
+ ctx.inject(["settings"], (settingsCtx) => {
206
+ const settings = settingsCtx.settings;
207
+ if (typeof settings?.installSection !== "function") {
208
+ ctx.logger?.warn?.(
209
+ "[visionary-image-bridge] the mounted settings service has no installSection(); keeping the composition entry config"
210
+ );
211
+ return;
212
+ }
213
+ settings.installSection(ctx, SETTINGS_NAMESPACE, Config, config, {
214
+ setSource: (thunk) => {
215
+ source = thunk;
216
+ },
217
+ onChange: () => {
218
+ const next = { ...source() };
219
+ // cleanPasted 是一次性触发器:切为 true 即触发清理,并把运行态复位为
220
+ // false(不再视为常态配置),再回写 settings 文档复位持久化值,避免
221
+ // 每次启动/切换都重复全量清理(design:打开一次触发一次)。
222
+ const triggered = next.cleanPasted === true;
223
+ if (triggered) next.cleanPasted = false;
224
+ runtime = next;
225
+ validateConfig(runtime); // belt-and-braces; validate hook already rejects bad writes
226
+ if (triggered) {
227
+ persistence
228
+ .cleanup({ all: true })
229
+ .then((removed) => {
230
+ ctx.logger.info(`[visionary-image-bridge] cleaned ${removed} pasted file(s)`);
231
+ })
232
+ .catch((err) => {
233
+ ctx.logger.warn(`[visionary-image-bridge] cleanPasted cleanup failed: ${err?.message ?? err}`);
234
+ });
235
+ const settingsService = ctx.get?.("settings");
236
+ if (settingsService && typeof settingsService.update === "function") {
237
+ settingsService.update(SETTINGS_NAMESPACE, { cleanPasted: false }).catch(() => {
238
+ // best-effort reset; runtime is already flipped, panel stays truthful
239
+ });
240
+ }
235
241
  }
236
- }
237
- ctx.logger.info("[visionary-image-bridge] configuration updated");
238
- },
239
- validate: validateConfig,
242
+ ctx.logger.info("[visionary-image-bridge] configuration updated");
243
+ },
244
+ validate: validateConfig,
245
+ });
240
246
  });
241
247
 
242
248
  // Lazy TTL cleanup at startup (design D7); later cleanups run after persists.
package/lib/index.mjs CHANGED
@@ -14,7 +14,6 @@
14
14
 
15
15
  import { defineTool } from "@deepseek-ai/dsh-tools";
16
16
  import z from "@deepseek-ai/schemastery";
17
- import { installSettingsSection, settingsNamespace } from "@deepseek-ai/dsh-settings";
18
17
  import { spawn } from "node:child_process";
19
18
  import { statSync, readFileSync } from "node:fs";
20
19
  import { promises as fs } from "node:fs";
@@ -24,8 +23,12 @@ import path from "node:path";
24
23
  const name = "visionary-vision";
25
24
  const inject = ["tools", "systemPrompt"];
26
25
 
27
- /** Settings namespace(面板 / settings.yaml 双入口,规范:dsh-plugin 设置面板上传路径配置)。 */
28
- const SETTINGS_NAMESPACE = settingsNamespace("visionary-vision");
26
+ /**
27
+ * Settings namespace(面板 / settings.yaml 双入口,规范:dsh-plugin 设置面板上传路径配置)。
28
+ * 字面量字符串:DSH 自 0.1.2-alpha.2 起以编译期字面量类型校验命名空间,
29
+ * 旧的 `settingsNamespace()` 运行时 helper 已不存在(`/^[a-z][a-z0-9-]*$/`)。
30
+ */
31
+ const SETTINGS_NAMESPACE = "visionary-vision";
29
32
 
30
33
  // Keep in lockstep with the Rust binary's minor version: tools rely on the
31
34
  // CLI's `--json` output shape. Bump when the binary's contract changes.
@@ -297,13 +300,27 @@ function apply(ctx, config) {
297
300
  // 而不是冻结的 config —— 设置面板切换 modelType(vision|ocr)无需重启 DSH。
298
301
  let runtime = { ...config };
299
302
  let source = () => config;
300
- installSettingsSection(ctx, SETTINGS_NAMESPACE, Config, config, {
301
- setSource: (thunk) => {
302
- source = thunk;
303
- },
304
- onChange: () => {
305
- runtime = { ...source() };
306
- },
303
+ // settings 服务可选:inject(["settings"], …) provider 就绪(或之后出现)时
304
+ // 才注册命名空间,provider 脱离时 installSection 会把 source 回退到 entry,
305
+ // 因此「无 settings 服务」表现为按组合 entry 配置运行,而不是插件行报错。
306
+ ctx.inject(["settings"], (settingsCtx) => {
307
+ const settings = settingsCtx.settings;
308
+ if (typeof settings?.installSection !== "function") {
309
+ // 契约不符(早于 0.1.2-alpha.2 的宿主):降级为 entry 配置并明确提示,
310
+ // 不让整行插件在加载期死掉。
311
+ ctx.logger?.warn?.(
312
+ "[visionary-vision] the mounted settings service has no installSection(); keeping the composition entry config"
313
+ );
314
+ return;
315
+ }
316
+ settings.installSection(ctx, SETTINGS_NAMESPACE, Config, config, {
317
+ setSource: (thunk) => {
318
+ source = thunk;
319
+ },
320
+ onChange: () => {
321
+ runtime = { ...source() };
322
+ },
323
+ });
307
324
  });
308
325
 
309
326
  const loginSeconds = (() => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xlight-oss/visionary-dsh",
3
- "version": "0.7.1",
3
+ "version": "0.7.3",
4
4
  "description": "DeepSeek Visionary native plugin for DeepSeek Harness: deepseek_vision / status / login / logout native tools plus the text-model image bridge, all backed by the visionary-server CLI (DeepSeek web vision model, no API key).",
5
5
  "type": "module",
6
6
  "main": "lib/index.mjs",
@@ -11,10 +11,9 @@
11
11
  "./image-bridge": {
12
12
  "default": "./lib/image-bridge/index.mjs"
13
13
  },
14
- "./settings-card": {
15
- "default": "./lib/settings-card/index.mjs"
14
+ "./client": {
15
+ "default": "./lib/client.js"
16
16
  },
17
- "./settings-card/package.json": "./lib/settings-card/package.json",
18
17
  "./cordis.patch.yml": "./cordis.patch.yml",
19
18
  "./package.json": "./package.json"
20
19
  },
@@ -26,26 +25,34 @@
26
25
  "dsh": {
27
26
  "bundle": {
28
27
  "patch": "./cordis.patch.yml"
28
+ },
29
+ "client": {
30
+ "platform": "web",
31
+ "inject": [
32
+ "@deepseek-ai/dsh-client-locale",
33
+ "@deepseek-ai/dsh-client-ui-settings",
34
+ "@deepseek-ai/dsh-client-ui-settings-plugins"
35
+ ]
29
36
  }
30
37
  },
31
38
  "publishConfig": {
32
39
  "access": "public"
33
40
  },
34
41
  "peerDependencies": {
35
- "@deepseek-ai/cordis": "^4.0.1",
36
- "@deepseek-ai/dsh-tools": "^0.1.0-rc.6",
37
- "@deepseek-ai/dsh-llm": "^0.1.0-rc.6",
38
- "@deepseek-ai/dsh-attachment": "^0.1.0-rc.6",
39
- "@deepseek-ai/dsh-settings": "^0.1.0-rc.6",
40
- "@deepseek-ai/schemastery": "^3.18.1"
42
+ "@deepseek-ai/cordis": "^4.0.2",
43
+ "@deepseek-ai/dsh-tools": "^0.1.5-rc.1",
44
+ "@deepseek-ai/dsh-llm": "^0.1.5-rc.1",
45
+ "@deepseek-ai/dsh-attachment": "^0.1.5-rc.1",
46
+ "@deepseek-ai/dsh-settings": "^0.1.5-rc.1",
47
+ "@deepseek-ai/schemastery": "^3.18.2"
41
48
  },
42
49
  "devDependencies": {
43
- "@deepseek-ai/cordis": "^4.0.1",
44
- "@deepseek-ai/dsh-tools": "^0.1.0-rc.6",
45
- "@deepseek-ai/dsh-llm": "^0.1.0-rc.6",
46
- "@deepseek-ai/dsh-attachment": "^0.1.0-rc.6",
47
- "@deepseek-ai/dsh-settings": "^0.1.0-rc.6",
48
- "@deepseek-ai/schemastery": "^3.18.1"
50
+ "@deepseek-ai/cordis": "4.0.2",
51
+ "@deepseek-ai/dsh-tools": "0.1.5-rc.2",
52
+ "@deepseek-ai/dsh-llm": "0.1.5-rc.2",
53
+ "@deepseek-ai/dsh-attachment": "0.1.5-rc.2",
54
+ "@deepseek-ai/dsh-settings": "0.1.5-rc.2",
55
+ "@deepseek-ai/schemastery": "3.18.2"
49
56
  },
50
57
  "repository": {
51
58
  "type": "git",
@@ -1,85 +0,0 @@
1
- // Browser-trust fence for the visionary settings HTTP routes.
2
- //
3
- // Behavioral mirror of the /api gateway's fence in @deepseek-ai/dsh-client-connection
4
- // (api-request-trust.ts, BSD-3-Clause). The DSH settings RPC domain only serves
5
- // allowlisted namespaces to configuration clients, so a third-party plugin's
6
- // namespace is unreachable through `connection.api.settings.*`. The
7
- // settings-card host mounts its own fenced JSON routes instead (see
8
- // lib/settings-route.mjs `visionary/api`, serving both the vision-tools and
9
- // image-bridge namespaces); the fence below is a DNS-rebinding / cross-site
10
- // defense, NOT authentication — the web browser is already same-origin with
11
- // the host.
12
- //
13
- // Dependency-free (pure functions over node:http headers) so unit tests run
14
- // without a node_modules install.
15
-
16
- /** The request facts the fence reads (structural subset of IncomingMessage). */
17
- function header(headers, name) {
18
- const value = headers[name];
19
- return typeof value === "string" ? value : undefined;
20
- }
21
-
22
- /** Normalized URL of a Host-header authority, or undefined when unparsable. */
23
- function parseAuthority(authority) {
24
- try {
25
- return new URL(`http://${authority}`);
26
- } catch {
27
- return undefined;
28
- }
29
- }
30
-
31
- /** Whether a normalized URL hostname names the local loopback authority. */
32
- export function isLoopbackHostname(hostname) {
33
- if (hostname === "localhost" || hostname === "[::1]") return true;
34
- const parts = hostname.split(".");
35
- return (
36
- parts.length === 4 &&
37
- parts[0] === "127" &&
38
- parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255)
39
- );
40
- }
41
-
42
- /** Canonical authority form: hostname, or hostname:port when a port was written. */
43
- function canonicalAuthority(entry, entryUrl) {
44
- const port =
45
- entryUrl.port !== ""
46
- ? entryUrl.port
47
- : new URL(`https://${entry}`).port;
48
- return port === "" ? entryUrl.hostname : `${entryUrl.hostname}:${port}`;
49
- }
50
-
51
- /** Whether the request authority matches a trustedHosts entry (exact or port-less). */
52
- function isTrustedAuthority(hostUrl, trustedHosts) {
53
- return trustedHosts.some((entry) => {
54
- const entryUrl = parseAuthority(entry);
55
- if (entryUrl === undefined) return false;
56
- return canonicalAuthority(entry, entryUrl) === entryUrl.hostname
57
- ? entryUrl.hostname === hostUrl.hostname
58
- : entryUrl.host === hostUrl.host;
59
- });
60
- }
61
-
62
- /**
63
- * Decide whether one request may reach the plugin's routes.
64
- * @param request - node HTTP request facts (headers).
65
- * @param trustedHosts - non-loopback authorities this deployment serves.
66
- * @returns true when the Host is ours (loopback or trusted) and browser
67
- * markers are same-origin.
68
- */
69
- export function isTrustedApiRequest(request, trustedHosts) {
70
- const host = header(request.headers, "host");
71
- if (host === undefined) return false;
72
- const hostUrl = parseAuthority(host);
73
- if (hostUrl === undefined) return false;
74
- if (!isLoopbackHostname(hostUrl.hostname) && !isTrustedAuthority(hostUrl, trustedHosts)) {
75
- return false;
76
- }
77
- if (header(request.headers, "sec-fetch-site") === "cross-site") return false;
78
- const origin = header(request.headers, "origin");
79
- if (origin === undefined) return true;
80
- try {
81
- return new URL(origin).host === hostUrl.host;
82
- } catch {
83
- return false;
84
- }
85
- }