Compare commits

..

3 Commits

2 changed files with 195 additions and 47 deletions

View File

@@ -322,6 +322,67 @@ async function addPendingFiles(files) {
continue; continue;
} }
const kind = getAttachmentKind(file);
let parsedText = null;
// Office 文件:前端解析提取文本
if (kind === 'docx') {
try {
const arrayBuffer = await readFileAsArrayBuffer(file);
const result = await mammoth.extractRawText({ arrayBuffer });
parsedText = result.value || '';
} catch (err) {
console.warn('mammoth 解析失败', err);
}
} else if (kind === 'xlsx') {
try {
const arrayBuffer = await readFileAsArrayBuffer(file);
const workbook = XLSX.read(arrayBuffer, { type: 'array' });
const lines = [];
workbook.SheetNames.forEach((sheetName) => {
const sheet = workbook.Sheets[sheetName];
const csv = XLSX.utils.sheet_to_csv(sheet);
if (csv.trim()) lines.push(`## Sheet: ${sheetName}\n${csv}`);
});
parsedText = lines.join('\n\n');
} catch (err) {
console.warn('SheetJS 解析失败', err);
}
} else if (kind === 'pptx') {
try {
const arrayBuffer = await readFileAsArrayBuffer(file);
// pptx 本质是 zip用 XLSX 的 zip 工具提取文本节点
const zip = XLSX.read(arrayBuffer, { type: 'array' });
const textParts = [];
Object.keys(zip.Strings || {}).forEach((k) => {
const v = zip.Strings[k];
if (typeof v === 'string' && v.trim()) textParts.push(v.trim());
});
// 更可靠的方式:直接用 JSZip-like 解包XLSX 内置 CFB/ZIP
// 若 zip.Strings 为空则给提示
parsedText = textParts.length ? textParts.join('\n') : 'PPT 文本提取失败,内容可能为空)';
} catch (err) {
console.warn('pptx 解析失败', err);
}
} else if (kind === 'pdf') {
try {
const arrayBuffer = await readFileAsArrayBuffer(file);
const pdfjsLib = window['pdfjs-dist/build/pdf'];
pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdn.jsdelivr.net/npm/pdfjs-dist@3.11.174/build/pdf.worker.min.js';
const pdf = await pdfjsLib.getDocument({ data: arrayBuffer }).promise;
const pageTexts = [];
for (let i = 1; i <= pdf.numPages; i++) {
const page = await pdf.getPage(i);
const textContent = await page.getTextContent();
const pageText = textContent.items.map((item) => item.str).join(' ');
if (pageText.trim()) pageTexts.push(`[第 ${i} 页]\n${pageText}`);
}
parsedText = pageTexts.join('\n\n') || 'PDF 文本提取为空,可能是扫描件)';
} catch (err) {
console.warn('pdf.js 解析失败', err);
}
}
const dataUrl = await readFileAsDataURL(file); const dataUrl = await readFileAsDataURL(file);
const base64 = dataUrl.split(',')[1] || ''; const base64 = dataUrl.split(',')[1] || '';
state.pendingAttachments.push({ state.pendingAttachments.push({
@@ -329,15 +390,25 @@ async function addPendingFiles(files) {
name: file.name, name: file.name,
type: file.type || 'application/octet-stream', type: file.type || 'application/octet-stream',
size: file.size, size: file.size,
kind: getAttachmentKind(file), kind,
dataUrl, dataUrl,
base64, base64,
parsedText,
}); });
} }
renderAttachments(); renderAttachments();
} }
function readFileAsArrayBuffer(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = () => reject(new Error(`读取文件失败:${file.name}`));
reader.readAsArrayBuffer(file);
});
}
function renderAttachments() { function renderAttachments() {
attachmentListEl.innerHTML = ''; attachmentListEl.innerHTML = '';
if (!state.pendingAttachments.length) return; if (!state.pendingAttachments.length) return;
@@ -452,31 +523,63 @@ function buildUserMessage(message, attachments) {
return { role: 'user', content: message.content || '' }; return { role: 'user', content: message.content || '' };
} }
const attachmentText = attachments
.map((file) => `- ${file.name} (${file.type || 'application/octet-stream'}, ${formatBytes(file.size)})`)
.join('\n');
const promptText = message.content || '请读取并处理这些附件。'; const promptText = message.content || '请读取并处理这些附件。';
const content = [ const content = [];
{
type: 'text', // 先放主文本
text: `${promptText}\n\n附件列表:\n${attachmentText}`, if (promptText) {
}, content.push({ type: 'text', text: promptText });
]; }
const nonInlineable = [];
attachments.forEach((file) => { attachments.forEach((file) => {
content.push({ if (file.kind === 'image') {
type: 'image_url', // 图片:用 image_url + base64 data URLOpenAI 标准)
image_url: { content.push({
url: file.dataUrl, type: 'image_url',
}, image_url: { url: file.dataUrl },
}); });
} else if (file.kind === 'text') {
// 文本类文件:解码 base64内联为 text block
let decoded = '';
try {
decoded = atob(file.base64);
// 尝试 UTF-8 解码(处理多字节字符)
decoded = new TextDecoder('utf-8').decode(
Uint8Array.from(atob(file.base64), (c) => c.charCodeAt(0))
);
} catch {
decoded = file.base64;
}
content.push({
type: 'text',
text: `文件名:${file.name}\n内容:\n\`\`\`\n${decoded}\n\`\`\``,
});
} else if ((file.kind === 'docx' || file.kind === 'xlsx' || file.kind === 'pptx' || file.kind === 'pdf') && file.parsedText != null) {
// Office 文档 / PDF使用前端解析出的文本内联
content.push({
type: 'text',
text: `文件名:${file.name}\n内容:\n\`\`\`\n${file.parsedText}\n\`\`\``,
});
} else {
// 其他二进制:记录下来,后面统一追加描述
nonInlineable.push(file);
}
}); });
return { // 不可内联的文件:追加描述性文本
role: 'user', if (nonInlineable.length) {
content, const desc = nonInlineable
}; .map((f) => `- ${f.name} (${f.type || 'application/octet-stream'}, ${formatBytes(f.size)})`)
.join('\n');
content.push({
type: 'text',
text: `以下附件无法直接内联,仅供参考:\n${desc}`,
});
}
return { role: 'user', content };
} }
function extractAssistantText(data) { function extractAssistantText(data) {
@@ -732,39 +835,52 @@ function renderMessageContent(container, message) {
container.appendChild(details); container.appendChild(details);
} }
// 主文本 // 主文本marked.js 会自动处理图片)
const images = extractImageDataUrls(mainText); if (mainText) {
const textOnly = removeImageDataUrls(mainText).trim();
if (textOnly) {
const div = document.createElement('div'); const div = document.createElement('div');
div.innerHTML = renderMarkdown(textOnly); div.innerHTML = renderMarkdown(mainText);
container.appendChild(div); // 给 marked 渲染出来的 img 加上样式类
} div.querySelectorAll('img').forEach((img) => {
if (images.length) {
const gallery = document.createElement('div');
gallery.className = 'message-image-gallery';
images.forEach((src) => {
const img = document.createElement('img');
img.src = src;
img.className = 'message-inline-image'; img.className = 'message-inline-image';
gallery.appendChild(img);
}); });
container.appendChild(gallery); container.appendChild(div);
} }
} }
function renderMarkdown(input) { function renderMarkdown(input) {
return marked.parse(String(input || ''), { const renderer = new marked.Renderer();
renderer: (() => {
const r = new marked.Renderer(); renderer.code = ({ text, lang }) => {
r.code = ({ text, lang }) => { const escaped = text.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
const escaped = text.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); return `<pre class="md-pre"><code>${escaped}</code></pre>`;
return `<pre class="md-pre"><code>${escaped}</code></pre>`; };
};
return r; renderer.image = ({ href, title, text }) => {
})(), if (!href) return '';
breaks: true, const isVideo = /\.(mp4|webm|ogg|mov)([?#]|$)/i.test(href) || /video/i.test(href);
}); if (isVideo) {
return `<video class="message-inline-video" controls playsinline preload="metadata" style="max-width:100%;border-radius:12px;margin-top:8px">
<source src="${href}">
<a href="${href}" target="_blank">点击查看视频</a>
</video>`;
}
const alt = text || title || '';
return `<img class="message-inline-image" src="${href}" alt="${alt}">`;
};
renderer.link = ({ href, title, text }) => {
if (!href) return text;
const isVideo = /\.(mp4|webm|ogg|mov)([?#]|$)/i.test(href) || /video/i.test(href);
if (isVideo) {
return `<video class="message-inline-video" controls playsinline preload="metadata" style="max-width:100%;border-radius:12px;margin-top:8px">
<source src="${href}">
<a href="${href}" target="_blank">点击查看视频</a>
</video>`;
}
return `<a href="${href}" target="_blank" rel="noopener noreferrer">${text}</a>`;
};
return marked.parse(String(input || ''), { renderer, breaks: true });
} }
function extractImageDataUrls(text) { function extractImageDataUrls(text) {
@@ -839,6 +955,35 @@ function loadConversations() {
function getAttachmentKind(file) { function getAttachmentKind(file) {
if (file.type?.startsWith('image/')) return 'image'; if (file.type?.startsWith('image/')) return 'image';
const textTypes = [
'text/',
'application/json',
'application/xml',
'application/javascript',
'application/typescript',
'application/x-yaml',
'application/x-sh',
'application/x-python',
];
if (textTypes.some((t) => file.type?.startsWith(t))) return 'text';
const textExts = /\.(txt|md|markdown|csv|json|xml|yaml|yml|toml|ini|cfg|conf|log|sh|bash|zsh|py|js|ts|jsx|tsx|java|c|cpp|h|hpp|cs|go|rs|rb|php|swift|kt|scala|r|sql|html|htm|css|scss|sass|less|vue|svelte|astro|diff|patch)$/i;
if (textExts.test(file.name)) return 'text';
if (file.type === 'application/pdf') return 'pdf';
const docxTypes = [
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/msword',
];
const xlsxTypes = [
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/vnd.ms-excel',
];
const pptxTypes = [
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'application/vnd.ms-powerpoint',
];
if (docxTypes.includes(file.type) || /\.(docx|doc)$/i.test(file.name)) return 'docx';
if (xlsxTypes.includes(file.type) || /\.(xlsx|xls)$/i.test(file.name)) return 'xlsx';
if (pptxTypes.includes(file.type) || /\.(pptx|ppt)$/i.test(file.name)) return 'pptx';
return 'file'; return 'file';
} }

View File

@@ -89,6 +89,9 @@
</div> </div>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/mammoth@1.8.0/mammoth.browser.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/xlsx@0.18.5/dist/xlsx.full.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/pdfjs-dist@3.11.174/build/pdf.min.js"></script>
<script src="/config.js"></script> <script src="/config.js"></script>
<script src="/app.js" defer></script> <script src="/app.js" defer></script>
</body> </body>