|
// retoor <retoor@molodetz.nl>
|
|
|
|
import FloatingWindow from "../components/FloatingWindow.js";
|
|
import { Http } from "../Http.js";
|
|
import { assetUrl } from "../assetVersion.js";
|
|
import Markdown from "./markdown.js";
|
|
import DeviiSocket from "./DeviiSocket.js";
|
|
import DeviiClient from "./DeviiClient.js";
|
|
import { UserScope } from "../userScope.js";
|
|
|
|
const CSS_ID = "devii-terminal-css";
|
|
const STORAGE_KEY = "devii-terminal-state";
|
|
const HISTORY_KEY = "devii-terminal-history";
|
|
const FONT_KEY = "devii-fontsize";
|
|
const FONT_MIN = 10;
|
|
const FONT_MAX = 26;
|
|
const FONT_DEFAULT = 14;
|
|
const HISTORY_LIMIT = 500;
|
|
const TRACE_GLYPHS = {
|
|
call: "↳",
|
|
ok: "✓",
|
|
err: "✗",
|
|
"plan-gate": "plan required first",
|
|
"reflect-trigger": "reflecting on error",
|
|
"verify-gate": "verification gate",
|
|
compact: "context compaction",
|
|
};
|
|
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",
|
|
" 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",
|
|
" /say <text> make devii speak directly",
|
|
" /character <name> switch devii's character",
|
|
"Anything else is sent to the agent.",
|
|
].join("\n");
|
|
|
|
export default class DeviiTerminalElement extends FloatingWindow {
|
|
constructor() {
|
|
super();
|
|
this.markdown = new Markdown();
|
|
this.deviiClient = new DeviiClient();
|
|
this.socket = null;
|
|
this.history = [];
|
|
this.historyIndex = 0;
|
|
this.draft = "";
|
|
this._historyKey = null;
|
|
this._hasFocus = false;
|
|
this._unloading = false;
|
|
}
|
|
|
|
get _dragClass() {
|
|
return "devii-dragging";
|
|
}
|
|
|
|
get fontControls() {
|
|
return true;
|
|
}
|
|
|
|
get _normalizeSize() {
|
|
return [760, 600];
|
|
}
|
|
|
|
_fontAtMin() {
|
|
return this._readFont() <= FONT_MIN;
|
|
}
|
|
|
|
_fontAtMax() {
|
|
return this._readFont() >= FONT_MAX;
|
|
}
|
|
|
|
connectedCallback() {
|
|
this._ensureCss();
|
|
this._buildChrome();
|
|
this._registerWindow();
|
|
this._applyFont(this._readFont());
|
|
this._mobile.addEventListener("change", () => this._onViewportChange());
|
|
window.addEventListener("resize", () => this._clampWindow());
|
|
this._vv = window.visualViewport || null;
|
|
this._vvHandler = () => this._onVisualViewport2();
|
|
if (this._vv) {
|
|
this._vv.addEventListener("resize", this._vvHandler);
|
|
this._vv.addEventListener("scroll", this._vvHandler);
|
|
}
|
|
window.addEventListener("pagehide", () => this._captureFocusForUnload());
|
|
window.addEventListener("beforeunload", () => this._captureFocusForUnload());
|
|
this._identity = null;
|
|
this._fetchIdentity().then((id) => {
|
|
this._identity = id;
|
|
this._initHistory(id);
|
|
});
|
|
window.addEventListener("focus", () => {
|
|
this._checkIdentity();
|
|
this._reportVisibility();
|
|
});
|
|
window.addEventListener("blur", () => this._reportVisibility());
|
|
document.addEventListener("visibilitychange", () => {
|
|
if (!document.hidden) this._checkIdentity();
|
|
this._reportVisibility();
|
|
});
|
|
this.socket = new DeviiSocket({
|
|
onOpen: () => {
|
|
this._reportVisibility();
|
|
this._reportClientInfo();
|
|
},
|
|
onReady: () => {
|
|
this._notice("connected.");
|
|
this._reportVisibility();
|
|
this._reportClientInfo();
|
|
},
|
|
onClose: () => this._notice("disconnected, reconnecting..."),
|
|
onMessage: (message) => this._onMessage(message),
|
|
});
|
|
this._connected = false;
|
|
this._restoring = true;
|
|
const saved = this._loadPersisted();
|
|
if (saved && saved.geometry) {
|
|
this.geometry = saved.geometry;
|
|
this._clampGeometry();
|
|
}
|
|
let initial = "closed";
|
|
if (saved && saved.state) {
|
|
initial = saved.state;
|
|
} else if (this.hasAttribute("open")) {
|
|
initial = "open";
|
|
}
|
|
this._restoring = false;
|
|
this._setState(initial);
|
|
this._observeResize();
|
|
if (this.state !== "closed") {
|
|
this._ensureConnected();
|
|
this._focusInput();
|
|
}
|
|
}
|
|
|
|
_focusInput() {
|
|
window.requestAnimationFrame(() => {
|
|
try {
|
|
this.input.focus({ preventScroll: true });
|
|
} catch (error) {
|
|
return;
|
|
}
|
|
});
|
|
}
|
|
|
|
_onFocusChange(focused) {
|
|
if (!focused && this._unloading) return;
|
|
this._hasFocus = focused;
|
|
this._persist();
|
|
}
|
|
|
|
_captureFocusForUnload() {
|
|
this._unloading = true;
|
|
this._hasFocus = document.activeElement === this.input;
|
|
this._persist();
|
|
}
|
|
|
|
hasStoredState() {
|
|
try {
|
|
return window.localStorage.getItem(STORAGE_KEY) !== null;
|
|
} catch (error) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
_loadPersisted() {
|
|
try {
|
|
const raw = window.localStorage.getItem(STORAGE_KEY);
|
|
return raw ? JSON.parse(raw) : null;
|
|
} catch (error) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
_persist() {
|
|
if (this._restoring) return;
|
|
try {
|
|
window.localStorage.setItem(
|
|
STORAGE_KEY,
|
|
JSON.stringify({ state: this.state, geometry: this.geometry, focused: this._hasFocus }),
|
|
);
|
|
} catch (error) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
_persistGeometry() {
|
|
this._persist();
|
|
}
|
|
|
|
_historyKeyFor(identity) {
|
|
const scope = identity && identity.loggedIn && identity.username
|
|
? `user:${identity.username}`
|
|
: "guest";
|
|
return `${HISTORY_KEY}:${scope}`;
|
|
}
|
|
|
|
_initHistory(identity) {
|
|
this._historyKey = this._historyKeyFor(identity);
|
|
this.history = this._loadHistory();
|
|
this.historyIndex = this.history.length;
|
|
this.draft = "";
|
|
}
|
|
|
|
_loadHistory() {
|
|
if (!this._historyKey) return [];
|
|
try {
|
|
const raw = window.localStorage.getItem(this._historyKey);
|
|
const parsed = raw ? JSON.parse(raw) : [];
|
|
return Array.isArray(parsed) ? parsed.filter((entry) => typeof entry === "string") : [];
|
|
} catch (error) {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
_saveHistory() {
|
|
if (!this._historyKey) return;
|
|
try {
|
|
window.localStorage.setItem(this._historyKey, JSON.stringify(this.history));
|
|
} catch (error) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
_ensureConnected() {
|
|
if (this._connected) return;
|
|
this._connected = true;
|
|
this.socket.connect();
|
|
}
|
|
|
|
_ensureCss() {
|
|
if (document.getElementById(CSS_ID)) return;
|
|
const link = document.createElement("link");
|
|
link.id = CSS_ID;
|
|
link.rel = "stylesheet";
|
|
link.href = assetUrl("/static/css/devii.css");
|
|
document.head.appendChild(link);
|
|
}
|
|
|
|
_buildChrome() {
|
|
this.innerHTML = "";
|
|
this.launcher = document.createElement("button");
|
|
this.launcher.type = "button";
|
|
this.launcher.className = "devii-launcher";
|
|
this.launcher.innerHTML = '<span class="devii-launcher-dot"></span> devii';
|
|
this.launcher.addEventListener("click", () => this.open());
|
|
this.appendChild(this.launcher);
|
|
|
|
this.win = document.createElement("div");
|
|
this.win.className = "devii-window";
|
|
this.win.setAttribute("role", "dialog");
|
|
this.win.setAttribute("aria-label", "Devii assistant");
|
|
this.win.innerHTML = [
|
|
'<div class="devii-titlebar">',
|
|
' <span class="devii-title"><span class="devii-dot" aria-hidden="true"></span>Devii</span>',
|
|
' <span class="devii-winctl">',
|
|
' <button type="button" data-win="font-dec" title="Smaller font">−</button>',
|
|
' <button type="button" data-win="font-inc" title="Larger font">+</button>',
|
|
' <button type="button" data-win="minimize" title="Minimize (smallest usable size)">▫</button>',
|
|
' <button type="button" data-win="normalize" title="Normalize (comfortable size)">▭</button>',
|
|
' <button type="button" data-win="fullscreen" title="Fullscreen">⛶</button>',
|
|
' <button type="button" data-win="maximize" title="Maximize">□</button>',
|
|
' <button type="button" data-win="close" title="Close">✕</button>',
|
|
" </span>",
|
|
"</div>",
|
|
'<div class="devii-toolbar">',
|
|
' <button type="button" data-cmd="/show">show devii</button>',
|
|
' <button type="button" data-cmd="/hide">hide devii</button>',
|
|
' <button type="button" data-cmd="/clear">clear</button>',
|
|
"</div>",
|
|
'<div class="devii-output" role="log" aria-live="polite" aria-label="Conversation"></div>',
|
|
'<div class="devii-inputrow">',
|
|
' <span class="devii-prompt" aria-hidden="true">you></span>',
|
|
' <input class="devii-input" type="text" autocomplete="off" spellcheck="false" aria-label="Message Devii" />',
|
|
"</div>",
|
|
].join("");
|
|
this.appendChild(this.win);
|
|
|
|
this.output = this.win.querySelector(".devii-output");
|
|
this.input = this.win.querySelector(".devii-input");
|
|
this.titlebar = this.win.querySelector(".devii-titlebar");
|
|
|
|
this.win.querySelectorAll(".devii-toolbar button").forEach((button) => {
|
|
button.addEventListener("click", () => this._submit(button.dataset.cmd));
|
|
});
|
|
this.win.querySelectorAll(".devii-winctl button").forEach((button) => {
|
|
if (button.title) button.setAttribute("aria-label", button.title);
|
|
button.addEventListener("click", () => this._window(button.dataset.win));
|
|
});
|
|
this.input.addEventListener("keydown", (event) => this._onKey(event));
|
|
this.input.addEventListener("focus", () => this._onFocusChange(true));
|
|
this.input.addEventListener("blur", () => this._onFocusChange(false));
|
|
this.win.addEventListener("click", (event) => {
|
|
if (event.target.closest("button, a, input, textarea")) return;
|
|
const selection = window.getSelection();
|
|
if (selection && selection.toString().length > 0) return;
|
|
this.input.focus({ preventScroll: true });
|
|
});
|
|
this.output.addEventListener("click", (event) => this._onOutputClick(event));
|
|
this.titlebar.addEventListener("mousedown", (event) => this._startDrag(event));
|
|
this.titlebar.addEventListener("touchstart", (event) => this._startDrag(event), { passive: false });
|
|
this.titlebar.addEventListener("dblclick", (event) => {
|
|
if (event.target.closest("button")) return;
|
|
this._window("maximize");
|
|
});
|
|
}
|
|
|
|
open() {
|
|
this._ensureConnected();
|
|
this._setState("open");
|
|
this.input.focus({ preventScroll: true });
|
|
}
|
|
|
|
close() {
|
|
this._setState("closed");
|
|
}
|
|
|
|
toggle() {
|
|
if (this.state === "closed") this.open();
|
|
else this.close();
|
|
}
|
|
|
|
_contextItems() {
|
|
const selection = window.getSelection ? String(window.getSelection()) : "";
|
|
return [
|
|
{ label: "Copy", disabled: !selection && !this.input.value, onSelect: () => this._copySelection() },
|
|
{ label: "Paste", onSelect: () => this._paste() },
|
|
{ separator: true },
|
|
{ label: "Minimize", onSelect: () => this.minimizeWindow() },
|
|
{ label: "Normalize", onSelect: () => this.normalizeWindow() },
|
|
{ separator: true },
|
|
{ label: "Close", danger: true, onSelect: () => this.close() },
|
|
];
|
|
}
|
|
|
|
_copySelection() {
|
|
const selection = window.getSelection ? String(window.getSelection()) : "";
|
|
const text = selection || this.input.value;
|
|
if (!text) return;
|
|
this._clipboardWrite(text);
|
|
}
|
|
|
|
async _paste() {
|
|
if (!navigator.clipboard || !navigator.clipboard.readText) {
|
|
this._error("clipboard read unavailable.");
|
|
return;
|
|
}
|
|
let text = "";
|
|
try {
|
|
text = await navigator.clipboard.readText();
|
|
} catch (error) {
|
|
this._error("paste failed.");
|
|
return;
|
|
}
|
|
this._insertInput(text);
|
|
}
|
|
|
|
_insertInput(text) {
|
|
if (!text) return;
|
|
const start = this.input.selectionStart != null ? this.input.selectionStart : this.input.value.length;
|
|
const end = this.input.selectionEnd != null ? this.input.selectionEnd : this.input.value.length;
|
|
this.input.value = this.input.value.slice(0, start) + text + this.input.value.slice(end);
|
|
const caret = start + text.length;
|
|
this.input.focus({ preventScroll: true });
|
|
this.input.setSelectionRange(caret, caret);
|
|
}
|
|
|
|
_clipboardWrite(text) {
|
|
if (!text) return;
|
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
navigator.clipboard.writeText(text).catch(() => this._fallbackCopy(text, () => {}));
|
|
} else {
|
|
this._fallbackCopy(text, () => {});
|
|
}
|
|
}
|
|
|
|
_fontKey() {
|
|
return `${FONT_KEY}:${UserScope.get()}`;
|
|
}
|
|
|
|
_readFont() {
|
|
try {
|
|
const value = parseInt(window.localStorage.getItem(this._fontKey()), 10);
|
|
return Number.isFinite(value) ? Math.max(FONT_MIN, Math.min(FONT_MAX, value)) : FONT_DEFAULT;
|
|
} catch (error) {
|
|
return FONT_DEFAULT;
|
|
}
|
|
}
|
|
|
|
_applyFont(size) {
|
|
this.style.setProperty("--devii-font-size", `${size}px`);
|
|
}
|
|
|
|
_changeFont(delta) {
|
|
const next = Math.max(FONT_MIN, Math.min(FONT_MAX, this._readFont() + delta));
|
|
try {
|
|
window.localStorage.setItem(this._fontKey(), String(next));
|
|
} catch (error) {
|
|
return this._applyFont(next);
|
|
}
|
|
this._applyFont(next);
|
|
}
|
|
|
|
_setState(state) {
|
|
let resolved = state;
|
|
if (state === "open") {
|
|
resolved = this._mobile.matches ? "fullscreen" : "normal";
|
|
} else if (state !== "closed" && this._mobile.matches) {
|
|
resolved = "fullscreen";
|
|
}
|
|
this.state = resolved;
|
|
this.classList.toggle("devii-mobile", this._mobile.matches);
|
|
["closed", "normal", "maximized", "fullscreen"].forEach((name) => {
|
|
this.classList.toggle(`devii-state-${name}`, name === resolved);
|
|
});
|
|
if (resolved === "normal") {
|
|
this._applyGeometry();
|
|
} else {
|
|
this.win.style.left = "";
|
|
this.win.style.top = "";
|
|
this.win.style.width = "";
|
|
this.win.style.height = "";
|
|
}
|
|
const keyboardVisible = this._keyboardVisible;
|
|
this.classList.toggle("devii-keyboard-visible", keyboardVisible);
|
|
if (resolved === "fullscreen" && keyboardVisible && this._vv) {
|
|
this.win.style.height = `${this._vv.height}px`;
|
|
this.win.style.top = `${this._vv.offsetTop}px`;
|
|
}
|
|
this._syncControls();
|
|
this._persist();
|
|
}
|
|
|
|
_defaultGeometry() {
|
|
const safeHeight = this._safeViewport.height;
|
|
const width = Math.min(760, Math.round(window.innerWidth * 0.94));
|
|
const height = Math.min(600, Math.round(safeHeight * 0.82));
|
|
return {
|
|
width,
|
|
height,
|
|
left: Math.max(12, window.innerWidth - width - 24),
|
|
top: Math.max(12, safeHeight - height - 24),
|
|
};
|
|
}
|
|
|
|
_onViewportChange() {
|
|
if (this.state === "closed") return;
|
|
this._setState("open");
|
|
}
|
|
|
|
_onVisualViewport2() {
|
|
if (this.state === "closed" || !this._vv) return;
|
|
this.classList.toggle("devii-keyboard-visible", this._keyboardVisible);
|
|
if (this.state === "fullscreen") {
|
|
this.win.style.height = `${this._vv.height}px`;
|
|
this.win.style.top = `${this._vv.offsetTop}px`;
|
|
}
|
|
this._ensureInputVisible();
|
|
}
|
|
|
|
_ensureInputVisible() {
|
|
window.requestAnimationFrame(() => {
|
|
if (document.activeElement === this.input && this.input.scrollIntoView) {
|
|
this.input.scrollIntoView({ block: "end" });
|
|
}
|
|
if (this.output) this.output.scrollTop = this.output.scrollHeight;
|
|
});
|
|
}
|
|
|
|
_onKey(event) {
|
|
if (event.key === "Enter") {
|
|
event.preventDefault();
|
|
const text = this.input.value;
|
|
this.input.value = "";
|
|
this._submit(text);
|
|
} else if (event.key === "ArrowUp" || event.key === "PageUp") {
|
|
event.preventDefault();
|
|
this._recall(-1);
|
|
} else if (event.key === "ArrowDown" || event.key === "PageDown") {
|
|
event.preventDefault();
|
|
this._recall(1);
|
|
} else if (event.key === "Escape") {
|
|
this.close();
|
|
}
|
|
}
|
|
|
|
_recall(direction) {
|
|
if (!this.history.length) return;
|
|
if (this.historyIndex === this.history.length) {
|
|
this.draft = this.input.value;
|
|
}
|
|
const target = Math.max(0, Math.min(this.history.length, this.historyIndex + direction));
|
|
if (target === this.historyIndex) return;
|
|
this.historyIndex = target;
|
|
const value = target === this.history.length ? this.draft : this.history[target];
|
|
this.input.value = value;
|
|
this.input.setSelectionRange(value.length, value.length);
|
|
}
|
|
|
|
_submit(raw) {
|
|
const text = (raw || "").trim();
|
|
if (!text) return;
|
|
if (this.history[this.history.length - 1] !== text) {
|
|
this.history.push(text);
|
|
if (this.history.length > HISTORY_LIMIT) {
|
|
this.history = this.history.slice(-HISTORY_LIMIT);
|
|
}
|
|
this._saveHistory();
|
|
}
|
|
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;
|
|
}
|
|
if (CLEAR_WORDS.has(lowered)) {
|
|
this._clearConversation();
|
|
return;
|
|
}
|
|
if (CLOSE_WORDS.has(lowered)) {
|
|
this.close();
|
|
return;
|
|
}
|
|
if (text.startsWith("/")) {
|
|
this._local(text);
|
|
return;
|
|
}
|
|
this._userLine(text);
|
|
if (!this.socket.send({ type: "input", text })) {
|
|
this._error("not connected.");
|
|
}
|
|
}
|
|
|
|
_stopTask() {
|
|
this.socket.send({ type: "stop" });
|
|
}
|
|
|
|
_resetConversation() {
|
|
this.output.innerHTML = "";
|
|
this.socket.send({ type: "reset" });
|
|
}
|
|
|
|
_clearConversation() {
|
|
this.output.innerHTML = "";
|
|
this.socket.send({ type: "clear" });
|
|
}
|
|
|
|
async _local(text) {
|
|
const [command, ...rest] = text.slice(1).split(" ");
|
|
const argument = rest.join(" ").trim();
|
|
const avatar = this._avatar();
|
|
if (command === "help") {
|
|
this._agent(HELP);
|
|
} else if (command === "clear") {
|
|
this.output.innerHTML = "";
|
|
} else if (command === "show") {
|
|
this._userLine(text);
|
|
if (avatar) await avatar.execute("show", {});
|
|
} else if (command === "hide") {
|
|
this._userLine(text);
|
|
if (avatar) await avatar.execute("hide", {});
|
|
} else if (command === "say") {
|
|
this._userLine(text);
|
|
if (avatar) await avatar.execute("speak", { text: argument });
|
|
} else if (command === "character") {
|
|
this._userLine(text);
|
|
if (avatar) await avatar.execute("switch_character", { name: argument });
|
|
} else {
|
|
this._error(`unknown command: /${command} (try /help)`);
|
|
}
|
|
}
|
|
|
|
async _onMessage(message) {
|
|
if (message.type === "reply") {
|
|
this._agent(message.text);
|
|
} else if (message.type === "history") {
|
|
this._renderHistory(message.messages);
|
|
} else if (message.type === "clear") {
|
|
this.output.innerHTML = "";
|
|
} else if (message.type === "status") {
|
|
this._notice(message.text);
|
|
} else if (message.type === "trace") {
|
|
this._trace(message);
|
|
} else if (message.type === "error") {
|
|
this._error(message.text);
|
|
} else if (message.type === "task") {
|
|
this._task(message);
|
|
} else if (message.type === "avatar" || message.type === "avatar_query") {
|
|
await this._avatarRequest(message);
|
|
} else if (message.type === "client") {
|
|
await this._clientRequest(message);
|
|
} else if (message.type === "auth") {
|
|
this._onAuth(message);
|
|
}
|
|
}
|
|
|
|
_onAuth(message) {
|
|
if (message.action === "logout") {
|
|
window.location.assign("/auth/logout");
|
|
} else if (message.action === "adopt") {
|
|
const next = location.pathname + location.search;
|
|
window.location.assign("/devii/adopt?next=" + encodeURIComponent(next));
|
|
}
|
|
}
|
|
|
|
async _fetchIdentity() {
|
|
try {
|
|
const data = await Http.getJson("/devii/session");
|
|
if (data.baseUrl) {
|
|
this.markdown.baseUrl = data.baseUrl;
|
|
}
|
|
return { loggedIn: !!data.loggedIn, username: data.username || "" };
|
|
} catch (error) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async _checkIdentity() {
|
|
if (this._checkingIdentity || !this._identity) return;
|
|
this._checkingIdentity = true;
|
|
try {
|
|
const now = await this._fetchIdentity();
|
|
if (now && (now.loggedIn !== this._identity.loggedIn || now.username !== this._identity.username)) {
|
|
window.location.reload();
|
|
}
|
|
} finally {
|
|
this._checkingIdentity = false;
|
|
}
|
|
}
|
|
|
|
_reportVisibility() {
|
|
if (!this.socket) return;
|
|
this.socket.send({
|
|
type: "visibility",
|
|
visible: !document.hidden,
|
|
focused: document.hasFocus(),
|
|
});
|
|
}
|
|
|
|
_reportClientInfo() {
|
|
if (!this.socket) return;
|
|
let timezone = "";
|
|
try {
|
|
timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || "";
|
|
} catch (error) {
|
|
timezone = "";
|
|
}
|
|
this.socket.send({
|
|
type: "clientinfo",
|
|
timezone,
|
|
offset: -new Date().getTimezoneOffset(),
|
|
});
|
|
}
|
|
|
|
async _clientRequest(message) {
|
|
let result;
|
|
try {
|
|
result = await this.deviiClient.execute(message.action, message.args || {});
|
|
} catch (error) {
|
|
result = { error: String(error && error.message ? error.message : error) };
|
|
}
|
|
this.socket.send({ type: "client_result", id: message.id, result });
|
|
}
|
|
|
|
async _avatarRequest(message) {
|
|
const avatar = this._avatar();
|
|
let result = { error: "no avatar attached" };
|
|
if (avatar) {
|
|
try {
|
|
result = await avatar.execute(message.action, message.args || {});
|
|
} catch (error) {
|
|
result = { error: String(error && error.message ? error.message : error) };
|
|
}
|
|
}
|
|
this.socket.send({ type: "avatar_result", id: message.id, result });
|
|
}
|
|
|
|
_avatar() {
|
|
if (this._avatarEl && this._avatarEl.isConnected) return this._avatarEl;
|
|
const selector = this.getAttribute("avatar");
|
|
if (selector) {
|
|
const found = document.getElementById(selector) || document.querySelector(selector);
|
|
if (found) {
|
|
this._avatarEl = found;
|
|
return found;
|
|
}
|
|
}
|
|
let element = document.querySelector("devii-avatar");
|
|
if (!element) {
|
|
element = document.createElement("devii-avatar");
|
|
if (selector && /^[a-zA-Z][\w-]*$/.test(selector)) element.id = selector;
|
|
document.body.appendChild(element);
|
|
}
|
|
this._avatarEl = element;
|
|
return element;
|
|
}
|
|
|
|
_append(node) {
|
|
const pinned = this._atBottom();
|
|
this.output.appendChild(node);
|
|
this.output.scrollTop = this.output.scrollHeight;
|
|
this._scrollOnMediaLoad(node, pinned);
|
|
}
|
|
|
|
_atBottom() {
|
|
const slack = 40;
|
|
return this.output.scrollHeight - this.output.scrollTop - this.output.clientHeight <= slack;
|
|
}
|
|
|
|
_scrollOnMediaLoad(node, pinned) {
|
|
const images = node.querySelectorAll ? node.querySelectorAll("img") : [];
|
|
images.forEach((image) => {
|
|
if (image.complete) return;
|
|
const settle = () => {
|
|
if (pinned) this.output.scrollTop = this.output.scrollHeight;
|
|
};
|
|
image.addEventListener("load", settle, { once: true });
|
|
image.addEventListener("error", settle, { once: true });
|
|
});
|
|
}
|
|
|
|
_onOutputClick(event) {
|
|
const messageButton = event.target.closest(".devii-copy");
|
|
if (messageButton) {
|
|
this._copyText(messageButton._source || "", messageButton);
|
|
return;
|
|
}
|
|
const button = event.target.closest(".md-copy");
|
|
if (!button) return;
|
|
const wrap = button.closest(".md-codewrap");
|
|
const code = wrap && wrap.querySelector("code");
|
|
if (!code) return;
|
|
this._copyText(code.textContent, button);
|
|
}
|
|
|
|
_attachCopy(line, source) {
|
|
const button = document.createElement("button");
|
|
button.type = "button";
|
|
button.className = "devii-copy";
|
|
button.title = "Copy markdown source";
|
|
button.textContent = "Copy";
|
|
button._source = source;
|
|
line.appendChild(button);
|
|
}
|
|
|
|
_copyText(text, button) {
|
|
const label = button.textContent;
|
|
const done = () => {
|
|
button.textContent = "Copied";
|
|
button.classList.add("md-copied");
|
|
window.setTimeout(() => {
|
|
button.textContent = label;
|
|
button.classList.remove("md-copied");
|
|
}, 1200);
|
|
};
|
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
navigator.clipboard.writeText(text).then(done, () => this._fallbackCopy(text, done));
|
|
} else {
|
|
this._fallbackCopy(text, done);
|
|
}
|
|
}
|
|
|
|
_fallbackCopy(text, done) {
|
|
const area = document.createElement("textarea");
|
|
area.value = text;
|
|
area.style.position = "fixed";
|
|
area.style.opacity = "0";
|
|
document.body.appendChild(area);
|
|
area.select();
|
|
try {
|
|
document.execCommand("copy");
|
|
done();
|
|
} catch (error) {
|
|
return;
|
|
}
|
|
document.body.removeChild(area);
|
|
}
|
|
|
|
_userLine(text) {
|
|
const line = document.createElement("div");
|
|
line.className = "devii-line devii-user";
|
|
line.textContent = `you> ${text}`;
|
|
this._attachCopy(line, text);
|
|
this._append(line);
|
|
}
|
|
|
|
_renderHistory(messages) {
|
|
this.output.innerHTML = "";
|
|
(messages || []).forEach((message) => {
|
|
if (message.role === "user") {
|
|
this._userLine(message.content);
|
|
} else if (message.role === "assistant" && message.content) {
|
|
this._agent(message.content);
|
|
}
|
|
});
|
|
}
|
|
|
|
_agent(text) {
|
|
const block = document.createElement("div");
|
|
block.className = "devii-line devii-agent";
|
|
block.innerHTML = this.markdown.render(text);
|
|
this._attachCopy(block, text);
|
|
this._append(block);
|
|
}
|
|
|
|
_notice(text) {
|
|
const line = document.createElement("div");
|
|
line.className = "devii-line devii-notice";
|
|
line.textContent = text;
|
|
this._append(line);
|
|
}
|
|
|
|
_error(text) {
|
|
const line = document.createElement("div");
|
|
line.className = "devii-line devii-error";
|
|
line.textContent = `[error] ${text}`;
|
|
this._append(line);
|
|
}
|
|
|
|
_task(message) {
|
|
const verbs = { start: "running...", done: "finished", finished: "finished", error: "failed" };
|
|
const head = document.createElement("div");
|
|
head.className = "devii-line devii-task";
|
|
head.textContent = `[task ${message.label}] ${verbs[message.kind] || message.kind}`;
|
|
this._append(head);
|
|
if (message.payload && message.kind !== "start") {
|
|
this._agent(message.payload);
|
|
}
|
|
}
|
|
|
|
_trace(message) {
|
|
const glyph = TRACE_GLYPHS[message.event] || message.event;
|
|
const line = document.createElement("div");
|
|
line.className = "devii-line devii-trace";
|
|
if (message.event === "call" || message.event === "ok" || message.event === "err") {
|
|
const detail = message.detail ? ` ${message.detail}` : "";
|
|
line.textContent = ` ${glyph} ${message.name}${detail}`;
|
|
} else {
|
|
line.textContent = ` ! ${glyph}`;
|
|
}
|
|
this._append(line);
|
|
}
|
|
}
|
|
|
|
customElements.define("devii-terminal", DeviiTerminalElement);
|