import { Panel } from './Panel'; import { createLazyClient, getRpcBaseUrl } from '@/services/rpc-client'; import { premiumFetch } from '@/services/premium-fetch'; import { h, replaceChildren, setTrustedHtml, trustedHtml } from '@/utils/dom-utils'; import { yieldToMain } from '@/utils/after-paint'; import { marked } from 'marked'; import DOMPurify from 'dompurify'; import type { NewsItem, DeductContextDetail } from '@/types'; import { buildNewsContext } from '@/utils/news-context'; import { getActiveFrameworkForPanel } from '@/services/analysis-framework-store'; import { hasPremiumAccess } from '@/services/panel-gating'; import { FrameworkSelector } from './FrameworkSelector'; import { extractDeductionProbability } from './deduction-probability'; import { IntelligenceServiceClient } from '@/services/generated-rpc-clients'; // deduct-situation + list-market-implications are premium-gated. const getIntelligenceClient = createLazyClient(() => new IntelligenceServiceClient(getRpcBaseUrl(), { fetch: premiumFetch })); const COOLDOWN_MS = 5_000; export class DeductionPanel extends Panel { private formEl: HTMLFormElement; private inputEl: HTMLTextAreaElement; private geoInputEl: HTMLInputElement; private resultContainer: HTMLElement; private submitBtn: HTMLButtonElement; private isSubmitting = false; private getLatestNews?: () => NewsItem[]; private contextHandler: EventListener; private fwSelector: FrameworkSelector; constructor(getLatestNews?: () => NewsItem[]) { super({ id: 'deduction', title: 'Deduct Situation', infoTooltip: 'Use AI intelligence to deduct the timeline and impact of a hypothetical or current event.', }); this.getLatestNews = getLatestNews; this.inputEl = h('textarea', { className: 'deduction-input', placeholder: 'E.g., What will possibly happen in the next 24 hours in Middle East?', required: true, rows: 3, }) as HTMLTextAreaElement; this.geoInputEl = h('input', { className: 'deduction-geo-input', type: 'text', placeholder: 'Geographic or situation context (optional)...', }) as HTMLInputElement; this.submitBtn = h('button', { className: 'deduction-submit-btn', type: 'submit', }, 'Analyze') as HTMLButtonElement; const formRow = h('div', { className: 'deduction-form-row' }, this.geoInputEl, this.submitBtn, ); this.formEl = h('form', { className: 'deduction-form' }, this.inputEl, formRow, ) as HTMLFormElement; this.formEl.addEventListener('submit', this.handleSubmit.bind(this)); this.resultContainer = h('div', { className: 'deduction-result' }); const container = h('div', { className: 'deduction-panel-content' }, this.formEl, this.resultContainer ); replaceChildren(this.content, container); /* Styles moved to panels.css (PERF-012) */ this.contextHandler = ((e: CustomEvent) => { const { query, geoContext, autoSubmit } = e.detail; if (query) { this.inputEl.value = query; } if (geoContext) { this.geoInputEl.value = geoContext; } this.show(); this.element.animate([ { backgroundColor: 'var(--overlay-heavy, rgba(255,255,255,.2))' }, { backgroundColor: 'transparent' } ], { duration: 800, easing: 'ease-out' }); if (autoSubmit && this.inputEl.value && !this.submitBtn.disabled) { this.formEl.requestSubmit(); } }) as EventListener; document.addEventListener('wm:deduct-context', this.contextHandler); this.fwSelector = new FrameworkSelector({ panelId: 'deduction', isPremium: hasPremiumAccess(), panel: this }); this.header.appendChild(this.fwSelector.el); } public override destroy(): void { document.removeEventListener('wm:deduct-context', this.contextHandler); this.fwSelector.destroy(); super.destroy(); } /** Post-process parsed markdown HTML into visually structured sections. */ private reformatResult(container: HTMLElement): void { const SECTIONS = [ { re: /^bottom\s+line/i, cls: 'ds-verdict', label: 'Bottom Line' }, { re: /^what\s+we\s+know/i, cls: 'ds-evidence', label: 'What We Know' }, { re: /^most\s+likely\s+path/i, cls: 'ds-primary', label: 'Most Likely Path' }, { re: /^alternative\s+path/i, cls: 'ds-alt', label: 'Alternative Paths' }, { re: /^confidence/i, cls: 'ds-confidence', label: 'Confidence' }, ] as const; // Collect top-level children; group by section boundary const nodes = Array.from(container.childNodes) as HTMLElement[]; const groups: { cls: string; label: string; nodes: HTMLElement[] }[] = []; let current: { cls: string; label: string; nodes: HTMLElement[] } | null = null; for (const node of nodes) { const strongText = (node.querySelector?.('strong')?.textContent ?? node.textContent ?? '').trim(); const match = SECTIONS.find(s => s.re.test(strongText)); if (match) { current = { cls: match.cls, label: match.label, nodes: [] }; groups.push(current); } if (current) current.nodes.push(node); else if (!match) groups.push({ cls: '', label: '', nodes: [node] }); // unsectioned } if (groups.every(g => !g.cls)) return; // nothing to restructure container.replaceChildren(); for (const group of groups) { if (!group.cls) { group.nodes.forEach(n => container.appendChild(n)); continue; } const section = document.createElement('div'); section.className = group.cls; // Inject section label (remove the header from content) const labelEl = document.createElement('div'); labelEl.className = 'ds-section-label'; // For primary path, extract probability from heading text if (group.cls === 'ds-primary') { const headingNode = group.nodes[0]; const fullText = headingNode?.textContent ?? ''; const probability = extractDeductionProbability(fullText); const timeMatch = /\(([^)]+)\)/.exec(fullText); labelEl.textContent = group.label; if (timeMatch) { const timeSpan = document.createElement('span'); timeSpan.style.cssText = 'font-size:10px;color:var(--text-dim);font-weight:400;text-transform:none;letter-spacing:0'; timeSpan.textContent = timeMatch[1] ?? ''; labelEl.appendChild(timeSpan); } if (probability) { const badge = document.createElement('span'); badge.className = 'ds-prob-badge'; badge.textContent = probability.label; badge.title = probability.isRange ? 'Rough probability range from the source' : 'Approximate probability from the source'; labelEl.appendChild(badge); } } else { labelEl.textContent = group.label; } section.appendChild(labelEl); // Add body nodes (skip first node which was the header paragraph) const bodyNodes = group.nodes.slice(1); if (bodyNodes.length === 0 && group.nodes[0]) { // Inline header: strip the strong header, keep the rest as body const clone = group.nodes[0].cloneNode(true) as HTMLElement; clone.querySelector('strong')?.remove(); const bodyDiv = document.createElement('div'); bodyDiv.className = group.cls === 'ds-primary' ? 'ds-primary-body' : ''; setTrustedHtml( bodyDiv, trustedHtml(clone.innerHTML.replace(/^[\s:–—-]+/, ''), 'legacy direct innerHTML migration'), ); section.appendChild(bodyDiv); } else { // For alt paths: inject probability badges into li items if (group.cls === 'ds-alt') { bodyNodes.forEach(n => { if (n.tagName === 'UL') { n.querySelectorAll('li').forEach(li => { const probability = extractDeductionProbability(li.textContent ?? '', { leadingOnly: true }); if (probability) { const badge = document.createElement('span'); badge.className = 'ds-alt-prob'; badge.textContent = probability.label; badge.title = probability.isRange ? 'Rough probability range from the source' : 'Approximate probability from the source'; li.textContent = probability.remainder; li.insertBefore(badge, li.firstChild); } }); } section.appendChild(n); }); } else { bodyNodes.forEach(n => section.appendChild(n)); } } container.appendChild(section); } } private async handleSubmit(e: Event) { e.preventDefault(); if (this.isSubmitting) return; const query = this.inputEl.value.trim(); if (!query) return; let geoContext = this.geoInputEl.value.trim(); if (this.getLatestNews && !geoContext.includes('Recent News:')) { const newsCtx = buildNewsContext(this.getLatestNews); if (newsCtx) { geoContext = geoContext ? `${geoContext}\n\n${newsCtx}` : newsCtx; } } const fw = getActiveFrameworkForPanel('deduction'); this.isSubmitting = true; this.submitBtn.disabled = true; this.resultContainer.className = 'deduction-result loading'; setTrustedHtml( this.resultContainer, trustedHtml( '
Analyzing…', 'legacy direct innerHTML migration', ), ); try { const resp = await getIntelligenceClient().deductSituation({ query, geoContext, framework: fw?.systemPromptAppend ?? '', }); if (!this.element?.isConnected) return; this.resultContainer.className = 'deduction-result'; if (resp.analysis) { const parsed = await marked.parse(resp.analysis); if (!this.element?.isConnected) return; // Yield so the response paint lands before the synchronous DOMPurify // pass (the heavy `sanitize` chunk) — breaks the post-response long // task instead of running parse+purify+innerHTML as one block (#4537). await yieldToMain(); if (!this.element?.isConnected) return; const safe = DOMPurify.sanitize(parsed); setTrustedHtml(this.resultContainer, trustedHtml(safe, 'legacy direct innerHTML migration')); this.reformatResult(this.resultContainer); } else { this.resultContainer.textContent = resp.provider === 'error' ? 'AI analysis temporarily unavailable. Please try again in a moment.' : 'No analysis available for this query.'; } } catch (err) { if (!this.element?.isConnected) return; console.error('[DeductionPanel] Error:', err); this.resultContainer.className = 'deduction-result error'; this.resultContainer.textContent = 'An error occurred while analyzing the situation.'; } finally { this.isSubmitting = false; if (this.element?.isConnected) { setTimeout(() => { this.submitBtn.disabled = false; }, COOLDOWN_MS); } } } }