Commit 304590ec authored by Vũ Hoàng Anh's avatar Vũ Hoàng Anh

Add model selection and lab updates

parent 0983572f
......@@ -48,7 +48,7 @@ class CANIFAGraph:
self.collection_tools = get_collection_tools() # Vẫn lấy list name để routing
self.retrieval_tools = self.all_tools
self.llm_with_tools = self.llm.bind_tools(self.all_tools, strict=True)
self.llm_with_tools = self.llm.bind_tools(self.all_tools) # No strict: compat with both OpenAI & Gemini schemas
self.cache = InMemoryCache()
# Chain caching: avoid rebuilding ChatPromptTemplate every turn
......@@ -185,13 +185,12 @@ class CANIFAGraph:
return self.build()
# --- Singleton & Public API ---
_instance: list[CANIFAGraph | None] = [None]
# --- Per-model Instance Cache & Public API ---
_instances: dict[str, CANIFAGraph] = {}
def build_graph(config: AgentConfig | None = None, llm: BaseChatModel | None = None, tools: list | None = None) -> Any:
"""Get compiled graph (Singleton usage)."""
# Use singleton to avoid rebuilding graph on every request
"""Get compiled graph (cached per model)."""
manager = get_graph_manager(config, llm, tools)
return manager.build()
......@@ -199,38 +198,33 @@ def build_graph(config: AgentConfig | None = None, llm: BaseChatModel | None = N
def get_graph_manager(
config: AgentConfig | None = None, llm: BaseChatModel | None = None, tools: list | None = None
) -> CANIFAGraph:
"""Get CANIFAGraph instance (Auto-rebuild if model config changes).
"""Get CANIFAGraph instance per model_name.
Prompt is now fetched dynamically per request from Langfuse,
so no need to rebuild graph when prompt changes.
Each model gets its own cached graph instance, enabling true parallel
execution when Lab sends requests to different models simultaneously.
"""
# 1. New Instance if Empty
if _instance[0] is None:
_instance[0] = CANIFAGraph(config, llm, tools)
logger.info(f"✨ Graph Created: {_instance[0].config.model_name} (prompts from Langfuse)")
return _instance[0]
effective_config = config or get_config()
model_key = effective_config.model_name
# 2. Check for Model Config Changes only
is_model_changed = config and config.model_name != _instance[0].config.model_name
if model_key not in _instances:
_instances[model_key] = CANIFAGraph(effective_config, llm, tools)
logger.info(f"✨ Graph Created: {model_key} (prompts from Langfuse) | Total cached: {len(_instances)}")
if is_model_changed:
logger.info(f"🔄 Rebuilding Graph: Model ({_instance[0].config.model_name}->{config.model_name})")
_instance[0] = CANIFAGraph(config, llm, tools)
return _instance[0]
return _instance[0]
return _instances[model_key]
def reset_graph() -> None:
"""Reset singleton for testing."""
_instance[0] = None
"""Reset all cached instances (for testing)."""
_instances.clear()
def reset_chain_cache() -> None:
"""Reset only the cached chain (when prompt changes).
"""Reset only the cached chain for all instances (when prompt changes).
Keeps the graph/LLM/tools intact, only forces chain rebuild on next request.
"""
if _instance[0] is not None:
_instance[0]._cached_chain = None
_instance[0]._cached_prompt_hash = None
logger.info("🔄 Chain cache cleared — will rebuild on next request")
for model_key, inst in _instances.items():
inst._cached_chain = None
inst._cached_prompt_hash = None
if _instances:
logger.info(f"🔄 Chain cache cleared for {len(_instances)} model(s) — will rebuild on next request")
......@@ -14,6 +14,7 @@ class QueryRequest(BaseModel):
user_query: str
images: list[str] | None = None
image_analysis: dict[str, Any] | None = None
model_name: str | None = None # Override model per-request (Lab mode)
class AgentState(TypedDict):
......
......@@ -31,7 +31,7 @@ from agent.prompt_utils import read_tool_prompt
class SearchItem(BaseModel):
model_config = {"extra": "ignore"} # Gemini may send extra fields
model_config = {"extra": "ignore", "json_schema_extra": {"additionalProperties": False}} # ignore for Gemini compat; additionalProperties for OpenAI strict mode
# ====== SEARCH TEXT (optional fallback) ======
description: str | None = Field(
......@@ -121,7 +121,7 @@ class SearchItem(BaseModel):
class MultiSearchParams(BaseModel):
model_config = {"extra": "ignore"} # Gemini may send extra fields
model_config = {"extra": "ignore", "json_schema_extra": {"additionalProperties": False}} # ignore for Gemini compat; additionalProperties for OpenAI strict mode
searches: list[SearchItem] = Field(description="Danh sách các truy vấn tìm kiếm")
......
......@@ -106,10 +106,12 @@ async def fashion_qa_chat_dev(request: Request, req: QueryRequest, background_ta
try:
# DEV MODE: Return ai_response + products immediately
# Lab mode: allow model override from request body
effective_model = req.model_name or DEFAULT_MODEL
result = await chat_controller(
query=req.user_query,
background_tasks=background_tasks,
model_name=DEFAULT_MODEL,
model_name=effective_model,
images=req.images,
identity_key=str(identity_id),
return_user_insight=False,
......
......@@ -107,6 +107,14 @@
color: #fff;
}
select {
padding: 8px 12px;
border: 1px solid #555;
border-radius: 6px;
background: #3d3d3d;
color: #fff;
}
button {
padding: 8px 16px;
background: #007acc;
......@@ -962,58 +970,74 @@
background: rgba(13, 148, 136, 0.1);
}
/* Auto-cart banner */
.auto-cart-banner {
background: linear-gradient(135deg, #0d9488 0%, #065f46 100%);
border-radius: 12px;
padding: 14px 18px;
/* ═══ Auto-cart Panel (Size/Color Selection) ═══ */
.auto-cart-panel {
background: linear-gradient(135deg, #1a3a36 0%, #132b28 100%);
border: 1px solid #0d9488;
border-radius: 14px;
padding: 16px;
margin-top: 14px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
animation: slideInUp 0.4s ease;
box-shadow: 0 4px 15px rgba(13, 148, 136, 0.3);
box-shadow: 0 6px 20px rgba(13, 148, 136, 0.25);
}
@keyframes slideInUp {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
}
.auto-cart-banner-text {
color: #fff;
font-size: 0.9em;
font-weight: 500;
line-height: 1.4;
}
.auto-cart-banner-text .auto-cart-count {
font-weight: 700;
font-size: 1.1em;
}
.auto-cart-btn {
background: #fff;
color: #065f46;
border: none;
padding: 10px 20px;
border-radius: 8px;
font-weight: 700;
font-size: 0.9em;
cursor: pointer;
white-space: nowrap;
transition: all 0.2s;
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
}
.auto-cart-btn:hover {
transform: scale(1.05);
box-shadow: 0 4px 12px rgba(0,0,0,0.25);
}
.auto-cart-btn:active {
transform: scale(0.97);
}
.auto-cart-btn.done {
background: #10b981;
color: #fff;
pointer-events: none;
}
.auto-cart-header {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 14px; padding-bottom: 10px;
border-bottom: 1px solid rgba(13, 148, 136, 0.3);
}
.auto-cart-header-text {
color: #5eead4; font-size: 0.95em; font-weight: 600;
}
.auto-cart-header-text .count { background: #0d9488; color: #fff; padding: 2px 8px; border-radius: 10px; font-size: 0.85em; margin-left: 6px; }
.auto-cart-items { display: flex; flex-direction: column; gap: 10px; margin-bottom: 14px; }
.auto-cart-item {
display: flex; gap: 12px; padding: 10px 12px;
background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.08);
border-radius: 10px; transition: all 0.2s;
}
.auto-cart-item:hover { background: rgba(255,255,255,0.07); border-color: rgba(13,148,136,0.3); }
.auto-cart-item img {
width: 60px; height: 60px; border-radius: 8px; object-fit: cover;
background: #2d2d2d; flex-shrink: 0;
}
.auto-cart-item-info { flex: 1; min-width: 0; }
.auto-cart-item-name { font-size: 0.85em; color: #e0e0e0; margin-bottom: 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.auto-cart-item-price { font-size: 0.9em; color: #10b981; font-weight: 700; margin-bottom: 6px; }
.auto-cart-item-price .original { color: #888; text-decoration: line-through; font-weight: 400; margin-left: 6px; font-size: 0.85em; }
.auto-cart-selectors { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.auto-cart-selectors select {
padding: 4px 8px; border-radius: 6px;
background: #2d2d2d; color: #e0e0e0; border: 1px solid #555;
font-size: 0.8em; cursor: pointer; outline: none;
}
.auto-cart-selectors select:focus { border-color: #0d9488; }
.auto-cart-color-chips { display: flex; gap: 4px; align-items: center; }
.auto-cart-color-chip {
padding: 3px 10px; border-radius: 12px; font-size: 0.75em;
background: #3d3d3d; color: #ccc; border: 1px solid #555;
cursor: pointer; transition: all 0.2s; white-space: nowrap;
}
.auto-cart-color-chip:hover { border-color: #0d9488; color: #0d9488; }
.auto-cart-color-chip.selected { background: rgba(13,148,136,0.2); border-color: #0d9488; color: #5eead4; }
.auto-cart-footer {
display: flex; align-items: center; justify-content: space-between;
padding-top: 12px; border-top: 1px solid rgba(13, 148, 136, 0.3);
}
.auto-cart-total { color: #5eead4; font-size: 0.85em; }
.auto-cart-total .amount { font-weight: 700; font-size: 1.1em; color: #10b981; }
.auto-cart-add-btn {
background: linear-gradient(135deg, #0d9488 0%, #10b981 100%);
color: #fff; border: none; padding: 10px 22px; border-radius: 8px;
font-weight: 700; font-size: 0.9em; cursor: pointer;
transition: all 0.2s; box-shadow: 0 3px 10px rgba(13,148,136,0.3);
}
.auto-cart-add-btn:hover { transform: scale(1.04); box-shadow: 0 5px 15px rgba(13,148,136,0.4); }
.auto-cart-add-btn:active { transform: scale(0.97); }
.auto-cart-add-btn.done { background: #10b981; pointer-events: none; }
/* Per-product action buttons */
.product-actions {
......@@ -1417,7 +1441,10 @@
<h1>🧪 Canifa AI <span
style="background:#fbbf24;color:#1e1e1e;padding:2px 10px;border-radius:6px;font-size:0.6em;vertical-align:middle;margin-left:8px;font-weight:700;letter-spacing:1px;">DEV
EXPERIMENTAL</span></h1>
<div class="nav-links"></div>
<div class="nav-links">
<a href="/static/index.html" class="active">💬 Chat</a>
<a href="/static/lab.html">🧪 Lab</a>
</div>
</div>
<div class="main-content">
......@@ -1443,6 +1470,18 @@
<input type="text" id="accessToken" placeholder="Token (optional)" style="width: 150px;"
onblur="saveConfig()" onchange="saveConfig()">
</div>
<div style="display: flex; gap: 5px; align-items: center;">
<label style="font-size: 0.8em; color: #aaa;">Model:</label>
<select id="modelSelect" style="width: 210px;" onchange="saveConfig()">
<option value="gemini-3.1-flash-lite-preview">Gemini 3.1 Flash Lite</option>
<option value="gpt-4.1-nano">GPT-4.1 Nano</option>
<option value="gpt-4.1-mini">GPT-4.1 Mini</option>
<option value="gpt-5.4-nano">GPT-5.4 Nano</option>
<option value="gpt-5.1-codex-mini">GPT-5.1 Codex Mini</option>
<option value="gemini-2.5-flash">Gemini 2.5 Flash</option>
<option value="gemini-2.5-pro">Gemini 2.5 Pro</option>
</select>
</div>
<!-- Action Buttons -->
<button onclick="loadHistory(true)" title="Load History">↻ History</button>
......@@ -1607,6 +1646,16 @@
let selectedToolPrompt = '';
let pendingImages = []; // 📸 Experimental: images to send with next message
const CHAT_MODELS = [
'gemini-3.1-flash-lite-preview',
'gpt-4.1-nano',
'gpt-4.1-mini',
'gpt-5.4-nano',
'gpt-5.1-codex-mini',
'gemini-2.5-flash',
'gemini-2.5-pro'
];
// ==================== IMAGE HANDLING (Experimental) ====================
function handleImageSelect(event) {
const file = event.target.files[0];
......@@ -2131,8 +2180,10 @@
const input = document.getElementById('userInput');
const deviceIdInput = document.getElementById('deviceId');
const accessTokenInput = document.getElementById('accessToken');
const modelSelect = document.getElementById('modelSelect');
const deviceId = deviceIdInput.value.trim();
const accessToken = accessTokenInput.value.trim();
const selectedModel = modelSelect.value;
const text = input.value.trim();
const sendBtn = document.getElementById('sendBtn');
const typingIndicator = document.getElementById('typingIndicator');
......@@ -2199,6 +2250,7 @@
body: JSON.stringify({
user_query: queryToSend,
device_id: deviceId,
model_name: selectedModel,
...(imagesToSend.length > 0 && { images: imagesToSend })
})
});
......@@ -2452,27 +2504,124 @@
filteredDiv.appendChild(productsContainer);
// ═══ AUTO-CART BANNER ═══
// ═══ AUTO-CART PANEL (Size/Color Selection) ═══
if (data.auto_cart && data.product_ids.length > 0) {
const banner = document.createElement('div');
banner.className = 'auto-cart-banner';
const bannerText = document.createElement('div');
bannerText.className = 'auto-cart-banner-text';
bannerText.innerHTML = `🛒 Em đã chọn <span class="auto-cart-count">${data.product_ids.length} sản phẩm</span> cho bạn!`;
banner.appendChild(bannerText);
const bannerBtn = document.createElement('button');
bannerBtn.className = 'auto-cart-btn';
bannerBtn.innerText = 'Thêm tất cả vào giỏ 🛒';
bannerBtn.onclick = () => {
const panel = document.createElement('div');
panel.className = 'auto-cart-panel';
// Header
const header = document.createElement('div');
header.className = 'auto-cart-header';
header.innerHTML = `<div class="auto-cart-header-text">🛒 Chọn size & màu rồi thêm vào giỏ! <span class="count">${data.product_ids.length}</span></div>`;
panel.appendChild(header);
// Items container
const itemsDiv = document.createElement('div');
itemsDiv.className = 'auto-cart-items';
data.product_ids.forEach((product, idx) => {
const item = document.createElement('div');
item.className = 'auto-cart-item';
item.setAttribute('data-idx', idx);
// Thumbnail
const img = document.createElement('img');
img.src = product.thumbnail_image_url || 'https://via.placeholder.com/60?text=No+Img';
img.alt = product.name || '';
img.onerror = function() { this.src = 'https://via.placeholder.com/60?text=No+Img'; };
item.appendChild(img);
// Info section
const info = document.createElement('div');
info.className = 'auto-cart-item-info';
const name = document.createElement('div');
name.className = 'auto-cart-item-name';
name.title = product.name || '';
name.textContent = `[${product.sku}] ${product.name || ''}`;
info.appendChild(name);
const price = document.createElement('div');
price.className = 'auto-cart-item-price';
const saleP = product.sale_price || product.price || 0;
const origP = product.price || 0;
price.innerHTML = saleP > 0 ? saleP.toLocaleString('vi-VN') + 'đ' : 'Liên hệ';
if (origP > saleP && saleP > 0) {
price.innerHTML += `<span class="original">${origP.toLocaleString('vi-VN')}đ</span>`;
}
info.appendChild(price);
// Selectors row
const selectors = document.createElement('div');
selectors.className = 'auto-cart-selectors';
// Size dropdown
const sizes = product.sizes || [];
if (sizes.length > 0) {
const sizeSelect = document.createElement('select');
sizeSelect.id = `ac-size-${idx}`;
sizeSelect.innerHTML = sizes.map((s, i) => `<option value="${s}" ${i===0?'selected':''}>${s}</option>`).join('');
selectors.appendChild(sizeSelect);
}
// Color chips
const colors = product.colors || [];
if (colors.length > 0) {
const colorDiv = document.createElement('div');
colorDiv.className = 'auto-cart-color-chips';
colors.forEach((c, ci) => {
const chip = document.createElement('span');
chip.className = 'auto-cart-color-chip' + (ci === 0 ? ' selected' : '');
chip.textContent = c.color || c;
chip.setAttribute('data-color', c.color || c);
chip.setAttribute('data-color-code', c.color_code || '');
chip.onclick = () => {
colorDiv.querySelectorAll('.auto-cart-color-chip').forEach(ch => ch.classList.remove('selected'));
chip.classList.add('selected');
// Update thumbnail if color has different image
if (c.thumbnail) img.src = c.thumbnail;
};
colorDiv.appendChild(chip);
});
selectors.appendChild(colorDiv);
}
info.appendChild(selectors);
item.appendChild(info);
itemsDiv.appendChild(item);
});
panel.appendChild(itemsDiv);
// Footer with total + add button
const footer = document.createElement('div');
footer.className = 'auto-cart-footer';
const total = data.product_ids.reduce((sum, p) => sum + (p.sale_price || p.price || 0), 0);
const totalDiv = document.createElement('div');
totalDiv.className = 'auto-cart-total';
totalDiv.innerHTML = `Tổng: <span class="amount">${total > 0 ? total.toLocaleString('vi-VN') + 'đ' : 'Liên hệ'}</span>`;
footer.appendChild(totalDiv);
const addBtn = document.createElement('button');
addBtn.className = 'auto-cart-add-btn';
addBtn.innerText = '🛒 Thêm tất cả vào giỏ';
addBtn.onclick = () => {
let addedCount = 0;
data.product_ids.forEach(product => {
const exists = cartItems.find(item => item.sku === product.sku);
data.product_ids.forEach((product, idx) => {
const sizeEl = document.getElementById(`ac-size-${idx}`);
const selectedSize = sizeEl ? sizeEl.value : '';
const colorChip = panel.querySelector(`.auto-cart-item[data-idx="${idx}"] .auto-cart-color-chip.selected`);
const selectedColor = colorChip ? colorChip.getAttribute('data-color') : (product.color || '');
const cartKey = `${product.sku}-${selectedSize}-${selectedColor}`;
const exists = cartItems.find(item => item.cartKey === cartKey);
if (!exists) {
cartItems.push({
cartKey: cartKey,
sku: product.sku,
name: product.name,
size: selectedSize,
color: selectedColor,
price: product.sale_price || product.price || 0,
originalPrice: product.price || 0,
image: product.thumbnail_image_url || ''
......@@ -2482,12 +2631,13 @@
});
saveCart();
updateCartUI();
bannerBtn.className = 'auto-cart-btn done';
bannerBtn.innerText = `✅ Đã thêm ${addedCount} sản phẩm!`;
addBtn.className = 'auto-cart-add-btn done';
addBtn.innerText = `✅ Đã thêm ${addedCount} sản phẩm!`;
showCartToast(`🎉 Đã thêm ${addedCount} sản phẩm vào giỏ hàng!`);
};
banner.appendChild(bannerBtn);
filteredDiv.appendChild(banner);
footer.appendChild(addBtn);
panel.appendChild(footer);
filteredDiv.appendChild(panel);
}
}
......@@ -3014,6 +3164,7 @@
function saveConfig() {
const deviceId = document.getElementById('deviceId').value.trim();
const accessToken = document.getElementById('accessToken').value.trim();
const selectedModel = document.getElementById('modelSelect').value;
if (deviceId) {
localStorage.setItem('canifa_device_id', deviceId);
......@@ -3023,6 +3174,9 @@
} else {
localStorage.removeItem('canifa_access_token');
}
if (selectedModel) {
localStorage.setItem('canifa_chat_model', selectedModel);
}
}
// Generate UUID for device_id
......@@ -3050,6 +3204,11 @@
document.getElementById('accessToken').value = savedAccessToken;
}
const savedModel = localStorage.getItem('canifa_chat_model');
document.getElementById('modelSelect').value = CHAT_MODELS.includes(savedModel)
? savedModel
: 'gemini-3.1-flash-lite-preview';
// Auto-load history
setTimeout(() => loadHistory(true), 50);
};
......@@ -3107,8 +3266,8 @@
showCartToast(`✅ Đã thêm ${product.name.substring(0, 30)}...`);
}
function removeFromCart(sku) {
cartItems = cartItems.filter(item => item.sku !== sku);
function removeFromCart(key) {
cartItems = cartItems.filter(item => (item.cartKey || item.sku) !== key);
saveCart();
updateCartUI();
}
......@@ -3162,6 +3321,8 @@
const priceStr = price > 0 ? price.toLocaleString('vi-VN') + 'đ' : 'Liên hệ';
const origStr = (item.originalPrice && item.originalPrice > price)
? `<span style="text-decoration:line-through;color:#888;font-size:0.8em;margin-left:6px">${item.originalPrice.toLocaleString('vi-VN')}đ</span>` : '';
const sizeColorStr = [item.size, item.color].filter(Boolean).join(' · ');
const removeKey = item.cartKey || item.sku;
html += `
<div class="cart-item">
......@@ -3170,9 +3331,9 @@
<div class="cart-item-info">
<div class="cart-item-name">${item.name}</div>
<div class="cart-item-price">${priceStr}${origStr}</div>
<div class="cart-item-sku">SKU: ${item.sku}</div>
<div class="cart-item-sku">SKU: ${item.sku}${sizeColorStr ? ' · ' + sizeColorStr : ''}</div>
</div>
<button class="cart-item-remove" onclick="removeFromCart('${item.sku}')">✕</button>
<button class="cart-item-remove" onclick="removeFromCart('${removeKey}')">✕</button>
</div>`;
});
itemsContainer.innerHTML = html;
......
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>🧪 Canifa AI Lab — Multi-Model Comparison</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg-0: #f5f5f7;
--bg-1: #ffffff;
--bg-2: #f0f0f3;
--bg-3: #e8e8ed;
--bg-4: #dddde3;
--border: rgba(0,0,0,0.08);
--border-hover: rgba(0,0,0,0.15);
--text-1: #1a1a2e;
--text-2: #555570;
--text-3: #8888a0;
--accent: #6c5ce7;
--accent-2: #a855f7;
--accent-glow: rgba(108,92,231,0.18);
--green: #059669;
--red: #dc2626;
--yellow: #d97706;
--blue: #2563eb;
--cyan: #0891b2;
}
body {
font-family: 'Inter', -apple-system, sans-serif;
background: var(--bg-0);
color: var(--text-1);
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* ═══ TOP BAR ═══ */
.topbar {
display: flex;
align-items: center;
gap: 16px;
padding: 12px 24px;
background: var(--bg-1);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.topbar-brand {
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
}
.topbar-brand .logo {
width: 32px;
height: 32px;
background: linear-gradient(135deg, var(--accent), var(--accent-2));
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
}
.topbar-brand h1 {
font-size: 1.1em;
font-weight: 700;
letter-spacing: -0.5px;
}
.topbar-brand h1 span {
background: linear-gradient(135deg, var(--accent), var(--accent-2));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.topbar-tag {
font-size: 0.6em;
padding: 2px 8px;
background: var(--accent);
color: #fff;
border-radius: 4px;
font-weight: 700;
letter-spacing: 1.5px;
text-transform: uppercase;
}
.topbar-actions {
display: flex;
gap: 8px;
margin-left: auto;
}
.glass-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
border: 1px solid var(--border);
border-radius: 10px;
background: rgba(255,255,255,0.04);
color: var(--text-2);
font-size: 0.82em;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
font-family: inherit;
backdrop-filter: blur(8px);
}
.glass-btn:hover {
background: rgba(255,255,255,0.08);
border-color: var(--border-hover);
color: var(--text-1);
}
.glass-btn.primary {
background: linear-gradient(135deg, var(--accent), var(--accent-2));
border-color: transparent;
color: #fff;
font-weight: 600;
}
.glass-btn.primary:hover {
transform: translateY(-1px);
box-shadow: 0 4px 20px var(--accent-glow);
}
.glass-btn.danger { color: var(--red); }
.glass-btn.danger:hover { background: rgba(255,71,87,0.12); }
.topbar-nav {
display: flex;
gap: 2px;
background: var(--bg-2);
border-radius: 10px;
padding: 3px;
}
.topbar-nav a {
color: var(--text-3);
text-decoration: none;
padding: 6px 14px;
border-radius: 8px;
font-size: 0.82em;
font-weight: 500;
transition: all 0.2s;
}
.topbar-nav a:hover { color: var(--text-2); background: var(--bg-3); }
.topbar-nav a.active {
color: #fff;
background: linear-gradient(135deg, var(--accent), var(--accent-2));
}
/* ═══ PANELS CONTAINER ═══ */
.panels-wrapper {
flex: 1;
display: flex;
gap: 10px;
padding: 12px 16px;
overflow-x: auto;
overflow-y: hidden;
min-height: 0;
}
.panels-wrapper::-webkit-scrollbar { height: 6px; }
.panels-wrapper::-webkit-scrollbar-track { background: transparent; }
.panels-wrapper::-webkit-scrollbar-thumb { background: var(--bg-4); border-radius: 3px; }
/* ═══ PANEL ═══ */
.panel {
flex: 1;
min-width: 360px;
max-width: 640px;
background: var(--bg-1);
border: 1px solid var(--border);
border-radius: 16px;
display: flex;
flex-direction: column;
overflow: hidden;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 2px 12px rgba(0,0,0,0.06);
}
.panel:hover { border-color: var(--border-hover); }
.panel.sending { border-color: var(--accent); box-shadow: 0 0 20px var(--accent-glow); }
.panel-head {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 16px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.panel-icon {
width: 30px;
height: 30px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-weight: 800;
font-size: 0.8em;
flex-shrink: 0;
color: #fff;
}
.model-combo {
flex: 1;
position: relative;
min-width: 0;
}
.model-combo input {
width: 100%;
padding: 7px 34px 7px 12px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg-2);
color: var(--text-1);
font-size: 0.85em;
font-family: inherit;
font-weight: 500;
transition: all 0.2s;
box-sizing: border-box;
}
.model-combo input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-glow); }
.model-combo input:hover { border-color: var(--border-hover); }
.model-combo input::placeholder { color: var(--text-3); font-weight: 400; }
.model-combo .combo-arrow {
position: absolute;
right: 2px;
top: 50%;
transform: translateY(-50%);
width: 28px;
height: 28px;
border: none;
background: transparent;
color: var(--text-3);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
border-radius: 6px;
transition: all 0.15s;
}
.model-combo .combo-arrow:hover { background: rgba(0,0,0,0.06); color: var(--text-1); }
.model-combo .combo-menu {
display: none;
position: absolute;
top: calc(100% + 4px);
left: 0;
right: 0;
background: #fff;
border: 1px solid var(--border);
border-radius: 10px;
box-shadow: 0 8px 24px rgba(0,0,0,0.12);
z-index: 100;
max-height: 240px;
overflow-y: auto;
padding: 4px;
}
.model-combo .combo-menu.open { display: block; }
.model-combo .combo-option {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-radius: 7px;
cursor: pointer;
transition: background 0.12s;
font-size: 0.85em;
}
.model-combo .combo-option:hover { background: var(--bg-1); }
.model-combo .combo-option .opt-emoji { font-size: 1.1em; }
.model-combo .combo-option .opt-label { font-weight: 600; color: var(--text-1); }
.model-combo .combo-option .opt-id { font-size: 0.78em; color: var(--text-3); margin-left: auto; }
.btn-icon {
width: 28px;
height: 28px;
border: none;
border-radius: 8px;
background: transparent;
color: var(--text-3);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s;
font-size: 0.9em;
}
.btn-icon:hover { background: var(--bg-3); color: var(--text-1); }
.btn-icon.close:hover { background: rgba(255,71,87,0.15); color: var(--red); }
/* ═══ CHAT AREA ═══ */
.panel-chat {
flex: 1;
overflow-y: auto;
padding: 16px;
display: flex;
flex-direction: column;
gap: 16px;
min-height: 0;
}
.panel-chat::-webkit-scrollbar { width: 4px; }
.panel-chat::-webkit-scrollbar-track { background: transparent; }
.panel-chat::-webkit-scrollbar-thumb { background: var(--bg-4); border-radius: 2px; }
/* Empty state */
.empty-state {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
opacity: 0.4;
}
.empty-state .icon { font-size: 2.5em; opacity: 0.5; }
.empty-state .title { font-weight: 600; font-size: 0.95em; color: var(--text-2); }
.empty-state .sub { font-size: 0.78em; color: var(--text-3); }
/* Messages */
.msg { display: flex; flex-direction: column; max-width: 92%; animation: msgIn 0.3s ease; }
.msg.user { align-self: flex-end; align-items: flex-end; }
.msg.bot { align-self: flex-start; align-items: flex-start; }
@keyframes msgIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
.msg-label { font-size: 0.68em; color: var(--text-3); margin-bottom: 4px; padding: 0 6px; font-weight: 500; }
.msg-body {
padding: 12px 16px;
border-radius: 14px;
font-size: 0.88em;
line-height: 1.65;
word-break: break-word;
}
.msg.user .msg-body {
background: linear-gradient(135deg, var(--accent), var(--accent-2));
color: #fff;
border-bottom-right-radius: 4px;
}
.msg.bot .msg-body {
background: var(--bg-2);
border: 1px solid var(--border);
border-bottom-left-radius: 4px;
}
.msg.error .msg-body { border-color: var(--red); color: var(--red); background: rgba(255,71,87,0.08); }
.msg-meta { display: flex; align-items: center; gap: 10px; margin-top: 6px; padding: 0 4px; }
.msg-time-badge {
font-size: 0.72em;
color: var(--green);
font-weight: 600;
display: flex;
align-items: center;
gap: 4px;
}
.msg-products-count { font-size: 0.72em; color: var(--text-3); }
/* Vote */
.vote-group { display: flex; gap: 4px; margin-top: 6px; }
.vote-chip {
padding: 3px 10px;
border: 1px solid var(--border);
border-radius: 20px;
background: transparent;
color: var(--text-3);
font-size: 0.72em;
cursor: pointer;
transition: all 0.2s;
font-family: inherit;
}
.vote-chip:hover { border-color: var(--accent); color: var(--accent); }
.vote-chip.up { border-color: var(--green); color: var(--green); background: rgba(0,214,143,0.08); }
.vote-chip.down { border-color: var(--red); color: var(--red); background: rgba(255,71,87,0.08); }
/* Products */
.products-row { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 8px; }
.product-tag {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 4px 10px;
background: var(--bg-3);
border: 1px solid var(--border);
border-radius: 8px;
font-size: 0.72em;
}
.product-tag img { width: 22px; height: 22px; border-radius: 4px; object-fit: cover; }
.product-tag .sku { color: var(--text-2); }
.product-tag .price { color: var(--green); font-weight: 600; }
/* Typing */
.typing { display: none; padding: 8px 16px; font-size: 0.8em; color: var(--text-3); }
.typing.active { display: flex; align-items: center; gap: 8px; }
.dot-pulse { display: flex; gap: 3px; }
.dot-pulse span {
width: 5px; height: 5px; background: var(--accent); border-radius: 50%;
animation: pulse 1.4s ease-in-out infinite;
}
.dot-pulse span:nth-child(2) { animation-delay: 0.15s; }
.dot-pulse span:nth-child(3) { animation-delay: 0.3s; }
@keyframes pulse { 0%,80%,100% { opacity: 0.2; transform: scale(0.8); } 40% { opacity: 1; transform: scale(1.2); } }
/* Panel footer */
.panel-foot {
padding: 6px 16px;
border-top: 1px solid var(--border);
font-size: 0.7em;
color: var(--text-3);
display: flex;
justify-content: space-between;
flex-shrink: 0;
}
/* ═══ INPUT BAR ═══ */
.input-bar {
display: flex;
gap: 10px;
padding: 12px 16px 16px;
background: var(--bg-1);
border-top: 1px solid var(--border);
flex-shrink: 0;
}
.input-wrap {
flex: 1;
display: flex;
border: 1px solid var(--border);
border-radius: 14px;
background: var(--bg-2);
overflow: hidden;
transition: border-color 0.2s;
}
.input-wrap:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-glow); }
.input-wrap input {
flex: 1;
padding: 14px 20px;
background: transparent;
border: none;
color: var(--text-1);
font-size: 0.92em;
font-family: inherit;
}
.input-wrap input:focus { outline: none; }
.input-wrap input::placeholder { color: var(--text-3); }
.input-panels-count {
display: flex;
align-items: center;
padding: 0 14px;
font-size: 0.78em;
color: var(--text-3);
border-left: 1px solid var(--border);
white-space: nowrap;
}
.btn-send {
padding: 14px 28px;
border: none;
border-radius: 14px;
background: linear-gradient(135deg, var(--accent), var(--accent-2));
color: #fff;
font-weight: 700;
font-size: 0.92em;
cursor: pointer;
transition: all 0.2s;
font-family: inherit;
white-space: nowrap;
}
.btn-send:hover { transform: translateY(-1px); box-shadow: 0 4px 24px var(--accent-glow); }
.btn-send:disabled { opacity: 0.4; cursor: not-allowed; transform: none; box-shadow: none; }
/* ═══ TOAST ═══ */
.toast {
position: fixed;
bottom: 80px;
right: 20px;
z-index: 999;
padding: 10px 20px;
border-radius: 12px;
color: #fff;
font-size: 0.82em;
font-weight: 500;
opacity: 0;
transform: translateY(8px);
transition: all 0.3s;
backdrop-filter: blur(12px);
}
.toast.show { opacity: 1; transform: translateY(0); }
.toast.ok { background: rgba(0,214,143,0.9); }
.toast.err { background: rgba(255,71,87,0.9); }
/* ═══ MARKDOWN ═══ */
.md strong { color: var(--text-1); font-weight: 600; }
.md em { color: var(--text-2); }
.md code {
background: rgba(108,92,231,0.15);
padding: 1px 5px;
border-radius: 4px;
font-family: 'SF Mono', 'Cascadia Code', monospace;
font-size: 0.88em;
color: var(--accent-2);
}
.md ul, .md ol { margin: 6px 0; padding-left: 18px; }
.md li { margin: 3px 0; }
.md li::marker { color: var(--accent); }
/* ═══ ADD-PANEL GHOST ═══ */
.add-panel-ghost {
min-width: 180px;
max-width: 180px;
background: transparent;
border: 2px dashed var(--border);
border-radius: 16px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
cursor: pointer;
transition: all 0.3s;
flex-shrink: 0;
}
.add-panel-ghost:hover {
border-color: var(--accent);
background: rgba(108,92,231,0.05);
}
.add-panel-ghost .plus {
width: 44px; height: 44px; border-radius: 50%;
background: var(--bg-2); border: 1px solid var(--border);
display: flex; align-items: center; justify-content: center;
font-size: 1.5em; color: var(--text-3);
transition: all 0.2s;
}
.add-panel-ghost:hover .plus { border-color: var(--accent); color: var(--accent); background: rgba(108,92,231,0.1); }
.add-panel-ghost .label { font-size: 0.78em; color: var(--text-3); font-weight: 500; }
</style>
</head>
<body>
<!-- TOP BAR -->
<div class="topbar">
<div class="topbar-brand">
<div class="logo">🧪</div>
<h1><span>AI Lab</span></h1>
<span class="topbar-tag">Beta</span>
</div>
<div class="topbar-nav">
<a href="/static/index.html">💬 Chat</a>
<a href="/static/lab.html" class="active">🧪 Lab</a>
</div>
<div class="topbar-actions">
<button class="glass-btn primary" onclick="addPanel()">+ Add Model</button>
<button class="glass-btn" onclick="clearAll()">Clear All</button>
</div>
</div>
<!-- PANELS -->
<div class="panels-wrapper" id="panelsContainer">
<!-- Dynamic panels go here -->
<div class="add-panel-ghost" onclick="addPanel()" id="addGhost">
<div class="plus">+</div>
<div class="label">Add Model</div>
</div>
</div>
<!-- INPUT -->
<div class="input-bar">
<div class="input-wrap">
<input type="text" id="labInput" placeholder="Send a message to all panels..."
onkeydown="if(event.key==='Enter')sendToAll()" autocomplete="off">
<div class="input-panels-count" id="panelCount">→ 0 panels</div>
</div>
<button class="btn-send" id="btnSend" onclick="sendToAll()">Send All ⚡</button>
</div>
<!-- TOAST -->
<div class="toast" id="toast"></div>
<script>
// ═══ CONFIG ═══
const MODELS = [
{ id: 'gemini-3.1-flash-lite-preview', label: 'Gemini 3.1 Flash Lite', emoji: '⚡', color: '#4285f4' },
{ id: 'gpt-4.1-nano', label: 'GPT-4.1 Nano', emoji: '🔵', color: '#3b82f6' },
{ id: 'gpt-4.1-mini', label: 'GPT-4.1 Mini', emoji: '🟣', color: '#a855f7' },
{ id: 'gpt-5.4-nano', label: 'GPT-5.4 Nano', emoji: '🔮', color: '#7c3aed' },
{ id: 'gpt-5.1-codex-mini', label: 'GPT-5.1 Codex Mini', emoji: '🟢', color: '#10b981' },
{ id: 'gemini-2.5-flash', label: 'Gemini 2.5 Flash', emoji: '🟡', color: '#eab308' },
{ id: 'gemini-2.5-pro', label: 'Gemini 2.5 Pro', emoji: '🟠', color: '#f97316' },
];
const LABELS = 'ABCDEFGHIJKLMNOP';
const COLORS = ['#6c5ce7','#00d68f','#3b82f6','#f97316','#ec4899','#eab308','#06d6a0','#ff4757'];
let panels = [];
let uid = 0;
let sending = false;
// ═══ DEVICE ═══
function getDeviceId() {
let d = localStorage.getItem('canifa_device_id');
if (!d) { d = 'lab-' + crypto.randomUUID().slice(0,8); localStorage.setItem('canifa_device_id', d); }
return d;
}
function getToken() { return localStorage.getItem('canifa_access_token') || ''; }
// ═══ INIT ═══
function init() {
const saved = localStorage.getItem('lab_layout_v2');
if (saved) {
try { JSON.parse(saved).forEach(m => addPanel(m)); } catch(e) { addDefaults(); }
} else { addDefaults(); }
document.getElementById('labInput').focus();
}
function addDefaults() {
addPanel(MODELS[0].id);
addPanel(MODELS[1].id);
}
function saveLayout() {
localStorage.setItem('lab_layout_v2', JSON.stringify(panels.map(p => p.model)));
}
function updatePanelCount() {
document.getElementById('panelCount').textContent = `→ ${panels.length} panel${panels.length > 1 ? 's' : ''}`;
}
// ═══ ADD PANEL ═══
function addPanel(modelId) {
const id = uid++;
const idx = panels.length;
const model = modelId || MODELS[idx % MODELS.length].id;
const color = COLORS[idx % COLORS.length];
const label = LABELS[idx % LABELS.length];
panels.push({ id, model, count: 0 });
const el = document.createElement('div');
el.className = 'panel';
el.id = 'p-' + id;
el.innerHTML = `
<div class="panel-head">
<div class="panel-icon" style="background:${color}">${label}</div>
<div class="model-combo" id="combo-${id}">
<input id="sel-${id}" value="${model}" placeholder="Type or select model..."
onfocus="openCombo(${id})" oninput="filterCombo(${id})">
<button class="combo-arrow" onclick="toggleCombo(${id})" type="button">▼</button>
<div class="combo-menu" id="menu-${id}"></div>
</div>
<button class="btn-icon" onclick="clearPanel(${id})" title="Clear">🗑</button>
<button class="btn-icon close" onclick="removePanel(${id})" title="Remove">✕</button>
</div>
<div class="panel-chat" id="chat-${id}">
<div class="empty-state">
<div class="icon">💬</div>
<div class="title">Ready</div>
<div class="sub">Send a message to start comparing</div>
</div>
</div>
<div class="typing" id="typ-${id}">
<div class="dot-pulse"><span></span><span></span><span></span></div>
<span>Generating...</span>
</div>
<div class="panel-foot">
<span id="foot-model-${id}">${getModelEmoji(model)} ${getModelName(model)}</span>
<span id="foot-count-${id}">0 msgs</span>
</div>
`;
const ghost = document.getElementById('addGhost');
ghost.parentNode.insertBefore(el, ghost);
// Entrance animation
el.style.opacity = '0';
el.style.transform = 'scale(0.95)';
requestAnimationFrame(() => {
el.style.transition = 'all 0.3s ease';
el.style.opacity = '1';
el.style.transform = 'scale(1)';
});
saveLayout();
updatePanelCount();
}
function removePanel(id) {
if (panels.length <= 1) { toast('Need at least 1 panel', 'err'); return; }
panels = panels.filter(p => p.id !== id);
const el = document.getElementById('p-' + id);
if (el) {
el.style.transition = 'all 0.25s ease';
el.style.opacity = '0';
el.style.transform = 'scale(0.92)';
setTimeout(() => el.remove(), 250);
}
saveLayout();
updatePanelCount();
}
// ═══ COMBO BOX FUNCTIONS ═══
function buildComboMenu(panelId, filter = '') {
const menu = document.getElementById('menu-' + panelId);
if (!menu) return;
const q = filter.toLowerCase();
menu.innerHTML = MODELS
.filter(m => !q || m.id.toLowerCase().includes(q) || m.label.toLowerCase().includes(q))
.map(m => `
<div class="combo-option" onclick="selectComboOption(${panelId},'${m.id}')">
<span class="opt-emoji">${m.emoji}</span>
<span class="opt-label">${m.label}</span>
<span class="opt-id">${m.id}</span>
</div>
`).join('') || '<div style="padding:12px;color:var(--text-3);text-align:center;font-size:0.85em">No matches — press Enter to use custom model</div>';
}
function openCombo(panelId) {
closeAllCombos();
const menu = document.getElementById('menu-' + panelId);
if (menu) { buildComboMenu(panelId); menu.classList.add('open'); }
}
function toggleCombo(panelId) {
const menu = document.getElementById('menu-' + panelId);
if (menu && menu.classList.contains('open')) { menu.classList.remove('open'); }
else { openCombo(panelId); }
}
function closeAllCombos() {
document.querySelectorAll('.combo-menu.open').forEach(m => m.classList.remove('open'));
}
function filterCombo(panelId) {
const input = document.getElementById('sel-' + panelId);
buildComboMenu(panelId, input?.value || '');
const menu = document.getElementById('menu-' + panelId);
if (menu && !menu.classList.contains('open')) menu.classList.add('open');
// Also update model in real-time as user types
changeModel(panelId, input.value);
}
function selectComboOption(panelId, modelId) {
const input = document.getElementById('sel-' + panelId);
if (input) input.value = modelId;
changeModel(panelId, modelId);
closeAllCombos();
}
// Close combos on outside click
document.addEventListener('click', (e) => {
if (!e.target.closest('.model-combo')) closeAllCombos();
});
// Enter key on combo input = accept custom model and close
document.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && e.target.closest('.model-combo')) {
closeAllCombos();
e.target.blur();
}
});
function changeModel(id, model) {
const p = panels.find(x => x.id === id);
if (p) {
p.model = model;
const f = document.getElementById('foot-model-' + id);
if (f) f.textContent = `${getModelEmoji(model)} ${getModelName(model)}`;
saveLayout();
}
}
function clearPanel(id) {
const p = panels.find(x => x.id === id);
if (p) {
p.count = 0;
const chat = document.getElementById('chat-' + id);
if (chat) chat.innerHTML = `<div class="empty-state"><div class="icon">💬</div><div class="title">Ready</div><div class="sub">Send a message to start comparing</div></div>`;
const f = document.getElementById('foot-count-' + id);
if (f) f.textContent = '0 msgs';
}
}
function clearAll() {
panels.forEach(p => clearPanel(p.id));
toast('All cleared', 'ok');
}
function getModelName(id) { const m = MODELS.find(x => x.id === id); return m ? m.label : id; }
function getModelEmoji(id) { const m = MODELS.find(x => x.id === id); return m ? m.emoji : '🤖'; }
// ═══ SEND TO ALL ═══
async function sendToAll() {
const input = document.getElementById('labInput');
const text = input.value.trim();
if (!text || sending) return;
sending = true;
document.getElementById('btnSend').disabled = true;
input.value = '';
const deviceId = getDeviceId();
const token = getToken();
const headers = { 'Content-Type': 'application/json', 'device_id': deviceId };
if (token) headers['Authorization'] = 'Bearer ' + token;
// User message in all panels
panels.forEach(p => {
appendMsg(p.id, 'user', text);
const typ = document.getElementById('typ-' + p.id);
if (typ) typ.classList.add('active');
const el = document.getElementById('p-' + p.id);
if (el) el.classList.add('sending');
});
// Parallel fire
const jobs = panels.map(p => {
const t0 = Date.now();
return fetch('/api/agent/chat-dev', {
method: 'POST',
headers,
body: JSON.stringify({ user_query: text, device_id: deviceId, model_name: p.model })
})
.then(async r => ({ pid: p.id, data: await r.json(), ms: Date.now()-t0, ok: r.ok }))
.catch(e => ({ pid: p.id, data: null, ms: Date.now()-t0, ok: false, err: e.message }));
});
const results = await Promise.allSettled(jobs);
results.forEach(r => {
const res = r.value;
const typ = document.getElementById('typ-' + res.pid);
if (typ) typ.classList.remove('active');
const el = document.getElementById('p-' + res.pid);
if (el) el.classList.remove('sending');
if (res.ok && res.data?.status === 'success') {
appendBot(res.pid, res.data, res.ms);
} else {
appendMsg(res.pid, 'error', `Error: ${res.err || res.data?.message || res.data?.detail || 'Failed'}`);
}
});
sending = false;
document.getElementById('btnSend').disabled = false;
input.focus();
}
// ═══ MESSAGES ═══
function appendMsg(pid, type, text) {
const chat = document.getElementById('chat-' + pid);
if (!chat) return;
const empty = chat.querySelector('.empty-state');
if (empty) empty.remove();
const wrap = document.createElement('div');
wrap.className = 'msg ' + type;
const label = document.createElement('div');
label.className = 'msg-label';
label.textContent = type === 'user' ? 'You' : 'AI';
wrap.appendChild(label);
const body = document.createElement('div');
body.className = 'msg-body';
body.innerHTML = type === 'user' ? esc(text) : md(text);
wrap.appendChild(body);
chat.appendChild(wrap);
chat.scrollTop = chat.scrollHeight;
incCount(pid);
}
function appendBot(pid, data, ms) {
const chat = document.getElementById('chat-' + pid);
if (!chat) return;
const empty = chat.querySelector('.empty-state');
if (empty) empty.remove();
const p = panels.find(x => x.id === pid);
const wrap = document.createElement('div');
wrap.className = 'msg bot';
const label = document.createElement('div');
label.className = 'msg-label';
label.textContent = p ? getModelName(p.model) : 'AI';
wrap.appendChild(label);
const body = document.createElement('div');
body.className = 'msg-body';
body.innerHTML = md(data.ai_response || '(empty)');
// Products
if (data.product_ids?.length) {
const row = document.createElement('div');
row.className = 'products-row';
data.product_ids.forEach(pr => {
const tag = document.createElement('span');
tag.className = 'product-tag';
tag.innerHTML = `${pr.thumbnail_image_url ? `<img src="${pr.thumbnail_image_url}" onerror="this.style.display='none'">` : ''}
<span class="sku">${pr.sku || '?'}</span>
<span class="price">${(pr.sale_price||pr.price||0).toLocaleString('vi-VN')}đ</span>`;
row.appendChild(tag);
});
body.appendChild(row);
}
wrap.appendChild(body);
// Meta
const meta = document.createElement('div');
meta.className = 'msg-meta';
meta.innerHTML = `<span class="msg-time-badge">⏱ ${(ms/1000).toFixed(2)}s</span>
<span class="msg-products-count">${data.product_ids?.length || 0} products</span>`;
wrap.appendChild(meta);
// Vote
const vg = document.createElement('div');
vg.className = 'vote-group';
const vid = 'v' + pid + Date.now();
vg.innerHTML = `<button class="vote-chip" onclick="vote(this,'${vid}','up')">👍 Good</button>
<button class="vote-chip" onclick="vote(this,'${vid}','down')">👎 Bad</button>`;
wrap.appendChild(vg);
chat.appendChild(wrap);
chat.scrollTop = chat.scrollHeight;
incCount(pid);
}
function incCount(pid) {
const p = panels.find(x => x.id === pid);
if (p) {
p.count++;
const f = document.getElementById('foot-count-' + pid);
if (f) f.textContent = p.count + ' msgs';
}
}
function vote(btn, vid, type) {
btn.parentElement.querySelectorAll('.vote-chip').forEach(b => b.classList.remove('up','down'));
btn.classList.add(type);
toast(type === 'up' ? '👍 Marked good' : '👎 Marked bad', 'ok');
}
// ═══ MARKDOWN ═══
function md(t) {
if (!t) return '';
let h = t.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
h = h.replace(/^### (.+)$/gm, '<div style="font-weight:700;color:var(--text-1);margin:6px 0 2px;">$1</div>');
h = h.replace(/^## (.+)$/gm, '<div style="font-weight:700;font-size:1.02em;color:var(--text-1);margin:8px 0 3px;">$1</div>');
h = h.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
h = h.replace(/`([^`]+?)`/g, '<code>$1</code>');
h = h.replace(/^(\d+)\.\s+(.+)$/gm, '<li>$2</li>');
h = h.replace(/^[-*]\s+(.+)$/gm, '<li>$1</li>');
h = h.replace(/\n/g, '<br>');
return '<div class="md">' + h + '</div>';
}
function esc(t) { return t.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/\n/g,'<br>'); }
// ═══ TOAST ═══
function toast(m, type='ok') {
const el = document.getElementById('toast');
el.textContent = m;
el.className = 'toast ' + type + ' show';
setTimeout(() => el.classList.remove('show'), 2200);
}
window.onload = init;
</script>
</body>
</html>
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment