Add initial llm-bridge project
This commit is contained in:
Vendored
+387
@@ -0,0 +1,387 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>ChatGPT fixture</title>
|
||||
<!--
|
||||
Local fake of the ChatGPT web UI used by the browser end-to-end tests.
|
||||
|
||||
The Rust driver opens this file with a file:// URL and drives it over CDP:
|
||||
it inserts text through Input.insertText / execCommand / key events, clicks
|
||||
button[data-testid=send-button], then polls the DOM until the answer settles.
|
||||
|
||||
DOM contract (selectors live in the test provider TOML):
|
||||
- editor div#prompt-textarea.ProseMirror[contenteditable=true]
|
||||
- send button button[data-testid=send-button] (disabled while empty)
|
||||
- answer containers div[data-message-author-role=assistant] (one per answer)
|
||||
the text lives in div.answer-body inside it, and the
|
||||
toolbar (with the copy button) is a sibling of the
|
||||
answer node, exactly like the real page: the capture
|
||||
must not pick the word "Copy" up as answer text
|
||||
- answer toolbar div.message-toolbar > button[data-testid=copy-button]
|
||||
copies the answer as Markdown, like the real UI
|
||||
- streaming indicator button[data-testid=stop-button] (only while generating)
|
||||
- file input input[type=file] (hidden)
|
||||
- attachment chips div[data-testid=attachment-chip]
|
||||
- new chat a[data-testid=create-new-chat-button]
|
||||
- login mode form[action="/auth/login"]
|
||||
- captcha mode div#challenge-form
|
||||
- error mode div[role=alert]
|
||||
|
||||
Query parameters:
|
||||
conv=1 after the first send, set location.hash to #c=<uuid> and keep it
|
||||
login=1 render the sign-in form instead of the editor
|
||||
captcha=1 render a bot challenge
|
||||
error=1 render an error banner; sending produces no answer
|
||||
ratelimit=1 the page text contains "too many requests"; sending produces no answer
|
||||
empty=1 sending shows the stop button but never writes any text
|
||||
delay=<ms> minimum generation time (default 1200)
|
||||
loading=1 the editor only appears after 3 seconds
|
||||
rewrite=1 the answer is rewritten mid-stream before settling
|
||||
rich=1 the answer is rendered as rich HTML (heading, list, code
|
||||
block, table) and the copy button copies it as Markdown
|
||||
-->
|
||||
<style>
|
||||
body { font: 15px/1.5 system-ui, sans-serif; margin: 0; background: #212121; color: #ececec; }
|
||||
header { padding: 10px 16px; border-bottom: 1px solid #3a3a3a; display: flex; gap: 12px; }
|
||||
#messages { padding: 16px; height: 320px; overflow: auto; }
|
||||
.msg { max-width: 70%; margin: 8px 0; padding: 8px 12px; border-radius: 10px; white-space: pre-wrap; }
|
||||
[data-message-author-role="user"] { background: #2f2f2f; margin-left: auto; }
|
||||
[data-message-author-role="assistant"] { background: #303030; }
|
||||
footer { padding: 12px 16px; border-top: 1px solid #3a3a3a; }
|
||||
#prompt-textarea { min-height: 48px; padding: 10px; background: #303030; border-radius: 12px;
|
||||
border: 1px solid #4a4a4a; outline: none; }
|
||||
#prompt-textarea:focus { border-color: #7aa2f7; }
|
||||
button { font: inherit; padding: 6px 12px; border-radius: 8px; border: 1px solid #4a4a4a;
|
||||
background: #303030; color: #ececec; cursor: pointer; }
|
||||
button[disabled] { opacity: .45; cursor: default; }
|
||||
#attachments { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 8px; }
|
||||
[data-testid="attachment-chip"] { background: #3a3a3a; border-radius: 6px; padding: 2px 8px; font-size: 13px; }
|
||||
.message-toolbar { display: flex; gap: 6px; margin: 2px 0 10px; }
|
||||
.message-toolbar button { font-size: 12px; padding: 2px 8px; }
|
||||
.answer-body table { border-collapse: collapse; margin: 6px 0; }
|
||||
.answer-body th, .answer-body td { border: 1px solid #555; padding: 2px 8px; }
|
||||
.answer-body pre { background: #1b1b1b; padding: 8px; border-radius: 6px; overflow: auto; }
|
||||
#toolbar { display: flex; justify-content: space-between; align-items: center; margin-top: 8px; }
|
||||
.banner { margin: 12px 16px; padding: 10px 12px; border-radius: 8px; background: #4a2020; }
|
||||
#challenge-form { margin: 24px 16px; padding: 20px; background: #3a3a3a; border-radius: 10px; }
|
||||
form { margin: 40px auto; width: 320px; padding: 24px; background: #2a2a2a; border-radius: 12px; }
|
||||
input[type="email"], input[type="password"] { width: 100%; padding: 8px; margin: 4px 0 12px;
|
||||
background: #303030; border: 1px solid #4a4a4a;
|
||||
border-radius: 8px; color: inherit; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script>
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
var params = new URLSearchParams(location.search);
|
||||
var mode = function (name) { return params.get(name) === "1"; };
|
||||
|
||||
var state = {
|
||||
generating: false,
|
||||
token: 0,
|
||||
attachments: [],
|
||||
conversation: null,
|
||||
answerCount: 0
|
||||
};
|
||||
|
||||
// A reopened conversation keeps its identifier so that the driver can prove
|
||||
// that it is continuing the same web thread.
|
||||
var hashMatch = /#c=([0-9a-zA-Z-]+)/.exec(location.hash);
|
||||
if (hashMatch) { state.conversation = hashMatch[1]; }
|
||||
|
||||
function answer(prompt) {
|
||||
var trimmed = prompt.trim();
|
||||
var text;
|
||||
if (trimmed.toLowerCase().indexOf("ok") !== -1) {
|
||||
text = "ok";
|
||||
} else {
|
||||
text = "Echo: " + trimmed + " — this answer was generated by the local ChatGPT fixture page.";
|
||||
}
|
||||
if (state.attachments.length) {
|
||||
text = text + " (attached: " + state.attachments.join(", ") + ")";
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
|
||||
// The rich rendering of one answer: a heading, a list, a fenced code block
|
||||
// and a table, i.e. exactly the constructs a plain innerText capture loses.
|
||||
function richHtml(prompt) {
|
||||
return (
|
||||
"<h2>Answer</h2>" +
|
||||
"<p>Echo: <strong>" + escapeHtml(prompt.trim()) + "</strong></p>" +
|
||||
"<ul><li>first point</li><li>second point</li></ul>" +
|
||||
'<pre><code class="language-rust">fn main() {\n println!("hi");\n}</code></pre>' +
|
||||
"<table><thead><tr><th>Name</th><th>Value</th></tr></thead>" +
|
||||
"<tbody><tr><td>alpha</td><td>1</td></tr><tr><td>beta</td><td>2</td></tr></tbody></table>"
|
||||
);
|
||||
}
|
||||
|
||||
// What the real copy button puts in the clipboard: the Markdown rendering.
|
||||
function richMarkdown(prompt) {
|
||||
return (
|
||||
"## Answer\n\n" +
|
||||
"Echo: **" + prompt.trim() + "**\n\n" +
|
||||
"- first point\n" +
|
||||
"- second point\n\n" +
|
||||
"```rust\n" +
|
||||
'fn main() {\n println!("hi");\n}\n' +
|
||||
"```\n\n" +
|
||||
"| Name | Value |\n" +
|
||||
"| --- | --- |\n" +
|
||||
"| alpha | 1 |\n" +
|
||||
"| beta | 2 |\n"
|
||||
);
|
||||
}
|
||||
|
||||
// Give one assistant message the toolbar the real page shows next to it.
|
||||
// The copy button is a sibling of the answer node, not a child, so a capture
|
||||
// that reads the whole turn would pick the word "Copy" up as answer text.
|
||||
function attachToolbar(turn, prompt) {
|
||||
var toolbar = document.createElement("div");
|
||||
toolbar.className = "message-toolbar";
|
||||
var copy = document.createElement("button");
|
||||
copy.setAttribute("data-testid", "copy-button");
|
||||
copy.setAttribute("aria-label", "Copy");
|
||||
copy.textContent = "Copy";
|
||||
copy.addEventListener("click", function (event) {
|
||||
event.preventDefault();
|
||||
var markdown = richMarkdown(prompt);
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard.writeText(markdown)["catch"](function () {});
|
||||
}
|
||||
});
|
||||
toolbar.appendChild(copy);
|
||||
turn.appendChild(toolbar);
|
||||
}
|
||||
|
||||
// Give one assistant message the toolbar the real page shows next to it.
|
||||
// The copy button is a sibling of the answer node, not a child, so a capture
|
||||
// that reads the whole turn would pick the word "Copy" up as answer text.
|
||||
function attachToolbar(turn, prompt) {
|
||||
var toolbar = document.createElement("div");
|
||||
toolbar.className = "message-toolbar";
|
||||
var copy = document.createElement("button");
|
||||
copy.setAttribute("data-testid", "copy-button");
|
||||
copy.setAttribute("aria-label", "Copy");
|
||||
copy.textContent = "Copy";
|
||||
copy.addEventListener("click", function (event) {
|
||||
event.preventDefault();
|
||||
var markdown = richMarkdown(prompt);
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard.writeText(markdown)["catch"](function () {});
|
||||
}
|
||||
});
|
||||
toolbar.appendChild(copy);
|
||||
turn.appendChild(toolbar);
|
||||
}
|
||||
|
||||
function render() {
|
||||
var app = document.getElementById("app");
|
||||
if (mode("login")) {
|
||||
app.innerHTML =
|
||||
'<form action="/auth/login" method="post">' +
|
||||
'<h2>Sign in (fixture)</h2>' +
|
||||
'<label>Email<input type="email" name="email" /></label>' +
|
||||
'<label>Password<input type="password" name="password" /></label>' +
|
||||
'<button type="submit">Continue</button>' +
|
||||
'</form>';
|
||||
return;
|
||||
}
|
||||
|
||||
var banner = "";
|
||||
if (mode("error")) {
|
||||
banner = '<div role="alert">Something went wrong. Please try again.</div>';
|
||||
}
|
||||
if (mode("ratelimit")) {
|
||||
banner += '<div class="banner">Too many requests right now, please try again later.</div>';
|
||||
}
|
||||
var challenge = mode("captcha") ? '<div id="challenge-form">Verify you are human</div>' : "";
|
||||
|
||||
app.innerHTML =
|
||||
'<header><a data-testid="create-new-chat-button" href="#">New chat</a><span>ChatGPT fixture</span></header>' +
|
||||
banner + challenge +
|
||||
'<div id="messages"></div>' +
|
||||
'<footer>' +
|
||||
'<div id="attachments"></div>' +
|
||||
'<div id="prompt-textarea" class="ProseMirror" contenteditable="true"></div>' +
|
||||
'<div id="toolbar">' +
|
||||
'<input type="file" multiple hidden />' +
|
||||
'<button data-testid="send-button" disabled>Send</button>' +
|
||||
'</div>' +
|
||||
'</footer>';
|
||||
|
||||
var editor = document.getElementById("prompt-textarea");
|
||||
var send = document.querySelector("[data-testid='send-button']");
|
||||
var fileInput = document.querySelector("input[type='file']");
|
||||
|
||||
function refresh() { send.disabled = editor.innerText.trim().length === 0 || state.generating; }
|
||||
editor.addEventListener("input", refresh);
|
||||
editor.addEventListener("keyup", refresh);
|
||||
editor.addEventListener("blur", refresh);
|
||||
window.setInterval(refresh, 100);
|
||||
|
||||
editor.addEventListener("keydown", function (event) {
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
submit();
|
||||
}
|
||||
});
|
||||
|
||||
send.addEventListener("click", function (event) { event.preventDefault(); submit(); });
|
||||
|
||||
fileInput.addEventListener("change", function () {
|
||||
var strip = document.getElementById("attachments");
|
||||
for (var i = 0; i < fileInput.files.length; i += 1) {
|
||||
var file = fileInput.files[i];
|
||||
state.attachments.push(file.name);
|
||||
var chip = document.createElement("div");
|
||||
chip.setAttribute("data-testid", "attachment-chip");
|
||||
chip.textContent = file.name;
|
||||
strip.appendChild(chip);
|
||||
}
|
||||
});
|
||||
|
||||
var newChat = document.querySelector("[data-testid='create-new-chat-button']");
|
||||
newChat.addEventListener("click", function (event) {
|
||||
event.preventDefault();
|
||||
state.token += 1;
|
||||
state.generating = false;
|
||||
state.answerCount = 0;
|
||||
state.attachments = [];
|
||||
document.getElementById("messages").innerHTML = "";
|
||||
document.getElementById("attachments").innerHTML = "";
|
||||
if (state.conversation) { history.replaceState(null, "", location.pathname + location.search); }
|
||||
state.conversation = null;
|
||||
editor.innerText = "";
|
||||
refresh();
|
||||
});
|
||||
|
||||
refresh();
|
||||
}
|
||||
|
||||
function stopButton() { return document.querySelector("[data-testid='stop-button']"); }
|
||||
|
||||
function setStopButton(visible) {
|
||||
var existing = stopButton();
|
||||
if (visible && !existing) {
|
||||
var button = document.createElement("button");
|
||||
button.setAttribute("data-testid", "stop-button");
|
||||
button.textContent = "Stop";
|
||||
button.addEventListener("click", function () { state.token += 1; state.generating = false; setStopButton(false); });
|
||||
document.getElementById("toolbar").appendChild(button);
|
||||
} else if (!visible && existing) {
|
||||
existing.remove();
|
||||
}
|
||||
}
|
||||
|
||||
function submit() {
|
||||
var editor = document.getElementById("prompt-textarea");
|
||||
var prompt = editor.innerText;
|
||||
if (!prompt.trim() || state.generating) { return; }
|
||||
|
||||
var messages = document.getElementById("messages");
|
||||
var user = document.createElement("div");
|
||||
user.setAttribute("data-message-author-role", "user");
|
||||
user.textContent = prompt;
|
||||
messages.appendChild(user);
|
||||
|
||||
editor.innerText = "";
|
||||
editor.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
|
||||
if (mode("conv") && !state.conversation) {
|
||||
state.conversation = (crypto.randomUUID ? crypto.randomUUID() : String(Date.now()));
|
||||
location.hash = "#c=" + state.conversation;
|
||||
}
|
||||
|
||||
if (mode("login") || mode("captcha")) { return; }
|
||||
if (mode("error") || mode("ratelimit")) { return; }
|
||||
|
||||
state.generating = true;
|
||||
state.token += 1;
|
||||
var token = state.token;
|
||||
setStopButton(true);
|
||||
|
||||
// The page wraps one exchange in a "turn" element, like the real UI: the
|
||||
// answer node holds the text only, and the toolbar is its sibling.
|
||||
var turn = document.createElement("div");
|
||||
turn.className = "turn";
|
||||
turn.setAttribute("data-testid", "conversation-turn");
|
||||
var node = document.createElement("div");
|
||||
node.setAttribute("data-message-author-role", "assistant");
|
||||
var body = document.createElement("div");
|
||||
body.className = "answer-body";
|
||||
node.appendChild(body);
|
||||
turn.appendChild(node);
|
||||
messages.appendChild(turn);
|
||||
state.answerCount += 1;
|
||||
if (mode("rich")) {
|
||||
attachToolbar(turn, prompt);
|
||||
}
|
||||
|
||||
var full = answer(prompt);
|
||||
var draft = mode("rewrite") ? "Draft answer being rewritten" : null;
|
||||
var delay = parseInt(params.get("delay") || "1200", 10);
|
||||
var steps = 22;
|
||||
var written = "";
|
||||
|
||||
if (mode("empty")) {
|
||||
window.setTimeout(function () {
|
||||
if (token !== state.token) { return; }
|
||||
state.generating = false;
|
||||
setStopButton(false);
|
||||
}, delay);
|
||||
return;
|
||||
}
|
||||
|
||||
var index = 0;
|
||||
var timer = window.setInterval(function () {
|
||||
if (token !== state.token) { window.clearInterval(timer); return; }
|
||||
index += 1;
|
||||
var target = full;
|
||||
if (draft && index * 2 < steps) { target = draft; }
|
||||
else if (draft && index === Math.floor(steps / 2) + 1) { written = ""; }
|
||||
var chars = Math.ceil(target.length * (index / steps));
|
||||
written = target.slice(0, chars);
|
||||
body.innerText = written;
|
||||
if (index >= steps) {
|
||||
window.clearInterval(timer);
|
||||
// The settled answer is rendered: the rich modes turn it into HTML,
|
||||
// which is what a plain text capture cannot represent.
|
||||
try {
|
||||
if (mode("rich")) {
|
||||
body.innerHTML = richHtml(prompt);
|
||||
} else {
|
||||
body.innerText = full;
|
||||
}
|
||||
} catch (e) {
|
||||
body.innerText = full;
|
||||
}
|
||||
state.generating = false;
|
||||
setStopButton(false);
|
||||
var send = document.querySelector("[data-testid='send-button']");
|
||||
if (send) { send.disabled = document.getElementById("prompt-textarea").innerText.trim().length === 0; }
|
||||
}
|
||||
}, Math.max(20, Math.floor(delay / steps)));
|
||||
}
|
||||
|
||||
window.fixtureState = state;
|
||||
if (mode("loading")) {
|
||||
window.setTimeout(render, 3000);
|
||||
} else {
|
||||
render();
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user