mirror of
https://github.com/delibae/claude-prism.git
synced 2026-08-28 05:14:59 +00:00
refactor: remove PDF sidecar extraction and related logic from project attachments
This commit is contained in:
parent
aa55622ecc
commit
792ecd6064
9 changed files with 186 additions and 290 deletions
|
|
@ -43,12 +43,15 @@ pub(super) fn anthropic_to_openai_request(
|
|||
.filter_map(anthropic_tool_to_openai_tool)
|
||||
.collect::<Vec<_>>();
|
||||
if !converted.is_empty() {
|
||||
let tool_choice = openai_tool_choice(request.get("tool_choice"));
|
||||
let mut converted = converted;
|
||||
if tool_choice == Value::String("required".to_string()) {
|
||||
let requested_tool_choice = openai_tool_choice(request.get("tool_choice"));
|
||||
let tool_choice = if requested_tool_choice.is_object() {
|
||||
requested_tool_choice
|
||||
} else {
|
||||
append_exit_tool(&mut converted);
|
||||
append_exit_tool_reminder(&mut body);
|
||||
}
|
||||
Value::String("required".to_string())
|
||||
};
|
||||
body["tools"] = Value::Array(converted);
|
||||
body["tool_choice"] = tool_choice;
|
||||
}
|
||||
|
|
@ -530,13 +533,13 @@ fn append_exit_tool(tools: &mut Vec<Value>) {
|
|||
"type": "function",
|
||||
"function": {
|
||||
"name": EXIT_TOOL_NAME,
|
||||
"description": "Use this when tool mode is active but no remaining tool call is needed. The response field is forwarded to the user as the final answer.",
|
||||
"description": "Use this when tool mode is active and no remaining tool call is needed. This is the valid way to exit tool mode with a final answer.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"response": {
|
||||
"type": "string",
|
||||
"description": "Final response to show the user."
|
||||
"description": "Final response to show the user exactly as written."
|
||||
}
|
||||
},
|
||||
"required": ["response"]
|
||||
|
|
@ -554,7 +557,7 @@ fn append_exit_tool_reminder(body: &mut Value) {
|
|||
};
|
||||
messages.push(json!({
|
||||
"role": "system",
|
||||
"content": "<system-reminder>Tool mode is active. Use the most suitable tool when it helps complete the task. If no available tool is appropriate or the task is complete, call ExitTool with the final response instead of inventing another tool call.</system-reminder>",
|
||||
"content": "<system-reminder>Tool mode is active. The user expects you to proactively execute the most suitable tool to help complete the task. Before invoking a tool, carefully evaluate whether it matches the current task. If no available tool is appropriate, or the task is complete, call ExitTool with the final response instead of inventing another tool call.</system-reminder>",
|
||||
}));
|
||||
}
|
||||
|
||||
|
|
@ -818,6 +821,45 @@ mod tests {
|
|||
assert_eq!(converted["tool_choice"], "required");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enters_tool_mode_when_tools_are_available() {
|
||||
let request = json!({
|
||||
"messages": [{ "role": "user", "content": "use the right tool" }],
|
||||
"tools": [{
|
||||
"name": "Skill",
|
||||
"description": "Load a skill",
|
||||
"input_schema": { "type": "object" }
|
||||
}]
|
||||
});
|
||||
|
||||
let converted = anthropic_to_openai_request(&request, &credential()).unwrap();
|
||||
let tool_names = converted["tools"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter_map(|tool| {
|
||||
tool.pointer("/function/name")
|
||||
.and_then(|value| value.as_str())
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let reminder = converted["messages"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|message| {
|
||||
message.get("role").and_then(|value| value.as_str()) == Some("system")
|
||||
&& message
|
||||
.get("content")
|
||||
.and_then(|value| value.as_str())
|
||||
.is_some_and(|content| content.contains("Tool mode is active"))
|
||||
});
|
||||
|
||||
assert_eq!(converted["tool_choice"], "required");
|
||||
assert!(tool_names.contains(&"Skill"));
|
||||
assert!(tool_names.contains(&EXIT_TOOL_NAME));
|
||||
assert!(reminder.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn converts_exit_tool_response_to_final_text() {
|
||||
let request = json!({ "model": "claude-sonnet-4" });
|
||||
|
|
|
|||
|
|
@ -1560,6 +1560,9 @@ fn create_command(
|
|||
let venv_dir = std::path::Path::new(cwd).join(".venv");
|
||||
if venv_dir.exists() {
|
||||
cmd.env("VIRTUAL_ENV", &venv_dir);
|
||||
cmd.env("UV_PROJECT_ENVIRONMENT", &venv_dir);
|
||||
cmd.env("PYTHONNOUSERSITE", "1");
|
||||
cmd.env("PIP_REQUIRE_VIRTUALENV", "true");
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let venv_bin = venv_dir.join("bin");
|
||||
#[cfg(target_os = "windows")]
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
use std::path::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
use tauri::{Emitter, WebviewWindow};
|
||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||
|
||||
|
|
@ -113,6 +113,28 @@ fn venv_python(venv_dir: &std::path::Path) -> PathBuf {
|
|||
}
|
||||
}
|
||||
|
||||
fn venv_pip(venv_dir: &std::path::Path) -> PathBuf {
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
venv_bin_dir(venv_dir).join("pip")
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
venv_bin_dir(venv_dir).join("pip.exe")
|
||||
}
|
||||
}
|
||||
|
||||
fn venv_pip_shim(venv_dir: &std::path::Path) -> PathBuf {
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
venv_bin_dir(venv_dir).join("pip")
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
venv_bin_dir(venv_dir).join("pip.cmd")
|
||||
}
|
||||
}
|
||||
|
||||
fn path_with_venv(venv_dir: &std::path::Path) -> String {
|
||||
let bin = venv_bin_dir(venv_dir);
|
||||
let current = std::env::var("PATH").unwrap_or_default();
|
||||
|
|
@ -123,6 +145,73 @@ fn path_with_venv(venv_dir: &std::path::Path) -> String {
|
|||
format!("{}{}{}", bin.to_string_lossy(), sep, current)
|
||||
}
|
||||
|
||||
fn write_pip_shim(venv_dir: &Path) -> Result<(), String> {
|
||||
let uv_bin = find_uv_binary().unwrap_or_else(|_| "uv".to_string());
|
||||
let shim_path = venv_pip_shim(venv_dir);
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let content = format!(
|
||||
"@echo off\r\nset \"VIRTUAL_ENV={}\"\r\n\"{}\" pip %*\r\n",
|
||||
venv_dir.to_string_lossy(),
|
||||
uv_bin
|
||||
);
|
||||
std::fs::write(&shim_path, &content)
|
||||
.map_err(|e| format!("Failed to create pip shim: {}", e))?;
|
||||
let pip3_path = venv_bin_dir(venv_dir).join("pip3.cmd");
|
||||
let _ = std::fs::write(pip3_path, content);
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
let content = format!(
|
||||
"#!/bin/sh\nVIRTUAL_ENV=\"{}\" exec \"{}\" pip \"$@\"\n",
|
||||
venv_dir.to_string_lossy(),
|
||||
uv_bin
|
||||
);
|
||||
std::fs::write(&shim_path, content)
|
||||
.map_err(|e| format!("Failed to create pip shim: {}", e))?;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mut perms = std::fs::metadata(&shim_path)
|
||||
.map_err(|e| format!("Failed to stat pip shim: {}", e))?
|
||||
.permissions();
|
||||
perms.set_mode(0o755);
|
||||
std::fs::set_permissions(&shim_path, perms)
|
||||
.map_err(|e| format!("Failed to mark pip shim executable: {}", e))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn ensure_venv_pip(venv_dir: &Path) -> Result<(), String> {
|
||||
if venv_pip(venv_dir).exists() || venv_pip_shim(venv_dir).exists() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let python = venv_python(venv_dir);
|
||||
if !python.exists() {
|
||||
return Err(format!(
|
||||
"Project .venv is missing Python at {}",
|
||||
python.display()
|
||||
));
|
||||
}
|
||||
|
||||
let mut ensure_cmd = tokio::process::Command::new(&python);
|
||||
ensure_cmd.args(["-m", "ensurepip", "--upgrade"]);
|
||||
ensure_cmd.env("VIRTUAL_ENV", venv_dir);
|
||||
ensure_cmd.env("PATH", path_with_venv(venv_dir));
|
||||
ensure_cmd.env("PYTHONNOUSERSITE", "1");
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
ensure_cmd.creation_flags(CREATE_NO_WINDOW);
|
||||
}
|
||||
|
||||
match ensure_cmd.output().await {
|
||||
Ok(output) if output.status.success() && venv_pip(venv_dir).exists() => Ok(()),
|
||||
_ => write_pip_shim(venv_dir),
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Tauri Commands ───
|
||||
|
||||
#[tauri::command]
|
||||
|
|
@ -287,6 +376,7 @@ pub async fn setup_project_venv(project_path: String) -> Result<VenvInfo, String
|
|||
|
||||
// If venv already exists, just return info
|
||||
if venv_dir.exists() {
|
||||
ensure_venv_pip(&venv_dir).await?;
|
||||
let python = venv_python(&venv_dir);
|
||||
return Ok(VenvInfo {
|
||||
venv_path: venv_dir.to_string_lossy().to_string(),
|
||||
|
|
@ -299,7 +389,8 @@ pub async fn setup_project_venv(project_path: String) -> Result<VenvInfo, String
|
|||
|
||||
// Create venv: uv venv <project_path>/.venv
|
||||
let mut venv_cmd = tokio::process::Command::new(&uv_bin);
|
||||
venv_cmd.args(["venv", &venv_dir.to_string_lossy()]);
|
||||
let venv_arg = venv_dir.to_string_lossy().to_string();
|
||||
venv_cmd.args(["venv", "--seed", venv_arg.as_str()]);
|
||||
venv_cmd.current_dir(project);
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
|
|
@ -316,6 +407,7 @@ pub async fn setup_project_venv(project_path: String) -> Result<VenvInfo, String
|
|||
}
|
||||
|
||||
let python = venv_python(&venv_dir);
|
||||
ensure_venv_pip(&venv_dir).await?;
|
||||
|
||||
Ok(VenvInfo {
|
||||
venv_path: venv_dir.to_string_lossy().to_string(),
|
||||
|
|
@ -343,6 +435,8 @@ pub async fn uv_add_packages(
|
|||
pip_cmd.args(&args);
|
||||
pip_cmd.current_dir(&project_path);
|
||||
pip_cmd.env("VIRTUAL_ENV", &venv_dir);
|
||||
pip_cmd.env("UV_PROJECT_ENVIRONMENT", &venv_dir);
|
||||
pip_cmd.env("PYTHONNOUSERSITE", "1");
|
||||
pip_cmd.env("PATH", path_with_venv(&venv_dir));
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
|
|
@ -386,6 +480,9 @@ pub async fn uv_run_command(
|
|||
run_cmd.args(args);
|
||||
run_cmd.current_dir(&project_path);
|
||||
run_cmd.env("VIRTUAL_ENV", &venv_dir);
|
||||
run_cmd.env("UV_PROJECT_ENVIRONMENT", &venv_dir);
|
||||
run_cmd.env("PYTHONNOUSERSITE", "1");
|
||||
run_cmd.env("PIP_REQUIRE_VIRTUALENV", "true");
|
||||
run_cmd.env("PATH", path_with_venv(&venv_dir));
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
|
|
|
|||
|
|
@ -2,42 +2,26 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
|||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
copyFileToProject: vi.fn(),
|
||||
join: vi.fn(),
|
||||
createPdfTextSidecar: vi.fn(),
|
||||
isPdfPath: vi.fn((path: string) => path.toLowerCase().endsWith(".pdf")),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/tauri/fs", () => ({
|
||||
copyFileToProject: mocks.copyFileToProject,
|
||||
join: mocks.join,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/pdf-text-extractor", () => ({
|
||||
createPdfTextSidecar: mocks.createPdfTextSidecar,
|
||||
isPdfPath: mocks.isPdfPath,
|
||||
}));
|
||||
|
||||
import {
|
||||
buildReferenceFilesSection,
|
||||
importReferenceFilesWithSidecars,
|
||||
importReferenceFiles,
|
||||
} from "@/lib/project-attachments";
|
||||
|
||||
describe("project attachment helpers", () => {
|
||||
beforeEach(() => {
|
||||
mocks.copyFileToProject.mockReset();
|
||||
mocks.join.mockReset();
|
||||
mocks.createPdfTextSidecar.mockReset();
|
||||
mocks.isPdfPath.mockClear();
|
||||
});
|
||||
|
||||
it("imports PDFs and creates extracted text sidecars", async () => {
|
||||
it("imports PDFs without creating extracted text files", async () => {
|
||||
mocks.copyFileToProject.mockResolvedValueOnce("attachments/paper.pdf");
|
||||
mocks.join.mockResolvedValueOnce("C:/project/attachments/paper.pdf");
|
||||
mocks.createPdfTextSidecar.mockResolvedValueOnce({
|
||||
sidecarRelativePath: "attachments/paper.pdf.txt",
|
||||
});
|
||||
|
||||
const files = await importReferenceFilesWithSidecars("C:/project", [
|
||||
const files = await importReferenceFiles("C:/project", [
|
||||
"C:/source/paper.pdf",
|
||||
]);
|
||||
|
||||
|
|
@ -46,50 +30,23 @@ describe("project attachment helpers", () => {
|
|||
"C:/source/paper.pdf",
|
||||
"attachments/paper.pdf",
|
||||
);
|
||||
expect(mocks.createPdfTextSidecar).toHaveBeenCalledWith(
|
||||
"C:/project",
|
||||
"attachments/paper.pdf",
|
||||
"C:/project/attachments/paper.pdf",
|
||||
);
|
||||
expect(files).toEqual([
|
||||
{
|
||||
relativePath: "attachments/paper.pdf",
|
||||
sidecarRelativePath: "attachments/paper.pdf.txt",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps imported PDF references when sidecar extraction fails", async () => {
|
||||
mocks.copyFileToProject.mockResolvedValueOnce("attachments/paper.pdf");
|
||||
mocks.join.mockResolvedValueOnce("C:/project/attachments/paper.pdf");
|
||||
mocks.createPdfTextSidecar.mockRejectedValueOnce(
|
||||
new Error("cannot extract"),
|
||||
);
|
||||
|
||||
const files = await importReferenceFilesWithSidecars("C:/project", [
|
||||
"C:/source/paper.pdf",
|
||||
]);
|
||||
|
||||
expect(files[0]).toMatchObject({
|
||||
relativePath: "attachments/paper.pdf",
|
||||
sidecarError: "cannot extract",
|
||||
});
|
||||
});
|
||||
|
||||
it("builds a prompt section that points models at PDF sidecars", () => {
|
||||
it("builds a prompt section that keeps PDF references as PDFs", () => {
|
||||
const section = buildReferenceFilesSection([
|
||||
{
|
||||
relativePath: "attachments/paper.pdf",
|
||||
sidecarRelativePath: "attachments/paper.pdf.txt",
|
||||
},
|
||||
{ relativePath: "attachments/paper.pdf" },
|
||||
{ relativePath: "attachments/data.csv" },
|
||||
]);
|
||||
|
||||
expect(section).toContain("### Reference Files");
|
||||
expect(section).toContain(
|
||||
"`attachments/paper.pdf` (extracted text: `attachments/paper.pdf.txt`)",
|
||||
);
|
||||
expect(section).toContain("`attachments/paper.pdf` (PDF)");
|
||||
expect(section).toContain("`attachments/data.csv`");
|
||||
expect(section).toContain("For PDFs, read the extracted text sidecar");
|
||||
expect(section).not.toContain("extracted text");
|
||||
expect(section).not.toContain(".pdf.txt");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -48,7 +48,6 @@ import {
|
|||
} from "@/stores/claude-setup-store";
|
||||
import { useDocumentStore, type ProjectFile } from "@/stores/document-store";
|
||||
import { getUniqueTargetName } from "@/lib/tauri/fs";
|
||||
import { createPdfTextSidecar, isPdfPath } from "@/lib/pdf-text-extractor";
|
||||
import {
|
||||
getProviderDisplayName,
|
||||
getProviderIconSrc,
|
||||
|
|
@ -141,6 +140,10 @@ function cleanupTemporaryPinnedContext(context: PinnedContext) {
|
|||
void cleanupTemporaryFilePaths([context.filePath]);
|
||||
}
|
||||
|
||||
function isPdfPath(path: string) {
|
||||
return path.toLowerCase().endsWith(".pdf");
|
||||
}
|
||||
|
||||
function getFileIcon(file: ProjectFile) {
|
||||
if (file.type === "image")
|
||||
return <ImageIcon className="size-3.5 shrink-0 text-muted-foreground" />;
|
||||
|
|
@ -598,33 +601,7 @@ export const ChatComposer: FC<{ isOpen?: boolean }> = ({ isOpen }) => {
|
|||
}, [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),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async (file: ProjectFile): Promise<PinnedContext> => {
|
||||
const isTextFile =
|
||||
file.type === "tex" ||
|
||||
file.type === "bib" ||
|
||||
|
|
@ -632,17 +609,14 @@ export const ChatComposer: FC<{ isOpen?: boolean }> = ({ isOpen }) => {
|
|||
file.type === "other";
|
||||
|
||||
return {
|
||||
context: {
|
||||
label: `@${file.relativePath}`,
|
||||
filePath: file.relativePath,
|
||||
selectedText: isTextFile
|
||||
? (file.content ?? "")
|
||||
: `[Referenced file: ${file.relativePath} (${file.type} file)]`,
|
||||
},
|
||||
createdSidecar: false,
|
||||
label: `@${file.relativePath}`,
|
||||
filePath: file.relativePath,
|
||||
selectedText: isTextFile
|
||||
? (file.content ?? "")
|
||||
: `[Referenced file: ${file.relativePath} (${file.type} file)]`,
|
||||
};
|
||||
},
|
||||
[projectRoot],
|
||||
[],
|
||||
);
|
||||
|
||||
const selectMention = useCallback(
|
||||
|
|
@ -660,16 +634,13 @@ export const ChatComposer: FC<{ isOpen?: boolean }> = ({ isOpen }) => {
|
|||
setMentionQuery(null);
|
||||
|
||||
// Pin the whole file as context
|
||||
const { context, createdSidecar } = await buildPinnedContextForFile(file);
|
||||
if (createdSidecar) {
|
||||
await refreshFiles();
|
||||
}
|
||||
const context = await buildPinnedContextForFile(file);
|
||||
setPinnedContexts((prev) => [...prev, context]);
|
||||
|
||||
// Refocus textarea
|
||||
setTimeout(() => textarea.focus(), 0);
|
||||
},
|
||||
[buildPinnedContextForFile, input, refreshFiles],
|
||||
[buildPinnedContextForFile, input],
|
||||
);
|
||||
|
||||
const selectSlashCommand = useCallback((command: SlashCommand) => {
|
||||
|
|
@ -712,7 +683,6 @@ 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(
|
||||
|
|
@ -720,10 +690,7 @@ export const ChatComposer: FC<{ isOpen?: boolean }> = ({ isOpen }) => {
|
|||
);
|
||||
|
||||
if (imported) {
|
||||
const { context, createdSidecar } =
|
||||
await buildPinnedContextForFile(imported);
|
||||
createdPdfSidecar ||= createdSidecar;
|
||||
newContexts.push(context);
|
||||
newContexts.push(await buildPinnedContextForFile(imported));
|
||||
} else {
|
||||
// File imported but type might be filtered out — still pin as reference
|
||||
newContexts.push({
|
||||
|
|
@ -735,10 +702,6 @@ 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));
|
||||
|
|
@ -876,25 +839,10 @@ export const ChatComposer: FC<{ isOpen?: boolean }> = ({ isOpen }) => {
|
|||
const buffer = await file.arrayBuffer();
|
||||
await writeFile(fullPath, new Uint8Array(buffer));
|
||||
|
||||
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})]`;
|
||||
}
|
||||
content = `[Attached file: ${uniqueName} (PDF)]`;
|
||||
} else {
|
||||
// Determine if it's a text file
|
||||
const isText = file.type.startsWith("text/");
|
||||
|
|
@ -905,7 +853,7 @@ export const ChatComposer: FC<{ isOpen?: boolean }> = ({ isOpen }) => {
|
|||
|
||||
newContexts.push({
|
||||
label: `@${uniqueName}`,
|
||||
filePath: contextFilePath,
|
||||
filePath: uniqueName,
|
||||
selectedText: content,
|
||||
});
|
||||
} catch (err) {
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ import { TemplateGallery } from "@/components/template-gallery";
|
|||
import { DEFAULT_CLAUDE_MD } from "@/lib/default-claude-md";
|
||||
import {
|
||||
buildReferenceFilesSection,
|
||||
importReferenceFilesWithSidecars,
|
||||
importReferenceFiles,
|
||||
} from "@/lib/project-attachments";
|
||||
import { getProjectNameError, normalizeProjectName } from "@/lib/project-name";
|
||||
|
||||
|
|
@ -247,7 +247,7 @@ function ScratchForm({ onBack }: { onBack: () => void }) {
|
|||
|
||||
const referenceFiles =
|
||||
attachments.length > 0
|
||||
? await importReferenceFilesWithSidecars(projectPath, attachments)
|
||||
? await importReferenceFiles(projectPath, attachments)
|
||||
: [];
|
||||
|
||||
if (purpose.trim()) {
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ import type { PageSize } from "@/lib/mupdf/types";
|
|||
import { createLogger } from "@/lib/debug/logger";
|
||||
import {
|
||||
buildReferenceFilesSection,
|
||||
importReferenceFilesWithSidecars,
|
||||
importReferenceFiles,
|
||||
} from "@/lib/project-attachments";
|
||||
import { getProjectNameError, normalizeProjectName } from "@/lib/project-name";
|
||||
|
||||
|
|
@ -411,7 +411,7 @@ export function TemplatePreview() {
|
|||
|
||||
const referenceFiles =
|
||||
attachments.length > 0
|
||||
? await importReferenceFilesWithSidecars(projectPath, attachments)
|
||||
? await importReferenceFiles(projectPath, attachments)
|
||||
: [];
|
||||
|
||||
if (purpose.trim()) {
|
||||
|
|
|
|||
|
|
@ -1,133 +0,0 @@
|
|||
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,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
|
@ -1,17 +1,18 @@
|
|||
import { createPdfTextSidecar, isPdfPath } from "@/lib/pdf-text-extractor";
|
||||
import { copyFileToProject, join } from "@/lib/tauri/fs";
|
||||
import { copyFileToProject } from "@/lib/tauri/fs";
|
||||
|
||||
export interface ImportedReferenceFile {
|
||||
relativePath: string;
|
||||
sidecarRelativePath?: string;
|
||||
sidecarError?: string;
|
||||
}
|
||||
|
||||
function baseName(path: string): string {
|
||||
return path.split(/[/\\]/).pop() || path;
|
||||
}
|
||||
|
||||
export async function importReferenceFilesWithSidecars(
|
||||
function isPdfPath(path: string): boolean {
|
||||
return path.toLowerCase().endsWith(".pdf");
|
||||
}
|
||||
|
||||
export async function importReferenceFiles(
|
||||
projectRoot: string,
|
||||
sourcePaths: string[],
|
||||
targetFolder = "attachments",
|
||||
|
|
@ -26,22 +27,6 @@ export async function importReferenceFilesWithSidecars(
|
|||
targetName,
|
||||
);
|
||||
const reference: ImportedReferenceFile = { relativePath };
|
||||
|
||||
if (isPdfPath(relativePath)) {
|
||||
try {
|
||||
const absolutePath = await join(projectRoot, relativePath);
|
||||
const sidecar = await createPdfTextSidecar(
|
||||
projectRoot,
|
||||
relativePath,
|
||||
absolutePath,
|
||||
);
|
||||
reference.sidecarRelativePath = sidecar.sidecarRelativePath;
|
||||
} catch (err) {
|
||||
reference.sidecarError =
|
||||
err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
}
|
||||
|
||||
imported.push(reference);
|
||||
}
|
||||
|
||||
|
|
@ -54,11 +39,8 @@ export function buildReferenceFilesSection(
|
|||
if (references.length === 0) return "";
|
||||
|
||||
const lines = references.map((reference) => {
|
||||
if (reference.sidecarRelativePath) {
|
||||
return `- \`${reference.relativePath}\` (extracted text: \`${reference.sidecarRelativePath}\`)`;
|
||||
}
|
||||
if (isPdfPath(reference.relativePath)) {
|
||||
return `- \`${reference.relativePath}\` (PDF; extracted text sidecar was not generated)`;
|
||||
return `- \`${reference.relativePath}\` (PDF)`;
|
||||
}
|
||||
return `- \`${reference.relativePath}\``;
|
||||
});
|
||||
|
|
@ -68,7 +50,7 @@ export function buildReferenceFilesSection(
|
|||
"### Reference Files",
|
||||
lines.join("\n"),
|
||||
"",
|
||||
"Please review them and incorporate relevant information. For PDFs, read the extracted text sidecar when one is listed instead of relying on the raw PDF file.",
|
||||
"Please review them and incorporate relevant information.",
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue