873 lines
32 KiB
TypeScript
873 lines
32 KiB
TypeScript
// @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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||
}
|