Files
rays a951b03b9a fix(compat): align host contracts and node focus lifecycle
Refresh exact frontend lanes and relocated host contracts while preserving
Desktop settings behavior. Recognize first-party Switch nodes and optional
branches without suppressing required-input or broken-link diagnostics.

Revalidate canvas, workflow and target identity across focus navigation;
cancel stale requests on newer actions and sidebar teardown. Keep legacy
positioning and nested graph traversal covered by action-level regressions.

Validation: 75 source contracts, 84 frontend unit tests, Windows full gate
with 871 backend tests and 125 subtests, and 149 mocked browser E2E tests.
Seven existing backend skips remain. No live-host certification is claimed.
2026-09-22 15:18:41 +08:00

686 lines
35 KiB
JavaScript

/**
* ComfyUI-Doctor Extension Entry Point
* Registers settings with ComfyUI's settings panel and initializes the Doctor UI.
*/
import { app } from "../../../scripts/app.js";
import { api } from "../../../scripts/api.js";
import { DoctorUI } from "./doctor_ui.js";
import { DoctorAPI } from "./doctor_api.js";
import { isPreactEnabled, loadPreact, getLoadError } from "./preact-loader.js";
import { tabRegistry, TabManager } from "./doctor_tabs.js";
import {
enforceDoctorSidebarMinWidth,
} from "./doctor_sidebar_layout.js";
import * as ChatTab from "./tabs/chat_tab.js";
import * as StatsTab from "./tabs/stats_tab.js";
import * as SettingsTab from "./tabs/settings_tab.js";
// R5: Error Boundaries
import { installGlobalErrorHandlers } from "./global_error_handler.js";
import { getRuntimeApiKey, setRuntimeApiKey, migrateLegacyApiKeyFromSettings } from "./llm_key_store.js";
import {
DOCTOR_DEFAULTS,
DOCTOR_EXTENSION_SETTINGS,
destroyDoctorSidebarMount,
ensureDoctorSettingsRegistered,
getDoctorRuntimeSettings,
getDoctorSetting,
isDoctorEnabled,
isDoctorErrorBoundariesEnabled,
registerDoctorSidebarTab,
} from "./comfyui_frontend_compat.js";
// ═══════════════════════════════════════════════════════════════════════════
// A7: PREACT ISLANDS FEATURE FLAG
// ═══════════════════════════════════════════════════════════════════════════
// Controls whether Preact-based UI components (islands) are loaded.
// When disabled, the extension uses pure Vanilla JS fallback UI.
//
// How to disable: localStorage.setItem('doctor_preact_disabled', 'true')
// Last Modified: 2026-01-05 (A7 Phase 2)
// ═══════════════════════════════════════════════════════════════════════════
const PREACT_ISLANDS_ENABLED = localStorage.getItem('doctor_preact_disabled') !== 'true' && isPreactEnabled();
// Provider default URLs (will be fetched from backend on init)
let PROVIDER_DEFAULTS = {
"openai": "https://api.openai.com/v1",
"anthropic": "https://api.anthropic.com",
"deepseek": "https://api.deepseek.com/v1",
"groq": "https://api.groq.com/openai/v1",
"gemini": "https://generativelanguage.googleapis.com/v1beta/openai",
"xai": "https://api.x.ai/v1",
"openrouter": "https://openrouter.ai/api/v1",
"ollama": "http://127.0.0.1:11434",
"lmstudio": "http://localhost:1234/v1",
"custom": ""
};
// ═══════════════════════════════════════════════════════════════════════════
// R5: ERROR BOUNDARIES FEATURE FLAG
// ═══════════════════════════════════════════════════════════════════════════
// Fetch provider defaults from backend (supports env vars)
async function loadProviderDefaults() {
try {
const response = await fetch('/doctor/provider_defaults');
if (response.ok) {
const defaults = await response.json();
PROVIDER_DEFAULTS = { ...PROVIDER_DEFAULTS, ...defaults };
console.log('[ComfyUI-Doctor] Provider defaults loaded:', PROVIDER_DEFAULTS);
}
} catch (error) {
console.warn('[ComfyUI-Doctor] Failed to load provider defaults, using fallback:', error);
}
}
// ComfyUI Settings → About panel badges (ComfyUI_frontend AboutPanel.vue)
const DOCTOR_REPO_URL = "https://github.com/rookiestar28/ComfyUI-Doctor";
const DOCTOR_ABOUT_BADGES = [
{
label: "ComfyUI Doctor",
url: DOCTOR_REPO_URL,
icon: "pi pi-github",
},
];
app.registerExtension({
name: "ComfyUI-Doctor",
aboutPageBadges: DOCTOR_ABOUT_BADGES,
settings: DOCTOR_EXTENSION_SETTINGS,
async setup() {
console.log("[ComfyUI-Doctor] 🟢 Frontend Extension Initialized");
// Load provider defaults from backend (supports env vars)
await loadProviderDefaults();
ensureDoctorSettingsRegistered(app);
if (!isDoctorEnabled()) {
console.log("[ComfyUI-Doctor] Extension disabled via settings");
return;
}
const migratedLegacyApiKey = migrateLegacyApiKeyFromSettings();
const {
language,
pollInterval,
autoOpenOnError,
enableNotifications,
} = getDoctorRuntimeSettings();
// Create Doctor UI instance with settings
const doctorUI = new DoctorUI({
language,
pollInterval,
autoOpenOnError,
enableNotifications,
api, // Pass ComfyUI API for event subscription
});
// Store reference on app for settings callbacks
app.Doctor = doctorUI;
app.Doctor.providerDefaults = PROVIDER_DEFAULTS;
app.Doctor.getRuntimeApiKey = getRuntimeApiKey;
app.Doctor.setRuntimeApiKey = setRuntimeApiKey;
app.Doctor.legacyApiKeyMigrated = migratedLegacyApiKey;
// NOTE (UI tooltip): Wait for UI text before sidebar registration to avoid "[Missing]" labels.
// Do not remove this await unless registerSidebarTab no longer uses getUIText().
if (doctorUI.uiTextReady) {
await doctorUI.uiTextReady;
}
// Populate Doctor version for ComfyUI Settings → About badge (best effort).
// Note: AboutPanel badges are computed and may not be reactive to later updates.
// Updating immediately after uiTextReady should be early enough for typical usage.
try {
const meta = doctorUI?.meta;
if (meta?.repository) DOCTOR_ABOUT_BADGES[0].url = meta.repository;
if (meta?.version && meta.version !== "unknown") {
DOCTOR_ABOUT_BADGES[0].label = `ComfyUI Doctor v${meta.version}`;
}
} catch (e) {
// no-op: keep fallback badge label/url
}
// ========================================
// Register Sidebar Tab
// ========================================
const sidebarRegistrationSource = registerDoctorSidebarTab(app, {
id: "comfyui-doctor",
icon: "pi pi-heart-fill", // PrimeVue icon
title: doctorUI.getUIText('sidebar_doctor_title'),
tooltip: doctorUI.getUIText('sidebar_doctor_tooltip'),
type: "custom",
destroy: () => destroyDoctorSidebarMount(doctorUI),
render: (container) => {
destroyDoctorSidebarMount(doctorUI);
let restoreLayout = () => {};
let manager = null;
let doctorRoot = null;
let ownershipObserver = null;
let sidebarDestroyed = false;
// CRITICAL: install this cleanup before the first registry, mount, or layout
// mutation. Moving it below geometry/render setup makes early exceptions leak
// Doctor markers or PrimeVue widths; the outer catch must invoke it and rethrow.
const cleanup = ({ hostTakeover = false } = {}) => {
if (sidebarDestroyed) return;
sidebarDestroyed = true;
let cleanupError = null;
const runCleanupStep = (step) => {
try {
step();
} catch (error) {
cleanupError ??= error;
}
};
runCleanupStep(() => ownershipObserver?.disconnect());
runCleanupStep(() => doctorUI.cancelPendingNodeFocus());
runCleanupStep(() => manager?.destroy());
runCleanupStep(() => tabRegistry.clear());
if (!hostTakeover) {
runCleanupStep(() => container.replaceChildren());
}
runCleanupStep(() => restoreLayout({
preserveExternalChanges: hostTakeover,
}));
doctorRoot = null;
doctorUI.sidebarTabContainer = null;
doctorUI.tabManager = null;
if (doctorUI.sidebarCleanup === cleanup) {
doctorUI.sidebarCleanup = null;
}
if (cleanupError) throw cleanupError;
};
doctorUI.sidebarCleanup = cleanup;
try {
tabRegistry.clear();
container.replaceChildren();
doctorRoot = document.createElement('div');
doctorRoot.className = 'doctor-sidebar-content';
doctorRoot.style.cssText = 'display: flex; flex-direction: column; height: 100%; background: var(--bg-color, #1a1a2e);';
container.appendChild(doctorRoot);
restoreLayout = enforceDoctorSidebarMinWidth(container);
// CRITICAL: current ExtensionSlot reuses one custom mount when its extension
// prop changes and only calls destroy on component unmount. Keep Doctor in an
// owned root and observe its removal; clearing the shared mount during takeover
// deletes the incoming extension, while unconditional restoration overwrites it.
if (typeof MutationObserver === 'function') {
ownershipObserver = new MutationObserver(() => {
if (
!sidebarDestroyed
&& doctorRoot
&& !container.contains(doctorRoot)
) {
cleanup({ hostTakeover: true });
}
});
ownershipObserver.observe(container, { childList: true });
}
// Add styles for the sidebar content if not already added
if (!document.getElementById('doctor-sidebar-styles')) {
const style = document.createElement('style');
style.id = 'doctor-sidebar-styles';
style.textContent = `
.doctor-sidebar-content {
box-sizing: border-box;
padding: 15px;
height: 100%;
min-height: 0;
min-width: 560px;
overflow-y: auto;
background: var(--bg-color, #1a1a2e);
color: var(--fg-color, #eee);
font-family: system-ui, -apple-system, sans-serif;
display: flex;
flex-direction: column;
}
.sidebar-content-container :has(> .doctor-sidebar-content) {
height: 100%;
min-height: 0;
}
/* CRITICAL: keep this selector and 560px aligned with doctor_sidebar_layout.js.
The host splitter and content wrapper must share the active Doctor minimum;
an inner-only rule overflows into a narrower, clipped SplitterPanel. */
.side-bar-panel:has(.doctor-sidebar-content),
.p-splitterpanel:has(.doctor-sidebar-content),
.sidebar-content-container:has(.doctor-sidebar-content) {
min-width: 560px !important;
}
.doctor-sidebar-content h3 {
margin: 0 0 15px 0;
font-size: 18px;
display: flex;
align-items: center;
gap: 8px;
}
.doctor-sidebar-content .status-indicator {
width: 10px;
height: 10px;
border-radius: 50%;
background: #4caf50;
display: inline-block;
}
.doctor-sidebar-content .status-indicator.error {
background: #ff4444;
animation: pulse 1s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.doctor-sidebar-content .error-card {
background: rgba(255, 68, 68, 0.1);
border: 1px solid #ff4444;
border-radius: 8px;
padding: 12px;
margin-bottom: 12px;
}
.doctor-sidebar-content .error-card .error-type {
font-weight: bold;
color: #ff6b6b;
margin-bottom: 5px;
}
.doctor-sidebar-content .error-card .error-message {
font-size: 13px;
color: #ddd;
word-break: break-word;
}
.doctor-sidebar-content .error-card .error-time {
font-size: 11px;
color: #888;
margin-top: 8px;
}
.doctor-sidebar-content .node-context {
background: rgba(0,0,0,0.2);
border-radius: 4px;
padding: 8px;
margin-top: 8px;
font-size: 12px;
}
.doctor-sidebar-content .node-context span {
display: block;
margin-bottom: 3px;
}
.doctor-sidebar-content .action-btn {
background: #333;
color: #eee;
border: 1px solid #555;
padding: 8px 12px;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
margin-top: 8px;
width: 100%;
text-align: center;
transition: background 0.2s;
}
.doctor-sidebar-content .action-btn:hover {
background: #444;
}
.doctor-sidebar-content .action-btn.primary {
background: #2563eb;
border-color: #3b82f6;
}
.doctor-sidebar-content .action-btn.primary:hover {
background: #1d4ed8;
}
.doctor-sidebar-content .no-errors {
text-align: center;
padding: 40px 20px;
color: #888;
}
.doctor-sidebar-content .no-errors .icon {
font-size: 48px;
margin-bottom: 10px;
}
.doctor-sidebar-content .ai-response {
background: rgba(37, 99, 235, 0.1);
border: 1px solid #2563eb;
border-radius: 8px;
padding: 12px;
margin-top: 12px;
font-size: 13px;
line-height: 1.5;
}
.doctor-sidebar-content .ai-response h4 {
margin: 0 0 8px 0;
color: #60a5fa;
}
/* F4: Statistics Dashboard Styles */
.doctor-sidebar-content .stats-panel {
background: rgba(0,0,0,0.2);
border-radius: 8px;
padding: 12px;
margin: 10px 0;
max-height: 200px;
overflow-y: auto;
flex-shrink: 0;
}
.doctor-sidebar-content .stats-panel summary {
cursor: pointer;
font-weight: bold;
font-size: 13px;
color: #ddd;
display: flex;
align-items: center;
gap: 6px;
}
.doctor-sidebar-content .stats-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin-top: 10px;
}
.doctor-sidebar-content .stat-card {
background: rgba(255,255,255,0.05);
border: 1px solid #444;
border-radius: 6px;
padding: 10px;
text-align: center;
transition: transform 0.2s, box-shadow 0.2s;
}
.doctor-sidebar-content .stat-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0,0,0,0.3);
}
.doctor-sidebar-content .stat-card .stat-value {
font-size: 24px;
font-weight: bold;
color: #f44;
}
.doctor-sidebar-content .stat-card .stat-label {
font-size: 11px;
color: #888;
margin-top: 4px;
}
.doctor-sidebar-content .top-patterns {
margin-top: 10px;
}
.doctor-sidebar-content .top-patterns h5 {
margin: 0 0 8px 0;
font-size: 12px;
color: #aaa;
}
.doctor-sidebar-content .pattern-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 6px 8px;
background: rgba(255,68,68,0.1);
border-radius: 4px;
margin-bottom: 4px;
font-size: 12px;
}
.doctor-sidebar-content .pattern-item .pattern-name {
color: #ff6b6b;
flex: 1;
}
.doctor-sidebar-content .pattern-item .pattern-count {
background: #f44;
color: white;
padding: 2px 6px;
border-radius: 10px;
font-size: 10px;
font-weight: bold;
}
.doctor-sidebar-content .category-bar {
margin-bottom: 6px;
}
.doctor-sidebar-content .category-bar .bar-label {
font-size: 11px;
color: #aaa;
display: flex;
justify-content: space-between;
margin-bottom: 2px;
}
.doctor-sidebar-content .category-bar .bar-track {
height: 6px;
background: #333;
border-radius: 3px;
overflow: hidden;
}
.doctor-sidebar-content .category-bar .bar-fill {
height: 100%;
border-radius: 3px;
transition: width 0.3s ease;
}
.doctor-sidebar-content .stats-empty {
text-align: center;
padding: 20px;
color: #666;
font-size: 12px;
}
`;
document.head.appendChild(style);
// F13: Tab System Styles
const tabStyle = document.createElement('style');
tabStyle.textContent = `
.doctor-tab-bar {
display: flex;
border-bottom: 1px solid var(--border-color, #444);
background: rgba(0,0,0,0.2);
}
.doctor-tab-button {
flex: 1;
text-align: center;
padding: 10px;
cursor: pointer;
color: #888;
border-bottom: 2px solid transparent;
transition: all 0.2s;
}
.doctor-tab-button:hover {
background: rgba(255,255,255,0.05);
color: #ddd;
}
.doctor-tab-button.active {
color: #4caf50;
border-bottom-color: #4caf50;
background: rgba(76, 175, 80, 0.1);
}
.doctor-tab-pane {
height: 100%;
display: none;
overflow: hidden;
/* Ensure flex children can fill height */
box-sizing: border-box;
}
.doctor-tab-pane[style*="display: block"] {
display: flex !important;
flex-direction: column;
}
`;
document.head.appendChild(tabStyle);
}
// ═══════════════════════════════════════════════════════════════
// LEFT SIDEBAR PANEL - Doctor Sidebar (Main UI)
// ═══════════════════════════════════════════════════════════════
// LOCATION: Left side of ComfyUI (accessed via 🏥 Doctor icon)
// DO NOT CONFUSE WITH: Right error panel (doctor_ui.js)
//
// STRUCTURE:
// 1. Header (🏥 Doctor + ⚙️ Settings toggle)
// 2. Settings Panel (collapsible)
// 3. Error Context Card (shows suggestion when error occurs)
// 4. Messages Area (AI chat interface)
// 5. Input Area (sticky at bottom)
//
// IMPORTANT NOTES:
// - Error Context Card displays CONCISE SUGGESTION ONLY
// - Suggestion extraction logic in doctor_ui.js:updateSidebarTab()
// - Full error details sent to LLM, only actionable advice shown here
// ═══════════════════════════════════════════════════════════════
// Create sidebar content - TAB BASED STRUCTURE (F13)
// 1. Header
const header = document.createElement('div');
header.style.cssText = 'padding: 12px 15px; border-bottom: 1px solid var(--border-color, #444); display: flex; align-items: center; justify-content: space-between; flex-shrink: 0;';
header.innerHTML = `
<div style="display: flex; align-items: center; gap: 8px;">
<span class="status-indicator" id="doctor-tab-status" style="width: 10px; height: 10px; border-radius: 50%; background: #4caf50; display: inline-block;"></span>
<span style="font-size: 16px; font-weight: bold; color: var(--fg-color, #eee);">🏥 ${app.Doctor.getUIText('sidebar_doctor_title')}</span>
</div>
<div style="display: flex; align-items: center; gap: 10px;">
<span id="doctor-sidebar-version" style="font-size: 13px; color: #c8ccd4;">v?</span>
<a id="doctor-sidebar-github-link" href="https://github.com/rookiestar28/ComfyUI-Doctor" target="_blank" rel="noopener noreferrer" style="display: inline-block; padding: 4px 10px; border: 1px solid var(--border-color, #4a4f5a); border-radius: 999px; font-size: 12px; font-weight: 600; text-decoration: none; color: var(--fg-color, #f1f5f9); background: rgba(255,255,255,0.03);">View on GitHub</a>
</div>
`;
doctorRoot.appendChild(header);
const applySidebarMeta = (meta) => {
const versionEl = header.querySelector('#doctor-sidebar-version');
const githubEl = header.querySelector('#doctor-sidebar-github-link');
if (!versionEl || !githubEl) return;
const version = (typeof meta?.version === 'string' && meta.version.trim() && meta.version.trim() !== 'unknown')
? meta.version.trim().replace(/^v/i, '')
: null;
const repository = (typeof meta?.repository === 'string' && meta.repository.trim())
? meta.repository.trim()
: 'https://github.com/rookiestar28/ComfyUI-Doctor';
versionEl.textContent = version ? `v${version}` : 'v?';
githubEl.href = repository;
};
const loadSidebarMeta = async () => {
applySidebarMeta(app.Doctor?.meta);
if (app.Doctor?.meta?.version && app.Doctor.meta.version !== 'unknown') return;
try {
const currentLang = getDoctorSetting('Doctor.General.Language', DOCTOR_DEFAULTS.LANGUAGE);
const endpoint = `/doctor/ui_text?lang=${encodeURIComponent(currentLang)}`;
const response = (typeof window !== 'undefined' && window.api && typeof window.api.fetchApi === 'function')
? await window.api.fetchApi(endpoint)
: await fetch(endpoint);
if (!response.ok) return;
const data = await response.json();
if (!data?.meta) return;
app.Doctor.meta = data.meta;
applySidebarMeta(data.meta);
} catch (_) {
// Keep best-effort fallback in header.
}
};
void loadSidebarMeta();
// 2. Tab Bar
const tabBar = document.createElement('div');
tabBar.id = 'doctor-tab-bar';
tabBar.className = 'doctor-tab-bar';
doctorRoot.appendChild(tabBar);
// 3. Tab Content
const content = document.createElement('div');
content.id = 'doctor-tab-content';
// ⚠️ CRITICAL: min-height: 0 required for nested flex scrolling
content.style.cssText = 'flex: 1 1 0; overflow: hidden; position: relative; min-height: 0;';
doctorRoot.appendChild(content);
// Register Tabs
const getTxt = (k, f) => app.Doctor.getUIText(k) || f;
try {
console.log("[ComfyUI-Doctor] Registering Chat tab...");
tabRegistry.register({
id: 'chat',
icon: '💬',
label: getTxt('tab_chat', 'Chat'),
order: 10,
render: ChatTab.render,
onActivate: ChatTab.onActivate
});
console.log("[ComfyUI-Doctor] Registering Stats tab...");
tabRegistry.register({
id: 'stats',
icon: '📊',
label: getTxt('tab_stats', 'Stats'),
order: 20,
render: StatsTab.render,
onActivate: StatsTab.onActivate
});
console.log("[ComfyUI-Doctor] Registering Settings tab...");
tabRegistry.register({
id: 'settings',
icon: '⚙️',
label: getTxt('tab_settings', 'Settings'),
order: 30,
render: SettingsTab.render
});
console.log("[ComfyUI-Doctor] Initializing TabManager...");
// Init Manager - pass DOM elements directly (not IDs)
// ⚠️ CRITICAL: document.getElementById() fails in ComfyUI's Vue context
manager = new TabManager(tabRegistry, content, tabBar);
manager.init();
console.log("[ComfyUI-Doctor] ✅ TabManager initialized successfully");
// Store references
doctorUI.sidebarTabContainer = content;
doctorUI.tabManager = manager;
// Update status dot if error exists
if (doctorUI.lastErrorData) {
const statusDot = header.querySelector('#doctor-tab-status');
}
} catch (tabError) {
console.error("[ComfyUI-Doctor] ❌ Tab initialization failed:", tabError);
// Show error message in sidebar content
content.innerHTML = `
<div style="padding: 20px; color: #ff5555;">
<h4>⚠️ Tab Initialization Error</h4>
<p style="font-size: 12px; white-space: pre-wrap;">${tabError.message}</p>
<p style="font-size: 10px; color: #888;">Check browser console for details</p>
</div>
`;
}
} catch (mountError) {
destroyDoctorSidebarMount(doctorUI);
throw mountError;
}
}
});
if (sidebarRegistrationSource) {
console.log(`[ComfyUI-Doctor] ✅ Sidebar tab registered via ${sidebarRegistrationSource}`);
} else {
console.log("[ComfyUI-Doctor] Sidebar API not available, using legacy menu button");
}
// Sync initial language with backend
try {
await DoctorAPI.setLanguage(language);
} catch (e) {
console.error("[ComfyUI-Doctor] Failed to sync initial language:", e);
}
console.log("[ComfyUI-Doctor] Settings initialized successfully");
// ═══════════════════════════════════════════════════════════════════
// R5: ERROR BOUNDARIES INITIALIZATION
// ═══════════════════════════════════════════════════════════════════
if (isDoctorErrorBoundariesEnabled()) {
// 1. Inject Error Boundary CSS
if (!document.getElementById('doctor-error-boundary-styles')) {
const link = document.createElement('link');
link.id = 'doctor-error-boundary-styles';
link.rel = 'stylesheet';
link.href = new URL('./error_boundary.css', import.meta.url).href;
document.head.appendChild(link);
}
// 2. Install global error handlers
installGlobalErrorHandlers();
console.log('[ComfyUI-Doctor] ✅ Error boundaries active');
} else {
console.log('[ComfyUI-Doctor] Error boundaries disabled by user setting');
}
}
});