Commit 53e1bcd9 authored by Vũ Hoàng Anh's avatar Vũ Hoàng Anh

feat: add AI analytic dashboard + report agent

- Add AI analytic dashboard (ai-analytic.html)
- Add report agent route and prompt
- Add langfuse analytics module
- Update middleware and server config
parent 7fd76787
Pipeline #3380 failed with stage
"""
AI Report Agent — SSE Streaming Endpoint with ReAct Reflect Loop.
POST /api/report-agent
Input: { question: str, history: list[{role, content}] }
Output: SSE stream of events
ReAct Agent Loop:
1. THINK → decide what tools to call
2. ACT → execute tools (parallel)
3. OBSERVE → collect results
4. REFLECT → is data sufficient? quality ok?
5. If sufficient → WRITE report
6. If not → go back to step 1 with additional queries
Max 3 reflect cycles to prevent infinite loops.
"""
import asyncio
import json
import logging
import re
import traceback
from typing import Any
from fastapi import APIRouter
from pydantic import BaseModel
from starlette.responses import StreamingResponse
from common.starrocks_connection import StarRocksConnection
from common.postgres_readonly import PostgresReadonly
from prompts.report_prompt import AGENT_PROMPT, WRITER_PROMPT
logger = logging.getLogger(__name__)
router = APIRouter()
# ─── Request Model ───────────────────────────────────────────────────
class ChatMessage(BaseModel):
role: str
content: str
class ReportAgentRequest(BaseModel):
question: str
history: list[ChatMessage] = []
model: str = "codex/gpt-5.3-codex"
# ─── SQL Safety ──────────────────────────────────────────────────────
_FORBIDDEN = re.compile(
r"\b(INSERT|UPDATE|DELETE|DROP|ALTER|CREATE|TRUNCATE|REPLACE|MERGE|GRANT|REVOKE)\b",
re.IGNORECASE,
)
def _validate_sql(sql: str) -> str | None:
sql_clean = sql.strip().rstrip(";")
if not sql_clean.upper().startswith("SELECT"):
return "Only SELECT queries allowed"
if _FORBIDDEN.search(sql_clean):
return "Forbidden SQL keyword"
check = sql_clean.lower().replace("_text_embedding", "").replace("_embedding", "")
if re.search(r'\b(embedding|vector)\b', check):
return "Cannot query embedding/vector column"
return None
# ─── LLM Call (Codex) ────────────────────────────────────────────────
async def _call_llm(system_prompt: str, user_content: str, model_id: str = "codex/gpt-5.3-codex") -> str:
"""Call Codex LLM and return raw text response."""
from common.codex_auth import get_codex_access_token, get_codex_account_id, get_refresh_token
from common.codex_chat_model import CodexChatModel
from langchain_core.messages import HumanMessage, SystemMessage
access_token = get_codex_access_token()
if not access_token:
raise RuntimeError("Codex auth not available. Run `codex login` first.")
actual_model = model_id.replace("codex/", "") if model_id.startswith("codex/") else model_id
model = CodexChatModel(
model_name=actual_model,
access_token=access_token,
account_id=get_codex_account_id() or "",
refresh_token=get_refresh_token(),
streaming=False,
)
messages = [SystemMessage(content=system_prompt), HumanMessage(content=user_content)]
result = await model._agenerate(messages)
return result.generations[0].message.content
def _parse_json(raw: str) -> dict:
"""Parse JSON from LLM response, stripping markdown fences."""
json_match = re.search(r'```(?:json)?\s*([\s\S]*?)```', raw)
json_str = json_match.group(1).strip() if json_match else raw.strip()
return json.loads(json_str)
# ─── Tool Executors ──────────────────────────────────────────────────
async def _exec_tool(tool_name: str, params: dict) -> dict[str, Any]:
"""Execute a single tool and return results."""
if tool_name == "sql_langfuse":
sql = params.get("sql", "")
err = _validate_sql(sql)
if err:
return {"error": err, "data": []}
db = StarRocksConnection()
try:
rows = await db.execute_query_async(sql)
data = [_serialize_row(dict(r)) for r in rows]
return {"data": data, "columns": list(rows[0].keys()) if rows else [], "row_count": len(data)}
except Exception as e:
return {"error": str(e)[:300], "data": []}
elif tool_name == "sql_starrocks":
sql = params.get("sql", "")
err = _validate_sql(sql)
if err:
return {"error": err, "data": []}
db = StarRocksConnection()
try:
rows = await db.execute_query_async(sql)
data = [_serialize_row(dict(r)) for r in rows]
return {"data": data, "columns": list(rows[0].keys()) if rows else [], "row_count": len(data)}
except Exception as e:
return {"error": str(e)[:300], "data": []}
elif tool_name == "sql_postgres":
sql = params.get("sql", "")
err = _validate_sql(sql)
if err:
return {"error": err, "data": []}
try:
rows = await PostgresReadonly.execute_query_async(sql)
data = [_serialize_row(dict(r)) for r in rows]
return {"data": data, "columns": list(rows[0].keys()) if rows else [], "row_count": len(data)}
except Exception as e:
return {"error": str(e)[:300], "data": []}
elif tool_name == "calculator":
expr = params.get("expression", "")
description = params.get("description", "")
try:
allowed = set("0123456789.+-*/() ,")
if all(c in allowed for c in expr.replace(" ", "")):
result = eval(expr) # noqa: S307
return {"expression": expr, "result": result, "description": description}
else:
return {"error": "Invalid expression (only math allowed)", "expression": expr}
except Exception as e:
return {"error": str(e), "expression": expr}
else:
return {"error": f"Unknown tool: {tool_name}"}
def _serialize_row(row: dict) -> dict:
"""Serialize row for JSON (handle Decimal, bytes, datetime, NaN)."""
for key, val in row.items():
if isinstance(val, (bytes, bytearray)):
row[key] = val.hex()
elif hasattr(val, "isoformat"):
row[key] = val.isoformat()
elif isinstance(val, float) and val != val: # NaN
row[key] = 0
elif hasattr(val, "as_tuple"): # Decimal
row[key] = float(val)
return row
async def _execute_tools_parallel(tools: list[dict]) -> list[dict]:
"""Execute multiple tools in parallel, return results."""
tasks = [_exec_tool(t.get("name", ""), t.get("params", {})) for t in tools]
return await asyncio.gather(*tasks, return_exceptions=True)
def _make_result_preview(result: dict) -> dict:
"""Create a compact preview of a tool result for SSE streaming."""
preview = {}
if isinstance(result, Exception):
return {"error": str(result)[:200]}
if "data" in result:
data = result["data"]
if isinstance(data, list):
preview["row_count"] = len(data)
preview["preview"] = data[:3] if data else []
elif isinstance(data, dict):
preview["keys"] = list(data.keys())[:10]
if "summary" in result:
preview["summary"] = result["summary"]
if "error" in result:
preview["error"] = result["error"]
if "result" in result:
preview["result"] = result["result"]
return preview
def _summarize_results(all_results: dict[str, Any]) -> str:
"""Build a text summary of all collected tool results for the LLM."""
parts = []
for key, result in all_results.items():
result_copy = dict(result) if isinstance(result, dict) else {"error": str(result)}
if "data" in result_copy and isinstance(result_copy["data"], list):
result_copy["data"] = result_copy["data"][:50] # max 50 rows per tool
parts.append(
f"\n## Tool Result: {key}\n```json\n{json.dumps(result_copy, ensure_ascii=False, default=str, indent=2)}\n```"
)
return "\n".join(parts)
# ─── SSE Streaming Endpoint ─────────────────────────────────────────
MAX_REFLECT_CYCLES = 3
def _sse(data: dict) -> str:
"""Format SSE event."""
return f"data: {json.dumps(data, ensure_ascii=False, default=str)}\n\n"
@router.post("/api/report-agent", summary="AI Report Agent (SSE Stream)")
async def report_agent_stream(req: ReportAgentRequest):
"""
Generate an analytical report via SSE — ReAct agent loop with reflection:
1. THINK → decide what data to gather
2. ACT → execute tools (parallel)
3. OBSERVE → collect results
4. REFLECT → is data sufficient?
5. WRITE → generate report from real data
"""
async def event_stream():
try:
logger.info("📝 Report Agent: %s", req.question[:100])
all_tool_results: dict[str, Any] = {}
tool_counter = 0
cycle = 0
# ── Initial THINK: Agent decides first batch of tools ────
yield _sse({"type": "thinking", "step": "🧠 Đang phân tích yêu cầu..."})
agent_input = f"User request: {req.question}"
agent_raw = await _call_llm(AGENT_PROMPT, agent_input, req.model)
agent_response = _parse_json(agent_raw)
# ── ReAct Loop ───────────────────────────────────────────
while cycle < MAX_REFLECT_CYCLES:
action = agent_response.get("action", "execute")
thinking = agent_response.get("thinking", "")
# Get tools to execute from either "execute" or "reflect with next_tools"
tools_to_run = []
if action == "execute":
tools_to_run = agent_response.get("tools", [])
elif action == "reflect":
data_sufficient = agent_response.get("data_sufficient", False)
missing = agent_response.get("missing", [])
quality_issues = agent_response.get("quality_issues", [])
yield _sse({
"type": "reflect",
"cycle": cycle + 1,
"thinking": thinking,
"data_sufficient": data_sufficient,
"missing": missing,
"quality_issues": quality_issues,
})
if data_sufficient and not missing:
# Data is sufficient — proceed to write
logger.info("✅ Reflect cycle %d: Data sufficient, proceeding to write", cycle + 1)
break
# Not sufficient — get additional tools
tools_to_run = agent_response.get("next_tools", [])
if not tools_to_run:
logger.info("⚠️ Reflect: not sufficient but no next_tools, forcing write")
break
elif action == "write_report":
break
if not tools_to_run:
break
# ── ACT: Execute tools in parallel ────────────────────
yield _sse({
"type": "plan_ready",
"thinking": thinking,
"tools_count": len(tools_to_run),
"cycle": cycle + 1,
})
logger.info("🔄 Cycle %d: executing %d tools", cycle + 1, len(tools_to_run))
# Stream tool_call events
for i, tool_spec in enumerate(tools_to_run):
yield _sse({
"type": "tool_call",
"index": tool_counter + i,
"tool": tool_spec.get("name", ""),
"params": tool_spec.get("params", {}),
"purpose": tool_spec.get("purpose", ""),
})
# Execute all tools in parallel
results = await _execute_tools_parallel(tools_to_run)
# ── OBSERVE: Collect and stream results ───────────────
for i, (tool_spec, result) in enumerate(zip(tools_to_run, results)):
tool_name = tool_spec.get("name", "unknown")
if isinstance(result, Exception):
result = {"error": str(result)[:300], "data": []}
key = f"{tool_name}_{tool_counter + i}"
all_tool_results[key] = result
yield _sse({
"type": "tool_result",
"index": tool_counter + i,
"tool": tool_name,
"success": "error" not in result,
"preview": _make_result_preview(result),
})
tool_counter += len(tools_to_run)
cycle += 1
# ── REFLECT: Ask LLM to assess data sufficiency ──────
yield _sse({"type": "thinking", "step": f"🔍 Đánh giá dữ liệu (vòng {cycle}/{MAX_REFLECT_CYCLES})..."})
results_summary = _summarize_results(all_tool_results)
reflect_input = (
f"User request: {req.question}\n\n"
f"## DATA COLLECTED SO FAR (cycle {cycle}):\n{results_summary}\n\n"
f"## INSTRUCTION:\n"
f"Review the data above. Respond with action='reflect'.\n"
f"Assess: Is this data SUFFICIENT for a comprehensive report? "
f"Any GAPS or QUALITY ISSUES? Need additional queries?\n"
f"If data is sufficient, set data_sufficient=true and missing=[].\n"
f"If not, provide next_tools with additional queries.\n"
f"RESPOND WITH RAW JSON ONLY."
)
reflect_raw = await _call_llm(AGENT_PROMPT, reflect_input, req.model)
agent_response = _parse_json(reflect_raw)
logger.info("🔍 Reflect cycle %d: action=%s", cycle, agent_response.get("action"))
# ── WRITE: Generate final report ─────────────────────────
logger.info("🔧 Total tools executed: %d across %d cycles", tool_counter, cycle)
yield _sse({"type": "thinking", "step": "✍️ Đang viết báo cáo từ dữ liệu thật..."})
data_context = _summarize_results(all_tool_results)
writer_input = (
f"User request: {req.question}\n\n"
f"## REAL DATA FROM TOOLS ({tool_counter} queries, {cycle} analysis cycles):\n{data_context}\n\n"
f"Write the full report using ONLY the data above. RESPOND WITH RAW JSON ONLY."
)
report_raw = await _call_llm(WRITER_PROMPT, writer_input, req.model)
report = _parse_json(report_raw)
# Stream sections progressively
sections = report.get("sections", [])
for i, section in enumerate(sections):
yield _sse({
"type": "section_ready",
"index": i,
"section": section,
})
await asyncio.sleep(0.1)
# Full report
yield _sse({"type": "report_complete", "report": report})
num_sec = len(sections)
num_chart = sum(1 for s in sections if s.get("chart"))
num_table = sum(1 for s in sections if s.get("table"))
logger.info(
"✅ Report complete: '%s' — %d sections, %d charts, %d tables, %d cycles",
report.get("title", "?"), num_sec, num_chart, num_table, cycle,
)
yield _sse({"type": "done"})
except json.JSONDecodeError as e:
logger.error("Report Agent JSON error: %s", e)
yield _sse({"type": "error", "message": f"AI response was not valid JSON: {str(e)[:200]}"})
except RuntimeError as e:
logger.error("Report Agent runtime error: %s", e)
yield _sse({"type": "error", "message": str(e)[:300]})
except Exception as e:
logger.error("Report Agent error: %s\n%s", e, traceback.format_exc())
yield _sse({"type": "error", "message": str(e)[:300]})
return StreamingResponse(event_stream(), media_type="text/event-stream")
# ─── Health Check ────────────────────────────────────────────────────
@router.get("/api/report-agent/status", summary="Check Report Agent availability")
async def report_agent_status():
"""Check if Codex is available."""
from common.codex_auth import get_codex_access_token
return {
"codex_available": bool(get_codex_access_token()),
"data_source": "StarRocks (analytic.chatbot_rsa_trace_event_detail)",
"agent_type": "ReAct with Reflect Loop (max 3 cycles)",
}
"""
Langfuse Analytics — Data retrieval wrapper for AI Report Agent.
Uses Langfuse SDK v3 + REST API to fetch traces, scores, metrics.
"""
import logging
from datetime import datetime, timedelta, timezone
from typing import Any
import httpx
from config import LANGFUSE_BASE_URL, LANGFUSE_PUBLIC_KEY, LANGFUSE_SECRET_KEY
logger = logging.getLogger(__name__)
__all__ = ["LangfuseAnalytics"]
class LangfuseAnalytics:
"""Fetch analytics data from Langfuse for report generation."""
def __init__(self):
self._base_url = (LANGFUSE_BASE_URL or "https://cloud.langfuse.com").rstrip("/")
self._auth = (LANGFUSE_PUBLIC_KEY or "", LANGFUSE_SECRET_KEY or "")
self._available = bool(LANGFUSE_PUBLIC_KEY and LANGFUSE_SECRET_KEY)
@property
def available(self) -> bool:
return self._available
# ─── REST helpers ────────────────────────────────────────────────
async def _get(self, path: str, params: dict | None = None) -> dict:
"""Make authenticated GET request to Langfuse API."""
url = f"{self._base_url}/api/public{path}"
async with httpx.AsyncClient(timeout=30) as client:
resp = await client.get(url, params=params, auth=self._auth)
resp.raise_for_status()
return resp.json()
# ─── Daily Metrics ───────────────────────────────────────────────
async def get_daily_metrics(
self,
from_date: str | None = None,
to_date: str | None = None,
trace_name: str | None = None,
limit: int = 30,
) -> dict[str, Any]:
"""
Fetch daily aggregated metrics (traces, cost, models).
Uses GET /api/public/metrics/daily
"""
if not self._available:
return {"error": "Langfuse not configured", "data": []}
now = datetime.now(timezone.utc)
params: dict[str, Any] = {"limit": limit}
if from_date:
params["fromTimestamp"] = from_date
else:
params["fromTimestamp"] = (now - timedelta(days=30)).strftime("%Y-%m-%dT00:00:00Z")
if to_date:
params["toTimestamp"] = to_date
if trace_name:
params["traceName"] = trace_name
try:
result = await self._get("/metrics/daily", params)
data = result.get("data", [])
logger.info("📊 Langfuse daily metrics: %d days", len(data))
return {
"data": data,
"total_days": len(data),
"summary": self._summarize_daily(data),
}
except Exception as e:
logger.error("❌ Langfuse daily metrics error: %s", e)
return {"error": str(e), "data": []}
def _summarize_daily(self, data: list[dict]) -> dict:
"""Compute summary stats from daily metrics."""
if not data:
return {}
total_traces = sum(d.get("countTraces", 0) for d in data)
total_observations = sum(d.get("countObservations", 0) for d in data)
total_cost = sum(d.get("totalCost", 0) or 0 for d in data)
# Model breakdown
model_usage: dict[str, int] = {}
for day in data:
for u in day.get("usage", []):
model = u.get("model", "unknown")
model_usage[model] = model_usage.get(model, 0) + u.get("countObservations", 0)
avg_traces_per_day = total_traces / len(data) if data else 0
return {
"total_traces": total_traces,
"total_observations": total_observations,
"total_cost_usd": round(total_cost, 4),
"avg_traces_per_day": round(avg_traces_per_day, 1),
"days_count": len(data),
"model_usage": dict(sorted(model_usage.items(), key=lambda x: x[1], reverse=True)),
}
# ─── Traces ──────────────────────────────────────────────────────
async def get_traces_summary(
self,
from_date: str | None = None,
to_date: str | None = None,
limit: int = 50,
) -> dict[str, Any]:
"""
Fetch recent traces list with metadata.
Uses GET /api/public/traces
"""
if not self._available:
return {"error": "Langfuse not configured", "data": []}
params: dict[str, Any] = {"limit": limit, "orderBy": "timestamp.desc"}
if from_date:
params["fromTimestamp"] = from_date
if to_date:
params["toTimestamp"] = to_date
try:
result = await self._get("/traces", params)
traces = result.get("data", [])
# Extract useful fields
simplified = []
for t in traces:
simplified.append({
"id": t.get("id", "")[:12],
"name": t.get("name", ""),
"userId": t.get("userId", ""),
"sessionId": t.get("sessionId", ""),
"timestamp": t.get("timestamp", ""),
"latency_ms": t.get("latency"),
"totalCost": t.get("totalCost"),
"observationCount": t.get("observationCount", 0),
"tags": t.get("tags", []),
})
# Compute stats
latencies = [t["latency_ms"] for t in simplified if t["latency_ms"] is not None]
unique_users = len(set(t["userId"] for t in simplified if t["userId"]))
unique_sessions = len(set(t["sessionId"] for t in simplified if t["sessionId"]))
logger.info("📊 Langfuse traces: %d traces, %d users", len(simplified), unique_users)
return {
"data": simplified,
"total_traces": len(simplified),
"unique_users": unique_users,
"unique_sessions": unique_sessions,
"avg_latency_ms": round(sum(latencies) / len(latencies), 0) if latencies else None,
"p95_latency_ms": round(sorted(latencies)[int(len(latencies) * 0.95)] if latencies else 0, 0),
}
except Exception as e:
logger.error("❌ Langfuse traces error: %s", e)
return {"error": str(e), "data": []}
# ─── Scores ──────────────────────────────────────────────────────
async def get_scores_summary(
self,
from_date: str | None = None,
to_date: str | None = None,
limit: int = 200,
) -> dict[str, Any]:
"""
Fetch scores and aggregate by name.
Uses GET /api/public/scores
"""
if not self._available:
return {"error": "Langfuse not configured", "data": []}
params: dict[str, Any] = {"limit": limit}
if from_date:
params["fromTimestamp"] = from_date
if to_date:
params["toTimestamp"] = to_date
try:
result = await self._get("/scores", params)
scores = result.get("data", [])
# Aggregate by score name
by_name: dict[str, list] = {}
for s in scores:
name = s.get("name", "unknown")
by_name.setdefault(name, []).append(s)
summary = {}
for name, score_list in by_name.items():
values = [s.get("value") for s in score_list if s.get("value") is not None]
numeric_values = [v for v in values if isinstance(v, (int, float))]
entry: dict[str, Any] = {
"count": len(score_list),
"data_type": score_list[0].get("dataType", "NUMERIC") if score_list else "UNKNOWN",
}
if numeric_values:
entry["avg"] = round(sum(numeric_values) / len(numeric_values), 3)
entry["min"] = round(min(numeric_values), 3)
entry["max"] = round(max(numeric_values), 3)
else:
# Categorical — count by value
val_counts: dict[str, int] = {}
for v in values:
val_counts[str(v)] = val_counts.get(str(v), 0) + 1
entry["distribution"] = val_counts
summary[name] = entry
logger.info("📊 Langfuse scores: %d scores, %d types", len(scores), len(summary))
return {
"data": summary,
"total_scores": len(scores),
"score_types": list(summary.keys()),
}
except Exception as e:
logger.error("❌ Langfuse scores error: %s", e)
return {"error": str(e), "data": {}}
# ─── Sessions ────────────────────────────────────────────────────
async def get_sessions_summary(self, limit: int = 50) -> dict[str, Any]:
"""
Fetch recent sessions.
Uses GET /api/public/sessions
"""
if not self._available:
return {"error": "Langfuse not configured", "data": []}
try:
result = await self._get("/sessions", {"limit": limit})
sessions = result.get("data", [])
simplified = []
for s in sessions:
simplified.append({
"id": s.get("id", "")[:20],
"createdAt": s.get("createdAt", ""),
"traceCount": len(s.get("traces", [])) if isinstance(s.get("traces"), list) else 0,
})
logger.info("📊 Langfuse sessions: %d", len(simplified))
return {
"data": simplified,
"total_sessions": len(simplified),
}
except Exception as e:
logger.error("❌ Langfuse sessions error: %s", e)
return {"error": str(e), "data": []}
......@@ -129,7 +129,7 @@ class CanifaAuthMiddleware(BaseHTTPMiddleware):
# ⚠️ Skip body reading for SSE/streaming endpoints — reading the
# body inside BaseHTTPMiddleware corrupts the receive chain and
# causes "Unexpected message received: http.request" errors.
SSE_PATHS = {"/api/sql-dashboard", "/api/sql-chat/stream"}
SSE_PATHS = {"/api/sql-dashboard", "/api/sql-chat/stream", "/api/report-agent"}
device_id = ""
if method in ["POST", "PUT", "PATCH"] and path not in SSE_PATHS:
try:
......
"""
Report Agent Prompts — ReAct loop with reflect for AI Report generation.
Imported by api/report_agent_route.py
"""
from prompts.dashboard_prompt import STARROCKS_TABLE, POSTGRES_TABLE, DB_SCHEMA
# ═══════════════════════════════════════════════════════════════════
# LANGFUSE TABLE (in StarRocks)
# ═══════════════════════════════════════════════════════════════════
LANGFUSE_TABLE = "analytic.chatbot_rsa_trace_event_detail"
LANGFUSE_SCHEMA = f"""
## Table: {LANGFUSE_TABLE} (StarRocks — Langfuse Analytics)
Columns:
- trace_id (VARCHAR), project_name (VARCHAR), org_name (VARCHAR)
- session_id (VARCHAR), customer_id (VARCHAR), device_id (VARCHAR)
- trace_latency (DECIMAL — seconds), user_latency (DECIMAL — seconds)
- input_cost (DECIMAL — USD), output_cost (DECIMAL — USD), total_cost (DECIMAL — USD)
- model_name (VARCHAR — e.g. 'gpt-5.2-codex', 'gemini-3.1-flash-lite')
- total_obs (BIGINT), total_obs_error (BIGINT), nb_generation_error (BIGINT)
- is_guest (TINYINT — 1/0), is_user (TINYINT — 1/0)
- traced_at (DATETIME), created_at (DATETIME), updated_at (DATETIME)
"""
# ═══════════════════════════════════════════════════════════════════
# AGENT PROMPT — ReAct loop with reflection
# ═══════════════════════════════════════════════════════════════════
AGENT_PROMPT = f"""You are an AI Data Analyst Agent for Canifa (Vietnamese fashion brand).
You work in a ReAct loop: THINK → ACT → OBSERVE → REFLECT → (repeat or finish).
## AVAILABLE TOOLS:
1. **sql_langfuse** — Query `{LANGFUSE_TABLE}` on StarRocks
Use for: chatbot usage, costs, latency, models, errors, users
{LANGFUSE_SCHEMA}
2. **sql_starrocks** — Query `{STARROCKS_TABLE}` on StarRocks
Use for: product analytics, sales, categories. NEVER SELECT 'vector'/'embedding' columns.
Columns: product_name, sale_price, original_price, quantity_sold, gender_by_product, product_line_vn, master_color, material, season, is_new_product, etc.
3. **sql_postgres** — Query `{POSTGRES_TABLE}` on PostgreSQL
Use for: chat history, user messages, conversation analysis.
Columns: identity_key, message, is_human, timestamp
4. **calculator** — Pure math only (digits + -, *, /, parentheses). No text in expression.
## YOUR TASK:
Given the user's question, iteratively gather data until you have ENOUGH for a comprehensive report.
## RESPONSE FORMAT (JSON):
At each step, respond with ONE of these:
### Option 1: Execute tools (gather more data)
```json
{{
"action": "execute",
"thinking": "What I need to find out and why",
"tools": [
{{"name": "tool_name", "params": {{"sql": "SELECT ..."}}, "purpose": "Why this query"}}
]
}}
```
### Option 2: Reflect on collected data
```json
{{
"action": "reflect",
"thinking": "Assessment of data collected so far",
"data_sufficient": true/false,
"missing": ["What data is still missing (if any)"],
"quality_issues": ["Data quality concerns (if any)"],
"next_tools": [
{{"name": "tool_name", "params": {{"sql": "SELECT ..."}}, "purpose": "Why this additional query"}}
]
}}
```
- If data_sufficient=true AND missing=[] → agent will proceed to write report
- If data_sufficient=false → next_tools will be executed, then another reflect cycle
### Option 3: Write the final report
```json
{{
"action": "write_report"
}}
```
(Only output this when told all data is ready)
## RULES:
- Start with 2-4 diverse queries covering different aspects of the user's question
- After seeing results, REFLECT: is the data enough? Good quality? Need deeper analysis?
- If data has gaps, errors, or insufficient depth → add more targeted queries
- Maximum 3 reflect cycles (to prevent infinite loops)
- For calculator: expression must contain ONLY digits and math operators
- All SQL must be SELECT-only
- Be thorough but efficient — don't over-query
RESPOND WITH RAW JSON ONLY. No markdown.
"""
# ═══════════════════════════════════════════════════════════════════
# WRITER — Generates the full report JSON from real data
# ═══════════════════════════════════════════════════════════════════
WRITER_PROMPT = """You are an expert business analyst and report writer for Canifa (Vietnamese fashion brand).
You have received REAL DATA from various data tools. Write a comprehensive analytical report.
## CRITICAL: USE REAL DATA ONLY
- All numbers, charts, and tables MUST come from the provided tool results
- NEVER fabricate or estimate numbers that aren't in the data
- If data is missing for a section, note it honestly
## REPORT JSON SCHEMA:
{
"title": "string — full report title",
"subtitle": "string — descriptive subtitle",
"organization": "string — e.g. 'Phòng Phân tích AI — Canifa'",
"period": "string — reporting period",
"prepared_by": "string — 'AI Report Agent · Canifa AI Platform'",
"executive_summary": "string — 3–5 sentences summarizing key findings from REAL data",
"highlights": [ { "label": "string", "value": "string (from data)", "trend": "up|down|neutral" } ],
"sections": [ Section ],
"conclusion": "string — 2–4 sentences based on findings",
"recommendations": [ "string — actionable, based on data" ]
}
Section = {
"id": "string",
"number": "number (1, 2, 3...)",
"title": "string",
"paragraphs": [ "string — analytical text using REAL numbers from data" ],
"chart": Chart | null,
"table": Table | null,
"callout": { "type": "info|warning|success", "text": "string" } | null
}
Chart = {
"type": "bar|line|area|donut|hbar",
"title": "string",
"caption": "string — 1 sentence explaining what the chart shows",
"labels": ["string"],
"datasets": [ { "label": "string", "values": [number] } ],
"segments": [ { "name": "string", "value": number } ]
}
Table = {
"title": "string",
"caption": "string",
"columns": ["string"],
"rows": [["string"]]
}
## CONTENT RULES:
- Write each paragraph as analytical text (3–5 sentences), referencing REAL data
- 4–6 sections, each focused on one aspect
- Each section should have 2–3 paragraphs
- Put a chart OR table in most sections (not both unless very relevant)
- highlights: 4–6 key KPIs from the data
- recommendations: 4–6 actionable points based on findings
- All text in Vietnamese
- Chart data MUST match the tool results — do NOT invent numbers
- For donut charts use "segments", for other types use "labels" + "datasets"
RESPOND WITH RAW JSON ONLY. No markdown fences.
"""
......@@ -24,6 +24,7 @@ from api.experiment_links_route import router as experiment_links_router
from api.product_route import router as product_router
from api.sql_chat_route import router as sql_chat_router
from api.cache_route import router as cache_router
from api.report_agent_route import router as report_agent_router
from common.cache import redis_cache
from common.middleware import middleware_manager
from config import PORT
......@@ -64,8 +65,7 @@ async def startup_event():
@app.get("/")
async def root():
return RedirectResponse(url="/static/dashboard.html")
return RedirectResponse(url="/static/main.html")
@app.get("/health")
async def health():
......@@ -88,8 +88,8 @@ async def serve_static(file_path: str):
# =====================================================================
middleware_manager.setup(
app,
enable_auth=True,
enable_rate_limit=True,
enable_auth=False,
enable_rate_limit=False,
enable_cors=True,
cors_origins=["*"],
)
......@@ -112,6 +112,7 @@ app.include_router(experiment_links_router) # Experiment links sidebar
app.include_router(product_router) # Product performance dashboard
app.include_router(sql_chat_router) # AI Data Analyst (Text-to-SQL)
app.include_router(cache_router) # Cache management dashboard
app.include_router(report_agent_router) # AI Report Agent (SSE)
if __name__ == "__main__":
......
<!DOCTYPE html>
<html lang="vi">
<head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Hướng dẫn — AI Report Agent · Canifa</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<style>
* { box-sizing: border-box; margin: 0; padding: 0 }
body { font-family: 'Inter', system-ui, sans-serif; background: #F8FAFC; color: #1E293B; line-height: 1.7 }
/* ── Hero ── */
.hero {
background: linear-gradient(135deg, #0F172A 0%, #1E293B 50%, #312E81 100%);
padding: 60px 40px 50px; text-align: center; color: white; position: relative; overflow: hidden;
}
.hero::before {
content: ''; position: absolute; inset: 0;
background: radial-gradient(circle at 30% 20%, rgba(99,102,241,.2) 0%, transparent 50%),
radial-gradient(circle at 70% 80%, rgba(59,130,246,.15) 0%, transparent 50%);
}
.hero > * { position: relative; z-index: 1 }
.hero-badge { display: inline-flex; align-items: center; gap: 6px; padding: 6px 14px; background: rgba(99,102,241,.2); border: 1px solid rgba(99,102,241,.3); border-radius: 100px; font-size: 12px; font-weight: 600; color: #A5B4FC; margin-bottom: 20px }
.hero h1 { font-size: 32px; font-weight: 800; letter-spacing: -0.03em; margin-bottom: 12px }
.hero p { font-size: 15px; color: #94A3B8; max-width: 600px; margin: 0 auto; line-height: 1.7 }
/* ── Container ── */
.container { max-width: 900px; margin: 0 auto; padding: 40px 24px 80px }
/* ── Section ── */
.section { margin-bottom: 48px }
.section-label { display: inline-flex; align-items: center; gap: 8px; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.1em; color: #6366F1; margin-bottom: 12px }
.section-label .dot { width: 8px; height: 8px; border-radius: 50%; background: #6366F1 }
.section h2 { font-size: 22px; font-weight: 800; letter-spacing: -0.02em; color: #0F172A; margin-bottom: 16px }
.section p { font-size: 14px; color: #475569; margin-bottom: 12px }
/* ── Flow diagram ── */
.flow { display: flex; flex-direction: column; gap: 0; }
.flow-step {
display: flex; gap: 16px; align-items: flex-start; position: relative;
}
.flow-line {
width: 3px; min-height: 100%; background: #E2E8F0; position: absolute; left: 23px; top: 48px; bottom: -12px;
}
.flow-step:last-child .flow-line { display: none }
.flow-icon {
width: 48px; height: 48px; border-radius: 14px; display: flex; align-items: center; justify-content: center;
font-size: 20px; flex-shrink: 0; position: relative; z-index: 1;
}
.flow-body { flex: 1; padding-bottom: 28px }
.flow-body h3 { font-size: 15px; font-weight: 700; color: #0F172A; margin-bottom: 4px }
.flow-body p { font-size: 13px; color: #64748B; line-height: 1.6 }
.flow-tag {
display: inline-block; font-size: 10px; font-weight: 700; padding: 3px 8px; border-radius: 6px;
text-transform: uppercase; letter-spacing: 0.05em; margin-top: 6px;
}
/* ── Cards grid ── */
.cards { display: grid; grid-template-columns: 1fr 1fr; gap: 14px }
.card {
background: white; border: 1.5px solid #E2E8F0; border-radius: 14px; padding: 20px;
transition: all .2s;
}
.card:hover { border-color: #C7D2FE; box-shadow: 0 4px 12px rgba(99,102,241,.08) }
.card-icon { width: 40px; height: 40px; border-radius: 10px; display: flex; align-items: center; justify-content: center; font-size: 18px; margin-bottom: 12px }
.card h3 { font-size: 14px; font-weight: 700; color: #0F172A; margin-bottom: 6px }
.card p { font-size: 12.5px; color: #64748B; line-height: 1.6 }
.card code { background: #F1F5F9; padding: 2px 6px; border-radius: 4px; font-size: 11px; color: #6366F1 }
/* ── Table ── */
.schema-table { width: 100%; border-collapse: collapse; font-size: 12.5px; margin-top: 12px }
.schema-table th { background: #F8FAFC; text-align: left; padding: 8px 12px; font-weight: 700; color: #475569; border-bottom: 2px solid #E2E8F0 }
.schema-table td { padding: 8px 12px; border-bottom: 1px solid #F1F5F9; color: #334155 }
.schema-table tr:hover td { background: #FAFBFF }
.type-badge { font-size: 10px; font-weight: 600; padding: 2px 8px; border-radius: 4px; font-family: monospace }
.type-varchar { background: #EEF2FF; color: #4338CA }
.type-decimal { background: #ECFDF5; color: #059669 }
.type-bigint { background: #FEF3C7; color: #D97706 }
.type-datetime { background: #FFF1F2; color: #E11D48 }
.type-tinyint { background: #F0F9FF; color: #0284C7 }
/* ── Callout ── */
.callout { padding: 16px 20px; border-radius: 12px; font-size: 13px; margin: 16px 0; display: flex; gap: 12px; align-items: flex-start }
.callout-info { background: #EEF2FF; border: 1px solid #E0E7FF; color: #3730A3 }
.callout-warn { background: #FFFBEB; border: 1px solid #FDE68A; color: #92400E }
.callout-success { background: #ECFDF5; border: 1px solid #A7F3D0; color: #065F46 }
.callout-icon { font-size: 18px; flex-shrink: 0; margin-top: 1px }
/* ── Back link ── */
.back-link {
display: inline-flex; align-items: center; gap: 6px; font-size: 13px; font-weight: 600;
color: #6366F1; text-decoration: none; padding: 8px 16px; border-radius: 8px;
background: #EEF2FF; border: 1px solid #E0E7FF; margin-bottom: 32px; transition: all .15s;
}
.back-link:hover { background: #E0E7FF; border-color: #C7D2FE }
/* ── Reflect loop diagram ── */
.loop-diagram {
background: white; border: 1.5px solid #E2E8F0; border-radius: 16px; padding: 28px;
margin: 20px 0; text-align: center;
}
.loop-diagram pre {
font-family: 'Inter', system-ui; font-size: 13px; color: #475569; line-height: 2;
text-align: left; max-width: 500px; margin: 0 auto;
}
.loop-arrow { color: #6366F1; font-weight: 700 }
.loop-yes { color: #059669; font-weight: 700 }
.loop-no { color: #DC2626; font-weight: 700 }
</style>
</head>
<body>
<!-- ═══════════════════════════════════════════════════════════════════ -->
<!-- HERO -->
<!-- ═══════════════════════════════════════════════════════════════════ -->
<div class="hero">
<div class="hero-badge">📋 Documentation · v2.0</div>
<h1>AI Report Agent</h1>
<p>Hướng dẫn chi tiết về luồng hoạt động, kiến trúc ReAct Reflect Loop, và các công cụ (tools) mà Agent sử dụng để tạo báo cáo phân tích từ dữ liệu thật.</p>
</div>
<div class="container">
<a href="ai-analytic.html" class="back-link">← Quay lại Report Agent</a>
<!-- ═══════════════════════════════════════════════════════════════════ -->
<!-- OVERVIEW -->
<!-- ═══════════════════════════════════════════════════════════════════ -->
<div class="section">
<div class="section-label"><span class="dot"></span> Tổng quan</div>
<h2>Agent hoạt động như thế nào?</h2>
<p>AI Report Agent sử dụng kiến trúc <strong>ReAct (Reasoning + Acting)</strong> với cơ chế <strong>Reflect Loop</strong>. Thay vì chạy cứng một pipeline, Agent tự <em>suy nghĩ</em>, <em>query dữ liệu</em>, <em>đánh giá</em> xem đã đủ chưa, và tự quyết định khi nào dừng lại để viết báo cáo.</p>
<div class="callout callout-info">
<span class="callout-icon">💡</span>
<div>
<strong>Tại sao dùng Reflect Loop?</strong><br>
Pipeline truyền thống (Planner → Tools → Writer) sẽ quyết định tất cả tools từ đầu. Nếu 1 tool lỗi hoặc trả data thiếu, không có cơ chế bổ sung. Reflect Loop cho phép Agent tự đánh giá và query thêm khi cần.
</div>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════════════════ -->
<!-- FLOW DIAGRAM -->
<!-- ═══════════════════════════════════════════════════════════════════ -->
<div class="section">
<div class="section-label"><span class="dot"></span> Luồng hoạt động</div>
<h2>ReAct Reflect Loop — Chi tiết</h2>
<div class="loop-diagram">
<pre>
📝 User nhập yêu cầu báo cáo
<span class="loop-arrow"></span>
🧠 <strong>THINK</strong> — Agent phân tích, chọn tools + viết SQL
<span class="loop-arrow"></span>
🔧 <strong>ACT</strong> — Execute tools <strong>song song</strong> (asyncio.gather)
<span class="loop-arrow"></span>
👁️ <strong>OBSERVE</strong> — Thu thập kết quả từ các tools
<span class="loop-arrow"></span>
🔍 <strong>REFLECT</strong> — LLM tự đánh giá:
│ • Dữ liệu đã đủ chưa?
│ • Chất lượng data có ổn không?
│ • Còn thiếu góc nhìn nào?
├── <span class="loop-no">❌ Chưa đủ</span> → Quay lại THINK (thêm queries)
│ (tối đa 3 vòng reflect)
└── <span class="loop-yes">✅ Đủ rồi</span> → Tiếp tục xuống
<span class="loop-arrow"></span>
✍️ <strong>WRITE</strong> — LLM viết báo cáo hoàn chỉnh từ data thật
<span class="loop-arrow"></span>
📄 Stream sections lên frontend (progressive rendering)
<span class="loop-arrow"></span>
🏁 <strong>DONE</strong> — Báo cáo hoàn thành!
</pre>
</div>
<div class="flow">
<div class="flow-step">
<div class="flow-line"></div>
<div class="flow-icon" style="background:linear-gradient(135deg,#EEF2FF,#E0E7FF);border:1.5px solid #C7D2FE">🧠</div>
<div class="flow-body">
<h3>Phase 1: THINK — Phân tích & Lên kế hoạch</h3>
<p>Agent nhận yêu cầu từ user, phân tích cần data gì, tạo SQL queries phù hợp. Chọn 2-4 tools đa dạng cho batch đầu.</p>
<span class="flow-tag" style="background:#EEF2FF;color:#4338CA">LLM Call #1</span>
</div>
</div>
<div class="flow-step">
<div class="flow-line"></div>
<div class="flow-icon" style="background:linear-gradient(135deg,#FEF3C7,#FDE68A);border:1.5px solid #FCD34D">🔧</div>
<div class="flow-body">
<h3>Phase 2: ACT — Execute Tools Song Song</h3>
<p>Tất cả tools trong batch được execute <strong>đồng thời</strong> bằng <code>asyncio.gather()</code>. Không chờ tuần tự → nhanh gấp 3-4x. Mỗi kết quả được stream lên sidebar realtime.</p>
<span class="flow-tag" style="background:#ECFDF5;color:#059669">Parallel I/O</span>
</div>
</div>
<div class="flow-step">
<div class="flow-line"></div>
<div class="flow-icon" style="background:linear-gradient(135deg,#FFF7ED,#FFEDD5);border:1.5px solid #FDBA74">🔍</div>
<div class="flow-body">
<h3>Phase 3: REFLECT — Tự đánh giá dữ liệu</h3>
<p>LLM xem lại toàn bộ data đã thu thập. Đánh giá: <em>Đủ chưa? Chất lượng ổn không? Thiếu góc nhìn nào?</em> Nếu thiếu → tạo thêm queries. Nếu đủ → chuyển sang viết.</p>
<span class="flow-tag" style="background:#FEF3C7;color:#D97706">LLM Call #2 · Reflect</span>
<span class="flow-tag" style="background:#FFF1F2;color:#E11D48;margin-left:6px">Max 3 vòng</span>
</div>
</div>
<div class="flow-step">
<div class="flow-icon" style="background:linear-gradient(135deg,#ECFDF5,#D1FAE5);border:1.5px solid #6EE7B7">✍️</div>
<div class="flow-body">
<h3>Phase 4: WRITE — Viết báo cáo hoàn chỉnh</h3>
<p>LLM nhận toàn bộ data thật → viết báo cáo JSON gồm: executive summary, highlights, 4-6 sections (mỗi section có paragraphs + chart/table), conclusion, recommendations. Sections được stream lần lượt lên frontend.</p>
<span class="flow-tag" style="background:#ECFDF5;color:#059669">LLM Call #3 · Writer</span>
</div>
</div>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════════════════ -->
<!-- DATA TOOLS -->
<!-- ═══════════════════════════════════════════════════════════════════ -->
<div class="section">
<div class="section-label"><span class="dot"></span> Công cụ dữ liệu</div>
<h2>4 Tools Agent sử dụng</h2>
<p>Agent có quyền truy cập 4 tools để thu thập dữ liệu. Mỗi tool chỉ được phép <strong>SELECT</strong> (read-only).</p>
<div class="cards">
<div class="card">
<div class="card-icon" style="background:#EEF2FF;border:1px solid #E0E7FF">📊</div>
<h3>sql_langfuse</h3>
<p>Query bảng <code>analytic.chatbot_rsa_trace_event_detail</code> trên StarRocks. Chứa dữ liệu Langfuse: traces, latency, costs, models, errors.</p>
<p style="margin-top:8px;font-size:11px;color:#94A3B8">👉 Dùng cho: Hiệu suất chatbot, chi phí LLM, user analytics</p>
</div>
<div class="card">
<div class="card-icon" style="background:#FEF3C7;border:1px solid #FDE68A">🏷️</div>
<h3>sql_starrocks</h3>
<p>Query bảng sản phẩm <code>shared_source.magento_product_dimension_*</code>. Chứa: tên SP, giá, số lượng bán, màu, chất liệu, mùa.</p>
<p style="margin-top:8px;font-size:11px;color:#94A3B8">👉 Dùng cho: Phân tích sản phẩm, doanh thu, thống kê catalog</p>
</div>
<div class="card">
<div class="card-icon" style="background:#ECFDF5;border:1px solid #A7F3D0">💬</div>
<h3>sql_postgres</h3>
<p>Query bảng <code>public.langgraph_chat_histories</code>. Chứa: tin nhắn user, bot, timestamp, identity.</p>
<p style="margin-top:8px;font-size:11px;color:#94A3B8">👉 Dùng cho: Phân tích hội thoại, volume chat, engagement</p>
</div>
<div class="card">
<div class="card-icon" style="background:#FFF1F2;border:1px solid #FECDD3">🧮</div>
<h3>calculator</h3>
<p>Tính toán math thuần túy: phép cộng, trừ, nhân, chia, phần trăm. Chỉ nhận digits + operators.</p>
<p style="margin-top:8px;font-size:11px;color:#94A3B8">👉 Dùng cho: Tỉ lệ tăng trưởng, % thay đổi, trung bình</p>
</div>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════════════════ -->
<!-- LANGFUSE SCHEMA -->
<!-- ═══════════════════════════════════════════════════════════════════ -->
<div class="section">
<div class="section-label"><span class="dot"></span> Schema</div>
<h2>Bảng Langfuse trên StarRocks</h2>
<p>Bảng <code>analytic.chatbot_rsa_trace_event_detail</code> — chứa toàn bộ traces từ Langfuse, sync tự động.</p>
<table class="schema-table">
<thead>
<tr><th>Cột</th><th>Kiểu</th><th>Mô tả</th></tr>
</thead>
<tbody>
<tr><td><code>trace_id</code></td><td><span class="type-badge type-varchar">VARCHAR</span></td><td>ID unique của trace</td></tr>
<tr><td><code>session_id</code></td><td><span class="type-badge type-varchar">VARCHAR</span></td><td>ID phiên hội thoại</td></tr>
<tr><td><code>customer_id</code></td><td><span class="type-badge type-varchar">VARCHAR</span></td><td>Mã khách hàng (từ Canifa)</td></tr>
<tr><td><code>device_id</code></td><td><span class="type-badge type-varchar">VARCHAR</span></td><td>ID thiết bị user</td></tr>
<tr><td><code>model_name</code></td><td><span class="type-badge type-varchar">VARCHAR</span></td><td>Model LLM (gpt-5.2-codex, gemini-3.1-flash-lite...)</td></tr>
<tr><td><code>trace_latency</code></td><td><span class="type-badge type-decimal">DECIMAL</span></td><td>Tổng latency trace (giây)</td></tr>
<tr><td><code>user_latency</code></td><td><span class="type-badge type-decimal">DECIMAL</span></td><td>Latency user cảm nhận (giây)</td></tr>
<tr><td><code>input_cost</code></td><td><span class="type-badge type-decimal">DECIMAL</span></td><td>Chi phí input tokens (USD)</td></tr>
<tr><td><code>output_cost</code></td><td><span class="type-badge type-decimal">DECIMAL</span></td><td>Chi phí output tokens (USD)</td></tr>
<tr><td><code>total_cost</code></td><td><span class="type-badge type-decimal">DECIMAL</span></td><td>Tổng chi phí (USD)</td></tr>
<tr><td><code>total_obs</code></td><td><span class="type-badge type-bigint">BIGINT</span></td><td>Tổng observations trong trace</td></tr>
<tr><td><code>total_obs_error</code></td><td><span class="type-badge type-bigint">BIGINT</span></td><td>Observations bị lỗi</td></tr>
<tr><td><code>nb_generation_error</code></td><td><span class="type-badge type-bigint">BIGINT</span></td><td>Số lỗi generation</td></tr>
<tr><td><code>is_guest</code></td><td><span class="type-badge type-tinyint">TINYINT</span></td><td>1 = khách vãng lai, 0 = đã login</td></tr>
<tr><td><code>is_user</code></td><td><span class="type-badge type-tinyint">TINYINT</span></td><td>1 = user đã xác thực</td></tr>
<tr><td><code>traced_at</code></td><td><span class="type-badge type-datetime">DATETIME</span></td><td>Thời điểm trace xảy ra</td></tr>
</tbody>
</table>
</div>
<!-- ═══════════════════════════════════════════════════════════════════ -->
<!-- REPORT STRUCTURE -->
<!-- ═══════════════════════════════════════════════════════════════════ -->
<div class="section">
<div class="section-label"><span class="dot"></span> Output</div>
<h2>Cấu trúc báo cáo</h2>
<p>Agent viết báo cáo dưới dạng JSON, frontend render realtime. Mỗi report có:</p>
<div class="cards" style="grid-template-columns: 1fr 1fr 1fr">
<div class="card" style="text-align:center">
<div style="font-size:28px;margin-bottom:8px">📝</div>
<h3>Executive Summary</h3>
<p>Tóm tắt 3-5 câu từ data thật</p>
</div>
<div class="card" style="text-align:center">
<div style="font-size:28px;margin-bottom:8px">📊</div>
<h3>4-6 Sections</h3>
<p>Mỗi section: text + chart hoặc table</p>
</div>
<div class="card" style="text-align:center">
<div style="font-size:28px;margin-bottom:8px">💡</div>
<h3>Recommendations</h3>
<p>4-6 khuyến nghị dựa trên findings</p>
</div>
</div>
<div class="callout callout-success" style="margin-top:20px">
<span class="callout-icon">📈</span>
<div>
<strong>Chart types hỗ trợ:</strong> Bar, Line, Area, Horizontal Bar, Donut.<br>
Tất cả data trên biểu đồ đều đến từ query thật — không có số giả.
</div>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════════════════ -->
<!-- TECH STACK -->
<!-- ═══════════════════════════════════════════════════════════════════ -->
<div class="section">
<div class="section-label"><span class="dot"></span> Tech Stack</div>
<h2>Kiến trúc kỹ thuật</h2>
<div class="cards">
<div class="card">
<h3>🖥️ Frontend</h3>
<p>React 18 (UMD) + Babel standalone + Chart.js 4. SSE streaming qua <code>fetch()</code> + <code>ReadableStream</code>. Progressive section rendering.</p>
</div>
<div class="card">
<h3>⚡ Backend</h3>
<p>FastAPI + <code>StreamingResponse</code> (SSE). Async tools via <code>asyncio.gather()</code>. Endpoint: <code>POST /api/report-agent</code></p>
</div>
<div class="card">
<h3>🤖 LLM</h3>
<p>Codex GPT-5.3 (default). 2-3 LLM calls per report: Think + Reflect + Write. Custom system prompts cho mỗi phase.</p>
</div>
<div class="card">
<h3>🗄️ Databases</h3>
<p>StarRocks (product catalog + Langfuse) + PostgreSQL (chat histories). Read-only access, SQL injection protected.</p>
</div>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════════════════ -->
<!-- CTA -->
<!-- ═══════════════════════════════════════════════════════════════════ -->
<div style="text-align:center;padding:32px 0 20px;border-top:1px solid #E2E8F0">
<p style="font-size:14px;color:#64748B;margin-bottom:16px">Sẵn sàng tạo báo cáo?</p>
<a href="ai-analytic.html" style="display:inline-flex;align-items:center;gap:8px;padding:12px 28px;background:linear-gradient(135deg,#1E40AF,#4338CA);color:white;text-decoration:none;border-radius:12px;font-size:14px;font-weight:700;box-shadow:0 4px 14px rgba(67,56,202,.3);transition:all .2s" onmouseenter="this.style.transform='translateY(-1px)'" onmouseleave="this.style.transform='translateY(0)'">
📋 Mở AI Report Agent →
</a>
</div>
</div>
</body>
</html>
<!DOCTYPE html>
<html lang="vi">
<head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AI Report Agent — Canifa AI</title>
<script src="https://unpkg.com/react@18/umd/react.production.min.js" crossorigin></script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js" crossorigin></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
</head>
<body>
<div id="root"></div>
<script type="text/babel">
const { useState, useRef, useEffect, useCallback } = React;
const ChartJS = Chart;
ChartJS.register(
ChartJS.CategoryScale, ChartJS.LinearScale, ChartJS.BarElement,
ChartJS.LineElement, ChartJS.PointElement, ChartJS.ArcElement,
ChartJS.Filler, ChartJS.Tooltip, ChartJS.Legend
)
const PAL = ["#2563EB","#059669","#D97706","#DC2626","#7C3AED","#0891B2","#EA580C","#DB2777"]
const rgba = (hex, a) => {
const [r,g,b] = [hex.slice(1,3),hex.slice(3,5),hex.slice(5,7)].map(v=>parseInt(v,16))
return `rgba(${r},${g},${b},${a})`
}
const SUGGESTIONS = [
"Báo cáo hiệu suất chatbot AI Canifa tháng 3/2026",
"Phân tích chi phí sử dụng LLM 30 ngày qua",
"Báo cáo chất lượng trả lời chatbot (scores & feedback)",
"Phân tích sản phẩm bán chạy nhất trên StarRocks",
]
// ─── CHARTS ──────────────────────────────────────────────────────────────────
const chartBase = (extra={}) => ({
responsive: true, maintainAspectRatio: false,
animation: { duration: 500, easing: "easeInOutQuart" },
plugins: {
legend: { display: false },
tooltip: { backgroundColor: "#1E293B", titleColor: "#94A3B8", bodyColor: "#F8FAFC", padding: 11, cornerRadius: 8,
titleFont:{size:10}, bodyFont:{size:12,weight:"600"} }
},
scales: {
x: { grid:{display:false}, border:{display:false}, ticks:{color:"#94A3B8",font:{size:10}} },
y: { grid:{color:"rgba(0,0,0,.05)"}, border:{display:false}, ticks:{color:"#94A3B8",font:{size:10}} }
},
...extra
})
function useChart(ref, build, deps) {
const ch = useRef(null)
useEffect(() => {
if (!ref.current) return
ch.current?.destroy()
ch.current = build(ref.current)
return () => ch.current?.destroy()
}, deps)
}
function InlineChart({ chart }) {
const ref = useRef(null)
const { type, labels=[], datasets=[], segments=[] } = chart
useChart(ref, (el) => {
if (type === "donut") {
return new ChartJS(el, {
type: "doughnut",
data: { labels: segments.map(s=>s.name), datasets: [{ data: segments.map(s=>s.value), backgroundColor: PAL, borderWidth:
0, hoverOffset: 4 }] },
options: { responsive:true, maintainAspectRatio:false, cutout:"68%", animation:{duration:500}, plugins:{ legend:{
display:true, position:"right", labels:{font:{size:11},color:"#475569",boxWidth:10,padding:10} }, tooltip:{
backgroundColor:"#1E293B",titleColor:"#94A3B8",bodyColor:"#F8FAFC",padding:10,cornerRadius:8 } } }
})
}
if (type === "hbar") {
return new ChartJS(el, {
type: "bar",
data: { labels, datasets: datasets.map((d,i)=>({ data:d.values,
backgroundColor:labels.map((_,j)=>rgba(PAL[j%PAL.length],.75)),
hoverBackgroundColor:labels.map((_,j)=>PAL[j%PAL.length]), borderRadius:4, borderSkipped:false })) },
options: { ...chartBase(), indexAxis:"y", scales:{ x:{
grid:{color:"rgba(0,0,0,.05)"},border:{display:false},ticks:{color:"#94A3B8",font:{size:10}} }, y:{
grid:{display:false},border:{display:false},ticks:{color:"#334155",font:{size:11,weight:"500"}} } } }
})
}
if (type === "area") {
const ctx = el.getContext("2d")
return new ChartJS(el, {
type: "line",
data: { labels, datasets: datasets.map((d,i) => {
const c = PAL[i%PAL.length], g = ctx.createLinearGradient(0,0,0,260)
g.addColorStop(0,rgba(c,.15)); g.addColorStop(1,rgba(c,0))
return { label:d.label, data:d.values, borderColor:c, backgroundColor:g, fill:true, borderWidth:2, tension:0.4,
pointRadius:3, pointBackgroundColor:c, pointBorderWidth:0 }
}) },
options: { ...chartBase(), plugins:{ ...chartBase().plugins, legend:{ display:datasets.length>1, position:"top",
labels:{font:{size:10},color:"#64748B",boxWidth:9,padding:10} } } }
})
}
if (type === "line") {
return new ChartJS(el, {
type: "line",
data: { labels, datasets: datasets.map((d,i)=>({ label:d.label, data:d.values, borderColor:PAL[i%PAL.length],
backgroundColor:"transparent", borderWidth:2.5, tension:0.4, pointRadius:3, pointBackgroundColor:PAL[i%PAL.length],
pointBorderWidth:0 })) },
options: { ...chartBase(), plugins:{ ...chartBase().plugins, legend:{ display:datasets.length>1, position:"top",
labels:{font:{size:10},color:"#64748B",boxWidth:9,padding:10} } } }
})
}
// bar default
return new ChartJS(el, {
type: "bar",
data: { labels, datasets: datasets.map((d,i)=>({ label:d.label, data:d.values,
backgroundColor:rgba(PAL[i%PAL.length],.75), hoverBackgroundColor:PAL[i%PAL.length], borderRadius:5, borderSkipped:false
})) },
options: { ...chartBase(), plugins:{ ...chartBase().plugins, legend:{ display:datasets.length>1, position:"top",
labels:{font:{size:10},color:"#64748B",boxWidth:9,padding:10} } } }
})
}, [JSON.stringify(chart)])
const h = type==="hbar" ? Math.max(180, (labels.length||4)*38) : 260
return (
<div style={{ margin:"20px 0" }}>
<p style={{
fontSize:12,fontWeight:600,color:"#64748B",textTransform:"uppercase",letterSpacing:"0.06em",marginBottom:10 }}>
{chart.title}
</p>
<div style={{ height:h, background:"#FAFBFC", borderRadius:10, padding:"16px", border:"1px solid #E8ECF0" }}>
<canvas ref={ref} />
</div>
{chart.caption && (
<p style={{ fontSize:12,color:"#94A3B8",marginTop:8,fontStyle:"italic",lineHeight:1.5 }}>
Hình: {chart.caption}
</p>
)}
</div>
)
}
function InlineTable({ table }) {
const { columns=[], rows=[] } = table
return (
<div style={{ margin:"20px 0" }}>
{table.title && <p style={{
fontSize:12,fontWeight:600,color:"#64748B",textTransform:"uppercase",letterSpacing:"0.06em",marginBottom:10 }}>
{table.title}</p>}
<div style={{ overflowX:"auto", border:"1px solid #E8ECF0", borderRadius:10 }}>
<table style={{ width:"100%", borderCollapse:"collapse", fontSize:13 }}>
<thead>
<tr style={{ background:"#F8FAFC" }}>
{columns.map((c,i) => <th key={i} style={{ textAlign:"left",padding:"10px 16px",color:"#475569",fontWeight:600,fontSize:11,borderBottom:"1px solid #E8ECF0",whiteSpace:"nowrap" }}>{c}</th>)}
</tr>
</thead>
<tbody>
{rows.map((row,i) => (
<tr key={i} style={{ borderBottom:i<rows.length-1?"1px solid #F1F5F9":"none" }} onMouseEnter={e=>
e.currentTarget.style.background="#F8FAFC"}
onMouseLeave={e=>e.currentTarget.style.background="transparent"}>
{row.map((cell,j) => <td key={j} style={{ padding:"10px 16px",color:j===0?"#0F172A":"#475569",fontWeight:j===0?600:400 }}>{cell}</td>)}
</tr>
))}
</tbody>
</table>
</div>
{table.caption && <p style={{ fontSize:12,color:"#94A3B8",marginTop:8,fontStyle:"italic" }}>Bng: {table.caption}
</p>}
</div>
)
}
function Callout({ type, text }) {
const styles = {
info: { bg:"#EFF6FF", border:"#BFDBFE", icon:"ℹ", iconBg:"#DBEAFE", iconColor:"#1D4ED8", textColor:"#1E40AF" },
warning: { bg:"#FFFBEB", border:"#FDE68A", icon:"!", iconBg:"#FEF3C7", iconColor:"#D97706", textColor:"#92400E" },
success: { bg:"#F0FDF4", border:"#BBF7D0", icon:"✓", iconBg:"#DCFCE7", iconColor:"#15803D", textColor:"#14532D" },
}
const s = styles[type]||styles.info
return (
<div style={{ background:s.bg, border:`1px solid ${s.border}`, borderRadius:10, padding:"14px 16px", margin:"20px 0",
display:"flex", gap:12, alignItems:"flex-start" }}>
<div style={{
width:22,height:22,borderRadius:"50%",background:s.iconBg,display:"flex",alignItems:"center",justifyContent:"center",flexShrink:0,fontSize:11,fontWeight:700,color:s.iconColor
}}>{s.icon}</div>
<p style={{ fontSize:13,color:s.textColor,lineHeight:1.6,margin:0 }}>{text}</p>
</div>
)
}
// ─── REPORT DOCUMENT ─────────────────────────────────────────────────────────
function ReportDoc({ report, onExport }) {
const now = new Date().toLocaleDateString("vi-VN", { year:"numeric", month:"long", day:"numeric" })
const TREND = { up:"↑", down:"↓", neutral:"→" }
const TREND_COLOR = { up:"#059669", down:"#DC2626", neutral:"#64748B" }
return (
<div style={{ maxWidth:780, margin:"0 auto", padding:"32px 24px 60px", fontFamily:"'Georgia',serif" }}>
{/* ── Cover ── */}
<div style={{ borderBottom:"3px solid #0F172A", paddingBottom:32, marginBottom:40 }}>
<div style={{ display:"flex", justifyContent:"space-between", alignItems:"flex-start", marginBottom:28 }}>
<div style={{ display:"flex", alignItems:"center", gap:10 }}>
<div style={{
width:36,height:36,borderRadius:9,background:"linear-gradient(135deg,#1E40AF,#4338CA)",display:"flex",alignItems:"center",justifyContent:"center",fontSize:16,color:"white"
}}></div>
<div>
<p style={{
fontSize:11,fontWeight:700,color:"#6366F1",textTransform:"uppercase",letterSpacing:"0.08em",margin:0
}}>ReportAI Agent</p>
<p style={{ fontSize:10,color:"#94A3B8",margin:0 }}>{report.organization||"Phòng Phân tích"}</p>
</div>
</div>
<div style={{ textAlign:"right" }}>
<p style={{ fontSize:10,color:"#94A3B8",margin:"0 0 2px" }}>Ngày xut bn</p>
<p style={{ fontSize:12,fontWeight:600,color:"#374151",margin:0 }}>{now}</p>
</div>
</div>
<div style={{ background:"linear-gradient(135deg,#F8FAFF,#EEF2FF)", borderRadius:14, padding:"28px 28px 24px",
border:"1px solid #E0E7FF" }}>
<div style={{
display:"inline-flex",alignItems:"center",gap:7,background:"#E0E7FF",borderRadius:100,padding:"4px 12px",marginBottom:14 }}>
<div style={{ width:6,height:6,borderRadius:"50%",background:"#4338CA" }} />
<span style={{
fontSize:10,fontWeight:700,color:"#3730A3",textTransform:"uppercase",letterSpacing:"0.07em" }}>Báo
cáo phân tích D liu tht</span>
</div>
<h1 style={{ fontSize:28,fontWeight:700,color:"#0F172A",lineHeight:1.25,margin:"0 0 10px",letterSpacing:"-0.02em",fontFamily:"'Georgia',serif" }}>{report.title}</h1>
<p style={{ fontSize:15,color:"#475569",margin:"0 0 20px",lineHeight:1.5,fontFamily:"system-ui,sans-serif"
}}>{report.subtitle}</p>
<div style={{ display:"flex", gap:20, flexWrap:"wrap" }}>
{[["Kỳ báo cáo",report.period],["Người lập",report.prepared_by]].map(([k,v])=>v&&(
<div key={k}>
<p style={{ fontSize:10,color:"#94A3B8",margin:"0 0 2px",textTransform:"uppercase",letterSpacing:"0.06em",fontFamily:"system-ui" }}>{k}</p>
<p style={{ fontSize:12.5,fontWeight:600,color:"#374151",margin:0,fontFamily:"system-ui" }}>{v}</p>
</div>
))}
</div>
</div>
</div>
{/* ── Highlights row ── */}
{report.highlights?.length>0 && (
<div style={{ display:"flex", gap:12, flexWrap:"wrap", marginBottom:36 }}>
{report.highlights.map((h,i) => (
<div key={i} style={{ flex:"1 1 120px",background:"#fff",borderRadius:10,padding:"14px 16px",border:"1px solid #E8ECF0",borderTop:`3px solid ${PAL[i%PAL.length]}` }}>
<p style={{
fontSize:9.5,fontWeight:700,color:"#94A3B8",textTransform:"uppercase",letterSpacing:"0.08em",margin:"0 0 6px",fontFamily:"system-ui" }}>{h.label}</p>
<p style={{ fontSize:22,fontWeight:800,color:"#0F172A",margin:"0 0 4px",letterSpacing:"-0.03em",fontFamily:"system-ui" }}>{h.value}</p>
<span style={{ fontSize:11,color:TREND_COLOR[h.trend]||"#64748B",fontFamily:"system-ui",fontWeight:600
}}>{TREND[h.trend]} {h.trend==="up"?"Tăng":h.trend==="down"?"Giảm":"Ổn định"}</span>
</div>
))}
</div>
)}
{/* ── Executive Summary ── */}
<div style={{ background:"#F8FAFF", borderLeft:"4px solid #4338CA", borderRadius:"0 10px 10px 0", padding:"20px 24px", marginBottom:36 }}>
<p style={{
fontSize:10,fontWeight:700,color:"#4338CA",textTransform:"uppercase",letterSpacing:"0.08em",margin:"0 0 10px",fontFamily:"system-ui" }}>Tóm tt điu hành</p>
<p style={{ fontSize:14,color:"#1E293B",lineHeight:1.8,margin:0 }}>{report.executive_summary}</p>
</div>
{/* ── Table of Contents ── */}
<div style={{ marginBottom:36, padding:"16px 20px", background:"#FAFBFC", borderRadius:10, border:"1px solid #E8ECF0" }}>
<p style={{
fontSize:10,fontWeight:700,color:"#64748B",textTransform:"uppercase",letterSpacing:"0.08em",margin:"0 0 12px",fontFamily:"system-ui" }}>Mc lc</p>
<div style={{ display:"flex", flexDirection:"column", gap:6 }}>
{report.sections?.map((s,i) => (
<div key={i} style={{ display:"flex", alignItems:"center", gap:10 }}>
<span style={{ fontSize:12,fontWeight:600,color:"#4338CA",fontFamily:"system-ui",width:20
}}>{s.number}.</span>
<span style={{ fontSize:13,color:"#334155",flex:1 }}>{s.title}</span>
<div style={{ flex:1,borderBottom:"1px dotted #CBD5E1",margin:"0 8px" }} />
<span style={{ fontSize:12,color:"#94A3B8",fontFamily:"system-ui" }}>{i+1+3}</span>
</div>
))}
</div>
</div>
{/* ── Sections ── */}
{report.sections?.map((section, si) => (
<div key={section.id||si} style={{ marginBottom:48 }}>
<div style={{ display:"flex", alignItems:"center", gap:14, marginBottom:18, paddingBottom:12, borderBottom:"1px solid #E8ECF0" }}>
<div style={{
width:36,height:36,borderRadius:10,background:`linear-gradient(135deg,${PAL[si%PAL.length]},${PAL[(si+1)%PAL.length]})`,display:"flex",alignItems:"center",justifyContent:"center",fontSize:14,fontWeight:800,color:"white",flexShrink:0,fontFamily:"system-ui"
}}>
{section.number}
</div>
<h2 style={{ fontSize:20,fontWeight:700,color:"#0F172A",margin:0,letterSpacing:"-0.01em" }}>{section.title}
</h2>
</div>
{section.paragraphs?.map((p, pi) => (
<p key={pi} style={{ fontSize:14,color:"#1E293B",lineHeight:1.85,marginBottom:14,textAlign:"justify" }}>{p}</p>
))}
{section.callout &&
<Callout type={section.callout.type} text={section.callout.text} />}
{section.chart &&
<InlineChart chart={section.chart} />}
{section.table &&
<InlineTable table={section.table} />}
</div>
))}
{/* ── Conclusion ── */}
{report.conclusion && (
<div style={{ marginBottom:36 }}>
<h2 style={{ fontSize:18,fontWeight:700,color:"#0F172A",marginBottom:14,paddingBottom:10,borderBottom:"1px solid #E8ECF0" }}>Kết lun</h2>
<p style={{ fontSize:14,color:"#1E293B",lineHeight:1.85 }}>{report.conclusion}</p>
</div>
)}
{/* ── Recommendations ── */}
{report.recommendations?.length>0 && (
<div style={{ background:"#F0FDF4", border:"1px solid #BBF7D0", borderRadius:14, padding:"22px 24px",
marginBottom:36 }}>
<p style={{
fontSize:10,fontWeight:700,color:"#15803D",textTransform:"uppercase",letterSpacing:"0.08em",margin:"0 0 14px",fontFamily:"system-ui" }}> Khuyến ngh</p>
<div style={{ display:"flex", flexDirection:"column", gap:10 }}>
{report.recommendations.map((r,i) => (
<div key={i} style={{ display:"flex", gap:12, alignItems:"flex-start" }}>
<div style={{
width:22,height:22,borderRadius:"50%",background:"#DCFCE7",display:"flex",alignItems:"center",justifyContent:"center",fontSize:10,fontWeight:700,color:"#15803D",flexShrink:0,marginTop:1,fontFamily:"system-ui"
}}>{i+1}</div>
<p style={{ fontSize:13.5,color:"#14532D",lineHeight:1.65,margin:0 }}>{r}</p>
</div>
))}
</div>
</div>
)}
{/* ── Footer ── */}
<div style={{ borderTop:"1px solid #E8ECF0", paddingTop:20, display:"flex", justifyContent:"space-between",
alignItems:"center" }}>
<p style={{ fontSize:11,color:"#94A3B8",fontFamily:"system-ui",margin:0 }}>Generated by ReportAI Agent · Powered by
Canifa AI Platform</p>
<button onClick={onExport} style={{ background:"#0F172A",color:"white",border:"none",borderRadius:8,padding:"8px 18px",fontSize:12,fontWeight:600,cursor:"pointer",fontFamily:"system-ui",display:"flex",alignItems:"center",gap:7
}}>
🖨 In / Lưu PDF
</button>
</div>
</div>
)
}
// ─── AGENT STEP CARD (shows tool calls in sidebar) ───────────────────────────
function AgentStep({ step }) {
const ICONS = {
thinking: "🧠",
plan_ready: "📋",
tool_call: "🔧",
tool_result: "📊",
section_ready: "📄",
report_complete: "✅",
reflect: "🔍",
error: "❌",
done: "🏁",
}
const icon = ICONS[step.type] || "•"
if (step.type === "tool_call") {
return (
<div style={{ padding:"7px 10px",background:"rgba(99,102,241,.12)",borderRadius:8,fontSize:11.5,color:"#C7D2FE",lineHeight:1.5 }}>
{icon} Gi <b style={{color:"#A5B4FC"}}>{step.tool}</b>
{step.params && Object.keys(step.params).length > 0 && (
<div style={{ fontSize:10,color:"#818CF8",marginTop:3,fontFamily:"monospace",wordBreak:"break-all" }}>
{JSON.stringify(step.params).slice(0, 120)}
</div>
)}
</div>
)
}
if (step.type === "tool_result") {
return (
<div style={{ padding:"7px 10px",background:step.success?"rgba(16,185,129,.12)":"rgba(239,68,68,.12)",borderRadius:8,fontSize:11.5,color:step.success?"#6EE7B7":"#FCA5A5",lineHeight:1.5 }}>
{step.success ? "✅" : "❌"} {step.tool}
{step.preview?.row_count != null && <span style={{color:"#A7F3D0"}}> {step.preview.row_count} rows</span>}
{step.preview?.summary?.total_traces != null && <span style={{color:"#A7F3D0"}}> {step.preview.summary.total_traces} traces</span>}
{step.preview?.error && <div style={{color:"#FCA5A5",fontSize:10,marginTop:2}}>{step.preview.error.slice(0,100)}</div>}
</div>
)
}
if (step.type === "plan_ready") {
return (
<div style={{ padding:"7px 10px",background:"rgba(99,102,241,.08)",borderRadius:8,fontSize:11.5,color:"#C7D2FE",lineHeight:1.5 }}>
📋 Kế hoch: <b>{step.tools_count} tools</b>{step.cycle ? ` · vòng ${step.cycle}` : ""}
{step.thinking && <div style={{fontSize:10,color:"#A5B4FC",marginTop:3,fontStyle:"italic"}}>{step.thinking.slice(0,150)}</div>}
</div>
)
}
if (step.type === "reflect") {
return (
<div style={{ padding:"7px 10px",background:step.data_sufficient?"rgba(16,185,129,.12)":"rgba(245,158,11,.12)",borderRadius:8,fontSize:11.5,color:step.data_sufficient?"#6EE7B7":"#FCD34D",lineHeight:1.5 }}>
{step.data_sufficient ? "✅" : "🔍"} Đánh giá vòng {step.cycle}: {step.data_sufficient ? <b>D liu đủ!</b> : <b>Cần thêm dữ liệu</b>}
{step.thinking && <div style={{fontSize:10,color:step.data_sufficient?"#A7F3D0":"#FDE68A",marginTop:3,fontStyle:"italic"}}>{step.thinking.slice(0,150)}</div>}
{step.missing?.length > 0 && <div style={{fontSize:10,color:"#FCA5A5",marginTop:2}}>Thiếu: {step.missing.join(", ").slice(0,100)}</div>}
{step.quality_issues?.length > 0 && <div style={{fontSize:10,color:"#FDBA74",marginTop:2}}>⚠️ {step.quality_issues.join(", ").slice(0,100)}</div>}
</div>
)
}
const text = step.step || step.message || (step.type === "done" ? "Hoàn thành!" : step.type)
return (
<div style={{ padding:"6px 10px",background:"rgba(255,255,255,.05)",borderRadius:8,fontSize:11.5,color:"#D1D5DB",lineHeight:1.5 }}>
{icon} {text}
</div>
)
}
// ─── GUIDE ────────────────────────────────────────────────────────────────────
function Guide() {
const S = (props) => <div style={{marginBottom:40,...props.style}}>{props.children}</div>
const Label = ({children}) => <div style={{display:"inline-flex",alignItems:"center",gap:8,fontSize:11,fontWeight:700,textTransform:"uppercase",letterSpacing:"0.1em",color:"#6366F1",marginBottom:12}}><span style={{width:8,height:8,borderRadius:"50%",background:"#6366F1"}}/>{children}</div>
const Card = ({icon,bg,border,title,desc,sub}) => <div style={{background:"#fff",border:`1.5px solid ${border||'#E2E8F0'}`,borderRadius:14,padding:20}}><div style={{width:40,height:40,borderRadius:10,background:bg,border:`1px solid ${border}`,display:"flex",alignItems:"center",justifyContent:"center",fontSize:18,marginBottom:12}}>{icon}</div><h3 style={{fontSize:14,fontWeight:700,color:"#0F172A",marginBottom:6}}>{title}</h3><p style={{fontSize:12.5,color:"#64748B",lineHeight:1.6}}>{desc}</p>{sub&&<p style={{marginTop:8,fontSize:11,color:"#94A3B8"}}>{sub}</p>}</div>
const FlowStep = ({icon,bg,border,title,desc,tags}) => <div style={{display:"flex",gap:16,alignItems:"flex-start",marginBottom:8}}><div style={{width:44,height:44,borderRadius:12,background:bg,border:`1.5px solid ${border}`,display:"flex",alignItems:"center",justifyContent:"center",fontSize:18,flexShrink:0}}>{icon}</div><div style={{flex:1,paddingBottom:20}}><h3 style={{fontSize:14,fontWeight:700,color:"#0F172A",marginBottom:4}}>{title}</h3><p style={{fontSize:12.5,color:"#64748B",lineHeight:1.6}}>{desc}</p>{tags&&<div style={{display:"flex",gap:6,marginTop:6,flexWrap:"wrap"}}>{tags.map((t,i)=><span key={i} style={{fontSize:10,fontWeight:700,padding:"3px 8px",borderRadius:6,background:t.bg,color:t.color,textTransform:"uppercase",letterSpacing:"0.03em"}}>{t.text}</span>)}</div>}</div></div>
const schemaRows = [
["trace_id","VARCHAR","ID unique của trace"],
["session_id","VARCHAR","ID phiên hội thoại"],
["customer_id","VARCHAR","Mã khách hàng"],
["device_id","VARCHAR","ID thiết bị user"],
["model_name","VARCHAR","Model LLM (gpt-5.2-codex, gemini-3.1-flash-lite...)"],
["trace_latency","DECIMAL","Tổng latency trace (giây)"],
["user_latency","DECIMAL","Latency user cảm nhận (giây)"],
["input_cost","DECIMAL","Chi phí input tokens (USD)"],
["output_cost","DECIMAL","Chi phí output tokens (USD)"],
["total_cost","DECIMAL","Tổng chi phí (USD)"],
["total_obs","BIGINT","Tổng observations"],
["total_obs_error","BIGINT","Observations bị lỗi"],
["nb_generation_error","BIGINT","Số lỗi generation"],
["is_guest","TINYINT","1=khách vãng lai, 0=đã login"],
["traced_at","DATETIME","Thời điểm trace xảy ra"],
]
const typeColor = {VARCHAR:"#4338CA",DECIMAL:"#059669",BIGINT:"#D97706",DATETIME:"#E11D48",TINYINT:"#0284C7"}
const typeBg = {VARCHAR:"#EEF2FF",DECIMAL:"#ECFDF5",BIGINT:"#FEF3C7",DATETIME:"#FFF1F2",TINYINT:"#F0F9FF"}
return (
<div style={{maxWidth:880,margin:"0 auto",padding:"40px 28px 80px",fontFamily:"'Inter',system-ui,sans-serif"}}>
{/* Hero */}
<div style={{background:"linear-gradient(135deg,#0F172A,#1E293B 50%,#312E81)",borderRadius:20,padding:"48px 36px 40px",textAlign:"center",color:"white",marginBottom:40,position:"relative",overflow:"hidden"}}>
<div style={{position:"absolute",inset:0,background:"radial-gradient(circle at 30% 20%, rgba(99,102,241,.2) 0%, transparent 50%), radial-gradient(circle at 70% 80%, rgba(59,130,246,.15) 0%, transparent 50%)"}} />
<div style={{position:"relative"}}>
<div style={{display:"inline-flex",alignItems:"center",gap:6,padding:"6px 14px",background:"rgba(99,102,241,.2)",border:"1px solid rgba(99,102,241,.3)",borderRadius:100,fontSize:12,fontWeight:600,color:"#A5B4FC",marginBottom:18}}>📋 Documentation · v2.0</div>
<h1 style={{fontSize:28,fontWeight:800,letterSpacing:"-0.03em",marginBottom:10}}>AI Report Agent</h1>
<p style={{fontSize:14,color:"#94A3B8",maxWidth:550,margin:"0 auto",lineHeight:1.7}}>Hướng dn chi tiết v lung hot động, kiến trúc ReAct Reflect Loop, và các công c mà Agent s dng.</p>
</div>
</div>
{/* Overview */}
<S><Label>Tng quan</Label>
<h2 style={{fontSize:20,fontWeight:800,color:"#0F172A",letterSpacing:"-0.02em",marginBottom:14}}>Agent hot động như thế nào?</h2>
<p style={{fontSize:13.5,color:"#475569",lineHeight:1.7,marginBottom:14}}>AI Report Agent s dng kiến trúc <b>ReAct (Reasoning + Acting)</b> với cơ chế <b>Reflect Loop</b>. Thay vì chy cng mt pipeline, Agent t suy nghĩ, query d liu, đánh giá xem đã đủ chưa, và t quyết định khi nào dng li để viết báo cáo.</p>
<div style={{padding:"14px 18px",background:"#EEF2FF",border:"1px solid #E0E7FF",borderRadius:12,fontSize:13,color:"#3730A3",display:"flex",gap:12,alignItems:"flex-start"}}>
<span style={{fontSize:18,flexShrink:0}}>💡</span>
<div><b>Ti sao dùng Reflect Loop?</b><br/>Pipeline truyn thng (Planner Tools Writer) s quyết định tt c tools t đầu. Nếu 1 tool li hoc data thiếu, không có cơ chế b sung. Reflect Loop cho phép Agent t đánh giá và query thêm khi cn.</div>
</div>
</S>
{/* Flow */}
<S><Label>Lung hot động</Label>
<h2 style={{fontSize:20,fontWeight:800,color:"#0F172A",letterSpacing:"-0.02em",marginBottom:14}}>ReAct Reflect Loop Chi tiết</h2>
<div style={{background:"#fff",border:"1.5px solid #E2E8F0",borderRadius:16,padding:"24px 28px",marginBottom:24}}>
<pre style={{fontFamily:"'Inter',system-ui",fontSize:12.5,color:"#475569",lineHeight:2.1,whiteSpace:"pre-wrap"}}>{` 📝 User nhập yêu cầu báo cáo
🧠 THINK — Agent phân tích, chọn tools + viết SQL
🔧 ACT — Execute tools song song (asyncio.gather)
👁️ OBSERVE — Thu thập kết quả từ các tools
🔍 REFLECT — LLM tự đánh giá:
│ • Dữ liệu đã đủ chưa?
│ • Chất lượng data có ổn không?
│ • Còn thiếu góc nhìn nào?
├── ❌ Chưa đủ → Quay lại THINK (max 3 vòng)
└── ✅ Đủ rồi → Tiếp tục xuống
✍️ WRITE — LLM viết báo cáo hoàn chỉnh từ data thật
📄 Stream sections lên frontend
🏁 DONE!`}</pre>
</div>
<FlowStep icon="🧠" bg="linear-gradient(135deg,#EEF2FF,#E0E7FF)" border="#C7D2FE" title="Phase 1: THINK — Phân tích & Lên kế hoạch" desc="Agent nhận yêu cầu từ user, phân tích cần data gì, tạo SQL queries phù hợp. Chọn 2-4 tools đa dạng cho batch đầu." tags={[{text:"LLM Call #1",bg:"#EEF2FF",color:"#4338CA"}]} />
<FlowStep icon="🔧" bg="linear-gradient(135deg,#FEF3C7,#FDE68A)" border="#FCD34D" title="Phase 2: ACT — Execute Tools Song Song" desc="Tất cả tools trong batch được execute đồng thời bằng asyncio.gather(). Nhanh gấp 3-4x so với tuần tự." tags={[{text:"Parallel I/O",bg:"#ECFDF5",color:"#059669"}]} />
<FlowStep icon="🔍" bg="linear-gradient(135deg,#FFF7ED,#FFEDD5)" border="#FDBA74" title="Phase 3: REFLECT — Tự đánh giá dữ liệu" desc="LLM xem lại toàn bộ data đã thu thập. Nếu thiếu → tạo thêm queries. Nếu đủ → chuyển sang viết." tags={[{text:"LLM Call #2",bg:"#FEF3C7",color:"#D97706"},{text:"Max 3 vòng",bg:"#FFF1F2",color:"#E11D48"}]} />
<FlowStep icon="✍️" bg="linear-gradient(135deg,#ECFDF5,#D1FAE5)" border="#6EE7B7" title="Phase 4: WRITE — Viết báo cáo hoàn chỉnh" desc="LLM nhận toàn bộ data thật → viết báo cáo JSON: executive summary, highlights, 4-6 sections (chart/table), conclusion, recommendations." tags={[{text:"LLM Call #3",bg:"#ECFDF5",color:"#059669"}]} />
</S>
{/* Tools */}
<S><Label>Công c d liu</Label>
<h2 style={{fontSize:20,fontWeight:800,color:"#0F172A",letterSpacing:"-0.02em",marginBottom:14}}>4 Tools Agent s dng</h2>
<p style={{fontSize:13.5,color:"#475569",marginBottom:16}}>Agent có quyn truy cp 4 tools để thu thp d liu. Mi tool ch được phép <b>SELECT</b> (read-only).</p>
<div style={{display:"grid",gridTemplateColumns:"1fr 1fr",gap:14}}>
<Card icon="📊" bg="#EEF2FF" border="#E0E7FF" title="sql_langfuse" desc="Query bảng analytic.chatbot_rsa_trace_event_detail — traces, latency, costs, models, errors." sub="👉 Hiệu suất chatbot, chi phí LLM, user analytics" />
<Card icon="🏷️" bg="#FEF3C7" border="#FDE68A" title="sql_starrocks" desc="Query bảng sản phẩm — tên, giá, số lượng bán, màu, chất liệu, mùa." sub="👉 Phân tích sản phẩm, doanh thu, thống kê catalog" />
<Card icon="💬" bg="#ECFDF5" border="#A7F3D0" title="sql_postgres" desc="Query chat histories — tin nhắn user, bot, timestamp, identity." sub="👉 Phân tích hội thoại, volume chat, engagement" />
<Card icon="🧮" bg="#FFF1F2" border="#FECDD3" title="calculator" desc="Tính toán math: cộng, trừ, nhân, chia, phần trăm. Chỉ nhận digits + operators." sub="👉 Tỉ lệ tăng trưởng, % thay đổi, trung bình" />
</div>
</S>
{/* Schema */}
<S><Label>Schema</Label>
<h2 style={{fontSize:20,fontWeight:800,color:"#0F172A",letterSpacing:"-0.02em",marginBottom:14}}>Bng Langfuse trên StarRocks</h2>
<p style={{fontSize:13.5,color:"#475569",marginBottom:14}}>Bng <code style={{background:"#F1F5F9",padding:"2px 6px",borderRadius:4,fontSize:11,color:"#6366F1"}}>analytic.chatbot_rsa_trace_event_detail</code> — chứa toàn bộ traces từ Langfuse.</p>
<table style={{width:"100%",borderCollapse:"collapse",fontSize:12.5}}>
<thead><tr style={{background:"#F8FAFC"}}><th style={{textAlign:"left",padding:"8px 12px",fontWeight:700,color:"#475569",borderBottom:"2px solid #E2E8F0"}}>Ct</th><th style={{textAlign:"left",padding:"8px 12px",fontWeight:700,color:"#475569",borderBottom:"2px solid #E2E8F0"}}>Kiểu</th><th style={{textAlign:"left",padding:"8px 12px",fontWeight:700,color:"#475569",borderBottom:"2px solid #E2E8F0"}}>Mô t</th></tr></thead>
<tbody>{schemaRows.map(([col,type,desc],i) => <tr key={i}><td style={{padding:"8px 12px",borderBottom:"1px solid #F1F5F9",color:"#334155"}}><code style={{background:"#F1F5F9",padding:"2px 6px",borderRadius:4,fontSize:11}}>{col}</code></td><td style={{padding:"8px 12px",borderBottom:"1px solid #F1F5F9"}}><span style={{fontSize:10,fontWeight:600,padding:"2px 8px",borderRadius:4,fontFamily:"monospace",background:typeBg[type],color:typeColor[type]}}>{type}</span></td><td style={{padding:"8px 12px",borderBottom:"1px solid #F1F5F9",color:"#334155"}}>{desc}</td></tr>)}</tbody>
</table>
</S>
{/* Tech */}
<S><Label>Tech Stack</Label>
<h2 style={{fontSize:20,fontWeight:800,color:"#0F172A",letterSpacing:"-0.02em",marginBottom:14}}>Kiến trúc k thut</h2>
<div style={{display:"grid",gridTemplateColumns:"1fr 1fr",gap:14}}>
<Card icon="🖥️" bg="#F8FAFC" border="#E2E8F0" title="Frontend" desc="React 18 (UMD) + Babel + Chart.js 4. SSE streaming qua fetch() + ReadableStream." />
<Card icon="⚡" bg="#F8FAFC" border="#E2E8F0" title="Backend" desc="FastAPI + StreamingResponse (SSE). Async tools via asyncio.gather(). POST /api/report-agent" />
<Card icon="🤖" bg="#F8FAFC" border="#E2E8F0" title="LLM" desc="Codex GPT-5.3. 2-3 LLM calls/report: Think + Reflect + Write. Custom system prompts." />
<Card icon="🗄️" bg="#F8FAFC" border="#E2E8F0" title="Databases" desc="StarRocks (products + Langfuse) + PostgreSQL (chat). Read-only, SQL injection protected." />
</div>
</S>
</div>
)
}
// ─── EMPTY ────────────────────────────────────────────────────────────────────
function Empty({ onSuggest, onShowGuide }) {
return (
<div style={{
flex:1,display:"flex",flexDirection:"column",alignItems:"center",justifyContent:"center",gap:24,padding:40,background:"#FAFBFD"
}}>
<div style={{ width:72,height:72,borderRadius:22,background:"linear-gradient(135deg,#EEF2FF,#F5F3FF)",border:"1px solid #E0E7FF",display:"flex",alignItems:"center",justifyContent:"center",fontSize:32 }}>📋</div>
<div style={{ textAlign:"center",maxWidth:460 }}>
<p style={{
fontSize:20,fontWeight:800,color:"#0F172A",letterSpacing:"-0.02em",marginBottom:8,fontFamily:"'Georgia',serif"
}}>AI Report Agent</p>
<p style={{ fontSize:13.5,color:"#64748B",lineHeight:1.6,fontFamily:"system-ui" }}>
Mô t ch đề báo cáo Agent s t query d liu tht<br />t Langfuse, StarRocks, Postgres ri viết báo
cáo hoàn chnh
</p>
</div>
<div style={{ display:"grid",gridTemplateColumns:"1fr 1fr",gap:10,width:"100%",maxWidth:540 }}>
{SUGGESTIONS.map((s,i) => (
<button key={i} onClick={()=>onSuggest(s)} style={{ background:"#fff",border:"1.5px solid #E2E8F0",borderRadius:11,padding:"13px 15px",fontSize:12.5,color:"#475569",cursor:"pointer",textAlign:"left",lineHeight:1.5,fontFamily:"system-ui",transition:"all .15s" }} onMouseEnter={e=>{e.currentTarget.style.borderColor="#C7D2FE";e.currentTarget.style.color="#4338CA";e.currentTarget.style.background="#FAFBFF"}} onMouseLeave={e=>{e.currentTarget.style.borderColor="#E2E8F0";e.currentTarget.style.color="#475569";e.currentTarget.style.background="#fff"}}>
{s}
</button>
))}
</div>
<button onClick={onShowGuide} style={{marginTop:8,display:"inline-flex",alignItems:"center",gap:6,padding:"10px 20px",background:"#F8FAFC",border:"1.5px solid #E2E8F0",borderRadius:10,fontSize:12.5,fontWeight:600,color:"#6366F1",cursor:"pointer",transition:"all .15s"}} onMouseEnter={e=>{e.currentTarget.style.background="#EEF2FF";e.currentTarget.style.borderColor="#C7D2FE"}} onMouseLeave={e=>{e.currentTarget.style.background="#F8FAFC";e.currentTarget.style.borderColor="#E2E8F0"}}>
📖 Xem hướng dn Agent hot động
</button>
</div>
)
}
// ─── MAIN ─────────────────────────────────────────────────────────────────────
const SMIN=52, SDEF=340, SMAX=500
function ReportAI() {
const [messages, setMessages] = useState([{role:"assistant",content:"👋 Xin chào!\n\nTôi là AI Report Agent — tạo báo cáo từ dữ liệu thật:\n\n• 📊 Query Langfuse: traces, scores, costs\n• 🗄️ Query StarRocks: sản phẩm, doanh thu\n• 💬 Query Postgres: lịch sử chat\n• 📈 Tạo biểu đồ + bảng số liệu\n\nChọn gợi ý bên phải hoặc nhập chủ đề →"}])
const [steps, setSteps] = useState([]) // Agent steps for sidebar
const [input, setInput] = useState("")
const [loading, setLoading] = useState(false)
const [report, setReport] = useState(null)
const [showGuide, setShowGuide] = useState(false)
const [sideW, setSideW] = useState(SDEF)
const [collapsed, setCollapsed] = useState(false)
const bottomRef = useRef(null)
const dragging = useRef(false), dragX = useRef(0), startW = useRef(0)
const abortRef = useRef(null)
useEffect(() => { bottomRef.current?.scrollIntoView({behavior:"smooth"}) }, [messages, steps, loading])
const onMouseDown = useCallback(e => {
if (collapsed) return
dragging.current=true; dragX.current=e.clientX; startW.current=sideW; e.preventDefault()
}, [collapsed, sideW])
useEffect(() => {
const mv = e => { if(!dragging.current) return; setSideW(Math.max(SMIN+1, Math.min(SMAX,
startW.current+(e.clientX-dragX.current)))) }
const up = () => { dragging.current=false }
window.addEventListener("mousemove",mv); window.addEventListener("mouseup",up)
return () => { window.removeEventListener("mousemove",mv); window.removeEventListener("mouseup",up) }
}, [])
// ─── SSE STREAMING ──────────────────────────────────────────────────────────
const send = async (text) => {
const msg = text||input
if (!msg.trim()||loading) return
setInput(""); setLoading(true); setReport(null); setSteps([])
if (collapsed) setCollapsed(false)
const uMsgs = [...messages,{role:"user",content:msg}]; setMessages(uMsgs)
try {
const abort = new AbortController()
abortRef.current = abort
const res = await fetch("/api/report-agent", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ question: msg }),
signal: abort.signal,
})
if (!res.ok) {
throw new Error(`Server error: ${res.status}`)
}
const reader = res.body.getReader()
const decoder = new TextDecoder()
let buffer = ""
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split("\n")
buffer = lines.pop() || ""
for (const line of lines) {
if (!line.startsWith("data: ")) continue
const jsonStr = line.slice(6).trim()
if (!jsonStr) continue
try {
const evt = JSON.parse(jsonStr)
// Add to steps sidebar
if (["thinking","plan_ready","tool_call","tool_result","reflect"].includes(evt.type)) {
setSteps(prev => [...prev, evt])
}
// Progressive section rendering
if (evt.type === "section_ready") {
setReport(prev => {
if (!prev) return { sections: [evt.section] }
return { ...prev, sections: [...(prev.sections||[]), evt.section] }
})
setSteps(prev => [...prev, { type: "section_ready", step: `📄 Section ${evt.index + 1}: ${evt.section?.title || ""}` }])
}
// Full report
if (evt.type === "report_complete") {
setReport(evt.report)
const rpt = evt.report
const numSec = rpt.sections?.length||0
const numChart = rpt.sections?.filter(s=>s.chart).length||0
const numTable = rpt.sections?.filter(s=>s.table).length||0
setMessages(prev => [...prev, {
role:"assistant",
content:` Báo cáo **"${rpt.title}"** hoàn thành!\n\n${numSec} chương · ${numChart} biu đồ · ${numTable} bng\n\nD liu tht t Langfuse + DB.`
}])
}
if (evt.type === "error") {
setMessages(prev => [...prev, {role:"assistant", content:` Li: ${evt.message}`}])
setSteps(prev => [...prev, evt])
}
if (evt.type === "done") {
setSteps(prev => [...prev, { type: "done" }])
}
} catch (parseErr) {
console.warn("SSE parse error:", parseErr, jsonStr)
}
}
}
} catch(e) {
if (e.name !== "AbortError") {
setMessages(prev => [...prev, {role:"assistant", content:`❌ Lỗi: ${e.message}`}])
}
}
setLoading(false)
abortRef.current = null
}
const handleExport = () => {
window.print()
}
const handleReset = () => {
if (abortRef.current) abortRef.current.abort()
setReport(null)
setSteps([])
setMessages(messages.slice(0,1))
setLoading(false)
}
const bot = <div
style={{width:26,height:26,borderRadius:8,background:"linear-gradient(135deg,#1E40AF,#4338CA)",display:"flex",alignItems:"center",justifyContent:"center",flexShrink:0,fontSize:11,color:"white",fontFamily:"system-ui"}}>
</div>
return (
<div style={{display:"flex",height:"100vh",fontFamily:"system-ui,sans-serif",background:"#F0F2F8"}}>
<style>
{
` @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Georgia&display=swap');
* {
box-sizing: border-box;
margin: 0;
padding: 0
}
::-webkit-scrollbar {
width: 5px
}
::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, .1);
border-radius: 4px
}
.sb-scroll::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, .1)
}
@keyframes pulse {
0%,100% { opacity: .3 }
50% { opacity: 1 }
}
@keyframes pop {
from { opacity: 0; transform: translateY(5px) }
to { opacity: 1; transform: translateY(0) }
}
@keyframes spin {
to { transform: rotate(360deg) }
}
@keyframes fadeIn {
from { opacity: 0 }
to { opacity: 1 }
}
.msg-row { animation: pop .2s ease }
textarea:focus, button:focus { outline: none }
button { cursor: pointer }
@media print {
.no-print { display: none !important }
body { background: white }
.report-area { overflow: visible !important; height: auto !important }
}
`
}
</style>
{/* ── Sidebar ── */}
<div className="no-print"
style={{width:collapsed?SMIN:sideW,flexShrink:0,background:"#111827",display:"flex",flexDirection:"column",transition:collapsed?"width .25s ease":"none",overflow:"hidden",borderRight:"1px solid rgba(255,255,255,.06)"}}>
{/* header */}
<div style={{height:54,display:"flex",alignItems:"center",padding:"0 13px",gap:10,borderBottom:"1px solid rgba(255,255,255,.06)",flexShrink:0}}>
{collapsed ? (
<button onClick={()=>setCollapsed(false)}
style={{width:36,height:36,borderRadius:10,background:"linear-gradient(135deg,#1E40AF,#4338CA)",border:"none",display:"flex",alignItems:"center",justifyContent:"center",margin:"0 auto",fontSize:14,color:"white"}}></button>
) : (
<React.Fragment>
<div
style={{width:34,height:34,borderRadius:10,background:"linear-gradient(135deg,#1E40AF,#4338CA)",display:"flex",alignItems:"center",justifyContent:"center",flexShrink:0,fontSize:14,color:"white"}}>
</div>
<div style={{flex:1,minWidth:0}}>
<p style={{fontSize:13,fontWeight:700,color:"#F9FAFB",letterSpacing:"-0.01em"}}>ReportAI Agent</p>
<div style={{display:"flex",alignItems:"center",gap:4,marginTop:1}}>
<div style={{width:5,height:5,borderRadius:"50%",background:loading?"#FBBF24":"#4ADE80",animation:"pulse 2.5s ease-in-out infinite"}} />
<span style={{fontSize:10,color:loading?"#FBBF24":"#4ADE80",fontWeight:500}}>{loading?"Working...":"Codex · Ready"}</span>
</div>
</div>
<button onClick={()=>setCollapsed(true)}
style={{background:"none",border:"none",fontSize:18,width:28,height:28,borderRadius:6,display:"flex",alignItems:"center",justifyContent:"center",color:"#4B5563"}}></button>
</React.Fragment>
)}
</div>
{/* messages + agent steps */}
{!collapsed && (
<div className="sb-scroll" style={{flex:1,overflowY:"auto",padding:"14px 12px",display:"flex",flexDirection:"column",gap:8}}>
{messages.map((m,i) => (
<div key={`m${i}`} className="msg-row" style={{display:"flex",flexDirection:m.role==="user"
?"row-reverse":"row",gap:8,alignItems:"flex-end"}}>
{m.role==="assistant"&&bot}
<div style={{maxWidth:"83%",padding:"9px 13px",borderRadius:m.role==="user" ?"13px 13px 3px 13px":"13px 13px 13px 3px",background:m.role==="user"
?"linear-gradient(135deg,#1E40AF,#4338CA)":"rgba(255,255,255,.07)",fontSize:12.5,lineHeight:1.6,color:m.role==="user"
?"#fff":"#D1D5DB",whiteSpace:"pre-wrap"}}>
{m.content.replace(/\*\*(.*?)\*\*/g,"$1")}
</div>
</div>
))}
{/* Agent steps */}
{steps.length > 0 && (
<div style={{display:"flex",flexDirection:"column",gap:5,marginTop:4}}>
<p style={{fontSize:9,fontWeight:700,color:"#4B5563",textTransform:"uppercase",letterSpacing:"0.1em",marginBottom:2}}>Agent Steps</p>
{steps.map((s,i) => <AgentStep key={`s${i}`} step={s} />)}
</div>
)}
{loading && (
<div className="msg-row" style={{display:"flex",gap:8,alignItems:"flex-end"}}>
{bot}
<div style={{padding:"11px 14px",background:"rgba(255,255,255,.07)",borderRadius:"13px 13px 13px 3px",display:"flex",gap:5,alignItems:"center"}}>
{[0,1,2].map(i=>
<div key={i} style={{width:5,height:5,borderRadius:"50%",background:"#3B82F6",animation:`pulse 1.4s
ease-in-out ${i*.2}s infinite`}} />)}
</div>
</div>
)}
<div ref={bottomRef} />
</div>
)}
{/* collapsed icons */}
{collapsed && (
<div style={{flex:1,display:"flex",flexDirection:"column",alignItems:"center",paddingTop:12,gap:8}}>
{["📋","📊","💬"].map((ic,i) => <button key={i} onClick={()=>setCollapsed(false)}
style={{width:34,height:34,borderRadius:8,background:"rgba(255,255,255,.05)",border:"none",fontSize:14}}>{ic}</button>)}
</div>
)}
{/* input */}
{!collapsed && (
<div style={{padding:"11px 12px",borderTop:"1px solid rgba(255,255,255,.06)",flexShrink:0}}>
<div style={{background:"rgba(255,255,255,.06)",borderRadius:12,padding:"8px 8px 8px 12px",border:"1px solid rgba(255,255,255,.09)",display:"flex",gap:8,alignItems:"flex-end"}}>
<textarea value={input} onChange={e=>setInput(e.target.value)} onKeyDown={e=>{if(e.key==="Enter"&&!e.shiftKey){e.preventDefault();send()}}}
placeholder="Mô tả báo cáo cần tạo..." rows={2}
style={{flex:1,background:"transparent",border:"none",color:"#F9FAFB",fontSize:12.5,lineHeight:1.5,resize:"none",fontFamily:"inherit"}}/>
<button onClick={()=>send()} disabled={!input.trim()||loading}
style={{width:32,height:32,borderRadius:8,background:input.trim()&&!loading?"linear-gradient(135deg,#1E40AF,#4338CA)":"rgba(255,255,255,.07)",border:"none",display:"flex",alignItems:"center",justifyContent:"center",flexShrink:0,color:"white",fontSize:16,opacity:input.trim()&&!loading?1:.35}}></button>
</div>
<p style={{textAlign:"center",fontSize:9.5,color:"#1F2937",marginTop:5}}>Enter gi · Shift+Enter xung dòng</p>
</div>
)}
</div>
{/* drag handle */}
{!collapsed && (
<div className="no-print" onMouseDown={onMouseDown} style={{width:4,flexShrink:0,cursor:"col-resize",position:"relative",zIndex:10}}
onMouseEnter={e=>e.currentTarget.children[0].style.opacity=1}
onMouseLeave={e=>e.currentTarget.children[0].style.opacity=0}>
<div style={{position:"absolute",inset:0,background:"rgba(37,99,235,.6)",borderRadius:2,opacity:0,transition:"opacity .15s"}}/>
</div>
)}
{/* ── Report area ── */}
<div className="report-area" style={{flex:1,overflowY:"auto",minWidth:0,background:"#F0F2F8"}}>
{/* topbar */}
<div className="no-print" style={{height:52,background:"#fff",borderBottom:"1px solid #E8ECF0",display:"flex",alignItems:"center",padding:"0 20px",gap:12,flexShrink:0,position:"sticky",top:0,zIndex:5}}>
{collapsed && <button onClick={()=>setCollapsed(false)} style={{width:30,height:30,borderRadius:8,background:"#EEF2FF",border:"none",color:"#4338CA",fontSize:16,display:"flex",alignItems:"center",justifyContent:"center"}}></button>}
<div style={{width:28,height:28,borderRadius:8,background:"#EEF2FF",display:"flex",alignItems:"center",justifyContent:"center",fontSize:13,flexShrink:0}}>📋</div>
<div style={{minWidth:0,flex:1}}>
<p style={{fontWeight:700,color:"#0F172A",fontSize:13.5,whiteSpace:"nowrap",overflow:"hidden",textOverflow:"ellipsis"}}>{report?.title||"AI Report Agent"}</p>
{report?.period && <p style={{fontSize:10.5,color:"#94A3B8",marginTop:1}}>K báo cáo: {report.period}</p>}
</div>
<div style={{display:"flex",gap:8,flexShrink:0}}>
<button onClick={()=>setShowGuide(!showGuide)} style={{background:showGuide?"#EEF2FF":"#F8FAFC",border:`1px solid ${showGuide?"#C7D2FE":"#E2E8F0"}`,borderRadius:8,padding:"0 12px",height:28,display:"flex",alignItems:"center",gap:5,color:showGuide?"#4338CA":"#64748B",fontSize:11.5,fontWeight:600,cursor:"pointer"}}>
📖 {showGuide?"Đóng":"Hướng dẫn"}
</button>
{report && (
<>
<span style={{fontSize:11,color:"#4338CA",background:"#EEF2FF",padding:"4px 12px",borderRadius:100,fontWeight:600,border:"1px solid #E0E7FF"}}>
{report.sections?.length||0} chương · {report.sections?.filter(s=>s.chart).length||0} biu đồ
</span>
<button onClick={handleExport} style={{background:"#0F172A",color:"white",border:"none",borderRadius:9,padding:"7px 14px",fontSize:12,fontWeight:600,display:"flex",alignItems:"center",gap:6}}>
<span style={{fontSize:13}}>🖨</span> Lưu PDF
</button>
<button onClick={handleReset} style={{background:"#F8FAFC",border:"1px solid #E2E8F0",borderRadius:8,width:28,height:28,display:"flex",alignItems:"center",justifyContent:"center",color:"#64748B",fontSize:13}}></button>
</>
)}
</div>
</div>
{/* content */}
{showGuide ? (
<div style={{background:"#fff",minHeight:"calc(100vh - 52px)",overflowY:"auto",animation:"fadeIn .3s ease"}}>
<Guide />
</div>
) : loading && !report ? (
<div style={{flex:1,display:"flex",alignItems:"center",justifyContent:"center",minHeight:"calc(100vh - 52px)"}}>
<div style={{textAlign:"center"}}>
<div style={{width:44,height:44,borderRadius:"50%",border:"3px solid #DBEAFE",borderTopColor:"#2563EB",animation:"spin .8s linear infinite",margin:"0 auto 16px"}}/>
<p style={{fontSize:14,color:"#374151",fontWeight:600,fontFamily:"'Georgia',serif"}}>Agent đang x lý...</p>
<p style={{fontSize:12,color:"#94A3B8",marginTop:6}}>Query d liu tht · Phân tích · Viết báo cáo</p>
</div>
</div>
) : !report ? (
<div style={{display:"flex",flex:1,minHeight:"calc(100vh - 52px)"}}>
<Empty onSuggest={send} onShowGuide={()=>setShowGuide(true)}/>
</div>
) : (
<div style={{background:"#fff",margin:"0",minHeight:"calc(100vh - 52px)",animation:"fadeIn .4s ease"}}>
<ReportDoc report={report} onExport={handleExport}/>
</div>
)}
</div>
</div>
)
}
ReactDOM.createRoot(document.getElementById('root')).render(React.createElement(ReportAI));
</script>
</body>
</html>
......@@ -48,7 +48,7 @@ body{margin:0;display:flex;min-height:100vh}
<span class="nav-icon">🏷️</span>
<span>Product Perf.</span>
</a>
<a data-page="sql-chart.html" class="nav-item" onclick="navigateTo(this)">
<a data-page="ai-analytic.html" class="nav-item" onclick="navigateTo(this)">
<span class="nav-icon">📊</span>
<span>AI Data Analyst</span>
<span class="nav-badge badge-beta">NEW</span>
......@@ -94,7 +94,7 @@ body{margin:0;display:flex;min-height:100vh}
<span>Feedback Demo</span>
<span class="nav-badge badge-new">NEW</span>
</a>
<a data-page="index.html" class="nav-item" onclick="navigateTo(this)">
<a data-page="http://172.16.2.210:5006/static/index.html" class="nav-item" onclick="navigateTo(this)">
<span class="nav-icon">💬</span>
<span>Chatbot (Dev)</span>
<span class="nav-badge badge-beta">DEV</span>
......@@ -135,7 +135,8 @@ body{margin:0;display:flex;min-height:100vh}
function navigateTo(el) {
const page = el.getAttribute('data-page');
if (!page) return;
document.getElementById('contentFrame').src = '/static/' + page;
const src = page.startsWith('http') ? page : '/static/' + page;
document.getElementById('contentFrame').src = src;
// Update active state
document.querySelectorAll('#mainSidebar .nav-item').forEach(n => n.classList.remove('active'));
el.classList.add('active');
......@@ -151,7 +152,8 @@ function navigateTo(el) {
const params = new URLSearchParams(window.location.search);
const page = params.get('page');
if (page) {
document.getElementById('contentFrame').src = '/static/' + page;
const src = page.startsWith('http') ? page : '/static/' + page;
document.getElementById('contentFrame').src = src;
// Highlight active nav
document.querySelectorAll('#mainSidebar .nav-item[data-page]').forEach(el => {
if (el.getAttribute('data-page') === page) {
......@@ -170,7 +172,8 @@ function navigateTo(el) {
// ═══ HANDLE BACK/FORWARD ═══
window.addEventListener('popstate', function(e) {
if (e.state && e.state.page) {
document.getElementById('contentFrame').src = '/static/' + e.state.page;
const src = e.state.page.startsWith('http') ? e.state.page : '/static/' + e.state.page;
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);
});
......
<!DOCTYPE html>
<html lang="vi">
<head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DashboardAI — Canifa</title>
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<script src="frame-detect.js"></script>
<style>
*{margin:0;padding:0;box-sizing:border-box}
html,body{height:100%;width:100%}
body{font-family:'Plus Jakarta Sans',system-ui,sans-serif;background:#F5F4F0;color:#18181B;display:flex;overflow:hidden}
::-webkit-scrollbar{width:5px}::-webkit-scrollbar-thumb{background:rgba(0,0,0,.12);border-radius:4px}
:root{--indigo:#6366F1;--emerald:#10B981;--amber:#F59E0B;--red:#EF4444;--purple:#8B5CF6;--cyan:#06B6D4;--pink:#EC4899;--orange:#F97316;--teal:#14B8A6;--blue:#3B82F6}
/* ── LEFT CHAT (warm/light theme to match platform) ── */
.chat{width:360px;min-width:320px;flex-shrink:0;background:#FFFFFF;display:flex;flex-direction:column;border-right:1px solid #E2E0D8}
html.in-iframe .chat{width:340px}
.chat-hdr{padding:18px 20px;border-bottom:1px solid #E2E0D8;display:flex;align-items:center;gap:10px}
.chat-avatar{width:38px;height:38px;border-radius:11px;background:linear-gradient(135deg,#6366F1,#8B5CF6);display:flex;align-items:center;justify-content:center;font-size:16px;flex-shrink:0;color:#fff;box-shadow:0 4px 12px rgba(99,102,241,.25)}
.chat-hdr-title{font-size:14px;font-weight:700;color:#18181B}
.chat-hdr-status{display:flex;align-items:center;gap:5px;margin-top:2px;font-size:11px;color:#10B981}
.online-dot{width:6px;height:6px;border-radius:50%;background:#10B981;animation:pulse 2s ease-in-out infinite}
@keyframes pulse{0%,100%{opacity:.4}50%{opacity:1}}
.chat-reset{margin-left:auto;background:#F5F4F0;border:1px solid #E2E0D8;border-radius:8px;width:30px;height:30px;cursor:pointer;display:flex;align-items:center;justify-content:center;color:#78716C;font-size:13px;transition:.15s}
.chat-reset:hover{background:#E2E0D8;color:#18181B}
.model-bar{padding:10px 20px;border-bottom:1px solid #E2E0D8;display:flex;align-items:center;gap:8px}
.model-bar label{font-size:10px;font-weight:600;color:#78716C}
.model-select{padding:5px 10px;border:1px solid #E2E0D8;border-radius:8px;font-family:inherit;font-size:11px;background:#F5F4F0;color:#18181B;outline:none;cursor:pointer;min-width:160px}
.model-select:focus{border-color:#B45309;box-shadow:0 0 0 3px rgba(180,83,9,.1)}
.chat-body{flex:1;overflow-y:auto;padding:14px 16px;display:flex;flex-direction:column;gap:10px;background:#FAFAF8}
.msg{display:flex;gap:8px;align-items:flex-end;animation:fadeUp .25s ease}
@keyframes fadeUp{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:translateY(0)}}
.msg-user{flex-direction:row-reverse}
.msg-avatar{width:26px;height:26px;border-radius:8px;background:linear-gradient(135deg,#6366F1,#8B5CF6);display:flex;align-items:center;justify-content:center;flex-shrink:0;font-size:11px;color:#fff}
.msg-bubble{max-width:78%;padding:10px 14px;font-size:12.5px;line-height:1.6;white-space:pre-wrap;border-radius:14px 14px 14px 4px}
.msg-user .msg-bubble{border-radius:14px 14px 4px 14px;background:linear-gradient(135deg,#6366F1,#7C6CF6);color:#fff;box-shadow:0 2px 8px rgba(99,102,241,.2)}
.msg-ai .msg-bubble{background:#FFFFFF;color:#334155;border:1px solid #E2E0D8;box-shadow:0 1px 2px rgba(0,0,0,.04)}
.msg-ai .msg-bubble b{color:#18181B}
.typing{display:flex;gap:5px;padding:11px 14px;background:#FFFFFF;border:1px solid #E2E0D8;border-radius:14px 14px 14px 4px}
.dot{width:5px;height:5px;border-radius:50%;background:#6366F1;animation:pulse 1.2s ease-in-out infinite}
.dot:nth-child(2){animation-delay:.2s}.dot:nth-child(3){animation-delay:.4s}
.chat-input-wrap{padding:14px 16px;border-top:1px solid #E2E0D8;background:#FFFFFF}
.chat-input-box{display:flex;gap:8px;background:#F5F4F0;border-radius:12px;padding:8px 8px 8px 14px;border:1px solid #E2E0D8;transition:.2s}
.chat-input-box:focus-within{border-color:#B45309;box-shadow:0 0 0 3px rgba(180,83,9,.1)}
.chat-input-box textarea{flex:1;background:transparent;border:none;color:#18181B;font-family:inherit;font-size:12.5px;line-height:1.5;resize:none;outline:none}
.chat-input-box textarea::placeholder{color:#A8A29E}
.send-btn{width:34px;height:34px;border-radius:8px;border:none;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:.2s;align-self:flex-end;flex-shrink:0;font-size:14px;color:#fff}
.send-btn.active{background:#6366F1;box-shadow:0 2px 8px rgba(99,102,241,.3)}
.send-btn.active:hover{background:#4F46E5;transform:scale(1.05)}
.send-btn.disabled{background:#E2E0D8;color:#A8A29E;cursor:default}
.chat-hint{margin:7px 0 0;font-size:10px;color:#A8A29E;text-align:center}
/* ── RIGHT DASHBOARD ── */
.dash{flex:1;display:flex;flex-direction:column;overflow:hidden}
.dash-top{height:56px;background:#fff;border-bottom:1px solid #E2E8F0;display:flex;align-items:center;padding:0 24px;gap:12px;flex-shrink:0;box-shadow:0 1px 3px rgba(0,0,0,.04)}
.dash-icon{width:32px;height:32px;border-radius:10px;background:linear-gradient(135deg,#EEF2FF,#E0E7FF);display:flex;align-items:center;justify-content:center;font-size:15px}
.dash-title{font-weight:700;color:#0F172A;font-size:14px}
.dash-sub{font-size:12px;color:#94A3B8;margin-left:4px}
.dash-badge{margin-left:auto;font-size:11px;color:#6366F1;background:#EEF2FF;padding:4px 12px;border-radius:100px;font-weight:600}
.dash-body{flex:1;overflow-y:auto;padding:24px;display:flex;flex-direction:column}
/* ── GRID (12 col) ── */
.widget-grid{display:grid;grid-template-columns:repeat(12,1fr);gap:16px}
.widget-xs{grid-column:span 2}.widget-sm{grid-column:span 3}.widget-md{grid-column:span 4}
.widget-half{grid-column:span 6}.widget-lg{grid-column:span 8}.widget-full{grid-column:span 12}
@media(max-width:1200px){.widget-xs,.widget-sm{grid-column:span 6}.widget-md{grid-column:span 6}.widget-lg{grid-column:span 12}}
/* ── WIDGET CARD ── */
.wcard{background:#fff;border-radius:16px;border:1px solid #F1F5F9;height:100%;transition:box-shadow .2s,transform .2s;box-shadow:0 1px 3px rgba(0,0,0,.04);overflow:hidden}
.wcard:hover{box-shadow:0 8px 24px rgba(0,0,0,.06);transform:translateY(-1px)}
/* Skeleton */
.wcard.skeleton{position:relative;overflow:hidden}
.wcard.skeleton::after{content:'';position:absolute;inset:0;background:linear-gradient(90deg,transparent,rgba(0,0,0,.03),transparent);animation:shimmer 1.5s infinite}
@keyframes shimmer{0%{transform:translateX(-100%)}100%{transform:translateX(100%)}}
.skel-title{height:12px;width:60%;background:#F1F5F9;border-radius:4px;margin:20px 22px 0}
.skel-value{height:28px;width:40%;background:#F1F5F9;border-radius:6px;margin:12px 22px}
.skel-chart{height:180px;background:#F8FAFC;border-radius:8px;margin:0 22px 20px}
/* ── KPI CARD ── */
.kpi-card{padding:20px 22px;display:flex;flex-direction:column;gap:8px;position:relative;overflow:hidden}
.kpi-card::before{content:'';position:absolute;top:0;left:0;width:4px;height:100%;border-radius:0 4px 4px 0}
.kpi-card.c-indigo::before{background:var(--indigo)}.kpi-card.c-emerald::before{background:var(--emerald)}
.kpi-card.c-amber::before{background:var(--amber)}.kpi-card.c-red::before{background:var(--red)}
.kpi-card.c-purple::before{background:var(--purple)}.kpi-card.c-cyan::before{background:var(--cyan)}
.kpi-card.c-pink::before{background:var(--pink)}.kpi-card.c-orange::before{background:var(--orange)}
.kpi-card.c-teal::before{background:var(--teal)}.kpi-card.c-blue::before{background:var(--blue)}
.kpi-card::after{content:'';position:absolute;top:-20px;right:-20px;width:80px;height:80px;border-radius:50%;opacity:.06}
.kpi-card.c-indigo::after{background:var(--indigo)}.kpi-card.c-emerald::after{background:var(--emerald)}
.kpi-card.c-amber::after{background:var(--amber)}.kpi-card.c-red::after{background:var(--red)}
.kpi-card.c-purple::after{background:var(--purple)}.kpi-card.c-cyan::after{background:var(--cyan)}
.kpi-card.c-pink::after{background:var(--pink)}.kpi-card.c-orange::after{background:var(--orange)}
.kpi-card.c-teal::after{background:var(--teal)}.kpi-card.c-blue::after{background:var(--blue)}
.kpi-label{font-size:11px;font-weight:600;color:#94A3B8;text-transform:uppercase;letter-spacing:.06em}
.kpi-value{font-size:28px;font-weight:800;line-height:1}
.kpi-sub{font-size:11px;color:#94A3B8;font-weight:500}
/* ── CHART + TABLE SHARED ── */
.chart-card{padding:20px 22px}
.chart-title{font-size:13px;font-weight:600;color:#0F172A;margin-bottom:16px;display:flex;align-items:center;gap:8px}
.chart-title::before{content:'';width:3px;height:14px;border-radius:2px}
.chart-title.c-indigo::before{background:var(--indigo)}.chart-title.c-emerald::before{background:var(--emerald)}
.chart-title.c-amber::before{background:var(--amber)}.chart-title.c-red::before{background:var(--red)}
.chart-title.c-purple::before{background:var(--purple)}.chart-title.c-cyan::before{background:var(--cyan)}
.chart-title.c-pink::before{background:var(--pink)}.chart-title.c-orange::before{background:var(--orange)}
.chart-title.c-teal::before{background:var(--teal)}.chart-title.c-blue::before{background:var(--blue)}
.chart-wrap{position:relative;height:220px}.chart-wrap canvas{width:100%!important;height:100%!important}
.donut-layout{display:flex;align-items:center;gap:20px}
.donut-canvas-wrap{flex-shrink:0;width:160px;height:160px}
.donut-legend{flex:1;display:flex;flex-direction:column;gap:8px}
.donut-item{display:flex;align-items:center;justify-content:space-between}
.donut-name{font-size:12px;color:#475569;display:flex;align-items:center;gap:8px}
.donut-dot{width:8px;height:8px;border-radius:50%;flex-shrink:0}
.donut-pct{font-size:12px;font-weight:700;color:#0F172A}
/* Number Row */
.number-row{display:flex;gap:16px;padding:20px 22px}
.nr-item{flex:1;text-align:center}
.nr-item-label{font-size:10px;font-weight:600;color:#94A3B8;text-transform:uppercase;letter-spacing:.04em;margin-bottom:4px}
.nr-item-value{font-size:22px;font-weight:800;line-height:1}
.nr-divider{width:1px;background:#F1F5F9;flex-shrink:0}
/* Progress */
.progress-list{display:flex;flex-direction:column;gap:12px}
.progress-row{display:flex;align-items:center;gap:10px}
.progress-label{font-size:12px;color:#475569;min-width:100px;flex-shrink:0}
.progress-bar-bg{flex:1;height:8px;background:#F1F5F9;border-radius:4px;overflow:hidden}
.progress-bar-fill{height:100%;border-radius:4px;transition:width .6s ease}
.progress-value{font-size:12px;font-weight:600;color:#0F172A;min-width:40px;text-align:right}
/* Table */
.table-card{padding:20px 22px}.table-wrap{overflow-x:auto;border-radius:10px;border:1px solid #F1F5F9}
.wtable{width:100%;border-collapse:collapse;font-size:12px}
.wtable th{text-align:left;padding:10px 14px;color:#64748B;font-weight:600;font-size:10px;text-transform:uppercase;letter-spacing:.04em;background:#F8FAFC;border-bottom:2px solid #E2E8F0;white-space:nowrap}
.wtable td{padding:10px 14px;color:#334155;border-bottom:1px solid #F1F5F9}
.wtable tbody tr:hover td{background:#F8FAFC}
.wtable td:first-child{font-weight:500;color:#0F172A}
/* Error */
.widget-error-msg{color:#EF4444;font-size:11px;background:#FEF2F2;padding:10px 14px;border-radius:10px;border:1px solid #FECACA}
/* Empty */
.empty{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:20px;padding:32px}
.empty-icon{width:64px;height:64px;border-radius:18px;background:linear-gradient(135deg,#EEF2FF,#E0E7FF);display:flex;align-items:center;justify-content:center;font-size:28px;box-shadow:0 4px 12px rgba(99,102,241,.1)}
.empty h3{font-weight:700;color:#0F172A;font-size:16px}
.empty p{font-size:13px;color:#94A3B8;margin-top:-12px}
.suggest-list{display:flex;flex-direction:column;gap:8px;width:100%;max-width:420px}
.suggest-btn{background:#fff;border:1px solid #E2E8F0;border-radius:12px;padding:12px 16px;font-size:12.5px;color:#475569;cursor:pointer;text-align:left;font-family:inherit;transition:.2s;box-shadow:0 1px 2px rgba(0,0,0,.04)}
.suggest-btn:hover{background:#EEF2FF;border-color:#C7D2FE;color:#4338CA;box-shadow:0 4px 12px rgba(99,102,241,.08);transform:translateY(-1px)}
.spinner-wrap{flex:1;display:flex;align-items:center;justify-content:center}
.spinner{width:40px;height:40px;border-radius:50%;border:3px solid #E2E8F0;border-top-color:#6366F1;animation:spin .7s linear infinite;margin:0 auto 14px}
@keyframes spin{to{transform:rotate(360deg)}}
.spinner-text{font-size:13px;color:#64748B;text-align:center}
.spinner-sub{font-size:11px;color:#94A3B8;text-align:center;margin-top:4px}
</style>
</head>
<body>
<div class="chat">
<div class="chat-hdr">
<div class="chat-avatar"></div>
<div><div class="chat-hdr-title">DashboardAI</div><div class="chat-hdr-status"><span class="online-dot"></span><span id="statusLabel">Checking...</span></div></div>
<button class="chat-reset" onclick="resetAll()" title="Reset"></button>
</div>
<div class="model-bar">
<label>🤖 Model:</label>
<select class="model-select" id="modelSelect" onchange="selectedModel=this.value;localStorage.setItem('sql_chat_model',this.value)">
<optgroup label="ChatGPT Plus (Codex)">
<option value="codex/gpt-5.3-codex">GPT-5.3 Codex</option>
<option value="codex/gpt-5.2-codex">GPT-5.2 Codex</option>
<option value="codex/gpt-5.2">GPT-5.2</option>
<option value="codex/gpt-5.1">GPT-5.1</option>
<option value="codex/gpt-5">GPT-5</option>
</optgroup>
</select>
</div>
<div class="chat-body" id="chatBody">
<div class="msg msg-ai"><div class="msg-avatar"></div><div class="msg-bubble">👋 Xin chào! Tôi là <b>DashboardAI</b>.<br><br>Mô tả báo cáo bạn muốn, tôi sẽ tạo dashboard với <b>KPI</b>, <b>charts</b>, <b>tables</b> từ dữ liệu thực.<br><br>Chọn gợi ý bên phải →</div></div>
</div>
<div class="chat-input-wrap">
<div class="chat-input-box">
<textarea id="chatInput" rows="2" placeholder="Mô tả dashboard bạn muốn..." onkeydown="if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();sendMessage()}"></textarea>
<button class="send-btn disabled" id="sendBtn" onclick="sendMessage()"></button>
</div>
<div class="chat-hint">Enter gửi · Shift+Enter xuống dòng</div>
</div>
</div>
<div class="dash">
<div class="dash-top">
<div class="dash-icon">📊</div>
<span class="dash-title" id="dashTitle">Dashboard Preview</span>
<span class="dash-sub" id="dashSub"></span>
<span class="dash-badge" id="dashBadge" style="display:none">⚡ 0 widgets</span>
</div>
<div class="dash-body" id="dashBody">
<div class="empty" id="emptyState">
<div class="empty-icon">📊</div>
<h3>Dashboard sẽ xuất hiện tại đây</h3>
<p>Mô tả báo cáo bạn muốn ở khung chat bên trái</p>
<div class="suggest-list">
<button class="suggest-btn" onclick="quickSend('Phân tích tổng quan sản phẩm Canifa')">📦 Phân tích tổng quan sản phẩm Canifa</button>
<button class="suggest-btn" onclick="quickSend('So sánh sản phẩm nam và nữ theo giá bán và số lượng')">👫 So sánh nam vs nữ theo giá & số lượng</button>
<button class="suggest-btn" onclick="quickSend('Dashboard phân tích chất liệu và form dáng sản phẩm')">🧵 Phân tích chất liệu & form dáng</button>
<button class="suggest-btn" onclick="quickSend('Báo cáo sản phẩm giảm giá và hiệu quả khuyến mãi')">🏷️ Báo cáo giảm giá & khuyến mãi</button>
</div>
</div>
</div>
</div>
<script>
const PALETTE=['#6366F1','#10B981','#F59E0B','#EF4444','#8B5CF6','#06B6D4','#EC4899','#F97316','#14B8A6','#3B82F6'];
const COLORS=['indigo','emerald','amber','red','purple','cyan','pink','orange','teal','blue'];
const SIZE_MAP={xs:'widget-xs',sm:'widget-sm',md:'widget-md',half:'widget-half',lg:'widget-lg',full:'widget-full'};
let selectedModel=localStorage.getItem('sql_chat_model')||'codex/gpt-5.3-codex';
let loading=false, chartInstances=[], widgetMeta={};
document.getElementById('modelSelect').value=selectedModel;
const inp=document.getElementById('chatInput');
inp.addEventListener('input',()=>{document.getElementById('sendBtn').className='send-btn '+(inp.value.trim()&&!loading?'active':'disabled')});
(async()=>{try{const r=await fetch('/api/sql-chat/status');const d=await r.json();const el=document.getElementById('statusLabel');el.textContent=d.codex_available?'Codex · Online':'Offline';el.style.color=d.codex_available?'#4ADE80':'#EF4444'}catch(e){document.getElementById('statusLabel').textContent='Offline'}})();
function esc(s){return(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')}
function fmt(v){if(v==null)return'—';if(typeof v!=='number')return String(v);if(Math.abs(v)>=1e9)return(v/1e9).toFixed(1)+'B';if(Math.abs(v)>=1e6)return(v/1e6).toFixed(1)+'M';if(Math.abs(v)>=1e3)return(v/1e3).toFixed(1)+'K';if(Number.isInteger(v))return v.toLocaleString('vi-VN');return v.toFixed(2)}
function humanCol(c){return c.replace(/_/g,' ').replace(/\b\w/g,c=>c.toUpperCase())}
function quickSend(t){inp.value=t;sendMessage()}
function getColor(c){const i=COLORS.indexOf(c);return PALETTE[i>=0?i:0]}
function addMsg(t,u){const b=document.getElementById('chatBody');const d=document.createElement('div');d.className='msg '+(u?'msg-user':'msg-ai');d.innerHTML=u?`<div class="msg-bubble">${esc(t)}</div>`:`<div class="msg-avatar">✦</div><div class="msg-bubble">${t}</div>`;b.appendChild(d);b.scrollTop=b.scrollHeight}
function showTyping(){const b=document.getElementById('chatBody');const d=document.createElement('div');d.className='msg msg-ai';d.id='typingMsg';d.innerHTML=`<div class="msg-avatar">✦</div><div class="typing"><div class="dot"></div><div class="dot"></div><div class="dot"></div></div>`;b.appendChild(d);b.scrollTop=b.scrollHeight}
function rmTyping(){const e=document.getElementById('typingMsg');if(e)e.remove()}
// ── SSE STREAMING ──
async function sendMessage(){
const text=inp.value.trim();if(!text||loading)return;
inp.value='';document.getElementById('sendBtn').className='send-btn disabled';loading=true;
addMsg(text,true);showTyping();
document.getElementById('dashBody').innerHTML=`<div class="spinner-wrap"><div><div class="spinner"></div><div class="spinner-text">AI đang thiết kế dashboard...</div><div class="spinner-sub">Generating layout → Executing SQL → Streaming widgets</div></div></div>`;
try{
const resp=await fetch('/api/sql-dashboard',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({question:text,model:selectedModel,history:[]})});
const reader=resp.body.getReader();
const decoder=new TextDecoder();
let buffer='';
let widgetCount=0, errorCount=0;
while(true){
const{done,value}=await reader.read();
if(done)break;
buffer+=decoder.decode(value,{stream:true});
const lines=buffer.split('\n');
buffer=lines.pop()||'';
for(const line of lines){
if(!line.startsWith('data: '))continue;
const json_str=line.slice(6).trim();
if(!json_str)continue;
try{
const event=JSON.parse(json_str);
console.log('📡 SSE event:', event.type, event);
if(event.type==='header') { console.log('📋 Header widgets:', event.widgets); handleHeader(event); }
else if(event.type==='widget') { console.log('📊 Widget data:', event.id, 'rows:', event.data?.length, 'error:', event.error, event); handleWidget(event); widgetCount++; }
else if(event.type==='error') { console.error('❌ SSE error:', event.message); rmTyping(); addMsg(`❌ ${esc(event.message)}`,false); }
else if(event.type==='done') {
console.log('✅ SSE done! widgets:', widgetCount);
rmTyping();
addMsg(`✅ Dashboard sẵn sàng! <b>${widgetCount}</b> widgets${errorCount?' ('+errorCount+' lỗi)':''}`,false);
}
}catch(e){ console.error('❌ SSE parse error:', e, 'raw:', json_str); }
}
}
}catch(e){rmTyping();addMsg(`❌ ${esc(e.message)}`,false)}
loading=false;
}
function handleHeader(h){
rmTyping(); // LLM phase done
showTyping(); // now show typing for SQL phase
document.getElementById('dashTitle').textContent=h.title||'Dashboard';
document.getElementById('dashSub').textContent=h.subtitle?`— ${h.subtitle}`:'';
const badge=document.getElementById('dashBadge');badge.innerHTML=`⚡ ${h.widgets.length} widgets`;badge.style.display='';
// Render skeleton grid
chartInstances.forEach(c=>c.destroy());chartInstances=[];widgetMeta={};
let html='<div class="widget-grid" id="widgetGrid">';
h.widgets.forEach((w,i)=>{
widgetMeta[w.id]={type:w.type,title:w.title,size:w.size,color:w.color||COLORS[i%COLORS.length]};
const skelContent=w.type==='kpi'||w.type==='number-row'
?`<div class="skel-title"></div><div class="skel-value"></div>`
:`<div class="skel-title"></div><div class="skel-chart"></div>`;
html+=`<div class="${SIZE_MAP[w.size]||'widget-md'}" id="slot_${w.id}" style="animation:fadeUp .3s ease ${i*50}ms both"><div class="wcard skeleton">${skelContent}</div></div>`;
});
html+='</div>';
document.getElementById('dashBody').innerHTML=html;
}
function handleWidget(ev){
const meta=widgetMeta[ev.id];if(!meta)return;
const slot=document.getElementById('slot_'+ev.id);if(!slot)return;
const {type: _evType, ...evData} = ev; // exclude ev.type="widget" to preserve meta.type="kpi"
const w={...meta,...evData};
slot.innerHTML=`<div class="wcard" style="animation:fadeUp .3s ease">${renderWidget(w)}</div>`;
// Render Chart.js after DOM insert
if(['bar','horizontal-bar','line','area','scatter'].includes(w.type)&&!w.error) setTimeout(()=>renderChart(w),30);
if(w.type==='donut'&&!w.error) setTimeout(()=>renderDonutChart(w),30);
}
function renderWidget(w){
if(w.error)return`<div class="chart-card"><div class="chart-title c-${w.color}">${esc(w.title)}</div><div class="widget-error-msg">⚠️ ${esc(w.error)}</div></div>`;
switch(w.type){
case'kpi':return renderKPI(w);
case'number-row':return renderNumberRow(w);
case'donut':return renderDonut(w);
case'table':return renderTable(w);
case'progress':return renderProgress(w);
default:return renderChartCard(w);
}
}
// ── KPI ──
function renderKPI(w){
const d=w.data&&w.data[0]?w.data[0]:{};const k=Object.keys(d);const v=k.length?d[k[0]]:0;
return`<div class="kpi-card c-${w.color}"><div class="kpi-label">${esc(w.title)}</div><div class="kpi-value" style="color:var(--${w.color})">${fmt(v)}</div><div class="kpi-sub">${esc(k[0]?humanCol(k[0]):'')}</div></div>`;
}
// ── NUMBER ROW ──
function renderNumberRow(w){
const d=w.data&&w.data[0]?w.data[0]:{};const keys=Object.keys(d);
let html=`<div class="number-row">`;
keys.forEach((k,i)=>{
if(i>0)html+=`<div class="nr-divider"></div>`;
html+=`<div class="nr-item"><div class="nr-item-label">${esc(humanCol(k))}</div><div class="nr-item-value" style="color:${PALETTE[i%PALETTE.length]}">${fmt(d[k])}</div></div>`;
});
return html+`</div>`;
}
// ── PROGRESS ──
function renderProgress(w){
const data=w.data||[];const xk=w.x_key||Object.keys(data[0]||{})[0];
const yks=w.y_keys||[];const ck=yks[0]||'';const mk=yks[1]||'';
let html=`<div class="chart-card"><div class="chart-title c-${w.color}">${esc(w.title)}</div><div class="progress-list">`;
data.slice(0,10).forEach((r,i)=>{
const cur=Number(r[ck])||0;const max=Number(r[mk])||1;const pct=Math.min(100,(cur/max*100)).toFixed(0);
html+=`<div class="progress-row"><div class="progress-label">${esc(String(r[xk]||''))}</div><div class="progress-bar-bg"><div class="progress-bar-fill" style="width:${pct}%;background:${PALETTE[i%PALETTE.length]}"></div></div><div class="progress-value">${pct}%</div></div>`;
});
return html+`</div></div>`;
}
// ── CHART (bar/horizontal-bar/line/area/scatter) ──
function renderChartCard(w){return`<div class="chart-card"><div class="chart-title c-${w.color}">${esc(w.title)}</div><div class="chart-wrap"><canvas id="c_${w.id}"></canvas></div></div>`}
function renderChart(w){
const canvas=document.getElementById('c_'+w.id);if(!canvas)return;
const data=w.data||[];if(!data.length)return;
const xk=w.x_key||Object.keys(data[0])[0];
const yk=w.y_key||Object.keys(data[0]).find(k=>k!==xk&&typeof data[0][k]==='number')||Object.keys(data[0])[1];
const labels=data.map(r=>String(r[xk]||''));
const values=data.map(r=>Number(r[yk])||0);
const mc=getColor(w.color);
const baseOpts={responsive:true,maintainAspectRatio:false,animation:{duration:600,easing:'easeOutQuart'},
plugins:{legend:{display:false},tooltip:{backgroundColor:'#0F172A',titleColor:'#fff',bodyColor:'#E2E8F0',borderColor:mc,borderWidth:1,cornerRadius:10,titleFont:{family:"'Plus Jakarta Sans'",size:12,weight:'600'},bodyFont:{family:"'Plus Jakarta Sans'",size:11},padding:12,callbacks:{label:ctx=>`${humanCol(yk)}: ${fmt(ctx.parsed.y!=null?ctx.parsed.y:ctx.parsed)}`}}},
scales:{x:{grid:{display:false},ticks:{font:{size:10,family:"'Plus Jakarta Sans'"},color:'#94A3B8',maxRotation:45},border:{display:false}},y:{grid:{color:'#F1F5F9',drawBorder:false},ticks:{font:{size:10,family:"'Plus Jakarta Sans'"},color:'#94A3B8',callback:v=>fmt(v)},border:{display:false},beginAtZero:true}}
};
let cfg;
if(w.type==='bar') cfg={type:'bar',data:{labels,datasets:[{data:values,backgroundColor:mc+'CC',hoverBackgroundColor:mc,borderRadius:6,borderSkipped:false,maxBarThickness:40}]},options:baseOpts};
else if(w.type==='horizontal-bar') cfg={type:'bar',data:{labels,datasets:[{data:values,backgroundColor:PALETTE.slice(0,labels.length).map(c=>c+'CC'),borderRadius:4,borderSkipped:false}]},options:{...baseOpts,indexAxis:'y',scales:{x:{grid:{color:'#F1F5F9',drawBorder:false},ticks:{font:{size:10},color:'#94A3B8',callback:v=>fmt(v)},border:{display:false},beginAtZero:true},y:{grid:{display:false},ticks:{font:{size:10,family:"'Plus Jakarta Sans'"},color:'#334155'},border:{display:false}}}}};
else if(w.type==='line') cfg={type:'line',data:{labels,datasets:[{data:values,borderColor:mc,backgroundColor:'transparent',borderWidth:2.5,tension:.4,pointRadius:3,pointHoverRadius:6,pointBackgroundColor:'#fff',pointBorderColor:mc,pointBorderWidth:2}]},options:baseOpts};
else if(w.type==='area') cfg={type:'line',data:{labels,datasets:[{data:values,borderColor:mc,borderWidth:2.5,tension:.4,fill:true,pointRadius:0,pointHoverRadius:5,pointBackgroundColor:'#fff',pointBorderColor:mc,pointBorderWidth:2}]},options:baseOpts,plugins:[{id:'areaGrad',beforeDatasetDraw:(chart)=>{const ds=chart.data.datasets[0];if(!ds._gDone){const ctx=chart.ctx;const a=chart.chartArea;const g=ctx.createLinearGradient(0,a.top,0,a.bottom);g.addColorStop(0,mc+'30');g.addColorStop(1,mc+'03');ds.backgroundColor=g;ds._gDone=true;chart.update('none')}}}]};
else if(w.type==='scatter'){
const xk2=w.x_key||Object.keys(data[0])[0];const yk2=w.y_key||Object.keys(data[0]).find(k=>k!==xk2&&typeof data[0][k]==='number');
const pts=data.map(r=>({x:Number(r[xk2])||0,y:Number(r[yk2])||0}));
cfg={type:'scatter',data:{datasets:[{data:pts,backgroundColor:mc+'99',borderColor:mc,borderWidth:1,pointRadius:5,pointHoverRadius:8}]},
options:{...baseOpts,scales:{x:{grid:{color:'#F1F5F9'},ticks:{font:{size:10},color:'#94A3B8',callback:v=>fmt(v)},border:{display:false},title:{display:true,text:humanCol(xk2),font:{size:10},color:'#94A3B8'}},y:{grid:{color:'#F1F5F9'},ticks:{font:{size:10},color:'#94A3B8',callback:v=>fmt(v)},border:{display:false},beginAtZero:true,title:{display:true,text:humanCol(yk2),font:{size:10},color:'#94A3B8'}}}}
};
}
if(cfg){const c=new Chart(canvas.getContext('2d'),cfg);chartInstances.push(c)}
}
// ── DONUT ──
function renderDonut(w){
const data=w.data||[];const xk=w.x_key||(data[0]?Object.keys(data[0])[0]:'');
const yk=w.y_key||(data[0]?Object.keys(data[0]).find(k=>k!==xk&&typeof data[0][k]==='number'):'');
const total=data.reduce((a,r)=>a+(Number(r[yk])||0),0);
let leg='<div class="donut-legend">';
data.forEach((r,i)=>{const pct=total>0?((Number(r[yk])||0)/total*100).toFixed(1):'0';
leg+=`<div class="donut-item"><div class="donut-name"><div class="donut-dot" style="background:${PALETTE[i%PALETTE.length]}"></div>${esc(String(r[xk]||''))}</div><div class="donut-pct">${pct}%</div></div>`;
}); leg+='</div>';
return`<div class="chart-card"><div class="chart-title c-${w.color}">${esc(w.title)}</div><div class="donut-layout"><div class="donut-canvas-wrap"><canvas id="c_${w.id}" width="160" height="160"></canvas></div>${leg}</div></div>`;
}
function renderDonutChart(w){
const cv=document.getElementById('c_'+w.id);if(!cv)return;
const data=w.data||[];const xk=w.x_key||(data[0]?Object.keys(data[0])[0]:'');
const yk=w.y_key||(data[0]?Object.keys(data[0]).find(k=>k!==xk&&typeof data[0][k]==='number'):'');
const c=new Chart(cv.getContext('2d'),{type:'doughnut',data:{labels:data.map(r=>String(r[xk]||'')),datasets:[{data:data.map(r=>Number(r[yk])||0),backgroundColor:PALETTE.slice(0,data.length),borderWidth:0,hoverOffset:6}]},options:{responsive:true,maintainAspectRatio:false,cutout:'65%',plugins:{legend:{display:false},tooltip:{backgroundColor:'#0F172A',cornerRadius:10,padding:12}}}});
chartInstances.push(c);
}
// ── TABLE ──
function renderTable(w){
const data=w.data||[];const cols=w.columns||(data[0]?Object.keys(data[0]):[]);
let h=`<div class="table-card"><div class="chart-title c-${w.color}">${esc(w.title)}</div><div class="table-wrap"><table class="wtable"><thead><tr>`;
cols.forEach(c=>{h+=`<th>${esc(humanCol(c))}</th>`});h+='</tr></thead><tbody>';
data.slice(0,50).forEach(r=>{h+='<tr>';cols.forEach(c=>{const v=r[c];h+=`<td>${v==null?'<span style="color:#CBD5E1">—</span>':(typeof v==='number'?fmt(v):esc(String(v)))}</td>`});h+='</tr>'});
return h+'</tbody></table></div></div>';
}
function resetAll(){
chartInstances.forEach(c=>c.destroy());chartInstances=[];widgetMeta={};
document.getElementById('dashBody').innerHTML=`<div class="empty"><div class="empty-icon">📊</div><h3>Dashboard sẽ xuất hiện tại đây</h3><p>Mô tả báo cáo bạn muốn</p><div class="suggest-list"><button class="suggest-btn" onclick="quickSend('Phân tích tổng quan sản phẩm Canifa')">📦 Phân tích tổng quan sản phẩm Canifa</button><button class="suggest-btn" onclick="quickSend('So sánh sản phẩm nam và nữ theo giá bán')">👫 So sánh nam vs nữ</button></div></div>`;
document.getElementById('dashTitle').textContent='Dashboard Preview';document.getElementById('dashSub').textContent='';document.getElementById('dashBadge').style.display='none';
document.getElementById('chatBody').innerHTML='';addMsg('👋 Dashboard đã reset!',false);
}
</script>
</body>
</html>
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