Phase 5: Notifications — WebSocket, APScheduler, AI tool, health review
Backend: - Notification model + Alembic migration - Notification service: CRUD, mark read, unread count, pending scheduled - WebSocket manager singleton for real-time push - WebSocket endpoint /ws/notifications with JWT auth via query param - APScheduler integration: periodic notification sender (every 60s), daily proactive health review job (8 AM) - AI tool: schedule_notification (immediate or scheduled) - Health review worker: analyzes user memory via Claude, creates ai_generated notifications with WebSocket push Frontend: - Notification API client + Zustand store - WebSocket hook with auto-reconnect (exponential backoff) - Notification bell in header with unread count badge + dropdown - Notifications page with type badges, mark read, mark all read - WebSocket initialized in AppLayout for app-wide real-time updates - Enabled notifications nav in sidebar - English + Russian translations Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -90,6 +90,19 @@
|
||||
"subtitle": "This context is added to all your AI conversations",
|
||||
"placeholder": "Add personal information that the AI should know about you..."
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notifications",
|
||||
"no_notifications": "No notifications yet.",
|
||||
"mark_all_read": "Mark all read",
|
||||
"mark_read": "Mark as read",
|
||||
"view_all": "View all notifications",
|
||||
"types": {
|
||||
"reminder": "Reminder",
|
||||
"alert": "Alert",
|
||||
"info": "Info",
|
||||
"ai_generated": "AI Generated"
|
||||
}
|
||||
},
|
||||
"documents": {
|
||||
"upload": "Upload",
|
||||
"drop_or_click": "Drop a file here or click to browse",
|
||||
|
||||
@@ -90,6 +90,19 @@
|
||||
"subtitle": "Этот контекст добавляется ко всем вашим разговорам с ИИ",
|
||||
"placeholder": "Добавьте личную информацию, которую ИИ должен знать о вас..."
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Уведомления",
|
||||
"no_notifications": "Уведомлений пока нет.",
|
||||
"mark_all_read": "Отметить все как прочитанные",
|
||||
"mark_read": "Отметить как прочитанное",
|
||||
"view_all": "Все уведомления",
|
||||
"types": {
|
||||
"reminder": "Напоминание",
|
||||
"alert": "Оповещение",
|
||||
"info": "Информация",
|
||||
"ai_generated": "От ИИ"
|
||||
}
|
||||
},
|
||||
"documents": {
|
||||
"upload": "Загрузить",
|
||||
"drop_or_click": "Перетащите файл или нажмите для выбора",
|
||||
|
||||
44
frontend/src/api/notifications.ts
Normal file
44
frontend/src/api/notifications.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import api from "./client";
|
||||
|
||||
export interface Notification {
|
||||
id: string;
|
||||
user_id: string;
|
||||
title: string;
|
||||
body: string;
|
||||
type: string;
|
||||
channel: string;
|
||||
status: string;
|
||||
scheduled_at: string | null;
|
||||
sent_at: string | null;
|
||||
read_at: string | null;
|
||||
metadata: Record<string, unknown> | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface NotificationListResponse {
|
||||
notifications: Notification[];
|
||||
unread_count: number;
|
||||
}
|
||||
|
||||
export async function getNotifications(params?: {
|
||||
status?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}): Promise<NotificationListResponse> {
|
||||
const { data } = await api.get<NotificationListResponse>("/notifications/", { params });
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getUnreadCount(): Promise<number> {
|
||||
const { data } = await api.get<{ count: number }>("/notifications/unread-count");
|
||||
return data.count;
|
||||
}
|
||||
|
||||
export async function markAsRead(notificationId: string): Promise<Notification> {
|
||||
const { data } = await api.patch<Notification>(`/notifications/${notificationId}/read`);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function markAllRead(): Promise<void> {
|
||||
await api.post("/notifications/mark-all-read");
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
import { Outlet } from "react-router-dom";
|
||||
import { Sidebar } from "./sidebar";
|
||||
import { Header } from "./header";
|
||||
import { useNotificationsWS } from "@/hooks/use-notifications-ws";
|
||||
|
||||
export function AppLayout() {
|
||||
useNotificationsWS();
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden">
|
||||
<Sidebar />
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Menu, Sun, Moon, LogOut, User } from "lucide-react";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useUIStore } from "@/stores/ui-store";
|
||||
import { LanguageToggle } from "@/components/shared/language-toggle";
|
||||
import { NotificationBell } from "@/components/notifications/notification-bell";
|
||||
import { logout as logoutApi } from "@/api/auth";
|
||||
|
||||
export function Header() {
|
||||
@@ -41,6 +42,8 @@ export function Header() {
|
||||
|
||||
<LanguageToggle />
|
||||
|
||||
<NotificationBell />
|
||||
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="rounded-md p-2 text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
|
||||
|
||||
@@ -21,7 +21,7 @@ const navItems = [
|
||||
{ key: "personal_context", to: "/profile/context", icon: BookOpen, enabled: true, end: true },
|
||||
{ key: "documents", to: "/documents", icon: FileText, enabled: true, end: true },
|
||||
{ key: "memory", to: "/memory", icon: Brain, enabled: true, end: true },
|
||||
{ key: "notifications", to: "/notifications", icon: Bell, enabled: false, end: true },
|
||||
{ key: "notifications", to: "/notifications", icon: Bell, enabled: true, end: true },
|
||||
];
|
||||
|
||||
const adminItems = [
|
||||
|
||||
93
frontend/src/components/notifications/notification-bell.tsx
Normal file
93
frontend/src/components/notifications/notification-bell.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Bell, Check } from "lucide-react";
|
||||
import { useNotificationStore } from "@/stores/notification-store";
|
||||
import { markAllRead as markAllReadApi } from "@/api/notifications";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function NotificationBell() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { notifications, unreadCount, markAllRead } = useNotificationStore();
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Close on outside click
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", handler);
|
||||
return () => document.removeEventListener("mousedown", handler);
|
||||
}, []);
|
||||
|
||||
const handleMarkAllRead = async () => {
|
||||
await markAllReadApi();
|
||||
markAllRead();
|
||||
};
|
||||
|
||||
const recent = notifications.slice(0, 5);
|
||||
|
||||
return (
|
||||
<div className="relative" ref={ref}>
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
className="relative rounded-md p-2 text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
|
||||
>
|
||||
<Bell className="h-5 w-5" />
|
||||
{unreadCount > 0 && (
|
||||
<span className="absolute -right-0.5 -top-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-destructive px-1 text-[10px] font-bold text-white">
|
||||
{unreadCount > 99 ? "99+" : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute right-0 top-12 z-50 w-80 rounded-xl border bg-card shadow-lg">
|
||||
<div className="flex items-center justify-between border-b px-4 py-3">
|
||||
<span className="text-sm font-semibold">{t("notifications.title")}</span>
|
||||
{unreadCount > 0 && (
|
||||
<button
|
||||
onClick={handleMarkAllRead}
|
||||
className="flex items-center gap-1 text-xs text-primary hover:underline"
|
||||
>
|
||||
<Check className="h-3 w-3" /> {t("notifications.mark_all_read")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="max-h-72 overflow-y-auto">
|
||||
{recent.length === 0 ? (
|
||||
<p className="px-4 py-6 text-center text-sm text-muted-foreground">
|
||||
{t("notifications.no_notifications")}
|
||||
</p>
|
||||
) : (
|
||||
recent.map((n) => (
|
||||
<div
|
||||
key={n.id}
|
||||
className={cn(
|
||||
"border-b px-4 py-3 last:border-0",
|
||||
!n.read_at && "bg-primary/5"
|
||||
)}
|
||||
>
|
||||
<p className="text-sm font-medium">{n.title}</p>
|
||||
<p className="text-xs text-muted-foreground line-clamp-2">{n.body}</p>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t px-4 py-2">
|
||||
<button
|
||||
onClick={() => { navigate("/notifications"); setOpen(false); }}
|
||||
className="w-full text-center text-xs text-primary hover:underline"
|
||||
>
|
||||
{t("notifications.view_all")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
67
frontend/src/hooks/use-notifications-ws.ts
Normal file
67
frontend/src/hooks/use-notifications-ws.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useNotificationStore } from "@/stores/notification-store";
|
||||
|
||||
export function useNotificationsWS() {
|
||||
const accessToken = useAuthStore((s) => s.accessToken);
|
||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||
const { setUnreadCount, addNotification } = useNotificationStore();
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const reconnectDelay = useRef(1000);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated || !accessToken) return;
|
||||
|
||||
let mounted = true;
|
||||
|
||||
function connect() {
|
||||
if (!mounted) return;
|
||||
|
||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const ws = new WebSocket(
|
||||
`${protocol}//${window.location.host}/api/v1/ws/notifications?token=${accessToken}`
|
||||
);
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
setIsConnected(true);
|
||||
reconnectDelay.current = 1000;
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.type === "unread_count") {
|
||||
setUnreadCount(data.count);
|
||||
} else if (data.type === "new_notification") {
|
||||
addNotification(data.notification);
|
||||
}
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
setIsConnected(false);
|
||||
if (mounted) {
|
||||
setTimeout(connect, reconnectDelay.current);
|
||||
reconnectDelay.current = Math.min(reconnectDelay.current * 2, 30000);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
ws.close();
|
||||
};
|
||||
}
|
||||
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
wsRef.current?.close();
|
||||
};
|
||||
}, [isAuthenticated, accessToken, setUnreadCount, addNotification]);
|
||||
|
||||
return { isConnected };
|
||||
}
|
||||
96
frontend/src/pages/notifications.tsx
Normal file
96
frontend/src/pages/notifications.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { getNotifications, markAsRead, markAllRead as markAllReadApi } from "@/api/notifications";
|
||||
import { useNotificationStore } from "@/stores/notification-store";
|
||||
import { Check, Bell } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const typeColors: Record<string, string> = {
|
||||
reminder: "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400",
|
||||
alert: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400",
|
||||
info: "bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-400",
|
||||
ai_generated: "bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400",
|
||||
};
|
||||
|
||||
export function NotificationsPage() {
|
||||
const { t } = useTranslation();
|
||||
const { notifications, setNotifications, setUnreadCount, markRead, markAllRead } = useNotificationStore();
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: ["notifications"],
|
||||
queryFn: () => getNotifications({ limit: 100 }),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
setNotifications(data.notifications);
|
||||
setUnreadCount(data.unread_count);
|
||||
}
|
||||
}, [data, setNotifications, setUnreadCount]);
|
||||
|
||||
const handleMarkRead = async (id: string) => {
|
||||
await markAsRead(id);
|
||||
markRead(id);
|
||||
};
|
||||
|
||||
const handleMarkAllRead = async () => {
|
||||
await markAllReadApi();
|
||||
markAllRead();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-semibold">{t("notifications.title")}</h1>
|
||||
<button
|
||||
onClick={handleMarkAllRead}
|
||||
className="inline-flex h-9 items-center gap-2 rounded-md border px-4 text-sm font-medium hover:bg-accent transition-colors"
|
||||
>
|
||||
<Check className="h-4 w-4" /> {t("notifications.mark_all_read")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{notifications.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
|
||||
<Bell className="h-12 w-12 mb-4 opacity-30" />
|
||||
<p>{t("notifications.no_notifications")}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{notifications.map((n) => (
|
||||
<div
|
||||
key={n.id}
|
||||
className={cn(
|
||||
"flex items-start gap-4 rounded-lg border bg-card p-4 transition-colors",
|
||||
!n.read_at && "bg-primary/5 border-primary/20"
|
||||
)}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<p className="font-medium">{n.title}</p>
|
||||
<span className={cn("rounded-full px-2 py-0.5 text-xs font-medium", typeColors[n.type] || typeColors.info)}>
|
||||
{t(`notifications.types.${n.type}`)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{n.body}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{new Date(n.created_at).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
{!n.read_at && (
|
||||
<button
|
||||
onClick={() => handleMarkRead(n.id)}
|
||||
className="shrink-0 rounded p-2 hover:bg-accent transition-colors"
|
||||
title={t("notifications.mark_read")}
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { AdminContextPage } from "@/pages/admin/context";
|
||||
import { AdminSkillsPage } from "@/pages/admin/skills";
|
||||
import { DocumentsPage } from "@/pages/documents";
|
||||
import { MemoryPage } from "@/pages/memory";
|
||||
import { NotificationsPage } from "@/pages/notifications";
|
||||
import { NotFoundPage } from "@/pages/not-found";
|
||||
|
||||
export const router = createBrowserRouter([
|
||||
@@ -33,6 +34,7 @@ export const router = createBrowserRouter([
|
||||
{ path: "chat/:chatId", element: <ChatPage /> },
|
||||
{ path: "documents", element: <DocumentsPage /> },
|
||||
{ path: "memory", element: <MemoryPage /> },
|
||||
{ path: "notifications", element: <NotificationsPage /> },
|
||||
{ path: "skills", element: <SkillsPage /> },
|
||||
{ path: "profile/context", element: <PersonalContextPage /> },
|
||||
{ path: "admin/context", element: <AdminContextPage /> },
|
||||
|
||||
40
frontend/src/stores/notification-store.ts
Normal file
40
frontend/src/stores/notification-store.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { create } from "zustand";
|
||||
import type { Notification } from "@/api/notifications";
|
||||
|
||||
interface NotificationState {
|
||||
notifications: Notification[];
|
||||
unreadCount: number;
|
||||
setNotifications: (notifications: Notification[]) => void;
|
||||
setUnreadCount: (count: number) => void;
|
||||
addNotification: (notification: Notification) => void;
|
||||
markRead: (id: string) => void;
|
||||
markAllRead: () => void;
|
||||
}
|
||||
|
||||
export const useNotificationStore = create<NotificationState>()((set) => ({
|
||||
notifications: [],
|
||||
unreadCount: 0,
|
||||
setNotifications: (notifications) => set({ notifications }),
|
||||
setUnreadCount: (count) => set({ unreadCount: count }),
|
||||
addNotification: (notification) =>
|
||||
set((s) => ({
|
||||
notifications: [notification, ...s.notifications],
|
||||
unreadCount: s.unreadCount + 1,
|
||||
})),
|
||||
markRead: (id) =>
|
||||
set((s) => ({
|
||||
notifications: s.notifications.map((n) =>
|
||||
n.id === id ? { ...n, status: "read", read_at: new Date().toISOString() } : n
|
||||
),
|
||||
unreadCount: Math.max(0, s.unreadCount - 1),
|
||||
})),
|
||||
markAllRead: () =>
|
||||
set((s) => ({
|
||||
notifications: s.notifications.map((n) => ({
|
||||
...n,
|
||||
status: "read",
|
||||
read_at: n.read_at || new Date().toISOString(),
|
||||
})),
|
||||
unreadCount: 0,
|
||||
})),
|
||||
}));
|
||||
Reference in New Issue
Block a user