dsh-model-groups 0.3.5 → 0.3.7

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-model-groups",
3
- "version": "0.3.5",
4
- "description": "DSH 网页模型选择器:供应商与密钥配置分层折叠、多密钥手动选择。支持 DSH 0.2.2-rc.1。",
3
+ "version": "0.3.7",
4
+ "description": "在 DSH 网页端按供应商和密钥配置选择模型,并为同一供应商的不同密钥分别设置模型和 API 协议。支持 DSH 0.2.2-rc.1。",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "repository": {
@@ -29,8 +29,11 @@
29
29
  "native/*.patch.json",
30
30
  "scripts",
31
31
  "!scripts/settings-models-collapse.mjs",
32
+ "!scripts/pi-ai-protocol.mjs",
32
33
  "cordis.patch.yml",
33
34
  "README.md",
35
+ "docs/maintenance.md",
36
+ "docs/images/*.webp",
34
37
  "LICENSE"
35
38
  ],
36
39
  "engines": {
package/scripts/build.mjs CHANGED
@@ -5,6 +5,7 @@ import { fileURLToPath } from 'node:url';
5
5
  const root = fileURLToPath(new URL('../', import.meta.url));
6
6
  const read = name => fs.readFileSync(path.join(root, name), 'utf8');
7
7
  const withoutExports = source => source.replace(/^export /gm, '');
8
+ const clientVersion = JSON.parse(read('package.json')).version;
8
9
  // Styles belong to the mounted plugin, not to module materialization: a
9
10
  // disposal removes them and a re-applied (memoized) module must restore them.
10
11
  const styles = `\nconst MODEL_GROUPS_CSS = ${JSON.stringify(read('src/groups.css'))};\nfunction installModelGroupsStyles() {\n const tagId = 'dsh-model-groups/groups.css';\n if (document.querySelector('style[data-plugin-css="' + tagId + '"]') !== null) return;\n const style = document.createElement('style');\n style.dataset.plugin = 'dsh-model-groups';\n style.dataset.pluginCss = tagId;\n style.textContent = MODEL_GROUPS_CSS;\n document.head.appendChild(style);\n}\n`;
@@ -12,7 +13,7 @@ const settingsStyles = styleInstaller('SETTINGS_PAGE_CSS', 'installSettingsPageS
12
13
  function styleInstaller(constant, installer, file, tagId) {
13
14
  return `\nconst ${constant} = ${JSON.stringify(read(file))};\nfunction ${installer}() {\n const tagId = ${JSON.stringify(tagId)};\n if (document.querySelector('style[data-plugin-css="' + tagId + '"]') !== null) return;\n const style = document.createElement('style');\n style.dataset.plugin = 'dsh-model-groups';\n style.dataset.pluginCss = tagId;\n style.textContent = ${constant};\n document.head.appendChild(style);\n}\n`;
14
15
  }
15
- const source = `// Generated by scripts/build.mjs; edit src instead.\nwindow.__ModuleLoader__.load({\n id: 'dsh-model-groups',\n factory(require) {\n const react = require('react');\n const react_dom = require('react-dom');\n const react_jsx_runtime = require('react/jsx-runtime');\n const _deepseek_ai_dsh_client_ui_primitives = require('@deepseek-ai/dsh-client-ui-primitives');\n${withoutExports(read('src/group-state.js'))}\n${withoutExports(read('src/hierarchy.js')).replace(/^import .*;\n/gm, '')}\n${read('src/model-select.js')}\n${withoutExports(read('src/fallback.js'))}\n${styles}\n${settingsStyles}\n${withoutExports(read('src/maintenance-state.js'))}\n${withoutExports(read('src/settings-page.js'))}\n${withoutExports(read('src/client.js'))}\n return { apply, inject };\n },\n});\n`;
16
+ const source = `// Generated by scripts/build.mjs; edit src instead.\nwindow.__ModuleLoader__.load({\n id: 'dsh-model-groups',\n factory(require) {\n const react = require('react');\n const react_dom = require('react-dom');\n const react_jsx_runtime = require('react/jsx-runtime');\n const _deepseek_ai_dsh_client_ui_primitives = require('@deepseek-ai/dsh-client-ui-primitives');\n${withoutExports(read('src/group-state.js'))}\n${withoutExports(read('src/hierarchy.js')).replace(/^import .*;\n/gm, '')}\n${read('src/model-select.js')}\n${withoutExports(read('src/fallback.js'))}\n${styles}\n${settingsStyles}\nconst MODEL_GROUPS_CLIENT_VERSION = ${JSON.stringify(clientVersion)};\n${withoutExports(read('src/compatibility.js'))}\n${withoutExports(read('src/maintenance-state.js')).replace(/^import .*;\n/gm, '')}\n${withoutExports(read('src/settings-page.js')).replace(/^import .*;\n/gm, '')}\n${withoutExports(read('src/client.js'))}\n return { apply, inject };\n },\n});\n`;
16
17
  const target = path.join(root, 'lib/client.js');
17
18
  // Compile the classic-script bundle before writing anything.
18
19
  new Function(source);
@@ -1,39 +1,38 @@
1
1
  import fs from 'node:fs';
2
2
  import { fileURLToPath } from 'node:url';
3
- import { planPatches } from './native-patches.mjs';
3
+ import { inspectPatches } from './native-patches.mjs';
4
+ import { compatibilityGuidance } from '../src/compatibility.js';
4
5
 
5
6
  const args = process.argv.slice(2);
6
7
  if (args.includes('--help')) {
7
- console.log('Usage: node scripts/doctor.mjs --dsh-root <DSH installation directory> [--json]\nRead-only installation check. Does not read credentials or change DSH.');
8
+ console.log('Usage: node scripts/doctor.mjs --dsh-root <DSH installation directory> [--json]\nRead-only program compatibility check. No credentials, paths, or source text in diagnostics.');
8
9
  } else {
9
10
  try {
10
11
  const i = args.indexOf('--dsh-root');
11
- if (i < 0 || !args[i + 1] || args.some((arg, index) => index !== i + 1 && !['--dsh-root', '--json'].includes(arg))) {
12
- throw new Error('请用 --dsh-root 指定正在使用的 DSH 安装目录。');
13
- }
12
+ if (i < 0 || !args[i + 1] || args.some((arg, index) => index !== i + 1 && !['--dsh-root', '--json'].includes(arg))) throw new Error('arguments');
14
13
  const pkg = JSON.parse(fs.readFileSync(fileURLToPath(new URL('../package.json', import.meta.url)), 'utf8'));
15
- const plans = planPatches(args[i + 1], 'check');
16
- const missing = plans.filter(plan => plan.state !== 'patched');
17
- const report = {
18
- plugin: pkg.name,
19
- version: pkg.version,
20
- supportedDsh: pkg.engines.dsh,
21
- diskPatchesReady: missing.length === 0,
22
- runtimeVerified: false,
23
- files: plans.map(plan => ({ target: plan.artifact.target, state: plan.state })),
24
- next: missing.length > 0
25
- ? '多密钥安装尚未完成:需要按 README 应用尚未安装的兼容补丁,再重启原 DSH 服务并刷新网页。'
26
- : '磁盘补丁齐全。请确认原 DSH 服务已在应用补丁后重启,并刷新网页;本检查不能确认运行中的模块或浏览器缓存。',
27
- };
14
+ const compatibility = inspectPatches(args[i + 1]);
15
+ const conflict = compatibility.files.some(file => file.state === 'conflict');
16
+ const ready = compatibility.files.every(file => file.code === 'ready');
17
+ const report = { plugin: pkg.name, version: pkg.version, supportedDsh: pkg.engines.dsh,
18
+ diskPatchesReady: ready, runtimeVerified: false, compatibility,
19
+ files: compatibility.files.map(file => ({ ...file, updateAvailable: file.code === 'update_available',
20
+ applySupported: ['ready', 'update_available', 'not_applied'].includes(file.code) })),
21
+ next: compatibilityGuidance(compatibility) || (ready
22
+ ? '磁盘补丁齐全。请确认原 DSH 服务已在应用补丁后重启,并刷新网页;本检查不能证明运行中的模块已刷新。'
23
+ : '请在设置 → 模型分组中确认启用,再重启原 DSH 服务并刷新网页。') };
28
24
  if (args.includes('--json')) console.log(JSON.stringify(report, null, 2));
29
25
  else {
30
- console.log(`${report.plugin} ${report.version} · DSH ${report.supportedDsh}`);
31
- for (const item of report.files) console.log(`${item.state}: ${item.target}`);
26
+ console.log(`${report.plugin} ${report.version} · DSH ${compatibility.hostVersion ?? 'unknown'}`);
27
+ for (const item of report.files) console.log(`${item.id}: ${item.state} / ${item.code} · SHA-256 ${item.observedSha256 ?? 'unavailable'}`);
32
28
  console.log(report.next);
33
29
  }
34
- if (missing.length) process.exitCode = 2;
35
- } catch (error) {
36
- console.error(`无法确认多密钥安装状态:${error.message}`);
30
+ process.exitCode = conflict ? 1 : ready ? 0 : 2;
31
+ } catch {
32
+ const report = { diskPatchesReady: false, runtimeVerified: false, error: 'diagnostic_unavailable',
33
+ next: '请用 --dsh-root 指定正在使用的 DSH 安装目录;诊断失败不代表补丁已就绪,未修改任何文件。' };
34
+ if (args.includes('--json')) console.log(JSON.stringify(report, null, 2));
35
+ else console.error(report.next);
37
36
  process.exitCode = 1;
38
37
  }
39
38
  }
@@ -2,6 +2,7 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import crypto from 'node:crypto';
4
4
  import { fileURLToPath } from 'node:url';
5
+ import { compatibilitySnapshot } from '../src/compatibility.js';
5
6
 
6
7
  export const ARTIFACTS = ['pi-ai.patch.json', 'settings-models.patch.json', 'llm-typert.host.patch.json', 'llm-typert.remote-client.patch.json', 'api-remotes.patch.json'];
7
8
  const pluginRoot = fileURLToPath(new URL('../', import.meta.url));
@@ -25,13 +26,16 @@ export function transformSource(source, artifact, reverse = false) {
25
26
 
26
27
  /** Validate ALL files before any modification. Check mode is read-only. */
27
28
  export function planPatches(dshRoot, mode = 'check', artifactDirectory = path.join(pluginRoot, 'native')) {
29
+ return planPatchFiles(dshRoot, mode, artifactDirectory, ARTIFACTS);
30
+ }
31
+ function planPatchFiles(dshRoot, mode, artifactDirectory, names) {
28
32
  if (!['check', 'apply', 'revert'].includes(mode)) throw new Error('Unknown patch mode');
29
33
  const root = fs.realpathSync(dshRoot);
30
34
  const inner = path.join(root, 'node_modules/@deepseek-ai/dsh/package.json');
31
35
  const dsh = JSON.parse(fs.readFileSync(fs.existsSync(inner) ? inner : path.join(root, 'package.json'), 'utf8'));
32
36
  if (dsh.name !== '@deepseek-ai/dsh' || dsh.version !== '0.2.2-rc.1') throw new Error('This patch supports only @deepseek-ai/dsh 0.2.2-rc.1');
33
37
  const seen = new Set();
34
- return ARTIFACTS.map(name => {
38
+ return names.map(name => {
35
39
  let artifact = JSON.parse(fs.readFileSync(path.join(artifactDirectory, name), 'utf8'));
36
40
  if (artifact.version !== '0.2.2-rc.1' || !/^@deepseek-ai\/dsh-[a-z-]+$/.test(artifact.packageName)) throw new Error('Unsupported patch package/version');
37
41
  if (!Array.isArray(artifact.replacements) || artifact.replacements.length === 0 || !/^[0-9a-f]{64}$/.test(artifact.baseSha256) || !/^[0-9a-f]{64}$/.test(artifact.resultSha256)) throw new Error('Invalid patch manifest');
@@ -46,7 +50,17 @@ export function planPatches(dshRoot, mode = 'check', artifactDirectory = path.jo
46
50
  const hash = sha256(source);
47
51
  const primary = artifact;
48
52
  if (artifact.variants !== undefined && (!Array.isArray(artifact.variants) || artifact.variants.some(variant => !/^[0-9a-f]{64}$/.test(variant.baseSha256) || !/^[0-9a-f]{64}$/.test(variant.resultSha256) || (variant.replacements !== undefined && !Array.isArray(variant.replacements))))) throw new Error('Invalid patch variants');
49
- const candidates = [primary, ...(primary.variants || []).map(variant => ({ ...primary, ...variant }))];
53
+ if (primary.current !== undefined && typeof primary.current !== 'boolean' || primary.variants?.some(variant => variant.current !== undefined && typeof variant.current !== 'boolean')) throw new Error('Invalid current patch marker');
54
+ if (primary.requiresCurrent !== undefined && typeof primary.requiresCurrent !== 'boolean') throw new Error('Invalid required-current patch marker');
55
+ // current belongs to a revision, not the shared package metadata. Do not
56
+ // inherit primary.current onto legacy revisions when merging their fields.
57
+ const candidates = [primary, ...(primary.variants || []).map(variant => ({ ...primary, ...variant, current: variant.current === true }))];
58
+ const currentByBase = new Map();
59
+ for (const candidate of candidates) {
60
+ if (candidate.current !== true) continue;
61
+ if (currentByBase.has(candidate.baseSha256)) throw new Error('Ambiguous current patch variant');
62
+ currentByBase.set(candidate.baseSha256, candidate);
63
+ }
50
64
  // A shared pristine base is intentional for legacy revisions. Prefer current
51
65
  // output, but never guess between two different recognized patched sources.
52
66
  const patchedMatches = candidates.filter(candidate => hash === candidate.resultSha256);
@@ -58,12 +72,58 @@ export function planPatches(dshRoot, mode = 'check', artifactDirectory = path.jo
58
72
  // Verify the observed revision bidirectionally, including check/no-op plans.
59
73
  const observedPatched = transformSource(original, artifact);
60
74
  if (state === 'patched' && observedPatched !== source) throw new Error('Patch roundtrip mismatch');
61
- const targetArtifact = artifact.baseSha256 === primary.baseSha256 ? primary : artifact;
75
+ const applySupported = primary.requiresCurrent !== true || currentByBase.has(artifact.baseSha256);
76
+ // A recognized old revision can still be checked/restored. It cannot be
77
+ // silently left behind while dependent UI/schema files upgrade around it.
78
+ if (mode === 'apply' && !applySupported) throw new Error('No verified current patch for this native build; no files written');
79
+ const targetArtifact = currentByBase.get(artifact.baseSha256) ?? (artifact.baseSha256 === primary.baseSha256 ? primary : artifact);
62
80
  const latest = transformSource(original, targetArtifact);
63
81
  const updateAvailable = state === 'patched' && sha256(latest) !== hash;
64
82
  const result = mode === 'apply' ? latest : mode === 'revert' ? original : source;
65
- return { name, artifact, file, source, result, state, updateAvailable, mode: fs.statSync(file).mode & 0o777 };
83
+ return { name, artifact, file, source, result, state, updateAvailable, applySupported, observedSha256: hash, baseSha256: artifact.baseSha256, targetSha256: targetArtifact.resultSha256, mode: fs.statSync(file).mode & 0o777 };
84
+ });
85
+ }
86
+
87
+
88
+ /** Diagnose each program independently, without weakening the all-or-nothing planner.
89
+ * Output is a closed schema: program hashes/versions only, never paths or errors. */
90
+ export function inspectPatches(dshRoot, artifactDirectory = path.join(pluginRoot, 'native')) {
91
+ const pkg = JSON.parse(fs.readFileSync(path.join(pluginRoot, 'package.json'), 'utf8'));
92
+ let root, hostVersion = null;
93
+ try {
94
+ root = fs.realpathSync(dshRoot);
95
+ const inner = path.join(root, 'node_modules/@deepseek-ai/dsh/package.json');
96
+ const host = JSON.parse(fs.readFileSync(fs.existsSync(inner) ? inner : path.join(root, 'package.json'), 'utf8'));
97
+ if (host.name === '@deepseek-ai/dsh') hostVersion = host.version;
98
+ } catch { /* Do not expose filesystem or parser error messages. */ }
99
+ const files = ARTIFACTS.map(id => {
100
+ const item = { id, state: 'conflict', code: 'target_unavailable', installedVersion: null, observedSha256: null, baseSha256: null, targetSha256: null };
101
+ if (!root) return item;
102
+ // Collect only metadata from the exact manifest-owned, regular program path.
103
+ try {
104
+ const manifest = JSON.parse(fs.readFileSync(path.join(artifactDirectory, id), 'utf8'));
105
+ const prefix = `node_modules/${manifest.packageName}/`;
106
+ if (!/^@deepseek-ai\/dsh-[a-z-]+$/.test(manifest.packageName) || typeof manifest.target !== 'string' || !manifest.target.startsWith(prefix) || manifest.target.includes('..') || manifest.target.includes('\\')) throw new Error();
107
+ const file = path.resolve(root, manifest.target);
108
+ if (!inside(root, file) || fs.realpathSync(file) !== file || !fs.statSync(file).isFile()) throw new Error();
109
+ const installed = JSON.parse(fs.readFileSync(path.join(root, 'node_modules', manifest.packageName, 'package.json'), 'utf8'));
110
+ if (installed.name === manifest.packageName) item.installedVersion = installed.version;
111
+ item.observedSha256 = sha256(fs.readFileSync(file));
112
+ } catch { /* Safe incomplete metadata; planner still refuses unknown targets. */ }
113
+ try {
114
+ const [plan] = planPatchFiles(root, 'check', artifactDirectory, [id]);
115
+ Object.assign(item, { state: plan.state, code: !plan.applySupported ? 'unsupported_patch_build' : plan.updateAvailable ? 'update_available' : plan.state === 'pristine' ? 'not_applied' : 'ready',
116
+ baseSha256: plan.baseSha256, targetSha256: plan.targetSha256 });
117
+ } catch (error) {
118
+ const message = error?.message ?? '';
119
+ item.code = message === 'This patch supports only @deepseek-ai/dsh 0.2.2-rc.1' ? 'host_version'
120
+ : message === 'Installed native package version mismatch' ? 'package_version'
121
+ : message.startsWith('Refusing unrecognized content: ') ? 'unrecognized_content'
122
+ : error?.code === 'ENOENT' || error?.code === 'EACCES' || message === 'Unexpected target path or duplicate patch' ? 'target_unavailable' : 'patch_manifest';
123
+ }
124
+ return item;
66
125
  });
126
+ return compatibilitySnapshot({ schema: 1, pluginVersion: pkg.version, hostVersion, supportedDsh: pkg.engines.dsh, files });
67
127
  }
68
128
 
69
129
  function stageReplacement(file, content, mode) {
@@ -138,7 +198,7 @@ function main(args) {
138
198
  if (args.some(arg => !allowed.has(arg))) throw new Error('Unknown argument');
139
199
  const mode = (modes[0] ?? '--check').slice(2);
140
200
  const plans = planPatches(args[i + 1], mode);
141
- for (const plan of plans) console.log(`${plan.artifact.target}: ${plan.state}`);
201
+ for (const plan of plans) console.log(`${plan.artifact.target}: ${plan.state}${plan.applySupported ? plan.updateAvailable ? ' (update available)' : '' : ' (recognized legacy build; no verified upgrade, apply disabled)'}`);
142
202
  if (mode !== 'check') console.log(`${applyPlan(plans)} native files changed. Restart the existing DSH service and refresh its page; the live process is not reloaded by this script.`);
143
203
  }
144
204
  if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
package/src/auto-patch.js CHANGED
@@ -15,9 +15,11 @@ export function findDshRoot(entry = process.argv[1]) {
15
15
  while (true) {
16
16
  const candidate = path.join(dir, 'node_modules/@deepseek-ai/dsh-llm-pi-ai/package.json');
17
17
  if (fs.existsSync(candidate) && fs.realpathSync(candidate) === resolved) {
18
- // Full patch planning validates the DSH package/version and every target.
19
- planPatches(dir, 'check');
20
- return dir;
18
+ // Discovery must survive unknown program hashes so maintenance can explain
19
+ // the conflict. The planner still validates every target before any write.
20
+ const inner = path.join(dir, 'node_modules/@deepseek-ai/dsh/package.json');
21
+ const host = JSON.parse(fs.readFileSync(fs.existsSync(inner) ? inner : path.join(dir, 'package.json'), 'utf8'));
22
+ if (host.name === '@deepseek-ai/dsh') return dir;
21
23
  }
22
24
  const parent = path.dirname(dir);
23
25
  if (parent === dir) break;
package/src/client.js CHANGED
@@ -21,8 +21,8 @@ export function apply(ctx) {
21
21
  statusNotice.setAttribute('role', 'status');
22
22
  statusNotice.style.cssText = 'position:fixed;bottom:16px;right:16px;max-width:min(360px,80vw);z-index:1000;background:var(--dsw-alias-bg-base,#fff);color:var(--dsw-alias-label-primary,#222);padding:12px;border:1px solid currentColor;border-radius:8px;box-sizing:border-box;pointer-events:none;';
23
23
  statusNotice.textContent = status.failed
24
- ? '多密钥自动补丁未完成。请回到 App 对话查看引擎日志或运行安装诊断,勿强制覆盖。请暂勿编辑模型配置;可到「设置 → 模型分组」检查并安全恢复。'
25
- : '多密钥补丁已自动安装。请先保存其他页面的草稿,再到 App 重启原引擎并刷新此网页。请暂勿编辑模型配置,避免新旧模块混用;维护入口仍可使用。';
24
+ ? '无法检查多密钥功能所需的 DSH 文件。请打开「设置 → 模型分组」查看详情,不要手动覆盖文件。'
25
+ : 'DSH 程序文件已更新。请先保存草稿,再重启 DSH 引擎并刷新网页。重启前请暂缓修改模型设置。';
26
26
  document.body.appendChild(statusNotice);
27
27
  }).catch(() => {});
28
28
  ctx.on('dispose', () => { statusDisposed = true; statusNotice?.remove(); });
@@ -0,0 +1,22 @@
1
+ // Public program metadata only. Never include paths, source text, configuration,
2
+ // credentials, environment variables, or arbitrary exception messages.
3
+ export const PATCH_IDS = ['pi-ai.patch.json', 'settings-models.patch.json', 'llm-typert.host.patch.json', 'llm-typert.remote-client.patch.json', 'api-remotes.patch.json'];
4
+ export const COMPATIBILITY_CODES = ['ready', 'update_available', 'not_applied', 'unsupported_patch_build', 'host_version', 'package_version', 'unrecognized_content', 'target_unavailable', 'patch_manifest'];
5
+ export const publicVersion = value => typeof value === 'string' && /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]{1,40})?$/.test(value) ? value : null;
6
+ export const publicHash = value => typeof value === 'string' && /^[0-9a-f]{64}$/.test(value) ? value : null;
7
+ export function compatibilitySnapshot(value) {
8
+ if (!value || value.schema !== 1 || !Array.isArray(value.files) || value.files.length > PATCH_IDS.length) return null;
9
+ return { schema: 1, pluginVersion: publicVersion(value.pluginVersion), hostVersion: publicVersion(value.hostVersion), supportedDsh: publicVersion(value.supportedDsh),
10
+ files: value.files.filter(item => item && PATCH_IDS.includes(item.id)).map(item => ({ id: item.id,
11
+ state: ['patched', 'pristine', 'conflict'].includes(item.state) ? item.state : 'conflict',
12
+ code: COMPATIBILITY_CODES.includes(item.code) ? item.code : 'patch_manifest',
13
+ installedVersion: publicVersion(item.installedVersion), observedSha256: publicHash(item.observedSha256),
14
+ baseSha256: publicHash(item.baseSha256), targetSha256: publicHash(item.targetSha256) })) };
15
+ }
16
+ export function compatibilityGuidance(snapshot) {
17
+ const codes = snapshot?.files.map(item => item.code) ?? [];
18
+ if (codes.includes('host_version') || codes.includes('package_version')) return '宿主程序版本不在本版支持范围。先核对插件与 DSH 的兼容版本;不要强行降级、覆盖文件或删除配置。';
19
+ if (codes.includes('unrecognized_content') || codes.includes('target_unavailable') || codes.includes('patch_manifest') || codes.includes('unsupported_patch_build')) return '已停止整批更新,未覆盖未知文件。先在原 profile 更新最新版插件,重启原引擎并刷新网页后复查;仍不支持时,复制脱敏诊断反馈。不要先卸载、还原补丁或删除配置;如需进一步适配,只提供被标记的程序文件,不提供密钥、配置或聊天记录。';
20
+ if (codes.includes('update_available')) return '已识别历史补丁,可直接更新到最新版,不需要逐版安装或先关闭多密钥。请保存草稿、停止任务,确认更新后重启原引擎并刷新网页。';
21
+ return '';
22
+ }
package/src/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import os from 'node:os';
2
2
  import path from 'node:path';
3
3
  import { findDshRoot } from './auto-patch.js';
4
- import { createMaintenance, maintenanceHandler } from './maintenance.js';
4
+ import { createMaintenance, createUnavailableMaintenance, maintenanceHandler } from './maintenance.js';
5
5
  import { createHostInspection } from './maintenance-host.js';
6
6
 
7
7
  // Neither disposal nor ordinary package removal restores patches: HMR and updates
@@ -21,11 +21,7 @@ export function apply(ctx) {
21
21
  ctx.logger.error('多密钥安装校验失败,未强制覆盖未知文件。请查看维护页。');
22
22
  }
23
23
  ctx.inject(['webServer', 'connection', 'pluginManager'], child => {
24
- const inspect = root ? createHostInspection(child, root, home) : async () => ({ blockers: [{ code: 'HOST_UNKNOWN', message: '无法验证当前宿主安装,禁止恢复。' }], fingerprint: '' });
25
- const controller = root ? createMaintenance({ root, home, inspect, manager: child.pluginManager }) : {
26
- snapshot: async () => ({ phase: 'failed', restartRequired: false, patches: [], blockers: (await inspect()).blockers, canRestore: false, canRestoreOnly: false, canApply: false, token: null }),
27
- restore: async () => { throw { code: 'HOST_UNKNOWN', message: '无法验证当前宿主安装。' }; },
28
- };
24
+ const controller = root ? createMaintenance({ root, home, inspect: createHostInspection(child, root, home), manager: child.pluginManager }) : createUnavailableMaintenance();
29
25
  child.effect(() => child.webServer.register({ kind: 'exact', path: '/dsh-model-groups/maintenance', handler: maintenanceHandler(controller, child.connection) }));
30
26
  child.effect(() => child.webServer.register({ kind: 'exact', path: '/dsh-model-groups/status', handler(req, res) {
31
27
  const admission = child.connection.admit(req);
@@ -1,3 +1,5 @@
1
+ import { compatibilitySnapshot, publicVersion } from './compatibility.js';
2
+ export const MAINTENANCE_CLIENT_PROTOCOL = 2;
1
3
  export const MAINTENANCE_CONFIRMATION = 'CONFIRM PATCH OPERATION';
2
4
  export const MAINTENANCE_ENDPOINT = '/dsh-model-groups/maintenance';
3
5
  const MAINTENANCE_KEYS = ['dsh:model-groups:v1', 'dsh:model-groups:mode'];
@@ -14,14 +16,16 @@ export function maintenanceSnapshot(value) {
14
16
  || !(value.warnings == null || (Array.isArray(value.warnings) && value.warnings.every(warning => warning && typeof warning.code === 'string'
15
17
  && (warning.count == null || (Number.isSafeInteger(warning.count) && warning.count > 0)))))
16
18
  || !value.patches.every(patch => patch && ['patched', 'pristine', 'conflict'].includes(patch.state)
17
- && (patch.updateAvailable == null || typeof patch.updateAvailable === 'boolean'))
19
+ && (patch.updateAvailable == null || typeof patch.updateAvailable === 'boolean')
20
+ && (patch.applySupported == null || typeof patch.applySupported === 'boolean'))
18
21
  || !value.blockers.every(blocker => blocker && typeof blocker.code === 'string' && typeof blocker.message === 'string'
19
22
  && (blocker.count == null || (Number.isSafeInteger(blocker.count) && blocker.count > 0)))) {
20
23
  throw new Error('Invalid maintenance snapshot');
21
24
  }
22
- return { phase: value.phase, restartRequired: value.restartRequired, canApply: value.canApply,
25
+ return { phase: value.phase, maintenanceProtocol: value.maintenanceProtocol === MAINTENANCE_CLIENT_PROTOCOL ? MAINTENANCE_CLIENT_PROTOCOL : null,
26
+ pluginVersion: publicVersion(value.pluginVersion), compatibility: compatibilitySnapshot(value.compatibility), restartRequired: value.restartRequired, canApply: value.canApply,
23
27
  canRestoreOnly: value.canRestoreOnly, canRestore: value.canRestore,
24
- token: value.token ?? null, patches: value.patches.map(patch => ({ state: patch.state, updateAvailable: patch.updateAvailable === true })),
28
+ token: value.token ?? null, patches: value.patches.map(patch => ({ state: patch.state, updateAvailable: patch.updateAvailable === true, applySupported: patch.applySupported !== false })),
25
29
  blockers: value.blockers.map(blocker => ({ code: blocker.code, count: blocker.count ?? null })),
26
30
  warnings: (value.warnings ?? []).map(warning => ({ code: warning.code, count: warning.count ?? null })) };
27
31
  }
@@ -29,14 +33,17 @@ export function maintenanceCanAction(snapshot, action) {
29
33
  const capability = Object.hasOwn(ACTION_CAPABILITIES, action) ? ACTION_CAPABILITIES[action] : null;
30
34
  return !!capability && !!snapshot && ['idle', 'applied', 'restored', 'failed'].includes(snapshot.phase) && snapshot[capability]
31
35
  && typeof snapshot.token === 'string' && snapshot.token.length > 0
32
- && !snapshot.patches.some(patch => patch.state === 'conflict');
36
+ && !snapshot.patches.some(patch => patch.state === 'conflict')
37
+ && (action !== 'apply' || !snapshot.patches.some(patch => patch.applySupported === false));
33
38
  }
34
39
  export function maintenanceCanRestore(snapshot) { return maintenanceCanAction(snapshot, 'restore-and-uninstall'); }
35
40
  export function maintenanceBlockerText(blocker) {
36
41
  const labels = {
37
42
  active_sessions: '存在正在运行的会话,请先停止运行中的任务。',
38
43
  active_turns: '存在正在运行的回合,请先等待结束或停止。',
39
- patch_conflict: '补丁状态冲突,请先人工诊断,不会强制覆盖。',
44
+ patch_conflict: '补丁状态冲突,不会强制覆盖。请复制脱敏诊断;不要先卸载、还原或删除配置。',
45
+ client_version_mismatch: '网页与插件后端版本不一致,已禁止维护。请重启原引擎并刷新网页后重新检查。',
46
+ unsupported_patch_build: '此宿主构建尚无经过验证的新版协议补丁,已阻止不完整更新;仍可关闭或卸载,不会改动密钥。',
40
47
  backup_missing: '缺少可验证的原版备份,无法安全恢复。',
41
48
  restart_required: '需要重启原引擎后重新检查。',
42
49
  inspection_failed: '状态检查失败,请使用安装诊断排查。',
@@ -62,8 +69,8 @@ export function maintenanceBlockerText(blocker) {
62
69
  inspection_unknown: '无法确认运行状态,请先停止任务并使用安装诊断排查。',
63
70
  profile_unknown: '无法确认当前配置档,不会执行维护。',
64
71
  cross_profile_bundle: '其他配置档也在使用此插件,请先处理跨配置档依赖。',
65
- alias_dependency: '仍有会话或配置引用多密钥模型,请先切换为普通模型再检查;本预览不会自动转换。',
66
- key_config_dependency: '仍有多密钥配置依赖:请先禁用 multiKey 并清理 keyEntries 配置;已保存的密钥会保留。本预览不会自动转换。',
72
+ alias_dependency: '仍有会话或设置在使用多密钥模型。请先切换为普通模型;插件不会自动转换。',
73
+ key_config_dependency: '仍有供应商启用了多密钥。请先在「设置 → 模型」中关闭对应开关并保存;已保存的密钥会保留,配置不会自动转换。',
67
74
  expression_unknown: '存在无法安全判断的配置表达式,请先人工检查配置。',
68
75
  other_process: '其他引擎进程可能正在使用补丁,请先停止相关进程。',
69
76
  };
@@ -74,7 +81,7 @@ export function maintenanceBlockerText(blocker) {
74
81
  // Copy only allowlisted codes and enumerated state: unknown codes may contain secrets or paths.
75
82
  export function maintenanceDiagnostic(state) {
76
83
  const snapshot = state.snapshot;
77
- const knownCodes = new Set(['active_sessions', 'active_turns', 'patch_conflict', 'backup_missing', 'restart_required',
84
+ const knownCodes = new Set(['active_sessions', 'active_turns', 'patch_conflict', 'client_version_mismatch', 'unsupported_patch_build', 'backup_missing', 'restart_required',
78
85
  'inspection_failed', 'config_inspection_failed', 'session_inspection_failed', 'session_service_missing',
79
86
  'process_inspection_failed', 'profile_inspection_failed', 'patch_inspection_failed', 'default_inspection_failed',
80
87
  'agent_inspection_failed', 'activity_unverified', 'not_removable', 'not_applicable', 'manager_unavailable',
@@ -88,6 +95,8 @@ export function maintenanceDiagnostic(state) {
88
95
  return JSON.stringify({
89
96
  status: snapshot?.phase ?? 'unknown', busy: !!state.busy, resultUnverified: !!state.error,
90
97
  restartRequired: snapshot?.restartRequired ?? null,
98
+ maintenanceProtocol: snapshot?.maintenanceProtocol ?? null, pluginVersion: publicVersion(snapshot?.pluginVersion),
99
+ compatibility: compatibilitySnapshot(snapshot?.compatibility),
91
100
  capabilities: snapshot ? { apply: snapshot.canApply, close: snapshot.canRestoreOnly, uninstall: snapshot.canRestore } : null,
92
101
  patches: snapshot?.patches.map(patch => patch.state) ?? [],
93
102
  blockers: snapshot?.blockers.map(diagnosticItem) ?? [],
@@ -106,7 +115,8 @@ export function maintenanceWarningText(warning) {
106
115
  + (warning.count ? `(${warning.count} 项)` : '');
107
116
  }
108
117
 
109
- export function createMaintenanceController({ request = (...args) => fetch(...args), storage = () => globalThis.localStorage } = {}) {
118
+ export function createMaintenanceController({ request = (...args) => fetch(...args), storage = () => globalThis.localStorage,
119
+ clientVersion = typeof MODEL_GROUPS_CLIENT_VERSION === 'string' ? MODEL_GROUPS_CLIENT_VERSION : null } = {}) {
110
120
  let state = { snapshot: null, busy: false, error: '', cleanupWarning: false, patchedWarning: false };
111
121
  let disposed = false;
112
122
  let generation = 0;
@@ -117,17 +127,22 @@ export function createMaintenanceController({ request = (...args) => fetch(...ar
117
127
  if (!response.ok) throw new Error('Maintenance HTTP error');
118
128
  const envelope = await response.json();
119
129
  if (envelope?.ok !== true) throw new Error('Maintenance refused');
120
- return maintenanceSnapshot(envelope.value);
130
+ const snapshot = maintenanceSnapshot(envelope.value);
131
+ if (clientVersion !== null && (snapshot.maintenanceProtocol !== MAINTENANCE_CLIENT_PROTOCOL || snapshot.pluginVersion !== clientVersion)) {
132
+ return { ...snapshot, versionMismatch: true, canApply: false, canRestoreOnly: false, canRestore: false, token: null,
133
+ blockers: [...snapshot.blockers, { code: 'CLIENT_VERSION_MISMATCH', count: null }] };
134
+ }
135
+ return snapshot;
121
136
  }
122
137
  function accept(snapshot) {
123
138
  let cleanupWarning = false;
124
- if (snapshot.phase === 'removed') {
139
+ if (snapshot.phase === 'removed' && !snapshot.versionMismatch) {
125
140
  for (const key of MAINTENANCE_KEYS) {
126
141
  try { storage()?.removeItem(key); } catch { cleanupWarning = true; }
127
142
  }
128
143
  }
129
144
  update({ snapshot, cleanupWarning, patchedWarning: snapshot.patches.some(patch => patch.state === 'patched'),
130
- error: snapshot.phase === 'failed' ? '维护操作失败,未确认完成。请刷新检查,不要强制覆盖。' : '' });
145
+ error: snapshot.versionMismatch ? '网页与插件后端版本不一致,已停止维护。请重启原引擎并刷新网页后复查。' : snapshot.phase === 'failed' ? '维护操作失败,未确认完成。请刷新检查,不要强制覆盖。' : '' });
131
146
  }
132
147
  async function run(action = null) {
133
148
  if (disposed || state.busy || (action && !maintenanceCanAction(state.snapshot, action))) return;
@@ -147,7 +162,7 @@ export function createMaintenanceController({ request = (...args) => fetch(...ar
147
162
  }
148
163
  update({ snapshot: { ...snapshot, canApply: false, canRestoreOnly: false, canRestore: false, token: null } });
149
164
  snapshot = await readSnapshot({ method: 'POST', headers: { 'Content-Type': 'application/json' },
150
- body: JSON.stringify({ action, token, confirmation: MAINTENANCE_CONFIRMATION }) });
165
+ body: JSON.stringify({ action, token, confirmation: MAINTENANCE_CONFIRMATION, ...(clientVersion === null ? {} : { maintenanceProtocol: MAINTENANCE_CLIENT_PROTOCOL, clientVersion }) }) });
151
166
  if (!current()) return;
152
167
  }
153
168
  accept(snapshot);
@@ -1,9 +1,12 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import crypto from 'node:crypto';
4
- import { planPatches, applyPlan, sha256 } from '../scripts/native-patches.mjs';
4
+ import { planPatches, inspectPatches, applyPlan, sha256 } from '../scripts/native-patches.mjs';
5
5
 
6
6
  export const BUNDLE_NAME = 'dsh-model-groups';
7
+ export const MAINTENANCE_PROTOCOL = 2;
8
+ const pluginPackage = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
9
+ const pluginVersion = pluginPackage.version;
7
10
  // Process-local evidence survives controller replacement and module HMR, but not an
8
11
  // engine restart. The restoration marker only suppresses automatic patching: it is
9
12
  // not evidence that this process has loaded old code. A new process's runtime
@@ -17,6 +20,18 @@ export function restorationPending(root, home) {
17
20
  return fs.existsSync(path.join(maintenanceStorage(root, home), 'restore-pending.json'));
18
21
  }
19
22
 
23
+ // Discovery failure is a host diagnostic, not a stale-client mismatch. Keep the
24
+ // same public handshake even when no safe root or maintenance ticket exists.
25
+ export function createUnavailableMaintenance() {
26
+ const blocker = { code: 'HOST_UNKNOWN', message: '无法验证当前宿主安装,不会修改未知文件。' };
27
+ return {
28
+ snapshot: async () => ({ phase: 'failed', maintenanceProtocol: MAINTENANCE_PROTOCOL, pluginVersion,
29
+ compatibility: { schema: 1, pluginVersion, hostVersion: null, supportedDsh: pluginPackage.engines.dsh, files: [] },
30
+ restartRequired: false, patches: [], blockers: [blocker], warnings: [], canRestore: false, canRestoreOnly: false, canApply: false, token: null }),
31
+ restore: async () => { throw { ...blocker }; },
32
+ };
33
+ }
34
+
20
35
  /** Local-only transaction. Inspection and removal are injected to keep tests off the live host. */
21
36
  export function createMaintenance({ root, home, inspect, manager, artifacts, now = Date.now }) {
22
37
  let phase = 'idle', busy = false, ticket;
@@ -43,9 +58,16 @@ export function createMaintenance({ root, home, inspect, manager, artifacts, now
43
58
  const generic = (code, message) => ({ code, message });
44
59
  async function assessment() {
45
60
  const blockers = [];
46
- let plans = [], removable = false;
47
- try { plans = patchPlans(); }
61
+ let plans = [], removable = false, compatibility;
62
+ try {
63
+ plans = patchPlans();
64
+ if (plans.some(plan => plan.applySupported === false)) blockers.push(generic('UNSUPPORTED_PATCH_BUILD', '此宿主构建仅能识别旧补丁,尚无经过验证的新版协议补丁;不会执行不完整更新。仍可关闭或卸载。'));
65
+ }
48
66
  catch { blockers.push(generic('PATCH_CONFLICT', '宿主版本或文件校验不匹配;未修改任何宿主文件,请先处理冲突。')); }
67
+ compatibility = plans.length ? { schema: 1, pluginVersion, hostVersion: '0.2.2-rc.1', supportedDsh: pluginPackage.engines.dsh,
68
+ files: plans.map(p => ({ id: p.name, state: p.state, code: !p.applySupported ? 'unsupported_patch_build' : p.updateAvailable ? 'update_available' : p.state === 'pristine' ? 'not_applied' : 'ready', installedVersion: p.artifact.version,
69
+ observedSha256: p.observedSha256, baseSha256: p.baseSha256, targetSha256: p.targetSha256 })) } : inspectPatches(root, artifacts);
70
+ compatibility.pluginVersion = pluginVersion;
49
71
  try {
50
72
  const report = await inspect();
51
73
  if (!report || !Array.isArray(report.blockers) || typeof report.fingerprint !== 'string') throw new Error('inspection');
@@ -60,20 +82,21 @@ export function createMaintenance({ root, home, inspect, manager, artifacts, now
60
82
  // Dependency observations describe consequences, not permission to overwrite files.
61
83
  // Only proven activity, other engines sharing the installation and file conflicts
62
84
  // stop patch changes. Unknown observations remain visible for explicit consent.
63
- const hardCodes = new Set(['patch_conflict', 'active_turns', 'other_process', 'not_removable', 'manager_unavailable']);
85
+ const hardCodes = new Set(['patch_conflict', 'unsupported_patch_build', 'active_turns', 'other_process', 'not_removable', 'manager_unavailable']);
64
86
  const warnings = blockers.filter(b => !hardCodes.has(b.code.toLowerCase()));
65
87
  const hard = blockers.filter(b => hardCodes.has(b.code.toLowerCase()));
66
- return { plans, blockers: hard, warnings, removable, stamp: sha256(JSON.stringify([hard, removable, plans.map(p => [p.name, sha256(p.source), !!p.updateAvailable, sha256(JSON.stringify(p.artifact))])])) };
88
+ return { plans, compatibility, blockers: hard, warnings, removable, stamp: sha256(JSON.stringify([pluginVersion, compatibility, hard, removable, plans.map(p => [p.name, sha256(p.source), !!p.updateAvailable, p.applySupported, sha256(JSON.stringify(p.artifact))])])) };
67
89
  }
68
90
  function view(report, executing = false) {
69
91
  const valid = report.plans.length > 0 && !report.blockers.some(b => b.code.toLowerCase() === 'patch_conflict');
70
- const dependencies = report.blockers.filter(b => !['not_removable', 'manager_unavailable'].includes(b.code.toLowerCase()));
71
- const installBlockers = dependencies;
92
+ const restoreBlockers = report.blockers.filter(b => b.code.toLowerCase() !== 'unsupported_patch_build');
93
+ const dependencies = restoreBlockers.filter(b => !['not_removable', 'manager_unavailable'].includes(b.code.toLowerCase()));
94
+ const installBlockers = report.blockers.filter(b => !['not_removable', 'manager_unavailable'].includes(b.code.toLowerCase()));
72
95
  const available = (!busy || executing) && phase !== 'removed';
73
- return { phase, restartRequired: restartRequired(), patches: report.plans.map(p => ({ name: p.name, state: p.state, updateAvailable: !!p.updateAvailable })), blockers: report.blockers, warnings: report.warnings,
96
+ return { phase, maintenanceProtocol: MAINTENANCE_PROTOCOL, pluginVersion, compatibility: report.compatibility, restartRequired: restartRequired(), patches: report.plans.length ? report.plans.map(p => ({ name: p.name, state: p.state, updateAvailable: !!p.updateAvailable, applySupported: p.applySupported !== false })) : report.compatibility.files.map(p => ({ name: p.id, state: p.state, updateAvailable: p.code === 'update_available', applySupported: ['ready', 'update_available', 'not_applied'].includes(p.code) })), blockers: report.blockers, warnings: report.warnings,
74
97
  token: null, canApply: available && valid && installBlockers.length === 0 && report.plans.some(p => p.state === 'pristine' || p.updateAvailable),
75
98
  canRestoreOnly: available && valid && dependencies.length === 0 && report.plans.some(p => p.state === 'patched'),
76
- canRestore: available && valid && report.blockers.length === 0,
99
+ canRestore: available && valid && restoreBlockers.length === 0,
77
100
  notice: '关闭会保留密钥和聊天记录,不自动更换服务。旧多密钥选择可能失效,旧配置可能影响重启;使用情况未完整检查时仍可确认继续。普通插件列表卸载不会还原补丁。' };
78
101
  }
79
102
  async function snapshot() {
@@ -87,6 +110,7 @@ export function createMaintenance({ root, home, inspect, manager, artifacts, now
87
110
  }
88
111
  async function restore(input) {
89
112
  if (busy) throw generic('BUSY', '已有维护任务正在执行。');
113
+ if (input?.maintenanceProtocol !== MAINTENANCE_PROTOCOL || input?.clientVersion !== pluginVersion) throw generic('CLIENT_VERSION_MISMATCH', '网页与维护后端版本不一致,请重启原引擎并刷新网页后重新确认。');
90
114
  if (!['apply', 'restore-only', 'restore-and-uninstall'].includes(input?.action) || input?.confirmation !== 'CONFIRM PATCH OPERATION' ||
91
115
  !ticket || typeof input.token !== 'string' || input.token !== ticket.token || ticket.expires < now()) {
92
116
  throw generic('CONFIRMATION_REQUIRED', '确认已失效,请重新检查并明确确认。');
@@ -106,7 +130,7 @@ export function createMaintenance({ root, home, inspect, manager, artifacts, now
106
130
  if (!permitted || report.stamp !== expected.stamp) throw generic('STATE_CHANGED', '文件或任务状态发生变化,请确认没有任务运行后重试。');
107
131
  if (action === 'apply') {
108
132
  changeFiles(planPatches(root, 'apply', artifacts));
109
- if (!patchPlans().every(p => p.state === 'patched' && !p.updateAvailable)) throw generic('VERIFY_FAILED', '补丁验证失败。');
133
+ if (!patchPlans().every(p => p.state === 'patched' && !p.updateAvailable && p.applySupported !== false)) throw generic('VERIFY_FAILED', '补丁验证失败。');
110
134
  // Never delete a computed or unverified path; only this exact owned marker.
111
135
  if (fs.existsSync(marker)) {
112
136
  const stat = fs.lstatSync(marker);
@@ -129,7 +153,7 @@ export function createMaintenance({ root, home, inspect, manager, artifacts, now
129
153
  if (remaining?.installed || remaining?.removable || remaining?.enabled) throw generic('REMOVE_UNVERIFIED', '尚未确认插件依赖已移除,请检查插件列表;补丁不会自动重新安装。');
130
154
  if (!patchPlans().every(p => p.state === 'pristine')) throw generic('VERIFY_FAILED', '卸载后宿主文件再次变化,不能确认完整恢复。');
131
155
  phase = 'removed';
132
- return { phase, restartRequired: restartRequired(), patches: patchPlans().map(p => ({ name: p.name, state: p.state, updateAvailable: !!p.updateAvailable })), blockers: [], canRestore: false, canRestoreOnly: false, canApply: false, token: null,
156
+ return { phase, maintenanceProtocol: MAINTENANCE_PROTOCOL, pluginVersion, compatibility: (await assessment()).compatibility, restartRequired: restartRequired(), patches: patchPlans().map(p => ({ name: p.name, state: p.state, updateAvailable: !!p.updateAvailable, applySupported: p.applySupported !== false })), blockers: [], canRestore: false, canRestoreOnly: false, canApply: false, token: null,
133
157
  notice: '已确认原版文件和插件依赖移除状态。密钥、历史和安全备份未删除;运行时状态未验证,是否需要重启以本进程实际文件修改记录为准。' };
134
158
  } catch (error) {
135
159
  phase = 'failed';