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/README.md +48 -84
- package/docs/images/add-key-configuration.webp +0 -0
- package/docs/images/collapsed-key-configurations.webp +0 -0
- package/docs/images/key-configuration.webp +0 -0
- package/docs/images/multi-key-management.webp +0 -0
- package/docs/images/provider-multi-key-toggle.webp +0 -0
- package/docs/maintenance.md +47 -0
- package/lib/client.js +75 -27
- package/native/pi-ai.patch.json +507 -4
- package/native/settings-models.patch.json +226 -7
- package/package.json +5 -2
- package/scripts/build.mjs +2 -1
- package/scripts/doctor.mjs +22 -23
- package/scripts/native-patches.mjs +65 -5
- package/src/auto-patch.js +5 -3
- package/src/client.js +2 -2
- package/src/compatibility.js +22 -0
- package/src/index.js +2 -6
- package/src/maintenance-state.js +28 -13
- package/src/maintenance.js +35 -11
- package/src/settings-page.js +23 -12
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-model-groups",
|
|
3
|
-
"version": "0.3.
|
|
4
|
-
"description": "DSH
|
|
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);
|
package/scripts/doctor.mjs
CHANGED
|
@@ -1,39 +1,38 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import { fileURLToPath } from 'node:url';
|
|
3
|
-
import {
|
|
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
|
|
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
|
|
16
|
-
const
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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 ${
|
|
31
|
-
for (const item of report.files) console.log(`${item.
|
|
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
|
-
|
|
35
|
-
} catch
|
|
36
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
? '
|
|
25
|
-
: '
|
|
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
|
|
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);
|
package/src/maintenance-state.js
CHANGED
|
@@ -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,
|
|
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: '
|
|
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
|
-
|
|
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);
|
package/src/maintenance.js
CHANGED
|
@@ -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 {
|
|
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
|
|
71
|
-
const
|
|
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 &&
|
|
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';
|