diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/DocumentExtractTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/DocumentExtractTool.java index 8ff9ba47..b18f9cdc 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/DocumentExtractTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/DocumentExtractTool.java @@ -153,31 +153,197 @@ public class DocumentExtractTool { // ==================== PDF 提取链 ==================== private ExtractedContent extractPdf(Path path, String options, List attempts) throws Exception { + String bestContent = null; + String bestMethod = null; + // 1. 尝试 pdftotext + long t0 = System.currentTimeMillis(); String content = tryPdftotext(path, options); if (content != null && !content.isBlank()) { - attempts.add("pdftotext: 成功"); - return new ExtractedContent(content, "pdftotext", estimatePages(content)); + int pages = estimatePages(content); + if (!needsOcr(content, pages)) { + attempts.add("pdftotext: 成功 (" + (System.currentTimeMillis() - t0) + "ms)"); + return new ExtractedContent(content, "pdftotext", pages); + } + attempts.add("pdftotext: 文本过少 (每页 " + charsPerPage(content, pages) + " 字符),可能是扫描版"); + bestContent = content; + bestMethod = "pdftotext"; + } else { + attempts.add("pdftotext: 失败或不可用"); } - attempts.add("pdftotext: 失败或不可用"); // 2. 尝试 Python pdfplumber/pypdf + long t1 = System.currentTimeMillis(); content = tryPythonPdfExtractor(path, options); if (content != null && !content.isBlank()) { - attempts.add("python_pdf: 成功"); - return new ExtractedContent(content, "python_pdfplumber", estimatePages(content)); + int pages = estimatePages(content); + if (!needsOcr(content, pages)) { + attempts.add("python_pdf: 成功 (" + (System.currentTimeMillis() - t1) + "ms)"); + return new ExtractedContent(content, "python_pdfplumber", pages); + } + attempts.add("python_pdf: 文本过少"); + if (bestContent == null || content.strip().length() > bestContent.strip().length()) { + bestContent = content; + bestMethod = "python_pdfplumber"; + } + } else { + attempts.add("python_pdf: 失败或不可用"); } - attempts.add("python_pdf: 失败或不可用"); - // 3. Java 实现(基于 Apache PDFBox 逻辑,纯 Java) + // 3. Java 实现 + long t2 = System.currentTimeMillis(); content = extractPdfWithJava(path); if (content != null && !content.isBlank()) { - attempts.add("java_pdf: 成功"); - return new ExtractedContent(content, "java_pdfbox", estimatePages(content)); + int pages = estimatePages(content); + if (!needsOcr(content, pages)) { + attempts.add("java_pdf: 成功 (" + (System.currentTimeMillis() - t2) + "ms)"); + return new ExtractedContent(content, "java_pdfbox", pages); + } + attempts.add("java_pdf: 文本过少"); + if (bestContent == null || content.strip().length() > bestContent.strip().length()) { + bestContent = content; + bestMethod = "java_pdfbox"; + } + } else { + attempts.add("java_pdf: 失败"); } - attempts.add("java_pdf: 失败"); - throw new Exception("所有 PDF 提取方法都失败"); + // 4. OCR fallback(扫描版/照片型 PDF) + log.info("[DocumentExtract] 文本提取不足,尝试 OCR: {}", path.getFileName()); + long t3 = System.currentTimeMillis(); + content = tryOcrExtract(path, attempts); + if (content != null && !content.isBlank()) { + attempts.add("ocr_tesseract: 成功 (" + (System.currentTimeMillis() - t3) + "ms)"); + return new ExtractedContent(content, "ocr_tesseract", estimatePages(content)); + } + // attempts 已由 tryOcrExtract 内部记录失败原因 + + // 返回之前级别的部分结果(如果有) + if (bestContent != null) { + log.warn("[DocumentExtract] OCR 不可用,返回部分文本结果: method={}, length={}", + bestMethod, bestContent.strip().length()); + return new ExtractedContent(bestContent, bestMethod + "_partial", estimatePages(bestContent)); + } + + throw new Exception("所有 PDF 提取方法都失败(包括 OCR)"); + } + + /** + * 判断提取到的文本是否太少、需要尝试 OCR。 + * 基于字符密度(每页平均字符数)而非总字符数,避免误判短票据/证书类 PDF。 + */ + private boolean needsOcr(String text, int estimatedPages) { + if (text == null || text.isBlank()) return true; + String stripped = text.strip(); + if (stripped.length() < 20) return true; + double perPage = charsPerPage(stripped, estimatedPages); + // 正常文本 PDF 每页至少数百字符;每页不到 30 字符大概率是扫描版 + return perPage < 30; + } + + private double charsPerPage(String text, int pages) { + return (double) text.strip().length() / Math.max(1, pages); + } + + /** + * OCR 提取:pdftoppm 转图片 + tesseract 识别文字。 + * 仅依赖 Poppler(pdftoppm)和 tesseract 系统命令,不引入新 Python 依赖。 + */ + private String tryOcrExtract(Path pdfPath, List attempts) { + Path tempDir = null; + try { + long startTime = System.currentTimeMillis(); + tempDir = Files.createTempDirectory("mc_ocr_"); + + // Step 1: 检查 pdftoppm 可用性 + try { + executeCommand(List.of("pdftoppm", "-v")); + } catch (Exception e) { + attempts.add("ocr: pdftoppm 不可用,无法将 PDF 转为图片"); + log.warn("[DocumentExtract] OCR: pdftoppm 不可用 - {}", e.getMessage()); + return null; + } + + // Step 2: PDF → PNG(200dpi,足够 OCR 但不过大) + long t1 = System.currentTimeMillis(); + String pagePrefix = tempDir.resolve("page").toString(); + executeCommand(List.of("pdftoppm", "-png", "-r", "200", + pdfPath.toString(), pagePrefix)); + log.info("[DocumentExtract] OCR: pdftoppm 耗时 {}ms", System.currentTimeMillis() - t1); + + File[] pageFiles = tempDir.toFile().listFiles((dir, name) -> name.endsWith(".png")); + if (pageFiles == null || pageFiles.length == 0) { + attempts.add("ocr: pdftoppm 未生成图片"); + return null; + } + java.util.Arrays.sort(pageFiles); + + // Step 3: 检查 tesseract 可用性并探测语言包 + String langParam; + try { + String langOutput = executeCommand(List.of("tesseract", "--list-langs")); + langParam = buildTesseractLangParam(langOutput); + log.info("[DocumentExtract] OCR: tesseract 可用,语言参数: {}", langParam); + } catch (Exception e) { + attempts.add("ocr: tesseract 未安装"); + log.warn("[DocumentExtract] OCR: tesseract 不可用 - {}", e.getMessage()); + return null; + } + + // Step 4: 对每页执行 OCR + long t2 = System.currentTimeMillis(); + StringBuilder ocrText = new StringBuilder(); + for (int i = 0; i < pageFiles.length; i++) { + ocrText.append("--- Page ").append(i + 1).append(" ---\n"); + try { + List cmd = new ArrayList<>(); + cmd.add("tesseract"); + cmd.add(pageFiles[i].getAbsolutePath()); + cmd.add("stdout"); + if (langParam != null) { + cmd.add("-l"); + cmd.add(langParam); + } + String pageText = executeCommand(cmd); + ocrText.append(pageText != null ? pageText.trim() : "").append("\n\n"); + } catch (Exception e) { + log.warn("[DocumentExtract] OCR: 页面 {} tesseract 失败: {}", i + 1, e.getMessage()); + ocrText.append("[OCR 失败]\n\n"); + } + } + log.info("[DocumentExtract] OCR: tesseract 处理 {} 页耗时 {}ms,总耗时 {}ms", + pageFiles.length, System.currentTimeMillis() - t2, System.currentTimeMillis() - startTime); + + String result = ocrText.toString().trim(); + return result.isEmpty() ? null : result; + + } catch (Exception e) { + log.warn("[DocumentExtract] OCR 失败: {}", e.getMessage()); + attempts.add("ocr: 异常 - " + e.getMessage()); + return null; + } finally { + if (tempDir != null) { + try { + Files.walk(tempDir) + .sorted(java.util.Comparator.reverseOrder()) + .forEach(f -> { try { Files.delete(f); } catch (IOException ignored) {} }); + } catch (IOException ignored) {} + } + } + } + + /** + * 从 tesseract --list-langs 输出中构建最优语言参数。 + * 优先 eng+chi_sim,缺中文则只用 eng,都缺则返回 null(用 tesseract 默认)。 + */ + private String buildTesseractLangParam(String listLangsOutput) { + if (listLangsOutput == null) return null; + boolean hasEng = listLangsOutput.contains("eng"); + boolean hasChiSim = listLangsOutput.contains("chi_sim"); + if (hasEng && hasChiSim) return "eng+chi_sim"; + if (hasEng) return "eng"; + if (hasChiSim) return "chi_sim"; + return null; // 用 tesseract 默认语言 } private String tryPdftotext(Path path, String options) { @@ -551,7 +717,7 @@ public class DocumentExtractTool { } } - private String tryPythonScript(String script, String filePath) { + private String tryPythonScript(String script, String... args) { Path tempScript = null; try { tempScript = Files.createTempFile("extract", ".py"); @@ -559,7 +725,10 @@ public class DocumentExtractTool { // Windows 通常只有 python,没有 python3 String pythonCmd = IS_WINDOWS ? "python" : "python3"; - List command = List.of(pythonCmd, tempScript.toString(), filePath); + List command = new ArrayList<>(); + command.add(pythonCmd); + command.add(tempScript.toString()); + for (String arg : args) command.add(arg); return executeCommand(command); } catch (Exception e) { log.debug("Python 脚本失败: {}", e.getMessage()); diff --git a/mateclaw-ui/src/api/index.ts b/mateclaw-ui/src/api/index.ts index 50771aff..52b10fcc 100644 --- a/mateclaw-ui/src/api/index.ts +++ b/mateclaw-ui/src/api/index.ts @@ -42,6 +42,22 @@ http.interceptors.response.use( } ) +// ==================== 受保护文件访问 ==================== + +/** + * 使用 JWT 认证 fetch 受保护文件,返回 Blob。 + * 用于 无法自动携带 Authorization header 的场景。 + * 使用原生 fetch(不走 axios),避免 baseURL 拼接和 R 拦截器干扰。 + */ +export async function fetchAuthenticatedBlob(fileUrl: string): Promise { + const token = localStorage.getItem('token') + const headers: Record = {} + if (token) headers.Authorization = `Bearer ${token}` + const response = await fetch(fileUrl, { headers }) + if (!response.ok) throw new Error(`Fetch failed: ${response.status}`) + return response.blob() +} + // ==================== Auth ==================== export const authApi = { login: (data: { username: string; password: string }) => diff --git a/mateclaw-ui/src/components/chat/MessageBubble.vue b/mateclaw-ui/src/components/chat/MessageBubble.vue index ca03cafc..293f46c9 100644 --- a/mateclaw-ui/src/components/chat/MessageBubble.vue +++ b/mateclaw-ui/src/components/chat/MessageBubble.vue @@ -214,21 +214,19 @@ class="message-attachment-image" > {{ attachment.name }} - @@ -236,7 +234,7 @@ {{ attachment.name }} {{ formatFileSize(attachment.size) }} - + @@ -288,12 +286,14 @@ import { computed, ref, watch, onBeforeUnmount } from 'vue' import { useI18n } from 'vue-i18n' import { useMarkdownRenderer } from '@/composables/useMarkdownRenderer' +import { useAuthenticatedAttachment } from '@/composables/useAuthenticatedAttachment' import TypingCursor from './TypingCursor.vue' import type { Message, ChatAttachment, ToolCallMeta, PlanMeta } from '@/types' import type { ChatErrorInfo } from '@/types/chatError' const { renderMarkdown } = useMarkdownRenderer() const { t } = useI18n() +const { blobUrls, loadAllImages, downloadFile, openImage, getDisplayUrl, revokeAll } = useAuthenticatedAttachment() interface Props { message: Message @@ -456,6 +456,7 @@ function copyMessage() { onBeforeUnmount(() => { if (copyTimer) clearTimeout(copyTimer) + revokeAll() }) // --- 附件 --- @@ -463,6 +464,11 @@ const attachments = computed(() => props.message.attachments || []) const imageAttachments = computed(() => attachments.value.filter(a => a.contentType?.startsWith('image/'))) const fileAttachments = computed(() => attachments.value.filter(a => !a.contentType?.startsWith('image/'))) +// 增量加载图片附件的鉴权 blob URL(watch 覆盖首次 + 后续变化) +watch(imageAttachments, (atts) => { + if (atts.length > 0) loadAllImages(atts) +}, { immediate: true }) + // --- 时间 --- const formattedTime = computed(() => { if (!props.message.createTime) return '' @@ -478,9 +484,7 @@ const formatFileSize = (size: number) => { return `${(size / (1024 * 1024)).toFixed(1)} MB` } -const openAttachment = (url: string) => { - window.open(url, '_blank') -} +// openAttachment 已由 useAuthenticatedAttachment 的 openImage / downloadFile 替代 // --- 执行过程面板 --- const executionExpanded = ref(false) @@ -1331,6 +1335,10 @@ watch(isGenerating, (generating) => { background: rgba(255, 255, 255, 0.14); color: inherit; text-decoration: none; + border: none; + font: inherit; + cursor: pointer; + width: 100%; } .user-bubble .message-attachment { diff --git a/mateclaw-ui/src/composables/useAuthenticatedAttachment.ts b/mateclaw-ui/src/composables/useAuthenticatedAttachment.ts new file mode 100644 index 00000000..eb77f6e2 --- /dev/null +++ b/mateclaw-ui/src/composables/useAuthenticatedAttachment.ts @@ -0,0 +1,134 @@ +import { ref } from 'vue' +import { fetchAuthenticatedBlob } from '@/api/index' +import type { ChatAttachment } from '@/types' + +/** + * 统一受保护附件 Loader + * + * 项目使用 Bearer token 认证(localStorage), 不会自动携带 + * Authorization header。此 composable 统一处理鉴权 fetch → blob → ObjectURL。 + * + * 使用方式: + * - 图片:loadAllImages() 批量加载 → blobUrls[key] 绑定 + * - 文件:downloadFile() 点击时鉴权下载 + * - 图片点击放大:openImage() 同步开窗避免弹窗拦截 + * - 组件卸载:revokeAll() 释放所有 blob URL + */ +export function useAuthenticatedAttachment() { + /** 已加载的 blob URL 映射(key = storedName || url) */ + const blobUrls = ref>({}) + + /** 跟踪所有创建的 blob URL,用于清理 */ + const trackedUrls: string[] = [] + + /** 正在加载的 URL 集合,防止重复请求 */ + const loading = new Set() + + /** + * 加载单个附件的 blob URL + */ + async function loadBlobUrl(url: string, key: string): Promise { + // 跳过:已加载、正在加载、本地 blob + if (blobUrls.value[key] || loading.has(key) || url.startsWith('blob:')) return blobUrls.value[key] || url + loading.add(key) + try { + const blob = await fetchAuthenticatedBlob(url) + const objectUrl = URL.createObjectURL(blob) + blobUrls.value[key] = objectUrl + trackedUrls.push(objectUrl) + return objectUrl + } catch (e) { + console.warn('[useAuthenticatedAttachment] Failed to load:', url, e) + return null + } finally { + loading.delete(key) + } + } + + /** + * 批量加载所有图片附件的 blob URL + */ + async function loadAllImages(attachments: ChatAttachment[]) { + const imageAtts = attachments.filter(a => a.contentType?.startsWith('image/')) + for (const att of imageAtts) { + const key = att.storedName || att.url + if (!att.previewUrl && att.url && !blobUrls.value[key]) { + await loadBlobUrl(att.url, key) + } + } + } + + /** + * 鉴权下载文件:fetch blob → 创建临时 → 触发点击 + */ + async function downloadFile(attachment: ChatAttachment) { + try { + const blob = await fetchAuthenticatedBlob(attachment.url) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = attachment.name || 'download' + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + // 延迟释放,确保下载已启动 + setTimeout(() => URL.revokeObjectURL(url), 10000) + } catch (e) { + console.error('[useAuthenticatedAttachment] Download failed:', attachment.name, e) + // fallback:直接打开 URL(可能 401 但至少给个反馈) + window.open(attachment.url, '_blank') + } + } + + /** + * 鉴权打开图片到新标签页。 + * 同步 window.open 空白页(绕过弹窗拦截),异步写入 blob URL。 + */ + async function openImage(url: string) { + if (url.startsWith('blob:')) { + window.open(url, '_blank') + return + } + // 同步开窗:必须在用户点击的同步调用栈中,否则被浏览器拦截 + const win = window.open('about:blank', '_blank') + if (!win) return + try { + const blob = await fetchAuthenticatedBlob(url) + const blobUrl = URL.createObjectURL(blob) + win.location.href = blobUrl + // 页面关闭后释放 + setTimeout(() => URL.revokeObjectURL(blobUrl), 300000) + } catch { + win.location.href = url // fallback + } + } + + /** + * 获取附件的显示 URL(优先 blob → previewUrl → 原始 url) + */ + function getDisplayUrl(attachment: ChatAttachment): string { + const key = attachment.storedName || attachment.url + return blobUrls.value[key] || attachment.previewUrl || attachment.url || '' + } + + /** + * 释放所有跟踪的 blob URL + */ + function revokeAll() { + for (const url of trackedUrls) { + URL.revokeObjectURL(url) + } + trackedUrls.length = 0 + blobUrls.value = {} + } + + return { + blobUrls, + loadBlobUrl, + loadAllImages, + downloadFile, + openImage, + getDisplayUrl, + revokeAll, + } +} diff --git a/mateclaw-ui/src/views/ChatConsole.vue b/mateclaw-ui/src/views/ChatConsole.vue index 74a58096..a00b3e30 100644 --- a/mateclaw-ui/src/views/ChatConsole.vue +++ b/mateclaw-ui/src/views/ChatConsole.vue @@ -1029,6 +1029,19 @@ function normalizeMessage(raw: Message): Message { // interrupted 是合法的历史状态(interrupt-with-followup),不映射为 stopped if (!msg.status) msg.status = 'completed' + // 从 file-type contentParts 恢复 attachments(历史消息 API 不返回单独的 attachments 字段) + const fileParts = msg.contentParts.filter(p => p.type === 'file' && p.fileUrl) + if (fileParts.length > 0 && (!msg.attachments || msg.attachments.length === 0)) { + msg.attachments = fileParts.map(p => ({ + name: p.fileName || 'unknown', + size: typeof p.fileSize === 'number' ? p.fileSize : Number(p.fileSize) || 0, + url: p.fileUrl!, + storedName: p.storedName || '', + path: p.path || '', + contentType: p.contentType, + })) + } + // 从持久化的 [错误] 文本重建 errorInfo,使刷新后也能显示错误卡片 if (msg.role === 'assistant' && !msg.errorInfo) { const text = msg.content || msg.contentParts?.find(p => p.type === 'text')?.text || ''