Commit 68319fd8 authored by Vũ Hoàng Anh's avatar Vũ Hoàng Anh

feat: add AI Diagram Agent - 2-agent LangGraph with Mermaid.js

- New diagram agent: Planner generates Mermaid code, Responder explains
- FastAPI route: /api/diagram/chat + /clear with Redis session history
- Frontend: split-pane UI with canvas pan/zoom (scroll wheel + drag)
- Auto-fit diagram to view, grid background, PNG export
- Prompt enforces ASCII syntax labels for Mermaid compatibility
- Planner LLM max_tokens=4000 for complex diagram generation
- Supports: flowchart, sequence, class, ER, gantt, mindmap, pie chart
parent 7d550b26
"""Diagram Agent — AI-powered diagram generation using Mermaid syntax."""
from .diagram_graph import get_diagram_agent
__all__ = ["get_diagram_agent"]
"""
Diagram Agent Graph — 2-Agent LangGraph: Planner → Tool → Responder.
Planner generates Mermaid code, Responder explains the result.
"""
import logging
import time
from typing import Annotated, Any, TypedDict
from langchain_core.messages import AIMessage, BaseMessage, HumanMessage, SystemMessage
from langchain_core.tools import tool
from langgraph.graph import END, StateGraph
from langgraph.graph.message import add_messages
from langgraph.prebuilt import ToolNode
from langchain_openai import ChatOpenAI
from config import OPENAI_API_KEY
from .prompts import PLANNER_PROMPT, RESPONDER_PROMPT
logger = logging.getLogger(__name__)
def _extract_text(content) -> str:
"""Extract plain text from msg.content — handles both str and Gemini list format."""
if isinstance(content, str):
return content
if isinstance(content, list):
parts = []
for item in content:
if isinstance(item, dict) and item.get("type") == "text":
parts.append(item.get("text", ""))
elif isinstance(item, str):
parts.append(item)
return "\n".join(parts) if parts else str(content)
return str(content or "")
# ═══════════════════════════════════════════════
# Tool: Generate Diagram
# ═══════════════════════════════════════════════
@tool
def generate_diagram(mermaid_code: str, title: str = "", diagram_type: str = "flowchart") -> str:
"""Generate a Mermaid diagram. Returns the Mermaid code for frontend rendering.
Args:
mermaid_code: Complete Mermaid syntax code for the diagram.
title: Optional title for the diagram.
diagram_type: Type of diagram (flowchart, sequence, class, gantt, pie, mindmap, er, state).
Returns:
JSON string with diagram data for frontend rendering.
"""
import json
# Validate mermaid_code is not empty
if not mermaid_code or not mermaid_code.strip():
return json.dumps({
"status": "error",
"message": "Mermaid code is empty",
}, ensure_ascii=False)
return json.dumps({
"status": "success",
"mermaid_code": mermaid_code.strip(),
"title": title,
"diagram_type": diagram_type,
}, ensure_ascii=False)
# ═══════════════════════════════════════════════
# State
# ═══════════════════════════════════════════════
class DiagramState(TypedDict):
messages: Annotated[list[BaseMessage], add_messages]
# ═══════════════════════════════════════════════
# Graph Builder — 2-Agent Architecture
# ═══════════════════════════════════════════════
class DiagramGraph:
"""2-Agent LangGraph: Planner → Tools → Responder."""
DIAGRAM_MODEL = "gpt-5.4-nano"
def __init__(self, model_name: str | None = None):
self.model_name = model_name or self.DIAGRAM_MODEL
self.tools = [generate_diagram]
# Planner needs higher max_tokens (4000) for complex Mermaid code generation
self.planner_llm = ChatOpenAI(
model=self.model_name, streaming=True, api_key=OPENAI_API_KEY,
temperature=0, max_tokens=4000,
)
self.planner_with_tools = self.planner_llm.bind_tools(self.tools)
# Responder only explains — default tokens fine
self.responder_llm = ChatOpenAI(
model=self.model_name, streaming=True, api_key=OPENAI_API_KEY,
temperature=0, max_tokens=1500,
)
self._compiled = None
logger.info(f"✅ DiagramGraph initialized with model: {self.model_name} (planner: 4000 tokens)")
# ─── Node 1: PLANNER ───
async def _planner_node(self, state: DiagramState) -> dict:
"""Planner AI: analyze request, generate Mermaid code via tool."""
messages = state["messages"]
planner_messages = [SystemMessage(content=PLANNER_PROMPT)]
for msg in messages:
if not isinstance(msg, SystemMessage):
planner_messages.append(msg)
response = await self.planner_with_tools.ainvoke(planner_messages)
return {"messages": [response]}
# ─── Node 2: RESPONDER ───
async def _responder_node(self, state: DiagramState) -> dict:
"""Responder AI: explain the generated diagram."""
messages = state["messages"]
responder_messages = [SystemMessage(content=RESPONDER_PROMPT)]
for msg in messages:
if not isinstance(msg, SystemMessage):
responder_messages.append(msg)
response = await self.responder_llm.ainvoke(responder_messages)
return {"messages": [response]}
# ─── Router ───
def _after_planner(self, state: DiagramState) -> str:
"""After Planner: if tool_calls → go tools, else → end (ask clarification)."""
last = state["messages"][-1]
if hasattr(last, "tool_calls") and last.tool_calls:
return "tools"
return "end"
def build(self):
"""Build 2-agent graph."""
if self._compiled:
return self._compiled
workflow = StateGraph(DiagramState)
workflow.add_node("planner", self._planner_node)
workflow.add_node("tools", ToolNode(self.tools))
workflow.add_node("responder", self._responder_node)
workflow.set_entry_point("planner")
workflow.add_conditional_edges(
"planner",
self._after_planner,
{"tools": "tools", "end": END},
)
workflow.add_edge("tools", "responder")
workflow.add_edge("responder", END)
self._compiled = workflow.compile()
logger.info("✅ DiagramGraph 2-Agent compiled")
return self._compiled
async def chat(self, user_message: str, history: list[BaseMessage] | None = None) -> dict:
"""Main entry — send message and get response with diagram."""
start = time.time()
graph = self.build()
messages: list[BaseMessage] = []
if history:
messages.extend(history)
messages.append(HumanMessage(content=user_message))
result = await graph.ainvoke({"messages": messages})
elapsed_ms = round((time.time() - start) * 1000, 2)
all_messages = result["messages"]
ai_response = ""
tool_calls_log: list[dict[str, Any]] = []
has_tool_calls = False
pipeline: list[dict[str, Any]] = []
diagram_data = None # Mermaid code for frontend
for msg in all_messages:
content_str = _extract_text(msg.content)
if isinstance(msg, HumanMessage):
pipeline.append({
"step": "user",
"label": "👤 User",
"content": content_str,
})
elif isinstance(msg, AIMessage):
if msg.tool_calls:
has_tool_calls = True
tool_calls_log.extend(msg.tool_calls)
pipeline.append({
"step": "planner",
"label": "🧠 Planner AI",
"content": content_str if content_str and content_str != "[]" else "(generating diagram...)",
"tool_calls": [
{"name": tc["name"], "args": tc["args"]}
for tc in msg.tool_calls
],
})
elif msg.content:
ai_response = content_str
pipeline.append({
"step": "responder",
"label": "💬 Responder AI",
"content": content_str[:500] + ("..." if len(content_str) > 500 else ""),
})
else:
# Tool result — extract Mermaid code
tool_content = content_str
try:
import json
parsed = json.loads(tool_content)
if parsed.get("status") == "success":
diagram_data = {
"mermaid_code": parsed.get("mermaid_code", ""),
"title": parsed.get("title", ""),
"diagram_type": parsed.get("diagram_type", "flowchart"),
}
pipeline.append({
"step": "tool_result",
"label": "📊 Diagram Generated",
"content": f"✅ Đã tạo {parsed.get('diagram_type', 'diagram')}: {parsed.get('title', 'Untitled')}",
})
else:
pipeline.append({
"step": "tool_result",
"label": "❌ Tool Error",
"content": parsed.get("message", "Unknown error"),
})
except Exception:
pipeline.append({
"step": "tool_result",
"label": "📊 Tool Result",
"content": tool_content[:300],
})
agent_path = "planner→tools→responder" if has_tool_calls else "planner_only"
logger.info(
"📊 [DIAGRAM] query='%s' | path=%s | time=%.2fms",
user_message[:50], agent_path, elapsed_ms,
)
return {
"response": ai_response,
"elapsed_ms": elapsed_ms,
"tool_calls": [
{"name": tc["name"], "args": tc["args"]}
for tc in tool_calls_log
],
"agent_path": agent_path,
"pipeline": pipeline,
"diagram": diagram_data,
}
# ═══════════════════════════════════════════════
# Singleton
# ═══════════════════════════════════════════════
_instance: DiagramGraph | None = None
def get_diagram_agent(model_name: str | None = None) -> DiagramGraph:
"""Get or create DiagramGraph singleton."""
global _instance
if _instance is None:
_instance = DiagramGraph(model_name)
return _instance
"""
Prompts for the Diagram Agent — Planner generates Mermaid, Responder formats.
"""
PLANNER_PROMPT = """Bạn là AI Diagram Agent chuyên vẽ sơ đồ, flowchart, và diagram.
## Khả năng
Bạn có 1 tool: `generate_diagram` — nhận Mermaid code và trả lại cho user.
## Luồng xử lý
1. Phân tích yêu cầu của user: loại diagram nào? (flowchart, sequence, class, gantt, mindmap, pie, ER...)
2. Thiết kế cấu trúc diagram trong đầu
3. Gọi tool `generate_diagram` với Mermaid code hoàn chỉnh
4. Nếu user chỉnh sửa → gọi lại tool với Mermaid code đã cập nhật
## ⚠️ QUY TẮC MERMAID BẮT BUỘC (PHẢI TUÂN THỦ)
### 1. Dùng TIẾNG ANH cho tất cả syntax-level labels
Mermaid KHÔNG hỗ trợ Unicode/tiếng Việt ở các vị trí sau:
- ER relationship labels: `CUSTOMER ||--o{ ORDER : places` ✅ (KHÔNG dùng `: đặt` ❌)
- Sequence diagram arrows: `Alice->>Bob: Send request` ✅ (KHÔNG dùng `Gửi yêu cầu` ❌)
- Edge labels trên arrow: `A -->|Yes| B` ✅
### 2. Tiếng Việt CHỈ được dùng trong display brackets
- Flowchart node labels: `A[Đặt hàng]` ✅
- Subgraph titles: `subgraph Quy trình đặt hàng` ✅
- Gantt task labels: `Khảo sát :a1, 2024-01-01, 30d` ✅
- Pie labels: `"Sản phẩm A" : 40` ✅
### 3. Node IDs phải ASCII, ngắn gọn
- `A`, `B`, `C1`, `start`, `end`, `checkout` ✅
- KHÔNG dùng: `đặt_hàng`, `khách_hàng` ❌
### 4. ER Diagram — QUY TẮC ĐẶC BIỆT
```
erDiagram
CUSTOMER ||--o{ ORDER : places
ORDER ||--|{ LINE_ITEM : contains
CUSTOMER {
int id PK
string name
string email
}
```
- Entity names: UPPERCASE ASCII (CUSTOMER, ORDER, PRODUCT)
- Attribute types: `int`, `string`, `decimal`, `date`, `boolean`
- Relationship labels: 1 từ tiếng Anh đơn giản (places, contains, has, owns, manages)
- KHÔNG dùng dấu gạch ngang trong entity name, dùng underscore: `LINE_ITEM` ✅
### 5. Giới hạn độ phức tạp
- Flowchart: tối đa 15-20 nodes
- ER diagram: tối đa 6-8 entities
- Sequence diagram: tối đa 6-8 participants
- Nếu user yêu cầu quá phức tạp → chia thành nhiều diagram nhỏ hoặc simplify
### 6. Syntax cơ bản
- Khai báo: `graph TD`, `sequenceDiagram`, `classDiagram`, `gantt`, `pie`, `mindmap`, `erDiagram`, `stateDiagram-v2`, `flowchart LR`
- Arrows: `-->` solid, `-.->` dashed, `==>` thick
- Shapes: `[text]` rectangle, `(text)` rounded, `{text}` diamond, `([text])` stadium, `((text))` circle
- Subgraph: `subgraph Title ... end`
- Styling: `classDef highlight fill:#e8f5e9,stroke:#4caf50; class A,B highlight;`
## Examples
### Flowchart
```
graph TD
A[Khách truy cập] --> B{Đã đăng nhập?}
B -->|Yes| C[Trang chủ]
B -->|No| D[Trang login]
D --> E[Nhập thông tin]
E --> F{Hợp lệ?}
F -->|Yes| C
F -->|No| D
classDef highlight fill:#e3f2fd,stroke:#1976d2
class C highlight
```
### ER Diagram
```
erDiagram
CUSTOMER ||--o{ ORDER : places
ORDER ||--|{ ORDER_ITEM : contains
PRODUCT ||--o{ ORDER_ITEM : included_in
CUSTOMER {
int id PK
string name
string email
string phone
}
ORDER {
int id PK
int customer_id FK
date order_date
string status
decimal total
}
PRODUCT {
int id PK
string name
string sku
decimal price
}
ORDER_ITEM {
int id PK
int order_id FK
int product_id FK
int quantity
decimal subtotal
}
```
### Sequence Diagram
```
sequenceDiagram
participant U as User
participant FE as Frontend
participant API as Backend API
participant DB as Database
U->>FE: Click login
FE->>API: POST /auth/login
API->>DB: Query user
DB-->>API: User data
API-->>FE: JWT token
FE-->>U: Redirect dashboard
```
### Mindmap
```
mindmap
root((Marketing Plan))
Content
Blog posts
Videos
Social media
SEO
Keywords
Backlinks
Ads
Google Ads
Facebook Ads
```
### Pie Chart
```
pie title Market Share 2025
"Product A" : 40
"Product B" : 30
"Product C" : 20
"Others" : 10
```
### Gantt Chart
```
gantt
title Project Timeline
dateFormat YYYY-MM-DD
section Phase 1
Research :a1, 2024-01-01, 14d
Design :a2, after a1, 10d
section Phase 2
Development :a3, after a2, 30d
Testing :a4, after a3, 14d
```
## Phong cách
- Diagram rõ ràng, dễ đọc, chuyên nghiệp
- Dùng classDef để tô màu nhóm phần tử
- Node labels ngắn gọn, dễ hiểu (tiếng Việt OK trong brackets)
- Direction: TD (top-down) cho flow dọc, LR (left-right) cho flow ngang
- Khi chỉnh sửa diagram cũ, PHẢI giữ nguyên cấu trúc, chỉ thêm/sửa phần user yêu cầu
## Khi user hỏi chung chung
Hỏi lại 1 câu: "Bạn muốn vẽ loại diagram nào? (flowchart, sequence, ER, mindmap, gantt, pie...)"
"""
RESPONDER_PROMPT = """Bạn là AI Assistant trình bày kết quả diagram cho user.
## Nhiệm vụ
1. Giải thích ngắn gọn về diagram vừa tạo (2-3 câu max)
2. Đề xuất chỉnh sửa nếu cần: "Bạn muốn thêm/sửa gì không?"
3. KHÔNG lặp lại Mermaid code — diagram đã hiển thị ở panel bên phải
## Phong cách
- Thân thiện, chuyên nghiệp, tiếng Việt
- Ngắn gọn, không giải thích quá chi tiết
- Gợi ý hữu ích: thêm node, đổi layout, thêm màu, thêm entity...
"""
"""
Canifa Persona Generator
Inspired by MiroFish's OasisProfileGenerator — nhưng nhẹ hơn nhiều.
MiroFish: Entity từ Knowledge Graph → 2000 chữ persona → mô phỏng MXH
Canifa: LLM generate trực tiếp → 100-150 chữ persona → test chatbot
Flow:
1. Gọi LLM generate N personas dạng JSON có cấu trúc
2. Mỗi persona có: demographics + shopping behavior + conversion trigger
3. User Simulator dùng persona để đóng vai chat với chatbot
"""
import json
import logging
import random
from dataclasses import dataclass, field
from typing import Any
from common.llm_factory import create_llm
from config import DEFAULT_MODEL
logger = logging.getLogger(__name__)
# ═══════════════════════════════════════════════════════════════
# DATA MODEL — Lightweight version of MiroFish's OasisAgentProfile
# ═══════════════════════════════════════════════════════════════
@dataclass
class CanifaPersona:
"""Canifa customer persona for chatbot testing."""
# Identity
name: str
age: int
gender: str # "male" | "female"
job: str
income_range: str # "5-10tr" | "10-20tr" | "20-40tr"
mbti: str
# Shopping context
shopping_for: str # "Bản thân" | "Con gái 3 tuổi" | "Vợ"
budget: str # "200k-500k"
occasion: str # "Đi làm" | "Đi chơi" | "Đi học"
style_preference: str # "Năng động" | "Thanh lịch"
# Chat behavior
chat_style: str # Mô tả cách chat: ngắn gọn, dùng emoji, hỏi nhiều...
system_prompt: str # Prompt để LLM đóng vai persona này
# Conversion criteria
conversion_trigger: str # Điều kiện MUA: "Bot gợi combo dưới 500k"
drop_trigger: str # Điều kiện BỎ: "Bot gợi SP trên 1tr"
# Metadata
persona_id: int = 0
archetype: str = "" # "Mẹ bỉm sữa" | "GenZ" | "Ông chú IT"
def to_dict(self) -> dict[str, Any]:
return {
"persona_id": self.persona_id,
"name": self.name,
"age": self.age,
"gender": self.gender,
"job": self.job,
"income_range": self.income_range,
"mbti": self.mbti,
"archetype": self.archetype,
"shopping_for": self.shopping_for,
"budget": self.budget,
"occasion": self.occasion,
"style_preference": self.style_preference,
"chat_style": self.chat_style,
"system_prompt": self.system_prompt,
"conversion_trigger": self.conversion_trigger,
"drop_trigger": self.drop_trigger,
}
# ═══════════════════════════════════════════════════════════════
# ARCHETYPES — Predefined customer segments for Canifa
# ═══════════════════════════════════════════════════════════════
CANIFA_ARCHETYPES = [
"Mẹ bỉm sữa — mua đồ cho con nhỏ, cẩn thận, so sánh giá",
"GenZ TikToker — thích trend, chat nhanh, dùng slang/emoji",
"Nhân viên văn phòng nữ — mua đồ đi làm, thanh lịch, budget vừa",
"Ông chú IT — mua quà cho vợ/con gái, không rành thời trang",
"Sinh viên tiết kiệm — budget thấp, săn sale, hỏi nhiều",
"Chị em công sở — mua theo nhóm, hay hỏi ý kiến, thích combo",
"Anh trai gym — tìm đồ thể thao/polo, quan tâm chất liệu",
"Bà ngoại — mua cho cháu, không quen chat, hỏi đơn giản",
"Fashionista — biết nhiều brand, kén chọn, so sánh Canifa vs Uniqlo",
"Khách vãng lai — vào hỏi 1 câu rồi đi, test bot có giữ chân được không",
]
# ═══════════════════════════════════════════════════════════════
# GENERATOR — Core logic
# ═══════════════════════════════════════════════════════════════
GENERATE_SYSTEM_PROMPT = """Bạn là chuyên gia tạo persona khách hàng cho thương hiệu thời trang CANIFA Việt Nam.
Tạo persona CHÂN THỰC, NGẮN GỌN, dùng cho mục đích test chatbot bán hàng.
Mỗi persona phải có tính cách rõ ràng, hành vi mua sắm cụ thể, và điều kiện conversion/drop rõ ràng.
Trả về JSON array. KHÔNG giải thích gì thêm."""
def _build_generate_prompt(count: int, archetypes: list[str] | None = None) -> str:
"""Build prompt to generate N personas."""
if not archetypes:
archetypes = random.sample(CANIFA_ARCHETYPES, min(count, len(CANIFA_ARCHETYPES)))
archetypes_str = "\n".join(f" {i+1}. {a}" for i, a in enumerate(archetypes))
return f"""Tạo {count} persona khách hàng CANIFA dựa trên các archetype sau:
{archetypes_str}
Mỗi persona là 1 JSON object gồm:
- "name": Tên Việt Nam tự nhiên (VD: "Chị Lan", "Bé Vy", "Anh Minh")
- "age": Số tuổi (18-65)
- "gender": "male" hoặc "female"
- "job": Nghề nghiệp ngắn gọn
- "income_range": Khoảng thu nhập/tháng (VD: "8-12tr")
- "mbti": 1 trong 16 MBTI types
- "archetype": Tên nhóm khách (VD: "Mẹ bỉm sữa")
- "shopping_for": Mua cho ai (VD: "Con trai 4 tuổi", "Bản thân", "Chồng")
- "budget": Ngân sách cho lần mua này (VD: "300k-600k")
- "occasion": Dịp mua (VD: "Đi học", "Đi chơi cuối tuần", "Đi làm")
- "style_preference": Phong cách thích (VD: "Năng động", "Basic clean")
- "chat_style": Mô tả CÁCH CHAT 1-2 câu (VD: "Hỏi ngắn gọn, hay dùng 'ạ', thích được tư vấn size")
- "conversion_trigger": Điều kiện để persona QUYẾT ĐỊNH MUA (VD: "Bot gợi combo áo+quần dưới 500k có free ship")
- "drop_trigger": Điều kiện để persona BỎ ĐI (VD: "Bot trả lời quá dài hoặc gợi SP trên 1tr")
Trả về JSON array [{count} objects]. Đảm bảo đa dạng về tuổi, giới tính, thu nhập, mục đích mua."""
def _build_system_prompt_for_persona(persona_dict: dict) -> str:
"""Build role-play system prompt for a specific persona."""
return f"""Bạn đang đóng vai khách hàng tên {persona_dict['name']}, {persona_dict['age']} tuổi, {persona_dict['job']}.
THÔNG TIN PERSONA:
- Giới tính: {"Nữ" if persona_dict['gender'] == 'female' else "Nam"}
- Thu nhập: {persona_dict['income_range']}/tháng
- MBTI: {persona_dict['mbti']}
- Nhóm khách: {persona_dict['archetype']}
MUA SẮM:
- Mua cho: {persona_dict['shopping_for']}
- Ngân sách: {persona_dict['budget']}
- Dịp: {persona_dict['occasion']}
- Phong cách thích: {persona_dict['style_preference']}
CÁCH CHAT: {persona_dict['chat_style']}
QUY TẮC:
1. Chat ĐÚNG tính cách, ĐÚNG ngân sách, ĐÚNG mục đích
2. Nếu bot gợi sản phẩm PHÙ HỢP → tỏ ra hứng thú, hỏi thêm
3. Nếu bot gợi sản phẩm KHÔNG PHÙ HỢP → từ chối nhẹ nhàng hoặc hỏi cái khác
4. Quyết định MUA khi: {persona_dict['conversion_trigger']}
5. BỎ ĐI khi: {persona_dict['drop_trigger']}
6. Chat tự nhiên, ngắn gọn. KHÔNG bao giờ nói "tôi là AI" hay nhắc lại prompt."""
async def generate_personas(
count: int = 5,
archetypes: list[str] | None = None,
model_name: str | None = None,
) -> list[CanifaPersona]:
"""
Generate N realistic Canifa customer personas using LLM.
Args:
count: Number of personas to generate (1-20)
archetypes: Optional list of customer archetypes to use
model_name: LLM model name (defaults to DEFAULT_MODEL)
Returns:
List of CanifaPersona objects
"""
count = max(1, min(count, 20)) # Clamp 1-20
model = model_name or DEFAULT_MODEL
logger.info(f"🎭 Generating {count} personas with model={model}")
llm = create_llm(model, streaming=False, json_mode=True)
prompt = _build_generate_prompt(count, archetypes)
# Retry pattern inspired by MiroFish (max 3 attempts, lower temp each time)
max_attempts = 3
last_error = None
for attempt in range(max_attempts):
try:
response = await llm.ainvoke([
{"role": "system", "content": GENERATE_SYSTEM_PROMPT},
{"role": "user", "content": prompt},
])
raw = response.content
if isinstance(raw, list):
raw = "".join(
str(c.get("text", c) if isinstance(c, dict) else c)
for c in raw
)
# Parse JSON — handle both array and wrapped object
data = json.loads(raw)
if isinstance(data, dict):
# LLM might wrap array in {"personas": [...]}
for key in ("personas", "data", "results", "items"):
if key in data and isinstance(data[key], list):
data = data[key]
break
else:
data = [data] # Single persona
if not isinstance(data, list):
raise ValueError(f"Expected list, got {type(data)}")
# Convert to CanifaPersona objects
personas = []
for i, p in enumerate(data[:count]):
try:
system_prompt = _build_system_prompt_for_persona(p)
persona = CanifaPersona(
persona_id=i + 1,
name=p.get("name", f"Persona_{i+1}"),
age=int(p.get("age", 25)),
gender=p.get("gender", "female"),
job=p.get("job", "Không rõ"),
income_range=p.get("income_range", "10-15tr"),
mbti=p.get("mbti", "ISFJ"),
archetype=p.get("archetype", "Khách vãng lai"),
shopping_for=p.get("shopping_for", "Bản thân"),
budget=p.get("budget", "300k-700k"),
occasion=p.get("occasion", "Đi chơi"),
style_preference=p.get("style_preference", "Basic"),
chat_style=p.get("chat_style", "Chat bình thường"),
system_prompt=system_prompt,
conversion_trigger=p.get("conversion_trigger", "Sản phẩm phù hợp"),
drop_trigger=p.get("drop_trigger", "Không tìm được SP phù hợp"),
)
personas.append(persona)
except Exception as e:
logger.warning(f"⚠️ Skipping persona {i}: {e}")
continue
logger.info(f"✅ Generated {len(personas)} personas successfully")
return personas
except json.JSONDecodeError as e:
last_error = e
logger.warning(f"⚠️ JSON parse failed (attempt {attempt+1}/{max_attempts}): {e}")
except Exception as e:
last_error = e
logger.warning(f"⚠️ Generate failed (attempt {attempt+1}/{max_attempts}): {e}")
logger.error(f"❌ All {max_attempts} attempts failed: {last_error}")
raise RuntimeError(f"Failed to generate personas after {max_attempts} attempts: {last_error}")
"""
Reaction Agent — Community Reaction Simulator
==============================================
Lấy cảm hứng từ BettaFish ForumEngine.
Simulate phản ứng cộng đồng khi Canifa launch chiến dịch mới.
Dùng LLM generate realistic reactions từ các persona segments.
"""
from .reaction_agent import (
run_reaction_simulation,
simulate_reaction_for_persona,
PERSONA_SEGMENTS,
CAMPAIGN_TYPES,
)
__all__ = [
"run_reaction_simulation",
"simulate_reaction_for_persona",
"PERSONA_SEGMENTS",
"CAMPAIGN_TYPES",
]
"""
Reaction Agent — Community Reaction Simulator Engine
=====================================================
Lấy cảm hứng từ BettaFish ForumEngine + MiroFish simulate_agent.
Simulate phản ứng cộng đồng khi Canifa có chiến dịch mới.
Dùng LLM để generate realistic reactions từ các persona segments.
Model: gpt-5.4-nano (from DEFAULT_MODEL env)
"""
import logging
import json
from typing import Any
from common.llm_factory import create_llm
from config import DEFAULT_MODEL
logger = logging.getLogger(__name__)
# ═══ PERSONA SEGMENTS ═══
PERSONA_SEGMENTS = [
{
"id": "mom_shopper",
"name": "Mom Shopper",
"desc": "Phụ nữ 30-40t, mua cho gia đình, ưu tiên chất lượng và giá hợp lý",
"traits": "thực tế, so sánh giá, quan tâm chất liệu và độ bền",
"tone": "nhẹ nhàng, có emoji, chia sẻ kinh nghiệm",
"weight": 0.20,
},
{
"id": "young_professional",
"name": "Young Professional",
"desc": "Nam/Nữ 25-32t, đi làm văn phòng, thích basic smart casual",
"traits": "theo dõi sale, thích đồ basic công sở, hay compare brand",
"tone": "ngắn gọn, lịch sự, đôi khi hỏi thêm chi tiết",
"weight": 0.20,
},
{
"id": "fashion_enthusiast",
"name": "Fashion Enthusiast",
"desc": "Nữ 28-38t, sẵn sàng chi tiền cho item đẹp, follow KOL",
"traits": "biết về trend, chú ý chất liệu, phong cách, so sánh với Muji/Uniqlo",
"tone": "hào hứng, dùng từ fashion, nhiều detail",
"weight": 0.15,
},
{
"id": "budget_conscious",
"name": "Budget Conscious",
"desc": "Nam 35-45t, mua khi sale, so sánh giá nhiều brand, thực dụng",
"traits": "nhạy giá, tính toán, compare kỹ, chờ sale",
"tone": "phân tích, so sánh số liệu, đôi khi tiêu cực về giá",
"weight": 0.15,
},
{
"id": "gen_z",
"name": "Gen Z Trendy",
"desc": "Nam/Nữ 18-25t, thích local brand, aesthetic, chia sẻ TikTok",
"traits": "FOMO, trend-driven, visual-first, share trên social",
"tone": "slang, emoji nhiều, hype, viết tắt",
"weight": 0.15,
},
{
"id": "kol",
"name": "KOL / Blogger",
"desc": "Nữ 25-35t, review sản phẩm chuyên nghiệp, phân tích kỹ",
"traits": "objective, so sánh chất liệu, phân tích chiến lược brand",
"tone": "chuyên nghiệp, review dài, phân tích deep",
"weight": 0.10,
},
{
"id": "troll",
"name": "Social Media Troll",
"desc": "Comment nhanh, hay chê, so sánh Uniqlo/Zara, toxic nhẹ",
"traits": "cynical, so sánh brand ngoại, hay dùng meme",
"tone": "châm biếm, ngắn, sarcastic, emoji mockery",
"weight": 0.05,
},
]
CAMPAIGN_TYPES = {
"product_launch": "Ra mắt sản phẩm mới",
"promotion": "Chương trình khuyến mãi / Flash Sale",
"collection": "Ra mắt Bộ sưu tập (BST)",
"price_change": "Thay đổi / Tăng giá",
"social_post": "Bài đăng Social Media",
"collab": "Hợp tác thương hiệu (Collab)",
}
REACTION_PROMPT = """Bạn là một người Việt Nam thực sự trên mạng xã hội.
Persona: {persona_name} — {persona_desc}
Tính cách: {persona_traits}
Giọng văn: {persona_tone}
Canifa vừa đăng một {campaign_type_label}:
---
{campaign_content}
---
Hãy viết MỘT comment phản ứng tự nhiên như trên Facebook/TikTok.
Trả về JSON (không markdown):
{{
"comment": "nội dung comment",
"sentiment": "positive" | "neutral" | "negative",
"likes_estimate": <number 1-200>,
"shares_estimate": <number 0-50>,
"key_concern": "vấn đề chính quan tâm (nếu có, null nếu không)"
}}
"""
async def simulate_reaction_for_persona(
persona: dict,
campaign_type: str,
campaign_content: str,
model_name: str | None = None,
) -> dict[str, Any]:
"""Generate a single persona's reaction to a campaign."""
model = model_name or DEFAULT_MODEL
llm = create_llm(model, streaming=False)
prompt = REACTION_PROMPT.format(
persona_name=persona["name"],
persona_desc=persona["desc"],
persona_traits=persona["traits"],
persona_tone=persona["tone"],
campaign_type_label=CAMPAIGN_TYPES.get(campaign_type, campaign_type),
campaign_content=campaign_content,
)
try:
response = await llm.ainvoke([
{"role": "system", "content": "You are a social media comment generator. Always respond in valid JSON."},
{"role": "user", "content": prompt},
])
raw = response.content
if isinstance(raw, list):
raw = "".join(str(c.get("text", c) if isinstance(c, dict) else c) for c in raw)
data = json.loads(raw.strip().removeprefix("```json").removesuffix("```").strip())
return {
"persona_id": persona["id"],
"persona_name": persona["name"],
"segment": persona["name"],
**data,
}
except Exception as e:
logger.error(f"❌ [{persona['name']}] Reaction gen failed: {e}")
return {
"persona_id": persona["id"],
"persona_name": persona["name"],
"segment": persona["name"],
"comment": f"[Error generating reaction: {e}]",
"sentiment": "neutral",
"likes_estimate": 0,
"shares_estimate": 0,
"key_concern": None,
}
async def run_reaction_simulation(
campaign_type: str,
campaign_content: str,
persona_weights: dict[str, float] | None = None,
model_name: str | None = None,
) -> dict[str, Any]:
"""
Run full community reaction simulation.
Args:
campaign_type: Type of campaign (product_launch, promotion, etc.)
campaign_content: The campaign text content
persona_weights: Optional weight overrides for each persona segment
model_name: LLM model override
Returns:
Full simulation results with reactions, sentiment, recommendations
"""
reactions = []
segments_used = PERSONA_SEGMENTS.copy()
# Apply weight overrides
if persona_weights:
for seg in segments_used:
if seg["id"] in persona_weights:
seg["weight"] = persona_weights[seg["id"]]
# Generate reactions
for persona in segments_used:
logger.info(f"🎭 Simulating: {persona['name']}...")
result = await simulate_reaction_for_persona(
persona, campaign_type, campaign_content, model_name
)
reactions.append(result)
# Calculate sentiment
pos = sum(1 for r in reactions if r.get("sentiment") == "positive")
neu = sum(1 for r in reactions if r.get("sentiment") == "neutral")
neg = sum(1 for r in reactions if r.get("sentiment") == "negative")
total = len(reactions)
sentiment = {
"positive": round(pos / total * 100) if total else 0,
"neutral": round(neu / total * 100) if total else 0,
"negative": round(neg / total * 100) if total else 0,
"total_reactions": total,
}
# Generate recommendations
recommendations = _generate_recommendations(reactions, sentiment, campaign_type)
return {
"status": "success",
"campaign_type": campaign_type,
"reactions": reactions,
"sentiment": sentiment,
"recommendations": recommendations,
}
def _generate_recommendations(
reactions: list[dict], sentiment: dict, campaign_type: str
) -> list[dict]:
"""Generate actionable recommendations based on reactions."""
recos = []
neg_pct = sentiment.get("negative", 0)
pos_pct = sentiment.get("positive", 0)
if neg_pct >= 40:
recos.append({
"icon": "🚨", "severity": "high",
"text": "Tỷ lệ tiêu cực CAO (>40%). Cân nhắc điều chỉnh nội dung hoặc delay launch.",
})
elif neg_pct >= 20:
recos.append({
"icon": "⚠️", "severity": "medium",
"text": "Có phản ứng tiêu cực đáng kể. Chuẩn bị response template cho team CS.",
})
neg_concerns = [r.get("key_concern") for r in reactions
if r.get("sentiment") == "negative" and r.get("key_concern")]
if neg_concerns:
recos.append({
"icon": "💡", "severity": "info",
"text": f"Concerns chính: {', '.join(neg_concerns[:3])}",
})
if pos_pct >= 60:
recos.append({
"icon": "🚀", "severity": "positive",
"text": "Tín hiệu tốt! Chiến dịch có tiềm năng viral. Chuẩn bị stock + hạ tầng đơn hàng.",
})
recos.append({
"icon": "📊", "severity": "info",
"text": "Ưu tiên kênh TikTok/Instagram nếu Gen Z phản ứng tích cực, Facebook nếu Mom Shopper positive.",
})
return recos
""",
<parameter name="Description">Backend reaction agent that uses LLM to simulate community reactions to campaigns. Follows the same pattern as the existing simulate_agent.
"""
Simulate Agent — MiroFish-Inspired Conversion Testing Engine
=============================================================
Modules:
- persona_generator: LLM-based realistic customer persona generation
- simulation_runner: Core loop: persona chat → bot reply → evaluate
- evaluator: Conversion measurement + insight accuracy scoring
Flow:
1. persona_generator tạo N personas (demographics + behavior + triggers)
2. simulation_runner chạy mỗi persona chat N turns với chatbot
3. evaluator đo conversion status + insight accuracy + product match
4. synthesis tổng hợp → Conversion Report
Model: gpt-5.4-nano (from DEFAULT_MODEL env)
"""
from .persona_generator import CanifaPersona, generate_personas, CANIFA_ARCHETYPES
from .simulation_runner import run_simulation
from .evaluator import evaluate_conversation, synthesize_results
__all__ = [
"CanifaPersona",
"generate_personas",
"CANIFA_ARCHETYPES",
"run_simulation",
"evaluate_conversation",
"synthesize_results",
]
"""
Evaluator — Conversion Measurement + Insight Accuracy
=======================================================
Đánh giá chatbot từ góc nhìn persona:
A. Quality scores (1-5)
B. Conversion status (converted/interested/dropped)
C. Insight accuracy (bot nhận ra persona đúng bao nhiêu %)
D. Product relevance (SP gợi ý phù hợp bao nhiêu %)
Model: gpt-5.4-nano
"""
import json
import logging
from typing import Any
from common.llm_factory import create_llm
from config import DEFAULT_MODEL
logger = logging.getLogger(__name__)
SIM_MODEL = DEFAULT_MODEL
# ═══════════════════════════════════════════════════════════════
# EVALUATE PROMPT
# ═══════════════════════════════════════════════════════════════
EVAL_SYSTEM = """Bạn là UX evaluator đánh giá chatbot bán hàng thời trang CANIFA.
Bạn quan sát cuộc chat giữa 1 khách giả lập (persona) và chatbot.
Đánh giá từ GÓC NHÌN PERSONA:
A. SCORES (1.0-5.0):
- clarity: Bot dễ hiểu?
- helpfulness: Giải quyết được vấn đề?
- naturalness: Tự nhiên?
- task_completion: Khách đạt mục đích?
- satisfaction: Hài lòng?
B. CONVERSION:
- conversion_status: "converted" | "interested" | "dropped"
+ converted = persona nói "mua", "đặt", "lấy cái này"
+ interested = hứng thú nhưng chưa quyết
+ dropped = mất hứng, từ chối, bot không phù hợp
- conversion_reason: Lý do 1 câu tiếng Việt
C. INSIGHT ACCURACY (nếu có):
- insight_accuracy: 0-100 (bot nhận đúng persona %)
- insight_details: Bot hiểu đúng/sai gì
D. PRODUCT:
- product_match: 0-100 (SP phù hợp persona %)
- product_details: SP hợp/không hợp
E. ANALYSIS:
- key_wins: 1-2 điểm mạnh (array, tiếng Việt)
- key_fails: 1-2 điểm yếu (array, tiếng Việt)
- prompt_fix: 1 instruction fix vấn đề lớn nhất (tiếng Việt)
Trả ONLY valid JSON."""
async def evaluate_conversation(
persona_name: str,
persona_age: int,
persona_archetype: str,
chat_style: str,
budget: str,
shopping_for: str,
style_preference: str,
conversion_trigger: str,
drop_trigger: str,
conversation: list[dict[str, str]],
model_name: str | None = None,
) -> dict[str, Any]:
"""
Evaluate 1 conversation → scores + conversion + insight accuracy.
Returns dict with: scores, conversion_status, conversion_reason,
insight_accuracy, product_match, key_wins, key_fails, prompt_fix
"""
model = model_name or SIM_MODEL
conv_text = "\n".join(
f"{'Persona' if m['role'] == 'user' else 'Chatbot'}: {m['content']}"
for m in conversation
)
eval_input = f"""Persona: {persona_name} ({persona_age} tuổi, {persona_archetype})
Chat style: {chat_style}
Budget: {budget}
Mua cho: {shopping_for}
Style: {style_preference}
CONVERSION CRITERIA:
- MUA khi: {conversion_trigger}
- BỎ khi: {drop_trigger}
Conversation ({len(conversation)} messages):
{conv_text}
Evaluate and determine CONVERSION STATUS."""
llm = create_llm(model, streaming=False, json_mode=True)
# Retry
for attempt in range(3):
try:
response = await llm.ainvoke([
{"role": "system", "content": EVAL_SYSTEM},
{"role": "user", "content": eval_input},
])
raw = response.content
if isinstance(raw, list):
raw = "".join(str(c.get("text", c) if isinstance(c, dict) else c) for c in raw)
return json.loads(raw)
except Exception as e:
logger.warning(f"⚠️ Evaluate fail (attempt {attempt+1}/3): {e}")
logger.error(f"❌ Evaluate failed for {persona_name}")
return {
"scores": {"clarity": 0, "helpfulness": 0, "naturalness": 0, "task_completion": 0, "satisfaction": 0},
"conversion_status": "error",
"conversion_reason": "Evaluation failed",
"key_wins": [],
"key_fails": ["Evaluation error"],
"prompt_fix": "",
}
# ═══════════════════════════════════════════════════════════════
# SYNTHESIZE PROMPT
# ═══════════════════════════════════════════════════════════════
SYNTHESIS_SYSTEM = """Bạn là senior UX researcher. Phân tích kết quả simulation nhiều personas.
Tạo BÁO CÁO CONVERSION:
1. conversion_summary:
- total_personas, converted, interested, dropped, conversion_rate
2. segment_analysis: Array of {archetype, conversion_status, avg_score, issue}
3. worst_personas: Tên persona điểm thấp nhất
4. systemic_issues: 2-3 vấn đề hệ thống (tiếng Việt)
5. top_fixes: Array of {title, instruction, impacts}
6. insight_accuracy_avg: Trung bình %
7. overall_score: 1.0-5.0
8. summary: 2-3 câu tóm executive (tiếng Việt)
Trả ONLY valid JSON."""
async def synthesize_results(
results: list[dict[str, Any]],
chatbot_prompt: str = "",
model_name: str | None = None,
) -> dict[str, Any]:
"""
Tổng hợp kết quả từ tất cả personas → Conversion Report.
"""
model = model_name or SIM_MODEL
summaries = []
for r in results:
scores = r.get("scores", {})
ov = sum(scores.values()) / max(len(scores), 1)
summaries.append(
f"{r.get('persona_name', '?')} ({r.get('archetype', '?')}): "
f"overall {ov:.1f}/5 | "
f"conversion: {r.get('conversion_status', '?')} | "
f"reason: {r.get('conversion_reason', '?')}\n"
f" Scores: {json.dumps(scores)}\n"
f" Insight accuracy: {r.get('insight_accuracy', '?')}%\n"
f" Product match: {r.get('product_match', '?')}%\n"
f" Wins: {'; '.join(r.get('key_wins', []))}\n"
f" Fails: {'; '.join(r.get('key_fails', []))}"
)
synth_input = f"""Chatbot prompt (trích): "{chatbot_prompt[:1000]}"
Results across {len(results)} personas:
{chr(10).join(summaries)}
Provide CONVERSION REPORT and top fixes."""
llm = create_llm(model, streaming=False, json_mode=True)
for attempt in range(3):
try:
response = await llm.ainvoke([
{"role": "system", "content": SYNTHESIS_SYSTEM},
{"role": "user", "content": synth_input},
])
raw = response.content
if isinstance(raw, list):
raw = "".join(str(c.get("text", c) if isinstance(c, dict) else c) for c in raw)
return json.loads(raw)
except Exception as e:
logger.warning(f"⚠️ Synthesize fail (attempt {attempt+1}/3): {e}")
logger.error("❌ Synthesize failed")
return {"error": "Synthesis failed after 3 attempts"}
"""
Canifa Persona Generator
=========================
Inspired by MiroFish OasisProfileGenerator — nhưng nhẹ hơn 90%.
MiroFish: Entity từ Knowledge Graph → 2000 chữ → mô phỏng MXH
Canifa: LLM generate trực tiếp → 100-150 chữ → test chatbot
Model: gpt-5.4-nano (DEFAULT_MODEL)
"""
import json
import logging
import random
from dataclasses import dataclass
from typing import Any
from common.llm_factory import create_llm
from config import DEFAULT_MODEL
logger = logging.getLogger(__name__)
# Model — dùng gpt-5.4-nano từ env
SIM_MODEL = DEFAULT_MODEL
# ═══════════════════════════════════════════════════════════════
# DATA MODEL
# ═══════════════════════════════════════════════════════════════
@dataclass
class CanifaPersona:
"""Canifa customer persona — lightweight version of MiroFish OasisAgentProfile."""
# Identity
name: str
age: int
gender: str # "male" | "female"
job: str
income_range: str # "5-10tr" | "10-20tr"
mbti: str
# Shopping context
shopping_for: str # "Bản thân" | "Con gái 3 tuổi"
budget: str # "200k-500k"
occasion: str # "Đi làm" | "Đi chơi"
style_preference: str
# Chat behavior
chat_style: str # Mô tả cách chat
system_prompt: str # Prompt để LLM đóng vai
# Conversion criteria
conversion_trigger: str # Điều kiện MUA
drop_trigger: str # Điều kiện BỎ
# Metadata
persona_id: int = 0
archetype: str = ""
def to_dict(self) -> dict[str, Any]:
return {
"persona_id": self.persona_id,
"name": self.name,
"age": self.age,
"gender": self.gender,
"job": self.job,
"income_range": self.income_range,
"mbti": self.mbti,
"archetype": self.archetype,
"shopping_for": self.shopping_for,
"budget": self.budget,
"occasion": self.occasion,
"style_preference": self.style_preference,
"chat_style": self.chat_style,
"system_prompt": self.system_prompt,
"conversion_trigger": self.conversion_trigger,
"drop_trigger": self.drop_trigger,
}
# ═══════════════════════════════════════════════════════════════
# ARCHETYPES — Khách hàng Canifa điển hình
# ═══════════════════════════════════════════════════════════════
CANIFA_ARCHETYPES = [
"Mẹ bỉm sữa — mua đồ cho con nhỏ, cẩn thận, so sánh giá",
"GenZ TikToker — thích trend, chat nhanh, dùng slang/emoji",
"Nhân viên văn phòng nữ — mua đồ đi làm, thanh lịch, budget vừa",
"Ông chú IT — mua quà cho vợ/con gái, không rành thời trang",
"Sinh viên tiết kiệm — budget thấp, săn sale, hỏi nhiều",
"Chị em công sở — mua theo nhóm, hay hỏi ý kiến, thích combo",
"Anh trai gym — tìm đồ thể thao/polo, quan tâm chất liệu",
"Bà ngoại — mua cho cháu, không quen chat, hỏi đơn giản",
"Fashionista — biết nhiều brand, kén chọn, so sánh Canifa vs Uniqlo",
"Khách vãng lai — vào hỏi 1 câu rồi đi, test bot giữ chân",
]
# ═══════════════════════════════════════════════════════════════
# PROMPTS
# ═══════════════════════════════════════════════════════════════
_GENERATE_SYSTEM = """Bạn là chuyên gia tạo persona khách hàng cho thương hiệu thời trang CANIFA Việt Nam.
Tạo persona CHÂN THỰC, NGẮN GỌN dùng cho test chatbot bán hàng.
Trả về JSON array. KHÔNG giải thích."""
def _build_generate_prompt(count: int, archetypes: list[str] | None = None) -> str:
if not archetypes:
archetypes = random.sample(CANIFA_ARCHETYPES, min(count, len(CANIFA_ARCHETYPES)))
archetypes_str = "\n".join(f" {i+1}. {a}" for i, a in enumerate(archetypes))
return f"""Tạo {count} persona khách hàng CANIFA:
{archetypes_str}
Mỗi persona là 1 JSON object:
- "name": Tên Việt Nam (VD: "Chị Lan", "Bé Vy")
- "age": Số tuổi (18-65)
- "gender": "male" hoặc "female"
- "job": Nghề nghiệp
- "income_range": Thu nhập/tháng (VD: "8-12tr")
- "mbti": MBTI type
- "archetype": Tên nhóm khách
- "shopping_for": Mua cho ai
- "budget": Ngân sách lần này (VD: "300k-600k")
- "occasion": Dịp mua
- "style_preference": Phong cách thích
- "chat_style": Cách chat 1-2 câu
- "conversion_trigger": Điều kiện MUA
- "drop_trigger": Điều kiện BỎ ĐI
Trả JSON array [{count} objects]. Đa dạng tuổi, giới tính, thu nhập."""
def _build_roleplay_prompt(p: dict) -> str:
"""Build system prompt để LLM đóng vai persona."""
return f"""Bạn đóng vai khách hàng tên {p['name']}, {p['age']} tuổi, {p['job']}.
PERSONA:
- Giới tính: {"Nữ" if p['gender'] == 'female' else "Nam"}
- Thu nhập: {p['income_range']}/tháng | MBTI: {p['mbti']}
- Nhóm: {p['archetype']}
MUA SẮM:
- Mua cho: {p['shopping_for']} | Budget: {p['budget']}
- Dịp: {p['occasion']} | Style: {p['style_preference']}
CÁCH CHAT: {p['chat_style']}
QUY TẮC:
1. Chat ĐÚNG tính cách, budget, mục đích
2. SP phù hợp → hứng thú, hỏi thêm
3. SP không hợp → từ chối nhẹ hoặc hỏi cái khác
4. MUA khi: {p['conversion_trigger']}
5. BỎ khi: {p['drop_trigger']}
6. Chat tự nhiên, ngắn. KHÔNG nói "tôi là AI"."""
# ═══════════════════════════════════════════════════════════════
# GENERATOR
# ═══════════════════════════════════════════════════════════════
async def generate_personas(
count: int = 5,
archetypes: list[str] | None = None,
model_name: str | None = None,
) -> list[CanifaPersona]:
"""
Generate N personas bằng LLM.
Retry pattern lấy từ MiroFish (max 3, giảm temperature).
"""
count = max(1, min(count, 20))
model = model_name or SIM_MODEL
logger.info(f"🎭 Generating {count} personas | model={model}")
llm = create_llm(model, streaming=False, json_mode=True)
prompt = _build_generate_prompt(count, archetypes)
# Retry (MiroFish pattern)
for attempt in range(3):
try:
response = await llm.ainvoke([
{"role": "system", "content": _GENERATE_SYSTEM},
{"role": "user", "content": prompt},
])
raw = response.content
if isinstance(raw, list):
raw = "".join(str(c.get("text", c) if isinstance(c, dict) else c) for c in raw)
data = json.loads(raw)
# Handle wrapped response
if isinstance(data, dict):
for key in ("personas", "data", "results", "items"):
if key in data and isinstance(data[key], list):
data = data[key]
break
else:
data = [data]
# Convert to CanifaPersona
personas = []
for i, p in enumerate(data[:count]):
try:
persona = CanifaPersona(
persona_id=i + 1,
name=p.get("name", f"Persona_{i+1}"),
age=int(p.get("age", 25)),
gender=p.get("gender", "female"),
job=p.get("job", "Không rõ"),
income_range=p.get("income_range", "10-15tr"),
mbti=p.get("mbti", "ISFJ"),
archetype=p.get("archetype", "Khách vãng lai"),
shopping_for=p.get("shopping_for", "Bản thân"),
budget=p.get("budget", "300k-700k"),
occasion=p.get("occasion", "Đi chơi"),
style_preference=p.get("style_preference", "Basic"),
chat_style=p.get("chat_style", "Chat bình thường"),
system_prompt=_build_roleplay_prompt(p),
conversion_trigger=p.get("conversion_trigger", "SP phù hợp"),
drop_trigger=p.get("drop_trigger", "Không tìm được SP"),
)
personas.append(persona)
except Exception as e:
logger.warning(f"⚠️ Skip persona {i}: {e}")
logger.info(f"✅ Generated {len(personas)} personas")
return personas
except json.JSONDecodeError as e:
logger.warning(f"⚠️ JSON fail (attempt {attempt+1}/3): {e}")
except Exception as e:
logger.warning(f"⚠️ Generate fail (attempt {attempt+1}/3): {e}")
raise RuntimeError("Failed to generate personas after 3 attempts")
"""
Simulation Runner — Core Engine
=================================
Chạy full loop: persona → chat → evaluate → report
Lấy từ MiroFish simulation_runner.py:
✅ Background simulation với progress tracking
✅ Retry/backoff khi gọi API
✅ Per-persona conversation logging
Model: gpt-5.4-nano
"""
import logging
from typing import Any
import httpx
from common.llm_factory import create_llm
from config import DEFAULT_MODEL
from .evaluator import evaluate_conversation
from .persona_generator import CanifaPersona, generate_personas
logger = logging.getLogger(__name__)
SIM_MODEL = DEFAULT_MODEL
DEFAULT_CHATBOT_URL = "http://172.16.2.207:5000/api/agent/chat-dev"
async def _generate_persona_message(
persona: CanifaPersona,
turn: int,
conversation: list[dict[str, str]],
) -> str:
"""Persona AI tạo tin nhắn tiếp theo."""
llm = create_llm(SIM_MODEL, streaming=False)
if turn == 0:
messages = [
{"role": "system", "content": persona.system_prompt},
{"role": "user", "content": (
f"Bắt đầu chat với chatbot thời trang Canifa. "
f"Gửi tin nhắn đầu tiên như {persona.name}. "
f"1 tin nhắn ngắn, tự nhiên. KHÔNG giải thích."
)},
]
else:
last_bot = conversation[-1]["content"] if conversation else ""
messages = [
{"role": "system", "content": persona.system_prompt},
*conversation,
{"role": "user", "content": (
f'Chatbot vừa trả lời: "{last_bot[:500]}"\n\n'
f"Tiếp tục chat như {persona.name}. 1 tin nhắn ngắn."
)},
]
response = await llm.ainvoke(messages)
raw = response.content
if isinstance(raw, list):
raw = "".join(str(c.get("text", c) if isinstance(c, dict) else c) for c in raw)
return raw.strip()
async def _send_to_chatbot(
message: str,
device_id: str,
chatbot_url: str,
) -> dict[str, Any]:
"""Forward message to chatbot API with retry."""
max_retries = 2
for attempt in range(max_retries + 1):
try:
async with httpx.AsyncClient(timeout=60) as client:
resp = await client.post(
chatbot_url,
json={"user_query": message, "device_id": device_id},
headers={"Content-Type": "application/json"},
)
resp.raise_for_status()
data = resp.json()
return {
"reply": data.get("ai_response", ""),
"product_ids": data.get("product_ids", []),
"user_insight": data.get("user_insight"),
"trace_id": data.get("trace_id", ""),
}
except Exception as e:
if attempt < max_retries:
logger.warning(f"⚠️ Chatbot retry {attempt+1}: {e}")
else:
logger.error(f"❌ Chatbot failed after {max_retries+1} attempts: {e}")
return {
"reply": "[Chatbot không phản hồi]",
"product_ids": [],
"user_insight": None,
"trace_id": "",
}
async def _simulate_one_persona(
persona: CanifaPersona,
turns: int,
chatbot_url: str,
) -> dict[str, Any]:
"""
Chạy simulation cho 1 persona:
1. Generate message → 2. Send to bot → 3. Repeat N turns → 4. Evaluate
"""
conversation: list[dict[str, str]] = []
products_recommended: list = []
device_id = f"sim-{persona.persona_id}"
logger.info(f"💬 [{persona.name}] Starting {turns} turns...")
for turn in range(turns):
# 1. Persona generates message
try:
user_msg = await _generate_persona_message(persona, turn, conversation)
except Exception as e:
logger.error(f"❌ [{persona.name}] Message gen failed turn {turn}: {e}")
break
conversation.append({"role": "user", "content": user_msg})
# 2. Send to chatbot
bot_result = await _send_to_chatbot(user_msg, device_id, chatbot_url)
bot_reply = bot_result["reply"]
if bot_result["product_ids"]:
products_recommended.extend(bot_result["product_ids"])
conversation.append({"role": "assistant", "content": bot_reply})
logger.info(
f" Turn {turn+1}: {persona.name}: {user_msg[:40]}... "
f"→ Bot: {bot_reply[:40]}..."
)
# 3. Evaluate
logger.info(f"📊 [{persona.name}] Evaluating...")
eval_result = await evaluate_conversation(
persona_name=persona.name,
persona_age=persona.age,
persona_archetype=persona.archetype,
chat_style=persona.chat_style,
budget=persona.budget,
shopping_for=persona.shopping_for,
style_preference=persona.style_preference,
conversion_trigger=persona.conversion_trigger,
drop_trigger=persona.drop_trigger,
conversation=conversation,
)
scores = eval_result.get("scores", {})
avg_score = sum(scores.values()) / max(len(scores), 1)
logger.info(
f"✅ [{persona.name}] {eval_result.get('conversion_status', '?')} | "
f"Score: {avg_score:.1f}/5"
)
return {
"persona_name": persona.name,
"archetype": persona.archetype,
"age": persona.age,
"gender": persona.gender,
"budget": persona.budget,
"shopping_for": persona.shopping_for,
"turns": len(conversation) // 2,
"conversation": conversation,
"products_recommended": products_recommended[:10],
**eval_result,
}
async def run_simulation(
persona_count: int = 3,
turns_per_persona: int = 5,
archetypes: list[str] | None = None,
chatbot_url: str | None = None,
model_name: str | None = None,
) -> dict[str, Any]:
"""
🔥 Run full automated simulation.
Flow:
1. Generate N personas (gpt-5.4-nano)
2. Each persona chats with chatbot
3. Evaluate each conversation
4. Return conversion summary
Args:
persona_count: Number of personas (1-20)
turns_per_persona: Chat turns per persona (1-10)
archetypes: Optional customer archetypes
chatbot_url: Chatbot API URL override
model_name: LLM model override
Returns:
Full simulation report with conversion summary
"""
url = chatbot_url or DEFAULT_CHATBOT_URL
model = model_name or SIM_MODEL
results: list[dict[str, Any]] = []
errors: list[dict[str, str]] = []
# ─── Step 1: Generate Personas ───
logger.info(f"🎭 Step 1: Generating {persona_count} personas | model={model}")
try:
personas = await generate_personas(
count=persona_count,
archetypes=archetypes,
model_name=model,
)
except Exception as e:
logger.error(f"❌ Persona generation failed: {e}")
return {"status": "error", "message": f"Persona generation failed: {e}"}
logger.info(f"✅ Generated {len(personas)} personas")
# ─── Step 2+3: Simulate each persona ───
for persona in personas:
try:
result = await _simulate_one_persona(persona, turns_per_persona, url)
results.append(result)
except Exception as e:
logger.error(f"❌ [{persona.name}] Simulation failed: {e}", exc_info=True)
errors.append({"persona": persona.name, "error": str(e)})
# ─── Step 4: Conversion Summary ───
converted = sum(1 for r in results if r.get("conversion_status") == "converted")
interested = sum(1 for r in results if r.get("conversion_status") == "interested")
dropped = sum(1 for r in results if r.get("conversion_status") == "dropped")
total = len(results)
conversion_summary = {
"total": total,
"converted": converted,
"interested": interested,
"dropped": dropped,
"conversion_rate": f"{converted / max(total, 1) * 100:.0f}%",
"interest_rate": f"{(converted + interested) / max(total, 1) * 100:.0f}%",
}
# Average scores
all_scores = [r.get("scores", {}) for r in results if r.get("scores")]
if all_scores:
avg_scores = {}
for key in all_scores[0]:
vals = [s.get(key, 0) for s in all_scores]
avg_scores[key] = round(sum(vals) / len(vals), 1)
conversion_summary["avg_scores"] = avg_scores
logger.info(
f"📊 SIMULATION COMPLETE: "
f"{converted}/{total} converted ({conversion_summary['conversion_rate']}) | "
f"Errors: {len(errors)}"
)
return {
"status": "success",
"conversion_summary": conversion_summary,
"persona_results": results,
"errors": errors,
"meta": {
"personas_generated": len(personas),
"personas_tested": total,
"turns_per_persona": turns_per_persona,
"chatbot_url": url,
"model": model,
},
}
"""
Diagram Agent API — FastAPI route for AI diagram generation.
Uses DiagramGraph 2-Agent (Planner → Tool → Responder).
History stored in Redis, auto-expires after 30 minutes.
"""
import json
import logging
from pydantic import BaseModel
from fastapi import APIRouter
from fastapi.responses import JSONResponse
from langchain_core.messages import AIMessage, HumanMessage
from agent.diagram_agent.diagram_graph import get_diagram_agent
from common.cache import redis_cache
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/diagram", tags=["Diagram Agent"])
HISTORY_KEY_PREFIX = "diagram:hist:"
HISTORY_TTL = 1800 # 30 min
class DiagramChatRequest(BaseModel):
query: str
session_id: str | None = None
# ─── Helpers: serialize/deserialize LangChain messages ↔ Redis ───
def _serialize_messages(messages: list) -> str:
data = []
for msg in messages:
content = msg.content if isinstance(msg.content, str) else str(msg.content)
if isinstance(msg, HumanMessage):
data.append({"role": "human", "content": content})
elif isinstance(msg, AIMessage):
data.append({"role": "ai", "content": content})
return json.dumps(data, ensure_ascii=False)
def _deserialize_messages(raw: str) -> list:
try:
data = json.loads(raw)
except Exception:
return []
messages = []
for item in data:
if item["role"] == "human":
messages.append(HumanMessage(content=item["content"]))
elif item["role"] == "ai":
messages.append(AIMessage(content=item["content"]))
return messages
async def _load_history(session_id: str) -> list:
try:
client = redis_cache.get_client()
if not client:
return []
raw = await client.get(f"{HISTORY_KEY_PREFIX}{session_id}")
if raw:
return _deserialize_messages(raw)
except Exception as e:
logger.warning("Redis load history error: %s", e)
return []
async def _save_history(session_id: str, messages: list):
try:
client = redis_cache.get_client()
if not client:
return
trimmed = messages[-30:]
raw = _serialize_messages(trimmed)
await client.setex(f"{HISTORY_KEY_PREFIX}{session_id}", HISTORY_TTL, raw)
except Exception as e:
logger.warning("Redis save history error: %s", e)
async def _clear_history(session_id: str):
try:
client = redis_cache.get_client()
if client:
await client.delete(f"{HISTORY_KEY_PREFIX}{session_id}")
except Exception as e:
logger.warning("Redis clear history error: %s", e)
# ─── Endpoints ───
@router.post("/chat", summary="Chat with Diagram Agent")
async def diagram_chat(req: DiagramChatRequest):
"""Send a message to the Diagram Agent. Returns AI response + Mermaid diagram data."""
query = req.query.strip()
if not query:
return JSONResponse(status_code=400, content={"status": "error", "message": "Query trống"})
session_id = req.session_id
try:
agent = get_diagram_agent()
history = []
if session_id:
history = await _load_history(session_id)
result = await agent.chat(query, history=history if history else None)
if session_id:
history.append(HumanMessage(content=query))
if result.get("response"):
history.append(AIMessage(content=result["response"]))
await _save_history(session_id, history)
return {
"status": "success",
"response": result["response"],
"elapsed_ms": result["elapsed_ms"],
"agent_path": result["agent_path"],
"tool_calls": result["tool_calls"],
"pipeline": result.get("pipeline", []),
"diagram": result.get("diagram"),
"session_id": session_id,
"history_count": len(history),
}
except Exception as e:
logger.error(f"❌ Diagram Agent error: {e}", exc_info=True)
return JSONResponse(status_code=500, content={"status": "error", "message": str(e)})
@router.post("/clear", summary="Clear diagram chat history")
async def diagram_clear(req: DiagramChatRequest):
if req.session_id:
await _clear_history(req.session_id)
return {"status": "success", "message": "History cleared"}
"""
Reaction Simulator Route — BettaFish-Inspired Community Reaction Simulator
===========================================================================
API layer for the reaction_agent package.
Endpoints:
GET /segments → List persona segments
GET /campaign-types → List campaign types
POST /simulate → Run full reaction simulation
POST /simulate-mock → Return mock data (no LLM needed)
"""
import logging
from typing import Any
from fastapi import APIRouter
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from agent.reaction_agent import (
CAMPAIGN_TYPES,
PERSONA_SEGMENTS,
run_reaction_simulation,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/reaction-simulator", tags=["Reaction Simulator"])
# ═══ REQUEST MODELS ═══
class SimulateRequest(BaseModel):
campaign_type: str = "product_launch"
campaign_content: str
persona_weights: dict[str, float] | None = None
model_name: str | None = None
# ═══ ENDPOINTS ═══
@router.get("/segments")
async def get_segments():
"""List all available persona segments."""
return JSONResponse(content={
"segments": [
{"id": s["id"], "name": s["name"], "desc": s["desc"], "weight": s["weight"]}
for s in PERSONA_SEGMENTS
]
})
@router.get("/campaign-types")
async def get_campaign_types():
"""List all campaign types."""
return JSONResponse(content={"types": CAMPAIGN_TYPES})
@router.post("/simulate")
async def simulate_reactions(req: SimulateRequest):
"""Run full LLM-powered reaction simulation."""
logger.info(f"🔮 Reaction simulation: type={req.campaign_type}")
try:
result = await run_reaction_simulation(
campaign_type=req.campaign_type,
campaign_content=req.campaign_content,
persona_weights=req.persona_weights,
model_name=req.model_name,
)
return JSONResponse(content=result)
except Exception as e:
logger.error(f"❌ Simulation failed: {e}", exc_info=True)
return JSONResponse(
status_code=500,
content={"status": "error", "message": str(e)},
)
@router.post("/simulate-mock")
async def simulate_mock(req: SimulateRequest):
"""Return mock reaction data (no LLM required)."""
mock_reactions = _get_mock_reactions(req.campaign_type)
pos = sum(1 for r in mock_reactions if r["sentiment"] == "positive")
neu = sum(1 for r in mock_reactions if r["sentiment"] == "neutral")
neg = sum(1 for r in mock_reactions if r["sentiment"] == "negative")
total = len(mock_reactions)
return JSONResponse(content={
"status": "success",
"mode": "mock",
"campaign_type": req.campaign_type,
"reactions": mock_reactions,
"sentiment": {
"positive": round(pos / total * 100),
"neutral": round(neu / total * 100),
"negative": round(neg / total * 100),
"total_reactions": total,
},
"recommendations": [
{"icon": "📊", "severity": "info", "text": "Mock mode — kết nối LLM để có phản ứng realistic hơn."},
],
})
def _get_mock_reactions(campaign_type: str) -> list[dict[str, Any]]:
"""Static mock reactions for demo."""
base = [
{"persona_id": "mom_shopper", "persona_name": "Nguyễn Thị Mai", "segment": "Mom Shopper",
"comment": "Sản phẩm này phù hợp cho gia đình quá!", "sentiment": "positive",
"likes_estimate": 38, "shares_estimate": 6, "key_concern": None},
{"persona_id": "young_professional", "persona_name": "Trần Văn Hùng", "segment": "Young Professional",
"comment": "Thiết kế ổn, cần xem thực tế.", "sentiment": "neutral",
"likes_estimate": 21, "shares_estimate": 3, "key_concern": "thiết kế thực tế"},
{"persona_id": "fashion_enthusiast", "persona_name": "Lê Phương Anh", "segment": "Fashion Enthusiast",
"comment": "Đúng trend luôn! Must have!", "sentiment": "positive",
"likes_estimate": 54, "shares_estimate": 14, "key_concern": None},
{"persona_id": "budget_conscious", "persona_name": "Phạm Minh Tuấn", "segment": "Budget Conscious",
"comment": "Giá hơi cao, cần cân nhắc.", "sentiment": "neutral",
"likes_estimate": 16, "shares_estimate": 1, "key_concern": "giá cả"},
{"persona_id": "gen_z", "persona_name": "Hoàng Thúy Linh", "segment": "Gen Z Trendy",
"comment": "Vibe chill ghê 🔥 sắm ngay!", "sentiment": "positive",
"likes_estimate": 62, "shares_estimate": 19, "key_concern": None},
{"persona_id": "kol", "persona_name": "Fashion Blogger", "segment": "KOL / Blogger",
"comment": "Canifa đang đi đúng hướng. Waiting full review.", "sentiment": "positive",
"likes_estimate": 102, "shares_estimate": 28, "key_concern": None},
{"persona_id": "troll", "persona_name": "Random Commenter", "segment": "Social Media Troll",
"comment": "Lại marketing, scroll qua thôi 🥱", "sentiment": "negative",
"likes_estimate": 11, "shares_estimate": 1, "key_concern": "marketing fatigue"},
]
return base
"""
User Insight Dashboard API
Scan Redis for all active user insights and return them.
"""
import json
import logging
from datetime import datetime
from zoneinfo import ZoneInfo
from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse
from common.cache import redis_cache
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/user-insights", tags=["User Insights"])
INSIGHT_PREFIX = "identity_key_insight:"
HISTORY_PREFIX = "identity_key_history:"
@router.get("/all", summary="Get all user insights from Redis")
async def get_all_insights(request: Request):
"""
Scan Redis for all identity_key_insight:* keys and return parsed insights.
Each insight contains the 6-layer UserInsight structure from the chatbot.
"""
try:
client = redis_cache.get_client()
if not client:
return {"status": "error", "message": "Redis not available", "insights": []}
# Scan for all insight keys
insight_keys = []
async for key in client.scan_iter(match=f"{INSIGHT_PREFIX}*", count=100):
if isinstance(key, bytes):
key = key.decode("utf-8")
insight_keys.append(key)
if not insight_keys:
return {"status": "success", "insights": [], "count": 0}
# Fetch all values
results = []
for key in insight_keys:
try:
raw = await client.get(key)
if not raw:
continue
identity_key = key.replace(INSIGHT_PREFIX, "")
# Parse insight JSON
if isinstance(raw, bytes):
raw = raw.decode("utf-8")
insight_data = None
try:
insight_data = json.loads(raw)
except json.JSONDecodeError:
insight_data = {"raw": raw}
# Get TTL to estimate when it was created
ttl = await client.ttl(key)
# Try to get conversation history count
msg_count = 0
history_key = f"{HISTORY_PREFIX}{identity_key}"
try:
history_raw = await client.get(history_key)
if history_raw:
if isinstance(history_raw, bytes):
history_raw = history_raw.decode("utf-8")
history = json.loads(history_raw)
if isinstance(history, list):
msg_count = len(history)
except Exception:
pass
results.append({
"identity_key": identity_key,
"insight": insight_data,
"ttl_seconds": ttl,
"message_count": msg_count,
})
except Exception as e:
logger.warning(f"Error processing key {key}: {e}")
continue
# Sort by TTL ascending (newest first = highest TTL)
results.sort(key=lambda x: x.get("ttl_seconds", 0), reverse=True)
return {
"status": "success",
"insights": results,
"count": len(results),
}
except Exception as e:
logger.error(f"Error in get_all_insights: {e}", exc_info=True)
return JSONResponse(
status_code=500,
content={"status": "error", "message": str(e), "insights": []},
)
@router.get("/{identity_key}", summary="Get single user insight")
async def get_single_insight(identity_key: str):
"""Get insight for a specific identity key."""
try:
client = redis_cache.get_client()
if not client:
return {"status": "error", "message": "Redis not available"}
raw = await client.get(f"{INSIGHT_PREFIX}{identity_key}")
if not raw:
return {"status": "not_found", "identity_key": identity_key}
if isinstance(raw, bytes):
raw = raw.decode("utf-8")
try:
insight = json.loads(raw)
except json.JSONDecodeError:
insight = {"raw": raw}
# Get conversation history
history = []
try:
hist_raw = await client.get(f"{HISTORY_PREFIX}{identity_key}")
if hist_raw:
if isinstance(hist_raw, bytes):
hist_raw = hist_raw.decode("utf-8")
history = json.loads(hist_raw)
except Exception:
pass
ttl = await client.ttl(f"{INSIGHT_PREFIX}{identity_key}")
return {
"status": "success",
"identity_key": identity_key,
"insight": insight,
"history": history,
"ttl_seconds": ttl,
}
except Exception as e:
logger.error(f"Error in get_single_insight: {e}", exc_info=True)
return JSONResponse(
status_code=500,
content={"status": "error", "message": str(e)},
)
@router.delete("/{identity_key}", summary="Delete user insight")
async def delete_insight(identity_key: str):
"""Delete insight for a specific identity key."""
try:
client = redis_cache.get_client()
if not client:
return {"status": "error", "message": "Redis not available"}
deleted = await client.delete(f"{INSIGHT_PREFIX}{identity_key}")
return {"status": "success", "deleted": bool(deleted)}
except Exception as e:
return JSONResponse(status_code=500, content={"status": "error", "message": str(e)})
""" """
User Simulator Route User Simulator Route — MiroFish-Inspired Conversion Testing
AI đóng vai từng persona để test chatbot, đánh giá từ góc nhìn user thật. ============================================================
Thin API layer that delegates to agent/simulate_agent/ package.
Flow:
1. generate-message: Persona AI (Gemini) tạo câu hỏi như user thật Endpoints:
2. chat-with-bot: Gọi chatbot API remote → lấy trả lời GET /archetypes → List available archetypes
3. evaluate: Gemini đánh giá conversation từ góc persona POST /generate-personas → Generate N personas
4. synthesize: Tổng hợp kết quả tất cả personas POST /generate-message → Persona generates 1 message
POST /chat-with-bot → Forward to chatbot API
POST /evaluate → Evaluate + conversion
POST /synthesize → Cross-persona report
POST /run-simulation → 🔥 Full automated loop
""" """
import json
import logging import logging
from typing import Any from typing import Any
import httpx
from fastapi import APIRouter from fastapi import APIRouter
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from pydantic import BaseModel from pydantic import BaseModel
from common.llm_factory import create_llm from agent.simulate_agent import (
from config import DEFAULT_MODEL CANIFA_ARCHETYPES,
evaluate_conversation,
generate_personas,
run_simulation,
synthesize_results,
)
from agent.simulate_agent.simulation_runner import _generate_persona_message, _send_to_chatbot
from agent.simulate_agent.persona_generator import CanifaPersona
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/user-simulator", tags=["User Simulator"]) router = APIRouter(prefix="/api/user-simulator", tags=["User Simulator"])
CHATBOT_API_URL = "http://172.16.2.207:5000/api/agent/chat-dev" DEFAULT_CHATBOT_URL = "http://172.16.2.207:5000/api/agent/chat-dev"
LLM_MODEL = DEFAULT_MODEL # Gemini Flash-Lite
# ═══════════════════════════════════════════════════════════════
# REQUEST MODELS
# ═══════════════════════════════════════════════════════════════
class GeneratePersonasRequest(BaseModel):
count: int = 5
archetypes: list[str] | None = None
# ═══ Request Models ═══
class GenerateMessageRequest(BaseModel): class GenerateMessageRequest(BaseModel):
persona_system: str # System prompt for persona role-play persona_system: str
persona_name: str persona_name: str
turn: int = 0 # Current turn number turn: int = 0
history: list[dict[str, str]] = [] # [{role, content}] history: list[dict[str, str]] = []
last_bot_reply: str = "" # Chatbot's last reply (for follow-up) last_bot_reply: str = ""
class ChatWithBotRequest(BaseModel): class ChatWithBotRequest(BaseModel):
message: str # User message to send to chatbot message: str
device_id: str = "simulator-test"
class EvaluateRequest(BaseModel): class EvaluateRequest(BaseModel):
...@@ -46,209 +62,126 @@ class EvaluateRequest(BaseModel): ...@@ -46,209 +62,126 @@ class EvaluateRequest(BaseModel):
persona_age: int persona_age: int
persona_archetype: str persona_archetype: str
persona_trait: str persona_trait: str
conversation: list[dict[str, str]] # [{role, content}] conversation: list[dict[str, str]]
conversion_trigger: str = ""
drop_trigger: str = ""
budget: str = ""
shopping_for: str = ""
class SynthesizeRequest(BaseModel): class SynthesizeRequest(BaseModel):
chatbot_prompt: str chatbot_prompt: str
results: list[dict[str, Any]] # [{persona_name, scores, key_wins, key_fails, prompt_fix}] results: list[dict[str, Any]]
# ═══ 1. GENERATE MESSAGE — Persona AI tạo tin nhắn ═══ class RunSimulationRequest(BaseModel):
@router.post("/generate-message", summary="Persona AI generates a message") persona_count: int = 3
async def generate_message(req: GenerateMessageRequest): turns_per_persona: int = 5
""" archetypes: list[str] | None = None
Persona AI (Gemini) đóng vai user, tạo tin nhắn tiếp theo. chatbot_url: str = ""
"""
try:
llm = create_llm(LLM_MODEL, streaming=False)
messages = [{"role": "system", "content": req.persona_system}]
if req.turn == 0:
messages.append({
"role": "user",
"content": f"Bạn sẽ bắt đầu chat với một chatbot thời trang Canifa. "
f"Hãy gửi tin nhắn đầu tiên như {req.persona_name}. "
f"Chỉ gửi 1 tin nhắn ngắn, tự nhiên, đúng tính cách. KHÔNG giải thích vai trò."
})
else:
# Build conversation context
for h in req.history:
messages.append(h)
messages.append({
"role": "user",
"content": f'Chatbot Canifa vừa trả lời: "{req.last_bot_reply[:500]}"\n\n'
f"Dựa vào response trên, hãy tiếp tục cuộc trò chuyện như {req.persona_name}. "
f"Chỉ gửi 1 tin nhắn ngắn. KHÔNG giải thích, KHÔNG nhắc lại prompt."
})
response = await llm.ainvoke(messages)
raw = response.content
if isinstance(raw, list):
raw = "".join(str(c.get("text", c) if isinstance(c, dict) else c) for c in raw)
return {"status": "success", "message": raw.strip()}
except Exception as e:
logger.error(f"Generate message error: {e}", exc_info=True)
return JSONResponse(status_code=500, content={"status": "error", "message": str(e)})
# ═══════════════════════════════════════════════════════════════
# ENDPOINTS
# ═══════════════════════════════════════════════════════════════
@router.get("/archetypes", summary="List available customer archetypes")
async def list_archetypes():
return {"archetypes": CANIFA_ARCHETYPES}
# ═══ 2. CHAT WITH BOT — Forward to chatbot API ═══
@router.post("/chat-with-bot", summary="Send message to chatbot API")
async def chat_with_bot(req: ChatWithBotRequest):
"""
Gọi chatbot API remote (172.16.2.207:5000) lấy câu trả lời.
"""
try:
async with httpx.AsyncClient(timeout=60) as client:
resp = await client.post(
CHATBOT_API_URL,
json={"user_query": req.message},
headers={"Content-Type": "application/json"},
)
resp.raise_for_status()
data = resp.json()
if data.get("status") != "success":
raise ValueError(data.get("message", "Chatbot API error"))
@router.post("/generate-personas", summary="Generate realistic customer personas")
async def api_generate_personas(req: GeneratePersonasRequest):
try:
personas = await generate_personas(count=req.count, archetypes=req.archetypes)
return { return {
"status": "success", "status": "success",
"reply": data.get("ai_response", ""), "count": len(personas),
"trace_id": data.get("trace_id", ""), "personas": [p.to_dict() for p in personas],
} }
except httpx.HTTPStatusError as e:
logger.error(f"Chatbot API error: {e}")
return JSONResponse(status_code=502, content={"status": "error", "message": f"Chatbot: {e.response.status_code}"})
except Exception as e: except Exception as e:
logger.error(f"Chat with bot error: {e}", exc_info=True) logger.error(f"Generate personas error: {e}", exc_info=True)
return JSONResponse(status_code=500, content={"status": "error", "message": str(e)}) return JSONResponse(status_code=500, content={"status": "error", "message": str(e)})
# ═══ 3. EVALUATE — Score conversation from persona's POV ═══ @router.post("/generate-message", summary="Persona AI generates a message")
EVAL_SYSTEM = """You are a UX evaluator. You just observed a conversation between a simulated user persona and a chatbot. async def api_generate_message(req: GenerateMessageRequest):
Rate the chatbot's performance from the PERSONA'S perspective.
Score each metric 1.0–5.0 (one decimal):
- clarity: Was the chatbot easy to understand for this type of user?
- helpfulness: Did it actually solve the user's problem?
- naturalness: Did the conversation feel natural and human?
- task_completion: Did the user achieve what they came for?
- satisfaction: Overall, would this user be satisfied?
Also provide:
- key_wins: 1-2 things the chatbot did well (array of strings, in Vietnamese)
- key_fails: 1-2 specific failures for THIS persona type (array of strings, in Vietnamese)
- prompt_fix: One concrete prompt instruction to fix the biggest issue (string, in Vietnamese)
Respond ONLY valid JSON:
{
"scores": {"clarity":0.0,"helpfulness":0.0,"naturalness":0.0,"task_completion":0.0,"satisfaction":0.0},
"key_wins": ["..."],
"key_fails": ["..."],
"prompt_fix": "..."
}"""
@router.post("/evaluate", summary="Evaluate conversation from persona POV")
async def evaluate_conversation(req: EvaluateRequest):
"""
Gemini đánh giá chatbot từ góc nhìn persona.
"""
try: try:
conv_text = "\n".join( persona = CanifaPersona(
f"{'Persona' if m['role'] == 'user' else 'Chatbot'}: {m['content']}" name=req.persona_name, age=0, gender="", job="", income_range="",
for m in req.conversation mbti="", shopping_for="", budget="", occasion="", style_preference="",
chat_style="", system_prompt=req.persona_system,
conversion_trigger="", drop_trigger="",
) )
msg = await _generate_persona_message(persona, req.turn, req.history)
return {"status": "success", "message": msg}
except Exception as e:
logger.error(f"Generate message error: {e}", exc_info=True)
return JSONResponse(status_code=500, content={"status": "error", "message": str(e)})
eval_input = f"""Persona: {req.persona_name} ({req.persona_age} tuổi, {req.persona_archetype})
Persona traits: {req.persona_trait}
Conversation:
{conv_text}
Evaluate the chatbot's performance from {req.persona_name}'s perspective."""
llm = create_llm(LLM_MODEL, streaming=False, json_mode=True) @router.post("/chat-with-bot", summary="Send message to chatbot API")
response = await llm.ainvoke([ async def api_chat_with_bot(req: ChatWithBotRequest):
{"role": "system", "content": EVAL_SYSTEM}, try:
{"role": "user", "content": eval_input}, result = await _send_to_chatbot(req.message, req.device_id, DEFAULT_CHATBOT_URL)
]) return {"status": "success", **result}
except Exception as e:
logger.error(f"Chat with bot error: {e}", exc_info=True)
return JSONResponse(status_code=500, content={"status": "error", "message": str(e)})
raw = response.content
if isinstance(raw, list):
raw = "".join(str(c.get("text", c) if isinstance(c, dict) else c) for c in raw)
result = json.loads(raw) @router.post("/evaluate", summary="Evaluate conversation + measure conversion")
async def api_evaluate(req: EvaluateRequest):
try:
result = await evaluate_conversation(
persona_name=req.persona_name,
persona_age=req.persona_age,
persona_archetype=req.persona_archetype,
chat_style=req.persona_trait,
budget=req.budget,
shopping_for=req.shopping_for,
style_preference="",
conversion_trigger=req.conversion_trigger,
drop_trigger=req.drop_trigger,
conversation=req.conversation,
)
return {"status": "success", **result} return {"status": "success", **result}
except Exception as e: except Exception as e:
logger.error(f"Evaluate error: {e}", exc_info=True) logger.error(f"Evaluate error: {e}", exc_info=True)
return JSONResponse(status_code=500, content={"status": "error", "message": str(e)}) return JSONResponse(status_code=500, content={"status": "error", "message": str(e)})
# ═══ 4. SYNTHESIZE — Cross-persona insights ═══ @router.post("/synthesize", summary="Cross-persona conversion report")
SYNTHESIS_SYSTEM = """You are a senior UX researcher. Analyze simulation results across multiple user personas and provide actionable insights. async def api_synthesize(req: SynthesizeRequest):
try:
Given scores and findings from all personas, identify: result = await synthesize_results(
1. Which persona groups are underserved (lowest scores) results=req.results,
2. What are the 2-3 systemic issues in the chatbot prompt chatbot_prompt=req.chatbot_prompt,
3. What are the 2-3 prompt improvements that would help the most personas )
return {"status": "success", **result}
Respond ONLY valid JSON: except Exception as e:
{ logger.error(f"Synthesize error: {e}", exc_info=True)
"worst_personas": ["persona names with lowest scores"], return JSONResponse(status_code=500, content={"status": "error", "message": str(e)})
"systemic_issues": ["issue 1 in Vietnamese", "issue 2", "issue 3"],
"top_fixes": [
{"title": "Fix title", "instruction": "Concrete prompt instruction in Vietnamese", "impacts": ["Persona1","Persona2"]},
{"title": "Fix title", "instruction": "Concrete prompt instruction", "impacts": ["Persona1"]},
{"title": "Fix title", "instruction": "Concrete prompt instruction", "impacts": ["Persona1","Persona2","Persona3"]}
],
"overall_score": 0.0,
"summary": "2-3 sentence executive summary in Vietnamese"
}"""
@router.post("/synthesize", summary="Cross-persona synthesis") @router.post("/run-simulation", summary="🔥 Run full automated simulation")
async def synthesize_results(req: SynthesizeRequest): async def api_run_simulation(req: RunSimulationRequest):
""" """
Tổng hợp kết quả từ tất cả personas → insights + prompt fixes. Chạy FULL LOOP tự động:
1. Generate personas (gpt-5.4-nano)
2. Mỗi persona chat N turns với chatbot
3. Evaluate + đo conversion
4. Return conversion summary
⚠️ Có thể mất 2-5 phút tùy số persona.
""" """
try: try:
summaries = [] result = await run_simulation(
for r in req.results: persona_count=req.persona_count,
scores = r.get("scores", {}) turns_per_persona=req.turns_per_persona,
ov = sum(scores.values()) / max(len(scores), 1) archetypes=req.archetypes,
summaries.append( chatbot_url=req.chatbot_url or None,
f"{r.get('persona_name', '?')} ({r.get('archetype', '?')}): overall {ov:.1f}/5\n" )
f" Scores: {json.dumps(scores)}\n" return result
f" Wins: {'; '.join(r.get('key_wins', []))}\n"
f" Fails: {'; '.join(r.get('key_fails', []))}"
)
synth_input = f"""Chatbot prompt: "{req.chatbot_prompt[:1000]}"
Results across {len(req.results)} personas:
{chr(10).join(summaries)}
Provide synthesis and top fixes."""
llm = create_llm(LLM_MODEL, streaming=False, json_mode=True)
response = await llm.ainvoke([
{"role": "system", "content": SYNTHESIS_SYSTEM},
{"role": "user", "content": synth_input},
])
raw = response.content
if isinstance(raw, list):
raw = "".join(str(c.get("text", c) if isinstance(c, dict) else c) for c in raw)
result = json.loads(raw)
return {"status": "success", **result}
except Exception as e: except Exception as e:
logger.error(f"Synthesize error: {e}", exc_info=True) logger.error(f"Simulation error: {e}", exc_info=True)
return JSONResponse(status_code=500, content={"status": "error", "message": str(e)}) return JSONResponse(status_code=500, content={"status": "error", "message": str(e)})
...@@ -38,7 +38,10 @@ from api.experiment_log_route import router as experiment_log_router ...@@ -38,7 +38,10 @@ from api.experiment_log_route import router as experiment_log_router
from api.auth_route import router as auth_router from api.auth_route import router as auth_router
from api.product_desc_route import router as product_desc_router from api.product_desc_route import router as product_desc_router
from api.bulk_ops_route import router as bulk_ops_router from api.bulk_ops_route import router as bulk_ops_router
from api.user_insight_route import router as user_insight_router
from api.reaction_simulator_route import router as reaction_simulator_router
from common.cache import redis_cache from common.cache import redis_cache
from common.event_bus import event_bus
from common.middleware import middleware_manager from common.middleware import middleware_manager
from config import PORT, REDIS_CACHE_TURN_ON from config import PORT, REDIS_CACHE_TURN_ON
...@@ -72,10 +75,13 @@ app = FastAPI( ...@@ -72,10 +75,13 @@ app = FastAPI(
@app.on_event("startup") @app.on_event("startup")
async def startup_event(): async def startup_event():
"""Initialize Redis cache and start background workers.""" """Initialize Redis cache, EventBus, and start background workers."""
await redis_cache.initialize() await redis_cache.initialize()
logger.info("✅ Redis cache initialized") logger.info("✅ Redis cache initialized")
# Start FastStream EventBus
await event_bus.start()
# Start report worker if Redis is available # Start report worker if Redis is available
if REDIS_CACHE_TURN_ON and redis_cache.get_client(): if REDIS_CACHE_TURN_ON and redis_cache.get_client():
from agent.report_agent.report_queue import report_worker_loop from agent.report_agent.report_queue import report_worker_loop
...@@ -86,11 +92,14 @@ async def startup_event(): ...@@ -86,11 +92,14 @@ async def startup_event():
@app.on_event("shutdown") @app.on_event("shutdown")
async def shutdown_event(): async def shutdown_event():
"""Cleanup resources before exit to prevent connection leaks during hot-reload.""" """Cleanup resources before exit to prevent connection leaks during hot-reload."""
# Stop FastStream EventBus
await event_bus.stop()
from common.db_pool import db_pool from common.db_pool import db_pool
if db_pool: if db_pool:
db_pool.close_all() db_pool.close_all()
logger.info("🛑 Postgres Connection Pool nicely closed") logger.info("🛑 Postgres Connection Pool nicely closed")
# Optional: If you want to also clean up StarRocks you can do it here # Optional: If you want to also clean up StarRocks you can do it here
try: try:
from common.starrocks_connection import StarRocksConnection from common.starrocks_connection import StarRocksConnection
...@@ -172,6 +181,12 @@ from api.ai_tag_search import router as tag_search_router ...@@ -172,6 +181,12 @@ from api.ai_tag_search import router as tag_search_router
app.include_router(tag_search_router) # Tag Search Agent app.include_router(tag_search_router) # Tag Search Agent
from api.lead_flow_route import router as lead_flow_router from api.lead_flow_route import router as lead_flow_router
app.include_router(lead_flow_router) # Lead Stage AI (Experiment) app.include_router(lead_flow_router) # Lead Stage AI (Experiment)
app.include_router(user_insight_router) # User Insight Dashboard
app.include_router(reaction_simulator_router) # Reaction Simulator
from api.canifa_product_api import router as canifa_product_router
app.include_router(canifa_product_router) # Canifa Product Proxy (GraphQL)
from api.ai_diagram_route import router as diagram_router
app.include_router(diagram_router) # AI Diagram Agent
if __name__ == "__main__": if __name__ == "__main__":
......
/**
* auth.js — Centralized Auth Guard for Canifa Admin
* ===================================================
*
* BẬT/TẮT AUTH LINH HOẠT:
*
* Cách 1: Sửa biến dưới đây
* const AUTH_ENABLED = false; // tắt auth
*
* Cách 2: URL param
* /static/main.html?noauth=1 // bypass auth cho lần này
*
* Cách 3: localStorage
* localStorage.setItem('canifa_auth_disabled', 'true'); // tắt auth mọi lúc
* localStorage.removeItem('canifa_auth_disabled'); // bật lại
*
* Cách 4: Console nhanh
* window.CANIFA_AUTH.disable(); // tắt + reload
* window.CANIFA_AUTH.enable(); // bật + reload
* window.CANIFA_AUTH.status(); // xem trạng thái
*
* USAGE: Thêm vào <head> của mọi page cần auth:
* <script src="/static/auth.js"></script>
*/
(function () {
'use strict';
// ═══════════════════════════════════════════════════
// CONFIG — Đổi false để tắt auth toàn bộ
// ═══════════════════════════════════════════════════
const AUTH_ENABLED = false; // ← false = dev mode (no login), true = production
// ═══════════════════════════════════════════════════
// HELPER: Check if auth should be enforced
// ═══════════════════════════════════════════════════
function isAuthRequired() {
// Master switch
if (!AUTH_ENABLED) return false;
// URL param bypass: ?noauth=1
const params = new URLSearchParams(window.location.search);
if (params.get('noauth') === '1') return false;
// localStorage bypass
if (localStorage.getItem('canifa_auth_disabled') === 'true') return false;
return true;
}
// ═══════════════════════════════════════════════════
// CORE: Get current auth state
// ═══════════════════════════════════════════════════
function getToken() {
return localStorage.getItem('canifa_token') || null;
}
function getUser() {
try {
return JSON.parse(localStorage.getItem('canifa_user') || 'null');
} catch {
return null;
}
}
function isLoggedIn() {
return !!getToken() && !!getUser();
}
// ═══════════════════════════════════════════════════
// AUTH GUARD — Redirect to login if needed
// ═══════════════════════════════════════════════════
function guard() {
if (!isAuthRequired()) {
console.log('🔓 Auth DISABLED — skipping guard');
// Inject fake user nếu chưa có (để sidebar không lỗi)
if (!getUser()) {
localStorage.setItem('canifa_user', JSON.stringify({
username: 'dev',
role: 'user',
id: 'dev-mode',
}));
localStorage.setItem('canifa_token', 'dev-bypass-token');
}
return true; // allow access
}
if (!isLoggedIn()) {
const redirect = encodeURIComponent(window.location.href);
window.location.replace('/static/login.html?redirect=' + redirect);
return false; // blocked
}
return true; // authenticated
}
// ═══════════════════════════════════════════════════
// ADMIN REDIRECT — For pages that need admin role
// ═══════════════════════════════════════════════════
function guardAdmin() {
if (!isAuthRequired()) return true;
if (!guard()) return false;
const user = getUser();
if (!user || user.role !== 'admin') {
window.location.replace('/static/main.html');
return false;
}
return true;
}
// ═══════════════════════════════════════════════════
// LOGOUT
// ═══════════════════════════════════════════════════
function logout() {
localStorage.removeItem('canifa_token');
localStorage.removeItem('canifa_user');
window.location.replace('/static/login.html');
}
// ═══════════════════════════════════════════════════
// AUTH HEADER — For fetch() calls
// ═══════════════════════════════════════════════════
function authHeaders(extra = {}) {
const token = getToken();
const headers = { 'Content-Type': 'application/json', ...extra };
if (token) headers['Authorization'] = 'Bearer ' + token;
return headers;
}
// ═══════════════════════════════════════════════════
// UI HELPERS — Update sidebar user info
// ═══════════════════════════════════════════════════
function updateSidebarUser() {
try {
const user = getUser();
if (!user) return;
const nameEl = document.getElementById('userName');
const avatarEl = document.getElementById('userAvatar');
if (nameEl && user.username) nameEl.textContent = user.username;
if (avatarEl && user.username) avatarEl.textContent = user.username.charAt(0).toUpperCase();
} catch (e) {
console.warn('updateSidebarUser:', e);
}
}
// ═══════════════════════════════════════════════════
// CONSOLE API — For quick toggling in DevTools
// ═══════════════════════════════════════════════════
window.CANIFA_AUTH = {
enable() {
localStorage.removeItem('canifa_auth_disabled');
console.log('🔒 Auth ENABLED. Reloading...');
location.reload();
},
disable() {
localStorage.setItem('canifa_auth_disabled', 'true');
console.log('🔓 Auth DISABLED. Reloading...');
location.reload();
},
status() {
const required = isAuthRequired();
const loggedIn = isLoggedIn();
const user = getUser();
console.table({
'AUTH_ENABLED (hardcode)': AUTH_ENABLED,
'localStorage bypass': localStorage.getItem('canifa_auth_disabled') === 'true',
'Effective': required ? '🔒 ON' : '🔓 OFF',
'Logged in': loggedIn,
'User': user?.username || 'none',
'Role': user?.role || 'none',
'Token': getToken() ? '✅ exists' : '❌ missing',
});
},
// Getters
token: getToken,
user: getUser,
isLoggedIn,
isAuthRequired,
};
// ═══════════════════════════════════════════════════
// EXPORTS (for pages that import this)
// ═══════════════════════════════════════════════════
window.canifaAuth = {
guard,
guardAdmin,
logout,
getToken,
getUser,
isLoggedIn,
isAuthRequired,
authHeaders,
updateSidebarUser,
};
// Log status on load
const mode = isAuthRequired() ? '🔒 Auth ON' : '🔓 Auth OFF (dev mode)';
console.log(`[auth.js] ${mode} | User: ${getUser()?.username || 'none'}`);
})();
/* ═══════════════════════════════════════════════════
SIMULATOR — Dark Premium Theme
═══════════════════════════════════════════════════ */
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap');
:root{
--bg:#0f172a;--bg2:#1e293b;--bg3:#334155;
--card:rgba(255,255,255,.06);--card-border:rgba(255,255,255,.08);
--t1:#f1f5f9;--t2:#94a3b8;--t3:#64748b;
--purple:#8B5CF6;--purple2:#7C3AED;--green:#10B981;--green2:#059669;
--amber:#F59E0B;--red:#EF4444;--blue:#3B82F6;
--gold:#D4A853;
}
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:'Inter',system-ui,sans-serif;background:var(--bg);color:var(--t1);min-height:100vh}
::-webkit-scrollbar{width:5px}::-webkit-scrollbar-thumb{background:rgba(255,255,255,.1);border-radius:4px}
::-webkit-scrollbar-track{background:transparent}
button,input,textarea,select{font-family:inherit;color:var(--t1)}
button{cursor:pointer}
/* Layout */
.sim-wrap{display:flex;flex-direction:column;height:100vh;overflow:hidden}
.sim-topbar{height:56px;background:rgba(15,23,42,.9);backdrop-filter:blur(16px);border-bottom:1px solid var(--card-border);display:flex;align-items:center;padding:0 20px;gap:14px;flex-shrink:0;z-index:10}
.sim-body{flex:1;overflow:hidden;display:flex}
/* Topbar */
.sim-logo{width:36px;height:36px;border-radius:10px;background:linear-gradient(135deg,var(--purple),var(--blue));display:flex;align-items:center;justify-content:center;font-size:15px;color:#fff;font-weight:800}
.sim-title{font-size:14px;font-weight:800;color:var(--t1);letter-spacing:-.01em}
.sim-subtitle{font-size:10px;color:var(--t3)}
/* Tabs */
.tab-group{display:flex;gap:2px;background:rgba(255,255,255,.04);border-radius:9px;padding:3px}
.tab-btn{padding:6px 14px;border-radius:7px;border:none;font-size:12px;font-weight:600;background:transparent;color:var(--t3);transition:all .15s}
.tab-btn:hover{color:var(--t2)}
.tab-btn.active{background:rgba(139,92,246,.15);color:var(--purple);box-shadow:0 1px 4px rgba(139,92,246,.15)}
.tab-badge{margin-left:4px;font-size:9px;background:rgba(139,92,246,.15);color:var(--purple);padding:1px 6px;border-radius:100px}
/* Buttons */
.btn-primary{background:linear-gradient(135deg,var(--purple),var(--purple2));color:#fff;border:none;border-radius:9px;padding:8px 18px;font-size:12.5px;font-weight:700;display:inline-flex;align-items:center;gap:6px;transition:all .15s}
.btn-primary:hover{transform:translateY(-1px);box-shadow:0 4px 16px rgba(139,92,246,.3)}
.btn-primary:disabled{opacity:.4;cursor:not-allowed;transform:none}
.btn-launch{background:linear-gradient(135deg,var(--green),var(--green2));padding:12px 28px;font-size:14px;border-radius:12px;box-shadow:0 4px 20px rgba(16,185,129,.25)}
.btn-launch:hover{box-shadow:0 8px 30px rgba(16,185,129,.35)}
.btn-secondary{background:rgba(255,255,255,.06);color:var(--t2);border:1px solid var(--card-border);border-radius:9px;padding:8px 16px;font-size:12.5px;font-weight:600;transition:all .12s}
.btn-secondary:hover{background:rgba(255,255,255,.1);color:var(--t1)}
.btn-danger{background:var(--red);color:#fff;border:none;border-radius:9px;padding:8px 16px;font-size:12.5px;font-weight:700}
.btn-success{background:linear-gradient(135deg,var(--green),var(--green2));color:#fff;border:none;border-radius:9px;padding:8px 16px;font-size:12.5px;font-weight:700}
/* Cards */
.card{background:var(--card);border:1px solid var(--card-border);border-radius:14px;padding:18px 20px;backdrop-filter:blur(12px)}
.card-title{font-size:14px;font-weight:700;color:var(--t1);margin-bottom:2px}
.card-desc{font-size:11.5px;color:var(--t3)}
/* Target chips */
.target-chips{display:flex;gap:6px;flex-wrap:wrap}
.target-chip{padding:8px 14px;border-radius:9px;border:1.5px solid var(--card-border);background:rgba(255,255,255,.03);font-size:12px;font-weight:600;color:var(--t2);cursor:pointer;transition:all .15s;display:flex;align-items:center;gap:6px}
.target-chip:hover{border-color:var(--purple);color:var(--t1)}
.target-chip.active{border-color:var(--purple);background:rgba(139,92,246,.1);color:var(--purple)}
.target-dot{width:8px;height:8px;border-radius:50%}
/* Conversion badges */
.conv-badge{display:inline-flex;align-items:center;gap:3px;padding:3px 10px;border-radius:100px;font-size:10px;font-weight:700;letter-spacing:.03em}
.conv-converted{background:rgba(16,185,129,.15);color:var(--green);border:1px solid rgba(16,185,129,.3)}
.conv-interested{background:rgba(245,158,11,.12);color:var(--amber);border:1px solid rgba(245,158,11,.25)}
.conv-dropped{background:rgba(239,68,68,.12);color:var(--red);border:1px solid rgba(239,68,68,.25)}
/* Persona Cards */
.persona-card{border:1.5px solid var(--card-border);border-radius:12px;padding:14px;background:var(--card);transition:all .2s;position:relative}
.persona-card:hover{border-color:rgba(139,92,246,.3)}
.persona-card.done{border-color:rgba(16,185,129,.3)}
.persona-card.running{border-color:rgba(59,130,246,.4);box-shadow:0 0 20px rgba(59,130,246,.1)}
.persona-avatar{width:38px;height:38px;border-radius:50%;background:rgba(139,92,246,.15);border:1.5px solid rgba(139,92,246,.3);display:flex;align-items:center;justify-content:center;font-size:14px;font-weight:800;color:var(--purple);flex-shrink:0}
.persona-name{font-size:13px;font-weight:700;color:var(--t1)}
.persona-meta{font-size:10.5px;color:var(--t3)}
/* Score helpers */
.score-dot{width:36px;height:36px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:700}
.minibar-track{height:4px;background:rgba(255,255,255,.06);border-radius:100px;overflow:hidden}
.minibar-fill{height:100%;border-radius:100px;transition:width .6s ease}
/* KPI Cards */
.kpi-card{background:var(--card);border:1px solid var(--card-border);border-radius:14px;padding:18px;text-align:center;backdrop-filter:blur(12px)}
.kpi-value{font-size:28px;font-weight:800;line-height:1;margin:6px 0 2px}
.kpi-label{font-size:10px;font-weight:600;color:var(--t3);text-transform:uppercase;letter-spacing:.08em}
/* Chat */
.chat-header{padding:12px 16px;border-bottom:1px solid var(--card-border);background:rgba(15,23,42,.6);display:flex;align-items:center;gap:10px;flex-shrink:0}
.chat-area{flex:1;overflow-y:auto;padding:14px 16px;display:flex;flex-direction:column;gap:10px}
.msg-label{font-size:9.5px;color:var(--t3);font-weight:600;text-transform:uppercase;letter-spacing:.06em;margin-bottom:1px}
.msg-bubble{max-width:80%;padding:9px 13px;font-size:13px;line-height:1.55;word-break:break-word}
.msg-user{align-self:flex-end;border-radius:14px 14px 3px 14px;background:linear-gradient(135deg,var(--purple),var(--purple2));color:#fff}
.msg-bot{align-self:flex-start;border-radius:14px 14px 14px 3px;background:var(--bg2);border:1px solid var(--card-border);color:var(--t1)}
.msg-thinking{align-self:flex-end;border-radius:14px 14px 3px 14px;background:rgba(245,158,11,.1);border:1px solid rgba(245,158,11,.2);color:var(--amber);font-style:italic}
/* Sim sidebar */
.sim-sidebar{width:200px;border-right:1px solid var(--card-border);overflow-y:auto;padding:10px;background:rgba(15,23,42,.5)}
.sim-sidebar-item{padding:9px 10px;border-radius:9px;cursor:pointer;margin-bottom:4px;transition:all .12s;border:1.5px solid transparent}
.sim-sidebar-item:hover{background:rgba(255,255,255,.04)}
.sim-sidebar-item.active{background:rgba(139,92,246,.08);border-color:rgba(139,92,246,.2)}
/* Metrics panel */
.metrics-panel{width:280px;border-left:1px solid var(--card-border);overflow-y:auto;padding:14px;background:rgba(15,23,42,.5)}
/* Progress bar */
.progress-bar{height:56px;background:rgba(15,23,42,.9);border-top:1px solid var(--card-border);display:flex;align-items:center;padding:0 20px;gap:14px;flex-shrink:0}
.progress-track{flex:1;height:6px;background:rgba(255,255,255,.06);border-radius:100px;overflow:hidden}
.progress-fill{height:100%;border-radius:100px;background:linear-gradient(90deg,var(--purple),var(--green));transition:width .4s ease}
/* Score table */
.score-table{width:100%;border-collapse:collapse;font-size:12px}
.score-table th{padding:10px;text-align:center;font-size:10px;font-weight:600;color:var(--t3);text-transform:uppercase;letter-spacing:.06em;border-bottom:1px solid var(--card-border)}
.score-table th:first-child{text-align:left;padding-left:14px}
.score-table td{padding:8px 10px;text-align:center;border-bottom:1px solid rgba(255,255,255,.03)}
.score-table td:first-child{text-align:left;padding-left:14px;font-family:monospace;font-weight:500;color:var(--t2)}
.score-table tr:hover{background:rgba(255,255,255,.02)}
/* Funnel */
.funnel-stage{display:flex;align-items:center;gap:10px;padding:10px 0}
.funnel-bar{height:32px;border-radius:8px;display:flex;align-items:center;padding:0 12px;font-size:12px;font-weight:700;color:#fff;transition:width .6s ease}
/* Insights */
.insight-box{padding:14px 16px;border-radius:12px;margin-bottom:10px}
.insight-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;margin-bottom:8px}
/* Modal */
.modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.6);display:flex;align-items:center;justify-content:center;z-index:1000;padding:20px;backdrop-filter:blur(4px)}
.modal-body{background:var(--bg2);border:1px solid var(--card-border);border-radius:16px;padding:24px;width:min(520px,100%);max-height:85vh;overflow-y:auto;box-shadow:0 20px 60px rgba(0,0,0,.5)}
.modal-body input,.modal-body textarea,.modal-body select{background:rgba(255,255,255,.06);border:1.5px solid var(--card-border);border-radius:8px;padding:8px 12px;font-size:13px;color:var(--t1);width:100%;transition:border-color .15s}
.modal-body input:focus,.modal-body textarea:focus{outline:none;border-color:var(--purple)}
/* Animations */
@keyframes pulse{0%,100%{opacity:.4}50%{opacity:1}}
@keyframes fadeUp{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:translateY(0)}}
@keyframes spin{to{transform:rotate(360deg)}}
.pulse-dot{width:8px;height:8px;border-radius:50%;background:var(--blue);animation:pulse 1s infinite}
.spinner{width:14px;height:14px;border-radius:50%;border:2px solid rgba(255,255,255,.3);border-top-color:#fff;animation:spin .7s linear infinite;display:inline-block}
/* Tab panels */
.tab-panel{display:none;flex:1;overflow:hidden}
.tab-panel.active{display:flex;flex-direction:column}
.tab-scroll{flex:1;overflow-y:auto;padding:20px}
<!DOCTYPE html>
<html lang="vi">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AI Diagram Agent — Canifa AI</title>
<link rel="stylesheet" href="/static/css/theme.css">
<link rel="stylesheet" href="/static/css/components.css">
<script src="https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<style>
body { margin:0; font-family:var(--font-sans, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif); background:var(--background,#faf9f7); color:var(--foreground,#1c1917); }
.page-wrap { max-width:1400px; margin:0 auto; padding:16px 20px; }
/* Header — same as tag-search */
.page-header { margin-bottom:12px; display:flex; justify-content:space-between; align-items:center; flex-wrap:wrap; gap:8px; }
.page-header h1 { font-size:20px; font-weight:700; margin:0; }
.page-header p { font-size:12px; color:var(--muted-fg,#78716c); margin:2px 0 0; }
.header-badges { display:flex; gap:6px; align-items:center; }
.header-badge { padding:3px 10px; font-size:11px; font-weight:600; border-radius:12px; background:#f0efec; color:#78716c; }
.session-badge { padding:3px 10px; font-size:10px; font-weight:500; border-radius:12px; background:#dbeafe; color:#3b82f6; font-family:'Consolas',monospace; }
/* ═══════ 2-Column Layout ═══════ */
.split-layout { display:grid; grid-template-columns:1fr 1fr; gap:12px; height:calc(100vh - 120px); min-height:450px; }
/* Left: Chat Panel */
.chat-panel { display:flex; flex-direction:column; background:var(--card,#fff); border:1px solid var(--border,#e7e5e2); border-radius:12px; overflow:hidden; }
.chat-messages { flex:1; overflow-y:auto; padding:16px; display:flex; flex-direction:column; gap:10px; }
.chat-messages::-webkit-scrollbar { width:3px; }
.chat-messages::-webkit-scrollbar-thumb { background:rgba(0,0,0,.08); border-radius:4px; }
.msg { max-width:88%; padding:9px 13px; border-radius:12px; font-size:13px; line-height:1.5; word-break:break-word; }
.msg.user { align-self:flex-end; background:var(--primary,#1c1917); color:#fff; border-bottom-right-radius:4px; }
.msg.ai { align-self:flex-start; background:var(--muted,#f0efec); color:var(--foreground); border-bottom-left-radius:4px; }
.msg.ai .meta { margin-top:5px; font-size:10px; color:var(--muted-fg,#78716c); display:flex; gap:6px; }
.msg.system { align-self:center; font-size:11px; color:var(--muted-fg); font-style:italic; background:none; padding:4px 0; }
.msg.error { align-self:center; font-size:11px; color:#dc2626; background:rgba(220,38,38,.05); padding:6px 12px; }
/* Markdown in AI messages */
.msg.ai a { color:var(--primary); text-decoration:underline; }
.msg.ai strong { font-weight:600; }
.msg.ai code { background:var(--muted); padding:1px 4px; border-radius:3px; font-size:11.5px; }
.msg.ai p { margin: 0 0 8px 0; }
.msg.ai p:last-child { margin: 0; }
.msg.ai ul, .msg.ai ol { margin: 0 0 8px 0; padding-left: 20px; }
.msg.ai li { margin-bottom: 2px; }
/* Quick actions */
.quick-actions { padding:6px 12px; display:flex; gap:5px; flex-wrap:wrap; border-top:1px solid var(--border); background:var(--card); }
.quick-btn { padding:4px 10px; font-size:11px; border:1px solid var(--border); border-radius:14px; background:var(--background); cursor:pointer; color:var(--foreground); transition:all .15s; }
.quick-btn:hover { background:var(--muted); border-color:var(--muted-fg); }
/* Input */
.chat-input { border-top:1px solid var(--border); padding:10px 12px; display:flex; gap:6px; background:var(--card); }
.chat-input textarea { flex:1; padding:9px 12px; border:1px solid var(--border); border-radius:8px; font-size:13px; outline:none; background:var(--background); transition:border-color .2s; resize:none; font-family:inherit; }
.chat-input textarea:focus { border-color:var(--primary); }
.chat-input button { padding:9px 16px; background:var(--primary,#1c1917); color:#fff; border:none; border-radius:8px; font-size:12px; font-weight:600; cursor:pointer; transition:opacity .2s; white-space:nowrap; }
.chat-input button:hover { opacity:.85; }
.chat-input button:disabled { opacity:.5; cursor:not-allowed; }
.btn-clear { background:#dc2626 !important; }
.btn-clear:hover { background:#b91c1c !important; }
/* Thinking animation */
.thinking { display:flex; gap:4px; padding:8px 13px; }
.thinking span { width:5px; height:5px; border-radius:50%; background:var(--muted-fg); animation:bounce 1.4s ease-in-out infinite; }
.thinking span:nth-child(2) { animation-delay:.2s; }
.thinking span:nth-child(3) { animation-delay:.4s; }
@keyframes bounce { 0%,80%,100%{transform:translateY(0)} 40%{transform:translateY(-7px)} }
/* Pipeline trace (inside chat) */
.pipeline-trace { display:flex; flex-direction:column; gap:0; }
.p-step { position:relative; padding:8px 10px 8px 24px; margin:0; }
.p-step:not(:last-child) { border-left:2px solid var(--border); margin-left:5px; padding-bottom:10px; }
.p-step:last-child { border-left:2px solid transparent; margin-left:5px; }
.p-step::before { content:''; position:absolute; left:-1px; top:11px; width:8px; height:8px; border-radius:50%; border:2px solid var(--border); background:var(--card); z-index:1; margin-left:-3px; }
.p-step.s-planner::before { background:#f59e0b; border-color:#f59e0b; }
.p-step.s-tool_result::before { background:#10b981; border-color:#10b981; }
.p-step.s-responder::before { background:#3b82f6; border-color:#3b82f6; }
.p-step .step-label { font-size:10px; font-weight:700; margin-bottom:2px; }
.p-step.s-planner .step-label { color:#f59e0b; }
.p-step.s-tool_result .step-label { color:#10b981; }
.p-step.s-responder .step-label { color:#3b82f6; }
.p-step .step-content { font-size:11px; line-height:1.4; color:var(--muted-fg); word-break:break-word; }
@keyframes slideIn { from { opacity:0; transform:translateY(8px); } to { opacity:1; transform:translateY(0); } }
.p-step { animation:slideIn .3s ease-out; }
/* ═══════ RIGHT: Diagram Panel ═══════ */
.diagram-panel { display:flex; flex-direction:column; background:var(--card,#fff); border:1px solid var(--border,#e7e5e2); border-radius:12px; overflow:hidden; }
.diagram-header { padding:10px 14px; border-bottom:1px solid var(--border); display:flex; align-items:center; justify-content:space-between; }
.diagram-header h3 { font-size:13px; font-weight:700; margin:0; }
.diagram-toolbar { display:flex; gap:4px; align-items:center; }
.toolbar-btn { padding:4px 10px; font-size:11px; border:1px solid var(--border); border-radius:8px; background:var(--background); cursor:pointer; color:var(--foreground); transition:all .15s; }
.toolbar-btn:hover { background:var(--muted); border-color:var(--muted-fg); }
.zoom-label { font-size:10px; font-weight:600; color:var(--muted-fg); min-width:38px; text-align:center; font-family:var(--font-mono); }
.diagram-canvas { flex:1; overflow:hidden; position:relative; background:var(--background); cursor:grab; user-select:none; }
.diagram-canvas.grabbing { cursor:grabbing; }
.diagram-canvas::-webkit-scrollbar { display:none; }
/* Grid background like draw.io */
.diagram-canvas::before { content:''; position:absolute; inset:0; background-image:radial-gradient(circle, var(--border) 0.5px, transparent 0.5px); background-size:20px 20px; opacity:.4; pointer-events:none; z-index:0; }
#diagramOutput { position:absolute; top:0; left:0; transform-origin:0 0; transition:none; will-change:transform; z-index:1; }
#diagramOutput svg { display:block; max-width:none; height:auto; }
/* Empty state */
.empty-state { text-align:center; color:var(--muted-fg); padding:40px; }
.empty-state .icon { font-size:56px; margin-bottom:12px; opacity:.5; }
.empty-state h3 { font-size:16px; font-weight:600; color:var(--secondary-fg); margin-bottom:6px; }
.empty-state p { font-size:12px; line-height:1.6; max-width:280px; margin:0 auto; }
/* Diagram info bar */
.diagram-info { padding:6px 14px; border-top:1px solid var(--border); display:flex; align-items:center; justify-content:space-between; font-size:10px; color:var(--muted-fg); background:var(--card); }
/* Mermaid code viewer */
.code-viewer { display:none; padding:12px 14px; border-top:1px solid var(--border); background:rgba(0,0,0,.02); max-height:180px; overflow:auto; }
.code-viewer.show { display:block; }
.code-viewer pre { font-family:var(--font-mono,'Consolas','Monaco',monospace); font-size:11px; color:var(--foreground); white-space:pre-wrap; line-height:1.5; margin:0; }
@media (max-width:900px) {
.split-layout { grid-template-columns:1fr; }
.split-layout > * { min-height:350px; }
}
</style>
</head>
<body>
<div class="page-wrap">
<div class="page-header">
<div>
<h1>📊 AI Diagram Agent</h1>
<p>Mô tả bằng lời — AI tự vẽ sơ đồ · Hỗ trợ Flowchart, Sequence, Class, ER, Gantt, Mindmap, Pie chart · <strong>History in Redis (30min TTL)</strong></p>
</div>
<div class="header-badges">
<span class="session-badge" id="sessionBadge"></span>
<span class="header-badge">2-Agent LangGraph</span>
<span class="header-badge">Mermaid.js</span>
</div>
</div>
<div class="split-layout">
<!-- ═══════ LEFT: CHAT ═══════ -->
<div class="chat-panel">
<div class="chat-messages" id="chatMessages">
<div class="msg system">💬 Chat history lưu Redis, reload thoải mái! Tự hết hạn sau 30 phút.</div>
<div class="msg system">Mô tả sơ đồ bạn muốn — VD: "Vẽ flow đặt hàng online", "Sequence diagram login"</div>
</div>
<div class="quick-actions">
<button class="quick-btn" onclick="sendQuick('Vẽ flow đặt hàng online')">🛒 Flow đặt hàng</button>
<button class="quick-btn" onclick="sendQuick('Sequence diagram login với OTP')">🔑 Sequence login</button>
<button class="quick-btn" onclick="sendQuick('Mindmap marketing plan 2025')">🧠 Mindmap marketing</button>
<button class="quick-btn" onclick="sendQuick('ER diagram hệ thống e-commerce')">🗃️ ER e-commerce</button>
<button class="quick-btn" onclick="sendQuick('Gantt chart dự án 3 tháng')">📅 Gantt chart</button>
<button class="quick-btn" onclick="sendQuick('Class diagram hệ thống OOP')">📐 Class diagram</button>
</div>
<div class="chat-input">
<textarea id="chatInput" placeholder="Mô tả sơ đồ... VD: Vẽ flow đặt hàng online" rows="1"
onkeydown="if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();sendMessage()}"></textarea>
<button id="btnSend" onclick="sendMessage()">Gửi</button>
<button class="btn-clear" onclick="clearChat()">Xoá</button>
</div>
</div>
<!-- ═══════ RIGHT: DIAGRAM ═══════ -->
<div class="diagram-panel">
<div class="diagram-header">
<h3 id="diagramTitle">📊 Diagram Preview</h3>
<div class="diagram-toolbar">
<button class="toolbar-btn" onclick="zoomIn()" title="Zoom in (Ctrl+Scroll)">🔍+</button>
<span class="zoom-label" id="zoomLabel">100%</span>
<button class="toolbar-btn" onclick="zoomOut()" title="Zoom out (Ctrl+Scroll)">🔍−</button>
<button class="toolbar-btn" onclick="fitToView()" title="Fit diagram to view">⊞ Fit</button>
<button class="toolbar-btn" onclick="resetZoom()" title="Reset zoom & position">↻ Reset</button>
<button class="toolbar-btn" onclick="toggleCode()">{ } Code</button>
<button class="toolbar-btn" onclick="exportDiagramPNG()">📥 PNG</button>
</div>
</div>
<div class="diagram-canvas" id="diagramCanvas">
<div id="diagramOutput">
<div class="empty-state">
<div class="icon">📊</div>
<h3>Chưa có diagram</h3>
<p>Nhập yêu cầu ở panel trái để AI vẽ sơ đồ. Hỗ trợ: flowchart, sequence, class, ER, gantt, mindmap, pie chart...</p>
</div>
</div>
</div>
<div class="code-viewer" id="codeViewer">
<pre id="mermaidCode">// Mermaid code sẽ hiện ở đây</pre>
</div>
<div class="diagram-info">
<span id="diagramType">Chưa có diagram</span>
<span id="diagramTime"></span>
</div>
</div>
</div>
</div>
<script>
// ═══ MERMAID INIT (light theme matching Canifa) ═══
mermaid.initialize({
startOnLoad: false,
theme: 'default',
themeVariables: {
primaryColor: '#eef1f8',
primaryTextColor: '#1c1917',
primaryBorderColor: '#3b5998',
secondaryColor: '#f0efec',
tertiaryColor: '#fef3c7',
lineColor: '#78716c',
fontFamily: 'Inter, system-ui, sans-serif',
fontSize: '13px',
},
flowchart: { useMaxWidth: true, htmlLabels: true, curve: 'basis' },
sequence: { useMaxWidth: true, showSequenceNumbers: true },
});
const SESSION_ID = 'diagram_' + Date.now().toString(36);
let currentMermaidCode = '';
let currentZoom = 1;
let panX = 0, panY = 0;
let isDragging = false, dragStartX = 0, dragStartY = 0, panStartX = 0, panStartY = 0;
const canvas = document.getElementById('diagramCanvas');
const output = document.getElementById('diagramOutput');
// Show session badge
document.getElementById('sessionBadge').textContent = SESSION_ID.substring(0, 16);
// ═══ PAN & ZOOM ═══
function applyTransform() {
output.style.transform = `translate(${panX}px, ${panY}px) scale(${currentZoom})`;
document.getElementById('zoomLabel').textContent = Math.round(currentZoom * 100) + '%';
}
// Mouse wheel zoom
canvas.addEventListener('wheel', (e) => {
e.preventDefault();
const rect = canvas.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
const oldZoom = currentZoom;
const delta = e.deltaY > 0 ? -0.1 : 0.1;
currentZoom = Math.min(Math.max(currentZoom + delta, 0.1), 5);
// Zoom towards mouse position
const scale = currentZoom / oldZoom;
panX = mouseX - scale * (mouseX - panX);
panY = mouseY - scale * (mouseY - panY);
applyTransform();
}, { passive: false });
// Click-drag pan
canvas.addEventListener('mousedown', (e) => {
if (e.button !== 0) return;
isDragging = true;
dragStartX = e.clientX;
dragStartY = e.clientY;
panStartX = panX;
panStartY = panY;
canvas.classList.add('grabbing');
});
window.addEventListener('mousemove', (e) => {
if (!isDragging) return;
panX = panStartX + (e.clientX - dragStartX);
panY = panStartY + (e.clientY - dragStartY);
applyTransform();
});
window.addEventListener('mouseup', () => {
isDragging = false;
canvas.classList.remove('grabbing');
});
// ═══ SEND MESSAGE ═══
async function sendMessage() {
const input = document.getElementById('chatInput');
const query = input.value.trim();
if (!query) return;
input.value = '';
input.style.height = 'auto';
appendMessage('user', query);
const typingEl = showTyping();
const btn = document.getElementById('btnSend');
btn.disabled = true;
try {
const resp = await fetch('/api/diagram/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query, session_id: SESSION_ID }),
});
const data = await resp.json();
removeTyping(typingEl);
if (data.status === 'success') {
if (data.pipeline && data.pipeline.length > 0) {
showPipeline(data.pipeline);
}
if (data.diagram && data.diagram.mermaid_code) {
await renderDiagram(data.diagram.mermaid_code, data.diagram.title, data.diagram.diagram_type);
}
if (data.response) {
appendMessage('ai', data.response);
}
document.getElementById('diagramTime').textContent = `⏱ ${data.elapsed_ms}ms · ${data.agent_path}`;
} else {
appendMessage('error', `❌ ${data.message || 'Unknown error'}`);
}
} catch (err) {
removeTyping(typingEl);
appendMessage('error', `❌ Lỗi kết nối: ${err.message}`);
} finally {
btn.disabled = false;
input.focus();
}
}
function sendQuick(text) {
document.getElementById('chatInput').value = text;
sendMessage();
}
// ═══ RENDER ═══
async function renderDiagram(code, title, type) {
currentMermaidCode = code;
document.getElementById('mermaidCode').textContent = code;
if (title) {
document.getElementById('diagramTitle').textContent = `📊 ${title}`;
}
document.getElementById('diagramType').textContent = `${type || 'diagram'} · ${code.split('\n').length} lines`;
try {
const { svg } = await mermaid.render('mermaid-svg-' + Date.now(), code);
output.innerHTML = svg;
// Auto-fit after render
requestAnimationFrame(() => fitToView());
} catch (err) {
console.error('Mermaid render error:', err);
output.innerHTML = `
<div class="empty-state" style="position:relative">
<div class="icon">⚠️</div>
<h3>Lỗi render diagram</h3>
<p style="color:#dc2626">${escHtml(err.message || String(err))}</p>
<pre style="text-align:left;font-size:10px;color:var(--muted-fg);margin-top:8px;max-width:360px;overflow:auto;white-space:pre-wrap">${escHtml(code)}</pre>
</div>`;
}
}
// ═══ CHAT UI ═══
function appendMessage(role, content) {
const c = document.getElementById('chatMessages');
const div = document.createElement('div');
div.className = `msg ${role}`;
if (role === 'ai') {
try { div.innerHTML = marked.parse(content); }
catch { div.textContent = content; }
} else {
div.textContent = content;
}
c.appendChild(div);
c.scrollTop = c.scrollHeight;
}
function showPipeline(steps) {
const c = document.getElementById('chatMessages');
const wrapper = document.createElement('div');
wrapper.className = 'pipeline-trace';
for (const step of steps) {
if (step.step === 'user') continue;
const div = document.createElement('div');
div.className = `p-step s-${step.step}`;
div.innerHTML = `<div class="step-label">${escHtml(step.label)}</div>
<div class="step-content">${escHtml((step.content || '').substring(0, 200))}</div>`;
wrapper.appendChild(div);
}
c.appendChild(wrapper);
c.scrollTop = c.scrollHeight;
}
function showTyping() {
const c = document.getElementById('chatMessages');
const div = document.createElement('div');
div.className = 'thinking';
div.innerHTML = '<span></span><span></span><span></span>';
c.appendChild(div);
c.scrollTop = c.scrollHeight;
return div;
}
function removeTyping(el) { if (el && el.parentNode) el.remove(); }
function escHtml(s) {
if (!s) return '';
return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// ═══ ACTIONS ═══
async function clearChat() {
document.getElementById('chatMessages').innerHTML =
'<div class="msg system">💬 Chat đã xoá. Bắt đầu lại nào!</div>';
document.getElementById('diagramOutput').innerHTML = `
<div class="empty-state"><div class="icon">📊</div><h3>Chưa có diagram</h3><p>Nhập yêu cầu ở panel trái.</p></div>`;
document.getElementById('mermaidCode').textContent = '// Mermaid code sẽ hiện ở đây';
document.getElementById('diagramType').textContent = 'Chưa có diagram';
document.getElementById('diagramTime').textContent = '';
currentMermaidCode = '';
try { await fetch('/api/diagram/clear', { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({session_id:SESSION_ID}) }); } catch {}
}
function toggleCode() { document.getElementById('codeViewer').classList.toggle('show'); }
// ═══ ZOOM CONTROLS ═══
function zoomIn() { currentZoom = Math.min(currentZoom + 0.15, 5); applyTransform(); }
function zoomOut() { currentZoom = Math.max(currentZoom - 0.15, 0.1); applyTransform(); }
function resetZoom() {
currentZoom = 1; panX = 0; panY = 0;
applyTransform();
}
function fitToView() {
const svg = output.querySelector('svg');
if (!svg) return;
// Step 1: Reset to identity so getBoundingClientRect measures natural SVG size
currentZoom = 1;
panX = 0;
panY = 0;
output.style.transform = 'translate(0px, 0px) scale(1)';
// Step 2: Force browser layout recalc, then measure natural dimensions
const canvasRect = canvas.getBoundingClientRect();
const svgRect = svg.getBoundingClientRect();
const svgW = svgRect.width;
const svgH = svgRect.height;
if (!svgW || !svgH) return;
// Step 3: Calculate scale to fit — NEVER zoom in beyond 100%
const padding = 40;
const scaleX = (canvasRect.width - padding) / svgW;
const scaleY = (canvasRect.height - padding) / svgH;
currentZoom = Math.min(scaleX, scaleY, 1); // cap at 100%
currentZoom = Math.max(currentZoom, 0.05); // min 5%
// Step 4: Center in canvas
const fittedW = svgW * currentZoom;
const fittedH = svgH * currentZoom;
panX = Math.max(0, (canvasRect.width - fittedW) / 2);
panY = Math.max(0, (canvasRect.height - fittedH) / 2);
applyTransform();
}
// ═══ EXPORT PNG ═══
function exportDiagramPNG() {
const svg = document.querySelector('#diagramOutput svg');
if (!svg) { alert('Chưa có diagram để export!'); return; }
const svgData = new XMLSerializer().serializeToString(svg);
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
const img = new Image();
img.onload = () => {
canvas.width = img.width * 2; canvas.height = img.height * 2;
ctx.scale(2, 2);
ctx.fillStyle = '#faf9f7';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(img, 0, 0);
const a = document.createElement('a');
a.download = 'diagram.png'; a.href = canvas.toDataURL('image/png'); a.click();
};
img.src = 'data:image/svg+xml;base64,' + btoa(unescape(encodeURIComponent(svgData)));
}
// Auto-resize textarea
document.getElementById('chatInput').addEventListener('input', function() {
this.style.height = 'auto';
this.style.height = Math.min(this.scrollHeight, 100) + 'px';
});
</script>
</body>
</html>
// ═══════════════════════════════════════════════════════
// Conversion Simulator — JS Engine
// ═══════════════════════════════════════════════════════
// ── STATE ─────────────────────────────────────────────
const TARGETS = {
prod: "http://172.16.2.207:5000/api/agent/chat",
dev: "http://172.16.2.207:5000/api/agent/chat-dev",
local:"http://localhost:5000/api/agent/chat-dev",
}
const PERSONAS_DEFAULT = [
{ id:1, name:"Chị Lan", age:35, archetype:"Mẹ bỉm sữa",
trait:"Mua đồ cho con trai 5 tuổi, budget 300k-600k, cẩn thận so sánh giá",
system:"Bạn đóng vai Chị Lan, 35 tuổi, nội trợ. Mua cho con trai 5 tuổi, budget 300k-600k. Hay hỏi 'có sale không?', so sánh giá. MUA khi combo dưới 500k+free ship. BỎ khi gợi SP trên 1tr.",
budget:"300k-600k", shopping_for:"Con trai 5 tuổi", conversion_trigger:"Combo dưới 500k + free ship", drop_trigger:"SP trên 1tr"},
{ id:2, name:"Vy GenZ", age:20, archetype:"GenZ TikToker",
trait:"Thích trend, chat nhanh, dùng slang/emoji, budget thấp",
system:"Bạn đóng vai Vy, 20 tuổi, sinh viên. Chat nhanh, dùng emoji 💀🔥. Budget 200k-400k. MUA khi có SP trendy giá rẻ. BỎ khi bot formal quá hoặc chậm.",
budget:"200k-400k", shopping_for:"Bản thân", conversion_trigger:"SP trendy giá rẻ dưới 300k", drop_trigger:"Bot quá formal"},
{ id:3, name:"Anh Tuấn", age:42, archetype:"Ông chú IT",
trait:"Mua quà cho vợ, không rành thời trang, cần tư vấn rõ ràng",
system:"Bạn đóng vai Anh Tuấn, 42 tuổi, IT manager. Mua quà sinh nhật cho vợ, budget 500k-1tr. Không biết size/style. MUA khi bot gợi ý cụ thể + quà tặng kèm. BỎ khi bot hỏi quá nhiều.",
budget:"500k-1tr", shopping_for:"Vợ", conversion_trigger:"Bot gợi ý cụ thể + quà tặng kèm", drop_trigger:"Bot hỏi quá nhiều"},
{ id:4, name:"Bà Hoa", age:63, archetype:"Bà ngoại",
trait:"Mua cho cháu, chat đơn giản, không biết thuật ngữ",
system:"Bạn đóng vai Bà Hoa, 63 tuổi. Mua quần áo cho cháu gái 8 tuổi. Chat đơn giản. MUA khi bot giải thích dễ hiểu + gợi SP trẻ em phù hợp. BỎ khi bot dùng từ khó hiểu.",
budget:"200k-500k", shopping_for:"Cháu gái 8 tuổi", conversion_trigger:"Bot giải thích dễ hiểu + SP trẻ em", drop_trigger:"Bot dùng từ khó hiểu"},
{ id:5, name:"Minh Anh", age:28, archetype:"Fashionista",
trait:"Biết nhiều brand, kén chọn, so sánh Canifa vs Uniqlo",
system:"Bạn đóng vai Minh Anh, 28 tuổi, designer. Biết rõ thời trang, hay so sánh Canifa với Uniqlo/H&M. Budget 700k-1.5tr. MUA khi bot show unique value Canifa. BỎ khi bot né so sánh.",
budget:"700k-1.5tr", shopping_for:"Bản thân", conversion_trigger:"Bot show unique value Canifa", drop_trigger:"Bot né so sánh"},
]
let personas = [...PERSONAS_DEFAULT]
let chatbotUrl = TARGETS.prod
let conversations = {}
let results = {}
let synthesis = null
let running = false, runningId = null, selectedId = null
let editingPersonaId = null
let currentTab = "command"
let simStartTime = null
const TABS = [["command","🎛 Command"],["live","🔬 Live Sim"],["report","📊 Report"],["insights","💡 Insights"]]
const CONV_ICONS = {converted:'✅',interested:'🤔',dropped:'❌',error:'⚠️'}
const scoreColor = v => v >= 4 ? "var(--green)" : v >= 3 ? "var(--amber)" : "var(--red)"
const scoreBg = v => v >= 4 ? "rgba(16,185,129,.15)" : v >= 3 ? "rgba(245,158,11,.12)" : "rgba(239,68,68,.12)"
const avg = a => a.length ? a.reduce((s,v)=>s+v,0)/a.length : 0
const sleep = ms => new Promise(r => setTimeout(r, ms))
const MAX_TURNS_DEFAULT = 5
// ── TARGET SELECTION ──────────────────────────────────
function setTarget(el, key) {
document.querySelectorAll('.target-chip').forEach(c => c.classList.remove('active'))
el.classList.add('active')
chatbotUrl = TARGETS[key] || el.dataset.url
document.getElementById('customUrl').value = ''
}
function onCustomUrl(inp) {
if (inp.value.trim()) {
chatbotUrl = inp.value.trim()
document.querySelectorAll('.target-chip').forEach(c => c.classList.remove('active'))
}
}
// ── TABS ──────────────────────────────────────────────
function switchTab(id) {
currentTab = id
renderTabs()
document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'))
document.getElementById('panel-'+id).classList.add('active')
if (id === 'live') renderSimSidebar()
if (id === 'report') renderReport()
}
function renderTabs() {
const doneCount = personas.filter(p => results[p.id]).length
document.getElementById('tabGroup').innerHTML = TABS.map(([id,lbl]) =>
`<button class="tab-btn ${currentTab===id?'active':''}" onclick="switchTab('${id}')">
${lbl}${id==='report'&&doneCount>0?`<span class="tab-badge">${doneCount}</span>`:''}
</button>`
).join('')
}
// ── TOP ACTIONS ───────────────────────────────────────
function renderActions() {
const el = document.getElementById('topActions')
const doneCount = personas.filter(p => results[p.id]).length
const overallEl = document.getElementById('overallStat')
if (doneCount > 0) {
const convCount = personas.filter(p => results[p.id]?.conversion_status === 'converted').length
overallEl.style.display = ''
document.getElementById('overallScore').textContent = Math.round(convCount/doneCount*100) + '%'
document.getElementById('overallScore').style.color = convCount/doneCount >= .5 ? 'var(--green)' : 'var(--amber)'
} else { overallEl.style.display = 'none' }
if (running) {
el.innerHTML = `<button class="btn-danger" onclick="stopAll()">⏹ Stop</button>`
} else {
el.innerHTML = `${doneCount > 0 ? `<button class="btn-success" onclick="runSynthesis()">💡 Synthesize</button>` : ''}`
}
}
// ── PERSONA GRID ──────────────────────────────────────
function renderPersonaGrid() {
document.getElementById('personaCount').textContent = personas.length
const grid = document.getElementById('personaGrid')
grid.innerHTML = personas.map(p => {
const r = results[p.id], isRun = runningId===p.id, isDone = !!r
const ov = r ? avg(Object.values(r.scores||{})) : null
const cs = r?.conversion_status
const cc = cs==='converted'?'conv-converted':cs==='interested'?'conv-interested':cs==='dropped'?'conv-dropped':''
return `<div class="persona-card ${isDone?'done':''} ${isRun?'running':''}">
${isRun?'<div class="pulse-dot" style="position:absolute;top:10px;right:10px"></div>':''}
${isDone&&cs?`<span class="conv-badge ${cc}" style="position:absolute;top:10px;right:10px">${CONV_ICONS[cs]||''} ${cs}</span>`:''}
<div style="display:flex;align-items:center;gap:10px;margin-bottom:8px">
<div class="persona-avatar">${p.name.charAt(0)}</div>
<div>
<p class="persona-name">${p.name}</p>
<p class="persona-meta">${p.age} tuổi · ${p.archetype}</p>
${p.budget?`<p style="font-size:10px;color:var(--t3)">💰 ${p.budget} · 🎯 ${p.shopping_for||''}</p>`:''}
</div>
</div>
<p style="font-size:11.5px;color:var(--t2);line-height:1.5;margin-bottom:8px">${p.trait}</p>
${r?`<div style="border-top:1px solid var(--card-border);padding-top:8px;margin-top:4px">
${r.conversion_reason?`<p style="font-size:10.5px;color:var(--t3);font-style:italic;margin-bottom:6px">"${r.conversion_reason}"</p>`:''}
${isDone&&ov!==null?`<div style="display:flex;align-items:center;gap:6px"><span style="font-size:20px;font-weight:800;color:${scoreColor(ov)}">${ov.toFixed(1)}</span><span style="font-size:10px;color:var(--t3)">/ 5</span></div>`:''}
</div>`:''}
<div style="display:flex;gap:6px;margin-top:8px">
<button class="btn-secondary" style="flex:1;padding:5px;font-size:11px" onclick="openPersonaModal(${p.id})">Edit</button>
<button style="background:rgba(239,68,68,.1);border:1px solid rgba(239,68,68,.2);border-radius:7px;padding:5px 8px;font-size:11px;color:var(--red)" onclick="deletePersona(${p.id})">×</button>
<button style="width:28px;height:28px;border-radius:50%;background:${running?'var(--bg3)':'var(--purple)'};border:none;color:#fff;font-size:11px;display:flex;align-items:center;justify-content:center" onclick="runSingle(${p.id})" ${running?'disabled':''}>▶</button>
</div>
</div>`
}).join('')
}
// ── AI PERSONA GENERATION ─────────────────────────────
async function generatePersonasAI() {
const btn = document.getElementById('genPersonaBtn')
const og = btn.innerHTML
btn.disabled = true; btn.innerHTML = '<span class="spinner"></span> Đang tạo...'
try {
const count = parseInt(document.getElementById('cfgCount').value) || 5
const resp = await fetch('/api/user-simulator/generate-personas', {
method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({count})
})
const d = await resp.json()
if (d.status !== 'success') throw new Error(d.message || 'Fail')
personas = d.personas.map((p,i) => ({
id: Date.now()+i, name:p.name, age:p.age, archetype:p.archetype,
trait: `${p.chat_style}. Budget: ${p.budget}, mua cho: ${p.shopping_for}`,
system: p.system_prompt, budget:p.budget, shopping_for:p.shopping_for,
conversion_trigger:p.conversion_trigger, drop_trigger:p.drop_trigger,
}))
results = {}; conversations = {}; synthesis = null
renderAll()
} catch(e) { alert('Lỗi: '+e.message) }
finally { btn.disabled=false; btn.innerHTML=og }
}
// ── PERSONA MODAL ─────────────────────────────────────
function openPersonaModal(id) {
editingPersonaId = id || null
const m = document.getElementById('personaModal')
document.getElementById('modalTitle').textContent = id ? 'Edit Persona' : 'New Persona'
if (id) {
const p = personas.find(x=>x.id===id); if(!p) return
document.getElementById('mName').value = p.name
document.getElementById('mAge').value = p.age
document.getElementById('mArchetype').value = p.archetype
document.getElementById('mBudget').value = p.budget || ''
document.getElementById('mTrait').value = p.trait
document.getElementById('mSystem').value = p.system
} else { ['mName','mArchetype','mBudget','mTrait','mSystem'].forEach(k=>document.getElementById(k).value=''); document.getElementById('mAge').value='30' }
m.style.display = 'flex'
}
function closePersonaModal() { document.getElementById('personaModal').style.display = 'none' }
function savePersona() {
const n = document.getElementById('mName').value.trim()
if (!n) return alert('Tên không được trống')
const data = {
name:n, age:parseInt(document.getElementById('mAge').value)||30,
archetype:document.getElementById('mArchetype').value.trim(),
trait:document.getElementById('mTrait').value.trim(),
system:document.getElementById('mSystem').value.trim(),
budget:document.getElementById('mBudget').value.trim(),
}
if (editingPersonaId) { const p = personas.find(x=>x.id===editingPersonaId); if(p) Object.assign(p, data) }
else { data.id = Date.now(); personas.push(data) }
closePersonaModal(); renderAll()
}
function deletePersona(id) { personas = personas.filter(p=>p.id!==id); delete results[id]; delete conversations[id]; renderAll() }
// ── SIMULATION ENGINE ─────────────────────────────────
async function launchSimulation() {
if (running) return
running = true; simStartTime = Date.now()
const maxTurns = parseInt(document.getElementById('cfgTurns').value) || MAX_TURNS_DEFAULT
showProgress(0, personas.length)
switchTab('live')
renderAll()
for (let i = 0; i < personas.length; i++) {
if (!running) break
await runSinglePersona(personas[i], maxTurns)
showProgress(i+1, personas.length)
}
running = false; runningId = null
hideProgress()
renderAll()
if (personas.filter(p => results[p.id]).length > 0) await runSynthesis()
}
async function runSingle(id) {
if (running) return
const p = personas.find(x=>x.id===id); if(!p) return
running = true; simStartTime = Date.now()
const maxTurns = parseInt(document.getElementById('cfgTurns').value) || MAX_TURNS_DEFAULT
showProgress(0, 1)
switchTab('live')
renderAll()
await runSinglePersona(p, maxTurns)
running = false; runningId = null
showProgress(1, 1)
setTimeout(hideProgress, 1000)
renderAll()
}
async function runSinglePersona(p, maxTurns) {
runningId = p.id; selectedId = p.id
conversations[p.id] = []
renderSimSidebar(); renderChatView(); renderMetricsPanel()
renderPersonaGrid(); renderActions()
for (let turn = 0; turn < maxTurns; turn++) {
if (!running) break
// 1. Generate user message
try {
const umResp = await fetch('/api/user-simulator/generate-message', {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ persona_system_prompt: p.system, conversation: conversations[p.id].filter(m=>!m.thinking).map(m=>({role:m.role,content:m.content})) })
})
const umData = await umResp.json()
if (umData.status !== 'success') { addMsg(p.id,'system','[Lỗi tạo tin nhắn]'); break }
if (umData.thinking) addMsg(p.id,'thinking',umData.thinking)
addMsg(p.id,'user', umData.message)
} catch(e) { addMsg(p.id,'system','[Lỗi: '+e.message+']'); break }
renderChatView(); await sleep(300)
// 2. Chat with bot
try {
const botResp = await fetch('/api/user-simulator/chat-with-bot', {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({
message: conversations[p.id].filter(m=>m.role==='user').pop()?.content || '',
chatbot_url: chatbotUrl,
device_id: 'sim-'+p.id
})
})
const botData = await botResp.json()
if (botData.status === 'success') { addMsg(p.id,'assistant',botData.response) }
else { addMsg(p.id,'assistant','[Chatbot không phản hồi]') }
} catch(e) { addMsg(p.id,'assistant','[Lỗi kết nối chatbot]') }
renderChatView(); await sleep(300)
}
// 3. Evaluate
try {
const evalResp = await fetch('/api/user-simulator/evaluate', {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({
persona_name:p.name, persona_age:p.age, persona_archetype:p.archetype, persona_trait:p.trait,
conversation: conversations[p.id].filter(m=>!m.thinking).map(m=>({role:m.role,content:m.content})),
conversion_trigger:p.conversion_trigger||'', drop_trigger:p.drop_trigger||'',
budget:p.budget||'', shopping_for:p.shopping_for||'',
})
})
const evalData = await evalResp.json()
if (evalData.status === 'success') results[p.id] = evalData
} catch(e) { console.error('Eval error:', e) }
renderSimSidebar(); renderChatView(); renderMetricsPanel()
renderPersonaGrid(); renderActions()
}
function addMsg(pid, role, content) {
if (!conversations[pid]) conversations[pid] = []
conversations[pid].push({role, content, thinking: role==='thinking'})
}
function stopAll() { running = false }
// ── LIVE TAB RENDERS ──────────────────────────────────
function renderSimSidebar() {
const sb = document.getElementById('simSidebar')
sb.innerHTML = personas.map(p => {
const r = results[p.id], isRun = runningId===p.id
const cs = r?.conversion_status
const icon = isRun ? '<div class="pulse-dot"></div>' : cs ? (CONV_ICONS[cs]||'') : '○'
const msgCount = (conversations[p.id]||[]).filter(m=>!m.thinking).length
return `<div class="sim-sidebar-item ${selectedId===p.id?'active':''}" onclick="selectPersona(${p.id})">
<div style="display:flex;align-items:center;gap:8px">
<span style="font-size:12px;width:18px;text-align:center">${icon}</span>
<div>
<p style="font-size:12px;font-weight:600;color:var(--t1)">${p.name}</p>
<p style="font-size:10px;color:var(--t3)">${msgCount} msgs${r?' · '+avg(Object.values(r.scores||{})).toFixed(1):''}</p>
</div>
</div>
</div>`
}).join('')
}
function selectPersona(id) { selectedId = id; renderSimSidebar(); renderChatView(); renderMetricsPanel() }
function renderChatView() {
const p = personas.find(x => x.id === selectedId)
if (!p) return
const header = document.getElementById('chatHeader')
const isRun = runningId === p.id
header.innerHTML = `<div class="persona-avatar" style="width:32px;height:32px;font-size:12px">${p.name.charAt(0)}</div>
<div><p style="font-size:13px;font-weight:700">${p.name} ${isRun?'đang test':'- '+p.archetype}</p>
<p style="font-size:10.5px;color:var(--t3)">${p.age} tuổi · ${p.budget||''}</p></div>
${isRun?'<div class="pulse-dot" style="margin-left:auto"></div>':''}`
const area = document.getElementById('chatArea')
const msgs = conversations[p.id] || []
area.innerHTML = msgs.map(m => {
if (m.thinking) return `<div class="msg-thinking"><span class="msg-label">THINKING</span>${m.content}</div>`
if (m.role==='system') return `<div style="text-align:center;font-size:11px;color:var(--t3)">${m.content}</div>`
return `<div><span class="msg-label" style="text-align:${m.role==='user'?'right':'left'};display:block">${m.role==='user'?p.name:'CHATBOT'}</span>
<div class="msg-bubble ${m.role==='user'?'msg-user':'msg-bot'}">${m.content}</div></div>`
}).join('')
area.scrollTop = area.scrollHeight
}
function renderMetricsPanel() {
const mc = document.getElementById('metricsContent')
const p = personas.find(x=>x.id===selectedId)
const r = p ? results[p.id] : null
if (!r) { mc.innerHTML = '<p style="color:var(--t3);font-size:12px;text-align:center;padding:20px 0">Chưa có dữ liệu</p>'; return }
const cs = r.conversion_status, cc = cs==='converted'?'conv-converted':cs==='interested'?'conv-interested':'conv-dropped'
const ov = avg(Object.values(r.scores||{}))
mc.innerHTML = `
<div style="text-align:center;margin-bottom:16px">
<span class="conv-badge ${cc}" style="font-size:13px;padding:6px 16px">${CONV_ICONS[cs]||''} ${(cs||'').toUpperCase()}</span>
</div>
<div style="text-align:center;margin-bottom:14px">
<span style="font-size:36px;font-weight:800;color:${scoreColor(ov)}">${ov.toFixed(1)}</span>
<span style="font-size:14px;color:var(--t3)">/ 5</span>
</div>
${Object.entries(r.scores||{}).map(([k,v]) => `
<div style="margin-bottom:10px">
<div style="display:flex;justify-content:space-between;margin-bottom:3px">
<span style="font-size:11px;color:var(--t2)">${k}</span>
<span style="font-size:11px;font-weight:700;color:${scoreColor(v)}">${v.toFixed(1)}</span>
</div>
<div class="minibar-track"><div class="minibar-fill" style="width:${(v/5)*100}%;background:${scoreColor(v)}"></div></div>
</div>
`).join('')}
${r.conversion_reason?`<div class="card" style="margin-top:12px;padding:10px 12px"><p style="font-size:10px;font-weight:600;color:var(--t3);margin-bottom:4px">📝 Lý do</p><p style="font-size:11.5px;color:var(--t2);line-height:1.5">${r.conversion_reason}</p></div>`:''}
${r.key_wins?.length?`<div style="margin-top:12px"><p style="font-size:10px;font-weight:600;color:var(--green);margin-bottom:6px">✅ WINS</p>${r.key_wins.map(w=>`<p style="font-size:11px;color:var(--t2);margin-bottom:4px">• ${w}</p>`).join('')}</div>`:''}
${r.key_fails?.length?`<div style="margin-top:10px"><p style="font-size:10px;font-weight:600;color:var(--red);margin-bottom:6px">❌ FAILS</p>${r.key_fails.map(f=>`<p style="font-size:11px;color:var(--t2);margin-bottom:4px">• ${f}</p>`).join('')}</div>`:''}`
}
// ── REPORT TAB ────────────────────────────────────────
function renderReport() {
const done = personas.filter(p => results[p.id])
if (!done.length) { document.getElementById('kpiRow').innerHTML = '<p style="grid-column:1/-1;text-align:center;color:var(--t3);font-size:13px;padding:40px">Chưa có kết quả. Chạy simulation trước.</p>'; return }
const convCount = done.filter(p => results[p.id].conversion_status === 'converted').length
const intCount = done.filter(p => results[p.id].conversion_status === 'interested').length
const dropCount = done.filter(p => results[p.id].conversion_status === 'dropped').length
const avgScore = avg(done.map(p => avg(Object.values(results[p.id].scores||{}))))
// KPIs
document.getElementById('kpiRow').innerHTML = `
<div class="kpi-card"><p class="kpi-label">Conversion Rate</p><p class="kpi-value" style="color:var(--green)">${Math.round(convCount/done.length*100)}%</p><p style="font-size:11px;color:var(--t3)">${convCount}/${done.length}</p></div>
<div class="kpi-card"><p class="kpi-label">Interest Rate</p><p class="kpi-value" style="color:var(--amber)">${Math.round((convCount+intCount)/done.length*100)}%</p><p style="font-size:11px;color:var(--t3)">${convCount+intCount}/${done.length}</p></div>
<div class="kpi-card"><p class="kpi-label">Avg Score</p><p class="kpi-value" style="color:${scoreColor(avgScore)}">${avgScore.toFixed(1)}</p><p style="font-size:11px;color:var(--t3)">/ 5.0</p></div>
<div class="kpi-card"><p class="kpi-label">Drop Rate</p><p class="kpi-value" style="color:var(--red)">${Math.round(dropCount/done.length*100)}%</p><p style="font-size:11px;color:var(--t3)">${dropCount}/${done.length}</p></div>`
// Funnel
const total = done.length
document.getElementById('funnelView').innerHTML = [
['Started', total, 'var(--purple)'],
['Engaged', total, 'var(--blue)'],
['Interested', convCount+intCount, 'var(--amber)'],
['Converted', convCount, 'var(--green)'],
].map(([label,count,color]) => {
const pct = Math.max((count/total)*100, 8)
return `<div class="funnel-stage"><span style="width:80px;font-size:11px;color:var(--t3);font-weight:600">${label}</span>
<div class="funnel-bar" style="width:${pct}%;background:${color}">${count}</div></div>`
}).join('')
// Matrix
const scoreKeys = [...new Set(done.flatMap(p => Object.keys(results[p.id].scores||{})))]
document.getElementById('matrixTable').innerHTML = `<table class="score-table">
<thead><tr><th>Persona</th><th>Status</th>${scoreKeys.map(k=>`<th>${k}</th>`).join('')}<th>Overall</th></tr></thead>
<tbody>${done.map(p => {
const r = results[p.id], cs = r.conversion_status
const cc = cs==='converted'?'conv-converted':cs==='interested'?'conv-interested':'conv-dropped'
const ov = avg(Object.values(r.scores||{}))
return `<tr style="cursor:pointer" onclick="selectedId=${p.id};switchTab('live')">
<td style="display:flex;align-items:center;gap:8px"><div class="persona-avatar" style="width:26px;height:26px;font-size:10px">${p.name.charAt(0)}</div>${p.name}</td>
<td><span class="conv-badge ${cc}">${CONV_ICONS[cs]||''} ${cs}</span></td>
${scoreKeys.map(k=>`<td style="color:${scoreColor(r.scores[k]||0)};font-weight:700">${(r.scores[k]||0).toFixed(1)}</td>`).join('')}
<td style="color:${scoreColor(ov)};font-weight:800">${ov.toFixed(1)}</td></tr>`
}).join('')}</tbody></table>`
}
// ── SYNTHESIS ─────────────────────────────────────────
async function runSynthesis() {
const done = personas.filter(p => results[p.id])
if (!done.length) return
switchTab('insights')
document.getElementById('insightsContent').innerHTML = '<div style="text-align:center;padding:40px 0"><div class="spinner" style="width:24px;height:24px;border-width:3px;margin-bottom:8px"></div><p style="color:var(--t2);font-size:13px">AI đang phân tích...</p></div>'
try {
const resp = await fetch('/api/user-simulator/synthesize', {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({
chatbot_prompt: '', system_prompt: '',
results: done.map(p => ({
persona_name:p.name, archetype:p.archetype, scores:results[p.id].scores,
conversion_status:results[p.id].conversion_status||'', conversion_reason:results[p.id].conversion_reason||'',
key_wins:results[p.id].key_wins||[], key_fails:results[p.id].key_fails||[],
}))
})
})
const d = await resp.json()
if (d.status === 'success') { synthesis = d; renderInsights(d) }
else throw new Error(d.message||'Fail')
} catch(e) { document.getElementById('insightsContent').innerHTML = `<div style="padding:20px;color:var(--red)">Li: ${e.message}</div>` }
}
function renderInsights(d) {
const ic = document.getElementById('insightsContent')
ic.innerHTML = `
<div class="card" style="margin-bottom:14px;background:rgba(139,92,246,.08);border-color:rgba(139,92,246,.2)">
<p class="insight-label" style="color:var(--purple)">📋 Executive Summary</p>
<p style="font-size:13px;color:var(--t1);line-height:1.6">${d.summary||'N/A'}</p>
</div>
${d.systemic_issues?.length?`<div class="card" style="margin-bottom:14px">
<p class="insight-label" style="color:var(--red)">⚠️ Systemic Issues</p>
${d.systemic_issues.map((s,i)=>`<p style="font-size:12.5px;color:var(--t2);margin-bottom:6px;line-height:1.5">${i+1}. ${s}</p>`).join('')}
</div>`:''}
${d.prompt_fixes?.length?`<div class="card" style="margin-bottom:14px">
<p class="insight-label" style="color:var(--green)">🔧 Prompt Fixes</p>
${d.prompt_fixes.map(f=>`<div style="background:rgba(16,185,129,.06);border-radius:8px;padding:10px 12px;margin-bottom:8px;border:1px solid rgba(16,185,129,.15)"><p style="font-size:12px;color:var(--t1);line-height:1.5">${f}</p></div>`).join('')}
</div>`:''}`
}
// ── PROGRESS BAR ──────────────────────────────────────
function showProgress(cur, total) {
const bar = document.getElementById('progressBar'); bar.style.display = 'flex'
document.getElementById('progressLabel').textContent = cur+'/'+total+' personas'
document.getElementById('progressFill').style.width = (total?cur/total*100:0)+'%'
if (simStartTime) {
const s = Math.floor((Date.now()-simStartTime)/1000)
document.getElementById('progressTime').textContent = Math.floor(s/60)+':'+String(s%60).padStart(2,'0')
}
}
function hideProgress() { document.getElementById('progressBar').style.display = 'none' }
// ── RENDER ALL ────────────────────────────────────────
function renderAll() { renderTabs(); renderActions(); renderPersonaGrid() }
renderAll()
// ═══════════════════════════════════════════ // ═══════════════════════════════════════════
// MOCK DATA — Canifa CDP Users // USER INSIGHT DATA — Mock + Real API
// Loads from /api/user-insights/all first,
// falls back to MOCK_USERS if API unavailable
// ═══════════════════════════════════════════ // ═══════════════════════════════════════════
const USERS = [
let USERS = [];
let DATA_SOURCE = 'mock'; // 'api' or 'mock'
const MOCK_USERS = [
{ {
id:1, name:"Nguyễn Thị Lan", email:"lan.nguyen@gmail.com", phone:"+84 912 345 678", id:1, name:"Nguyễn Thị Lan", email:"lan.nguyen@gmail.com", phone:"+84 912 345 678",
userId:"CNF-20240156", gender:"Nữ", avatar_color:"#6366F1", userId:"CNF-20240156", gender:"Nữ", age:35, dob:"1991-03-15", avatar_color:"#6366F1",
location:"Quận Cầu Giấy, Hà Nội", membership:"VIP Gold", join_date:"2024-01-12",
channels:["Zalo OA","Web","Email"], channels:["Zalo OA","Web","Email"],
aov:485000, total_orders:12, total_spent:5820000, aov:485000, total_orders:12, total_spent:5820000,
health_score:82, health_score:82,
rfm:{recency:90, frequency:75, monetary:68}, rfm:{recency:90, frequency:75, monetary:68},
predictions:{next_purchase:"Quần jean nữ",next_purchase_conf:78,churn_risk:12,clv_6m:2400000,next_visit_days:3, // Chatbot insight (6-layer từ AI)
chatbot_insight:{
USER:"Nữ, 35 tuổi, mẹ bỉm sữa. Gu: basic, thoải mái, ưu tiên cotton organic.",
TARGET:"Con trai 5 tuổi, thích màu xanh navy và đỏ. Size 110-120cm.",
GOAL:"Mua quần áo trẻ em cho con đi học + đi chơi. Dịp: đầu năm học mới.",
CONSTRAINS:"Budget 300k-600k/bộ. Chỉ muốn cotton 100%. Tránh polyester. Size 5T-6T.",
LATEST_PRODUCT_INTEREST:"Áo thun bé trai Basic Cotton [6TS24B005] - Xanh navy",
LAST_ACTION:"Bot đã gợi ý 3 combo áo+quần bé trai dưới 500k, khách hỏi thêm về chính sách đổi size.",
SUMMARY_HISTORY:"Khách hỏi về áo thun bé trai → bot gợi ý → khách compare giá → hỏi size chart → hỏi combo deal → hỏi đổi trả"
},
predictions:{next_purchase:"Quần jean bé trai",next_purchase_conf:78,churn_risk:12,clv_6m:2400000,next_visit_days:3,
recommended_collab:[ recommended_collab:[
{name:"Quần Jean Slim Nữ",icon:"👖",reason:"Đã xem 3 lần, chưa mua",conf:85}, {name:"Quần Jean Slim Bé Trai",icon:"👖",reason:"Đã xem 3 lần combo với áo thun",conf:85},
{name:"Set Áo + Quần Combo",icon:"👚",reason:"Hay mua combo giảm giá",conf:72}, {name:"Set Áo + Quần Combo",icon:"👚",reason:"Hay hỏi combo giảm giá",conf:72},
{name:"Túi tote canvas",icon:"👜",reason:"Cross-sell phụ kiện",conf:58} {name:"Balo trẻ em",icon:"🎒",reason:"Cross-sell phụ kiện trẻ em",conf:58}
], ],
recommended_basket:[ recommended_basket:[
{name:"Áo khoác jean",icon:"🧥",reason:"75% mua jean cũng mua",conf:75}, {name:"Áo khoác gió bé trai",icon:"🧥",reason:"75% mua quần áo cũng mua",conf:75},
{name:"Thắt lưng da nữ",icon:"🪢",reason:"Mua kèm 40% đơn jean",conf:52}, {name:"Tất cotton trẻ em x3",icon:"🧦",reason:"Add-on phổ biến đơn trẻ em",conf:52},
{name:"Khăn choàng cổ",icon:"🧣",reason:"Phụ kiện mùa đông",conf:45} {name:"Khẩu trang trẻ em",icon:"😷",reason:"Cross-sell tiện ích",conf:45}
], ],
recommended_trending:[ recommended_trending:[
{name:"Váy midi hoa nhí",icon:"👗",reason:"#1 trending nữ 25-35",conf:80}, {name:"Váy midi hoa nhí",icon:"👗",reason:"#1 trending nữ 25-35",conf:80},
...@@ -29,43 +46,51 @@ const USERS = [ ...@@ -29,43 +46,51 @@ const USERS = [
email_metrics:{delivered:42,opens:24,open_rate:57,click_rate:14,conversion_rate:8.5}, email_metrics:{delivered:42,opens:24,open_rate:57,click_rate:14,conversion_rate:8.5},
segments:{purchase_likelihood:"High",discount_affinity:"Medium",customer_value:"VIP",lifecycle:"Active Customer"}, segments:{purchase_likelihood:"High",discount_affinity:"Medium",customer_value:"VIP",lifecycle:"Active Customer"},
top_categories:[ top_categories:[
{name:"Áo thun nữ",pct:30,color:"#6366F1"},{name:"Váy đầm",pct:22,color:"#F59E0B"}, {name:"Trẻ em bé trai",pct:35,color:"#6366F1"},{name:"Áo thun nữ",pct:22,color:"#F59E0B"},
{name:"Quần jean",pct:18,color:"#10B981"},{name:"Áo khoác",pct:15,color:"#EF4444"}, {name:"Quần jean",pct:18,color:"#10B981"},{name:"Áo khoác",pct:15,color:"#EF4444"},
{name:"Phụ kiện",pct:15,color:"#8B5CF6"} {name:"Phụ kiện",pct:10,color:"#8B5CF6"}
], ],
last_attrs:{"Last Seen":"canifa.com/nu","Last Email Open":"Summer Sale 2026","Last Visited Category":"Áo thun nữ","Last Abandoned Cart":"320,000đ","Last Visited Product":"Áo thun Basic Cotton"}, last_attrs:{"Last Seen":"canifa.com/tre-em/be-trai","Last Email Open":"Summer Sale 2026","Last Visited Category":"Áo thun bé trai","Last Abandoned Cart":"320,000đ","Last Visited Product":"Áo thun Basic Cotton Bé Trai"},
products:{last_visited:{name:"Áo thun Basic Cotton",price:"249,000đ",icon:"👕"},last_purchased:{name:"Váy hoa nhí cổ V",price:"599,000đ",icon:"👗"},last_abandoned:{name:"Quần jean slim fit",price:"459,000đ",icon:"👖"}}, products:{last_visited:{name:"Áo thun Basic Cotton Bé Trai",price:"249,000đ",icon:"👕"},last_purchased:{name:"Set quần áo bé trai",price:"459,000đ",icon:"👖"},last_abandoned:{name:"Combo 3 áo thun bé trai",price:"320,000đ",icon:"👕"}},
mobile:{app_opened:8,push_opened:12,inapp_seen:5,inapp_from_push:3}, mobile:{app_opened:8,push_opened:12,inapp_seen:5,inapp_from_push:3},
journey:{entered:9,completed:6}, journey:{entered:9,completed:6},
purchase_timeline:[ purchase_timeline:[
{date:"22/03/2026",name:"Áo thun Basic Cotton x2",price:"498,000đ",icon:"👕",channel:"Web"}, {date:"22/03/2026",name:"Áo thun bé trai x2",price:"498,000đ",icon:"👕",channel:"Web"},
{date:"15/03/2026",name:"Váy hoa nhí cổ V",price:"599,000đ",icon:"👗",channel:"Zalo"}, {date:"15/03/2026",name:"Set quần áo bé trai",price:"459,000đ",icon:"👖",channel:"Zalo"},
{date:"28/02/2026",name:"Quần jean skinny nữ",price:"459,000đ",icon:"👖",channel:"Web"}, {date:"28/02/2026",name:"Quần jean bé trai skinny",price:"359,000đ",icon:"👖",channel:"Web"},
{date:"14/02/2026",name:"Set quà Valentine (áo+túi)",price:"750,000đ",icon:"🎁",channel:"Web"}, {date:"14/02/2026",name:"Áo khoác gió bé trai",price:"450,000đ",icon:"🧥",channel:"Web"},
{date:"20/01/2026",name:"Áo khoác dạ nữ",price:"890,000đ",icon:"🧥",channel:"Zalo"} {date:"20/01/2026",name:"Áo len cổ lọ bé trai",price:"390,000đ",icon:"🧶",channel:"Zalo"}
], ],
next_best_actions:[ next_best_actions:[
{icon:"📧",title:"Gửi email giới thiệu BST Jean mới",desc:"Conf 85% — dựa trên browsing behavior + abandoned cart jean",urgency:"urgent"}, {icon:"📧",title:"Gửi email BST Back-to-School",desc:"Conf 85% — mua đồ cho con đi học, timing cuối tháng 8",urgency:"urgent"},
{icon:"🎁",title:"Push combo 'Mua 2 giảm 15%'",desc:"Phù hợp hành vi mua combo, tăng AOV",urgency:"medium"}, {icon:"🎁",title:"Push combo 'Mua 2 giảm 15%' trẻ em",desc:"Phù hợp hành vi mua combo, tăng AOV",urgency:"medium"},
{icon:"📱",title:"Nhắc nhở giỏ hàng bỏ quên (320k)",desc:"Abandoned 2 ngày trước, tỉ lệ recovery 40%",urgency:"urgent"} {icon:"📱",title:"Nhắc cart bỏ quên (320k combo)",desc:"Abandoned 2 ngày trước, recovery rate 40%",urgency:"urgent"}
],
engagement_heatmap:[
[0,1,2,0,1,3,2],[1,2,3,1,2,4,3],[0,0,1,0,0,2,1],[0,1,0,1,0,1,0]
], ],
engagement_heatmap:[[0,1,2,0,1,3,2],[1,2,3,1,2,4,3],[0,0,1,0,0,2,1],[0,1,0,1,0,1,0]],
chatbot_history:[ chatbot_history:[
{role:"user",text:"Cho mình xem áo thun cotton nữ màu trắng size M"}, {role:"user",text:"Cho mình xem áo thun cotton cho bé trai 5 tuổi"},
{role:"bot",text:"Dạ chị ơi, em có Áo Thun Basic Cotton Nữ (6TS24S003) màu trắng, size M còn hàng. Giá 249,000đ."}, {role:"bot",text:"Dạ chị ơi, em có nhiều mẫu áo thun cotton bé trai size 5T (110cm). Giá từ 149k-249k. Chị muốn xem màu nào ạ?"},
{role:"user",text:"Có khuyến mãi gì không em?"}, {role:"user",text:"Xanh navy hoặc đỏ, có combo nào giảm giá không?"},
{role:"bot",text:"Hiện tại sản phẩm đang có chương trình Mua 2 giảm 15% chị ạ!"} {role:"bot",text:"Dạ có Combo 3 áo thun Basic Cotton bé trai chỉ 320k (tiết kiệm 30%) ạ! Có xanh navy, đỏ và trắng. Chị muốn em check size 5T không?"}
] ]
}, },
{ {
id:2, name:"Trần Văn Minh", email:"minh.tran@outlook.com", phone:"+84 903 456 789", id:2, name:"Trần Văn Minh", email:"minh.tran@outlook.com", phone:"+84 903 456 789",
userId:"CNF-20240089", gender:"Nam", avatar_color:"#059669", userId:"CNF-20240089", gender:"Nam", age:42, dob:"1984-07-22", avatar_color:"#059669",
location:"Quận 7, TP HCM", membership:"Regular", join_date:"2024-03-05",
channels:["Zalo OA","Web"], channels:["Zalo OA","Web"],
aov:720000, total_orders:8, total_spent:5760000, aov:720000, total_orders:8, total_spent:5760000,
health_score:61, health_score:61,
rfm:{recency:55, frequency:50, monetary:72}, rfm:{recency:55, frequency:50, monetary:72},
chatbot_insight:{
USER:"Nam, 42 tuổi, IT manager. Gu: smart casual, công sở, không quá formal.",
TARGET:"Bản thân (đi làm) + Vợ (35 tuổi, mua quà).",
GOAL:"Tìm áo sơ mi đi làm + quà sinh nhật cho vợ.",
CONSTRAINS:"Budget sơ mi 400k-600k. Quà vợ 500k-1tr. Size M nam, size S nữ. Không thích hoa văn quá nhiều.",
LATEST_PRODUCT_INTEREST:"Sơ mi Oxford Slim [8SM24S012] - Trắng/xanh nhạt",
LAST_ACTION:"Bot đã gợi ý 2 sơ mi + set quà cho vợ (váy + khăn). Khách hỏi về gói quà tặng.",
SUMMARY_HISTORY:"Hỏi sơ mi công sở → bot gợi ý Oxford → khách OK → chuyển sang hỏi quà vợ → bot gợi ý váy + phụ kiện"
},
predictions:{next_purchase:"Áo sơ mi",next_purchase_conf:62,churn_risk:25,clv_6m:1800000,next_visit_days:7, predictions:{next_purchase:"Áo sơ mi",next_purchase_conf:62,churn_risk:25,clv_6m:1800000,next_visit_days:7,
recommended_collab:[{name:"Sơ mi Oxford Slim",icon:"👔",reason:"Mua sơ mi mỗi quý",conf:70},{name:"Thắt lưng da",icon:"🪢",reason:"Cross-sell phụ kiện nam",conf:55},{name:"Polo Dry-fit",icon:"👕",reason:"Tương tự SP đã mua",conf:48}], recommended_collab:[{name:"Sơ mi Oxford Slim",icon:"👔",reason:"Mua sơ mi mỗi quý",conf:70},{name:"Thắt lưng da",icon:"🪢",reason:"Cross-sell phụ kiện nam",conf:55},{name:"Polo Dry-fit",icon:"👕",reason:"Tương tự SP đã mua",conf:48}],
recommended_basket:[{name:"Quần âu slim",icon:"👖",reason:"Kèm sơ mi 65% đơn",conf:68},{name:"Tất cổ thấp",icon:"🧦",reason:"Add-on phổ biến",conf:42},{name:"Nước hoa mini",icon:"🧴",reason:"Gift set combo",conf:35}], recommended_basket:[{name:"Quần âu slim",icon:"👖",reason:"Kèm sơ mi 65% đơn",conf:68},{name:"Tất cổ thấp",icon:"🧦",reason:"Add-on phổ biến",conf:42},{name:"Nước hoa mini",icon:"🧴",reason:"Gift set combo",conf:35}],
...@@ -73,32 +98,42 @@ const USERS = [ ...@@ -73,32 +98,42 @@ const USERS = [
}, },
email_metrics:{delivered:28,opens:10,open_rate:36,click_rate:7,conversion_rate:3.2}, email_metrics:{delivered:28,opens:10,open_rate:36,click_rate:7,conversion_rate:3.2},
segments:{purchase_likelihood:"Medium",discount_affinity:"Low",customer_value:"Regular",lifecycle:"Active Customer"}, segments:{purchase_likelihood:"Medium",discount_affinity:"Low",customer_value:"Regular",lifecycle:"Active Customer"},
top_categories:[{name:"Polo nam",pct:35,color:"#059669"},{name:"Quần kaki",pct:25,color:"#6366F1"},{name:"Áo sơ mi",pct:20,color:"#F59E0B"},{name:"Giày dép",pct:12,color:"#EF4444"},{name:"Khác",pct:8,color:"#8B5CF6"}], top_categories:[{name:"Sơ mi nam",pct:35,color:"#059669"},{name:"Quần âu",pct:25,color:"#6366F1"},{name:"Polo nam",pct:20,color:"#F59E0B"},{name:"Giày dép",pct:12,color:"#EF4444"},{name:"Quà tặng",pct:8,color:"#8B5CF6"}],
last_attrs:{"Last Seen":"canifa.com/nam","Last Email Open":"New Arrivals Men","Last Visited Category":"Polo nam","Last Abandoned Cart":"—","Last Visited Product":"Polo Pique Classic"}, last_attrs:{"Last Seen":"canifa.com/nam/so-mi","Last Email Open":"New Arrivals Men","Last Visited Category":"Sơ mi nam","Last Abandoned Cart":"—","Last Visited Product":"Sơ mi Oxford Slim"},
products:{last_visited:{name:"Polo Pique Classic",price:"399,000đ",icon:"👔"},last_purchased:{name:"Quần kaki slim",price:"549,000đ",icon:"👖"},last_abandoned:{name:"—",price:"—",icon:"📦"}}, products:{last_visited:{name:"Sơ mi Oxford Slim",price:"499,000đ",icon:"👔"},last_purchased:{name:"Quần kaki slim",price:"549,000đ",icon:"👖"},last_abandoned:{name:"—",price:"—",icon:"📦"}},
mobile:{app_opened:3,push_opened:5,inapp_seen:2,inapp_from_push:1}, mobile:{app_opened:3,push_opened:5,inapp_seen:2,inapp_from_push:1},
journey:{entered:4,completed:3}, journey:{entered:4,completed:3},
purchase_timeline:[ purchase_timeline:[
{date:"18/03/2026",name:"Polo Pique Classic x1",price:"399,000đ",icon:"👔",channel:"Web"}, {date:"18/03/2026",name:"Sơ mi Oxford Slim x2",price:"998,000đ",icon:"👔",channel:"Web"},
{date:"02/03/2026",name:"Quần kaki slim",price:"549,000đ",icon:"👖",channel:"Web"}, {date:"02/03/2026",name:"Quần kaki slim",price:"549,000đ",icon:"👖",channel:"Web"},
{date:"15/01/2026",name:"Sơ mi Oxford trắng",price:"499,000đ",icon:"👔",channel:"Zalo"} {date:"15/01/2026",name:"Sơ mi Oxford trắng",price:"499,000đ",icon:"👔",channel:"Zalo"}
], ],
next_best_actions:[ next_best_actions:[
{icon:"📧",title:"Email BST Polo mùa hè mới",desc:"Conf 70% — top category, thời điểm mua quý",urgency:"medium"}, {icon:"📧",title:"Email BST Polo mùa hè mới",desc:"Conf 70% — top category, thời điểm mua quý",urgency:"medium"},
{icon:"🔔",title:"Push thông báo New Arrivals",desc:"Open rate push cao hơn email 2x",urgency:"low"}, {icon:"🎁",title:"Gợi ý set quà cho vợ",desc:"Đã hỏi về quà sinh nhật, tạo combo voucher gift",urgency:"urgent"},
{icon:"🎯",title:"Upsell sơ mi premium tier",desc:"AOV cao, có thể nâng phân khúc",urgency:"medium"} {icon:"🎯",title:"Upsell sơ mi premium tier",desc:"AOV cao, có thể nâng phân khúc",urgency:"medium"}
], ],
engagement_heatmap:[[0,0,1,0,0,1,0],[0,1,1,1,0,2,1],[0,0,0,0,0,1,0],[0,0,0,0,0,0,0]], engagement_heatmap:[[0,0,1,0,0,1,0],[0,1,1,1,0,2,1],[0,0,0,0,0,1,0],[0,0,0,0,0,0,0]],
chatbot_history:[{role:"user",text:"Polo nam có mấy màu?"},{role:"bot",text:"Dạ anh, dòng Polo Pique Classic có 6 màu: Trắng, Đen, Navy, Xanh rêu, Xám nhạt và Hồng pastel."}] chatbot_history:[{role:"user",text:"Sơ mi nam công sở, size M, budget 500k?"},{role:"bot",text:"Dạ anh, em gợi ý Sơ mi Oxford Slim (499k) và Sơ mi Linen (459k). Cả 2 đều có size M, phù hợp đi làm ạ!"}]
}, },
{ {
id:3, name:"Lê Phương Anh", email:"phuonganh.le@yahoo.com", phone:"+84 987 654 321", id:3, name:"Lê Phương Anh", email:"phuonganh.le@yahoo.com", phone:"+84 987 654 321",
userId:"CNF-20250012", gender:"Nữ", avatar_color:"#EC4899", userId:"CNF-20250012", gender:"Nữ", age:28, dob:"1998-12-01", avatar_color:"#EC4899",
location:"Quận Hoàn Kiếm, Hà Nội", membership:"VIP Platinum", join_date:"2025-01-03",
channels:["Zalo OA","Web","Email","SMS"], channels:["Zalo OA","Web","Email","SMS"],
aov:380000, total_orders:22, total_spent:8360000, aov:380000, total_orders:22, total_spent:8360000,
health_score:95, health_score:95,
rfm:{recency:95, frequency:92, monetary:78}, rfm:{recency:95, frequency:92, monetary:78},
predictions:{next_purchase:"Đồ mặc nhà",next_purchase_conf:91,churn_risk:5,clv_6m:3200000,next_visit_days:1, chatbot_insight:{
USER:"Nữ, 28 tuổi, designer freelance. Gu: minimalist, neutral tones, oversized fit.",
TARGET:"Bản thân. Thích loungewear & đồ mặc nhà cao cấp.",
GOAL:"Mua set pyjama lụa + đồ WFH thoải mái. Dịp: tự thưởng cuối tháng.",
CONSTRAINS:"Budget 300k-500k/set. Chỉ muốn neutral colors (beige, trắng, xám). Size S. Không thích họa tiết cartoon.",
LATEST_PRODUCT_INTEREST:"Set Pyjama Lụa [7PJ24W003] - Beige",
LAST_ACTION:"Bot gửi link 3 set pyjama lụa neutral. Khách add to cart set beige size S.",
SUMMARY_HISTORY:"Hỏi đồ mặc nhà → bot gợi ý cotton → khách muốn lụa → bot show 3 options → khách chọn beige → add cart → hỏi free ship"
},
predictions:{next_purchase:"Đồ mặc nhà lụa",next_purchase_conf:91,churn_risk:5,clv_6m:3200000,next_visit_days:1,
recommended_collab:[{name:"Set Pyjama Lụa Mới",icon:"👚",reason:"Loyal buyer, mua set mỗi tháng",conf:92},{name:"Áo len oversized",icon:"🧶",reason:"Top category #2",conf:78},{name:"Quần legging fleece",icon:"🩳",reason:"Mùa đông sắp tới",conf:65}], recommended_collab:[{name:"Set Pyjama Lụa Mới",icon:"👚",reason:"Loyal buyer, mua set mỗi tháng",conf:92},{name:"Áo len oversized",icon:"🧶",reason:"Top category #2",conf:78},{name:"Quần legging fleece",icon:"🩳",reason:"Mùa đông sắp tới",conf:65}],
recommended_basket:[{name:"Dép lông trong nhà",icon:"🩴",reason:"Mua kèm pyjama 55%",conf:60},{name:"Bộ chăn ga cotton",icon:"🛏️",reason:"Bundle deal đồ nhà",conf:48},{name:"Nến thơm",icon:"🕯️",reason:"Lifestyle add-on",conf:38}], recommended_basket:[{name:"Dép lông trong nhà",icon:"🩴",reason:"Mua kèm pyjama 55%",conf:60},{name:"Bộ chăn ga cotton",icon:"🛏️",reason:"Bundle deal đồ nhà",conf:48},{name:"Nến thơm",icon:"🕯️",reason:"Lifestyle add-on",conf:38}],
recommended_trending:[{name:"Set loungewear modal",icon:"👚",reason:"#1 trending đồ nhà",conf:88},{name:"Cardigan len mỏng",icon:"🧶",reason:"Rising +150% MoM",conf:70},{name:"Áo hoodie oversize",icon:"🧥",reason:"Hot item Gen Z",conf:55}] recommended_trending:[{name:"Set loungewear modal",icon:"👚",reason:"#1 trending đồ nhà",conf:88},{name:"Cardigan len mỏng",icon:"🧶",reason:"Rising +150% MoM",conf:70},{name:"Áo hoodie oversize",icon:"🧥",reason:"Hot item Gen Z",conf:55}]
...@@ -106,13 +141,13 @@ const USERS = [ ...@@ -106,13 +141,13 @@ const USERS = [
email_metrics:{delivered:68,opens:45,open_rate:66,click_rate:22,conversion_rate:14.7}, email_metrics:{delivered:68,opens:45,open_rate:66,click_rate:22,conversion_rate:14.7},
segments:{purchase_likelihood:"High",discount_affinity:"High",customer_value:"VIP",lifecycle:"Loyal Customer"}, segments:{purchase_likelihood:"High",discount_affinity:"High",customer_value:"VIP",lifecycle:"Loyal Customer"},
top_categories:[{name:"Đồ mặc nhà",pct:28,color:"#EC4899"},{name:"Áo len",pct:22,color:"#6366F1"},{name:"Quần legging",pct:20,color:"#10B981"},{name:"Áo thun",pct:18,color:"#F59E0B"},{name:"Khác",pct:12,color:"#8B5CF6"}], top_categories:[{name:"Đồ mặc nhà",pct:28,color:"#EC4899"},{name:"Áo len",pct:22,color:"#6366F1"},{name:"Quần legging",pct:20,color:"#10B981"},{name:"Áo thun",pct:18,color:"#F59E0B"},{name:"Khác",pct:12,color:"#8B5CF6"}],
last_attrs:{"Last Seen":"canifa.com/nu/do-mac-nha","Last Email Open":"Flash Sale Weekend","Last Visited Category":"Đồ mặc nhà","Last Abandoned Cart":"185,000đ","Last Visited Product":"Set Pyjama Lụa"}, last_attrs:{"Last Seen":"canifa.com/nu/do-mac-nha","Last Email Open":"Flash Sale Weekend","Last Visited Category":"Đồ mặc nhà","Last Abandoned Cart":"450,000đ","Last Visited Product":"Set Pyjama Lụa Beige"},
products:{last_visited:{name:"Set Pyjama Lụa",price:"450,000đ",icon:"👚"},last_purchased:{name:"Áo len cổ lọ",price:"399,000đ",icon:"🧶"},last_abandoned:{name:"Quần legging thể thao",price:"185,000đ",icon:"🩳"}}, products:{last_visited:{name:"Set Pyjama Lụa Beige",price:"450,000đ",icon:"👚"},last_purchased:{name:"Áo len cổ lọ oversized",price:"399,000đ",icon:"🧶"},last_abandoned:{name:"Set Pyjama Lụa Beige",price:"450,000đ",icon:"👚"}},
mobile:{app_opened:15,push_opened:22,inapp_seen:11,inapp_from_push:8}, mobile:{app_opened:15,push_opened:22,inapp_seen:11,inapp_from_push:8},
journey:{entered:14,completed:11}, journey:{entered:14,completed:11},
purchase_timeline:[ purchase_timeline:[
{date:"23/03/2026",name:"Set Pyjama Cotton",price:"279,000đ",icon:"👚",channel:"Email"}, {date:"23/03/2026",name:"Set Pyjama Cotton",price:"279,000đ",icon:"👚",channel:"Email"},
{date:"20/03/2026",name:"Áo len cổ lọ",price:"399,000đ",icon:"🧶",channel:"Web"}, {date:"20/03/2026",name:"Áo len cổ lọ oversized",price:"399,000đ",icon:"🧶",channel:"Web"},
{date:"10/03/2026",name:"Quần legging x2",price:"370,000đ",icon:"🩳",channel:"SMS"}, {date:"10/03/2026",name:"Quần legging x2",price:"370,000đ",icon:"🩳",channel:"SMS"},
{date:"28/02/2026",name:"Set Pyjama Lụa",price:"450,000đ",icon:"👚",channel:"Zalo"}, {date:"28/02/2026",name:"Set Pyjama Lụa",price:"450,000đ",icon:"👚",channel:"Zalo"},
{date:"14/02/2026",name:"Áo thun oversize x3",price:"447,000đ",icon:"👕",channel:"Web"}, {date:"14/02/2026",name:"Áo thun oversize x3",price:"447,000đ",icon:"👕",channel:"Web"},
...@@ -121,19 +156,29 @@ const USERS = [ ...@@ -121,19 +156,29 @@ const USERS = [
next_best_actions:[ next_best_actions:[
{icon:"⭐",title:"Nâng hạng VIP Platinum",desc:"Chỉ còn 1.6M nữa — 22 đơn trong 3 tháng",urgency:"medium"}, {icon:"⭐",title:"Nâng hạng VIP Platinum",desc:"Chỉ còn 1.6M nữa — 22 đơn trong 3 tháng",urgency:"medium"},
{icon:"📧",title:"Email early access BST mới",desc:"Loyal customer — cho xem trước 24h",urgency:"low"}, {icon:"📧",title:"Email early access BST mới",desc:"Loyal customer — cho xem trước 24h",urgency:"low"},
{icon:"🎁",title:"Tặng voucher sinh nhật 01/12",desc:"Auto-trigger voucher 150k, còn 8 tháng",urgency:"low"} {icon:"🛒",title:"Nhắc cart Pyjama Lụa Beige (450k)",desc:"Added to cart 1 ngày trước, chưa thanh toán",urgency:"urgent"}
], ],
engagement_heatmap:[[2,3,4,2,3,5,4],[3,4,5,3,4,6,5],[1,2,3,1,2,4,3],[1,1,2,1,1,2,1]], engagement_heatmap:[[2,3,4,2,3,5,4],[3,4,5,3,4,6,5],[1,2,3,1,2,4,3],[1,1,2,1,1,2,1]],
chatbot_history:[{role:"user",text:"Có set đồ mặc nhà nào đang sale không?"},{role:"bot",text:"Dạ chị ơi! Set Pyjama Cotton giảm 30% còn 279,000đ và set Pyjama Lụa giảm 20% còn 360,000đ ạ."},{role:"user",text:"Lụa nhé, còn size S không?"},{role:"bot",text:"Dạ set Pyjama Lụa size S còn 3 bộ (Hồng pastel và Xanh mint). Free ship cho đơn từ 300k chị ạ!"}] chatbot_history:[{role:"user",text:"Có set đồ mặc nhà nào đang sale không?"},{role:"bot",text:"Dạ chị ơi! Set Pyjama Cotton giảm 30% còn 279,000đ và set Pyjama Lụa giảm 20% còn 360,000đ ạ."},{role:"user",text:"Lụa nhé, còn size S không?"},{role:"bot",text:"Dạ set Pyjama Lụa size S còn 3 bộ (Beige và Xám nhạt). Free ship cho đơn từ 300k chị nhé!"}]
}, },
{ {
id:4, name:"Phạm Quốc Hùng", email:"hung.pham@canifa.vn", phone:"+84 911 222 333", id:4, name:"Phạm Quốc Hùng", email:"hung.pham@company.vn", phone:"+84 911 222 333",
userId:"CNF-20230445", gender:"Nam", avatar_color:"#D97706", userId:"CNF-20230445", gender:"Nam", age:38, dob:"1988-05-10", avatar_color:"#D97706",
location:"Quận Bình Thạnh, TP HCM", membership:"Premium", join_date:"2023-04-15",
channels:["Web"], channels:["Web"],
aov:1200000, total_orders:5, total_spent:6000000, aov:1200000, total_orders:5, total_spent:6000000,
health_score:35, health_score:35,
rfm:{recency:25, frequency:30, monetary:85}, rfm:{recency:25, frequency:30, monetary:85},
predictions:{next_purchase:"Áo khoác",next_purchase_conf:35,churn_risk:68,clv_6m:800000,next_visit_days:21, chatbot_insight:{
USER:"Nam, 38 tuổi, giám đốc kinh doanh. Gu: premium, formal, thích brand cao cấp.",
TARGET:"Bản thân (đi meeting, event).",
GOAL:"Tìm blazer wool cho mùa đông, cần sang trọng.",
CONSTRAINS:"Budget 1tr-2tr. Size L. Chỉ muốn màu tối (đen, navy, charcoal). Không muốn synthetic fabric.",
LATEST_PRODUCT_INTEREST:"Áo Blazer Wool [8BL24W001] - Navy",
LAST_ACTION:"Bot gợi ý Blazer Wool + quần âu matching. Khách abandoned cart 2 lần.",
SUMMARY_HISTORY:"Hỏi blazer premium → bot gợi ý → khách add cart → abandoned → quay lại hỏi → abandoned lần 2"
},
predictions:{next_purchase:"Áo khoác blazer",next_purchase_conf:35,churn_risk:68,clv_6m:800000,next_visit_days:21,
recommended_collab:[{name:"Áo Blazer Wool",icon:"🧥",reason:"Abandoned cart x2",conf:60},{name:"Sơ mi premium",icon:"👔",reason:"Phân khúc cao cấp",conf:45},{name:"Gift card Canifa",icon:"🎁",reason:"Win-back campaign",conf:40}], recommended_collab:[{name:"Áo Blazer Wool",icon:"🧥",reason:"Abandoned cart x2",conf:60},{name:"Sơ mi premium",icon:"👔",reason:"Phân khúc cao cấp",conf:45},{name:"Gift card Canifa",icon:"🎁",reason:"Win-back campaign",conf:40}],
recommended_basket:[{name:"Quần âu wool blend",icon:"👖",reason:"Kèm blazer 70% đơn",conf:62},{name:"Cà vạt lụa",icon:"👔",reason:"Business set",conf:45},{name:"Giày leather",icon:"👞",reason:"Premium bundle",conf:38}], recommended_basket:[{name:"Quần âu wool blend",icon:"👖",reason:"Kèm blazer 70% đơn",conf:62},{name:"Cà vạt lụa",icon:"👔",reason:"Business set",conf:45},{name:"Giày leather",icon:"👞",reason:"Premium bundle",conf:38}],
recommended_trending:[{name:"Áo khoác bomber",icon:"🧥",reason:"Trending nam 30+",conf:50},{name:"Polo premium pima",icon:"👔",reason:"Premium tier bestseller",conf:42},{name:"Belt da Ý",icon:"🪢",reason:"Rising luxury segment",conf:35}] recommended_trending:[{name:"Áo khoác bomber",icon:"🧥",reason:"Trending nam 30+",conf:50},{name:"Polo premium pima",icon:"👔",reason:"Premium tier bestseller",conf:42},{name:"Belt da Ý",icon:"🪢",reason:"Rising luxury segment",conf:35}]
...@@ -141,8 +186,8 @@ const USERS = [ ...@@ -141,8 +186,8 @@ const USERS = [
email_metrics:{delivered:15,opens:3,open_rate:20,click_rate:3,conversion_rate:1.5}, email_metrics:{delivered:15,opens:3,open_rate:20,click_rate:3,conversion_rate:1.5},
segments:{purchase_likelihood:"Low",discount_affinity:"Low",customer_value:"Premium",lifecycle:"At-Risk"}, segments:{purchase_likelihood:"Low",discount_affinity:"Low",customer_value:"Premium",lifecycle:"At-Risk"},
top_categories:[{name:"Áo khoác nam",pct:40,color:"#D97706"},{name:"Sơ mi",pct:30,color:"#6366F1"},{name:"Quần âu",pct:20,color:"#10B981"},{name:"Khác",pct:10,color:"#EF4444"}], top_categories:[{name:"Áo khoác nam",pct:40,color:"#D97706"},{name:"Sơ mi",pct:30,color:"#6366F1"},{name:"Quần âu",pct:20,color:"#10B981"},{name:"Khác",pct:10,color:"#EF4444"}],
last_attrs:{"Last Seen":"canifa.com/nam/ao-khoac","Last Email Open":"—","Last Visited Category":"Áo khoác nam","Last Abandoned Cart":"1,290,000đ","Last Visited Product":"Áo Blazer Wool"}, last_attrs:{"Last Seen":"canifa.com/nam/ao-khoac","Last Email Open":"—","Last Visited Category":"Áo khoác nam","Last Abandoned Cart":"1,290,000đ","Last Visited Product":"Áo Blazer Wool Navy"},
products:{last_visited:{name:"Áo Blazer Wool",price:"1,290,000đ",icon:"🧥"},last_purchased:{name:"Sơ mi Oxford",price:"499,000đ",icon:"👔"},last_abandoned:{name:"Áo Blazer Wool",price:"1,290,000đ",icon:"🧥"}}, products:{last_visited:{name:"Áo Blazer Wool Navy",price:"1,290,000đ",icon:"🧥"},last_purchased:{name:"Sơ mi Oxford",price:"499,000đ",icon:"👔"},last_abandoned:{name:"Áo Blazer Wool Navy",price:"1,290,000đ",icon:"🧥"}},
mobile:{app_opened:1,push_opened:0,inapp_seen:0,inapp_from_push:0}, mobile:{app_opened:1,push_opened:0,inapp_seen:0,inapp_from_push:0},
journey:{entered:2,completed:1}, journey:{entered:2,completed:1},
purchase_timeline:[ purchase_timeline:[
...@@ -155,15 +200,25 @@ const USERS = [ ...@@ -155,15 +200,25 @@ const USERS = [
{icon:"📞",title:"Gọi điện chăm sóc VIP",desc:"Premium tier — personal touch có thể giữ chân",urgency:"medium"} {icon:"📞",title:"Gọi điện chăm sóc VIP",desc:"Premium tier — personal touch có thể giữ chân",urgency:"medium"}
], ],
engagement_heatmap:[[0,0,0,0,0,0,0],[0,0,0,0,0,1,0],[0,0,0,0,0,0,0],[0,0,0,0,0,0,0]], engagement_heatmap:[[0,0,0,0,0,0,0],[0,0,0,0,0,1,0],[0,0,0,0,0,0,0],[0,0,0,0,0,0,0]],
chatbot_history:[] chatbot_history:[{role:"user",text:"Blazer wool nam, size L, navy?"},{role:"bot",text:"Dạ anh, em có Blazer Wool Slim Fit (1,290,000đ) màu Navy, size L còn hàng. Chất wool blend 70%, lót viscose. Phù hợp đi meeting ạ!"}]
}, },
{ {
id:5, name:"Đỗ Thanh Hà", email:"ha.do@gmail.com", phone:"+84 978 111 222", id:5, name:"Đỗ Thanh Hà", email:"ha.do@gmail.com", phone:"+84 978 111 222",
userId:"CNF-20260034", gender:"Nữ", avatar_color:"#7C3AED", userId:"CNF-20260034", gender:"Nữ", age:20, dob:"2006-06-18", avatar_color:"#7C3AED",
channels:["Zalo OA","Web","Email"], location:"Quận Đống Đa, Hà Nội", membership:"New Member", join_date:"2026-03-01",
channels:["Zalo OA","Web","TikTok"],
aov:295000, total_orders:3, total_spent:885000, aov:295000, total_orders:3, total_spent:885000,
health_score:72, health_score:72,
rfm:{recency:80, frequency:35, monetary:30}, rfm:{recency:80, frequency:35, monetary:30},
chatbot_insight:{
USER:"Nữ, 20 tuổi, sinh viên ĐH Kinh tế. Gu: trendy, GenZ, thích crop top & Y2K.",
TARGET:"Bản thân, mua đi chơi + đi học.",
GOAL:"Tìm áo crop top trendy, giá rẻ. Hay mua theo trend TikTok.",
CONSTRAINS:"Budget dưới 300k. Size XS/S. Thích pastel & màu neon. Ghét basic boring.",
LATEST_PRODUCT_INTEREST:"Crop Top Ribbed [7CT24S005] - Hồng pastel",
LAST_ACTION:"Bot show 3 crop top dưới 200k. Khách thích Ribbed nhưng chưa mua vì hết pastel size XS.",
SUMMARY_HISTORY:"Hỏi crop top trendy → bot list 3 options → khách thích ribbed → hết size → bot gợi ý size S → khách cân nhắc"
},
predictions:{next_purchase:"Áo crop top",next_purchase_conf:82,churn_risk:18,clv_6m:1500000,next_visit_days:2, predictions:{next_purchase:"Áo crop top",next_purchase_conf:82,churn_risk:18,clv_6m:1500000,next_visit_days:2,
recommended_collab:[{name:"Crop Top Ribbed",icon:"👕",reason:"Abandoned cart, giá rẻ",conf:88},{name:"Quần short denim",icon:"🩳",reason:"Combo mùa hè",conf:72},{name:"Kính mát",icon:"🕶️",reason:"Phụ kiện Gen Z",conf:50}], recommended_collab:[{name:"Crop Top Ribbed",icon:"👕",reason:"Abandoned cart, giá rẻ",conf:88},{name:"Quần short denim",icon:"🩳",reason:"Combo mùa hè",conf:72},{name:"Kính mát",icon:"🕶️",reason:"Phụ kiện Gen Z",conf:50}],
recommended_basket:[{name:"Sandal quai chéo",icon:"👡",reason:"Combo mùa hè 60%",conf:65},{name:"Túi đeo chéo mini",icon:"👜",reason:"Cross-sell Gen Z",conf:55},{name:"Nón bucket",icon:"🧢",reason:"Add-on trending",conf:45}], recommended_basket:[{name:"Sandal quai chéo",icon:"👡",reason:"Combo mùa hè 60%",conf:65},{name:"Túi đeo chéo mini",icon:"👜",reason:"Cross-sell Gen Z",conf:55},{name:"Nón bucket",icon:"🧢",reason:"Add-on trending",conf:45}],
...@@ -172,54 +227,115 @@ const USERS = [ ...@@ -172,54 +227,115 @@ const USERS = [
email_metrics:{delivered:8,opens:6,open_rate:75,click_rate:25,conversion_rate:12.0}, email_metrics:{delivered:8,opens:6,open_rate:75,click_rate:25,conversion_rate:12.0},
segments:{purchase_likelihood:"High",discount_affinity:"High",customer_value:"New",lifecycle:"New Customer"}, segments:{purchase_likelihood:"High",discount_affinity:"High",customer_value:"New",lifecycle:"New Customer"},
top_categories:[{name:"Áo crop top",pct:40,color:"#7C3AED"},{name:"Quần short",pct:30,color:"#F59E0B"},{name:"Phụ kiện",pct:18,color:"#10B981"},{name:"Khác",pct:12,color:"#EF4444"}], top_categories:[{name:"Áo crop top",pct:40,color:"#7C3AED"},{name:"Quần short",pct:30,color:"#F59E0B"},{name:"Phụ kiện",pct:18,color:"#10B981"},{name:"Khác",pct:12,color:"#EF4444"}],
last_attrs:{"Last Seen":"canifa.com/nu/ao-croptop","Last Email Open":"Welcome Series #2","Last Visited Category":"Áo crop top","Last Abandoned Cart":"195,000đ","Last Visited Product":"Crop Top Ribbed"}, last_attrs:{"Last Seen":"canifa.com/nu/ao-croptop","Last Email Open":"Welcome Series #2","Last Visited Category":"Áo crop top","Last Abandoned Cart":"195,000đ","Last Visited Product":"Crop Top Ribbed Hồng"},
products:{last_visited:{name:"Crop Top Ribbed",price:"195,000đ",icon:"👕"},last_purchased:{name:"Quần short kaki",price:"299,000đ",icon:"🩳"},last_abandoned:{name:"Crop Top Ribbed",price:"195,000đ",icon:"👕"}}, products:{last_visited:{name:"Crop Top Ribbed Hồng",price:"195,000đ",icon:"👕"},last_purchased:{name:"Quần short kaki",price:"299,000đ",icon:"🩳"},last_abandoned:{name:"Crop Top Ribbed Hồng",price:"195,000đ",icon:"👕"}},
mobile:{app_opened:12,push_opened:8,inapp_seen:6,inapp_from_push:4}, mobile:{app_opened:12,push_opened:8,inapp_seen:6,inapp_from_push:4},
journey:{entered:3,completed:2}, journey:{entered:3,completed:2},
purchase_timeline:[ purchase_timeline:[
{date:"21/03/2026",name:"Quần short kaki",price:"299,000đ",icon:"🩳",channel:"Web"}, {date:"21/03/2026",name:"Quần short kaki",price:"299,000đ",icon:"🩳",channel:"Web"},
{date:"15/03/2026",name:"Áo crop top basic x2",price:"298,000đ",icon:"👕",channel:"Zalo"}, {date:"15/03/2026",name:"Áo crop top basic x2",price:"298,000đ",icon:"👕",channel:"TikTok Shop"},
{date:"10/03/2026",name:"Nón bucket cotton",price:"149,000đ",icon:"🧢",channel:"Web"} {date:"10/03/2026",name:"Nón bucket cotton",price:"149,000đ",icon:"🧢",channel:"Web"}
], ],
next_best_actions:[ next_best_actions:[
{icon:"🛒",title:"Nhắc abandoned cart Crop Top 195k",desc:"Viewed 5x, abandoned 1x — very hot lead",urgency:"urgent"}, {icon:"🛒",title:"Nhắc cart Crop Top Ribbed 195k",desc:"Viewed 5x, abandoned 1x — very hot lead",urgency:"urgent"},
{icon:"🎉",title:"Gửi Welcome Series #3",desc:"New customer — nurture với content mùa hè",urgency:"low"}, {icon:"📱",title:"Push TikTok sale notification",desc:"High TikTok engagement — push > email cho GenZ",urgency:"medium"},
{icon:"📱",title:"Push notification flash sale",desc:"High mobile engagement — push > email",urgency:"medium"} {icon:"🎉",title:"Gửi Welcome voucher 10%",desc:"New customer — nurture với content mùa hè",urgency:"low"}
], ],
engagement_heatmap:[[1,2,3,1,2,4,3],[2,3,4,2,3,5,4],[0,1,2,0,1,3,2],[0,0,1,0,0,1,0]], engagement_heatmap:[[1,2,3,1,2,4,3],[2,3,4,2,3,5,4],[0,1,2,0,1,3,2],[0,0,1,0,0,1,0]],
chatbot_history:[{role:"user",text:"Có áo croptop nào dưới 200k không?"},{role:"bot",text:"Dạ chị, em có 3 mẫu Crop Top dưới 200k: Crop Top Basic (149k), Crop Top Ribbed (195k), và Crop Henley (189k)."}] chatbot_history:[{role:"user",text:"có áo croptop nào dưới 200k k 💀"},{role:"bot",text:"Dạ chị, em có 3 mẫu Crop Top dưới 200k: Basic (149k), Ribbed (195k), và Henley (189k). Ribbed đang hot nhất ạ! 🔥"}]
}, },
{ {
id:6, name:"Bùi Đức Thịnh", email:"thinh.bui@company.com", phone:"+84 966 333 444", id:6, name:"Bùi Đức Thịnh", email:"thinh.bui@company.com", phone:"+84 966 333 444",
userId:"CNF-20240201", gender:"Nam", avatar_color:"#2563EB", userId:"CNF-20240201", gender:"Nam", age:30, dob:"1996-09-14", avatar_color:"#2563EB",
location:"Quận Thanh Xuân, Hà Nội", membership:"VIP Silver", join_date:"2024-02-01",
channels:["Zalo OA","Web","Email"], channels:["Zalo OA","Web","Email"],
aov:520000, total_orders:15, total_spent:7800000, aov:520000, total_orders:15, total_spent:7800000,
health_score:88, health_score:88,
rfm:{recency:85, frequency:82, monetary:75}, rfm:{recency:85, frequency:82, monetary:75},
predictions:{next_purchase:"Đồ thể thao",next_purchase_conf:75,churn_risk:8,clv_6m:2800000,next_visit_days:4, chatbot_insight:{
USER:"Nam, 30 tuổi, nhân viên gym. Gu: sporty, athleisure, thích Dryfit.",
TARGET:"Bản thân (gym + chạy bộ).",
GOAL:"Mua đồ thể thao, quần jogger mới + áo tập gym.",
CONSTRAINS:"Budget 300k-600k/item. Size M. Chỉ muốn Dryfit/tech fabric. Không thích cotton (thấm mồ hôi).",
LATEST_PRODUCT_INTEREST:"Quần Jogger Tech v2 [8JG24S008] - Đen",
LAST_ACTION:"Bot gợi ý set gym (áo tank + jogger). Khách mua áo Dryfit, jogger cân nhắc.",
SUMMARY_HISTORY:"Hỏi đồ gym → bot gợi ý Dryfit → khách mua ngay áo → hỏi thêm jogger → cân nhắc v1 vs v2"
},
predictions:{next_purchase:"Quần jogger",next_purchase_conf:75,churn_risk:8,clv_6m:2800000,next_visit_days:4,
recommended_collab:[{name:"Quần Jogger Tech v2",icon:"👖",reason:"Bản nâng cấp SP cũ",conf:80},{name:"Áo gió thể thao",icon:"🧥",reason:"Bổ sung set",conf:68},{name:"Giày sneaker",icon:"👟",reason:"Category #4, chưa mua",conf:52}], recommended_collab:[{name:"Quần Jogger Tech v2",icon:"👖",reason:"Bản nâng cấp SP cũ",conf:80},{name:"Áo gió thể thao",icon:"🧥",reason:"Bổ sung set",conf:68},{name:"Giày sneaker",icon:"👟",reason:"Category #4, chưa mua",conf:52}],
recommended_basket:[{name:"Bình nước thể thao",icon:"🧴",reason:"Add-on 45% đơn sportswear",conf:50},{name:"Tất thể thao x3",icon:"🧦",reason:"Bundle deal phổ biến",conf:42},{name:"Túi gym",icon:"🎒",reason:"Cross-sell lifestyle",conf:38}], recommended_basket:[{name:"Bình nước thể thao",icon:"🧴",reason:"Add-on 45% đơn sportswear",conf:50},{name:"Tất thể thao x3",icon:"🧦",reason:"Bundle deal phổ biến",conf:42},{name:"Túi gym",icon:"🎒",reason:"Cross-sell lifestyle",conf:38}],
recommended_trending:[{name:"Áo tank top dry-fit",icon:"👕",reason:"#1 sportswear nam",conf:72},{name:"Quần short chạy bộ",icon:"🩳",reason:"Rising +90%",conf:60},{name:"Áo compression",icon:"👕",reason:"New category hot",conf:48}] recommended_trending:[{name:"Áo tank top dry-fit",icon:"👕",reason:"#1 sportswear nam",conf:72},{name:"Quần short chạy bộ",icon:"🩳",reason:"Rising +90%",conf:60},{name:"Áo compression",icon:"👕",reason:"New category hot",conf:48}]
}, },
email_metrics:{delivered:55,opens:32,open_rate:58,click_rate:18,conversion_rate:9.1}, email_metrics:{delivered:55,opens:32,open_rate:58,click_rate:18,conversion_rate:9.1},
segments:{purchase_likelihood:"High",discount_affinity:"Medium",customer_value:"VIP",lifecycle:"Loyal Customer"}, segments:{purchase_likelihood:"High",discount_affinity:"Medium",customer_value:"VIP",lifecycle:"Loyal Customer"},
top_categories:[{name:"Áo thun nam",pct:32,color:"#2563EB"},{name:"Quần jogger",pct:24,color:"#10B981"},{name:"Đồ thể thao",pct:20,color:"#F59E0B"},{name:"Giày sneaker",pct:14,color:"#EF4444"},{name:"Khác",pct:10,color:"#8B5CF6"}], top_categories:[{name:"Đồ thể thao",pct:35,color:"#2563EB"},{name:"Quần jogger",pct:24,color:"#10B981"},{name:"Áo thun nam",pct:21,color:"#F59E0B"},{name:"Giày sneaker",pct:12,color:"#EF4444"},{name:"Phụ kiện",pct:8,color:"#8B5CF6"}],
last_attrs:{"Last Seen":"canifa.com/nam/the-thao","Last Email Open":"Member Day","Last Visited Category":"Đồ thể thao","Last Abandoned Cart":"—","Last Visited Product":"Quần Jogger Tech"}, last_attrs:{"Last Seen":"canifa.com/nam/the-thao","Last Email Open":"Member Day Sale","Last Visited Category":"Đồ thể thao","Last Abandoned Cart":"—","Last Visited Product":"Quần Jogger Tech v2"},
products:{last_visited:{name:"Quần Jogger Tech",price:"429,000đ",icon:"👖"},last_purchased:{name:"Áo thun Dryfit",price:"349,000đ",icon:"👕"},last_abandoned:{name:"—",price:"—",icon:"📦"}}, products:{last_visited:{name:"Quần Jogger Tech v2",price:"429,000đ",icon:"👖"},last_purchased:{name:"Áo thun Dryfit x2",price:"698,000đ",icon:"👕"},last_abandoned:{name:"—",price:"—",icon:"📦"}},
mobile:{app_opened:20,push_opened:18,inapp_seen:9,inapp_from_push:7}, mobile:{app_opened:20,push_opened:18,inapp_seen:9,inapp_from_push:7},
journey:{entered:11,completed:9}, journey:{entered:11,completed:9},
purchase_timeline:[ purchase_timeline:[
{date:"22/03/2026",name:"Áo thun Dryfit x2",price:"698,000đ",icon:"👕",channel:"Web"}, {date:"22/03/2026",name:"Áo thun Dryfit x2",price:"698,000đ",icon:"👕",channel:"Web"},
{date:"18/03/2026",name:"Quần Jogger Tech",price:"429,000đ",icon:"👖",channel:"Zalo"}, {date:"18/03/2026",name:"Quần Jogger Tech v1",price:"399,000đ",icon:"👖",channel:"Zalo"},
{date:"05/03/2026",name:"Set đồ thể thao (áo+quần)",price:"650,000đ",icon:"🏃",channel:"Web"}, {date:"05/03/2026",name:"Set đồ thể thao (áo+quần)",price:"650,000đ",icon:"🏃",channel:"Web"},
{date:"20/02/2026",name:"Giày sneaker trắng",price:"890,000đ",icon:"👟",channel:"Web"} {date:"20/02/2026",name:"Giày sneaker trắng",price:"890,000đ",icon:"👟",channel:"Web"}
], ],
next_best_actions:[ next_best_actions:[
{icon:"🏋️",title:"Email BST thể thao mùa hè",desc:"Top buyer sportswear — early access",urgency:"low"}, {icon:"🏋️",title:"Push Jogger Tech v2 upgrade",desc:"Đã mua v1, giờ v2 ra → conf 80% upgrade",urgency:"urgent"},
{icon:"⭐",title:"Mời tham gia chương trình Member Referral",desc:"Loyal + high engagement → ambassador potential",urgency:"medium"}, {icon:"⭐",title:"Mời referral program",desc:"Loyal + high engagement → ambassador potential",urgency:"medium"},
{icon:"📱",title:"Push deal bundle 'Gym Set'",desc:"Áo + Quần + Giày combo giảm 25%",urgency:"medium"} {icon:"📱",title:"Push deal bundle 'Gym Set'",desc:"Áo + Quần + Giày combo giảm 25%",urgency:"medium"}
], ],
engagement_heatmap:[[1,2,3,2,2,4,3],[2,3,4,3,3,5,4],[1,1,2,1,1,3,2],[0,1,1,1,0,1,1]], engagement_heatmap:[[1,2,3,2,2,4,3],[2,3,4,3,3,5,4],[1,1,2,1,1,3,2],[0,1,1,1,0,1,1]],
chatbot_history:[{role:"user",text:"Quần jogger nam có size 2XL không?"},{role:"bot",text:"Dạ anh, Quần Jogger Tech hiện có từ S đến XL. Size 2XL hiện chưa có ạ. Anh có thể thử Quần Jogger Relaxed Fit, rộng hơn!"}] chatbot_history:[{role:"user",text:"Quần jogger tech v2 khác v1 gì anh?"},{role:"bot",text:"Dạ anh, v2 nâng cấp: vải 4-way stretch, túi zip chống rơi, thêm lưới thoáng khí ở đùi. Giá 429k (v1 là 399k). Đáng upgrade ạ!"}]
} }
]; ];
// ═══ TRY LOAD FROM API, FALLBACK TO MOCK ═══
async function loadInsightData() {
try {
const resp = await fetch('/api/user-insights/all');
const data = await resp.json();
if (data.status === 'success' && data.insights && data.insights.length > 0) {
// Merge API insights into mock users by identity_key or append as new
data.insights.forEach((apiUser, idx) => {
const existing = MOCK_USERS.find(u => u.userId === apiUser.identity_key);
if (existing && apiUser.insight) {
existing.chatbot_insight = apiUser.insight;
existing._source = 'api';
} else if (apiUser.insight) {
// Create a new user entry from API data
const insight = apiUser.insight;
MOCK_USERS.push({
id: 100 + idx, name: apiUser.identity_key,
email: "—", phone: "—", userId: apiUser.identity_key,
gender: insight.USER?.includes("Nữ") ? "Nữ" : insight.USER?.includes("Nam") ? "Nam" : "—",
age: 0, dob: "—", avatar_color: ["#6366F1","#059669","#EC4899","#D97706","#7C3AED","#2563EB"][idx%6],
location: "—", membership: "—", join_date: "—", channels: ["Chatbot"],
aov: 0, total_orders: apiUser.message_count || 0, total_spent: 0,
health_score: 50, rfm: {recency:50,frequency:50,monetary:50},
chatbot_insight: insight,
predictions: {next_purchase:"—",next_purchase_conf:0,churn_risk:50,clv_6m:0,next_visit_days:0,
recommended_collab:[],recommended_basket:[],recommended_trending:[]},
email_metrics: {delivered:0,opens:0,open_rate:0,click_rate:0,conversion_rate:0},
segments: {purchase_likelihood:"Unknown",discount_affinity:"Unknown",customer_value:"Unknown",lifecycle:"Unknown"},
top_categories: [], last_attrs: {},
products: {last_visited:{name:"—",price:"—",icon:"📦"},last_purchased:{name:"—",price:"—",icon:"📦"},last_abandoned:{name:"—",price:"—",icon:"📦"}},
mobile: {app_opened:0,push_opened:0,inapp_seen:0,inapp_from_push:0},
journey: {entered:0,completed:0}, purchase_timeline: [],
next_best_actions: [], engagement_heatmap: [[0,0,0,0,0,0,0],[0,0,0,0,0,0,0],[0,0,0,0,0,0,0],[0,0,0,0,0,0,0]],
chatbot_history: [], _source: 'api'
});
}
});
DATA_SOURCE = 'api+mock';
console.log(`✅ Loaded ${data.insights.length} insights from API + ${MOCK_USERS.length} total users`);
}
} catch (e) {
console.log('ℹ️ API not available, using mock data only');
}
USERS = MOCK_USERS;
if (typeof renderUserList === 'function') renderUserList();
}
// Auto-load on page init
USERS = MOCK_USERS;
loadInsightData();
// ═══════════════════════════════════════════ // ═══════════════════════════════════════════
// USER INSIGHT — RENDER ENGINE // USER INSIGHT — RENDER ENGINE v2
// Renders full user profile + 6-layer chatbot insight
// ═══════════════════════════════════════════ // ═══════════════════════════════════════════
let selectedUserId = null; let selectedUserId = null;
const fmt = n => new Intl.NumberFormat('vi-VN').format(n) + 'đ'; const fmt = n => new Intl.NumberFormat('vi-VN').format(n) + 'đ';
...@@ -13,13 +14,13 @@ const segC = v => { ...@@ -13,13 +14,13 @@ const segC = v => {
// ═══ USER LIST ═══ // ═══ USER LIST ═══
function renderUserList(filter=''){ function renderUserList(filter=''){
const el=document.getElementById('userItems'); const el=document.getElementById('userItems');
const f=USERS.filter(u=>!filter||u.name.toLowerCase().includes(filter.toLowerCase())||u.email.toLowerCase().includes(filter.toLowerCase())||u.phone.includes(filter)); const f=USERS.filter(u=>!filter||u.name.toLowerCase().includes(filter.toLowerCase())||(u.email||'').toLowerCase().includes(filter.toLowerCase())||(u.phone||'').includes(filter));
el.innerHTML=f.map(u=>` el.innerHTML=f.map(u=>`
<div class="user-item ${selectedUserId===u.id?'active':''}" onclick="selectUser(${u.id})"> <div class="user-item ${selectedUserId===u.id?'active':''}" onclick="selectUser(${u.id})">
<div class="user-avatar" style="background:${u.avatar_color}">${u.name.charAt(0)}</div> <div class="user-avatar" style="background:${u.avatar_color}">${u.name.charAt(0)}</div>
<div class="user-item-info"> <div class="user-item-info">
<div class="user-item-name">${u.name}</div> <div class="user-item-name">${u.name}</div>
<div class="user-item-meta">${u.email}</div> <div class="user-item-meta">${u.age?u.age+'t, ':''}${u.gender||''} ${u.location?'· '+u.location.split(',')[0]:''}</div>
</div> </div>
<div class="user-item-right"> <div class="user-item-right">
<div class="val" style="color:${u.health_score>=70?'#059669':u.health_score>=40?'#D97706':'#DC2626'}">${u.health_score}</div> <div class="val" style="color:${u.health_score>=70?'#059669':u.health_score>=40?'#D97706':'#DC2626'}">${u.health_score}</div>
...@@ -45,6 +46,7 @@ function healthRing(score){ ...@@ -45,6 +46,7 @@ function healthRing(score){
// ═══ DONUT CHART ═══ // ═══ DONUT CHART ═══
function buildDonut(cats){ function buildDonut(cats){
if(!cats||!cats.length) return '<div style="text-align:center;color:#9CA3AF;padding:20px">Chưa có dữ liệu</div>';
const s=140,cx=70,cy=70,r=50,sw=20,ci=2*Math.PI*r;let off=0; const s=140,cx=70,cy=70,r=50,sw=20,ci=2*Math.PI*r;let off=0;
const segs=cats.map(c=>{const l=(c.pct/100)*ci;const s2=`<circle cx="${cx}" cy="${cy}" r="${r}" fill="none" stroke="${c.color}" stroke-width="${sw}" stroke-dasharray="${l} ${ci-l}" stroke-dashoffset="${-off}" transform="rotate(-90 ${cx} ${cy})"/>`;off+=l;return s2}); const segs=cats.map(c=>{const l=(c.pct/100)*ci;const s2=`<circle cx="${cx}" cy="${cy}" r="${r}" fill="none" stroke="${c.color}" stroke-width="${sw}" stroke-dasharray="${l} ${ci-l}" stroke-dashoffset="${-off}" transform="rotate(-90 ${cx} ${cy})"/>`;off+=l;return s2});
return `<svg class="donut-svg" viewBox="0 0 ${s} ${s}">${segs.join('')}</svg>`; return `<svg class="donut-svg" viewBox="0 0 ${s} ${s}">${segs.join('')}</svg>`;
...@@ -58,6 +60,7 @@ function factorBar(label,score){ ...@@ -58,6 +60,7 @@ function factorBar(label,score){
// ═══ HEATMAP ═══ // ═══ HEATMAP ═══
function buildHeatmap(data){ function buildHeatmap(data){
if(!data||!data.length) return '<div style="text-align:center;color:#9CA3AF;padding:20px">Chưa có dữ liệu</div>';
const days=['Mon','Tue','Wed','Thu','Fri','Sat','Sun']; const days=['Mon','Tue','Wed','Thu','Fri','Sat','Sun'];
const slots=['Sáng','Trưa','Chiều','Tối']; const slots=['Sáng','Trưa','Chiều','Tối'];
const maxV=Math.max(...data.flat(),1); const maxV=Math.max(...data.flat(),1);
...@@ -70,30 +73,61 @@ function buildHeatmap(data){ ...@@ -70,30 +73,61 @@ function buildHeatmap(data){
// ═══ RECO SECTION ═══ // ═══ RECO SECTION ═══
function recoBlock(label,emoji,items){ function recoBlock(label,emoji,items){
if(!items||!items.length) return '';
return `<div class="reco-section"><div class="reco-strat-label">${emoji} ${label}</div><div class="reco-grid">${items.map(r=>` return `<div class="reco-section"><div class="reco-strat-label">${emoji} ${label}</div><div class="reco-grid">${items.map(r=>`
<div class="reco-item"><span class="reco-icon">${r.icon}</span><div><div class="reco-name">${r.name}</div><div class="reco-reason">${r.reason}</div></div><span class="reco-conf" style="color:${r.conf>=70?'#059669':r.conf>=50?'#D97706':'#9CA3AF'}">${r.conf}%</span></div> <div class="reco-item"><span class="reco-icon">${r.icon}</span><div><div class="reco-name">${r.name}</div><div class="reco-reason">${r.reason}</div></div><span class="reco-conf" style="color:${r.conf>=70?'#059669':r.conf>=50?'#D97706':'#9CA3AF'}">${r.conf}%</span></div>
`).join('')}</div></div>`; `).join('')}</div></div>`;
} }
// ═══ CHATBOT INSIGHT RENDER (6-layer) ═══
function renderChatbotInsight(insight) {
if (!insight) return '<div style="text-align:center;color:#9CA3AF;padding:30px">Chatbot chưa phân tích user này</div>';
const layers = [
{ key:'USER', icon:'👤', label:'Người chat', color:'#6366F1', bgColor:'#EEF2FF' },
{ key:'TARGET', icon:'🎯', label:'Đối tượng thụ hưởng', color:'#059669', bgColor:'#F0FDF4' },
{ key:'GOAL', icon:'🛒', label:'Mục tiêu mua sắm', color:'#D97706', bgColor:'#FFFBEB' },
{ key:'CONSTRAINS', icon:'🔒', label:'Ràng buộc cứng', color:'#DC2626', bgColor:'#FEF2F2' },
{ key:'LATEST_PRODUCT_INTEREST', icon:'💎', label:'Sản phẩm quan tâm gần nhất', color:'#7C3AED', bgColor:'#F5F3FF' },
{ key:'LAST_ACTION', icon:'⚡', label:'Hành động bot vừa thực hiện', color:'#2563EB', bgColor:'#EFF6FF' },
{ key:'SUMMARY_HISTORY', icon:'📝', label:'Tóm tắt lịch sử chat', color:'#64748B', bgColor:'#F8FAFC' },
];
return `<div class="insight-layers">${layers.map(l => {
const val = insight[l.key];
if (!val || val === 'Chưa rõ.' || val === 'Chưa có.' || val === 'Chưa có' || val === '') return '';
return `<div class="insight-layer" style="border-left:3px solid ${l.color};background:${l.bgColor}">
<div class="insight-layer-header">
<span class="insight-layer-icon">${l.icon}</span>
<span class="insight-layer-label" style="color:${l.color}">${l.label}</span>
</div>
<div class="insight-layer-value">${val}</div>
</div>`;
}).join('')}</div>`;
}
// ═══ MAIN RENDER ═══ // ═══ MAIN RENDER ═══
function renderProfile(u){ function renderProfile(u){
if(!u)return; if(!u)return;
document.getElementById('profileEmpty').style.display='none'; document.getElementById('profileEmpty').style.display='none';
const el=document.getElementById('profileContent'); const el=document.getElementById('profileContent');
el.style.display='block'; el.style.display='block';
const p=u.predictions; const p=u.predictions||{};
const chC=p.churn_risk>=50?'pred-red':p.churn_risk>=20?'pred-amber':'pred-green'; const chC=(p.churn_risk||0)>=50?'pred-red':(p.churn_risk||0)>=20?'pred-amber':'pred-green';
const coC=p.next_purchase_conf>=70?'pred-green':p.next_purchase_conf>=40?'pred-amber':'pred-red'; const coC=(p.next_purchase_conf||0)>=70?'pred-green':(p.next_purchase_conf||0)>=40?'pred-amber':'pred-red';
el.innerHTML=` el.innerHTML=`
<div class="profile-tabs"> <div class="profile-tabs">
<button class="tab-btn active" onclick="switchTab(this, 'tab-actual')">Tổng quan & Thực tế</button> <button class="tab-btn active" onclick="switchTab(this, 'tab-actual')">Tng quan</button>
<button class="tab-btn" onclick="switchTab(this, 'tab-insight')">
<span style="color:#6366F1">🧠</span> AI Chatbot Insight
</button>
<button class="tab-btn" onclick="switchTab(this, 'tab-ai')"> <button class="tab-btn" onclick="switchTab(this, 'tab-ai')">
<span style="color:#6366F1">✨</span> AI Phân tích & Đề xuất <span style="color:#6366F1"></span> AI Phân tích
</button> </button>
</div> </div>
<!-- TAB 1: THỰC TẾ (ACTUAL) --> <!-- TAB 1: TNG QUAN -->
<div id="tab-actual" class="tab-pane active"> <div id="tab-actual" class="tab-pane active">
<!-- OVERVIEW --> <!-- OVERVIEW -->
<div class="section"> <div class="section">
...@@ -101,12 +135,18 @@ function renderProfile(u){ ...@@ -101,12 +135,18 @@ function renderProfile(u){
<div class="overview-grid"> <div class="overview-grid">
<div class="overview-avatar" style="background:${u.avatar_color}">${u.name.charAt(0)}</div> <div class="overview-avatar" style="background:${u.avatar_color}">${u.name.charAt(0)}</div>
<div class="overview-details"> <div class="overview-details">
<div><div class="overview-field-label">Name</div><div class="overview-field-value">${u.name}</div></div> <div><div class="overview-field-label">Tên</div><div class="overview-field-value">${u.name}</div></div>
<div><div class="overview-field-label">Email</div><div class="overview-field-value">${u.email}</div></div> <div><div class="overview-field-label">Email</div><div class="overview-field-value">${u.email||'—'}</div></div>
<div><div class="overview-field-label">User ID</div><div class="overview-field-value">${u.userId}</div></div> <div><div class="overview-field-label">User ID</div><div class="overview-field-value">${u.userId}</div></div>
<div><div class="overview-field-label">Channels</div><div>${u.channels.map(c=>`<span class="channel-tag">${c}</span>`).join('')}</div></div> <div><div class="overview-field-label">Tui / Gii tính</div><div class="overview-field-value">${u.age||'—'} tuổi, ${u.gender||'—'}</div></div>
<div><div class="overview-field-label">Phone</div><div class="overview-field-value">${u.phone}</div></div> <div><div class="overview-field-label">Ngày sinh</div><div class="overview-field-value">${u.dob||'—'}</div></div>
<div><div class="overview-field-label">Tổng chi tiêu</div><div class="overview-field-value">${fmt(u.total_spent)}</div></div> <div><div class="overview-field-label">Membership</div><div class="overview-field-value"><span class="channel-tag" style="${u.membership?.includes('VIP')?'background:#FEF3C7;color:#92400E':u.membership?.includes('Premium')?'background:#EDE9FE;color:#5B21B6':''}">${u.membership||'—'}</span></div></div>
<div><div class="overview-field-label">Địa ch</div><div class="overview-field-value">${u.location||'—'}</div></div>
<div><div class="overview-field-label">SĐT</div><div class="overview-field-value">${u.phone||'—'}</div></div>
<div><div class="overview-field-label">Channels</div><div>${(u.channels||[]).map(c=>`<span class="channel-tag">${c}</span>`).join('')}</div></div>
<div><div class="overview-field-label">Ngày gia nhập</div><div class="overview-field-value">${u.join_date||'—'}</div></div>
<div><div class="overview-field-label">Tổng đơn</div><div class="overview-field-value">${u.total_orders||0} đơn</div></div>
<div><div class="overview-field-label">Tổng chi tiêu</div><div class="overview-field-value">${u.total_spent?fmt(u.total_spent):'—'}</div></div>
</div> </div>
</div> </div>
</div> </div>
...@@ -115,17 +155,17 @@ function renderProfile(u){ ...@@ -115,17 +155,17 @@ function renderProfile(u){
<div class="section"> <div class="section">
<div class="section-label">Customer Health Score & RFM Analysis</div> <div class="section-label">Customer Health Score & RFM Analysis</div>
<div class="health-row"> <div class="health-row">
${healthRing(u.health_score)} ${healthRing(u.health_score||0)}
<div class="health-factors"> <div class="health-factors">
${factorBar('Recency',u.rfm.recency)} ${factorBar('Recency',u.rfm?.recency||0)}
${factorBar('Frequency',u.rfm.frequency)} ${factorBar('Frequency',u.rfm?.frequency||0)}
${factorBar('Monetary',u.rfm.monetary)} ${factorBar('Monetary',u.rfm?.monetary||0)}
</div> </div>
</div> </div>
</div> </div>
<!-- EMAIL METRICS --> <!-- EMAIL METRICS -->
<div class="section"> ${u.email_metrics?.delivered ? `<div class="section">
<div class="section-label">Email Metrics (3 tháng)</div> <div class="section-label">Email Metrics (3 tháng)</div>
<div class="metrics-row"> <div class="metrics-row">
<div class="metric-cell"><div class="m-label">Delivered</div><div class="m-value">${u.email_metrics.delivered}</div></div> <div class="metric-cell"><div class="m-label">Delivered</div><div class="m-value">${u.email_metrics.delivered}</div></div>
...@@ -134,24 +174,22 @@ function renderProfile(u){ ...@@ -134,24 +174,22 @@ function renderProfile(u){
<div class="metric-cell"><div class="m-label">Click Rate</div><div class="m-value">${u.email_metrics.click_rate}<span class="m-unit">%</span></div></div> <div class="metric-cell"><div class="m-label">Click Rate</div><div class="m-value">${u.email_metrics.click_rate}<span class="m-unit">%</span></div></div>
<div class="metric-cell"><div class="m-label">Conversion</div><div class="m-value">${u.email_metrics.conversion_rate}<span class="m-unit">%</span></div></div> <div class="metric-cell"><div class="m-label">Conversion</div><div class="m-value">${u.email_metrics.conversion_rate}<span class="m-unit">%</span></div></div>
</div> </div>
</div> </div>` : ''}
<!-- PRODUCT CARDS --> <!-- PRODUCT CARDS -->
<div class="product-cards"> ${u.products?.last_visited?.name!=='—' ? `<div class="product-cards">
${['last_visited','last_purchased','last_abandoned'].map(k=>{const pr=u.products[k];const lbl=k==='last_visited'?'Last Visited':k==='last_purchased'?'Last Purchased':'Last Abandoned';return `<div class="product-card"><div class="product-card-icon">${pr.icon}</div><div class="product-card-info"><div class="product-card-label">${lbl}</div><div class="product-card-name">${pr.name}</div><div class="product-card-price">${pr.price}</div></div></div>`}).join('')} ${['last_visited','last_purchased','last_abandoned'].map(k=>{const pr=u.products[k];if(!pr||pr.name==='—')return'';const lbl=k==='last_visited'?'Last Visited':k==='last_purchased'?'Last Purchased':'Last Abandoned';return `<div class="product-card"><div class="product-card-icon">${pr.icon}</div><div class="product-card-info"><div class="product-card-label">${lbl}</div><div class="product-card-name">${pr.name}</div><div class="product-card-price">${pr.price}</div></div></div>`}).join('')}
</div> </div><div style="height:16px"></div>` : ''}
<div style="height:16px"></div>
<!-- PURCHASE TIMELINE --> <!-- PURCHASE TIMELINE -->
<div class="section"> ${u.purchase_timeline?.length ? `<div class="section">
<div class="section-label">Lịch sử mua hàng (Purchase Timeline)</div> <div class="section-label">Lịch sử mua hàng</div>
<div class="timeline"> <div class="timeline">
${u.purchase_timeline.map(t=>`<div class="tl-item"><div class="tl-date">${t.date} · ${t.channel}</div><div class="tl-content"><span class="tl-icon">${t.icon}</span><div class="tl-detail"><div class="tl-name">${t.name}</div></div><span class="tl-price">${t.price}</span></div></div>`).join('')} ${u.purchase_timeline.map(t=>`<div class="tl-item"><div class="tl-date">${t.date} · ${t.channel}</div><div class="tl-content"><span class="tl-icon">${t.icon}</span><div class="tl-detail"><div class="tl-name">${t.name}</div></div><span class="tl-price">${t.price}</span></div></div>`).join('')}
</div> </div>
</div> </div>` : ''}
<!-- ENGAGEMENT HEATMAP + MOBILE/JOURNEY --> <!-- ENGAGEMENT HEATMAP + MOBILE -->
<div class="two-col"> <div class="two-col">
<div class="section"> <div class="section">
<div class="section-label">Engagement Heatmap (tun gn nht)</div> <div class="section-label">Engagement Heatmap (tun gn nht)</div>
...@@ -160,66 +198,78 @@ function renderProfile(u){ ...@@ -160,66 +198,78 @@ function renderProfile(u){
<div class="section"> <div class="section">
<div class="section-label">Mobile & Journey</div> <div class="section-label">Mobile & Journey</div>
<div class="mini-metrics" style="margin-bottom:12px"> <div class="mini-metrics" style="margin-bottom:12px">
<div class="mini-cell"><div class="m-label">App</div><div class="m-value">${u.mobile.app_opened}</div></div> <div class="mini-cell"><div class="m-label">App</div><div class="m-value">${u.mobile?.app_opened||0}</div></div>
<div class="mini-cell"><div class="m-label">Push</div><div class="m-value">${u.mobile.push_opened}</div></div> <div class="mini-cell"><div class="m-label">Push</div><div class="m-value">${u.mobile?.push_opened||0}</div></div>
<div class="mini-cell"><div class="m-label">InApp</div><div class="m-value">${u.mobile.inapp_seen}</div></div> <div class="mini-cell"><div class="m-label">InApp</div><div class="m-value">${u.mobile?.inapp_seen||0}</div></div>
<div class="mini-cell"><div class="m-label">Push→InApp</div><div class="m-value">${u.mobile.inapp_from_push}</div></div> <div class="mini-cell"><div class="m-label">PushInApp</div><div class="m-value">${u.mobile?.inapp_from_push||0}</div></div>
</div> </div>
<div class="mini-metrics cols-2"> <div class="mini-metrics cols-2">
<div class="mini-cell"><div class="m-label">Journeys Entered</div><div class="m-value">${u.journey.entered}</div></div> <div class="mini-cell"><div class="m-label">Journeys Entered</div><div class="m-value">${u.journey?.entered||0}</div></div>
<div class="mini-cell"><div class="m-label">Completed</div><div class="m-value">${u.journey.completed}</div></div> <div class="mini-cell"><div class="m-label">Completed</div><div class="m-value">${u.journey?.completed||0}</div></div>
</div> </div>
</div> </div>
</div> </div>
<!-- CHAT HISTORY --> <!-- CHAT HISTORY -->
${u.chatbot_history.length?`<div class="section"><div class="section-label">Lịch sử Chat gần nhất</div><div class="chat-history">${u.chatbot_history.map(m=>`<div class="chat-msg ${m.role}">${m.text}</div>`).join('')}</div></div>`:''} ${u.chatbot_history?.length?`<div class="section"><div class="section-label">Lịch sử Chat gần nhất</div><div class="chat-history">${u.chatbot_history.map(m=>`<div class="chat-msg ${m.role}">${m.text}</div>`).join('')}</div></div>`:''}
</div> </div>
<!-- TAB 2: AI PHÂN TÍCH (AI ANALYSIS) --> <!-- TAB 2: AI CHATBOT INSIGHT (6-layer) -->
<div id="tab-insight" class="tab-pane">
<div class="section">
<div class="section-label">🧠 Chatbot AI đã phân tích ${u.name}</div>
<p style="font-size:12px;color:#6B7280;margin-bottom:16px">Insight 6 tng được chatbot t động to trong quá trình chat. D liu cp nht real-time.</p>
${renderChatbotInsight(u.chatbot_insight)}
</div>
<!-- CHAT HISTORY in insight tab too -->
${u.chatbot_history?.length?`<div class="section"><div class="section-label">💬 Cuộc chat đã phân tích</div><div class="chat-history" style="max-height:400px">${u.chatbot_history.map(m=>`<div class="chat-msg ${m.role}">${m.text}</div>`).join('')}</div></div>`:''}
</div>
<!-- TAB 3: AI PHÂN TÍCH -->
<div id="tab-ai" class="tab-pane"> <div id="tab-ai" class="tab-pane">
<!-- AI PREDICTIONS --> <!-- AI PREDICTIONS -->
<div class="section"> ${p.next_purchase ? `<div class="section">
<div class="section-label">⚡ AI Core Predictions</div> <div class="section-label">⚡ AI Core Predictions</div>
<div class="pred-grid"> <div class="pred-grid">
<div class="pred-card ${coC}"><div class="pred-icon">🛒</div><div class="pred-val">${p.next_purchase}</div><div class="pred-lbl">Mua tiếp (${p.next_purchase_conf}%)</div></div> <div class="pred-card ${coC}"><div class="pred-icon">🛒</div><div class="pred-val">${p.next_purchase}</div><div class="pred-lbl">Mua tiếp (${p.next_purchase_conf}%)</div></div>
<div class="pred-card ${chC}"><div class="pred-icon">⚠️</div><div class="pred-val">${p.churn_risk}%</div><div class="pred-lbl">Churn Risk</div></div> <div class="pred-card ${chC}"><div class="pred-icon">⚠️</div><div class="pred-val">${p.churn_risk}%</div><div class="pred-lbl">Churn Risk</div></div>
<div class="pred-card pred-blue"><div class="pred-icon">💰</div><div class="pred-val">${fmt(p.clv_6m)}</div><div class="pred-lbl">CLV 6 tháng</div></div> <div class="pred-card pred-blue"><div class="pred-icon">💰</div><div class="pred-val">${p.clv_6m?fmt(p.clv_6m):'—'}</div><div class="pred-lbl">CLV 6 tháng</div></div>
<div class="pred-card pred-blue"><div class="pred-icon">📅</div><div class="pred-val">${p.next_visit_days}d</div><div class="pred-lbl">Quay lại sau</div></div> <div class="pred-card pred-blue"><div class="pred-icon">📅</div><div class="pred-val">${p.next_visit_days||'—'}d</div><div class="pred-lbl">Quay lại sau</div></div>
</div> </div>
</div> </div>` : ''}
<!-- SEGMENTS + CATEGORIES + ATTRS --> <!-- SEGMENTS + CATEGORIES + ATTRS -->
<div class="three-col"> <div class="three-col">
<div class="section"> <div class="section">
<div class="section-label">Predictive Segments</div> <div class="section-label">Predictive Segments</div>
${Object.entries(u.segments).map(([k,v])=>`<div class="segment-row"><span class="seg-label">${segK(k)}</span><span class="seg-value ${segC(v)}">${v}</span></div>`).join('')} ${u.segments?Object.entries(u.segments).map(([k,v])=>`<div class="segment-row"><span class="seg-label">${segK(k)}</span><span class="seg-value ${segC(v)}">${v}</span></div>`).join(''):'<div style="color:#9CA3AF">Chưa có</div>'}
</div> </div>
<div class="section"> <div class="section">
<div class="section-label">Danh mc quan tâm</div> <div class="section-label">Danh mc quan tâm</div>
<div class="donut-wrap">${buildDonut(u.top_categories)}<div class="donut-legend">${u.top_categories.map(c=>`<div class="legend-row"><span class="legend-dot" style="background:${c.color}"></span><span>${c.name}</span><span class="legend-pct">${c.pct}%</span></div>`).join('')}</div></div> <div class="donut-wrap">${buildDonut(u.top_categories)}<div class="donut-legend">${(u.top_categories||[]).map(c=>`<div class="legend-row"><span class="legend-dot" style="background:${c.color}"></span><span>${c.name}</span><span class="legend-pct">${c.pct}%</span></div>`).join('')}</div></div>
</div> </div>
<div class="section"> <div class="section">
<div class="section-label">Predicted Last Attributes</div> <div class="section-label">Last Attributes</div>
${Object.entries(u.last_attrs).map(([k,v])=>`<div class="attr-row"><span class="attr-label">${k}</span><span class="attr-value">${v}</span></div>`).join('')} ${u.last_attrs?Object.entries(u.last_attrs).map(([k,v])=>`<div class="attr-row"><span class="attr-label">${k}</span><span class="attr-value">${v}</span></div>`).join(''):'<div style="color:#9CA3AF">Chưa có</div>'}
</div> </div>
</div> </div>
<!-- RECOMMENDATION ENGINE --> <!-- RECOMMENDATION ENGINE -->
<div class="section"> ${p.recommended_collab?.length ? `<div class="section">
<div class="section-label">🎯 Product Recommendation Engine</div> <div class="section-label">🎯 Product Recommendation Engine</div>
${recoBlock('Vì bạn đã mua (Collaborative Filtering)','🧠',p.recommended_collab)} ${recoBlock('Vì bạn đã mua (Collaborative Filtering)','🧠',p.recommended_collab)}
${recoBlock('Thường mua cùng (Market Basket Analysis)','🛒',p.recommended_basket)} ${recoBlock('Thường mua cùng (Market Basket Analysis)','🛒',p.recommended_basket)}
${recoBlock('Trending trong phân khúc','🔥',p.recommended_trending)} ${recoBlock('Trending trong phân khúc','🔥',p.recommended_trending)}
</div> </div>` : ''}
<!-- NEXT BEST ACTIONS --> <!-- NEXT BEST ACTIONS -->
<div class="section"> ${u.next_best_actions?.length ? `<div class="section">
<div class="section-label">🚀 Next Best Actions (AI Suggested)</div> <div class="section-label">🚀 Next Best Actions (AI Suggested)</div>
<div class="nba-grid"> <div class="nba-grid">
${u.next_best_actions.map(a=>`<div class="nba-card nba-${a.urgency}"><div class="nba-icon">${a.icon}</div><div class="nba-title">${a.title}</div><div class="nba-desc">${a.desc}</div><span class="nba-tag">${a.urgency==='urgent'?'URGENT':a.urgency==='medium'?'MEDIUM':'LOW'}</span></div>`).join('')} ${u.next_best_actions.map(a=>`<div class="nba-card nba-${a.urgency}"><div class="nba-icon">${a.icon}</div><div class="nba-title">${a.title}</div><div class="nba-desc">${a.desc}</div><span class="nba-tag">${a.urgency==='urgent'?'URGENT':a.urgency==='medium'?'MEDIUM':'LOW'}</span></div>`).join('')}
</div> </div>
</div> </div>` : ''}
</div> </div>
`; `;
document.getElementById('profilePane').scrollTop=0; document.getElementById('profilePane').scrollTop=0;
......
<!DOCTYPE html>
<html lang="vi">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Knowledge Graph — Canifa AI</title>
<link rel="stylesheet" href="/static/lab.css">
<link rel="stylesheet" href="/static/dashboard.css">
<script src="/static/frame-detect.js"></script>
<script src="https://d3js.org/d3.v7.min.js"></script>
<style>
/* ═══ PAGE LAYOUT ═══ */
body{margin:0;background:var(--bg);overflow:hidden;height:100vh}
/* ═══ TOP BAR ═══ */
.page-topbar{
height:54px;
background:var(--s);
border-bottom:1px solid var(--b);
display:flex;
align-items:center;
justify-content:space-between;
padding:0 24px;
flex-shrink:0;
}
.topbar-left{display:flex;align-items:center;gap:14px}
.topbar-icon{
width:34px;height:34px;
background:var(--t);
border-radius:10px;
display:flex;align-items:center;justify-content:center;
font-size:12px;font-weight:800;color:#FDE68A;
letter-spacing:.04em;
}
.topbar-title{font-size:15px;font-weight:700;color:var(--t)}
.topbar-subtitle{font-size:11px;color:var(--m);margin-top:1px}
.topbar-right{display:flex;align-items:center;gap:10px}
.topbar-stat{
display:flex;align-items:center;gap:6px;
padding:6px 14px;
background:var(--bg);
border:1px solid var(--b);
border-radius:8px;
font-size:12px;
}
.stat-dot{width:7px;height:7px;border-radius:50%}
.stat-dot.nodes{background:var(--diamond)}
.stat-dot.edges{background:var(--blue)}
.stat-dot.types{background:var(--gold)}
.stat-label{color:var(--m);font-weight:500}
.stat-value{color:var(--t);font-weight:700;font-family:'Fraunces',serif}
/* ═══ GRAPH CONTAINER ═══ */
.graph-container{
width:100%;
height:calc(100vh - 54px);
position:relative;
background:var(--bg);
}
.graph-svg{width:100%;height:100%;cursor:grab}
.graph-svg:active{cursor:grabbing}
/* ═══ CONTROLS ═══ */
.controls{
position:absolute;
top:16px;left:16px;
display:flex;flex-direction:column;gap:4px;
z-index:50;
}
.ctrl-btn{
width:38px;height:38px;
background:var(--s);
border:1px solid var(--b);
border-radius:var(--rs);
color:var(--m);
font-size:16px;
cursor:pointer;
display:flex;align-items:center;justify-content:center;
transition:all .2s;
font-family:'Outfit',sans-serif;
}
.ctrl-btn:hover{background:var(--bg);color:var(--t);border-color:var(--m)}
.ctrl-btn.active{background:var(--gold-l);color:var(--gold);border-color:var(--gold-b)}
.ctrl-divider{height:1px;background:var(--b);margin:4px 6px}
/* ═══ LEGEND ═══ */
.legend{
position:absolute;
bottom:16px;left:16px;
background:var(--s);
border:1px solid var(--b);
border-radius:var(--r);
padding:14px 18px;
z-index:50;
min-width:180px;
box-shadow:0 2px 8px rgba(0,0,0,0.04);
}
.legend-title{
font-size:10px;font-weight:700;color:var(--m);
text-transform:uppercase;letter-spacing:.1em;margin-bottom:10px;
}
.legend-items{display:flex;flex-direction:column;gap:7px}
.legend-item{display:flex;align-items:center;gap:10px;font-size:12px}
.legend-dot{width:10px;height:10px;border-radius:50%;flex-shrink:0;border:1px solid rgba(0,0,0,0.08)}
.legend-label{color:var(--m);font-weight:500}
.legend-count{margin-left:auto;color:var(--t);font-weight:700;font-size:12px;font-family:'Fraunces',serif}
/* ═══ DETAIL PANEL ═══ */
.detail-panel{
position:absolute;
top:16px;right:16px;
width:320px;
max-height:calc(100vh - 100px);
overflow-y:auto;
background:var(--s);
border:1px solid var(--b);
border-radius:var(--r);
z-index:50;
box-shadow:0 4px 16px rgba(0,0,0,0.06);
display:none;
animation:slideIn .2s ease;
}
.detail-panel.open{display:block}
@keyframes slideIn{
from{opacity:0;transform:translateX(12px)}
to{opacity:1;transform:translateX(0)}
}
.detail-header{
padding:14px 18px;
border-bottom:1px solid var(--b);
display:flex;align-items:center;justify-content:space-between;
}
.detail-type{display:flex;align-items:center;gap:8px}
.detail-type-dot{width:10px;height:10px;border-radius:50%}
.detail-type-label{
font-size:10px;font-weight:700;
text-transform:uppercase;letter-spacing:.08em;color:var(--m);
}
.detail-close{
background:none;border:none;color:var(--m);cursor:pointer;font-size:16px;
width:28px;height:28px;border-radius:var(--rs);
display:flex;align-items:center;justify-content:center;
transition:all .15s;
}
.detail-close:hover{background:var(--bg);color:var(--t)}
.detail-body{padding:16px 18px}
.detail-name{
font-size:16px;font-weight:700;
color:var(--t);margin-bottom:14px;line-height:1.3;
font-family:'Fraunces',serif;
}
.detail-row{
display:flex;align-items:flex-start;gap:8px;
padding:8px 0;
border-bottom:1px solid var(--b);
font-size:12px;
}
.detail-row:last-child{border-bottom:none}
.detail-key{color:var(--m);font-weight:600;min-width:80px;flex-shrink:0;text-transform:uppercase;font-size:10px;letter-spacing:.04em;padding-top:1px}
.detail-val{color:var(--t);line-height:1.5;font-weight:500}
.detail-conn-title{
font-size:10px;font-weight:700;color:var(--m);
text-transform:uppercase;letter-spacing:.08em;margin-bottom:8px;
}
.detail-conn-item{
display:flex;align-items:center;gap:6px;
padding:5px 0;font-size:12px;
}
.conn-arrow{font-weight:700;width:14px;font-size:11px}
.conn-arrow.out{color:var(--blue)}
.conn-arrow.in{color:var(--gold)}
.conn-label{color:var(--m);font-size:10px;font-weight:600;min-width:50px}
.conn-dot{width:6px;height:6px;border-radius:50%;flex-shrink:0}
.conn-name{color:var(--t);font-weight:500}
/* Edge detail */
.edge-detail-header{
padding:12px 18px;
background:var(--blue-l);
border-bottom:1px solid var(--b);
font-size:13px;color:var(--blue);font-weight:600;text-align:center;
}
.edge-arrow{color:var(--m);margin:0 6px}
/* ═══ SEARCH ═══ */
.search-box{
position:absolute;
top:16px;left:50%;transform:translateX(-50%);
z-index:50;display:none;
}
.search-box.open{display:block}
.search-input{
width:360px;
padding:12px 16px 12px 40px;
background:var(--s);
border:1px solid var(--gold-b);
border-radius:var(--r);
color:var(--t);font-size:14px;outline:none;
font-family:'Outfit',sans-serif;
box-shadow:0 4px 16px rgba(0,0,0,0.06);
}
.search-input::placeholder{color:var(--f)}
.search-input:focus{border-color:var(--gold);box-shadow:0 0 0 3px rgba(180,83,9,0.08)}
.search-icon{
position:absolute;left:14px;top:50%;transform:translateY(-50%);
color:var(--m);font-size:14px;
}
/* ═══ TOOLTIP ═══ */
.node-tooltip{
position:absolute;
background:var(--s);
border:1px solid var(--b);
border-radius:var(--rs);
padding:8px 12px;
font-size:12px;color:var(--t);
pointer-events:none;z-index:200;
white-space:nowrap;
box-shadow:0 4px 12px rgba(0,0,0,0.08);
display:none;
}
.node-tooltip.show{display:block}
.tooltip-name{font-weight:700;font-size:13px;margin-bottom:2px}
.tooltip-type{color:var(--m);font-size:10px;text-transform:uppercase;letter-spacing:.06em;font-weight:600}
</style>
</head>
<body>
<!-- TOP BAR -->
<div class="page-topbar">
<div class="topbar-left">
<div class="topbar-icon">KG</div>
<div>
<div class="topbar-title">Knowledge Graph</div>
<div class="topbar-subtitle">Canifa Customer Intelligence Map</div>
</div>
</div>
<div class="topbar-right">
<div class="topbar-stat">
<span class="stat-dot nodes"></span>
<span class="stat-label">Nodes</span>
<span class="stat-value" id="statNodes">0</span>
</div>
<div class="topbar-stat">
<span class="stat-dot edges"></span>
<span class="stat-label">Edges</span>
<span class="stat-value" id="statEdges">0</span>
</div>
<div class="topbar-stat">
<span class="stat-dot types"></span>
<span class="stat-label">Types</span>
<span class="stat-value" id="statTypes">0</span>
</div>
</div>
</div>
<!-- GRAPH AREA -->
<div class="graph-container" id="graphContainer">
<svg class="graph-svg" id="graphSvg"></svg>
<!-- Controls -->
<div class="controls">
<button class="ctrl-btn" id="btnZoomIn" title="Zoom In">+</button>
<button class="ctrl-btn" id="btnZoomOut" title="Zoom Out"></button>
<button class="ctrl-btn" id="btnZoomFit" title="Fit to View"></button>
<div class="ctrl-divider"></div>
<button class="ctrl-btn" id="btnSearch" title="Search (Ctrl+F)">🔍</button>
<button class="ctrl-btn" id="btnToggleLabels" title="Toggle Labels">Aa</button>
<button class="ctrl-btn" id="btnReset" title="Reset Layout"></button>
</div>
<!-- Search -->
<div class="search-box" id="searchBox">
<span class="search-icon">🔍</span>
<input type="text" class="search-input" id="searchInput" placeholder="Tìm node... (Esc to close)">
</div>
<!-- Legend -->
<div class="legend" id="legend"></div>
<!-- Detail Panel -->
<div class="detail-panel" id="detailPanel"></div>
<!-- Tooltip -->
<div class="node-tooltip" id="tooltip"></div>
</div>
<script>
// ═══════════════════════════════════════════════════════
// MOCK DATA: Canifa Customer Knowledge Graph
// ═══════════════════════════════════════════════════════
const graphData = {
nodes: [
// === CUSTOMERS ===
{id:'c1',name:'Nguyễn Thị Mai',type:'Customer',attrs:{age:'32',gender:'Nữ',segment:'VIP',city:'Hà Nội',totalOrders:'24',ltv:'12.5M'}},
{id:'c2',name:'Trần Văn Hùng',type:'Customer',attrs:{age:'28',gender:'Nam',segment:'Regular',city:'TP.HCM',totalOrders:'8',ltv:'4.2M'}},
{id:'c3',name:'Lê Phương Anh',type:'Customer',attrs:{age:'35',gender:'Nữ',segment:'VIP',city:'Đà Nẵng',totalOrders:'31',ltv:'18.7M'}},
{id:'c4',name:'Phạm Minh Tuấn',type:'Customer',attrs:{age:'41',gender:'Nam',segment:'Premium',city:'Hà Nội',totalOrders:'15',ltv:'9.8M'}},
{id:'c5',name:'Hoàng Thúy Linh',type:'Customer',attrs:{age:'26',gender:'Nữ',segment:'New',city:'TP.HCM',totalOrders:'3',ltv:'1.5M'}},
// === PRODUCTS ===
{id:'p1',name:'Áo Polo Classic',type:'Product',attrs:{sku:'8TS26C005',price:'399K',category:'Áo',stock:'245'}},
{id:'p2',name:'Váy Midi Floral',type:'Product',attrs:{sku:'6VD24A012',price:'599K',category:'Váy',stock:'120'}},
{id:'p3',name:'Quần Jeans Slim',type:'Product',attrs:{sku:'7QJ25B003',price:'499K',category:'Quần',stock:'180'}},
{id:'p4',name:'Áo Khoác Bomber',type:'Product',attrs:{sku:'8AK26D008',price:'799K',category:'Áo khoác',stock:'95'}},
{id:'p5',name:'Đầm Công Sở',type:'Product',attrs:{sku:'6DC24E015',price:'699K',category:'Đầm',stock:'75'}},
{id:'p6',name:'Áo Sơ Mi Linen',type:'Product',attrs:{sku:'8SM26F002',price:'459K',category:'Áo',stock:'200'}},
// === CATEGORIES ===
{id:'cat1',name:'Thời trang Nữ',type:'Category',attrs:{products:'156',revenue:'2.3B'}},
{id:'cat2',name:'Thời trang Nam',type:'Category',attrs:{products:'98',revenue:'1.1B'}},
{id:'cat3',name:'Trẻ em',type:'Category',attrs:{products:'67',revenue:'680M'}},
{id:'cat4',name:'Phụ kiện',type:'Category',attrs:{products:'45',revenue:'320M'}},
// === BEHAVIORS ===
{id:'b1',name:'Mua sắm cuối tuần',type:'Behavior',attrs:{frequency:'Weekly',channel:'Online+Offline'}},
{id:'b2',name:'Flash Sale Hunter',type:'Behavior',attrs:{frequency:'Every sale',channel:'App'}},
{id:'b3',name:'Gift Buyer',type:'Behavior',attrs:{frequency:'Seasonal',channel:'Offline'}},
{id:'b4',name:'Browsing Only',type:'Behavior',attrs:{frequency:'Daily',channel:'Website'}},
{id:'b5',name:'Loyal Repeater',type:'Behavior',attrs:{frequency:'Monthly',channel:'All'}},
// === SEGMENTS ===
{id:'s1',name:'Mom Shopper',type:'Segment',attrs:{size:'2,340',growth:'+12%',avgLTV:'8.5M'}},
{id:'s2',name:'Young Professional',type:'Segment',attrs:{size:'1,850',growth:'+18%',avgLTV:'5.2M'}},
{id:'s3',name:'Fashion Enthusiast',type:'Segment',attrs:{size:'980',growth:'+25%',avgLTV:'15.3M'}},
{id:'s4',name:'Budget Conscious',type:'Segment',attrs:{size:'3,200',growth:'+5%',avgLTV:'2.1M'}},
// === OCCASIONS ===
{id:'o1',name:'Đi làm',type:'Occasion',attrs:{peak:'Mon-Fri'}},
{id:'o2',name:'Đi chơi',type:'Occasion',attrs:{peak:'Weekend'}},
{id:'o3',name:'Sự kiện',type:'Occasion',attrs:{peak:'Holiday'}},
{id:'o4',name:'Mặc nhà',type:'Occasion',attrs:{peak:'Everyday'}},
// === TRENDS ===
{id:'t1',name:'Minimalism',type:'Trend',attrs:{strength:'Strong',since:'2025'}},
{id:'t2',name:'Sustainable Fashion',type:'Trend',attrs:{strength:'Growing',since:'2024'}},
{id:'t3',name:'Oversized Fit',type:'Trend',attrs:{strength:'Peak',since:'2025'}},
],
edges: [
// Customers → Products
{source:'c1',target:'p1',label:'mua',weight:3},
{source:'c1',target:'p2',label:'mua',weight:5},
{source:'c1',target:'p5',label:'mua',weight:2},
{source:'c2',target:'p1',label:'mua',weight:2},
{source:'c2',target:'p3',label:'mua',weight:4},
{source:'c2',target:'p4',label:'mua',weight:1},
{source:'c3',target:'p2',label:'mua',weight:6},
{source:'c3',target:'p5',label:'mua',weight:4},
{source:'c3',target:'p6',label:'mua',weight:3},
{source:'c4',target:'p3',label:'mua',weight:3},
{source:'c4',target:'p4',label:'mua',weight:2},
{source:'c4',target:'p6',label:'mua',weight:5},
{source:'c5',target:'p1',label:'xem',weight:1},
{source:'c5',target:'p2',label:'thích',weight:2},
// Products → Categories
{source:'p1',target:'cat2',label:'thuộc',weight:1},
{source:'p2',target:'cat1',label:'thuộc',weight:1},
{source:'p3',target:'cat2',label:'thuộc',weight:1},
{source:'p4',target:'cat2',label:'thuộc',weight:1},
{source:'p5',target:'cat1',label:'thuộc',weight:1},
{source:'p6',target:'cat2',label:'thuộc',weight:1},
// Customers → Behaviors
{source:'c1',target:'b1',label:'hành vi',weight:2},
{source:'c1',target:'b5',label:'hành vi',weight:3},
{source:'c2',target:'b2',label:'hành vi',weight:2},
{source:'c3',target:'b5',label:'hành vi',weight:3},
{source:'c3',target:'b3',label:'hành vi',weight:2},
{source:'c4',target:'b1',label:'hành vi',weight:1},
{source:'c5',target:'b4',label:'hành vi',weight:2},
{source:'c5',target:'b2',label:'hành vi',weight:1},
// Customers → Segments
{source:'c1',target:'s1',label:'thuộc nhóm',weight:2},
{source:'c2',target:'s2',label:'thuộc nhóm',weight:2},
{source:'c3',target:'s3',label:'thuộc nhóm',weight:3},
{source:'c4',target:'s2',label:'thuộc nhóm',weight:1},
{source:'c5',target:'s4',label:'thuộc nhóm',weight:1},
// Products → Occasions
{source:'p1',target:'o1',label:'phù hợp',weight:1},
{source:'p1',target:'o2',label:'phù hợp',weight:1},
{source:'p2',target:'o2',label:'phù hợp',weight:2},
{source:'p2',target:'o3',label:'phù hợp',weight:1},
{source:'p3',target:'o1',label:'phù hợp',weight:1},
{source:'p4',target:'o2',label:'phù hợp',weight:2},
{source:'p5',target:'o1',label:'phù hợp',weight:3},
{source:'p5',target:'o3',label:'phù hợp',weight:2},
{source:'p6',target:'o1',label:'phù hợp',weight:2},
{source:'p6',target:'o4',label:'phù hợp',weight:1},
// Categories → Trends
{source:'cat1',target:'t1',label:'xu hướng',weight:2},
{source:'cat1',target:'t2',label:'xu hướng',weight:1},
{source:'cat2',target:'t3',label:'xu hướng',weight:3},
{source:'cat2',target:'t1',label:'xu hướng',weight:2},
// Segments → Behaviors
{source:'s1',target:'b1',label:'đặc trưng',weight:2},
{source:'s1',target:'b3',label:'đặc trưng',weight:1},
{source:'s2',target:'b2',label:'đặc trưng',weight:2},
{source:'s3',target:'b5',label:'đặc trưng',weight:3},
{source:'s4',target:'b2',label:'đặc trưng',weight:3},
{source:'s4',target:'b4',label:'đặc trưng',weight:2},
// Cross
{source:'c1',target:'c3',label:'tương tự',weight:2},
{source:'p1',target:'p6',label:'mua kèm',weight:3},
{source:'p2',target:'p5',label:'mua kèm',weight:2},
{source:'t1',target:'t2',label:'liên quan',weight:2},
]
};
// ═══════════════════════════════════════════════════════
// COLOR PALETTE — warm tones matching Canifa
// ═══════════════════════════════════════════════════════
const typeColors = {
'Customer': '#6D28D9', // diamond/purple
'Product': '#DB2777', // pink
'Category': '#0891B2', // cyan
'Behavior': '#EA580C', // orange
'Segment': '#B45309', // gold
'Occasion': '#065F46', // green
'Trend': '#1D4ED8', // blue
};
// ═══════════════════════════════════════════════════════
// RENDER GRAPH — delayed init for iframe compatibility
// ═══════════════════════════════════════════════════════
const container = document.getElementById('graphContainer');
const svg = d3.select('#graphSvg');
let simulation, link, linkLabel, linkLabelBg, node, g, zoom;
let showLabels = true;
let selectedNode = null;
// Stats
document.getElementById('statNodes').textContent = graphData.nodes.length;
document.getElementById('statEdges').textContent = graphData.edges.length;
const uniqueTypes = [...new Set(graphData.nodes.map(n => n.type))];
document.getElementById('statTypes').textContent = uniqueTypes.length;
function initGraph(){
const w = container.clientWidth || window.innerWidth;
const h = container.clientHeight || (window.innerHeight - 54);
svg.attr('width', w).attr('height', h);
// Root group
g = svg.append('g');
// Defs
const defs = svg.append('defs');
defs.append('marker')
.attr('id','arrowhead')
.attr('viewBox','0 -5 10 10')
.attr('refX',24).attr('refY',0)
.attr('markerWidth',5).attr('markerHeight',5)
.attr('orient','auto')
.append('path')
.attr('d','M0,-5L10,0L0,5')
.attr('fill','#C4C0B8');
// Simulation — stronger centering
simulation = d3.forceSimulation(graphData.nodes)
.force('link', d3.forceLink(graphData.edges).id(d => d.id).distance(d => 100 + (1/(d.weight||1)) * 25))
.force('charge', d3.forceManyBody().strength(-280))
.force('center', d3.forceCenter(w/2, h/2).strength(1))
.force('collide', d3.forceCollide(30))
.force('x', d3.forceX(w/2).strength(0.08))
.force('y', d3.forceY(h/2).strength(0.08));
// Links
const linkGroup = g.append('g');
link = linkGroup.selectAll('line')
.data(graphData.edges)
.enter().append('line')
.attr('stroke','#E2E0D8')
.attr('stroke-width', d => Math.max(1, (d.weight||1)*0.6))
.attr('marker-end','url(#arrowhead)')
.style('cursor','pointer')
.on('click', onEdgeClick);
// Link labels
const linkLabelGroup = g.append('g');
linkLabelBg = linkLabelGroup.selectAll('rect')
.data(graphData.edges)
.enter().append('rect')
.attr('fill','rgba(245,244,240,0.9)')
.attr('rx',4).attr('ry',4)
.style('pointer-events','none');
linkLabel = linkLabelGroup.selectAll('text')
.data(graphData.edges)
.enter().append('text')
.text(d => d.label)
.attr('font-size','9px')
.attr('fill','#78716C')
.attr('text-anchor','middle')
.attr('dominant-baseline','middle')
.attr('font-weight','500')
.style('pointer-events','none')
.style('font-family','Outfit,system-ui,sans-serif');
// Nodes
const nodeGroup = g.append('g');
node = nodeGroup.selectAll('g')
.data(graphData.nodes)
.enter().append('g')
.style('cursor','pointer')
.call(d3.drag()
.on('start', (e,d) => { if(!e.active) simulation.alphaTarget(0.3).restart(); d.fx=d.x;d.fy=d.y; })
.on('drag', (e,d) => { d.fx=e.x;d.fy=e.y; })
.on('end', (e,d) => { if(!e.active) simulation.alphaTarget(0); d.fx=null;d.fy=null; }))
.on('click', onNodeClick)
.on('mouseenter', onNodeHover)
.on('mouseleave', onNodeLeave);
// Outer ring
node.append('circle')
.attr('r',20)
.attr('fill','none')
.attr('stroke', d => typeColors[d.type])
.attr('stroke-width',1.5)
.attr('stroke-opacity',0.12)
.attr('class','node-ring');
// Inner circle
node.append('circle')
.attr('r',13)
.attr('fill', d => typeColors[d.type])
.attr('stroke','#fff')
.attr('stroke-width',2.5)
.attr('class','node-circle');
// Node label
node.append('text')
.text(d => d.name.length > 10 ? d.name.substring(0,10)+'…' : d.name)
.attr('dy', 28)
.attr('text-anchor','middle')
.attr('font-size','11px')
.attr('fill','#78716C')
.attr('font-weight','500')
.style('pointer-events','none')
.style('font-family','Outfit,system-ui,sans-serif');
// Tick
simulation.on('tick', () => {
link.attr('x1',d=>d.source.x).attr('y1',d=>d.source.y)
.attr('x2',d=>d.target.x).attr('y2',d=>d.target.y);
linkLabel
.attr('x',d=>(d.source.x+d.target.x)/2)
.attr('y',d=>(d.source.y+d.target.y)/2);
linkLabelBg.each(function(d, i){
const tx = (d.source.x+d.target.x)/2;
const ty = (d.source.y+d.target.y)/2;
const textEl = linkLabel.nodes()[i];
if(textEl){
const bb = textEl.getBBox();
d3.select(this)
.attr('x', tx - bb.width/2 - 4)
.attr('y', ty - bb.height/2 - 2)
.attr('width', bb.width + 8)
.attr('height', bb.height + 4);
}
});
node.attr('transform', d => `translate(${d.x},${d.y})`);
});
// Zoom
zoom = d3.zoom().scaleExtent([0.1,5]).on('zoom', e => g.attr('transform', e.transform));
svg.call(zoom);
// Auto-fit after simulation settles
simulation.on('end', () => {
fitGraphToView(w, h);
});
// Also fit after 2 seconds in case simulation still running
setTimeout(() => fitGraphToView(w, h), 2000);
// Click blank to deselect
svg.on('mousemove', e => {
const tt = document.getElementById('tooltip');
if(tt.classList.contains('show')){ tt.style.left=(e.pageX+14)+'px'; tt.style.top=(e.pageY-8)+'px'; }
});
svg.on('click', () => { selectedNode=null; resetStyles(); document.getElementById('detailPanel').classList.remove('open'); });
// Setup controls
setupControls(w, h);
setupSearch();
}
// Auto-fit: calculate bounding box of all nodes and center the view
function fitGraphToView(w, h){
if(!graphData.nodes.length) return;
let minX=Infinity, maxX=-Infinity, minY=Infinity, maxY=-Infinity;
graphData.nodes.forEach(n => {
if(n.x < minX) minX = n.x;
if(n.x > maxX) maxX = n.x;
if(n.y < minY) minY = n.y;
if(n.y > maxY) maxY = n.y;
});
const padding = 80;
const gw = maxX - minX + padding*2;
const gh = maxY - minY + padding*2;
const scale = Math.min(w/gw, h/gh, 1.2);
const cx = (minX + maxX)/2;
const cy = (minY + maxY)/2;
svg.transition().duration(600).call(
zoom.transform,
d3.zoomIdentity.translate(w/2, h/2).scale(scale).translate(-cx, -cy)
);
}
function resetStyles(){
if(!node || !link) return;
node.selectAll('.node-circle').attr('stroke','#fff').attr('stroke-width',2.5);
node.selectAll('.node-ring').attr('stroke-opacity',0.12).attr('stroke-width',1.5);
link.attr('stroke','#E2E0D8').attr('stroke-width', d => Math.max(1,(d.weight||1)*0.6));
}
function onNodeClick(event, d){
event.stopPropagation();
selectedNode = d;
resetStyles();
d3.select(this).select('.node-circle').attr('stroke',typeColors[d.type]).attr('stroke-width',3.5);
d3.select(this).select('.node-ring').attr('stroke-opacity',0.4).attr('stroke-width',2.5);
const connectedIds = new Set();
link.each(function(l){
if(l.source.id===d.id || l.target.id===d.id){
d3.select(this).attr('stroke',typeColors[d.type]).attr('stroke-width', Math.max(2,(l.weight||1)));
if(l.source.id===d.id) connectedIds.add(l.target.id);
if(l.target.id===d.id) connectedIds.add(l.source.id);
}
});
node.filter(n => connectedIds.has(n.id)).select('.node-ring').attr('stroke-opacity',0.3);
showDetailPanel(d,'node');
}
function onEdgeClick(event,d){ event.stopPropagation(); showDetailPanel(d,'edge'); }
function onNodeHover(event,d){
if(selectedNode?.id===d.id) return;
d3.select(this).select('.node-ring').attr('stroke-opacity',0.35).attr('stroke-width',2);
const tt = document.getElementById('tooltip');
tt.innerHTML = `<div class="tooltip-name">${d.name}</div><div class="tooltip-type">${d.type}</div>`;
tt.classList.add('show');
tt.style.left = (event.pageX+14)+'px';
tt.style.top = (event.pageY-8)+'px';
}
function onNodeLeave(event,d){
if(selectedNode?.id===d.id) return;
d3.select(this).select('.node-ring').attr('stroke-opacity',0.12).attr('stroke-width',1.5);
document.getElementById('tooltip').classList.remove('show');
}
// ═══ DETAIL PANEL ═══
function showDetailPanel(d, type){
const panel = document.getElementById('detailPanel');
if(type === 'node'){
const color = typeColors[d.type];
let attrsHtml = '';
if(d.attrs) Object.entries(d.attrs).forEach(([k,v]) => {
attrsHtml += `<div class="detail-row"><span class="detail-key">${k}</span><span class="detail-val">${v}</span></div>`;
});
const connections = [];
graphData.edges.forEach(e => {
const sId = typeof e.source==='object'?e.source.id:e.source;
const tId = typeof e.target==='object'?e.target.id:e.target;
if(sId===d.id){ const t=graphData.nodes.find(n=>n.id===tId); if(t) connections.push({dir:'→',label:e.label,node:t}); }
if(tId===d.id){ const s=graphData.nodes.find(n=>n.id===sId); if(s) connections.push({dir:'←',label:e.label,node:s}); }
});
let connHtml = '';
if(connections.length){
connHtml = `<div style="margin-top:16px;padding-top:14px;border-top:1px solid var(--b)">
<div class="detail-conn-title">Connections (${connections.length})</div>`;
connections.forEach(c => {
const cc = typeColors[c.node.type];
connHtml += `<div class="detail-conn-item">
<span class="conn-arrow ${c.dir==='→'?'out':'in'}">${c.dir}</span>
<span class="conn-label">${c.label}</span>
<span class="conn-dot" style="background:${cc}"></span>
<span class="conn-name">${c.node.name}</span>
</div>`;
});
connHtml += '</div>';
}
panel.innerHTML = `
<div class="detail-header">
<div class="detail-type"><span class="detail-type-dot" style="background:${color}"></span><span class="detail-type-label">${d.type}</span></div>
<button class="detail-close" onclick="closeDetail()">✕</button>
</div>
<div class="detail-body">
<div class="detail-name">${d.name}</div>
${attrsHtml}${connHtml}
</div>`;
} else {
const sN = typeof d.source==='object'?d.source.name:d.source;
const tN = typeof d.target==='object'?d.target.name:d.target;
panel.innerHTML = `
<div class="detail-header">
<div class="detail-type"><span class="detail-type-dot" style="background:var(--blue)"></span><span class="detail-type-label">Relationship</span></div>
<button class="detail-close" onclick="closeDetail()">✕</button>
</div>
<div class="edge-detail-header">${sN} <span class="edge-arrow">→</span> <strong>${d.label}</strong> <span class="edge-arrow">→</span> ${tN}</div>
<div class="detail-body">
<div class="detail-row"><span class="detail-key">Type</span><span class="detail-val">${d.label}</span></div>
<div class="detail-row"><span class="detail-key">Weight</span><span class="detail-val">${d.weight||1}</span></div>
</div>`;
}
panel.classList.add('open');
}
function closeDetail(){ document.getElementById('detailPanel').classList.remove('open'); selectedNode=null; resetStyles(); }
// ═══ LEGEND ═══
(function renderLegend(){
const el = document.getElementById('legend');
const counts = {};
graphData.nodes.forEach(n => { counts[n.type]=(counts[n.type]||0)+1; });
let h = '<div class="legend-title">Entity Types</div><div class="legend-items">';
Object.entries(typeColors).forEach(([type,color]) => {
if(counts[type]) h += `<div class="legend-item"><span class="legend-dot" style="background:${color}"></span><span class="legend-label">${type}</span><span class="legend-count">${counts[type]}</span></div>`;
});
h += '</div>';
el.innerHTML = h;
})();
// ═══ CONTROLS ═══
function setupControls(w, h){
document.getElementById('btnZoomIn').onclick = () => svg.transition().duration(300).call(zoom.scaleBy, 1.3);
document.getElementById('btnZoomOut').onclick = () => svg.transition().duration(300).call(zoom.scaleBy, 0.7);
document.getElementById('btnZoomFit').onclick = () => fitGraphToView(container.clientWidth, container.clientHeight);
document.getElementById('btnToggleLabels').onclick = function(){
showLabels = !showLabels;
linkLabel.style('display', showLabels?'block':'none');
linkLabelBg.style('display', showLabels?'block':'none');
this.classList.toggle('active', showLabels);
};
document.getElementById('btnReset').onclick = () => {
graphData.nodes.forEach(n=>{n.fx=null;n.fy=null});
const cw = container.clientWidth, ch = container.clientHeight;
simulation.force('center', d3.forceCenter(cw/2, ch/2).strength(1));
simulation.force('x', d3.forceX(cw/2).strength(0.08));
simulation.force('y', d3.forceY(cw/2).strength(0.08));
simulation.alpha(1).restart();
setTimeout(() => fitGraphToView(cw, ch), 2000);
};
}
// ═══ SEARCH ═══
function setupSearch(){
const searchBox = document.getElementById('searchBox');
const searchInput = document.getElementById('searchInput');
document.getElementById('btnSearch').onclick = () => { searchBox.classList.toggle('open'); if(searchBox.classList.contains('open')) searchInput.focus(); };
document.addEventListener('keydown', e => {
if(e.ctrlKey && e.key==='f'){ e.preventDefault(); searchBox.classList.add('open'); searchInput.focus(); }
if(e.key==='Escape'){ searchBox.classList.remove('open'); searchInput.value=''; node.selectAll('.node-circle').attr('opacity',1); node.selectAll('text').attr('opacity',1); link.attr('opacity',1); linkLabelBg.attr('opacity',1); linkLabel.attr('opacity',1); }
});
searchInput.addEventListener('input', () => {
const q = searchInput.value.toLowerCase().trim();
if(!q){ node.selectAll('.node-circle').attr('opacity',1); node.selectAll('text').attr('opacity',1); link.attr('opacity',1); linkLabelBg.attr('opacity',1); linkLabel.attr('opacity',1); return; }
const matchIds = new Set();
graphData.nodes.forEach(n => { if(n.name.toLowerCase().includes(q)||n.type.toLowerCase().includes(q)) matchIds.add(n.id); });
node.selectAll('.node-circle').attr('opacity', d => matchIds.has(d.id)?1:0.12);
node.selectAll('text').attr('opacity', d => matchIds.has(d.id)?1:0.12);
link.attr('opacity', d => { const s=d.source.id||d.source, t=d.target.id||d.target; return (matchIds.has(s)||matchIds.has(t))?0.8:0.05; });
linkLabelBg.attr('opacity', d => { const s=d.source.id||d.source, t=d.target.id||d.target; return (matchIds.has(s)||matchIds.has(t))?1:0.05; });
linkLabel.attr('opacity', d => { const s=d.source.id||d.source, t=d.target.id||d.target; return (matchIds.has(s)||matchIds.has(t))?1:0.05; });
});
}
// ═══ RESIZE ═══
window.addEventListener('resize', () => {
const w=container.clientWidth, h=container.clientHeight;
svg.attr('width',w).attr('height',h);
simulation.force('center',d3.forceCenter(w/2,h/2).strength(1));
simulation.force('x', d3.forceX(w/2).strength(0.08));
simulation.force('y', d3.forceY(h/2).strength(0.08));
simulation.alpha(0.3).restart();
});
// ═══ DELAYED INIT — wait for iframe to provide correct dimensions ═══
requestAnimationFrame(() => {
setTimeout(initGraph, 100);
});
</script>
</body>
</html>
<!DOCTYPE html> <!DOCTYPE html>
<html lang="vi"> <html lang="vi">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Canifa AI Platform</title> <title>Canifa AI Platform</title>
<link rel="stylesheet" href="/static/css/theme.css"> <script src="/static/auth.js"></script>
<link rel="stylesheet" href="/static/css/components.css"> <link rel="stylesheet" href="/static/css/theme.css">
<link rel="stylesheet" href="/static/css/lab.css"> <link rel="stylesheet" href="/static/css/components.css">
<link rel="stylesheet" href="/static/css/dashboard.css?v=4"> <link rel="stylesheet" href="/static/css/lab.css">
<link rel="stylesheet" href="/static/css/ai-assistant.css?v=1"> <link rel="stylesheet" href="/static/css/dashboard.css?v=4">
<style> <link rel="stylesheet" href="/static/css/ai-assistant.css?v=1">
/* ═══ MAIN LAYOUT OVERRIDES ═══ */ <style>
body{margin:0;display:flex;min-height:100vh} /* ═══ MAIN LAYOUT OVERRIDES ═══ */
.main{margin-left:240px;flex:1;display:flex;flex-direction:column;min-height:100vh;position:relative} body{margin:0;display:flex;min-height:100vh}
.content-frame{flex:1;border:none;width:100%;height:100%} .main{margin-left:240px;flex:1;display:flex;flex-direction:column;min-height:100vh;position:relative}
/* hide topbar since each page has its own */ .content-frame{flex:1;border:none;width:100%;height:100%}
.page-topbar{padding:0;margin:0;display:none} /* hide topbar since each page has its own */
.page-topbar{padding:0;margin:0;display:none}
/* Professional sidebar tone: keep subtle markers instead of emoji icons */
#mainSidebar .brand-icon{ /* Professional sidebar tone: keep subtle markers instead of emoji icons */
font-size:.78em; #mainSidebar .brand-icon{
font-weight:700; font-size:.78em;
letter-spacing:.08em; font-weight:700;
} letter-spacing:.08em;
#mainSidebar .nav-item{gap:10px} }
#mainSidebar .nav-icon{ #mainSidebar .nav-item{gap:10px}
position:relative; #mainSidebar .nav-icon{
width:8px; position:relative;
min-width:8px; width:8px;
font-size:0; min-width:8px;
line-height:0; font-size:0;
color:transparent; line-height:0;
} color:transparent;
#mainSidebar .nav-icon::before{ }
content:''; #mainSidebar .nav-icon::before{
display:block; content:'';
width:6px; display:block;
height:6px; width:6px;
border-radius:999px; height:6px;
background:#CFC6B9; border-radius:999px;
transition:background .2s ease, transform .2s ease; background:#CFC6B9;
} transition:background .2s ease, transform .2s ease;
#mainSidebar .nav-item:hover .nav-icon::before{background:#A99E91} }
#mainSidebar .nav-item.active .nav-icon::before{background:var(--gold);transform:scale(1.12)} #mainSidebar .nav-item:hover .nav-icon::before{background:#A99E91}
#mainSidebar .nav-item.active .nav-icon::before{background:var(--gold);transform:scale(1.12)}
@media(max-width:1024px){
.main{margin-left:64px} @media(max-width:1024px){
} .main{margin-left:64px}
.sidebar-scroll { flex: 1; overflow-y: auto; overflow-x: hidden; min-height: 0; padding-bottom: 20px; } }
.sidebar-scroll::-webkit-scrollbar { width: 4px; background: transparent; } .sidebar-scroll { flex: 1; overflow-y: auto; overflow-x: hidden; min-height: 0; padding-bottom: 20px; }
.sidebar-scroll::-webkit-scrollbar-thumb { background: rgba(0,0,0,0.1); border-radius: 4px; } .sidebar-scroll::-webkit-scrollbar { width: 4px; background: transparent; }
.sidebar-scroll:hover::-webkit-scrollbar-thumb { background: rgba(0,0,0,0.2); } .sidebar-scroll::-webkit-scrollbar-thumb { background: rgba(0,0,0,0.1); border-radius: 4px; }
.sidebar-scroll:hover::-webkit-scrollbar-thumb { background: rgba(0,0,0,0.2); }
/* Settings Modal (Memos-style) */
.settings-overlay { position:fixed; inset:0; background:rgba(0,0,0,0.25); z-index:1000; display:none; justify-content:center; align-items:center; backdrop-filter:blur(2px); } /* Settings Modal */
.settings-overlay.open { display:flex; } .modal-overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.4); z-index: 1000; display: none; justify-content: center; align-items: center; backdrop-filter: blur(2px); }
.settings-dialog { background:var(--card,#fff); border:1px solid var(--border,#e7e5e2); border-radius:12px; box-shadow:var(--shadow-lg, 0 4px 12px rgba(0,0,0,0.08)); width:680px; max-width:92vw; height:520px; max-height:85vh; display:flex; flex-direction:column; overflow:hidden; } .modal-overlay.open { display: flex; animation: fadeIn 0.2s; }
.settings-body { display:flex; flex:1; min-height:0; } .modal-content { background: white; border-radius: 12px; padding: 24px; width: 420px; max-width: 90%; box-shadow: 0 10px 25px rgba(0,0,0,0.1); }
.settings-sidebar { width:180px; border-right:1px solid var(--border,#e7e5e2); display:flex; flex-direction:column; padding:16px 12px; gap:2px; background:var(--card,#fff); } .modal-header h3 { margin: 0; font-size: 16px; font-weight: 700; color:var(--text-main); }
.settings-sidebar .group-label { font-size:11px; font-weight:600; color:var(--muted-fg,#78716c); padding:4px 12px; text-transform:uppercase; letter-spacing:0.05em; } .modal-body { margin: 20px 0; }
.settings-sidebar .group-label.admin { margin-top:16px; } .form-group { margin-bottom: 14px; }
.settings-content { flex:1; display:flex; flex-direction:column; min-width:0; } .form-group label { display: block; font-size: 12px; font-weight: 600; color:var(--text-muted); margin-bottom: 6px; }
.settings-scroll { flex:1; overflow-y:auto; padding:20px 24px; } .form-group input { width: 100%; padding: 10px 12px; border: 1px solid var(--border); border-radius: 6px; box-sizing: border-box; font-family: monospace; font-size: 13px; color:var(--text-main); }
.settings-footer { padding:12px 24px; border-top:1px solid var(--border,#e7e5e2); display:flex; justify-content:flex-end; gap:8px; background:var(--card,#fff); } .form-group input:focus { outline:none; border-color:var(--gold); }
.settings-user-block { border-top:1px solid var(--border,#e7e5e2); padding-top:12px; margin-top:auto; } .modal-footer { display: flex; justify-content: flex-end; gap: 8px; }
.settings-user-row { display:flex; align-items:center; gap:8px; padding:4px 8px; } .btn { padding: 9px 16px; border-radius: 8px; font-size: 13px; font-weight: 600; cursor: pointer; border: none; transition:all 0.2s; }
.settings-user-avatar { width:28px; height:28px; border-radius:50%; background:var(--muted,#f0efec); display:flex; align-items:center; justify-content:center; font-size:12px; font-weight:600; color:var(--muted-fg,#78716c); flex-shrink:0; } .btn-cancel { background: #f1f5f9; color: #475569; }
.settings-version { display:flex; align-items:center; gap:6px; padding:6px 8px 0; font-size:11px; color:var(--muted-fg,#78716c); } .btn-cancel:hover { background: #e2e8f0; }
.settings-version-dot { width:6px; height:6px; border-radius:50%; background:var(--success,#16a34a); flex-shrink:0; } .btn-save { background: var(--gold); color: white; }
</style> .btn-save:hover { background: var(--gold-d,#B39356); }
</head> </style>
<body> </head>
<!-- ═══ SIDEBAR (single source of truth) ═══ --> <body>
<aside class="sidebar" id="mainSidebar"> <!-- ═══ SIDEBAR (single source of truth) ═══ -->
<div class="sidebar-brand"> <aside class="sidebar" id="mainSidebar">
<div class="brand-icon">CA</div> <div class="sidebar-brand">
<div class="brand-text"> <div class="brand-icon">CA</div>
<h2>Canifa AI</h2> <div class="brand-text">
<span>Admin Console</span> <h2>Canifa AI</h2>
</div> <span>Admin Console</span>
</div> </div>
</div>
<div class="sidebar-scroll">
<div class="nav-group"> <div class="sidebar-scroll">
<div class="nav-group-label">Main</div> <div class="nav-group">
<a data-page="roadmap.html" class="nav-item" onclick="navigateTo(this)"> <div class="nav-group-label">Main</div>
<span class="nav-icon" style="font-size:10px;font-weight:800;letter-spacing:.04em">RM</span> <a data-page="roadmap.html" class="nav-item" onclick="navigateTo(this)">
<span>Kế hoạch phát triển</span> <span class="nav-icon" style="font-size:10px;font-weight:800;letter-spacing:.04em">RM</span>
</a> <span>Kế hoạch phát triển</span>
<a data-page="flow.html" class="nav-item" onclick="navigateTo(this)"> </a>
<span class="nav-icon"></span> <a data-page="flow.html" class="nav-item" onclick="navigateTo(this)">
<span>Sơ đồ hoạt động</span> <span class="nav-icon"></span>
</a> <span>Sơ đồ hoạt động</span>
<a data-page="experiment_detail.html?id=exp_chatbot_prod" class="nav-item" onclick="navigateTo(this)"> </a>
<span class="nav-icon"></span> <a data-page="experiment_detail.html?id=exp_chatbot_prod" class="nav-item" onclick="navigateTo(this)">
<span>Chatbot</span> <span class="nav-icon"></span>
<span class="nav-badge badge-live">LIVE</span> <span>Chatbot</span>
</a> <span class="nav-badge badge-live">LIVE</span>
<a data-page="history.html" class="nav-item" onclick="navigateTo(this)"> </a>
<span class="nav-icon"></span> <a data-page="history.html" class="nav-item" onclick="navigateTo(this)">
<span>History</span> <span class="nav-icon"></span>
</a> <span>History</span>
<a data-page="product.html" class="nav-item" onclick="navigateTo(this)"> </a>
<span class="nav-icon"></span> <a data-page="product.html" class="nav-item" onclick="navigateTo(this)">
<span>Product Perf.</span> <span class="nav-icon"></span>
</a> <span>Product Perf.</span>
<a data-page="product-desc.html" class="nav-item" onclick="navigateTo(this)"> </a>
<span class="nav-icon" style="font-size:10px;font-weight:800;letter-spacing:.04em">UD</span> <a data-page="product-desc.html" class="nav-item" onclick="navigateTo(this)">
<span>Ultra Description</span> <span class="nav-icon" style="font-size:10px;font-weight:800;letter-spacing:.04em">UD</span>
<span class="nav-badge badge-beta">NEW</span> <span>Ultra Description</span>
</a> <span class="nav-badge badge-beta">NEW</span>
<a data-page="ai-report.html" class="nav-item" onclick="navigateTo(this)"> </a>
<span class="nav-icon"></span> <a data-page="ai-report.html" class="nav-item" onclick="navigateTo(this)">
<span>AI Data Analyst</span> <span class="nav-icon"></span>
<span class="nav-badge badge-beta">NEW</span> <span>AI Data Analyst</span>
</a> <span class="nav-badge badge-beta">NEW</span>
<a data-page="ai-sql.html" class="nav-item" onclick="navigateTo(this)"> </a>
<span class="nav-icon" style="font-size:11px;font-weight:800;letter-spacing:.04em">SQL</span> <a data-page="ai-sql.html" class="nav-item" onclick="navigateTo(this)">
<span>AI sinh SQL</span> <span class="nav-icon" style="font-size:11px;font-weight:800;letter-spacing:.04em">SQL</span>
<span class="nav-badge badge-beta">NEW</span> <span>AI sinh SQL</span>
</a> <span class="nav-badge badge-beta">NEW</span>
<a data-page="live-monitor.html" class="nav-item" onclick="navigateTo(this)"> </a>
<span class="nav-icon" style="font-size:10px;font-weight:800;letter-spacing:.08em">LIVE</span> <a data-page="live-monitor.html" class="nav-item" onclick="navigateTo(this)">
<span>Realtime Monitor</span> <span class="nav-icon" style="font-size:10px;font-weight:800;letter-spacing:.08em">LIVE</span>
<span class="nav-badge badge-live">LIVE</span> <span>Realtime Monitor</span>
</a> <span class="nav-badge badge-live">LIVE</span>
<a data-page="prompt-optimizer.html" class="nav-item" onclick="navigateTo(this)"> </a>
<span class="nav-icon" style="font-size:10px;font-weight:800;letter-spacing:.04em">PO</span> <a data-page="prompt-optimizer.html" class="nav-item" onclick="navigateTo(this)">
<span>Prompt Optimizer</span> <span class="nav-icon" style="font-size:10px;font-weight:800;letter-spacing:.04em">PO</span>
<span class="nav-badge badge-beta">NEW</span> <span>Prompt Optimizer</span>
</a> <span class="nav-badge badge-beta">NEW</span>
<a data-page="user-simulator.html" class="nav-item" onclick="navigateTo(this)"> </a>
<span class="nav-icon" style="font-size:10px;font-weight:800;letter-spacing:.04em">US</span> <a data-page="user-simulator.html" class="nav-item" onclick="navigateTo(this)">
<span>User Simulator</span> <span class="nav-icon" style="font-size:10px;font-weight:800;letter-spacing:.04em">US</span>
<span class="nav-badge badge-beta">NEW</span> <span>User Simulator</span>
</a> <span class="nav-badge badge-beta">NEW</span>
<a data-page="user-insight.html" class="nav-item" onclick="navigateTo(this)"> </a>
<span class="nav-icon" style="font-size:10px;font-weight:800;letter-spacing:.04em">UI</span> <a data-page="user-insight.html" class="nav-item" onclick="navigateTo(this)">
<span>User Insight</span> <span class="nav-icon" style="font-size:10px;font-weight:800;letter-spacing:.04em">UI</span>
<span class="nav-badge badge-beta">NEW</span> <span>User Insight</span>
</a> <span class="nav-badge badge-beta">NEW</span>
<a data-page="limit.html" class="nav-item" onclick="navigateTo(this)"> </a>
<span class="nav-icon" style="font-size:10px;font-weight:800;letter-spacing:.04em">RL</span> <a data-page="knowledge-graph.html" class="nav-item" onclick="navigateTo(this)">
<span>Rate Limit</span> <span class="nav-icon" style="font-size:10px;font-weight:800;letter-spacing:.04em">KG</span>
<span class="nav-badge badge-beta">NEW</span> <span>Knowledge Graph</span>
</a> <span class="nav-badge badge-beta">NEW</span>
<a data-page="regression-test.html" class="nav-item" onclick="navigateTo(this)"> </a>
<span class="nav-icon" style="font-size:10px;font-weight:800;letter-spacing:.04em">RT</span> <a data-page="reaction-simulator.html" class="nav-item" onclick="navigateTo(this)">
<span>Regression Test</span> <span class="nav-icon" style="font-size:10px;font-weight:800;letter-spacing:.04em">🔮</span>
<span class="nav-badge badge-beta">NEW</span> <span>Reaction Sim.</span>
</a> <span class="nav-badge badge-beta">NEW</span>
<a data-page="stress-test.html" class="nav-item" onclick="navigateTo(this)"> </a>
<span class="nav-icon" style="font-size:10px;font-weight:800;letter-spacing:.04em">ST</span> <a data-page="regression-test.html" class="nav-item" onclick="navigateTo(this)">
<span>Stress Test</span> <span class="nav-icon" style="font-size:10px;font-weight:800;letter-spacing:.04em">RT</span>
<span class="nav-badge badge-beta">NEW</span> <span>Regression Test</span>
</a> <span class="nav-badge badge-beta">NEW</span>
<a data-page="competitor-research.html" class="nav-item" onclick="navigateTo(this)"> </a>
<span class="nav-icon" style="font-size:10px;font-weight:800;letter-spacing:.04em">CR</span> <a data-page="stress-test.html" class="nav-item" onclick="navigateTo(this)">
<span>Competitor Research</span> <span class="nav-icon" style="font-size:10px;font-weight:800;letter-spacing:.04em">ST</span>
<span class="nav-badge badge-beta">NEW</span> <span>Stress Test</span>
</a> <span class="nav-badge badge-beta">NEW</span>
</div> </a>
<a data-page="diagram-agent.html" class="nav-item" onclick="navigateTo(this)">
<div class="nav-group"> <span class="nav-icon" style="font-size:10px;font-weight:800;letter-spacing:.04em">DA</span>
<div class="nav-group-label">Workspace</div> <span>AI Diagram</span>
<a data-page="resources.html" class="nav-item" onclick="navigateTo(this)"> <span class="nav-badge badge-beta">NEW</span>
<span class="nav-icon"></span> </a>
<span>Resources</span> <a data-page="competitor-research.html" class="nav-item" onclick="navigateTo(this)">
</a> <span class="nav-icon" style="font-size:10px;font-weight:800;letter-spacing:.04em">CR</span>
<a data-page="notes.html" class="nav-item" onclick="navigateTo(this)"> <span>Competitor Research</span>
<span class="nav-icon"></span> <span class="nav-badge badge-beta">NEW</span>
<span>Team Notes</span> </a>
</a> </div>
<a data-page="changelog.html" class="nav-item" onclick="navigateTo(this)">
<span class="nav-icon"></span> <div class="nav-group">
<span>Changelog</span> <div class="nav-group-label">Workspace</div>
</a> <a data-page="resources.html" class="nav-item" onclick="navigateTo(this)">
<a data-page="guide.html" class="nav-item" onclick="navigateTo(this)"> <span class="nav-icon"></span>
<span class="nav-icon"></span> <span>Resources</span>
<span>Hướng dẫn</span> </a>
</a> <a data-page="notes.html" class="nav-item" onclick="navigateTo(this)">
</div> <span class="nav-icon"></span>
<span>Team Notes</span>
</a>
<a data-page="changelog.html" class="nav-item" onclick="navigateTo(this)">
<div class="nav-group"> <span class="nav-icon"></span>
<div class="nav-group-label">Thử nghiệm</div> <span>Changelog</span>
<a data-page="test_sql.html" class="nav-item" onclick="navigateTo(this)"> </a>
<span class="nav-icon"></span> <a data-page="guide.html" class="nav-item" onclick="navigateTo(this)">
<span>Text-to-SQL</span> <span class="nav-icon"></span>
<span class="nav-badge badge-beta">BETA</span> <span>Hướng dẫn</span>
</a> </a>
<a data-page="test_db.html" class="nav-item" onclick="navigateTo(this)"> </div>
<span class="nav-icon"></span>
<span>DB Test</span>
<span class="nav-badge badge-beta">BETA</span>
</a> <div class="nav-group">
<a data-page="feedback_demo.html" class="nav-item" onclick="navigateTo(this)"> <div class="nav-group-label">Thử nghiệm</div>
<span class="nav-icon"></span> <a data-page="test_sql.html" class="nav-item" onclick="navigateTo(this)">
<span>Feedback Demo</span> <span class="nav-icon"></span>
<span class="nav-badge badge-new">NEW</span> <span>Text-to-SQL</span>
</a> <span class="nav-badge badge-beta">BETA</span>
<a data-page="http://172.16.2.210:5006/static/index.html" class="nav-item" onclick="navigateTo(this)"> </a>
<span class="nav-icon"></span> <a data-page="test_db.html" class="nav-item" onclick="navigateTo(this)">
<span>Chatbot (Dev)</span> <span class="nav-icon"></span>
<span class="nav-badge badge-beta">DEV</span> <span>DB Test</span>
</a> <span class="nav-badge badge-beta">BETA</span>
<a data-page="cache.html" class="nav-item" onclick="navigateTo(this)"> </a>
<span class="nav-icon"></span> <a data-page="feedback_demo.html" class="nav-item" onclick="navigateTo(this)">
<span>Cache Manager</span> <span class="nav-icon"></span>
</a> <span>Feedback Demo</span>
<a data-page="sku-search.html" class="nav-item" onclick="navigateTo(this)"> <span class="nav-badge badge-new">NEW</span>
<span class="nav-icon" style="font-size:10px;font-weight:800;letter-spacing:.04em">SK</span> </a>
<span>SKU Search</span> <a data-page="http://172.16.2.210:5006/static/index.html" class="nav-item" onclick="navigateTo(this)">
<span class="nav-badge badge-beta">DEV</span> <span class="nav-icon"></span>
</a> <span>Chatbot (Dev)</span>
<a data-page="tag-search.html" class="nav-item" onclick="navigateTo(this)"> <span class="nav-badge badge-beta">DEV</span>
<span class="nav-icon" style="font-size:10px;font-weight:800;letter-spacing:.04em">TG</span> </a>
<span>Tag Search</span> <a data-page="cache.html" class="nav-item" onclick="navigateTo(this)">
<span class="nav-badge badge-beta">DEV</span> <span class="nav-icon"></span>
</a> <span>Cache Manager</span>
<a data-page="store-search.html" class="nav-item" onclick="navigateTo(this)"> </a>
<span class="nav-icon" style="font-size:10px;font-weight:800;letter-spacing:.04em">ST</span> <a data-page="sku-search.html" class="nav-item" onclick="navigateTo(this)">
<span>Store Search</span> <span class="nav-icon" style="font-size:10px;font-weight:800;letter-spacing:.04em">SK</span>
<span class="nav-badge badge-beta">DEV</span> <span>SKU Search</span>
</a> <span class="nav-badge badge-beta">DEV</span>
<a data-page="image-search.html" class="nav-item" onclick="navigateTo(this)"> </a>
<span class="nav-icon" style="font-size:10px;font-weight:800;letter-spacing:.04em">IM</span> <a data-page="tag-search.html" class="nav-item" onclick="navigateTo(this)">
<span>Image Search</span> <span class="nav-icon" style="font-size:10px;font-weight:800;letter-spacing:.04em">TG</span>
<span class="nav-badge badge-new">NEW</span> <span>Tag Search</span>
</a> <span class="nav-badge badge-beta">DEV</span>
<a data-page="lead-flow.html" class="nav-item" onclick="navigateTo(this)"> </a>
<span class="nav-icon" style="font-size:10px;font-weight:800;letter-spacing:.04em">LG</span> <a data-page="store-search.html" class="nav-item" onclick="navigateTo(this)">
<span>Lead Generation</span> <span class="nav-icon" style="font-size:10px;font-weight:800;letter-spacing:.04em">ST</span>
<span class="nav-badge badge-new">NEW</span> <span>Store Search</span>
</a> <span class="nav-badge badge-beta">DEV</span>
</div> </a>
<a data-page="image-search.html" class="nav-item" onclick="navigateTo(this)">
<div class="nav-group"> <span class="nav-icon" style="font-size:10px;font-weight:800;letter-spacing:.04em">IM</span>
<div class="nav-group-label">External</div> <span>Image Search</span>
<a href="/docs" target="_blank" class="nav-item"> <span class="nav-badge badge-new">NEW</span>
<span class="nav-icon"></span> </a>
<span>API Docs</span> <a data-page="lead-flow.html" class="nav-item" onclick="navigateTo(this)">
</a> <span class="nav-icon" style="font-size:10px;font-weight:800;letter-spacing:.04em">LG</span>
<a href="/redoc" target="_blank" class="nav-item"> <span>Lead Generation</span>
<span class="nav-icon"></span> <span class="nav-badge badge-new">NEW</span>
<span>ReDoc</span> </a>
</a> <a data-page="limit.html" class="nav-item" onclick="navigateTo(this)">
</div> <span class="nav-icon" style="font-size:10px;font-weight:800;letter-spacing:.04em">RL</span>
<span>Rate Limit</span>
</div> <span class="nav-badge badge-beta">NEW</span>
<div class="sidebar-footer"> </a>
<div class="settings-user-block"> </div>
<div class="settings-user-row">
<div class="settings-user-avatar" id="userAvatar">A</div> <div class="nav-group">
<span style="flex:1; font-size:13px; font-weight:500; color:var(--foreground,#1c1917);" id="userName">admin</span> <div class="nav-group-label">External</div>
<button class="btn-icon" style="padding:4px;font-size:13px;" onclick="openSettingsModal()" title="Settings"></button> <a href="/docs" target="_blank" class="nav-item">
<button class="btn-icon" style="padding:4px;font-size:13px;" onclick="handleLogout()" title="Đăng xuất"></button> <span class="nav-icon"></span>
</div> <span>API Docs</span>
<div class="settings-version"> </a>
<span class="settings-version-dot"></span> <a href="/redoc" target="_blank" class="nav-item">
<strong>v2.5.0</strong> · Online <span class="nav-icon"></span>
</div> <span>ReDoc</span>
</div> </a>
</div> </div>
</aside>
</div>
<!-- ═══ SETTINGS MODAL (Memos-style: sidebar + content) ═══ --> <div class="sidebar-footer">
<div id="llmSettingsModal" class="settings-overlay" onclick="if(event.target===this)closeSettingsModal()"> <div class="user-info" id="sidebarUserInfo" style="display:flex;align-items:center;gap:8px;padding:8px 12px;margin-bottom:8px;border-radius:8px;background:var(--bg)">
<div class="settings-dialog"> <div style="width:28px;height:28px;border-radius:50%;background:var(--gold-l,#FFFBEB);display:flex;align-items:center;justify-content:center;font-weight:700;font-size:11px;color:var(--gold,#B45309)" id="userAvatar">A</div>
<div class="settings-body"> <span style="flex:1;font-size:12px;color:var(--t);font-weight:600" id="userName">admin</span>
<!-- LEFT SIDEBAR --> <button onclick="openSettingsModal()" title="Cài đặt LLM" style="background:none;border:none;cursor:pointer;color:var(--m);font-size:14px;padding:4px;transition:color .2s" onmouseover="this.style.color='var(--gold)'" onmouseout="this.style.color='var(--m)'"></button>
<div class="settings-sidebar"> <button onclick="handleLogout()" title="Đăng xuất" style="background:none;border:none;cursor:pointer;color:var(--m);font-size:14px;padding:4px;transition:color .2s" onmouseover="this.style.color='#ef4444'" onmouseout="this.style.color='var(--m)'"></button>
<span class="group-label">Basic</span> </div>
<div class="section-menu-item active" onclick="switchMainTab('account')" data-tab="account"> <div class="version-info">
<i class="fas fa-user icon" style="font-size:12px;width:16px;text-align:center;"></i> <span>My Account</span> <span class="version-dot"></span>
</div> <span class="version-text"><strong>v2.5.0</strong> · Online</span>
<div class="section-menu-item" onclick="switchMainTab('llm')" data-tab="llm"> </div>
<i class="fas fa-key icon" style="font-size:12px;width:16px;text-align:center;"></i> <span>LLM Keys</span> </div>
</div> </aside>
<div class="section-menu-item" onclick="switchMainTab('connection')" data-tab="connection">
<i class="fas fa-database icon" style="font-size:12px;width:16px;text-align:center;"></i> <span>Kết nối</span> <!-- ═══ LLM SETTINGS MODAL ═══ -->
</div> <div id="llmSettingsModal" class="modal-overlay">
<div class="section-menu-item" onclick="switchMainTab('display')" data-tab="display"> <div class="modal-content">
<i class="fas fa-palette icon" style="font-size:12px;width:16px;text-align:center;"></i> <span>Giao diện</span> <div class="modal-header">
</div> <h3>Cài đặt LLM Cá nhân</h3>
</div>
<span class="group-label admin">Admin</span> <div class="modal-body">
<div class="section-menu-item" onclick="switchMainTab('advanced')" data-tab="advanced"> <div class="form-group">
<i class="fas fa-cog icon" style="font-size:12px;width:16px;text-align:center;"></i> <span>Nâng cao</span> <label>Codex Auth Token</label>
</div> <input type="password" id="inputCodexToken" placeholder="eyJhbGciOiJIUzI1NiIs..." />
</div>
<!-- Spacer --> <div class="form-group">
<div style="flex:1;"></div> <label>OpenAI API Key (Tuỳ chọn)</label>
<input type="password" id="inputOpenAiKey" placeholder="sk-..." />
<!-- User row at bottom of settings sidebar --> </div>
<div class="settings-user-block"> </div>
<div class="settings-user-row"> <div class="modal-footer">
<div class="settings-user-avatar" id="settingsAvatar">A</div> <button class="btn btn-cancel" onclick="closeSettingsModal()">Hủy</button>
<span style="flex:1; font-size:13px; font-weight:500; color:var(--foreground,#1c1917);" id="settingsUserName">admin</span> <button class="btn btn-save" onclick="saveSettingsModal()">Lưu lại</button>
<button class="btn-icon" style="padding:4px;" title="Copy ID" onclick="navigator.clipboard.writeText(localStorage.getItem('canifa_user')||'')"><i style="font-size:11px;" class="fas fa-copy"></i></button> </div>
</div> </div>
<div class="settings-version"> </div>
<span class="settings-version-dot"></span>
<strong>v2.5.0</strong> · Online <!-- ═══ MAIN CONTENT ═══ -->
</div> <div class="main">
</div> <iframe id="contentFrame" class="content-frame" src="/static/product.html"></iframe>
</div> </div>
<!-- RIGHT CONTENT --> <script>
<div class="settings-content"> // ═══ AUTH GUARD (via auth.js) ═══
<div class="settings-scroll"> canifaAuth.guard();
<!-- TAB: Account --> canifaAuth.updateSidebarUser();
<div id="mainTab_account" class="setting-tab-content">
<div class="setting-section"> function handleLogout() {
<div class="setting-section-header"> canifaAuth.logout();
<h3>My Account</h3> }
<p class="desc">Thông tin tài khoản cá nhân</p>
</div> // ═══ SETTINGS MODAL LOGIC ═══
<div class="setting-section-body"> async function loadUserSettings() {
<div class="setting-row"> const token = localStorage.getItem('canifa_token');
<div class="setting-label"> if (!token) return;
<span class="setting-label-text">Username</span> try {
</div> const res = await fetch('/api/auth/me', { headers: { 'Authorization': 'Bearer ' + token }});
<div class="setting-control"> if (res.ok) {
<span id="settingUsername" style="font-size:13px; font-weight:600; color:var(--foreground);"></span> const data = await res.json();
</div> const user = data.user || {};
</div> const settings = user.settings || {};
<div class="setting-row"> document.getElementById('inputCodexToken').value = settings.codex_token || '';
<div class="setting-label"> document.getElementById('inputOpenAiKey').value = settings.openai_key || '';
<span class="setting-label-text">Role</span> // update cached user settings just in case
</div> localStorage.setItem('canifa_user', JSON.stringify(user));
<div class="setting-control"> }
<span class="badge badge-info" id="settingRole">user</span> } catch(e) { console.error('Failed to load settings', e); }
</div> }
</div>
</div> function openSettingsModal() {
</div> document.getElementById('llmSettingsModal').classList.add('open');
</div> loadUserSettings(); // refresh on open
}
<!-- TAB: LLM Keys -->
<div id="mainTab_llm" class="setting-tab-content" style="display:none;"> function closeSettingsModal() {
<div class="setting-section"> document.getElementById('llmSettingsModal').classList.remove('open');
<div class="setting-section-header"> }
<h3>LLM API Keys</h3>
<p class="desc">Cấu hình API keys cho các mô hình AI</p> async function saveSettingsModal() {
</div> const btn = document.querySelector('.btn-save');
<div class="setting-section-body"> const ogText = btn.textContent;
<div class="setting-row vertical"> btn.textContent = 'Đang lưu...';
<div class="setting-label"> btn.disabled = true;
<span class="setting-label-text">Codex Auth Token</span>
<p class="setting-label-desc">Token xác thực cho Codex API</p> const codex = document.getElementById('inputCodexToken').value.trim();
</div> const oai = document.getElementById('inputOpenAiKey').value.trim();
<div class="setting-control" style="width:100%;">
<input class="input" type="password" id="inputCodexToken" placeholder="eyJhbGciOiJIUzI1NiIs..." style="width:100%; font-family:var(--font-mono,monospace);"> const settings = {};
</div> if (codex) settings.codex_token = codex;
</div> if (oai) settings.openai_key = oai;
<div class="setting-row vertical">
<div class="setting-label"> try {
<span class="setting-label-text">OpenAI API Key</span> const token = localStorage.getItem('canifa_token');
<p class="setting-label-desc">Tuỳ chọn — dùng cho GPT-4o, GPT-5.4</p> const res = await fetch('/api/auth/me/settings', {
</div> method: 'PUT',
<div class="setting-control" style="width:100%;"> headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token },
<input class="input" type="password" id="inputOpenAiKey" placeholder="sk-..." style="width:100%; font-family:var(--font-mono,monospace);"> body: JSON.stringify({ settings: settings })
</div> });
</div> if (res.ok) {
</div> closeSettingsModal();
</div> } else {
</div> alert('Lỗi lưu cài đặt');
}
<!-- TAB: Connection --> } catch(e) {
<div id="mainTab_connection" class="setting-tab-content" style="display:none;"> alert('Lỗi hệ thống');
<div class="setting-section"> } finally {
<div class="setting-section-header"> btn.textContent = ogText;
<h3>Kết nối</h3> btn.disabled = false;
<p class="desc">Cấu hình endpoint và database</p> }
</div> }
<div class="setting-section-body">
<div class="setting-row"> // ═══ NAVIGATION ═══
<div class="setting-label"> function navigateTo(el) {
<span class="setting-label-text">Backend API</span> const page = el.getAttribute('data-page');
<p class="setting-label-desc">URL backend server</p> if (!page) return;
</div> const src = page.startsWith('http') ? page : '/static/' + page + (page.includes('?') ? '&' : '?') + 't=' + Date.now();
<div class="setting-control"> document.getElementById('contentFrame').src = src;
<span style="font-size:12px; font-family:var(--font-mono,monospace); color:var(--muted-fg);" id="settingBackendUrl"></span> // Update active state
</div> document.querySelectorAll('#mainSidebar .nav-item').forEach(n => n.classList.remove('active'));
</div> el.classList.add('active');
<div class="setting-row"> // Update URL without reload
<div class="setting-label"> const pageName = page.split('?')[0].replace('.html','');
<span class="setting-label-text">Trạng thái</span> history.pushState({page}, '', '/static/main.html?page=' + page);
<p class="setting-label-desc">Kết nối tới backend</p> // Update title
</div> document.title = (el.querySelector('span:nth-child(2)')?.textContent || 'Canifa AI') + ' — Canifa AI';
<div class="setting-control"> }
<span class="badge badge-success" id="settingConnStatus">Connected</span>
</div> // ═══ INIT: Load page from URL param ═══
</div> (function() {
<hr class="separator"> const params = new URLSearchParams(window.location.search);
<div class="setting-row"> const page = params.get('page');
<div class="setting-label"> if (page) {
<span class="setting-label-text">Langfuse</span> const src = page.startsWith('http') ? page : '/static/' + page + (page.includes('?') ? '&' : '?') + 't=' + Date.now();
<p class="setting-label-desc">Observability & tracing</p> document.getElementById('contentFrame').src = src;
</div> // Highlight active nav
<div class="setting-control"> document.querySelectorAll('#mainSidebar .nav-item[data-page]').forEach(el => {
<a href="http://172.16.2.210:3100" target="_blank" class="btn btn-outline btn-sm"><i class="fas fa-external-link-alt" style="margin-right:4px;"></i> Open</a> if (el.getAttribute('data-page') === page) {
</div> el.classList.add('active');
</div> } else {
</div> el.classList.remove('active');
</div> }
</div> });
} else {
<!-- TAB: Display --> // Default: dashboard active
<div id="mainTab_display" class="setting-tab-content" style="display:none;"> const dashLink = document.querySelector('[data-page="product.html"]');
<div class="setting-section"> if (dashLink) dashLink.classList.add('active');
<div class="setting-section-header"> }
<h3>Giao diện</h3> })();
<p class="desc">Tùy chỉnh theme và hiển thị</p>
</div> // ═══ HANDLE BACK/FORWARD ═══
<div class="setting-section-body"> window.addEventListener('popstate', function(e) {
<div class="setting-row"> if (e.state && e.state.page) {
<div class="setting-label"> const src = e.state.page.startsWith('http') ? e.state.page : '/static/' + e.state.page + (e.state.page.includes('?') ? '&' : '?') + 't=' + Date.now();
<span class="setting-label-text">Theme</span> document.getElementById('contentFrame').src = src;
<p class="setting-label-desc">Giao diện sáng/tối</p> document.querySelectorAll('#mainSidebar .nav-item[data-page]').forEach(el => {
</div> el.classList.toggle('active', el.getAttribute('data-page') === e.state.page);
<div class="setting-control"> });
<select class="select" disabled><option>Light (Memos)</option></select> }
</div> });
</div> </script>
<div class="setting-row"> </body>
<div class="setting-label"> </html>
<span class="setting-label-text">Sidebar mặc định</span>
<p class="setting-label-desc">Mở rộng hoặc thu gọn</p>
</div>
<div class="setting-control">
<select class="select"><option>Expanded</option><option>Collapsed</option></select>
</div>
</div>
</div>
</div>
</div>
<!-- TAB: Advanced -->
<div id="mainTab_advanced" class="setting-tab-content" style="display:none;">
<div class="setting-section">
<div class="setting-section-header">
<h3>Nâng cao</h3>
<p class="desc">Cấu hình admin</p>
</div>
<div class="setting-section-body">
<div class="setting-row">
<div class="setting-label">
<span class="setting-label-text">Clear Cache</span>
<p class="setting-label-desc">Xóa cache sản phẩm & embedding</p>
</div>
<div class="setting-control">
<button class="btn btn-outline btn-sm" onclick="fetch('/api/cache/clear',{method:'POST',headers:{'Authorization':'Bearer '+localStorage.getItem('canifa_token')}}).then(()=>alert('Cache cleared!'))"><i class="fas fa-broom" style="margin-right:4px;"></i> Clear</button>
</div>
</div>
<div class="setting-row">
<div class="setting-label">
<span class="setting-label-text">Health Check</span>
<p class="setting-label-desc">Kiểm tra trạng thái hệ thống</p>
</div>
<div class="setting-control">
<button class="btn btn-outline btn-sm" onclick="fetch('/health').then(r=>r.json()).then(d=>alert(JSON.stringify(d,null,2)))"><i class="fas fa-heartbeat" style="margin-right:4px;"></i> Check</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Footer -->
<div class="settings-footer">
<button class="btn btn-outline" onclick="closeSettingsModal()">Hủy</button>
<button class="btn btn-primary" onclick="saveSettingsModal()"><i class="fas fa-check" style="margin-right:4px;"></i> Lưu</button>
</div>
</div>
</div>
</div>
</div>
<!-- ═══ MAIN CONTENT ═══ -->
<div class="main">
<iframe id="contentFrame" class="content-frame" src="/static/product.html"></iframe>
</div>
<script>
// ═══ AUTH GUARD ═══
(function authGuard() {
const token = localStorage.getItem('canifa_token');
if (!token) {
window.location.replace('/static/login.html?redirect=' + encodeURIComponent(window.location.href));
return;
}
// Admin only sees admin.html
const _u = JSON.parse(localStorage.getItem('canifa_user') || '{}');
if (_u.role === 'admin') {
window.location.replace('/static/admin.html');
return;
}
// Show user info in sidebar
try {
const user = JSON.parse(localStorage.getItem('canifa_user') || '{}');
const nameEl = document.getElementById('userName');
const avatarEl = document.getElementById('userAvatar');
if (nameEl && user.username) nameEl.textContent = user.username;
if (avatarEl && user.username) avatarEl.textContent = user.username.charAt(0).toUpperCase();
// Show Admin Console link for admin users
if (user.role === 'admin') {
const extGroup = document.querySelector('#mainSidebar .nav-group:last-of-type .nav-group-label');
if (extGroup) {
const adminLink = document.createElement('a');
adminLink.href = '/static/admin.html';
adminLink.className = 'nav-item';
adminLink.innerHTML = '<span class="nav-icon" style="font-size:10px;font-weight:800">⚙</span><span>Admin Console</span><span class="nav-badge badge-live">ADMIN</span>';
extGroup.after(adminLink);
}
}
} catch(e) {}
})();
function handleLogout() {
localStorage.removeItem('canifa_token');
localStorage.removeItem('canifa_user');
window.location.replace('/static/login.html');
}
// ═══ SETTINGS MODAL LOGIC ═══
function switchMainTab(tabName) {
document.querySelectorAll('#llmSettingsModal .setting-tab-content').forEach(el => el.style.display = 'none');
const tab = document.getElementById('mainTab_' + tabName);
if (tab) tab.style.display = '';
document.querySelectorAll('#llmSettingsModal .section-menu-item').forEach(el => {
el.classList.toggle('active', el.dataset.tab === tabName);
});
}
async function loadUserSettings() {
const token = localStorage.getItem('canifa_token');
if (!token) return;
try {
const res = await fetch('/api/auth/me', { headers: { 'Authorization': 'Bearer ' + token }});
if (res.ok) {
const data = await res.json();
const user = data.user || {};
const settings = user.settings || {};
document.getElementById('inputCodexToken').value = settings.codex_token || '';
document.getElementById('inputOpenAiKey').value = settings.openai_key || '';
localStorage.setItem('canifa_user', JSON.stringify(user));
}
} catch(e) { console.error('Failed to load settings', e); }
}
function openSettingsModal() {
const modal = document.getElementById('llmSettingsModal');
// Populate user info
try {
const user = JSON.parse(localStorage.getItem('canifa_user') || '{}');
const name = user.username || 'user';
document.getElementById('settingsUserName').textContent = name;
document.getElementById('settingsAvatar').textContent = name.charAt(0).toUpperCase();
document.getElementById('settingUsername').textContent = name;
document.getElementById('settingRole').textContent = user.role || 'user';
document.getElementById('settingBackendUrl').textContent = window.location.origin;
} catch(e) {}
// Reset to first tab
switchMainTab('account');
modal.classList.add('open');
loadUserSettings();
}
function closeSettingsModal() {
document.getElementById('llmSettingsModal').classList.remove('open');
}
async function saveSettingsModal() {
const btn = document.querySelector('.settings-footer .btn-primary');
const ogHTML = btn.innerHTML;
btn.innerHTML = '<i class="fas fa-spinner fa-spin" style="margin-right:4px;"></i> Đang lưu...';
btn.disabled = true;
const codex = document.getElementById('inputCodexToken').value.trim();
const oai = document.getElementById('inputOpenAiKey').value.trim();
const settings = {};
if (codex) settings.codex_token = codex;
if (oai) settings.openai_key = oai;
try {
const token = localStorage.getItem('canifa_token');
const res = await fetch('/api/auth/me/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token },
body: JSON.stringify({ settings: settings })
});
if (res.ok) {
closeSettingsModal();
} else {
alert('Lỗi lưu cài đặt');
}
} catch(e) {
alert('Lỗi hệ thống');
} finally {
btn.innerHTML = ogHTML;
btn.disabled = false;
}
}
// ═══ NAVIGATION ═══
function navigateTo(el) {
const page = el.getAttribute('data-page');
if (!page) return;
const src = page.startsWith('http') ? page : '/static/' + page + (page.includes('?') ? '&' : '?') + 't=' + Date.now();
document.getElementById('contentFrame').src = src;
// Update active state
document.querySelectorAll('#mainSidebar .nav-item').forEach(n => n.classList.remove('active'));
el.classList.add('active');
// Update URL without reload
const pageName = page.split('?')[0].replace('.html','');
history.pushState({page}, '', '/static/main.html?page=' + page);
// Update title
document.title = (el.querySelector('span:nth-child(2)')?.textContent || 'Canifa AI') + ' — Canifa AI';
}
// ═══ INIT: Load page from URL param ═══
(function() {
const params = new URLSearchParams(window.location.search);
const page = params.get('page');
let qs = new URLSearchParams();
for (const [k, v] of params.entries()) {
if (k !== 'page') qs.append(k, v);
}
const qsString = qs.toString() ? ('&' + qs.toString()) : '';
if (page) {
const src = page.startsWith('http') ? page : '/static/' + page + (page.includes('?') ? '&' : '?') + 't=' + Date.now() + qsString;
document.getElementById('contentFrame').src = src;
// Highlight active nav
document.querySelectorAll('#mainSidebar .nav-item[data-page]').forEach(el => {
if (el.getAttribute('data-page') === page) {
el.classList.add('active');
} else {
el.classList.remove('active');
}
});
} else {
// Default: dashboard active
const dashLink = document.querySelector('[data-page="product.html"]');
if (dashLink) dashLink.classList.add('active');
}
})();
// ═══ HANDLE BACK/FORWARD ═══
window.addEventListener('popstate', function(e) {
if (e.state && e.state.page) {
const src = e.state.page.startsWith('http') ? e.state.page : '/static/' + e.state.page + (e.state.page.includes('?') ? '&' : '?') + 't=' + Date.now();
document.getElementById('contentFrame').src = src;
document.querySelectorAll('#mainSidebar .nav-item[data-page]').forEach(el => {
el.classList.toggle('active', el.getAttribute('data-page') === e.state.page);
});
}
});
</script>
</body>
</html>
<!DOCTYPE html>
<html lang="vi">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>Reaction Simulator — Canifa AI</title>
<link rel="stylesheet" href="/static/lab.css">
<link rel="stylesheet" href="/static/dashboard.css">
<script src="/static/frame-detect.js"></script>
<style>
body{margin:0;background:var(--bg);overflow:hidden;height:100vh;display:flex;flex-direction:column}
/* TOPBAR */
.page-topbar{height:50px;background:var(--s);border-bottom:1px solid var(--b);display:flex;align-items:center;padding:0 20px;flex-shrink:0;gap:12px}
.topbar-icon{width:30px;height:30px;background:linear-gradient(135deg,#EA580C,#B45309);border-radius:8px;display:flex;align-items:center;justify-content:center;font-size:13px;color:#fff}
.topbar-title{font-size:14px;font-weight:700;color:var(--t)}
.topbar-badges{margin-left:auto;display:flex;gap:6px}
.tbadge{padding:3px 8px;border-radius:12px;font-size:9px;font-weight:700;letter-spacing:.04em}
.tbadge-ai{background:var(--diamond-l);color:var(--diamond)}
.tbadge-beta{background:var(--orange-l);color:var(--orange)}
/* TABS */
.tab-bar{display:flex;background:var(--s);border-bottom:1px solid var(--b);padding:0 20px;flex-shrink:0}
.tab-btn{padding:10px 18px;font-size:12px;font-weight:600;color:var(--m);cursor:pointer;border:none;background:none;border-bottom:2px solid transparent;font-family:inherit;transition:all .15s}
.tab-btn:hover{color:var(--t)}
.tab-btn.active{color:var(--gold);border-bottom-color:var(--gold)}
.tab-content{display:none;flex:1;overflow:hidden;min-height:0}
.tab-content.active{display:flex}
/* ═══ TAB 1: SIMULATOR ═══ */
.sim-layout{flex:1;display:flex;overflow:hidden}
.sim-left{width:360px;flex-shrink:0;background:var(--s);border-right:1px solid var(--b);display:flex;flex-direction:column;overflow:hidden}
.sim-right{flex:1;overflow-y:auto;padding:16px 20px;display:flex;flex-direction:column;gap:16px}
.sim-right::-webkit-scrollbar{width:3px}
.sim-right::-webkit-scrollbar-thumb{background:var(--b);border-radius:2px}
/* Form */
.form-section{padding:14px 16px;border-bottom:1px solid var(--b)}
.form-section:last-child{border-bottom:none}
.form-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:var(--m);margin-bottom:6px}
.form-select,.form-textarea{width:100%;padding:9px 10px;border:1px solid var(--b);border-radius:var(--rs);font-size:12px;font-family:inherit;color:var(--t);background:var(--bg);outline:none}
.form-textarea{min-height:90px;resize:vertical;line-height:1.6}
.form-textarea:focus,.form-select:focus{border-color:var(--gold);box-shadow:0 0 0 2px rgba(180,83,9,.06)}
.form-textarea::placeholder{color:var(--f)}
/* Tuning sliders */
.tuning-section{padding:10px 16px;flex:1;overflow-y:auto}
.tuning-title{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:var(--m);margin-bottom:8px;display:flex;align-items:center;gap:6px}
.slider-row{display:flex;align-items:center;gap:8px;margin-bottom:6px}
.slider-name{font-size:10px;font-weight:600;color:var(--t);min-width:90px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.slider-input{flex:1;accent-color:var(--gold);height:4px}
.slider-val{font-size:10px;font-weight:700;color:var(--gold);min-width:28px;text-align:right;font-family:'Fraunces',serif}
/* Buttons */
.btn-row{padding:10px 16px;display:flex;gap:8px}
.btn-sim{flex:1;padding:10px;border:none;border-radius:var(--rs);font-size:12px;font-weight:600;font-family:inherit;cursor:pointer;transition:all .2s;display:flex;align-items:center;justify-content:center;gap:6px}
.btn-primary{background:var(--t);color:#fff}
.btn-primary:hover{opacity:.85}
.btn-secondary{background:var(--bg);color:var(--t);border:1px solid var(--b)}
.btn-sim:disabled{opacity:.4;cursor:not-allowed}
.spinner{width:12px;height:12px;border:2px solid rgba(255,255,255,.3);border-top-color:#fff;border-radius:50%;animation:spin .6s linear infinite;display:none}
.btn-sim.loading .spinner{display:block}
.btn-sim.loading .btn-text{display:none}
@keyframes spin{to{transform:rotate(360deg)}}
/* Presets */
.presets{padding:10px 16px;border-top:1px solid var(--b)}
.preset-chips{display:flex;flex-wrap:wrap;gap:4px}
.preset-chip{padding:4px 10px;border-radius:14px;border:1px solid var(--b);font-size:10px;color:var(--m);cursor:pointer;transition:all .12s;font-family:inherit;background:var(--bg)}
.preset-chip:hover{border-color:var(--gold);color:var(--gold);background:var(--gold-l)}
/* Empty state */
.empty-state{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:10px;color:var(--f)}
.empty-icon{font-size:40px;opacity:.4}
.empty-title{font-size:14px;font-weight:600;color:var(--m)}
.empty-desc{font-size:11px;color:var(--f);text-align:center;max-width:280px;line-height:1.6}
/* Sentiment overview */
.sent-overview{display:grid;grid-template-columns:160px 1fr;gap:16px;padding:14px;background:var(--s);border:1px solid var(--b);border-radius:var(--r)}
.sent-chart{position:relative;width:130px;height:130px;margin:0 auto}
.sent-chart canvas{width:100%!important;height:100%!important}
.sent-center{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);text-align:center}
.sent-score{font-size:24px;font-weight:700;font-family:'Fraunces',serif;color:var(--t)}
.sent-lbl{font-size:9px;color:var(--m);font-weight:600;text-transform:uppercase;letter-spacing:.06em}
.sent-bars{display:flex;flex-direction:column;justify-content:center;gap:8px}
.sbar{display:flex;align-items:center;gap:8px}
.sbar-label{font-size:10px;font-weight:600;min-width:60px;color:var(--m)}
.sbar-track{flex:1;height:6px;background:var(--bg);border-radius:3px;overflow:hidden}
.sbar-fill{height:100%;border-radius:3px;transition:width 1s ease}
.sbar-val{font-size:11px;font-weight:700;min-width:30px;text-align:right;font-family:'Fraunces',serif}
.fill-pos{background:#059669}.fill-neu{background:#D97706}.fill-neg{background:#DC2626}
.val-pos{color:#059669}.val-neu{color:#D97706}.val-neg{color:#DC2626}
/* Section title */
.sec-title{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:var(--m);display:flex;align-items:center;gap:6px}
.sec-count{background:var(--bg);border:1px solid var(--b);padding:1px 6px;border-radius:10px;font-size:9px;font-family:'Fraunces',serif;color:var(--t)}
/* Reaction cards */
.rx-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:10px}
.rx-card{background:var(--s);border:1px solid var(--b);border-radius:var(--r);overflow:hidden;transition:all .2s}
.rx-card:hover{box-shadow:0 3px 12px rgba(0,0,0,.04)}
.rx-head{padding:10px 14px;display:flex;align-items:center;gap:8px;border-bottom:1px solid var(--b)}
.rx-avatar{width:32px;height:32px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:12px;color:#fff;font-weight:700;flex-shrink:0}
.rx-info{flex:1;min-width:0}
.rx-name{font-size:12px;font-weight:600;color:var(--t)}
.rx-seg{font-size:9px;color:var(--m);margin-top:1px}
.rx-sent{padding:2px 8px;border-radius:10px;font-size:9px;font-weight:700;flex-shrink:0}
.s-pos{background:var(--green-l);color:var(--green)}.s-neu{background:var(--gold-l);color:var(--gold)}.s-neg{background:var(--red-l);color:var(--red)}
.rx-body{padding:12px 14px}
.rx-comment{font-size:12px;color:var(--t);line-height:1.6;font-style:italic;padding-left:14px;position:relative}
.rx-comment::before{content:'"';position:absolute;left:0;top:-3px;font-size:20px;color:var(--f);font-family:'Fraunces',serif}
.rx-meta{display:flex;gap:10px;margin-top:8px;padding-top:8px;border-top:1px solid var(--b)}
.rx-mi{font-size:9px;color:var(--m);display:flex;align-items:center;gap:3px}
/* Agent thinking */
.think-box{background:var(--s);border:1px solid var(--b);border-radius:var(--r);padding:16px}
.ag-row{display:flex;align-items:center;gap:10px;padding:6px 0}
.ag-icon{width:28px;height:28px;border-radius:6px;display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:700;color:#fff;flex-shrink:0}
.ag-name{font-size:11px;font-weight:600;color:var(--t);min-width:90px}
.ag-status{font-size:10px;color:var(--m);flex:1}
.ag-dots{display:flex;gap:3px}
.ag-dots span{width:5px;height:5px;border-radius:50%;background:var(--f);animation:dp 1.4s infinite}
.ag-dots span:nth-child(2){animation-delay:.2s}
.ag-dots span:nth-child(3){animation-delay:.4s}
@keyframes dp{0%,80%,100%{opacity:.3;transform:scale(.8)}40%{opacity:1;transform:scale(1)}}
.ag-check{color:var(--green);font-weight:700;font-size:11px}
/* Recommendations */
.reco-box{background:var(--s);border:1px solid var(--b);border-radius:var(--r);padding:14px}
.reco-item{display:flex;gap:8px;padding:6px 0;border-bottom:1px solid var(--b);font-size:11px;line-height:1.5}
.reco-item:last-child{border-bottom:none}
.reco-icon{font-size:12px;flex-shrink:0;margin-top:1px}
.reco-text{color:var(--t)}.reco-text strong{color:var(--gold)}
/* ═══ TAB 2: CRISIS ALERT ═══ */
.crisis-layout{flex:1;overflow-y:auto;padding:20px;display:flex;flex-direction:column;gap:16px}
.crisis-stats{display:grid;grid-template-columns:repeat(4,1fr);gap:12px}
.crisis-stat{background:var(--s);border:1px solid var(--b);border-radius:var(--r);padding:16px;text-align:center}
.cs-value{font-size:28px;font-weight:700;font-family:'Fraunces',serif}
.cs-label{font-size:10px;color:var(--m);font-weight:600;text-transform:uppercase;letter-spacing:.06em;margin-top:4px}
.cs-trend{font-size:10px;font-weight:700;margin-top:2px}
.trend-up{color:var(--red)}.trend-down{color:var(--green)}.trend-flat{color:var(--m)}
.alert-feed{display:flex;flex-direction:column;gap:8px}
.alert-card{background:var(--s);border:1px solid var(--b);border-radius:var(--r);padding:12px 16px;display:flex;align-items:flex-start;gap:12px}
.alert-severity{width:8px;height:8px;border-radius:50%;flex-shrink:0;margin-top:4px}
.sev-critical{background:#DC2626}.sev-warning{background:#D97706}.sev-info{background:#0891B2}
.alert-body{flex:1}
.alert-title{font-size:12px;font-weight:600;color:var(--t)}
.alert-desc{font-size:11px;color:var(--m);margin-top:2px;line-height:1.5}
.alert-time{font-size:9px;color:var(--f);margin-top:4px}
.alert-tag{padding:2px 6px;border-radius:8px;font-size:9px;font-weight:700;margin-left:auto;flex-shrink:0}
.tag-critical{background:var(--red-l);color:var(--red)}.tag-warning{background:var(--gold-l);color:var(--gold)}.tag-info{background:var(--cyan-l);color:var(--cyan)}
/* ═══ TAB 3: DOCS ═══ */
.docs-layout{flex:1;overflow-y:auto;padding:24px 32px;max-width:800px}
.docs-layout h2{font-size:18px;font-family:'Fraunces',serif;margin:20px 0 10px;color:var(--t)}
.docs-layout h3{font-size:14px;margin:14px 0 6px;color:var(--gold)}
.docs-layout p,.docs-layout li{font-size:12px;line-height:1.7;color:var(--m)}
.docs-layout ul{padding-left:20px}
.docs-layout code{background:var(--bg);padding:1px 6px;border-radius:4px;font-size:11px}
.docs-layout .arch-box{background:var(--s);border:1px solid var(--b);border-radius:var(--r);padding:16px;margin:12px 0;font-family:monospace;font-size:11px;line-height:1.8;white-space:pre;overflow-x:auto;color:var(--t)}
.docs-layout .feature-grid{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin:12px 0}
.docs-layout .feat-card{background:var(--s);border:1px solid var(--b);border-radius:var(--rs);padding:12px}
.feat-card .feat-icon{font-size:20px;margin-bottom:6px}
.feat-card .feat-title{font-size:12px;font-weight:700;color:var(--t)}
.feat-card .feat-desc{font-size:10px;color:var(--m);margin-top:4px;line-height:1.5}
@media(max-width:900px){.sim-left{width:100%;max-height:35vh;border-right:none;border-bottom:1px solid var(--b)}.sim-layout{flex-direction:column}.crisis-stats{grid-template-columns:1fr 1fr}.docs-layout .feature-grid{grid-template-columns:1fr}}
</style>
</head>
<body>
<!-- TOP BAR -->
<div class="page-topbar">
<div class="topbar-icon">🔮</div>
<div class="topbar-title">Community Reaction Simulator</div>
<div class="topbar-badges">
<span class="tbadge tbadge-ai">AI-Powered</span>
<span class="tbadge tbadge-beta">BETA</span>
</div>
</div>
<!-- TAB BAR -->
<div class="tab-bar">
<button class="tab-btn active" onclick="switchTab('simulator')">🔮 Simulator</button>
<button class="tab-btn" onclick="switchTab('crisis')">🚨 Crisis Alert</button>
<button class="tab-btn" onclick="switchTab('docs')">📄 Docs</button>
</div>
<!-- ═══════ TAB 1: SIMULATOR ═══════ -->
<div class="tab-content active" id="tab-simulator">
<div class="sim-layout">
<div class="sim-left">
<div class="form-section">
<div class="form-label">Loại nội dung</div>
<select class="form-select" id="campaignType">
<option value="product_launch">🚀 Ra mắt sản phẩm</option>
<option value="promotion">🏷️ Khuyến mãi</option>
<option value="social_post">📱 Social Post</option>
<option value="price_change">💰 Thay đổi giá</option>
<option value="collection">👗 BST mới</option>
<option value="collab">🤝 Collab</option>
</select>
</div>
<div class="form-section">
<div class="form-label">Nội dung chiến dịch</div>
<textarea class="form-textarea" id="campaignContent" placeholder="VD: Canifa ra mắt BST Hè 2026..."></textarea>
</div>
<div class="tuning-section">
<div class="tuning-title">⚙️ Persona Weight Tuning</div>
<div id="slidersContainer"></div>
</div>
<div class="btn-row">
<button class="btn-sim btn-primary" id="btnSim" onclick="runSimulation()">
<span class="spinner"></span>
<span class="btn-text">🔮 Simulate</span>
</button>
<button class="btn-sim btn-secondary" onclick="runABMode()">A/B Test</button>
</div>
<div class="presets">
<div class="form-label">Thử nhanh</div>
<div class="preset-chips">
<button class="preset-chip" onclick="usePreset(0)">BST Hè</button>
<button class="preset-chip" onclick="usePreset(1)">Sale 50%</button>
<button class="preset-chip" onclick="usePreset(2)">Collab</button>
<button class="preset-chip" onclick="usePreset(3)">Tăng giá</button>
<button class="preset-chip" onclick="usePreset(4)">Polo mới</button>
</div>
</div>
</div>
<div class="sim-right" id="simResults">
<div class="empty-state" id="emptyState">
<div class="empty-icon">🔮</div>
<div class="empty-title">Chưa có dữ liệu</div>
<div class="empty-desc">Nhập chiến dịch và nhấn Simulate để xem AI dự đoán phản ứng cộng đồng</div>
</div>
</div>
</div>
</div>
<!-- ═══════ TAB 2: CRISIS ALERT ═══════ -->
<div class="tab-content" id="tab-crisis">
<div class="crisis-layout" id="crisisContent"></div>
</div>
<!-- ═══════ TAB 3: DOCS ═══════ -->
<div class="tab-content" id="tab-docs">
<div class="docs-layout" id="docsContent"></div>
</div>
<script>
// ═══ TAB SYSTEM ═══
function switchTab(name){
document.querySelectorAll('.tab-btn').forEach((b,i)=>{
b.classList.toggle('active', b.textContent.toLowerCase().includes(name.slice(0,4)));
});
document.querySelectorAll('.tab-content').forEach(t=>t.classList.remove('active'));
document.getElementById('tab-'+name).classList.add('active');
if(name==='crisis' && !crisisLoaded) loadCrisis();
if(name==='docs' && !docsLoaded) loadDocs();
}
// ═══ PERSONAS & SLIDERS ═══
const personas = [
{id:'mom_shopper',name:'Mom Shopper',color:'#6D28D9',weight:20},
{id:'young_professional',name:'Young Professional',color:'#1D4ED8',weight:20},
{id:'fashion_enthusiast',name:'Fashion Enthusiast',color:'#DB2777',weight:15},
{id:'budget_conscious',name:'Budget Conscious',color:'#B45309',weight:15},
{id:'gen_z',name:'Gen Z Trendy',color:'#EA580C',weight:15},
{id:'kol',name:'KOL / Blogger',color:'#0891B2',weight:10},
{id:'troll',name:'Troll',color:'#78716C',weight:5}
];
function initSliders(){
const c=document.getElementById('slidersContainer');
c.innerHTML='';
personas.forEach(p=>{
c.innerHTML+=`<div class="slider-row">
<span class="slider-name" style="color:${p.color}">${p.name}</span>
<input type="range" class="slider-input" min="0" max="40" value="${p.weight}" data-id="${p.id}" oninput="this.nextElementSibling.textContent=this.value+'%'">
<span class="slider-val">${p.weight}%</span>
</div>`;
});
}
initSliders();
// ═══ PRESETS ═══
const presets = [
{type:'collection',content:'Canifa ra mắt BST Hè 2026 "Coastal Breeze" — Linen & Cotton hữu cơ, minimalist. Giá 299K-599K. Free ship từ 500K.'},
{type:'promotion',content:'⚡ FLASH SALE — Giảm 50% toàn bộ Polo, Sơ mi, Jeans! Chỉ 2 ngày cuối tuần. Mua 2 giảm thêm 10%.'},
{type:'collab',content:'Canifa x NEM — BST "Urban Heritage" limited 500 bộ. Phong cách công sở + họa tiết truyền thống. Giá 799K-1.299K.'},
{type:'price_change',content:'Thông báo: Canifa điều chỉnh giá tăng 15% từ 01/07 do chi phí nguyên liệu. SP cũ giữ nguyên đến hết hàng.'},
{type:'product_launch',content:'MỚI — Polo DryTech 3.0: Khô nhanh, kháng khuẩn, chống UV. 12 màu, slim-fit. Giá 399K (tuần đầu, giá gốc 499K).'}
];
function usePreset(i){document.getElementById('campaignType').value=presets[i].type;document.getElementById('campaignContent').value=presets[i].content;}
// ═══ MOCK REACTIONS ═══
function getMockReactions(type){
const sets={
collection:[
{pid:'mom_shopper',name:'Nguyễn Thị Mai',seg:'Mom Shopper',age:32,city:'Hà Nội',sentiment:'positive',comment:'BST linen mát cho hè! 299K hợp lý, mua cho cả nhà mặc đi biển được luôn!',likes:38,shares:6},
{pid:'young_professional',name:'Trần Văn Hùng',seg:'Young Professional',age:28,city:'TP.HCM',sentiment:'neutral',comment:'Minimalist Nhật Bản nghe hay nhưng cần xem thực tế. Canifa đôi khi overdesign.',likes:21,shares:3},
{pid:'fashion_enthusiast',name:'Lê Phương Anh',seg:'Fashion Enthusiast',age:35,city:'Đà Nẵng',sentiment:'positive',comment:'Linen + Cotton organic + Minimalist = đúng trend! Giá competitive. Wait list ngay!',likes:54,shares:14},
{pid:'budget_conscious',name:'Phạm Minh Tuấn',seg:'Budget Conscious',age:41,city:'Hà Nội',sentiment:'neutral',comment:'Linen dễ nhăn, 599K cho quần linen cần cân nhắc.',likes:16,shares:1},
{pid:'gen_z',name:'Hoàng Thúy Linh',seg:'Gen Z Trendy',age:22,city:'TP.HCM',sentiment:'positive',comment:'"Coastal Breeze" vibes biển chill ghê 🌊 Content review BST này sẽ viral đó',likes:62,shares:19},
{pid:'kol',name:'Fashion Blogger',seg:'KOL / Blogger',age:29,city:'Hà Nội',sentiment:'positive',comment:'Canifa đúng hướng sustainable fashion. Linen organic giá mass market = win.',likes:102,shares:28},
{pid:'troll',name:'Random Commenter',seg:'Social Media Troll',age:25,city:'Online',sentiment:'negative',comment:'Lại "thiết kế Nhật Bản", brand VN nào cũng claim kiểu này 🥱',likes:11,shares:1}
],
price_change:[
{pid:'mom_shopper',name:'Nguyễn Thị Mai',seg:'Mom Shopper',age:32,city:'Hà Nội',sentiment:'negative',comment:'Tăng 15%?? Gia đình mua nhiều, chắc phải cân nhắc brand khác 😢',likes:89,shares:24},
{pid:'young_professional',name:'Trần Văn Hùng',seg:'Young Professional',age:28,city:'TP.HCM',sentiment:'negative',comment:'Tăng giá thì mất lợi thế với Uniqlo. Cùng tầm giá chọn brand Nhật hơn.',likes:67,shares:18},
{pid:'fashion_enthusiast',name:'Lê Phương Anh',seg:'Fashion Enthusiast',age:35,city:'Đà Nẵng',sentiment:'neutral',comment:'Nếu quality tăng theo thì OK, nhưng chỉ tăng giá thì khó chấp nhận.',likes:43,shares:8},
{pid:'budget_conscious',name:'Phạm Minh Tuấn',seg:'Budget Conscious',age:41,city:'Hà Nội',sentiment:'negative',comment:'15% quá nhiều! Thu nhập không tăng. Chuyển Coolmate thôi.',likes:112,shares:31},
{pid:'gen_z',name:'Hoàng Thúy Linh',seg:'Gen Z Trendy',age:22,city:'TP.HCM',sentiment:'negative',comment:'Canifa đắt lên mà design basic thì bye bye 👋',likes:78,shares:15},
{pid:'kol',name:'Fashion Blogger',seg:'KOL / Blogger',age:29,city:'Hà Nội',sentiment:'neutral',comment:'Tăng giá 15% trong lạm phát hiểu được, nhưng cần upgrade rõ ràng.',likes:95,shares:22},
{pid:'troll',name:'Random Commenter',seg:'Social Media Troll',age:25,city:'Online',sentiment:'negative',comment:'RIP Canifa. Từ brand bình dân giờ muốn lên sang 💀',likes:134,shares:42}
]
};
return sets[type]||sets.collection;
}
// ═══ SIMULATION ═══
let isRunning=false;
async function runSimulation(){
if(isRunning)return;
const content=document.getElementById('campaignContent').value.trim();
if(!content)return alert('Nhập nội dung chiến dịch!');
isRunning=true;
const btn=document.getElementById('btnSim');btn.classList.add('loading');btn.disabled=true;
const type=document.getElementById('campaignType').value;
const panel=document.getElementById('simResults');
document.getElementById('emptyState')?.remove();
// Phase 1: Thinking
panel.innerHTML=buildThinkHTML();
const rows=panel.querySelectorAll('.ag-row');
for(let i=0;i<rows.length;i++){
await delay(600+Math.random()*500);
rows[i].querySelector('.ag-dots').innerHTML='<span class="ag-check">✓</span>';
rows[i].querySelector('.ag-status').textContent='Hoàn thành';
rows[i].querySelector('.ag-status').style.color='var(--green)';
}
await delay(400);
// Phase 2: Results
const reactions=getMockReactions(type);
const sent=calcSent(reactions);
panel.innerHTML=buildResultsHTML(reactions,sent);
animateBars();
btn.classList.remove('loading');btn.disabled=false;isRunning=false;
}
function runABMode(){
const content=document.getElementById('campaignContent').value.trim();
if(!content)return alert('Nhập nội dung chiến dịch trước!');
alert('🚧 A/B Testing Mode sẽ available trong bản cập nhật tiếp theo!\n\nConcept: So sánh 2 phiên bản campaign side-by-side để xem version nào có sentiment tốt hơn.');
}
function delay(ms){return new Promise(r=>setTimeout(r,ms))}
function calcSent(rx){let p=0,n=0,u=0;rx.forEach(r=>{if(r.sentiment==='positive')p++;else if(r.sentiment==='neutral')u++;else n++;});const t=rx.length;return{pos:Math.round(p/t*100),neu:Math.round(u/t*100),neg:Math.round(n/t*100),total:t};}
function buildThinkHTML(){
const agents=[{n:'Persona Engine',c:'#6D28D9',s:'Phân tích user insight...'},{n:'Sentiment Agent',c:'#059669',s:'Dự đoán cảm xúc...'},{n:'Social Simulator',c:'#EA580C',s:'Giả lập phản ứng...'},{n:'Report Agent',c:'#1D4ED8',s:'Tổng hợp kết quả...'}];
let h='<div class="think-box"><div class="sec-title">🤖 AI Agents đang phân tích...</div>';
agents.forEach(a=>{h+=`<div class="ag-row"><div class="ag-icon" style="background:${a.c}">${a.n[0]}</div><div class="ag-name">${a.n}</div><div class="ag-status">${a.s}</div><div class="ag-dots"><span></span><span></span><span></span></div></div>`;});
return h+'</div>';
}
function buildResultsHTML(reactions,sent){
const pColors={mom_shopper:'#6D28D9',young_professional:'#1D4ED8',fashion_enthusiast:'#DB2777',budget_conscious:'#B45309',gen_z:'#EA580C',kol:'#0891B2',troll:'#78716C'};
let h=`<div class="sent-overview"><div class="sent-chart"><canvas id="sChart" width="130" height="130"></canvas><div class="sent-center"><div class="sent-score">${sent.pos}%</div><div class="sent-lbl">Tích cực</div></div></div>
<div class="sent-bars"><div class="sbar"><span class="sbar-label">Tích cực</span><div class="sbar-track"><div class="sbar-fill fill-pos" data-w="${sent.pos}" style="width:0"></div></div><span class="sbar-val val-pos">${sent.pos}%</span></div>
<div class="sbar"><span class="sbar-label">Trung lập</span><div class="sbar-track"><div class="sbar-fill fill-neu" data-w="${sent.neu}" style="width:0"></div></div><span class="sbar-val val-neu">${sent.neu}%</span></div>
<div class="sbar"><span class="sbar-label">Tiêu cực</span><div class="sbar-track"><div class="sbar-fill fill-neg" data-w="${sent.neg}" style="width:0"></div></div><span class="sbar-val val-neg">${sent.neg}%</span></div>
<div style="margin-top:4px;font-size:10px;color:var(--m)">Dựa trên <strong>${sent.total}</strong> persona</div></div></div>`;
h+=`<div class="sec-title">💬 Phản ứng dự đoán <span class="sec-count">${reactions.length}</span></div><div class="rx-grid">`;
reactions.forEach(r=>{
const sc=r.sentiment==='positive'?'s-pos':r.sentiment==='neutral'?'s-neu':'s-neg';
const st=r.sentiment==='positive'?'Tích cực':r.sentiment==='neutral'?'Trung lập':'Tiêu cực';
const col=pColors[r.pid]||'#78716C';
h+=`<div class="rx-card"><div class="rx-head"><div class="rx-avatar" style="background:${col}">${r.name[0]}</div><div class="rx-info"><div class="rx-name">${r.name}</div><div class="rx-seg">${r.seg} · ${r.age}t · ${r.city}</div></div><span class="rx-sent ${sc}">${st}</span></div><div class="rx-body"><div class="rx-comment">${r.comment}</div><div class="rx-meta"><span class="rx-mi">❤️ ${r.likes}</span><span class="rx-mi">🔄 ${r.shares}</span><span class="rx-mi">💬 ${Math.floor(Math.random()*10)+1}</span></div></div></div>`;
});
h+='</div>';
// Recommendations
const negC=reactions.filter(r=>r.sentiment==='negative').length;
const recos=[];
if(negC>=3)recos.push({i:'⚠️',t:'<strong>Cảnh báo:</strong> Tỷ lệ tiêu cực cao. Điều chỉnh thông điệp hoặc thêm ưu đãi đi kèm.'});
if(negC>=2)recos.push({i:'💡',t:'<strong>Tip:</strong> Chuẩn bị response template cho comment so sánh giá.'});
recos.push({i:'📊',t:'<strong>Insight:</strong> Gen Z và Fashion Enthusiast phản ứng tích cực nhất — ưu tiên TikTok/Instagram.'});
recos.push({i:'🎯',t:'<strong>Hành động:</strong> Kết hợp KOL review sớm để tăng credibility.'});
if(negC<2)recos.push({i:'🚀',t:'<strong>Tín hiệu tốt:</strong> Chiến dịch có tiềm năng viral — chuẩn bị stock!'});
h+='<div class="sec-title">📋 Gợi ý</div><div class="reco-box">';
recos.forEach(r=>{h+=`<div class="reco-item"><span class="reco-icon">${r.i}</span><div class="reco-text">${r.t}</div></div>`;});
h+='</div>';
setTimeout(()=>drawDonut(),80);
return h;
}
function animateBars(){setTimeout(()=>{document.querySelectorAll('.sbar-fill').forEach(e=>{e.style.width=e.dataset.w+'%';});},150);}
function drawDonut(){
const c=document.getElementById('sChart');if(!c)return;const ctx=c.getContext('2d');
const fills=document.querySelectorAll('.sbar-fill');const vals=[];fills.forEach(f=>vals.push(parseInt(f.dataset.w)||0));
const cols=['#059669','#D97706','#DC2626'];const total=vals.reduce((a,b)=>a+b,0)||1;
let sa=-Math.PI/2;ctx.clearRect(0,0,130,130);
vals.forEach((v,i)=>{const sl=(v/total)*Math.PI*2;ctx.beginPath();ctx.arc(65,65,50,sa,sa+sl);ctx.strokeStyle=cols[i];ctx.lineWidth=14;ctx.lineCap='round';ctx.stroke();sa+=sl+0.04;});
}
// ═══ CRISIS ALERT TAB ═══
let crisisLoaded=false;
function loadCrisis(){
crisisLoaded=true;
const c=document.getElementById('crisisContent');
c.innerHTML=`
<div class="sec-title">📊 Tổng quan 24h qua</div>
<div class="crisis-stats">
<div class="crisis-stat"><div class="cs-value" style="color:var(--t)">847</div><div class="cs-label">Mentions</div><div class="cs-trend trend-up">↑ +12%</div></div>
<div class="crisis-stat"><div class="cs-value" style="color:var(--green)">72%</div><div class="cs-label">Positive</div><div class="cs-trend trend-down">↓ -3%</div></div>
<div class="crisis-stat"><div class="cs-value" style="color:var(--red)">8%</div><div class="cs-label">Negative</div><div class="cs-trend trend-up">↑ +2%</div></div>
<div class="crisis-stat"><div class="cs-value" style="color:var(--gold)">2</div><div class="cs-label">Alerts</div><div class="cs-trend trend-flat">— 0</div></div>
</div>
<div class="sec-title">🚨 Cảnh báo gần đây</div>
<div class="alert-feed">
<div class="alert-card">
<div class="alert-severity sev-critical"></div>
<div class="alert-body">
<div class="alert-title">Phàn nàn hàng loạt về chất lượng áo Polo mùa mới</div>
<div class="alert-desc">15+ comment trên Facebook group "Hội mua sắm thông minh" phản ánh áo Polo DryTech bị phai màu sau 2 lần giặt. Cần xác minh lô hàng.</div>
<div class="alert-time">🕐 2 giờ trước · Facebook · 23 interactions</div>
</div>
<span class="alert-tag tag-critical">CRITICAL</span>
</div>
<div class="alert-card">
<div class="alert-severity sev-warning"></div>
<div class="alert-body">
<div class="alert-title">So sánh tiêu cực với Uniqlo trên TikTok</div>
<div class="alert-desc">Video review so sánh Canifa vs Uniqlo có 50K views, tone tiêu cực về chất liệu. Creator: @fashionvn (120K followers).</div>
<div class="alert-time">🕐 5 giờ trước · TikTok · 50K views</div>
</div>
<span class="alert-tag tag-warning">WARNING</span>
</div>
<div class="alert-card">
<div class="alert-severity sev-info"></div>
<div class="alert-body">
<div class="alert-title">BST Hè được báo Vnexpress đánh giá tích cực</div>
<div class="alert-desc">Bài PR trên Vnexpress Life nhận 200+ comments positive. Keyword: "chất lượng tốt", "giá hợp lý", "thiết kế đẹp".</div>
<div class="alert-time">🕐 8 giờ trước · Vnexpress · 200+ comments</div>
</div>
<span class="alert-tag tag-info">INFO</span>
</div>
<div class="alert-card">
<div class="alert-severity sev-warning"></div>
<div class="alert-body">
<div class="alert-title">Shopee reviews: Rating giảm từ 4.5 → 4.2 sao</div>
<div class="alert-desc">Trend giảm rating trên Shopee official store trong 7 ngày qua. Nguyên nhân chính: giao hàng chậm và size không đúng.</div>
<div class="alert-time">🕐 1 ngày trước · Shopee · 45 reviews</div>
</div>
<span class="alert-tag tag-warning">WARNING</span>
</div>
<div class="alert-card">
<div class="alert-severity sev-info"></div>
<div class="alert-body">
<div class="alert-title">KOL @styleguide_vn đăng bài collab positive</div>
<div class="alert-desc">Post Instagram collab reach 80K, engagement rate 5.2%. Tone rất positive, recommend BST Coastal Breeze.</div>
<div class="alert-time">🕐 1 ngày trước · Instagram · 80K reach</div>
</div>
<span class="alert-tag tag-info">INFO</span>
</div>
</div>`;
}
// ═══ DOCS TAB ═══
let docsLoaded=false;
function loadDocs(){
docsLoaded=true;
document.getElementById('docsContent').innerHTML=`
<h2>🔮 Community Reaction Simulator</h2>
<p>Hệ thống giả lập phản ứng cộng đồng khi Canifa launch chiến dịch mới. Lấy cảm hứng từ BettaFish ForumEngine và MiroFish Simulate Agent.</p>
<h3>💡 Concept</h3>
<p>Thay vì launch chiến dịch rồi mới biết phản ứng, hệ thống cho phép <strong>test trước</strong> bằng cách AI giả lập phản ứng từ các nhóm khách hàng thực tế dựa trên user insight data.</p>
<h3>🏗️ Architecture</h3>
<div class="arch-box">┌─────────────┐ ┌──────────────────┐ ┌─────────────┐
│ Campaign │────▶│ Reaction Agent │────▶│ Sentiment │
│ Input │ │ (LLM-powered) │ │ Analysis │
└─────────────┘ └──────┬───────────┘ └──────┬──────┘
│ │
┌──────▼───────┐ ┌──────▼──────┐
│ 7 Persona │ │ Recommend. │
│ Segments │ │ Engine │
└──────────────┘ └─────────────┘
Backend: /api/reaction-simulator/simulate
Agent: backend/agent/reaction_agent/
Frontend: static/reaction-simulator.html</div>
<h3>👥 Persona Segments</h3>
<div class="feature-grid">
<div class="feat-card"><div class="feat-icon">👩</div><div class="feat-title">Mom Shopper (20%)</div><div class="feat-desc">Phụ nữ 30-40t, mua cho gia đình, quan tâm chất lượng + giá</div></div>
<div class="feat-card"><div class="feat-icon">👨‍💼</div><div class="feat-title">Young Professional (20%)</div><div class="feat-desc">25-32t, smart casual công sở, theo dõi sale</div></div>
<div class="feat-card"><div class="feat-icon">👗</div><div class="feat-title">Fashion Enthusiast (15%)</div><div class="feat-desc">Sẵn sàng chi tiền, follow KOL, biết trend</div></div>
<div class="feat-card"><div class="feat-icon">💰</div><div class="feat-title">Budget Conscious (15%)</div><div class="feat-desc">So sánh giá kỹ, chờ sale, thực dụng</div></div>
<div class="feat-card"><div class="feat-icon">🔥</div><div class="feat-title">Gen Z Trendy (15%)</div><div class="feat-desc">18-25t, TikTok, local brand lover</div></div>
<div class="feat-card"><div class="feat-icon">📸</div><div class="feat-title">KOL / Blogger (10%)</div><div class="feat-desc">Review chuyên nghiệp, phân tích chiến lược</div></div>
<div class="feat-card"><div class="feat-icon">🎭</div><div class="feat-title">Social Media Troll (5%)</div><div class="feat-desc">Comment châm biếm, so sánh brand ngoại</div></div>
</div>
<h3>📋 Cách sử dụng</h3>
<ul>
<li><strong>Bước 1:</strong> Chọn loại chiến dịch (product launch, promotion, BST...)</li>
<li><strong>Bước 2:</strong> Nhập nội dung chiến dịch — càng chi tiết càng tốt</li>
<li><strong>Bước 3:</strong> Tuỳ chỉnh Persona Weight nếu muốn focus segment cụ thể</li>
<li><strong>Bước 4:</strong> Nhấn <strong>Simulate</strong> để AI generate phản ứng</li>
<li><strong>Bước 5:</strong> Đọc kết quả sentiment + recommendations</li>
</ul>
<h3>🗺️ Roadmap</h3>
<ul>
<li>✅ <strong>v1.0</strong> — Mock simulation với 7 personas + preset campaigns</li>
<li>✅ <strong>v1.1</strong> — Persona Weight Tuning + Crisis Alert tab</li>
<li>🔲 <strong>v1.2</strong> — A/B Testing mode (so sánh 2 phiên bản campaign)</li>
<li>🔲 <strong>v1.3</strong> — LLM-powered reactions (kết nối GPT API)</li>
<li>🔲 <strong>v2.0</strong> — Real-time social monitoring + auto-alert</li>
<li>🔲 <strong>v2.1</strong> — Competitor Intelligence module</li>
<li>🔲 <strong>v3.0</strong> — Export PDF/HTML report cho team Marketing</li>
</ul>
<h3>🔗 Nguồn cảm hứng</h3>
<ul>
<li><strong>BettaFish (微舆)</strong> — Multi-agent public opinion analysis, ForumEngine pattern</li>
<li><strong>MiroFish</strong> — Simulate Agent architecture, persona-based testing</li>
<li><strong>Canifa User Insight</strong> — Real customer data cho persona generation</li>
</ul>
<h3>📡 API Endpoints</h3>
<ul>
<li><code>GET /api/reaction-simulator/segments</code> — List persona segments</li>
<li><code>GET /api/reaction-simulator/campaign-types</code> — List campaign types</li>
<li><code>POST /api/reaction-simulator/simulate</code> — LLM-powered simulation</li>
<li><code>POST /api/reaction-simulator/simulate-mock</code> — Mock data (no LLM)</li>
</ul>`;
}
</script>
</body>
</html>
...@@ -4,160 +4,172 @@ ...@@ -4,160 +4,172 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>User Insight — CDP</title> <title>User Insight — CDP</title>
<link rel="stylesheet" href="/static/css/theme.css"> <link rel="stylesheet" href="/static/lab.css">
<link rel="stylesheet" href="/static/css/components.css">
<link rel="stylesheet" href="/static/css/lab.css">
<style> <style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap'); @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap');
*{box-sizing:border-box;margin:0;padding:0} *{box-sizing:border-box;margin:0;padding:0}
body{font-family:var(--font-sans,'Inter',system-ui,sans-serif);background:var(--background,#f8f7f4);min-height:100vh;color:var(--foreground,#1c1917)} body{font-family:'Inter',system-ui,sans-serif;background:#F8F9FC;min-height:100vh;color:#1a1a2e}
.app-wrap{display:flex;height:100vh;overflow:hidden} .app-wrap{display:flex;height:100vh;overflow:hidden}
.user-list-pane{width:320px;border-right:1px solid var(--border,#e7e5e2);display:flex;flex-direction:column;background:var(--card,#ffffff);flex-shrink:0} .user-list-pane{width:320px;border-right:1px solid #E5E7EB;display:flex;flex-direction:column;background:#fff;flex-shrink:0}
.profile-pane{flex:1;overflow-y:auto;background:var(--background,#f8f7f4)} .profile-pane{flex:1;overflow-y:auto;background:#F8F9FC}
.list-header{padding:20px 20px 16px;border-bottom:1px solid var(--border,#e7e5e2)} .list-header{padding:20px 20px 16px;border-bottom:1px solid #E5E7EB}
.list-header h2{font-size:18px;font-weight:800;color:var(--foreground,#1c1917);margin-bottom:4px} .list-header h2{font-size:18px;font-weight:800;color:#1a1a2e;margin-bottom:4px}
.list-header p{font-size:12px;color:var(--muted-fg,#78716c)} .list-header p{font-size:12px;color:#6B7280}
.search-box{margin-top:12px;width:100%;padding:9px 14px;border:1px solid var(--border,#e7e5e2);border-radius:10px;font-size:13px;outline:none;background:var(--muted,#f0efec)} .search-box{margin-top:12px;width:100%;padding:9px 14px;border:1px solid #E5E7EB;border-radius:10px;font-size:13px;outline:none;background:#F9FAFB}
.search-box:focus{border-color:var(--primary,#3b5998);box-shadow:0 0 0 3px rgba(59,89,152,0.08)} .search-box:focus{border-color:#6366F1;box-shadow:0 0 0 3px rgba(99,102,241,.1)}
.user-items{flex:1;overflow-y:auto;padding:8px} .user-items{flex:1;overflow-y:auto;padding:8px}
.user-item{display:flex;align-items:center;gap:12px;padding:12px 14px;border-radius:12px;cursor:pointer;transition:all .15s;border:1.5px solid transparent;margin-bottom:4px} .user-item{display:flex;align-items:center;gap:12px;padding:12px 14px;border-radius:12px;cursor:pointer;transition:all .15s;border:1.5px solid transparent;margin-bottom:4px}
.user-item:hover{background:var(--muted,#f0efec)} .user-item:hover{background:#F3F4F6}
.user-item.active{background:var(--primary-light,#eef1f8);border-color:var(--primary,#3b5998)} .user-item.active{background:#EEF2FF;border-color:#6366F1}
.user-avatar{width:44px;height:44px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:16px;font-weight:800;color:#fff;flex-shrink:0} .user-avatar{width:44px;height:44px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:16px;font-weight:800;color:#fff;flex-shrink:0}
.user-item-info{flex:1;min-width:0} .user-item-info{flex:1;min-width:0}
.user-item-name{font-size:14px;font-weight:700;color:var(--foreground,#1c1917);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} .user-item-name{font-size:14px;font-weight:700;color:#1a1a2e;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.user-item-meta{font-size:11px;color:var(--muted-fg,#78716c);margin-top:2px} .user-item-meta{font-size:11px;color:#6B7280;margin-top:2px}
.user-item-right{text-align:right;flex-shrink:0} .user-item-right{text-align:right;flex-shrink:0}
.user-item-right .val{font-size:14px;font-weight:800;color:var(--foreground,#1c1917)} .user-item-right .val{font-size:14px;font-weight:800;color:#1a1a2e}
.user-item-right .lbl{font-size:10px;color:var(--muted-fg,#78716c)} .user-item-right .lbl{font-size:10px;color:#6B7280}
.profile-content{max-width:980px;margin:0 auto;padding:24px 28px 40px} .profile-content{max-width:980px;margin:0 auto;padding:24px 28px 40px}
.profile-empty{display:flex;align-items:center;justify-content:center;height:100%;text-align:center;color:var(--muted-fg,#78716c);font-size:15px} .profile-empty{display:flex;align-items:center;justify-content:center;height:100%;text-align:center;color:#9CA3AF;font-size:15px}
/* Tabs */ /* Tabs */
.profile-tabs{display:flex;gap:32px;border-bottom:2px solid var(--border,#e7e5e2);margin-bottom:24px} .profile-tabs{display:flex;gap:32px;border-bottom:2px solid #E5E7EB;margin-bottom:24px}
.tab-btn{padding:0 8px 16px;background:none;border:none;font-size:15px;font-weight:700;color:var(--muted-fg,#78716c);cursor:pointer;position:relative;transition:color .2s} .tab-btn{padding:0 8px 16px;background:none;border:none;font-size:15px;font-weight:700;color:#6B7280;cursor:pointer;position:relative;transition:color .2s}
.tab-btn:hover{color:var(--foreground,#1c1917)} .tab-btn:hover{color:#1a1a2e}
.tab-btn.active{color:var(--primary,#3b5998)} .tab-btn.active{color:#6366F1}
.tab-btn.active::after{content:'';position:absolute;bottom:-2px;left:0;right:0;height:3px;background:var(--primary,#3b5998);border-radius:3px 3px 0 0} .tab-btn.active::after{content:'';position:absolute;bottom:-2px;left:0;right:0;height:3px;background:#6366F1;border-radius:3px 3px 0 0}
.tab-pane{display:none;animation:fadeIn .3s ease} .tab-pane{display:none;animation:fadeIn .3s ease}
.tab-pane.active{display:block} .tab-pane.active{display:block}
@keyframes fadeIn{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:translateY(0)}} @keyframes fadeIn{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:translateY(0)}}
.section{background:var(--card,#ffffff);border:1px solid var(--border,#e7e5e2);border-radius:14px;padding:20px 24px;margin-bottom:16px} .section{background:#fff;border:1px solid #E5E7EB;border-radius:14px;padding:20px 24px;margin-bottom:16px}
.section-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:var(--muted-fg,#78716c);margin-bottom:14px} .section-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:#6B7280;margin-bottom:14px}
/* Overview */ /* Overview */
.overview-grid{display:flex;align-items:center;gap:20px} .overview-grid{display:flex;align-items:center;gap:20px}
.overview-avatar{width:72px;height:72px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:28px;font-weight:800;color:#fff;flex-shrink:0} .overview-avatar{width:72px;height:72px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:28px;font-weight:800;color:#fff;flex-shrink:0}
.overview-details{flex:1;display:grid;grid-template-columns:1fr 1fr 1fr;gap:4px 24px} .overview-details{flex:1;display:grid;grid-template-columns:1fr 1fr 1fr;gap:4px 24px}
.overview-field-label{font-size:10px;color:var(--muted-fg,#78716c);font-weight:600} .overview-field-label{font-size:10px;color:#9CA3AF;font-weight:600}
.overview-field-value{font-size:14px;font-weight:700;color:var(--foreground,#1c1917);margin-bottom:8px} .overview-field-value{font-size:14px;font-weight:700;color:#1a1a2e;margin-bottom:8px}
.channel-tag{display:inline-block;font-size:10px;font-weight:700;padding:3px 8px;border-radius:6px;background:var(--primary-light,#eef1f8);color:var(--primary,#3b5998);margin-right:4px;margin-top:4px} .channel-tag{display:inline-block;font-size:10px;font-weight:700;padding:3px 8px;border-radius:6px;background:#EEF2FF;color:#4338CA;margin-right:4px;margin-top:4px}
/* Health Score */ /* Health Score */
.health-row{display:grid;grid-template-columns:180px 1fr;gap:20px;align-items:center} .health-row{display:grid;grid-template-columns:180px 1fr;gap:20px;align-items:center}
.health-ring{width:140px;height:140px;position:relative;margin:0 auto} .health-ring{width:140px;height:140px;position:relative;margin:0 auto}
.health-ring svg{width:100%;height:100%} .health-ring svg{width:100%;height:100%}
.health-ring .score-text{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);text-align:center} .health-ring .score-text{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);text-align:center}
.health-ring .score-num{font-size:36px;font-weight:900;line-height:1} .health-ring .score-num{font-size:36px;font-weight:900;line-height:1}
.health-ring .score-lbl{font-size:10px;font-weight:700;color:var(--muted-fg,#78716c);text-transform:uppercase;letter-spacing:.06em} .health-ring .score-lbl{font-size:10px;font-weight:700;color:#6B7280;text-transform:uppercase;letter-spacing:.06em}
.health-factors{display:grid;grid-template-columns:1fr 1fr 1fr;gap:14px} .health-factors{display:grid;grid-template-columns:1fr 1fr 1fr;gap:14px}
.factor-card{padding:14px;border-radius:10px;border:1px solid var(--border,#e7e5e2);background:var(--muted,#f0efec)} .factor-card{padding:14px;border-radius:10px;border:1px solid #E5E7EB;background:#F9FAFB}
.factor-card .f-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:10px} .factor-card .f-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:10px}
.factor-card .f-name{font-size:11px;font-weight:700;color:var(--foreground,#1c1917);text-transform:uppercase;letter-spacing:.04em} .factor-card .f-name{font-size:11px;font-weight:700;color:#374151;text-transform:uppercase;letter-spacing:.04em}
.factor-card .f-score{font-size:18px;font-weight:900} .factor-card .f-score{font-size:18px;font-weight:900}
.factor-bar{width:100%;height:6px;background:#E5E7EB;border-radius:3px;overflow:hidden} .factor-bar{width:100%;height:6px;background:#E5E7EB;border-radius:3px;overflow:hidden}
.factor-bar-fill{height:100%;border-radius:3px;transition:width .6s ease} .factor-bar-fill{height:100%;border-radius:3px;transition:width .6s ease}
/* Metrics */ /* Metrics */
.metrics-row{display:grid;grid-template-columns:repeat(5,1fr);gap:0} .metrics-row{display:grid;grid-template-columns:repeat(5,1fr);gap:0}
.metric-cell{text-align:center;padding:12px 8px;border-right:1px solid var(--border,#e7e5e2)} .metric-cell{text-align:center;padding:12px 8px;border-right:1px solid #F3F4F6}
.metric-cell:last-child{border-right:none} .metric-cell:last-child{border-right:none}
.metric-cell .m-label{font-size:10px;color:var(--muted-fg,#78716c);font-weight:600;text-transform:uppercase;letter-spacing:.06em;margin-bottom:8px} .metric-cell .m-label{font-size:10px;color:#6B7280;font-weight:600;text-transform:uppercase;letter-spacing:.06em;margin-bottom:8px}
.metric-cell .m-value{font-size:28px;font-weight:800;color:var(--foreground,#1c1917);line-height:1} .metric-cell .m-value{font-size:28px;font-weight:800;color:#1a1a2e;line-height:1}
.metric-cell .m-unit{font-size:11px;color:var(--muted-fg,#78716c);font-weight:600} .metric-cell .m-unit{font-size:11px;color:#9CA3AF;font-weight:600}
/* Predictions */ /* Predictions */
.pred-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:14px} .pred-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:14px}
.pred-card{padding:16px;border-radius:12px;text-align:center} .pred-card{padding:16px;border-radius:12px;text-align:center}
.pred-card .pred-icon{font-size:24px;margin-bottom:8px} .pred-card .pred-icon{font-size:24px;margin-bottom:8px}
.pred-card .pred-val{font-size:22px;font-weight:800;line-height:1;margin-bottom:4px} .pred-card .pred-val{font-size:22px;font-weight:800;line-height:1;margin-bottom:4px}
.pred-card .pred-lbl{font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-fg,#78716c)} .pred-card .pred-lbl{font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:#6B7280}
.pred-green{background:#F0FDF4;border:1px solid #BBF7D0}.pred-green .pred-val{color:#059669} .pred-green{background:#F0FDF4;border:1px solid #BBF7D0}.pred-green .pred-val{color:#059669}
.pred-amber{background:#FFFBEB;border:1px solid #FDE68A}.pred-amber .pred-val{color:#D97706} .pred-amber{background:#FFFBEB;border:1px solid #FDE68A}.pred-amber .pred-val{color:#D97706}
.pred-red{background:#FEF2F2;border:1px solid #FECACA}.pred-red .pred-val{color:#DC2626} .pred-red{background:#FEF2F2;border:1px solid #FECACA}.pred-red .pred-val{color:#DC2626}
.pred-blue{background:#EFF6FF;border:1px solid #BFDBFE}.pred-blue .pred-val{color:#2563EB} .pred-blue{background:#EFF6FF;border:1px solid #BFDBFE}.pred-blue .pred-val{color:#2563EB}
/* Three-col */ /* Three-col */
.three-col{display:grid;grid-template-columns:1fr 1.2fr 1fr;gap:16px} .three-col{display:grid;grid-template-columns:1fr 1.2fr 1fr;gap:16px}
.segment-row{display:flex;justify-content:space-between;padding:8px 0;border-bottom:1px solid var(--border,#e7e5e2)} .segment-row{display:flex;justify-content:space-between;padding:8px 0;border-bottom:1px solid #F3F4F6}
.segment-row:last-child{border-bottom:none} .segment-row:last-child{border-bottom:none}
.seg-label{font-size:13px;color:var(--foreground,#1c1917)} .seg-label{font-size:13px;color:#374151}
.seg-value{font-size:13px;font-weight:700} .seg-value{font-size:13px;font-weight:700}
.seg-high{color:#059669}.seg-medium{color:#D97706}.seg-low{color:var(--muted-fg,#78716c)}.seg-vip{color:#7C3AED}.seg-active{color:#059669} .seg-high{color:#059669}.seg-medium{color:#D97706}.seg-low{color:#6B7280}.seg-vip{color:#7C3AED}.seg-active{color:#059669}
.donut-wrap{display:flex;flex-direction:column;align-items:center;gap:14px} .donut-wrap{display:flex;flex-direction:column;align-items:center;gap:14px}
.donut-svg{width:140px;height:140px} .donut-svg{width:140px;height:140px}
.donut-legend{display:flex;flex-direction:column;gap:5px;width:100%} .donut-legend{display:flex;flex-direction:column;gap:5px;width:100%}
.legend-row{display:flex;align-items:center;gap:8px;font-size:12px;color:var(--foreground,#1c1917)} .legend-row{display:flex;align-items:center;gap:8px;font-size:12px;color:#374151}
.legend-dot{width:10px;height:10px;border-radius:50%;flex-shrink:0} .legend-dot{width:10px;height:10px;border-radius:50%;flex-shrink:0}
.legend-pct{margin-left:auto;font-weight:700;color:var(--foreground,#1c1917)} .legend-pct{margin-left:auto;font-weight:700;color:#1a1a2e}
.attr-row{display:flex;justify-content:space-between;padding:7px 0;border-bottom:1px solid var(--border,#e7e5e2);font-size:13px} .attr-row{display:flex;justify-content:space-between;padding:7px 0;border-bottom:1px solid #F3F4F6;font-size:13px}
.attr-row:last-child{border-bottom:none} .attr-row:last-child{border-bottom:none}
.attr-label{color:var(--muted-fg,#78716c)}.attr-value{font-weight:700;color:var(--foreground,#1c1917);text-align:right;max-width:55%} .attr-label{color:#6B7280}.attr-value{font-weight:700;color:#1a1a2e;text-align:right;max-width:55%}
/* Product cards */ /* Product cards */
.product-cards{display:grid;grid-template-columns:repeat(3,1fr);gap:16px} .product-cards{display:grid;grid-template-columns:repeat(3,1fr);gap:16px}
.product-card{display:flex;align-items:center;gap:14px;padding:14px;background:var(--muted,#f0efec);border-radius:12px;border:1px solid var(--border,#e7e5e2)} .product-card{display:flex;align-items:center;gap:14px;padding:14px;background:#F9FAFB;border-radius:12px;border:1px solid #E5E7EB}
.product-card-icon{width:52px;height:52px;border-radius:10px;background:var(--primary-light,#eef1f8);display:flex;align-items:center;justify-content:center;font-size:20px;flex-shrink:0} .product-card-icon{width:52px;height:52px;border-radius:10px;background:#EEF2FF;display:flex;align-items:center;justify-content:center;font-size:20px;flex-shrink:0}
.product-card-info{flex:1} .product-card-info{flex:1}
.product-card-label{font-size:10px;color:var(--muted-fg,#78716c);font-weight:600;text-transform:uppercase;letter-spacing:.06em;margin-bottom:3px} .product-card-label{font-size:10px;color:#6B7280;font-weight:600;text-transform:uppercase;letter-spacing:.06em;margin-bottom:3px}
.product-card-name{font-size:13px;font-weight:700;color:var(--foreground,#1c1917);margin-bottom:2px} .product-card-name{font-size:13px;font-weight:700;color:#1a1a2e;margin-bottom:2px}
.product-card-price{font-size:18px;font-weight:800;color:var(--foreground,#1c1917)} .product-card-price{font-size:18px;font-weight:800;color:#1a1a2e}
/* Purchase Timeline */ /* Purchase Timeline */
.timeline{position:relative;padding-left:28px} .timeline{position:relative;padding-left:28px}
.timeline::before{content:'';position:absolute;left:10px;top:4px;bottom:4px;width:2px;background:#E5E7EB;border-radius:1px} .timeline::before{content:'';position:absolute;left:10px;top:4px;bottom:4px;width:2px;background:#E5E7EB;border-radius:1px}
.tl-item{position:relative;padding:10px 0 14px} .tl-item{position:relative;padding:10px 0 14px}
.tl-item::before{content:'';position:absolute;left:-22px;top:14px;width:10px;height:10px;border-radius:50%;background:var(--primary,#3b5998);border:2px solid #fff;box-shadow:0 0 0 2px var(--primary,#3b5998);z-index:1} .tl-item::before{content:'';position:absolute;left:-22px;top:14px;width:10px;height:10px;border-radius:50%;background:#6366F1;border:2px solid #fff;box-shadow:0 0 0 2px #6366F1;z-index:1}
.tl-date{font-size:11px;font-weight:600;color:var(--muted-fg,#78716c);margin-bottom:4px} .tl-date{font-size:11px;font-weight:600;color:#9CA3AF;margin-bottom:4px}
.tl-content{display:flex;align-items:center;gap:10px} .tl-content{display:flex;align-items:center;gap:10px}
.tl-icon{font-size:18px} .tl-icon{font-size:18px}
.tl-detail{flex:1} .tl-detail{flex:1}
.tl-name{font-size:13px;font-weight:700;color:var(--foreground,#1c1917)} .tl-name{font-size:13px;font-weight:700;color:#1a1a2e}
.tl-sub{font-size:11px;color:var(--muted-fg,#78716c)} .tl-sub{font-size:11px;color:#6B7280}
.tl-price{font-size:14px;font-weight:800;color:var(--foreground,#1c1917);flex-shrink:0} .tl-price{font-size:14px;font-weight:800;color:#1a1a2e;flex-shrink:0}
/* Recommendation Engine */ /* Recommendation Engine */
.reco-section{border-top:1px solid var(--border,#e7e5e2);padding-top:16px;margin-top:6px} .reco-section{border-top:1px solid #E5E7EB;padding-top:16px;margin-top:6px}
.reco-strat-label{font-size:11px;font-weight:700;color:var(--primary,#3b5998);margin-bottom:10px;display:flex;align-items:center;gap:6px} .reco-strat-label{font-size:11px;font-weight:700;color:#6366F1;margin-bottom:10px;display:flex;align-items:center;gap:6px}
.reco-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:12px;margin-bottom:18px} .reco-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:12px;margin-bottom:18px}
.reco-item{display:flex;align-items:center;gap:10px;padding:10px 14px;background:var(--muted,#f0efec);border:1px solid var(--border,#e7e5e2);border-radius:10px} .reco-item{display:flex;align-items:center;gap:10px;padding:10px 14px;background:#F9FAFB;border:1px solid #E5E7EB;border-radius:10px}
.reco-item .reco-icon{font-size:18px;flex-shrink:0} .reco-item .reco-icon{font-size:18px;flex-shrink:0}
.reco-item .reco-name{font-size:13px;font-weight:700;color:var(--foreground,#1c1917)} .reco-item .reco-name{font-size:13px;font-weight:700;color:#1a1a2e}
.reco-item .reco-reason{font-size:11px;color:var(--muted-fg,#78716c);margin-top:2px} .reco-item .reco-reason{font-size:11px;color:#6B7280;margin-top:2px}
.reco-item .reco-conf{font-size:12px;font-weight:800;flex-shrink:0;margin-left:auto} .reco-item .reco-conf{font-size:12px;font-weight:800;flex-shrink:0;margin-left:auto}
/* Next Best Action */ /* Next Best Action */
.nba-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:14px} .nba-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:14px}
.nba-card{padding:16px;border-radius:12px;border:1px solid var(--border,#e7e5e2);display:flex;flex-direction:column;gap:8px;cursor:pointer;transition:all .15s} .nba-card{padding:16px;border-radius:12px;border:1px solid #E5E7EB;display:flex;flex-direction:column;gap:8px;cursor:pointer;transition:all .15s}
.nba-card:hover{box-shadow:0 4px 16px rgba(0,0,0,.08);transform:translateY(-2px)} .nba-card:hover{box-shadow:0 4px 16px rgba(0,0,0,.08);transform:translateY(-2px)}
.nba-card .nba-icon{font-size:28px} .nba-card .nba-icon{font-size:28px}
.nba-card .nba-title{font-size:13px;font-weight:800;color:var(--foreground,#1c1917)} .nba-card .nba-title{font-size:13px;font-weight:800;color:#1a1a2e}
.nba-card .nba-desc{font-size:12px;color:var(--muted-fg,#78716c);line-height:1.4} .nba-card .nba-desc{font-size:12px;color:#6B7280;line-height:1.4}
.nba-card .nba-tag{display:inline-block;font-size:10px;font-weight:700;padding:3px 8px;border-radius:6px;margin-top:4px} .nba-card .nba-tag{display:inline-block;font-size:10px;font-weight:700;padding:3px 8px;border-radius:6px;margin-top:4px}
.nba-urgent .nba-tag{background:#FEF2F2;color:#DC2626} .nba-urgent .nba-tag{background:#FEF2F2;color:#DC2626}
.nba-medium .nba-tag{background:#FFFBEB;color:#D97706} .nba-medium .nba-tag{background:#FFFBEB;color:#D97706}
.nba-low .nba-tag{background:#F0FDF4;color:#059669} .nba-low .nba-tag{background:#F0FDF4;color:#059669}
/* Engagement Heatmap */ /* Engagement Heatmap */
.heatmap-grid{display:grid;grid-template-columns:60px repeat(7,1fr);gap:3px;font-size:11px} .heatmap-grid{display:grid;grid-template-columns:60px repeat(7,1fr);gap:3px;font-size:11px}
.heatmap-grid .h-label{font-weight:600;color:var(--muted-fg,#78716c);display:flex;align-items:center} .heatmap-grid .h-label{font-weight:600;color:#6B7280;display:flex;align-items:center}
.heatmap-grid .h-day{text-align:center;font-weight:700;color:var(--muted-fg,#78716c);padding:4px} .heatmap-grid .h-day{text-align:center;font-weight:700;color:#6B7280;padding:4px}
.heatmap-grid .h-cell{border-radius:4px;height:28px;display:flex;align-items:center;justify-content:center;font-weight:700;font-size:10px;color:var(--foreground,#1c1917)} .heatmap-grid .h-cell{border-radius:4px;height:28px;display:flex;align-items:center;justify-content:center;font-weight:700;font-size:10px;color:#374151}
/* Chat history */ /* Chat history */
.chat-history{display:flex;flex-direction:column;gap:8px;max-height:280px;overflow-y:auto} .chat-history{display:flex;flex-direction:column;gap:8px;max-height:280px;overflow-y:auto}
.chat-msg{padding:10px 14px;border-radius:12px;font-size:13px;line-height:1.5;max-width:85%} .chat-msg{padding:10px 14px;border-radius:12px;font-size:13px;line-height:1.5;max-width:85%}
.chat-msg.user{align-self:flex-end;background:var(--primary,#3b5998);color:#fff;border-bottom-right-radius:4px} .chat-msg.user{align-self:flex-end;background:#6366F1;color:#fff;border-bottom-right-radius:4px}
.chat-msg.bot{align-self:flex-start;background:var(--muted,#f0efec);color:var(--foreground,#1c1917);border-bottom-left-radius:4px} .chat-msg.bot{align-self:flex-start;background:#F3F4F6;color:#1a1a2e;border-bottom-left-radius:4px}
/* Two-col */ /* Two-col */
.two-col{display:grid;grid-template-columns:1fr 1fr;gap:16px} .two-col{display:grid;grid-template-columns:1fr 1fr;gap:16px}
.mini-metrics{display:grid;grid-template-columns:repeat(4,1fr);gap:0} .mini-metrics{display:grid;grid-template-columns:repeat(4,1fr);gap:0}
.mini-metrics.cols-2{grid-template-columns:repeat(2,1fr)} .mini-metrics.cols-2{grid-template-columns:repeat(2,1fr)}
.mini-cell{text-align:center;padding:12px 8px;border-right:1px solid var(--border,#e7e5e2)} .mini-cell{text-align:center;padding:12px 8px;border-right:1px solid #F3F4F6}
.mini-cell:last-child{border-right:none} .mini-cell:last-child{border-right:none}
.mini-cell .m-label{font-size:10px;color:var(--muted-fg,#78716c);font-weight:600;text-transform:uppercase;letter-spacing:.06em;margin-bottom:6px} .mini-cell .m-label{font-size:10px;color:#6B7280;font-weight:600;text-transform:uppercase;letter-spacing:.06em;margin-bottom:6px}
.mini-cell .m-value{font-size:24px;font-weight:800;color:var(--foreground,#1c1917);line-height:1} .mini-cell .m-value{font-size:24px;font-weight:800;color:#1a1a2e;line-height:1}
@media(max-width:900px){.app-wrap{flex-direction:column}.user-list-pane{width:100%;height:260px;border-right:none;border-bottom:1px solid var(--border,#e7e5e2)}.three-col,.product-cards,.pred-grid,.reco-grid,.nba-grid{grid-template-columns:1fr}.overview-details{grid-template-columns:1fr 1fr}.metrics-row{grid-template-columns:repeat(3,1fr)}.two-col{grid-template-columns:1fr}} /* Insight Layers (6-layer chatbot insight) */
.insight-layers{display:flex;flex-direction:column;gap:10px}
.insight-layer{padding:14px 18px;border-radius:10px;transition:transform .15s ease,box-shadow .15s ease}
.insight-layer:hover{transform:translateX(4px);box-shadow:0 2px 12px rgba(0,0,0,.06)}
.insight-layer-header{display:flex;align-items:center;gap:8px;margin-bottom:6px}
.insight-layer-icon{font-size:16px}
.insight-layer-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em}
.insight-layer-value{font-size:13px;line-height:1.6;color:#1a1a2e;font-weight:500}
/* Data source badge */
.data-source-badge{display:inline-flex;align-items:center;gap:4px;font-size:10px;font-weight:700;padding:3px 8px;border-radius:6px;margin-left:8px}
.badge-api{background:#F0FDF4;color:#059669}
.badge-mock{background:#FEF3C7;color:#92400E}
/* Overview 4-col */
.overview-details{grid-template-columns:1fr 1fr 1fr 1fr !important}
@media(max-width:900px){.app-wrap{flex-direction:column}.user-list-pane{width:100%;height:260px;border-right:none;border-bottom:1px solid #E5E7EB}.three-col,.product-cards,.pred-grid,.reco-grid,.nba-grid{grid-template-columns:1fr}.overview-details{grid-template-columns:1fr 1fr !important}.metrics-row{grid-template-columns:repeat(3,1fr)}.two-col{grid-template-columns:1fr}}
</style> </style>
</head> </head>
<body> <body>
...@@ -181,7 +193,7 @@ body{font-family:var(--font-sans,'Inter',system-ui,sans-serif);background:var(-- ...@@ -181,7 +193,7 @@ body{font-family:var(--font-sans,'Inter',system-ui,sans-serif);background:var(--
<div class="profile-content" id="profileContent" style="display:none"></div> <div class="profile-content" id="profileContent" style="display:none"></div>
</div> </div>
</div> </div>
<script src="/static/js/user-insight-data.js"></script> <script src="/static/user-insight-data.js"></script>
<script src="/static/js/user-insight-render.js"></script> <script src="/static/user-insight-render.js"></script>
</body> </body>
</html> </html>
This source diff could not be displayed because it is too large. You can view the blob instead.
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