Commit 7b429387 authored by Vũ Hoàng Anh's avatar Vũ Hoàng Anh

feat: add chat history analytics, product guide, experiment edit, docker...

feat: add chat history analytics, product guide, experiment edit, docker network 10.101.10.x, workers=1
parent 6d50eb6b
Pipeline #3377 canceled with stage
......@@ -28,4 +28,4 @@ EXPOSE 5000
HEALTHCHECK --interval=10s --timeout=5s --start-period=5s --retries=2 \
CMD python -c "import requests; requests.get('http://localhost:5000/docs')" || exit 1
CMD ["gunicorn", "--workers", "4", "--worker-class", "uvicorn.workers.UvicornWorker", "--bind", "0.0.0.0:5000", "--timeout", "120", "--reload", "server:app"]
CMD ["gunicorn", "--workers", "1", "--worker-class", "uvicorn.workers.UvicornWorker", "--bind", "0.0.0.0:5000", "--timeout", "120", "--reload", "server:app"]
......@@ -16,7 +16,7 @@ COPY . .
EXPOSE 5000
ENV WORKERS=16
ENV WORKERS=1
ENV TIMEOUT=60
CMD gunicorn server:app --workers $WORKERS --worker-class uvicorn.workers.UvicornWorker --bind 0.0.0.0:5000 --timeout $TIMEOUT
......@@ -61,22 +61,34 @@ async def extract_and_save_user_insight(json_content: str, identity_key: str) ->
# Normalize double braces → single (LLM sometimes outputs {{ }} per prompt instruction)
normalized = json_content.replace("{{", "{").replace("}}", "}")
# Regex match user_insight object
insight_match = re.search(r'"user_insight"\s*:\s*(\{.*?\})\s*}?\s*$', normalized, re.DOTALL)
if insight_match:
insight_json_str = insight_match.group(1)
# Parse to validate
insight_dict = json.loads(insight_json_str)
insight_str = json.dumps(insight_dict, ensure_ascii=False, indent=2)
# Save to Redis
await save_user_insight_to_redis(identity_key, insight_str)
elapsed = time.time() - start_time
logger.warning(f"✅ [user_insight] Extracted + saved in {elapsed:.2f}s | Key: {identity_key}")
return insight_dict
logger.warning(f"⚠️ [Background] No user_insight found in JSON for {identity_key}")
# Try multiple regex patterns for user_insight
patterns = [
# Pattern 1: Standard JSON key-value
r'"user_insight"\s*:\s*(\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\})',
# Pattern 2: user_insight at end of JSON
r'"user_insight"\s*:\s*(\{.*?\})\s*\}?\s*$',
# Pattern 3: Relaxed match
r'"user_insight"\s*:\s*(\{[^}]+\})',
]
for pattern in patterns:
insight_match = re.search(pattern, normalized, re.DOTALL)
if insight_match:
try:
insight_json_str = insight_match.group(1)
insight_dict = json.loads(insight_json_str)
insight_str = json.dumps(insight_dict, ensure_ascii=False, indent=2)
# Save to Redis
await save_user_insight_to_redis(identity_key, insight_str)
elapsed = time.time() - start_time
logger.warning(f"✅ [user_insight] Extracted + saved in {elapsed:.2f}s | Key: {identity_key}")
return insight_dict
except json.JSONDecodeError:
continue # Try next pattern
# Not found — this is normal for some responses (e.g. tool-only, greetings)
logger.debug(f"[Background] No user_insight in response for {identity_key} (normal for non-product queries)")
return None
except Exception as e:
......
......@@ -39,8 +39,8 @@ PRODUCT_LINE_MAP: dict[str, list[str]] = {
"Quần soóc": ["quần soóc", "quần đùi", "quần short", "quần lửng", "quần ngố"],
"Quần nỉ": ["quần nỉ", "quần jogger", "quần ống bo chun"],
"Quần mặc nhà": ["quần mặc nhà"],
"Quần lót đùi": ["quần lót đùi"],
"Quần lót tam giác": ["quần lót tam giác"],
"Quần lót đùi": ["quần lót đùi", "quần sịp đùi", "quần boxer"],
"Quần lót tam giác": ["quần lót tam giác", "quần sịp tam giác", "quần brief"],
"Quần lót": ["quần lót", "quần chip", "quần sịp", "quần trong", "quần nhỏ", "quần xơ lít", "quần xì", "quần lót nữ", "quần lót nam", "quần lót trẻ em", "quần sơ lít"],
"Quần leggings mặc nhà": ["quần leggings mặc nhà"],
"Quần leggings": ["quần leggings"],
......@@ -82,6 +82,10 @@ for db_value, synonyms in PRODUCT_LINE_MAP.items():
RELATED_LINES: dict[str, list[str]] = {
"Áo bra active": ["Áo lót"],
"Áo lót": ["Áo bra active"],
# Quần lót (chung) → mở rộng tìm cả Quần lót đùi (Trunk) + Quần lót tam giác (Brief)
"Quần lót": ["Quần lót đùi", "Quần lót tam giác"],
"Quần lót đùi": ["Quần lót", "Quần lót tam giác"],
"Quần lót tam giác": ["Quần lót", "Quần lót đùi"],
}
......@@ -127,4 +131,74 @@ def resolve_product_line(raw_value: str) -> list[str]:
resolved.append(mapped)
else:
resolved.append(part)
return resolved
\ No newline at end of file
return resolved
# ==============================================================================
# COLOR MAPPING: User synonym → DB master_color
# DB values: "Đen/ Black", "Trắng/ White", "Xanh da trời/ Blue", etc.
# ==============================================================================
COLOR_MAP: dict[str, list[str]] = {
"Đen/ Black": ["đen", "black", "tối", "màu đen"],
"Trắng/ White": ["trắng", "white", "màu trắng"],
"Xanh da trời/ Blue": ["xanh da trời", "xanh dương", "blue", "xanh biển", "xanh nước biển", "màu xanh"],
"Xám/ Gray": ["xám", "gray", "grey", "ghi", "màu xám", "xám nhạt", "xám đậm"],
"Be/ Beige": ["be", "beige", "kem", "màu be", "nude", "da", "màu da"],
"Hồng/ Pink- Magenta": ["hồng", "pink", "magenta", "hồng nhạt", "hồng đậm", "màu hồng", "cánh sen"],
"Đỏ/ Red": ["đỏ", "red", "màu đỏ", "đỏ đô", "đỏ tươi"],
"Xanh rêu/ Olive- Army": ["xanh rêu", "olive", "army", "xanh quân đội", "rêu", "xanh lá"],
"Nâu/ Brown": ["nâu", "brown", "màu nâu", "nâu đất", "coffee", "cà phê"],
"Cam/ Orange": ["cam", "orange", "màu cam"],
"Vàng/ Yellow": ["vàng", "yellow", "màu vàng", "vàng nhạt", "vàng chanh"],
"Tím/ Purple": ["tím", "purple", "violet", "màu tím"],
"Xanh lá/ Green": ["xanh lá", "green", "xanh lá cây", "xanh ngọc"],
"Navy/ Navy Blue": ["navy", "xanh navy", "xanh đen", "xanh than"],
}
# Auto-generate reverse lookup: synonym → DB master_color
COLOR_SYNONYM_TO_DB: dict[str, str] = {}
for db_color, synonyms in COLOR_MAP.items():
for syn in synonyms:
COLOR_SYNONYM_TO_DB[syn.lower()] = db_color
# Pre-sort color synonyms by length DESC for longest-match-first
_SORTED_COLOR_SYNONYMS = sorted(COLOR_SYNONYM_TO_DB.keys(), key=len, reverse=True)
def resolve_color(raw_color: str) -> str | None:
"""
Map user color term → DB master_color value.
VD:
"hồng" → "Hồng/ Pink- Magenta"
"xanh dương" → "Xanh da trời/ Blue"
"đen" → "Đen/ Black"
"unknown" → None
"""
color_lower = raw_color.lower().strip()
for synonym in _SORTED_COLOR_SYNONYMS:
if color_lower == synonym or color_lower.startswith(synonym):
return COLOR_SYNONYM_TO_DB[synonym]
return None
# ==============================================================================
# AGE GROUP MAPPING: User synonym → DB age_by_product
# ==============================================================================
AGE_MAP: dict[str, list[str]] = {
"người lớn": ["người lớn", "adult"],
"bé trai": ["bé trai", "con trai", "nam nhí"],
"bé gái": ["bé gái", "con gái", "nữ nhí"],
"trẻ em": ["trẻ em", "em bé", "kids", "children", "nhí"],
}
AGE_SYNONYM_TO_DB: dict[str, str] = {}
for db_age, synonyms in AGE_MAP.items():
for syn in synonyms:
AGE_SYNONYM_TO_DB[syn.lower()] = db_age
def resolve_age(raw_age: str) -> str | None:
"""Map user age term → DB age_by_product value."""
return AGE_SYNONYM_TO_DB.get(raw_age.lower().strip())
"""
Cache Management API
GET /api/cache/stats — Get cache statistics + key breakdown
GET /api/cache/keys — List all cached keys with TTL
POST /api/cache/clear — Clear cache by pattern
POST /api/cache/clear-all — Flush all cache
"""
import logging
from fastapi import APIRouter
from fastapi.responses import JSONResponse
from common.cache import redis_cache
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/cache", tags=["Cache"])
@router.get("/stats")
async def cache_stats():
"""Get cache statistics and key breakdown."""
client = redis_cache.get_client()
if not client:
return JSONResponse({"error": "Redis not available"}, status_code=503)
try:
info = await client.info("memory")
db_size = await client.dbsize()
# Scan all keys and categorize
categories = {}
async for key in client.scan_iter("*", count=500):
prefix = key.split(":")[0] if ":" in key else "(no prefix)"
if prefix not in categories:
categories[prefix] = {"count": 0, "keys": []}
categories[prefix]["count"] += 1
if len(categories[prefix]["keys"]) < 5:
ttl = await client.ttl(key)
categories[prefix]["keys"].append({"key": key, "ttl": ttl})
return {
"status": "connected",
"memory_used": info.get("used_memory_human", "?"),
"memory_peak": info.get("used_memory_peak_human", "?"),
"total_keys": db_size,
"in_memory_stats": redis_cache._stats,
"categories": categories,
}
except Exception as e:
logger.error(f"Cache stats error: {e}")
return JSONResponse({"error": str(e)}, status_code=500)
@router.get("/keys")
async def list_keys(pattern: str = "*", limit: int = 100):
"""List cached keys matching pattern with TTL info."""
client = redis_cache.get_client()
if not client:
return JSONResponse({"error": "Redis not available"}, status_code=503)
try:
keys = []
count = 0
async for key in client.scan_iter(pattern, count=500):
if count >= limit:
break
ttl = await client.ttl(key)
key_type = await client.type(key)
size = await client.strlen(key) if key_type == "string" else -1
keys.append({
"key": key,
"type": key_type,
"ttl": ttl,
"size": size,
})
count += 1
return {"pattern": pattern, "count": len(keys), "keys": keys}
except Exception as e:
logger.error(f"List keys error: {e}")
return JSONResponse({"error": str(e)}, status_code=500)
@router.post("/clear")
async def clear_pattern(pattern: str = "resp_cache:*"):
"""Clear cache keys matching pattern."""
client = redis_cache.get_client()
if not client:
return JSONResponse({"error": "Redis not available"}, status_code=503)
try:
deleted = 0
async for key in client.scan_iter(pattern, count=500):
await client.delete(key)
deleted += 1
logger.info(f"🗑️ Cleared {deleted} keys matching '{pattern}'")
return {"deleted": deleted, "pattern": pattern}
except Exception as e:
logger.error(f"Clear cache error: {e}")
return JSONResponse({"error": str(e)}, status_code=500)
@router.post("/clear-all")
async def clear_all():
"""Flush entire cache database."""
client = redis_cache.get_client()
if not client:
return JSONResponse({"error": "Redis not available"}, status_code=503)
try:
await client.flushdb()
redis_cache._stats = {"resp_hits": 0, "emb_hits": 0, "misses": 0}
logger.info("🗑️ FLUSHED all cache")
return {"status": "flushed", "message": "All cache cleared"}
except Exception as e:
logger.error(f"Flush cache error: {e}")
return JSONResponse({"error": str(e)}, status_code=500)
@router.get("/get")
async def get_key(key: str):
"""Get the value stored in a specific cache key."""
client = redis_cache.get_client()
if not client:
return JSONResponse({"error": "Redis not available"}, status_code=503)
try:
key_type = await client.type(key)
ttl = await client.ttl(key)
value = None
if key_type == "string":
raw = await client.get(key)
# Try to parse as JSON
try:
import json
value = json.loads(raw)
except Exception:
value = raw
elif key_type == "hash":
value = await client.hgetall(key)
elif key_type == "list":
value = await client.lrange(key, 0, 50)
elif key_type == "set":
value = list(await client.smembers(key))
elif key_type == "zset":
value = await client.zrange(key, 0, 50, withscores=True)
else:
value = f"(unsupported type: {key_type})"
return {
"key": key,
"type": key_type,
"ttl": ttl,
"value": value,
}
except Exception as e:
logger.error(f"Get key error: {e}")
return JSONResponse({"error": str(e)}, status_code=500)
......@@ -8,7 +8,7 @@ from datetime import datetime
from pathlib import Path
from typing import Optional
from fastapi import APIRouter, HTTPException
from fastapi import APIRouter, Header, HTTPException
from pydantic import BaseModel
router = APIRouter(prefix="/api/dashboard", tags=["Experiment Links"])
......@@ -75,7 +75,7 @@ async def get_link(link_id: str):
@router.post("/experiment-links", summary="Add experiment link")
async def add_link(body: LinkCreate):
async def add_link(body: LinkCreate, x_author: str = Header(default="Admin")):
data = _load()
item = {
"id": f"exp_{uuid.uuid4().hex[:8]}",
......@@ -88,6 +88,7 @@ async def add_link(body: LinkCreate):
"description": body.description or "",
"versions": [],
"created_at": datetime.now().isoformat(),
"author": x_author,
}
data.append(item)
_save(data)
......@@ -95,11 +96,13 @@ async def add_link(body: LinkCreate):
@router.put("/experiment-links/{link_id}", summary="Update experiment link")
async def update_link(link_id: str, body: LinkUpdate):
async def update_link(link_id: str, body: LinkUpdate, x_author: str = Header(default="Admin")):
data = _load()
item = _find(data, link_id)
if not item:
raise HTTPException(404, "Link not found")
if item.get("author", "Admin") != x_author and x_author != "Admin":
raise HTTPException(403, f"Bạn không thể sửa link của {item.get('author')}")
if body.name is not None: item["name"] = body.name
if body.url is not None: item["url"] = body.url
if body.icon is not None: item["icon"] = body.icon
......@@ -111,8 +114,11 @@ async def update_link(link_id: str, body: LinkUpdate):
@router.delete("/experiment-links/{link_id}", summary="Delete experiment link")
async def delete_link(link_id: str):
async def delete_link(link_id: str, x_author: str = Header(default="Admin")):
data = _load()
item = _find(data, link_id)
if item and item.get("author", "Admin") != x_author and x_author != "Admin":
raise HTTPException(403, f"Bạn không thể xóa link của {item.get('author')}")
new_data = [x for x in data if x["id"] != link_id]
if len(new_data) == len(data):
raise HTTPException(404, "Link not found")
......
......@@ -9,7 +9,7 @@ from datetime import datetime
from typing import Optional
from pathlib import Path
from fastapi import APIRouter, HTTPException
from fastapi import APIRouter, Header, HTTPException
from pydantic import BaseModel
router = APIRouter(prefix="/api/dashboard", tags=["Dashboard Notes"])
......@@ -63,7 +63,7 @@ async def list_notes():
@router.post("/notes")
async def create_note(note: NoteCreate):
async def create_note(note: NoteCreate, x_author: str = Header(default="Admin")):
"""Create a new note."""
notes = _load_notes()
now = datetime.now().isoformat()
......@@ -76,7 +76,7 @@ async def create_note(note: NoteCreate):
"color": note.color or _default_color(note.category),
"created_at": now,
"updated_at": now,
"author": "Admin",
"author": x_author,
}
notes.append(new_note)
_save_notes(notes)
......@@ -84,11 +84,13 @@ async def create_note(note: NoteCreate):
@router.put("/notes/{note_id}")
async def update_note(note_id: str, update: NoteUpdate):
"""Update an existing note."""
async def update_note(note_id: str, update: NoteUpdate, x_author: str = Header(default="Admin")):
"""Update an existing note. Only the original author can edit."""
notes = _load_notes()
for i, n in enumerate(notes):
if n["id"] == note_id:
if n.get("author", "Admin") != x_author and x_author != "Admin":
raise HTTPException(status_code=403, detail=f"Bạn không thể sửa note của {n.get('author')}")
if update.title is not None:
notes[i]["title"] = update.title
if update.content is not None:
......@@ -106,9 +108,14 @@ async def update_note(note_id: str, update: NoteUpdate):
@router.delete("/notes/{note_id}")
async def delete_note(note_id: str):
"""Delete a note."""
async def delete_note(note_id: str, x_author: str = Header(default="Admin")):
"""Delete a note. Only the original author can delete."""
notes = _load_notes()
for n in notes:
if n["id"] == note_id:
if n.get("author", "Admin") != x_author and x_author != "Admin":
raise HTTPException(status_code=403, detail=f"Bạn không thể xóa note của {n.get('author')}")
break
original_len = len(notes)
notes = [n for n in notes if n["id"] != note_id]
if len(notes) == original_len:
......@@ -190,7 +197,7 @@ async def list_links():
@router.post("/links")
async def create_link(link: LinkCreate):
async def create_link(link: LinkCreate, x_author: str = Header(default="Admin")):
"""Create a new resource link."""
links = _load_links()
now = datetime.now().isoformat()
......@@ -203,7 +210,7 @@ async def create_link(link: LinkCreate):
"icon": link.icon or _default_icon(link.category),
"pinned": link.pinned,
"created_at": now,
"author": "Admin",
"author": x_author,
}
links.append(new_link)
_save_links(links)
......@@ -211,11 +218,13 @@ async def create_link(link: LinkCreate):
@router.put("/links/{link_id}")
async def update_link(link_id: str, update: LinkUpdate):
"""Update an existing resource link."""
async def update_link(link_id: str, update: LinkUpdate, x_author: str = Header(default="Admin")):
"""Update an existing resource link. Only original author can edit."""
links = _load_links()
for i, l in enumerate(links):
if l["id"] == link_id:
if l.get("author", "Admin") != x_author and x_author != "Admin":
raise HTTPException(status_code=403, detail=f"Bạn không thể sửa link của {l.get('author')}")
for field in ["title", "url", "description", "category", "icon", "pinned"]:
val = getattr(update, field, None)
if val is not None:
......@@ -226,9 +235,14 @@ async def update_link(link_id: str, update: LinkUpdate):
@router.delete("/links/{link_id}")
async def delete_link(link_id: str):
"""Delete a resource link."""
async def delete_link(link_id: str, x_author: str = Header(default="Admin")):
"""Delete a resource link. Only original author can delete."""
links = _load_links()
for l in links:
if l["id"] == link_id:
if l.get("author", "Admin") != x_author and x_author != "Admin":
raise HTTPException(status_code=403, detail=f"Bạn không thể xóa link của {l.get('author')}")
break
original_len = len(links)
links = [l for l in links if l["id"] != link_id]
if len(links) == original_len:
......@@ -318,4 +332,60 @@ async def delete_changelog_entry(entry_id: str):
raise HTTPException(status_code=404, detail="Entry not found")
_save_changelog(entries)
return {"status": "success", "message": "Entry deleted"}
\ No newline at end of file
# ═══════════════════════════════════════════════════
# USERS / ROLES
# ═══════════════════════════════════════════════════
USERS_FILE = Path(__file__).parent.parent / "data" / "users.json"
def _load_users() -> list:
if not USERS_FILE.exists():
USERS_FILE.parent.mkdir(parents=True, exist_ok=True)
USERS_FILE.write_text('[{"name":"Admin","role":"admin"}]', encoding="utf-8")
return [{"name": "Admin", "role": "admin"}]
try:
return json.loads(USERS_FILE.read_text(encoding="utf-8"))
except (json.JSONDecodeError, FileNotFoundError):
return []
def _save_users(users: list):
USERS_FILE.parent.mkdir(parents=True, exist_ok=True)
USERS_FILE.write_text(json.dumps(users, ensure_ascii=False, indent=2), encoding="utf-8")
class UserCreate(BaseModel):
name: str
role: str = "user" # admin | user
@router.get("/users")
async def list_users():
"""Get all users and their roles."""
return {"status": "success", "users": _load_users()}
@router.post("/users")
async def add_user(user: UserCreate):
"""Add a new user role."""
users = _load_users()
# Update if exists
for u in users:
if u["name"].lower() == user.name.lower():
u["role"] = user.role
_save_users(users)
return {"status": "success", "message": f"Updated {user.name} to {user.role}", "users": users}
users.append({"name": user.name, "role": user.role})
_save_users(users)
return {"status": "success", "message": f"Added {user.name} as {user.role}", "users": users}
@router.delete("/users/{name}")
async def remove_user(name: str):
"""Remove a user role entry."""
users = _load_users()
users = [u for u in users if u["name"].lower() != name.lower()]
_save_users(users)
return {"status": "success", "users": users}
\ No newline at end of file
"""
Product Performance API Route
Provides real-time product data from StarRocks for the Product Performance dashboard.
"""
import logging
from typing import Optional
from fastapi import APIRouter, Query
from fastapi.responses import JSONResponse
from common.starrocks_connection import StarRocksConnection
from agent.tools.product_mapping import resolve_color, resolve_product_name, SYNONYM_TO_DB
logger = logging.getLogger(__name__)
router = APIRouter()
TABLE_NAME = "shared_source.magento_product_dimension_with_text_embedding"
@router.get("/api/products/overview", summary="Product catalog KPI overview")
async def products_overview():
"""Return aggregate KPIs for the product catalog."""
db = StarRocksConnection()
try:
rows = await db.execute_query_async(f"""
SELECT
COUNT(DISTINCT internal_ref_code) AS total_products,
COUNT(DISTINCT product_line_vn) AS total_lines,
COUNT(DISTINCT gender_by_product) AS total_genders,
ROUND(MIN(sale_price)) AS min_price,
ROUND(MAX(sale_price)) AS max_price,
ROUND(AVG(sale_price)) AS avg_price,
SUM(CASE WHEN sale_price < original_price AND original_price > 0 THEN 1 ELSE 0 END) AS discounted_count,
SUM(CASE WHEN is_new_product = 1 THEN 1 ELSE 0 END) AS new_count,
SUM(CASE WHEN quantity_sold > 0 THEN 1 ELSE 0 END) AS selling_count,
SUM(quantity_sold) AS total_sold
FROM {TABLE_NAME}
""")
# Get top product lines breakdown
lines = await db.execute_query_async(f"""
SELECT
product_line_vn,
COUNT(DISTINCT internal_ref_code) AS count,
ROUND(AVG(sale_price)) AS avg_price,
SUM(quantity_sold) AS total_sold
FROM {TABLE_NAME}
WHERE product_line_vn IS NOT NULL AND product_line_vn != ''
GROUP BY product_line_vn
ORDER BY count DESC
LIMIT 15
""")
# Gender breakdown
genders = await db.execute_query_async(f"""
SELECT
gender_by_product,
COUNT(DISTINCT internal_ref_code) AS count
FROM {TABLE_NAME}
WHERE gender_by_product IS NOT NULL AND gender_by_product != ''
GROUP BY gender_by_product
ORDER BY count DESC
""")
stats = rows[0] if rows else {}
return {
"status": "success",
"stats": {
"total_products": stats.get("total_products", 0),
"total_lines": stats.get("total_lines", 0),
"min_price": stats.get("min_price", 0),
"max_price": stats.get("max_price", 0),
"avg_price": stats.get("avg_price", 0),
"discounted_count": stats.get("discounted_count", 0),
"new_count": stats.get("new_count", 0),
"selling_count": stats.get("selling_count", 0),
"total_sold": stats.get("total_sold", 0),
},
"product_lines": lines,
"genders": genders,
}
except Exception as e:
logger.error(f"❌ Product overview error: {e}")
return JSONResponse(
status_code=500,
content={"status": "error", "message": str(e)},
)
@router.get("/api/products/list", summary="Product listing grouped by internal_ref_code")
async def products_list(
sort: str = Query("quantity_sold", description="Sort field"),
order: str = Query("desc", description="Sort order: asc or desc"),
gender: Optional[str] = Query(None, description="Filter by gender"),
age: Optional[str] = Query(None, description="Filter by age group"),
product_line: Optional[str] = Query(None, description="Filter by product line"),
search: Optional[str] = Query(None, description="Search by name or code"),
color: Optional[str] = Query(None, description="Filter by master_color (user-friendly term)"),
is_new: Optional[bool] = Query(None, description="Filter new products"),
has_discount: Optional[bool] = Query(None, description="Filter discounted"),
limit: int = Query(50, ge=1, le=500),
offset: int = Query(0, ge=0),
):
"""Return paginated product listing grouped by internal_ref_code."""
db = StarRocksConnection()
# Build WHERE clauses
clauses = []
params = []
if gender:
clauses.append("gender_by_product = %s")
params.append(gender.lower())
if age:
clauses.append("age_by_product = %s")
params.append(age.lower())
if product_line:
clauses.append("LOWER(product_line_vn) LIKE %s")
params.append(f"%{product_line.lower()}%")
if search:
# Resolve synonym: "áo thun" → "Áo phông", "quần bò" → "Quần jean"
resolved_name = resolve_product_name(search)
search_lower = search.lower()
# Check if search matches a product_line synonym
matched_line = SYNONYM_TO_DB.get(search_lower)
if matched_line and resolved_name.lower() != search_lower:
# User searched a synonym that maps to a DB product_line
# Search by: original term OR resolved name OR exact product_line match
clauses.append(
"(LOWER(product_name) LIKE %s OR LOWER(product_name) LIKE %s "
"OR LOWER(product_line_vn) = %s "
"OR internal_ref_code LIKE %s OR magento_ref_code LIKE %s)"
)
params.extend([
f"%{search_lower}%",
f"%{resolved_name.lower()}%",
matched_line.lower(),
f"%{search.upper()}%",
f"%{search.upper()}%",
])
logger.info(f"🔍 Search mapped: '{search}' → '{resolved_name}' (line: {matched_line})")
else:
# No synonym match — standard search
clauses.append(
"(LOWER(product_name) LIKE %s OR internal_ref_code LIKE %s OR magento_ref_code LIKE %s)"
)
params.extend([f"%{search_lower}%", f"%{search.upper()}%", f"%{search.upper()}%"])
if is_new is True:
clauses.append("is_new_product = 1")
if has_discount is True:
clauses.append("sale_price < original_price AND original_price > 0")
if color:
# Map user-friendly color term to DB value
db_color = resolve_color(color)
if db_color:
clauses.append("master_color = %s")
params.append(db_color)
else:
# Fallback: partial match on DB value
clauses.append("LOWER(master_color) LIKE %s")
params.append(f"%{color.lower()}%")
where_str = " AND ".join(clauses) if clauses else "1=1"
# Validate sort field (prevent injection)
allowed_sorts = {
"quantity_sold", "sale_price", "original_price", "product_name",
"discount_percent", "internal_ref_code",
}
if sort not in allowed_sorts:
sort = "quantity_sold"
order_dir = "ASC" if order.lower() == "asc" else "DESC"
try:
# Get total count (grouped)
count_rows = await db.execute_query_async(
f"SELECT COUNT(DISTINCT internal_ref_code) AS total FROM {TABLE_NAME} WHERE {where_str}",
params=tuple(params) if params else None,
)
total = count_rows[0]["total"] if count_rows else 0
# Get products GROUPED by internal_ref_code
products = await db.execute_query_async(
f"""
SELECT
internal_ref_code,
ANY_VALUE(product_name) AS product_name,
ANY_VALUE(product_image_url_thumbnail) AS product_image_url_thumbnail,
ANY_VALUE(product_web_url) AS product_web_url,
MIN(sale_price) AS sale_price,
MAX(original_price) AS original_price,
ROUND(CASE WHEN MAX(original_price) > 0
THEN ((MAX(original_price) - MIN(sale_price)) / MAX(original_price) * 100)
ELSE 0 END, 0) AS discount_percent,
ANY_VALUE(age_by_product) AS age_by_product,
ANY_VALUE(gender_by_product) AS gender_by_product,
ANY_VALUE(product_line_vn) AS product_line_vn,
MAX(quantity_sold) AS quantity_sold,
MAX(is_new_product) AS is_new_product,
ANY_VALUE(size_scale) AS size_scale,
COUNT(DISTINCT product_color_code) AS color_count,
GROUP_CONCAT(DISTINCT product_color_code) AS color_codes,
GROUP_CONCAT(DISTINCT master_color) AS colors
FROM {TABLE_NAME}
WHERE {where_str}
GROUP BY internal_ref_code
ORDER BY {sort} {order_dir}
LIMIT %s OFFSET %s
""",
params=tuple(params + [limit, offset]),
)
return {
"status": "success",
"total": total,
"limit": limit,
"offset": offset,
"products": products,
}
except Exception as e:
logger.error(f"❌ Product list error: {e}")
return JSONResponse(
status_code=500,
content={"status": "error", "message": str(e)},
)
@router.get("/api/products/colors", summary="Color variants for a product")
async def products_colors(
code: str = Query(..., description="internal_ref_code"),
):
"""Return all color variants for a given internal_ref_code."""
db = StarRocksConnection()
try:
variants = await db.execute_query_async(
f"""
SELECT
product_color_code,
master_color,
product_color_name,
product_image_url_thumbnail,
product_web_url,
sale_price,
original_price,
quantity_sold,
size_scale
FROM {TABLE_NAME}
WHERE internal_ref_code = %s
ORDER BY quantity_sold DESC
""",
params=(code,),
)
return {"status": "success", "variants": variants}
except Exception as e:
logger.error(f"❌ Product colors error: {e}")
return JSONResponse(
status_code=500,
content={"status": "error", "message": str(e)},
)
@router.get("/api/products/filters", summary="Available filter options")
async def products_filters():
"""Return all available filter values for color, product_line, age, season."""
db = StarRocksConnection()
try:
colors = await db.execute_query_async(f"""
SELECT master_color, COUNT(DISTINCT internal_ref_code) AS cnt
FROM {TABLE_NAME}
WHERE master_color IS NOT NULL AND master_color != ''
GROUP BY master_color ORDER BY cnt DESC
""")
lines = await db.execute_query_async(f"""
SELECT product_line_vn, COUNT(DISTINCT internal_ref_code) AS cnt
FROM {TABLE_NAME}
WHERE product_line_vn IS NOT NULL AND product_line_vn != ''
GROUP BY product_line_vn ORDER BY cnt DESC
""")
ages = await db.execute_query_async(f"""
SELECT age_by_product, COUNT(DISTINCT internal_ref_code) AS cnt
FROM {TABLE_NAME}
WHERE age_by_product IS NOT NULL AND age_by_product != ''
GROUP BY age_by_product ORDER BY cnt DESC
""")
seasons = await db.execute_query_async(f"""
SELECT season, COUNT(DISTINCT internal_ref_code) AS cnt
FROM {TABLE_NAME}
WHERE season IS NOT NULL AND season != ''
GROUP BY season ORDER BY cnt DESC
""")
return {
"status": "success",
"colors": colors,
"product_lines": lines,
"ages": ages,
"seasons": seasons,
}
except Exception as e:
logger.error(f"❌ Product filters error: {e}")
return JSONResponse(
status_code=500,
content={"status": "error", "message": str(e)},
)
"""
AI Data Analyst — Text-to-SQL Chat API.
POST /api/sql-chat
Input: { question: str, history: list[{role, content}] }
Output: { sql: str, data: list, columns: list, chart_type: str, x_key, y_keys, explanation: str }
Uses Codex (ChatGPT backend API) to generate SQL from Vietnamese questions,
executes on StarRocks, and returns structured results with optional Chart.js config.
"""
import json
import logging
import re
import traceback
from typing import Any
from fastapi import APIRouter, HTTPException
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from common.starrocks_connection import StarRocksConnection
from common.postgres_readonly import PostgresReadonly
logger = logging.getLogger(__name__)
router = APIRouter()
TABLE_NAME = "shared_source.magento_product_dimension_with_text_embedding"
# ─── Database Schema (provided to LLM as context) ────────────────────
DB_SCHEMA = f"""
## Database: StarRocks
## Table: {TABLE_NAME}
### Columns (SELECT only, KHÔNG query cột 'vector'):
- internal_ref_code (VARCHAR) — Mã sản phẩm nội bộ, VD: "8TP25A005"
- magento_ref_code (VARCHAR) — Mã ref = internal_ref_code + color, VD: "8TP25A005-SW011"
- product_color_code (VARCHAR) — Mã màu sản phẩm
- product_name (VARCHAR) — Tên sản phẩm, VD: "ÁO THUN NAM"
- color_code (VARCHAR) — Mã màu
- master_color (VARCHAR) — Nhóm màu chính: "Trắng", "Đen", "Đỏ", etc.
- product_color_name (VARCHAR) — Tên màu đầy đủ
- season_sale (VARCHAR) — Mùa sale
- season (VARCHAR) — Mùa: "Xuân Hè", "Thu Đông", etc.
- style (VARCHAR) — Phong cách sản phẩm
- fitting (VARCHAR) — Kiểu form: "Regular Fit", "Slim Fit", etc.
- size_scale (VARCHAR) — Thang size: "XS-XL", "1-6", etc.
- graphic (VARCHAR) — Họa tiết
- pattern (VARCHAR) — Hoa văn
- weaving (VARCHAR) — Kiểu dệt
- shape_detail (VARCHAR) — Chi tiết kiểu dáng
- form_neckline (VARCHAR) — Kiểu cổ: "Cổ tròn", "Cổ V", etc.
- form_sleeve (VARCHAR) — Kiểu tay: "Ngắn tay", "Dài tay", etc.
- form_length (VARCHAR) — Kiểu dài
- form_waistline (VARCHAR) — Kiểu eo
- form_shoulderline (VARCHAR) — Kiểu vai
- material (VARCHAR) — Chất liệu
- product_group (VARCHAR) — Nhóm sản phẩm
- product_line_vn (VARCHAR) — Dòng sản phẩm VN: "Áo Thun", "Quần Jean", "Váy/Đầm", etc.
- unit_of_measure (VARCHAR) — Đơn vị
- sale_price (DECIMAL) — Giá bán
- original_price (DECIMAL) — Giá gốc
- discount_amount (DECIMAL) — Số tiền giảm giá (= original_price - sale_price)
- material_group (VARCHAR) — Nhóm chất liệu
- product_line_en (VARCHAR) — Dòng sản phẩm EN
- age_by_product (VARCHAR) — Nhóm tuổi
- gender_by_product (VARCHAR) — Giới tính: "nam", "nữ", "unisex", "bé trai", "bé gái"
- quantity_sold (DECIMAL) — Số lượng đã bán
- is_new_product (TINYINT) — Sản phẩm mới: 1 = mới, 0 = cũ
- product_image_url (VARCHAR) — URL ảnh sản phẩm
- description_text (VARCHAR) — Mô tả ngắn
- product_image_url_thumbnail (VARCHAR) — URL ảnh thumbnail
- product_web_url (VARCHAR) — URL trang sản phẩm
- product_web_material (VARCHAR) — Chất liệu trên web
- description_text_full (VARCHAR) — Mô tả đầy đủ
## NOTE: KHÔNG query cột 'vector' (quá lớn, dùng cho embedding)
"""
SYSTEM_PROMPT = f"""Bạn là AI Data Analyst cho Canifa (thương hiệu thời trang Việt Nam).
User sẽ hỏi bằng tiếng Việt về dữ liệu sản phẩm. Bạn cần:
1. Phân tích câu hỏi
2. Viết SQL query phù hợp
3. Chọn loại biểu đồ phù hợp nhất từ 25 loại
{DB_SCHEMA}
## 25 CHART TYPES:
| Type | Dùng khi |
|---|---|
| bar | So sánh categories (top sản phẩm, so sánh dòng) |
| horizontalBar | Bars ngang (tên dài, ranking) |
| multiBar | So sánh 2+ metrics cùng lúc |
| stackedBar | Tỷ lệ composition theo category |
| line | Trend theo thời gian |
| area | Volume theo thời gian (fill dưới line) |
| stackedArea | Composition thay đổi theo thời gian |
| pie | Phân bổ % (< 7 categories) |
| doughnut | Phân bổ % với tổng ở giữa |
| scatter | Correlation 2 biến (giá vs lượng bán) |
| combo | Bar + Line cùng lúc (VD: doanh thu + growth%) |
| bubble | 3 biến (x, y, size) |
| radar | So sánh đa chiều (spider chart) |
| polarArea | Phân bổ weighted |
| waterfall | Sequential tăng/giảm (breakdown) |
| numberCard | Chỉ 1 con số KPI lớn (tổng SP, avg giá) |
| gauge | Tiến độ % (% đạt target) |
| progress | Multiple progress bars (so sánh tiến độ) |
| funnel | Conversion pipeline (phễu) |
| table | Dữ liệu dạng bảng (không cần chart) |
| treemap | Hierarchical blocks |
| heatmap | Color-coded matrix |
| boxplot | Distribution statistics |
| sankey | Flow diagram |
| pivotTable | Cross-tab grouped table |
## RULES:
- CHỈNH sử dụng SELECT. TUYỆT ĐỐI KHÔNG dùng INSERT, UPDATE, DELETE, DROP, ALTER, CREATE.
- Luôn thêm LIMIT (tối đa 200 rows)
- KHÔNG query cột 'embedding' (quá lớn)
- Trả lời JSON format theo schema bên dưới
## OUTPUT FORMAT (trả về JSON):
```json
{{
"thinking": "Phân tích ngắn gọn câu hỏi",
"sql": "SELECT ... FROM {TABLE_NAME} WHERE ... LIMIT 50",
"chart_type": "bar|line|pie|doughnut|numberCard|...",
"x_key": "tên cột làm trục X hoặc labels",
"y_keys": ["tên cột(s) làm giá trị Y"],
"y_labels": {{"column_name": "Label hiển thị"}},
"trend": {{"value": 12.5, "direction": "up", "label": "vs tháng trước"}} hoặc null,
"total_label": "Tổng sản phẩm" (cho doughnut),
"gauge_max": 100 (cho gauge),
"footer": "Ghi chú thêm",
"explanation": "Giải thích kết quả bằng tiếng Việt"
}}
```
## TIPS CHỌN chart_type:
- Câu hỏi "có bao nhiêu" / "tổng" → numberCard
- Câu hỏi "top N" → bar hoặc horizontalBar
- Câu hỏi "phân bổ" / "tỷ lệ" → pie hoặc doughnut
- Câu hỏi "so sánh" 2+ metrics → multiBar hoặc combo
- Câu hỏi "trend" / "theo thời gian" → line hoặc area
- Câu hỏi "ranking" → horizontalBar hoặc progress
- Câu hỏi conversion → funnel
- Câu hỏi matrix / cross-tab → heatmap hoặc pivotTable
"""
# ─── Request/Response Models ─────────────────────────────────────────
class ChatMessage(BaseModel):
role: str # "user" or "assistant"
content: str
class SqlChatRequest(BaseModel):
question: str
history: list[ChatMessage] = []
model: str = "codex/gpt-5.3-codex"
class SqlChatResponse(BaseModel):
sql: str = ""
data: list[dict] = []
columns: list[str] = []
chart_type: str | None = None
explanation: str = ""
row_count: int = 0
error: str | None = None
# ─── SQL Safety ──────────────────────────────────────────────────────
_FORBIDDEN_PATTERNS = re.compile(
r"\b(INSERT|UPDATE|DELETE|DROP|ALTER|CREATE|TRUNCATE|REPLACE|MERGE|GRANT|REVOKE)\b",
re.IGNORECASE,
)
def _validate_sql(sql: str) -> str | None:
"""Validate SQL is safe (SELECT only). Returns error message or None."""
sql_clean = sql.strip().rstrip(";")
if not sql_clean.upper().startswith("SELECT"):
return "Only SELECT queries are allowed"
match = _FORBIDDEN_PATTERNS.search(sql_clean)
if match:
return f"Forbidden SQL keyword: {match.group()}"
# Block selecting the vector/embedding column (too large)
check_str = sql_clean.lower().replace("_text_embedding", "").replace("_embedding", "")
if re.search(r'\b(embedding|vector)\b', check_str):
return "Cannot query embedding/vector column"
return None
# ─── Codex LLM Call ──────────────────────────────────────────────────
async def _call_codex(question: str, history: list[ChatMessage], model_id: str = "codex/gpt-5.3-codex") -> dict:
"""Call Codex LLM to generate SQL from user question."""
from common.codex_auth import get_codex_access_token, get_codex_account_id, get_refresh_token
access_token = get_codex_access_token()
if not access_token:
raise HTTPException(status_code=503, detail="Codex auth not available. Run `codex login` first.")
account_id = get_codex_account_id() or ""
refresh_token = get_refresh_token()
# Strip codex/ prefix to get actual model name for the API
actual_model = model_id.replace("codex/", "") if model_id.startswith("codex/") else model_id
logger.info("🔧 Using Codex model: %s (from %s)", actual_model, model_id)
from common.codex_chat_model import CodexChatModel
from langchain_core.messages import HumanMessage, SystemMessage, AIMessage
model = CodexChatModel(
model_name=actual_model,
access_token=access_token,
account_id=account_id,
refresh_token=refresh_token,
streaming=False,
)
# Build messages
messages = [SystemMessage(content=SYSTEM_PROMPT)]
for msg in history[-6:]: # Keep last 6 messages for context
if msg.role == "user":
messages.append(HumanMessage(content=msg.content))
else:
messages.append(AIMessage(content=msg.content))
messages.append(HumanMessage(content=question))
# Call LLM
result = await model._agenerate(messages)
response_text = result.generations[0].message.content
# Parse JSON from response
# Try to extract JSON from markdown code block or raw text
json_match = re.search(r'```(?:json)?\s*([\s\S]*?)```', response_text)
if json_match:
json_str = json_match.group(1).strip()
else:
json_str = response_text.strip()
try:
parsed = json.loads(json_str)
except json.JSONDecodeError:
logger.warning("Failed to parse LLM JSON response: %s", response_text[:500])
# Fallback: try to extract SQL from response
sql_match = re.search(r'(SELECT\s+.+?)(?:;|$)', response_text, re.IGNORECASE | re.DOTALL)
parsed = {
"sql": sql_match.group(1).strip() if sql_match else "",
"chart_type": None,
"x_key": None, "y_keys": None, "y_labels": None,
"trend": None, "total_label": None, "gauge_max": None, "footer": None,
"explanation": response_text[:200],
}
return parsed
# ─── API Endpoint ────────────────────────────────────────────────────
@router.post("/api/sql-chat", summary="AI Text-to-SQL Chat")
async def sql_chat(req: SqlChatRequest):
"""Convert Vietnamese question to SQL, execute, return results + chart."""
try:
# 1. Call Codex LLM
logger.info("🤖 SQL Chat: %s", req.question[:100])
llm_result = await _call_codex(req.question, req.history, req.model)
sql = llm_result.get("sql", "").strip()
explanation = llm_result.get("explanation", "")
thinking = llm_result.get("thinking", "")
chart_type = llm_result.get("chart_type")
x_key = llm_result.get("x_key")
y_keys = llm_result.get("y_keys")
y_labels = llm_result.get("y_labels")
trend = llm_result.get("trend")
total_label = llm_result.get("total_label")
gauge_max = llm_result.get("gauge_max")
footer = llm_result.get("footer")
# Build chart metadata dict
chart_meta = {
"chart_type": chart_type,
"x_key": x_key,
"y_keys": y_keys,
"y_labels": y_labels,
"trend": trend,
"total_label": total_label,
"gauge_max": gauge_max,
"footer": footer,
}
if not sql:
return JSONResponse(content={
"sql": "",
"data": [],
"columns": [],
**chart_meta,
"explanation": explanation or "Không thể tạo SQL query cho câu hỏi này.",
"row_count": 0,
"error": None,
})
# 2. Validate SQL safety
safety_error = _validate_sql(sql)
if safety_error:
return JSONResponse(content={
"sql": sql,
"data": [],
"columns": [],
"chart_type": None,
"explanation": f"⚠️ SQL không hợp lệ: {safety_error}",
"row_count": 0,
"error": safety_error,
})
# 3. Execute SQL on StarRocks
db = StarRocksConnection()
try:
rows = await db.execute_query_async(sql)
except Exception as e:
error_msg = str(e)
logger.error("SQL execution error: %s\nSQL: %s", error_msg, sql)
return JSONResponse(content={
"sql": sql,
"data": [],
"columns": [],
"chart_type": None,
"explanation": f"❌ Lỗi thực thi SQL: {error_msg[:200]}",
"row_count": 0,
"error": error_msg[:200],
})
# 4. Format response
columns = list(rows[0].keys()) if rows else []
data = [dict(row) for row in rows]
# Convert non-serializable types
from decimal import Decimal
for row in data:
for key, val in row.items():
if isinstance(val, Decimal):
row[key] = float(val)
elif isinstance(val, (bytes, bytearray)):
row[key] = val.hex()
elif hasattr(val, 'isoformat'):
row[key] = val.isoformat()
logger.info("✅ SQL Chat result: %d rows, %d columns", len(data), len(columns))
return JSONResponse(content={
"sql": sql,
"data": data,
"columns": columns,
**chart_meta,
"explanation": explanation,
"row_count": len(data),
"error": None,
})
except HTTPException:
raise
except Exception as e:
logger.error("SQL Chat error: %s\n%s", e, traceback.format_exc())
return JSONResponse(
status_code=500,
content={"error": str(e)[:200], "sql": "", "data": [], "columns": [], "explanation": ""},
)
@router.get("/api/sql-chat/status", summary="Check Codex availability")
async def sql_chat_status():
"""Check if Codex auth is available."""
from common.codex_auth import is_codex_available
return {"codex_available": is_codex_available()}
# ═══════════════════════════════════════════════════════════════════════
# DashboardAI — Multi-Widget Dashboard Generator (SSE Streaming)
# ═══════════════════════════════════════════════════════════════════════
import asyncio
from starlette.responses import StreamingResponse
from prompts.dashboard_prompt import DASHBOARD_PROMPT, STARROCKS_TABLE as DASH_TABLE
class DashboardRequest(BaseModel):
question: str
history: list[ChatMessage] = []
model: str = "codex/gpt-5.3-codex"
def _serialize_row(row: dict) -> dict:
"""Serialize a row for JSON output (handle Decimal, bytes, datetime, NaN)."""
for key, val in row.items():
if isinstance(val, (bytes, bytearray)):
row[key] = val.hex()
elif hasattr(val, 'isoformat'):
row[key] = val.isoformat()
elif isinstance(val, (float,)) and (val != val):
row[key] = 0
elif hasattr(val, 'as_tuple'): # Decimal
row[key] = float(val)
return row
async def _call_dashboard_llm(question: str, history: list, model_name: str) -> dict:
"""Call Codex LLM to generate dashboard JSON."""
from common.codex_auth import get_codex_access_token, get_codex_account_id, get_refresh_token
access_token = get_codex_access_token()
if not access_token:
raise HTTPException(status_code=503, detail="Codex auth not available.")
account_id = get_codex_account_id() or ""
refresh_token = get_refresh_token()
actual_model = model_name.replace("codex/", "") if model_name.startswith("codex/") else model_name
logger.info("🔧 Dashboard model: %s", actual_model)
from common.codex_chat_model import CodexChatModel
from langchain_core.messages import HumanMessage, SystemMessage, AIMessage
model = CodexChatModel(
model_name=actual_model, access_token=access_token,
account_id=account_id, refresh_token=refresh_token, streaming=False,
)
messages = [SystemMessage(content=DASHBOARD_PROMPT)]
for msg in history[-4:]:
if msg.role == "user":
messages.append(HumanMessage(content=msg.content))
else:
messages.append(AIMessage(content=msg.content))
messages.append(HumanMessage(content=question))
result = await model._agenerate(messages)
response_text = result.generations[0].message.content
json_match = re.search(r'```(?:json)?\s*([\s\S]*?)```', response_text)
json_str = json_match.group(1).strip() if json_match else response_text.strip()
return json.loads(json_str)
async def _exec_widget_sql(db_starrocks, db_postgres, widget: dict) -> dict:
"""Execute a single widget's SQL query and attach results.
Routes to StarRocks or PostgreSQL based on widget['db'] field.
"""
sql = widget.get("sql", "")
if not sql:
widget["data"] = []
widget["columns"] = []
widget["error"] = None
return widget
safety_err = _validate_sql(sql)
if safety_err:
widget["data"] = []
widget["columns"] = []
widget["error"] = safety_err
return widget
# Route to correct database
target_db = widget.get("db", "starrocks")
try:
if target_db == "postgres":
rows = await db_postgres.execute_query_async(sql)
columns = list(rows[0].keys()) if rows else []
data = [_serialize_row(dict(r)) for r in rows]
else:
rows = await db_starrocks.execute_query_async(sql)
columns = list(rows[0].keys()) if rows else []
data = [_serialize_row(dict(r)) for r in rows]
widget["data"] = data
widget["columns"] = columns
widget["error"] = None
logger.info(" ✅ Widget %s [%s]: %d rows", widget.get("id", "?"), target_db, len(data))
except Exception as e:
logger.error(" ❌ Widget %s [%s] error: %s", widget.get("id", "?"), target_db, str(e)[:200])
widget["data"] = []
widget["columns"] = []
widget["error"] = str(e)[:200]
return widget
@router.post("/api/sql-dashboard", summary="AI Dashboard Generator (SSE Stream)")
async def sql_dashboard_stream(req: DashboardRequest):
"""Generate a dashboard via SSE — streams header first, then each widget as SQL completes."""
async def event_stream():
try:
logger.info("📊 Dashboard AI (SSE): %s", req.question[:100])
# Phase 1: Call LLM → get dashboard layout
dashboard = await _call_dashboard_llm(req.question, req.history, req.model)
widgets = dashboard.get("widgets", [])
logger.info("📊 Dashboard: %s — %d widgets", dashboard.get("title", "?"), len(widgets))
# Send header (title + subtitle + widget skeletons without data)
header = {
"type": "header",
"title": dashboard.get("title", "Dashboard"),
"subtitle": dashboard.get("subtitle", ""),
"widgets": [
{"id": w.get("id"), "type": w.get("type"), "title": w.get("title"),
"size": w.get("size", "md"), "color": w.get("color", "indigo")}
for w in widgets
],
}
yield f"data: {json.dumps(header, ensure_ascii=False)}\n\n"
# Phase 2: Execute all SQLs concurrently, stream each as it completes
db_starrocks = StarRocksConnection()
db_postgres = PostgresReadonly()
tasks = {
asyncio.create_task(_exec_widget_sql(db_starrocks, db_postgres, w)): w.get("id")
for w in widgets
}
for done_task in asyncio.as_completed(tasks):
widget = await done_task
event = {
"type": "widget",
"id": widget.get("id"),
"data": widget.get("data", []),
"columns": widget.get("columns", []),
"x_key": widget.get("x_key"),
"y_key": widget.get("y_key"),
"y_keys": widget.get("y_keys"),
"error": widget.get("error"),
}
yield f"data: {json.dumps(event, ensure_ascii=False)}\n\n"
# Done signal
yield f"data: {json.dumps({'type': 'done'})}\n\n"
except json.JSONDecodeError:
yield f"data: {json.dumps({'type': 'error', 'message': 'AI response was not valid JSON'})}\n\n"
except Exception as e:
logger.error("Dashboard SSE error: %s\n%s", e, traceback.format_exc())
yield f"data: {json.dumps({'type': 'error', 'message': str(e)[:200]})}\n\n"
return StreamingResponse(event_stream(), media_type="text/event-stream")
# common/codex_auth.py
"""
Codex OAuth Token Manager (Simplified for Canifa AI Platform).
Loads Codex access tokens from local file ~/.codex/auth.json (from `codex login`).
Auto-refreshes when expired.
Copied from contract-ai, simplified: removed MongoDB dependency.
"""
import base64
import json
import logging
import os
import threading
import time
from pathlib import Path
from typing import Any
import requests
logger = logging.getLogger(__name__)
# OAuth endpoints
_AUTH_TOKEN_URL = "https://auth.openai.com/oauth/token"
_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann" # Codex CLI client ID
# Token cache (in-memory)
_lock = threading.Lock()
_cached_token: str | None = None
_cached_account_id: str | None = None
_token_expiry: float = 0.0
# ─── JWT Helpers ──────────────────────────────────────────────────────
def _decode_jwt_payload(token: str) -> dict:
"""Decode JWT payload without verification. Returns claims dict."""
try:
parts = token.split(".")
if len(parts) != 3:
return {}
payload_b64 = parts[1] + "=" * (4 - len(parts[1]) % 4)
return json.loads(base64.urlsafe_b64decode(payload_b64))
except Exception:
return {}
def _decode_jwt_expiry(token: str) -> float:
"""Extract expiry timestamp from JWT without verification."""
return float(_decode_jwt_payload(token).get("exp", 0))
def _extract_account_id(token: str) -> str | None:
"""Extract chatgpt_account_id from JWT claims."""
payload = _decode_jwt_payload(token)
auth_claims = payload.get("https://api.openai.com/auth", {})
return auth_claims.get("chatgpt_account_id")
# ─── Local File Token Loading ────────────────────────────────────────
def _get_auth_json_path() -> Path:
"""Get path to Codex auth.json file."""
codex_home = os.getenv("CODEX_HOME", "")
if codex_home:
return Path(codex_home) / "auth.json"
return Path.home() / ".codex" / "auth.json"
def _read_auth_json() -> dict[str, Any] | None:
"""Read and parse auth.json file."""
path = _get_auth_json_path()
if not path.exists():
logger.debug("Codex auth.json not found at %s", path)
return None
try:
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
return data
except Exception as e:
logger.error("❌ Failed to read Codex auth.json: %s", e)
return None
# ─── Token Refresh ───────────────────────────────────────────────────
def _refresh_token(refresh_token: str) -> str | None:
"""Refresh the access token using the refresh token."""
try:
resp = requests.post(
_AUTH_TOKEN_URL,
json={
"grant_type": "refresh_token",
"client_id": _CLIENT_ID,
"refresh_token": refresh_token,
},
headers={"Content-Type": "application/json"},
timeout=15,
)
if resp.status_code == 200:
data = resp.json()
new_access = data.get("access_token")
new_refresh = data.get("refresh_token")
if new_access:
logger.info("✅ Codex token refreshed successfully")
_update_auth_json(new_access, new_refresh or refresh_token)
return new_access
logger.error("❌ Codex token refresh failed: %d %s", resp.status_code, resp.text[:200])
return None
except Exception as e:
logger.error("❌ Codex token refresh error: %s", e)
return None
def _update_auth_json(access_token: str, refresh_token: str):
"""Write updated tokens back to auth.json."""
path = _get_auth_json_path()
try:
data = {}
if path.exists():
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
if "tokens" not in data:
data["tokens"] = {}
data["tokens"]["access_token"] = access_token
data["tokens"]["refresh_token"] = refresh_token
data["last_refresh"] = time.strftime("%Y-%m-%dT%H:%M:%S.000000000Z", time.gmtime())
with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2)
logger.debug("📝 Updated auth.json with refreshed token")
except Exception as e:
logger.warning("⚠️ Failed to update auth.json: %s", e)
# ─── Public API ──────────────────────────────────────────────────────
def get_codex_access_token() -> str | None:
"""Get a valid Codex access token from local auth.json.
Auto-refreshes if expired."""
global _cached_token, _cached_account_id, _token_expiry
with _lock:
now = time.time()
# Return cached token if still valid (with 60s buffer)
if _cached_token and _token_expiry > (now + 60):
return _cached_token
# Read auth.json
auth_data = _read_auth_json()
if not auth_data:
return None
tokens = auth_data.get("tokens", {})
access_token = tokens.get("access_token")
refresh_token = tokens.get("refresh_token")
if not access_token:
logger.warning("⚠️ No access_token in Codex auth.json")
return None
# Extract account_id from JWT claims
_cached_account_id = _extract_account_id(access_token)
# Check if token is expired
expiry = _decode_jwt_expiry(access_token)
if expiry > (now + 60):
# Token still valid
_cached_token = access_token
_token_expiry = expiry
logger.info("🔑 Codex token valid (expires in %dm)", int((expiry - now) / 60))
return access_token
# Token expired → refresh
if refresh_token:
logger.info("🔄 Codex token expired, refreshing...")
new_token = _refresh_token(refresh_token)
if new_token:
_cached_token = new_token
_cached_account_id = _extract_account_id(new_token)
_token_expiry = _decode_jwt_expiry(new_token)
return new_token
# Fallback: return expired token anyway (might still work briefly)
logger.warning("⚠️ Codex token may be expired, using anyway")
_cached_token = access_token
_token_expiry = 0
return access_token
def get_codex_account_id() -> str | None:
"""Get the ChatGPT account ID from the cached token."""
if _cached_account_id:
return _cached_account_id
get_codex_access_token()
return _cached_account_id
def get_refresh_token() -> str:
"""Get the refresh token from auth.json."""
auth_data = _read_auth_json()
if auth_data:
return auth_data.get("tokens", {}).get("refresh_token", "")
return ""
def is_codex_available() -> bool:
"""Check if Codex auth is configured and accessible."""
return get_codex_access_token() is not None
# common/codex_chat_model.py
"""
CodexChatModel — Custom LangChain BaseChatModel for ChatGPT Backend API.
Codex models (included with ChatGPT Plus/Pro/Business/Enterprise)
are accessed via a proprietary endpoint at chatgpt.com, NOT api.openai.com.
Endpoint: POST https://chatgpt.com/backend-api/codex/responses
Headers:
- Authorization: Bearer <access_token>
- chatgpt-account-id: <account_id>
Body: OpenAI Responses API format
Reverse-engineered from Codex CLI source:
codex-rs/core/src/model_provider_info.rs (line 148-152)
codex-rs/core/tests/suite/client.rs (line 468-530)
"""
import asyncio
import json
import logging
import uuid
from typing import Any
import httpx
from langchain_core.callbacks import CallbackManagerForLLMRun, AsyncCallbackManagerForLLMRun
from langchain_core.language_models import BaseChatModel
from langchain_core.messages import (
AIMessage,
AIMessageChunk,
BaseMessage,
HumanMessage,
SystemMessage,
ToolMessage,
)
from langchain_core.outputs import ChatGeneration, ChatGenerationChunk, ChatResult
logger = logging.getLogger(__name__)
CODEX_BASE_URL = "https://chatgpt.com/backend-api/codex"
# Retry config for transient 5xx errors
_MAX_RETRIES = 3
_RETRY_BASE_DELAY = 2 # seconds (exponential: 2, 4, 8)
def _split_instructions_and_input(
messages: list[BaseMessage],
) -> tuple[str, list[dict]]:
"""Split messages into instructions (system) and input items.
The Codex backend API requires `instructions` as a top-level field
(NOT inside the input array). SystemMessages are extracted and
concatenated into the instructions string.
IMPORTANT: function_call items must be TOP-LEVEL entries in the
input array, NOT nested inside assistant message content.
Supported content types inside messages are:
input_text, input_image, output_text, refusal, input_file,
computer_screenshot, summary_text.
"""
instructions_parts: list[str] = []
items: list[dict] = []
for msg in messages:
if isinstance(msg, SystemMessage):
# SystemMessage → top-level instructions field
instructions_parts.append(str(msg.content))
elif isinstance(msg, HumanMessage):
items.append({
"role": "user",
"content": [{"type": "input_text", "text": str(msg.content)}],
})
elif isinstance(msg, AIMessage):
# Add text content as assistant message (output_text only)
text_content = str(msg.content) if msg.content else ""
if text_content:
items.append({
"role": "assistant",
"content": [{"type": "output_text", "text": text_content}],
})
# Tool calls go as SEPARATE top-level items (NOT inside content)
if hasattr(msg, "tool_calls") and msg.tool_calls:
for tc in msg.tool_calls:
# Codex API needs TWO different IDs:
# id: item ID, MUST start with "fc_"
# call_id: call ID, starts with "call_" (used by function_call_output)
raw_id = tc.get("id", "") or f"fc_{uuid.uuid4().hex[:24]}"
# Ensure id starts with fc_
if raw_id.startswith("fc_"):
fc_id = raw_id
call_id = raw_id
elif raw_id.startswith("call_"):
fc_id = f"fc_{raw_id[5:]}" # convert call_xxx → fc_xxx
call_id = raw_id
else:
fc_id = f"fc_{raw_id}"
call_id = raw_id
items.append({
"type": "function_call",
"id": fc_id,
"call_id": call_id,
"name": tc["name"],
"arguments": json.dumps(tc["args"]) if isinstance(tc["args"], dict) else tc["args"],
})
elif isinstance(msg, ToolMessage):
# call_id in function_call_output must match function_call's call_id
tool_call_id = msg.tool_call_id or ""
# If tool_call_id starts with fc_, convert to call_ format
if tool_call_id.startswith("fc_"):
tool_call_id = f"call_{tool_call_id[3:]}"
items.append({
"type": "function_call_output",
"call_id": tool_call_id,
"output": str(msg.content),
})
else:
items.append({
"role": "user",
"content": [{"type": "input_text", "text": str(msg.content)}],
})
# Default instructions if none provided
instructions = "\n\n".join(instructions_parts) if instructions_parts else "You are a helpful assistant."
return instructions, items
def _parse_sse_line(line: str) -> dict | None:
"""Parse a Server-Sent Events line into data dict."""
line = line.strip()
if not line or line.startswith(":"):
return None
if line.startswith("data: "):
data_str = line[6:]
if data_str == "[DONE]":
return None
try:
return json.loads(data_str)
except json.JSONDecodeError:
return None
return None
def _convert_tool_to_function(tool: Any) -> dict:
"""Convert a LangChain tool definition to Responses API function format.
Uses LangChain's built-in converter which handles edge cases like
callable schemas, nested Pydantic models, etc.
"""
from langchain_core.utils.function_calling import convert_to_openai_function
# If already in the right format, return as-is
if isinstance(tool, dict) and tool.get("type") == "function":
return tool
try:
# Use LangChain's robust converter
openai_fn = convert_to_openai_function(tool)
return {
"type": "function",
"name": openai_fn.get("name", ""),
"description": openai_fn.get("description", ""),
"parameters": openai_fn.get("parameters", {}),
}
except Exception as e:
logger.warning(f"Failed to convert tool {tool}: {e}")
# Fallback: try dict-based conversion
if isinstance(tool, dict):
return {
"type": "function",
"name": tool.get("name", ""),
"description": tool.get("description", ""),
"parameters": tool.get("parameters", tool.get("schema", {})),
}
raise ValueError(f"Cannot convert tool of type {type(tool)}") from e
class CodexChatModel(BaseChatModel):
"""LangChain ChatModel that calls ChatGPT's backend API for Codex models.
This targets https://chatgpt.com/backend-api/codex/responses
which is the proprietary endpoint used by the Codex CLI.
Streaming: _astream() yields AIMessageChunk for both text tokens
AND tool_call_chunks. This enables LangGraph to fire
on_chat_model_stream events needed for reasoning display.
_agenerate() is kept as a non-streaming fallback.
"""
model_name: str = "gpt-5.2-codex"
access_token: str = ""
account_id: str = ""
refresh_token: str = "" # For auto-refresh on 401
user_id: str = "" # For MongoDB token persistence
base_url: str = CODEX_BASE_URL
streaming: bool = False
temperature: float | None = None
max_tokens: int | None = None
# Bound tools: stored as regular fields (not private) so Pydantic tracks them
bound_tools: list[dict] = []
bound_tool_choice: Any = None
class Config:
arbitrary_types_allowed = True
@property
def _llm_type(self) -> str:
return "codex-chatgpt-backend"
@property
def _identifying_params(self) -> dict[str, Any]:
return {
"model_name": self.model_name,
"base_url": self.base_url,
}
def bind_tools(
self,
tools: list[Any],
*,
tool_choice: Any | None = None,
**kwargs: Any,
) -> "CodexChatModel":
"""Return a new model with tools bound for function calling.
This is called by LangGraph agent framework to attach tool
definitions to the model before making API calls.
"""
converted_tools = [_convert_tool_to_function(t) for t in tools]
logger.info(
f"🔧 bind_tools: binding {len(converted_tools)} tools "
f"(names: {[t.get('name', '?') for t in converted_tools]})"
)
# Create a copy of self with tools bound
new_model = self.__class__(
model_name=self.model_name,
access_token=self.access_token,
account_id=self.account_id,
refresh_token=self.refresh_token,
user_id=self.user_id,
base_url=self.base_url,
streaming=self.streaming,
temperature=self.temperature,
max_tokens=self.max_tokens,
bound_tools=converted_tools,
bound_tool_choice=tool_choice,
)
return new_model
def _build_headers(self) -> dict[str, str]:
headers = {
"Authorization": f"Bearer {self.access_token}",
"Content-Type": "application/json",
"Accept": "text/event-stream",
}
if self.account_id:
headers["chatgpt-account-id"] = self.account_id
return headers
async def _refresh_and_update_token(self) -> bool:
"""Refresh the access token using refresh_token. Returns True on success."""
if not self.refresh_token:
logger.warning("⚠️ No refresh_token available for Codex token refresh")
return False
logger.info("🔄 Codex token expired (401), refreshing...")
try:
async with httpx.AsyncClient(timeout=15) as client:
resp = await client.post(
"https://auth.openai.com/oauth/token",
json={
"grant_type": "refresh_token",
"client_id": "app_EMoamEEZ73f0CkXaXp7hrann",
"refresh_token": self.refresh_token,
},
headers={"Content-Type": "application/json"},
)
if resp.status_code == 200:
data = resp.json()
new_access = data.get("access_token", "")
new_refresh = data.get("refresh_token", "") or self.refresh_token
if new_access:
self.access_token = new_access
self.refresh_token = new_refresh
# Extract new account_id from JWT
from common.codex_auth import _extract_account_id
new_account_id = _extract_account_id(new_access)
if new_account_id:
self.account_id = new_account_id
# Persist to MongoDB if user_id is available
if self.user_id:
try:
from common.codex_auth import save_codex_token_to_db
await save_codex_token_to_db(
self.user_id, new_access, new_refresh, self.account_id
)
except Exception as e:
logger.warning(f"⚠️ Failed to persist refreshed token: {e}")
# Also update local auth.json
try:
from common.codex_auth import _update_auth_json
_update_auth_json(new_access, new_refresh)
except Exception:
pass
logger.info("✅ Codex token refreshed successfully")
return True
logger.error("❌ Codex token refresh failed: %d %s", resp.status_code, resp.text[:200])
except Exception as e:
logger.error("❌ Codex token refresh error: %s", e)
return False
def _build_request_body(
self, messages: list[BaseMessage], **kwargs: Any
) -> dict:
instructions, input_items = _split_instructions_and_input(messages)
body: dict[str, Any] = {
"model": self.model_name,
"instructions": instructions,
"input": input_items,
"stream": True, # Codex API requires stream=true always
"store": False,
}
if self.temperature is not None:
body["temperature"] = self.temperature
if self.max_tokens:
body["max_output_tokens"] = self.max_tokens
# Include reasoning content (matches Codex CLI behavior)
body["include"] = ["reasoning.encrypted_content"]
# Add tools if bound
if self.bound_tools:
body["tools"] = self.bound_tools
logger.info(f"🔧 Request body includes {len(self.bound_tools)} tools")
if self.bound_tool_choice is not None:
body["tool_choice"] = self.bound_tool_choice
return body
def _generate(
self,
messages: list[BaseMessage],
stop: list[str] | None = None,
run_manager: CallbackManagerForLLMRun | None = None,
**kwargs: Any,
) -> ChatResult:
"""Synchronous generation — internally streams SSE and collects full response."""
url = f"{self.base_url}/responses"
headers = self._build_headers()
body = self._build_request_body(messages, **kwargs)
# Debug: log request
self._log_request_debug(body, "sync")
parts: list[str] = []
tool_calls: list[dict] = []
event_types_seen: list[str] = []
# Track function call metadata across SSE events
pending_fn_calls: dict[int, dict] = {} # output_index -> {name, call_id, id}
with httpx.Client(timeout=120) as client:
with client.stream("POST", url, headers=headers, json=body) as resp:
if resp.status_code != 200:
resp.read()
logger.error(f"❌ Codex API error {resp.status_code}: {resp.text[:500]}")
raise httpx.HTTPStatusError(
f"HTTP {resp.status_code}", request=resp.request, response=resp
)
for line in resp.iter_lines():
event = _parse_sse_line(line)
if not event:
continue
event_type = event.get("type", "")
event_types_seen.append(event_type)
text, tc = self._process_sse_event(event, event_type, pending_fn_calls)
if text:
parts.append(text)
if tc:
tool_calls.append(tc)
content = "".join(parts)
self._log_response_debug(content, tool_calls, event_types_seen)
ai_msg = AIMessage(content=content)
if tool_calls:
ai_msg.tool_calls = tool_calls
return ChatResult(generations=[ChatGeneration(message=ai_msg)])
async def _agenerate(
self,
messages: list[BaseMessage],
stop: list[str] | None = None,
run_manager: AsyncCallbackManagerForLLMRun | None = None,
**kwargs: Any,
) -> ChatResult:
"""Async generation — internally streams SSE and collects full response.
This is the PRIMARY method used by LangGraph's async execution.
Handles both text responses and function/tool calls.
"""
url = f"{self.base_url}/responses"
headers = self._build_headers()
body = self._build_request_body(messages, **kwargs)
# Debug: log request
self._log_request_debug(body, "async")
parts: list[str] = []
tool_calls: list[dict] = []
event_types_seen: list[str] = []
# Track function call metadata across SSE events
pending_fn_calls: dict[int, dict] = {} # output_index -> {name, call_id, id}
async with httpx.AsyncClient(timeout=120) as client:
async with client.stream("POST", url, headers=headers, json=body) as resp:
if resp.status_code == 401:
await resp.aread()
logger.warning("⚠️ Codex API 401 — attempting token refresh...")
if await self._refresh_and_update_token():
headers = self._build_headers()
# Retry with refreshed token
async with client.stream("POST", url, headers=headers, json=body) as retry_resp:
if retry_resp.status_code != 200:
await retry_resp.aread()
raise httpx.HTTPStatusError(
f"HTTP {retry_resp.status_code} after refresh",
request=retry_resp.request, response=retry_resp,
)
async for line in retry_resp.aiter_lines():
event = _parse_sse_line(line)
if not event:
continue
event_type = event.get("type", "")
text, tc = self._process_sse_event(event, event_type, pending_fn_calls)
if text:
parts.append(text)
if tc:
tool_calls.append(tc)
content = "".join(parts)
self._log_response_debug(content, tool_calls, event_types_seen)
ai_msg = AIMessage(content=content)
if tool_calls:
ai_msg.tool_calls = tool_calls
return ChatResult(generations=[ChatGeneration(message=ai_msg)])
else:
raise httpx.HTTPStatusError(
"HTTP 401: Token expired and refresh failed",
request=resp.request, response=resp,
)
if resp.status_code != 200:
await resp.aread()
error_body = resp.text[:1000] if resp.text else 'N/A'
logger.error(f"❌ Codex API error {resp.status_code}: {error_body}")
logger.error(
f"❌ Request body that caused {resp.status_code}:\n"
f"{json.dumps(body, default=str, ensure_ascii=False)[:2000]}"
)
raise httpx.HTTPStatusError(
f"HTTP {resp.status_code}: {error_body[:200]}", request=resp.request, response=resp
)
async for line in resp.aiter_lines():
event = _parse_sse_line(line)
if not event:
continue
event_type = event.get("type", "")
event_types_seen.append(event_type)
text, tc = self._process_sse_event(event, event_type, pending_fn_calls)
if text:
parts.append(text)
if tc:
tool_calls.append(tc)
content = "".join(parts)
self._log_response_debug(content, tool_calls, event_types_seen)
ai_msg = AIMessage(content=content)
if tool_calls:
ai_msg.tool_calls = tool_calls
return ChatResult(generations=[ChatGeneration(message=ai_msg)])
# ── Async Streaming (_astream) ────────────────────────────────
async def _astream(
self,
messages: list[BaseMessage],
stop: list[str] | None = None,
run_manager: AsyncCallbackManagerForLLMRun | None = None,
**kwargs: Any,
):
"""Async streaming — yields AIMessageChunk for real-time token display.
This enables LangGraph to fire on_chat_model_stream events,
which drives the reasoning/reflect display on the frontend.
Yields both text tokens (as content) and tool calls (as tool_call_chunks).
Includes retry logic for transient 5xx errors from the Codex API.
"""
url = f"{self.base_url}/responses"
headers = self._build_headers()
body = self._build_request_body(messages, **kwargs)
self._log_request_debug(body, "astream")
last_error: Exception | None = None
for attempt in range(_MAX_RETRIES):
# Reset state for each retry attempt
pending_fn_calls: dict[int, dict] = {} # output_index → {name, call_id, id}
fn_call_index_map: dict[int, int] = {} # output_index → sequential tool_call index
next_tc_index = 0
event_types_seen: list[str] = []
try:
async with httpx.AsyncClient(timeout=120) as client:
async with client.stream("POST", url, headers=headers, json=body) as resp:
if resp.status_code >= 500:
await resp.aread()
error_body = resp.text[:1000] if resp.text else 'N/A'
logger.warning(
f"⚠️ Codex API {resp.status_code} (attempt {attempt + 1}/{_MAX_RETRIES}): "
f"{error_body[:200]}"
)
last_error = httpx.HTTPStatusError(
f"HTTP {resp.status_code}: {error_body[:200]}",
request=resp.request, response=resp,
)
if attempt < _MAX_RETRIES - 1:
delay = _RETRY_BASE_DELAY * (2 ** attempt)
logger.info(f"🔄 Retrying in {delay}s...")
await asyncio.sleep(delay)
continue
raise last_error
# ── 401 Token Expired → auto-refresh and retry ──
if resp.status_code == 401:
await resp.aread()
logger.warning("⚠️ Codex API 401 in _astream — attempting token refresh...")
if await self._refresh_and_update_token():
headers = self._build_headers()
# Don't count this as an attempt — continue will retry
continue
else:
raise httpx.HTTPStatusError(
"HTTP 401: Token expired and refresh failed",
request=resp.request, response=resp,
)
if resp.status_code != 200:
await resp.aread()
error_body = resp.text[:1000] if resp.text else 'N/A'
logger.error(f"❌ Codex API error {resp.status_code}: {error_body}")
raise httpx.HTTPStatusError(
f"HTTP {resp.status_code}: {error_body[:200]}",
request=resp.request, response=resp,
)
async for line in resp.aiter_lines():
event = _parse_sse_line(line)
if not event:
continue
event_type = event.get("type", "")
event_types_seen.append(event_type)
# ── Text delta → yield content chunk ──
if event_type == "response.output_text.delta":
delta = event.get("delta", "")
if delta:
yield ChatGenerationChunk(
message=AIMessageChunk(content=delta)
)
# ── Function call started → capture metadata + yield name chunk ──
elif event_type == "response.output_item.added":
item = event.get("item", {})
if item.get("type") == "function_call":
output_index = event.get("output_index", 0)
fn_name = item.get("name", "")
fn_id = item.get("id", "") or f"fc_{uuid.uuid4().hex[:24]}"
fn_call_id = item.get("call_id", "") or fn_id
# Normalize call_id format
if fn_call_id.startswith("fc_"):
call_id = fn_call_id
elif fn_call_id.startswith("call_"):
call_id = fn_call_id
else:
call_id = f"call_{fn_call_id}"
pending_fn_calls[output_index] = {
"name": fn_name,
"call_id": call_id,
"id": fn_id,
}
tc_idx = next_tc_index
fn_call_index_map[output_index] = tc_idx
next_tc_index += 1
logger.info(
f"🔧 [stream] Function call started: {fn_name} "
f"(id={fn_id}, call_id={call_id})"
)
yield ChatGenerationChunk(
message=AIMessageChunk(
content="",
tool_call_chunks=[{
"index": tc_idx,
"id": call_id,
"name": fn_name,
"args": "",
}],
)
)
# ── Function call args streaming → yield args delta ──
elif event_type == "response.function_call_arguments.delta":
output_index = event.get("output_index", 0)
tc_idx = fn_call_index_map.get(output_index, 0)
args_delta = event.get("delta", "")
if args_delta:
yield ChatGenerationChunk(
message=AIMessageChunk(
content="",
tool_call_chunks=[{
"index": tc_idx,
"id": None,
"name": None,
"args": args_delta,
}],
)
)
# ── Function call done → log completion ──
elif event_type == "response.function_call_arguments.done":
output_index = event.get("output_index", 0)
fn_info = pending_fn_calls.pop(output_index, {})
name = fn_info.get("name") or event.get("name", "")
args_str = event.get("arguments", "{}")
logger.info(
f"🔧 [stream] Function call done: {name}"
f"({args_str[:100]})"
)
# Success — break out of retry loop
unique_types = list(dict.fromkeys(event_types_seen))
logger.info(
f"📊 Codex _astream complete: event_types={unique_types}"
)
break
except httpx.HTTPStatusError:
# Already logged above — will retry or propagate
if attempt >= _MAX_RETRIES - 1:
raise
# ── Debug helpers ──────────────────────────────────────────────
def _log_request_debug(self, body: dict, mode: str) -> None:
"""Log request body structure for debugging."""
debug = {k: v for k, v in body.items() if k != "input"}
debug["input_count"] = len(body.get("input", []))
debug["tools_count"] = len(body.get("tools", []))
if body.get("tools"):
debug["tool_names"] = [t.get("name", "?") for t in body["tools"]]
logger.info(
f"📡 Codex API request ({mode}): "
f"{json.dumps(debug, default=str, ensure_ascii=False)[:600]}"
)
def _log_response_debug(
self, content: str, tool_calls: list[dict], event_types: list[str]
) -> None:
"""Log response summary for debugging."""
unique_types = list(dict.fromkeys(event_types))
logger.info(
f"📊 Codex response: {len(content)} chars, "
f"{len(tool_calls)} tool_calls, "
f"event_types={unique_types}"
)
if tool_calls:
logger.info(f"🔧 Tool calls: {[tc.get('name') for tc in tool_calls]}")
elif not content:
logger.warning("⚠️ Empty response — no text and no tool calls!")
else:
logger.info(f"💬 Text response preview: {content[:150]}...")
# ── SSE parsing ──────────────────────────────────────────────
@staticmethod
def _process_sse_event(
event: dict,
event_type: str,
pending_fn_calls: dict[int, dict],
) -> tuple[str | None, dict | None]:
"""Process a parsed SSE event, returning (text_delta, tool_call_or_none).
Uses pending_fn_calls to track function call metadata across events:
- response.output_item.added: captures name, call_id, id
- response.function_call_arguments.done: uses captured metadata
This is necessary because the Codex API sends function call name/id
only in output_item.added, NOT in function_call_arguments.done.
"""
# Text delta
if event_type == "response.output_text.delta":
return event.get("delta", ""), None
# Function call started — capture metadata for later
if event_type == "response.output_item.added":
item = event.get("item", {})
if item.get("type") == "function_call":
output_index = event.get("output_index", 0)
fn_name = item.get("name", "")
# id starts with fc_ (item ID), call_id starts with call_ (call ID)
fn_id = item.get("id", "") or f"fc_{uuid.uuid4().hex[:24]}"
fn_call_id = item.get("call_id", "") or fn_id
pending_fn_calls[output_index] = {
"name": fn_name,
"call_id": fn_call_id,
"id": fn_id,
}
logger.info(f"🔧 Function call started: {fn_name} (id={fn_id}, call_id={fn_call_id})")
return None, None
# Function call completed — use captured metadata
if event_type == "response.function_call_arguments.done":
output_index = event.get("output_index", 0)
fn_info = pending_fn_calls.pop(output_index, {})
# Use metadata from output_item.added, fallback to event fields
name = fn_info.get("name") or event.get("name", "")
# LangChain tool_call only has one 'id' field — use call_id
# (this is what ToolMessage.tool_call_id will match against)
call_id = fn_info.get("call_id") or event.get("call_id", "") or f"call_{uuid.uuid4().hex[:24]}"
args_str = event.get("arguments", "{}")
logger.info(f"🔧 Function call done: {name}({args_str[:100]}) call_id={call_id}")
try:
args = json.loads(args_str)
except json.JSONDecodeError:
args = {}
return None, {
"id": call_id,
"name": name,
"args": args,
}
return None, None
......@@ -5,6 +5,7 @@ Singleton Pattern cho cả 2 services
from __future__ import annotations
import asyncio
import json
import logging
from collections.abc import Callable
......@@ -69,6 +70,24 @@ class CanifaAuthMiddleware(BaseHTTPMiddleware):
5. Routes lấy trực tiếp từ request.state
"""
@staticmethod
def _make_receive(body: bytes):
"""Return an async receive callable that replays the cached body ONCE,
then blocks forever on subsequent calls. This prevents
StreamingResponse's listen_for_disconnect from getting an unexpected
http.request message (it expects http.disconnect)."""
_sent = False
async def receive():
nonlocal _sent
if not _sent:
_sent = True
return {"type": "http.request", "body": body}
# Block forever — the task will be cancelled when the
# response stream finishes or the client disconnects.
await asyncio.Event().wait()
return receive
async def dispatch(self, request: Request, call_next: Callable):
path = request.url.path
method = request.method
......@@ -107,11 +126,15 @@ class CanifaAuthMiddleware(BaseHTTPMiddleware):
logger.error(f"❌ Auth Error: {e}")
# 2. Lấy Device ID (Để tracking hoặc dùng cho Guest)
# ⚠️ Skip body reading for SSE/streaming endpoints — reading the
# body inside BaseHTTPMiddleware corrupts the receive chain and
# causes "Unexpected message received: http.request" errors.
SSE_PATHS = {"/api/sql-dashboard", "/api/sql-chat/stream"}
device_id = ""
if method in ["POST", "PUT", "PATCH"]:
if method in ["POST", "PUT", "PATCH"] and path not in SSE_PATHS:
try:
body_bytes = await request.body()
request._receive = lambda: {"type": "http.request", "body": body_bytes}
request._receive = self._make_receive(body_bytes)
if body_bytes:
try:
data = json.loads(body_bytes)
......
"""
PostgreSQL Read-Only Connection for Chat History Analytics.
Uses asyncpg for async queries — read-only (SELECT only).
"""
import logging
from typing import Any
import asyncpg
from config import (
HISTORY_POSTGRES_DB,
HISTORY_POSTGRES_HOST,
HISTORY_POSTGRES_PASSWORD,
HISTORY_POSTGRES_PORT,
HISTORY_POSTGRES_USER,
)
logger = logging.getLogger(__name__)
__all__ = ["PostgresReadonly"]
class PostgresReadonly:
"""Async read-only PostgreSQL connection via asyncpg."""
_pool: asyncpg.Pool | None = None
@classmethod
async def _get_pool(cls) -> asyncpg.Pool:
if cls._pool is None:
cls._pool = await asyncpg.create_pool(
host=HISTORY_POSTGRES_HOST,
port=HISTORY_POSTGRES_PORT,
user=HISTORY_POSTGRES_USER,
password=HISTORY_POSTGRES_PASSWORD,
database=HISTORY_POSTGRES_DB,
min_size=1,
max_size=5,
command_timeout=30,
)
logger.info(
"✅ PostgresReadonly pool created: %s:%s/%s",
HISTORY_POSTGRES_HOST,
HISTORY_POSTGRES_PORT,
HISTORY_POSTGRES_DB,
)
return cls._pool
@classmethod
async def execute_query_async(cls, sql: str) -> list[dict[str, Any]]:
"""Execute a SELECT query and return list of dicts."""
pool = await cls._get_pool()
async with pool.acquire() as conn:
rows = await conn.fetch(sql)
return [dict(r) for r in rows]
......@@ -55,6 +55,11 @@ __all__ = [
"STARROCKS_PASSWORD",
"STARROCKS_PORT",
"STARROCKS_USER",
"HISTORY_POSTGRES_HOST",
"HISTORY_POSTGRES_PORT",
"HISTORY_POSTGRES_USER",
"HISTORY_POSTGRES_PASSWORD",
"HISTORY_POSTGRES_DB",
"STOCK_API_URL",
"USE_MONGO_CONVERSATION",
]
......@@ -130,6 +135,13 @@ STARROCKS_USER: str | None = os.getenv("STARROCKS_USER")
STARROCKS_PASSWORD: str | None = os.getenv("STARROCKS_PASSWORD")
STARROCKS_DB: str | None = os.getenv("STARROCKS_DB")
# ====================== HISTORY POSTGRES (READ-ONLY) ======================
HISTORY_POSTGRES_HOST: str | None = os.getenv("HISTORY_POSTGRES_HOST")
HISTORY_POSTGRES_PORT: int = int(os.getenv("HISTORY_POSTGRES_PORT", "15433"))
HISTORY_POSTGRES_USER: str | None = os.getenv("HISTORY_POSTGRES_USER")
HISTORY_POSTGRES_PASSWORD: str | None = os.getenv("HISTORY_POSTGRES_PASSWORD")
HISTORY_POSTGRES_DB: str | None = os.getenv("HISTORY_POSTGRES_DB", "postgres")
# Placeholder for backward compatibility if needed
AI_MODEL_NAME = DEFAULT_MODEL
# ====================== OPENTELEMETRY CONFIGURATION ======================
......
[
{"name": "Admin", "role": "admin"},
{"name": "anhvh", "role": "admin"}
]
......@@ -28,5 +28,5 @@ networks:
ipam:
driver: default
config:
- subnet: "172.24.0.0/16"
gateway: "172.24.0.1"
- subnet: "10.101.10.0/24"
gateway: "10.101.10.1"
......@@ -30,5 +30,5 @@ networks:
ipam:
driver: default
config:
- subnet: "172.24.0.0/16"
gateway: "172.24.0.1"
- subnet: "10.101.10.0/24"
gateway: "10.101.10.1"
"""
DashboardAI Prompt — Generates diverse dashboard layouts from natural language.
Supports 2 databases: StarRocks (product catalog) + PostgreSQL (chat history).
Imported by sql_chat_route.py.
"""
STARROCKS_TABLE = "shared_source.magento_product_dimension_with_text_embedding"
POSTGRES_TABLE = "public.langgraph_chat_histories"
DB_SCHEMA = f"""
## Database 1: StarRocks (db: "starrocks")
## Table: {STARROCKS_TABLE}
### Columns (SELECT only, KHÔNG query cột 'vector'):
- internal_ref_code (VARCHAR) — Mã sản phẩm nội bộ, VD: "8TP25A005"
- magento_ref_code (VARCHAR) — Mã ref = internal_ref_code + color, VD: "8TP25A005-SW011"
- product_color_code (VARCHAR) — Mã màu sản phẩm
- product_name (VARCHAR) — Tên sản phẩm, VD: "ÁO THUN NAM"
- color_code (VARCHAR) — Mã màu
- master_color (VARCHAR) — Nhóm màu chính: "Trắng", "Đen", "Đỏ", etc.
- product_color_name (VARCHAR) — Tên màu đầy đủ
- season_sale (VARCHAR) — Mùa sale
- season (VARCHAR) — Mùa: "Xuân Hè", "Thu Đông", etc.
- style (VARCHAR) — Phong cách sản phẩm
- fitting (VARCHAR) — Kiểu form: "Regular Fit", "Slim Fit", etc.
- size_scale (VARCHAR) — Thang size: "XS-XL", "1-6", etc.
- graphic (VARCHAR) — Họa tiết
- pattern (VARCHAR) — Hoa văn
- weaving (VARCHAR) — Kiểu dệt
- shape_detail (VARCHAR) — Chi tiết kiểu dáng
- form_neckline (VARCHAR) — Kiểu cổ: "Cổ tròn", "Cổ V", etc.
- form_sleeve (VARCHAR) — Kiểu tay: "Ngắn tay", "Dài tay", etc.
- form_length (VARCHAR) — Kiểu dài
- form_waistline (VARCHAR) — Kiểu eo
- form_shoulderline (VARCHAR) — Kiểu vai
- form_pants (VARCHAR) — Kiểu quần
- material (VARCHAR) — Chất liệu
- specific_material (VARCHAR) — Chất liệu chi tiết
- sale_price (DECIMAL) — Giá bán (VND)
- original_price (DECIMAL) — Giá gốc (VND)
- discount_amount (DECIMAL) — Giá giảm (VND)
- quantity_sold (INT) — Số lượng đã bán
- is_new_product (TINYINT) — Sản phẩm mới (1=new)
- gender_by_product (VARCHAR) — Giới tính: "men", "women", "boy", "girl", "unisex"
- product_line_vn (VARCHAR) — Dòng sản phẩm: "Áo phông", "Quần short", etc.
- product_web_url (VARCHAR) — URL web
---
## Database 2: PostgreSQL (db: "postgres")
## Table: {POSTGRES_TABLE}
This table stores ALL chatbot conversations (user messages + AI responses).
### Columns:
- id (SERIAL) — Auto-increment ID
- identity_key (VARCHAR 255) — User/session identifier
- message (TEXT) — Content: plain text for user messages, JSON string for AI responses
- AI response JSON format: {{"ai_response": "...", "product_ids": [...]}}
- User message: plain text, e.g. "tôi muốn mua áo polo"
- is_human (BOOLEAN) — true = user message, false = AI response
- timestamp (TIMESTAMPTZ) — Message timestamp
### Important Notes for querying chat history:
- To search user questions: WHERE is_human = true AND message ILIKE '%keyword%'
- To count unique users: COUNT(DISTINCT identity_key)
- To count conversations about a topic: COUNT(*) WHERE is_human = true AND message ILIKE '%topic%'
- message for AI responses is JSON, use message::json->>'ai_response' to extract AI text
- Common topics: "áo polo", "áo phông", "quần", "váy", "đầm", "áo khoác", "áo lót"
- Use timestamp for date filtering: WHERE timestamp >= '2026-01-01'
"""
DASHBOARD_PROMPT = f"""You are DashboardAI, an intelligent analytics dashboard generator for Canifa (Vietnamese fashion brand).
When a user describes a report or dashboard, respond ONLY with a raw JSON object (no markdown, no explanation).
{DB_SCHEMA}
## ⚡ CRITICAL: Each widget MUST include "db" field ⚡
- "db": "starrocks" — for product catalog queries (table: {STARROCKS_TABLE})
- "db": "postgres" — for chat history queries (table: {POSTGRES_TABLE})
## RULES:
- ONLY SELECT queries. NEVER INSERT/UPDATE/DELETE/DROP/ALTER/CREATE.
- Always LIMIT (max 200 rows per widget).
- NEVER query 'embedding' or 'vector' columns.
- Each widget MUST have a "db" field indicating which database to query.
- For product data: use StarRocks table {STARROCKS_TABLE}
- For chat/conversation data: use PostgreSQL table {POSTGRES_TABLE}
- You can combine both databases in one dashboard (e.g., product KPIs + chat analytics)
## Widget JSON Schema:
Each widget has: id, type, title, size, color, sql, x_key, y_key, db
## Available Types:
- **kpi**: Single metric (COUNT/SUM/AVG/MAX/MIN). SQL returns 1 row 1 col.
- **bar**: Vertical bar chart. SQL returns x_key + y_key columns.
- **horizontal-bar**: Horizontal bar chart. Same data as bar.
- **stacked-bar**: Stacked bar chart. SQL returns x_key + multiple numeric cols. Use y_keys (array).
- **line**: Line chart. SQL returns x_key + y_key.
- **area**: Filled area chart. Same as line.
- **donut**: Donut/pie chart. SQL returns label + value.
- **scatter**: Scatter plot. SQL returns x_key + y_key (both numeric).
- **table**: Data table. SQL returns any columns.
- **progress**: Progress bars showing completion. SQL returns label + current + max columns. y_keys: ["current_col","max_col"].
- **number-row**: Row of 2-3 inline metrics. SQL returns 1 row with 2-3 numeric cols.
## Available Sizes (12-column grid):
- "xs": 2 columns — tiny metric
- "sm": 3 columns — compact KPI
- "md": 4 columns — standard card
- "half": 6 columns — half width
- "lg": 8 columns — wide chart
- "full": 12 columns — full width
## Available Colors:
indigo, emerald, amber, red, purple, cyan, pink, orange, teal, blue
## ⚡ CRITICAL: Layout MUST BE DIVERSE! ⚡
You MUST vary the layout. NEVER produce the same layout twice. Here are 6 patterns — randomly pick one or create your own mix:
### Pattern A: "KPI Row + Mixed Charts"
Row 1: 4× kpi (sm) | Row 2: 1× bar (half) + 1× donut (half) | Row 3: 1× line (full) | Row 4: 1× table (full)
### Pattern B: "Hero KPI + Detail Grid"
Row 1: 1× kpi (md) + 1× kpi (md) + 1× kpi (md) | Row 2: 1× area (lg) + 1× number-row (md) | Row 3: 1× horizontal-bar (half) + 1× donut (half) | Row 4: 1× table (full)
### Pattern C: "Wide Charts Focus"
Row 1: 3× kpi (md) | Row 2: 1× bar (full) | Row 3: 1× donut (md) + 1× line (lg) | Row 4: 1× table (full)
### Pattern D: "Compact Dashboard"
Row 1: 2× kpi (half) | Row 2: 1× horizontal-bar (half) + 1× progress (half) | Row 3: 1× area (half) + 1× donut (half) | Row 4: 1× table (full)
### Pattern E: "Analytics Deep Dive"
Row 1: 4× kpi (sm) | Row 2: 1× scatter (half) + 1× bar (half) | Row 3: 1× stacked-bar (full) | Row 4: 1× table (full)
### Pattern F: "Executive Summary"
Row 1: 1× kpi (half) + 1× number-row (half) | Row 2: 1× area (full) | Row 3: 1× donut (md) + 1× horizontal-bar (lg) | Row 4: 1× table (full)
## Tips:
- Mix sizes! Don't make all KPIs the same size.
- Use horizontal-bar for categorical comparisons (top 10 products, etc.)
- Use scatter when comparing two numeric dimensions (price vs quantity)
- Use progress for goal-tracking or top-N with % share
- Use stacked-bar to show composition across categories
- number-row is great for showing 2-3 metrics in one compact widget
- Always produce 5-10 widgets total
- RANDOMIZE which pattern you use. Be creative!
- Respond ONLY with the raw JSON. No other text.
"""
......@@ -21,6 +21,9 @@ from api.text_to_sql_route import router as text_to_sql_router
from api.dashboard_route import router as dashboard_router
from api.notes_route import router as notes_router
from api.experiment_links_route import router as experiment_links_router
from api.product_route import router as product_router
from api.sql_chat_route import router as sql_chat_router
from api.cache_route import router as cache_router
from common.cache import redis_cache
from common.middleware import middleware_manager
from config import PORT
......@@ -61,7 +64,7 @@ async def startup_event():
@app.get("/")
async def root():
return RedirectResponse(url="/static/index.html")
return RedirectResponse(url="/static/dashboard.html")
@app.get("/health")
......@@ -106,6 +109,9 @@ app.include_router(text_to_sql_router) # Bản 2: Text-to-SQL
app.include_router(dashboard_router) # Dashboard overview
app.include_router(notes_router) # Dashboard team notes
app.include_router(experiment_links_router) # Experiment links sidebar
app.include_router(product_router) # Product performance dashboard
app.include_router(sql_chat_router) # AI Data Analyst (Text-to-SQL)
app.include_router(cache_router) # Cache management dashboard
if __name__ == "__main__":
......
"""Create a read-only PostgreSQL user for the chatbot API."""
import psycopg2
conn = psycopg2.connect(
host="172.16.2.190",
port=15433,
user="pgvector",
password="password",
dbname="postgres"
)
conn.autocommit = True
cur = conn.cursor()
# 1. Create user
cur.execute("CREATE USER canifa_bot_readonly WITH PASSWORD 'CanifaBot@R3adOnly2026'")
print("User canifa_bot_readonly created")
# 2. Grant connect to database
cur.execute("GRANT CONNECT ON DATABASE postgres TO canifa_bot_readonly")
print("CONNECT granted on database postgres")
# 3. Grant usage on schema public
cur.execute("GRANT USAGE ON SCHEMA public TO canifa_bot_readonly")
print("USAGE granted on schema public")
# 4. Grant SELECT on langgraph + n8n history tables
tables = [
"langgraph_chat_histories",
"n8n_chat_histories",
"n8n_chat_histories_dev",
]
for t in tables:
cur.execute(f"GRANT SELECT ON public.{t} TO canifa_bot_readonly")
print(f"SELECT granted on public.{t}")
cur.close()
conn.close()
print("Done! Read-only user ready.")
<!DOCTYPE html>
<html lang="vi">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Cache Manager — Canifa AI</title>
<link rel="stylesheet" href="/static/lab.css">
<link rel="stylesheet" href="/static/product.css">
<script src="/static/frame-detect.js"></script>
<style>
/* ═══ CACHE PAGE ═══ */
.cache-kpis { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 14px; margin-bottom: 24px; }
.ck { background: #fff; border: 1px solid #e8e6df; border-radius: 12px; padding: 18px 16px; text-align: center; }
.ck-val { font-size: 26px; font-weight: 700; line-height: 1.1; }
.ck-label { font-size: 11px; color: #888; text-transform: uppercase; letter-spacing: .5px; margin-top: 4px; }
/* Tabs */
.cache-tabs { display: flex; gap: 4px; margin-bottom: 20px; background: #f0eeea; padding: 4px; border-radius: 10px; width: fit-content; }
.cache-tab { padding: 8px 20px; border-radius: 8px; font-size: 13px; font-weight: 500; cursor: pointer; border: none; background: transparent; color: #888; transition: all .15s; }
.cache-tab.active { background: #fff; color: #333; box-shadow: 0 1px 4px rgba(0,0,0,.08); }
/* Category cards */
.cat-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 14px; }
.cat-card { background: #fff; border: 1px solid #e8e6df; border-radius: 10px; padding: 16px; }
.cat-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; padding-bottom: 8px; border-bottom: 1px solid #f0eeea; }
.cat-name { font-weight: 600; font-size: 14px; color: #333; }
.cat-badge { padding: 2px 10px; border-radius: 12px; font-size: 12px; font-weight: 600; color: #fff; }
.cat-keys-list { }
.ck-row { display: flex; align-items: center; justify-content: space-between; padding: 5px 0; font-size: 12px; border-bottom: 1px dashed #f0eeea; cursor: pointer; transition: background .1s; border-radius: 4px; padding: 5px 6px; margin: 0 -6px; }
.ck-row:hover { background: #faf8f5; }
.ck-row:last-child { border-bottom: none; }
.ck-key { font-family: 'Fira Code', 'Consolas', monospace; color: #555; max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ck-ttl { font-size: 11px; color: #aaa; white-space: nowrap; }
.ck-ttl.hot { color: #ef4444; }
.cat-clear { margin-top: 10px; padding: 5px 12px; border: 1px solid #e8e6df; border-radius: 6px; background: #fff; font-size: 11px; cursor: pointer; color: #888; transition: all .15s; }
.cat-clear:hover { background: #fef2f2; color: #dc2626; border-color: #fca5a5; }
.cat-more { display: inline-block; margin-top: 6px; padding: 4px 12px; border: 1px solid #C9A96E; border-radius: 6px; background: #fff; font-size: 11px; cursor: pointer; color: #C9A96E; font-weight: 500; transition: all .15s; }
.cat-more:hover { background: #C9A96E; color: #fff; }
.cat-keys-list.expanded { max-height: 320px; overflow-y: auto; }
/* Keys table */
.keys-toolbar { display: flex; gap: 10px; margin-bottom: 16px; flex-wrap: wrap; align-items: center; }
.keys-toolbar input { padding: 8px 14px; border-radius: 8px; border: 1px solid #e8e6df; font-size: 13px; min-width: 200px; outline: none; background: #fff; }
.keys-toolbar input:focus { border-color: #C9A96E; box-shadow: 0 0 0 3px rgba(201,169,110,.12); }
.kt-btn { padding: 8px 16px; border-radius: 8px; border: 1px solid #e8e6df; background: #fff; font-size: 13px; cursor: pointer; font-weight: 500; transition: all .15s; }
.kt-btn:hover { background: #faf8f5; }
.kt-btn.primary { background: #C9A96E; color: #fff; border-color: #C9A96E; }
.kt-btn.primary:hover { background: #b8944f; }
.kt-btn.danger { color: #dc2626; border-color: #fca5a5; }
.kt-btn.danger:hover { background: #fef2f2; }
.keys-tbl { width: 100%; border-collapse: collapse; font-size: 13px; }
.keys-tbl th { background: #faf8f5; text-align: left; padding: 10px 12px; font-weight: 600; font-size: 11px; text-transform: uppercase; letter-spacing: .5px; color: #888; border-bottom: 2px solid #e8e6df; }
.keys-tbl td { padding: 8px 12px; border-bottom: 1px solid #f0eeea; }
.keys-tbl tr { cursor: pointer; transition: background .1s; }
.keys-tbl tr:hover td { background: #fdfcfa; }
.key-mono { font-family: 'Fira Code', 'Consolas', monospace; font-size: 12px; max-width: 320px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: inline-block; }
.type-pill { display: inline-block; padding: 1px 8px; border-radius: 8px; font-size: 10px; font-weight: 600; }
.type-string { background: #dbeafe; color: #1d4ed8; }
.type-hash { background: #fef3c7; color: #92400e; }
.type-list { background: #dcfce7; color: #166534; }
.type-set { background: #f3e8ff; color: #7c3aed; }
/* Detail modal */
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,.35); z-index: 999; display: none; align-items: center; justify-content: center; backdrop-filter: blur(2px); }
.modal-overlay.show { display: flex; }
.modal { background: #fff; border-radius: 14px; width: min(700px, 90vw); max-height: 80vh; overflow: hidden; box-shadow: 0 20px 60px rgba(0,0,0,.15); }
.modal-head { display: flex; align-items: center; justify-content: space-between; padding: 16px 20px; border-bottom: 1px solid #e8e6df; }
.modal-title { font-weight: 600; font-size: 14px; }
.modal-close { width: 28px; height: 28px; border: none; background: #f0eeea; border-radius: 6px; cursor: pointer; font-size: 14px; display: flex; align-items: center; justify-content: center; }
.modal-close:hover { background: #e8e6df; }
.modal-meta { display: flex; gap: 16px; padding: 12px 20px; background: #faf8f5; font-size: 12px; color: #888; border-bottom: 1px solid #f0eeea; }
.modal-meta b { color: #555; }
.modal-body { padding: 16px 20px; max-height: 55vh; overflow-y: auto; }
.modal-body pre { background: #1e1e2e; color: #cdd6f4; padding: 16px; border-radius: 10px; font-size: 12px; font-family: 'Fira Code', 'Consolas', monospace; overflow-x: auto; line-height: 1.5; white-space: pre-wrap; word-break: break-all; }
/* Toast */
.toast { position: fixed; top: 20px; right: 20px; padding: 12px 20px; border-radius: 10px; color: #fff; font-size: 13px; font-weight: 500; z-index: 9999; transform: translateY(-20px); opacity: 0; transition: all .3s; pointer-events: none; }
.toast.show { transform: translateY(0); opacity: 1; }
.toast.success { background: #10b981; }
.toast.error { background: #ef4444; }
.loading { text-align: center; padding: 40px; color: #888; }
.spinner { width: 28px; height: 28px; border: 3px solid #f0eeea; border-top-color: #C9A96E; border-radius: 50%; animation: spin .7s linear infinite; margin: 0 auto 10px; }
@keyframes spin { to { transform: rotate(360deg); } }
.empty { text-align: center; padding: 40px; color: #aaa; font-size: 14px; }
</style>
</head>
<body>
<!-- ═══ SIDEBAR (hidden when in iframe via frame-detect.js) ═══ -->
<nav class="sidebar">
<div class="sb-logo">Canif<span>a</span><div class="sb-live"><div class="sb-live-dot"></div>LIVE</div></div>
<div class="sb-section">Tools</div>
<div class="sb-nav">
<a class="sb-link active" href="cache.html"><span class="sb-link-icon">🗄️</span>Cache Manager</a>
<a class="sb-link" href="product.html"><span class="sb-link-icon">🏷️</span>Product Perf.</a>
</div>
</nav>
<div class="main">
<div class="topbar">
<div class="page-title">🗄️ Cache Manager</div>
<div style="margin-left:auto;font-size:10px;color:var(--m,#888)" id="topbar-meta">Loading...</div>
<button class="refresh-btn" onclick="loadAll()">🔄 Refresh</button>
</div>
<div class="content">
<!-- KPI Row -->
<div class="cache-kpis">
<div class="ck"><div class="ck-val" style="color:#6366f1" id="s-keys"></div><div class="ck-label">Total Keys</div></div>
<div class="ck"><div class="ck-val" style="color:#10b981" id="s-mem"></div><div class="ck-label">Memory Used</div></div>
<div class="ck"><div class="ck-val" style="color:#f59e0b" id="s-peak"></div><div class="ck-label">Peak Memory</div></div>
<div class="ck"><div class="ck-val" style="color:#06b6d4" id="s-rhit"></div><div class="ck-label">Resp Hits</div></div>
<div class="ck"><div class="ck-val" style="color:#8b5cf6" id="s-ehit"></div><div class="ck-label">Emb Hits</div></div>
<div class="ck"><div class="ck-val" style="color:#ef4444" id="s-miss"></div><div class="ck-label">Misses</div></div>
</div>
<!-- Tabs -->
<div class="cache-tabs">
<button class="cache-tab active" onclick="switchTab('cat',this)">📂 Categories</button>
<button class="cache-tab" onclick="switchTab('keys',this)">🔑 All Keys</button>
</div>
<!-- Tab: Categories -->
<div id="tab-cat">
<div id="cat-container"><div class="loading"><div class="spinner"></div>Loading...</div></div>
</div>
<!-- Tab: All Keys -->
<div id="tab-keys" style="display:none">
<div class="keys-toolbar">
<input type="text" id="pat-input" placeholder="🔍 Pattern (e.g. resp_cache:*)" value="*">
<button class="kt-btn primary" onclick="loadKeys()">🔍 Search</button>
<button class="kt-btn" onclick="clearByPattern()">🗑️ Clear Pattern</button>
<button class="kt-btn danger" onclick="clearAll()">💥 Flush All</button>
</div>
<div id="keys-container"><div class="empty">Click Search to load keys</div></div>
</div>
</div>
</div>
<!-- Detail Modal -->
<div class="modal-overlay" id="modal" onclick="if(event.target===this)closeModal()">
<div class="modal">
<div class="modal-head">
<div class="modal-title" id="m-title">Key Detail</div>
<button class="modal-close" onclick="closeModal()"></button>
</div>
<div class="modal-meta">
<span>Type: <b id="m-type"></b></span>
<span>TTL: <b id="m-ttl"></b></span>
<span>Key: <b id="m-key" style="font-family:monospace"></b></span>
</div>
<div class="modal-body">
<pre id="m-value">Loading...</pre>
</div>
</div>
</div>
<!-- Toast -->
<div class="toast" id="toast"></div>
<script>
const fmt = n => n == null ? '—' : Number(n).toLocaleString('vi-VN');
function formatTTL(ttl) {
if (ttl === -1) return '∞ no expiry';
if (ttl === -2) return '⚠ expired';
if (ttl > 86400) return Math.floor(ttl/86400) + 'd ' + Math.floor((ttl%86400)/3600) + 'h';
if (ttl > 3600) return Math.floor(ttl/3600) + 'h ' + Math.floor((ttl%3600)/60) + 'm';
if (ttl > 60) return Math.floor(ttl/60) + 'm ' + (ttl%60) + 's';
return ttl + 's';
}
function formatSize(b) {
if (b < 0) return '—';
if (b < 1024) return b + ' B';
if (b < 1048576) return (b/1024).toFixed(1) + ' KB';
return (b/1048576).toFixed(2) + ' MB';
}
function showToast(msg, type='success') {
const t = document.getElementById('toast');
t.textContent = msg;
t.className = 'toast ' + type + ' show';
setTimeout(() => t.classList.remove('show'), 3000);
}
// Clock
setInterval(() => {
const el = document.getElementById('clock');
if (el) el.textContent = new Date().toLocaleTimeString('vi-VN', {hour:'2-digit',minute:'2-digit',second:'2-digit'});
}, 1000);
// ═══ TABS ═══
function switchTab(name, el) {
document.querySelectorAll('.cache-tab').forEach(t => t.classList.remove('active'));
el.classList.add('active');
document.getElementById('tab-cat').style.display = name === 'cat' ? 'block' : 'none';
document.getElementById('tab-keys').style.display = name === 'keys' ? 'block' : 'none';
if (name === 'keys') loadKeys();
}
// ═══ MODAL ═══
async function viewKey(key) {
document.getElementById('modal').classList.add('show');
document.getElementById('m-title').textContent = 'Loading...';
document.getElementById('m-key').textContent = key;
document.getElementById('m-type').textContent = '...';
document.getElementById('m-ttl').textContent = '...';
document.getElementById('m-value').textContent = 'Loading...';
try {
const r = await fetch('/api/cache/get?key=' + encodeURIComponent(key));
const d = await r.json();
if (d.error) throw new Error(d.error);
// Shorten title
const shortKey = key.length > 40 ? key.substring(0, 20) + '...' + key.substring(key.length - 15) : key;
document.getElementById('m-title').textContent = shortKey;
document.getElementById('m-type').textContent = d.type;
document.getElementById('m-ttl').textContent = formatTTL(d.ttl);
// Pretty print value
let display;
if (typeof d.value === 'object') {
display = JSON.stringify(d.value, null, 2);
} else {
display = String(d.value);
}
document.getElementById('m-value').textContent = display;
} catch(e) {
document.getElementById('m-value').textContent = '❌ Error: ' + e.message;
}
}
function closeModal() { document.getElementById('modal').classList.remove('show'); }
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeModal(); });
// ═══ LOAD STATS ═══
const CAT_COLORS = ['#6366f1','#10b981','#f59e0b','#ef4444','#8b5cf6','#06b6d4','#ec4899','#84cc16','#f97316','#14b8a6'];
async function loadStats() {
try {
const r = await fetch('/api/cache/stats');
const d = await r.json();
if (d.error) throw new Error(d.error);
document.getElementById('s-keys').textContent = fmt(d.total_keys);
document.getElementById('s-mem').textContent = d.memory_used || '—';
document.getElementById('s-peak').textContent = d.memory_peak || '—';
document.getElementById('s-rhit').textContent = fmt(d.in_memory_stats?.resp_hits || 0);
document.getElementById('s-ehit').textContent = fmt(d.in_memory_stats?.emb_hits || 0);
document.getElementById('s-miss').textContent = fmt(d.in_memory_stats?.misses || 0);
document.getElementById('topbar-meta').textContent = fmt(d.total_keys) + ' keys · ' + d.memory_used + ' · Redis ✓';
// Render categories
const cats = d.categories || {};
const sorted = Object.entries(cats).sort((a,b) => b[1].count - a[1].count);
if (!sorted.length) {
document.getElementById('cat-container').innerHTML = '<div class="empty">📭 Cache trống — chưa có key nào</div>';
return;
}
document.getElementById('cat-container').innerHTML = '<div class="cat-grid">' + sorted.map(([name, cat], i) => {
const color = CAT_COLORS[i % CAT_COLORS.length];
const keys = (cat.keys || []).map(k =>
`<div class="ck-row" onclick="viewKey('${k.key.replace(/'/g,"\\'")}')">
<span class="ck-key" title="${k.key}">${k.key}</span>
<span class="ck-ttl ${k.ttl > 0 && k.ttl < 300 ? 'hot' : ''}">${formatTTL(k.ttl)}</span>
</div>`
).join('');
const remaining = cat.count - (cat.keys || []).length;
const loadMoreBtn = remaining > 0
? `<button class="cat-more" id="more-${i}" onclick="loadMoreKeys('${name}', ${i})">📋 Load ${remaining} keys còn lại</button>`
: '';
return `<div class="cat-card">
<div class="cat-head">
<span class="cat-name">${name}</span>
<span class="cat-badge" style="background:${color}">${cat.count}</span>
</div>
<div class="cat-keys-list" id="keylist-${i}">${keys}</div>
${loadMoreBtn}
<button class="cat-clear" onclick="clearCat('${name}')">🗑️ Clear ${name}:*</button>
</div>`;
}).join('') + '</div>';
} catch(e) {
document.getElementById('cat-container').innerHTML = '<div class="empty">❌ ' + e.message + '</div>';
document.getElementById('topbar-meta').textContent = 'Redis unavailable';
}
}
// ═══ LOAD MORE KEYS IN CATEGORY ═══
async function loadMoreKeys(prefix, idx) {
const btn = document.getElementById('more-' + idx);
if (btn) btn.textContent = '⏳ Loading...';
try {
const r = await fetch('/api/cache/keys?pattern=' + encodeURIComponent(prefix + ':*') + '&limit=500');
const d = await r.json();
if (d.error) throw new Error(d.error);
const list = document.getElementById('keylist-' + idx);
list.classList.add('expanded');
list.innerHTML = (d.keys || []).map(k =>
`<div class="ck-row" onclick="viewKey('${k.key.replace(/'/g,"\\'")}')">
<span class="ck-key" title="${k.key}">${k.key}</span>
<span class="ck-ttl ${k.ttl > 0 && k.ttl < 300 ? 'hot' : ''}">${formatTTL(k.ttl)}</span>
</div>`
).join('');
if (btn) btn.remove();
} catch(e) {
if (btn) btn.textContent = '❌ Error';
showToast('Error: ' + e.message, 'error');
}
}
// ═══ LOAD KEYS ═══
async function loadKeys() {
const pattern = document.getElementById('pat-input').value.trim() || '*';
const c = document.getElementById('keys-container');
c.innerHTML = '<div class="loading"><div class="spinner"></div>Scanning keys...</div>';
try {
const r = await fetch('/api/cache/keys?pattern=' + encodeURIComponent(pattern) + '&limit=200');
const d = await r.json();
if (d.error) throw new Error(d.error);
if (!d.keys?.length) {
c.innerHTML = '<div class="empty">📭 No keys matching "' + pattern + '"</div>';
return;
}
c.innerHTML = `<table class="keys-tbl">
<thead><tr><th>#</th><th>Key</th><th>Type</th><th>Size</th><th>TTL</th></tr></thead>
<tbody>${d.keys.map((k, i) =>
`<tr onclick="viewKey('${k.key.replace(/'/g,"\\'")}')">
<td style="color:#aaa">${i+1}</td>
<td><span class="key-mono" title="${k.key}">${k.key}</span></td>
<td><span class="type-pill type-${k.type}">${k.type}</span></td>
<td style="font-size:12px;color:#888">${formatSize(k.size)}</td>
<td style="font-size:12px;color:#888">${formatTTL(k.ttl)}</td>
</tr>`
).join('')}</tbody>
</table>
<div style="font-size:11px;color:#aaa;margin-top:8px;text-align:right">${d.count} keys found</div>`;
} catch(e) {
c.innerHTML = '<div class="empty">❌ ' + e.message + '</div>';
}
}
// ═══ CLEAR ACTIONS ═══
async function clearCat(prefix) {
if (!confirm('🗑️ Clear all "' + prefix + ':*" keys?')) return;
try {
const r = await fetch('/api/cache/clear?pattern=' + encodeURIComponent(prefix + ':*'), {method:'POST'});
const d = await r.json();
showToast('🗑️ Deleted ' + d.deleted + ' keys');
loadAll();
} catch(e) { showToast('Error: ' + e.message, 'error'); }
}
async function clearByPattern() {
const p = document.getElementById('pat-input').value.trim() || 'resp_cache:*';
if (!confirm('🗑️ Clear all keys matching "' + p + '"?')) return;
try {
const r = await fetch('/api/cache/clear?pattern=' + encodeURIComponent(p), {method:'POST'});
const d = await r.json();
showToast('🗑️ Deleted ' + d.deleted + ' keys');
loadAll();
} catch(e) { showToast('Error: ' + e.message, 'error'); }
}
async function clearAll() {
if (!confirm('💥 FLUSH ALL CACHE?\nThis will delete ALL cached data!')) return;
if (!confirm('⚠️ Are you really sure? This cannot be undone.')) return;
try {
const r = await fetch('/api/cache/clear-all', {method:'POST'});
showToast('💥 All cache flushed!');
loadAll();
} catch(e) { showToast('Error: ' + e.message, 'error'); }
}
// ═══ INIT ═══
async function loadAll() { await loadStats(); }
loadAll();
</script>
</body>
</html>
<!DOCTYPE html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Changelog - Canifa AI System</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&display=swap" rel="stylesheet">
<style>
<link rel="stylesheet" href="/static/lab.css">
<link rel="stylesheet" href="/static/warm-override.css">
<script src="/static/frame-detect.js"></script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Inter', sans-serif; background: #0b0e14; color: #c9d1d9; min-height: 100vh; display: flex; }
body { font-family: 'Inter', sans-serif; background: var(--bg, #FAF6F0); color: var(--t, #2C1810); min-height: 100vh; display: flex; }
/* ═══ SIDEBAR ═══ */
.sidebar { width: 260px; min-height: 100vh; background: #0d1117; border-right: 1px solid #1b2030; display: flex; flex-direction: column; position: fixed; top: 0; left: 0; z-index: 100; }
.sidebar-brand { padding: 24px 20px; border-bottom: 1px solid #1b2030; display: flex; align-items: center; gap: 12px; }
.sidebar { width: 260px; min-height: 100vh; background: var(--s, #FFFFFF); border-right: 1px solid var(--b, #E8DED0); display: flex; flex-direction: column; position: fixed; top: 0; left: 0; z-index: 100; }
.sidebar-brand { padding: 24px 20px; border-bottom: 1px solid var(--b, #E8DED0); display: flex; align-items: center; gap: 12px; }
.brand-icon { width: 40px; height: 40px; background: linear-gradient(135deg, #667eea, #764ba2); border-radius: 10px; display: flex; align-items: center; justify-content: center; font-size: 1.3em; flex-shrink: 0; }
.brand-text h2 { font-size: 1em; font-weight: 700; color: #e6edf3; }
.brand-text h2 { font-size: 1em; font-weight: 700; color: var(--t, #2C1810); }
.brand-text span { font-size: 0.7em; color: #484f58; font-weight: 500; }
.nav-group { padding: 16px 12px 8px; }
.nav-group-label { font-size: 0.65em; font-weight: 700; text-transform: uppercase; letter-spacing: 1.5px; color: #484f58; padding: 0 12px; margin-bottom: 8px; }
.nav-item { display: flex; align-items: center; gap: 12px; padding: 10px 16px; border-radius: 8px; color: #8b949e; text-decoration: none; font-size: 0.88em; font-weight: 500; transition: all 0.2s; margin-bottom: 2px; cursor: pointer; position: relative; }
.nav-item:hover { background: #161b22; color: #e6edf3; }
.nav-item { display: flex; align-items: center; gap: 12px; padding: 10px 16px; border-radius: 8px; color: var(--m, #6B5B4F); text-decoration: none; font-size: 0.88em; font-weight: 500; transition: all 0.2s; margin-bottom: 2px; cursor: pointer; position: relative; }
.nav-item:hover { background: var(--bg, #FAF6F0); color: var(--t, #2C1810); }
.nav-item.active { background: linear-gradient(135deg, rgba(102,126,234,0.15), rgba(118,75,162,0.1)); color: #a78bfa; font-weight: 600; }
.nav-item.active::before { content: ''; position: absolute; left: 0; top: 50%; transform: translateY(-50%); width: 3px; height: 20px; background: linear-gradient(180deg, #667eea, #764ba2); border-radius: 0 3px 3px 0; }
.nav-icon { font-size: 1.1em; width: 22px; text-align: center; flex-shrink: 0; }
......@@ -26,61 +29,61 @@
.badge-live { background: rgba(76,175,80,0.15); color: #4caf50; border: 1px solid rgba(76,175,80,0.25); }
.badge-new { background: rgba(102,126,234,0.15); color: #667eea; border: 1px solid rgba(102,126,234,0.25); }
.badge-beta { background: rgba(255,152,0,0.15); color: #ff9800; border: 1px solid rgba(255,152,0,0.25); }
.badge-count { background: rgba(88,166,255,0.15); color: #58a6ff; border: 1px solid rgba(88,166,255,0.2); }
.sidebar-footer { margin-top: auto; padding: 16px; border-top: 1px solid #1b2030; }
.version-info { display: flex; align-items: center; gap: 10px; padding: 10px 12px; background: #161b22; border-radius: 8px; border: 1px solid #1b2030; }
.badge-count { background: rgba(88,166,255,0.15); color: var(--gold, #B8860B); border: 1px solid rgba(88,166,255,0.2); }
.sidebar-footer { margin-top: auto; padding: 16px; border-top: 1px solid var(--b, #E8DED0); }
.version-info { display: flex; align-items: center; gap: 10px; padding: 10px 12px; background: var(--bg, #FAF6F0); border-radius: 8px; border: 1px solid var(--b, #E8DED0); }
.version-dot { width: 8px; height: 8px; border-radius: 50%; background: #56d364; box-shadow: 0 0 8px rgba(86,211,100,0.4); }
.version-text { font-size: 0.75em; color: #8b949e; }
.version-text { font-size: 0.75em; color: var(--m, #6B5B4F); }
/* ═══ MAIN ═══ */
.main { margin-left: 260px; flex: 1; min-height: 100vh; display: flex; flex-direction: column; }
.topbar { padding: 24px 32px; border-bottom: 1px solid #1b2030; display: flex; justify-content: space-between; align-items: center; }
.topbar h1 { font-size: 1.5em; font-weight: 800; color: #e6edf3; }
.topbar { padding: 24px 32px; border-bottom: 1px solid var(--b, #E8DED0); display: flex; justify-content: space-between; align-items: center; }
.topbar h1 { font-size: 1.5em; font-weight: 800; color: var(--t, #2C1810); }
.topbar p { font-size: 0.85em; color: #484f58; margin-top: 2px; }
.content { padding: 24px 32px; flex: 1; max-width: 800px; }
/* ═══ FORM ═══ */
.form-input { width: 100%; padding: 10px 14px; border-radius: 8px; border: 1px solid #1b2030; background: #0d1117; color: #e6edf3; font-size: 0.88em; font-family: inherit; transition: border-color 0.2s; }
.form-input { width: 100%; padding: 10px 14px; border-radius: 8px; border: 1px solid var(--b, #E8DED0); background: var(--s, #FFFFFF); color: var(--t, #2C1810); font-size: 0.88em; font-family: inherit; transition: border-color 0.2s; }
.form-input:focus { outline: none; border-color: #667eea; }
.btn { padding: 8px 16px; border-radius: 8px; font-size: 0.82em; font-weight: 600; border: 1px solid #1b2030; background: #161b22; color: #c9d1d9; cursor: pointer; transition: all 0.2s; }
.btn { padding: 8px 16px; border-radius: 8px; font-size: 0.82em; font-weight: 600; border: 1px solid var(--b, #E8DED0); background: var(--bg, #FAF6F0); color: var(--t, #2C1810); cursor: pointer; transition: all 0.2s; }
.btn-primary { background: linear-gradient(135deg, #667eea, #764ba2); border: none; color: #fff; }
.btn-primary:hover { opacity: 0.9; transform: translateY(-1px); }
/* ═══ CHANGELOG ═══ */
.cl-form { display: flex; gap: 10px; margin-bottom: 24px; background: #0d1117; border: 1px solid #1b2030; border-radius: 12px; padding: 14px; }
.cl-form { display: flex; gap: 10px; margin-bottom: 24px; background: var(--s, #FFFFFF); border: 1px solid var(--b, #E8DED0); border-radius: 12px; padding: 14px; }
.cl-form input { flex: 1; }
.cl-form .author { max-width: 140px; }
.cl-entry { display: flex; gap: 14px; padding: 14px 0; border-bottom: 1px solid #1b2030; position: relative; }
.cl-entry { display: flex; gap: 14px; padding: 14px 0; border-bottom: 1px solid var(--b, #E8DED0); position: relative; }
.cl-entry:last-child { border-bottom: none; }
.cl-dot { width: 10px; height: 10px; border-radius: 50%; background: #56d364; margin-top: 5px; flex-shrink: 0; box-shadow: 0 0 6px rgba(86,211,100,0.3); }
.cl-body { flex: 1; min-width: 0; }
.cl-meta { display: flex; gap: 10px; align-items: center; margin-bottom: 4px; }
.cl-author { font-weight: 700; color: #e6edf3; font-size: 0.85em; }
.cl-author { font-weight: 700; color: var(--t, #2C1810); font-size: 0.85em; }
.cl-time { font-size: 0.75em; color: #484f58; }
.cl-content { font-size: 0.88em; color: #b1bac4; line-height: 1.6; }
.cl-delete { position: absolute; right: 0; top: 14px; width: 26px; height: 26px; border-radius: 6px; border: 1px solid transparent; background: transparent; color: #484f58; font-size: 0.7em; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: all 0.15s; opacity: 0; }
.cl-entry:hover .cl-delete { opacity: 1; }
.cl-delete:hover { background: rgba(248,81,73,0.1); color: #f85149; border-color: rgba(248,81,73,0.2); }
.cl-line { position: absolute; left: 4px; top: 24px; bottom: 0; width: 2px; background: #1b2030; }
.cl-delete:hover { background: rgba(248,81,73,0.1); color: var(--red, #DC2626); border-color: rgba(248,81,73,0.2); }
.cl-line { position: absolute; left: 4px; top: 24px; bottom: 0; width: 2px; background: var(--b, #E8DED0); }
.empty-state { text-align: center; padding: 60px 20px; color: #484f58; }
.empty-state .empty-icon { font-size: 3em; margin-bottom: 12px; opacity: 0.5; }
/* ═══ USAGE GUIDE ═══ */
.guide { margin-top: 40px; border-top: 1px solid #1b2030; padding-top: 20px; }
.guide { margin-top: 40px; border-top: 1px solid var(--b, #E8DED0); padding-top: 20px; }
.guide-toggle { display: flex; align-items: center; gap: 8px; cursor: pointer; color: #484f58; font-size: 0.82em; font-weight: 600; background: none; border: none; padding: 6px 0; transition: color 0.2s; }
.guide-toggle:hover { color: #8b949e; }
.guide-toggle:hover { color: var(--m, #6B5B4F); }
.guide-toggle .arrow { transition: transform 0.2s; display: inline-block; }
.guide-toggle.open .arrow { transform: rotate(90deg); }
.guide-body { display: none; margin-top: 14px; padding: 20px; background: #0d1117; border: 1px solid #1b2030; border-radius: 12px; }
.guide-body { display: none; margin-top: 14px; padding: 20px; background: var(--s, #FFFFFF); border: 1px solid var(--b, #E8DED0); border-radius: 12px; }
.guide-body.show { display: block; }
.guide-body h4 { font-size: 0.85em; font-weight: 700; color: #e6edf3; margin: 0 0 10px; }
.guide-body h4 { font-size: 0.85em; font-weight: 700; color: var(--t, #2C1810); margin: 0 0 10px; }
.guide-body table { width: 100%; border-collapse: collapse; font-size: 0.8em; }
.guide-body th { text-align: left; padding: 8px 10px; color: #8b949e; font-weight: 600; border-bottom: 1px solid #1b2030; }
.guide-body td { padding: 8px 10px; color: #c9d1d9; border-bottom: 1px solid rgba(27,32,48,0.5); }
.guide-body th { text-align: left; padding: 8px 10px; color: var(--m, #6B5B4F); font-weight: 600; border-bottom: 1px solid var(--b, #E8DED0); }
.guide-body td { padding: 8px 10px; color: var(--t, #2C1810); border-bottom: 1px solid rgba(27,32,48,0.5); }
.guide-body tr:last-child td { border-bottom: none; }
.guide-tip { margin-top: 14px; padding: 10px 14px; background: rgba(102,126,234,0.08); border: 1px solid rgba(102,126,234,0.15); border-radius: 8px; font-size: 0.78em; color: #8b949e; line-height: 1.6; }
.guide-tip { margin-top: 14px; padding: 10px 14px; background: rgba(102,126,234,0.08); border: 1px solid rgba(102,126,234,0.15); border-radius: 8px; font-size: 0.78em; color: var(--m, #6B5B4F); line-height: 1.6; }
.guide-tip strong { color: #a78bfa; }
@media (max-width: 1024px) {
......
/**
* 📊 Chart Templates — 25 Metabase-Inspired Visualization Types
*
* AI returns structured schema → frontend matches chart_type → renders with warm palette.
*
* Types: bar, horizontalBar, multiBar, stackedBar, line, area, stackedArea,
* pie, doughnut, scatter, combo, bubble, radar, polarArea, waterfall,
* numberCard, gauge, progress, funnel, table,
* treemap, heatmap, boxplot, sankey, pivotTable
*/
// ─── Warm Color Palettes ────────────────────────────────────────────
const PALETTES = {
warm: ['#C75B39','#E8734F','#B8860B','#2E7D32','#5C6BC0','#AB47BC','#00897B','#F4511E','#6D4C41','#78909C'],
pastel: ['#FFB74D','#FF8A65','#A1887F','#81C784','#64B5F6','#BA68C8','#4DB6AC','#FF7043','#8D6E63','#90A4AE'],
earth: ['#8D6E63','#BCAAA4','#A5D6A7','#FFE082','#90CAF9','#CE93D8','#80CBC4','#FFAB91','#D7CCC8','#B0BEC5'],
vibrant: ['#FF6F00','#D84315','#1B5E20','#0D47A1','#4A148C','#880E4F','#004D40','#BF360C','#3E2723','#263238'],
};
function getColors(n, palette) {
const p = PALETTES[palette] || PALETTES.warm;
const colors = [];
for (let i = 0; i < n; i++) colors.push(p[i % p.length]);
return colors;
}
// ─── Shared Chart.js Defaults ───────────────────────────────────────
const CHART_DEFAULTS = {
font: { family: "'Outfit', sans-serif", size: 11 },
gridColor: '#E8DED4',
textColor: '#3D2B1F',
mutedColor: '#8B7355',
bgColor: '#FFFFFF',
};
function applyWarmTheme(config) {
if (!config.options) config.options = {};
if (!config.options.plugins) config.options.plugins = {};
// Font
config.options.font = CHART_DEFAULTS.font;
// Legend
if (!config.options.plugins.legend) config.options.plugins.legend = {};
config.options.plugins.legend.labels = {
color: CHART_DEFAULTS.textColor,
font: { family: CHART_DEFAULTS.font.family, size: 11 },
padding: 16,
usePointStyle: true,
pointStyle: 'rectRounded',
};
// Tooltip
config.options.plugins.tooltip = {
backgroundColor: '#3D2B1F',
titleColor: '#FFF',
bodyColor: '#FFF',
borderColor: '#C75B39',
borderWidth: 1,
cornerRadius: 8,
padding: 10,
titleFont: { family: CHART_DEFAULTS.font.family, size: 12, weight: '600' },
bodyFont: { family: CHART_DEFAULTS.font.family, size: 11 },
displayColors: true,
boxPadding: 4,
};
// Scales
if (config.options.scales) {
for (const [key, axis] of Object.entries(config.options.scales)) {
if (axis.grid) axis.grid.color = CHART_DEFAULTS.gridColor;
if (axis.ticks) {
axis.ticks.color = CHART_DEFAULTS.mutedColor;
axis.ticks.font = { family: CHART_DEFAULTS.font.family, size: 10 };
}
axis.border = { display: false };
}
}
// Animation
config.options.animation = { duration: 600, easing: 'easeOutQuart' };
config.options.responsive = true;
config.options.maintainAspectRatio = false;
return config;
}
function fmt(val) {
if (val === null || val === undefined) return '—';
if (typeof val === 'number') {
if (Math.abs(val) >= 1e6) return (val / 1e6).toFixed(1) + 'M';
if (Math.abs(val) >= 1e3) return (val / 1e3).toFixed(1) + 'K';
return val.toLocaleString('vi-VN');
}
return String(val);
}
function escH(s) {
if (!s) return '';
return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// ─── Card Wrapper ───────────────────────────────────────────────────
function chartCard(data, innerHtml, canvasId) {
return `
<div class="chart-card">
<div class="cc-header">
<div class="cc-title">${escH(data.title || 'Visualization')}</div>
${data.description ? `<div class="cc-desc">${escH(data.description)}</div>` : ''}
</div>
${data.trend ? `
<div class="cc-trend ${data.trend.direction === 'up' ? 'up' : 'down'}">
${data.trend.direction === 'up' ? '↑' : '↓'} ${data.trend.value || data.trend.percentage || 0}%
${data.trend.label ? `<span class="cc-trend-label">${escH(data.trend.label)}</span>` : ''}
</div>` : ''}
<div class="cc-body">${innerHtml}</div>
${data.footer ? `<div class="cc-footer">${escH(data.footer)}</div>` : ''}
<div class="cc-meta">
<span>📊 ${escH(data.chart_type || 'chart')}</span>
<span>📋 ${data.data ? data.data.length : 0} rows</span>
</div>
</div>`;
}
function makeCanvas(id) {
return `<div class="cc-canvas-wrap"><canvas id="${id}"></canvas></div>`;
}
// ─── Global counter ─────────────────────────────────────────────────
let _chartId = 0;
function nextId() { return 'cht_' + (++_chartId); }
// ═══════════════════════════════════════════════════════════════════
// TIER 1 — Chart.js Native (10 types)
// ═══════════════════════════════════════════════════════════════════
function renderBar(data) {
const id = nextId();
const xKey = data.x_key || data.xAxisKey || Object.keys(data.data[0])[0];
const yKeys = data.y_keys || [Object.keys(data.data[0]).find(k => k !== xKey)];
const colors = getColors(yKeys.length, 'warm');
const config = applyWarmTheme({
type: 'bar',
data: {
labels: data.data.map(r => r[xKey]),
datasets: yKeys.map((k, i) => ({
label: (data.y_labels && data.y_labels[k]) || k,
data: data.data.map(r => r[k]),
backgroundColor: colors[i] + 'CC',
borderColor: colors[i],
borderWidth: 1,
borderRadius: 6,
borderSkipped: false,
})),
},
options: {
scales: {
x: { grid: { display: false }, ticks: {} },
y: { grid: {}, ticks: { callback: v => fmt(v) }, beginAtZero: true },
},
plugins: { legend: { display: yKeys.length > 1 } },
},
});
return { html: chartCard(data, makeCanvas(id), id), config, canvasId: id };
}
function renderHorizontalBar(data) {
const id = nextId();
const xKey = data.x_key || Object.keys(data.data[0])[0];
const yKeys = data.y_keys || [Object.keys(data.data[0]).find(k => k !== xKey)];
const colors = getColors(yKeys.length, 'warm');
const config = applyWarmTheme({
type: 'bar',
data: {
labels: data.data.map(r => r[xKey]),
datasets: yKeys.map((k, i) => ({
label: (data.y_labels && data.y_labels[k]) || k,
data: data.data.map(r => r[k]),
backgroundColor: colors[i] + 'CC',
borderColor: colors[i],
borderWidth: 1,
borderRadius: 6,
borderSkipped: false,
})),
},
options: {
indexAxis: 'y',
scales: {
x: { grid: {}, ticks: { callback: v => fmt(v) }, beginAtZero: true },
y: { grid: { display: false }, ticks: {} },
},
plugins: { legend: { display: yKeys.length > 1 } },
},
});
return { html: chartCard(data, makeCanvas(id), id), config, canvasId: id };
}
function renderMultiBar(data) {
// Same as bar but always shows legend
const result = renderBar(data);
result.config.options.plugins.legend.display = true;
return result;
}
function renderStackedBar(data) {
const result = renderBar(data);
result.config.options.scales.x.stacked = true;
result.config.options.scales.y.stacked = true;
result.config.options.plugins.legend.display = true;
return result;
}
function renderLine(data) {
const id = nextId();
const xKey = data.x_key || Object.keys(data.data[0])[0];
const yKeys = data.y_keys || Object.keys(data.data[0]).filter(k => k !== xKey);
const colors = getColors(yKeys.length, 'warm');
const config = applyWarmTheme({
type: 'line',
data: {
labels: data.data.map(r => r[xKey]),
datasets: yKeys.map((k, i) => ({
label: (data.y_labels && data.y_labels[k]) || k,
data: data.data.map(r => r[k]),
borderColor: colors[i],
backgroundColor: colors[i] + '20',
borderWidth: 2.5,
pointRadius: 3,
pointHoverRadius: 6,
pointBackgroundColor: '#fff',
pointBorderColor: colors[i],
pointBorderWidth: 2,
tension: 0.35,
})),
},
options: {
scales: {
x: { grid: { display: false }, ticks: {} },
y: { grid: {}, ticks: { callback: v => fmt(v) }, beginAtZero: true },
},
plugins: { legend: { display: yKeys.length > 1 } },
interaction: { intersect: false, mode: 'index' },
},
});
return { html: chartCard(data, makeCanvas(id), id), config, canvasId: id };
}
function renderArea(data) {
const result = renderLine(data);
result.config.data.datasets.forEach(ds => { ds.fill = true; ds.backgroundColor = ds.borderColor + '25'; });
return result;
}
function renderStackedArea(data) {
const result = renderArea(data);
result.config.options.scales.y.stacked = true;
result.config.data.datasets.forEach(ds => { ds.fill = true; ds.backgroundColor = ds.borderColor + '40'; });
result.config.options.plugins.legend.display = true;
return result;
}
function renderPie(data) {
const id = nextId();
const xKey = data.x_key || Object.keys(data.data[0])[0];
const yKey = data.y_keys ? data.y_keys[0] : Object.keys(data.data[0]).find(k => k !== xKey);
const labels = data.data.map(r => r[xKey]);
const values = data.data.map(r => r[yKey]);
const colors = getColors(labels.length, 'pastel');
const config = applyWarmTheme({
type: 'pie',
data: {
labels,
datasets: [{ data: values, backgroundColor: colors, borderColor: '#fff', borderWidth: 2, hoverOffset: 8 }],
},
options: {
plugins: {
legend: { position: 'right', labels: { padding: 12 } },
},
},
});
return { html: chartCard(data, makeCanvas(id), id), config, canvasId: id };
}
function renderDoughnut(data) {
const id = nextId();
const xKey = data.x_key || Object.keys(data.data[0])[0];
const yKey = data.y_keys ? data.y_keys[0] : Object.keys(data.data[0]).find(k => k !== xKey);
const labels = data.data.map(r => r[xKey]);
const values = data.data.map(r => r[yKey]);
const total = values.reduce((a, b) => a + (b || 0), 0);
const colors = getColors(labels.length, 'pastel');
const config = applyWarmTheme({
type: 'doughnut',
data: {
labels,
datasets: [{ data: values, backgroundColor: colors, borderColor: '#fff', borderWidth: 2, hoverOffset: 8 }],
},
options: {
cutout: '65%',
plugins: {
legend: { position: 'right', labels: { padding: 12 } },
// Center label plugin
},
},
});
// Doughnut center text plugin
const centerPlugin = {
id: 'doughnutCenter',
afterDraw(chart) {
const { ctx, width, height } = chart;
ctx.save();
ctx.font = `bold 22px ${CHART_DEFAULTS.font.family}`;
ctx.fillStyle = CHART_DEFAULTS.textColor;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(fmt(total), width / 2, height / 2 - 8);
ctx.font = `11px ${CHART_DEFAULTS.font.family}`;
ctx.fillStyle = CHART_DEFAULTS.mutedColor;
ctx.fillText(data.total_label || 'Total', width / 2, height / 2 + 14);
ctx.restore();
}
};
config.plugins = [centerPlugin];
return { html: chartCard(data, makeCanvas(id), id), config, canvasId: id };
}
function renderScatter(data) {
const id = nextId();
const keys = Object.keys(data.data[0]);
const xKey = data.x_key || keys[0];
const yKey = data.y_keys ? data.y_keys[0] : keys[1];
const config = applyWarmTheme({
type: 'scatter',
data: {
datasets: [{
label: `${(data.y_labels && data.y_labels[yKey]) || yKey} vs ${xKey}`,
data: data.data.map(r => ({ x: r[xKey], y: r[yKey] })),
backgroundColor: '#C75B39' + '99',
borderColor: '#C75B39',
pointRadius: 5,
pointHoverRadius: 8,
}],
},
options: {
scales: {
x: { grid: {}, ticks: { callback: v => fmt(v) }, title: { display: true, text: xKey, color: CHART_DEFAULTS.mutedColor } },
y: { grid: {}, ticks: { callback: v => fmt(v) }, title: { display: true, text: yKey, color: CHART_DEFAULTS.mutedColor } },
},
},
});
return { html: chartCard(data, makeCanvas(id), id), config, canvasId: id };
}
// ═══════════════════════════════════════════════════════════════════
// TIER 2 — Chart.js Mixed/Custom (5 types)
// ═══════════════════════════════════════════════════════════════════
function renderCombo(data) {
const id = nextId();
const xKey = data.x_key || Object.keys(data.data[0])[0];
const yKeys = data.y_keys || Object.keys(data.data[0]).filter(k => k !== xKey);
const colors = getColors(yKeys.length, 'warm');
// First key = bar, rest = line
const barKeys = yKeys.slice(0, 1);
const lineKeys = yKeys.slice(1);
const datasets = [];
barKeys.forEach((k, i) => {
datasets.push({
type: 'bar',
label: (data.y_labels && data.y_labels[k]) || k,
data: data.data.map(r => r[k]),
backgroundColor: colors[i] + 'CC',
borderColor: colors[i],
borderWidth: 1,
borderRadius: 6,
order: 2,
yAxisID: 'y',
});
});
lineKeys.forEach((k, i) => {
datasets.push({
type: 'line',
label: (data.y_labels && data.y_labels[k]) || k,
data: data.data.map(r => r[k]),
borderColor: colors[barKeys.length + i],
backgroundColor: 'transparent',
borderWidth: 2.5,
pointRadius: 3,
pointHoverRadius: 6,
tension: 0.35,
order: 1,
yAxisID: lineKeys.length > 0 ? 'y1' : 'y',
});
});
const scales = {
x: { grid: { display: false }, ticks: {} },
y: { grid: {}, ticks: { callback: v => fmt(v) }, beginAtZero: true, position: 'left' },
};
if (lineKeys.length > 0) {
scales.y1 = { grid: { display: false }, ticks: { callback: v => fmt(v) }, position: 'right', beginAtZero: true };
}
const config = applyWarmTheme({
type: 'bar',
data: { labels: data.data.map(r => r[xKey]), datasets },
options: { scales, plugins: { legend: { display: true } }, interaction: { intersect: false, mode: 'index' } },
});
return { html: chartCard(data, makeCanvas(id), id), config, canvasId: id };
}
function renderBubble(data) {
const id = nextId();
const keys = Object.keys(data.data[0]);
const xKey = data.x_key || keys[0];
const yKey = data.y_keys ? data.y_keys[0] : keys[1];
const rKey = data.y_keys && data.y_keys[1] ? data.y_keys[1] : keys[2];
const maxR = Math.max(...data.data.map(r => Math.abs(r[rKey] || 1)));
const config = applyWarmTheme({
type: 'bubble',
data: {
datasets: [{
label: data.title || 'Bubble',
data: data.data.map(r => ({ x: r[xKey], y: r[yKey], r: Math.max(3, (Math.abs(r[rKey] || 1) / maxR) * 25) })),
backgroundColor: '#C75B39' + '66',
borderColor: '#C75B39',
borderWidth: 1,
}],
},
options: {
scales: {
x: { grid: {}, ticks: { callback: v => fmt(v) } },
y: { grid: {}, ticks: { callback: v => fmt(v) } },
},
},
});
return { html: chartCard(data, makeCanvas(id), id), config, canvasId: id };
}
function renderRadar(data) {
const id = nextId();
const xKey = data.x_key || Object.keys(data.data[0])[0];
const yKeys = data.y_keys || Object.keys(data.data[0]).filter(k => k !== xKey);
const colors = getColors(yKeys.length, 'warm');
const config = applyWarmTheme({
type: 'radar',
data: {
labels: data.data.map(r => r[xKey]),
datasets: yKeys.map((k, i) => ({
label: (data.y_labels && data.y_labels[k]) || k,
data: data.data.map(r => r[k]),
backgroundColor: colors[i] + '30',
borderColor: colors[i],
borderWidth: 2,
pointBackgroundColor: colors[i],
pointRadius: 3,
})),
},
options: {
scales: {
r: {
grid: { color: CHART_DEFAULTS.gridColor },
ticks: { color: CHART_DEFAULTS.mutedColor, backdropColor: 'transparent', font: { size: 9 } },
pointLabels: { color: CHART_DEFAULTS.textColor, font: { family: CHART_DEFAULTS.font.family, size: 11 } },
},
},
},
});
return { html: chartCard(data, makeCanvas(id), id), config, canvasId: id };
}
function renderPolarArea(data) {
const id = nextId();
const xKey = data.x_key || Object.keys(data.data[0])[0];
const yKey = data.y_keys ? data.y_keys[0] : Object.keys(data.data[0]).find(k => k !== xKey);
const colors = getColors(data.data.length, 'pastel');
const config = applyWarmTheme({
type: 'polarArea',
data: {
labels: data.data.map(r => r[xKey]),
datasets: [{
data: data.data.map(r => r[yKey]),
backgroundColor: colors.map(c => c + '99'),
borderColor: colors,
borderWidth: 1,
}],
},
options: {
scales: {
r: { grid: { color: CHART_DEFAULTS.gridColor }, ticks: { display: false } },
},
plugins: { legend: { position: 'right' } },
},
});
return { html: chartCard(data, makeCanvas(id), id), config, canvasId: id };
}
function renderWaterfall(data) {
const id = nextId();
const xKey = data.x_key || Object.keys(data.data[0])[0];
const yKey = data.y_keys ? data.y_keys[0] : Object.keys(data.data[0]).find(k => k !== xKey);
// Calculate waterfall segments
let cumulative = 0;
const bases = [], values = [], bgColors = [], bdColors = [];
data.data.forEach((r, i) => {
const val = r[yKey] || 0;
if (i === data.data.length - 1 && (r[xKey] || '').toLowerCase().includes('total')) {
bases.push(0);
values.push(cumulative + val);
bgColors.push('#5C6BC0CC');
bdColors.push('#5C6BC0');
} else {
if (val >= 0) {
bases.push(cumulative);
values.push(val);
bgColors.push('#2E7D32CC');
bdColors.push('#2E7D32');
cumulative += val;
} else {
cumulative += val;
bases.push(cumulative);
values.push(Math.abs(val));
bgColors.push('#C75B39CC');
bdColors.push('#C75B39');
}
}
});
const config = applyWarmTheme({
type: 'bar',
data: {
labels: data.data.map(r => r[xKey]),
datasets: [
{ label: 'Base', data: bases, backgroundColor: 'transparent', borderWidth: 0, borderSkipped: false },
{ label: (data.y_labels && data.y_labels[yKey]) || yKey, data: values, backgroundColor: bgColors, borderColor: bdColors, borderWidth: 1, borderRadius: 4, borderSkipped: false },
],
},
options: {
scales: {
x: { stacked: true, grid: { display: false }, ticks: {} },
y: { stacked: true, grid: {}, ticks: { callback: v => fmt(v) } },
},
plugins: { legend: { display: false } },
},
});
return { html: chartCard(data, makeCanvas(id), id), config, canvasId: id };
}
// ═══════════════════════════════════════════════════════════════════
// TIER 3 — Custom HTML/CSS (5 types)
// ═══════════════════════════════════════════════════════════════════
function renderNumberCard(data) {
const keys = Object.keys(data.data[0] || {});
const valueKey = data.y_keys ? data.y_keys[0] : keys[keys.length - 1];
const value = data.data[0] ? data.data[0][valueKey] : 0;
const label = (data.y_labels && data.y_labels[valueKey]) || valueKey;
const trendHtml = data.trend ? `
<div class="nc-trend ${data.trend.direction === 'up' ? 'up' : 'down'}">
${data.trend.direction === 'up' ? '▲' : '▼'} ${data.trend.value || data.trend.percentage || 0}%
${data.trend.label ? `<span>${escH(data.trend.label)}</span>` : ''}
</div>` : '';
const inner = `
<div class="number-card">
<div class="nc-value">${fmt(value)}</div>
<div class="nc-label">${escH(label)}</div>
${trendHtml}
</div>`;
return { html: chartCard(data, inner), config: null, canvasId: null };
}
function renderGauge(data) {
const id = nextId();
const keys = Object.keys(data.data[0] || {});
const valueKey = data.y_keys ? data.y_keys[0] : keys[keys.length - 1];
const value = data.data[0] ? data.data[0][valueKey] : 0;
const max = data.gauge_max || 100;
const pct = Math.min(100, Math.round((value / max) * 100));
// Use doughnut as gauge
const color = pct >= 75 ? '#2E7D32' : pct >= 50 ? '#B8860B' : '#C75B39';
const config = applyWarmTheme({
type: 'doughnut',
data: {
labels: ['Value', 'Remaining'],
datasets: [{
data: [value, Math.max(0, max - value)],
backgroundColor: [color + 'CC', '#E8DED4' + '40'],
borderWidth: 0,
circumference: 270,
rotation: 225,
}],
},
options: {
cutout: '75%',
plugins: { legend: { display: false }, tooltip: { enabled: false } },
},
});
const centerPlugin = {
id: 'gaugeCenter',
afterDraw(chart) {
const { ctx, width, height } = chart;
ctx.save();
ctx.font = `bold 28px ${CHART_DEFAULTS.font.family}`;
ctx.fillStyle = color;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(fmt(value), width / 2, height / 2 + 10);
ctx.font = `11px ${CHART_DEFAULTS.font.family}`;
ctx.fillStyle = CHART_DEFAULTS.mutedColor;
ctx.fillText(`${pct}% of ${fmt(max)}`, width / 2, height / 2 + 32);
ctx.restore();
}
};
config.plugins = [centerPlugin];
return { html: chartCard(data, makeCanvas(id), id), config, canvasId: id };
}
function renderProgress(data) {
const rows = data.data || [];
const xKey = data.x_key || Object.keys(rows[0] || {})[0];
const yKey = data.y_keys ? data.y_keys[0] : Object.keys(rows[0] || {}).find(k => k !== xKey);
const max = data.gauge_max || Math.max(...rows.map(r => r[yKey] || 0));
const colors = getColors(rows.length, 'warm');
let bars = '';
rows.forEach((r, i) => {
const val = r[yKey] || 0;
const pct = Math.round((val / max) * 100);
bars += `
<div class="progress-row">
<div class="progress-label">${escH(String(r[xKey]))}</div>
<div class="progress-bar-wrap">
<div class="progress-bar-fill" style="width:${pct}%;background:${colors[i]}"></div>
</div>
<div class="progress-val">${fmt(val)}</div>
</div>`;
});
return { html: chartCard(data, `<div class="progress-list">${bars}</div>`), config: null, canvasId: null };
}
function renderFunnel(data) {
const rows = data.data || [];
const xKey = data.x_key || Object.keys(rows[0] || {})[0];
const yKey = data.y_keys ? data.y_keys[0] : Object.keys(rows[0] || {}).find(k => k !== xKey);
const maxVal = Math.max(...rows.map(r => r[yKey] || 0));
const colors = getColors(rows.length, 'warm');
let steps = '';
rows.forEach((r, i) => {
const val = r[yKey] || 0;
const pct = Math.round((val / maxVal) * 100);
const convRate = i > 0 ? Math.round((val / (rows[i - 1][yKey] || 1)) * 100) : 100;
steps += `
<div class="funnel-step">
<div class="funnel-bar-wrap">
<div class="funnel-bar" style="width:${pct}%;background:${colors[i]}">
<span class="funnel-bar-text">${escH(String(r[xKey]))}${fmt(val)}</span>
</div>
</div>
${i > 0 ? `<div class="funnel-conv">↓ ${convRate}%</div>` : ''}
</div>`;
});
return { html: chartCard(data, `<div class="funnel-list">${steps}</div>`), config: null, canvasId: null };
}
function renderTable(data) {
const rows = data.data || [];
if (rows.length === 0) return { html: chartCard(data, '<div style="padding:16px;color:var(--muted)">No data</div>'), config: null, canvasId: null };
const cols = data.columns || Object.keys(rows[0]);
let tbl = '<div class="data-table-wrap"><table class="data-table"><thead><tr>';
cols.forEach(c => { tbl += `<th>${escH(c)}</th>`; });
tbl += '</tr></thead><tbody>';
rows.slice(0, 100).forEach(r => {
tbl += '<tr>';
cols.forEach(c => {
const v = r[c];
tbl += `<td>${v === null || v === undefined ? '<span style="color:#ccc">NULL</span>' : escH(String(v))}</td>`;
});
tbl += '</tr>';
});
tbl += '</tbody></table></div>';
if (rows.length > 100) tbl += `<div style="font-size:9px;color:var(--muted);text-align:center;margin-top:6px">Showing 100/${rows.length} rows</div>`;
return { html: chartCard(data, tbl), config: null, canvasId: null };
}
// ═══════════════════════════════════════════════════════════════════
// TIER 4 — Chart.js Plugins (5 types, graceful fallback)
// ═══════════════════════════════════════════════════════════════════
function renderTreemap(data) {
const id = nextId();
const xKey = data.x_key || Object.keys(data.data[0])[0];
const yKey = data.y_keys ? data.y_keys[0] : Object.keys(data.data[0]).find(k => k !== xKey);
const colors = getColors(data.data.length, 'pastel');
// Check if treemap plugin is available
if (typeof Chart !== 'undefined' && Chart.controllers && Chart.controllers.treemap) {
const config = {
type: 'treemap',
data: {
datasets: [{
tree: data.data.map(r => r[yKey]),
labels: { display: true, formatter: (ctx) => data.data[ctx.dataIndex] ? data.data[ctx.dataIndex][xKey] : '' },
backgroundColor: colors.map(c => c + 'CC'),
borderColor: '#fff',
borderWidth: 2,
}],
},
options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } } },
};
return { html: chartCard(data, makeCanvas(id), id), config, canvasId: id };
}
// Fallback: render as horizontal bar
data.chart_type = 'horizontalBar';
return renderHorizontalBar(data);
}
function renderHeatmap(data) {
// Fallback: render as table with color-coded cells
const rows = data.data || [];
if (rows.length === 0) return renderTable(data);
const cols = data.columns || Object.keys(rows[0]);
const numCols = cols.filter(c => {
return rows.some(r => typeof r[c] === 'number');
});
// Find min/max for color scaling
let minVal = Infinity, maxVal = -Infinity;
rows.forEach(r => numCols.forEach(c => {
if (typeof r[c] === 'number') {
minVal = Math.min(minVal, r[c]);
maxVal = Math.max(maxVal, r[c]);
}
}));
function heatColor(val) {
if (typeof val !== 'number') return '';
const ratio = maxVal === minVal ? 0.5 : (val - minVal) / (maxVal - minVal);
// Warm gradient: light cream → deep orange
const r = Math.round(255 - ratio * 56);
const g = Math.round(248 - ratio * 157);
const b = Math.round(240 - ratio * 183);
return `background:rgb(${r},${g},${b})`;
}
let tbl = '<div class="data-table-wrap"><table class="data-table"><thead><tr>';
cols.forEach(c => { tbl += `<th>${escH(c)}</th>`; });
tbl += '</tr></thead><tbody>';
rows.slice(0, 100).forEach(r => {
tbl += '<tr>';
cols.forEach(c => {
const v = r[c];
const style = numCols.includes(c) ? heatColor(v) : '';
tbl += `<td style="${style}">${v === null ? '<span style="color:#ccc">NULL</span>' : escH(String(v))}</td>`;
});
tbl += '</tr>';
});
tbl += '</tbody></table></div>';
return { html: chartCard(data, tbl), config: null, canvasId: null };
}
function renderBoxplot(data) {
// Fallback: render as bar chart showing min/avg/max
const id = nextId();
const xKey = data.x_key || Object.keys(data.data[0])[0];
const numKeys = Object.keys(data.data[0]).filter(k => k !== xKey && typeof data.data[0][k] === 'number');
if (typeof Chart !== 'undefined' && Chart.controllers && Chart.controllers.boxplot) {
// Plugin available — use it
const config = {
type: 'boxplot',
data: {
labels: data.data.map(r => r[xKey]),
datasets: numKeys.map((k, i) => ({
label: k,
data: data.data.map(r => r[k]),
backgroundColor: getColors(1, 'warm')[0] + '40',
borderColor: getColors(1, 'warm')[0],
borderWidth: 1,
})),
},
options: { responsive: true, maintainAspectRatio: false },
};
return { html: chartCard(data, makeCanvas(id), id), config, canvasId: id };
}
// Fallback: bar
return renderBar(data);
}
function renderSankey(data) {
// Fallback: render as funnel or table
if (typeof Chart !== 'undefined' && Chart.controllers && Chart.controllers.sankey) {
const id = nextId();
const config = {
type: 'sankey',
data: {
datasets: [{
data: data.data,
colorFrom: '#C75B39',
colorTo: '#E8734F',
colorMode: 'gradient',
}],
},
options: { responsive: true, maintainAspectRatio: false },
};
return { html: chartCard(data, makeCanvas(id), id), config, canvasId: id };
}
return renderTable(data);
}
function renderPivotTable(data) {
// Enhanced table with grouped headers
const rows = data.data || [];
if (rows.length === 0) return renderTable(data);
const cols = data.columns || Object.keys(rows[0]);
const groupKey = data.x_key || cols[0];
// Group by first column
const groups = {};
rows.forEach(r => {
const g = r[groupKey] || 'Other';
if (!groups[g]) groups[g] = [];
groups[g].push(r);
});
const otherCols = cols.filter(c => c !== groupKey);
let tbl = '<div class="data-table-wrap"><table class="data-table"><thead><tr>';
tbl += `<th>${escH(groupKey)}</th>`;
otherCols.forEach(c => { tbl += `<th>${escH(c)}</th>`; });
tbl += '</tr></thead><tbody>';
Object.entries(groups).forEach(([group, gRows]) => {
gRows.forEach((r, i) => {
tbl += '<tr>';
if (i === 0) {
tbl += `<td rowspan="${gRows.length}" style="font-weight:600;background:var(--card);vertical-align:top">${escH(group)}</td>`;
}
otherCols.forEach(c => {
const v = r[c];
tbl += `<td>${v === null ? '<span style="color:#ccc">NULL</span>' : escH(String(v))}</td>`;
});
tbl += '</tr>';
});
});
tbl += '</tbody></table></div>';
return { html: chartCard(data, tbl), config: null, canvasId: null };
}
// ═══════════════════════════════════════════════════════════════════
// MAIN DISPATCHER
// ═══════════════════════════════════════════════════════════════════
const CHART_RENDERERS = {
// Tier 1
bar: renderBar,
horizontalBar: renderHorizontalBar,
multiBar: renderMultiBar,
stackedBar: renderStackedBar,
line: renderLine,
area: renderArea,
stackedArea: renderStackedArea,
pie: renderPie,
doughnut: renderDoughnut,
scatter: renderScatter,
// Tier 2
combo: renderCombo,
bubble: renderBubble,
radar: renderRadar,
polarArea: renderPolarArea,
waterfall: renderWaterfall,
// Tier 3
numberCard: renderNumberCard,
number_card: renderNumberCard,
gauge: renderGauge,
progress: renderProgress,
funnel: renderFunnel,
table: renderTable,
// Tier 4
treemap: renderTreemap,
heatmap: renderHeatmap,
boxplot: renderBoxplot,
sankey: renderSankey,
pivotTable: renderPivotTable,
pivot_table: renderPivotTable,
};
/**
* Main entry: render a chart from structured AI data
* @param {Object} data - { chart_type, title, description, x_key, y_keys, y_labels, trend, footer, data: [...] }
* @returns {{ html: string, config: object|null, canvasId: string|null }}
*/
function renderChart(data) {
const type = data.chart_type || 'bar';
const renderer = CHART_RENDERERS[type];
if (!renderer) {
console.warn(`Unknown chart type: ${type}, falling back to bar`);
return renderBar(data);
}
return renderer(data);
}
/* ═══════════════════════════════════════════════
Dashboard CSS — Canifa AI System
Uses CSS variables from lab.css
═══════════════════════════════════════════════ */
body{display:flex;flex-direction:row}
/* ═══ SIDEBAR ═══ */
.sidebar{width:240px;min-height:100vh;background:var(--s);border-right:1px solid var(--b);display:flex;flex-direction:column;position:fixed;top:0;left:0;z-index:100}
.sidebar-brand{padding:18px 20px;border-bottom:1px solid var(--b);display:flex;align-items:center;gap:12px}
.brand-icon{width:40px;height:40px;background:var(--t);border-radius:10px;display:flex;align-items:center;justify-content:center;font-size:1.3em;flex-shrink:0}
.brand-text h2{font-size:1em;font-weight:700;color:var(--t)}
.brand-text span{font-size:.7em;color:var(--m)}
.nav-group{padding:16px 12px 8px}
.nav-group-label{font-size:.65em;font-weight:700;text-transform:uppercase;letter-spacing:1.5px;color:var(--m);padding:0 12px;margin-bottom:8px}
.nav-item{display:flex;align-items:center;gap:12px;padding:10px 16px;border-radius:var(--rs);color:var(--m);text-decoration:none;font-size:.88em;font-weight:500;transition:all .2s;margin-bottom:2px;cursor:pointer}
.nav-item:hover{background:var(--bg);color:var(--t)}
.nav-item.active{background:var(--gold-l);color:var(--gold);font-weight:700;border-left:3px solid var(--gold)}
.nav-icon{font-size:1.1em;width:22px;text-align:center;flex-shrink:0}
.nav-badge{margin-left:auto;font-size:.7em;padding:2px 8px;border-radius:999px;font-weight:600}
.badge-live{background:var(--green-l);color:var(--green)}
.badge-new{background:var(--gold-l);color:var(--gold)}
.badge-beta{background:var(--orange-l);color:var(--orange)}
.badge-count{background:var(--blue-l);color:var(--blue)}
.sidebar-footer{margin-top:auto;padding:16px;border-top:1px solid var(--b)}
.version-info{display:flex;align-items:center;gap:10px;padding:10px 12px;background:var(--bg);border-radius:var(--rs);border:1px solid var(--b)}
.version-dot{width:8px;height:8px;border-radius:50%;background:#16A34A;animation:dotpulse 2s infinite}
@keyframes dotpulse{0%,100%{box-shadow:0 0 0 0 rgba(22,163,74,.4)}50%{box-shadow:0 0 0 6px rgba(22,163,74,0)}}
.version-text{font-size:.78em;color:var(--m)}
.version-text strong{color:var(--t);font-weight:600}
/* ═══ MAIN AREA ═══ */
.main{margin-left:240px;flex:1;min-height:100vh;display:flex;flex-direction:column}
.topbar{padding:14px 24px;display:flex;justify-content:space-between;align-items:center;border-bottom:1px solid var(--b);background:var(--s);position:sticky;top:0;z-index:50}
.topbar-left h1{font-size:1.3em;font-weight:700;color:var(--t)}
.topbar-left p{font-size:.8em;color:var(--m);margin-top:2px}
.topbar-right{display:flex;align-items:center;gap:10px}
.refresh-btn{display:flex;align-items:center;gap:6px;padding:8px 16px;background:var(--s);border:1px solid var(--b);border-radius:var(--rs);color:var(--m);font-size:.82em;font-weight:500;cursor:pointer;transition:all .2s;font-family:inherit}
.refresh-btn:hover{background:var(--bg);color:var(--t)}
.refresh-btn.spinning .r-icon{animation:spin .6s linear infinite}
@keyframes spin{from{transform:rotate(0)}to{transform:rotate(360deg)}}
.status-pill{display:flex;align-items:center;gap:6px;padding:6px 14px;border-radius:999px;font-size:.78em;font-weight:600;background:var(--green-l);color:var(--green);border:1px solid #A7F3D0}
.status-pill .dot{width:6px;height:6px;border-radius:50%;background:#16A34A;animation:dotpulse 2s infinite}
.content{flex:1;padding:24px;overflow-y:auto}
/* ═══ TABS ═══ */
.tab-bar{display:flex;gap:4px;margin-bottom:24px;background:var(--s);border:1px solid var(--b);padding:4px;border-radius:10px;width:fit-content}
.tab-btn{padding:8px 20px;border-radius:var(--rs);border:none;background:transparent;color:var(--m);font-family:inherit;font-size:.85em;font-weight:500;cursor:pointer;transition:all .2s;display:flex;align-items:center;gap:6px}
.tab-btn:hover{color:var(--t);background:var(--bg)}
.tab-btn.active{background:var(--gold-l);color:var(--gold);font-weight:700}
.tab-content{display:none}
.tab-content.active{display:block}
/* ═══ STATS ═══ */
.stats-row{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:14px;margin-bottom:20px}
.stat-card{background:var(--s);border:1px solid var(--b);border-radius:var(--r);padding:18px;transition:all .25s}
.stat-card:hover{border-color:var(--gold);transform:translateY(-2px);box-shadow:0 4px 12px rgba(0,0,0,.06)}
.stat-label{font-size:.72em;font-weight:600;text-transform:uppercase;letter-spacing:1px;color:var(--m);margin-bottom:6px}
.stat-value{font-family:'Fraunces',serif;font-size:1.5em;font-weight:700;color:var(--t)}
.stat-value.purple{color:var(--diamond)}
.stat-value.blue{color:var(--blue)}
.stat-value.green{color:var(--green)}
.stat-value.amber{color:var(--gold)}
.stat-value.pink{color:#DB2777}
.stat-sub{font-size:.78em;color:var(--m);margin-top:4px}
/* ═══ CARDS ═══ */
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(380px,1fr));gap:14px;margin-bottom:20px}
.grid-full{grid-template-columns:1fr}
.card{background:var(--s);border:1px solid var(--b);border-radius:var(--r);overflow:hidden;transition:all .25s}
.card:hover{border-color:var(--gold)}
.card-head{display:flex;justify-content:space-between;align-items:center;padding:14px 18px;border-bottom:1px solid var(--b)}
.card-head h3{font-size:.9em;font-weight:700;color:var(--t);display:flex;align-items:center;gap:8px}
.card-head .tag{font-size:.7em;padding:2px 10px;border-radius:999px;font-weight:700}
.tag-green{background:var(--green-l);color:var(--green)}
.tag-amber{background:var(--gold-l);color:var(--gold)}
.tag-red{background:var(--red-l);color:var(--red)}
.card-body{padding:16px 18px}
/* ═══ SERVICES ═══ */
.svc-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:10px}
.svc{padding:12px;background:var(--bg);border:1px solid var(--b);border-radius:var(--rs);transition:all .2s}
.svc:hover{border-color:var(--gold)}
.svc.ok{border-left:3px solid var(--green)}
.svc.fail{border-left:3px solid var(--red)}
.svc-name{font-size:.78em;font-weight:600;color:var(--m);margin-bottom:4px}
.svc-status{font-size:.82em;font-weight:500}
.svc-status.ok{color:var(--green)}
.svc-status.fail{color:var(--red)}
.hbar{margin-top:14px}
.hbar-bg{width:100%;height:6px;background:var(--bg);border-radius:999px;overflow:hidden}
.hbar-fill{height:100%;border-radius:999px;background:linear-gradient(90deg,#16A34A,#22C55E);transition:width 1s}
.hbar-label{display:flex;justify-content:space-between;font-size:.72em;color:var(--m);margin-top:6px}
/* ═══ KEY/CONFIG ═══ */
.key-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:10px}
.key-item{padding:12px;background:var(--bg);border:1px solid var(--b);border-radius:var(--rs)}
.key-label{font-size:.68em;font-weight:600;text-transform:uppercase;letter-spacing:.5px;color:var(--m);margin-bottom:4px}
.key-val{font-size:.85em;font-family:'JetBrains Mono',monospace}
.key-val.set{color:var(--green)}
.key-val.unset{color:var(--red)}
.cfg-table{width:100%;border-collapse:collapse}
.cfg-table tr{border-bottom:1px solid var(--b)}
.cfg-table tr:hover{background:var(--bg)}
.cfg-table tr:last-child{border-bottom:none}
.cfg-table td{padding:10px 8px;font-size:.85em}
.cfg-table td:first-child{color:var(--m);font-weight:500;width:180px}
.cfg-table td:last-child{color:var(--t);font-family:'JetBrains Mono',monospace;font-size:.82em}
/* ═══ FEEDBACK ═══ */
.fb-row{display:grid;grid-template-columns:repeat(4,1fr);gap:12px}
.fb-card{background:var(--bg);border:1px solid var(--b);border-radius:10px;padding:16px;text-align:center;transition:all .2s}
.fb-card:hover{border-color:var(--gold);transform:translateY(-2px)}
.fb-num{font-family:'Fraunces',serif;font-size:1.8em;font-weight:700;margin-bottom:2px}
.fb-lbl{font-size:.72em;color:var(--m);text-transform:uppercase;letter-spacing:1px;font-weight:600}
.fb-total .fb-num{color:var(--blue)}
.fb-like .fb-num{color:var(--green)}
.fb-dislike .fb-num{color:var(--red)}
.fb-pending .fb-num{color:var(--gold)}
/* ═══ ROUTES ═══ */
.routes-list{max-height:350px;overflow-y:auto}
.route{display:flex;align-items:center;gap:10px;padding:8px 10px;border-bottom:1px solid var(--b);transition:background .15s}
.route:hover{background:var(--bg)}
.route:last-child{border-bottom:none}
.method{font-size:.65em;font-weight:700;padding:2px 7px;border-radius:4px;min-width:44px;text-align:center}
.m-GET{background:var(--green-l);color:var(--green)}
.m-POST{background:var(--blue-l);color:var(--blue)}
.m-PUT{background:var(--gold-l);color:var(--gold)}
.m-DELETE{background:var(--red-l);color:var(--red)}
.m-HEAD{background:var(--diamond-l);color:var(--diamond)}
.m-PATCH{background:#FDF2F8;color:#DB2777}
.route-path{font-family:'JetBrains Mono',monospace;font-size:.82em;color:var(--t)}
/* ═══ NOTES ═══ */
.notes-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:20px}
.notes-header h2{font-size:1.1em;font-weight:700;color:var(--t);display:flex;align-items:center;gap:8px}
.notes-actions{display:flex;gap:8px}
.btn{padding:8px 16px;border-radius:var(--rs);border:1px solid var(--b);background:var(--s);color:var(--m);font-family:inherit;font-size:.82em;font-weight:500;cursor:pointer;transition:all .2s;display:flex;align-items:center;gap:6px}
.btn:hover{background:var(--bg);color:var(--t)}
.btn-primary{background:var(--t);border-color:transparent;color:#fff}
.btn-primary:hover{opacity:.9;transform:translateY(-1px)}
.btn-danger{border-color:#FCA5A5;color:var(--red)}
.btn-danger:hover{background:var(--red-l)}
.btn-sm{padding:5px 10px;font-size:.75em}
.notes-filter{display:flex;gap:6px;margin-bottom:16px;flex-wrap:wrap}
.filter-chip{padding:5px 12px;border-radius:999px;border:1px solid var(--b);background:transparent;color:var(--m);font-family:inherit;font-size:.75em;font-weight:600;cursor:pointer;transition:all .2s}
.filter-chip:hover{border-color:var(--gold);color:var(--t)}
.filter-chip.active{background:var(--gold-l);color:var(--gold);border-color:var(--gold-b)}
.notes-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:14px}
.note-card{background:var(--s);border:1px solid var(--b);border-radius:var(--r);overflow:hidden;transition:all .25s;position:relative}
.note-card:hover{border-color:var(--gold);transform:translateY(-2px);box-shadow:0 4px 12px rgba(0,0,0,.06)}
.note-card.pinned{border-color:var(--gold-b)}
.note-card.pinned::after{content:'📌';position:absolute;top:12px;right:12px;font-size:.9em}
.note-color-bar{height:3px;width:100%}
.note-body{padding:16px}
.note-cat{display:inline-flex;align-items:center;gap:4px;font-size:.65em;font-weight:700;text-transform:uppercase;letter-spacing:1px;padding:2px 8px;border-radius:4px;margin-bottom:10px}
.cat-note{background:var(--blue-l);color:var(--blue)}
.cat-doc{background:var(--diamond-l);color:var(--diamond)}
.cat-todo{background:var(--gold-l);color:var(--gold)}
.cat-announcement{background:var(--red-l);color:var(--red)}
.note-title{font-size:.95em;font-weight:700;color:var(--t);margin-bottom:8px;line-height:1.4}
.note-content{font-size:.82em;color:var(--m);line-height:1.6;margin-bottom:12px;max-height:120px;overflow:hidden;white-space:pre-wrap;word-break:break-word}
.note-footer{display:flex;justify-content:space-between;align-items:center;padding-top:10px;border-top:1px solid var(--b)}
.note-meta{font-size:.7em;color:var(--f)}
.note-actions{display:flex;gap:4px}
.note-act-btn{width:28px;height:28px;border-radius:6px;border:1px solid var(--b);background:transparent;color:var(--f);font-size:.82em;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:all .15s}
.note-act-btn:hover{background:var(--bg);color:var(--t)}
.note-act-btn.pin-active{color:var(--gold);border-color:var(--gold-b)}
/* ═══ LINKS ═══ */
.link-card{background:var(--s);border:1px solid var(--b);border-radius:var(--r);padding:16px;transition:all .25s;position:relative;display:flex;gap:14px;align-items:flex-start}
.link-card:hover{border-color:var(--gold);transform:translateY(-2px);box-shadow:0 4px 12px rgba(0,0,0,.06)}
.link-card.pinned{border-color:var(--gold-b)}
.link-icon{width:42px;height:42px;border-radius:10px;background:var(--bg);display:flex;align-items:center;justify-content:center;font-size:1.3em;flex-shrink:0;border:1px solid var(--b)}
.link-info{flex:1;min-width:0}
.link-cat-tag{display:inline-flex;font-size:.6em;font-weight:700;text-transform:uppercase;letter-spacing:1px;padding:1px 6px;border-radius:3px;margin-bottom:6px}
.lcat-tool{background:var(--green-l);color:var(--green)}
.lcat-doc{background:var(--diamond-l);color:var(--diamond)}
.lcat-design{background:#FDF2F8;color:#DB2777}
.lcat-api{background:var(--blue-l);color:var(--blue)}
.lcat-repo{background:var(--gold-l);color:var(--gold)}
.lcat-other{background:var(--bg);color:var(--m)}
.link-title{font-size:.92em;font-weight:700;color:var(--t);margin-bottom:4px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.link-url{font-size:.75em;color:var(--blue);font-family:'JetBrains Mono',monospace;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-bottom:4px;cursor:pointer}
.link-url:hover{text-decoration:underline}
.link-desc{font-size:.78em;color:var(--m);line-height:1.5;max-height:40px;overflow:hidden}
.link-actions-row{display:flex;gap:4px;flex-shrink:0;align-self:center}
.link-act{width:28px;height:28px;border-radius:6px;border:1px solid var(--b);background:transparent;color:var(--f);font-size:.78em;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:all .15s}
.link-act:hover{background:var(--bg);color:var(--t)}
/* ═══ CHANGELOG ═══ */
.changelog-form{display:flex;gap:10px;margin-bottom:20px;background:var(--s);border:1px solid var(--b);border-radius:var(--r);padding:14px}
.changelog-form input{flex:1}
.changelog-form .author-input{max-width:140px}
.changelog-entry{display:flex;gap:14px;padding:14px 0;border-bottom:1px solid var(--b);position:relative}
.changelog-entry:last-child{border-bottom:none}
.cl-dot{width:10px;height:10px;border-radius:50%;background:var(--gold);margin-top:5px;flex-shrink:0;box-shadow:0 0 6px rgba(180,83,9,.3)}
.cl-body{flex:1;min-width:0}
.cl-meta{display:flex;gap:10px;align-items:center;margin-bottom:4px}
.cl-author{font-weight:700;color:var(--t);font-size:.85em}
.cl-time{font-size:.75em;color:var(--f)}
.cl-content{font-size:.88em;color:var(--m);line-height:1.6}
.cl-delete{position:absolute;right:0;top:14px;width:26px;height:26px;border-radius:6px;border:1px solid transparent;background:transparent;color:var(--f);font-size:.7em;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:all .15s;opacity:0}
.changelog-entry:hover .cl-delete{opacity:1}
.cl-delete:hover{background:var(--red-l);color:var(--red)}
.cl-line{position:absolute;left:4px;top:24px;bottom:0;width:2px;background:var(--b)}
/* ═══ ROLE MODAL ═══ */
.role-modal-overlay{position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.4);z-index:300;display:none;align-items:center;justify-content:center;backdrop-filter:blur(4px)}
.role-modal-overlay.show{display:flex}
.role-modal{background:var(--s);border:1px solid var(--b);border-radius:16px;width:450px;max-width:90vw;box-shadow:0 20px 60px rgba(0,0,0,.15);animation:modalIn .2s ease-out}
.role-user-row{display:flex;justify-content:space-between;align-items:center;padding:10px 16px;border-bottom:1px solid var(--b)}
.role-user-row:last-child{border-bottom:none}
.role-user-name{color:var(--t);font-weight:600;font-size:.9em}
.role-badge{padding:2px 10px;border-radius:10px;font-size:.75em;font-weight:700}
.role-badge.admin{background:var(--gold-l);color:var(--gold)}
.role-badge.user{background:var(--bg);color:var(--m)}
.role-remove-btn{background:none;border:none;color:var(--f);cursor:pointer;font-size:.9em}
.role-remove-btn:hover{color:var(--red)}
.role-add-form{display:flex;gap:8px;padding:12px 16px;border-top:1px solid var(--b)}
.role-add-form input,.role-add-form select{padding:8px 12px;background:var(--bg);border:1px solid var(--b);border-radius:var(--rs);color:var(--t);font-family:inherit;font-size:.85em}
.role-add-form input{flex:1}
.role-add-form select{width:100px}
.role-add-form button{padding:8px 16px;background:var(--t);border:none;border-radius:var(--rs);color:#fff;font-weight:700;cursor:pointer;font-size:.85em}
.settings-btn{background:none;border:1px solid var(--b);border-radius:var(--rs);color:var(--m);padding:8px 14px;cursor:pointer;font-size:.85em;transition:all .2s;font-family:inherit}
.settings-btn:hover{background:var(--bg);color:var(--t)}
/* ═══ MODALS ═══ */
.modal-overlay{position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.4);z-index:200;display:none;align-items:center;justify-content:center;backdrop-filter:blur(4px)}
.modal-overlay.show{display:flex}
.modal{background:var(--s);border:1px solid var(--b);border-radius:16px;width:550px;max-width:90vw;box-shadow:0 20px 60px rgba(0,0,0,.15);animation:modalIn .2s ease-out}
@keyframes modalIn{from{transform:scale(.95) translateY(10px);opacity:0}to{transform:scale(1) translateY(0);opacity:1}}
.modal-head{padding:20px 24px;border-bottom:1px solid var(--b);display:flex;justify-content:space-between;align-items:center}
.modal-head h3{font-size:1em;font-weight:700;color:var(--t)}
.modal-close{background:none;border:none;color:var(--f);font-size:1.3em;cursor:pointer;transition:color .15s;padding:4px}
.modal-close:hover{color:var(--red)}
.modal-body{padding:24px}
.form-group{margin-bottom:16px}
.form-label{display:block;font-size:.78em;font-weight:600;color:var(--m);margin-bottom:6px}
.form-input,.form-textarea,.form-select{width:100%;padding:10px 14px;background:var(--bg);border:1px solid var(--b);border-radius:var(--rs);color:var(--t);font-family:inherit;font-size:.88em;transition:all .2s}
.form-input:focus,.form-textarea:focus,.form-select:focus{outline:none;border-color:var(--gold);box-shadow:0 0 0 3px rgba(180,83,9,.1)}
.form-textarea{min-height:120px;resize:vertical;line-height:1.6}
.form-select{cursor:pointer}
.form-select option{background:var(--s)}
.form-row{display:grid;grid-template-columns:1fr 1fr;gap:12px}
.color-dots{display:flex;gap:8px;margin-top:6px}
.color-dot{width:24px;height:24px;border-radius:50%;border:2px solid transparent;cursor:pointer;transition:all .15s}
.color-dot:hover{transform:scale(1.2)}
.color-dot.active{border-color:var(--t);box-shadow:0 0 0 2px rgba(0,0,0,.1)}
.modal-foot{padding:16px 24px;border-top:1px solid var(--b);display:flex;justify-content:flex-end;gap:10px}
/* ═══ EMPTY/LOADING ═══ */
.empty-state{text-align:center;padding:60px 20px;color:var(--m)}
.empty-state .empty-icon{font-size:3em;margin-bottom:12px;opacity:.5}
.empty-state p{font-size:.9em;margin-bottom:16px}
.loading{display:flex;flex-direction:column;align-items:center;justify-content:center;padding:80px;color:var(--m)}
.loading .spinner{width:36px;height:36px;border:3px solid var(--b);border-top-color:var(--gold);border-radius:50%;animation:spin .7s linear infinite;margin-bottom:16px}
/* ═══ SCROLLBAR ═══ */
::-webkit-scrollbar{width:5px}
::-webkit-scrollbar-track{background:transparent}
::-webkit-scrollbar-thumb{background:var(--b);border-radius:3px}
::-webkit-scrollbar-thumb:hover{background:var(--m)}
/* ═══ RESPONSIVE ═══ */
@media(max-width:1024px){
.sidebar{width:64px}
.sidebar .brand-text,.sidebar .nav-group-label,.sidebar .nav-item span,.sidebar .nav-badge,.sidebar .version-text,.sidebar .version-dot{display:none}
.sidebar .nav-item{justify-content:center;padding:12px}
.sidebar-brand{justify-content:center;padding:16px}
.main{margin-left:64px}
.grid{grid-template-columns:1fr}
.fb-row{grid-template-columns:repeat(2,1fr)}
}
/* ═══ IFRAME MODE: hide sidebar when embedded ═══ */
html.in-iframe .sidebar{display:none!important}
html.in-iframe .main{margin-left:0!important}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dashboard - Canifa AI System</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&display=swap" rel="stylesheet">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Inter', sans-serif;
background: #0b0e14;
color: #c9d1d9;
min-height: 100vh;
display: flex;
}
/* ═══ SIDEBAR ═══ */
.sidebar {
width: 260px;
min-height: 100vh;
background: #0d1117;
border-right: 1px solid #1b2030;
display: flex;
flex-direction: column;
position: fixed;
top: 0; left: 0;
z-index: 100;
}
.sidebar-brand {
padding: 24px 20px;
border-bottom: 1px solid #1b2030;
display: flex;
align-items: center;
gap: 12px;
}
.brand-icon {
width: 40px; height: 40px;
background: linear-gradient(135deg, #667eea, #764ba2);
border-radius: 10px;
display: flex; align-items: center; justify-content: center;
font-size: 1.3em; flex-shrink: 0;
}
.brand-text h2 { font-size: 1em; font-weight: 700; color: #e6edf3; }
.brand-text span { font-size: 0.7em; color: #484f58; font-weight: 500; }
.nav-group { padding: 16px 12px 8px; }
.nav-group-label {
font-size: 0.65em; font-weight: 700; text-transform: uppercase;
letter-spacing: 1.5px; color: #484f58; padding: 0 12px; margin-bottom: 8px;
}
.nav-item {
display: flex; align-items: center; gap: 12px;
padding: 10px 16px; border-radius: 8px; color: #8b949e;
text-decoration: none; font-size: 0.88em; font-weight: 500;
transition: all 0.2s; margin-bottom: 2px; cursor: pointer;
position: relative;
}
.nav-item:hover { background: #161b22; color: #e6edf3; }
.nav-item.active {
background: linear-gradient(135deg, rgba(102,126,234,0.15), rgba(118,75,162,0.1));
color: #a78bfa; font-weight: 600;
}
.nav-item.active::before {
content: ''; position: absolute; left: 0; top: 50%;
transform: translateY(-50%); width: 3px; height: 20px;
background: linear-gradient(180deg, #667eea, #764ba2);
border-radius: 0 3px 3px 0;
}
.nav-icon { font-size: 1.1em; width: 22px; text-align: center; flex-shrink: 0; }
.nav-badge {
margin-left: auto; font-size: 0.7em; padding: 2px 8px;
border-radius: 999px; font-weight: 600;
}
.badge-live { background: rgba(76,175,80,0.15); color: #4caf50; border: 1px solid rgba(76,175,80,0.25); }
.badge-new { background: rgba(102,126,234,0.15); color: #667eea; border: 1px solid rgba(102,126,234,0.25); }
.badge-beta { background: rgba(255,152,0,0.15); color: #ff9800; border: 1px solid rgba(255,152,0,0.25); }
.badge-count { background: rgba(88,166,255,0.15); color: #58a6ff; border: 1px solid rgba(88,166,255,0.2); }
.sidebar-footer {
margin-top: auto; padding: 16px; border-top: 1px solid #1b2030;
}
.version-info {
display: flex; align-items: center; gap: 10px;
padding: 10px 12px; background: #161b22;
border-radius: 8px; border: 1px solid #1b2030;
}
.version-dot {
width: 8px; height: 8px; border-radius: 50%; background: #4caf50;
animation: pulse 2s infinite;
}
@keyframes pulse {
0%,100% { box-shadow: 0 0 0 0 rgba(76,175,80,0.4); }
50% { box-shadow: 0 0 0 6px rgba(76,175,80,0); }
}
.version-text { font-size: 0.78em; color: #8b949e; }
.version-text strong { color: #e6edf3; font-weight: 600; }
/* ═══ MAIN ═══ */
.main {
margin-left: 260px; flex: 1; min-height: 100vh;
display: flex; flex-direction: column;
}
.topbar {
padding: 16px 32px; display: flex; justify-content: space-between;
align-items: center; border-bottom: 1px solid #1b2030;
background: #0d1117; position: sticky; top: 0; z-index: 50;
}
.topbar-left h1 { font-size: 1.3em; font-weight: 700; color: #e6edf3; }
.topbar-left p { font-size: 0.8em; color: #484f58; margin-top: 2px; }
.topbar-right { display: flex; align-items: center; gap: 12px; }
.refresh-btn {
display: flex; align-items: center; gap: 6px;
padding: 8px 16px; background: #161b22;
border: 1px solid #1b2030; border-radius: 8px;
color: #8b949e; font-size: 0.82em; font-weight: 500;
cursor: pointer; transition: all 0.2s; font-family: inherit;
}
.refresh-btn:hover { background: #1b2030; color: #e6edf3; border-color: #30363d; }
.refresh-btn.spinning .r-icon { animation: spin 0.6s linear infinite; }
@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
.status-pill {
display: flex; align-items: center; gap: 6px;
padding: 6px 14px; border-radius: 999px; font-size: 0.78em; font-weight: 600;
background: rgba(76,175,80,0.1); color: #4caf50;
border: 1px solid rgba(76,175,80,0.2);
}
.status-pill .dot {
width: 6px; height: 6px; border-radius: 50%;
background: #4caf50; animation: pulse 2s infinite;
}
.content { flex: 1; padding: 28px 32px; overflow-y: auto; }
/* ═══ TAB SWITCHER ═══ */
.tab-bar {
display: flex; gap: 4px; margin-bottom: 24px;
background: #0d1117; border: 1px solid #1b2030;
padding: 4px; border-radius: 10px; width: fit-content;
}
.tab-btn {
padding: 8px 20px; border-radius: 8px; border: none;
background: transparent; color: #8b949e; font-family: inherit;
font-size: 0.85em; font-weight: 500; cursor: pointer;
transition: all 0.2s; display: flex; align-items: center; gap: 6px;
}
.tab-btn:hover { color: #e6edf3; background: #161b22; }
.tab-btn.active {
background: linear-gradient(135deg, rgba(102,126,234,0.2), rgba(118,75,162,0.15));
color: #a78bfa; font-weight: 600;
}
.tab-content { display: none; }
.tab-content.active { display: block; }
/* ═══ STATS ROW ═══ */
.stats-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 16px; margin-bottom: 24px;
}
.stat-card {
background: #0d1117; border: 1px solid #1b2030;
border-radius: 12px; padding: 20px; transition: all 0.25s;
}
.stat-card:hover { border-color: #30363d; transform: translateY(-2px); box-shadow: 0 8px 25px rgba(0,0,0,0.3); }
.stat-label {
font-size: 0.72em; font-weight: 600; text-transform: uppercase;
letter-spacing: 1px; color: #484f58; margin-bottom: 8px;
}
.stat-value { font-size: 1.6em; font-weight: 800; color: #e6edf3; }
.stat-value.purple { color: #a78bfa; }
.stat-value.blue { color: #58a6ff; }
.stat-value.green { color: #56d364; }
.stat-value.amber { color: #e3b341; }
.stat-value.pink { color: #f778ba; }
.stat-sub { font-size: 0.78em; color: #484f58; margin-top: 4px; }
/* ═══ CARDS ═══ */
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(380px, 1fr));
gap: 20px; margin-bottom: 24px;
}
.grid-full { grid-template-columns: 1fr; }
.card {
background: #0d1117; border: 1px solid #1b2030;
border-radius: 12px; overflow: hidden; transition: all 0.25s;
}
.card:hover { border-color: #30363d; }
.card-head {
display: flex; justify-content: space-between; align-items: center;
padding: 16px 20px; border-bottom: 1px solid #1b2030;
}
.card-head h3 {
font-size: 0.9em; font-weight: 600; color: #e6edf3;
display: flex; align-items: center; gap: 8px;
}
.card-head .tag {
font-size: 0.7em; padding: 2px 10px; border-radius: 999px; font-weight: 600;
}
.tag-green { background: rgba(86,211,100,0.12); color: #56d364; border: 1px solid rgba(86,211,100,0.2); }
.tag-amber { background: rgba(227,179,65,0.12); color: #e3b341; border: 1px solid rgba(227,179,65,0.2); }
.tag-red { background: rgba(248,81,73,0.12); color: #f85149; border: 1px solid rgba(248,81,73,0.2); }
.card-body { padding: 16px 20px; }
/* Service grid */
.svc-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 10px; }
.svc {
padding: 12px; background: #161b22; border: 1px solid #1b2030;
border-radius: 8px; transition: all 0.2s;
}
.svc:hover { border-color: #30363d; }
.svc.ok { border-left: 3px solid #56d364; }
.svc.fail { border-left: 3px solid #f85149; }
.svc-name { font-size: 0.78em; font-weight: 600; color: #8b949e; margin-bottom: 4px; }
.svc-status { font-size: 0.82em; font-weight: 500; }
.svc-status.ok { color: #56d364; }
.svc-status.fail { color: #f85149; }
.hbar { margin-top: 14px; }
.hbar-bg { width: 100%; height: 6px; background: #161b22; border-radius: 999px; overflow: hidden; }
.hbar-fill { height: 100%; border-radius: 999px; background: linear-gradient(90deg, #56d364, #3fb950); transition: width 1s; }
.hbar-label { display: flex; justify-content: space-between; font-size: 0.72em; color: #484f58; margin-top: 6px; }
/* Key grid */
.key-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 10px; }
.key-item { padding: 12px; background: #161b22; border: 1px solid #1b2030; border-radius: 8px; }
.key-label { font-size: 0.68em; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; color: #484f58; margin-bottom: 4px; }
.key-val { font-size: 0.85em; font-family: 'Consolas', monospace; }
.key-val.set { color: #56d364; }
.key-val.unset { color: #f85149; }
/* Config table */
.cfg-table { width: 100%; border-collapse: collapse; }
.cfg-table tr { border-bottom: 1px solid #161b22; transition: background 0.15s; }
.cfg-table tr:hover { background: #161b22; }
.cfg-table tr:last-child { border-bottom: none; }
.cfg-table td { padding: 10px 8px; font-size: 0.85em; }
.cfg-table td:first-child { color: #484f58; font-weight: 500; width: 180px; }
.cfg-table td:last-child { color: #c9d1d9; font-family: 'Consolas', monospace; font-size: 0.82em; }
/* Feedback */
.fb-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; }
.fb-card { background: #161b22; border: 1px solid #1b2030; border-radius: 10px; padding: 16px; text-align: center; transition: all 0.2s; }
.fb-card:hover { border-color: #30363d; transform: translateY(-2px); }
.fb-num { font-size: 1.8em; font-weight: 800; margin-bottom: 2px; }
.fb-lbl { font-size: 0.72em; color: #484f58; text-transform: uppercase; letter-spacing: 1px; font-weight: 600; }
.fb-total .fb-num { color: #58a6ff; }
.fb-like .fb-num { color: #56d364; }
.fb-dislike .fb-num { color: #f85149; }
.fb-pending .fb-num { color: #e3b341; }
/* Routes */
.routes-list { max-height: 350px; overflow-y: auto; }
.route { display: flex; align-items: center; gap: 10px; padding: 8px 10px; border-bottom: 1px solid #161b22; transition: background 0.15s; }
.route:hover { background: #161b22; }
.route:last-child { border-bottom: none; }
.method { font-size: 0.65em; font-weight: 700; padding: 2px 7px; border-radius: 4px; min-width: 44px; text-align: center; letter-spacing: 0.5px; }
.m-GET { background: rgba(86,211,100,0.15); color: #56d364; }
.m-POST { background: rgba(88,166,255,0.15); color: #58a6ff; }
.m-PUT { background: rgba(227,179,65,0.15); color: #e3b341; }
.m-DELETE { background: rgba(248,81,73,0.15); color: #f85149; }
.m-HEAD { background: rgba(188,140,255,0.15); color: #bc8cff; }
.m-PATCH { background: rgba(247,120,186,0.15); color: #f778ba; }
.route-path { font-family: 'Consolas', monospace; font-size: 0.82em; color: #c9d1d9; }
/* ═══ NOTES ═══ */
.notes-header {
display: flex; justify-content: space-between; align-items: center;
margin-bottom: 20px;
}
.notes-header h2 {
font-size: 1.1em; font-weight: 700; color: #e6edf3;
display: flex; align-items: center; gap: 8px;
}
.notes-actions { display: flex; gap: 8px; }
.btn {
padding: 8px 16px; border-radius: 8px; border: 1px solid #1b2030;
background: #161b22; color: #8b949e; font-family: inherit;
font-size: 0.82em; font-weight: 500; cursor: pointer;
transition: all 0.2s; display: flex; align-items: center; gap: 6px;
}
.btn:hover { background: #1b2030; color: #e6edf3; border-color: #30363d; }
.btn-primary {
background: linear-gradient(135deg, #667eea, #764ba2);
border-color: transparent; color: #fff;
}
.btn-primary:hover { opacity: 0.9; transform: translateY(-1px); }
.btn-danger { border-color: rgba(248,81,73,0.3); color: #f85149; }
.btn-danger:hover { background: rgba(248,81,73,0.1); }
.btn-sm { padding: 5px 10px; font-size: 0.75em; }
/* Notes filter */
.notes-filter {
display: flex; gap: 6px; margin-bottom: 16px; flex-wrap: wrap;
}
.filter-chip {
padding: 5px 12px; border-radius: 999px; border: 1px solid #1b2030;
background: transparent; color: #8b949e; font-family: inherit;
font-size: 0.75em; font-weight: 500; cursor: pointer;
transition: all 0.2s;
}
.filter-chip:hover { border-color: #30363d; color: #e6edf3; }
.filter-chip.active { background: rgba(102,126,234,0.15); color: #a78bfa; border-color: rgba(102,126,234,0.3); }
/* Notes grid */
.notes-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 16px;
}
.note-card {
background: #0d1117; border: 1px solid #1b2030; border-radius: 12px;
overflow: hidden; transition: all 0.25s; position: relative;
}
.note-card:hover { border-color: #30363d; transform: translateY(-2px); box-shadow: 0 8px 25px rgba(0,0,0,0.3); }
.note-card.pinned { border-color: rgba(227,179,65,0.3); }
.note-card.pinned::after {
content: '📌'; position: absolute; top: 12px; right: 12px; font-size: 0.9em;
}
.note-color-bar { height: 3px; width: 100%; }
.note-body { padding: 16px; }
.note-cat {
display: inline-flex; align-items: center; gap: 4px;
font-size: 0.65em; font-weight: 700; text-transform: uppercase;
letter-spacing: 1px; padding: 2px 8px; border-radius: 4px;
margin-bottom: 10px;
}
.cat-note { background: rgba(88,166,255,0.1); color: #58a6ff; }
.cat-doc { background: rgba(167,139,250,0.1); color: #a78bfa; }
.cat-todo { background: rgba(227,179,65,0.1); color: #e3b341; }
.cat-announcement { background: rgba(248,81,73,0.1); color: #f85149; }
.note-title {
font-size: 0.95em; font-weight: 600; color: #e6edf3;
margin-bottom: 8px; line-height: 1.4;
}
.note-content {
font-size: 0.82em; color: #8b949e; line-height: 1.6;
margin-bottom: 12px; max-height: 120px; overflow: hidden;
white-space: pre-wrap; word-break: break-word;
}
.note-footer {
display: flex; justify-content: space-between; align-items: center;
padding-top: 10px; border-top: 1px solid #1b2030;
}
.note-meta { font-size: 0.7em; color: #484f58; }
.note-actions { display: flex; gap: 4px; }
.note-act-btn {
width: 28px; height: 28px; border-radius: 6px;
border: 1px solid #1b2030; background: transparent;
color: #484f58; font-size: 0.82em; cursor: pointer;
display: flex; align-items: center; justify-content: center;
transition: all 0.15s;
}
.note-act-btn:hover { background: #161b22; color: #e6edf3; border-color: #30363d; }
.note-act-btn.pin-active { color: #e3b341; border-color: rgba(227,179,65,0.3); }
/* ═══ LINKS ═══ */
.link-card {
background: #0d1117; border: 1px solid #1b2030; border-radius: 12px;
padding: 16px; transition: all 0.25s; position: relative;
display: flex; gap: 14px; align-items: flex-start;
}
.link-card:hover { border-color: #30363d; transform: translateY(-2px); box-shadow: 0 8px 25px rgba(0,0,0,0.3); }
.link-card.pinned { border-color: rgba(227,179,65,0.3); }
.link-icon {
width: 42px; height: 42px; border-radius: 10px;
background: #161b22; display: flex; align-items: center;
justify-content: center; font-size: 1.3em; flex-shrink: 0;
border: 1px solid #1b2030;
}
.link-info { flex: 1; min-width: 0; }
.link-cat-tag {
display: inline-flex; font-size: 0.6em; font-weight: 700;
text-transform: uppercase; letter-spacing: 1px;
padding: 1px 6px; border-radius: 3px; margin-bottom: 6px;
}
.lcat-tool { background: rgba(86,211,100,0.1); color: #56d364; }
.lcat-doc { background: rgba(167,139,250,0.1); color: #a78bfa; }
.lcat-design { background: rgba(247,120,186,0.1); color: #f778ba; }
.lcat-api { background: rgba(88,166,255,0.1); color: #58a6ff; }
.lcat-repo { background: rgba(227,179,65,0.1); color: #e3b341; }
.lcat-other { background: rgba(139,148,158,0.1); color: #8b949e; }
.link-title {
font-size: 0.92em; font-weight: 600; color: #e6edf3;
margin-bottom: 4px; white-space: nowrap; overflow: hidden;
text-overflow: ellipsis;
}
.link-url {
font-size: 0.75em; color: #58a6ff; font-family: 'Consolas', monospace;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
margin-bottom: 4px; cursor: pointer;
}
.link-url:hover { text-decoration: underline; }
.link-desc {
font-size: 0.78em; color: #8b949e; line-height: 1.5;
max-height: 40px; overflow: hidden;
}
.link-actions-row {
display: flex; gap: 4px; flex-shrink: 0; align-self: center;
}
.link-act {
width: 28px; height: 28px; border-radius: 6px;
border: 1px solid #1b2030; background: transparent;
color: #484f58; font-size: 0.78em; cursor: pointer;
display: flex; align-items: center; justify-content: center;
transition: all 0.15s;
}
.link-act:hover { background: #161b22; color: #e6edf3; border-color: #30363d; }
/* ═══ CHANGELOG ═══ */
.changelog-form {
display: flex; gap: 10px; margin-bottom: 20px;
background: #0d1117; border: 1px solid #1b2030;
border-radius: 12px; padding: 14px;
}
.changelog-form input { flex: 1; }
.changelog-form .author-input { max-width: 140px; }
.changelog-entry {
display: flex; gap: 14px; padding: 14px 0;
border-bottom: 1px solid #1b2030;
position: relative;
}
.changelog-entry:last-child { border-bottom: none; }
.cl-dot {
width: 10px; height: 10px; border-radius: 50%;
background: #56d364; margin-top: 5px; flex-shrink: 0;
box-shadow: 0 0 6px rgba(86,211,100,0.3);
}
.cl-body { flex: 1; min-width: 0; }
.cl-meta {
display: flex; gap: 10px; align-items: center;
margin-bottom: 4px;
}
.cl-author {
font-weight: 700; color: #e6edf3; font-size: 0.85em;
}
.cl-time {
font-size: 0.75em; color: #484f58;
}
.cl-content {
font-size: 0.88em; color: #b1bac4; line-height: 1.6;
}
.cl-delete {
position: absolute; right: 0; top: 14px;
width: 26px; height: 26px; border-radius: 6px;
border: 1px solid transparent; background: transparent;
color: #484f58; font-size: 0.7em; cursor: pointer;
display: flex; align-items: center; justify-content: center;
transition: all 0.15s; opacity: 0;
}
.changelog-entry:hover .cl-delete { opacity: 1; }
.cl-delete:hover { background: rgba(248,81,73,0.1); color: #f85149; border-color: rgba(248,81,73,0.2); }
.cl-line {
position: absolute; left: 4px; top: 24px; bottom: 0;
width: 2px; background: #1b2030;
}
/* Empty state */
.empty-state {
text-align: center; padding: 60px 20px;
color: #484f58;
}
.empty-state .empty-icon { font-size: 3em; margin-bottom: 12px; opacity: 0.5; }
.empty-state p { font-size: 0.9em; margin-bottom: 16px; }
/* ═══ MODAL ═══ */
.modal-overlay {
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.7); z-index: 200;
display: none; align-items: center; justify-content: center;
backdrop-filter: blur(4px);
}
.modal-overlay.show { display: flex; }
.modal {
background: #0d1117; border: 1px solid #1b2030;
border-radius: 16px; width: 550px; max-width: 90vw;
box-shadow: 0 20px 60px rgba(0,0,0,0.6);
animation: modalIn 0.2s ease-out;
}
@keyframes modalIn {
from { transform: scale(0.95) translateY(10px); opacity: 0; }
to { transform: scale(1) translateY(0); opacity: 1; }
}
.modal-head {
padding: 20px 24px; border-bottom: 1px solid #1b2030;
display: flex; justify-content: space-between; align-items: center;
}
.modal-head h3 { font-size: 1em; font-weight: 700; color: #e6edf3; }
.modal-close {
background: none; border: none; color: #484f58; font-size: 1.3em;
cursor: pointer; transition: color 0.15s; padding: 4px;
}
.modal-close:hover { color: #f85149; }
.modal-body { padding: 24px; }
.form-group { margin-bottom: 16px; }
.form-label {
display: block; font-size: 0.78em; font-weight: 600; color: #8b949e;
margin-bottom: 6px;
}
.form-input, .form-textarea, .form-select {
width: 100%; padding: 10px 14px; background: #161b22;
border: 1px solid #1b2030; border-radius: 8px; color: #e6edf3;
font-family: inherit; font-size: 0.88em; transition: all 0.2s;
}
.form-input:focus, .form-textarea:focus, .form-select:focus {
outline: none; border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102,126,234,0.15);
}
.form-textarea { min-height: 120px; resize: vertical; line-height: 1.6; }
.form-select { cursor: pointer; }
.form-select option { background: #161b22; }
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.color-dots { display: flex; gap: 8px; margin-top: 6px; }
.color-dot {
width: 24px; height: 24px; border-radius: 50%;
border: 2px solid transparent; cursor: pointer;
transition: all 0.15s;
}
.color-dot:hover { transform: scale(1.2); }
.color-dot.active { border-color: #fff; box-shadow: 0 0 0 2px rgba(255,255,255,0.2); }
.modal-foot {
padding: 16px 24px; border-top: 1px solid #1b2030;
display: flex; justify-content: flex-end; gap: 10px;
}
/* Loading */
.loading {
display: flex; flex-direction: column; align-items: center;
justify-content: center; padding: 80px; color: #484f58;
}
.loading .spinner {
width: 36px; height: 36px; border: 3px solid #1b2030;
border-top-color: #667eea; border-radius: 50%;
animation: spin 0.7s linear infinite; margin-bottom: 16px;
}
/* Scrollbar */
::-webkit-scrollbar { width: 5px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #1b2030; border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: #30363d; }
@media (max-width: 1024px) {
.sidebar { width: 64px; }
.sidebar .brand-text, .sidebar .nav-group-label,
.sidebar .nav-item span, .sidebar .nav-badge,
.sidebar .version-text, .sidebar .version-dot { display: none; }
.sidebar .nav-item { justify-content: center; padding: 12px; }
.sidebar-brand { justify-content: center; padding: 16px; }
.main { margin-left: 64px; }
.grid { grid-template-columns: 1fr; }
.fb-row { grid-template-columns: repeat(2, 1fr); }
}
</style>
</head>
<body>
<!-- ═══ SIDEBAR ═══ -->
<aside class="sidebar">
<div class="sidebar-brand">
<div class="brand-icon">🤖</div>
<div class="brand-text">
<h2>Canifa AI</h2>
<span>Admin Console</span>
</div>
</div>
<div class="nav-group">
<div class="nav-group-label">Main</div>
<a href="/static/flow.html" class="nav-item">
<span class="nav-icon">🔀</span>
<span>Sơ đồ hoạt động</span>
</a>
<a href="/static/dashboard.html" class="nav-item active">
<span class="nav-icon">📊</span>
<span>Dashboard</span>
</a>
<a href="/static/experiment_detail.html?id=exp_chatbot_prod" class="nav-item">
<span class="nav-icon">💬</span>
<span>Chatbot</span>
<span class="nav-badge badge-live">LIVE</span>
</a>
<a href="/static/history.html" class="nav-item">
<span class="nav-icon">🧾</span>
<span>History</span>
</a>
</div>
<div class="nav-group">
<div class="nav-group-label">Workspace</div>
<a href="/static/resources.html" class="nav-item">
<span class="nav-icon">🔗</span>
<span>Resources</span>
</a>
<a href="/static/notes.html" class="nav-item">
<span class="nav-icon">📝</span>
<span>Team Notes</span>
</a>
<a href="/static/changelog.html" class="nav-item">
<span class="nav-icon">📋</span>
<span>Changelog</span>
</a>
<a href="/static/guide.html" class="nav-item">
<span class="nav-icon">📖</span>
<span>Hướng dẫn</span>
</a>
</div>
<div class="nav-group">
<div class="nav-group-label">Thử nghiệm</div>
<a href="/static/test_sql.html" class="nav-item">
<span class="nav-icon">🗄️</span>
<span>Text-to-SQL</span>
<span class="nav-badge badge-beta">BETA</span>
</a>
<a href="/static/test_db.html" class="nav-item">
<span class="nav-icon">🔍</span>
<span>DB Test</span>
<span class="nav-badge badge-beta">BETA</span>
</a>
<a href="/static/feedback_demo.html" class="nav-item">
<span class="nav-icon">📝</span>
<span>Feedback Demo</span>
<span class="nav-badge badge-new">NEW</span>
</a>
</div>
<div class="nav-group">
<div class="nav-group-label">External</div>
<a href="/docs" target="_blank" class="nav-item">
<span class="nav-icon">📚</span>
<span>API Docs</span>
</a>
<a href="/redoc" target="_blank" class="nav-item">
<span class="nav-icon">📖</span>
<span>ReDoc</span>
</a>
</div>
<div class="sidebar-footer">
<div class="version-info">
<span class="version-dot"></span>
<span class="version-text"><strong id="sidebarVersion">v2.5.0</strong> · Online</span>
</div>
</div>
</aside>
<!-- ═══ MAIN ═══ -->
<div class="main">
<div class="topbar">
<div class="topbar-left">
<h1 id="pageTitle">Dashboard</h1>
<p id="pageSubtitle">System overview & monitoring</p>
</div>
<div class="topbar-right">
<div class="status-pill">
<span class="dot"></span>
All Systems Operational
</div>
<button class="refresh-btn" id="refreshBtn" onclick="manualRefresh()">
<span class="r-icon"></span> Refresh
</button>
</div>
</div>
<div class="content">
<!-- OVERVIEW -->
<div id="tab-overview">
<div class="loading" id="loadingState">
<div class="spinner"></div>
<div>Loading dashboard...</div>
</div>
</div>
</div>
</div>
<script>
/* ═══ STATE ═══ */
let dashData = null;
/* ═══ HELPERS ═══ */
function esc(str) {
if (!str) return '';
return str.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function fmtTime(iso) {
if (!iso) return '';
const d = new Date(iso);
const now = new Date();
const diff = (now - d) / 1000;
if (diff < 60) return 'vừa xong';
if (diff < 3600) return Math.floor(diff/60) + ' phút trước';
if (diff < 86400) return Math.floor(diff/3600) + ' giờ trước';
if (diff < 604800) return Math.floor(diff/86400) + ' ngày trước';
return d.toLocaleDateString('vi-VN', {day:'2-digit', month:'2-digit', year:'numeric'});
}
/* ═══ TABS ═══ */
function switchTab(tab) {
activeTab = tab;
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-btn').forEach(b => {
if ((tab === 'overview' && b.textContent.includes('Overview')) ||
(tab === 'notes' && b.textContent.includes('Notes')) ||
(tab === 'resources' && b.textContent.includes('Resources')) ||
(tab === 'changelog' && b.textContent.includes('Changelog')))
b.classList.add('active');
});
const target = document.getElementById('tab-' + tab);
if (target) target.classList.add('active');
if (tab === 'overview') {
document.getElementById('pageTitle').textContent = 'Dashboard';
document.getElementById('pageSubtitle').textContent = 'System overview & monitoring';
} else if (tab === 'notes') {
document.getElementById('pageTitle').textContent = 'Team Notes';
document.getElementById('pageSubtitle').textContent = 'Notes, pinned docs & team workspace';
loadNotes();
} else if (tab === 'resources') {
document.getElementById('pageTitle').textContent = 'Resources';
document.getElementById('pageSubtitle').textContent = 'Documentation, links & team resources';
loadLinks();
} else if (tab === 'changelog') {
document.getElementById('pageTitle').textContent = 'Changelog';
document.getElementById('pageSubtitle').textContent = 'Lịch sử thay đổi chatbot';
loadChangelog();
}
}
/* ═══ DASHBOARD ═══ */
async function fetchDashboard() {
try {
const res = await fetch('/api/dashboard/info');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
dashData = await res.json();
renderDashboard();
} catch (err) {
document.getElementById('tab-overview').innerHTML = `
<div class="loading" style="color:#f85149;">
<div style="font-size:2em;margin-bottom:12px;">⚠️</div>
<div>Failed to load: ${err.message}</div>
<button class="refresh-btn" style="margin-top:16px;" onclick="fetchDashboard()">↻ Retry</button>
</div>`;
}
}
function renderDashboard() {
const d = dashData;
const root = document.getElementById('tab-overview');
const fb = d.feedback || {};
const hp = d.services_summary?.health_pct || 0;
const hc = d.services_summary?.healthy || 0;
const tc = d.services_summary?.total || 0;
const sv = document.getElementById('sidebarVersion');
if (sv) sv.textContent = 'v' + d.system.app_version;
root.innerHTML = `
<div class="stats-row">
<div class="stat-card"><div class="stat-label">Version</div><div class="stat-value purple">v${d.system.app_version}</div><div class="stat-sub">Git: ${d.git.branch} @ ${d.git.commit}</div></div>
<div class="stat-card"><div class="stat-label">Default Model</div><div class="stat-value blue" style="font-size:1.05em">${d.llm.default_model}</div><div class="stat-sub">LLM Engine</div></div>
<div class="stat-card"><div class="stat-label">Uptime</div><div class="stat-value green">${d.system.uptime}</div><div class="stat-sub">Python ${d.system.python_version}</div></div>
<div class="stat-card"><div class="stat-label">Services</div><div class="stat-value amber">${hc}/${tc}</div><div class="stat-sub">${hp}% healthy</div></div>
<div class="stat-card"><div class="stat-label">Feedback</div><div class="stat-value pink">${fb.total || 0}</div><div class="stat-sub">👍 ${fb.likes || 0} · 👎 ${fb.dislikes || 0}</div></div>
</div>
<div class="grid">
<div class="card"><div class="card-head"><h3>🔌 Service Health</h3><span class="tag ${hp>=80?'tag-green':hp>=50?'tag-amber':'tag-red'}">${hc}/${tc} Online</span></div><div class="card-body"><div class="svc-grid">${d.services.map(s=>`<div class="svc ${s.configured?'ok':'fail'}"><div class="svc-name">${s.name}</div><div class="svc-status ${s.configured?'ok':'fail'}">${s.status}</div></div>`).join('')}</div><div class="hbar"><div class="hbar-bg"><div class="hbar-fill" style="width:${hp}%"></div></div><div class="hbar-label"><span>Service Health</span><span>${hp}%</span></div></div></div></div>
<div class="card"><div class="card-head"><h3>🧠 LLM & API Keys</h3></div><div class="card-body"><div class="key-grid"><div class="key-item"><div class="key-label">Default Model</div><div class="key-val set">${d.llm.default_model}</div></div><div class="key-item"><div class="key-label">OpenAI</div><div class="key-val ${d.llm.openai_key.includes('Not')?'unset':'set'}">${d.llm.openai_key}</div></div><div class="key-item"><div class="key-label">Google AI</div><div class="key-val ${d.llm.google_key.includes('Not')?'unset':'set'}">${d.llm.google_key}</div></div><div class="key-item"><div class="key-label">Groq</div><div class="key-val ${d.llm.groq_key.includes('Not')?'unset':'set'}">${d.llm.groq_key}</div></div></div></div></div>
<div class="card"><div class="card-head"><h3>📊 Feedback Stats</h3></div><div class="card-body"><div class="fb-row"><div class="fb-card fb-total"><div class="fb-num">${fb.total||0}</div><div class="fb-lbl">Total</div></div><div class="fb-card fb-like"><div class="fb-num">${fb.likes||0}</div><div class="fb-lbl">👍 Likes</div></div><div class="fb-card fb-dislike"><div class="fb-num">${fb.dislikes||0}</div><div class="fb-lbl">👎 Dislikes</div></div><div class="fb-card fb-pending"><div class="fb-num">${fb.pending||0}</div><div class="fb-lbl">⏳ Pending</div></div></div></div></div>
<div class="card"><div class="card-head"><h3>⚙️ Configuration</h3></div><div class="card-body"><table class="cfg-table"><tr><td>Server Port</td><td>${d.system.server_port}</td></tr><tr><td>Conv Storage</td><td>${d.config.conversation_storage}</td></tr><tr><td>MongoDB DB</td><td>${d.config.mongodb_db||'N/A'}</td></tr><tr><td>Redis</td><td>${d.config.redis_host}:${d.config.redis_port}</td></tr><tr><td>Langfuse</td><td>${d.config.langfuse_url}</td></tr><tr><td>StarRocks</td><td>${d.config.starrocks_db}</td></tr><tr><td>Rate (Guest)</td><td>${d.config.rate_limit_guest} req</td></tr><tr><td>Rate (User)</td><td>${d.config.rate_limit_user} req</td></tr><tr><td>Platform</td><td>${d.system.os}</td></tr></table></div></div>
</div>
<div class="grid grid-full"><div class="card"><div class="card-head"><h3>🛤️ API Routes</h3><span class="tag tag-green">${d.routes.length} endpoints</span></div><div class="card-body"><div class="routes-list">${d.routes.map(r=>`<div class="route">${r.methods.map(m=>`<span class="method m-${m}">${m}</span>`).join('')}<span class="route-path">${r.path}</span></div>`).join('')}</div></div></div></div>
`;
}
/* ═══ NOTES ═══ */
async function loadNotes() {
try {
const res = await fetch('/api/dashboard/notes');
const data = await res.json();
notesData = data.notes || [];
document.getElementById('notesBadge').textContent = notesData.length;
renderNotes();
} catch (err) {
console.error('Failed to load notes:', err);
}
}
function renderNotes() {
const grid = document.getElementById('notesGrid');
let filtered = notesData;
if (activeFilter === 'pinned') filtered = notesData.filter(n => n.pinned);
else if (activeFilter !== 'all') filtered = notesData.filter(n => n.category === activeFilter);
// Sort: pinned first, then by date
filtered.sort((a, b) => {
if (a.pinned && !b.pinned) return -1;
if (!a.pinned && b.pinned) return 1;
return new Date(b.updated_at) - new Date(a.updated_at);
});
if (filtered.length === 0) {
grid.innerHTML = `<div class="empty-state"><div class="empty-icon">📝</div><p>No notes found.</p><button class="btn btn-primary" onclick="openNoteModal()">+ New Note</button></div>`;
return;
}
grid.innerHTML = filtered.map(n => `
<div class="note-card ${n.pinned ? 'pinned' : ''}">
<div class="note-color-bar" style="background:${n.color || '#58a6ff'}"></div>
<div class="note-body">
<div class="note-cat cat-${n.category}">${catIcon(n.category)} ${n.category}</div>
<div class="note-title">${esc(n.title)}</div>
<div class="note-content">${esc(n.content)}</div>
<div class="note-footer">
<span class="note-meta">${timeAgo(n.updated_at)} · ${n.author || 'Admin'}</span>
<div class="note-actions">
<button class="note-act-btn ${n.pinned?'pin-active':''}" title="Pin" onclick="togglePin('${n.id}')">📌</button>
<button class="note-act-btn" title="Edit" onclick="editNote('${n.id}')">✏️</button>
<button class="note-act-btn" title="Delete" onclick="deleteNote('${n.id}')">🗑️</button>
</div>
</div>
</div>
</div>
`).join('');
}
function catIcon(cat) {
return { note: '📝', doc: '📄', todo: '✅', announcement: '📢' }[cat] || '📝';
}
function esc(s) {
const d = document.createElement('div');
d.textContent = s || '';
return d.innerHTML;
}
function timeAgo(dateStr) {
const diff = Date.now() - new Date(dateStr).getTime();
const mins = Math.floor(diff / 60000);
if (mins < 1) return 'just now';
if (mins < 60) return mins + 'm ago';
const hrs = Math.floor(mins / 60);
if (hrs < 24) return hrs + 'h ago';
const days = Math.floor(hrs / 24);
return days + 'd ago';
}
function filterNotes(filter, el) {
activeFilter = filter;
document.querySelectorAll('.filter-chip').forEach(c => c.classList.remove('active'));
if (el) el.classList.add('active');
renderNotes();
}
/* Modal */
function openNoteModal(noteId) {
document.getElementById('noteModal').classList.add('show');
document.getElementById('editNoteId').value = '';
document.getElementById('noteTitle').value = '';
document.getElementById('noteContent').value = '';
document.getElementById('noteCategory').value = 'note';
document.getElementById('notePinned').checked = false;
document.getElementById('modalTitle').textContent = '✏️ New Note';
selectColor('#58a6ff', document.querySelector('.color-dot'));
// Auto-focus title
setTimeout(() => document.getElementById('noteTitle').focus(), 100);
}
function closeNoteModal() {
document.getElementById('noteModal').classList.remove('show');
}
function selectColor(color, el) {
selectedColor = color;
document.querySelectorAll('.color-dot').forEach(d => d.classList.remove('active'));
if (el) el.classList.add('active');
}
function editNote(id) {
const note = notesData.find(n => n.id === id);
if (!note) return;
document.getElementById('noteModal').classList.add('show');
document.getElementById('editNoteId').value = id;
document.getElementById('noteTitle').value = note.title;
document.getElementById('noteContent').value = note.content;
document.getElementById('noteCategory').value = note.category;
document.getElementById('notePinned').checked = note.pinned;
document.getElementById('modalTitle').textContent = '✏️ Edit Note';
selectedColor = note.color || '#58a6ff';
document.querySelectorAll('.color-dot').forEach(d => {
d.classList.toggle('active', d.style.background === selectedColor || d.style.backgroundColor === selectedColor);
});
setTimeout(() => document.getElementById('noteTitle').focus(), 100);
}
async function saveNote() {
const id = document.getElementById('editNoteId').value;
const title = document.getElementById('noteTitle').value.trim();
const content = document.getElementById('noteContent').value.trim();
const category = document.getElementById('noteCategory').value;
const pinned = document.getElementById('notePinned').checked;
if (!title) { document.getElementById('noteTitle').focus(); return; }
const body = { title, content, category, pinned, color: selectedColor };
try {
if (id) {
await fetch(`/api/dashboard/notes/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
} else {
await fetch('/api/dashboard/notes', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
}
closeNoteModal();
await loadNotes();
} catch (err) {
alert('Failed to save note: ' + err.message);
}
}
async function togglePin(id) {
try {
await fetch(`/api/dashboard/notes/${id}/pin`, { method: 'PATCH' });
await loadNotes();
} catch (err) {
console.error('Pin toggle failed:', err);
}
}
async function deleteNote(id) {
if (!confirm('Delete this note?')) return;
try {
await fetch(`/api/dashboard/notes/${id}`, { method: 'DELETE' });
await loadNotes();
} catch (err) {
alert('Failed to delete: ' + err.message);
}
}
/* ═══ LINKS ═══ */
async function loadLinks() {
try {
const res = await fetch('/api/dashboard/links');
const data = await res.json();
linksData = data.links || [];
document.getElementById('linksBadge').textContent = linksData.length;
renderLinks();
} catch (err) {
console.error('Failed to load links:', err);
}
}
function renderLinks() {
const grid = document.getElementById('linksGrid');
let filtered = linksData;
if (activeLinkFilter === 'pinned') filtered = linksData.filter(l => l.pinned);
else if (activeLinkFilter !== 'all') filtered = linksData.filter(l => l.category === activeLinkFilter);
filtered.sort((a, b) => {
if (a.pinned && !b.pinned) return -1;
if (!a.pinned && b.pinned) return 1;
return 0;
});
if (filtered.length === 0) {
grid.innerHTML = `<div class="empty-state"><div class="empty-icon">🔗</div><p>No resources found.</p><button class="btn btn-primary" onclick="openLinkModal()">+ Add Link</button></div>`;
return;
}
grid.innerHTML = filtered.map(l => `
<div class="link-card ${l.pinned ? 'pinned' : ''}">
<div class="link-icon">${l.icon || '🔗'}</div>
<div class="link-info">
<div class="link-cat-tag lcat-${l.category}">${l.category}</div>
<div class="link-title">${esc(l.title)}</div>
<div class="link-url" onclick="window.open('${l.url}','_blank')">${l.url}</div>
${l.description ? `<div class="link-desc">${esc(l.description)}</div>` : ''}
</div>
<div class="link-actions-row">
<button class="link-act" title="Open" onclick="window.open('${l.url}','_blank')">↗</button>
<button class="link-act ${l.pinned?'pin-active':''}" title="Pin" onclick="toggleLinkPin('${l.id}')">📌</button>
<button class="link-act" title="Edit" onclick="editLink('${l.id}')">✏️</button>
<button class="link-act" title="Delete" onclick="deleteLink('${l.id}')">🗑️</button>
</div>
</div>
`).join('');
}
function filterLinks(filter, el) {
activeLinkFilter = filter;
document.querySelectorAll('#linkFilters .filter-chip').forEach(c => c.classList.remove('active'));
if (el) el.classList.add('active');
renderLinks();
}
function openLinkModal() {
document.getElementById('linkModal').classList.add('show');
document.getElementById('editLinkId').value = '';
document.getElementById('linkTitle').value = '';
document.getElementById('linkUrl').value = '';
document.getElementById('linkDesc').value = '';
document.getElementById('linkCategory').value = 'tool';
document.getElementById('linkIcon').value = '';
document.getElementById('linkPinned').checked = false;
document.getElementById('linkModalTitle').textContent = '🔗 Add Resource';
setTimeout(() => document.getElementById('linkTitle').focus(), 100);
}
function closeLinkModal() {
document.getElementById('linkModal').classList.remove('show');
}
function editLink(id) {
const link = linksData.find(l => l.id === id);
if (!link) return;
document.getElementById('linkModal').classList.add('show');
document.getElementById('editLinkId').value = id;
document.getElementById('linkTitle').value = link.title;
document.getElementById('linkUrl').value = link.url;
document.getElementById('linkDesc').value = link.description || '';
document.getElementById('linkCategory').value = link.category;
document.getElementById('linkIcon').value = link.icon || '';
document.getElementById('linkPinned').checked = link.pinned;
document.getElementById('linkModalTitle').textContent = '✏️ Edit Resource';
setTimeout(() => document.getElementById('linkTitle').focus(), 100);
}
async function saveLink() {
const id = document.getElementById('editLinkId').value;
const title = document.getElementById('linkTitle').value.trim();
const url = document.getElementById('linkUrl').value.trim();
const description = document.getElementById('linkDesc').value.trim();
const category = document.getElementById('linkCategory').value;
const icon = document.getElementById('linkIcon').value.trim();
const pinned = document.getElementById('linkPinned').checked;
if (!title) { document.getElementById('linkTitle').focus(); return; }
if (!url) { document.getElementById('linkUrl').focus(); return; }
const body = { title, url, description, category, icon, pinned };
try {
if (id) {
await fetch(`/api/dashboard/links/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
} else {
await fetch('/api/dashboard/links', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
}
closeLinkModal();
await loadLinks();
} catch (err) {
alert('Failed to save link: ' + err.message);
}
}
async function toggleLinkPin(id) {
try {
await fetch(`/api/dashboard/links/${id}/pin`, { method: 'PATCH' });
await loadLinks();
} catch (err) { console.error('Pin toggle failed:', err); }
}
async function deleteLink(id) {
if (!confirm('Delete this resource?')) return;
try {
await fetch(`/api/dashboard/links/${id}`, { method: 'DELETE' });
await loadLinks();
} catch (err) { alert('Failed to delete: ' + err.message); }
}
/* ═══ CHANGELOG ═══ */
let changelogData = [];
async function loadChangelog() {
try {
const res = await fetch('/api/dashboard/changelog');
const data = await res.json();
changelogData = data.entries || [];
renderChangelog();
} catch (err) {
console.error('Failed to load changelog:', err);
}
}
function renderChangelog() {
const list = document.getElementById('changelogList');
if (changelogData.length === 0) {
list.innerHTML = `<div class="empty-state"><div class="empty-icon">📋</div><p>Chưa có ghi chú nào. Thêm dòng đầu tiên!</p></div>`;
return;
}
list.innerHTML = changelogData.map((e, i) => `
<div class="changelog-entry">
<div class="cl-dot"></div>
${i < changelogData.length - 1 ? '<div class="cl-line"></div>' : ''}
<div class="cl-body">
<div class="cl-meta">
<span class="cl-author">${esc(e.author)}</span>
<span class="cl-time">${fmtTime(e.created_at)}</span>
</div>
<div class="cl-content">${esc(e.content)}</div>
</div>
<button class="cl-delete" title="Xóa" onclick="deleteChangelog('${e.id}')">🗑️</button>
</div>
`).join('');
}
async function addChangelog() {
const author = document.getElementById('clAuthor').value.trim();
const content = document.getElementById('clContent').value.trim();
if (!content) { document.getElementById('clContent').focus(); return; }
try {
await fetch('/api/dashboard/changelog', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content, author: author || 'Admin' }),
});
document.getElementById('clContent').value = '';
await loadChangelog();
} catch (err) { alert('Lỗi: ' + err.message); }
}
async function deleteChangelog(id) {
if (!confirm('Xóa ghi chú này?')) return;
try {
await fetch(`/api/dashboard/changelog/${id}`, { method: 'DELETE' });
await loadChangelog();
} catch (err) { alert('Lỗi: ' + err.message); }
}
/* ═══ GENERAL ═══ */
function manualRefresh() {
const btn = document.getElementById('refreshBtn');
if (btn) btn.classList.add('spinning');
fetchDashboard().finally(() => setTimeout(() => {
const b = document.getElementById('refreshBtn');
if (b) b.classList.remove('spinning');
}, 500));
}
// Init
fetchDashboard();
setInterval(fetchDashboard, 30000);
</script>
<script src="/static/js/sidebar-experiments.js"></script>
</body>
</html>
......@@ -5,7 +5,10 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Chi tiết thử nghiệm - Canifa AI</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&display=swap" rel="stylesheet">
<style>
<link rel="stylesheet" href="/static/lab.css">
<link rel="stylesheet" href="/static/warm-override.css">
<script src="/static/frame-detect.js"></script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Inter', sans-serif; background: #0b0e14; color: #c9d1d9; min-height: 100vh; display: flex; }
......@@ -51,6 +54,18 @@
.btn-outline:hover { color: #e6edf3; border-color: #484f58; }
.btn-primary { background: linear-gradient(135deg, #667eea, #764ba2); border: none; color: #fff; }
.btn-primary:hover { opacity: 0.9; }
.btn-sm { padding: 5px 12px; font-size: 0.75em; }
.btn-danger { background: none; border: 1px solid #f8514930; color: #f85149; }
.btn-danger:hover { background: #f8514915; border-color: #f85149; }
/* ─── HEADER EDIT FORM ─── */
.header-edit-form { display: none; margin-top: 16px; padding: 16px; background: #161b22; border: 1px solid #21262d; border-radius: 10px; }
.header-edit-form.show { display: block; }
.header-edit-row { display: flex; gap: 10px; margin-bottom: 10px; align-items: center; }
.header-edit-row label { font-size: 0.78em; font-weight: 600; color: #8b949e; min-width: 50px; }
.header-edit-row input { flex: 1; padding: 8px 12px; background: #0d1117; border: 1px solid #21262d; border-radius: 8px; color: #e6edf3; font-size: 0.85em; outline: none; font-family: inherit; }
.header-edit-row input:focus { border-color: #667eea; box-shadow: 0 0 0 3px rgba(102,126,234,0.1); }
.header-edit-actions { display: flex; gap: 8px; justify-content: flex-end; }
/* ─── CONTENT PANELS ─── */
.content { padding: 32px 40px; display: grid; grid-template-columns: 1fr 1fr; gap: 32px; }
......@@ -154,7 +169,28 @@
</div>
</div>
</div>
<a id="openLink" href="#" target="_blank" class="btn btn-outline">🔗 Mở trang</a>
<div style="display:flex;gap:8px;align-items:center">
<button class="btn btn-outline btn-sm" id="headerEditBtn" onclick="toggleHeaderEdit()">✏️ Sửa</button>
<a id="openLink" href="#" target="_blank" class="btn btn-outline">🔗 Mở trang</a>
</div>
</div>
<div class="header-edit-form" id="headerEditForm">
<div class="header-edit-row">
<label>Tên</label>
<input type="text" id="editName" placeholder="VD: Chatbot (Production)" />
</div>
<div class="header-edit-row">
<label>URL</label>
<input type="text" id="editUrl" placeholder="VD: http://172.16.2.207:5005/static/index.html" />
</div>
<div class="header-edit-row">
<label>Icon</label>
<input type="text" id="editIcon" placeholder="Emoji, VD: 💬" style="max-width:80px" />
</div>
<div class="header-edit-actions">
<button class="btn btn-outline btn-sm" onclick="toggleHeaderEdit()">Hủy</button>
<button class="btn btn-primary btn-sm" onclick="saveHeaderEdit()">Lưu</button>
</div>
</div>
</div>
......@@ -316,6 +352,37 @@
await load();
}
// ─── Header Edit (Name/URL/Icon) ───
function toggleHeaderEdit() {
const form = document.getElementById('headerEditForm');
const btn = document.getElementById('headerEditBtn');
if (form.classList.contains('show')) {
form.classList.remove('show');
btn.style.display = '';
} else {
document.getElementById('editName').value = currentData.name || '';
document.getElementById('editUrl').value = currentData.url || '';
document.getElementById('editIcon').value = currentData.icon || '🔗';
form.classList.add('show');
btn.style.display = 'none';
document.getElementById('editName').focus();
}
}
async function saveHeaderEdit() {
const name = document.getElementById('editName').value.trim();
const url = document.getElementById('editUrl').value.trim();
const icon = document.getElementById('editIcon').value.trim();
if (!name) return alert('Tên không được để trống!');
await fetch(`${API}/${linkId}`, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ name, url, icon })
});
toggleHeaderEdit();
await load();
}
// ─── Versions ───
async function addVersion() {
const version = document.getElementById('vVersion').value.trim();
......
<!DOCTYPE html>
<!DOCTYPE html>
<html lang="vi">
<head>
......@@ -6,7 +6,10 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Canifa Chatbot - Feedback Demo</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
<link rel="stylesheet" href="/static/lab.css">
<link rel="stylesheet" href="/static/warm-override.css">
<script src="/static/frame-detect.js"></script>
<style>
* {
margin: 0;
padding: 0;
......@@ -21,20 +24,9 @@
}
/* Nav */
.nav-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 14px 30px;
display: flex;
justify-content: space-between;
align-items: center;
box-shadow: 0 4px 20px rgba(102, 126, 234, 0.3);
}
.nav-header h1 {
color: white;
font-size: 1.4em;
font-weight: 700;
}
.nav-badge {
background: rgba(255, 255, 255, 0.2);
......@@ -146,7 +138,7 @@
.msg-bubble.bot {
background: #252540;
color: #d0d0e8;
border: 1px solid #333355;
border: 1px solid var(--b, #E8DED0)355;
border-bottom-left-radius: 4px;
}
......@@ -165,7 +157,7 @@
gap: 4px;
padding: 6px 12px;
border-radius: 20px;
border: 1px solid #333355;
border: 1px solid var(--b, #E8DED0)355;
background: rgba(37, 37, 64, 0.8);
color: #8888aa;
cursor: pointer;
......@@ -203,7 +195,7 @@
gap: 4px;
padding: 6px 12px;
border-radius: 20px;
border: 1px dashed #444466;
border: 1px dashed var(--b, #E8DED0)466;
background: transparent;
color: #6666aa;
cursor: pointer;
......@@ -235,7 +227,7 @@
.feedback-comment-inner {
background: #1e1e38;
border: 1px solid #333358;
border: 1px solid var(--b, #E8DED0)358;
border-radius: 14px;
padding: 14px;
max-width: 85%;
......@@ -258,7 +250,7 @@
.category-chip {
padding: 5px 12px;
border-radius: 16px;
border: 1px solid #444466;
border: 1px solid var(--b, #E8DED0)466;
background: transparent;
color: #9999bb;
cursor: pointer;
......@@ -282,7 +274,7 @@
.feedback-textarea {
width: 100%;
background: #151528;
border: 1px solid #333355;
border: 1px solid var(--b, #E8DED0)355;
border-radius: 10px;
padding: 10px 14px;
color: #d0d0e8;
......@@ -299,7 +291,7 @@
}
.feedback-textarea::placeholder {
color: #555577;
color: var(--f, #A89880)577;
}
.feedback-submit-row {
......@@ -322,7 +314,7 @@
.fb-cancel-btn {
background: transparent;
border: 1px solid #444466;
border: 1px solid var(--b, #E8DED0)466;
color: #8888aa;
}
......@@ -386,7 +378,7 @@
flex: 1;
padding: 12px 18px;
background: #252540;
border: 1px solid #333355;
border: 1px solid var(--b, #E8DED0)355;
border-radius: 14px;
color: #d0d0e8;
font-size: 0.95em;
......@@ -399,7 +391,7 @@
}
.chat-input-bar input::placeholder {
color: #555577;
color: var(--f, #A89880)577;
}
.send-btn {
......@@ -429,7 +421,7 @@
}
.chat-messages::-webkit-scrollbar-thumb {
background: #333355;
background: var(--b, #E8DED0)355;
border-radius: 3px;
}
......@@ -446,7 +438,7 @@
min-width: 140px;
background: #1e1e38;
border-radius: 12px;
border: 1px solid #333358;
border: 1px solid var(--b, #E8DED0)358;
overflow: hidden;
transition: all 0.2s;
}
......@@ -486,20 +478,7 @@
</head>
<body>
<div class="nav-header">
<h1>🤖 Canifa AI Stylist</h1>
<span class="nav-badge">📋 Feedback Demo</span>
</div>
<div class="main-content">
<div class="chat-container">
<div class="chat-header">
<div class="avatar">🤖</div>
<div class="info">
<h3>Canifa AI Stylist</h3>
<span>Online • GPT-4.1-mini</span>
</div>
</div>
<div class="chat-messages" id="chatMessages">
<!-- Message 1: User -->
......
/* ═══════════════════════════════════════
Flow Page — Warm Cream Theme
Uses CSS variables from lab.css
═══════════════════════════════════════ */
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:'Outfit',sans-serif;background:var(--bg,#F5F4F0);color:var(--t,#18181B);min-height:100vh;display:flex}
/* ═══ SIDEBAR ═══ */
.sidebar{width:240px;min-height:100vh;background:var(--s,#FFF);border-right:1px solid var(--b,#E2E0D8);display:flex;flex-direction:column;position:fixed;top:0;left:0;z-index:100;transition:width .3s cubic-bezier(.4,0,.2,1);overflow:hidden}
.sidebar.collapsed{width:64px}
.sidebar.collapsed .brand-text,.sidebar.collapsed .nav-group-label,.sidebar.collapsed .nav-item span:not(.nav-icon),.sidebar.collapsed .nav-badge,.sidebar.collapsed .sidebar-footer .version-text{display:none}
.sidebar.collapsed .nav-item{justify-content:center;padding:10px}
.sidebar.collapsed .sidebar-brand{justify-content:center;padding:24px 12px}
.sidebar.collapsed .version-info{justify-content:center}
.sidebar-brand{padding:24px 20px;border-bottom:1px solid var(--b,#E2E0D8);display:flex;align-items:center;gap:12px}
.brand-icon{width:40px;height:40px;background:var(--t,#18181B);border-radius:10px;display:flex;align-items:center;justify-content:center;font-size:1.3em;flex-shrink:0;cursor:pointer;transition:transform .2s}
.brand-icon:hover{transform:scale(1.08)}
.brand-text h2{font-size:1em;font-weight:700;color:var(--t,#18181B);white-space:nowrap}
.brand-text span{font-size:.7em;color:var(--m,#78716C);font-weight:500;white-space:nowrap}
.nav-group{padding:16px 12px 8px}
.nav-group+.nav-group{border-top:1px solid var(--b,#E2E0D8)}
.nav-group-label{font-size:.65em;font-weight:700;text-transform:uppercase;letter-spacing:1.5px;color:var(--m,#78716C);padding:0 12px;margin-bottom:8px;white-space:nowrap}
.nav-item{display:flex;align-items:center;gap:12px;padding:10px 16px;border-radius:8px;color:var(--m,#78716C);text-decoration:none;font-size:.88em;font-weight:500;transition:all .2s;margin-bottom:2px;cursor:pointer;position:relative;white-space:nowrap}
.nav-item:hover{background:var(--bg,#F5F4F0);color:var(--t,#18181B)}
.nav-item.active{background:var(--gold-l,#FFFBEB);color:var(--gold,#B45309);font-weight:600}
.nav-item.active::before{content:'';position:absolute;left:0;top:50%;transform:translateY(-50%);width:3px;height:20px;background:var(--gold,#B45309);border-radius:0 3px 3px 0}
.nav-icon{font-size:1.1em;width:22px;text-align:center;flex-shrink:0}
.nav-badge{margin-left:auto;font-size:.7em;padding:2px 8px;border-radius:999px;font-weight:600}
.badge-live{background:rgba(22,163,74,.1);color:#16a34a;border:1px solid rgba(22,163,74,.2)}
.badge-new{background:var(--gold-l,#FFFBEB);color:var(--gold,#B45309);border:1px solid rgba(180,83,9,.2)}
.badge-beta{background:rgba(234,179,8,.1);color:#ca8a04;border:1px solid rgba(234,179,8,.2)}
.badge-dev{background:rgba(124,58,237,.1);color:#7c3aed;border:1px solid rgba(124,58,237,.2)}
.sidebar-footer{margin-top:auto;padding:16px;border-top:1px solid var(--b,#E2E0D8)}
.version-info{display:flex;align-items:center;gap:10px;padding:10px 12px;background:var(--bg,#F5F4F0);border-radius:8px;border:1px solid var(--b,#E2E0D8)}
.version-dot{width:8px;height:8px;border-radius:50%;background:#16a34a;box-shadow:0 0 8px rgba(22,163,74,.4);flex-shrink:0}
.version-text{font-size:.75em;color:var(--m,#78716C);white-space:nowrap}
/* ═══ TOGGLE ═══ */
.sidebar-toggle{position:fixed;top:28px;left:228px;z-index:150;width:24px;height:24px;border-radius:50%;border:1px solid var(--b,#E2E0D8);background:var(--s,#FFF);color:var(--m,#78716C);cursor:pointer;display:flex;align-items:center;justify-content:center;font-size:12px;transition:left .3s cubic-bezier(.4,0,.2,1),transform .2s,background .2s;box-shadow:0 2px 8px rgba(0,0,0,.08)}
.sidebar-toggle:hover{background:var(--bg,#F5F4F0);color:var(--t,#18181B);transform:scale(1.1)}
.sidebar-toggle.collapsed{left:52px}
.sidebar-toggle.collapsed .toggle-icon{transform:rotate(180deg)}
.toggle-icon{transition:transform .3s;display:flex}
/* ═══ MAIN ═══ */
.main{margin-left:240px;flex:1;min-height:100vh;transition:margin-left .3s cubic-bezier(.4,0,.2,1)}
.main.expanded{margin-left:64px}
.topbar{padding:24px 40px;border-bottom:1px solid var(--b,#E2E0D8);background:var(--s,#FFF)}
.topbar h1{font-size:1.5em;font-weight:800;color:var(--t,#18181B);font-family:'Fraunces',serif}
.topbar p{font-size:.85em;color:var(--m,#78716C);margin-top:4px}
/* ═══ LAYOUT: 2 PANELS ═══ */
.panels{display:flex;min-height:calc(100vh - 80px)}
.flow-panel{width:300px;min-width:180px;max-width:80vw;position:sticky;top:0;height:calc(100vh - 80px);overflow:auto;flex-shrink:0;display:flex;flex-direction:column;background:var(--s,#FFF);border-right:1px solid var(--b,#E2E0D8)}
.flow-panel::-webkit-scrollbar{width:4px}
.flow-panel::-webkit-scrollbar-thumb{background:var(--b,#E2E0D8);border-radius:4px}
/* Zoom controls */
.zoom-controls{display:flex;align-items:center;gap:6px;padding:8px 12px;margin:12px 12px 0;background:var(--bg,#F5F4F0);border:1px solid var(--b,#E2E0D8);border-radius:10px;flex-shrink:0}
.zoom-btn{width:28px;height:28px;border-radius:6px;border:1px solid var(--b,#E2E0D8);background:var(--s,#FFF);color:var(--m,#78716C);cursor:pointer;display:flex;align-items:center;justify-content:center;font-size:14px;font-weight:700;transition:all .15s}
.zoom-btn:hover{background:var(--bg,#F5F4F0);color:var(--t,#18181B);border-color:var(--gold,#B45309)}
.zoom-level{font-size:.7em;font-weight:600;color:var(--m,#78716C);min-width:36px;text-align:center;user-select:none}
.zoom-btn.reset{font-size:11px;width:auto;padding:0 8px}
/* Flow content wrapper */
.flow-content{padding:24px 16px 32px;display:flex;flex-direction:column;align-items:center;transform-origin:top center;transition:transform .15s ease}
/* Resizer handle */
.panel-resizer{width:6px;cursor:col-resize;background:var(--b,#E2E0D8);position:relative;flex-shrink:0;transition:background .2s;z-index:5}
.panel-resizer:hover,.panel-resizer.dragging{background:var(--gold,#B45309)}
.panel-resizer::after{content:'⋮';position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);color:var(--m,#78716C);font-size:14px;pointer-events:none}
.panel-resizer:hover::after,.panel-resizer.dragging::after{color:var(--s,#FFF)}
/* RIGHT: Explanations */
.explain-panel{padding:32px 40px;overflow-y:auto;flex:1;min-width:0}
/* ═══ FLOW NODES ═══ */
.flow-node{padding:12px 20px;border-radius:12px;font-size:.82em;font-weight:600;text-align:center;min-width:180px;max-width:240px;transition:transform .2s,box-shadow .2s;cursor:pointer}
.flow-node:hover{transform:translateY(-2px);box-shadow:0 6px 20px rgba(0,0,0,.1)}
.flow-node.active-step{box-shadow:0 0 0 2px var(--gold,#B45309),0 6px 20px rgba(180,83,9,.12)}
.node-start{background:linear-gradient(135deg,var(--gold,#B45309),var(--t,#18181B));color:#fff;border-radius:50px}
.node-process{background:var(--t,#18181B);border:1px solid var(--t,#18181B);color:#fff}
.node-decision{background:var(--s,#FFF);border:2px solid var(--gold,#B45309);color:var(--gold,#B45309)}
.node-tool{background:var(--gold-l,#FFFBEB);border:1px solid rgba(180,83,9,.2);color:var(--gold,#B45309);border-radius:10px}
.node-end{background:linear-gradient(135deg,#16a34a,#15803d);color:#fff;border-radius:50px}
.node-sub{font-size:.72em;font-weight:400;color:rgba(255,255,255,.7);margin-top:3px}
.node-process .node-sub{color:rgba(255,255,255,.5)}
.node-decision .node-sub{color:var(--m,#78716C)}
.flow-arrow{display:flex;flex-direction:column;align-items:center;padding:2px 0}
.arrow-line{width:2px;height:20px;background:var(--b,#E2E0D8)}
.arrow-head{width:0;height:0;border-left:5px solid transparent;border-right:5px solid transparent;border-top:7px solid var(--b,#E2E0D8)}
.arrow-label{font-size:.62em;color:var(--m,#78716C);font-weight:600;padding:2px 6px;background:var(--s,#FFF);border-radius:4px}
.flow-branch{display:flex;gap:16px;align-items:flex-start;justify-content:center;position:relative}
.flow-branch::before{content:'';position:absolute;top:0;left:50%;transform:translateX(-50%);width:calc(100% - 60px);height:2px;background:var(--b,#E2E0D8)}
.branch-item{display:flex;flex-direction:column;align-items:center}
.branch-connector{width:2px;height:12px;background:var(--b,#E2E0D8)}
.loop-indicator{position:relative;width:100%;max-width:260px;border:2px dashed rgba(180,83,9,.25);border-radius:14px;padding:18px 12px;display:flex;flex-direction:column;align-items:center}
.loop-label{position:absolute;top:-9px;left:12px;background:var(--s,#FFF);padding:0 6px;font-size:.6em;font-weight:700;color:var(--gold,#B45309);letter-spacing:.5px}
/* ═══ EXPLANATION CARDS ═══ */
.explain-card{padding:24px 28px;background:var(--s,#FFF);border:1px solid var(--b,#E2E0D8);border-radius:14px;margin-bottom:20px;transition:border-color .2s;scroll-margin-top:20px}
.explain-card:hover{border-color:var(--gold,#B45309)}
.explain-card.highlight{border-color:var(--gold,#B45309);box-shadow:0 0 20px rgba(180,83,9,.06)}
/* Step 5 special highlight */
.explain-card.core-highlight{border:2px solid #dc2626;background:linear-gradient(135deg,rgba(220,38,38,.03),rgba(234,179,8,.02));box-shadow:0 0 30px rgba(220,38,38,.05);position:relative}
.explain-card.core-highlight::before{content:'🔥 CORE';position:absolute;top:-10px;right:20px;background:linear-gradient(135deg,#dc2626,#ea580c);color:#fff;font-size:.6em;font-weight:800;letter-spacing:1.5px;padding:3px 12px;border-radius:999px}
.explain-card.core-highlight h4 .step-badge{background:rgba(220,38,38,.1);color:#dc2626;border-color:rgba(220,38,38,.2)}
.query-example{background:var(--bg,#F5F4F0);border:1px solid var(--b,#E2E0D8);border-radius:10px;padding:14px 18px;margin:10px 0}
.query-example .q-label{font-size:.7em;font-weight:700;color:#dc2626;text-transform:uppercase;letter-spacing:1px;margin-bottom:6px}
.query-example .q-input{font-size:.9em;color:var(--t,#18181B);margin-bottom:8px;padding:8px 12px;background:rgba(220,38,38,.04);border-radius:8px;border-left:3px solid #dc2626}
.query-example .q-output{display:flex;flex-wrap:wrap;gap:6px}
.query-example .q-tag{font-size:.75em;padding:3px 10px;border-radius:999px;font-weight:600}
.q-tag.cat{background:rgba(22,163,74,.08);color:#16a34a;border:1px solid rgba(22,163,74,.15)}
.q-tag.color{background:rgba(124,58,237,.08);color:#7c3aed;border:1px solid rgba(124,58,237,.15)}
.q-tag.style{background:rgba(234,179,8,.08);color:#ca8a04;border:1px solid rgba(234,179,8,.15)}
.q-tag.price{background:rgba(220,38,38,.08);color:#dc2626;border:1px solid rgba(220,38,38,.15)}
.q-tag.size{background:rgba(14,165,233,.08);color:#0ea5e9;border:1px solid rgba(14,165,233,.15)}
.q-tag.gender{background:rgba(234,179,8,.08);color:#ca8a04;border:1px solid rgba(234,179,8,.15)}
.q-tag.occasion{background:rgba(236,72,153,.08);color:#ec4899;border:1px solid rgba(236,72,153,.15)}
.q-arrow{color:var(--m,#78716C);font-weight:700;margin:4px 0;font-size:.8em}
.product-preview{border:1px solid var(--b,#E2E0D8);border-radius:12px;overflow:hidden;margin:16px 0;max-width:100%}
.product-preview img{width:100%;display:block}
.product-preview .preview-caption{padding:10px 14px;font-size:.78em;color:var(--m,#78716C);background:var(--bg,#F5F4F0);border-top:1px solid var(--b,#E2E0D8)}
.product-preview .preview-caption strong{color:var(--t,#18181B)}
.explain-card h4{font-size:1em;font-weight:700;color:var(--t,#18181B);margin-bottom:12px;display:flex;align-items:center;gap:8px}
.explain-card h4 .step-badge{font-size:.65em;padding:2px 8px;border-radius:999px;font-weight:600;background:var(--gold-l,#FFFBEB);color:var(--gold,#B45309);border:1px solid rgba(180,83,9,.2)}
.explain-card p{font-size:.88em;color:var(--m,#78716C);line-height:1.75;margin-bottom:10px}
.explain-card p strong{color:var(--t,#18181B)}
.explain-card p em{color:var(--gold,#B45309);font-style:normal}
.why-box{background:linear-gradient(135deg,var(--gold-l,#FFFBEB),rgba(234,179,8,.04));border:1px solid rgba(180,83,9,.12);border-left:3px solid var(--gold,#B45309);border-radius:0 10px 10px 0;padding:14px 18px;margin-top:14px;font-size:.85em;color:var(--t,#18181B);line-height:1.7}
.why-box strong{color:var(--t,#18181B)}
.why-label{font-size:.7em;font-weight:700;text-transform:uppercase;letter-spacing:1px;color:var(--gold,#B45309);margin-bottom:5px}
/* Tool grid */
.tool-grid-inline{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin:14px 0}
.tool-chip{padding:12px 14px;background:var(--bg,#F5F4F0);border:1px solid var(--b,#E2E0D8);border-radius:10px;font-size:.82em;font-weight:500;color:var(--t,#18181B);display:flex;align-items:center;gap:8px;transition:all .2s}
.tool-chip:hover{border-color:var(--gold,#B45309);transform:translateY(-1px)}
.tool-chip .chip-icon{font-size:1.2em}
.tool-chip .chip-name{font-weight:600}
.tool-chip .chip-type{font-size:.7em;color:var(--m,#78716C);display:block;margin-top:2px}
.section-title{font-size:1.1em;font-weight:800;color:var(--t,#18181B);margin:32px 0 20px;padding-bottom:10px;border-bottom:1px solid var(--b,#E2E0D8);font-family:'Fraunces',serif}
/* Responsive */
@media(max-width:1000px){
.panels{grid-template-columns:1fr}
.flow-panel{display:none}
.sidebar{width:64px}
.sidebar .brand-text,.sidebar .nav-group-label,.sidebar .nav-item span:not(.nav-icon),.sidebar .nav-badge,.sidebar-footer .version-text{display:none}
.sidebar .nav-item{justify-content:center;padding:10px}
.sidebar .sidebar-brand{justify-content:center;padding:24px 12px}
.main{margin-left:64px}
.sidebar-toggle{display:none}
.tool-grid-inline{grid-template-columns:repeat(2,1fr)}
}
/* ═══ IFRAME MODE ═══ */
html.in-iframe .sidebar{display:none!important}
html.in-iframe .sidebar-toggle{display:none!important}
html.in-iframe .main{margin-left:0!important}
......@@ -7,898 +7,8 @@
<title>Sơ đồ hoạt động - Canifa AI</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&display=swap"
rel="stylesheet">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Inter', sans-serif;
background: #0b0e14;
color: #c9d1d9;
min-height: 100vh;
display: flex;
}
/* ═══ SIDEBAR ═══ */
.sidebar {
width: 260px;
min-height: 100vh;
background: #0d1117;
border-right: 1px solid #1b2030;
display: flex;
flex-direction: column;
position: fixed;
top: 0;
left: 0;
z-index: 100;
transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
}
.sidebar.collapsed {
width: 64px;
}
.sidebar.collapsed .brand-text,
.sidebar.collapsed .nav-group-label,
.sidebar.collapsed .nav-item span:not(.nav-icon),
.sidebar.collapsed .nav-badge,
.sidebar.collapsed .sidebar-footer .version-text {
display: none;
}
.sidebar.collapsed .nav-item {
justify-content: center;
padding: 10px;
}
.sidebar.collapsed .sidebar-brand {
justify-content: center;
padding: 24px 12px;
}
.sidebar.collapsed .version-info {
justify-content: center;
}
.sidebar-brand {
padding: 24px 20px;
border-bottom: 1px solid #1b2030;
display: flex;
align-items: center;
gap: 12px;
}
.brand-icon {
width: 40px;
height: 40px;
background: linear-gradient(135deg, #667eea, #764ba2);
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.3em;
flex-shrink: 0;
cursor: pointer;
transition: transform 0.2s;
}
.brand-icon:hover {
transform: scale(1.08);
}
.brand-text h2 {
font-size: 1em;
font-weight: 700;
color: #e6edf3;
white-space: nowrap;
}
.brand-text span {
font-size: 0.7em;
color: #484f58;
font-weight: 500;
white-space: nowrap;
}
.nav-group {
padding: 16px 12px 8px;
}
.nav-group+.nav-group {
border-top: 1px solid #1b2030;
}
.nav-group-label {
font-size: 0.65em;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 1.5px;
color: #484f58;
padding: 0 12px;
margin-bottom: 8px;
white-space: nowrap;
}
.nav-item {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 16px;
border-radius: 8px;
color: #8b949e;
text-decoration: none;
font-size: 0.88em;
font-weight: 500;
transition: all 0.2s;
margin-bottom: 2px;
cursor: pointer;
position: relative;
white-space: nowrap;
}
.nav-item:hover {
background: #161b22;
color: #e6edf3;
}
.nav-item.active {
background: linear-gradient(135deg, rgba(102, 126, 234, 0.15), rgba(118, 75, 162, 0.1));
color: #a78bfa;
font-weight: 600;
}
.nav-item.active::before {
content: '';
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 3px;
height: 20px;
background: linear-gradient(180deg, #667eea, #764ba2);
border-radius: 0 3px 3px 0;
}
.nav-icon {
font-size: 1.1em;
width: 22px;
text-align: center;
flex-shrink: 0;
}
.nav-badge {
margin-left: auto;
font-size: 0.7em;
padding: 2px 8px;
border-radius: 999px;
font-weight: 600;
}
.badge-live {
background: rgba(76, 175, 80, 0.15);
color: #4caf50;
border: 1px solid rgba(76, 175, 80, 0.25);
}
.badge-new {
background: rgba(102, 126, 234, 0.15);
color: #667eea;
border: 1px solid rgba(102, 126, 234, 0.25);
}
.badge-beta {
background: rgba(255, 152, 0, 0.15);
color: #ff9800;
border: 1px solid rgba(255, 152, 0, 0.25);
}
.badge-dev {
background: rgba(167, 139, 250, 0.15);
color: #a78bfa;
border: 1px solid rgba(167, 139, 250, 0.25);
}
.sidebar-footer {
margin-top: auto;
padding: 16px;
border-top: 1px solid #1b2030;
}
.version-info {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
background: #161b22;
border-radius: 8px;
border: 1px solid #1b2030;
}
.version-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #56d364;
box-shadow: 0 0 8px rgba(86, 211, 100, 0.4);
flex-shrink: 0;
}
.version-text {
font-size: 0.75em;
color: #8b949e;
white-space: nowrap;
}
/* ═══ TOGGLE ═══ */
.sidebar-toggle {
position: fixed;
top: 28px;
left: 248px;
z-index: 150;
width: 24px;
height: 24px;
border-radius: 50%;
border: 1px solid #30363d;
background: #161b22;
color: #8b949e;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
transition: left 0.3s cubic-bezier(0.4, 0, 0.2, 1), transform 0.2s, background 0.2s;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
.sidebar-toggle:hover {
background: #1f2937;
color: #e6edf3;
transform: scale(1.1);
}
.sidebar-toggle.collapsed {
left: 52px;
}
.sidebar-toggle.collapsed .toggle-icon {
transform: rotate(180deg);
}
.toggle-icon {
transition: transform 0.3s;
display: flex;
}
/* ═══ MAIN ═══ */
.main {
margin-left: 260px;
flex: 1;
min-height: 100vh;
transition: margin-left 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.main.expanded {
margin-left: 64px;
}
.topbar {
padding: 24px 40px;
border-bottom: 1px solid #1b2030;
}
.topbar h1 {
font-size: 1.5em;
font-weight: 800;
color: #e6edf3;
}
.topbar p {
font-size: 0.85em;
color: #484f58;
margin-top: 4px;
}
/* ═══ LAYOUT: 2 PANELS ═══ */
.panels {
display: flex;
min-height: calc(100vh - 80px);
}
/* LEFT: Flow diagram (sticky) */
.flow-panel {
width: 300px;
min-width: 180px;
max-width: 80vw;
border-right: none;
position: sticky;
top: 0;
height: calc(100vh - 80px);
overflow: auto;
flex-shrink: 0;
display: flex;
flex-direction: column;
}
.flow-panel::-webkit-scrollbar {
width: 4px;
}
.flow-panel::-webkit-scrollbar-thumb {
background: #30363d;
border-radius: 4px;
}
/* Zoom controls */
.zoom-controls {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
margin: 12px 12px 0;
background: #161b22;
border: 1px solid #1b2030;
border-radius: 10px;
flex-shrink: 0;
}
.zoom-btn {
width: 28px;
height: 28px;
border-radius: 6px;
border: 1px solid #30363d;
background: #0d1117;
color: #8b949e;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 700;
transition: all 0.15s;
}
.zoom-btn:hover {
background: #1f2937;
color: #e6edf3;
border-color: #667eea;
}
.zoom-level {
font-size: 0.7em;
font-weight: 600;
color: #8b949e;
min-width: 36px;
text-align: center;
user-select: none;
}
.zoom-btn.reset {
font-size: 11px;
width: auto;
padding: 0 8px;
}
/* Flow content wrapper (zoom target) */
.flow-content {
padding: 24px 16px 32px;
display: flex;
flex-direction: column;
align-items: center;
transform-origin: top center;
transition: transform 0.15s ease;
}
/* Resizer handle */
.panel-resizer {
width: 6px;
cursor: col-resize;
background: #1b2030;
position: relative;
flex-shrink: 0;
transition: background 0.2s;
z-index: 5;
}
.panel-resizer:hover,
.panel-resizer.dragging {
background: #667eea;
}
.panel-resizer::after {
content: '⋮';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: #484f58;
font-size: 14px;
pointer-events: none;
}
.panel-resizer:hover::after,
.panel-resizer.dragging::after {
color: #fff;
}
/* RIGHT: Explanations */
.explain-panel {
padding: 32px 40px;
overflow-y: auto;
flex: 1;
min-width: 0;
}
/* ═══ FLOW NODES ═══ */
.flow-node {
padding: 12px 20px;
border-radius: 12px;
font-size: 0.82em;
font-weight: 600;
text-align: center;
min-width: 180px;
max-width: 240px;
transition: transform 0.2s, box-shadow 0.2s;
cursor: pointer;
}
.flow-node:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.4);
}
.flow-node.active-step {
box-shadow: 0 0 0 2px #667eea, 0 6px 20px rgba(102, 126, 234, 0.2);
}
.node-start {
background: linear-gradient(135deg, #667eea, #764ba2);
color: #fff;
border-radius: 50px;
}
.node-process {
background: #161b22;
border: 1px solid #30363d;
color: #e6edf3;
}
.node-decision {
background: #161b22;
border: 2px solid #ff9800;
color: #ff9800;
}
.node-tool {
background: rgba(102, 126, 234, 0.1);
border: 1px solid rgba(102, 126, 234, 0.3);
color: #667eea;
border-radius: 10px;
}
.node-end {
background: linear-gradient(135deg, #56d364, #2ea043);
color: #fff;
border-radius: 50px;
}
.node-sub {
font-size: 0.72em;
font-weight: 400;
color: #8b949e;
margin-top: 3px;
}
.node-process .node-sub {
color: #484f58;
}
.flow-arrow {
display: flex;
flex-direction: column;
align-items: center;
padding: 2px 0;
}
.arrow-line {
width: 2px;
height: 20px;
background: #30363d;
}
.arrow-head {
width: 0;
height: 0;
border-left: 5px solid transparent;
border-right: 5px solid transparent;
border-top: 7px solid #30363d;
}
.arrow-label {
font-size: 0.62em;
color: #484f58;
font-weight: 600;
padding: 2px 6px;
background: #0b0e14;
border-radius: 4px;
}
.flow-branch {
display: flex;
gap: 16px;
align-items: flex-start;
justify-content: center;
position: relative;
}
.flow-branch::before {
content: '';
position: absolute;
top: 0;
left: 50%;
transform: translateX(-50%);
width: calc(100% - 60px);
height: 2px;
background: #30363d;
}
.branch-item {
display: flex;
flex-direction: column;
align-items: center;
}
.branch-connector {
width: 2px;
height: 12px;
background: #30363d;
}
.loop-indicator {
position: relative;
width: 100%;
max-width: 260px;
border: 2px dashed rgba(102, 126, 234, 0.25);
border-radius: 14px;
padding: 18px 12px;
display: flex;
flex-direction: column;
align-items: center;
}
.loop-label {
position: absolute;
top: -9px;
left: 12px;
background: #0b0e14;
padding: 0 6px;
font-size: 0.6em;
font-weight: 700;
color: #667eea;
letter-spacing: 0.5px;
}
/* ═══ EXPLANATION CARDS ═══ */
.explain-card {
padding: 24px 28px;
background: #0d1117;
border: 1px solid #1b2030;
border-radius: 14px;
margin-bottom: 20px;
transition: border-color 0.2s;
scroll-margin-top: 20px;
}
.explain-card:hover {
border-color: #30363d;
}
.explain-card.highlight {
border-color: #667eea;
box-shadow: 0 0 20px rgba(102, 126, 234, 0.08);
}
/* ═══ STEP 5 SPECIAL HIGHLIGHT ═══ */
.explain-card.core-highlight {
border: 2px solid #f85149;
background: linear-gradient(135deg, rgba(248, 81, 73, 0.04), rgba(255, 152, 0, 0.03));
box-shadow: 0 0 30px rgba(248, 81, 73, 0.08);
position: relative;
}
.explain-card.core-highlight::before {
content: '🔥 CORE';
position: absolute;
top: -10px;
right: 20px;
background: linear-gradient(135deg, #f85149, #ff6b35);
color: #fff;
font-size: 0.6em;
font-weight: 800;
letter-spacing: 1.5px;
padding: 3px 12px;
border-radius: 999px;
}
.explain-card.core-highlight h4 .step-badge {
background: rgba(248, 81, 73, 0.15);
color: #f85149;
border-color: rgba(248, 81, 73, 0.3);
}
.query-example {
background: #0b0e14;
border: 1px solid #1e2530;
border-radius: 10px;
padding: 14px 18px;
margin: 10px 0;
}
.query-example .q-label {
font-size: 0.7em;
font-weight: 700;
color: #f85149;
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 6px;
}
.query-example .q-input {
font-size: 0.9em;
color: #e6edf3;
margin-bottom: 8px;
padding: 8px 12px;
background: rgba(248, 81, 73, 0.06);
border-radius: 8px;
border-left: 3px solid #f85149;
}
.query-example .q-output {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.query-example .q-tag {
font-size: 0.75em;
padding: 3px 10px;
border-radius: 999px;
font-weight: 600;
}
.q-tag.cat {
background: rgba(86, 211, 100, 0.12);
color: #56d364;
border: 1px solid rgba(86, 211, 100, 0.2);
}
.q-tag.color {
background: rgba(167, 139, 250, 0.12);
color: #a78bfa;
border: 1px solid rgba(167, 139, 250, 0.2);
}
.q-tag.style {
background: rgba(255, 152, 0, 0.12);
color: #ff9800;
border: 1px solid rgba(255, 152, 0, 0.2);
}
.q-tag.price {
background: rgba(248, 81, 73, 0.12);
color: #f85149;
border: 1px solid rgba(248, 81, 73, 0.2);
}
.q-tag.size {
background: rgba(56, 189, 248, 0.12);
color: #38bdf8;
border: 1px solid rgba(56, 189, 248, 0.2);
}
.q-tag.gender {
background: rgba(251, 191, 36, 0.12);
color: #fbbf24;
border: 1px solid rgba(251, 191, 36, 0.2);
}
.q-tag.occasion {
background: rgba(244, 114, 182, 0.12);
color: #f472b6;
border: 1px solid rgba(244, 114, 182, 0.2);
}
.q-arrow {
color: #484f58;
font-weight: 700;
margin: 4px 0;
font-size: 0.8em;
}
.product-preview {
border: 1px solid #1e2530;
border-radius: 12px;
overflow: hidden;
margin: 16px 0;
max-width: 100%;
}
.product-preview img {
width: 100%;
display: block;
}
.product-preview .preview-caption {
padding: 10px 14px;
font-size: 0.78em;
color: #8b949e;
background: #0b0e14;
border-top: 1px solid #1e2530;
}
.product-preview .preview-caption strong {
color: #c9d1d9;
}
.explain-card h4 {
font-size: 1em;
font-weight: 700;
color: #e6edf3;
margin-bottom: 12px;
display: flex;
align-items: center;
gap: 8px;
}
.explain-card h4 .step-badge {
font-size: 0.65em;
padding: 2px 8px;
border-radius: 999px;
font-weight: 600;
background: rgba(102, 126, 234, 0.12);
color: #667eea;
border: 1px solid rgba(102, 126, 234, 0.2);
}
.explain-card p {
font-size: 0.88em;
color: #8b949e;
line-height: 1.75;
margin-bottom: 10px;
}
.explain-card p strong {
color: #c9d1d9;
}
.explain-card p em {
color: #a78bfa;
font-style: normal;
}
.why-box {
background: linear-gradient(135deg, rgba(102, 126, 234, 0.06), rgba(118, 75, 162, 0.04));
border: 1px solid rgba(102, 126, 234, 0.15);
border-left: 3px solid #667eea;
border-radius: 0 10px 10px 0;
padding: 14px 18px;
margin-top: 14px;
font-size: 0.85em;
color: #a78bfa;
line-height: 1.7;
}
.why-box strong {
color: #c4b5fd;
}
.why-label {
font-size: 0.7em;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 1px;
color: #667eea;
margin-bottom: 5px;
}
/* Tool grid inside explanation */
.tool-grid-inline {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
margin: 14px 0;
}
.tool-chip {
padding: 12px 14px;
background: #161b22;
border: 1px solid #1b2030;
border-radius: 10px;
font-size: 0.82em;
font-weight: 500;
color: #c9d1d9;
display: flex;
align-items: center;
gap: 8px;
transition: all 0.2s;
}
.tool-chip:hover {
border-color: #667eea;
transform: translateY(-1px);
}
.tool-chip .chip-icon {
font-size: 1.2em;
}
.tool-chip .chip-name {
font-weight: 600;
}
.tool-chip .chip-type {
font-size: 0.7em;
color: #484f58;
display: block;
margin-top: 2px;
}
.section-title {
font-size: 1.1em;
font-weight: 800;
color: #e6edf3;
margin: 32px 0 20px;
padding-bottom: 10px;
border-bottom: 1px solid #1b2030;
}
@media (max-width: 1000px) {
.panels {
grid-template-columns: 1fr;
}
.flow-panel {
display: none;
}
.sidebar {
width: 64px;
}
.sidebar .brand-text,
.sidebar .nav-group-label,
.sidebar .nav-item span:not(.nav-icon),
.sidebar .nav-badge,
.sidebar-footer .version-text {
display: none;
}
.sidebar .nav-item {
justify-content: center;
padding: 10px;
}
.sidebar .sidebar-brand {
justify-content: center;
padding: 24px 12px;
}
.main {
margin-left: 64px;
}
.sidebar-toggle {
display: none;
}
.tool-grid-inline {
grid-template-columns: repeat(2, 1fr);
}
}
</style>
<link rel="stylesheet" href="/static/lab.css"> <script src="/static/frame-detect.js"></script>
<link rel="stylesheet" href="/static/flow.css">
</head>
<body>
......@@ -1050,8 +160,8 @@
<!-- Resizer -->
<div class="panel-resizer" id="panelResizer"></div>
<!-- ═══ RIGHT: EXPLANATIONS (full width) ═══ -->
<div class="explain-panel">
<!-- ═══ RIGHT: EXPLANATIONS (temporarily hidden) ═══ -->
<div class="explain-panel" style="display:none">
<!-- Step 1 -->
<div class="explain-card" id="step-1">
......
/* ═══ iframe detection + auto-redirect ═══
When a page is loaded inside main.html's iframe,
hide its own sidebar + adjust main margin.
When accessed directly (not in iframe), redirect to main.html. */
(function() {
if (window.self !== window.top) {
// Inside iframe - just mark it
document.documentElement.classList.add('in-iframe');
} else {
// Accessed directly - redirect to main.html unless already on main.html
var path = window.location.pathname;
var filename = path.split('/').pop();
// Don't redirect main.html itself, chatbot pages, or test pages
var noRedirect = ['main.html', 'chatbot.html', 'test_sp.html', 'test_db.html'];
if (filename && filename.endsWith('.html') && noRedirect.indexOf(filename) === -1) {
// Preserve any query string
var qs = window.location.search;
window.location.replace('/static/main.html?page=' + filename + (qs ? '&' + qs.substring(1) : ''));
}
}
})();
<!DOCTYPE html>
<!DOCTYPE html>
<html lang="vi">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Hướng dẫn sử dụng - Canifa AI System</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&display=swap" rel="stylesheet">
<style>
<link rel="stylesheet" href="/static/lab.css">
<link rel="stylesheet" href="/static/warm-override.css">
<script src="/static/frame-detect.js"></script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Inter', sans-serif; background: #0b0e14; color: #c9d1d9; min-height: 100vh; display: flex; }
body { font-family: 'Inter', sans-serif; background: var(--bg, #FAF6F0); color: var(--t, #2C1810); min-height: 100vh; display: flex; }
/* ═══ SIDEBAR ═══ */
.sidebar { width: 260px; min-height: 100vh; background: #0d1117; border-right: 1px solid #1b2030; display: flex; flex-direction: column; position: fixed; top: 0; left: 0; z-index: 100; }
.sidebar-brand { padding: 24px 20px; border-bottom: 1px solid #1b2030; display: flex; align-items: center; gap: 12px; }
.sidebar { width: 260px; min-height: 100vh; background: var(--s, #FFFFFF); border-right: 1px solid var(--b, #E8DED0); display: flex; flex-direction: column; position: fixed; top: 0; left: 0; z-index: 100; }
.sidebar-brand { padding: 24px 20px; border-bottom: 1px solid var(--b, #E8DED0); display: flex; align-items: center; gap: 12px; }
.brand-icon { width: 40px; height: 40px; background: linear-gradient(135deg, #667eea, #764ba2); border-radius: 10px; display: flex; align-items: center; justify-content: center; font-size: 1.3em; flex-shrink: 0; }
.brand-text h2 { font-size: 1em; font-weight: 700; color: #e6edf3; }
.brand-text h2 { font-size: 1em; font-weight: 700; color: var(--t, #2C1810); }
.brand-text span { font-size: 0.7em; color: #484f58; font-weight: 500; }
.nav-group { padding: 16px 12px 8px; }
.nav-group-label { font-size: 0.65em; font-weight: 700; text-transform: uppercase; letter-spacing: 1.5px; color: #484f58; padding: 0 12px; margin-bottom: 8px; }
.nav-item { display: flex; align-items: center; gap: 12px; padding: 10px 16px; border-radius: 8px; color: #8b949e; text-decoration: none; font-size: 0.88em; font-weight: 500; transition: all 0.2s; margin-bottom: 2px; cursor: pointer; position: relative; }
.nav-item:hover { background: #161b22; color: #e6edf3; }
.nav-item { display: flex; align-items: center; gap: 12px; padding: 10px 16px; border-radius: 8px; color: var(--m, #6B5B4F); text-decoration: none; font-size: 0.88em; font-weight: 500; transition: all 0.2s; margin-bottom: 2px; cursor: pointer; position: relative; }
.nav-item:hover { background: var(--bg, #FAF6F0); color: var(--t, #2C1810); }
.nav-item.active { background: linear-gradient(135deg, rgba(102,126,234,0.15), rgba(118,75,162,0.1)); color: #a78bfa; font-weight: 600; }
.nav-item.active::before { content: ''; position: absolute; left: 0; top: 50%; transform: translateY(-50%); width: 3px; height: 20px; background: linear-gradient(180deg, #667eea, #764ba2); border-radius: 0 3px 3px 0; }
.nav-icon { font-size: 1.1em; width: 22px; text-align: center; flex-shrink: 0; }
......@@ -26,32 +29,32 @@
.badge-live { background: rgba(76,175,80,0.15); color: #4caf50; border: 1px solid rgba(76,175,80,0.25); }
.badge-new { background: rgba(102,126,234,0.15); color: #667eea; border: 1px solid rgba(102,126,234,0.25); }
.badge-beta { background: rgba(255,152,0,0.15); color: #ff9800; border: 1px solid rgba(255,152,0,0.25); }
.sidebar-footer { margin-top: auto; padding: 16px; border-top: 1px solid #1b2030; }
.version-info { display: flex; align-items: center; gap: 10px; padding: 10px 12px; background: #161b22; border-radius: 8px; border: 1px solid #1b2030; }
.sidebar-footer { margin-top: auto; padding: 16px; border-top: 1px solid var(--b, #E8DED0); }
.version-info { display: flex; align-items: center; gap: 10px; padding: 10px 12px; background: var(--bg, #FAF6F0); border-radius: 8px; border: 1px solid var(--b, #E8DED0); }
.version-dot { width: 8px; height: 8px; border-radius: 50%; background: #56d364; box-shadow: 0 0 8px rgba(86,211,100,0.4); }
.version-text { font-size: 0.75em; color: #8b949e; }
.version-text { font-size: 0.75em; color: var(--m, #6B5B4F); }
/* ═══ MAIN ═══ */
.main { margin-left: 260px; flex: 1; min-height: 100vh; display: flex; flex-direction: column; }
.topbar { padding: 24px 32px; border-bottom: 1px solid #1b2030; }
.topbar h1 { font-size: 1.5em; font-weight: 800; color: #e6edf3; }
.topbar { padding: 24px 32px; border-bottom: 1px solid var(--b, #E8DED0); }
.topbar h1 { font-size: 1.5em; font-weight: 800; color: var(--t, #2C1810); }
.topbar p { font-size: 0.85em; color: #484f58; margin-top: 2px; }
.content { padding: 24px 32px; flex: 1; max-width: 900px; }
/* ═══ GUIDE ═══ */
.section { margin-bottom: 36px; }
.section h2 { font-size: 1.1em; font-weight: 700; color: #e6edf3; margin-bottom: 8px; }
.section .desc { font-size: 0.84em; color: #8b949e; line-height: 1.6; margin-bottom: 16px; }
.card { background: #0d1117; border: 1px solid #1b2030; border-radius: 12px; padding: 20px; margin-bottom: 14px; }
.card h3 { font-size: 0.88em; font-weight: 700; color: #e6edf3; margin-bottom: 12px; }
.section h2 { font-size: 1.1em; font-weight: 700; color: var(--t, #2C1810); margin-bottom: 8px; }
.section .desc { font-size: 0.84em; color: var(--m, #6B5B4F); line-height: 1.6; margin-bottom: 16px; }
.card { background: var(--s, #FFFFFF); border: 1px solid var(--b, #E8DED0); border-radius: 12px; padding: 20px; margin-bottom: 14px; }
.card h3 { font-size: 0.88em; font-weight: 700; color: var(--t, #2C1810); margin-bottom: 12px; }
.tbl { width: 100%; border-collapse: collapse; font-size: 0.82em; }
.tbl th { text-align: left; padding: 9px 12px; color: #8b949e; font-weight: 600; border-bottom: 1px solid #1b2030; background: rgba(22,27,34,0.5); }
.tbl td { padding: 9px 12px; color: #c9d1d9; border-bottom: 1px solid rgba(27,32,48,0.5); }
.tbl th { text-align: left; padding: 9px 12px; color: var(--m, #6B5B4F); font-weight: 600; border-bottom: 1px solid var(--b, #E8DED0); background: rgba(22,27,34,0.5); }
.tbl td { padding: 9px 12px; color: var(--t, #2C1810); border-bottom: 1px solid rgba(27,32,48,0.5); }
.tbl tr:last-child td { border-bottom: none; }
.tbl tr:hover td { background: rgba(22,27,34,0.3); }
.tip { margin-top: 14px; padding: 12px 16px; background: rgba(102,126,234,0.08); border: 1px solid rgba(102,126,234,0.15); border-radius: 8px; font-size: 0.8em; color: #8b949e; line-height: 1.7; }
.tip { margin-top: 14px; padding: 12px 16px; background: rgba(102,126,234,0.08); border: 1px solid rgba(102,126,234,0.15); border-radius: 8px; font-size: 0.8em; color: var(--m, #6B5B4F); line-height: 1.7; }
.tip strong { color: #a78bfa; }
.divider { border: none; border-top: 1px solid #1b2030; margin: 32px 0; }
.divider { border: none; border-top: 1px solid var(--b, #E8DED0); margin: 32px 0; }
@media (max-width: 1024px) {
.sidebar { width: 64px; }
......
<!DOCTYPE html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Canifa History Viewer</title>
<style>
<link rel="stylesheet" href="/static/lab.css">
<link rel="stylesheet" href="/static/warm-override.css">
<script src="/static/frame-detect.js"></script>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
margin: 0;
padding: 0;
background-color: #1e1e1e;
color: #e0e0e0;
}
.nav-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 15px 30px;
display: flex;
justify-content: space-between;
align-items: center;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.3);
}
.nav-header h1 {
margin: 0;
color: white;
font-size: 1.5em;
}
.nav-links {
display: flex;
gap: 15px;
}
.nav-links a {
color: white;
text-decoration: none;
padding: 8px 16px;
border-radius: 6px;
background: rgba(255, 255, 255, 0.2);
transition: all 0.3s;
font-weight: 500;
}
.nav-links a:hover {
background: rgba(255, 255, 255, 0.3);
transform: translateY(-2px);
}
.nav-links a.active {
background: rgba(255, 255, 255, 0.4);
background-color: var(--bg, #FAF6F0);
color: var(--t, #2C1810);
}
.main-content {
......@@ -60,14 +24,14 @@
}
.container {
background: #2d2d2d;
background: var(--s, #fff);
padding: 20px;
border-radius: 12px;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.5);
box-shadow: 0 4px 15px rgba(0,0,0,.06);
display: flex;
flex-direction: column;
height: 90vh;
border: 1px solid #444;
border: 1px solid var(--b, #E8DED0);
}
.header {
......@@ -76,17 +40,17 @@
align-items: center;
margin-bottom: 20px;
padding-bottom: 10px;
border-bottom: 1px solid #444;
border-bottom: 1px solid var(--b, #E8DED0);
}
.header h2 {
margin: 0;
color: #fff;
color: var(--t, #2C1810);
}
.subtitle {
font-size: 0.85em;
color: #bdbdbd;
color: var(--m, #6B5B4F);
margin-top: 4px;
}
......@@ -99,15 +63,15 @@
input[type="text"] {
padding: 8px 12px;
border: 1px solid #555;
border: 1px solid var(--b, #E8DED0);
border-radius: 6px;
background: #3d3d3d;
color: #fff;
background: var(--bg, #FAF6F0);
color: var(--t, #2C1810);
}
button {
padding: 8px 16px;
background: #007acc;
background: var(--gold, #B8860B);
color: white;
border: none;
border-radius: 6px;
......@@ -124,10 +88,10 @@
flex: 1;
overflow-y: auto;
padding: 20px;
border: 1px solid #444;
border: 1px solid var(--b, #E8DED0);
border-radius: 8px;
margin-bottom: 20px;
background: #252526;
background: var(--s, #fff);
display: flex;
flex-direction: column;
gap: 10px;
......@@ -152,7 +116,7 @@
.sender-name {
font-size: 0.8em;
margin-bottom: 4px;
color: #aaa;
color: var(--f, #A89880);
margin-left: 4px;
margin-right: 4px;
}
......@@ -174,31 +138,31 @@
}
.message-meta span {
background: rgba(255, 255, 255, 0.08);
background: var(--bg, #FAF6F0);
padding: 2px 6px;
border-radius: 6px;
}
.message.user {
background: #007acc;
background: var(--gold, #B8860B);
color: white;
border-bottom-right-radius: 2px;
}
.message.bot {
background: #3e3e42;
color: #e0e0e0;
background: var(--bg, #FAF6F0);
color: var(--t, #2C1810);
border-bottom-left-radius: 2px;
border: 1px solid #555;
border: 1px solid var(--b, #E8DED0);
}
.message.system {
background: #3d2d2d;
background: rgba(220,38,38,.08);
color: #ff6b6b;
align-self: center;
font-size: 0.9em;
max-width: 90%;
border: 1px solid #552b2b;
border: 1px solid rgba(220,38,38,.2);
}
.timestamp {
......@@ -210,30 +174,30 @@
.raw-json {
margin-top: 10px;
background: #1c1c1c;
border: 1px dashed #444;
background: var(--bg, #FAF6F0);
border: 1px dashed var(--b, #E8DED0);
padding: 10px;
border-radius: 8px;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
font-size: 0.75em;
white-space: pre-wrap;
color: #d1d5db;
color: var(--m, #6B5B4F);
}
.pill {
display: inline-flex;
align-items: center;
gap: 6px;
background: rgba(255, 255, 255, 0.12);
background: var(--bg, #FAF6F0);
padding: 4px 8px;
border-radius: 999px;
font-size: 0.75em;
color: #e5e5e5;
color: var(--t, #2C1810);
}
.note {
font-size: 0.85em;
color: #bbb;
color: var(--m, #6B5B4F);
margin-top: 6px;
}
......@@ -244,9 +208,9 @@
.load-more-btn {
padding: 10px 20px;
background: #3e3e42;
color: #ccc;
border: 1px dashed #555;
background: var(--bg, #FAF6F0);
color: var(--m, #6B5B4F);
border: 1px dashed var(--b, #E8DED0);
border-radius: 6px;
cursor: pointer;
font-weight: 500;
......@@ -258,8 +222,8 @@
}
.load-more-btn:hover {
background: #4e4e52;
color: #fff;
background: var(--bg, #FAF6F0);
color: var(--t, #2C1810);
}
.load-more-btn.visible {
......@@ -269,16 +233,7 @@
</head>
<body>
<div class="nav-header">
<h1>🤖 Canifa AI System</h1>
<div class="nav-links">
<a href="/static/dashboard.html">📊 Dashboard</a>
<a href="/static/index.html">💬 Chatbot</a>
<a href="/static/test_sql.html">🤖 Text-to-SQL</a>
<a href="/static/test_db.html">🔍 DB Test</a>
<a href="/static/history.html" class="active">🧾 History</a>
</div>
</div>
<div class="main-content">
<div class="container">
......@@ -288,10 +243,10 @@
<div class="subtitle">Hiển thị lịch sử theo identity_key (DB raw)</div>
</div>
<div class="config-area">
<label style="font-size: 0.8em; color: #aaa;">Identity Key:</label>
<label style="font-size: 0.8em; color: var(--f, #A89880);">Identity Key:</label>
<input type="text" id="identityKey" placeholder="device_id hoặc user_id" style="width: 200px;">
<button onclick="loadHistory()">Fetch (20)</button>
<button onclick="clearResults()" style="background: #555;">Clear</button>
<button onclick="clearResults()" style="background: var(--f, #A89880);">Clear</button>
</div>
</div>
......
......@@ -5,54 +5,20 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Canifa Chatbot Test</title>
<style>
<link rel="stylesheet" href="/static/lab.css">
<link rel="stylesheet" href="/static/warm-override.css">
<script src="/static/frame-detect.js"></script>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
margin: 0;
padding: 0;
background-color: #1e1e1e;
color: #e0e0e0;
background-color: #F5F4F0;
color: #18181B;
}
/* Navigation Header */
.nav-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 15px 30px;
display: flex;
justify-content: space-between;
align-items: center;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.3);
}
.nav-header h1 {
margin: 0;
color: white;
font-size: 1.5em;
}
.nav-links {
display: flex;
gap: 15px;
}
.nav-links a {
color: white;
text-decoration: none;
padding: 8px 16px;
border-radius: 6px;
background: rgba(255, 255, 255, 0.2);
transition: all 0.3s;
font-weight: 500;
}
.nav-links a:hover {
background: rgba(255, 255, 255, 0.3);
transform: translateY(-2px);
}
.nav-links a.active {
background: rgba(255, 255, 255, 0.4);
}
.main-content {
max-width: 900px;
......@@ -61,14 +27,14 @@
}
.container {
background: #2d2d2d;
background: #FFFFFF;
padding: 20px;
border-radius: 12px;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.5);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
display: flex;
flex-direction: column;
height: 90vh;
border: 1px solid #444;
border: 1px solid #E2E0D8;
}
.header {
......@@ -77,12 +43,12 @@
align-items: center;
margin-bottom: 20px;
padding-bottom: 10px;
border-bottom: 1px solid #444;
border-bottom: 1px solid #E2E0D8;
}
.header h2 {
margin: 0;
color: #fff;
color: #18181B;
}
.config-area {
......@@ -93,10 +59,10 @@
input[type="text"] {
padding: 8px 12px;
border: 1px solid #555;
border: 1px solid #E2E0D8;
border-radius: 6px;
background: #3d3d3d;
color: #fff;
background: #F5F4F0;
color: #18181B;
}
button {
......@@ -115,7 +81,7 @@
}
button:disabled {
background: #555;
background: #D6D3D1;
cursor: not-allowed;
}
......@@ -123,10 +89,10 @@
flex: 1;
overflow-y: auto;
padding: 20px;
border: 1px solid #444;
border: 1px solid #E2E0D8;
border-radius: 8px;
margin-bottom: 20px;
background: #252526;
background: #FAFAF8;
display: flex;
flex-direction: column;
gap: 10px;
......@@ -151,7 +117,7 @@
.sender-name {
font-size: 0.8em;
margin-bottom: 4px;
color: #aaa;
color: #78716C;
margin-left: 4px;
margin-right: 4px;
}
......@@ -171,24 +137,25 @@
}
.message.bot {
background: #3e3e42;
color: #e0e0e0;
background: #FFFFFF;
color: #334155;
border-bottom-left-radius: 2px;
border: 1px solid #555;
border: 1px solid #E2E0D8;
box-shadow: 0 1px 2px rgba(0,0,0,.04);
}
.message.system {
background: #3d2d2d;
color: #ff6b6b;
background: #FEF2F2;
color: #DC2626;
align-self: center;
font-size: 0.9em;
max-width: 90%;
border: 1px solid #552b2b;
border: 1px solid #FECACA;
}
.message.rate-limit-error {
background: linear-gradient(135deg, #3d2d2d 0%, #2d2d3d 100%);
border: 1px solid #ff6b6b;
background: linear-gradient(135deg, #FEF2F2 0%, #FFF7ED 100%);
border: 1px solid #FECACA;
padding: 16px;
max-width: 350px;
}
......@@ -209,11 +176,11 @@
.input-area input {
flex: 1;
padding: 12px;
border: 1px solid #555;
border: 1px solid #E2E0D8;
border-radius: 8px;
font-size: 16px;
background: #3d3d3d;
color: #fff;
background: #F5F4F0;
color: #18181B;
}
.input-area input:focus {
......@@ -227,16 +194,16 @@
}
.load-more button {
background: #3e3e42;
color: #ccc;
background: #F5F4F0;
color: #78716C;
font-size: 0.85em;
width: 100%;
border: 1px dashed #555;
border: 1px dashed #E2E0D8;
}
.load-more button:hover {
background: #4e4e52;
color: #fff;
background: #E2E0D8;
color: #18181B;
}
.typing-indicator {
......@@ -254,16 +221,16 @@
}
::-webkit-scrollbar-track {
background: #2d2d2d;
background: #F5F4F0;
}
::-webkit-scrollbar-thumb {
background: #555;
background: #D6D3D1;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #666;
background: #A8A29E;
}
/* Product Cards Styling */
......@@ -273,15 +240,15 @@
gap: 15px;
margin-top: 15px;
padding-top: 15px;
border-top: 1px solid #555;
border-top: 1px solid #E2E0D8;
}
.product-card {
background: #3d3d3d;
background: #FFFFFF;
border-radius: 8px;
overflow: hidden;
transition: all 0.3s;
border: 1px solid #555;
border: 1px solid #E2E0D8;
display: flex;
flex-direction: column;
}
......@@ -296,7 +263,7 @@
width: 100%;
height: 200px;
object-fit: cover;
background: #2d2d2d;
background: #F5F4F0;
}
.product-card-body {
......@@ -315,7 +282,7 @@
.product-name {
font-size: 0.9em;
color: #fff;
color: #18181B;
margin-bottom: 10px;
line-height: 1.3;
flex-grow: 1;
......@@ -882,16 +849,7 @@
<body>
<!-- Navigation Header -->
<div class="nav-header">
<h1>🤖 Canifa AI System</h1>
<div class="nav-links">
<a href="/static/dashboard.html">📊 Dashboard</a>
<a href="/static/index.html" class="active">💬 Chatbot</a>
<a href="/static/test_sql.html">🤖 Text-to-SQL</a>
<a href="/static/test_db.html">🔍 DB Test</a>
<a href="/static/history.html">🧾 History</a>
</div>
</div>
<div class="main-content">
<div class="main-layout">
......
@import url('https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&family=Fraunces:ital,wght@0,400;0,600;1,400&display=swap');
*{margin:0;padding:0;box-sizing:border-box}
:root{
--bg:#F5F4F0;--s:#FFFFFF;--b:#E2E0D8;
--t:#18181B;--m:#78716C;--f:#C4C0B8;
--gold:#B45309;--gold-l:#FFFBEB;--gold-b:#FDE68A;
--diamond:#6D28D9;--diamond-l:#F5F3FF;
--green:#065F46;--green-l:#D1FAE5;
--red:#B91C1C;--red-l:#FEE2E2;
--blue:#1D4ED8;--blue-l:#DBEAFE;
--cyan:#0891B2;--cyan-l:#ECFEFF;
--orange:#EA580C;--orange-l:#FFF7ED;
--r:14px;--rs:8px;
}
body{font-family:'Outfit',sans-serif;background:var(--bg);color:var(--t);min-height:100vh;display:flex;flex-direction:column}
.topbar{background:var(--t);color:#fff;padding:0 24px;display:flex;align-items:center;height:52px;gap:20px;flex-shrink:0}
.logo{font-family:'Fraunces',serif;font-size:20px;letter-spacing:-.5px}
.logo em{font-style:italic;color:#FDE68A}
.mode-toggle{display:flex;gap:2px;margin-left:auto;background:rgba(255,255,255,.1);padding:3px;border-radius:20px}
.mode-btn{padding:5px 16px;border-radius:16px;border:none;font-size:12px;font-weight:500;cursor:pointer;transition:all .2s;font-family:inherit;color:rgba(255,255,255,.6);background:transparent}
.mode-btn.active{background:#fff;color:var(--t)}
.layout{flex:1;display:flex;overflow:hidden;min-height:0}
/* SIDEBAR */
.sidebar{width:280px;flex-shrink:0;background:var(--s);border-right:1px solid var(--b);display:flex;flex-direction:column;overflow:hidden}
.sb-section{padding:14px 16px;border-bottom:1px solid var(--b)}
.sb-title{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--m);margin-bottom:10px}
.sb-flow{padding:8px 12px;border-radius:8px;border:1.5px solid var(--b);margin-bottom:6px;cursor:pointer;transition:all .15s;display:flex;align-items:center;gap:8px;font-size:13px}
.sb-flow.active{border-color:var(--t);background:var(--bg)}
.sb-flow .dot{width:8px;height:8px;border-radius:50%;flex-shrink:0}
.sb-cases{flex:1;overflow-y:auto;padding:10px 16px}
.sb-cases::-webkit-scrollbar{width:3px}
.sb-cases::-webkit-scrollbar-thumb{background:var(--b);border-radius:2px}
.sb-case{padding:8px 10px;border-radius:8px;margin-bottom:4px;cursor:pointer;transition:all .12s;font-size:12px;display:flex;align-items:center;gap:8px}
.sb-case:hover{background:var(--bg)}
.sb-case.active{background:var(--bg);border:1px solid var(--b)}
.sb-case .oc-badge{font-size:10px;font-weight:700;padding:1px 6px;border-radius:10px;flex-shrink:0;margin-left:auto}
.oc-converted{background:var(--green-l);color:var(--green)}
.oc-intent{background:var(--gold-l);color:var(--gold)}
.oc-consider{background:var(--blue-l);color:var(--blue)}
.oc-friction{background:var(--orange-l);color:var(--orange)}
.oc-drop{background:var(--red-l);color:var(--red)}
.run-btn{width:100%;padding:12px;border-radius:var(--r);border:none;background:var(--t);color:#fff;font-size:14px;font-weight:700;cursor:pointer;font-family:inherit;transition:all .15s;letter-spacing:.02em;margin-top:6px}
.run-btn:hover{opacity:.9;transform:translateY(-1px)}
.filter-row{display:flex;gap:4px;flex-wrap:wrap;margin-top:8px}
.filter-pill{padding:3px 8px;border-radius:12px;font-size:10px;font-weight:600;cursor:pointer;border:1px solid var(--b);background:var(--s);color:var(--m);transition:all .12s}
.filter-pill.active{border-color:var(--t);color:var(--t);background:var(--bg)}
/* MAIN */
.main{flex:1;display:flex;flex-direction:column;overflow:hidden}
.main-tabs{display:flex;border-bottom:1px solid var(--b);background:var(--s);padding:0 20px}
.mtab{padding:13px 18px;font-size:13px;font-weight:500;cursor:pointer;color:var(--m);border-bottom:2px solid transparent;transition:all .15s;border:none;background:transparent;font-family:inherit}
.mtab.active{color:var(--t);border-bottom-color:var(--t)}
.main-content{flex:1;overflow-y:auto;padding:20px}
.mpanel{display:none}.mpanel.active{display:block}
@keyframes fadeUp{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}}
/* KPI CARDS */
.kpi-row{display:grid;grid-template-columns:repeat(5,1fr);gap:10px;margin-bottom:20px}
.kpi-card{background:var(--s);border:1px solid var(--b);border-radius:var(--r);padding:16px;text-align:center;animation:fadeUp .3s ease}
.kpi-card .num{font-size:28px;font-weight:700;font-family:'Fraunces',serif;line-height:1}
.kpi-card .lbl{font-size:11px;color:var(--m);margin-top:4px}
.kpi-card .delta{font-size:11px;font-weight:600;margin-top:4px}
/* SECTION HEADERS */
.sec-header{font-size:15px;font-weight:700;margin-bottom:12px;display:flex;align-items:center;gap:8px}
.sec-sub{font-size:12px;color:var(--m);font-weight:400}
/* BAR CHART */
.chart-row{display:grid;grid-template-columns:repeat(3,1fr);gap:12px;margin-bottom:20px}
.chart-box{background:var(--s);border:1px solid var(--b);border-radius:var(--r);padding:16px;animation:fadeUp .3s ease}
.chart-title{font-size:13px;font-weight:600;margin-bottom:12px}
.bar-group{margin-bottom:10px}
.bar-label{font-size:11px;color:var(--m);margin-bottom:4px;display:flex;justify-content:space-between}
.bar-track{height:24px;background:var(--bg);border-radius:6px;overflow:hidden;display:flex;gap:2px}
.bar-fill{height:100%;border-radius:6px;display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:700;color:#fff;transition:width .6s ease;min-width:0}
.bar-a{background:var(--blue)}.bar-b{background:var(--gold)}
/* INSIGHT CARD */
.insight-card{background:var(--s);border:1px solid var(--b);border-radius:var(--r);padding:16px;margin-bottom:16px;animation:fadeUp .4s ease}
.insight-title{font-size:14px;font-weight:700;margin-bottom:10px;display:flex;align-items:center;gap:8px}
.insight-row{font-size:12px;color:var(--m);line-height:1.8;padding:6px 0;border-bottom:1px dashed var(--b)}
.insight-row:last-child{border-bottom:none}
.insight-row b{color:var(--t)}
/* EXPORT BTN */
.export-btn{padding:7px 14px;border-radius:8px;border:1px solid var(--b);background:var(--s);font-size:12px;font-weight:600;cursor:pointer;font-family:inherit;transition:all .15s;margin-left:auto}
.export-btn:hover{background:var(--bg);border-color:var(--m)}
/* LOADING */
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.4}}
.loading{text-align:center;padding:30px;font-size:13px;color:var(--m);animation:pulse 1.2s infinite}
/* STATS BAR */
.stats-bar{display:flex;gap:6px;margin-bottom:16px;padding:10px 14px;background:var(--s);border:1px solid var(--b);border-radius:10px;font-size:12px;align-items:center;flex-wrap:wrap}
.stats-bar .stat{display:flex;align-items:center;gap:4px}
.stats-bar .sep{color:var(--b)}
/* TABLE */
.data-table{width:100%;border-collapse:collapse;font-size:13px;background:var(--s);border:1px solid var(--b);border-radius:var(--r);overflow:hidden}
.data-table th{padding:10px 14px;text-align:left;font-weight:600;font-size:12px;color:var(--m);background:var(--bg);border-bottom:1px solid var(--b)}
.data-table td{padding:10px 14px;border-bottom:1px solid var(--b)}
.data-table tr:last-child td{border-bottom:none}
.data-table .prog{height:6px;background:var(--bg);border-radius:3px;overflow:hidden;width:100px;display:inline-block;vertical-align:middle;margin-left:8px}
.data-table .prog-fill{height:100%;border-radius:3px}
/* FUNNEL */
.funnel{max-width:500px;margin:0 auto}
.funnel-step{display:flex;align-items:center;gap:12px;margin-bottom:2px}
.funnel-bar{height:36px;border-radius:8px;display:flex;align-items:center;padding:0 14px;font-size:12px;font-weight:600;color:#fff;transition:width .6s ease}
.funnel-label{font-size:12px;color:var(--m);width:160px;text-align:right;flex-shrink:0}
.funnel-pct{font-size:13px;font-weight:700;width:50px;flex-shrink:0}
/* TRANSCRIPT */
.transcript-header{background:var(--s);border:1px solid var(--b);border-radius:var(--r);padding:16px;margin-bottom:12px;display:flex;align-items:center;gap:14px}
.tr-av{width:44px;height:44px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:18px;flex-shrink:0}
.tr-info{flex:1}.tr-name{font-size:15px;font-weight:600}.tr-meta{font-size:12px;color:var(--m);margin-top:2px}
.tr-outcome{font-size:12px;font-weight:700;padding:4px 12px;border-radius:20px}
.score-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;margin-bottom:14px}
.score-item{background:var(--s);border:1px solid var(--b);border-radius:10px;padding:10px;text-align:center}
.score-val{font-size:22px;font-weight:700;font-family:'Fraunces',serif}
.score-lbl{font-size:10px;color:var(--m);margin-top:2px;line-height:1.3}
.transcript-chat{background:var(--s);border:1px solid var(--b);border-radius:var(--r);padding:16px;max-height:400px;overflow-y:auto}
.tc-msg{display:flex;gap:8px;margin-bottom:10px;align-items:flex-start}
.tc-msg.user{flex-direction:row-reverse}
.tc-bubble{max-width:75%;padding:9px 13px;border-radius:16px;font-size:13px;line-height:1.6}
.tc-msg.bot .tc-bubble{background:var(--bg);border:1px solid var(--b);border-bottom-left-radius:4px}
.tc-msg.user .tc-bubble{background:var(--t);color:#fff;border-bottom-right-radius:4px}
.tc-av{width:24px;height:24px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:700;flex-shrink:0;color:#fff;background:var(--t)}
/* PERSONA GRID */
.persona-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:12px}
.persona-card{background:var(--s);border:1px solid var(--b);border-radius:var(--r);padding:16px;transition:all .15s}
.persona-card:hover{border-color:var(--m);transform:translateY(-2px);box-shadow:0 4px 12px rgba(0,0,0,.06)}
.pc-head{display:flex;align-items:center;gap:10px;margin-bottom:10px}
.pc-emoji{width:40px;height:40px;border-radius:50%;background:var(--bg);display:flex;align-items:center;justify-content:center;font-size:20px;flex-shrink:0}
.pc-pname{font-size:14px;font-weight:600}
.pc-psub{font-size:11px;color:var(--m)}
.pc-detail{font-size:12px;color:var(--m);display:flex;flex-direction:column;gap:4px}
.pc-tag{display:inline-block;padding:2px 8px;border-radius:10px;font-size:10px;font-weight:600;margin-right:4px}
/* TWO COL */
.two-col{display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-bottom:20px}
/* NOTIF */
.notif{position:fixed;bottom:20px;left:50%;transform:translateX(-50%) translateY(10px);background:var(--t);color:#fff;padding:10px 20px;border-radius:20px;font-size:13px;opacity:0;transition:all .3s;z-index:999;pointer-events:none}
.notif.show{opacity:1;transform:translateX(-50%) translateY(0)}
/* EMPTY STATE */
.empty-state{text-align:center;padding:40px;color:var(--m)}
.empty-state .icon{font-size:48px;margin-bottom:12px}
.empty-state .title{font-size:15px;font-weight:600;color:var(--t);margin-bottom:6px}
.empty-state .desc{font-size:12px;line-height:1.7}
/* HEATMAP */
.heatmap{display:grid;gap:2px;margin-bottom:16px}
.hm-cell{border-radius:4px;padding:6px 8px;font-size:11px;font-weight:600;text-align:center;transition:all .2s}
.hm-cell:hover{transform:scale(1.05);box-shadow:0 2px 8px rgba(0,0,0,.1)}
.hm-header{font-weight:700;color:var(--m);background:transparent;font-size:10px;text-transform:uppercase}
/* DISTRIBUTION */
.dist-container{background:var(--s);border:1px solid var(--b);border-radius:var(--r);padding:16px;margin-bottom:16px}
.dist-bars{display:flex;align-items:flex-end;gap:4px;height:160px;padding:0 4px}
.dist-col{flex:1;display:flex;flex-direction:column;align-items:center;gap:2px}
.dist-bar{width:100%;border-radius:4px 4px 0 0;transition:height .6s ease;position:relative;min-height:2px;cursor:pointer}
.dist-bar:hover{opacity:.85;transform:scaleY(1.02)}
.dist-bar .dist-val{position:absolute;top:-18px;left:50%;transform:translateX(-50%);font-size:10px;font-weight:700;white-space:nowrap}
.dist-label{font-size:10px;color:var(--m);font-weight:600;margin-top:6px}
.dist-legend{display:flex;gap:16px;justify-content:center;margin-top:12px;font-size:11px}
.dist-legend-dot{width:10px;height:10px;border-radius:50%;display:inline-block;margin-right:4px}
/* RADAR CHART */
.radar-wrap{position:relative;width:280px;height:280px;margin:0 auto}
.radar-bg{position:absolute;inset:0}
.radar-axis{position:absolute;width:1px;background:var(--b);transform-origin:bottom center;left:50%;bottom:50%}
.radar-label{position:absolute;font-size:11px;font-weight:600;color:var(--m);white-space:nowrap}
.radar-polygon{position:absolute;inset:0}
.radar-polygon svg{width:100%;height:100%}
/* SENTIMENT JOURNEY */
.sj-chart{background:var(--s);border:1px solid var(--b);border-radius:var(--r);padding:16px;margin-bottom:14px}
.sj-canvas{position:relative;height:120px;display:flex;align-items:stretch}
.sj-grid{position:absolute;inset:0;display:flex;flex-direction:column;justify-content:space-between}
.sj-grid-line{height:1px;background:var(--b);opacity:.5}
.sj-dots{display:flex;align-items:flex-end;gap:0;flex:1;position:relative;z-index:1}
.sj-col{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:flex-end;position:relative;height:100%}
.sj-dot{width:10px;height:10px;border-radius:50%;position:absolute;transition:all .3s;cursor:pointer;z-index:2}
.sj-dot:hover{transform:scale(1.4);box-shadow:0 2px 8px rgba(0,0,0,.2)}
.sj-line{position:absolute;height:2px;z-index:1;transform-origin:left center}
.sj-zero{position:absolute;left:0;right:0;height:1px;background:var(--m);opacity:.3}
.sj-y-labels{position:absolute;left:-30px;top:0;bottom:0;display:flex;flex-direction:column;justify-content:space-between;font-size:9px;color:var(--m)}
/* LEADERBOARD */
.lb-card{background:var(--s);border:1px solid var(--b);border-radius:var(--r);padding:16px;margin-bottom:12px}
.lb-row{display:flex;align-items:center;gap:10px;padding:8px 10px;border-radius:8px;margin-bottom:4px;transition:all .15s;cursor:pointer}
.lb-row:hover{background:var(--bg)}
.lb-rank{font-size:18px;font-weight:700;font-family:'Fraunces',serif;width:30px;text-align:center;flex-shrink:0}
.lb-info{flex:1;min-width:0}
.lb-name{font-size:13px;font-weight:600}
.lb-sub{font-size:11px;color:var(--m)}
.lb-score{text-align:right}
.lb-score-val{font-size:18px;font-weight:700;font-family:'Fraunces',serif}
.lb-score-sub{font-size:10px;color:var(--m)}
.lb-gap{display:flex;align-items:center;gap:4px;padding:4px 8px;border-radius:6px;font-size:11px;font-weight:600}
/* FAILURE ANALYSIS */
.fail-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-bottom:16px}
.fail-card{background:var(--s);border:1px solid var(--b);border-radius:var(--r);padding:16px}
.fail-bar-row{display:flex;align-items:center;gap:8px;margin-bottom:8px}
.fail-bar-label{font-size:12px;width:140px;flex-shrink:0;text-align:right;color:var(--m)}
.fail-bar-track{flex:1;height:20px;background:var(--bg);border-radius:4px;overflow:hidden}
.fail-bar-fill{height:100%;border-radius:4px;display:flex;align-items:center;padding:0 6px;font-size:10px;font-weight:700;color:#fff;transition:width .6s ease}
.fail-bar-val{font-size:12px;font-weight:700;width:40px;flex-shrink:0}
/* A/B STATS */
.ab-stat-card{background:var(--s);border:1px solid var(--b);border-radius:var(--r);padding:16px;margin-bottom:12px}
.ab-stat-row{display:flex;align-items:center;gap:12px;margin-bottom:8px}
.ab-stat-label{font-size:12px;color:var(--m);width:120px;flex-shrink:0}
.ab-stat-val{font-size:16px;font-weight:700;font-family:'Fraunces',serif}
.ab-ci{display:flex;align-items:center;gap:4px;height:24px;margin:4px 0}
.ab-ci-bar{height:8px;border-radius:4px;position:relative}
.ab-ci-marker{width:2px;height:16px;position:absolute;top:-4px;border-radius:1px;background:var(--t)}
/* RESPONSIVE */
@media(max-width:1200px){.chart-row{grid-template-columns:repeat(2,1fr)}.kpi-row{grid-template-columns:repeat(3,1fr)}}
@media(max-width:900px){.layout{flex-direction:column}.sidebar{width:100%;max-height:300px}.two-col{grid-template-columns:1fr}}
<!DOCTYPE html>
<html lang="vi">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Canifa AI Platform</title>
<link rel="stylesheet" href="/static/lab.css">
<link rel="stylesheet" href="/static/dashboard.css">
<style>
/* ═══ MAIN LAYOUT OVERRIDES ═══ */
body{margin:0;display:flex;min-height:100vh}
.main{margin-left:240px;flex:1;display:flex;flex-direction:column;min-height:100vh;position:relative}
.content-frame{flex:1;border:none;width:100%;height:100%}
/* hide topbar since each page has its own */
.page-topbar{padding:0;margin:0;display:none}
@media(max-width:1024px){
.main{margin-left:64px}
}
</style>
</head>
<body>
<!-- ═══ SIDEBAR (single source of truth) ═══ -->
<aside class="sidebar" id="mainSidebar">
<div class="sidebar-brand">
<div class="brand-icon">🤖</div>
<div class="brand-text">
<h2>Canifa AI</h2>
<span>Admin Console</span>
</div>
</div>
<div class="nav-group">
<div class="nav-group-label">Main</div>
<a data-page="flow.html" class="nav-item" onclick="navigateTo(this)">
<span class="nav-icon">🔀</span>
<span>Sơ đồ hoạt động</span>
</a>
<a data-page="experiment_detail.html?id=exp_chatbot_prod" class="nav-item" onclick="navigateTo(this)">
<span class="nav-icon">💬</span>
<span>Chatbot</span>
<span class="nav-badge badge-live">LIVE</span>
</a>
<a data-page="history.html" class="nav-item" onclick="navigateTo(this)">
<span class="nav-icon">🧾</span>
<span>History</span>
</a>
<a data-page="product.html" class="nav-item" onclick="navigateTo(this)">
<span class="nav-icon">🏷️</span>
<span>Product Perf.</span>
</a>
<a data-page="sql-chart.html" class="nav-item" onclick="navigateTo(this)">
<span class="nav-icon">📊</span>
<span>AI Data Analyst</span>
<span class="nav-badge badge-beta">NEW</span>
</a>
</div>
<div class="nav-group">
<div class="nav-group-label">Workspace</div>
<a data-page="resources.html" class="nav-item" onclick="navigateTo(this)">
<span class="nav-icon">🔗</span>
<span>Resources</span>
</a>
<a data-page="notes.html" class="nav-item" onclick="navigateTo(this)">
<span class="nav-icon">📝</span>
<span>Team Notes</span>
</a>
<a data-page="changelog.html" class="nav-item" onclick="navigateTo(this)">
<span class="nav-icon">📋</span>
<span>Changelog</span>
</a>
<a data-page="guide.html" class="nav-item" onclick="navigateTo(this)">
<span class="nav-icon">📖</span>
<span>Hướng dẫn</span>
</a>
</div>
<div class="nav-group">
<div class="nav-group-label">Thử nghiệm</div>
<a data-page="test_sql.html" class="nav-item" onclick="navigateTo(this)">
<span class="nav-icon">🗄️</span>
<span>Text-to-SQL</span>
<span class="nav-badge badge-beta">BETA</span>
</a>
<a data-page="test_db.html" class="nav-item" onclick="navigateTo(this)">
<span class="nav-icon">🔍</span>
<span>DB Test</span>
<span class="nav-badge badge-beta">BETA</span>
</a>
<a data-page="feedback_demo.html" class="nav-item" onclick="navigateTo(this)">
<span class="nav-icon">📝</span>
<span>Feedback Demo</span>
<span class="nav-badge badge-new">NEW</span>
</a>
<a data-page="index.html" class="nav-item" onclick="navigateTo(this)">
<span class="nav-icon">💬</span>
<span>Chatbot (Dev)</span>
<span class="nav-badge badge-beta">DEV</span>
</a>
<a data-page="cache.html" class="nav-item" onclick="navigateTo(this)">
<span class="nav-icon">🗄️</span>
<span>Cache Manager</span>
</a>
</div>
<div class="nav-group">
<div class="nav-group-label">External</div>
<a href="/docs" target="_blank" class="nav-item">
<span class="nav-icon">📚</span>
<span>API Docs</span>
</a>
<a href="/redoc" target="_blank" class="nav-item">
<span class="nav-icon">📖</span>
<span>ReDoc</span>
</a>
</div>
<div class="sidebar-footer">
<div class="version-info">
<span class="version-dot"></span>
<span class="version-text"><strong>v2.5.0</strong> · Online</span>
</div>
</div>
</aside>
<!-- ═══ MAIN CONTENT ═══ -->
<div class="main">
<iframe id="contentFrame" class="content-frame" src="/static/product.html"></iframe>
</div>
<script>
// ═══ NAVIGATION ═══
function navigateTo(el) {
const page = el.getAttribute('data-page');
if (!page) return;
document.getElementById('contentFrame').src = '/static/' + page;
// Update active state
document.querySelectorAll('#mainSidebar .nav-item').forEach(n => n.classList.remove('active'));
el.classList.add('active');
// Update URL without reload
const pageName = page.split('?')[0].replace('.html','');
history.pushState({page}, '', '/static/main.html?page=' + page);
// Update title
document.title = (el.querySelector('span:nth-child(2)')?.textContent || 'Canifa AI') + ' — Canifa AI';
}
// ═══ INIT: Load page from URL param ═══
(function() {
const params = new URLSearchParams(window.location.search);
const page = params.get('page');
if (page) {
document.getElementById('contentFrame').src = '/static/' + page;
// Highlight active nav
document.querySelectorAll('#mainSidebar .nav-item[data-page]').forEach(el => {
if (el.getAttribute('data-page') === page) {
el.classList.add('active');
} else {
el.classList.remove('active');
}
});
} else {
// Default: dashboard active
const dashLink = document.querySelector('[data-page="product.html"]');
if (dashLink) dashLink.classList.add('active');
}
})();
// ═══ HANDLE BACK/FORWARD ═══
window.addEventListener('popstate', function(e) {
if (e.state && e.state.page) {
document.getElementById('contentFrame').src = '/static/' + e.state.page;
document.querySelectorAll('#mainSidebar .nav-item[data-page]').forEach(el => {
el.classList.toggle('active', el.getAttribute('data-page') === e.state.page);
});
}
});
</script>
</body>
</html>
<!DOCTYPE html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Team Notes - Canifa AI System</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&display=swap" rel="stylesheet">
<style>
<link rel="stylesheet" href="/static/lab.css">
<link rel="stylesheet" href="/static/warm-override.css">
<script src="/static/frame-detect.js"></script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Inter', sans-serif; background: #0b0e14; color: #c9d1d9; min-height: 100vh; display: flex; }
body { font-family: 'Inter', sans-serif; background: var(--bg, #FAF6F0); color: var(--t, #2C1810); min-height: 100vh; display: flex; }
/* ═══ SIDEBAR ═══ */
.sidebar { width: 260px; min-height: 100vh; background: #0d1117; border-right: 1px solid #1b2030; display: flex; flex-direction: column; position: fixed; top: 0; left: 0; z-index: 100; }
.sidebar-brand { padding: 24px 20px; border-bottom: 1px solid #1b2030; display: flex; align-items: center; gap: 12px; }
.sidebar { width: 260px; min-height: 100vh; background: var(--s, #FFFFFF); border-right: 1px solid var(--b, #E8DED0); display: flex; flex-direction: column; position: fixed; top: 0; left: 0; z-index: 100; }
.sidebar-brand { padding: 24px 20px; border-bottom: 1px solid var(--b, #E8DED0); display: flex; align-items: center; gap: 12px; }
.brand-icon { width: 40px; height: 40px; background: linear-gradient(135deg, #667eea, #764ba2); border-radius: 10px; display: flex; align-items: center; justify-content: center; font-size: 1.3em; flex-shrink: 0; }
.brand-text h2 { font-size: 1em; font-weight: 700; color: #e6edf3; }
.brand-text h2 { font-size: 1em; font-weight: 700; color: var(--t, #2C1810); }
.brand-text span { font-size: 0.7em; color: #484f58; font-weight: 500; }
.nav-group { padding: 16px 12px 8px; }
.nav-group-label { font-size: 0.65em; font-weight: 700; text-transform: uppercase; letter-spacing: 1.5px; color: #484f58; padding: 0 12px; margin-bottom: 8px; }
.nav-item { display: flex; align-items: center; gap: 12px; padding: 10px 16px; border-radius: 8px; color: #8b949e; text-decoration: none; font-size: 0.88em; font-weight: 500; transition: all 0.2s; margin-bottom: 2px; cursor: pointer; position: relative; }
.nav-item:hover { background: #161b22; color: #e6edf3; }
.nav-item { display: flex; align-items: center; gap: 12px; padding: 10px 16px; border-radius: 8px; color: var(--m, #6B5B4F); text-decoration: none; font-size: 0.88em; font-weight: 500; transition: all 0.2s; margin-bottom: 2px; cursor: pointer; position: relative; }
.nav-item:hover { background: var(--bg, #FAF6F0); color: var(--t, #2C1810); }
.nav-item.active { background: linear-gradient(135deg, rgba(102,126,234,0.15), rgba(118,75,162,0.1)); color: #a78bfa; font-weight: 600; }
.nav-item.active::before { content: ''; position: absolute; left: 0; top: 50%; transform: translateY(-50%); width: 3px; height: 20px; background: linear-gradient(180deg, #667eea, #764ba2); border-radius: 0 3px 3px 0; }
.nav-icon { font-size: 1.1em; width: 22px; text-align: center; flex-shrink: 0; }
......@@ -26,89 +29,89 @@
.badge-live { background: rgba(76,175,80,0.15); color: #4caf50; border: 1px solid rgba(76,175,80,0.25); }
.badge-new { background: rgba(102,126,234,0.15); color: #667eea; border: 1px solid rgba(102,126,234,0.25); }
.badge-beta { background: rgba(255,152,0,0.15); color: #ff9800; border: 1px solid rgba(255,152,0,0.25); }
.badge-count { background: rgba(88,166,255,0.15); color: #58a6ff; border: 1px solid rgba(88,166,255,0.2); }
.sidebar-footer { margin-top: auto; padding: 16px; border-top: 1px solid #1b2030; }
.version-info { display: flex; align-items: center; gap: 10px; padding: 10px 12px; background: #161b22; border-radius: 8px; border: 1px solid #1b2030; }
.badge-count { background: rgba(88,166,255,0.15); color: var(--gold, #B8860B); border: 1px solid rgba(88,166,255,0.2); }
.sidebar-footer { margin-top: auto; padding: 16px; border-top: 1px solid var(--b, #E8DED0); }
.version-info { display: flex; align-items: center; gap: 10px; padding: 10px 12px; background: var(--bg, #FAF6F0); border-radius: 8px; border: 1px solid var(--b, #E8DED0); }
.version-dot { width: 8px; height: 8px; border-radius: 50%; background: #56d364; box-shadow: 0 0 8px rgba(86,211,100,0.4); }
.version-text { font-size: 0.75em; color: #8b949e; }
.version-text { font-size: 0.75em; color: var(--m, #6B5B4F); }
/* ═══ MAIN ═══ */
.main { margin-left: 260px; flex: 1; min-height: 100vh; display: flex; flex-direction: column; }
.topbar { padding: 24px 32px; border-bottom: 1px solid #1b2030; display: flex; justify-content: space-between; align-items: center; }
.topbar h1 { font-size: 1.5em; font-weight: 800; color: #e6edf3; }
.topbar { padding: 24px 32px; border-bottom: 1px solid var(--b, #E8DED0); display: flex; justify-content: space-between; align-items: center; }
.topbar h1 { font-size: 1.5em; font-weight: 800; color: var(--t, #2C1810); }
.topbar p { font-size: 0.85em; color: #484f58; margin-top: 2px; }
.content { padding: 24px 32px; flex: 1; }
/* ═══ BUTTONS ═══ */
.btn { padding: 8px 16px; border-radius: 8px; font-size: 0.82em; font-weight: 600; border: 1px solid #1b2030; background: #161b22; color: #c9d1d9; cursor: pointer; transition: all 0.2s; }
.btn:hover { background: #1b2030; border-color: #30363d; }
.btn { padding: 8px 16px; border-radius: 8px; font-size: 0.82em; font-weight: 600; border: 1px solid var(--b, #E8DED0); background: var(--bg, #FAF6F0); color: var(--t, #2C1810); cursor: pointer; transition: all 0.2s; }
.btn:hover { background: var(--b, #E8DED0); border-color: var(--b, #E8DED0); }
.btn-primary { background: linear-gradient(135deg, #667eea, #764ba2); border: none; color: #fff; }
.btn-primary:hover { opacity: 0.9; transform: translateY(-1px); }
/* ═══ FILTERS ═══ */
.filter-bar { display: flex; gap: 8px; margin-bottom: 20px; flex-wrap: wrap; }
.filter-chip { padding: 6px 14px; border-radius: 20px; font-size: 0.78em; font-weight: 600; border: 1px solid #1b2030; background: transparent; color: #8b949e; cursor: pointer; transition: all 0.2s; }
.filter-chip:hover { background: #161b22; color: #e6edf3; }
.filter-chip { padding: 6px 14px; border-radius: 20px; font-size: 0.78em; font-weight: 600; border: 1px solid var(--b, #E8DED0); background: transparent; color: var(--m, #6B5B4F); cursor: pointer; transition: all 0.2s; }
.filter-chip:hover { background: var(--bg, #FAF6F0); color: var(--t, #2C1810); }
.filter-chip.active { background: rgba(102,126,234,0.15); color: #a78bfa; border-color: rgba(102,126,234,0.3); }
/* ═══ NOTES ═══ */
.notes-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(350px, 1fr)); gap: 14px; }
.note-card { background: #0d1117; border: 1px solid #1b2030; border-radius: 12px; padding: 18px; transition: all 0.25s; border-top: 3px solid var(--nc, #58a6ff); }
.note-card:hover { border-color: #30363d; transform: translateY(-2px); box-shadow: 0 8px 25px rgba(0,0,0,0.3); }
.note-card { background: var(--s, #FFFFFF); border: 1px solid var(--b, #E8DED0); border-radius: 12px; padding: 18px; transition: all 0.25s; border-top: 3px solid var(--nc, var(--gold, #B8860B)); }
.note-card:hover { border-color: var(--b, #E8DED0); transform: translateY(-2px); box-shadow: 0 8px 25px rgba(0,0,0,0.3); }
.note-card.pinned { border-color: rgba(227,179,65,0.3); border-top-color: #e3b341; }
.note-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 8px; }
.note-cat { font-size: 0.65em; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; padding: 2px 8px; border-radius: 4px; background: rgba(88,166,255,0.1); color: #58a6ff; }
.note-title { font-size: 0.95em; font-weight: 700; color: #e6edf3; margin-bottom: 6px; }
.note-body { font-size: 0.83em; color: #8b949e; line-height: 1.7; white-space: pre-wrap; word-break: break-word; }
.note-footer { display: flex; justify-content: space-between; align-items: center; margin-top: 12px; padding-top: 10px; border-top: 1px solid #1b2030; }
.note-cat { font-size: 0.65em; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; padding: 2px 8px; border-radius: 4px; background: rgba(88,166,255,0.1); color: var(--gold, #B8860B); }
.note-title { font-size: 0.95em; font-weight: 700; color: var(--t, #2C1810); margin-bottom: 6px; }
.note-body { font-size: 0.83em; color: var(--m, #6B5B4F); line-height: 1.7; white-space: pre-wrap; word-break: break-word; }
.note-footer { display: flex; justify-content: space-between; align-items: center; margin-top: 12px; padding-top: 10px; border-top: 1px solid var(--b, #E8DED0); }
.note-time { font-size: 0.7em; color: #484f58; }
.note-actions { display: flex; gap: 4px; }
.note-act-btn { width: 28px; height: 28px; border-radius: 6px; border: 1px solid #1b2030; background: transparent; color: #484f58; font-size: 0.78em; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: all 0.15s; }
.note-act-btn:hover { background: #161b22; color: #e6edf3; border-color: #30363d; }
.note-act-btn { width: 28px; height: 28px; border-radius: 6px; border: 1px solid var(--b, #E8DED0); background: transparent; color: #484f58; font-size: 0.78em; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: all 0.15s; }
.note-act-btn:hover { background: var(--bg, #FAF6F0); color: var(--t, #2C1810); border-color: var(--b, #E8DED0); }
.note-act-btn.pin-active { color: #e3b341; border-color: rgba(227,179,65,0.3); }
/* ═══ MODAL ═══ */
.modal-overlay { display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.7); backdrop-filter: blur(4px); z-index: 200; align-items: center; justify-content: center; }
.modal-overlay.show { display: flex; }
.modal { background: #161b22; border: 1px solid #1b2030; border-radius: 16px; width: 500px; max-width: 90vw; max-height: 85vh; overflow-y: auto; }
.modal-head { padding: 20px 24px; border-bottom: 1px solid #1b2030; display: flex; justify-content: space-between; align-items: center; }
.modal-head h3 { font-size: 1em; font-weight: 700; color: #e6edf3; }
.modal { background: var(--bg, #FAF6F0); border: 1px solid var(--b, #E8DED0); border-radius: 16px; width: 500px; max-width: 90vw; max-height: 85vh; overflow-y: auto; }
.modal-head { padding: 20px 24px; border-bottom: 1px solid var(--b, #E8DED0); display: flex; justify-content: space-between; align-items: center; }
.modal-head h3 { font-size: 1em; font-weight: 700; color: var(--t, #2C1810); }
.modal-close { width: 32px; height: 32px; border-radius: 8px; border: none; background: transparent; color: #484f58; font-size: 1.2em; cursor: pointer; display: flex; align-items: center; justify-content: center; }
.modal-close:hover { background: #0d1117; color: #f85149; }
.modal-close:hover { background: var(--s, #FFFFFF); color: var(--red, #DC2626); }
.modal-body { padding: 20px 24px; }
.modal-foot { padding: 16px 24px; border-top: 1px solid #1b2030; display: flex; justify-content: flex-end; gap: 10px; }
.modal-foot { padding: 16px 24px; border-top: 1px solid var(--b, #E8DED0); display: flex; justify-content: flex-end; gap: 10px; }
.form-group { margin-bottom: 14px; }
.form-label { font-size: 0.78em; font-weight: 600; color: #8b949e; margin-bottom: 6px; display: block; }
.form-input, .form-select, .form-textarea { width: 100%; padding: 10px 14px; border-radius: 8px; border: 1px solid #1b2030; background: #0d1117; color: #e6edf3; font-size: 0.88em; font-family: inherit; transition: border-color 0.2s; }
.form-label { font-size: 0.78em; font-weight: 600; color: var(--m, #6B5B4F); margin-bottom: 6px; display: block; }
.form-input, .form-select, .form-textarea { width: 100%; padding: 10px 14px; border-radius: 8px; border: 1px solid var(--b, #E8DED0); background: var(--s, #FFFFFF); color: var(--t, #2C1810); font-size: 0.88em; font-family: inherit; transition: border-color 0.2s; }
.form-input:focus, .form-select:focus, .form-textarea:focus { outline: none; border-color: #667eea; }
.form-textarea { min-height: 120px; resize: vertical; }
.form-select { cursor: pointer; }
.form-select option { background: #161b22; }
.form-select option { background: var(--bg, #FAF6F0); }
.color-dots { display: flex; gap: 8px; }
.color-dot { width: 28px; height: 28px; border-radius: 50%; cursor: pointer; border: 3px solid transparent; transition: all 0.2s; }
.color-dot:hover { transform: scale(1.15); }
.color-dot.active { border-color: #e6edf3; box-shadow: 0 0 10px rgba(255,255,255,0.2); }
.color-dot.active { border-color: var(--t, #2C1810); box-shadow: 0 0 10px rgba(255,255,255,0.2); }
.empty-state { text-align: center; padding: 60px 20px; color: #484f58; }
.empty-state .empty-icon { font-size: 3em; margin-bottom: 12px; opacity: 0.5; }
.header-row { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
.header-row h2 { font-size: 1.2em; font-weight: 700; color: #e6edf3; }
.header-row h2 { font-size: 1.2em; font-weight: 700; color: var(--t, #2C1810); }
/* ═══ USAGE GUIDE ═══ */
.guide { margin-top: 40px; border-top: 1px solid #1b2030; padding-top: 20px; }
.guide { margin-top: 40px; border-top: 1px solid var(--b, #E8DED0); padding-top: 20px; }
.guide-toggle { display: flex; align-items: center; gap: 8px; cursor: pointer; color: #484f58; font-size: 0.82em; font-weight: 600; background: none; border: none; padding: 6px 0; transition: color 0.2s; }
.guide-toggle:hover { color: #8b949e; }
.guide-toggle:hover { color: var(--m, #6B5B4F); }
.guide-toggle .arrow { transition: transform 0.2s; display: inline-block; }
.guide-toggle.open .arrow { transform: rotate(90deg); }
.guide-body { display: none; margin-top: 14px; padding: 20px; background: #0d1117; border: 1px solid #1b2030; border-radius: 12px; }
.guide-body { display: none; margin-top: 14px; padding: 20px; background: var(--s, #FFFFFF); border: 1px solid var(--b, #E8DED0); border-radius: 12px; }
.guide-body.show { display: block; }
.guide-body h4 { font-size: 0.85em; font-weight: 700; color: #e6edf3; margin: 0 0 10px; }
.guide-body h4 { font-size: 0.85em; font-weight: 700; color: var(--t, #2C1810); margin: 0 0 10px; }
.guide-body table { width: 100%; border-collapse: collapse; font-size: 0.8em; }
.guide-body th { text-align: left; padding: 8px 10px; color: #8b949e; font-weight: 600; border-bottom: 1px solid #1b2030; }
.guide-body td { padding: 8px 10px; color: #c9d1d9; border-bottom: 1px solid rgba(27,32,48,0.5); }
.guide-body th { text-align: left; padding: 8px 10px; color: var(--m, #6B5B4F); font-weight: 600; border-bottom: 1px solid var(--b, #E8DED0); }
.guide-body td { padding: 8px 10px; color: var(--t, #2C1810); border-bottom: 1px solid rgba(27,32,48,0.5); }
.guide-body tr:last-child td { border-bottom: none; }
.guide-tip { margin-top: 14px; padding: 10px 14px; background: rgba(102,126,234,0.08); border: 1px solid rgba(102,126,234,0.15); border-radius: 8px; font-size: 0.78em; color: #8b949e; line-height: 1.6; }
.guide-tip { margin-top: 14px; padding: 10px 14px; background: rgba(102,126,234,0.08); border: 1px solid rgba(102,126,234,0.15); border-radius: 8px; font-size: 0.78em; color: var(--m, #6B5B4F); line-height: 1.6; }
.guide-tip strong { color: #a78bfa; }
@media (max-width: 1024px) {
......@@ -196,11 +199,11 @@
</div>
<div class="form-group"><label class="form-label">Color</label>
<div class="color-dots">
<div class="color-dot active" style="background:#58a6ff" onclick="pickColor('#58a6ff',this)"></div>
<div class="color-dot active" style="background:var(--gold, #B8860B)" onclick="pickColor('var(--gold, #B8860B)',this)"></div>
<div class="color-dot" style="background:#a78bfa" onclick="pickColor('#a78bfa',this)"></div>
<div class="color-dot" style="background:#56d364" onclick="pickColor('#56d364',this)"></div>
<div class="color-dot" style="background:#e3b341" onclick="pickColor('#e3b341',this)"></div>
<div class="color-dot" style="background:#f85149" onclick="pickColor('#f85149',this)"></div>
<div class="color-dot" style="background:var(--red, #DC2626)" onclick="pickColor('var(--red, #DC2626)',this)"></div>
<div class="color-dot" style="background:#f778ba" onclick="pickColor('#f778ba',this)"></div>
</div>
</div>
......@@ -211,7 +214,7 @@
</div>
<script>
let data = [], activeFilter = 'all', selectedColor = '#58a6ff';
let data = [], activeFilter = 'all', selectedColor = 'var(--gold, #B8860B)';
function esc(s) { return s ? s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;') : ''; }
function fmtTime(iso) { if(!iso)return''; const d=new Date(iso),now=new Date(),diff=(now-d)/1000; if(diff<60)return'vừa xong'; if(diff<3600)return Math.floor(diff/60)+' phút trước'; if(diff<86400)return Math.floor(diff/3600)+' giờ trước'; if(diff<604800)return Math.floor(diff/86400)+' ngày trước'; return d.toLocaleDateString('vi-VN',{day:'2-digit',month:'2-digit',year:'numeric'}); }
const catIcons = {note:'📝',doc:'📄',todo:'✅',announcement:'📢'};
......@@ -231,7 +234,7 @@
if (!f.length) { grid.innerHTML = `<div class="empty-state"><div class="empty-icon">📝</div><p>No notes found.</p></div>`; return; }
grid.innerHTML = f.map(n=>`
<div class="note-card ${n.pinned?'pinned':''}" style="--nc:${n.color||'#58a6ff'}">
<div class="note-card ${n.pinned?'pinned':''}" style="--nc:${n.color||'var(--gold, #B8860B)'}">
<div class="note-header">
<span class="note-cat">${catIcons[n.category]||'📝'} ${n.category}</span>
${n.pinned?'<span style="font-size:0.8em">📌</span>':''}
......@@ -252,10 +255,10 @@
function filter(f,el) { activeFilter=f; document.querySelectorAll('.filter-chip').forEach(c=>c.classList.remove('active')); el.classList.add('active'); render(); }
function pickColor(c,el) { selectedColor=c; document.querySelectorAll('.color-dot').forEach(d=>d.classList.remove('active')); el.classList.add('active'); }
function openModal() { document.getElementById('modal').classList.add('show'); document.getElementById('editId').value=''; document.getElementById('fTitle').value=''; document.getElementById('fContent').value=''; document.getElementById('fCat').value='note'; document.getElementById('fPin').checked=false; selectedColor='#58a6ff'; document.querySelectorAll('.color-dot').forEach(d=>{d.classList.remove('active');if(d.style.background==='rgb(88, 166, 255)')d.classList.add('active');}); document.getElementById('modalTitle').textContent='📝 New Note'; setTimeout(()=>document.getElementById('fTitle').focus(),100); }
function openModal() { document.getElementById('modal').classList.add('show'); document.getElementById('editId').value=''; document.getElementById('fTitle').value=''; document.getElementById('fContent').value=''; document.getElementById('fCat').value='note'; document.getElementById('fPin').checked=false; selectedColor='var(--gold, #B8860B)'; document.querySelectorAll('.color-dot').forEach(d=>{d.classList.remove('active');if(d.style.background==='rgb(88, 166, 255)')d.classList.add('active');}); document.getElementById('modalTitle').textContent='📝 New Note'; setTimeout(()=>document.getElementById('fTitle').focus(),100); }
function closeModal() { document.getElementById('modal').classList.remove('show'); }
function edit(id) { const n=data.find(x=>x.id===id); if(!n)return; document.getElementById('modal').classList.add('show'); document.getElementById('editId').value=id; document.getElementById('fTitle').value=n.title; document.getElementById('fContent').value=n.content||''; document.getElementById('fCat').value=n.category; document.getElementById('fPin').checked=n.pinned; selectedColor=n.color||'#58a6ff'; document.querySelectorAll('.color-dot').forEach(d=>{d.classList.remove('active');if(d.style.background===selectedColor||`rgb(${parseInt(selectedColor.slice(1,3),16)}, ${parseInt(selectedColor.slice(3,5),16)}, ${parseInt(selectedColor.slice(5,7),16)})`===d.style.background)d.classList.add('active');}); document.getElementById('modalTitle').textContent='✏️ Edit Note'; }
function edit(id) { const n=data.find(x=>x.id===id); if(!n)return; document.getElementById('modal').classList.add('show'); document.getElementById('editId').value=id; document.getElementById('fTitle').value=n.title; document.getElementById('fContent').value=n.content||''; document.getElementById('fCat').value=n.category; document.getElementById('fPin').checked=n.pinned; selectedColor=n.color||'var(--gold, #B8860B)'; document.querySelectorAll('.color-dot').forEach(d=>{d.classList.remove('active');if(d.style.background===selectedColor||`rgb(${parseInt(selectedColor.slice(1,3),16)}, ${parseInt(selectedColor.slice(3,5),16)}, ${parseInt(selectedColor.slice(5,7),16)})`===d.style.background)d.classList.add('active');}); document.getElementById('modalTitle').textContent='✏️ Edit Note'; }
async function save() {
const id=document.getElementById('editId').value,title=document.getElementById('fTitle').value.trim(),content=document.getElementById('fContent').value.trim();
......
<!DOCTYPE html>
<html lang="vi">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Hướng dẫn sử dụng — Product Performance</title>
<link rel="stylesheet" href="/static/lab.css">
<link rel="stylesheet" href="/static/product.css">
<script src="/static/frame-detect.js"></script>
<style>
.guide-content { max-width: 900px; padding: 32px 40px; }
.guide-content h1 { font-size: 1.6em; color: var(--gold); margin-bottom: 8px; font-family: 'Fraunces', serif; }
.guide-content .subtitle { color: var(--m); font-size: 0.88em; margin-bottom: 32px; }
.guide-section { margin-bottom: 36px; }
.guide-section h2 { font-size: 1.1em; color: var(--f); margin-bottom: 12px; padding-bottom: 8px; border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 10px; }
.guide-section h3 { font-size: 0.95em; color: var(--f); margin: 16px 0 8px; }
.guide-section p, .guide-section li { font-size: 0.88em; color: var(--m); line-height: 1.8; }
.guide-section ul { padding-left: 20px; margin: 8px 0; }
.guide-section li { margin-bottom: 6px; }
.guide-card { background: var(--card); border: 1px solid var(--border); border-radius: 12px; padding: 20px; margin: 12px 0; }
.guide-card h4 { font-size: 0.9em; color: var(--gold); margin-bottom: 8px; }
.guide-card p { margin: 0; }
.feature-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 14px; margin: 14px 0; }
.feature-item { background: var(--card); border: 1px solid var(--border); border-radius: 10px; padding: 16px; }
.feature-item .fi-icon { font-size: 1.4em; margin-bottom: 8px; }
.feature-item .fi-title { font-size: 0.85em; color: var(--f); font-weight: 700; margin-bottom: 4px; }
.feature-item .fi-desc { font-size: 0.78em; color: var(--m); line-height: 1.6; }
.step-list { counter-reset: step; list-style: none; padding-left: 0; }
.step-list li { counter-increment: step; position: relative; padding-left: 36px; margin-bottom: 16px; }
.step-list li::before { content: counter(step); position: absolute; left: 0; top: 0; width: 24px; height: 24px; background: var(--gold); color: var(--bg); border-radius: 50%; font-size: 0.72em; font-weight: 800; display: flex; align-items: center; justify-content: center; }
.tip-box { background: rgba(212,175,55,0.08); border: 1px solid rgba(212,175,55,0.2); border-radius: 10px; padding: 14px 18px; margin: 12px 0; font-size: 0.85em; color: var(--m); line-height: 1.7; }
.tip-box strong { color: var(--gold); }
.back-btn { display: inline-flex; align-items: center; gap: 6px; padding: 8px 16px; background: var(--card); border: 1px solid var(--border); border-radius: 8px; color: var(--m); font-size: 0.82em; text-decoration: none; transition: all 0.2s; margin-bottom: 24px; }
.back-btn:hover { color: var(--f); border-color: var(--gold); }
.toc { background: var(--card); border: 1px solid var(--border); border-radius: 12px; padding: 20px; margin-bottom: 28px; }
.toc h3 { font-size: 0.85em; color: var(--f); margin-bottom: 10px; }
.toc a { display: block; font-size: 0.82em; color: var(--gold); text-decoration: none; padding: 4px 0; }
.toc a:hover { text-decoration: underline; }
</style>
</head>
<body>
<!-- ═══ SIDEBAR ═══ -->
<nav class="sidebar">
<div class="sb-logo">Canif<span>a</span><div class="sb-live"><div class="sb-live-dot"></div>LIVE</div></div>
<div class="sb-section">Dashboard</div>
<div class="sb-nav">
<a class="sb-link" href="dashboard.html"><span class="sb-link-icon">📊</span>Dashboard</a>
</div>
<div class="sb-section">Phân tích</div>
<div class="sb-nav">
<a class="sb-link" href="product.html"><span class="sb-link-icon">🏷️</span>Product Perf.</a>
<a class="sb-link active" href="product-guide.html"><span class="sb-link-icon">📖</span>Hướng dẫn</a>
<a class="sb-link" href="feedback_demo.html"><span class="sb-link-icon">📝</span>Feedback</a>
<a class="sb-link" href="history.html"><span class="sb-link-icon">🧾</span>History</a>
</div>
<div class="sb-section">Tools</div>
<div class="sb-nav">
<a class="sb-link" href="experiment_detail.html?id=exp_chatbot_prod"><span class="sb-link-icon">🤖</span>Chatbot</a>
<a class="sb-link" href="test_sql.html"><span class="sb-link-icon">🗄️</span>Text-to-SQL</a>
<a class="sb-link" href="test_db.html"><span class="sb-link-icon">🔍</span>DB Test</a>
</div>
<div class="sb-footer">
<div id="clock" style="font-family:'Fraunces',serif;font-size:14px;color:var(--gold);margin-bottom:4px">--:--:--</div>
Canifa AI Platform v2.5
</div>
</nav>
<div class="main">
<div class="topbar">
<div class="page-title">📖 Hướng dẫn sử dụng</div>
<div style="margin-left:auto"></div>
<a href="product.html" class="refresh-btn" style="text-decoration:none">← Quay lại Product</a>
</div>
<div class="content">
<div class="guide-content">
<a href="product.html" class="back-btn">← Quay lại Product Performance</a>
<h1>📖 Hướng dẫn sử dụng — Product Performance</h1>
<p class="subtitle">Trang quản lý và phân tích toàn bộ catalog sản phẩm Canifa — dành cho team BA, Product & Marketing</p>
<!-- TABLE OF CONTENTS -->
<div class="toc">
<h3>📌 Mục lục</h3>
<a href="#purpose">1. Mục đích tính năng</a>
<a href="#features">2. Các tính năng chính</a>
<a href="#how-to">3. Hướng dẫn sử dụng</a>
<a href="#kpi">4. Giải thích KPI</a>
<a href="#filters">5. Bộ lọc & Tìm kiếm</a>
<a href="#stock">6. Kiểm tra tồn kho</a>
<a href="#tips">7. Mẹo sử dụng</a>
<a href="#faq">8. Câu hỏi thường gặp</a>
</div>
<!-- 1. MỤC ĐÍCH -->
<div class="guide-section" id="purpose">
<h2>🎯 1. Mục đích tính năng</h2>
<p>Trang <strong>Product Performance</strong> cung cấp cái nhìn tổng quan về toàn bộ catalog sản phẩm Canifa, giúp team:</p>
<ul>
<li><strong>Nắm bắt nhanh</strong> tổng sản phẩm, dòng SP, giá trung bình, sản phẩm giảm giá, sản phẩm mới, tổng đã bán</li>
<li><strong>Tìm kiếm & lọc</strong> sản phẩm theo tên, mã SP, giới tính, dòng sản phẩm</li>
<li><strong>Phân tích chi tiết</strong> từng sản phẩm: thông tin, giá, tồn kho thực tế (real-time)</li>
<li><strong>Hỗ trợ quyết định</strong> về nhập hàng, khuyến mãi, chiến lược sản phẩm</li>
</ul>
<div class="tip-box">
<strong>💡 Dữ liệu từ đâu?</strong> Trang này lấy dữ liệu từ StarRocks Data Lake (bảng <code>magento_product_dimension</code>), được đồng bộ từ hệ thống Magento của Canifa. Tồn kho được check real-time qua API Canifa.
</div>
</div>
<!-- 2. TÍNH NĂNG CHÍNH -->
<div class="guide-section" id="features">
<h2>⚡ 2. Các tính năng chính</h2>
<div class="feature-grid">
<div class="feature-item">
<div class="fi-icon">📊</div>
<div class="fi-title">KPI Dashboard</div>
<div class="fi-desc">6 chỉ số tổng quan: Tổng SP, Dòng SP, Giá TB, Đang giảm giá, Hàng mới, Tổng đã bán. Cập nhật ngay khi load trang.</div>
</div>
<div class="feature-item">
<div class="fi-icon">🔍</div>
<div class="fi-title">Tìm kiếm thông minh</div>
<div class="fi-desc">Tìm theo tên sản phẩm, mã nội bộ hoặc mã magento. Tự động debounce 300ms — gõ đến đâu, lọc đến đó.</div>
</div>
<div class="feature-item">
<div class="fi-icon">🏷️</div>
<div class="fi-title">Lọc theo tiêu chí</div>
<div class="fi-desc">Lọc theo giới tính (Nam/Nữ/Bé trai/Bé gái/Unisex), dòng sản phẩm (Áo phông, Quần jean, Váy...), sắp xếp theo tên/giá/bán chạy.</div>
</div>
<div class="feature-item">
<div class="fi-icon">📦</div>
<div class="fi-title">Check tồn kho Real-time</div>
<div class="fi-desc">Bấm vào sản phẩm bất kỳ để xem tồn kho thực tế từ API Canifa — chi tiết theo size, màu, cửa hàng.</div>
</div>
<div class="feature-item">
<div class="fi-icon">📱</div>
<div class="fi-title">Responsive</div>
<div class="fi-desc">Giao diện tự thích ứng trên desktop, tablet, mobile. Sidebar có thể thu gọn để xem nhiều hơn.</div>
</div>
<div class="feature-item">
<div class="fi-icon">🔗</div>
<div class="fi-title">Link trực tiếp</div>
<div class="fi-desc">Mỗi sản phẩm có link trực tiếp đến trang canifa.com — tiện cho team kiểm tra nhanh.</div>
</div>
</div>
</div>
<!-- 3. HƯỚNG DẪN SỬ DỤNG -->
<div class="guide-section" id="how-to">
<h2>📝 3. Hướng dẫn sử dụng</h2>
<h3>Bước cơ bản</h3>
<ul class="step-list">
<li><strong>Truy cập trang:</strong> Vào <code>Product Perf.</code> từ sidebar hoặc truy cập trực tiếp <code>/static/product.html</code></li>
<li><strong>Xem KPI tổng quan:</strong> 6 chỉ số ở đầu trang hiện ngay khi load. Bấm 🔄 Refresh để cập nhật lại.</li>
<li><strong>Tìm sản phẩm:</strong> Gõ tên/mã SP vào ô tìm kiếm. Kết quả hiện ngay khi gõ.</li>
<li><strong>Lọc & Sắp xếp:</strong> Dùng dropdown "Giới tính", "Dòng SP", "Sắp xếp" để thu hep kết quả.</li>
<li><strong>Xem chi tiết:</strong> Bấm vào sản phẩm → xem thông tin chi tiết + check tồn kho real-time.</li>
<li><strong>Load thêm:</strong> Cuộn xuống cuối danh sách, bấm "Load More" để xem thêm sản phẩm.</li>
</ul>
</div>
<!-- 4. GIẢI THÍCH KPI -->
<div class="guide-section" id="kpi">
<h2>📊 4. Giải thích KPI</h2>
<div class="guide-card">
<h4>SP trong Catalog</h4>
<p>Tổng số sản phẩm (đếm theo mã magento_ref_code, mỗi màu = 1 SP riêng). VD: Áo polo trắng và áo polo đen cùng kiểu = 2 SP.</p>
</div>
<div class="guide-card">
<h4>Dòng sản phẩm</h4>
<p>Số lượng dòng SP khác nhau (VD: Áo phông, Quần short, Váy liền, Áo khoác...). Giúp nắm độ đa dạng catalog.</p>
</div>
<div class="guide-card">
<h4>Giá trung bình</h4>
<p>Giá bán trung bình (sale_price) của toàn bộ catalog. Sub-text hiển thị giá gốc trung bình để so sánh mức giảm.</p>
</div>
<div class="guide-card">
<h4>Đang giảm giá</h4>
<p>Số SP có discount_amount > 0 (đang được markdown). Sub-text hiện % trên tổng catalog.</p>
</div>
<div class="guide-card">
<h4>Hàng mới</h4>
<p>Số SP có flag is_new_product = 1. Thường là SP mới nhập trong mùa hiện tại.</p>
</div>
<div class="guide-card">
<h4>Tổng đã bán</h4>
<p>Tổng quantity_sold của toàn bộ catalog. Con số này tích lũy, không phải theo ngày/tháng.</p>
</div>
</div>
<!-- 5. BỘ LỌC & TÌM KIẾM -->
<div class="guide-section" id="filters">
<h2>🔍 5. Bộ lọc & Tìm kiếm</h2>
<div class="guide-card">
<h4>Tìm kiếm</h4>
<p>Tìm theo: <strong>tên sản phẩm</strong> (VD: "áo polo"), <strong>mã nội bộ</strong> (VD: "8TP25A005"), hoặc <strong>mã magento</strong> (VD: "8TP25A005-SW011"). Không phân biệt hoa thường. Tự động debounce 300ms.</p>
</div>
<div class="guide-card">
<h4>Lọc giới tính</h4>
<p>Tất cả / Nam (men) / Nữ (women) / Bé trai (boy) / Bé gái (girl) / Unisex. Dữ liệu theo trường <code>gender_by_product</code>.</p>
</div>
<div class="guide-card">
<h4>Lọc dòng SP</h4>
<p>Dropdown tự động load tất cả dòng SP có trong catalog: Áo phông, Quần short, Chân váy, Đầm/Váy, Áo khoác, v.v.</p>
</div>
<div class="guide-card">
<h4>Sắp xếp</h4>
<p>Mặc định: <strong>Bán chạy nhất</strong>. Các tùy chọn: Tên A→Z / Z→A, Giá thấp→cao / Cao→thấp, Bán chạy nhất, Giảm giá nhiều nhất.</p>
</div>
</div>
<!-- 6. TỒN KHO -->
<div class="guide-section" id="stock">
<h2>📦 6. Kiểm tra tồn kho</h2>
<p>Khi bấm vào 1 sản phẩm, hệ thống sẽ gọi API Canifa real-time để check tồn kho:</p>
<ul>
<li><strong>Còn hàng</strong> — Hiển thị danh sách size còn hàng, số lượng mỗi size</li>
<li><strong>Hết hàng</strong> — Hiển thị thông báo "Hết hàng" với label đỏ</li>
<li><strong>Đang load</strong> — Spinner hiện trong khi chờ API trả về</li>
</ul>
<div class="tip-box">
<strong>⚠️ Lưu ý:</strong> Tồn kho được check real-time nên có thể mất 1-3 giây. Dữ liệu tồn kho phản ánh số lượng tại thời điểm check, không phải lúc load trang.
</div>
</div>
<!-- 7. MẸO SỬ DỤNG -->
<div class="guide-section" id="tips">
<h2>💡 7. Mẹo sử dụng</h2>
<ul>
<li><strong>So sánh nhanh:</strong> Dùng bộ lọc "Giảm giá nhiều nhất" để tìm SP đang markdown mạnh → kiểm tra tồn kho → quyết định push marketing.</li>
<li><strong>Check hàng mới:</strong> Sắp xếp theo "Bán chạy nhất" + lọc giới tính → nhanh chóng biết SP mới nào đang hot.</li>
<li><strong>Tìm gap catalog:</strong> Lọc theo dòng SP → so sánh số lượng giữa các dòng → phát hiện dòng nào cần bổ sung.</li>
<li><strong>Refresh thường xuyên:</strong> Bấm 🔄 Refresh trước khi ra quyết định quan trọng để đảm bảo dữ liệu mới nhất.</li>
</ul>
</div>
<!-- 8. FAQ -->
<div class="guide-section" id="faq">
<h2>❓ 8. Câu hỏi thường gặp</h2>
<div class="guide-card">
<h4>Q: Dữ liệu cập nhật bao lâu 1 lần?</h4>
<p>Dữ liệu catalog được đồng bộ từ Magento → StarRocks hàng ngày. Tồn kho được check real-time khi bấm vào sản phẩm.</p>
</div>
<div class="guide-card">
<h4>Q: Tại sao "Tổng đã bán" không khớp với báo cáo kinh doanh?</h4>
<p>Con số này lấy từ trường <code>quantity_sold</code> trong catalog, chỉ dùng để tham khảo mức độ phổ biến. Để so sánh doanh số chính xác, dùng báo cáo từ hệ thống ERP.</p>
</div>
<div class="guide-card">
<h4>Q: Sao tìm không ra sản phẩm?</h4>
<p>Thử tìm theo mã nội bộ (VD: "8TP25A005") thay vì tên. Hoặc bỏ bớt bộ lọc giới tính/dòng SP đang chọn.</p>
</div>
<div class="guide-card">
<h4>Q: Trang load chậm?</h4>
<p>Trang mặc định load 50 SP/lần. Nếu vẫn chậm, kiểm tra kết nối mạng nội bộ đến StarRocks (172.16.2.100:9030).</p>
</div>
</div>
</div>
</div>
</div>
<script>
// Clock
setInterval(() => {
const n = new Date();
document.getElementById('clock').textContent =
n.getHours().toString().padStart(2,'0') + ':' +
n.getMinutes().toString().padStart(2,'0') + ':' +
n.getSeconds().toString().padStart(2,'0');
}, 1000);
</script>
</body>
</html>
// Product table rendering - grouped by internal_ref_code
// Replaces inline rendering in product.html
function renderProductTable(d, maxSold) {
let html = `<table><thead><tr>
<th>Sản phẩm</th><th>Giá bán</th><th>Giảm giá</th>
<th>Đã bán</th><th>Màu sắc</th><th>Dòng SP</th><th>Giới tính</th><th>Link</th>
</tr></thead><tbody>`;
for (const p of d.products) {
const e = emoji(p.product_line_vn);
const hasImg = !!p.product_image_url_thumbnail;
const imgHtml = hasImg
? `<img class="td-thumb" src="${p.product_image_url_thumbnail}" alt="" loading="lazy" onerror="this.outerHTML='<div class=\\'td-swatch\\' style=\\'background:var(--s2)\\'>${e}</div>'">`
: `<div class="td-swatch" style="background:var(--s2)">${e}</div>`;
const discount = p.discount_percent > 0
? `<span class="td-badge" style="background:var(--red-l);color:var(--red)">-${p.discount_percent}%</span>`
: `<span style="color:var(--f)">—</span>`;
const soldPct = Math.min((p.quantity_sold||0)/maxSold*100, 100);
const soldColor = soldPct > 50 ? 'var(--green)' : soldPct > 20 ? 'var(--gold)' : 'var(--f)';
const soldBar = `<div class="td-bar-wrap"><div class="td-bar" style="width:${soldPct}%;background:${soldColor}"></div></div><b>${fmt(p.quantity_sold||0)}</b>`;
const gLabel = p.gender_by_product === 'men' ? '👨 Nam'
: p.gender_by_product === 'women' ? '👩 Nữ'
: p.gender_by_product === 'unisex' ? '🧑 Uni'
: p.gender_by_product || '—';
const link = p.product_web_url
? `<a href="${p.product_web_url}" target="_blank" class="td-link">Xem ↗</a>` : '—';
const newTag = p.is_new_product
? ' <span class="td-badge" style="background:var(--blue-l);color:var(--blue)">NEW</span>' : '';
// Size chips
const sizeChips = p.size_scale
? `<div style="margin-top:3px;display:flex;gap:3px;flex-wrap:wrap">${p.size_scale.split('|').map(s =>
`<span style="display:inline-block;padding:1px 6px;border-radius:4px;font-size:9px;font-weight:700;background:var(--bg);color:var(--m);border:1px solid var(--b)">${s.trim()}</span>`
).join('')}</div>` : '';
// Color count badge with click to expand
const colorCount = p.color_count || 1;
const colorBadge = `<span class="color-expand-btn" data-code="${p.internal_ref_code}" onclick="toggleColors('${p.internal_ref_code}', this)" style="cursor:pointer;display:inline-flex;align-items:center;gap:4px;padding:4px 10px;border-radius:6px;font-size:11px;font-weight:700;background:var(--gold-l);color:var(--gold);border:1px solid var(--gold);transition:all .2s">🎨 ${colorCount} màu ▼</span>`;
html += `<tr>
<td><div class="td-product">${imgHtml}<div>
<div class="td-name">${p.product_name||'N/A'}${newTag}</div>
<div class="td-sku"><b>${p.internal_ref_code||''}</b></div>
${sizeChips}
</div></div></td>
<td><b style="font-family:'Fraunces',serif;font-size:13px">${fmtK(p.sale_price)}₫</b>${p.original_price > p.sale_price ? `<br><span style="font-size:9px;color:var(--f);text-decoration:line-through">${fmtK(p.original_price)}₫</span>` : ''}</td>
<td>${discount}</td>
<td>${soldBar}</td>
<td>${colorBadge}</td>
<td><span class="td-badge" style="background:var(--purple-l);color:var(--purple)">${p.product_line_vn||'—'}</span></td>
<td style="font-size:10px">${gLabel}</td>
<td>${link}</td>
</tr>
<tr id="colors-${p.internal_ref_code}" style="display:none">
<td colspan="8" style="padding:0;background:var(--bg)">
<div id="colors-content-${p.internal_ref_code}" style="padding:12px 16px 12px 70px">
<span style="color:var(--f);font-size:11px">⏳ Đang tải màu sắc...</span>
</div>
</td>
</tr>`;
}
html += '</tbody></table>';
return html;
}
// Toggle color variants for a product
async function toggleColors(code, btn) {
const row = document.getElementById('colors-' + code);
if (!row) return;
if (row.style.display !== 'none') {
row.style.display = 'none';
btn.innerHTML = '🎨 ' + btn.innerHTML.match(/\d+/)[0] + ' màu ▼';
return;
}
row.style.display = '';
btn.innerHTML = '🎨 ' + btn.innerHTML.match(/\d+/)[0] + ' màu ▲';
const container = document.getElementById('colors-content-' + code);
// Check if already loaded
if (container.dataset.loaded) return;
try {
// Fetch color variants from API
const r = await fetch(`/api/products/colors?code=${encodeURIComponent(code)}`);
const d = await r.json();
if (d.status !== 'success' || !d.variants?.length) {
container.innerHTML = '<span style="color:var(--f)">Không có dữ liệu</span>';
return;
}
// Also fetch stock for all color codes
const colorCodes = d.variants.map(v => v.product_color_code).join(',');
let stockMap = {};
try {
const sr = await fetch('/api/stock/check', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({codes: code, max_skus: 500, chunk_size: 50, timeout_sec: 15})
});
const sd = await sr.json();
if (sd.status === 'success') {
(sd.stock_responses || []).forEach(resp => {
(resp.result || []).forEach(item => {
const parts = item.sku.split('-');
const size = parts[parts.length - 1];
const colorCode = parts.slice(0, -1).join('-');
if (!stockMap[colorCode]) stockMap[colorCode] = {total: 0, sizes: {}};
stockMap[colorCode].total += (item.qty || 0);
stockMap[colorCode].sizes[size] = {qty: item.qty || 0, inStock: item.is_in_stock};
});
});
}
} catch(e) { console.warn('Stock fetch error:', e); }
const sizeOrder = ['XS','S','M','L','XL','XXL','2XL','3XL','100','110','120','130','140','150','160'];
let html = '<div style="display:flex;flex-direction:column;gap:10px">';
for (const v of d.variants) {
const stock = stockMap[v.product_color_code];
const totalQty = stock ? stock.total : 0;
const qtyColor = totalQty > 20 ? 'var(--green)' : totalQty > 0 ? 'var(--gold)' : 'var(--red)';
const qtyIcon = totalQty > 20 ? '✅' : totalQty > 0 ? '⚠️' : '❌';
// Size breakdown chips
let sizeHtml = '';
if (stock && stock.sizes) {
const sortedSizes = Object.keys(stock.sizes).sort((a, b) => {
const ia = sizeOrder.indexOf(a), ib = sizeOrder.indexOf(b);
return (ia === -1 ? 99 : ia) - (ib === -1 ? 99 : ib);
});
sizeHtml = sortedSizes.map(sz => {
const s = stock.sizes[sz];
const sc = s.qty > 10 ? 'var(--green)' : s.qty > 0 ? 'var(--gold)' : 'var(--red)';
const bg = s.qty > 10 ? 'rgba(22,163,74,.08)' : s.qty > 0 ? 'rgba(234,179,8,.08)' : 'rgba(220,38,38,.08)';
return `<span style="display:inline-block;padding:2px 6px;border-radius:4px;font-size:10px;font-weight:600;background:${bg};color:${sc};border:1px solid ${sc}">${sz}: ${s.qty}</span>`;
}).join(' ');
}
const imgUrl = v.product_image_url_thumbnail;
const imgTag = imgUrl
? `<img src="${imgUrl}" style="width:40px;height:40px;border-radius:6px;object-fit:cover;border:1px solid var(--b)" loading="lazy">`
: `<div style="width:40px;height:40px;border-radius:6px;background:var(--s2);display:flex;align-items:center;justify-content:center;font-size:14px">🎨</div>`;
html += `<div style="display:flex;align-items:flex-start;gap:12px;padding:10px 14px;background:var(--s);border-radius:8px;border:1px solid var(--b)">
${imgTag}
<div style="flex:1;min-width:0">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:4px">
<b style="font-family:'JetBrains Mono',monospace;font-size:12px">${v.product_color_code}</b>
<span style="font-size:11px;color:var(--m)">· ${v.master_color || ''}</span>
<span style="font-size:11px;color:var(--m)">${v.product_color_name || ''}</span>
</div>
<div style="display:flex;align-items:center;gap:6px;margin-bottom:4px">
<span style="font-weight:700;color:${qtyColor};font-size:13px">${qtyIcon} Tồn: ${totalQty}</span>
<span style="color:var(--f);font-size:10px">| Đã bán: ${fmt(v.quantity_sold || 0)}</span>
</div>
<div style="display:flex;flex-wrap:wrap;gap:4px">${sizeHtml || '<span style="color:var(--f);font-size:10px">Chưa có dữ liệu tồn kho</span>'}</div>
</div>
</div>`;
}
html += '</div>';
container.innerHTML = html;
container.dataset.loaded = 'true';
} catch(e) {
console.error('Colors error:', e);
container.innerHTML = `<span style="color:var(--red)">❌ Lỗi: ${e.message}</span>`;
}
}
/* ═══════════════════════════════════════════════
Product Performance CSS — Canifa AI System
Uses CSS variables from lab.css
═══════════════════════════════════════════════ */
body{font-family:'Outfit',sans-serif;background:var(--bg);color:var(--t);min-height:100vh;display:flex}
/* ═══ SIDEBAR ═══ */
.sidebar{width:240px;background:var(--s);display:flex;flex-direction:column;position:fixed;top:0;left:0;bottom:0;overflow-y:auto;z-index:100;border-right:1px solid var(--b)}
.sb-logo{padding:18px 20px;font-family:'Fraunces',serif;font-size:20px;font-weight:600;border-bottom:1px solid var(--b);display:flex;align-items:center;gap:8px}
.sb-logo span{color:var(--gold)}
.sb-live{display:flex;align-items:center;gap:6px;margin-left:auto;font-size:10px;font-weight:700;color:#16A34A}
.sb-live-dot{width:6px;height:6px;border-radius:50%;background:#16A34A;animation:pulse 2s infinite}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.3}}
.sb-section{padding:12px 14px 6px;font-size:9px;font-weight:700;text-transform:uppercase;letter-spacing:1px;color:var(--f)}
.sb-nav{padding:4px 10px}
.sb-link{display:flex;align-items:center;gap:10px;padding:9px 12px;border-radius:var(--rs);color:var(--m);text-decoration:none;font-size:13px;font-weight:500;transition:all .15s;cursor:pointer;margin-bottom:1px}
.sb-link:hover{background:var(--bg);color:var(--t)}
.sb-link.active{background:var(--gold-l);color:var(--gold);font-weight:700;border-left:3px solid var(--gold)}
.sb-link-icon{font-size:16px;width:20px;text-align:center}
.sb-footer{margin-top:auto;padding:14px;border-top:1px solid var(--b);font-size:10px;color:var(--f)}
/* ═══ MAIN ═══ */
.main{margin-left:240px;flex:1;display:flex;flex-direction:column;min-height:100vh}
.topbar{background:var(--s);border-bottom:1px solid var(--b);padding:0 24px;height:56px;display:flex;align-items:center;gap:14px;position:sticky;top:0;z-index:50}
.page-title{font-weight:700;font-size:16px;display:flex;align-items:center;gap:8px}
.content{padding:24px;flex:1}
.card{background:var(--s);border:1px solid var(--b);border-radius:var(--r);padding:18px;margin-bottom:16px}
.card-title{font-size:14px;font-weight:700;margin-bottom:14px;display:flex;align-items:center;gap:8px}
.card-sub{font-size:11px;color:var(--m);margin-left:auto;font-weight:400}
/* ═══ KPI ═══ */
.kpi-row{display:grid;grid-template-columns:repeat(6,1fr);gap:12px;margin-bottom:20px}
.kpi{background:var(--s);border:1px solid var(--b);border-radius:var(--r);padding:16px;text-align:center;transition:all .2s}
.kpi:hover{border-color:var(--gold);transform:translateY(-2px);box-shadow:0 4px 12px rgba(0,0,0,.06)}
.kpi-val{font-family:'Fraunces',serif;font-size:26px;font-weight:700}
.kpi-label{font-size:10px;color:var(--m);font-weight:600;margin-top:2px}
.kpi-sub{font-size:9px;color:var(--f);margin-top:2px}
/* ═══ TOOLBAR ═══ */
.toolbar{display:flex;align-items:center;gap:10px;margin-bottom:16px;flex-wrap:wrap}
.toolbar-search{flex:1;min-width:200px;padding:8px 14px;background:var(--s);border:1px solid var(--b);border-radius:var(--rs);font-family:inherit;font-size:12px;color:var(--t);transition:all .2s}
.toolbar-search:focus{outline:none;border-color:var(--gold);box-shadow:0 0 0 3px rgba(180,83,9,.1)}
.toolbar-select{padding:8px 12px;background:var(--s);border:1px solid var(--b);border-radius:var(--rs);font-family:inherit;font-size:12px;color:var(--t);cursor:pointer}
.toolbar-select:focus{outline:none;border-color:var(--gold)}
.chip{padding:5px 12px;border-radius:20px;border:1px solid var(--b);background:transparent;color:var(--m);font-family:inherit;font-size:11px;font-weight:600;cursor:pointer;transition:all .15s}
.chip:hover{border-color:var(--gold);color:var(--t)}
.chip.active{background:var(--gold-l);color:var(--gold);border-color:var(--gold-b)}
.refresh-btn{padding:6px 14px;border-radius:var(--rs);border:1px solid var(--b);background:var(--s);color:var(--m);font-family:inherit;font-size:11px;font-weight:600;cursor:pointer;transition:all .15s;display:flex;align-items:center;gap:4px}
.refresh-btn:hover{border-color:var(--gold);color:var(--t)}
/* ═══ GRID ═══ */
.grid-2{display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-bottom:16px}
.grid-3-2{display:grid;grid-template-columns:3fr 2fr;gap:16px;margin-bottom:16px}
/* ═══ TABLE ═══ */
table{width:100%;border-collapse:collapse}
th{text-align:left;font-size:10px;font-weight:700;color:var(--m);text-transform:uppercase;letter-spacing:.5px;padding:8px 10px;border-bottom:2px solid var(--b)}
td{padding:14px 10px;border-bottom:1px solid var(--b);font-size:12px}
tr{transition:background .1s}
tr:hover{background:var(--bg)}
.td-product{display:flex;align-items:center;gap:10px}
.td-thumb{width:52px;height:52px;border-radius:10px;object-fit:cover;background:var(--bg);flex-shrink:0;border:1px solid var(--b)}
.td-swatch{width:52px;height:52px;border-radius:10px;display:flex;align-items:center;justify-content:center;font-size:16px;flex-shrink:0}
.td-name{font-weight:800;font-size:14px;color:var(--t);letter-spacing:-.2px}
.td-sku{font-size:11px;color:var(--m);font-family:'JetBrains Mono',monospace;font-weight:600;margin-top:2px}
.td-badge{display:inline-block;padding:2px 8px;border-radius:10px;font-size:9px;font-weight:700}
.td-bar-wrap{width:60px;height:6px;background:var(--bg);border-radius:3px;display:inline-block;vertical-align:middle;margin-right:4px}
.td-bar{height:100%;border-radius:3px;transition:width .5s}
.td-link{color:var(--blue);text-decoration:none;font-size:10px;font-weight:600}
.td-link:hover{text-decoration:underline}
/* ═══ CROSS-SELL ═══ */
.cs-pair{display:flex;align-items:center;gap:6px;padding:10px;border:1px solid var(--b);border-radius:var(--rs);margin-bottom:6px;font-size:11px;cursor:default;transition:all .15s}
.cs-pair:hover{background:var(--bg);border-color:var(--gold)}
.cs-sp{padding:4px 10px;background:var(--bg);border-radius:6px;font-weight:700;font-size:10px}
.cs-arrow{color:var(--gold);font-weight:700}
.cs-rate{margin-left:auto;font-family:'Fraunces',serif;font-size:14px;font-weight:700}
/* ═══ OOS ═══ */
.oos{display:flex;align-items:center;gap:8px;padding:8px;border-bottom:1px solid var(--b);font-size:11px}
.oos:last-child{border-bottom:none}
.oos-tag{font-size:8px;font-weight:700;padding:2px 6px;border-radius:4px}
.oos-name{flex:1;font-weight:600}
.oos-count{font-family:'JetBrains Mono',monospace;font-size:10px;font-weight:700}
/* ═══ BREAKDOWN ═══ */
.bd-item{display:flex;justify-content:space-between;align-items:center;padding:8px 0;border-bottom:1px solid var(--b);font-size:11px}
.bd-item:last-child{border-bottom:none}
.bd-name{font-weight:600}
.bd-val{font-family:'JetBrains Mono',monospace;font-size:10px;font-weight:700;color:var(--gold)}
/* ═══ PAGINATION ═══ */
.pager{display:flex;justify-content:space-between;align-items:center;padding:12px 0;font-size:11px;color:var(--m)}
.pager-btns{display:flex;gap:6px}
.pager-btn{padding:5px 12px;background:var(--s);border:1px solid var(--b);border-radius:var(--rs);font-family:inherit;font-size:11px;cursor:pointer;color:var(--t);transition:all .15s}
.pager-btn:hover:not(:disabled){border-color:var(--gold);background:var(--gold-l)}
.pager-btn:disabled{opacity:.3;cursor:not-allowed}
/* ═══ LOADING ═══ */
.loading{text-align:center;padding:40px;color:var(--m);font-size:12px}
.loading .spinner{width:28px;height:28px;border:3px solid var(--b);border-top-color:var(--gold);border-radius:50%;animation:spin .6s linear infinite;margin:0 auto 10px}
@keyframes spin{from{transform:rotate(0deg)}to{transform:rotate(360deg)}}
.empty{text-align:center;padding:40px;color:var(--f);font-size:12px}
/* ═══ SCROLLBAR ═══ */
::-webkit-scrollbar{width:5px}
::-webkit-scrollbar-track{background:transparent}
::-webkit-scrollbar-thumb{background:var(--b);border-radius:3px}
::-webkit-scrollbar-thumb:hover{background:var(--m)}
/* ═══ RESPONSIVE ═══ */
@media(max-width:900px){
.kpi-row{grid-template-columns:repeat(3,1fr)}
.grid-2,.grid-3-2{grid-template-columns:1fr}
.sidebar{display:none}
.main{margin-left:0}
}
/* ═══ IFRAME MODE: hide sidebar when embedded ═══ */
html.in-iframe .sidebar{display:none!important}
html.in-iframe .main{margin-left:0!important}
<!DOCTYPE html>
<html lang="vi">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Product Performance � Canifa AI</title>
<link rel="stylesheet" href="/static/lab.css">
<link rel="stylesheet" href="/static/product.css">
<script src="/static/frame-detect.js"></script>
<script src="/static/stock-loader.js"></script>
<script src="/static/product-render.js"></script>`r`n</head>
<body>
<!-- ═══ SIDEBAR ═══ -->
<nav class="sidebar">
<div class="sb-logo">Canif<span>a</span><div class="sb-live"><div class="sb-live-dot"></div>LIVE</div></div>
<div class="sb-section">Dashboard</div>
<div class="sb-nav">
<a class="sb-link" href="dashboard.html"><span class="sb-link-icon">📊</span>Dashboard</a>
</div>
<div class="sb-section">Phân tích</div>
<div class="sb-nav">
<a class="sb-link active" href="product.html"><span class="sb-link-icon">🏷️</span>Product Perf.</a>
<a class="sb-link" href="product-guide.html"><span class="sb-link-icon">📖</span>Hướng dẫn</a>
<a class="sb-link" href="feedback_demo.html"><span class="sb-link-icon">📝</span>Feedback</a>
<a class="sb-link" href="history.html"><span class="sb-link-icon">🧾</span>History</a>
</div>
<div class="sb-section">Tools</div>
<div class="sb-nav">
<a class="sb-link" href="experiment_detail.html?id=exp_chatbot_prod"><span class="sb-link-icon">🤖</span>Chatbot</a>
<a class="sb-link" href="test_sql.html"><span class="sb-link-icon">🗄️</span>Text-to-SQL</a>
<a class="sb-link" href="test_db.html"><span class="sb-link-icon">🔍</span>DB Test</a>
</div>
<div class="sb-footer">
<div id="clock" style="font-family:'Fraunces',serif;font-size:14px;color:var(--gold);margin-bottom:4px">--:--:--</div>
Canifa AI Platform v2.5
</div>
</nav>
<div class="main">
<div class="topbar">
<div class="page-title">🏷️ Product Performance</div>
<div style="margin-left:auto;font-size:10px;color:var(--m)" id="topbar-meta">Loading...</div>
<button class="refresh-btn" onclick="loadAll()">🔄 Refresh</button>
<a href="product-guide.html" class="refresh-btn" style="text-decoration:none;margin-left:6px;background:rgba(212,175,55,0.1);border-color:rgba(212,175,55,0.3);color:var(--gold)">📖 Hướng dẫn</a>
</div>
<div class="content">
<!-- KPIs -->
<div class="kpi-row">
<div class="kpi"><div class="kpi-val" style="color:var(--blue)" id="kpi-total"></div><div class="kpi-label">SP trong Catalog</div><div class="kpi-sub" id="kpi-total-sub">&nbsp;</div></div>
<div class="kpi"><div class="kpi-val" style="color:var(--green)" id="kpi-lines"></div><div class="kpi-label">Dòng sản phẩm</div><div class="kpi-sub" id="kpi-lines-sub">&nbsp;</div></div>
<div class="kpi"><div class="kpi-val" style="color:var(--gold)" id="kpi-avg"></div><div class="kpi-label">Giá trung bình</div><div class="kpi-sub" id="kpi-avg-sub">&nbsp;</div></div>
<div class="kpi"><div class="kpi-val" style="color:var(--purple)" id="kpi-discount"></div><div class="kpi-label">Đang giảm giá</div><div class="kpi-sub" id="kpi-discount-sub">&nbsp;</div></div>
<div class="kpi"><div class="kpi-val" style="color:var(--cyan)" id="kpi-new"></div><div class="kpi-label">Hàng mới</div><div class="kpi-sub" id="kpi-new-sub">&nbsp;</div></div>
<div class="kpi"><div class="kpi-val" style="color:var(--red)" id="kpi-sold"></div><div class="kpi-label">Tổng đã bán</div><div class="kpi-sub" id="kpi-sold-sub">&nbsp;</div></div>
</div>
<!-- Toolbar -->
<div class="toolbar">
<input type="text" class="toolbar-search" id="search-input" placeholder="🔍 Tìm theo tên, mã sản phẩm..." oninput="debouncedSearch()">
<select class="toolbar-select" id="sort-select" onchange="loadProducts()">
<option value="quantity_sold">🔥 Bán chạy</option>
<option value="sale_price">💰 Giá cao → thấp</option>
<option value="product_name">🔤 Tên A → Z</option>
<option value="discount_percent">🏷️ Giảm nhiều nhất</option>
</select>
<select class="toolbar-select" id="gender-select" onchange="loadProducts()">
<option value="">Tất cả</option>
<option value="men">👨 Nam</option>
<option value="women">👩 Nữ</option>
<option value="unisex">🧑 Unisex</option>
</select>
<select class="toolbar-select" id="color-select" onchange="offset=0;loadProducts()">
<option value="">🎨 Tất cả màu</option>
</select>
<select class="toolbar-select" id="line-select" onchange="offset=0;loadProducts()">
<option value="">📦 Tất cả loại</option>
</select>
<button class="chip" id="btn-new" onclick="toggleFilter('new')">🆕 Hàng mới</button>
<button class="chip" id="btn-discount" onclick="toggleFilter('discount')">🏷️ Đang giảm</button>
</div>
<!-- TOP PRODUCTS TABLE -->
<div class="card">
<div class="card-title">🏆 Product Catalog <span class="card-sub" id="table-meta">Loading...</span></div>
<div id="table-container">
<div class="loading"><div class="spinner"></div>Đang tải dữ liệu từ StarRocks...</div>
</div>
<div class="pager" id="pager" style="display:none">
<span id="page-info"></span>
<div class="pager-btns">
<button class="pager-btn" id="btn-prev" onclick="prevPage()">← Trước</button>
<button class="pager-btn" id="btn-next" onclick="nextPage()">Sau →</button>
</div>
</div>
</div>
<!-- BREAKDOWNS -->
<div class="grid-2" id="breakdowns" style="display:none">
<div class="card">
<div class="card-title">📊 Product Lines <span class="card-sub">theo số lượng SP</span></div>
<div id="lines-breakdown"></div>
</div>
<div class="card">
<div class="card-title">👥 Gender Breakdown <span class="card-sub">phân bổ giới tính</span></div>
<div id="gender-breakdown"></div>
</div>
</div>
</div>
</div>
<script>
// ═══ STATE ═══
let offset = 0;
const LIMIT = 50;
let filterNew = false;
let filterDiscount = false;
let searchTimer = null;
// ═══ HELPERS ═══
function fmt(n) { return n == null ? '—' : Number(n).toLocaleString('vi-VN'); }
function fmtK(n) {
if (n == null || n === 0) return '—';
if (n >= 1000000) return (n/1e6).toFixed(1) + 'M';
if (n >= 1000) return Math.round(n/1000) + 'K';
return fmt(n);
}
function emoji(line) {
if (!line) return '📦';
const l = line.toLowerCase();
if (l.includes('polo') || l.includes('áo thun')) return '👕';
if (l.includes('khoác') || l.includes('bomber') || l.includes('jacket') || l.includes('hoodie')) return '🧥';
if (l.includes('quần')) return '👖';
if (l.includes('váy') || l.includes('đầm')) return '👗';
if (l.includes('sơ mi')) return '👔';
if (l.includes('trẻ em') || l.includes('bé')) return '👶';
if (l.includes('khăn') || l.includes('mũ')) return '🧣';
if (l.includes('túi') || l.includes('balo')) return '👜';
if (l.includes('áo')) return '👚';
return '📦';
}
// ═══ CLOCK ═══
setInterval(() => {
document.getElementById('clock').textContent = new Date().toLocaleTimeString('vi-VN', {hour:'2-digit',minute:'2-digit',second:'2-digit'});
}, 1000);
// ═══ LOAD OVERVIEW ═══
async function loadOverview() {
try {
const r = await fetch('/api/products/overview');
const d = await r.json();
if (d.status !== 'success') return;
const s = d.stats;
document.getElementById('kpi-total').textContent = fmt(s.total_products);
document.getElementById('kpi-total-sub').textContent = 'real-time StarRocks';
document.getElementById('kpi-lines').textContent = fmt(s.total_lines);
document.getElementById('kpi-lines-sub').textContent = 'loại sản phẩm';
document.getElementById('kpi-avg').textContent = fmtK(s.avg_price) + '₫';
document.getElementById('kpi-avg-sub').textContent = fmtK(s.min_price) + '₫ — ' + fmtK(s.max_price) + '₫';
document.getElementById('kpi-discount').textContent = fmt(s.discounted_count);
document.getElementById('kpi-discount-sub').textContent = 'SP đang khuyến mãi';
document.getElementById('kpi-new').textContent = fmt(s.new_count);
document.getElementById('kpi-new-sub').textContent = 'sản phẩm mới';
document.getElementById('kpi-sold').textContent = fmt(s.total_sold);
document.getElementById('kpi-sold-sub').textContent = fmt(s.selling_count) + ' SP đã bán';
document.getElementById('topbar-meta').textContent = fmt(s.total_products) + ' SP · ' + fmt(s.total_lines) + ' dòng SP · StarRocks live';
// Lines breakdown
if (d.product_lines?.length) {
document.getElementById('lines-breakdown').innerHTML = d.product_lines.map(l =>
`<div class="bd-item"><span class="bd-name">${emoji(l.product_line_vn)} ${l.product_line_vn||'N/A'}</span><span class="bd-val">${fmt(l.count)} SP · ${fmtK(l.avg_price)}₫</span></div>`
).join('');
}
// Gender breakdown
if (d.genders?.length) {
document.getElementById('gender-breakdown').innerHTML = d.genders.map(g => {
const e = g.gender_by_product === 'men' ? '👨' : g.gender_by_product === 'women' ? '👩' : '🧑';
const n = g.gender_by_product === 'men' ? 'Nam' : g.gender_by_product === 'women' ? 'Nữ' : g.gender_by_product === 'unisex' ? 'Unisex' : g.gender_by_product || 'N/A';
return `<div class="bd-item"><span class="bd-name">${e} ${n}</span><span class="bd-val">${fmt(g.count)} SP</span></div>`;
}).join('');
}
document.getElementById('breakdowns').style.display = 'grid';
} catch(e) { console.error('Overview error:', e); }
}
// ═══ LOAD PRODUCTS ═══
async function loadProducts() {
const c = document.getElementById('table-container');
c.innerHTML = '<div class="loading"><div class="spinner"></div>Đang tải...</div>';
const sort = document.getElementById('sort-select').value;
const gender = document.getElementById('gender-select').value;
const search = document.getElementById('search-input').value.trim();
let order = sort === 'product_name' ? 'asc' : 'desc';
let url = `/api/products/list?sort=${sort}&order=${order}&limit=${LIMIT}&offset=${offset}`;
if (gender) url += `&gender=${gender}`;
if (search) url += `&search=${encodeURIComponent(search)}`;
const color = document.getElementById('color-select').value;
if (color) url += `&color=${encodeURIComponent(color)}`;
const line = document.getElementById('line-select').value;
if (line) url += `&product_line=${encodeURIComponent(line)}`;
if (filterNew) url += `&is_new=true`;
if (filterDiscount) url += `&has_discount=true`;
try {
const r = await fetch(url);
const d = await r.json();
if (d.status !== 'success') throw new Error(d.message);
document.getElementById('table-meta').textContent = fmt(d.total) + ' sản phẩm tìm thấy';
if (!d.products?.length) {
c.innerHTML = '<div class="empty">📦 Không tìm thấy sản phẩm nào</div>';
document.getElementById('pager').style.display = 'none';
return;
}
// Determine max sold for bar scaling
const maxSold = Math.max(...d.products.map(p => p.quantity_sold || 0), 1);
c.innerHTML = renderProductTable(d, maxSold);
// Pagination
const pg = document.getElementById('pager');
pg.style.display = 'flex';
document.getElementById('page-info').textContent = `${offset+1}${Math.min(offset+d.products.length, d.total)} / ${fmt(d.total)}`;
document.getElementById('btn-prev').disabled = offset === 0;
document.getElementById('btn-next').disabled = offset + LIMIT >= d.total;
} catch(e) {
console.error('Products error:', e);
c.innerHTML = `<div class="empty">❌ Lỗi: ${e.message}</div>`;
}
}
// ═══ ACTIONS ═══
function prevPage() { offset = Math.max(0, offset-LIMIT); loadProducts(); }
function nextPage() { offset += LIMIT; loadProducts(); }
function toggleFilter(type) {
if (type === 'new') { filterNew = !filterNew; document.getElementById('btn-new').classList.toggle('active', filterNew); }
if (type === 'discount') { filterDiscount = !filterDiscount; document.getElementById('btn-discount').classList.toggle('active', filterDiscount); }
offset = 0; loadProducts();
}
function debouncedSearch() { clearTimeout(searchTimer); searchTimer = setTimeout(() => { offset = 0; loadProducts(); }, 400); }
async function loadAll() { await Promise.all([loadOverview(), loadProducts()]); }
// ═══ LOAD FILTER OPTIONS ═══
async function loadFilterOptions() {
try {
const r = await fetch('/api/products/filters');
const d = await r.json();
if (d.status !== 'success') return;
// Populate color dropdown
const colorSel = document.getElementById('color-select');
(d.colors || []).forEach(c => {
const opt = document.createElement('option');
opt.value = c.master_color;
// Extract Vietnamese part: "Đen/ Black" → "Đen"
const vn = c.master_color.split('/')[0].trim();
opt.textContent = `${vn} (${c.cnt})`;
colorSel.appendChild(opt);
});
// Populate product line dropdown
const lineSel = document.getElementById('line-select');
(d.product_lines || []).forEach(l => {
const opt = document.createElement('option');
opt.value = l.product_line_vn;
opt.textContent = `${l.product_line_vn} (${l.cnt})`;
lineSel.appendChild(opt);
});
} catch(e) { console.error('Filters error:', e); }
}
// ═══ INIT ═══
async function initAll() {
await loadFilterOptions();
await Promise.all([loadOverview(), loadProducts()]);
}
initAll();
</script>
</body>
</html>
<!DOCTYPE html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Resources - Canifa AI System</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&display=swap" rel="stylesheet">
<style>
<link rel="stylesheet" href="/static/lab.css">
<link rel="stylesheet" href="/static/warm-override.css">
<script src="/static/frame-detect.js"></script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Inter', sans-serif; background: #0b0e14; color: #c9d1d9; min-height: 100vh; display: flex; }
body { font-family: 'Inter', sans-serif; background: var(--bg, #FAF6F0); color: var(--t, #2C1810); min-height: 100vh; display: flex; }
/* ═══ SIDEBAR ═══ */
.sidebar { width: 260px; min-height: 100vh; background: #0d1117; border-right: 1px solid #1b2030; display: flex; flex-direction: column; position: fixed; top: 0; left: 0; z-index: 100; }
.sidebar-brand { padding: 24px 20px; border-bottom: 1px solid #1b2030; display: flex; align-items: center; gap: 12px; }
.sidebar { width: 260px; min-height: 100vh; background: var(--s, #FFFFFF); border-right: 1px solid var(--b, #E8DED0); display: flex; flex-direction: column; position: fixed; top: 0; left: 0; z-index: 100; }
.sidebar-brand { padding: 24px 20px; border-bottom: 1px solid var(--b, #E8DED0); display: flex; align-items: center; gap: 12px; }
.brand-icon { width: 40px; height: 40px; background: linear-gradient(135deg, #667eea, #764ba2); border-radius: 10px; display: flex; align-items: center; justify-content: center; font-size: 1.3em; flex-shrink: 0; }
.brand-text h2 { font-size: 1em; font-weight: 700; color: #e6edf3; }
.brand-text h2 { font-size: 1em; font-weight: 700; color: var(--t, #2C1810); }
.brand-text span { font-size: 0.7em; color: #484f58; font-weight: 500; }
.nav-group { padding: 16px 12px 8px; }
.nav-group-label { font-size: 0.65em; font-weight: 700; text-transform: uppercase; letter-spacing: 1.5px; color: #484f58; padding: 0 12px; margin-bottom: 8px; }
.nav-item { display: flex; align-items: center; gap: 12px; padding: 10px 16px; border-radius: 8px; color: #8b949e; text-decoration: none; font-size: 0.88em; font-weight: 500; transition: all 0.2s; margin-bottom: 2px; cursor: pointer; position: relative; }
.nav-item:hover { background: #161b22; color: #e6edf3; }
.nav-item { display: flex; align-items: center; gap: 12px; padding: 10px 16px; border-radius: 8px; color: var(--m, #6B5B4F); text-decoration: none; font-size: 0.88em; font-weight: 500; transition: all 0.2s; margin-bottom: 2px; cursor: pointer; position: relative; }
.nav-item:hover { background: var(--bg, #FAF6F0); color: var(--t, #2C1810); }
.nav-item.active { background: linear-gradient(135deg, rgba(102,126,234,0.15), rgba(118,75,162,0.1)); color: #a78bfa; font-weight: 600; }
.nav-item.active::before { content: ''; position: absolute; left: 0; top: 50%; transform: translateY(-50%); width: 3px; height: 20px; background: linear-gradient(180deg, #667eea, #764ba2); border-radius: 0 3px 3px 0; }
.nav-icon { font-size: 1.1em; width: 22px; text-align: center; flex-shrink: 0; }
......@@ -26,91 +29,91 @@
.badge-live { background: rgba(76,175,80,0.15); color: #4caf50; border: 1px solid rgba(76,175,80,0.25); }
.badge-new { background: rgba(102,126,234,0.15); color: #667eea; border: 1px solid rgba(102,126,234,0.25); }
.badge-beta { background: rgba(255,152,0,0.15); color: #ff9800; border: 1px solid rgba(255,152,0,0.25); }
.badge-count { background: rgba(88,166,255,0.15); color: #58a6ff; border: 1px solid rgba(88,166,255,0.2); }
.sidebar-footer { margin-top: auto; padding: 16px; border-top: 1px solid #1b2030; }
.version-info { display: flex; align-items: center; gap: 10px; padding: 10px 12px; background: #161b22; border-radius: 8px; border: 1px solid #1b2030; }
.badge-count { background: rgba(88,166,255,0.15); color: var(--gold, #B8860B); border: 1px solid rgba(88,166,255,0.2); }
.sidebar-footer { margin-top: auto; padding: 16px; border-top: 1px solid var(--b, #E8DED0); }
.version-info { display: flex; align-items: center; gap: 10px; padding: 10px 12px; background: var(--bg, #FAF6F0); border-radius: 8px; border: 1px solid var(--b, #E8DED0); }
.version-dot { width: 8px; height: 8px; border-radius: 50%; background: #56d364; box-shadow: 0 0 8px rgba(86,211,100,0.4); }
.version-text { font-size: 0.75em; color: #8b949e; }
.version-text { font-size: 0.75em; color: var(--m, #6B5B4F); }
/* ═══ MAIN ═══ */
.main { margin-left: 260px; flex: 1; min-height: 100vh; display: flex; flex-direction: column; }
.topbar { padding: 24px 32px; border-bottom: 1px solid #1b2030; display: flex; justify-content: space-between; align-items: center; }
.topbar h1 { font-size: 1.5em; font-weight: 800; color: #e6edf3; }
.topbar { padding: 24px 32px; border-bottom: 1px solid var(--b, #E8DED0); display: flex; justify-content: space-between; align-items: center; }
.topbar h1 { font-size: 1.5em; font-weight: 800; color: var(--t, #2C1810); }
.topbar p { font-size: 0.85em; color: #484f58; margin-top: 2px; }
.content { padding: 24px 32px; flex: 1; }
/* ═══ BUTTONS ═══ */
.btn { padding: 8px 16px; border-radius: 8px; font-size: 0.82em; font-weight: 600; border: 1px solid #1b2030; background: #161b22; color: #c9d1d9; cursor: pointer; transition: all 0.2s; }
.btn:hover { background: #1b2030; border-color: #30363d; }
.btn { padding: 8px 16px; border-radius: 8px; font-size: 0.82em; font-weight: 600; border: 1px solid var(--b, #E8DED0); background: var(--bg, #FAF6F0); color: var(--t, #2C1810); cursor: pointer; transition: all 0.2s; }
.btn:hover { background: var(--b, #E8DED0); border-color: var(--b, #E8DED0); }
.btn-primary { background: linear-gradient(135deg, #667eea, #764ba2); border: none; color: #fff; }
.btn-primary:hover { opacity: 0.9; transform: translateY(-1px); }
/* ═══ FILTERS ═══ */
.filter-bar { display: flex; gap: 8px; margin-bottom: 20px; flex-wrap: wrap; }
.filter-chip { padding: 6px 14px; border-radius: 20px; font-size: 0.78em; font-weight: 600; border: 1px solid #1b2030; background: transparent; color: #8b949e; cursor: pointer; transition: all 0.2s; }
.filter-chip:hover { background: #161b22; color: #e6edf3; }
.filter-chip { padding: 6px 14px; border-radius: 20px; font-size: 0.78em; font-weight: 600; border: 1px solid var(--b, #E8DED0); background: transparent; color: var(--m, #6B5B4F); cursor: pointer; transition: all 0.2s; }
.filter-chip:hover { background: var(--bg, #FAF6F0); color: var(--t, #2C1810); }
.filter-chip.active { background: rgba(102,126,234,0.15); color: #a78bfa; border-color: rgba(102,126,234,0.3); }
/* ═══ LINKS ═══ */
.links-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(380px, 1fr)); gap: 14px; }
.link-card { background: #0d1117; border: 1px solid #1b2030; border-radius: 12px; padding: 16px; transition: all 0.25s; display: flex; gap: 14px; align-items: flex-start; }
.link-card:hover { border-color: #30363d; transform: translateY(-2px); box-shadow: 0 8px 25px rgba(0,0,0,0.3); }
.link-card { background: var(--s, #FFFFFF); border: 1px solid var(--b, #E8DED0); border-radius: 12px; padding: 16px; transition: all 0.25s; display: flex; gap: 14px; align-items: flex-start; }
.link-card:hover { border-color: var(--b, #E8DED0); transform: translateY(-2px); box-shadow: 0 8px 25px rgba(0,0,0,0.3); }
.link-card.pinned { border-color: rgba(227,179,65,0.3); }
.link-icon-box { width: 42px; height: 42px; border-radius: 10px; background: #161b22; display: flex; align-items: center; justify-content: center; font-size: 1.3em; flex-shrink: 0; border: 1px solid #1b2030; }
.link-icon-box { width: 42px; height: 42px; border-radius: 10px; background: var(--bg, #FAF6F0); display: flex; align-items: center; justify-content: center; font-size: 1.3em; flex-shrink: 0; border: 1px solid var(--b, #E8DED0); }
.link-info { flex: 1; min-width: 0; }
.link-cat-tag { display: inline-flex; font-size: 0.6em; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; padding: 1px 6px; border-radius: 3px; margin-bottom: 6px; }
.lcat-tool { background: rgba(86,211,100,0.1); color: #56d364; }
.lcat-doc { background: rgba(167,139,250,0.1); color: #a78bfa; }
.lcat-design { background: rgba(247,120,186,0.1); color: #f778ba; }
.lcat-api { background: rgba(88,166,255,0.1); color: #58a6ff; }
.lcat-api { background: rgba(88,166,255,0.1); color: var(--gold, #B8860B); }
.lcat-repo { background: rgba(227,179,65,0.1); color: #e3b341; }
.lcat-other { background: rgba(139,148,158,0.1); color: #8b949e; }
.link-title { font-size: 0.92em; font-weight: 600; color: #e6edf3; margin-bottom: 4px; }
.link-url { font-size: 0.75em; color: #58a6ff; font-family: 'Consolas', monospace; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-bottom: 4px; cursor: pointer; }
.lcat-other { background: rgba(139,148,158,0.1); color: var(--m, #6B5B4F); }
.link-title { font-size: 0.92em; font-weight: 600; color: var(--t, #2C1810); margin-bottom: 4px; }
.link-url { font-size: 0.75em; color: var(--gold, #B8860B); font-family: 'Consolas', monospace; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-bottom: 4px; cursor: pointer; }
.link-url:hover { text-decoration: underline; }
.link-desc { font-size: 0.78em; color: #8b949e; line-height: 1.5; }
.link-desc { font-size: 0.78em; color: var(--m, #6B5B4F); line-height: 1.5; }
.link-actions-row { display: flex; gap: 4px; flex-shrink: 0; align-self: center; }
.link-act { width: 28px; height: 28px; border-radius: 6px; border: 1px solid #1b2030; background: transparent; color: #484f58; font-size: 0.78em; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: all 0.15s; }
.link-act:hover { background: #161b22; color: #e6edf3; border-color: #30363d; }
.link-act { width: 28px; height: 28px; border-radius: 6px; border: 1px solid var(--b, #E8DED0); background: transparent; color: #484f58; font-size: 0.78em; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: all 0.15s; }
.link-act:hover { background: var(--bg, #FAF6F0); color: var(--t, #2C1810); border-color: var(--b, #E8DED0); }
/* ═══ MODAL ═══ */
.modal-overlay { display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.7); backdrop-filter: blur(4px); z-index: 200; align-items: center; justify-content: center; }
.modal-overlay.show { display: flex; }
.modal { background: #161b22; border: 1px solid #1b2030; border-radius: 16px; width: 500px; max-width: 90vw; max-height: 85vh; overflow-y: auto; }
.modal-head { padding: 20px 24px; border-bottom: 1px solid #1b2030; display: flex; justify-content: space-between; align-items: center; }
.modal-head h3 { font-size: 1em; font-weight: 700; color: #e6edf3; }
.modal { background: var(--bg, #FAF6F0); border: 1px solid var(--b, #E8DED0); border-radius: 16px; width: 500px; max-width: 90vw; max-height: 85vh; overflow-y: auto; }
.modal-head { padding: 20px 24px; border-bottom: 1px solid var(--b, #E8DED0); display: flex; justify-content: space-between; align-items: center; }
.modal-head h3 { font-size: 1em; font-weight: 700; color: var(--t, #2C1810); }
.modal-close { width: 32px; height: 32px; border-radius: 8px; border: none; background: transparent; color: #484f58; font-size: 1.2em; cursor: pointer; display: flex; align-items: center; justify-content: center; }
.modal-close:hover { background: #0d1117; color: #f85149; }
.modal-close:hover { background: var(--s, #FFFFFF); color: var(--red, #DC2626); }
.modal-body { padding: 20px 24px; }
.modal-foot { padding: 16px 24px; border-top: 1px solid #1b2030; display: flex; justify-content: flex-end; gap: 10px; }
.modal-foot { padding: 16px 24px; border-top: 1px solid var(--b, #E8DED0); display: flex; justify-content: flex-end; gap: 10px; }
.form-group { margin-bottom: 14px; }
.form-label { font-size: 0.78em; font-weight: 600; color: #8b949e; margin-bottom: 6px; display: block; }
.form-input, .form-select { width: 100%; padding: 10px 14px; border-radius: 8px; border: 1px solid #1b2030; background: #0d1117; color: #e6edf3; font-size: 0.88em; font-family: inherit; transition: border-color 0.2s; }
.form-label { font-size: 0.78em; font-weight: 600; color: var(--m, #6B5B4F); margin-bottom: 6px; display: block; }
.form-input, .form-select { width: 100%; padding: 10px 14px; border-radius: 8px; border: 1px solid var(--b, #E8DED0); background: var(--s, #FFFFFF); color: var(--t, #2C1810); font-size: 0.88em; font-family: inherit; transition: border-color 0.2s; }
.form-input:focus, .form-select:focus { outline: none; border-color: #667eea; }
.form-select { cursor: pointer; }
.form-select option { background: #161b22; }
.form-select option { background: var(--bg, #FAF6F0); }
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.empty-state { text-align: center; padding: 60px 20px; color: #484f58; }
.empty-state .empty-icon { font-size: 3em; margin-bottom: 12px; opacity: 0.5; }
.header-row { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
.header-row h2 { font-size: 1.2em; font-weight: 700; color: #e6edf3; }
.header-row h2 { font-size: 1.2em; font-weight: 700; color: var(--t, #2C1810); }
/* ═══ USAGE GUIDE ═══ */
.guide { margin-top: 40px; border-top: 1px solid #1b2030; padding-top: 20px; }
.guide { margin-top: 40px; border-top: 1px solid var(--b, #E8DED0); padding-top: 20px; }
.guide-toggle { display: flex; align-items: center; gap: 8px; cursor: pointer; color: #484f58; font-size: 0.82em; font-weight: 600; background: none; border: none; padding: 6px 0; transition: color 0.2s; }
.guide-toggle:hover { color: #8b949e; }
.guide-toggle:hover { color: var(--m, #6B5B4F); }
.guide-toggle .arrow { transition: transform 0.2s; display: inline-block; }
.guide-toggle.open .arrow { transform: rotate(90deg); }
.guide-body { display: none; margin-top: 14px; padding: 20px; background: #0d1117; border: 1px solid #1b2030; border-radius: 12px; }
.guide-body { display: none; margin-top: 14px; padding: 20px; background: var(--s, #FFFFFF); border: 1px solid var(--b, #E8DED0); border-radius: 12px; }
.guide-body.show { display: block; }
.guide-body h4 { font-size: 0.85em; font-weight: 700; color: #e6edf3; margin: 0 0 10px; }
.guide-body h4 { font-size: 0.85em; font-weight: 700; color: var(--t, #2C1810); margin: 0 0 10px; }
.guide-body table { width: 100%; border-collapse: collapse; font-size: 0.8em; }
.guide-body th { text-align: left; padding: 8px 10px; color: #8b949e; font-weight: 600; border-bottom: 1px solid #1b2030; }
.guide-body td { padding: 8px 10px; color: #c9d1d9; border-bottom: 1px solid rgba(27,32,48,0.5); }
.guide-body th { text-align: left; padding: 8px 10px; color: var(--m, #6B5B4F); font-weight: 600; border-bottom: 1px solid var(--b, #E8DED0); }
.guide-body td { padding: 8px 10px; color: var(--t, #2C1810); border-bottom: 1px solid rgba(27,32,48,0.5); }
.guide-body tr:last-child td { border-bottom: none; }
.guide-tip { margin-top: 14px; padding: 10px 14px; background: rgba(102,126,234,0.08); border: 1px solid rgba(102,126,234,0.15); border-radius: 8px; font-size: 0.78em; color: #8b949e; line-height: 1.6; }
.guide-tip { margin-top: 14px; padding: 10px 14px; background: rgba(102,126,234,0.08); border: 1px solid rgba(102,126,234,0.15); border-radius: 8px; font-size: 0.78em; color: var(--m, #6B5B4F); line-height: 1.6; }
.guide-tip strong { color: #a78bfa; }
@media (max-width: 1024px) {
......
<!-- <!DOCTYPE html>
<!-- <!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Canifa Chatbot Test</title>
<style>
<link rel="stylesheet" href="/static/lab.css">
<link rel="stylesheet" href="/static/warm-override.css">
<script src="/static/frame-detect.js"></script>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
margin: 0;
......@@ -15,44 +18,7 @@
}
/* Navigation Header */
.nav-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 15px 30px;
display: flex;
justify-content: space-between;
align-items: center;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.3);
}
.nav-header h1 {
margin: 0;
color: white;
font-size: 1.5em;
}
.nav-links {
display: flex;
gap: 15px;
}
.nav-links a {
color: white;
text-decoration: none;
padding: 8px 16px;
border-radius: 6px;
background: rgba(255, 255, 255, 0.2);
transition: all 0.3s;
font-weight: 500;
}
.nav-links a:hover {
background: rgba(255, 255, 255, 0.3);
transform: translateY(-2px);
}
.nav-links a.active {
background: rgba(255, 255, 255, 0.4);
}
.main-content {
max-width: 900px;
......@@ -614,16 +580,7 @@
<body>
<!-- Navigation Header -->
<div class="nav-header">
<h1>🤖 Canifa AI System</h1>
<div class="nav-links">
<a href="/static/dashboard.html">📊 Dashboard</a>
<a href="/static/index.html" class="active">💬 Chatbot</a>
<a href="/static/test_sql.html">🤖 Text-to-SQL</a>
<a href="/static/test_db.html">🔍 DB Test</a>
<a href="/static/history.html">🧾 History</a>
</div>
</div>
<div class="main-content">
<div class="main-layout">
......
<!DOCTYPE html>
<html lang="vi">
<head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DashboardAI — Canifa</title>
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<script src="frame-detect.js"></script>
<style>
*{margin:0;padding:0;box-sizing:border-box}
html,body{height:100%;width:100%}
body{font-family:'Plus Jakarta Sans',system-ui,sans-serif;background:#F5F4F0;color:#18181B;display:flex;overflow:hidden}
::-webkit-scrollbar{width:5px}::-webkit-scrollbar-thumb{background:rgba(0,0,0,.12);border-radius:4px}
:root{--indigo:#6366F1;--emerald:#10B981;--amber:#F59E0B;--red:#EF4444;--purple:#8B5CF6;--cyan:#06B6D4;--pink:#EC4899;--orange:#F97316;--teal:#14B8A6;--blue:#3B82F6}
/* ── LEFT CHAT (warm/light theme to match platform) ── */
.chat{width:360px;min-width:320px;flex-shrink:0;background:#FFFFFF;display:flex;flex-direction:column;border-right:1px solid #E2E0D8}
html.in-iframe .chat{width:340px}
.chat-hdr{padding:18px 20px;border-bottom:1px solid #E2E0D8;display:flex;align-items:center;gap:10px}
.chat-avatar{width:38px;height:38px;border-radius:11px;background:linear-gradient(135deg,#6366F1,#8B5CF6);display:flex;align-items:center;justify-content:center;font-size:16px;flex-shrink:0;color:#fff;box-shadow:0 4px 12px rgba(99,102,241,.25)}
.chat-hdr-title{font-size:14px;font-weight:700;color:#18181B}
.chat-hdr-status{display:flex;align-items:center;gap:5px;margin-top:2px;font-size:11px;color:#10B981}
.online-dot{width:6px;height:6px;border-radius:50%;background:#10B981;animation:pulse 2s ease-in-out infinite}
@keyframes pulse{0%,100%{opacity:.4}50%{opacity:1}}
.chat-reset{margin-left:auto;background:#F5F4F0;border:1px solid #E2E0D8;border-radius:8px;width:30px;height:30px;cursor:pointer;display:flex;align-items:center;justify-content:center;color:#78716C;font-size:13px;transition:.15s}
.chat-reset:hover{background:#E2E0D8;color:#18181B}
.model-bar{padding:10px 20px;border-bottom:1px solid #E2E0D8;display:flex;align-items:center;gap:8px}
.model-bar label{font-size:10px;font-weight:600;color:#78716C}
.model-select{padding:5px 10px;border:1px solid #E2E0D8;border-radius:8px;font-family:inherit;font-size:11px;background:#F5F4F0;color:#18181B;outline:none;cursor:pointer;min-width:160px}
.model-select:focus{border-color:#B45309;box-shadow:0 0 0 3px rgba(180,83,9,.1)}
.chat-body{flex:1;overflow-y:auto;padding:14px 16px;display:flex;flex-direction:column;gap:10px;background:#FAFAF8}
.msg{display:flex;gap:8px;align-items:flex-end;animation:fadeUp .25s ease}
@keyframes fadeUp{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:translateY(0)}}
.msg-user{flex-direction:row-reverse}
.msg-avatar{width:26px;height:26px;border-radius:8px;background:linear-gradient(135deg,#6366F1,#8B5CF6);display:flex;align-items:center;justify-content:center;flex-shrink:0;font-size:11px;color:#fff}
.msg-bubble{max-width:78%;padding:10px 14px;font-size:12.5px;line-height:1.6;white-space:pre-wrap;border-radius:14px 14px 14px 4px}
.msg-user .msg-bubble{border-radius:14px 14px 4px 14px;background:linear-gradient(135deg,#6366F1,#7C6CF6);color:#fff;box-shadow:0 2px 8px rgba(99,102,241,.2)}
.msg-ai .msg-bubble{background:#FFFFFF;color:#334155;border:1px solid #E2E0D8;box-shadow:0 1px 2px rgba(0,0,0,.04)}
.msg-ai .msg-bubble b{color:#18181B}
.typing{display:flex;gap:5px;padding:11px 14px;background:#FFFFFF;border:1px solid #E2E0D8;border-radius:14px 14px 14px 4px}
.dot{width:5px;height:5px;border-radius:50%;background:#6366F1;animation:pulse 1.2s ease-in-out infinite}
.dot:nth-child(2){animation-delay:.2s}.dot:nth-child(3){animation-delay:.4s}
.chat-input-wrap{padding:14px 16px;border-top:1px solid #E2E0D8;background:#FFFFFF}
.chat-input-box{display:flex;gap:8px;background:#F5F4F0;border-radius:12px;padding:8px 8px 8px 14px;border:1px solid #E2E0D8;transition:.2s}
.chat-input-box:focus-within{border-color:#B45309;box-shadow:0 0 0 3px rgba(180,83,9,.1)}
.chat-input-box textarea{flex:1;background:transparent;border:none;color:#18181B;font-family:inherit;font-size:12.5px;line-height:1.5;resize:none;outline:none}
.chat-input-box textarea::placeholder{color:#A8A29E}
.send-btn{width:34px;height:34px;border-radius:8px;border:none;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:.2s;align-self:flex-end;flex-shrink:0;font-size:14px;color:#fff}
.send-btn.active{background:#6366F1;box-shadow:0 2px 8px rgba(99,102,241,.3)}
.send-btn.active:hover{background:#4F46E5;transform:scale(1.05)}
.send-btn.disabled{background:#E2E0D8;color:#A8A29E;cursor:default}
.chat-hint{margin:7px 0 0;font-size:10px;color:#A8A29E;text-align:center}
/* ── RIGHT DASHBOARD ── */
.dash{flex:1;display:flex;flex-direction:column;overflow:hidden}
.dash-top{height:56px;background:#fff;border-bottom:1px solid #E2E8F0;display:flex;align-items:center;padding:0 24px;gap:12px;flex-shrink:0;box-shadow:0 1px 3px rgba(0,0,0,.04)}
.dash-icon{width:32px;height:32px;border-radius:10px;background:linear-gradient(135deg,#EEF2FF,#E0E7FF);display:flex;align-items:center;justify-content:center;font-size:15px}
.dash-title{font-weight:700;color:#0F172A;font-size:14px}
.dash-sub{font-size:12px;color:#94A3B8;margin-left:4px}
.dash-badge{margin-left:auto;font-size:11px;color:#6366F1;background:#EEF2FF;padding:4px 12px;border-radius:100px;font-weight:600}
.dash-body{flex:1;overflow-y:auto;padding:24px;display:flex;flex-direction:column}
/* ── GRID (12 col) ── */
.widget-grid{display:grid;grid-template-columns:repeat(12,1fr);gap:16px}
.widget-xs{grid-column:span 2}.widget-sm{grid-column:span 3}.widget-md{grid-column:span 4}
.widget-half{grid-column:span 6}.widget-lg{grid-column:span 8}.widget-full{grid-column:span 12}
@media(max-width:1200px){.widget-xs,.widget-sm{grid-column:span 6}.widget-md{grid-column:span 6}.widget-lg{grid-column:span 12}}
/* ── WIDGET CARD ── */
.wcard{background:#fff;border-radius:16px;border:1px solid #F1F5F9;height:100%;transition:box-shadow .2s,transform .2s;box-shadow:0 1px 3px rgba(0,0,0,.04);overflow:hidden}
.wcard:hover{box-shadow:0 8px 24px rgba(0,0,0,.06);transform:translateY(-1px)}
/* Skeleton */
.wcard.skeleton{position:relative;overflow:hidden}
.wcard.skeleton::after{content:'';position:absolute;inset:0;background:linear-gradient(90deg,transparent,rgba(0,0,0,.03),transparent);animation:shimmer 1.5s infinite}
@keyframes shimmer{0%{transform:translateX(-100%)}100%{transform:translateX(100%)}}
.skel-title{height:12px;width:60%;background:#F1F5F9;border-radius:4px;margin:20px 22px 0}
.skel-value{height:28px;width:40%;background:#F1F5F9;border-radius:6px;margin:12px 22px}
.skel-chart{height:180px;background:#F8FAFC;border-radius:8px;margin:0 22px 20px}
/* ── KPI CARD ── */
.kpi-card{padding:20px 22px;display:flex;flex-direction:column;gap:8px;position:relative;overflow:hidden}
.kpi-card::before{content:'';position:absolute;top:0;left:0;width:4px;height:100%;border-radius:0 4px 4px 0}
.kpi-card.c-indigo::before{background:var(--indigo)}.kpi-card.c-emerald::before{background:var(--emerald)}
.kpi-card.c-amber::before{background:var(--amber)}.kpi-card.c-red::before{background:var(--red)}
.kpi-card.c-purple::before{background:var(--purple)}.kpi-card.c-cyan::before{background:var(--cyan)}
.kpi-card.c-pink::before{background:var(--pink)}.kpi-card.c-orange::before{background:var(--orange)}
.kpi-card.c-teal::before{background:var(--teal)}.kpi-card.c-blue::before{background:var(--blue)}
.kpi-card::after{content:'';position:absolute;top:-20px;right:-20px;width:80px;height:80px;border-radius:50%;opacity:.06}
.kpi-card.c-indigo::after{background:var(--indigo)}.kpi-card.c-emerald::after{background:var(--emerald)}
.kpi-card.c-amber::after{background:var(--amber)}.kpi-card.c-red::after{background:var(--red)}
.kpi-card.c-purple::after{background:var(--purple)}.kpi-card.c-cyan::after{background:var(--cyan)}
.kpi-card.c-pink::after{background:var(--pink)}.kpi-card.c-orange::after{background:var(--orange)}
.kpi-card.c-teal::after{background:var(--teal)}.kpi-card.c-blue::after{background:var(--blue)}
.kpi-label{font-size:11px;font-weight:600;color:#94A3B8;text-transform:uppercase;letter-spacing:.06em}
.kpi-value{font-size:28px;font-weight:800;line-height:1}
.kpi-sub{font-size:11px;color:#94A3B8;font-weight:500}
/* ── CHART + TABLE SHARED ── */
.chart-card{padding:20px 22px}
.chart-title{font-size:13px;font-weight:600;color:#0F172A;margin-bottom:16px;display:flex;align-items:center;gap:8px}
.chart-title::before{content:'';width:3px;height:14px;border-radius:2px}
.chart-title.c-indigo::before{background:var(--indigo)}.chart-title.c-emerald::before{background:var(--emerald)}
.chart-title.c-amber::before{background:var(--amber)}.chart-title.c-red::before{background:var(--red)}
.chart-title.c-purple::before{background:var(--purple)}.chart-title.c-cyan::before{background:var(--cyan)}
.chart-title.c-pink::before{background:var(--pink)}.chart-title.c-orange::before{background:var(--orange)}
.chart-title.c-teal::before{background:var(--teal)}.chart-title.c-blue::before{background:var(--blue)}
.chart-wrap{position:relative;height:220px}.chart-wrap canvas{width:100%!important;height:100%!important}
.donut-layout{display:flex;align-items:center;gap:20px}
.donut-canvas-wrap{flex-shrink:0;width:160px;height:160px}
.donut-legend{flex:1;display:flex;flex-direction:column;gap:8px}
.donut-item{display:flex;align-items:center;justify-content:space-between}
.donut-name{font-size:12px;color:#475569;display:flex;align-items:center;gap:8px}
.donut-dot{width:8px;height:8px;border-radius:50%;flex-shrink:0}
.donut-pct{font-size:12px;font-weight:700;color:#0F172A}
/* Number Row */
.number-row{display:flex;gap:16px;padding:20px 22px}
.nr-item{flex:1;text-align:center}
.nr-item-label{font-size:10px;font-weight:600;color:#94A3B8;text-transform:uppercase;letter-spacing:.04em;margin-bottom:4px}
.nr-item-value{font-size:22px;font-weight:800;line-height:1}
.nr-divider{width:1px;background:#F1F5F9;flex-shrink:0}
/* Progress */
.progress-list{display:flex;flex-direction:column;gap:12px}
.progress-row{display:flex;align-items:center;gap:10px}
.progress-label{font-size:12px;color:#475569;min-width:100px;flex-shrink:0}
.progress-bar-bg{flex:1;height:8px;background:#F1F5F9;border-radius:4px;overflow:hidden}
.progress-bar-fill{height:100%;border-radius:4px;transition:width .6s ease}
.progress-value{font-size:12px;font-weight:600;color:#0F172A;min-width:40px;text-align:right}
/* Table */
.table-card{padding:20px 22px}.table-wrap{overflow-x:auto;border-radius:10px;border:1px solid #F1F5F9}
.wtable{width:100%;border-collapse:collapse;font-size:12px}
.wtable th{text-align:left;padding:10px 14px;color:#64748B;font-weight:600;font-size:10px;text-transform:uppercase;letter-spacing:.04em;background:#F8FAFC;border-bottom:2px solid #E2E8F0;white-space:nowrap}
.wtable td{padding:10px 14px;color:#334155;border-bottom:1px solid #F1F5F9}
.wtable tbody tr:hover td{background:#F8FAFC}
.wtable td:first-child{font-weight:500;color:#0F172A}
/* Error */
.widget-error-msg{color:#EF4444;font-size:11px;background:#FEF2F2;padding:10px 14px;border-radius:10px;border:1px solid #FECACA}
/* Empty */
.empty{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:20px;padding:32px}
.empty-icon{width:64px;height:64px;border-radius:18px;background:linear-gradient(135deg,#EEF2FF,#E0E7FF);display:flex;align-items:center;justify-content:center;font-size:28px;box-shadow:0 4px 12px rgba(99,102,241,.1)}
.empty h3{font-weight:700;color:#0F172A;font-size:16px}
.empty p{font-size:13px;color:#94A3B8;margin-top:-12px}
.suggest-list{display:flex;flex-direction:column;gap:8px;width:100%;max-width:420px}
.suggest-btn{background:#fff;border:1px solid #E2E8F0;border-radius:12px;padding:12px 16px;font-size:12.5px;color:#475569;cursor:pointer;text-align:left;font-family:inherit;transition:.2s;box-shadow:0 1px 2px rgba(0,0,0,.04)}
.suggest-btn:hover{background:#EEF2FF;border-color:#C7D2FE;color:#4338CA;box-shadow:0 4px 12px rgba(99,102,241,.08);transform:translateY(-1px)}
.spinner-wrap{flex:1;display:flex;align-items:center;justify-content:center}
.spinner{width:40px;height:40px;border-radius:50%;border:3px solid #E2E8F0;border-top-color:#6366F1;animation:spin .7s linear infinite;margin:0 auto 14px}
@keyframes spin{to{transform:rotate(360deg)}}
.spinner-text{font-size:13px;color:#64748B;text-align:center}
.spinner-sub{font-size:11px;color:#94A3B8;text-align:center;margin-top:4px}
</style>
</head>
<body>
<div class="chat">
<div class="chat-hdr">
<div class="chat-avatar"></div>
<div><div class="chat-hdr-title">DashboardAI</div><div class="chat-hdr-status"><span class="online-dot"></span><span id="statusLabel">Checking...</span></div></div>
<button class="chat-reset" onclick="resetAll()" title="Reset"></button>
</div>
<div class="model-bar">
<label>🤖 Model:</label>
<select class="model-select" id="modelSelect" onchange="selectedModel=this.value;localStorage.setItem('sql_chat_model',this.value)">
<optgroup label="ChatGPT Plus (Codex)">
<option value="codex/gpt-5.3-codex">GPT-5.3 Codex</option>
<option value="codex/gpt-5.2-codex">GPT-5.2 Codex</option>
<option value="codex/gpt-5.2">GPT-5.2</option>
<option value="codex/gpt-5.1">GPT-5.1</option>
<option value="codex/gpt-5">GPT-5</option>
</optgroup>
</select>
</div>
<div class="chat-body" id="chatBody">
<div class="msg msg-ai"><div class="msg-avatar"></div><div class="msg-bubble">👋 Xin chào! Tôi là <b>DashboardAI</b>.<br><br>Mô tả báo cáo bạn muốn, tôi sẽ tạo dashboard với <b>KPI</b>, <b>charts</b>, <b>tables</b> từ dữ liệu thực.<br><br>Chọn gợi ý bên phải →</div></div>
</div>
<div class="chat-input-wrap">
<div class="chat-input-box">
<textarea id="chatInput" rows="2" placeholder="Mô tả dashboard bạn muốn..." onkeydown="if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();sendMessage()}"></textarea>
<button class="send-btn disabled" id="sendBtn" onclick="sendMessage()"></button>
</div>
<div class="chat-hint">Enter gửi · Shift+Enter xuống dòng</div>
</div>
</div>
<div class="dash">
<div class="dash-top">
<div class="dash-icon">📊</div>
<span class="dash-title" id="dashTitle">Dashboard Preview</span>
<span class="dash-sub" id="dashSub"></span>
<span class="dash-badge" id="dashBadge" style="display:none">⚡ 0 widgets</span>
</div>
<div class="dash-body" id="dashBody">
<div class="empty" id="emptyState">
<div class="empty-icon">📊</div>
<h3>Dashboard sẽ xuất hiện tại đây</h3>
<p>Mô tả báo cáo bạn muốn ở khung chat bên trái</p>
<div class="suggest-list">
<button class="suggest-btn" onclick="quickSend('Phân tích tổng quan sản phẩm Canifa')">📦 Phân tích tổng quan sản phẩm Canifa</button>
<button class="suggest-btn" onclick="quickSend('So sánh sản phẩm nam và nữ theo giá bán và số lượng')">👫 So sánh nam vs nữ theo giá & số lượng</button>
<button class="suggest-btn" onclick="quickSend('Dashboard phân tích chất liệu và form dáng sản phẩm')">🧵 Phân tích chất liệu & form dáng</button>
<button class="suggest-btn" onclick="quickSend('Báo cáo sản phẩm giảm giá và hiệu quả khuyến mãi')">🏷️ Báo cáo giảm giá & khuyến mãi</button>
</div>
</div>
</div>
</div>
<script>
const PALETTE=['#6366F1','#10B981','#F59E0B','#EF4444','#8B5CF6','#06B6D4','#EC4899','#F97316','#14B8A6','#3B82F6'];
const COLORS=['indigo','emerald','amber','red','purple','cyan','pink','orange','teal','blue'];
const SIZE_MAP={xs:'widget-xs',sm:'widget-sm',md:'widget-md',half:'widget-half',lg:'widget-lg',full:'widget-full'};
let selectedModel=localStorage.getItem('sql_chat_model')||'codex/gpt-5.3-codex';
let loading=false, chartInstances=[], widgetMeta={};
document.getElementById('modelSelect').value=selectedModel;
const inp=document.getElementById('chatInput');
inp.addEventListener('input',()=>{document.getElementById('sendBtn').className='send-btn '+(inp.value.trim()&&!loading?'active':'disabled')});
(async()=>{try{const r=await fetch('/api/sql-chat/status');const d=await r.json();const el=document.getElementById('statusLabel');el.textContent=d.codex_available?'Codex · Online':'Offline';el.style.color=d.codex_available?'#4ADE80':'#EF4444'}catch(e){document.getElementById('statusLabel').textContent='Offline'}})();
function esc(s){return(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')}
function fmt(v){if(v==null)return'—';if(typeof v!=='number')return String(v);if(Math.abs(v)>=1e9)return(v/1e9).toFixed(1)+'B';if(Math.abs(v)>=1e6)return(v/1e6).toFixed(1)+'M';if(Math.abs(v)>=1e3)return(v/1e3).toFixed(1)+'K';if(Number.isInteger(v))return v.toLocaleString('vi-VN');return v.toFixed(2)}
function humanCol(c){return c.replace(/_/g,' ').replace(/\b\w/g,c=>c.toUpperCase())}
function quickSend(t){inp.value=t;sendMessage()}
function getColor(c){const i=COLORS.indexOf(c);return PALETTE[i>=0?i:0]}
function addMsg(t,u){const b=document.getElementById('chatBody');const d=document.createElement('div');d.className='msg '+(u?'msg-user':'msg-ai');d.innerHTML=u?`<div class="msg-bubble">${esc(t)}</div>`:`<div class="msg-avatar">✦</div><div class="msg-bubble">${t}</div>`;b.appendChild(d);b.scrollTop=b.scrollHeight}
function showTyping(){const b=document.getElementById('chatBody');const d=document.createElement('div');d.className='msg msg-ai';d.id='typingMsg';d.innerHTML=`<div class="msg-avatar">✦</div><div class="typing"><div class="dot"></div><div class="dot"></div><div class="dot"></div></div>`;b.appendChild(d);b.scrollTop=b.scrollHeight}
function rmTyping(){const e=document.getElementById('typingMsg');if(e)e.remove()}
// ── SSE STREAMING ──
async function sendMessage(){
const text=inp.value.trim();if(!text||loading)return;
inp.value='';document.getElementById('sendBtn').className='send-btn disabled';loading=true;
addMsg(text,true);showTyping();
document.getElementById('dashBody').innerHTML=`<div class="spinner-wrap"><div><div class="spinner"></div><div class="spinner-text">AI đang thiết kế dashboard...</div><div class="spinner-sub">Generating layout → Executing SQL → Streaming widgets</div></div></div>`;
try{
const resp=await fetch('/api/sql-dashboard',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({question:text,model:selectedModel,history:[]})});
const reader=resp.body.getReader();
const decoder=new TextDecoder();
let buffer='';
let widgetCount=0, errorCount=0;
while(true){
const{done,value}=await reader.read();
if(done)break;
buffer+=decoder.decode(value,{stream:true});
const lines=buffer.split('\n');
buffer=lines.pop()||'';
for(const line of lines){
if(!line.startsWith('data: '))continue;
const json_str=line.slice(6).trim();
if(!json_str)continue;
try{
const event=JSON.parse(json_str);
console.log('📡 SSE event:', event.type, event);
if(event.type==='header') { console.log('📋 Header widgets:', event.widgets); handleHeader(event); }
else if(event.type==='widget') { console.log('📊 Widget data:', event.id, 'rows:', event.data?.length, 'error:', event.error, event); handleWidget(event); widgetCount++; }
else if(event.type==='error') { console.error('❌ SSE error:', event.message); rmTyping(); addMsg(`❌ ${esc(event.message)}`,false); }
else if(event.type==='done') {
console.log('✅ SSE done! widgets:', widgetCount);
rmTyping();
addMsg(`✅ Dashboard sẵn sàng! <b>${widgetCount}</b> widgets${errorCount?' ('+errorCount+' lỗi)':''}`,false);
}
}catch(e){ console.error('❌ SSE parse error:', e, 'raw:', json_str); }
}
}
}catch(e){rmTyping();addMsg(`❌ ${esc(e.message)}`,false)}
loading=false;
}
function handleHeader(h){
rmTyping(); // LLM phase done
showTyping(); // now show typing for SQL phase
document.getElementById('dashTitle').textContent=h.title||'Dashboard';
document.getElementById('dashSub').textContent=h.subtitle?`— ${h.subtitle}`:'';
const badge=document.getElementById('dashBadge');badge.innerHTML=`⚡ ${h.widgets.length} widgets`;badge.style.display='';
// Render skeleton grid
chartInstances.forEach(c=>c.destroy());chartInstances=[];widgetMeta={};
let html='<div class="widget-grid" id="widgetGrid">';
h.widgets.forEach((w,i)=>{
widgetMeta[w.id]={type:w.type,title:w.title,size:w.size,color:w.color||COLORS[i%COLORS.length]};
const skelContent=w.type==='kpi'||w.type==='number-row'
?`<div class="skel-title"></div><div class="skel-value"></div>`
:`<div class="skel-title"></div><div class="skel-chart"></div>`;
html+=`<div class="${SIZE_MAP[w.size]||'widget-md'}" id="slot_${w.id}" style="animation:fadeUp .3s ease ${i*50}ms both"><div class="wcard skeleton">${skelContent}</div></div>`;
});
html+='</div>';
document.getElementById('dashBody').innerHTML=html;
}
function handleWidget(ev){
const meta=widgetMeta[ev.id];if(!meta)return;
const slot=document.getElementById('slot_'+ev.id);if(!slot)return;
const {type: _evType, ...evData} = ev; // exclude ev.type="widget" to preserve meta.type="kpi"
const w={...meta,...evData};
slot.innerHTML=`<div class="wcard" style="animation:fadeUp .3s ease">${renderWidget(w)}</div>`;
// Render Chart.js after DOM insert
if(['bar','horizontal-bar','line','area','scatter'].includes(w.type)&&!w.error) setTimeout(()=>renderChart(w),30);
if(w.type==='donut'&&!w.error) setTimeout(()=>renderDonutChart(w),30);
}
function renderWidget(w){
if(w.error)return`<div class="chart-card"><div class="chart-title c-${w.color}">${esc(w.title)}</div><div class="widget-error-msg">⚠️ ${esc(w.error)}</div></div>`;
switch(w.type){
case'kpi':return renderKPI(w);
case'number-row':return renderNumberRow(w);
case'donut':return renderDonut(w);
case'table':return renderTable(w);
case'progress':return renderProgress(w);
default:return renderChartCard(w);
}
}
// ── KPI ──
function renderKPI(w){
const d=w.data&&w.data[0]?w.data[0]:{};const k=Object.keys(d);const v=k.length?d[k[0]]:0;
return`<div class="kpi-card c-${w.color}"><div class="kpi-label">${esc(w.title)}</div><div class="kpi-value" style="color:var(--${w.color})">${fmt(v)}</div><div class="kpi-sub">${esc(k[0]?humanCol(k[0]):'')}</div></div>`;
}
// ── NUMBER ROW ──
function renderNumberRow(w){
const d=w.data&&w.data[0]?w.data[0]:{};const keys=Object.keys(d);
let html=`<div class="number-row">`;
keys.forEach((k,i)=>{
if(i>0)html+=`<div class="nr-divider"></div>`;
html+=`<div class="nr-item"><div class="nr-item-label">${esc(humanCol(k))}</div><div class="nr-item-value" style="color:${PALETTE[i%PALETTE.length]}">${fmt(d[k])}</div></div>`;
});
return html+`</div>`;
}
// ── PROGRESS ──
function renderProgress(w){
const data=w.data||[];const xk=w.x_key||Object.keys(data[0]||{})[0];
const yks=w.y_keys||[];const ck=yks[0]||'';const mk=yks[1]||'';
let html=`<div class="chart-card"><div class="chart-title c-${w.color}">${esc(w.title)}</div><div class="progress-list">`;
data.slice(0,10).forEach((r,i)=>{
const cur=Number(r[ck])||0;const max=Number(r[mk])||1;const pct=Math.min(100,(cur/max*100)).toFixed(0);
html+=`<div class="progress-row"><div class="progress-label">${esc(String(r[xk]||''))}</div><div class="progress-bar-bg"><div class="progress-bar-fill" style="width:${pct}%;background:${PALETTE[i%PALETTE.length]}"></div></div><div class="progress-value">${pct}%</div></div>`;
});
return html+`</div></div>`;
}
// ── CHART (bar/horizontal-bar/line/area/scatter) ──
function renderChartCard(w){return`<div class="chart-card"><div class="chart-title c-${w.color}">${esc(w.title)}</div><div class="chart-wrap"><canvas id="c_${w.id}"></canvas></div></div>`}
function renderChart(w){
const canvas=document.getElementById('c_'+w.id);if(!canvas)return;
const data=w.data||[];if(!data.length)return;
const xk=w.x_key||Object.keys(data[0])[0];
const yk=w.y_key||Object.keys(data[0]).find(k=>k!==xk&&typeof data[0][k]==='number')||Object.keys(data[0])[1];
const labels=data.map(r=>String(r[xk]||''));
const values=data.map(r=>Number(r[yk])||0);
const mc=getColor(w.color);
const baseOpts={responsive:true,maintainAspectRatio:false,animation:{duration:600,easing:'easeOutQuart'},
plugins:{legend:{display:false},tooltip:{backgroundColor:'#0F172A',titleColor:'#fff',bodyColor:'#E2E8F0',borderColor:mc,borderWidth:1,cornerRadius:10,titleFont:{family:"'Plus Jakarta Sans'",size:12,weight:'600'},bodyFont:{family:"'Plus Jakarta Sans'",size:11},padding:12,callbacks:{label:ctx=>`${humanCol(yk)}: ${fmt(ctx.parsed.y!=null?ctx.parsed.y:ctx.parsed)}`}}},
scales:{x:{grid:{display:false},ticks:{font:{size:10,family:"'Plus Jakarta Sans'"},color:'#94A3B8',maxRotation:45},border:{display:false}},y:{grid:{color:'#F1F5F9',drawBorder:false},ticks:{font:{size:10,family:"'Plus Jakarta Sans'"},color:'#94A3B8',callback:v=>fmt(v)},border:{display:false},beginAtZero:true}}
};
let cfg;
if(w.type==='bar') cfg={type:'bar',data:{labels,datasets:[{data:values,backgroundColor:mc+'CC',hoverBackgroundColor:mc,borderRadius:6,borderSkipped:false,maxBarThickness:40}]},options:baseOpts};
else if(w.type==='horizontal-bar') cfg={type:'bar',data:{labels,datasets:[{data:values,backgroundColor:PALETTE.slice(0,labels.length).map(c=>c+'CC'),borderRadius:4,borderSkipped:false}]},options:{...baseOpts,indexAxis:'y',scales:{x:{grid:{color:'#F1F5F9',drawBorder:false},ticks:{font:{size:10},color:'#94A3B8',callback:v=>fmt(v)},border:{display:false},beginAtZero:true},y:{grid:{display:false},ticks:{font:{size:10,family:"'Plus Jakarta Sans'"},color:'#334155'},border:{display:false}}}}};
else if(w.type==='line') cfg={type:'line',data:{labels,datasets:[{data:values,borderColor:mc,backgroundColor:'transparent',borderWidth:2.5,tension:.4,pointRadius:3,pointHoverRadius:6,pointBackgroundColor:'#fff',pointBorderColor:mc,pointBorderWidth:2}]},options:baseOpts};
else if(w.type==='area') cfg={type:'line',data:{labels,datasets:[{data:values,borderColor:mc,borderWidth:2.5,tension:.4,fill:true,pointRadius:0,pointHoverRadius:5,pointBackgroundColor:'#fff',pointBorderColor:mc,pointBorderWidth:2}]},options:baseOpts,plugins:[{id:'areaGrad',beforeDatasetDraw:(chart)=>{const ds=chart.data.datasets[0];if(!ds._gDone){const ctx=chart.ctx;const a=chart.chartArea;const g=ctx.createLinearGradient(0,a.top,0,a.bottom);g.addColorStop(0,mc+'30');g.addColorStop(1,mc+'03');ds.backgroundColor=g;ds._gDone=true;chart.update('none')}}}]};
else if(w.type==='scatter'){
const xk2=w.x_key||Object.keys(data[0])[0];const yk2=w.y_key||Object.keys(data[0]).find(k=>k!==xk2&&typeof data[0][k]==='number');
const pts=data.map(r=>({x:Number(r[xk2])||0,y:Number(r[yk2])||0}));
cfg={type:'scatter',data:{datasets:[{data:pts,backgroundColor:mc+'99',borderColor:mc,borderWidth:1,pointRadius:5,pointHoverRadius:8}]},
options:{...baseOpts,scales:{x:{grid:{color:'#F1F5F9'},ticks:{font:{size:10},color:'#94A3B8',callback:v=>fmt(v)},border:{display:false},title:{display:true,text:humanCol(xk2),font:{size:10},color:'#94A3B8'}},y:{grid:{color:'#F1F5F9'},ticks:{font:{size:10},color:'#94A3B8',callback:v=>fmt(v)},border:{display:false},beginAtZero:true,title:{display:true,text:humanCol(yk2),font:{size:10},color:'#94A3B8'}}}}
};
}
if(cfg){const c=new Chart(canvas.getContext('2d'),cfg);chartInstances.push(c)}
}
// ── DONUT ──
function renderDonut(w){
const data=w.data||[];const xk=w.x_key||(data[0]?Object.keys(data[0])[0]:'');
const yk=w.y_key||(data[0]?Object.keys(data[0]).find(k=>k!==xk&&typeof data[0][k]==='number'):'');
const total=data.reduce((a,r)=>a+(Number(r[yk])||0),0);
let leg='<div class="donut-legend">';
data.forEach((r,i)=>{const pct=total>0?((Number(r[yk])||0)/total*100).toFixed(1):'0';
leg+=`<div class="donut-item"><div class="donut-name"><div class="donut-dot" style="background:${PALETTE[i%PALETTE.length]}"></div>${esc(String(r[xk]||''))}</div><div class="donut-pct">${pct}%</div></div>`;
}); leg+='</div>';
return`<div class="chart-card"><div class="chart-title c-${w.color}">${esc(w.title)}</div><div class="donut-layout"><div class="donut-canvas-wrap"><canvas id="c_${w.id}" width="160" height="160"></canvas></div>${leg}</div></div>`;
}
function renderDonutChart(w){
const cv=document.getElementById('c_'+w.id);if(!cv)return;
const data=w.data||[];const xk=w.x_key||(data[0]?Object.keys(data[0])[0]:'');
const yk=w.y_key||(data[0]?Object.keys(data[0]).find(k=>k!==xk&&typeof data[0][k]==='number'):'');
const c=new Chart(cv.getContext('2d'),{type:'doughnut',data:{labels:data.map(r=>String(r[xk]||'')),datasets:[{data:data.map(r=>Number(r[yk])||0),backgroundColor:PALETTE.slice(0,data.length),borderWidth:0,hoverOffset:6}]},options:{responsive:true,maintainAspectRatio:false,cutout:'65%',plugins:{legend:{display:false},tooltip:{backgroundColor:'#0F172A',cornerRadius:10,padding:12}}}});
chartInstances.push(c);
}
// ── TABLE ──
function renderTable(w){
const data=w.data||[];const cols=w.columns||(data[0]?Object.keys(data[0]):[]);
let h=`<div class="table-card"><div class="chart-title c-${w.color}">${esc(w.title)}</div><div class="table-wrap"><table class="wtable"><thead><tr>`;
cols.forEach(c=>{h+=`<th>${esc(humanCol(c))}</th>`});h+='</tr></thead><tbody>';
data.slice(0,50).forEach(r=>{h+='<tr>';cols.forEach(c=>{const v=r[c];h+=`<td>${v==null?'<span style="color:#CBD5E1">—</span>':(typeof v==='number'?fmt(v):esc(String(v)))}</td>`});h+='</tr>'});
return h+'</tbody></table></div></div>';
}
function resetAll(){
chartInstances.forEach(c=>c.destroy());chartInstances=[];widgetMeta={};
document.getElementById('dashBody').innerHTML=`<div class="empty"><div class="empty-icon">📊</div><h3>Dashboard sẽ xuất hiện tại đây</h3><p>Mô tả báo cáo bạn muốn</p><div class="suggest-list"><button class="suggest-btn" onclick="quickSend('Phân tích tổng quan sản phẩm Canifa')">📦 Phân tích tổng quan sản phẩm Canifa</button><button class="suggest-btn" onclick="quickSend('So sánh sản phẩm nam và nữ theo giá bán')">👫 So sánh nam vs nữ</button></div></div>`;
document.getElementById('dashTitle').textContent='Dashboard Preview';document.getElementById('dashSub').textContent='';document.getElementById('dashBadge').style.display='none';
document.getElementById('chatBody').innerHTML='';addMsg('👋 Dashboard đã reset!',false);
}
</script>
</body>
</html>
// Stock loader - fetches stock per product_color_code with per-size breakdown
async function fetchStock() {
const cells = document.querySelectorAll('.td-stock[data-code]');
if (!cells.length) return;
const codeSet = new Set();
cells.forEach(function(el) { if (el.dataset.code) codeSet.add(el.dataset.code); });
const codes = Array.from(codeSet).join(',');
try {
const r = await fetch('/api/stock/check', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({codes: codes, max_skus: 500, chunk_size: 50, timeout_sec: 15})
});
const d = await r.json();
if (d.status !== 'success') return;
// Aggregate stock by product_color_code with per-size detail
var stockMap = {};
(d.stock_responses || []).forEach(function(resp) {
(resp.result || []).forEach(function(item) {
var parts = item.sku.split('-');
var size = parts[parts.length - 1];
var colorCode = parts.slice(0, -1).join('-');
if (!stockMap[colorCode]) stockMap[colorCode] = {total: 0, sizes: {}};
stockMap[colorCode].total += (item.qty || 0);
stockMap[colorCode].sizes[size] = {qty: item.qty || 0, inStock: item.is_in_stock};
});
});
var sizeOrder = ['XS','S','M','L','XL','XXL','2XL','3XL','100','110','120','130','140','150','160'];
cells.forEach(function(el) {
var code = el.dataset.code;
var info = stockMap[code];
if (!info) { el.innerHTML = '<span style="color:var(--f);font-size:10px">\u2014</span>'; return; }
var qty = info.total;
var color = qty > 20 ? 'var(--green)' : qty > 0 ? 'var(--gold)' : 'var(--red)';
var icon = qty > 20 ? '\u2705' : qty > 0 ? '\u26A0\uFE0F' : '\u274C';
// Sort sizes
var sortedSizes = Object.keys(info.sizes).sort(function(a, b) {
var ia = sizeOrder.indexOf(a), ib = sizeOrder.indexOf(b);
return (ia === -1 ? 99 : ia) - (ib === -1 ? 99 : ib);
});
// Build size chips
var sizeHtml = sortedSizes.map(function(sz) {
var s = info.sizes[sz];
var sc = s.qty > 10 ? 'var(--green)' : s.qty > 0 ? 'var(--gold)' : 'var(--red)';
var bg = s.qty > 10 ? 'rgba(22,163,74,.08)' : s.qty > 0 ? 'rgba(234,179,8,.08)' : 'rgba(220,38,38,.08)';
return '<span style="display:inline-block;padding:2px 6px;margin:1px;border-radius:4px;font-size:10px;font-weight:600;background:' + bg + ';color:' + sc + ';border:1px solid ' + sc + '">' + sz + ': ' + s.qty + '</span>';
}).join('');
var detailId = 'stk_' + code.replace(/[^a-zA-Z0-9]/g, '_');
el.innerHTML =
'<div onclick="var dd=document.getElementById(\'' + detailId + '\');dd.style.display=dd.style.display===\'none\'?\'flex\':\'none\'" style="cursor:pointer">' +
'<span style="color:' + color + ';font-weight:700;font-size:13px">' + icon + ' ' + qty + '</span>' +
'<span style="color:var(--f);font-size:9px;margin-left:3px">\u25BC</span>' +
'</div>' +
'<div id="' + detailId + '" style="display:none;flex-wrap:wrap;gap:2px;margin-top:4px;max-width:220px">' + sizeHtml + '</div>';
});
} catch(e) {
console.error('Stock error:', e);
cells.forEach(function(el) { el.innerHTML = '<span style="color:var(--f)">err</span>'; });
}
}
......@@ -5,7 +5,10 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>🔍 DB Direct Query Tester</title>
<style>
<link rel="stylesheet" href="/static/lab.css">
<link rel="stylesheet" href="/static/warm-override.css">
<script src="/static/frame-detect.js"></script>
<style>
* {
box-sizing: border-box;
margin: 0;
......
......@@ -5,7 +5,10 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>🤖 Text-to-SQL Chatbot — Bản 2</title>
<style>
<link rel="stylesheet" href="/static/lab.css">
<link rel="stylesheet" href="/static/warm-override.css">
<script src="/static/frame-detect.js"></script>
<style>
* {
box-sizing: border-box;
margin: 0;
......
/* ═══════════════════════════════════════════════
Warm Theme Override
Injects warm cream colors over dark-themed pages
This file MUST be loaded AFTER any inline dark CSS
═══════════════════════════════════════════════ */
/* Force warm background and text */
body{background:var(--bg,#F5F4F0)!important;color:var(--t,#18181B)!important;font-family:'Outfit',sans-serif!important}
/* Dark sidebar overrides */
.sidebar,aside.sidebar,nav.sidebar{background:var(--s,#FFFFFF)!important;border-right:1px solid var(--b,#E2E0D8)!important}
.sidebar *{color:var(--m,#78716C)!important}
.sidebar .active,.sidebar .nav-item.active,.sidebar .sb-link.active{background:var(--gold-l,#FFFBEB)!important;color:var(--gold,#B45309)!important;border-left:3px solid var(--gold,#B45309)!important}
.sidebar .active *,.sidebar .nav-item.active *,.sidebar .sb-link.active *{color:var(--gold,#B45309)!important}
.sidebar-brand,.sb-logo{border-bottom:1px solid var(--b,#E2E0D8)!important}
.sidebar-brand *,.brand-text *,.sb-logo *{color:var(--t,#18181B)!important}
.sidebar-footer,.sb-footer{border-top:1px solid var(--b,#E2E0D8)!important}
/* Main content */
.main{background:var(--bg,#F5F4F0)!important}
/* Topbar */
.topbar,.header{background:var(--s,#FFFFFF)!important;border-bottom:1px solid var(--b,#E2E0D8)!important}
.topbar *,.header *{color:var(--t,#18181B)!important}
/* Cards, containers */
.card,.container,.panel,.section,.content-card,.experiment-card,.flow-card{background:var(--s,#FFFFFF)!important;border:1px solid var(--b,#E2E0D8)!important;color:var(--t,#18181B)!important}
.card *,.container *,.panel *{color:inherit!important}
/* Tables */
table,th,td{border-color:var(--b,#E2E0D8)!important}
th{color:var(--m,#78716C)!important}
td{color:var(--t,#18181B)!important}
tr:hover{background:var(--bg,#F5F4F0)!important}
/* Inputs */
input,textarea,select{background:var(--bg,#F5F4F0)!important;border:1px solid var(--b,#E2E0D8)!important;color:var(--t,#18181B)!important;font-family:'Outfit',sans-serif!important}
input:focus,textarea:focus,select:focus{border-color:var(--gold,#B45309)!important;box-shadow:0 0 0 3px rgba(180,83,9,.1)!important;outline:none!important}
/* Buttons */
button{background:var(--s,#FFFFFF)!important;border:1px solid var(--b,#E2E0D8)!important;color:var(--m,#78716C)!important;font-family:'Outfit',sans-serif!important;border-radius:var(--rs,8px)!important;transition:all .2s!important}
button:hover{background:var(--bg,#F5F4F0)!important;color:var(--t,#18181B)!important;border-color:var(--gold,#B45309)!important}
button.primary,button.active,.btn-primary{background:var(--t,#18181B)!important;color:#fff!important;border-color:transparent!important}
button:disabled{opacity:.4!important}
/* Badges, tags */
.badge,.tag,.label,.chip{border-radius:999px!important}
/* Links */
a{color:var(--blue,#1D4ED8)!important;text-decoration:none!important}
a:hover{text-decoration:underline!important}
/* Scrollbar */
::-webkit-scrollbar{width:5px!important}
::-webkit-scrollbar-track{background:transparent!important}
::-webkit-scrollbar-thumb{background:var(--b,#E2E0D8)!important;border-radius:3px!important}
::-webkit-scrollbar-thumb:hover{background:var(--m,#78716C)!important}
/* code, pre */
code,pre{background:var(--bg,#F5F4F0)!important;color:var(--t,#18181B)!important;border:1px solid var(--b,#E2E0D8)!important;border-radius:var(--rs,8px)!important;font-family:'JetBrains Mono',monospace!important}
/* IFRAME MODE */
html.in-iframe .sidebar,html.in-iframe aside.sidebar,html.in-iframe nav.sidebar{display:none!important}
html.in-iframe .nav-header{display:none!important}
html.in-iframe .main{margin-left:0!important}
html.in-iframe .main-content{margin:0!important;padding:20px!important}
/* nav-header - hidden by default since main.html provides navigation */
.nav-header{display:none!important}
"""Quick script to list all columns in the product table."""
import asyncio
import sys
sys.stdout.reconfigure(encoding='utf-8')
from common.starrocks_connection import get_db_connection
async def main():
db = get_db_connection()
# Get column names from INFORMATION_SCHEMA
rows = await db.execute_query_async(
"SELECT COLUMN_NAME, DATA_TYPE, COLUMN_COMMENT "
"FROM INFORMATION_SCHEMA.COLUMNS "
"WHERE TABLE_SCHEMA = 'shared_source' "
"AND TABLE_NAME = 'magento_product_dimension_with_text_embedding' "
"ORDER BY ORDINAL_POSITION"
)
print(f"\n=== {len(rows)} columns ===\n")
for r in rows:
name = r.get('COLUMN_NAME', r.get('column_name', ''))
dtype = r.get('DATA_TYPE', r.get('data_type', ''))
comment = r.get('COLUMN_COMMENT', r.get('column_comment', ''))
print(f" - {name} ({dtype}) — {comment}")
asyncio.run(main())
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