fix(provider): confirm prompt submission

This commit is contained in:
2026-09-15 14:26:01 +08:00
parent 869efc4a04
commit cf54805d60
3 changed files with 302 additions and 12 deletions
@@ -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();
} }
} }
+204
View File
@@ -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");
});