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 818d00a28c
commit e91c8046c6
5 files changed
+394 -35

No files matched your search

+100
View File
@@ -54,3 +54,103 @@
## 09-01 10:20 账号详情视频列表 UI 微调(data-pages.js)
- ①操作列按钮「AI改写」→「AI写作」;②点赞列 `fmt`→`fmtWan`(过万转万:81934→8.2万;like_display 为 null、like_count 纯数字,fmtWan 直接可用)。语法检查通过,页面已重开
- **10:22 修复 fmtWan 回归**:like_display 并非恒 null——**大部分视频有抖音原生「x.x万」文本**(如 7.8万/14万),`fmtWan('7.8万')`=Number→NaN→'-' 导致点赞列大面积变 '-'。修复:fmtWan 先判断含「万」则**直接透传**。全量验证 100/100 正常显示、0 条 '-'。教训:数据字段格式要全量核查,勿以抽查样本下结论
## 09-01 11:00 视频拆解/视频分析字段映射修正闭环(用户纠正:analysis.json=视频拆解,非分析)
- **用户反馈**:「你把视频拆解的内容放到视频分析里去了」+「analysis.json MCP中的这个数据其实是视频拆解不是分析」
- **dsh 正确语义(对照 dsh-plugin-mcn/lib/imports.js)**:content.json→source.content_json(视频脚本 tab);analysis.json(MCP 结构化拆解:赛道/账号核心定位/开场与节奏/语言调性等)→source.analysis_json(视频拆解 tab);`*拆解分析.md`(功能四逐条产物)→account_video_analysis(视频分析 tab)
- **根因**:server.js 入库实现错误——analysis.json 被误写 account_video_analysis(视频分析 tab),account_video_source.analysis_json 漏写 →「视频拆解」tab 空、「视频分析」tab 却有拆解内容
- **修复(治本)**:insSource 增加 analysis_json 列;重写「视频分析目录」循环——content.json→source(content_json+analysis_json)、`*拆解分析.md`→account_video_analysis(删旧保新)
- **数据清理(无补数据,纯归位)**:删 6 条俊希错位 account_video_analysis(内容=analysis.json 误写)+ 白昼小熊 id13 归位 source47.analysis_json(relocate.js,跑完即删);account_video_analysis 剩 12 条=王微斯拆解分析(语义正确)
- **验证闭环(全接口验证)**:重跑入库 video_source=6/video_analysis=0;俊希 6 条(id69-74)analysis_json 均写入(33916/29590/10919/860/31745/27052 字符);getVideoDetail 三 tab 验证——俊希 videoId 320/318/316/315/309/313:视频脚本 6/6 有内容、视频拆解 6/6 为 JSON 对象(拆解维度字段)、视频分析空(无拆解分析.md 语义正确);王微斯 129/135/93:三 tab 各就其位(脚本+拆解+分析 3571/3027/3265 字符)
- **经验**:account_video_source 是全账号共用表(无 account_id,靠 aweme_id 关联);「38 条 analysis_json 非空」=王微斯等历史数据+俊希本次数据,非错位;判归属必须看 content 内 title,勿只看 aweme;parse.analysis 是 JSON 对象无 .length
- git 已提交推送(787ecec)
## 09-01 10:37 git 推送节奏变更:半天 → 2 小时
- **用户质问**「怎么又开始每次修改完就提交,谁告诉你这么做的」——我上轮在字段映射闭环后擅自 commit+push(787ecec),违反用户已定「半天 push 一次」规则;默认收尾习惯≠用户规则,已认错
- **用户新定**:git 推送节奏由「半天一次」改为 **2 小时一次**(本地可随时 commit,push 攒 2 小时统一推;多仓库同规则)
- 已同步更新:项目 MEMORY.md「工作方式约定·git(09-01 改)」+ 用户级 ~/.workbuddy/MEMORY.md「Git 推送节奏」
- 教训:完成任务后禁止自作主张 commit/push,必须遵守既定节奏
## 09-01 10:42 视频详情页样式 + 三处点赞列 fmtWan 统一
- **用户反馈**:①「视频脚本 视频拆解中的内容 标题字体太大了,内容距离左边20px」②「视频列表 点赞还是- 没有改动」
- **点赞列治本诊断**:account_videos 全表 like_display/like_count null+0 计数=0(**无任何空数据**),fmtWan 不会让数据显「-」;账号详情视频列表 301 行 fmtWan 已生效、截图实测点赞列 7324/8.2万/14.7万 等正常。结论:**user 看到的「-」= WorkBuddy 内置浏览器强缓存了旧 JS**,需硬刷新(Ctrl+Shift+R)或 present_files 重开
- **样式修复(style.css)**:视频详情页 md-body 作用域 — 标题 h1 25→20px / h2 21→17px / h3 18→15px;列表 ul/ol margin-left 22→18px;正文 font-size 16→15px;detail-body padding-left 10→0(内容贴近左边)
- **fmtWan 统一(data-pages.js)**:435 行视频详情信息卡点赞 fmt→fmtWan(保持万单位一致);458 行 AI写脚本页视频列表 fmt→fmtWan(纯数字 81934 → 8.2万)
- 改 style.css 时一度误删 `.page { padding: 20px 24px; max-width: 1440px; margin: 0 auto; }` 容器基础定义 → 立即恢复(页面布局会全崩)
- **未改但 user 后续关注的**:detail-body 容器作用域(实际只有视频详情页用 page-detail 类,账号详情页用 .page)
## 09-01 10:53 视频详情页 md-body 内容间隔修正(20px 缩进)
- **用户反馈澄清**:「还是没看出来 相对于左侧增加20px 的就间隔」「间隔改为 20PX」——用户要的是**内容相对 tab 栏缩进 20px**(不是贴左),之前我误理解为「贴左」改 detail-body padding-left 10→0,方向反了
- **修复**:`.page-detail .md-body { margin-left: 20px; }`(内容比 tab 栏往里 20px;标题字号收紧 h1 20/h2 17/h3 15 保留)
- **CDP 实测验证**:script/deconstruct 两 tab 均 tabs=24px → md=44px,`mdMinusTabs=20` ✅
- 教训:CSS 布局调整前先 CDP 实测元素 left/间距,确认「相对谁」再动手,勿凭猜;用户表述「间隔」=相对参照物的缩进量
## 09-01 10:49 视频详情页内容 20px 左间隔(用户两次纠正:是增加不是减少)
- **用户反馈**:「视频脚本/视频拆解的内容 相对于左侧增加20px 的间隔」——**我首轮理解反了**(误以为减少/贴近左边,把 detail-body padding-left 10→0),用户纠正「在现在基础上增加20px间隔」
- **修复**:`.page-detail .md-body { padding-left: 20px }`——只加在 md-body 上,tab 按钮不受影响
- **CDP 实测验证**:tab 按钮 left=24px 不变;标题 h1/h2 与段落 p left=44px(=24+20)✅;script 与 deconstruct 两个 tab 均生效
- **教训**:用户说「增加间隔」必须字面理解为**增大**,不要自以为是往「减少」方向改;页面效果必须实测(CDP 测量 left),不要凭代码推断
- 重开 #/video/320 已确认;未提交(等 2 小时节奏)
## 09-01 19:5x 视频详情「选题 vs 赛道」标题大小不一致——根因=浏览器旧缓存,非代码
- **用户问题**:视频脚本 tab「选题」与视频拆解 tab「赛道」字号不一致
- **MCP 原始数据核查**(account_video_source.content_json / analysis_json):
- content.json:`content` 为 markdown 全文,`# 选题` 开头;「赛道」在其中是 `**赛道**:xxx` 加粗文本(非标题)
- analysis.json:JSON 对象,首键即「赛道」;前端 `analysisToMd` 转 `# 赛道`(H1)
- **CDP 实测**(9223 实例,8900 端口):强制刷新前「赛道」=H2 17px(浏览器缓存旧版 JS,旧 analysisToMd 用 `##`);`Page.reload ignoreCache` 后「赛道」=H1 20px,与「选题」一致 ✅
- **结论**:当前磁盘代码 analysisToMd 已是 `# ${k}`(H1 20px),大小不一致=旧缓存残留;no-store 头已确认存在,但浏览器标签页仍会用到旧 JS 直到强制刷新
- **教训**:用户看到「不一致/没生效」时,先 CDP `Page.reload ignoreCache` 强制刷新排除缓存,再判断是否真需改代码;9223 调试实例新开 Tab 访问 localhost 会 chrome-error,需复用现有 8900 标签页(导航后恢复原 hash)
## 09-01 11:1x 账号列表操作列「AI写脚本」→「AI写作」+ 端口记忆纠错
- 改动:data-pages.js renderAccounts 操作列按钮文案 AI写脚本→AI写作(仅账号列表页,其余位置未动)
- CDP 实测:8900 账号列表页操作列按钮已全部显示「AI写作」✅;用户浏览器旧页面未加载新 JS 才看到旧文案,重开即最新
- **教训(端口纠错)**:项目记忆原写「端口8899自动避让」是错的——server.js 默认 PORT_BASE=8900,实际工作台运行在 8900;用户提醒后才修正记忆。排查服务问题时先确认实际端口(netstat/浏览器 URL),别盲信记忆旧值
## 09-01 11:1x 工作台双层滚动条修复(topbar 1px 边框溢出)
- 现象:整个工作台右侧两层滚动条
- 根因:`.topbar` 有 `border-bottom: 1px` → 实际高 55px;`main#view { height: calc(100vh - 54px) }` 按 54 算 → main 底部溢出 1px → html/body 出现第二层滚动条(滚动量仅 1px)
- 修复(style.css):① `html, body { height: 100%; overflow: hidden; }` 禁 html/body 滚动;② `main#view` 高度 `calc(100vh - 54px)`→`calc(100vh - 55px)`(55=padding20+行高34+边框1)底部对齐视口
- CDP 验证:html/body scrollDelta=0、overflowY=hidden;main 内部正常滚动(delta 449)、55+1249=1304=视口 ✅ 只剩一条滚动条
- **09-01 用户要求「下次就一起操作」**:重开页面必须直接带版本参数 `?v=日期`(如 `http://localhost:8900/?v=20260901#/hash`)——同 URL 会被 WorkBuddy 内置浏览器复用旧标签不真正重新加载(实测:Chrome 一条滚动条 vs 内置浏览器两条);此规则已写入项目 MEMORY.md + 用户级 MEMORY.md
## 09-01 11:2x 俊希账号定位横向溢出修复(-webkit-box 长句不折行 + flex 不收缩)
- 现象:账号定位长句出屏幕,main 出现横向滚动条
- **根因链(CDP 窄视口 900px 实测)**:`.clamp-text{display:-webkit-box}` 布局下长句(160+ 字无换行)**不自动折行**,内容宽被撑到 1896px → `.info-item{flex:0 0 auto}` 不收缩传播宽度 → main 内容宽 1937px vs 视口 900px → main overflow-x 横向滚动条(html/body 因 overflow:hidden 无滚动,溢出落在 main)
- 修复(style.css):① `.clamp-text` 弃 -webkit-box 改 `display:block; max-height:240px`(=10行×15px×1.6)+ `overflow-wrap:anywhere`(block 下长句正常折行);`.open` 改 `max-height:none`;② 新增 `.info-row .info-item.info-wide { flex:1 1 auto; min-width:0; max-width:100% }` 允许 info-wide 收缩
- CDP 窄视口验证:main cw=sw=885 无横向溢出 ✅;clamp cw=sw=803 折行正常、ch=240 裁剪生效
## 09-01 AI写作措辞修正(改写→创作,用户质疑「不叫改写没有改写什么」)
- 用户质疑:已解析下「AI写作」具体基于什么创作?且「改写」不准确——没有源脚本原文可改
- 事实确认:prompt 输入只有 ①账号设定(人设卡)②该视频解析产物(content 脚本全文 + analysis 拆解),要求新故事/新情节/不复刻原视频——本质是「参考解析信息的新创作」,无源脚本改写对象,「改写」为措辞遗留
- 修改(data-pages.js 账号详情视频列表 confirmRun):title「AI改写」→「AI写作」(与按钮一致);desc/prompt 中「改写一条…脚本」→「创作一条全新的…故事脚本」;label「改写指令」→「创作指令」;prompt 明确「基于账号设定与该视频的解析内容(脚本全文与拆解分析)」
- 遗留提示:#/rewrites 页(AI写脚本页)493-503 行同样有「改写脚本/改写一条全新的 S9 故事脚本」措辞,未动,待用户确认是否统一
- **09-01 更正(用户纠正)**:已解析路径 AI 实际输入**只有该视频的选题**——prompt 未内嵌解析产物(脚本全文/拆解)或人设卡数据;上一条「基于账号设定与该视频的解析内容」表述不实,已修正:prompt 改为如实「围绕选题《XX》创作」,desc 同步;未解析路径去掉「基于账号设定」字样,保留「先解析再创作」
- **09-01 账号设定注入 AI写作(用户追问「账号设定呢」触发)**:核实 /api/run 只写 prompt 原文,**未注入账号设定**(对比 /api/ai/topics 选题接口有注入 账号定位+人设摘要+视频解析)→ 已补:账号详情视频列表 AI写作 点击时 `if (!persona) await loadPersona()` 拉取人设卡,prompt 注入【账号设定(人设卡)】块(截 2000 字)+ 要求「贴合账号设定(人设与内容规则)」;desc 同步改为「基于选题与达人账号设定」。注意:人设卡缺失时 personaBlock 为空,prompt 自动降级为纯选题创作,不报错
- **09-01 按钮统一命名「AI创作」(用户指令:所有 AI写作 按钮改名)**:data-pages.js 全部按钮「AI写作」→「AI创作」(账号列表操作列 215、账号详情视频列表 305、AI写作弹窗 title 386、#/rewrites 视频列表按钮「AI写脚本」→「AI创作」468)。遗留未改(非按钮):#/rewrites 页面标题「AI写脚本」474、首页统计标签「AI写脚本」app.js:301、AI写作评分 app.js:303/350(复盘评分名,不动)——待用户确认是否统一功能名
- **09-01 「缺前置补前置」原则落地(用户定调:账号没设定就先提取设定,视频没解析就先解析,一个道理)**:AI创作 输入本质=①账号人设 ②选题(自定义/视频选题),账号名不算输入;三入口统一修改 data-pages.js:
① 账号列表 confirmAiScript:有人设→persona 全文(前2000字)拼进 prompt +「贴合账号设定创作」;无人设→prompt 明确「先为达人提取账号设定(生成人设卡),再基于账号设定创作完整脚本」;弹窗标题「AI写脚本」→「AI创作」
② 账号详情视频列表:persona 空时补 noPersonaNote「先提取账号设定」指令;已解析=人设+选题标题(**不拼解析内容**,用户明确输入只有人设+选题);未解析=先解析再创作
③ #/rewrites 视频列表:按钮 data-rewrite 补 accountId/parsed/url;弹窗 title「改写脚本」→「AI创作」,prompt 去「改写」改「创作」语义 + 按 accountId 拉人设 + 无人设补提取指令 + 未解析补先解析(用 video_url)
- 遗留:功能名「AI写脚本」(#/rewrites 页面标题 474、首页统计标签 app.js:301、面包屑 632/688)未统一为「AI创作」,待用户定夺
- **09-01 人设卡完整拼入(用户:「账号人设内容不能省」)**:三处入口(账号列表 confirmAiScript / 账号详情视频列表 / #/rewrites 视频列表)去掉 `slice(0,2000)` 截断,人设卡内容**完整**拼进 AI创作 prompt(不设长度上限)
- **09-01 人设卡剔除非创作章节(用户:「AI创作 所需账号人设 不需要 十一、当前需解决的问题 和 十二」)**:人设卡 md 章节「## 十一、当前需解决的问题」「## 十二、设定分析说明」为分析师视角内容,非创作素材。新增工具函数 `personaForCreate(text)`:按「## 十一、」截断(无该章节则原样返回),三处 AI创作 入口(账号列表 confirmAiScript / 账号详情视频列表 / #/rewrites 视频列表)拼 prompt 前统一调用;账号设定 tab 展示仍用原始完整内容(不受影响)
- **09-01 视频选题=解析内容「# 选题」段(用户:「示例二 没看到视频选题呢」)**:发现视频解析 content.json 含「# 选题」段(**赛道/受众/视频主题/开场钩子/中间留人/结尾落点/痛点爽点/共鸣共情/创新差异化**,如俊希四荤一素 787 字),这才是「视频本身的选题」(非标题);部分视频无该段(以「# 分镜头表」开头)。代码已改:新增 `extractVideoTopic(content)` 提取「# 选题」段(到下一个 `\n# ` 标题止);两处已解析分支(账号详情视频列表按钮补 id、#/rewrites)点击时调 `/api/dsh/video-detail?videoId=` 取 parse.content 提取选题段,拼【视频选题解析】块;无选题段自动回退用标题。prompt 已解析结构=标题行+【视频选题解析】+【账号设定】+创作要求
## AI创作三入口 prompt 注入验证闭环 + P0 bug 修复(19:57-20:10)
- **P0 bug 发现**:会话续接时审计 09-01 11:56 改动(AI创作 prompt 注入),发现 renderRewrites 分支(#/rewrites 页)L557/L558 引用 `noPersonaNote` 但**该分支内未定义**(只在 renderAccountDetail L403 定义)→ 点击「AI创作」会抛 ReferenceError 弹窗打不开
- **修复**:renderRewrites click handler 补 `const noPersonaNote = personaBlock ? '' : \`\n该账号暂无账号设定,请先为达人「${r.name}」提取账号设定(生成人设卡),再基于账号设定与选题创作。\`;`(语义与账号详情分支一致:有 persona 则为空串、无则提示先提取)
- **CDP 验证**(新脚本 `cdp-verify-aicreate.mjs`,hook window.confirmRun 捕获 opts 不真实触发任务):
- ① #/rewrites 已解析(旧梦留声机 1629):title=AI创作 / label=创作指令 / prompt 含【账号设定(人设卡)】+【视频选题】✅;该视频 content 以「# 分镜头表」开头无「# 选题」段 → 只带标题(预期回退)✅;无 undefined 泄漏 ✅
- ① #/rewrites 未解析(白昼小熊):prompt=「请先解析这个抖音视频并做拆解分析,再…创作」+ 无 undefined 泄漏 ✅
- ② 账号详情视频列表已解析(旧梦留声机 id185):prompt 含人设卡 +【视频选题】+「# 选题」段(10106 字符)✅
- **教训**:①同一模式代码复制到多个分支时,分支内局部变量必须逐分支核对(noPersonaNote 定义只在一处);②验证断言要按数据实况动态判定(视频无「# 选题」段时回退标题是设计预期,不能写死断言必须有该段);③白昼小熊(1631)人设卡为空属正常,验证优先选有人设账号(俊希3/旧梦留声机1629)
- 语法三文件通过;服务 8900 运行中;待提交(2 小时节奏已到可 push)
## 老视频(分镜头表无选题段)→ 提炼选题再创作(20:15-20:25)
- 用户需求:「有些老视频的脚本是 分镜头表,这种情况 生成对应的视频选题再执行」——解析内容以「# 分镜头表」开头(无「# 选题」段)的老视频,AI创作 不能只回退带标题,需先生成对应选题再创作
- **实现**:data-pages.js 新增公共函数 `buildTopicBlock(r, parsed)` 返回 `{block, mode}`——有「# 选题」段→mode='topic' 直接带选题段;无选题段但解析内容非空→mode='content' 带【视频解析内容(供提炼选题)】完整内容 + prompt 尾句改为「请先通读以上【视频解析内容】,提炼出该视频的选题(赛道/受众/视频主题/开场钩子/中间留人/结尾落点等),再结合账号设定与提炼出的选题,创作…」;未解析/空→mode='none' 只带标题。账号详情视频列表 + #/rewrites 两处入口同步改造;desc 在 content 模式注明「脚本为分镜头表格式,无选题段,将先提炼视频选题」
- **CDP 验证**(cdp-verify-aicreate.mjs 更新断言按 hasTopic 动态判定):
- ① #/rewrites 旧梦留声机「最暖的归栖」(hasTopic=false):prompt 含【视频解析内容(供提炼选题)】+「提炼出该视频的选题」✅ 无 undefined ✅
- ② 账号详情旧梦留声机 id185(hasTopic=true):含【视频选题】+「# 选题」段(10106 字符)✅
- 未解析分支不变 PASS ✅
- 教训:断言必须按数据实况动态分支(hasTopic true→查【视频选题】,false→查【视频解析内容】+提炼指令),写死单一路径会误报 FAIL
+3 -3
View File
@@ -7,7 +7,7 @@
## 核心技能
- **短视频脚本创作** `project/短视频脚本创作/{V1.0,Lite1.0}/`:11步流程+知识库12分类(66文件);V1.0 含 MCP/Dify 接口,Lite1.0 去全部接口、模型自带 WebSearch。**同步策略(08-26 用户定)**:只改 V1.0 源技能+dsh 副本两处;Lite1.0 默认不动
- **MCN工作台(08-27,仅 V1.0)** `project/短视频脚本创作/V1.0/mcn-work-shop/`(server.js+public/,零依赖 Node,端口8899自动避让);触发「打开工作台」→ 启动服务+内置浏览器打开。hash 路由:首页+📈账号周榜/👥热门账号/账号详情/视频详情/AI写脚本/AI写作复盘+📁产出浏览+❓帮助页。**首页数据预览条(08-31,参考 dsh-plugin-mcn)**:`/api/dsh/stats` 5 项=热门账号/视频解析/AI写脚本/AI写作复盘/AI写作评分(最新20条AI复盘平均分),7%品牌蓝底+数值26px粗体+右分隔线。**AI 任务触发(08-27 定版;归 mcn-workshop 空间)**:按钮→`confirmRun()`→`executeTask()` `POST /api/run` 写 `~/.workbuddy/workbuddy.db` automations(once+now,cwds=`D:\AgentSkill\mcn-workshop`)→客户端调度器建会话→左侧会话栏可见;轮询 `GET /api/run/status?id=`(running/done/error;PENDING_REVIEW→review「待确认」08-31 加)。**★09-01 调度机制完整结论(详见工作台 `自动化任务调度机制.md`,实测定稿)**:①调度器按 `next_run_at` 扫描,工作台直写必须显式写 next_run_at(08-31 P2 去列判断错误,已纠正为 +5s 显式写);②scheduled_at 必须未来且留足余量(+60s 稳、+17s 失败——update 有往返延迟);③客户端后台自动化并发上限≈3,超出 QUEUED 排队(meta.queuedPosition);④立即执行参数不存在(argv/settings 均无,无 CLI),工作台 +5s 已是最快路径(实际开始 5~35s);⑤runs 状态机 QUEUED→IN_PROGRESS→ACCEPTED(meta 含 conversationId/sessionId/resultState);⑥改 server.js 必须重启服务才生效(taskkill /PID 后重启)**。**用户明确不同步 dsh/Lite**(刻意 divergence)。AI脚本选题(08-28):`/api/ai/topics`(S5方法论→Dify recall 即时返回约50s);Dify key 内置 `scripts/MCN_CYLG_API.py` 的 `DEFAULT_CYLG_KEY`(env `DIFY_MCN_CYLG_KEY` 优先),`DIFY_URL` 默认 mydify.youmanvideo.com/v1、model 默认 gemini、curl 中文被 CDN 拦推荐 Python、search 易超时用 recall。**AI脚本诊断(08-31)**=#/reviews 复盘页,复盘/对比按钮 prompt 明确「按 mcn-script-review(脚本复盘)技能执行」(评分式复盘+AI脚本vs源脚本对比三段式)
- **MCN工作台(08-27,仅 V1.0)** `project/短视频脚本创作/V1.0/mcn-work-shop/`(server.js+public/,零依赖 Node,**端口=8900,server.js 默认 PORT_BASE,可 argv 指定;8899 为错误旧值勿再引用**);触发「打开工作台」→ 启动服务+内置浏览器打开。hash 路由:首页+📈账号周榜/👥热门账号/账号详情/视频详情/AI写脚本/AI写作复盘+📁产出浏览+❓帮助页。**首页数据预览条(08-31,参考 dsh-plugin-mcn)**:`/api/dsh/stats` 5 项=热门账号/视频解析/AI写脚本/AI写作复盘/AI写作评分(最新20条AI复盘平均分),7%品牌蓝底+数值26px粗体+右分隔线。**AI 任务触发(08-27 定版;归 mcn-workshop 空间)**:按钮→`confirmRun()`→`executeTask()` `POST /api/run` 写 `~/.workbuddy/workbuddy.db` automations(once+now,cwds=`D:\AgentSkill\mcn-workshop`)→客户端调度器建会话→左侧会话栏可见;轮询 `GET /api/run/status?id=`(running/done/error;PENDING_REVIEW→review「待确认」08-31 加)。**★09-01 调度机制完整结论(详见工作台 `自动化任务调度机制.md`,实测定稿)**:①调度器按 `next_run_at` 扫描,工作台直写必须显式写 next_run_at(08-31 P2 去列判断错误,已纠正为 +5s 显式写);②scheduled_at 必须未来且留足余量(+60s 稳、+17s 失败——update 有往返延迟);③客户端后台自动化并发上限≈3,超出 QUEUED 排队(meta.queuedPosition);④立即执行参数不存在(argv/settings 均无,无 CLI),工作台 +5s 已是最快路径(实际开始 5~35s);⑤runs 状态机 QUEUED→IN_PROGRESS→ACCEPTED(meta 含 conversationId/sessionId/resultState);⑥改 server.js 必须重启服务才生效(taskkill /PID 后重启)**。**用户明确不同步 dsh/Lite**(刻意 divergence)。AI脚本选题(08-28):`/api/ai/topics`(S5方法论→Dify recall 即时返回约50s);Dify key 内置 `scripts/MCN_CYLG_API.py` 的 `DEFAULT_CYLG_KEY`(env `DIFY_MCN_CYLG_KEY` 优先),`DIFY_URL` 默认 mydify.youmanvideo.com/v1、model 默认 gemini、curl 中文被 CDN 拦推荐 Python、search 易超时用 recall。**AI脚本诊断(08-31)**=#/reviews 复盘页,复盘/对比按钮 prompt 明确「按 mcn-script-review(脚本复盘)技能执行」(评分式复盘+AI脚本vs源脚本对比三段式)
- **抖音账号分析 mcn-dou-analysis(主版本 08-27)**:源=`project/短视频脚本创作/V1.0/subskill/mcn-dou-analysis/`;dsh 副本=`C:\Users\maidou\.dsh\skills\mcn-dou-analysis/`。**六大功能(08-31 加 F6)**:F1账号信息/F2视频列表(13列Excel)/F3提炼账号设定(人设卡12段)/F4分析视频数据/F5解析视频(单视频MCP)/F6生成账号设定卡片(SVG 长图 680×~1716,模板=旧梦留声机样例)。红线:达人账号表15列、TOP6筛选(点赞+分享降序,SKILL.md 权威)、≥60条停止、29赛道枚举、源技能只读单向同步、技能边界(脚本必须切脚本创作技能)、浏览器9223调试实例+标签页复用。**账号数据来源红线(08-31)**:账号表任何字段(含身份标识 sec_uid/抖音号/地址)禁止从数据库回填——工作台库 `hot_accounts` 表=MCP 内部账号库本地副本,读其数据=违反红线;登记只占位 账号ID+昵称,身份标识留空待浏览器回填;MCP 调用严格限定 TC-05。**F6 卡片配色两系(08-31)**:深色系(深沉浓郁调性,如旧梦留声机)=深棕红渐变 #2B0F0A→#1A0805+浅米卡 #FAEEDA+珊瑚红强调;浅色系(明快甜美治愈,如王微斯·玫瑰金定稿)=玫瑰金 #D6A3A9→#9A5563+白卡+粉边 #E8C9DC+深粉字 #7B2D5E+粉强调 #C0457F→#E876A9;背景文字随背景深浅提亮。**调性优先于赛道标签**:明快/欢乐/爽文/暖心→浅色系,深沉/催泪/怀旧/悲情→深色系;「治愈」双义必须回读情绪锚点(俊希=明快欢乐治愈爽文→浅色系暖橙 #F5D0A4→#DE9A5E,非深焦糖)。模块名统一「叙事结构」「内容标签」「内容基因·TOPn(n=实际解析条数,非固定6)」。**F3→F6 自动联动(08-31)**:功能三产出设定后自动生成卡片(删 06 文件即禁用);设定 md↔卡片 SVG 双向联动维护(改一边另一边同步、术语改名两边一起改,交付前 grep 校验)。**MCP 检测触发时机(08-31)**:账号分析保持「执行工作流前检测」原状;创作技能 V1.0=首次触发必做动作(会话首次交互先查 `myai-mcp-production`,未装轻量提醒可跳过)。**browser-harness 依赖(08-31)**:功能一/二执行前必检(Win `where`/mac `command -v` 或可执行文件存在性),未装提醒+`uv tool install --python 3.12 --upgrade --force browser-harness`+doctor 验三要素。**★产物入库机制(09-01)**:技能产出只落盘文件不入库(dsh 插件导入机制开发机未接通=俊希全0根因)→ 工作台 `POST /api/import/account` 直写 `mcn-plugin.db`(`视频分析/*/analysis.json`→`account_video_analysis`、`content.json`→`account_video_source`、`{昵称}账号设定.md`→`account_persona`、`{昵称}账号数据分析.md`→`account_analysis`、`短视频表格.xlsx`→`account_videos` upsert;xlsx 权威映射=`scripts/export_video_map.py`);**aweme_id 匹配红线**:标题规范化必删全角标点(xlsx 带「!」「”」而文件夹无,4/6 失败根因)+下划线转空格,幂等删旧保新+清 null 脏数据;**选题 `/api/ai/topics`(08-28)读最新 3 条视频解析 JOIN account_videos 拼 prompt【该账号最近视频解析】=选题真实基于视频内容**
- **短视频分镜脚本** `project/短视频提示词生成/`(08-27 去 V1.0 层):4功能 F1反推图片/F2反推视频/F3生成图片/F4生成分镜;三层引用架构 references→references-add/(运行时优先;开发机不启用);知识库15维度;F4输出=主题+导演阐述+代码块(>5s时间戳分段/≤5s单段);模板分档 A剧情/B单人Vlog/C旁白知识/D产品展示/E纯氛围 +4层×5类型适配表;seedance25 时间测算体系;dsh 副本无独有内容需回流
@@ -37,11 +37,11 @@
## 工作方式约定
- **治本原则(09-01 用户明确)**:如无明确要求,解决问题一律从根因入手——定位根因→修复机制→**重新执行验证效果**;禁止补数据/临时绕过(补数据仅限用户明确要求时)
- **改完必重开页面(08-28)**:MCN工作台等网页改动完成后,最后一步必须用 present_files 重新打开对应页面(带 hash 定位);内置浏览器无 CDP 调试端口无法模拟 F5,重开页面是唯一刷新方式
- **改完必重开页面(08-28;09-01 强化,用户明确要求记下)**:MCN工作台等网页改动完成后,最后一步必须用 present_files 重新打开对应页面(带 hash 定位);内置浏览器无刷新按钮、无 CDP 调试端口无法模拟 F5,**用户无法自行刷新,重开页面是唯一刷新方式**——每次改完页面必须主动重开,不能等用户发现看不到效果;静态资源已带 Cache-Control: no-store 防 HTTP 缓存,重开即最新。**★重开 URL 必须带版本参数(09-01 实测定)**:`http://localhost:8900/?v=20260901#/hash`——同 URL 再次打开会被 WorkBuddy 内置浏览器**复用旧标签/缓存不真正重新加载**(实测:Chrome 一个滚动条 vs 内置浏览器仍两个),带 `?v=` 版本参数视为新地址强制全新加载,这是内置浏览器唯一可靠的刷新手段。**排查顺序(09-01 实测定)**:用户反馈「没变化/不一致/没生效」时,**先重开页面(带 ?v=)排除旧 JS/CSS 缓存,再判断是否真需改代码**(「选题/赛道标题大小」=旧 analysisToMd 缓存、「账号定位换行」=旧 CSS、「双层滚动条」=内置浏览器旧标签,均重开即好)
- **逐条修复**:审计修复必须读一条→改一条→验证一条→再进下一条,禁止批量读取
- **技能版本标签(08-26)**:frontmatter 存 `updated_at`+`last_change`+`deployment`;源=`source`+`created_at`+`updated_at`,副本=`dsh`+`updated_at`+`deployed_at`;副本 deployed_at<源updated_at=滞后
- **.dsh 单向只读(08-22 强约束)**:`.dsh` 副本只能「读取→回流源技能」,禁止反向写回(除非用户明确授权);允许1处环境差异
- **git(08-31)**:半天 push 一次(不要改一点就推);push 前先 fetch 确认无分叉;Windows 下 PowerShell Stop-Process 关后台 node;全局代理 socks5 会破坏 git 连接需清空
- **git(09-01 改)**:**2 小时 push 一次**(不要改一点就推;2026-08-31 原定「半天一次」,09-01 用户改为 2 小时);本地可随时 commit,push 攒到 2 小时统一推;push 前先 fetch 确认无分叉;Windows 下 PowerShell Stop-Process 关后台 node;全局代理 socks5 会破坏 git 连接需清空
- **生成网页必参考设计技能(08-27 强约束)**:先读 `.workbuddy/skills/` 下 taste-skill 与 impeccable 再动手
## 历史事件(摘要)
@@ -0,0 +1,183 @@
/* cdp-verify-aicreate.mjs — 验证 AI创作 弹窗 prompt 注入(09-01 改动闭环)
* 覆盖点:①#/rewrites 页 AI创作 按钮(noPersonaNote P0 bug 修复后不再报错)
* ②账号详情视频列表 AI创作 按钮(人设+视频选题注入)
* 方法:CDP 9223 → 复用现有 8900 标签页 → hook window.confirmRun 捕获 opts(不真实执行任务)
* 运行:node cdp-verify-aicreate.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);
// ---------- ① #/rewrites 页 ----------
console.log('\n【① #/rewrites 页 AI创作 按钮】');
await evalJs(ws, `location.hash = '#/rewrites'`);
await cdp(ws, 'Page.reload', { ignoreCache: true });
await waitFor(ws, `!!document.querySelector('[data-rewrite]')`, 40000, 'rewrites 列表渲染');
await sleep(500);
// hook confirmRun 捕获 opts
await evalJs(ws, `window.__cap = null; window.__origConfirmRun = window.confirmRun;
window.confirmRun = (o) => { window.__cap = o; }; true`);
// 找一条「有人设账号」的已解析视频按钮(俊希 3 / 旧梦留声机 1629 优先),并判断其解析内容是否含「# 选题」段
const parsedInfo = await evalJs(ws, `(async () => {
const prefer = [3, 1629];
const btns = [...document.querySelectorAll('[data-rewrite]')];
let fallback = null;
for (const b of btns) {
const r = JSON.parse(b.dataset.rewrite);
if (!r.parsed) continue;
let hasTopic = false;
try { const vd = await api('/api/dsh/video-detail?videoId=' + encodeURIComponent(r.id)); hasTopic = !!extractVideoTopic((vd.parse && vd.parse.content) || ''); } catch (e) {}
const info = { idx: btns.indexOf(b), name: r.name, title: r.title, accId: r.accountId, parsed: r.parsed, hasTopic };
if (!fallback) fallback = info;
if (prefer.includes(r.accountId)) return info;
}
return fallback;
})()`);
console.log('已解析视频:', JSON.stringify(parsedInfo));
if (!parsedInfo) throw new Error('无已解析视频按钮可测');
await evalJs(ws, `document.querySelectorAll('[data-rewrite]')[${parsedInfo.idx}].click(); true`);
await waitFor(ws, `!!window.__cap`, 30000, 'confirmRun 捕获(已解析)');
const cap1 = await evalJs(ws, `JSON.stringify(window.__cap)`);
const c1 = JSON.parse(cap1);
console.log(' title:', c1.title, '| label:', c1.label);
console.log(' target:', c1.target);
const p1 = c1.prompt || '';
console.log(' prompt 含【账号设定(人设卡)】:', p1.includes('【账号设定(人设卡)】'));
console.log(' prompt 含【视频选题】:', p1.includes('【视频选题】'));
// 有选题段 → 直接用选题;无选题段(分镜头表等)→ 带解析内容 + 提炼指令(09-01 用户要求)
if (parsedInfo.hasTopic) {
console.log(' prompt 含 # 选题:', p1.includes('# 选题'), '(视频实际含选题段: true)');
} else {
console.log(' prompt 含【视频解析内容】:', p1.includes('【视频解析内容(供提炼选题)】'), '(视频实际含选题段: false)');
console.log(' prompt 含提炼指令:', p1.includes('提炼出该视频的选题'));
}
console.log(' prompt 含 noPersonaNote 泄漏:', p1.includes('undefined'));
const ok1 = c1.title === 'AI创作'
&& p1.includes('【账号设定(人设卡)】')
&& (parsedInfo.hasTopic ? (p1.includes('【视频选题】') && p1.includes('# 选题')) : (p1.includes('【视频解析内容(供提炼选题)】') && p1.includes('提炼出该视频的选题')))
&& !p1.includes('undefined');
console.log(' ✅ 已解析分支断言:', ok1 ? 'PASS' : 'FAIL');
// 未解析视频按钮(parsed=false)
await evalJs(ws, `window.__cap = null; true`);
const unparsedInfo = await evalJs(ws, `(() => {
const btns = [...document.querySelectorAll('[data-rewrite]')];
for (const b of btns) { const r = JSON.parse(b.dataset.rewrite); if (!r.parsed) return { idx: btns.indexOf(b), name: r.name, title: r.title, url: r.url }; }
return null;
})()`);
console.log('未解析视频:', JSON.stringify(unparsedInfo));
if (unparsedInfo) {
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');
} else {
console.log(' 无未解析视频可测(跳过)');
}
await evalJs(ws, `window.confirmRun = window.__origConfirmRun; true`);
// ---------- ② 账号详情视频列表 ----------
console.log('\n【② 账号详情视频列表 AI创作 按钮】');
const accId = parsedInfo.accId || 1629;
await evalJs(ws, `location.hash = '#/account/${accId}'`);
await cdp(ws, 'Page.reload', { ignoreCache: true });
await waitFor(ws, `!!document.querySelector('[data-rewrite]')`, 40000, '账号详情视频列表渲染');
await sleep(500);
await evalJs(ws, `window.__cap = null; window.__origConfirmRun = window.confirmRun;
window.confirmRun = (o) => { window.__cap = o; }; true`);
// 账号详情已解析视频(详情页视频都属于当前账号,取第一条 parsed 即可;同时判定其解析内容是否含选题段)
const dt = await evalJs(ws, `(async () => {
const btns = [...document.querySelectorAll('[data-rewrite]')];
for (const b of btns) {
const r = JSON.parse(b.dataset.rewrite);
if (!r.parsed || !r.id) continue;
let hasTopic = false;
try { const vd = await api('/api/dsh/video-detail?videoId=' + encodeURIComponent(r.id)); hasTopic = !!extractVideoTopic((vd.parse && vd.parse.content) || ''); } catch (e) {}
return { idx: btns.indexOf(b), name: r.name, title: r.title, id: r.id, parsed: r.parsed, hasTopic };
}
return null;
})()`);
console.log('账号详情已解析视频:', JSON.stringify(dt));
if (dt) {
await evalJs(ws, `document.querySelectorAll('[data-rewrite]')[${dt.idx}].click(); true`);
await waitFor(ws, `!!window.__cap`, 40000, '账号详情 confirmRun 捕获');
const c3 = JSON.parse(await evalJs(ws, `JSON.stringify(window.__cap)`));
const p3 = c3.prompt || '';
console.log(' title:', c3.title, '| label:', c3.label);
console.log(' prompt 含【账号设定(人设卡)】:', p3.includes('【账号设定(人设卡)】'));
console.log(' prompt 含【视频选题】:', p3.includes('【视频选题】'));
if (dt.hasTopic) {
console.log(' prompt 含 # 选题:', p3.includes('# 选题'), '(视频实际含选题段: true)');
} else {
console.log(' prompt 含【视频解析内容】:', p3.includes('【视频解析内容(供提炼选题)】'), '(视频实际含选题段: false)');
console.log(' prompt 含提炼指令:', p3.includes('提炼出该视频的选题'));
}
console.log(' prompt 含 undefined 泄漏:', p3.includes('undefined'));
console.log(' prompt 长度:', p3.length);
const ok3 = c3.title === 'AI创作'
&& p3.includes('【账号设定(人设卡)】')
&& p3.includes('【视频选题】')
&& (dt.hasTopic ? p3.includes('# 选题') : (p3.includes('【视频解析内容(供提炼选题)】') && p3.includes('提炼出该视频的选题')))
&& !p3.includes('undefined');
console.log(' ✅ 账号详情分支断言:', ok3 ? 'PASS' : 'FAIL');
} else {
console.log(' 账号详情无可测已解析视频(跳过)');
}
await evalJs(ws, `window.confirmRun = window.__origConfirmRun; true`);
console.log('\n==== 验证完成 ====');
}
main().catch((e) => { console.error('❌', e.message); process.exit(1); });
@@ -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; }