diff --git a/packages/extension/src/action/aiPrompt.test.ts b/packages/extension/src/action/aiPrompt.test.ts index 03403796..b067112c 100644 --- a/packages/extension/src/action/aiPrompt.test.ts +++ b/packages/extension/src/action/aiPrompt.test.ts @@ -4,6 +4,7 @@ import { Ipc, BgCommand } from "@/services/ipc" import { findAiService } from "@/services/aiPrompt" import { Storage } from "@/services/storage" import { + APP_ID, OPEN_MODE, PAGE_ACTION_EVENT, PAGE_ACTION_CONDITION_ACTION, @@ -248,6 +249,44 @@ describe("AiPrompt.execute", () => { expect(payload.selectionHtml).toBeUndefined() }) + it("AP-01c-2: pageHtml should exclude the extension's own injected UI containers", async () => { + vi.mocked(findAiService).mockResolvedValue(makeDomService()) + + const rootDom = document.createElement("div") + rootDom.id = APP_ID + document.body.appendChild(rootDom) + + const hubDom = document.createElement("div") + hubDom.id = `${APP_ID}-hub` + document.body.appendChild(hubDom) + + try { + await AiPrompt.execute({ + selectionText: "hello world", + command: { + ...baseCommand, + aiPromptOption: { + ...baseCommand.aiPromptOption, + serviceId: "gemini", + prompt: toInsertTemplate(INSERT.PAGE_HTML), + }, + } as any, + position: { x: 100, y: 100 }, + }) + + const call = vi + .mocked(Ipc.send) + .mock.calls.find(([cmd]) => cmd === BgCommand.openAndRunPageAction) + const payload = call?.[1] as any + + expect(payload.pageHtml).not.toContain(`id="${APP_ID}"`) + expect(payload.pageHtml).not.toContain(`id="${APP_ID}-hub"`) + } finally { + rootDom.remove() + hubDom.remove() + } + }) + it("AP-01d: should still attach SELECTION_HTML when the prompt contains only SELECTION_HTML", async () => { vi.mocked(findAiService).mockResolvedValue(makeDomService()) diff --git a/packages/extension/src/action/aiPrompt.ts b/packages/extension/src/action/aiPrompt.ts index 0e6e9588..8f986079 100644 --- a/packages/extension/src/action/aiPrompt.ts +++ b/packages/extension/src/action/aiPrompt.ts @@ -22,15 +22,10 @@ import type { OpenAndRunProps } from "@/services/pageAction/background" import type { OpenSidePanelProps } from "@/services/chrome" import { findAiService } from "@/services/aiPrompt" import { isAiPromptType } from "@/types/schema" -import { - INSERT, - InsertSymbol, - toInsertTemplate, - PAGE_HTML_MAX_CHARS, -} from "@/services/pageAction" +import { INSERT, InsertSymbol, toInsertTemplate } from "@/services/pageAction" import { Storage, SESSION_STORAGE_KEY } from "@/services/storage" import { getUILanguage } from "@/services/i18n" -import { getSelectionHtml } from "@/services/dom" +import { getSelectionHtml, getPageHtml } from "@/services/dom" /** * Convert bare URLs in text to Markdown link format [URL](URL). @@ -104,9 +99,7 @@ export const AiPrompt = { const needPageHtml = aiPromptOption.prompt.includes( toInsertTemplate(INSERT.PAGE_HTML), ) - const pageHtml = needPageHtml - ? document.documentElement.outerHTML.slice(0, PAGE_HTML_MAX_CHARS) - : undefined + const pageHtml = needPageHtml ? getPageHtml() : undefined // Only one HTML attachment is allowed per execution; PAGE_HTML takes // priority over SELECTION_HTML when the prompt contains both. @@ -297,9 +290,9 @@ export const AiPrompt = { // Remove the HTML placeholders from the prompt value. const promptValue = needFilePaste ? aiPromptOption.prompt - .replaceAll(toInsertTemplate(INSERT.PAGE_HTML), "") - .replaceAll(toInsertTemplate(INSERT.SELECTION_HTML), "") - .trim() + .replaceAll(toInsertTemplate(INSERT.PAGE_HTML), "") + .replaceAll(toInsertTemplate(INSERT.SELECTION_HTML), "") + .trim() : aiPromptOption.prompt steps = [ diff --git a/packages/extension/src/command_hub.tsx b/packages/extension/src/command_hub.tsx index cc6d791b..565ac5ec 100644 --- a/packages/extension/src/command_hub.tsx +++ b/packages/extension/src/command_hub.tsx @@ -26,7 +26,7 @@ const cloneCss = (from: ShadowRoot, to: ShadowRoot, selector: string) => { function setupCommandHub() { const rootDom = document.createElement("div") - rootDom.id = `${APP_ID}-command-hub` + rootDom.id = `${APP_ID}-hub` document.body.insertAdjacentElement("afterend", rootDom) const shadow = rootDom.attachShadow({ mode }) const root = createRoot(shadow) @@ -45,9 +45,7 @@ function renderMyCommands() { container.style.display = "block" if (isDebug) { - const from = document.getElementById( - "selection-command-command-hub", - )?.shadowRoot + const from = document.getElementById(`${APP_ID}-hub`)?.shadowRoot from && cloneCss(from, shadow, "style") } else { insertCss(shadow) diff --git a/packages/extension/src/components/pageAction/InputPopup.tsx b/packages/extension/src/components/pageAction/InputPopup.tsx index d4a8e5c2..54d59459 100644 --- a/packages/extension/src/components/pageAction/InputPopup.tsx +++ b/packages/extension/src/components/pageAction/InputPopup.tsx @@ -156,7 +156,7 @@ const useVisibleDelay = (props: useDelayProps) => { return () => { clearTimeout(delayTimer) } - }, [visible]) + }, [visible, updater]) } export function InputPopup(): JSX.Element { @@ -173,16 +173,17 @@ export function InputPopup(): JSX.Element { setTargetElm(getFocusNode(e)) } - const onFocusIn = (e: any) => { + const onFocusIn = (e: FocusEvent) => { + if (!e.target) return if (!isTargetEditable(e.target)) return - if (isPopup(e.target)) return + if (isPopup(e.target as Element)) return setMenuVisible(true) updateTarget(e) } - const onFocusOut = (e: any) => { + const onFocusOut = (e: FocusEvent) => { if (!isTargetEditable(e.target)) return - if (isPopup(e.relatedTarget)) return + if (isPopup(e.relatedTarget as Element)) return setMenuVisible(false) setTargetElm(null) } @@ -195,7 +196,7 @@ export function InputPopup(): JSX.Element { if (isHtmlElement(e.target)) { setDisabled( e.target.children.length > 0 && - e.target.innerText.trim().length !== 0, + e.target.innerText.trim().length !== 0, ) } } @@ -287,7 +288,7 @@ export function InputMenu(props: MenuProps): JSX.Element { return ( @@ -332,40 +333,40 @@ export function InputMenu(props: MenuProps): JSX.Element { - {!props.hideFilePaste && ( - - +
+ + onSelectedMenuChange(MENU.FILE_PASTE)} - > - {t("PageAction_InputMenu_fileAttach")} - {!showFileAttachMenu && - (selectedMenu === MENU.FILE_PASTE ? ( - - ) : ( - - ))} - - setSelectedMenu("")} - sideOffset={2} - > - - - {t("PageAction_InputMenu_pageHtml")} - - - - {t("PageAction_InputMenu_selectionHtml")} - - - + )} + disabled={props.fileAttachDisabled} + onMouseEnter={() => onSelectedMenuChange(MENU.FILE_PASTE)} + > + {t("PageAction_InputMenu_fileAttach")} + + setSelectedMenu("")} + sideOffset={2} + > + + + {t("PageAction_InputMenu_pageHtml")} + + + + {t("PageAction_InputMenu_selectionHtml")} + + + + )} ) @@ -438,7 +439,7 @@ function FocusOutline(props: FocusOutlineProps): JSX.Element { requestAnimationFrame(() => { setRect(container?.getBoundingClientRect()) }) - }, [shouldRender]) + }, [container, shouldRender]) if (elm == null || rect == null || !visible) return <> diff --git a/packages/extension/src/services/dom/index.test.ts b/packages/extension/src/services/dom/index.test.ts index ea9e2d4d..dc1dbf77 100644 --- a/packages/extension/src/services/dom/index.test.ts +++ b/packages/extension/src/services/dom/index.test.ts @@ -1,8 +1,10 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest" +import { APP_ID } from "@/const" import { getSelectionText, getInputSelectionEndPoint, getEditableSelectionEndPoint, + getPageHtml, isInputOrTextarea, isEditable, } from "./index" @@ -242,6 +244,69 @@ describe("getEditableSelectionEndPoint", () => { }) }) +describe("getPageHtml", () => { + afterEach(() => { + document.body.innerHTML = "" + }) + + it("GPH-01: excludes the content script's injected container", () => { + const marker = document.createElement("p") + marker.textContent = "page content" + document.body.appendChild(marker) + + const rootDom = document.createElement("div") + rootDom.id = APP_ID + document.body.appendChild(rootDom) + + const html = getPageHtml() + expect(html).not.toContain(`id="${APP_ID}"`) + expect(html).toContain("page content") + }) + + it("GPH-02: excludes the Command Hub's injected container", () => { + const marker = document.createElement("p") + marker.textContent = "page content" + document.body.appendChild(marker) + + const hubDom = document.createElement("div") + hubDom.id = `${APP_ID}-hub` + document.body.appendChild(hubDom) + + const html = getPageHtml() + expect(html).not.toContain(`id="${APP_ID}-hub"`) + expect(html).toContain("page content") + }) + + it("GPH-03: excludes both injected containers when present together", () => { + const marker = document.createElement("p") + marker.textContent = "page content" + document.body.appendChild(marker) + + const rootDom = document.createElement("div") + rootDom.id = APP_ID + document.body.appendChild(rootDom) + + const hubDom = document.createElement("div") + hubDom.id = `${APP_ID}-hub` + document.body.appendChild(hubDom) + + const html = getPageHtml() + expect(html).not.toContain(`id="${APP_ID}"`) + expect(html).not.toContain(`id="${APP_ID}-hub"`) + expect(html).toContain("page content") + }) + + it("GPH-04: returns page HTML unchanged when no injected containers exist", () => { + const marker = document.createElement("p") + marker.textContent = "page content" + document.body.appendChild(marker) + + const html = getPageHtml() + expect(html).toContain("page content") + expect(html).toContain(" { it("IIOT-01: returns true for text input", () => { const input = document.createElement("input") diff --git a/packages/extension/src/services/dom/index.ts b/packages/extension/src/services/dom/index.ts index 08d53b44..52fa1e90 100644 --- a/packages/extension/src/services/dom/index.ts +++ b/packages/extension/src/services/dom/index.ts @@ -1,7 +1,7 @@ import type { Point } from "@/types" import { isEmpty } from "@/lib/utils" -import { PAGE_HTML_MAX_CHARS } from "@/const" +import { APP_ID, PAGE_HTML_MAX_CHARS } from "@/const" export function toDataURL(src: string, outputFormat?: string): Promise { return new Promise((resolve, reject) => { @@ -104,6 +104,18 @@ export function getSelectionHtml(): string { return div.innerHTML.slice(0, PAGE_HTML_MAX_CHARS) } +/** + * Get the page's outer HTML, excluding the extension's own injected UI + * containers (content script root and Command Hub root) so they aren't + * sent along as page content. + */ +export function getPageHtml(): string { + const clone = document.documentElement.cloneNode(true) as HTMLElement + clone.querySelector(`#${APP_ID}`)?.remove() + clone.querySelector(`#${APP_ID}-hub`)?.remove() + return clone.outerHTML.slice(0, PAGE_HTML_MAX_CHARS) +} + /** * Find the anchor element from the specified element. * diff --git a/packages/extension/vite.config.ts b/packages/extension/vite.config.ts index 055c6df0..9e57c363 100644 --- a/packages/extension/vite.config.ts +++ b/packages/extension/vite.config.ts @@ -27,23 +27,23 @@ export default defineConfig(({ mode }) => { const activeManifest = isProduction ? { - ...manifest, - ...(extensionKey ? { key: extensionKey } : {}), - content_scripts: manifest.content_scripts.map((cs) => ({ - ...cs, - matches: cs.matches.filter((m) => !m.includes("localhost")), - })), - externally_connectable: { - ...manifest.externally_connectable, - matches: manifest.externally_connectable.matches.filter( - (m) => !m.includes("localhost"), - ), - }, - } + ...manifest, + ...(extensionKey ? { key: extensionKey } : {}), + content_scripts: manifest.content_scripts.map((cs) => ({ + ...cs, + matches: cs.matches.filter((m) => !m.includes("localhost")), + })), + externally_connectable: { + ...manifest.externally_connectable, + matches: manifest.externally_connectable.matches.filter( + (m) => !m.includes("localhost"), + ), + }, + } : { - ...manifest, - ...(extensionKey ? { key: extensionKey } : {}), - } + ...manifest, + ...(extensionKey ? { key: extensionKey } : {}), + } const plugins = [ react(), @@ -99,7 +99,7 @@ export default defineConfig(({ mode }) => { if ( options.attributes["data-vite-dev-id"].match(/command_hub/) ) { - targetId = "selection-command-command-hub" + targetId = "selection-command-hub" } const root = document.getElementById(targetId)?.shadowRoot ?? document.head // Option page @@ -149,20 +149,20 @@ export default defineConfig(({ mode }) => { pure: mode === "production" ? [ - "console.log", - "console.debug", - "console.info", - "console.trace", - "console.dir", - "console.count", - "console.countReset", - "console.group", - "console.groupCollapsed", - "console.groupEnd", - "console.time", - "console.timeEnd", - "console.timeLog", - ] + "console.log", + "console.debug", + "console.info", + "console.trace", + "console.dir", + "console.count", + "console.countReset", + "console.group", + "console.groupCollapsed", + "console.groupEnd", + "console.time", + "console.timeEnd", + "console.timeLog", + ] : [], }, build: {