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:
1 parent
047f2d0b9c
commit
a92d84dcc9
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;
|
||||
|
||||
Reference in new issue
Block a user