Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 119 additions & 1 deletion packages/extension/src/action/aiPrompt.test.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -77,6 +77,7 @@ const makeAutoSubmitService = (overrides?: Partial<AiService>): AiService => ({
selectorType: "css" as any,
queryUrl: "https://www.perplexity.ai/search/new?q=%s",
autoSubmit: true,
urlToMarkdown: true,
...overrides,
})

Expand Down Expand Up @@ -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).")
})
})
})
27 changes: 26 additions & 1 deletion packages/extension/src/action/aiPrompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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).
Expand Down
1 change: 1 addition & 0 deletions packages/extension/src/services/aiPromptFallback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions packages/extension/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
1 change: 1 addition & 0 deletions packages/hub/public/data/ai-services.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down