diff --git a/packages/extension/src/action/aiPrompt.test.ts b/packages/extension/src/action/aiPrompt.test.ts index a4fac095..65d0cb32 100644 --- a/packages/extension/src/action/aiPrompt.test.ts +++ b/packages/extension/src/action/aiPrompt.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from "vitest" -import { AiPrompt } from "./aiPrompt" +import { AiPrompt, convertUrlsToMarkdown } from "./aiPrompt" import { Ipc, BgCommand } from "@/services/ipc" import { findAiService } from "@/services/aiPrompt" import { Storage } from "@/services/storage" @@ -77,6 +77,7 @@ const makeAutoSubmitService = (overrides?: Partial): AiService => ({ selectorType: "css" as any, queryUrl: "https://www.perplexity.ai/search/new?q=%s", autoSubmit: true, + urlToMarkdown: true, ...overrides, }) @@ -281,4 +282,121 @@ describe("AiPrompt.execute", () => { expect(stepTypes).not.toContain(PAGE_ACTION_EVENT.input) }) }) + + describe("URL to Markdown conversion (urlToMarkdown)", () => { + it("AP-11: should convert bare URLs to Markdown format when urlToMarkdown is true", async () => { + vi.mocked(findAiService).mockResolvedValue(makeAutoSubmitService()) + + await AiPrompt.execute({ + selectionText: "https://example.com/page", + command: { + ...baseCommand, + aiPromptOption: { + ...baseCommand.aiPromptOption, + serviceId: "perplexity", + prompt: "Summarize: {{SelectedText}}", + }, + } as any, + position: { x: 0, y: 0 }, + }) + + const sentArgs = vi.mocked(Ipc.send).mock.calls[0][1] as any + expect(sentArgs.url.selectionText).toBe( + "Summarize: [https://example.com/page](https://example.com/page)", + ) + }) + + it("AP-12: should convert {{Url}} expansion to Markdown format when urlToMarkdown is true", async () => { + vi.mocked(findAiService).mockResolvedValue(makeAutoSubmitService()) + + await AiPrompt.execute({ + selectionText: "hello", + command: { + ...baseCommand, + aiPromptOption: { + ...baseCommand.aiPromptOption, + serviceId: "perplexity", + prompt: "Summarize {{Url}}", + }, + } as any, + position: { x: 0, y: 0 }, + }) + + const sentArgs = vi.mocked(Ipc.send).mock.calls[0][1] as any + // location.href is "https://example.com/page" from beforeEach mock + expect(sentArgs.url.selectionText).toBe( + "Summarize [https://example.com/page](https://example.com/page)", + ) + }) + + it("AP-13: should NOT convert URLs when urlToMarkdown is false", async () => { + vi.mocked(findAiService).mockResolvedValue( + makeQueryService({ urlToMarkdown: false }), + ) + + await AiPrompt.execute({ + selectionText: "https://example.com/page", + command: { + ...baseCommand, + aiPromptOption: { + ...baseCommand.aiPromptOption, + prompt: "Summarize: {{SelectedText}}", + }, + } as any, + position: { x: 0, y: 0 }, + }) + + const sentArgs = vi.mocked(Ipc.send).mock.calls[0][1] as any + expect(sentArgs.url.selectionText).toBe("Summarize: https://example.com/page") + }) + }) + + describe("convertUrlsToMarkdown", () => { + it("CU-01: should convert a bare URL to Markdown link format", () => { + expect(convertUrlsToMarkdown("https://example.com")).toBe( + "[https://example.com](https://example.com)", + ) + }) + + it("CU-02: should convert multiple bare URLs in text", () => { + const result = convertUrlsToMarkdown( + "Check https://example.com and https://other.com for details", + ) + expect(result).toBe( + "Check [https://example.com](https://example.com) and [https://other.com](https://other.com) for details", + ) + }) + + it("CU-03: should not double-convert URLs already in Markdown format", () => { + const input = "[https://example.com](https://example.com)" + expect(convertUrlsToMarkdown(input)).toBe(input) + }) + + it("CU-03b: should not convert Markdown links with non-URL text labels", () => { + const input = "[Example Site](https://example.com)" + expect(convertUrlsToMarkdown(input)).toBe(input) + }) + + it("CU-04: should handle text with no URLs unchanged", () => { + expect(convertUrlsToMarkdown("hello world")).toBe("hello world") + }) + + it("CU-05: should convert http URLs as well as https", () => { + expect(convertUrlsToMarkdown("http://example.com")).toBe( + "[http://example.com](http://example.com)", + ) + }) + + it("CU-06: should strip trailing comma from URL", () => { + const result = convertUrlsToMarkdown("Check https://example.com, please") + expect(result).toBe( + "Check [https://example.com](https://example.com), please", + ) + }) + + it("CU-07: should strip trailing period from URL", () => { + const result = convertUrlsToMarkdown("See https://example.com.") + expect(result).toBe("See [https://example.com](https://example.com).") + }) + }) }) diff --git a/packages/extension/src/action/aiPrompt.ts b/packages/extension/src/action/aiPrompt.ts index 99e1b2b2..aee64986 100644 --- a/packages/extension/src/action/aiPrompt.ts +++ b/packages/extension/src/action/aiPrompt.ts @@ -23,6 +23,27 @@ import { INSERT, InsertSymbol, toInsertTemplate } from "@/services/pageAction" import { Storage, SESSION_STORAGE_KEY } from "@/services/storage" import { getUILanguage } from "@/services/i18n" +/** + * Convert bare URLs in text to Markdown link format [URL](URL). + * URLs already in Markdown link format ([text](url)) are returned unchanged. + * Trailing punctuation characters that are unlikely to be part of the URL are + * excluded from the link and preserved in the surrounding text. + */ +export const convertUrlsToMarkdown = (text: string): string => { + // The alternation tries the markdown link pattern first; if matched, leave it + // unchanged. Otherwise, convert bare URLs to [URL](URL) format. + return text.replace( + /\[[^\]]*\]\([^)]*\)|https?:\/\/[^\s]+/g, + (match) => { + if (match.startsWith("[")) return match + // Strip trailing punctuation that is unlikely to be part of the URL + const trimmed = match.replace(/[.,!?;:)'"]+$/, "") + const trailing = match.slice(trimmed.length) + return `[${trimmed}](${trimmed})${trailing}` + }, + ) +} + // Map OPEN_MODE to PAGE_ACTION_OPEN_MODE for openAndRun const toPageActionMode = (mode: OPEN_MODE): PAGE_ACTION_OPEN_MODE => { switch (mode) { @@ -89,9 +110,13 @@ export const AiPrompt = { [InsertSymbol[INSERT.LANG]]: getUILanguage(), }) + const finalPrompt = service.urlToMarkdown + ? convertUrlsToMarkdown(expandedPrompt) + : expandedPrompt + urlParam = { searchUrl: service.queryUrl!, - selectionText: expandedPrompt, + selectionText: finalPrompt, useClipboard: false, } // Resolve the final URL for cases that require a plain string (e.g. side panel). diff --git a/packages/extension/src/services/aiPromptFallback.ts b/packages/extension/src/services/aiPromptFallback.ts index ef8573c0..e36afc11 100644 --- a/packages/extension/src/services/aiPromptFallback.ts +++ b/packages/extension/src/services/aiPromptFallback.ts @@ -34,6 +34,7 @@ export const normalizeServices = (raw: unknown[]): AiService[] => { selectorType: s.selectorType ?? SelectorType.css, queryUrl: s.queryUrl, autoSubmit: s.autoSubmit, + urlToMarkdown: s.urlToMarkdown, }) } return results diff --git a/packages/extension/src/types/index.ts b/packages/extension/src/types/index.ts index d11b00da..5ab3e91a 100644 --- a/packages/extension/src/types/index.ts +++ b/packages/extension/src/types/index.ts @@ -313,6 +313,8 @@ export type AiService = { queryUrl?: string /** When true, the service processes the prompt automatically after navigation (no submit click needed). */ autoSubmit?: boolean + /** When true, bare URLs in the prompt are converted to Markdown link format [URL](URL) before sending via queryUrl. */ + urlToMarkdown?: boolean } export type AiServicesCache = { diff --git a/packages/hub/public/data/ai-services.json b/packages/hub/public/data/ai-services.json index 80aadaea..06e8dfbd 100644 --- a/packages/hub/public/data/ai-services.json +++ b/packages/hub/public/data/ai-services.json @@ -51,6 +51,7 @@ "url": "https://www.perplexity.ai", "queryUrl": "https://www.perplexity.ai/search/new?q=%s", "autoSubmit": true, + "urlToMarkdown": true, "faviconUrl": "https://favicon.im/perplexity.ai", "inputSelectors": [ "div#ask-input",