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.
- package/LICENSE +53 -0
- package/README.md +174 -0
- package/bin/cli.mjs +314 -0
- package/package.json +44 -0
- package/python/agent_core.py +1029 -0
- package/python/webui_codex.html +673 -0
- package/python/webui_codex.py +583 -0
- package/skills/faceless-explainer.md +194 -0
- package/skills/general-video.md +141 -0
- package/skills/hyperframes-animation.md +82 -0
- package/skills/hyperframes-cli.md +109 -0
- package/skills/hyperframes-core.md +78 -0
- package/skills/hyperframes-creative.md +68 -0
- package/skills/hyperframes-media.md +81 -0
- package/skills/hyperframes-registry.md +101 -0
- package/skills/hyperframes.md +144 -0
- package/skills/motion-graphics.md +170 -0
- package/skills/product-launch-video.md +199 -0
- package/skills/website-to-video.md +141 -0
- package/templates/mykey_template.json +14 -0
|
@@ -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()
|