feat(monitor_web): 导出筛选模态框 — 不再一点就跑全量 (closes #112)

## 痛点

之前 Web UI 工具箱里 "③ 导出聊天" / "⑦ 企业微信导出" 一点就跑全量。
用户 3142 个个人微信会话 + 14 个企微会话, 全量导出几个 GB JSON 几小时,
还没法只导某几个群。

## 设计

点击导出按钮 → 弹模态框选会话 + 格式 → 确认后才跑。

```
┌────────────────────────────────────────┐
│  导出个人微信聊天             [×]      │
├────────────────────────────────────────┤
│  🔍 按名字 / wxid 搜索...               │
│  ┌──────────────────────────────────┐ │
│  │ ☐ [群]   交易所消息    2026-05-14│ │
│  │ ☐ [单聊] 张三          2026-05-13│ │
│  │ ☐ [公众号] xxx日报     2026-05-10│ │
│  │ ...                              │ │
│  └──────────────────────────────────┘ │
│  [全选] [清空] [选最近30天活跃]  已选 N│
│                                        │
│  格式 (仅企微) ☑CSV ☐HTML ☐JSON         │
│                                        │
│         [取消]  [确认导出 →]            │
└────────────────────────────────────────┘
```

## 实现拆解

### Backend
1. **GET /api/sessions?source=wechat|wxwork** — 列会话
   - wechat: 从 decrypted/session/session.db SessionTable 读, 拼合
     contact.db 的 nick_name/remark, type 分群/单聊/公众号
   - wxwork: 从 wxwork_decrypted/session.db conversation_table 读,
     id 前缀分 R/S/E/Y → 群/单聊/外部/其他
   - 按 last_ts 降序

2. **TOOL_TASKS schema 加 build_steps + needs_modal**
   - 旧固定任务: `steps: [cmd, ...]`
   - 新动态任务: `build_steps: fn(users, formats) → [cmd, ...]`
   - export_all / wxwork_export 用 build_steps + needs_modal 标记

3. **_run_tool_task 接收 args**, 优先调 build_steps 生成 cmd

4. **POST /api/tool body 接收 args**: `{task, args: {users:[...], formats:[...]}}`

### CLI 脚本
- `export_all_chats.py` 加 `--users wxid1,wxid2` (alt: env WECHAT_EXPORT_USERS),
  在加载完 sessions 后做白名单过滤, 空集报错退出
- `export_wxwork_messages.py` 原本就支持 `--conversation` (multi-arg) +
  `--formats csv,html,json`, 不动它, monitor_web 拼 argv 即可

### Frontend
- HTML 加 `#exportModal` 模态框骨架 (overlay + dialog + search + list +
  format checkboxes + footer buttons)
- CSS .modal-* 一套 (用 design tokens, 跟整体暗色风格统一)
- JS:
  - NEEDS_MODAL = {export_all: 'export_wechat', wxwork_export: 'export_wxwork'}
  - runTool 拦截这两个 task → openExportModal
  - openExportModal fetch /api/sessions → renderSessions (复选框列表)
  - filterSessions 实时搜索过滤
  - selectAllSessions / selectRecentSessions(30) 批量选择
  - confirmExport 收集 selected usernames + formats → runToolWithArgs
- runTool 拆成 runTool (entry, 拦截/取消) + runToolWithArgs (实际跑)

## 实测验证

```
$ curl -s "http://localhost:5678/api/sessions?source=wxwork"
[{"username": "R:358645240322", "name": "交易所消息", "type": "群", "last_ts": 1778658597, ...},
 {"username": "E:10223", "name": "E:10223", "type": "外部", ...}, ...]

$ curl -s "http://localhost:5678/api/sessions?source=wechat"
[{"username": "46222992238@chatroom", "name": "月下健人", "type": "群", "last_ts": 1779014016, ...}, ...]
```

API 返回正确, 个人微信 3142 个 / 企微 14 个会话, 按 last_ts 降序。
测试 185/185 通过。

## 副带改进

- runToolWithArgs 拆出来后, fix 之前一个小 bug: error 路径里的
  `b.textContent = b.dataset.origText` 改成 innerHTML 路径 (一致性)

## 仍未跟进 (follow-up)

- export_all_chats 加 `--format csv,html` 多格式输出 (个人微信脚本
  目前只支持 JSON, 模态框格式选项对它隐藏了, 看 #109 推进)
- 个人微信导出 sessions 列表性能: 3142 个一次 fetch ~5MB JSON,
  搜索 + render 在低端机可能卡, 可加分页 / 虚拟列表
This commit is contained in:
ylytdeng
2026-05-17 18:35:44 +08:00
parent a1e79f764d
commit b93e85a1a0
2 changed files with 324 additions and 13 deletions

View File

@@ -299,6 +299,12 @@ def main():
action="store_true", action="store_true",
help="预览模式:显示将导出的会话数和新消息数,不实际写入", help="预览模式:显示将导出的会话数和新消息数,不实际写入",
) )
parser.add_argument(
"--users",
default=None,
help="只导出指定 username 的会话, 逗号分隔 (如 wxid_xxx,12345@chatroom). "
"为空时导出全部 (旧行为). 也可用 env WECHAT_EXPORT_USERS",
)
args = parser.parse_args() args = parser.parse_args()
script_dir = os.path.dirname(os.path.abspath(__file__)) script_dir = os.path.dirname(os.path.abspath(__file__))
@@ -338,6 +344,18 @@ def main():
print(f"会话数据库查询失败: {e}", file=sys.stderr) print(f"会话数据库查询失败: {e}", file=sys.stderr)
sys.exit(1) sys.exit(1)
# username 白名单过滤 (--users 参数 / WECHAT_EXPORT_USERS 环境变量)
users_filter_raw = args.users or os.environ.get("WECHAT_EXPORT_USERS", "")
if users_filter_raw.strip():
wanted = {u.strip() for u in users_filter_raw.split(",") if u.strip()}
before = len(sessions)
sessions = [u for u in sessions if u in wanted]
print(f"按 --users 过滤: {before}{len(sessions)} 会话")
if not sessions:
print(f"[!] 指定的 username 列表跟会话表没交集 (wanted={list(wanted)[:5]}...)",
file=sys.stderr)
sys.exit(1)
names = mcp_server.get_contact_names() names = mcp_server.get_contact_names()
# 显示模式信息 # 显示模式信息

View File

@@ -1782,6 +1782,45 @@ a.msg-link{text-decoration:none;color:inherit}
.add-rule-btn:hover{background:rgba(79,195,247,.2)} .add-rule-btn:hover{background:rgba(79,195,247,.2)}
/* 通知高亮 */ /* 通知高亮 */
.msg.notify-hl{border-left:3px solid #ffd54f;background:rgba(255,213,79,.08);box-shadow:0 0 12px rgba(255,213,79,.1)} .msg.notify-hl{border-left:3px solid #ffd54f;background:rgba(255,213,79,.08);box-shadow:0 0 12px rgba(255,213,79,.1)}
/* 导出筛选模态框 */
.modal-overlay{display:none;position:fixed;inset:0;background:rgba(0,0,0,.65);backdrop-filter:blur(4px);z-index:1100;align-items:center;justify-content:center}
.modal-overlay.show{display:flex;animation:fadeIn .15s ease-out}
.modal{background:var(--bg-elev);border:1px solid var(--border);border-radius:var(--r3);width:560px;max-width:90vw;max-height:85vh;display:flex;flex-direction:column;box-shadow:var(--shadow-2);overflow:hidden}
.modal-h{padding:16px 20px;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between;flex-shrink:0}
.modal-h h2{font-size:var(--t4);color:var(--text);font-weight:600}
.modal-close{background:none;border:none;color:var(--text-faint);font-size:20px;cursor:pointer;padding:4px 10px;border-radius:var(--r1);transition:all .15s}
.modal-close:hover{color:var(--text);background:var(--surface)}
.modal-b{padding:16px 20px;overflow-y:auto;flex:1}
.modal-f{padding:14px 20px;border-top:1px solid var(--border);display:flex;justify-content:space-between;gap:8px;align-items:center;flex-shrink:0;background:rgba(0,0,0,.2)}
.modal-search{width:100%;background:var(--surface);border:1px solid var(--border);border-radius:var(--r1);padding:8px 12px;color:var(--text);font-size:var(--t3);outline:none;font-family:inherit;margin-bottom:var(--s3)}
.modal-search:focus{border-color:var(--accent)}
.modal-search::placeholder{color:var(--text-faint)}
.session-list{max-height:280px;overflow-y:auto;border:1px solid var(--border);border-radius:var(--r1);background:rgba(0,0,0,.2)}
.session-item{display:flex;align-items:center;gap:var(--s2);padding:8px 12px;cursor:pointer;border-bottom:1px solid rgba(255,255,255,.03);transition:background .1s;font-size:var(--t3)}
.session-item:hover{background:var(--surface-hover)}
.session-item:last-child{border-bottom:none}
.session-item input[type=checkbox]{accent-color:var(--accent);width:14px;height:14px;cursor:pointer;margin:0}
.session-type{font-size:10px;padding:2px 6px;border-radius:var(--r-pill);color:var(--text-dim);background:var(--surface);min-width:28px;text-align:center;flex-shrink:0}
.session-type.grp{background:rgba(206,147,216,.12);color:#ce93d8}
.session-type.single{background:rgba(79,195,247,.12);color:var(--accent)}
.session-name{flex:1;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.session-ts{font-size:var(--t1);color:var(--text-faint);flex-shrink:0;font-family:Consolas,monospace}
.modal-selctrl{display:flex;gap:var(--s2);margin:var(--s3) 0;flex-wrap:wrap}
.modal-selctrl button{background:var(--surface);border:1px solid var(--border);color:var(--text-dim);padding:5px 10px;border-radius:var(--r1);font-size:var(--t1);cursor:pointer;transition:all .15s;font-family:inherit}
.modal-selctrl button:hover{background:var(--surface-hover);color:var(--text)}
.modal-section{margin-top:var(--s4)}
.modal-section-label{font-size:var(--t1);color:var(--text-faint);margin-bottom:var(--s2);text-transform:uppercase;letter-spacing:1.2px;font-weight:600}
.modal-fmt{display:flex;gap:var(--s4);flex-wrap:wrap}
.modal-fmt label{display:inline-flex;align-items:center;gap:6px;cursor:pointer;font-size:var(--t3);color:var(--text)}
.modal-fmt input{accent-color:var(--accent);width:14px;height:14px;cursor:pointer}
.modal-selcount{color:var(--text-dim);font-size:var(--t2)}
.modal-btn{padding:8px 18px;border-radius:var(--r1);font-size:var(--t3);cursor:pointer;border:none;font-family:inherit;font-weight:500;transition:all .15s}
.modal-btn.secondary{background:var(--surface);color:var(--text-dim);border:1px solid var(--border)}
.modal-btn.secondary:hover{background:var(--surface-hover);color:var(--text)}
.modal-btn.primary{background:linear-gradient(135deg,#4fc3f7,#29b6f6);color:#001528;font-weight:600;box-shadow:0 4px 14px rgba(79,195,247,.35)}
.modal-btn.primary:hover:not(:disabled){background:linear-gradient(135deg,#5fd0ff,#3fc4ff);transform:translateY(-1px)}
.modal-btn:disabled{opacity:.4;cursor:not-allowed;transform:none!important}
.modal-loading{text-align:center;color:var(--text-faint);padding:30px;font-size:var(--t3)}
/* Icon 通用样式 (替代 emoji) */ /* Icon 通用样式 (替代 emoji) */
.i{width:16px;height:16px;display:inline-block;vertical-align:-3px;flex-shrink:0;color:inherit} .i{width:16px;height:16px;display:inline-block;vertical-align:-3px;flex-shrink:0;color:inherit}
.i-sm{width:13px;height:13px;vertical-align:-2px} .i-sm{width:13px;height:13px;vertical-align:-2px}
@@ -1957,6 +1996,39 @@ a.msg-link{text-decoration:none;color:inherit}
</div> </div>
</div> </div>
<div id="lightbox" onclick="this.classList.remove('show')"><img id="lb-img" /></div> <div id="lightbox" onclick="this.classList.remove('show')"><img id="lb-img" /></div>
<!-- 导出筛选模态框 -->
<div class="modal-overlay" id="exportModal">
<div class="modal" onclick="event.stopPropagation()">
<div class="modal-h">
<h2 id="exportModalTitle">导出聊天</h2>
<button class="modal-close" onclick="closeExportModal()">&times;</button>
</div>
<div class="modal-b">
<input type="text" class="modal-search" id="exportSearch" placeholder="🔍 按名字 / wxid 搜索..." oninput="filterSessions()">
<div class="session-list" id="exportSessionList">
<div class="modal-loading">加载中...</div>
</div>
<div class="modal-selctrl">
<button onclick="selectAllSessions(true)">全选</button>
<button onclick="selectAllSessions(false)">清空</button>
<button onclick="selectRecentSessions(30)">选最近 30 天活跃</button>
<span class="modal-selcount" id="exportSelCount" style="margin-left:auto">已选 0 个</span>
</div>
<div class="modal-section" id="exportFmtSection">
<div class="modal-section-label">格式</div>
<div class="modal-fmt">
<label><input type="checkbox" value="csv" checked> CSV</label>
<label><input type="checkbox" value="html"> HTML</label>
<label><input type="checkbox" value="json"> JSON</label>
</div>
</div>
</div>
<div class="modal-f">
<button class="modal-btn secondary" onclick="closeExportModal()">取消</button>
<button class="modal-btn primary" id="exportConfirmBtn" onclick="confirmExport()" disabled>确认导出 →</button>
</div>
</div>
</div>
<div class="messages" id="msgs"> <div class="messages" id="msgs">
<div class="empty" id="empty"><svg class="i i-xl" style="opacity:.4;margin-bottom:12px"><use href="#i-radio"/></svg><p>等待新消息...</p><p style="margin-top:6px;font-size:11px;color:#333">WAL增量解密 · SSE推送</p></div> <div class="empty" id="empty"><svg class="i i-xl" style="opacity:.4;margin-bottom:12px"><use href="#i-radio"/></svg><p>等待新消息...</p><p style="margin-top:6px;font-size:11px;color:#333">WAL增量解密 · SSE推送</p></div>
</div> </div>
@@ -2090,16 +2162,111 @@ async function cancelTool(){
await fetch('/api/tool/cancel',{method:'POST'}); await fetch('/api/tool/cancel',{method:'POST'});
}catch(e){} }catch(e){}
} }
// —— 导出筛选模态框 ——
window.__exportCtx = { source: null, task: null, btn: null, sessions: [] };
async function openExportModal(modalKind, task, btn){
const source = modalKind === 'export_wxwork' ? 'wxwork' : 'wechat';
window.__exportCtx = { source, task, btn, sessions: [] };
document.getElementById('exportModalTitle').textContent =
source === 'wxwork' ? '导出企业微信聊天' : '导出个人微信聊天';
document.getElementById('exportSearch').value = '';
// 企微脚本支持 --formats, 个人微信脚本目前只 JSON; 隐藏个人微信的格式选项
document.getElementById('exportFmtSection').style.display = source === 'wxwork' ? 'block' : 'none';
document.getElementById('exportConfirmBtn').disabled = true;
document.getElementById('exportSelCount').textContent = '已选 0 个';
document.getElementById('exportSessionList').innerHTML = '<div class="modal-loading">加载会话列表...</div>';
document.getElementById('exportModal').classList.add('show');
try{
const r = await fetch('/api/sessions?source=' + source);
const sessions = await r.json();
if(sessions.error) throw new Error(sessions.error);
window.__exportCtx.sessions = sessions;
renderSessions(sessions, '');
}catch(e){
document.getElementById('exportSessionList').innerHTML =
'<div class="modal-loading" style="color:var(--danger)">加载失败: ' + esc(e.message) + '</div>';
}
}
function closeExportModal(){
document.getElementById('exportModal').classList.remove('show');
}
function renderSessions(sessions, filter){
const list = document.getElementById('exportSessionList');
const lo = filter.toLowerCase();
const filtered = sessions.filter(s =>
!lo || s.name.toLowerCase().includes(lo) || (s.username||'').toLowerCase().includes(lo)
);
if(!filtered.length){
list.innerHTML = '<div class="modal-loading">没匹配的会话</div>';
return;
}
list.innerHTML = filtered.map((s, idx) => {
const tsLabel = s.last_ts ? new Date(s.last_ts*1000).toISOString().slice(0,10) : '';
const typeCls = s.type === '' ? 'grp' : (s.type === '单聊' ? 'single' : '');
return `<label class="session-item">
<input type="checkbox" data-username="${esc(s.username)}" onchange="updateSelCount()">
<span class="session-type ${typeCls}">${esc(s.type)}</span>
<span class="session-name" title="${esc(s.username)}">${esc(s.name)}</span>
<span class="session-ts">${tsLabel}</span>
</label>`;
}).join('');
}
function filterSessions(){
renderSessions(window.__exportCtx.sessions, document.getElementById('exportSearch').value);
updateSelCount(); // refilter 后保留 selection 但 count 重新统计当前可见的
}
function updateSelCount(){
const checked = document.querySelectorAll('#exportSessionList input[type=checkbox]:checked');
document.getElementById('exportSelCount').textContent = '已选 ' + checked.length + '';
document.getElementById('exportConfirmBtn').disabled = checked.length === 0;
}
function selectAllSessions(yes){
document.querySelectorAll('#exportSessionList input[type=checkbox]').forEach(c => c.checked = yes);
updateSelCount();
}
function selectRecentSessions(days){
const cutoff = Date.now()/1000 - days*86400;
const list = window.__exportCtx.sessions;
document.querySelectorAll('#exportSessionList input[type=checkbox]').forEach(c => {
const u = c.dataset.username;
const s = list.find(x => x.username === u);
c.checked = s && s.last_ts >= cutoff;
});
updateSelCount();
}
function confirmExport(){
const users = [...document.querySelectorAll('#exportSessionList input[type=checkbox]:checked')]
.map(c => c.dataset.username);
const formats = [...document.querySelectorAll('#exportFmtSection input[type=checkbox]:checked')]
.map(c => c.value);
closeExportModal();
// 实际触发任务,带 args
const { task, btn } = window.__exportCtx;
runToolWithArgs(task, btn, { users, formats });
}
// 需要弹模态框先筛选会话的任务
const NEEDS_MODAL = { 'export_all': 'export_wechat', 'wxwork_export': 'export_wxwork' };
async function runTool(task, btn){ async function runTool(task, btn){
// 如果已经在运行 (按钮变成 cancel) → 取消 // 取消已运行任务
if(btn.classList.contains('cancel')){ if(btn.classList.contains('cancel')){
btn.disabled = true; btn.disabled = true;
btn.textContent = '终止中...'; btn.textContent = '终止中...';
cancelTool(); cancelTool();
return; return;
} }
// 导出类任务先弹模态框
if(NEEDS_MODAL[task]){
openExportModal(NEEDS_MODAL[task], task, btn);
return;
}
runToolWithArgs(task, btn, null);
}
async function runToolWithArgs(task, btn, args){
const s=document.getElementById('toolStatus'); const s=document.getElementById('toolStatus');
// 禁用其他按钮, 当前按钮改成"终止"
document.querySelectorAll('.tool-task-btn').forEach(b=>{ document.querySelectorAll('.tool-task-btn').forEach(b=>{
if(b!==btn) b.disabled=true; if(b!==btn) b.disabled=true;
}); });
@@ -2108,22 +2275,22 @@ async function runTool(task, btn){
btn.innerHTML = '<svg class="i"><use href="#i-stop"/></svg> 终止'; btn.innerHTML = '<svg class="i"><use href="#i-stop"/></svg> 终止';
btn.classList.add('cancel'); btn.classList.add('cancel');
window.__runningBtn = btn; window.__runningBtn = btn;
// 清当前 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.innerHTML='<svg class="i i-sm spin"><use href="#i-loader"/></svg> 运行中: '+esc(btn.dataset.origText.trim()); s.innerHTML='<svg class="i i-sm spin"><use href="#i-loader"/></svg> 运行中: '+esc(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 payload = { task: task };
if(args) payload.args = args;
const r=await fetch('/api/tool',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
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=>{ document.querySelectorAll('.tool-task-btn').forEach(b=>{
b.disabled=false; b.disabled=false;
if(b.dataset.origText){b.textContent = b.dataset.origText; b.dataset.origText='';} if(b.dataset.origHtml){b.innerHTML = b.dataset.origHtml; b.dataset.origHtml=''; b.dataset.origText='';}
b.classList.remove('cancel'); b.classList.remove('cancel');
}); });
} }
@@ -2335,6 +2502,30 @@ fetch('/api/history').then(r=>r.json()).then(ms=>{
# 前端在 HTML_PAGE 顶部加一个折叠区,点按钮 POST /api/tool {task: "..."}。 # 前端在 HTML_PAGE 顶部加一个折叠区,点按钮 POST /api/tool {task: "..."}。
# SSE 事件用 event=tool_log / tool_done 跟原 message 事件区分。 # SSE 事件用 event=tool_log / tool_done 跟原 message 事件区分。
def _build_export_steps(users, formats):
"""根据用户选择的会话 + 格式拼 export_all_chats.py argv"""
cmd = [sys.executable, "export_all_chats.py"]
if users:
cmd += ["--users", ",".join(users)]
# export_all_chats 当前只输出 JSON, formats 暂时忽略
# (export_messages.py 有 CSV/HTML/JSON, 但走的是另一条路径, 不在 v1 范围)
return [cmd]
def _build_wxwork_export_steps(users, formats):
"""根据用户选择拼 export_wxwork_messages.py argv (--conversation 可重复)"""
cmd = [sys.executable, "export_wxwork_messages.py"]
for u in (users or []):
cmd += ["--conversation", u]
if formats:
cmd += ["--formats", ",".join(formats)]
return [cmd]
# task 配置:
# steps — 固定 cmd 列表 (无参任务)
# build_steps — fn(args)->[cmd, ...] 动态构造 (需要用户选会话/格式的导出任务)
# needs_modal — 前端点这个 task 时要弹模态框 ('export_wechat' | 'export_wxwork')
TOOL_TASKS = { TOOL_TASKS = {
# —— 个人微信 —— # —— 个人微信 ——
"wechat_decrypt": { "wechat_decrypt": {
@@ -2346,8 +2537,9 @@ TOOL_TASKS = {
"steps": [[sys.executable, "find_image_key.py"]], "steps": [[sys.executable, "find_image_key.py"]],
}, },
"export_all": { "export_all": {
"name": "③ 导出全部聊天 (JSON)", "name": "③ 导出聊天",
"steps": [[sys.executable, "export_all_chats.py"]], "build_steps": _build_export_steps,
"needs_modal": "export_wechat",
}, },
"decode_images": { "decode_images": {
"name": "④ 批量解密图片", "name": "④ 批量解密图片",
@@ -2371,7 +2563,8 @@ TOOL_TASKS = {
}, },
"wxwork_export": { "wxwork_export": {
"name": "⑦ 企业微信导出", "name": "⑦ 企业微信导出",
"steps": [[sys.executable, "export_wxwork_messages.py"]], "build_steps": _build_wxwork_export_steps,
"needs_modal": "export_wxwork",
}, },
# —— 工具 —— # —— 工具 ——
"voice_mp3": { "voice_mp3": {
@@ -2384,13 +2577,87 @@ _tool_lock = threading.Lock()
_tool_running = {"job": None, "proc": None, "cancelled": False} # 同时只允许一个任务 _tool_running = {"job": None, "proc": None, "cancelled": False} # 同时只允许一个任务
def _list_sessions(source):
"""列会话, 供导出筛选模态框消费。返回 [{name, username, type, last_ts}]
按 last_ts 降序。
source:
wechat — 个人微信, 从 decrypted/session/session.db 读 SessionTable
wxwork — 企业微信, 从 wxwork_decrypted/session.db 读 conversation_table
"""
out = []
if source == "wechat":
if not os.path.exists(DECRYPTED_SESSION):
return []
# 复用 load_contact_names (静态快照即可, 模态框开时调一次性 OK)
names = load_contact_names()
try:
with closing(sqlite3.connect(f"file:{DECRYPTED_SESSION}?mode=ro&immutable=1", uri=True)) as conn:
for r in conn.execute(
"SELECT username, type, last_timestamp, last_sender_display_name, summary "
"FROM SessionTable WHERE username IS NOT NULL AND username != '' "
"ORDER BY last_timestamp DESC"
):
username, type_, ts, sender, summary = r
is_group = username.endswith("@chatroom") if username else False
is_public = username.startswith("gh_") if username else False
type_label = "" if is_group else ("公众号" if is_public else "单聊")
name = names.get(username) or sender or username
out.append({
"username": username,
"name": name,
"type": type_label,
"last_ts": ts or 0,
"summary": (summary or "")[:60],
})
except Exception:
pass
elif source == "wxwork":
script_dir = os.path.dirname(os.path.abspath(__file__))
path = os.path.join(script_dir, "wxwork_decrypted", "session.db")
if not os.path.exists(path):
return []
try:
with closing(sqlite3.connect(f"file:{path}?mode=ro&immutable=1", uri=True)) as conn:
conn.text_factory = lambda b: b.decode("utf-8", errors="replace") if isinstance(b, bytes) else b
for r in conn.execute(
"SELECT id, name, last_message_time FROM conversation_table "
"WHERE id IS NOT NULL AND id != '' "
"ORDER BY last_message_time DESC"
):
cid, name, ts = r
# id 前缀: R=群 / S=单聊 / E=外部/系统 / Y=其他
if cid.startswith("R:"):
type_label = ""
elif cid.startswith("S:"):
type_label = "单聊"
elif cid.startswith("E:"):
type_label = "外部"
else:
type_label = "其他"
out.append({
"username": cid,
"name": name or cid,
"type": type_label,
"last_ts": ts or 0,
"summary": "",
})
except Exception:
pass
return out
def _broadcast_tool_event(event, **fields): def _broadcast_tool_event(event, **fields):
payload = {"event": event, **fields} payload = {"event": event, **fields}
broadcast_sse(payload) broadcast_sse(payload)
def _run_tool_task(job_id, task_name): def _run_tool_task(job_id, task_name, args=None):
"""后台线程: 顺序跑 TOOL_TASKS[task_name].steps 的每条命令,实时推 SSE。""" """后台线程: 顺序跑 TOOL_TASKS[task_name].steps 的每条命令,实时推 SSE。
args 含用户在前端模态框选的参数 (users / formats 等), 由 build_steps
动态构造 cmd。固定任务 (无 build_steps) 直接用 steps。
"""
task = TOOL_TASKS.get(task_name) task = TOOL_TASKS.get(task_name)
if not task: if not task:
_broadcast_tool_event("tool_done", job_id=job_id, ok=False, _broadcast_tool_event("tool_done", job_id=job_id, ok=False,
@@ -2399,12 +2666,19 @@ def _run_tool_task(job_id, task_name):
_tool_running["job"] = None _tool_running["job"] = None
return return
# 动态构造 steps (导出类) 或用固定 steps
if "build_steps" in task:
a = args or {}
steps = task["build_steps"](a.get("users", []), a.get("formats", []))
else:
steps = task["steps"]
_broadcast_tool_event("tool_log", job_id=job_id, _broadcast_tool_event("tool_log", job_id=job_id,
line=f"━━━ 开始: {task['name']} ━━━\n") line=f"━━━ 开始: {task['name']} ━━━\n")
exit_code = 0 exit_code = 0
cancelled = False cancelled = False
for step in task["steps"]: for step in 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,
line=f"\n>>> {cmd_str}\n\n") line=f"\n>>> {cmd_str}\n\n")
@@ -2575,6 +2849,23 @@ class Handler(BaseHTTPRequestHandler):
with sse_lock: with sse_lock:
if q in sse_clients: if q in sse_clients:
sse_clients.remove(q) sse_clients.remove(q)
elif self.path == "/api/sessions" or self.path.startswith("/api/sessions?"):
# 列会话(用于导出筛选模态框)
parsed = urllib.parse.urlparse(self.path)
params = urllib.parse.parse_qs(parsed.query)
source = params.get("source", ["wechat"])[0]
try:
sessions = _list_sessions(source)
self.send_response(200)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.end_headers()
self.wfile.write(json.dumps(sessions, ensure_ascii=False).encode())
except Exception as e:
self.send_response(500)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(json.dumps({"error": str(e)}).encode())
else: else:
self.send_error(404) self.send_error(404)
@@ -2585,6 +2876,7 @@ class Handler(BaseHTTPRequestHandler):
body = self.rfile.read(length).decode("utf-8") if length else "{}" body = self.rfile.read(length).decode("utf-8") if length else "{}"
req = json.loads(body) req = json.loads(body)
task_name = req.get("task", "") task_name = req.get("task", "")
task_args = req.get("args", {}) or {}
except Exception as e: except Exception as e:
self.send_response(400) self.send_response(400)
self.send_header("Content-Type", "application/json") self.send_header("Content-Type", "application/json")
@@ -2613,7 +2905,8 @@ class Handler(BaseHTTPRequestHandler):
job_id = "j_" + uuid.uuid4().hex[:8] job_id = "j_" + uuid.uuid4().hex[:8]
_tool_running["job"] = job_id _tool_running["job"] = job_id
threading.Thread(target=_run_tool_task, args=(job_id, task_name), threading.Thread(target=_run_tool_task,
args=(job_id, task_name, task_args),
daemon=True).start() daemon=True).start()
self.send_response(200) self.send_response(200)