2026-09-04 09:59:31 +08:00
// 短视频工作台 - 本地 Web 服务(零依赖 Node 实现)
2026-09-01 09:37:02 +08:00
// 启动: node server.js [端口] → http://localhost:8900(默认;可传参指定端口,如 node server.js 9000)
2026-08-27 12:33:57 +08:00
// 功能: 技能导航 + 产出内容浏览(默认读桌面 MCNSkill项目)+ dsh 数据功能页(账号/视频/脚本/复盘/周榜,只读)
'use strict' ;
const http = require ( 'http' );
const fs = require ( 'fs' );
const path = require ( 'path' );
const os = require ( 'os' );
2026-09-01 10:12:36 +08:00
const { execFileSync } = require ( 'child_process' );
2026-08-27 12:33:57 +08:00
const { URL } = require ( 'url' );
const dsh = require ( './dsh-data' );
2026-09-02 16:31:57 +08:00
const { parseAttachment } = require ( './scripts/attachment-parser' );
2026-08-27 12:33:57 +08:00
2026-09-01 09:37:02 +08:00
// 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 ;
2026-08-27 12:33:57 +08:00
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 ;
}
2026-09-01 10:12:36 +08:00
// ---- 分析产物入库(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 (?,?,?,?,?)` );
2026-09-01 10:34:07 +08:00
const insSource = db . prepare ( `INSERT INTO account_video_source (video_id, aweme_id, analysis_time, content_json, summary, analysis_json) VALUES (?,?,?,?,?,?)` );
2026-09-01 10:12:36 +08:00
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 导入机制处理)
2026-09-01 10:34:07 +08:00
// 字段映射(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 空」
2026-09-01 10:12:36 +08:00
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' );
2026-09-01 10:34:07 +08:00
const analysisJson = fs . existsSync ( anPath ) ? fs . readFileSync ( anPath , 'utf8' ) : null ;
2026-09-01 10:12:36 +08:00
if ( awemeId ) db . prepare ( 'DELETE FROM account_video_source WHERE aweme_id=?' ). run ( awemeId );
2026-09-01 10:34:07 +08:00
insSource . run ( null , awemeId , now , content , '' , analysisJson );
2026-09-01 10:12:36 +08:00
stats . video_source ++ ;
}
2026-09-01 10:34:07 +08:00
// 拆解分析.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 ++ ;
}
2026-09-01 10:12:36 +08:00
}
}
// 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 [];
}
}
2026-08-31 15:27:54 +08:00
// ---- 查找账号设定卡 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 ;
}
2026-08-27 12:33:57 +08:00
// ---- 目录树(递归,跳过隐藏项)----
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 );
}
2026-09-03 17:06:19 +08:00
// ---------- 榜单数据更新任务(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 完全一致,供更新榜单等新入口复用
2026-09-04 17:57:15 +08:00
// 09-04 connectorIdsArr:任务会话要用的「用户连接器」configId 列表(如 ['custom-mcp:myai-mcp-production'])——
// 宿主会话 register(connectorIds) 只注入列出的连接器,空数组=不注入任何连接器(后台任务「找不到 MCP」根因),
// 依赖 MCP 的任务(视频解析等)必须显式挂载;纯脚本类任务(榜单更新)保持 [] 即可
function createOnceAutomation ( name , prompt , skillsArr , connectorIdsArr ) {
2026-09-03 17:06:19 +08:00
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 (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)` )
2026-09-04 17:57:15 +08:00
. run ( id , name , prompt , 'ACTIVE' , 'once' , scheduledAt , nextRunAt , '' , JSON . stringify ([ cwd ]), now , now , JSON . stringify ( Array . isArray ( skillsArr ) ? skillsArr : []), JSON . stringify ( Array . isArray ( connectorIdsArr ) ? connectorIdsArr : []), modelId , 'fullAccess' , ownerUserId , 'confirmed' );
2026-09-03 17:06:19 +08:00
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 ; }
}
2026-08-27 12:33:57 +08:00
// ---- 静态文件 / 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 ;
}
2026-08-31 15:27:54 +08:00
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 ;
}
2026-08-27 12:33:57 +08:00
// ---------- 数据功能(只读工作台自有数据库副本) ----------
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 ); }
}
2026-08-28 18:54:01 +08:00
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 ;
}
2026-09-01 14:31:46 +08:00
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 ;
}
2026-09-04 12:08:47 +08:00
if ( p === '/api/dsh/script-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 { id } = JSON . parse ( body || '{}' );
if ( ! ( Number ( id ) > 0 )) return sendErr ( 400 , '缺少脚本 id' );
const r = dsh . deleteScript ( Number ( id )); /* R58:连坐删除该脚本的诊断复盘与分镜提示词 */
return dshOk ( 200 , { ok : true , deleted : r . deleted });
} catch ( e ) { return sendErr ( 500 , e . message ); }
});
return ;
}
2026-08-27 12:33:57 +08:00
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 });
}
2026-08-31 15:27:54 +08:00
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 });
}
2026-08-27 12:33:57 +08:00
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 ;
2026-09-01 18:33:20 +08:00
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' ) || '' ) }));
2026-08-27 12:33:57 +08:00
}
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 )));
}
2026-09-04 17:57:15 +08:00
if ( p === '/api/dsh/custom-scripts' ) { /* 09-02 v25:原创选题(无视频载体)脚本列表 */
2026-09-02 19:32:19 +08:00
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' ) || '' ) }));
}
2026-09-04 17:57:15 +08:00
if ( p === '/api/dsh/rewrite-stats' ) { /* 09-02 v25: AI写脚本 tab 徽标计数(参考选题/原创选题),可传 account 按账号统计 */
2026-09-02 19:32:19 +08:00
if ( dshGuard ()) return ;
return dshOk ( 200 , dsh . getRewriteStats ( String ( q . get ( 'account' ) || '' )));
}
2026-09-04 17:57:15 +08:00
if ( p === '/api/dsh/custom-script' ) { /* 原创选题脚本全文(查看弹窗) */
2026-09-02 19:32:19 +08:00
if ( dshGuard ()) return ;
try { return dshOk ( 200 , { script : dsh . getCustomScript ( qInt ( 'id' , 0 )) }); }
catch ( e ) { return sendErr ( 404 , e . message ); }
}
2026-09-04 17:57:15 +08:00
if ( p === '/api/dsh/script-save' && req . method === 'POST' ) { /* AI创作任务完成回调写库(参考选题传 videoId;原创选题省略→video_id 空) */
2026-09-02 19:32:19 +08:00
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 ;
}
2026-08-27 12:33:57 +08:00
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 {
2026-09-03 15:00:01 +08:00
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 ) });
2026-08-27 12:33:57 +08:00
return dshOk ( 200 , r );
} catch ( e ) { return sendErr ( 500 , e . message ); }
}
2026-09-03 17:06:19 +08:00
// ---------- 榜单数据更新(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 ;
}
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 {
2026-09-04 17:57:15 +08:00
const { prompt , name , skills , connectorIds } = JSON . parse ( body || '{}' );
2026-08-27 20:43:39 +08:00
if ( ! prompt || typeof prompt !== 'string' || ! prompt . trim ()) return sendErr ( 400 , 'prompt 不能为空' );
2026-09-04 17:57:15 +08:00
// 09-04 连接器级挂载:connector_ids_json 填「用户连接器」configId 数组(如 custom-mcp:myai-mcp-production)。
// 宿主会话 register(connectorIds) 白名单注入;空数组=不注入任何连接器 → 依赖 MCP 的任务会「找不到 MCP」。
// 仅放行合法 configId 形态(connector:/custom-mcp:/裸名),其余丢弃
const connArr = Array . isArray ( connectorIds )
? connectorIds . filter (( c ) => typeof c === 'string' && /^[A-Za-z0-9:_-]{1,120}$/ . test ( c . trim ())). map (( c ) => c . trim ())
: [];
2026-09-01 16:31:40 +08:00
// 09-01 参数级技能挂载:skills_json 是 automations 表原生字段(default '[]'),
2026-09-03 16:43:21 +08:00
// 客户端调度器创建会话时按该字段挂载技能;前端传技能名数组(如 ['短视频工作台'])即完成任务级技能绑定
2026-09-03 11:23:00 +08:00
// 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 验证通过)
2026-09-01 16:31:40 +08:00
const skillsArr = Array . isArray ( skills ) ? skills . filter (( s ) => typeof s === 'string' && s . trim ()). map (( s ) => s . trim ()) : [];
2026-09-01 00:36:02 +08:00
// 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. 若确认本任务实际不需要浏览器(如数据已齐),忽略本条约束,直接跳过` ;
}
2026-08-27 20:43:39 +08:00
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 空间分组
2026-09-01 00:36:02 +08:00
const d = new Date ( Date . now () + 5 * 1000 ); // 未来 5 秒(08-31 根因:客户端只对未来 scheduledAt 补算 next_run_at;写 now=过去时间→不补算→调度器扫不到→卡死)
2026-08-27 20:43:39 +08:00
const pad = ( n ) => String ( n ). padStart ( 2 , '0' );
2026-09-01 00:36:02 +08:00
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=永不拾取(工作台任务卡死根因)
2026-08-27 20:43:39 +08:00
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 : '' ;
2026-09-03 11:23:00 +08:00
// 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' ;
2026-09-01 00:36:02 +08:00
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 (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)` )
2026-09-04 17:57:15 +08:00
. run ( id , name || '工作台任务' , finalPrompt , 'ACTIVE' , 'once' , scheduledAt , nextRunAt , '' , JSON . stringify ([ cwd ]), now , now , JSON . stringify ( skillsArr ), JSON . stringify ( connArr ), modelId , 'fullAccess' , ownerUserId , 'confirmed' );
2026-08-27 20:43:39 +08:00
db . close ();
log ( '已提交 AI 任务: ' + id + ( name ? ' (' + name + ')' : '' ));
return sendJSON ( 200 , { ok : true , id , message : '任务已提交,请在左侧会话栏查看执行' });
} catch ( e ) {
return sendErr ( 500 , '任务提交失败: ' + e . message );
}
});
return ;
}
2026-08-28 18:54:01 +08:00
// 查询 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' ;
2026-08-31 17:47:47 +08:00
else if ( s === 'PENDING_REVIEW' ) state = 'review' ; // 任务停在「待用户确认/已中断」,前端应提示而非假装执行中
2026-09-01 16:18:09 +08:00
else if ([ 'DONE' , 'COMPLETED' , 'SUCCESS' , 'FINISHED' , 'ACCEPTED' ]. includes ( s )) state = 'done' ; // ACCEPTED=客户端调度器终态(成功)
2026-08-28 18:54:01 +08:00
else if ([ 'ERROR' , 'CANCELLED' , 'FAILED' , 'INTERRUPTED' ]. includes ( s )) state = 'error' ;
2026-08-31 17:47:47 +08:00
else state = 'pending' ; // ACCEPTED / QUEUED / PENDING 等 = 排队等待执行
2026-08-28 18:54:01 +08:00
}
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 , '缺少账号名' );
2026-09-01 10:12:36 +08:00
// 1) 查账号定位 + 人设摘要 + 最新视频解析(工作台库)
let contentText = '' , personaText = '' , videoRefText = '' ;
2026-08-28 18:54:01 +08:00
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 );
2026-09-01 10:12:36 +08:00
// 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 ) { /* 视频解析缺失不阻塞 */ }
2026-08-28 18:54:01 +08:00
}
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)' );
2026-09-01 10:12:36 +08:00
// 3) 构造选题 query(参考 references/创作流程/5_生成短视频选题.md 方法论;09-01 追加视频解析参考)
const query = '你是短视频选题策划师,请为达人「' + accountName + '」结合账号设定与最新视频内容生成 3 个爆款选题方案。\n\n'
2026-08-28 18:54:01 +08:00
+ '【达人信息】\n' + ( contentText || '(无账号定位信息)' ) + '\n'
+ ( personaText ? '【人设摘要】\n' + personaText + '\n' : '' )
2026-09-01 10:12:36 +08:00
+ ( videoRefText ? '【该账号最近视频解析(选题须参考的真实视频内容)】\n' + videoRefText + '\n' : '' )
2026-08-28 18:54:01 +08:00
+ '\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 ;
}
2026-09-02 16:00:46 +08:00
// ---------- AI 创作需求打磨(09-02 弹窗内对话:Dify chat-messages 同步、无状态多轮) ----------
2026-09-02 18:53:22 +08:00
// body: { accountName, turn(本轮输入), reqMd(当前需求5行md), history(最近轮[{u,a}]) } → { answer }
2026-09-02 16:00:46 +08:00
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 {
2026-09-02 16:31:57 +08:00
const { accountName , turn , reqMd , history , attach } = JSON . parse ( body || '{}' );
2026-09-02 16:00:46 +08:00
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 ();
2026-09-02 16:31:57 +08:00
const attachText = String ( attach || '' ). trim (). slice ( 0 , 20000 );
2026-09-02 16:00:46 +08:00
const query = '你是资深短视频编导,正在创作前通过多轮对话,帮达人「' + accountName + '」把创作需求打磨清楚。'
+ '你的职责只限需求澄清、整理与追问;严禁生成脚本正文、台词、分镜或完整故事。\n\n'
+ '【账号设定摘要】\n' + ( personaText || '(暂无可用账号设定,按通用编导经验澄清)' ) + '\n'
+ ( contentText ? '【账号定位】\n' + contentText + '\n' : '' )
2026-09-02 16:31:57 +08:00
+ ( attachText ? '【附件素材】(达人上传的需求文档/资料,打磨与最终创作都须充分参考;把其中的关键诉求/约束吸收进需求清单)\n' + attachText + '\n' : '' )
2026-09-02 16:00:46 +08:00
+ '【当前需求清单】\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'
2026-09-02 19:07:15 +08:00
+ '接着输出 5 行需求清单(仍未确定的项留空值,严禁编造;情节/结尾/时长不由本清单承载,由创作阶段按人设与选题自决;共鸣/共情亦不收集,创作时由 AI 依痛点 / 爽点自然发挥),模板:\n'
+ '- 一句话创意:\n- 创新 / 爆点:\n- 痛点 / 爽点:\n- 叙事结构:\n- 其他约束:\n'
2026-09-02 18:22:28 +08:00
+ '5. 需求框只收集本条视频的选题相关信息;账号级设定(情绪基调 / 目标受众 / 结构性符号 / 广告植入等)已由账号设定自动带入创作,不要向达人重复询问这些维度——仅当达人主动提出与账号设定不同的要求时,将其记入「其他约束」。' ;
2026-09-02 16:00:46 +08:00
// 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 ;
}
2026-09-02 16:31:57 +08:00
// 需求附件解析(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 ;
}
2026-09-01 10:12:36 +08:00
// 分析产物同步入库(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 ;
}
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 );
});
});
2026-09-01 14:13:17 +08:00
// ---- 会话自动清理: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 );
2026-08-27 12:33:57 +08:00
// ---- 端口自动避让 ----
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' , () => {
2026-09-04 09:59:31 +08:00
log ( '短视频工作台已启动: http://localhost:' + port );
2026-08-27 12:33:57 +08:00
log ( '默认项目地址: ' + DEFAULT_ROOT + ( fs . existsSync ( DEFAULT_ROOT ) ? '' : '(不存在)' ));
log ( '按 Ctrl+C 停止服务' );
});
}
listen ( PORT_BASE );