Commit a819741
Changed files (4)
exe
lib
elelem
server
exe/elelem-server
@@ -11,7 +11,10 @@ OptionParser.new do |o|
o.banner = "Usage: elelem-server [options]"
o.on("-p", "--provider NAME", "Provider to use (default: #{provider})") { |v| provider = v }
o.on("--port PORT", Integer, "Port to listen on (default: #{port})") { |v| port = v }
- o.on("-h", "--help", "Show this help") { puts o; exit }
+ o.on("-h", "--help", "Show this help") do
+ puts o
+ exit
+ end
end.parse!(ARGV)
Elelem::Server.serve(provider: provider, port: port)
lib/elelem/server/index.html
@@ -0,0 +1,144 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
+<title>elelem</title>
+<style>
+ :root { color-scheme: dark; }
+ * { box-sizing: border-box; }
+ body {
+ margin: 0; height: 100dvh; display: flex; flex-direction: column;
+ background: #16161a; color: #e6e6e6;
+ font: 16px/1.55 ui-sans-serif, system-ui, -apple-system, sans-serif;
+ }
+ #log { flex: 1; overflow-y: auto; padding: 1rem; -webkit-overflow-scrolling: touch; }
+ .msg { max-width: 46rem; margin: 0 auto 1rem; padding: .65rem .85rem; border-radius: .6rem; }
+ .user { background: #2d3a4f; }
+ .assistant { background: #22222a; }
+ .thinking { opacity: .55; font-style: italic; white-space: pre-wrap; }
+ .msg pre {
+ background: #0d0d10; padding: .7rem; border-radius: .4rem;
+ overflow-x: auto; font-size: .875rem;
+ }
+ .msg code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
+ .prompt { max-width: 46rem; margin: 0 auto 1rem; padding: .85rem; border-radius: .6rem; background: #4a3a1e; }
+ .prompt button {
+ font-size: 1rem; padding: .7rem 1.4rem; margin: .5rem .5rem 0 0;
+ border: 0; border-radius: .5rem; color: #fff; touch-action: manipulation;
+ }
+ .allow { background: #2f7d4f; }
+ .deny { background: #8c3535; }
+ form { display: flex; gap: .5rem; padding: .75rem; padding-bottom: max(.75rem, env(safe-area-inset-bottom));
+ border-top: 1px solid #2c2c33; background: #1b1b20; }
+ textarea {
+ flex: 1; resize: none; font: inherit; padding: .6rem; border-radius: .5rem;
+ border: 1px solid #35353d; background: #101014; color: inherit;
+ }
+ button.send { font-size: 1rem; padding: .6rem 1.2rem; border: 0; border-radius: .5rem; background: #3b6ea5; color: #fff; }
+</style>
+</head>
+<body>
+<div id="log"></div>
+<form id="composer">
+ <textarea id="input" rows="1" placeholder="Message elelem…" autocapitalize="sentences"></textarea>
+ <button class="send" type="submit">Send</button>
+</form>
+<script>
+const log = document.getElementById("log");
+const input = document.getElementById("input");
+let current = null;
+
+const atBottom = () => log.scrollHeight - log.scrollTop - log.clientHeight < 80;
+const scroll = (was) => { if (was) log.scrollTop = log.scrollHeight; };
+
+function escape(text) {
+ return text.replace(/[&<>]/g, (c) => ({ "&": "&", "<": "<", ">": ">" })[c]);
+}
+
+function render(text) {
+ return escape(text)
+ .replace(/```(\\w*)\\n([\\s\\S]*?)```/g, (_m, _lang, code) => "<pre><code>" + code + "</code></pre>")
+ .replace(/`([^`\\n]+)`/g, "<code>$1</code>")
+ .replace(/\\*\\*([^*]+)\\*\\*/g, "<strong>$1</strong>")
+ .replace(/\\n/g, "<br>");
+}
+
+function bubble(cls) {
+ const was = atBottom();
+ const el = document.createElement("div");
+ el.className = "msg " + cls;
+ log.appendChild(el);
+ scroll(was);
+ return el;
+}
+
+function append(cls, text) {
+ if (!current || current.dataset.cls !== cls) {
+ current = bubble(cls);
+ current.dataset.cls = cls;
+ current.dataset.raw = "";
+ }
+ const was = atBottom();
+ current.dataset.raw += text;
+ current.innerHTML = render(current.dataset.raw);
+ scroll(was);
+}
+
+function askPermission(id, text) {
+ current = null;
+ const was = atBottom();
+ const el = document.createElement("div");
+ el.className = "prompt";
+ el.dataset.id = id;
+ el.innerHTML = "<div>" + escape(text) + "</div>";
+ const respond = (answer) => {
+ el.querySelectorAll("button").forEach((b) => b.remove());
+ el.insertAdjacentHTML("beforeend", "<div><em>" + answer + "</em></div>");
+ fetch("/answer", { method: "POST", body: JSON.stringify({ id: el.dataset.id, text: answer }) });
+ };
+ const allow = document.createElement("button");
+ allow.className = "allow";
+ allow.textContent = "Allow";
+ allow.onclick = () => respond("y");
+ const deny = document.createElement("button");
+ deny.className = "deny";
+ deny.textContent = "Deny";
+ deny.onclick = () => respond("n");
+ el.append(allow, deny);
+ log.appendChild(el);
+ scroll(was);
+}
+
+new EventSource("/events").onmessage = (e) => {
+ const event = JSON.parse(e.data);
+ if (event.type === "content") append("assistant", event.text);
+ else if (event.type === "thinking") append("thinking", event.text);
+ else if (event.type === "prompt") askPermission(event.id, event.text);
+ else if (event.type === "done") current = null;
+};
+
+document.getElementById("composer").onsubmit = (e) => {
+ e.preventDefault();
+ const text = input.value.trim();
+ if (!text) return;
+ append("user", text);
+ current = null;
+ input.value = "";
+ fetch("/message", { method: "POST", body: JSON.stringify({ text: text }) });
+};
+
+input.addEventListener("keydown", (e) => {
+ if (e.key === "Enter" && !e.shiftKey) {
+ e.preventDefault();
+ document.getElementById("composer").requestSubmit();
+ }
+});
+
+input.addEventListener("input", () => {
+ input.style.height = "auto";
+ input.style.height = Math.min(input.scrollHeight, 160) + "px";
+});
+</script>
+</body>
+</html>
lib/elelem/server/web_terminal.rb
@@ -24,7 +24,10 @@ module Elelem
def ask(prompt)
queue = Queue.new
- id = @mutex.synchronize { @prompts[@prompt_counter += 1] = queue; @prompt_counter }
+ id = @mutex.synchronize do
+ @prompts[@prompt_counter += 1] = queue
+ @prompt_counter
+ end
emit("prompt", prompt, id: id)
queue.pop
end
@@ -42,8 +45,7 @@ module Elelem
say "#{state} #{name}(#{args})"
end
- def waiting
- end
+ def waiting; end
def print(text)
emit("content", text)
lib/elelem/server.rb
@@ -9,6 +9,8 @@ require_relative "server/web_terminal"
module Elelem
class Server
+ INDEX = File.read(File.expand_path("server/index.html", __dir__))
+
def self.serve(provider: "stub", port: 4567, toolbox: Toolbox.new)
llm_provider = Config.build_provider(provider)
web_terminal = WebTerminal.new
@@ -71,7 +73,7 @@ module Elelem
text = JSON.parse(req.body)["text"].to_s
Thread.new do
@session.turn(text)
- rescue => e
+ rescue StandardError => e
@terminal.say("Error: #{e.message}")
ensure
@terminal.done
@@ -84,152 +86,5 @@ module Elelem
@terminal.answer(body["id"].to_i, body["text"].to_s)
res.status = 204
end
-
- INDEX = <<~HTML
- <!doctype html>
- <html lang="en">
- <head>
- <meta charset="utf-8">
- <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
- <title>elelem</title>
- <style>
- :root { color-scheme: dark; }
- * { box-sizing: border-box; }
- body {
- margin: 0; height: 100dvh; display: flex; flex-direction: column;
- background: #16161a; color: #e6e6e6;
- font: 16px/1.55 ui-sans-serif, system-ui, -apple-system, sans-serif;
- }
- #log { flex: 1; overflow-y: auto; padding: 1rem; -webkit-overflow-scrolling: touch; }
- .msg { max-width: 46rem; margin: 0 auto 1rem; padding: .65rem .85rem; border-radius: .6rem; }
- .user { background: #2d3a4f; }
- .assistant { background: #22222a; }
- .thinking { opacity: .55; font-style: italic; white-space: pre-wrap; }
- .msg pre {
- background: #0d0d10; padding: .7rem; border-radius: .4rem;
- overflow-x: auto; font-size: .875rem;
- }
- .msg code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
- .prompt { max-width: 46rem; margin: 0 auto 1rem; padding: .85rem; border-radius: .6rem; background: #4a3a1e; }
- .prompt button {
- font-size: 1rem; padding: .7rem 1.4rem; margin: .5rem .5rem 0 0;
- border: 0; border-radius: .5rem; color: #fff; touch-action: manipulation;
- }
- .allow { background: #2f7d4f; }
- .deny { background: #8c3535; }
- form { display: flex; gap: .5rem; padding: .75rem; padding-bottom: max(.75rem, env(safe-area-inset-bottom));
- border-top: 1px solid #2c2c33; background: #1b1b20; }
- textarea {
- flex: 1; resize: none; font: inherit; padding: .6rem; border-radius: .5rem;
- border: 1px solid #35353d; background: #101014; color: inherit;
- }
- button.send { font-size: 1rem; padding: .6rem 1.2rem; border: 0; border-radius: .5rem; background: #3b6ea5; color: #fff; }
- </style>
- </head>
- <body>
- <div id="log"></div>
- <form id="composer">
- <textarea id="input" rows="1" placeholder="Message elelem…" autocapitalize="sentences"></textarea>
- <button class="send" type="submit">Send</button>
- </form>
- <script>
- const log = document.getElementById("log");
- const input = document.getElementById("input");
- let current = null;
-
- const atBottom = () => log.scrollHeight - log.scrollTop - log.clientHeight < 80;
- const scroll = (was) => { if (was) log.scrollTop = log.scrollHeight; };
-
- function escape(text) {
- return text.replace(/[&<>]/g, (c) => ({ "&": "&", "<": "<", ">": ">" })[c]);
- }
-
- function render(text) {
- return escape(text)
- .replace(/```(\\w*)\\n([\\s\\S]*?)```/g, (_m, _lang, code) => "<pre><code>" + code + "</code></pre>")
- .replace(/`([^`\\n]+)`/g, "<code>$1</code>")
- .replace(/\\*\\*([^*]+)\\*\\*/g, "<strong>$1</strong>")
- .replace(/\\n/g, "<br>");
- }
-
- function bubble(cls) {
- const was = atBottom();
- const el = document.createElement("div");
- el.className = "msg " + cls;
- log.appendChild(el);
- scroll(was);
- return el;
- }
-
- function append(cls, text) {
- if (!current || current.dataset.cls !== cls) {
- current = bubble(cls);
- current.dataset.cls = cls;
- current.dataset.raw = "";
- }
- const was = atBottom();
- current.dataset.raw += text;
- current.innerHTML = render(current.dataset.raw);
- scroll(was);
- }
-
- function askPermission(id, text) {
- current = null;
- const was = atBottom();
- const el = document.createElement("div");
- el.className = "prompt";
- el.dataset.id = id;
- el.innerHTML = "<div>" + escape(text) + "</div>";
- const respond = (answer) => {
- el.querySelectorAll("button").forEach((b) => b.remove());
- el.insertAdjacentHTML("beforeend", "<div><em>" + answer + "</em></div>");
- fetch("/answer", { method: "POST", body: JSON.stringify({ id: el.dataset.id, text: answer }) });
- };
- const allow = document.createElement("button");
- allow.className = "allow";
- allow.textContent = "Allow";
- allow.onclick = () => respond("y");
- const deny = document.createElement("button");
- deny.className = "deny";
- deny.textContent = "Deny";
- deny.onclick = () => respond("n");
- el.append(allow, deny);
- log.appendChild(el);
- scroll(was);
- }
-
- new EventSource("/events").onmessage = (e) => {
- const event = JSON.parse(e.data);
- if (event.type === "content") append("assistant", event.text);
- else if (event.type === "thinking") append("thinking", event.text);
- else if (event.type === "prompt") askPermission(event.id, event.text);
- else if (event.type === "done") current = null;
- };
-
- document.getElementById("composer").onsubmit = (e) => {
- e.preventDefault();
- const text = input.value.trim();
- if (!text) return;
- append("user", text);
- current = null;
- input.value = "";
- fetch("/message", { method: "POST", body: JSON.stringify({ text: text }) });
- };
-
- input.addEventListener("keydown", (e) => {
- if (e.key === "Enter" && !e.shiftKey) {
- e.preventDefault();
- document.getElementById("composer").requestSubmit();
- }
- });
-
- input.addEventListener("input", () => {
- input.style.height = "auto";
- input.style.height = Math.min(input.scrollHeight, 160) + "px";
- });
- </script>
- </body>
- </html>
- HTML
end
end