From 50bbed30f735947e7f7fbe76b6902d026eff1a93 Mon Sep 17 00:00:00 2001 From: maogeigei Date: Wed, 2 Sep 2026 16:31:57 +0800 Subject: [PATCH] =?UTF-8?q?feat(workshop):=20AI=E5=88=9B=E4=BD=9C=E5=BC=B9?= =?UTF-8?q?=E7=AA=97v4-=E4=B8=8A=E4=BC=A0=E9=9C=80=E6=B1=82=E9=99=84?= =?UTF-8?q?=E4=BB=B6(doc/docx/txt/pdf)=E2=86=92AI=E6=89=93=E7=A3=A8?= =?UTF-8?q?=E6=90=BA=E5=B8=A6+=E5=88=9B=E4=BD=9C=E4=B8=8A=E4=B8=8B?= =?UTF-8?q?=E6=96=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 已改中立内容,避免冒烟带真实账号名 --- .../V1.0/mcn-work-shop/public/app.js | 1 + .../V1.0/mcn-work-shop/public/data-pages.js | 79 +++++++- .../V1.0/mcn-work-shop/public/style.css | 28 ++- .../scripts/attachment-parser.js | 173 ++++++++++++++++++ .../V1.0/mcn-work-shop/server.js | 26 ++- 5 files changed, 294 insertions(+), 13 deletions(-) create mode 100644 project/短视频脚本创作/V1.0/mcn-work-shop/scripts/attachment-parser.js 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 = ``; 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 = '';