术语统一:视觉脚本→视觉短片、预设钩子→开场钩子(技能层全量)+ clarify v30 维度口径衔接

- P0: D·视觉脚本→D·视觉短片 20 处/5 文件(SKILL.md S9、6_框架映射表、9_脚本、创作流程规范、07_格式指南);F07 纯视觉短片保留
- P2a: 预设钩子→开场钩子 10 处/3 文件(5_选题、7_大纲、04_爆款开场口径说明)
- P1a/P1b: clarify.md 新增第 7 条维度口径衔接(内容形式4选→S6细化、故事框架倾向词→S6定型、开场钩子同口径) CHANGELOG v30
- SKILL.md frontmatter updated_at=2026-09-07; references-add/变更日志.md 归档 09-07 条目
- 需求框 v29(内容形式置首/Vlog去人物采访/叙事结构→故事框架) 已于上轮提交范围
This commit is contained in:
maogeigei committed 2026-09-07 15:03:21 +08:00
1 parent c3727d8e6f
commit f7cd6ffa72
15 files changed
+376 -172

No files matched your search

@@ -200,14 +200,34 @@ function bindBatchBar(container, opts) {
// 需求单字段顺序:弹窗需求框展示、AI 输出模板、创作 prompt 共用(值空 = 待 AI 按账号设定与选题自决)
// 09-02 v10:需求框定位=选题补充——只留本条视频选题侧字段 + 兜底「其他」;账号设定类(情绪基调/受众/结构性符号/广告植入)由人设卡提供,不在此重复收集
// 09-02 v11:dur 时长目标 移出需求栏 → 需求栏下方「功能选项」下拉(30/60/120/240/自定义),值由 w.dur 单独承载
// 09-02 v17:选题落点要素(痛点/爽点、共鸣/共情、爆点)加入需求框——创作流程需要但不是必填,空值由 AI 按账号设定与选题自决
// 09-02 v17:选题落点要素加入需求框——创作流程需要但不是必填,空值由 AI 按账号设定与选题自决
// 09-02 v18:叙事形态→叙事结构;删核心情节/结尾落点(情节与结尾由 AI 创作阶段自决,无需用户填写)
// 09-02 v19:爆点→创新/爆点,字段序调整至「一句话创意」之后
// 09-02 v20:删共鸣/共情(由 AI 按痛点/爽点自然发挥,无需用户单独填写)
// 09-02 v20:删共鸣/共情(由 AI 按爽点自然发挥,无需用户单独填写)
// 09-07 v28:8 维编导名定稿(用户确认)——idea 一句话故事 / hook 开场钩子 / blow 故事爆点 / pain 故事爽点 /
// ftype 内容形式 / form 叙事结构(不再含内容形式)/ cast 人物关系(剧情向)/ note 特别要求;
// 收尾门槛=核心 6 项取 4(clarify.md 同步);旧草稿缓存缺新字段自动为空,不报错
// 09-07 v29:内容形式(ftype)置首位;Vlog 项去掉「人物采访」(Vlog 就是 Vlog);叙事结构 → 故事框架(key form 不变,草稿兼容)
const REQ_FIELDS = [
['idea', '一句话创意'], ['blow', '创新 / 爆点'], ['pain', '痛点 / 爽点'],
['form', '叙事结构'], ['note', '其他约束'],
['ftype', '内容形式'],
['idea', '一句话故事'],
['hook', '开场钩子'],
['blow', '故事爆点'],
['pain', '故事爽点'],
['form', '故事框架'],
['cast', '人物关系'],
['note', '特别要求'],
];
// 字段 → 悬停提示(renderSheet 用;用编导语言解释落到本条视频要定什么,hover 标题可见)
const REQ_FIELD_HINTS = {
ftype: '内容形式:剧情 / Vlog / 口播 / 视觉短片(画面+音乐驱动)',
idea: '主角 + 事件 + 看点,一句话说清这条视频讲什么',
hook: '开场前 3 秒靠什么留人:悬念提问 / 反差画面 / 直击痛点 / 身份共鸣 / 利益承诺(S5 开场钩子场,3-7s 铁律)',
blow: '全片最炸的那一下 / 记忆点 / 反转高潮,观众看完记住什么',
pain: '观众为什么代入、看了爽在哪 / 被治愈在哪(目标体感:爽感 / 治愈感 / 获得感)',
form: '故事框架走向:钩子前置 / 悬念反转 / 递进铺垫 / 先抑后扬',
cast: '剧情向必补:主要人物是谁、彼此什么关系(父子 / 夫妻 / 同事 / 陌生冲突)',
note: '禁忌、广告植入、参考资料、与账号设定的差异要求等',
};
// 需求 md 行 → 向导字段映射(parseReqSheet 用)
const REQ_LABEL_TO_KEY = Object.fromEntries(REQ_FIELDS.map(([k, label]) => [label, k]));
// 需求单 markdown 解析 → 状态对象(仅解析已知字段,空值/待填写忽略)
@@ -246,7 +266,8 @@ function buildCreatePrompt(accountName, w, personaBlock, hasPersona, attachText)
}
/* AI创作 弹窗(09-02 v2:需求框 + 输入框 对话式打磨)——
顶部=创作需求(5 行选题字段:创意/创新爆点/痛点爽点/叙事结构/其他约束,共鸣共情由 AI 按痛点爽点发挥,随对话逐轮被 AI 更新,空值标「待完善」)+ 下方时长选项条
顶部=创作需求(09-07 v29:8 个选题字段——内容形式/一句话故事/开场钩子/故事爆点/故事爽点/故事框架/人物关系/特别要求;
内容形式置首位;Vlog 不含人物采访;人物关系剧情向补;空值标「待完善」)+ 下方时长选项条
中部=对话流(用户输入 vs AI 整理/追问),底部=输入框 + 提交创作 */
async function confirmAiScript(accountName, btn) {
const accId = btn && btn.dataset.accId ? btn.dataset.accId : '';
@@ -259,7 +280,7 @@ async function confirmAiScript(accountName, btn) {
} catch (e) { return null; }
};
const cache = readCache();
const w = { idea: '', pain: '', blow: '', form: '', dur: '', note: '', ...(cache && cache.w ? cache.w : {}) };
const w = { idea: '', hook: '', blow: '', pain: '', ftype: '', form: '', cast: '', note: '', dur: '', ...(cache && cache.w ? cache.w : {}) };
const convo = (cache && Array.isArray(cache.convo)) ? cache.convo.filter((c) => c && c.role && typeof c.text === 'string').map((c) => ({ role: c.role === 'ai' ? 'ai' : 'u', text: String(c.text), opts: Array.isArray(c.opts) ? c.opts.filter((o) => typeof o === 'string') : [] })) : []; // 对话流 { role: 'u' | 'ai', text, opts }
let pHas = false, pContent = '';
let attach = (cache && cache.attach && cache.attach.name)
@@ -343,11 +364,12 @@ async function confirmAiScript(accountName, btn) {
showToast('已重置,本次对话已清空');
};
/* —— 需求框渲染:5 个选题字段(落点要素可空;共鸣/情节/结尾由 AI 按痛点爽点自决),维度名作标题、内容在下方 —— */
/* —— 需求框渲染:8 个选题字段(内容形式置首位;开场钩子/人物关系 v28 新增、v29 故事框架命名),维度名作标题、内容在下方 —— */
const renderSheet = () => {
const rows = REQ_FIELDS.map(([k, label]) => {
const v = String(w[k] || '').trim();
return `<div class="req-field${v ? '' : ' empty'}"><div class="rf-title">${label}</div><div class="rf-val">${v ? esc(v) : '待完善'}</div></div>`;
const tip = REQ_FIELD_HINTS[k] ? ` title="${esc(REQ_FIELD_HINTS[k])}"` : '';
return `<div class="req-field${v ? '' : ' empty'}"${tip}><div class="rf-title">${label}</div><div class="rf-val">${v ? esc(v) : '待完善'}</div></div>`;
}).join('');
q('#reqSheet').innerHTML = rows;
const filled = REQ_FIELDS.filter(([k]) => String(w[k] || '').trim()).length;
@@ -380,18 +402,20 @@ async function confirmAiScript(accountName, btn) {
const n = Math.round(Number(durNum.value));
w.dur = (Number.isFinite(n) && n > 0) ? n + '秒' : '';
});
/* —— 对话流渲染(09-07:AI 消息可携带 ==OPTS== 候选,渲染为可点选 chips;点击即以该候选作为用户输入发送) —— */
/* —— 对话流渲染(09-07 v27:GPT 风格——用户主色实心圆角、AI 无背景 markdown;候选可点;支持流式追加与思考占位) —— */
const renderDialog = () => {
const el = q('#reqDialog');
el.innerHTML = convo.length
? convo.map((c, ci) => {
if (c.role === 'u') return `<div class="req-msg mine"><div class="req-bubble">${esc(c.text)}</div></div>`;
if (c.role === 'u') return `<div class="req-msg mine"><div class="req-bubble-mine">${esc(c.text)}</div></div>`;
const thinking = c.thinking ? `<div class="req-thinking" role="status" aria-live="polite"><span class="dot"></span><span class="dot"></span><span class="dot"></span><span class="req-thinking-label">正在思考</span></div>` : '';
const body = c.streaming ? '<div class="req-md req-md-stream" data-stream="' + ci + '"></div>' : (c.text ? `<div class="req-md">${esc(c.text)}</div>` : '');
const opts = (Array.isArray(c.opts) && c.opts.length)
? `<div class="req-opt-row">${c.opts.map((o) => `<button type="button" class="req-opt-chip" data-i="${ci}" data-opt="${esc(o)}">${esc(o)}</button>`).join('')}</div>`
: '';
return `<div class="req-msg ai"><div class="req-bubble">${esc(c.text)}</div>${opts}</div>`;
return `<div class="req-msg ai" data-ci="${ci}">${thinking}${body}${opts}</div>`;
}).join('')
: `<div class="req-dialog-empty">在这里输入一个初步想法,AI 会引导你补齐创意 / 创新·爆点 / 痛点爽点 / 叙事结构等需求(落点要素可留空,由 AI 按账号设定与选题补齐,共鸣与情节结尾由 AI 依痛点爽点发挥);每条输入都会即时更新上方的创作需求框,AI 追问时会给出可点选的候选。支持上传附件。Enter 发送,Shift+Enter 换行。</div>`;
: `<div class="req-dialog-empty">像聊天一样说出想法,AI 帮你把这条视频的需求聊到可开拍。</div>`;
el.querySelectorAll('.req-opt-chip').forEach((btn) => {
btn.addEventListener('click', () => {
if (busy) return;
@@ -458,7 +482,7 @@ async function confirmAiScript(accountName, btn) {
parsing = false; paperBtn.disabled = false; paperBtn.classList.remove('loading');
});
paperBtn.addEventListener('click', () => { if (!parsing) attachInput.click(); });
/* —— 发送一轮:调 /api/ai/clarify(同步打磨,返回 reply + ==REQ== 段) —— */
/* —— 发送一轮(09-07 v27:流式输出——首字节前显示"正在思考"占位,逐 chunk 追加到 AI 气泡;流结束后再解析 ==OPTS==/==REQ==) —— */
const sendTurn = async (raw) => {
let text = String(raw || '').trim();
if (busy) return;
@@ -469,46 +493,91 @@ async function confirmAiScript(accountName, btn) {
convo.push({ role: 'u', text });
q('#reqInput').value = '';
autoGrow();
// 先放占位 AI 气泡(thinking=true),再渲染:用户可见"正在思考"动画
const aiIdx = convo.length;
convo.push({ role: 'ai', text: '', opts: [], thinking: true });
renderDialog();
setBusy(true);
try {
// 带最近 2 轮问答做上下文(无状态:需求本身在 reqMd 中已完整携带)
const history = [];
for (let i = convo.length - 2; i >= 0 && history.length < 2; i--) {
if (convo[i].role === 'ai') {
for (let i = convo.length - 3; i >= 0 && history.length < 2; i--) {
if (convo[i].role === 'ai' && !convo[i].thinking) {
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', {
const resp = await fetch('/api/ai/clarify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ accountName, turn: text, reqMd: reqMdOf(w), history, attach: attach.text }),
body: JSON.stringify({ accountName, turn: text, reqMd: reqMdOf(w), history, attach: attach.text, stream: 1 }),
});
if (r.error) throw new Error(r.error);
const answer = String(r.answer || '').trim();
const ri = answer.lastIndexOf('==REQ==');
const head = (ri >= 0 ? answer.slice(0, ri) : answer).trim();
// 09-07:==OPTS== 候选段(位于正文与 ==REQ== 之间,每行一个可点选短句)→ 渲染为 chips
const oi = head.lastIndexOf('==OPTS==');
const reply = (oi >= 0 ? head.slice(0, oi) : head).trim();
const opts = oi >= 0
? head.slice(oi + '==OPTS=='.length).split('\n').map((s) => s.trim()).filter((s) => s && !s.startsWith('==')).slice(0, 6)
: [];
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 (!resp.ok) {
const t = await resp.text().catch(() => '');
throw new Error('HTTP ' + resp.status + (t ? ': ' + t.slice(0, 120) : ''));
}
if (reply || opts.length) convo.push({ role: 'ai', text: reply || '(已记录本轮信息,继续补充或点选上方候选)', opts });
saveCache(); // 09-07 v26:每轮成功后即时存草稿,防刷新/误关丢失
renderSheet();
renderDialog();
showToast(updated > 0 ? `✅ 需求已更新 ${updated} 项` : (ri >= 0 ? '✅ 已记录(本次无新变更)' : '⚠️ 本次未返回结构化需求,可换种说法再试'));
const reader = resp.body && resp.body.getReader ? resp.body.getReader() : null;
const ctype = resp.headers.get('content-type') || '';
// 若后端未真正流式(仍走 blocking),兼容降级:读全部 → 一次性写入
if (!reader || !ctype.includes('text/event-stream')) {
const t = await resp.text();
const buf = String(t || '');
// 兼容 data:{answer:"..."} JSON 包裹
let ans = buf;
try { const j = JSON.parse(buf); if (j && typeof j.answer === 'string') ans = j.answer; } catch (e) {}
convo[aiIdx] = { role: 'ai', text: ans, opts: [], thinking: false };
finishAi(aiIdx, ans);
saveCache();
renderDialog();
showToast('✅ 已记录');
return;
}
const dec = new TextDecoder('utf-8');
let buf2 = '';
let firstByte = false;
// 流式:首字节到达即关闭 thinking 占位,进入"边推边渲染"
while (true) {
const { value, done } = await reader.read();
if (done) break;
buf2 += dec.decode(value, { stream: true });
// 按行切分(SSE 事件以空行分隔,每行以 data: 开头)
const lines = buf2.split('\n');
buf2 = lines.pop() || '';
for (const line of lines) {
if (!line.startsWith('data:')) continue;
const payload = line.slice(5).trim();
if (payload === '[DONE]') continue;
let chunk = '';
try {
const j = JSON.parse(payload);
if (typeof j.answer === 'string') chunk = j.answer;
else if (typeof j.text === 'string') chunk = j.text;
else if (typeof j.delta === 'string') chunk = j.delta;
} catch (e) { continue; }
if (!chunk) continue;
if (!firstByte) {
firstByte = true;
convo[aiIdx].thinking = false;
convo[aiIdx].text = '';
convo[aiIdx].streaming = true;
renderDialog();
appendStreamChunk(aiIdx, chunk);
} else {
appendStreamChunk(aiIdx, chunk);
}
}
}
// 流结束
convo[aiIdx].streaming = false;
const finalText = convo[aiIdx].text || '';
finishAi(aiIdx, finalText);
saveCache();
showToast('✅ 已记录');
} catch (e) {
convo.pop(); // 移除失败的用户消息,恢复输入
// 移除失败的两条占位(用户消息 + 思考占位)
convo.splice(aiIdx, 1);
convo.pop();
q('#reqInput').value = text;
autoGrow();
renderDialog();
@@ -516,6 +585,41 @@ async function confirmAiScript(accountName, btn) {
}
setBusy(false);
};
// 流式过程中:把新片段追加到 convo[aiIdx].text,就地更新对应 .req-md-stream 容器(不重建整个对话流)
function appendStreamChunk(aiIdx, chunk) {
convo[aiIdx].text = (convo[aiIdx].text || '') + chunk;
const el = q('#reqDialog');
if (!el) return;
const node = el.querySelector('.req-md-stream[data-stream="' + aiIdx + '"]');
if (node) {
node.textContent = convo[aiIdx].text; // 纯文本直写,不做 HTML(防注入)
el.scrollTop = el.scrollHeight;
}
}
// 流结束后:解析 ==OPTS== / ==REQ==,回填需求框与候选
function finishAi(aiIdx, fullText) {
const ri = fullText.lastIndexOf('==REQ==');
const head = (ri >= 0 ? fullText.slice(0, ri) : fullText).trim();
const oi = head.lastIndexOf('==OPTS==');
const reply = (oi >= 0 ? head.slice(0, oi) : head).trim();
const opts = oi >= 0
? head.slice(oi + '==OPTS=='.length).split('\n').map((s) => s.trim()).filter((s) => s && !s.startsWith('==')).slice(0, 6)
: [];
const fields = ri >= 0 ? parseReqSheet(fullText.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++; }
}
convo[aiIdx].text = reply || '(已记录本轮信息,继续补充或点选上方候选)';
convo[aiIdx].opts = opts;
convo[aiIdx].thinking = false;
convo[aiIdx].streaming = false;
renderSheet();
renderDialog();
if (updated > 0) showToast(`✅ 需求已更新 ${updated} 项`);
else if (ri < 0) showToast('⚠️ 本次未返回结构化需求,可换种说法再试');
}
/* —— 事件 —— */
const inputEl = q('#reqInput');
/* 输入自动增高:textarea scrollHeight 动态写入 style.height,上限 100px(与 CSS max-height 一致) */
@@ -260,13 +260,38 @@ main { height: calc(100vh - 54px); }
.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;
/* 09-07 v27:GPT 风格气泡——用户主色实心圆角右对齐,AI 无背景直接 markdown 铺排左对齐 */
.req-bubble-mine {
max-width: 80%;
background: var(--primary); color: #fff;
padding: 10px 14px; border-radius: 14px;
font-size: 14px; line-height: 1.55; white-space: pre-wrap; word-break: break-word;
}
/* 09-07 v26:AI 消息 column 布局下气泡宽度自适应文本(最长行限制在消息行宽内) */
.req-msg.ai { align-items: flex-start; }
.req-msg.ai .req-bubble { max-width: 100%; width: fit-content; min-width: 0; }
.req-md {
max-width: 100%;
color: var(--text); font-size: 14.5px; line-height: 1.7;
white-space: pre-wrap; word-break: break-word;
}
/* 流式占位容器(流结束时会被替换为最终 markdown 节点) */
.req-md-stream { min-height: 1.6em; }
/* 思考动画:三个点交替跳动 + "正在思考"提示(不展示模型思考内容) */
.req-thinking {
display: inline-flex; align-items: center; gap: 6px;
color: var(--dim); font-size: 13px;
padding: 4px 2px;
}
.req-thinking .dot {
width: 6px; height: 6px; border-radius: 50%;
background: var(--primary); display: inline-block;
animation: req-dot 1.2s ease-in-out infinite;
}
.req-thinking .dot:nth-child(2) { animation-delay: .15s; }
.req-thinking .dot:nth-child(3) { animation-delay: .30s; }
.req-thinking-label { margin-left: 2px; }
@keyframes req-dot {
0%, 80%, 100% { opacity: .25; transform: translateY(0); }
40% { opacity: 1; transform: translateY(-3px); }
}
.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; }
/* 09-07:AI 消息内嵌候选按钮行(flova 式:气泡下方紧跟可点选项,无额外提示语/虚线框) */