Kgshop commited on
Commit
a010620
·
verified ·
1 Parent(s): 071d80b

Delete app.py

Browse files
Files changed (1) hide show
  1. app.py +0 -872
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">&#9776;</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">&#9662;</span>
327
- </button>
328
- </div>
329
- <button id="btn-new" class="icon" aria-label="New chat">&#43;</button>
330
- </header>
331
-
332
- <main id="messages">
333
- <div id="empty" class="empty">
334
- <div class="empty-logo">&#9678;</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&#8230;" enterkeyhint="send"></textarea>
342
- <button id="btn-send" type="submit" aria-label="Send">&#10148;</button>
343
- <button id="btn-stop" type="button" aria-label="Stop" hidden>&#9632;</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>&#10005;</button></div>
352
- <input id="model-search" class="search" placeholder="Search models&#8230;" />
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>&#10005;</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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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()