Agent/ui/sidebar.ts
2026-08-06 22:17:39 +08:00

873 lines
32 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// @ts-nocheck
import * as mcp from '../utils/mcp-client.js';
import * as http from './http-client.js';
import {
loadModelSource, saveModelSource,
loadOllamaConfig, loadAnthropicConfig, loadOmlxConfig,
getCurrentModelInfo, CONFIG,
loadCrossModuleEnabled, saveCrossModuleEnabled,
} from '../utils/config.js';
import { shortName as shortModelName } from '../utils/metrics-client.js';
const STORAGE_KEY = 'ccsparkle_enabled_tools';
const CUSTOM_PRESETS_KEY = 'ccsparkle_custom_presets';
const PRESETS_INIT_KEY = 'ccsparkle_custom_presets_v4_initialized';
const DEFAULT_CUSTOM_PRESETS = [
{
id: 'default_prod',
name: '生产主链路',
tools: [
'metro_load_timetable_full',
'metro_confirm_debug_train_delete',
'metro_setup_rest_rules',
'metro_setup_handover_rules',
'metro_get_station_list',
'metro_get_app_state',
'metro_get_rest_rules',
'metro_get_handover_rules',
'metro_validate_all_tasks',
'metro_query_driver_segments',
'metro_health_check',
],
createdAt: 0,
isDefault: true,
},
];
let SUBSYSTEMS = [
{ id: 'assignment', name: 'Assignment', descPrefix: '司机任务排班', healthUrl: 'http://127.0.0.1:7777/health' },
{ id: 'profile', name: 'Profile', descPrefix: '员工能力画像', healthUrl: 'http://127.0.0.1:7778/health' },
{ id: 'profile+', name: 'Profile+', descPrefix: '乘务派班系统', healthUrl: null },
{ id: 'center', name: 'Center', descPrefix: '任务中心', healthUrl: null },
{ id: 'git', name: 'Git', descPrefix: '代码托管平台', healthUrl: null },
{ id: 'inspection', name: 'Inspection', descPrefix: '安全分析系统', healthUrl: null },
];
async function refreshSubsystemsFromRegistry() {
try {
const r = await fetch(`${CONFIG.ZHONGTAI_BASE}/subsystems`);
if (!r.ok) return;
const data = await r.json();
if (!Array.isArray(data.subsystems) || data.subsystems.length === 0) return;
const remoteById = new Map(data.subsystems.map(s => [s.id, s]));
const localIds = new Set(SUBSYSTEMS.map(s => s.id));
const merged = SUBSYSTEMS.map(local => {
const remote = remoteById.get(local.id);
if (!remote) return local;
return { ...local, ...remote };
});
for (const remote of data.subsystems) {
if (!localIds.has(remote.id)) merged.push(remote);
}
SUBSYSTEMS = merged;
} catch (e) {
}
}
let _allTools = [];
let _enabled = new Set();
let _expandedGroups = new Set();
let _collapsedSubsystems = new Set();
const COLLAPSE_STORAGE_KEY = 'ccsparkle_collapse_state';
function loadCollapseState() {
try {
const s = localStorage.getItem(COLLAPSE_STORAGE_KEY);
if (s) {
const d = JSON.parse(s);
_collapsedSubsystems = new Set(d.subsystems || ['assignment', 'profile', 'profile+', 'center', 'git', 'inspection']);
_expandedGroups = new Set(d.groups || []);
} else {
_collapsedSubsystems = new Set(['assignment', 'profile', 'profile+', 'center', 'git', 'inspection']);
}
} catch (e) {
_collapsedSubsystems = new Set(['assignment', 'profile', 'profile+', 'center', 'git', 'inspection']);
}
}
function saveCollapseState() {
try {
localStorage.setItem(COLLAPSE_STORAGE_KEY, JSON.stringify({
subsystems: [..._collapsedSubsystems],
groups: [..._expandedGroups],
}));
} catch (e) {}
}
loadCollapseState();
let _subsystemStates = {};
let _customPresets = [];
let _sidebarEl = null;
let _subsystemsListEl = null;
let _enabledCountEl = null;
let _totalCountEl = null;
let _sourceInfoEl = null;
let _subsystemSummaryEl = null;
let _onToolsChange = null;
let _monitorTimer = null;
let _registryTimer = null;
function groupOf(name) {
if (name.startsWith('profile_')) {
if (name === 'profile_health_check' || name === 'profile_get_app_state') return '元工具';
if (name.includes('employee')) return '员工';
if (name.includes('exam')) return '考试';
if (name.includes('kilometer')) return '公里数';
if (name.includes('dashboard')) return '仪表盘';
return '其他';
}
if (name === 'metro_list_tools' || name === 'metro_health_check' || name === 'metro_get_app_state') return '元工具';
if (name.includes('setup')) return '高阶工作流';
if (name.includes('timetable') || name.includes('debug_train')) return '加载时刻表';
if (name.includes('big_station')) return '大站点';
if (name.includes('rest')) return '轮乘规则';
if (name.includes('handover')) return '交接规则';
if (name.includes('segment') || name.includes('driver')) return '司机段';
if (name.includes('station')) return '站点';
if (name.includes('validate')) return '校验';
if (name.includes('export') || name.includes('backup') || name.includes('workspace') || name.includes('import')) return 'IO/备份';
if (name.includes('stat') || name.includes('radar') || name.includes('workload') || name.includes('depot')) return '统计';
if (name.includes('apply')) return '应用规则';
return '其他';
}
const TOOL_NAME_LABELS = {
metro_list_tools: '列工具',
metro_health_check: '健康检查',
metro_get_app_state: '应用状态',
metro_load_timetable_full: '加载运营图',
metro_setup_rest_rules: '设置轮乘规则',
metro_setup_handover_rules: '设置交接规则',
metro_search_tools: '搜索工具',
metro_get_tool_detail: '工具详情',
metro_list_timetable_files: '列时刻表文件',
metro_load_timetable: '加载时刻表',
metro_confirm_debug_train_delete: '确认删调试车',
metro_import_timetable_file: '导入时刻表',
metro_delete_timetable: '删除时刻表',
metro_open_timetable_folder: '打开时刻表目录',
metro_add_debug_train: '添加调试车',
metro_remove_debug_train: '移除调试车',
metro_add_big_station: '添加大站点',
metro_edit_big_station: '编辑大站点',
metro_delete_big_station: '删除大站点',
metro_get_big_station_counts: '大站点统计',
metro_get_big_stations: '大站点列表',
metro_add_rest_rule: '添加轮乘规则',
metro_edit_rest_rule: '编辑轮乘规则',
metro_delete_rest_rule: '删除轮乘规则',
metro_save_rest_config_as: '另存轮乘配置',
metro_load_rest_config: '加载轮乘配置',
metro_get_rest_configs: '轮乘配置列表',
metro_get_rest_rules_by_station: '按站点查轮乘',
metro_validate_rest_rule: '预检轮乘冲突',
metro_add_handover_rule: '添加交接规则',
metro_edit_handover_rule: '编辑交接规则',
metro_delete_handover_rule: '删除交接规则',
metro_get_handover_rule: '查询单条交接',
metro_get_handover_rules: '交接规则列表',
metro_apply_rest_rules: '应用轮乘规则',
metro_apply_handover_rules: '应用交接规则',
metro_apply_flexible_rules: '应用灵活规则',
metro_apply_special_shifts: '应用特殊班次',
metro_apply_circle_rules: '应用圈环规则',
metro_auto_sort: '自动排序匹配表',
metro_add_flexible_entry: '添加灵活规则',
metro_remove_flexible_entry: '删除灵活规则',
metro_add_overnight_train: '添加过夜车',
metro_add_circle_history: '加入圈环历史',
metro_delete_circle_history: '删除圈环历史',
metro_merge_segments: '合并分段',
metro_swap_driver_ids: '交换司机编号',
metro_update_driver_shift: '修改司机班制',
metro_update_segment_reason: '修改分段原因',
metro_split_segment_by_time: '按时间拆分段',
metro_split_segment_by_station: '按站点拆分段',
metro_delete_segments: '批量删除段',
metro_assign_driver_to_section: '给未分配段加司机',
metro_get_station_list: '站点列表',
metro_get_station_name_by_id: 'ID 查站名',
metro_get_station_id_by_name: '站名查 ID',
metro_get_station_order: '站点排序索引',
metro_find_path_between_stations: '查站点路径',
metro_validate_all_tasks: '校验所有任务',
metro_query_complete_data: '完整分段数据',
metro_query_unassigned: '未分配段',
metro_query_duplicates: '重复分段',
metro_query_station_workload: '站点 workload',
metro_get_timetable_summary: '运营图速看',
metro_calculate_segment_distances: '计算分段距离',
metro_calculate_driver_work_time: '司机工时',
metro_get_depot_counts: '出回库统计',
metro_analyze_rest_points: '分析休息点',
metro_export_workspace: '导出工作区',
metro_export_csv: '导出 CSV',
metro_list_backups: '列出备份',
metro_save_backup: '保存备份',
metro_load_backup: '加载备份',
metro_delete_backup: '删除备份',
profile_health_check: '画像健康检查',
profile_get_app_state: '画像应用状态',
profile_query_employees: '查员工列表',
profile_get_employee: '查单个员工',
profile_query_online_exams: '查在线考试',
profile_query_practical_exams: '查实操考试',
profile_get_practical_exam_stats: '实操考试统计',
profile_query_kilometers: '查公里数',
profile_get_kilometers_stats: '公里数统计',
profile_get_kilometers_by_month: '按月查公里数',
profile_get_dashboard: '画像仪表盘',
};
const _toolLabels = new Map();
function refreshToolLabels(tools) {
_toolLabels.clear();
for (const t of tools || []) {
if (t?.name && typeof t.label === 'string' && t.label.trim()) {
_toolLabels.set(t.name, t.label.trim());
}
}
}
export function getToolNameLabel(name) {
if (!name) return '';
return _toolLabels.get(name) || TOOL_NAME_LABELS[name] || name;
}
export function getSubsystemDisplayName(id) {
const sys = SUBSYSTEMS.find((s) => s.id === id);
return String(sys?.name || '').trim();
}
export function resolveAppNamesFromToolNames(toolNames) {
const names = [];
const seen = new Set();
for (const tool of toolNames || []) {
const id = mcp.getToolSubsystemId(tool) || subsysIdOfTool(tool);
const label = getSubsystemDisplayName(id);
if (!label || seen.has(label)) continue;
seen.add(label);
names.push(label);
}
return names;
}
function subsysIdOfTool(name) {
if (name.startsWith('metro_')) return 'assignment';
if (name.startsWith('profile_')) return 'profile';
return 'assignment';
}
function getToolLabel(t) {
if (typeof t.label === 'string' && t.label.trim()) {
return t.label.trim();
}
if (_toolLabels.has(t.name)) return _toolLabels.get(t.name);
if (TOOL_NAME_LABELS[t.name]) return TOOL_NAME_LABELS[t.name];
let d = (t.description || '').trim();
if (d.startsWith('【')) {
const idx = d.indexOf('】');
if (idx > 0) d = d.slice(idx + 1).trim();
}
const m = d.match(/^([^。,!!\n,;]+)/);
if (m) d = m[1].trim();
if (d.length > 35) d = d.slice(0, 35) + '...';
return d || t.name;
}
export async function loadTools(mode, force = false) {
if (_allTools.length > 0 && !force) return _allTools;
if (mode === 'http') {
_allTools = await http.listTools();
} else {
_allTools = await mcp.listTools();
}
refreshToolLabels(_allTools);
const stored = loadStored();
_enabled = new Set(stored.filter(n => _allTools.some(t => t.name === n)));
if (_enabled.size === 0) {
const prod = DEFAULT_CUSTOM_PRESETS.find(p => p.id === 'default_prod');
const fallbackNames = (prod?.tools || []).filter(n => _allTools.some(t => t.name === n));
if (fallbackNames.length > 0) {
fallbackNames.forEach(n => _enabled.add(n));
} else if (_allTools.length <= 30) {
_allTools.forEach(t => _enabled.add(t.name));
}
saveStored();
} renderSubsystems();
return _allTools;
}
export function clearTools() {
_allTools = [];
_toolLabels.clear();
_enabled = new Set();
renderSubsystems();
}
export function getEnabledTools() {
return _allTools.filter(t => _enabled.has(t.name));
}
export function applyPreset(preset, subsystId = null) {
const targetTools = subsystId
? _allTools.filter(t => subsysIdOfTool(t.name) === subsystId)
: _allTools;
if (preset === 'all') {
targetTools.forEach(t => _enabled.add(t.name));
} else if (preset === 'none') {
targetTools.forEach(t => _enabled.delete(t.name));
}
saveStored();
renderSubsystems();
}
function toggleTool(name, enabled) {
if (enabled) _enabled.add(name);
else _enabled.delete(name);
saveStored();
renderSubsystems();
}
function loadCustomPresets() {
const initialized = localStorage.getItem(PRESETS_INIT_KEY);
if (!initialized) {
_customPresets = DEFAULT_CUSTOM_PRESETS.map(p => ({ ...p, tools: [...p.tools] }));
saveCustomPresets();
localStorage.setItem(PRESETS_INIT_KEY, '1');
return;
}
try {
const s = localStorage.getItem(CUSTOM_PRESETS_KEY);
_customPresets = s ? JSON.parse(s) : [];
} catch (e) { _customPresets = []; }
}
export function restoreDefaultPresets() {
const existingIds = new Set(_customPresets.map(p => p.id));
let added = 0;
for (const def of DEFAULT_CUSTOM_PRESETS) {
if (!existingIds.has(def.id)) {
_customPresets.push({ ...def, tools: [...def.tools] });
added++;
}
}
saveCustomPresets();
renderCustomPresets();
return added;
}
function saveCustomPresets() {
try {
localStorage.setItem(CUSTOM_PRESETS_KEY, JSON.stringify(_customPresets));
} catch (e) {}
}
export function saveCurrentAsPreset() {
const enabledTools = getEnabledTools();
if (enabledTools.length === 0) {
alert('请先勾选工具,再保存为预设');
return;
}
let name = null;
try {
name = prompt(`保存当前 ${enabledTools.length} 个工具为预设,请输入名称:`);
} catch (e) {
alert('当前环境( Electron 打包版)不支持 prompt,请在「配」中保存预设');
return;
}
if (!name || !name.trim()) return;
_customPresets.push({
id: `preset_${Date.now()}`,
name: name.trim(),
tools: enabledTools.map(t => t.name),
createdAt: Date.now(),
});
saveCustomPresets();
renderCustomPresets();
}
export function deleteCustomPreset(id) {
_customPresets = _customPresets.filter(p => p.id !== id);
saveCustomPresets();
renderCustomPresets();
}
export function applyCustomPreset(id) {
const p = _customPresets.find(x => x.id === id);
if (!p) return;
_enabled.clear();
p.tools.forEach(name => {
if (_allTools.some(t => t.name === name)) _enabled.add(name);
});
saveStored();
renderSubsystems();
}
function renderCustomPresets() {
const container = _sidebarEl?.querySelector('#custom-presets');
if (!container) return;
if (_customPresets.length === 0) {
container.innerHTML = '<div class="preset-empty">勾选工具后点 + 保存为预设</div>';
return;
}
container.innerHTML = '';
for (const p of _customPresets) {
const item = document.createElement('div');
item.className = 'custom-preset-item';
item.innerHTML = `
<button class="custom-preset-apply" type="button" title="应用此预设">
<span class="preset-name">${escapeHtml(p.name)}</span>
<span class="preset-count">${p.tools.length}</span>
</button>
<button class="custom-preset-delete" type="button" title="删除">×</button>
`;
item.querySelector('.custom-preset-apply').addEventListener('click', () => applyCustomPreset(p.id));
item.querySelector('.custom-preset-delete').addEventListener('click', (e) => {
e.stopPropagation();
if (confirm(`删除预设 "${p.name}"?`)) deleteCustomPreset(p.id);
});
container.appendChild(item);
}
}
export function init(sidebarEl, opts = {}) {
_sidebarEl = sidebarEl;
_subsystemsListEl = sidebarEl.querySelector('#subsystems-list');
_enabledCountEl = sidebarEl.querySelector('[data-role="enabled-count"]');
_totalCountEl = sidebarEl.querySelector('[data-role="total-count"]');
_sourceInfoEl = sidebarEl.querySelector('#source-info');
_subsystemSummaryEl = sidebarEl.querySelector('#subsystem-summary');
_onToolsChange = opts.onToolsChange;
const onSourceChange = opts.onSourceChange;
sidebarEl.querySelector('#sidebar-toggle').addEventListener('click', () => {
sidebarEl.classList.toggle('collapsed');
});
sidebarEl.querySelector('#subsystems-title').addEventListener('click', (e) => {
if (e.target.closest('.collapse-btn') || e.target.classList.contains('sidebar-collapsible-title') || e.currentTarget === e.target) {
sidebarEl.querySelector('.subsystems-section').classList.toggle('section-collapsed');
const btn = sidebarEl.querySelector('#subsystems-title .collapse-btn');
if (btn) btn.textContent = sidebarEl.querySelector('.subsystems-section').classList.contains('section-collapsed') ? '▶' : '▼';
}
});
sidebarEl.querySelectorAll('[data-preset]').forEach(btn => {
btn.addEventListener('click', () => applyPreset(btn.dataset.preset));
});
const btnSave = sidebarEl.querySelector('#btn-save-preset');
btnSave?.addEventListener('click', () => saveCurrentAsPreset());
const btnRestore = sidebarEl.querySelector('#btn-restore-presets');
btnRestore?.addEventListener('click', () => {
const added = restoreDefaultPresets();
alert(added > 0 ? `已恢复 ${added} 个默认预设` : '默认预设已全部存在');
});
const presetsTitle = sidebarEl.querySelector('#presets-title');
presetsTitle?.addEventListener('click', (e) => {
if (e.target.closest('.config-btn')) return;
const section = e.currentTarget.closest('.presets-section');
section.classList.toggle('section-collapsed');
const btn = e.currentTarget.querySelector('.collapse-btn');
if (btn) btn.textContent = section.classList.contains('section-collapsed') ? '▶' : '▼';
});
let currentSource = loadModelSource();
sidebarEl.querySelectorAll('[data-source]').forEach(btn => {
btn.classList.toggle('active', btn.dataset.source === currentSource);
btn.addEventListener('click', async () => {
const newSource = btn.dataset.source;
if (newSource === currentSource) return;
if (newSource === 'anthropic') {
const cfg = loadAnthropicConfig();
if (!cfg.authToken) {
alert('请先点配置按钮(配)配置云端 API 的 Auth Token');
return;
}
}
if (newSource === 'omlx') {
const cfg = loadOmlxConfig();
if (!cfg.apiKey) {
alert('请先点配置按钮(配)配置 oMLX 的 API Key');
return;
}
}
const labels = { ollama: 'Ollama', omlx: 'oMLX', sparkle: 'Sparkle', anthropic: '云端 API' };
const fromLabel = labels[currentSource] || currentSource;
const toLabel = labels[newSource] || newSource;
const isLocalSwitch = (currentSource !== 'anthropic' || newSource !== 'anthropic');
const msg = `确认从「${fromLabel}」切换到「${toLabel}」?` +
(isLocalSwitch ? '\n\n切换会卸载当前模型 + 加载新模型(约 5-15 秒)。\n频繁切换会消耗内存和加载时间。' : '') +
(newSource === 'sparkle' ? '\nSparkle 引擎加载约需 90 秒。' : '');
if (!confirm(msg)) return;
saveModelSource(newSource);
currentSource = newSource;
sidebarEl.querySelectorAll('[data-source]').forEach(b => {
b.classList.toggle('active', b.dataset.source === newSource);
});
updateSourceInfo();
if (onSourceChange) onSourceChange(newSource);
});
});
loadCustomPresets();
renderCustomPresets();
updateSourceInfo();
renderSubsystems();
}
function updateSourceInfo() {
if (!_sourceInfoEl) return;
const info = getCurrentModelInfo();
const model = shortModelName(info.model);
const tierLabel = ({ fast: '快速档', standard: '平衡档', pro: '增强档' })[info.tier] || '';
if (info.source === 'anthropic') {
_sourceInfoEl.textContent = `${model} · ${tierLabel}`;
_sourceInfoEl.className = 'source-info cloud';
} else if (info.source === 'omlx') {
_sourceInfoEl.textContent = model;
_sourceInfoEl.className = 'source-info local';
} else if (info.source === 'sparkle') {
_sourceInfoEl.textContent = model;
_sourceInfoEl.className = 'source-info local';
} else {
_sourceInfoEl.textContent = model;
_sourceInfoEl.className = 'source-info local';
}
}
export function refreshSourceDisplay() {
if (!_sidebarEl) return;
const currentSource = loadModelSource();
_sidebarEl.querySelectorAll('[data-source]').forEach(btn => {
btn.classList.toggle('active', btn.dataset.source === currentSource);
});
updateSourceInfo();
}
export function startSubsystemMonitor(onSubsystemsReady) {
if (_monitorTimer) return;
refreshSubsystemsFromRegistry().finally(() => {
checkSubsystems(onSubsystemsReady);
_monitorTimer = setInterval(() => checkSubsystems(onSubsystemsReady), 5000);
_registryTimer = setInterval(refreshSubsystemsFromRegistry, 30000);
});
}
export function stopSubsystemMonitor() {
if (_monitorTimer) {
clearInterval(_monitorTimer);
_monitorTimer = null;
}
if (_registryTimer) {
clearInterval(_registryTimer);
_registryTimer = null;
}
}
async function checkSubsystems(onReady) {
let connectedCount = 0;
const wasAnyConnected = Object.values(_subsystemStates).some(s => s === 'active');
const prevStates = { ..._subsystemStates };
for (const sys of SUBSYSTEMS) {
if (!sys.healthUrl) {
_subsystemStates[sys.id] = 'disabled';
continue;
}
try {
const r = await fetch(sys.healthUrl, { signal: AbortSignal.timeout(2000) });
if (r.ok) {
const data = await r.json();
let healthy = data.ok === true;
if (sys.id === 'assignment') {
if (data.service === 'profile-mcp-http') healthy = false;
else if (data.service === 'assignment-mcp-http') healthy = true;
else if (!data.service && data.result && typeof data.result.appStarted !== 'undefined') healthy = true;
else healthy = false;
}
if (healthy) {
_subsystemStates[sys.id] = 'active';
connectedCount++;
} else {
_subsystemStates[sys.id] = 'error';
}
} else {
_subsystemStates[sys.id] = 'error';
}
} catch (e) {
_subsystemStates[sys.id] = 'offline';
}
}
if (_subsystemSummaryEl) {
_subsystemSummaryEl.textContent = `${connectedCount}/${SUBSYSTEMS.length}`;
}
const nowAnyConnected = Object.values(_subsystemStates).some(s => s === 'active');
const anyJustCameOnline = SUBSYSTEMS.some(sys =>
prevStates[sys.id] !== 'active' && _subsystemStates[sys.id] === 'active'
);
if (anyJustCameOnline && onReady) {
onReady();
}
if (wasAnyConnected && !nowAnyConnected && _allTools.length > 0) {
clearTools();
}
renderSubsystems();
}
function renderSubsystems() {
if (!_subsystemsListEl) return;
const enabledCount = _enabled.size;
const totalCount = _allTools.length;
if (_enabledCountEl) _enabledCountEl.textContent = enabledCount;
if (_totalCountEl) _totalCountEl.textContent = totalCount;
_subsystemsListEl.innerHTML = '';
for (const sys of SUBSYSTEMS) {
const state = _subsystemStates[sys.id] || 'offline';
const collapsed = _collapsedSubsystems.has(sys.id);
const isActive = state === 'active';
const block = document.createElement('div');
block.className = `subsystem-block ${state} ${collapsed ? 'collapsed' : ''}`;
block.dataset.subsystem = sys.id;
const statusText = getStatusText(sys, state);
block.innerHTML = `
<div class="subsystem-block-header">
<button class="subsystem-block-toggle" type="button">${collapsed ? '▶' : '▼'}</button>
<span class="subsystem-block-name">${sys.name}</span>
<span class="subsystem-dot">${getDotEmoji(state)}</span>
<span class="subsystem-status">${escapeHtml(statusText)}</span>
</div>
<div class="subsystem-block-body"></div>
`;
const body = block.querySelector('.subsystem-block-body');
if (isActive) {
const sysTools = _allTools.filter(t => subsysIdOfTool(t.name) === sys.id);
const sysEnabled = sysTools.filter(t => _enabled.has(t.name)).length;
const crossDiagBtn = sys.id === 'profile'
? `<button class="mini-btn mini-btn-toggle ${loadCrossModuleEnabled() ? 'active' : ''}" data-action="cross-module-toggle" type="button" title="开启后,综合评价模块升级为跨模块深度诊断(1200-1500 字,跨模块关联+根因+优先级+风险)">深度</button>`
: '';
body.innerHTML = `
<div class="subsystem-actions">
<button class="mini-btn" data-preset="all" data-subsyst="${sys.id}" type="button">全选</button>
<button class="mini-btn" data-preset="none" data-subsyst="${sys.id}" type="button">全清</button>
${crossDiagBtn}
<span class="tools-count">${sysEnabled}/${sysTools.length}</span>
</div>
<div class="tool-groups"></div>
`;
body.querySelectorAll('[data-preset]').forEach(btn => {
btn.addEventListener('click', () => applyPreset(btn.dataset.preset, btn.dataset.subsyst));
});
const toggleBtn = body.querySelector('[data-action="cross-module-toggle"]');
if (toggleBtn) {
toggleBtn.addEventListener('click', () => {
const next = !loadCrossModuleEnabled();
saveCrossModuleEnabled(next);
toggleBtn.classList.toggle('active', next);
toggleBtn.title = next
? '已开启:综合评价升级为跨模块深度诊断(点击关闭)'
: '开启后,综合评价模块升级为跨模块深度诊断(1200-1500 字,跨模块关联+根因+优先级+风险)';
});
}
renderToolGroups(body.querySelector('.tool-groups'), sys.id);
} else if (state === 'offline') {
body.innerHTML = '<div class="subsystem-empty">等待接入...</div>';
} else if (state === 'disabled') {
body.innerHTML = `<div class="subsystem-empty">${sys.descPrefix} · 未接入</div>`;
} else if (state === 'error') {
body.innerHTML = '<div class="subsystem-empty subsystem-error">健康检查失败</div>';
}
block.querySelector('.subsystem-block-toggle').addEventListener('click', (e) => {
e.stopPropagation();
if (_collapsedSubsystems.has(sys.id)) {
_collapsedSubsystems.delete(sys.id);
} else {
_collapsedSubsystems.add(sys.id);
}
saveCollapseState();
renderSubsystems();
});
_subsystemsListEl.appendChild(block);
}
if (_onToolsChange) _onToolsChange(getEnabledTools());
}
function getStatusText(sys, state) {
if (state === 'active') {
const sysTools = _allTools.filter(t => subsysIdOfTool(t.name) === sys.id);
if (sysTools.length > 0) return `${sysTools.length} 工具`;
return '已连接';
}
if (state === 'error') return '错误';
if (state === 'disabled') return '未接入';
if (state === 'offline') return '未启动';
return '';
}
function getDotEmoji(state) {
return `<span class="css-dot dot-${state}"></span>`;
}
function renderToolGroups(container, subsystemId) {
const tools = _allTools.filter(t => subsysIdOfTool(t.name) === subsystemId);
if (tools.length === 0) {
container.innerHTML = '<div class="tool-loading">该子系统暂无工具</div>';
return;
}
const groups = new Map();
for (const t of tools) {
const g = groupOf(t.name);
if (!groups.has(g)) groups.set(g, []);
groups.get(g).push(t);
}
const orderedGroups = [...groups.entries()].sort((a, b) => a[0].localeCompare(b[0], 'zh'));
container.innerHTML = '';
for (const [gname, tools] of orderedGroups) {
tools.sort((a, b) => a.name.localeCompare(b.name));
const enabledCount = tools.filter(t => _enabled.has(t.name)).length;
const allEnabled = enabledCount === tools.length;
const expanded = _expandedGroups.has(gname);
const groupEl = document.createElement('div');
groupEl.className = 'tool-group' + (expanded ? ' expanded' : '');
groupEl.innerHTML = `
<div class="tool-group-header">
<span class="tool-group-chevron">▶</span>
<span class="tool-group-name">${escapeHtml(gname)}</span>
<span class="tool-group-count ${allEnabled ? 'all-enabled' : ''}">${enabledCount}/${tools.length}</span>
</div>
<div class="tool-group-body"></div>
`;
const bodyEl = groupEl.querySelector('.tool-group-body');
for (const t of tools) {
const checked = _enabled.has(t.name);
const label = getToolLabel(t);
const itemEl = document.createElement('label');
itemEl.className = 'tool-item';
itemEl.title = t.name;
itemEl.innerHTML = `
<input type="checkbox" ${checked ? 'checked' : ''}/>
<span class="tool-item-label">${escapeHtml(label)}</span>
`;
const cb = itemEl.querySelector('input');
cb.addEventListener('change', () => {
toggleTool(t.name, cb.checked);
});
bodyEl.appendChild(itemEl);
}
groupEl.querySelector('.tool-group-header').addEventListener('click', () => {
if (_expandedGroups.has(gname)) {
_expandedGroups.delete(gname);
} else {
_expandedGroups.add(gname);
}
saveCollapseState();
renderSubsystems();
});
container.appendChild(groupEl);
}
}
function loadStored() {
try {
const s = localStorage.getItem(STORAGE_KEY);
return s ? JSON.parse(s) : [];
} catch (e) { return []; }
}
function saveStored() {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify([..._enabled]));
} catch (e) {}
}
function escapeHtml(s) {
if (s == null) return '';
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}