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"/>',
|
'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"/>',
|
'arrow-up': '<path d="m5 12 7-7 7 7"/><path d="M12 19V5"/>',
|
||||||
'check': '<path d="M20 6 9 17l-5-5"/>',
|
'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"/>',
|
'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"/>',
|
'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"/>',
|
'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) {
|
function reqLines(w) {
|
||||||
return REQ_FIELDS.filter(([k]) => String(w[k] || '').trim()).map(([k, label]) => `- ${label}:${String(w[k]).trim()}`);
|
return REQ_FIELDS.filter(([k]) => String(w[k] || '').trim()).map(([k, label]) => `- ${label}:${String(w[k]).trim()}`);
|
||||||
}
|
}
|
||||||
// 组装创作 prompt:人设块在前(既有铁律),向导需求在后;空维度不输出由 AI 自主决策
|
// 组装创作 prompt:人设块在前(既有铁律),需求+附件素材在后;空维度不输出由 AI 自主决策
|
||||||
function buildCreatePrompt(accountName, w, personaBlock, hasPersona) {
|
function buildCreatePrompt(accountName, w, personaBlock, hasPersona, attachText) {
|
||||||
const req = reqLines(w);
|
const req = reqLines(w);
|
||||||
const reqSec = req.length ? `\n【创作需求】\n${req.join('\n')}` : '';
|
const reqSec = req.length ? `\n【创作需求】\n${req.join('\n')}` : '';
|
||||||
|
const at = String(attachText || '').trim();
|
||||||
|
const attachSec = at ? `\n【附件素材】(达人上传的需求文档/资料原文,创作时须充分参考,其中的诉求与约束同【创作需求】一样必须满足)\n${at}` : '';
|
||||||
const tail = hasPersona
|
const tail = hasPersona
|
||||||
? `请贴合上述账号设定(人设与内容规则)创作;【创作需求】中填写的各项为明确要求、必须满足,未填写的维度由你按人设与选题自主决策。`
|
? `请贴合上述账号设定(人设与内容规则)创作;【创作需求】与【附件素材】中提出的各项为明确要求、必须满足,未填写的维度由你按人设与选题自主决策。`
|
||||||
: `该账号暂无账号设定,请先为达人「${accountName}」提取账号设定(生成人设卡),再基于账号设定与【创作需求】创作完整脚本;未填写的维度由你自主决策。`;
|
: `该账号暂无账号设定,请先为达人「${accountName}」提取账号设定(生成人设卡),再基于账号设定与【创作需求】创作完整脚本;未填写的维度由你自主决策。`;
|
||||||
return `请为达人「${accountName}」创作一条短视频脚本。${personaBlock}${reqSec}\n${tail}`;
|
return `请为达人「${accountName}」创作一条短视频脚本。${personaBlock}${reqSec}${attachSec}\n${tail}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* AI创作 弹窗(09-02 v2:需求框 + 输入框 对话式打磨)——
|
/* 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 w = { idea: '', form: '', dur: '', mood: '', aud: '', plot: '', end: '', sym: '', ad: '', note: '' };
|
||||||
const convo = []; // 对话流 { role: 'u' | 'ai', text }
|
const convo = []; // 对话流 { role: 'u' | 'ai', text }
|
||||||
let pHas = false, pContent = '';
|
let pHas = false, pContent = '';
|
||||||
|
let attach = { name: '', text: '' }; // 已上传需求附件(解析后的纯文本,打磨与创作共用)
|
||||||
|
let parsing = false; // 附件解析中(防重入)
|
||||||
let busy = false;
|
let busy = false;
|
||||||
const loadPersona = async () => {
|
const loadPersona = async () => {
|
||||||
if (pContent || !accId) return;
|
if (pContent || !accId) return;
|
||||||
@@ -219,16 +223,19 @@ async function confirmAiScript(accountName, btn) {
|
|||||||
const mask = document.createElement('div'); mask.className = 'modal-mask';
|
const mask = document.createElement('div'); mask.className = 'modal-mask';
|
||||||
mask.innerHTML = `<div class="modal req-modal">
|
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><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-sheet" id="reqSheet"></div>
|
||||||
<div class="req-dialog" id="reqDialog"></div>
|
<div class="req-dialog" id="reqDialog"></div>
|
||||||
|
<div class="req-attach-row" id="attachRow"></div>
|
||||||
<div class="req-bar">
|
<div class="req-bar">
|
||||||
<button type="button" class="req-create" id="reqCreate" title="提交创作(生成完整脚本)">${svgIcon('check', 20)}</button>
|
<button type="button" class="req-create" id="reqCreate" title="提交创作(生成完整脚本)">${svgIcon('check', 20)}</button>
|
||||||
<div class="req-input-box">
|
<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>
|
<button type="button" class="req-send" id="reqSend" title="发送">${svgIcon('arrow-up', 18)}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<input type="file" id="attachInput" accept=".doc,.docx,.txt,.pdf" hidden>
|
||||||
</div>`;
|
</div>`;
|
||||||
document.body.appendChild(mask);
|
document.body.appendChild(mask);
|
||||||
const close = () => mask.remove();
|
const close = () => mask.remove();
|
||||||
@@ -259,10 +266,62 @@ async function confirmAiScript(accountName, btn) {
|
|||||||
const s = q('#reqSend');
|
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); }
|
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== 段) —— */
|
/* —— 发送一轮:调 /api/ai/clarify(同步打磨,返回 reply + ==REQ== 段) —— */
|
||||||
const sendTurn = async (raw) => {
|
const sendTurn = async (raw) => {
|
||||||
const text = String(raw || '').trim();
|
let text = String(raw || '').trim();
|
||||||
if (!text || busy) return;
|
if (busy) return;
|
||||||
|
if (!text) {
|
||||||
|
if (!attach.text) return; // 无输入且无附件:不响应
|
||||||
|
text = '(我已上传需求附件)请通读附件内容,把其中可落到创作的诉求与约束整理进需求清单;仍缺的关键信息可以问我。';
|
||||||
|
}
|
||||||
convo.push({ role: 'u', text });
|
convo.push({ role: 'u', text });
|
||||||
q('#reqInput').value = '';
|
q('#reqInput').value = '';
|
||||||
autoGrow();
|
autoGrow();
|
||||||
@@ -281,7 +340,7 @@ async function confirmAiScript(accountName, btn) {
|
|||||||
const r = await api('/api/ai/clarify', {
|
const r = await api('/api/ai/clarify', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
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);
|
if (r.error) throw new Error(r.error);
|
||||||
const answer = String(r.answer || '').trim();
|
const answer = String(r.answer || '').trim();
|
||||||
@@ -321,7 +380,7 @@ async function confirmAiScript(accountName, btn) {
|
|||||||
q('#reqCreate').addEventListener('click', async () => {
|
q('#reqCreate').addEventListener('click', async () => {
|
||||||
if (busy) return;
|
if (busy) return;
|
||||||
await loadPersona();
|
await loadPersona();
|
||||||
const prompt = buildCreatePrompt(accountName, w, personaBlock(), pHas);
|
const prompt = buildCreatePrompt(accountName, w, personaBlock(), pHas, attach.text);
|
||||||
close();
|
close();
|
||||||
executeTask({ prompt, label: '创作指令', btn, skills: ['短视频脚本创作'] });
|
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-create:hover { border-color: var(--primary); background: var(--primary-soft); }
|
||||||
.req-input-box {
|
.req-input-box {
|
||||||
flex: 1; display: flex; align-items: flex-end; gap: 6px;
|
flex: 1; display: flex; align-items: flex-end; gap: 4px;
|
||||||
border: 1px solid var(--border); border-radius: 12px; padding: 5px 5px 5px 13px;
|
border: 1px solid var(--border); border-radius: 12px; padding: 5px 5px 5px 2px;
|
||||||
background: #fff; transition: border-color .15s, box-shadow .15s;
|
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); }
|
.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;
|
display: inline-block; animation: req-spin .8s linear infinite;
|
||||||
}
|
}
|
||||||
@keyframes req-spin { to { transform: rotate(360deg); } }
|
@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-list { display: flex; flex-direction: column; gap: 6px; margin-bottom: 12px; }
|
||||||
.root-item {
|
.root-item {
|
||||||
display: flex; align-items: center; gap: 8px;
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
/* attachment-parser.js — 需求附件解析(零依赖 Node)
|
||||||
|
* 支持:.txt(UTF-8)/ .docx(zip → word/document.xml)/ .pdf(FlateDecode 内容流文本提取)
|
||||||
|
* .doc 老格式(OLE2 复合文档)不支持,返回明确提示
|
||||||
|
* 返回:{ text, chars, warn? } 或 { error }
|
||||||
|
* 说明:附件仅用于需求澄清/创作上下文,非安全执行环境——只做文本提取,不落盘、不渲染
|
||||||
|
*/
|
||||||
|
'use strict';
|
||||||
|
const zlib = require('node:zlib');
|
||||||
|
|
||||||
|
const XML_ENT = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'" };
|
||||||
|
|
||||||
|
function xmlUnescape(s) {
|
||||||
|
return String(s).replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z]+);/g, (all, code) => {
|
||||||
|
if (code[0] === '#') {
|
||||||
|
const n = code[1] === 'x' || code[1] === 'X' ? parseInt(code.slice(2), 16) : parseInt(code.slice(1), 10);
|
||||||
|
try { return String.fromCodePoint(n); } catch (e) { return ''; }
|
||||||
|
}
|
||||||
|
return XML_ENT[code] !== undefined ? XML_ENT[code] : all;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- .txt ---------- */
|
||||||
|
function parseTxt(buf) {
|
||||||
|
const raw = buf.toString('utf8');
|
||||||
|
// 粗略检测:若大量 U+FFFD 替换符,说明不是 UTF-8(常见 GBK 中文 txt)
|
||||||
|
const bad = (raw.match(/\uFFFD/g) || []).length;
|
||||||
|
const warn = bad > 0
|
||||||
|
? '检测到非 UTF-8 编码内容,可能乱码——建议将文档另存为 UTF-8 编码后重传(当前按 UTF-8 读取)'
|
||||||
|
: '';
|
||||||
|
return { text: raw, chars: raw.length, warn };
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- .docx:zip 中央目录定位 → 找 word/document.xml → inflateRaw → 抽 <w:t> ---------- */
|
||||||
|
function parseDocx(buf) {
|
||||||
|
// 1) 找 EOCD(从尾部向前 64KB 内)
|
||||||
|
let eocd = -1;
|
||||||
|
const from = Math.max(0, buf.length - 22 - 65536);
|
||||||
|
for (let i = buf.length - 22; i >= from; i--) {
|
||||||
|
if (buf.readUInt32LE(i) === 0x06054b50) { eocd = i; break; }
|
||||||
|
}
|
||||||
|
if (eocd < 0) return { error: 'docx 解析失败:不是有效的压缩包文件' };
|
||||||
|
const total = buf.readUInt16LE(eocd + 10);
|
||||||
|
let cd = buf.readUInt32LE(eocd + 16);
|
||||||
|
let xml = null;
|
||||||
|
for (let i = 0; i < total && cd + 46 <= buf.length; i++) {
|
||||||
|
if (buf.readUInt32LE(cd) !== 0x02014b50) break; // central directory header 签名
|
||||||
|
const compSize = buf.readUInt32LE(cd + 20);
|
||||||
|
const nameLen = buf.readUInt16LE(cd + 28);
|
||||||
|
const extraLen = buf.readUInt16LE(cd + 30);
|
||||||
|
const commentLen = buf.readUInt16LE(cd + 32);
|
||||||
|
const localOff = buf.readUInt32LE(cd + 42);
|
||||||
|
const fname = buf.slice(cd + 46, cd + 46 + nameLen).toString('utf8');
|
||||||
|
if (fname === 'word/document.xml') {
|
||||||
|
const lhNameLen = buf.readUInt16LE(localOff + 26);
|
||||||
|
const lhExtraLen = buf.readUInt16LE(localOff + 28);
|
||||||
|
const dataStart = localOff + 30 + lhNameLen + lhExtraLen;
|
||||||
|
if (dataStart + compSize > buf.length) return { error: 'docx 解析失败:正文数据越界' };
|
||||||
|
try {
|
||||||
|
xml = zlib.inflateRawSync(buf.slice(dataStart, dataStart + compSize)).toString('utf8');
|
||||||
|
} catch (e) {
|
||||||
|
return { error: 'docx 解析失败:正文解压出错(文件可能损坏)' };
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
cd += 46 + nameLen + extraLen + commentLen;
|
||||||
|
}
|
||||||
|
if (!xml) return { error: 'docx 解析失败:未找到正文(word/document.xml)' };
|
||||||
|
// 2) 抽文本:段尾 → 换行;w:t 内容保留;其余标签剔除;解码实体
|
||||||
|
const text = xmlUnescape(
|
||||||
|
xml
|
||||||
|
.replace(/<w:tab[^>]*\/>/g, '\t')
|
||||||
|
.replace(/<w:br[^>]*\/>/g, '\n')
|
||||||
|
.replace(/<\/w:p\s*>/g, '\n')
|
||||||
|
.replace(/<w:t(?:\s[^>]*)?>([\s\S]*?)<\/w:t>/g, '$1')
|
||||||
|
.replace(/<[^>]+>/g, '')
|
||||||
|
)
|
||||||
|
.replace(/\n{3,}/g, '\n\n') // 压掉连续空行
|
||||||
|
.replace(/[ \t]+\n/g, '\n')
|
||||||
|
.trim();
|
||||||
|
if (!text) return { error: 'docx 未提取到文本内容(文档可能为空或全为图片)' };
|
||||||
|
return { text, chars: text.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- .pdf:对象流扫描 → FlateDecode 解压 → BT..ET 内 Tj/TJ 文本 ----------
|
||||||
|
* 局限:扫描件(无文本层)无法提取;CID/非 Unicode 编码的中文 PDF 文本可能乱码
|
||||||
|
*/
|
||||||
|
function unescapePdfString(s) {
|
||||||
|
// 处理括号字符串中的转义
|
||||||
|
return String(s)
|
||||||
|
.replace(/\\([nrtbf()\\])/g, (m, ch) => ({ n: '\n', r: '\r', t: '\t', b: '\b', f: '\f', '(': '(', ')': ')', '\\': '\\' })[ch])
|
||||||
|
.replace(/\\([0-7]{1,3})/g, (m, oct) => String.fromCharCode(parseInt(oct, 8)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePdf(buf) {
|
||||||
|
const size = buf.length;
|
||||||
|
const parts = [];
|
||||||
|
const warn = [];
|
||||||
|
let searchFrom = 0;
|
||||||
|
// 逐对象扫描 stream 块(latin1 定位不影响二进制偏移,仅用其索引用 Buffer 切片)
|
||||||
|
const latin = buf.toString('latin1');
|
||||||
|
let idx = latin.indexOf('stream', 0);
|
||||||
|
while (idx >= 0) {
|
||||||
|
// stream 后通常是 \r\n 或 \n
|
||||||
|
let start = idx + 6;
|
||||||
|
while (start < size && (buf[start] === 0x0d || buf[start] === 0x0a)) start++;
|
||||||
|
const endTag = latin.indexOf('endstream', start);
|
||||||
|
if (endTag < 0) break;
|
||||||
|
// 判定该 stream 是否属于内容流(往前找最近的 /Filter /FlateDecode)
|
||||||
|
const before = latin.slice(Math.max(0, idx - 400), idx);
|
||||||
|
const raw = buf.slice(start, endTag);
|
||||||
|
if (/\/Filter\s*\/FlateDecode/.test(before)) {
|
||||||
|
try {
|
||||||
|
const dec = zlib.inflateSync(raw);
|
||||||
|
parts.push(dec.toString('latin1'));
|
||||||
|
} catch (e) { /* 单流失败跳过(图像流常见) */ }
|
||||||
|
}
|
||||||
|
searchFrom = endTag + 9;
|
||||||
|
idx = latin.indexOf('stream', searchFrom);
|
||||||
|
}
|
||||||
|
if (!parts.length) {
|
||||||
|
// 可能有未压缩文本流
|
||||||
|
return { error: 'PDF 未提取到可解析的文本层(可能是扫描件/图片型 PDF,或需压缩流解析)' };
|
||||||
|
}
|
||||||
|
// 在解压内容流里抽 Tj / TJ 文本
|
||||||
|
const out = [];
|
||||||
|
for (const content of parts) {
|
||||||
|
// BT ... ET 之间的文本算子
|
||||||
|
let rest = content;
|
||||||
|
let m;
|
||||||
|
// 简单正则:([…]或<…>)Tj / TJ 数组
|
||||||
|
const re = /(?:\[((?:[^\]\\]|\\.)*)\]|\(((?:[^()\\]|\\.)*)\)|<([0-9A-Fa-f\s]*)>)\s*(Tj|TJ)/g;
|
||||||
|
while ((m = re.exec(rest)) !== null) {
|
||||||
|
if (m[1] !== undefined) { // TJ 数组:多段拼接
|
||||||
|
const arr = m[1];
|
||||||
|
const pieces = [];
|
||||||
|
const pm = /\((?:[^()\\]|\\.)*\)|<([0-9A-Fa-f\s]*)>/g;
|
||||||
|
let q;
|
||||||
|
while ((q = pm.exec(arr)) !== null) {
|
||||||
|
if (q[0][0] === '(') pieces.push(unescapePdfString(q[0].slice(1, -1)));
|
||||||
|
else pieces.push(Buffer.from(q[1].replace(/\s+/g, ''), 'hex').toString('latin1'));
|
||||||
|
}
|
||||||
|
out.push(pieces.join(''));
|
||||||
|
} else if (m[2] !== undefined) { // 单括号 Tj
|
||||||
|
out.push(unescapePdfString(m[2]));
|
||||||
|
} else if (m[3] !== undefined) { // hex Tj
|
||||||
|
out.push(Buffer.from(m[3].replace(/\s+/g, ''), 'hex').toString('latin1'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let text = out.join('')
|
||||||
|
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, '') // 去控制字符
|
||||||
|
.replace(/\s{3,}/g, ' ')
|
||||||
|
.trim();
|
||||||
|
if (!text) return { error: 'PDF 未提取到文本(扫描件或加密文档无法读取)' };
|
||||||
|
warn.push('PDF 提取基于文本层;若文档为扫描件/特殊编码(如部分中文 CID 字体),内容可能缺失或乱码');
|
||||||
|
return { text, chars: text.length, warn: warn.join(';') };
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 主入口 ---------- */
|
||||||
|
function parseAttachment(name, buf) {
|
||||||
|
if (!Buffer.isBuffer(buf) || !buf.length) return { error: '文件内容为空' };
|
||||||
|
const ext = (String(name || '').match(/\.([a-zA-Z0-9]+)$/) || [])[1];
|
||||||
|
if (!ext) return { error: '无法识别文件类型(请使用 .txt / .docx / .pdf 扩展名)' };
|
||||||
|
switch (ext.toLowerCase()) {
|
||||||
|
case 'txt': return parseTxt(buf);
|
||||||
|
case 'docx': return parseDocx(buf);
|
||||||
|
case 'pdf': return parsePdf(buf);
|
||||||
|
case 'doc': return { error: '暂不支持 .doc 老格式(Word 97-2003 二进制),请用 Word/WPS 另存为 .docx 或 .txt 后重传' };
|
||||||
|
default: return { error: '不支持的文件类型 .' + ext.toLowerCase() + '(支持 .txt / .docx / .pdf)' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { parseAttachment };
|
||||||
@@ -10,6 +10,7 @@ const os = require('os');
|
|||||||
const { execFileSync } = require('child_process');
|
const { execFileSync } = require('child_process');
|
||||||
const { URL } = require('url');
|
const { URL } = require('url');
|
||||||
const dsh = require('./dsh-data');
|
const dsh = require('./dsh-data');
|
||||||
|
const { parseAttachment } = require('./scripts/attachment-parser');
|
||||||
|
|
||||||
// 09-01 默认端口由 8899 改为 8900:本机 8899 被小米 PC 管家 MiPCAudio.exe 系统服务占用(0.0.0.0 监听且自动复活),
|
// 09-01 默认端口由 8899 改为 8900:本机 8899 被小米 PC 管家 MiPCAudio.exe 系统服务占用(0.0.0.0 监听且自动复活),
|
||||||
// 此时 127.0.0.1:8899 绑定不生效 → 默认直接起在 8900;仍可 `node server.js <端口>` 显式换端口
|
// 此时 127.0.0.1:8899 绑定不生效 → 默认直接起在 8900;仍可 `node server.js <端口>` 显式换端口
|
||||||
@@ -657,7 +658,7 @@ const server = http.createServer((req, res) => {
|
|||||||
req.on('data', (c) => { body += c; if (body.length > 1e6) req.destroy(); });
|
req.on('data', (c) => { body += c; if (body.length > 1e6) req.destroy(); });
|
||||||
req.on('end', async () => {
|
req.on('end', async () => {
|
||||||
try {
|
try {
|
||||||
const { accountName, turn, reqMd, history } = JSON.parse(body || '{}');
|
const { accountName, turn, reqMd, history, attach } = JSON.parse(body || '{}');
|
||||||
if (!accountName || typeof accountName !== 'string') return sendErr(400, '缺少账号名');
|
if (!accountName || typeof accountName !== 'string') return sendErr(400, '缺少账号名');
|
||||||
if (!turn || typeof turn !== 'string' || !turn.trim()) return sendErr(400, '缺少用户输入');
|
if (!turn || typeof turn !== 'string' || !turn.trim()) return sendErr(400, '缺少用户输入');
|
||||||
// 1) 账号设定摘要(工作台库,与选题接口同源)
|
// 1) 账号设定摘要(工作台库,与选题接口同源)
|
||||||
@@ -688,10 +689,12 @@ const server = http.createServer((req, res) => {
|
|||||||
const his = Array.isArray(history) ? history.filter((h) => h && h.u).slice(-2) : [];
|
const his = Array.isArray(history) ? history.filter((h) => h && h.u).slice(-2) : [];
|
||||||
const histText = his.map((h) => '你:' + String(h.a || '') + '\n达人:' + String(h.u || '')).join('\n');
|
const histText = his.map((h) => '你:' + String(h.a || '') + '\n达人:' + String(h.u || '')).join('\n');
|
||||||
const reqMdText = String(reqMd || '').trim();
|
const reqMdText = String(reqMd || '').trim();
|
||||||
|
const attachText = String(attach || '').trim().slice(0, 20000);
|
||||||
const query = '你是资深短视频编导,正在创作前通过多轮对话,帮达人「' + accountName + '」把创作需求打磨清楚。'
|
const query = '你是资深短视频编导,正在创作前通过多轮对话,帮达人「' + accountName + '」把创作需求打磨清楚。'
|
||||||
+ '你的职责只限需求澄清、整理与追问;严禁生成脚本正文、台词、分镜或完整故事。\n\n'
|
+ '你的职责只限需求澄清、整理与追问;严禁生成脚本正文、台词、分镜或完整故事。\n\n'
|
||||||
+ '【账号设定摘要】\n' + (personaText || '(暂无可用账号设定,按通用编导经验澄清)') + '\n'
|
+ '【账号设定摘要】\n' + (personaText || '(暂无可用账号设定,按通用编导经验澄清)') + '\n'
|
||||||
+ (contentText ? '【账号定位】\n' + contentText + '\n' : '')
|
+ (contentText ? '【账号定位】\n' + contentText + '\n' : '')
|
||||||
|
+ (attachText ? '【附件素材】(达人上传的需求文档/资料,打磨与最终创作都须充分参考;把其中的关键诉求/约束吸收进需求清单)\n' + attachText + '\n' : '')
|
||||||
+ '【当前需求清单】\n' + (reqMdText || '(空:等待用户先给出初步想法)') + '\n'
|
+ '【当前需求清单】\n' + (reqMdText || '(空:等待用户先给出初步想法)') + '\n'
|
||||||
+ (histText ? '【对话历史】\n' + histText + '\n' : '')
|
+ (histText ? '【对话历史】\n' + histText + '\n' : '')
|
||||||
+ '【达人本轮输入】\n' + turn.trim() + '\n\n'
|
+ '【达人本轮输入】\n' + turn.trim() + '\n\n'
|
||||||
@@ -727,6 +730,27 @@ const server = http.createServer((req, res) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 需求附件解析(09-02 新增):txt/docx/pdf → 纯文本(供 AI 打磨与创作上下文)
|
||||||
|
if (p === '/api/req/parse-attachment' && req.method === 'POST') {
|
||||||
|
let body = '';
|
||||||
|
req.on('data', (c) => { body += c; if (body.length > 30e6) req.destroy(); }); // 附件 base64 上限约 22MB 原文件
|
||||||
|
req.on('end', async () => {
|
||||||
|
try {
|
||||||
|
const { name, data } = JSON.parse(body || '{}');
|
||||||
|
if (!name || typeof name !== 'string' || !data || typeof data !== 'string') return sendErr(400, '缺少文件参数');
|
||||||
|
const buf = Buffer.from(data, 'base64');
|
||||||
|
if (buf.length > 20e6) return sendErr(413, '文件过大(上限 20MB)');
|
||||||
|
const r = parseAttachment(name, buf);
|
||||||
|
if (r.error) return sendErr(400, r.error);
|
||||||
|
const text = String(r.text || '').slice(0, 30000); // 打磨上下文上限 3 万字符
|
||||||
|
return dshOk(200, { text, chars: text.length, warn: r.warn || '', sourceChars: r.chars });
|
||||||
|
} catch (e) {
|
||||||
|
return sendErr(500, '附件解析失败: ' + e.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 分析产物同步入库(09-01 新增):扫描账号产出目录 → 写工作台库 mcn-plugin.db
|
// 分析产物同步入库(09-01 新增):扫描账号产出目录 → 写工作台库 mcn-plugin.db
|
||||||
if (p === '/api/import/account' && req.method === 'POST') {
|
if (p === '/api/import/account' && req.method === 'POST') {
|
||||||
let body = '';
|
let body = '';
|
||||||
|
|||||||
Reference in new issue
Block a user