feat(monitor_web): 加终止按钮 + voice_to_mp3 优雅降级

## 痛点

用户反馈:
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
This commit is contained in:
ylytdeng
2026-05-17 18:14:28 +08:00
parent fb260063ef
commit cf0e67b50b
3 changed files with 124 additions and 11 deletions

View File

@@ -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) 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.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{ .tool-log-wrap{
background:#05060a;border:1px solid var(--border);border-radius:var(--r2); 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-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)); 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){ async function runTool(task, btn){
// 如果已经在运行 (按钮变成 cancel) → 取消
if(btn.classList.contains('cancel')){
btn.disabled = true;
btn.textContent = '终止中...';
cancelTool();
return;
}
const s=document.getElementById('toolStatus'); 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 的日志框 // 清当前 pane 的日志框
const L=document.getElementById('toolLog_'+window.__activeToolPane); const L=document.getElementById('toolLog_'+window.__activeToolPane);
if(L) L.textContent=''; if(L) L.textContent='';
s.style.display='inline-block'; s.style.display='inline-block';
s.className='tool-status running'; s.className='tool-status running';
s.textContent='⏳ 运行中: '+btn.textContent.trim(); s.textContent='⏳ 运行中: '+btn.dataset.origText.trim();
try{ try{
const r=await fetch('/api/tool',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({task:task})}); const r=await fetch('/api/tool',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({task:task})});
const d=await r.json(); const d=await r.json();
if(!r.ok){ if(!r.ok){
s.className='tool-status err'; s.className='tool-status err';
s.textContent=''+(d.error||'启动失败'); 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){ }catch(e){
s.className='tool-status err'; s.className='tool-status err';
s.textContent='✗ 网络错误: '+e.message; 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 绑定 // 工具按钮 + tab 切换 click handler 绑定
@@ -2215,9 +2255,20 @@ function connectSSE(){
es.addEventListener('tool_done', ev=>{ es.addEventListener('tool_done', ev=>{
const d=JSON.parse(ev.data); const d=JSON.parse(ev.data);
const s=document.getElementById('toolStatus'); const s=document.getElementById('toolStatus');
if(d.cancelled){
s.textContent = '⊘ 已终止';
s.className = 'tool-status err';
} else {
s.textContent = d.ok ? '✓ 完成' : ('✗ 失败 (code ' + d.exit_code + ')'); s.textContent = d.ok ? '✓ 完成' : ('✗ 失败 (code ' + d.exit_code + ')');
s.className = 'tool-status ' + (d.ok ? 'ok' : 'err'); s.className = 'tool-status ' + (d.ok ? 'ok' : 'err');
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');
});
window.__runningBtn = null;
}); });
es.onerror=()=>{ es.onerror=()=>{
S.textContent='重连...'; S.textContent='重连...';
@@ -2293,7 +2344,7 @@ TOOL_TASKS = {
} }
_tool_lock = threading.Lock() _tool_lock = threading.Lock()
_tool_running = {"job": None} # 同时只允许一个任务 _tool_running = {"job": None, "proc": None, "cancelled": False} # 同时只允许一个任务
def _broadcast_tool_event(event, **fields): def _broadcast_tool_event(event, **fields):
@@ -2315,6 +2366,7 @@ def _run_tool_task(job_id, task_name):
line=f"━━━ 开始: {task['name']} ━━━\n") line=f"━━━ 开始: {task['name']} ━━━\n")
exit_code = 0 exit_code = 0
cancelled = False
for step in task["steps"]: for step in task["steps"]:
cmd_str = " ".join(step) cmd_str = " ".join(step)
_broadcast_tool_event("tool_log", job_id=job_id, _broadcast_tool_event("tool_log", job_id=job_id,
@@ -2341,19 +2393,39 @@ def _run_tool_task(job_id, task_name):
exit_code = -1 exit_code = -1
break break
# 暴露 proc 给取消路由
with _tool_lock:
_tool_running["proc"] = proc
for line in proc.stdout: for line in proc.stdout:
_broadcast_tool_event("tool_log", job_id=job_id, line=line) _broadcast_tool_event("tool_log", job_id=job_id, line=line)
with _tool_lock:
if _tool_running.get("cancelled"):
break
proc.wait() 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: if proc.returncode != 0:
_broadcast_tool_event("tool_log", job_id=job_id, _broadcast_tool_event("tool_log", job_id=job_id,
line=f"\n[FAIL] 返回码 {proc.returncode}\n") line=f"\n[FAIL] 返回码 {proc.returncode}\n")
exit_code = proc.returncode exit_code = proc.returncode
break break
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), _broadcast_tool_event("tool_done", job_id=job_id, ok=(exit_code == 0),
exit_code=exit_code) exit_code=exit_code)
with _tool_lock: with _tool_lock:
_tool_running["job"] = None _tool_running["job"] = None
_tool_running["proc"] = None
_tool_running["cancelled"] = False
class Handler(BaseHTTPRequestHandler): class Handler(BaseHTTPRequestHandler):
@@ -2515,6 +2587,30 @@ class Handler(BaseHTTPRequestHandler):
"task": task_name, "task": task_name,
"name": TOOL_TASKS[task_name]["name"], "name": TOOL_TASKS[task_name]["name"],
}).encode()) }).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: else:
self.send_error(404) self.send_error(404)

View File

@@ -1,6 +1,8 @@
pycryptodome>=3.19,<4 pycryptodome>=3.19,<4
zstandard>=0.22,<1 zstandard>=0.22,<1
mcp>=1.0,<2 mcp>=1.0,<2
# pilk: SILK_V3 解码 (语音转 MP3 需要); 还需要 ffmpeg 在 PATH 中
pilk>=0.2 pilk>=0.2
# pyinstaller: 只在打包成 exe 时需要 (开发/CLI 不需要)
pyinstaller>=6.0 pyinstaller>=6.0
# 可选:进度条 (pip install tqdm) # 可选:进度条 (pip install tqdm)

View File

@@ -9,7 +9,22 @@ from datetime import datetime
if sys.platform == "win32" and hasattr(sys.stdout, "reconfigure"): if sys.platform == "win32" and hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8", errors="replace") sys.stdout.reconfigure(encoding="utf-8", errors="replace")
try:
import pilk 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 from config import load_config
_cfg = load_config() _cfg = load_config()