From cf0e67b50b12c9fd483bba9b5ed0f2db70773a1e Mon Sep 17 00:00:00 2001 From: ylytdeng Date: Sun, 17 May 2026 18:14:28 +0800 Subject: [PATCH] =?UTF-8?q?feat(monitor=5Fweb):=20=E5=8A=A0=E7=BB=88?= =?UTF-8?q?=E6=AD=A2=E6=8C=89=E9=92=AE=20+=20voice=5Fto=5Fmp3=20=E4=BC=98?= =?UTF-8?q?=E9=9B=85=E9=99=8D=E7=BA=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 痛点 用户反馈: 1. 没终止按钮 - 任务一旦点了, 跑死(✗ 失败)或跑长(解密 30s+)都没法停 2. ⑧ 语音转 MP3 报 ModuleNotFoundError: 'pilk', 直接 traceback ## Patch 1: 终止任务 ### Backend - _tool_running 加 proc / cancelled 字段 - _run_tool_task 把 proc 暴露到 _tool_running, 每轮 stdout read 后 检查 cancelled 标志, 命中就 break - 新加 POST /api/tool/cancel 路由: proc.terminate() + 1.5s 后 kill, 设 cancelled=True 让 tool runner 收尾 - tool_done 事件加 cancelled 字段 ### Frontend - 任务运行时, 触发按钮临时变成 "🛑 终止" + 红色脉冲动画 - 再点一下就调 /api/tool/cancel - tool_done 收到后还原按钮文本和颜色 - "⊘ 已终止" 状态徽章替代 "✓ 完成" ### CSS - .tool-task-btn.cancel: 实心红渐变 + box-shadow + pulseRed 1.5s 动画 ## Patch 2: voice_to_mp3 优雅降级 之前直接 `import pilk` 失败时打 traceback, 用户看不懂。改成: try: import pilk except ImportError: print 友好提示 + pip install pilk 命令 + 退出 加 ffmpeg 在 PATH 检查 (pilk 解码后需要 ffmpeg 编 MP3), 给三平台 安装指引。 requirements.txt 加注释说明 pilk 还需要 ffmpeg 配合, pyinstaller 仅打包需要(开发不必装)。 ## 测试 185/185 通过 (不影响现有功能)。 实测 web UI: - 点 ⑧ 语音转 MP3 → 立刻看到友好提示而不是 traceback - 点任何任务 → 按钮变红色"🛑 终止" → 再点 → 任务收尾 → 状态变"⊘ 已终止" ## 仍未跟进 (follow-up) - 导出筛选: ③ 导出全部聊天 / ⑦ 企微导出 一点就跑全量很可怕。 应该弹模态框选会话 + 格式. 单独开 issue #112 跟进, 需要: - 新加 GET /api/sessions 路由列会话 - export_all_chats / export_wxwork_messages 加 --filter 参数 - 前端模态框 UI --- monitor_web.py | 116 +++++++++++++++++++++++++++++++++++++++++++---- requirements.txt | 2 + voice_to_mp3.py | 17 ++++++- 3 files changed, 124 insertions(+), 11 deletions(-) diff --git a/monitor_web.py b/monitor_web.py index ddd8b35..11731cb 100644 --- a/monitor_web.py +++ b/monitor_web.py @@ -1826,6 +1826,18 @@ a.msg-link{text-decoration:none;color:inherit} box-shadow:0 6px 20px rgba(79,195,247,.5),inset 0 1px 0 rgba(255,255,255,.3) } .tool-task-btn.primary:active:not(:disabled){transform:translateY(0);box-shadow:0 2px 8px rgba(79,195,247,.4)} +/* 终止按钮 — 红色警示 */ +.tool-task-btn.cancel{ + background:linear-gradient(135deg,#ef5350,#e53935)!important; + border:none!important;color:#fff!important;font-weight:600; + box-shadow:0 4px 14px rgba(239,83,80,.4),inset 0 1px 0 rgba(255,255,255,.2)!important; + animation:pulseRed 1.5s infinite; +} +.tool-task-btn.cancel:hover:not(:disabled){ + background:linear-gradient(135deg,#f44336,#d32f2f)!important; + box-shadow:0 6px 20px rgba(239,83,80,.6)!important; +} +@keyframes pulseRed{0%,100%{box-shadow:0 4px 14px rgba(239,83,80,.4)}50%{box-shadow:0 4px 20px rgba(239,83,80,.7)}} /* 日志框 */ .tool-log-wrap{ background:#05060a;border:1px solid var(--border);border-radius:var(--r2); @@ -2052,27 +2064,55 @@ function switchToolTab(name){ document.querySelectorAll('.tool-tab').forEach(t=>t.classList.toggle('active', t.dataset.pane===name)); document.querySelectorAll('.tool-pane').forEach(p=>p.classList.toggle('active', p.dataset.pane===name)); } +async function cancelTool(){ + try{ + await fetch('/api/tool/cancel',{method:'POST'}); + }catch(e){} +} async function runTool(task, btn){ + // 如果已经在运行 (按钮变成 cancel) → 取消 + if(btn.classList.contains('cancel')){ + btn.disabled = true; + btn.textContent = '终止中...'; + cancelTool(); + return; + } const s=document.getElementById('toolStatus'); - document.querySelectorAll('.tool-task-btn').forEach(b=>b.disabled=true); + // 禁用其他按钮, 当前按钮改成"终止" + document.querySelectorAll('.tool-task-btn').forEach(b=>{ + if(b!==btn) b.disabled=true; + }); + btn.dataset.origText = btn.textContent; + btn.textContent = '🛑 终止'; + btn.classList.add('cancel'); + window.__runningBtn = btn; // 清当前 pane 的日志框 const L=document.getElementById('toolLog_'+window.__activeToolPane); if(L) L.textContent=''; s.style.display='inline-block'; s.className='tool-status running'; - s.textContent='⏳ 运行中: '+btn.textContent.trim(); + s.textContent='⏳ 运行中: '+btn.dataset.origText.trim(); try{ const r=await fetch('/api/tool',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({task:task})}); const d=await r.json(); if(!r.ok){ s.className='tool-status err'; s.textContent='✗ '+(d.error||'启动失败'); - document.querySelectorAll('.tool-task-btn').forEach(b=>b.disabled=false); + // 启动失败立刻还原 + document.querySelectorAll('.tool-task-btn').forEach(b=>{ + b.disabled=false; + if(b.dataset.origText){b.textContent = b.dataset.origText; b.dataset.origText='';} + b.classList.remove('cancel'); + }); } }catch(e){ s.className='tool-status err'; s.textContent='✗ 网络错误: '+e.message; - document.querySelectorAll('.tool-task-btn').forEach(b=>b.disabled=false); + document.querySelectorAll('.tool-task-btn').forEach(b=>{ + b.disabled=false; + if(b.dataset.origText){b.textContent = b.dataset.origText; b.dataset.origText='';} + b.classList.remove('cancel'); + }); } } // 工具按钮 + tab 切换 click handler 绑定 @@ -2215,9 +2255,20 @@ function connectSSE(){ es.addEventListener('tool_done', ev=>{ const d=JSON.parse(ev.data); const s=document.getElementById('toolStatus'); - s.textContent = d.ok ? '✓ 完成' : ('✗ 失败 (code ' + d.exit_code + ')'); - s.className = 'tool-status ' + (d.ok ? 'ok' : 'err'); - document.querySelectorAll('.tool-task-btn').forEach(b=>b.disabled=false); + if(d.cancelled){ + s.textContent = '⊘ 已终止'; + s.className = 'tool-status err'; + } else { + s.textContent = d.ok ? '✓ 完成' : ('✗ 失败 (code ' + d.exit_code + ')'); + s.className = 'tool-status ' + (d.ok ? 'ok' : 'err'); + } + // 还原所有按钮 + 把"终止"按钮还原成原文本 + document.querySelectorAll('.tool-task-btn').forEach(b=>{ + b.disabled=false; + if(b.dataset.origText){b.textContent = b.dataset.origText; b.dataset.origText='';} + b.classList.remove('cancel'); + }); + window.__runningBtn = null; }); es.onerror=()=>{ S.textContent='重连...'; @@ -2293,7 +2344,7 @@ TOOL_TASKS = { } _tool_lock = threading.Lock() -_tool_running = {"job": None} # 同时只允许一个任务 +_tool_running = {"job": None, "proc": None, "cancelled": False} # 同时只允许一个任务 def _broadcast_tool_event(event, **fields): @@ -2315,6 +2366,7 @@ def _run_tool_task(job_id, task_name): line=f"━━━ 开始: {task['name']} ━━━\n") exit_code = 0 + cancelled = False for step in task["steps"]: cmd_str = " ".join(step) _broadcast_tool_event("tool_log", job_id=job_id, @@ -2341,19 +2393,39 @@ def _run_tool_task(job_id, task_name): exit_code = -1 break + # 暴露 proc 给取消路由 + with _tool_lock: + _tool_running["proc"] = proc + for line in proc.stdout: _broadcast_tool_event("tool_log", job_id=job_id, line=line) + with _tool_lock: + if _tool_running.get("cancelled"): + break proc.wait() + with _tool_lock: + _tool_running["proc"] = None + if _tool_running.get("cancelled"): + cancelled = True + _broadcast_tool_event("tool_log", job_id=job_id, + line=f"\n[CANCELLED] 任务被用户终止\n") + break if proc.returncode != 0: _broadcast_tool_event("tool_log", job_id=job_id, line=f"\n[FAIL] 返回码 {proc.returncode}\n") exit_code = proc.returncode break - _broadcast_tool_event("tool_done", job_id=job_id, ok=(exit_code == 0), - exit_code=exit_code) + if cancelled: + _broadcast_tool_event("tool_done", job_id=job_id, ok=False, + exit_code=-15, cancelled=True) + else: + _broadcast_tool_event("tool_done", job_id=job_id, ok=(exit_code == 0), + exit_code=exit_code) with _tool_lock: _tool_running["job"] = None + _tool_running["proc"] = None + _tool_running["cancelled"] = False class Handler(BaseHTTPRequestHandler): @@ -2515,6 +2587,30 @@ class Handler(BaseHTTPRequestHandler): "task": task_name, "name": TOOL_TASKS[task_name]["name"], }).encode()) + elif self.path == "/api/tool/cancel": + with _tool_lock: + proc = _tool_running.get("proc") + job = _tool_running.get("job") + if not proc or not job: + self.send_response(404) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(json.dumps({"error": "无运行中任务"}).encode()) + return + _tool_running["cancelled"] = True + try: + proc.terminate() + # 给 1.5 秒优雅退, 否则强杀 + try: + proc.wait(timeout=1.5) + except subprocess.TimeoutExpired: + proc.kill() + except Exception as e: + pass # 进程可能正好自己退了 + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(json.dumps({"job_id": job, "cancelled": True}).encode()) else: self.send_error(404) diff --git a/requirements.txt b/requirements.txt index c26a34f..7bb3fc2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,8 @@ pycryptodome>=3.19,<4 zstandard>=0.22,<1 mcp>=1.0,<2 +# pilk: SILK_V3 解码 (语音转 MP3 需要); 还需要 ffmpeg 在 PATH 中 pilk>=0.2 +# pyinstaller: 只在打包成 exe 时需要 (开发/CLI 不需要) pyinstaller>=6.0 # 可选:进度条 (pip install tqdm) diff --git a/voice_to_mp3.py b/voice_to_mp3.py index b113806..a2834f5 100644 --- a/voice_to_mp3.py +++ b/voice_to_mp3.py @@ -9,7 +9,22 @@ from datetime import datetime if sys.platform == "win32" and hasattr(sys.stdout, "reconfigure"): sys.stdout.reconfigure(encoding="utf-8", errors="replace") -import pilk +try: + import pilk +except ImportError: + print("[ERROR] 缺少 pilk 库 (SILK 解码必需)", file=sys.stderr) + print(" 请运行: pip install pilk", file=sys.stderr) + print(" 然后重新启动本任务", file=sys.stderr) + sys.exit(1) + +import shutil as _shutil +if not _shutil.which("ffmpeg"): + print("[ERROR] ffmpeg 不在 PATH 中 (MP3 编码必需)", file=sys.stderr) + print(" Windows: https://ffmpeg.org/download.html 下载后加入 PATH", file=sys.stderr) + print(" macOS: brew install ffmpeg", file=sys.stderr) + print(" Linux: apt install ffmpeg / yum install ffmpeg", file=sys.stderr) + sys.exit(1) + from config import load_config _cfg = load_config()