// MCN热点雷达 - 本地 Web 服务(零依赖 Node 实现) // 启动: node server.js [端口] → http://localhost:8900(默认;可传参指定端口,如 node server.js 9000) // 功能: 技能导航 + 产出内容浏览(默认读桌面 MCNSkill项目)+ dsh 数据功能页(账号/视频/脚本/复盘/周榜,只读) 'use strict'; const http = require('http'); const fs = require('fs'); const path = require('path'); const os = require('os'); const { execFileSync } = require('child_process'); const { URL } = require('url'); const dsh = require('./dsh-data'); const { parseAttachment } = require('./scripts/attachment-parser'); // 09-01 默认端口由 8899 改为 8900:本机 8899 被小米 PC 管家 MiPCAudio.exe 系统服务占用(0.0.0.0 监听且自动复活), // 此时 127.0.0.1:8899 绑定不生效 → 默认直接起在 8900;仍可 `node server.js <端口>` 显式换端口 const PORT_BASE = parseInt(process.argv[2], 10) || 8900; const ROOT = __dirname; const PUBLIC_DIR = path.join(ROOT, 'public'); const CONFIG_FILE = path.join(ROOT, 'config.json'); // ---- 默认根目录:桌面/MCNSkill项目 ---- const DEFAULT_ROOT = path.join(os.homedir(), 'Desktop', 'MCNSkill项目'); // ---- 配置读写(自定义根目录列表)---- function loadConfig() { try { if (fs.existsSync(CONFIG_FILE)) { const c = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8')); if (Array.isArray(c.extraRoots)) return { extraRoots: c.extraRoots }; } } catch (e) { /* 忽略损坏配置 */ } return { extraRoots: [] }; } function saveConfig(cfg) { fs.writeFileSync(CONFIG_FILE, JSON.stringify(cfg, null, 2), 'utf8'); } // ---- 允许读取的根目录白名单 ---- function allowedRoots() { const cfg = loadConfig(); const list = [DEFAULT_ROOT].concat(cfg.extraRoots || []); // 去重、只保留存在的目录 return list.filter((p, i) => p && fs.existsSync(p) && fs.statSync(p).isDirectory() && list.indexOf(p) === i); } // ---- 路径安全:必须落在某个允许根目录内 ---- function resolveSafe(rawPath) { if (!rawPath) return null; let p; try { p = path.resolve(rawPath); } catch (e) { return null; } for (const root of allowedRoots()) { const r = path.resolve(root); if (p === r || p.startsWith(r + path.sep)) { return { abs: p, root: r }; } } return null; } // ---- 分析产物入库(09-01 新增):扫描账号产出目录 → 写工作台库 mcn-plugin.db ---- // 背景:账号分析技能产出只落盘文件系统未入库(俊希解析/人设卡全缺),工作台选题等读不到视频内容。 // 实现参照 dsh-plugin-mcn lib/imports.js 的 analyses 入库逻辑(dsh 接口 3080/3081 不可用时直写库)。 function resolveAccountDir(name, root) { const safeName = String(name || '').replace(/[\\/:*?"<>|]/g, '_').trim(); if (!safeName) return null; const roots = root ? [root] : allowedRoots(); for (const r of roots) { const dir = path.join(r, safeName); try { if (fs.statSync(dir).isDirectory()) return dir; } catch (e) { /* 忽略 */ } } return null; } function importAccountIntoDb(db, account, dir) { const stats = { video_analysis: 0, video_source: 0, persona: 0, account_analysis: 0, videos_upserted: 0, skipped: [] }; const now = new Date().toISOString(); const insAnalysis = db.prepare(`INSERT INTO account_video_analysis (video_id, aweme_id, analysis_time, content_json, summary) VALUES (?,?,?,?,?)`); const insSource = db.prepare(`INSERT INTO account_video_source (video_id, aweme_id, analysis_time, content_json, summary, analysis_json) VALUES (?,?,?,?,?,?)`); const insPersona = db.prepare(`INSERT INTO account_persona (account_id, analysis_time, content_json, summary) VALUES (?,?,?,?)`); const insAccAna = db.prepare(`INSERT INTO account_analysis (account_id, analysis_time, content_json, summary) VALUES (?,?,?,?)`); const acc = db.prepare('SELECT id FROM hot_accounts WHERE account_name=? LIMIT 1').get(account); const accountId = acc ? acc.id : null; // 清理历史误插脏数据:09-01 匹配缺陷(标点未规范化)产生 aweme_id=null 且 video_id=null 的无意义记录,重跑入库前先清 db.prepare("DELETE FROM account_video_analysis WHERE aweme_id IS NULL AND video_id IS NULL").run(); db.prepare("DELETE FROM account_video_source WHERE aweme_id IS NULL AND video_id IS NULL").run(); // 规范化标题:去 # 标签 → 删全角/半角标点(xlsx 标题带「!」「“”」而文件夹名无,09-01 匹配失败根因)→ 下划线转空格(对齐文件夹 _标签 结构)→ 空格归一 const normTitle = (s) => String(s || '').split('#')[0].replace(/[!!“”‘’"'`,.…。,、;::;·]/g, '').replace(/[_-]+/g, ' ').replace(/\s+/g, ' ').trim(); // 0) aweme_id 权威映射:短视频表格.xlsx(子进程 python,最高优先级)→ account_videos 表(兜底) const xlsxList = readXlsxMap(dir); const xlsxByNorm = {}; for (const r of xlsxList) { if (r.aweme_id && r.title) xlsxByNorm[normTitle(r.title)] = r.aweme_id; } const titleRows = db.prepare('SELECT aweme_id, video_title FROM account_videos WHERE video_title IS NOT NULL').all(); const dbByNorm = {}; for (const r of titleRows) { if (r.aweme_id && r.video_title) dbByNorm[normTitle(r.video_title)] = String(r.aweme_id); } // 匹配:精确优先;包含匹配要求双方标题 ≥10 字且按标题长度降序(最具体优先,防短标题误配) const matchAweme = (folder) => { const base = normTitle(folder); if (!base) return null; if (xlsxByNorm[base]) return xlsxByNorm[base]; if (dbByNorm[base]) return dbByNorm[base]; const cands = []; for (const [t, aw] of Object.entries(xlsxByNorm)) { if (base.length >= 10 && t.length >= 10 && (base.includes(t) || t.includes(base))) cands.push([t.length, aw]); } if (!cands.length) { for (const [t, aw] of Object.entries(dbByNorm)) { if (base.length >= 10 && t.length >= 10 && (base.includes(t) || t.includes(base))) cands.push([t.length, aw]); } } cands.sort((a, b) => b[0] - a[0]); return cands.length ? cands[0][1] : null; }; // 0.5) 视频列表入库:短视频表格.xlsx → account_videos(按 account_id+aweme_id 去重,补上工作台视频列表数据) if (accountId && xlsxList.length) { const insVideo = db.prepare(`INSERT INTO account_videos (account_id, aweme_id, video_title, video_url, like_count, comment_count, share_count, collect_count, play_count, duration, publish_time, tags, collected_time) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)`); for (const v of xlsxList) { const ex = db.prepare('SELECT id FROM account_videos WHERE account_id=? AND aweme_id=? LIMIT 1').get(accountId, v.aweme_id); if (!ex) { insVideo.run(accountId, v.aweme_id, v.title, v.url || null, v.like_count ?? null, v.comment_count ?? null, v.share_count ?? null, v.collect_count ?? null, v.play_count ?? null, v.duration ?? null, v.publish_time || null, v.tags || null, now); stats.videos_upserted++; } } } // 1) 视频分析目录(功能三 Step5 产出;功能五产出在 视频对标/ 目录,由 dsh 导入机制处理) // 字段映射(09-01 修正,对齐 dsh 语义):content.json→account_video_source.content_json(视频脚本); // analysis.json→account_video_source.analysis_json(视频拆解,MCP 结构化分析);*拆解分析.md→account_video_analysis(视频分析,功能四产物)。 // 原实现把 analysis.json 误写 account_video_analysis 导致「拆解内容进了视频分析」+「视频拆解 tab 空」 const vaDir = path.join(dir, '视频分析'); if (fs.existsSync(vaDir)) { for (const folder of fs.readdirSync(vaDir)) { const fdir = path.join(vaDir, folder); let st; try { st = fs.statSync(fdir); } catch (e) { continue; } if (!st.isDirectory()) continue; const awemeId = matchAweme(folder); const anPath = path.join(fdir, 'analysis.json'); const coPath = path.join(fdir, 'content.json'); if (fs.existsSync(coPath)) { const content = fs.readFileSync(coPath, 'utf8'); const analysisJson = fs.existsSync(anPath) ? fs.readFileSync(anPath, 'utf8') : null; if (awemeId) db.prepare('DELETE FROM account_video_source WHERE aweme_id=?').run(awemeId); insSource.run(null, awemeId, now, content, '', analysisJson); stats.video_source++; } // 拆解分析.md → account_video_analysis(功能四逐条拆解产物,与解析并存于同一文件夹;无则不入) const decon = fs.readdirSync(fdir).find((f) => f.endsWith('拆解分析.md')); if (decon) { const content = fs.readFileSync(path.join(fdir, decon), 'utf8'); if (awemeId) db.prepare('DELETE FROM account_video_analysis WHERE aweme_id=?').run(awemeId); insAnalysis.run(null, awemeId, now, content, content.slice(0, 500)); stats.video_analysis++; } } } // 2) 人设卡 + 账号分析(去重,不删旧) if (accountId) { const personaPath = path.join(dir, account + '账号设定.md'); if (fs.existsSync(personaPath)) { const content = fs.readFileSync(personaPath, 'utf8'); const dup = db.prepare('SELECT id FROM account_persona WHERE account_id=? AND content_json=? LIMIT 1').get(accountId, content); if (!dup) { insPersona.run(accountId, now, content, content.slice(0, 200)); stats.persona++; } else stats.skipped.push('persona(重复)'); } const anaPath = path.join(dir, account + '账号数据分析.md'); if (fs.existsSync(anaPath)) { const content = fs.readFileSync(anaPath, 'utf8'); const dup = db.prepare('SELECT id FROM account_analysis WHERE account_id=? AND content_json=? LIMIT 1').get(accountId, content); if (!dup) { insAccAna.run(accountId, now, content, content.slice(0, 200)); stats.account_analysis++; } else stats.skipped.push('account_analysis(重复)'); } } return { account, account_id: accountId, dir, xlsx_rows: xlsxList.length, title_map_size: Object.keys(dbByNorm).length, ...stats }; } // 读短视频表格.xlsx → 视频映射数组(子进程调 python export_video_map.py,openpyxl 读 xlsx;失败返回 []) function readXlsxMap(dir) { const xlsxPath = path.join(dir, '短视频表格.xlsx'); if (!fs.existsSync(xlsxPath)) return []; const py = process.env.PYTHON || 'python'; try { const out = execFileSync(py, [path.join(ROOT, 'scripts', 'export_video_map.py'), xlsxPath], { encoding: 'utf8', timeout: 20000, windowsHide: true }); const arr = JSON.parse(out); return Array.isArray(arr) ? arr : []; } catch (e) { log('xlsx 视频映射读取失败(用表内匹配兜底): ' + e.message); return []; } } // ---- 查找账号设定卡 SVG ---- // 产出规范:{产出根}/{达人昵称}/{达人昵称}账号设定卡.svg(功能六产出,与账号设定.md 同目录) // 查找顺序:规范路径 → 根下直挂 → 账号目录内扫描(防目录名/账号名不一致) function findAccountCard(name) { const safeName = String(name || '').replace(/[\\/:*?"<>|]/g, '_').trim(); if (!safeName) return null; for (const root of allowedRoots()) { const p1 = path.join(root, safeName, safeName + '账号设定卡.svg'); const p2 = path.join(root, safeName + '账号设定卡.svg'); for (const c of [p1, p2]) { try { if (fs.existsSync(c) && fs.statSync(c).isFile()) return c; } catch (e) { /* 忽略 */ } } } for (const root of allowedRoots()) { const dir = path.join(root, safeName); try { if (fs.statSync(dir).isDirectory()) { const hit = fs.readdirSync(dir).find((f) => f.toLowerCase().endsWith('.svg') && f.includes('账号设定卡')); if (hit) return path.join(dir, hit); } } catch (e) { /* 忽略 */ } } return null; } // ---- 目录树(递归,跳过隐藏项)---- function buildTree(absPath, depth) { if (depth > 4) return null; let entries; try { entries = fs.readdirSync(absPath, { withFileTypes: true }); } catch (e) { return null; } const dirs = []; const files = []; for (const ent of entries) { if (ent.name.startsWith('.')) continue; // 隐藏文件 if (ent.name === 'node_modules' || ent.name === '.git') continue; const full = path.join(absPath, ent.name); try { if (ent.isDirectory()) { const child = buildTree(full, depth + 1); if (child) dirs.push({ name: ent.name, path: full, type: 'dir', children: child }); } else if (ent.isFile()) { const st = fs.statSync(full); files.push({ name: ent.name, path: full, type: 'file', ext: path.extname(ent.name).toLowerCase(), size: st.size }); } } catch (e) { /* 权限/占用跳过 */ } } dirs.sort((a, b) => a.name.localeCompare(b.name, 'zh')); files.sort((a, b) => a.name.localeCompare(b.name, 'zh')); return dirs.concat(files); } // ---- MIME ---- const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.json': 'application/json; charset=utf-8', '.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.ico': 'image/x-icon', '.woff2': 'font/woff2', }; function mimeOf(p) { return MIME[path.extname(p).toLowerCase()] || 'application/octet-stream'; } // ---- 读取文本文件(按扩展名判定预览能力)---- const PREVIEW_EXTS = ['.md', '.markdown', '.txt', '.json', '.csv', '.log']; function readText(absPath) { const ext = path.extname(absPath).toLowerCase(); if (!PREVIEW_EXTS.includes(ext)) return null; const buf = fs.readFileSync(absPath); // BOM 去除 let text = buf.toString('utf8'); if (text.charCodeAt(0) === 0xFEFF) text = text.slice(1); return text; } // ---- 简易日志 ---- function log(msg) { console.log('[' + new Date().toLocaleTimeString('zh-CN', { hour12: false }) + '] ' + msg); } // ---------- 榜单数据更新任务(09-03 新增:热点数据页「更新榜单」按钮后台任务)---------- // 任务名固定用于防重识别(同名最近一次任务状态 = 是否有更新在跑 / 上次执行结果) const RANK_UPDATE_NAME = '更新榜单数据'; const RANK_UPDATE_COOLDOWN_MS = 30 * 60 * 1000; // 防重复更新窗口:距上次成功落盘 < 30 分钟拦截(可 force 跳过) // workbuddy 宿主库(automations/sessions/automation_runs),与 /api/run 同款路径解析 function wbDb() { const { DatabaseSync } = require('node:sqlite'); return new DatabaseSync(process.env.WORKBUDDY_DB || path.join(os.homedir(), '.workbuddy', 'workbuddy.db')); } // 创建一次性后台任务(08-31 实测约定:scheduled_at 与 next_run_at 均取 +5s 未来,调度器按 next_run_at 扫描拾取; // 模型跟随用户最近活跃手动会话选择,兜底 'auto')——逻辑与 /api/run 完全一致,供更新榜单等新入口复用 function createOnceAutomation(name, prompt, skillsArr) { const db = wbDb(); try { const now = Date.now(); const id = 'automation-' + now; const cwd = 'D:\\AgentSkill\\mcn-workshop'; // 工作台触发的会话归入 mcn-workshop 空间分组 const d = new Date(now + 5 * 1000); const pad = (n) => String(n).padStart(2, '0'); const scheduledAt = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; const nextRunAt = now + 5 * 1000; const uidRow = db.prepare("SELECT user_id FROM sessions WHERE user_id IS NOT NULL AND user_id <> '' ORDER BY created_at DESC LIMIT 1").get(); const ownerUserId = uidRow ? uidRow.user_id : ''; const modelRow = db.prepare(`SELECT model FROM sessions WHERE deleted_at IS NULL AND COALESCE(is_background_automation,0)=0 AND model IS NOT NULL AND model <> '' ORDER BY COALESCE(last_activity_at, updated_at) DESC LIMIT 1`).get(); const modelId = (modelRow && modelRow.model) ? modelRow.model : 'auto'; db.prepare(`INSERT INTO automations (id,name,prompt,status,schedule_type,scheduled_at,next_run_at,rrule,cwds,created_at,updated_at,skills_json,connector_ids_json,model_id,permission_mode,owner_user_id,owner_status) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`) .run(id, name, prompt, 'ACTIVE', 'once', scheduledAt, nextRunAt, '', JSON.stringify([cwd]), now, now, JSON.stringify(Array.isArray(skillsArr) ? skillsArr : []), '[]', modelId, 'fullAccess', ownerUserId, 'confirmed'); return { id }; } finally { db.close(); } } // 最近一次「更新榜单数据」任务状态(running/queued/pending = 进行中;done/error/review = 已结束) function rankLastTaskState() { try { const db = wbDb(); try { const row = db.prepare('SELECT id, status, deleted_at FROM automations WHERE name=? AND deleted_at IS NULL ORDER BY created_at DESC LIMIT 1').get(RANK_UPDATE_NAME); if (!row) return null; const st = db.prepare('SELECT running, last_error FROM automation_runtime_state WHERE automation_id=?').get(row.id); const runs = db.prepare('SELECT status FROM automation_runs WHERE automation_id=? ORDER BY created_at DESC LIMIT 1').get(row.id); let state = 'queued'; if (st && Number(st.running) === 1) state = 'running'; else if (runs) { const s = String(runs.status || '').toUpperCase(); if (s === 'IN_PROGRESS') state = 'running'; else if (['DONE', 'COMPLETED', 'SUCCESS', 'FINISHED', 'ACCEPTED'].includes(s)) state = 'done'; else if (['ERROR', 'CANCELLED', 'FAILED', 'INTERRUPTED'].includes(s)) state = 'error'; else if (s === 'PENDING_REVIEW') state = 'review'; else state = 'pending'; } return { id: row.id, state, error: (st && st.last_error) || null }; } finally { db.close(); } } catch (e) { return null; } } // ---- 静态文件 / API ---- const server = http.createServer((req, res) => { const u = new URL(req.url, 'http://localhost'); const p = decodeURIComponent(u.pathname); const q = u.searchParams; // 统一 JSON 响应 const sendJSON = (code, obj) => { const body = JSON.stringify(obj); res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' }); res.end(body); }; const sendErr = (code, msg) => sendJSON(code, { error: msg }); // ---------- API ---------- if (p === '/api/roots') { return sendJSON(200, { defaultRoot: DEFAULT_ROOT, extraRoots: loadConfig().extraRoots, defaultExists: fs.existsSync(DEFAULT_ROOT), }); } if (p === '/api/roots' && req.method === 'POST') { let body = ''; req.on('data', (c) => { body += c; if (body.length > 1e6) req.destroy(); }); req.on('end', () => { try { const { extraRoots } = JSON.parse(body || '{}'); if (!Array.isArray(extraRoots)) return sendErr(400, 'extraRoots 必须为数组'); const cleaned = extraRoots .map((x) => (typeof x === 'string' ? x.trim() : '')) .filter((x) => x && fs.existsSync(x) && fs.statSync(x).isDirectory()); saveConfig({ extraRoots: cleaned }); sendJSON(200, { extraRoots: cleaned }); } catch (e) { sendErr(500, '配置保存失败: ' + e.message); } }); return; } if (p === '/api/tree') { const rootParam = q.get('root') || DEFAULT_ROOT; const safe = resolveSafe(rootParam); if (!safe) return sendErr(403, '目录不在允许范围,请先在设置中添加'); const children = buildTree(safe.abs, 0) || []; return sendJSON(200, { root: safe.abs, tree: children }); } if (p === '/api/read') { const safe = resolveSafe(q.get('path')); if (!safe) return sendErr(403, '路径不在允许范围'); try { const st = fs.statSync(safe.abs); if (st.size > 5 * 1024 * 1024) return sendErr(413, '文件超过 5MB,不支持在线预览,请下载查看'); const text = readText(safe.abs); if (text === null) return sendErr(415, '该类型不支持在线预览,请下载后用本地软件打开'); return sendJSON(200, { name: path.basename(safe.abs), path: safe.abs, ext: path.extname(safe.abs).toLowerCase(), size: st.size, content: text, }); } catch (e) { return sendErr(500, '读取失败: ' + e.message); } } if (p === '/api/download') { const safe = resolveSafe(q.get('path')); if (!safe) return sendErr(403, '路径不在允许范围'); try { const st = fs.statSync(safe.abs); const fname = encodeURIComponent(path.basename(safe.abs)); res.writeHead(200, { 'Content-Type': mimeOf(safe.abs), 'Content-Length': st.size, 'Content-Disposition': "attachment; filename*=UTF-8''" + fname, 'Cache-Control': 'no-store', }); fs.createReadStream(safe.abs).pipe(res); } catch (e) { return sendErr(500, '下载失败: ' + e.message); } return; } if (p === '/api/file') { // 内联输出(无 attachment 头):供 / 直接渲染(如账号设定卡 SVG) const safe = resolveSafe(q.get('path')); if (!safe) return sendErr(403, '路径不在允许范围'); try { const st = fs.statSync(safe.abs); if (!st.isFile()) return sendErr(404, '文件不存在'); res.writeHead(200, { 'Content-Type': mimeOf(safe.abs), 'Content-Length': st.size, 'Cache-Control': 'no-store', }); fs.createReadStream(safe.abs).pipe(res); } catch (e) { return sendErr(500, '读取失败: ' + e.message); } return; } // ---------- 数据功能(只读工作台自有数据库副本) ---------- const dshOk = (code, obj) => sendJSON(code, { ok: true, ...obj }); const dshGuard = (res2) => { if (!dsh.dbAvailable()) { sendJSON(200, { ok: false, error: '未检测到工作台数据库(mcn-plugin.db),本功能不可用' }); return true; } return false; }; const qInt = (k, def) => { const v = Number(q.get(k)); return Number.isFinite(v) ? v : def; }; if (p === '/api/dsh/meta') { return sendJSON(200, { ok: true, db: dsh.dbAvailable(), dbPath: dsh.DSH_DB, rankingDir: dsh.RANKING_DIR, ranking: dsh.rankingMeta() }); } if (p === '/api/dsh/stats') { if (dshGuard()) return; return dshOk(200, { stats: dsh.getStats() }); } if (p === '/api/dsh/recent-scripts') { if (dshGuard()) return; return dshOk(200, { items: dsh.listRecentScripts(qInt('limit', 4)) }); } if (p === '/api/dsh/tracks') { if (dshGuard()) return; return dshOk(200, { tracks: dsh.listTracks() }); } if (p === '/api/dsh/accounts') { if (dshGuard()) return; try { return dshOk(200, dsh.listAccounts({ page: qInt('page', 1), pageSize: qInt('pageSize', 20), search: String(q.get('search') || ''), track: String(q.get('track') || ''), type: String(q.get('type') || 'all') })); } catch (e) { return sendErr(500, e.message); } } if (p === '/api/dsh/account-favorite' && req.method === 'POST') { if (dshGuard()) return; let body = ''; req.on('data', (c) => { body += c; if (body.length > 1e6) req.destroy(); }); req.on('end', () => { try { const { id, fav } = JSON.parse(body || '{}'); if (!id) return sendErr(400, '缺少 id'); dsh.setFavorite(id, !!fav); return dshOk(200, { id, favorite: !!fav }); } catch (e) { return sendErr(500, e.message); } }); return; } if (p === '/api/dsh/batch-delete' && req.method === 'POST') { if (dshGuard()) return; let body = ''; req.on('data', (c) => { body += c; if (body.length > 1e6) req.destroy(); }); req.on('end', () => { try { const { kind, ids } = JSON.parse(body || '{}'); if (!['accounts', 'videos', 'rewrites'].includes(kind)) return sendErr(400, 'kind 不合法'); const r = dsh.batchDelete(kind, ids); return dshOk(200, { ok: true, deleted: r.deleted }); } catch (e) { return sendErr(500, e.message); } }); return; } if (p === '/api/dsh/account-videos') { if (dshGuard()) return; try { return dshOk(200, dsh.listAccountVideos(qInt('accountId', 0), qInt('page', 1), qInt('pageSize', 20))); } catch (e) { return sendErr(500, e.message); } } if (p === '/api/dsh/account-persona') { if (dshGuard()) return; const r = dsh.getPersona(qInt('accountId', 0)); return dshOk(200, { content: r ? r.content : null, analysisTime: r ? r.analysis_time : null }); } if (p === '/api/dsh/account-card') { // 查询账号设定卡 SVG 文件(桌面产出,允许根目录下查找):{账号名}账号设定卡.svg if (dshGuard()) return; const hit = findAccountCard(q.get('name')); return dshOk(200, { exists: !!hit, path: hit || null }); } if (p === '/api/dsh/account-analysis') { if (dshGuard()) return; const r = dsh.getAccountAnalysis(qInt('accountId', 0)); return dshOk(200, { content: r ? r.content : null, analysisTime: r ? r.analysis_time : null }); } if (p === '/api/dsh/videos') { if (dshGuard()) return; return dshOk(200, dsh.listVideos({ page: qInt('page', 1), pageSize: qInt('pageSize', 20), search: String(q.get('search') || ''), parsed: String(q.get('parsed') || ''), days: String(q.get('days') || '') })); } if (p === '/api/dsh/video-detail') { if (dshGuard()) return; try { return dshOk(200, dsh.getVideoDetail({ videoId: qInt('videoId', 0), awemeId: q.get('awemeId') })); } catch (e) { return sendErr(500, e.message); } } if (p === '/api/dsh/video-script') { if (dshGuard()) return; try { return dshOk(200, dsh.getVideoScript(qInt('videoId', 0))); } catch (e) { return sendErr(500, e.message); } } if (p === '/api/dsh/rewrites') { if (dshGuard()) return; return dshOk(200, dsh.listRewrites(qInt('videoId', 0))); } if (p === '/api/dsh/custom-scripts') { /* 09-02 v25:自定义选题(无视频载体)脚本列表 */ if (dshGuard()) return; return dshOk(200, dsh.listCustomScripts({ page: qInt('page', 1), pageSize: qInt('pageSize', 20), search: String(q.get('search') || ''), days: String(q.get('days') || ''), account: String(q.get('account') || '') })); } if (p === '/api/dsh/rewrite-stats') { /* 09-02 v25:AI写脚本 tab 徽标计数(参考选题/自定义选题),可传 account 按账号统计 */ if (dshGuard()) return; return dshOk(200, dsh.getRewriteStats(String(q.get('account') || ''))); } if (p === '/api/dsh/custom-script') { /* 自定义选题脚本全文(查看弹窗) */ if (dshGuard()) return; try { return dshOk(200, { script: dsh.getCustomScript(qInt('id', 0)) }); } catch (e) { return sendErr(404, e.message); } } if (p === '/api/dsh/script-save' && req.method === 'POST') { /* AI创作任务完成回调写库(参考选题传 videoId;自定义选题省略→video_id 空) */ if (dshGuard()) return; let body = ''; req.on('data', (c) => { body += c; if (body.length > 2e6) req.destroy(); }); req.on('end', () => { try { const { videoId, awemeId, accountId, title, scriptText } = JSON.parse(body || '{}'); if (!scriptText || !accountId) return sendErr(400, '缺少 scriptText/accountId'); const r = dsh.saveScript({ videoId, awemeId, accountId, title, scriptText }); return dshOk(200, { ok: true, id: r.id }); } catch (e) { return sendErr(500, e.message); } }); return; } if (p === '/api/dsh/storyboards') { if (dshGuard()) return; return dshOk(200, { items: dsh.listStoryboards(qInt('videoId', 0)) }); } if (p === '/api/dsh/review-detail') { if (dshGuard()) return; const r = dsh.getReviewDetail(qInt('id', 0)); return r ? dshOk(200, { review: r }) : sendErr(404, '复盘记录不存在'); } if (p === '/api/dsh/review-full') { if (dshGuard()) return; const r = dsh.getReviewFull(qInt('rewriteId', 0)); return r ? dshOk(200, { reviews: r }) : sendErr(404, '改写记录不存在'); } if (p === '/api/dsh/ranking') { try { const r = dsh.getRanking({ board: String(q.get('board') || 'week'), date: String(q.get('date') || ''), category: String(q.get('category') || ''), sortBy: String(q.get('sortBy') || ''), order: String(q.get('order') || ''), page: qInt('page', 1), pageSize: qInt('pageSize', 20) }); return dshOk(200, r); } catch (e) { return sendErr(500, e.message); } } // ---------- 榜单数据更新(09-03 新增:热点数据页「更新榜单」按钮;执行前防重复校验)---------- // GET:返回数据新鲜度(最近成功落盘时刻 = RANKING_DIR 最新 json mtime)+ 最近同名更新任务状态,供前端展示与预拦截 if (p === '/api/dsh/ranking-update' && req.method === 'GET') { try { const fr = dsh.rankingFreshness(); return sendJSON(200, { ok: true, exists: fr.exists, files: fr.files, lastUpdateAt: fr.latestMtime || null, lastUpdateDate: fr.latestDate || '', lastUpdateAgeMin: fr.latestMtime ? Math.floor((Date.now() - fr.latestMtime) / 60000) : null, cooldownMin: RANK_UPDATE_COOLDOWN_MS / 60000, task: rankLastTaskState(), }); } catch (e) { return sendErr(500, '查询更新状态失败: ' + e.message); } } // POST:防重校验(① 更新任务进行中拦截;② 距上次落盘 < 冷却窗口且未 force 拦截)→ 创建后台抓取任务 if (p === '/api/dsh/ranking-update' && req.method === 'POST') { let body = ''; req.on('data', (c) => { body += c; if (body.length > 1e6) req.destroy(); }); req.on('end', () => { try { const { prompt, force } = JSON.parse(body || '{}'); if (!prompt || typeof prompt !== 'string' || !prompt.trim()) return sendErr(400, 'prompt 不能为空'); // 防重①:进行中(running/queued/pending)→ 拦截 const task = rankLastTaskState(); if (task && ['running', 'queued', 'pending'].includes(task.state)) { return sendJSON(200, { ok: false, error: '已有榜单更新任务在执行中(可在左侧会话栏查看进度),请勿重复提交' }); } // 防重②:距上次成功落盘 < 冷却窗口且未 force → 拦截(needForce 供前端提示仍可强制更新) const fr = dsh.rankingFreshness(); if (!force && fr.latestMtime && Date.now() - fr.latestMtime < RANK_UPDATE_COOLDOWN_MS) { const ageMin = Math.floor((Date.now() - fr.latestMtime) / 60000); return sendJSON(200, { ok: false, needForce: true, error: `榜单数据 ${Math.max(ageMin, 1)} 分钟前刚更新过(${fr.latestDate || ''}),${RANK_UPDATE_COOLDOWN_MS / 60000} 分钟内防重复更新;如确需强制刷新请再次点击并选择「仍要更新」`, }); } // 通过 → 创建后台更新任务(skills 挂 mcn-data-insight:三榜抓取脚本与落盘规范所在) const r = createOnceAutomation(RANK_UPDATE_NAME, prompt.trim(), ['mcn-data-insight']); log('已提交榜单更新任务: ' + r.id); return sendJSON(200, { ok: true, id: r.id, message: '榜单更新任务已提交,请在左侧会话栏查看执行' }); } catch (e) { return sendErr(500, '任务提交失败: ' + e.message); } }); return; } // ---------- 提交 AI 任务(Automation 一次性任务,WorkBuddy 自动执行,左侧会话栏可见)---------- if (p === '/api/run' && req.method === 'POST') { let body = ''; req.on('data', (c) => { body += c; if (body.length > 1e6) req.destroy(); }); req.on('end', () => { try { const { prompt, name, skills } = JSON.parse(body || '{}'); if (!prompt || typeof prompt !== 'string' || !prompt.trim()) return sendErr(400, 'prompt 不能为空'); // 09-01 参数级技能挂载:skills_json 是 automations 表原生字段(default '[]'), // 客户端调度器创建会话时按该字段挂载技能;前端传技能名数组(如 ['短视频工作台'])即完成任务级技能绑定 // 09-03 模型跟随用户选择:原 model_id 钉死 'deepseek-v4-flash'(绕过用户模型选择,全任务跑 flash)。 // 已改为动态读取最近活跃手动会话的 model(下方 modelRow/modelId,跟随用户在 UI 的 auto/flash/pro 选择); // 兜底 'auto' = 系统按任务复杂度自动路由。实证:调度器 model_id 有值 → setSessionModel 强制指定会话模型, // 'auto' 与具体模型 ID 均被接受(auto 会话 model='auto'、任务 ACCEPTED success=1 验证通过) const skillsArr = Array.isArray(skills) ? skills.filter((s) => typeof s === 'string' && s.trim()).map((s) => s.trim()) : []; // 09-01 并发方案A:浏览器类任务(账号信息/视频列表/采集/导入)自动注入浏览器锁约束—— // 多个 AI 会话任务并行共享同一 9223 Chrome 实例,必须互斥使用,否则标签页互相导航抢占 const BROWSER_HINT = /账号信息|视频列表|保存并分析|采集|导入账号|网页采集|浏览器/; const BROWSER_LOCK = 'D:\\\\AgentSkill\\\\mcn-workshop\\\\.browser-lock'; let finalPrompt = prompt.trim(); if (BROWSER_HINT.test(finalPrompt)) { finalPrompt += ` 【浏览器锁约束(必须遵守)】本任务需要操作浏览器(browser-harness / 9223 Chrome)。多个 AI 会话任务可能并行,浏览器是共享单实例,必须互斥使用: 1. 执行任何浏览器操作前,先检查锁文件:${BROWSER_LOCK} 是否存在(bash: ls) 2. 锁存在 → 等待 10 秒后重试,最多重试 18 次(约 3 分钟);若锁文件修改时间已超过 10 分钟视为死锁,可删除后抢占 3. 拿到锁(bash: echo <任务名+时间戳> > ${BROWSER_LOCK})→ 才可操作浏览器 4. 浏览器操作全部完成后(无论成功失败)必须删除锁文件(bash: rm -f ${BROWSER_LOCK}) 5. 若确认本任务实际不需要浏览器(如数据已齐),忽略本条约束,直接跳过`; } const { DatabaseSync } = require('node:sqlite'); const db = new DatabaseSync(process.env.WORKBUDDY_DB || path.join(os.homedir(), '.workbuddy', 'workbuddy.db')); const now = Date.now(); const id = 'automation-' + now; const cwd = 'D:\\AgentSkill\\mcn-workshop'; // 工作台触发的会话归入 mcn-workshop 空间分组 const d = new Date(Date.now() + 5 * 1000); // 未来 5 秒(08-31 根因:客户端只对未来 scheduledAt 补算 next_run_at;写 now=过去时间→不补算→调度器扫不到→卡死) const pad = (n) => String(n).padStart(2, '0'); const scheduledAt = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; const nextRunAt = Date.now() + 5 * 1000; // 08-31 实测:客户端调度器按 next_run_at 扫描;缺此列=null=永不拾取(工作台任务卡死根因) const uidRow = db.prepare("SELECT user_id FROM sessions WHERE user_id IS NOT NULL AND user_id <> '' ORDER BY created_at DESC LIMIT 1").get(); const ownerUserId = uidRow ? uidRow.user_id : ''; // 09-03 动态跟随用户模型选择:WorkBuddy 模型为会话级(sessions.model,无全局设置), // 读最近活跃的【手动】会话(非后台自动化)的 model 写入任务 → 用户在 UI 切 auto/flash/pro,任务即跟随; // 无手动会话记录时兜底 'auto'(自动路由)。实证:调度接受 'auto' 与具体模型 ID('deepseek-v4-pro' 曾成功) const modelRow = db.prepare(`SELECT model FROM sessions WHERE deleted_at IS NULL AND COALESCE(is_background_automation,0)=0 AND model IS NOT NULL AND model <> '' ORDER BY COALESCE(last_activity_at, updated_at) DESC LIMIT 1`).get(); const modelId = (modelRow && modelRow.model) ? modelRow.model : 'auto'; db.prepare(`INSERT INTO automations (id,name,prompt,status,schedule_type,scheduled_at,next_run_at,rrule,cwds,created_at,updated_at,skills_json,connector_ids_json,model_id,permission_mode,owner_user_id,owner_status) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`) .run(id, name || '工作台任务', finalPrompt, 'ACTIVE', 'once', scheduledAt, nextRunAt, '', JSON.stringify([cwd]), now, now, JSON.stringify(skillsArr), '[]', modelId, 'fullAccess', ownerUserId, 'confirmed'); db.close(); log('已提交 AI 任务: ' + id + (name ? ' (' + name + ')' : '')); return sendJSON(200, { ok: true, id, message: '任务已提交,请在左侧会话栏查看执行' }); } catch (e) { return sendErr(500, '任务提交失败: ' + e.message); } }); return; } // 查询 AI 任务执行状态(供前端轮询恢复按钮):queued / pending / running / done / error / notfound if (p === '/api/run/status' && req.method === 'GET') { const id = q.get('id'); if (!id) return sendErr(400, '缺少 id'); try { const { DatabaseSync } = require('node:sqlite'); const db = new DatabaseSync(process.env.WORKBUDDY_DB || path.join(os.homedir(), '.workbuddy', 'workbuddy.db'), { readOnly: true }); const auto = db.prepare('SELECT status, deleted_at, last_run_at FROM automations WHERE id=?').get(id); if (!auto || auto.deleted_at) { db.close(); return sendJSON(200, { ok: true, id, state: 'notfound' }); } const st = db.prepare('SELECT running, last_error FROM automation_runtime_state WHERE automation_id=?').get(id); const runs = db.prepare("SELECT status FROM automation_runs WHERE automation_id=? ORDER BY created_at DESC LIMIT 1").get(id); db.close(); let state = 'queued'; if (st && Number(st.running) === 1) state = 'running'; else if (runs) { const s = String(runs.status || '').toUpperCase(); if (s === 'IN_PROGRESS') state = 'running'; else if (s === 'PENDING_REVIEW') state = 'review'; // 任务停在「待用户确认/已中断」,前端应提示而非假装执行中 else if (['DONE', 'COMPLETED', 'SUCCESS', 'FINISHED', 'ACCEPTED'].includes(s)) state = 'done'; // ACCEPTED=客户端调度器终态(成功) else if (['ERROR', 'CANCELLED', 'FAILED', 'INTERRUPTED'].includes(s)) state = 'error'; else state = 'pending'; // ACCEPTED / QUEUED / PENDING 等 = 排队等待执行 } return sendJSON(200, { ok: true, id, state, error: (st && st.last_error) || null }); } catch (e) { return sendErr(500, '状态查询失败: ' + e.message); } } // ---------- AI 生成选题(Dify chat-messages 即时调用,参考技能 S5 选题方法论) ---------- if (p === '/api/ai/topics' && req.method === 'POST') { let body = ''; req.on('data', (c) => { body += c; if (body.length > 1e6) req.destroy(); }); req.on('end', async () => { try { const { accountName } = JSON.parse(body || '{}'); if (!accountName || typeof accountName !== 'string') return sendErr(400, '缺少账号名'); // 1) 查账号定位 + 人设摘要 + 最新视频解析(工作台库) let contentText = '', personaText = '', videoRefText = ''; try { const { DatabaseSync } = require('node:sqlite'); const db = new DatabaseSync(path.join(ROOT, 'mcn-plugin.db'), { readOnly: true }); const acc = db.prepare('SELECT * FROM hot_accounts WHERE account_name=? LIMIT 1').get(accountName); if (acc) { contentText = String(acc.content || '').slice(0, 500); const p = db.prepare('SELECT content_json, summary FROM account_persona WHERE account_id=? ORDER BY id DESC LIMIT 1').get(acc.id); if (p) personaText = String(p.summary || p.content_json || '').slice(0, 500); // 09-01:读该账号最新视频解析(account_video_analysis 按 aweme_id 关联),让选题基于真实视频内容 try { const vrows = db.prepare( 'SELECT va.content_json AS c FROM account_video_analysis va JOIN account_videos v ON v.aweme_id = va.aweme_id WHERE v.account_id=? ORDER BY va.analysis_time DESC LIMIT 3' ).all(acc.id); if (vrows.length) videoRefText = vrows.map((x) => String(x.c || '')).join('\n---\n').slice(0, 3000); } catch (e) { /* 视频解析缺失不阻塞 */ } } db.close(); } catch (e) { /* 库不可用不阻塞,仍可生成 */ } // 2) 读 Dify key:环境变量优先,其次技能脚本内置默认 let apiKey = process.env.DIFY_MCN_CYLG_KEY || ''; if (!apiKey) { const scriptPath = path.join(ROOT, '..', 'scripts', 'MCN_CYLG_API.py'); try { const src = fs.readFileSync(scriptPath, 'utf8'); const m = src.match(/DEFAULT_CYLG_KEY\s*=\s*"([^"]+)"/); if (m) apiKey = m[1]; } catch (e) { /* 脚本不存在则走 env */ } } if (!apiKey) return sendErr(500, '未配置 DIFY_MCN_CYLG_KEY(环境变量或技能脚本内置 KEY)'); // 3) 构造选题 query(参考 references/创作流程/5_生成短视频选题.md 方法论;09-01 追加视频解析参考) const query = '你是短视频选题策划师,请为达人「' + accountName + '」结合账号设定与最新视频内容生成 3 个爆款选题方案。\n\n' + '【达人信息】\n' + (contentText || '(无账号定位信息)') + '\n' + (personaText ? '【人设摘要】\n' + personaText + '\n' : '') + (videoRefText ? '【该账号最近视频解析(选题须参考的真实视频内容)】\n' + videoRefText + '\n' : '') + '\n要求:\n' + '1. 选题要跳出账号核心行为框架,但保留账号结构性符号(固定机制/口头禅等)\n' + '2. 每个选题一句话说清主题+切入角度,标注目标体感(如治愈感/爽感/获得感)\n' + '3. 只输出 3 个选题,每行一个,格式:1. 主题(切入角度|目标体感)\n' + '4. 不要输出其他解释'; // 4) 调 Dify chat-messages(blocking 即时返回) const difyUrl = String(process.env.DIFY_URL || 'https://mydify.youmanvideo.com/v1').replace(/\/+$/, ''); const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), 100000); let resp; try { resp = await fetch(difyUrl + '/chat-messages', { method: 'POST', headers: { 'Authorization': 'Bearer ' + apiKey, 'Content-Type': 'application/json' }, body: JSON.stringify({ inputs: { is_think: '0', is_online: '0', model: process.env.DIFY_MCN_MODEL || 'gemini' }, query, response_mode: 'blocking', user: 'mcn-work-shop' }), signal: ctrl.signal, }); } finally { clearTimeout(timer); } const data = await resp.json().catch(() => ({})); if (!resp.ok) return sendErr(502, 'AI 接口错误: ' + (data.message || resp.status)); log('AI 选题生成: ' + accountName); return dshOk(200, { topics: data.answer || '' }); } catch (e) { return sendErr(500, '选题生成失败: ' + e.message); } }); return; } // ---------- AI 创作需求打磨(09-02 弹窗内对话:Dify chat-messages 同步、无状态多轮) ---------- // body: { accountName, turn(本轮输入), reqMd(当前需求5行md), history(最近轮[{u,a}]) } → { answer } if (p === '/api/ai/clarify' && req.method === 'POST') { let body = ''; req.on('data', (c) => { body += c; if (body.length > 1e6) req.destroy(); }); req.on('end', async () => { try { const { accountName, turn, reqMd, history, attach } = JSON.parse(body || '{}'); if (!accountName || typeof accountName !== 'string') return sendErr(400, '缺少账号名'); if (!turn || typeof turn !== 'string' || !turn.trim()) return sendErr(400, '缺少用户输入'); // 1) 账号设定摘要(工作台库,与选题接口同源) let personaText = '', contentText = ''; try { const { DatabaseSync } = require('node:sqlite'); const db = new DatabaseSync(path.join(ROOT, 'mcn-plugin.db'), { readOnly: true }); const acc = db.prepare('SELECT * FROM hot_accounts WHERE account_name=? LIMIT 1').get(accountName); if (acc) { contentText = String(acc.content || '').slice(0, 300); const p = db.prepare('SELECT content_json, summary FROM account_persona WHERE account_id=? ORDER BY id DESC LIMIT 1').get(acc.id); if (p) personaText = String(p.summary || p.content_json || '').slice(0, 900); } db.close(); } catch (e) { /* 库不可用不阻塞 */ } // 2) Dify key:环境变量优先,其次技能脚本内置默认 let apiKey = process.env.DIFY_MCN_CYLG_KEY || ''; if (!apiKey) { const scriptPath = path.join(ROOT, '..', 'scripts', 'MCN_CYLG_API.py'); try { const src = fs.readFileSync(scriptPath, 'utf8'); const m = src.match(/DEFAULT_CYLG_KEY\s*=\s*"([^"]+)"/); if (m) apiKey = m[1]; } catch (e) { /* 脚本不存在则走 env */ } } if (!apiKey) return sendErr(500, '未配置 DIFY_MCN_CYLG_KEY(环境变量或技能脚本内置 KEY)'); // 3) 构造打磨 query(无状态:携带当前需求 + 最近对话,让 AI 输出 reply + ==REQ== 结构化段) const his = Array.isArray(history) ? history.filter((h) => h && h.u).slice(-2) : []; const histText = his.map((h) => '你:' + String(h.a || '') + '\n达人:' + String(h.u || '')).join('\n'); const reqMdText = String(reqMd || '').trim(); const attachText = String(attach || '').trim().slice(0, 20000); const query = '你是资深短视频编导,正在创作前通过多轮对话,帮达人「' + accountName + '」把创作需求打磨清楚。' + '你的职责只限需求澄清、整理与追问;严禁生成脚本正文、台词、分镜或完整故事。\n\n' + '【账号设定摘要】\n' + (personaText || '(暂无可用账号设定,按通用编导经验澄清)') + '\n' + (contentText ? '【账号定位】\n' + contentText + '\n' : '') + (attachText ? '【附件素材】(达人上传的需求文档/资料,打磨与最终创作都须充分参考;把其中的关键诉求/约束吸收进需求清单)\n' + attachText + '\n' : '') + '【当前需求清单】\n' + (reqMdText || '(空:等待用户先给出初步想法)') + '\n' + (histText ? '【对话历史】\n' + histText + '\n' : '') + '【达人本轮输入】\n' + turn.trim() + '\n\n' + '【行为要求】\n' + '1. 理解本轮输入:可能是新想法、对上一问的回答、或想修改某一维度。将信息吸收进需求清单,可改写得更专业准确,但保留用户原意与账号结构性符号。\n' + '2. 若关键维度仍有缺口且本轮输入不足以补齐:最多追问 1~2 个问题(优先给 A/B/C 选项供挑选),不要一次抛出全部问题,也不要重复已明确的维度。\n' + '3. 若信息已基本齐备:用一两句话总结并提示用户可点击「提交创作」。\n' + '4. 输出必须严格分两段:第一段是对用户说的话(120 字以内,含必要时的问题);\n' + '然后另起一行单独输出 ==REQ==;\n' + '接着输出 5 行需求清单(仍未确定的项留空值,严禁编造;情节/结尾/时长不由本清单承载,由创作阶段按人设与选题自决;共鸣/共情亦不收集,创作时由 AI 依痛点 / 爽点自然发挥),模板:\n' + '- 一句话创意:\n- 创新 / 爆点:\n- 痛点 / 爽点:\n- 叙事结构:\n- 其他约束:\n' + '5. 需求框只收集本条视频的选题相关信息;账号级设定(情绪基调 / 目标受众 / 结构性符号 / 广告植入等)已由账号设定自动带入创作,不要向达人重复询问这些维度——仅当达人主动提出与账号设定不同的要求时,将其记入「其他约束」。'; // 4) Dify blocking 调用 const difyUrl = String(process.env.DIFY_URL || 'https://mydify.youmanvideo.com/v1').replace(/\/+$/, ''); const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), 100000); let resp; try { resp = await fetch(difyUrl + '/chat-messages', { method: 'POST', headers: { 'Authorization': 'Bearer ' + apiKey, 'Content-Type': 'application/json' }, body: JSON.stringify({ inputs: { is_think: '0', is_online: '0', model: process.env.DIFY_MCN_MODEL || 'gemini' }, query, response_mode: 'blocking', user: 'mcn-work-shop' }), signal: ctrl.signal, }); } finally { clearTimeout(timer); } const data = await resp.json().catch(() => ({})); if (!resp.ok) return sendErr(502, 'AI 接口错误: ' + (data.message || resp.status)); log('AI 需求打磨: ' + accountName); return dshOk(200, { answer: data.answer || '' }); } catch (e) { return sendErr(500, '需求打磨失败: ' + e.message); } }); return; } // 需求附件解析(09-02 新增):txt/docx/pdf → 纯文本(供 AI 打磨与创作上下文) if (p === '/api/req/parse-attachment' && req.method === 'POST') { let body = ''; req.on('data', (c) => { body += c; if (body.length > 30e6) req.destroy(); }); // 附件 base64 上限约 22MB 原文件 req.on('end', async () => { try { const { name, data } = JSON.parse(body || '{}'); if (!name || typeof name !== 'string' || !data || typeof data !== 'string') return sendErr(400, '缺少文件参数'); const buf = Buffer.from(data, 'base64'); if (buf.length > 20e6) return sendErr(413, '文件过大(上限 20MB)'); const r = parseAttachment(name, buf); if (r.error) return sendErr(400, r.error); const text = String(r.text || '').slice(0, 30000); // 打磨上下文上限 3 万字符 return dshOk(200, { text, chars: text.length, warn: r.warn || '', sourceChars: r.chars }); } catch (e) { return sendErr(500, '附件解析失败: ' + e.message); } }); return; } // 分析产物同步入库(09-01 新增):扫描账号产出目录 → 写工作台库 mcn-plugin.db if (p === '/api/import/account' && req.method === 'POST') { let body = ''; req.on('data', (c) => { body += c; if (body.length > 1e6) req.destroy(); }); req.on('end', async () => { try { const { account, root } = JSON.parse(body || '{}'); if (!account || typeof account !== 'string') return sendErr(400, '缺少账号名'); const accountDir = resolveAccountDir(account, root); if (!accountDir) return sendErr(404, '账号产出目录不存在: ' + account); const { DatabaseSync } = require('node:sqlite'); const db = new DatabaseSync(path.join(ROOT, 'mcn-plugin.db')); const res = importAccountIntoDb(db, account, accountDir); db.close(); log('分析产物入库: ' + account + ' → ' + JSON.stringify(res)); return dshOk(200, res); } catch (e) { return sendErr(500, '入库失败: ' + e.message); } }); return; } // ---------- 静态文件 ---------- let filePath = path.join(PUBLIC_DIR, p === '/' ? 'index.html' : p); // 防穿越 if (!filePath.startsWith(PUBLIC_DIR)) return sendErr(403, '禁止访问'); fs.stat(filePath, (err, st) => { if (err || !st.isFile()) return sendErr(404, 'Not Found'); res.writeHead(200, { 'Content-Type': mimeOf(filePath), 'Content-Length': st.size, 'Cache-Control': 'no-store' }); fs.createReadStream(filePath).pipe(res); }); }); // ---- 会话自动清理:mcn-workshop 空间超 6 小时无活动的已结束会话自动清除 ---- // 09-01 用户需求:工作台触发(cwd=D:\AgentSkill\mcn-workshop)的 AI 会话任务,超过 6 小时自动清除 // 判定:cwd 含 mcn-workshop + status=completed(执行中一律不删)+ 未删过 + 最后活动时间超过 6 小时 // 方式:软删除(置 deleted_at),客户端左侧会话栏按 deleted_at 过滤即不再显示;可逆安全 const SESSION_TTL_MS = 6 * 3600 * 1000; // 6 小时 const SESSION_CWD_PATTERN = '%mcn-workshop%'; // 与 /api/run 写入的 cwd 一致 const SESSION_CLEAN_INTERVAL_MS = 60 * 60 * 1000; // 每小时检查一次 function cleanupExpiredSessions() { try { const { DatabaseSync } = require('node:sqlite'); const db = new DatabaseSync(process.env.WORKBUDDY_DB || path.join(os.homedir(), '.workbuddy', 'workbuddy.db')); const now = Date.now(); const cutoff = now - SESSION_TTL_MS; const r = db.prepare(` UPDATE sessions SET deleted_at = ? WHERE cwd LIKE ? AND status = 'completed' AND deleted_at IS NULL AND COALESCE(last_activity_at, updated_at, created_at, 0) < ? `).run(now, SESSION_CWD_PATTERN, cutoff); db.close(); if (Number(r.changes) > 0) log(`会话自动清理:已清除 mcn-workshop 超 6 小时无活动的已完成会话 ${r.changes} 条`); } catch (e) { console.error('会话自动清理失败:', e.message); } } cleanupExpiredSessions(); setInterval(cleanupExpiredSessions, SESSION_CLEAN_INTERVAL_MS); // ---- 端口自动避让 ---- function listen(port) { server.once('error', (e) => { if (e.code === 'EADDRINUSE') { log('端口 ' + port + ' 被占用,尝试 ' + (port + 1)); listen(port + 1); } else { console.error('启动失败:', e.message); process.exit(1); } }); server.listen(port, '127.0.0.1', () => { log('MCN热点雷达已启动: http://localhost:' + port); log('默认项目地址: ' + DEFAULT_ROOT + (fs.existsSync(DEFAULT_ROOT) ? '' : '(不存在)')); log('按 Ctrl+C 停止服务'); }); } listen(PORT_BASE);