本提交为 dsh_mcn_workshop 仓库的首个提交(旧仓 dsh_MCNProject 已停用), 完整固化 C:/Users/Administrator/.dsh 当前磁盘状态。 1) 临时数据清理:移出 sessions/(81M)、logs/、skill-backup-20260906/(14M)、 scripts/__pycache__、storages/workspace.json.bak —— 合计 94M(.dsh 162M → 68M) 2) 凭据脱敏:.credentials.yaml(含明文 DEEPSEEK_API_KEY)移出 git 跟踪并加入 .gitignore;mcn-data-insight/scripts/setup_redfox_key.py 中真实 key 示例改为占位符 3) .gitignore 增补 logs/、skill-backup-*/ 规则 4) 技能结构以当前磁盘状态为准:skills/ = mcn-short-video / impeccable / taste-skill / dsh-multi-user-migration;旧技能目录(mcn-dou-analysis、storyboard-prompt、 short-video-script、browser-harness、mcn-data-insight)与 profiles/web/node_modules/dsh-vision-router 本地已不存在,本提交记为删除
3096 lines
177 KiB
JavaScript
3096 lines
177 KiB
JavaScript
// dsh-plugin-mcn 宿主端:MCN 抖音账号列表数据层
|
||
// - 本地 SQLite 缓存(node:sqlite,Node 24 内置)
|
||
// - 首次/更新时通过 stdio MCP 协议从 myai-mcp 拉取 list_hot_accounts
|
||
// - 注册 /mcn/api/* HTTP 路由供客户端查询
|
||
// 模块结构(拆分后):
|
||
// config.js 常量 · db.js SQLite · mcp.js MCP 客户端 · data.js 数据访问 · http.js 路由工具
|
||
// video-tools.js 匹配工具 · imports.js 外部导入 · index.js 任务管理 + 路由注册
|
||
import { createUserMessage } from "@deepseek-ai/dsh-llm";
|
||
import { homedir } from "node:os";
|
||
import { join, resolve, dirname } from "node:path";
|
||
import { readFileSync, mkdirSync, readdirSync, existsSync, statSync, rmSync, writeFileSync } from "node:fs";
|
||
import { spawn } from "node:child_process";
|
||
import { zstdDecompressSync } from "node:zlib";
|
||
import {
|
||
STATS_TTL, CREATIVE_SESSION_ID, CREATIVE_PROVIDER, CREATIVE_MODEL, CHROME_PORTS,
|
||
IMPORT_PREFIX, REFRESH_PREFIX, VIDEO_ANALYZE_PREFIX, REWRITE_PREFIX, PERSONA_PREFIX, STORYBOARD_PREFIX, REVIEW_PREFIX, ACCT_ANALYSIS_PREFIX,
|
||
VIDEO_ANALYZE_MAX_CONCURRENT, REWRITE_MAX_CONCURRENT, STORYBOARD_MAX_CONCURRENT, REVIEW_MAX_CONCURRENT, MAX_UNGROUPED,
|
||
SESSION_ACTIVE_WINDOW_MS, SESSION_RETENTION_MS, resolveCreativeCwd,
|
||
RANK_UPDATE_COOLDOWN_MS, RANK_SESSION_ID, RANK_META_FILE, RANK_TASK_MAX_MS,
|
||
} from "./config.js";
|
||
import { initDb, extractScriptTitle } from "./db.js";
|
||
import { ensureData, refreshData, queryAccounts, listTracks, queryAccountVideos } from "./data.js";
|
||
import { parseQuery, json, readBody } from "./http.js";
|
||
import {
|
||
isInternalAccount,
|
||
readVideoSource, readVideoParse, backfillVideoSource,
|
||
importExternalDir, importExternalData, previewExternalDir,
|
||
} from "./imports.js";
|
||
import {
|
||
getRanking, rankingFreshness, listCustomScripts, getCustomScriptRow, countRewriteStats, batchDeleteCascade,
|
||
} from "./workshop.js";
|
||
|
||
// 数据预览统计缓存(60s TTL,避免每次从数据库刷新)
|
||
let statsCache = { ts: 0, data: null };
|
||
|
||
//#region 创作会话
|
||
async function composeDefaultPreset(ctx) {
|
||
const presets = ctx.get("agentPresets");
|
||
if (!presets) return { setup: () => Promise.resolve() };
|
||
let resolvedId;
|
||
// 优先 MCN 模式(标准能力 + myai MCP 工具:视频解析/账号设定/改写等会话需要 MCP);缺失时回退默认 preset
|
||
try {
|
||
resolvedId = (await presets.resolve("mcn")).id;
|
||
} catch (e) {
|
||
try {
|
||
resolvedId = (await presets.resolve(undefined)).id;
|
||
} catch (e2) {
|
||
return { setup: () => Promise.resolve() };
|
||
}
|
||
}
|
||
return {
|
||
agentPreset: resolvedId,
|
||
setup: async (agentCtx) => { await presets.mount(agentCtx, resolvedId); },
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 获取(或创建)创作专用会话 agent。
|
||
* 默认会话名 dsh_short-video-script;可传自定义 sessionId(如对标创作 dsh_benchmark-<videoId>),
|
||
* 已存在则复用,否则新建(指定 cwd 归入工作区,重启后仍在会话列表显示)。
|
||
*/
|
||
async function getOrCreateCreativeAgent(ctx, sessionId = CREATIVE_SESSION_ID) {
|
||
const existing = ctx.agents.get(sessionId);
|
||
if (existing !== undefined) {
|
||
return { agent: existing, created: false };
|
||
}
|
||
const agentOptions = {
|
||
provider: CREATIVE_PROVIDER,
|
||
model: CREATIVE_MODEL,
|
||
};
|
||
const compose = await composeDefaultPreset(ctx);
|
||
try {
|
||
// 磁盘有日志:resume 恢复历史(重启后会话不 live 时走这里,避免 create 触发 id collision)
|
||
const handle = await ctx.agents.resume({
|
||
resumeSessionId: sessionId,
|
||
agentOptions,
|
||
setup: compose.setup,
|
||
});
|
||
return { agent: handle.agent, created: false };
|
||
} catch (e) {
|
||
// resume 失败(如无磁盘日志)→ 新建
|
||
const handle = await ctx.agents.create({
|
||
sessionId,
|
||
// 必须指定 provider/model,否则 deployment:persona 的 {{model}} 变量无值
|
||
agentOptions,
|
||
// 必须指定 cwd,否则会话无归属目录,DSH 重启后从会话列表消失(仅存于 _no-cwd)
|
||
meta: {
|
||
cwd: resolveCreativeCwd(),
|
||
agentPreset: compose.agentPreset,
|
||
},
|
||
// 挂载默认 preset:获得文件/shell/skill 工具(否则工具集为空)
|
||
setup: compose.setup,
|
||
});
|
||
return { agent: handle.agent, created: true };
|
||
}
|
||
}
|
||
|
||
//#endregion
|
||
|
||
//#region 外部账号导入/刷新(AI 分析确认链路,每任务独立会话支持并发)
|
||
//#region 外部账号导入/刷新(AI 分析确认链路,每任务独立会话支持并发)
|
||
|
||
/** 简单字符串 hash(djb2),用于生成会话 key */
|
||
function hashKey(s) {
|
||
let h = 5381;
|
||
for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) >>> 0;
|
||
return h.toString(16).padStart(8, "0");
|
||
}
|
||
|
||
/** 并发浏览器任务隔离:调试 Chrome 端口池(需预启动对应实例),按会话 id 稳定分配。
|
||
* 导入/刷新等浏览器类任务各自使用独立端口,避免多个 AI 会话抢同一 Chrome。 */
|
||
|
||
function chromePortFor(sessionId) {
|
||
let h = 5381;
|
||
for (let i = 0; i < sessionId.length; i++) h = ((h << 5) + h + sessionId.charCodeAt(i)) >>> 0;
|
||
return CHROME_PORTS[h % CHROME_PORTS.length];
|
||
}
|
||
|
||
/** 浏览器操作提示(注入 prompt):固定使用 9223 调试 Chrome(浏览器任务已串行,无需多端口隔离;
|
||
* 避免每次任务换端口导致冷启动新 Chrome 窗口、操作出错);优先复用已打开标签页。 */
|
||
function chromeHint(sessionId) {
|
||
const port = 9223;
|
||
return `浏览器操作:本任务使用**调试 Chrome 实例**(固定端口 ${port})。\n`
|
||
+ `执行前确认该端口实例可用(http://127.0.0.1:${port}/json/version);若未启动,用 pwsh 启动:Start-Process "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe" -ArgumentList "--remote-debugging-port=${port}","--user-data-dir=C:\\Users\\$env:USERNAME\\.chrome-dbg-${port}","about:blank"\n`
|
||
+ `然后设置 $env:BU_CDP_URL="http://127.0.0.1:${port}",并清除代理环境变量(Remove-Item Env:HTTP_PROXY,Env:HTTPS_PROXY,Env:ALL_PROXY -ErrorAction SilentlyContinue; $env:NO_PROXY="*")后再调用 browser-harness。\n`
|
||
+ `**标签页复用**:操作前先检查已打开的标签页(cdp("Target.getTargets", flatten=True)),有已打开的抖音标签页直接 goto_url 导航复用,不要每次 new_tab 新建;无标签页时才 new_tab 新建;任务完成保留标签页供后续任务复用(详见技能 references/浏览器搜索抖音账号操作规范.md 5.5)。\n`;
|
||
}
|
||
|
||
/** 导入/刷新任务注册表:会话 key -> 提交时间戳(每任务独立会话,不同任务互不阻塞) */
|
||
const importTasks = new Map();
|
||
|
||
/**
|
||
* 计算不与「已归档会话 id」冲突的自增 id(rewrite/storyboard 用):
|
||
* 会话 id = 前缀 + 自增 id(如 dsh_rewrite-{logId})。SQLite 删除最大行后自增 id 会被复用,
|
||
* 而归档状态按会话 id 记录——复用的新会话会继承旧会话的归档状态,导致侧边栏不显示。
|
||
* 这里在插入前跳过 archivedSessionIds 中已占用的会话 id(与「解析抖音账号」会话用内容 hash
|
||
* 永不冲突的思路等价:保证新任务会话 id 从未被归档过,从而正常显示在侧边栏)。
|
||
* @param {import("node:sqlite").DatabaseSync} d
|
||
* @param {any} ctx
|
||
* @param {string} prefix 会话前缀(REWRITE_PREFIX / STORYBOARD_PREFIX)
|
||
* @param {string} table 自增 id 来源表(rewrite_log / storyboard_log)
|
||
* @returns {number} 不与归档会话 id 冲突的可用 id
|
||
*/
|
||
function nextTaskIdAvoidingArchived(d, ctx, prefix, table) {
|
||
const row = d.prepare(`SELECT COALESCE(MAX(id), 0) AS m FROM ${table}`).get();
|
||
let id = Number(row.m) + 1;
|
||
// 归档集(workspaceRegistry 内存态为准,缺失时退化为不跳过——仅影响侧边栏显示,不影响任务执行)
|
||
const reg = ctx.get("workspaceRegistry");
|
||
if (!reg) return id;
|
||
const archived = new Set(reg.archivedSessionIds);
|
||
while (archived.has(prefix + id)) id++;
|
||
return id;
|
||
}
|
||
|
||
/** 获取(或创建)导入/刷新专用会话 agent(每任务一个会话:不同账号/目录可并发;同一 key 重复提交复用同一会话)。
|
||
* 会话创建于「解析任务」工作区(cwd = {主工作区}/解析任务),并 attach 到该工作区分组。 */
|
||
async function getOrCreateImportAgent(ctx, sessionKey) {
|
||
const sessionId = IMPORT_PREFIX + sessionKey;
|
||
const existing = ctx.agents.get(sessionId);
|
||
if (existing !== undefined) {
|
||
await attachAccountAnalyzeSession(ctx, sessionId);
|
||
return { agent: existing, created: false };
|
||
}
|
||
const agentOptions = {
|
||
provider: CREATIVE_PROVIDER,
|
||
model: CREATIVE_MODEL,
|
||
};
|
||
const compose = await composeDefaultPreset(ctx);
|
||
const cwd = resolvePersonaCwd();
|
||
try {
|
||
const handle = await ctx.agents.resume({
|
||
resumeSessionId: sessionId,
|
||
agentOptions,
|
||
setup: compose.setup,
|
||
});
|
||
return { agent: handle.agent, created: false };
|
||
} catch (e) {
|
||
const handle = await ctx.agents.create({
|
||
sessionId,
|
||
agentOptions,
|
||
meta: { cwd, agentPreset: compose.agentPreset },
|
||
setup: compose.setup,
|
||
});
|
||
return { agent: handle.agent, created: true };
|
||
} finally {
|
||
await attachAccountAnalyzeSession(ctx, sessionId);
|
||
}
|
||
}
|
||
|
||
/** 刷新专用会话(key = accountId) */
|
||
async function getOrCreateRefreshAgent(ctx, accountId) {
|
||
const sessionId = REFRESH_PREFIX + accountId;
|
||
const existing = ctx.agents.get(sessionId);
|
||
if (existing !== undefined) {
|
||
await attachAccountAnalyzeSession(ctx, sessionId);
|
||
return { agent: existing, created: false };
|
||
}
|
||
const agentOptions = {
|
||
provider: CREATIVE_PROVIDER,
|
||
model: CREATIVE_MODEL,
|
||
};
|
||
const compose = await composeDefaultPreset(ctx);
|
||
const cwd = resolvePersonaCwd();
|
||
try {
|
||
const handle = await ctx.agents.resume({
|
||
resumeSessionId: sessionId,
|
||
agentOptions,
|
||
setup: compose.setup,
|
||
});
|
||
return { agent: handle.agent, created: false };
|
||
} catch (e) {
|
||
const handle = await ctx.agents.create({
|
||
sessionId,
|
||
agentOptions,
|
||
meta: { cwd, agentPreset: compose.agentPreset },
|
||
setup: compose.setup,
|
||
});
|
||
return { agent: handle.agent, created: true };
|
||
} finally {
|
||
await attachAccountAnalyzeSession(ctx, sessionId);
|
||
}
|
||
}
|
||
|
||
/** 当前导入/刷新任务列表(供前端轮询:并发多任务各自状态;key 前缀 "refresh:" 对应 dsh_refresh-{id} 会话) */
|
||
function listImportTasks(ctx) {
|
||
const out = [];
|
||
for (const [key, submittedAt] of importTasks) {
|
||
if (key.startsWith("refresh:")) {
|
||
const accountId = key.slice("refresh:".length);
|
||
const a = ctx.agents.get(REFRESH_PREFIX + accountId);
|
||
out.push({ id: key, status: a !== undefined ? a.status : "idle", submittedAt });
|
||
} else {
|
||
const a = ctx.agents.get(IMPORT_PREFIX + key);
|
||
out.push({ id: key, status: a !== undefined ? a.status : "idle", submittedAt });
|
||
}
|
||
}
|
||
while (out.length > 200) out.shift();
|
||
return out;
|
||
}
|
||
|
||
/** 浏览器类任务判定:刷新(refresh:*)与账号网页导入(acc:*)操作本地调试 Chrome,串行执行避免抢浏览器;
|
||
* dir:* 目录导入读本地文件+AI 分析,不涉及浏览器,可并行。 */
|
||
function isBrowserTaskKey(key) {
|
||
return key.startsWith("refresh:") || key.startsWith("acc:");
|
||
}
|
||
|
||
/** 是否有浏览器类任务正在运行(全局互斥锁:同一时刻只允许一个浏览器任务) */
|
||
function browserTaskRunning(ctx) {
|
||
for (const [key] of importTasks) {
|
||
if (!isBrowserTaskKey(key)) continue;
|
||
const sessionId = key.startsWith("refresh:")
|
||
? REFRESH_PREFIX + key.slice("refresh:".length)
|
||
: IMPORT_PREFIX + key;
|
||
const a = ctx.agents.get(sessionId);
|
||
if (a !== undefined && a.status === "running") return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/** 视频解析任务注册表:videoId -> 提交时间戳。用于 task/status 枚举与同视频重复提交保护(重启后为空,会话按 videoId 幂等恢复) */
|
||
const videoAnalyzeTasks = new Map();
|
||
|
||
/** 视频解析会话工作目录:{主工作区}/解析任务(与解析任务共用「解析任务」目录 → 同一工作区,会话归「解析任务」分组) */
|
||
function resolveVideoAnalyzeCwd() {
|
||
return join(resolveCreativeCwd(), "解析任务");
|
||
}
|
||
|
||
let videoAnalyzeWorkspace = null;
|
||
|
||
/**
|
||
* 确保「视频解析」工作区存在(幂等):目录不存在则创建;registry.create 按 canonical path 复用已有记录。
|
||
* 会话 cwd 与该目录一致后 attachSession 才能通过校验,从而在会话列表归入「视频解析」分组。
|
||
* workspaceRegistry 服务不可用时降级返回 null(会话不分组,保持未分组行为)。
|
||
*/
|
||
async function ensureVideoAnalyzeWorkspace(ctx) {
|
||
const reg = ctx.get("workspaceRegistry");
|
||
if (!reg) return null;
|
||
if (videoAnalyzeWorkspace) {
|
||
// 缓存失效兜底(服务重建后实体可能已替换)
|
||
if (reg.get(videoAnalyzeWorkspace.id) !== undefined) return videoAnalyzeWorkspace;
|
||
videoAnalyzeWorkspace = null;
|
||
}
|
||
const dir = resolveVideoAnalyzeCwd();
|
||
try {
|
||
mkdirSync(dir, { recursive: true });
|
||
} catch (e) {
|
||
console.warn(`[dsh-plugin-mcn] 视频解析目录创建失败: ${e.message}`);
|
||
return null;
|
||
}
|
||
try {
|
||
videoAnalyzeWorkspace = await reg.create(dir, "解析任务"); // title「解析任务」(与解析任务归组)
|
||
if (videoAnalyzeWorkspace.title !== "解析任务") {
|
||
try { await videoAnalyzeWorkspace.setTitle("解析任务"); } catch (e) { /* 更名失败不阻塞 */ }
|
||
}
|
||
return videoAnalyzeWorkspace;
|
||
} catch (e) {
|
||
console.warn(`[dsh-plugin-mcn] 「视频解析」工作区创建失败: ${e.message}`);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/** 获取(或创建)视频解析专用会话 agent(每视频一个会话:与导入/刷新解耦互不阻塞,支持并发解析多个视频;同一视频重复解析复用同一会话)。
|
||
* 会话创建于「视频解析」工作区(cwd = {主工作区}/视频解析),并 attach 到该工作区分组。 */
|
||
async function getOrCreateVideoAnalyzeAgent(ctx, videoId) {
|
||
const sessionId = VIDEO_ANALYZE_PREFIX + videoId;
|
||
const existing = ctx.agents.get(sessionId);
|
||
if (existing !== undefined) return { agent: existing, created: false };
|
||
const agentOptions = {
|
||
provider: CREATIVE_PROVIDER,
|
||
model: CREATIVE_MODEL,
|
||
};
|
||
const compose = await composeDefaultPreset(ctx);
|
||
const cwd = resolveVideoAnalyzeCwd();
|
||
try {
|
||
const handle = await ctx.agents.resume({
|
||
resumeSessionId: sessionId,
|
||
agentOptions,
|
||
setup: compose.setup,
|
||
});
|
||
return { agent: handle.agent, created: false };
|
||
} catch (e) {
|
||
const handle = await ctx.agents.create({
|
||
sessionId,
|
||
agentOptions,
|
||
meta: { cwd, agentPreset: compose.agentPreset },
|
||
setup: compose.setup,
|
||
});
|
||
return { agent: handle.agent, created: true };
|
||
} finally {
|
||
// 归入「视频解析」工作区分组(幂等;旧会话 cwd 不匹配时 attach 抛错,仅告警不影响解析)
|
||
const ws = await ensureVideoAnalyzeWorkspace(ctx);
|
||
if (ws && !ws.sessionIds.includes(sessionId)) {
|
||
try {
|
||
await ws.attachSession(sessionId);
|
||
console.log(`[dsh-plugin-mcn] 会话 ${sessionId} 已归入「视频解析」分组`);
|
||
} catch (attachErr) {
|
||
console.warn(`[dsh-plugin-mcn] 会话 ${sessionId} 归组失败(cwd 与工作区不匹配): ${attachErr.message}`);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/** 当前视频解析任务列表(供前端轮询:并发多视频各自状态) */
|
||
function listVideoAnalyzeTasks(ctx) {
|
||
const out = [];
|
||
for (const [videoId, submittedAt] of videoAnalyzeTasks) {
|
||
const a = ctx.agents.get(VIDEO_ANALYZE_PREFIX + videoId);
|
||
out.push({ videoId, status: a !== undefined ? a.status : "idle", submittedAt });
|
||
}
|
||
// 数量上限保护(FIFO 裁剪,防无限增长)
|
||
while (out.length > 200) out.shift();
|
||
return out;
|
||
}
|
||
|
||
/** 改写任务注册表:logId -> 提交时间戳 */
|
||
const rewriteTasks = new Map(); // logId -> 提交时间戳
|
||
|
||
/** 当前运行中的视频改写任务数 */
|
||
function runningRewriteCount(ctx) {
|
||
let n = 0;
|
||
for (const [logId] of rewriteTasks) {
|
||
const a = ctx.agents.get(REWRITE_PREFIX + logId);
|
||
if (a !== undefined && a.status === "running") n++;
|
||
}
|
||
return n;
|
||
}
|
||
|
||
/** 改写用账号设定:去掉人设卡末尾两个二级段落(「当前需解决的问题」「人设底线」——运营向内容,与脚本创作无关) */
|
||
function trimPersonaForRewrite(text) {
|
||
const lines = String(text || "").split(/\r?\n/);
|
||
const h2 = [];
|
||
lines.forEach((l, i) => { if (/^##\s+\S/.test(l.trim())) h2.push(i); });
|
||
if (h2.length >= 2) {
|
||
const cut = h2[h2.length - 2];
|
||
return lines.slice(0, cut).join("\n").trimEnd();
|
||
}
|
||
return String(text || "").trim();
|
||
}
|
||
|
||
/** 改写会话工作目录:{主工作区}/创作任务(与复盘共用「创作任务」目录 → 同一工作区,会话归「创作任务」分组) */
|
||
function resolveRewriteCwd() {
|
||
return join(resolveCreativeCwd(), "创作任务");
|
||
}
|
||
|
||
/** 改写工作区实体缓存(workspaceRegistry 服务不可用时为 null,会话不归组) */
|
||
let rewriteWorkspace = null;
|
||
|
||
/** 确保「改写脚本」工作区存在(幂等):会话 cwd 与该目录一致后 attachSession 才能通过校验,
|
||
* 从而在会话列表归入「改写脚本」分组(方案 A:改写会话可见)。 */
|
||
async function ensureRewriteWorkspace(ctx) {
|
||
const reg = ctx.get("workspaceRegistry");
|
||
if (!reg) return null;
|
||
if (rewriteWorkspace) {
|
||
if (reg.get(rewriteWorkspace.id) !== undefined) return rewriteWorkspace;
|
||
rewriteWorkspace = null;
|
||
}
|
||
const dir = resolveRewriteCwd();
|
||
try {
|
||
mkdirSync(dir, { recursive: true });
|
||
} catch (e) {
|
||
console.warn(`[dsh-plugin-mcn] 改写脚本目录创建失败: ${e.message}`);
|
||
return null;
|
||
}
|
||
try {
|
||
rewriteWorkspace = await reg.create(dir, "创作任务"); // title「创作任务」(与复盘归组)
|
||
if (rewriteWorkspace.title !== "创作任务") {
|
||
try { await rewriteWorkspace.setTitle("创作任务"); } catch (e) { /* 更名失败不阻塞 */ }
|
||
}
|
||
return rewriteWorkspace;
|
||
} catch (e) {
|
||
console.warn(`[dsh-plugin-mcn] 「改写脚本」工作区创建失败: ${e.message}`);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/** 获取(或创建)改写会话:dsh_rewrite-{logId},每个改写任务独立会话(可 8 并发),归入「改写脚本」工作区分组 */
|
||
async function getOrCreateRewriteAgent(ctx, logId) {
|
||
const sessionId = REWRITE_PREFIX + logId;
|
||
const existing = ctx.agents.get(sessionId);
|
||
if (existing !== undefined) return { agent: existing, created: false };
|
||
const agentOptions = { provider: CREATIVE_PROVIDER, model: CREATIVE_MODEL };
|
||
const compose = await composeDefaultPreset(ctx);
|
||
const cwd = resolveRewriteCwd();
|
||
try {
|
||
const handle = await ctx.agents.resume({ resumeSessionId: sessionId, agentOptions, setup: compose.setup });
|
||
return { agent: handle.agent, created: false };
|
||
} catch (e) {
|
||
const handle = await ctx.agents.create({ sessionId, agentOptions, meta: { cwd, agentPreset: compose.agentPreset }, setup: compose.setup });
|
||
return { agent: handle.agent, created: true };
|
||
} finally {
|
||
// 归入「改写脚本」工作区分组(幂等;旧 cwd 会话 attach 抛错仅告警)
|
||
try {
|
||
const ws = await ensureRewriteWorkspace(ctx);
|
||
if (ws && !ws.sessionIds.includes(sessionId)) await ws.attachSession(sessionId);
|
||
} catch (e) { /* 归组失败不影响改写 */ }
|
||
}
|
||
}
|
||
|
||
/** 分镜提示词任务注册表:storyboardId -> 提交时间戳 */
|
||
const storyboardTasks = new Map();
|
||
|
||
/** 分镜提示词会话工作目录:{主工作区}/分镜提示词(与改写脚本同级,不注册为工作区 → 会话显示在「未分组」) */
|
||
function resolveStoryboardCwd() {
|
||
return join(resolveCreativeCwd(), "分镜提示词");
|
||
}
|
||
|
||
/** 获取(或创建)分镜提示词会话:dsh_storyboard-{logId},每个生成任务独立会话(可 8 并发),不归入工作区分组 */
|
||
async function getOrCreateStoryboardAgent(ctx, logId) {
|
||
const sessionId = STORYBOARD_PREFIX + logId;
|
||
const existing = ctx.agents.get(sessionId);
|
||
if (existing !== undefined) return { agent: existing, created: false };
|
||
const agentOptions = { provider: CREATIVE_PROVIDER, model: CREATIVE_MODEL };
|
||
const compose = await composeDefaultPreset(ctx);
|
||
const cwd = resolveStoryboardCwd();
|
||
// 会话工作目录必须存在:agent 内工具(pwsh/glob 等)以 cwd 为工作目录 spawn,目录缺失会 ENOENT
|
||
try { mkdirSync(cwd, { recursive: true }); } catch (e) { /* 目录创建失败不阻塞,工具可能受影响 */ }
|
||
try {
|
||
const handle = await ctx.agents.resume({ resumeSessionId: sessionId, agentOptions, setup: compose.setup });
|
||
return { agent: handle.agent, created: false };
|
||
} catch (e) {
|
||
const handle = await ctx.agents.create({ sessionId, agentOptions, meta: { cwd, agentPreset: compose.agentPreset }, setup: compose.setup });
|
||
return { agent: handle.agent, created: true };
|
||
}
|
||
}
|
||
|
||
/** 当前运行中的分镜提示词任务数 */
|
||
function runningStoryboardCount(ctx) {
|
||
let n = 0;
|
||
for (const [id] of storyboardTasks) {
|
||
const a = ctx.agents.get(STORYBOARD_PREFIX + id);
|
||
if (a !== undefined && a.status === "running") n++;
|
||
}
|
||
return n;
|
||
}
|
||
|
||
/** 当前运行中的视频解析任务数 */
|
||
function runningVideoAnalyzeCount(ctx) {
|
||
let n = 0;
|
||
for (const [videoId] of videoAnalyzeTasks) {
|
||
const a = ctx.agents.get(VIDEO_ANALYZE_PREFIX + videoId);
|
||
if (a !== undefined && a.status === "running") n++;
|
||
}
|
||
return n;
|
||
}
|
||
|
||
//#region 脚本复盘会话(AI写作复盘页「复盘」:source 原视频脚本 / ai AI脚本 / compare 对比分析,三份独立存储)
|
||
/** 复盘会话工作目录:{主工作区}/创作任务(与改写共用「创作任务」目录 → 同一工作区,会话归「创作任务」分组) */
|
||
function resolveReviewCwd() {
|
||
return join(resolveCreativeCwd(), "创作任务");
|
||
}
|
||
|
||
/** 复盘工作区实体缓存(workspaceRegistry 服务不可用时为 null,会话不归组) */
|
||
let reviewWorkspace = null;
|
||
|
||
/** 确保「脚本复盘」工作区存在(幂等,title「创作任务」与改写脚本归组):会话 cwd 与该目录一致后 attach 才能通过校验 */
|
||
async function ensureReviewWorkspace(ctx) {
|
||
const reg = ctx.get("workspaceRegistry");
|
||
if (!reg) return null;
|
||
if (reviewWorkspace) {
|
||
if (reg.get(reviewWorkspace.id) !== undefined) return reviewWorkspace;
|
||
reviewWorkspace = null;
|
||
}
|
||
const dir = resolveReviewCwd();
|
||
try {
|
||
mkdirSync(dir, { recursive: true });
|
||
} catch (e) {
|
||
console.warn(`[dsh-plugin-mcn] 脚本复盘目录创建失败: ${e.message}`);
|
||
return null;
|
||
}
|
||
try {
|
||
reviewWorkspace = await reg.create(dir, "创作任务");
|
||
return reviewWorkspace;
|
||
} catch (e) {
|
||
console.warn(`[dsh-plugin-mcn] 「脚本复盘」工作区创建失败: ${e.message}`);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/** 复盘任务注册表:`${type}:${id}` -> 提交时间戳(type: source|ai|compare;id: video_id 或 rewrite_id) */
|
||
const reviewTasks = new Map();
|
||
|
||
/** 复盘会话 id:dsh_review-{type}-{id} */
|
||
function reviewSessionId(type, id) {
|
||
return REVIEW_PREFIX + type + "-" + id;
|
||
}
|
||
|
||
/** 获取(或创建)复盘会话:每个复盘任务独立会话(可并发,不归入工作区分组) */
|
||
async function getOrCreateReviewAgent(ctx, type, id) {
|
||
const sessionId = reviewSessionId(type, id);
|
||
const existing = ctx.agents.get(sessionId);
|
||
if (existing !== undefined) return { agent: existing, created: false };
|
||
const agentOptions = { provider: CREATIVE_PROVIDER, model: CREATIVE_MODEL };
|
||
const compose = await composeDefaultPreset(ctx);
|
||
const cwd = resolveReviewCwd();
|
||
// 会话工作目录必须存在:agent 内工具(pwsh/glob 等)以 cwd 为工作目录 spawn,目录缺失会 ENOENT
|
||
try { mkdirSync(cwd, { recursive: true }); } catch (e) { /* 目录创建失败不阻塞,工具可能受影响 */ }
|
||
try {
|
||
const handle = await ctx.agents.resume({ resumeSessionId: sessionId, agentOptions, setup: compose.setup });
|
||
return { agent: handle.agent, created: false };
|
||
} catch (e) {
|
||
const handle = await ctx.agents.create({ sessionId, agentOptions, meta: { cwd, agentPreset: compose.agentPreset }, setup: compose.setup });
|
||
return { agent: handle.agent, created: true };
|
||
} finally {
|
||
// 归入「创作任务」工作区分组(幂等;旧 cwd 会话 attach 抛错仅告警)
|
||
try {
|
||
const ws = await ensureReviewWorkspace(ctx);
|
||
if (ws && !ws.sessionIds.includes(sessionId)) await ws.attachSession(sessionId);
|
||
} catch (e) { /* 归组失败不影响复盘 */ }
|
||
}
|
||
}
|
||
|
||
/** 当前运行中的复盘任务数 */
|
||
function runningReviewCount(ctx) {
|
||
let n = 0;
|
||
for (const [k] of reviewTasks) {
|
||
const sep = k.indexOf(":");
|
||
if (sep < 0) continue;
|
||
const a = ctx.agents.get(reviewSessionId(k.slice(0, sep), k.slice(sep + 1)));
|
||
if (a !== undefined && a.status === "running") n++;
|
||
}
|
||
return n;
|
||
}
|
||
|
||
/** 从复盘会话目录名解析 type 与 id(启动恢复用):"dsh_review-ai-23" → {type:"ai", id:"23"} */
|
||
function parseReviewSessionId(name) {
|
||
const pre = REVIEW_PREFIX.length;
|
||
if (!name.startsWith(REVIEW_PREFIX)) return null;
|
||
const rest = name.slice(pre);
|
||
const sep = rest.indexOf("-");
|
||
if (sep <= 0) return null;
|
||
const type = rest.slice(0, sep);
|
||
const id = rest.slice(sep + 1);
|
||
if (!/^(source|ai|compare)$/.test(type) || !/^\d+$/.test(id)) return null;
|
||
return { type, id };
|
||
}
|
||
//#endregion
|
||
|
||
/** 账号设定提炼任务注册表:accountId -> 提交时间戳(每账号独立会话,与导入/刷新/视频解析互不阻塞) */
|
||
const personaTasks = new Map();
|
||
|
||
/** 账号数据分析任务注册表:accountId -> 提交时间戳(V1.0 同步:账号详情「数据分析」tab 触发,每账号独立会话 dsh_acctana-{id}) */
|
||
const acctAnalysisTasks = new Map();
|
||
|
||
/** 解析任务相关会话工作目录:{主工作区}/解析任务(与视频解析共用「解析任务」目录 → 同一工作区)。
|
||
* 覆盖账号设定提炼、导入账号、刷新账号数据三类会话。 */
|
||
function resolvePersonaCwd() {
|
||
return join(resolveCreativeCwd(), "解析任务");
|
||
}
|
||
|
||
let personaWorkspace = null;
|
||
|
||
/**
|
||
* 确保「解析任务」工作区存在(幂等):目录(产出根目录)不存在则创建;
|
||
* workspace.create 按 canonical path 复用已有记录,title 显式指定「解析任务」(目录名为「解析任务」);
|
||
* 旧版创建的 title 为「账号分析」,此处自动更名为「解析任务」)。
|
||
* 会话 cwd 与该目录一致后 attachSession 才能通过校验,从而在会话列表归入「解析任务」分组。
|
||
*/
|
||
async function ensurePersonaWorkspace(ctx) {
|
||
const reg = ctx.get("workspaceRegistry");
|
||
if (!reg) return null;
|
||
if (personaWorkspace) {
|
||
if (reg.get(personaWorkspace.id) !== undefined) return personaWorkspace;
|
||
personaWorkspace = null;
|
||
}
|
||
const dir = resolvePersonaCwd();
|
||
try {
|
||
mkdirSync(dir, { recursive: true });
|
||
} catch (e) {
|
||
console.warn(`[dsh-plugin-mcn] 解析任务目录创建失败: ${e.message}`);
|
||
return null;
|
||
}
|
||
try {
|
||
personaWorkspace = await reg.create(dir, "解析任务"); // title「解析任务」(与视频解析归组)
|
||
if (personaWorkspace.title !== "解析任务") {
|
||
try { await personaWorkspace.setTitle("解析任务"); } catch (e) { /* 更名失败不阻塞 */ }
|
||
}
|
||
return personaWorkspace;
|
||
} catch (e) {
|
||
console.warn(`[dsh-plugin-mcn] 「解析任务」工作区创建失败: ${e.message}`);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/** 获取(或创建)账号设定提炼专用会话 agent(每账号一个会话:不同账号可并发;同一账号重复提炼复用同一会话)。
|
||
* 会话创建于「解析任务」工作区(cwd = {主工作区}/解析任务),并 attach 到该工作区分组。
|
||
* 注意:旧版本创建的会话 cwd 为旧路径(如 D:\dshworkspace),attachSession 校验 cwd===workspace.path 会失败,
|
||
* 此类会话归组失败(仅告警);旧会话迁移见 attachAccountAnalyzeSession 注释。 */
|
||
async function getOrCreatePersonaAgent(ctx, accountId) {
|
||
const sessionId = PERSONA_PREFIX + accountId;
|
||
const existing = ctx.agents.get(sessionId);
|
||
if (existing !== undefined) {
|
||
// live 会话也要确保归组(早期版本提前 return 导致不 attach)
|
||
await attachAccountAnalyzeSession(ctx, sessionId);
|
||
return { agent: existing, created: false };
|
||
}
|
||
const agentOptions = {
|
||
provider: CREATIVE_PROVIDER,
|
||
model: CREATIVE_MODEL,
|
||
};
|
||
const compose = await composeDefaultPreset(ctx);
|
||
const cwd = resolvePersonaCwd();
|
||
try {
|
||
const handle = await ctx.agents.resume({
|
||
resumeSessionId: sessionId,
|
||
agentOptions,
|
||
setup: compose.setup,
|
||
});
|
||
return { agent: handle.agent, created: false };
|
||
} catch (e) {
|
||
const handle = await ctx.agents.create({
|
||
sessionId,
|
||
agentOptions,
|
||
meta: { cwd, agentPreset: compose.agentPreset },
|
||
setup: compose.setup,
|
||
});
|
||
return { agent: handle.agent, created: true };
|
||
} finally {
|
||
// 归入「解析任务」工作区分组(幂等;旧会话 cwd 不匹配时 attach 抛错,仅告警不影响提炼)
|
||
await attachAccountAnalyzeSession(ctx, sessionId);
|
||
}
|
||
}
|
||
|
||
/** 将解析任务类会话(导入/刷新 dsh_external-import、提炼 dsh_persona-*)归入「解析任务」工作区分组(幂等)。
|
||
* 旧 cwd 会话(如 D:\dshworkspace 下创建的 dsh_persona-* / dsh_external-import)attach 校验失败无法归组,
|
||
* 迁移方式:等会话空闲后删除其磁盘会话目录,下次触发时将以新 cwd 重建并自动归组。 */
|
||
async function attachAccountAnalyzeSession(ctx, sessionId) {
|
||
const ws = await ensurePersonaWorkspace(ctx);
|
||
if (ws && !ws.sessionIds.includes(sessionId)) {
|
||
try {
|
||
await ws.attachSession(sessionId);
|
||
console.log(`[dsh-plugin-mcn] 会话 ${sessionId} 已归入「解析任务」分组`);
|
||
} catch (attachErr) {
|
||
console.warn(`[dsh-plugin-mcn] 会话 ${sessionId} 归组失败(cwd 与工作区不匹配): ${attachErr.message}`);
|
||
}
|
||
}
|
||
}
|
||
|
||
/** 获取(或创建)账号数据分析专用会话 agent(V1.0 同步:每账号一个会话,复用「解析任务」工作区/分组;
|
||
* 与账号设定提炼(dsh_persona-*)并存互不干扰;同一账号重复发起复用同一会话)。 */
|
||
async function getOrCreateAcctAnalysisAgent(ctx, accountId) {
|
||
const sessionId = ACCT_ANALYSIS_PREFIX + accountId;
|
||
const existing = ctx.agents.get(sessionId);
|
||
if (existing !== undefined) {
|
||
await attachAccountAnalyzeSession(ctx, sessionId);
|
||
return { agent: existing, created: false };
|
||
}
|
||
const agentOptions = {
|
||
provider: CREATIVE_PROVIDER,
|
||
model: CREATIVE_MODEL,
|
||
};
|
||
const compose = await composeDefaultPreset(ctx);
|
||
const cwd = resolvePersonaCwd();
|
||
try {
|
||
const handle = await ctx.agents.resume({
|
||
resumeSessionId: sessionId,
|
||
agentOptions,
|
||
setup: compose.setup,
|
||
});
|
||
return { agent: handle.agent, created: false };
|
||
} catch (e) {
|
||
const handle = await ctx.agents.create({
|
||
sessionId,
|
||
agentOptions,
|
||
meta: { cwd, agentPreset: compose.agentPreset },
|
||
setup: compose.setup,
|
||
});
|
||
return { agent: handle.agent, created: true };
|
||
} finally {
|
||
await attachAccountAnalyzeSession(ctx, sessionId);
|
||
}
|
||
}
|
||
|
||
/** 账号数据分析任务列表(供前端轮询:并发多账号各自状态) */
|
||
function listAcctAnalysisTasks(ctx) {
|
||
const out = [];
|
||
for (const [accountId, submittedAt] of acctAnalysisTasks) {
|
||
const a = ctx.agents.get(ACCT_ANALYSIS_PREFIX + accountId);
|
||
out.push({ accountId, status: a !== undefined && a.status === "running" ? "running" : "idle", submittedAt });
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* 启动修复:把磁盘上已存在的解析/提炼/导入会话重新 attach 到对应分组(幂等)。
|
||
* workspaceRegistry 的 sessionPaths 索引只在 attach/live 时建立,重启后磁盘会话
|
||
* (dsh_video-analyze-* / dsh_persona-* / dsh_external-import)不在索引中会导致分组不显示;
|
||
* 这里对磁盘会话逐个 attachSession(内部按 header cwd 校验并重建索引),恢复分组显示。
|
||
*/
|
||
async function repairWorkspaceSessions(ctx) {
|
||
const reg = ctx.get("workspaceRegistry");
|
||
if (!reg) return;
|
||
try {
|
||
const sessionsRoot = join(homedir(), ".dsh", "sessions");
|
||
if (!existsSync(sessionsRoot)) return;
|
||
const dirs = readdirSync(sessionsRoot, { withFileTypes: true })
|
||
.filter((e) => e.isDirectory())
|
||
.flatMap((e) => readdirSync(join(sessionsRoot, e.name), { withFileTypes: true })
|
||
.filter((s) => s.isDirectory() && /^(dsh_video-analyze-\d+|dsh_persona-\d+|dsh_acctana-\d+|dsh_rewrite-\d+|dsh_review-\w+-\d+|dsh_import-|dsh_refresh-|dsh_external-import)$/.test(s.name))
|
||
.map((s) => s.name));
|
||
for (const sessionId of dirs) {
|
||
let ws;
|
||
if (sessionId.startsWith(VIDEO_ANALYZE_PREFIX)) ws = await ensureVideoAnalyzeWorkspace(ctx);
|
||
else if (sessionId.startsWith(REWRITE_PREFIX)) ws = await ensureRewriteWorkspace(ctx);
|
||
else if (parseReviewSessionId(sessionId)) ws = await ensureReviewWorkspace(ctx);
|
||
else ws = await ensurePersonaWorkspace(ctx);
|
||
if (!ws || ws.sessionIds.includes(sessionId)) continue;
|
||
try {
|
||
await ws.attachSession(sessionId);
|
||
console.log(`[dsh-plugin-mcn] 启动修复:会话 ${sessionId} 已归入「${ws.title}」分组`);
|
||
} catch (e) {
|
||
// 旧 cwd 会话(如 D:\dshworkspace 下创建的视频解析/提炼会话)attach 校验必然失败,
|
||
// 属已知情形(数据已落库),静默跳过,不打印告警
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.warn(`[dsh-plugin-mcn] 启动会话归组修复失败: ${e.message}`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 未分组会话自动清理:未分组(不在任何工作区且未归档)会话超过上限时,
|
||
* 按最后活动时间归档最老的已完成(非运行中改写任务)会话,直到不超过上限。
|
||
*/
|
||
async function cleanupUngroupedSessions(ctx) {
|
||
try {
|
||
const reg = ctx.get("workspaceRegistry");
|
||
if (!reg) return;
|
||
const archived = new Set(reg.archivedSessionIds);
|
||
const grouped = new Set();
|
||
for (const w of reg.list()) for (const sid of w.sessionIds) grouped.add(sid);
|
||
const sessionsRoot = join(homedir(), ".dsh", "sessions");
|
||
if (!existsSync(sessionsRoot)) return;
|
||
const all = [];
|
||
for (const grp of readdirSync(sessionsRoot, { withFileTypes: true }).filter((e) => e.isDirectory())) {
|
||
const gdir = join(sessionsRoot, grp.name);
|
||
for (const s of readdirSync(gdir, { withFileTypes: true }).filter((x) => x.isDirectory())) {
|
||
const log = join(gdir, s.name, "session.jsonl.zstd");
|
||
if (!existsSync(log)) continue;
|
||
all.push({ id: s.name, mtime: statSync(log).mtimeMs });
|
||
}
|
||
}
|
||
const ungrouped = all.filter((s) => !archived.has(s.id) && !grouped.has(s.id));
|
||
if (ungrouped.length <= MAX_UNGROUPED) return;
|
||
const sorted = [...ungrouped].sort((a, b) => a.mtime - b.mtime); // 最老在前
|
||
const d = initDb();
|
||
// 状态确认:agent 实时运行状态优先,改写任务记录(rewrite_log)补充——运行中的会话一律不处理
|
||
const isRunning = (id) => {
|
||
try {
|
||
const a = ctx.agents.get(id);
|
||
if (a !== undefined && a.status === "running") return true;
|
||
} catch (e) { /* agent 查询失败按非运行处理 */ }
|
||
const m = /^dsh_rewrite-(\d+)$/.exec(id);
|
||
if (m) {
|
||
const r = d.prepare(`SELECT status FROM rewrite_log WHERE id=?`).get(Number(m[1]));
|
||
return !!r && r.status === "running";
|
||
}
|
||
const ms = /^dsh_storyboard-(\d+)$/.exec(id);
|
||
if (ms) {
|
||
const r = d.prepare(`SELECT status FROM storyboard_log WHERE id=?`).get(Number(ms[1]));
|
||
return !!r && r.status === "running";
|
||
}
|
||
return false;
|
||
};
|
||
let removed = 0;
|
||
const now = Date.now();
|
||
for (const s of sorted) {
|
||
if (ungrouped.length - removed <= MAX_UNGROUPED) break;
|
||
if (isRunning(s.id)) continue; // 运行中的会话不处理
|
||
if (now - s.mtime <= SESSION_ACTIVE_WINDOW_MS) continue; // 近期活跃(15 分钟内有写入)不归档
|
||
try {
|
||
await reg.archiveSession(s.id);
|
||
removed++;
|
||
console.log(`[dsh-plugin-mcn] 未分组会话超限(${ungrouped.length} 个),已归档最老会话 ${s.id}`);
|
||
} catch (e) { /* 单个归档失败(如会话不可知)继续处理下一个 */ }
|
||
}
|
||
} catch (e) {
|
||
console.warn(`[dsh-plugin-mcn] 未分组会话清理失败: ${e.message}`);
|
||
}
|
||
}
|
||
|
||
/** 启动恢复改写会话:resume 使会话回到 live(UI 列表可见 + 未完成任务继续) */
|
||
/** 任务是否已完成(结果已入库):改写 done / 视频已解析 / 人设卡已入库 */
|
||
function taskDone(id) {
|
||
const d = initDb();
|
||
let m = /^dsh_rewrite-(\d+)$/.exec(id);
|
||
if (m) {
|
||
const r = d.prepare(`SELECT status FROM rewrite_log WHERE id=?`).get(Number(m[1]));
|
||
return !!r && r.status === "done";
|
||
}
|
||
m = /^dsh_storyboard-(\d+)$/.exec(id);
|
||
if (m) {
|
||
const r = d.prepare(`SELECT status FROM storyboard_log WHERE id=?`).get(Number(m[1]));
|
||
return !!r && r.status === "done";
|
||
}
|
||
m = /^dsh_video-analyze-(\d+)$/.exec(id);
|
||
if (m) {
|
||
const videoId = Number(m[1]);
|
||
const hit = d.prepare(`SELECT COUNT(*) c FROM account_video_source WHERE video_id=? OR aweme_id=(SELECT aweme_id FROM account_videos WHERE id=?)`).get(videoId, videoId).c
|
||
+ d.prepare(`SELECT COUNT(*) c FROM account_video_analysis WHERE video_id=? OR aweme_id=(SELECT aweme_id FROM account_videos WHERE id=?)`).get(videoId, videoId).c;
|
||
return hit > 0;
|
||
}
|
||
m = /^dsh_persona-(\d+)$/.exec(id);
|
||
if (m) {
|
||
const accId = Number(m[1]);
|
||
return d.prepare(`SELECT COUNT(*) c FROM account_persona WHERE account_id=?`).get(accId).c > 0;
|
||
}
|
||
m = /^dsh_acctana-(\d+)$/.exec(id);
|
||
if (m) {
|
||
const accId = Number(m[1]);
|
||
return d.prepare(`SELECT COUNT(*) c FROM account_analysis WHERE account_id=?`).get(accId).c > 0;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/** agent 是否运行中 */
|
||
function agentRunning(ctx, id) {
|
||
try {
|
||
const a = ctx.agents.get(id);
|
||
return a !== undefined && a.status === "running";
|
||
} catch (e) { return false; }
|
||
}
|
||
|
||
function sessionMtime(id) {
|
||
try {
|
||
const sessionsRoot = join(homedir(), ".dsh", "sessions");
|
||
for (const grp of readdirSync(sessionsRoot, { withFileTypes: true }).filter((e) => e.isDirectory())) {
|
||
const log = join(sessionsRoot, grp.name, id, "session.jsonl.zstd");
|
||
if (existsSync(log)) return statSync(log).mtimeMs;
|
||
}
|
||
} catch (e) { /* 忽略 */ }
|
||
return 0;
|
||
}
|
||
|
||
/**
|
||
* 启动恢复 mcn 磁盘会话(方案 B:任务驱动挂载):
|
||
* 只恢复「最近活跃」的会话(15 分钟内仍在写入 = 任务进行中,可能是重启前未完成的重新解析/改写);
|
||
* 长期无写入的会话(已完成或中断)不恢复(不挂 MCP)。导入/刷新类不自动恢复。
|
||
*/
|
||
async function resumeMcNSessions(ctx) {
|
||
try {
|
||
const sessionsRoot = join(homedir(), ".dsh", "sessions");
|
||
if (!existsSync(sessionsRoot)) return;
|
||
const now = Date.now();
|
||
const dirs = readdirSync(sessionsRoot, { withFileTypes: true })
|
||
.filter((e) => e.isDirectory())
|
||
.flatMap((e) => readdirSync(join(sessionsRoot, e.name), { withFileTypes: true })
|
||
.filter((s) => s.isDirectory() && /^(dsh_video-analyze-\d+|dsh_persona-\d+|dsh_acctana-\d+|dsh_import-|dsh_refresh-\d+|dsh_external-import|dsh_rewrite-\d+|dsh_storyboard-\d+)$/.test(s.name))
|
||
.map((s) => s.name));
|
||
for (const id of dirs) {
|
||
try {
|
||
// 长期无写入的会话不恢复(结果已入库或中断,恢复只会白挂 MCP)
|
||
if (now - sessionMtime(id) > SESSION_ACTIVE_WINDOW_MS) continue;
|
||
if (id.startsWith(VIDEO_ANALYZE_PREFIX)) await getOrCreateVideoAnalyzeAgent(ctx, Number(id.slice(VIDEO_ANALYZE_PREFIX.length)));
|
||
else if (id.startsWith(PERSONA_PREFIX)) await getOrCreatePersonaAgent(ctx, Number(id.slice(PERSONA_PREFIX.length)));
|
||
else if (id.startsWith(ACCT_ANALYSIS_PREFIX)) await getOrCreateAcctAnalysisAgent(ctx, Number(id.slice(ACCT_ANALYSIS_PREFIX.length)));
|
||
else if (id.startsWith(IMPORT_PREFIX)) await getOrCreateImportAgent(ctx, id.slice(IMPORT_PREFIX.length));
|
||
else if (id.startsWith(REFRESH_PREFIX)) await getOrCreateRefreshAgent(ctx, Number(id.slice(REFRESH_PREFIX.length)));
|
||
else if (id.startsWith(REWRITE_PREFIX)) await getOrCreateRewriteAgent(ctx, Number(id.slice(REWRITE_PREFIX.length)));
|
||
else if (id.startsWith(STORYBOARD_PREFIX)) await getOrCreateStoryboardAgent(ctx, Number(id.slice(STORYBOARD_PREFIX.length)));
|
||
else if (id === "dsh_external-import") await getOrCreateImportAgent(ctx, "external-import");
|
||
else {
|
||
const pv = parseReviewSessionId(id);
|
||
if (pv) await getOrCreateReviewAgent(ctx, pv.type, Number(pv.id));
|
||
}
|
||
} catch (e) { /* 单会话恢复失败不影响其他 */ }
|
||
}
|
||
} catch (e) {
|
||
console.warn(`[dsh-plugin-mcn] MCN 会话恢复失败: ${e.message}`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 杀掉 DSH(本进程)spawn 的 myai-mcp 孤儿进程树(父链归属 web 进程的实例;
|
||
* WorkBuddy 等外部应用的 myai 实例父链不同,不受影响)。
|
||
*/
|
||
function killMcpChildren() {
|
||
try {
|
||
const webPid = process.pid;
|
||
const ps = spawn("powershell", [
|
||
"-NoProfile", "-NonInteractive", "-Command",
|
||
`$web=${webPid}; $ps = Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -match 'myai' }; foreach ($p in $ps) { $cur = $p; $ok = $false; for ($i = 0; $i -lt 12 -and $cur; $i++) { if ($cur.ProcessId -eq $web) { $ok = $true; break }; $cur = Get-CimInstance Win32_Process -Filter "ProcessId=$($cur.ParentProcessId)" -ErrorAction SilentlyContinue }; if ($ok) { Stop-Process -Id $p.ProcessId -Force -ErrorAction SilentlyContinue; Write-Output ("killed " + $p.ProcessId) } }`,
|
||
], { stdio: ["ignore", "pipe", "inherit"], windowsHide: true });
|
||
ps.stdout.on("data", (c) => { const t = c.toString().trim(); if (t) console.log(`[dsh-plugin-mcn] ${t}`); });
|
||
ps.on("error", () => { /* 忽略 */ });
|
||
} catch (e) { /* 忽略 */ }
|
||
}
|
||
|
||
/**
|
||
* 已完成任务会话清理(方案 B):任务结果已入库、会话长期无写入(>30 分钟)且不在运行中的
|
||
* mcn 任务会话,物理删除(数据在库,会话删除释放 MCP 连接)。改写/解析/提炼会话适用。
|
||
* 近期活跃的会话不删(重新解析/改写可能仍在执行)。
|
||
*/
|
||
async function cleanupCompletedTaskSessions(ctx) {
|
||
try {
|
||
const sessionsRoot = join(homedir(), ".dsh", "sessions");
|
||
if (!existsSync(sessionsRoot)) return;
|
||
const now = Date.now();
|
||
const targets = [];
|
||
for (const grp of readdirSync(sessionsRoot, { withFileTypes: true }).filter((e) => e.isDirectory())) {
|
||
const gdir = join(sessionsRoot, grp.name);
|
||
for (const s of readdirSync(gdir, { withFileTypes: true }).filter((x) => x.isDirectory())) {
|
||
const id = s.name;
|
||
if (!/^(dsh_video-analyze-\d+|dsh_persona-\d+|dsh_acctana-\d+|dsh_rewrite-\d+|dsh_storyboard-\d+)$/.test(id)) continue;
|
||
if (now - sessionMtime(id) <= 30 * 60 * 1000) continue; // 近期活跃(30 分钟内有写入)不清理
|
||
if (!taskDone(id)) continue;
|
||
if (agentRunning(ctx, id)) continue; // 运行中不删
|
||
targets.push({ id, dir: join(gdir, id) });
|
||
}
|
||
}
|
||
for (const t of targets) {
|
||
try {
|
||
const reg = ctx.get("workspaceRegistry");
|
||
if (reg) {
|
||
for (const w of reg.list()) {
|
||
if (w.sessionIds.includes(t.id)) {
|
||
try { await w.detachSession(t.id); } catch (e) { /* 忽略 */ }
|
||
}
|
||
}
|
||
}
|
||
rmSync(t.dir, { recursive: true, force: true });
|
||
console.log(`[dsh-plugin-mcn] 已完成任务会话已清理(释放 MCP): ${t.id}`);
|
||
} catch (e) { /* 单个失败继续 */ }
|
||
}
|
||
// 清理本进程派生的 myai-mcp 孤儿进程(会话已删但 MCP 进程可能残留)
|
||
if (targets.length > 0) killMcpChildren();
|
||
} catch (e) {
|
||
console.warn(`[dsh-plugin-mcn] 已完成任务会话清理失败: ${e.message}`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* MCN 任务会话自动删除:最后活动时间距今超过 SESSION_RETENTION_MS(24h)且不在运行中的
|
||
* dsh_* 会话记录,物理删除会话目录(数据在库,删除仅释放磁盘/列表)。
|
||
* 运行中会话(agent 实时状态 + 任务表状态双保险)与 24h 内有写入的会话一律保留。
|
||
*/
|
||
async function cleanupOldMcNSessions(ctx) {
|
||
try {
|
||
const sessionsRoot = join(homedir(), ".dsh", "sessions");
|
||
if (!existsSync(sessionsRoot)) return;
|
||
const now = Date.now();
|
||
const targets = [];
|
||
const d = initDb();
|
||
// 任务表运行状态(agent 实时状态之外的补充,仅对有任务表的类型):改写/分镜/复盘
|
||
const taskRunning = (id) => {
|
||
let m = /^dsh_rewrite-(\d+)$/.exec(id);
|
||
if (m) { const r = d.prepare(`SELECT status FROM rewrite_log WHERE id=?`).get(Number(m[1])); return !!r && r.status === "running"; }
|
||
m = /^dsh_storyboard-(\d+)$/.exec(id);
|
||
if (m) { const r = d.prepare(`SELECT status FROM storyboard_log WHERE id=?`).get(Number(m[1])); return !!r && r.status === "running"; }
|
||
m = /^dsh_review-(ai|source|compare)-(\d+)$/.exec(id);
|
||
if (m) {
|
||
// review 会话名的数字是 rewrite_id(ai/compare)或 video_id(source),查对应类型的 running 记录
|
||
const key = Number(m[2]);
|
||
const r = m[1] === "source"
|
||
? d.prepare(`SELECT id FROM script_review WHERE status='running' AND type='source' AND video_id=? LIMIT 1`).get(key)
|
||
: d.prepare(`SELECT id FROM script_review WHERE status='running' AND type=? AND rewrite_id=? LIMIT 1`).get(m[1], key);
|
||
return !!r;
|
||
}
|
||
// 视频解析/人设提炼等无任务状态表,运行中判断完全依赖 agentRunning(实时 agent 状态)
|
||
return false;
|
||
};
|
||
for (const grp of readdirSync(sessionsRoot, { withFileTypes: true }).filter((e) => e.isDirectory())) {
|
||
const gdir = join(sessionsRoot, grp.name);
|
||
for (const s of readdirSync(gdir, { withFileTypes: true }).filter((x) => x.isDirectory())) {
|
||
const id = s.name;
|
||
if (!/^dsh_/.test(id)) continue; // 只处理 MCN 任务会话(dsh_* 前缀)
|
||
if (id === CREATIVE_SESSION_ID) continue; // 固定创作会话永不自动删除
|
||
const log = join(gdir, id, "session.jsonl.zstd");
|
||
if (!existsSync(log)) continue;
|
||
if (now - statSync(log).mtimeMs <= SESSION_RETENTION_MS) continue; // 24h 内有写入 → 保留
|
||
if (agentRunning(ctx, id)) continue; // 运行中不删
|
||
if (taskRunning(id)) continue; // 任务表 running 不删
|
||
targets.push({ id, dir: join(gdir, id) });
|
||
}
|
||
}
|
||
for (const t of targets) {
|
||
try {
|
||
const reg = ctx.get("workspaceRegistry");
|
||
if (reg) {
|
||
for (const w of reg.list()) {
|
||
if (w.sessionIds.includes(t.id)) {
|
||
try { await w.detachSession(t.id); } catch (e) { /* 忽略 */ }
|
||
}
|
||
}
|
||
}
|
||
rmSync(t.dir, { recursive: true, force: true });
|
||
console.log(`[dsh-plugin-mcn] 超过 24h 未活动的 MCN 会话记录已自动删除: ${t.id}`);
|
||
} catch (e) { /* 单个失败继续 */ }
|
||
}
|
||
} catch (e) {
|
||
console.warn(`[dsh-plugin-mcn] MCN 会话自动删除失败: ${e.message}`);
|
||
}
|
||
}
|
||
|
||
/** 当前账号设定提炼任务列表(供前端轮询:并发多账号各自状态) */
|
||
function listPersonaTasks(ctx) {
|
||
const out = [];
|
||
for (const [accountId, submittedAt] of personaTasks) {
|
||
const a = ctx.agents.get(PERSONA_PREFIX + accountId);
|
||
out.push({ accountId, status: a !== undefined ? a.status : "idle", submittedAt });
|
||
}
|
||
while (out.length > 100) out.shift();
|
||
return out;
|
||
}
|
||
|
||
/** 当前改写任务列表(供前端轮询:rewrite_log 运行中 + 会话存活;与 persona/analyze 交互一致) */
|
||
function listRewriteTasks(ctx) {
|
||
const out = [];
|
||
for (const [logId, submittedAt] of rewriteTasks) {
|
||
const a = ctx.agents.get(REWRITE_PREFIX + logId);
|
||
out.push({ id: Number(logId), status: a !== undefined ? a.status : "idle", submittedAt });
|
||
}
|
||
while (out.length > 100) out.shift();
|
||
return out;
|
||
}
|
||
|
||
/** 当前复盘任务列表(供前端轮询:reviewTasks 注册 + 会话存活;与 rewrite/persona 交互一致) */
|
||
function listReviewTasks(ctx) {
|
||
const out = [];
|
||
for (const [k, submittedAt] of reviewTasks) {
|
||
const sep = k.indexOf(":");
|
||
if (sep < 0) continue;
|
||
const type = k.slice(0, sep);
|
||
const id = Number(k.slice(sep + 1));
|
||
const a = ctx.agents.get(reviewSessionId(type, id));
|
||
out.push({ key: k, type, id, status: a !== undefined ? a.status : "idle", submittedAt });
|
||
}
|
||
while (out.length > 100) out.shift();
|
||
return out;
|
||
}
|
||
|
||
//#region AI会话模块(MCN 工作台内查看插件创建的 dsh_* 会话:扫描 + 多帧 zstd 解压 + 对话解析)
|
||
/** zstd 帧扫描(复制 DSH session-persistence-jsonl 逻辑):解析多帧边界 */
|
||
const ZSTD_MAGIC = 4247762216;
|
||
function scanZstdFrames(buffer) {
|
||
const frames = [];
|
||
let offset = 0;
|
||
while (offset < buffer.length) {
|
||
const start = offset;
|
||
if (buffer.length - offset < 4) break;
|
||
if (buffer.readUInt32LE(offset) !== ZSTD_MAGIC) throw new Error("invalid zstd frame magic");
|
||
offset += 4;
|
||
if (offset === buffer.length) break;
|
||
const descriptor = buffer.readUInt8(offset);
|
||
offset += 1;
|
||
const contentSizeFlag = descriptor >>> 6;
|
||
const singleSegment = (descriptor & 32) !== 0;
|
||
const checksum = (descriptor & 4) !== 0;
|
||
const dictionaryFlag = descriptor & 3;
|
||
const dictionaryBytes = dictionaryFlag === 3 ? 4 : dictionaryFlag;
|
||
const contentSizeBytes = contentSizeFlag === 0 ? (singleSegment ? 1 : 0) : (1 << contentSizeFlag);
|
||
const remainingHeaderBytes = (singleSegment ? 0 : 1) + dictionaryBytes + contentSizeBytes;
|
||
if (buffer.length - offset < remainingHeaderBytes) break;
|
||
offset += remainingHeaderBytes;
|
||
for (;;) {
|
||
if (buffer.length - offset < 3) return { frames, tornStart: start };
|
||
const blockHeader = buffer.readUIntLE(offset, 3);
|
||
offset += 3;
|
||
const lastBlock = (blockHeader & 1) !== 0;
|
||
const blockType = (blockHeader >>> 1) & 3;
|
||
const blockSize = blockHeader >>> 3;
|
||
if (blockType === 3) throw new Error("reserved zstd block type");
|
||
const payloadBytes = blockType === 1 ? 1 : blockSize;
|
||
if (buffer.length - offset < payloadBytes) return { frames, tornStart: start };
|
||
offset += payloadBytes;
|
||
if (lastBlock) break;
|
||
}
|
||
if (checksum) {
|
||
if (buffer.length - offset < 4) return { frames, tornStart: start };
|
||
offset += 4;
|
||
}
|
||
frames.push({ start, end: offset });
|
||
}
|
||
return { frames, tornStart: -1 };
|
||
}
|
||
/** 多帧 zstd 解压会话日志 → 完整 JSONL 文本(Node 内置 zstdDecompressSync 逐帧) */
|
||
function decompressSessionLog(buffer) {
|
||
const { frames } = scanZstdFrames(buffer);
|
||
const parts = [];
|
||
for (const { start, end } of frames) parts.push(zstdDecompressSync(buffer.subarray(start, end)));
|
||
return Buffer.concat(parts).toString("utf8");
|
||
}
|
||
/** 扫描 sessions 目录下插件创建的 dsh_* 会话(MCN 工作台 AI会话 列表数据源) */
|
||
function listMcNSessions(ctx) {
|
||
const sessionsRoot = join(homedir(), ".dsh", "sessions");
|
||
if (!existsSync(sessionsRoot)) return [];
|
||
const out = [];
|
||
for (const grp of readdirSync(sessionsRoot, { withFileTypes: true }).filter((e) => e.isDirectory())) {
|
||
const gdir = join(sessionsRoot, grp.name);
|
||
for (const s of readdirSync(gdir, { withFileTypes: true }).filter((x) => x.isDirectory())) {
|
||
if (!/^dsh_/.test(s.name)) continue;
|
||
const log = join(gdir, s.name, "session.jsonl.zstd");
|
||
if (!existsSync(log)) continue;
|
||
const mtime = statSync(log).mtimeMs;
|
||
let status = "idle";
|
||
try {
|
||
const a = ctx.agents.get(s.name);
|
||
if (a !== undefined && a.status === "running") status = "running";
|
||
} catch (e) { /* agent 查询失败按 idle */ }
|
||
let group = "其他";
|
||
if (/^dsh_rewrite-\d+$/.test(s.name) || /^dsh_review-/.test(s.name)) group = "创作任务";
|
||
else if (/^dsh_(video-analyze-\d+|persona-\d+|acctana-\d+|import-|refresh-|external-import)$/.test(s.name)) group = "解析任务";
|
||
else if (/^dsh_storyboard-/.test(s.name)) group = "分镜";
|
||
else if (/^dsh_schedule-/.test(s.name)) group = "计划任务";
|
||
else if (/^dsh_benchmark-/.test(s.name)) group = "对标创作";
|
||
out.push({ id: s.name, group, status, mtime, updated: new Date(mtime).toISOString() });
|
||
}
|
||
}
|
||
out.sort((a, b) => b.mtime - a.mtime);
|
||
return out;
|
||
}
|
||
/** 解析会话事件 content → 纯文本 */
|
||
function extractSessionText(content) {
|
||
if (!content) return "";
|
||
if (typeof content === "string") return content;
|
||
if (Array.isArray(content)) {
|
||
return content.map((b) => {
|
||
if (typeof b === "string") return b;
|
||
if (b && b.type === "text") return b.text || "";
|
||
if (b && b.text) return b.text;
|
||
return "";
|
||
}).filter(Boolean).join("\n");
|
||
}
|
||
return "";
|
||
}
|
||
/** 读取会话对话消息(user/assistant/tool 摘要),live 写入中容错 */
|
||
function readSessionMessages(id) {
|
||
const sessionsRoot = join(homedir(), ".dsh", "sessions");
|
||
if (!existsSync(sessionsRoot)) return null;
|
||
let file = null;
|
||
for (const grp of readdirSync(sessionsRoot, { withFileTypes: true }).filter((e) => e.isDirectory())) {
|
||
const p = join(sessionsRoot, grp.name, id, "session.jsonl.zstd");
|
||
if (existsSync(p)) { file = p; break; }
|
||
}
|
||
if (!file) return null;
|
||
let buf;
|
||
try { buf = readFileSync(file); } catch (e) { return null; }
|
||
// 大文件防护:超 8MB 只读尾部(最近帧)
|
||
if (buf.length > 8 * 1024 * 1024) buf = buf.subarray(buf.length - 8 * 1024 * 1024);
|
||
let text;
|
||
try { text = decompressSessionLog(buf); } catch (e) { return { error: "会话日志解压失败(可能正在写入中,稍后再试)" }; }
|
||
const messages = [];
|
||
const lines = text.split("\n");
|
||
for (let i = 0; i < lines.length; i++) {
|
||
const line = lines[i];
|
||
if (!line.trim()) continue;
|
||
let ev;
|
||
try { ev = JSON.parse(line); } catch (e) { continue; }
|
||
if (ev.type === "user/message") {
|
||
const t = extractSessionText(ev.data && ev.data.content);
|
||
if (t) messages.push({ role: "user", text: t });
|
||
} else if (ev.type === "assistant/message") {
|
||
const t = extractSessionText(ev.data && ev.data.content);
|
||
if (t) messages.push({ role: "assistant", text: t });
|
||
} else if (ev.type === "tool/call") {
|
||
const d = ev.data || {};
|
||
const args = String(d.arguments || "").slice(0, 300);
|
||
messages.push({ role: "tool", name: d.name || "", args });
|
||
}
|
||
}
|
||
// 限制返回消息数(最近 200 条)
|
||
if (messages.length > 200) messages.splice(0, messages.length - 200);
|
||
return { messages, count: messages.length };
|
||
}
|
||
//#endregion
|
||
//#endregion
|
||
|
||
//#region 外部账号导入提交(保留在宿主:依赖会话/任务状态)
|
||
async function stageExternalImport(ctx, body) {
|
||
const dir = typeof body.dir === "string" && body.dir.trim() ? body.dir.trim() : "";
|
||
const acc = body.account && typeof body.account === "object" ? body.account : null;
|
||
const accName = acc && typeof acc.name === "string" && acc.name.trim() ? acc.name.trim() : "";
|
||
const accDid = acc && typeof acc.douyinId === "string" && acc.douyinId.trim() ? acc.douyinId.trim() : "";
|
||
const accUrl = acc && typeof acc.url === "string" && acc.url.trim() ? acc.url.trim() : "";
|
||
// 周榜赛道(热门账号周榜 category):提交时携带,写库 track 直接采用(按 SKILL.md 29 赛道枚举归一化)
|
||
const accCat = acc && typeof acc.category === "string" && acc.category.trim() ? acc.category.trim() : "";
|
||
const hasAccount = !!(accName || accDid || accUrl);
|
||
if (!dir && !hasAccount) throw new Error("缺少参数:请提供本地文件夹路径(dir)或抖音账号信息(account.name / account.douyinId / account.url)");
|
||
if (dir && !existsSync(dir)) throw new Error(`目录不存在: ${dir}`);
|
||
|
||
// 每任务独立会话(key = 目录 hash 或账号信息 hash),不同任务可并发;同 key 60s 防抖保护
|
||
const key = dir ? "dir:" + hashKey(dir) : "acc:" + hashKey(accName + "|" + accDid + "|" + accUrl);
|
||
const sessionId = IMPORT_PREFIX + key;
|
||
|
||
// 公共:接口调用方式说明(本会话无 HTTP 工具,用 pwsh 调本地接口)
|
||
const httpHint =
|
||
`本会话没有 HTTP 工具,调用本地接口请用 pwsh 工具执行 PowerShell。接口基地址用 $env:DSH_WEB_URL(如为空则依次试 http://127.0.0.1:3081、http://127.0.0.1:3080)。\n`
|
||
+ `若 pwsh 命令被沙箱拒绝(输出含 [sandbox: file access denied] 标记),请立即给同一命令加 sandbox_permissions 参数(如 danger-full-access)和 justification 参数重试——系统会弹出授权窗口,用户批准后命令才会执行。\n`;
|
||
|
||
let prompt;
|
||
if (dir) {
|
||
prompt = `用户通过「抖音账号」页面导入了外部账号数据文件夹:${dir}\n`
|
||
+ `请用文件工具(read/glob 等)读取并分析该目录下文件的结构(账号信息.json / 短视频表格.xlsx / *拆解分析.md / 账号设定.md / 账号数据分析.md 等)。\n`
|
||
+ `如果文件字段/格式与标准结构有出入,请归一化为标准结构(字段名、类型、标签拆分等)。\n`
|
||
+ `「定位/内容」(content)从 *账号设定.md 的「账号核心定位」章节提取,转纯文本段落(键名:值,逐行,去掉表格样式);**该字段只在提炼账号设定后才有,提取不到则留空,禁止用简介(bio)填充**。详见插件 docs/外部账号导入处理规范.md。\n`
|
||
+ `「赛道」(track)从 *账号设定.md「账号核心定位」的「赛道」键提取,取第一个主赛道(" / " 分隔取首段),如「生活vlog赛道」。\n`
|
||
+ httpHint
|
||
+ `先调用 POST /mcn/api/external/preview {"dir":"<目录路径>"} 获取导入差异(新增/更新/已存在),向用户展示后确认是否导入。\n`
|
||
+ `用户确认后调用 /mcn/api/external/import 接口完成导入,推荐使用 data 模式:`
|
||
+ `POST {"data":{"accounts":[{account_name,douyin_id,sec_uid,followers,total_likes,works_count,ip_location,location,age,bio}],`
|
||
+ `"videos":[{account_name,aweme_id,video_title,video_url,like_count,like_display,comment_count,share_count,collect_count,play_count,duration,publish_time,tags}],`
|
||
+ `"analyses":[{type:"persona"|"account"|"video",account_name,video_aweme_id,content,summary}]}}(脚本层会自动去重)。\n`
|
||
+ `若数据结构与标准完全一致,也可用 POST {"dir":"<目录路径>"} 让脚本直接扫描解析。`;
|
||
} else {
|
||
// 网页获取模式:AI 判断 → mcn-dou-analysis 技能浏览器采集 → 写库
|
||
const accInfo = [
|
||
accName ? `账号名称:${accName}` : "",
|
||
accDid ? `抖音号:${accDid}` : "",
|
||
accUrl ? `抖音账号地址:${accUrl}` : "",
|
||
accCat ? `热门账号周榜赛道:${accCat}` : "",
|
||
].filter(Boolean).join(";");
|
||
prompt = `用户通过「抖音账号」页面提交了账号信息:${accInfo}\n`
|
||
+ (accCat ? `该账号来自热门账号周榜,周榜赛道(category)为「${accCat}」。写入数据库时 accounts.track 直接采用该赛道,并按技能 SKILL.md「赛道标准枚举(29个)」归一化(不带「赛道」后缀;如「化妆美容」→「美妆」);此赛道为周榜权威分类,无需自行推断。\n` : "")
|
||
+ `请先判断处理方式:\n`
|
||
+ `1) 若用户提供的是本地文件夹路径(以盘符或 / 开头)→ 按「本地文件夹导入」流程处理(读取目录文件结构 → preview → 用户确认 → import)。\n`
|
||
+ `2) 若提供的是抖音账号信息(名称/抖音号/账号地址)→ 调用「mcn-dou-analysis」技能(路径 ~/.dsh/skills/mcn-dou-analysis/,已全局注册)通过抖音网页获取账号数据:\n`
|
||
+ ` - 用 browser-harness 打开抖音网页版搜索该账号(优先用抖音号,其次账号地址/名称),进入达人主页;\n`
|
||
+ ` - 提取账号信息:昵称、抖音号、抖音地址、粉丝数、获赞数、作品数、简介等;\n`
|
||
+ ` - 滚动加载视频列表(间隔1~3秒,收集≥30条),保存 13 列 Excel 到「{工作空间}/解析任务/{达人昵称}/短视频表格.xlsx」(工作空间 = D:\\dshworkspace,产出路径以技能 references/路径配置.md 为准);\n`
|
||
+ ` - 将账号信息与视频列表数据**写入数据库**:调用 POST /mcn/api/external/import 的 data 模式(见下方格式),账号信息存 hot_accounts(account_type 用 "external"),视频存 account_videos(account_name 关联账号);\n`
|
||
+ ` - 视频列表不足时按技能规则处理(解析失败补充下一位等);\n`
|
||
+ chromeHint(IMPORT_PREFIX + key)
|
||
+ httpHint
|
||
+ `写入数据库时调用:POST {"data":{"accounts":[{account_name,douyin_id,sec_uid,followers,total_likes,works_count,ip_location,location,age,bio}],`
|
||
+ `"videos":[{account_name,aweme_id,video_title,video_url,like_count,like_display,comment_count,share_count,collect_count,play_count,duration,publish_time,tags}]}}(脚本层自动去重/更新)。\n`
|
||
+ `完成后向用户汇报:账号信息、视频数量、保存路径、数据库写入结果。`;
|
||
}
|
||
// 每任务独立会话(key = 目录 hash 或账号信息 hash),不同任务可并发;同 key 60s 防抖保护
|
||
const { agent } = await getOrCreateImportAgent(ctx, key);
|
||
const lastSubmit = importTasks.get(key) || 0;
|
||
if (agent.status === "running" || Date.now() - lastSubmit < 60000) {
|
||
throw new Error("该导入任务正在执行中(不同账号/目录可同时导入),请稍后再试");
|
||
}
|
||
importTasks.set(key, Date.now());
|
||
agent.followup(createUserMessage({
|
||
content: [{ type: "text", text: prompt }],
|
||
source: { kind: "user" },
|
||
}));
|
||
return { ok: true, dir: dir || null, account: hasAccount ? { name: accName, douyinId: accDid, url: accUrl } : null };
|
||
}
|
||
//#endregion
|
||
|
||
//#region HTTP 路由
|
||
function apply(ctx) {
|
||
const server = ctx.get("webServer");
|
||
if (!server) {
|
||
console.warn("[dsh-plugin-mcn] webServer 服务不可用,跳过路由注册");
|
||
return;
|
||
}
|
||
// 启动时尝试预加载(失败不阻塞,查询时会懒加载兜底)
|
||
ensureData().then((r) => {
|
||
console.log(`[dsh-plugin-mcn] 数据就绪: cached=${r.cached} count=${r.count}`);
|
||
}).catch((e) => {
|
||
console.log(`[dsh-plugin-mcn] 启动预加载跳过(查询时懒加载兜底): ${e.message}`);
|
||
});
|
||
// 启动修复:确保「视频解析」「解析任务」工作区存在/改名(幂等),并把磁盘上已有会话重新归组
|
||
setTimeout(() => {
|
||
Promise.all([ensureVideoAnalyzeWorkspace(ctx), ensurePersonaWorkspace(ctx)])
|
||
.catch((e) => console.warn(`[dsh-plugin-mcn] 工作区初始化失败: ${e.message}`))
|
||
.finally(() => {
|
||
repairWorkspaceSessions(ctx).catch((e) => console.warn(`[dsh-plugin-mcn] 会话归组修复失败: ${e.message}`));
|
||
// 恢复改写会话到 live(UI 会话列表可见 + 未完成任务继续执行)
|
||
resumeMcNSessions(ctx).catch((e) => console.warn(`[dsh-plugin-mcn] 改写会话恢复失败: ${e.message}`));
|
||
// 未分组会话超限清理 + 已完成任务会话清理(释放 MCP)+ 超过 24h 的 MCN 会话记录自动删除
|
||
cleanupUngroupedSessions(ctx).catch(() => {});
|
||
cleanupCompletedTaskSessions(ctx).catch(() => {});
|
||
cleanupOldMcNSessions(ctx).catch(() => {});
|
||
});
|
||
}, 3000);
|
||
// 未分组会话定期清理(每 5 分钟兜底,防长时间运行累积)
|
||
const cleanupTimer = setInterval(() => {
|
||
cleanupUngroupedSessions(ctx).catch(() => {});
|
||
cleanupCompletedTaskSessions(ctx).catch(() => {});
|
||
cleanupOldMcNSessions(ctx).catch(() => {});
|
||
}, 5 * 60 * 1000);
|
||
cleanupTimer.unref?.();
|
||
|
||
server.register({
|
||
path: "/mcn/api/accounts",
|
||
exact: true,
|
||
handler: async (req, res) => {
|
||
try {
|
||
const q = parseQuery(req.url);
|
||
// 不自动从 MCP 拉取账号数据(除非开发人员明确要求点击「拉取」按钮);空库返回空列表
|
||
const result = queryAccounts(q);
|
||
json(res, 200, { ok: true, ...result });
|
||
} catch (e) {
|
||
json(res, 500, { ok: false, error: e.message });
|
||
}
|
||
},
|
||
});
|
||
|
||
// 账号数据分析信息(账号详情页「数据分析」tab,V1.0 同步):account_analysis 最新一条 markdown 内容(来源:{名}账号数据分析.md 导入)
|
||
server.register({
|
||
path: "/mcn/api/account/analysis",
|
||
exact: true,
|
||
handler: async (req, res) => {
|
||
try {
|
||
const q = parseQuery(req.url);
|
||
const accountId = Number(q.accountId);
|
||
if (!Number.isFinite(accountId) || accountId <= 0) {
|
||
json(res, 400, { ok: false, error: "缺少有效的 accountId 参数" });
|
||
return;
|
||
}
|
||
const d = initDb();
|
||
const row = d.prepare(
|
||
`SELECT content_json AS content, analysis_time FROM account_analysis WHERE account_id=? ORDER BY id DESC LIMIT 1`
|
||
).get(accountId);
|
||
json(res, 200, {
|
||
ok: true,
|
||
content: row ? row.content : null,
|
||
analysisTime: row ? row.analysis_time : null,
|
||
});
|
||
} catch (e) {
|
||
console.error(`[dsh-plugin-mcn] 账号数据分析查询失败: ${e.message}`);
|
||
json(res, 500, { ok: false, error: e.message });
|
||
}
|
||
},
|
||
});
|
||
|
||
// 账号收藏切换:{accountId, favorite: 0|1}(收藏的账号在列表中排最前,按粉丝数降序)
|
||
server.register({
|
||
path: "/mcn/api/account/favorite",
|
||
exact: true,
|
||
handler: async (req, res) => {
|
||
try {
|
||
const body = await readBody(req);
|
||
const accountId = Number(body.accountId);
|
||
const favorite = body.favorite ? 1 : 0;
|
||
if (!Number.isFinite(accountId) || accountId <= 0) {
|
||
json(res, 400, { ok: false, error: "缺少有效的 accountId 参数" });
|
||
return;
|
||
}
|
||
const d = initDb();
|
||
const r = d.prepare(`UPDATE hot_accounts SET favorite=? WHERE id=?`).run(favorite, accountId);
|
||
if (r.changes === 0) { json(res, 404, { ok: false, error: "账号不存在" }); return; }
|
||
json(res, 200, { ok: true, favorite });
|
||
} catch (e) {
|
||
console.error(`[dsh-plugin-mcn] 账号收藏失败: ${e.message}`);
|
||
json(res, 500, { ok: false, error: e.message });
|
||
}
|
||
},
|
||
});
|
||
|
||
// 账号视频分页(账号详情页):GET ?accountId=&page=&pageSize= → { account, total, items }
|
||
server.register({
|
||
path: "/mcn/api/account/videos",
|
||
exact: true,
|
||
handler: async (req, res) => {
|
||
try {
|
||
const q = parseQuery(req.url);
|
||
const accountId = Number(q.accountId);
|
||
if (!Number.isFinite(accountId) || accountId <= 0) {
|
||
json(res, 400, { ok: false, error: "缺少有效的 accountId 参数" });
|
||
return;
|
||
}
|
||
const result = queryAccountVideos(accountId, Number(q.page) || 1, Number(q.pageSize) || 20);
|
||
json(res, 200, { ok: true, ...result });
|
||
} catch (e) {
|
||
json(res, 500, { ok: false, error: e.message });
|
||
}
|
||
},
|
||
});
|
||
|
||
// 账号设定信息(账号详情页):account_persona 最新一条 markdown 内容(来源:账号设定.md 导入)
|
||
server.register({
|
||
path: "/mcn/api/account/persona",
|
||
exact: true,
|
||
handler: async (req, res) => {
|
||
try {
|
||
const q = parseQuery(req.url);
|
||
const accountId = Number(q.accountId);
|
||
if (!Number.isFinite(accountId) || accountId <= 0) {
|
||
json(res, 400, { ok: false, error: "缺少有效的 accountId 参数" });
|
||
return;
|
||
}
|
||
const d = initDb();
|
||
const row = d.prepare(
|
||
`SELECT content_json AS content, analysis_time FROM account_persona WHERE account_id=? ORDER BY id DESC LIMIT 1`
|
||
).get(accountId);
|
||
json(res, 200, {
|
||
ok: true,
|
||
content: row ? row.content : null,
|
||
analysisTime: row ? row.analysis_time : null,
|
||
});
|
||
} catch (e) {
|
||
console.error(`[dsh-plugin-mcn] 账号设定信息查询失败: ${e.message}`);
|
||
json(res, 500, { ok: false, error: e.message });
|
||
}
|
||
},
|
||
});
|
||
|
||
// 全部视频列表(AI写作提升页):跨账号分页,排序 = 已解析优先 → 点赞数降序
|
||
server.register({
|
||
path: "/mcn/api/videos",
|
||
exact: true,
|
||
handler: async (req, res) => {
|
||
try {
|
||
const q = parseQuery(req.url);
|
||
const page = Math.max(1, Number(q.page) || 1);
|
||
const pageSize = Math.max(1, Math.min(Number(q.pageSize) || 20, 100));
|
||
const search = String(q.search || "").trim();
|
||
const parsed = String(q.parsed || "").trim();
|
||
// V1.0 同步:account=账号级预筛(AI写脚本页按账号进入;此时放开爆款窗,展示该账号全部视频);days=发布时间窗口(天)
|
||
const account = Number(q.account) || 0;
|
||
const days = Number(q.days) || 0;
|
||
const d = initDb();
|
||
const where = [];
|
||
const params = [];
|
||
if (account > 0) {
|
||
// 账号级:全部视频(关注账号优先/按账号预筛,与爆款窗语义区分)
|
||
where.push("v.account_id = ?");
|
||
params.push(account);
|
||
} else {
|
||
// 数据规则:每个账号取「点赞 30W 以上 且 评论 1W 以上」的视频中「近期(发布时间倒序)」的 10 条
|
||
where.push("v.id IN (SELECT id FROM (SELECT id, ROW_NUMBER() OVER (PARTITION BY account_id ORDER BY publish_time DESC, id DESC) rn FROM account_videos WHERE like_count >= 300000 AND comment_count >= 10000) t WHERE rn <= 10)");
|
||
}
|
||
if (search) {
|
||
where.push("(v.video_title LIKE ? OR h.account_name LIKE ?)");
|
||
params.push(`%${search}%`, `%${search}%`);
|
||
}
|
||
if (days > 0) {
|
||
where.push("substr(v.publish_time, 1, 10) >= ?");
|
||
params.push(new Date(Date.now() - days * 86400000).toISOString().slice(0, 10));
|
||
}
|
||
// 解析状态筛选:1=已解析,0=未解析,空=全部
|
||
if (parsed === "1" || parsed === "0") {
|
||
const parsedCond = "(v.id IN (SELECT video_id FROM account_video_source) OR v.id IN (SELECT video_id FROM account_video_analysis))";
|
||
where.push(parsed === "1" ? parsedCond : `NOT (${parsedCond})`);
|
||
}
|
||
const w = where.length ? "WHERE " + where.join(" AND ") : "";
|
||
const total = d.prepare(`SELECT COUNT(*) c FROM account_videos v LEFT JOIN hot_accounts h ON h.id=v.account_id ${w}`).get(...params).c;
|
||
const items = d.prepare(`
|
||
SELECT v.*, h.account_name,
|
||
SUM(v.like_count) OVER (PARTITION BY v.account_id) AS acc_likes,
|
||
(SELECT COUNT(*) FROM account_video_source s WHERE s.video_id = v.id OR s.aweme_id = v.aweme_id) AS has_source,
|
||
(SELECT COUNT(*) FROM account_video_analysis a WHERE a.video_id = v.id OR a.aweme_id = v.aweme_id) AS has_analysis,
|
||
(SELECT COUNT(*) FROM rewrite_log r WHERE r.video_id = v.id AND r.status='done' AND r.script_text IS NOT NULL AND r.script_text <> '') AS rewrite_count,
|
||
CASE WHEN v.topic IS NOT NULL AND v.topic <> '' THEN 1 ELSE 0 END AS has_topic,
|
||
CASE WHEN (h.content IS NOT NULL AND h.content <> '') OR EXISTS(SELECT 1 FROM account_persona p WHERE p.account_id = v.account_id) THEN 1 ELSE 0 END AS has_content
|
||
FROM account_videos v LEFT JOIN hot_accounts h ON h.id=v.account_id
|
||
${w}
|
||
ORDER BY acc_likes DESC, v.publish_time DESC, v.id DESC
|
||
LIMIT ? OFFSET ?
|
||
`).all(...params, pageSize, (page - 1) * pageSize);
|
||
// 改写执行中标记:rewrite_log status=running 且对应 AI 改写会话存活(网页/聊天窗口触发的改写任务都实时反映到列表)
|
||
const runningByVideo = new Map();
|
||
for (const log of d.prepare(`SELECT id, video_id FROM rewrite_log WHERE status='running'`).all()) {
|
||
const a = ctx.agents.get(REWRITE_PREFIX + log.id);
|
||
if (a !== undefined && a.status === "running") runningByVideo.set(log.video_id, true);
|
||
}
|
||
for (const it of items) it.rewrite_running = !!runningByVideo.get(it.id);
|
||
// 解析中标记:存在解析会话且 session.jsonl.zstd 最近 3 分钟内活跃(会话活跃=任务执行中;
|
||
// 用会话目录而非内存注册表,服务重启后恢复的解析任务仍能正确显示「解析中」)
|
||
const analyzingByVideo = new Map();
|
||
try {
|
||
const sessionsRoot = join(homedir(), ".dsh", "sessions");
|
||
if (existsSync(sessionsRoot)) {
|
||
const nowTs = Date.now();
|
||
for (const grp of readdirSync(sessionsRoot, { withFileTypes: true }).filter((e) => e.isDirectory())) {
|
||
const gdir = join(sessionsRoot, grp.name);
|
||
for (const s of readdirSync(gdir, { withFileTypes: true }).filter((x) => x.isDirectory())) {
|
||
const m = /^dsh_video-analyze-(\d+)$/.exec(s.name);
|
||
if (!m) continue;
|
||
const log = join(gdir, s.name, "session.jsonl.zstd");
|
||
if (!existsSync(log)) continue;
|
||
if (nowTs - statSync(log).mtimeMs <= 3 * 60 * 1000) analyzingByVideo.set(Number(m[1]), true);
|
||
}
|
||
}
|
||
}
|
||
} catch (e) { /* 解析中标记失败不影响列表 */ }
|
||
for (const it of items) it.analyzing = !!analyzingByVideo.get(it.id) && !(it.has_source > 0 || it.has_analysis > 0);
|
||
json(res, 200, { ok: true, total, page, pageSize, items });
|
||
} catch (e) {
|
||
console.error(`[dsh-plugin-mcn] 全部视频查询失败: ${e.message}`);
|
||
json(res, 500, { ok: false, error: e.message });
|
||
}
|
||
},
|
||
});
|
||
|
||
// 视频改写提交:{videoId}。要求账号设定(hot_accounts.content)与视频选题(topic)都存在;并发上限 8;同一视频可多次改写(一对多)
|
||
server.register({
|
||
path: "/mcn/api/video/rewrite",
|
||
exact: true,
|
||
handler: async (req, res) => {
|
||
try {
|
||
const body = await readBody(req);
|
||
const videoId = Number(body.videoId);
|
||
if (!Number.isFinite(videoId) || videoId <= 0) { json(res, 400, { ok: false, error: "缺少有效的 videoId 参数" }); return; }
|
||
if (runningRewriteCount(ctx) >= REWRITE_MAX_CONCURRENT) {
|
||
json(res, 409, { ok: false, error: `改写任务已达并发上限(${REWRITE_MAX_CONCURRENT} 条),请等待部分完成后再提交` });
|
||
return;
|
||
}
|
||
const d = initDb();
|
||
const v = d.prepare(`SELECT * FROM account_videos WHERE id=?`).get(videoId);
|
||
if (!v) { json(res, 404, { ok: false, error: "视频不存在" }); return; }
|
||
const acc = v.account_id ? d.prepare(`SELECT * FROM hot_accounts WHERE id=?`).get(v.account_id) : null;
|
||
// 账号设定:优先完整人设卡(account_persona.content_json,去掉末尾与脚本创作无关的两段「当前需解决的问题」「人设底线」),
|
||
// 无则回退「定位/内容」摘要(hot_accounts.content)
|
||
let setting = "";
|
||
if (v.account_id) {
|
||
const persona = d.prepare(`SELECT content_json FROM account_persona WHERE account_id=? ORDER BY id DESC LIMIT 1`).get(v.account_id);
|
||
if (persona && typeof persona.content_json === "string" && persona.content_json.trim()) {
|
||
setting = trimPersonaForRewrite(persona.content_json);
|
||
}
|
||
}
|
||
if (!setting && acc && typeof acc.content === "string") setting = acc.content.trim();
|
||
const topic = v.topic && typeof v.topic === "string" ? v.topic.trim() : "";
|
||
if (!setting || !topic) {
|
||
const missing = [!setting ? "账号设定" : null, !topic ? "视频选题" : null].filter(Boolean).join("、");
|
||
json(res, 400, { ok: false, error: `该视频缺少${missing},无法改写(需先完成账号设定提炼与视频选题解析)` });
|
||
return;
|
||
}
|
||
const now = new Date().toISOString();
|
||
// 自增 id 跳过已归档会话 id(防删除后 id 复用继承旧归档状态 → 侧边栏不显示)
|
||
const logId = nextTaskIdAvoidingArchived(d, ctx, REWRITE_PREFIX, "rewrite_log");
|
||
const ins = d.prepare(`INSERT INTO rewrite_log (id, video_id, aweme_id, account_id, topic, gen_method, status, created_time, updated_time) VALUES (?, ?, ?, ?, ?, 'ref', 'pending', ?, ?)`);
|
||
const r = ins.run(logId, videoId, v.aweme_id || null, v.account_id || null, topic, now, now);
|
||
rewriteTasks.set(logId, Date.now());
|
||
const { agent } = await getOrCreateRewriteAgent(ctx, logId);
|
||
if (agent.status === "running") {
|
||
json(res, 409, { ok: false, error: "该视频的改写任务正在执行中,请等待完成后再试" });
|
||
return;
|
||
}
|
||
const prompt = `用户请求改写爆款视频「${v.video_title || v.aweme_id || videoId}」的故事脚本。\n`
|
||
+ `请调用「short-video-script」技能(短视频脚本创作技能,已全局注册,路径 ~/.dsh/skills/short-video-script/)的完整流程,基于以下「账号设定」与「视频选题」改写一条全新的 **S9 故事脚本**(角色的行为、对话和情绪推进,不含分镜):\n`
|
||
+ `【账号设定】\n${setting}\n`
|
||
+ `【视频选题】\n${topic}\n`
|
||
+ `要求:\n`
|
||
+ `1. 严格按技能流程生成 S9 故事脚本,最终只输出脚本正文;\n`
|
||
+ `2. 保留原视频的爆款基因(开场钩子/节奏/情绪),但在剧情、台词、场景上有明显改写,不照抄原文;\n`
|
||
+ `3. 完成后调用 POST /mcn/api/rewrite/save 保存:body {"id": ${logId}, "script": "<S9 故事脚本全文>"}(接口基地址用 $env:DSH_WEB_URL,为空则依次试 http://127.0.0.1:3081、http://127.0.0.1:3080);\n`
|
||
+ `4. 保存方式(重要,避免中文编码问题):先把脚本全文写入工作区文件(如 save_${logId}.json,用文件工具写 UTF-8 JSON,注意字符串转义),再用 curl.exe --data-binary "@save_${logId}.json" -H "Content-Type: application/json" 发送;或使用可用的 HTTP 工具。切勿用 PowerShell 的 Invoke-RestMethod 直接传含中文的 -Body 字符串(Windows PowerShell 5.1 会按 GBK 编码发送,导致服务端解析失败或乱码);\n`
|
||
+ `5. 保存接口返回 ok 才算完成;若返回 400/500,检查 JSON 转义与编码后重试,不要用测试文本占位。`;
|
||
agent.followup(createUserMessage({ content: [{ type: "text", text: prompt }], source: { kind: "user" } }));
|
||
d.prepare(`UPDATE rewrite_log SET status='running', updated_time=? WHERE id=?`).run(now, logId);
|
||
json(res, 200, { ok: true, id: logId, message: "已提交改写,AI 正在生成 S9 故事脚本(当前并发 " + runningRewriteCount(ctx) + "/" + REWRITE_MAX_CONCURRENT + ")" });
|
||
} catch (e) {
|
||
console.error(`[dsh-plugin-mcn] 视频改写提交失败: ${e.message}`);
|
||
json(res, 500, { ok: false, error: e.message });
|
||
}
|
||
},
|
||
});
|
||
|
||
// 改写结果保存(AI 会话调用):{id, script}
|
||
server.register({
|
||
path: "/mcn/api/rewrite/save",
|
||
exact: true,
|
||
handler: async (req, res) => {
|
||
try {
|
||
const body = await readBody(req);
|
||
const id = Number(body.id);
|
||
if (!Number.isFinite(id) || id <= 0) { json(res, 400, { ok: false, error: "缺少有效的 id 参数" }); return; }
|
||
const script = typeof body.script === "string" && body.script.trim() ? body.script.trim() : "";
|
||
if (!script) { json(res, 400, { ok: false, error: "脚本内容为空" }); return; }
|
||
const d = initDb();
|
||
const rec = d.prepare(`SELECT * FROM rewrite_log WHERE id=?`).get(id);
|
||
if (!rec) { json(res, 404, { ok: false, error: "改写记录不存在" }); return; }
|
||
d.prepare(`UPDATE rewrite_log SET script_text=?, title=?, status='done', updated_time=? WHERE id=?`).run(script, extractScriptTitle(script), new Date().toISOString(), id);
|
||
statsCache = { ts: 0, data: null }; // 失效统计缓存(改写脚本计数变化)
|
||
// 改写任务完成:不立即归档(会话保留在侧边栏,与解析任务一致),
|
||
// 由 cleanupOldMcNSessions(>24h 未活动且不在运行中 → 物理删除 + detach)统一清理
|
||
cleanupUngroupedSessions(ctx).catch(() => {});
|
||
json(res, 200, { ok: true });
|
||
} catch (e) {
|
||
console.error(`[dsh-plugin-mcn] 改写结果保存失败: ${e.message}`);
|
||
json(res, 500, { ok: false, error: e.message });
|
||
}
|
||
},
|
||
});
|
||
|
||
// 改写脚本删除:{id}(rewrite_log 记录,删除后弹窗/列表同步刷新)
|
||
server.register({
|
||
path: "/mcn/api/rewrite/delete",
|
||
exact: true,
|
||
handler: async (req, res) => {
|
||
try {
|
||
const body = await readBody(req);
|
||
const id = Number(body.id);
|
||
if (!Number.isFinite(id) || id <= 0) { json(res, 400, { ok: false, error: "缺少有效的 id 参数" }); return; }
|
||
const d = initDb();
|
||
const r = d.prepare(`DELETE FROM rewrite_log WHERE id=?`).run(id);
|
||
if (r.changes === 0) { json(res, 404, { ok: false, error: "改写记录不存在" }); return; }
|
||
// 联动删除该脚本的 ai/compare 复盘(source 为视频级,保留复用)
|
||
try { d.prepare(`DELETE FROM script_review WHERE rewrite_id=? AND type IN ('ai','compare')`).run(id); } catch (e) { /* 联动删除失败不影响主删除 */ }
|
||
// 联动删除该脚本的分镜提示词(storyboard_log 按 rewrite_id 关联)+ 对应会话目录
|
||
const sbIds = [];
|
||
try { sbIds.push(...d.prepare(`SELECT id FROM storyboard_log WHERE rewrite_id=?`).all(id).map((x) => x.id)); d.prepare(`DELETE FROM storyboard_log WHERE rewrite_id=?`).run(id); } catch (e) { /* 分镜联动删除失败不影响主删除 */ }
|
||
// 同步删除对应会话目录(防止 logId 被 SQLite 复用后旧会话上下文污染新任务)
|
||
try {
|
||
const sessionsRoot = join(homedir(), ".dsh", "sessions");
|
||
if (existsSync(sessionsRoot)) {
|
||
for (const grp of readdirSync(sessionsRoot, { withFileTypes: true }).filter((e) => e.isDirectory())) {
|
||
const gdir = join(sessionsRoot, grp.name);
|
||
const sids = [REWRITE_PREFIX + id, reviewSessionId("ai", id), reviewSessionId("compare", id)];
|
||
for (const sbId of sbIds) sids.push(STORYBOARD_PREFIX + sbId);
|
||
for (const sid of sids) {
|
||
const sdir = join(gdir, sid);
|
||
if (existsSync(sdir)) rmSync(sdir, { recursive: true, force: true });
|
||
}
|
||
}
|
||
}
|
||
} catch (e) { /* 会话清理失败不影响删除结果 */ }
|
||
json(res, 200, { ok: true });
|
||
} catch (e) {
|
||
console.error(`[dsh-plugin-mcn] 改写脚本删除失败: ${e.message}`);
|
||
json(res, 500, { ok: false, error: e.message });
|
||
}
|
||
},
|
||
});
|
||
|
||
// 视频源脚本与改写记录:?videoId=(弹窗对比用:左源脚本 + 右改写脚本列表)
|
||
server.register({
|
||
path: "/mcn/api/video/script",
|
||
exact: true,
|
||
handler: async (req, res) => {
|
||
try {
|
||
const q = parseQuery(req.url);
|
||
const videoId = Number(q.videoId);
|
||
if (!Number.isFinite(videoId) || videoId <= 0) { json(res, 400, { ok: false, error: "缺少有效的 videoId 参数" }); return; }
|
||
const d = initDb();
|
||
let source = null;
|
||
const src = d.prepare(`SELECT content_json FROM account_video_source WHERE video_id=? OR aweme_id=(SELECT aweme_id FROM account_videos WHERE id=?) ORDER BY id DESC LIMIT 1`).get(videoId, videoId);
|
||
if (src && src.content_json) {
|
||
try {
|
||
const j = JSON.parse(src.content_json);
|
||
const c = String(j.content || j.script || src.content_json);
|
||
const idx = c.indexOf("# 视频脚本");
|
||
source = idx >= 0 ? c.slice(idx) : c;
|
||
} catch (e) { source = src.content_json; }
|
||
}
|
||
const rewrites = d.prepare(`SELECT id, script_text, created_time FROM rewrite_log WHERE video_id=? AND script_text IS NOT NULL AND script_text <> '' ORDER BY id DESC`).all(videoId);
|
||
json(res, 200, { ok: true, source, rewrites });
|
||
} catch (e) {
|
||
console.error(`[dsh-plugin-mcn] 视频脚本查询失败: ${e.message}`);
|
||
json(res, 500, { ok: false, error: e.message });
|
||
}
|
||
},
|
||
});
|
||
|
||
// 改写历史查询:?videoId=(该视频的改写记录;AI写作复盘 列表数据源,附带三类复盘结果摘要)
|
||
server.register({
|
||
path: "/mcn/api/rewrite/list",
|
||
exact: true,
|
||
handler: async (req, res) => {
|
||
try {
|
||
const q = parseQuery(req.url);
|
||
const videoId = Number(q.videoId);
|
||
const d = initDb();
|
||
const rows = videoId > 0
|
||
? d.prepare(`SELECT id, video_id, status, title, created_time, updated_time, length(script_text) AS script_len FROM rewrite_log WHERE video_id=? ORDER BY id DESC`).all(videoId)
|
||
: d.prepare(`SELECT id, video_id, status, title, created_time, updated_time, length(script_text) AS script_len FROM rewrite_log ORDER BY id DESC LIMIT 100`).all();
|
||
// 复盘结果摘要联表:ai/compare 按 rewrite_id;source 按 video_id(视频级一份,避免重复复盘源脚本)
|
||
const revCols = `id, type, rewrite_id, video_id, dims_json, total, verdict, status, created_time, updated_time`;
|
||
const aiRev = new Map();
|
||
const cmpRev = new Map();
|
||
const srcRev = new Map();
|
||
const ids = rows.map((r) => r.id);
|
||
const vidIds = [...new Set(rows.map((r) => r.video_id).filter((v) => v > 0))];
|
||
if (ids.length > 0) {
|
||
for (const rv of d.prepare(`SELECT ${revCols} FROM script_review WHERE type IN ('ai','compare') AND rewrite_id IN (${ids.map(() => "?").join(",")}) ORDER BY id DESC`).all(...ids)) {
|
||
if (rv.type === "ai") { if (!aiRev.has(rv.rewrite_id)) aiRev.set(rv.rewrite_id, rv); }
|
||
else if (!cmpRev.has(rv.rewrite_id)) cmpRev.set(rv.rewrite_id, rv);
|
||
}
|
||
}
|
||
if (vidIds.length > 0) {
|
||
for (const rv of d.prepare(`SELECT ${revCols} FROM script_review WHERE type='source' AND video_id IN (${vidIds.map(() => "?").join(",")}) ORDER BY id DESC`).all(...vidIds)) {
|
||
if (!srcRev.has(rv.video_id)) srcRev.set(rv.video_id, rv);
|
||
}
|
||
}
|
||
// 复盘运行中标记:以 script_review.status='running' 行为准(任务真在执行中;
|
||
// 不用内存 agent 状态——归档/完成后 agent.status 可能未及时更新导致误报)
|
||
const runningKeys = new Set();
|
||
for (const rr of d.prepare(`SELECT type, rewrite_id, video_id, status FROM script_review WHERE status='running'`).all()) {
|
||
const key = rr.type === "source" ? "source:" + rr.video_id : rr.type + ":" + rr.rewrite_id;
|
||
runningKeys.add(key);
|
||
}
|
||
for (const it of rows) {
|
||
it.review_ai = aiRev.get(it.id) || null;
|
||
it.review_compare = cmpRev.get(it.id) || null;
|
||
it.review_source = (it.video_id && srcRev.get(it.video_id)) || null;
|
||
it.review_running = runningKeys.has("ai:" + it.id)
|
||
|| runningKeys.has("compare:" + it.id)
|
||
|| (it.video_id && runningKeys.has("source:" + it.video_id));
|
||
}
|
||
json(res, 200, { ok: true, items: rows });
|
||
} catch (e) {
|
||
json(res, 500, { ok: false, error: e.message });
|
||
}
|
||
},
|
||
});
|
||
|
||
// 复盘提交:{logId, type}(type: ai=AI脚本复盘 / source=原视频脚本复盘 / compare=对比分析)
|
||
// 三份独立存储:ai/compare 按 rewrite_id 去重;source 按 video_id 去重(同一视频多版脚本只复盘一次源脚本)
|
||
server.register({
|
||
path: "/mcn/api/review/start",
|
||
exact: true,
|
||
handler: async (req, res) => {
|
||
try {
|
||
const body = await readBody(req);
|
||
const logId = Number(body.logId);
|
||
const type = String(body.type || "");
|
||
if (!Number.isFinite(logId) || logId <= 0) { json(res, 400, { ok: false, error: "缺少有效的 logId 参数" }); return; }
|
||
if (!["ai", "source", "compare"].includes(type)) { json(res, 400, { ok: false, error: "缺少有效的 type 参数(ai/source/compare)" }); return; }
|
||
if (runningReviewCount(ctx) >= REVIEW_MAX_CONCURRENT) {
|
||
json(res, 409, { ok: false, error: `复盘任务已达并发上限(${REVIEW_MAX_CONCURRENT} 条),请等待部分完成后再提交` });
|
||
return;
|
||
}
|
||
const d = initDb();
|
||
const rw = d.prepare(`SELECT * FROM rewrite_log WHERE id=?`).get(logId);
|
||
if (!rw) { json(res, 404, { ok: false, error: "改写记录不存在" }); return; }
|
||
const script = rw.script_text && String(rw.script_text).trim() ? String(rw.script_text).trim() : "";
|
||
if (!script) { json(res, 400, { ok: false, error: "该 AI 脚本没有内容,无法复盘" }); return; }
|
||
const videoId = rw.video_id || 0;
|
||
const now = new Date().toISOString();
|
||
const video = videoId ? d.prepare(`SELECT video_title FROM account_videos WHERE id=?`).get(videoId) : null;
|
||
const title = (video && video.video_title) || rw.aweme_id || logId;
|
||
// 源脚本正文提取(复用 /video/script 逻辑:优先 # 视频脚本 新格式)
|
||
const readSource = () => {
|
||
const src = videoId ? d.prepare(`SELECT content_json FROM account_video_source WHERE video_id=? OR aweme_id=(SELECT aweme_id FROM account_videos WHERE id=?) ORDER BY id DESC LIMIT 1`).get(videoId, videoId) : null;
|
||
if (!src || !src.content_json) return "";
|
||
try {
|
||
const j = JSON.parse(src.content_json);
|
||
const c = String(j.content || j.script || src.content_json);
|
||
const idx = c.indexOf("# 视频脚本");
|
||
return idx >= 0 ? c.slice(idx) : c;
|
||
} catch (e) { return src.content_json; }
|
||
};
|
||
// 复盘会话已存在且正在执行 → 409(防重复提交)
|
||
const sessionId = reviewSessionId(type, type === "source" ? videoId : logId);
|
||
const liveAgent = ctx.agents.get(sessionId);
|
||
if (liveAgent !== undefined && liveAgent.status === "running") {
|
||
json(res, 409, { ok: false, error: "该对象的复盘任务正在执行中,请等待完成后再试" });
|
||
return;
|
||
}
|
||
// 已存在 done 结果 → 409 + 返回已有(避免重复复盘)
|
||
const doneKey = type === "source"
|
||
? d.prepare(`SELECT id, type, rewrite_id, video_id, dims_json, total, verdict, status, created_time, updated_time FROM script_review WHERE type='source' AND video_id=? AND status='done' ORDER BY id DESC LIMIT 1`).get(videoId)
|
||
: d.prepare(`SELECT id, type, rewrite_id, video_id, dims_json, total, verdict, status, created_time, updated_time FROM script_review WHERE type=? AND rewrite_id=? AND status='done' ORDER BY id DESC LIMIT 1`).get(type, logId);
|
||
if (doneKey) {
|
||
json(res, 409, { ok: false, error: type === "source" ? "该视频源脚本已复盘,直接查看即可(避免重复复盘)" : "该脚本已复盘,直接查看即可(避免重复复盘)", review: doneKey });
|
||
return;
|
||
}
|
||
const runKey = type === "source"
|
||
? d.prepare(`SELECT id FROM script_review WHERE type='source' AND video_id=? AND status='running' ORDER BY id DESC LIMIT 1`).get(videoId)
|
||
: d.prepare(`SELECT id FROM script_review WHERE type=? AND rewrite_id=? AND status='running' ORDER BY id DESC LIMIT 1`).get(type, logId);
|
||
if (runKey) { json(res, 409, { ok: false, error: "该对象的复盘任务正在执行中,请等待完成后再试" }); return; }
|
||
|
||
let reviewId;
|
||
let prompt;
|
||
if (type === "source") {
|
||
if (!videoId) { json(res, 400, { ok: false, error: "该脚本没有关联视频,无法复盘源脚本" }); return; }
|
||
const sourceText = readSource();
|
||
if (!sourceText) { json(res, 400, { ok: false, error: "该视频没有源脚本正文,无法复盘源脚本(需先完成视频解析)" }); return; }
|
||
const ins = d.prepare(`INSERT INTO script_review (type, video_id, status, created_time, updated_time) VALUES ('source', ?, 'running', ?, ?)`).run(videoId, now, now);
|
||
reviewId = Number(ins.lastInsertRowid);
|
||
reviewTasks.set("source:" + videoId, Date.now());
|
||
prompt = `用户请求对视频「${title}」的原视频脚本做质量复盘。\n`
|
||
+ `请调用「script-review」技能(脚本复盘技能,已全局注册,路径 ~/.dsh/skills/script-review/)的【独立分析模式(评分制)】:\n`
|
||
+ `1. 读取下方【源脚本全文】与【选题】,先判定内容类型(剧情/教程/口播),决定检查范围(剧情类查故事成立性、教程类查教程完整链,互不误用);\n`
|
||
+ `2. 按 10 维逐维评分(每维 100 分制,锚点 90-100 优秀/75-89 良好/60-74 及格/40-59 有问题/0-39 严重):表格6维【设定承接/框架节奏/钩子设置(全片留人链路)/叙事逻辑/极致事件/共鸣和共情】+ 追加4维【格式规范性/台词口语化/内容合规/教程完整链(仅教程类评分,不适用给 null)】;\n`
|
||
+ `3. 输出复盘结果后调用 POST /mcn/api/review/save 保存(接口基地址用 $env:DSH_WEB_URL,为空则依次试 http://127.0.0.1:3081、http://127.0.0.1:3080):\n`
|
||
+ ` body {"id": ${reviewId}, "dims": {"设定承接":85,"框架节奏":80,...,"教程完整链":null}, "issues": [{"severity":"高|中|低","issue":"问题描述","locate":"定位(场/段)","suggestion":"修改建议","step":"所属环节S5-S11"}], "summary": "综合评语2-3句", "content": "完整复盘报告(markdown:各维得分+档位+依据+问题清单+建议)"}\n`
|
||
+ ` (total/verdict 由服务端按 dims 等权平均自动计算,无需提交;严重度按技能规则执行)\n`
|
||
+ `4. 保存方式(重要,避免中文编码问题):先把 JSON 写入工作区文件(如 save_review_${reviewId}.json,用文件工具写 UTF-8,注意字符串转义),再用 curl.exe --data-binary "@save_review_${reviewId}.json" -H "Content-Type: application/json" 发送;或使用可用的 HTTP 工具。切勿用 PowerShell 的 Invoke-RestMethod 直接传含中文的 -Body 字符串(Windows PowerShell 5.1 会按 GBK 编码发送,导致服务端解析失败或乱码);\n`
|
||
+ `5. 保存接口返回 ok 才算完成;若返回 400/500,检查 JSON 转义与编码后重试,不要用测试文本占位。\n`
|
||
+ `【源脚本全文】\n${sourceText}\n`
|
||
+ `【选题】\n${rw.topic || ""}\n`;
|
||
} else if (type === "ai") {
|
||
const ins = d.prepare(`INSERT INTO script_review (type, rewrite_id, video_id, status, created_time, updated_time) VALUES ('ai', ?, ?, 'running', ?, ?)`).run(logId, videoId || null, now, now);
|
||
reviewId = Number(ins.lastInsertRowid);
|
||
reviewTasks.set("ai:" + logId, Date.now());
|
||
prompt = `用户请求对 AI 写脚本 #${logId}(视频「${title}」)做质量复盘。\n`
|
||
+ `请调用「script-review」技能(脚本复盘技能,已全局注册,路径 ~/.dsh/skills/script-review/)的【独立分析模式(评分制)】:\n`
|
||
+ `1. 读取下方【AI脚本全文】与【选题】,先判定内容类型(剧情/教程/口播),决定检查范围(剧情类查故事成立性、教程类查教程完整链,互不误用);\n`
|
||
+ `2. 按 10 维逐维评分(每维 100 分制,锚点 90-100 优秀/75-89 良好/60-74 及格/40-59 有问题/0-39 严重):表格6维【设定承接/框架节奏/钩子设置(全片留人链路)/叙事逻辑/极致事件/共鸣和共情】+ 追加4维【格式规范性/台词口语化/内容合规/教程完整链(仅教程类评分,不适用给 null)】;\n`
|
||
+ `3. 输出复盘结果后调用 POST /mcn/api/review/save 保存(接口基地址用 $env:DSH_WEB_URL,为空则依次试 http://127.0.0.1:3081、http://127.0.0.1:3080):\n`
|
||
+ ` body {"id": ${reviewId}, "dims": {"设定承接":85,"框架节奏":80,...,"教程完整链":null}, "issues": [{"severity":"高|中|低","issue":"问题描述","locate":"定位(场/段)","suggestion":"修改建议","step":"所属环节S5-S11"}], "summary": "综合评语2-3句", "content": "完整复盘报告(markdown:各维得分+档位+依据+问题清单+建议)"}\n`
|
||
+ ` (total/verdict 由服务端按 dims 等权平均自动计算,无需提交;严重度按技能规则执行)\n`
|
||
+ `4. 保存方式(重要,避免中文编码问题):先把 JSON 写入工作区文件(如 save_review_${reviewId}.json,用文件工具写 UTF-8,注意字符串转义),再用 curl.exe --data-binary "@save_review_${reviewId}.json" -H "Content-Type: application/json" 发送;或使用可用的 HTTP 工具。切勿用 PowerShell 的 Invoke-RestMethod 直接传含中文的 -Body 字符串(Windows PowerShell 5.1 会按 GBK 编码发送,导致服务端解析失败或乱码);\n`
|
||
+ `5. 保存接口返回 ok 才算完成;若返回 400/500,检查 JSON 转义与编码后重试,不要用测试文本占位。\n`
|
||
+ `【AI脚本全文】\n${script}\n`
|
||
+ `【选题】\n${rw.topic || ""}\n`;
|
||
} else {
|
||
// compare:前置要求两份独立复盘已完成(source 按 video_id、ai 按 rewrite_id)
|
||
if (!videoId) { json(res, 400, { ok: false, error: "该脚本没有关联视频,无法对比分析" }); return; }
|
||
const aiRev = d.prepare(`SELECT * FROM script_review WHERE type='ai' AND rewrite_id=? AND status='done' ORDER BY id DESC LIMIT 1`).get(logId);
|
||
const srcRev = d.prepare(`SELECT * FROM script_review WHERE type='source' AND video_id=? AND status='done' ORDER BY id DESC LIMIT 1`).get(videoId);
|
||
if (!aiRev) { json(res, 409, { ok: false, error: "请先完成该脚本的 AI 脚本复盘,再进行对比分析" }); return; }
|
||
if (!srcRev) { json(res, 409, { ok: false, error: "请先完成该视频的原视频脚本复盘,再进行对比分析" }); return; }
|
||
const ins = d.prepare(`INSERT INTO script_review (type, rewrite_id, video_id, status, created_time, updated_time) VALUES ('compare', ?, ?, 'running', ?, ?)`).run(logId, videoId, now, now);
|
||
reviewId = Number(ins.lastInsertRowid);
|
||
reviewTasks.set("compare:" + logId, Date.now());
|
||
prompt = `用户请求对视频「${title}」的 AI 写脚本 #${logId} 与源脚本做对比分析。\n`
|
||
+ `请调用「script-review」技能(脚本复盘技能,已全局注册,路径 ~/.dsh/skills/script-review/)的【对比分析模式(三段式)】。\n`
|
||
+ `两份独立复盘已存在,**无需重新分析任何脚本**,直接基于两份复盘结果做第 3 段对比:\n`
|
||
+ `1. 读取下方【AI脚本复盘结果】与【源脚本复盘结果】;\n`
|
||
+ `2. 逐维对比表:**相同的点 / AI 好的点 / AI 差的点 / 中立结论(AI优于/劣于/持平源)**,每维给依据;\n`
|
||
+ `3. 总分对比:AI X/100 vs 源 Y/100(取自两份复盘的 total);\n`
|
||
+ `4. 综合判断:AI 整体优于/劣于/各有优劣 + 建议(保留优势、补回被削弱的);\n`
|
||
+ `5. 输出后调用 POST /mcn/api/review/save 保存(接口基地址用 $env:DSH_WEB_URL,为空则依次试 http://127.0.0.1:3081、http://127.0.0.1:3080):\n`
|
||
+ ` body {"id": ${reviewId}, "dims": {"ai": {AI 10维...}, "source": {源 10维...}}, "issues": [{"severity":"高|中|低","issue":"问题描述","suggestion":"建议"}], "summary": "综合判断1-2句", "verdict": "AI 整体优于/劣于/各有优劣源", "content": "完整三段式对比报告(markdown)"}\n`
|
||
+ ` (total 由服务端按 dims 等权平均自动计算,无需提交;严重度按技能规则执行)\n`
|
||
+ `6. 保存方式(重要,避免中文编码问题):先把 JSON 写入工作区文件(如 save_review_${reviewId}.json,用文件工具写 UTF-8,注意字符串转义),再用 curl.exe --data-binary "@save_review_${reviewId}.json" -H "Content-Type: application/json" 发送;或使用可用的 HTTP 工具。切勿用 PowerShell 的 Invoke-RestMethod 直接传含中文的 -Body 字符串(Windows PowerShell 5.1 会按 GBK 编码发送,导致服务端解析失败或乱码);\n`
|
||
+ `7. 保存接口返回 ok 才算完成;若返回 400/500,检查 JSON 转义与编码后重试,不要用测试文本占位。\n`
|
||
+ `【AI脚本复盘结果】\n${JSON.stringify({ dims: aiRev.dims_json, total: aiRev.total, verdict: aiRev.verdict, issues: aiRev.issues_json, summary: aiRev.summary })}\n`
|
||
+ `【源脚本复盘结果】\n${JSON.stringify({ dims: srcRev.dims_json, total: srcRev.total, verdict: srcRev.verdict, issues: srcRev.issues_json, summary: srcRev.summary })}\n`;
|
||
}
|
||
const { agent } = await getOrCreateReviewAgent(ctx, type, type === "source" ? videoId : logId);
|
||
if (agent.status === "running") {
|
||
json(res, 409, { ok: false, error: "该对象的复盘任务正在执行中,请等待完成后再试" });
|
||
return;
|
||
}
|
||
agent.followup(createUserMessage({ content: [{ type: "text", text: prompt }], source: { kind: "user" } }));
|
||
d.prepare(`UPDATE script_review SET updated_time=? WHERE id=?`).run(now, reviewId);
|
||
const typeName = type === "source" ? "原视频脚本复盘" : type === "ai" ? "AI 脚本复盘" : "对比分析";
|
||
json(res, 200, { ok: true, id: reviewId, type, message: `已提交${typeName},AI 正在执行(当前并发 ${runningReviewCount(ctx)}/${REVIEW_MAX_CONCURRENT})` });
|
||
} catch (e) {
|
||
console.error(`[dsh-plugin-mcn] 复盘提交失败: ${e.message}`);
|
||
json(res, 500, { ok: false, error: e.message });
|
||
}
|
||
},
|
||
});
|
||
|
||
// 复盘结果保存(AI 会话调用):{id, dims, issues, summary, verdict, content}
|
||
// 服务端按 dims 等权平均计算 total 与"其他"聚合分;compare 模式 dims 为 {ai:{...}, source:{...}}
|
||
server.register({
|
||
path: "/mcn/api/review/save",
|
||
exact: true,
|
||
handler: async (req, res) => {
|
||
try {
|
||
const body = await readBody(req);
|
||
const id = Number(body.id);
|
||
if (!Number.isFinite(id) || id <= 0) { json(res, 400, { ok: false, error: "缺少有效的 id 参数" }); return; }
|
||
const d = initDb();
|
||
const rec = d.prepare(`SELECT * FROM script_review WHERE id=?`).get(id);
|
||
if (!rec) { json(res, 404, { ok: false, error: "复盘记录不存在" }); return; }
|
||
if (typeof body.dims !== "object" || body.dims === null) { json(res, 400, { ok: false, error: "复盘结果缺少 dims 评分数据" }); return; }
|
||
// 评分计算:叙事逻辑 2 倍权重(故事逻辑是剧情类立身之本),忽略 null 不适用维
|
||
const avgOf = (obj) => {
|
||
const entries = Object.entries(obj).filter(([k, v]) => typeof v === "number" && Number.isFinite(v));
|
||
if (entries.length === 0) return null;
|
||
let sum = 0, wsum = 0;
|
||
for (const [k, v] of entries) { const w = k === "叙事逻辑" ? 2 : 1; sum += v * w; wsum += w; }
|
||
return Math.round((sum / wsum) * 10) / 10;
|
||
};
|
||
const OTHER_DIMS = ["格式规范性", "台词口语化", "内容合规", "教程完整链"];
|
||
let dimsStored;
|
||
let totalStored;
|
||
let verdictStored = typeof body.verdict === "string" && body.verdict.trim() ? body.verdict.trim() : "";
|
||
if (rec.type === "compare") {
|
||
const ai = body.dims.ai || {};
|
||
const src = body.dims.source || {};
|
||
const otherAi = avgOf(Object.fromEntries(Object.entries(ai).filter(([k]) => OTHER_DIMS.includes(k))));
|
||
const otherSrc = avgOf(Object.fromEntries(Object.entries(src).filter(([k]) => OTHER_DIMS.includes(k))));
|
||
dimsStored = JSON.stringify({ ai: { ...ai, 其他: otherAi }, source: { ...src, 其他: otherSrc } });
|
||
let totalAi = avgOf(ai);
|
||
let totalSrc = avgOf(src);
|
||
// 叙事逻辑 <80 → 各自总分不超过叙事逻辑分(与单复盘口径一致)
|
||
const logicAi = typeof ai["叙事逻辑"] === "number" ? ai["叙事逻辑"] : null;
|
||
if (logicAi !== null && logicAi < 80 && totalAi !== null) totalAi = Math.min(totalAi, logicAi);
|
||
const logicSrc = typeof src["叙事逻辑"] === "number" ? src["叙事逻辑"] : null;
|
||
if (logicSrc !== null && logicSrc < 80 && totalSrc !== null) totalSrc = Math.min(totalSrc, logicSrc);
|
||
totalStored = JSON.stringify({ ai: totalAi, source: totalSrc });
|
||
if (!verdictStored && totalAi != null && totalSrc != null) {
|
||
verdictStored = totalAi - totalSrc >= 1 ? "AI 整体优于源" : totalSrc - totalAi >= 1 ? "AI 整体劣于源" : "AI 与源各有优劣";
|
||
}
|
||
} else {
|
||
const other = avgOf(Object.fromEntries(Object.entries(body.dims).filter(([k]) => OTHER_DIMS.includes(k))));
|
||
dimsStored = JSON.stringify({ ...body.dims, 其他: other });
|
||
totalStored = avgOf(body.dims);
|
||
// 叙事逻辑 <80 → 总分不超过叙事逻辑分(逻辑是根本,总分不能高于逻辑分)
|
||
const logicScore = typeof body.dims["叙事逻辑"] === "number" ? body.dims["叙事逻辑"] : null;
|
||
if (logicScore !== null && logicScore < 80 && totalStored !== null) {
|
||
totalStored = Math.min(totalStored, logicScore);
|
||
}
|
||
if (!verdictStored && totalStored != null) {
|
||
verdictStored = totalStored >= 75 ? "可交付" : totalStored >= 60 ? "需优化" : "需大改";
|
||
}
|
||
}
|
||
const issues = typeof body.issues === "string" ? body.issues : (body.issues ? JSON.stringify(body.issues) : null);
|
||
const summary = typeof body.summary === "string" ? body.summary.trim() : "";
|
||
const content = typeof body.content === "string" ? body.content.trim() : "";
|
||
d.prepare(`UPDATE script_review SET dims_json=?, total=?, verdict=?, issues_json=?, summary=?, content=?, status='done', updated_time=? WHERE id=?`)
|
||
.run(dimsStored, totalStored != null ? String(totalStored) : null, verdictStored, issues, summary, content, new Date().toISOString(), id);
|
||
// 复盘任务完成:不立即归档(会话保留在侧边栏,与解析任务一致),
|
||
// 由 cleanupOldMcNSessions(>24h 未活动且不在运行中 → 物理删除 + detach)统一清理
|
||
cleanupUngroupedSessions(ctx).catch(() => {});
|
||
json(res, 200, { ok: true });
|
||
} catch (e) {
|
||
console.error(`[dsh-plugin-mcn] 复盘结果保存失败: ${e.message}`);
|
||
json(res, 500, { ok: false, error: e.message });
|
||
}
|
||
},
|
||
});
|
||
|
||
// 复盘详情:?id=(完整结果,含 content 报告全文与 issues 清单)
|
||
server.register({
|
||
path: "/mcn/api/review/detail",
|
||
exact: true,
|
||
handler: async (req, res) => {
|
||
try {
|
||
const q = parseQuery(req.url);
|
||
const id = Number(q.id);
|
||
if (!Number.isFinite(id) || id <= 0) { json(res, 400, { ok: false, error: "缺少有效的 id 参数" }); return; }
|
||
const d = initDb();
|
||
const rv = d.prepare(`SELECT * FROM script_review WHERE id=?`).get(id);
|
||
if (!rv) { json(res, 404, { ok: false, error: "复盘记录不存在" }); return; }
|
||
json(res, 200, { ok: true, review: rv });
|
||
} catch (e) {
|
||
json(res, 500, { ok: false, error: e.message });
|
||
}
|
||
},
|
||
});
|
||
|
||
// 复盘数据删除:{rewriteId}(删除该 AI 脚本的 ai/compare 复盘记录与对应会话;
|
||
// source 为视频级共享保留,脚本本身(rewrite_log)保留)
|
||
server.register({
|
||
path: "/mcn/api/review/delete",
|
||
exact: true,
|
||
handler: async (req, res) => {
|
||
try {
|
||
const body = await readBody(req);
|
||
const rewriteId = Number(body.rewriteId);
|
||
if (!Number.isFinite(rewriteId) || rewriteId <= 0) { json(res, 400, { ok: false, error: "缺少有效的 rewriteId 参数" }); return; }
|
||
const d = initDb();
|
||
const r = d.prepare(`DELETE FROM script_review WHERE rewrite_id=? AND type IN ('ai','compare')`).run(rewriteId);
|
||
// 同步删除对应复盘会话目录(防 id 复用后旧会话上下文污染)
|
||
try {
|
||
const sessionsRoot = join(homedir(), ".dsh", "sessions");
|
||
if (existsSync(sessionsRoot)) {
|
||
for (const grp of readdirSync(sessionsRoot, { withFileTypes: true }).filter((e) => e.isDirectory())) {
|
||
const gdir = join(sessionsRoot, grp.name);
|
||
for (const sid of [reviewSessionId("ai", rewriteId), reviewSessionId("compare", rewriteId)]) {
|
||
const sdir = join(gdir, sid);
|
||
if (existsSync(sdir)) rmSync(sdir, { recursive: true, force: true });
|
||
}
|
||
}
|
||
}
|
||
} catch (e) { /* 会话清理失败不影响删除结果 */ }
|
||
json(res, 200, { ok: true, deleted: r.changes });
|
||
} catch (e) {
|
||
console.error(`[dsh-plugin-mcn] 复盘数据删除失败: ${e.message}`);
|
||
json(res, 500, { ok: false, error: e.message });
|
||
}
|
||
},
|
||
});
|
||
|
||
// 完整复盘三栏数据:?rewriteId=(AI脚本 #N 的 ai/source/compare 三份完整复盘,
|
||
// 供「完整报告」弹窗左=源视频复盘 / 右=AI脚本复盘 / 下=对比分析)
|
||
server.register({
|
||
path: "/mcn/api/review/full",
|
||
exact: true,
|
||
handler: async (req, res) => {
|
||
try {
|
||
const q = parseQuery(req.url);
|
||
const rewriteId = Number(q.rewriteId);
|
||
if (!Number.isFinite(rewriteId) || rewriteId <= 0) { json(res, 400, { ok: false, error: "缺少有效的 rewriteId 参数" }); return; }
|
||
const d = initDb();
|
||
const rw = d.prepare(`SELECT id, video_id FROM rewrite_log WHERE id=?`).get(rewriteId);
|
||
if (!rw) { json(res, 404, { ok: false, error: "改写记录不存在" }); return; }
|
||
const one = (sql, ...p) => d.prepare(sql).get(...p) || null;
|
||
const ai = one(`SELECT * FROM script_review WHERE type='ai' AND rewrite_id=? AND status='done' ORDER BY id DESC LIMIT 1`, rewriteId);
|
||
const cmp = one(`SELECT * FROM script_review WHERE type='compare' AND rewrite_id=? AND status='done' ORDER BY id DESC LIMIT 1`, rewriteId);
|
||
const src = rw.video_id ? one(`SELECT * FROM script_review WHERE type='source' AND video_id=? AND status='done' ORDER BY id DESC LIMIT 1`, rw.video_id) : null;
|
||
json(res, 200, { ok: true, rewriteId, videoId: rw.video_id, reviews: { ai, source: src, compare: cmp } });
|
||
} catch (e) {
|
||
json(res, 500, { ok: false, error: e.message });
|
||
}
|
||
},
|
||
});
|
||
|
||
// AI会话列表:MCN 工作台内查看插件创建的 dsh_* 会话(扫描 sessions 目录,按类型分组)
|
||
server.register({
|
||
path: "/mcn/api/sessions",
|
||
exact: true,
|
||
handler: async (_req, res) => {
|
||
try {
|
||
const items = listMcNSessions(ctx);
|
||
json(res, 200, { ok: true, items });
|
||
} catch (e) {
|
||
console.error(`[dsh-plugin-mcn] AI会话列表失败: ${e.message}`);
|
||
json(res, 500, { ok: false, error: e.message });
|
||
}
|
||
},
|
||
});
|
||
|
||
// AI会话详情:?id=(多帧 zstd 解压会话日志 → 对话消息 user/assistant/tool)
|
||
server.register({
|
||
path: "/mcn/api/session/detail",
|
||
exact: true,
|
||
handler: async (req, res) => {
|
||
try {
|
||
const q = parseQuery(req.url);
|
||
const id = String(q.id || "");
|
||
if (!id || !/^dsh_[\w-]+$/.test(id)) { json(res, 400, { ok: false, error: "缺少有效的会话 id" }); return; }
|
||
const data = readSessionMessages(id);
|
||
if (!data) { json(res, 404, { ok: false, error: "会话不存在" }); return; }
|
||
json(res, 200, { ok: true, id, ...data });
|
||
} catch (e) {
|
||
console.error(`[dsh-plugin-mcn] AI会话详情失败: ${e.message}`);
|
||
json(res, 500, { ok: false, error: e.message });
|
||
}
|
||
},
|
||
});
|
||
|
||
// 分镜提示词生成提交:{rewriteId}(基于指定版本的 AI 改写脚本,调用 storyboard-prompt 技能生成分镜提示词)
|
||
server.register({
|
||
path: "/mcn/api/storyboard/create",
|
||
exact: true,
|
||
handler: async (req, res) => {
|
||
try {
|
||
const body = await readBody(req);
|
||
const rewriteId = Number(body.rewriteId);
|
||
if (!Number.isFinite(rewriteId) || rewriteId <= 0) { json(res, 400, { ok: false, error: "缺少有效的 rewriteId 参数" }); return; }
|
||
if (runningStoryboardCount(ctx) >= STORYBOARD_MAX_CONCURRENT) {
|
||
json(res, 409, { ok: false, error: `分镜提示词任务已达并发上限(${STORYBOARD_MAX_CONCURRENT} 条),请等待部分完成后再提交` });
|
||
return;
|
||
}
|
||
const d = initDb();
|
||
const rw = d.prepare(`SELECT * FROM rewrite_log WHERE id=?`).get(rewriteId);
|
||
if (!rw) { json(res, 404, { ok: false, error: "改写记录不存在" }); return; }
|
||
const script = rw.script_text && String(rw.script_text).trim() ? String(rw.script_text).trim() : "";
|
||
if (!script) { json(res, 400, { ok: false, error: "该改写版本没有脚本内容,无法生成分镜提示词" }); return; }
|
||
const now = new Date().toISOString();
|
||
// 自增 id 跳过已归档会话 id(防 id 复用继承旧归档状态 → 侧边栏不显示)
|
||
const logId = nextTaskIdAvoidingArchived(d, ctx, STORYBOARD_PREFIX, "storyboard_log");
|
||
const ins = d.prepare(`INSERT INTO storyboard_log (id, video_id, rewrite_id, status, created_time, updated_time) VALUES (?, ?, ?, 'pending', ?, ?)`);
|
||
const r = ins.run(logId, rw.video_id || null, rewriteId, now, now);
|
||
storyboardTasks.set(logId, Date.now());
|
||
const { agent } = await getOrCreateStoryboardAgent(ctx, logId);
|
||
if (agent.status === "running") {
|
||
json(res, 409, { ok: false, error: "该视频的分镜提示词任务正在执行中,请等待完成后再试" });
|
||
return;
|
||
}
|
||
const video = rw.video_id ? d.prepare(`SELECT * FROM account_videos WHERE id=?`).get(rw.video_id) : null;
|
||
const title = video && video.video_title ? video.video_title : (rw.aweme_id || rw.video_id || rewriteId);
|
||
const prompt = `用户请求为视频「${title}」的 AI 改写脚本生成分镜提示词。\n`
|
||
+ `请调用「storyboard-prompt」技能(短视频分镜脚本技能,已全局注册,路径 ~/.dsh/skills/storyboard-prompt/)的 **F4 功能(生成分镜提示词:脚本→分镜提示词序列)**,基于以下「AI改写脚本」生成可直接用于 AI 生视频平台(即梦 Seedance 2.0/2.5、NanoBanana 等)的分镜提示词序列:\n`
|
||
+ `【AI改写脚本】\n${script}\n`
|
||
+ `要求:\n`
|
||
+ `1. 严格按技能 SKILL.md 的 **4 层结构**(制作流程第三节 3.2)生成,最终只输出分镜提示词正文;\n`
|
||
+ `2. **分段输出(重要)**:先把「AI改写脚本」按叙事段落/场景切分为**多段**(参考技能「长视频分段制作流程」:每段 3~6 个镜头、时长 15 秒内为宜),**每段(即一个分镜)输出一份完整独立的 4 层结构分镜提示词**;段与段之间用一行 \`---\` 分隔;\n`
|
||
+ `3. **段首行(重要,供前端分镜 header 展示)**:每段第一行必须是 \`段N(开始-结束秒)|段标题\` 格式(如 \`段1(0-6秒)|场1:二爸家卧室·深夜\`、\`段2(6-18秒)|场2前半:二爸家堂屋·正午\`),段标题简述该段场景/情节;\n`
|
||
+ `4. **每段内部结构**(按顺序,段内各层之间用空行分隔,不用 \`---\`):\n`
|
||
+ ` a. 主体:主体列表(置顶,按类别分行):\`人物:xxx 兄弟\`、\`场景:xxx\`、\`道具:xxx\`;\n`
|
||
+ ` b. 设定:以「设定」开头,逐项列出「人物」(每人——气质/状态1句)、「场景」(空间布置1句)、「光线」(1句)、「构图」(1句)、「视觉风格」(风格关键词)、「摄影质感」(英文摄影参数,用[]框起,如 [ARRI Alexa Mini LF, 35mm prime lens, f/2.8, 180 degree shutter, ISO 800, Natural color palette, realistic tones, ...]);\n`
|
||
+ ` c. 时长:\`视频总时长:N秒\`(放设定层之后、镜头序列之前);\n`
|
||
+ ` d. 镜头叙事:每个镜头一段(\`镜头1:\`、\`镜头2:\`…),段内为完整流畅的自然语言描述(含景别、运镜、主体动作、表情、台词及语气,可直接作为生视频提示词);\n`
|
||
+ ` e. 负面提示词:\`负面提示词:\` + 标准禁止清单(制作流程 3.3 通用项全带 + 场景勾选项):文字,字幕,水印,logo,签名,变形,多手指,少手指,肢体融合,错误手部,错误嘴型,面部扭曲,穿模,塑料人物质感,过度磨皮,蜡像感,CG感,3D渲染感,夸张表演,油腻表演,慢动作,背景音乐,BGM,配乐,廉价电视剧质感,网红短视频风格,画面噪点,人物畸形,鱼眼畸变,桶形畸变,枕形畸变,超广角拉伸,人脸边缘拉伸,微缩景观感,玩具模型感,数字锐化,过度降噪,伪HDR,纯黑死影,规则化颗粒(每段都要带);\n`
|
||
+ `5. 段数视脚本长度而定(脚本越长段越多),每段独立完整、不跨段引用;\n`
|
||
+ `6. 完成后调用 POST /mcn/api/storyboard/save 保存:body {"id": ${logId}, "script": "<分镜提示词全文>"}(接口基地址用 $env:DSH_WEB_URL,为空则依次试 http://127.0.0.1:3081、http://127.0.0.1:3080);\n`
|
||
+ `7. 保存方式(重要,避免中文编码问题):先把全文写入工作区文件(如 save_${logId}.json,用文件工具写 UTF-8 JSON,注意字符串转义),再用 curl.exe --data-binary "@save_${logId}.json" -H "Content-Type: application/json" 发送;或使用可用的 HTTP 工具。切勿用 PowerShell 的 Invoke-RestMethod 直接传含中文的 -Body 字符串(Windows PowerShell 5.1 会按 GBK 编码发送,导致服务端解析失败或乱码);\n`
|
||
+ `8. 保存接口返回 ok 才算完成;若返回 400/500,检查 JSON 转义与编码后重试,不要用测试文本占位。`;
|
||
agent.followup(createUserMessage({ content: [{ type: "text", text: prompt }], source: { kind: "user" } }));
|
||
d.prepare(`UPDATE storyboard_log SET status='running', updated_time=? WHERE id=?`).run(now, logId);
|
||
json(res, 200, { ok: true, id: logId, message: "已提交分镜提示词生成,AI 正在执行(当前并发 " + runningStoryboardCount(ctx) + "/" + STORYBOARD_MAX_CONCURRENT + ")" });
|
||
} catch (e) {
|
||
console.error(`[dsh-plugin-mcn] 分镜提示词提交失败: ${e.message}`);
|
||
json(res, 500, { ok: false, error: e.message });
|
||
}
|
||
},
|
||
});
|
||
|
||
// 分镜提示词结果保存(AI 会话调用):{id, script}
|
||
server.register({
|
||
path: "/mcn/api/storyboard/save",
|
||
exact: true,
|
||
handler: async (req, res) => {
|
||
try {
|
||
const body = await readBody(req);
|
||
const id = Number(body.id);
|
||
if (!Number.isFinite(id) || id <= 0) { json(res, 400, { ok: false, error: "缺少有效的 id 参数" }); return; }
|
||
const script = typeof body.script === "string" && body.script.trim() ? body.script.trim() : "";
|
||
if (!script) { json(res, 400, { ok: false, error: "分镜提示词内容为空" }); return; }
|
||
const d = initDb();
|
||
const rec = d.prepare(`SELECT * FROM storyboard_log WHERE id=?`).get(id);
|
||
if (!rec) { json(res, 404, { ok: false, error: "分镜提示词记录不存在" }); return; }
|
||
d.prepare(`UPDATE storyboard_log SET script_text=?, status='done', updated_time=? WHERE id=?`).run(script, new Date().toISOString(), id);
|
||
// 任务完成:不立即归档(会话保留在侧边栏,与解析任务一致),
|
||
// 由 cleanupOldMcNSessions(>24h 未活动且不在运行中 → 物理删除 + detach)统一清理
|
||
cleanupUngroupedSessions(ctx).catch(() => {});
|
||
json(res, 200, { ok: true });
|
||
} catch (e) {
|
||
console.error(`[dsh-plugin-mcn] 分镜提示词保存失败: ${e.message}`);
|
||
json(res, 500, { ok: false, error: e.message });
|
||
}
|
||
},
|
||
});
|
||
|
||
// 分镜提示词删除:{id}(同步删除对应会话目录,防止 rowid 复用污染)
|
||
server.register({
|
||
path: "/mcn/api/storyboard/delete",
|
||
exact: true,
|
||
handler: async (req, res) => {
|
||
try {
|
||
const body = await readBody(req);
|
||
const id = Number(body.id);
|
||
if (!Number.isFinite(id) || id <= 0) { json(res, 400, { ok: false, error: "缺少有效的 id 参数" }); return; }
|
||
const d = initDb();
|
||
const r = d.prepare(`DELETE FROM storyboard_log WHERE id=?`).run(id);
|
||
if (r.changes === 0) { json(res, 404, { ok: false, error: "分镜提示词记录不存在" }); return; }
|
||
try {
|
||
const sessionsRoot = join(homedir(), ".dsh", "sessions");
|
||
if (existsSync(sessionsRoot)) {
|
||
for (const grp of readdirSync(sessionsRoot, { withFileTypes: true }).filter((e) => e.isDirectory())) {
|
||
const gdir = join(sessionsRoot, grp.name);
|
||
const sid = STORYBOARD_PREFIX + id;
|
||
const sdir = join(gdir, sid);
|
||
if (existsSync(sdir)) rmSync(sdir, { recursive: true, force: true });
|
||
}
|
||
}
|
||
} catch (e) { /* 会话清理失败不影响删除结果 */ }
|
||
json(res, 200, { ok: true });
|
||
} catch (e) {
|
||
console.error(`[dsh-plugin-mcn] 分镜提示词删除失败: ${e.message}`);
|
||
json(res, 500, { ok: false, error: e.message });
|
||
}
|
||
},
|
||
});
|
||
|
||
// 分镜提示词列表:?videoId=(弹窗展示用:该视频所有分镜提示词,含所属改写版本与生成状态)
|
||
server.register({
|
||
path: "/mcn/api/storyboard/list",
|
||
exact: true,
|
||
handler: async (req, res) => {
|
||
try {
|
||
const q = parseQuery(req.url);
|
||
const videoId = Number(q.videoId);
|
||
const d = initDb();
|
||
const rows = videoId > 0
|
||
? d.prepare(`SELECT id, video_id, rewrite_id, script_text, status, created_time FROM storyboard_log WHERE video_id=? AND status='done' AND script_text IS NOT NULL AND script_text <> '' ORDER BY id DESC`).all(videoId)
|
||
: d.prepare(`SELECT id, video_id, rewrite_id, script_text, status, created_time FROM storyboard_log ORDER BY id DESC LIMIT 100`).all();
|
||
json(res, 200, { ok: true, items: rows });
|
||
} catch (e) {
|
||
json(res, 500, { ok: false, error: e.message });
|
||
}
|
||
},
|
||
});
|
||
|
||
// 结合 MCP 判断账号是否内部签约账号:GET ?name= 或 ?douyinId=(匹配 account_type='signed' 的内部列表)
|
||
server.register({
|
||
path: "/mcn/api/account/check-internal",
|
||
exact: true,
|
||
handler: async (req, res) => {
|
||
try {
|
||
const q = parseQuery(req.url);
|
||
const d = initDb();
|
||
const hit = isInternalAccount(d, q.name || "", q.douyinId || "");
|
||
json(res, 200, { ok: true, internal: !!hit, match: hit ? { id: hit.id, account_name: hit.account_name, douyin_id: hit.douyin_id } : null });
|
||
} catch (e) {
|
||
json(res, 500, { ok: false, error: e.message });
|
||
}
|
||
},
|
||
});
|
||
|
||
// 抖音榜单查询(四榜:week 账号周榜 / day 账号日榜 / hot 视频热榜 / likes 点赞榜;同步 V1.0 09-03~09-04 语义:
|
||
// 新分目录结构 + 根目录旧平铺兼容、赛道白名单、默认排序键随榜、账号行 inDb 关联)
|
||
server.register({
|
||
path: "/mcn/api/ranking",
|
||
exact: true,
|
||
handler: async (req, res) => {
|
||
try {
|
||
const q = parseQuery(req.url);
|
||
const board = ["week", "day", "hot", "likes"].includes(q.board) ? q.board : "week";
|
||
const result = getRanking({
|
||
dir: resolveRankingDir(),
|
||
board,
|
||
date: String(q.date || ""),
|
||
category: String(q.category || ""),
|
||
sortBy: String(q.sortBy || ""),
|
||
order: String(q.order || ""),
|
||
page: Number(q.page) || 1,
|
||
pageSize: Number(q.pageSize) || 20,
|
||
db: initDb(),
|
||
});
|
||
json(res, 200, { ok: true, board, ...result });
|
||
} catch (e) {
|
||
console.error(`[dsh-plugin-mcn] 榜单查询失败: ${e.message}`);
|
||
json(res, 500, { ok: false, error: e.message });
|
||
}
|
||
},
|
||
});
|
||
|
||
// 榜单更新状态/提交(同步 V1.0 09-03 机制:30min 防重复更新窗口,可 force 跳过;红狐 API 按次计费)
|
||
// GET → { running, freshness, remainingMs, canUpdate, lastAt, lastOk, lastError }
|
||
// POST {force} → 校验后创建榜单更新会话(dsh_rank-update 固定会话 followup 触发)
|
||
server.register({
|
||
path: "/mcn/api/ranking/update",
|
||
exact: true,
|
||
handler: async (req, res) => {
|
||
try {
|
||
if (req.method === "GET") {
|
||
const meta = healRankMeta(ctx);
|
||
const freshness = rankingFreshness(resolveRankingDir());
|
||
const lastAt = Number(meta.lastAt || freshness.latestMtime || 0);
|
||
const remainingMs = Math.max(0, RANK_UPDATE_COOLDOWN_MS - (Date.now() - Math.max(lastAt, freshness.latestMtime)));
|
||
const running = rankTaskRunning(ctx, meta);
|
||
json(res, 200, {
|
||
ok: true,
|
||
running,
|
||
freshness: { latestMtime: freshness.latestMtime, latestDate: freshness.latestDate, files: freshness.files },
|
||
remainingMs,
|
||
canUpdate: !running && remainingMs <= 0,
|
||
lastAt: lastAt || null,
|
||
lastOk: meta.lastOk ?? null,
|
||
lastError: meta.lastError || null,
|
||
});
|
||
return;
|
||
}
|
||
const body = await readBody(req);
|
||
const meta = healRankMeta(ctx);
|
||
if (rankTaskRunning(ctx, meta)) {
|
||
json(res, 409, { ok: false, error: "榜单更新任务正在执行中(四榜抓取约 10-20 分钟),请等待完成后再试" });
|
||
return;
|
||
}
|
||
const freshness = rankingFreshness(resolveRankingDir());
|
||
const lastAt = Number(meta.lastAt || freshness.latestMtime || 0);
|
||
const remainingMs = Math.max(0, RANK_UPDATE_COOLDOWN_MS - (Date.now() - Math.max(lastAt, freshness.latestMtime)));
|
||
if (remainingMs > 0 && !body.force) {
|
||
json(res, 409, {
|
||
ok: false,
|
||
error: `榜单数据 ${Math.ceil(remainingMs / 60000)} 分钟前刚更新过,${Math.ceil(remainingMs / 60000)} 分钟内重复抓取会浪费红狐 API 配额;确需强制刷新请点「仍要更新」`,
|
||
remainingMs,
|
||
});
|
||
return;
|
||
}
|
||
const dir = resolveRankingDir();
|
||
try { mkdirSync(dir, { recursive: true }); } catch (e) {}
|
||
const { agent } = await getOrCreateRankAgent(ctx);
|
||
if (agent.status === "running") {
|
||
json(res, 409, { ok: false, error: "榜单更新会话正在执行中,请等待完成后再试" });
|
||
return;
|
||
}
|
||
const prompt = rankUpdatePrompt(dir, !!body.force);
|
||
agent.followup(createUserMessage({ content: [{ type: "text", text: prompt }], source: { kind: "user" } }));
|
||
writeRankMeta({ lastAt: Date.now(), running: true, lastOk: null, lastError: null });
|
||
json(res, 200, {
|
||
ok: true,
|
||
message: "已提交榜单更新:账号日榜(全品类 1 次)+ 账号周榜(保留 5 赛道)+ 视频热榜/点赞榜,AI 正在抓取落盘(红狐 API 按次计费,约 10-20 分钟)",
|
||
});
|
||
} catch (e) {
|
||
console.error(`[dsh-plugin-mcn] 榜单更新提交失败: ${e.message}`);
|
||
json(res, 500, { ok: false, error: e.message });
|
||
}
|
||
},
|
||
});
|
||
|
||
// 提炼账号设定:调用 AI 会话(每账号独立会话 dsh_persona-{id}),使用 mcn-dou-analysis 技能功能三执行「筛选TOP6→MCP解析→生成人设卡」并写库。
|
||
// 与导入/刷新(dsh_external-import)、视频解析(dsh_video-analyze-*)完全解耦:互不阻塞,多账号可并发。
|
||
server.register({
|
||
path: "/mcn/api/account/persona/extract",
|
||
exact: true,
|
||
handler: async (req, res) => {
|
||
try {
|
||
const body = await readBody(req);
|
||
const accountId = Number(body.accountId);
|
||
if (!Number.isFinite(accountId) || accountId <= 0) {
|
||
json(res, 400, { ok: false, error: "缺少有效的 accountId 参数" });
|
||
return;
|
||
}
|
||
const d = initDb();
|
||
const acc = d.prepare(`SELECT * FROM hot_accounts WHERE id=?`).get(accountId);
|
||
if (!acc) { json(res, 404, { ok: false, error: "账号不存在" }); return; }
|
||
const { agent } = await getOrCreatePersonaAgent(ctx, accountId);
|
||
// 同账号保护:会话运行中或刚提交(60s 窗口)→ 409;不同账号互不影响
|
||
const lastSubmit = personaTasks.get(accountId) || 0;
|
||
if (agent.status === "running" || Date.now() - lastSubmit < 60000) {
|
||
json(res, 409, { ok: false, error: "该账号正在提炼账号设定中(约 10-20 分钟),可先处理其他账号" });
|
||
return;
|
||
}
|
||
personaTasks.set(accountId, Date.now());
|
||
const prompt = `用户请求为达人「${acc.account_name}」提炼账号设定(生成人设卡)。\n`
|
||
+ `请按「mcn-dou-analysis」技能(路径 ~/.dsh/skills/mcn-dou-analysis/,已全局注册)**功能三:提炼账号设定** 的完整流程执行:\n`
|
||
+ `1. 数据准备:检查该达人的视频数据 ` + "`{产出根目录}/{达人昵称}/短视频表格.xlsx`" + `(产出根目录以技能 references/路径配置.md 为准,当前为 D:\\dshworkspace\\解析任务)。Excel 不存在或数据不足时:可调用 GET /mcn/api/account/videos?accountId=${accountId}&pageSize=100 从数据库获取视频列表构建数据(或按技能功能二流程先获取视频列表);\n`
|
||
+ `2. 按技能 Step 3 筛选 TOP6:最近三个月 + 时长≤15分钟 + 点赞+分享降序(scripts/excel_tool.py select-top6 默认取6条,数量以技能 SKILL.md「视频筛选标准」唯一权威参数为准);\n`
|
||
+ `3. Step 4 解析:逐个 upload_douyin_video(share_text = "https://www.douyin.com/video/{aweme_id}")提交解析,等待 3-5 分钟后 short_video_detail 查询(content + analysis);一条条处理,禁止并行;\n`
|
||
+ `4. Step 5 保存:用技能 scripts/json_tool.py 保存 content.json / analysis.json 到「{产出根目录}/${acc.account_name}/视频对标/{视频文件夹}/」(视频文件夹按 SKILL.md 命名规则);\n`
|
||
+ `5. Step 6 生成人设卡:调用源技能(脚本创作技能 D:\\AgentSkill\\MCNVideo AI\\project\\短视频脚本创作\\V1.0\\脚本创作技能 + 女娲造人术)综合生成,**按技能 12 段结构**(账号核心定位→内容基因→类型库→情绪结构→视听风格→内容打法→内容规则→系列内容规划→差异化壁垒→红线清单→当前需解决的问题→人设底线),基准《王微斯账号设定参考》,聚焦内容创作维度、不含商业化变现分析;\n`
|
||
+ `6. 保存与写库:人设卡保存到「{产出根目录}/${acc.account_name}/」下(如「${acc.account_name}账号设定.md」),并调用 POST /mcn/api/external/import 的 data 模式写入数据库:{"data":{"analyses":[{"type":"persona","account_name":"${acc.account_name}","content":"人设卡全文","summary":"人设卡核心摘要"}]}}(脚本层自动去重);\n`
|
||
+ `7. 完善账号信息:\n`
|
||
+ ` a. 「定位/内容」(content):从人设卡「账号核心定位」章节提取六项(赛道/账号类型/核心受众/核心标签/内容路线/情绪锚点),转为纯文本键值段落(键名:值,逐行),写入 hot_accounts.content(账号列表「定位/内容」列);提取不到则留空,**禁止用简介(bio)填充**;\n`
|
||
+ ` b. 「赛道」(track):从人设卡「账号核心定位」的「赛道」键取第一个主赛道(" / " 分隔取首段),**去掉「赛道」后缀**(如「生活vlog赛道」→「生活vlog」),写入 hot_accounts.track;\n`
|
||
+ ` 以上两项通过 POST /mcn/api/account/update 一次写入:{"id":${accountId},"content":"提取的定位内容","track":"提取的赛道"};\n`
|
||
+ `8. 完成后向用户汇报:TOP6 视频、解析状态(content/analysis 是否齐备)、人设卡保存路径、数据库写入结果、定位/内容与赛道更新结果。\n`
|
||
+ `本会话没有 HTTP 工具,调用本地接口请用 pwsh 工具执行 PowerShell。接口基地址用 $env:DSH_WEB_URL(如为空则依次试 http://127.0.0.1:3081、http://127.0.0.1:3080)。\n`
|
||
+ `若 pwsh 命令被沙箱拒绝(输出含 [sandbox: file access denied] 标记),请立即给同一命令加 sandbox_permissions 参数(如 danger-full-access)和 justification 参数重试——系统会弹出授权窗口,用户批准后命令才会执行。`;
|
||
agent.followup(createUserMessage({
|
||
content: [{ type: "text", text: prompt }],
|
||
source: { kind: "user" },
|
||
}));
|
||
json(res, 200, { ok: true, accountId, message: `已提交提炼「${acc.account_name}」的账号设定,AI 正在执行(约 10-20 分钟)` });
|
||
} catch (e) {
|
||
console.error(`[dsh-plugin-mcn] 账号设定提炼提交失败: ${e.message}`);
|
||
json(res, 500, { ok: false, error: e.message });
|
||
}
|
||
},
|
||
});
|
||
|
||
// 账号数据分析任务(V1.0 同步:账号详情「数据分析」tab 空态按钮):调用 AI 会话(每账号独立 dsh_acctana-{id}),
|
||
// 使用 mcn-dou-analysis 技能功能四执行「近4月最好/最差视频逐条拆解 → 8 章汇总报告」并落库(account_analysis)。
|
||
server.register({
|
||
path: "/mcn/api/account/analysis/start",
|
||
exact: true,
|
||
handler: async (req, res) => {
|
||
try {
|
||
const body = await readBody(req);
|
||
const accountId = Number(body.accountId);
|
||
if (!Number.isFinite(accountId) || accountId <= 0) {
|
||
json(res, 400, { ok: false, error: "缺少有效的 accountId 参数" });
|
||
return;
|
||
}
|
||
const d = initDb();
|
||
const acc = d.prepare(`SELECT * FROM hot_accounts WHERE id=? AND (del_flag IS NULL OR del_flag <> 1)`).get(accountId);
|
||
if (!acc) { json(res, 404, { ok: false, error: "账号不存在" }); return; }
|
||
const { agent } = await getOrCreateAcctAnalysisAgent(ctx, accountId);
|
||
// 同账号保护:会话运行中或刚提交(60s 窗口)→ 409;不同账号互不影响
|
||
const lastSubmit = acctAnalysisTasks.get(accountId) || 0;
|
||
if (agent.status === "running" || Date.now() - lastSubmit < 60000) {
|
||
json(res, 409, { ok: false, error: "该账号的数据分析任务正在执行中(约 10-20 分钟),可先处理其他账号" });
|
||
return;
|
||
}
|
||
acctAnalysisTasks.set(accountId, Date.now());
|
||
const prompt = `用户请求为达人「${acc.account_name}」做账号数据分析(账号详情页「数据分析」入口触发)。\n`
|
||
+ `请按「mcn-dou-analysis」技能(路径 ~/.dsh/skills/mcn-dou-analysis/,已全局注册)**功能四:分析视频数据(账号数据分析)** 的流程执行:\n`
|
||
+ `1. 先读该技能 SKILL.md「功能总览」定位功能四,再按 references/feature/04_分析视频数据.md 与 references/账号数据分析方法.md 执行(若 Skill 工具无法加载,直接读取该目录下 SKILL.md);\n`
|
||
+ `2. 数据准备:优先读取「{产出根目录}/${acc.account_name}/短视频表格*.xlsx」(13 列标准格式,产出根目录以技能 references/路径配置.md 为准,当前为 D:\\dshworkspace\\解析任务);xlsx 缺失时以数据库视频数据为准(GET /mcn/api/account/videos?accountId=${accountId}&pageSize=100,可分页拉全),无需重新抓取账号信息/视频列表;\n`
|
||
+ `3. 样本选取:近 4 个月视频数据,每月按 点赞数+分享数 降序取最好 1 条 + 最差 1 条(共最多 8 条;某月不足 2 条则全取);\n`
|
||
+ `4. 两段式强制:先逐条拆解(可复用该账号已有 MCP 解析 analysis,缺解析的按规范提交补齐——复用 upload_douyin_video/short_video_detail 流程,一条条处理禁止并行)→ 拆解全部完成后再进入汇总分析(禁止跳过拆解直接写报告),按 3.1-3.6 维度产出 8 章结构报告;\n`
|
||
+ `5. 红线:所有分析结论必须具体到视频标题 + 数值(点赞/评论/分享/收藏等);私有数据(完播率/GMV 等)拿不到时标注「无数据」禁止推断;无 MCP 解析的视频只做数据层分析,禁止作为内容层结论论据(注明「无解析,植入方式待验证」);\n`
|
||
+ `6. 保存与写库:报告保存到「{产出根目录}/${acc.account_name}/${acc.account_name}账号数据分析.md」,并调用 POST /mcn/api/external/import 的 data 模式写入数据库:{"data":{"analyses":[{"type":"account","account_name":"${acc.account_name}","content":"报告全文","summary":"报告核心摘要"}]}}(脚本层自动去重);\n`
|
||
+ `7. 完成后向用户汇报:样本视频清单、拆解完成数、报告保存路径、数据库写入结果。\n`
|
||
+ `技能边界:只做账号数据分析报告,不生成/改写脚本(属「短视频工作台」技能 S1-S11 流程),不提炼账号设定,不触发账号信息/视频列表抓取流程。\n`
|
||
+ `本会话没有 HTTP 工具,调用本地接口请用 pwsh 工具执行 PowerShell。接口基地址用 $env:DSH_WEB_URL(如为空则依次试 http://127.0.0.1:3081、http://127.0.0.1:3080)。\n`
|
||
+ `若 pwsh 命令被沙箱拒绝(输出含 [sandbox: file access denied] 标记),请立即给同一命令加 sandbox_permissions 参数(如 danger-full-access)和 justification 参数重试——系统会弹出授权窗口,用户批准后命令才会执行。`;
|
||
agent.followup(createUserMessage({
|
||
content: [{ type: "text", text: prompt }],
|
||
source: { kind: "user" },
|
||
}));
|
||
json(res, 200, { ok: true, accountId, message: `已提交「${acc.account_name}」的账号数据分析,AI 正在执行(约 10-20 分钟)` });
|
||
} catch (e) {
|
||
console.error(`[dsh-plugin-mcn] 账号数据分析提交失败: ${e.message}`);
|
||
json(res, 500, { ok: false, error: e.message });
|
||
}
|
||
},
|
||
});
|
||
|
||
// 视频详情(视频分析详情页):视频信息 + 最新一条拆解分析 markdown(来源:拆解分析.md 导入)
|
||
server.register({
|
||
path: "/mcn/api/video/detail",
|
||
exact: true,
|
||
handler: async (req, res) => {
|
||
try {
|
||
const q = parseQuery(req.url);
|
||
const d = initDb();
|
||
const videoId = Number(q.videoId);
|
||
let video = null;
|
||
if (Number.isFinite(videoId) && videoId > 0) {
|
||
video = d.prepare(`
|
||
SELECT v.*, h.account_name FROM account_videos v
|
||
LEFT JOIN hot_accounts h ON h.id = v.account_id
|
||
WHERE v.id=?
|
||
`).get(videoId);
|
||
} else if (q.awemeId) {
|
||
// 支持按视频ID(aweme_id,业务主键)查询
|
||
video = d.prepare(`
|
||
SELECT v.*, h.account_name FROM account_videos v
|
||
LEFT JOIN hot_accounts h ON h.id = v.account_id
|
||
WHERE v.aweme_id=?
|
||
`).get(String(q.awemeId));
|
||
}
|
||
if (!video) { json(res, 404, { ok: false, error: "视频不存在" }); return; }
|
||
const analysis = d.prepare(
|
||
`SELECT content_json AS content, summary, analysis_time FROM account_video_analysis WHERE aweme_id=? OR video_id=? ORDER BY id DESC LIMIT 1`
|
||
).get(String(video.aweme_id), video.id);
|
||
// MCP 解析内容(视频脚本 content + 视频拆解 analysis),从视频对标目录读取
|
||
const parse = readVideoParse(video.id);
|
||
json(res, 200, { ok: true, video, analysis: analysis || null, parse });
|
||
} catch (e) {
|
||
console.error(`[dsh-plugin-mcn] 视频详情查询失败: ${e.message}`);
|
||
json(res, 500, { ok: false, error: e.message });
|
||
}
|
||
},
|
||
});
|
||
|
||
// 视频原始解析内容(对标创作选题素材):视频对标目录下的 原视频解析.json / content.json
|
||
server.register({
|
||
path: "/mcn/api/video/source",
|
||
exact: true,
|
||
handler: async (req, res) => {
|
||
try {
|
||
const q = parseQuery(req.url);
|
||
const videoId = Number(q.videoId);
|
||
if (!Number.isFinite(videoId) || videoId <= 0) {
|
||
json(res, 400, { ok: false, error: "缺少有效的 videoId 参数" });
|
||
return;
|
||
}
|
||
const r = readVideoSource(videoId);
|
||
json(res, 200, { ok: true, content: r.found ? r.content : null, fileName: r.found ? r.fileName : null });
|
||
} catch (e) {
|
||
console.error(`[dsh-plugin-mcn] 视频原始解析读取失败: ${e.message}`);
|
||
json(res, 500, { ok: false, error: e.message });
|
||
}
|
||
},
|
||
});
|
||
|
||
// 批量补录原视频解析到数据库(扫描所有外部账号视频对标目录,幂等)
|
||
server.register({
|
||
path: "/mcn/api/video/source/backfill",
|
||
exact: true,
|
||
handler: async (_req, res) => {
|
||
try {
|
||
const added = backfillVideoSource();
|
||
json(res, 200, { ok: true, added, message: `已补录 ${added} 条原视频解析数据` });
|
||
} catch (e) {
|
||
console.error(`[dsh-plugin-mcn] 原视频解析补录失败: ${e.message}`);
|
||
json(res, 500, { ok: false, error: e.message });
|
||
}
|
||
},
|
||
});
|
||
|
||
// 账号刷新:调用 AI 会话(每账号独立会话 dsh_refresh-{id}),使用 mcn-dou-analysis 技能重新获取账号数据并更新数据库。
|
||
// 不同账号可并发刷新(与导入/提炼/解析互不阻塞);同账号 60s 防抖保护。
|
||
server.register({
|
||
path: "/mcn/api/account/refresh-web",
|
||
exact: true,
|
||
handler: async (req, res) => {
|
||
try {
|
||
const body = await readBody(req);
|
||
const id = Number(body.id);
|
||
if (!Number.isFinite(id) || id <= 0) { json(res, 400, { ok: false, error: "缺少有效的 id 参数" }); return; }
|
||
const d = initDb();
|
||
const acc = d.prepare(`SELECT id, account_name, douyin_id, source_dir FROM hot_accounts WHERE id=?`).get(id);
|
||
if (!acc) { json(res, 404, { ok: false, error: "账号不存在" }); return; }
|
||
// 浏览器类任务串行(全局互斥,避免多会话抢同一调试 Chrome;后续云浏览器再放开并发)
|
||
if (browserTaskRunning(ctx)) {
|
||
json(res, 409, { ok: false, error: "浏览器任务(导入/刷新)正在执行中,为串行执行避免抢浏览器,请等待完成后再试" });
|
||
return;
|
||
}
|
||
const { agent } = await getOrCreateRefreshAgent(ctx, id);
|
||
const lastSubmit = importTasks.get("refresh:" + id) || 0;
|
||
if (agent.status === "running" || Date.now() - lastSubmit < 60000) {
|
||
json(res, 409, { ok: false, error: "该账号正在刷新中(不同账号可同时刷新),请等待完成后再试" });
|
||
return;
|
||
}
|
||
importTasks.set("refresh:" + id, Date.now());
|
||
const accInfo = [
|
||
acc.account_name ? `账号名称:${acc.account_name}` : "",
|
||
acc.douyin_id ? `抖音号:${acc.douyin_id}` : "",
|
||
].filter(Boolean).join(";");
|
||
const prompt = `用户请求刷新账号「${acc.account_name}」的数据(${accInfo})。\n`
|
||
+ `请调用「mcn-dou-analysis」技能(路径 ~/.dsh/skills/mcn-dou-analysis/,已全局注册)通过抖音网页重新获取该账号的最新数据:\n`
|
||
+ `1. 用 browser-harness 打开抖音网页版(优先用抖音号${acc.douyin_id ? `(${acc.douyin_id})` : ""},其次账号名称),进入达人主页;\n`
|
||
+ `2. 提取账号信息:昵称、抖音号、粉丝数、获赞数、作品数、简介等;**「定位/内容」(content)只在提炼账号设定后才有,网页获取不到,不要用简介填充,保持数据库原值/留空**;\n`
|
||
+ `3. 获取视频列表(**按数据库现有视频数决定是否滚动**):\n`
|
||
+ ` - 先查询该账号数据库现有视频数:GET /mcn/api/account/videos?accountId=${id}&pageSize=1 的 total 字段;\n`
|
||
+ ` - **若现有视频数 > 60:不滚动页面,只采集首屏可见的最新视频(约前 20-30 条)**,用于获取最新数据(新视频入库、已有视频更新数据);\n`
|
||
+ ` - 若现有视频数 ≤ 60:按技能 SKILL.md「视频获取数量」参数滚动加载(达到数量或加载不出更多即停);\n`
|
||
+ ` - 保存 13 列 Excel 到「{工作空间}/解析任务/{达人昵称}/短视频表格.xlsx」(工作空间 = D:\\dshworkspace,产出路径以技能 references/路径配置.md 为准);\n`
|
||
+ `4. 将最新账号信息与视频列表**更新到数据库**:调用 POST /mcn/api/external/import 的 data 模式(账号 upsert 到 hot_accounts、视频 upsert 到 account_videos,脚本层自动去重/更新,账号识别键 account_name/douyin_id 保持不变);\n`
|
||
+ `5. 完成后向用户汇报:账号信息变化、视频数量、保存路径、数据库更新结果。\n`
|
||
+ chromeHint(REFRESH_PREFIX + id)
|
||
+ `本会话没有 HTTP 工具,调用本地接口请用 pwsh 工具执行 PowerShell。接口基地址用 $env:DSH_WEB_URL(如为空则依次试 http://127.0.0.1:3081、http://127.0.0.1:3080)。\n`
|
||
+ `若 pwsh 命令被沙箱拒绝(输出含 [sandbox: file access denied] 标记),请立即给同一命令加 sandbox_permissions 参数(如 danger-full-access)和 justification 参数重试——系统会弹出授权窗口,用户批准后命令才会执行。`;
|
||
agent.followup(createUserMessage({
|
||
content: [{ type: "text", text: prompt }],
|
||
source: { kind: "user" },
|
||
}));
|
||
json(res, 200, { ok: true, id, accountName: acc.account_name, message: `已提交刷新「${acc.account_name}」,AI 正在重新获取数据` });
|
||
} catch (e) {
|
||
console.error(`[dsh-plugin-mcn] 账号刷新提交失败: ${e.message}`);
|
||
json(res, 500, { ok: false, error: e.message });
|
||
}
|
||
},
|
||
});
|
||
|
||
// 解析视频:调用 AI 会话(每视频独立会话 dsh_video-analyze-{id}),使用 mcn-dou-analysis 技能功能五对单个视频进行 MCP 解析。
|
||
// 与导入/刷新(dsh_external-import)完全解耦:互不阻塞,多个视频可同时并发解析(每个视频一个会话)。
|
||
server.register({
|
||
path: "/mcn/api/video/analyze",
|
||
exact: true,
|
||
handler: async (req, res) => {
|
||
try {
|
||
const body = await readBody(req);
|
||
const videoId = Number(body.videoId);
|
||
if (!Number.isFinite(videoId) || videoId <= 0) { json(res, 400, { ok: false, error: "缺少有效的 videoId 参数" }); return; }
|
||
const d = initDb();
|
||
const v = d.prepare(`
|
||
SELECT v.id, v.aweme_id, v.video_title, v.account_id, h.account_name FROM account_videos v
|
||
LEFT JOIN hot_accounts h ON h.id = v.account_id WHERE v.id=?
|
||
`).get(videoId);
|
||
if (!v) { json(res, 404, { ok: false, error: "视频不存在" }); return; }
|
||
// 并发上限:最多 ${VIDEO_ANALYZE_MAX_CONCURRENT} 条同时解析
|
||
if (runningVideoAnalyzeCount(ctx) >= VIDEO_ANALYZE_MAX_CONCURRENT) {
|
||
json(res, 409, { ok: false, error: `视频解析任务已达并发上限(${VIDEO_ANALYZE_MAX_CONCURRENT} 条),请等待部分完成后再提交` });
|
||
return;
|
||
}
|
||
const { agent } = await getOrCreateVideoAnalyzeAgent(ctx, videoId);
|
||
// 同视频保护:会话运行中或刚提交(60s 窗口,防 followup 排队期重复提交)→ 409;不同视频互不影响
|
||
const lastSubmit = videoAnalyzeTasks.get(videoId) || 0;
|
||
if (agent.status === "running" || Date.now() - lastSubmit < 60000) {
|
||
json(res, 409, { ok: false, error: "该视频正在解析中(约 3-5 分钟),可同时提交其他视频解析" });
|
||
return;
|
||
}
|
||
videoAnalyzeTasks.set(videoId, Date.now());
|
||
const prompt = `用户请求解析视频「${v.video_title}」(aweme_id:${v.aweme_id},达人:${v.account_name || "-"})。\n`
|
||
+ `请按「mcn-dou-analysis」技能(路径 ~/.dsh/skills/mcn-dou-analysis/,已全局注册)**功能五:解析视频** 的流程执行:\n`
|
||
+ `1. 调用 MCP 工具 upload_douyin_video(share_text = "https://www.douyin.com/video/${v.aweme_id}")提交解析,得到 detailId;\n`
|
||
+ `2. 等待 3-5 分钟后调用 short_video_detail(id = detailId)查询,获取 content(分镜头表)与 analysis(结构化JSON);content 已出而 analysis 未出时稍后重试;\n`
|
||
+ `3. 用技能 scripts/json_tool.py 保存 content.json / analysis.json 到「{产出根目录}/${v.account_name || "未知达人"}/视频对标/{视频文件夹}/」(产出根目录以技能 references/路径配置.md 为准,当前为 D:\\dshworkspace\\解析任务;视频文件夹按 SKILL.md 命名规则);\n`
|
||
+ `4. 若环境可用,调用 POST /mcn/api/video/source/backfill 将解析结果补录进数据库(account_video_source + 提取选题);接口不可用则跳过;\n`
|
||
+ `5. 完成后向用户汇报:detailId、解析状态(content/analysis 是否齐备)、保存路径、数据库写入结果。\n`
|
||
+ `本会话没有 HTTP 工具,调用本地接口请用 pwsh 工具执行 PowerShell。接口基地址用 $env:DSH_WEB_URL(如为空则依次试 http://127.0.0.1:3081、http://127.0.0.1:3080)。\n`
|
||
+ `若 pwsh 命令被沙箱拒绝(输出含 [sandbox: file access denied] 标记),请立即给同一命令加 sandbox_permissions 参数(如 danger-full-access)和 justification 参数重试——系统会弹出授权窗口,用户批准后命令才会执行。`;
|
||
agent.followup(createUserMessage({
|
||
content: [{ type: "text", text: prompt }],
|
||
source: { kind: "user" },
|
||
}));
|
||
json(res, 200, { ok: true, videoId, message: `已提交解析「${v.video_title}」,AI 正在解析(约 3-5 分钟)` });
|
||
} catch (e) {
|
||
console.error(`[dsh-plugin-mcn] 视频解析提交失败: ${e.message}`);
|
||
json(res, 500, { ok: false, error: e.message });
|
||
}
|
||
},
|
||
});
|
||
|
||
server.register({
|
||
path: "/mcn/api/tracks",
|
||
exact: true,
|
||
handler: async (_req, res) => {
|
||
try {
|
||
json(res, 200, { ok: true, tracks: listTracks() });
|
||
} catch (e) {
|
||
json(res, 500, { ok: false, error: e.message });
|
||
}
|
||
},
|
||
});
|
||
|
||
server.register({
|
||
path: "/mcn/api/refresh",
|
||
exact: true,
|
||
handler: async (_req, res) => {
|
||
try {
|
||
const result = await refreshData();
|
||
json(res, 200, { ok: true, ...result });
|
||
} catch (e) {
|
||
json(res, 500, { ok: false, error: e.message });
|
||
}
|
||
},
|
||
});
|
||
|
||
server.register({
|
||
path: "/mcn/api/status",
|
||
exact: true,
|
||
handler: async (_req, res) => {
|
||
try {
|
||
const d = initDb();
|
||
const row = d.prepare("SELECT COUNT(*) AS c FROM hot_accounts").get();
|
||
json(res, 200, { ok: true, count: row.c });
|
||
} catch (e) {
|
||
json(res, 500, { ok: false, error: e.message });
|
||
}
|
||
},
|
||
});
|
||
|
||
// 任务状态:供前端轮询禁用按钮(creative=创作会话、import=导入分析会话、analyze=视频解析任务列表[可并发]、persona=账号设定提炼任务列表[可并发])
|
||
server.register({
|
||
path: "/mcn/api/task/status",
|
||
exact: true,
|
||
handler: async (_req, res) => {
|
||
try {
|
||
const creative = ctx.agents.get(CREATIVE_SESSION_ID);
|
||
const imports = listImportTasks(ctx);
|
||
json(res, 200, {
|
||
ok: true,
|
||
creative: creative !== undefined ? creative.status : "idle",
|
||
// 兼容字段:任一导入/刷新任务 running 即为 running
|
||
import: imports.some((t) => t.status === "running") ? "running" : "idle",
|
||
imports,
|
||
analyze: listVideoAnalyzeTasks(ctx),
|
||
persona: listPersonaTasks(ctx),
|
||
accountAnalysis: listAcctAnalysisTasks(ctx),
|
||
// 改写任务列表(与 persona/analyze 交互一致,AI写脚本 页面轮询运行状态)
|
||
rewrite: listRewriteTasks(ctx),
|
||
// 复盘任务列表(与 rewrite/persona 交互一致,AI写作复盘 页面轮询运行状态)
|
||
review: listReviewTasks(ctx),
|
||
});
|
||
} catch (e) {
|
||
json(res, 500, { ok: false, error: e.message });
|
||
}
|
||
},
|
||
});
|
||
|
||
// 编辑账号信息:除 account_name 外均可更新(account_type/id/del_flag 等系统字段不可改)
|
||
server.register({
|
||
path: "/mcn/api/account/update",
|
||
exact: true,
|
||
handler: async (req, res) => {
|
||
try {
|
||
const body = await readBody(req);
|
||
const id = Number(body.id);
|
||
if (!Number.isFinite(id)) { json(res, 400, { ok: false, error: "缺少 id 参数" }); return; }
|
||
const d = initDb();
|
||
const ex = d.prepare(`SELECT id FROM hot_accounts WHERE id=?`).get(id);
|
||
if (!ex) { json(res, 404, { ok: false, error: "账号不存在" }); return; }
|
||
const allowed = ["track", "content", "tags", "masterpiece", "douyin_id", "followers", "total_likes", "works_count", "ip_location", "location", "age", "bio", "is_ai"];
|
||
const sets = [];
|
||
const params = [];
|
||
for (const k of allowed) {
|
||
if (body[k] === undefined) continue;
|
||
if (k === "works_count" || k === "age") {
|
||
const v = body[k];
|
||
params.push(v === null || v === "" ? null : (Number.isFinite(Number(v)) ? Number(v) : null));
|
||
} else {
|
||
params.push(body[k] === null ? null : String(body[k]));
|
||
}
|
||
sets.push(`${k} = ?`);
|
||
}
|
||
if (!sets.length) { json(res, 400, { ok: false, error: "没有可更新的字段" }); return; }
|
||
sets.push("updated_time = ?");
|
||
params.push(new Date().toISOString(), id);
|
||
d.prepare(`UPDATE hot_accounts SET ${sets.join(", ")} WHERE id=?`).run(...params);
|
||
json(res, 200, { ok: true, message: "账号信息已更新" });
|
||
} catch (e) {
|
||
console.error(`[dsh-plugin-mcn] 账号更新失败: ${e.message}`);
|
||
json(res, 500, { ok: false, error: e.message });
|
||
}
|
||
},
|
||
});
|
||
|
||
// 创作入口:向创作会话注入创作请求,触发短视频脚本创作技能
|
||
// 默认复用固定会话(dsh_short-video-script);body.sessionId 可指定独立会话(如对标创作 dsh_benchmark-<videoId>)
|
||
server.register({
|
||
path: "/mcn/api/creative",
|
||
exact: true,
|
||
handler: async (req, res) => {
|
||
try {
|
||
const body = await readBody(req);
|
||
const prompt = typeof body.prompt === "string" && body.prompt.trim()
|
||
? body.prompt.trim()
|
||
: "请帮我创作一个短视频脚本(使用短视频脚本创作技能)";
|
||
const sid = typeof body.sessionId === "string" && /^[A-Za-z0-9_-]{1,80}$/.test(body.sessionId)
|
||
? body.sessionId
|
||
: CREATIVE_SESSION_ID;
|
||
const { agent, created } = await getOrCreateCreativeAgent(ctx, sid);
|
||
// 同一功能会话执行中禁止重复触发
|
||
if (agent.status === "running") {
|
||
json(res, 409, { ok: false, error: `会话 ${sid} 的创作任务仍在执行中,请等待完成后再试` });
|
||
return;
|
||
}
|
||
// followup:向 agent 提交用户消息,触发一轮执行(空闲驱动会启动 turn)
|
||
agent.followup(createUserMessage({
|
||
content: [{ type: "text", text: prompt }],
|
||
source: { kind: "user" },
|
||
}));
|
||
// 改写脚本计数(数据预览统计用)
|
||
try {
|
||
initDb().prepare(`INSERT INTO creative_log (created_time, source) VALUES (?, ?)`).run(new Date().toISOString(), sid);
|
||
statsCache = { ts: 0, data: null }; // 失效统计缓存
|
||
} catch (e) { /* 计数失败不阻塞创作 */ }
|
||
json(res, 200, {
|
||
ok: true,
|
||
sessionId: sid,
|
||
created,
|
||
message: created
|
||
? `已创建会话 ${sid} 并开始创作`
|
||
: `已复用会话 ${sid} 并开始创作`,
|
||
});
|
||
} catch (e) {
|
||
console.error(`[dsh-plugin-mcn] 创作触发失败: ${e.message}`);
|
||
json(res, 500, { ok: false, error: e.message });
|
||
}
|
||
},
|
||
});
|
||
// 数据预览统计:账号数/设定提炼数/视频数/视频解析数/改写脚本数(60s 内存缓存)
|
||
server.register({
|
||
path: "/mcn/api/stats",
|
||
exact: true,
|
||
handler: async (req, res) => {
|
||
try {
|
||
const now = Date.now();
|
||
if (statsCache.data && now - statsCache.ts < STATS_TTL) {
|
||
json(res, 200, { ok: true, ...statsCache.data, cached: true });
|
||
return;
|
||
}
|
||
const d = initDb();
|
||
const c = (sql) => d.prepare(sql).get().c;
|
||
const accounts = c(`SELECT COUNT(*) c FROM hot_accounts WHERE del_flag=0`);
|
||
// 爆款视频 = 各账号点赞 TOP10(赛道间量级差异大,不用全局阈值)
|
||
const videos = c(`SELECT COUNT(*) c FROM account_videos WHERE id IN (SELECT id FROM (SELECT id, ROW_NUMBER() OVER (PARTITION BY account_id ORDER BY like_count DESC) rn FROM account_videos) t WHERE rn <= 10)`);
|
||
const analyses = c(`SELECT COUNT(*) c FROM account_video_analysis`);
|
||
// AI改写脚本 = rewrite_log 中已完成的脚本数(done 且脚本非空;creative_log 为旧「AI写作」计数,不再用于此处)
|
||
const rewrites = c(`SELECT COUNT(*) c FROM rewrite_log WHERE status='done' AND script_text IS NOT NULL AND script_text <> ''`);
|
||
// AI写作复盘 = 已完成的 AI 脚本复盘数(script_review type='ai')
|
||
let reviews = 0;
|
||
try { reviews = c(`SELECT COUNT(*) c FROM script_review WHERE type='ai' AND status='done'`); } catch (e) { /* 复盘统计失败不影响其他 */ }
|
||
// writingScore:AI 写作评分 = 最新 20 条 AI 复盘总分的平均(script_review type='ai' 且已完成)
|
||
let writingScore = null;
|
||
try {
|
||
const scoreRows = d.prepare(`SELECT total FROM script_review WHERE type='ai' AND status='done' AND total IS NOT NULL AND total <> '' ORDER BY updated_time DESC, id DESC LIMIT 20`).all();
|
||
const nums = scoreRows.map((r) => Number(r.total)).filter((v) => Number.isFinite(v));
|
||
if (nums.length > 0) writingScore = Math.round((nums.reduce((s, v) => s + v, 0) / nums.length) * 10) / 10;
|
||
} catch (e) { /* 评分统计失败不影响其他统计 */ }
|
||
statsCache = { ts: now, data: { accounts, videos, analyses, rewrites, reviews, writingScore } };
|
||
json(res, 200, { ok: true, ...statsCache.data, cached: false });
|
||
} catch (e) {
|
||
console.error(`[dsh-plugin-mcn] 数据统计失败: ${e.message}`);
|
||
json(res, 500, { ok: false, error: e.message });
|
||
}
|
||
},
|
||
});
|
||
|
||
// 外部账号导入:暂存文件并通知 AI 分析确认(AI 确认后调 /external/import)
|
||
server.register({
|
||
path: "/mcn/api/external/stage",
|
||
exact: true,
|
||
handler: async (req, res) => {
|
||
try {
|
||
const body = await readBody(req);
|
||
// 浏览器类任务串行(全局互斥):账号模式导入走浏览器采集,刷新进行中时 409;目录导入不涉及浏览器可并行
|
||
const hasAcc = !!(body.account && (body.account.name || body.account.douyinId || body.account.url));
|
||
if (hasAcc && browserTaskRunning(ctx)) {
|
||
json(res, 409, { ok: false, error: "浏览器任务(导入/刷新)正在执行中,为串行执行避免抢浏览器,请等待完成后再试" });
|
||
return;
|
||
}
|
||
// 并发:每任务独立会话,同任务 409 保护在 stageExternalImport 内部(不同任务互不阻塞)
|
||
const result = await stageExternalImport(ctx, body);
|
||
json(res, 200, { ok: true, ...result, message: "已提交,AI 正在分析文件结构,请到对话中确认导入" });
|
||
} catch (e) {
|
||
console.error(`[dsh-plugin-mcn] 外部导入暂存失败: ${e.message}`);
|
||
json(res, 500, { ok: false, error: e.message });
|
||
}
|
||
},
|
||
});
|
||
|
||
// 导入预览:AI 确认前调用,扫描比对不写库,展示 新增/更新/已存在
|
||
server.register({
|
||
path: "/mcn/api/external/preview",
|
||
exact: true,
|
||
handler: async (req, res) => {
|
||
try {
|
||
const body = await readBody(req);
|
||
const dir = typeof body.dir === "string" && body.dir.trim() ? body.dir.trim() : "";
|
||
if (!dir) { json(res, 400, { ok: false, error: "缺少 dir 参数" }); return; }
|
||
const result = previewExternalDir(dir);
|
||
json(res, 200, { ok: true, ...result });
|
||
} catch (e) {
|
||
console.error(`[dsh-plugin-mcn] 导入预览失败: ${e.message}`);
|
||
json(res, 500, { ok: false, error: e.message });
|
||
}
|
||
},
|
||
});
|
||
|
||
// 外部账号导入:AI 确认后调用
|
||
// 支持两种模式:{dir} 脚本扫描解析;{data} AI 归一化后的标准结构数据
|
||
server.register({
|
||
path: "/mcn/api/external/import",
|
||
exact: true,
|
||
handler: async (req, res) => {
|
||
try {
|
||
const body = await readBody(req);
|
||
if (body.data) {
|
||
const d = initDb();
|
||
const result = importExternalData(d, body.data);
|
||
json(res, 200, { ok: true, ...result, message: `导入完成:账号 ${result.accounts},视频 ${result.videos},分析 ${result.analyses}` });
|
||
return;
|
||
}
|
||
const dir = typeof body.dir === "string" && body.dir.trim() ? body.dir.trim() : "";
|
||
if (!dir) { json(res, 400, { ok: false, error: "缺少 dir 或 data 参数" }); return; }
|
||
const result = importExternalDir(dir);
|
||
json(res, 200, { ok: true, ...result, message: `导入完成:账号 ${result.accounts},视频 ${result.videos},分析 ${result.analyses}` });
|
||
} catch (e) {
|
||
console.error(`[dsh-plugin-mcn] 外部导入失败: ${e.message}`);
|
||
json(res, 500, { ok: false, error: e.message });
|
||
}
|
||
},
|
||
});
|
||
|
||
// ---- V1.0 同步增量路由(P1:批量删除 / 原创选题脚本中心 / 原创创作提交) ----
|
||
|
||
// 列表批量删除(连坐):{kind: accounts|videos|rewrites, ids}
|
||
// accounts=软删账号(del_flag=1)+ 连坐清 persona/analysis/videos 及全部关联;videos|rewrites=物理删视频行+连坐
|
||
// (rewrites 为 AI写脚本 ref 列表批量口径,V1.0 语义=删除该视频载体及其 AI 脚本/复盘/分镜)
|
||
server.register({
|
||
path: "/mcn/api/batch/delete",
|
||
exact: true,
|
||
handler: async (req, res) => {
|
||
try {
|
||
const body = await readBody(req);
|
||
const kind = String(body.kind || "");
|
||
if (!["accounts", "videos", "rewrites"].includes(kind)) {
|
||
json(res, 400, { ok: false, error: "kind 必须为 accounts / videos / rewrites" });
|
||
return;
|
||
}
|
||
const ids = (Array.isArray(body.ids) ? body.ids : []).map(Number).filter((n) => Number.isFinite(n) && n > 0);
|
||
if (ids.length === 0) { json(res, 400, { ok: false, error: "缺少有效的 ids 参数" }); return; }
|
||
const d = initDb();
|
||
const result = batchDeleteCascade(d, kind, ids);
|
||
// 联动清理对应任务会话目录(防 logId 复用后旧会话上下文污染新任务)
|
||
try {
|
||
const sessionsRoot = join(homedir(), ".dsh", "sessions");
|
||
if (existsSync(sessionsRoot) && (result.rewriteIds.length || result.storyboardIds.length)) {
|
||
const sids = [];
|
||
for (const id of result.rewriteIds) {
|
||
sids.push(REWRITE_PREFIX + id, reviewSessionId("ai", id), reviewSessionId("compare", id));
|
||
}
|
||
for (const sbId of result.storyboardIds) sids.push(STORYBOARD_PREFIX + sbId);
|
||
for (const grp of readdirSync(sessionsRoot, { withFileTypes: true }).filter((e) => e.isDirectory())) {
|
||
const gdir = join(sessionsRoot, grp.name);
|
||
for (const sid of sids) {
|
||
const sdir = join(gdir, sid);
|
||
if (existsSync(sdir)) rmSync(sdir, { recursive: true, force: true });
|
||
}
|
||
}
|
||
}
|
||
} catch (e) { /* 会话清理失败不影响删除结果 */ }
|
||
json(res, 200, { ok: true, ...result, message: `已删除 ${result.deleted} 条(含关联的解析/复盘/分镜数据)` });
|
||
} catch (e) {
|
||
console.error(`[dsh-plugin-mcn] 批量删除失败: ${e.message}`);
|
||
json(res, 500, { ok: false, error: e.message });
|
||
}
|
||
},
|
||
});
|
||
|
||
// 原创选题脚本列表(无视频载体,同步 V1.0 v25:AI写脚本 custom tab 中心)
|
||
server.register({
|
||
path: "/mcn/api/custom-scripts",
|
||
exact: true,
|
||
handler: async (req, res) => {
|
||
try {
|
||
const q = parseQuery(req.url);
|
||
const result = listCustomScripts(initDb(), {
|
||
page: Number(q.page) || 1,
|
||
pageSize: Number(q.pageSize) || 20,
|
||
search: String(q.search || ""),
|
||
days: String(q.days || ""),
|
||
account: String(q.account || ""),
|
||
});
|
||
json(res, 200, { ok: true, ...result });
|
||
} catch (e) {
|
||
json(res, 500, { ok: false, error: e.message });
|
||
}
|
||
},
|
||
});
|
||
|
||
// 原创选题脚本全文(查看弹窗)
|
||
server.register({
|
||
path: "/mcn/api/custom-script",
|
||
exact: true,
|
||
handler: async (req, res) => {
|
||
try {
|
||
const q = parseQuery(req.url);
|
||
const id = Number(q.id);
|
||
if (!Number.isFinite(id) || id <= 0) { json(res, 400, { ok: false, error: "缺少有效的 id 参数" }); return; }
|
||
const row = getCustomScriptRow(initDb(), id);
|
||
if (!row) { json(res, 404, { ok: false, error: "脚本不存在或非原创选题脚本" }); return; }
|
||
json(res, 200, { ok: true, script: row });
|
||
} catch (e) {
|
||
json(res, 500, { ok: false, error: e.message });
|
||
}
|
||
},
|
||
});
|
||
|
||
// AI写脚本 三 tab 徽标计数(参考选题/原创选题,同步 V1.0 v25)
|
||
server.register({
|
||
path: "/mcn/api/rewrite-stats",
|
||
exact: true,
|
||
handler: async (req, res) => {
|
||
try {
|
||
const q = parseQuery(req.url);
|
||
const stats = countRewriteStats(initDb(), String(q.account || ""));
|
||
json(res, 200, { ok: true, ...stats });
|
||
} catch (e) {
|
||
json(res, 500, { ok: false, error: e.message });
|
||
}
|
||
},
|
||
});
|
||
|
||
// 首页「最近 AI 脚本」(UI 复刻 V1.0):rewrite_log done 前 N 条 + 账号名 + 复盘评分(latest ai done total)
|
||
server.register({
|
||
path: "/mcn/api/recent-scripts",
|
||
exact: true,
|
||
handler: async (req, res) => {
|
||
try {
|
||
const q = parseQuery(req.url);
|
||
const limit = Math.max(1, Math.min(Number(q.limit) || 8, 50));
|
||
const d = initDb();
|
||
const items = d.prepare(`
|
||
SELECT r.id, r.video_id, r.account_id, r.title, r.topic, r.script_text, r.created_time, h.account_name,
|
||
(SELECT total FROM script_review WHERE rewrite_id = r.id AND type='ai' AND status='done' AND total IS NOT NULL AND total <> '' ORDER BY id DESC LIMIT 1) AS score
|
||
FROM rewrite_log r LEFT JOIN hot_accounts h ON h.id = r.account_id
|
||
WHERE r.status='done' AND r.script_text IS NOT NULL AND r.script_text <> ''
|
||
ORDER BY r.id DESC LIMIT ?`).all(limit);
|
||
json(res, 200, { ok: true, items });
|
||
} catch (e) {
|
||
json(res, 500, { ok: false, error: e.message });
|
||
}
|
||
},
|
||
});
|
||
// 原创选题创作提交:{accountId, reqText?, attachText?}
|
||
// 服务器补全技能声明/人设/保存回调(与 /mcn/api/video/rewrite 同模式),落 rewrite_log(video_id 空 + gen_method='new')
|
||
server.register({
|
||
path: "/mcn/api/rewrite/original",
|
||
exact: true,
|
||
handler: async (req, res) => {
|
||
try {
|
||
const body = await readBody(req);
|
||
const accountId = Number(body.accountId);
|
||
if (!Number.isFinite(accountId) || accountId <= 0) {
|
||
json(res, 400, { ok: false, error: "缺少有效的 accountId 参数" });
|
||
return;
|
||
}
|
||
if (runningRewriteCount(ctx) >= REWRITE_MAX_CONCURRENT) {
|
||
json(res, 409, { ok: false, error: `创作任务已达并发上限(${REWRITE_MAX_CONCURRENT} 条),请等待部分完成后再提交` });
|
||
return;
|
||
}
|
||
const d = initDb();
|
||
const acc = d.prepare(`SELECT * FROM hot_accounts WHERE id=?`).get(accountId);
|
||
if (!acc || (acc.del_flag ?? 0) === 1) { json(res, 404, { ok: false, error: "账号不存在" }); return; }
|
||
// 账号设定:优先完整人设卡(剔除尾部非创作章节),无则回退「定位/内容」摘要
|
||
let setting = "";
|
||
try {
|
||
const persona = d.prepare(`SELECT content_json FROM account_persona WHERE account_id=? ORDER BY id DESC LIMIT 1`).get(accountId);
|
||
if (persona && typeof persona.content_json === "string" && persona.content_json.trim()) {
|
||
setting = trimPersonaForRewrite(persona.content_json);
|
||
}
|
||
} catch (e) {}
|
||
if (!setting && typeof acc.content === "string" && acc.content.trim()) setting = acc.content.trim();
|
||
const reqText = String(body.reqText || "").trim();
|
||
const attachText = String(body.attachText || "").trim();
|
||
const now = new Date().toISOString();
|
||
const logId = nextTaskIdAvoidingArchived(d, ctx, REWRITE_PREFIX, "rewrite_log");
|
||
const ins = d.prepare(`INSERT INTO rewrite_log (id, video_id, aweme_id, account_id, topic, gen_method, status, created_time, updated_time) VALUES (?, NULL, NULL, ?, ?, 'new', 'pending', ?, ?)`);
|
||
ins.run(logId, accountId, reqText || null, now, now);
|
||
rewriteTasks.set(logId, Date.now());
|
||
const { agent } = await getOrCreateRewriteAgent(ctx, logId);
|
||
if (agent.status === "running") {
|
||
// 会话已存在且在跑(极少数同 id 复用场景)→ 回滚行
|
||
try { d.prepare(`DELETE FROM rewrite_log WHERE id=?`).run(logId); } catch (e) {}
|
||
json(res, 409, { ok: false, error: "该创作任务正在执行中,请等待完成后再试" });
|
||
return;
|
||
}
|
||
const settingBlock = setting
|
||
? `【账号设定(人设卡)】\n${setting}`
|
||
: `该账号暂无账号设定,请先基于账号库中已有解析数据提炼生成人设卡(按「mcn-dou-analysis」技能功能三,路径 ~/.dsh/skills/mcn-dou-analysis/),再基于账号设定与【创作需求】创作完整脚本;未填写的维度由你按人设与选题自主决策。`;
|
||
const prompt = `用户请求为达人「${acc.account_name}」创作一条全新的短视频故事脚本(原创选题:无原视频载体)。\n`
|
||
+ `请调用「short-video-script」技能(短视频脚本创作技能,已全局注册,路径 ~/.dsh/skills/short-video-script/),按 ⚡自动模式 严格依其 SKILL.md 的 S1-S11 步骤顺序完整走创作流程(选题→框架→大纲→情绪→结构→脚本),中间步骤后台静默(各步产物在对话上下文中按序完整产出但不逐个展示),禁止跳过或合并中间步骤一步直出成品;逐条严格执行 SKILL.md「开场钩子铁律」与下方【账号设定】中的「内容规则」(如果…则…)——二者为硬约束,不得自由发挥、不得跳过。\n`
|
||
+ `${settingBlock}\n`
|
||
+ (reqText ? `【创作需求】\n${reqText}\n` : "")
|
||
+ (attachText ? `【附件素材】(达人上传的需求文档/资料原文,其中的诉求与约束同【创作需求】一样必须满足)\n${attachText}\n` : "")
|
||
+ `要求:\n`
|
||
+ `1. 严格按技能流程最终产出 S9 标准故事脚本(按场次+对白组织,画面标注【景别|环境|光线|场景|场景元素】),最终只输出脚本正文,禁止输出分镜头表格;\n`
|
||
+ `2. 完成后调用 POST /mcn/api/rewrite/save 保存:body {"id": ${logId}, "script": "<S9 故事脚本全文>"}(接口基地址用 $env:DSH_WEB_URL,为空则依次试 http://127.0.0.1:3081、http://127.0.0.1:3080);\n`
|
||
+ `3. 保存方式(重要,避免中文编码问题):先把脚本全文写入工作区文件(如 save_${logId}.json,用文件工具写 UTF-8 JSON,注意字符串转义),再用 curl.exe --data-binary "@save_${logId}.json" -H "Content-Type: application/json" 发送;或使用可用的 HTTP 工具。切勿用 PowerShell 的 Invoke-RestMethod 直接传含中文的 -Body 字符串(Windows PowerShell 5.1 会按 GBK 编码发送,导致服务端解析失败或乱码);\n`
|
||
+ `4. 保存接口返回 ok 才算完成;若返回 400/500,检查 JSON 转义与编码后重试,不要用测试文本占位。`;
|
||
agent.followup(createUserMessage({ content: [{ type: "text", text: prompt }], source: { kind: "user" } }));
|
||
d.prepare(`UPDATE rewrite_log SET status='running', updated_time=? WHERE id=?`).run(now, logId);
|
||
json(res, 200, { ok: true, id: logId, message: `已提交「${acc.account_name}」原创选题创作,AI 正在按 S1-S11 流程生成(当前并发 ${runningRewriteCount(ctx)}/${REWRITE_MAX_CONCURRENT})` });
|
||
} catch (e) {
|
||
console.error(`[dsh-plugin-mcn] 原创选题创作提交失败: ${e.message}`);
|
||
json(res, 500, { ok: false, error: e.message });
|
||
}
|
||
},
|
||
});
|
||
}
|
||
|
||
//#region 榜单更新任务辅助(同步 V1.0 09-03:30min 冷却 / 在跑防重 / force 覆盖 / meta 跨重启持久化)
|
||
|
||
/** 榜单目录解析:环境变量 MCN_RANKING_DIR 显式覆盖(与 V1.0 dsh-data.js 同源分层),否则主工作区/抖音榜单 */
|
||
function resolveRankingDir() {
|
||
const env = process.env.MCN_RANKING_DIR;
|
||
if (env && existsSync(env)) return env;
|
||
return join(resolveCreativeCwd(), "抖音榜单");
|
||
}
|
||
|
||
function readRankMeta() {
|
||
try {
|
||
const m = JSON.parse(readFileSync(RANK_META_FILE, "utf8"));
|
||
return m && typeof m === "object" ? m : {};
|
||
} catch (e) {
|
||
return {};
|
||
}
|
||
}
|
||
|
||
function writeRankMeta(m) {
|
||
try {
|
||
mkdirSync(dirname(RANK_META_FILE), { recursive: true });
|
||
writeFileSync(RANK_META_FILE, JSON.stringify(m, null, 2), "utf8");
|
||
} catch (e) {
|
||
console.warn(`[dsh-plugin-mcn] 榜单更新元数据写入失败: ${e.message}`);
|
||
}
|
||
}
|
||
|
||
/** 运行中判定:会话 live 且 running,或 meta 标记 running 且提交未超 10 分钟(重启后会话未恢复的宽限窗) */
|
||
function rankTaskRunning(ctx, meta) {
|
||
const a = ctx.agents.get(RANK_SESSION_ID);
|
||
if (a !== undefined && a.status === "running") return true;
|
||
return !!(meta.running && Date.now() - Number(meta.lastAt || 0) < 10 * 60 * 1000);
|
||
}
|
||
|
||
/** 自愈:meta 标记 running 但会话已不在跑且超宽限窗 → 依据落盘新鲜度判定完成/中断,置回空闲 */
|
||
function healRankMeta(ctx) {
|
||
const meta = readRankMeta();
|
||
if (!meta.running) return meta;
|
||
const a = ctx.agents.get(RANK_SESSION_ID);
|
||
if (a !== undefined && a.status === "running") return meta;
|
||
if (Date.now() - Number(meta.lastAt || 0) < 10 * 60 * 1000) return meta;
|
||
const freshness = rankingFreshness(resolveRankingDir());
|
||
if (freshness.latestMtime >= Number(meta.lastAt || 0)) {
|
||
meta.running = false;
|
||
meta.lastOk = true;
|
||
meta.lastError = null;
|
||
} else {
|
||
meta.running = false;
|
||
meta.lastOk = false;
|
||
meta.lastError = "任务中断或未完成(无新落盘文件,可能因 DSH 重启)";
|
||
}
|
||
writeRankMeta(meta);
|
||
return meta;
|
||
}
|
||
|
||
/** 获取(或创建)榜单更新专用会话:dsh_rank-update(固定命名复用,与创作会话同模式) */
|
||
async function getOrCreateRankAgent(ctx) {
|
||
const sessionId = RANK_SESSION_ID;
|
||
const existing = ctx.agents.get(sessionId);
|
||
if (existing !== undefined) return { agent: existing, created: false };
|
||
const agentOptions = { provider: CREATIVE_PROVIDER, model: CREATIVE_MODEL };
|
||
const compose = await composeDefaultPreset(ctx);
|
||
const cwd = resolveRankingDir();
|
||
// 会话工作目录必须存在(agent 工具以 cwd spawn,缺失会 ENOENT)
|
||
try { mkdirSync(cwd, { recursive: true }); } catch (e) {}
|
||
try {
|
||
const handle = await ctx.agents.resume({ resumeSessionId: sessionId, agentOptions, setup: compose.setup });
|
||
return { agent: handle.agent, created: false };
|
||
} catch (e) {
|
||
const handle = await ctx.agents.create({ sessionId, agentOptions, meta: { cwd, agentPreset: compose.agentPreset }, setup: compose.setup });
|
||
return { agent: handle.agent, created: true };
|
||
}
|
||
}
|
||
|
||
/** 榜单更新任务 prompt(同步 V1.0 SKILL_HINT_RANKING 语义:四榜落盘规范 + 查询幂等铁律 + 技能边界;force=覆盖刷新段) */
|
||
function rankUpdatePrompt(dir, force) {
|
||
return `用户请求更新本地抖音榜单数据(热点数据页「更新榜单」按钮触发,调用范围与次数已获用户确认:账号日榜 全品类 1 次 + 账号周榜 保留 5 赛道 + 视频热榜/点赞榜,红狐 API 按次计费)。\n`
|
||
+ `请调用「mcn-data-insight」技能(已全局注册,路径 ~/.dsh/skills/mcn-data-insight/,先读取其 SKILL.md 及 references 规范;若 Skill 工具无法加载,请直接读取该目录下 SKILL.md)。本任务按该技能「榜单数据落盘规范」将热点数据四榜最新一期落盘到本地目录 ${dir}:\n`
|
||
+ `① 视频热榜 + 点赞榜:运行技能 scripts/fetch_week_ranks.py --board both(全品类单次调用,禁止逐赛道/逐日循环);\n`
|
||
+ `② 账号日榜:按技能 subskills/douyin-top-account 流程抓取最新一期——用其脚本 --period day 直接获取全品类(不传赛道,单次调用),写入 ${dir}/账号日榜/抖音账号日榜_{榜单日期}.json;\n`
|
||
+ `③ 账号周榜:按 douyin-top-account 流程抓取保留 5 赛道(生活vlog/小剧场/亲子/美食/旅行)最新一期账号周榜,逐赛道写入 ${dir}/账号周榜/抖音周榜_{赛道}_{周一}.json;\n`
|
||
+ `④ 产物一律落入 账号日榜/账号周榜/视频热榜/点赞榜 四个子目录,文件名保持 抖音账号日榜_/抖音周榜_ 前缀;${force ? "本次为**强制覆盖刷新**(用户明确要求):对同榜名+赛道+数据日期已存在的文件也重新抓取覆盖,不得因文件已存在而跳过。\n" : ""}`
|
||
+ `⑤ 查询幂等铁律:每次调用前先 ls 目标目录,同榜名+赛道+数据日期文件已存在则直接跳过对应 API 调用(红狐 API 按次计费,禁止浪费配额);\n`
|
||
+ `⑥ 数据日期取 API 返回的榜单对应日期(最新一期),非获取时刻;文件 JSON 结构保持红狐原始 list 数组格式(勿改字段名),与既有 抖音榜单 文件一致。\n`
|
||
+ `完成后汇报:四榜各写入的文件路径与数据期、缓存命中跳过项、实际 API 调用次数。技能边界:只做榜单数据抓取与落盘,不做账号分析/脚本创作等其他能力。`;
|
||
}
|
||
|
||
//#endregion
|
||
|
||
const inject = ["webServer", "agents", "workspaceRegistry"];
|
||
export { apply, inject }; |