`);
}
// 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 += `
${this._esc(col)}
`;
}
if (isInteractive) {
h += `
AI Recommendation
`;
h += `
Your Input
`;
}
h += '
';
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
h += `
`;
if (isInteractive) {
h += `
`;
}
for (const cell of (row.cells || [])) {
h += `
${this._esc(String(cell))}
`;
}
if (isInteractive) {
// Recommendation
h += `
`;
if (row.recommendation) {
const rc = row.recommendation;
h += `${this._badgeLabel(rc.action)}`;
h += `${this._esc(rc.reason || "")}`;
}
h += `
`;
// User input
h += `
`;
}
h += '
';
}
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 || ''}
';
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 += '