fix(provider): confirm prompt submission
This commit is contained in:
@@ -142,11 +142,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function findGenericSendButton(editor) {
|
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"];
|
const hints = ["send", "发送", "提交", "submit"];
|
||||||
for (const scope of scopes) {
|
for (const scope of scopes) {
|
||||||
const buttons = [...scope.querySelectorAll("button")].filter((button) => {
|
const buttons = [...scope.querySelectorAll("button")].filter((button) => {
|
||||||
if (!isVisible(button) || button.disabled) return false;
|
if (!isEnabledControl(button)) return false;
|
||||||
const text = normalizedText(button).toLowerCase();
|
const text = normalizedText(button).toLowerCase();
|
||||||
return button.getAttribute("type") === "submit" || hints.some((hint) => text.includes(hint));
|
return button.getAttribute("type") === "submit" || hints.some((hint) => text.includes(hint));
|
||||||
});
|
});
|
||||||
@@ -155,17 +155,60 @@
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submit(adapter, editor) {
|
function isEnabledControl(element) {
|
||||||
const button = await waitFor(() => (
|
return isVisible(element)
|
||||||
queryFirstVisible(adapter.sendSelectors, (element) => isVisible(element) && !element.disabled)
|
&& !element.disabled
|
||||||
|| findGenericSendButton(editor)
|
&& element.getAttribute("aria-disabled") !== "true"
|
||||||
), adapter.sendReadyTimeoutMs ?? 2500, 100);
|
&& element.getAttribute("aria-busy") !== "true";
|
||||||
|
}
|
||||||
|
|
||||||
if (button) {
|
function editorScopes(editor) {
|
||||||
button.click();
|
const scopes = [];
|
||||||
return;
|
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();
|
editor.focus();
|
||||||
for (const type of ["keydown", "keypress", "keyup"]) {
|
for (const type of ["keydown", "keypress", "keyup"]) {
|
||||||
editor.dispatchEvent(new KeyboardEvent(type, {
|
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) {
|
async function sendPrompt(adapter, prompt) {
|
||||||
const editor = await waitFor(
|
const editor = await waitFor(
|
||||||
() => queryFirstVisible(adapter.editorSelectors, isUsableEditor),
|
() => queryFirstVisible(adapter.editorSelectors, isUsableEditor),
|
||||||
@@ -191,8 +274,7 @@
|
|||||||
if (!editorContainsPrompt(editor, prompt)) await fillEditor(adapter, editor, prompt);
|
if (!editorContainsPrompt(editor, prompt)) await fillEditor(adapter, editor, prompt);
|
||||||
if (!editorContainsPrompt(editor, prompt)) throw new Error("无法可靠写入 Prompt");
|
if (!editorContainsPrompt(editor, prompt)) throw new Error("无法可靠写入 Prompt");
|
||||||
|
|
||||||
await submit(adapter, editor);
|
return submit(adapter, editor, prompt);
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function collectResponse(adapter, rootDocument = document) {
|
function collectResponse(adapter, rootDocument = document) {
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ let selected = new Set();
|
|||||||
let currentLayout = "auto";
|
let currentLayout = "auto";
|
||||||
let runtimeUpgradeWarning = "";
|
let runtimeUpgradeWarning = "";
|
||||||
let pendingLaunchRunning = false;
|
let pendingLaunchRunning = false;
|
||||||
|
let dispatchInFlight = false;
|
||||||
|
|
||||||
function providerById(id) {
|
function providerById(id) {
|
||||||
return PROVIDERS.find((provider) => provider.id === id);
|
return PROVIDERS.find((provider) => provider.id === id);
|
||||||
@@ -553,10 +554,12 @@ function collectResponseFromProvider(providerId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function dispatchPrompt() {
|
async function dispatchPrompt() {
|
||||||
|
if (dispatchInFlight) return;
|
||||||
const prompt = promptInput.value.trim();
|
const prompt = promptInput.value.trim();
|
||||||
if (!prompt) return showError("请输入 Prompt");
|
if (!prompt) return showError("请输入 Prompt");
|
||||||
if (!selected.size) return showError("至少选择一个模型");
|
if (!selected.size) return showError("至少选择一个模型");
|
||||||
|
|
||||||
|
dispatchInFlight = true;
|
||||||
responseBundles.clear();
|
responseBundles.clear();
|
||||||
renderResponses();
|
renderResponses();
|
||||||
showError(runtimeUpgradeWarning);
|
showError(runtimeUpgradeWarning);
|
||||||
@@ -589,6 +592,7 @@ async function dispatchPrompt() {
|
|||||||
showError(error instanceof Error ? error.message : String(error));
|
showError(error instanceof Error ? error.message : String(error));
|
||||||
dispatchStatus.textContent = "发送失败";
|
dispatchStatus.textContent = "发送失败";
|
||||||
} finally {
|
} finally {
|
||||||
|
dispatchInFlight = false;
|
||||||
updateMeta();
|
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