Add Miyo document parsing toggle and PDF parse-doc integration (#2199)

* feat: add miyo document parsing toggle for PDF parser

* refactor: consolidate miyo toggles into single enable flag
This commit is contained in:
Wenzheng Jiang 2026-02-22 13:11:11 +09:00 committed by GitHub
parent 30b54b6fdd
commit ea552077d9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 221 additions and 4 deletions

View file

@ -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<typeof requestUrl>;
const mockedGetSettings = getSettings as jest.MockedFunction<typeof getSettings>;
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"
);
});
});

View file

@ -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<MiyoParseDocResponse> {
return this.requestJson<MiyoParseDocResponse>(baseUrl, "/v0/parse-doc", {
method: "POST",
body: { path },
});
}
/**
* Build request headers, including optional auth.
*

View file

@ -214,8 +214,8 @@ export const CopilotPlusSettings: React.FC = () => {
<>
<SettingItem
type="switch"
title="Enable Miyo Search"
description="Use Miyo as your local search engine and context hub — supports larger vaults than built-in Copilot search. Enabling this will prompt a one-time index refresh."
title="Enable Miyo"
description="Use Miyo as your local search, PDF parsing, and context hub. Enabling this will prompt a one-time index refresh."
checked={settings.enableMiyoSearch}
onCheckedChange={handleMiyoSearchToggle}
disabled={isValidatingSelfHost}

View file

@ -2,9 +2,12 @@ import { BrevilabsClient } from "@/LLMProviders/brevilabsClient";
import { ProjectConfig } from "@/aiParams";
import { PDFCache } from "@/cache/pdfCache";
import { ProjectContextCache } from "@/cache/projectContextCache";
import { logError, logInfo } from "@/logger";
import { logError, logInfo, logWarn } from "@/logger";
import { MiyoClient } from "@/miyo/MiyoClient";
import { isSelfHostModeValid } from "@/plusUtils";
import { getSettings } from "@/settings/model";
import { extractRetryTime, isRateLimitError } from "@/utils/rateLimitUtils";
import { Notice, TFile, Vault } from "obsidian";
import { FileSystemAdapter, Notice, TFile, Vault } from "obsidian";
import { CanvasLoader } from "./CanvasLoader";
interface FileParser {
@ -12,6 +15,81 @@ interface FileParser {
parseFile: (file: TFile, vault: Vault) => Promise<string>;
}
/**
* 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<string | null> {
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<string> {
@ -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);