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:
116
monitor_web.py
116
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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user