Files
rays cf23770200 fix(ui): scope chat activation to owned tab pane
Pass the concrete tab pane through activation hooks and release late async renders after manager destruction. Keep Chat island activation, unmount, and vanilla status updates bound to their owning container.
2026-08-08 17:00:32 +08:00

308 lines
13 KiB
JavaScript

/**
* ComfyUI-Doctor Chat Tab Component
* Provides AI chat interface within the Doctor sidebar.
* Uses Island Registry for standardized mount/unmount.
*/
import { app } from "../../../../scripts/app.js";
import { register, mount, unmount } from "../island_registry.js";
import { activateChatIsland, renderChatIsland, unmountChatIsland } from "../chat-island.js";
import { isPreactEnabled } from "../preact-loader.js";
import { applyProviderQuickSwitch, getProviderQuickSwitchState } from "../llm_provider_quick_switch.js";
// ═══════════════════════════════════════════════════════════════
// ISLAND REGISTRATION (5C.1)
// ═══════════════════════════════════════════════════════════════
register({
id: 'chat',
isEnabled: () => isPreactEnabled(),
render: async (container, props) => {
const success = await renderChatIsland(container, props, { replace: true });
if (!success) throw new Error('ChatIsland render returned false');
},
unmount: (container) => {
unmountChatIsland(container);
},
fallbackRender: (container, error) => {
renderVanilla(container);
},
onError: (error) => {
console.warn('[ChatTab] Island error:', error?.message);
}
});
// ═══════════════════════════════════════════════════════════════
// TAB RENDER & ACTIVATE
// ═══════════════════════════════════════════════════════════════
export async function render(container) {
const doctorUI = app.Doctor;
// 5C.1: Use registry for mount - it handles Preact and fallback automatically
// Attempt Preact via Registry
const success = await mount('chat', container, { uiText: doctorUI.uiText });
// A destroyed manager can detach the pane while the async island load is pending.
if (container.isConnected === false) {
if (success) unmount('chat', container);
clearOwnedVanillaReferences(doctorUI, container);
return () => {};
}
if (success) {
// 5B.1: Set island active flag to gate DoctorUI DOM updates
doctorUI.chatIslandActive = true;
// Styles for container
container.style.cssText = 'display: flex; flex-direction: column; flex: 1; min-height: 0; overflow: hidden;';
// Clear vanilla references so DoctorUI doesn't try to manipulate removed DOM.
doctorUI.sidebarErrorContext = null;
doctorUI.sidebarSanitizationStatus = null;
doctorUI.sidebarMessages = null;
doctorUI.sidebarInput = null;
doctorUI.sidebarSendBtn = null;
doctorUI.sidebarClearBtn = null;
doctorUI.updateSanitizationStatusVanilla = null;
} else {
// 5B.1: Ensure flag is false when Preact fails
// Note: fallbackRender was already called by registry
doctorUI.chatIslandActive = false;
}
const ownedVanillaReferences = success ? null : {
errorContext: doctorUI.sidebarErrorContext,
sanitizationStatus: doctorUI.sidebarSanitizationStatus,
messages: doctorUI.sidebarMessages,
input: doctorUI.sidebarInput,
sendBtn: doctorUI.sidebarSendBtn,
clearBtn: doctorUI.sidebarClearBtn,
updateSanitizationStatus: doctorUI.updateSanitizationStatusVanilla,
};
return () => {
if (container.isConnected !== false) {
doctorUI.chatIslandActive = false;
}
clearOwnedVanillaReferences(doctorUI, container, ownedVanillaReferences);
if (success) {
unmount('chat', container);
}
};
}
export function onActivate(container) {
if (!container || container.isConnected === false) return;
const doctorUI = app.Doctor;
if (activateChatIsland(container)) return;
const messages = container.querySelector('#doctor-messages');
if (messages) {
messages.scrollTop = messages.scrollHeight;
}
const statusEl = container.querySelector('#doctor-sanitization-status');
if (statusEl && doctorUI.updateSanitizationStatusVanilla) {
doctorUI.updateSanitizationStatusVanilla(statusEl);
}
}
function clearOwnedVanillaReferences(doctorUI, container, owned = null) {
const references = owned || {
errorContext: doctorUI.sidebarErrorContext,
sanitizationStatus: doctorUI.sidebarSanitizationStatus,
messages: doctorUI.sidebarMessages,
input: doctorUI.sidebarInput,
sendBtn: doctorUI.sidebarSendBtn,
clearBtn: doctorUI.sidebarClearBtn,
updateSanitizationStatus: doctorUI.updateSanitizationStatusVanilla,
};
const fields = [
['sidebarErrorContext', 'errorContext'],
['sidebarSanitizationStatus', 'sanitizationStatus'],
['sidebarMessages', 'messages'],
['sidebarInput', 'input'],
['sidebarSendBtn', 'sendBtn'],
['sidebarClearBtn', 'clearBtn'],
];
for (const [field, key] of fields) {
const reference = references[key];
if (reference && doctorUI[field] === reference && container.contains(reference)) {
doctorUI[field] = null;
}
}
if (references.updateSanitizationStatus
&& doctorUI.updateSanitizationStatusVanilla === references.updateSanitizationStatus) {
doctorUI.updateSanitizationStatusVanilla = null;
}
}
// ═══════════════════════════════════════════════════════════════
// VANILLA RENDERER (Legacy F13 Logic)
// ═══════════════════════════════════════════════════════════════
function renderVanilla(container) {
const doctorUI = app.Doctor;
// Container fills parent flex container and uses column layout
container.style.cssText = 'display: flex; flex-direction: column; flex: 1; min-height: 0; overflow: hidden;';
// 0. LLM Provider Quick Switch
container.appendChild(createProviderQuickSwitch());
// 1. Error Context Area
const errorContext = document.createElement('div');
errorContext.id = 'doctor-error-context';
errorContext.style.cssText = 'flex-shrink: 0; border-bottom: 1px solid var(--border-color, #444); display: none;';
container.appendChild(errorContext);
// 2. Sanitization Status Bar
const sanitizationStatus = document.createElement('div');
sanitizationStatus.id = 'doctor-sanitization-status';
sanitizationStatus.style.cssText = `
flex-shrink: 0;
background: rgba(76, 175, 80, 0.1);
border-bottom: 1px solid var(--border-color, #444);
padding: 6px 10px;
font-size: 11px;
color: #888;
display: none;
`;
container.appendChild(sanitizationStatus);
// 3. Messages Area
const messages = document.createElement('div');
messages.id = 'doctor-messages';
messages.style.cssText = 'flex: 1 1 0; overflow-y: auto; overflow-x: hidden; padding: 10px; min-height: 0;';
messages.innerHTML = `
<div style="text-align: center; padding: 40px 20px; color: #888;">
<div style="font-size: 48px; margin-bottom: 10px;">✅</div>
<div>${doctorUI.getUIText('no_errors_detected')}</div>
<div style="margin-top: 5px; font-size: 12px;">${doctorUI.getUIText('system_running_smoothly')}</div>
</div>
`;
container.appendChild(messages);
// 4. Input Area
const inputArea = document.createElement('div');
inputArea.id = 'doctor-input-area';
inputArea.style.cssText = 'border-top: 1px solid var(--border-color, #444); background: var(--bg-color, #252525); padding: 10px; position: sticky; bottom: 0; flex-shrink: 0;';
inputArea.innerHTML = `
<textarea id="doctor-input" placeholder="${doctorUI.getUIText('ask_ai_placeholder')}"
style="width: 100%; min-height: 60px; background: #111; border: 1px solid #444; border-radius: 4px; color: #eee; padding: 8px; font-family: inherit; font-size: 13px; resize: vertical;"
rows="2"></textarea>
<div style="display: flex; gap: 8px; margin-top: 8px;">
<button id="doctor-send-btn" style="flex: 1; background: #4caf50; color: white; border: none; border-radius: 4px; padding: 8px; cursor: pointer; font-weight: bold;">${doctorUI.getUIText('send_btn')}</button>
<button id="doctor-clear-btn" style="background: #666; color: white; border: none; border-radius: 4px; padding: 8px; cursor: pointer;">${doctorUI.getUIText('clear_btn')}</button>
</div>
`;
container.appendChild(inputArea);
// Store references for DoctorUI logic
doctorUI.sidebarErrorContext = errorContext;
doctorUI.sidebarSanitizationStatus = sanitizationStatus;
doctorUI.sidebarMessages = messages;
doctorUI.sidebarInput = inputArea.querySelector('#doctor-input');
doctorUI.sidebarSendBtn = inputArea.querySelector('#doctor-send-btn');
doctorUI.sidebarClearBtn = inputArea.querySelector('#doctor-clear-btn');
// Attach Event Listeners
doctorUI.sidebarSendBtn.addEventListener('click', () => doctorUI.handleSendMessage());
doctorUI.sidebarClearBtn.addEventListener('click', () => doctorUI.handleClearChat());
doctorUI.sidebarInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
doctorUI.handleSendMessage();
}
});
doctorUI.sidebarSendBtn._hasListener = true;
doctorUI.sidebarClearBtn._hasListener = true;
doctorUI.sidebarInput._hasListener = true;
// Initial Updates
if (doctorUI.lastErrorData) {
doctorUI.updateSidebarTab(doctorUI.lastErrorData);
}
// Attach update helper to doctorUI for onActivate usage
doctorUI.updateSanitizationStatusVanilla = (el) => updateSanitizationStatus(doctorUI, el);
updateSanitizationStatus(doctorUI, sanitizationStatus);
}
function createProviderQuickSwitch() {
const doctorUI = app.Doctor;
const state = getProviderQuickSwitchState(app);
const wrapper = document.createElement('div');
wrapper.className = 'doctor-provider-quick-switch';
wrapper.style.cssText = `
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 4px;
padding: 8px 10px;
border-bottom: 1px solid var(--border-color, #444);
background: rgba(255, 255, 255, 0.02);
`;
const options = state.options.map((provider) =>
`<option value="${provider.value}" ${state.provider === provider.value ? 'selected' : ''}>${provider.label}</option>`
).join('');
wrapper.innerHTML = `
<label for="doctor-provider-quick-switch" style="font-size: 11px; color: #aaa;">
${doctorUI.getUIText('ai_provider_label') || 'AI Provider'}
</label>
<select
id="doctor-provider-quick-switch"
aria-label="AI provider quick switch"
style="width: 100%; padding: 6px 8px; background: #111; border: 1px solid #444; border-radius: 4px; color: #eee; font-size: 12px;"
>
${options}
</select>
<div id="doctor-provider-quick-status" style="font-size: 10px; color: #777; overflow-wrap: anywhere;"></div>
`;
const select = wrapper.querySelector('#doctor-provider-quick-switch');
const status = wrapper.querySelector('#doctor-provider-quick-status');
status.textContent = state.baseUrl || '';
select.addEventListener('change', () => {
const result = applyProviderQuickSwitch(select.value, app);
status.textContent = result.baseUrl || '';
});
return wrapper;
}
function updateSanitizationStatus(doctorUI, statusEl) {
const metadata = doctorUI.lastAnalysisMetadata?.sanitization;
if (!metadata) {
statusEl.style.display = 'none';
return;
}
const privacyMode = metadata.privacy_mode || 'basic';
const piiFound = metadata.pii_found === true;
const privacyLabel = doctorUI.getUIText('sanitization_label') || 'Privacy';
const modeLabel = doctorUI.getUIText(`sanitization_${privacyMode}`) || privacyMode;
const piiLabel = piiFound
? (doctorUI.getUIText('sanitization_pii_found') || 'PII removed')
: (doctorUI.getUIText('sanitization_pii_not_found') || 'No PII');
const modeIcon = privacyMode === 'strict' ? '🔒' : (privacyMode === 'basic' ? '🔐' : '🔓');
const piiIcon = piiFound ? '✓' : '';
statusEl.innerHTML = `
<span style="margin-right: 12px;">${modeIcon} ${privacyLabel}: <strong>${modeLabel}</strong></span>
<span style="color: ${piiFound ? '#4caf50' : '#888'};">${piiIcon} ${piiLabel}</span>
`;
statusEl.style.display = 'block';
if (privacyMode === 'strict') statusEl.style.background = 'rgba(76, 175, 80, 0.15)';
else if (privacyMode === 'basic') statusEl.style.background = 'rgba(255, 193, 7, 0.1)';
else statusEl.style.background = 'rgba(158, 158, 158, 0.1)';
}