Commit 910b67cb authored by Hoanganhvu123's avatar Hoanganhvu123

Integrate Clerk auth, memo retrieval tool, and chatbot UI

parent fbd1e348
......@@ -3,23 +3,24 @@ Tools Factory
Chỉ return 1 tool duy nhất: data_retrieval_tool
"""
from langchain_core.tools import Tool
from langchain_core.tools import BaseTool
from .brand_knowledge_tool import canifa_knowledge_search
from .customer_info_tool import collect_customer_info
from .data_retrieval_tool import data_retrieval_tool
from .memo_retrieval_tool import memo_retrieval_tool
def get_retrieval_tools() -> list[Tool]:
def get_retrieval_tools() -> list[BaseTool]:
"""Các tool chỉ dùng để đọc/truy vấn dữ liệu (Có thể cache)"""
return [data_retrieval_tool, canifa_knowledge_search]
return [data_retrieval_tool, canifa_knowledge_search, memo_retrieval_tool]
def get_collection_tools() -> list[Tool]:
def get_collection_tools() -> list[BaseTool]:
"""Các tool dùng để ghi/thu thập dữ liệu (KHÔNG cache)"""
return [collect_customer_info]
def get_all_tools() -> list[Tool]:
def get_all_tools() -> list[BaseTool]:
"""Return toàn bộ list tools cho Agent"""
return get_retrieval_tools() + get_collection_tools()
"""
Memo Retrieval Tool - đọc ghi chú từ SQLite memos.db cho Agent.
Dùng để trả lời các câu hỏi kiểu:
- "Hôm nay tôi đã note gì?"
- "Tuần trước tôi note những gì về dự án X?"
"""
from __future__ import annotations
import json
import logging
from datetime import date
from typing import Optional
from langchain_core.tools import tool
from memos_core import db as memo_db
logger = logging.getLogger(__name__)
@tool
async def memo_retrieval_tool(
date: str,
tag: Optional[str] = None,
) -> str:
"""
Truy vấn các memo đã note trong memos.db (SQLite).
- Ưu tiên dùng để trả lời câu hỏi về lịch sử note, ví dụ:
- "Hôm nay tôi đã note gì thế?"
- "Ngày 2026-01-15 tôi note những gì?"
- "Hôm nay tôi note gì về tag #work?"
- Không làm semantic search phức tạp, chỉ lọc theo ngày và tag.
"""
try:
target_date = date or date_today_iso()
logger.info("memo_retrieval_tool started, date=%s, tag=%s", target_date, tag)
sql = """
SELECT id, content, visibility, tags_json, created_at, updated_at
FROM memos
WHERE DATE(created_at) = ?
"""
params: list[object] = [target_date]
if tag:
# Lọc đơn giản: tags_json là chuỗi JSON array, tìm substring theo tag.
sql += " AND tags_json LIKE ?"
params.append(f"%{tag}%")
rows = await memo_db.fetch_all(sql, tuple(params))
memos = []
for row in rows:
# Chuẩn hóa tags
raw_tags = row.get("tags_json") or "[]"
try:
tags = json.loads(raw_tags)
if not isinstance(tags, list):
tags = []
except Exception:
tags = []
memos.append(
{
"id": row.get("id"),
"content": row.get("content"),
"visibility": row.get("visibility"),
"tags": tags,
"created_at": row.get("created_at"),
"updated_at": row.get("updated_at"),
}
)
return json.dumps(
{
"status": "success",
"date": target_date,
"tag": tag,
"count": len(memos),
"memos": memos,
},
ensure_ascii=False,
)
except Exception as exc: # pragma: no cover
logger.exception("memo_retrieval_tool error: %s", exc)
return json.dumps(
{
"status": "error",
"message": str(exc),
},
ensure_ascii=False,
)
def date_today_iso() -> str:
"""Return today's date in YYYY-MM-DD format."""
return date.today().isoformat()
from __future__ import annotations
import logging
from functools import lru_cache
from typing import Any
import jwt
from jwt import PyJWKClient
from config import CLERK_ISSUER, CLERK_JWKS_URL
logger = logging.getLogger(__name__)
@lru_cache(maxsize=1)
def _jwks_client() -> PyJWKClient:
if not CLERK_JWKS_URL:
raise ValueError("CLERK_JWKS_URL is not configured")
return PyJWKClient(CLERK_JWKS_URL)
def verify_clerk_jwt(token: str) -> dict[str, Any]:
"""
Verify Clerk JWT using JWKS.
Requires:
- CLERK_JWKS_URL: e.g. https://<your-clerk-domain>/.well-known/jwks.json
- CLERK_ISSUER: e.g. https://<your-clerk-domain>
"""
if not token:
raise ValueError("Missing token")
if not CLERK_JWKS_URL or not CLERK_ISSUER:
raise ValueError("Missing CLERK_JWKS_URL / CLERK_ISSUER")
signing_key = _jwks_client().get_signing_key_from_jwt(token).key
# Clerk tokens are typically RS256.
payload = jwt.decode(
token,
signing_key,
algorithms=["RS256"],
issuer=CLERK_ISSUER,
options={
"verify_aud": False, # allow multiple audiences in dev
},
)
return payload
......@@ -8,10 +8,11 @@ import logging
from collections.abc import Callable
from typing import TYPE_CHECKING
from fastapi import HTTPException, Request, status
from fastapi import Request
from starlette.middleware.base import BaseHTTPMiddleware
from common.clerk_auth import verify_clerk_jwt
from config import DISABLE_AUTH
from starlette.middleware.base import BaseHTTPMiddleware
if TYPE_CHECKING:
from fastapi import FastAPI
......@@ -50,7 +51,7 @@ RATE_LIMITED_PATHS = [
class CanifaAuthMiddleware(BaseHTTPMiddleware):
"""
Canifa Authentication + Rate Limit Middleware
Authentication + Rate Limit Middleware
Flow:
1. Frontend gửi request với Authorization: Bearer <canifa_token>
......@@ -81,7 +82,7 @@ class CanifaAuthMiddleware(BaseHTTPMiddleware):
return await call_next(request)
# =====================================================================
# STEP 1: AUTHENTICATION (Canifa API)
# STEP 1: AUTHENTICATION (Clerk JWT)
# =====================================================================
try:
auth_header = request.headers.get("Authorization")
......@@ -104,31 +105,26 @@ class CanifaAuthMiddleware(BaseHTTPMiddleware):
request.state.is_authenticated = False
request.state.device_id = device_id
else:
# --- TRƯỜNG HỢP 2: CÓ TOKEN -> GỌI CANIFA VERIFY ---
# --- TRƯỜNG HỢP 2: CÓ TOKEN -> VERIFY CLERK JWT ---
token = auth_header.replace("Bearer ", "")
from common.canifa_api import verify_canifa_token, extract_user_id_from_canifa_response
try:
user_data = await verify_canifa_token(token)
user_id = await extract_user_id_from_canifa_response(user_data)
payload = verify_clerk_jwt(token)
user_id = payload.get("sub")
if user_id:
request.state.user = user_data
request.state.user_id = user_id
request.state.user = payload
request.state.user_id = str(user_id)
request.state.token = token
request.state.is_authenticated = True
request.state.device_id = device_id
logger.debug(f"✅ Canifa Auth Success: User {user_id}")
request.state.device_id = device_id or str(user_id)
logger.debug("✅ Clerk Auth Success: user_id=%s", user_id)
else:
logger.warning(f"⚠️ Invalid Canifa Token -> Guest Mode")
logger.warning("⚠️ Clerk token missing sub -> Guest Mode")
request.state.user = None
request.state.user_id = None
request.state.is_authenticated = False
request.state.device_id = device_id
except Exception as e:
logger.error(f"❌ Canifa Auth Error: {e} -> Guest Mode")
logger.error("❌ Clerk Auth Error: %s -> Guest Mode", e)
request.state.user = None
request.state.user_id = None
request.state.is_authenticated = False
......@@ -169,8 +165,10 @@ class CanifaAuthMiddleware(BaseHTTPMiddleware):
if not can_send:
logger.warning(
f"⚠️ Rate Limit Exceeded: {rate_limit_key} | "
f"used={limit_info['used']}/{limit_info['limit']}"
"⚠️ Rate Limit Exceeded: %s | used=%s/%s",
rate_limit_key,
limit_info["used"],
limit_info["limit"],
)
return JSONResponse(
status_code=429,
......
......@@ -18,6 +18,8 @@ __all__ = [
"CHECKPOINT_POSTGRES_SCHEMA",
"CHECKPOINT_POSTGRES_URL",
"CLERK_SECRET_KEY",
"CLERK_JWKS_URL",
"CLERK_ISSUER",
"CONV_DATABASE_URL",
"CONV_SUPABASE_KEY",
"CONV_SUPABASE_URL",
......@@ -78,8 +80,8 @@ OPENAI_API_KEY: str | None = os.getenv("OPENAI_API_KEY")
GOOGLE_API_KEY: str | None = os.getenv("GOOGLE_API_KEY")
GROQ_API_KEY: str | None = os.getenv("GROQ_API_KEY")
DEFAULT_MODEL: str = os.getenv("DEFAULT_MODEL", "gpt-5-nano")
# DEFAULT_MODEL: str = os.getenv("DEFAULT_MODEL")
DEFAULT_MODEL: str = os.getenv("DEFAULT_MODEL", "gpt-4o-mini")
AI_MODEL_NAME = DEFAULT_MODEL
# ====================== JWT CONFIGURATION ======================
JWT_SECRET: str | None = os.getenv("JWT_SECRET")
......@@ -106,6 +108,8 @@ LANGSMITH_PROJECT = None
# ====================== CLERK AUTHENTICATION ======================
CLERK_SECRET_KEY: str | None = os.getenv("CLERK_SECRET_KEY")
CLERK_JWKS_URL: str | None = os.getenv("CLERK_JWKS_URL")
CLERK_ISSUER: str | None = os.getenv("CLERK_ISSUER")
# ====================== DATABASE CONNECTION ======================
# Redis Cache Configuration
......@@ -133,7 +137,6 @@ STARROCKS_PASSWORD: str | None = os.getenv("STARROCKS_PASSWORD")
STARROCKS_DB: str | None = os.getenv("STARROCKS_DB")
# Placeholder for backward compatibility if needed
AI_MODEL_NAME = DEFAULT_MODEL
# ====================== OPENTELEMETRY CONFIGURATION ======================
OTEL_EXPORTER_JAEGER_AGENT_HOST = os.getenv("OTEL_EXPORTER_JAEGER_AGENT_HOST")
OTEL_EXPORTER_JAEGER_AGENT_PORT = os.getenv("OTEL_EXPORTER_JAEGER_AGENT_PORT")
......
No preview for this file type
......@@ -94,6 +94,7 @@ Pygments==2.19.2
PyMySQL==1.1.2
pyscn==1.5.5
pytest==9.0.2
PyJWT==2.10.1
python-dotenv==1.2.1
python-multipart==0.0.20
python-engineio==4.12.3
......
VITE_CLERK_PUBLISHABLE_KEY=pk_test_Y29tbXVuYWwtc3VuYmVhbS0wLmNsZXJrLmFjY291bnRzLmRldiQ
CLERK_SECRET_KEY=sk_test_ek7ozVR80Qi9UdvhGaTmlXovS16GDuBDlDrpH1rkyQ
\ No newline at end of file
This diff is collapsed.
......@@ -10,6 +10,7 @@
"format": "biome format --write src"
},
"dependencies": {
"@clerk/clerk-react": "^6.36.8",
"@connectrpc/connect": "^2.1.1",
"@connectrpc/connect-web": "^2.1.1",
"@emotion/react": "^11.14.0",
......
......@@ -6,6 +6,7 @@ import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
import { getClerkSessionToken } from "@/utils/clerk";
import { generateUUID } from "@/utils/uuid";
type ProductSummary = {
......@@ -26,6 +27,10 @@ type ChatMessage = {
const DEVICE_STORAGE_KEY = "canifa_device_id";
// Call backend directly (bypass Vite proxy).
// Override via VITE_API_BASE_URL, e.g. "http://localhost:5000"
const API_ORIGIN = (import.meta.env.VITE_API_BASE_URL as string | undefined) || "http://localhost:5000";
const getDeviceId = (): string => {
if (typeof window === "undefined") return "unknown";
try {
......@@ -90,12 +95,12 @@ const ChatbotPanel = ({ className, variant = "inline", onClose }: ChatbotPanelPr
"Content-Type": "application/json",
device_id: deviceId,
};
const token = getAccessToken();
const token = (await getClerkSessionToken()) || getAccessToken();
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
const response = await fetch("/api/agent/chat", {
const response = await fetch(`${API_ORIGIN}/api/agent/chat`, {
method: "POST",
headers,
credentials: "include",
......
......@@ -27,8 +27,12 @@ import {
type UserWebhook,
} from "./types/proto/api/v1/user_service_pb";
import { redirectOnAuthFailure } from "./utils/auth-redirect";
import { getClerkSessionToken } from "./utils/clerk";
const API_BASE = "/api/v1";
// Call backend directly (bypass Vite proxy).
// Override via VITE_API_BASE_URL, e.g. "http://localhost:5000"
const API_ORIGIN = (import.meta.env.VITE_API_BASE_URL as string | undefined) || "http://localhost:5000";
const API_BASE = `${API_ORIGIN}/api/v1`;
type ApiMemo = {
id: number;
......@@ -102,7 +106,9 @@ const parseBody = async (response: Response): Promise<unknown> => {
};
const fetchJson = async <T>(path: string, options: RequestOptions = {}): Promise<T> => {
const token = getAccessToken();
// Prefer Clerk token if available; fallback to legacy token store.
const clerkToken = await getClerkSessionToken();
const token = clerkToken || getAccessToken();
const headers = new Headers(options.headers);
if (token) {
headers.set("Authorization", `Bearer ${token}`);
......@@ -368,7 +374,7 @@ export const instanceServiceClient = {
async getInstanceProfile(_request?: unknown): Promise<InstanceProfile> {
void _request;
const data = await fetchJson<ApiInstanceInfo>("/instance", { method: "GET" });
const instanceUrl = typeof window === "undefined" ? "" : window.location.origin;
const instanceUrl = API_ORIGIN;
return {
owner: "users/1",
version: typeof data?.version === "string" ? data.version : "",
......
import { Suspense, useEffect, useMemo } from "react";
import { Outlet, useLocation, useSearchParams } from "react-router-dom";
import { Outlet, useLocation, useSearchParams, Link } from "react-router-dom";
import usePrevious from "react-use/lib/usePrevious";
import { SignedIn, SignedOut, UserButton } from "@clerk/clerk-react";
import Navigation from "@/components/Navigation";
import ChatbotWidget from "@/components/ChatbotWidget";
import Spinner from "@/components/Spinner";
......@@ -58,6 +59,19 @@ const RootLayout = () => {
<Outlet />
</Suspense>
</main>
<div className="fixed top-4 right-4 z-50">
<SignedOut>
<Link
to="/auth"
className="rounded-full bg-primary px-3 py-2 text-sm font-medium text-primary-foreground shadow hover:bg-primary/90"
>
Sign in
</Link>
</SignedOut>
<SignedIn>
<UserButton />
</SignedIn>
</div>
<ChatbotWidget />
</div>
);
......
import "@github/relative-time-element";
import { QueryClientProvider } from "@tanstack/react-query";
import { ClerkProvider } from "@clerk/clerk-react";
import React, { useEffect, useRef } from "react";
import { createRoot } from "react-dom/client";
import { Toaster } from "react-hot-toast";
......@@ -51,8 +52,31 @@ function AppInitializer({ children }: { children: React.ReactNode }) {
}
function Main() {
const publishableKey = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY as string | undefined;
if (!publishableKey) {
return (
<div className="w-full min-h-svh flex items-center justify-center p-6">
<div className="max-w-xl w-full rounded-xl border border-border bg-background p-6 space-y-3">
<h1 className="text-xl font-semibold">Missing Clerk publishable key</h1>
<p className="text-sm text-muted-foreground">
App requires <code className="px-1 py-0.5 rounded bg-muted">VITE_CLERK_PUBLISHABLE_KEY</code>.
</p>
<div className="text-sm space-y-2">
<p>Create file <code className="px-1 py-0.5 rounded bg-muted">frontend/.env</code> with:</p>
<pre className="whitespace-pre-wrap rounded bg-muted p-3 text-xs">
VITE_CLERK_PUBLISHABLE_KEY=pk_test_...
VITE_API_BASE_URL=http://localhost:5000
</pre>
<p>Then restart Vite dev server.</p>
</div>
</div>
</div>
);
}
return (
<ErrorBoundary>
<ClerkProvider publishableKey={publishableKey}>
<QueryClientProvider client={queryClient}>
<InstanceProvider>
<AuthProvider>
......@@ -65,6 +89,7 @@ function Main() {
</AuthProvider>
</InstanceProvider>
</QueryClientProvider>
</ClerkProvider>
</ErrorBoundary>
);
}
......
import { SignedIn, SignedOut, SignIn } from "@clerk/clerk-react";
import { useEffect } from "react";
import { useNavigate } from "react-router-dom";
const AuthPage = () => {
const navigate = useNavigate();
// Nếu đã đăng nhập rồi thì đưa về trang chủ
useEffect(() => {
// nhỏ gọn: SignedIn phía dưới cũng handle, đây chỉ là fallback
}, [navigate]);
return (
<div className="w-full min-h-svh flex items-center justify-center bg-background px-4">
<div className="max-w-md w-full flex flex-col items-center gap-6">
<SignedOut>
<SignIn
routing="path"
path="/auth"
signUpUrl="/auth"
redirectUrl="/"
/>
</SignedOut>
<SignedIn>
<button
type="button"
className="rounded-full bg-primary px-4 py-2 text-sm font-medium text-primary-foreground shadow hover:bg-primary/90"
onClick={() => navigate("/")}
>
You are already signed in – Go to app
</button>
</SignedIn>
</div>
</div>
);
};
export default AuthPage;
......@@ -17,6 +17,7 @@ const Attachments = lazy(() => import("@/pages/Attachments"));
const Setting = lazy(() => import("@/pages/Setting"));
const UserProfile = lazy(() => import("@/pages/UserProfile"));
const MemoDetailRedirect = lazy(() => import("./MemoDetailRedirect"));
const AuthPage = lazy(() => import("@/pages/Auth"));
import { ROUTES } from "./routes";
......@@ -55,6 +56,7 @@ const router = createBrowserRouter([
{ path: "u/:username", element: <LazyRoute component={UserProfile} /> },
],
},
{ path: Routes.AUTH, element: <LazyRoute component={AuthPage} /> },
{ path: Routes.ATTACHMENTS, element: <LazyRoute component={Attachments} /> },
{ path: Routes.INBOX, element: <LazyRoute component={Inboxes} /> },
{ path: Routes.SETTING, element: <LazyRoute component={Setting} /> },
......
declare global {
interface Window {
Clerk?: {
session?: {
getToken: (options?: Record<string, unknown>) => Promise<string>;
};
};
}
}
/**
* Get a Clerk session JWT without React hooks (works from non-React modules).
* Returns null if Clerk isn't initialized or user isn't signed in.
*/
export async function getClerkSessionToken(): Promise<string | null> {
try {
const clerk = typeof window === "undefined" ? undefined : window.Clerk;
if (!clerk?.session?.getToken) return null;
const token = await clerk.session.getToken();
return token || null;
} catch {
return null;
}
}
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