mejoras en el modelo de clarificacion de productos

This commit is contained in:
Lucas Tettamanti
2026-01-17 06:31:49 -03:00
parent 63b9ecef61
commit 204403560e
24 changed files with 1940 additions and 873 deletions

View File

@@ -102,7 +102,7 @@ class AliasesCrud extends HTMLElement {
async loadProducts() {
try {
const data = await api.products({ limit: 500 });
const data = await api.products({ limit: 2000 });
this.products = data.items || [];
} catch (e) {
console.error("Error loading products:", e);

View File

@@ -133,7 +133,7 @@ class ChatSimulator extends HTMLElement {
return;
}
// Optimistic: que aparezca en la columna izquierda al instante
// 1. Actualizar lista de conversaciones
emit("conversation:upsert", {
chat_id: from,
from: pushName || "test_lucas",
@@ -143,9 +143,11 @@ class ChatSimulator extends HTMLElement {
last_activity: new Date().toISOString(),
last_run_id: null,
});
// 2. Seleccionar el chat (si es el mismo, no recarga - optimizado en run-timeline)
emit("ui:selectedChat", { chat_id: from });
// Optimistic: mostrar burbuja del usuario inmediatamente
// 3. Mostrar burbuja optimista INMEDIATAMENTE
emit("message:optimistic", {
chat_id: from,
message_id: `optimistic-${Date.now()}`,

View File

@@ -61,6 +61,8 @@ class ConversationInspector extends HTMLElement {
this.shadowRoot.getElementById("step").onclick = () => this.step();
this._unsubSel = on("ui:selectedChat", async ({ chat_id }) => {
// Si es el mismo chat, no recargar (para no borrar items optimistas)
if (this.chatId === chat_id) return;
this.chatId = chat_id;
await this.loadData();
});
@@ -87,6 +89,17 @@ class ConversationInspector extends HTMLElement {
const messageId = message?.message_id || null;
if (messageId) this.highlight(messageId);
});
// Listen for optimistic messages to add placeholder item
this._unsubOptimistic = on("message:optimistic", (msg) => {
if (!this.chatId) {
this.chatId = msg.chat_id;
this.shadowRoot.getElementById("chat").textContent = msg.chat_id;
this.shadowRoot.getElementById("meta").textContent = "Nueva conversación";
}
if (msg.chat_id !== this.chatId) return;
this.addOptimisticItem(msg);
});
}
disconnectedCallback() {
@@ -95,6 +108,7 @@ class ConversationInspector extends HTMLElement {
this._unsubLayout?.();
this._unsubScroll?.();
this._unsubSelectMessage?.();
this._unsubOptimistic?.();
this.pause();
}
@@ -180,6 +194,14 @@ class ConversationInspector extends HTMLElement {
metaEl.textContent = `Inspector de ${this.messages.length} mensajes.`;
countEl.textContent = this.messages.length ? `${this.messages.length} filas` : "";
// Preserve optimistic items before clearing
const optimisticItems = [...list.querySelectorAll('.item[data-message-id^="optimistic-"]')];
// Obtener timestamps de mensajes IN del servidor para comparar
const serverInTimestamps = this.messages
.filter(m => m.direction === "in")
.map(m => new Date(m.ts).getTime());
list.innerHTML = "";
this.rowMap.clear();
this.rowOrder = [];
@@ -196,7 +218,7 @@ class ConversationInspector extends HTMLElement {
const intent = run?.llm_output?.intent || "—";
const nextState = run?.llm_output?.next_state || "—";
const prevState = row.nextRun?.prev_state || "—";
const basket = run?.llm_output?.basket_resolved?.items || [];
const basket = run?.llm_output?.full_basket?.items || run?.llm_output?.basket_resolved?.items || [];
const tools = this.toolSummary(run?.tools || []);
const llmMeta = run?.llm_output?._llm || null;
@@ -237,6 +259,23 @@ class ConversationInspector extends HTMLElement {
this.rowMap.set(msg.message_id, el);
this.rowOrder.push(msg.message_id);
}
// Re-add preserved optimistic items ONLY if no server message covers it
for (const optItem of optimisticItems) {
// Obtener timestamp del optimista (está en el ID: optimistic-{timestamp})
const msgId = optItem.dataset.messageId;
const optTs = parseInt(msgId.replace("optimistic-", ""), 10) || 0;
// Si hay un mensaje del servidor con timestamp cercano (10 seg), no re-agregar
const hasServerMatch = serverInTimestamps.some(ts => Math.abs(ts - optTs) < 10000);
if (hasServerMatch) {
continue;
}
list.appendChild(optItem);
this.rowMap.set(msgId, optItem);
this.rowOrder.push(msgId);
}
}
applyHeights() {
@@ -302,6 +341,44 @@ class ConversationInspector extends HTMLElement {
this.highlight(messageId);
this._playIdx += 1;
}
addOptimisticItem(msg) {
const list = this.shadowRoot.getElementById("list");
if (!list) return;
// Remove any existing optimistic item
const existing = list.querySelector(`.item[data-message-id^="optimistic-"]`);
if (existing) existing.remove();
const el = document.createElement("div");
el.className = "item in";
el.dataset.messageId = msg.message_id;
el.innerHTML = `
<div class="kv">
<div class="k">IN</div>
<div class="v">${new Date(msg.ts).toLocaleString()}</div>
<div class="k">STATE</div>
<div class="v">—</div>
<div class="k">INTENT</div>
<div class="v">—</div>
<div class="k">NLU</div>
<div class="v">procesando...</div>
</div>
<div class="cart"><strong>Carrito:</strong> —</div>
<div class="chips"></div>
`;
list.appendChild(el);
list.scrollTop = list.scrollHeight;
this.rowMap.set(msg.message_id, el);
this.rowOrder.push(msg.message_id);
// Apply min height
el.style.minHeight = "120px";
el.style.marginBottom = "12px";
}
}
customElements.define("conversation-inspector", ConversationInspector);

View File

@@ -5,7 +5,8 @@ class ProductsCrud extends HTMLElement {
super();
this.attachShadow({ mode: "open" });
this.items = [];
this.selected = null;
this.selectedItems = []; // Array de productos seleccionados
this.lastClickedIndex = -1; // Para Shift+Click
this.loading = false;
this.searchQuery = "";
this.stockFilter = false;
@@ -29,9 +30,10 @@ class ProductsCrud extends HTMLElement {
button.secondary:hover { background:#2d3e52; }
.list { flex:1; overflow-y:auto; }
.item { background:#0f1520; border:1px solid #253245; border-radius:8px; padding:12px; margin-bottom:8px; cursor:pointer; transition:all .15s; }
.item { background:#0f1520; border:1px solid #253245; border-radius:8px; padding:12px; margin-bottom:8px; cursor:pointer; transition:all .15s; user-select:none; }
.item:hover { border-color:#1f6feb; }
.item.active { border-color:#1f6feb; background:#111b2a; }
.item.selected { border-color:#2ecc71; background:#0f2a1a; }
.item-name { font-weight:600; color:#e7eef7; margin-bottom:4px; }
.item-meta { font-size:12px; color:#8aa0b5; }
.item-price { color:#2ecc71; font-weight:600; }
@@ -182,9 +184,15 @@ class ProductsCrud extends HTMLElement {
}
list.innerHTML = "";
for (const item of filteredItems) {
this._filteredItems = filteredItems; // Guardar referencia para Shift+Click
for (let i = 0; i < filteredItems.length; i++) {
const item = filteredItems[i];
const el = document.createElement("div");
el.className = "item" + (this.selected?.woo_product_id === item.woo_product_id ? " active" : "");
const isSelected = this.selectedItems.some(s => s.woo_product_id === item.woo_product_id);
const isSingleSelected = isSelected && this.selectedItems.length === 1;
el.className = "item" + (isSelected ? " selected" : "") + (isSingleSelected ? " active" : "");
el.dataset.index = i;
const price = item.price != null ? `$${Number(item.price).toLocaleString()}` : "—";
const sku = item.sku || "—";
@@ -192,9 +200,13 @@ class ProductsCrud extends HTMLElement {
const stockBadge = stock === "instock"
? `<span class="badge stock">En stock</span>`
: `<span class="badge nostock">Sin stock</span>`;
// Mostrar unidad actual si está definida
const unit = item.sell_unit || item.payload?._sell_unit_override;
const unitBadge = unit ? `<span class="badge" style="background:#1a3a5c;color:#7eb8e7;">${unit === 'unit' ? 'Unidad' : 'Kg'}</span>` : '';
el.innerHTML = `
<div class="item-name">${item.name || "Sin nombre"} ${stockBadge}</div>
<div class="item-name">${item.name || "Sin nombre"} ${stockBadge} ${unitBadge}</div>
<div class="item-meta">
<span class="item-price">${price}</span> ·
SKU: ${sku} ·
@@ -202,30 +214,78 @@ class ProductsCrud extends HTMLElement {
</div>
`;
el.onclick = () => {
this.selected = item;
this.renderList();
this.renderDetail();
// Scroll detail panel to top
const detail = this.shadowRoot.getElementById("detail");
if (detail) detail.scrollTop = 0;
};
el.onclick = (e) => this.handleItemClick(e, item, i);
list.appendChild(el);
}
}
handleItemClick(e, item, index) {
if (e.shiftKey && this.lastClickedIndex >= 0) {
// Shift+Click: seleccionar rango
const start = Math.min(this.lastClickedIndex, index);
const end = Math.max(this.lastClickedIndex, index);
for (let i = start; i <= end; i++) {
const rangeItem = this._filteredItems[i];
if (!this.selectedItems.some(s => s.woo_product_id === rangeItem.woo_product_id)) {
this.selectedItems.push(rangeItem);
}
}
} else if (e.ctrlKey || e.metaKey) {
// Ctrl+Click: toggle individual
const idx = this.selectedItems.findIndex(s => s.woo_product_id === item.woo_product_id);
if (idx >= 0) {
this.selectedItems.splice(idx, 1);
} else {
this.selectedItems.push(item);
}
} else {
// Click normal: selección única
this.selectedItems = [item];
}
this.lastClickedIndex = index;
this.renderList();
this.renderDetail();
// Scroll detail panel to top
const detail = this.shadowRoot.getElementById("detail");
if (detail) detail.scrollTop = 0;
}
renderDetail() {
const detail = this.shadowRoot.getElementById("detail");
if (!this.selected) {
if (!this.selectedItems.length) {
detail.innerHTML = `<div class="detail-empty">Seleccioná un producto para ver detalles</div>`;
return;
}
const p = this.selected;
const categories = (p.payload?.categories || []).map(c => c.name).join(", ") || "—";
const attributes = (p.payload?.attributes || []).map(a => `${a.name}: ${a.options?.join(", ")}`).join("; ") || "—";
// Si hay múltiples seleccionados, mostrar vista de edición masiva
if (this.selectedItems.length > 1) {
this.renderMultiDetail();
return;
}
const p = this.selectedItems[0];
// Categorías: pueden estar en p.categories (string JSON) o p.payload.categories (array)
let categoriesArray = [];
if (p.categories) {
try {
const cats = typeof p.categories === 'string' ? JSON.parse(p.categories) : p.categories;
categoriesArray = Array.isArray(cats) ? cats.map(c => c.name || c) : [String(cats)];
} catch { categoriesArray = [String(p.categories)]; }
} else if (p.payload?.categories) {
categoriesArray = p.payload.categories.map(c => c.name || c);
}
const categoriesText = categoriesArray.join(", ");
const attributes = (p.attributes_normalized || p.payload?.attributes || []).map(a => `${a.name}: ${a.options?.join(", ")}`).join("; ") || "—";
// Determinar unidad actual (de payload o inferida)
const currentUnit = p.sell_unit || p.payload?._sell_unit_override || this.inferUnit(p);
detail.innerHTML = `
<div class="field">
@@ -245,13 +305,31 @@ class ProductsCrud extends HTMLElement {
<div class="field-value">${p.price != null ? `$${Number(p.price).toLocaleString()} ${p.currency || ""}` : "—"}</div>
</div>
<div class="field">
<label>Categorías</label>
<div class="field-value">${categories}</div>
<label>Unidad de venta</label>
<div style="display:flex;gap:8px;align-items:center;">
<select id="sellUnit" style="background:#0f1520;color:#e7eef7;border:1px solid #253245;border-radius:8px;padding:8px 12px;font-size:13px;">
<option value="kg" ${currentUnit === "kg" ? "selected" : ""}>Por peso (kg)</option>
<option value="unit" ${currentUnit === "unit" ? "selected" : ""}>Por unidad</option>
</select>
</div>
<div style="font-size:11px;color:#8aa0b5;margin-top:4px;">
Define si este producto se vende por peso o por unidad
</div>
</div>
<div class="field">
<label>Categorías (separadas por coma)</label>
<input type="text" id="categoriesInput" value="${categoriesText}" placeholder="ej: Carnes, Vacuno, Premium" style="width:100%;" />
<div style="font-size:11px;color:#8aa0b5;margin-top:4px;">
Categorías del producto, separadas por coma
</div>
</div>
<div class="field">
<label>Atributos</label>
<div class="field-value">${attributes}</div>
</div>
<div class="field">
<button id="saveProduct" style="width:100%;padding:10px;">Guardar cambios</button>
</div>
<div class="field">
<label>Última actualización</label>
<div class="field-value">${p.refreshed_at ? new Date(p.refreshed_at).toLocaleString() : "—"}</div>
@@ -261,6 +339,136 @@ class ProductsCrud extends HTMLElement {
<div class="field-value json">${JSON.stringify(p.payload || {}, null, 2)}</div>
</div>
`;
// Bind save button
this.shadowRoot.getElementById("saveProduct").onclick = () => this.saveProduct();
}
async saveProduct() {
if (this.selectedItems.length !== 1) return;
const p = this.selectedItems[0];
const btn = this.shadowRoot.getElementById("saveProduct");
const sellUnitSelect = this.shadowRoot.getElementById("sellUnit");
const categoriesInput = this.shadowRoot.getElementById("categoriesInput");
const sell_unit = sellUnitSelect.value;
const categories = categoriesInput.value.split(",").map(s => s.trim()).filter(Boolean);
btn.disabled = true;
btn.textContent = "Guardando...";
try {
await api.updateProduct(p.woo_product_id, { sell_unit, categories });
// Actualizar localmente
p.sell_unit = sell_unit;
p.categories = JSON.stringify(categories.map(name => ({ name })));
const idx = this.items.findIndex(i => i.woo_product_id === p.woo_product_id);
if (idx >= 0) {
this.items[idx].sell_unit = sell_unit;
this.items[idx].categories = p.categories;
}
btn.textContent = "Guardado!";
this.renderList();
setTimeout(() => { btn.textContent = "Guardar cambios"; btn.disabled = false; }, 1500);
} catch (e) {
console.error("Error saving product:", e);
alert("Error guardando: " + (e.message || e));
btn.textContent = "Guardar cambios";
btn.disabled = false;
}
}
renderMultiDetail() {
const detail = this.shadowRoot.getElementById("detail");
const count = this.selectedItems.length;
const names = this.selectedItems.slice(0, 5).map(p => p.name).join(", ");
const moreText = count > 5 ? ` y ${count - 5} más...` : "";
detail.innerHTML = `
<div class="field">
<label>Productos seleccionados</label>
<div class="field-value" style="color:#2ecc71;font-weight:600;">${count} productos</div>
<div style="font-size:11px;color:#8aa0b5;margin-top:4px;">${names}${moreText}</div>
</div>
<div class="field">
<label>Unidad de venta (para todos)</label>
<div style="display:flex;gap:8px;align-items:center;">
<select id="sellUnit" style="background:#0f1520;color:#e7eef7;border:1px solid #253245;border-radius:8px;padding:8px 12px;font-size:13px;flex:1;">
<option value="kg">Por peso (kg)</option>
<option value="unit">Por unidad</option>
</select>
<button id="saveUnit" style="padding:8px 16px;">Guardar para ${count}</button>
</div>
<div style="font-size:11px;color:#8aa0b5;margin-top:4px;">
Se aplicará a todos los productos seleccionados
</div>
</div>
<div class="field">
<button id="clearSelection" class="secondary" style="width:100%;">Limpiar selección</button>
</div>
`;
this.shadowRoot.getElementById("saveUnit").onclick = () => this.saveProductUnit();
this.shadowRoot.getElementById("clearSelection").onclick = () => {
this.selectedItems = [];
this.lastClickedIndex = -1;
this.renderList();
this.renderDetail();
};
}
inferUnit(p) {
const name = String(p.name || "").toLowerCase();
const cats = (p.payload?.categories || []).map(c => String(c.name || c.slug || "").toLowerCase());
const allText = name + " " + cats.join(" ");
// Productos que típicamente se venden por unidad
if (/chimichurri|provoleta|queso|pan|salsa|aderezo|condimento|especias?|vino|vinos|bebida|cerveza|gaseosa|whisky|ron|gin|vodka|fernet/i.test(allText)) {
return "unit";
}
return "kg";
}
async saveProductUnit() {
if (!this.selectedItems.length) return;
const select = this.shadowRoot.getElementById("sellUnit");
const btn = this.shadowRoot.getElementById("saveUnit");
const unit = select.value;
const count = this.selectedItems.length;
btn.disabled = true;
btn.textContent = "Guardando...";
try {
// IDs de todos los productos seleccionados
const wooProductIds = this.selectedItems.map(p => p.woo_product_id);
// Un solo request para todos
await api.updateProductsUnit(wooProductIds, { sell_unit: unit });
// Actualizar localmente
for (const p of this.selectedItems) {
p.sell_unit = unit;
const idx = this.items.findIndex(i => i.woo_product_id === p.woo_product_id);
if (idx >= 0) this.items[idx].sell_unit = unit;
}
btn.textContent = `Guardado ${count}!`;
this.renderList(); // Actualizar badges en lista
setTimeout(() => {
btn.textContent = count > 1 ? `Guardar para ${count}` : "Guardar";
btn.disabled = false;
}, 1500);
} catch (e) {
console.error("Error saving product unit:", e);
alert("Error guardando: " + (e.message || e));
btn.textContent = count > 1 ? `Guardar para ${count}` : "Guardar";
btn.disabled = false;
}
}
}

View File

@@ -9,12 +9,20 @@ class RecommendationsCrud extends HTMLElement {
this.loading = false;
this.searchQuery = "";
this.editMode = null; // 'create' | 'edit' | null
// Cache de productos para el selector
this.allProducts = [];
this.productsLoaded = false;
// Productos seleccionados en el formulario
this.selectedTriggerProducts = [];
this.selectedRecommendedProducts = [];
this.shadowRoot.innerHTML = `
<style>
:host { display:block; height:100%; padding:16px; overflow:hidden; }
* { box-sizing:border-box; font-family:system-ui,Segoe UI,Roboto,Arial; }
.container { display:grid; grid-template-columns:1fr 450px; gap:16px; height:100%; }
.container { display:grid; grid-template-columns:1fr 500px; gap:16px; height:100%; }
.panel { background:#121823; border:1px solid #1e2a3a; border-radius:10px; padding:16px; overflow:hidden; display:flex; flex-direction:column; }
.panel-title { font-size:14px; font-weight:700; color:#8aa0b5; text-transform:uppercase; letter-spacing:.4px; margin-bottom:12px; }
@@ -55,6 +63,38 @@ class RecommendationsCrud extends HTMLElement {
.toggle { display:flex; align-items:center; gap:8px; cursor:pointer; }
.toggle input { width:auto; }
/* Product selector styles */
.product-selector { position:relative; }
.product-search { margin-bottom:8px; }
.product-dropdown {
position:absolute; top:100%; left:0; right:0; z-index:100;
background:#0f1520; border:1px solid #253245; border-radius:8px;
max-height:200px; overflow-y:auto; display:none;
}
.product-dropdown.open { display:block; }
.product-option {
padding:8px 12px; cursor:pointer; font-size:13px; color:#e7eef7;
display:flex; justify-content:space-between; align-items:center;
}
.product-option:hover { background:#1a2535; }
.product-option.selected { background:#1a3a5c; }
.product-option .price { font-size:11px; color:#8aa0b5; }
.selected-products { display:flex; flex-wrap:wrap; gap:6px; margin-top:8px; min-height:30px; }
.product-chip {
display:inline-flex; align-items:center; gap:4px;
background:#253245; color:#e7eef7; padding:4px 8px 4px 12px;
border-radius:999px; font-size:12px;
}
.product-chip .remove {
cursor:pointer; width:16px; height:16px; border-radius:50%;
background:#e74c3c; color:#fff; font-size:10px;
display:flex; align-items:center; justify-content:center;
}
.product-chip .remove:hover { background:#c0392b; }
.empty-hint { color:#8aa0b5; font-size:12px; font-style:italic; }
</style>
<div class="container">
@@ -72,7 +112,7 @@ class RecommendationsCrud extends HTMLElement {
<div class="panel">
<div class="panel-title" id="formTitle">Detalle</div>
<div class="form" id="form">
<div class="form-empty">Seleccioná una regla o creá una nueva</div>
<div class="form-empty">Selecciona una regla o crea una nueva</div>
</div>
</div>
</div>
@@ -89,6 +129,19 @@ class RecommendationsCrud extends HTMLElement {
this.shadowRoot.getElementById("newBtn").onclick = () => this.showCreateForm();
this.load();
this.loadProducts();
}
async loadProducts() {
if (this.productsLoaded) return;
try {
const data = await api.products({ limit: 2000 });
this.allProducts = (data.items || []).filter(p => p.stock_status === "instock");
this.productsLoaded = true;
} catch (e) {
console.error("Error loading products:", e);
this.allProducts = [];
}
}
async load() {
@@ -108,6 +161,11 @@ class RecommendationsCrud extends HTMLElement {
}
}
getProductName(id) {
const p = this.allProducts.find(x => x.woo_product_id === id);
return p?.name || `Producto #${id}`;
}
renderList() {
const list = this.shadowRoot.getElementById("list");
@@ -126,10 +184,15 @@ class RecommendationsCrud extends HTMLElement {
const el = document.createElement("div");
el.className = "item" + (this.selected?.id === item.id ? " active" : "");
const trigger = item.trigger || {};
const keywords = (trigger.keywords || []).join(", ") || "—";
const queries = (item.queries || []).slice(0, 3).join(", ");
const hasMore = (item.queries || []).length > 3;
// Mostrar productos trigger
const triggerIds = item.trigger_product_ids || [];
const triggerNames = triggerIds.slice(0, 3).map(id => this.getProductName(id)).join(", ");
const triggerMore = triggerIds.length > 3 ? ` (+${triggerIds.length - 3})` : "";
// Mostrar productos recomendados
const recoIds = item.recommended_product_ids || [];
const recoNames = recoIds.slice(0, 3).map(id => this.getProductName(id)).join(", ");
const recoMore = recoIds.length > 3 ? ` (+${recoIds.length - 3})` : "";
el.innerHTML = `
<div class="item-key">
@@ -137,13 +200,15 @@ class RecommendationsCrud extends HTMLElement {
<span class="badge ${item.active ? "active" : "inactive"}">${item.active ? "Activa" : "Inactiva"}</span>
<span class="badge priority">P: ${item.priority}</span>
</div>
<div class="item-trigger">Keywords: ${keywords}</div>
<div class="item-queries">→ ${queries}${hasMore ? "..." : ""}</div>
<div class="item-trigger">Cuando piden: ${triggerNames || "—"}${triggerMore}</div>
<div class="item-queries">→ Recomendar: ${recoNames || "—"}${recoMore}</div>
`;
el.onclick = () => {
this.selected = item;
this.editMode = "edit";
this.selectedTriggerProducts = [...(item.trigger_product_ids || [])];
this.selectedRecommendedProducts = [...(item.recommended_product_ids || [])];
this.renderList();
this.renderForm();
};
@@ -155,6 +220,8 @@ class RecommendationsCrud extends HTMLElement {
showCreateForm() {
this.selected = null;
this.editMode = "create";
this.selectedTriggerProducts = [];
this.selectedRecommendedProducts = [];
this.renderList();
this.renderForm();
}
@@ -165,7 +232,7 @@ class RecommendationsCrud extends HTMLElement {
if (!this.editMode) {
title.textContent = "Detalle";
form.innerHTML = `<div class="form-empty">Seleccioná una regla o creá una nueva</div>`;
form.innerHTML = `<div class="form-empty">Selecciona una regla o crea una nueva</div>`;
return;
}
@@ -173,31 +240,20 @@ class RecommendationsCrud extends HTMLElement {
title.textContent = isCreate ? "Nueva Regla" : "Editar Regla";
const rule_key = this.selected?.rule_key || "";
const trigger = this.selected?.trigger || {};
const queries = this.selected?.queries || [];
const ask_slots = this.selected?.ask_slots || [];
const active = this.selected?.active !== false;
const priority = this.selected?.priority || 100;
// Convert arrays to comma-separated strings for display
const triggerKeywords = (trigger.keywords || []).join(", ");
const queriesText = (queries || []).join(", ");
const askSlotsText = Array.isArray(ask_slots)
? ask_slots.map(s => typeof s === "string" ? s : s?.slot || s?.keyword || "").filter(Boolean).join(", ")
: "";
form.innerHTML = `
<div class="field">
<label>Rule Key (identificador unico)</label>
<input type="text" id="ruleKeyInput" value="${rule_key}" ${isCreate ? "" : "disabled"} placeholder="ej: asado_core, alcohol_yes" />
<div class="field-hint">Sin espacios, en minusculas con guiones bajos</div>
<label>Nombre de la regla</label>
<input type="text" id="ruleKeyInput" value="${rule_key}" ${isCreate ? "" : "disabled"} placeholder="ej: asado_recos, vinos_con_carne" />
<div class="field-hint">Identificador unico, sin espacios</div>
</div>
<div class="field-row">
<div class="field">
<label>Prioridad</label>
<input type="number" id="priorityInput" value="${priority}" min="1" max="1000" />
<div class="field-hint">Mayor = primero</div>
</div>
<div class="field">
<label>Estado</label>
@@ -209,21 +265,23 @@ class RecommendationsCrud extends HTMLElement {
</div>
<div class="field">
<label>Trigger (palabras clave)</label>
<textarea id="triggerInput" placeholder="asado, parrilla, carne, bife...">${triggerKeywords}</textarea>
<div class="field-hint">Palabras que activan esta regla, separadas por coma</div>
<label>Cuando el cliente pide...</label>
<div class="product-selector" id="triggerSelector">
<input type="text" class="product-search" id="triggerSearch" placeholder="Buscar producto..." />
<div class="product-dropdown" id="triggerDropdown"></div>
<div class="selected-products" id="triggerSelected"></div>
</div>
<div class="field-hint">Productos que activan esta recomendacion</div>
</div>
<div class="field">
<label>Productos a recomendar</label>
<textarea id="queriesInput" placeholder="provoleta, chimichurri, ensalada...">${queriesText}</textarea>
<div class="field-hint">Productos a buscar cuando se activa la regla, separados por coma</div>
</div>
<div class="field">
<label>Preguntar sobre... (opcional)</label>
<textarea id="askSlotsInput" placeholder="achuras, cerdo, vino...">${askSlotsText}</textarea>
<div class="field-hint">El bot preguntara al usuario sobre estos temas de forma natural</div>
<label>Recomendar estos productos...</label>
<div class="product-selector" id="recoSelector">
<input type="text" class="product-search" id="recoSearch" placeholder="Buscar producto..." />
<div class="product-dropdown" id="recoDropdown"></div>
<div class="selected-products" id="recoSelected"></div>
</div>
<div class="field-hint">Productos a sugerir al cliente</div>
</div>
<div class="actions">
@@ -233,48 +291,145 @@ class RecommendationsCrud extends HTMLElement {
</div>
`;
// Setup event handlers
this.shadowRoot.getElementById("saveBtn").onclick = () => this.save();
this.shadowRoot.getElementById("cancelBtn").onclick = () => this.cancel();
if (!isCreate) {
this.shadowRoot.getElementById("deleteBtn").onclick = () => this.delete();
}
// Setup product selectors
this.setupProductSelector("trigger", this.selectedTriggerProducts);
this.setupProductSelector("reco", this.selectedRecommendedProducts);
}
parseCommaSeparated(str) {
return String(str || "")
.split(",")
.map(s => s.trim().toLowerCase())
.filter(Boolean);
setupProductSelector(type, selectedIds) {
const searchInput = this.shadowRoot.getElementById(`${type}Search`);
const dropdown = this.shadowRoot.getElementById(`${type}Dropdown`);
const selectedContainer = this.shadowRoot.getElementById(`${type}Selected`);
const renderSelected = () => {
const ids = type === "trigger" ? this.selectedTriggerProducts : this.selectedRecommendedProducts;
if (!ids.length) {
selectedContainer.innerHTML = `<span class="empty-hint">Ningun producto seleccionado</span>`;
return;
}
selectedContainer.innerHTML = ids.map(id => {
const name = this.getProductName(id);
return `<span class="product-chip" data-id="${id}">${name}<span class="remove">×</span></span>`;
}).join("");
selectedContainer.querySelectorAll(".remove").forEach(btn => {
btn.onclick = (e) => {
e.stopPropagation();
const id = parseInt(btn.parentElement.dataset.id, 10);
if (type === "trigger") {
this.selectedTriggerProducts = this.selectedTriggerProducts.filter(x => x !== id);
} else {
this.selectedRecommendedProducts = this.selectedRecommendedProducts.filter(x => x !== id);
}
renderSelected();
renderDropdown(searchInput.value);
};
});
};
const renderDropdown = (query) => {
const q = (query || "").toLowerCase().trim();
const selectedSet = new Set(type === "trigger" ? this.selectedTriggerProducts : this.selectedRecommendedProducts);
let filtered = this.allProducts;
if (q) {
filtered = filtered.filter(p => p.name.toLowerCase().includes(q));
}
filtered = filtered.slice(0, 50); // Limit for performance
if (!q && !filtered.length) {
dropdown.classList.remove("open");
return;
}
dropdown.innerHTML = filtered.map(p => {
const isSelected = selectedSet.has(p.woo_product_id);
return `
<div class="product-option ${isSelected ? "selected" : ""}" data-id="${p.woo_product_id}">
<span>${p.name}</span>
<span class="price">$${p.price || 0}</span>
</div>
`;
}).join("");
dropdown.querySelectorAll(".product-option").forEach(opt => {
opt.onclick = () => {
const id = parseInt(opt.dataset.id, 10);
if (type === "trigger") {
if (!this.selectedTriggerProducts.includes(id)) {
this.selectedTriggerProducts.push(id);
}
} else {
if (!this.selectedRecommendedProducts.includes(id)) {
this.selectedRecommendedProducts.push(id);
}
}
searchInput.value = "";
dropdown.classList.remove("open");
renderSelected();
};
});
dropdown.classList.add("open");
};
searchInput.oninput = () => {
clearTimeout(this[`_${type}Timer`]);
this[`_${type}Timer`] = setTimeout(() => renderDropdown(searchInput.value), 150);
};
searchInput.onfocus = () => {
if (searchInput.value || this.allProducts.length) {
renderDropdown(searchInput.value);
}
};
// Close dropdown on outside click
document.addEventListener("click", (e) => {
if (!this.shadowRoot.getElementById(`${type}Selector`)?.contains(e.target)) {
dropdown.classList.remove("open");
}
});
renderSelected();
}
async save() {
const ruleKey = this.shadowRoot.getElementById("ruleKeyInput").value.trim().toLowerCase().replace(/\s+/g, "_");
const priority = parseInt(this.shadowRoot.getElementById("priorityInput").value, 10) || 100;
const active = this.shadowRoot.getElementById("activeInput").checked;
// Parse comma-separated values into arrays
const triggerKeywords = this.parseCommaSeparated(this.shadowRoot.getElementById("triggerInput").value);
const queries = this.parseCommaSeparated(this.shadowRoot.getElementById("queriesInput").value);
const askSlotsKeywords = this.parseCommaSeparated(this.shadowRoot.getElementById("askSlotsInput").value);
if (!ruleKey) {
alert("El rule_key es requerido");
alert("El nombre de la regla es requerido");
return;
}
// Build trigger object with keywords array
const trigger = triggerKeywords.length > 0 ? { keywords: triggerKeywords } : {};
// Ask slots as simple array of keywords (LLM will formulate questions naturally)
const ask_slots = askSlotsKeywords;
if (!this.selectedTriggerProducts.length) {
alert("Selecciona al menos un producto trigger");
return;
}
if (!this.selectedRecommendedProducts.length) {
alert("Selecciona al menos un producto para recomendar");
return;
}
const data = {
rule_key: ruleKey,
trigger,
queries,
ask_slots,
trigger: {}, // Legacy field, keep empty
queries: [], // Legacy field, keep empty
ask_slots: [],
active,
priority,
trigger_product_ids: this.selectedTriggerProducts,
recommended_product_ids: this.selectedRecommendedProducts,
};
try {
@@ -312,6 +467,8 @@ class RecommendationsCrud extends HTMLElement {
cancel() {
this.editMode = null;
this.selected = null;
this.selectedTriggerProducts = [];
this.selectedRecommendedProducts = [];
this.renderList();
this.renderForm();
}

View File

@@ -53,6 +53,8 @@ class RunTimeline extends HTMLElement {
this.shadowRoot.getElementById("refresh").onclick = () => this.loadMessages();
this._unsubSel = on("ui:selectedChat", async ({ chat_id }) => {
// Si es el mismo chat, no recargar (para no borrar burbujas optimistas)
if (this.chatId === chat_id) return;
this.chatId = chat_id;
await this.loadMessages();
});
@@ -76,7 +78,13 @@ class RunTimeline extends HTMLElement {
// Listen for optimistic messages (show bubble immediately before API response)
this._unsubOptimistic = on("message:optimistic", (msg) => {
if (!this.chatId || msg.chat_id !== this.chatId) return;
// Si no hay chatId seteado, setearlo al del mensaje
if (!this.chatId) {
this.chatId = msg.chat_id;
this.shadowRoot.getElementById("chat").textContent = msg.chat_id;
this.shadowRoot.getElementById("meta").textContent = "Nueva conversación";
}
if (msg.chat_id !== this.chatId) return;
this.addOptimisticBubble(msg);
});
}
@@ -135,8 +143,20 @@ class RunTimeline extends HTMLElement {
meta.textContent = `Mostrando historial (últimos ${this.items.length}).`;
count.textContent = this.items.length ? `${this.items.length} msgs` : "";
// Capturar info de burbujas optimistas antes de limpiar
const optimisticBubbles = [...log.querySelectorAll('.bubble[data-message-id^="optimistic-"]')];
const optimisticTexts = optimisticBubbles.map(b => {
const textEl = b.querySelector("div:not(.name):not(.meta)");
return (textEl ? textEl.textContent : "").trim().toLowerCase();
});
log.innerHTML = "";
// Obtener textos de mensajes IN del servidor (normalizados para comparación)
const serverUserTexts = this.items
.filter(m => m.direction === "in")
.map(m => (m.text || "").trim().toLowerCase());
for (const m of this.items) {
const who = m.direction === "in" ? "user" : "bot";
const isErr = this.isErrorMsg(m);
@@ -164,8 +184,23 @@ class RunTimeline extends HTMLElement {
log.appendChild(bubble);
}
// auto-scroll
log.scrollTop = log.scrollHeight;
// Re-agregar burbujas optimistas SOLO si su texto no está ya en los mensajes del servidor
// Comparación case-insensitive y trimmed
let addedOptimistic = false;
for (let i = 0; i < optimisticBubbles.length; i++) {
const optText = optimisticTexts[i];
// Si el texto ya existe en un mensaje del servidor, no re-agregar
if (serverUserTexts.includes(optText)) {
continue;
}
log.appendChild(optimisticBubbles[i]);
addedOptimistic = true;
}
// auto-scroll solo si agregamos burbujas optimistas nuevas
if (addedOptimistic) {
log.scrollTop = log.scrollHeight;
}
requestAnimationFrame(() => this.emitLayout());
this.bindScroll(log);
@@ -193,29 +228,7 @@ class RunTimeline extends HTMLElement {
};
});
emit("ui:bubblesLayout", { chat_id: this.chatId, items });
// #region agent log
fetch("http://127.0.0.1:7242/ingest/86c7b1cd-c414-4eae-852c-08e57e562b3b", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
sessionId: "debug-session",
runId: "pre-fix",
hypothesisId: "H15",
location: "run-timeline.js:180",
message: "bubbles_layout",
data: {
count: items.length,
chat_id: this.chatId || null,
scroll_height: log.scrollHeight,
client_height: log.clientHeight,
host_height: this.getBoundingClientRect().height,
box_height: box ? box.getBoundingClientRect().height : null,
},
timestamp: Date.now(),
}),
}).catch(() => {});
// #endregion
}
}
highlightMessage(message_id) {
const log = this.shadowRoot.getElementById("log");
@@ -275,7 +288,12 @@ class RunTimeline extends HTMLElement {
bubble.appendChild(metaEl);
log.appendChild(bubble);
log.scrollTop = log.scrollHeight;
// Solo hacer scroll si el usuario ya estaba cerca del final (dentro de 100px)
const wasNearBottom = (log.scrollHeight - log.scrollTop - log.clientHeight) < 100;
if (wasNearBottom) {
log.scrollTop = log.scrollHeight;
}
// Emit layout update
requestAnimationFrame(() => this.emitLayout());

View File

@@ -9,6 +9,7 @@ class UsersCrud extends HTMLElement {
this.selected = null;
this.loading = false;
this.searchQuery = "";
this.wooFilter = false;
this.shadowRoot.innerHTML = `
<style>
@@ -49,7 +50,9 @@ class UsersCrud extends HTMLElement {
.loading { text-align:center; padding:40px; color:#8aa0b5; }
.stats { display:flex; gap:16px; margin-bottom:16px; }
.stat { background:#0f1520; border:1px solid #253245; border-radius:8px; padding:12px; flex:1; text-align:center; }
.stat { background:#0f1520; border:1px solid #253245; border-radius:8px; padding:12px; flex:1; text-align:center; cursor:pointer; transition:all .15s; }
.stat:hover { border-color:#1f6feb; }
.stat.active { border-color:#1f6feb; background:#111b2a; }
.stat-value { font-size:24px; font-weight:700; color:#1f6feb; }
.stat-label { font-size:11px; color:#8aa0b5; text-transform:uppercase; margin-top:4px; }
</style>
@@ -58,11 +61,11 @@ class UsersCrud extends HTMLElement {
<div class="panel">
<div class="panel-title">Usuarios</div>
<div class="stats">
<div class="stat">
<div class="stat" id="statTotal">
<div class="stat-value" id="totalCount">—</div>
<div class="stat-label">Total</div>
</div>
<div class="stat">
<div class="stat" id="statWoo">
<div class="stat-value" id="wooCount">—</div>
<div class="stat-label">Con Woo ID</div>
</div>
@@ -92,9 +95,29 @@ class UsersCrud extends HTMLElement {
this._searchTimer = setTimeout(() => this.load(), 300);
};
// Stats click handlers
this.shadowRoot.getElementById("statTotal").onclick = () => {
this.wooFilter = false;
this.renderList();
this.updateStatStyles();
};
this.shadowRoot.getElementById("statWoo").onclick = () => {
this.wooFilter = !this.wooFilter;
this.renderList();
this.updateStatStyles();
};
this.load();
}
updateStatStyles() {
const statTotal = this.shadowRoot.getElementById("statTotal");
const statWoo = this.shadowRoot.getElementById("statWoo");
statTotal.classList.toggle("active", !this.wooFilter);
statWoo.classList.toggle("active", this.wooFilter);
}
async load() {
this.loading = true;
this.renderList();
@@ -129,13 +152,18 @@ class UsersCrud extends HTMLElement {
return;
}
if (!this.items.length) {
// Filter by woo ID if filter is active
const filteredItems = this.wooFilter
? this.items.filter(u => u.external_customer_id)
: this.items;
if (!filteredItems.length) {
list.innerHTML = `<div class="loading">No se encontraron usuarios</div>`;
return;
}
list.innerHTML = "";
for (const item of this.items) {
for (const item of filteredItems) {
const el = document.createElement("div");
el.className = "item" + (this.selected?.chat_id === item.chat_id ? " active" : "");