feat(workshop): AI创作弹窗v4-上传需求附件(doc/docx/txt/pdf)→AI打磨携带+创作上下文

* attachment-parser.js 零依赖(node:zlib)txt/docx/pdf纯文本提取;.doc明确400报错
* server.js 新增 POST /api/req/parse-attachment(20MB/30MB/3万字符限);
  /api/ai/clarify 入参增 attach 拼【附件素材】段(2万字符)
* data-pages.js confirmAiScript:req-input-box内左侧回形针(新ICON_PATHS paperclip)
  + 隐藏 file input + attach chip 行(paperclip+文件名+字数+×);
  空输入但有附件自动发引导语;sendTurn 携带 attach;
  buildCreatePrompt 增第5参 attachText→【附件素材】段并入创作 prompt
* style.css:req-attach-row/req-chip/chip-x/req-paper 系列样式;req-input-box padding-left 调
* 端到端 4 路径 API 验证 OK;CDP 9224 冒烟(纯Runtime+DataTransfer 注入 + 持久WS)全绿;
  真实 Dify 一轮打磨确认附件诉求已吸收进需求清单
* 测试样例 tmp/attach-tests/{req.txt,req.docx,old.doc}.b64(make_samples.py)
  9-02 16:31 已改中立内容,避免冒烟带真实账号名
This commit is contained in:
maogeigei committed 2026-09-02 16:31:57 +08:00
1 parent e0dec07721
commit 50bbed30f7
5 files changed
+294 -13

No files matched your search

@@ -168,6 +168,7 @@ const ICON_PATHS = {
'x': '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>',
'arrow-up': '<path d="m5 12 7-7 7 7"/><path d="M12 19V5"/>',
'check': '<path d="M20 6 9 17l-5-5"/>',
'paperclip': '<path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 18 8.84l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48"/>',
'check-square': '<polyline points="9 11 12 14 22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/>',
'trash': '<path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/>',
'zap': '<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/>',
@@ -186,14 +186,16 @@ function reqMdOf(w) {
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) {
// 组装创作 prompt:人设块在前(既有铁律),需求+附件素材在后;空维度不输出由 AI 自主决策
function buildCreatePrompt(accountName, w, personaBlock, hasPersona, attachText) {
const req = reqLines(w);
const reqSec = req.length ? `\n【创作需求】\n${req.join('\n')}` : '';
const at = String(attachText || '').trim();
const attachSec = at ? `\n【附件素材】(达人上传的需求文档/资料原文,创作时须充分参考,其中的诉求与约束同【创作需求】一样必须满足)\n${at}` : '';
const tail = hasPersona
? `请贴合上述账号设定(人设与内容规则)创作;【创作需求】中填写的各项为明确要求、必须满足,未填写的维度由你按人设与选题自主决策。`
? `请贴合上述账号设定(人设与内容规则)创作;【创作需求】与【附件素材】中提出的各项为明确要求、必须满足,未填写的维度由你按人设与选题自主决策。`
: `该账号暂无账号设定,请先为达人「${accountName}」提取账号设定(生成人设卡),再基于账号设定与【创作需求】创作完整脚本;未填写的维度由你自主决策。`;
return `请为达人「${accountName}」创作一条短视频脚本。${personaBlock}${reqSec}\n${tail}`;
return `请为达人「${accountName}」创作一条短视频脚本。${personaBlock}${reqSec}${attachSec}\n${tail}`;
}
/* AI创作 弹窗(09-02 v2:需求框 + 输入框 对话式打磨)——
@@ -204,6 +206,8 @@ async function confirmAiScript(accountName, btn) {
const w = { idea: '', form: '', dur: '', mood: '', aud: '', plot: '', end: '', sym: '', ad: '', note: '' };
const convo = []; // 对话流 { role: 'u' | 'ai', text }
let pHas = false, pContent = '';
let attach = { name: '', text: '' }; // 已上传需求附件(解析后的纯文本,打磨与创作共用)
let parsing = false; // 附件解析中(防重入)
let busy = false;
const loadPersona = async () => {
if (pContent || !accId) return;
@@ -219,16 +223,19 @@ 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="req-head">在下方输入想法,AI 会边聊边把需求整理到上方「创作需求」里;打磨满意后点输入框左侧的 ✓ 提交创作。Enter 直接发送,Shift+Enter 换行。</div>
<div class="req-head">输入想法,或点回形针上传需求文档(doc / docx / txt / pdf),AI 会把需求整理到上方「创作需求」里;打磨满意后点左侧的 ✓ 提交创作(附件原文会一并作为创作上下文)。Enter 发送,Shift+Enter 换行。</div>
<div class="req-sheet" id="reqSheet"></div>
<div class="req-dialog" id="reqDialog"></div>
<div class="req-attach-row" id="attachRow"></div>
<div class="req-bar">
<button type="button" class="req-create" id="reqCreate" title="提交创作(生成完整脚本)">${svgIcon('check', 20)}</button>
<div class="req-input-box">
<textarea id="reqInput" class="req-input" rows="1" placeholder="说说你的想法,例如:想拍一个亲子互动的搞笑短剧…"></textarea>
<button type="button" class="req-paper" id="reqPaper" title="上传需求附件(doc / docx / txt / pdf)">${svgIcon('paperclip', 17)}</button>
<textarea id="reqInput" class="req-input" rows="1" placeholder="说说你的想法,例如:想拍一个亲子互动的搞笑短剧…(也可以先上传需求文档)"></textarea>
<button type="button" class="req-send" id="reqSend" title="发送">${svgIcon('arrow-up', 18)}</button>
</div>
</div>
<input type="file" id="attachInput" accept=".doc,.docx,.txt,.pdf" hidden>
</div>`;
document.body.appendChild(mask);
const close = () => mask.remove();
@@ -259,10 +266,62 @@ async function confirmAiScript(accountName, btn) {
const s = q('#reqSend');
if (s) { s.disabled = b; s.title = b ? 'AI 整理中…' : '发送'; s.innerHTML = b ? '<span class="req-send-busy"></span>' : svgIcon('arrow-up', 18); }
};
/* —— 附件:chip 渲染 / 上传解析(doc/docx/txt/pdf → 服务端提取纯文本,解析后并入打磨与创作上下文) —— */
const renderAttach = () => {
const row = q('#attachRow');
if (!attach.name) { row.hidden = true; row.innerHTML = ''; return; }
row.hidden = false;
row.innerHTML = `<div class="req-chip"><span class="chip-ic">${svgIcon('paperclip', 13)}</span><span class="chip-name" title="${esc(attach.name)}">${esc(attach.name)}</span><span class="chip-meta">${esc(attach.meta || '')}</span><button type="button" class="chip-x" id="chipRemove" title="移除附件">${svgIcon('x', 13)}</button></div>`;
q('#chipRemove').addEventListener('click', () => {
attach = { name: '', text: '' };
renderAttach();
showToast('已移除附件');
});
};
const fileToBase64 = (f) => new Promise((res, rej) => {
const rd = new FileReader();
rd.onload = () => res(String(rd.result || '').split(',')[1] || '');
rd.onerror = () => rej(rd.error || new Error('读取文件失败'));
rd.readAsDataURL(f);
});
const attachInput = q('#attachInput');
const paperBtn = q('#reqPaper');
attachInput.addEventListener('change', async () => {
const f = attachInput.files && attachInput.files[0];
attachInput.value = ''; // 允许重复选择同一文件(重选即替换)
if (!f || parsing) return;
const ext = (String(f.name).split('.').pop() || '').toLowerCase();
if (!['doc', 'docx', 'txt', 'pdf'].includes(ext)) { showToast('❌ 仅支持 .doc / .docx / .txt / .pdf'); return; }
if (f.size > 20 * 1024 * 1024) { showToast('❌ 文件超过 20MB 上限'); return; }
parsing = true; paperBtn.disabled = true; paperBtn.classList.add('loading');
try {
showToast('⏳ 正在解析附件…');
const base64 = await fileToBase64(f);
const r = await api('/api/req/parse-attachment', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: f.name, data: base64 }),
});
if (r.error) throw new Error(r.error);
const text = String(r.text || '').trim();
if (!text) throw new Error('未能提取到文本(文件可能为空或纯图片/扫描件)');
attach = { name: f.name, text, meta: (r.chars || text.length) + ' 字' + (r.warn ? ' · 部分内容可能乱码' : '') };
renderAttach();
showToast('✅ 附件已载入:' + attach.name + ',可直接输入想法或直接发送让 AI 通读');
} catch (e) {
showToast('❌ 附件解析失败: ' + e.message);
}
parsing = false; paperBtn.disabled = false; paperBtn.classList.remove('loading');
});
paperBtn.addEventListener('click', () => { if (!parsing) attachInput.click(); });
/* —— 发送一轮:调 /api/ai/clarify(同步打磨,返回 reply + ==REQ== 段) —— */
const sendTurn = async (raw) => {
const text = String(raw || '').trim();
if (!text || busy) return;
let text = String(raw || '').trim();
if (busy) return;
if (!text) {
if (!attach.text) return; // 无输入且无附件:不响应
text = '(我已上传需求附件)请通读附件内容,把其中可落到创作的诉求与约束整理进需求清单;仍缺的关键信息可以问我。';
}
convo.push({ role: 'u', text });
q('#reqInput').value = '';
autoGrow();
@@ -281,7 +340,7 @@ async function confirmAiScript(accountName, btn) {
const r = await api('/api/ai/clarify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ accountName, turn: text, reqMd: reqMdOf(w), history }),
body: JSON.stringify({ accountName, turn: text, reqMd: reqMdOf(w), history, attach: attach.text }),
});
if (r.error) throw new Error(r.error);
const answer = String(r.answer || '').trim();
@@ -321,7 +380,7 @@ async function confirmAiScript(accountName, btn) {
q('#reqCreate').addEventListener('click', async () => {
if (busy) return;
await loadPersona();
const prompt = buildCreatePrompt(accountName, w, personaBlock(), pHas);
const prompt = buildCreatePrompt(accountName, w, personaBlock(), pHas, attach.text);
close();
executeTask({ prompt, label: '创作指令', btn, skills: ['短视频脚本创作'] });
});
@@ -249,8 +249,8 @@ main { height: calc(100vh - 54px); }
}
.req-create:hover { border-color: var(--primary); background: var(--primary-soft); }
.req-input-box {
flex: 1; display: flex; align-items: flex-end; gap: 6px;
border: 1px solid var(--border); border-radius: 12px; padding: 5px 5px 5px 13px;
flex: 1; display: flex; align-items: flex-end; gap: 4px;
border: 1px solid var(--border); border-radius: 12px; padding: 5px 5px 5px 2px;
background: #fff; transition: border-color .15s, box-shadow .15s;
}
.req-input-box:focus-within { border-color: var(--primary); box-shadow: 0 0 0 3px var(--primary-soft); }
@@ -273,6 +273,30 @@ main { height: calc(100vh - 54px); }
display: inline-block; animation: req-spin .8s linear infinite;
}
@keyframes req-spin { to { transform: rotate(360deg); } }
/* 附件:chip 行(对话流与输入条之间)+ 回形针上传钮(输入框区域左侧) */
.req-attach-row { flex: none; min-height: 0; }
.req-chip {
display: inline-flex; align-items: center; gap: 7px; max-width: 100%;
background: var(--primary-soft); border: 1px solid #d3e1f6; border-radius: 999px;
padding: 3px 6px 3px 10px; font-size: 12.5px; color: var(--primary);
}
.chip-ic { display: inline-flex; flex: none; }
.chip-name { max-width: 320px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 600; }
.chip-meta { flex: none; color: #7a8494; font-size: 11.5px; }
.chip-x {
flex: none; display: inline-flex; align-items: center; justify-content: center;
width: 18px; height: 18px; border: none; border-radius: 50%;
background: transparent; color: var(--primary); cursor: pointer; padding: 0;
}
.chip-x:hover { background: rgba(30, 70, 180, .12); }
.req-paper {
flex: none; display: inline-flex; align-items: center; justify-content: center;
width: 28px; height: 32px; padding: 0; border: none; border-radius: 8px;
background: transparent; color: #8a93a3; cursor: pointer;
}
.req-paper:hover:not(:disabled) { color: var(--primary); background: var(--primary-soft); }
.req-paper:disabled { cursor: default; opacity: .6; }
.req-paper.loading { color: var(--primary); animation: req-spin 1.1s linear infinite; }
.root-list { display: flex; flex-direction: column; gap: 6px; margin-bottom: 12px; }
.root-item {
display: flex; align-items: center; gap: 8px;