Files
mcn-short-video/project/短视频脚本创作/V1.0/mcn-work-shop/server.js
T
maogeigei 24886338d1 09-01 调度机制修复落地+并发方案A+全链路复测通过
- 调度根因修复:工作台/api/run 显式写 next_run_at=now+5s(17列),scheduledAt=now+5s 带秒
- 新增 自动化任务调度机制.md(根因/硬规则/状态机/并发上限≈3/排查命令)
- 并发方案A(浏览器锁):/api/run 对浏览器类任务自动注入锁约束;浏览器搜索抖音账号操作规范.md 新增5.6并发互斥
- 端口 8899→8900 可传参(8899被小米MiPCAudio.exe系统服务占用且杀后复活)
- cdp-test-junxi.mjs 支持 --port 参数
- SKILL.md 工作台段落同步修正(旧认知 scheduled_at=now 已废弃)
- 俊希全流程 TC-01~11 复测全通过(验收表更新):并行解析6条+AI写脚本会话链路完整跑通
2026-09-01 00:36:02 +08:00

528 lines
24 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// MCN工作台 - 本地 Web 服务(零依赖 Node 实现)
// 启动: node server.js [端口] → http://localhost:8899(默认;可传参指定端口,如 node server.js 8900)
// 功能: 技能导航 + 产出内容浏览(默认读桌面 MCNSkill项目)+ dsh 数据功能页(账号/视频/脚本/复盘/周榜,只读)
'use strict';
const http = require('http');
const fs = require('fs');
const path = require('path');
const os = require('os');
const { URL } = require('url');
const dsh = require('./dsh-data');
// 09-01 端口改为可传参(默认8899):本机 8899 可能被小米 PC 管家 MiPCAudio.exe 系统服务占用(0.0.0.0 监听且自动复活),
// 此时 127.0.0.1:8899 绑定不生效 → 用 `node server.js 8900` 显式换端口
const PORT_BASE = parseInt(process.argv[2], 10) || 8899;
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;
}
// ---- 查找账号设定卡 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);
}
// ---- 静态文件 / 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 头):供 <img>/<object> 直接渲染(如账号设定卡 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/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') || '') }));
}
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/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({ date: String(q.get('date') || ''), category: String(q.get('category') || ''), sortBy: String(q.get('sortBy') || 'rank'), order: String(q.get('order') || ''), page: qInt('page', 1), pageSize: qInt('pageSize', 20) });
return dshOk(200, r);
} catch (e) { return sendErr(500, e.message); }
}
// ---------- 提交 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 } = JSON.parse(body || '{}');
if (!prompt || typeof prompt !== 'string' || !prompt.trim()) return sendErr(400, 'prompt 不能为空');
// 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 : '';
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, '[]', '[]', 'deepseek-v4-flash', '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'].includes(s)) state = 'done';
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 = '';
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);
}
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 方法论)
const query = '你是短视频选题策划师,请为达人「' + accountName + '」结合账号设定生成 3 个爆款选题方案。\n\n'
+ '【达人信息】\n' + (contentText || '(无账号定位信息)') + '\n'
+ (personaText ? '【人设摘要】\n' + personaText + '\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;
}
// ---------- 静态文件 ----------
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);
});
});
// ---- 端口自动避让 ----
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);