diff --git a/src/miyo/MiyoClient.test.ts b/src/miyo/MiyoClient.test.ts new file mode 100644 index 00000000..0327cffe --- /dev/null +++ b/src/miyo/MiyoClient.test.ts @@ -0,0 +1,93 @@ +import { MiyoClient } from "@/miyo/MiyoClient"; +import { MiyoServiceDiscovery } from "@/miyo/MiyoServiceDiscovery"; +import { getSettings } from "@/settings/model"; +import { requestUrl } from "obsidian"; + +jest.mock("obsidian", () => ({ + requestUrl: jest.fn(), +})); + +jest.mock("@/settings/model", () => ({ + getSettings: jest.fn(), +})); + +const mockResolveBaseUrl = jest.fn(); + +jest.mock("@/miyo/MiyoServiceDiscovery", () => ({ + MiyoServiceDiscovery: { + getInstance: jest.fn(() => ({ + resolveBaseUrl: (...args: unknown[]) => mockResolveBaseUrl(...args), + })), + }, +})); + +jest.mock("@/logger", () => ({ + logInfo: jest.fn(), + logWarn: jest.fn(), + logError: jest.fn(), +})); + +describe("MiyoClient.parseDoc", () => { + const mockedRequestUrl = requestUrl as jest.MockedFunction; + const mockedGetSettings = getSettings as jest.MockedFunction; + const mockedGetInstance = MiyoServiceDiscovery.getInstance as unknown as jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + mockedGetSettings.mockReturnValue({ + selfHostApiKey: "", + debug: false, + } as any); + mockResolveBaseUrl.mockResolvedValue("http://127.0.0.1:8742"); + mockedGetInstance.mockReturnValue({ + resolveBaseUrl: mockResolveBaseUrl, + }); + }); + + it("posts absolute path to /v0/parse-doc and returns parsed payload", async () => { + mockedRequestUrl.mockResolvedValue({ + status: 200, + json: { + text: "parsed text", + format: "pdf", + source_path: "/tmp/sample.pdf", + title: "Sample", + page_count: 3, + }, + text: "", + } as any); + + const client = new MiyoClient(); + const result = await client.parseDoc("http://127.0.0.1:8742", "/tmp/sample.pdf"); + + expect(result).toEqual({ + text: "parsed text", + format: "pdf", + source_path: "/tmp/sample.pdf", + title: "Sample", + page_count: 3, + }); + expect(mockedRequestUrl).toHaveBeenCalledWith( + expect.objectContaining({ + url: "http://127.0.0.1:8742/v0/parse-doc", + method: "POST", + contentType: "application/json", + body: JSON.stringify({ path: "/tmp/sample.pdf" }), + }) + ); + }); + + it("throws when /v0/parse-doc returns an error status", async () => { + mockedRequestUrl.mockResolvedValue({ + status: 500, + text: "internal server error", + json: { detail: "fail" }, + } as any); + + const client = new MiyoClient(); + + await expect(client.parseDoc("http://127.0.0.1:8742", "/tmp/sample.pdf")).rejects.toThrow( + "Miyo request failed with status 500" + ); + }); +}); diff --git a/src/miyo/MiyoClient.ts b/src/miyo/MiyoClient.ts index 4dfbe423..7819519c 100644 --- a/src/miyo/MiyoClient.ts +++ b/src/miyo/MiyoClient.ts @@ -103,6 +103,17 @@ export interface MiyoRelatedSearchResponse { results: MiyoRelatedSearchResult[]; } +/** + * Response for Miyo document parsing endpoint. + */ +export interface MiyoParseDocResponse { + text: string; + format: string; + source_path: string; + title?: string; + page_count?: number; +} + /** * Search filters for Miyo queries. */ @@ -331,6 +342,20 @@ export class MiyoClient { }); } + /** + * Parse a local document via Miyo. + * + * @param baseUrl - Miyo base URL. + * @param path - Absolute local file path. + * @returns Parsed document response. + */ + public async parseDoc(baseUrl: string, path: string): Promise { + return this.requestJson(baseUrl, "/v0/parse-doc", { + method: "POST", + body: { path }, + }); + } + /** * Build request headers, including optional auth. * diff --git a/src/settings/v2/components/CopilotPlusSettings.tsx b/src/settings/v2/components/CopilotPlusSettings.tsx index 5ca9ebda..9865a9c9 100644 --- a/src/settings/v2/components/CopilotPlusSettings.tsx +++ b/src/settings/v2/components/CopilotPlusSettings.tsx @@ -214,8 +214,8 @@ export const CopilotPlusSettings: React.FC = () => { <> Promise; } +/** + * Resolve absolute file path for a vault file when supported by the adapter. + * + * @param file - Target file. + * @param vault - Obsidian vault instance. + * @returns Absolute file path or null when unavailable. + */ +function resolveAbsoluteFilePath(file: TFile, vault: Vault): string | null { + const adapter = vault.adapter; + if (adapter instanceof FileSystemAdapter) { + return adapter.getFullPath(file.path); + } + + const adapterAny = adapter as unknown as { getFullPath?: (normalizedPath: string) => string }; + if (typeof adapterAny.getFullPath === "function") { + return adapterAny.getFullPath(file.path); + } + + return null; +} + +/** + * Self-host PDF parser bridge using Miyo parse-doc endpoint. + */ +class SelfHostPdfParser { + private miyoClient: MiyoClient; + + /** + * Create a new self-host PDF parser. + */ + constructor() { + this.miyoClient = new MiyoClient(); + } + + /** + * Parse a PDF via Miyo when self-host mode is active. + * + * @param file - PDF file to parse. + * @param vault - Obsidian vault instance. + * @returns Parsed text when successful, otherwise null. + */ + public async parsePdf(file: TFile, vault: Vault): Promise { + const settings = getSettings(); + if (!settings.enableMiyoSearch || file.extension.toLowerCase() !== "pdf") { + return null; + } + + const absolutePath = resolveAbsoluteFilePath(file, vault); + if (!absolutePath) { + logWarn( + `[SelfHostPdfParser] Could not resolve absolute path for ${file.path}; cannot call Miyo parse-doc` + ); + return null; + } + + try { + const baseUrl = await this.miyoClient.resolveBaseUrl(settings.selfHostUrl); + const response = await this.miyoClient.parseDoc(baseUrl, absolutePath); + if (typeof response.text !== "string" || response.text.trim().length === 0) { + throw new Error("Miyo parse-doc returned empty text"); + } + + logInfo(`[SelfHostPdfParser] Parsed PDF via Miyo: ${file.path}`); + return response.text; + } catch (error) { + logWarn( + `[SelfHostPdfParser] Failed to parse ${file.path} via Miyo parse-doc: ${ + error instanceof Error ? error.message : String(error) + }` + ); + return null; + } + } +} + export class MarkdownParser implements FileParser { supportedExtensions = ["md"]; @@ -24,10 +102,12 @@ export class PDFParser implements FileParser { supportedExtensions = ["pdf"]; private brevilabsClient: BrevilabsClient; private pdfCache: PDFCache; + private selfHostPdfParser: SelfHostPdfParser; constructor(brevilabsClient: BrevilabsClient) { this.brevilabsClient = brevilabsClient; this.pdfCache = PDFCache.getInstance(); + this.selfHostPdfParser = new SelfHostPdfParser(); } async parseFile(file: TFile, vault: Vault): Promise { @@ -41,6 +121,25 @@ export class PDFParser implements FileParser { return cachedResponse.response; } + const settings = getSettings(); + if ( + isSelfHostModeValid() && + settings.enableMiyoSearch && + file.extension.toLowerCase() === "pdf" + ) { + const selfHostPdfContent = await this.selfHostPdfParser.parsePdf(file, vault); + if (selfHostPdfContent !== null) { + await this.pdfCache.set(file, { + response: selfHostPdfContent, + elapsed_time_ms: 0, + }); + return selfHostPdfContent; + } + + logError(`[PDFParser] Miyo enabled but parse-doc failed for ${file.path}`); + return `[Error: Could not extract content from PDF ${file.basename}]`; + } + // If not in cache, read the file and call the API const binaryContent = await vault.readBinary(file); logInfo("Calling pdf4llm API for:", file.path);