2026-08-27 12:33:57 +08:00
// MCN工作台 - 本地 Web 服务(零依赖 Node 实现)
// 启动: node server.js → http://localhost:8899
// 功能: 技能导航 + 产出内容浏览(默认读桌面 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' );
const PORT_BASE = 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 ;
}
// ---- 目录树(递归,跳过隐藏项)----
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 ;
}
// ---------- 数据功能(只读工作台自有数据库副本) ----------
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-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-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 ); }
}
2026-08-27 20:43:39 +08:00
// ---------- 提交 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 不能为空' );
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 ();
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 ()) } ` ;
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,next_run_at,scheduled_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 . trim (), 'ACTIVE' , 'once' , now , scheduledAt , '' , 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 ;
}
2026-08-27 12:33:57 +08:00
// ---------- 静态文件 ----------
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 );