Added training files models, viewsets and api paths with frontend upload

This commit is contained in:
Viswamedha Nalabotu 2026-01-25 17:29:37 +00:00
parent 1f5ce71e5d
commit 7714fa4d8f
19 changed files with 615 additions and 60 deletions

View file

@ -31,7 +31,7 @@ class AgentModelAdmin(ModelAdmin):
@admin.register(Agent) @admin.register(Agent)
class AgentAdmin(ModelAdmin): class AgentAdmin(ModelAdmin):
list_display = ('id', 'uuid', 'model', 'status', 'started_at', 'completed_at') list_display = ('id', 'uuid', 'model', 'status', 'started_at', 'completed_at', 'organization')
search_fields = ('model__name', 'uuid') search_fields = ('model__name', 'uuid')
list_filter = ('status',) list_filter = ('status',)
inlines = (AgentRunInline,) inlines = (AgentRunInline,)

View file

@ -8,6 +8,7 @@ class Migration(migrations.Migration):
initial = True initial = True
dependencies = [ dependencies = [
('orgs', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL), migrations.swappable_dependency(settings.AUTH_USER_MODEL),
] ]
@ -19,6 +20,7 @@ class Migration(migrations.Migration):
('uuid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)), ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
('name', models.CharField(max_length=255)), ('name', models.CharField(max_length=255)),
('version', models.CharField(max_length=50)), ('version', models.CharField(max_length=50)),
('path', models.CharField(blank=True, default='', max_length=1024)),
], ],
options={ options={
'verbose_name': 'Model', 'verbose_name': 'Model',
@ -36,6 +38,7 @@ class Migration(migrations.Migration):
('description', models.TextField(blank=True, default='')), ('description', models.TextField(blank=True, default='')),
('started_at', models.DateTimeField(blank=True, null=True)), ('started_at', models.DateTimeField(blank=True, null=True)),
('completed_at', models.DateTimeField(blank=True, null=True)), ('completed_at', models.DateTimeField(blank=True, null=True)),
('organization', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='agents', to='orgs.organization')),
('model', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='agents', to='mlstore.agentmodel')), ('model', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='agents', to='mlstore.agentmodel')),
], ],
options={ options={

View file

@ -1,15 +0,0 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('mlstore', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='agentmodel',
name='path',
field=models.CharField(blank=True, default='', max_length=1024),
),
]

View file

@ -1,6 +1,7 @@
from django.db.models import BigAutoField, CASCADE, CharField, DateTimeField, ForeignKey, JSONField, Model, TextField, UUIDField from django.db.models import BigAutoField, CASCADE, CharField, DateTimeField, ForeignKey, JSONField, Model, TextField, UUIDField
from apps.users.mixins import TimeStampMixin from apps.users.mixins import TimeStampMixin
from apps.users.models import User from apps.users.models import User
from apps.orgs.models import Organization
from uuid import uuid4 from uuid import uuid4
class AgentModel(Model): class AgentModel(Model):
@ -32,6 +33,7 @@ class Agent(TimeStampMixin, Model):
uuid = UUIDField(default = uuid4, unique = True, editable = False) uuid = UUIDField(default = uuid4, unique = True, editable = False)
model = ForeignKey(AgentModel, on_delete = CASCADE, related_name = 'agents') model = ForeignKey(AgentModel, on_delete = CASCADE, related_name = 'agents')
organization = ForeignKey(Organization, on_delete = CASCADE, related_name = 'agents', null = True, blank = True)
status = CharField(max_length = 20, choices = STATUS_CHOICES, default = 'idle') status = CharField(max_length = 20, choices = STATUS_CHOICES, default = 'idle')
description = TextField(blank = True, default = '') description = TextField(blank = True, default = '')
@ -68,7 +70,7 @@ class AgentRun(TimeStampMixin, Model):
completed_at = DateTimeField(null = True, blank = True) completed_at = DateTimeField(null = True, blank = True)
def __str__(self) -> str: def __str__(self) -> str:
return f"Execution {self.uuid} - {self.agent.name} ({self.status})" return f"Execution {self.uuid} - {self.agent} ({self.status})"
class Meta: class Meta:
verbose_name = "Agent Run" verbose_name = "Agent Run"
@ -92,7 +94,7 @@ class AgentEvent(Model):
timestamp = DateTimeField(auto_now_add = True) timestamp = DateTimeField(auto_now_add = True)
def __str__(self) -> str: def __str__(self) -> str:
return f"{self.id} - {self.event_type} - {self.execution.agent.name}" return f"{self.id} - {self.event_type} - {self.execution.agent}"
class Meta: class Meta:
ordering = ['timestamp'] ordering = ['timestamp']

View file

@ -18,6 +18,7 @@ class AgentSerializer(ModelSerializer):
'id', 'id',
'uuid', 'uuid',
'model', 'model',
'organization',
'status', 'status',
'description', 'description',
'started_at', 'started_at',

View file

@ -1,17 +1,38 @@
import asyncio import asyncio
import logging
import os
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from django.conf import settings from django.conf import settings
from mcp_agent.mcp_client import MCPClient from mcp_agent.mcp_client import MCPClient
from .models import AgentModel from .models import AgentModel
logger = logging.getLogger(__name__)
# Get reference to the base model cache directory
try:
from mcp_agent.mcp_server import BASE_MODEL_CACHE_DIR
BASE_MODEL_CACHE = BASE_MODEL_CACHE_DIR
except ImportError:
# Fallback: construct the path manually
project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
BASE_MODEL_CACHE = os.path.join(project_root, "model", "base-model")
logger.info(f"Base model cache directory reference: {BASE_MODEL_CACHE}")
async def _call_mcp(tool: str, arguments: Dict[str, Any]) -> Dict[str, Any]: async def _call_mcp(tool: str, arguments: Dict[str, Any]) -> Dict[str, Any]:
"""Internal async helper to call the MCP HTTP bridge via MCPClient.""" """Internal async helper to call the MCP HTTP bridge via MCPClient."""
server_url = getattr(settings, "MCP_AGENT_URL") server_url = getattr(settings, "MCP_AGENT_URL")
client = MCPClient(server_url) client = MCPClient(server_url)
logger.info(f"MCP: Calling tool '{tool}' on {server_url}")
logger.debug(f"MCP: Arguments for '{tool}': {arguments}")
try: try:
resp = await client.send(tool, arguments) resp = await client.send(tool, arguments)
logger.info(f"MCP: Tool '{tool}' completed successfully")
logger.debug(f"MCP: Response from '{tool}': {resp}")
return resp return resp
except Exception as e:
logger.error(f"MCP: Tool '{tool}' failed with error: {str(e)}")
raise
finally: finally:
await client.close() await client.close()
@ -28,13 +49,30 @@ def fine_tune_model(
Expects the MCP tool `fine_tune` to accept: {base_model, training_files, hyperparams, name, version} Expects the MCP tool `fine_tune` to accept: {base_model, training_files, hyperparams, name, version}
and to return a JSON-like dict containing at least `status` and on success `model_path` and `version`. and to return a JSON-like dict containing at least `status` and on success `model_path` and `version`.
""" """
return asyncio.run(_call_mcp("fine_tune", { logger.info(f"Fine-tuning model: name={name}, version={version}, base_model={base_model}")
logger.info(f"Training files count: {len(training_files)}")
logger.debug(f"Training files: {training_files}")
try:
logger.info("Calling MCP fine_tune tool...")
result = asyncio.run(_call_mcp("fine_tune", {
"base_model": base_model, "base_model": base_model,
"training_files": training_files, "training_files": training_files,
"hyperparams": hyperparams, "hyperparams": hyperparams,
"name": name, "name": name,
"version": version, "version": version,
})) }))
logger.info(f"Fine-tune completed: status={result.get('status')}")
logger.debug(f"Fine-tune result: {result}")
return result
except Exception as e:
error_msg = str(e) if str(e) else f"Unknown error: {type(e).__name__}"
logger.error(f"Fine-tune failed: {error_msg}", exc_info=True)
# Return a failed response instead of raising
return {
"status": "failed",
"error": error_msg,
"error_type": type(e).__name__,
}
def load_model_for_inference(model_path: str) -> Dict[str, Any]: def load_model_for_inference(model_path: str) -> Dict[str, Any]:
@ -42,7 +80,14 @@ def load_model_for_inference(model_path: str) -> Dict[str, Any]:
Expects the MCP tool `load_model` with {model_path} returning status info. Expects the MCP tool `load_model` with {model_path} returning status info.
""" """
return asyncio.run(_call_mcp("load_model", {"model_path": model_path})) logger.info(f"Loading model for inference: {model_path}")
try:
result = asyncio.run(_call_mcp("load_model", {"model_path": model_path}))
logger.info(f"Model loaded successfully")
return result
except Exception as e:
logger.error(f"Failed to load model: {str(e)}", exc_info=True)
raise
def infer_with_model(model_path: str, prompt: str, options: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: def infer_with_model(model_path: str, prompt: str, options: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
@ -50,7 +95,17 @@ def infer_with_model(model_path: str, prompt: str, options: Optional[Dict[str, A
Calls the MCP tool `infer` with {model_path, prompt, options}. Calls the MCP tool `infer` with {model_path, prompt, options}.
""" """
return asyncio.run(_call_mcp("infer", {"model_path": model_path, "prompt": prompt, "options": options or {}})) logger.info(f"Running inference with model: {model_path}")
logger.debug(f"Prompt length: {len(prompt)} characters")
logger.debug(f"Inference options: {options}")
try:
result = asyncio.run(_call_mcp("infer", {"model_path": model_path, "prompt": prompt, "options": options or {}}))
logger.info(f"Inference completed successfully")
logger.debug(f"Inference result keys: {list(result.keys()) if isinstance(result, dict) else 'not a dict'}")
return result
except Exception as e:
logger.error(f"Inference failed: {str(e)}", exc_info=True)
raise
def register_model_in_db(name: str, version: str, model_path: str) -> AgentModel: def register_model_in_db(name: str, version: str, model_path: str) -> AgentModel:

View file

@ -5,6 +5,9 @@ from asgiref.sync import async_to_sync
from . import services from . import services
from .models import AgentModel, Agent, AgentRun, AgentEvent from .models import AgentModel, Agent, AgentRun, AgentEvent
import traceback import traceback
import logging
logger = logging.getLogger(__name__)
@shared_task @shared_task
@ -86,37 +89,59 @@ def _update_agent_status(agent: Agent, status: str):
@shared_task @shared_task
def start_fine_tune_run_task(execution_id: str): def start_fine_tune_run_task(execution_id: str):
logger.info(f"Fine-tune run task started for execution: {execution_id}")
try: try:
execution = AgentRun.objects.get(uuid=execution_id) execution = AgentRun.objects.get(uuid=execution_id)
except AgentRun.DoesNotExist: except AgentRun.DoesNotExist:
logger.error(f"Execution not found: {execution_id}")
return {"status": "error", "error": "execution_not_found", "execution_id": execution_id} return {"status": "error", "error": "execution_not_found", "execution_id": execution_id}
agent = execution.agent agent = execution.agent
room_group_name = f"mlstore_agent_{agent.uuid}" room_group_name = f"mlstore_agent_{agent.uuid}"
logger.info(f"Agent: {agent.uuid}, User: {execution.user.email_address}")
execution.status = "running" execution.status = "running"
execution.started_at = timezone.now() execution.started_at = timezone.now()
execution.save() execution.save()
_update_agent_status(agent, "running") _update_agent_status(agent, "running")
logger.info(f"Execution {execution_id} status updated to 'running'")
from apps.mlstore.services import BASE_MODEL_CACHE
logger.info(f"Base model cache directory: {BASE_MODEL_CACHE}")
input_data = execution.input_data or {} input_data = execution.input_data or {}
base_model = input_data.get("base_model") or agent.model.name base_model = input_data.get("base_model") or agent.model.name
training_files = input_data.get("training_files") or [] training_files = input_data.get("training_files") or []
if not training_files and agent.organization:
from apps.orgs.models import TrainingFile
org_training_files = TrainingFile.objects.filter(
organization=agent.organization,
is_processed=False
).select_related('uploaded_by')
training_files = [tf.file.path for tf in org_training_files if tf.file]
logger.info(f"Fetched {len(training_files)} training files from organization {agent.organization.name}")
hyperparams = input_data.get("hyperparams") or {} hyperparams = input_data.get("hyperparams") or {}
name = input_data.get("name") or f"{agent.model.name}-ft" name = input_data.get("name") or f"{agent.model.name}-ft"
version = input_data.get("version") or "v1" version = input_data.get("version") or "v1"
logger.info(f"Fine-tune parameters: base_model={base_model}, name={name}, version={version}")
_send_group_event(room_group_name, "started", {"execution_id": str(execution.uuid), "action": "fine_tune"}) _send_group_event(room_group_name, "started", {"execution_id": str(execution.uuid), "action": "fine_tune"})
_persist_event(execution, "started", {"execution_id": str(execution.uuid), "action": "fine_tune"}) _persist_event(execution, "started", {"execution_id": str(execution.uuid), "action": "fine_tune"})
try: try:
result = services.fine_tune_model(base_model, training_files, hyperparams, name, version) result = services.fine_tune_model(base_model, training_files, hyperparams, name, version)
logger.info(f"Fine-tune result received: {result.get('status')}")
logger.debug(f"Full fine-tune result: {result}")
if isinstance(result, dict) and result.get("status") == "completed": if isinstance(result, dict) and result.get("status") == "completed":
model_path = result.get("model_path") or result.get("path") or "" model_path = result.get("model_path") or result.get("path") or ""
model_version = result.get("version") or version model_version = result.get("version") or version
new_model = AgentModel.objects.create(name=name, version=model_version, path=model_path) new_model = AgentModel.objects.create(name=name, version=model_version, path=model_path)
agent.model = new_model agent.model = new_model
agent.save() agent.save()
logger.info(f"Fine-tune completed. New model created: {new_model.uuid} at {model_path}")
execution.status = "completed" execution.status = "completed"
execution.output_data = { execution.output_data = {
@ -127,6 +152,7 @@ def start_fine_tune_run_task(execution_id: str):
execution.completed_at = timezone.now() execution.completed_at = timezone.now()
execution.save() execution.save()
_update_agent_status(agent, "completed") _update_agent_status(agent, "completed")
logger.info(f"Execution {execution_id} completed successfully")
_send_group_event(room_group_name, "completed", {"execution_id": str(execution.uuid), "model_id": new_model.id, "model_path": model_path}) _send_group_event(room_group_name, "completed", {"execution_id": str(execution.uuid), "model_id": new_model.id, "model_path": model_path})
_persist_event(execution, "completed", {"execution_id": str(execution.uuid), "model_id": new_model.id, "model_path": model_path}) _persist_event(execution, "completed", {"execution_id": str(execution.uuid), "model_id": new_model.id, "model_path": model_path})
@ -142,6 +168,7 @@ def start_fine_tune_run_task(execution_id: str):
return {"status": "completed", "execution_id": execution_id, "model_id": new_model.id} return {"status": "completed", "execution_id": execution_id, "model_id": new_model.id}
logger.warning(f"Fine-tune did not complete successfully. Status: {result.get('status')}")
execution.status = "failed" execution.status = "failed"
execution.error_message = str(result) execution.error_message = str(result)
execution.completed_at = timezone.now() execution.completed_at = timezone.now()
@ -162,6 +189,7 @@ def start_fine_tune_run_task(execution_id: str):
return {"status": "failed", "execution_id": execution_id, "result": result} return {"status": "failed", "execution_id": execution_id, "result": result}
except Exception as e: except Exception as e:
logger.error(f"Fine-tune task failed with exception for execution {execution_id}: {str(e)}", exc_info=True)
traceback.print_exc() traceback.print_exc()
execution.status = "failed" execution.status = "failed"
execution.error_message = str(e) execution.error_message = str(e)
@ -183,24 +211,30 @@ def start_fine_tune_run_task(execution_id: str):
@shared_task @shared_task
def infer_run_task(execution_id: str): def infer_run_task(execution_id: str):
logger.info(f"Inference run task started for execution: {execution_id}")
try: try:
execution = AgentRun.objects.get(uuid=execution_id) execution = AgentRun.objects.get(uuid=execution_id)
except AgentRun.DoesNotExist: except AgentRun.DoesNotExist:
logger.error(f"Execution not found: {execution_id}")
return {"status": "error", "error": "execution_not_found", "execution_id": execution_id} return {"status": "error", "error": "execution_not_found", "execution_id": execution_id}
agent = execution.agent agent = execution.agent
room_group_name = f"mlstore_agent_{agent.uuid}" room_group_name = f"mlstore_agent_{agent.uuid}"
logger.info(f"Agent: {agent.uuid}, User: {execution.user.email_address}")
execution.status = "running" execution.status = "running"
execution.started_at = timezone.now() execution.started_at = timezone.now()
execution.save() execution.save()
_update_agent_status(agent, "running") _update_agent_status(agent, "running")
logger.info(f"Execution {execution_id} status updated to 'running'")
input_data = execution.input_data or {} input_data = execution.input_data or {}
prompt = input_data.get("prompt") or input_data.get("query") or "" prompt = input_data.get("prompt") or input_data.get("query") or ""
options = input_data.get("options") or {} options = input_data.get("options") or {}
logger.info(f"Prompt length: {len(prompt)} characters")
if not prompt: if not prompt:
logger.warning(f"No prompt provided for inference run {execution_id}")
execution.status = "failed" execution.status = "failed"
execution.error_message = "prompt_required" execution.error_message = "prompt_required"
execution.completed_at = timezone.now() execution.completed_at = timezone.now()
@ -223,10 +257,13 @@ def infer_run_task(execution_id: str):
try: try:
try: try:
logger.info(f"Loading model: {agent.model.path}")
services.load_model_for_inference(agent.model.path) services.load_model_for_inference(agent.model.path)
except Exception: except Exception as e:
logger.warning(f"Failed to preload model: {str(e)}")
pass pass
logger.info(f"Starting inference with model: {agent.model.path}")
result = services.infer_with_model(agent.model.path, prompt, options) result = services.infer_with_model(agent.model.path, prompt, options)
execution.status = "completed" execution.status = "completed"
@ -234,6 +271,7 @@ def infer_run_task(execution_id: str):
execution.completed_at = timezone.now() execution.completed_at = timezone.now()
execution.save() execution.save()
_update_agent_status(agent, "completed") _update_agent_status(agent, "completed")
logger.info(f"Inference execution {execution_id} completed successfully")
_send_group_event(room_group_name, "completed", {"execution_id": str(execution.uuid), "result": result}) _send_group_event(room_group_name, "completed", {"execution_id": str(execution.uuid), "result": result})
_persist_event(execution, "completed", {"execution_id": str(execution.uuid), "result": result}) _persist_event(execution, "completed", {"execution_id": str(execution.uuid), "result": result})
@ -248,6 +286,7 @@ def infer_run_task(execution_id: str):
return {"status": "completed", "execution_id": execution_id} return {"status": "completed", "execution_id": execution_id}
except Exception as e: except Exception as e:
logger.error(f"Inference task failed with exception for execution {execution_id}: {str(e)}", exc_info=True)
traceback.print_exc() traceback.print_exc()
execution.status = "failed" execution.status = "failed"
execution.error_message = str(e) execution.error_message = str(e)

View file

@ -1,5 +1,5 @@
from django.contrib.admin import ModelAdmin, TabularInline, register from django.contrib.admin import ModelAdmin, TabularInline, register
from apps.orgs.models import Organization, OrganizationInvitation, OrganizationMembership, Role, RoleMembership from apps.orgs.models import Organization, OrganizationInvitation, OrganizationMembership, Role, RoleMembership, TrainingFile
class OrganizationMembershipInline(TabularInline): class OrganizationMembershipInline(TabularInline):
model = OrganizationMembership model = OrganizationMembership
@ -51,3 +51,11 @@ class RoleAdmin(ModelAdmin):
class RoleMembershipAdmin(ModelAdmin): class RoleMembershipAdmin(ModelAdmin):
list_display = ('id', 'user', 'role') list_display = ('id', 'user', 'role')
raw_id_fields = ('user', 'role') raw_id_fields = ('user', 'role')
@register(TrainingFile)
class TrainingFileAdmin(ModelAdmin):
list_display = ('id', 'uuid', 'file_name', 'organization', 'uploaded_by', 'is_processed', 'created_at')
search_fields = ('file_name', 'organization__name', 'uploaded_by__email_address')
list_filter = ('is_processed', 'created_at')
raw_id_fields = ('organization', 'uploaded_by')
readonly_fields = ('uuid', 'created_at', 'updated_at')

View file

@ -1,8 +1,11 @@
# Generated by Django 5.2.10 on 2026-01-25 11:02
import django.db.models.deletion import django.db.models.deletion
import uuid import uuid
from django.conf import settings from django.conf import settings
from django.db import migrations, models from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
initial = True initial = True
@ -103,4 +106,26 @@ class Migration(migrations.Migration):
name='members', name='members',
field=models.ManyToManyField(related_name='roles', through='orgs.RoleMembership', to=settings.AUTH_USER_MODEL), field=models.ManyToManyField(related_name='roles', through='orgs.RoleMembership', to=settings.AUTH_USER_MODEL),
), ),
migrations.CreateModel(
name='TrainingFile',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated At')),
('id', models.BigAutoField(primary_key=True, serialize=False)),
('uuid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
('file', models.FileField(upload_to='training_files/%Y/%m/%d/')),
('file_name', models.CharField(max_length=255)),
('file_size', models.IntegerField()),
('file_type', models.CharField(max_length=50)),
('description', models.TextField(blank=True, default='')),
('is_processed', models.BooleanField(default=False)),
('organization', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='training_files', to='orgs.organization')),
('uploaded_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='uploaded_training_files', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'Training File',
'verbose_name_plural': 'Training Files',
'ordering': ['-created_at'],
},
),
] ]

View file

@ -2,7 +2,9 @@ from datetime import timedelta
from uuid import uuid4 from uuid import uuid4
from django.utils import timezone from django.utils import timezone
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.db.models import BigAutoField, BooleanField, CASCADE, CharField, DateTimeField, ForeignKey, ManyToManyField, Model, TextField, UUIDField, IntegerField from django.db.models import BigAutoField, BooleanField, CASCADE, CharField, DateTimeField, ForeignKey, ManyToManyField, Model, TextField, UUIDField, IntegerField, FileField
from django.db.models.signals import post_delete
from django.dispatch import receiver
from apps.users.mixins import TimeStampMixin from apps.users.mixins import TimeStampMixin
from apps.users.models import User from apps.users.models import User
@ -97,3 +99,39 @@ class RoleMembership(TimeStampMixin, Model):
def __str__(self) -> str: def __str__(self) -> str:
return f"{self.user.full_name} - {self.role.name}" return f"{self.user.full_name} - {self.role.name}"
class TrainingFile(TimeStampMixin, Model):
ALLOWED_EXTENSIONS = ('txt', 'pdf', 'md', 'csv', 'json', 'docx', 'doc')
id = BigAutoField(primary_key = True)
uuid = UUIDField(default = uuid4, unique = True, editable = False)
organization = ForeignKey(Organization, on_delete = CASCADE, related_name = "training_files")
uploaded_by = ForeignKey(User, on_delete = CASCADE, related_name = "uploaded_training_files")
file = FileField(upload_to = 'training_files/%Y/%m/%d/')
file_name = CharField(max_length = 255)
file_size = IntegerField()
file_type = CharField(max_length = 50)
description = TextField(blank = True, default = '')
is_processed = BooleanField(default = False)
class Meta:
verbose_name = _("Training File")
verbose_name_plural = _("Training Files")
ordering = ['-created_at']
def __str__(self) -> str:
return f"{self.file_name} - {self.organization.name}"
@receiver(post_delete, sender=TrainingFile)
def delete_training_file_on_delete(sender, instance, **kwargs):
if instance.file:
try:
import os
if os.path.isfile(instance.file.path):
os.remove(instance.file.path)
except Exception:
pass

View file

@ -1,5 +1,5 @@
from rest_framework.serializers import ModelSerializer, SerializerMethodField, IntegerField from rest_framework.serializers import ModelSerializer, SerializerMethodField, IntegerField
from apps.orgs.models import Organization, OrganizationMembership, OrganizationInvitation, Role, RoleMembership from apps.orgs.models import Organization, OrganizationMembership, OrganizationInvitation, Role, RoleMembership, TrainingFile
from apps.users.serializers import UserSerializer from apps.users.serializers import UserSerializer
class OrganizationSerializer(ModelSerializer): class OrganizationSerializer(ModelSerializer):
@ -71,3 +71,47 @@ class RoleSerializer(ModelSerializer):
def get_member_count(self, obj): def get_member_count(self, obj):
return obj.memberships.count() return obj.memberships.count()
class TrainingFileSerializer(ModelSerializer):
uploaded_by = UserSerializer(read_only = True)
file_url = SerializerMethodField()
class Meta:
model = TrainingFile
fields = ['id', 'uuid', 'organization', 'uploaded_by', 'file', 'file_name', 'file_size', 'file_type', 'description', 'is_processed', 'file_url', 'created_at', 'updated_at']
read_only_fields = ['uuid', 'uploaded_by', 'file_size', 'file_type', 'is_processed', 'created_at', 'updated_at', 'organization']
def get_file_url(self, obj):
request = self.context.get('request')
if request and obj.file:
return request.build_absolute_uri(obj.file.url)
return None
def validate_file(self, value):
"""Validate that uploaded file is a text-based file."""
if not value:
raise ValueError('File is required')
import os
file_extension = os.path.splitext(value.name)[1][1:].lower()
if file_extension not in TrainingFile.ALLOWED_EXTENSIONS:
raise ValueError(
f'File type ".{file_extension}" is not allowed. '
f'Allowed types: {", ".join(TrainingFile.ALLOWED_EXTENSIONS)}'
)
max_size = 50 * 1024 * 1024
if value.size > max_size:
raise ValueError(f'File size must not exceed 50MB. Current size: {value.size / 1024 / 1024:.2f}MB')
return value
def create(self, validated_data):
file_obj = validated_data.get('file')
if file_obj:
validated_data['file_size'] = file_obj.size
import os
file_extension = os.path.splitext(file_obj.name)[1][1:].lower()
validated_data['file_type'] = file_extension
return super().create(validated_data)

View file

@ -1,5 +1,5 @@
from apps.orgs.models import Organization, OrganizationMembership, OrganizationInvitation, Role, RoleMembership from apps.orgs.models import Organization, OrganizationMembership, OrganizationInvitation, Role, RoleMembership, TrainingFile
from apps.orgs.serializers import ModelSerializer, OrganizationSerializer, OrganizationMembershipSerializer, OrganizationInvitationSerializer, RoleSerializer, RoleMembershipSerializer from apps.orgs.serializers import ModelSerializer, OrganizationSerializer, OrganizationMembershipSerializer, OrganizationInvitationSerializer, RoleSerializer, RoleMembershipSerializer, TrainingFileSerializer
from rest_framework.viewsets import ModelViewSet from rest_framework.viewsets import ModelViewSet
from rest_framework.permissions import IsAuthenticated from rest_framework.permissions import IsAuthenticated
from django.db.models import Q from django.db.models import Q
@ -9,6 +9,7 @@ from rest_framework.decorators import action
from django.utils import timezone from django.utils import timezone
from apps.users.models import User from apps.users.models import User
from apps.users.serializers import UserSerializer from apps.users.serializers import UserSerializer
from rest_framework.parsers import MultiPartParser, FormParser
class OrganizationViewSet(ModelViewSet): class OrganizationViewSet(ModelViewSet):
@ -190,3 +191,50 @@ class OrganizationViewSet(ModelViewSet):
serializer = RoleMembershipSerializer(memberships, many = True) serializer = RoleMembershipSerializer(memberships, many = True)
return Response(serializer.data) return Response(serializer.data)
@action(detail=True, methods=['get', 'post'], url_path='training-file')
def training_files(self, request, uuid = None):
organization = self.get_object()
if request.method == 'GET':
training_files = TrainingFile.objects.filter(organization=organization)
serializer = TrainingFileSerializer(training_files, many=True)
return Response(serializer.data)
if not (organization.owner == request.user or
organization.memberships.filter(user=request.user).exists()):
return Response(
{'error': 'You do not have permission to upload files to this organization'},
status=HTTP_403_FORBIDDEN
)
serializer = TrainingFileSerializer(data=request.data)
if serializer.is_valid():
serializer.save(uploaded_by=request.user, organization=organization)
return Response(serializer.data, status=201)
return Response(serializer.errors, status=HTTP_400_BAD_REQUEST)
@action(detail=True, methods=['get', 'delete'], url_path='training-file/(?P<file_uuid>[0-9a-f-]{36})')
def training_file_detail(self, request, uuid = None, file_uuid = None):
organization = self.get_object()
try:
training_file = TrainingFile.objects.get(uuid=file_uuid, organization=organization)
except TrainingFile.DoesNotExist:
return Response({'error': 'Training file not found'}, status=HTTP_404_NOT_FOUND)
if request.method == 'GET':
serializer = TrainingFileSerializer(training_file)
return Response(serializer.data)
if not (training_file.uploaded_by == request.user or
training_file.organization.owner == request.user or
request.user.is_manager):
return Response(
{'error': 'You do not have permission to delete this file'},
status=HTTP_403_FORBIDDEN
)
file_name = training_file.file_name
training_file.delete()
return Response({'message': f'File "{file_name}" successfully deleted'})

View file

@ -86,7 +86,7 @@ const onSelect = (info: SimpleMenuInfo) => {
} }
} }
if (found && found.path && route.path !== found.path) { if (found && found.path && route.path !== found.path) {
const selectedOrgUuid = userStore.selectedOrganizationUuid const selectedOrgUuid = userStore.userSelectedOrganization?.uuid
if (found.path === '/organization' && selectedOrgUuid) { if (found.path === '/organization' && selectedOrgUuid) {
router.push(`/organization/${selectedOrgUuid}`) router.push(`/organization/${selectedOrgUuid}`)
} else { } else {
@ -161,19 +161,20 @@ const user = userStore
<Space> <Space>
<template v-if="user.isAuthenticated"> <template v-if="user.isAuthenticated">
<Select <Select
v-if="user.organizations && user.organizations.length > 0" v-if="user.userJoinedOrganizations && user.userJoinedOrganizations.length > 0"
:value="user.selectedOrganizationUuid ?? undefined" :value="user.userSelectedOrganization?.uuid ?? undefined"
@change=" @change="
(val) => { (val) => {
const org = user.userJoinedOrganizations.find(o => o.uuid === val)
user.setSelectedOrganization && user.setSelectedOrganization &&
user.setSelectedOrganization(val == null ? null : String(val)) user.setSelectedOrganization(org ?? null)
} }
" "
style="min-width: 220px; margin-right: 0.5rem" style="min-width: 220px; margin-right: 0.5rem"
placeholder="Select organization" placeholder="Select organization"
> >
<Select.Option <Select.Option
v-for="o in user.organizations" v-for="o in user.userJoinedOrganizations"
:key="o.uuid" :key="o.uuid"
:value="o.uuid" :value="o.uuid"
> >

View file

@ -93,6 +93,9 @@ export const API = {
`/api/organization/${orgUuid}/create-invite/?max_uses=${max_uses}`, `/api/organization/${orgUuid}/create-invite/?max_uses=${max_uses}`,
organizationJoin: (token: string) => `/api/organization/join/${token}/`, organizationJoin: (token: string) => `/api/organization/join/${token}/`,
organizationLeave: (orgUuid: string) => `/api/organization/${orgUuid}/leave/`, organizationLeave: (orgUuid: string) => `/api/organization/${orgUuid}/leave/`,
organizationTrainingFiles: (orgUuid: string) => `/api/organization/${orgUuid}/training-file/`,
organizationTrainingFile: (orgUuid: string, fileUuid: string) =>
`/api/organization/${orgUuid}/training-file/${fileUuid}/`,
agents: () => '/api/agent/', agents: () => '/api/agent/',
agent: (id: string) => `/api/agent/${id}/`, agent: (id: string) => `/api/agent/${id}/`,
} }

View file

@ -111,6 +111,16 @@ export const useAgentStore = defineStore('agent', () => {
) )
} }
const startFineTune = (inputData?: Record<string, unknown>) => {
if (!socket || socket.readyState !== WebSocket.OPEN) return
socket.send(
JSON.stringify({
action: 'fine_tune',
input_data: inputData ?? {},
}),
)
}
const stopAgent = (executionId?: string) => { const stopAgent = (executionId?: string) => {
if (!socket || socket.readyState !== WebSocket.OPEN) return if (!socket || socket.readyState !== WebSocket.OPEN) return
socket.send( socket.send(
@ -132,6 +142,7 @@ export const useAgentStore = defineStore('agent', () => {
connect, connect,
disconnect, disconnect,
startAgent, startAgent,
startFineTune,
stopAgent, stopAgent,
resetLog, resetLog,
lastExecutionId, lastExecutionId,

View file

@ -34,3 +34,18 @@ export interface InviteToken {
max_uses?: number max_uses?: number
uses?: number uses?: number
} }
export interface TrainingFile {
id: number
uuid: string
organization: string
uploaded_by: User
file: string
file_name: string
file_size: number
file_type: string
description: string
is_processed: boolean
file_url: string
created_at: string
updated_at: string
}

View file

@ -84,6 +84,16 @@ const startAgent = () => {
message.success('Agent execution started') message.success('Agent execution started')
} }
const startFineTune = () => {
if (!agentStore.isConnected) {
message.error('WebSocket not connected')
return
}
agentStore.startFineTune()
message.success('Fine-tune started')
}
const stopAgent = () => { const stopAgent = () => {
agentStore.stopAgent(agentStore.lastExecutionId || undefined) agentStore.stopAgent(agentStore.lastExecutionId || undefined)
message.success('Agent stop requested') message.success('Agent stop requested')
@ -138,6 +148,9 @@ onUnmounted(() => {
<Button type="primary" :disabled="isRunning || !isConnected" @click="startAgent"> <Button type="primary" :disabled="isRunning || !isConnected" @click="startAgent">
Run Agent Run Agent
</Button> </Button>
<Button :disabled="isRunning || !isConnected" @click="startFineTune">
Fine-Tune
</Button>
<Button danger :disabled="!isRunning" @click="stopAgent"> <Button danger :disabled="!isRunning" @click="stopAgent">
Stop Agent Stop Agent
</Button> </Button>

View file

@ -163,16 +163,8 @@ onMounted(async () => {
<div class="page"> <div class="page">
<Spin :spinning="loading" tip="Loading organization..."> <Spin :spinning="loading" tip="Loading organization...">
<Card v-if="organization" class="panel" :bordered="false"> <Card v-if="organization" class="panel" :bordered="false">
<div class="header">
<Typography.Title :level="2">Manage {{ organization.name }}</Typography.Title> <Typography.Title :level="2">Manage {{ organization.name }}</Typography.Title>
<div
style="
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
"
>
<Button type="default" @click="router.push(`/organization/${orgId}`)"> <Button type="default" @click="router.push(`/organization/${orgId}`)">
Back to Organization Back to Organization
</Button> </Button>
@ -181,7 +173,7 @@ onMounted(async () => {
<Tabs> <Tabs>
<Tabs.TabPane key="details" tab="Details"> <Tabs.TabPane key="details" tab="Details">
<div class="section"> <div class="section">
<Typography.Title :level="4">Description</Typography.Title> <Typography.Title :level="4" style="color: #ffffff !important">Description</Typography.Title>
<div v-if="!editingDescription"> <div v-if="!editingDescription">
<Typography.Paragraph> <Typography.Paragraph>
{{ organization.description || 'No description provided' }} {{ organization.description || 'No description provided' }}
@ -207,7 +199,7 @@ onMounted(async () => {
<Tabs.TabPane key="members" tab="Members"> <Tabs.TabPane key="members" tab="Members">
<div class="section"> <div class="section">
<div class="section-header"> <div class="section-header">
<Typography.Title :level="4"> <Typography.Title :level="4" style="color: #ffffff !important">
Members ({{ members.length }}) Members ({{ members.length }})
</Typography.Title> </Typography.Title>
</div> </div>
@ -239,7 +231,7 @@ onMounted(async () => {
<Tabs.TabPane key="invites" tab="Invites"> <Tabs.TabPane key="invites" tab="Invites">
<div class="section"> <div class="section">
<div class="section-header"> <div class="section-header">
<Typography.Title :level="4">Invite Tokens</Typography.Title> <Typography.Title :level="4" style="color: #ffffff !important">Invite Tokens</Typography.Title>
<Space> <Space>
<InputNumber v-model:value="newInviteMaxUses" :min="1" /> <InputNumber v-model:value="newInviteMaxUses" :min="1" />
<Button type="primary" @click="createInvite"> <Button type="primary" @click="createInvite">
@ -289,7 +281,7 @@ onMounted(async () => {
<Tabs.TabPane key="Roles" tab="Roles"> <Tabs.TabPane key="Roles" tab="Roles">
<div class="section"> <div class="section">
<Typography.Title :level="4"> <Typography.Title :level="4" style="color: #ffffff !important">
Roles ({{ Roles.length }}) Roles ({{ Roles.length }})
</Typography.Title> </Typography.Title>
@ -341,6 +333,13 @@ onMounted(async () => {
padding: 1rem; padding: 1rem;
} }
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.section { .section {
margin: 2rem 0; margin: 2rem 0;
} }
@ -357,8 +356,25 @@ onMounted(async () => {
color: #000000 !important; color: #000000 !important;
} }
.invite-item ::v-deep .ant-tag, :deep(.ant-typography),
.invite-item ::v-deep .ant-tag * { :deep(.ant-typography p),
color: #000000 !important; :deep(.ant-typography span),
:deep(.ant-typography h4),
:deep(.ant-list-item),
:deep(.ant-list-item-meta-title),
:deep(.ant-list-item-meta-description),
:deep(.ant-tabs-tab),
:deep(.ant-input-number),
:deep(.ant-input-number-input) {
color: #e5e7eb !important;
}
:deep(.ant-typography-secondary) {
color: #cbd5e1 !important;
}
:deep(.ant-input-number) {
background: #111827;
border-color: #334155;
} }
</style> </style>

View file

@ -1,10 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, computed } from 'vue' import { ref, onMounted, computed, h } from 'vue'
import { useRouter, useRoute } from 'vue-router' import { useRouter, useRoute } from 'vue-router'
import { Card, Typography, Button, List, Space, Spin, message, Tag, Divider } from 'ant-design-vue' import { Card, Typography, Button, List, Space, Spin, message, Tag, Divider, Upload, Modal, Table } from 'ant-design-vue'
import { apiClient, isAxiosError, API } from '../router/api' import { apiClient, isAxiosError, API } from '../router/api'
import { useUserStore } from '../stores/userStore' import { useUserStore } from '../stores/userStore'
import type { Role, Organization } from '../types/organization' import { InboxOutlined, DeleteOutlined } from '@ant-design/icons-vue'
import type { Role, Organization, TrainingFile } from '../types/organization'
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
@ -13,7 +14,10 @@ const orgId = route.params.id as string
const organization = ref<Organization | null>(null) const organization = ref<Organization | null>(null)
const roles = ref<Role[]>([]) const roles = ref<Role[]>([])
const members = ref<Array<{ user: { id: number }; role: string }>>([]) const members = ref<Array<{ user: { id: number }; role: string }>>([])
const trainingFiles = ref<TrainingFile[]>([])
const loading = ref(false) const loading = ref(false)
const uploading = ref(false)
const showUploadModal = ref(false)
const auth = useUserStore() const auth = useUserStore()
const isManager = computed(() => { const isManager = computed(() => {
@ -131,11 +135,189 @@ const selectRole = async (roleUuid: string) => {
} }
} }
const fetchTrainingFiles = async () => {
if (!organization.value?.uuid) return
try {
const response = await apiClient.get<TrainingFile[]>(
API.organizationTrainingFiles(organization.value.uuid),
)
trainingFiles.value = response.data
} catch (error) {
console.error('Failed to fetch training files:', error)
}
}
const beforeUpload = (file: File) => {
const allowedExtensions = ['txt', 'pdf', 'md', 'csv', 'json', 'docx', 'doc']
const fileExtension = file.name.split('.').pop()?.toLowerCase()
if (!fileExtension || !allowedExtensions.includes(fileExtension)) {
message.error(
`File type ".${fileExtension}" is not allowed. Allowed types: ${allowedExtensions.join(', ')}`,
)
return false
}
const maxSize = 50 * 1024 * 1024 // 50MB
if (file.size > maxSize) {
message.error(`File size must not exceed 50MB. Current size: ${(file.size / 1024 / 1024).toFixed(2)}MB`)
return false
}
return false // Return false to prevent automatic upload
}
const selectedFile = ref<File | null>(null)
const fileDescription = ref('')
const handleFileSelected = (file: File) => {
selectedFile.value = file
}
const handleFileUploadClick = async () => {
if (!selectedFile.value) {
message.error('Please select a file to upload')
return
}
await handleFileUpload(selectedFile.value, fileDescription.value)
selectedFile.value = null
fileDescription.value = ''
}
const handleFileUpload = async (file: File, description: string = '') => {
if (!organization.value?.uuid) {
message.error('Organization not loaded')
return
}
uploading.value = true
try {
const formData = new FormData()
formData.append('file', file)
formData.append('file_name', file.name)
formData.append('description', description)
const response = await apiClient.post<TrainingFile>(
API.organizationTrainingFiles(organization.value.uuid),
formData,
{
headers: {
'Content-Type': 'multipart/form-data',
},
},
)
message.success(`File "${file.name}" uploaded successfully`)
trainingFiles.value.unshift(response.data)
showUploadModal.value = false
} catch (error) {
console.error('Failed to upload file:', error)
if (isAxiosError(error)) {
const errorMsg = error.response?.data?.error || error.response?.data?.file?.[0] || 'Failed to upload file'
message.error(errorMsg)
} else {
message.error('Failed to upload file')
}
} finally {
uploading.value = false
}
}
const deleteFile = async (uuid: string, fileName: string) => {
if (!organization.value?.uuid) {
message.error('Organization not loaded')
return
}
Modal.confirm({
title: 'Delete File',
content: `Are you sure you want to delete "${fileName}"? This action cannot be undone.`,
okText: 'Delete',
okType: 'danger',
cancelText: 'Cancel',
onOk: async () => {
try {
await apiClient.delete(API.organizationTrainingFile(organization.value!.uuid, uuid))
message.success('File deleted successfully')
trainingFiles.value = trainingFiles.value.filter((f) => f.uuid !== uuid)
} catch (error) {
console.error('Failed to delete file:', error)
message.error('Failed to delete file')
}
},
})
}
const formatFileSize = (bytes: number) => {
if (bytes === 0) return '0 Bytes'
const k = 1024
const sizes = ['Bytes', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i]
}
const trainingFileColumns = [
{
title: 'File Name',
dataIndex: 'file_name',
key: 'file_name',
},
{
title: 'Uploaded By',
key: 'uploaded_by',
customRender: ({ record }: { record: TrainingFile }) => {
if (!record.uploaded_by) return '-'
const full_name = `${record.uploaded_by.first_name} ${record.uploaded_by.last_name}`
return full_name
},
},
{
title: 'Size',
dataIndex: 'file_size',
key: 'file_size',
customRender: ({ value }: { value: number }) => formatFileSize(value || 0),
},
{
title: 'Status',
dataIndex: 'is_processed',
key: 'is_processed',
customRender: ({ value }: { value: boolean }) =>
h(Tag, { color: value ? 'success' : 'processing' }, () => value ? 'Processed' : 'Processing'),
},
{
title: 'Uploaded',
dataIndex: 'created_at',
key: 'created_at',
customRender: ({ value }: { value: string }) => new Date(value).toLocaleDateString(),
},
{
title: 'Action',
key: 'action',
customRender: ({ record }: { record: TrainingFile }) => {
if (isManager.value || auth.user?.id === record.uploaded_by?.id) {
return h(
Button,
{
danger: true,
size: 'small',
icon: h(DeleteOutlined),
onClick: () => deleteFile(record.uuid, record.file_name),
},
() => 'Delete',
)
}
return null
},
},
]
onMounted(() => { onMounted(() => {
fetchOrganization().then(async () => { fetchOrganization().then(async () => {
await fetchMembers() await fetchMembers()
await fetchRoles() await fetchRoles()
await fetchUserRoleMemberships() await fetchUserRoleMemberships()
await fetchTrainingFiles()
}) })
}) })
</script> </script>
@ -179,6 +361,35 @@ onMounted(() => {
<Divider /> <Divider />
<Typography.Title :level="4" class="section-title">
Training Files
</Typography.Title>
<div style="margin-bottom: 1rem">
<Button
type="primary"
@click="showUploadModal = true"
style="margin-bottom: 1rem"
>
Upload Training File
</Button>
</div>
<div v-if="trainingFiles.length > 0">
<Table
:columns="trainingFileColumns"
:data-source="trainingFiles"
:pagination="{ pageSize: 10 }"
:row-key="(record: TrainingFile) => record.uuid"
size="small"
/>
</div>
<Typography.Paragraph v-else type="secondary">
No training files uploaded yet.
</Typography.Paragraph>
<Divider />
<Typography.Title :level="4" class="section-title"> <Typography.Title :level="4" class="section-title">
Available Roles Available Roles
</Typography.Title> </Typography.Title>
@ -189,10 +400,11 @@ onMounted(() => {
<List.Item class="role-item"> <List.Item class="role-item">
<List.Item.Meta :title="item.name" /> <List.Item.Meta :title="item.name" />
<Space> <Space>
<Tag>{{ item.member_count ?? 0 }} members</Tag> <Tag color="cyan">{{ item.member_count ?? 0 }} members</Tag>
<Button <Button
type="default" type="default"
size="small" size="small"
:disabled="isManager"
@click="router.push(`/onboarding/${item.uuid}`)" @click="router.push(`/onboarding/${item.uuid}`)"
> >
Start Onboarding Start Onboarding
@ -201,6 +413,7 @@ onMounted(() => {
v-if="item.uuid && !isRoleJoined(item.uuid)" v-if="item.uuid && !isRoleJoined(item.uuid)"
type="primary" type="primary"
size="small" size="small"
:disabled="isManager"
@click="selectRole(item.uuid)" @click="selectRole(item.uuid)"
> >
Join Role Join Role
@ -216,6 +429,41 @@ onMounted(() => {
</Typography.Paragraph> </Typography.Paragraph>
</Card> </Card>
</Spin> </Spin>
<Modal
v-model:open="showUploadModal"
title="Upload Training File"
width="600px"
ok-text="Upload"
cancel-text="Cancel"
:ok-button-props="{ loading: uploading, disabled: !selectedFile }"
@ok="handleFileUploadClick"
@cancel="showUploadModal = false"
>
<div style="display: flex; flex-direction: column; gap: 1rem">
<Typography.Text>
Supported formats: <strong>txt, pdf, md, csv, json, docx, doc</strong> (Max 50MB)
</Typography.Text>
<Upload.Dragger
accept=".txt,.pdf,.md,.csv,.json,.docx,.doc"
:before-upload="(file) => {
beforeUpload(file)
handleFileSelected(file)
return false
}"
:multiple="false"
:auto-upload="false"
>
<p class="ant-upload-drag-icon">
<InboxOutlined />
</p>
<p class="ant-upload-text">Click or drag file to this area to upload</p>
<p class="ant-upload-hint">
{{ selectedFile ? selectedFile.name : 'Single file upload' }}
</p>
</Upload.Dragger>
</div>
</Modal>
</div> </div>
</template> </template>