/** @odoo-module **/ import { Component, useState, useRef, onWillStart, onMounted, onPatched } from "@odoo/owl"; import { rpc } from "@web/core/network/rpc"; import { FusionApprovalCard } from "./approval_card"; /** * Parse a fusion-table JSON block from AI response. * Returns {json, placeholder} or null if not a fusion-table block. */ function parseFusionTableBlock(text) { // Match ```fusion-table ... ``` blocks const regex = /```fusion-table\s*\n([\s\S]*?)```/g; const tables = []; let lastIndex = 0; const parts = []; let match; while ((match = regex.exec(text)) !== null) { // Add text before the block if (match.index > lastIndex) { parts.push({ type: "md", content: text.slice(lastIndex, match.index) }); } // Parse the JSON try { const data = JSON.parse(match[1].trim()); const tableIdx = tables.length; tables.push(data); parts.push({ type: "table", idx: tableIdx }); } catch (e) { // If JSON parse fails, treat as regular code block parts.push({ type: "md", content: match[0] }); } lastIndex = match.index + match[0].length; } // Remaining text after last block if (lastIndex < text.length) { parts.push({ type: "md", content: text.slice(lastIndex) }); } if (tables.length === 0) { return null; } return { parts, tables }; } function mdToHtml(text) { if (!text) return ""; // Split into lines for block-level processing const lines = text.split("\n"); const output = []; let inTable = false; let tableHeader = false; let inList = false; let listType = null; for (let i = 0; i < lines.length; i++) { let line = lines[i]; const trimmed = line.trim(); // Close list if we're not in a list item anymore if (inList && !trimmed.match(/^[-*]\s/) && !trimmed.match(/^\d+\.\s/) && trimmed !== "") { output.push(listType === "ul" ? "" : ""); inList = false; listType = null; } // Table row detection (line has at least 2 pipes) const pipeCount = (trimmed.match(/\|/g) || []).length; if (pipeCount >= 2 && trimmed.includes("|")) { // Separator row (|---|---|) if (/^[\s|:\-]+$/.test(trimmed)) { tableHeader = true; continue; } // Split cells let cells = trimmed.split("|").map(c => c.trim()); // Remove empty first/last from leading/trailing pipes if (cells[0] === "") cells.shift(); if (cells.length > 0 && cells[cells.length - 1] === "") cells.pop(); if (!inTable) { output.push('
'); inTable = true; // First row is header output.push(""); cells.forEach(c => output.push(``)); output.push(""); continue; } // Body row output.push(""); cells.forEach(c => output.push(``)); output.push(""); continue; } // Close table if we were in one if (inTable) { output.push("
${inlineFormat(c)}
${inlineFormat(c)}
"); inTable = false; tableHeader = false; } // Empty line if (trimmed === "") { output.push(""); continue; } // Headers const headerMatch = trimmed.match(/^(#{1,5})\s+(.+)$/); if (headerMatch) { const level = Math.min(headerMatch[1].length + 2, 6); // ## -> h4, ### -> h5 output.push(`${inlineFormat(headerMatch[2])}`); continue; } // Horizontal rule if (/^[-*_]{3,}$/.test(trimmed)) { output.push('
'); continue; } // Unordered list const ulMatch = trimmed.match(/^[-*]\s+(.+)$/); if (ulMatch) { if (!inList || listType !== "ul") { if (inList) output.push(listType === "ul" ? "" : ""); output.push('" : ""); output.push('
    '); inList = true; listType = "ol"; } output.push(`
  1. ${inlineFormat(olMatch[1])}
  2. `); continue; } // Regular paragraph output.push(`

    ${inlineFormat(trimmed)}

    `); } // Close open elements if (inTable) output.push(""); if (inList) output.push(listType === "ul" ? "" : "
"); return output.join("\n"); } function inlineFormat(text) { if (!text) return ""; return text // Escape HTML entities .replace(/&/g, "&") .replace(//g, ">") // Bold + italic .replace(/\*\*\*(.+?)\*\*\*/g, '$1') .replace(/\*\*(.+?)\*\*/g, '$1') .replace(/\*(.+?)\*/g, '$1') // Inline code .replace(/`([^`]+)`/g, '$1') // Links [text](url) .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1') // Odoo record links #model,id .replace(/#([\w.]+),(\d+)/g, '$1#$2'); } export class FusionChatPanel extends Component { static template = "fusion_accounting.ChatPanel"; static components = { FusionApprovalCard }; static props = ["sessionId?"]; setup() { this.inputRef = useRef("chatInput"); this.messagesRef = useRef("messages"); this.fileRef = useRef("fileInput"); // Track parsed table data per message index for interactive tables this._parsedTables = {}; this.state = useState({ messages: [], pendingApprovals: [], inputText: "", sending: false, loading: true, internalSessionId: null, sessionName: null, // Interactive tables extracted from AI messages, keyed by msg index interactiveTables: {}, // Session history picker showSessionPicker: false, sessionList: [], // Image attachment pendingImage: null, // { name, dataUrl, base64, mediaType } // Live execution status (from polling) liveThinking: '', liveToolCalls: [], }); this._statusPollInterval = null; onWillStart(async () => { await this.loadLatestSession(); }); onMounted(() => { this._renderRichMessages(); }); onPatched(() => { this._renderRichMessages(); }); } _renderRichMessages() { const container = this.messagesRef.el; if (!container) return; const richDivs = container.querySelectorAll(".fusion_rich_slot[data-idx]"); for (const div of richDivs) { const idx = parseInt(div.dataset.idx); const msg = this.state.messages[idx]; if (msg && msg.role === "assistant") { let html = ""; // Priority 1: Server-side reconciliation table (direct from tool result) if (msg.reconciliationTable && !div.dataset.reconMounted) { const reconKey = `recon_${idx}`; // Strip any fusion-table blocks from AI text to avoid double rendering const cleanText = (msg.content || "").replace(/```fusion-table[\s\S]*?```/g, '').trim(); html = mdToHtml(cleanText); html += `
`; this._parsedTables[reconKey] = { mode: "reconciliation", source_tool: "suggest_bank_line_matches", bank_line: msg.reconciliationTable.bank_line, candidates: msg.reconciliationTable.candidates, best_combination: msg.reconciliationTable.best_combination, }; div.innerHTML = html; div.dataset.reconMounted = "true"; this._mountInteractiveTables(div); } // Priority 2: AI-generated fusion-table blocks else if (msg.content && !div.dataset.reconMounted) { const parsed = parseFusionTableBlock(msg.content); if (parsed) { for (const part of parsed.parts) { if (part.type === "md") { html += mdToHtml(part.content); } else if (part.type === "table") { const tableKey = `${idx}_${part.idx}`; html += `
`; this._parsedTables[tableKey] = parsed.tables[part.idx]; } } if (div.innerHTML !== html) { div.innerHTML = html; } this._mountInteractiveTables(div); } else { const mdHtml = mdToHtml(msg.content); if (div.innerHTML !== mdHtml) { div.innerHTML = mdHtml; } } } } } } _mountInteractiveTables(container) { const mounts = container.querySelectorAll(".fusion_table_mount[data-table-key]"); for (const el of mounts) { const key = el.dataset.tableKey; if (el.dataset.mounted === "true") continue; const tableData = this._parsedTables[key]; if (!tableData) continue; el.dataset.mounted = "true"; if (tableData.mode === "reconciliation") { el.innerHTML = this._buildReconciliationTableHtml(tableData, key); this._wireReconciliationEvents(el, tableData, key); } else { el.innerHTML = this._buildInteractiveTableHtml(tableData, key); this._wireTableEvents(el, tableData, key); } } } _badgeClass(action) { switch (action) { case "dismiss": return "bg-success-subtle text-success"; case "flag": return "bg-warning-subtle text-warning"; case "create_rule": return "bg-info-subtle text-info"; default: return "bg-secondary-subtle text-secondary"; } } _badgeLabel(action) { switch (action) { case "dismiss": return "Dismiss"; case "flag": return "Flag"; case "create_rule": return "Create Rule"; default: return action || "Review"; } } _esc(text) { const d = document.createElement("div"); d.textContent = text; return d.innerHTML; } _buildInteractiveTableHtml(tableData, key) { const cols = tableData.columns || []; const rows = tableData.rows || []; const isInteractive = tableData.mode === "interactive"; const actions = tableData.actions || []; const title = tableData.title || ""; let h = '
'; // Title if (title) { h += `
`; h += ``; h += `${this._esc(title)}`; h += `${rows.length} rows`; h += `
`; } // Table h += '
'; if (isInteractive) { h += ``; } for (const col of cols) { h += ``; } if (isInteractive) { h += ``; h += ``; } h += ''; for (let i = 0; i < rows.length; i++) { const row = rows[i]; h += ``; if (isInteractive) { h += ``; } for (const cell of (row.cells || [])) { h += ``; } if (isInteractive) { // Recommendation h += ``; // User input h += ``; } h += ''; } h += '
${this._esc(col)}AI RecommendationYour Input
${this._esc(String(cell))}`; if (row.recommendation) { const rc = row.recommendation; h += `${this._badgeLabel(rc.action)}`; h += `${this._esc(rc.reason || "")}`; } h += `
'; // Action bar if (isInteractive) { h += '
'; h += '0 selected'; h += ``; if (actions.includes("flag")) { h += ``; } if (actions.includes("create_rule")) { h += ``; } if (actions.includes("dismiss")) { h += ``; } h += '
'; h += ``; h += '
'; } h += '
'; return h; } _wireTableEvents(container, tableData, key) { const rows = tableData.rows || []; // Select all checkbox const selectAll = container.querySelector('[data-action="select-all"]'); if (selectAll) { selectAll.addEventListener("change", () => { const cbs = container.querySelectorAll('[data-action="select-row"]'); for (const cb of cbs) cb.checked = selectAll.checked; this._updateTableActionBar(container); }); } // Individual row checkboxes const rowCbs = container.querySelectorAll('[data-action="select-row"]'); for (const cb of rowCbs) { cb.addEventListener("change", () => this._updateTableActionBar(container)); } // Action buttons const actionBtns = container.querySelectorAll('.fusion_table_action_bar button[data-action]'); for (const btn of actionBtns) { btn.addEventListener("click", () => { const action = btn.dataset.action; const selectedRows = this._collectTableRows(container, tableData, action === "submit_notes"); if (selectedRows.length === 0) return; this.onTableAction({ action, source_tool: tableData.source_tool, rows: selectedRows, }); }); } } _updateTableActionBar(container) { const cbs = container.querySelectorAll('[data-action="select-row"]:checked'); const count = cbs.length; const countEl = container.querySelector('.fusion_selected_count'); if (countEl) countEl.textContent = `${count} selected`; // Enable/disable action buttons const btns = container.querySelectorAll('.fusion_table_action_bar button[data-action]'); for (const btn of btns) { if (btn.dataset.action === "submit_notes") continue; // always enabled btn.disabled = (count === 0); } } _collectTableRows(container, tableData, allNotes) { const rows = tableData.rows || []; const result = []; if (allNotes) { // Collect all rows that have a note const inputs = container.querySelectorAll('.fusion_row_note'); for (const inp of inputs) { const idx = parseInt(inp.dataset.idx); const note = inp.value.trim(); if (note && rows[idx]) { result.push({ id: rows[idx].id, cells: rows[idx].cells, recommendation: rows[idx].recommendation, userNote: note, }); } } } else { // Collect checked rows const cbs = container.querySelectorAll('[data-action="select-row"]:checked'); for (const cb of cbs) { const idx = parseInt(cb.dataset.idx); if (rows[idx]) { const noteInput = container.querySelector(`.fusion_row_note[data-idx="${idx}"]`); result.push({ id: rows[idx].id, cells: rows[idx].cells, recommendation: rows[idx].recommendation, userNote: noteInput ? noteInput.value.trim() : "", }); } } } return result; } // ================================================================ // Image Upload // ================================================================ triggerFileUpload() { const input = this.fileRef.el; if (input) input.click(); } onFileSelected(ev) { const file = ev.target.files?.[0]; if (!file) return; if (!file.type.startsWith('image/')) { console.warn("Only image files are supported"); return; } if (file.size > 10 * 1024 * 1024) { console.warn("Image too large (max 10MB)"); return; } const reader = new FileReader(); reader.onload = () => { const dataUrl = reader.result; // Extract base64 and media type const match = dataUrl.match(/^data:(image\/[^;]+);base64,(.+)$/); if (match) { this.state.pendingImage = { name: file.name, dataUrl, base64: match[2], mediaType: match[1], }; } }; reader.readAsDataURL(file); // Reset input so same file can be selected again ev.target.value = ''; } clearImage() { this.state.pendingImage = null; } _startStatusPolling() { this._stopStatusPolling(); this.state.liveThinking = ''; this.state.liveToolCalls = []; this._statusPollInterval = setInterval(async () => { if (!this.state.sending || !this.sessionId) { this._stopStatusPolling(); return; } try { const status = await rpc('/fusion_accounting/chat/status', { session_id: this.sessionId, }); if (status.thinking) { this.state.liveThinking = status.thinking; } if (status.tool_calls && status.tool_calls.length) { this.state.liveToolCalls = status.tool_calls; } } catch (e) { // Polling failure is not critical — just skip } }, 600); } _stopStatusPolling() { if (this._statusPollInterval) { clearInterval(this._statusPollInterval); this._statusPollInterval = null; } this.state.liveThinking = ''; this.state.liveToolCalls = []; } // ================================================================ // Reconciliation Table Mode // ================================================================ _buildReconciliationTableHtml(tableData, key) { const bankLine = tableData.bank_line || {}; // Accept candidates from either 'candidates' (tool output) or 'rows' (AI formatted) let rawCandidates = tableData.candidates || tableData.rows || []; // Normalize: if AI sent rows with 'id' instead of 'aml_id', or 'cells' arrays, fix them const candidates = rawCandidates.map(c => { if (c.aml_id) return c; // Already in correct format // Try to extract from cells array if AI used interactive table format const norm = { ...c }; if (!norm.aml_id && norm.id) norm.aml_id = norm.id; if (!norm.amount_residual && typeof norm.amount_residual !== 'number') norm.amount_residual = norm.amount_total || norm.amount || 0; return norm; }); // Store normalized rows back for collect function tableData.rows = candidates; const bestCombo = new Set((tableData.best_combination || []).map(String)); const bankAmount = Math.abs(bankLine.abs_amount || bankLine.amount || 0); const title = tableData.title || `Match: ${bankLine.ref || ''} $${bankAmount.toFixed(2)}`; let h = `
`; // Header h += `
`; h += `
${this._esc(title)}`; h += `${bankLine.direction || ''}
`; h += `${bankLine.journal || ''}
`; // Table h += '
'; h += ''; h += ''; h += ''; h += ''; h += ''; h += ''; h += ''; h += ''; h += ''; for (let i = 0; i < candidates.length; i++) { const c = candidates[i]; const inCombo = bestCombo.has(String(c.aml_id)); const checked = inCombo ? 'checked' : ''; const residual = c.amount_residual || c.amount_total || 0; const score = c.score || 0; const scoreClass = score >= 60 ? 'text-success' : score >= 30 ? 'text-warning' : 'text-muted'; h += ``; h += ``; h += ``; const typeClass = c.type === 'payment' ? 'bg-success-subtle text-success' : c.type === 'bill' ? 'bg-info-subtle text-info' : 'bg-primary-subtle text-primary'; h += ``; h += ``; h += ``; h += ``; h += ``; h += ``; } h += '
EntryTypePartnerDateResidualApplyScore
${this._esc(c.name)}`; if (c.ref) h += `
${this._esc(c.ref)}`; h += `
${this._esc(c.type || 'entry')}${this._esc(c.partner)}${this._esc(c.date)}$${residual.toFixed(2)}`; h += ``; h += `${score}`; if (c.reasons) h += ` — ${this._esc(c.reasons)}`; h += `
'; // Search bar h += ''; // Running total footer h += '
'; h += '
'; h += '$0.00'; h += ` / Bank: $${bankAmount.toFixed(2)}`; h += ' '; h += '
'; h += `'; h += '
'; h += '
'; return h; } _wireReconciliationEvents(container, tableData, key) { const bankAmount = parseFloat(container.querySelector('.fusion_recon_table')?.dataset.bankAmount || '0'); const recalcTotal = () => { let total = 0; const checks = container.querySelectorAll('.recon-row-check:checked'); for (const cb of checks) { const idx = parseInt(cb.dataset.idx); const amtInput = container.querySelector(`.fusion_apply_amount[data-idx="${idx}"]`); if (amtInput) total += parseFloat(amtInput.value) || 0; } total = Math.round(total * 100) / 100; const totalEl = container.querySelector('.fusion_selected_total'); const badgeEl = container.querySelector('.fusion_remaining_badge'); const applyBtn = container.querySelector('.fusion_apply_match_btn'); if (totalEl) totalEl.textContent = `$${total.toFixed(2)}`; const remaining = Math.round((bankAmount - total) * 100) / 100; if (badgeEl) { if (Math.abs(remaining) < 0.01) { badgeEl.textContent = 'Balanced ✓'; badgeEl.className = 'fusion_remaining_badge badge ms-1 bg-success'; } else if (remaining > 0) { badgeEl.textContent = `Remaining: $${remaining.toFixed(2)}`; badgeEl.className = 'fusion_remaining_badge badge ms-1 bg-warning text-dark'; } else { badgeEl.textContent = `Over: $${Math.abs(remaining).toFixed(2)}`; badgeEl.className = 'fusion_remaining_badge badge ms-1 bg-danger'; } } if (applyBtn) applyBtn.disabled = total === 0; }; // Select-all const selectAll = container.querySelector('[data-action="recon-select-all"]'); if (selectAll) { selectAll.addEventListener('change', () => { for (const cb of container.querySelectorAll('.recon-row-check')) cb.checked = selectAll.checked; recalcTotal(); }); } // Row checkboxes for (const cb of container.querySelectorAll('.recon-row-check')) { cb.addEventListener('change', recalcTotal); } // Amount inputs for (const inp of container.querySelectorAll('.fusion_apply_amount')) { inp.addEventListener('input', () => { const max = parseFloat(inp.dataset.max) || 0; let val = parseFloat(inp.value) || 0; if (val > max) { inp.value = max.toFixed(2); } if (val < 0) { inp.value = '0.00'; } recalcTotal(); }); } // Apply Match button const applyBtn = container.querySelector('.fusion_apply_match_btn'); if (applyBtn) { applyBtn.addEventListener('click', () => { const rows = this._collectReconciliationRows(container, tableData); if (!rows.length) return; const bankLineId = container.querySelector('.fusion_recon_table')?.dataset.bankLineId; this.onTableAction({ action: 'apply_match', source_tool: tableData.source_tool || 'suggest_bank_line_matches', bank_line_id: bankLineId, bank_amount: bankAmount, rows, }); }); } // Search bar with debounce const searchInput = container.querySelector('.fusion_match_search'); const resultsDiv = container.querySelector('.fusion_search_results'); let searchTimeout = null; if (searchInput && resultsDiv) { searchInput.addEventListener('input', () => { clearTimeout(searchTimeout); const q = searchInput.value.trim(); if (q.length < 2) { resultsDiv.classList.add('d-none'); return; } searchTimeout = setTimeout(async () => { try { const data = await rpc('/fusion_accounting/search_matches', { statement_line_id: parseInt(searchInput.dataset.lineId) || 0, query: q, }); this._renderSearchResults(resultsDiv, container, data.candidates || [], tableData, key); } catch (e) { resultsDiv.innerHTML = '
Search failed
'; resultsDiv.classList.remove('d-none'); } }, 300); }); } // Initial total calculation recalcTotal(); } _renderSearchResults(resultsDiv, tableContainer, candidates, tableData, key) { if (!candidates.length) { resultsDiv.innerHTML = '
No matching entries found
'; resultsDiv.classList.remove('d-none'); return; } let h = '
'; for (const c of candidates.slice(0, 8)) { // Skip if already in the table if (tableContainer.querySelector(`tr[data-aml-id="${c.aml_id}"]`)) continue; h += ``; } h += '
'; resultsDiv.innerHTML = h; resultsDiv.classList.remove('d-none'); // Wire add-on-click for (const btn of resultsDiv.querySelectorAll('.fusion_search_result')) { btn.addEventListener('click', () => { const candidate = JSON.parse(btn.dataset.aml); this._addRowToReconciliationTable(tableContainer, candidate, tableData); resultsDiv.classList.add('d-none'); const searchInput = tableContainer.querySelector('.fusion_match_search'); if (searchInput) searchInput.value = ''; }); } } _addRowToReconciliationTable(container, candidate, tableData) { const tbody = container.querySelector('tbody'); if (!tbody) return; const rows = tableData.rows || []; const idx = rows.length; rows.push(candidate); const residual = candidate.amount_residual || 0; const tr = document.createElement('tr'); tr.dataset.rowIdx = idx; tr.dataset.amlId = candidate.aml_id; const cType = candidate.type || 'entry'; const cTypeClass = cType === 'payment' ? 'bg-success-subtle text-success' : cType === 'bill' ? 'bg-info-subtle text-info' : 'bg-primary-subtle text-primary'; tr.innerHTML = ` ${this._esc(candidate.name)}${candidate.ref ? '
' + this._esc(candidate.ref) + '' : ''} ${this._esc(cType)} ${this._esc(candidate.partner)} ${this._esc(candidate.date)} $${residual.toFixed(2)} added `; tbody.appendChild(tr); // Wire new row's checkbox and amount input const cb = tr.querySelector('.recon-row-check'); const amt = tr.querySelector('.fusion_apply_amount'); const recalc = () => { // Trigger recalc by dispatching change on first checkbox const first = container.querySelector('.recon-row-check'); if (first) first.dispatchEvent(new Event('change')); }; if (cb) cb.addEventListener('change', recalc); if (amt) amt.addEventListener('input', recalc); recalc(); } _collectReconciliationRows(container, tableData) { const result = []; const checks = container.querySelectorAll('.recon-row-check:checked'); const rows = tableData.rows || []; for (const cb of checks) { const idx = parseInt(cb.dataset.idx); const tr = cb.closest('tr'); const amlId = tr?.dataset.amlId; const amtInput = container.querySelector(`.fusion_apply_amount[data-idx="${idx}"]`); const applyAmount = parseFloat(amtInput?.value) || 0; const maxAmount = parseFloat(amtInput?.dataset.max) || 0; const c = rows[idx] || {}; result.push({ aml_id: parseInt(amlId) || c.aml_id, name: c.name || '', apply_amount: applyAmount, amount_residual: maxAmount, is_partial: applyAmount < maxAmount - 0.01, }); } // Sort: full matches first, partial last (Odoo applies partial on last AML) result.sort((a, b) => (a.is_partial ? 1 : 0) - (b.is_partial ? 1 : 0)); return result; } get sessionId() { return this.state.internalSessionId || this.props.sessionId; } async loadLatestSession() { this.state.loading = true; try { const data = await rpc("/fusion_accounting/session/latest", {}); if (data.session_id) { this.state.internalSessionId = data.session_id; this.state.messages = data.messages || []; this.state.sessionName = data.name; if (data.pending_approvals && data.pending_approvals.length) { this.state.pendingApprovals = data.pending_approvals; } } } catch (e) { console.error("Failed to load session:", e); } this.state.loading = false; this.scrollToBottom(); } async toggleSessionPicker() { if (this.state.showSessionPicker) { this.state.showSessionPicker = false; return; } try { const data = await rpc("/fusion_accounting/session/list", { limit: 20 }); this.state.sessionList = data.sessions || []; } catch (e) { console.error("Failed to load session list:", e); this.state.sessionList = []; } this.state.showSessionPicker = true; } async loadSession(sessionId) { this.state.showSessionPicker = false; this.state.loading = true; try { const data = await rpc("/fusion_accounting/session/history", { session_id: sessionId }); if (data.messages) { this.state.internalSessionId = data.session_id; // Filter display messages same as session/latest const display = []; for (const msg of data.messages) { if (typeof msg.content === "string" && msg.content.trim()) { display.push(msg); } else if (Array.isArray(msg.content)) { for (const block of msg.content) { if (block && block.type === "text" && block.text && block.text.trim()) { display.push({ role: msg.role, content: block.text }); } } } } this.state.messages = display; // Find session name from the list const found = this.state.sessionList.find(s => s.id === sessionId); this.state.sessionName = found ? found.name : `Session #${sessionId}`; this.state.pendingApprovals = []; this._parsedTables = {}; } } catch (e) { console.error("Failed to load session:", e); } this.state.loading = false; this.scrollToBottom(); } formatSessionDate(isoDate) { if (!isoDate) return ""; try { const d = new Date(isoDate); return d.toLocaleDateString("en-CA", { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }); } catch (e) { return isoDate; } } async onNewChat() { // Close current session first — must succeed before creating new one if (this.sessionId) { try { const closeResult = await rpc("/fusion_accounting/session/close", { session_id: this.sessionId }); if (closeResult.error) { console.warn("Failed to close session:", closeResult.error); } } catch (e) { console.warn("Error closing session:", e); } } try { const session = await rpc("/fusion_accounting/session/create", {}); this.state.internalSessionId = session.session_id; this.state.sessionName = session.name; this.state.messages = []; this.state.pendingApprovals = []; this._parsedTables = {}; } catch (e) { console.error("Failed to create new session:", e); } } async sendMessage() { const text = this.state.inputText.trim(); const image = this.state.pendingImage; if ((!text && !image) || this.state.sending) return; if (!this.sessionId) { const session = await rpc("/fusion_accounting/session/create", {}); this.state.internalSessionId = session.session_id; this.state.sessionName = session.name; } this.state.messages.push({ role: "user", content: text || (image ? `[Attached: ${image.name}]` : ''), hasImage: !!image, imageUrl: image?.dataUrl || null, }); this.state.inputText = ""; this.state.pendingImage = null; this.state.sending = true; this.scrollToBottom(); this._startStatusPolling(); const chatPayload = { session_id: this.sessionId, message: text || "Please analyze the attached image.", }; if (image) { chatPayload.image = { base64: image.base64, media_type: image.mediaType, name: image.name, }; } try { const result = await rpc("/fusion_accounting/chat", chatPayload); this._stopStatusPolling(); if (result.text || (result.tool_calls_log && result.tool_calls_log.length)) { this.state.messages.push({ role: "assistant", content: result.text || "", toolCalls: result.tool_calls_log || [], // Attach server-side reconciliation data for direct rendering reconciliationTable: result.reconciliation_table || null, }); } if (result.pending_approvals) { this.state.pendingApprovals = result.pending_approvals; } } catch (e) { this._stopStatusPolling(); this.state.messages.push({ role: "assistant", content: `Error: ${e.message || "Something went wrong."}`, }); } this.state.sending = false; this.scrollToNewReply(); } /** * Handle actions from interactive tables (Apply, Flag, Create Rule, Dismiss, Submit Notes). * Formats a structured message and sends it back through the chat. */ async onTableAction(payload) { const { action, source_tool, rows } = payload; const actionLabels = { apply_recommendations: "Apply Recommendations", flag: "Flag", create_rule: "Create Rules", dismiss: "Dismiss", submit_notes: "Submit Notes", apply_match: "Apply Match", }; const label = actionLabels[action] || action; let message; if (action === 'apply_match') { // Reconciliation-specific format with AML IDs and custom amounts const bankLineId = payload.bank_line_id || ''; const bankAmount = payload.bank_amount || 0; const total = rows.reduce((s, r) => s + (r.apply_amount || 0), 0); let parts = [`[TABLE_ACTION] source=${source_tool} action=apply_match`]; parts.push(`bank_line_id=${bankLineId} bank_amount=${bankAmount}`); for (const row of rows) { const tag = row.is_partial ? 'partial' : 'full'; parts.push(`- AML #${row.aml_id}: ${row.name} | apply=$${(row.apply_amount || 0).toFixed(2)} residual=$${(row.amount_residual || 0).toFixed(2)} (${tag})`); } parts.push(`Total: $${total.toFixed(2)} / Bank: $${bankAmount.toFixed(2)}`); message = parts.join('\n'); } else { // Standard interactive table format let parts = [`[TABLE_ACTION] source=${source_tool} action=${action}`]; for (const row of rows) { const cellSummary = (row.cells || []).join(" | "); let line = `- Row #${row.id}: ${cellSummary}`; if (row.recommendation) { line += ` (AI suggested: ${row.recommendation.action} - ${row.recommendation.reason})`; } if (row.userNote) { line += ` [User note: ${row.userNote}]`; } parts.push(line); } message = parts.join('\n'); } // Show user what we're sending this.state.messages.push({ role: "user", content: `**${label}** on ${rows.length} row(s) from ${source_tool}`, }); this.state.sending = true; this.scrollToBottom(); this._startStatusPolling(); try { const result = await rpc("/fusion_accounting/chat", { session_id: this.sessionId, message: message, }); this._stopStatusPolling(); if (result.text || (result.tool_calls_log && result.tool_calls_log.length)) { this.state.messages.push({ role: "assistant", content: result.text || "", toolCalls: result.tool_calls_log || [], }); } if (result.pending_approvals) { this.state.pendingApprovals = result.pending_approvals; } } catch (e) { this._stopStatusPolling(); this.state.messages.push({ role: "assistant", content: `Error processing table action: ${e.message || "Something went wrong."}`, }); } this.state.sending = false; this.scrollToNewReply(); } sendStarter(text) { this.state.inputText = text; this.sendMessage(); } onKeyDown(ev) { if (ev.key === "Enter" && !ev.shiftKey) { ev.preventDefault(); this.sendMessage(); } } onPaste(ev) { const items = ev.clipboardData?.items; if (!items) return; for (const item of items) { if (item.type.startsWith('image/')) { ev.preventDefault(); const file = item.getAsFile(); if (!file) return; const reader = new FileReader(); reader.onload = () => { const dataUrl = reader.result; const match = dataUrl.match(/^data:(image\/[^;]+);base64,(.+)$/); if (match) { this.state.pendingImage = { name: `screenshot-${Date.now()}.png`, dataUrl, base64: match[2], mediaType: match[1], }; } }; reader.readAsDataURL(file); return; } } } scrollToBottom() { const el = this.messagesRef.el; if (el) { setTimeout(() => { el.scrollTop = el.scrollHeight; }, 100); } } scrollToNewReply() { // Scroll so the TOP of the latest AI message is visible const el = this.messagesRef.el; if (!el) return; setTimeout(() => { const aiMsgs = el.querySelectorAll(".fusion_ai_msg"); if (aiMsgs.length) { const last = aiMsgs[aiMsgs.length - 1]; // Scroll so the message top aligns near the top of the container const offset = last.offsetTop - el.offsetTop - 8; el.scrollTop = offset; } else { el.scrollTop = el.scrollHeight; } }, 120); } async onApprove(matchHistoryId) { await rpc("/fusion_accounting/approve", { match_history_id: matchHistoryId }); this.state.pendingApprovals = this.state.pendingApprovals.filter(a => a.id !== matchHistoryId); this.state.messages.push({ role: "assistant", content: "Action approved and executed." }); } async onReject(matchHistoryId) { await rpc("/fusion_accounting/reject", { match_history_id: matchHistoryId, reason: "User rejected" }); this.state.pendingApprovals = this.state.pendingApprovals.filter(a => a.id !== matchHistoryId); this.state.messages.push({ role: "assistant", content: "Action rejected." }); } async onApproveAll() { const ids = this.state.pendingApprovals.map(a => a.id); if (!ids.length) return; await rpc("/fusion_accounting/approve_all", { match_history_ids: ids }); const count = this.state.pendingApprovals.length; this.state.pendingApprovals = []; this.state.messages.push({ role: "assistant", content: `${count} actions approved and executed.` }); } async onRejectAll() { const ids = this.state.pendingApprovals.map(a => a.id); if (!ids.length) return; await rpc("/fusion_accounting/reject_all", { match_history_ids: ids, reason: "Batch rejected" }); const count = this.state.pendingApprovals.length; this.state.pendingApprovals = []; this.state.messages.push({ role: "assistant", content: `${count} actions rejected.` }); } }