|
// retoor <retoor@molodetz.nl>
|
|
|
|
import Markdown from "./markdown.js";
|
|
import DeviiSocket from "./DeviiSocket.js";
|
|
import DeviiClient from "./DeviiClient.js";
|
|
|
|
const CSS_ID = "devii-terminal-css";
|
|
const STORAGE_KEY = "devii-terminal-state";
|
|
const HISTORY_KEY = "devii-terminal-history";
|
|
const HISTORY_LIMIT = 500;
|
|
const MOBILE_QUERY = "(max-width: 640px)";
|
|
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 CLOSE_WORDS = new Set(["exit", "close", "quit", "bye", "/exit", "/close", "/quit", "/bye"]);
|
|
const HELP = [
|
|
"Local commands:",
|
|
" /help show this help",
|
|
" clear clear the conversation",
|
|
" reset cancel the running task and 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 HTMLElement {
|
|
constructor() {
|
|
super();
|
|
this.markdown = new Markdown();
|
|
this.deviiClient = new DeviiClient();
|
|
this.socket = null;
|
|
this.history = [];
|
|
this.historyIndex = 0;
|
|
this.draft = "";
|
|
this._historyKey = null;
|
|
this.state = "closed";
|
|
this.geometry = null;
|
|
this._drag = null;
|
|
this._hasFocus = false;
|
|
this._unloading = false;
|
|
this._mobile = window.matchMedia(MOBILE_QUERY);
|
|
}
|
|
|
|
connectedCallback() {
|
|
this._ensureCss();
|
|
this._build();
|
|
if (globalThis.app && typeof globalThis.app.register === "function") {
|
|
globalThis.app.register(this);
|
|
}
|
|
this._mobile.addEventListener("change", () => this._onViewportChange());
|
|
window.addEventListener("resize", () => this._clampWindow());
|
|
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._notice("connected.");
|
|
this._reportVisibility();
|
|
},
|
|
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);
|
|
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;
|
|
}
|
|
}
|
|
|
|
_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 = "/static/css/devii.css";
|
|
document.head.appendChild(link);
|
|
}
|
|
|
|
_build() {
|
|
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.innerHTML = [
|
|
'<div class="devii-titlebar">',
|
|
' <span class="devii-title"><span class="devii-dot"></span>Devii</span>',
|
|
' <span class="devii-winctl">',
|
|
' <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"></div>',
|
|
'<div class="devii-inputrow">',
|
|
' <span class="devii-prompt">you></span>',
|
|
' <input class="devii-input" type="text" autocomplete="off" spellcheck="false" />',
|
|
"</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) => {
|
|
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", () => 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();
|
|
}
|
|
|
|
_window(action) {
|
|
if (action === "close") {
|
|
this.close();
|
|
} else if (action === "maximize") {
|
|
this._setState(this.state === "maximized" ? "normal" : "maximized");
|
|
} else if (action === "fullscreen") {
|
|
this._setState(this.state === "fullscreen" ? "normal" : "fullscreen");
|
|
}
|
|
}
|
|
|
|
_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 = "";
|
|
}
|
|
this._persist();
|
|
}
|
|
|
|
_defaultGeometry() {
|
|
const width = Math.min(760, Math.round(window.innerWidth * 0.94));
|
|
const height = Math.min(600, Math.round(window.innerHeight * 0.82));
|
|
return {
|
|
width,
|
|
height,
|
|
left: Math.max(12, window.innerWidth - width - 24),
|
|
top: Math.max(12, window.innerHeight - height - 24),
|
|
};
|
|
}
|
|
|
|
_applyGeometry() {
|
|
if (!this.geometry) this.geometry = this._defaultGeometry();
|
|
const g = this.geometry;
|
|
this.win.style.width = `${g.width}px`;
|
|
this.win.style.height = `${g.height}px`;
|
|
this.win.style.left = `${g.left}px`;
|
|
this.win.style.top = `${g.top}px`;
|
|
}
|
|
|
|
_clampGeometry() {
|
|
if (!this.geometry) return;
|
|
const g = this.geometry;
|
|
g.width = Math.min(g.width, window.innerWidth - 16);
|
|
g.height = Math.min(g.height, window.innerHeight - 16);
|
|
g.left = Math.max(8, Math.min(g.left, window.innerWidth - g.width - 8));
|
|
g.top = Math.max(8, Math.min(g.top, window.innerHeight - g.height - 8));
|
|
}
|
|
|
|
_clampWindow() {
|
|
if (this.state !== "normal" || !this.geometry) return;
|
|
this._clampGeometry();
|
|
this._applyGeometry();
|
|
this._persist();
|
|
}
|
|
|
|
_onViewportChange() {
|
|
if (this.state === "closed") return;
|
|
this._setState("open");
|
|
}
|
|
|
|
_startDrag(event) {
|
|
if (this.state !== "normal") return;
|
|
if (event.target.tagName === "BUTTON") return;
|
|
event.preventDefault();
|
|
const point = event.touches ? event.touches[0] : event;
|
|
const rect = this.win.getBoundingClientRect();
|
|
this._drag = { dx: point.clientX - rect.left, dy: point.clientY - rect.top };
|
|
this.classList.add("devii-dragging");
|
|
this._moveHandler = (move) => this._onDrag(move);
|
|
this._upHandler = () => this._endDrag();
|
|
window.addEventListener("mousemove", this._moveHandler);
|
|
window.addEventListener("mouseup", this._upHandler);
|
|
window.addEventListener("touchmove", this._moveHandler, { passive: false });
|
|
window.addEventListener("touchend", this._upHandler);
|
|
}
|
|
|
|
_onDrag(event) {
|
|
if (!this._drag) return;
|
|
if (event.cancelable) event.preventDefault();
|
|
const point = event.touches ? event.touches[0] : event;
|
|
const g = this.geometry;
|
|
g.left = Math.max(8, Math.min(point.clientX - this._drag.dx, window.innerWidth - g.width - 8));
|
|
g.top = Math.max(8, Math.min(point.clientY - this._drag.dy, window.innerHeight - g.height - 8));
|
|
this.win.style.left = `${g.left}px`;
|
|
this.win.style.top = `${g.top}px`;
|
|
}
|
|
|
|
_endDrag() {
|
|
this._drag = null;
|
|
this.classList.remove("devii-dragging");
|
|
window.removeEventListener("mousemove", this._moveHandler);
|
|
window.removeEventListener("mouseup", this._upHandler);
|
|
window.removeEventListener("touchmove", this._moveHandler);
|
|
window.removeEventListener("touchend", this._upHandler);
|
|
this._syncSize();
|
|
}
|
|
|
|
_syncSize() {
|
|
if (this.state !== "normal") return;
|
|
const rect = this.win.getBoundingClientRect();
|
|
this.geometry.width = Math.round(rect.width);
|
|
this.geometry.height = Math.round(rect.height);
|
|
this._persist();
|
|
}
|
|
|
|
_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 (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.");
|
|
}
|
|
}
|
|
|
|
_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 response = await fetch("/devii/session", { credentials: "same-origin" });
|
|
const data = await response.json();
|
|
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(),
|
|
});
|
|
}
|
|
|
|
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) {
|
|
this.output.appendChild(node);
|
|
this.output.scrollTop = this.output.scrollHeight;
|
|
}
|
|
|
|
_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) {
|
|
// copy unavailable; leave the button unchanged
|
|
}
|
|
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);
|