improved uiux

This commit is contained in:
ALIHAN DIKEL
2025-03-10 16:19:13 +03:00
parent 7f2c20cb07
commit 17b8341535
3 changed files with 420 additions and 146 deletions

View File

@@ -2,9 +2,10 @@ import asyncio
import shutil
import json
from contextlib import asynccontextmanager
from typing import List
from fastapi import FastAPI, UploadFile, File, HTTPException, Request, WebSocket, WebSocketDisconnect
from fastapi.responses import HTMLResponse
from fastapi import FastAPI, UploadFile, File, HTTPException, Request, WebSocket, WebSocketDisconnect, Form
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from loguru import logger
@@ -73,6 +74,123 @@ async def upload_file(file: UploadFile = File(...)):
return {"filename": file.filename, "saved_path": str(file_path), "status": "success"}
@app.post("/upload-multiple")
async def upload_multiple_files(files: List[UploadFile] = File(...)):
"""
API endpoint to handle multiple file uploads
- Processes each file individually
- Returns a summary of the upload results
"""
results = []
for file in files:
# Validate file extension
if not is_valid_file(file.filename):
results.append({
"filename": file.filename,
"status": "error",
"detail": f"Invalid file type. Only {', '.join(ALLOWED_EXTENSIONS)} are allowed."
})
continue
# Check if file already exists
file_path = UPLOAD_DIR / file.filename
if file_path.exists():
results.append({
"filename": file.filename,
"status": "duplicate",
"detail": "File already exists"
})
continue
# Save the file
try:
with open(file_path, "wb") as buffer:
shutil.copyfileobj(file.file, buffer)
results.append({
"filename": file.filename,
"saved_path": str(file_path),
"status": "success"
})
except Exception as e:
logger.error(f"Error saving file {file.filename}: {str(e)}")
results.append({
"filename": file.filename,
"status": "error",
"detail": f"Failed to save file: {str(e)}"
})
# Broadcast file list update to all connected clients
asyncio.create_task(broadcast_file_list())
# Calculate summary
summary = {
"total": len(files),
"success": sum(1 for r in results if r["status"] == "success"),
"duplicate": sum(1 for r in results if r["status"] == "duplicate"),
"error": sum(1 for r in results if r["status"] == "error"),
"results": results
}
return summary
@app.post("/process-multiple")
async def process_multiple_files(filenames: List[str] = Form(...)):
"""
API endpoint to process multiple files at once
"""
results = []
for filename in filenames:
file_path = UPLOAD_DIR / filename
# Check if file exists
if not file_path.exists():
results.append({
"filename": filename,
"status": "error",
"detail": "File not found"
})
continue
# Process the file
try:
success = await audio_processor.process_file(filename)
if success:
results.append({
"filename": filename,
"status": "processing_started"
})
else:
results.append({
"filename": filename,
"status": "error",
"detail": "Failed to start processing"
})
except Exception as e:
logger.error(f"Error processing file {filename}: {str(e)}")
results.append({
"filename": filename,
"status": "error",
"detail": f"Error: {str(e)}"
})
# Broadcast updated status
await broadcast_file_list()
# Calculate summary
summary = {
"total": len(filenames),
"success": sum(1 for r in results if r["status"] == "processing_started"),
"error": sum(1 for r in results if r["status"] == "error"),
"results": results
}
return summary
def get_file_list():
"""Helper function to get file list with metadata and status"""
files = []
@@ -124,6 +242,7 @@ async def process_file(filename: str):
return {"filename": filename, "status": "processing_started"}
@app.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket):
"""WebSocket endpoint for real-time file updates"""

View File

@@ -1,11 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<!-- Head content remains the same -->
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Transcriptor Agent</title>
<link rel="icon" href="/static/favicon.ico" type="image/x-icon">
<style>
/* Existing styles */
body {
font-family: Arial, sans-serif;
max-width: 600px;
@@ -67,20 +69,24 @@
margin-top: 15px;
}
.file-item {
padding: 8px;
padding: 8px 10px;
border-bottom: 1px solid #eee;
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.95em;
}
.file-item:last-child {
border-bottom: none;
}
.file-name {
font-weight: bold;
font-weight: normal;
color: #444;
margin-bottom: 3px;
}
.file-meta {
color: #777;
font-size: 0.9em;
color: #888;
font-size: 0.85em;
}
.connection-status {
font-size: 0.8em;
@@ -101,35 +107,87 @@
background-color: #fcf8e3;
color: #8a6d3b;
}
.file-actions {
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-end;
gap: 10px;
white-space: nowrap;
min-width: 180px;
}
.status-badge {
display: inline-block;
padding: 2px 6px;
border-radius: 10px;
padding: 2px 8px;
border-radius: 12px;
font-size: 0.8em;
margin-right: 8px;
width: 80px;
text-align: center;
}
.status-pending { background-color: #fcf8e3; color: #8a6d3b; }
.status-queued { background-color: #f0f8ff; color: #5588bb; }
.status-processing { background-color: #d9edf7; color: #31708f; }
.status-completed { background-color: #dff0d8; color: #3c763d; }
.status-failed { background-color: #f2dede; color: #a94442; }
.process-btn {
background-color: #5bc0de;
padding: 3px 8px;
font-size: 0.8em;
padding: 4px 10px;
font-size: 0.85em;
border-radius: 4px;
min-width: 70px;
display: inline-block;
text-align: center;
}
.transcript-available {
color: #3c763d;
font-size: 0.85em;
margin-right: 8px;
}
/* New styles for upload progress */
.upload-progress {
margin-top: 15px;
display: none;
}
.progress-item {
margin-bottom: 6px;
padding: 4px 8px;
border: 1px solid #eee;
border-radius: 3px;
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.9em;
}
.progress-filename {
font-weight: normal;
color: #444;
max-width: 75%;
overflow: hidden;
text-overflow: ellipsis;
}
.progress-status {
color: #777;
font-size: 0.9em;
}
</style>
</head>
<body>
<h1>Transcriptor Agent</h1>
<div class="upload-form">
<form id="uploadForm">
<div class="form-group">
<label for="audioFile">Select audio file:</label>
<input type="file" id="audioFile" name="file" accept=".mp3,.wav" required>
<label for="audioFile">Select audio files (MP3, WAV):</label>
<input type="file" id="audioFile" name="files" accept=".mp3,.wav" multiple required>
</div>
<button type="submit">Upload</button>
<button type="submit">Upload Files</button>
</form>
<!-- New progress container -->
<div id="uploadProgress" class="upload-progress">
<h3>Upload Progress</h3>
<div id="progressItems"></div>
</div>
</div>
<div id="result" style="display: none;"></div>
@@ -148,49 +206,93 @@
e.preventDefault();
const fileInput = document.getElementById('audioFile');
const file = fileInput.files[0];
const files = fileInput.files;
if (!file) {
showResult('Please select a file.', false);
if (!files || files.length === 0) {
showResult('Please select at least one file.', false);
return;
}
// Check file extension
const fileName = file.name;
const fileExt = fileName.split('.').pop().toLowerCase();
// Setup progress tracking
const progressContainer = document.getElementById('uploadProgress');
const progressItems = document.getElementById('progressItems');
progressContainer.style.display = 'block';
progressItems.innerHTML = '';
if (!['mp3', 'wav'].includes(fileExt)) {
showResult('Only MP3 and WAV files are allowed.', false);
return;
}
// Keep track of successful and failed uploads
let successCount = 0;
let failCount = 0;
let duplicateCount = 0;
// Create form data
const formData = new FormData();
formData.append('file', file);
// Process each file
for (let i = 0; i < files.length; i++) {
const file = files[i];
try {
const response = await fetch('/upload', {
method: 'POST',
body: formData
});
// Create progress item
const progressId = `progress-${i}`;
const progressItem = document.createElement('div');
progressItem.className = 'progress-item';
progressItem.innerHTML = `
<span class="progress-filename">${file.name}</span>
<span id="${progressId}" class="progress-status">Uploading...</span>
`;
progressItems.appendChild(progressItem);
const result = await response.json();
// Check file extension
const fileName = file.name;
const fileExt = fileName.split('.').pop().toLowerCase();
if (response.ok) {
if (result.status === "duplicate") {
showResult(`File "${result.filename}" already exists in uploads directory!`, false);
} else {
showResult(`File "${result.filename}" uploaded successfully!`, true);
fileInput.value = ''; // Clear the input
// No need to manually refresh - WebSocket will handle it
}
} else {
showResult(`Error: ${result.detail}`, false);
if (!['mp3', 'wav'].includes(fileExt)) {
document.getElementById(progressId).textContent = 'Invalid format (MP3/WAV only)';
document.getElementById(progressId).style.color = '#a94442';
failCount++;
continue;
}
// Create form data for this file
const formData = new FormData();
formData.append('file', file);
try {
const response = await fetch('/upload', {
method: 'POST',
body: formData
});
const result = await response.json();
if (response.ok) {
if (result.status === "duplicate") {
document.getElementById(progressId).textContent = 'Already exists';
document.getElementById(progressId).style.color = '#8a6d3b';
duplicateCount++;
} else {
document.getElementById(progressId).textContent = 'Uploaded successfully';
document.getElementById(progressId).style.color = '#3c763d';
successCount++;
}
} else {
document.getElementById(progressId).textContent = `Error: ${result.detail}`;
document.getElementById(progressId).style.color = '#a94442';
failCount++;
}
} catch (error) {
document.getElementById(progressId).textContent = `Failed: ${error.message}`;
document.getElementById(progressId).style.color = '#a94442';
failCount++;
}
} catch (error) {
showResult(`Upload failed: ${error.message}`, false);
}
// Show summary
let summary = "";
if (successCount > 0) summary += `${successCount} file(s) uploaded successfully. `;
if (duplicateCount > 0) summary += `${duplicateCount} file(s) already exist. `;
if (failCount > 0) summary += `${failCount} file(s) failed to upload.`;
showResult(summary, successCount > 0 && failCount === 0);
// Clear the input
fileInput.value = '';
});
function showResult(message, isSuccess) {
@@ -256,73 +358,104 @@
}
function updateFileList(files) {
const fileListElement = document.getElementById('fileList');
const fileListElement = document.getElementById('fileList');
if (!files || files.length === 0) {
fileListElement.innerHTML = '<p>No files uploaded yet.</p>';
return;
}
if (!files || files.length === 0) {
fileListElement.innerHTML = '<p>No files uploaded yet.</p>';
return;
}
let html = '';
files.forEach(file => {
// Format the file size
const fileSizeKB = Math.round(file.size / 1024);
let fileSizeStr = fileSizeKB + ' KB';
if (fileSizeKB >= 1024) {
const fileSizeMB = (fileSizeKB / 1024).toFixed(1);
fileSizeStr = fileSizeMB + ' MB';
}
let html = '';
files.forEach(file => {
// Format the file size
const fileSizeKB = Math.round(file.size / 1024);
let fileSizeStr = fileSizeKB + ' KB';
if (fileSizeKB >= 1024) {
const fileSizeMB = (fileSizeKB / 1024).toFixed(1);
fileSizeStr = fileSizeMB + ' MB';
}
// Format the date
const date = new Date(file.created * 1000);
const dateStr = date.toLocaleString();
// Format the date
const date = new Date(file.created * 1000);
const dateStr = date.toLocaleString();
// Status badge
const statusClass = `status-${file.status || 'pending'}`;
// Status badge
const statusClass = `status-${file.status || 'pending'}`;
// Process button (only show if not completed/processing)
const processButton = file.status === 'completed' || file.status === 'processing'
? ''
: `<button class="process-btn" onclick="triggerProcessing('${file.name}')">Process</button>`;
// Process button (only show if not completed/processing)
const processButton = file.status === 'completed' || file.status === 'processing'
? ''
: `<button class="process-btn" onclick="triggerProcessing('${file.name}')">Process</button>`;
// Transcript indicator
const transcriptInfo = file.has_transcript
? '<span class="transcript-available">✓ Transcript</span>'
: '';
// Transcript indicator
const transcriptInfo = file.has_transcript
? '<span class="transcript-available">✓ Transcript</span>'
: '';
html += `<div class="file-item">
<div>
<div class="file-name">${file.name}</div>
<div class="file-meta">Size: ${fileSizeStr} | Uploaded: ${dateStr}</div>
</div>
<div>
<span class="status-badge ${statusClass}">${file.status || 'pending'}</span>
${transcriptInfo}
${processButton}
</div>
</div>`;
});
fileListElement.innerHTML = html;
}
// Add this function after updateFileList
async function triggerProcessing(filename) {
try {
const response = await fetch(`/process/${filename}`, {
method: 'POST'
html += `<div class="file-item">
<div>
<div class="file-name">${file.name}</div>
<div class="file-meta">Size: ${fileSizeStr} | Uploaded: ${dateStr}</div>
</div>
<div>
<span class="status-badge ${statusClass}">${file.status || 'pending'}</span>
${transcriptInfo}
${processButton}
</div>
</div>`;
});
if (response.ok) {
showResult(`Processing started for "${filename}"`, true);
} else {
const result = await response.json();
showResult(`Error: ${result.detail}`, false);
}
} catch (error) {
showResult(`Processing request failed: ${error.message}`, false);
fileListElement.innerHTML = html;
}
// Add this function after updateFileList
async function triggerProcessing(filename) {
try {
const response = await fetch(`/process/${filename}`, {
method: 'POST'
});
if (response.ok) {
showResult(`Processing started for "${filename}"`, true);
} else {
const result = await response.json();
showResult(`Error: ${result.detail}`, false);
}
} catch (error) {
showResult(`Processing request failed: ${error.message}`, false);
}
}
// New function to process multiple files at once
async function processAllSelected() {
const selectedFiles = document.querySelectorAll('.file-item input[type="checkbox"]:checked');
if (selectedFiles.length === 0) {
showResult('Please select at least one file to process', false);
return;
}
let successCount = 0;
let failCount = 0;
for (const checkbox of selectedFiles) {
const filename = checkbox.getAttribute('data-filename');
try {
const response = await fetch(`/process/${filename}`, {
method: 'POST'
});
if (response.ok) {
successCount++;
} else {
failCount++;
}
} catch (error) {
failCount++;
}
}
showResult(`Processing started for ${successCount} file(s). ${failCount > 0 ? failCount + ' file(s) failed.' : ''}`, failCount === 0);
}
}
// Initialize WebSocket connection when page loads
document.addEventListener('DOMContentLoaded', connectWebSocket);

View File

@@ -3,8 +3,9 @@ import asyncio
import json
import time
from pathlib import Path
from typing import Dict, List, Set, Optional
from typing import Dict, List, Set, Optional, Deque
from enum import Enum
from collections import deque
from loguru import logger
@@ -14,6 +15,7 @@ from engine.stt import WhisperEngine, TranscriptionResult
class FileStatus(Enum):
PENDING = "pending"
QUEUED = "queued"
PROCESSING = "processing"
COMPLETED = "completed"
FAILED = "failed"
@@ -24,7 +26,9 @@ class AudioProcessor:
self.file_status: Dict[str, FileStatus] = {}
self.is_running = False
self._task: Optional[asyncio.Task] = None
self._transcription_tasks: Dict[str, asyncio.Task] = {}
self._processing_queue: Deque[str] = deque()
self._processing_lock = asyncio.Lock()
self._queue_processor_task: Optional[asyncio.Task] = None
# Initialize Whisper engine
self.stt_engine = WhisperEngine()
@@ -53,6 +57,7 @@ class AudioProcessor:
if not self._task or self._task.done():
self.is_running = True
self._task = asyncio.create_task(self._monitor_files())
self._queue_processor_task = asyncio.create_task(self._process_queue())
logger.info(f"STT Engine started with model: {self.stt_engine.model_name}")
def stop(self):
@@ -61,11 +66,8 @@ class AudioProcessor:
if self._task and not self._task.done():
self._task.cancel()
# Cancel any running transcription tasks
for filename, task in self._transcription_tasks.items():
if not task.done():
task.cancel()
logger.info(f"Cancelled transcription task for {filename}")
if self._queue_processor_task and not self._queue_processor_task.done():
self._queue_processor_task.cancel()
async def _monitor_files(self):
"""Monitor the uploads folder for new files"""
@@ -90,21 +92,41 @@ class AudioProcessor:
logger.error(f"File not found: {file_path}")
return False
# Check if already processing
if filename in self._transcription_tasks and not self._transcription_tasks[filename].done():
logger.info(f"File {filename} is already being processed")
# Check if already in queue or processing
current_status = self.file_status.get(filename, FileStatus.PENDING)
if current_status in [FileStatus.QUEUED, FileStatus.PROCESSING]:
logger.info(f"File {filename} is already in queue or being processed")
return True
# Update status
self.file_status[filename] = FileStatus.PROCESSING
# Update status to queued
self.file_status[filename] = FileStatus.QUEUED
# Create transcription task
self._transcription_tasks[filename] = asyncio.create_task(
self._transcribe_file(filename)
)
# Add to processing queue
self._processing_queue.append(filename)
logger.info(f"Added {filename} to processing queue. Queue size: {len(self._processing_queue)}")
return True
async def _process_queue(self):
"""Process files in the queue sequentially"""
while self.is_running:
if self._processing_queue:
# Get the next file to process
filename = self._processing_queue.popleft()
# Process the file
self.file_status[filename] = FileStatus.PROCESSING
logger.info(f"Processing file from queue: {filename}")
try:
await self._transcribe_file(filename)
except Exception as e:
logger.error(f"Error processing queued file {filename}: {e}")
self.file_status[filename] = FileStatus.FAILED
# Sleep briefly before checking the queue again
await asyncio.sleep(0.5)
async def _transcribe_file(self, filename: str) -> bool:
"""Run the actual transcription in a separate task."""
file_path = UPLOAD_DIR / filename
@@ -115,32 +137,34 @@ class AudioProcessor:
try:
logger.info(f"Starting transcription for {filename}")
# Run transcription (in a thread pool to avoid blocking the event loop)
result = await asyncio.to_thread(
self.stt_engine.transcribe,
file_path
)
# Ensure only one transcription runs at a time
async with self._processing_lock:
# Run transcription (in a thread pool to avoid blocking the event loop)
result = await asyncio.to_thread(
self.stt_engine.transcribe,
file_path
)
# Save plain text transcript
with open(transcript_path, "w", encoding="utf-8") as f:
f.write(result.text)
# Save plain text transcript
with open(transcript_path, "w", encoding="utf-8") as f:
f.write(result.text)
# Save detailed JSON result
with open(json_path, "w", encoding="utf-8") as f:
# Create a serializable version of the result
serializable_result = {
"text": result.text,
"language": result.language,
"segments": result.segments,
"duration": result.duration,
"processing_time": result.processing_time,
"metadata": {
"model": self.stt_engine.model_name,
"device": self.stt_engine.device,
"timestamp": time.strftime('%Y-%m-%d %H:%M:%S')
# Save detailed JSON result
with open(json_path, "w", encoding="utf-8") as f:
# Create a serializable version of the result
serializable_result = {
"text": result.text,
"language": result.language,
"segments": result.segments,
"duration": result.duration,
"processing_time": result.processing_time,
"metadata": {
"model": self.stt_engine.model_name,
"device": self.stt_engine.device,
"timestamp": time.strftime('%Y-%m-%d %H:%M:%S')
}
}
}
json.dump(serializable_result, f, indent=2)
json.dump(serializable_result, f, indent=2)
logger.success(f"Transcription completed for {filename} "
f"({result.duration:.1f}s audio, {result.processing_time:.1f}s processing)")
@@ -160,6 +184,4 @@ class AudioProcessor:
def get_all_statuses(self) -> Dict[str, str]:
"""Get status of all files"""
return {name: status.value for name, status in self.file_status.items()}
return {name: status.value for name, status in self.file_status.items()}