cybercode-cli 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,583 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ webui_codex — a Codex-dark–styled web frontend with a self-contained agent.
5
+
6
+ Standalone: no GenericAgent dependency. The agent core (agent_core.py) is
7
+ bundled. Only Python stdlib + `requests` are needed.
8
+
9
+ Usage:
10
+ python webui_codex.py # http://127.0.0.1:18600
11
+ python webui_codex.py --port 9000 --host 0.0.0.0
12
+
13
+ API:
14
+ GET / -> the web UI
15
+ GET /api/status -> {running, configured, llm_no, llm_name, ...}
16
+ GET /api/sessions -> past conversation logs
17
+ GET /api/skills -> memory/SOP files
18
+ GET /api/messages?path=... -> [{role, content}] replay of one session
19
+ GET /api/hyperframes -> bundled HyperFrames skill list + preamble
20
+ GET /api/hyperframes/<slug> -> raw skill markdown
21
+ GET /api/videos -> rendered .mp4 files
22
+ GET /api/video/<relpath> -> stream a video file (supports Range)
23
+ POST /api/chat {text} -> SSE stream: delta / done / error
24
+ POST /api/chat {text, video:true} -> same, with HyperFrames preamble
25
+ POST /api/llm {idx} -> switch LLM
26
+ POST /api/stop -> abort current task
27
+ POST /api/new -> start a fresh conversation
28
+ POST /api/continue {idx} -> restore session N
29
+ """
30
+ import argparse
31
+ import glob
32
+ import json
33
+ import os
34
+ import queue as Q
35
+ import re
36
+ import sys
37
+ import threading
38
+ import time
39
+ import traceback
40
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
41
+ from urllib.parse import urlparse, parse_qs, unquote
42
+
43
+ HERE = os.path.dirname(os.path.abspath(__file__))
44
+ if HERE not in sys.path:
45
+ sys.path.insert(0, HERE)
46
+
47
+ from agent_core import (
48
+ Agent, extract_files, strip_files, clean_reply, format_error,
49
+ smart_format, TEMP_DIR, ROOT_DIR,
50
+ )
51
+
52
+ HTML_PATH = os.path.join(HERE, "webui_codex.html")
53
+ SKILLS_DIR = os.path.join(HERE, "skills")
54
+ MEMORY_DIR = os.path.join(HERE, "memory")
55
+ DEFAULT_HOST = "127.0.0.1"
56
+ DEFAULT_PORT = 18600
57
+
58
+ FILE_HINT = "If you need to show files to user, use [FILE:filepath] in your response."
59
+
60
+ # ---------------------------------------------------------------------------
61
+ # HyperFrames skill pack
62
+ # ---------------------------------------------------------------------------
63
+ HF_SKILL_ORDER = [
64
+ "hyperframes", "hyperframes-core", "hyperframes-cli",
65
+ "hyperframes-animation", "hyperframes-creative", "hyperframes-media",
66
+ "hyperframes-registry", "general-video", "product-launch-video",
67
+ "website-to-video", "faceless-explainer", "motion-graphics",
68
+ ]
69
+
70
+ HF_PREAMBLE = """You have the HyperFrames video skill pack bundled locally.
71
+ HyperFrames renders video from HTML compositions and the `npx hyperframes` CLI.
72
+
73
+ Before writing any video code, read these bundled skill files (use file_read):
74
+ {skills_path}/hyperframes.md — entry point + intent router
75
+ {skills_path}/hyperframes-core.md — the composition contract (data-* attrs, determinism)
76
+ {skills_path}/hyperframes-cli.md — init / lint / validate / preview / render workflow
77
+ {skills_path}/hyperframes-animation.md — motion rules + runtime adapters
78
+ {skills_path}/hyperframes-creative.md — design direction, palettes, narration
79
+ {skills_path}/hyperframes-media.md — TTS, BGM, SFX, captions
80
+
81
+ The standard workflow is:
82
+ 1. npx hyperframes init <project-name> (scaffolds the composition)
83
+ 2. Author the HTML composition per hyperframes-core contract
84
+ 3. npx hyperframes lint && npx hyperframes validate && npx hyperframes inspect
85
+ 4. npx hyperframes preview (ask user before rendering)
86
+ 5. npx hyperframes render --output out.mp4 (after user approves)
87
+
88
+ Rendered .mp4 files appear in the UI's video gallery automatically. Use [FILE:path]
89
+ to reference the output so the user can download it.
90
+ """
91
+
92
+ # ---------------------------------------------------------------------------
93
+ # Agent singleton
94
+ # ---------------------------------------------------------------------------
95
+ _agent = None
96
+ _agent_lock = threading.Lock()
97
+
98
+
99
+ def get_agent():
100
+ global _agent
101
+ with _agent_lock:
102
+ if _agent is None:
103
+ _agent = Agent()
104
+ if _agent.llmclient is None:
105
+ print("[webui_codex] WARNING: no LLM configured — create mykey.py or mykey.json")
106
+ _agent.inc_out = True
107
+ threading.Thread(target=_agent.run, daemon=True).start()
108
+ return _agent
109
+
110
+
111
+ # ---------------------------------------------------------------------------
112
+ # Helpers
113
+ # ---------------------------------------------------------------------------
114
+ def _clean_for_ui(raw):
115
+ r"""Light touch-up: drop <thinking>/<file_content> blocks, keep <summary>,
116
+ tool headers, [FILE:] refs, and turn markers for the frontend to decorate."""
117
+ s = re.sub(r"<thinking>[\s\S]*?</thinking>", "", raw or "")
118
+ s = re.sub(r"<file_content>[\s\S]*?</file_content>", "", s)
119
+ s = re.sub(r"\n{3,}", "\n\n", s)
120
+ return s.strip()
121
+
122
+
123
+ def _hf_skills_list():
124
+ out = []
125
+ if not os.path.isdir(SKILLS_DIR):
126
+ return out
127
+ for slug in HF_SKILL_ORDER:
128
+ fpath = os.path.join(SKILLS_DIR, slug + ".md")
129
+ if not os.path.isfile(fpath):
130
+ continue
131
+ st = os.stat(fpath)
132
+ out.append({"slug": slug, "path": fpath, "size": st.st_size, "mtime": int(st.st_mtime)})
133
+ for name in sorted(os.listdir(SKILLS_DIR)):
134
+ slug = name[:-3] if name.endswith(".md") else None
135
+ if slug and slug not in HF_SKILL_ORDER and os.path.isfile(os.path.join(SKILLS_DIR, name)):
136
+ st = os.stat(os.path.join(SKILLS_DIR, name))
137
+ out.append({"slug": slug, "path": os.path.join(SKILLS_DIR, name),
138
+ "size": st.st_size, "mtime": int(st.st_mtime)})
139
+ return out
140
+
141
+
142
+ def _hf_preamble():
143
+ return HF_PREAMBLE.format(skills_path=SKILLS_DIR)
144
+
145
+
146
+ def _skills_list():
147
+ out = []
148
+ if not os.path.isdir(MEMORY_DIR):
149
+ return out
150
+ for name in sorted(os.listdir(MEMORY_DIR)):
151
+ full = os.path.join(MEMORY_DIR, name)
152
+ try:
153
+ st = os.stat(full)
154
+ except OSError:
155
+ continue
156
+ if os.path.isdir(full):
157
+ out.append({"name": name + "/", "path": full, "is_dir": True, "mtime": int(st.st_mtime)})
158
+ elif name.lower().endswith((".md", ".txt", ".py")):
159
+ out.append({"name": name, "path": full, "is_dir": False,
160
+ "mtime": int(st.st_mtime), "size": st.st_size})
161
+ return out
162
+
163
+
164
+ def _sessions_list():
165
+ """Scan temp/model_responses/ for conversation logs."""
166
+ log_dir = os.path.join(TEMP_DIR, "model_responses")
167
+ out = []
168
+ if not os.path.isdir(log_dir):
169
+ return out
170
+ for fpath in sorted(glob.glob(os.path.join(log_dir, "model_responses_*.txt")), reverse=True):
171
+ try:
172
+ st = os.stat(fpath)
173
+ except OSError:
174
+ continue
175
+ name = os.path.basename(fpath)
176
+ # Try to extract a preview from the first USER prompt
177
+ preview = ""
178
+ try:
179
+ with open(fpath, "r", encoding="utf-8", errors="replace") as f:
180
+ content = f.read(2000)
181
+ m = re.search(r"=== USER ===\n(.+?)(?==== |\Z)", content, re.DOTALL)
182
+ if m:
183
+ preview = m.group(1).strip().replace("\n", " ")[:80]
184
+ else:
185
+ m2 = re.search(r"\[USER\]:\s*(.+)", content)
186
+ if m2:
187
+ preview = m2.group(1).strip()[:80]
188
+ except Exception:
189
+ pass
190
+ rounds = content.count("[USER]:") if content else 0
191
+ out.append({
192
+ "path": fpath, "name": name, "mtime": int(st.st_mtime),
193
+ "preview": preview, "rounds": rounds,
194
+ "current": fpath == (get_agent().log_path or ""),
195
+ })
196
+ return out
197
+
198
+
199
+ def _extract_messages(log_path):
200
+ """Extract conversation turns from a model_responses log file."""
201
+ if not log_path or not os.path.isfile(log_path):
202
+ return []
203
+ try:
204
+ with open(log_path, "r", encoding="utf-8", errors="replace") as f:
205
+ content = f.read()
206
+ except Exception:
207
+ return []
208
+ msgs = []
209
+ # Try native format: === USER === / === ASSISTANT === blocks
210
+ blocks = re.findall(r"=== (?:USER|ASSISTANT) ===\n(.+?)(?==== (?:USER|ASSISTANT) ===|\Z)", content, re.DOTALL)
211
+ if blocks:
212
+ for i, block in enumerate(blocks):
213
+ role = "user" if i % 2 == 0 else "assistant"
214
+ msgs.append({"role": role, "content": block.strip()[:5000]})
215
+ else:
216
+ # Try [USER]: / [Agent] format
217
+ for line in content.split("\n"):
218
+ line = line.strip()
219
+ if line.startswith("[USER]:"):
220
+ msgs.append({"role": "user", "content": line[7:].strip()})
221
+ elif line.startswith("[Agent]"):
222
+ msgs.append({"role": "assistant", "content": line[7:].strip()})
223
+ return msgs
224
+
225
+
226
+ def _videos_list():
227
+ """Scan for rendered .mp4 files."""
228
+ out = []
229
+ search_dirs = [HERE, TEMP_DIR, os.path.join(HERE, "output"), ROOT_DIR]
230
+ seen = set()
231
+ for d in search_dirs:
232
+ if not os.path.isdir(d):
233
+ continue
234
+ for fpath in glob.glob(os.path.join(d, "**", "*.mp4"), recursive=True):
235
+ try:
236
+ rp = os.path.relpath(fpath, HERE)
237
+ except ValueError:
238
+ rp = fpath
239
+ if rp in seen:
240
+ continue
241
+ seen.add(rp)
242
+ try:
243
+ st = os.stat(fpath)
244
+ except OSError:
245
+ continue
246
+ if st.st_size < 1024:
247
+ continue
248
+ out.append({"name": os.path.basename(fpath), "path": rp, "abspath": fpath,
249
+ "size": st.st_size, "mtime": int(st.st_mtime)})
250
+ out.sort(key=lambda v: v["mtime"], reverse=True)
251
+ return out
252
+
253
+
254
+ # ---------------------------------------------------------------------------
255
+ # HTTP handler
256
+ # ---------------------------------------------------------------------------
257
+ class Handler(BaseHTTPRequestHandler):
258
+ server_version = "webui_codex/2.0"
259
+ protocol_version = "HTTP/1.1"
260
+
261
+ def _send_json(self, obj, code=200):
262
+ body = json.dumps(obj, ensure_ascii=False).encode("utf-8")
263
+ self.send_response(code)
264
+ self.send_header("Content-Type", "application/json; charset=utf-8")
265
+ self.send_header("Content-Length", str(len(body)))
266
+ self.send_header("Cache-Control", "no-store")
267
+ self.end_headers()
268
+ try:
269
+ self.wfile.write(body)
270
+ except (BrokenPipeError, ConnectionResetError):
271
+ pass
272
+
273
+ def _read_json(self):
274
+ length = int(self.headers.get("Content-Length") or 0)
275
+ if length <= 0:
276
+ return {}
277
+ raw = self.rfile.read(length)
278
+ try:
279
+ return json.loads(raw.decode("utf-8") or "{}")
280
+ except Exception:
281
+ return {}
282
+
283
+ def log_message(self, *args):
284
+ pass
285
+
286
+ # ---- routing ----
287
+ def do_GET(self):
288
+ url = urlparse(self.path)
289
+ path, qs = url.path, parse_qs(url.query)
290
+ try:
291
+ if path in ("", "/"):
292
+ return self._serve_html()
293
+ if path == "/api/status":
294
+ return self._api_status()
295
+ if path == "/api/sessions":
296
+ return self._send_json({"items": _sessions_list()})
297
+ if path == "/api/skills":
298
+ return self._send_json({"items": _skills_list()})
299
+ if path == "/api/messages":
300
+ return self._send_json({"items": _extract_messages((qs.get("path") or [""])[0])})
301
+ if path == "/api/hyperframes":
302
+ return self._send_json({"items": _hf_skills_list(), "preamble": _hf_preamble()})
303
+ if path.startswith("/api/hyperframes/"):
304
+ return self._api_hf_skill(unquote(path[len("/api/hyperframes/"):]))
305
+ if path == "/api/videos":
306
+ return self._send_json({"items": _videos_list()})
307
+ if path.startswith("/api/video/"):
308
+ return self._serve_video(unquote(path[len("/api/video/"):]))
309
+ self._send_json({"error": "not found"}, 404)
310
+ except Exception as e:
311
+ traceback.print_exc()
312
+ self._send_json({"error": str(e)}, 500)
313
+
314
+ def do_POST(self):
315
+ path = urlparse(self.path).path
316
+ try:
317
+ if path == "/api/chat":
318
+ return self._api_chat()
319
+ if path == "/api/llm":
320
+ return self._api_llm()
321
+ if path == "/api/stop":
322
+ get_agent().abort()
323
+ return self._send_json({"ok": True})
324
+ if path == "/api/new":
325
+ agent = get_agent()
326
+ agent.history = []
327
+ if agent.handler:
328
+ agent.handler.working = {}
329
+ return self._send_json({"ok": True, "message": "New conversation started."})
330
+ if path == "/api/continue":
331
+ return self._api_continue()
332
+ self._send_json({"error": "not found"}, 404)
333
+ except Exception as e:
334
+ traceback.print_exc()
335
+ self._send_json({"error": str(e)}, 500)
336
+
337
+ # ---- GET handlers ----
338
+ def _serve_html(self):
339
+ try:
340
+ with open(HTML_PATH, "rb") as f:
341
+ body = f.read()
342
+ except OSError:
343
+ body = b'<!doctype html><title>webui_codex</title><pre>webui_codex.html not found</pre>'
344
+ self.send_response(200)
345
+ self.send_header("Content-Type", "text/html; charset=utf-8")
346
+ self.send_header("Content-Length", str(len(body)))
347
+ self.send_header("Cache-Control", "no-store")
348
+ self.end_headers()
349
+ try:
350
+ self.wfile.write(body)
351
+ except (BrokenPipeError, ConnectionResetError):
352
+ pass
353
+
354
+ def _api_status(self):
355
+ agent = get_agent()
356
+ llms = agent.list_llms()
357
+ name = agent.get_llm_name()
358
+ configured = agent.llmclient is not None and agent.is_configured()
359
+ self._send_json({
360
+ "running": bool(agent.is_running),
361
+ "configured": configured,
362
+ "llm_no": agent.llm_no,
363
+ "llm_name": name,
364
+ "llms": [{"idx": i, "name": n, "active": a} for i, n, a in llms],
365
+ "history": (agent.history or [])[-40:],
366
+ "log": os.path.basename(agent.log_path or ""),
367
+ })
368
+
369
+ def _api_hf_skill(self, slug):
370
+ slug = os.path.basename(slug)
371
+ fpath = os.path.join(SKILLS_DIR, slug + ".md")
372
+ if not os.path.isfile(fpath):
373
+ return self._send_json({"error": "skill not found"}, 404)
374
+ try:
375
+ with open(fpath, "r", encoding="utf-8") as f:
376
+ content = f.read()
377
+ except Exception as e:
378
+ return self._send_json({"error": str(e)}, 500)
379
+ self._send_json({"slug": slug, "content": content})
380
+
381
+ def _serve_video(self, relpath):
382
+ # Resolve safely (no path traversal)
383
+ candidates = [
384
+ os.path.join(HERE, relpath),
385
+ os.path.join(TEMP_DIR, relpath),
386
+ os.path.join(ROOT_DIR, relpath),
387
+ ]
388
+ fpath = next((c for c in candidates if os.path.isfile(c)), None)
389
+ if not fpath:
390
+ return self._send_json({"error": "video not found"}, 404)
391
+ try:
392
+ fsize = os.path.getsize(fpath)
393
+ except OSError:
394
+ return self._send_json({"error": "stat failed"}, 500)
395
+ range_header = self.headers.get("Range")
396
+ start = 0
397
+ end_req = None
398
+ if range_header and range_header.startswith("bytes="):
399
+ try:
400
+ parts = range_header[6:].split("-")
401
+ start = int(parts[0]) if parts[0] else 0
402
+ if len(parts) > 1 and parts[1]:
403
+ end_req = int(parts[1]) # inclusive end
404
+ except ValueError:
405
+ start = 0
406
+ # Honor client's end, otherwise cap at 1MB chunks
407
+ end = min(end_req + 1 if end_req is not None else start + 1024 * 1024, fsize)
408
+ if start >= fsize:
409
+ self.send_response(416)
410
+ self.end_headers()
411
+ return
412
+ self.send_response(206 if range_header else 200)
413
+ self.send_header("Content-Type", "video/mp4")
414
+ self.send_header("Content-Length", str(end - start))
415
+ self.send_header("Accept-Ranges", "bytes")
416
+ if range_header:
417
+ self.send_header("Content-Range", f"bytes {start}-{end-1}/{fsize}")
418
+ self.end_headers()
419
+ try:
420
+ with open(fpath, "rb") as f:
421
+ f.seek(start)
422
+ remaining = end - start
423
+ while remaining > 0:
424
+ chunk = f.read(min(65536, remaining))
425
+ if not chunk:
426
+ break
427
+ self.wfile.write(chunk)
428
+ remaining -= len(chunk)
429
+ except (BrokenPipeError, ConnectionResetError):
430
+ pass
431
+
432
+ # ---- POST handlers ----
433
+ def _api_llm(self):
434
+ data = self._read_json()
435
+ idx = data.get("idx")
436
+ agent = get_agent()
437
+ if idx is None:
438
+ return self._send_json({"error": "missing idx"})
439
+ try:
440
+ agent.next_llm(int(idx))
441
+ except Exception as e:
442
+ return self._send_json({"error": str(e)})
443
+ llms = agent.list_llms()
444
+ self._send_json({
445
+ "ok": True, "llm_no": agent.llm_no,
446
+ "llm_name": agent.get_llm_name(),
447
+ "llms": [{"idx": i, "name": n, "active": a} for i, n, a in llms],
448
+ })
449
+
450
+ def _api_continue(self):
451
+ data = self._read_json()
452
+ idx = data.get("idx")
453
+ if idx is None:
454
+ return self._send_json({"error": "missing idx"})
455
+ sessions = _sessions_list()
456
+ if not (1 <= int(idx) <= len(sessions)):
457
+ return self._send_json({"error": "invalid session index"})
458
+ sess = sessions[int(idx) - 1]
459
+ # Restore history from the log file
460
+ msgs = _extract_messages(sess["path"])
461
+ agent = get_agent()
462
+ agent.abort()
463
+ agent.history = []
464
+ for m in msgs:
465
+ prefix = "[USER]: " if m["role"] == "user" else "[Agent] "
466
+ agent.history.append(prefix + m["content"][:200])
467
+ self._send_json({"ok": True, "message": f"Restored {len(msgs)} messages.", "path": sess["path"]})
468
+
469
+ def _api_chat(self):
470
+ data = self._read_json()
471
+ text = (data.get("text") or "").strip()
472
+ if not text:
473
+ return self._send_json({"error": "empty text"})
474
+ agent = get_agent()
475
+ if agent.llmclient is None:
476
+ return self._send_json({"error": "no LLM configured — create mykey.py or mykey.json"})
477
+ if agent.is_running:
478
+ return self._send_json({"error": "agent is already running — send /api/stop first"}, 409)
479
+
480
+ is_video = bool(data.get("video"))
481
+ preamble = _hf_preamble() if is_video else ""
482
+ prompt = f"{FILE_HINT}\n\n{preamble}\n\n{text}" if preamble else f"{FILE_HINT}\n\n{text}"
483
+ dq = agent.put_task(prompt, source="user")
484
+
485
+ # SSE headers
486
+ self.send_response(200)
487
+ self.send_header("Content-Type", "text/event-stream; charset=utf-8")
488
+ self.send_header("Cache-Control", "no-cache, no-transform")
489
+ self.send_header("Connection", "keep-alive")
490
+ self.send_header("X-Accel-Buffering", "no")
491
+ self.end_headers()
492
+
493
+ def emit(obj):
494
+ line = "data: " + json.dumps(obj, ensure_ascii=False) + "\n\n"
495
+ try:
496
+ self.wfile.write(line.encode("utf-8"))
497
+ self.wfile.flush()
498
+ except (BrokenPipeError, ConnectionResetError):
499
+ return False
500
+ return True
501
+
502
+ emit({"type": "start", "ts": int(time.time() * 1000)})
503
+ idle = 0
504
+ got_done = False
505
+ try:
506
+ while True:
507
+ try:
508
+ item = dq.get(timeout=1)
509
+ except Q.Empty:
510
+ if agent.is_running:
511
+ idle = 0
512
+ continue
513
+ idle += 1
514
+ if idle >= 3 and not got_done:
515
+ emit({"type": "done", "text": "", "aborted": True})
516
+ break
517
+ continue
518
+ idle = 0
519
+ if "done" in item:
520
+ got_done = True
521
+ raw = item.get("done", "") or ""
522
+ files = [p for p in extract_files(raw) if os.path.exists(p)]
523
+ body = _clean_for_ui(raw)
524
+ emit({"type": "done", "text": body, "files": files,
525
+ "source": item.get("source", "user")})
526
+ break
527
+ if "next" in item:
528
+ if not emit({"type": "delta", "text": item.get("next", ""),
529
+ "turn": item.get("turn", 0)}):
530
+ break
531
+ except Exception as e:
532
+ try:
533
+ emit({"type": "error", "message": f"{type(e).__name__}: {e}"})
534
+ except Exception:
535
+ pass
536
+ finally:
537
+ try:
538
+ self.wfile.flush()
539
+ except Exception:
540
+ pass
541
+
542
+
543
+ # ---------------------------------------------------------------------------
544
+ # Main
545
+ # ---------------------------------------------------------------------------
546
+ def main():
547
+ parser = argparse.ArgumentParser(description="webui_codex — self-contained Codex-dark agent web UI")
548
+ parser.add_argument("--host", default=DEFAULT_HOST)
549
+ parser.add_argument("--port", type=int, default=DEFAULT_PORT)
550
+ parser.add_argument("--llm_no", type=int, default=0, help="LLM index to start on")
551
+ args = parser.parse_args()
552
+
553
+ agent = get_agent()
554
+ if agent.llmclient is not None:
555
+ try:
556
+ agent.next_llm(args.llm_no)
557
+ except Exception as e:
558
+ print(f"[webui_codex] llm switch failed: {e}")
559
+
560
+ # Ensure memory + temp dirs exist
561
+ os.makedirs(MEMORY_DIR, exist_ok=True)
562
+ os.makedirs(os.path.join(TEMP_DIR, "model_responses"), exist_ok=True)
563
+
564
+ server = ThreadingHTTPServer((args.host, args.port), Handler)
565
+ daemon = threading.Thread(target=server.serve_forever, daemon=True)
566
+ daemon.start()
567
+
568
+ url = f"http://{args.host}:{args.port}"
569
+ print(f"\n ╭───────────────────────────────────────────╮")
570
+ print(f" │ webui_codex · self-contained agent │")
571
+ print(f" │ open {url:<33}│")
572
+ print(f" │ Ctrl+C to stop │")
573
+ print(f" ╰───────────────────────────────────────────╯\n")
574
+ try:
575
+ while True:
576
+ time.sleep(3600)
577
+ except KeyboardInterrupt:
578
+ print("\n[webui_codex] shutting down.")
579
+ server.shutdown()
580
+
581
+
582
+ if __name__ == "__main__":
583
+ main()