fix(provider): confirm prompt submission
This commit is contained in:
@@ -142,11 +142,11 @@
|
||||
}
|
||||
|
||||
function findGenericSendButton(editor) {
|
||||
const scopes = [editor.closest("form"), editor.parentElement, editor.closest("main"), document.body].filter(Boolean);
|
||||
const scopes = editorScopes(editor);
|
||||
const hints = ["send", "发送", "提交", "submit"];
|
||||
for (const scope of scopes) {
|
||||
const buttons = [...scope.querySelectorAll("button")].filter((button) => {
|
||||
if (!isVisible(button) || button.disabled) return false;
|
||||
if (!isEnabledControl(button)) return false;
|
||||
const text = normalizedText(button).toLowerCase();
|
||||
return button.getAttribute("type") === "submit" || hints.some((hint) => text.includes(hint));
|
||||
});
|
||||
@@ -155,17 +155,60 @@
|
||||
return null;
|
||||
}
|
||||
|
||||
async function submit(adapter, editor) {
|
||||
const button = await waitFor(() => (
|
||||
queryFirstVisible(adapter.sendSelectors, (element) => isVisible(element) && !element.disabled)
|
||||
|| findGenericSendButton(editor)
|
||||
), adapter.sendReadyTimeoutMs ?? 2500, 100);
|
||||
function isEnabledControl(element) {
|
||||
return isVisible(element)
|
||||
&& !element.disabled
|
||||
&& element.getAttribute("aria-disabled") !== "true"
|
||||
&& element.getAttribute("aria-busy") !== "true";
|
||||
}
|
||||
|
||||
if (button) {
|
||||
button.click();
|
||||
return;
|
||||
function editorScopes(editor) {
|
||||
const scopes = [];
|
||||
const add = (scope) => {
|
||||
if (scope && !scopes.includes(scope)) scopes.push(scope);
|
||||
};
|
||||
|
||||
let current = editor.parentElement;
|
||||
for (let depth = 0; current && depth < 5; depth += 1) {
|
||||
add(current);
|
||||
current = current.parentElement;
|
||||
}
|
||||
add(editor.closest("form"));
|
||||
add(editor.closest("[role='form']"));
|
||||
add(editor.closest("main"));
|
||||
return scopes;
|
||||
}
|
||||
|
||||
function queryFirstVisibleInEditorScope(selectors, editor) {
|
||||
for (const scope of editorScopes(editor)) {
|
||||
const match = queryFirstVisible(selectors, isEnabledControl, scope);
|
||||
if (match) return match;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function findSendButton(adapter, editor) {
|
||||
return queryFirstVisibleInEditorScope(adapter.sendSelectors, editor) || findGenericSendButton(editor);
|
||||
}
|
||||
|
||||
function responseCount(adapter) {
|
||||
return queryVisible(
|
||||
adapter.responseSelectors,
|
||||
(element) => isVisible(element) && !element.closest("[aria-hidden='true']")
|
||||
).length;
|
||||
}
|
||||
|
||||
function submissionConfirmed(adapter, editor, prompt, initialButton, initialResponseCount) {
|
||||
const currentEditor = queryFirstVisible(adapter.editorSelectors, isUsableEditor);
|
||||
if (!currentEditor || !editorContainsPrompt(currentEditor, prompt)) return true;
|
||||
if (initialButton && (!initialButton.isConnected
|
||||
|| initialButton.disabled
|
||||
|| initialButton.getAttribute("aria-disabled") === "true"
|
||||
|| initialButton.getAttribute("aria-busy") === "true")) return true;
|
||||
return responseCount(adapter) > initialResponseCount;
|
||||
}
|
||||
|
||||
function dispatchEnter(editor) {
|
||||
editor.focus();
|
||||
for (const type of ["keydown", "keypress", "keyup"]) {
|
||||
editor.dispatchEvent(new KeyboardEvent(type, {
|
||||
@@ -179,6 +222,46 @@
|
||||
}
|
||||
}
|
||||
|
||||
function requestFormSubmit(editor) {
|
||||
const form = editor.closest("form");
|
||||
if (typeof form?.requestSubmit !== "function") return false;
|
||||
form.requestSubmit();
|
||||
return true;
|
||||
}
|
||||
|
||||
async function submit(adapter, editor, prompt) {
|
||||
const immediateButton = findSendButton(adapter, editor);
|
||||
const button = immediateButton || (adapter.sendSelectors?.length
|
||||
? await waitFor(
|
||||
() => findSendButton(adapter, editor),
|
||||
adapter.sendReadyTimeoutMs ?? 2500,
|
||||
100
|
||||
)
|
||||
: null);
|
||||
const initialResponseCount = responseCount(adapter);
|
||||
const attempts = button
|
||||
? [
|
||||
() => button.click(),
|
||||
() => requestFormSubmit(editor) || dispatchEnter(editor)
|
||||
]
|
||||
: [
|
||||
() => requestFormSubmit(editor) || dispatchEnter(editor),
|
||||
() => dispatchEnter(editor)
|
||||
];
|
||||
|
||||
for (const attempt of attempts) {
|
||||
try { attempt(); } catch { /* Try the next bounded submission path. */ }
|
||||
const confirmed = await waitFor(
|
||||
() => submissionConfirmed(adapter, editor, prompt, button, initialResponseCount),
|
||||
adapter.submitConfirmationTimeoutMs ?? 3000,
|
||||
100
|
||||
);
|
||||
if (confirmed) return true;
|
||||
}
|
||||
|
||||
throw new Error("无法确认 Prompt 已发送;请在模型窗口中重试");
|
||||
}
|
||||
|
||||
async function sendPrompt(adapter, prompt) {
|
||||
const editor = await waitFor(
|
||||
() => queryFirstVisible(adapter.editorSelectors, isUsableEditor),
|
||||
@@ -191,8 +274,7 @@
|
||||
if (!editorContainsPrompt(editor, prompt)) await fillEditor(adapter, editor, prompt);
|
||||
if (!editorContainsPrompt(editor, prompt)) throw new Error("无法可靠写入 Prompt");
|
||||
|
||||
await submit(adapter, editor);
|
||||
return true;
|
||||
return submit(adapter, editor, prompt);
|
||||
}
|
||||
|
||||
function collectResponse(adapter, rootDocument = document) {
|
||||
|
||||
@@ -55,6 +55,7 @@ let selected = new Set();
|
||||
let currentLayout = "auto";
|
||||
let runtimeUpgradeWarning = "";
|
||||
let pendingLaunchRunning = false;
|
||||
let dispatchInFlight = false;
|
||||
|
||||
function providerById(id) {
|
||||
return PROVIDERS.find((provider) => provider.id === id);
|
||||
@@ -553,10 +554,12 @@ function collectResponseFromProvider(providerId) {
|
||||
}
|
||||
|
||||
async function dispatchPrompt() {
|
||||
if (dispatchInFlight) return;
|
||||
const prompt = promptInput.value.trim();
|
||||
if (!prompt) return showError("请输入 Prompt");
|
||||
if (!selected.size) return showError("至少选择一个模型");
|
||||
|
||||
dispatchInFlight = true;
|
||||
responseBundles.clear();
|
||||
renderResponses();
|
||||
showError(runtimeUpgradeWarning);
|
||||
@@ -589,6 +592,7 @@ async function dispatchPrompt() {
|
||||
showError(error instanceof Error ? error.message : String(error));
|
||||
dispatchStatus.textContent = "发送失败";
|
||||
} finally {
|
||||
dispatchInFlight = false;
|
||||
updateMeta();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const test = require("node:test");
|
||||
const vm = require("node:vm");
|
||||
|
||||
const corePath = path.join(__dirname, "..", "apps", "browser-extension", "content", "providers", "core.js");
|
||||
|
||||
class FakeElement {
|
||||
constructor({ parent = null, onClick = null } = {}) {
|
||||
this.parentElement = parent;
|
||||
this.onClick = onClick;
|
||||
this.disabled = false;
|
||||
this.readOnly = false;
|
||||
this.attributes = {};
|
||||
this.style = {};
|
||||
this.connected = true;
|
||||
this.clickCount = 0;
|
||||
this.events = [];
|
||||
this.queryMap = new Map();
|
||||
}
|
||||
|
||||
get isConnected() {
|
||||
return this.connected;
|
||||
}
|
||||
|
||||
get textContent() {
|
||||
return this._textContent || "";
|
||||
}
|
||||
|
||||
set textContent(value) {
|
||||
this._textContent = String(value);
|
||||
}
|
||||
|
||||
getAttribute(name) {
|
||||
return this.attributes[name] ?? null;
|
||||
}
|
||||
|
||||
closest(selector) {
|
||||
if (selector === "form") return this.form || null;
|
||||
return null;
|
||||
}
|
||||
|
||||
querySelectorAll(selector) {
|
||||
return this.queryMap.get(selector) || [];
|
||||
}
|
||||
|
||||
getBoundingClientRect() {
|
||||
return { width: 100, height: 20 };
|
||||
}
|
||||
|
||||
compareDocumentPosition() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
focus() {}
|
||||
|
||||
click() {
|
||||
this.clickCount += 1;
|
||||
this.onClick?.();
|
||||
}
|
||||
|
||||
dispatchEvent(event) {
|
||||
this.events.push(event.type);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
class FakeTextArea extends FakeElement {
|
||||
constructor(options) {
|
||||
super(options);
|
||||
this._value = "";
|
||||
}
|
||||
|
||||
get value() {
|
||||
return this._value;
|
||||
}
|
||||
|
||||
set value(value) {
|
||||
this._value = String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function loadCore({ withButton = true, confirmOn = "button" } = {}) {
|
||||
const body = new FakeElement();
|
||||
const form = new FakeElement();
|
||||
const editor = new FakeTextArea({ parent: form });
|
||||
editor.form = form;
|
||||
form.form = form;
|
||||
const button = withButton ? new FakeElement({ parent: form }) : null;
|
||||
if (button) {
|
||||
button.attributes.type = "submit";
|
||||
button.onClick = () => {
|
||||
if (confirmOn === "button") {
|
||||
editor.value = "";
|
||||
button.disabled = true;
|
||||
}
|
||||
};
|
||||
}
|
||||
let requestSubmitCount = 0;
|
||||
form.requestSubmit = () => {
|
||||
requestSubmitCount += 1;
|
||||
if (confirmOn === "form") {
|
||||
editor.value = "";
|
||||
if (button) button.disabled = true;
|
||||
}
|
||||
};
|
||||
form.queryMap.set("button", button ? [button] : []);
|
||||
form.queryMap.set("button[type='submit']", button ? [button] : []);
|
||||
body.queryMap.set("button[type='submit']", [new FakeElement({ parent: body })]);
|
||||
|
||||
const document = {
|
||||
body,
|
||||
querySelectorAll(selector) {
|
||||
if (selector === "textarea") return [editor];
|
||||
if (selector === "button[type='submit']") return body.querySelectorAll(selector);
|
||||
return [];
|
||||
},
|
||||
createRange() {
|
||||
return { selectNodeContents() {} };
|
||||
}
|
||||
};
|
||||
const context = {
|
||||
console,
|
||||
document,
|
||||
Element: FakeElement,
|
||||
HTMLTextAreaElement: FakeTextArea,
|
||||
HTMLInputElement: class extends FakeElement {},
|
||||
InputEvent: class { constructor(type) { this.type = type; } },
|
||||
Event: class { constructor(type) { this.type = type; } },
|
||||
KeyboardEvent: class { constructor(type) { this.type = type; } },
|
||||
getComputedStyle: () => ({ display: "block", visibility: "visible", opacity: "1" }),
|
||||
setTimeout,
|
||||
clearTimeout
|
||||
};
|
||||
context.globalThis = context;
|
||||
vm.createContext(context);
|
||||
vm.runInContext(fs.readFileSync(corePath, "utf8"), context, { filename: corePath });
|
||||
|
||||
return {
|
||||
core: context.AIParallelProviderCore,
|
||||
editor,
|
||||
button,
|
||||
form,
|
||||
get requestSubmitCount() { return requestSubmitCount; }
|
||||
};
|
||||
}
|
||||
|
||||
test("provider submission scopes the send button and confirms the UI transition", async () => {
|
||||
const fixture = loadCore({ withButton: true, confirmOn: "button" });
|
||||
const adapter = fixture.core.createProviderAdapter({
|
||||
id: "fixture",
|
||||
hosts: ["fixture.test"],
|
||||
editorSelectors: ["textarea"],
|
||||
sendSelectors: ["button[type='submit']"],
|
||||
responseSelectors: [],
|
||||
submitConfirmationTimeoutMs: 50
|
||||
});
|
||||
|
||||
const result = await adapter.sendPrompt("hello provider");
|
||||
|
||||
assert.equal(result, true);
|
||||
assert.equal(fixture.button.clickCount, 1);
|
||||
assert.equal(fixture.form.querySelectorAll("button[type='submit']").length, 1);
|
||||
assert.equal(fixture.editor.value, "");
|
||||
});
|
||||
|
||||
test("provider submission uses native form submission before synthetic Enter", async () => {
|
||||
const fixture = loadCore({ withButton: false, confirmOn: "form" });
|
||||
const adapter = fixture.core.createProviderAdapter({
|
||||
id: "fixture",
|
||||
hosts: ["fixture.test"],
|
||||
editorSelectors: ["textarea"],
|
||||
sendSelectors: [],
|
||||
responseSelectors: [],
|
||||
submitConfirmationTimeoutMs: 50
|
||||
});
|
||||
|
||||
await adapter.sendPrompt("submit through form");
|
||||
|
||||
assert.equal(fixture.requestSubmitCount, 1);
|
||||
assert.equal(fixture.editor.value, "");
|
||||
});
|
||||
|
||||
test("provider submission reports deterministic failure when no UI confirmation occurs", async () => {
|
||||
const fixture = loadCore({ withButton: true, confirmOn: "never" });
|
||||
const adapter = fixture.core.createProviderAdapter({
|
||||
id: "fixture",
|
||||
hosts: ["fixture.test"],
|
||||
editorSelectors: ["textarea"],
|
||||
sendSelectors: ["button[type='submit']"],
|
||||
responseSelectors: [],
|
||||
sendReadyTimeoutMs: 50,
|
||||
submitConfirmationTimeoutMs: 20
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
adapter.sendPrompt("never submitted"),
|
||||
/无法确认 Prompt 已发送/
|
||||
);
|
||||
assert.equal(fixture.button.clickCount, 1);
|
||||
assert.equal(fixture.requestSubmitCount, 1);
|
||||
assert.equal(fixture.editor.value, "never submitted");
|
||||
});
|
||||
Reference in New Issue
Block a user