dsh-model-groups 0.3.4 → 0.3.5

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.
@@ -0,0 +1,171 @@
1
+ export const MAINTENANCE_CONFIRMATION = 'CONFIRM PATCH OPERATION';
2
+ export const MAINTENANCE_ENDPOINT = '/dsh-model-groups/maintenance';
3
+ const MAINTENANCE_KEYS = ['dsh:model-groups:v1', 'dsh:model-groups:mode'];
4
+ const ACTION_CAPABILITIES = { apply: 'canApply', 'restore-only': 'canRestoreOnly', 'restore-and-uninstall': 'canRestore' };
5
+
6
+ // Never project arbitrary server messages, patch paths, or tokens into the DOM.
7
+ export function maintenanceSnapshot(value) {
8
+ const phases = ['idle', 'applied', 'restored', 'removing', 'removed', 'failed'];
9
+ if (!value || !phases.includes(value.phase) || typeof value.restartRequired !== 'boolean'
10
+ || !Object.values(ACTION_CAPABILITIES).every(key => typeof value[key] === 'boolean')
11
+ || !(value.token == null || (typeof value.token === 'string' && value.token.length > 0))
12
+ || (Object.values(ACTION_CAPABILITIES).some(key => value[key]) && !(typeof value.token === 'string' && value.token.length > 0))
13
+ || !Array.isArray(value.patches) || !Array.isArray(value.blockers)
14
+ || !(value.warnings == null || (Array.isArray(value.warnings) && value.warnings.every(warning => warning && typeof warning.code === 'string'
15
+ && (warning.count == null || (Number.isSafeInteger(warning.count) && warning.count > 0)))))
16
+ || !value.patches.every(patch => patch && ['patched', 'pristine', 'conflict'].includes(patch.state)
17
+ && (patch.updateAvailable == null || typeof patch.updateAvailable === 'boolean'))
18
+ || !value.blockers.every(blocker => blocker && typeof blocker.code === 'string' && typeof blocker.message === 'string'
19
+ && (blocker.count == null || (Number.isSafeInteger(blocker.count) && blocker.count > 0)))) {
20
+ throw new Error('Invalid maintenance snapshot');
21
+ }
22
+ return { phase: value.phase, restartRequired: value.restartRequired, canApply: value.canApply,
23
+ canRestoreOnly: value.canRestoreOnly, canRestore: value.canRestore,
24
+ token: value.token ?? null, patches: value.patches.map(patch => ({ state: patch.state, updateAvailable: patch.updateAvailable === true })),
25
+ blockers: value.blockers.map(blocker => ({ code: blocker.code, count: blocker.count ?? null })),
26
+ warnings: (value.warnings ?? []).map(warning => ({ code: warning.code, count: warning.count ?? null })) };
27
+ }
28
+ export function maintenanceCanAction(snapshot, action) {
29
+ const capability = Object.hasOwn(ACTION_CAPABILITIES, action) ? ACTION_CAPABILITIES[action] : null;
30
+ return !!capability && !!snapshot && ['idle', 'applied', 'restored', 'failed'].includes(snapshot.phase) && snapshot[capability]
31
+ && typeof snapshot.token === 'string' && snapshot.token.length > 0
32
+ && !snapshot.patches.some(patch => patch.state === 'conflict');
33
+ }
34
+ export function maintenanceCanRestore(snapshot) { return maintenanceCanAction(snapshot, 'restore-and-uninstall'); }
35
+ export function maintenanceBlockerText(blocker) {
36
+ const labels = {
37
+ active_sessions: '存在正在运行的会话,请先停止运行中的任务。',
38
+ active_turns: '存在正在运行的回合,请先等待结束或停止。',
39
+ patch_conflict: '补丁状态冲突,请先人工诊断,不会强制覆盖。',
40
+ backup_missing: '缺少可验证的原版备份,无法安全恢复。',
41
+ restart_required: '需要重启原引擎后重新检查。',
42
+ inspection_failed: '状态检查失败,请使用安装诊断排查。',
43
+ config_inspection_failed: '配置检查失败,无法验证模型与密钥依赖;请使用安装诊断排查。',
44
+ session_inspection_failed: '会话检查失败,无法验证活动回合与模型引用;请使用安装诊断排查。',
45
+ session_service_missing: '缺少会话查询或持久化服务,无法验证历史会话的模型依赖;请使用安装诊断排查。',
46
+ process_inspection_failed: '进程检查失败,无法排除其他引擎使用补丁;请使用安装诊断排查。',
47
+ profile_inspection_failed: '配置档检查失败,无法排除跨配置档依赖;请使用安装诊断排查。',
48
+ patch_inspection_failed: '补丁文件检查失败,无法验证磁盘状态;不会修改未知文件。',
49
+ not_removable: '当前插件状态不允许安全卸载。',
50
+ not_applicable: '当前状态不允许安全应用补丁。',
51
+ manager_unavailable: '插件管理服务不可用,请稍后重试。',
52
+ host_unknown: '无法验证宿主版本,不会修改未知文件。',
53
+ state_changed: '状态已发生变化,请刷新检查后重新打开操作并确认。',
54
+ apply_failed: '补丁应用未完成,请刷新检查,不要视为成功。',
55
+ restore_failed: '原版恢复未完成,请刷新检查,不要视为成功。',
56
+ remove_failed: '卸载未完成,请刷新检查并使用安装诊断排查。',
57
+ remove_unverified: '无法验证卸载结果,请刷新检查,不要视为成功。',
58
+ verify_failed: '安全验证失败,不会强制覆盖文件。',
59
+ busy: '维护或其他任务正在运行,请结束后刷新。',
60
+ confirmation_required: '安全确认已失效,请刷新检查后重新打开操作并确认。',
61
+ maintenance_failed: '维护失败,请刷新检查并使用安装诊断排查。',
62
+ inspection_unknown: '无法确认运行状态,请先停止任务并使用安装诊断排查。',
63
+ profile_unknown: '无法确认当前配置档,不会执行维护。',
64
+ cross_profile_bundle: '其他配置档也在使用此插件,请先处理跨配置档依赖。',
65
+ alias_dependency: '仍有会话或配置引用多密钥模型,请先切换为普通模型再检查;本预览不会自动转换。',
66
+ key_config_dependency: '仍有多密钥配置依赖:请先禁用 multiKey 并清理 keyEntries 配置;已保存的密钥会保留。本预览不会自动转换。',
67
+ expression_unknown: '存在无法安全判断的配置表达式,请先人工检查配置。',
68
+ other_process: '其他引擎进程可能正在使用补丁,请先停止相关进程。',
69
+ };
70
+ return (labels[blocker.code.toLowerCase()] || '安全检查未通过,请先处理运行中的任务或使用安装诊断排查。')
71
+ + (blocker.count ? `(${blocker.count} 项)` : '');
72
+ }
73
+
74
+ // Copy only allowlisted codes and enumerated state: unknown codes may contain secrets or paths.
75
+ export function maintenanceDiagnostic(state) {
76
+ const snapshot = state.snapshot;
77
+ const knownCodes = new Set(['active_sessions', 'active_turns', 'patch_conflict', 'backup_missing', 'restart_required',
78
+ 'inspection_failed', 'config_inspection_failed', 'session_inspection_failed', 'session_service_missing',
79
+ 'process_inspection_failed', 'profile_inspection_failed', 'patch_inspection_failed', 'default_inspection_failed',
80
+ 'agent_inspection_failed', 'activity_unverified', 'not_removable', 'not_applicable', 'manager_unavailable',
81
+ 'host_unknown', 'state_changed', 'apply_failed', 'restore_failed', 'remove_failed', 'remove_unverified',
82
+ 'verify_failed', 'busy', 'confirmation_required', 'maintenance_failed', 'inspection_unknown', 'profile_unknown',
83
+ 'cross_profile_bundle', 'alias_dependency', 'key_config_dependency', 'expression_unknown', 'other_process']);
84
+ const diagnosticItem = item => {
85
+ const code = item.code.toLowerCase();
86
+ return { code: knownCodes.has(code) ? code : 'unknown', count: item.count };
87
+ };
88
+ return JSON.stringify({
89
+ status: snapshot?.phase ?? 'unknown', busy: !!state.busy, resultUnverified: !!state.error,
90
+ restartRequired: snapshot?.restartRequired ?? null,
91
+ capabilities: snapshot ? { apply: snapshot.canApply, close: snapshot.canRestoreOnly, uninstall: snapshot.canRestore } : null,
92
+ patches: snapshot?.patches.map(patch => patch.state) ?? [],
93
+ blockers: snapshot?.blockers.map(diagnosticItem) ?? [],
94
+ warnings: snapshot?.warnings.map(diagnosticItem) ?? [],
95
+ }, null, 2);
96
+ }
97
+ export function maintenanceWarningText(warning) {
98
+ const labels = {
99
+ alias_dependency: '旧多密钥模型选择可能影响后续使用或重启;本页不会自动切换服务或模型。',
100
+ key_config_dependency: '旧多密钥配置可能影响后续使用或重启;关闭会保留密钥和历史,不自动转换配置。',
101
+ expression_unknown: '部分配置表达式无法完整检查,请留意关闭后的使用和重启情况。',
102
+ };
103
+ const code = warning.code.toLowerCase();
104
+ const inspection = ['config_inspection_failed', 'session_inspection_failed', 'session_service_missing', 'process_inspection_failed', 'profile_inspection_failed', 'inspection_unknown', 'default_inspection_failed', 'agent_inspection_failed', 'activity_unverified'];
105
+ return (labels[code] || (inspection.includes(code) ? '检查不完整,无法确认全部依赖;这只是提醒,不会额外禁用操作。' : '部分使用情况未能验证,不等于安全;这只是提醒,不会额外禁用操作。'))
106
+ + (warning.count ? `(${warning.count} 项)` : '');
107
+ }
108
+
109
+ export function createMaintenanceController({ request = (...args) => fetch(...args), storage = () => globalThis.localStorage } = {}) {
110
+ let state = { snapshot: null, busy: false, error: '', cleanupWarning: false, patchedWarning: false };
111
+ let disposed = false;
112
+ let generation = 0;
113
+ const listeners = new Set();
114
+ const update = next => { if (disposed) return; state = { ...state, ...next }; for (const listener of listeners) listener(); };
115
+ async function readSnapshot(options) {
116
+ const response = await request(MAINTENANCE_ENDPOINT, { credentials: 'same-origin', cache: 'no-store', ...options });
117
+ if (!response.ok) throw new Error('Maintenance HTTP error');
118
+ const envelope = await response.json();
119
+ if (envelope?.ok !== true) throw new Error('Maintenance refused');
120
+ return maintenanceSnapshot(envelope.value);
121
+ }
122
+ function accept(snapshot) {
123
+ let cleanupWarning = false;
124
+ if (snapshot.phase === 'removed') {
125
+ for (const key of MAINTENANCE_KEYS) {
126
+ try { storage()?.removeItem(key); } catch { cleanupWarning = true; }
127
+ }
128
+ }
129
+ update({ snapshot, cleanupWarning, patchedWarning: snapshot.patches.some(patch => patch.state === 'patched'),
130
+ error: snapshot.phase === 'failed' ? '维护操作失败,未确认完成。请刷新检查,不要强制覆盖。' : '' });
131
+ }
132
+ async function run(action = null) {
133
+ if (disposed || state.busy || (action && !maintenanceCanAction(state.snapshot, action))) return;
134
+ const sequence = ++generation;
135
+ const token = state.snapshot?.token;
136
+ const current = () => !disposed && sequence === generation;
137
+ update({ busy: true, error: '' });
138
+ try {
139
+ // Recheck immediately before using the confirmed token; a changed state needs new consent.
140
+ let snapshot = await readSnapshot({ method: 'GET' });
141
+ if (!current()) return;
142
+ if (action) {
143
+ if (snapshot.token !== token || !maintenanceCanAction(snapshot, action)) {
144
+ accept(snapshot);
145
+ update({ error: '状态已发生变化或操作被阻止,未执行操作。请检查当前状态并重新打开操作并确认。' });
146
+ return;
147
+ }
148
+ update({ snapshot: { ...snapshot, canApply: false, canRestoreOnly: false, canRestore: false, token: null } });
149
+ snapshot = await readSnapshot({ method: 'POST', headers: { 'Content-Type': 'application/json' },
150
+ body: JSON.stringify({ action, token, confirmation: MAINTENANCE_CONFIRMATION }) });
151
+ if (!current()) return;
152
+ }
153
+ accept(snapshot);
154
+ } catch {
155
+ if (current()) update({ snapshot: null,
156
+ error: action ? '未能确认维护结果:连接中断、请求被拒绝或响应无效不代表成功。请刷新检查。' : '状态检查失败,请确认连接后刷新重试。' });
157
+ } finally { if (current()) update({ busy: false }); }
158
+ }
159
+ const operate = (action, checked) => checked === true && Object.hasOwn(ACTION_CAPABILITIES, action) ? run(action) : Promise.resolve();
160
+ return {
161
+ getSnapshot: () => state,
162
+ subscribe: listener => { listeners.add(listener); return () => listeners.delete(listener); },
163
+ activate: () => { disposed = false; generation++; update({ busy: false }); },
164
+ refresh: () => run(),
165
+ operate,
166
+ apply: checked => operate('apply', checked),
167
+ restoreOnly: checked => operate('restore-only', checked),
168
+ restore: checked => operate('restore-and-uninstall', checked),
169
+ dispose: () => { disposed = true; generation++; listeners.clear(); },
170
+ };
171
+ }
@@ -0,0 +1,164 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import crypto from 'node:crypto';
4
+ import { planPatches, applyPlan, sha256 } from '../scripts/native-patches.mjs';
5
+
6
+ export const BUNDLE_NAME = 'dsh-model-groups';
7
+ // Process-local evidence survives controller replacement and module HMR, but not an
8
+ // engine restart. The restoration marker only suppresses automatic patching: it is
9
+ // not evidence that this process has loaded old code. A new process's runtime
10
+ // remains unverified; checking disk must never create persistent restart state.
11
+ const processChangesKey = Symbol.for('dsh-model-groups.maintenance.process-changes.v1');
12
+ const processChanges = globalThis[processChangesKey] ??= new Map();
13
+ export function maintenanceStorage(root, home) {
14
+ return path.join(home, BUNDLE_NAME, sha256(fs.realpathSync(root)).slice(0, 16));
15
+ }
16
+ export function restorationPending(root, home) {
17
+ return fs.existsSync(path.join(maintenanceStorage(root, home), 'restore-pending.json'));
18
+ }
19
+
20
+ /** Local-only transaction. Inspection and removal are injected to keep tests off the live host. */
21
+ export function createMaintenance({ root, home, inspect, manager, artifacts, now = Date.now }) {
22
+ let phase = 'idle', busy = false, ticket;
23
+ const rootKey = fs.realpathSync(root);
24
+ const storage = maintenanceStorage(root, home);
25
+ const marker = path.join(storage, 'restore-pending.json');
26
+ const restartRequired = () => (processChanges.get(rootKey)?.length ?? 0) > 0;
27
+ function changeFiles(plans) {
28
+ try { applyPlan(plans, path.join(storage, 'backups')); }
29
+ finally {
30
+ // Record actual observed disk changes, not an attempted/no-op operation.
31
+ // Also retain evidence if verification later fails or a partial write survives.
32
+ const changes = plans.flatMap(plan => {
33
+ try {
34
+ const current = fs.readFileSync(plan.file, 'utf8');
35
+ return plan.source !== plan.result && current === plan.result
36
+ ? [{ file: plan.file, before: sha256(plan.source), after: sha256(current) }] : [];
37
+ } catch { return []; }
38
+ });
39
+ if (changes.length) processChanges.set(rootKey, [...(processChanges.get(rootKey) ?? []), ...changes]);
40
+ }
41
+ }
42
+ const patchPlans = () => planPatches(root, 'revert', artifacts);
43
+ const generic = (code, message) => ({ code, message });
44
+ async function assessment() {
45
+ const blockers = [];
46
+ let plans = [], removable = false;
47
+ try { plans = patchPlans(); }
48
+ catch { blockers.push(generic('PATCH_CONFLICT', '宿主版本或文件校验不匹配;未修改任何宿主文件,请先处理冲突。')); }
49
+ try {
50
+ const report = await inspect();
51
+ if (!report || !Array.isArray(report.blockers) || typeof report.fingerprint !== 'string') throw new Error('inspection');
52
+ blockers.push(...report.blockers);
53
+ } catch { blockers.push(generic('INSPECTION_FAILED', '部分配置和会话使用情况未能检查;关闭后可能需要重新配置普通模型。')); }
54
+ try {
55
+ const bundles = await manager.listBundles();
56
+ const bundle = bundles.find(item => item.name === BUNDLE_NAME);
57
+ removable = !!bundle?.removable;
58
+ if (!removable) blockers.push(generic('NOT_REMOVABLE', '当前插件不属于可卸载的 profile 依赖,请使用安装它的原 profile 管理器。'));
59
+ } catch { blockers.push(generic('MANAGER_UNAVAILABLE', '插件管理器不可用,无法安全卸载。')); }
60
+ // Dependency observations describe consequences, not permission to overwrite files.
61
+ // Only proven activity, other engines sharing the installation and file conflicts
62
+ // 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']);
64
+ const warnings = blockers.filter(b => !hardCodes.has(b.code.toLowerCase()));
65
+ 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))])])) };
67
+ }
68
+ function view(report, executing = false) {
69
+ 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;
72
+ 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,
74
+ token: null, canApply: available && valid && installBlockers.length === 0 && report.plans.some(p => p.state === 'pristine' || p.updateAvailable),
75
+ canRestoreOnly: available && valid && dependencies.length === 0 && report.plans.some(p => p.state === 'patched'),
76
+ canRestore: available && valid && report.blockers.length === 0,
77
+ notice: '关闭会保留密钥和聊天记录,不自动更换服务。旧多密钥选择可能失效,旧配置可能影响重启;使用情况未完整检查时仍可确认继续。普通插件列表卸载不会还原补丁。' };
78
+ }
79
+ async function snapshot() {
80
+ const report = await assessment();
81
+ const value = view(report);
82
+ if (value.canRestore || value.canRestoreOnly || value.canApply) {
83
+ if (!ticket || ticket.stamp !== report.stamp || ticket.expires < now()) ticket = { token: crypto.randomBytes(32).toString('hex'), stamp: report.stamp, expires: now() + 120000 };
84
+ value.token = ticket.token;
85
+ }
86
+ return value;
87
+ }
88
+ async function restore(input) {
89
+ if (busy) throw generic('BUSY', '已有维护任务正在执行。');
90
+ if (!['apply', 'restore-only', 'restore-and-uninstall'].includes(input?.action) || input?.confirmation !== 'CONFIRM PATCH OPERATION' ||
91
+ !ticket || typeof input.token !== 'string' || input.token !== ticket.token || ticket.expires < now()) {
92
+ throw generic('CONFIRMATION_REQUIRED', '确认已失效,请重新检查并明确确认。');
93
+ }
94
+ const expected = ticket; ticket = undefined;
95
+ const action = input.action;
96
+ busy = true;
97
+ let locked = false;
98
+ const lock = path.join(storage, 'patch.lock');
99
+ try {
100
+ fs.mkdirSync(storage, { recursive: true, mode: 0o700 });
101
+ // Same inter-process lock as automatic installation. Never remove somebody else's lock.
102
+ fs.mkdirSync(lock, { mode: 0o700 }); locked = true;
103
+ const report = await assessment();
104
+ const permissions = view(report, true);
105
+ const permitted = action === 'apply' ? permissions.canApply : action === 'restore-only' ? permissions.canRestoreOnly : permissions.canRestore;
106
+ if (!permitted || report.stamp !== expected.stamp) throw generic('STATE_CHANGED', '文件或任务状态发生变化,请确认没有任务运行后重试。');
107
+ if (action === 'apply') {
108
+ changeFiles(planPatches(root, 'apply', artifacts));
109
+ if (!patchPlans().every(p => p.state === 'patched' && !p.updateAvailable)) throw generic('VERIFY_FAILED', '补丁验证失败。');
110
+ // Never delete a computed or unverified path; only this exact owned marker.
111
+ if (fs.existsSync(marker)) {
112
+ const stat = fs.lstatSync(marker);
113
+ if (!stat.isFile() || stat.isSymbolicLink()) throw generic('VERIFY_FAILED', '恢复标记异常。');
114
+ fs.unlinkSync(marker);
115
+ }
116
+ phase = 'applied';
117
+ return view(await assessment());
118
+ }
119
+ fs.writeFileSync(marker, JSON.stringify({ version: 1, purpose: 'explicit-restore-and-uninstall' }), { mode: 0o600 });
120
+ // Fresh synchronous planning checks concurrent target edits again; applyPlan performs an additional check.
121
+ changeFiles(patchPlans());
122
+ if (!patchPlans().every(p => p.state === 'pristine')) throw generic('VERIFY_FAILED', '原版文件复查失败,未宣告卸载成功。');
123
+ phase = 'restored';
124
+ if (action === 'restore-only') return view(await assessment());
125
+ phase = 'removing';
126
+ const result = await manager.removeBundle(BUNDLE_NAME);
127
+ if (!result || result.application !== 'applied' || result.error || (result.packageResult && (result.packageResult.exitCode !== 0 || result.packageResult.timedOut))) throw generic('REMOVE_FAILED', '已确认原版文件,但插件卸载失败。请保留恢复标记并在插件列表中重试卸载;是否需要重启以文件修改记录为准。');
128
+ const remaining = (await manager.listBundles()).find(item => item.name === BUNDLE_NAME);
129
+ if (remaining?.installed || remaining?.removable || remaining?.enabled) throw generic('REMOVE_UNVERIFIED', '尚未确认插件依赖已移除,请检查插件列表;补丁不会自动重新安装。');
130
+ if (!patchPlans().every(p => p.state === 'pristine')) throw generic('VERIFY_FAILED', '卸载后宿主文件再次变化,不能确认完整恢复。');
131
+ 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,
133
+ notice: '已确认原版文件和插件依赖移除状态。密钥、历史和安全备份未删除;运行时状态未验证,是否需要重启以本进程实际文件修改记录为准。' };
134
+ } catch (error) {
135
+ phase = 'failed';
136
+ throw ['STATE_CHANGED', 'VERIFY_FAILED', 'REMOVE_FAILED', 'REMOVE_UNVERIFIED'].includes(error?.code) ? error : generic('MAINTENANCE_FAILED', '维护未完成。未宣告成功;如已还原文件,恢复标记会阻止自动重新打补丁。请检查后重试。');
137
+ } finally {
138
+ busy = false;
139
+ if (locked) fs.rmdirSync(lock);
140
+ }
141
+ }
142
+ return { snapshot, restore, isBusy: () => busy };
143
+ }
144
+
145
+ /** Every request requires the host's authenticated owner admission, even on stock DSH. */
146
+ export function maintenanceHandler(controller, connection) {
147
+ return async (req, res) => {
148
+ const send = (status, body) => { res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store', 'x-content-type-options': 'nosniff' }); res.end(JSON.stringify(body)); };
149
+ try {
150
+ const admission = connection.admit(req);
151
+ if (!admission?.peer || admission.peer.principal?.kind !== 'owner') return send(admission?.rejection || 403, { ok: false, error: { code: 'FORBIDDEN', message: '需要已登录的主机所有者。' } });
152
+ if (req.method === 'GET') return send(200, { ok: true, value: await controller.snapshot() });
153
+ if (req.method !== 'POST') return send(405, { ok: false, error: { code: 'METHOD', message: '不支持此操作。' } });
154
+ if (!/^application\/json(?:;|$)/i.test(req.headers['content-type'] || '')) return send(415, { ok: false, error: { code: 'CONTENT_TYPE', message: '只接受 JSON。' } });
155
+ let body = '', length = 0;
156
+ for await (const chunk of req) { length += Buffer.byteLength(chunk); if (length > 4096) return send(413, { ok: false, error: { code: 'BODY_LIMIT', message: '请求过大。' } }); body += chunk; }
157
+ let input;
158
+ try { input = JSON.parse(body); } catch { return send(400, { ok: false, error: { code: 'JSON', message: '请求格式错误。' } }); }
159
+ return send(200, { ok: true, value: await controller.restore(input) });
160
+ } catch (error) {
161
+ return send(409, { ok: false, error: { code: error?.code || 'FAILED', message: error?.code ? error.message : '检查或维护失败,请重新检查。' } });
162
+ }
163
+ };
164
+ }
@@ -0,0 +1,21 @@
1
+ .mg-maintenance{box-sizing:border-box;display:flex;flex-direction:column;gap:12px;max-width:720px;min-width:0;color:var(--dsw-alias-label-primary);font-family:inherit;font-size:13px;line-height:1.5}
2
+ .mg-maintenance h2{margin:0;font-size:18px;font-weight:600;line-height:26px}
3
+ .mg-maintenance h3{margin:0;font-size:14px;font-weight:600;line-height:22px}
4
+ .mg-maintenance p{margin:0;color:var(--dsw-alias-label-tertiary);font-size:13px;line-height:20px}
5
+ .mg-maintenance-card{display:flex;flex-direction:column;gap:12px;min-width:0;border:.5px solid var(--dsw-alias-settings-card-stroke);border-radius:var(--dsw-radius-xl,12px);background:var(--dsw-alias-settings-card-fill);padding:16px}
6
+ .mg-maintenance-row{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap}
7
+ .mg-maintenance ul{padding-left:20px;margin:0;color:var(--dsw-alias-label-secondary);overflow-wrap:anywhere}
8
+ .mg-maintenance-actions{display:flex;flex-wrap:wrap;gap:10px;align-items:center}
9
+ .mg-maintenance button{align-self:flex-start;font:inherit;font-weight:500;line-height:20px;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-fill-tsp-secondary);border:.5px solid var(--dsw-alias-settings-card-stroke);border-radius:var(--dsw-radius-sm,6px);padding:8px 14px;min-height:40px;cursor:pointer;white-space:normal}
10
+ .mg-maintenance .mg-maintenance-primary:not(:disabled){border-color:var(--dsw-alias-state-business-primary);font-weight:600}
11
+ .mg-maintenance .mg-maintenance-secondary{font-size:12px}
12
+ @media(hover:hover){.mg-maintenance button:not(:disabled):hover{background:var(--dsw-alias-interactive-bg-hover)}}
13
+ .mg-maintenance button:disabled{color:var(--dsw-alias-label-tertiary);background:var(--dsw-alias-fill-tsp-secondary);cursor:not-allowed;opacity:1}
14
+ .mg-maintenance .mg-maintenance-danger:not(:disabled),.mg-maintenance .mg-maintenance-error{color:var(--dsw-alias-state-error-primary)}
15
+ .mg-maintenance .mg-maintenance-help{color:var(--dsw-alias-label-secondary)}
16
+ .mg-maintenance-dialog{display:flex;flex-direction:column;gap:12px;border:1px solid var(--dsw-alias-state-business-primary);border-radius:var(--dsw-radius-sm,6px);padding:16px}
17
+ .mg-maintenance-warning{padding:10px;border-left:3px solid var(--dsw-alias-state-business-primary)}
18
+ .mg-maintenance summary{cursor:pointer;font-weight:500;min-height:28px}
19
+ .mg-maintenance details[open]>*+*{margin-top:12px}
20
+ .mg-maintenance :focus-visible{outline:2px solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-offset:3px}
21
+ @media(max-width:480px){.mg-maintenance-card,.mg-maintenance-dialog{padding:12px}.mg-maintenance button{max-width:100%}}
@@ -0,0 +1,56 @@
1
+ export function ModelGroupsSettingsPage() {
2
+ const h = react.createElement;
3
+ const controller = react.useMemo(() => createMaintenanceController(), []);
4
+ const state = react.useSyncExternalStore(controller.subscribe, controller.getSnapshot, controller.getSnapshot);
5
+ const [pending, setPending] = react.useState(null);
6
+ const [copyStatus, setCopyStatus] = react.useState('');
7
+ const confirmationId = react.useId();
8
+ react.useEffect(() => { controller.activate(); controller.refresh(); return () => controller.dispose(); }, [controller]);
9
+ const snapshot = state.snapshot;
10
+ const phaseLabels = { idle: '检查完成,可选择操作', applied: '多密钥已应用(磁盘状态)', restored: '多密钥已关闭,插件仍保留(磁盘状态)', removing: '正在卸载,尚未确认完成', removed: '已确认关闭多密钥并卸载插件', failed: '维护失败,未确认完成' };
11
+ const updatesAvailable = snapshot?.patches.some(patch => patch.updateAvailable) ?? false;
12
+ const statusLabel = snapshot?.phase === 'idle'
13
+ ? snapshot.patches.length && snapshot.patches.every(patch => patch.state === 'patched') ? '多密钥已启用(磁盘状态)'
14
+ : snapshot.patches.length && snapshot.patches.every(patch => patch.state === 'pristine') ? '多密钥已关闭(磁盘状态)' : '磁盘状态待处理,请查看操作限制'
15
+ : phaseLabels[snapshot?.phase];
16
+ const patchLabels = { patched: '已应用', pristine: '原版', conflict: '冲突(不会强制覆盖)' };
17
+ const actions = [['apply', updatesAvailable ? '更新多密钥补丁' : '启用多密钥'], ['restore-only', '关闭多密钥'], ['restore-and-uninstall', '关闭并卸载插件']];
18
+ const anyAllowed = actions.some(([action]) => maintenanceCanAction(snapshot, action));
19
+ const refresh = () => { setPending(null); controller.refresh(); };
20
+ const confirm = () => { const action = pending; setPending(null); if (action) controller.operate(action, true); };
21
+ const copy = async () => {
22
+ try { await globalThis.navigator.clipboard.writeText(maintenanceDiagnostic(state)); setCopyStatus('已复制脱敏诊断'); }
23
+ catch { setCopyStatus('无法复制,请检查浏览器剪贴板权限。'); }
24
+ };
25
+ return h('section', { className: 'mg-maintenance', 'aria-label': '模型分组设置', 'aria-busy': state.busy },
26
+ h('h2', null, '模型分组 · 多密钥管理'),
27
+ h('p', null, '选择操作,再确认一次即可。不会调用 AI,不会显示或删除 API 密钥。'),
28
+ h('section', { className: 'mg-maintenance-card', 'aria-label': '当前状态' },
29
+ h('div', { className: 'mg-maintenance-row' }, h('h3', null, '当前状态'), h('button', { type: 'button', disabled: state.busy, onClick: refresh }, state.busy ? '处理中…' : '刷新检查')),
30
+ h('p', { role: 'status', 'aria-live': 'polite', className: 'mg-maintenance-help' }, state.busy ? '正在检查或执行维护,请勿关闭或重复操作。' : snapshot ? statusLabel : '尚无可验证状态,请刷新检查。'),
31
+ state.error && h('p', { role: 'alert', className: 'mg-maintenance-error' }, state.error),
32
+ state.patchedWarning && h('p', { role: 'alert', className: 'mg-maintenance-error' }, '警告:从普通插件列表卸载只移除插件,仍会留下宿主补丁,可能造成升级、文件冲突及旧多密钥模型依赖问题。请使用这里的“关闭并卸载插件”。'),
33
+ snapshot?.restartRequired && h('p', { role: 'status' }, '本进程已实际修改宿主文件,需要重启原引擎并刷新;页面刷新或插件重载不等于进程重启。'),
34
+ updatesAvailable && h('p', { role: 'status' }, '多密钥补丁有更新可用;确认后将备份、校验并更新已识别的旧补丁。'),
35
+ snapshot?.phase === 'removed' && h('p', { role: 'status' }, '卸载已确认:仅清理本插件的两个浏览器显示偏好,密钥、历史和备份均保留。'),
36
+ snapshot?.warnings.length > 0 && h('p', { className: 'mg-maintenance-warning', role: 'status' }, '部分使用情况未验证,关闭前请阅读提示;检查提醒不会额外禁用操作。'),
37
+ snapshot?.blockers.length > 0 && h('div', { role: 'alert' }, h('strong', null, '操作限制(各按钮以安全权限为准)'), h('ul', null, ...snapshot.blockers.map((blocker, index) => h('li', { key: index }, maintenanceBlockerText(blocker))))),
38
+ state.cleanupWarning && h('p', { role: 'status' }, '卸载已确认,但浏览器阻止清理本插件的显示偏好;其他数据未受影响。')),
39
+ h('section', { className: 'mg-maintenance-card', 'aria-label': '多密钥操作' },
40
+ h('h3', null, '多密钥操作'),
41
+ h('div', { className: 'mg-maintenance-actions' }, ...actions.map(([action, label]) => h('button', { key: action, type: 'button', className: action === 'restore-and-uninstall' ? 'mg-maintenance-secondary mg-maintenance-danger' : 'mg-maintenance-primary', disabled: state.busy || !!pending || !maintenanceCanAction(snapshot, action), onClick: () => setPending(action) }, label))),
42
+ !anyAllowed && h('p', null, '请刷新检查或处理上方操作限制。检查提醒本身不会禁用按钮。'),
43
+ pending && h('div', { role: 'dialog', 'aria-labelledby': confirmationId, 'aria-describedby': `${confirmationId}-help`, className: 'mg-maintenance-dialog' },
44
+ h('h3', { id: confirmationId }, `确认${actions.find(([action]) => action === pending)[1]}?`),
45
+ h('p', { id: `${confirmationId}-help` }, pending === 'apply' ? '将备份、校验并修改宿主文件以启用多密钥。不会自动换服务或模型。请保存草稿、停止任务,完成后重启原引擎。' : '将校验并恢复宿主原版,关闭多密钥。密钥、配置和历史保留,不自动换服务或模型;旧多密钥选择和配置可能影响后续使用或重启。请保存草稿、停止任务,完成后重启原引擎。'),
46
+ h('p', null, '请先停止所有任务。部分使用情况未能检查不等于安全;检查提醒不会替代您的判断。'),
47
+ pending === 'restore-and-uninstall' && h('p', null, '确认原版恢复成功后才卸载插件;只有确认卸载完成才清理本插件的两个浏览器显示偏好。连接中断不代表成功。'),
48
+ h('div', { className: 'mg-maintenance-actions' }, h('button', { type: 'button', onClick: () => setPending(null) }, '取消'), h('button', { type: 'button', disabled: state.busy || !maintenanceCanAction(snapshot, pending), onClick: confirm }, '确认执行')))),
49
+ h('details', { className: 'mg-maintenance-card' },
50
+ h('summary', null, '检查详情与脱敏诊断'),
51
+ h('p', null, '这里只验证磁盘和安全权限,运行时是否已切换未知。每次确认后会重新检查;状态变化需重新确认。未知文件不会强制覆盖,检查不完整仅提醒;操作仍以各自安全权限为准。'),
52
+ snapshot?.warnings.length > 0 && h('ul', null, ...snapshot.warnings.map((warning, index) => h('li', { key: index }, maintenanceWarningText(warning)))),
53
+ snapshot && h('ul', null, ...snapshot.patches.map((patch, index) => h('li', { key: index }, `宿主文件 ${index + 1}:${patchLabels[patch.state]}${patch.updateAvailable ? '(更新可用)' : ''}`))),
54
+ h('button', { type: 'button', onClick: copy }, '复制脱敏诊断'),
55
+ h('p', { role: 'status' }, copyStatus)));
56
+ }