Commit 2fc434b5 authored by Vũ Hoàng Anh's avatar Vũ Hoàng Anh

chore: simplify Docker - single Dockerfile + compose on port 5004 with hot...

chore: simplify Docker - single Dockerfile + compose on port 5004 with hot reload, remove dev/prod split
parent ac6be50f
FROM python:3.11-slim
WORKDIR /app
ENV PYTHONUNBUFFERED=1
ENV PYTHONDONTWRITEBYTECODE=1
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 5004
CMD ["uvicorn", "server:app", "--host", "0.0.0.0", "--port", "5004", "--reload"]
# ============================================================
# DOCKERFILE.DEV - Local Development (Hot Reload + Cache)
# ============================================================
# Sử dụng Python 3.11 Slim để tối ưu dung lượng
FROM python:3.11-slim
# Thiết lập thư mục làm việc
WORKDIR /app
# Thiết lập biến môi trường
ENV PYTHONUNBUFFERED=1
ENV PYTHONDONTWRITEBYTECODE=1
ENV ENV=development
# Copy requirements.txt trước để tận dụng Docker cache
COPY requirements.txt .
# Cài đặt thư viện Python (Docker layer cache)
RUN pip install -r requirements.txt && pip install watchdog[watchmedo]
# Copy toàn bộ source code vào image
COPY . .
# Expose port 5000
EXPOSE 5000
# Health check (optional)
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"]
FROM python:3.11-slim
WORKDIR /app
ENV PYTHONUNBUFFERED=1
ENV PYTHONDONTWRITEBYTECODE=1
ENV ENV=development
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .
# Expose port 5000 (Port chạy server)
EXPOSE 5000
# Tự động tính số worker = (Số Core * 2) + 1 để tận dụng tối đa CPU
CMD gunicorn server:app --workers $(( 2 * $(nproc) + 1 )) --worker-class uvicorn.workers.UvicornWorker --bind 0.0.0.0:5000 --timeout 60
"""
Fashion Q&A Agent Router
FastAPI endpoints cho Fashion Q&A Agent service.
Router chỉ chứa định nghĩa API, logic nằm ở controller.
Note: Rate limit check đã được xử lý trong middleware (CanifaAuthMiddleware)
"""
import logging
from fastapi import APIRouter, BackgroundTasks, Request
from fastapi.responses import JSONResponse
from agent.controller import chat_controller
from agent.models import QueryRequest
from common.cache import redis_cache
from common.message_limit import message_limit_service
from common.rate_limit import rate_limit_service
from config import DEFAULT_MODEL
logger = logging.getLogger(__name__)
router = APIRouter()
@router.post("/api/agent/chat", summary="Fashion Q&A Chat (Non-streaming)")
@rate_limit_service.limiter.limit("20/minute")
async def fashion_qa_chat(request: Request, req: QueryRequest, background_tasks: BackgroundTasks):
"""
Endpoint chat không stream - trả về response JSON đầy đủ một lần.
Note: Rate limit đã được check trong middleware.
"""
user_id = getattr(request.state, "user_id", None)
device_id = getattr(request.state, "device_id", "unknown")
is_authenticated = getattr(request.state, "is_authenticated", False)
identity_id = user_id if is_authenticated else device_id
logger.info(f"📥 [Incoming Query - NonStream] User: {identity_id} | Query: {req.user_query}")
try:
# Call controller để xử lý chat
result = await chat_controller(
query=req.user_query,
background_tasks=background_tasks,
model_name=DEFAULT_MODEL,
images=req.images,
identity_key=str(identity_id), # Key lưu history
is_authenticated=is_authenticated, # Pass auth status for Langfuse metadata
device_id=device_id, # Luôn truyền device_id để lưu vào Langfuse metadata
)
logger.info(f"💬 AI Response: {result['ai_response']}")
logger.info(f"🛍️ Product IDs: {result.get('product_ids', [])}")
# Increment message count SAU KHI chat thành công
usage_info = await message_limit_service.increment(
identity_key=identity_id,
is_authenticated=is_authenticated,
)
return {
"status": "success",
"ai_response": result["ai_response"],
"product_ids": result.get("product_ids", []),
"limit_info": {
"limit": usage_info["limit"],
"used": usage_info["used"],
"remaining": usage_info["remaining"],
"reset_seconds": usage_info.get("reset_seconds"),
},
}
except Exception as e:
logger.error(f"Error in fashion_qa_chat: {e}", exc_info=True)
return JSONResponse(
status_code=500,
content={
"status": "error",
"error_code": "SYSTEM_ERROR",
"message": "Oops 😥 Hiện Canifa-AI chưa thể xử lý yêu cầu của bạn ngay lúc này, vui lòng quay lại trong giây lát.",
},
)
@router.post("/api/agent/chat-dev", summary="Fashion Q&A Chat (Dev - includes user_insight)")
@rate_limit_service.limiter.limit("50/minute")
async def fashion_qa_chat_dev(request: Request, req: QueryRequest, background_tasks: BackgroundTasks):
"""
Endpoint chat dành cho DEV - trả về đầy đủ user_insight.
Note: Rate limit đã được check trong middleware.
"""
user_id = getattr(request.state, "user_id", None)
device_id = getattr(request.state, "device_id", "unknown")
is_authenticated = getattr(request.state, "is_authenticated", False)
identity_id = user_id if is_authenticated else device_id
logger.info(f"📥 [Incoming Query - Dev] User: {identity_id} | Query: {req.user_query}")
try:
# DEV MODE: Return ai_response + products immediately
result = await chat_controller(
query=req.user_query,
background_tasks=background_tasks,
model_name=DEFAULT_MODEL,
images=req.images,
identity_key=str(identity_id),
return_user_insight=False,
is_authenticated=is_authenticated, # Pass auth status for Langfuse metadata
device_id=device_id, # Luôn truyền device_id để lưu vào Langfuse metadata
)
usage_info = await message_limit_service.increment(
identity_key=identity_id,
is_authenticated=is_authenticated,
)
logger.warning(f"🔍 [DEBUG] usage_info = {usage_info}")
return {
"status": "success",
"ai_response": result["ai_response"],
"product_ids": result.get("product_ids", []),
"insight_status": "success" if result.get("user_insight") else "pending",
"user_insight": result.get("user_insight"),
"limit_info": {
"limit": usage_info["limit"],
"used": usage_info["used"],
"remaining": usage_info["remaining"],
"reset_seconds": usage_info.get("reset_seconds"),
},
}
except Exception as e:
logger.error(f"Error in fashion_qa_chat_dev: {e}", exc_info=True)
return JSONResponse(
status_code=500,
content={
"status": "error",
"error_code": "SYSTEM_ERROR",
"message": "Oops 😥 Hiện Canifa-AI chưa thể xử lý yêu cầu của bạn ngay lúc này, vui lòng quay lại trong giây lát.",
},
)
@router.get("/api/agent/user-insight", summary="Get latest user_insight (Dev)")
@rate_limit_service.limiter.limit("120/minute")
async def get_user_insight(request: Request):
"""
Polling endpoint for dev UI to fetch latest user_insight from Redis.
"""
user_id = getattr(request.state, "user_id", None)
device_id = getattr(request.state, "device_id", "unknown")
is_authenticated = getattr(request.state, "is_authenticated", False)
identity_id = user_id if is_authenticated else device_id
try:
client = redis_cache.get_client()
if not client:
return {"status": "pending", "user_insight": None}
insight_key = f"identity_key_insight:{identity_id}"
user_insight = await client.get(insight_key)
if user_insight:
return {"status": "success", "user_insight": user_insight}
return {"status": "pending", "user_insight": None}
except Exception as e:
logger.error(f"Error in get_user_insight: {e}", exc_info=True)
return JSONResponse(
status_code=500,
content={
"status": "error",
"error_code": "SYSTEM_ERROR",
"message": "Không thể tải user_insight lúc này.",
},
)
import asyncio
import json
import logging
import time
from fastapi import APIRouter, BackgroundTasks, HTTPException
from pydantic import BaseModel
from agent.tools.data_retrieval_tool import SearchItem, data_retrieval_tool
from agent.mock_controller import mock_chat_controller
logger = logging.getLogger(__name__)
router = APIRouter()
# --- HELPERS ---
async def retry_with_backoff(coro_fn, max_retries=3, backoff_factor=2):
"""Retry async function with exponential backoff"""
for attempt in range(max_retries):
try:
return await coro_fn()
except Exception as e:
if attempt == max_retries - 1:
raise
wait_time = backoff_factor**attempt
logger.warning(f"⚠️ Attempt {attempt + 1} failed: {e!s}, retrying in {wait_time}s...")
await asyncio.sleep(wait_time)
# --- MODELS ---
class MockQueryRequest(BaseModel):
user_query: str
user_id: str | None = "test_user"
session_id: str | None = None
images: list[str] | None = None
class MockDBRequest(BaseModel):
query: str | None = None
magento_ref_code: str | None = None
price_min: float | None = None
price_max: float | None = None
top_k: int = 10
class MockRetrieverRequest(BaseModel):
user_query: str
price_min: float | None = None
price_max: float | None = None
magento_ref_code: str | None = None
user_id: str | None = "test_user"
session_id: str | None = None
# --- MOCK LLM RESPONSES (không gọi OpenAI) ---
MOCK_AI_RESPONSES = [
"Dựa trên tìm kiếm của bạn, tôi tìm thấy các sản phẩm phù hợp với nhu cầu của bạn. Những mặt hàng này có chất lượng tốt và giá cả phải chăng.",
"Tôi gợi ý cho bạn những sản phẩm sau. Chúng đều là những lựa chọn phổ biến và nhận được đánh giá cao từ khách hàng.",
"Dựa trên tiêu chí tìm kiếm của bạn, đây là những sản phẩm tốt nhất mà tôi có thể giới thiệu.",
"Những sản phẩm này hoàn toàn phù hợp với yêu cầu của bạn. Hãy xem chi tiết để chọn sản phẩm yêu thích nhất.",
"Tôi đã tìm được các mặt hàng tuyệt vời cho bạn. Hãy kiểm tra chúng để tìm ra lựa chọn tốt nhất.",
]
# --- ENDPOINTS ---
@router.post("/api/mock/agent/chat", summary="Mock Agent Chat (Real Tools + Fake LLM)")
async def mock_chat(req: MockQueryRequest, background_tasks: BackgroundTasks):
"""
Mock Agent Chat using mock_chat_controller:
- ✅ Real embedding + vector search (data_retrieval_tool THẬT)
- ✅ Real products from StarRocks
- ❌ Fake LLM response (no OpenAI cost)
- Perfect for stress testing + end-to-end testing
"""
try:
logger.info(f"🚀 [Mock Agent Chat] Starting with query: {req.user_query}")
result = await mock_chat_controller(
query=req.user_query,
user_id=req.user_id or "test_user",
background_tasks=background_tasks,
images=req.images,
)
return {
"status": "success",
"user_query": req.user_query,
"user_id": req.user_id,
"session_id": req.session_id,
**result, # Include status, ai_response, product_ids, etc.
}
except Exception as e:
logger.error(f"❌ Error in mock agent chat: {e!s}", exc_info=True)
raise HTTPException(status_code=500, detail=f"Mock Agent Chat Error: {e!s}")
@router.post("/api/mock/db/search", summary="Real Data Retrieval Tool (Agent Tool)")
async def mock_db_search(req: MockDBRequest):
"""
Dùng `data_retrieval_tool` THẬT từ Agent:
- Nếu có magento_ref_code → CODE SEARCH (không cần embedding)
- Nếu có query → HYDE SEMANTIC SEARCH (embedding + vector search)
- Lọc theo giá nếu có price_min/price_max
- Trả về sản phẩm thực từ StarRocks
Format input giống SearchItem của agent tool.
"""
try:
logger.info("📍 Data Retrieval Tool called")
start_time = time.time()
# Xây dựng SearchItem từ request - include all required fields
search_item = SearchItem(
query=req.query or "sản phẩm",
magento_ref_code=req.magento_ref_code,
price_min=req.price_min,
price_max=req.price_max,
action="search",
# Metadata fields - all required with None default
gender_by_product=None,
age_by_product=None,
product_name=None,
style=None,
master_color=None,
season=None,
material_group=None,
fitting=None,
form_neckline=None,
form_sleeve=None,
)
logger.info(f"🔧 Search params: {search_item.dict(exclude_none=True)}")
# Gọi data_retrieval_tool THẬT với retry
result_json = await retry_with_backoff(
lambda: data_retrieval_tool.ainvoke({"searches": [search_item]}), max_retries=3
)
result = json.loads(result_json)
elapsed_time = time.time() - start_time
logger.info(f"✅ Data Retrieval completed in {elapsed_time:.3f}s")
return {
"status": result.get("status", "success"),
"search_params": search_item.dict(exclude_none=True),
"total_results": len(result.get("results", [{}])[0].get("products", [])),
"products": result.get("results", [{}])[0].get("products", []),
"processing_time_ms": round(elapsed_time * 1000, 2),
"raw_result": result,
}
except Exception as e:
logger.error(f"❌ Error in DB search: {e!s}", exc_info=True)
raise HTTPException(status_code=500, detail=f"DB Search Error: {e!s}")
@router.post("/api/mock/retrieverdb", summary="Real Embedding + Real DB Vector Search")
@router.post("/api/mock/retriverdb", summary="Real Embedding + Real DB Vector Search (Legacy)")
async def mock_retriever_db(req: MockRetrieverRequest):
"""
API thực tế để test Retriever + DB Search (dùng agent tool):
- Lấy query từ user
- Embedding THẬT (gọi OpenAI embedding trong tool)
- Vector search THẬT trong StarRocks
- Trả về kết quả sản phẩm thực (bỏ qua LLM)
Dùng để test performance của embedding + vector search riêng biệt.
"""
try:
logger.info(f"📍 Retriever DB started: {req.user_query}")
start_time = time.time()
# Xây dựng SearchItem từ request - include all required fields
search_item = SearchItem(
query=req.user_query,
magento_ref_code=req.magento_ref_code,
price_min=req.price_min,
price_max=req.price_max,
action="search",
# Metadata fields - all required with None default
gender_by_product=None,
age_by_product=None,
product_name=None,
style=None,
master_color=None,
season=None,
material_group=None,
fitting=None,
form_neckline=None,
form_sleeve=None,
)
logger.info(f"🔧 Retriever params: {search_item.dict(exclude_none=True)}")
# Gọi data_retrieval_tool THẬT (embedding + vector search) với retry
result_json = await retry_with_backoff(
lambda: data_retrieval_tool.ainvoke({"searches": [search_item]}), max_retries=3
)
result = json.loads(result_json)
elapsed_time = time.time() - start_time
logger.info(f"✅ Retriever completed in {elapsed_time:.3f}s")
# Parse kết quả
search_results = result.get("results", [{}])[0]
products = search_results.get("products", [])
return {
"status": result.get("status", "success"),
"user_query": req.user_query,
"user_id": req.user_id,
"session_id": req.session_id,
"search_params": search_item.dict(exclude_none=True),
"total_results": len(products),
"products": products,
"processing_time_ms": round(elapsed_time * 1000, 2),
}
except Exception as e:
logger.error(f"❌ Error in retriever DB: {e!s}", exc_info=True)
raise HTTPException(status_code=500, detail=f"Retriever DB Error: {e!s}")
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel
import os
import re
from agent.graph import reset_graph
from common.cache import bump_prompt_version
router = APIRouter()
PROMPT_FILE_PATH = os.path.join(os.path.dirname(__file__), "../agent/system_prompt.txt")
# Allowed variables in prompt (single braces OK for these)
ALLOWED_VARIABLES = {"date_str"}
class PromptUpdateRequest(BaseModel):
content: str
def validate_prompt_braces(content: str) -> tuple[bool, list[str]]:
"""
Validate that all braces in prompt are properly escaped.
Returns (is_valid, list of problematic patterns)
"""
# Find all {word} patterns
single_brace_pattern = re.findall(r'\{([^{}]+)\}', content)
# Filter out allowed variables
problematic = [
var for var in single_brace_pattern
if var.strip() not in ALLOWED_VARIABLES and not var.startswith('{')
]
return len(problematic) == 0, problematic
from common.rate_limit import rate_limit_service
@router.get("/api/agent/system-prompt")
async def get_system_prompt_content(request: Request):
"""Get current system prompt content"""
try:
if os.path.exists(PROMPT_FILE_PATH):
with open(PROMPT_FILE_PATH, "r", encoding="utf-8") as f:
content = f.read()
return {"status": "success", "content": content}
else:
return {"status": "error", "message": "Prompt file not found"}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/api/agent/system-prompt")
@rate_limit_service.limiter.limit("10/minute")
async def update_system_prompt_content(request: Request, body: PromptUpdateRequest):
"""Update system prompt content"""
try:
# Validate braces
is_valid, problematic = validate_prompt_braces(body.content)
if not is_valid:
# Return warning but still allow save
warning = (
f"⚠️ Phát hiện {{...}} chưa escape: {problematic[:3]}... "
f"Nếu đây là JSON, hãy dùng {{{{ }}}} thay vì {{ }}. "
f"Prompt vẫn được lưu nhưng có thể gây lỗi khi chat."
)
else:
warning = None
# 1. Update file
with open(PROMPT_FILE_PATH, "w", encoding="utf-8") as f:
f.write(body.content)
# 2. Bump prompt version in Redis (ALL workers will detect this)
new_version = await bump_prompt_version()
# 3. Reset local worker's Graph Singleton (immediate effect for this worker)
reset_graph()
response = {
"status": "success",
"message": f"System prompt updated. Version: {new_version}. All workers will reload on next request.",
"prompt_version": new_version
}
if warning:
response["warning"] = warning
return response
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
...@@ -33,7 +33,7 @@ _test_results: dict[str, Any] = {} ...@@ -33,7 +33,7 @@ _test_results: dict[str, Any] = {}
# Models # Models
# ───────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────
class TestRunRequest(BaseModel): class TestRunRequest(BaseModel):
endpoint_url: str = "http://localhost:5004/api/agent/chat-dev" endpoint_url: str = "http://172.16.2.207:5000/api/agent/chat-dev"
conversations: dict[str, list[str]] # conv_id -> [messages] conversations: dict[str, list[str]] # conv_id -> [messages]
num_versions: int = 3 num_versions: int = 3
delay_ms: int = 1000 delay_ms: int = 1000
...@@ -157,7 +157,7 @@ async def run_test_batch(req: TestRunRequest): ...@@ -157,7 +157,7 @@ async def run_test_batch(req: TestRunRequest):
messages = conversations[conv_id] messages = conversations[conv_id]
for version in range(1, req.num_versions + 1): for version in range(1, req.num_versions + 1):
device_id = f"test-conv{conv_id}-v{version}" device_id = f"test-c{conv_id}-v{version}-{uuid.uuid4().hex[:6]}"
result_key = f"{conv_id}_v{version}" result_key = f"{conv_id}_v{version}"
try: try:
...@@ -168,10 +168,11 @@ async def run_test_batch(req: TestRunRequest): ...@@ -168,10 +168,11 @@ async def run_test_batch(req: TestRunRequest):
start_time = time.time() start_time = time.time()
# Send to target endpoint with fake device_id # Send to target endpoint with fake device_id
tagged_message = f"{message} [v{version}]"
response = await client.post( response = await client.post(
req.endpoint_url, req.endpoint_url,
json={ json={
"user_query": message, "user_query": tagged_message,
"device_id": device_id, "device_id": device_id,
}, },
headers={ headers={
...@@ -227,7 +228,7 @@ async def run_test_batch(req: TestRunRequest): ...@@ -227,7 +228,7 @@ async def run_test_batch(req: TestRunRequest):
"completed": completed, "completed": completed,
"total_tasks": total_tasks, "total_tasks": total_tasks,
"total_time": result["total_time"], "total_time": result["total_time"],
"final_response": result["final_response"][:300], "responses": version_responses,
"num_messages": len(messages), "num_messages": len(messages),
}) })
...@@ -291,6 +292,17 @@ async def list_test_results(): ...@@ -291,6 +292,17 @@ async def list_test_results():
} }
# ─────────────────────────────────────────────────────────
# 4. Clear Results
# ─────────────────────────────────────────────────────────
@router.delete("/api/test/results/clear", summary="Clear all test results")
async def clear_test_results():
"""Clear all stored test results from memory."""
count = len(_test_results)
_test_results.clear()
return {"status": "success", "cleared": count, "message": f"Cleared {count} test result(s)"}
# ───────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────
# Helper # Helper
# ───────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────
......
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel
from agent.graph import reset_graph
from agent.prompt_utils import list_tool_prompts, read_tool_prompt, write_tool_prompt
router = APIRouter()
class ToolPromptUpdateRequest(BaseModel):
content: str
@router.get("/api/agent/tool-prompts")
async def get_tool_prompts_list(request: Request):
"""List all available tool prompt files."""
try:
files = list_tool_prompts()
return {"status": "success", "files": files}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/api/agent/tool-prompts/{filename}")
async def get_tool_prompt_content(filename: str, request: Request):
"""Get content of a specific tool prompt file."""
try:
content = read_tool_prompt(filename)
if not content:
# Try appending .txt if not present
if not filename.endswith(".txt"):
content = read_tool_prompt(filename + ".txt")
return {"status": "success", "content": content}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/api/agent/tool-prompts/{filename}")
async def update_tool_prompt_content(filename: str, request: Request, body: ToolPromptUpdateRequest):
"""Update content of a tool prompt file and reset graph."""
try:
# Ensure filename is safe (basic check)
if ".." in filename or "/" in filename or "\\" in filename:
raise HTTPException(status_code=400, detail="Invalid filename")
success = write_tool_prompt(filename, body.content)
if not success:
raise HTTPException(status_code=500, detail="Failed to write file")
# Reset Graph to reload tools with new prompts
reset_graph()
return {"status": "success", "message": f"Tool prompt {filename} updated successfully. Graph reloaded."}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
services:
# --- Backend Service ---
backend:
build:
context: .
dockerfile: Dockerfile.prod
container_name: canifa_backend
env_file: .env
ports:
- "5000:5000"
volumes:
- .:/app
environment:
- PORT=5000
restart: unless-stopped
deploy:
resources:
limits:
memory: 8g
networks:
- backend_network
logging:
driver: "json-file"
options:
tag: "{{.Name}}"
networks:
backend_network:
driver: bridge
ipam:
driver: default
config:
- subnet: "172.24.0.0/16"
gateway: "172.24.0.1"
services: services:
# --- Backend Service --- backend:
backend: build: .
build: . container_name: canifa_backend
container_name: canifa_backend env_file: .env
env_file: .env ports:
ports: - "5004:5004"
- "5000:5000" volumes:
volumes: - .:/app
- .:/app restart: unless-stopped
environment: deploy:
- PORT=5000 resources:
restart: unless-stopped limits:
deploy: memory: 8g
resources: logging:
limits: driver: "json-file"
memory: 8g options:
networks: max-size: "50m"
- backend_network max-file: "3"
logging:
driver: "json-file"
options:
tag: "{{.Name}}"
networks:
backend_network:
driver: bridge
ipam:
driver: default
config:
- subnet: "172.24.0.0/16"
gateway: "172.24.0.1"
"""
CANIFA Fashion Agent - Terminal Tester
Test agent với full flow: LLM + Tools + MongoDB Memory (No Postgres Checkpoint)
"""
import asyncio
import platform
import sys
from pathlib import Path
# Add parent dir to sys.path
sys.path.insert(0, str(Path(__file__).parent))
from langchain_core.messages import AIMessage, HumanMessage
# from langfuse import get_client, observe, propagate_attributes # TẮT TẠM
# from langfuse.langchain import CallbackHandler # TẮT TẠM
from agent.graph import build_graph
from agent.models import get_config
async def main():
import logging
# Tắt log rác của LangChain để terminal sạch sẽ
logging.getLogger("langchain").setLevel(logging.ERROR)
logging.getLogger("langgraph").setLevel(logging.ERROR)
logging.getLogger("langfuse").setLevel(logging.ERROR) # TẮT Langfuse logs
# 📝 Enable INFO logs for Data Retrieval & Embedding tools (Timing & Debug)
logging.basicConfig(level=logging.INFO, format="%(message)s") # Bật INFO để thấy tool calls
logging.getLogger("agent.tools.data_retrieval_tool").setLevel(logging.INFO)
logging.getLogger("agent.tools.product_search_helpers").setLevel(logging.INFO)
print("\n" + "=" * 50)
print("🚀 CANIFA FASHION AGENT - TERMINAL TESTER (MEM V2)")
print("=" * 50)
try:
# 1. Init Langfuse (Monitor tool) - TẮT TẠM ĐỂ TRÁNH RATE LIMIT
# from langfuse import Langfuse
# Langfuse() # Initialize singleton
# 2. Build Graph
config = get_config()
graph = build_graph(config)
print("✅ System: Graph & MongoDB Memory Ready!")
except Exception as e:
print(f"❌ System Error: {e}")
import traceback
traceback.print_exc()
return
print("\n💬 CiCi: 'Em chào anh/chị ạ! Em là CiCi, stylist riêng của mình đây. Anh/chị cần em tư vấn gì không ạ?'")
print("(Gõ 'q' để thoát, 'image' để giả lập gửi ảnh)")
conversation_id = "test_terminal_session_v2"
user_id = "tester_01"
while True:
query = input("\n[User]: ").strip()
if query.lower() in ["exit", "q"]:
break
if not query:
continue
# Giả lập gửi ảnh
if query.lower() == "image":
print("📸 [System]: Đã giả lập gửi 1 ảnh (base64).")
query = "Mẫu này chất liệu gì vậy em?"
print(f"[User]: {query}")
# ⚙️ STREAMING MODE - Bật/Tắt
ENABLE_STREAMING = True
print("⏳ CiCi is thinking...")
# 🎯 Call wrapped function - each call = 1 trace
await run_single_query(
graph=graph,
query=query,
conversation_id=conversation_id,
user_id=user_id,
enable_streaming=ENABLE_STREAMING,
)
print("\n👋 CiCi: 'Cảm ơn anh/chị đã ghé thăm CANIFA. Hẹn gặp lại nhé!'")
# @observe() # TẮT TẠM - Tránh Langfuse rate limit
async def run_single_query(graph, query: str, conversation_id: str, user_id: str, enable_streaming: bool = True):
"""Run single query - Langfuse DISABLED"""
import logging
logger = logging.getLogger(__name__)
# Load History từ MongoDB
history = []
current_human_msg = HumanMessage(content=query)
input_state = {
"messages": [current_human_msg],
"history": history,
"user_id": user_id,
}
# TẮT Langfuse callback
# langfuse_handler = CallbackHandler()
config_runnable = {
"configurable": {"conversation_id": conversation_id, "user_id": user_id, "transient_images": []},
# "callbacks": [langfuse_handler], # TẮT
}
final_ai_message = None
ai_content = ""
try:
# Chạy Stream
if enable_streaming:
print("\n👸 CiCi: ", end="", flush=True)
async for event in graph.astream(input_state, config=config_runnable, stream_mode="values"):
if "messages" in event:
msg = event["messages"][-1]
# 🔍 LOG TOOL CALLS
if hasattr(msg, "tool_calls") and msg.tool_calls:
logger.info(f"\n🛠️ TOOL CALLED: {[tc['name'] for tc in msg.tool_calls]}")
if isinstance(msg, AIMessage) and msg.content:
final_ai_message = msg
# STREAMING MODE: In từng đoạn content
if enable_streaming and msg.content != ai_content:
new_content = msg.content[len(ai_content) :]
print(new_content, end="", flush=True)
ai_content = msg.content
# Nếu không stream, in toàn bộ
if not enable_streaming and final_ai_message:
print(f"\n👸 CiCi: {final_ai_message.content}")
else:
print() # Xuống dòng
if final_ai_message:
# Lưu History mới
new_history = [*history, current_human_msg, final_ai_message]
# TẮT Langfuse update
# langfuse = get_client()
# langfuse.update_current_trace(...)
print("[System]: ✅ Response complete")
except Exception as e:
print(f"\n❌ Error during execution: {e}")
import traceback
traceback.print_exc()
if __name__ == "__main__":
if platform.system() == "Windows":
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
asyncio.run(main())
# ============================================================
# CANIFA BACKEND - Quick Reference
# ============================================================
# === LOCAL DEV (port 5000) ===
.\.venv\Scripts\activate .\.venv\Scripts\activate
uvicorn server:app --host 0.0.0.0 --port 5000 --reload uvicorn server:app --host 0.0.0.0 --port 5000 --reload
uvicorn server:app --host 0.0.0.0 --port 5000 # === DOCKER (port 5004, hot reload) ===
docker compose up -d --build
docker restart chatbot-backend
docker restart chatbot-backend && docker logs -f chatbot-backend
docker logs -f chatbot-backend
docker restart canifa_backend docker restart canifa_backend
docker logs -f canifa_backend
docker restart canifa_backend && docker logs -f canifa_backend
docker compose down
sudo docker compose -f docker-compose.prod.yml up -d --build # === KILL PORT ===
Get-NetTCPConnection -LocalPort 5000 | ForEach-Object { Stop-Process -Id $_.OwningProcess -Force }
taskkill /F /IM python.exe taskkill /F /IM python.exe
netstat -ano | findstr :5004 | ForEach-Object { $_.Split()[-1] } | Sort-Object -Unique | ForEach-Object { taskkill /F /PID $_ }
netstat -ano | findstr :5000 | ForEach-Object { $_.Split()[-1] } | Sort-Object -Unique | ForEach-Object { taskkill /F /PID $_ } \ No newline at end of file
\ No newline at end of file
...@@ -8,14 +8,10 @@ from fastapi import FastAPI ...@@ -8,14 +8,10 @@ from fastapi import FastAPI
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from api.chatbot_route import router as chatbot_router
from api.check_history_route import router as check_history_router from api.check_history_route import router as check_history_router
from api.conservation_route import router as conservation_router from api.conservation_route import router as conservation_router
from api.mock_api_route import router as mock_router
from api.prompt_route import router as prompt_router
from api.stock_route import router as stock_router from api.stock_route import router as stock_router
from api.test_conversation_route import router as test_conversation_router from api.test_conversation_route import router as test_conversation_router
from api.tool_prompt_route import router as tool_prompt_router
from common.cache import redis_cache from common.cache import redis_cache
from common.middleware import middleware_manager from common.middleware import middleware_manager
from config import PORT from config import PORT
...@@ -56,7 +52,7 @@ async def startup_event(): ...@@ -56,7 +52,7 @@ async def startup_event():
@app.get("/") @app.get("/")
async def root(): async def root():
return RedirectResponse(url="/static/index.html") return RedirectResponse(url="/static/test_conversation.html")
@app.get("/health") @app.get("/health")
...@@ -90,10 +86,6 @@ middleware_manager.setup( ...@@ -90,10 +86,6 @@ middleware_manager.setup(
# api include # api include
app.include_router(conservation_router) app.include_router(conservation_router)
app.include_router(check_history_router) app.include_router(check_history_router)
app.include_router(chatbot_router)
app.include_router(prompt_router)
app.include_router(tool_prompt_router) # Register new router
app.include_router(mock_router)
app.include_router(stock_router) app.include_router(stock_router)
app.include_router(test_conversation_router) app.include_router(test_conversation_router)
...@@ -103,14 +95,14 @@ if __name__ == "__main__": ...@@ -103,14 +95,14 @@ if __name__ == "__main__":
print("🚀 Contract AI Service Starting...") print("🚀 Contract AI Service Starting...")
print("=" * 60) print("=" * 60)
print(f"📡 REST API: http://localhost:{PORT}") print(f"📡 REST API: http://localhost:{PORT}")
print(f"📡 Test Chatbot: http://localhost:{PORT}/static/index.html") print(f"📡 Test Tool: http://localhost:{PORT}/static/test_conversation.html")
print(f"📚 API Docs: http://localhost:{PORT}/docs") print(f"📚 API Docs: http://localhost:{PORT}/docs")
print("=" * 60) print("=" * 60)
ENABLE_RELOAD = False ENABLE_RELOAD = False
print(f"⚠️ Hot reload: {ENABLE_RELOAD}") print(f"⚠️ Hot reload: {ENABLE_RELOAD}")
reload_dirs = ["common", "api", "agent"] reload_dirs = ["common", "api"]
if ENABLE_RELOAD: if ENABLE_RELOAD:
os.environ["PYTHONUNBUFFERED"] = "1" os.environ["PYTHONUNBUFFERED"] = "1"
......
<!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>
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);
}
.main-content {
max-width: 900px;
margin: 0 auto;
padding: 20px;
}
.container {
background: #2d2d2d;
padding: 20px;
border-radius: 12px;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.5);
display: flex;
flex-direction: column;
height: 90vh;
border: 1px solid #444;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding-bottom: 10px;
border-bottom: 1px solid #444;
}
.header h2 {
margin: 0;
color: #fff;
}
.subtitle {
font-size: 0.85em;
color: #bdbdbd;
margin-top: 4px;
}
.config-area {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
}
input[type="text"] {
padding: 8px 12px;
border: 1px solid #555;
border-radius: 6px;
background: #3d3d3d;
color: #fff;
}
button {
padding: 8px 16px;
background: #007acc;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
transition: opacity 0.2s;
font-weight: 500;
}
button:hover {
opacity: 0.9;
}
.chat-box {
flex: 1;
overflow-y: auto;
padding: 20px;
border: 1px solid #444;
border-radius: 8px;
margin-bottom: 20px;
background: #252526;
display: flex;
flex-direction: column;
gap: 10px;
}
.message-container {
display: flex;
flex-direction: column;
max-width: 80%;
}
.message-container.user {
align-self: flex-end;
align-items: flex-end;
}
.message-container.bot {
align-self: flex-start;
align-items: flex-start;
}
.sender-name {
font-size: 0.8em;
margin-bottom: 4px;
color: #aaa;
margin-left: 4px;
margin-right: 4px;
}
.message {
padding: 12px 16px;
border-radius: 12px;
line-height: 1.5;
word-wrap: break-word;
position: relative;
}
.message-meta {
display: flex;
gap: 10px;
font-size: 0.7em;
opacity: 0.8;
margin-top: 6px;
}
.message-meta span {
background: rgba(255, 255, 255, 0.08);
padding: 2px 6px;
border-radius: 6px;
}
.message.user {
background: #007acc;
color: white;
border-bottom-right-radius: 2px;
}
.message.bot {
background: #3e3e42;
color: #e0e0e0;
border-bottom-left-radius: 2px;
border: 1px solid #555;
}
.message.system {
background: #3d2d2d;
color: #ff6b6b;
align-self: center;
font-size: 0.9em;
max-width: 90%;
border: 1px solid #552b2b;
}
.timestamp {
font-size: 0.7em;
opacity: 0.7;
margin-top: 6px;
display: block;
}
.raw-json {
margin-top: 10px;
background: #1c1c1c;
border: 1px dashed #444;
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;
}
.pill {
display: inline-flex;
align-items: center;
gap: 6px;
background: rgba(255, 255, 255, 0.12);
padding: 4px 8px;
border-radius: 999px;
font-size: 0.75em;
color: #e5e5e5;
}
.note {
font-size: 0.85em;
color: #bbb;
margin-top: 6px;
}
.error {
color: #ff6b6b;
margin-top: 10px;
}
.load-more-btn {
padding: 10px 20px;
background: #3e3e42;
color: #ccc;
border: 1px dashed #555;
border-radius: 6px;
cursor: pointer;
font-weight: 500;
margin: 10px auto;
display: none;
transition: opacity 0.2s;
width: 100%;
font-size: 0.85em;
}
.load-more-btn:hover {
background: #4e4e52;
color: #fff;
}
.load-more-btn.visible {
display: block;
}
</style>
</head>
<body>
<div class="nav-header">
<h1>🤖 Canifa AI System</h1>
<div class="nav-links">
<a href="/static/index.html">💬 Chatbot</a>
<a href="/static/history.html" class="active">🧾 Show History</a>
</div>
</div>
<div class="main-content">
<div class="container">
<div class="header">
<div>
<h2>🧾 History Viewer</h2>
<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>
<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>
</div>
</div>
<div class="chat-box" id="chatBox">
<button id="loadMoreBtn" class="load-more-btn" onclick="loadMore()">Load Older Messages ⬆️</button>
<div id="messagesArea" style="display: flex; flex-direction: column; gap: 15px;"></div>
</div>
<div class="note">Tip: nhập identity key rồi bấm Fetch để xem lịch sử.</div>
<div class="error" id="errorBox"></div>
</div>
</div>
<script>
const messagesArea = document.getElementById('messagesArea');
const errorBox = document.getElementById('errorBox');
const loadMoreBtn = document.getElementById('loadMoreBtn');
let currentIdentityKey = '';
let nextCursor = null;
function clearResults() {
messagesArea.innerHTML = '';
errorBox.textContent = '';
currentIdentityKey = '';
nextCursor = null;
loadMoreBtn.classList.remove('visible');
}
function renderSystemMessage(text) {
const msg = document.createElement('div');
msg.className = 'message system';
msg.textContent = text;
messagesArea.appendChild(msg);
}
function renderMessage(item) {
const container = document.createElement('div');
container.className = `message-container ${item.is_human ? 'user' : 'bot'}`;
const sender = document.createElement('div');
sender.className = 'sender-name';
sender.textContent = item.is_human ? 'USER' : 'AI';
const bubble = document.createElement('div');
bubble.className = `message ${item.is_human ? 'user' : 'bot'}`;
bubble.textContent = item.message || '';
const meta = document.createElement('div');
meta.className = 'message-meta';
const time = document.createElement('span');
time.textContent = item.timestamp || '';
const idChip = document.createElement('span');
idChip.textContent = `id=${item.id ?? ''}`;
meta.appendChild(time);
meta.appendChild(idChip);
bubble.appendChild(meta);
container.appendChild(sender);
container.appendChild(bubble);
if (!item.is_human && item.product_ids && item.product_ids.length) {
const products = document.createElement('div');
products.className = 'message bot';
products.style.marginTop = '6px';
products.textContent = `product_ids: ${JSON.stringify(item.product_ids, null, 2)}`;
container.appendChild(products);
}
const raw = document.createElement('div');
raw.className = 'raw-json';
raw.textContent = JSON.stringify(item, null, 2);
container.appendChild(raw);
messagesArea.appendChild(container);
}
async function loadHistory() {
clearResults();
const identityKey = document.getElementById('identityKey').value.trim();
if (!identityKey) {
errorBox.textContent = 'Vui lòng nhập identity key.';
return;
}
currentIdentityKey = identityKey;
await fetchMessages();
}
async function loadMore() {
if (!nextCursor || !currentIdentityKey) return;
await fetchMessages(nextCursor);
}
async function fetchMessages(beforeId = null) {
const params = new URLSearchParams({ limit: '20' });
if (beforeId) {
params.append('before_id', beforeId);
}
try {
const res = await fetch(`/api/check-history/${encodeURIComponent(currentIdentityKey)}?${params.toString()}`);
if (!res.ok) {
const text = await res.text();
throw new Error(text || `HTTP ${res.status}`);
}
const payload = await res.json();
const items = payload.data || [];
if (!items.length) {
if (!beforeId) {
renderSystemMessage('No history found. Start chatting!');
}
loadMoreBtn.classList.remove('visible');
nextCursor = null;
return;
}
// Reverse to show oldest first when appending to top
items.reverse().forEach((item) => {
if (beforeId) {
// Prepend older messages to top
messagesArea.insertBefore(createMessageElement(item), messagesArea.firstChild);
} else {
// Initial load - append normally
renderMessage(item);
}
});
nextCursor = payload.next_cursor;
if (nextCursor) {
loadMoreBtn.classList.add('visible');
} else {
loadMoreBtn.classList.remove('visible');
}
} catch (err) {
errorBox.textContent = `Li: ${err.message || err}`;
}
}
function createMessageElement(item) {
const container = document.createElement('div');
container.className = 'message-container';
container.onclick = function (e) {
if (e.target === container || e.target.classList.contains('message')) return;
const rawDiv = container.querySelector('.raw-json');
if (rawDiv) rawDiv.classList.toggle('show');
};
const sender = document.createElement('div');
sender.className = 'message-sender';
sender.innerHTML = item.is_human
? '<span class="pill">👤 Khách hàng</span>'
: '<span class="pill">🤖 Bot</span>';
const bubble = document.createElement('div');
bubble.className = 'message ' + (item.is_human ? 'user' : 'bot');
bubble.textContent = item.content || '';
const meta = document.createElement('div');
meta.className = 'message-meta';
const time = document.createElement('span');
time.textContent = item.timestamp || '';
const idChip = document.createElement('span');
idChip.textContent = `id=${item.id ?? ''}`;
meta.appendChild(time);
meta.appendChild(idChip);
bubble.appendChild(meta);
container.appendChild(sender);
container.appendChild(bubble);
if (!item.is_human && item.product_ids && item.product_ids.length) {
const products = document.createElement('div');
products.className = 'message bot';
products.style.marginTop = '6px';
products.textContent = `product_ids: ${JSON.stringify(item.product_ids, null, 2)}`;
container.appendChild(products);
}
const raw = document.createElement('div');
raw.className = 'raw-json';
raw.textContent = JSON.stringify(item, null, 2);
container.appendChild(raw);
return container;
}
</script>
</body>
</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>
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;
}
/* 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;
margin: 0 auto;
padding: 20px;
}
.container {
background: #2d2d2d;
padding: 20px;
border-radius: 12px;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.5);
display: flex;
flex-direction: column;
height: 90vh;
border: 1px solid #444;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding-bottom: 10px;
border-bottom: 1px solid #444;
}
.header h2 {
margin: 0;
color: #fff;
}
.config-area {
display: flex;
gap: 10px;
align-items: center;
}
input[type="text"] {
padding: 8px 12px;
border: 1px solid #555;
border-radius: 6px;
background: #3d3d3d;
color: #fff;
}
button {
padding: 8px 16px;
background: #007acc;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
transition: opacity 0.2s;
font-weight: 500;
}
button:hover {
opacity: 0.9;
}
button:disabled {
background: #555;
cursor: not-allowed;
}
.chat-box {
flex: 1;
overflow-y: auto;
padding: 20px;
border: 1px solid #444;
border-radius: 8px;
margin-bottom: 20px;
background: #252526;
display: flex;
flex-direction: column;
gap: 10px;
}
.message-container {
display: flex;
flex-direction: column;
max-width: 80%;
}
.message-container.user {
align-self: flex-end;
align-items: flex-end;
}
.message-container.bot {
align-self: flex-start;
align-items: flex-start;
}
.sender-name {
font-size: 0.8em;
margin-bottom: 4px;
color: #aaa;
margin-left: 4px;
margin-right: 4px;
}
.message {
padding: 12px 16px;
border-radius: 12px;
line-height: 1.5;
word-wrap: break-word;
position: relative;
}
.message.user {
background: #007acc;
color: white;
border-bottom-right-radius: 2px;
}
.message.bot {
background: #3e3e42;
color: #e0e0e0;
border-bottom-left-radius: 2px;
border: 1px solid #555;
}
.message.system {
background: #3d2d2d;
color: #ff6b6b;
align-self: center;
font-size: 0.9em;
max-width: 90%;
border: 1px solid #552b2b;
}
.message.rate-limit-error {
background: linear-gradient(135deg, #3d2d2d 0%, #2d2d3d 100%);
border: 1px solid #ff6b6b;
padding: 16px;
max-width: 350px;
}
.timestamp {
font-size: 0.7em;
opacity: 0.7;
margin-top: 6px;
display: block;
text-align: right;
}
.input-area {
display: flex;
gap: 10px;
}
.input-area input {
flex: 1;
padding: 12px;
border: 1px solid #555;
border-radius: 8px;
font-size: 16px;
background: #3d3d3d;
color: #fff;
}
.input-area input:focus {
outline: 2px solid #007acc;
border-color: transparent;
}
.load-more {
text-align: center;
margin-bottom: 10px;
}
.load-more button {
background: #3e3e42;
color: #ccc;
font-size: 0.85em;
width: 100%;
border: 1px dashed #555;
}
.load-more button:hover {
background: #4e4e52;
color: #fff;
}
.typing-indicator {
font-style: italic;
color: #888;
font-size: 0.9em;
margin-bottom: 10px;
display: none;
margin-left: 10px;
}
/* Scrollbar styling */
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: #2d2d2d;
}
::-webkit-scrollbar-thumb {
background: #555;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #666;
}
/* Product Cards Styling */
.product-cards-container {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 15px;
margin-top: 15px;
padding-top: 15px;
border-top: 1px solid #555;
}
.product-card {
background: #3d3d3d;
border-radius: 8px;
overflow: hidden;
transition: all 0.3s;
border: 1px solid #555;
display: flex;
flex-direction: column;
}
.product-card:hover {
transform: translateY(-5px);
box-shadow: 0 5px 15px rgba(102, 126, 234, 0.3);
border-color: #667eea;
}
.product-card img {
width: 100%;
height: 200px;
object-fit: cover;
background: #2d2d2d;
}
.product-card-body {
padding: 12px;
flex-grow: 1;
display: flex;
flex-direction: column;
}
.product-sku {
font-size: 0.75em;
color: #667eea;
font-weight: bold;
margin-bottom: 5px;
}
.product-name {
font-size: 0.9em;
color: #fff;
margin-bottom: 10px;
line-height: 1.3;
flex-grow: 1;
}
.product-price {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
}
.price-original {
font-size: 0.85em;
color: #888;
text-decoration: line-through;
}
.price-sale {
font-size: 1.1em;
color: #ff6b6b;
font-weight: bold;
}
.price-regular {
font-size: 1.1em;
color: #4caf50;
font-weight: bold;
}
.product-link {
display: block;
text-align: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
text-decoration: none;
padding: 8px 12px;
border-radius: 6px;
font-size: 0.85em;
transition: all 0.3s;
}
.product-link:hover {
opacity: 0.9;
transform: scale(1.02);
}
/* Response Time */
.response-time {
font-size: 0.75em;
color: #888;
margin-top: 8px;
font-style: italic;
}
.user-insight {
margin-top: 12px;
padding: 10px 12px;
border-radius: 10px;
background: #263238;
border: 1px solid #37474f;
font-size: 0.85em;
color: #b2ebf2;
}
/* Per-Message Toggle Button */
.message-view-toggle {
display: flex;
gap: 5px;
background: #3d3d3d;
border-radius: 6px;
padding: 4px;
border: 1px solid #555;
margin-top: 10px;
width: fit-content;
}
.message-view-toggle button {
padding: 6px 12px;
font-size: 0.8em;
background: transparent;
color: #aaa;
border: none;
border-radius: 4px;
cursor: pointer;
transition: all 0.3s;
}
.message-view-toggle button.active {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.message-view-toggle button:hover:not(.active) {
background: #4d4d4d;
color: #fff;
}
/* Raw JSON View */
.raw-json-view {
background: #1e1e1e;
border: 1px solid #555;
border-radius: 8px;
padding: 12px;
margin-top: 10px;
overflow-x: auto;
}
.raw-json-view pre {
margin: 0;
font-family: 'Courier New', monospace;
font-size: 0.85em;
color: #d4d4d4;
white-space: pre-wrap;
word-wrap: break-word;
}
/* Filtered content view */
.filtered-content {
display: block;
}
.raw-content {
display: none;
}
/* --- Modern Layout & Animations --- */
.main-content {
max-width: 1400px;
/* Wider container */
margin: 0 auto;
padding: 20px;
height: calc(100vh - 80px);
/* Fill remaining height */
box-sizing: border-box;
}
.main-layout {
display: flex;
height: 100%;
gap: 0;
/* Gap handled by margin in panel for smooth transition */
position: relative;
}
/* Chat Container flex fix */
.container {
flex: 1;
display: flex;
flex-direction: column;
background: #2d2d2d;
border-radius: 16px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
border: 1px solid #444;
height: 100%;
padding: 0;
overflow: hidden;
transition: all 0.3s ease;
z-index: 10;
}
/* Internal padding for chat container */
.chat-internal-wrapper {
padding: 20px;
display: flex;
flex-direction: column;
height: 100%;
box-sizing: border-box;
}
/* PROMPT PANEL - Slide In Style */
.prompt-panel {
width: 0;
opacity: 0;
background: #1e1e1e;
/* Darker contrast */
border-left: 1px solid #444;
border-radius: 16px;
display: flex;
flex-direction: column;
padding: 0;
/* Padding handled internally to avoid width jump */
transition: all 0.4s cubic-bezier(0.16, 1, 0.3, 1);
overflow: hidden;
margin-left: 0;
box-shadow: -5px 0 20px rgba(0, 0, 0, 0.3);
white-space: nowrap;
/* Prevent content flicker during width change */
}
.prompt-panel.open {
width: 500px;
/* Wider editor */
opacity: 1;
margin-left: 20px;
padding: 20px;
}
.prompt-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
border-bottom: 1px solid #333;
padding-bottom: 15px;
}
.prompt-tabs {
display: flex;
gap: 8px;
background: #2b2b2b;
border: 1px solid #3a3a3a;
border-radius: 999px;
padding: 4px;
}
.prompt-tab-btn {
background: transparent;
border: none;
color: #aaa;
padding: 6px 12px;
border-radius: 999px;
font-size: 0.85em;
cursor: pointer;
transition: all 0.2s ease;
}
.prompt-tab-btn.active {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff;
}
.prompt-tab-btn:hover:not(.active) {
color: #fff;
background: #3a3a3a;
}
.prompt-header h3 {
font-size: 1.2em;
color: #4fc3f7;
/* Nice blue accent */
display: flex;
align-items: center;
gap: 10px;
}
.prompt-textarea {
flex: 1;
background: #111;
color: #dcdccc;
/* Soft code color */
border: 1px solid #333;
border-radius: 8px;
padding: 15px;
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
font-size: 14px;
line-height: 1.6;
resize: none;
margin-bottom: 15px;
white-space: pre-wrap;
/* Wrap code */
overflow-y: auto;
box-shadow: inset 0 2px 5px rgba(0, 0, 0, 0.5);
}
.prompt-textarea:focus {
outline: none;
border-color: #667eea;
box-shadow: inset 0 2px 5px rgba(0, 0, 0, 0.5), 0 0 0 2px rgba(102, 126, 234, 0.2);
}
.panel-footer {
display: flex;
justify-content: space-between;
align-items: center;
padding-top: 10px;
border-top: 1px solid #333;
}
.prompt-section {
display: none;
flex: 1;
flex-direction: column;
gap: 12px;
}
.prompt-section.active {
display: flex;
}
.tool-prompt-toolbar {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
}
.tool-prompt-select {
min-width: 220px;
padding: 8px 12px;
border: 1px solid #444;
border-radius: 8px;
background: #2d2d2d;
color: #e0e0e0;
}
.status-text {
font-size: 0.8em;
color: #666;
font-style: italic;
}
/* Buttons Update */
.action-btn {
padding: 10px 20px;
border-radius: 8px;
font-weight: 600;
font-size: 0.9em;
border: none;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
gap: 8px;
}
.btn-reload {
background: #333;
color: #aaa;
}
.btn-reload:hover {
background: #444;
color: white;
}
.btn-save {
background: linear-gradient(135deg, #43a047 0%, #2e7d32 100%);
color: white;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.2);
}
.btn-save:hover {
transform: translateY(-2px);
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.3);
}
.btn-close-panel {
background: transparent;
border: none;
color: #666;
font-size: 1.5rem;
cursor: pointer;
transition: color 0.2s;
line-height: 1;
}
.btn-close-panel:hover {
color: #ff6b6b;
}
</style>
</head>
<body>
<!-- Navigation Header -->
<div class="nav-header">
<h1>🤖 Canifa AI System</h1>
<div class="nav-links">
<a href="/static/index.html" class="active">💬 Chatbot</a>
<a href="/static/history.html">🧾 Show History</a>
<a href="/static/test_conversation.html">🧪 Test Tool</a>
</div>
</div>
<div class="main-content">
<div class="main-layout">
<!-- Chat Container -->
<div class="container">
<div class="chat-internal-wrapper">
<div class="header">
<h2>🤖 Canifa AI Chat</h2>
<div class="config-area"
style="flex-wrap: wrap; display: flex; align-items: center; gap: 10px;">
<div style="display: flex; gap: 5px; align-items: center;">
<label style="font-size: 0.8em; color: #aaa;">Device ID:</label>
<input type="text" id="deviceId" placeholder="auto-generated" style="width: 150px;"
onblur="saveConfig()" onchange="saveConfig()">
</div>
<div style="display: flex; gap: 5px; align-items: center;">
<label style="font-size: 0.8em; color: #aaa;">Status:</label>
<!-- Status Indicator logic could go here later -->
</div>
<div style="display: flex; gap: 5px; align-items: center;">
<label style="font-size: 0.8em; color: #aaa;">Access Token:</label>
<input type="text" id="accessToken" placeholder="Token (optional)" style="width: 150px;"
onblur="saveConfig()" onchange="saveConfig()">
</div>
<!-- Action Buttons -->
<button onclick="loadHistory(true)" title="Load History">↻ History</button>
<button onclick="togglePromptEditor()"
style="background: #e6b800; color: #2d2d2d; font-weight: bold;">📝 Prompt</button>
<button onclick="clearUI()" style="background: #d32f2f;">✗ UI</button>
</div>
</div>
<div class="chat-box" id="chatBox">
<div class="load-more" id="loadMoreBtn" style="display: none;">
<button onclick="loadHistory(false)">Load Older Messages ⬆️</button>
</div>
<div id="messagesArea" style="display: flex; flex-direction: column; gap: 15px;"></div>
</div>
<div class="typing-indicator" id="typingIndicator">
<span style="font-style: normal;">🤖</span> AI is thinking...
</div>
<!-- Image Preview Strip -->
<div id="imagePreviewStrip"
style="display: none; padding: 8px 0; gap: 8px; overflow-x: auto; white-space: nowrap;">
</div>
<div class="input-area">
<input type="file" id="imageFileInput" accept="image/*" style="display: none;"
onchange="handleImageSelect(event)">
<button onclick="document.getElementById('imageFileInput').click()" id="imgBtn"
title="Upload Image (Experimental 📸)"
style="background: #4a4a4a; color: #ccc; padding: 0 14px; border: 1px dashed #666; border-radius: 8px; font-size: 1.2em; cursor: pointer; transition: all 0.2s;"
onmouseover="this.style.background='#5a5a5a'; this.style.borderColor='#667eea'; this.style.color='#667eea'"
onmouseout="this.style.background='#4a4a4a'; this.style.borderColor='#666'; this.style.color='#ccc'">📷</button>
<input type="text" id="userInput" placeholder="Type your message..."
onkeypress="handleKeyPress(event)" autocomplete="off">
<button onclick="sendMessage()" id="sendBtn">➤ Send</button>
<button onclick="resetChat()" id="resetBtn" title="Reset Session"
style="background: #ffc107; color: #333; font-weight: bold; padding: 0 20px; margin-left: 10px; border: none; border-radius: 8px; cursor: pointer; display: flex; align-items: center; justify-content: center;">🔄
Reset</button>
</div>
</div>
</div>
<!-- Prompt Editor Panel -->
<div class="prompt-panel" id="promptPanel">
<div class="prompt-header">
<h3>📝 Prompt Editor</h3>
<div style="display: flex; gap: 10px; align-items: center;">
<div class="prompt-tabs">
<button class="prompt-tab-btn active" id="tab-system"
onclick="switchPromptTab('system')">System</button>
<button class="prompt-tab-btn" id="tab-tool" onclick="switchPromptTab('tool')">Tool</button>
</div>
<button class="btn-close-panel" onclick="togglePromptEditor()">×</button>
</div>
</div>
<div class="prompt-section active" id="systemPromptSection">
<textarea id="systemPromptInput" class="prompt-textarea"
placeholder="Loading system prompt content..." spellcheck="false"></textarea>
<div class="panel-footer">
<span class="status-text" id="promptStatus">Ready to edit</span>
<div style="display: flex; gap: 10px;">
<button class="action-btn btn-reload" onclick="loadSystemPrompt()">↻ Reset</button>
<button class="action-btn btn-save" onclick="saveSystemPrompt()">💾 Save & Apply</button>
</div>
</div>
</div>
<div class="prompt-section" id="toolPromptSection">
<div class="tool-prompt-toolbar">
<select id="toolPromptSelect" class="tool-prompt-select" onchange="loadToolPromptFromSelect()">
<option value="">Loading tools...</option>
</select>
<button class="action-btn btn-reload" onclick="refreshToolPromptList()">↻ Refresh</button>
</div>
<textarea id="toolPromptInput" class="prompt-textarea" placeholder="Select a tool prompt to load..."
spellcheck="false"></textarea>
<div class="panel-footer">
<span class="status-text" id="toolPromptStatus">Ready to edit</span>
<div style="display: flex; gap: 10px;">
<button class="action-btn btn-reload" onclick="reloadToolPromptContent()">↻ Reset</button>
<button class="action-btn btn-save" onclick="saveToolPrompt()">💾 Save Tool Prompt</button>
</div>
</div>
</div>
</div>
</div>
<script>
let messageHistory = []; // Store messages for reference
let isPromptPanelOpen = false;
let currentPromptTab = 'system';
let selectedToolPrompt = '';
let pendingImages = []; // 📸 Experimental: images to send with next message
// ==================== IMAGE HANDLING (Experimental) ====================
function handleImageSelect(event) {
const file = event.target.files[0];
if (!file) return;
if (file.size > 5 * 1024 * 1024) {
alert('⚠️ Image too large (max 5MB)');
return;
}
const reader = new FileReader();
reader.onload = function (e) {
pendingImages.push(e.target.result); // data:image/...;base64,...
renderImagePreview();
};
reader.readAsDataURL(file);
event.target.value = ''; // Reset so same file can be re-selected
}
function renderImagePreview() {
const strip = document.getElementById('imagePreviewStrip');
if (pendingImages.length === 0) {
strip.style.display = 'none';
strip.innerHTML = '';
return;
}
strip.style.display = 'flex';
strip.innerHTML = pendingImages.map((img, i) => `
<div style="position: relative; display: inline-block; flex-shrink: 0;">
<img src="${img}" style="height: 60px; border-radius: 8px; border: 1px solid #555; object-fit: cover;">
<button onclick="removePendingImage(${i})" style="position: absolute; top: -5px; right: -5px; background: #d32f2f; color: white; border: none; border-radius: 50%; width: 18px; height: 18px; font-size: 10px; cursor: pointer; line-height: 1;">✕</button>
</div>
`).join('') + `<button onclick="clearPendingImages()" style="background: #555; color: #ccc; border: none; border-radius: 6px; padding: 4px 10px; font-size: 0.75em; cursor: pointer; align-self: center;">Clear all</button>`;
}
function removePendingImage(index) {
pendingImages.splice(index, 1);
renderImagePreview();
}
function clearPendingImages() {
pendingImages = [];
renderImagePreview();
}
// 📋 Clipboard Paste — Ctrl+V ảnh vào ô chat
document.getElementById('userInput').addEventListener('paste', function (e) {
const items = (e.clipboardData || e.originalEvent.clipboardData).items;
for (const item of items) {
if (item.type.startsWith('image/')) {
e.preventDefault();
const blob = item.getAsFile();
if (blob.size > 5 * 1024 * 1024) {
alert('⚠️ Image too large (max 5MB)');
return;
}
const reader = new FileReader();
reader.onload = function (ev) {
pendingImages.push(ev.target.result);
renderImagePreview();
};
reader.readAsDataURL(blob);
break;
}
}
});
async function resetChat() {
if (!confirm('Bạn có chắc muốn làm mới cuộc trò chuyện? Lịch sử cũ sẽ được lưu trữ.')) return;
const deviceId = document.getElementById('deviceId').value;
const accessToken = document.getElementById('accessToken').value.trim();
if (!deviceId) return alert("Missing Device ID");
const headers = {
'Content-Type': 'application/json',
'device_id': deviceId
};
if (accessToken) {
headers['Authorization'] = 'Bearer ' + accessToken;
}
try {
const response = await fetch('/api/history/archive', {
method: 'POST',
headers: headers,
body: JSON.stringify({})
});
const data = await response.json();
if (response.ok && data.status === 'success') {
document.getElementById('messagesArea').innerHTML = '';
const remaining = data.remaining_resets !== undefined ? ` (Còn ${data.remaining_resets} lượt)` : '';
alert('✅ ' + (data.message || 'Reset thành công!') + remaining);
} else {
const errorMsg = data.message || data.detail || 'Không thể reset';
const prefix = data.error_code === 'RESET_LIMIT_EXCEEDED' ? '⚠️ ' : '❌ Lỗi: ';
if (data.require_login) {
if (confirm('🔒 ' + errorMsg + '\nBạn có muốn nhập Token để đăng nhập không?')) {
const token = prompt("Nhập Access Token của bạn:");
if (token) {
document.getElementById('accessToken').value = token;
saveConfig();
alert("Token đã lưu! Hãy thử Reset lại.");
}
}
} else {
alert(prefix + errorMsg);
}
}
} catch (error) {
console.error('Reset error:', error);
alert('Có lỗi xảy ra khi reset.');
}
}
function togglePromptEditor() {
const panel = document.getElementById('promptPanel');
isPromptPanelOpen = !isPromptPanelOpen;
if (isPromptPanelOpen) {
panel.classList.add('open');
if (currentPromptTab === 'system') {
loadSystemPrompt();
} else {
refreshToolPromptList();
}
} else {
panel.classList.remove('open');
}
}
function switchPromptTab(tab) {
currentPromptTab = tab;
const systemSection = document.getElementById('systemPromptSection');
const toolSection = document.getElementById('toolPromptSection');
const systemTab = document.getElementById('tab-system');
const toolTab = document.getElementById('tab-tool');
if (tab === 'system') {
systemSection.classList.add('active');
toolSection.classList.remove('active');
systemTab.classList.add('active');
toolTab.classList.remove('active');
loadSystemPrompt();
} else {
systemSection.classList.remove('active');
toolSection.classList.add('active');
systemTab.classList.remove('active');
toolTab.classList.add('active');
refreshToolPromptList();
}
}
async function loadSystemPrompt() {
const textarea = document.getElementById('systemPromptInput');
textarea.value = "Loading...";
textarea.disabled = true;
try {
const response = await fetch('/api/agent/system-prompt');
const data = await response.json();
if (data.status === 'success') {
textarea.value = data.content;
} else {
textarea.value = "Error loading prompt: " + data.message;
}
} catch (error) {
textarea.value = "Error connecting to server.";
console.error(error);
} finally {
textarea.disabled = false;
}
}
async function saveSystemPrompt() {
const content = document.getElementById('systemPromptInput').value;
const statusLabel = document.getElementById('promptStatus');
if (!content) return;
if (!confirm('Bạn có chắc muốn lưu Prompt mới? Bot sẽ bị reset graph để học prompt mới này.')) {
return;
}
statusLabel.innerText = "Saving...";
try {
const response = await fetch('/api/agent/system-prompt', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: content })
});
const data = await response.json();
if (data.status === 'success') {
statusLabel.innerText = "Saved!";
alert('✅ Đã lưu Prompt thành công!\nBot đã sẵn sàng với prompt mới.');
} else {
statusLabel.innerText = "Error!";
alert('❌ Lỗi: ' + data.detail);
}
} catch (error) {
statusLabel.innerText = "Connection Error";
alert('❌ Lỗi kết nối server');
console.error(error);
}
}
async function refreshToolPromptList() {
const select = document.getElementById('toolPromptSelect');
select.innerHTML = '<option value="">Loading tools...</option>';
try {
const response = await fetch('/api/agent/tool-prompts');
const data = await response.json();
if (data.status !== 'success' || !Array.isArray(data.files)) {
throw new Error(data.message || 'Failed to load tool prompts');
}
const files = data.files;
select.innerHTML = '<option value="">Select tool prompt...</option>';
files.forEach(file => {
const option = document.createElement('option');
option.value = file;
option.textContent = file;
select.appendChild(option);
});
if (selectedToolPrompt && files.includes(selectedToolPrompt)) {
select.value = selectedToolPrompt;
loadToolPrompt(selectedToolPrompt);
}
} catch (error) {
select.innerHTML = '<option value="">Error loading tool list</option>';
console.error(error);
alert('❌ Không thể tải danh sách tool prompts.');
}
}
function loadToolPromptFromSelect() {
const select = document.getElementById('toolPromptSelect');
const filename = select.value;
if (!filename) return;
selectedToolPrompt = filename;
loadToolPrompt(filename);
}
async function loadToolPrompt(filename) {
const textarea = document.getElementById('toolPromptInput');
const statusLabel = document.getElementById('toolPromptStatus');
textarea.value = 'Loading...';
textarea.disabled = true;
statusLabel.innerText = `Loading ${filename}...`;
try {
const response = await fetch(`/api/agent/tool-prompts/${encodeURIComponent(filename)}`);
const data = await response.json();
if (data.status === 'success') {
textarea.value = data.content || '';
statusLabel.innerText = `Loaded ${filename}`;
} else {
textarea.value = '';
statusLabel.innerText = 'Error loading prompt';
alert('❌ Lỗi: ' + (data.detail || data.message || 'Không thể load prompt'));
}
} catch (error) {
textarea.value = '';
statusLabel.innerText = 'Connection Error';
alert('❌ Lỗi kết nối server');
console.error(error);
} finally {
textarea.disabled = false;
}
}
function reloadToolPromptContent() {
if (!selectedToolPrompt) {
alert('Vui lòng chọn tool prompt trước.');
return;
}
loadToolPrompt(selectedToolPrompt);
}
async function saveToolPrompt() {
const statusLabel = document.getElementById('toolPromptStatus');
const textarea = document.getElementById('toolPromptInput');
const content = textarea.value;
if (!selectedToolPrompt) {
alert('Vui lòng chọn tool prompt trước.');
return;
}
if (!confirm(`Lưu prompt cho ${selectedToolPrompt}? Graph sẽ reload.`)) {
return;
}
statusLabel.innerText = 'Saving...';
try {
const response = await fetch(`/api/agent/tool-prompts/${encodeURIComponent(selectedToolPrompt)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content })
});
const data = await response.json();
if (data.status === 'success') {
statusLabel.innerText = 'Saved!';
alert(`✅ ${data.message || 'Đã lưu prompt thành công!'}`);
} else {
statusLabel.innerText = 'Error!';
alert('❌ Lỗi: ' + (data.detail || data.message || 'Không thể lưu prompt'));
}
} catch (error) {
statusLabel.innerText = 'Connection Error';
alert('❌ Lỗi kết nối server');
console.error(error);
}
}
function toggleMessageView(messageId) {
const filteredContent = document.getElementById('filtered-' + messageId);
const rawContent = document.getElementById('raw-' + messageId);
const filteredBtn = document.getElementById('filtered-btn-' + messageId);
const rawBtn = document.getElementById('raw-btn-' + messageId);
if (filteredContent.style.display === 'none') {
// Switch to filtered
filteredContent.style.display = 'block';
rawContent.style.display = 'none';
filteredBtn.classList.add('active');
rawBtn.classList.remove('active');
} else {
// Switch to raw
filteredContent.style.display = 'none';
rawContent.style.display = 'block';
rawBtn.classList.add('active');
filteredBtn.classList.remove('active');
}
}
let currentCursor = null;
let isTyping = false;
async function loadHistory(isRefresh) {
const deviceId = document.getElementById('deviceId').value;
const accessToken = document.getElementById('accessToken').value.trim();
const messagesArea = document.getElementById('messagesArea');
const loadMoreBtn = document.getElementById('loadMoreBtn');
if (!deviceId) {
alert('Please enter a Device ID');
return;
}
if (isRefresh) {
messagesArea.innerHTML = '';
currentCursor = null;
}
// Gọi API với device_id trong URL, nhưng gửi kèm headers để middleware resolve đúng identity
const url = `/api/history/${deviceId}?limit=20${currentCursor ? `&before_id=${currentCursor}` : ''}`;
// Build headers for identity resolution (middleware sẽ dùng token để override nếu có)
const headers = {
'Content-Type': 'application/json',
'device_id': deviceId
};
if (accessToken) {
headers['Authorization'] = 'Bearer ' + accessToken;
}
try {
const response = await fetch(url, { headers: headers });
const data = await response.json();
const messages = data.data || data;
const cursor = data.next_cursor || null;
if (Array.isArray(messages) && messages.length > 0) {
currentCursor = cursor;
if (isRefresh) {
// Refresh: reverse để oldest ở trên, newest ở dưới
const batch = [...messages].reverse();
batch.forEach(msg => appendMessage(msg, 'bottom'));
setTimeout(() => {
const chatBox = document.getElementById('chatBox');
chatBox.scrollTop = chatBox.scrollHeight;
}, 100);
} else {
// Load more: messages từ API theo DESC (newest first của batch cũ)
const chatBox = document.getElementById('chatBox');
const oldHeight = chatBox.scrollHeight;
for (let i = 0; i < messages.length; i++) {
appendMessage(messages[i], 'top');
}
// Adjust scroll to keep view stable
chatBox.scrollTop = chatBox.scrollHeight - oldHeight;
}
loadMoreBtn.style.display = currentCursor ? 'block' : 'none';
} else {
if (isRefresh) {
messagesArea.innerHTML = '<div class="message system">No history found. Start chatting!</div>';
}
loadMoreBtn.style.display = 'none';
}
} catch (error) {
console.error('Error loading history:', error);
alert('Failed to load history');
}
}
function appendMessage(msg, position = 'bottom') {
const messagesArea = document.getElementById('messagesArea');
// Container wrapper for alignment
const container = document.createElement('div');
container.className = `message-container ${msg.is_human ? 'user' : 'bot'}`;
// Sender Name Label
const sender = document.createElement('div');
sender.className = 'sender-name';
sender.innerText = msg.is_human ? 'You' : 'Canifa AI';
container.appendChild(sender);
// Message Bubble
const div = document.createElement('div');
div.className = `message ${msg.is_human ? 'user' : 'bot'}`;
// Generate unique message ID for toggle
const messageId = 'hist-' + (msg.id || Date.now() + Math.random());
if (msg.is_human) {
// User message: show images (if any) + text
if (msg.images && msg.images.length > 0) {
const imgStrip = document.createElement('div');
imgStrip.style.cssText = 'display: flex; gap: 6px; margin-bottom: 8px; flex-wrap: wrap;';
msg.images.forEach(src => {
const img = document.createElement('img');
img.src = src;
img.style.cssText = 'max-height: 120px; max-width: 180px; border-radius: 8px; object-fit: cover; border: 1px solid rgba(255,255,255,0.2);';
imgStrip.appendChild(img);
});
div.appendChild(imgStrip);
}
const textSpan = document.createElement('span');
textSpan.innerText = msg.message;
div.appendChild(textSpan);
} else {
// Bot message: add Widget/Raw JSON toggle
// FILTERED CONTENT (default visible)
const filteredDiv = document.createElement('div');
filteredDiv.id = 'filtered-' + messageId;
filteredDiv.className = 'filtered-content';
filteredDiv.innerText = msg.message;
div.appendChild(filteredDiv);
// RAW CONTENT (hidden by default)
const rawDiv = document.createElement('div');
rawDiv.id = 'raw-' + messageId;
rawDiv.className = 'raw-content';
rawDiv.style.display = 'none';
const rawJsonDiv = document.createElement('div');
rawJsonDiv.className = 'raw-json-view';
const pre = document.createElement('pre');
pre.textContent = JSON.stringify({
id: msg.id,
message: msg.message,
product_ids: msg.product_ids || [],
timestamp: msg.timestamp,
is_human: msg.is_human
}, null, 2);
rawJsonDiv.appendChild(pre);
rawDiv.appendChild(rawJsonDiv);
div.appendChild(rawDiv);
// Toggle Buttons
const toggleDiv = document.createElement('div');
toggleDiv.className = 'message-view-toggle';
const filteredBtn = document.createElement('button');
filteredBtn.id = 'filtered-btn-' + messageId;
filteredBtn.className = 'active';
filteredBtn.innerText = '🎨 Widget';
filteredBtn.onclick = () => toggleMessageView(messageId);
const rawBtn = document.createElement('button');
rawBtn.id = 'raw-btn-' + messageId;
rawBtn.innerText = '👁️ Raw JSON';
rawBtn.onclick = () => toggleMessageView(messageId);
toggleDiv.appendChild(filteredBtn);
toggleDiv.appendChild(rawBtn);
div.appendChild(toggleDiv);
}
// Timestamp inside bubble
const time = document.createElement('span');
time.className = 'timestamp';
time.innerText = new Date(msg.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
div.appendChild(time);
container.appendChild(div);
if (position === 'top') {
messagesArea.insertBefore(container, messagesArea.firstChild);
} else {
messagesArea.appendChild(container);
}
}
async function sendMessage() {
const input = document.getElementById('userInput');
const deviceIdInput = document.getElementById('deviceId');
const accessTokenInput = document.getElementById('accessToken');
const deviceId = deviceIdInput.value.trim();
const accessToken = accessTokenInput.value.trim();
const text = input.value.trim();
const sendBtn = document.getElementById('sendBtn');
const typingIndicator = document.getElementById('typingIndicator');
const chatBox = document.getElementById('chatBox');
if (!deviceId) {
alert('Please enter a Device ID first!');
deviceIdInput.focus();
return;
}
if (!text) return;
// Disable input
input.disabled = true;
sendBtn.disabled = true;
typingIndicator.style.display = 'block';
// Capture images before clearing
const imagesToSend = [...pendingImages];
// Add user message immediately (with image previews)
appendMessage({
message: text,
is_human: true,
timestamp: new Date().toISOString(),
id: 'pending',
images: imagesToSend.length > 0 ? imagesToSend : undefined
});
input.value = '';
clearPendingImages(); // Clear image previews after send
chatBox.scrollTop = chatBox.scrollHeight;
// Save config to localStorage
saveConfig();
// Track response time
const startTime = Date.now();
try {
// Build headers
const headers = {
'Content-Type': 'application/json',
'device_id': deviceId
};
// Add Authorization if access token provided
if (accessToken) {
headers['Authorization'] = 'Bearer ' + accessToken;
}
const response = await fetch('/api/agent/chat-dev', {
method: 'POST',
headers: headers,
body: JSON.stringify({
user_query: text,
device_id: deviceId,
...(imagesToSend.length > 0 && { images: imagesToSend })
})
});
// Handle API Errors (Rate Limit, System Error) with Widget/Raw View
if (response.status === 429 || response.status === 500) {
const errorData = await response.json();
const responseTime = ((Date.now() - startTime) / 1000).toFixed(2);
const messageId = 'msg-error-' + Date.now();
// Create bot message container
const messagesArea = document.getElementById('messagesArea');
const container = document.createElement('div');
container.className = 'message-container bot';
const sender = document.createElement('div');
sender.className = 'sender-name';
sender.innerText = 'Canifa AI';
container.appendChild(sender);
const botMsgDiv = document.createElement('div');
botMsgDiv.className = 'message bot';
// 1. FILTERED CONTENT (Widget View)
const filteredDiv = document.createElement('div');
filteredDiv.id = 'filtered-' + messageId;
filteredDiv.className = 'filtered-content';
filteredDiv.style.color = '#ff6b6b';
// Extract message
const errorMessage = errorData.message ||
errorData.detail?.message ||
'Có lỗi xảy ra!';
filteredDiv.innerHTML = `
<div style="font-weight: bold; margin-bottom: 8px;">⚠️ ${errorData.error_code || 'ERROR'}</div>
<div>${errorMessage}</div>
${errorData.require_login ? '<div style="margin-top: 10px; padding: 8px; background: #3d2d2d; border-radius: 6px;">👉 Đăng nhập ngay để tiếp tục!</div>' : ''}
`;
botMsgDiv.appendChild(filteredDiv);
// 2. RAW CONTENT (JSON View)
const rawDiv = document.createElement('div');
rawDiv.id = 'raw-' + messageId;
rawDiv.className = 'raw-content';
rawDiv.style.display = 'none';
const rawJsonDiv = document.createElement('div');
rawJsonDiv.className = 'raw-json-view';
const pre = document.createElement('pre');
pre.textContent = JSON.stringify(errorData, null, 2);
rawJsonDiv.appendChild(pre);
rawDiv.appendChild(rawJsonDiv);
botMsgDiv.appendChild(rawDiv);
// 3. Toggle Buttons
const toggleDiv = document.createElement('div');
toggleDiv.className = 'message-view-toggle';
const filteredBtn = document.createElement('button');
filteredBtn.id = 'filtered-btn-' + messageId;
filteredBtn.className = 'active';
filteredBtn.innerText = '🎨 Widget';
filteredBtn.onclick = () => toggleMessageView(messageId);
const rawBtn = document.createElement('button');
rawBtn.id = 'raw-btn-' + messageId;
rawBtn.innerText = '👁️ Raw JSON';
rawBtn.onclick = () => toggleMessageView(messageId);
toggleDiv.appendChild(filteredBtn);
toggleDiv.appendChild(rawBtn);
botMsgDiv.appendChild(toggleDiv);
// Response time
const timeDiv = document.createElement('div');
timeDiv.className = 'response-time';
timeDiv.innerText = `⏱️ ${responseTime}s`;
botMsgDiv.appendChild(timeDiv);
container.appendChild(botMsgDiv);
messagesArea.appendChild(container);
chatBox.scrollTop = chatBox.scrollHeight;
input.disabled = false;
sendBtn.disabled = false;
typingIndicator.style.display = 'none';
return;
}
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.detail?.message || errorData.detail || 'Có lỗi xảy ra');
}
const data = await response.json();
const responseTime = (data.response_ready_s !== undefined && data.response_ready_s !== null)
? Number(data.response_ready_s).toFixed(2)
: ((Date.now() - startTime) / 1000).toFixed(2);
// Generate unique message ID
const messageId = 'msg-' + Date.now();
// Store message data
messageHistory.push({
type: 'bot',
data: data,
responseTime: responseTime,
timestamp: new Date().toISOString(),
id: messageId
});
const messagesArea = document.getElementById('messagesArea');
// ============================================
// MESSAGE 1: AI Response + Products (Immediate)
// ============================================
if (data.status === 'success') {
const container1 = document.createElement('div');
container1.className = 'message-container bot';
const sender1 = document.createElement('div');
sender1.className = 'sender-name';
sender1.innerText = 'Canifa AI';
container1.appendChild(sender1);
const botMsgDiv1 = document.createElement('div');
botMsgDiv1.className = 'message bot';
// FILTERED CONTENT
const filteredDiv = document.createElement('div');
filteredDiv.id = 'filtered-' + messageId;
filteredDiv.className = 'filtered-content';
// Display AI text response
const textDiv = document.createElement('div');
textDiv.innerText = data.ai_response || 'No response';
filteredDiv.appendChild(textDiv);
// Render product cards if available
if (data.product_ids && data.product_ids.length > 0) {
const productsContainer = document.createElement('div');
productsContainer.className = 'product-cards-container';
data.product_ids.forEach(product => {
const card = document.createElement('div');
card.className = 'product-card';
// Product image
const img = document.createElement('img');
img.src = product.thumbnail_image_url || 'https://via.placeholder.com/200';
img.alt = product.name;
img.onerror = function () { this.src = 'https://via.placeholder.com/200?text=No+Image'; };
card.appendChild(img);
// Product body
const body = document.createElement('div');
body.className = 'product-card-body';
// SKU
const sku = document.createElement('div');
sku.className = 'product-sku';
sku.innerText = product.sku;
body.appendChild(sku);
// Name
const name = document.createElement('div');
name.className = 'product-name';
name.innerText = product.name;
body.appendChild(name);
// Price
const priceDiv = document.createElement('div');
priceDiv.className = 'product-price';
if (product.sale_price && product.price && product.sale_price < product.price) {
const originalPrice = document.createElement('span');
originalPrice.className = 'price-original';
originalPrice.innerText = (product.price || 0).toLocaleString('vi-VN') + 'đ';
priceDiv.appendChild(originalPrice);
const salePrice = document.createElement('span');
salePrice.className = 'price-sale';
salePrice.innerText = (product.sale_price || 0).toLocaleString('vi-VN') + 'đ';
priceDiv.appendChild(salePrice);
} else if (product.price) {
const regularPrice = document.createElement('span');
regularPrice.className = 'price-regular';
regularPrice.innerText = (product.price || 0).toLocaleString('vi-VN') + 'đ';
priceDiv.appendChild(regularPrice);
} else {
const noPrice = document.createElement('span');
noPrice.className = 'price-regular';
noPrice.innerText = 'Liên hệ';
priceDiv.appendChild(noPrice);
}
body.appendChild(priceDiv);
// Link button
const link = document.createElement('a');
link.className = 'product-link';
link.href = product.url;
link.target = '_blank';
link.innerText = '🛍️ Xem chi tiết';
body.appendChild(link);
card.appendChild(body);
productsContainer.appendChild(card);
});
filteredDiv.appendChild(productsContainer);
}
botMsgDiv1.appendChild(filteredDiv);
// RAW CONTENT
const rawDiv = document.createElement('div');
rawDiv.id = 'raw-' + messageId;
rawDiv.className = 'raw-content';
rawDiv.style.display = 'none';
const rawJsonDiv = document.createElement('div');
rawJsonDiv.className = 'raw-json-view';
const pre = document.createElement('pre');
pre.textContent = JSON.stringify({
status: data.status,
ai_response: data.ai_response,
product_ids: data.product_ids,
response_ready_s: data.response_ready_s,
response_ready_stream_s: data.response_ready_stream_s,
limit_info: data.limit_info || null,
}, null, 2);
rawJsonDiv.appendChild(pre);
rawDiv.appendChild(rawJsonDiv);
botMsgDiv1.appendChild(rawDiv);
// Toggle Buttons
const toggleDiv = document.createElement('div');
toggleDiv.className = 'message-view-toggle';
const filteredBtn = document.createElement('button');
filteredBtn.id = 'filtered-btn-' + messageId;
filteredBtn.className = 'active';
filteredBtn.innerText = '🎨 Widget';
filteredBtn.onclick = () => toggleMessageView(messageId);
const rawBtn = document.createElement('button');
rawBtn.id = 'raw-btn-' + messageId;
rawBtn.innerText = '👁️ Raw JSON';
rawBtn.onclick = () => toggleMessageView(messageId);
toggleDiv.appendChild(filteredBtn);
toggleDiv.appendChild(rawBtn);
botMsgDiv1.appendChild(toggleDiv);
// Response time for message 1
const timeDiv = document.createElement('div');
timeDiv.className = 'response-time';
timeDiv.innerText = `⏱️ ${responseTime}s`;
botMsgDiv1.appendChild(timeDiv);
// Limit info display
if (data.limit_info) {
const limitDiv = document.createElement('div');
limitDiv.className = 'limit-info';
limitDiv.style.fontSize = '0.85em';
limitDiv.style.color = '#a0a0a0';
limitDiv.style.marginTop = '8px';
limitDiv.style.padding = '8px';
limitDiv.style.background = 'rgba(255, 255, 255, 0.05)';
limitDiv.style.borderRadius = '6px';
limitDiv.style.borderLeft = '3px solid #667eea';
const limitText = `📊 Message Limit: ${data.limit_info.used}/${data.limit_info.limit} (Còn ${data.limit_info.remaining} tin nhắn)`;
limitDiv.innerText = limitText;
botMsgDiv1.appendChild(limitDiv);
}
container1.appendChild(botMsgDiv1);
messagesArea.appendChild(container1);
chatBox.scrollTop = chatBox.scrollHeight;
// ============================================
// MESSAGE 2: User Insight (Polling - real backend delay)
// ============================================
const renderUserInsightMessage = (insightObj) => {
const container2 = document.createElement('div');
container2.className = 'message-container bot';
const sender2 = document.createElement('div');
sender2.className = 'sender-name';
sender2.innerText = 'Canifa AI - Analytics';
container2.appendChild(sender2);
const botMsgDiv2 = document.createElement('div');
botMsgDiv2.className = 'message bot';
const insightDiv = document.createElement('div');
insightDiv.className = 'user-insight';
insightDiv.innerHTML = '<strong>🧠 User Insight:</strong><br/>';
Object.entries(insightObj).forEach(([key, value]) => {
const line = document.createElement('div');
line.style.fontSize = '0.9em';
line.style.marginTop = '2px';
line.innerHTML = `<strong>${key}:</strong> ${value}`;
insightDiv.appendChild(line);
});
botMsgDiv2.appendChild(insightDiv);
// Real timing for insight render
const insightTime = ((Date.now() - startTime) / 1000).toFixed(2);
const insightTimeDiv = document.createElement('div');
insightTimeDiv.className = 'response-time';
insightTimeDiv.innerText = `⏱️ Insight ${insightTime}s`;
botMsgDiv2.appendChild(insightTimeDiv);
container2.appendChild(botMsgDiv2);
messagesArea.appendChild(container2);
chatBox.scrollTop = chatBox.scrollHeight;
};
const pollUserInsight = async () => {
const maxAttempts = 60; // ~12s at 200ms
const intervalMs = 200;
let attempts = 0;
const headers = {
'Content-Type': 'application/json',
'device_id': deviceId
};
if (accessToken) {
headers['Authorization'] = 'Bearer ' + accessToken;
}
const tick = async () => {
attempts += 1;
try {
const res = await fetch('/api/agent/user-insight', { headers });
if (!res.ok) throw new Error('Failed to fetch user_insight');
const payload = await res.json();
if (payload.status === 'success' && payload.user_insight) {
const insightObj = typeof payload.user_insight === 'string'
? JSON.parse(payload.user_insight)
: payload.user_insight;
renderUserInsightMessage(insightObj);
return;
}
} catch (e) {
console.warn('Polling user_insight failed:', e);
}
if (attempts < maxAttempts) {
setTimeout(tick, intervalMs);
}
};
setTimeout(tick, 50);
};
if (data.insight_status === 'pending') {
pollUserInsight();
}
} else {
// ERROR CASE: Limit exceeded or other errors
const container = document.createElement('div');
container.className = 'message-container bot';
const sender = document.createElement('div');
sender.className = 'sender-name';
sender.innerText = 'Canifa AI';
container.appendChild(sender);
const botMsgDiv = document.createElement('div');
botMsgDiv.className = 'message bot';
// FILTERED CONTENT (error message - default visible)
const filteredDiv = document.createElement('div');
filteredDiv.id = 'filtered-' + messageId;
filteredDiv.className = 'filtered-content';
filteredDiv.style.color = '#ff6b6b';
filteredDiv.innerHTML = `
<div style="font-weight: bold; margin-bottom: 8px;">⚠️ ${data.error_code || 'ERROR'}</div>
<div>${data.message || 'Unknown error'}</div>
${data.require_login ? '<div style="margin-top: 10px; padding: 8px; background: #3d2d2d; border-radius: 6px;">👉 Vui lòng đăng nhập để tiếp tục sử dụng!</div>' : ''}
`;
botMsgDiv.appendChild(filteredDiv);
// RAW CONTENT (hidden by default)
const rawDiv = document.createElement('div');
rawDiv.id = 'raw-' + messageId;
rawDiv.className = 'raw-content';
rawDiv.style.display = 'none';
const rawJsonDiv = document.createElement('div');
rawJsonDiv.className = 'raw-json-view';
const pre = document.createElement('pre');
pre.textContent = JSON.stringify({
status: data.status,
error_code: data.error_code,
message: data.message,
require_login: data.require_login,
limit_info: data.limit_info || null
}, null, 2);
rawJsonDiv.appendChild(pre);
rawDiv.appendChild(rawJsonDiv);
botMsgDiv.appendChild(rawDiv);
// Toggle Buttons
const toggleDiv = document.createElement('div');
toggleDiv.className = 'message-view-toggle';
const filteredBtn = document.createElement('button');
filteredBtn.id = 'filtered-btn-' + messageId;
filteredBtn.className = 'active';
filteredBtn.innerText = '🎨 Widget';
filteredBtn.onclick = () => toggleMessageView(messageId);
const rawBtn = document.createElement('button');
rawBtn.id = 'raw-btn-' + messageId;
rawBtn.innerText = '👁️ Raw JSON';
rawBtn.onclick = () => toggleMessageView(messageId);
toggleDiv.appendChild(filteredBtn);
toggleDiv.appendChild(rawBtn);
botMsgDiv.appendChild(toggleDiv);
container.appendChild(botMsgDiv);
messagesArea.appendChild(container);
}
chatBox.scrollTop = chatBox.scrollHeight;
} catch (error) {
console.error('Error sending message:', error);
appendMessage({
message: `Error: ${error.message}`,
is_human: false,
timestamp: new Date().toISOString(),
id: 'error'
});
} finally {
input.disabled = false;
sendBtn.disabled = false;
typingIndicator.style.display = 'none';
input.focus();
chatBox.scrollTop = chatBox.scrollHeight;
}
}
function handleKeyPress(event) {
if (event.key === 'Enter') {
sendMessage();
}
}
function clearUI() {
document.getElementById('messagesArea').innerHTML = '';
}
// Apply token from login prompt in rate limit error
function applyLoginToken() {
const tokenInput = document.getElementById('loginTokenInput');
if (tokenInput && tokenInput.value.trim()) {
document.getElementById('accessToken').value = tokenInput.value.trim();
saveConfig();
alert('✅ Token đã được lưu! Bạn có thể tiếp tục chat.');
} else {
alert('Vui lòng nhập Access Token!');
}
}
// Save config to localStorage (called on input change/blur)
function saveConfig() {
const deviceId = document.getElementById('deviceId').value.trim();
const accessToken = document.getElementById('accessToken').value.trim();
if (deviceId) {
localStorage.setItem('canifa_device_id', deviceId);
}
if (accessToken) {
localStorage.setItem('canifa_access_token', accessToken);
} else {
localStorage.removeItem('canifa_access_token');
}
}
// Generate UUID for device_id
function generateUUID() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
const r = Math.random() * 16 | 0;
const v = c === 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
}
// Load config from localStorage on page load
window.onload = function () {
// Load or generate Device ID
let savedDeviceId = localStorage.getItem('canifa_device_id');
if (!savedDeviceId) {
savedDeviceId = 'device-' + generateUUID().substring(0, 8);
localStorage.setItem('canifa_device_id', savedDeviceId);
}
document.getElementById('deviceId').value = savedDeviceId;
// Load Access Token (optional)
const savedAccessToken = localStorage.getItem('canifa_access_token');
if (savedAccessToken) {
document.getElementById('accessToken').value = savedAccessToken;
}
// Auto-load history
setTimeout(() => loadHistory(true), 50);
};
</script>
</div> <!-- Close main-content -->
</body>
</html>
\ No newline at end of file
message_content,conversation_id_test
Cho mình xem áo khoác nam,1
Có size L không?,1
Mình muốn mua váy đỏ,2
Giá bao nhiêu vậy?,2
Có khuyến mãi không?,2
Tư vấn quần jean nữ,3
<!-- <!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>
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;
}
/* 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;
margin: 0 auto;
padding: 20px;
}
.container {
background: #2d2d2d;
padding: 20px;
border-radius: 12px;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.5);
display: flex;
flex-direction: column;
height: 90vh;
border: 1px solid #444;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding-bottom: 10px;
border-bottom: 1px solid #444;
}
.header h2 {
margin: 0;
color: #fff;
}
.config-area {
display: flex;
gap: 10px;
align-items: center;
}
input[type="text"] {
padding: 8px 12px;
border: 1px solid #555;
border-radius: 6px;
background: #3d3d3d;
color: #fff;
}
button {
padding: 8px 16px;
background: #007acc;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
transition: opacity 0.2s;
font-weight: 500;
}
button:hover {
opacity: 0.9;
}
button:disabled {
background: #555;
cursor: not-allowed;
}
.chat-box {
flex: 1;
overflow-y: auto;
padding: 20px;
border: 1px solid #444;
border-radius: 8px;
margin-bottom: 20px;
background: #252526;
display: flex;
flex-direction: column;
gap: 10px;
}
.message-container {
display: flex;
flex-direction: column;
max-width: 80%;
}
.message-container.user {
align-self: flex-end;
align-items: flex-end;
}
.message-container.bot {
align-self: flex-start;
align-items: flex-start;
}
.sender-name {
font-size: 0.8em;
margin-bottom: 4px;
color: #aaa;
margin-left: 4px;
margin-right: 4px;
}
.message {
padding: 12px 16px;
border-radius: 12px;
line-height: 1.5;
word-wrap: break-word;
position: relative;
}
.message.user {
background: #007acc;
color: white;
border-bottom-right-radius: 2px;
}
.message.bot {
background: #3e3e42;
color: #e0e0e0;
border-bottom-left-radius: 2px;
border: 1px solid #555;
}
.message.system {
background: #3d2d2d;
color: #ff6b6b;
align-self: center;
font-size: 0.9em;
max-width: 90%;
border: 1px solid #552b2b;
}
.message.rate-limit-error {
background: linear-gradient(135deg, #3d2d2d 0%, #2d2d3d 100%);
border: 1px solid #ff6b6b;
padding: 16px;
max-width: 350px;
}
.timestamp {
font-size: 0.7em;
opacity: 0.7;
margin-top: 6px;
display: block;
text-align: right;
}
.input-area {
display: flex;
gap: 10px;
}
.input-area input {
flex: 1;
padding: 12px;
border: 1px solid #555;
border-radius: 8px;
font-size: 16px;
background: #3d3d3d;
color: #fff;
}
.input-area input:focus {
outline: 2px solid #007acc;
border-color: transparent;
}
.load-more {
text-align: center;
margin-bottom: 10px;
}
.load-more button {
background: #3e3e42;
color: #ccc;
font-size: 0.85em;
width: 100%;
border: 1px dashed #555;
}
.load-more button:hover {
background: #4e4e52;
color: #fff;
}
.typing-indicator {
font-style: italic;
color: #888;
font-size: 0.9em;
margin-bottom: 10px;
display: none;
margin-left: 10px;
}
/* Scrollbar styling */
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: #2d2d2d;
}
::-webkit-scrollbar-thumb {
background: #555;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #666;
}
/* Product Cards Styling */
.product-cards-container {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 15px;
margin-top: 15px;
padding-top: 15px;
border-top: 1px solid #555;
}
.product-card {
background: #3d3d3d;
border-radius: 8px;
overflow: hidden;
transition: all 0.3s;
border: 1px solid #555;
display: flex;
flex-direction: column;
}
.product-card:hover {
transform: translateY(-5px);
box-shadow: 0 5px 15px rgba(102, 126, 234, 0.3);
border-color: #667eea;
}
.product-card img {
width: 100%;
height: 200px;
object-fit: cover;
background: #2d2d2d;
}
.product-card-body {
padding: 12px;
flex-grow: 1;
display: flex;
flex-direction: column;
}
.product-sku {
font-size: 0.75em;
color: #667eea;
font-weight: bold;
margin-bottom: 5px;
}
.product-name {
font-size: 0.9em;
color: #fff;
margin-bottom: 10px;
line-height: 1.3;
flex-grow: 1;
}
.product-price {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
}
.price-original {
font-size: 0.85em;
color: #888;
text-decoration: line-through;
}
.price-sale {
font-size: 1.1em;
color: #ff6b6b;
font-weight: bold;
}
.price-regular {
font-size: 1.1em;
color: #4caf50;
font-weight: bold;
}
.product-link {
display: block;
text-align: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
text-decoration: none;
padding: 8px 12px;
border-radius: 6px;
font-size: 0.85em;
transition: all 0.3s;
}
.product-link:hover {
opacity: 0.9;
transform: scale(1.02);
}
/* Response Time */
.response-time {
font-size: 0.75em;
color: #888;
margin-top: 8px;
font-style: italic;
}
/* Per-Message Toggle Button */
.message-view-toggle {
display: flex;
gap: 5px;
background: #3d3d3d;
border-radius: 6px;
padding: 4px;
border: 1px solid #555;
margin-top: 10px;
width: fit-content;
}
.message-view-toggle button {
padding: 6px 12px;
font-size: 0.8em;
background: transparent;
color: #aaa;
border: none;
border-radius: 4px;
cursor: pointer;
transition: all 0.3s;
}
.message-view-toggle button.active {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.message-view-toggle button:hover:not(.active) {
background: #4d4d4d;
color: #fff;
}
/* Raw JSON View */
.raw-json-view {
background: #1e1e1e;
border: 1px solid #555;
border-radius: 8px;
padding: 12px;
margin-top: 10px;
overflow-x: auto;
}
.raw-json-view pre {
margin: 0;
font-family: 'Courier New', monospace;
font-size: 0.85em;
color: #d4d4d4;
white-space: pre-wrap;
word-wrap: break-word;
}
/* Filtered content view */
.filtered-content {
display: block;
}
.raw-content {
display: none;
}
/* --- Modern Layout & Animations --- */
.main-content {
max-width: 1400px;
/* Wider container */
margin: 0 auto;
padding: 20px;
height: calc(100vh - 80px);
/* Fill remaining height */
box-sizing: border-box;
}
.main-layout {
display: flex;
height: 100%;
gap: 0;
/* Gap handled by margin in panel for smooth transition */
position: relative;
}
/* Chat Container flex fix */
.container {
flex: 1;
display: flex;
flex-direction: column;
background: #2d2d2d;
border-radius: 16px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
border: 1px solid #444;
height: 100%;
padding: 0;
overflow: hidden;
transition: all 0.3s ease;
z-index: 10;
}
/* Internal padding for chat container */
.chat-internal-wrapper {
padding: 20px;
display: flex;
flex-direction: column;
height: 100%;
box-sizing: border-box;
}
/* PROMPT PANEL - Slide In Style */
.prompt-panel {
width: 0;
opacity: 0;
background: #1e1e1e;
/* Darker contrast */
border-left: 1px solid #444;
border-radius: 16px;
display: flex;
flex-direction: column;
padding: 0;
/* Padding handled internally to avoid width jump */
transition: all 0.4s cubic-bezier(0.16, 1, 0.3, 1);
overflow: hidden;
margin-left: 0;
box-shadow: -5px 0 20px rgba(0, 0, 0, 0.3);
white-space: nowrap;
/* Prevent content flicker during width change */
}
.prompt-panel.open {
width: 500px;
/* Wider editor */
opacity: 1;
margin-left: 20px;
padding: 20px;
}
.prompt-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
border-bottom: 1px solid #333;
padding-bottom: 15px;
}
.prompt-header h3 {
font-size: 1.2em;
color: #4fc3f7;
/* Nice blue accent */
display: flex;
align-items: center;
gap: 10px;
}
.prompt-textarea {
flex: 1;
background: #111;
color: #dcdccc;
/* Soft code color */
border: 1px solid #333;
border-radius: 8px;
padding: 15px;
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
font-size: 14px;
line-height: 1.6;
resize: none;
margin-bottom: 15px;
white-space: pre-wrap;
/* Wrap code */
overflow-y: auto;
box-shadow: inset 0 2px 5px rgba(0, 0, 0, 0.5);
}
.prompt-textarea:focus {
outline: none;
border-color: #667eea;
box-shadow: inset 0 2px 5px rgba(0, 0, 0, 0.5), 0 0 0 2px rgba(102, 126, 234, 0.2);
}
.panel-footer {
display: flex;
justify-content: space-between;
align-items: center;
padding-top: 10px;
border-top: 1px solid #333;
}
.status-text {
font-size: 0.8em;
color: #666;
font-style: italic;
}
/* Buttons Update */
.action-btn {
padding: 10px 20px;
border-radius: 8px;
font-weight: 600;
font-size: 0.9em;
border: none;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
gap: 8px;
}
.btn-reload {
background: #333;
color: #aaa;
}
.btn-reload:hover {
background: #444;
color: white;
}
.btn-save {
background: linear-gradient(135deg, #43a047 0%, #2e7d32 100%);
color: white;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.2);
}
.btn-save:hover {
transform: translateY(-2px);
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.3);
}
.btn-close-panel {
background: transparent;
border: none;
color: #666;
font-size: 1.5rem;
cursor: pointer;
transition: color 0.2s;
line-height: 1;
}
.btn-close-panel:hover {
color: #ff6b6b;
}
</style>
</head>
<body>
<!-- Navigation Header -->
<div class="nav-header">
<h1>🤖 Canifa AI System</h1>
<div class="nav-links">
<a href="/static/index.html" class="active">💬 Chatbot</a>
</div>
</div>
<div class="main-content">
<div class="main-layout">
<!-- Chat Container -->
<div class="container">
<div class="chat-internal-wrapper">
<div class="header">
<h2>🤖 Canifa AI Chat</h2>
<div class="config-area" style="flex-wrap: wrap; display: flex; align-items: center; gap: 10px;">
<div style="display: flex; gap: 5px; align-items: center;">
<label style="font-size: 0.8em; color: #aaa;">Device ID:</label>
<input type="text" id="deviceId" placeholder="auto-generated" style="width: 150px;"
onblur="saveConfig()" onchange="saveConfig()">
</div>
<div style="display: flex; gap: 5px; align-items: center;">
<label style="font-size: 0.8em; color: #aaa;">Status:</label>
<!-- Status Indicator logic could go here later -->
</div>
<div style="display: flex; gap: 5px; align-items: center;">
<label style="font-size: 0.8em; color: #aaa;">Access Token:</label>
<input type="text" id="accessToken" placeholder="Token (optional)" style="width: 150px;"
onblur="saveConfig()" onchange="saveConfig()">
</div>
<!-- Action Buttons -->
<button onclick="loadHistory(true)" title="Load History">↻ History</button>
<button onclick="togglePromptEditor()"
style="background: #e6b800; color: #2d2d2d; font-weight: bold;">📝 Prompt</button>
<button onclick="clearUI()" style="background: #d32f2f;">✗ UI</button>
</div>
</div>
<div class="chat-box" id="chatBox">
<div class="load-more" id="loadMoreBtn" style="display: none;">
<button onclick="loadHistory(false)">Load Older Messages ⬆️</button>
</div>
<div id="messagesArea" style="display: flex; flex-direction: column; gap: 15px;"></div>
</div>
<div class="typing-indicator" id="typingIndicator">
<span style="font-style: normal;">🤖</span> AI is thinking...
</div>
<div class="input-area">
<input type="text" id="userInput" placeholder="Type your message..."
onkeypress="handleKeyPress(event)" autocomplete="off">
<button onclick="sendMessage()" id="sendBtn">➤ Send</button>
<button onclick="resetChat()" id="resetBtn" title="Reset Session"
style="background: #ffc107; color: #333; font-weight: bold; padding: 0 20px; margin-left: 10px; border: none; border-radius: 8px; cursor: pointer; display: flex; align-items: center; justify-content: center;">🔄
Reset</button>
</div>
</div>
</div>
<!-- Prompt Editor Panel -->
<div class="prompt-panel" id="promptPanel">
<div class="prompt-header">
<h3>📝 System Prompt</h3>
<button class="btn-close-panel" onclick="togglePromptEditor()">×</button>
</div>
<textarea id="systemPromptInput" class="prompt-textarea" placeholder="Loading prompt content..."
spellcheck="false"></textarea>
<div class="panel-footer">
<span class="status-text" id="promptStatus">Ready to edit</span>
<div style="display: flex; gap: 10px;">
<button class="action-btn btn-reload" onclick="loadSystemPrompt()">↻ Reset</button>
<button class="action-btn btn-save" onclick="saveSystemPrompt()">💾 Save & Apply</button>
</div>
</div>
</div>
</div>
<script>
let messageHistory = []; // Store messages for reference
let isPromptPanelOpen = false;
async function resetChat() {
if (!confirm('Bạn có chắc muốn làm mới cuộc trò chuyện? Lịch sử cũ sẽ được lưu trữ.')) return;
const deviceId = document.getElementById('deviceId').value;
if (!deviceId) return alert("Missing Device ID");
try {
const response = await fetch('/api/history/archive', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'device_id': deviceId
},
body: JSON.stringify({})
});
const data = await response.json();
if (response.ok && data.status === 'success') {
document.getElementById('messagesArea').innerHTML = '';
const remaining = data.remaining_resets !== undefined ? ` (Còn ${data.remaining_resets} lượt)` : '';
alert('✅ ' + (data.message || 'Reset thành công!') + remaining);
} else {
const errorMsg = data.message || data.detail || 'Không thể reset';
const prefix = data.error_code === 'RESET_LIMIT_EXCEEDED' ? '⚠️ ' : '❌ Lỗi: ';
alert(prefix + errorMsg);
}
} catch (error) {
console.error('Reset error:', error);
alert('Có lỗi xảy ra khi reset.');
}
}
function togglePromptEditor() {
const panel = document.getElementById('promptPanel');
isPromptPanelOpen = !isPromptPanelOpen;
if (isPromptPanelOpen) {
panel.classList.add('open');
loadSystemPrompt();
} else {
panel.classList.remove('open');
}
}
async function loadSystemPrompt() {
const textarea = document.getElementById('systemPromptInput');
textarea.value = "Loading...";
textarea.disabled = true;
try {
const response = await fetch('/api/agent/system-prompt');
const data = await response.json();
if (data.status === 'success') {
textarea.value = data.content;
} else {
textarea.value = "Error loading prompt: " + data.message;
}
} catch (error) {
textarea.value = "Error connecting to server.";
console.error(error);
} finally {
textarea.disabled = false;
}
}
async function saveSystemPrompt() {
const content = document.getElementById('systemPromptInput').value;
const statusLabel = document.getElementById('promptStatus');
if (!content) return;
if (!confirm('Bạn có chắc muốn lưu Prompt mới? Bot sẽ bị reset graph để học prompt mới này.')) {
return;
}
statusLabel.innerText = "Saving...";
try {
const response = await fetch('/api/agent/system-prompt', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: content })
});
const data = await response.json();
if (data.status === 'success') {
statusLabel.innerText = "Saved!";
alert('✅ Đã lưu Prompt thành công!\nBot đã sẵn sàng với prompt mới.');
} else {
statusLabel.innerText = "Error!";
alert('❌ Lỗi: ' + data.detail);
}
} catch (error) {
statusLabel.innerText = "Connection Error";
alert('❌ Lỗi kết nối server');
console.error(error);
}
}
function toggleMessageView(messageId) {
const filteredContent = document.getElementById('filtered-' + messageId);
const rawContent = document.getElementById('raw-' + messageId);
const filteredBtn = document.getElementById('filtered-btn-' + messageId);
const rawBtn = document.getElementById('raw-btn-' + messageId);
if (filteredContent.style.display === 'none') {
// Switch to filtered
filteredContent.style.display = 'block';
rawContent.style.display = 'none';
filteredBtn.classList.add('active');
rawBtn.classList.remove('active');
} else {
// Switch to raw
filteredContent.style.display = 'none';
rawContent.style.display = 'block';
rawBtn.classList.add('active');
filteredBtn.classList.remove('active');
}
}
let currentCursor = null;
let isTyping = false;
async function loadHistory(isRefresh) {
const deviceId = document.getElementById('deviceId').value;
const accessToken = document.getElementById('accessToken').value.trim();
const messagesArea = document.getElementById('messagesArea');
const loadMoreBtn = document.getElementById('loadMoreBtn');
if (!deviceId) {
alert('Please enter a Device ID');
return;
}
if (isRefresh) {
messagesArea.innerHTML = '';
currentCursor = null;
}
// Gọi API với device_id trong URL, nhưng gửi kèm headers để middleware resolve đúng identity
const url = `/api/history/${deviceId}?limit=20${currentCursor ? `&before_id=${currentCursor}` : ''}`;
// Build headers for identity resolution (middleware sẽ dùng token để override nếu có)
const headers = {
'Content-Type': 'application/json',
'device_id': deviceId
};
if (accessToken) {
headers['Authorization'] = 'Bearer ' + accessToken;
}
try {
const response = await fetch(url, { headers: headers });
const data = await response.json();
const messages = data.data || data;
const cursor = data.next_cursor || null;
if (Array.isArray(messages) && messages.length > 0) {
currentCursor = cursor;
if (isRefresh) {
// Refresh: reverse để oldest ở trên, newest ở dưới
const batch = [...messages].reverse();
batch.forEach(msg => appendMessage(msg, 'bottom'));
setTimeout(() => {
const chatBox = document.getElementById('chatBox');
chatBox.scrollTop = chatBox.scrollHeight;
}, 100);
} else {
// Load more: messages từ API theo DESC (newest first của batch cũ)
const chatBox = document.getElementById('chatBox');
const oldHeight = chatBox.scrollHeight;
for (let i = 0; i < messages.length; i++) {
appendMessage(messages[i], 'top');
}
// Adjust scroll to keep view stable
chatBox.scrollTop = chatBox.scrollHeight - oldHeight;
}
loadMoreBtn.style.display = currentCursor ? 'block' : 'none';
} else {
if (isRefresh) {
messagesArea.innerHTML = '<div class="message system">No history found. Start chatting!</div>';
}
loadMoreBtn.style.display = 'none';
}
} catch (error) {
console.error('Error loading history:', error);
alert('Failed to load history');
}
}
function appendMessage(msg, position = 'bottom') {
const messagesArea = document.getElementById('messagesArea');
// Container wrapper for alignment
const container = document.createElement('div');
container.className = `message-container ${msg.is_human ? 'user' : 'bot'}`;
// Sender Name Label
const sender = document.createElement('div');
sender.className = 'sender-name';
sender.innerText = msg.is_human ? 'You' : 'Canifa AI';
container.appendChild(sender);
// Message Bubble
const div = document.createElement('div');
div.className = `message ${msg.is_human ? 'user' : 'bot'}`;
// Generate unique message ID for toggle
const messageId = 'hist-' + (msg.id || Date.now() + Math.random());
if (msg.is_human) {
// User message: simple text
div.innerText = msg.message;
} else {
// Bot message: add Widget/Raw JSON toggle
// FILTERED CONTENT (default visible)
const filteredDiv = document.createElement('div');
filteredDiv.id = 'filtered-' + messageId;
filteredDiv.className = 'filtered-content';
filteredDiv.innerText = msg.message;
div.appendChild(filteredDiv);
// RAW CONTENT (hidden by default)
const rawDiv = document.createElement('div');
rawDiv.id = 'raw-' + messageId;
rawDiv.className = 'raw-content';
rawDiv.style.display = 'none';
const rawJsonDiv = document.createElement('div');
rawJsonDiv.className = 'raw-json-view';
const pre = document.createElement('pre');
pre.textContent = JSON.stringify({
id: msg.id,
message: msg.message,
product_ids: msg.product_ids || [],
timestamp: msg.timestamp,
is_human: msg.is_human
}, null, 2);
rawJsonDiv.appendChild(pre);
rawDiv.appendChild(rawJsonDiv);
div.appendChild(rawDiv);
// Toggle Buttons
const toggleDiv = document.createElement('div');
toggleDiv.className = 'message-view-toggle';
const filteredBtn = document.createElement('button');
filteredBtn.id = 'filtered-btn-' + messageId;
filteredBtn.className = 'active';
filteredBtn.innerText = '🎨 Widget';
filteredBtn.onclick = () => toggleMessageView(messageId);
const rawBtn = document.createElement('button');
rawBtn.id = 'raw-btn-' + messageId;
rawBtn.innerText = '👁️ Raw JSON';
rawBtn.onclick = () => toggleMessageView(messageId);
toggleDiv.appendChild(filteredBtn);
toggleDiv.appendChild(rawBtn);
div.appendChild(toggleDiv);
}
// Timestamp inside bubble
const time = document.createElement('span');
time.className = 'timestamp';
time.innerText = new Date(msg.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
div.appendChild(time);
container.appendChild(div);
if (position === 'top') {
messagesArea.insertBefore(container, messagesArea.firstChild);
} else {
messagesArea.appendChild(container);
}
}
async function sendMessage() {
const input = document.getElementById('userInput');
const deviceIdInput = document.getElementById('deviceId');
const accessTokenInput = document.getElementById('accessToken');
const deviceId = deviceIdInput.value.trim();
const accessToken = accessTokenInput.value.trim();
const text = input.value.trim();
const sendBtn = document.getElementById('sendBtn');
const typingIndicator = document.getElementById('typingIndicator');
const chatBox = document.getElementById('chatBox');
if (!deviceId) {
alert('Please enter a Device ID first!');
deviceIdInput.focus();
return;
}
if (!text) return;
// Disable input
input.disabled = true;
sendBtn.disabled = true;
typingIndicator.style.display = 'block';
// Add user message immediately
appendMessage({
message: text,
is_human: true,
timestamp: new Date().toISOString(),
id: 'pending'
});
input.value = '';
chatBox.scrollTop = chatBox.scrollHeight;
// Save config to localStorage
saveConfig();
// Track response time
const startTime = Date.now();
try {
// Build headers
const headers = {
'Content-Type': 'application/json',
'device_id': deviceId
};
// Add Authorization if access token provided
if (accessToken) {
headers['Authorization'] = 'Bearer ' + accessToken;
}
const response = await fetch('/api/agent/chat', {
method: 'POST',
headers: headers,
body: JSON.stringify({
user_query: text,
device_id: deviceId
})
});
// Handle API Errors (Rate Limit, System Error) with Widget/Raw View
if (response.status === 429 || response.status === 500) {
const errorData = await response.json();
const responseTime = ((Date.now() - startTime) / 1000).toFixed(2);
const messageId = 'msg-error-' + Date.now();
// Create bot message container
const messagesArea = document.getElementById('messagesArea');
const container = document.createElement('div');
container.className = 'message-container bot';
const sender = document.createElement('div');
sender.className = 'sender-name';
sender.innerText = 'Canifa AI';
container.appendChild(sender);
const botMsgDiv = document.createElement('div');
botMsgDiv.className = 'message bot';
// 1. FILTERED CONTENT (Widget View)
const filteredDiv = document.createElement('div');
filteredDiv.id = 'filtered-' + messageId;
filteredDiv.className = 'filtered-content';
filteredDiv.style.color = '#ff6b6b';
// Extract message
const errorMessage = errorData.message ||
errorData.detail?.message ||
'Có lỗi xảy ra!';
filteredDiv.innerHTML = `
<div style="font-weight: bold; margin-bottom: 8px;">⚠️ ${errorData.error_code || 'ERROR'}</div>
<div>${errorMessage}</div>
${errorData.require_login ? '<div style="margin-top: 10px; padding: 8px; background: #3d2d2d; border-radius: 6px;">👉 Đăng nhập ngay để tiếp tục!</div>' : ''}
`;
botMsgDiv.appendChild(filteredDiv);
// 2. RAW CONTENT (JSON View)
const rawDiv = document.createElement('div');
rawDiv.id = 'raw-' + messageId;
rawDiv.className = 'raw-content';
rawDiv.style.display = 'none';
const rawJsonDiv = document.createElement('div');
rawJsonDiv.className = 'raw-json-view';
const pre = document.createElement('pre');
pre.textContent = JSON.stringify(errorData, null, 2);
rawJsonDiv.appendChild(pre);
rawDiv.appendChild(rawJsonDiv);
botMsgDiv.appendChild(rawDiv);
// 3. Toggle Buttons
const toggleDiv = document.createElement('div');
toggleDiv.className = 'message-view-toggle';
const filteredBtn = document.createElement('button');
filteredBtn.id = 'filtered-btn-' + messageId;
filteredBtn.className = 'active';
filteredBtn.innerText = '🎨 Widget';
filteredBtn.onclick = () => toggleMessageView(messageId);
const rawBtn = document.createElement('button');
rawBtn.id = 'raw-btn-' + messageId;
rawBtn.innerText = '👁️ Raw JSON';
rawBtn.onclick = () => toggleMessageView(messageId);
toggleDiv.appendChild(filteredBtn);
toggleDiv.appendChild(rawBtn);
botMsgDiv.appendChild(toggleDiv);
// Response time
const timeDiv = document.createElement('div');
timeDiv.className = 'response-time';
timeDiv.innerText = `⏱️ ${responseTime}s`;
botMsgDiv.appendChild(timeDiv);
container.appendChild(botMsgDiv);
messagesArea.appendChild(container);
chatBox.scrollTop = chatBox.scrollHeight;
input.disabled = false;
sendBtn.disabled = false;
typingIndicator.style.display = 'none';
return;
}
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.detail?.message || errorData.detail || 'Có lỗi xảy ra');
}
const data = await response.json();
const responseTime = ((Date.now() - startTime) / 1000).toFixed(2);
// Generate unique message ID
const messageId = 'msg-' + Date.now();
// Store message data
messageHistory.push({
type: 'bot',
data: data,
responseTime: responseTime,
timestamp: new Date().toISOString(),
id: messageId
});
// Create bot message placeholder
const messagesArea = document.getElementById('messagesArea');
const container = document.createElement('div');
container.className = 'message-container bot';
const sender = document.createElement('div');
sender.className = 'sender-name';
sender.innerText = 'Canifa AI';
container.appendChild(sender);
const botMsgDiv = document.createElement('div');
botMsgDiv.className = 'message bot';
if (data.status === 'success') {
// FILTERED CONTENT (default visible)
const filteredDiv = document.createElement('div');
filteredDiv.id = 'filtered-' + messageId;
filteredDiv.className = 'filtered-content';
// Display AI text response
const textDiv = document.createElement('div');
textDiv.innerText = data.ai_response || 'No response';
filteredDiv.appendChild(textDiv);
// Render product cards if available
if (data.product_ids && data.product_ids.length > 0) {
const productsContainer = document.createElement('div');
productsContainer.className = 'product-cards-container';
data.product_ids.forEach(product => {
const card = document.createElement('div');
card.className = 'product-card';
// Product image
const img = document.createElement('img');
img.src = product.thumbnail_image_url || 'https://via.placeholder.com/200';
img.alt = product.name;
img.onerror = function () { this.src = 'https://via.placeholder.com/200?text=No+Image'; };
card.appendChild(img);
// Product body
const body = document.createElement('div');
body.className = 'product-card-body';
// SKU
const sku = document.createElement('div');
sku.className = 'product-sku';
sku.innerText = product.sku;
body.appendChild(sku);
// Name
const name = document.createElement('div');
name.className = 'product-name';
name.innerText = product.name;
body.appendChild(name);
// Price
const priceDiv = document.createElement('div');
priceDiv.className = 'product-price';
if (product.sale_price && product.sale_price < product.price) {
// Show original price with strikethrough
const originalPrice = document.createElement('span');
originalPrice.className = 'price-original';
originalPrice.innerText = product.price.toLocaleString('vi-VN') + 'đ';
priceDiv.appendChild(originalPrice);
// Show sale price
const salePrice = document.createElement('span');
salePrice.className = 'price-sale';
salePrice.innerText = product.sale_price.toLocaleString('vi-VN') + 'đ';
priceDiv.appendChild(salePrice);
} else {
// Show regular price
const regularPrice = document.createElement('span');
regularPrice.className = 'price-regular';
regularPrice.innerText = product.price.toLocaleString('vi-VN') + 'đ';
priceDiv.appendChild(regularPrice);
}
body.appendChild(priceDiv);
// Link button
const link = document.createElement('a');
link.className = 'product-link';
link.href = product.url;
link.target = '_blank';
link.innerText = '🛍️ Xem chi tiết';
body.appendChild(link);
card.appendChild(body);
productsContainer.appendChild(card);
});
filteredDiv.appendChild(productsContainer);
}
botMsgDiv.appendChild(filteredDiv);
// RAW CONTENT (hidden by default)
const rawDiv = document.createElement('div');
rawDiv.id = 'raw-' + messageId;
rawDiv.className = 'raw-content';
rawDiv.style.display = 'none';
const rawJsonDiv = document.createElement('div');
rawJsonDiv.className = 'raw-json-view';
const pre = document.createElement('pre');
pre.textContent = JSON.stringify({
status: data.status,
ai_response: data.ai_response,
product_ids: data.product_ids,
limit_info: data.limit_info || null
}, null, 2);
rawJsonDiv.appendChild(pre);
rawDiv.appendChild(rawJsonDiv);
botMsgDiv.appendChild(rawDiv);
// Add toggle button
const toggleDiv = document.createElement('div');
toggleDiv.className = 'message-view-toggle';
const filteredBtn = document.createElement('button');
filteredBtn.id = 'filtered-btn-' + messageId;
filteredBtn.className = 'active';
filteredBtn.innerText = '🎨 Widget';
filteredBtn.onclick = () => toggleMessageView(messageId);
const rawBtn = document.createElement('button');
rawBtn.id = 'raw-btn-' + messageId;
rawBtn.innerText = '👁️ Raw JSON';
rawBtn.onclick = () => toggleMessageView(messageId);
toggleDiv.appendChild(filteredBtn);
toggleDiv.appendChild(rawBtn);
botMsgDiv.appendChild(toggleDiv);
// Add response time
const timeDiv = document.createElement('div');
timeDiv.className = 'response-time';
timeDiv.innerText = `⏱️ ${responseTime}s`;
botMsgDiv.appendChild(timeDiv);
} else {
// ERROR CASE: Limit exceeded or other errors
// FILTERED CONTENT (error message - default visible)
const filteredDiv = document.createElement('div');
filteredDiv.id = 'filtered-' + messageId;
filteredDiv.className = 'filtered-content';
filteredDiv.style.color = '#ff6b6b';
filteredDiv.innerHTML = `
<div style="font-weight: bold; margin-bottom: 8px;">⚠️ ${data.error_code || 'ERROR'}</div>
<div>${data.message || 'Unknown error'}</div>
${data.require_login ? '<div style="margin-top: 10px; padding: 8px; background: #3d2d2d; border-radius: 6px;">👉 Vui lòng đăng nhập để tiếp tục sử dụng!</div>' : ''}
`;
botMsgDiv.appendChild(filteredDiv);
// RAW CONTENT (hidden by default)
const rawDiv = document.createElement('div');
rawDiv.id = 'raw-' + messageId;
rawDiv.className = 'raw-content';
rawDiv.style.display = 'none';
const rawJsonDiv = document.createElement('div');
rawJsonDiv.className = 'raw-json-view';
const pre = document.createElement('pre');
pre.textContent = JSON.stringify({
status: data.status,
error_code: data.error_code,
message: data.message,
require_login: data.require_login,
limit_info: data.limit_info || null
}, null, 2);
rawJsonDiv.appendChild(pre);
rawDiv.appendChild(rawJsonDiv);
botMsgDiv.appendChild(rawDiv);
// Toggle Buttons
const toggleDiv = document.createElement('div');
toggleDiv.className = 'message-view-toggle';
const filteredBtn = document.createElement('button');
filteredBtn.id = 'filtered-btn-' + messageId;
filteredBtn.className = 'active';
filteredBtn.innerText = '🎨 Widget';
filteredBtn.onclick = () => toggleMessageView(messageId);
const rawBtn = document.createElement('button');
rawBtn.id = 'raw-btn-' + messageId;
rawBtn.innerText = '👁️ Raw JSON';
rawBtn.onclick = () => toggleMessageView(messageId);
toggleDiv.appendChild(filteredBtn);
toggleDiv.appendChild(rawBtn);
botMsgDiv.appendChild(toggleDiv);
}
container.appendChild(botMsgDiv);
messagesArea.appendChild(container);
chatBox.scrollTop = chatBox.scrollHeight;
} catch (error) {
console.error('Error sending message:', error);
appendMessage({
message: `Error: ${error.message}`,
is_human: false,
timestamp: new Date().toISOString(),
id: 'error'
});
} finally {
input.disabled = false;
sendBtn.disabled = false;
typingIndicator.style.display = 'none';
input.focus();
chatBox.scrollTop = chatBox.scrollHeight;
}
}
function handleKeyPress(event) {
if (event.key === 'Enter') {
sendMessage();
}
}
function clearUI() {
document.getElementById('messagesArea').innerHTML = '';
}
// Apply token from login prompt in rate limit error
function applyLoginToken() {
const tokenInput = document.getElementById('loginTokenInput');
if (tokenInput && tokenInput.value.trim()) {
document.getElementById('accessToken').value = tokenInput.value.trim();
saveConfig();
alert('✅ Token đã được lưu! Bạn có thể tiếp tục chat.');
} else {
alert('Vui lòng nhập Access Token!');
}
}
// Save config to localStorage (called on input change/blur)
function saveConfig() {
const deviceId = document.getElementById('deviceId').value.trim();
const accessToken = document.getElementById('accessToken').value.trim();
if (deviceId) {
localStorage.setItem('canifa_device_id', deviceId);
}
if (accessToken) {
localStorage.setItem('canifa_access_token', accessToken);
} else {
localStorage.removeItem('canifa_access_token');
}
}
// Generate UUID for device_id
function generateUUID() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
const r = Math.random() * 16 | 0;
const v = c === 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
}
// Load config from localStorage on page load
window.onload = function () {
// Load or generate Device ID
let savedDeviceId = localStorage.getItem('canifa_device_id');
if (!savedDeviceId) {
savedDeviceId = 'device-' + generateUUID().substring(0, 8);
localStorage.setItem('canifa_device_id', savedDeviceId);
}
document.getElementById('deviceId').value = savedDeviceId;
// Load Access Token (optional)
const savedAccessToken = localStorage.getItem('canifa_access_token');
if (savedAccessToken) {
document.getElementById('accessToken').value = savedAccessToken;
}
// Auto-load history
setTimeout(() => loadHistory(true), 50);
};
</script>
</div> <!-- Close main-content -->
</body>
</html> -->
\ No newline at end of file
...@@ -88,10 +88,17 @@ ...@@ -88,10 +88,17 @@
gap: 10px; gap: 10px;
} }
/* ── Top Bar: Config + Upload side by side ── */
.top-bar {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
}
/* ── Config Section ── */ /* ── Config Section ── */
.config-grid { .config-grid {
display: grid; display: grid;
grid-template-columns: 1fr 120px 120px; grid-template-columns: 1fr 100px 100px;
gap: 14px; gap: 14px;
align-items: end; align-items: end;
} }
...@@ -132,11 +139,16 @@ ...@@ -132,11 +139,16 @@
.upload-area { .upload-area {
border: 2px dashed #2a2a3a; border: 2px dashed #2a2a3a;
border-radius: 14px; border-radius: 14px;
padding: 40px; padding: 20px;
text-align: center; text-align: center;
cursor: pointer; cursor: pointer;
transition: all 0.3s; transition: all 0.3s;
background: #12121a; background: #12121a;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100px;
} }
.upload-area:hover, .upload-area:hover,
...@@ -146,20 +158,20 @@ ...@@ -146,20 +158,20 @@
} }
.upload-area .icon { .upload-area .icon {
font-size: 2.5em; font-size: 2em;
margin-bottom: 12px; margin-bottom: 8px;
} }
.upload-area p { .upload-area p {
color: #8888aa; color: #8888aa;
font-size: 0.9em; font-size: 0.85em;
} }
.upload-area .filename { .upload-area .filename {
color: #667eea; color: #667eea;
font-weight: 600; font-weight: 600;
margin-top: 8px; margin-top: 6px;
font-size: 1em; font-size: 0.95em;
} }
/* ── Buttons ── */ /* ── Buttons ── */
...@@ -213,106 +225,26 @@ ...@@ -213,106 +225,26 @@
color: #aaa; color: #aaa;
} }
/* ── Preview ── */ /* ── Data Table ── */
.preview-container { .data-table-card {
max-height: 300px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 8px;
}
.conv-preview {
background: #12121a;
border: 1px solid #2a2a3a;
border-radius: 10px;
padding: 12px 16px;
display: flex;
justify-content: space-between;
align-items: center;
transition: background 0.2s;
}
.conv-preview:hover {
background: #1e1e2e;
}
.conv-id-badge {
background: linear-gradient(135deg, #667eea, #764ba2);
color: white;
font-size: 0.75em;
font-weight: 700;
padding: 4px 10px;
border-radius: 6px;
min-width: 36px;
text-align: center;
}
.conv-messages {
flex: 1;
margin-left: 14px;
font-size: 0.88em;
color: #b0b0cc;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.conv-count {
font-size: 0.75em;
color: #667eea;
font-weight: 600;
background: rgba(102, 126, 234, 0.1);
padding: 4px 10px;
border-radius: 6px;
}
/* ── Progress ── */
.progress-section {
display: none;
}
.progress-bar-wrapper {
background: #12121a;
border-radius: 10px;
height: 8px;
overflow: hidden;
margin: 10px 0;
}
.progress-bar {
height: 100%;
width: 0%;
background: linear-gradient(90deg, #667eea, #764ba2);
border-radius: 10px;
transition: width 0.3s ease;
}
.progress-text {
font-size: 0.85em;
color: #8888aa;
display: flex;
justify-content: space-between;
}
/* ── Results Table ── */
.results-section {
display: none; display: none;
} }
.results-table-wrapper { .table-wrapper {
overflow-x: auto; overflow-x: auto;
border-radius: 12px; border-radius: 12px;
border: 1px solid #2a2a3a; border: 1px solid #2a2a3a;
max-height: 70vh;
overflow-y: auto;
} }
.results-table { .data-table {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
font-size: 0.85em; font-size: 0.85em;
} }
.results-table th { .data-table th {
background: #1e1e2e; background: #1e1e2e;
color: #8888aa; color: #8888aa;
font-weight: 600; font-weight: 600;
...@@ -320,31 +252,81 @@ ...@@ -320,31 +252,81 @@
text-align: left; text-align: left;
position: sticky; position: sticky;
top: 0; top: 0;
z-index: 10;
border-bottom: 2px solid #2a2a3a; border-bottom: 2px solid #2a2a3a;
font-size: 0.8em; font-size: 0.8em;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.5px; letter-spacing: 0.5px;
white-space: nowrap;
} }
.results-table td { .data-table th.col-conv {
padding: 12px 14px; min-width: 80px;
background: #22223a;
}
.data-table th.col-msg {
min-width: 250px;
background: #22223a;
}
.data-table th.col-response {
min-width: 300px;
background: linear-gradient(135deg, rgba(102, 126, 234, 0.15), rgba(118, 75, 162, 0.15));
}
.data-table td {
padding: 10px 14px;
border-bottom: 1px solid #1e1e2e; border-bottom: 1px solid #1e1e2e;
vertical-align: top; vertical-align: top;
max-width: 400px;
} }
.results-table tr:hover td { .data-table tr:hover td {
background: rgba(102, 126, 234, 0.03); background: rgba(102, 126, 234, 0.03);
} }
/* Conversation group separator */
.data-table tr.conv-first td {
border-top: 2px solid #2a2a3a;
}
.conv-id-badge {
background: linear-gradient(135deg, #667eea, #764ba2);
color: white;
font-size: 0.75em;
font-weight: 700;
padding: 4px 10px;
border-radius: 6px;
min-width: 36px;
text-align: center;
display: inline-block;
}
.msg-text {
color: #ccc;
font-size: 0.9em;
line-height: 1.5;
}
.msg-index {
color: #5a5a7a;
font-size: 0.75em;
margin-right: 6px;
}
.response-cell { .response-cell {
max-height: 120px; max-height: 150px;
overflow-y: auto; overflow-y: auto;
font-size: 0.9em; font-size: 0.9em;
line-height: 1.5; line-height: 1.5;
color: #ccc; color: #ccc;
} }
.response-cell.waiting {
color: #5a5a7a;
font-style: italic;
}
.time-badge { .time-badge {
display: inline-block; display: inline-block;
background: rgba(102, 126, 234, 0.1); background: rgba(102, 126, 234, 0.1);
...@@ -360,27 +342,32 @@ ...@@ -360,27 +342,32 @@
color: #ff6b6b; color: #ff6b6b;
} }
.status-badge { /* ── Progress ── */
display: inline-block; .progress-section {
padding: 3px 10px; display: none;
border-radius: 6px;
font-size: 0.75em;
font-weight: 600;
} }
.status-badge.running { .progress-bar-wrapper {
background: rgba(255, 193, 7, 0.15); background: #12121a;
color: #ffc107; border-radius: 10px;
height: 8px;
overflow: hidden;
margin: 10px 0;
} }
.status-badge.done { .progress-bar {
background: rgba(76, 175, 80, 0.15); height: 100%;
color: #4caf50; width: 0%;
background: linear-gradient(90deg, #667eea, #764ba2);
border-radius: 10px;
transition: width 0.3s ease;
} }
.status-badge.error { .progress-text {
background: rgba(244, 67, 54, 0.15); font-size: 0.85em;
color: #f44336; color: #8888aa;
display: flex;
justify-content: space-between;
} }
/* ── Log ── */ /* ── Log ── */
...@@ -389,12 +376,13 @@ ...@@ -389,12 +376,13 @@
border: 1px solid #1a1a2a; border: 1px solid #1a1a2a;
border-radius: 10px; border-radius: 10px;
padding: 14px; padding: 14px;
max-height: 200px; max-height: 160px;
overflow-y: auto; overflow-y: auto;
font-family: 'Consolas', 'Monaco', monospace; font-family: 'Consolas', 'Monaco', monospace;
font-size: 0.8em; font-size: 0.8em;
line-height: 1.6; line-height: 1.6;
color: #6a6a8a; color: #6a6a8a;
margin-top: 12px;
} }
.log-entry { .log-entry {
...@@ -419,39 +407,6 @@ ...@@ -419,39 +407,6 @@
gap: 12px; gap: 12px;
align-items: center; align-items: center;
flex-wrap: wrap; flex-wrap: wrap;
margin-top: 16px;
}
/* ── Scrollbar ── */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #2a2a3a;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #3a3a4a;
}
/* ── Empty State ── */
.empty-state {
text-align: center;
padding: 40px 20px;
color: #5a5a7a;
}
.empty-state .icon {
font-size: 3em;
margin-bottom: 12px;
opacity: 0.5;
} }
/* ── Stats row ── */ /* ── Stats row ── */
...@@ -459,6 +414,7 @@ ...@@ -459,6 +414,7 @@
display: flex; display: flex;
gap: 16px; gap: 16px;
flex-wrap: wrap; flex-wrap: wrap;
margin-bottom: 16px;
} }
.stat-box { .stat-box {
...@@ -467,7 +423,7 @@ ...@@ -467,7 +423,7 @@
border-radius: 10px; border-radius: 10px;
padding: 14px 20px; padding: 14px 20px;
flex: 1; flex: 1;
min-width: 140px; min-width: 120px;
} }
.stat-box .label { .stat-box .label {
...@@ -496,7 +452,198 @@ ...@@ -496,7 +452,198 @@
color: #ffc107; color: #ffc107;
} }
@media (max-width: 768px) { /* ── Tab Toggle ── */
.tab-toggle {
display: flex;
gap: 0;
margin-bottom: 16px;
border-radius: 10px;
overflow: hidden;
border: 1px solid #2a2a3a;
}
.tab-toggle button {
flex: 1;
padding: 10px 16px;
border: none;
background: #12121a;
color: #6a6a8a;
font-weight: 600;
font-size: 0.85em;
cursor: pointer;
transition: all 0.2s;
}
.tab-toggle button.active {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.tab-toggle button:hover:not(.active) {
background: #1e1e2e;
color: #bbb;
}
.tab-content {
display: none;
}
.tab-content.active {
display: block;
}
/* ── Inline Editor ── */
.editor-table {
width: 100%;
border-collapse: collapse;
font-size: 0.85em;
}
.editor-table th {
background: #1e1e2e;
color: #8888aa;
font-weight: 600;
padding: 10px 12px;
text-align: left;
font-size: 0.8em;
text-transform: uppercase;
letter-spacing: 0.5px;
border-bottom: 2px solid #2a2a3a;
}
.editor-table td {
padding: 4px;
border-bottom: 1px solid #1e1e2e;
}
.editor-table input[type="text"],
.editor-table input[type="number"] {
width: 100%;
padding: 8px 10px;
border: 1px solid #2a2a3a;
border-radius: 6px;
background: #0f0f14;
color: #e0e0e0;
font-size: 0.95em;
transition: border-color 0.2s;
}
.editor-table input:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.15);
}
.editor-table .conv-input {
width: 80px;
text-align: center;
}
.editor-table .btn-icon {
width: 30px;
height: 30px;
border: none;
border-radius: 6px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 1em;
transition: all 0.2s;
}
.btn-icon.delete {
background: rgba(244, 67, 54, 0.1);
color: #f44336;
}
.btn-icon.delete:hover {
background: rgba(244, 67, 54, 0.25);
}
.editor-actions {
display: flex;
gap: 10px;
margin-top: 12px;
}
.btn-sm {
padding: 7px 14px;
border: none;
border-radius: 8px;
font-weight: 600;
font-size: 0.8em;
cursor: pointer;
transition: all 0.2s;
display: inline-flex;
align-items: center;
gap: 6px;
}
.btn-sm:hover {
transform: translateY(-1px);
}
.btn-add {
background: rgba(102, 126, 234, 0.15);
color: #667eea;
}
.btn-add:hover {
background: rgba(102, 126, 234, 0.25);
}
.btn-load {
background: rgba(76, 175, 80, 0.15);
color: #4caf50;
}
.btn-load:hover {
background: rgba(76, 175, 80, 0.25);
}
.editor-hint {
font-size: 0.75em;
color: #5a5a7a;
margin-top: 8px;
}
.saved-indicator {
font-size: 0.75em;
color: #4caf50;
opacity: 0;
transition: opacity 0.3s;
margin-left: 8px;
}
.saved-indicator.show {
opacity: 1;
}
/* ── Scrollbar ── */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #2a2a3a;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #3a3a4a;
}
@media (max-width: 900px) {
.top-bar {
grid-template-columns: 1fr;
}
.config-grid { .config-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
...@@ -513,88 +660,114 @@ ...@@ -513,88 +660,114 @@
<div class="nav-header"> <div class="nav-header">
<h1>🤖 Canifa AI System</h1> <h1>🤖 Canifa AI System</h1>
<div class="nav-links"> <div class="nav-links">
<a href="/static/index.html">💬 Chatbot</a>
<a href="/static/history.html">🧾 History</a>
<a href="/static/test_conversation.html" class="active">🧪 Test Tool</a> <a href="/static/test_conversation.html" class="active">🧪 Test Tool</a>
</div> </div>
</div> </div>
<div class="main-content"> <div class="main-content">
<!-- Config Card --> <!-- Top Bar: Config + Upload side by side -->
<div class="card"> <div class="top-bar">
<h2>⚙️ Test Configuration</h2> <!-- Config Card -->
<div class="config-grid"> <div class="card">
<div class="form-group"> <h2>⚙️ Configuration</h2>
<label>Agent Chat Endpoint URL</label> <div class="config-grid">
<input type="text" id="endpointUrl" value="http://localhost:5004/api/agent/chat-dev" <div class="form-group">
placeholder="http://localhost:5004/api/agent/chat-dev"> <label>Endpoint URL</label>
<input type="text" id="endpointUrl" value="http://172.16.2.207:5000/api/agent/chat-dev"
placeholder="http://172.16.2.207:5000/api/agent/chat-dev">
</div>
<div class="form-group">
<label>Versions</label>
<input type="number" id="numVersions" value="3" min="1" max="20">
</div>
<div class="form-group">
<label>Delay</label>
<input type="number" id="delayMs" value="1000" min="0" max="30000" step="500">
</div>
</div> </div>
<div class="form-group"> </div>
<label>Versions</label>
<input type="number" id="numVersions" value="3" min="1" max="20"> <!-- Data Input Card (Upload / Manual) -->
<div class="card">
<h2>📁 Test Data <span class="saved-indicator" id="savedIndicator">✓ Saved</span></h2>
<div class="tab-toggle">
<button class="active" onclick="switchTab('upload', this)">📁 Upload File</button>
<button onclick="switchTab('manual', this)">✏️ Manual Input</button>
</div> </div>
<div class="form-group">
<label>Delay (ms)</label> <!-- Tab: Upload File -->
<input type="number" id="delayMs" value="1000" min="0" max="30000" step="500"> <div class="tab-content active" id="tab-upload">
<input type="file" id="fileInput" accept=".xlsx,.csv" style="display:none"
onchange="handleFileSelect(event)">
<label for="fileInput" class="upload-area" id="uploadArea"
ondragover="event.preventDefault(); this.classList.add('dragover')"
ondragleave="this.classList.remove('dragover')" ondrop="handleDrop(event)">
<div class="icon">📄</div>
<p>Click or drag &amp; drop <strong>.xlsx</strong> / <strong>.csv</strong></p>
<p style="font-size:0.75em; margin-top:4px; color:#5a5a7a">
Columns: <code>message_content</code> + <code>conversation_id_test</code>
</p>
<div class="filename" id="uploadedFilename"></div>
</label>
</div>
<!-- Tab: Manual Input -->
<div class="tab-content" id="tab-manual">
<div style="border-radius: 10px; border: 1px solid #2a2a3a; overflow: hidden;">
<table class="editor-table" id="editorTable">
<thead>
<tr>
<th style="width:100px;">Conv ID</th>
<th>Message</th>
<th style="width:44px;"></th>
</tr>
</thead>
<tbody id="editorBody"></tbody>
</table>
</div>
<div class="editor-actions">
<button class="btn-sm btn-add" onclick="addEditorRow()">+ Add Row</button>
<button class="btn-sm btn-load" onclick="loadEditorData()">▶ Load Data</button>
</div>
<div class="editor-hint">💡 Same conv ID = same conversation. Auto-saved to localStorage.</div>
</div> </div>
</div> </div>
</div> </div>
<!-- Upload Card --> <!-- Data Table Card (shown after upload, updated with results) -->
<div class="card"> <div class="card data-table-card" id="dataTableCard">
<h2>📁 Upload Test Data</h2> <div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:16px">
<div class="upload-area" id="uploadArea" onclick="document.getElementById('fileInput').click()" <h2 style="margin-bottom:0">📊 Test Data & Results</h2>
ondragover="event.preventDefault(); this.classList.add('dragover')" <div class="actions-bar" style="margin-top:0">
ondragleave="this.classList.remove('dragover')" ondrop="handleDrop(event)"> <button class="btn btn-primary" id="runAllBtn" onclick="runTest()">▶ Run Test</button>
<div class="icon">📄</div> <button class="btn btn-danger" id="stopBtn" onclick="stopTest()" style="display:none">
<p>Click or drag & drop an <strong>.xlsx</strong> or <strong>.csv</strong> file</p> Stop</button>
<p style="font-size:0.8em; margin-top:6px; color:#5a5a7a"> <button class="btn btn-success" id="exportBtn" onclick="exportCSV()" style="display:none">📥 Export
Format: <code>message_content</code> + <code>conversation_id_test</code> CSV</button>
</p> <button class="btn btn-outline" onclick="clearAll()">✕ Clear</button>
<div class="filename" id="uploadedFilename"></div> </div>
</div> </div>
<input type="file" id="fileInput" accept=".xlsx,.csv" style="display:none"
onchange="handleFileSelect(event)">
</div>
<!-- Preview Card --> <!-- Stats -->
<div class="card" id="previewCard" style="display:none">
<h2>👁️ Conversation Preview</h2>
<div class="stats-row" id="statsRow"></div> <div class="stats-row" id="statsRow"></div>
<div class="preview-container" id="previewContainer" style="margin-top:16px"></div>
<div class="actions-bar">
<button class="btn btn-primary" id="runAllBtn" onclick="runTest()">
▶ Run All Conversations
</button>
<button class="btn btn-outline" onclick="clearAll()">✕ Clear</button>
</div>
</div>
<!-- Progress Card --> <!-- Progress (inline) -->
<div class="card progress-section" id="progressCard"> <div class="progress-section" id="progressCard">
<h2>🔄 Test Progress</h2> <div class="progress-bar-wrapper">
<div class="progress-bar-wrapper"> <div class="progress-bar" id="progressBar"></div>
<div class="progress-bar" id="progressBar"></div> </div>
</div> <div class="progress-text">
<div class="progress-text"> <span id="progressLabel">0 / 0 tasks</span>
<span id="progressLabel">0 / 0 tasks</span> <span id="progressPercent">0%</span>
<span id="progressPercent">0%</span> </div>
<div class="log-panel" id="logPanel"></div>
</div> </div>
<div class="log-panel" id="logPanel"></div>
</div>
<!-- Results Card --> <!-- Unified Table -->
<div class="card results-section" id="resultsCard"> <div class="table-wrapper">
<h2>📊 Test Results</h2> <table class="data-table" id="dataTable">
<div class="stats-row" id="resultStatsRow" style="margin-bottom:16px"></div> <thead id="dataTableHead"></thead>
<div class="actions-bar" style="margin-top:0; margin-bottom:16px"> <tbody id="dataTableBody"></tbody>
<button class="btn btn-success" onclick="exportCSV()">📥 Export CSV</button>
<button class="btn btn-secondary" onclick="toggleExpandAll()">📖 Expand/Collapse All</button>
</div>
<div class="results-table-wrapper" style="max-height:600px; overflow-y:auto">
<table class="results-table" id="resultsTable">
<thead id="resultsTableHead"></thead>
<tbody id="resultsTableBody"></tbody>
</table> </table>
</div> </div>
</div> </div>
...@@ -603,11 +776,118 @@ ...@@ -603,11 +776,118 @@
<script> <script>
// ─── State ─── // ─── State ───
let parsedConversations = {}; let parsedConversations = {};
let flatRows = []; // [{convId, msgIndex, message}, ...]
let testResults = {}; let testResults = {};
let totalTasks = 0; let totalTasks = 0;
let completedTasks = 0; let completedTasks = 0;
let allExpanded = false;
let currentTestId = ''; let currentTestId = '';
let isRunning = false;
let abortController = null;
const LS_KEY = 'canifa_test_editor_data';
// ─── Tab Switching ───
function switchTab(tabName, btn) {
document.querySelectorAll('.tab-content').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-toggle button').forEach(b => b.classList.remove('active'));
document.getElementById('tab-' + tabName).classList.add('active');
btn.classList.add('active');
}
// ─── Inline Editor ───
function addEditorRow(convId = null, message = '') {
const tbody = document.getElementById('editorBody');
// Smart Conv ID: inherit from last row if not explicitly provided
if (convId === null) {
const lastRow = tbody.querySelector('tr:last-child');
convId = lastRow ? (lastRow.querySelector('.conv-input').value || '1') : '1';
}
const tr = document.createElement('tr');
tr.innerHTML = `
<td><input type="number" class="conv-input" value="${convId}" min="1" placeholder="1" oninput="saveEditorToLS()"></td>
<td><input type="text" value="${escapeAttr(message)}" placeholder="Nhập nội dung tin nhắn..." oninput="saveEditorToLS()"></td>
<td><button class="btn-icon delete" onclick="this.closest('tr').remove(); saveEditorToLS();">✕</button></td>
`;
tbody.appendChild(tr);
// Auto-focus the message input of the new row
tr.querySelector('input[type="text"]').focus();
}
function getEditorRows() {
const rows = [];
document.querySelectorAll('#editorBody tr').forEach(tr => {
const inputs = tr.querySelectorAll('input');
const convId = (inputs[0].value || '').trim();
const message = (inputs[1].value || '').trim();
if (convId && message) rows.push({ convId, message });
});
return rows;
}
function saveEditorToLS() {
const rows = [];
document.querySelectorAll('#editorBody tr').forEach(tr => {
const inputs = tr.querySelectorAll('input');
rows.push({ convId: inputs[0].value || '', message: inputs[1].value || '' });
});
localStorage.setItem(LS_KEY, JSON.stringify(rows));
showSaved();
}
function loadEditorFromLS() {
try {
const data = JSON.parse(localStorage.getItem(LS_KEY));
if (data && data.length > 0) {
const tbody = document.getElementById('editorBody');
tbody.innerHTML = '';
data.forEach(r => addEditorRow(r.convId, r.message));
return true;
}
} catch (e) { /* ignore */ }
return false;
}
function loadEditorData() {
const rows = getEditorRows();
if (rows.length === 0) { alert('Please add at least one row with conversation ID and message.'); return; }
// Group by convId
const conversations = {};
let totalMsgs = 0;
for (const r of rows) {
if (!conversations[r.convId]) conversations[r.convId] = [];
conversations[r.convId].push(r.message);
totalMsgs++;
}
parsedConversations = conversations;
buildTable({
conversations,
total_conversations: Object.keys(conversations).length,
total_messages: totalMsgs
});
}
function showSaved() {
const el = document.getElementById('savedIndicator');
el.classList.add('show');
clearTimeout(el._timer);
el._timer = setTimeout(() => el.classList.remove('show'), 1500);
}
function escapeAttr(str) {
return String(str).replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
// ─── Init: load from localStorage on page load ───
document.addEventListener('DOMContentLoaded', () => {
const restored = loadEditorFromLS();
if (!restored) {
// Add 3 default empty rows
addEditorRow('1', '');
addEditorRow('1', '');
addEditorRow('2', '');
}
});
// ─── File Upload ─── // ─── File Upload ───
function handleFileSelect(event) { function handleFileSelect(event) {
...@@ -623,8 +903,7 @@ ...@@ -623,8 +903,7 @@
} }
async function uploadFile(file) { async function uploadFile(file) {
const filename = file.name; document.getElementById('uploadedFilename').textContent = `📎 ${file.name}`;
document.getElementById('uploadedFilename').textContent = `📎 ${filename}`;
const formData = new FormData(); const formData = new FormData();
formData.append('file', file); formData.append('file', file);
...@@ -635,7 +914,7 @@ ...@@ -635,7 +914,7 @@
if (data.status === 'success') { if (data.status === 'success') {
parsedConversations = data.conversations; parsedConversations = data.conversations;
renderPreview(data); buildTable(data);
} else { } else {
alert(`Upload error: ${data.message}`); alert(`Upload error: ${data.message}`);
} }
...@@ -644,46 +923,90 @@ ...@@ -644,46 +923,90 @@
} }
} }
// ─── Preview ─── // ─── Build Table after Upload ───
function renderPreview(data) { function buildTable(data) {
const card = document.getElementById('previewCard'); const numVersions = parseInt(document.getElementById('numVersions').value) || 3;
const card = document.getElementById('dataTableCard');
card.style.display = 'block'; card.style.display = 'block';
// Stats // Stats
const numVersions = parseInt(document.getElementById('numVersions').value) || 3; const totalConvs = data.total_conversations;
const totalMsgs = data.total_messages;
document.getElementById('statsRow').innerHTML = ` document.getElementById('statsRow').innerHTML = `
<div class="stat-box"> <div class="stat-box">
<div class="label">Conversations</div> <div class="label">Conversations</div>
<div class="value purple">${data.total_conversations}</div> <div class="value purple">${totalConvs}</div>
</div> </div>
<div class="stat-box"> <div class="stat-box">
<div class="label">Total Messages</div> <div class="label">Messages</div>
<div class="value">${data.total_messages}</div> <div class="value">${totalMsgs}</div>
</div> </div>
<div class="stat-box"> <div class="stat-box">
<div class="label">Versions</div> <div class="label">Versions</div>
<div class="value amber">${numVersions}</div> <div class="value amber">${numVersions}</div>
</div> </div>
<div class="stat-box"> <div class="stat-box">
<div class="label">Total Test Tasks</div> <div class="label">Total Tasks</div>
<div class="value green">${data.total_conversations * numVersions}</div> <div class="value green">${totalConvs * numVersions}</div>
</div> </div>
`; `;
// Conversation list // Build header: Conv ID | Message | V1 Response | V2 Response ...
const container = document.getElementById('previewContainer'); const thead = document.getElementById('dataTableHead');
container.innerHTML = ''; let headerHtml = '<tr>';
headerHtml += '<th class="col-conv">Conv</th>';
for (const [convId, messages] of Object.entries(data.conversations)) { headerHtml += '<th class="col-msg">Message</th>';
const preview = messages.map(m => `"${m}"`).join(' → '); for (let v = 1; v <= numVersions; v++) {
container.innerHTML += ` headerHtml += `<th class="col-response">V${v} Response</th>`;
<div class="conv-preview">
<span class="conv-id-badge">#${convId}</span>
<span class="conv-messages" title="${preview}">${preview}</span>
<span class="conv-count">${messages.length} msg${messages.length > 1 ? 's' : ''}</span>
</div>
`;
} }
headerHtml += '</tr>';
thead.innerHTML = headerHtml;
// Build rows - one row per message
flatRows = [];
const tbody = document.getElementById('dataTableBody');
tbody.innerHTML = '';
const convIds = Object.keys(data.conversations).sort((a, b) => {
const na = parseInt(a), nb = parseInt(b);
return (isNaN(na) || isNaN(nb)) ? a.localeCompare(b) : na - nb;
});
for (const convId of convIds) {
const messages = data.conversations[convId];
messages.forEach((msg, msgIdx) => {
const rowId = `row-${convId}-${msgIdx}`;
flatRows.push({ convId, msgIndex: msgIdx, message: msg, rowId });
const isFirst = msgIdx === 0;
const tr = document.createElement('tr');
tr.id = rowId;
if (isFirst) tr.classList.add('conv-first');
let html = '';
// Conv ID column (show badge only on first message of conversation)
if (isFirst) {
html += `<td rowspan="${messages.length}"><span class="conv-id-badge">#${convId}</span></td>`;
}
// Message column
html += `<td><span class="msg-index">${msgIdx + 1}.</span><span class="msg-text">${escapeHtml(msg)}</span></td>`;
// Version response columns — each message has its own response cell
for (let v = 1; v <= numVersions; v++) {
html += `<td id="resp-${convId}-msg${msgIdx}-v${v}" class="response-cell waiting">—</td>`;
}
tr.innerHTML = html;
tbody.appendChild(tr);
});
}
// Reset results
testResults = {};
document.getElementById('exportBtn').style.display = 'none';
} }
// ─── Run Test ─── // ─── Run Test ───
...@@ -696,19 +1019,30 @@ ...@@ -696,19 +1019,30 @@
if (Object.keys(parsedConversations).length === 0) { alert('Please upload test data first'); return; } if (Object.keys(parsedConversations).length === 0) { alert('Please upload test data first'); return; }
// Reset // Reset
isRunning = true;
abortController = new AbortController();
testResults = {}; testResults = {};
completedTasks = 0; completedTasks = 0;
totalTasks = Object.keys(parsedConversations).length * numVersions; totalTasks = Object.keys(parsedConversations).length * numVersions;
// Reset response cells to waiting
for (const convId of Object.keys(parsedConversations)) {
const msgs = parsedConversations[convId];
for (let mi = 0; mi < msgs.length; mi++) {
for (let v = 1; v <= numVersions; v++) {
const respCell = document.getElementById(`resp-${convId}-msg${mi}-v${v}`);
if (respCell) { respCell.innerHTML = '<span style="color:#ffc107">⏳</span>'; respCell.classList.add('waiting'); }
}
}
}
// Show progress // Show progress
document.getElementById('progressCard').style.display = 'block'; document.getElementById('progressCard').style.display = 'block';
document.getElementById('resultsCard').style.display = 'block';
document.getElementById('runAllBtn').disabled = true; document.getElementById('runAllBtn').disabled = true;
document.getElementById('stopBtn').style.display = 'inline-flex';
document.getElementById('progressBar').style.width = '0%'; document.getElementById('progressBar').style.width = '0%';
document.getElementById('logPanel').innerHTML = ''; document.getElementById('logPanel').innerHTML = '';
document.getElementById('exportBtn').style.display = 'none';
// Build table header
buildResultsTableHeader(numVersions);
// SSE request // SSE request
try { try {
...@@ -720,7 +1054,8 @@ ...@@ -720,7 +1054,8 @@
conversations: parsedConversations, conversations: parsedConversations,
num_versions: numVersions, num_versions: numVersions,
delay_ms: delayMs, delay_ms: delayMs,
}) }),
signal: abortController.signal,
}); });
const reader = response.body.getReader(); const reader = response.body.getReader();
...@@ -748,10 +1083,25 @@ ...@@ -748,10 +1083,25 @@
} }
} }
} catch (err) { } catch (err) {
addLog(`Connection error: ${err.message}`, 'error'); if (err.name === 'AbortError') {
addLog(`⏹ Test stopped by user at ${completedTasks}/${totalTasks} tasks`, 'error');
} else {
addLog(`Connection error: ${err.message}`, 'error');
}
} }
isRunning = false;
abortController = null;
document.getElementById('runAllBtn').disabled = false; document.getElementById('runAllBtn').disabled = false;
document.getElementById('stopBtn').style.display = 'none';
document.getElementById('exportBtn').style.display = 'inline-flex';
}
function stopTest() {
if (abortController) {
abortController.abort();
addLog('🛑 Stopping test... waiting for current request to finish', 'error');
}
} }
function handleSSEEvent(type, data) { function handleSSEEvent(type, data) {
...@@ -759,108 +1109,68 @@ ...@@ -759,108 +1109,68 @@
case 'start': case 'start':
currentTestId = data.test_id; currentTestId = data.test_id;
totalTasks = data.total_tasks; totalTasks = data.total_tasks;
addLog(`🚀 Test started — ${data.total_conversations} conversations × ${data.num_versions} versions = ${data.total_tasks} tasks`, 'info'); addLog(`🚀 Test started — ${data.total_conversations} convs × ${data.num_versions} versions = ${data.total_tasks} tasks`, 'info');
break; break;
case 'progress': case 'progress':
completedTasks = data.completed; completedTasks = data.completed;
updateProgress(data.completed, totalTasks); updateProgress(data.completed, totalTasks);
testResults[data.result_key] = data; testResults[data.result_key] = data;
updateResultRow(data); updateResponseCell(data);
addLog(`✅ Conv #${data.conv_id} v${data.version}${data.total_time}s (${data.num_messages} msgs)`, 'success'); addLog(`✅ Conv #${data.conv_id} v${data.version}${data.total_time}s — 🔑 ${data.device_id}`, 'success');
break; break;
case 'error': case 'error':
completedTasks = data.completed; completedTasks = data.completed;
updateProgress(data.completed, totalTasks); updateProgress(data.completed, totalTasks);
updateResponseCellError(data);
addLog(`❌ Conv #${data.conv_id} v${data.version}${data.error}`, 'error'); addLog(`❌ Conv #${data.conv_id} v${data.version}${data.error}`, 'error');
break; break;
case 'complete': case 'complete':
addLog(`🏁 Test completed — ${data.total_completed} tasks finished`, 'info'); addLog(`🏁 Done — ${data.total_completed} tasks completed`, 'info');
updateResultStats(); updateResultStats();
break; break;
} }
} }
// ─── Progress ─── // ─── Update Response Cell in Table ───
function updateProgress(completed, total) { function updateResponseCell(data) {
const pct = total > 0 ? Math.round((completed / total) * 100) : 0; const convId = data.conv_id;
document.getElementById('progressBar').style.width = pct + '%'; const v = data.version;
document.getElementById('progressLabel').textContent = `${completed} / ${total} tasks`; const responses = data.responses || [];
document.getElementById('progressPercent').textContent = pct + '%';
} for (let i = 0; i < responses.length; i++) {
const cell = document.getElementById(`resp-${convId}-msg${i}-v${v}`);
// ─── Log ─── if (cell) {
function addLog(text, cls = '') { cell.classList.remove('waiting');
const panel = document.getElementById('logPanel'); const text = responses[i].ai_response || '';
panel.innerHTML += `<div class="log-entry ${cls}">${new Date().toLocaleTimeString()}${text}</div>`; const time = responses[i].response_time || 0;
panel.scrollTop = panel.scrollHeight; cell.innerHTML = `<div class="response-cell"><div>${escapeHtml(text)}</div><div style="margin-top:4px;font-size:11px;color:#888">${time}s</div></div>`;
} }
// ─── Results Table ───
function buildResultsTableHeader(numVersions) {
const thead = document.getElementById('resultsTableHead');
let html = '<tr><th>Conv ID</th><th>Messages</th>';
for (let v = 1; v <= numVersions; v++) {
html += `<th>V${v} Response</th><th>V${v} Time</th>`;
} }
html += '</tr>';
thead.innerHTML = html;
// Clear body
document.getElementById('resultsTableBody').innerHTML = '';
} }
function updateResultRow(data) { function updateResponseCellError(data) {
const tbody = document.getElementById('resultsTableBody');
const numVersions = parseInt(document.getElementById('numVersions').value) || 3;
const convId = data.conv_id; const convId = data.conv_id;
const v = data.version;
const numMsgs = data.num_messages || 1;
// Find or create row for (let i = 0; i < numMsgs; i++) {
let row = document.getElementById(`row-conv-${convId}`); const cell = document.getElementById(`resp-${convId}-msg${i}-v${v}`);
if (!row) { if (cell) {
row = document.createElement('tr'); cell.classList.remove('waiting');
row.id = `row-conv-${convId}`; cell.innerHTML = `<div class="response-cell" style="color:#f44336">❌ ${escapeHtml(data.error || 'Error')}</div>`;
// Conv ID cell
const msgs = parsedConversations[convId] || [];
const msgsPreview = msgs.map(m => `"${m}"`).join(' → ');
let html = `
<td><span class="conv-id-badge">#${convId}</span></td>
<td>
<div style="max-width:200px; font-size:0.85em; color:#8888aa; cursor:pointer"
title="${escapeHtml(msgsPreview)}"
onclick="this.style.whiteSpace = this.style.whiteSpace === 'normal' ? 'nowrap' : 'normal'">
${escapeHtml(msgs[0] || '')}${msgs.length > 1 ? ` <span style="color:#667eea">(+${msgs.length - 1})</span>` : ''}
</div>
</td>
`;
for (let v = 1; v <= numVersions; v++) {
html += `<td id="resp-${convId}-v${v}" class="response-cell">⏳</td>`;
html += `<td id="time-${convId}-v${v}">—</td>`;
} }
row.innerHTML = html;
tbody.appendChild(row);
} }
}
// Update the version cells // ─── Progress ───
const v = data.version; function updateProgress(completed, total) {
const respCell = document.getElementById(`resp-${convId}-v${v}`); const pct = total > 0 ? Math.round((completed / total) * 100) : 0;
const timeCell = document.getElementById(`time-${convId}-v${v}`); document.getElementById('progressBar').style.width = pct + '%';
document.getElementById('progressLabel').textContent = `${completed} / ${total} tasks`;
if (respCell) { document.getElementById('progressPercent').textContent = pct + '%';
const responseText = data.final_response || '';
respCell.innerHTML = `<div class="response-cell">${escapeHtml(responseText)}</div>`;
}
if (timeCell) {
const t = data.total_time;
const cls = t > 10 ? 'slow' : '';
timeCell.innerHTML = `<span class="time-badge ${cls}">${t}s</span>`;
}
} }
function updateResultStats() { function updateResultStats() {
...@@ -869,7 +1179,7 @@ ...@@ -869,7 +1179,7 @@
const avgTime = results.length > 0 ? (totalTime / results.length).toFixed(1) : 0; const avgTime = results.length > 0 ? (totalTime / results.length).toFixed(1) : 0;
const errors = results.filter(r => r.error).length; const errors = results.filter(r => r.error).length;
document.getElementById('resultStatsRow').innerHTML = ` document.getElementById('statsRow').innerHTML = `
<div class="stat-box"> <div class="stat-box">
<div class="label">Completed</div> <div class="label">Completed</div>
<div class="value green">${results.length}</div> <div class="value green">${results.length}</div>
...@@ -889,32 +1199,40 @@ ...@@ -889,32 +1199,40 @@
`; `;
} }
// ─── Log ───
function addLog(text, cls = '') {
const panel = document.getElementById('logPanel');
panel.innerHTML += `<div class="log-entry ${cls}">${new Date().toLocaleTimeString()}${text}</div>`;
panel.scrollTop = panel.scrollHeight;
}
// ─── Export CSV ─── // ─── Export CSV ───
function exportCSV() { function exportCSV() {
const numVersions = parseInt(document.getElementById('numVersions').value) || 3; const numVersions = parseInt(document.getElementById('numVersions').value) || 3;
const convIds = Object.keys(parsedConversations).sort((a, b) => parseInt(a) - parseInt(b)); const convIds = Object.keys(parsedConversations).sort((a, b) => parseInt(a) - parseInt(b));
let csv = 'conversation_id,messages'; let csv = 'conversation_id,msg_index,message';
for (let v = 1; v <= numVersions; v++) { for (let v = 1; v <= numVersions; v++) {
csv += `,v${v}_device_id,v${v}_response,v${v}_time_s`; csv += `,v${v}_response,v${v}_time_s`;
} }
csv += '\n'; csv += '\n';
for (const convId of convIds) { for (const convId of convIds) {
const msgs = (parsedConversations[convId] || []).join(' | '); const msgs = parsedConversations[convId] || [];
let row = `${convId},"${msgs.replace(/"/g, '""')}"`; msgs.forEach((msg, mi) => {
let row = `${convId},${mi + 1},"${msg.replace(/"/g, '""')}"`;
for (let v = 1; v <= numVersions; v++) { for (let v = 1; v <= numVersions; v++) {
const key = `${convId}_v${v}`; const key = `${convId}_v${v}`;
const r = testResults[key]; const r = testResults[key];
if (r) { if (r && r.responses && r.responses[mi]) {
const resp = (r.final_response || '').replace(/"/g, '""'); const resp = (r.responses[mi].ai_response || '').replace(/"/g, '""');
row += `,test-conv${convId}-v${v},"${resp}",${r.total_time || 0}`; row += `,"${resp}",${r.responses[mi].response_time || 0}`;
} else { } else {
row += `,,,`; row += `,,`;
}
} }
} csv += row + '\n';
csv += row + '\n'; });
} }
const BOM = '\uFEFF'; const BOM = '\uFEFF';
...@@ -925,23 +1243,29 @@ ...@@ -925,23 +1243,29 @@
link.click(); link.click();
} }
// ─── Toggle Expand ───
function toggleExpandAll() {
allExpanded = !allExpanded;
document.querySelectorAll('.response-cell').forEach(el => {
el.style.maxHeight = allExpanded ? 'none' : '120px';
});
}
// ─── Clear ─── // ─── Clear ───
function clearAll() { function clearAll() {
parsedConversations = {}; parsedConversations = {};
flatRows = [];
testResults = {}; testResults = {};
document.getElementById('previewCard').style.display = 'none'; document.getElementById('dataTableCard').style.display = 'none';
document.getElementById('progressCard').style.display = 'none'; document.getElementById('progressCard').style.display = 'none';
document.getElementById('resultsCard').style.display = 'none';
document.getElementById('uploadedFilename').textContent = ''; document.getElementById('uploadedFilename').textContent = '';
document.getElementById('fileInput').value = ''; document.getElementById('fileInput').value = '';
// Clear editor rows and localStorage
const tbody = document.getElementById('editorBody');
tbody.innerHTML = '';
addEditorRow('1', '');
addEditorRow('1', '');
addEditorRow('2', '');
localStorage.removeItem(LS_KEY);
// Also clear server-side results
fetch('/api/test/results/clear', { method: 'DELETE' })
.then(r => r.json())
.then(d => console.log('🧹 Server cleared:', d))
.catch(e => console.warn('Clear error:', e));
} }
// ─── Utils ─── // ─── Utils ───
......
conversation_id_test,message_content
1,Xin chào
1,Tôi muốn tìm áo khoác nữ
2,Hi
2,Có áo phao nam không?
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