mirror of
https://github.com/delibae/claude-prism.git
synced 2026-10-07 02:58:00 +00:00
Extract PDF attachment text with MuPDF
This commit is contained in:
parent
7cdc01a0b2
commit
77d464da24
2 changed files with 227 additions and 37 deletions
|
|
@ -35,6 +35,7 @@ import {
|
|||
} from "@/stores/claude-chat-store";
|
||||
import { useDocumentStore, type ProjectFile } from "@/stores/document-store";
|
||||
import { getUniqueTargetName } from "@/lib/tauri/fs";
|
||||
import { createPdfTextSidecar, isPdfPath } from "@/lib/pdf-text-extractor";
|
||||
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { SlashCommandPicker, type SlashCommand } from "./slash-command-picker";
|
||||
|
|
@ -246,8 +247,56 @@ export const ChatComposer: FC<{ isOpen?: boolean }> = ({ isOpen }) => {
|
|||
.catch(() => setSlashCommands([]));
|
||||
}, [slashQuery !== null, projectRoot]);
|
||||
|
||||
const buildPinnedContextForFile = useCallback(
|
||||
async (
|
||||
file: ProjectFile,
|
||||
): Promise<{ context: PinnedContext; createdSidecar: boolean }> => {
|
||||
if (projectRoot && file.type === "pdf") {
|
||||
try {
|
||||
const sidecar = await createPdfTextSidecar(
|
||||
projectRoot,
|
||||
file.relativePath,
|
||||
file.absolutePath,
|
||||
);
|
||||
|
||||
return {
|
||||
context: {
|
||||
label: `@${file.relativePath}`,
|
||||
filePath: sidecar.sidecarRelativePath,
|
||||
selectedText: sidecar.contextText,
|
||||
},
|
||||
createdSidecar: true,
|
||||
};
|
||||
} catch (err) {
|
||||
log.error("Failed to extract PDF text", {
|
||||
path: file.relativePath,
|
||||
error: String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const isTextFile =
|
||||
file.type === "tex" ||
|
||||
file.type === "bib" ||
|
||||
file.type === "style" ||
|
||||
file.type === "other";
|
||||
|
||||
return {
|
||||
context: {
|
||||
label: `@${file.relativePath}`,
|
||||
filePath: file.relativePath,
|
||||
selectedText: isTextFile
|
||||
? (file.content ?? "")
|
||||
: `[Referenced file: ${file.relativePath} (${file.type} file)]`,
|
||||
},
|
||||
createdSidecar: false,
|
||||
};
|
||||
},
|
||||
[projectRoot],
|
||||
);
|
||||
|
||||
const selectMention = useCallback(
|
||||
(file: ProjectFile) => {
|
||||
async (file: ProjectFile) => {
|
||||
// Replace @query with empty and pin the file as context
|
||||
const textarea = textareaRef.current;
|
||||
if (!textarea) return;
|
||||
|
|
@ -261,26 +310,16 @@ export const ChatComposer: FC<{ isOpen?: boolean }> = ({ isOpen }) => {
|
|||
setMentionQuery(null);
|
||||
|
||||
// Pin the whole file as context
|
||||
const isTextFile =
|
||||
file.type === "tex" ||
|
||||
file.type === "bib" ||
|
||||
file.type === "style" ||
|
||||
file.type === "other";
|
||||
setPinnedContexts((prev) => [
|
||||
...prev,
|
||||
{
|
||||
label: `@${file.relativePath}`,
|
||||
filePath: file.relativePath,
|
||||
selectedText: isTextFile
|
||||
? (file.content ?? "")
|
||||
: `[Referenced file: ${file.relativePath} (${file.type} file)]`,
|
||||
},
|
||||
]);
|
||||
const { context, createdSidecar } = await buildPinnedContextForFile(file);
|
||||
if (createdSidecar) {
|
||||
await refreshFiles();
|
||||
}
|
||||
setPinnedContexts((prev) => [...prev, context]);
|
||||
|
||||
// Refocus textarea
|
||||
setTimeout(() => textarea.focus(), 0);
|
||||
},
|
||||
[input],
|
||||
[buildPinnedContextForFile, input, refreshFiles],
|
||||
);
|
||||
|
||||
const selectSlashCommand = useCallback((command: SlashCommand) => {
|
||||
|
|
@ -323,6 +362,7 @@ export const ChatComposer: FC<{ isOpen?: boolean }> = ({ isOpen }) => {
|
|||
// Pin each file as context
|
||||
const storeFiles = useDocumentStore.getState().files;
|
||||
const newContexts: PinnedContext[] = [];
|
||||
let createdPdfSidecar = false;
|
||||
|
||||
for (const relativePath of importedPaths) {
|
||||
const imported = storeFiles.find(
|
||||
|
|
@ -330,18 +370,10 @@ export const ChatComposer: FC<{ isOpen?: boolean }> = ({ isOpen }) => {
|
|||
);
|
||||
|
||||
if (imported) {
|
||||
const isText =
|
||||
imported.type === "tex" ||
|
||||
imported.type === "bib" ||
|
||||
imported.type === "style" ||
|
||||
imported.type === "other";
|
||||
newContexts.push({
|
||||
label: `@${relativePath}`,
|
||||
filePath: relativePath,
|
||||
selectedText: isText
|
||||
? (imported.content ?? "")
|
||||
: `[Attached file: ${relativePath} (${imported.type} file)]`,
|
||||
});
|
||||
const { context, createdSidecar } =
|
||||
await buildPinnedContextForFile(imported);
|
||||
createdPdfSidecar ||= createdSidecar;
|
||||
newContexts.push(context);
|
||||
} else {
|
||||
// File imported but type might be filtered out — still pin as reference
|
||||
newContexts.push({
|
||||
|
|
@ -353,6 +385,10 @@ export const ChatComposer: FC<{ isOpen?: boolean }> = ({ isOpen }) => {
|
|||
}
|
||||
|
||||
if (newContexts.length > 0) {
|
||||
if (createdPdfSidecar) {
|
||||
await refreshFiles();
|
||||
}
|
||||
|
||||
setPinnedContexts((prev) => {
|
||||
// Deduplicate by label
|
||||
const existingLabels = new Set(prev.map((c) => c.label));
|
||||
|
|
@ -450,15 +486,36 @@ export const ChatComposer: FC<{ isOpen?: boolean }> = ({ isOpen }) => {
|
|||
const buffer = await file.arrayBuffer();
|
||||
await writeFile(fullPath, new Uint8Array(buffer));
|
||||
|
||||
// Determine if it's a text file
|
||||
const isText = file.type.startsWith("text/");
|
||||
const content = isText
|
||||
? await file.text()
|
||||
: `[Attached file: ${uniqueName} (${file.type})]`;
|
||||
let contextFilePath = uniqueName;
|
||||
let content: string;
|
||||
|
||||
if (isPdfPath(uniqueName) || file.type === "application/pdf") {
|
||||
try {
|
||||
const sidecar = await createPdfTextSidecar(
|
||||
projectRoot,
|
||||
uniqueName,
|
||||
fullPath,
|
||||
);
|
||||
contextFilePath = sidecar.sidecarRelativePath;
|
||||
content = sidecar.contextText;
|
||||
} catch (err) {
|
||||
log.error("Failed to extract pasted PDF text", {
|
||||
fileName: uniqueName,
|
||||
error: String(err),
|
||||
});
|
||||
content = `[Attached file: ${uniqueName} (${file.type})]`;
|
||||
}
|
||||
} else {
|
||||
// Determine if it's a text file
|
||||
const isText = file.type.startsWith("text/");
|
||||
content = isText
|
||||
? await file.text()
|
||||
: `[Attached file: ${uniqueName} (${file.type})]`;
|
||||
}
|
||||
|
||||
newContexts.push({
|
||||
label: `@${uniqueName}`,
|
||||
filePath: uniqueName,
|
||||
filePath: contextFilePath,
|
||||
selectedText: content,
|
||||
});
|
||||
} catch (err) {
|
||||
|
|
@ -564,7 +621,7 @@ export const ChatComposer: FC<{ isOpen?: boolean }> = ({ isOpen }) => {
|
|||
}
|
||||
if (e.key === "Enter" || e.key === "Tab") {
|
||||
e.preventDefault();
|
||||
selectMention(mentionFiles[mentionIndex]);
|
||||
void selectMention(mentionFiles[mentionIndex]);
|
||||
return;
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
|
|
@ -804,7 +861,7 @@ export const ChatComposer: FC<{ isOpen?: boolean }> = ({ isOpen }) => {
|
|||
)}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault(); // prevent textarea blur
|
||||
selectMention(file);
|
||||
void selectMention(file);
|
||||
}}
|
||||
onMouseEnter={() => setMentionIndex(i)}
|
||||
>
|
||||
|
|
|
|||
133
apps/desktop/src/lib/pdf-text-extractor.ts
Normal file
133
apps/desktop/src/lib/pdf-text-extractor.ts
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
import { join } from "@tauri-apps/api/path";
|
||||
import { readFile, writeTextFile } from "@tauri-apps/plugin-fs";
|
||||
import { getMupdfClient } from "@/lib/mupdf/mupdf-client";
|
||||
import type { StructuredTextData } from "@/lib/mupdf/types";
|
||||
|
||||
const PDF_CONTEXT_CHAR_LIMIT = 80_000;
|
||||
|
||||
export interface PdfTextSidecar {
|
||||
pageCount: number;
|
||||
sidecarRelativePath: string;
|
||||
sidecarAbsolutePath: string;
|
||||
sidecarContent: string;
|
||||
contextText: string;
|
||||
}
|
||||
|
||||
export function isPdfPath(path: string): boolean {
|
||||
return path.toLowerCase().endsWith(".pdf");
|
||||
}
|
||||
|
||||
function structuredTextToPlainText(data: StructuredTextData): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
for (const block of data.blocks || []) {
|
||||
if (block.type !== "text") continue;
|
||||
|
||||
let addedBlockLine = false;
|
||||
for (const line of block.lines || []) {
|
||||
const text = line.text?.trimEnd();
|
||||
if (!text) continue;
|
||||
|
||||
lines.push(text);
|
||||
addedBlockLine = true;
|
||||
}
|
||||
|
||||
if (addedBlockLine) {
|
||||
lines.push("");
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join("\n").trim();
|
||||
}
|
||||
|
||||
async function extractPdfText(absolutePath: string): Promise<{
|
||||
pageCount: number;
|
||||
pages: string[];
|
||||
}> {
|
||||
const bytes = await readFile(absolutePath);
|
||||
const buffer = new Uint8Array(bytes).buffer;
|
||||
const client = getMupdfClient();
|
||||
const docId = await client.openDocument(buffer, "application/pdf");
|
||||
|
||||
try {
|
||||
const pageCount = await client.countPages(docId);
|
||||
const pages: string[] = [];
|
||||
|
||||
for (let pageIndex = 0; pageIndex < pageCount; pageIndex++) {
|
||||
const structuredText = await client.getPageText(docId, pageIndex);
|
||||
pages.push(structuredTextToPlainText(structuredText));
|
||||
}
|
||||
|
||||
return { pageCount, pages };
|
||||
} finally {
|
||||
await client.closeDocument(docId).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
function buildSidecarContent(
|
||||
pdfRelativePath: string,
|
||||
pageCount: number,
|
||||
pages: string[],
|
||||
): string {
|
||||
const body = pages
|
||||
.map((text, index) => {
|
||||
const pageText = text.trim() || "[No extractable text on this page]";
|
||||
return `## Page ${index + 1}\n\n${pageText}`;
|
||||
})
|
||||
.join("\n\n---\n\n");
|
||||
|
||||
return [
|
||||
`# Extracted PDF Text: ${pdfRelativePath}`,
|
||||
"",
|
||||
`Pages: ${pageCount}`,
|
||||
"",
|
||||
body,
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function buildContextText(
|
||||
pdfRelativePath: string,
|
||||
sidecarRelativePath: string,
|
||||
pageCount: number,
|
||||
sidecarContent: string,
|
||||
): string {
|
||||
const truncated =
|
||||
sidecarContent.length > PDF_CONTEXT_CHAR_LIMIT
|
||||
? `${sidecarContent.slice(0, PDF_CONTEXT_CHAR_LIMIT)}\n\n[Truncated for chat context. Full extracted text is available at ${sidecarRelativePath}.]`
|
||||
: sidecarContent;
|
||||
|
||||
return [
|
||||
`[PDF attachment: ${pdfRelativePath}]`,
|
||||
`[ClaudePrism extracted ${pageCount} page(s) with built-in MuPDF.]`,
|
||||
`[Use this extracted text first. If you need more, read ${sidecarRelativePath}; do not rely on the raw PDF reader unless Poppler is installed.]`,
|
||||
"",
|
||||
truncated,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export async function createPdfTextSidecar(
|
||||
projectRoot: string,
|
||||
pdfRelativePath: string,
|
||||
pdfAbsolutePath: string,
|
||||
): Promise<PdfTextSidecar> {
|
||||
const { pageCount, pages } = await extractPdfText(pdfAbsolutePath);
|
||||
const sidecarRelativePath = `${pdfRelativePath}.txt`;
|
||||
const sidecarAbsolutePath = await join(projectRoot, sidecarRelativePath);
|
||||
const sidecarContent = buildSidecarContent(pdfRelativePath, pageCount, pages);
|
||||
|
||||
await writeTextFile(sidecarAbsolutePath, sidecarContent);
|
||||
|
||||
return {
|
||||
pageCount,
|
||||
sidecarRelativePath,
|
||||
sidecarAbsolutePath,
|
||||
sidecarContent,
|
||||
contextText: buildContextText(
|
||||
pdfRelativePath,
|
||||
sidecarRelativePath,
|
||||
pageCount,
|
||||
sidecarContent,
|
||||
),
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue