feat(工作台): AI创作输入输出梳理+顺序统一(账号设定前置)+类型列签约改内部

- data-pages.js: 未解析两分支账号设定前置; 账号详情 data-rewrite 补 url; 类型列 badge+筛选下拉 签约→内部(后端 type=signed 不变)
- docs/AI会话任务输入输出对照.md: 8类功能入口输入输出+触发链路+prompt结构模板
- cdp-verify-aicreate.mjs: 断言升级(hasTopic动态分支+顺序断言)
- cdp-verify-typebadge.mjs: 新增类型列改名验证(排除bio数据文本)
This commit is contained in:
maogeigei committed 2026-09-01 12:20:20 +08:00
1 parent e91c8046c6
commit 79b8a3d036
5 files changed
+215 -7

No files matched your search

+15
View File
@@ -154,3 +154,18 @@
- ② 账号详情旧梦留声机 id185(hasTopic=true):含【视频选题】+「# 选题」段(10106 字符)✅
- 未解析分支不变 PASS ✅
- 教训:断言必须按数据实况动态分支(hasTopic true→查【视频选题】,false→查【视频解析内容】+提炼指令),写死单一路径会误报 FAIL
## 全功能 AI 会话任务输入输出梳理 + 顺序统一(12:05-12:20)
- **用户需求**:①把每个功能对应的 AI 会话任务输入输出搞清楚 ②创作类 prompt 一般「账号设定放前面、选题信息放后面」
- **梳理产出**:`mcn-work-shop/docs/AI会话任务输入输出对照.md`(新建 docs/ 目录)——8 类功能入口对照表(账号列表 AI创作/账号详情视频列表 AI创作/#/rewrites AI创作/提炼账号设定/生成分镜/复盘评分/保存并分析账号/添加账号)+ 触发链路 + 各功能 prompt 结构模板 + 关键细节 + 验证说明
- **顺序统一改动(data-pages.js 2 处未解析分支)**:账号详情视频列表 L436 + #/rewrites L566 —— 未解析 prompt 原为「请先解析…再创作:URL\n标题…${personaBlock}」(账号设定在末尾),统一改为「请为达人…创作…。${personaBlock}${noPersonaNote}\n该视频尚未解析,请先解析…(视频地址+标题)…再基于账号设定与提炼出的选题创作」(账号信息前置)
- **补漏**:账号详情分支 data-rewrite JSON 缺 url 字段(#/rewrites 分支有),未解析 prompt 用 r.url 会丢视频地址 → 补齐 `url: v.video_url || ''`(L340)
- **验证**:cdp-verify-aicreate.mjs 升级断言——已解析 topic/content 两模式 + 未解析「账号信息(人设块或缺失提示)位置 < 解析要求位置」顺序断言;全部 PASS(①已解析旧梦留声机 1629 content 模式、②未解析白昼小熊缺失提示 idx26<解析要求 idx75、③账号详情旧梦留声机 topic 模式)
- **已确认无需改**:confirmAiScript(账号列表)本就是 人设→选题方向 顺序;生成分镜/提炼设定/复盘/保存添加为转换/诊断类任务,无账号设定-选题顺序概念
## 09-01 20:0x 账号列表「类型」列 签约→内部(badge + 筛选下拉)
- **用户需求**:「把账号列表下的 类型列的签约改为 内部」
- **改动(data-pages.js 2 处文案)**:L237 badge `${t === 'external' ? '外部' : '签约'}`→`'内部'`;L260 筛选下拉 option「签约」→「内部」;后端 type=signed 值不变
- **CDP 验证全 PASS**(cdp-verify-typebadge.mjs):badge 文本 ["内部","外部","外部","外部","外部","内部","内部"];下拉 ["全部","内部","外部"];UI(badge+下拉)无「签约」残留
- **「签约」残留排查边界**:页面含「签约」文本来自账号 bio 数据(如「Oiiioii签约超创」简介),属账号数据不在改名范围——用 CDP 脚本定位(tmp/find-signed.mjs,跑完即删)确认为数据文本而非 UI
- 临时脚本已删;待提交(与顺序统一+docs 文档一起按 2 小时节奏 push)
@@ -119,8 +119,16 @@ async function main() {
await evalJs(ws, `document.querySelectorAll('[data-rewrite]')[${unparsedInfo.idx}].click(); true`);
await waitFor(ws, `!!window.__cap`, 30000, 'confirmRun 捕获(未解析)');
const p2 = JSON.parse(await evalJs(ws, `JSON.stringify(window.__cap)`)).prompt || '';
const ok2 = p2.includes('请先解析这个抖音视频') && !p2.includes('undefined');
console.log(' 未解析分支(先解析再创作+无 undefined 泄漏):', ok2 ? 'PASS' : 'FAIL');
// 账号设定在前:账号相关信息(【账号设定(人设卡)】块,或无人设时的「该账号暂无账号设定」提示)必须出现在解析要求之前(09-01 用户要求统一顺序)
const iP2 = p2.indexOf('【账号设定(人设卡)】');
const iNote = p2.indexOf('该账号暂无账号设定');
const iUn = p2.indexOf('该视频尚未解析');
const acctPos = iP2 >= 0 ? iP2 : iNote; // 有 persona 取人设块位置;无 persona 取缺失提示位置
const ok2 = p2.includes('该视频尚未解析,请先解析')
&& acctPos >= 0 && iUn > acctPos // 账号相关信息在解析要求之前
&& !p2.includes('undefined');
console.log(' 未解析分支:人设块 idx=', iP2, '| 缺失提示 idx=', iNote, '| 解析要求 idx=', iUn);
console.log(' 未解析分支(账号信息前置+先解析再创作+无泄漏):', ok2 ? 'PASS' : 'FAIL');
} else {
console.log(' 无未解析视频可测(跳过)');
}
@@ -0,0 +1,83 @@
/* cdp-verify-typebadge.mjs — 账号列表「类型」列 签约→内部 改名验证(09-01)
* 断言:①badge 文本(非 external)=「内部」、external=「外部」不变 ②筛选下拉选项「内部」③页面无「签约」残留
* 运行:node cdp-verify-typebadge.mjs
*/
const CDP = 'http://127.0.0.1:9223';
async function getWs(targetId) {
const tabs = await (await fetch(CDP + '/json')).json();
const t = tabs.find((x) => x.id === targetId);
if (!t) throw new Error('target not found: ' + targetId);
return t.webSocketDebuggerUrl;
}
function cdp(ws, method, params = {}) {
return new Promise((resolve, reject) => {
const id = ++cdp._id;
const w = new WebSocket(ws);
const timer = setTimeout(() => { try { w.close(); } catch (e) {} reject(new Error('timeout: ' + method)); }, 30000);
w.onopen = () => w.send(JSON.stringify({ id, method, params }));
w.onmessage = (ev) => {
const m = JSON.parse(ev.data);
if (m.id !== id) return;
clearTimeout(timer);
try { w.close(); } catch (e) {}
m.error ? reject(new Error(method + ': ' + JSON.stringify(m.error))) : resolve(m.result);
};
w.onerror = () => { clearTimeout(timer); reject(new Error('ws error: ' + method)); };
});
}
cdp._id = 0;
async function evalJs(ws, expr) {
const r = await cdp(ws, 'Runtime.evaluate', { expression: expr, awaitPromise: true, returnByValue: true });
if (r.exceptionDetails) throw new Error('eval err: ' + JSON.stringify(r.exceptionDetails).slice(0, 300));
return r.result && r.result.value;
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function waitFor(ws, expr, timeout = 40000, label = '') {
const t0 = Date.now();
while (Date.now() - t0 < timeout) {
try { if (await evalJs(ws, expr)) return true; } catch (e) {}
await sleep(800);
}
throw new Error('waitFor timeout: ' + label + ' -> ' + expr);
}
async function main() {
const tabs = await (await fetch(CDP + '/json')).json();
const wb = tabs.find((t) => t.url.includes('localhost:8900') || t.url.includes('127.0.0.1:8900'));
if (!wb) throw new Error('未找到 8900 工作台标签页');
const ws = await getWs(wb.id);
console.log('▶ 复用标签页:', wb.url);
await evalJs(ws, `location.hash = '#/accounts'`);
await cdp(ws, 'Page.reload', { ignoreCache: true });
await waitFor(ws, `!!document.querySelector('[data-create]')`, 40000, '账号列表渲染');
await sleep(500);
const r = await evalJs(ws, `(() => {
const badges = [...document.querySelectorAll('.badge')].map((b) => b.textContent.trim());
const sel = document.querySelector('#accType');
const opts = sel ? [...sel.options].map((o) => o.textContent.trim()) : [];
// UI 残留检查仅限 badge 与筛选下拉(账号 bio 简介等数据文本含「签约」属账号数据,不在改名范围)
const badgeText = document.querySelector('.table-wrap') ? [...document.querySelectorAll('.badge')].map((b) => b.textContent).join('') : '';
const selText = sel ? sel.textContent : '';
return { badges, opts, uiSigned: (badgeText + selText).includes('签约') };
})()`);
console.log('badge 文本:', JSON.stringify(r.badges));
console.log('筛选下拉选项:', JSON.stringify(r.opts));
console.log('UI(badge+下拉)含「签约」残留:', r.uiSigned);
const extOk = r.badges.includes('外部');
const intOk = r.badges.some((b) => b === '内部');
const optOk = r.opts.includes('内部') && !r.opts.includes('签约');
const noSigned = !r.uiSigned;
console.log('✅ 外部 badge 保留:', extOk ? 'PASS' : 'FAIL');
console.log('✅ 内部 badge 出现:', intOk ? 'PASS' : 'FAIL');
console.log('✅ 下拉含「内部」且无「签约」:', optOk ? 'PASS' : 'FAIL');
console.log('✅ UI 无「签约」残留:', noSigned ? 'PASS' : 'FAIL');
console.log('\n====', (extOk && intOk && optOk && noSigned) ? '全部 PASS ====' : '存在 FAIL ====');
}
main().catch((e) => { console.error('❌', e.message); process.exit(1); });
@@ -0,0 +1,102 @@
# MCN 工作台 · AI 会话任务输入输出对照
> 更新:2026-09-01 | 范围:工作台所有触发 AI 会话任务(/api/run → 左侧会话栏后台会话)的功能入口
> 统一规则(用户定):**创作类任务 prompt 一律「账号设定(人设卡)在前,选题信息在后」**;无人设时以「该账号暂无账号设定,请先提取」提示占位(同样前置)。
## 触发链路(所有入口共用)
按钮点击 → 确认弹窗(confirmRun / confirmAiScript / confirmSaveAccount)→ `executeTask()` → `POST /api/run`(server.js 写 automations 表,scheduledAt=now+5s + next_run_at=now+5000)→ 客户端调度器拾取 → 左侧会话栏「mcn-workshop」空间后台会话执行 → 轮询 `/api/run/status`(done/error/review)→ 完成刷新页面。
## 功能对照表
| # | 功能入口 | 按钮 | 任务名(label) | 输入(prompt 注入) | 输出 | 代码位置 |
|---|---------|------|--------------|--------------------|------|---------|
| 1 | 账号列表(#/accounts) | AI创作 | 创作指令 | 账号名 + 【账号设定(人设卡)】(完整、剔非创作章节) + 选题方向(手输 或 AI 结合人设生成) | 全新故事脚本 | confirmAiScript |
| 2 | 账号详情(#/account/:id)视频列表 | AI创作 | 创作指令 | 账号名 + 【账号设定(人设卡)】+ 视频选题块(有「# 选题」段→直接用;无→【视频解析内容(供提炼选题)】+ 提炼指令)或 未解析→先解析再创作 | 全新故事脚本 | renderAccountDetail data-rewrite |
| 3 | AI写脚本(#/rewrites)视频列表 | AI创作 | 创作指令 | 同 2(按 accountId 拉人设,带 video_url) | 全新故事脚本 | renderRewrites data-rewrite |
| 4 | 账号详情(#/account/:id) | 提炼账号设定 | 提炼指令 | 账号名 | 人设卡(12段账号设定) | renderAccountDetail data-extract |
| 5 | 改写对比弹窗(AI脚本 vs 源脚本) | 生成分镜 / 重新生成 | 分镜指令 | 视频标题 + AI改写脚本 | AI 分镜提示词(按分镜技能) | openRewriteCompare fireGenSb |
| 6 | AI脚本诊断(#/reviews) | 复盘 | 复盘指令 | 该条 AI 脚本(后台按 mcn-script-review 技能执行) | 评分式复盘报告(AI/源/对比三模式) | renderReviews data-review-row |
| 7 | 周榜(#/ranking / 首页) | 分析 / 关注 | 保存指令 | 账号名 + 主页 URL | 账号信息 + 视频列表入库 | confirmSaveAccount |
| 8 | 账号列表(#/accounts) | 添加 | 添加指令 | 账号名称 + 账号地址(可选) | 网页采集账号信息 + 视频列表入库 | openImportAccounts |
## 各功能 prompt 结构与顺序
### 1/2/3 创作类(账号设定前 → 选题后)
**已解析 + 有「# 选题」段**(mode=topic):
```
请为达人「{name}」创作一条全新的短视频故事脚本。
【账号设定(人设卡)】
{人设卡全文,剔「## 十一、」及之后}
【视频选题】
标题:《{title}》
# 选题
{选题段:赛道/受众/视频主题/开场钩子/中间留人/结尾落点}
请结合账号设定与视频选题,创作一个全新的故事脚本(新故事、新情节,不要复刻原视频),输出完整故事脚本。
```
**已解析 + 无「# 选题」段**(老视频脚本=分镜头表,mode=content):
```
请为达人「{name}」创作一条全新的短视频故事脚本。
【账号设定(人设卡)】
{人设卡全文}
【视频解析内容(供提炼选题)】
标题:《{title}》
{解析 content 全文}
请先通读以上【视频解析内容】,提炼出该视频的选题(赛道/受众/视频主题/开场钩子/中间留人/结尾落点等),再结合账号设定与提炼出的选题,创作一个全新的故事脚本(新故事、新情节,不要复刻原视频),输出完整故事脚本。
```
**未解析**(先解析再创作,账号信息仍前置):
```
请为达人「{name}」创作一条全新的短视频故事脚本。
【账号设定(人设卡)】 或 「该账号暂无账号设定,请先为达人「{name}」提取账号设定(生成人设卡),再基于账号设定与选题创作。」
该视频尚未解析,请先解析这个抖音视频并做拆解分析(视频地址:{url}
视频标题:《{title}》),再基于账号设定与提炼出的选题,创作完整故事脚本。
```
**账号列表入口(confirmAiScript,选题方向)**:
```
请为达人「{name}」创作一条短视频脚本。
【账号设定(人设卡)】
{人设卡全文}
选题方向:{topic}(可选)
请贴合上述账号设定(人设与内容规则)创作。
```
### 4 提炼账号设定
```
请为达人「{name}」提炼账号设定(生成人设卡)。
```
### 5 生成分镜(转换类,无账号设定/选题概念)
```
请将视频《{title}》的 AI 改写脚本{重新}展开成 AI 分镜提示词(按分镜技能生成镜头级描述)。
```
### 6 复盘评分(诊断类,由技能定义输入输出)
```
请对这条 AI 脚本做复盘评分(AI脚本 / 原视频脚本 / 对比分析三种模式),按 mcn-script-review(脚本复盘)技能执行。
```
### 7 保存并分析账号
```
请帮我把抖音账号「{name}」保存并分析(账号信息 + 视频列表):{url}
```
### 8 添加账号
```
请帮我把抖音账号 {账号名称「x」, 账号地址「y」} 添加:通过抖音网页获取账号信息与视频列表,写入账号列表并保存到本地
```
## 关键细节
- **人设卡注入**:完整拼入(不截断),仅通过 `personaForCreate()` 剔除「## 十一、当前需解决的问题」及之后章节(分析师视角内容非创作素材);三处创作入口统一。
- **视频选题提取**:`extractVideoTopic(content)` 取解析 content 的「# 选题」段(到下一个 `\n# ` 标题止);无该段(老脚本为分镜头表格式)→ `buildTopicBlock` mode=content 携带全文 + 提炼指令,AI 先提炼选题再创作。
- **按钮命名**:全部统一「AI创作」(2026-09-01),弹窗标题同;「提炼账号设定」「生成分镜」「复盘」「分析/关注」「添加」为各自独立任务。
- **未解析分支**:带 video_url 时 prompt 内嵌「视频地址:{url}」;账号列表视频(#/rewrites)用 r.url,账号详情视频列表用 r.aweme 拼 https://www.douyin.com/video/{aweme}(历史遗留,建议统一为 video_url)。
## 验证
- 脚本:`cdp-verify-aicreate.mjs`(hook confirmRun 捕获 prompt,断言账号设定在前、选题在后、无 undefined 泄漏、未解析分支账号信息前置)
- 覆盖:①#/rewrites 已解析(topic/content 两模式)②#/rewrites 未解析 ③账号详情已解析
@@ -234,7 +234,7 @@ async function renderAccounts(view) {
const tracks = (await api('/api/dsh/tracks').catch(() => ({ tracks: [] }))).tracks || [];
async function load() {
const d = await api('/api/dsh/accounts?type=' + state.type + '&search=' + encodeURIComponent(state.search) + '&track=' + encodeURIComponent(state.track) + '&page=' + state.page + '&pageSize=' + state.pageSize);
const badge = (t) => `<span class="badge ${t === 'external' ? 'badge-ext' : 'badge-signed'}">${t === 'external' ? '外部' : '签约'}</span>`;
const badge = (t) => `<span class="badge ${t === 'external' ? 'badge-ext' : 'badge-signed'}">${t === 'external' ? '外部' : '内部'}</span>`;
const rows = (d.items || []).map((a) => `<tr>
<td><button class="fav-btn" data-fav="${a.id}" data-fav-val="${a.favorite ? 1 : 0}" title="${a.favorite ? '取消关注' : '点击关注'}">${a.favorite ? '⭐' : '☆'}</button></td>
<td>${badge(a.account_type)}</td>
@@ -257,7 +257,7 @@ async function renderAccounts(view) {
<div class="toolbar-right" style="margin-right:10px">
<select id="accType" class="input-select">
<option value="all" ${state.type === 'all' ? 'selected' : ''}>全部</option>
<option value="signed" ${state.type === 'signed' ? 'selected' : ''}>签约</option>
<option value="signed" ${state.type === 'signed' ? 'selected' : ''}>内部</option>
<option value="external" ${state.type === 'external' ? 'selected' : ''}>外部</option>
</select>
<select id="accTrack" class="input-select"><option value="">全部赛道</option>${tracks.map((t) => `<option value="${esc(t)}" ${t === state.track ? 'selected' : ''}>${esc(t)}</option>`).join('')}</select>
@@ -337,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({ 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>
<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, url: v.video_url || '', 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 = '';
@@ -433,7 +433,7 @@ async function renderAccountDetail(view, id) {
target: `达人「${r.name}」|选题《${r.title}》`,
prompt: parsed
? `请为达人「${r.name}」创作一条全新的短视频故事脚本。${personaBlock}${noPersonaNote}${tb.block}${createTail}`
: `请先解析这个抖音视频并做拆解分析,再为达人「${r.name}」创作一条全新故事脚本:https://www.douyin.com/video/${r.aweme || ''}\n视频标题:《${r.title}》${personaBlock}${noPersonaNote}`,
: `请为达人「${r.name}」创作一条全新的短视频故事脚本。${personaBlock}${noPersonaNote}\n该视频尚未解析,请先解析这个抖音视频并做拆解分析(${r.url ? '视频地址:' + r.url + '\n' : ''}视频标题:《${r.title}》),再基于账号设定与提炼出的选题,创作完整故事脚本。`,
label: '创作指令',
btn: b,
});
@@ -563,7 +563,7 @@ async function renderRewrites(view) {
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}`,
: `请为达人「${r.name}」创作一条全新的短视频故事脚本。${personaBlock}${noPersonaNote}\n该视频尚未解析,请先解析这个抖音视频并做拆解分析(${r.url ? '视频地址:' + r.url + '\n' : ''}视频标题:《${r.title}》),再基于账号设定与提炼出的选题,创作完整故事脚本。`,
label: '创作指令',
btn: b,
});