AI创作弹窗对话打磨 v2:单弹窗需求框+输入框+AI全程引导

废弃旧分步向导+跨会话打磨+md载入路径,改为单弹窗 confirmAiScript 重写。

改动:
- public/data-pages.js: confirmAiScript v2(L199-326)= REQ_FIELDS 10 项常量 + 顶部10行需求框常显(空值灰显"待定·由AI按人设自决")+ 中部对话流(u/ai气泡)+ 底部输入行 + 取消/提交创作;无 step/chip/AI按钮/载入按钮/上下步
- public/style.css: 删 .wizard-modal/.wz-/.ai-topic 旧样式;新增 .req-modal/.req-sheet/.req-dialog/.req-msg.mine/.req-msg.ai/.req-bubble/.req-input-row/.req-input/.req-send 全套
- server.js: 新增 POST /api/ai/clarify(紧跟 /api/ai/topics 之后)= 同步阻塞 Dify chat-messages 100s AbortController;入参 {accountName,turn,reqMd,history};query 拼装账号人设+当前需求+最近2轮历史+本轮输入;强制输出"对话+==REQ==+10行需求"模板
- 记忆:MEMORY.md 核心技能段补"AI创作弹窗对话打磨 v2"长期记忆;2026-09-02.md 追加完整收尾日志

关键设计:每轮重发 reqMd + 最近 2 轮 history 做无状态上下文;AI 可更新任意字段但严禁生成脚本正文;reqLines 仅取非空字段 + "未填由AI按人设自决"提示。

冒烟 tmp/cdp-req-dialog-smoke.mjs(CDP 9224)4 步端到端 53s 全绿:结构✓/双轮真实Dify打磨✓/需求框0→6→8字段✓/提交捕获{label:创作指令,skills:[短视频脚本创作],hasPersona:true,reqRows:8,modalClosed:true}✓。
This commit is contained in:
maogeigei committed 2026-09-02 16:00:46 +08:00
1 parent 5b5bcf790f
commit 7984520728
6 files changed
+291 -57

No files matched your search

+8
View File
@@ -330,3 +330,11 @@
- 失误:验证视频详情页对齐时误用 agent-browser(system 技能列表里可见),未遵守项目铁律「浏览器操作一律 browser-harness」(user 级技能,Always use browser-harness for any web interaction)
- 检查:browser-harness 环境未安装——技能目录只有 SKILL.md,无 subskills/browser-harness/envs/ 环境(可执行文件 browser-harness.exe 不存在)
- 规避:涉及浏览器自动化/截图/交互时,先 where browser-harness 或查 C:/Users/maidou/.workbuddy/skills/browser-harness/subskills/browser-harness/envs/browser-harness/Scripts/browser-harness.exe 是否存在;未装则 uv tool install --python 3.12 --upgrade --force browser-harness + doctor 验三要素,不装 agent-browser
## AI创作 向导(C+A)落地后冒烟验证(16:23)
- 目标:账号列表 AI创作按钮 → 弹向导分 5 步收集创意/选题/设定/叙事框架;A=弹窗正向确认(chips+手动) C=打磨用 /api/run 建空技能会话产《创作需求单.md》→ 人工「载入需求单」一键回填再提交创作(人工点击确认并提交创作)
- 落地位置:data-pages.js confirmAiScript (L267-418) + 辅助 buildClarifyPrompt/parseReqSheet/loadReqSheet/reqLines/buildCreatePrompt (L199-263) + 常量 CREATE_CHIP_GROUPS/CREATE_STEPS/REQ_SHEET_FILE/REQ_SHEET_TEMPLATE/REQ_LABEL_TO_KEY (L163-197) + style.css .wizard-modal 与 .wz-* 样式 (L226-251)——本轮代码已存在于工作区,未做修改
- 验证方法:启动 Chrome headless CDP 9224(user-data-dir 独立;CDP 9223 在用户内置浏览器实例,本次用 9224 独立验证),Node + WebSocket 写 tmp/cdp-wizard-smoke.mjs 模拟用户点击 5 步 + 注入 window.executeTask 拦截真实任务
- 结果(report.tmp/wizard-report.json):① step1 5 元素全在 (idea/gen/load/clarify/create) maskN=1 ② 空想法直接创作 skills=['短视频脚本创作'] ③ 5 步填写后 step5 dotActive=5+9 行摘要+skills=['短视频脚本创作']+hasReqSec=true ④ 打磨 skills=[]+hasSheetPath+forbidCreate+hasAccount ⑤ 载入(无文件) toast「未找到《创作需求单.md》」+maskN=1+stillAt1=true ⑥ chips/自由输入互斥 onAfterChip=true→onAfterFree=0+valAfterFree=「微短剧改编」
- 教训:① 子 shell `cmd &` 启的 Chrome 会被沙箱回收(与 node server.js 同事发现),必须用 run_in_background=true 启任务;② first 版测试未在每节清 modal-mask 导致 wz-5-confirm.png 出现双 modal 重影,强化版每节前 removeAll(.modal-mask) 后 maskN=1 一致;③ user 提出的「人工点击确认并提交创作」铁律体现在:clarify 路径不自动接续 create,强制走 载入需求单 → 人工 review → 直接创作
- 截图:tmp/wz-A1-idea.png(步骤1)/ tmp/wz-A2-form.png(步骤2 chips)/ tmp/wz-A3-mood.png / tmp/wz-A4-plot.png / tmp/wz-A5-confirm.png(步骤5 摘要9行)/ tmp/wz-A6-load-empty.png(载入无文件 toast)
+13
View File
@@ -178,3 +178,16 @@
- 数据源:`/api/dsh/video-script?videoId=` 的 rewrites(同表 rewrite_log.id,与列表 items.id 同体系)+ items 行聚合 review_ai/compare(find 不到时兜底 raw curRow → 显示空态)
- CSS 补 `.diag-empty`(style.css,dim 灰 + padding)
- 语法 node --check 通过 ×2;服务未重启(纯静态,no-store);git commit afc45b7,与之前 6 个 commit 一起 push(d0cbd41..afc45b7 成功)
## AI创作弹窗对话打磨 v2 定稿(15:50-16:00)
- **用户反馈**:「跟 AI 打磨需求 和 载入需求单 交互很奇怪,而且不连贯」→ 要求改为「弹窗内 需求框 + 输入框 对话打磨」「AI 按钮去掉,全过程都用 AI」——彻底废弃旧分步向导 + 跨会话打磨 + 需求单 md 载入路径
- **方案**:单弹窗 v2(`confirmAiScript` 完全重写,data-pages.js:199-326)= 顶部 10 行「创作需求」框(`REQ_FIELDS` 空值灰显"待定·由 AI 按人设自决")+ 中部对话流(u/ai 气泡)+ 底部输入行(Enter 发,Shift+Enter 换行)+ 取消/提交创作;**无 step/chip/AI 按钮/载入按钮/上下步**
- **后端新增** `POST /api/ai/clarify`(server.js,紧跟 `/api/ai/topics` 之后)= 同步阻塞 Dify chat-messages(100s AbortController),request `{ accountName, turn, reqMd, history }`,response `{ answer }`;query 拼接=账号人设摘要+当前需求 md+最近 2 轮历史+用户本轮输入,强制输出"对话回复 + ==REQ== + 10 行需求"
- **关键设计**:每轮重发「当前完整需求 + 最近 2 轮历史」做无状态上下文;AI 可更新任意字段、用户可改写 AI 输出;AI **只澄清需求严禁生成脚本正文**;最终 prompt `reqLines` 仅取非空字段,未填字段加 "由 AI 按人设自决" 提示
- **冒烟脚本 `tmp/cdp-req-dialog-smoke.mjs`**(CDP 9224,4 步端到端)3 轮迭代定稿——首两轮 2 个脚本 bug:
1. ④ 提交点击 `querySelector('[data-create]')` 命中背后列表按钮(弹窗内外按钮同名 data-create)→ 改为 `.req-modal [data-create]` 限定作用域;同时 `title` 改为 `.req-modal .modal-title-row span`(原全局 querySelector 命中其他 modal 模板)
2. `Page.navigate` 同 URL 不真正 reload(旧弹窗残留叠加新弹窗,rows 30=3 个弹窗叠加)→ 改用 `?v=Date.now()` 强制 reload + `waitFor('!document.querySelector(".req-modal")')` 等清场
- **最终全绿**(53s):①结构 rows:10 / title="AI创作 · 旧梦留声机" ②第一轮 busy=true,AI 回复贴合人设("账号主打【隔辈亲情与温情催泪】...A. 角色换为笨拙爷"),filled 0→6 ③第二轮 filled 6→8 ④提交 label="创作指令"/skills=["短视频脚本创作"]/hasPersona=true(人设卡注入 prompt)/reqRows=8(8 条需求进 prompt)/modalClosed=true
- **截图产出** `tmp/req-1-empty.png` / `req-2-after-turn1.png` / `req-3-after-turn2.png` + 报告 `tmp/req-dialog-report.json`
- **工作台页面** 按"改完必重开"约定 present_files `?v=20260902-req#/accounts`
- **经验沉淀**:①账号列表/弹窗内同名 `data-create` 触发选择器歧义——统一加 modal 作用域或换名(如 `data-confirm-create`)②CDP Page.navigate 同 URL 不可靠——浏览器/CDP 跨会话残留必须强制 reload + 显式等清空;冒烟脚本应假设首状态脏
+8
View File
@@ -11,6 +11,14 @@
- **抖音账号分析 mcn-dou-analysis(主版本 08-27)**:源=`project/短视频脚本创作/V1.0/subskills/mcn-dou-analysis/`(**09-02 主技能 subskill 目录更名 subskills**);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 副本无独有内容需回流
## AI创作弹窗对话打磨 v2(09-02 定稿,废弃旧分步向导+跨会话打磨+md载入)
- **路径**:账号列表 → AI创作 → 单弹窗(`confirmAiScript` 完全重写 data-pages.js:199-326)
- **后端新增** `POST /api/ai/clarify`(server.js 紧跟 /api/ai/topics 之后):同步阻塞 Dify chat-messages,100s AbortController;入参 `{ accountName, turn, reqMd, history }`,返回 `{ answer }`;query 拼装=账号人设摘要+当前需求 md+最近 2 轮 history+本轮输入;强制输出「对话+==REQ==+10 行需求」模板
- **前端**:REQ_FIELDS 10 项 (idea/form/dur/mood/aud/plot/end/sym/ad/note);顶部 10 行需求框常显(空值灰显"待定·由 AI 按人设自决")+ 中部对话流(u/ai 气泡)+ 底部输入行(Enter 发,Shift+Enter 换行)+ 取消/提交创作;**无 step/chip/AI 按钮/载入按钮/上下步**
- **关键设计**:每轮重发 reqMd + 最近 2 轮 history 做无状态上下文;AI 任何字段都允许更新、严禁生成脚本正文;reqLines 仅取非空字段 + 「未填由 AI 按人设自决」提示
- **冒烟 CDP 坑**(3 轮迭代定稿):①querySelector('[data-create]') 命中背后列表按钮(弹窗内外同名 data-create)→ 必须 `.req-modal [data-create]` 限定;②Page.navigate 同 URL 不真正 reload 致旧弹窗叠加 → `?v=Date.now()` 强制 reload + `waitFor('!document.querySelector(".req-modal")')` 等清场
- **冒烟脚本** `tmp/cdp-req-dialog-smoke.mjs`(4 步端到端 CDP 9224)= 结构/双轮真实 Dify 打磨/需求框递增/提交捕获(拦截 executeTask 验 label+skills+persona+reqRows+modalClosed),端到端 ~50s 全绿
## 创作技能关键规则(权威源:SKILL.md + references/创作流程规范.md)
1. 三态创作:⚡自动(默认)=静默执行+只落最终交付物;🤝共创=+每确认门询问;🛠️开发=+每步全落盘。7确认门不可合并;角色边界(分析不做创作)
2. 内容总监兜底所有质量检查点;文件 `scripts/{视频标题}/XX_阶段名.md`
@@ -156,66 +156,173 @@ function bindBatchBar(container, opts) {
}));
}
/* AI写脚本 确认弹窗:按账号是否有设定动态文案;选题输入区(手输 / AI 结合人设生成)→ 确认后执行创作任务 */
/* ============================================================
AI创作 需求打磨辅助(09-02 v2:弹窗内对话式——需求框 + 输入框,无跳会话/无文件回填/无选题按钮)
============================================================ */
// 需求单字段顺序:弹窗需求框展示、AI 输出模板、创作 prompt 共用(值空 = 待 AI 按人设自决)
const REQ_FIELDS = [
['idea', '一句话创意与选题'], ['form', '叙事形态'], ['dur', '时长目标'], ['mood', '情绪基调'], ['aud', '受众'],
['plot', '核心情节'], ['end', '结尾落点'], ['sym', '结构性符号'], ['ad', '广告植入'], ['note', '其他约束'],
];
// 需求 md 行 → 向导字段映射(parseReqSheet 用)
const REQ_LABEL_TO_KEY = Object.fromEntries(REQ_FIELDS.map(([k, label]) => [label, k]));
// 需求单 markdown 解析 → 状态对象(仅解析已知字段,空值/待填写忽略)
function parseReqSheet(md) {
const out = {};
for (const line of String(md || '').split('\n')) {
const m = /^[-*]\s*([^::]+?)\s*[::]\s*(.*)$/.exec(line.trim());
if (!m) continue;
const key = REQ_LABEL_TO_KEY[m[1].trim()];
const val = (m[2] || '').trim();
if (key && val && val !== '(待填写)') out[key] = val;
}
return out;
}
// 状态 → 10 行完整需求清单(供 AI 每轮保持同模板输出;空字段留空值)
function reqMdOf(w) {
return REQ_FIELDS.map(([k, label]) => `- ${label}:${String(w[k] || '').trim()}`).join('\n');
}
// 状态 → 已填需求行(创作 prompt 用,非空才输出)
function reqLines(w) {
return REQ_FIELDS.filter(([k]) => String(w[k] || '').trim()).map(([k, label]) => `- ${label}:${String(w[k]).trim()}`);
}
// 组装创作 prompt:人设块在前(既有铁律),向导需求在后;空维度不输出由 AI 自主决策
function buildCreatePrompt(accountName, w, personaBlock, hasPersona) {
const req = reqLines(w);
const reqSec = req.length ? `\n【创作需求】\n${req.join('\n')}` : '';
const tail = hasPersona
? `请贴合上述账号设定(人设与内容规则)创作;【创作需求】中填写的各项为明确要求、必须满足,未填写的维度由你按人设与选题自主决策。`
: `该账号暂无账号设定,请先为达人「${accountName}」提取账号设定(生成人设卡),再基于账号设定与【创作需求】创作完整脚本;未填写的维度由你自主决策。`;
return `请为达人「${accountName}」创作一条短视频脚本。${personaBlock}${reqSec}\n${tail}`;
}
/* AI创作 弹窗(09-02 v2:需求框 + 输入框 对话式打磨)——
顶部=创作需求(10 行字段,随对话逐轮被 AI 更新,空值标「待定·由 AI 按人设自决」)
中部=对话流(用户输入 vs AI 整理/追问),底部=输入框 + 提交创作 */
async function confirmAiScript(accountName, btn) {
// 判断账号是否有有效设定(persona 非空且非测试占位):有 →「基于当前账号设定」,无 →「先进行账号设定分析」
const accId = btn && btn.dataset.accId ? btn.dataset.accId : '';
let hasPersona = false, personaContent = '';
if (accId) {
const w = { idea: '', form: '', dur: '', mood: '', aud: '', plot: '', end: '', sym: '', ad: '', note: '' };
const convo = []; // 对话流 { role: 'u' | 'ai', text }
let pHas = false, pContent = '';
let busy = false;
const loadPersona = async () => {
if (pContent || !accId) return;
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
? `将为达人「${esc(accountName)}」基于当前账号设定生成脚本。可先填写选题方向,或点击右下角 AI 按钮结合人设生成选题:`
: `将为达人「${esc(accountName)}」创作一条短视频脚本(先进行账号设定分析,再生成完整脚本)。可先填写选题方向,或点击右下角 AI 按钮结合人设生成选题:`;
const c = String(r.content || '').trim();
pHas = !!c && !/测试写入|连通性测试/.test(c);
if (pHas) pContent = personaForCreate(c); // 完整拼入,仅剔除「十一、当前需解决的问题」及之后非创作章节
} catch (e) { pHas = false; }
};
const personaBlock = () => (pHas && pContent ? `\n【账号设定(人设卡)】\n${pContent}\n` : '');
const mask = document.createElement('div'); mask.className = 'modal-mask';
mask.innerHTML = `
<div class="modal">
<h3>${svgIcon('pen', 18)} AI创作</h3>
<p class="dim">${desc}</p>
<div class="ai-topic">
<textarea id="aiTopicInput" class="ai-topic-input" placeholder="输入选题方向(可选),如:亲子露营的搞笑瞬间"></textarea>
<button class="ai-topic-btn" id="aiTopicGen" title="AI 结合人设生成选题">AI</button>
</div>
<div class="modal-actions">
<button class="ghost" data-cancel>取消</button>
<button class="primary" data-ok>确认执行</button>
</div>
</div>`;
mask.innerHTML = `<div class="modal req-modal">
<div class="modal-title-row"><span>AI创作 · ${esc(accountName)}</span><button class="btn" data-x title="关闭">${svgIcon('x', 14)}</button></div>
<div class="req-head">在下方输入想法,AI 会边聊边把需求整理到「创作需求」框里;聊够了点「提交创作」。</div>
<div class="req-sheet" id="reqSheet"></div>
<div class="req-dialog" id="reqDialog"></div>
<div class="req-input-row">
<textarea id="reqInput" class="req-input" rows="2" placeholder="说说你的想法,例如:想拍一个亲子互动的搞笑短剧…&#10;(Enter 发送,Shift+Enter 换行)"></textarea>
<button type="button" class="primary req-send" id="reqSend">发送</button>
</div>
<div class="modal-actions">
<button class="ghost" data-cancel>取消</button>
<button class="primary" data-create>提交创作</button>
</div>
</div>`;
document.body.appendChild(mask);
const close = () => mask.remove();
mask.addEventListener('click', (e) => { if (e.target === mask) close(); });
mask.querySelector('[data-x]').addEventListener('click', close);
mask.querySelector('[data-cancel]').addEventListener('click', close);
const genBtn = mask.querySelector('#aiTopicGen');
genBtn.addEventListener('click', async () => {
if (genBtn.disabled) return;
genBtn.disabled = true; genBtn.textContent = '…';
const q = (s) => mask.querySelector(s);
/* —— 需求框渲染:10 行字段,空值灰显 —— */
const renderSheet = () => {
const rows = REQ_FIELDS.map(([k, label]) => {
const v = String(w[k] || '').trim();
return `<div class="req-sheet-row${v ? '' : ' empty'}"><span class="rs-label">${label}</span><span class="rs-val">${v ? esc(v) : '(待定 · 由 AI 按人设自决)'}</span></div>`;
}).join('');
q('#reqSheet').innerHTML = rows;
};
/* —— 对话流渲染 —— */
const renderDialog = () => {
const el = q('#reqDialog');
el.innerHTML = convo.length
? convo.map((c) => (c.role === 'u'
? `<div class="req-msg mine"><div class="req-bubble">${esc(c.text)}</div></div>`
: `<div class="req-msg ai"><div class="req-bubble">${esc(c.text)}</div></div>`)).join('')
: `<div class="req-dialog-empty">在这里输入一个初步想法,AI 会引导你补齐形态 / 时长 / 情绪 / 受众 / 情节 / 结尾 / 植入等需求;每条输入都会即时更新上方的创作需求框。</div>`;
el.scrollTop = el.scrollHeight;
};
const setBusy = (b, txt) => {
busy = b;
const s = q('#reqSend');
if (s) { s.disabled = b; s.textContent = b ? (txt || '整理中…') : '发送'; }
};
/* —— 发送一轮:调 /api/ai/clarify(同步打磨,返回 reply + ==REQ== 段) —— */
const sendTurn = async (raw) => {
const text = String(raw || '').trim();
if (!text || busy) return;
convo.push({ role: 'u', text });
q('#reqInput').value = '';
renderDialog();
setBusy(true);
try {
const r = await api('/api/ai/topics', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ accountName }) });
// 带最近 2 轮问答做上下文(无状态:需求本身在 reqMd 中已完整携带)
const history = [];
for (let i = convo.length - 2; i >= 0 && history.length < 2; i--) {
if (convo[i].role === 'ai') {
const u = convo[i - 1] && convo[i - 1].role === 'u' ? convo[i - 1].text : '';
history.unshift({ u, a: convo[i].text });
i--;
}
}
const r = await api('/api/ai/clarify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ accountName, turn: text, reqMd: reqMdOf(w), history }),
});
if (r.error) throw new Error(r.error);
mask.querySelector('#aiTopicInput').value = r.topics || '(未生成内容)';
showToast('✅ 已生成 3 个选题方向,可直接使用或修改');
const answer = String(r.answer || '').trim();
const ri = answer.lastIndexOf('==REQ==');
const reply = (ri >= 0 ? answer.slice(0, ri) : answer).trim();
const fields = ri >= 0 ? parseReqSheet(answer.slice(ri + '==REQ=='.length)) : {};
let updated = 0;
for (const k of Object.keys(fields)) {
const v = String(fields[k] || '').trim();
if (v && w[k] !== v) { w[k] = v; updated++; }
}
if (reply) convo.push({ role: 'ai', text: reply });
renderSheet();
renderDialog();
showToast(updated > 0 ? `✅ 需求已更新 ${updated} 项` : (ri >= 0 ? '✅ 已记录(本次无新变更)' : '⚠️ 本次未返回结构化需求,可换种说法再试'));
} catch (e) {
showToast('❌ 选题生成失败: ' + e.message);
convo.pop(); // 移除失败的用户消息,恢复输入
q('#reqInput').value = text;
renderDialog();
showToast('❌ 打磨失败: ' + e.message);
}
genBtn.disabled = false; genBtn.textContent = 'AI';
setBusy(false);
};
/* —— 事件 —— */
const inputEl = q('#reqInput');
inputEl.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendTurn(inputEl.value); }
});
mask.querySelector('[data-ok]').addEventListener('click', () => {
const topic = mask.querySelector('#aiTopicInput').value.trim();
q('#reqSend').addEventListener('click', () => sendTurn(inputEl.value));
mask.querySelector('[data-create]').addEventListener('click', async () => {
if (busy) return;
await loadPersona();
const prompt = buildCreatePrompt(accountName, w, personaBlock(), pHas);
close();
const personaBlock = personaContent ? `\n【账号设定(人设卡)】\n${personaContent}\n` : '';
const act = hasPersona
? `请贴合上述账号设定(人设与内容规则)创作。`
: `该账号暂无账号设定,请先为达人「${accountName}」提取账号设定(生成人设卡),再基于账号设定创作完整脚本。`;
const prompt = topic
? `请为达人「${accountName}」创作一条短视频脚本。${personaBlock}\n选题方向:${topic}\n${act}`
: `请为达人「${accountName}」创作一条短视频脚本。${personaBlock}\n${act}`;
executeTask({ prompt, label: '创作指令', btn, skills: ['短视频脚本创作'] });
});
renderSheet();
renderDialog();
inputEl.focus();
}
/* ================= 周榜 ================= */
@@ -206,22 +206,43 @@ main { height: calc(100vh - 54px); }
color: var(--dim); font-size: 13px; word-break: break-all;
padding: 0 2px 4px; margin-bottom: 6px;
}
/* AI脚本弹窗:选题输入区 + 右下角 AI 生成按钮 */
.ai-topic { position: relative; margin-bottom: 4px; }
.ai-topic-input {
width: 100%; min-height: 168px; padding: 8px 10px;
border: 1px solid var(--border); border-radius: 8px;
font-size: 15px; font-family: inherit; line-height: 1.5; resize: vertical;
box-sizing: border-box;
/* AI创作 弹窗(09-02 v2):需求框 + 对话流 + 输入框 对话式打磨 */
.req-modal { width: 720px; max-width: 96vw; }
.req-head { color: var(--dim); font-size: 13px; margin-bottom: 10px; line-height: 1.6; }
.req-sheet {
border: 1px solid var(--border); border-radius: 8px; background: #fbfcfe;
max-height: 218px; overflow: auto; padding: 2px 12px;
}
.ai-topic-input:focus { outline: none; border-color: var(--primary); }
.ai-topic-btn {
position: absolute; right: 8px; bottom: 8px;
border: 1px solid var(--primary); background: var(--primary-soft); color: var(--primary);
border-radius: 6px; padding: 3px 12px; font-size: 14px; font-weight: 600; cursor: pointer;
.req-sheet-row {
display: flex; gap: 10px; padding: 6px 0; border-bottom: 1px dashed #eceff3;
font-size: 14px; line-height: 1.55;
}
.ai-topic-btn:hover { background: var(--primary); color: #fff; }
.ai-topic-btn:disabled { opacity: .6; cursor: wait; }
.req-sheet-row:last-child { border-bottom: none; }
.req-sheet-row.empty .rs-val { color: #c6ccd6; }
.rs-label { flex: none; width: 108px; color: var(--dim); font-size: 13px; padding-top: 1px; }
.rs-val { flex: 1; word-break: break-word; }
.req-dialog {
margin-top: 10px; max-height: 172px; overflow: auto;
display: flex; flex-direction: column; gap: 6px; padding: 2px;
}
.req-dialog-empty { color: var(--dim); font-size: 13px; line-height: 1.7; padding: 2px; }
.req-msg { display: flex; }
.req-msg.mine { justify-content: flex-end; }
.req-msg.ai { justify-content: flex-start; }
.req-bubble {
max-width: 88%; padding: 7px 11px; border-radius: 10px;
font-size: 14px; line-height: 1.55; white-space: pre-wrap; word-break: break-word;
}
.req-msg.mine .req-bubble { background: var(--primary); color: #fff; border-bottom-right-radius: 3px; }
.req-msg.ai .req-bubble { background: #f0f2f5; color: var(--text); border-bottom-left-radius: 3px; }
.req-input-row { display: flex; gap: 8px; margin-top: 10px; align-items: flex-end; }
.req-input {
flex: 1; border: 1px solid var(--border); border-radius: 8px; padding: 8px 10px;
font-size: 14px; font-family: inherit; line-height: 1.5; box-sizing: border-box;
resize: vertical; min-height: 44px; max-height: 132px;
}
.req-input:focus { outline: none; border-color: var(--primary); }
.req-send { min-width: 68px; }
.root-list { display: flex; flex-direction: column; gap: 6px; margin-bottom: 12px; }
.root-item {
display: flex; align-items: center; gap: 8px;
@@ -650,6 +650,83 @@ const server = http.createServer((req, res) => {
return;
}
// ---------- AI 创作需求打磨(09-02 弹窗内对话:Dify chat-messages 同步、无状态多轮) ----------
// body: { accountName, turn(本轮输入), reqMd(当前需求10行md), history(最近轮[{u,a}]) } → { answer }
if (p === '/api/ai/clarify' && req.method === 'POST') {
let body = '';
req.on('data', (c) => { body += c; if (body.length > 1e6) req.destroy(); });
req.on('end', async () => {
try {
const { accountName, turn, reqMd, history } = JSON.parse(body || '{}');
if (!accountName || typeof accountName !== 'string') return sendErr(400, '缺少账号名');
if (!turn || typeof turn !== 'string' || !turn.trim()) return sendErr(400, '缺少用户输入');
// 1) 账号设定摘要(工作台库,与选题接口同源)
let personaText = '', contentText = '';
try {
const { DatabaseSync } = require('node:sqlite');
const db = new DatabaseSync(path.join(ROOT, 'mcn-plugin.db'), { readOnly: true });
const acc = db.prepare('SELECT * FROM hot_accounts WHERE account_name=? LIMIT 1').get(accountName);
if (acc) {
contentText = String(acc.content || '').slice(0, 300);
const p = db.prepare('SELECT content_json, summary FROM account_persona WHERE account_id=? ORDER BY id DESC LIMIT 1').get(acc.id);
if (p) personaText = String(p.summary || p.content_json || '').slice(0, 900);
}
db.close();
} catch (e) { /* 库不可用不阻塞 */ }
// 2) Dify key:环境变量优先,其次技能脚本内置默认
let apiKey = process.env.DIFY_MCN_CYLG_KEY || '';
if (!apiKey) {
const scriptPath = path.join(ROOT, '..', 'scripts', 'MCN_CYLG_API.py');
try {
const src = fs.readFileSync(scriptPath, 'utf8');
const m = src.match(/DEFAULT_CYLG_KEY\s*=\s*"([^"]+)"/);
if (m) apiKey = m[1];
} catch (e) { /* 脚本不存在则走 env */ }
}
if (!apiKey) return sendErr(500, '未配置 DIFY_MCN_CYLG_KEY(环境变量或技能脚本内置 KEY)');
// 3) 构造打磨 query(无状态:携带当前需求 + 最近对话,让 AI 输出 reply + ==REQ== 结构化段)
const his = Array.isArray(history) ? history.filter((h) => h && h.u).slice(-2) : [];
const histText = his.map((h) => '你:' + String(h.a || '') + '\n达人:' + String(h.u || '')).join('\n');
const reqMdText = String(reqMd || '').trim();
const query = '你是资深短视频编导,正在创作前通过多轮对话,帮达人「' + accountName + '」把创作需求打磨清楚。'
+ '你的职责只限需求澄清、整理与追问;严禁生成脚本正文、台词、分镜或完整故事。\n\n'
+ '【账号设定摘要】\n' + (personaText || '(暂无可用账号设定,按通用编导经验澄清)') + '\n'
+ (contentText ? '【账号定位】\n' + contentText + '\n' : '')
+ '【当前需求清单】\n' + (reqMdText || '(空:等待用户先给出初步想法)') + '\n'
+ (histText ? '【对话历史】\n' + histText + '\n' : '')
+ '【达人本轮输入】\n' + turn.trim() + '\n\n'
+ '【行为要求】\n'
+ '1. 理解本轮输入:可能是新想法、对上一问的回答、或想修改某一维度。将信息吸收进需求清单,可改写得更专业准确,但保留用户原意与账号结构性符号。\n'
+ '2. 若关键维度仍有缺口且本轮输入不足以补齐:最多追问 1~2 个问题(优先给 A/B/C 选项供挑选),不要一次抛出全部问题,也不要重复已明确的维度。\n'
+ '3. 若信息已基本齐备:用一两句话总结并提示用户可点击「提交创作」。\n'
+ '4. 输出必须严格分两段:第一段是对用户说的话(120 字以内,含必要时的问题);\n'
+ '然后另起一行单独输出 ==REQ==;\n'
+ '接着输出 10 行需求清单(仍未确定的项留空值,严禁编造),模板:\n'
+ '- 一句话创意与选题:\n- 叙事形态:\n- 时长目标:\n- 情绪基调:\n- 受众:\n- 核心情节:\n- 结尾落点:\n- 结构性符号:\n- 广告植入:\n- 其他约束:';
// 4) Dify blocking 调用
const difyUrl = String(process.env.DIFY_URL || 'https://mydify.youmanvideo.com/v1').replace(/\/+$/, '');
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 100000);
let resp;
try {
resp = await fetch(difyUrl + '/chat-messages', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + apiKey, 'Content-Type': 'application/json' },
body: JSON.stringify({ inputs: { is_think: '0', is_online: '0', model: process.env.DIFY_MCN_MODEL || 'gemini' }, query, response_mode: 'blocking', user: 'mcn-work-shop' }),
signal: ctrl.signal,
});
} finally { clearTimeout(timer); }
const data = await resp.json().catch(() => ({}));
if (!resp.ok) return sendErr(502, 'AI 接口错误: ' + (data.message || resp.status));
log('AI 需求打磨: ' + accountName);
return dshOk(200, { answer: data.answer || '' });
} catch (e) {
return sendErr(500, '需求打磨失败: ' + e.message);
}
});
return;
}
// 分析产物同步入库(09-01 新增):扫描账号产出目录 → 写工作台库 mcn-plugin.db
if (p === '/api/import/account' && req.method === 'POST') {
let body = '';