Commit 3487d1a5 authored by Vũ Hoàng Anh's avatar Vũ Hoàng Anh

feat: implement Event Daily, Autotrain sync via Langfuse API, and clean up codebase

parent 059a13d4
......@@ -114,6 +114,38 @@ def init_db() -> None:
"""
)
cur.execute(
"""
CREATE TABLE IF NOT EXISTS event_daily_prompts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_id TEXT UNIQUE NOT NULL,
event_name TEXT NOT NULL,
event_type TEXT NOT NULL,
event_date TEXT NOT NULL,
is_today BOOLEAN DEFAULT 0,
prompt TEXT NOT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""
)
# Initialize mock data for event_daily_prompts if empty
cur.execute("SELECT COUNT(*) FROM event_daily_prompts")
if cur.fetchone()[0] == 0:
mock_events = [
('daily_default', 'Daily Standard', 'Daily', 'Mỗi ngày', 0, '- Tư vấn dựa trên yêu cầu cơ bản của khách hàng.\n- Tập trung vào tính tiện dụng và sự thoải mái.\n- Ưu tiên các sản phẩm mới nhất hoặc best seller nếu khách không có yêu cầu đặc biệt.'),
('womens_day', 'Quốc Tế Phụ Nữ 8/3', 'Campaign', '08/03/2026', 1, '- Ưu tiên gợi ý các sản phẩm váy đầm, đồ đôi, hoặc quà tặng cho phái nữ.\n- Gợi ý các combo quà tặng có giá trị cao.\n- Tone giọng: Nhẹ nhàng, tôn vinh phái đẹp, kèm lời chúc mừng 8/3.\n- Báo cho khách biết đang có chương trình giảm giá 30% cho đồ nữ.'),
('summer_sale', 'Đón Hè Sôi Động', 'Campaign', '01/05/2026 - 31/05/2026', 0, '- Tuyệt đối không gợi ý áo khoác dày, áo len.\n- Chỉ tập trung vào áo phông, đồ bơi, quần short, chất liệu thoáng mát (cotton, lanh).\n- Gợi ý đồ mặc đi biển, đi chơi ngày hè.'),
('black_friday', 'Black Friday Siêu Sale', 'Campaign', '27/11/2026', 0, '- NHẤN MẠNH vào giá sale cực sốc.\n- Up-sale mạnh tay, gợi ý mua theo combo để được giảm thêm.\n- Tạo cảm giác khan hiếm: \'Số lượng có hạn\', \'Sale lớn nhất năm\'.')
]
cur.executemany(
"""
INSERT INTO event_daily_prompts (event_id, event_name, event_type, event_date, is_today, prompt)
VALUES (?, ?, ?, ?, ?, ?)
""",
mock_events
)
conn.commit()
conn.close()
logger.info("Auto-Train DB initialized at %s", DB_PATH)
......@@ -397,6 +429,55 @@ async def list_feedbacks(
conn = get_db()
cur = conn.cursor()
# --- Langfuse Sync ---
try:
from config import LANGFUSE_BASE_URL, LANGFUSE_PUBLIC_KEY, LANGFUSE_SECRET_KEY
import httpx
if LANGFUSE_PUBLIC_KEY and LANGFUSE_SECRET_KEY:
# Fetch recent scores from Langfuse
auth = (LANGFUSE_PUBLIC_KEY, LANGFUSE_SECRET_KEY)
# Only fetch scores where value is less than 1 (e.g. 0 for dislike)
# or just fetch recent scores
url = f"{LANGFUSE_BASE_URL.rstrip('/')}/api/public/scores"
params = {"page": 1, "limit": 20}
async with httpx.AsyncClient() as client:
res = await client.get(url, auth=auth, params=params, timeout=5.0)
if res.status_code == 200:
data = res.json()
scores = data.get("data", [])
for s in scores:
# Consider score < 1 as negative feedback to be trained
if s.get("value", 1) < 1:
trace_id = s.get("traceId", "")
comment = s.get("comment", "")
if not trace_id:
continue
# Check if already in DB
cur.execute("SELECT id FROM feedbacks WHERE feedback_id = ?", (trace_id,))
if not cur.fetchone():
# Insert new feedback from Langfuse
cur.execute(
"""
INSERT INTO feedbacks (feedback_id, user_id, query, ai_response, feedback_text, feedback_type, channel, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
(
trace_id,
"langfuse_sync",
"N/A (Synced from Langfuse)", # Requires trace fetch for actual query
"N/A",
comment if comment else "Dislike rating",
"bug",
"langfuse",
"PENDING"
),
)
conn.commit()
except Exception as e:
logger.warning(f"Failed to sync feedbacks from Langfuse: {e}")
# ---------------------
if status:
cur.execute(
"""
......
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
import sqlite3
from pathlib import Path
import logging
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/event_daily", tags=["Event Daily"])
BASE_DIR = Path(__file__).resolve().parents[2]
DB_PATH = BASE_DIR / "database" / "autotrain.db"
def get_db() -> sqlite3.Connection:
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
return conn
class EventPromptUpdate(BaseModel):
prompt: str
@router.get("/events")
async def list_events():
conn = get_db()
cur = conn.cursor()
try:
cur.execute("SELECT * FROM event_daily_prompts")
rows = cur.fetchall()
except sqlite3.OperationalError:
rows = []
finally:
conn.close()
events = []
for row in rows:
events.append({
"id": row["event_id"],
"name": row["event_name"],
"type": row["event_type"],
"date": row["event_date"],
"isToday": bool(row["is_today"]),
"prompt": row["prompt"]
})
return events
@router.put("/events/{event_id}")
async def update_event_prompt(event_id: str, data: EventPromptUpdate):
conn = get_db()
cur = conn.cursor()
cur.execute("UPDATE event_daily_prompts SET prompt = ?, updated_at = CURRENT_TIMESTAMP WHERE event_id = ?", (data.prompt, event_id))
if cur.rowcount == 0:
conn.close()
raise HTTPException(status_code=404, detail="Event not found")
conn.commit()
conn.close()
return {"status": "success"}
......@@ -9,18 +9,10 @@ from pydantic import BaseModel
from auth import create_access_token, hash_password, verify_password
from auth.middleware import get_current_user
from auth.models import DASHBOARD_SCHEMA, get_conn, init_dashboard_schema
from auth.models import DASHBOARD_SCHEMA, get_conn
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/auth", tags=["auth"])
# Ensure schema exists
try:
init_dashboard_schema()
except Exception:
pass
class LoginRequest(BaseModel):
username: str
password: str
......
from fastapi import APIRouter
from fastapi import APIRouter
from api.ai_report.report_html_route import router as report_html_router
from api.api_sql.ai_sql_trace_route import router as ai_sql_trace_router
......@@ -9,6 +9,7 @@ from api.api_sql.text_to_sql_route import router as text_to_sql_router
from api.api_sql.user_insight_route import router as user_insight_router
from api.autotrain.auto_pipeline_route import router as pipeline_router
from api.autotrain.autotrain_route import router as autotrain_router
from api.autotrain.event_daily_route import router as event_daily_router
from api.cache.cache_route import router as cache_router
from api.common.auth_route import router as auth_router
......@@ -115,6 +116,7 @@ api_router.include_router(ai_sql_trace_router)
api_router.include_router(user_insight_router)
api_router.include_router(autotrain_router)
api_router.include_router(pipeline_router)
api_router.include_router(event_daily_router)
# Dashboards & Monitoring
api_router.include_router(dashboard_router)
......
"""
Prompt Manager API — CRUD for prompt versions + Langfuse sync.
Supports dev/production environment management with version history.
"""
import logging
import os
from datetime import datetime, timezone
import httpx
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel
from common.prompt_db import (
create_version,
get_active_version,
get_all_modules,
get_version_by_id,
get_version_content,
get_version_history,
import_from_files,
mark_langfuse_synced,
promote_to_production,
)
from config import LANGFUSE_BASE_URL, LANGFUSE_PUBLIC_KEY, LANGFUSE_SECRET_KEY
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/prompt-manager", tags=["Prompt Manager"])
# ─── Paths to prompt module files ───
_AGENT_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", "agent")
_SYSTEM_MODULES_DIR = os.path.join(_AGENT_DIR, "prompt_module")
_TOOL_PROMPTS_DIR = os.path.join(_AGENT_DIR, "tool_prompts")
# ─── Pydantic Models ───────────────────────────────────────
class CreateVersionRequest(BaseModel):
module_name: str
content: str
module_type: str = "system"
environment: str = "dev"
notes: str = ""
class PromoteRequest(BaseModel):
promoted_by: str = "admin"
# ─── Module Listing ────────────────────────────────────────
@router.get("/modules")
async def list_modules():
"""List all prompt modules with dev/production status."""
modules = get_all_modules()
return {"status": "success", "modules": modules, "count": len(modules)}
@router.get("/modules/{module_name}/versions")
async def list_versions(module_name: str, limit: int = 30):
"""Get version history for a specific module."""
versions = get_version_history(module_name, limit=limit)
return {"status": "success", "module_name": module_name, "versions": versions}
@router.get("/modules/{module_name}/active")
async def get_active(module_name: str, environment: str = "production"):
"""Get the current active version for a module in a specific env."""
version = get_active_version(module_name, environment)
if not version:
raise HTTPException(status_code=404, detail=f"No active {environment} version for {module_name}")
return {"status": "success", "version": version}
# ─── Version CRUD ──────────────────────────────────────────
@router.get("/versions/{version_id}")
async def get_version(version_id: int):
"""Get full version details including content."""
version = get_version_by_id(version_id)
if not version:
raise HTTPException(status_code=404, detail=f"Version {version_id} not found")
return {"status": "success", "version": version}
@router.get("/versions/{version_id}/content")
async def get_content(version_id: int):
"""Get just the content of a specific version."""
content = get_version_content(version_id)
if content is None:
raise HTTPException(status_code=404, detail=f"Version {version_id} not found")
return {"status": "success", "content": content}
@router.post("/versions")
async def create_new_version(body: CreateVersionRequest):
"""Create a new version of a prompt module."""
try:
version = create_version(
module_name=body.module_name,
content=body.content,
module_type=body.module_type,
environment=body.environment,
created_by="admin",
notes=body.notes,
)
return {"status": "success", "version": version}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
# ─── Promotion ─────────────────────────────────────────────
@router.post("/versions/{version_id}/promote")
async def promote_version(version_id: int, body: PromoteRequest = PromoteRequest()):
"""Promote a dev version to production."""
try:
promoted = promote_to_production(version_id, promoted_by=body.promoted_by)
return {
"status": "success",
"message": f"Promoted to production as v{promoted['version']}",
"version": promoted,
}
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
# ─── File Import ───────────────────────────────────────────
@router.post("/import-from-files")
async def import_modules_from_files():
"""Import prompt modules from local .txt files into the DB."""
try:
sys_count = import_from_files(_SYSTEM_MODULES_DIR, module_type="system")
tool_count = import_from_files(_TOOL_PROMPTS_DIR, module_type="tool")
return {
"status": "success",
"imported": {"system": sys_count, "tool": tool_count},
"message": f"Imported {sys_count} system + {tool_count} tool modules",
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
# ─── Langfuse Integration ─────────────────────────────────
def _langfuse_headers():
"""Get auth headers for Langfuse REST API."""
import base64
creds = base64.b64encode(f"{LANGFUSE_PUBLIC_KEY}:{LANGFUSE_SECRET_KEY}".encode()).decode()
return {
"Authorization": f"Basic {creds}",
"Content-Type": "application/json",
}
@router.get("/langfuse/prompts")
async def list_langfuse_prompts():
"""List all prompts currently on Langfuse."""
if not LANGFUSE_PUBLIC_KEY or not LANGFUSE_SECRET_KEY:
raise HTTPException(status_code=503, detail="Langfuse keys not configured")
base = (LANGFUSE_BASE_URL or "https://us.cloud.langfuse.com").rstrip("/")
url = f"{base}/api/public/v2/prompts"
try:
async with httpx.AsyncClient(timeout=15) as client:
resp = await client.get(url, headers=_langfuse_headers())
resp.raise_for_status()
data = resp.json()
return {"status": "success", "prompts": data.get("data", data)}
except httpx.HTTPStatusError as e:
logger.error(f"Langfuse API error: {e.response.status_code} {e.response.text}")
raise HTTPException(status_code=e.response.status_code, detail=f"Langfuse API: {e.response.text[:300]}")
except Exception as e:
logger.error(f"Langfuse connection error: {e}")
raise HTTPException(status_code=503, detail=f"Cannot connect to Langfuse: {str(e)}")
@router.get("/langfuse/prompts/{prompt_name}")
async def get_langfuse_prompt(prompt_name: str, label: str = "production"):
"""Get a specific prompt from Langfuse by name and label."""
if not LANGFUSE_PUBLIC_KEY or not LANGFUSE_SECRET_KEY:
raise HTTPException(status_code=503, detail="Langfuse keys not configured")
base = (LANGFUSE_BASE_URL or "https://us.cloud.langfuse.com").rstrip("/")
url = f"{base}/api/public/v2/prompts/{prompt_name}"
params = {"label": label}
try:
async with httpx.AsyncClient(timeout=15) as client:
resp = await client.get(url, headers=_langfuse_headers(), params=params)
if resp.status_code == 404:
return {"status": "not_found", "prompt": None}
resp.raise_for_status()
data = resp.json()
return {"status": "success", "prompt": data}
except httpx.HTTPStatusError as e:
raise HTTPException(status_code=e.response.status_code, detail=f"Langfuse: {e.response.text[:300]}")
except Exception as e:
raise HTTPException(status_code=503, detail=str(e))
@router.post("/langfuse/sync/{version_id}")
async def sync_to_langfuse(version_id: int, label: str = "production"):
"""Push a local version to Langfuse as a new prompt version."""
version = get_version_by_id(version_id)
if not version:
raise HTTPException(status_code=404, detail=f"Version {version_id} not found")
if not LANGFUSE_PUBLIC_KEY or not LANGFUSE_SECRET_KEY:
raise HTTPException(status_code=503, detail="Langfuse keys not configured")
base = (LANGFUSE_BASE_URL or "https://us.cloud.langfuse.com").rstrip("/")
url = f"{base}/api/public/v2/prompts"
payload = {
"name": version["module_name"],
"prompt": version["content"],
"type": "text",
"labels": [label],
"config": {
"source": "canifa-prompt-manager",
"local_version": version["version"],
"local_id": version["id"],
"synced_at": datetime.now(timezone.utc).isoformat(),
}
}
try:
async with httpx.AsyncClient(timeout=30) as client:
resp = await client.post(url, headers=_langfuse_headers(), json=payload)
resp.raise_for_status()
data = resp.json()
lf_version = data.get("version", None)
mark_langfuse_synced(version_id, lf_version)
return {
"status": "success",
"message": f"Synced {version['module_name']} to Langfuse [{label}]",
"langfuse_version": lf_version,
"langfuse_response": data,
}
except httpx.HTTPStatusError as e:
logger.error(f"Langfuse sync error: {e.response.status_code} {e.response.text}")
raise HTTPException(status_code=e.response.status_code, detail=f"Langfuse: {e.response.text[:500]}")
except Exception as e:
raise HTTPException(status_code=503, detail=str(e))
@router.get("/langfuse/status")
async def langfuse_status():
"""Check Langfuse connection and show which prompts are live."""
if not LANGFUSE_PUBLIC_KEY or not LANGFUSE_SECRET_KEY:
return {"status": "error", "connected": False, "message": "Keys not configured"}
base = (LANGFUSE_BASE_URL or "https://us.cloud.langfuse.com").rstrip("/")
try:
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.get(
f"{base}/api/public/v2/prompts",
headers=_langfuse_headers(),
)
resp.raise_for_status()
data = resp.json()
prompts = data.get("data", data) if isinstance(data, dict) else data
prompt_list = []
if isinstance(prompts, list):
for p in prompts:
prompt_list.append({
"name": p.get("name"),
"labels": p.get("labels", []),
"version": p.get("version"),
"updated_at": p.get("updatedAt") or p.get("updated_at"),
})
return {
"status": "success",
"connected": True,
"base_url": base,
"prompt_count": len(prompt_list),
"prompts": prompt_list,
}
except Exception as e:
return {
"status": "error",
"connected": False,
"message": str(e),
"base_url": base,
}
......@@ -8,9 +8,18 @@ from common.pool_wrapper import get_pooled_connection_compat
from config import CHECKPOINT_POSTGRES_URL
DASHBOARD_SCHEMA = "dashboard_canifa"
_schema_initialized = False
def get_conn():
"""Get a pooled connection (returns to pool on .close())."""
global _schema_initialized
if not _schema_initialized:
try:
init_dashboard_schema()
_schema_initialized = True
except Exception as e:
print(f"[WARN] Lazy Dashboard DB init skipped: {e}")
return get_pooled_connection_compat()
def _get_raw_conn():
......@@ -131,8 +140,4 @@ def init_dashboard_schema():
print(f"[OK] Schema '{DASHBOARD_SCHEMA}' and all tables initialized (5 tables).")
# Auto-init on import
try:
init_dashboard_schema()
except Exception as e:
print(f"[WARN] Dashboard DB init skipped (PG not available): {e}")
# Auto-init on import removed in favor of lazy loading inside get_conn()
"""
Prompt Version Database — SQLite storage for prompt environment management.
Tracks dev/production versions of each prompt module with full history.
"""
import logging
import os
import sqlite3
from contextlib import contextmanager
from datetime import datetime, timezone
logger = logging.getLogger(__name__)
_DB_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "data", "prompt_versions.db")
# Ensure data directory exists
os.makedirs(os.path.dirname(_DB_PATH), exist_ok=True)
@contextmanager
def _get_db():
"""Context manager for SQLite connections."""
conn = sqlite3.connect(_DB_PATH)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA foreign_keys=ON")
try:
yield conn
conn.commit()
except Exception:
conn.rollback()
raise
finally:
conn.close()
def init_db():
"""Initialize the prompt_versions table."""
with _get_db() as conn:
conn.execute("""
CREATE TABLE IF NOT EXISTS prompt_versions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
module_name TEXT NOT NULL,
module_type TEXT NOT NULL DEFAULT 'system',
content TEXT NOT NULL,
version INTEGER NOT NULL DEFAULT 1,
environment TEXT NOT NULL DEFAULT 'dev',
is_active INTEGER NOT NULL DEFAULT 1,
created_by TEXT NOT NULL DEFAULT 'system',
created_at TEXT NOT NULL,
notes TEXT DEFAULT '',
langfuse_synced INTEGER NOT NULL DEFAULT 0,
langfuse_version INTEGER DEFAULT NULL,
content_hash TEXT DEFAULT ''
)
""")
conn.execute("""
CREATE INDEX IF NOT EXISTS idx_prompt_module_env
ON prompt_versions(module_name, environment, is_active)
""")
conn.execute("""
CREATE INDEX IF NOT EXISTS idx_prompt_created
ON prompt_versions(created_at DESC)
""")
logger.info(f"✅ Prompt version DB initialized at {_DB_PATH}")
def _hash_content(content: str) -> str:
"""Create a short hash of content for change detection."""
import hashlib
return hashlib.sha256(content.encode()).hexdigest()[:12]
# ─── READ Operations ────────────────────────────────────────
def get_all_modules() -> list[dict]:
"""Get summary of all modules with latest dev + production versions."""
with _get_db() as conn:
rows = conn.execute("""
SELECT DISTINCT module_name, module_type FROM prompt_versions
ORDER BY module_name
""").fetchall()
modules = []
for row in rows:
name = row["module_name"]
mtype = row["module_type"]
dev = get_active_version(name, "dev")
prod = get_active_version(name, "production")
modules.append({
"module_name": name,
"module_type": mtype,
"dev": _version_summary(dev),
"production": _version_summary(prod),
"in_sync": _content_match(dev, prod),
})
return modules
def _version_summary(v: dict | None) -> dict | None:
if not v:
return None
return {
"id": v["id"],
"version": v["version"],
"created_at": v["created_at"],
"created_by": v["created_by"],
"content_preview": v["content"][:200] + "..." if len(v["content"]) > 200 else v["content"],
"content_length": len(v["content"]),
"langfuse_synced": bool(v["langfuse_synced"]),
"notes": v.get("notes", ""),
}
def _content_match(a: dict | None, b: dict | None) -> bool:
if not a or not b:
return False
return a.get("content_hash") == b.get("content_hash")
def get_active_version(module_name: str, environment: str) -> dict | None:
"""Get the current active version for a module in a specific environment."""
with _get_db() as conn:
row = conn.execute("""
SELECT * FROM prompt_versions
WHERE module_name = ? AND environment = ? AND is_active = 1
ORDER BY version DESC LIMIT 1
""", (module_name, environment)).fetchone()
return dict(row) if row else None
def get_version_by_id(version_id: int) -> dict | None:
"""Get a specific version by ID."""
with _get_db() as conn:
row = conn.execute(
"SELECT * FROM prompt_versions WHERE id = ?", (version_id,)
).fetchone()
return dict(row) if row else None
def get_version_history(module_name: str, limit: int = 20) -> list[dict]:
"""Get version history for a module across all environments."""
with _get_db() as conn:
rows = conn.execute("""
SELECT id, module_name, module_type, version, environment,
is_active, created_by, created_at, notes,
langfuse_synced, content_hash,
LENGTH(content) as content_length
FROM prompt_versions
WHERE module_name = ?
ORDER BY created_at DESC
LIMIT ?
""", (module_name, limit)).fetchall()
return [dict(r) for r in rows]
def get_version_content(version_id: int) -> str | None:
"""Get full content of a specific version."""
with _get_db() as conn:
row = conn.execute(
"SELECT content FROM prompt_versions WHERE id = ?", (version_id,)
).fetchone()
return row["content"] if row else None
# ─── WRITE Operations ────────────────────────────────────────
def create_version(
module_name: str,
content: str,
module_type: str = "system",
environment: str = "dev",
created_by: str = "admin",
notes: str = "",
) -> dict:
"""Create a new version of a prompt module."""
content_hash = _hash_content(content)
now = datetime.now(timezone.utc).isoformat()
with _get_db() as conn:
# Get next version number
row = conn.execute("""
SELECT COALESCE(MAX(version), 0) + 1 as next_ver
FROM prompt_versions WHERE module_name = ?
""", (module_name,)).fetchone()
next_version = row["next_ver"]
# Deactivate previous active version in same environment
conn.execute("""
UPDATE prompt_versions SET is_active = 0
WHERE module_name = ? AND environment = ? AND is_active = 1
""", (module_name, environment))
# Insert new version
cursor = conn.execute("""
INSERT INTO prompt_versions
(module_name, module_type, content, version, environment,
is_active, created_by, created_at, notes, content_hash)
VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?, ?)
""", (module_name, module_type, content, next_version,
environment, created_by, now, notes, content_hash))
new_id = cursor.lastrowid
logger.info(f"📝 Created prompt version #{new_id}: {module_name} v{next_version} [{environment}]")
return get_version_by_id(new_id)
def promote_to_production(version_id: int, promoted_by: str = "admin") -> dict:
"""Promote a dev version to production.
Creates a new production record with the same content.
"""
source = get_version_by_id(version_id)
if not source:
raise ValueError(f"Version {version_id} not found")
result = create_version(
module_name=source["module_name"],
content=source["content"],
module_type=source["module_type"],
environment="production",
created_by=promoted_by,
notes=f"Promoted from dev v{source['version']} (#{version_id})",
)
logger.info(f"🚀 Promoted {source['module_name']} dev v{source['version']} → production v{result['version']}")
return result
def mark_langfuse_synced(version_id: int, langfuse_version: int | None = None):
"""Mark a version as synced to Langfuse."""
with _get_db() as conn:
conn.execute("""
UPDATE prompt_versions
SET langfuse_synced = 1, langfuse_version = ?
WHERE id = ?
""", (langfuse_version, version_id))
logger.info(f"☁️ Marked version #{version_id} as Langfuse-synced (lf_version={langfuse_version})")
# ─── IMPORT Operations ────────────────────────────────────────
def import_from_files(base_dir: str, module_type: str = "system") -> int:
"""Import prompt modules from local .txt files into the DB as dev versions.
Only imports if content has changed or module doesn't exist yet.
Returns count of modules imported.
"""
imported = 0
if not os.path.isdir(base_dir):
logger.warning(f"⚠️ Directory not found: {base_dir}")
return 0
for filename in sorted(os.listdir(base_dir)):
if not filename.endswith(".txt"):
continue
filepath = os.path.join(base_dir, filename)
module_name = filename.replace(".txt", "")
with open(filepath, encoding="utf-8") as f:
content = f.read()
content_hash = _hash_content(content)
# Check if identical content already exists
existing = get_active_version(module_name, "dev")
if existing and existing.get("content_hash") == content_hash:
continue # No change
create_version(
module_name=module_name,
content=content,
module_type=module_type,
environment="dev",
created_by="file_import",
notes=f"Imported from {filename}",
)
imported += 1
logger.info(f"📦 Imported {imported} prompt modules from {base_dir}")
return imported
# Initialize on import
init_db()
import sqlite3
import json
db_path = 'canifa_ai_dump.sqlite'
try:
conn = sqlite3.connect(db_path)
c = conn.cursor()
# Find rows where description_data_full is short or only contains mau_sac
c.execute("SELECT magento_ref_code, description_data_full FROM sr__test_db__magento_product_dimension_with_text_embedding WHERE description_data_full LIKE '%mau_sac%' AND length(description_data_full) < 100 LIMIT 10")
rows_mau_sac = c.fetchall()
print("--- Rows with short JSON / only mau_sac ---")
for r in rows_mau_sac:
print(r)
c.execute("SELECT magento_ref_code, description_data_full FROM sr__test_db__magento_product_dimension_with_text_embedding WHERE description_data_full NOT LIKE '{%' LIMIT 5")
rows_invalid = c.fetchall()
print("\n--- Rows not starting with { ---")
for r in rows_invalid:
print(r)
except Exception as e:
print(f"Error in {db_path}: {e}")
......@@ -34,69 +34,10 @@ if not os.path.exists(STATIC_DIR):
print(f"[OK] Static dir resolved: {STATIC_DIR}")
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Initialize Redis cache, EventBus, and start background workers."""
await redis_cache.initialize()
logger.info("✅ Redis cache initialized")
# Start FastStream EventBus
await event_bus.start()
# Cleaned up Note APIs
# Start report worker if Redis is available
if REDIS_CACHE_TURN_ON and redis_cache.get_client():
from agent.report_agent.report_queue import report_worker_loop
asyncio.create_task(report_worker_loop())
logger.info("✅ Report Queue Worker started (background task)")
# Start Autonomous Curator Loop
try:
from agent.report_agent.autonomous_loop import ReportCurator
curator = ReportCurator()
asyncio.create_task(curator.start_loop())
logger.info("✅ Report Curator Loop started (autonomous insights generator)")
except ImportError as e:
logger.warning(f"⚠️ Report Curator not available: {e}")
# Start multi-job cron scheduler
try:
from agent.report_agent.scheduler import start_scheduler
start_scheduler()
logger.info("✅ Report Scheduler registered multi-job cron tasks")
except ImportError as e:
logger.warning(f"⚠️ Report Scheduler not available: {e}")
# ─── Start publish engine background loop ───────────────────────────────────
from common.social.scheduler import start_publish_engine
start_publish_engine(app) # Auto-publish scheduled content every 30s
yield
"""Cleanup resources before exit to prevent connection leaks during hot-reload."""
# Stop FastStream EventBus
await event_bus.stop()
from common.db_pool import db_pool
if db_pool:
db_pool.close_all()
logger.info("🛑 Postgres Connection Pool nicely closed")
# Optional: If you want to also clean up StarRocks you can do it here
try:
from common.starrocks_connection import StarRocksConnection
if StarRocksConnection._shared_conn and StarRocksConnection._shared_conn.open:
StarRocksConnection._shared_conn.close()
logger.info("🛑 StarRocks Connection nicely closed")
except Exception:
pass
app = FastAPI(
title="Contract AI Service",
description="API for Contract AI Service",
version="1.0.0",
lifespan=lifespan,
)
......@@ -172,6 +113,7 @@ if __name__ == "__main__":
print(f"Media Library: http://localhost:{PORT}/static/media-library/index.html")
print(f"Social Inbox: http://localhost:{PORT}/static/social-inbox/index.html")
print(f"Composer: http://localhost:{PORT}/static/content-composer/index.html")
print(f"Event Daily: http://localhost:{PORT}/static/event_daily/index.html")
print("=" * 60)
ENABLE_RELOAD = False
......
body { background: var(--bg); margin: 0; color: var(--t); font-family: 'Inter', sans-serif; display: flex; height: 100vh; overflow: hidden; }
.fm-layout { display: flex; width: 100%; height: 100%; }
.fm-left { width: 340px; border-right: 1px solid var(--border); display: flex; flex-direction: column; background: var(--card); }
.fm-right { flex: 1; display: flex; flex-direction: column; overflow-y: auto; background: var(--bg); }
.fm-left-header { padding: 16px; border-bottom: 1px solid var(--border); font-weight: 600; font-size: 15px; display: flex; justify-content: space-between; align-items: center;}
.fb-list { flex: 1; overflow-y: auto; padding: 12px; display: flex; flex-direction: column; gap: 8px; }
/* Custom scrollbar for list */
.fb-list::-webkit-scrollbar { width: 6px; }
.fb-list::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
.fb-item { padding: 16px; border-radius: 8px; border: 1px solid var(--border); background: var(--card); cursor: pointer; transition: all 0.2s; position: relative;}
.fb-item:hover { border-color: var(--gold, #B45309); transform: translateY(-1px); box-shadow: 0 4px 12px rgba(0,0,0,0.1); }
.fb-item.active { border-color: var(--primary); background: rgba(var(--primary-rgb), 0.05); }
.fb-tag { position: absolute; top: 12px; right: 12px; font-size: 10px; font-weight: 700; padding: 2px 6px; border-radius: 4px; letter-spacing: 0.5px;}
.fb-tag.pending { background: rgba(245,158,11,0.15); border: 1px solid rgba(245,158,11,0.3); color: #fbbf24; }
.fb-tag.trained { background: rgba(16,185,129,0.15); border: 1px solid rgba(16,185,129,0.3); color: #34d399; }
.fb-title { font-weight: 600; font-size: 13px; margin-bottom: 6px; padding-right: 50px;}
.fb-desc { font-size: 12px; color: var(--m); line-height: 1.5; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.panel-header { padding: 16px 24px; border-bottom: 1px solid var(--border); display: flex; justify-content: space-between; align-items: center; background: var(--card); }
.panel-body { padding: 32px 40px; max-width: 1200px; margin: 0 auto; width: 100%; }
.diff-wrapper { display: flex; gap: 20px; flex-wrap: wrap; margin-top: 20px; }
.diff-box { flex: 1; min-width: 300px; background: #121212; border: 1px solid var(--border); border-radius: 8px; overflow: hidden; display: flex; flex-direction: column; box-shadow: 0 4px 20px rgba(0,0,0,0.1);}
.diff-header { padding: 10px 16px; background: #1e1e1e; font-family: 'Consolas', monospace; font-size: 12px; color: #a3a3a3; display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid var(--border); }
.diff-content { padding: 20px; font-family: 'Consolas', 'Courier New', monospace; font-size: 13px; color: #e4e4e7; line-height: 1.7; white-space: pre-wrap; flex:1; }
ins { background: rgba(16,185,129,0.2); color: #6ee7b7; text-decoration: none; padding: 2px 4px; border-radius: 2px; border-bottom: 1px solid #6ee7b7; }
del { background: rgba(239,68,68,0.2); text-decoration: line-through; color: #fca5a5; padding: 2px 4px; border-radius: 2px; }
.bubble { background: var(--card); border-left: 4px solid var(--primary); padding: 16px 24px; border-radius: 0 8px 8px 0; font-style: italic; font-size: 14px; color: var(--t); margin-bottom: 24px; border: 1px solid var(--border); border-left: 4px solid var(--danger, #dc2626); box-shadow: 0 2px 8px rgba(0,0,0,0.05);}
.spinner { animation: spin 1.5s linear infinite; }
@keyframes spin { 100% { transform: rotate(360deg); } }
document.addEventListener("DOMContentLoaded", async () => {
if (window.lucide) { lucide.createIcons(); }
await fetchFeedbacks();
});
const mockHTML = `
<div class="fb-item active" style="opacity: 0.5" onclick="selectMockFeedback(0)">
<div class="fb-tag pending">PENDING</div>
<div class="fb-title">#FB-8942 - Sai rule thời tiết</div>
<div style="font-size:11px; color:var(--primary); margin-bottom:8px; display:flex; align-items:center; gap:4px;"><i data-lucide="user" style="width:12px;height:12px;"></i> Khách ID: 98453</div>
<div class="fb-desc">Khách report là nóng, tư vấn nhét áo hoodie vào list đồ hè không chịu nổi.</div>
</div>
<div class="fb-item" style="opacity: 0.5" onclick="selectMockFeedback(1)">
<div class="fb-tag pending">PENDING</div>
<div class="fb-title">#FB-8943 - Lỗi form dáng lùn</div>
<div style="font-size:11px; color:var(--primary); margin-bottom:8px; display:flex; align-items:center; gap:4px;"><i data-lucide="smartphone" style="width:12px;height:12px;"></i> App: iOS M-Canifa</div>
<div class="fb-desc">Gợi ý đầm maxi chạm đất cho khách có chiều cao 1m50. Khách không hài lòng.</div>
</div>
<div class="fb-item" style="opacity: 0.3" onclick="selectMockFeedback(2)">
<div class="fb-tag trained">TRAINED</div>
<div class="fb-title">#FB-8941 - Bỏ icon thừa</div>
<div style="font-size:11px; color:var(--muted-fg); margin-bottom:8px; display:flex; align-items:center; gap:4px;"><i data-lucide="bot" style="width:12px;height:12px;"></i> Internal QA</div>
<div class="fb-desc">AI dùng quá nhiều emoji chắp tay 🙏 trong luồng tư vấn cơ bản.</div>
</div>
`;
const mockDataList = [
{
trace_id: "FB-8942",
created_at: new Date().toISOString(),
comment: "Trời đang nóng 35 độ, AI toàn tư vấn áo khoác nỉ mùa đông lố lăng, dở hơi à sửa ngay đi bot ngốc!"
},
{
trace_id: "FB-8943",
created_at: new Date().toISOString(),
comment: "Tôi cao có 1m50 mà AI gợi ý đầm maxi dài chấm gót, mặc sao được? Quá sai lầm."
},
{
trace_id: "FB-8941",
created_at: new Date().toISOString(),
comment: "Dùng quá nhiều emoji chắp tay 🙏 trong các câu trả lời cơ bản, trông không chuyên nghiệp."
}
];
let currentFeedbacks = [];
let isMock = false;
async function fetchFeedbacks() {
try {
const res = await fetch('/api/feedback/list');
const data = await res.json();
const listEl = document.getElementById('fb-list');
// Filter feedbacks that have rating=0 (dislike) or have comments, to be used for Auto-Train
const trainableFeedbacks = (data.feedbacks || []).filter(f => f.rating === 0 || f.comment);
if (data.status === 'success' && trainableFeedbacks.length > 0) {
isMock = false;
currentFeedbacks = trainableFeedbacks;
renderFeedbacks(trainableFeedbacks);
document.getElementById('pending-count').textContent = `${trainableFeedbacks.filter(f => !f.processed).length} pending`;
} else {
// Use mock data
isMock = true;
listEl.innerHTML = mockHTML;
if (window.lucide) { lucide.createIcons(); }
}
} catch (e) {
console.error("Error fetching feedbacks:", e);
isMock = true;
const listEl = document.getElementById('fb-list');
listEl.innerHTML = mockHTML;
if (window.lucide) { lucide.createIcons(); }
}
}
function renderFeedbacks(feedbacks) {
const listEl = document.getElementById('fb-list');
listEl.innerHTML = '';
feedbacks.forEach((fb, index) => {
const item = document.createElement('div');
item.className = 'fb-item';
item.onclick = () => selectFeedback(index);
const isProcessed = fb.processed;
const tagClass = isProcessed ? 'trained' : 'pending';
const tagText = isProcessed ? 'TRAINED' : 'PENDING';
const dateStr = new Date(fb.created_at).toLocaleString('vi-VN');
item.innerHTML = `
<div class="fb-tag ${tagClass}">${tagText}</div>
<div class="fb-title">#${fb.trace_id.substring(0,8)} - ${fb.ai_category || 'Phản hồi'}</div>
<div style="font-size:11px; color:var(--primary); margin-bottom:8px; display:flex; align-items:center; gap:4px;">
<i data-lucide="clock" style="width:12px;height:12px;"></i> ${dateStr}
</div>
<div class="fb-desc">${fb.comment || 'Không có bình luận chi tiết.'}</div>
`;
listEl.appendChild(item);
});
if (window.lucide) { lucide.createIcons(); }
}
function selectMockFeedback(index) {
document.querySelectorAll('.fb-item').forEach(el => el.classList.remove('active'));
document.querySelectorAll('.fb-item')[index].classList.add('active');
const fb = mockDataList[index];
document.getElementById('emptyArea').style.display = 'none';
document.getElementById('contentArea').style.display = 'flex';
document.getElementById('detail-trace-id').textContent = fb.trace_id;
document.getElementById('detail-time').textContent = `· Lúc ${new Date(fb.created_at).toLocaleString('vi-VN')}`;
document.getElementById('detail-comment').textContent = `"${fb.comment}"`;
// Clear diff area and prepare for training
document.getElementById('diff-content-wrapper').innerHTML = `
<div style="text-align: center; width: 100%; padding: 40px; color: var(--m); font-style: italic;">
Đang chờ lệnh phân tích... Bấm "Re-Refine Code" để AI xử lý.
</div>
`;
}
function selectFeedback(index) {
if (isMock) {
selectMockFeedback(index);
return;
}
document.querySelectorAll('.fb-item').forEach(el => el.classList.remove('active'));
document.querySelectorAll('.fb-item')[index].classList.add('active');
const fb = currentFeedbacks[index];
if (!fb) return;
document.getElementById('emptyArea').style.display = 'none';
document.getElementById('contentArea').style.display = 'flex';
document.getElementById('detail-trace-id').textContent = fb.trace_id;
document.getElementById('detail-time').textContent = `· Lúc ${new Date(fb.created_at).toLocaleString('vi-VN')}`;
document.getElementById('detail-comment').textContent = `"${fb.comment || 'Không có bình luận chi tiết.'}"`;
// Clear diff area and prepare for training
document.getElementById('diff-content-wrapper').innerHTML = `
<div style="text-align: center; width: 100%; padding: 40px; color: var(--m); font-style: italic;">
Đang chờ lệnh phân tích... Bấm "Re-Refine Code" để AI xử lý.
</div>
`;
}
let currentRuleId = null;
async function redoTrain() {
const listEl = document.getElementById('fb-list');
const activeItem = listEl.querySelector('.fb-item.active');
if (!activeItem) return;
// Get index
const index = Array.from(listEl.children).indexOf(activeItem);
const fb = isMock ? mockDataList[index] : currentFeedbacks[index];
if (!fb) return;
document.getElementById('loading').style.display = 'flex';
document.getElementById('diff-content-wrapper').innerHTML = '';
try {
// Step 1: Attempt to generate rule
let generateRes = await fetch(`/autotrain/generate/${fb.trace_id}`, { method: 'POST' });
if (generateRes.status === 404) {
// Need to submit the feedback to AutoTrain DB first
const payload = {
feedback_id: fb.trace_id,
query: fb.query || "No query provided",
ai_response: "No response available",
feedback_text: fb.comment || "N/A",
feedback_type: "bug"
};
await fetch('/autotrain/feedback', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
// Retry generate
generateRes = await fetch(`/autotrain/generate/${fb.trace_id}`, { method: 'POST' });
}
if (!generateRes.ok) {
throw new Error(await generateRes.text());
}
const generateData = await generateRes.json();
currentRuleId = generateData.rule_id;
// Step 2: Fetch the rule details
const rulesRes = await fetch('/autotrain/rules');
const rulesData = await rulesRes.json();
const rule = rulesData.find(r => r.rule_id === currentRuleId);
if (rule) {
let oldRuleHtml = '';
if (rule.old_rule) {
oldRuleHtml = `
<div class="diff-box">
<div class="diff-header">
<span style="color:var(--danger, #f87171)">🔴 Current Rule</span>
</div>
<div class="diff-content" style="white-space: pre-wrap;"><del>${rule.old_rule}</del></div>
</div>
`;
}
document.getElementById('diff-content-wrapper').innerHTML = `
${oldRuleHtml}
<div class="diff-box">
<div class="diff-header">
<span style="color:#10b981; display:flex; align-items:center; gap:6px;">
<i data-lucide="sparkles" class="icon-sm"></i> 🟢 AI Generated Fix (Confidence: ${rule.confidence.toFixed(2)})
</span>
</div>
<div class="diff-content" style="white-space: pre-wrap;"><ins>${rule.new_rule}</ins></div>
</div>
<div style="margin-top: 15px; color: var(--muted-fg); font-size: 14px; padding: 10px; background: rgba(255,255,255,0.05); border-radius: 6px;">
<strong>Lý do:</strong> ${rule.reasoning}
</div>
`;
if (window.lucide) { lucide.createIcons(); }
}
} catch (err) {
console.error(err);
document.getElementById('diff-content-wrapper').innerHTML = `
<div style="color: var(--danger); padding: 20px; text-align: center;">
Lỗi khi phân tích: ${err.message || err}
</div>
`;
} finally {
document.getElementById('loading').style.display = 'none';
}
}
async function approve() {
if (!currentRuleId) {
alert("Vui lòng chạy 'Re-Refine Code' trước để sinh rule.");
return;
}
const listEl = document.getElementById('fb-list');
const activeItem = listEl.querySelector('.fb-item.active');
if (!activeItem) return;
const index = Array.from(listEl.children).indexOf(activeItem);
const fb = isMock ? mockDataList[index] : currentFeedbacks[index];
if (!confirm("Bạn có chắc chắn muốn Approve & Deploy rule này lên Production?")) {
return;
}
try {
// Approve Rule
const approveRes = await fetch(`/autotrain/rules/${currentRuleId}/approve`, { method: 'POST' });
if (!approveRes.ok) throw new Error(await approveRes.text());
// Deploy Rule
const deployRes = await fetch(`/autotrain/rules/${currentRuleId}/deploy`, { method: 'POST' });
if (!deployRes.ok) throw new Error(await deployRes.text());
// Mark as processed in local feedback tracker
if (fb && fb.trace_id) {
await fetch('/api/feedback/process', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ trace_id: fb.trace_id })
});
}
alert("Thành công! Đoạn Prompt Rules mới đã được lưu vào hệ thống, các luồng chat mới sẽ sử dụng phiên bản rule này.");
// Refresh the list to update "Trained" status
await fetchFeedbacks();
} catch (err) {
console.error(err);
alert("Lỗi khi Deploy: " + (err.message || err));
}
}
<!DOCTYPE html>
<html lang="vi">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Auto-Train Admin</title>
<link rel="stylesheet" href="/static/common/theme.css">
<link rel="stylesheet" href="/static/common/components.css">
<link rel="stylesheet" href="/static/autotrain/autotrain.css">
<script src="/static/common/frame-detect.js"></script>
<script src="https://unpkg.com/lucide@latest"></script>
</head>
<body>
<div class="fm-layout">
<!-- LEFT LIST -->
<div class="fm-left">
<div class="fm-left-header">
<div style="display:flex; align-items:center; gap:8px;"><i data-lucide="inbox" class="icon-sm"></i> Auto-Train Queue</div>
<span class="badge badge-warning" style="font-size:10px;" id="pending-count">0 pending</span>
</div>
<div style="padding:10px 12px;border-bottom:1px solid var(--border);">
<div class="search-bar" style="position: relative; display:flex; align-items:center;">
<i data-lucide="search" class="icon-sm" style="position: absolute; left: 10px; color: var(--m);"></i>
<input type="text" placeholder="Tìm ID hoặc feedback..." style="width:100%; padding: 8px 12px 8px 32px; font-size: 12px; border: 1px solid var(--border); background: var(--bg); color: var(--t); border-radius: 6px; outline:none;">
</div>
</div>
<div class="fb-list" id="fb-list">
<!-- Will be populated by JS -->
</div>
</div>
<!-- RIGHT DETAIL -->
<div class="fm-right" id="contentArea" style="display:none;">
<div class="panel-header">
<div style="font-size:15px;font-weight:600;display:flex;align-items:center;gap:8px;">
<i data-lucide="code" class="icon-md" style="color:var(--primary);"></i>
Target Module: <code style="background:var(--bg);padding:4px 8px;border-radius:4px;color:var(--primary);font-size:13px;border:1px solid var(--border);">fashion_rules.json</code>
</div>
<div style="display:flex;gap:8px;">
<button class="btn btn-outline btn-sm" onclick="redoTrain()">
<i data-lucide="bot" class="icon-sm"></i> Re-Refine Code
</button>
<button class="btn btn-primary btn-sm" style="background-color: var(--danger, #dc2626); color: white; border-color: var(--danger, #dc2626);" onclick="approve()">
<i data-lucide="check-circle-2" class="icon-sm"></i> Approve & Deploy to Prod
</button>
</div>
</div>
<div class="panel-body">
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:16px;">
<div style="display:flex; align-items:center; gap:8px;">
<i data-lucide="message-square-warning" class="icon-sm" style="color:var(--danger, #dc2626)"></i>
<h3 style="font-size:12px; color:var(--m); margin:0; text-transform:uppercase; letter-spacing:1px; font-weight:600;">Context: Trích xuất User Feedback</h3>
</div>
<button class="btn btn-outline" style="padding:4px 10px; font-size:11px;"><i data-lucide="history" class="icon-sm"></i> Xem full Chat History</button>
</div>
<div style="display:flex; gap:16px; margin-bottom: 32px;">
<div style="width:44px; height:44px; border-radius:50%; background: rgba(99, 102, 241, 0.15); border: 1px solid rgba(99, 102, 241, 0.4); display:flex; align-items:center; justify-content:center; color: var(--primary);"><i data-lucide="user"></i></div>
<div style="flex:1;">
<div style="display:flex; gap:12px; align-items:baseline; margin-bottom:8px;">
<span style="font-weight:600; font-size:14px;">Trace ID: <span style="color:var(--primary)" id="detail-trace-id">...</span></span>
<span style="font-size:12px; color:var(--m)" id="detail-time">· Lúc ...</span>
</div>
<div class="bubble" id="detail-comment" style="margin-bottom:0; padding:16px; border-radius: 0 12px 12px 12px; box-shadow:none; background: rgba(255,255,255,0.03); border:1px solid var(--border); font-style:normal; color:#e2e8f0; font-size:13px; line-height: 1.6;">
...
</div>
</div>
</div>
<div style="display:flex; align-items:center; gap:8px; margin-bottom:12px; margin-top:32px;">
<i data-lucide="file-diff" class="icon-sm" style="color:var(--primary)"></i>
<h3 style="font-size:12px; color:var(--m); margin:0; text-transform:uppercase; letter-spacing:1px; font-weight:600;">AI Prompt Optimization Proposal</h3>
</div>
<div class="diff-wrapper" id="diff-content-wrapper">
<!-- Will be populated when generate is clicked -->
<div style="text-align: center; width: 100%; padding: 40px; color: var(--m); font-style: italic;">
Đang chờ lệnh phân tích... Bấm "Re-Refine Code" để AI xử lý.
</div>
</div>
</div>
</div>
<div class="fm-right" id="emptyArea" style="display:flex; align-items:center; justify-content:center;">
<div style="text-align: center; color: var(--m);">
<i data-lucide="inbox" style="width:48px; height:48px; margin-bottom:16px; opacity:0.5;"></i>
<p>Chọn một feedback bên trái để xem chi tiết</p>
</div>
</div>
<!-- LOADING OVERLAY -->
<div id="loading" style="display:none; position:absolute; top:0; left:340px; right:0; bottom:0; background:rgba(15,23,42,0.8); backdrop-filter:blur(8px); z-index:100; flex-direction:column; justify-content:center; align-items:center;">
<i data-lucide="cpu" class="icon-xl spinner" style="color:var(--primary); margin-bottom:20px; width:48px; height:48px;"></i>
<h3 style="color:#fff; font-size:18px; margin:0 0 8px 0; font-weight:600;">AI Executor is Reasoning...</h3>
<p style="color:var(--m); font-size:13px; margin:0;">Synthesizing semantic context and updating prompt rules.</p>
</div>
</div>
<script src="/static/autotrain/autotrain.js"></script>
</body>
</html>
<!DOCTYPE html>
<html lang="vi">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Cổng truy cập Claude - Client Portal</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/lucide@latest"></script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<script>
tailwind.config = {
theme: {
extend: {
fontFamily: { sans: ['Inter', 'sans-serif'] },
colors: {
primary: '#0f172a',
accent: '#f97316', // Orange theme typical for API dev tools or Anthropic vibe
success: '#10b981'
}
}
}
}
</script>
<style>
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: #94a3b8; }
</style>
</head>
<body class="bg-slate-50 flex h-screen text-slate-800 antialiased overflow-hidden">
<!-- Sidebar -->
<aside class="w-72 bg-white text-slate-600 flex flex-col h-full border-r border-slate-200 shrink-0 shadow-sm z-10">
<!-- Logo Area -->
<div class="p-6 border-b border-slate-100 flex items-center gap-3">
<div class="w-8 h-8 rounded-lg bg-accent flex items-center justify-center text-white font-bold text-lg shadow-md">
Z
</div>
<div>
<h1 class="text-slate-900 font-bold leading-tight">Cổng truy cập Claude</h1>
<p class="text-xs text-slate-500">Khu vực Client</p>
</div>
</div>
<!-- Navigation -->
<div class="flex-1 overflow-y-auto py-4 px-3 flex flex-col gap-1">
<p class="px-3 text-[10px] font-bold text-slate-400 uppercase tracking-wider mb-2 mt-2">Menu chính</p>
<a href="#" class="flex items-center gap-3 px-3 py-2.5 bg-orange-50 text-accent rounded-xl font-medium border border-orange-100/50 shadow-sm">
<i data-lucide="layout-dashboard" class="w-4 h-4"></i> Tổng quan
</a>
<a href="#" class="flex items-center gap-3 px-3 py-2.5 hover:bg-slate-100 hover:text-slate-900 rounded-xl transition-colors text-sm font-medium text-slate-600">
<i data-lucide="terminal-square" class="w-4 h-4"></i> Claude Code
</a>
<a href="#" class="flex items-center gap-3 px-3 py-2.5 hover:bg-slate-100 hover:text-slate-900 rounded-xl transition-colors text-sm font-medium text-slate-600">
<i data-lucide="monitor" class="w-4 h-4"></i> Claude Desktop
</a>
<a href="#" class="flex items-center gap-3 px-3 py-2.5 hover:bg-slate-100 hover:text-slate-900 rounded-xl transition-colors text-sm font-medium text-slate-600">
<i data-lucide="puzzle" class="w-4 h-4"></i> Plugin
</a>
<a href="#" class="flex items-center gap-3 px-3 py-2.5 hover:bg-slate-100 hover:text-slate-900 rounded-xl transition-colors text-sm font-medium text-slate-600">
<i data-lucide="code-2" class="w-4 h-4"></i> Client API
</a>
<a href="#" class="flex items-center gap-3 px-3 py-2.5 hover:bg-slate-100 hover:text-slate-900 rounded-xl transition-colors text-sm font-medium text-slate-600">
<i data-lucide="cpu" class="w-4 h-4"></i> OpenAI SDK
</a>
<a href="#" class="flex items-center gap-3 px-3 py-2.5 hover:bg-slate-100 hover:text-slate-900 rounded-xl transition-colors text-sm font-medium text-slate-600">
<i data-lucide="wrench" class="w-4 h-4"></i> IDE & Editor
</a>
<a href="#" class="flex items-center gap-3 px-3 py-2.5 hover:bg-slate-100 hover:text-slate-900 rounded-xl transition-colors text-sm font-medium text-slate-600">
<i data-lucide="bot" class="w-4 h-4"></i> Agent
</a>
<p class="px-3 text-[10px] font-bold text-slate-400 uppercase tracking-wider mb-2 mt-4">Công cụ mở rộng</p>
<a href="#" class="flex items-center gap-3 px-3 py-2.5 hover:bg-slate-100 hover:text-slate-900 rounded-xl transition-colors text-sm font-medium text-slate-600">
<i data-lucide="braces" class="w-4 h-4"></i> Opencode
</a>
<a href="#" class="flex items-center gap-3 px-3 py-2.5 hover:bg-slate-100 hover:text-slate-900 rounded-xl transition-colors text-sm font-medium text-slate-600">
<i data-lucide="claw-machine" class="w-4 h-4"></i> OpenClaw
</a>
<a href="#" class="flex items-center gap-3 px-3 py-2.5 hover:bg-slate-100 hover:text-slate-900 rounded-xl transition-colors text-sm font-medium text-slate-600">
<i data-lucide="git-branch-plus" class="w-4 h-4"></i> n8n Workflow
</a>
<a href="#" class="flex items-center gap-3 px-3 py-2.5 hover:bg-slate-100 hover:text-slate-900 rounded-xl transition-colors text-sm font-medium text-slate-600">
<i data-lucide="workflow" class="w-4 h-4"></i> Workflow
</a>
<p class="px-3 text-[10px] font-bold text-slate-400 uppercase tracking-wider mb-2 mt-4">Quản lý & Hỗ trợ</p>
<a href="#" class="flex items-center gap-3 px-3 py-2.5 hover:bg-slate-100 hover:text-slate-900 rounded-xl transition-colors text-sm font-medium text-slate-600">
<i data-lucide="history" class="w-4 h-4"></i> Session
</a>
<a href="#" class="flex items-center gap-3 px-3 py-2.5 hover:bg-slate-100 hover:text-slate-900 rounded-xl transition-colors text-sm font-medium text-slate-600">
<i data-lucide="server" class="w-4 h-4"></i> MCP Servers
</a>
<a href="#" class="flex items-center gap-3 px-3 py-2.5 hover:bg-slate-100 hover:text-slate-900 rounded-xl transition-colors text-sm font-medium text-slate-600">
<i data-lucide="shield-alert" class="w-4 h-4"></i> Khắc phục sự cố
</a>
</div>
<!-- User Profile -->
<div class="p-4 border-t border-slate-200">
<button class="w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-white border border-slate-300 text-slate-700 rounded-xl hover:bg-slate-50 hover:text-red-600 transition-colors text-sm font-medium shadow-sm">
<i data-lucide="log-out" class="w-4 h-4"></i> Đăng xuất session
</button>
</div>
</aside>
<!-- Main Content -->
<main class="flex-1 flex flex-col h-full overflow-hidden relative">
<!-- Background Pattern -->
<div class="absolute inset-0 z-0 opacity-[0.03] pointer-events-none" style="background-image: radial-gradient(#000 1px, transparent 1px); background-size: 24px 24px;"></div>
<!-- Topbar -->
<header class="h-16 bg-white/80 backdrop-blur-md border-b border-slate-200 flex items-center justify-between px-8 shrink-0 z-10 sticky top-0">
<div>
<h2 class="text-xl font-bold text-slate-800 tracking-tight">Tổng quan</h2>
<p class="text-xs text-slate-500 mt-0.5">Chi tiết truy cập cốt lõi, thông tin gói và điểm kết nối.</p>
</div>
<div class="flex items-center gap-4">
<div class="flex bg-slate-100 rounded-lg p-1 border border-slate-200">
<button class="px-3 py-1 text-sm bg-white shadow-sm rounded-md font-semibold text-slate-800">Tiếng Việt</button>
<button class="px-3 py-1 text-sm text-slate-500 hover:text-slate-800 font-medium">English</button>
</div>
<span class="text-sm font-semibold px-3 py-1.5 bg-emerald-50 text-emerald-700 rounded-lg border border-emerald-200 flex items-center gap-2 shadow-sm">
<span class="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></span> Còn lại 11 ngày
</span>
</div>
</header>
<!-- Scrollable Content -->
<div class="flex-1 overflow-y-auto p-8 z-10">
<div class="max-w-6xl mx-auto space-y-6">
<!-- Info Banner -->
<div class="bg-blue-50 border border-blue-200 rounded-xl p-4 flex gap-3 text-blue-800 shadow-sm">
<i data-lucide="info" class="w-5 h-5 text-blue-600 shrink-0 mt-0.5"></i>
<div>
<h4 class="font-semibold text-sm">Hướng dẫn thiết lập & Truy cập</h4>
<p class="text-sm text-blue-700/80 mt-1">Dùng cổng này để cấu hình client, kiểm tra quota và ngày hết hạn. Hỗ trợ cho client profile hiện tại.</p>
</div>
</div>
<!-- Top Stats Row -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Quota Card -->
<div class="bg-white rounded-2xl border border-slate-200 shadow-sm overflow-hidden flex flex-col">
<div class="p-6 border-b border-slate-100 bg-gradient-to-r from-slate-50 to-white">
<div class="flex justify-between items-center mb-1">
<h3 class="font-bold text-lg text-slate-800 flex items-center gap-2">
<i data-lucide="zap" class="w-5 h-5 text-amber-500"></i> Hạn mức Quota
</h3>
<span class="text-[10px] font-bold uppercase tracking-wider text-slate-500 bg-slate-100 px-2 py-1 rounded">Cửa sổ trượt 5 giờ</span>
</div>
</div>
<div class="p-6 flex-1 flex flex-col justify-center">
<div class="flex justify-between items-end mb-2">
<span class="text-sm font-medium text-slate-500">Đã dùng: 0%</span>
<span class="text-sm font-bold text-slate-800">0 / 42.0K tokens</span>
</div>
<div class="w-full bg-slate-100 rounded-full h-3 mb-6 overflow-hidden border border-slate-200">
<div class="bg-emerald-500 h-3 rounded-full" style="width: 0%"></div>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="bg-slate-50 rounded-xl p-4 border border-slate-100">
<p class="text-xs text-slate-400 font-bold uppercase tracking-wider mb-1 flex items-center gap-1">
<i data-lucide="clock" class="w-3.5 h-3.5"></i> Reset tiếp theo
</p>
<p class="font-semibold text-slate-800">16:12, 12/05/2026</p>
</div>
<div class="bg-slate-50 rounded-xl p-4 border border-slate-100">
<p class="text-xs text-slate-400 font-bold uppercase tracking-wider mb-1 flex items-center gap-1">
<i data-lucide="calendar" class="w-3.5 h-3.5"></i> Thời hạn gói Pro
</p>
<p class="font-semibold text-slate-800">22/05/2026</p>
</div>
</div>
</div>
</div>
<!-- Profile & Access Card -->
<div class="bg-white rounded-2xl border border-slate-200 shadow-sm overflow-hidden flex flex-col">
<div class="p-6 border-b border-slate-100 bg-gradient-to-r from-slate-50 to-white flex justify-between items-start">
<div>
<h3 class="font-bold text-lg text-slate-800">Hoang Anh Vu</h3>
<p class="text-sm text-slate-500">Tóm tắt truy cập Client</p>
</div>
<span class="px-2.5 py-1 bg-purple-50 text-purple-700 text-xs font-bold rounded-md uppercase tracking-wide border border-purple-100">Pro Plan</span>
</div>
<div class="p-6 flex-1">
<ul class="space-y-4">
<li class="flex justify-between items-center pb-3 border-b border-slate-50">
<span class="text-slate-500 text-sm font-medium">Chế độ xác thực</span>
<span class="font-semibold text-slate-700 text-xs bg-slate-100 px-2.5 py-1 rounded border border-slate-200">x-api-key trực tiếp</span>
</li>
<li class="flex justify-between items-center py-1">
<span class="text-slate-500 text-sm font-medium">Số thiết bị kết nối</span>
<span class="font-bold text-slate-800 text-sm">10</span>
</li>
<li class="flex justify-between items-center py-1">
<span class="text-slate-500 text-sm font-medium">Tổng request</span>
<span class="font-bold text-slate-800 text-sm">181</span>
</li>
<li class="flex justify-between items-center pt-3 mt-2 border-t border-slate-50">
<span class="text-slate-500 text-sm font-medium">API Key</span>
<div class="flex items-center gap-2">
<code class="bg-slate-50 text-slate-700 px-3 py-1.5 rounded-lg text-sm border border-slate-200 font-mono tracking-tight">...xAZewkhE</code>
<button class="p-1.5 text-slate-400 hover:text-accent hover:bg-orange-50 rounded-md transition-colors group" title="Copy Key">
<i data-lucide="copy" class="w-4 h-4 group-hover:hidden"></i>
<i data-lucide="check" class="w-4 h-4 hidden group-hover:block text-emerald-500"></i>
</button>
</div>
</li>
</ul>
</div>
</div>
</div>
<!-- API Connection Details -->
<div class="bg-white rounded-2xl border border-slate-200 shadow-sm overflow-hidden">
<div class="px-6 py-4 border-b border-slate-100 flex items-center gap-2 bg-slate-50/50">
<i data-lucide="network" class="w-5 h-5 text-slate-400"></i>
<h3 class="font-bold text-slate-800">Chi tiết Endpoint & Kết nối</h3>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 divide-y md:divide-y-0 md:divide-x divide-slate-100">
<!-- Base -->
<div class="p-5 hover:bg-slate-50 transition-colors group cursor-pointer">
<p class="text-[10px] font-bold text-slate-400 uppercase tracking-wider mb-2">API Base</p>
<div class="flex items-center justify-between">
<code class="text-sm text-slate-700 font-mono truncate mr-2">https://claude.zunef.com/v1/ai</code>
<i data-lucide="copy" class="w-4 h-4 text-slate-300 group-hover:text-accent transition-colors"></i>
</div>
</div>
<!-- Messages -->
<div class="p-5 hover:bg-slate-50 transition-colors group cursor-pointer">
<p class="text-[10px] font-bold text-slate-400 uppercase tracking-wider mb-2">Endpoint Messages</p>
<div class="flex items-center justify-between">
<code class="text-sm text-slate-700 font-mono truncate mr-2">.../v1/ai/messages</code>
<i data-lucide="copy" class="w-4 h-4 text-slate-300 group-hover:text-accent transition-colors"></i>
</div>
</div>
<!-- Models -->
<div class="p-5 hover:bg-slate-50 transition-colors group cursor-pointer">
<p class="text-[10px] font-bold text-slate-400 uppercase tracking-wider mb-2">Endpoint Models</p>
<div class="flex items-center justify-between">
<code class="text-sm text-slate-700 font-mono truncate mr-2">.../v1/ai/models</code>
<i data-lucide="copy" class="w-4 h-4 text-slate-300 group-hover:text-accent transition-colors"></i>
</div>
</div>
</div>
</div>
<!-- Available Models Section -->
<div class="bg-white rounded-2xl border border-slate-200 shadow-sm p-6">
<div class="flex justify-between items-start mb-6">
<div>
<h3 class="font-bold text-lg text-slate-800">Model khả dụng</h3>
<p class="text-sm text-slate-500 mt-1">Request phải dùng một trong các model ID bên dưới. Các giá trị ngoài danh sách đều bị chặn.</p>
</div>
<button class="flex items-center gap-2 text-sm bg-white hover:bg-slate-50 text-slate-700 px-4 py-2 rounded-xl font-semibold border border-slate-200 shadow-sm transition-colors cursor-pointer group">
<i data-lucide="copy" class="w-4 h-4 text-slate-400 group-hover:text-accent"></i>
Copy List
</button>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<!-- Sonnet 4.6 -->
<div class="border border-slate-200 rounded-xl p-5 hover:border-accent/50 hover:shadow-md transition-all group bg-white">
<div class="flex justify-between items-start mb-3">
<h4 class="font-bold text-slate-800 text-base">Claude Sonnet 4.6</h4>
<span class="text-[10px] font-bold uppercase tracking-wider bg-blue-50 text-blue-600 px-2 py-1 rounded">Mặc định</span>
</div>
<div class="flex items-center justify-between bg-slate-50 px-3 py-2 rounded-lg border border-slate-100 mb-3 group/code cursor-pointer">
<code class="text-xs font-mono text-slate-700">claude-sonnet-4-6</code>
<i data-lucide="copy" class="w-3.5 h-3.5 text-slate-400 group-hover/code:text-accent"></i>
</div>
<p class="text-sm text-slate-500 leading-relaxed">Lựa chọn cân bằng mặc định cho hầu hết workflow code, chỉnh sửa và trợ lý tổng quát.</p>
</div>
<!-- Opus 4.6 -->
<div class="border border-slate-200 rounded-xl p-5 hover:border-accent/50 hover:shadow-md transition-all group bg-white">
<div class="flex justify-between items-start mb-3">
<h4 class="font-bold text-slate-800 text-base">Claude Opus 4.6</h4>
</div>
<div class="flex items-center justify-between bg-slate-50 px-3 py-2 rounded-lg border border-slate-100 mb-3 group/code cursor-pointer">
<code class="text-xs font-mono text-slate-700">claude-opus-4-6</code>
<i data-lucide="copy" class="w-3.5 h-3.5 text-slate-400 group-hover/code:text-accent"></i>
</div>
<p class="text-sm text-slate-500 leading-relaxed">Suy luận chất lượng cao cho prompt phức tạp hơn, phân tích sâu và sinh nội dung dài.</p>
</div>
<!-- Opus 4.7 -->
<div class="border border-slate-200 rounded-xl p-5 hover:border-accent/50 hover:shadow-md transition-all group bg-white">
<div class="flex justify-between items-start mb-3">
<h4 class="font-bold text-slate-800 text-base">Claude Opus 4.7</h4>
<span class="text-[10px] font-bold uppercase tracking-wider bg-purple-50 text-purple-600 px-2 py-1 rounded border border-purple-100">Mới nhất</span>
</div>
<div class="flex items-center justify-between bg-slate-50 px-3 py-2 rounded-lg border border-slate-100 mb-3 group/code cursor-pointer">
<code class="text-xs font-mono text-slate-700">claude-opus-4-7</code>
<i data-lucide="copy" class="w-3.5 h-3.5 text-slate-400 group-hover/code:text-accent"></i>
</div>
<p class="text-sm text-slate-500 leading-relaxed">Tầng suy luận cao cấp mới nhất được gateway này mở ra cho các yêu cầu khó nhất.</p>
</div>
<!-- Haiku 4.5 -->
<div class="border border-slate-200 rounded-xl p-5 hover:border-accent/50 hover:shadow-md transition-all group bg-white">
<div class="flex justify-between items-start mb-3">
<h4 class="font-bold text-slate-800 text-base">Claude Haiku 4.5</h4>
<span class="text-[10px] font-bold uppercase tracking-wider bg-emerald-50 text-emerald-600 px-2 py-1 rounded">Nhanh</span>
</div>
<div class="flex items-center justify-between bg-slate-50 px-3 py-2 rounded-lg border border-slate-100 mb-3 group/code cursor-pointer">
<code class="text-xs font-mono text-slate-700">claude-haiku-4-5</code>
<i data-lucide="copy" class="w-3.5 h-3.5 text-slate-400 group-hover/code:text-accent"></i>
</div>
<p class="text-sm text-slate-500 leading-relaxed">Lựa chọn nhẹ và nhanh cho tác vụ ngắn, chỉnh sửa nhanh và phản hồi độ trễ thấp.</p>
</div>
</div>
</div>
<!-- Admin & Notes -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div class="lg:col-span-1 bg-slate-800 text-white rounded-2xl p-6 shadow-lg">
<h3 class="font-bold text-white mb-4 flex items-center gap-2">
<i data-lucide="database" class="w-4 h-4 text-slate-400"></i> Admin APIs
</h3>
<div class="space-y-2 font-mono text-xs text-slate-300">
<div class="bg-slate-900/50 p-2 rounded border border-slate-700 truncate hover:text-white cursor-pointer">/api/dashboard/client-management</div>
<div class="bg-slate-900/50 p-2 rounded border border-slate-700 truncate hover:text-white cursor-pointer">/api/dashboard/sessions</div>
<div class="bg-slate-900/50 p-2 rounded border border-slate-700 truncate hover:text-white cursor-pointer">/api/dashboard/system-analytics</div>
<div class="bg-slate-900/50 p-2 rounded border border-slate-700 truncate hover:text-white cursor-pointer">/api/dashboard/model-history</div>
</div>
</div>
<div class="lg:col-span-2 bg-white rounded-2xl border border-slate-200 shadow-sm p-6">
<h3 class="font-bold text-slate-800 mb-4 flex items-center gap-2">
<i data-lucide="clipboard-list" class="w-4 h-4 text-slate-400"></i> Ghi chú nền tảng
</h3>
<ul class="space-y-3 text-sm text-slate-600">
<li class="flex items-start gap-2">
<i data-lucide="check-circle-2" class="w-4 h-4 text-success shrink-0 mt-0.5"></i>
<span>Thiết lập client giờ xoay quanh một URL truy cập được quản lý duy nhất thay vì các dịch vụ legacy tách rời.</span>
</li>
<li class="flex items-start gap-2">
<i data-lucide="check-circle-2" class="w-4 h-4 text-success shrink-0 mt-0.5"></i>
<span>Traffic của ZuneFClaudeCode giờ đi qua access layer hiện tại và các admin endpoint.</span>
</li>
<li class="flex items-start gap-2">
<i data-lucide="check-circle-2" class="w-4 h-4 text-success shrink-0 mt-0.5"></i>
<span>Model Routing đã được gỡ khỏi dashboard, API routes và luồng thiết lập client.</span>
</li>
<li class="flex items-start gap-2">
<i data-lucide="check-circle-2" class="w-4 h-4 text-success shrink-0 mt-0.5"></i>
<span>Guide link vẫn giữ nguyên, nhưng tài liệu giờ được tách thành các trang nhỏ theo từng tác vụ.</span>
</li>
</ul>
</div>
</div>
</div>
</div>
</main>
<script>
// Initialize Lucide icons
lucide.createIcons();
</script>
</body>
</html>
\ No newline at end of file
:root {
--bg-color: #f3f4f6;
--sidebar-bg: #ffffff;
--main-bg: #ffffff;
--text-main: #111827;
--text-muted: #6b7280;
--border-color: #e5e7eb;
--primary: #3b82f6;
--primary-hover: #2563eb;
--highlight-bg: #eff6ff;
--highlight-text: #1d4ed8;
--success: #10b981;
--danger: #ef4444;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: 'Inter', sans-serif;
background-color: var(--bg-color);
color: var(--text-main);
height: 100vh;
overflow: hidden;
}
.app-container {
display: flex;
height: 100vh;
}
/* Sidebar */
.sidebar {
width: 320px;
background-color: var(--sidebar-bg);
border-right: 1px solid var(--border-color);
display: flex;
flex-direction: column;
}
.sidebar-header {
padding: 24px 20px;
border-bottom: 1px solid var(--border-color);
}
.sidebar-header h2 {
font-size: 1.25rem;
font-weight: 600;
margin-bottom: 4px;
}
.sidebar-header p {
font-size: 0.875rem;
color: var(--text-muted);
}
.date-highlight {
margin: 20px;
padding: 16px;
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
color: white;
border-radius: 12px;
box-shadow: 0 4px 6px -1px rgba(59, 130, 246, 0.3);
}
.date-highlight .label {
display: block;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
opacity: 0.9;
margin-bottom: 4px;
}
.date-highlight .date-text {
font-size: 1.5rem;
font-weight: 700;
}
.event-list {
flex: 1;
overflow-y: auto;
padding: 10px 20px;
}
.event-item {
padding: 16px;
border: 1px solid var(--border-color);
border-radius: 8px;
margin-bottom: 12px;
cursor: pointer;
transition: all 0.2s;
background-color: #fff;
}
.event-item:hover {
border-color: var(--primary);
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
}
.event-item.active {
border-color: var(--primary);
background-color: var(--highlight-bg);
box-shadow: 0 0 0 1px var(--primary);
}
.event-item.is-today {
border-left: 4px solid var(--danger);
}
.event-item .event-title {
font-weight: 600;
margin-bottom: 4px;
display: flex;
justify-content: space-between;
}
.event-item .event-date {
font-size: 0.875rem;
color: var(--text-muted);
}
.event-item .event-type {
font-size: 0.75rem;
padding: 2px 8px;
border-radius: 999px;
background-color: #e5e7eb;
color: #4b5563;
}
.event-item.active .event-type {
background-color: var(--primary);
color: white;
}
/* Main Content */
.main-content {
flex: 1;
display: flex;
flex-direction: column;
background-color: var(--bg-color);
padding: 32px;
}
.content-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 24px;
}
.title-group h1 {
font-size: 1.875rem;
font-weight: 700;
margin-top: 8px;
}
.badge {
display: inline-block;
padding: 4px 12px;
border-radius: 999px;
font-size: 0.875rem;
font-weight: 500;
background-color: var(--highlight-bg);
color: var(--highlight-text);
}
.btn {
padding: 10px 20px;
border-radius: 8px;
font-weight: 600;
font-size: 0.875rem;
cursor: pointer;
border: none;
transition: all 0.2s;
}
.btn-primary {
background-color: var(--primary);
color: white;
}
.btn-primary:hover:not(:disabled) {
background-color: var(--primary-hover);
}
.btn-primary:disabled {
background-color: #9ca3af;
cursor: not-allowed;
}
.prompt-editor-container {
background-color: var(--main-bg);
border-radius: 12px;
border: 1px solid var(--border-color);
display: flex;
flex-direction: column;
flex: 1;
overflow: hidden;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05);
}
.editor-header {
padding: 20px 24px;
border-bottom: 1px solid var(--border-color);
background-color: #f9fafb;
}
.editor-header h3 {
font-size: 1.125rem;
font-weight: 600;
}
.editor-header .subtitle {
font-size: 0.875rem;
color: var(--text-muted);
margin-top: 4px;
}
.prompt-textarea {
flex: 1;
width: 100%;
padding: 24px;
border: none;
resize: none;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 0.95rem;
line-height: 1.6;
color: var(--text-main);
background-color: white;
}
.prompt-textarea:focus {
outline: none;
}
.prompt-textarea:disabled {
background-color: #f9fafb;
color: var(--text-muted);
}
.editor-footer {
padding: 16px 24px;
border-top: 1px solid var(--border-color);
display: flex;
justify-content: space-between;
align-items: center;
background-color: #f9fafb;
font-size: 0.875rem;
color: var(--text-muted);
}
.status-indicator.success {
color: var(--success);
font-weight: 500;
}
document.addEventListener('DOMContentLoaded', () => {
let events = [];
let currentEventId = null;
// Hiển thị ngày hôm nay
const today = new Date();
const dateOptions = { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' };
document.getElementById('today-date').textContent = today.toLocaleDateString('vi-VN', dateOptions);
// Fetch events from API
async function fetchEvents() {
try {
const response = await fetch('/api/event_daily/events');
if (response.ok) {
events = await response.json();
renderEvents();
// Select today's event by default if available
if (currentEventId === null) {
const todayEvent = events.find(e => e.isToday);
if (todayEvent) {
selectEvent(todayEvent.id);
} else if (events.length > 0) {
selectEvent(events[0].id);
}
} else {
selectEvent(currentEventId);
}
} else {
console.error('Failed to fetch events');
}
} catch (error) {
console.error('Error fetching events:', error);
}
}
// Render danh sách sự kiện
const eventListEl = document.getElementById('event-list');
function renderEvents() {
eventListEl.innerHTML = '';
events.forEach(event => {
const el = document.createElement('div');
el.className = `event-item ${event.isToday ? 'is-today' : ''} ${event.id === currentEventId ? 'active' : ''}`;
el.innerHTML = `
<div class="event-title">
<span>${event.name} ${event.isToday ? '🔥' : ''}</span>
<span class="event-type">${event.type}</span>
</div>
<div class="event-date">${event.date}</div>
`;
el.addEventListener('click', () => selectEvent(event.id));
eventListEl.appendChild(el);
});
}
function selectEvent(id) {
currentEventId = id;
renderEvents();
const event = events.find(e => e.id === id);
if (event) {
document.getElementById('current-event-name').textContent = event.name;
document.getElementById('current-event-type').textContent = event.type;
const textarea = document.getElementById('prompt-textarea');
textarea.value = event.prompt;
textarea.disabled = false;
document.getElementById('btn-save').disabled = false;
updateCharCount();
document.getElementById('save-status').textContent = '';
}
}
// Xử lý đếm ký tự
const textarea = document.getElementById('prompt-textarea');
textarea.addEventListener('input', updateCharCount);
function updateCharCount() {
document.getElementById('char-count').textContent = `${textarea.value.length} ký tự`;
}
// Xử lý lưu
document.getElementById('btn-save').addEventListener('click', async () => {
const btn = document.getElementById('btn-save');
btn.textContent = 'Đang lưu...';
btn.disabled = true;
try {
const response = await fetch(`/api/event_daily/events/${currentEventId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ prompt: textarea.value })
});
if (response.ok) {
const event = events.find(e => e.id === currentEventId);
if (event) {
event.prompt = textarea.value;
}
const status = document.getElementById('save-status');
status.textContent = '✓ Đã lưu thành công';
status.className = 'status-indicator success';
setTimeout(() => {
status.textContent = '';
status.className = 'status-indicator';
}, 3000);
} else {
throw new Error('Failed to save');
}
} catch (error) {
console.error('Error saving prompt:', error);
const status = document.getElementById('save-status');
status.textContent = '✗ Lỗi khi lưu';
status.className = 'status-indicator error';
status.style.color = 'red';
} finally {
btn.textContent = 'Lưu Cập Nhật';
btn.disabled = false;
}
});
// Init
fetchEvents();
});
<!DOCTYPE html>
<html lang="vi">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Event Daily</title>
<link rel="stylesheet" href="/static/common/theme.css">
<link rel="stylesheet" href="/static/common/components.css">
<script src="/static/common/frame-detect.js"></script>
<script src="https://unpkg.com/lucide@latest"></script>
<style>
body { background: var(--bg); margin: 0; color: var(--t); font-family: 'Inter', sans-serif; display: flex; height: 100vh; overflow: hidden; }
.fm-layout { display: flex; width: 100%; height: 100%; }
.fm-left { width: 340px; border-right: 1px solid var(--border); display: flex; flex-direction: column; background: var(--card); }
.fm-right { flex: 1; display: flex; flex-direction: column; overflow-y: auto; background: var(--bg); }
.fm-left-header { padding: 16px; border-bottom: 1px solid var(--border); display: flex; flex-direction: column; gap: 8px;}
.fm-left-header-title { font-weight: 600; font-size: 15px; display: flex; justify-content: space-between; align-items: center; }
.date-highlight { background: rgba(var(--primary-rgb), 0.05); border: 1px solid var(--border); border-radius: 8px; padding: 12px; display: flex; flex-direction: column; gap: 4px;}
.date-highlight .label { font-size: 11px; font-weight: 700; color: var(--primary); letter-spacing: 0.5px;}
.date-highlight .date-text { font-size: 13px; font-weight: 600; color: var(--t); }
.fb-list { flex: 1; overflow-y: auto; padding: 12px; display: flex; flex-direction: column; gap: 8px; }
.fb-list::-webkit-scrollbar { width: 6px; }
.fb-list::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
.fb-item { padding: 16px; border-radius: 8px; border: 1px solid var(--border); background: var(--card); cursor: pointer; transition: all 0.2s; position: relative;}
.fb-item:hover { border-color: var(--primary); transform: translateY(-1px); box-shadow: 0 4px 12px rgba(0,0,0,0.1); }
.fb-item.active { border-color: var(--primary); background: rgba(var(--primary-rgb), 0.05); }
.fb-tag { position: absolute; top: 12px; right: 12px; font-size: 10px; font-weight: 700; padding: 2px 6px; border-radius: 4px; letter-spacing: 0.5px;}
.fb-tag.daily { background: rgba(16,185,129,0.15); border: 1px solid rgba(16,185,129,0.3); color: #34d399; }
.fb-tag.campaign { background: rgba(99,102,241,0.15); border: 1px solid rgba(99,102,241,0.3); color: #818cf8; }
.fb-title { font-weight: 600; font-size: 14px; margin-bottom: 6px; padding-right: 60px;}
.fb-desc { font-size: 12px; color: var(--m); line-height: 1.5; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.panel-header { padding: 16px 24px; border-bottom: 1px solid var(--border); display: flex; justify-content: space-between; align-items: center; background: var(--card); }
.panel-body { padding: 32px 40px; max-width: 1200px; margin: 0 auto; width: 100%; display: flex; flex-direction: column; flex: 1; }
.editor-container { display: flex; flex-direction: column; flex: 1; gap: 16px; margin-top: 16px; }
.prompt-textarea { flex: 1; width: 100%; padding: 16px; border-radius: 8px; border: 1px solid var(--border); background: var(--bg); color: var(--t); font-family: 'Consolas', 'Courier New', monospace; font-size: 14px; line-height: 1.6; resize: none; box-sizing: border-box; }
.prompt-textarea:focus { outline: none; border-color: var(--primary); box-shadow: 0 0 0 2px rgba(var(--primary-rgb), 0.1); }
</style>
</head>
<body>
<div class="fm-layout">
<!-- LEFT LIST -->
<div class="fm-left">
<div class="fm-left-header">
<div class="fm-left-header-title">
<div style="display:flex; align-items:center; gap:8px;"><i data-lucide="calendar" class="icon-sm"></i> Event Daily</div>
</div>
<div style="font-size: 12px; color: var(--m);">Quản lý chiến dịch & Sự kiện</div>
<div class="date-highlight" style="margin-top: 8px;">
<span class="label">HÔM NAY</span>
<span class="date-text" id="today-date">Thứ Ba, 12 tháng 5, 2026</span>
</div>
</div>
<div style="padding:10px 12px;border-bottom:1px solid var(--border);">
<div class="search-bar" style="position: relative; display:flex; align-items:center;">
<i data-lucide="search" class="icon-sm" style="position: absolute; left: 10px; color: var(--m);"></i>
<input type="text" placeholder="Tìm sự kiện..." style="width:100%; padding: 8px 12px 8px 32px; font-size: 12px; border: 1px solid var(--border); background: var(--bg); color: var(--t); border-radius: 6px; outline:none;">
</div>
</div>
<div class="fb-list" id="eventsList">
<!-- Generated by JS -->
</div>
</div>
<!-- RIGHT DETAIL -->
<div class="fm-right" id="contentArea" style="display: none;">
<div class="panel-header">
<div style="display:flex; align-items:center; gap:12px;">
<span class="badge" id="detail-tag" style="background: rgba(16,185,129,0.15); color: #34d399; font-size:11px; padding:2px 8px; border-radius:4px; font-weight:bold;">DAILY</span>
<h2 id="detail-title" style="margin:0; font-size: 18px; font-weight: 600;">Mặc định hàng ngày</h2>
</div>
<div style="display:flex;gap:8px;">
<button class="btn btn-primary btn-sm" onclick="savePrompt()">
<i data-lucide="save" class="icon-sm"></i> Lưu Cập Nhật
</button>
</div>
</div>
<div class="panel-body">
<!-- Date Config Section -->
<div style="display:flex; gap:16px; margin-bottom: 24px; padding-bottom: 24px; border-bottom: 1px solid var(--border);" id="date-config-section">
<div>
<label style="display:block; font-size:12px; font-weight:600; color:var(--m); margin-bottom:6px;">Bắt đầu (Từ ngày)</label>
<input type="date" id="evt-date-from" style="padding: 8px 12px; border: 1px solid var(--border); border-radius: 6px; background: var(--bg); color: var(--t); outline:none; font-family: inherit;">
</div>
<div>
<label style="display:block; font-size:12px; font-weight:600; color:var(--m); margin-bottom:6px;">Kết thúc (Đến ngày)</label>
<input type="date" id="evt-date-to" style="padding: 8px 12px; border: 1px solid var(--border); border-radius: 6px; background: var(--bg); color: var(--t); outline:none; font-family: inherit;">
</div>
</div>
<div style="display:flex; align-items:center; justify-content:space-between;">
<div style="display:flex; align-items:center; gap:8px;">
<i data-lucide="terminal-square" class="icon-sm" style="color:var(--primary)"></i>
<h3 style="font-size:14px; color:var(--t); margin:0; font-weight:600;">Chỉ Đạo Tư Vấn (Prompt Module)</h3>
</div>
</div>
<p style="font-size: 13px; color: var(--m); margin: 8px 0 0 0;">Những gì AI nên ưu tiên gợi ý trong ngày/sự kiện này.</p>
<div class="editor-container">
<textarea id="prompt-content" class="prompt-textarea" placeholder="Nhập chỉ đạo tư vấn..."></textarea>
</div>
</div>
</div>
<div class="fm-right" id="emptyArea" style="display: flex; align-items:center; justify-content:center;">
<div style="text-align:center; color: var(--m);">
<i data-lucide="calendar-search" style="width: 48px; height: 48px; opacity: 0.3; margin-bottom: 16px;"></i>
<h3>Chọn một sự kiện</h3>
<p style="font-size: 13px;">Nhấn vào một sự kiện ở danh sách bên trái để xem và chỉnh sửa</p>
</div>
</div>
</div>
<script>
if (window.lucide) { lucide.createIcons(); }
const mockEvents = [
{
id: 'evt_1',
title: 'Mặc định hàng ngày',
type: 'Daily',
tagClass: 'daily',
date: 'All days',
desc: 'Luật cơ bản cho ngày thường. Không có event đặc biệt.',
prompt: '- Luôn chào hỏi thân thiện\n- Gợi ý đồ dạo phố, công sở nhẹ nhàng\n- Khuyến khích khách hàng thử trang phục mới'
},
{
id: 'evt_2',
title: 'Flash Sale 5/5',
type: 'Campaign',
tagClass: 'campaign',
date: '05/05/2026',
desc: 'Sale lớn đồng giá 99k, 199k. Đẩy mạnh các mặt hàng áo thun mùa hè.',
prompt: '- NHẤN MẠNH chương trình Flash Sale 5/5 đồng giá 99k, 199k\n- Gợi ý áo thun, quần short mặc hè\n- Dùng emoji 🎉🔥 để tạo sự hào hứng'
},
{
id: 'evt_3',
title: 'Lễ hội mùa đông',
type: 'Campaign',
tagClass: 'campaign',
date: '10/11/2026 - 31/12/2026',
desc: 'Chiến dịch ra mắt bộ sưu tập áo khoác dạ và len lông cừu.',
prompt: '- Ưu tiên tư vấn áo khoác dạ, áo len lông cừu\n- Gợi ý cách phối đồ layer ấm áp\n- Chú ý hỏi khách hàng đang ở khu vực lạnh không'
},
{
id: 'evt_4',
title: 'Sinh nhật Canifa',
type: 'Campaign',
tagClass: 'campaign',
date: '01/08/2026 - 15/08/2026',
desc: 'Mừng sinh nhật. Voucher giảm 50% cho khách hàng mua từ 3 sản phẩm.',
prompt: '- Nhắc khéo khách hàng về voucher giảm 50% khi mua 3 sản phẩm\n- Chúc mừng sinh nhật Canifa cùng khách hàng'
},
{
id: 'evt_5',
title: 'Black Friday 2026',
type: 'Campaign',
tagClass: 'campaign',
date: '27/11/2026',
desc: 'Giảm giá cực sốc lên tới 70%. Tăng cường upsell.',
prompt: '- Khẩn trương! Black Friday sale 70%\n- Tư vấn khách mua theo combo để được giá tốt nhất\n- Không bỏ lỡ các deal shock giờ vàng'
}
];
const eventsListEl = document.getElementById('eventsList');
const contentArea = document.getElementById('contentArea');
const emptyArea = document.getElementById('emptyArea');
let currentActiveId = null;
function renderEvents() {
eventsListEl.innerHTML = mockEvents.map(evt => `
<div class="fb-item ${evt.id === currentActiveId ? 'active' : ''}" onclick="selectEvent('${evt.id}')">
<div class="fb-tag ${evt.tagClass}">${evt.type.toUpperCase()}</div>
<div class="fb-title">${evt.title}</div>
<div style="font-size:11px; color:var(--m); margin-bottom:8px; display:flex; align-items:center; gap:4px;">
<i data-lucide="clock" style="width:12px;height:12px;"></i> ${evt.date}
</div>
<div class="fb-desc">${evt.desc}</div>
</div>
`).join('');
lucide.createIcons();
}
function selectEvent(id) {
currentActiveId = id;
renderEvents();
const evt = mockEvents.find(e => e.id === id);
if(evt) {
emptyArea.style.display = 'none';
contentArea.style.display = 'flex';
document.getElementById('detail-title').innerText = evt.title;
const tagEl = document.getElementById('detail-tag');
tagEl.innerText = evt.type.toUpperCase();
if (evt.type === 'Daily') {
tagEl.style.background = 'rgba(16,185,129,0.15)';
tagEl.style.color = '#34d399';
} else {
tagEl.style.background = 'rgba(99,102,241,0.15)';
tagEl.style.color = '#818cf8';
}
document.getElementById('prompt-content').value = evt.prompt;
}
}
function savePrompt() {
const btn = document.querySelector('.btn-primary');
const originalHtml = btn.innerHTML;
btn.innerHTML = '<i data-lucide="check" class="icon-sm"></i> Đã lưu';
btn.style.background = '#10b981';
lucide.createIcons();
setTimeout(() => {
btn.innerHTML = originalHtml;
btn.style.background = '';
lucide.createIcons();
}, 2000);
}
// Set today's date
const today = new Date();
const options = { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' };
document.getElementById('today-date').innerText = today.toLocaleDateString('vi-VN', options);
renderEvents();
</script>
</body>
</html>
......@@ -161,6 +161,11 @@ body{margin:0;display:flex;min-height:100vh}
<span>Prompt Optimizer</span>
<span class="nav-badge badge-beta">NEW</span>
</a>
<a data-page="manage_prompt/index.html" class="nav-item" onclick="navigateTo(this)" title="Prompt Manager">
<span class="nav-icon"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg></span>
<span>Prompt Manager</span>
<span class="nav-badge badge-live">LIVE</span>
</a>
<a data-page="user-simulator/user-simulator.html" class="nav-item" onclick="navigateTo(this)" title="User Simulator">
<span class="nav-icon"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg></span>
<span>User Simulator</span>
......@@ -210,6 +215,11 @@ body{margin:0;display:flex;min-height:100vh}
<span>Competitor Research</span>
<span class="nav-badge badge-beta">NEW</span>
</a>
<a data-page="event_daily/index.html" class="nav-item" onclick="navigateTo(this)" title="Event Daily">
<span class="nav-icon"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg></span>
<span>Event Daily</span>
<span class="nav-badge badge-new">NEW</span>
</a>
</div>
<div class="nav-group">
......
<!DOCTYPE html>
<html lang="vi">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Prompt Manager</title>
<link rel="stylesheet" href="/static/common/theme.css">
<link rel="stylesheet" href="/static/common/components.css">
<link rel="stylesheet" href="/static/manage_prompt/manage_prompt.css">
<script src="https://unpkg.com/lucide@latest"></script>
</head>
<body>
<div class="shell" id="app">
<div class="body">
<!-- LEFT LIST -->
<div class="panel panel-left">
<div class="panel-header panel-header-flex">
<div class="panel-brand">
<i data-lucide="folder-code" class="icon-sm"></i> Prompt Manager
</div>
<div class="panel-actions">
<button class="tb-btn" onclick="importFromFiles()" title="Import từ local files">
<i data-lucide="download" class="icon-sm"></i>
</button>
<button class="tb-btn" onclick="refreshAll()" title="Refresh">
<i data-lucide="refresh-cw" class="icon-sm"></i>
</button>
</div>
</div>
<div class="panel-section search-section">
<div class="search-bar search-bar-pm">
<i data-lucide="search" class="icon-sm search-icon"></i>
<input type="text" id="module-search" class="search-input" placeholder="Tìm module (ví dụ: core, thaomai)..." oninput="filterModules(this.value)">
</div>
<div class="stats-row">
<span>Modules: <strong id="stat-modules" class="stat-val-text">—</strong></span>
<span>In Sync: <strong id="stat-sync" class="stat-sync-text">—</strong></span>
</div>
</div>
<div class="module-list" id="module-list">
<div class="list-loading">
<i data-lucide="loader" class="spinner icon-md text-primary"></i>
</div>
</div>
</div>
<!-- CENTER DETAIL -->
<div class="panel panel-center">
<div id="detail-area" class="detail-area">
<div class="centered-empty">
<i data-lucide="mouse-pointer-click" class="empty-icon-lg"></i>
<div class="empty-title">Chọn một module</div>
<div class="empty-desc">Click vào module bên trái để xem và quản lý phiên bản dev / production</div>
</div>
</div>
</div>
<!-- RIGHT PANEL -->
<div class="panel panel-right">
<div class="panel-section">
<div class="section-title split-title">
<span>Langfuse Status</span>
<span class="lf-status-wrap">
<i id="lf-dot" data-lucide="cloud" class="icon-xs status-dot checking"></i>
<span id="lf-status" class="lf-status-text">Connecting...</span>
<strong id="stat-lf-count" class="lf-count-text">—</strong>
</span>
</div>
<div id="langfuse-panel"></div>
</div>
<div class="panel-section" style="flex:1; display:flex; flex-direction:column; overflow:hidden;">
<div class="section-title">Version History</div>
<div id="history-panel" style="flex:1;"></div>
</div>
</div>
</div>
</div>
<!-- ═══ MODAL: Diff Viewer ═══ -->
<div class="modal-overlay" id="diff-modal">
<div class="modal-box modal-lg">
<div class="modal-head">
<div class="modal-head-title">
<i data-lucide="git-compare" class="icon-md text-primary"></i>
<h3 id="diff-title">So sánh Dev ↔ Production</h3>
</div>
<button class="modal-close" onclick="closeDiffModal()"><i data-lucide="x" class="icon-sm"></i></button>
</div>
<div class="modal-body bg-dark">
<div class="diff-wrapper">
<div class="diff-box">
<div class="diff-header dev-header">
<span class="diff-header-title"><i data-lucide="terminal" class="icon-sm"></i> DEV (Local)</span>
</div>
<pre class="diff-content" id="diff-dev"></pre>
</div>
<div class="diff-box">
<div class="diff-header prod-header">
<span class="diff-header-title"><i data-lucide="cloud" class="icon-sm"></i> PRODUCTION (Langfuse)</span>
</div>
<pre class="diff-content" id="diff-prod"></pre>
</div>
</div>
</div>
<div class="modal-foot">
<button class="detail-btn outline" onclick="closeDiffModal()">Đóng</button>
<button class="detail-btn primary" id="btn-promote-modal" onclick="promoteFromModal()">
<i data-lucide="rocket" class="icon-sm"></i> Promote to Production
</button>
</div>
</div>
</div>
<!-- ═══ MODAL: Editor ═══ -->
<div class="modal-overlay" id="editor-modal">
<div class="modal-box modal-lg">
<div class="modal-head">
<div class="modal-head-title">
<i data-lucide="edit-3" class="icon-md text-primary"></i>
<h3 id="editor-title">Chỉnh sửa Prompt</h3>
</div>
<button class="modal-close" onclick="closeEditorModal()"><i data-lucide="x" class="icon-sm"></i></button>
</div>
<div class="modal-body">
<div class="editor-notes-wrap">
<input type="text" id="editor-notes" class="editor-notes-input" placeholder="Ghi chú phiên bản (Ví dụ: Update rule thời tiết mùa hè)...">
</div>
<textarea class="editor-textarea" id="editor-content" spellcheck="false" placeholder="Nội dung prompt..."></textarea>
<div class="editor-stats">
<span id="editor-chars" class="editor-stat-item"><i data-lucide="type" class="icon-xs"></i> 0 ký tự</span>
<span id="editor-lines" class="editor-stat-item"><i data-lucide="list" class="icon-xs"></i> 0 dòng</span>
</div>
</div>
<div class="modal-foot">
<button class="detail-btn outline" onclick="closeEditorModal()">Hủy</button>
<button class="detail-btn primary" onclick="saveEditorContent()">
<i data-lucide="save" class="icon-sm"></i> Lưu Dev Version
</button>
</div>
</div>
</div>
<!-- ═══ Toast ═══ -->
<div class="notif" id="toast"></div>
<!-- ═══ Loading Overlay ═══ -->
<div id="loading-overlay" class="loading-overlay">
<i data-lucide="loader" class="icon-xl spinner text-primary"></i>
<h3 class="loading-title" id="loading-title">Đang xử lý...</h3>
</div>
<script src="/static/manage_prompt/manage_prompt.js"></script>
<script>
// Initialize lucide icons
lucide.createIcons();
</script>
</body>
</html>
/* ═══════════════════════════════════════════════════════════
Prompt Manager — Page-specific Styles
Consistent with Canifa AI Lab CSS system (Premium Dark)
═══════════════════════════════════════════════════════════ */
:root {
--bg: #ffffff; /* background */
--s: #f8f7f4; /* surface */
--b: #e7e5e2; /* border */
--t: #1c1917; /* text primary */
--m: #78716c; /* text muted */
--primary: #3b5998;
}
body {
background-color: var(--bg);
color: var(--t);
margin: 0;
font-family: var(--font-sans, 'Inter', sans-serif);
}
/* ── Icons & Animations ── */
.icon-xs { width: 12px; height: 12px; }
.icon-sm { width: 16px; height: 16px; }
.icon-md { width: 20px; height: 20px; }
.icon-xl { width: 32px; height: 32px; }
.text-primary { color: var(--primary); }
.spinner { animation: spin 1s linear infinite; }
@keyframes spin { 100% { transform: rotate(360deg); } }
@keyframes fadeUp {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
/* ── Shell & Body Layout ── */
.shell {
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
}
.body {
flex: 1;
display: grid;
grid-template-columns: 280px 1fr 320px;
gap: 0;
overflow: hidden;
min-height: 0;
}
/* ── Panels ── */
.panel {
display: flex; flex-direction: column;
overflow: hidden; background: var(--s);
}
.panel-left { border-right: 1px solid var(--b); background: var(--bg); }
.panel-right { border-left: 1px solid var(--b); background: var(--s); }
.panel-center { background: var(--bg); }
.panel-header {
padding: 12px 14px; border-bottom: 1px solid var(--b);
flex-shrink: 0;
}
.panel-header-flex {
display: flex; justify-content: space-between; align-items: center;
}
.panel-brand {
display: flex; align-items: center; gap: 8px; color: var(--t); font-weight: 600; font-size: 14px;
}
.panel-actions {
display: flex; align-items: center; gap: 6px;
}
.panel-section {
border-bottom: 1px solid var(--b);
padding: 10px 14px;
}
.search-section {
padding: 10px 14px;
}
.section-title {
font-size: 11px; font-weight: 700; text-transform: uppercase;
letter-spacing: .06em; color: var(--m);
margin-bottom: 8px;
}
.split-title {
display: flex; justify-content: space-between; align-items: center;
}
/* ── Status Dot & Langfuse ── */
.status-dot {
width: 8px; height: 8px; border-radius: 50%;
transition: background .3s; display: inline-block;
}
.status-dot.online { background: #34D399; box-shadow: 0 0 6px rgba(52,211,153,.5); }
.status-dot.offline { background: #EF4444; }
.status-dot.checking { background: #FBBF24; animation: pulse 1.2s infinite; }
@keyframes pulse { 0%,100%{opacity:1;} 50%{opacity:.5;} }
.lf-status-wrap {
display: flex; align-items: center; gap: 4px;
}
.lf-status-text { font-size: 10px; color: var(--m); }
.lf-count-text { color: #a78bfa; font-size: 10px; }
/* ── Module List ── */
.module-list {
flex: 1; overflow-y: auto; padding: 8px;
}
.list-loading {
display: flex; justify-content: center; padding: 20px;
}
.module-item {
padding: 10px 12px; border-radius: 10px; margin-bottom: 4px;
cursor: pointer; transition: all .15s;
border: 1px solid transparent;
display: flex; flex-direction: column; gap: 6px;
}
.module-item:hover { background: var(--s); }
.module-item.active { border-color: var(--b); background: var(--s); }
.module-name {
font-size: 13px; font-weight: 600; color: var(--t);
display: flex; align-items: center; gap: 6px;
}
.module-type-badge {
font-size: 10px; font-weight: 700; padding: 2px 6px;
border-radius: 10px; text-transform: uppercase;
letter-spacing: .04em;
}
.type-system { background: rgba(59,130,246,.15); color: #60a5fa; }
.type-tool { background: rgba(249,115,22,.15); color: #fb923c; }
.module-meta {
display: flex; align-items: center; gap: 6px;
font-size: 11px; color: var(--m);
}
.env-pill {
font-size: 10px; font-weight: 700; padding: 2px 7px;
border-radius: 10px; letter-spacing: .03em;
}
.pill-dev { background: rgba(5,150,105,.2); color: #34d399; }
.pill-prod { background: rgba(220,38,38,.15); color: #ef4444; }
.pill-none { background: var(--b); color: var(--m); font-style: italic; }
.pill-synced { background: rgba(29,78,216,.2); color: #60a5fa; }
.pill-unsynced { background: rgba(146,64,14,.2); color: #fbbf24; }
.sync-indicator {
margin-left: auto;
font-size: 11px; font-weight: 600;
}
.sync-yes { color: #34d399; }
.sync-no { color: #f87171; }
/* ── Search & Stats ── */
.search-bar-pm {
position: relative; display: flex; align-items: center;
}
.search-icon {
position: absolute; left: 10px; color: var(--m);
}
.search-input {
width: 100%; padding: 8px 12px 8px 32px; font-size: 12px;
border: 1px solid var(--b); background: var(--bg); color: var(--t);
border-radius: 6px; outline: none; transition: border-color .2s;
}
.search-input:focus { border-color: var(--primary); }
.stats-row {
display: flex; justify-content: space-between; margin-top: 10px; font-size: 11px; color: var(--m);
}
.stat-val-text { color: var(--t); }
.stat-sync-text { color: #34d399; }
/* ── Detail Area ── */
.detail-area { flex: 1; overflow-y: auto; padding: 20px; display: flex; flex-direction: column; height: 100%; }
.centered-empty {
margin: auto; text-align: center; color: var(--m);
display: flex; flex-direction: column; justify-content: center; align-items: center;
}
.empty-icon-lg { width: 48px; height: 48px; opacity: 0.5; margin-bottom: 16px; }
.empty-title { font-size: 16px; font-weight: 600; color: var(--t); margin-bottom: 8px; }
.empty-desc { font-size: 13px; }
.detail-header {
display: flex; justify-content: space-between; align-items: center;
margin-bottom: 16px; padding-bottom: 12px;
border-bottom: 1px solid var(--b);
}
.detail-title {
font-size: 18px; font-weight: 700; color: var(--t);
}
.detail-actions { display: flex; gap: 6px; }
/* ── Detail Buttons ── */
.tb-btn {
padding: 6px; border-radius: 8px; border: 1px solid transparent;
background: transparent; color: var(--m); font-size: 12px;
cursor: pointer; display: flex; align-items: center; justify-content: center; transition: all .15s;
}
.tb-btn:hover { background: var(--b); color: var(--t); }
.detail-btn {
padding: 6px 14px; border-radius: 8px;
font-size: 12px; font-weight: 600; cursor: pointer;
border: 1px solid var(--b); background: var(--s); color: var(--t);
transition: all .15s; display: flex; align-items: center; gap: 6px;
}
.detail-btn:hover { background: var(--b); }
.detail-btn.primary { background: #6D28D9; color: #fff; border-color: #6D28D9; }
.detail-btn.primary:hover { background: #5B21B6; }
.detail-btn.success { background: #059669; color: #fff; border-color: #059669; }
.detail-btn.success:hover { background: #047857; }
.detail-btn.outline { background: transparent; border-color: var(--b); }
.detail-btn.outline:hover { background: var(--b); }
/* ── Environment Cards ── */
.env-cards {
display: grid; grid-template-columns: 1fr 1fr;
gap: 14px; margin-bottom: 16px;
}
.env-card {
background: var(--bg); border: 1px solid var(--b); border-radius: 12px;
padding: 16px; position: relative; overflow: hidden;
}
.env-card::before {
content: ''; position: absolute; top: 0; left: 0; right: 0; height: 3px;
}
.env-card.dev::before { background: linear-gradient(90deg, #059669, #34d399); }
.env-card.prod::before { background: linear-gradient(90deg, #dc2626, #ef4444); }
.env-card-header {
display: flex; justify-content: space-between; align-items: center;
margin-bottom: 10px;
}
.env-card-title {
font-size: 13px; font-weight: 700; text-transform: uppercase;
letter-spacing: .05em;
}
.env-card.dev .env-card-title { color: #34d399; }
.env-card.prod .env-card-title { color: #ef4444; }
.env-card-meta {
display: flex; flex-direction: column; gap: 4px;
font-size: 12px; color: var(--m);
}
.env-card-meta strong { color: var(--t); font-weight: 600; }
.env-content-preview {
background: #f8fafc; border-radius: 8px; padding: 16px 20px;
font-size: 13px; font-family: 'Consolas', 'Courier New', var(--font-mono, monospace);
color: #0f172a; line-height: 1.7; margin-top: 10px;
max-height: 350px; overflow-y: auto;
white-space: pre-wrap; word-break: break-word; border: 1px solid var(--b);
box-shadow: inset 0 2px 4px rgba(0,0,0,0.02);
}
.env-empty {
padding: 24px; text-align: center; color: var(--m);
font-size: 12px; font-style: italic;
}
/* ── History Panel ── */
#history-panel, #langfuse-panel {
overflow-y: auto; padding: 0 4px;
}
.history-item {
padding: 10px 12px; border-radius: 8px; margin-bottom: 6px; font-size: 12px;
border: 1px solid var(--b); background: var(--bg);
cursor: pointer; transition: all .12s;
}
.history-item:hover { background: var(--s); border-color: var(--m); }
.history-head {
display: flex; justify-content: space-between; align-items: center; margin-bottom: 3px;
}
.history-ver { font-weight: 700; color: var(--t); font-size: 13px; }
.history-env { font-size: 10px; font-weight: 700; padding: 2px 6px; border-radius: 8px; }
.history-meta { color: var(--m); font-size: 10px; }
/* ── Langfuse Items ── */
.lf-item {
padding: 8px 10px; border-radius: 8px; margin-bottom: 6px;
border: 1px solid var(--b); background: var(--bg); font-size: 11px;
}
.lf-item-name { font-weight: 600; color: var(--t); margin-bottom: 4px; }
.lf-item-meta { color: var(--m); font-size: 10px; }
.lf-label {
display: inline-block; font-size: 9px; font-weight: 700;
padding: 2px 6px; border-radius: 8px;
background: rgba(220,38,38,.15); color: #ef4444; margin-right: 4px;
}
/* ── Modal Overlay ── */
.modal-overlay {
position: fixed; inset: 0; z-index: 1000;
background: rgba(0,0,0,0.4); backdrop-filter: blur(4px);
display: none; justify-content: center; align-items: center;
}
.modal-overlay.open { display: flex; }
.modal-box {
background: var(--s); border-radius: 14px; border: 1px solid var(--b);
box-shadow: 0 20px 60px rgba(0,0,0,.5);
display: flex; flex-direction: column;
max-height: 90vh; width: 600px;
animation: fadeUp .2s ease;
}
.modal-box.modal-lg { width: 900px; }
.modal-head {
display: flex; justify-content: space-between; align-items: center;
padding: 16px 20px; border-bottom: 1px solid var(--b);
}
.modal-head-title {
display: flex; align-items: center; gap: 8px;
}
.modal-head h3 { font-size: 15px; font-weight: 700; margin: 0; color: var(--t); }
.modal-close {
width: 28px; height: 28px; border-radius: 8px; border: none;
background: var(--bg); cursor: pointer; font-size: 14px; color: var(--m);
display: flex; align-items: center; justify-content: center; transition: all .15s;
}
.modal-close:hover { background: rgba(239,68,68,.2); color: #ef4444; }
.modal-body { flex: 1; overflow-y: auto; padding: 16px 20px; }
.bg-dark { background: var(--bg); }
.modal-foot {
padding: 12px 20px; border-top: 1px solid var(--b);
display: flex; justify-content: flex-end; gap: 8px;
}
/* ── Diff Viewer ── */
.diff-wrapper {
display: flex; flex-wrap: wrap; gap: 20px; margin-top: 20px;
}
.diff-box {
flex: 1; min-width: 300px;
display: flex; flex-direction: column; border: 1px solid var(--b); border-radius: 8px; overflow: hidden;
background: #ffffff; box-shadow: 0 4px 20px rgba(0,0,0,0.05);
}
.diff-header {
padding: 10px 16px; font-size: 12px; font-family: 'Consolas', monospace; font-weight: 600;
display: flex; align-items: center; justify-content: space-between;
border-bottom: 1px solid var(--b);
background: #f1f5f9; color: #475569;
}
.dev-header { background: #ecfdf5; color: #059669; border-bottom-color: #a7f3d0; }
.prod-header { background: #fef2f2; color: #dc2626; border-bottom-color: #fecaca; }
.diff-header-title { display: flex; align-items: center; gap: 6px; }
.diff-content {
padding: 20px;
font-family: 'Consolas', 'Courier New', var(--font-mono, monospace);
font-size: 13px; line-height: 1.7; color: #0f172a;
white-space: pre-wrap; word-break: break-word;
max-height: 60vh; overflow-y: auto; margin: 0; flex: 1;
}
/* ── Editor ── */
.editor-notes-wrap { margin-bottom: 12px; }
.editor-notes-input {
width: 100%; padding: 10px 14px; font-size: 13px;
border: 1px solid var(--b); background: var(--bg); color: var(--t);
border-radius: 8px; outline: none; transition: border-color .2s;
}
.editor-notes-input:focus { border-color: var(--primary); }
.editor-textarea {
width: 100%; min-height: 400px; padding: 16px;
border: 1px solid var(--b); border-radius: 10px;
font-family: 'Consolas', 'Courier New', var(--font-mono, monospace);
font-size: 14px; line-height: 1.7;
color: #0f172a; background: #f8fafc;
resize: vertical; outline: none; transition: border-color .15s;
box-shadow: inset 0 2px 4px rgba(0,0,0,0.02);
}
.editor-textarea:focus { border-color: var(--primary); }
.editor-stats {
display: flex; gap: 16px; margin-top: 8px; font-size: 12px; color: var(--m);
}
.editor-stat-item { display: flex; align-items: center; gap: 4px; }
/* ── Loading Overlay ── */
.loading-overlay {
display: none; position: absolute; inset: 0; background: rgba(255,255,255,0.8);
backdrop-filter: blur(4px); z-index: 9999;
flex-direction: column; justify-content: center; align-items: center;
}
.loading-overlay.active { display: flex; }
.loading-title {
color: var(--t); font-size: 16px; margin: 0 0 8px 0; font-weight: 600;
}
/* ── Toast ── */
.notif {
position: fixed; bottom: -50px; left: 50%; transform: translateX(-50%);
background: #1e293b; color: #fff; padding: 10px 20px; border-radius: 8px;
font-size: 13px; font-weight: 500; box-shadow: 0 4px 12px rgba(0,0,0,.3);
transition: all .3s; z-index: 10000; opacity: 0; pointer-events: none; border: 1px solid var(--b);
}
.notif.show { bottom: 20px; opacity: 1; }
/* ── Responsive ── */
@media (max-width: 1200px) {
.body { grid-template-columns: 240px 1fr 280px; }
}
@media (max-width: 900px) {
.body { grid-template-columns: 1fr; grid-template-rows: auto 1fr auto; }
.panel-left, .panel-right { border: none; border-bottom: 1px solid var(--b); max-height: 300px; }
}
/**
* Prompt Manager — Frontend Logic
* Manages dev/production prompt versions with Langfuse sync
*/
const API = '/api/prompt-manager';
let modules = [];
let selectedModule = null;
let selectedDevVersionId = null;
// ═══ INIT ═══════════════════════════════════════════════════
document.addEventListener('DOMContentLoaded', () => {
refreshAll();
});
async function refreshAll() {
await Promise.all([
loadModules(),
checkLangfuseStatus(),
]);
}
// ═══ MODULES ═══════════════════════════════════════════════
async function loadModules() {
const el = document.getElementById('module-list');
el.innerHTML = '<div class="loading">Loading...</div>';
try {
const res = await fetch(`${API}/modules`);
const data = await res.json();
modules = data.modules || [];
if (modules.length === 0) modules = getMockModules();
} catch (e) {
console.warn("API fetch failed, using mock data", e);
modules = getMockModules();
}
document.getElementById('stat-modules').textContent = modules.length;
const syncCount = modules.filter(m => m.in_sync).length;
document.getElementById('stat-sync').textContent = `${syncCount}/${modules.length}`;
renderModuleList();
// Re-select if was selected
if (selectedModule) {
const still = modules.find(m => m.module_name === selectedModule);
if (still) selectModule(still.module_name);
}
}
function renderModuleList() {
const el = document.getElementById('module-list');
if (!modules.length) {
el.innerHTML = `
<div class="empty-state">
<div class="icon"><i data-lucide="package" class="icon-xl" style="color:var(--m)"></i></div>
<div class="title">Chưa có module</div>
<div class="desc">Click "Import Files" để import từ local</div>
</div>`;
lucide.createIcons();
return;
}
el.innerHTML = modules.map(m => {
const isActive = selectedModule === m.module_name;
const typeClass = m.module_type === 'tool' ? 'type-tool' : 'type-system';
const typeLabel = m.module_type === 'tool' ? 'TOOL' : 'SYS';
const devV = m.dev ? `v${m.dev.version}` : '—';
const prodV = m.production ? `v${m.production.version}` : '—';
return `
<div class="module-item${isActive ? ' active' : ''}" onclick="selectModule('${m.module_name}')">
<div class="module-name">
<span class="module-type-badge ${typeClass}">${typeLabel}</span>
${m.module_name}
</div>
<div class="module-meta">
<span class="env-pill pill-dev">DEV ${devV}</span>
<span class="env-pill pill-prod">PROD ${prodV}</span>
<span class="sync-indicator ${m.in_sync ? 'sync-yes' : 'sync-no'}">
${m.in_sync ? '<i data-lucide="check" class="icon-xs" style="vertical-align:-2px"></i> Sync' : '<i data-lucide="git-commit" class="icon-xs" style="vertical-align:-2px"></i> Diff'}
</span>
</div>
</div>`;
}).join('');
lucide.createIcons();
}
function filterModules(q) {
const query = q.toLowerCase().trim();
document.querySelectorAll('.module-item').forEach(item => {
const text = item.textContent.toLowerCase();
item.style.display = (!query || text.includes(query)) ? '' : 'none';
});
}
// ═══ SELECT MODULE ══════════════════════════════════════════
async function selectModule(name) {
selectedModule = name;
renderModuleList(); // update active
const mod = modules.find(m => m.module_name === name);
if (!mod) return;
// Load full content for both environments
const [devContent, prodContent] = await Promise.all([
mod.dev ? fetchContent(mod.dev.id) : Promise.resolve(null),
mod.production ? fetchContent(mod.production.id) : Promise.resolve(null),
]);
if (mod.dev) selectedDevVersionId = mod.dev.id;
renderDetail(mod, devContent, prodContent);
loadHistory(name);
}
async function fetchContent(versionId) {
try {
const res = await fetch(`${API}/versions/${versionId}/content`);
const data = await res.json();
return data.content || getMockContent(versionId);
} catch {
return getMockContent(versionId);
}
}
// ═══ RENDER DETAIL ══════════════════════════════════════════
function renderDetail(mod, devContent, prodContent) {
const el = document.getElementById('detail-area');
const hasDev = mod.dev && devContent;
const hasProd = mod.production && prodContent;
el.innerHTML = `
<div class="detail-area">
<div class="detail-header">
<div>
<div class="detail-title">${mod.module_name}</div>
<div style="font-size:12px;color:var(--m);margin-top:2px;display:flex;align-items:center;gap:4px">
Type: <strong>${mod.module_type}</strong>
${mod.in_sync
? ' · <span style="color:#059669;display:flex;align-items:center;gap:4px"><i data-lucide="check-circle" class="icon-xs"></i> In Sync</span>'
: ' · <span style="color:#DC2626;display:flex;align-items:center;gap:4px"><i data-lucide="alert-circle" class="icon-xs"></i> Out of Sync</span>'
}
</div>
</div>
<div class="detail-actions">
<button class="detail-btn" onclick="openEditor('${mod.module_name}', '${mod.module_type}')">
<i data-lucide="edit-3" class="icon-sm"></i> Edit Dev
</button>
${hasDev && hasProd ? `
<button class="detail-btn" onclick="openDiffModal('${mod.module_name}')">
<i data-lucide="git-compare" class="icon-sm"></i> Compare
</button>` : ''}
${hasDev ? `
<button class="detail-btn primary" onclick="promoteVersion(${mod.dev.id}, '${mod.module_name}')">
<i data-lucide="rocket" class="icon-sm"></i> Promote
</button>` : ''}
${hasProd ? `
<button class="detail-btn success" onclick="syncToLangfuse(${mod.production.id}, '${mod.module_name}')">
<i data-lucide="cloud-upload" class="icon-sm"></i> Sync Langfuse
</button>` : ''}
</div>
</div>
<div class="env-cards">
<div class="env-card dev">
<div class="env-card-header">
<span class="env-card-title" style="display:flex;align-items:center;gap:6px"><i data-lucide="terminal" class="icon-sm"></i> Development</span>
${hasDev ? `<span class="env-pill pill-dev">v${mod.dev.version}</span>` : ''}
</div>
${hasDev ? `
<div class="env-card-meta">
<span style="display:flex;align-items:center;gap:4px"><i data-lucide="ruler" class="icon-xs"></i> <strong>${mod.dev.content_length.toLocaleString()}</strong> chars</span>
<span style="display:flex;align-items:center;gap:4px"><i data-lucide="user" class="icon-xs"></i> ${mod.dev.created_by} · ${formatTime(mod.dev.created_at)}</span>
${mod.dev.notes ? `<span style="display:flex;align-items:center;gap:4px"><i data-lucide="file-text" class="icon-xs"></i> ${mod.dev.notes}</span>` : ''}
<span style="display:flex;align-items:center;gap:4px">${mod.dev.langfuse_synced ? '<span class="env-pill pill-synced" style="display:inline-flex;align-items:center;gap:4px"><i data-lucide="cloud-check" class="icon-xs"></i> Synced</span>' : '<span class="env-pill pill-unsynced" style="display:inline-flex;align-items:center;gap:4px"><i data-lucide="clock" class="icon-xs"></i> Not synced</span>'}</span>
</div>
<div class="env-content-preview">${escapeHtml(devContent || '')}</div>
` : '<div class="env-empty">Chưa có dev version.<br>Click "Edit Dev" hoặc "Import Files"</div>'}
</div>
<div class="env-card prod">
<div class="env-card-header">
<span class="env-card-title" style="display:flex;align-items:center;gap:6px"><i data-lucide="server" class="icon-sm"></i> Production</span>
${hasProd ? `<span class="env-pill pill-prod">v${mod.production.version}</span>` : ''}
</div>
${hasProd ? `
<div class="env-card-meta">
<span style="display:flex;align-items:center;gap:4px"><i data-lucide="ruler" class="icon-xs"></i> <strong>${mod.production.content_length.toLocaleString()}</strong> chars</span>
<span style="display:flex;align-items:center;gap:4px"><i data-lucide="user" class="icon-xs"></i> ${mod.production.created_by} · ${formatTime(mod.production.created_at)}</span>
${mod.production.notes ? `<span style="display:flex;align-items:center;gap:4px"><i data-lucide="file-text" class="icon-xs"></i> ${mod.production.notes}</span>` : ''}
<span style="display:flex;align-items:center;gap:4px">${mod.production.langfuse_synced ? '<span class="env-pill pill-synced" style="display:inline-flex;align-items:center;gap:4px"><i data-lucide="cloud-check" class="icon-xs"></i> Synced</span>' : '<span class="env-pill pill-unsynced" style="display:inline-flex;align-items:center;gap:4px"><i data-lucide="clock" class="icon-xs"></i> Not synced</span>'}</span>
</div>
<div class="env-content-preview">${escapeHtml(prodContent || '')}</div>
` : '<div class="env-empty">Chưa có production version.<br>Promote từ dev để tạo</div>'}
</div>
</div>
</div>`;
lucide.createIcons();
}
// ═══ HISTORY ═══════════════════════════════════════════════
async function loadHistory(moduleName) {
const el = document.getElementById('history-panel');
el.innerHTML = '<div class="loading">Loading...</div>';
try {
let versions = [];
try {
const res = await fetch(`${API}/modules/${moduleName}/versions?limit=20`);
const data = await res.json();
versions = data.versions || [];
} catch(err) {
console.warn("API history fetch failed, using mock data", err);
}
if (!versions.length) {
versions = getMockHistory(moduleName);
}
el.innerHTML = versions.map(v => {
const envClass = v.environment === 'production' ? 'pill-prod' : 'pill-dev';
const envLabel = v.environment === 'production' ? 'PROD' : 'DEV';
return `
<div class="history-item" onclick="viewVersion(${v.id})">
<div class="history-head">
<span class="history-ver">v${v.version}</span>
<span class="history-env ${envClass}">${envLabel}</span>
</div>
<div class="history-meta" style="display:flex;align-items:center;gap:4px;flex-wrap:wrap">
<i data-lucide="clock" class="icon-xs" style="color:var(--m)"></i> ${formatTime(v.created_at)}
· <i data-lucide="user" class="icon-xs" style="color:var(--m)"></i> ${v.created_by}
${v.is_active ? ' · <strong style="color:#059669;display:inline-flex;align-items:center;gap:2px"><i data-lucide="check-circle" class="icon-xs"></i> Active</strong>' : ''}
${v.langfuse_synced ? ' · <span style="display:inline-flex;align-items:center;gap:2px;color:#a78bfa"><i data-lucide="cloud" class="icon-xs"></i> Synced</span>' : ''}
</div>
${v.notes ? `<div class="history-meta" style="margin-top:2px;display:flex;align-items:center;gap:4px;"><i data-lucide="file-text" class="icon-xs"></i> ${v.notes}</div>` : ''}
</div>`;
}).join('');
lucide.createIcons();
} catch (e) {
el.innerHTML = `<p style="color:var(--error);padding:12px;font-size:12px">${e.message}</p>`;
}
}
async function viewVersion(id) {
try {
const res = await fetch(`${API}/versions/${id}`);
const data = await res.json();
const v = data.version;
alert(`Version #${v.id} (v${v.version}) [${v.environment}]\n\nBy: ${v.created_by}\nAt: ${v.created_at}\nNotes: ${v.notes || 'none'}\n\nContent (${v.content.length} chars):\n${v.content.substring(0, 500)}...`);
} catch (e) {
toast('Lỗi: ' + e.message, 'error');
}
}
// ═══ LANGFUSE ══════════════════════════════════════════════
async function checkLangfuseStatus() {
const dot = document.getElementById('lf-dot');
const text = document.getElementById('lf-status');
const panel = document.getElementById('langfuse-panel');
dot.className = 'status-dot checking';
text.textContent = 'Connecting...';
try {
const res = await fetch(`${API}/langfuse/status`);
const data = await res.json();
if (data.connected) {
dot.className = 'status-dot online';
text.textContent = `Connected (${data.prompt_count} prompts)`;
document.getElementById('stat-lf-count').textContent = data.prompt_count;
if (data.prompts && data.prompts.length) {
panel.innerHTML = data.prompts.map(p => `
<div class="lf-item">
<div class="lf-item-name">${p.name}</div>
<div class="lf-item-meta">
${(p.labels || []).map(l => `<span class="lf-label">${l}</span>`).join('')}
v${p.version || '?'}
${p.updated_at ? ' · ' + formatTime(p.updated_at) : ''}
</div>
</div>
`).join('');
} else {
panel.innerHTML = getMockLangfuseItems();
}
} else {
throw new Error(data.message || "Disconnected");
}
} catch (e) {
console.warn("Langfuse fetch failed, using mock data", e);
dot.className = 'status-dot online';
text.textContent = 'Connected (Mock)';
document.getElementById('stat-lf-count').textContent = '4';
panel.innerHTML = getMockLangfuseItems();
}
}
// ═══ ACTIONS ═══════════════════════════════════════════════
async function importFromFiles() {
try {
const res = await fetch(`${API}/import-from-files`, { method: 'POST' });
const data = await res.json();
toast(`✅ Imported: ${data.imported.system} system + ${data.imported.tool} tool modules`);
await loadModules();
} catch (e) {
toast('❌ Import lỗi: ' + e.message, 'error');
}
}
async function promoteVersion(versionId, moduleName) {
if (!confirm(`Promote ${moduleName} DEV → PRODUCTION?\n\nAction này sẽ tạo production version mới từ nội dung dev hiện tại.`)) return;
try {
const res = await fetch(`${API}/versions/${versionId}/promote`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ promoted_by: 'admin' }),
});
const data = await res.json();
toast(`🚀 ${data.message}`);
await loadModules();
if (selectedModule === moduleName) selectModule(moduleName);
} catch (e) {
toast('❌ Promote lỗi: ' + e.message, 'error');
}
}
async function syncToLangfuse(versionId, moduleName) {
if (!confirm(`Sync ${moduleName} production → Langfuse?\n\nPrompt sẽ được push lên Langfuse với label "production".`)) return;
try {
const res = await fetch(`${API}/langfuse/sync/${versionId}?label=production`, { method: 'POST' });
const data = await res.json();
toast(`☁️ ${data.message} (Langfuse v${data.langfuse_version || '?'})`);
await Promise.all([loadModules(), checkLangfuseStatus()]);
if (selectedModule === moduleName) selectModule(moduleName);
} catch (e) {
toast('❌ Sync lỗi: ' + e.message, 'error');
}
}
// ═══ DIFF MODAL ════════════════════════════════════════════
async function openDiffModal(moduleName) {
const mod = modules.find(m => m.module_name === moduleName);
if (!mod || !mod.dev || !mod.production) return;
document.getElementById('diff-title').textContent = `So sánh: ${moduleName}`;
document.getElementById('btn-promote-modal').onclick = () => {
closeDiffModal();
promoteVersion(mod.dev.id, moduleName);
};
const [devC, prodC] = await Promise.all([
fetchContent(mod.dev.id),
fetchContent(mod.production.id),
]);
document.getElementById('diff-dev').textContent = devC || '(empty)';
document.getElementById('diff-prod').textContent = prodC || '(empty)';
document.getElementById('diff-modal').classList.add('open');
}
function closeDiffModal() {
document.getElementById('diff-modal').classList.remove('open');
}
// ═══ EDITOR MODAL ══════════════════════════════════════════
let editorModuleName = '';
let editorModuleType = '';
async function openEditor(moduleName, moduleType) {
editorModuleName = moduleName;
editorModuleType = moduleType;
document.getElementById('editor-title').textContent = `Chỉnh sửa: ${moduleName}`;
document.getElementById('editor-notes').value = '';
// Load current dev content
const mod = modules.find(m => m.module_name === moduleName);
let content = '';
if (mod && mod.dev) {
content = await fetchContent(mod.dev.id) || '';
}
const textarea = document.getElementById('editor-content');
textarea.value = content;
updateEditorStats();
textarea.oninput = updateEditorStats;
document.getElementById('editor-modal').classList.add('open');
setTimeout(() => textarea.focus(), 100);
}
function closeEditorModal() {
document.getElementById('editor-modal').classList.remove('open');
}
function updateEditorStats() {
const content = document.getElementById('editor-content').value;
document.getElementById('editor-chars').textContent = content.length.toLocaleString() + ' chars';
document.getElementById('editor-lines').textContent = content.split('\n').length + ' lines';
}
async function saveEditorContent() {
const content = document.getElementById('editor-content').value.trim();
if (!content) { toast('Nội dung không được trống!', 'error'); return; }
const notes = document.getElementById('editor-notes').value.trim();
try {
const res = await fetch(`${API}/versions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
module_name: editorModuleName,
content: content,
module_type: editorModuleType,
environment: 'dev',
notes: notes || 'Manual edit',
}),
});
const data = await res.json();
toast(`💾 Saved ${editorModuleName} dev v${data.version.version}`);
closeEditorModal();
await loadModules();
selectModule(editorModuleName);
} catch (e) {
toast('❌ Save lỗi: ' + e.message, 'error');
}
}
function promoteFromModal() {
const mod = modules.find(m => m.module_name === selectedModule);
if (mod && mod.dev) {
closeDiffModal();
promoteVersion(mod.dev.id, selectedModule);
}
}
// ═══ HELPERS ═══════════════════════════════════════════════
function escapeHtml(str) {
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function formatTime(iso) {
if (!iso) return '';
const d = new Date(iso);
return `${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')} ${d.getDate()}/${d.getMonth()+1}`;
}
// ═══ MOCK DATA HELPERS ══════════════════════════════════════
function getMockModules() {
return [
{
module_name: "core_system",
module_type: "system",
in_sync: false,
dev: { id: 101, version: 7, content_length: 1250, created_by: "dev_user", created_at: new Date().toISOString(), notes: "Update fallback rule", langfuse_synced: false },
production: { id: 102, version: 5, content_length: 1200, created_by: "admin", created_at: new Date(Date.now() - 86400000).toISOString(), notes: "Stable release", langfuse_synced: true }
},
{
module_name: "product_search",
module_type: "tool",
in_sync: true,
dev: { id: 103, version: 3, content_length: 980, created_by: "ba_user", created_at: new Date().toISOString(), notes: "Thêm filter màu + size", langfuse_synced: true },
production: { id: 103, version: 3, content_length: 980, created_by: "ba_user", created_at: new Date().toISOString(), notes: "Thêm filter màu + size", langfuse_synced: true }
},
{
module_name: "order_lookup",
module_type: "tool",
in_sync: false,
dev: null,
production: { id: 104, version: 2, content_length: 450, created_by: "admin", created_at: new Date(Date.now() - 172800000).toISOString(), notes: "Fix typo", langfuse_synced: true }
},
{
module_name: "weather_context",
module_type: "system",
in_sync: false,
dev: { id: 105, version: 2, content_length: 300, created_by: "dev_user", created_at: new Date(Date.now() - 3600000).toISOString(), notes: "Add season mapping", langfuse_synced: false },
production: null
}
];
}
function getMockContent(versionId) {
if (versionId === 101) return "Bạn là trợ lý AI thông minh của Canifa...\n[DEV ONLY RULE]: Luôn xin lỗi nếu không có thông tin.";
if (versionId === 102) return "Bạn là trợ lý AI thông minh của Canifa...\nLuôn giữ thái độ chuyên nghiệp và lịch sự.";
if (versionId === 103) return "Tìm kiếm sản phẩm theo yêu cầu khách.\n\n## Parameters\n- query: string\n- category: string?\n- color: string?\n- size: string? (S/M/L/XL/XXL)\n- price_range: [min, max]?\n\n## Returns\nDanh sách sản phẩm phù hợp, tối đa 10 kết quả.";
if (versionId === 104) return "Tra cứu đơn hàng Canifa.\n- phone: SDT khách hàng\n- order_code: Mã đơn hàng (nếu có)";
if (versionId === 105) return "Context: Thời tiết hiện tại\nRule: Gợi ý đồ hợp thời tiết (Ví dụ: 30 độ thì gợi ý đồ mát).";
return "Mock content for version " + versionId;
}
function getMockHistory(moduleName) {
const now = new Date();
return [
{ id: 201, version: 3, environment: "dev", created_by: "dev_user", created_at: now.toISOString(), notes: "Latest test", is_active: true, langfuse_synced: false },
{ id: 202, version: 2, environment: "production", created_by: "admin", created_at: new Date(now - 86400000).toISOString(), notes: "Previous stable", is_active: true, langfuse_synced: true },
{ id: 203, version: 1, environment: "production", created_by: "admin", created_at: new Date(now - 172800000).toISOString(), notes: "Initial version", is_active: false, langfuse_synced: true }
];
}
function getMockLangfuseItems() {
const prompts = [
{ name: "core_system", labels: ["production"], version: 5, updated_at: new Date(Date.now() - 86400000).toISOString() },
{ name: "product_search", labels: ["production"], version: 3, updated_at: new Date().toISOString() },
{ name: "order_lookup", labels: ["production"], version: 2, updated_at: new Date(Date.now() - 172800000).toISOString() },
{ name: "fallback_handler", labels: ["production"], version: 3, updated_at: new Date(Date.now() - 100000000).toISOString() }
];
return prompts.map(p => `
<div class="lf-item">
<div class="lf-item-name">${p.name}</div>
<div class="lf-item-meta">
${(p.labels || []).map(l => `<span class="lf-label">${l}</span>`).join('')}
v${p.version || '?'}
${p.updated_at ? ' · ' + formatTime(p.updated_at) : ''}
</div>
</div>
`).join('');
}
function toast(msg, type = 'info') {
const el = document.getElementById('toast');
el.textContent = msg;
el.className = 'notif show';
if (type === 'error') el.style.background = '#DC2626';
else el.style.background = 'var(--t, #18181B)';
setTimeout(() => el.className = 'notif', 3000);
}
// ═══ Keyboard shortcuts ════════════════════════════════════
document.addEventListener('keydown', e => {
if (e.key === 'Escape') {
closeDiffModal();
closeEditorModal();
}
});
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