工作台:AI创作弹窗四项修复+flova式选项交互(草稿缓存/去遮罩关/收尾门槛/候选具体化)

This commit is contained in:
maogeigei committed 2026-09-07 12:31:56 +08:00
1 parent badb2abffd
commit 39659d6966
3 files changed
+87 -26

No files matched your search

@@ -250,10 +250,21 @@ function buildCreatePrompt(accountName, w, personaBlock, hasPersona, attachText)
中部=对话流(用户输入 vs AI 整理/追问),底部=输入框 + 提交创作 */
async function confirmAiScript(accountName, btn) {
const accId = btn && btn.dataset.accId ? btn.dataset.accId : '';
const w = { idea: '', pain: '', blow: '', form: '', dur: '', note: '' };
const convo = []; // 对话流 { role: 'u' | 'ai', text }
/* 09-07 v26:草稿缓存(localStorage,按账号隔离)——关闭/误关后重开恢复已对话内容与已填需求 */
const CACHE_KEY = 'aicreate_draft_' + accountName;
const readCache = () => {
try {
const raw = localStorage.getItem(CACHE_KEY);
return raw ? JSON.parse(raw) : null;
} catch (e) { return null; }
};
const cache = readCache();
const w = { idea: '', pain: '', blow: '', form: '', dur: '', note: '', ...(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 = { name: '', text: '' }; // 已上传需求附件(解析后的纯文本,打磨与创作共用)
let attach = (cache && cache.attach && cache.attach.name)
? { name: String(cache.attach.name), text: String(cache.attach.text || '').slice(0, 20000), meta: String(cache.attach.meta || '') }
: { name: '', text: '' }; // 已上传需求附件(解析后的纯文本,打磨与创作共用)
let parsing = false; // 附件解析中(防重入)
let busy = false;
const loadPersona = async () => {
@@ -269,7 +280,11 @@ async function confirmAiScript(accountName, btn) {
const mask = document.createElement('div'); mask.className = 'modal-mask';
mask.innerHTML = `<div class="modal req-modal">
<div class="modal-title-row"><span>AI创作 · ${esc(accountName)}</span><button class="btn btn-icon" data-x title="关闭">${svgIcon('x', 16)}</button></div>
<div class="modal-title-row"><span>AI创作 · ${esc(accountName)}</span>
<span class="mtr-actions">
<button type="button" class="btn btn-ghost btn-sm" data-reset title="清空本次对话与已填需求,重新开始">重置</button>
<button class="btn btn-icon" data-x title="关闭">${svgIcon('x', 16)}</button>
</span></div>
<div class="req-sheet-box" id="reqSheetBox">
<button type="button" class="req-sheet-toggle" id="reqSheetToggle" title="展开/收起创作需求" aria-expanded="false">
<span class="rst-label">创作需求</span>
@@ -308,10 +323,25 @@ async function confirmAiScript(accountName, btn) {
<input type="file" id="attachInput" accept=".doc,.docx,.txt,.pdf" hidden>
</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);
/* 09-07 v26:关闭仅走标题栏 X / 重置;点击遮罩(弹窗外)不再关闭,避免误触清空已对话内容(CACHE_KEY 见函数头部) */
const saveCache = () => {
try {
const has = convo.length > 0 || REQ_FIELDS.some(([k]) => String(w[k] || '').trim()) || attach.name;
if (!has) { localStorage.removeItem(CACHE_KEY); return; }
localStorage.setItem(CACHE_KEY, JSON.stringify({ w, convo, attach: { name: attach.name, meta: attach.meta || '', text: attach.text ? attach.text.slice(0, 20000) : '' } }));
} catch (e) { /* 存储不可用不阻塞 */ }
};
const close = () => { saveCache(); mask.remove(); };
const q = (s) => mask.querySelector(s);
const resetAll = () => {
for (const k of Object.keys(w)) w[k] = '';
convo.length = 0;
attach = { name: '', text: '' };
try { localStorage.removeItem(CACHE_KEY); } catch (e) {}
renderSheet(); renderDialog(); renderAttach();
syncDur(true);
showToast('已重置,本次对话已清空');
};
/* —— 需求框渲染:5 个选题字段(落点要素可空;共鸣/情节/结尾由 AI 按痛点爽点自决),维度名作标题、内容在下方 —— */
const renderSheet = () => {
@@ -338,7 +368,8 @@ async function confirmAiScript(accountName, btn) {
const durSel = q('#reqDur');
const durCustom = q('#reqDurCustom');
const durNum = q('#reqDurNum');
const syncDur = () => {
const syncDur = (forceClear) => {
if (forceClear) { durSel.value = ''; durCustom.hidden = true; durNum.value = ''; w.dur = ''; return; }
const sel = durSel.value;
if (sel === 'custom') { durCustom.hidden = false; durNum.focus(); w.dur = ''; return; }
durCustom.hidden = true;
@@ -356,7 +387,7 @@ async function confirmAiScript(accountName, btn) {
? convo.map((c, ci) => {
if (c.role === 'u') return `<div class="req-msg mine"><div class="req-bubble">${esc(c.text)}</div></div>`;
const opts = (Array.isArray(c.opts) && c.opts.length)
? `<div class="req-opts">${c.opts.map((o) => `<button type="button" class="req-opt-chip" data-i="${ci}" data-opt="${esc(o)}">${esc(o)}</button>`).join('')}</div>`
? `<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>`;
}).join('')
@@ -472,6 +503,7 @@ async function confirmAiScript(accountName, btn) {
if (v && w[k] !== v) { w[k] = v; updated++; }
}
if (reply || opts.length) convo.push({ role: 'ai', text: reply || '(已记录本轮信息,继续补充或点选上方候选)', opts });
saveCache(); // 09-07 v26:每轮成功后即时存草稿,防刷新/误关丢失
renderSheet();
renderDialog();
showToast(updated > 0 ? `✅ 需求已更新 ${updated} 项` : (ri >= 0 ? '✅ 已记录(本次无新变更)' : '⚠️ 本次未返回结构化需求,可换种说法再试'));
@@ -496,6 +528,14 @@ async function confirmAiScript(accountName, btn) {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendTurn(inputEl.value); }
});
q('#reqSend').addEventListener('click', () => sendTurn(inputEl.value));
/* 09-07 v26:关闭=仅标题栏 X(已去除点遮罩自动关);重置=清空草稿缓存重来 */
q('[data-x]').addEventListener('click', close);
q('[data-reset]').addEventListener('click', () => {
if (convo.length || REQ_FIELDS.some(([k]) => String(w[k] || '').trim()) || attach.name) {
if (!window.confirm('确定重置吗?将清空本次对话与已填需求,重新开始。')) return;
}
resetAll();
});
q('#reqCreate').addEventListener('click', async () => {
if (busy) return;
await loadPersona(); // 先加载人设,供确认弹窗展示人设/附件状态
@@ -522,14 +562,24 @@ async function confirmAiScript(accountName, btn) {
ck.querySelector('[data-ok]').addEventListener('click', async () => {
rm();
const prompt = `${SKILL_HINT_CREATE}\n${buildCreatePrompt(accountName, w, personaBlock(), pHas, attach.text)}${saveHintOf({ accountId: accId })}`;
close();
close(); // close 内部 saveCache
try { localStorage.removeItem(CACHE_KEY); } catch (e) {} // 提交后清草稿,避免下次重开误恢复
executeTask({ prompt, label: '创作指令', btn, skills: ['短视频工作台'], connectorIds: MYAI_CONNECTORS });
});
document.body.appendChild(ck);
});
/* 草稿恢复时同步时长下拉(w.dur 形如 "60秒"/"") */
const durVal = String(w.dur || '').replace(/秒$/, '');
if (durVal && ['30', '60', '120', '240'].includes(durVal)) durSel.value = durVal;
else if (durVal && durVal !== '') { durSel.value = 'custom'; durCustom.hidden = false; durNum.value = durVal; }
else { durSel.value = ''; durCustom.hidden = true; }
if (convo.length || REQ_FIELDS.some(([k]) => String(w[k] || '').trim()) || attach.name) {
setTimeout(() => showToast('已恢复上次未提交的对话草稿,可继续完善或点「重置」清空'), 300);
}
renderSheet();
renderDialog();
renderAttach();
inputEl.focus();
}
@@ -264,19 +264,27 @@ main { height: calc(100vh - 54px); }
max-width: 88%; padding: 7px 11px; border-radius: 10px;
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-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 追问候选 chips(==OPTS== 段渲染;置于 AI 气泡下方,点击即作为用户输入发送) */
.req-msg.ai { flex-wrap: wrap; column-gap: 8px; }
.req-msg.ai .req-bubble + .req-opts { margin-top: 6px; }
.req-opts { display: flex; flex-wrap: wrap; gap: 6px; max-width: 92%; }
/* 09-07:AI 消息内嵌候选按钮行(flova 式:气泡下方紧跟可点选项,无额外提示语/虚线框) */
.req-msg.ai {
flex-wrap: wrap; column-gap: 8px;
/* 09-07 v26:AI 消息改纵向——气泡在下层自然左对齐,选项按钮行紧随其后,视觉同属一条消息 */
flex-direction: column; align-items: flex-start; row-gap: 6px;
}
.req-opt-row { display: flex; flex-wrap: wrap; gap: 6px; max-width: 100%; padding: 0 2px; }
.req-opt-chip {
display: inline-flex; align-items: center;
background: #fff; border: 1px solid var(--border); border-radius: 999px;
padding: 4px 12px; font-size: 13px; color: var(--text); cursor: pointer;
line-height: 1.4; transition: border-color .15s, color .15s, background .15s;
background: #fff; border: 1px solid #cfd8e6; border-radius: 8px;
padding: 6px 13px; font-size: 13px; color: var(--text); cursor: pointer;
line-height: 1.45; transition: border-color .15s, color .15s, background .15s, box-shadow .15s;
box-shadow: 0 1px 2px rgba(15, 23, 42, .04);
}
.req-opt-chip:hover { border-color: var(--primary); color: var(--primary); background: var(--primary-soft); }
.req-opt-chip:hover { border-color: var(--primary); color: var(--primary); background: #f2f6ff; }
.req-opt-chip:active { transform: translateY(1px); }
/* 底部功能条:输入框(圆角块内嵌 textarea + 底部工具栏:回形针 · ✓ 提交创作 · ↑ 发送) */
.req-bar { flex: none; display: flex; align-items: flex-end; gap: 10px; }
.req-create {
@@ -570,6 +578,9 @@ td.td-title { max-width: 240px; }
/* ================= 对比弹窗 ================= */
.modal-title-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 14px; font-size: 18px; font-weight: 600; }
.modal-title-row span { white-space: pre-wrap; word-break: break-word; } /* 标题内换行(标签/共创信息分行显示) */
.mtr-actions { display: inline-flex; align-items: center; gap: 6px; flex: none; }
.btn-ghost { background: transparent; border: 1px solid var(--border); color: var(--text); }
.btn-ghost:hover { border-color: var(--primary); color: var(--primary); }
.rw-modal { width: min(1600px, calc(100% - 100px)); height: calc(100vh - 60px); overflow: auto; } /* 宽度:距左右各 50px,最大 1600px;固定高度距屏幕上下各 30px */
.rw-split { display: flex; gap: 14px; }
.rw-pane { flex: 1; min-width: 0; display: flex; flex-direction: column; }
@@ -882,15 +882,15 @@ const server = http.createServer((req, res) => {
+ '【达人本轮输入】\n' + turn.trim() + '\n\n'
+ '【行为要求】\n'
+ '1. 理解本轮输入:可能是新想法、对上一问的回答、或想修改某一维度。将信息吸收进需求清单,可改写得更专业准确,但保留用户原意与账号结构性符号。\n'
+ '2. 若关键维度仍有缺口且本轮输入不足以补齐:最多追问 1~2 个问题,不要一次抛出全部问题,也不要重复已明确的维度。追问须给出可点选的候选,供达人一键选用。\n'
+ '3. 候选来源受控(只能出自以下词表,严禁编造清单外选项):\n'
+ ' - 叙事结构未定 → 候选:钩子前置 / 悬念反转 / 递进铺垫 / 先抑后扬\n'
+ ' - 内容形式(叙事形态)未定 → 候选:剧情(场次叙事,多角色对白)/ Vlog 纪实·人物采访(第一视角)/ 口播(干货·教程·盘点)/ 视觉短片(画面+音乐驱动,测评种草或纯氛围,台词极少)\n'
+ ' - 达人倾向剧情演绎(含多角色/冲突反转的意向)但人物与关系未明 → 候选追问主要人物身份 / 人物间关系(如:父子 / 夫妻 / 同事 / 陌生冲突),一次只问一个层面\n'
+ '4. 若信息已基本齐备:用一两句话总结并提示用户可点击「提交创作」,无需输出候选。\n'
+ '2. 若关键维度仍有缺口且本轮输入不足以补齐:只追问 1 个问题,不要一次抛出多个,也不要重复已明确的维度。问题必须单独另起一段(前后空行),便于界面突出显示;且必须给出可点选的候选(==OPTS==),供达人一键选用,严禁让达人自己空想选项。\n'
+ '3. 候选生成规则:选项骨架须受控(来自以下词表),但每项必须结合本条视频的创意/人设给出「落到这条视频里的具体形态」——即选项 = 骨架词 + 一句话怎么拍/什么内容,让达人一眼知道选了会得到什么,而不是干巴巴的术语。\n'
+ ' - 叙事结构未定 → 骨架:钩子前置 / 悬念反转 / 递进铺垫 / 先抑后扬,每项后缀具体展开(如:钩子前置——开场 3 秒就用「男保姆被娃折腾」制造笑点;悬念反转——结尾突然揭晓妈妈一直在偷看)。\n'
+ ' - 内容形式(叙事形态)未定 → 骨架:剧情(场次叙事,多角色对白)/ Vlog 纪实·人物采访(第一视角)/ 口播(干货·教程·盘点)/ 视觉短片(画面+音乐驱动,测评种草或纯氛围,台词极少),后缀结合本条内容给画面特征。\n'
+ ' - 达人倾向剧情演绎但人物与关系未明 → 骨架:主要人物身份 / 人物间关系(如:父子 / 夫妻 / 同事 / 陌生冲突),每项后缀结合已有创意给关系落点,一次只问一个层面。\n'
+ '4. 收尾门槛:仅当以下 4 项中至少 3 项已明确时才可总结收尾——一句话创意、创新/爆点、痛点/爽点、叙事结构(含内容形式)。未达门槛前,即使达人本轮回答已吸收,也须继续补齐最关键的缺口(把缺口本身作为问题+候选追问);已明确项可从达人输入或账号设定合理推断,但推断项须在回复中点明「我按账号设定帮你定了XX,如不对可点重置改」。\n'
+ '5. 输出三段结构,顺序固定:\n'
+ ' 第一段:对用户说的话(120 字以内,含必要时的问题);\n'
+ ' 若本轮提供了候选:另起一行单独输出 ==OPTS==,每行一个候选、直接是达人可原样回复的短句(≤30 字、不含编号前缀);若未提供候选则不输出该段;\n'
+ ' 第一段:对用户说的话(150 字以内)。需追问时:先一句确认本轮吸收的内容,空一行后单独输出问题(问题独占一段,不要与解释挤在一行);无需追问时:总结已明确项并提示「可直接点击右下角 ✓ 提交创作」;\n'
+ ' 若本轮提供了候选:另起一行单独输出 ==OPTS==,每行一个候选,格式为「骨架词——具体展开」(≤45 字、不含编号前缀),达人点选后整行即作为其回复;若未提供候选则不输出该段;\n'
+ ' 最后另起一行单独输出 ==REQ==,接着输出 5 行需求清单(仍未确定的项留空值,严禁编造;情节/结尾/时长不由本清单承载,由创作阶段按人设与选题自决;共鸣/共情亦不收集,创作时由 AI 依痛点 / 爽点自然发挥),模板:\n'
+ '- 一句话创意:\n- 创新 / 爆点:\n- 痛点 / 爽点:\n- 叙事结构:\n- 其他约束:\n'
+ ' 达人点击候选后,把该选择吸收进对应字段(如内容形式选「视觉短片」→ 写入叙事结构行并注明画面驱动;人物选择 → 写入一句话创意行的事件或人物信息)。\n'