Lots of changes
This commit is contained in:
parent
e36ed818fb
commit
2a83118689
@ -1,9 +1,15 @@
|
||||
import React from 'react';
|
||||
import { Box, Link, Typography, Avatar, Paper, Grid, Chip, SxProps } from '@mui/material';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardActionArea,
|
||||
Divider,
|
||||
useTheme,
|
||||
} from '@mui/material';
|
||||
import { useMediaQuery } from '@mui/material';
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
import { styled } from '@mui/material/styles';
|
||||
import { Route, Routes, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { UserInfo, useUser } from "./UserContext";
|
||||
import LinkIcon from '@mui/icons-material/Link';
|
||||
import { CopyBubble } from "../../Components/CopyBubble";
|
||||
@ -42,18 +48,34 @@ const CandidateInfo: React.FC<CandidateInfoProps> = (props: CandidateInfoProps)
|
||||
if (size < 1000000) return `${(size / 1000).toFixed(1)}K RAG elements`;
|
||||
return `${(size / 1000000).toFixed(1)}M RAG elements`;
|
||||
};
|
||||
const view = props.user || user;
|
||||
const candidate = props.user || user;
|
||||
|
||||
if (!view) {
|
||||
if (!candidate) {
|
||||
return <Box>No user loaded.</Box>;
|
||||
}
|
||||
return (
|
||||
<StyledPaper sx={sx}>
|
||||
<Grid container spacing={2}>
|
||||
<Card
|
||||
elevation={1}
|
||||
sx={{
|
||||
display: "flex",
|
||||
borderColor: 'transparent',
|
||||
borderWidth: 2,
|
||||
borderStyle: 'solid',
|
||||
transition: 'all 0.3s ease',
|
||||
...sx
|
||||
}}
|
||||
>
|
||||
<CardActionArea
|
||||
//onClick={() => setSelectedCandidate(candidate)}
|
||||
sx={{ height: '100%', display: 'flex', flexDirection: 'column', alignItems: 'stretch' }}
|
||||
>
|
||||
<CardContent sx={{ flexGrow: 1, p: 3 }}>
|
||||
|
||||
<Grid container spacing={2}>
|
||||
<Grid size={{ xs: 12, sm: 2 }} sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minWidth: "80px", maxWidth: "80px" }}>
|
||||
<Avatar
|
||||
src={view.has_profile ? `/api/u/${view.username}/profile/${sessionId}?timestamp=${Date.now()}` : ''}
|
||||
alt={`${view.full_name}'s profile`}
|
||||
src={candidate.has_profile ? `/api/u/${candidate.username}/profile/${sessionId}?timestamp=${Date.now()}` : ''}
|
||||
alt={`${candidate.full_name}'s profile`}
|
||||
sx={{
|
||||
width: 80,
|
||||
height: 80,
|
||||
@ -68,20 +90,20 @@ const CandidateInfo: React.FC<CandidateInfoProps> = (props: CandidateInfoProps)
|
||||
<Box sx={{ display: "flex", flexDirection: isMobile ? "column" : "row", alignItems: "center", gap: 1, "& > .MuiTypography-root": { m: 0 } }}>
|
||||
{action !== '' && <Typography variant="body1">{action}</Typography>}
|
||||
<Typography variant="h5" component="h1" sx={{ fontWeight: 'bold', whiteSpace: 'nowrap' }}>
|
||||
{view.full_name}
|
||||
{candidate.full_name}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ fontSize: "0.75rem", alignItems: "center" }} >
|
||||
<Link href={`/u/${view.username}`}>/u/{view.username}</Link>
|
||||
<Link href={`/u/${candidate.username}`}>/u/{candidate.username}</Link>
|
||||
<CopyBubble
|
||||
onClick={(event: any) => { event.stopPropagation() }}
|
||||
tooltip="Copy link" content={`${window.location.origin}/u/{view.username}`} />
|
||||
tooltip="Copy link" content={`${window.location.origin}/u/{candidate.username}`} />
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{view.rag_content_size !== undefined && view.rag_content_size > 0 && <Chip
|
||||
{candidate.rag_content_size !== undefined && candidate.rag_content_size > 0 && <Chip
|
||||
onClick={(event: React.MouseEvent<HTMLDivElement>) => { navigate('/knowledge-explorer'); event.stopPropagation() }}
|
||||
label={formatRagSize(view.rag_content_size)}
|
||||
label={formatRagSize(candidate.rag_content_size)}
|
||||
color="primary"
|
||||
size="small"
|
||||
sx={{ ml: 2 }}
|
||||
@ -89,11 +111,26 @@ const CandidateInfo: React.FC<CandidateInfoProps> = (props: CandidateInfoProps)
|
||||
</Box>
|
||||
|
||||
<Typography variant="body1" color="text.secondary">
|
||||
{view.description}
|
||||
{candidate.description}
|
||||
</Typography>
|
||||
|
||||
<Divider sx={{ my: 2 }} />
|
||||
|
||||
<Typography variant="body2" sx={{ mb: 1 }}>
|
||||
<strong>Location:</strong> {candidate.location}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 1 }}>
|
||||
<strong>Email:</strong> {candidate.email}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
<strong>Phone:</strong> {candidate.phone}
|
||||
</Typography>
|
||||
</Grid>
|
||||
|
||||
</Grid>
|
||||
</Grid>
|
||||
</StyledPaper>
|
||||
</CardContent>
|
||||
</CardActionArea>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -21,6 +21,10 @@ interface UserInfo {
|
||||
questions: UserQuestion[],
|
||||
isAuthenticated: boolean,
|
||||
has_profile: boolean,
|
||||
title: string;
|
||||
location: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
// Fields used in AI generated personas
|
||||
age?: number,
|
||||
ethnicity?: string,
|
||||
|
@ -1,5 +1,6 @@
|
||||
import React, { forwardRef, useEffect, useState, MouseEventHandler } from 'react';
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import Button from '@mui/material/Button';
|
||||
import useMediaQuery from '@mui/material/useMediaQuery';
|
||||
import Box from '@mui/material/Box';
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
@ -62,7 +63,17 @@ const CandidateListingPage = (props: BackstoryPageProps) => {
|
||||
}, [users]);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{display: "flex", flexDirection: "column"}}>
|
||||
<Box sx={{ p: 1 }}>
|
||||
Not seeing a candidate you like?
|
||||
<Button
|
||||
variant="contained"
|
||||
sx={{ml: 1}}
|
||||
onClick={() => { navigate('/generate-candidate')}}>
|
||||
Generate your own perfect AI candidate!
|
||||
</Button>
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap"}}>
|
||||
{users?.map((u, i) =>
|
||||
<Box key={`${u.username}`}
|
||||
onClick={(event: React.MouseEvent<HTMLDivElement>) : void => {
|
||||
@ -70,10 +81,11 @@ const CandidateListingPage = (props: BackstoryPageProps) => {
|
||||
}}
|
||||
sx={{ cursor: "pointer" }}
|
||||
>
|
||||
<CandidateInfo sessionId={sessionId} sx={{ "cursor": "pointer", "&:hover": { border: "2px solid orange" }, border: "2px solid transparent" }} user={u} />
|
||||
<CandidateInfo sessionId={sessionId} sx={{ maxWidth: "320px", "cursor": "pointer", "&:hover": { border: "2px solid orange" }, border: "2px solid transparent" }} user={u} />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -33,7 +33,11 @@ const emptyUser : UserInfo = {
|
||||
contact_info: {},
|
||||
questions: [],
|
||||
isAuthenticated: false,
|
||||
has_profile: false
|
||||
has_profile: false,
|
||||
title: '[blank]',
|
||||
location: '[blank]',
|
||||
email: '[blank]',
|
||||
phone: '[blank]',
|
||||
};
|
||||
|
||||
const GenerateCandidate = (props: BackstoryElementProps) => {
|
||||
@ -167,7 +171,7 @@ const GenerateCandidate = (props: BackstoryElementProps) => {
|
||||
}
|
||||
|
||||
const imagePrompt = `A photorealistic profile picture of a ${user.age} year old ${user.gender} ${user.ethnicity} person. ${prompt}`
|
||||
setStatus(`Generating: ${imagePrompt}`);
|
||||
setStatus('Staring image generation...');
|
||||
setProcessing(true);
|
||||
setCanGenImage(false);
|
||||
setState(3);
|
||||
@ -260,55 +264,76 @@ const GenerateCandidate = (props: BackstoryElementProps) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<Box className="GenerateCandidate" sx={{
|
||||
display: "flex", flexDirection: "column", flexGrow: 1, gap: 1, width: { xs: '100%', md: '700px', lg: '1024px' }
|
||||
}}>
|
||||
{user && <CandidateInfo sessionId={sessionId} user={user} />}
|
||||
{processing &&
|
||||
<Box sx={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
m: 2,
|
||||
}}>
|
||||
<Box sx={{flexDirection: "row", fontWeight: "bold"}}>{status}</Box>
|
||||
<PropagateLoader
|
||||
size="10px"
|
||||
loading={processing}
|
||||
aria-label="Loading Spinner"
|
||||
data-testid="loader"
|
||||
/>
|
||||
<Box className="GenerateCandidate" sx={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
flexGrow: 1,
|
||||
gap: 1, width: { xs: '100%', md: '700px', lg: '1024px' }
|
||||
}}>
|
||||
{user && <CandidateInfo
|
||||
sessionId={sessionId}
|
||||
user={user}
|
||||
sx={{flexShrink: 1}}/>
|
||||
}
|
||||
{ prompt &&
|
||||
<Box sx={{ display: "flex", flexDirection: "column"}}>
|
||||
<Box sx={{ fontSize: "0.5rem"}}>Persona prompt</Box>
|
||||
<Box sx={{ fontWeight: "bold"}}>{prompt}</Box>
|
||||
</Box>
|
||||
}
|
||||
{processing &&
|
||||
<Box sx={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
m: 2,
|
||||
}}>
|
||||
{ status && <Box sx={{ display: "flex", flexDirection: "column"}}>
|
||||
<Box sx={{ fontSize: "0.5rem"}}>Generation status</Box>
|
||||
<Box sx={{ fontWeight: "bold"}}>{status}</Box>
|
||||
</Box>}
|
||||
<PropagateLoader
|
||||
size="10px"
|
||||
loading={processing}
|
||||
aria-label="Loading Spinner"
|
||||
data-testid="loader"
|
||||
/>
|
||||
</Box>
|
||||
}
|
||||
<Box sx={{display: "flex", flexDirection: "column"}}>
|
||||
<Box sx={{ display: "flex", flexDirection: "row", position: "relative" }}>
|
||||
<Box sx={{ display: "flex", position: "relative" }}>
|
||||
<Avatar
|
||||
src={user.has_profile ? `/api/u/${user.username}/profile/${sessionId}` : ''}
|
||||
alt={`${user.full_name}'s profile`}
|
||||
sx={{
|
||||
width: 80,
|
||||
height: 80,
|
||||
border: '2px solid #e0e0e0',
|
||||
}}
|
||||
/>
|
||||
{processing && <Pulse sx={{ position: "relative", left: "-80px", top: "0px" }} timestamp={timestamp} />}
|
||||
</Box>
|
||||
<Tooltip title={"Generate Picture"}>
|
||||
<span style={{ display: "flex", flexGrow: 1 }}>
|
||||
<Button
|
||||
sx={{ m: 1, gap: 1, flexGrow: 1 }}
|
||||
variant="contained"
|
||||
disabled={sessionId === undefined || processing || !canGenImage}
|
||||
onClick={() => { setShouldGenerateProfile(true); }}>
|
||||
Generate Picture<SendIcon />
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Box sx={{ display: "flex", flexDirection: "row", position: "relative" }}>
|
||||
<Box sx={{ display: "flex", position: "relative" }}>
|
||||
<Avatar
|
||||
src={user.has_profile ? `/api/u/${user.username}/profile/${sessionId}` : ''}
|
||||
alt={`${user.full_name}'s profile`}
|
||||
sx={{
|
||||
width: 80,
|
||||
height: 80,
|
||||
border: '2px solid #e0e0e0',
|
||||
}}
|
||||
/>
|
||||
{processing && <Pulse sx={{ position: "relative", left: "-80px", top: "0px" }} timestamp={timestamp} />}
|
||||
</Box>
|
||||
<Tooltip title={"Generate Picture"}>
|
||||
<span style={{ display: "flex", flexGrow: 1 }}>
|
||||
<Button
|
||||
sx={{ m: 1, gap: 1, flexGrow: 1 }}
|
||||
variant="contained"
|
||||
disabled={sessionId === undefined || processing || !canGenImage}
|
||||
onClick={() => { setShouldGenerateProfile(true); }}>
|
||||
Generate Picture<SendIcon />
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Box>
|
||||
{ resume !== '' && <Paper sx={{pt: 1, pb: 1, pl: 2, pr: 2}}><Scrollable sx={{flexGrow: 1}}><StyledMarkdown {...{content: resume, setSnack, sessionId, submitQuery}}/></Scrollable></Paper> }
|
||||
{ resume !== '' &&
|
||||
<Paper sx={{pt: 1, pb: 1, pl: 2, pr: 2}}>
|
||||
<Scrollable sx={{flexGrow: 1}}>
|
||||
<StyledMarkdown {...{content: resume, setSnack, sessionId, submitQuery}}/>
|
||||
</Scrollable>
|
||||
</Paper> }
|
||||
<BackstoryTextField
|
||||
style={{ flexGrow: 0, flexShrink: 1 }}
|
||||
ref={backstoryTextRef}
|
||||
@ -343,6 +368,7 @@ const GenerateCandidate = (props: BackstoryElementProps) => {
|
||||
</span>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
<Box sx={{display: "flex", flexGrow: 1}}/>
|
||||
</Box>);
|
||||
};
|
||||
|
||||
|
@ -21,6 +21,8 @@ import time
|
||||
import asyncio
|
||||
import time
|
||||
import os
|
||||
import random
|
||||
from names_dataset import NameDataset, NameWrapper # type: ignore
|
||||
|
||||
from .base import Agent, agent_registry, LLMMessage
|
||||
from ..message import Message
|
||||
@ -42,6 +44,9 @@ emptyUser = {
|
||||
"first_name": "",
|
||||
"last_name": "",
|
||||
"full_name": "",
|
||||
"email": "",
|
||||
"phone": "",
|
||||
"title": "",
|
||||
"contact_info": {},
|
||||
"questions": [],
|
||||
}
|
||||
@ -76,6 +81,9 @@ Provide all information in English ONLY, with no other commentary:
|
||||
"last_name": string,
|
||||
"description": string, # One to two sentence description of their job
|
||||
"location": string, # In the location, provide ALL of: City, State/Region, and Country
|
||||
"phone": string, # Location appropriate phone number with area code
|
||||
"email": string, # primary email address
|
||||
"title": string, # Job title of their current job
|
||||
}
|
||||
```
|
||||
|
||||
@ -107,6 +115,117 @@ Use that information to invent a full career resume. Include sections such as:
|
||||
Provide the resume in Markdown format. DO NOT provide any commentary before or after the resume.
|
||||
"""
|
||||
|
||||
class EthnicNameGenerator:
|
||||
def __init__(self):
|
||||
self.nd = NameDataset()
|
||||
|
||||
# US Census 2020 approximate ethnic distribution
|
||||
self.ethnic_weights = {
|
||||
'White': 0.576,
|
||||
'Hispanic': 0.186,
|
||||
'Black': 0.134,
|
||||
'Asian': 0.062,
|
||||
'Native American': 0.013,
|
||||
'Pacific Islander': 0.003,
|
||||
'Mixed/Other': 0.026
|
||||
}
|
||||
|
||||
# Map ethnicities to name origins/countries that approximate those populations
|
||||
self.ethnic_name_mapping = {
|
||||
'White': ['United States', 'United Kingdom', 'Germany', 'Ireland', 'Italy', 'Poland'],
|
||||
'Hispanic': ['Mexico', 'Spain', 'Colombia', 'Peru', 'Argentina', 'Cuba', 'Puerto Rico'],
|
||||
'Black': ['United States'], # African American names
|
||||
'Asian': ['China', 'India', 'Philippines', 'Vietnam', 'Korea', 'Japan'],
|
||||
'Native American': ['United States'], # Limited options in dataset
|
||||
'Pacific Islander': ['United States'], # Limited options in dataset
|
||||
'Mixed/Other': ['United States'] # Default to US names
|
||||
}
|
||||
|
||||
def get_weighted_ethnicity(self):
|
||||
"""Select ethnicity based on US demographic weights"""
|
||||
ethnicities = list(self.ethnic_weights.keys())
|
||||
weights = list(self.ethnic_weights.values())
|
||||
return random.choices(ethnicities, weights=weights)[0]
|
||||
|
||||
def get_name_by_ethnicity(self, ethnicity, gender='random'):
|
||||
"""Generate a name based on ethnicity"""
|
||||
if gender == 'random':
|
||||
gender = random.choice(['Male', 'Female'])
|
||||
|
||||
countries = self.ethnic_name_mapping.get(ethnicity, ['United States'])
|
||||
|
||||
# Try to get names from the mapped countries
|
||||
first_name = None
|
||||
last_name = None
|
||||
|
||||
for country in countries:
|
||||
try:
|
||||
# Get first names
|
||||
if country in self.nd.first_names:
|
||||
country_first_names = self.nd.first_names[country]
|
||||
if gender.lower() in country_first_names:
|
||||
gender_names = country_first_names[gender.lower()]
|
||||
if gender_names:
|
||||
first_name = random.choice(gender_names)
|
||||
break
|
||||
|
||||
except (KeyError, AttributeError):
|
||||
continue
|
||||
|
||||
# Fallback to US names if no ethnicity-specific name found
|
||||
if not first_name:
|
||||
try:
|
||||
us_names = self.nd.first_names.get('United States', {})
|
||||
gender_names = us_names.get(gender.lower(), [])
|
||||
if gender_names:
|
||||
first_name = random.choice(gender_names)
|
||||
else:
|
||||
# Ultimate fallback
|
||||
first_name = random.choice(['John', 'Jane', 'Michael', 'Sarah'])
|
||||
except:
|
||||
first_name = random.choice(['John', 'Jane', 'Michael', 'Sarah'])
|
||||
|
||||
# Get last name
|
||||
for country in countries:
|
||||
try:
|
||||
if country in self.nd.last_names and self.nd.last_names[country]:
|
||||
last_name = random.choice(self.nd.last_names[country])
|
||||
break
|
||||
except (KeyError, AttributeError):
|
||||
continue
|
||||
|
||||
# Fallback for last name
|
||||
if not last_name:
|
||||
try:
|
||||
us_last_names = self.nd.last_names.get('United States', [])
|
||||
if us_last_names:
|
||||
last_name = random.choice(us_last_names)
|
||||
else:
|
||||
last_name = random.choice(['Smith', 'Johnson', 'Williams', 'Brown'])
|
||||
except:
|
||||
last_name = random.choice(['Smith', 'Johnson', 'Williams', 'Brown'])
|
||||
|
||||
return first_name, last_name, ethnicity, gender
|
||||
|
||||
def generate_random_name(self, gender='random'):
|
||||
"""Generate a random name with ethnicity based on US demographics"""
|
||||
ethnicity = self.get_weighted_ethnicity()
|
||||
return self.get_name_by_ethnicity(ethnicity, gender)
|
||||
|
||||
def generate_multiple_names(self, count=10, gender='random'):
|
||||
"""Generate multiple random names"""
|
||||
names = []
|
||||
for _ in range(count):
|
||||
first, last, ethnicity, actual_gender = self.generate_random_name(gender)
|
||||
names.append({
|
||||
'full_name': f"{first} {last}",
|
||||
'first_name': first,
|
||||
'last_name': last,
|
||||
'ethnicity': ethnicity,
|
||||
'gender': actual_gender
|
||||
})
|
||||
return names
|
||||
|
||||
class PersonaGenerator(Agent):
|
||||
agent_type: Literal["persona"] = "persona" # type: ignore
|
||||
_agent_type: ClassVar[str] = agent_type # Add this for registration
|
||||
@ -115,32 +234,19 @@ class PersonaGenerator(Agent):
|
||||
system_prompt: str = generate_persona_system_prompt
|
||||
age: int = Field(default_factory=lambda: random.randint(22, 67))
|
||||
gender: str = Field(default_factory=lambda: random.choice(["male", "female"]))
|
||||
ethnicity: Literal[
|
||||
"Asian", "African", "Caucasian", "Hispanic/Latino", "Mixed/Multiracial"
|
||||
] = Field(
|
||||
default_factory=lambda: random.choices(
|
||||
["Asian", "African", "Caucasian", "Hispanic/Latino", "Mixed/Multiracial"],
|
||||
weights=[57.69, 15.38, 19.23, 5.77, 1.92],
|
||||
k=1
|
||||
)[0]
|
||||
)
|
||||
username: str = ""
|
||||
first_name: str = ""
|
||||
last_name: str = ""
|
||||
ethnicity: str = ""
|
||||
|
||||
generator: Any = Field(default=EthnicNameGenerator(), exclude=True)
|
||||
llm: Any = Field(default=None, exclude=True)
|
||||
model: str = Field(default=None, exclude=True)
|
||||
|
||||
def randomize(self):
|
||||
self.age = random.randint(22, 67)
|
||||
self.gender = random.choice(["male", "female"])
|
||||
# Use random.choices with explicit type casting to satisfy Literal type
|
||||
self.ethnicity = cast(
|
||||
Literal["Asian", "African", "Caucasian", "Hispanic/Latino", "Mixed/Multiracial"],
|
||||
random.choices(
|
||||
["Asian", "African", "Caucasian", "Hispanic/Latino", "Mixed/Multiracial"],
|
||||
weights=[57.69, 15.38, 19.23, 5.77, 1.92],
|
||||
k=1
|
||||
)[0]
|
||||
)
|
||||
self.first_name, self.last_name, self.ethnicity, self.gender = self.generator.generate_random_name()
|
||||
|
||||
async def prepare_message(self, message: Message) -> AsyncGenerator[Message, None]:
|
||||
logger.info(f"{self.agent_type} - {inspect.stack()[0].function}")
|
||||
@ -152,6 +258,8 @@ class PersonaGenerator(Agent):
|
||||
message.tunables.enable_rag = False
|
||||
message.tunables.enable_context = False
|
||||
|
||||
self.randomize()
|
||||
|
||||
message.prompt = f"""\
|
||||
```json
|
||||
{json.dumps({
|
||||
@ -260,7 +368,10 @@ class PersonaGenerator(Agent):
|
||||
"full_name": "{persona["full_name"]}",
|
||||
"location": "{persona["location"]}",
|
||||
"age": {persona["age"]},
|
||||
"description": {persona["description"]}
|
||||
"description": {persona["description"]},
|
||||
"title": {persona["title"]},
|
||||
"email": {persona["email"]},
|
||||
"phone": {persona["phone"]}
|
||||
}}
|
||||
```
|
||||
"""
|
||||
|
Loading…
x
Reference in New Issue
Block a user