feat: add /stop and /reset chat commands and bots internals docs section

Add two new chat commands (`/stop` and `/reset`) to the Devii WebSocket handler, enabling users to stop or reset a session via text input. Introduce a new "Bots internals" documentation section with six prose pages covering architecture, personas, content generation, engagement, realism, and configuration for the autonomous bot fleet. Extend the bot service with article scoring, category picking, configurable pause/break timing, and a `gist_min_lines` parameter for LLM client initialization.
This commit is contained in:
2026-06-11 12:06:17 +00:00
parent 3540bc8fa6
commit 5488008216
37 changed files with 2576 additions and 340 deletions
@@ -0,0 +1,32 @@
// retoor <retoor@molodetz.nl>
export class ProjectActionsMenu {
constructor(root) {
this.root = root;
this.trigger = root.querySelector(".project-actions-more");
this.overflow = root.querySelector(".project-actions-overflow");
if (!this.trigger || !this.overflow) {
return;
}
if (!this.overflow.querySelector("[data-menu-action]")) {
this.trigger.hidden = true;
return;
}
this.trigger.addEventListener("click", (event) => this.open(event));
}
open(event) {
event.preventDefault();
event.stopPropagation();
const items = Array.from(
this.overflow.querySelectorAll("[data-menu-action]")
).map((element) => ({
icon: element.dataset.menuIcon || "",
label: element.dataset.menuLabel || element.textContent.trim(),
danger: element.hasAttribute("data-confirm-danger"),
onSelect: () => element.click(),
}));
const rect = this.trigger.getBoundingClientRect();
window.app.contextMenu.open(rect.left, rect.bottom + 4, items);
}
}
+671 -28
View File
@@ -2,42 +2,89 @@
const MAX_RESULT_CHARS = 8000;
const NAV_DELAY_MS = 150;
const LABEL_MAX = 120;
const VALUE_MAX = 200;
const DISCOVER_LIMIT = 40;
const SETTLE_MS = 350;
const WAIT_POLL_MS = 150;
const WAIT_CAP_MS = 55000;
const DISCOVER_SELECTOR = [
"a[href]",
"button",
"[role='button']",
"[role='link']",
"[role='tab']",
"[role='menuitem']",
"input:not([type='hidden'])",
"textarea",
"select",
"[contenteditable='']",
"[contenteditable='true']",
"summary",
".poll-option",
"[data-modal]",
"[data-devii-open]",
].join(", ");
export default class DeviiClient {
constructor() {
this._highlights = [];
this._reposition = () => this._repositionHighlights();
this._refs = new Map();
}
async execute(action, args = {}) {
try {
switch (action) {
case "get_page_context":
return this._context();
case "run_js":
return await this._runJs(String(args.code || ""));
case "highlight_element":
return this._highlight(String(args.selector || ""), args.label);
case "clear_highlights":
return this._clearHighlights();
case "show_toast":
return this._toast(String(args.text || ""), Number(args.duration_ms) || 4000);
case "scroll_to_element":
return this._scrollTo(String(args.selector || ""));
case "navigate_to":
return this._navigate(String(args.url || ""));
case "reload_page":
return this._reload();
case "open_terminal":
return this._openTerminal(args);
default:
return { error: `unknown client action '${action}'` };
}
return await this._dispatch(action, args);
} catch (error) {
return { error: String(error && error.message ? error.message : error) };
}
}
async _dispatch(action, args = {}) {
switch (action) {
case "get_page_context":
return this._context();
case "run_js":
return await this._runJs(String(args.code || ""));
case "discover_elements":
return this._discover(args);
case "read_element":
return this._readElement(String(args.target || ""));
case "click_element":
return await this._click(String(args.target || ""), String(args.button || "left"));
case "fill_field":
return await this._fill(String(args.target || ""), String(args.value ?? ""));
case "set_control":
return this._setControl(args);
case "submit_form":
return await this._submitForm(args);
case "wait_for":
return await this._waitFor(args);
case "press_key":
return this._pressKey(args);
case "run_sequence":
return await this._runSequence(args.steps || []);
case "highlight_element":
return this._highlight(String(args.selector || args.target || ""), args.label);
case "clear_highlights":
return this._clearHighlights();
case "show_toast":
return this._toast(String(args.text || ""), Number(args.duration_ms) || 4000);
case "scroll_to_element":
return this._scrollTo(String(args.selector || args.target || ""));
case "navigate_to":
return this._navigate(String(args.url || ""));
case "reload_page":
return this._reload();
case "open_terminal":
return this._openTerminal(args);
default:
return { error: `unknown client action '${action}'` };
}
}
_context() {
const headings = Array.from(document.querySelectorAll("h1, h2, h3"))
.map((h) => h.textContent.trim())
@@ -56,6 +103,8 @@ export default class DeviiClient {
}
const pageTypeEl = document.querySelector("[data-page-type]");
const pageType = pageTypeEl ? pageTypeEl.getAttribute("data-page-type") : location.pathname;
const snap = this._snapshot();
const interactiveCount = document.querySelectorAll(DISCOVER_SELECTOR).length;
return {
url: location.href,
path: location.pathname + location.search,
@@ -68,6 +117,10 @@ export default class DeviiClient {
headings,
loggedIn,
username: username || null,
openModal: snap.modal,
toasts: snap.toasts,
activeElement: this._briefLabel(document.activeElement),
interactiveCount,
};
}
@@ -95,18 +148,245 @@ export default class DeviiClient {
return out;
}
_resolve(selector) {
_visible(element) {
if (!element || !element.getClientRects) return false;
if (element.getClientRects().length === 0) return false;
const style = getComputedStyle(element);
return style.visibility !== "hidden" && style.display !== "none" && Number(style.opacity) !== 0;
}
_enabled(element) {
return !element.disabled && element.getAttribute("aria-disabled") !== "true";
}
_role(element) {
const explicit = element.getAttribute("role");
if (explicit) return explicit;
const tag = element.tagName.toLowerCase();
if (tag === "a") return "link";
if (tag === "button" || tag === "summary") return "button";
if (tag === "textarea") return "textbox";
if (tag === "select") return "select";
if (tag === "input") {
const type = (element.getAttribute("type") || "text").toLowerCase();
if (type === "checkbox") return "checkbox";
if (type === "radio") return "radio";
if (type === "submit" || type === "button") return "button";
return "input";
}
if (element.isContentEditable) return "textbox";
return "element";
}
_label(element) {
const aria = element.getAttribute("aria-label");
if (aria) return aria.trim().slice(0, LABEL_MAX);
const labelledby = element.getAttribute("aria-labelledby");
if (labelledby) {
const text = labelledby
.split(/\s+/)
.map((id) => {
const el = document.getElementById(id);
return el ? el.textContent.trim() : "";
})
.filter(Boolean)
.join(" ");
if (text) return text.slice(0, LABEL_MAX);
}
const tag = element.tagName.toLowerCase();
if (tag === "input" || tag === "textarea" || tag === "select") {
const id = element.getAttribute("id");
if (id) {
const lab = document.querySelector(`label[for="${CSS.escape(id)}"]`);
if (lab && lab.textContent.trim()) return lab.textContent.trim().slice(0, LABEL_MAX);
}
const wrap = element.closest("label");
if (wrap && wrap.textContent.trim()) return wrap.textContent.trim().slice(0, LABEL_MAX);
const placeholder = element.getAttribute("placeholder");
if (placeholder) return placeholder.trim().slice(0, LABEL_MAX);
if (tag !== "textarea" && element.value) return String(element.value).slice(0, LABEL_MAX);
}
const text = (element.textContent || "").replace(/\s+/g, " ").trim();
if (text) return text.slice(0, LABEL_MAX);
const title = element.getAttribute("title");
if (title) return title.trim().slice(0, LABEL_MAX);
const img = element.querySelector("img[alt]");
if (img) return img.getAttribute("alt").trim().slice(0, LABEL_MAX);
return "";
}
_briefLabel(element) {
if (!element || element === document.body) return null;
const role = this._role(element);
const label = this._label(element);
return label ? `${role}: ${label}`.slice(0, LABEL_MAX) : role;
}
_state(element) {
const tag = element.tagName.toLowerCase();
const state = {
visible: this._visible(element),
enabled: this._enabled(element),
inViewport: false,
};
const rect = element.getBoundingClientRect();
state.inViewport =
rect.bottom > 0 && rect.right > 0 && rect.top < window.innerHeight && rect.left < window.innerWidth;
if (tag === "input") {
const type = (element.getAttribute("type") || "text").toLowerCase();
if (type === "checkbox" || type === "radio") state.checked = !!element.checked;
else state.value = String(element.value || "").slice(0, VALUE_MAX);
} else if (tag === "textarea" || tag === "select") {
state.value = String(element.value || "").slice(0, VALUE_MAX);
}
const expanded = element.getAttribute("aria-expanded");
if (expanded !== null) state.expanded = expanded === "true";
return state;
}
_rect(element) {
const r = element.getBoundingClientRect();
return { x: Math.round(r.left), y: Math.round(r.top), w: Math.round(r.width), h: Math.round(r.height) };
}
_cssPath(element) {
const id = element.getAttribute("id");
if (id && document.querySelectorAll(`#${CSS.escape(id)}`).length === 1) {
return `#${CSS.escape(id)}`;
}
const parts = [];
let node = element;
let depth = 0;
while (node && node.nodeType === 1 && depth < 4) {
let segment = node.tagName.toLowerCase();
const nid = node.getAttribute("id");
if (nid && document.querySelectorAll(`#${CSS.escape(nid)}`).length === 1) {
parts.unshift(`#${CSS.escape(nid)}`);
break;
}
const parent = node.parentElement;
if (parent) {
const sameTag = Array.from(parent.children).filter((c) => c.tagName === node.tagName);
if (sameTag.length > 1) segment += `:nth-of-type(${sameTag.indexOf(node) + 1})`;
}
parts.unshift(segment);
node = node.parentElement;
depth += 1;
}
return parts.join(" > ").slice(0, LABEL_MAX);
}
_container(element) {
const modal = element.closest(".modal-overlay, dialog, dp-dialog");
if (modal) return modal.getAttribute("id") ? `#${modal.getAttribute("id")}` : modal.tagName.toLowerCase();
const form = element.closest("form");
if (form) return form.getAttribute("id") ? `#${form.getAttribute("id")}` : "form";
return null;
}
_matchesKind(role, kind) {
if (!kind) return true;
const k = kind.toLowerCase();
if (k === "input" || k === "textbox") return role === "input" || role === "textbox";
return role === k;
}
_describe(element, ref) {
return {
ref,
role: this._role(element),
label: this._label(element),
tag: element.tagName.toLowerCase(),
selector: this._cssPath(element),
state: this._state(element),
rect: this._rect(element),
container: this._container(element),
};
}
_discover(args = {}) {
const query = String(args.query || "").trim().toLowerCase();
const kind = String(args.kind || "").trim();
const limit = Math.max(1, Math.min(Number(args.limit) || DISCOVER_LIMIT, 100));
let root = document;
if (args.within) {
try {
root = this._resolveTarget(String(args.within));
} catch (error) {
return { error: `within: ${error.message}` };
}
}
const seen = new Set();
const elements = [];
this._refs = new Map();
let index = 0;
const candidates = root.querySelectorAll(DISCOVER_SELECTOR);
for (const element of candidates) {
if (seen.has(element)) continue;
seen.add(element);
if (!this._visible(element)) continue;
const role = this._role(element);
if (!this._matchesKind(role, kind)) continue;
const record = this._describe(element, `e${index + 1}`);
if (query && !record.label.toLowerCase().includes(query)) continue;
index += 1;
record.ref = `e${index}`;
this._refs.set(record.ref, element);
elements.push(record);
if (elements.length >= limit) break;
}
return {
count: elements.length,
truncated: elements.length >= limit,
scope: args.within ? String(args.within) : "page",
openModal: this._snapshot().modal,
elements,
};
}
_readElement(target) {
const element = this._resolveTarget(target);
const attrs = {};
for (const name of ["id", "name", "type", "href", "value", "placeholder", "class"]) {
const value = element.getAttribute(name);
if (value) attrs[name] = String(value).slice(0, VALUE_MAX);
}
return {
tag: element.tagName.toLowerCase(),
role: this._role(element),
label: this._label(element),
text: (element.textContent || "").replace(/\s+/g, " ").trim().slice(0, 600),
selector: this._cssPath(element),
state: this._state(element),
rect: this._rect(element),
container: this._container(element),
attributes: attrs,
error: this._nearbyError(element),
};
}
_resolveTarget(target) {
const value = String(target || "").trim();
if (!value) throw new Error("no target provided");
if (/^e\d+$/.test(value)) {
const element = this._refs.get(value);
if (element && element.isConnected) return element;
}
let element = null;
try {
element = document.querySelector(selector);
element = document.querySelector(value);
} catch (error) {
element = null;
}
if (!element) element = this._findByText(selector);
if (!element) throw new Error(`no element matches '${selector}'`);
if (!element) element = this._findByText(value);
if (!element) throw new Error(`no element matches '${value}'`);
return element;
}
_resolve(selector) {
return this._resolveTarget(selector);
}
_findByText(text) {
const needle = String(text).trim();
if (!needle) return null;
@@ -126,14 +406,377 @@ export default class DeviiClient {
return partial;
}
_snapshot() {
const overlays = Array.from(
document.querySelectorAll(".modal-overlay.visible, .modal-overlay.show, dialog[open], dp-dialog[open]"),
).filter((el) => this._visible(el));
const top = overlays[overlays.length - 1];
let modal = null;
if (top) modal = top.getAttribute("id") ? `#${top.getAttribute("id")}` : top.tagName.toLowerCase();
const toasts = Array.from(document.querySelectorAll(".dp-toast, .toast, .devii-toast, [class*='toast']"))
.filter((el) => this._visible(el))
.map((el) => el.textContent.trim())
.filter(Boolean)
.slice(0, 5);
return { url: location.pathname + location.search, modal, toasts };
}
_nearbyError(element) {
const scope = element.closest("form, .modal-overlay, .field, .form-group") || document;
const node = scope.querySelector(
".field-error, .form-error, .error-message, .input-error, [aria-invalid='true']",
);
if (node && this._visible(node)) {
const text = (node.textContent || "").trim();
if (text) return text.slice(0, VALUE_MAX);
}
return null;
}
_delta(before, after, element) {
const delta = {};
if (before.url !== after.url) {
delta.urlChanged = true;
delta.navigatedTo = after.url;
}
if (before.modal !== after.modal) {
if (after.modal && !before.modal) delta.modalOpened = after.modal;
else if (!after.modal && before.modal) delta.modalClosed = before.modal;
else delta.modalChanged = after.modal;
}
const fresh = after.toasts.filter((t) => !before.toasts.includes(t));
if (fresh.length) delta.toast = fresh.join(" | ").slice(0, VALUE_MAX);
if (element && !element.isConnected) delta.elementGone = true;
const error = element && element.isConnected ? this._nearbyError(element) : null;
if (error) delta.error = error;
return delta;
}
_sleep(ms) {
return new Promise((resolve) => window.setTimeout(resolve, ms));
}
async _waitVisibleEnabled(element, timeout) {
const deadline = Date.now() + timeout;
while (Date.now() < deadline) {
if (this._visible(element) && this._enabled(element)) return true;
await this._sleep(WAIT_POLL_MS);
}
return this._visible(element) && this._enabled(element);
}
_fireMouse(element, type) {
const rect = element.getBoundingClientRect();
const opts = {
bubbles: true,
cancelable: true,
view: window,
clientX: Math.round(rect.left + rect.width / 2),
clientY: Math.round(rect.top + rect.height / 2),
};
element.dispatchEvent(new MouseEvent(type, opts));
}
_fireClick(element, button) {
element.focus && element.focus();
if (button === "right") {
this._fireMouse(element, "contextmenu");
return;
}
this._fireMouse(element, "pointerdown");
this._fireMouse(element, "mousedown");
this._fireMouse(element, "pointerup");
this._fireMouse(element, "mouseup");
this._fireMouse(element, "click");
if (button === "double") {
this._fireMouse(element, "click");
this._fireMouse(element, "dblclick");
}
}
async _click(target, button) {
const element = this._resolveTarget(target);
const label = this._briefLabel(element);
element.scrollIntoView({ behavior: "smooth", block: "center", inline: "nearest" });
const ready = await this._waitVisibleEnabled(element, 3000);
if (!ready) {
return { error: `element not clickable`, target, state: this._state(element) };
}
const before = this._snapshot();
this._fireClick(element, button);
await this._sleep(SETTLE_MS);
const after = this._snapshot();
return { clicked: label, button, ...this._delta(before, after, element) };
}
_setNativeValue(element, value) {
const proto =
element instanceof window.HTMLTextAreaElement ? window.HTMLTextAreaElement.prototype : window.HTMLInputElement.prototype;
const setter = Object.getOwnPropertyDescriptor(proto, "value");
if (setter && setter.set) setter.set.call(element, value);
else element.value = value;
}
_codeMirrorOf(element) {
let host = element.closest && element.closest(".CodeMirror");
if (host && host.CodeMirror) return host.CodeMirror;
const parent = element.parentElement;
if (parent) {
host = parent.querySelector(".CodeMirror");
if (host && host.CodeMirror) return host.CodeMirror;
}
return null;
}
async _fill(target, value) {
const element = this._resolveTarget(target);
const label = this._briefLabel(element);
element.scrollIntoView({ behavior: "smooth", block: "center", inline: "nearest" });
const cm = this._codeMirrorOf(element);
if (cm) {
cm.setValue(value);
if (cm.save) cm.save();
return { filled: label, via: "codemirror", value: value.slice(0, VALUE_MAX) };
}
if (element.isContentEditable) {
element.focus && element.focus();
element.textContent = value;
element.dispatchEvent(new Event("input", { bubbles: true }));
return { filled: label, via: "contenteditable", value: value.slice(0, VALUE_MAX) };
}
const tag = element.tagName.toLowerCase();
if (tag !== "input" && tag !== "textarea" && tag !== "select") {
return { error: `'${target}' is a ${tag}, not a fillable field` };
}
element.focus && element.focus();
if (tag === "select") {
element.value = value;
} else {
this._setNativeValue(element, value);
}
element.dispatchEvent(new Event("input", { bubbles: true }));
element.dispatchEvent(new Event("change", { bubbles: true }));
return { filled: label, value: String(element.value).slice(0, VALUE_MAX), error: this._nearbyError(element) };
}
_setControl(args = {}) {
const element = this._resolveTarget(String(args.target || ""));
const label = this._briefLabel(element);
const tag = element.tagName.toLowerCase();
if (tag === "select") {
const wanted = String(args.option ?? "");
let chosen = null;
for (const opt of element.options) {
if (opt.value === wanted || opt.textContent.trim() === wanted) {
chosen = opt.value;
break;
}
}
if (chosen === null) {
for (const opt of element.options) {
if (opt.textContent.trim().toLowerCase().includes(wanted.toLowerCase())) {
chosen = opt.value;
break;
}
}
}
if (chosen === null) return { error: `no option matches '${wanted}'` };
element.value = chosen;
element.dispatchEvent(new Event("change", { bubbles: true }));
return { set: label, value: chosen };
}
const type = (element.getAttribute("type") || "").toLowerCase();
if (type !== "checkbox" && type !== "radio") {
return { error: `'${args.target}' is not a checkbox, radio, or select` };
}
const want = args.checked === undefined ? !element.checked : !!args.checked;
element.checked = type === "radio" ? true : want;
element.dispatchEvent(new Event("input", { bubbles: true }));
element.dispatchEvent(new Event("change", { bubbles: true }));
return { set: label, checked: element.checked };
}
_fieldInForm(form, key) {
let field = form.querySelector(`[name="${CSS.escape(key)}"]`);
if (field) return field;
try {
field = form.querySelector(`#${CSS.escape(key)}`);
if (field) return field;
} catch (error) {
field = null;
}
const labels = form.querySelectorAll("label");
for (const lab of labels) {
if (lab.textContent.trim().toLowerCase().includes(String(key).toLowerCase())) {
const id = lab.getAttribute("for");
if (id) {
const byId = document.getElementById(id);
if (byId) return byId;
}
const inner = lab.querySelector("input, textarea, select");
if (inner) return inner;
}
}
return null;
}
async _submitForm(args = {}) {
const element = this._resolveTarget(String(args.target || ""));
const form = element.tagName === "FORM" ? element : element.closest("form") || element.form || null;
if (!form) return { error: `no form found for '${args.target}'` };
const filled = [];
const fields = args.fields && typeof args.fields === "object" ? args.fields : null;
if (fields) {
for (const [key, value] of Object.entries(fields)) {
const field = this._fieldInForm(form, key);
if (!field) {
filled.push({ field: key, error: "not found" });
continue;
}
const cm = this._codeMirrorOf(field);
if (cm) {
cm.setValue(String(value));
if (cm.save) cm.save();
} else if (field.tagName.toLowerCase() === "select") {
field.value = String(value);
field.dispatchEvent(new Event("change", { bubbles: true }));
} else if (field.type === "checkbox" || field.type === "radio") {
field.checked = !!value;
field.dispatchEvent(new Event("change", { bubbles: true }));
} else {
this._setNativeValue(field, String(value));
field.dispatchEvent(new Event("input", { bubbles: true }));
field.dispatchEvent(new Event("change", { bubbles: true }));
}
filled.push({ field: key });
}
await this._sleep(120);
}
const before = this._snapshot();
const submit = form.querySelector("button[type='submit'], input[type='submit'], button:not([type])");
if (submit) this._fireClick(submit, "left");
else if (form.requestSubmit) form.requestSubmit();
else form.submit();
await this._sleep(SETTLE_MS);
const after = this._snapshot();
return { submitted: true, filled, ...this._delta(before, after, form) };
}
async _waitFor(args = {}) {
const target = String(args.target || "");
const condition = String(args.condition || "visible").toLowerCase();
const text = String(args.text || "");
const timeout = Math.min(Math.max(Number(args.timeout_ms) || 8000, 100), WAIT_CAP_MS);
const start = Date.now();
const deadline = start + timeout;
while (Date.now() < deadline) {
let element = null;
try {
element = this._resolveTarget(target);
} catch (error) {
element = null;
}
const visible = element && this._visible(element);
if (condition === "hidden" && !visible) {
return { satisfied: true, condition, waited_ms: Date.now() - start };
}
if (condition === "visible" && visible) {
return { satisfied: true, condition, waited_ms: Date.now() - start };
}
if (condition === "text_contains" && element && (element.textContent || "").includes(text)) {
return { satisfied: true, condition, waited_ms: Date.now() - start };
}
await this._sleep(WAIT_POLL_MS);
}
return {
satisfied: false,
condition,
waited_ms: Date.now() - start,
message: `condition '${condition}' not met for '${target}' within ${timeout}ms`,
};
}
_keyCode(key) {
if (key.length === 1) {
if (/[a-z]/i.test(key)) return `Key${key.toUpperCase()}`;
if (/[0-9]/.test(key)) return `Digit${key}`;
}
return key;
}
_pressKey(args = {}) {
const key = String(args.key || "");
if (!key) return { error: "no key provided" };
let element = document.activeElement || document.body;
if (args.target) element = this._resolveTarget(String(args.target));
element.focus && element.focus();
const code = this._keyCode(key);
const opts = { key, code, bubbles: true, cancelable: true };
const down = new KeyboardEvent("keydown", opts);
element.dispatchEvent(down);
if (key.length === 1) element.dispatchEvent(new KeyboardEvent("keypress", opts));
element.dispatchEvent(new KeyboardEvent("keyup", opts));
if (key === "Enter" && !down.defaultPrevented) {
const form = element.form || (element.closest && element.closest("form"));
if (form && element.tagName === "INPUT" && form.requestSubmit) {
const submit = form.querySelector("button[type='submit'], input[type='submit']");
if (submit) this._fireClick(submit, "left");
else form.requestSubmit();
}
}
return { pressed: key, target: this._briefLabel(element) };
}
async _runSequence(steps) {
if (!Array.isArray(steps) || steps.length === 0) return { error: "no steps provided" };
const blocked = new Set(["run_js", "run_sequence", "navigate_to", "reload_page", "open_terminal"]);
const results = [];
let failedAt = null;
for (let i = 0; i < steps.length; i += 1) {
const step = steps[i] || {};
const action = String(step.action || "");
if (!action) {
results.push({ step: i + 1, error: "missing action" });
failedAt = i + 1;
break;
}
if (blocked.has(action)) {
results.push({ step: i + 1, action, error: `'${action}' is not allowed inside a sequence` });
failedAt = i + 1;
break;
}
const stepArgs = { ...step };
delete stepArgs.action;
let result;
try {
result = await this._dispatch(action, stepArgs);
} catch (error) {
result = { error: String(error && error.message ? error.message : error) };
}
results.push({ step: i + 1, action, result });
if (result && result.error) {
failedAt = i + 1;
break;
}
await this._sleep(WAIT_POLL_MS);
}
return {
completed: failedAt === null ? steps.length : failedAt - 1,
total: steps.length,
failedAt,
results,
context: this._context(),
};
}
_scrollTo(selector) {
const element = this._resolve(selector);
const element = this._resolveTarget(selector);
element.scrollIntoView({ behavior: "smooth", block: "center", inline: "nearest" });
return `scrolled to ${selector}`;
}
_highlight(selector, label) {
const element = this._resolve(selector);
const element = this._resolveTarget(selector);
element.scrollIntoView({ behavior: "smooth", block: "center", inline: "nearest" });
const box = document.createElement("div");
box.className = "devii-hl-box";
+11 -1
View File
@@ -25,12 +25,14 @@ const TRACE_GLYPHS = {
};
const CLEAR_WORDS = new Set(["clear", "/clear"]);
const RESET_WORDS = new Set(["reset", "/reset"]);
const STOP_WORDS = new Set(["stop", "/stop"]);
const CLOSE_WORDS = new Set(["exit", "close", "quit", "bye", "/exit", "/close", "/quit", "/bye"]);
const HELP = [
"Local commands:",
" /help show this help",
" clear clear the conversation",
" stop cancel the running task, keep the conversation",
" reset cancel the running task and clear the conversation",
" clear clear the conversation",
" exit | close | quit | bye close the terminal",
" /show show devii (the avatar)",
" /hide hide devii",
@@ -459,6 +461,10 @@ export default class DeviiTerminalElement extends FloatingWindow {
this.historyIndex = this.history.length;
this.draft = "";
const lowered = text.toLowerCase();
if (STOP_WORDS.has(lowered)) {
this._stopTask();
return;
}
if (RESET_WORDS.has(lowered)) {
this._resetConversation();
return;
@@ -481,6 +487,10 @@ export default class DeviiTerminalElement extends FloatingWindow {
}
}
_stopTask() {
this.socket.send({ type: "stop" });
}
_resetConversation() {
this.output.innerHTML = "";
this.socket.send({ type: "reset" });