Toby Chui пре 2 недеља
родитељ
комит
d9a2ff92f4

+ 22 - 0
src/web/Terminal/backend/readfile.agi

@@ -0,0 +1,22 @@
+/*
+    Terminal — Read File Backend
+
+    Reads an AGI/JS script from the user's virtual filesystem and
+    returns its raw text content so the frontend can preview and
+    optionally execute it.
+
+    POST param: path  (virtual path, e.g. user:/Desktop/script.agi)
+*/
+
+requirelib("filelib");
+
+if (!filelib.fileExists(path)) {
+    sendResp("__ERROR__File not found: " + path);
+} else {
+    var content = filelib.readFile(path);
+    if (content === false || content === null || content === undefined) {
+        sendResp("__ERROR__Cannot read file: " + path);
+    } else {
+        sendResp(content);
+    }
+}

+ 150 - 0
src/web/Terminal/backend/session.agi

@@ -0,0 +1,150 @@
+/*
+    Terminal — WebSocket REPL Session
+
+    Maintains a persistent Otto VM over a WebSocket connection.
+    All eval() calls execute in the same global scope, so variables
+    defined in one command are accessible in the next.
+
+    Protocol (JSON)
+    ───────────────
+    Client → Server
+      { type: "exec", code: "<js>" }   run a snippet
+      { type: "ping" }                 keep-alive
+
+    Server → Client
+      { type: "ready",  user, build }                  session started
+      { type: "result", output, error, logs }           exec result
+      { type: "pong" }                                  ping response
+*/
+
+requirelib("websocket");
+
+if (!websocket.upgrade(300)) {
+    sendResp("WebSocket upgrade failed");
+    exit();
+}
+
+// ── Output capture ─────────────────────────────────────────────────────────
+// Override console.log so user output is returned via the protocol rather
+// than being lost to the server log.  The original is still called so the
+// server log also gets the line for debugging.
+
+var _logs        = [];
+var _origLog     = console.log;
+var _capturedResp = "";
+
+console.log = function() {
+    var parts = [];
+    for (var i = 0; i < arguments.length; i++) {
+        var a = arguments[i];
+        parts.push(typeof a === "object" ? JSON.stringify(a) : String(a));
+    }
+    var line = parts.join(" ");
+    _logs.push(line);
+    _origLog("[Terminal:" + USERNAME + "] " + line);
+};
+
+// Capture sendResp / echo output instead of writing to HTTP response
+// (the connection is already upgraded; writing to w would be a no-op)
+var _origSendResp     = sendResp;
+var _origEcho         = echo;
+var _origSendJSONResp = sendJSONResp;
+
+sendResp = function(v) {
+    _capturedResp = String(v);
+};
+echo = function(v) {
+    _capturedResp = _capturedResp + String(v);
+};
+sendJSONResp = function(v) {
+    _capturedResp = typeof v === "string" ? v : JSON.stringify(v);
+};
+
+// Prevent exit() from panicking the session goroutine
+var _sessionRunning = true;
+var _origExit = exit;
+exit = function() {
+    _sessionRunning = false;
+};
+
+// ── Helpers ────────────────────────────────────────────────────────────────
+
+function _formatResult(v) {
+    if (v === undefined || v === null) return "";
+    if (typeof v === "string")  return v;
+    if (typeof v === "number" || typeof v === "boolean") return String(v);
+    try {
+        var s = JSON.stringify(v, null, 2);
+        return s !== undefined ? s : String(v);
+    } catch(e) {
+        return String(v);
+    }
+}
+
+// ── Session ready ──────────────────────────────────────────────────────────
+
+websocket.send(JSON.stringify({
+    type:  "ready",
+    user:  USERNAME,
+    build: BUILD_VERSION
+}));
+
+// ── REPL loop ──────────────────────────────────────────────────────────────
+
+while (_sessionRunning && !websocket.isClosed()) {
+
+    var raw = websocket.read(60000); // 60-second idle read
+
+    if (raw === false) break;   // connection closed
+    if (raw === null)  continue; // idle timeout — loop back
+
+    var req;
+    try {
+        req = JSON.parse(raw);
+    } catch(e) {
+        // Malformed JSON — ignore
+        continue;
+    }
+
+    if (req.type === "ping") {
+        websocket.send(JSON.stringify({ type: "pong" }));
+        continue;
+    }
+
+    if (req.type !== "exec" || typeof req.code !== "string") continue;
+
+    // Reset per-invocation state
+    _logs         = [];
+    _capturedResp = "";
+    HTTP_RESP     = "";
+
+    var _execOutput = "";
+    var _execError  = false;
+
+    try {
+        var _execResult = eval(req.code);
+
+        // Priority: explicit sendResp/echo/sendJSONResp > HTTP_RESP > return value
+        if (_capturedResp !== "") {
+            _execOutput = _capturedResp;
+        } else if (HTTP_RESP !== undefined && HTTP_RESP !== "") {
+            _execOutput = String(HTTP_RESP);
+            HTTP_RESP = "";
+        } else {
+            _execOutput = _formatResult(_execResult);
+        }
+
+    } catch(e) {
+        _execError  = true;
+        _execOutput = e.toString();
+    }
+
+    websocket.send(JSON.stringify({
+        type:   "result",
+        output: _execOutput,
+        error:  _execError,
+        logs:   _logs
+    }));
+}
+
+websocket.close();

+ 5 - 0
src/web/Terminal/img/icon.svg

@@ -0,0 +1,5 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48">
+  <rect width="48" height="48" rx="8" fill="#1e1e1e"/>
+  <polygon points="8,16 20,24 8,32" fill="#4ec94e"/>
+  <rect x="22" y="29" width="18" height="3" rx="1.5" fill="#4ec94e"/>
+</svg>

+ 513 - 0
src/web/Terminal/index.html

@@ -0,0 +1,513 @@
+<!DOCTYPE html>
+<html>
+    <head>
+        <meta charset="UTF-8">
+        <meta name="viewport" content="width=device-width, initial-scale=1">
+        <title>Terminal</title>
+        <script src="../script/jquery.min.js"></script>
+        <script src="../script/ao_module.js"></script>
+        <style>
+            *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
+
+            :root {
+                --bg:       #1e1e1e;
+                --bg2:      #252526;
+                --bg3:      #2d2d2d;
+                --border:   #3c3c3c;
+                --fg:       #d4d4d4;
+                --fg-dim:   #858585;
+                --green:    #4ec94e;
+                --red:      #f44747;
+                --yellow:   #dcdcaa;
+                --blue:     #569cd6;
+                --orange:   #ce9178;
+                --prompt:   #4ec94e;
+            }
+
+            html, body {
+                height: 100%;
+                background: var(--bg);
+                color: var(--fg);
+                font-family: 'Cascadia Code', 'Consolas', 'Courier New', monospace;
+                font-size: 13px;
+                overflow: hidden;
+            }
+
+            /* ── Shell layout ──────────────────────────────────────── */
+            .shell {
+                display: flex;
+                flex-direction: column;
+                height: 100vh;
+            }
+
+            /* ── Toolbar ───────────────────────────────────────────── */
+            .toolbar {
+                flex-shrink: 0;
+                background: var(--bg3);
+                border-bottom: 1px solid var(--border);
+                display: flex;
+                align-items: center;
+                gap: 4px;
+                padding: 4px 10px;
+                height: 34px;
+            }
+
+            .toolbar-title {
+                font-size: 12px;
+                color: var(--fg-dim);
+                margin-right: 6px;
+                white-space: nowrap;
+            }
+
+            .tb-btn {
+                padding: 2px 10px;
+                background: transparent;
+                border: 1px solid var(--border);
+                color: var(--fg-dim);
+                cursor: pointer;
+                border-radius: 3px;
+                font-family: inherit;
+                font-size: 11px;
+                transition: background 0.1s, color 0.1s;
+                white-space: nowrap;
+            }
+            .tb-btn:hover { background: var(--bg2); color: var(--fg); }
+
+            .conn-badge {
+                margin-left: auto;
+                display: flex;
+                align-items: center;
+                gap: 5px;
+                font-size: 11px;
+                color: var(--fg-dim);
+            }
+            .conn-dot {
+                width: 7px; height: 7px;
+                border-radius: 50%;
+                background: var(--fg-dim);
+                flex-shrink: 0;
+                transition: background 0.2s;
+            }
+            .conn-dot.connecting { background: var(--yellow); animation: blink 1s ease-in-out infinite; }
+            .conn-dot.connected  { background: var(--green); }
+            .conn-dot.error      { background: var(--red); }
+            @keyframes blink { 0%,100%{opacity:1} 50%{opacity:.3} }
+
+            /* ── Output area ───────────────────────────────────────── */
+            .term-output {
+                flex: 1;
+                overflow-y: auto;
+                padding: 10px 14px 6px;
+                cursor: text;
+                /* custom scrollbar */
+                scrollbar-width: thin;
+                scrollbar-color: var(--border) transparent;
+            }
+            .term-output::-webkit-scrollbar       { width: 6px; }
+            .term-output::-webkit-scrollbar-track  { background: transparent; }
+            .term-output::-webkit-scrollbar-thumb  { background: var(--border); border-radius: 3px; }
+
+            /* ── Lines ─────────────────────────────────────────────── */
+            .tl {
+                line-height: 1.65;
+                white-space: pre-wrap;
+                word-break: break-all;
+                min-height: 1.65em;
+            }
+            /* type variants */
+            .tl-in  { color: var(--fg); }                        /* user input echo */
+            .tl-ok  { color: var(--green); }                      /* successful result */
+            .tl-err { color: var(--red); }                        /* error result */
+            .tl-log { color: var(--yellow); }                     /* console.log */
+            .tl-sys { color: var(--blue); }                       /* system / session messages */
+            .tl-file{ color: var(--orange); }                     /* file preview lines */
+            .tl-dim { color: var(--fg-dim); }                     /* decorative separators */
+
+            /* Blinking cursor on the last line when idle */
+            .tl-cursor::after {
+                content: '▋';
+                animation: blink 1s step-end infinite;
+                color: var(--prompt);
+            }
+
+            /* ── Pending file banner ───────────────────────────────── */
+            .pending-banner {
+                display: none;
+                background: #2a2d2e;
+                border-top: 1px solid var(--border);
+                border-bottom: 1px solid var(--border);
+                padding: 5px 14px;
+                font-size: 11px;
+                color: var(--yellow);
+                flex-shrink: 0;
+            }
+
+            /* ── Input row ─────────────────────────────────────────── */
+            .term-input-row {
+                flex-shrink: 0;
+                display: flex;
+                align-items: center;
+                padding: 7px 14px;
+                background: var(--bg2);
+                border-top: 1px solid var(--border);
+                gap: 8px;
+            }
+
+            .term-prompt {
+                color: var(--prompt);
+                flex-shrink: 0;
+                user-select: none;
+                font-size: 13px;
+            }
+
+            .term-input {
+                flex: 1;
+                background: transparent;
+                border: none;
+                color: var(--fg);
+                font-family: inherit;
+                font-size: 13px;
+                outline: none;
+                caret-color: var(--prompt);
+            }
+            .term-input::placeholder { color: var(--fg-dim); opacity: 0.5; }
+
+            .run-btn {
+                padding: 3px 12px;
+                background: transparent;
+                border: 1px solid var(--border);
+                color: var(--fg-dim);
+                cursor: pointer;
+                border-radius: 3px;
+                font-family: inherit;
+                font-size: 11px;
+                flex-shrink: 0;
+            }
+            .run-btn:hover { background: var(--bg3); color: var(--green); border-color: var(--green); }
+        </style>
+    </head>
+    <body>
+        <div class="shell">
+
+            <!-- Toolbar -->
+            <div class="toolbar">
+                <span class="toolbar-title">&#9654; AGI Terminal</span>
+                <button class="tb-btn" onclick="clearOutput()">Clear</button>
+                <button class="tb-btn" onclick="reconnect()">Reconnect</button>
+                <button class="tb-btn" onclick="showHelp()">Help</button>
+                <div class="conn-badge">
+                    <span class="conn-dot connecting" id="connDot"></span>
+                    <span id="connLabel">Connecting&hellip;</span>
+                </div>
+            </div>
+
+            <!-- Output -->
+            <div class="term-output" id="termOutput" onclick="focusInput()"></div>
+
+            <!-- Pending-file banner -->
+            <div class="pending-banner" id="pendingBanner">
+                &#9658; File loaded &mdash; press <kbd style="background:#3c3c3c;padding:0 4px;border-radius:2px">Enter</kbd> to run, or type a new command to cancel
+            </div>
+
+            <!-- Input row -->
+            <div class="term-input-row">
+                <span class="term-prompt">&gt;</span>
+                <input class="term-input" id="termInput" type="text"
+                       autocomplete="off" autocorrect="off" spellcheck="false"
+                       placeholder="JavaScript / AGI&hellip;"
+                       onkeydown="handleKey(event)">
+                <button class="run-btn" onclick="submitInput()">&#9654; Run</button>
+            </div>
+
+        </div>
+
+        <script>
+            // ── State ─────────────────────────────────────────────────────────
+            var ws          = null;
+            var cmdHistory  = [];
+            var histIdx     = -1;
+            var pendingCode = null;   // code loaded from a file, awaiting confirm
+            var sessionReady = false;
+            var pendingFiles = [];
+
+            // ── WebSocket helpers ─────────────────────────────────────────────
+            function wsEndpoint() {
+                var proto = location.protocol === "https:" ? "wss://" : "ws://";
+                return proto + location.hostname + ":" + location.port;
+            }
+
+            function connect() {
+                setConn("connecting", "Connecting…");
+                ws = new WebSocket(wsEndpoint() + "/system/ajgi/interface?script=Terminal/backend/session.agi");
+
+                ws.onopen = function() {
+                    // Wait for "ready" message before marking connected
+                };
+
+                ws.onmessage = function(e) {
+                    var msg;
+                    try { msg = JSON.parse(e.data); } catch(_) { return; }
+                    handleServerMsg(msg);
+                };
+
+                ws.onclose = function() {
+                    sessionReady = false;
+                    setConn("error", "Disconnected");
+                    line("sys", "# Session closed.  Click Reconnect to start a new one.");
+                    ws = null;
+                };
+
+                ws.onerror = function() {
+                    setConn("error", "Connection error");
+                };
+            }
+
+            function send(obj) {
+                if (ws && ws.readyState === WebSocket.OPEN) {
+                    ws.send(JSON.stringify(obj));
+                }
+            }
+
+            // Keep session alive
+            setInterval(function() {
+                if (ws && ws.readyState === WebSocket.OPEN) send({ type: "ping" });
+            }, 30000);
+
+            // ── Server message handler ────────────────────────────────────────
+            function handleServerMsg(msg) {
+                if (msg.type === "ready") {
+                    sessionReady = true;
+                    setConn("connected", "Connected — " + msg.user);
+                    line("sys", "# AGI Terminal — ArozOS " + (msg.build || "") + "  |  user: " + msg.user);
+                    line("sys", "# All AGI globals and requirelib() are available.");
+                    line("sys", "# Type .help for commands.  Variables persist for this session.");
+                    line("dim", "");
+                    // Now it is safe to load any files passed at launch
+                    if (pendingFiles.length > 0) {
+                        pendingFiles.forEach(function(vpath) { loadFile(vpath); });
+                        pendingFiles = [];
+                    }
+                    focusInput();
+                    return;
+                }
+
+                if (msg.type === "result") {
+                    // console.log lines
+                    if (msg.logs && msg.logs.length > 0) {
+                        msg.logs.forEach(function(l) { line("log", "· " + l); });
+                    }
+                    // result value / error
+                    if (msg.error) {
+                        line("err", "✕ " + msg.output);
+                    } else if (msg.output !== "" && msg.output !== undefined) {
+                        // Pretty-print multi-line results (e.g. JSON.stringify'd arrays)
+                        var lines = String(msg.output).split("\n");
+                        lines.forEach(function(l, i) {
+                            line("ok", (i === 0 ? "← " : "  ") + l);
+                        });
+                    } else {
+                        line("dim", "← undefined");
+                    }
+                    return;
+                }
+                // pong: silently ignored
+            }
+
+            // ── Execution ────────────────────────────────────────────────────
+            function execCode(code) {
+                if (!sessionReady) {
+                    line("err", "✕ Session not ready.  Please wait or click Reconnect.");
+                    return;
+                }
+                send({ type: "exec", code: code });
+            }
+
+            // ── Input handling ────────────────────────────────────────────────
+            function handleKey(e) {
+                if (e.key === "Enter") {
+                    submitInput();
+                } else if (e.key === "ArrowUp") {
+                    e.preventDefault();
+                    if (histIdx < cmdHistory.length - 1) {
+                        histIdx++;
+                        inp().value = cmdHistory[histIdx];
+                        caretToEnd();
+                    }
+                } else if (e.key === "ArrowDown") {
+                    e.preventDefault();
+                    if (histIdx > 0) {
+                        histIdx--;
+                        inp().value = cmdHistory[histIdx];
+                    } else {
+                        histIdx = -1;
+                        inp().value = "";
+                    }
+                } else if (e.key === "Escape") {
+                    cancelPending();
+                }
+            }
+
+            function submitInput() {
+                var raw = inp().value;
+                inp().value = "";
+                histIdx = -1;
+
+                // Empty Enter with pending file → run it
+                if (raw.trim() === "" && pendingCode !== null) {
+                    var code = pendingCode;
+                    cancelPending();
+                    line("sys", "# Running loaded file…");
+                    line("in",  "> (file)");
+                    execCode(code);
+                    return;
+                }
+
+                if (raw.trim() === "") return;
+
+                // Record history (deduplicate consecutive)
+                if (cmdHistory.length === 0 || cmdHistory[0] !== raw) {
+                    cmdHistory.unshift(raw);
+                    if (cmdHistory.length > 500) cmdHistory.pop();
+                }
+
+                // Cancel any pending file if the user typed something new
+                if (pendingCode !== null) cancelPending();
+
+                line("in", "> " + raw);
+
+                // Built-in dot-commands (handled client-side)
+                var cmd = raw.trim();
+                if      (cmd === ".help")    { showHelp(); return; }
+                else if (cmd === ".clear")   { clearOutput(); return; }
+                else if (cmd === ".reset")   { reconnect(); return; }
+                else if (cmd === ".history") { showHistory(); return; }
+
+                execCode(raw);
+            }
+
+            // ── File loading ──────────────────────────────────────────────────
+            function loadFile(vpath) {
+                ao_module_agirun("Terminal/backend/readfile.agi", { path: vpath },
+                    function(content) {
+                        if (typeof content === "string" && content.indexOf("__ERROR__") === 0) {
+                            line("err", "✕ " + content.replace("__ERROR__", ""));
+                            return;
+                        }
+                        var codeLines = String(content).split("\n");
+                        var preview   = Math.min(codeLines.length, 20);
+                        var filename  = vpath.split("/").pop();
+
+                        line("dim", "");
+                        line("file", "# ■ Loaded: " + filename + "  (" + codeLines.length + " line" + (codeLines.length !== 1 ? "s" : "") + ")");
+                        line("dim",  "# " + "─".repeat(50));
+                        for (var i = 0; i < preview; i++) {
+                            var num = String(i + 1);
+                            while (num.length < 3) num = " " + num;
+                            line("file", num + "  " + codeLines[i]);
+                        }
+                        if (codeLines.length > preview) {
+                            line("dim", "     … and " + (codeLines.length - preview) + " more line" + (codeLines.length - preview !== 1 ? "s" : ""));
+                        }
+                        line("dim",  "# " + "─".repeat(50));
+
+                        pendingCode = content;
+                        document.getElementById("pendingBanner").style.display = "block";
+                        focusInput();
+                    },
+                    function() {
+                        line("err", "✕ Failed to read file: " + vpath);
+                    }
+                );
+            }
+
+            function cancelPending() {
+                pendingCode = null;
+                document.getElementById("pendingBanner").style.display = "none";
+            }
+
+            // ── Built-in commands ─────────────────────────────────────────────
+            function showHelp() {
+                line("sys", "# ── Terminal Commands ──────────────────────────────");
+                line("sys", "#  .help       show this help");
+                line("sys", "#  .clear      clear all output");
+                line("sys", "#  .reset      reconnect and start a fresh session");
+                line("sys", "#  .history    print command history");
+                line("sys", "# ");
+                line("sys", "# ── Key Bindings ───────────────────────────────");
+                line("sys", "#  Enter       execute input (or run loaded file)");
+                line("sys", "#  ↑ / ↓       navigate command history");
+                line("sys", "#  Escape      cancel loaded file");
+                line("sys", "# ");
+                line("sys", "# ── Tips ─────────────────────────────────────");
+                line("sys", "#  Variables persist across commands within a session.");
+                line("sys", "#  requirelib(\"filelib\") works exactly as in .agi scripts.");
+                line("sys", "#  sendResp() / echo() output is captured and returned.");
+                line("sys", "#  Open .agi files from the file manager to load them here.");
+            }
+
+            function showHistory() {
+                if (cmdHistory.length === 0) {
+                    line("sys", "# (no history yet)");
+                    return;
+                }
+                cmdHistory.slice().reverse().forEach(function(cmd, i) {
+                    line("sys", "# " + String(i + 1) + "  " + cmd);
+                });
+            }
+
+            function clearOutput() {
+                document.getElementById("termOutput").innerHTML = "";
+            }
+
+            function reconnect() {
+                if (ws) { ws.close(); ws = null; }
+                sessionReady = false;
+                cancelPending();
+                clearOutput();
+                setTimeout(connect, 100);
+            }
+
+            // ── UI helpers ────────────────────────────────────────────────────
+            function line(type, text) {
+                var out  = document.getElementById("termOutput");
+                var div  = document.createElement("div");
+                var map  = { in:"tl-in", ok:"tl-ok", err:"tl-err",
+                             log:"tl-log", sys:"tl-sys", file:"tl-file", dim:"tl-dim" };
+                div.className = "tl " + (map[type] || "tl-dim");
+                div.textContent = text;
+                out.appendChild(div);
+                out.scrollTop = out.scrollHeight;
+            }
+
+            function inp() { return document.getElementById("termInput"); }
+
+            function focusInput() { inp().focus(); }
+
+            function caretToEnd() {
+                var el = inp();
+                var v  = el.value.length;
+                el.setSelectionRange(v, v);
+            }
+
+            function setConn(state, label) {
+                var dot = document.getElementById("connDot");
+                dot.className = "conn-dot " + state;
+                document.getElementById("connLabel").textContent = label;
+            }
+
+            // ── Boot ──────────────────────────────────────────────────────────
+            // Collect any files passed from the file manager (URL hash)
+            (function() {
+                var inputFiles = ao_module_loadInputFiles();
+                inputFiles.forEach(function(vpath) {
+                    vpath = vpath.trim();
+                    if (vpath !== "" && (vpath.match(/\.agi$/) || vpath.match(/\.js$/))) {
+                        pendingFiles.push(vpath);
+                    }
+                });
+            })();
+
+            connect();
+        </script>
+    </body>
+</html>

+ 25 - 0
src/web/Terminal/init.agi

@@ -0,0 +1,25 @@
+/*
+    Terminal Module Registration
+
+    Registers the AGI Terminal as a Development tool.
+    Handles .agi and .js files so users can open scripts
+    directly from the file manager.
+*/
+
+newDBTableIfNotExists("Terminal");
+
+var moduleLaunchInfo = {
+    Name: "Terminal",
+    Desc: "Interactive AGI script execution terminal with persistent VM session",
+    Group: "Development",
+    IconPath: "Terminal/img/icon.svg",
+    Version: "1.0",
+    StartDir: "Terminal/index.html",
+    SupportFW: true,
+    LaunchFWDir: "Terminal/index.html",
+    InitFWSize: [900, 580],
+    SupportEmb: false,
+    SupportedExt: [".agi", ".js"]
+};
+
+registerModule(JSON.stringify(moduleLaunchInfo));