diff --git a/project/短视频脚本创作/V1.0/mcn-work-shop/public/app.js b/project/短视频脚本创作/V1.0/mcn-work-shop/public/app.js
index 499533c..cfd7cf2 100644
--- a/project/短视频脚本创作/V1.0/mcn-work-shop/public/app.js
+++ b/project/短视频脚本创作/V1.0/mcn-work-shop/public/app.js
@@ -168,6 +168,7 @@ const ICON_PATHS = {
'x': '',
'arrow-up': '',
'check': '',
+ 'paperclip': '',
'check-square': '',
'trash': '',
'zap': '',
diff --git a/project/短视频脚本创作/V1.0/mcn-work-shop/public/data-pages.js b/project/短视频脚本创作/V1.0/mcn-work-shop/public/data-pages.js
index b007dfd..02ec10d 100644
--- a/project/短视频脚本创作/V1.0/mcn-work-shop/public/data-pages.js
+++ b/project/短视频脚本创作/V1.0/mcn-work-shop/public/data-pages.js
@@ -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 = `
AI创作 · ${esc(accountName)}
-
在下方输入想法,AI 会边聊边把需求整理到上方「创作需求」里;打磨满意后点输入框左侧的 ✓ 提交创作。Enter 直接发送,Shift+Enter 换行。
+
输入想法,或点回形针上传需求文档(doc / docx / txt / pdf),AI 会把需求整理到上方「创作需求」里;打磨满意后点左侧的 ✓ 提交创作(附件原文会一并作为创作上下文)。Enter 发送,Shift+Enter 换行。
+
-
+
+
+
`;
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 ? '' : 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 = `${svgIcon('paperclip', 13)}${esc(attach.name)}${esc(attach.meta || '')}
`;
+ 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: ['短视频脚本创作'] });
});
diff --git a/project/短视频脚本创作/V1.0/mcn-work-shop/public/style.css b/project/短视频脚本创作/V1.0/mcn-work-shop/public/style.css
index 2757c1f..0694b94 100644
--- a/project/短视频脚本创作/V1.0/mcn-work-shop/public/style.css
+++ b/project/短视频脚本创作/V1.0/mcn-work-shop/public/style.css
@@ -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;
diff --git a/project/短视频脚本创作/V1.0/mcn-work-shop/scripts/attachment-parser.js b/project/短视频脚本创作/V1.0/mcn-work-shop/scripts/attachment-parser.js
new file mode 100644
index 0000000..047ec98
--- /dev/null
+++ b/project/短视频脚本创作/V1.0/mcn-work-shop/scripts/attachment-parser.js
@@ -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 → 抽 ---------- */
+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(/]*\/>/g, '\t')
+ .replace(/]*\/>/g, '\n')
+ .replace(/<\/w:p\s*>/g, '\n')
+ .replace(/]*)?>([\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 };
diff --git a/project/短视频脚本创作/V1.0/mcn-work-shop/server.js b/project/短视频脚本创作/V1.0/mcn-work-shop/server.js
index 4749107..79f6d61 100644
--- a/project/短视频脚本创作/V1.0/mcn-work-shop/server.js
+++ b/project/短视频脚本创作/V1.0/mcn-work-shop/server.js
@@ -10,6 +10,7 @@ const os = require('os');
const { execFileSync } = require('child_process');
const { URL } = require('url');
const dsh = require('./dsh-data');
+const { parseAttachment } = require('./scripts/attachment-parser');
// 09-01 默认端口由 8899 改为 8900:本机 8899 被小米 PC 管家 MiPCAudio.exe 系统服务占用(0.0.0.0 监听且自动复活),
// 此时 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('end', async () => {
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 (!turn || typeof turn !== 'string' || !turn.trim()) return sendErr(400, '缺少用户输入');
// 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 histText = his.map((h) => '你:' + String(h.a || '') + '\n达人:' + String(h.u || '')).join('\n');
const reqMdText = String(reqMd || '').trim();
+ const attachText = String(attach || '').trim().slice(0, 20000);
const query = '你是资深短视频编导,正在创作前通过多轮对话,帮达人「' + accountName + '」把创作需求打磨清楚。'
+ '你的职责只限需求澄清、整理与追问;严禁生成脚本正文、台词、分镜或完整故事。\n\n'
+ '【账号设定摘要】\n' + (personaText || '(暂无可用账号设定,按通用编导经验澄清)') + '\n'
+ (contentText ? '【账号定位】\n' + contentText + '\n' : '')
+ + (attachText ? '【附件素材】(达人上传的需求文档/资料,打磨与最终创作都须充分参考;把其中的关键诉求/约束吸收进需求清单)\n' + attachText + '\n' : '')
+ '【当前需求清单】\n' + (reqMdText || '(空:等待用户先给出初步想法)') + '\n'
+ (histText ? '【对话历史】\n' + histText + '\n' : '')
+ '【达人本轮输入】\n' + turn.trim() + '\n\n'
@@ -727,6 +730,27 @@ const server = http.createServer((req, res) => {
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
if (p === '/api/import/account' && req.method === 'POST') {
let body = '';