semblance-dev/src/lib/api.ts
Vadym Samoilenko 915c81b8f1 Complete phases D–G: quota enforcement, token invalidation, admin writes, backfill
Backend:
- token_version in JWT (bump_token_version, get_token_version on User model);
  jwt_required checks tv claim → 401 on mismatch; login routes embed version
- Quota pre-flight in all 3 LLM public methods (QuotaExceededError bubbles up)
- AI runner catches QuotaExceededError → sets status paused_quota + emits WS event
- Admin routes: POST /users (create), POST /users/<id>/reset-password,
  POST /pricing, GET /focus-groups with aggregated cost; PUT /users/<id>
  now bumps token_version on disable or role change
- backfill_usage.py: idempotent estimated-event generator for historical data,
  tiktoken for GPT models, char/3.8 for Gemini, --dry-run flag

Frontend:
- 402 interceptor dispatches quota_exceeded CustomEvent
- adminApi: createUser, resetPassword, createPricing, listFocusGroups
- UsersTab: New User dialog + Reset Password in edit dialog
- PricingTab: New Price dialog (model, provider, input/output/cached prices)
- FocusGroupsTab: focus groups table sorted by total cost
- Admin.tsx: 4th tab (Focus Groups)
- FocusGroupSession: admin-only cost badge + dismissable quota exceeded banner

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 18:34:48 +01:00

758 lines
No EOL
25 KiB
TypeScript
Executable file

import axios from 'axios';
// Base URL for API requests
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '/semblance_back/api';
// Create axios instance with baseURL
const api = axios.create({
baseURL: API_BASE_URL,
headers: {
'Content-Type': 'application/json'
},
// Default timeout for regular endpoints; AI endpoints override below
timeout: 120000 // 2 minutes
});
// Helper function to check if JWT token is expired
const isTokenExpired = (token: string): boolean => {
// Offline mode token is never expired
if (localStorage.getItem('offline_mode') === 'true') return false;
try {
const payload = JSON.parse(atob(token.split('.')[1]));
const currentTime = Date.now() / 1000;
return payload.exp < currentTime;
} catch (error) {
console.error('Error parsing JWT token:', error);
return true; // Treat malformed tokens as expired
}
};
// Request interceptor to add JWT token
api.interceptors.request.use(
(config) => {
const token = localStorage.getItem('auth_token');
if (token) {
// Check if token is expired before making request
if (isTokenExpired(token)) {
localStorage.removeItem('auth_token');
localStorage.removeItem('user');
localStorage.removeItem('auth_type');
// Dispatch auth error to trigger redirect
dispatchAuthError({ source: config.url });
// Reject the request
return Promise.reject(new Error('Token expired'));
}
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => Promise.reject(error)
);
// Create a custom event for auth errors
export const AUTH_ERROR_EVENT = 'auth_error';
// Custom event with details
export interface AuthErrorDetail {
source?: string;
isPersonaCreation?: boolean;
}
// Function to dispatch auth error event
export const dispatchAuthError = (details?: AuthErrorDetail) => {
// Only remove auth data if not a persona creation request
if (!details?.isPersonaCreation) {
// Clean up local storage auth data
localStorage.removeItem('auth_token');
localStorage.removeItem('user');
}
// Dispatch event for handling in auth context
const event = new CustomEvent(AUTH_ERROR_EVENT, {
detail: details || {}
});
window.dispatchEvent(event);
};
// Response interceptor to handle common errors
api.interceptors.response.use(
(response) => response,
(error) => {
// Handle 402 (Quota Exceeded)
if (error.response && error.response.status === 402) {
const data = error.response.data;
window.dispatchEvent(new CustomEvent('quota_exceeded', {
detail: { scope: data.scope, limit_usd: data.limit_usd, used_usd: data.used_usd }
}));
}
// Handle 401 (Unauthorized) - dispatch event instead of redirect
if (error.response && error.response.status === 401) {
// Check if this is a persona-related request - these are handled separately
const isPersonaRequest =
error.config &&
(error.config.url?.includes('/personas') ||
error.config.url?.includes('/personas/batch') ||
(error.config.method && error.config.url?.startsWith('/personas')));
// For persona-related requests, don't automatically log out
// Let the component handle the auth error
if (!isPersonaRequest) {
dispatchAuthError({
source: error.config?.url,
isPersonaCreation: false
});
} else {
console.warn('Authentication error in persona request, letting component handle it');
}
}
return Promise.reject(error);
}
);
// Auth endpoints
export const authApi = {
login: (username: string, password: string) =>
api.post('/auth/login', { username, password }),
loginWithMicrosoft: (idToken: string) =>
api.post('/auth/microsoft', { id_token: idToken }),
register: (username: string, email: string, password: string) =>
api.post('/auth/register', { username, email, password }),
getProfile: () =>
api.get('/auth/me')
};
// Personas endpoints
export const personasApi = {
getAll: () =>
api.get('/personas/all'),
getById: (id: string) =>
api.get(`/personas/${id}`),
create: (personaData: any) =>
api.post('/personas', personaData),
update: (id: string, personaData: any) => {
// Don't try to update with local-prefixed IDs - they won't work
if (id && id.startsWith('local-')) {
return api.post('/personas', personaData);
}
return api.put(`/personas/${id}`, personaData);
},
modifyWithAI: (id: string, modificationData: any) => {
const personaId = typeof id === 'object' && id !== null ? (id as any)._id || id : id;
return api.post(`/personas/${personaId}/modify-with-ai`, modificationData);
},
delete: (id: string) => {
// Handle different ID formats
// If the ID is an object with an _id property, use that
// Otherwise, use the provided ID string
const personaId = typeof id === 'object' && id !== null ? (id as any)._id || id : id;
return api.delete(`/personas/${personaId}`);
},
createBatch: (personasData: any[]) =>
api.post('/personas/batch', personasData),
// Export individual persona profile as markdown
exportProfile: (id: string, options?: { llm_model?: string; temperature?: number }) =>
api.post(`/personas/${id}/export-profile`, options || {}, {
timeout: 180000 // 3 minutes for profile export
}),
// Bulk export personas to specified format
bulkExportPersonas: (data: {
persona_ids: string[];
export_format: 'markdown' | 'json' | 'csv';
}) =>
api.post('/personas/bulk-export', data, {
timeout: 180000 // 3 minutes for bulk export
})
};
// AI Persona Generation endpoints
export const aiPersonasApi = {
// Legacy endpoints
// Generate a persona without saving
generate: (options?: any) =>
api.post('/ai-personas/generate', options || {}, {
timeout: 180000 // 3 minutes for single persona generation
}),
// Generate and immediately save to database
generateAndSave: (options?: any) =>
api.post('/ai-personas/generate-and-save', options || {}, {
timeout: 180000 // 3 minutes for single persona generation
}),
// Generate multiple personas without saving
batchGenerate: (options: { count: number, customizations?: any[], temperature?: number }) =>
api.post('/ai-personas/batch-generate', options, {
timeout: 180000 // 3 minutes for batch generation
}),
// Generate multiple personas and save to database
batchGenerateAndSave: (options: { count: number, customizations?: any[], temperature?: number }) =>
api.post('/ai-personas/batch-generate-and-save', options, {
timeout: 180000 // 3 minutes for batch generation and save
}),
// Two-stage generation endpoints
// First stage: Generate basic profiles
generateBasicProfiles: (
audienceBrief: string,
count: number = 5,
temperature: number = 0.8
) =>
api.post('/ai-personas/generate-basic-profiles', {
audience_brief: audienceBrief,
count,
temperature
}, {
timeout: 180000 // 3 minutes for basic profile generation
}),
// Second stage: Complete a single persona without saving
completePersona: (basicProfile: any, temperature: number = 0.7) =>
api.post('/ai-personas/complete-persona', {
basic_profile: basicProfile,
temperature
}, {
timeout: 180000 // 3 minutes for detailed persona generation
}),
// Second stage: Complete a single persona and save to database
completeAndSavePersona: (
basicProfile: any,
temperature: number = 0.7,
audienceBrief?: string,
researchObjective?: string,
customerDataSessionId?: string,
llmModel?: string
) =>
api.post('/ai-personas/complete-and-save-persona', {
basic_profile: basicProfile,
temperature,
audience_brief: audienceBrief,
research_objective: researchObjective,
customer_data_session_id: customerDataSessionId,
llm_model: llmModel
}, {
timeout: 180000 // 3 minutes for detailed persona generation and saving
}),
// Generate summary for an existing persona
generatePersonaSummary: (personaData: any, temperature: number = 0.7) =>
api.post('/ai-personas/generate-persona-summary', {
persona_data: personaData,
temperature
}, {
timeout: 180000 // 3 minutes for summary generation
}),
// Helper method for two-stage batch generation and save
batchGenerateWithStages: async (
audienceBrief: string,
researchObjective?: string,
count: number = 5,
temperature: number = 0.7,
customerDataSessionId?: string,
llmModel?: string,
onTaskIdReceived?: (taskId: string) => void
) => {
try {
// First stage: Generate basic profiles
const basicProfilesResponse = await api.post('/ai-personas/generate-basic-profiles', {
audience_brief: audienceBrief,
research_objective: researchObjective,
count,
temperature: 0.7, // Use 0.7 temperature for basic profiles
customer_data_session_id: customerDataSessionId,
llm_model: llmModel || 'gemini-2.5-pro'
}, {
timeout: 180000 // 3 minutes for basic profile generation
});
const basicProfiles = basicProfilesResponse.data.profiles;
const taskId = basicProfilesResponse.data.task_id; // Extract task_id from first call
// Call the callback immediately with the task ID
if (taskId && onTaskIdReceived) {
onTaskIdReceived(taskId);
}
const personas = [];
const personaIds = [];
const errors = [];
// Second stage: Complete each profile in parallel
const completeRequests = basicProfiles.map(profile =>
api.post('/ai-personas/complete-and-save-persona', {
basic_profile: profile,
temperature,
audience_brief: audienceBrief,
research_objective: researchObjective,
customer_data_session_id: customerDataSessionId,
llm_model: llmModel || 'gemini-2.5-pro'
}, {
timeout: 180000 // 3 minutes for each persona completion
})
);
// Wait for all requests to complete
const results = await Promise.allSettled(completeRequests);
// Process results
results.forEach((result, index) => {
if (result.status === 'fulfilled') {
personas.push(result.value.data.persona);
personaIds.push(result.value.data.persona_id);
} else {
const basicProfile = basicProfiles[index];
const errorInfo = {
index,
name: basicProfile.name || `Persona ${index + 1}`,
error: result.reason
};
errors.push(errorInfo);
console.error(`Failed to complete persona ${index + 1} (${basicProfile.name || 'unnamed'}):`, result.reason);
}
});
// Only fail completely if ALL personas failed
if (personas.length === 0 && errors.length > 0) {
throw new Error(`Failed to generate any personas. ${errors.length} profile(s) failed.`);
}
return {
data: {
message: `Generated and saved ${personas.length} personas${errors.length > 0 ? ` (${errors.length} failed)` : ''}`,
personas,
persona_ids: personaIds,
task_id: taskId,
errors: errors.length > 0 ? errors : undefined,
partial_success: errors.length > 0 && personas.length > 0
}
};
} catch (error) {
// For errors in the first stage
if (error.response?.status === 504 || error.code === "ECONNABORTED") {
throw new Error(`Timeout error: The server took too long to generate personas. Please try with fewer personas or try again later.`);
}
throw error;
}
},
// Enhance audience brief endpoint
enhanceAudienceBrief: (audienceBrief: string, researchObjective: string, temperature: number = 0.7) =>
api.post('/ai-personas/enhance-audience-brief', {
audience_brief: audienceBrief,
research_objective: researchObjective,
temperature
}, {
timeout: 180000 // 3 minutes timeout for AI enhancement
}),
// Batch generate summaries for download
batchGenerateSummaries: (personaIds: string[], temperature: number = 0.7, llmModel?: string) => {
return api.post('/ai-personas/batch-generate-summaries', {
persona_ids: personaIds,
temperature,
llm_model: llmModel || 'gemini-2.5-pro'
}, {
timeout: 180000 // 3 minutes timeout for batch processing
});
},
// Upload customer data files for parsing
uploadCustomerData: (files: FileList) => {
const formData = new FormData();
for (let i = 0; i < files.length; i++) {
formData.append('files', files[i]);
}
return api.post('/ai-personas/upload-customer-data', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
timeout: 180000 // 3 minutes for file upload and parsing
});
},
// Clean up customer data files for a session
cleanupCustomerData: (sessionId: string) =>
api.delete(`/ai-personas/cleanup-customer-data/${sessionId}`),
// Unified persona generation endpoint with cancellation support
generatePersonasFull: async (
audienceBrief: string,
researchObjective?: string,
count: number = 5,
temperature: number = 0.8,
customerDataSessionId?: string,
llmModel?: string,
targetFolderId?: string
) => {
return api.post('/ai-personas/generate-personas-full', {
audience_brief: audienceBrief,
research_objective: researchObjective,
count,
temperature,
customer_data_session_id: customerDataSessionId,
llm_model: llmModel || 'gemini-2.5-pro',
target_folder_id: targetFolderId
}, {
timeout: 10000 // 10 seconds — endpoint returns immediately with task_id
});
}
};
// Focus Groups endpoints
export const focusGroupsApi = {
getAll: () =>
api.get('/focus-groups'),
getById: (id: string) =>
api.get(`/focus-groups/${id}`),
create: (focusGroupData: any) =>
api.post('/focus-groups', focusGroupData),
update: (id: string, focusGroupData: any) =>
api.put(`/focus-groups/${id}`, focusGroupData),
delete: (id: string) =>
api.delete(`/focus-groups/${id}`),
addParticipant: (focusGroupId: string, personaId: string) =>
api.post(`/focus-groups/${focusGroupId}/participants`, { persona_id: personaId }),
removeParticipant: (focusGroupId: string, personaId: string) =>
api.delete(`/focus-groups/${focusGroupId}/participants/${personaId}`),
sendMessage: (focusGroupId: string, messageData: any) =>
api.post(`/focus-groups/${focusGroupId}/messages`, messageData),
getMessages: (focusGroupId: string) =>
api.get(`/focus-groups/${focusGroupId}/messages`),
updateMessageHighlight: (focusGroupId: string, messageId: string, highlighted: boolean) =>
api.patch(`/focus-groups/${focusGroupId}/messages/${messageId}`, { highlighted }),
describeAsset: (focusGroupId: string, assetFilename: string) =>
api.post(`/focus-groups/${focusGroupId}/describe-asset`, { asset_filename: assetFilename }, {
timeout: 120000 // 2 minutes timeout for AI description generation
}),
generateDiscussionGuide: (data: any) =>
api.post('/focus-groups/generate-discussion-guide', data, {
timeout: 180000 // 3 minutes timeout for AI generation
}),
generateDiscussionGuideForGroup: (focusGroupId: string, data: any) =>
api.post(`/focus-groups/${focusGroupId}/generate-discussion-guide`, data, {
timeout: 180000 // 3 minutes timeout for AI generation
}),
downloadDiscussionGuide: async (focusGroupId: string) => {
try {
const response = await api.get(`/focus-groups/${focusGroupId}/discussion-guide/download`, {
responseType: 'blob', // Important for file download
timeout: 30000 // 30 seconds timeout for download
});
// Extract filename from Content-Disposition header
const contentDisposition = response.headers['content-disposition'];
let filename = 'discussion-guide.md';
if (contentDisposition) {
const filenameMatch = contentDisposition.match(/filename="([^"]+)"/);
if (filenameMatch) {
filename = filenameMatch[1];
}
}
// Create blob URL and trigger download
const blob = new Blob([response.data], { type: 'text/markdown' });
const url = URL.createObjectURL(blob);
// Create temporary anchor element for download
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = filename;
anchor.style.display = 'none';
// Trigger download
document.body.appendChild(anchor);
anchor.click();
document.body.removeChild(anchor);
// Clean up
URL.revokeObjectURL(url);
return { success: true, filename };
} catch (error) {
console.error('Error downloading discussion guide:', error);
throw new Error('Failed to download discussion guide');
}
},
// Notes endpoints
createNote: (focusGroupId: string, noteData: any) =>
api.post(`/focus-groups/${focusGroupId}/notes`, noteData),
getNotes: (focusGroupId: string) =>
api.get(`/focus-groups/${focusGroupId}/notes`),
deleteNote: (focusGroupId: string, noteId: string) =>
api.delete(`/focus-groups/${focusGroupId}/notes/${noteId}`),
// Asset management endpoints
uploadAssets: (focusGroupId: string, formData: FormData, replace?: boolean) => {
// Add replace flag to form data if specified
if (replace === true) {
formData.append('replace', 'true');
}
return api.post(`/focus-groups/${focusGroupId}/assets`, formData, {
headers: {
'Content-Type': 'multipart/form-data'
},
timeout: 120000 // 2 minutes for file upload
});
},
getAssets: (focusGroupId: string) =>
api.get(`/focus-groups/${focusGroupId}/assets`),
getAssetUrl: (focusGroupId: string, filename: string) =>
`${API_BASE_URL}/focus-groups/${focusGroupId}/assets/${filename}`,
updateAssetName: (focusGroupId: string, filename: string, userAssignedName: string) =>
api.patch(`/focus-groups/${focusGroupId}/assets/${filename}`, {
user_assigned_name: userAssignedName
}),
deleteAsset: (focusGroupId: string, filename: string) =>
api.delete(`/focus-groups/${focusGroupId}/assets/${filename}`)
};
// Focus Group AI endpoints
export const focusGroupAiApi = {
generateResponse: (
focusGroupId: string,
personaId: string,
currentTopic: string,
temperature: number = 0.7
) =>
api.post('/focus-group-ai/generate-response', {
focus_group_id: focusGroupId,
persona_id: personaId,
current_topic: currentTopic,
temperature: temperature
}, {
timeout: 180000 // 3 minutes for AI response generation
}),
generateKeyThemes: (
focusGroupId: string,
temperature: number = 0.7
) =>
api.post('/focus-group-ai/generate-key-themes', {
focus_group_id: focusGroupId,
temperature: temperature
}, {
timeout: 180000 // 3 minutes for key theme generation
}),
getKeyThemes: (focusGroupId: string) =>
api.get(`/focus-group-ai/key-themes/${focusGroupId}`),
deleteKeyTheme: (focusGroupId: string, themeId: string) =>
api.delete(`/focus-group-ai/key-themes/${focusGroupId}/${themeId}`),
// AI Moderator endpoints
getModeratorStatus: (focusGroupId: string) =>
api.get(`/focus-group-ai/moderator/status/${focusGroupId}`),
advanceModeratorDiscussion: (focusGroupId: string) =>
api.post(`/focus-group-ai/moderator/advance/${focusGroupId}`, {}, {
timeout: 180000 // 3 minutes for AI moderator response
}),
setModeratorPosition: (focusGroupId: string, sectionId: string, itemId?: string) =>
api.put(`/focus-group-ai/moderator/position/${focusGroupId}`, {
section_id: sectionId,
item_id: itemId
}),
// Autonomous Conversation endpoints
startAutonomousConversation: (focusGroupId: string, initialPrompt?: string) =>
api.post(`/focus-group-ai/autonomous/start/${focusGroupId}`, {
initial_prompt: initialPrompt
}, {
timeout: 180000 // 3 minutes for autonomous conversation startup
}),
stopAutonomousConversation: (focusGroupId: string, reason?: string) =>
api.post(`/focus-group-ai/autonomous/stop/${focusGroupId}`, {
reason: reason
}),
getAutonomousConversationStatus: (focusGroupId: string) =>
api.get(`/focus-group-ai/autonomous/status/${focusGroupId}`),
// Conversation State and Analytics endpoints
getConversationState: (focusGroupId: string) =>
api.get(`/focus-group-ai/conversation/state/${focusGroupId}`),
getConversationAnalytics: (focusGroupId: string) =>
api.get(`/focus-group-ai/conversation/analytics/${focusGroupId}`),
makeConversationDecision: (focusGroupId: string, temperature: number = 0.7, mode: string = 'ai') =>
api.post(`/focus-group-ai/conversation/decision/${focusGroupId}`, {
temperature: temperature,
mode: mode
}, {
timeout: 10000 // Returns 202 immediately
}),
getConversationInsights: (focusGroupId: string) =>
api.get(`/focus-group-ai/conversation/insights/${focusGroupId}`, {
timeout: 180000 // LLM call, keep long timeout
}),
manualIntervention: (focusGroupId: string, action: string, message?: string, participantId?: string) =>
api.post(`/focus-group-ai/conversation/intervene/${focusGroupId}`, {
action: action,
message: message,
participant_id: participantId
}),
getReasoningHistory: (focusGroupId: string) =>
api.get(`/focus-group-ai/conversation/reasoning-history/${focusGroupId}`),
// Session ending endpoint
endSession: (focusGroupId: string, reason?: string) =>
api.post(`/focus-group-ai/moderator/end-session/${focusGroupId}`, {
reason: reason || 'session_ended'
})
};
// Folders endpoints
export const foldersApi = {
getAll: () =>
api.get('/folders'),
getById: (id: string) =>
api.get(`/folders/${id}`),
create: (folderData: any) =>
api.post('/folders', folderData),
update: (id: string, folderData: any) =>
api.put(`/folders/${id}`, folderData),
delete: (id: string) =>
api.delete(`/folders/${id}`),
addPersona: (folderId: string, personaId: string) =>
api.post(`/folders/${folderId}/personas`, { persona_id: personaId }),
removePersona: (folderId: string, personaId: string) =>
api.delete(`/folders/${folderId}/personas/${personaId}`),
addPersonasBatch: (folderId: string, personaIds: string[]) =>
api.post(`/folders/${folderId}/personas/batch`, { persona_ids: personaIds }),
removePersonasBatch: (folderId: string, personaIds: string[]) => {
return api.post(`/folders/${folderId}/personas/remove-batch`, {
persona_ids: personaIds
});
},
// Hierarchical operations
moveFolder: (folderId: string, parentFolderId: string | null) =>
api.put(`/folders/${folderId}/move`, { parent_folder_id: parentFolderId }),
getDescendants: (folderId: string) =>
api.get(`/folders/${folderId}/descendants`),
// New endpoints for multiple folder management
addPersonaToMultipleFolders: (personaId: string, folderIds: string[]) => {
// Add persona to multiple folders
const promises = folderIds.map(folderId =>
api.post(`/folders/${folderId}/personas`, { persona_id: personaId })
);
return Promise.all(promises);
},
removePersonaFromAllFolders: (personaId: string) => {
// This would require getting all folders first, then removing from each
// For now, we'll handle this on a per-folder basis in the UI
throw new Error("Use removePersona for specific folders");
}
};
// ─── Admin API ────────────────────────────────────────────────────────────────
export const adminApi = {
// Users
listUsers: (params?: { q?: string; role?: string; skip?: number; limit?: number }) =>
api.get('/admin/users', { params }),
getUser: (id: string) =>
api.get(`/admin/users/${id}`),
updateUser: (id: string, data: { role?: string; is_active?: boolean; quota?: { monthly_usd?: number }; override_quota?: boolean }) =>
api.put(`/admin/users/${id}`, data),
disableUser: (id: string) =>
api.post(`/admin/users/${id}/disable`),
enableUser: (id: string) =>
api.post(`/admin/users/${id}/enable`),
// Usage
usageSummary: (params?: { from?: string; to?: string; group_by?: string; user_id?: string; focus_group_id?: string }) =>
api.get('/admin/usage/summary', { params }),
usageEvents: (params?: { user_id?: string; focus_group_id?: string; feature?: string; skip?: number; limit?: number }) =>
api.get('/admin/usage/events', { params }),
// Pricing
listPricing: () =>
api.get('/admin/pricing'),
createPricing: (data: any) => api.post('/admin/pricing', data),
// Users — create + reset password
createUser: (data: { username: string; email: string; password: string; role?: string }) =>
api.post('/admin/users', data),
resetPassword: (id: string, password: string) =>
api.post(`/admin/users/${id}/reset-password`, { password }),
// Focus Groups (admin view)
listFocusGroups: (params?: { skip?: number; limit?: number }) =>
api.get('/admin/focus-groups', { params }),
};
export const usageApi = {
me: () => api.get('/auth/me'),
};
export default api;