Added training files models, viewsets and api paths with frontend upload
This commit is contained in:
parent
1f5ce71e5d
commit
7714fa4d8f
19 changed files with 615 additions and 60 deletions
|
|
@ -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,)
|
||||||
|
|
|
||||||
|
|
@ -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={
|
||||||
|
|
|
||||||
|
|
@ -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),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
@ -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']
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ class AgentSerializer(ModelSerializer):
|
||||||
'id',
|
'id',
|
||||||
'uuid',
|
'uuid',
|
||||||
'model',
|
'model',
|
||||||
|
'organization',
|
||||||
'status',
|
'status',
|
||||||
'description',
|
'description',
|
||||||
'started_at',
|
'started_at',
|
||||||
|
|
|
||||||
|
|
@ -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}")
|
||||||
"base_model": base_model,
|
logger.info(f"Training files count: {len(training_files)}")
|
||||||
"training_files": training_files,
|
logger.debug(f"Training files: {training_files}")
|
||||||
"hyperparams": hyperparams,
|
try:
|
||||||
"name": name,
|
logger.info("Calling MCP fine_tune tool...")
|
||||||
"version": version,
|
result = asyncio.run(_call_mcp("fine_tune", {
|
||||||
}))
|
"base_model": base_model,
|
||||||
|
"training_files": training_files,
|
||||||
|
"hyperparams": hyperparams,
|
||||||
|
"name": name,
|
||||||
|
"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:
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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')
|
||||||
|
|
@ -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'],
|
||||||
|
},
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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'})
|
||||||
|
|
||||||
|
|
|
||||||
11
src/App.vue
11
src/App.vue
|
|
@ -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"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -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}/`,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
<Typography.Title :level="2">Manage {{ organization.name }}</Typography.Title>
|
<div class="header">
|
||||||
|
<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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue