feat(工作台): AI创作 分镜头表老视频先提炼选题再创作 + 修复 #/rewrites noPersonaNote 未定义 P0

- buildTopicBlock(): 有#选题段直接用;无(分镜头表等)带解析内容+提炼指令;空只带标题
- 账号详情视频列表 + #/rewrites 两入口同步改造,desc 注明分镜头表先提炼
- 修复 renderRewrites 分支引用未定义 noPersonaNote 导致弹窗打不开(ReferenceError)
- 验证脚本 cdp-verify-aicreate.mjs(hook confirmRun 捕获 prompt,按 hasTopic 动态断言)
This commit is contained in:
maogeigei committed 2026-09-01 12:03:47 +08:00
1 parent 787ececedb
commit 9589680dc3
5 files changed
+394 -35

No files matched your search

@@ -12,9 +12,40 @@ const fmtWan = (v) => {
if (!Number.isFinite(n) || n <= 0) return '-';
return n >= 10000 ? (n / 10000).toFixed(1) + '万' : String(n);
};
/* AI创作用账号人设:剔除「## 十一、当前需解决的问题」及之后的非创作章节(十二、设定分析说明等),只留创作素材(09-01 用户要求) */
const personaForCreate = (text) => {
const s = String(text || '').trim();
const idx = s.indexOf('## 十一、');
return idx >= 0 ? s.slice(0, idx).trim() : s;
};
/* 从视频解析 content 提取「# 选题」段(赛道/受众/视频主题/开场钩子/中间留人/结尾落点);无则返回 ''(09-01 用户要求:视频本身的选题=解析内容的选题段,非标题) */
const extractVideoTopic = (content) => {
const s = String(content || '');
const i = s.indexOf('# 选题');
if (i < 0) return '';
const rest = s.slice(i + '# 选题'.length);
const j = rest.search(/\n# /);
return (j >= 0 ? rest.slice(0, j) : rest).trim();
};
/* AI创作 视频选题块:有「# 选题」段→直接用;无(老视频脚本为分镜头表等)→携带解析内容,创作 AI 先提炼选题再创作(09-01 用户要求) */
async function buildTopicBlock(r, parsed) {
if (!parsed) return { block: '', mode: 'none' };
let content = '';
if (r.id) {
try {
const vd = await api('/api/dsh/video-detail?videoId=' + encodeURIComponent(r.id));
content = (vd.parse && vd.parse.content) || '';
} catch (e) { /* 获取失败只带标题 */ }
}
const tc = extractVideoTopic(content);
if (tc) return { block: `\n【视频选题】\n标题:《${r.title}》\n# 选题\n${tc}\n`, mode: 'topic' };
const body = String(content || '').trim();
if (!body) return { block: `\n【视频选题】\n标题:《${r.title}》\n`, mode: 'none' };
// 无选题段(老视频脚本=分镜头表等)→ 完整解析内容作为提炼素材,prompt 先提炼选题再创作
return { block: `\n【视频解析内容(供提炼选题)】\n标题:《${r.title}》\n${body}\n`, mode: 'content' };
}
/* 弹窗标题:书名号只包标题主体;#标签及共创/素材来源信息整体换一行显示 */
const fmtTitleQ = (t) => {
const s = String(t || '').trim();
const fmtTitleQ = (t) => { const s = String(t || '').trim();
const idx = s.indexOf('#');
if (idx <= 0) return s; // 无 # 或 # 开头(纯标签标题)→ 原样显示
const title = stripTitleQuotes(s.slice(0, idx));
@@ -70,12 +101,13 @@ function bindBack(container) {
async function confirmAiScript(accountName, btn) {
// 判断账号是否有有效设定(persona 非空且非测试占位):有 →「基于当前账号设定」,无 →「先进行账号设定分析」
const accId = btn && btn.dataset.accId ? btn.dataset.accId : '';
let hasPersona = false;
let hasPersona = false, personaContent = '';
if (accId) {
try {
const r = await api('/api/dsh/account-persona?accountId=' + encodeURIComponent(accId));
const content = String(r.content || '').trim();
hasPersona = !!content && !/测试写入|连通性测试/.test(content);
if (hasPersona) personaContent = personaForCreate(content); // 完整拼入,仅剔除「十一、当前需解决的问题」及之后非创作章节
} catch (e) { hasPersona = false; }
}
const desc = hasPersona
@@ -84,7 +116,7 @@ async function confirmAiScript(accountName, btn) {
const mask = document.createElement('div'); mask.className = 'modal-mask';
mask.innerHTML = `
<div class="modal">
<h3>${svgIcon('pen', 18)} AI写脚本</h3>
<h3>${svgIcon('pen', 18)} AI创作</h3>
<p class="dim">${desc}</p>
<div class="ai-topic">
<textarea id="aiTopicInput" class="ai-topic-input" placeholder="输入选题方向(可选),如:亲子露营的搞笑瞬间"></textarea>
@@ -116,10 +148,13 @@ async function confirmAiScript(accountName, btn) {
mask.querySelector('[data-ok]').addEventListener('click', () => {
const topic = mask.querySelector('#aiTopicInput').value.trim();
close();
const act = hasPersona ? '基于当前账号设定创作。' : '请先进行账号设定分析,再生成完整脚本。';
const personaBlock = personaContent ? `\n【账号设定(人设卡)】\n${personaContent}\n` : '';
const act = hasPersona
? `请贴合上述账号设定(人设与内容规则)创作。`
: `该账号暂无账号设定,请先为达人「${accountName}」提取账号设定(生成人设卡),再基于账号设定创作完整脚本。`;
const prompt = topic
? `请为达人「${accountName}」创作一条短视频脚本,选题方向:${topic}\n${act}`
: `请为达人「${accountName}」创作一条短视频脚本。${act}`;
? `请为达人「${accountName}」创作一条短视频脚本。${personaBlock}\n选题方向:${topic}\n${act}`
: `请为达人「${accountName}」创作一条短视频脚本。${personaBlock}\n${act}`;
executeTask({ prompt, label: '创作指令', btn });
});
}
@@ -212,7 +247,7 @@ async function renderAccounts(view) {
<td>${esc(a.location || '-')}</td>
<td class="cell-content" title="${esc(a.bio || '')}">${esc((a.bio || '').slice(0, 50))}</td>
<td>${a.video_count ?? 0}</td>
<td class="cell-act"><button class="btn btn-sm" data-create='${esc(a.account_name)}' data-acc-id="${a.id}">AI写脚本</button></td>
<td class="cell-act"><button class="btn btn-sm" data-create='${esc(a.account_name)}' data-acc-id="${a.id}">AI创作</button></td>
</tr>`).join('');
view.innerHTML = `
<div class="page">
@@ -302,7 +337,7 @@ async function renderAccountDetail(view, id) {
<td>${fmtWan(v.comment_count)}</td>
<td>${fmtWan(v.share_count)}</td>
<td>${fmtDuration(v.duration)}</td>
<td class="cell-act"><button class="btn btn-sm ${(v.has_source || v.has_analysis) ? 'btn-view' : ''}" data-rewrite='${esc(JSON.stringify({ name: acc.account_name, title: cleanTitle(v.video_title), aweme: v.aweme_id, parsed: !!(v.has_source || v.has_analysis) }))}'>AI写作</button></td>
<td class="cell-act"><button class="btn btn-sm ${(v.has_source || v.has_analysis) ? 'btn-view' : ''}" data-rewrite='${esc(JSON.stringify({ id: v.id, name: acc.account_name, title: cleanTitle(v.video_title), aweme: v.aweme_id, parsed: !!(v.has_source || v.has_analysis) }))}'>AI创作</button></td>
</tr>`).join('');
const tabBtns = [['info', '账号信息'], ['persona', '账号设定'], ['analysis', '数据分析']].map(([k, label]) => `<button class="tab ${tab === k ? 'tab-active' : ''}" data-tab="${k}">${label}</button>`).join('');
let panel = '';
@@ -376,19 +411,30 @@ async function renderAccountDetail(view, id) {
if (cardEl) cardEl.classList.toggle('vs-hidden', viewMode !== 'card');
if (mdEl) mdEl.classList.toggle('vs-hidden', viewMode !== 'text');
}));
view.querySelectorAll('[data-rewrite]').forEach((b) => b.addEventListener('click', () => {
view.querySelectorAll('[data-rewrite]').forEach((b) => b.addEventListener('click', async () => {
const r = JSON.parse(b.dataset.rewrite);
const parsed = !!r.parsed;
if (!persona) await loadPersona(); // 点击时才确保账号设定已加载(不阻塞页面首屏)
const personaText = personaForCreate(persona || ''); // 剔除「十一、当前需解决的问题」及之后章节
const personaBlock = personaText ? `\n【账号设定(人设卡)】\n${personaText}\n` : ''; // 完整拼入,不截断
const noPersonaNote = personaText ? '' : `\n该账号暂无账号设定,请先为达人「${r.name}」提取账号设定(生成人设卡),再基于账号设定与选题创作。`;
// 已解析 → 视频选题块:有「# 选题」段直接用;无(老视频脚本=分镜头表等)→ 带解析内容,AI 先提炼选题再创作
const tb = await buildTopicBlock(r, parsed);
const createTail = tb.mode === 'content'
? `请先通读以上【视频解析内容】,提炼出该视频的选题(赛道/受众/视频主题/开场钩子/中间留人/结尾落点等),再结合账号设定与提炼出的选题,创作一个全新的故事脚本(新故事、新情节,不要复刻原视频),输出完整故事脚本。`
: `请结合账号设定与视频选题,创作一个全新的故事脚本(新故事、新情节,不要复刻原视频),输出完整故事脚本。`;
confirmRun({
title: 'AI改写',
title: 'AI创作',
desc: parsed
? `该视频已解析,将基于解析内容为达人「${r.name}」改写一条全新故事脚本(新故事、新情节,不复刻原视频)。`
: `该视频尚未解析,将先解析视频内容,再为达人「${r.name}」改写一条全新故事脚本(新故事、新情节,不复刻原视频)。`,
? tb.mode === 'content'
? `该视频已解析(脚本为分镜头表格式,无选题段),将先提炼视频选题,再为达人「${r.name}」创作一条全新故事脚本(新故事、新情节,不复刻原视频)。`
: `该视频已解析,将基于达人账号设定与视频选题,为达人「${r.name}」创作一条全新故事脚本(新故事、新情节,不复刻原视频)。`
: `该视频尚未解析,将先解析视频内容,再基于达人账号设定为达人「${r.name}」创作一条全新故事脚本(新故事、新情节,不复刻原视频)。`,
target: `达人「${r.name}」|选题《${r.title}》`,
prompt: parsed
? `请为达人「${r.name}」改写一条短视频故事脚本。视频选题:《${r.title}》\n请基于账号设定与已解析的视频内容,创作一个全新的故事脚本(新故事、新情节,不要复刻原视频),输出完整故事脚本。`
: `请先解析这个抖音视频并做拆解分析,再为达人「${r.name}」改写一条全新故事脚本:https://www.douyin.com/video/${r.aweme || ''}\n视频标题:《${r.title}》`,
label: '改写指令',
? `请为达人「${r.name}」创作一条全新的短视频故事脚本。${personaBlock}${noPersonaNote}${tb.block}${createTail}`
: `请先解析这个抖音视频并做拆解分析,再为达人「${r.name}」创作一条全新故事脚本:https://www.douyin.com/video/${r.aweme || ''}\n视频标题:《${r.title}》${personaBlock}${noPersonaNote}`,
label: '创作指令',
btn: b,
});
}));
@@ -414,7 +460,7 @@ async function renderVideoDetail(view, id) {
if (!d.ok) { view.innerHTML = `<div class="page"><div class="page-toolbar">${toolbarBack()}</div><div class="empty">${esc(d.error || '视频不存在')}</div></div>`; bindBack(view); return; }
const v = d.video, analysis = d.analysis, parse = d.parse || {};
const infoGrid = (label, val, wide, raw) => `<div class="info-item ${wide ? 'info-wide' : ''}"><div class="info-label">${label}</div><div class="info-value">${raw ? val : esc(fmt(val))}</div></div>`;
const analysisToMd = (obj) => Object.entries(obj || {}).map(([k, val]) => `## ${k}\n\n${val}`).join('\n\n---\n\n');
const analysisToMd = (obj) => Object.entries(obj || {}).map(([k, val]) => `# ${k}\n\n${val}`).join('\n\n---\n\n');
function load() {
const titleVal = v.video_url ? `<a class="link" data-ext="${esc(v.video_url)}" title="在新标签打开视频">${esc(cleanTitle(v.video_title))}</a>` : esc(cleanTitle(v.video_title));
@@ -432,7 +478,7 @@ async function renderVideoDetail(view, id) {
<div class="detail-body">
<div class="info-card"><div class="info-grid">
${infoGrid('视频标题', titleVal, true, true)}${infoGrid('账号', v.account_name)}${infoGrid('视频ID', v.aweme_id)}
${infoGrid('点赞', v.like_display || v.like_count)}${infoGrid('评论', v.comment_count)}${infoGrid('分享', v.share_count)}
${infoGrid('点赞', fmtWan(v.like_display || v.like_count))}${infoGrid('评论', fmtWan(v.comment_count))}${infoGrid('分享', fmtWan(v.share_count))}
${infoGrid('收藏', v.collect_count)}${infoGrid('播放', (v.play_count == null || Number(v.play_count) === 0) ? null : v.play_count)}${infoGrid('时长', fmtDuration(v.duration))}
${infoGrid('发布时间', v.publish_time)}${infoGrid('标签', v.tags, true)}
</div></div>
@@ -455,14 +501,14 @@ async function renderRewrites(view) {
const rows = (d.items || []).map((v) => `<tr>
<td>${v.account_id ? `<a class="link" href="#/account/${v.account_id}" title="查看账号详情">${esc(v.account_name || '-')}</a>` : esc(v.account_name || '-')}</td>
<td class="cell-content">${v.video_url ? `<a class="title-plain" data-ext="${esc(v.video_url)}" title="在新标签打开原视频">${esc(cleanTitle(v.video_title) || '-')}</a>` : esc(cleanTitle(v.video_title) || '-')}</td>
<td>${fmt(v.like_display || v.like_count)}</td>
<td>${fmtWan(v.like_display || v.like_count)}</td>
<td>${fmtWan(v.comment_count)}</td>
<td>${fmtWan(v.share_count)}</td>
<td>${fmtDuration(v.duration)}</td>
<td>${fmt(v.publish_time)}</td>
<td>${(v.has_source || v.has_analysis) ? '✅已解析' : '未解析'}</td>
<td>${v.rewrite_count > 0 ? `<button class="btn btn-sm btn-view" data-detail="${v.id}" title="查看改写脚本">${v.rewrite_count} 查看</button>` : '—'}</td>
<td class="cell-act"><button class="btn btn-sm" data-rewrite='${esc(JSON.stringify({ id: v.id, name: v.account_name, title: cleanTitle(v.video_title) }))}'>AI写脚本</button></td>
<td class="cell-act"><button class="btn btn-sm" data-rewrite='${esc(JSON.stringify({ id: v.id, name: v.account_name, title: cleanTitle(v.video_title), accountId: v.account_id, parsed: !!(v.has_source || v.has_analysis), url: v.video_url || '' }))}'>AI创作</button></td>
</tr>`).join('');
view.innerHTML = `
<div class="page">
@@ -490,14 +536,35 @@ async function renderRewrites(view) {
view.querySelector('#rwParsed').addEventListener('change', (e) => { state.parsed = e.target.value; state.page = 1; load(); });
bindPager(view, (p) => { state.page = p; load(); });
view.querySelectorAll('[data-detail]').forEach((a) => a.addEventListener('click', () => openRewriteCompare(a.dataset.detail)));
view.querySelectorAll('[data-rewrite]').forEach((b) => b.addEventListener('click', () => {
view.querySelectorAll('[data-rewrite]').forEach((b) => b.addEventListener('click', async () => {
const r = JSON.parse(b.dataset.rewrite);
const parsed = !!r.parsed;
let personaBlock = '';
if (r.accountId) {
try {
const pr = await api('/api/dsh/account-persona?accountId=' + encodeURIComponent(r.accountId));
const c = String(pr.content || '').trim();
if (c) personaBlock = `\n【账号设定(人设卡)】\n${personaForCreate(c)}\n`; // 剔除「十一、当前需解决的问题」及之后章节
} catch (e) { /* 人设缺失不阻塞 */ }
}
const noPersonaNote = personaBlock ? '' : `\n该账号暂无账号设定,请先为达人「${r.name}」提取账号设定(生成人设卡),再基于账号设定与选题创作。`;
// 已解析 → 视频选题块:有「# 选题」段直接用;无(老视频脚本=分镜头表等)→ 带解析内容,AI 先提炼选题再创作
const tb = await buildTopicBlock(r, parsed);
const createTail = tb.mode === 'content'
? `请先通读以上【视频解析内容】,提炼出该视频的选题(赛道/受众/视频主题/开场钩子/中间留人/结尾落点等),再结合账号设定与提炼出的选题,创作一个全新的故事脚本(新故事、新情节,不要复刻原视频),输出完整故事脚本。`
: `请结合账号设定与视频选题,创作一个全新的故事脚本(新故事、新情节,不要复刻原视频),输出完整故事脚本。`;
confirmRun({
title: '改写脚本',
desc: '将基于该视频,为达人改写一条全新的 S9 故事脚本(新故事、新情节,不复刻原视频)。',
target: `达人「${r.name}」|《${r.title}》`,
prompt: `请为达人「${r.name}」基于视频《${r.title}》改写一条全新的 S9 故事脚本(新故事、新情节,不复刻原视频)。`,
label: '改写指令',
title: 'AI创作',
desc: parsed
? tb.mode === 'content'
? `该视频已解析(脚本为分镜头表格式,无选题段),将先提炼视频选题,再为达人「${r.name}」创作一条全新故事脚本(新故事、新情节,不复刻原视频)。`
: `该视频已解析,将基于达人账号设定与视频选题,为达人「${r.name}」创作一条全新故事脚本(新故事、新情节,不复刻原视频)。`
: `该视频尚未解析,将先解析视频内容,再为达人「${r.name}」创作一条全新故事脚本(新故事、新情节,不复刻原视频)。`,
target: `达人「${r.name}」|选题《${r.title}》`,
prompt: parsed
? `请为达人「${r.name}」创作一条全新的短视频故事脚本。${personaBlock}${noPersonaNote}${tb.block}${createTail}`
: `请先解析这个抖音视频并做拆解分析,再为达人「${r.name}」创作一条全新故事脚本${r.url ? ':' + r.url : ''}\n视频标题:《${r.title}》${personaBlock}${noPersonaNote}`,
label: '创作指令',
btn: b,
});
}));
@@ -12,7 +12,7 @@
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; }
html, body { height: 100%; overflow: hidden; } /* 09-01 禁止 html/body 滚动,消除 topbar 1px 边框导致的第二层滚动条;滚动统一由 main#view 承担 */
body {
font-family: "Microsoft YaHei", "PingFang SC", "Segoe UI", sans-serif;
background: var(--bg);
@@ -260,7 +260,7 @@ a.crumb-home:hover { color: var(--primary); }
.crumb-sep { color: var(--dim); font-size: 16px; }
.crumb-current { color: var(--text); font-weight: 600; font-size: 16px; }
main#view { height: calc(100vh - 54px); overflow: auto; }
main#view { height: calc(100vh - 55px); overflow: auto; } /* 55px=topbar 实际高度(padding20+行高34+边框1);54px 时底部溢出 1px 触发第二层滚动条 */
.view-empty { padding: 40px; text-align: center; color: var(--dim); }
/* ================= 首页 ================= */
@@ -280,8 +280,15 @@ main#view { height: calc(100vh - 54px); overflow: auto; }
/* ================= 功能页通用 ================= */
.page { padding: 20px 24px; max-width: 1440px; margin: 0 auto; }
/* 账号详情页:仅内容区(信息卡/列表/面板)右移 10px,返回按钮与 tab 保持原位 */
.page-detail .detail-body { padding-left: 10px; }
/* ================= 视频详情页 md-body:内容相对 tab 缩进 20px + 标题字号收紧 ================= */
.page-detail .md-body { font-size: 15px; margin-left: 20px; }
.page-detail .md-body h1 { font-size: 20px; border-bottom: 1px solid var(--border); padding-bottom: 4px; margin: 14px 0 8px; }
.page-detail .md-body h2 { font-size: 17px; border-bottom: 1px solid var(--border); padding-bottom: 3px; margin: 12px 0 6px; }
.page-detail .md-body h3 { font-size: 15px; margin: 10px 0 6px; }
.page-detail .md-body ul, .page-detail .md-body ol { margin: 6px 0 6px 18px; }
.page-detail .md-body p { margin: 6px 0; }
/* 账号详情页:仅内容区(信息卡/列表/面板)右移 10px,返回按钮与 tab 保持原位;视频详情页内容贴近左边(2026-09-01 改为 0) */
.page-detail .detail-body { padding-left: 0; }
.page-toolbar { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 24px; }
.toolbar-right { display: flex; align-items: center; gap: 8px; margin-left: auto; }
.toolbar-note { font-size: 12px; color: var(--dim); text-align: right; margin: -14px 10px 16px auto; } /* 功能栏下方小字说明,右对齐 */
@@ -363,11 +370,13 @@ th.th-id { width: 120px; }
.info-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 10px 18px; }
.info-item { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
.info-wide { grid-column: 1/-1; }
.info-row .info-item.info-wide { flex: 1 1 auto; min-width: 0; max-width: 100%; } /* 09-01 修复横向溢出:info-wide 允许收缩,否则长内容把 main 撑出横向滚动条 */
.info-label { font-size: 14px; color: var(--dim); }
.info-value { font-size: 15px; color: var(--text); word-break: break-word; white-space: pre-wrap; line-height: 1.6; }
/* 长文本默认最多 10 行,可点击展开/收起(账号定位等) */
.clamp-text { display: -webkit-box; -webkit-line-clamp: 10; -webkit-box-orient: vertical; overflow: hidden; }
.clamp-text.open { -webkit-line-clamp: unset; }
/* 09-01 改用 block+max-height 裁剪:display:-webkit-box 下长句不折行,内容宽被撑到 1896px 导致 main 横向滚动条 */
.clamp-text { display: block; max-height: 240px; overflow: hidden; word-break: break-word; overflow-wrap: anywhere; } /* 240px=10行×15px×1.6 */
.clamp-text.open { max-height: none; }
.clamp-btn { border: none; background: none; color: #4a90e2; cursor: pointer; font-size: 13px; padding: 2px 0; }
.clamp-btn:hover { text-decoration: underline; }