Add web service: FastAPI backend + minimal frontend with drag-drop, WebSocket progress, file tree and MD viewer
This commit is contained in:
parent
c771f83351
commit
beb411dfdc
4
.gitignore
vendored
4
.gitignore
vendored
@ -66,3 +66,7 @@ Thumbs.db
|
||||
# User data
|
||||
video/
|
||||
*.mp4
|
||||
|
||||
# Server logs
|
||||
server.log
|
||||
*.log
|
||||
|
||||
0
backend/__init__.py
Normal file
0
backend/__init__.py
Normal file
183
backend/main.py
Normal file
183
backend/main.py
Normal 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
227
backend/queue.py
Normal 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
362
backend/static/app.js
Normal 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
69
backend/static/index.html
Normal 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
467
backend/static/styles.css
Normal 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
28
start_server.py
Normal 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",
|
||||
)
|
||||
Loading…
Reference in New Issue
Block a user