工作台:对话式选题完善 ==OPTS== 候选 chips(编导词定稿+分支追问)

This commit is contained in:
maogeigei committed 2026-09-07 12:12:54 +08:00
1 parent 383532719d
commit 01a1301a4a
3 files changed
+51 -13

No files matched your search

@@ -349,14 +349,29 @@ 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;点击即以该候选作为用户输入发送) —— */
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 会引导你补齐创意 / 创新·爆点 / 痛点爽点 / 叙事结构等需求(落点要素可留空,由 AI 按账号设定与选题补齐,共鸣与情节结尾由 AI 依痛点爽点发挥);每条输入都会即时更新上方的创作需求框。支持上传附件。Enter 发送,Shift+Enter 换行。</div>`;
? 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>`
: '';
return `<div class="req-msg ai"><div class="req-bubble">${esc(c.text)}</div>${opts}</div>`;
}).join('')
: `<div class="req-dialog-empty">在这里输入一个初步想法,AI 会引导你补齐创意 / 创新·爆点 / 痛点爽点 / 叙事结构等需求(落点要素可留空,由 AI 按账号设定与选题补齐,共鸣与情节结尾由 AI 依痛点爽点发挥);每条输入都会即时更新上方的创作需求框,AI 追问时会给出可点选的候选。支持上传附件。Enter 发送,Shift+Enter 换行。</div>`;
el.querySelectorAll('.req-opt-chip').forEach((btn) => {
btn.addEventListener('click', () => {
if (busy) return;
const ci = Number(btn.dataset.i);
const opt = btn.dataset.opt || '';
if (!opt || !Number.isFinite(ci) || !convo[ci]) return;
if (convo[ci].role === 'ai') convo[ci].opts = []; // 点过的候选即时收起
renderDialog();
sendTurn(opt);
});
});
el.scrollTop = el.scrollHeight;
};
const setBusy = (b) => {
@@ -443,14 +458,20 @@ async function confirmAiScript(accountName, btn) {
if (r.error) throw new Error(r.error);
const answer = String(r.answer || '').trim();
const ri = answer.lastIndexOf('==REQ==');
const reply = (ri >= 0 ? answer.slice(0, ri) : answer).trim();
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 (reply) convo.push({ role: 'ai', text: reply });
if (reply || opts.length) convo.push({ role: 'ai', text: reply || '(已记录本轮信息,继续补充或点选上方候选)', opts });
renderSheet();
renderDialog();
showToast(updated > 0 ? `✅ 需求已更新 ${updated} 项` : (ri >= 0 ? '✅ 已记录(本次无新变更)' : '⚠️ 本次未返回结构化需求,可换种说法再试'));
@@ -266,6 +266,17 @@ main { height: calc(100vh - 54px); }
}
.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%; }
.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;
}
.req-opt-chip:hover { border-color: var(--primary); color: var(--primary); background: var(--primary-soft); }
/* 底部功能条:输入框(圆角块内嵌 textarea + 底部工具栏:回形针 · ✓ 提交创作 · ↑ 发送) */
.req-bar { flex: none; display: flex; align-items: flex-end; gap: 10px; }
.req-create {
@@ -882,13 +882,19 @@ const server = http.createServer((req, res) => {
+ '【达人本轮输入】\n' + turn.trim() + '\n\n'
+ '【行为要求】\n'
+ '1. 理解本轮输入:可能是新想法、对上一问的回答、或想修改某一维度。将信息吸收进需求清单,可改写得更专业准确,但保留用户原意与账号结构性符号。\n'
+ '2. 若关键维度仍有缺口且本轮输入不足以补齐:最多追问 1~2 个问题(优先给 A/B/C 选项供挑选),不要一次抛出全部问题,也不要重复已明确的维度。\n'
+ '3. 若信息已基本齐备:用一两句话总结并提示用户可点击「提交创作」。\n'
+ '4. 输出必须严格分两段:第一段是对用户说的话(120 字以内,含必要时的问题);\n'
+ '然后另起一行单独输出 ==REQ==;\n'
+ '接着输出 5 行需求清单(仍未确定的项留空值,严禁编造;情节/结尾/时长不由本清单承载,由创作阶段按人设与选题自决;共鸣/共情亦不收集,创作时由 AI 依痛点 / 爽点自然发挥),模板:\n'
+ '2. 若关键维度仍有缺口且本轮输入不足以补齐:最多追问 1~2 个问题,不要一次抛出全部问题,也不要重复已明确的维度。追问须给出可点选的候选,供达人一键选用。\n'
+ '3. 候选来源受控(只能出自以下词表,严禁编造清单外选项):\n'
+ ' - 叙事结构未定 → 候选:钩子前置 / 悬念反转 / 递进铺垫 / 先抑后扬\n'
+ ' - 内容形式(叙事形态)未定 → 候选:剧情(场次叙事,多角色对白)/ Vlog 纪实·人物采访(第一视角)/ 口播(干货·教程·盘点)/ 视觉短片(画面+音乐驱动,测评种草或纯氛围,台词极少)\n'
+ ' - 达人倾向剧情演绎(含多角色/冲突反转的意向)但人物与关系未明 → 候选追问主要人物身份 / 人物间关系(如:父子 / 夫妻 / 同事 / 陌生冲突),一次只问一个层面\n'
+ '4. 若信息已基本齐备:用一两句话总结并提示用户可点击「提交创作」,无需输出候选。\n'
+ '5. 输出三段结构,顺序固定:\n'
+ ' 第一段:对用户说的话(120 字以内,含必要时的问题);\n'
+ ' 若本轮提供了候选:另起一行单独输出 ==OPTS==,每行一个候选、直接是达人可原样回复的短句(≤30 字、不含编号前缀);若未提供候选则不输出该段;\n'
+ ' 最后另起一行单独输出 ==REQ==,接着输出 5 行需求清单(仍未确定的项留空值,严禁编造;情节/结尾/时长不由本清单承载,由创作阶段按人设与选题自决;共鸣/共情亦不收集,创作时由 AI 依痛点 / 爽点自然发挥),模板:\n'
+ '- 一句话创意:\n- 创新 / 爆点:\n- 痛点 / 爽点:\n- 叙事结构:\n- 其他约束:\n'
+ '5. 需求框只收集本条视频的选题相关信息;账号级设定(情绪基调 / 目标受众 / 结构性符号 / 广告植入等)已由账号设定自动带入创作,不要向达人重复询问这些维度——仅当达人主动提出与账号设定不同的要求时,将其记入「其他约束」。';
+ ' 达人点击候选后,把该选择吸收进对应字段(如内容形式选「视觉短片」→ 写入叙事结构行并注明画面驱动;人物选择 → 写入一句话创意行的事件或人物信息)。\n'
+ '6. 需求框只收集本条视频的选题相关信息;账号级设定(情绪基调 / 目标受众 / 结构性符号 / 广告植入等)已由账号设定自动带入创作,不要向达人重复询问这些维度——仅当达人主动提出与账号设定不同的要求时,将其记入「其他约束」。';
// 4) Dify blocking 调用
const difyUrl = String(process.env.DIFY_URL || 'https://mydify.youmanvideo.com/v1').replace(/\/+$/, '');
const ctrl = new AbortController();