Backend: - @active_required + @with_user_context applied to all LLM-invoking routes in personas.py, focus_group_ai.py, ai_personas.py - backend/app/routes/usage.py: GET /api/usage/me (MTD summary by feature), GET /api/usage/focus-groups/<id> (owner or admin) - Registered usage_bp in app/__init__.py - llm_service._record_usage now emits usage_update WS event to focus group room Frontend: - useMyUsage + useFocusGroupUsage hooks - MyUsage.tsx: personal billing dashboard (cost cards + per-feature table) - /billing route (ProtectedRoute) + Billing nav link - FocusGroupSession: quota_warning amber banner with Progress bar, quota_exceeded + quota_warning WS events wired via websocketServiceNew Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
182 lines
5.2 KiB
TypeScript
Executable file
182 lines
5.2 KiB
TypeScript
Executable file
/**
|
|
* Singleton WebSocket Service
|
|
*/
|
|
import { io, Socket } from "socket.io-client";
|
|
|
|
const BASE_URL = import.meta.env.DEV
|
|
? "http://localhost:5137"
|
|
: (import.meta.env.VITE_WEBSOCKET_URL || window.location.origin);
|
|
|
|
const SOCKET_PATH = import.meta.env.VITE_WEBSOCKET_PATH || "/semblance_back/socket.io/";
|
|
|
|
let socket: Socket | null = null;
|
|
let currentRoom: string | null = null;
|
|
let coreListenersBound = false;
|
|
|
|
export function initSocket(getToken: () => string): Socket {
|
|
if (socket) {
|
|
socket.io.opts.auth = { token: getToken() };
|
|
return socket;
|
|
}
|
|
|
|
socket = io(BASE_URL, {
|
|
path: SOCKET_PATH,
|
|
transports: ["websocket"],
|
|
reconnection: true,
|
|
autoConnect: false,
|
|
timeout: 60000,
|
|
pingInterval: 45000,
|
|
pingTimeout: 120000,
|
|
auth: (cb) => cb({ token: getToken() }),
|
|
});
|
|
|
|
socket.io.on("reconnect_attempt", () => {
|
|
socket!.io.opts.auth = { token: getToken() };
|
|
});
|
|
|
|
socket.on("connect", () => {
|
|
bindCoreListeners();
|
|
if (currentRoom) rejoinRoom();
|
|
});
|
|
|
|
socket.onAny((event, ...args) => {
|
|
const payload = args[0];
|
|
|
|
switch (event) {
|
|
case 'joined_focus_group':
|
|
window.dispatchEvent(new CustomEvent("ws:joined_focus_group", { detail: payload }));
|
|
break;
|
|
case 'left_focus_group':
|
|
window.dispatchEvent(new CustomEvent("ws:left_focus_group", { detail: payload }));
|
|
break;
|
|
case 'message_update':
|
|
window.dispatchEvent(new CustomEvent("ws:message_update", { detail: payload }));
|
|
break;
|
|
case 'ai_status_update':
|
|
window.dispatchEvent(new CustomEvent("ws:ai_status_update", { detail: payload }));
|
|
break;
|
|
case 'moderator_status_update':
|
|
window.dispatchEvent(new CustomEvent("ws:moderator_status_update", { detail: payload }));
|
|
break;
|
|
case 'theme_update':
|
|
window.dispatchEvent(new CustomEvent("ws:theme_update", { detail: payload }));
|
|
break;
|
|
case 'focus_group_update':
|
|
window.dispatchEvent(new CustomEvent("ws:focus_group_update", { detail: payload }));
|
|
break;
|
|
case 'mode_event_update':
|
|
window.dispatchEvent(new CustomEvent("ws:mode_event_update", { detail: payload }));
|
|
break;
|
|
case 'task_started':
|
|
window.dispatchEvent(new CustomEvent("ws:task_started", { detail: payload }));
|
|
break;
|
|
case 'task_cancelled':
|
|
window.dispatchEvent(new CustomEvent("ws:task_cancelled", { detail: payload }));
|
|
break;
|
|
case 'task_completed':
|
|
window.dispatchEvent(new CustomEvent("ws:task_completed", { detail: payload }));
|
|
break;
|
|
case 'task_failed':
|
|
window.dispatchEvent(new CustomEvent("ws:task_failed", { detail: payload }));
|
|
break;
|
|
case 'bulk_export_progress':
|
|
window.dispatchEvent(new CustomEvent("ws:bulk_export_progress", { detail: payload }));
|
|
break;
|
|
case 'quota_warning':
|
|
window.dispatchEvent(new CustomEvent("quota_warning", { detail: payload }));
|
|
break;
|
|
case 'quota_exceeded':
|
|
window.dispatchEvent(new CustomEvent("quota_exceeded", { detail: payload }));
|
|
break;
|
|
case 'error':
|
|
console.error('[WebSocket] Error:', payload);
|
|
break;
|
|
case 'auth_error':
|
|
console.error('[WebSocket] Auth error');
|
|
window.dispatchEvent(new CustomEvent("ws:auth_error", { detail: payload }));
|
|
break;
|
|
}
|
|
});
|
|
|
|
socket.on("connect_error", (error) => {
|
|
console.error('[WebSocket] Connect error:', error.message);
|
|
});
|
|
|
|
socket.on("disconnect", (_reason) => {
|
|
});
|
|
|
|
return socket;
|
|
}
|
|
|
|
export function connectSocket() {
|
|
if (socket && !socket.connected) {
|
|
socket.connect();
|
|
}
|
|
}
|
|
|
|
export function disconnectSocket() {
|
|
if (socket) {
|
|
socket.disconnect();
|
|
currentRoom = null;
|
|
}
|
|
}
|
|
|
|
export function joinFocusGroup(focus_group_id: string, ack?: (resp: any) => void) {
|
|
currentRoom = focus_group_id;
|
|
|
|
if (!socket?.connected) {
|
|
connectSocket();
|
|
setTimeout(() => {
|
|
if (socket?.connected) {
|
|
socket.emit("join_focus_group", { focus_group_id }, (resp: any) => ack?.(resp));
|
|
}
|
|
}, 1000);
|
|
return;
|
|
}
|
|
|
|
socket.emit("join_focus_group", { focus_group_id }, (resp: any) => ack?.(resp));
|
|
}
|
|
|
|
export function leaveFocusGroup(focus_group_id: string) {
|
|
if (currentRoom === focus_group_id) {
|
|
currentRoom = null;
|
|
}
|
|
if (socket?.connected) {
|
|
socket.emit("leave_focus_group", { focus_group_id });
|
|
}
|
|
}
|
|
|
|
function rejoinRoom() {
|
|
if (!socket?.connected || !currentRoom) return;
|
|
socket.emit("join_focus_group", { focus_group_id: currentRoom });
|
|
}
|
|
|
|
function bindCoreListeners() {
|
|
if (!socket) return;
|
|
|
|
// Already bound — listeners are re-added via onAny, no need to duplicate
|
|
if (coreListenersBound) return;
|
|
|
|
socket.on("auth_error", (payload: any) => {
|
|
console.error('[WebSocket] Auth error');
|
|
window.dispatchEvent(new CustomEvent("ws:auth_error", { detail: payload }));
|
|
});
|
|
|
|
socket.on("error", (error: any) => {
|
|
console.error('[WebSocket] Socket error:', error?.message || error);
|
|
});
|
|
|
|
coreListenersBound = true;
|
|
}
|
|
|
|
export function getSocket() {
|
|
return socket;
|
|
}
|
|
|
|
export function getSocketId() {
|
|
return socket?.id;
|
|
}
|
|
|
|
export function isConnected() {
|
|
return socket?.connected ?? false;
|
|
}
|