Add web service: FastAPI backend + minimal frontend with drag-drop, WebSocket progress, file tree and MD viewer

This commit is contained in:
Кирилл Блинов 2026-05-29 12:17:08 +03:00
parent c771f83351
commit beb411dfdc
8 changed files with 1340 additions and 0 deletions

4
.gitignore vendored
View File

@ -66,3 +66,7 @@ Thumbs.db
# User data
video/
*.mp4
# Server logs
server.log
*.log

0
backend/__init__.py Normal file
View File

183
backend/main.py Normal file
View File

@ -0,0 +1,183 @@
"""FastAPI backend для сервиса транскрибации."""
import json
from pathlib import Path
from typing import List, Optional
from fastapi import FastAPI, File, UploadFile, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, PlainTextResponse, HTMLResponse
from fastapi.staticfiles import StaticFiles
from backend.queue import (
UPLOAD_DIR,
PROCESSED_DIR,
enqueue,
get_all_tasks,
get_task_status,
get_processed_tree,
read_file_content,
set_progress_callback,
)
app = FastAPI(title="Transcription Service", version="1.0.0")
# CORS
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# WebSocket менеджер
class ConnectionManager:
def __init__(self):
self.active_connections: List[WebSocket] = []
async def connect(self, websocket: WebSocket):
await websocket.accept()
self.active_connections.append(websocket)
def disconnect(self, websocket: WebSocket):
if websocket in self.active_connections:
self.active_connections.remove(websocket)
async def broadcast(self, message: dict):
for conn in self.active_connections:
try:
await conn.send_json(message)
except Exception:
pass
manager = ConnectionManager()
# Устанавливаем callback для отправки прогресса через WebSocket
set_progress_callback(manager.broadcast)
# === API Endpoints ===
@app.get("/", response_class=HTMLResponse)
async def root():
"""Главная страница."""
index_path = Path(__file__).parent / "static" / "index.html"
if index_path.exists():
return index_path.read_text(encoding="utf-8")
return "<h1>Transcription Service</h1><p>Frontend not built</p>"
@app.post("/upload")
async def upload_file(file: UploadFile = File(...)):
"""Загружает файл и добавляет в очередь обработки."""
# Сохраняем файл
file_path = UPLOAD_DIR / file.filename
with open(file_path, "wb") as f:
content = await file.read()
f.write(content)
# Добавляем в очередь
task_id = await enqueue(file_path)
return {
"task_id": task_id,
"filename": file.filename,
"status": "queued",
"message": "Файл добавлен в очередь обработки",
}
@app.post("/upload-batch")
async def upload_batch(files: List[UploadFile] = File(...)):
"""Загружает несколько файлов пакетно."""
results = []
for file in files:
file_path = UPLOAD_DIR / file.filename
with open(file_path, "wb") as f:
content = await file.read()
f.write(content)
task_id = await enqueue(file_path)
results.append({
"task_id": task_id,
"filename": file.filename,
"status": "queued",
})
return {
"uploaded": len(results),
"tasks": results,
}
@app.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket):
"""WebSocket для получения прогресса обработки."""
await manager.connect(websocket)
try:
while True:
# Ждём сообщения от клиента (ping/keepalive)
data = await websocket.receive_text()
msg = json.loads(data)
if msg.get("action") == "get_tasks":
tasks = get_all_tasks()
await websocket.send_json({
"type": "tasks_list",
"tasks": tasks,
})
elif msg.get("action") == "get_tree":
tree = get_processed_tree()
await websocket.send_json({
"type": "file_tree",
"tree": tree,
})
except WebSocketDisconnect:
manager.disconnect(websocket)
except Exception:
manager.disconnect(websocket)
@app.get("/api/tasks")
async def api_tasks():
"""Возвращает список всех задач."""
return {"tasks": get_all_tasks()}
@app.get("/api/tasks/{task_id}")
async def api_task(task_id: str):
"""Возвращает статус конкретной задачи."""
status = get_task_status(task_id)
if not status:
return {"error": "Task not found"}
return status
@app.get("/api/files")
async def api_files():
"""Возвращает дерево обработанных файлов."""
return {"tree": get_processed_tree()}
@app.get("/api/files/content")
async def api_file_content(path: str):
"""Возвращает содержимое файла."""
try:
content = read_file_content(path)
return {"content": content, "path": path}
except Exception as e:
return {"error": str(e)}
@app.get("/api/files/download")
async def api_download(path: str):
"""Скачивает файл."""
file_path = PROCESSED_DIR / path
if not file_path.exists():
return {"error": "File not found"}
return FileResponse(file_path, filename=file_path.name)
# Статические файлы
app.mount("/static", StaticFiles(directory="backend/static"), name="static")

227
backend/queue.py Normal file
View File

@ -0,0 +1,227 @@
"""Фоновая очередь обработки аудио/видео."""
import asyncio
import json
import os
import shutil
import sys
from datetime import datetime
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional
sys.path.insert(0, str(Path(__file__).parent.parent))
from src.audio_utils import prepare_audio_input
from src.config import get_profile, load_config, resolve_hf_token
from src.document import build_document
from src.pipeline import run_pipeline
UPLOAD_DIR = Path("uploads")
PROCESSED_DIR = Path("processed")
UPLOAD_DIR.mkdir(exist_ok=True)
PROCESSED_DIR.mkdir(exist_ok=True)
# Глобальное хранилище состояний задач
tasks: Dict[str, Dict[str, Any]] = {}
# Callback для отправки прогресса через WebSocket
_progress_callback: Optional[Callable] = None
def set_progress_callback(callback: Callable):
"""Устанавливает callback для отправки прогресса."""
global _progress_callback
_progress_callback = callback
async def _send_progress(task_id: str, progress: int, message: str, status: str, result=None, error=None):
"""Отправляет прогресс через callback."""
if _progress_callback:
try:
await _progress_callback({
"task_id": task_id,
"progress": progress,
"message": message,
"status": status,
"result": result,
"error": error,
})
except Exception:
pass
async def process_file(file_path: Path, task_id: str):
"""Обрабатывает один файл и отправляет прогресс."""
tasks[task_id] = {
"task_id": task_id,
"status": "processing",
"progress": 0,
"message": "Начало обработки...",
"file": str(file_path.name),
"result": None,
"error": None,
"started": datetime.now().isoformat(),
}
await _send_progress(task_id, 5, "Извлечение аудио...", "processing")
try:
# Загружаем конфиг
config = load_config()
profile = get_profile(config)
await _send_progress(task_id, 15, "Загрузка моделей ИИ...", "processing")
# Подготовка аудио
audio_path = prepare_audio_input(str(file_path))
await _send_progress(task_id, 25, "Транскрибация (распознавание речи)...", "processing")
# Запуск пайплайна
result = run_pipeline(
input_path=str(file_path),
profile_name=None,
config_path=None,
)
await _send_progress(task_id, 75, "Генерация документов...", "processing")
# Определяем имена выходных файлов
stem = file_path.stem
output_dir = PROCESSED_DIR / stem
output_dir.mkdir(parents=True, exist_ok=True)
# Сохраняем docx и md
docx_path = str(output_dir / f"{stem}.docx")
md_path = str(output_dir / f"{stem}.md")
build_document(result["segments"], docx_path, config)
build_document(result["segments"], md_path, config)
# Также сохраняем исходник
src_copy = output_dir / file_path.name
if not src_copy.exists():
shutil.copy2(str(file_path), str(src_copy))
result_data = {
"docx": str(docx_path),
"md": str(md_path),
"dir": str(output_dir),
}
await _send_progress(task_id, 100, "Обработка завершена", "completed", result=result_data)
tasks[task_id].update({
"status": "completed",
"progress": 100,
"message": "Обработка завершена",
"result": result_data,
"finished": datetime.now().isoformat(),
})
except Exception as e:
error_msg = str(e)
await _send_progress(task_id, 0, f"Ошибка: {error_msg}", "error", error=error_msg)
tasks[task_id].update({
"status": "error",
"progress": 0,
"message": f"Ошибка: {error_msg}",
"error": error_msg,
})
# Очередь задач
_queue: asyncio.Queue = asyncio.Queue()
_workers: List[asyncio.Task] = []
async def _worker_loop():
"""Рабочий цикл обработки."""
while True:
try:
task_id, file_path = await _queue.get()
await process_file(file_path, task_id)
_queue.task_done()
except asyncio.CancelledError:
break
except Exception as e:
print(f"[Worker Error] {e}")
def start_workers(num_workers: int = 1):
"""Запускает рабочих."""
global _workers
loop = asyncio.get_event_loop()
for i in range(num_workers):
task = loop.create_task(_worker_loop())
_workers.append(task)
def stop_workers():
"""Останавливает рабочих."""
for w in _workers:
w.cancel()
async def enqueue(file_path: Path) -> str:
"""Добавляет файл в очередь."""
task_id = f"task_{datetime.now().strftime('%Y%m%d_%H%M%S')}_{file_path.stem}"
tasks[task_id] = {
"task_id": task_id,
"status": "queued",
"progress": 0,
"message": "В очереди...",
"file": str(file_path.name),
"result": None,
"error": None,
"started": datetime.now().isoformat(),
}
await _queue.put((task_id, file_path))
return task_id
def get_task_status(task_id: str) -> Optional[Dict[str, Any]]:
"""Возвращает статус задачи."""
return tasks.get(task_id)
def get_all_tasks() -> List[Dict[str, Any]]:
"""Возвращает все задачи."""
return list(tasks.values())
def get_processed_tree() -> List[Dict[str, Any]]:
"""Возвращает дерево обработанных файлов."""
tree = []
if not PROCESSED_DIR.exists():
return tree
for item in sorted(PROCESSED_DIR.iterdir()):
if item.is_dir():
files = []
for f in sorted(item.iterdir()):
if f.is_file():
files.append({
"name": f.name,
"path": str(f.relative_to(PROCESSED_DIR)),
"size": f.stat().st_size,
"ext": f.suffix.lower(),
})
tree.append({
"name": item.name,
"path": str(item.relative_to(PROCESSED_DIR)),
"files": files,
"created": datetime.fromtimestamp(item.stat().st_ctime).isoformat(),
})
return tree
def read_file_content(rel_path: str) -> str:
"""Читает содержимое файла."""
full_path = PROCESSED_DIR / rel_path
if not full_path.exists() or not full_path.is_file():
raise FileNotFoundError(f"Файл не найден: {rel_path}")
with open(full_path, "r", encoding="utf-8") as f:
return f.read()

362
backend/static/app.js Normal file
View File

@ -0,0 +1,362 @@
/**
* Frontend application for Transcription Service
*/
class TranscriptionApp {
constructor() {
this.ws = null;
this.tasks = new Map();
this.currentFile = null;
this.init();
}
init() {
this.connectWebSocket();
this.setupUpload();
this.loadFileTree();
}
// ===== WebSocket =====
connectWebSocket() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
this.ws = new WebSocket(`${protocol}//${window.location.host}/ws`);
this.ws.onopen = () => {
console.log('WebSocket connected');
this.showToast('Подключено к серверу', 'success');
this.requestTasks();
this.requestTree();
};
this.ws.onmessage = (event) => {
const data = JSON.parse(event.data);
this.handleWebSocketMessage(data);
};
this.ws.onclose = () => {
console.log('WebSocket disconnected, reconnecting in 3s...');
setTimeout(() => this.connectWebSocket(), 3000);
};
this.ws.onerror = (error) => {
console.error('WebSocket error:', error);
};
}
sendWS(data) {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(data));
}
}
requestTasks() {
this.sendWS({ action: 'get_tasks' });
}
requestTree() {
this.sendWS({ action: 'get_tree' });
}
handleWebSocketMessage(data) {
if (data.type === 'tasks_list') {
this.updateTasks(data.tasks);
} else if (data.type === 'file_tree') {
this.renderFileTree(data.tree);
} else if (data.task_id) {
// Прогресс обработки
this.updateTaskProgress(data);
}
}
// ===== Upload =====
setupUpload() {
const dropZone = document.getElementById('dropZone');
const fileInput = document.getElementById('fileInput');
const browseLink = document.querySelector('.browse-link');
// Click to browse
browseLink.addEventListener('click', () => fileInput.click());
dropZone.addEventListener('click', (e) => {
if (e.target === dropZone || e.target.closest('.drop-zone-content')) {
fileInput.click();
}
});
// File input change
fileInput.addEventListener('change', (e) => {
this.handleFiles(e.target.files);
});
// Drag & drop
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.classList.add('drag-over');
});
dropZone.addEventListener('dragleave', () => {
dropZone.classList.remove('drag-over');
});
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.classList.remove('drag-over');
this.handleFiles(e.dataTransfer.files);
});
}
async handleFiles(files) {
if (!files.length) return;
const formData = new FormData();
for (const file of files) {
formData.append('files', file);
}
try {
this.showToast(`Загрузка ${files.length} файл(а)...`, 'info');
const response = await fetch('/upload-batch', {
method: 'POST',
body: formData,
});
const result = await response.json();
if (result.error) {
this.showToast(`Ошибка: ${result.error}`, 'error');
} else {
this.showToast(`Загружено ${result.uploaded} файл(а). Начинается обработка...`, 'success');
result.tasks.forEach(task => {
this.tasks.set(task.task_id, task);
});
this.renderTasks();
}
} catch (error) {
this.showToast(`Ошибка загрузки: ${error.message}`, 'error');
}
}
// ===== Tasks / Progress =====
updateTasks(tasks) {
tasks.forEach(task => {
this.tasks.set(task.file + '_' + task.started, task);
});
this.renderTasks();
}
updateTaskProgress(data) {
const existing = Array.from(this.tasks.values()).find(t => t.task_id === data.task_id);
if (existing) {
Object.assign(existing, data);
} else {
this.tasks.set(data.task_id, data);
}
this.renderTasks();
if (data.status === 'completed') {
this.showToast(`Готово: ${data.message}`, 'success');
this.requestTree();
} else if (data.status === 'error') {
this.showToast(`Ошибка: ${data.message}`, 'error');
}
}
renderTasks() {
const container = document.getElementById('tasksList');
const tasks = Array.from(this.tasks.values());
if (tasks.length === 0) {
container.innerHTML = '<p class="empty-state">Нет активных задач</p>';
return;
}
container.innerHTML = tasks.map(task => this.renderTaskItem(task)).join('');
}
renderTaskItem(task) {
const progress = task.progress || 0;
const statusClass = task.status === 'completed' ? 'success' :
task.status === 'error' ? 'error' :
task.status === 'processing' ? 'processing' : 'queued';
return `
<div class="task-item ${statusClass}">
<div class="task-header">
<span class="task-filename">${this.escapeHtml(task.file || '')}</span>
<span class="task-status-badge ${statusClass}">${this.getStatusLabel(task.status)}</span>
</div>
<div class="task-progress-bar">
<div class="task-progress-fill" style="width: ${progress}%"></div>
</div>
<div class="task-message">${this.escapeHtml(task.message || '')}</div>
</div>
`;
}
getStatusLabel(status) {
const labels = {
'queued': 'В очереди',
'processing': 'Обработка',
'completed': 'Готово',
'error': 'Ошибка',
};
return labels[status] || status;
}
// ===== File Tree =====
renderFileTree(tree) {
const container = document.getElementById('fileTree');
if (!tree || tree.length === 0) {
container.innerHTML = '<p class="empty-state">Нет обработанных файлов</p>';
return;
}
container.innerHTML = tree.map(folder => this.renderFolder(folder)).join('');
}
renderFolder(folder) {
const files = folder.files.map(file => {
const isMd = file.ext === '.md';
const isDocx = file.ext === '.docx';
const icon = isMd ? '📝' : isDocx ? '📄' : '📎';
const clickable = isMd ? 'clickable' : '';
return `
<div class="file-item ${clickable}" data-path="${this.escapeHtml(file.path)}" data-ext="${file.ext}">
<span class="file-icon">${icon}</span>
<span class="file-name">${this.escapeHtml(file.name)}</span>
<span class="file-size">${this.formatBytes(file.size)}</span>
</div>
`;
}).join('');
return `
<div class="folder-item">
<div class="folder-header">
<span class="folder-toggle"></span>
<span class="folder-icon">📁</span>
<span class="folder-name">${this.escapeHtml(folder.name)}</span>
<span class="folder-date">${this.formatDate(folder.created)}</span>
</div>
<div class="folder-files">
${files}
</div>
</div>
`;
}
setupFileTreeEvents() {
const container = document.getElementById('fileTree');
container.addEventListener('click', (e) => {
const folderHeader = e.target.closest('.folder-header');
if (folderHeader) {
const folder = folderHeader.closest('.folder-item');
const files = folder.querySelector('.folder-files');
const toggle = folderHeader.querySelector('.folder-toggle');
if (files.style.display === 'none') {
files.style.display = 'block';
toggle.textContent = '▼';
} else {
files.style.display = 'none';
toggle.textContent = '▶';
}
}
const fileItem = e.target.closest('.file-item.clickable');
if (fileItem) {
const path = fileItem.dataset.path;
const ext = fileItem.dataset.ext;
this.loadFileContent(path, ext);
}
});
}
// ===== Viewer =====
async loadFileContent(path, ext) {
const viewer = document.getElementById('viewer');
if (ext === '.md') {
try {
const response = await fetch(`/api/files/content?path=${encodeURIComponent(path)}`);
const result = await response.json();
if (result.error) {
viewer.innerHTML = `<div class="error">${this.escapeHtml(result.error)}</div>`;
return;
}
// Render markdown
const html = marked.parse(result.content);
viewer.innerHTML = `
<div class="md-content">
<div class="md-header">
<span class="md-title">${this.escapeHtml(path)}</span>
<a href="/api/files/download?path=${encodeURIComponent(path)}"
class="btn-download" download> Скачать</a>
</div>
<div class="md-body">${html}</div>
</div>
`;
this.currentFile = path;
} catch (error) {
viewer.innerHTML = `<div class="error">Ошибка загрузки: ${this.escapeHtml(error.message)}</div>`;
}
} else if (ext === '.docx') {
viewer.innerHTML = `
<div class="file-preview">
<p>Для просмотра DOCX скачайте файл:</p>
<a href="/api/files/download?path=${encodeURIComponent(path)}"
class="btn-download" download> Скачать ${this.escapeHtml(path)}</a>
</div>
`;
}
}
// ===== Utilities =====
showToast(message, type = 'info') {
const container = document.getElementById('toastContainer');
const toast = document.createElement('div');
toast.className = `toast toast-${type}`;
toast.textContent = message;
container.appendChild(toast);
setTimeout(() => {
toast.classList.add('toast-hide');
setTimeout(() => toast.remove(), 300);
}, 4000);
}
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
formatBytes(bytes) {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
}
formatDate(isoDate) {
const date = new Date(isoDate);
return date.toLocaleString('ru-RU', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
}
// Initialize
document.addEventListener('DOMContentLoaded', () => {
const app = new TranscriptionApp();
app.setupFileTreeEvents();
});

69
backend/static/index.html Normal file
View File

@ -0,0 +1,69 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Транскрибация совещаний</title>
<link rel="stylesheet" href="/static/styles.css">
<!-- Markdown renderer -->
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
</head>
<body>
<div class="container">
<header>
<h1>🎙️ Транскрибация совещаний</h1>
<p class="subtitle">Загрузите аудио или видео файл для получения протокола</p>
</header>
<main>
<!-- Upload Section -->
<section class="upload-section" id="uploadSection">
<div class="drop-zone" id="dropZone">
<div class="drop-zone-content">
<svg class="upload-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="17 8 12 3 7 8"/>
<line x1="12" y1="3" x2="12" y2="15"/>
</svg>
<p>Перетащите файлы сюда или <span class="browse-link">выберите</span></p>
<p class="hint">Поддерживаются: MP4, AVI, MKV, MOV, WAV, MP3, M4A, OGG, FLAC</p>
</div>
<input type="file" id="fileInput" multiple accept="video/*,audio/*" hidden>
</div>
<!-- Queue Status -->
<div class="queue-status" id="queueStatus">
<h3>Очередь обработки</h3>
<div class="tasks-list" id="tasksList">
<p class="empty-state">Нет активных задач</p>
</div>
</div>
</section>
<!-- Results Section -->
<section class="results-section">
<div class="panel-left">
<h2>📁 Файлы</h2>
<div class="file-tree" id="fileTree">
<p class="empty-state">Нет обработанных файлов</p>
</div>
</div>
<div class="panel-right">
<h2>📝 Просмотр</h2>
<div class="viewer" id="viewer">
<div class="empty-viewer">
<p>Выберите файл для просмотра</p>
</div>
</div>
</div>
</section>
</main>
</div>
<!-- Toast notifications -->
<div class="toast-container" id="toastContainer"></div>
<script src="/static/app.js"></script>
</body>
</html>

467
backend/static/styles.css Normal file
View File

@ -0,0 +1,467 @@
/* ===== Base ===== */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--bg-primary: #0f0f1a;
--bg-secondary: #1a1a2e;
--bg-tertiary: #16213e;
--bg-hover: #1e2a4a;
--text-primary: #e0e0e0;
--text-secondary: #a0a0b0;
--accent: #4a9eff;
--accent-hover: #6ab2ff;
--success: #4ade80;
--error: #f87171;
--warning: #fbbf24;
--border: #2a2a4a;
--radius: 8px;
--shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
line-height: 1.6;
min-height: 100vh;
}
.container {
max-width: 1400px;
margin: 0 auto;
padding: 20px;
}
/* ===== Header ===== */
header {
text-align: center;
padding: 30px 0;
border-bottom: 1px solid var(--border);
margin-bottom: 30px;
}
header h1 {
font-size: 2rem;
font-weight: 600;
margin-bottom: 8px;
}
.subtitle {
color: var(--text-secondary);
font-size: 1rem;
}
/* ===== Upload Section ===== */
.upload-section {
margin-bottom: 30px;
}
.drop-zone {
background: var(--bg-secondary);
border: 2px dashed var(--border);
border-radius: var(--radius);
padding: 40px;
text-align: center;
cursor: pointer;
transition: all 0.3s ease;
}
.drop-zone:hover {
border-color: var(--accent);
background: var(--bg-tertiary);
}
.drop-zone.drag-over {
border-color: var(--accent);
background: var(--bg-hover);
transform: scale(1.02);
}
.upload-icon {
width: 48px;
height: 48px;
color: var(--accent);
margin-bottom: 12px;
}
.browse-link {
color: var(--accent);
text-decoration: underline;
cursor: pointer;
}
.hint {
color: var(--text-secondary);
font-size: 0.85rem;
margin-top: 8px;
}
/* ===== Queue Status ===== */
.queue-status {
margin-top: 20px;
background: var(--bg-secondary);
border-radius: var(--radius);
padding: 20px;
}
.queue-status h3 {
margin-bottom: 15px;
font-size: 1.1rem;
color: var(--text-primary);
}
.tasks-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.empty-state {
color: var(--text-secondary);
text-align: center;
padding: 20px;
font-style: italic;
}
.task-item {
background: var(--bg-tertiary);
border-radius: var(--radius);
padding: 12px 16px;
border-left: 3px solid var(--accent);
transition: all 0.3s ease;
}
.task-item.success { border-left-color: var(--success); }
.task-item.error { border-left-color: var(--error); }
.task-item.processing { border-left-color: var(--warning); }
.task-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.task-filename {
font-weight: 500;
font-size: 0.9rem;
}
.task-status-badge {
font-size: 0.75rem;
padding: 2px 8px;
border-radius: 12px;
font-weight: 600;
}
.task-status-badge.queued { background: var(--bg-hover); color: var(--text-secondary); }
.task-status-badge.processing { background: rgba(251, 191, 36, 0.2); color: var(--warning); }
.task-status-badge.success { background: rgba(74, 222, 128, 0.2); color: var(--success); }
.task-status-badge.error { background: rgba(248, 113, 113, 0.2); color: var(--error); }
.task-progress-bar {
height: 4px;
background: var(--bg-hover);
border-radius: 2px;
overflow: hidden;
margin-bottom: 6px;
}
.task-progress-fill {
height: 100%;
background: var(--accent);
border-radius: 2px;
transition: width 0.5s ease;
}
.task-item.success .task-progress-fill { background: var(--success); }
.task-item.error .task-progress-fill { background: var(--error); }
.task-message {
font-size: 0.8rem;
color: var(--text-secondary);
}
/* ===== Results Section ===== */
.results-section {
display: grid;
grid-template-columns: 350px 1fr;
gap: 20px;
min-height: 500px;
}
.panel-left, .panel-right {
background: var(--bg-secondary);
border-radius: var(--radius);
padding: 20px;
overflow: hidden;
}
.panel-left h2, .panel-right h2 {
font-size: 1.2rem;
margin-bottom: 15px;
padding-bottom: 10px;
border-bottom: 1px solid var(--border);
}
/* ===== File Tree ===== */
.file-tree {
overflow-y: auto;
max-height: calc(100vh - 250px);
}
.folder-item {
margin-bottom: 8px;
}
.folder-header {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
cursor: pointer;
border-radius: var(--radius);
transition: background 0.2s;
}
.folder-header:hover {
background: var(--bg-hover);
}
.folder-toggle {
font-size: 0.7rem;
color: var(--text-secondary);
width: 16px;
text-align: center;
}
.folder-icon {
font-size: 1.1rem;
}
.folder-name {
flex: 1;
font-weight: 500;
font-size: 0.9rem;
}
.folder-date {
font-size: 0.7rem;
color: var(--text-secondary);
}
.folder-files {
margin-left: 24px;
padding-left: 8px;
border-left: 1px solid var(--border);
}
.file-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
border-radius: var(--radius);
transition: background 0.2s;
cursor: default;
}
.file-item.clickable {
cursor: pointer;
}
.file-item.clickable:hover {
background: var(--bg-hover);
}
.file-icon {
font-size: 1rem;
}
.file-name {
flex: 1;
font-size: 0.85rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.file-size {
font-size: 0.7rem;
color: var(--text-secondary);
}
/* ===== Viewer ===== */
.viewer {
overflow-y: auto;
max-height: calc(100vh - 250px);
}
.empty-viewer {
display: flex;
align-items: center;
justify-content: center;
height: 300px;
color: var(--text-secondary);
}
.md-content {
background: var(--bg-tertiary);
border-radius: var(--radius);
overflow: hidden;
}
.md-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
background: var(--bg-hover);
border-bottom: 1px solid var(--border);
}
.md-title {
font-size: 0.85rem;
color: var(--text-secondary);
font-family: monospace;
}
.btn-download {
background: var(--accent);
color: white;
text-decoration: none;
padding: 6px 12px;
border-radius: var(--radius);
font-size: 0.8rem;
transition: background 0.2s;
}
.btn-download:hover {
background: var(--accent-hover);
}
.md-body {
padding: 20px;
line-height: 1.8;
}
.md-body h1 {
font-size: 1.5rem;
margin-bottom: 16px;
color: var(--text-primary);
}
.md-body h2, .md-body h3 {
margin-top: 20px;
margin-bottom: 10px;
}
.md-body p {
margin-bottom: 12px;
}
.md-body strong {
color: var(--accent);
}
.md-body blockquote {
border-left: 3px solid var(--accent);
padding-left: 16px;
margin: 16px 0;
color: var(--text-secondary);
}
.md-body code {
background: var(--bg-hover);
padding: 2px 6px;
border-radius: 4px;
font-family: monospace;
font-size: 0.9em;
}
.md-body pre {
background: var(--bg-hover);
padding: 16px;
border-radius: var(--radius);
overflow-x: auto;
margin: 16px 0;
}
.md-body ul, .md-body ol {
margin-left: 20px;
margin-bottom: 12px;
}
.file-preview {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 300px;
gap: 20px;
}
.error {
color: var(--error);
padding: 20px;
text-align: center;
}
/* ===== Toast ===== */
.toast-container {
position: fixed;
top: 20px;
right: 20px;
z-index: 1000;
display: flex;
flex-direction: column;
gap: 10px;
}
.toast {
padding: 12px 20px;
border-radius: var(--radius);
color: white;
font-size: 0.9rem;
animation: slideIn 0.3s ease;
max-width: 300px;
word-wrap: break-word;
box-shadow: var(--shadow);
}
.toast-info { background: var(--accent); }
.toast-success { background: var(--success); }
.toast-error { background: var(--error); }
.toast-hide {
animation: slideOut 0.3s ease forwards;
}
@keyframes slideIn {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
@keyframes slideOut {
from { transform: translateX(0); opacity: 1; }
to { transform: translateX(100%); opacity: 0; }
}
/* ===== Responsive ===== */
@media (max-width: 768px) {
.results-section {
grid-template-columns: 1fr;
}
header h1 {
font-size: 1.5rem;
}
.drop-zone {
padding: 20px;
}
}

28
start_server.py Normal file
View File

@ -0,0 +1,28 @@
"""Скрипт для запуска веб-сервиса транскрибации."""
import sys
from pathlib import Path
# Добавляем родительскую директорию в путь
sys.path.insert(0, str(Path(__file__).parent))
import uvicorn
from backend.main import app
from backend.queue import start_workers
if __name__ == "__main__":
print("🚀 Запуск сервиса транскрибации...")
print("📡 Сервер: http://localhost:8000")
print("")
# Запускаем фоновых рабочих
start_workers(num_workers=1)
# Запускаем сервер
uvicorn.run(
"backend.main:app",
host="0.0.0.0",
port=8000,
reload=False,
log_level="info",
)