| 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';
|
|
|
|
|
| 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);
|
|
|
|
|
|
|
| this.contextHandler = ((e: CustomEvent<DeductContextDetail>) => {
|
| 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();
|
| }
|
|
|
|
|
| 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;
|
|
|
|
|
| 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] });
|
| }
|
|
|
| if (groups.every(g => !g.cls)) return;
|
|
|
| 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;
|
|
|
|
|
| const labelEl = document.createElement('div');
|
| labelEl.className = 'ds-section-label';
|
|
|
|
|
| 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);
|
|
|
|
|
| const bodyNodes = group.nodes.slice(1);
|
| if (bodyNodes.length === 0 && group.nodes[0]) {
|
|
|
| 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 {
|
|
|
| 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(
|
| '<div class="deduction-loading-dots"><span></span><span></span><span></span></div>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;
|
|
|
|
|
|
|
| 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);
|
| }
|
| }
|
| }
|
| }
|
|
|