feat: 调度器+并发管线+GPU优化+日志分级+前端修复
- scheduler: ffmpeg 异步线程 + GPU 串行调度 + 模型复用(2N→2 次加载) - pipeline: 阶段拆分(extract/asr/translate),中间数据存 Task 字段 - translate_service: 长度排序批处理,padding 浪费减少 91% - model_manager: ASR/翻译不共驻,BatchedInferencePipeline 批量解码 - 日志分级: INFO=任务流转里程碑,DEBUG=进度详情;默认 INFO - 前端: 日志最新在上+滚动感知+退避轮询;24h 时间;上传中状态显示 - /health: 返回完整 Whisper/NLLB 配置 - upload_service: 单事务 complete + 扩展名白名单 - task_router: 合并 UploadSession 虚拟任务到列表 - Dockerfile: CPU/GPU 独立构建链,deps 缓存稳定 - prefetch_models: 安装时预下载模型权重
This commit is contained in:
@@ -42,12 +42,13 @@ function renderTasks(tasks) {{
|
||||
for (const task of tasks) {{
|
||||
tasksEl.appendChild(makeTaskCard(task));
|
||||
}}
|
||||
// 对进行中的任务启动轮询
|
||||
// 对进行中的任务启动轮询(上传会话用 upload_id 去重,Task 用 id 去重)
|
||||
for (const task of tasks) {{
|
||||
if (ACTIVE_STATES.includes(task.status) && !pollingIds.has(task.id)) {{
|
||||
pollingIds.add(task.id);
|
||||
pollTask(task.id);
|
||||
}}
|
||||
if (!ACTIVE_STATES.includes(task.status)) continue;
|
||||
const pollKey = task.is_upload ? task.upload_id : String(task.id);
|
||||
if (pollingIds.has(pollKey)) continue;
|
||||
pollingIds.add(pollKey);
|
||||
pollTask(task);
|
||||
}}
|
||||
}}
|
||||
|
||||
@@ -61,6 +62,7 @@ function makeTaskCard(task) {{
|
||||
const el = document.createElement("div");
|
||||
el.className = "card task server-task";
|
||||
el.dataset.taskId = task.id;
|
||||
if (task.is_upload) el.dataset.uploadId = task.upload_id;
|
||||
el.innerHTML = renderTaskInner(task);
|
||||
return el;
|
||||
}}
|
||||
@@ -69,7 +71,11 @@ function renderTaskInner(task) {{
|
||||
const label = STATUS_LABEL[task.status] || task.status;
|
||||
const stateClass = task.status === "done" ? "state-done"
|
||||
: task.status === "failed" ? "state-fail" : "state-running";
|
||||
const created = new Date(task.created_at).toLocaleString();
|
||||
|
||||
// 上传中:created_at 可能是 naive UTC,前端按 UTC 解析
|
||||
let created;
|
||||
try {{ created = fmtDateTime24(new Date(task.created_at + "Z")); }}
|
||||
catch (e) {{ created = task.created_at; }}
|
||||
|
||||
let body;
|
||||
if (task.status === "done") {{
|
||||
@@ -77,35 +83,72 @@ function renderTaskInner(task) {{
|
||||
}} else if (task.status === "failed") {{
|
||||
body = `<div class="task-meta fail-msg">${{escapeHtml(task.error || "未知错误")}}</div>`;
|
||||
}} else {{
|
||||
body = `<div class="bar"><div class="fill" style="width:${{task.progress}}%"></div><span class="pct">${{task.progress.toFixed(0)}}%</span></div>`;
|
||||
const pct = (task.progress == null) ? 0 : task.progress;
|
||||
const sizeInfo = task.size_bytes ? ` · ${{fmtBytes(task.size_bytes)}}` : "";
|
||||
body = `<div class="bar"><div class="fill" style="width:${{pct}}%"></div><span class="pct">${{pct.toFixed(0)}}%${{sizeInfo}}</span></div>`;
|
||||
}}
|
||||
return `
|
||||
<div class="task-head">
|
||||
<span class="fname">#${{task.id}} ${{escapeHtml(task.filename)}}</span>
|
||||
<span class="fname">${{task.is_upload ? "" : "#" + task.id + " "}}${{escapeHtml(task.filename)}}</span>
|
||||
<span class="fstate ${{stateClass}}">${{label}}</span>
|
||||
</div>
|
||||
${{body}}
|
||||
<div class="task-time">${{created}}</div>`;
|
||||
}}
|
||||
|
||||
async function pollTask(taskId) {{
|
||||
async function pollTask(task) {{
|
||||
// 上传会话:轮询 upload status 接口;Task:轮询 task 接口
|
||||
const isUpload = task.is_upload === true && task.upload_id;
|
||||
const url = isUpload
|
||||
? "/api/tasks/chunk-uploads/" + task.upload_id + "/status"
|
||||
: "/api/tasks/" + task.id;
|
||||
let backoff = POLL_INTERVAL;
|
||||
const tick = async () => {{
|
||||
try {{
|
||||
const r = await fetch("/api/tasks/" + taskId);
|
||||
if (!r.ok) {{ pollingIds.delete(taskId); return; }}
|
||||
const task = await r.json();
|
||||
const card = tasksEl.querySelector('.server-task[data-task-id="' + taskId + '"]');
|
||||
if (!card) {{ pollingIds.delete(taskId); return; }}
|
||||
const r = await fetch(url);
|
||||
if (!r.ok) {{ pollingIds.delete(task.id); pollingIds.delete(task.upload_id); return; }}
|
||||
const data = await r.json();
|
||||
|
||||
card.innerHTML = renderTaskInner(task);
|
||||
|
||||
if (task.status === "done" || task.status === "failed") {{
|
||||
pollingIds.delete(taskId);
|
||||
return;
|
||||
if (isUpload) {{
|
||||
// 上传会话:complete 后 task_id 出现,切换为 Task 轮询
|
||||
if (data.completed && data.task_id) {{
|
||||
pollingIds.delete(task.upload_id);
|
||||
pollingIds.add(data.task_id);
|
||||
pollTask({{ id: data.task_id, is_upload: false }});
|
||||
// 刷新列表让新 Task 卡片出现
|
||||
refreshList();
|
||||
return;
|
||||
}}
|
||||
// 更新上传进度
|
||||
const card = tasksEl.querySelector('.server-task[data-upload-id="' + task.upload_id + '"]');
|
||||
if (!card) {{ pollingIds.delete(task.upload_id); return; }}
|
||||
const uploaded = (data.uploaded_chunks || []).length;
|
||||
const total = data.total_chunks || 1;
|
||||
const pct = Math.min(4, uploaded / total * 4);
|
||||
const fakeTask = {{
|
||||
status: "uploading", progress: pct, is_upload: true,
|
||||
upload_id: task.upload_id, filename: data.filename,
|
||||
size_bytes: data.size_bytes, created_at: task.created_at,
|
||||
}};
|
||||
card.innerHTML = renderTaskInner(fakeTask);
|
||||
backoff = POLL_INTERVAL;
|
||||
setTimeout(tick, backoff);
|
||||
}} else {{
|
||||
// Task 轮询
|
||||
const card = tasksEl.querySelector('.server-task[data-task-id="' + task.id + '"]');
|
||||
if (!card) {{ pollingIds.delete(task.id); return; }}
|
||||
card.innerHTML = renderTaskInner(data);
|
||||
if (data.status === "done" || data.status === "failed") {{
|
||||
pollingIds.delete(task.id);
|
||||
return;
|
||||
}}
|
||||
backoff = POLL_INTERVAL;
|
||||
setTimeout(tick, backoff);
|
||||
}}
|
||||
setTimeout(tick, POLL_INTERVAL);
|
||||
}} catch (e) {{
|
||||
setTimeout(tick, POLL_INTERVAL);
|
||||
// 网络错误:指数退避,上限 30s
|
||||
backoff = Math.min(backoff * 1.6, 30000);
|
||||
setTimeout(tick, backoff);
|
||||
}}
|
||||
}};
|
||||
tick();
|
||||
|
||||
Reference in New Issue
Block a user