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