carbon-tokenization / backend /demo /human-typing.ts
tfrere's picture
tfrere HF Staff
feat(demo): full showcase polish - mouse cursor, robust selection, HF affiliations
f469961
Raw
History Blame Contribute Delete
5.02 kB
/**
* human-typing.ts
*
* Helpers to make Playwright type like a real human: variable cadence,
* thinking pauses around punctuation, occasional typos that get corrected
* with backspace, short words sent as bursts, and longer words slowed down.
*
* Designed for demo/marketing videos where the goal is "this looks like a
* person writing", not deterministic test output.
*/
import type { Page, Locator } from "playwright";
import { humanClick } from "./lib/mouse-cursor.js";
export interface HumanTypingOptions {
/** Baseline delay between keystrokes (ms). Actual delay is jittered. */
baseDelay?: number;
/** Jitter range added on top of baseDelay (ms). */
jitter?: number;
/** Probability (0-1) of mistyping a character, then fixing it. */
typoRate?: number;
/** Probability (0-1) of a short "thinking" pause between words. */
thinkRate?: number;
/** Multiplier applied to all delays (1 = normal, 0.5 = fast, 2 = slow). */
speed?: number;
/**
* Optional hook called before every keystroke (including typo chars and
* backspaces). Useful for re-setting the editor caret to a tracked
* position in concurrent/CRDT demos so remote edits can't push the
* persona's cursor off-target.
*/
beforeKey?: () => void | Promise<void>;
}
const DEFAULTS: Required<Omit<HumanTypingOptions, "beforeKey">> = {
baseDelay: 55,
jitter: 85,
typoRate: 0.025,
thinkRate: 0.18,
speed: 1,
};
function rand(min: number, max: number): number {
return min + Math.random() * (max - min);
}
function sleep(ms: number): Promise<void> {
return new Promise((r) => setTimeout(r, ms));
}
/** Picks a plausible typo character adjacent to the target on QWERTY. */
const TYPO_MAP: Record<string, string> = {
a: "s", b: "v", c: "v", d: "s", e: "r", f: "d", g: "h", h: "j",
i: "o", j: "k", k: "l", l: "k", m: "n", n: "m", o: "p", p: "o",
q: "w", r: "t", s: "a", t: "y", u: "i", v: "b", w: "e", x: "c",
y: "u", z: "x",
};
function typoFor(ch: string): string | null {
const lower = ch.toLowerCase();
const neighbor = TYPO_MAP[lower];
if (!neighbor) return null;
return ch === lower ? neighbor : neighbor.toUpperCase();
}
/**
* Type `text` into the currently focused element of `page` with human-like
* rhythm. Adds pauses after punctuation, thinking pauses between words,
* the occasional typo followed by a correction, and small speed variation.
*/
export async function humanType(
page: Page,
text: string,
options: HumanTypingOptions = {}
): Promise<void> {
const { baseDelay, jitter, typoRate, thinkRate, speed } = { ...DEFAULTS, ...options };
const beforeKey = options.beforeKey;
const scale = 1 / speed;
// Small helper that guarantees the caller's `beforeKey` runs before
// every Playwright keystroke. Used by showcase.ts to re-anchor the PM
// selection to a tracked `MappablePosition` so concurrent remote
// edits can't steal the persona's cursor.
const tap = async (fn: () => Promise<void>) => {
if (beforeKey) await beforeKey();
await fn();
};
const tokens = text.split(/(\s+)/);
for (let t = 0; t < tokens.length; t++) {
const token = tokens[t];
if (!token) continue;
if (/^\s+$/.test(token)) {
// Whitespace is still a keystroke from PM's point of view (it can
// trigger input rules like heading or list shortcuts), so keep it
// anchored the same way as regular chars.
for (const ws of token) {
await tap(() => page.keyboard.type(ws, { delay: 0 }));
}
if (Math.random() < thinkRate) {
await sleep(rand(180, 520) * scale);
}
continue;
}
const wordSpeed = token.length > 7 ? rand(1.0, 1.3) : rand(0.7, 1.0);
for (let i = 0; i < token.length; i++) {
const ch = token[i];
const delay = (baseDelay + Math.random() * jitter) * wordSpeed * scale;
const shouldTypo = Math.random() < typoRate && /[a-zA-Z]/.test(ch);
if (shouldTypo) {
const wrong = typoFor(ch);
if (wrong) {
await tap(() => page.keyboard.type(wrong, { delay }));
await sleep(rand(90, 260) * scale);
await tap(() => page.keyboard.press("Backspace"));
await sleep(rand(60, 180) * scale);
}
}
await tap(() => page.keyboard.type(ch, { delay }));
if (/[,;:]/.test(ch)) {
await sleep(rand(280, 700) * scale);
} else if (/[.!?]/.test(ch)) {
await sleep(rand(600, 1400) * scale);
}
}
}
}
/**
* Focus a locator and type with `humanType`. Waits for visibility first.
*/
export async function humanTypeInto(
page: Page,
target: Locator,
text: string,
options: HumanTypingOptions = {}
): Promise<void> {
await target.waitFor({ state: "visible" });
await humanClick(page, target);
await sleep(rand(250, 550));
await humanType(page, text, options);
}
/**
* Sleep for a human-ish "reading/thinking" pause.
*/
export function pause(min: number, max: number = min): Promise<void> {
return sleep(rand(min, max));
}