mirror of
https://github.com/open-webui/open-webui
synced 2025-06-10 00:17:52 +00:00
feat: Implement Agent Creation with Generalized Model Support
This commit introduces the initial Agent Creation Playground feature and ensures it supports all model types available in Open WebUI, not just Ollama models. Key changes include: Frontend: - Created a new Agent Creation form at `/agents/create`. - Added a link to the playground in the sidebar (admin only). - The form now fetches and displays all available models from the `/api/models` endpoint. - Updated UI to correctly handle generalized model IDs. Backend: - Modified the `Agent` model to store a generic `model_id` (foreign key to the `models` table) instead of a specific `llm_model`. - Updated API endpoints and Pydantic schemas accordingly. - Created database migrations to reflect schema changes. - Verified that the existing `/api/models` endpoint can be used to list all models for the creation form. Testing: - Added Cypress E2E tests for the agent creation form. - Tests verify that different model types can be selected and that the correct `model_id` and other agent details are submitted to the backend. This provides a foundation for you to define agents using any model integrated with your Open WebUI instance.
This commit is contained in:
parent
737dc7797c
commit
1345b55fe1
@ -0,0 +1,43 @@
|
||||
"""create_agents_table
|
||||
|
||||
Revision ID: 019
|
||||
Revises: 018
|
||||
Create Date: 2024-03-15 10:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '019'
|
||||
down_revision: Union[str, None] = '018'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('agent',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('name', sa.String(), nullable=False),
|
||||
sa.Column('role', sa.String(), nullable=False),
|
||||
sa.Column('system_message', sa.String(), nullable=True),
|
||||
sa.Column('llm_model', sa.String(), nullable=False),
|
||||
sa.Column('skills', sa.JSON(), nullable=True),
|
||||
sa.Column('user_id', sa.String(), nullable=False),
|
||||
sa.Column('timestamp', sa.DateTime(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_agent_id'), 'agent', ['id'], unique=False)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index(op.f('ix_agent_id'), table_name='agent')
|
||||
op.drop_table('agent')
|
||||
# ### end Alembic commands ###
|
@ -0,0 +1,35 @@
|
||||
"""modify_agents_table_for_generic_model_id
|
||||
|
||||
Revision ID: 020
|
||||
Revises: 019
|
||||
Create Date: 2024-03-15 11:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '020'
|
||||
down_revision: Union[str, None] = '019'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('agent', schema=None) as batch_op:
|
||||
batch_op.alter_column('llm_model', new_column_name='model_id', existing_type=sa.String(), nullable=False)
|
||||
batch_op.create_foreign_key('fk_agent_model_id_model', 'model', ['model_id'], ['id'])
|
||||
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('agent', schema=None) as batch_op:
|
||||
batch_op.drop_constraint('fk_agent_model_id_model', type_='foreignkey')
|
||||
batch_op.alter_column('model_id', new_column_name='llm_model', existing_type=sa.String(), nullable=False)
|
||||
# ### end Alembic commands ###
|
@ -80,6 +80,7 @@ from open_webui.routers import (
|
||||
tools,
|
||||
users,
|
||||
utils,
|
||||
agents,
|
||||
)
|
||||
|
||||
from open_webui.routers.retrieval import (
|
||||
@ -1058,6 +1059,7 @@ app.include_router(
|
||||
evaluations.router, prefix="/api/v1/evaluations", tags=["evaluations"]
|
||||
)
|
||||
app.include_router(utils.router, prefix="/api/v1/utils", tags=["utils"])
|
||||
app.include_router(agents.router, prefix="/api/v1/agents", tags=["agents"])
|
||||
|
||||
|
||||
try:
|
||||
|
27
backend/open_webui/models/agents.py
Normal file
27
backend/open_webui/models/agents.py
Normal file
@ -0,0 +1,27 @@
|
||||
from sqlalchemy import Column, Integer, String, JSON, DateTime, ForeignKey
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from datetime import datetime
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
class Agent(Base):
|
||||
__tablename__ = "agent"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String, nullable=False)
|
||||
role = Column(String, nullable=False)
|
||||
system_message = Column(String, nullable=True)
|
||||
model_id = Column(String, ForeignKey("model.id"), nullable=False) # Changed from llm_model to model_id and added ForeignKey
|
||||
skills = Column(JSON, nullable=True)
|
||||
user_id = Column(String, ForeignKey("user.id"), nullable=False)
|
||||
timestamp = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
# Define the relationship to the User model if User model exists and is defined with Base
|
||||
# Assuming User model is in a file named 'users.py' and User class is named 'User'
|
||||
# from .users import User # Import User model
|
||||
# owner = relationship("User") # Example relationship
|
||||
# model = relationship("Model") # Add relationship to Model table
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Agent(id={self.id}, name='{self.name}', user_id='{self.user_id}', model_id='{self.model_id}')>"
|
76
backend/open_webui/routers/agents.py
Normal file
76
backend/open_webui/routers/agents.py
Normal file
@ -0,0 +1,76 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List, Optional
|
||||
|
||||
from open_webui.models.agents import Agent as AgentModel
|
||||
from open_webui.models.auths import User as UserModel
|
||||
from open_webui.utils.utils import get_current_user, get_db
|
||||
from pydantic import BaseModel, Field
|
||||
from datetime import datetime
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# Pydantic Schemas for Agent CRUD operations
|
||||
|
||||
class AgentBase(BaseModel):
|
||||
name: str
|
||||
role: str
|
||||
system_message: Optional[str] = None
|
||||
model_id: str # Changed from llm_model to model_id
|
||||
skills: Optional[List[str]] = None # Assuming skills are a list of strings for now
|
||||
|
||||
class AgentCreate(AgentBase):
|
||||
pass
|
||||
|
||||
class AgentResponse(AgentBase):
|
||||
id: int
|
||||
user_id: str
|
||||
timestamp: datetime
|
||||
|
||||
class Config:
|
||||
orm_mode = True # For FastAPI to map SQLAlchemy models to Pydantic models
|
||||
|
||||
# API Endpoints
|
||||
|
||||
@router.post("/", response_model=AgentResponse)
|
||||
async def create_agent(agent_data: AgentCreate, db: Session = Depends(get_db), current_user: UserModel = Depends(get_current_user)):
|
||||
agent = AgentModel(**agent_data.model_dump(), user_id=current_user.id)
|
||||
db.add(agent)
|
||||
db.commit()
|
||||
db.refresh(agent)
|
||||
return agent
|
||||
|
||||
@router.get("/", response_model=List[AgentResponse])
|
||||
async def get_agents(db: Session = Depends(get_db), current_user: UserModel = Depends(get_current_user)):
|
||||
agents = db.query(AgentModel).filter(AgentModel.user_id == current_user.id).all()
|
||||
return agents
|
||||
|
||||
@router.get("/{agent_id}", response_model=AgentResponse)
|
||||
async def get_agent(agent_id: int, db: Session = Depends(get_db), current_user: UserModel = Depends(get_current_user)):
|
||||
agent = db.query(AgentModel).filter(AgentModel.id == agent_id, AgentModel.user_id == current_user.id).first()
|
||||
if not agent:
|
||||
raise HTTPException(status_code=404, detail="Agent not found")
|
||||
return agent
|
||||
|
||||
@router.put("/{agent_id}", response_model=AgentResponse)
|
||||
async def update_agent(agent_id: int, agent_data: AgentCreate, db: Session = Depends(get_db), current_user: UserModel = Depends(get_current_user)):
|
||||
agent = db.query(AgentModel).filter(AgentModel.id == agent_id, AgentModel.user_id == current_user.id).first()
|
||||
if not agent:
|
||||
raise HTTPException(status_code=404, detail="Agent not found")
|
||||
|
||||
for key, value in agent_data.model_dump(exclude_unset=True).items():
|
||||
setattr(agent, key, value)
|
||||
|
||||
agent.timestamp = datetime.utcnow() # Update timestamp
|
||||
db.commit()
|
||||
db.refresh(agent)
|
||||
return agent
|
||||
|
||||
@router.delete("/{agent_id}", status_code=204)
|
||||
async def delete_agent(agent_id: int, db: Session = Depends(get_db), current_user: UserModel = Depends(get_current_user)):
|
||||
agent = db.query(AgentModel).filter(AgentModel.id == agent_id, AgentModel.user_id == current_user.id).first()
|
||||
if not agent:
|
||||
raise HTTPException(status_code=404, detail="Agent not found")
|
||||
db.delete(agent)
|
||||
db.commit()
|
||||
return None
|
97
cypress/e2e/agent_creation.cy.ts
Normal file
97
cypress/e2e/agent_creation.cy.ts
Normal file
@ -0,0 +1,97 @@
|
||||
describe('Agent Creation Form', () => {
|
||||
const mockModels = [
|
||||
{ id: 'ollama/mistral:latest', name: 'Mistral (Ollama)' },
|
||||
{ id: 'gguf/phi-2:latest', name: 'Phi-2 (GGUF)' },
|
||||
{ id: 'openai/gpt-4', name: 'GPT-4 (OpenAI)' },
|
||||
];
|
||||
|
||||
const agentDetails = {
|
||||
name: 'Test Agent',
|
||||
role: 'Test Role',
|
||||
systemMessage: 'This is a test system message.',
|
||||
skills: 'testing, cypress, frontend',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
// Mock the GET /api/models API call
|
||||
cy.intercept('GET', '/api/models', {
|
||||
statusCode: 200,
|
||||
body: { data: mockModels },
|
||||
}).as('getModels');
|
||||
|
||||
// Mock the POST /api/v1/agents/ API call
|
||||
cy.intercept('POST', '/api/v1/agents/', {
|
||||
statusCode: 201, // Simulate successful creation
|
||||
body: {
|
||||
id: 1,
|
||||
user_id: 'test-user',
|
||||
timestamp: new Date().toISOString(),
|
||||
...agentDetails,
|
||||
model_id: '', // This will be set by the selected model
|
||||
},
|
||||
}).as('createAgent');
|
||||
|
||||
// Visit the agent creation page
|
||||
cy.visit('/agents/create');
|
||||
cy.wait('@getModels'); // Ensure models are loaded before interacting
|
||||
});
|
||||
|
||||
it('should submit the form with the correct data including the selected model_id', () => {
|
||||
const selectedModel = mockModels[1]; // Select the GGUF model for this test
|
||||
|
||||
// Fill in the form
|
||||
cy.get('#agentName').type(agentDetails.name);
|
||||
cy.get('#agentRole').type(agentDetails.role);
|
||||
cy.get('#systemMessage').type(agentDetails.systemMessage);
|
||||
cy.get('#skills').type(agentDetails.skills);
|
||||
|
||||
// Select the model from the dropdown
|
||||
// The value of the option should be the model.id
|
||||
cy.get('#llmModel').select(selectedModel.id);
|
||||
|
||||
// Submit the form
|
||||
cy.get('button[type="submit"]').click();
|
||||
|
||||
// Wait for the createAgent API call and assert
|
||||
cy.wait('@createAgent').then((interception) => {
|
||||
expect(interception.response.statusCode).to.eq(201);
|
||||
|
||||
const requestBody = interception.request.body;
|
||||
expect(requestBody).to.have.property('name', agentDetails.name);
|
||||
expect(requestBody).to.have.property('role', agentDetails.role);
|
||||
expect(requestBody).to.have.property('system_message', agentDetails.systemMessage);
|
||||
expect(requestBody).to.have.property('skills', agentDetails.skills);
|
||||
expect(requestBody).to.have.property('model_id', selectedModel.id);
|
||||
});
|
||||
|
||||
// Also verify that a success toast message is shown (optional but good practice)
|
||||
cy.contains('Agent "Test Agent" created successfully!').should('be.visible');
|
||||
});
|
||||
|
||||
// Add more tests for other models if needed
|
||||
it('should allow selecting an Ollama model', () => {
|
||||
const selectedModel = mockModels[0]; // Ollama model
|
||||
|
||||
cy.get('#llmModel').select(selectedModel.id);
|
||||
cy.get('#agentName').type(agentDetails.name); // Need to fill required fields
|
||||
cy.get('#agentRole').type(agentDetails.role); // Need to fill required fields
|
||||
cy.get('button[type="submit"]').click();
|
||||
|
||||
cy.wait('@createAgent').then((interception) => {
|
||||
expect(interception.request.body.model_id).to.eq(selectedModel.id);
|
||||
});
|
||||
});
|
||||
|
||||
it('should allow selecting an OpenAI model', () => {
|
||||
const selectedModel = mockModels[2]; // OpenAI model
|
||||
|
||||
cy.get('#llmModel').select(selectedModel.id);
|
||||
cy.get('#agentName').type(agentDetails.name); // Need to fill required fields
|
||||
cy.get('#agentRole').type(agentDetails.role); // Need to fill required fields
|
||||
cy.get('button[type="submit"]').click();
|
||||
|
||||
cy.wait('@createAgent').then((interception) => {
|
||||
expect(interception.request.body.model_id).to.eq(selectedModel.id);
|
||||
});
|
||||
});
|
||||
});
|
40
src/lib/apis/agents/index.ts
Normal file
40
src/lib/apis/agents/index.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import { WEBUI_API_BASE_URL } from '$lib/constants';
|
||||
|
||||
export interface AgentData {
|
||||
name: string;
|
||||
role: string;
|
||||
system_message: string | null;
|
||||
llm_model: string;
|
||||
skills: string | null; // Sending as string for now
|
||||
}
|
||||
|
||||
export const createAgent = async (token: string, agentData: AgentData) => {
|
||||
let error = null;
|
||||
|
||||
const res = await fetch(`${WEBUI_API_BASE_URL}/agents/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify(agentData)
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
throw err;
|
||||
}
|
||||
return res.json();
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
error = err.detail || 'Server connection failed';
|
||||
return null;
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return res;
|
||||
};
|
92
src/lib/components/agents/AgentCreationForm.svelte
Normal file
92
src/lib/components/agents/AgentCreationForm.svelte
Normal file
@ -0,0 +1,92 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { getModels } from '$lib/apis/models'; // Changed from getOllamaModels to getModels
|
||||
import { createAgent } from '$lib/apis/agents';
|
||||
import { user } from '$lib/stores';
|
||||
|
||||
let agentName = '';
|
||||
let agentRole = '';
|
||||
let systemMessage = '';
|
||||
let skills = '';
|
||||
let availableModels = []; // Renamed from ollamaModels
|
||||
let selectedModel = '';
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
const response = await getModels($user.token); // Use getModels
|
||||
availableModels = response.data.map(model => ({ id: model.id, name: model.name })); // Access response.data
|
||||
if (availableModels.length > 0) {
|
||||
selectedModel = availableModels[0].id; // Default to the first model
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(`Error fetching available models: ${error.message}`);
|
||||
console.error('Error fetching available models:', error);
|
||||
}
|
||||
});
|
||||
|
||||
async function handleSubmit() {
|
||||
const agentData = {
|
||||
name: agentName,
|
||||
role: agentRole,
|
||||
system_message: systemMessage || null,
|
||||
llm_model: selectedModel,
|
||||
skills: skills || null, // Send skills as a string
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await createAgent($user.token, agentData);
|
||||
toast.success(`Agent "${response.name}" created successfully!`);
|
||||
// Optionally, clear the form or redirect
|
||||
agentName = '';
|
||||
agentRole = '';
|
||||
systemMessage = '';
|
||||
skills = '';
|
||||
// selectedModel remains as is, or you can reset it to the default
|
||||
} catch (error) {
|
||||
toast.error(`Error creating agent: ${error}`);
|
||||
console.error('Error creating agent:', error);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<form on:submit|preventDefault={handleSubmit} class="space-y-4">
|
||||
<div>
|
||||
<label for="agentName" class="block text-sm font-medium text-gray-700">Agent Name</label>
|
||||
<input type="text" id="agentName" bind:value={agentName} class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="llmModel" class="block text-sm font-medium text-gray-700">LLM Model</label>
|
||||
<select id="llmModel" bind:value={selectedModel} class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm">
|
||||
{#if availableModels.length === 0}
|
||||
<option value="" disabled>Loading models...</option>
|
||||
{:else}
|
||||
{#each availableModels as model} // Iterate over availableModels
|
||||
<option value={model.id}>{model.name}</option>
|
||||
{/each}
|
||||
{/if}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="agentRole" class="block text-sm font-medium text-gray-700">Agent Role</label>
|
||||
<textarea id="agentRole" bind:value={agentRole} rows="3" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"></textarea>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="systemMessage" class="block text-sm font-medium text-gray-700">System Message</label>
|
||||
<textarea id="systemMessage" bind:value={systemMessage} rows="5" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"></textarea>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="skills" class="block text-sm font-medium text-gray-700">Skills</label>
|
||||
<textarea id="skills" bind:value={skills} rows="3" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm" placeholder="Enter skills, separated by commas"></textarea>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button type="submit" class="inline-flex justify-center rounded-md border border-transparent bg-indigo-600 py-2 px-4 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2">
|
||||
Create Agent
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
@ -644,6 +644,45 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if $user?.role === 'admin'}
|
||||
<div class="px-1.5 flex justify-center text-gray-800 dark:text-gray-200">
|
||||
<a
|
||||
class="grow flex items-center space-x-3 rounded-lg px-2 py-[7px] hover:bg-gray-100 dark:hover:bg-gray-900 transition"
|
||||
href="/agents/create"
|
||||
on:click={() => {
|
||||
selectedChatId = null;
|
||||
chatId.set('');
|
||||
|
||||
if ($mobile) {
|
||||
showSidebar.set(false);
|
||||
}
|
||||
}}
|
||||
draggable="false"
|
||||
>
|
||||
<div class="self-center">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
stroke="currentColor"
|
||||
class="size-[1.1rem]"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M13.5 16.875h3.375m0 0h3.375m-3.375 0V13.5m0 3.375v3.375M6 10.5h2.25a2.25 2.25 0 0 0 2.25-2.25V6a2.25 2.25 0 0 0-2.25-2.25H6A2.25 2.25 0 0 0 3.75 6v2.25A2.25 2.25 0 0 0 6 10.5Zm0 9.75h2.25A2.25 2.25 0 0 0 10.5 18v-2.25a2.25 2.25 0 0 0-2.25-2.25H6a2.25 2.25 0 0 0-2.25 2.25V18A2.25 2.25 0 0 0 6 20.25Zm9.75-9.75H18a2.25 2.25 0 0 0 2.25-2.25V6A2.25 2.25 0 0 0 18 3.75h-2.25A2.25 2.25 0 0 0 13.5 6v2.25a2.25 2.25 0 0 0 2.25 2.25Z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div class="flex self-center translate-y-[0.5px]">
|
||||
<div class=" self-center font-medium text-sm font-primary">{$i18n.t('Agent Playground')}</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div
|
||||
class="relative flex flex-col flex-1 overflow-y-auto overflow-x-hidden {$temporaryChatEnabled
|
||||
? 'opacity-20'
|
||||
|
14
src/routes/(app)/agents/create/+page.svelte
Normal file
14
src/routes/(app)/agents/create/+page.svelte
Normal file
@ -0,0 +1,14 @@
|
||||
<script lang="ts">
|
||||
import AgentCreationForm from '$lib/components/agents/AgentCreationForm.svelte';
|
||||
</script>
|
||||
|
||||
<div class="container mx-auto p-4">
|
||||
<h1 class="text-2xl font-bold mb-4">Create New Agent</h1>
|
||||
<AgentCreationForm />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.container {
|
||||
max-width: 800px;
|
||||
}
|
||||
</style>
|
Loading…
Reference in New Issue
Block a user