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: ...@@ -48,7 +48,7 @@ class CANIFAGraph:
self.collection_tools = get_collection_tools() # Vẫn lấy list name để routing self.collection_tools = get_collection_tools() # Vẫn lấy list name để routing
self.retrieval_tools = self.all_tools 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() self.cache = InMemoryCache()
# Chain caching: avoid rebuilding ChatPromptTemplate every turn # Chain caching: avoid rebuilding ChatPromptTemplate every turn
...@@ -185,13 +185,12 @@ class CANIFAGraph: ...@@ -185,13 +185,12 @@ class CANIFAGraph:
return self.build() return self.build()
# --- Singleton & Public API --- # --- Per-model Instance Cache & Public API ---
_instance: list[CANIFAGraph | None] = [None] _instances: dict[str, CANIFAGraph] = {}
def build_graph(config: AgentConfig | None = None, llm: BaseChatModel | None = None, tools: list | None = None) -> Any: def build_graph(config: AgentConfig | None = None, llm: BaseChatModel | None = None, tools: list | None = None) -> Any:
"""Get compiled graph (Singleton usage).""" """Get compiled graph (cached per model)."""
# Use singleton to avoid rebuilding graph on every request
manager = get_graph_manager(config, llm, tools) manager = get_graph_manager(config, llm, tools)
return manager.build() return manager.build()
...@@ -199,38 +198,33 @@ def build_graph(config: AgentConfig | None = None, llm: BaseChatModel | None = N ...@@ -199,38 +198,33 @@ def build_graph(config: AgentConfig | None = None, llm: BaseChatModel | None = N
def get_graph_manager( def get_graph_manager(
config: AgentConfig | None = None, llm: BaseChatModel | None = None, tools: list | None = None config: AgentConfig | None = None, llm: BaseChatModel | None = None, tools: list | None = None
) -> CANIFAGraph: ) -> 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, Each model gets its own cached graph instance, enabling true parallel
so no need to rebuild graph when prompt changes. execution when Lab sends requests to different models simultaneously.
""" """
# 1. New Instance if Empty effective_config = config or get_config()
if _instance[0] is None: model_key = effective_config.model_name
_instance[0] = CANIFAGraph(config, llm, tools)
logger.info(f"✨ Graph Created: {_instance[0].config.model_name} (prompts from Langfuse)")
return _instance[0]
# 2. Check for Model Config Changes only if model_key not in _instances:
is_model_changed = config and config.model_name != _instance[0].config.model_name _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})") return _instances[model_key]
_instance[0] = CANIFAGraph(config, llm, tools)
return _instance[0]
return _instance[0]
def reset_graph() -> None: def reset_graph() -> None:
"""Reset singleton for testing.""" """Reset all cached instances (for testing)."""
_instance[0] = None _instances.clear()
def reset_chain_cache() -> None: 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. Keeps the graph/LLM/tools intact, only forces chain rebuild on next request.
""" """
if _instance[0] is not None: for model_key, inst in _instances.items():
_instance[0]._cached_chain = None inst._cached_chain = None
_instance[0]._cached_prompt_hash = None inst._cached_prompt_hash = None
logger.info("🔄 Chain cache cleared — will rebuild on next request") 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): ...@@ -14,6 +14,7 @@ class QueryRequest(BaseModel):
user_query: str user_query: str
images: list[str] | None = None images: list[str] | None = None
image_analysis: dict[str, Any] | None = None image_analysis: dict[str, Any] | None = None
model_name: str | None = None # Override model per-request (Lab mode)
class AgentState(TypedDict): class AgentState(TypedDict):
......
...@@ -31,7 +31,7 @@ from agent.prompt_utils import read_tool_prompt ...@@ -31,7 +31,7 @@ from agent.prompt_utils import read_tool_prompt
class SearchItem(BaseModel): 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) ====== # ====== SEARCH TEXT (optional fallback) ======
description: str | None = Field( description: str | None = Field(
...@@ -121,7 +121,7 @@ class SearchItem(BaseModel): ...@@ -121,7 +121,7 @@ class SearchItem(BaseModel):
class MultiSearchParams(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") 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 ...@@ -106,10 +106,12 @@ async def fashion_qa_chat_dev(request: Request, req: QueryRequest, background_ta
try: try:
# DEV MODE: Return ai_response + products immediately # 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( result = await chat_controller(
query=req.user_query, query=req.user_query,
background_tasks=background_tasks, background_tasks=background_tasks,
model_name=DEFAULT_MODEL, model_name=effective_model,
images=req.images, images=req.images,
identity_key=str(identity_id), identity_key=str(identity_id),
return_user_insight=False, return_user_insight=False,
......
...@@ -107,6 +107,14 @@ ...@@ -107,6 +107,14 @@
color: #fff; color: #fff;
} }
select {
padding: 8px 12px;
border: 1px solid #555;
border-radius: 6px;
background: #3d3d3d;
color: #fff;
}
button { button {
padding: 8px 16px; padding: 8px 16px;
background: #007acc; background: #007acc;
...@@ -962,58 +970,74 @@ ...@@ -962,58 +970,74 @@
background: rgba(13, 148, 136, 0.1); background: rgba(13, 148, 136, 0.1);
} }
/* Auto-cart banner */ /* ═══ Auto-cart Panel (Size/Color Selection) ═══ */
.auto-cart-banner { .auto-cart-panel {
background: linear-gradient(135deg, #0d9488 0%, #065f46 100%); background: linear-gradient(135deg, #1a3a36 0%, #132b28 100%);
border-radius: 12px; border: 1px solid #0d9488;
padding: 14px 18px; border-radius: 14px;
padding: 16px;
margin-top: 14px; margin-top: 14px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
animation: slideInUp 0.4s ease; 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 { @keyframes slideInUp {
from { opacity: 0; transform: translateY(12px); } from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); } to { opacity: 1; transform: translateY(0); }
} }
.auto-cart-banner-text { .auto-cart-header {
color: #fff; display: flex; align-items: center; justify-content: space-between;
font-size: 0.9em; margin-bottom: 14px; padding-bottom: 10px;
font-weight: 500; border-bottom: 1px solid rgba(13, 148, 136, 0.3);
line-height: 1.4; }
} .auto-cart-header-text {
.auto-cart-banner-text .auto-cart-count { color: #5eead4; font-size: 0.95em; font-weight: 600;
font-weight: 700; }
font-size: 1.1em; .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-btn { .auto-cart-item {
background: #fff; display: flex; gap: 12px; padding: 10px 12px;
color: #065f46; background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.08);
border: none; border-radius: 10px; transition: all 0.2s;
padding: 10px 20px; }
border-radius: 8px; .auto-cart-item:hover { background: rgba(255,255,255,0.07); border-color: rgba(13,148,136,0.3); }
font-weight: 700; .auto-cart-item img {
font-size: 0.9em; width: 60px; height: 60px; border-radius: 8px; object-fit: cover;
cursor: pointer; background: #2d2d2d; flex-shrink: 0;
white-space: nowrap; }
transition: all 0.2s; .auto-cart-item-info { flex: 1; min-width: 0; }
box-shadow: 0 2px 8px rgba(0,0,0,0.15); .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-btn:hover { .auto-cart-item-price .original { color: #888; text-decoration: line-through; font-weight: 400; margin-left: 6px; font-size: 0.85em; }
transform: scale(1.05); .auto-cart-selectors { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
box-shadow: 0 4px 12px rgba(0,0,0,0.25); .auto-cart-selectors select {
} padding: 4px 8px; border-radius: 6px;
.auto-cart-btn:active { background: #2d2d2d; color: #e0e0e0; border: 1px solid #555;
transform: scale(0.97); font-size: 0.8em; cursor: pointer; outline: none;
} }
.auto-cart-btn.done { .auto-cart-selectors select:focus { border-color: #0d9488; }
background: #10b981; .auto-cart-color-chips { display: flex; gap: 4px; align-items: center; }
color: #fff; .auto-cart-color-chip {
pointer-events: none; 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 */ /* Per-product action buttons */
.product-actions { .product-actions {
...@@ -1417,7 +1441,10 @@ ...@@ -1417,7 +1441,10 @@
<h1>🧪 Canifa AI <span <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 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> 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>
<div class="main-content"> <div class="main-content">
...@@ -1443,6 +1470,18 @@ ...@@ -1443,6 +1470,18 @@
<input type="text" id="accessToken" placeholder="Token (optional)" style="width: 150px;" <input type="text" id="accessToken" placeholder="Token (optional)" style="width: 150px;"
onblur="saveConfig()" onchange="saveConfig()"> onblur="saveConfig()" onchange="saveConfig()">
</div> </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 --> <!-- Action Buttons -->
<button onclick="loadHistory(true)" title="Load History">↻ History</button> <button onclick="loadHistory(true)" title="Load History">↻ History</button>
...@@ -1607,6 +1646,16 @@ ...@@ -1607,6 +1646,16 @@
let selectedToolPrompt = ''; let selectedToolPrompt = '';
let pendingImages = []; // 📸 Experimental: images to send with next message 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) ==================== // ==================== IMAGE HANDLING (Experimental) ====================
function handleImageSelect(event) { function handleImageSelect(event) {
const file = event.target.files[0]; const file = event.target.files[0];
...@@ -2131,8 +2180,10 @@ ...@@ -2131,8 +2180,10 @@
const input = document.getElementById('userInput'); const input = document.getElementById('userInput');
const deviceIdInput = document.getElementById('deviceId'); const deviceIdInput = document.getElementById('deviceId');
const accessTokenInput = document.getElementById('accessToken'); const accessTokenInput = document.getElementById('accessToken');
const modelSelect = document.getElementById('modelSelect');
const deviceId = deviceIdInput.value.trim(); const deviceId = deviceIdInput.value.trim();
const accessToken = accessTokenInput.value.trim(); const accessToken = accessTokenInput.value.trim();
const selectedModel = modelSelect.value;
const text = input.value.trim(); const text = input.value.trim();
const sendBtn = document.getElementById('sendBtn'); const sendBtn = document.getElementById('sendBtn');
const typingIndicator = document.getElementById('typingIndicator'); const typingIndicator = document.getElementById('typingIndicator');
...@@ -2199,6 +2250,7 @@ ...@@ -2199,6 +2250,7 @@
body: JSON.stringify({ body: JSON.stringify({
user_query: queryToSend, user_query: queryToSend,
device_id: deviceId, device_id: deviceId,
model_name: selectedModel,
...(imagesToSend.length > 0 && { images: imagesToSend }) ...(imagesToSend.length > 0 && { images: imagesToSend })
}) })
}); });
...@@ -2452,27 +2504,124 @@ ...@@ -2452,27 +2504,124 @@
filteredDiv.appendChild(productsContainer); filteredDiv.appendChild(productsContainer);
// ═══ AUTO-CART BANNER ═══ // ═══ AUTO-CART PANEL (Size/Color Selection) ═══
if (data.auto_cart && data.product_ids.length > 0) { if (data.auto_cart && data.product_ids.length > 0) {
const banner = document.createElement('div'); const panel = document.createElement('div');
banner.className = 'auto-cart-banner'; panel.className = 'auto-cart-panel';
const bannerText = document.createElement('div'); // Header
bannerText.className = 'auto-cart-banner-text'; const header = document.createElement('div');
bannerText.innerHTML = `🛒 Em đã chọn <span class="auto-cart-count">${data.product_ids.length} sản phẩm</span> cho bạn!`; header.className = 'auto-cart-header';
banner.appendChild(bannerText); 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);
const bannerBtn = document.createElement('button');
bannerBtn.className = 'auto-cart-btn'; // Items container
bannerBtn.innerText = 'Thêm tất cả vào giỏ 🛒'; const itemsDiv = document.createElement('div');
bannerBtn.onclick = () => { 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; let addedCount = 0;
data.product_ids.forEach(product => { data.product_ids.forEach((product, idx) => {
const exists = cartItems.find(item => item.sku === product.sku); 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) { if (!exists) {
cartItems.push({ cartItems.push({
cartKey: cartKey,
sku: product.sku, sku: product.sku,
name: product.name, name: product.name,
size: selectedSize,
color: selectedColor,
price: product.sale_price || product.price || 0, price: product.sale_price || product.price || 0,
originalPrice: product.price || 0, originalPrice: product.price || 0,
image: product.thumbnail_image_url || '' image: product.thumbnail_image_url || ''
...@@ -2482,12 +2631,13 @@ ...@@ -2482,12 +2631,13 @@
}); });
saveCart(); saveCart();
updateCartUI(); updateCartUI();
bannerBtn.className = 'auto-cart-btn done'; addBtn.className = 'auto-cart-add-btn done';
bannerBtn.innerText = `✅ Đã thêm ${addedCount} sản phẩm!`; addBtn.innerText = `✅ Đã thêm ${addedCount} sản phẩm!`;
showCartToast(`🎉 Đã thêm ${addedCount} sản phẩm vào giỏ hàng!`); showCartToast(`🎉 Đã thêm ${addedCount} sản phẩm vào giỏ hàng!`);
}; };
banner.appendChild(bannerBtn); footer.appendChild(addBtn);
filteredDiv.appendChild(banner); panel.appendChild(footer);
filteredDiv.appendChild(panel);
} }
} }
...@@ -3014,6 +3164,7 @@ ...@@ -3014,6 +3164,7 @@
function saveConfig() { function saveConfig() {
const deviceId = document.getElementById('deviceId').value.trim(); const deviceId = document.getElementById('deviceId').value.trim();
const accessToken = document.getElementById('accessToken').value.trim(); const accessToken = document.getElementById('accessToken').value.trim();
const selectedModel = document.getElementById('modelSelect').value;
if (deviceId) { if (deviceId) {
localStorage.setItem('canifa_device_id', deviceId); localStorage.setItem('canifa_device_id', deviceId);
...@@ -3023,6 +3174,9 @@ ...@@ -3023,6 +3174,9 @@
} else { } else {
localStorage.removeItem('canifa_access_token'); localStorage.removeItem('canifa_access_token');
} }
if (selectedModel) {
localStorage.setItem('canifa_chat_model', selectedModel);
}
} }
// Generate UUID for device_id // Generate UUID for device_id
...@@ -3050,6 +3204,11 @@ ...@@ -3050,6 +3204,11 @@
document.getElementById('accessToken').value = savedAccessToken; 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 // Auto-load history
setTimeout(() => loadHistory(true), 50); setTimeout(() => loadHistory(true), 50);
}; };
...@@ -3107,8 +3266,8 @@ ...@@ -3107,8 +3266,8 @@
showCartToast(`✅ Đã thêm ${product.name.substring(0, 30)}...`); showCartToast(`✅ Đã thêm ${product.name.substring(0, 30)}...`);
} }
function removeFromCart(sku) { function removeFromCart(key) {
cartItems = cartItems.filter(item => item.sku !== sku); cartItems = cartItems.filter(item => (item.cartKey || item.sku) !== key);
saveCart(); saveCart();
updateCartUI(); updateCartUI();
} }
...@@ -3162,6 +3321,8 @@ ...@@ -3162,6 +3321,8 @@
const priceStr = price > 0 ? price.toLocaleString('vi-VN') + 'đ' : 'Liên hệ'; const priceStr = price > 0 ? price.toLocaleString('vi-VN') + 'đ' : 'Liên hệ';
const origStr = (item.originalPrice && item.originalPrice > price) 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>` : ''; ? `<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 += ` html += `
<div class="cart-item"> <div class="cart-item">
...@@ -3170,9 +3331,9 @@ ...@@ -3170,9 +3331,9 @@
<div class="cart-item-info"> <div class="cart-item-info">
<div class="cart-item-name">${item.name}</div> <div class="cart-item-name">${item.name}</div>
<div class="cart-item-price">${priceStr}${origStr}</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> </div>
<button class="cart-item-remove" onclick="removeFromCart('${item.sku}')">✕</button> <button class="cart-item-remove" onclick="removeFromCart('${removeKey}')">✕</button>
</div>`; </div>`;
}); });
itemsContainer.innerHTML = html; 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