feat: commit all app changes — billing API, new auth, design overhaul
All checks were successful
Deploy to Production / deploy (push) Successful in 2m23s

Includes frontend redesign (Navigation, billingApi), backend updates
(auth routes, admin routes, LLM service refactor), MSAL removal,
and dependency updates.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Vadym Samoilenko 2026-05-23 19:04:43 +01:00
parent 0b88d0a2a5
commit e01569c412
49 changed files with 848 additions and 2326 deletions

View file

@ -1,5 +1,5 @@
# MongoDB Configuration
MONGO_URI=mongodb://localhost:27017/semblance_db
MONGO_URI=mongodb://localhost:27017/cohorta_db
# MongoDB auth (uncomment if your MongoDB requires authentication)
# MONGO_USER=admin
@ -11,10 +11,17 @@ DEBUG=0
SECRET_KEY=REPLACE_WITH_RANDOM_SECRET
JWT_SECRET_KEY=REPLACE_WITH_RANDOM_SECRET
# AI API Keys
OPENAI_API_KEY=REPLACE_WITH_KEY
GEMINI_API_KEY=REPLACE_WITH_KEY
# Azure AI Foundry — base URL ending at /v1/ (do NOT include the operation path)
# Example: https://<project>.services.ai.azure.com/api/projects/<name>/openai/v1/
AZURE_AI_ENDPOINT=REPLACE_WITH_AZURE_ENDPOINT
AZURE_AI_API_KEY=REPLACE_WITH_AZURE_KEY
# Optional: override deployed model names (defaults below match the Foundry deployments)
AZURE_AI_MODEL_MAIN=gpt-5.4
AZURE_AI_MODEL_MINI=gpt-5.4-mini
# Microsoft Azure (optional, for MS login)
# MSAL_TENANT_ID=your-tenant-id
# MSAL_CLIENT_ID=your-client-id
# CORS — comma-separated allowed origins
CORS_ALLOWED_ORIGINS=https://cohorta.ai-impress.com
# Stripe (get from dashboard.stripe.com — use test keys locally)
STRIPE_SECRET_KEY=REPLACE_WITH_STRIPE_SECRET_KEY
STRIPE_WEBHOOK_SECRET=REPLACE_WITH_STRIPE_WEBHOOK_SECRET

View file

@ -94,7 +94,7 @@ def create_app():
# Initialize extensions — restrict CORS to known origins
_allowed_origins = os.environ.get(
'CORS_ALLOWED_ORIGINS',
'https://optical-dev.oliver.solutions'
'https://cohorta.ai-impress.com'
)
_origins = [o.strip() for o in _allowed_origins.split(',')]
app = cors(app, allow_origin=_origins, allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"])
@ -149,6 +149,7 @@ def create_app():
from app.routes.tasks import tasks_bp
from app.routes.admin import admin_bp
from app.routes.usage import usage_bp
from app.routes.billing import billing_bp
app.register_blueprint(auth_bp, url_prefix='/api/auth')
app.register_blueprint(personas_bp, url_prefix='/api/personas')
@ -159,6 +160,7 @@ def create_app():
app.register_blueprint(tasks_bp, url_prefix='/api/tasks')
app.register_blueprint(admin_bp, url_prefix='/api/admin')
app.register_blueprint(usage_bp, url_prefix='/api/usage')
app.register_blueprint(billing_bp, url_prefix='/api/billing')
@app.before_serving
async def start_task_sweeper():

View file

@ -32,13 +32,13 @@ async def get_db():
# Build URI: prefer MONGO_URI, fall back to host+port with optional credentials
if not mongo_uri:
if mongo_user and mongo_pass:
mongo_uri = f"mongodb://{mongo_user}:{mongo_pass}@{mongo_host}:{mongo_port}/semblance_db?authSource=admin"
mongo_uri = f"mongodb://{mongo_user}:{mongo_pass}@{mongo_host}:{mongo_port}/cohorta_db?authSource=admin"
else:
mongo_uri = f"mongodb://{mongo_host}:{mongo_port}"
try:
motor_client = AsyncIOMotorClient(mongo_uri, serverSelectionTimeoutMS=5000)
database = motor_client.semblance_db
database = motor_client.cohorta_db
await database.command('ping')
logging.info("Successfully connected to MongoDB")
except Exception as e:

View file

@ -52,7 +52,7 @@ class FocusGroup:
# Set default LLM model if not provided
if "llm_model" not in focus_group_data:
focus_group_data["llm_model"] = "gemini-3.1-pro-preview"
focus_group_data["llm_model"] = "gpt-5.4"
# Set default GPT-5 parameters if not provided
if "reasoning_effort" not in focus_group_data:

View file

@ -21,6 +21,8 @@ from app.utils import admin_required, make_serializable
from app.models.user import User
from app.models.usage_event import UsageEvent
from app.models.model_pricing import ModelPricing
from app.models.credit_transaction import CreditTransaction
from app.models.app_settings import get_settings, update_settings
from app.db import get_db
logger = logging.getLogger(__name__)
@ -480,3 +482,164 @@ async def list_focus_groups():
result.sort(key=lambda x: x['cost_total'], reverse=True)
total = await db.focus_groups.count_documents({})
return jsonify({'focus_groups': make_serializable(result), 'total': total}), 200
# ─────────────────────────────────────────────────────────────────────────────
# App Settings (credit pricing config)
# ─────────────────────────────────────────────────────────────────────────────
@admin_bp.route('/settings', methods=['GET'])
@jwt_required()
@admin_required
async def get_app_settings():
settings = await get_settings()
return jsonify(make_serializable(settings)), 200
@admin_bp.route('/settings', methods=['PUT'])
@jwt_required()
@admin_required
async def put_app_settings():
data = await request.get_json()
allowed = {'persona_cost', 'run_cost', 'trial_grant', 'credit_packs'}
fields = {k: v for k, v in data.items() if k in allowed}
if not fields:
return jsonify({'message': 'No valid fields to update'}), 400
updated = await update_settings(fields)
return jsonify(make_serializable(updated)), 200
# ─────────────────────────────────────────────────────────────────────────────
# Manual credit management
# ─────────────────────────────────────────────────────────────────────────────
@admin_bp.route('/users/<user_id>/credits', methods=['POST'])
@jwt_required()
@admin_required
async def adjust_user_credits(user_id):
"""Grant or deduct credits. amount>0 = grant, amount<0 = deduct."""
admin_id = get_jwt_identity()
data = await request.get_json()
amount = data.get('amount')
reason = data.get('reason', 'Admin adjustment')
if amount is None or not isinstance(amount, int) or amount == 0:
return jsonify({'message': 'amount must be a non-zero integer'}), 400
user = await User.find_by_id(user_id)
if not user:
return jsonify({'message': 'User not found'}), 404
if amount > 0:
new_balance = await User.grant_credits(user_id, amount)
tx_type = 'admin_grant'
else:
# Allow negative adjustment only if user has enough
abs_amount = abs(amount)
new_balance = await User.deduct_credits(user_id, abs_amount)
if new_balance is None:
return jsonify({'message': 'Insufficient credits to deduct'}), 400
tx_type = 'debit'
await CreditTransaction.record(
user_id=user_id,
tx_type=tx_type,
amount=amount,
balance_after=new_balance,
description=reason,
ref={'admin_id': admin_id},
)
return jsonify({'credits_balance': new_balance, 'adjustment': amount}), 200
# ─────────────────────────────────────────────────────────────────────────────
# Analytics
# ─────────────────────────────────────────────────────────────────────────────
@admin_bp.route('/analytics', methods=['GET'])
@jwt_required()
@admin_required
async def get_analytics():
"""Aggregate key business metrics."""
from_str = request.args.get('from')
to_str = request.args.get('to')
period_filter = _period_match(from_str, to_str)
db = await get_db()
# User counts
total_users = await db.users.count_documents({})
new_users = await db.users.count_documents(
{'created_at': period_filter} if period_filter else {}
)
# Focus group runs in period
fg_match = {'type': 'debit', 'description': {'$regex': 'Focus group'}}
if period_filter:
fg_match['ts'] = period_filter
run_count_agg = await db.credit_transactions.count_documents(fg_match)
# Persona creations in period
persona_match = {'type': 'debit', 'description': {'$regex': 'persona'}}
if period_filter:
persona_match['ts'] = period_filter
persona_count = await db.credit_transactions.count_documents(persona_match)
# Revenue: credits purchased (credit transactions of type 'purchase')
rev_match: dict = {'type': 'purchase'}
if period_filter:
rev_match['ts'] = period_filter
rev_agg = await db.credit_transactions.aggregate([
{'$match': rev_match},
{'$group': {'_id': None, 'total_credits': {'$sum': '$amount'}, 'count': {'$sum': 1}}},
]).to_list(1)
revenue_credits = rev_agg[0]['total_credits'] if rev_agg else 0
purchase_count = rev_agg[0]['count'] if rev_agg else 0
# Cost (USD) from usage_events
usage_match: dict = {}
if period_filter:
usage_match['ts'] = period_filter
cost_agg = await db.usage_events.aggregate([
{'$match': usage_match},
{'$group': {'_id': None, 'total_cost': {'$sum': '$cost_usd.total'}}},
]).to_list(1)
total_cost_usd = cost_agg[0]['total_cost'] if cost_agg else 0
# Per-model breakdown
model_agg = await db.usage_events.aggregate([
{'$match': usage_match},
{'$group': {
'_id': '$model',
'cost': {'$sum': '$cost_usd.total'},
'calls': {'$sum': 1},
}},
]).to_list(20)
# Daily credits purchased (last 30 days)
daily_pipeline = [
{'$match': {'type': 'purchase'}},
{'$group': {
'_id': {'$dateToString': {'format': '%Y-%m-%d', 'date': '$ts'}},
'credits': {'$sum': '$amount'},
'count': {'$sum': 1},
}},
{'$sort': {'_id': 1}},
{'$limit': 30},
]
daily_purchases = await db.credit_transactions.aggregate(daily_pipeline).to_list(30)
return jsonify({
'users': {'total': total_users, 'new_in_period': new_users},
'activity': {
'focus_group_runs': run_count_agg,
'personas_created': persona_count,
},
'revenue': {
'credits_sold': revenue_credits,
'purchase_count': purchase_count,
'cost_usd': round(total_cost_usd, 4),
},
'model_breakdown': make_serializable(model_agg),
'daily_purchases': make_serializable(daily_purchases),
}), 200

View file

@ -20,6 +20,9 @@ from app.services.ai_persona_service import (
from app.services.task_manager import register_cancellable_task, CancellableTask
from app.services.customer_data_service import customer_data_service, CustomerDataServiceError
from app.models.persona import Persona
from app.models.user import User
from app.models.credit_transaction import CreditTransaction
from app.models.app_settings import get_settings
from app.utils.rate_limiter import rate_limit, ip_key
from app.utils import active_required, with_user_context
@ -77,7 +80,7 @@ async def generate_basic_profiles():
temperature = 1.0
customer_data_session_id = data.get('customer_data_session_id') # Optional parameter
llm_model = data.get('llm_model', 'gemini-3.1-pro-preview') # Optional parameter with default
llm_model = data.get('llm_model', 'gpt-5.4') # Optional parameter with default
try:
# Register current task for cancellation
@ -217,7 +220,7 @@ async def complete_and_save_persona():
temperature = 1.0
customer_data_session_id = data.get('customer_data_session_id') # Optional parameter
llm_model = data.get('llm_model', 'gemini-3.1-pro-preview') # Optional parameter with default
llm_model = data.get('llm_model', 'gpt-5.4') # Optional parameter with default
# Get persona name for logging
persona_name = basic_profile.get('name', 'Unknown')
@ -856,7 +859,7 @@ async def batch_generate_summaries():
if not (0 <= temperature <= 1.5):
temperature = 1.0
llm_model = data.get('llm_model', 'gemini-3.1-pro-preview') # Optional parameter with default
llm_model = data.get('llm_model', 'gpt-5.4') # Optional parameter with default
# Log the request with model information
print(f"🔄 Backend: Received batch-generate-summaries request for {len(persona_ids)} personas with model: {llm_model}")
@ -1215,9 +1218,35 @@ async def generate_personas_full():
temperature = 1.0
customer_data_session_id = data.get('customer_data_session_id')
llm_model = data.get('llm_model', 'gemini-3.1-pro-preview')
llm_model = data.get('llm_model', 'gpt-5.4')
target_folder_id = data.get('target_folder_id')
# Pre-flight credit check: N × persona_cost
settings = await get_settings()
persona_cost = settings.get("persona_cost", 2)
total_cost = count * persona_cost
user_data = await User.find_by_id(user_id)
balance = (user_data or {}).get("credits_balance", 0)
if balance < total_cost:
return jsonify({
"error": "Insufficient credits",
"message": f"You need {total_cost} credits to generate {count} persona(s). Current balance: {balance}.",
"credits_required": total_cost,
"credits_balance": balance,
}), 402
# Deduct credits atomically
new_balance = await User.deduct_credits(user_id, total_cost)
if new_balance is None:
return jsonify({"error": "Insufficient credits", "message": "Credit balance changed. Please try again."}), 402
await CreditTransaction.record(
user_id=user_id,
tx_type="debit",
amount=-total_cost,
balance_after=new_balance,
description=f"Generated {count} persona(s)",
)
try:
from app.services.task_manager import get_task_manager
from app.websocket_manager_async import get_async_websocket_manager

View file

@ -30,6 +30,9 @@ from app.services.ai_runner_service import get_ai_runner
from app.services.image_description_service import ImageDescriptionService, ImageDescriptionError
from app.models.focus_group import FocusGroup
from app.models.persona import Persona
from app.models.user import User
from app.models.credit_transaction import CreditTransaction
from app.models.app_settings import get_settings
from app.utils.rate_limiter import rate_limit
from app.utils import active_required, with_user_context
@ -188,8 +191,8 @@ Be genuine and specific in your feedback, drawing on your personal experiences a
conversation_context=multimodal_context['conversation_context'],
temperature=temperature,
model_name=llm_model,
reasoning_effort=reasoning_effort if llm_model in ('gpt-5', 'gpt-5.4-2026-03-05') else None,
verbosity=verbosity if llm_model in ('gpt-5', 'gpt-5.4-2026-03-05') else None
reasoning_effort=reasoning_effort,
verbosity=verbosity
)
else:
response_text = await generate_persona_response(
@ -757,7 +760,32 @@ async def start_autonomous_conversation(focus_group_id):
data = (await request.get_json()) or {}
initial_prompt = data.get('initial_prompt')
current_app.logger.info(f"Request data: {data}")
# Pre-flight credit check
run_user_id = get_jwt_identity()
settings = await get_settings()
run_cost = settings.get("run_cost", 40)
user_data = await User.find_by_id(run_user_id)
balance = (user_data or {}).get("credits_balance", 0)
if balance < run_cost:
return jsonify({
"error": "Insufficient credits",
"message": f"You need {run_cost} credits to run a focus group session. Current balance: {balance}.",
"credits_required": run_cost,
"credits_balance": balance,
}), 402
new_balance = await User.deduct_credits(run_user_id, run_cost)
if new_balance is None:
return jsonify({"error": "Insufficient credits", "message": "Credit balance changed. Please try again."}), 402
await CreditTransaction.record(
user_id=run_user_id,
tx_type="debit",
amount=-run_cost,
balance_after=new_balance,
description=f"Focus group session run",
ref={"focus_group_id": focus_group_id},
)
# Create autonomous conversation controller
current_app.logger.info("Creating AutonomousConversationController...")
controller = AutonomousConversationController(focus_group_id, current_app.logger)

View file

@ -898,7 +898,7 @@ def convert_discussion_guide_to_markdown(discussion_guide, focus_group_name=None
---
*Exported from Semblance Synthetic Society*"""
*Exported from Cohorta*"""
# Handle structured format
if isinstance(discussion_guide, dict):
@ -965,7 +965,7 @@ def convert_discussion_guide_to_markdown(discussion_guide, focus_group_name=None
markdown += "---\n\n"
markdown += "*Exported from Semblance Synthetic Society*"
markdown += "*Exported from Cohorta*"
return markdown
# Fallback for unknown format
@ -982,7 +982,7 @@ Raw content:
---
*Exported from Semblance Synthetic Society*"""
*Exported from Cohorta*"""
def format_discussion_item_markdown(item, index, item_type):
"""Format a discussion item (question or activity) as markdown."""

View file

@ -163,7 +163,7 @@ async def modify_persona_with_ai(persona_id):
Request body should include:
- modification_prompt: Natural language description of desired changes
- llm_model: Model to use (defaults to 'gemini-3.1-pro-preview')
- llm_model: Model to use (defaults to 'gpt-5.4')
- reasoning_effort: For GPT-5 (minimal, low, medium, high)
- verbosity: For GPT-5 (low, medium, high)
- preview_only: If true, returns modified data without saving to database (defaults to false)
@ -177,7 +177,7 @@ async def modify_persona_with_ai(persona_id):
if not modification_prompt:
return jsonify({"error": "modification_prompt is required"}), 400
llm_model = request_data.get('llm_model', 'gemini-3.1-pro-preview')
llm_model = request_data.get('llm_model', 'gpt-5.4')
reasoning_effort = request_data.get('reasoning_effort', 'medium')
verbosity = request_data.get('verbosity', 'medium')
preview_only = request_data.get('preview_only', False)
@ -250,7 +250,7 @@ async def export_persona_profile(persona_id):
Returns 202 immediately; result delivered via WebSocket task_completed event.
Request body can optionally include:
- llm_model: Model to use (defaults to 'gemini-3.1-pro-preview')
- llm_model: Model to use (defaults to 'gpt-5.4')
- temperature: Temperature for generation (defaults to 0.3)
"""
try:
@ -259,7 +259,7 @@ async def export_persona_profile(persona_id):
return jsonify({"error": "Persona not found"}), 404
request_data = await request.get_json() or {}
llm_model = request_data.get('llm_model', 'gemini-3.1-pro-preview')
llm_model = request_data.get('llm_model', 'gpt-5.4')
temperature = request_data.get('temperature', 0.3)
user_id = get_jwt_identity()

View file

@ -1,7 +1,5 @@
"""
AI Persona Generation Service using Google's Gemini model.
This service handles the integration with the Gemini API to generate
synthetic persona data based on a predefined prompt.
AI Persona Generation Service uses Azure AI Foundry (gpt-5.4 / gpt-5.4-mini) via llm_service.
"""
import os
@ -220,7 +218,7 @@ async def _generate_basic_personas_attempt(
# Log the LLM API call with attempt number
attempt_text = f" (attempt {attempt})" if attempt > 1 else ""
print(f"🤖 Backend: Making LLM API call to {llm_model or 'gemini-3-pro-preview'} for basic persona generation{attempt_text}")
print(f"🤖 Backend: Making LLM API call to {llm_model or 'gpt-5.4'} for basic persona generation{attempt_text}")
raw_response = await LLMService.generate_content(
prompt=final_prompt,
@ -519,7 +517,7 @@ async def generate_persona(
# Log the LLM API call
persona_name = basic_persona.get('name', 'Unknown') if basic_persona else 'New Persona'
print(f"🤖 Backend: Making LLM API call to {llm_model or 'gemini-3-pro-preview'} for detailed persona generation of '{persona_name}'")
print(f"🤖 Backend: Making LLM API call to {llm_model or 'gpt-5.4'} for detailed persona generation of '{persona_name}'")
persona_data = await LLMService.generate_structured_response(
prompt=final_prompt,
@ -604,7 +602,7 @@ async def generate_persona_summary(
# Log the LLM API call
persona_name = persona_data.get('name', 'Unknown')
print(f"🤖 Backend: Making LLM API call to {llm_model or 'gemini-3-pro-preview'} for summary generation of '{persona_name}'")
print(f"🤖 Backend: Making LLM API call to {llm_model or 'gpt-5.4'} for summary generation of '{persona_name}'")
raw_response = await LLMService.generate_content(
prompt=final_prompt,
@ -709,7 +707,7 @@ async def generate_persona_download_summary(
# Log the LLM API call
persona_name = persona_data.get('name', 'Unknown')
print(f"🤖 Backend: Making LLM API call to {llm_model or 'gemini-3-pro-preview'} for download summary of '{persona_name}'")
print(f"🤖 Backend: Making LLM API call to {llm_model or 'gpt-5.4'} for download summary of '{persona_name}'")
# Generate the markdown content directly
markdown_response = await LLMService.generate_content(

View file

@ -691,7 +691,7 @@ class AutonomousConversationController:
llm_model = focus_group.get('llm_model')
reasoning_effort = focus_group.get('reasoning_effort', 'medium')
verbosity = focus_group.get('verbosity', 'medium')
self.logger.info(f"🤖 Autonomous conversation using model: {llm_model or 'default (gemini-3-pro-preview)'} for focus group {self.focus_group_id}")
self.logger.info(f"🤖 Autonomous conversation using model: {llm_model or 'default (gpt-5.4)'} for focus group {self.focus_group_id}")
# Get recent messages
messages = await FocusGroup.get_messages(self.focus_group_id)

View file

@ -191,5 +191,13 @@ class CustomerDataService:
return True # Nothing to clean up
# Global service instance
customer_data_service = CustomerDataService()
class _UnavailableService:
"""Stub returned when llama-cloud-services is not installed."""
def __getattr__(self, name):
raise CustomerDataServiceError("llama-cloud-services package not installed. File parsing unavailable.")
if LlamaParse:
customer_data_service = CustomerDataService()
else:
customer_data_service = _UnavailableService()

View file

@ -224,7 +224,7 @@ class FocusGroupService:
'content': question.get('content', 'No content')[:100] + '...'
})
logger.info(f"=== CREATIVE REVIEW VALIDATION RESULTS (Model: {llm_model or 'gemini-3-pro-preview'}) ===")
logger.info(f"=== CREATIVE REVIEW VALIDATION RESULTS (Model: {llm_model or 'gpt-5.4'}) ===")
logger.info(f"Found {creative_review_count} creative_review activities for {len(uploaded_assets)} uploaded assets")
if creative_review_activities:
@ -236,7 +236,7 @@ class FocusGroupService:
# If no creative review activities were generated, retry with enhanced prompt
if creative_review_count == 0:
logger.warning(f"❌ WARNING: No creative_review activities generated despite {len(uploaded_assets)} uploaded assets!")
logger.warning(f"❌ This suggests {llm_model or 'gemini-3-pro-preview'} is not following the creative asset instructions")
logger.warning(f"❌ This suggests {llm_model or 'gpt-5.4'} is not following the creative asset instructions")
# For GPT models, if this was already the enhanced prompt, we have a serious issue
if llm_model and llm_model.startswith('gpt') and attempt < max_retries:

View file

@ -107,7 +107,7 @@ class KeyThemeService:
"""
logger = logging.getLogger(__name__)
logger.info(f"Beginning theme extraction from {len(messages)} messages")
logger.info(f"Theme extraction using LLM model: {llm_model or 'default (gemini-3-pro-preview)'}")
logger.info(f"Theme extraction using LLM model: {llm_model or 'default (gpt-5.4)'}")
try:
# Load and prepare the prompt for the LLM
@ -137,7 +137,7 @@ class KeyThemeService:
for attempt in range(max_retries):
attempt_num = attempt + 1
logger.info(f"Attempt {attempt_num}/{max_retries}: Calling LLM ({llm_model or 'gemini-3-pro-preview'}) for theme generation")
logger.info(f"Attempt {attempt_num}/{max_retries}: Calling LLM ({llm_model or 'gpt-5.4'}) for theme generation")
try:
themes = await LLMService.generate_structured_array(
@ -147,7 +147,7 @@ class KeyThemeService:
model_name=llm_model
)
logger.info(f"Attempt {attempt_num}/{max_retries}: LLM ({llm_model or 'gemini-3-pro-preview'}) call successful, received {len(themes)} themes")
logger.info(f"Attempt {attempt_num}/{max_retries}: LLM ({llm_model or 'gpt-5.4'}) call successful, received {len(themes)} themes")
# Validate the response structure
validated_themes = []
@ -177,7 +177,7 @@ class KeyThemeService:
validated_themes.append(validated_theme)
logger.info(f"Theme generation completed successfully with {len(validated_themes)} validated themes using {llm_model or 'gemini-3-pro-preview'}")
logger.info(f"Theme generation completed successfully with {len(validated_themes)} validated themes using {llm_model or 'gpt-5.4'}")
return validated_themes
except LLMServiceError as e:

File diff suppressed because it is too large Load diff

View file

@ -1,106 +0,0 @@
import jwt
from jwt import PyJWKClient
import logging
from typing import Optional, Dict, Any
from quart import current_app
class MSALService:
"""Service for validating Microsoft MSAL tokens and extracting user information."""
def __init__(self):
import os
self.tenant_id = os.environ.get('MSAL_TENANT_ID')
self.client_id = os.environ.get('MSAL_CLIENT_ID')
if not self.tenant_id or not self.client_id:
raise RuntimeError("MSAL_TENANT_ID and MSAL_CLIENT_ID environment variables must be set")
# Microsoft endpoints
self.jwks_url = f'https://login.microsoftonline.com/{self.tenant_id}/discovery/v2.0/keys'
# Initialize JWK client for token verification
self.jwks_client = PyJWKClient(self.jwks_url)
def validate_token(self, id_token: str) -> Optional[Dict[str, Any]]:
"""
Validate a Microsoft ID token and return user information.
Args:
id_token: The Microsoft ID token (JWT) to validate
Returns:
Dictionary containing user information if valid, None if invalid
"""
try:
# Decode and validate the ID token as a JWT
return self._decode_jwt_token(id_token)
except Exception as e:
current_app.logger.error(f"ID token validation failed: {str(e)}")
return None
def _decode_jwt_token(self, id_token: str) -> Optional[Dict[str, Any]]:
"""
Decode and validate ID token as JWT.
Args:
id_token: The Microsoft ID token (JWT) to validate
Returns:
Dictionary containing user information if valid, None if invalid
"""
try:
# Get the signing key
signing_key = self.jwks_client.get_signing_key_from_jwt(id_token)
# Decode and validate the ID token
decoded_token = jwt.decode(
id_token,
signing_key.key,
algorithms=['RS256'],
audience=self.client_id,
issuer=f'https://login.microsoftonline.com/{self.tenant_id}/v2.0'
)
# Extract user information from token claims
return {
'microsoft_id': decoded_token.get('oid') or decoded_token.get('sub'),
'username': decoded_token.get('preferred_username', '').split('@')[0],
'email': decoded_token.get('email') or decoded_token.get('preferred_username'),
'display_name': decoded_token.get('name', ''),
'given_name': decoded_token.get('given_name', ''),
'surname': decoded_token.get('family_name', ''),
'auth_type': 'microsoft'
}
except jwt.InvalidTokenError as e:
current_app.logger.error(f"JWT token validation failed: {str(e)}")
return None
except Exception as e:
current_app.logger.error(f"Token decoding failed: {str(e)}")
return None
def create_user_data(self, microsoft_user_info: Dict[str, Any]) -> Dict[str, Any]:
"""
Create user data dictionary from Microsoft user information.
Args:
microsoft_user_info: User information from Microsoft
Returns:
Dictionary formatted for our user system
"""
# Use display name if available, otherwise construct from given/surname
display_name = microsoft_user_info.get('display_name', '')
if not display_name:
given_name = microsoft_user_info.get('given_name', '')
surname = microsoft_user_info.get('surname', '')
display_name = f"{given_name} {surname}".strip() or microsoft_user_info.get('username', 'Microsoft User')
return {
'username': display_name, # Use display name as username for Microsoft users
'email': microsoft_user_info.get('email', ''),
'microsoft_id': microsoft_user_info.get('microsoft_id', ''),
'role': 'user', # Default role for all users
'auth_type': 'microsoft',
'password_hash': None # Microsoft users don't have local passwords
}

View file

@ -47,7 +47,7 @@ class PersonaExportService:
async def generate_profile_markdown(
self,
persona_data: Dict[str, Any],
llm_model: str = "gpt-4.1",
llm_model: str = "gpt-5.4",
temperature: float = 0.3
) -> Dict[str, Any]:
"""
@ -55,7 +55,7 @@ class PersonaExportService:
Args:
persona_data: Complete persona data as dictionary
llm_model: LLM model to use (default: gpt-4.1 for speed)
llm_model: LLM model to use (default: gpt-5.4 for speed)
temperature: Temperature for LLM generation (lower for consistency)
Returns:

View file

@ -134,7 +134,7 @@ class PersonaModificationService:
async def modify_persona(
persona_id: str,
modification_prompt: str,
llm_model: str = 'gemini-3.1-pro-preview',
llm_model: str = 'gpt-5.4',
reasoning_effort: str = 'medium',
verbosity: str = 'medium',
max_retries: int = 3,
@ -190,8 +190,8 @@ class PersonaModificationService:
prompt=final_prompt,
temperature=0.3, # Lower temperature for consistent modifications
model_name=llm_model,
reasoning_effort=reasoning_effort if llm_model in ('gpt-5', 'gpt-5.4-2026-03-05') else None,
verbosity=verbosity if llm_model in ('gpt-5', 'gpt-5.4-2026-03-05') else None
reasoning_effort=reasoning_effort,
verbosity=verbosity
)
# Parse JSON response

View file

@ -11,12 +11,10 @@ pymongo==4.14.1
# Authentication & Security
bcrypt==4.0.1
PyJWT==2.8.0
msal==1.24.1
# AI & LLM Services
google-genai
openai==1.99.5
llama-cloud-services==0.6.62
stripe>=10.0.0
# WebSocket & Real-time
python-socketio==5.13.0

View file

@ -74,7 +74,7 @@ async def run_server():
print("⚡ All operations async and non-blocking")
print("🛑 Use Ctrl-C for graceful shutdown")
print("🔍 Debug: Send SIGUSR1 for stack dump if it hangs")
print("Started Semblance back end service")
print("Started Cohorta back end service")
# Create hypercorn config with debug settings
config = Config()

View file

@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""Seed model pricing for Semblance.
"""Seed model pricing for Cohorta (Azure AI Foundry).
Run from the backend/ directory:
source venv/bin/activate
@ -23,20 +23,23 @@ MONGO_USER = os.environ.get("MONGO_USER")
MONGO_PASS = os.environ.get("MONGO_PASS")
MONGO_HOST = os.environ.get("MONGO_HOST", "localhost")
MONGO_PORT = os.environ.get("MONGO_PORT", "27017")
MONGO_DB = os.environ.get("MONGO_DB", "cohorta_db")
if not MONGO_URI:
if MONGO_USER and MONGO_PASS:
MONGO_URI = f"mongodb://{MONGO_USER}:{MONGO_PASS}@{MONGO_HOST}:{MONGO_PORT}/semblance_db?authSource=admin"
MONGO_URI = f"mongodb://{MONGO_USER}:{MONGO_PASS}@{MONGO_HOST}:{MONGO_PORT}/{MONGO_DB}?authSource=admin"
else:
MONGO_URI = f"mongodb://{MONGO_HOST}:{MONGO_PORT}"
# Pricing effective from project start — covers all historical backfill
EFFECTIVE_FROM = datetime(2024, 1, 1, tzinfo=timezone.utc)
# Pricing effective from project launch
EFFECTIVE_FROM = datetime(2026, 5, 23, tzinfo=timezone.utc)
# Azure AI Foundry pricing (GlobalStandard, <272k context, USD per 1M tokens)
# Source: Azure OpenAI pricing page, May 2026
PRICING_ROWS = [
{
"model": "gpt-5.4-2026-03-05",
"provider": "openai",
"model": "gpt-5.4",
"provider": "azure",
"currency": "USD",
"tiers": [
{
@ -49,38 +52,48 @@ PRICING_ROWS = [
],
"effective_from": EFFECTIVE_FROM,
"effective_until": None,
"notes": "gpt-5.4-2026-03-05 pricing as of 2026-04",
"notes": "gpt-5.4 GlobalStandard <272k ctx. Retirement 2027-03-05.",
},
{
"model": "gemini-3.1-pro-preview",
"provider": "gemini",
"model": "gpt-5.4-mini",
"provider": "azure",
"currency": "USD",
"tiers": [
{
"threshold_input_tokens": 0,
"input_per_mtok": 2.00,
"cached_input_per_mtok": None,
"output_per_mtok": 12.00,
"input_per_mtok": 0.75,
"cached_input_per_mtok": 0.08,
"output_per_mtok": 4.50,
"image_per_mtok": None,
},
{
"threshold_input_tokens": 200_000,
"input_per_mtok": 4.00,
"cached_input_per_mtok": None,
"output_per_mtok": 18.00,
"image_per_mtok": None,
},
}
],
"effective_from": EFFECTIVE_FROM,
"effective_until": None,
"notes": "gemini-3.1-pro-preview pricing: $2/$12 (<200k ctx), $4/$18 (>=200k ctx)",
"notes": "gpt-5.4-mini GlobalStandard. Used for cheap features (summary, key_themes, etc.). Retirement 2027-03-18.",
},
{
"model": "gpt-5.4-nano",
"provider": "azure",
"currency": "USD",
"tiers": [
{
"threshold_input_tokens": 0,
"input_per_mtok": 0.20,
"cached_input_per_mtok": 0.02,
"output_per_mtok": 1.25,
"image_per_mtok": None,
}
],
"effective_from": EFFECTIVE_FROM,
"effective_until": None,
"notes": "gpt-5.4-nano GlobalStandard. Optional ultra-cheap tier.",
},
]
def main():
client = pymongo.MongoClient(MONGO_URI, serverSelectionTimeoutMS=5000)
db = client.semblance_db
db = client[MONGO_DB]
db.model_pricing.create_index(
[("model", pymongo.ASCENDING), ("effective_from", pymongo.DESCENDING)],

View file

@ -28,7 +28,7 @@ _stub(
'pymongo', 'pymongo.errors',
'quart', 'quart_cors', 'hypercorn', 'werkzeug', 'werkzeug.exceptions',
'socketio',
'bcrypt', 'jwt', 'msal',
'bcrypt', 'jwt',
'bson', 'bson.objectid',
'pydantic',
'PIL', 'PIL.Image',

View file

@ -1,47 +0,0 @@
function f(t,n){return typeof t=="string"?m(t,n):g(t,n)}function m(t,n){const s=new Date().toLocaleString();return`# ${n?`Discussion Guide: ${n}`:"Discussion Guide"}
**Generated:** ${s}
**Format:** Legacy Text Format
---
${t}
---
*Exported from Semblance Synthetic Society*`}function g(t,n){const s=new Date().toLocaleString();let e=`# ${n?`Discussion Guide: ${n}`:t.title}
**Duration:** ${t.total_duration} minutes
**Generated:** ${s}
`;return t.metadata&&(e+=`**Additional Information:** ${JSON.stringify(t.metadata,null,2)}
`),e+=`---
`,t.sections.forEach((r,c)=>{e+=`## Section ${c+1}: ${r.title}
`,r.content&&(e+=`*${r.content}*
`),r.activities&&r.activities.length>0&&(e+=`### Activities
`,r.activities.forEach((i,a)=>{e+=l(i,a+1,"Activity")}),e+=`
`),r.questions&&r.questions.length>0&&(e+=`### Questions
`,r.questions.forEach((i,a)=>{e+=l(i,a+1,"Question")}),e+=`
`),r.subsections&&r.subsections.length>0&&r.subsections.forEach((i,a)=>{e+=`### Subsection ${a+1}: ${i.title}
`,i.activities&&i.activities.length>0&&(e+=`#### Activities
`,i.activities.forEach((d,u)=>{e+=l(d,u+1,"Activity")}),e+=`
`),i.questions&&i.questions.length>0&&(e+=`#### Questions
`,i.questions.forEach((d,u)=>{e+=l(d,u+1,"Question")}),e+=`
`)}),e+=`---
`}),e+="*Exported from Semblance Synthetic Society*",e}function l(t,n,s){let o=`${n}. **${$(t.type)}**`;return t.time_limit&&(o+=` *(${t.time_limit} min)*`),o+=`
${t.content}
`,s==="Question"&&t.probes&&t.probes.length>0&&(o+=`
**Probe Questions:**
`,t.probes.forEach(e=>{o+=` - ${e}
`})),o+=`
`,o}function $(t){return t.split("_").map(n=>n.charAt(0).toUpperCase()+n.slice(1)).join(" ")}function h(t,n){const s=new Date().toISOString().split("T")[0];let o="discussion-guide";return t?o=`discussion-guide-${t.toLowerCase().replace(/[^a-z0-9\s-]/g,"").replace(/\s+/g,"-").replace(/-+/g,"-").trim()}`:n&&(o=`discussion-guide-${n.toLowerCase().replace(/[^a-z0-9\s-]/g,"").replace(/\s+/g,"-").replace(/-+/g,"-").trim()}`),`${o}-${s}.md`}function p(t,n){try{const s=f(t,n),o=typeof t=="string"?void 0:t.title,e=h(n,o),r=new Blob([s],{type:"text/markdown"}),c=URL.createObjectURL(r),i=document.createElement("a");i.href=c,i.download=e,i.style.display="none",document.body.appendChild(i),i.click(),document.body.removeChild(i),URL.revokeObjectURL(c)}catch(s){throw console.error("Error downloading discussion guide:",s),new Error("Failed to download discussion guide")}}export{f as convertDiscussionGuideToMarkdown,p as downloadDiscussionGuideAsMarkdown,h as generateDiscussionGuideFilename};

View file

@ -5,15 +5,16 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<title>Semblance</title>
<meta name="description" content="Lovable Generated Project" />
<meta name="author" content="Lovable" />
<title>Cohorta</title>
<meta name="description" content="AI-powered synthetic focus groups for product research" />
<meta name="author" content="AImpress" />
<meta property="og:title" content="Cohorta" />
<meta property="og:description" content="AI-powered synthetic focus groups for product research" />
<meta property="og:image" content="/og-image.png" />
</head>
<body>
<div id="root"></div>
<!-- GPT Engineer script removed to fix CORS and MIME type issues -->
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View file

@ -12,9 +12,7 @@
"backend": "cd backend && python run.py"
},
"dependencies": {
"@azure/msal-browser": "^4.19.0",
"@azure/msal-react": "^3.0.17",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
@ -84,8 +82,7 @@
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
"eslint-plugin-react-refresh": "^0.4.9",
"globals": "^15.9.0",
"lovable-tagger": "^1.1.7",
"postcss": "^8.4.47",
"postcss": "^8.4.47",
"tailwindcss": "^3.4.11",
"typescript": "^5.5.3",
"typescript-eslint": "^8.0.1",

View file

@ -83,7 +83,7 @@ export default function FocusGroupModerator({
focusGroupName: "",
discussionTopics: "",
duration: "60",
llm_model: "gemini-3-pro-preview",
llm_model: "gpt-5.4",
reasoning_effort: "medium",
verbosity: "medium",
},
@ -193,7 +193,7 @@ export default function FocusGroupModerator({
objective: draftToEdit.description || draftToEdit.objective || '',
topic: draftToEdit.topic || '',
duration: draftToEdit.duration || 60,
llm_model: draftToEdit.llm_model || 'gemini-3-pro-preview',
llm_model: draftToEdit.llm_model || 'gpt-5.4',
reasoning_effort: draftToEdit.reasoning_effort || 'medium',
verbosity: draftToEdit.verbosity || 'medium',
participants: draftToEdit.participants || [],
@ -383,7 +383,7 @@ export default function FocusGroupModerator({
topic: values.discussionTopics || '',
description: values.researchBrief || '',
objective: values.researchBrief || '',
llm_model: values.llm_model || 'gemini-3-pro-preview',
llm_model: values.llm_model || 'gpt-5.4',
reasoning_effort: values.reasoning_effort || 'medium',
verbosity: values.verbosity || 'medium',
discussionGuide: sourceFocusGroup.discussionGuide

View file

@ -34,7 +34,7 @@ export default function PricingTab() {
const [showDialog, setShowDialog] = useState(false);
const [model, setModel] = useState('');
const [provider, setProvider] = useState('gemini');
const [provider, setProvider] = useState('azure');
const [inputPerMtok, setInputPerMtok] = useState('');
const [outputPerMtok, setOutputPerMtok] = useState('');
const [cachedInputPerMtok, setCachedInputPerMtok] = useState('');
@ -56,7 +56,7 @@ export default function PricingTab() {
onSuccess: () => {
setShowDialog(false);
setModel('');
setProvider('gemini');
setProvider('azure');
setInputPerMtok('');
setOutputPerMtok('');
setCachedInputPerMtok('');
@ -148,7 +148,7 @@ export default function PricingTab() {
<div className="space-y-1">
<Label>Model</Label>
<Input
placeholder="e.g. gemini-2.5-pro"
placeholder="e.g. gpt-5.4"
value={model}
onChange={e => setModel(e.target.value)}
/>
@ -160,7 +160,7 @@ export default function PricingTab() {
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="gemini">Gemini</SelectItem>
<SelectItem value="azure">Azure AI</SelectItem>
<SelectItem value="openai">OpenAI</SelectItem>
</SelectContent>
</Select>

View file

@ -9,7 +9,9 @@ import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
import { Loader2, Search, UserCog, Ban, CheckCircle, UserPlus } from 'lucide-react';
import { Loader2, Search, UserCog, Ban, CheckCircle, UserPlus, Zap } from 'lucide-react';
import { adminApi } from '@/lib/api';
import { toastService } from '@/lib/toast';
interface User {
_id: string;
@ -20,6 +22,7 @@ interface User {
override_quota?: boolean;
quota?: { monthly_usd?: number };
cost_mtd?: number;
credits_balance?: number;
}
export default function UsersTab() {
@ -31,6 +34,30 @@ export default function UsersTab() {
const [editOverride, setEditOverride] = useState(false);
const [resetPassword, setResetPassword] = useState('');
// Credit adjustment dialog state
const [creditUser, setCreditUser] = useState<User | null>(null);
const [creditAmount, setCreditAmount] = useState('');
const [creditReason, setCreditReason] = useState('');
const [creditSaving, setCreditSaving] = useState(false);
const handleCreditAdjust = async () => {
if (!creditUser || !creditAmount) return;
const amount = parseInt(creditAmount);
if (isNaN(amount) || amount === 0) return;
setCreditSaving(true);
try {
await adminApi.adjustCredits(creditUser._id, amount, creditReason || 'Admin adjustment');
toastService.success(`Credits adjusted: ${amount > 0 ? '+' : ''}${amount} for ${creditUser.username}`);
setCreditUser(null);
setCreditAmount('');
setCreditReason('');
} catch (e: any) {
toastService.error('Failed to adjust credits', { description: e.response?.data?.message });
} finally {
setCreditSaving(false);
}
};
// Create user dialog state
const [showCreateDialog, setShowCreateDialog] = useState(false);
const [newUsername, setNewUsername] = useState('');
@ -140,6 +167,7 @@ export default function UsersTab() {
<TableHead>User</TableHead>
<TableHead>Role</TableHead>
<TableHead>Status</TableHead>
<TableHead>Credits</TableHead>
<TableHead>Cost ({periodLabel})</TableHead>
<TableHead>Monthly Quota</TableHead>
<TableHead className="text-right">Actions</TableHead>
@ -148,7 +176,7 @@ export default function UsersTab() {
<TableBody>
{users.length === 0 && (
<TableRow>
<TableCell colSpan={6} className="text-center text-slate-500 py-8">
<TableCell colSpan={7} className="text-center text-slate-500 py-8">
No users found
</TableCell>
</TableRow>
@ -172,6 +200,12 @@ export default function UsersTab() {
{u.is_active === false ? 'Disabled' : 'Active'}
</Badge>
</TableCell>
<TableCell>
<span className="flex items-center gap-1 font-medium">
<Zap className="h-3.5 w-3.5 text-amber-500" />
{u.credits_balance ?? 0}
</span>
</TableCell>
<TableCell className="font-mono text-sm">
${(u.cost_mtd ?? 0).toFixed(4)}
</TableCell>
@ -183,6 +217,9 @@ export default function UsersTab() {
<Button size="sm" variant="ghost" onClick={() => openEdit(u)} title="Edit">
<UserCog className="h-4 w-4" />
</Button>
<Button size="sm" variant="ghost" onClick={() => { setCreditUser(u); setCreditAmount(''); setCreditReason(''); }} title="Adjust credits">
<Zap className="h-4 w-4 text-amber-500" />
</Button>
{u.is_active === false ? (
<Button size="sm" variant="ghost" onClick={() => enableUser.mutate(u._id)} title="Enable">
<CheckCircle className="h-4 w-4 text-green-600" />
@ -331,6 +368,47 @@ export default function UsersTab() {
</DialogFooter>
</DialogContent>
</Dialog>
{/* Credit Adjustment Dialog */}
<Dialog open={!!creditUser} onOpenChange={open => !open && setCreditUser(null)}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Zap className="h-4 w-4 text-amber-500" />
Adjust Credits {creditUser?.username}
</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<p className="text-sm text-muted-foreground">
Current balance: <strong>{creditUser?.credits_balance ?? 0} cr</strong>
</p>
<div className="space-y-1">
<Label>Amount (positive = grant, negative = deduct)</Label>
<Input
type="number"
placeholder="e.g. 50 or -10"
value={creditAmount}
onChange={e => setCreditAmount(e.target.value)}
/>
</div>
<div className="space-y-1">
<Label>Reason (optional)</Label>
<Input
placeholder="Admin adjustment reason"
value={creditReason}
onChange={e => setCreditReason(e.target.value)}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setCreditUser(null)}>Cancel</Button>
<Button onClick={handleCreditAdjust} disabled={!creditAmount || creditSaving}>
{creditSaving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Apply
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View file

@ -84,7 +84,7 @@ export default function AIRecruiterForm({ onSubmit, isGenerating }: AIRecruiterF
researchObjective: "",
personaCount: "5",
temperature: 0.75,
llm_model: "gemini-3-pro-preview",
llm_model: "gpt-5.4",
},
});
@ -346,9 +346,8 @@ export default function AIRecruiterForm({ onSubmit, isGenerating }: AIRecruiterF
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="gemini-3-pro-preview">Gemini 3 Pro (Slow, best for most tasks)</SelectItem>
<SelectItem value="gpt-4.1">GPT-4.1 (Fast, best for speed)</SelectItem>
<SelectItem value="gpt-5.2">GPT-5.2 (Slow, best for complex tasks)</SelectItem>
<SelectItem value="gpt-5.4">GPT-5.4 (Recommended)</SelectItem>
<SelectItem value="gpt-5.4-mini">GPT-5.4 Mini (Faster, lower cost)</SelectItem>
</SelectContent>
</Select>
<FormDescription>

View file

@ -1,26 +0,0 @@
import { ReactNode } from 'react';
import { PublicClientApplication } from '@azure/msal-browser';
import { MsalProvider as BaseMsalProvider } from '@azure/msal-react';
import { msalConfig } from '@/config/msalConfig';
// Initialize MSAL instance
const msalInstance = new PublicClientApplication(msalConfig);
// Initialize MSAL - handle any initialization errors
msalInstance.initialize().catch((error) => {
console.error('MSAL initialization error:', error);
});
interface MsalProviderProps {
children: ReactNode;
}
export function MsalProvider({ children }: MsalProviderProps) {
return (
<BaseMsalProvider instance={msalInstance}>
{children}
</BaseMsalProvider>
);
}
export { msalInstance };

View file

@ -155,9 +155,8 @@ export function SetupTab({
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="gemini-3-pro-preview">Gemini 3 Pro (Slow, best for most tasks)</SelectItem>
<SelectItem value="gpt-4.1">GPT-4.1 (Fast, best for speed)</SelectItem>
<SelectItem value="gpt-5.2">GPT-5.2 (Slow, best for complex tasks)</SelectItem>
<SelectItem value="gpt-5.4">GPT-5.4 (Recommended)</SelectItem>
<SelectItem value="gpt-5.4-mini">GPT-5.4 Mini (Faster, lower cost)</SelectItem>
</SelectContent>
</Select>
<FormDescription>
@ -169,7 +168,7 @@ export function SetupTab({
/>
{/* GPT-5 specific parameters */}
{selectedModel === "gpt-5.2" && (
{(selectedModel === "gpt-5.4" || selectedModel === "gpt-5.4-mini") && (
<>
<FormField
control={form.control}
@ -191,10 +190,10 @@ export function SetupTab({
</SelectContent>
</Select>
<FormDescription>
Controls how much time GPT-5.2 spends thinking before responding
Controls how much time GPT-5.4 spends thinking before responding
</FormDescription>
<div className="text-xs text-amber-600 font-medium mt-1">
Controls how much time GPT-5.2 spends thinking before responding
Controls how much time GPT-5.4 spends thinking before responding
</div>
<FormMessage />
</FormItem>
@ -220,10 +219,10 @@ export function SetupTab({
</SelectContent>
</Select>
<FormDescription>
Controls how detailed and lengthy GPT-5.2's responses will be
Controls how detailed and lengthy GPT-5.4's responses will be
</FormDescription>
<div className="text-xs text-amber-600 font-medium mt-1">
Controls how much time GPT-5.2 spends thinking before responding
Controls how much time GPT-5.4 spends thinking before responding
</div>
<FormMessage />
</FormItem>

View file

@ -1,5 +1,4 @@
import { FocusGroup, Message, Theme } from './types';
import { Persona } from '@/types/persona';
// Sample focus group data
export const sampleFocusGroups: FocusGroup[] = [
@ -92,344 +91,6 @@ Final impressions and recommendations.
}
];
// Sample personas data
export const samplePersonas: Persona[] = [
{
id: '0',
name: 'Oliver Reynolds',
age: '42',
gender: 'Male',
occupation: 'Senior Investment Manager',
education: 'Master\'s degree (Business and Finance)',
location: 'Kensington, London, UK',
techSavviness: 85,
personality: 'Discerning, sophisticated, detail-oriented, values heritage and craftsmanship',
interests: 'Classic automobiles, high-end timepieces, luxury real estate, fine dining, art exhibitions',
brandLoyalty: 90,
priceConsciousness: 30,
environmentalConcern: 60,
hasPurchasingPower: true,
hasChildren: true,
thinkFeelDo: {
thinks: [
'How does this reflect my personal standards and values?',
'Is this truly the pinnacle of craftsmanship and quality?',
'Will this purchase stand the test of time as a lasting investment?',
'How can this be further personalized to my exact preferences?'
],
feels: [
'Proud to associate with heritage brands that reflect my achievements',
'Gratified by bespoke experiences that acknowledge my unique tastes',
'Frustrated by standardized approaches that fail to recognize individual preferences',
'Reassured by transparent, detailed information about craftsmanship and materials'
],
does: [
'Conducts thorough research before making significant purchasing decisions',
'Seeks personalized consultations with dedicated specialists',
'Expects seamless integration between digital and in-person experiences',
'Values and maintains long-term relationships with trusted luxury brands',
'Regularly attends exclusive events and private showings'
]
},
oceanTraits: {
openness: 85,
conscientiousness: 90,
extraversion: 60,
agreeableness: 65,
neuroticism: 25
},
goals: [
'Build a legacy of discerning taste and refined investments',
'Access truly personalized experiences that acknowledge his status',
'Forge meaningful connections with brands that share his values',
'Discover unique, limited-edition items that few others will possess',
'Cultivate a network of trusted advisors across various luxury segments',
'Balance professional achievement with meaningful family experiences'
],
frustrations: [
'Mass-market approaches disguised as premium experiences',
'Fragmented communication across different channels',
'Delays in response or service that waste valuable time',
'Sales representatives who lack deep product knowledge'
],
motivations: [
'Recognition of his refined tastes and achievement',
'Access to exclusive, members-only opportunities',
'Building a collection of meaningful, high-quality possessions',
'Experiences that seamlessly blend heritage with innovation'
],
scenarioType: "Life & Luxury Scenarios",
scenarios: [
'Oliver is considering commissioning a bespoke luxury vehicle with custom interior features. He expects a dedicated consultant to guide him through the entire process, from initial design to delivery.',
'While traveling abroad, Oliver seeks remote access to his preferred brands and expects the same level of personalized service through digital channels.',
'Oliver is attending an exclusive product launch event where he anticipates VIP treatment and early access to limited-edition items.',
'When researching a significant purchase, Oliver consults both trusted peer networks and expects detailed information about materials, craftsmanship, and heritage.',
'Oliver is planning a milestone family celebration and wants to book a private dining experience at an exclusive venue that reflects his sophisticated taste.'
]
},
{
id: '1',
name: 'Fiona Caldwell',
age: '38',
gender: 'Female',
occupation: 'Founder and Creative Director of a luxury lifestyle brand',
education: 'First-Class Honours degree from a prestigious university',
location: 'Chelsea, London, UK',
ethnicity: 'White British',
socialGrade: 'A/B',
householdIncome: 'Approximately £195,000 per annum',
householdComposition: 'Single professional with established network',
livingSituation: 'Stylish, modern flat in exclusive London district',
techSavviness: 90,
personality: 'Innovative, discerning, detail-oriented, values quality and distinctiveness',
interests: 'Bespoke fashion, high-end design, contemporary art, exclusive dining experiences',
mediaConsumption: 'Premium publications (The Spectator, Tatler, Vogue) and digital influencers',
deviceUsage: 'High-performance smartphone, tablet, and ultrabook; active on luxury-focused social platforms',
shoppingHabits: 'Prefers bespoke shopping with personalized digital interfaces and in-person exclusivity',
brandPreferences: 'Brands combining heritage with innovation; appreciates tailored craftsmanship',
brandLoyalty: 85,
priceConsciousness: 25,
environmentalConcern: 70,
hasPurchasingPower: true,
hasChildren: false,
communicationPreferences: 'Clear, direct, personalized communication through premium channels',
thinkFeelDo: {
thinks: [
'How does this complement my personal brand and creative vision?',
'Is this innovative yet timeless enough for my lifestyle?',
'Will this experience or product truly stand out from the mainstream?',
'How can this be tailored to reflect my unique aesthetic sensibilities?'
],
feels: [
'Excited by innovative designs that push creative boundaries',
'Valued when brands recognize her accomplishments and creative influence',
'Frustrated by cookie-cutter luxury experiences that lack personality',
'Inspired by perfect execution of bespoke experiences that reflect attention to detail'
],
does: [
'Engages with immersive digital platforms and virtual showrooms',
'Attends exclusive industry events and creative collaborations',
'Seeks one-to-one consultancy sessions for significant purchases',
'Shares refined experiences within her select network of peers',
'Collaborates with luxury brands that align with her creative vision'
]
},
oceanTraits: {
openness: 95,
conscientiousness: 85,
extraversion: 70,
agreeableness: 65,
neuroticism: 30
},
goals: [
'Establish herself as a tastemaker in the luxury creative community',
'Experience highly personalized services that acknowledge her uniqueness',
'Discover innovative yet timeless designs that complement her lifestyle',
'Build meaningful connections with brands that share her creative vision',
'Balance digital innovation with high-touch personal experiences',
'Access exclusive opportunities before they reach the mainstream market'
],
frustrations: [
'Generic luxury experiences that don\'t recognize her unique tastes',
'Disconnected online and offline brand experiences',
'Mass-market approaches disguised as premium services',
'Brands that prioritize heritage without embracing innovation'
],
motivations: [
'Recognition of her creative influence and accomplishments',
'Access to limited-edition collaborations and early releases',
'Experiences that seamlessly blend digital innovation with personal service',
'Relationships with brands that value her feedback and perspective'
],
scenarioType: "Lifestyle & Professional Scenarios",
scenarios: [
'Fiona is considering collaborating with a luxury automotive brand on a limited-edition design concept, expecting a personalized presentation that respects her creative expertise.',
'While attending London Fashion Week, Fiona expects seamless integration between digital showcase tools and exclusive in-person appointments with designers.',
'Fiona is hosting a product launch event for her brand and wants to incorporate innovative digital experiences alongside traditional luxury elements.',
'When sourcing materials for a new collection, Fiona expects detailed information about craftsmanship, sustainability credentials, and exclusivity.',
'Fiona is planning a creative retreat and seeks a bespoke travel experience that combines luxury accommodations with artistic inspiration.'
],
coreValues: 'Exceptional quality, distinctiveness, and high-touch service; balancing innovation with timeless elegance',
lifestyleChoices: 'Cultural experiences such as art gallery openings, theatre premieres, and curated travel destinations',
socialActivities: 'Networks with high-achieving professionals at exclusive events; active in industry panels and luxury brand collaborations',
categoryKnowledge: 'Well-informed about luxury offerings; appreciates intricate design details and distinctive craftsmanship',
paymentMethods: 'Premium digital payment systems and secure banking apps for high-net-worth individuals',
purchaseBehaviour: 'Decisions driven by emotional connection and design evaluation; perceives high-value purchases as integral to personal brand',
decisionInfluences: 'Brand heritage, exclusivity of customizations, and recommendations from discerning peer network',
painPoints: 'Cookie-cutter approaches in luxury retail; seeks recognition of her individuality and creative sensibilities',
journeyContext: 'Engages through immersive digital platforms complemented by in-person appointments',
keyTouchpoints: 'Exclusive previews, one-to-one consultancy, and personalized digital interactions',
selfDeterminationNeeds: {
autonomy: 'Seeks independence in decision-making and values bespoke offerings reflecting uniqueness',
competence: 'Desires acknowledgment of refined tastes and expects flawless service',
relatedness: 'Values personalized relationships with brands understanding her lifestyle'
},
fears: [
'Being treated as an anonymous customer in a mass-market approach',
'Loss of personal touch in increasingly digitized luxury experiences'
],
narrative: 'Fiona Caldwell is a pioneering creative entrepreneur blending artistic flair with unwavering commitment to quality. At 38, her taste reflects both innovation and timeless elegance. She thrives in exclusive circles where bespoke, high-touch service is expected at every stage of her luxury journey.'
},
{
id: '2',
name: 'Michael Chen',
age: '37',
gender: 'Male',
occupation: 'Software Engineer',
location: 'San Francisco, USA',
techSavviness: 95,
personality: 'Analytical, detail-oriented, values efficiency',
thinkFeelDo: {
thinks: ['I need to understand how things work', 'Efficiency is key'],
feels: ['Annoyed by bugs or performance issues', 'Satisfied by clean, logical interfaces'],
does: ['Tests edge cases', 'Reads documentation thoroughly']
},
oceanTraits: {
openness: 70,
conscientiousness: 90,
extraversion: 40,
agreeableness: 55,
neuroticism: 30
}
},
{
id: '4',
name: 'David Kim',
age: '22',
gender: 'Male',
occupation: 'Student',
location: 'Austin, USA',
techSavviness: 90,
personality: 'Curious, experimental, price-conscious',
thinkFeelDo: {
thinks: ['How can I customize this?', 'Is this worth my time?'],
feels: ['Bored by traditional interfaces', 'Excited by customization options'],
does: ['Tries all settings and features', "Abandons apps that don't engage quickly"]
},
oceanTraits: {
openness: 90,
conscientiousness: 50,
extraversion: 65,
agreeableness: 70,
neuroticism: 40
}
},
{
id: '5',
name: 'Lisa Patel',
age: '41',
gender: 'Female',
occupation: 'Product Manager',
location: 'Seattle, USA',
techSavviness: 80,
personality: 'Strategic thinker, detail-oriented, collaborative',
thinkFeelDo: {
thinks: ['How does this fit into the ecosystem?', 'What problems does this solve?'],
feels: ['Concerned about integration issues', 'Satisfied by cohesive user journeys'],
does: ['Evaluates the full user journey', 'Compares with competing products']
},
oceanTraits: {
openness: 75,
conscientiousness: 85,
extraversion: 60,
agreeableness: 75,
neuroticism: 35
}
},
{
id: '7',
name: 'Olivia Brown',
age: '31',
gender: 'Female',
occupation: 'UX Designer',
location: 'Portland, USA',
techSavviness: 90,
personality: 'Creative, empathetic, user-centered',
thinkFeelDo: {
thinks: ['How does this make users feel?', 'Is this accessible to everyone?'],
feels: ['Frustrated by poor accessibility', 'Inspired by elegant solutions'],
does: ['Analyzes micro-interactions', 'Considers edge cases and accessibility']
},
oceanTraits: {
openness: 85,
conscientiousness: 75,
extraversion: 60,
agreeableness: 80,
neuroticism: 40
}
},
{
id: '8',
name: 'Arash Montazeri',
age: '46',
gender: 'Male',
occupation: 'Senior Executive at a leading technology firm',
education: "Bachelor's degree in Engineering from a prestigious UK university",
location: 'Ascot, Berkshire, UK',
ethnicity: 'Iranian-British',
householdIncome: 'Approximately £240,000 per annum',
socialGrade: 'A',
householdComposition: 'Married with two grown-up children',
livingSituation: 'Elegant country estate near Ascot blending British comfort and Persian design',
techSavviness: 94,
personality: 'Integrates heritage and innovation; values bespoke, culturally nuanced service and excellence',
interests: 'Classic cars, bespoke tailoring, fine wines, Persian and contemporary art, luxury travel, golfing at country clubs',
brandLoyalty: 95,
priceConsciousness: 40,
environmentalConcern: 75,
hasPurchasingPower: true,
hasChildren: true,
deviceUsage: 'Uses latest smartphones, tablets, smart home tech; prefers personalized luxury interfaces',
shoppingHabits: 'Relationship-driven, high-touch purchasing process with bespoke consultations; expects seamless online-offline integration',
brandPreferences: 'Heritage-driven, premium brands merging traditional craftsmanship with innovation (e.g., RollsRoyce)',
paymentMethods: 'Secure digital banking, premium credit, bespoke financing for high-value purchases',
mediaConsumption: 'Reads Financial Times, The Economist, and select cultural journals reflecting Iranian heritage and global outlook',
coreValues: 'Strong emphasis on heritage and innovation; bespoke service honoring tradition and Iranian culture',
lifestyleChoices: 'Golfing, fine dining at gourmet restaurants, immersive luxury travel, and revisiting Iranian roots',
socialActivities: 'Active in elite clubs, attends high-profile charity/cultural events, celebrates diversity and craftsmanship',
categoryKnowledge: 'Well-versed in luxury automotive engineering and bespoke options; values modern and traditional artistry',
purchaseBehaviour: 'Balances rational analysis and emotional attachment; major purchases are investments in legacy and taste',
decisionInfluences: 'Brand heritage, craftsmanship events, peer endorsements, transparency, and personalized narratives',
painPoints: 'Frustrated by fragmented, impersonal journeys and digital/in-person integration gaps',
journeyContext: 'Engages via invitation-only showrooms, virtual tours, and bespoke digital experiences with seamless support',
keyTouchpoints: 'One-to-one consultations, private previews of new bespoke options, post-purchase concierge support',
communicationPreferences: 'Prefers direct, timely engagement via a relationship manager, comfortable in-person and with premium digital',
oceanTraits: {
openness: 93,
conscientiousness: 97,
extraversion: 68,
agreeableness: 64,
neuroticism: 18,
},
selfDeterminationNeeds: {
autonomy: 'Seeks independence and offerings reflecting multifaceted identity',
competence: 'Desires recognition for refined taste and expects flawless service mirroring achievements',
relatedness: 'Wants personalized, respectful brand relationships honoring Iranian and British sophistication'
},
motivations: [
'Pursuit of excellence in every aspect',
'Preserve cultural legacy through selective luxury experiences',
'Leave a lasting impact via refined, innovative purchases',
'Deep connections with heritage brands',
'Integrity and authenticity in every luxury engagement',
'Opportunities to express identity through bespoke, meaningful customization',
],
fears: [
'Receiving subpar, impersonal service',
'Excessive digitization eroding tailored luxury experience'
],
scenarioType: "Scenarios Across Life, Luxury, Technology, and Heritage",
scenarios: [
'Arash attends a private RollsRoyce preview showcasing a bespoke vehicle that artfully blends British engineering with Persian design, collaborating one-on-one with brand artisans.',
'Invited to a virtual configurator experience, Arash works directly with a relationship manager to design a tailored vehicle from the comfort of his study, later completing the process with an in-person consultation.',
'At a high-profile cultural gala, Arash discusses his curated automotive and art collections, valuing brands that recognize and celebrate his unique heritage and refined taste.',
'Arash grows frustrated when a luxury brands digital appointment system does not seamlessly coordinate with in-person experience, prompting him to seek out brands with superior omnichannel integration.',
'When considering a new bespoke vehicle, Arash weighs heritage, innovation, and family legacy, seeking a process that honors his background in every detail—from material selection to narrative storytelling.',
'Post-purchase, Arash values ongoing, dedicated aftercare provided by a trusted relationship manager, ensuring every aspect of ownership exceeds expectations and reflects his status.'
],
narrative: "Arash Montazeri exemplifies the modern luxury consumer who seamlessly integrates his Iranian heritage with contemporary British sophistication. As a successful senior executive, Arash demands a bespoke, integrated luxury experience that honours both tradition and innovation. His elevated conscientiousness ensures every detail is handled with precision, while his high openness allows him to embrace creative customisations that reflect his cultural legacy. Arashs balanced social orientation and calm, confident demeanour make him particularly sensitive to any disconnect between digital and physical service channels. His thoughtful approach to luxury purchases—considering both emotional resonance and practical excellence—positions him as an ideal candidate for RollsRoyces “House of Luxury” experience."
}
];
// Sample initial messages - REMOVED to prevent boilerplate messages
// All messages should now be AI-generated during focus group sessions

View file

@ -72,7 +72,7 @@ export default function PersonaModificationModal({
resolver: zodResolver(modificationFormSchema),
defaultValues: {
modificationPrompt: "",
llm_model: "gemini-3-pro-preview",
llm_model: "gpt-5.4",
reasoning_effort: "medium",
verbosity: "medium",
},
@ -246,9 +246,8 @@ export default function PersonaModificationModal({
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="gemini-3-pro-preview">Gemini 3 Pro (Slow, best for most tasks)</SelectItem>
<SelectItem value="gpt-4.1">GPT-4.1 (Fast, best for speed)</SelectItem>
<SelectItem value="gpt-5.2">GPT-5.2 (Slow, best for complex tasks)</SelectItem>
<SelectItem value="gpt-5.4">GPT-5.4 (Recommended)</SelectItem>
<SelectItem value="gpt-5.4-mini">GPT-5.4 Mini (Faster, lower cost)</SelectItem>
</SelectContent>
</Select>
<FormDescription>
@ -260,7 +259,7 @@ export default function PersonaModificationModal({
/>
{/* GPT-5 specific parameters */}
{form.watch("llm_model") === "gpt-5.2" && (
{(form.watch("llm_model") === "gpt-5.4" || form.watch("llm_model") === "gpt-5.4-mini") && (
<>
{/* Reasoning Effort Parameter */}
<FormField

View file

@ -99,15 +99,15 @@ export default function PersonaProfile() {
try {
toastService.info("Generating persona profile...", {
description: "Using GPT-4.1 to create a beautifully formatted markdown profile"
description: "Using GPT-5.4 to create a beautifully formatted markdown profile"
});
// Use the persona's MongoDB _id or fallback to id
const personaId = currentPersona._id || currentPersona.id;
// Call the export API with GPT-4.1
// Call the export API with GPT-5.4
const response = await personasApi.exportProfile(personaId, {
llm_model: 'gpt-4.1',
llm_model: 'gpt-5.4',
temperature: 0.3
});
@ -132,7 +132,7 @@ export default function PersonaProfile() {
description: `${persona_name} profile saved as ${filename}`
});
} else {
const modelDisplay = model_used === 'gpt-4.1' ? 'GPT-4.1' : model_used;
const modelDisplay = model_used === 'gpt-5.4-mini' ? 'GPT-5.4 Mini' : 'GPT-5.4';
toastService.success("Profile downloaded successfully", {
description: `${persona_name} profile processed with ${modelDisplay} and saved as ${filename}`
});
@ -170,7 +170,7 @@ export default function PersonaProfile() {
description: `${persona_name} profile saved as ${filename}`
});
} else {
const modelDisplay = model_used === 'gpt-4.1' ? 'GPT-4.1' : model_used;
const modelDisplay = model_used === 'gpt-5.4-mini' ? 'GPT-5.4 Mini' : 'GPT-5.4';
toastService.success("Profile downloaded successfully", {
description: `${persona_name} profile processed with ${modelDisplay} and saved as ${filename}`
});

View file

@ -1,4 +1,5 @@
import { User, Users, MapPin, BookOpen, Heart, DollarSign, Briefcase, Home, Monitor, ShoppingBag, Info } from 'lucide-react';
import { User, Users, MapPin, BookOpen, Heart, Monitor, ShoppingBag, Info } from 'lucide-react';
import { Card } from '@/components/ui/card';
import { Persona } from '@/types/persona';
import { getPersonaAvatarSrc } from '@/utils/avatarUtils';
@ -8,10 +9,6 @@ interface PersonaSidebarProps {
}
export function PersonaSidebar({ persona }: PersonaSidebarProps) {
// Use for special cases of detailed profiles
const isOliver = persona.id === '0';
const isFiona = persona.id === '1';
return (
<Card className="p-6">
<div className="flex items-center space-x-4">
@ -187,50 +184,6 @@ export function PersonaSidebar({ persona }: PersonaSidebarProps) {
</div>
</div>
)}
{isOliver && (
<div className="pt-2 space-y-2">
<div className="sidebar-section">
<Briefcase className="sidebar-icon" />
<span className="text-muted-foreground text-sm">Maintains an extensive network of financial and luxury industry contacts</span>
</div>
<div className="sidebar-section">
<Home className="sidebar-icon" />
<span className="text-muted-foreground text-sm">Owns vacation properties in the Cotswolds and South of France</span>
</div>
<div className="sidebar-section">
<BookOpen className="sidebar-icon" />
<span className="text-muted-foreground text-sm">Collector of rare first-edition books and limited-edition art prints</span>
</div>
<div className="sidebar-section">
<DollarSign className="sidebar-icon" />
<span className="text-muted-foreground text-sm">Significant investment portfolio with focus on sustainable luxury ventures</span>
</div>
</div>
)}
{isFiona && (
<div className="pt-2 space-y-2">
<div className="sidebar-section">
<BookOpen className="sidebar-icon" />
<span className="text-muted-foreground text-sm">Active in industry panels, luxury brand collaborations, follows influencers in luxury & design</span>
</div>
<div className="sidebar-section">
<Home className="sidebar-icon" />
<span className="text-muted-foreground text-sm">Modern flat in exclusive Chelsea, accessible to boutique services</span>
</div>
<div className="sidebar-section">
<DollarSign className="sidebar-icon" />
<span className="text-muted-foreground text-sm">Uses premium digital payment & secure banking for HNWIs</span>
</div>
<div className="sidebar-section">
<Briefcase className="sidebar-icon" />
<span className="text-muted-foreground text-sm">Respected network in London's luxury sector; attends exclusive events</span>
</div>
<div className="sidebar-section">
<User className="sidebar-icon" />
<span className="text-muted-foreground text-sm">Seeks autonomy, bespoke service, and acknowledgment for taste</span>
</div>
</div>
)}
</div>
</div>
</div>

View file

@ -1,43 +0,0 @@
import { Configuration, LogLevel } from '@azure/msal-browser';
// MSAL configuration
export const msalConfig: Configuration = {
auth: {
clientId: import.meta.env.VITE_MSAL_CLIENT_ID,
authority: `https://login.microsoftonline.com/${import.meta.env.VITE_MSAL_TENANT_ID}`,
redirectUri: import.meta.env.VITE_MSAL_REDIRECT_URI,
postLogoutRedirectUri: import.meta.env.VITE_MSAL_POST_LOGOUT_REDIRECT_URI
},
cache: {
cacheLocation: 'localStorage',
storeAuthStateInCookie: true,
},
system: {
loggerOptions: {
loggerCallback: (level, message, containsPii) => {
if (containsPii) return;
},
logLevel: LogLevel.Error,
piiLoggingEnabled: false,
},
allowNativeBroker: false,
},
};
// Login request configuration
export const loginRequest = {
scopes: ['openid', 'profile', 'email'],
prompt: 'select_account' as const,
};
// Token request configuration for additional API calls
export const tokenRequest = {
scopes: ['openid', 'profile', 'email'],
account: null as any,
};
// Silent request configuration for token refresh
export const silentRequest = {
scopes: ['openid', 'profile', 'email'],
account: null as any,
};

View file

@ -2,16 +2,11 @@ import { createContext, useContext, useState, useEffect, ReactNode } from 'react
import { authApi, AUTH_ERROR_EVENT, AuthErrorDetail } from '@/lib/api';
import { toast } from 'sonner';
import { useNavigate } from 'react-router-dom';
import { useMsal } from '@azure/msal-react';
import { loginRequest, silentRequest } from '@/config/msalConfig';
import { AccountInfo, AuthenticationResult } from '@azure/msal-browser';
interface User {
username: string;
email: string;
role: string;
authType?: 'local' | 'microsoft';
microsoftId?: string;
}
interface AuthContextType {
@ -19,10 +14,8 @@ interface AuthContextType {
token: string | null;
isLoading: boolean;
login: (username: string, password: string) => Promise<void>;
loginWithMicrosoft: () => Promise<void>;
logout: () => void;
isAuthenticated: boolean;
isMsalLoading: boolean;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
@ -31,109 +24,61 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [token, setToken] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isMsalLoading, setIsMsalLoading] = useState(false);
const navigate = useNavigate();
const { instance, accounts, inProgress } = useMsal();
// Handle Microsoft redirect response on page load (loginRedirect flow)
useEffect(() => {
instance.handleRedirectPromise()
.then(async (response) => {
if (response?.idToken) {
try {
const backendResponse = await authApi.loginWithMicrosoft(response.idToken);
if (backendResponse.data.access_token) {
localStorage.setItem('auth_token', backendResponse.data.access_token);
localStorage.setItem('user', JSON.stringify(backendResponse.data.user));
localStorage.setItem('auth_type', 'microsoft');
setToken(backendResponse.data.access_token);
setUser(backendResponse.data.user);
toast.success('Successfully signed in with Microsoft!');
}
} catch (err: any) {
console.error('Backend Microsoft auth failed:', err);
toast.error('Microsoft sign-in failed', { description: err.message });
}
}
})
.catch((err: any) => {
if (err?.errorCode !== 'no_account_error') {
console.error('MSAL redirect error:', err);
toast.error('Microsoft sign-in failed', { description: err.message });
}
});
}, [instance]);
// Listen for authentication errors and handle navigation
useEffect(() => {
const handleAuthError = (event: Event) => {
// Get details from the event
const customEvent = event as CustomEvent<AuthErrorDetail>;
const details = customEvent.detail || {};
// For persona creation errors, don't clear session or redirect
if (details.isPersonaCreation) {
return;
}
// For all other auth errors, clear the session and redirect
if (details.isPersonaCreation) return;
clearAuthData();
toast.error('Session expired', { description: 'Please log in again' });
navigate('/login');
};
// Listen for WebSocket authentication errors
const handleWebSocketAuthError = (event: Event) => {
const customEvent = event as CustomEvent<any>;
const errorData = customEvent.detail || {};
// Clear auth data and redirect to login
clearAuthData();
toast.error('Session expired', {
description: errorData.expired ? 'Your session has expired. Please log in again.' : 'Authentication failed. Please log in again.'
toast.error('Session expired', {
description: errorData.expired
? 'Your session has expired. Please log in again.'
: 'Authentication failed. Please log in again.',
});
navigate('/login');
};
window.addEventListener(AUTH_ERROR_EVENT, handleAuthError);
window.addEventListener('ws:auth_error', handleWebSocketAuthError);
return () => {
window.removeEventListener(AUTH_ERROR_EVENT, handleAuthError);
window.removeEventListener('ws:auth_error', handleWebSocketAuthError);
};
}, [navigate]);
// Helper function to check if JWT token is expired
const isTokenExpired = (token: string): boolean => {
if (localStorage.getItem('offline_mode') === 'true') return false;
const isTokenExpired = (t: string): boolean => {
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
const payload = JSON.parse(atob(t.split('.')[1]));
return payload.exp < Date.now() / 1000;
} catch {
return true;
}
};
// Helper function to clear authentication data
const clearAuthData = () => {
localStorage.removeItem('auth_token');
localStorage.removeItem('user');
localStorage.removeItem('auth_type');
localStorage.removeItem('offline_mode');
setToken(null);
setUser(null);
};
useEffect(() => {
// Check if user is already logged in
const storedToken = localStorage.getItem('auth_token');
const storedUser = localStorage.getItem('user');
if (storedToken && storedUser) {
// Check if token is expired
if (isTokenExpired(storedToken)) {
clearAuthData();
toast.error('Session expired', { description: 'Please log in again' });
@ -141,45 +86,33 @@ export function AuthProvider({ children }: { children: ReactNode }) {
try {
setToken(storedToken);
setUser(JSON.parse(storedUser));
} catch (error) {
console.error('Failed to parse stored user data:', error);
} catch {
clearAuthData();
}
}
}
setIsLoading(false);
}, []);
// Verify token is valid by fetching user profile
useEffect(() => {
if (token) {
// Set a flag to avoid unnecessary token validation on every render
const validationKey = `token_validated_${token.substring(0, 10)}`;
const alreadyValidated = sessionStorage.getItem(validationKey);
if (alreadyValidated === 'true' && user) {
return;
}
if (sessionStorage.getItem(validationKey) === 'true' && user) return;
authApi.getProfile()
.then(response => {
if (response && 'data' in response) {
setUser(response.data);
// Mark this token as validated for this session
sessionStorage.setItem(validationKey, 'true');
}
})
.catch(error => {
if (error.response && error.response.status === 401) {
// Handle unauthorized - invalid or expired token
console.error('Token invalid or expired (401):', error);
if (error.response?.status === 401) {
clearAuthData();
toast.error('Session expired', { description: 'Please log in again' });
navigate('/login');
} else {
import.meta.env.DEV && console.warn('Profile validation error (not clearing token):', error);
// Do not mark as validated on non-401 errors; allow retry on next render
import.meta.env.DEV && console.warn('Profile validation error:', error);
}
});
}
@ -187,28 +120,16 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const login = async (username: string, password: string) => {
setIsLoading(true);
try {
const response = await authApi.login(username, password);
if (!response.data.access_token) {
throw new Error('No access token received from server');
}
// Save token and user data
if (!response.data.access_token) throw new Error('No access token received');
localStorage.setItem('auth_token', response.data.access_token);
localStorage.setItem('user', JSON.stringify(response.data.user));
// Update state
setToken(response.data.access_token);
setUser(response.data.user);
toast.success('Login successful!');
// Return the token to indicate successful login
return response.data.access_token;
} catch (error: any) {
console.error('Login failed:', error);
toast.error('Login failed', {
description: error.response?.data?.message || 'Invalid username or password',
});
@ -218,48 +139,15 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}
};
const loginWithMicrosoft = async () => {
setIsMsalLoading(true);
try {
await instance.loginRedirect(loginRequest);
// Page navigates away — execution stops here
} catch (error: any) {
console.error('Microsoft login redirect failed:', error);
toast.error('Microsoft sign-in failed', {
description: error.message || 'An error occurred during authentication',
});
setIsMsalLoading(false);
}
};
const logout = async () => {
const authType = localStorage.getItem('auth_type');
// Clear local storage using helper function
const logout = () => {
clearAuthData();
// If user was authenticated with Microsoft, also sign out from Microsoft
if (authType === 'microsoft' && accounts.length > 0) {
try {
await instance.logoutRedirect({
account: accounts[0],
postLogoutRedirectUri: window.location.origin + import.meta.env.BASE_URL,
});
} catch (error) {
console.error('Microsoft logout error:', error);
// Continue with local logout even if Microsoft logout fails
}
}
toast.info('You have been logged out');
};
// Determine authentication status: token must exist and not be expired
const _storedToken = localStorage.getItem('auth_token');
const isAuthenticated = (() => {
const t = token || _storedToken;
if (!t) return false;
if (localStorage.getItem('offline_mode') === 'true') return true;
try {
const payload = JSON.parse(atob(t.split('.')[1]));
return typeof payload.exp === 'number' && payload.exp > Date.now() / 1000;
@ -267,25 +155,16 @@ export function AuthProvider({ children }: { children: ReactNode }) {
return false;
}
})();
const value = {
user,
token,
isLoading,
login,
loginWithMicrosoft,
logout,
isAuthenticated,
isMsalLoading,
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
return (
<AuthContext.Provider value={{ user, token, isLoading, login, logout, isAuthenticated }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
if (context === undefined) throw new Error('useAuth must be used within an AuthProvider');
return context;
}
}

View file

@ -85,7 +85,7 @@ export function useFocusGroupAutoSave({
objective: values.researchBrief || '',
topic: values.discussionTopics || '',
duration: values.duration ? parseInt(values.duration) : 60,
llm_model: values.llm_model || 'gemini-3-pro-preview',
llm_model: values.llm_model || 'gpt-5.4',
reasoning_effort: values.reasoning_effort || 'medium',
verbosity: values.verbosity || 'medium',
participants: selectedParticipants,

View file

@ -1,551 +1,11 @@
import { useState, useEffect } from 'react';
import { Persona } from '@/types/persona';
import { GENERATED_PERSONAS_KEY } from '@/hooks/usePersonaStorage';
import { useParams, useLocation, useNavigate } from 'react-router-dom';
import { toast } from 'sonner';
import { personasApi } from '@/lib/api';
import { useNavigation } from '@/contexts/NavigationContext';
// Sample user data for fallback/demo purposes
const sampleUsers: Persona[] = [
{
id: '0',
name: 'Oliver Reynolds',
age: '42',
gender: 'Male',
occupation: 'Senior Investment Manager',
education: 'Master\'s degree (Business and Finance)',
location: 'Kensington, London, UK',
techSavviness: 85,
brandLoyalty: 90,
priceConsciousness: 30,
environmentalConcern: 60,
personality: 'Discerning, sophisticated, detail-oriented, values heritage and craftsmanship',
interests: 'Classic automobiles, high-end timepieces, luxury real estate, fine dining, art exhibitions',
hasPurchasingPower: true,
hasChildren: true,
ethnicity: 'White British',
socialGrade: 'A',
householdIncome: '£275,000 per annum',
householdComposition: 'Married with two children (ages 8 and 12)',
livingSituation: 'Owns a 5-bedroom townhouse in Kensington',
mediaConsumption: 'Financial Times, The Economist, premium streaming services',
deviceUsage: 'Latest iPhone, iPad Pro, MacBook Air, and high-end smart home devices',
shoppingHabits: 'Prefers personal shopping assistants and concierge services',
brandPreferences: 'Heritage luxury brands with impeccable reputation',
communicationPreferences: 'Email for formal communications, encrypted messaging for sensitive matters',
goals: [
'Build a legacy of discerning taste and refined investments',
'Access truly personalized experiences that acknowledge his status',
'Forge meaningful connections with brands that share his values',
'Discover unique, limited-edition items that few others will possess',
'Cultivate a network of trusted advisors across various luxury segments',
'Balance professional achievement with meaningful family experiences'
],
frustrations: [
'Mass-market approaches disguised as premium experiences',
'Fragmented communication across different channels',
'Delays in response or service that waste valuable time',
'Sales representatives who lack deep product knowledge'
],
motivations: [
'Recognition of his refined tastes and achievement',
'Access to exclusive, members-only opportunities',
'Building a collection of meaningful, high-quality possessions',
'Experiences that seamlessly blend heritage with innovation'
],
oceanTraits: {
openness: 85,
conscientiousness: 90,
extraversion: 60,
agreeableness: 65,
neuroticism: 25
},
thinkFeelDo: {
thinks: [
'How does this reflect my personal standards and values?',
'Is this truly the pinnacle of craftsmanship and quality?',
'Will this purchase stand the test of time as a lasting investment?',
'How can this be further personalized to my exact preferences?'
],
feels: [
'Proud to associate with heritage brands that reflect my achievements',
'Gratified by bespoke experiences that acknowledge my unique tastes',
'Frustrated by standardized approaches that fail to recognize individual preferences',
'Reassured by transparent, detailed information about craftsmanship and materials'
],
does: [
'Conducts thorough research before making significant purchasing decisions',
'Seeks personalized consultations with dedicated specialists',
'Expects seamless integration between digital and in-person experiences',
'Values and maintains long-term relationships with trusted luxury brands',
'Regularly attends exclusive events and private showings'
]
},
scenarioType: "Life & Luxury Scenarios",
scenarios: [
'Oliver is considering commissioning a bespoke luxury vehicle with custom interior features. He expects a dedicated consultant to guide him through the entire process, from initial design to delivery.',
'While traveling abroad, Oliver seeks remote access to his preferred brands and expects the same level of personalized service through digital channels.',
'Oliver is attending an exclusive product launch event where he anticipates VIP treatment and early access to limited-edition items.',
'When researching a significant purchase, Oliver consults both trusted peer networks and expects detailed information about materials, craftsmanship, and heritage.',
'Oliver is planning a milestone family celebration and wants to book a private dining experience at an exclusive venue that reflects his sophisticated taste.'
]
},
{
id: '1',
name: 'Fiona Caldwell',
age: '38',
gender: 'Female',
ethnicity: 'White British',
occupation: 'Founder and Creative Director of a luxury lifestyle brand',
education: "First-Class Honours degree from a prestigious university (e.g., Oxbridge)",
location: 'Chelsea, London, UK',
techSavviness: 90,
personality: 'Pioneering creative entrepreneur who blends artistic flair with an unwavering commitment to quality. Thrives in exclusive circles where bespoke, high-touch service is expected at every stage of her luxury journey.',
interests: "Bespoke fashion, high-end design, contemporary art, exclusive dining experiences, curated travel, networking at industry panels and luxury brand collaborations",
socialGrade: "A/B",
householdIncome: "£195,000 per annum",
householdComposition: "Single professional, well-established network, occasional family engagements",
livingSituation: "Stylish, modern flat in an exclusive London district with access to boutique services",
coreValues: "Exceptional quality, distinctiveness, high-touch service, innovation, timeless elegance",
lifestyleChoices: "Enjoys cultural experiences (gallery openings, theatre premieres, curated travel)",
socialActivities: "Networks with high-achieving professionals, attends exclusive events, active in luxury collaborations",
mediaConsumption: "Premium publications (Spectator, Tatler, Vogue); follows luxury & design influencers",
brandLoyalty: 85,
priceConsciousness: 35,
environmentalConcern: 70,
hasPurchasingPower: true,
hasChildren: false,
deviceUsage: "High-performance smartphone, tablet, ultrabook; active on luxury-focused social media",
shoppingHabits: "Entirely bespoke, prefers personalised digital interfaces & in-person exclusivity",
brandPreferences: "Heritage brands with modern innovation (e.g., Rolls-Royce), tailored craftsmanship",
paymentMethods: "Premium digital payment systems, secure banking apps, selective use of credit for HNWIs",
categoryKnowledge: "Luxury automotive, bespoke interior design, limited-edition collections",
purchaseBehaviour: "Blends emotional connection & rational design evaluation; purchases reflect personal brand",
decisionInfluences: "Brand heritage, exclusivity, bespoke customisation, peer recommendations",
painPoints: "Dislikes cookie-cutter luxury retail, seeks recognition of individuality & creative sensibility",
journeyContext: "Engages via immersive digital platforms (virtual showrooms) and in-person appointments",
keyTouchpoints: "Exclusive previews, 1:1 consultancy, personalised digital interactions",
communicationPreferences: "Favors clear, direct, and personalised communication via premium digital and face-to-face",
oceanTraits: {
openness: 95,
conscientiousness: 85,
extraversion: 60,
agreeableness: 60,
neuroticism: 20
},
selfDeterminationNeeds: {
autonomy: "Seeks independence in decision-making; values unique bespoke offerings",
competence: "Wants acknowledgment for refined taste and flawless service",
relatedness: "Values relationships with brands who understand aspirations"
},
goals: [
"Establish herself as a tastemaker in the luxury creative community",
"Experience highly personalized services that acknowledge her uniqueness",
"Discover innovative yet timeless designs that complement her lifestyle",
"Build meaningful connections with brands that share her creative vision",
"Balance digital innovation with high-touch personal experiences",
"Access exclusive opportunities before they reach the mainstream market"
],
motivations: [
"Distinguishing herself in luxury landscape",
"Aligning with brands that echo her creative vision",
"Finding perfect balance of heritage and innovation",
"Building a network of like-minded creative professionals"
],
frustrations: [
"Generic luxury experiences that don't recognize her unique tastes",
"Disconnected online and offline brand experiences",
"Mass-market approaches disguised as premium services",
"Brands that prioritize heritage without embracing innovation"
],
fears: [
"Being treated as anonymous in a mass-market approach",
"Loss of personal touch in digital luxury experiences",
"Missing emerging trends in the luxury space"
],
thinkFeelDo: {
thinks: [
"How does this complement my personal brand and creative vision?",
"Is this innovative yet timeless enough for my lifestyle?",
"Will this experience or product truly stand out from the mainstream?",
"How can this be tailored to reflect my unique aesthetic sensibilities?"
],
feels: [
"Excited by innovative designs that push creative boundaries",
"Valued when brands recognize her accomplishments and creative influence",
"Frustrated by cookie-cutter luxury experiences that lack personality",
"Inspired by perfect execution of bespoke experiences that reflect attention to detail"
],
does: [
"Engages with immersive digital platforms and virtual showrooms",
"Attends exclusive industry events and creative collaborations",
"Seeks one-to-one consultancy sessions for significant purchases",
"Shares refined experiences within her select network of peers",
"Collaborates with luxury brands that align with her creative vision"
]
},
scenarioType: "Lifestyle & Professional Scenarios",
scenarios: [
"Fiona is considering collaborating with a luxury automotive brand on a limited-edition design concept, expecting a personalized presentation that respects her creative expertise.",
"While attending London Fashion Week, Fiona expects seamless integration between digital showcase tools and exclusive in-person appointments with designers.",
"Fiona is hosting a product launch event for her brand and wants to incorporate innovative digital experiences alongside traditional luxury elements.",
"When sourcing materials for a new collection, Fiona expects detailed information about craftsmanship, sustainability credentials, and exclusivity.",
"Fiona is planning a creative retreat and seeks a bespoke travel experience that combines luxury accommodations with artistic inspiration."
],
narrative: "Fiona Caldwell is a pioneering creative entrepreneur who blends artistic flair with an unwavering commitment to quality. At 38, her taste reflects both innovation and timeless elegance. She thrives in exclusive circles where bespoke, high-touch service is expected at every stage of her luxury journey. With an exceptionally high degree of openness, Fiona embraces new ideas and digital innovations that enhance her experience. Her strong conscientiousness ensures that each interaction is meticulously tailored to her exacting standards, while her moderate extraversion and agreeableness allow her to enjoy both intimate consultations and high-profile social gatherings. Fiona's minimal neuroticism underpins a confident, decisive approach to luxury purchases, making her an ideal candidate for RollsRoyce's \"House of Luxury\" proposition."
},
{
id: '9',
name: 'Arash Montazeri',
age: '46',
gender: 'Male',
ethnicity: 'Iranian-British',
occupation: 'Senior Executive at a leading technology firm',
education: 'Bachelor\'s degree in Engineering from a prestigious UK university',
location: 'Ascot, Berkshire, UK',
techSavviness: 95,
personality: 'A modern luxury consumer who seamlessly integrates Iranian heritage with contemporary British sophistication.',
interests: 'Classic cars, bespoke tailoring, fine wines, Persian art, modern design',
socialGrade: 'A',
householdIncome: '£240,000 per annum',
householdComposition: 'Married with two grown-up children',
livingSituation: 'Elegant country estate near Ascot blending contemporary British comfort with refined Persian design accents',
coreValues: 'Strong emphasis on heritage and innovation; values bespoke service that respects both traditional luxury and Iranian cultural legacy',
lifestyleChoices: 'Golfing at exclusive country clubs, fine dining at gourmet restaurants, luxury travel with culturally immersive experiences',
socialActivities: 'Member of elite clubs, attends high-profile charity events and cultural gatherings celebrating diversity and craftsmanship',
mediaConsumption: 'Financial Times, The Economist, selective cultural journals reflecting Iranian heritage and global outlook',
brandLoyalty: 92,
priceConsciousness: 25,
environmentalConcern: 65,
hasPurchasingPower: true,
hasChildren: true,
deviceUsage: 'Latest high-end smartphones, tablets, and connected home devices; utilises personalised digital interfaces',
shoppingHabits: 'Relationship-driven, high-touch purchasing process with bespoke consultations and integrated online-to-offline experiences',
brandPreferences: 'Heritage-driven premium brands that merge traditional craftsmanship with modern innovation',
paymentMethods: 'Secure digital banking solutions, premium credit facilities, bespoke financing options for high-value purchases',
categoryKnowledge: 'Well-versed in luxury automotive design and engineering; appreciates intricate customisation options',
purchaseBehaviour: 'Balances rational analysis with emotional attachment—viewing purchases as investments in personal legacy',
decisionInfluences: 'Brand heritage, bespoke craftsmanship, endorsements from trusted peers; values transparency and personalised narrative',
painPoints: 'Fragmented, impersonal customer journeys and inconsistent integration between digital and in-person service channels',
journeyContext: 'Engages through invitation-only showrooms complemented by immersive, customised digital experiences',
keyTouchpoints: 'One-to-one consultations, private previews of bespoke options, dedicated post-purchase concierge support',
communicationPreferences: 'Direct, transparent engagement through a dedicated relationship manager; comfortable with structured meetings and digital interactions',
oceanTraits: {
openness: 85,
conscientiousness: 95,
extraversion: 60,
agreeableness: 60,
neuroticism: 20
},
selfDeterminationNeeds: {
autonomy: 'Seeks independence in decision-making and prizes bespoke offerings that reflect his multifaceted identity',
competence: 'Desires recognition of his refined tastes and expects flawless service that mirrors his achievements',
relatedness: 'Values personalised, respectful relationships with brands that understand his unique blend of Iranian heritage and British sophistication'
},
goals: [
'Curate a collection of bespoke luxury items that reflect both heritage and innovation',
'Establish lasting relationships with brands that honor his dual cultural identity',
'Access truly personalized experiences that acknowledge his unique perspective',
'Create a legacy of refined taste to pass down to his children',
'Support innovation that respects traditional craftsmanship',
'Build connections with like-minded individuals in elite cultural circles'
],
motivations: [
'Recognition of his unique cultural perspective',
'Appreciation for his attention to detail and high standards',
'Access to exclusive, curated experiences',
'Opportunities to express his personal legacy through bespoke acquisitions'
],
frustrations: [
'Mass-market approaches disguised as premium experiences',
'Disjointed communication between digital and in-person channels',
'Service that fails to recognize his cultural background',
'Standardized luxury that lacks true personalization'
],
fears: [
'Erosion of truly bespoke luxury experiences through excessive digitization',
'Losing connection to cultural heritage in modern luxury contexts',
'Receiving impersonal service despite premium pricing'
],
thinkFeelDo: {
thinks: [
'How does this purchase reflect my personal heritage and contemporary values?',
'Is this truly the pinnacle of craftsmanship that honors both tradition and innovation?',
'Will this experience create a meaningful legacy I can share with my family?',
'Does this brand genuinely understand my unique cultural perspective?'
],
feels: [
'Pride in experiences that honor his dual cultural identity',
'Satisfaction when brands recognize his refined tastes and cultural background',
'Frustration with standardized luxury that fails to acknowledge individuality',
'Connection to heritage through thoughtfully crafted luxury experiences'
],
does: [
'Thoroughly researches the heritage and craftsmanship behind luxury brands',
'Engages deeply with dedicated consultants who understand his preferences',
'Seeks seamless integration between digital convenience and personal service',
'Builds long-term relationships with brands that respect his cultural background',
'Introduces his children to refined experiences that blend heritage and innovation'
]
},
scenarioType: 'Luxury & Cultural Experiences',
scenarios: [
'Arash is commissioning a bespoke vehicle with custom interior features that subtly incorporate elements of Persian design, expecting a dedicated consultant who appreciates both traditional craftsmanship and his cultural background.',
'While hosting international colleagues at his home, Arash wants to showcase luxury items that reflect his dual heritage and sophisticated taste, expecting his chosen brands to provide support for creating a memorable experience.',
'Arash is planning a milestone anniversary celebration that blends British elegance with Persian cultural elements, seeking partners who can provide truly personalized service for this significant occasion.',
'When introducing his grown children to the art of fine collecting, Arash expects luxury brands to recognize this important moment of legacy-building and provide an exceptional educational experience.',
'Arash is attending an exclusive cultural event where he anticipates connecting with like-minded individuals who appreciate the intersection of heritage and innovation in luxury experiences.'
],
narrative: 'Arash Montazeri exemplifies the modern luxury consumer who seamlessly integrates his Iranian heritage with contemporary British sophistication. As a successful senior executive, Arash demands a bespoke, integrated luxury experience that honours both tradition and innovation. His elevated conscientiousness ensures every detail of his journey is handled with precision, while his high openness allows him to embrace creative customisations that reflect his cultural legacy. Arash\'s balanced social orientation and calm, confident demeanour make him particularly sensitive to any disconnect between digital and physical service channels. His thoughtful approach to luxury purchases—considering both emotional resonance and practical excellence—positions him as an ideal candidate for RollsRoyce\'s "House of Luxury" experience.'
},
{
id: '2',
name: 'Michael Chen',
age: '37',
gender: 'Male',
occupation: 'Software Engineer',
education: 'Bachelor\'s Degree',
location: 'San Francisco, USA',
techSavviness: 95,
brandLoyalty: 25,
priceConsciousness: 90,
environmentalConcern: 50,
personality: 'Analytical, detail-oriented, values efficiency',
interests: 'Programming, gadgets, hiking, craft beer',
hasPurchasingPower: true,
hasChildren: true,
goals: [
'Lead a successful project team',
'Contribute to open-source projects',
'Achieve financial independence'
],
frustrations: [
'Dealing with legacy code',
'Unclear project requirements',
'Meetings that could have been emails'
],
motivations: [
'Solving complex problems',
'Learning new technologies',
'Making a positive impact through code'
],
oceanTraits: {
openness: 70,
conscientiousness: 85,
extraversion: 30,
agreeableness: 55,
neuroticism: 20
},
thinkFeelDo: {
thinks: [
'How can I optimize this algorithm?',
'Is this the most efficient solution?',
'Will this scale effectively?'
],
feels: [
'Frustrated by bugs',
'Satisfied when code works flawlessly',
'Excited about new frameworks'
],
does: [
'Writes clean, well-documented code',
'Participates in code reviews',
'Automates repetitive tasks'
]
},
scenarios: [
'Michael is debugging a critical system failure and needs to quickly identify the root cause.',
'While on vacation, Michael wants to stay updated on important project updates.',
'Michael needs to estimate the effort required for a new feature implementation.'
]
},
{
id: '3',
name: 'Sarah Martinez',
age: '48',
gender: 'Female',
occupation: 'Healthcare Administrator',
education: 'Master\'s Degree',
location: 'Chicago, USA',
techSavviness: 60,
brandLoyalty: 80,
priceConsciousness: 70,
environmentalConcern: 90,
personality: 'Practical, thorough, concerned with security',
interests: 'Gardening, reading, volunteering, classical music',
hasPurchasingPower: true,
hasChildren: true,
goals: [
'Improve patient outcomes',
'Streamline administrative processes',
'Ensure regulatory compliance'
],
frustrations: [
'Dealing with insurance companies',
'Keeping up with changing regulations',
'Balancing cost and quality of care'
],
motivations: [
'Making a difference in people\'s lives',
'Providing high-quality care',
'Creating a positive work environment'
],
oceanTraits: {
openness: 40,
conscientiousness: 90,
extraversion: 60,
agreeableness: 80,
neuroticism: 30
},
thinkFeelDo: {
thinks: [
'How can we improve patient satisfaction?',
'Are we meeting all regulatory requirements?',
'Can we reduce costs without sacrificing quality?'
],
feels: [
'Concerned about patient well-being',
'Stressed by administrative burdens',
'Proud of the team\'s accomplishments'
],
does: [
'Implements best practices',
'Conducts regular audits',
'Collaborates with other healthcare professionals'
]
},
scenarios: [
'Sarah is preparing for a hospital accreditation survey and needs to ensure all standards are met.',
'While at a conference, Sarah wants to learn about new healthcare technologies and best practices.',
'Sarah needs to resolve a conflict between staff members while maintaining a positive work environment.'
]
},
{
id: '4',
name: 'David Kim',
age: '22',
gender: 'Male',
occupation: 'Student',
education: 'High School',
location: 'Austin, USA',
techSavviness: 90,
brandLoyalty: 40,
priceConsciousness: 95,
environmentalConcern: 75,
personality: 'Curious, experimental, price-conscious',
interests: 'Gaming, streaming, social media, DIY projects',
hasPurchasingPower: false,
hasChildren: false,
goals: [
'Get good grades',
'Gain new experiences',
'Save money for future investments'
],
frustrations: [
'Paying for tuition and expenses',
'Dealing with student loan debt',
'Finding affordable housing'
],
motivations: [
'Achieving academic success',
'Exploring new interests',
'Building a strong financial foundation'
],
oceanTraits: {
openness: 80,
conscientiousness: 60,
extraversion: 70,
agreeableness: 50,
neuroticism: 40
},
thinkFeelDo: {
thinks: [
'How can I improve my grades?',
'What are the best deals on textbooks?',
'Can I balance school and work?'
],
feels: [
'Stressed about exams',
'Excited about learning new things',
'Anxious about the future'
],
does: [
'Studies regularly',
'Participates in extracurricular activities',
'Seeks out internships and job opportunities'
]
},
scenarios: [
'David is preparing for final exams and needs to find effective study strategies.',
'While browsing online, David wants to find the best deals on textbooks and school supplies.',
'David needs to manage his time effectively to balance school, work, and social activities.'
]
},
{
id: '5',
name: 'Lisa Patel',
age: '41',
gender: 'Female',
occupation: 'Product Manager',
education: 'Bachelor\'s Degree',
location: 'Seattle, USA',
techSavviness: 80,
brandLoyalty: 65,
priceConsciousness: 55,
environmentalConcern: 85,
personality: 'Strategic thinker, detail-oriented, collaborative',
interests: 'Hiking, cooking, travel, photography',
hasPurchasingPower: true,
hasChildren: true,
goals: [
'Launch successful products',
'Build strong relationships with stakeholders',
'Advance her career to a leadership position'
],
frustrations: [
'Dealing with conflicting priorities',
'Managing stakeholder expectations',
'Keeping up with rapidly changing technology'
],
motivations: [
'Creating innovative products',
'Solving customer problems',
'Driving business growth'
],
oceanTraits: {
openness: 75,
conscientiousness: 80,
extraversion: 65,
agreeableness: 70,
neuroticism: 35
},
thinkFeelDo: {
thinks: [
'How can we improve product performance?',
'What are the key customer needs?',
'Can we streamline the development process?'
],
feels: [
'Excited about new product ideas',
'Stressed by tight deadlines',
'Proud of the team\'s accomplishments'
],
does: [
'Conducts market research',
'Collaborates with engineering and design teams',
'Monitors product performance metrics'
]
},
scenarios: [
'Lisa is preparing a product roadmap and needs to prioritize features based on customer feedback.',
'While attending a conference, Lisa wants to learn about new product management methodologies.',
'Lisa needs to resolve a conflict between team members while maintaining a positive work environment.'
]
}
];
export function usePersonaDetails() {
const { id } = useParams<{ id: string }>();
const location = useLocation();

View file

@ -95,7 +95,7 @@ export function useWebSocket(
};
// Set WebSocket path from environment variable
const path = import.meta.env.VITE_WEBSOCKET_PATH || '/semblance_back/socket.io/';
const path = import.meta.env.VITE_WEBSOCKET_PATH || '/socket.io/';
socketOptions.path = path;
log(`Setting WebSocket path: ${socketOptions.path}`);

View file

@ -1,7 +1,7 @@
import axios from 'axios';
// Base URL for API requests
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '/semblance_back/api';
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api';
// Create axios instance with baseURL
@ -16,8 +16,6 @@ const api = axios.create({
// 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;
@ -119,19 +117,23 @@ api.interceptors.response.use(
// Auth endpoints
export const authApi = {
login: (username: string, password: string) =>
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) =>
register: (username: string, email: string, password: string) =>
api.post('/auth/register', { username, email, password }),
getProfile: () =>
api.get('/auth/me')
};
// Billing endpoints
export const billingApi = {
getBalance: () => api.get('/billing/balance'),
getTransactions: (limit = 50) => api.get(`/billing/transactions?limit=${limit}`),
createCheckout: (packId: string) => api.post('/billing/checkout', { pack_id: packId }),
};
// Personas endpoints
export const personasApi = {
getAll: () =>
@ -281,7 +283,7 @@ export const aiPersonasApi = {
count,
temperature: 0.7, // Use 0.7 temperature for basic profiles
customer_data_session_id: customerDataSessionId,
llm_model: llmModel || 'gemini-2.5-pro'
llm_model: llmModel || 'gpt-5.4'
}, {
timeout: 180000 // 3 minutes for basic profile generation
});
@ -306,7 +308,7 @@ export const aiPersonasApi = {
audience_brief: audienceBrief,
research_objective: researchObjective,
customer_data_session_id: customerDataSessionId,
llm_model: llmModel || 'gemini-2.5-pro'
llm_model: llmModel || 'gpt-5.4'
}, {
timeout: 180000 // 3 minutes for each persona completion
})
@ -371,7 +373,7 @@ export const aiPersonasApi = {
return api.post('/ai-personas/batch-generate-summaries', {
persona_ids: personaIds,
temperature,
llm_model: llmModel || 'gemini-2.5-pro'
llm_model: llmModel || 'gpt-5.4'
}, {
timeout: 180000 // 3 minutes timeout for batch processing
});
@ -412,7 +414,7 @@ export const aiPersonasApi = {
count,
temperature,
customer_data_session_id: customerDataSessionId,
llm_model: llmModel || 'gemini-2.5-pro',
llm_model: llmModel || 'gpt-5.4',
target_folder_id: targetFolderId
}, {
timeout: 10000 // 10 seconds — endpoint returns immediately with task_id
@ -749,6 +751,18 @@ export const adminApi = {
// Focus Groups (admin view)
listFocusGroups: (params?: { skip?: number; limit?: number; from?: string; to?: string }) =>
api.get('/admin/focus-groups', { params }),
// App settings (credit pricing config)
getSettings: () => api.get('/admin/settings'),
updateSettings: (data: any) => api.put('/admin/settings', data),
// Manual credit adjustment
adjustCredits: (userId: string, amount: number, reason: string) =>
api.post(`/admin/users/${userId}/credits`, { amount, reason }),
// Analytics
getAnalytics: (params?: { from?: string; to?: string }) =>
api.get('/admin/analytics', { params }),
};
export const usageApi = {

View file

@ -6,6 +6,8 @@ import UsersTab from '@/components/admin/UsersTab';
import UsageTab from '@/components/admin/UsageTab';
import PricingTab from '@/components/admin/PricingTab';
import FocusGroupsTab from '@/components/admin/FocusGroupsTab';
import AnalyticsTab from '@/components/admin/AnalyticsTab';
import CreditSettingsTab from '@/components/admin/CreditSettingsTab';
export default function Admin() {
const navigate = useNavigate();
@ -25,10 +27,12 @@ export default function Admin() {
</div>
<Tabs defaultValue="users">
<TabsList className="mb-6">
<TabsList className="mb-6 flex-wrap">
<TabsTrigger value="users">Users</TabsTrigger>
<TabsTrigger value="analytics">Analytics</TabsTrigger>
<TabsTrigger value="credits">Credits</TabsTrigger>
<TabsTrigger value="usage">Usage</TabsTrigger>
<TabsTrigger value="pricing">Pricing</TabsTrigger>
<TabsTrigger value="pricing">Model Pricing</TabsTrigger>
<TabsTrigger value="focus-groups">Focus Groups</TabsTrigger>
</TabsList>
@ -36,6 +40,14 @@ export default function Admin() {
<UsersTab />
</TabsContent>
<TabsContent value="analytics">
<AnalyticsTab />
</TabsContent>
<TabsContent value="credits">
<CreditSettingsTab />
</TabsContent>
<TabsContent value="usage">
<UsageTab />
</TabsContent>

View file

@ -739,11 +739,11 @@ const FocusGroupSession = () => {
duration: data.duration || 60,
topic: data.topic || 'general',
discussionGuide: data.discussionGuide || '',
llm_model: data.llm_model || 'gemini-3-pro-preview'
llm_model: data.llm_model || 'gpt-5.4'
};
setFocusGroup(focusGroupData);
setSelectedModel(focusGroupData.llm_model || 'gemini-3-pro-preview');
setSelectedModel(focusGroupData.llm_model || 'gpt-5.4');
setSelectedReasoningEffort(focusGroupData.reasoning_effort || 'medium');
setSelectedVerbosity(focusGroupData.verbosity || 'medium');
@ -796,8 +796,8 @@ const FocusGroupSession = () => {
try {
const updateData: any = { llm_model: newModel };
// Only include GPT-5.2 parameters if the model is GPT-5.2
if (newModel === 'gpt-5.2') {
// Include reasoning_effort/verbosity for models that support it
if (newModel === 'gpt-5.4' || newModel === 'gpt-5.4-mini') {
updateData.reasoning_effort = reasoningEffort || selectedReasoningEffort;
updateData.verbosity = verbosity || selectedVerbosity;
}
@ -808,14 +808,13 @@ const FocusGroupSession = () => {
setFocusGroup(prev => prev ? {
...prev,
llm_model: newModel,
reasoning_effort: newModel === 'gpt-5.2' ? (reasoningEffort || selectedReasoningEffort) : prev?.reasoning_effort,
verbosity: newModel === 'gpt-5.2' ? (verbosity || selectedVerbosity) : prev?.verbosity
reasoning_effort: (newModel === 'gpt-5.4' || newModel === 'gpt-5.4-mini') ? (reasoningEffort || selectedReasoningEffort) : prev?.reasoning_effort,
verbosity: (newModel === 'gpt-5.4' || newModel === 'gpt-5.4-mini') ? (verbosity || selectedVerbosity) : prev?.verbosity
} : null);
toastService.success('AI Model Updated', {
description: `Focus group will now use ${
newModel === 'gemini-3-pro-preview' ? 'Gemini 3 Pro' :
newModel === 'gpt-4.1' ? 'GPT-4.1' :
newModel === 'gpt-5.2' ? 'GPT-5.2' : newModel
newModel === 'gpt-5.4' ? 'GPT-5.4' :
newModel === 'gpt-5.4-mini' ? 'GPT-5.4 Mini' : newModel
} for AI responses`
});
setShowModelSettings(false);
@ -860,11 +859,11 @@ const FocusGroupSession = () => {
duration: data.duration || 60,
topic: data.topic || 'general',
discussionGuide: data.discussionGuide || '',
llm_model: data.llm_model || 'gemini-3-pro-preview'
llm_model: data.llm_model || 'gpt-5.4'
};
setFocusGroup(focusGroupData);
setSelectedModel(focusGroupData.llm_model || 'gemini-3-pro-preview');
setSelectedModel(focusGroupData.llm_model || 'gpt-5.4');
setSelectedReasoningEffort(focusGroupData.reasoning_effort || 'medium');
setSelectedVerbosity(focusGroupData.verbosity || 'medium');
@ -1889,8 +1888,7 @@ const FocusGroupSession = () => {
<div className="flex items-center mt-1">
<Bot className="h-3 w-3 text-slate-500 mr-1" />
<Badge variant="secondary" className="text-xs">
{focusGroup.llm_model === 'gpt-4.1' ? 'GPT-4.1' :
focusGroup.llm_model === 'gpt-5.2' ? 'GPT-5.2' : 'Gemini 3 Pro'}
{focusGroup.llm_model === 'gpt-5.4-mini' ? 'GPT-5.4 Mini' : 'GPT-5.4'}
</Badge>
</div>
{user?.role === 'admin' && fgCostTotal > 0 && (
@ -2265,8 +2263,7 @@ const FocusGroupSession = () => {
<Bot className="h-4 w-4 text-slate-500" />
<span className="text-sm font-medium">Current Model:</span>
<Badge variant="secondary">
{focusGroup?.llm_model === 'gpt-4.1' ? 'GPT-4.1' :
focusGroup?.llm_model === 'gpt-5.2' ? 'GPT-5.2' : 'Gemini 3 Pro'}
{focusGroup?.llm_model === 'gpt-5.4-mini' ? 'GPT-5.4 Mini' : 'GPT-5.4'}
</Badge>
</div>
@ -2279,15 +2276,14 @@ const FocusGroupSession = () => {
<SelectValue placeholder="Select AI model" />
</SelectTrigger>
<SelectContent>
<SelectItem value="gemini-3-pro-preview">Gemini 3 Pro (Slow, best for most tasks)</SelectItem>
<SelectItem value="gpt-4.1">GPT-4.1 (Fast, best for speed)</SelectItem>
<SelectItem value="gpt-5.2">GPT-5.2 (Slow, best for complex tasks)</SelectItem>
<SelectItem value="gpt-5.4">GPT-5.4 (Recommended)</SelectItem>
<SelectItem value="gpt-5.4-mini">GPT-5.4 Mini (Faster, lower cost)</SelectItem>
</SelectContent>
</Select>
</div>
{/* GPT-5.2 specific parameters - only show when GPT-5.2 is selected */}
{selectedModel === "gpt-5.2" && (
{/* Reasoning/verbosity parameters for gpt-5.4 models */}
{(selectedModel === "gpt-5.4" || selectedModel === "gpt-5.4-mini") && (
<>
{/* Reasoning Effort Parameter */}
<div>
@ -2304,10 +2300,10 @@ const FocusGroupSession = () => {
</SelectContent>
</Select>
<p className="text-xs text-slate-600 mt-1">
Controls how much time GPT-5.2 spends thinking before responding
Controls how much time GPT-5.4 spends thinking before responding
</p>
<p className="text-xs text-amber-600 font-medium mt-1">
Controls how thoroughly GPT-5.2 thinks and how detailed responses are
Controls how thoroughly GPT-5.4 thinks and how detailed responses are
</p>
</div>
@ -2325,19 +2321,18 @@ Controls how thoroughly GPT-5.2 thinks and how detailed responses are
</SelectContent>
</Select>
<p className="text-xs text-slate-600 mt-1">
Controls how detailed and lengthy GPT-5.2's responses will be
Controls how detailed and lengthy GPT-5.4's responses will be
</p>
<p className="text-xs text-amber-600 font-medium mt-1">
Controls how thoroughly GPT-5.2 thinks and how detailed responses are
Controls how thoroughly GPT-5.4 thinks and how detailed responses are
</p>
</div>
</>
)}
<div className="text-xs text-slate-600">
<p><strong>Gemini 3 Pro:</strong> Google's advanced model, great for creative and analytical tasks.</p>
<p><strong>GPT-4.1:</strong> OpenAI's latest model, excellent for conversational and reasoning tasks.</p>
<p><strong>GPT-5.2:</strong> OpenAI's newest model with advanced reasoning and customizable response styles.</p>
<p><strong>GPT-5.4:</strong> Recommended model. Best quality for complex analysis and persona responses.</p>
<p><strong>GPT-5.4 Mini:</strong> Faster and lower cost. Great for most tasks with good quality.</p>
</div>
</div>
@ -2354,7 +2349,7 @@ Controls how thoroughly GPT-5.2 thinks and how detailed responses are
updateFocusGroupModel(selectedModel, selectedReasoningEffort, selectedVerbosity);
}}
disabled={isUpdatingModel || (selectedModel === focusGroup?.llm_model &&
(selectedModel !== 'gpt-5.2' ||
(!(selectedModel === 'gpt-5.4' || selectedModel === 'gpt-5.4-mini') ||
(selectedReasoningEffort === (focusGroup?.reasoning_effort || 'medium') &&
selectedVerbosity === (focusGroup?.verbosity || 'medium'))))}
>

View file

@ -141,7 +141,7 @@ const SyntheticUsers = () => {
});
// LLM selection for download
const [downloadLlmModalOpen, setDownloadLlmModalOpen] = useState(false);
const [selectedDownloadLlmModel, setSelectedDownloadLlmModel] = useState<string>('gemini-3-pro-preview');
const [selectedDownloadLlmModel, setSelectedDownloadLlmModel] = useState<string>('gpt-5.4');
// Bulk export no longer needs state - direct download
@ -1035,7 +1035,7 @@ const SyntheticUsers = () => {
summaryGenerationControls.completeGeneration();
// Show success toast with details including model information
const modelDisplayName = selectedDownloadLlmModel === 'gpt-4.1' ? 'GPT-4.1' : 'Gemini 3 Pro';
const modelDisplayName = selectedDownloadLlmModel === 'gpt-5.4-mini' ? 'GPT-5.4 Mini' : 'GPT-5.4';
if (summary_stats.total_successful === summary_stats.total_requested) {
toastService.success("Persona summary downloaded", {
description: `Successfully processed all ${summary_stats.total_successful} persona${summary_stats.total_successful !== 1 ? 's' : ''} from "${folderName}" using ${modelDisplayName}`
@ -1099,7 +1099,7 @@ const SyntheticUsers = () => {
try {
// Get JWT token for the request
const token = localStorage.getItem('auth_token');
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '/semblance_back/api';
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api';
// Make direct fetch request since response will be a file
const response = await fetch(`${API_BASE_URL}/personas/bulk-export`, {
@ -1904,15 +1904,15 @@ const SyntheticUsers = () => {
className="space-y-3"
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="gemini-3-pro-preview" id="download-gemini" />
<Label htmlFor="download-gemini" className="text-sm font-medium">
Gemini 3 Pro (Slow, best for most tasks)
<RadioGroupItem value="gpt-5.4" id="download-gpt54" />
<Label htmlFor="download-gpt54" className="text-sm font-medium">
GPT-5.4 (Recommended)
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="gpt-4.1" id="download-gpt" />
<Label htmlFor="download-gpt" className="text-sm font-medium">
GPT-4.1 (Fast, best for speed)
<RadioGroupItem value="gpt-5.4-mini" id="download-gpt54mini" />
<Label htmlFor="download-gpt54mini" className="text-sm font-medium">
GPT-5.4 Mini (Faster, lower cost)
</Label>
</div>
</RadioGroup>

View file

@ -167,14 +167,7 @@ export function getWebSocketUrl(): string {
return import.meta.env.VITE_WEBSOCKET_URL;
}
// TEMP DEBUG: Try direct connection to backend bypassing Apache
// Add ?direct=1 to URL to test direct connection
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.get('direct') === '1') {
return 'https://optical-dev.oliver.solutions:5137';
}
// For production with Apache proxy, use the current origin
// For production with Traefik proxy, use the current origin
// The Apache proxy handles the routing to backend
const protocol = window.location.protocol === 'https:' ? 'https:' : 'http:';
const host = window.location.host; // includes port if any

View file

@ -7,7 +7,7 @@ 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/";
const SOCKET_PATH = import.meta.env.VITE_WEBSOCKET_PATH || "/socket.io/";
let socket: Socket | null = null;
let currentRoom: string | null = null;

View file

@ -74,7 +74,7 @@ ${discussionGuide}
---
*Exported from Semblance Synthetic Society*`;
*Exported from Cohorta*`;
}
/**
@ -158,7 +158,7 @@ function convertStructuredDiscussionGuideToMarkdown(
markdown += '---\n\n';
});
markdown += '*Exported from Semblance Synthetic Society*';
markdown += '*Exported from Cohorta*';
return markdown;
}

View file

@ -7,7 +7,7 @@ YELLOW='\033[0;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
echo -e "${BLUE}=== Semblance Synthetic Society Startup Script ===${NC}"
echo -e "${BLUE}=== Cohorta Startup Script ===${NC}"
# Check if MongoDB is installed
if ! command -v mongod &> /dev/null; then

View file

@ -1,11 +1,9 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react-swc";
import path from "path";
import { componentTagger } from "lovable-tagger";
// https://vitejs.dev/config/
export default defineConfig(({ mode }) => ({
base: mode === 'production' ? '/semblance/' : '/',
base: '/',
define: {
'import.meta.env.VITE_ENABLE_WEBSOCKET': JSON.stringify(mode === 'development' ? 'true' : 'false'),
},
@ -30,9 +28,7 @@ export default defineConfig(({ mode }) => ({
},
plugins: [
react(),
mode === 'development' &&
componentTagger(),
].filter(Boolean),
],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),