Files
admin 856e632807 baseline: dsh 工作区基线快照(2026-09-10,全新历史起点)
本提交为 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 本地已不存在,本提交记为删除
2026-09-10 22:31:45 +08:00

3096 lines
177 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 };