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
This diff is collapsed.
This diff is collapsed.
# 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
This diff is collapsed.
......@@ -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.")
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
......@@ -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 -->
......
This diff is collapsed.
This diff is collapsed.
/* ═══ 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; }
......
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
......@@ -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;
......
This diff is collapsed.
This diff is collapsed.
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