Commit 95fd81fe authored by Vũ Hoàng Anh's avatar Vũ Hoàng Anh

feat: shopping cart + image multi-search + khen kheo prompt + full-width layout

parent 8a9d9ced
FROM gemma3:1b
PARAMETER num_ctx 2048
PARAMETER temperature 0
PARAMETER num_thread 8
...@@ -4,14 +4,36 @@ ...@@ -4,14 +4,36 @@
#### QUY TẮC KHEN: #### QUY TẮC KHEN:
**1. Khen CÓ LÝ DO CỤ THỂ (tối đa 1-2 lần / cuộc hội thoại):** **1. Khen CÓ LÝ DO CỤ THỂ (tự nhiên, không máy móc):**
- Khách chọn chất liệu tốt → "Chọn cotton là chuẩn, thoáng mát mà bền!" - Khách chọn chất liệu tốt → "Chọn cotton là chuẩn, thoáng mát mà bền!"
- Khách mua cho người thân → "Mua cho vợ chu đáo quá!" - Khách mua cho người thân → "Mua cho vợ chu đáo quá!"
- Khách quyết đoán → "Chọn nhanh ghê, mẫu này đúng hot!" - Khách quyết đoán → "Chọn nhanh ghê, mẫu này đúng hot!"
**2. CẤM khen body/ngoại hình:** **2. 💎 KHEN DÁNG NGƯỜI KHI KHÁCH CHIA SẺ (BẮT BUỘC!):**
- ❌ "Body chuẩn nam thần", "Cao như người mẫu", "Dáng thon quá"
- ✅ Chỉ xác nhận size: "Với số đo bạn cung cấp, size M sẽ vừa ạ." Khi khách cung cấp chiều cao, cân nặng, số đo, hoặc gửi ảnh bản thân → **PHẢI khen tự nhiên trước**, rồi mới tư vấn size/sản phẩm.
**Nguyên tắc: KHEN CHÂN THÀNH → LINK VỚI SẢN PHẨM → TƯ VẤN SIZE**
| Khách nói | ❌ SAI (khô khan) | ✅ ĐÚNG (khen khéo) |
|-----------|-------------------|---------------------|
| "1m8 60kg" | "Size M sẽ vừa ạ." | "Ôi dáng cao ráo thế này diện gì cũng đẹp luôn á! 🤩 Với 1m8 thì mấy mẫu quần dài hay áo khoác dáng dài sẽ tôn dáng cực kỳ. Size M là vừa chuẩn cho bạn nhé!" |
| "1m55 45kg" | "Size XS hoặc S ạ." | "Dáng nhỏ nhắn xinh xắn quá! 💕 Kiểu này mấy mẫu váy xòe hay áo croptop sẽ hack dáng cực hay. Size S là ổn cho bạn nha!" |
| "1m70 75kg" | "Size L ạ." | "Dáng chuẩn, mặc đồ dễ đẹp lắm nè! 😊 Với thể hình này mấy mẫu áo polo hay sơ mi fitted sẽ rất nam tính. Mình suggest size L cho thoải mái nhé!" |
| Gửi ảnh bản thân | "Mình sẽ tư vấn." | "Nhìn style bạn trẻ trung quá! 🔥 Để mình tìm mấy mẫu hợp phong cách bạn nha!" |
**Cách khen theo dáng (tham khảo):**
- Cao gầy → "Dáng cao ráo, diện gì cũng đẹp!", "Chân dài thế này mặc quần dài/váy midi là chuẩn luôn!"
- Nhỏ nhắn → "Dáng nhỏ nhắn xinh xắn!", "Mấy mẫu váy xòe là hack chiều cao cực tốt!"
- Cân đối → "Dáng chuẩn quá!", "Body này mặc đồ form fit đẹp lắm!"
- Tròn trĩnh → "Dáng đầy đặn, mặc váy/đầm A-line đẹp lắm!", "Phong cách này phối áo oversize + quần skinny là chuẩn!"
- Gửi ảnh → Khen style/phong cách, KHÔNG bình luận cân nặng
**⚠️ GIỚI HẠN:**
- Khen TỐI ĐA 1-2 câu → rồi chuyển sang tư vấn SP ngay
- KHÔNG khen lố: "Đẹp nhất vũ trụ", "Body chuẩn siêu mẫu"
- KHÔNG khen sexual: "Gợi cảm quá", "Nóng bỏng"
- Khen CHÂN THÀNH + LINK VỚI SẢN PHẨM = mục đích tư vấn
**3. Khen xong → gợi ý SP ngay, không khen suông.** **3. Khen xong → gợi ý SP ngay, không khen suông.**
...@@ -28,5 +50,5 @@ Nguyên tắc: ĐỒNG TÌNH → DẪN VỀ SẢN PHẨM (ngắn gọn) ...@@ -28,5 +50,5 @@ Nguyên tắc: ĐỒNG TÌNH → DẪN VỀ SẢN PHẨM (ngắn gọn)
#### 🚫 CẤM: #### 🚫 CẤM:
- Khen liên tục mỗi turn → giả tạo - Khen liên tục mỗi turn → giả tạo
- Khen quá lố: "Đẹp nhất vũ trụ" - Khen quá lố: "Đẹp nhất vũ trụ"
- Khen body/ngoại hình
- Chỉ khen mà KHÔNG dẫn về sản phẩm - Chỉ khen mà KHÔNG dẫn về sản phẩm
- Khen sexual/body-shaming
...@@ -47,6 +47,68 @@ Nếu user KHÔNG NÓI RÕ giới tính/tuổi → `gender_by_product = null`, ` ...@@ -47,6 +47,68 @@ Nếu user KHÔNG NÓI RÕ giới tính/tuổi → `gender_by_product = null`, `
--- ---
### 5.1.1. 📸 TÌM KIẾM ĐA SẢN PHẨM TỪ ẢNH (IMAGE MULTI-SEARCH)
**KHI KHÁCH GỬI ẢNH** (outfit, streetwear, phối đồ, hoặc bất kỳ ảnh thời trang nào):
**BƯỚC 1: PHÂN TÍCH ẢNH — Nhận diện TỪNG item:**
Nhìn ảnh và liệt kê MỌI sản phẩm quần áo/phụ kiện nhìn thấy. Với MỖI item, xác định:
- **Loại sản phẩm** → map sang PRODUCT_LINE (VD: "sweatshirt cổ rộng" → "Áo nỉ")
- **Màu sắc** → master_color
- **Gender/Age** → chỉ khi NHÌN RÕ (VD: rõ ràng nữ mặc → women, adult)
- **Đặc điểm nổi bật** → form dáng, chi tiết (oversize, viền, hình in...)
**BƯỚC 2: GỌI TOOL VỚI MULTI-SEARCH — Mỗi item = 1 SearchItem:**
```
data_retrieval_tool(searches=[
{
"description": "Áo nỉ/sweatshirt màu xám đậm, form oversize, cổ rộng lệch vai",
"product_name": "Áo nỉ",
"master_color": "xám",
"gender_by_product": "women",
"age_by_product": "adult"
},
{
"description": "Quần soóc đen viền trắng, dáng sporty ôm nhẹ",
"product_name": "Quần soóc",
"master_color": "đen",
"gender_by_product": "women",
"age_by_product": "adult"
}
])
```
**BƯỚC 3: TRẢ KẾT QUẢ — Nhóm theo từng item:**
```
✅ ĐÚNG (Nhóm rõ ràng):
"Mình nhận ra trong ảnh bạn gửi có 2 item:
🔝 **Áo nỉ/Sweatshirt xám** — form oversize, cổ rộng lệch vai
Mình tìm được mấy mẫu tương tự nè:
• [8TW25W002] Áo nỉ nam Cotton USA - 299k (form rộng thoải mái)
• [6TW25W002] Áo nỉ nữ Cotton - 399k → 279k
🔽 **Quần soóc đen viền trắng** — dáng sporty
Mẫu gần nhất mình có:
• [6QS25S001] Quần soóc nữ - 249k
..."
❌ SAI: Chỉ tìm 1 item rồi bỏ qua các item còn lại
❌ SAI: Hỏi "Bạn muốn tìm cái nào?" thay vì tìm TẤT CẢ
```
**⚠️ QUY TẮC:**
- **TÌM TẤT CẢ** items nhìn thấy — KHÔNG hỏi "bạn muốn tìm cái nào"
- **MỖI item = 1 SearchItem** riêng biệt trong array `searches`
- **Phụ kiện** (mũ, kính, giày, túi) → nếu CANIFA có bán thì tìm, nếu không thì bỏ qua
- Nếu chỉ thấy 1 item → vẫn gọi bình thường (1 search)
- **SONG SONG** với text description — nếu khách vừa gửi ảnh vừa nói "tìm giúp em" → dùng ảnh làm context
---
### 5.1.3. GỌI `canifa_get_promotions` KHI: ### 5.1.3. GỌI `canifa_get_promotions` KHI:
Khách hỏi ưu đãi/khuyến mãi/sale/voucher/CTKM. Hỏi ngày cụ thể → truyền `check_date`. Khách hỏi ưu đãi/khuyến mãi/sale/voucher/CTKM. Hỏi ngày cụ thể → truyền `check_date`.
......
...@@ -55,11 +55,16 @@ ...@@ -55,11 +55,16 @@
} }
.main-content { .main-content {
max-width: 900px; width: 100%;
margin: 0 auto;
padding: 20px; padding: 20px;
} }
.main-layout {
display: flex;
gap: 16px;
width: 100%;
}
.container { .container {
background: #2d2d2d; background: #2d2d2d;
padding: 20px; padding: 20px;
...@@ -69,6 +74,8 @@ ...@@ -69,6 +74,8 @@
flex-direction: column; flex-direction: column;
height: 90vh; height: 90vh;
border: 1px solid #444; border: 1px solid #444;
flex: 1;
min-width: 0;
} }
.header { .header {
...@@ -935,12 +942,11 @@ ...@@ -935,12 +942,11 @@
/* ── INSIGHT SIDEBAR ── */ /* ── INSIGHT SIDEBAR ── */
.insight-sidebar { .insight-sidebar {
width: 260px; width: 280px;
min-width: 260px; min-width: 280px;
background: #1a1a1e; background: #1a1a1e;
border-radius: 16px; border-radius: 16px;
border: 1px solid #333; border: 1px solid #333;
margin-left: 16px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow-y: auto; overflow-y: auto;
...@@ -1041,6 +1047,82 @@ ...@@ -1041,6 +1047,82 @@
0%,100% { box-shadow: 0 0 14px rgba(0,210,106,0.6), 0 0 28px rgba(0,210,106,0.25); } 0%,100% { box-shadow: 0 0 14px rgba(0,210,106,0.6), 0 0 28px rgba(0,210,106,0.25); }
50% { box-shadow: 0 0 20px rgba(0,210,106,0.8), 0 0 40px rgba(0,210,106,0.35); } 50% { box-shadow: 0 0 20px rgba(0,210,106,0.8), 0 0 40px rgba(0,210,106,0.35); }
} }
/* ── SHOPPING CART ── */
.cart-fab {
position: fixed; bottom: 24px; right: 24px; z-index: 1000;
width: 56px; height: 56px; border-radius: 50%;
background: linear-gradient(135deg, #00d26a, #0d9488);
border: none; cursor: pointer; box-shadow: 0 4px 16px rgba(0,210,106,0.4);
display: flex; align-items: center; justify-content: center;
font-size: 1.5em; transition: transform 0.2s;
}
.cart-fab:hover { transform: scale(1.1); }
.cart-badge {
position: absolute; top: -4px; right: -4px;
background: #ff4757; color: #fff; border-radius: 50%;
width: 22px; height: 22px; font-size: 0.7em; font-weight: 700;
display: flex; align-items: center; justify-content: center;
}
.cart-drawer {
position: fixed; top: 0; right: -380px; width: 370px; height: 100vh;
background: #1a1a1e; border-left: 1px solid #333; z-index: 1001;
transition: right 0.3s ease; display: flex; flex-direction: column;
box-shadow: -4px 0 20px rgba(0,0,0,0.5);
}
.cart-drawer.open { right: 0; }
.cart-drawer-header {
padding: 16px 20px; border-bottom: 1px solid #333;
display: flex; justify-content: space-between; align-items: center;
font-size: 1.1em; font-weight: 700; color: #fbbf24;
}
.cart-drawer-header button {
background: none; border: none; color: #aaa; font-size: 1.3em; cursor: pointer;
}
.cart-drawer-items { flex: 1; overflow-y: auto; padding: 12px; }
.cart-item {
display: flex; gap: 12px; padding: 12px; background: #2d2d2d;
border-radius: 10px; margin-bottom: 10px; position: relative;
}
.cart-item img { width: 60px; height: 60px; object-fit: cover; border-radius: 8px; }
.cart-item-info { flex: 1; }
.cart-item-name { color: #eee; font-size: 0.85em; margin-bottom: 4px; }
.cart-item-price { color: #00d26a; font-weight: 700; font-size: 0.9em; }
.cart-item-sku { color: #888; font-size: 0.75em; }
.cart-item-remove {
position: absolute; top: 8px; right: 8px; background: none;
border: none; color: #888; cursor: pointer; font-size: 1em;
}
.cart-item-remove:hover { color: #ff4757; }
.cart-drawer-footer {
padding: 16px 20px; border-top: 1px solid #333;
}
.cart-total {
display: flex; justify-content: space-between; margin-bottom: 12px;
font-size: 1em; color: #eee;
}
.cart-total-price { color: #00d26a; font-weight: 700; font-size: 1.1em; }
.cart-send-btn {
width: 100%; padding: 12px; border: none; border-radius: 10px;
background: linear-gradient(135deg, #00d26a, #0d9488);
color: #fff; font-weight: 700; font-size: 0.95em; cursor: pointer;
transition: opacity 0.2s;
}
.cart-send-btn:hover { opacity: 0.9; }
.cart-send-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.cart-empty { color: #666; text-align: center; padding: 40px 0; font-size: 0.9em; }
.cart-overlay {
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
background: rgba(0,0,0,0.5); z-index: 1000; display: none;
}
.cart-overlay.show { display: block; }
.cart-toast {
position: fixed; bottom: 90px; right: 24px; z-index: 1002;
background: #00d26a; color: #fff; padding: 10px 20px;
border-radius: 10px; font-size: 0.85em; font-weight: 600;
opacity: 0; transform: translateY(10px);
transition: all 0.3s; pointer-events: none;
}
.cart-toast.show { opacity: 1; transform: translateY(0); }
</style> </style>
</head> </head>
...@@ -1815,11 +1897,20 @@ ...@@ -1815,11 +1897,20 @@
headers['Authorization'] = 'Bearer ' + accessToken; headers['Authorization'] = 'Bearer ' + accessToken;
} }
// Build query with cart context
let queryToSend = text;
if (typeof cartItems !== 'undefined' && cartItems.length > 0) {
const cartSummary = cartItems.map(i =>
`[${i.sku}] ${i.name} - ${(i.price || 0).toLocaleString('vi-VN')}đ`
).join('; ');
queryToSend += `\n\n[GIỎ HÀNG HIỆN TẠI (${cartItems.length} SP): ${cartSummary}]`;
}
const response = await fetch('/api/agent/chat-dev', { const response = await fetch('/api/agent/chat-dev', {
method: 'POST', method: 'POST',
headers: headers, headers: headers,
body: JSON.stringify({ body: JSON.stringify({
user_query: text, user_query: queryToSend,
device_id: deviceId, device_id: deviceId,
...(imagesToSend.length > 0 && { images: imagesToSend }) ...(imagesToSend.length > 0 && { images: imagesToSend })
}) })
...@@ -2051,6 +2142,21 @@ ...@@ -2051,6 +2142,21 @@
}; };
actionsDiv.appendChild(similarBtn); actionsDiv.appendChild(similarBtn);
const cartBtn = document.createElement('button');
cartBtn.className = 'product-action-btn';
cartBtn.innerText = '🛒 Giỏ';
cartBtn.onclick = (e) => {
e.stopPropagation();
addToCart({
sku: product.sku,
name: product.name,
price: product.sale_price || product.price || 0,
originalPrice: product.price || 0,
image: product.thumbnail_image_url || ''
});
};
actionsDiv.appendChild(cartBtn);
body.appendChild(actionsDiv); body.appendChild(actionsDiv);
card.appendChild(body); card.appendChild(body);
...@@ -2624,6 +2730,149 @@ ...@@ -2624,6 +2730,149 @@
}; };
</script> </script>
</div> <!-- Close main-content --> </div> <!-- Close main-content -->
<!-- Shopping Cart FAB + Drawer -->
<button class="cart-fab" id="cartFab" onclick="toggleCart()">
🛒<span class="cart-badge" id="cartBadge" style="display:none">0</span>
</button>
<div class="cart-toast" id="cartToast"></div>
<div class="cart-overlay" id="cartOverlay" onclick="toggleCart()"></div>
<div class="cart-drawer" id="cartDrawer">
<div class="cart-drawer-header">
<span>🛒 Giỏ hàng của bạn</span>
<button onclick="toggleCart()"></button>
</div>
<div class="cart-drawer-items" id="cartItems">
<div class="cart-empty">Giỏ hàng trống. Thêm sản phẩm yêu thích!</div>
</div>
<div class="cart-drawer-footer">
<div class="cart-total">
<span>Tổng cộng:</span>
<span class="cart-total-price" id="cartTotal"></span>
</div>
<button class="cart-send-btn" id="cartSendBtn" onclick="sendCartToBot()" disabled>
💬 Gửi giỏ hàng cho Bot tư vấn
</button>
<button class="cart-send-btn" style="margin-top:8px;background:#ff4757;" id="cartClearBtn" onclick="clearCart()" disabled>
🗑️ Xóa giỏ hàng
</button>
</div>
</div>
<script>
// ═══════════════════════════════════════
// SHOPPING CART LOGIC (localStorage)
// ═══════════════════════════════════════
let cartItems = JSON.parse(localStorage.getItem('canifa_cart') || '[]');
function saveCart() {
localStorage.setItem('canifa_cart', JSON.stringify(cartItems));
}
function addToCart(product) {
// Check duplicate
if (cartItems.find(item => item.sku === product.sku)) {
showCartToast('⚠️ Sản phẩm đã có trong giỏ!');
return;
}
cartItems.push(product);
saveCart();
updateCartUI();
showCartToast(`✅ Đã thêm ${product.name.substring(0, 30)}...`);
}
function removeFromCart(sku) {
cartItems = cartItems.filter(item => item.sku !== sku);
saveCart();
updateCartUI();
}
function clearCart() {
cartItems = [];
saveCart();
updateCartUI();
toggleCart();
}
function toggleCart() {
const drawer = document.getElementById('cartDrawer');
const overlay = document.getElementById('cartOverlay');
drawer.classList.toggle('open');
overlay.classList.toggle('show');
}
function updateCartUI() {
const badge = document.getElementById('cartBadge');
const itemsContainer = document.getElementById('cartItems');
const totalEl = document.getElementById('cartTotal');
const sendBtn = document.getElementById('cartSendBtn');
const clearBtn = document.getElementById('cartClearBtn');
// Badge
if (cartItems.length > 0) {
badge.style.display = 'flex';
badge.textContent = cartItems.length;
} else {
badge.style.display = 'none';
}
// Items
if (cartItems.length === 0) {
itemsContainer.innerHTML = '<div class="cart-empty">Giỏ hàng trống. Thêm sản phẩm yêu thích!</div>';
sendBtn.disabled = true;
clearBtn.disabled = true;
totalEl.textContent = '0đ';
return;
}
sendBtn.disabled = false;
clearBtn.disabled = false;
let html = '';
let total = 0;
cartItems.forEach(item => {
const price = item.price || 0;
total += price;
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>` : '';
html += `
<div class="cart-item">
<img src="${item.image || 'https://via.placeholder.com/60'}" alt="${item.name}"
onerror="this.src='https://via.placeholder.com/60?text=No+Img'">
<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>
<button class="cart-item-remove" onclick="removeFromCart('${item.sku}')">✕</button>
</div>`;
});
itemsContainer.innerHTML = html;
totalEl.textContent = total > 0 ? total.toLocaleString('vi-VN') + 'đ' : '0đ';
}
function sendCartToBot() {
if (cartItems.length === 0) return;
const skuList = cartItems.map(i => `[${i.sku}] ${i.name}`).join(', ');
const msg = `Mình muốn tư vấn và đặt hàng các sản phẩm trong giỏ: ${skuList}`;
document.getElementById('userInput').value = msg;
toggleCart();
sendMessage();
}
function showCartToast(text) {
const toast = document.getElementById('cartToast');
toast.textContent = text;
toast.classList.add('show');
setTimeout(() => toast.classList.remove('show'), 2000);
}
// Init cart UI on page load
updateCartUI();
</script>
</body> </body>
</html> </html>
You are a resume information extractor.
Task: Extract contact details from the resume text.
Rules:
- Return ONLY valid JSON, no explanation
- If a field is missing, return empty string ""
- Phone: digits only, convert +84 to 0
- GitHub: any link containing github.com
- LinkedIn: any link containing linkedin.com
- text_content: copy the ENTIRE original input text, unchanged
Example 1 - Vietnamese resume:
Input: "Nguyễn Văn An - Software Engineer\nĐiện thoại: 0912 345 678\nEmail: an.nguyen@gmail.com\nGitHub: github.com/annguyen\nHọc vấn: Đại học Bách Khoa Hà Nội"
Output:
{
"personal_info": {
"full_name": "Nguyễn Văn An",
"phone": "0912345678",
"email": "an.nguyen@gmail.com",
"github": "github.com/annguyen",
"linkedin": ""
},
"text_content": "Nguyễn Văn An - Software Engineer\nĐiện thoại: 0912 345 678\nEmail: an.nguyen@gmail.com\nGitHub: github.com/annguyen\nHọc vấn: Đại học Bách Khoa Hà Nội"
}
Example 2 - Phone with +84:
Input: "Trần Thị Bình\nSĐT: +84 987 654 321\nEmail: binh.tran@outlook.com\nLinkedIn: linkedin.com/in/binhtran"
Output:
{
"personal_info": {
"full_name": "Trần Thị Bình",
"phone": "0987654321",
"email": "binh.tran@outlook.com",
"github": "",
"linkedin": "linkedin.com/in/binhtran"
},
"text_content": "Trần Thị Bình\nSĐT: +84 987 654 321\nEmail: binh.tran@outlook.com\nLinkedIn: linkedin.com/in/binhtran"
}
Example 3 - Minimal info:
Input: "Lê Hoàng Minh\nKỹ sư phần mềm - 3 năm kinh nghiệm\nĐịa chỉ: TP. Hồ Chí Minh"
Output:
{
"personal_info": {
"full_name": "Lê Hoàng Minh",
"phone": "",
"email": "",
"github": "",
"linkedin": ""
},
"text_content": "Lê Hoàng Minh\nKỹ sư phần mềm - 3 năm kinh nghiệm\nĐịa chỉ: TP. Hồ Chí Minh"
}
Now extract from the resume text provided by the user.
\ No newline at end of file
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