Delete app.py
Browse files
app.py
DELETED
|
@@ -1,872 +0,0 @@
|
|
| 1 |
-
#!/usr/bin/env python3
|
| 2 |
-
"""
|
| 3 |
-
opencode mobile web UI · single file · Hugging Face Space ready
|
| 4 |
-
==================================================================
|
| 5 |
-
|
| 6 |
-
One process that:
|
| 7 |
-
1. launches the `opencode serve` backend on 127.0.0.1:<internal>,
|
| 8 |
-
2. reverse-proxies its API (REST + SSE) on 0.0.0.0:7860,
|
| 9 |
-
3. serves a mobile-first chat interface with model selection.
|
| 10 |
-
|
| 11 |
-
No pip dependencies — standard library only.
|
| 12 |
-
|
| 13 |
-
Environment:
|
| 14 |
-
PORT / OPENCODE_PORT public port (default 7860)
|
| 15 |
-
OPENCODE_HOST public bind host (default 0.0.0.0)
|
| 16 |
-
INTERNAL_PORT backend port (default 4096)
|
| 17 |
-
OPENCODE_BIN path to the binary (auto-detected otherwise)
|
| 18 |
-
WORKSPACE agent working dir (default /app/workspace or $HOME)
|
| 19 |
-
APP_PASSWORD optional password for the UI (HTTP Basic auth)
|
| 20 |
-
ANTHROPIC_API_KEY / OPENAI_API_KEY / ... picked up by opencode automatically.
|
| 21 |
-
|
| 22 |
-
Archive : 2026-10-18
|
| 23 |
-
Target : opencode 1.19.x server API
|
| 24 |
-
"""
|
| 25 |
-
|
| 26 |
-
import base64
|
| 27 |
-
import http.server
|
| 28 |
-
import json
|
| 29 |
-
import os
|
| 30 |
-
import shutil
|
| 31 |
-
import signal
|
| 32 |
-
import socket
|
| 33 |
-
import subprocess
|
| 34 |
-
import sys
|
| 35 |
-
import threading
|
| 36 |
-
import time
|
| 37 |
-
import urllib.error
|
| 38 |
-
import urllib.request
|
| 39 |
-
|
| 40 |
-
# --------------------------------------------------------------------------- #
|
| 41 |
-
# configuration
|
| 42 |
-
# --------------------------------------------------------------------------- #
|
| 43 |
-
|
| 44 |
-
PUBLIC_HOST = os.environ.get("OPENCODE_HOST", "0.0.0.0")
|
| 45 |
-
PUBLIC_PORT = int(os.environ.get("OPENCODE_PORT", os.environ.get("PORT", "7860")))
|
| 46 |
-
INTERNAL_HOST = "127.0.0.1"
|
| 47 |
-
INTERNAL_PORT = int(os.environ.get("INTERNAL_PORT", "4096"))
|
| 48 |
-
APP_PASSWORD = os.environ.get("APP_PASSWORD", "").strip()
|
| 49 |
-
WORKSPACE = os.environ.get(
|
| 50 |
-
"WORKSPACE",
|
| 51 |
-
"/app/workspace" if os.path.isdir("/app/workspace") else os.path.expanduser("~"),
|
| 52 |
-
)
|
| 53 |
-
|
| 54 |
-
_print_lock = threading.Lock()
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
def log(*a):
|
| 58 |
-
with _print_lock:
|
| 59 |
-
print("[space]", *a, flush=True)
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
# --------------------------------------------------------------------------- #
|
| 63 |
-
# backend lifecycle
|
| 64 |
-
# --------------------------------------------------------------------------- #
|
| 65 |
-
|
| 66 |
-
def find_opencode():
|
| 67 |
-
env = os.environ.get("OPENCODE_BIN")
|
| 68 |
-
if env and os.path.isfile(env):
|
| 69 |
-
return env
|
| 70 |
-
found = shutil.which("opencode")
|
| 71 |
-
if found:
|
| 72 |
-
return found
|
| 73 |
-
for c in (
|
| 74 |
-
os.path.expanduser("~/.opencode/bin/opencode"),
|
| 75 |
-
"/root/.opencode/bin/opencode",
|
| 76 |
-
"/home/user/.opencode/bin/opencode",
|
| 77 |
-
):
|
| 78 |
-
if os.path.isfile(c) and os.access(c, os.X_OK):
|
| 79 |
-
return c
|
| 80 |
-
sys.exit("[space] FATAL: opencode binary not found (set OPENCODE_BIN)")
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
def wait_ready(timeout=60):
|
| 84 |
-
deadline = time.time() + timeout
|
| 85 |
-
url = f"http://{INTERNAL_HOST}:{INTERNAL_PORT}/api/session"
|
| 86 |
-
while time.time() < deadline:
|
| 87 |
-
try:
|
| 88 |
-
with urllib.request.urlopen(url, timeout=3) as r:
|
| 89 |
-
if r.status == 200:
|
| 90 |
-
return True
|
| 91 |
-
except Exception:
|
| 92 |
-
pass
|
| 93 |
-
time.sleep(0.4)
|
| 94 |
-
return False
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
def start_backend():
|
| 98 |
-
binary = find_opencode()
|
| 99 |
-
os.makedirs(WORKSPACE, exist_ok=True)
|
| 100 |
-
cmd = [binary, "serve", "--hostname", INTERNAL_HOST, "--port", str(INTERNAL_PORT)]
|
| 101 |
-
log("starting backend:", " ".join(cmd), "(cwd=%s)" % WORKSPACE)
|
| 102 |
-
proc = subprocess.Popen(cmd, cwd=WORKSPACE, env=os.environ.copy())
|
| 103 |
-
if not wait_ready():
|
| 104 |
-
proc.terminate()
|
| 105 |
-
sys.exit("[space] FATAL: opencode backend did not become ready")
|
| 106 |
-
log("backend ready on %s:%d" % (INTERNAL_HOST, INTERNAL_PORT))
|
| 107 |
-
return proc
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
# --------------------------------------------------------------------------- #
|
| 111 |
-
# HTTP proxy + UI server
|
| 112 |
-
# --------------------------------------------------------------------------- #
|
| 113 |
-
|
| 114 |
-
HOP_BY_HOP = {
|
| 115 |
-
"connection", "keep-alive", "proxy-authenticate", "proxy-authorization",
|
| 116 |
-
"te", "trailers", "transfer-encoding", "upgrade", "content-length", "host",
|
| 117 |
-
"accept-encoding",
|
| 118 |
-
}
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
class Handler(http.server.BaseHTTPRequestHandler):
|
| 122 |
-
protocol_version = "HTTP/1.1"
|
| 123 |
-
server_version = "opencode-space/1.0"
|
| 124 |
-
|
| 125 |
-
# -- helpers ---------------------------------------------------------- #
|
| 126 |
-
|
| 127 |
-
def log_message(self, *a): # silence access log
|
| 128 |
-
pass
|
| 129 |
-
|
| 130 |
-
def _authenticated(self):
|
| 131 |
-
if not APP_PASSWORD:
|
| 132 |
-
return True
|
| 133 |
-
header = self.headers.get("Authorization", "")
|
| 134 |
-
if not header.startswith("Basic "):
|
| 135 |
-
return False
|
| 136 |
-
try:
|
| 137 |
-
decoded = base64.b64decode(header[6:]).decode("utf-8", "replace")
|
| 138 |
-
except Exception:
|
| 139 |
-
return False
|
| 140 |
-
# Accept "APP_PASSWORD" on its own or as the password part of user:password.
|
| 141 |
-
return decoded == APP_PASSWORD or decoded.split(":", 1)[-1] == APP_PASSWORD
|
| 142 |
-
|
| 143 |
-
def _require_auth(self):
|
| 144 |
-
if self._authenticated():
|
| 145 |
-
return True
|
| 146 |
-
self.send_response(401)
|
| 147 |
-
self.send_header("WWW-Authenticate", 'Basic realm="opencode"')
|
| 148 |
-
self.send_header("Content-Length", "0")
|
| 149 |
-
self.end_headers()
|
| 150 |
-
return False
|
| 151 |
-
|
| 152 |
-
def _send(self, status, body=b"", ctype="application/octet-stream"):
|
| 153 |
-
if isinstance(body, str):
|
| 154 |
-
body = body.encode("utf-8")
|
| 155 |
-
self.send_response(status)
|
| 156 |
-
self.send_header("Content-Type", ctype)
|
| 157 |
-
self.send_header("Content-Length", str(len(body)))
|
| 158 |
-
self.send_header("Cache-Control", "no-store")
|
| 159 |
-
self.end_headers()
|
| 160 |
-
if self.command != "HEAD":
|
| 161 |
-
self.wfile.write(body)
|
| 162 |
-
|
| 163 |
-
def _send_json(self, obj, status=200):
|
| 164 |
-
self._send(status, json.dumps(obj), "application/json; charset=utf-8")
|
| 165 |
-
|
| 166 |
-
def _upstream_url(self):
|
| 167 |
-
path = self.path if self.path.startswith("/") else "/" + self.path
|
| 168 |
-
return f"http://{INTERNAL_HOST}:{INTERNAL_PORT}{path}"
|
| 169 |
-
|
| 170 |
-
def _read_body(self):
|
| 171 |
-
length = int(self.headers.get("Content-Length") or 0)
|
| 172 |
-
return self.rfile.read(length) if length else None
|
| 173 |
-
|
| 174 |
-
def _forward_headers(self):
|
| 175 |
-
out = {}
|
| 176 |
-
for k, v in self.headers.items():
|
| 177 |
-
if k.lower() in HOP_BY_HOP:
|
| 178 |
-
continue
|
| 179 |
-
out[k] = v
|
| 180 |
-
return out
|
| 181 |
-
|
| 182 |
-
# -- routing ---------------------------------------------------------- #
|
| 183 |
-
|
| 184 |
-
def do_GET(self):
|
| 185 |
-
self._dispatch("GET")
|
| 186 |
-
|
| 187 |
-
def do_POST(self):
|
| 188 |
-
self._dispatch("POST")
|
| 189 |
-
|
| 190 |
-
def do_PUT(self):
|
| 191 |
-
self._dispatch("PUT")
|
| 192 |
-
|
| 193 |
-
def do_PATCH(self):
|
| 194 |
-
self._dispatch("PATCH")
|
| 195 |
-
|
| 196 |
-
def do_DELETE(self):
|
| 197 |
-
self._dispatch("DELETE")
|
| 198 |
-
|
| 199 |
-
def do_HEAD(self):
|
| 200 |
-
self._dispatch("HEAD")
|
| 201 |
-
|
| 202 |
-
def do_OPTIONS(self):
|
| 203 |
-
self.send_response(204)
|
| 204 |
-
self.send_header("Access-Control-Allow-Origin", "*")
|
| 205 |
-
self.send_header("Access-Control-Allow-Headers", "content-type, authorization")
|
| 206 |
-
self.send_header("Access-Control-Allow-Methods", "GET,POST,PUT,PATCH,DELETE,OPTIONS")
|
| 207 |
-
self.send_header("Content-Length", "0")
|
| 208 |
-
self.end_headers()
|
| 209 |
-
|
| 210 |
-
def _dispatch(self, method):
|
| 211 |
-
if not self._require_auth():
|
| 212 |
-
return
|
| 213 |
-
path = self.path.split("?", 1)[0]
|
| 214 |
-
try:
|
| 215 |
-
if path == "/healthz":
|
| 216 |
-
return self._send(200, "ok", "text/plain")
|
| 217 |
-
if path in ("/", "/index.html"):
|
| 218 |
-
return self._send(200, INDEX_HTML, "text/html; charset=utf-8")
|
| 219 |
-
if path == "/app.css":
|
| 220 |
-
return self._send(200, APP_CSS, "text/css; charset=utf-8")
|
| 221 |
-
if path == "/app.js":
|
| 222 |
-
return self._send(200, APP_JS, "application/javascript; charset=utf-8")
|
| 223 |
-
if path.startswith("/api/"):
|
| 224 |
-
if path == "/api/event" and method == "GET":
|
| 225 |
-
return self._proxy_sse()
|
| 226 |
-
return self._proxy(method)
|
| 227 |
-
return self._send(404, "not found", "text/plain")
|
| 228 |
-
except (BrokenPipeError, ConnectionResetError):
|
| 229 |
-
pass
|
| 230 |
-
except Exception as e: # noqa: BLE001
|
| 231 |
-
log("dispatch error:", repr(e))
|
| 232 |
-
try:
|
| 233 |
-
self._send(502, f"proxy error: {e}", "text/plain")
|
| 234 |
-
except Exception:
|
| 235 |
-
pass
|
| 236 |
-
|
| 237 |
-
# -- plain proxy ------------------------------------------------------ #
|
| 238 |
-
|
| 239 |
-
def _proxy(self, method):
|
| 240 |
-
body = self._read_body()
|
| 241 |
-
req = urllib.request.Request(self._upstream_url(), data=body, method=method)
|
| 242 |
-
for k, v in self._forward_headers().items():
|
| 243 |
-
req.add_header(k, v)
|
| 244 |
-
try:
|
| 245 |
-
with urllib.request.urlopen(req, timeout=300) as up:
|
| 246 |
-
data = up.read()
|
| 247 |
-
status = up.status
|
| 248 |
-
ctype = up.headers.get("Content-Type", "application/octet-stream")
|
| 249 |
-
except urllib.error.HTTPError as e:
|
| 250 |
-
data = e.read()
|
| 251 |
-
status = e.code
|
| 252 |
-
ctype = e.headers.get("Content-Type", "application/json")
|
| 253 |
-
self.send_response(status)
|
| 254 |
-
self.send_header("Content-Type", ctype)
|
| 255 |
-
self.send_header("Content-Length", str(len(data)))
|
| 256 |
-
self.send_header("Cache-Control", "no-store")
|
| 257 |
-
self.end_headers()
|
| 258 |
-
if method != "HEAD":
|
| 259 |
-
self.wfile.write(data)
|
| 260 |
-
|
| 261 |
-
# -- SSE proxy -------------------------------------------------------- #
|
| 262 |
-
|
| 263 |
-
def _proxy_sse(self):
|
| 264 |
-
req = urllib.request.Request(self._upstream_url(), method="GET")
|
| 265 |
-
req.add_header("Accept", "text/event-stream")
|
| 266 |
-
up = urllib.request.urlopen(req, timeout=None)
|
| 267 |
-
|
| 268 |
-
self.send_response(200)
|
| 269 |
-
self.send_header("Content-Type", "text/event-stream; charset=utf-8")
|
| 270 |
-
self.send_header("Cache-Control", "no-cache, no-transform")
|
| 271 |
-
self.send_header("X-Accel-Buffering", "no")
|
| 272 |
-
self.send_header("Transfer-Encoding", "chunked")
|
| 273 |
-
self.end_headers()
|
| 274 |
-
|
| 275 |
-
def chunk(payload: bytes):
|
| 276 |
-
if not payload:
|
| 277 |
-
return
|
| 278 |
-
self.wfile.write(b"%x\r\n" % len(payload) + payload + b"\r\n")
|
| 279 |
-
self.wfile.flush()
|
| 280 |
-
|
| 281 |
-
read = getattr(up, "read1", None) or up.read
|
| 282 |
-
try:
|
| 283 |
-
while True:
|
| 284 |
-
data = read(4096)
|
| 285 |
-
if not data:
|
| 286 |
-
break
|
| 287 |
-
chunk(data)
|
| 288 |
-
except (BrokenPipeError, ConnectionResetError, socket.timeout):
|
| 289 |
-
pass
|
| 290 |
-
finally:
|
| 291 |
-
try:
|
| 292 |
-
self.wfile.write(b"0\r\n\r\n")
|
| 293 |
-
self.wfile.flush()
|
| 294 |
-
except Exception:
|
| 295 |
-
pass
|
| 296 |
-
up.close()
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
class ThreadingServer(http.server.ThreadingHTTPServer):
|
| 300 |
-
daemon_threads = True
|
| 301 |
-
allow_reuse_address = True
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
# --------------------------------------------------------------------------- #
|
| 305 |
-
# front-end assets
|
| 306 |
-
# --------------------------------------------------------------------------- #
|
| 307 |
-
|
| 308 |
-
INDEX_HTML = r"""<!doctype html>
|
| 309 |
-
<html lang="en">
|
| 310 |
-
<head>
|
| 311 |
-
<meta charset="utf-8" />
|
| 312 |
-
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-content" />
|
| 313 |
-
<meta name="theme-color" content="#0b0b0f" />
|
| 314 |
-
<meta name="mobile-web-app-capable" content="yes" />
|
| 315 |
-
<meta name="apple-mobile-web-app-capable" content="yes" />
|
| 316 |
-
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
| 317 |
-
<title>opencode</title>
|
| 318 |
-
<link rel="stylesheet" href="/app.css" />
|
| 319 |
-
</head>
|
| 320 |
-
<body>
|
| 321 |
-
<header id="topbar">
|
| 322 |
-
<button id="btn-menu" class="icon" aria-label="Sessions">☰</button>
|
| 323 |
-
<div id="title-wrap">
|
| 324 |
-
<div id="title">opencode</div>
|
| 325 |
-
<button id="btn-model" class="model-chip" aria-label="Select model">
|
| 326 |
-
<span class="dot"></span><span id="model-name">model</span><span class="caret">▾</span>
|
| 327 |
-
</button>
|
| 328 |
-
</div>
|
| 329 |
-
<button id="btn-new" class="icon" aria-label="New chat">+</button>
|
| 330 |
-
</header>
|
| 331 |
-
|
| 332 |
-
<main id="messages">
|
| 333 |
-
<div id="empty" class="empty">
|
| 334 |
-
<div class="empty-logo">◎</div>
|
| 335 |
-
<div class="empty-title">opencode</div>
|
| 336 |
-
<div class="empty-sub">Ask anything. Pick a model up top.</div>
|
| 337 |
-
</div>
|
| 338 |
-
</main>
|
| 339 |
-
|
| 340 |
-
<form id="composer">
|
| 341 |
-
<textarea id="input" rows="1" placeholder="Message opencode…" enterkeyhint="send"></textarea>
|
| 342 |
-
<button id="btn-send" type="submit" aria-label="Send">➤</button>
|
| 343 |
-
<button id="btn-stop" type="button" aria-label="Stop" hidden>■</button>
|
| 344 |
-
</form>
|
| 345 |
-
|
| 346 |
-
<!-- model sheet -->
|
| 347 |
-
<div id="sheet-model" class="sheet" hidden>
|
| 348 |
-
<div class="sheet-backdrop"></div>
|
| 349 |
-
<div class="sheet-panel">
|
| 350 |
-
<div class="sheet-grab"></div>
|
| 351 |
-
<div class="sheet-head"><span>Model</span><button class="icon" data-close>✕</button></div>
|
| 352 |
-
<input id="model-search" class="search" placeholder="Search models…" />
|
| 353 |
-
<div id="model-list" class="list"></div>
|
| 354 |
-
<div class="manual">
|
| 355 |
-
<input id="manual-model" placeholder="providerID/modelID (manual)" />
|
| 356 |
-
<button id="manual-apply">Set</button>
|
| 357 |
-
</div>
|
| 358 |
-
</div>
|
| 359 |
-
</div>
|
| 360 |
-
|
| 361 |
-
<!-- sessions sheet -->
|
| 362 |
-
<div id="sheet-sessions" class="sheet" hidden>
|
| 363 |
-
<div class="sheet-backdrop"></div>
|
| 364 |
-
<div class="sheet-panel">
|
| 365 |
-
<div class="sheet-grab"></div>
|
| 366 |
-
<div class="sheet-head"><span>Sessions</span><button class="icon" data-close>✕</button></div>
|
| 367 |
-
<div id="session-list" class="list"></div>
|
| 368 |
-
</div>
|
| 369 |
-
</div>
|
| 370 |
-
|
| 371 |
-
<div id="toast" class="toast" hidden></div>
|
| 372 |
-
<script src="/app.js"></script>
|
| 373 |
-
</body>
|
| 374 |
-
</html>
|
| 375 |
-
"""
|
| 376 |
-
|
| 377 |
-
APP_CSS = r""":root{
|
| 378 |
-
--bg:#0b0b0f; --bg2:#141418; --bg3:#1c1c22; --fg:#ececf1; --muted:#8b8b96;
|
| 379 |
-
--line:#26262e; --accent:#c9a86a; --accent2:#7c9cff; --user:#1f2a44; --radius:16px;
|
| 380 |
-
}
|
| 381 |
-
*{box-sizing:border-box;-webkit-tap-highlight-color:transparent}
|
| 382 |
-
html,body{margin:0;height:100%}
|
| 383 |
-
body{
|
| 384 |
-
background:var(--bg);color:var(--fg);
|
| 385 |
-
font:16px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
|
| 386 |
-
display:flex;flex-direction:column;
|
| 387 |
-
height:100dvh;overscroll-behavior:none;
|
| 388 |
-
}
|
| 389 |
-
#topbar{
|
| 390 |
-
display:flex;align-items:center;gap:10px;
|
| 391 |
-
padding:calc(env(safe-area-inset-top) + 8px) 12px 8px;
|
| 392 |
-
background:rgba(11,11,15,.92);backdrop-filter:blur(12px);
|
| 393 |
-
border-bottom:1px solid var(--line);
|
| 394 |
-
position:sticky;top:0;z-index:20;
|
| 395 |
-
}
|
| 396 |
-
#title-wrap{flex:1;min-width:0;display:flex;flex-direction:column;align-items:center;gap:2px}
|
| 397 |
-
#title{font-weight:600;font-size:15px;max-width:100%;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
| 398 |
-
.model-chip{
|
| 399 |
-
display:inline-flex;align-items:center;gap:6px;max-width:100%;
|
| 400 |
-
background:var(--bg3);border:1px solid var(--line);color:var(--muted);
|
| 401 |
-
border-radius:999px;padding:3px 10px;font-size:12px;
|
| 402 |
-
}
|
| 403 |
-
.model-chip .dot{width:6px;height:6px;border-radius:50%;background:var(--accent);flex:none}
|
| 404 |
-
.model-chip #model-name{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
| 405 |
-
.model-chip .caret{font-size:9px;opacity:.7}
|
| 406 |
-
.icon{
|
| 407 |
-
background:transparent;border:0;color:var(--fg);font-size:20px;line-height:1;
|
| 408 |
-
width:40px;height:40px;border-radius:12px;display:flex;align-items:center;justify-content:center;
|
| 409 |
-
}
|
| 410 |
-
.icon:active{background:var(--bg3)}
|
| 411 |
-
|
| 412 |
-
#messages{flex:1;overflow-y:auto;padding:14px 12px 8px;display:flex;flex-direction:column;gap:14px;
|
| 413 |
-
scroll-behavior:smooth;overscroll-behavior:contain}
|
| 414 |
-
.msg{display:flex;max-width:100%}
|
| 415 |
-
.msg.user{justify-content:flex-end}
|
| 416 |
-
.bubble{max-width:86%;border-radius:var(--radius);padding:10px 13px;word-wrap:break-word;overflow-wrap:anywhere}
|
| 417 |
-
.msg.user .bubble{background:var(--user);border:1px solid #2b3a5e;border-bottom-right-radius:6px}
|
| 418 |
-
.msg.assistant .bubble{background:var(--bg2);border:1px solid var(--line);border-bottom-left-radius:6px;max-width:96%;width:100%}
|
| 419 |
-
.part+.part{margin-top:8px}
|
| 420 |
-
.part.text{white-space:pre-wrap}
|
| 421 |
-
.part.text code{background:var(--bg3);padding:1px 5px;border-radius:6px;font-size:.9em}
|
| 422 |
-
.part.text pre{background:#0e0e12;border:1px solid var(--line);border-radius:10px;padding:10px;overflow:auto;font-size:13px;margin:6px 0}
|
| 423 |
-
.part.text pre code{background:none;padding:0}
|
| 424 |
-
details.part{border:1px solid var(--line);border-radius:10px;background:#101014;overflow:hidden}
|
| 425 |
-
details.part>summary{list-style:none;cursor:pointer;padding:8px 10px;display:flex;align-items:center;gap:8px;font-size:13px;color:var(--muted)}
|
| 426 |
-
details.part>summary::-webkit-details-marker{display:none}
|
| 427 |
-
details.part .body{padding:0 10px 10px;font-size:13px;color:#c9c9d2}
|
| 428 |
-
details.part pre{margin:6px 0 0;background:#0b0b0e;border:1px solid var(--line);border-radius:8px;padding:8px;overflow:auto;max-height:320px}
|
| 429 |
-
.status{width:8px;height:8px;border-radius:50%;background:var(--muted);flex:none}
|
| 430 |
-
.status.running{background:var(--accent2);animation:pulse 1s infinite}
|
| 431 |
-
.status.completed{background:#4ec97a}
|
| 432 |
-
.status.error{background:#e06a6a}
|
| 433 |
-
@keyframes pulse{50%{opacity:.35}}
|
| 434 |
-
.tname{color:var(--fg);font-weight:500}
|
| 435 |
-
.err{color:#ff9c9c;font-size:13px;margin-top:6px}
|
| 436 |
-
.reasoning{opacity:.85}
|
| 437 |
-
.cursor{display:inline-block;width:7px;height:15px;background:var(--accent);vertical-align:-2px;animation:blink 1s steps(1) infinite}
|
| 438 |
-
@keyframes blink{50%{opacity:0}}
|
| 439 |
-
|
| 440 |
-
.empty{margin:auto;text-align:center;color:var(--muted);padding:40px 20px}
|
| 441 |
-
.empty-logo{font-size:44px;color:var(--accent);margin-bottom:10px}
|
| 442 |
-
.empty-title{font-size:20px;color:var(--fg);font-weight:600}
|
| 443 |
-
.empty-sub{font-size:13px;margin-top:4px}
|
| 444 |
-
|
| 445 |
-
#composer{
|
| 446 |
-
display:flex;align-items:flex-end;gap:8px;padding:10px 10px calc(env(safe-area-inset-bottom) + 10px);
|
| 447 |
-
background:rgba(11,11,15,.95);border-top:1px solid var(--line);position:sticky;bottom:0;
|
| 448 |
-
}
|
| 449 |
-
#input{
|
| 450 |
-
flex:1;resize:none;max-height:40vh;min-height:44px;
|
| 451 |
-
background:var(--bg3);color:var(--fg);border:1px solid var(--line);border-radius:14px;
|
| 452 |
-
padding:11px 13px;font:16px/1.4 inherit;outline:none;
|
| 453 |
-
}
|
| 454 |
-
#input:focus{border-color:#3a3a46}
|
| 455 |
-
#btn-send,#btn-stop{
|
| 456 |
-
width:46px;height:46px;flex:none;border:0;border-radius:14px;font-size:18px;
|
| 457 |
-
background:var(--accent);color:#1a1406;display:flex;align-items:center;justify-content:center;
|
| 458 |
-
}
|
| 459 |
-
#btn-stop{background:#e06a6a;color:#fff}
|
| 460 |
-
#btn-send:disabled{opacity:.4}
|
| 461 |
-
|
| 462 |
-
.sheet{position:fixed;inset:0;z-index:40;display:flex;flex-direction:column;justify-content:flex-end}
|
| 463 |
-
.sheet[hidden]{display:none}
|
| 464 |
-
.sheet-backdrop{position:absolute;inset:0;background:rgba(0,0,0,.55)}
|
| 465 |
-
.sheet-panel{position:relative;background:var(--bg2);border-top-left-radius:20px;border-top-right-radius:20px;
|
| 466 |
-
border-top:1px solid var(--line);max-height:78dvh;display:flex;flex-direction:column;
|
| 467 |
-
padding-bottom:calc(env(safe-area-inset-bottom) + 8px);animation:up .18s ease}
|
| 468 |
-
@keyframes up{from{transform:translateY(14px);opacity:.6}}
|
| 469 |
-
.sheet-grab{width:38px;height:4px;border-radius:2px;background:#3a3a46;margin:8px auto 0}
|
| 470 |
-
.sheet-head{display:flex;align-items:center;justify-content:space-between;padding:8px 14px 4px;font-weight:600}
|
| 471 |
-
.search{margin:6px 14px;background:var(--bg3);border:1px solid var(--line);border-radius:12px;color:var(--fg);
|
| 472 |
-
padding:10px 12px;font:15px inherit;outline:none}
|
| 473 |
-
.list{overflow-y:auto;padding:4px 8px 8px}
|
| 474 |
-
.group{color:var(--muted);font-size:12px;text-transform:uppercase;letter-spacing:.05em;padding:10px 10px 4px}
|
| 475 |
-
.row{display:flex;align-items:center;gap:10px;padding:11px 10px;border-radius:12px}
|
| 476 |
-
.row:active{background:var(--bg3)}
|
| 477 |
-
.row.sel{background:var(--bg3)}
|
| 478 |
-
.row .r-main{flex:1;min-width:0}
|
| 479 |
-
.row .r-t{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
| 480 |
-
.row .r-s{color:var(--muted);font-size:12px}
|
| 481 |
-
.row .check{color:var(--accent)}
|
| 482 |
-
.manual{display:flex;gap:8px;padding:8px 14px;border-top:1px solid var(--line)}
|
| 483 |
-
.manual input{flex:1;background:var(--bg3);border:1px solid var(--line);border-radius:12px;color:var(--fg);padding:10px 12px;font:14px inherit;outline:none}
|
| 484 |
-
.manual button{background:var(--bg3);border:1px solid var(--line);border-radius:12px;color:var(--fg);padding:0 16px;font:14px inherit}
|
| 485 |
-
.toast{position:fixed;left:50%;transform:translateX(-50%);bottom:calc(env(safe-area-inset-bottom) + 76px);
|
| 486 |
-
background:#26262e;color:#fff;padding:9px 14px;border-radius:999px;font-size:13px;z-index:60;max-width:90%;
|
| 487 |
-
border:1px solid #34343e}
|
| 488 |
-
.toast[hidden]{display:none}
|
| 489 |
-
"""
|
| 490 |
-
|
| 491 |
-
APP_JS = r"""'use strict';
|
| 492 |
-
const $ = (s) => document.querySelector(s);
|
| 493 |
-
const state = {
|
| 494 |
-
sessionID: localStorage.getItem('oc.session') || null,
|
| 495 |
-
model: JSON.parse(localStorage.getItem('oc.model') || 'null'),
|
| 496 |
-
models: [], providers: {},
|
| 497 |
-
messages: [], lastJSON: '', busy: false, streaming: false,
|
| 498 |
-
};
|
| 499 |
-
const el = {
|
| 500 |
-
messages: $('#messages'), input: $('#input'), send: $('#btn-send'), stop: $('#btn-stop'),
|
| 501 |
-
title: $('#title'), modelName: $('#model-name'), modelList: $('#model-list'),
|
| 502 |
-
search: $('#model-search'), sessionList: $('#session-list'), toast: $('#toast'),
|
| 503 |
-
empty: $('#empty'),
|
| 504 |
-
};
|
| 505 |
-
|
| 506 |
-
function toast(msg){ el.toast.textContent = msg; el.toast.hidden = false;
|
| 507 |
-
clearTimeout(toast._t); toast._t = setTimeout(()=>el.toast.hidden=true, 2600); }
|
| 508 |
-
|
| 509 |
-
async function api(path, opts){
|
| 510 |
-
const r = await fetch(path, Object.assign({headers:{'Content-Type':'application/json'}}, opts||{}));
|
| 511 |
-
if(!r.ok){ let t=''; try{t=await r.text();}catch(e){} throw new Error(r.status+' '+t.slice(0,200)); }
|
| 512 |
-
if(r.status===204) return null;
|
| 513 |
-
const ct = r.headers.get('Content-Type')||'';
|
| 514 |
-
return ct.includes('json') ? r.json() : r.text();
|
| 515 |
-
}
|
| 516 |
-
|
| 517 |
-
function esc(s){ return (s||'').replace(/[&<>"']/g, c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); }
|
| 518 |
-
|
| 519 |
-
function md(src){
|
| 520 |
-
let s = esc(src||'');
|
| 521 |
-
const blocks = [];
|
| 522 |
-
s = s.replace(/```([\s\S]*?)```/g, (m,code)=>{ blocks.push('<pre><code>'+code.trim()+'\n</code></pre>'); return '\u0000'+(blocks.length-1)+'\u0000'; });
|
| 523 |
-
s = s.replace(/`([^`\n]+)`/g, '<code>$1</code>');
|
| 524 |
-
s = s.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
|
| 525 |
-
s = s.replace(/(^|\n)(#{1,3})\s*(.+)/g, (m,p,h,t)=>p+'<strong>'+t+'</strong>');
|
| 526 |
-
s = s.replace(/\u0000(\d+)\u0000/g, (m,i)=>blocks[+i]);
|
| 527 |
-
return s;
|
| 528 |
-
}
|
| 529 |
-
|
| 530 |
-
function toolBody(p){
|
| 531 |
-
const st = p.state||{};
|
| 532 |
-
const bits = [];
|
| 533 |
-
if(st.input && Object.keys(st.input).length) bits.push('<pre>'+esc(JSON.stringify(st.input,null,2))+'</pre>');
|
| 534 |
-
if(st.content){
|
| 535 |
-
let txt = '';
|
| 536 |
-
try { txt = typeof st.content==='string' ? st.content : JSON.stringify(st.content,null,2); } catch(e){}
|
| 537 |
-
if(txt) bits.push('<pre>'+esc(txt.slice(0,4000))+'</pre>');
|
| 538 |
-
}
|
| 539 |
-
if(st.error) bits.push('<div class="err">'+esc(typeof st.error==='string'?st.error:JSON.stringify(st.error))+'</div>');
|
| 540 |
-
return bits.join('') || '<div class="r-s">'+(st.status||'')+'</div>';
|
| 541 |
-
}
|
| 542 |
-
|
| 543 |
-
function renderMessage(m){
|
| 544 |
-
const isUser = m.type==='user';
|
| 545 |
-
const wrap = document.createElement('div');
|
| 546 |
-
wrap.className = 'msg '+(isUser?'user':'assistant');
|
| 547 |
-
const bubble = document.createElement('div');
|
| 548 |
-
bubble.className = 'bubble';
|
| 549 |
-
if(isUser){
|
| 550 |
-
bubble.innerHTML = '<div class="part text">'+md(m.text||'')+'</div>';
|
| 551 |
-
} else {
|
| 552 |
-
const parts = Array.isArray(m.content) ? m.content : [];
|
| 553 |
-
for(const p of parts){
|
| 554 |
-
if(p.type==='text'){
|
| 555 |
-
bubble.insertAdjacentHTML('beforeend','<div class="part text">'+md(p.text||'')+'</div>');
|
| 556 |
-
} else if(p.type==='reasoning'){
|
| 557 |
-
bubble.insertAdjacentHTML('beforeend',
|
| 558 |
-
'<details class="part reasoning"><summary>reasoning</summary><div class="body">'+md(p.text||'')+'</div></details>');
|
| 559 |
-
} else if(p.type==='tool'){
|
| 560 |
-
const st=(p.state&&p.state.status)||'';
|
| 561 |
-
bubble.insertAdjacentHTML('beforeend',
|
| 562 |
-
'<details class="part"><summary><span class="status '+esc(st)+'"></span>'+
|
| 563 |
-
'<span class="tname">'+esc(p.name||'tool')+'</span><span>'+esc(st)+'</span></summary>'+
|
| 564 |
-
'<div class="body">'+toolBody(p)+'</div></details>');
|
| 565 |
-
} else {
|
| 566 |
-
bubble.insertAdjacentHTML('beforeend','<div class="part"><pre>'+esc(JSON.stringify(p,null,2).slice(0,2000))+'</pre></div>');
|
| 567 |
-
}
|
| 568 |
-
}
|
| 569 |
-
if(!parts.length && !m.error) bubble.insertAdjacentHTML('beforeend','<div class="part text"><span class="cursor"></span></div>');
|
| 570 |
-
if(m.error) bubble.insertAdjacentHTML('beforeend','<div class="err">'+esc(m.error.message||JSON.stringify(m.error))+'</div>');
|
| 571 |
-
}
|
| 572 |
-
wrap.appendChild(bubble);
|
| 573 |
-
return wrap;
|
| 574 |
-
}
|
| 575 |
-
|
| 576 |
-
function computeBusy(all){
|
| 577 |
-
const msgs = (all||[]).filter(m=>m.type==='user'||m.type==='assistant');
|
| 578 |
-
if(!msgs.length) return false;
|
| 579 |
-
const last = msgs[msgs.length-1];
|
| 580 |
-
if(last.type==='user') return true;
|
| 581 |
-
if(last.type==='assistant'){
|
| 582 |
-
if(!last.time || !last.time.completed) return true;
|
| 583 |
-
const parts = last.content||[];
|
| 584 |
-
if(parts.some(p=>p.type==='tool' && p.state && (p.state.status==='running'||p.state.status==='streaming'))) return true;
|
| 585 |
-
}
|
| 586 |
-
return false;
|
| 587 |
-
}
|
| 588 |
-
|
| 589 |
-
function nearBottom(){ return el.messages.scrollHeight - el.messages.scrollTop - el.messages.clientHeight < 120; }
|
| 590 |
-
|
| 591 |
-
function render(){
|
| 592 |
-
const wasBottom = nearBottom();
|
| 593 |
-
const visible = state.messages.filter(m=>m.type==='user'||m.type==='assistant');
|
| 594 |
-
el.messages.innerHTML = '';
|
| 595 |
-
if(!visible.length){
|
| 596 |
-
el.messages.appendChild(el.empty || (()=>{const d=document.createElement('div');d.className='empty';d.innerHTML='<div class="empty-title">opencode</div><div class="empty-sub">Ask anything.</div>';return d;})());
|
| 597 |
-
} else {
|
| 598 |
-
for(const m of visible) el.messages.appendChild(renderMessage(m));
|
| 599 |
-
}
|
| 600 |
-
if(wasBottom) el.messages.scrollTop = el.messages.scrollHeight;
|
| 601 |
-
}
|
| 602 |
-
|
| 603 |
-
function setBusy(b){
|
| 604 |
-
state.busy = b;
|
| 605 |
-
el.send.hidden = b; el.stop.hidden = !b;
|
| 606 |
-
}
|
| 607 |
-
|
| 608 |
-
async function refreshMessages(){
|
| 609 |
-
if(!state.sessionID) return;
|
| 610 |
-
try{
|
| 611 |
-
const r = await api('/api/session/'+encodeURIComponent(state.sessionID)+'/message?order=asc&limit=200');
|
| 612 |
-
const data = (r && r.data) || [];
|
| 613 |
-
const j = JSON.stringify(data);
|
| 614 |
-
if(j===state.lastJSON) return;
|
| 615 |
-
state.lastJSON = j;
|
| 616 |
-
state.messages = data;
|
| 617 |
-
render();
|
| 618 |
-
setBusy(computeBusy(data));
|
| 619 |
-
}catch(e){ /* ignore transient */ }
|
| 620 |
-
}
|
| 621 |
-
|
| 622 |
-
async function refreshSessions(){
|
| 623 |
-
try{
|
| 624 |
-
const r = await api('/api/session?limit=60');
|
| 625 |
-
const data = (r && r.data) || [];
|
| 626 |
-
el.sessionList.innerHTML = '';
|
| 627 |
-
if(!data.length){ el.sessionList.innerHTML = '<div class="row r-s">No sessions yet</div>'; return; }
|
| 628 |
-
for(const s of data){
|
| 629 |
-
const row = document.createElement('div');
|
| 630 |
-
row.className = 'row'+(s.id===state.sessionID?' sel':'');
|
| 631 |
-
row.innerHTML = '<div class="r-main"><div class="r-t">'+esc(s.title||s.id)+'</div>'+
|
| 632 |
-
'<div class="r-s">'+esc((s.model? (s.model.providerID+'/'+s.model.id):'') )+'</div></div>'+
|
| 633 |
-
(s.id===state.sessionID?'<span class="check">\u2713</span>':'');
|
| 634 |
-
row.onclick = ()=>selectSession(s.id);
|
| 635 |
-
el.sessionList.appendChild(row);
|
| 636 |
-
}
|
| 637 |
-
}catch(e){ toast('sessions: '+e.message); }
|
| 638 |
-
}
|
| 639 |
-
|
| 640 |
-
function renderModels(){
|
| 641 |
-
const q = (el.search.value||'').toLowerCase();
|
| 642 |
-
el.modelList.innerHTML = '';
|
| 643 |
-
const models = state.models.filter(m=>{
|
| 644 |
-
const hay = ((m.providerID||'')+'/'+(m.id||'')+' '+(m.name||'')).toLowerCase();
|
| 645 |
-
return !q || hay.includes(q);
|
| 646 |
-
});
|
| 647 |
-
if(!state.models.length){
|
| 648 |
-
el.modelList.innerHTML = '<div class="group">no models</div>'+
|
| 649 |
-
'<div class="row r-s">No models available. Add a provider API key as a Space secret '+
|
| 650 |
-
'(e.g. ANTHROPIC_API_KEY), then reload.</div>';
|
| 651 |
-
return;
|
| 652 |
-
}
|
| 653 |
-
const byProv = {};
|
| 654 |
-
for(const m of models){ (byProv[m.providerID]=byProv[m.providerID]||[]).push(m); }
|
| 655 |
-
const cur = state.model;
|
| 656 |
-
for(const pid of Object.keys(byProv).sort()){
|
| 657 |
-
const g = document.createElement('div'); g.className='group';
|
| 658 |
-
g.textContent = (state.providers[pid] && state.providers[pid].name) || pid;
|
| 659 |
-
el.modelList.appendChild(g);
|
| 660 |
-
for(const m of byProv[pid]){
|
| 661 |
-
const sel = cur && cur.providerID===m.providerID && cur.id===m.id;
|
| 662 |
-
const row = document.createElement('div'); row.className='row'+(sel?' sel':'');
|
| 663 |
-
row.innerHTML = '<div class="r-main"><div class="r-t">'+esc(m.name||m.id)+'</div>'+
|
| 664 |
-
'<div class="r-s">'+esc(m.providerID+'/'+m.id)+'</div></div>'+(sel?'<span class="check">\u2713</span>':'');
|
| 665 |
-
row.onclick = ()=>{ setModel({providerID:m.providerID, id:m.id, name:m.name||m.id}); closeSheets(); };
|
| 666 |
-
el.modelList.appendChild(row);
|
| 667 |
-
}
|
| 668 |
-
}
|
| 669 |
-
}
|
| 670 |
-
|
| 671 |
-
async function loadModels(){
|
| 672 |
-
try{
|
| 673 |
-
const [models, providers] = await Promise.all([ api('/api/model'), api('/api/provider') ]);
|
| 674 |
-
state.models = (models && models.data) || [];
|
| 675 |
-
state.providers = {};
|
| 676 |
-
for(const p of ((providers && providers.data)||[])) state.providers[p.id]=p;
|
| 677 |
-
renderModels();
|
| 678 |
-
}catch(e){ /* non fatal */ }
|
| 679 |
-
}
|
| 680 |
-
|
| 681 |
-
async function loadModelsUntilReady(){
|
| 682 |
-
// /api/model can be empty right after boot until the plugin/provider catalog settles.
|
| 683 |
-
for(let i=0;i<20;i++){
|
| 684 |
-
await loadModels();
|
| 685 |
-
if(state.models.length) return;
|
| 686 |
-
await new Promise(r=>setTimeout(r,2000));
|
| 687 |
-
}
|
| 688 |
-
}
|
| 689 |
-
|
| 690 |
-
async function setModel(m){
|
| 691 |
-
state.model = {providerID:m.providerID, id:m.id, name:m.name||m.id};
|
| 692 |
-
localStorage.setItem('oc.model', JSON.stringify(state.model));
|
| 693 |
-
el.modelName.textContent = state.model.name;
|
| 694 |
-
renderModels();
|
| 695 |
-
if(state.sessionID){
|
| 696 |
-
try{ await api('/api/session/'+encodeURIComponent(state.sessionID)+'/model',
|
| 697 |
-
{method:'POST', body: JSON.stringify({model:{providerID:m.providerID, id:m.id}})}); }
|
| 698 |
-
catch(e){ toast('model: '+e.message); }
|
| 699 |
-
}
|
| 700 |
-
}
|
| 701 |
-
|
| 702 |
-
async function ensureSession(){
|
| 703 |
-
if(state.sessionID) return state.sessionID;
|
| 704 |
-
const body = {};
|
| 705 |
-
if(state.model) body.model = {providerID:state.model.providerID, id:state.model.id};
|
| 706 |
-
const r = await api('/api/session', {method:'POST', body: JSON.stringify(body)});
|
| 707 |
-
state.sessionID = r.data.id;
|
| 708 |
-
localStorage.setItem('oc.session', state.sessionID);
|
| 709 |
-
updateTitle(r.data.title||'');
|
| 710 |
-
return state.sessionID;
|
| 711 |
-
}
|
| 712 |
-
|
| 713 |
-
async function selectSession(id){
|
| 714 |
-
state.sessionID = id; localStorage.setItem('oc.session', id);
|
| 715 |
-
state.messages=[]; state.lastJSON='';
|
| 716 |
-
closeSheets();
|
| 717 |
-
try{ const r = await api('/api/session/'+encodeURIComponent(id)); updateTitle(r.data.title||''); }catch(e){}
|
| 718 |
-
await refreshMessages();
|
| 719 |
-
refreshSessions();
|
| 720 |
-
}
|
| 721 |
-
|
| 722 |
-
function updateTitle(t){
|
| 723 |
-
el.title.textContent = t || 'opencode';
|
| 724 |
-
document.title = t || 'opencode';
|
| 725 |
-
}
|
| 726 |
-
|
| 727 |
-
async function send(){
|
| 728 |
-
const text = el.input.value.trim();
|
| 729 |
-
if(!text || state.busy) return;
|
| 730 |
-
el.input.value=''; autosize();
|
| 731 |
-
try{
|
| 732 |
-
const id = await ensureSession();
|
| 733 |
-
state.lastJSON='';
|
| 734 |
-
setBusy(true);
|
| 735 |
-
await api('/api/session/'+encodeURIComponent(id)+'/prompt', {method:'POST', body: JSON.stringify({prompt:{text}})});
|
| 736 |
-
refreshMessages();
|
| 737 |
-
}catch(e){ setBusy(false); toast('send: '+e.message); }
|
| 738 |
-
}
|
| 739 |
-
|
| 740 |
-
async function stop(){
|
| 741 |
-
if(!state.sessionID) return;
|
| 742 |
-
try{ await api('/api/session/'+encodeURIComponent(state.sessionID)+'/interrupt', {method:'POST', body:'{}'}); }
|
| 743 |
-
catch(e){ toast('stop: '+e.message); }
|
| 744 |
-
}
|
| 745 |
-
|
| 746 |
-
async function newChat(){
|
| 747 |
-
state.sessionID=null; state.messages=[]; state.lastJSON=''; localStorage.removeItem('oc.session');
|
| 748 |
-
updateTitle(''); closeSheets(); render(); setBusy(false); loadModels();
|
| 749 |
-
el.input.focus();
|
| 750 |
-
}
|
| 751 |
-
|
| 752 |
-
function autosize(){ el.input.style.height='auto'; el.input.style.height=Math.min(el.input.scrollHeight, window.innerHeight*0.4)+'px'; }
|
| 753 |
-
|
| 754 |
-
function openSheet(sel){ document.querySelector(sel).hidden=false; }
|
| 755 |
-
function closeSheets(){ document.querySelectorAll('.sheet').forEach(s=>s.hidden=true); }
|
| 756 |
-
|
| 757 |
-
// ---- SSE ----
|
| 758 |
-
let refreshTimer=null, modelsTimer=null;
|
| 759 |
-
function scheduleRefresh(){ if(refreshTimer) return; refreshTimer=setTimeout(()=>{refreshTimer=null; refreshMessages();},250); }
|
| 760 |
-
function scheduleModels(){ if(modelsTimer) return; modelsTimer=setTimeout(()=>{modelsTimer=null; loadModels();},1200); }
|
| 761 |
-
function onEvent(type){
|
| 762 |
-
if(!type) return;
|
| 763 |
-
if(type.indexOf('session.')===0) scheduleRefresh();
|
| 764 |
-
if(type==='model.updated'||type==='catalog.updated'||type==='provider.updated'||
|
| 765 |
-
type==='plugin.updated'||type==='models-dev.refreshed') scheduleModels();
|
| 766 |
-
}
|
| 767 |
-
|
| 768 |
-
async function startStream(){
|
| 769 |
-
for(;;){
|
| 770 |
-
try{
|
| 771 |
-
const r = await fetch('/api/event', {headers:{'Accept':'text/event-stream'}});
|
| 772 |
-
if(!r.ok || !r.body) throw new Error('sse '+r.status);
|
| 773 |
-
const reader = r.body.getReader(); const dec=new TextDecoder();
|
| 774 |
-
let buf='';
|
| 775 |
-
for(;;){
|
| 776 |
-
const {done,value} = await reader.read();
|
| 777 |
-
if(done) break;
|
| 778 |
-
if(!value) continue;
|
| 779 |
-
buf += dec.decode(value,{stream:true});
|
| 780 |
-
let idx;
|
| 781 |
-
while((idx=buf.indexOf('\n'))>=0){
|
| 782 |
-
const line = buf.slice(0,idx).trim(); buf = buf.slice(idx+1);
|
| 783 |
-
if(line.slice(0,5)==='data:'){
|
| 784 |
-
const payload = line.slice(5).trim();
|
| 785 |
-
if(payload){ try{ onEvent(JSON.parse(payload).type); }catch(e){} }
|
| 786 |
-
}
|
| 787 |
-
}
|
| 788 |
-
if(buf.length>1<<16) buf='';
|
| 789 |
-
}
|
| 790 |
-
}catch(e){ /* reconnect */ }
|
| 791 |
-
await new Promise(res=>setTimeout(res, 1500));
|
| 792 |
-
}
|
| 793 |
-
}
|
| 794 |
-
|
| 795 |
-
// ---- wire up ----
|
| 796 |
-
$('#btn-send').onclick = (e)=>{ e.preventDefault(); send(); };
|
| 797 |
-
$('#composer').onsubmit = (e)=>{ e.preventDefault(); send(); };
|
| 798 |
-
$('#btn-stop').onclick = stop;
|
| 799 |
-
$('#btn-new').onclick = newChat;
|
| 800 |
-
$('#btn-menu').onclick = ()=>{ refreshSessions(); openSheet('#sheet-sessions'); };
|
| 801 |
-
$('#btn-model').onclick = ()=>{ loadModels(); openSheet('#sheet-model'); };
|
| 802 |
-
el.search.oninput = renderModels;
|
| 803 |
-
$('#manual-apply').onclick = ()=>{
|
| 804 |
-
const v = $('#manual-model').value.trim();
|
| 805 |
-
if(!v.includes('/')) return toast('use providerID/modelID');
|
| 806 |
-
const [pid, mid] = v.split('/');
|
| 807 |
-
setModel({providerID:pid, id:mid, name:v}); closeSheets();
|
| 808 |
-
};
|
| 809 |
-
document.querySelectorAll('[data-close]').forEach(b=>b.onclick=closeSheets);
|
| 810 |
-
document.querySelectorAll('.sheet-backdrop').forEach(b=>b.onclick=closeSheets);
|
| 811 |
-
el.input.addEventListener('input', autosize);
|
| 812 |
-
el.input.addEventListener('keydown', (e)=>{
|
| 813 |
-
if(e.key==='Enter' && !e.shiftKey && !e.isComposing){ e.preventDefault(); send(); }
|
| 814 |
-
});
|
| 815 |
-
|
| 816 |
-
(async function init(){
|
| 817 |
-
if(state.model) el.modelName.textContent = state.model.name;
|
| 818 |
-
await loadModelsUntilReady();
|
| 819 |
-
if(!state.model && state.models.length){ const m=state.models[0]; await setModel({providerID:m.providerID,id:m.id,name:m.name||m.id}); }
|
| 820 |
-
if(!state.model){ el.modelName.textContent = 'pick model'; }
|
| 821 |
-
if(state.sessionID){
|
| 822 |
-
try{ const r=await api('/api/session/'+encodeURIComponent(state.sessionID)); updateTitle(r.data.title||''); }
|
| 823 |
-
catch(e){ state.sessionID=null; localStorage.removeItem('oc.session'); }
|
| 824 |
-
}
|
| 825 |
-
await refreshMessages();
|
| 826 |
-
render();
|
| 827 |
-
setInterval(refreshMessages, 1500);
|
| 828 |
-
startStream();
|
| 829 |
-
})();
|
| 830 |
-
"""
|
| 831 |
-
|
| 832 |
-
|
| 833 |
-
# --------------------------------------------------------------------------- #
|
| 834 |
-
# main
|
| 835 |
-
# --------------------------------------------------------------------------- #
|
| 836 |
-
|
| 837 |
-
def main():
|
| 838 |
-
backend = start_backend()
|
| 839 |
-
|
| 840 |
-
server = ThreadingServer((PUBLIC_HOST, PUBLIC_PORT), Handler)
|
| 841 |
-
server.timeout = None
|
| 842 |
-
|
| 843 |
-
stopping = threading.Event()
|
| 844 |
-
|
| 845 |
-
def shutdown(signum, _frame):
|
| 846 |
-
if stopping.is_set():
|
| 847 |
-
return
|
| 848 |
-
stopping.set()
|
| 849 |
-
log("signal", signum, "- shutting down")
|
| 850 |
-
try:
|
| 851 |
-
backend.terminate()
|
| 852 |
-
backend.wait(timeout=8)
|
| 853 |
-
except Exception:
|
| 854 |
-
try:
|
| 855 |
-
backend.kill()
|
| 856 |
-
except Exception:
|
| 857 |
-
pass
|
| 858 |
-
threading.Thread(target=server.shutdown, daemon=True).start()
|
| 859 |
-
|
| 860 |
-
signal.signal(signal.SIGTERM, shutdown)
|
| 861 |
-
signal.signal(signal.SIGINT, shutdown)
|
| 862 |
-
|
| 863 |
-
log(f"UI listening on http://{PUBLIC_HOST}:{PUBLIC_PORT}"
|
| 864 |
-
+ (" (password protected)" if APP_PASSWORD else ""))
|
| 865 |
-
try:
|
| 866 |
-
server.serve_forever(poll_interval=0.5)
|
| 867 |
-
finally:
|
| 868 |
-
shutdown(0, None)
|
| 869 |
-
|
| 870 |
-
|
| 871 |
-
if __name__ == "__main__":
|
| 872 |
-
main()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|