open-codex-ui 0.1.4__py3-none-any.whl

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.
Files changed (40) hide show
  1. open_codex_ui-0.1.4.dist-info/METADATA +249 -0
  2. open_codex_ui-0.1.4.dist-info/RECORD +40 -0
  3. open_codex_ui-0.1.4.dist-info/WHEEL +5 -0
  4. open_codex_ui-0.1.4.dist-info/entry_points.txt +7 -0
  5. open_codex_ui-0.1.4.dist-info/top_level.txt +1 -0
  6. yier_web/__init__.py +6 -0
  7. yier_web/app.py +223 -0
  8. yier_web/auth.py +269 -0
  9. yier_web/cli.py +172 -0
  10. yier_web/codex/__init__.py +3 -0
  11. yier_web/codex/ipc_manager.py +1761 -0
  12. yier_web/codex/session_events.py +110 -0
  13. yier_web/codex/ws_commands.py +299 -0
  14. yier_web/config.py +651 -0
  15. yier_web/directory_picker.py +93 -0
  16. yier_web/event_stream.py +29 -0
  17. yier_web/frontend.py +204 -0
  18. yier_web/routes/__init__.py +17 -0
  19. yier_web/routes/codex.py +573 -0
  20. yier_web/routes/core.py +281 -0
  21. yier_web/schemas.py +534 -0
  22. yier_web/static/assets/CodexEmbedView-CN_-Mhe2.js +1 -0
  23. yier_web/static/assets/CodexView-wpI61iXa.js +606 -0
  24. yier_web/static/assets/LoginView-CELCom2O.js +101 -0
  25. yier_web/static/assets/api-CeihACIV.js +1099 -0
  26. yier_web/static/assets/index-BdFqJ-Kl.css +1 -0
  27. yier_web/static/assets/index-CjVNk6ja.js +183 -0
  28. yier_web/static/assets/index-mSBvq1p8.js +79 -0
  29. yier_web/static/assets/open-codex-ui-icon-DKJ1ZKj4.svg +99 -0
  30. yier_web/static/assets/primeicons-C6QP2o4f.woff2 +0 -0
  31. yier_web/static/assets/primeicons-DMOk5skT.eot +0 -0
  32. yier_web/static/assets/primeicons-Dr5RGzOO.svg +345 -0
  33. yier_web/static/assets/primeicons-MpK4pl85.ttf +0 -0
  34. yier_web/static/assets/primeicons-WjwUDZjB.woff +0 -0
  35. yier_web/static/assets/useCodexWorkspace-6QenDzvb.css +1 -0
  36. yier_web/static/assets/useCodexWorkspace-D1rCOO1x.js +1128 -0
  37. yier_web/static/brand/open-codex-ui-logo.svg +85 -0
  38. yier_web/static/favicon.ico +0 -0
  39. yier_web/static/favicon.svg +99 -0
  40. yier_web/static/index.html +24 -0
@@ -0,0 +1,573 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import contextlib
5
+ import mimetypes
6
+ import os
7
+ import string
8
+ from pathlib import Path
9
+ from typing import Any, cast
10
+
11
+ from litestar import Controller, get, post, put, websocket
12
+ from litestar.connection import WebSocket
13
+ from litestar.datastructures import State
14
+ from litestar.exceptions import HTTPException, WebSocketDisconnect
15
+ from litestar.response import File
16
+ from litestar.status_codes import (
17
+ HTTP_400_BAD_REQUEST,
18
+ HTTP_403_FORBIDDEN,
19
+ HTTP_404_NOT_FOUND,
20
+ )
21
+
22
+ from yier_web.codex.ipc_manager import CodexIpcManager, CodexSubscriberQueue
23
+ from yier_web.codex.ws_commands import (
24
+ CodexWsCommandContext,
25
+ CodexWsCommandStrategyFactory,
26
+ )
27
+ from yier_web.auth import AuthService
28
+ from yier_web.schemas import (
29
+ ArchiveCodexSessionResponse,
30
+ CodexFilesystemEntry,
31
+ CodexFilesystemEntryKind,
32
+ CodexFilesystemResponse,
33
+ CodexProjectDefinition,
34
+ CodexProjectPayload,
35
+ CodexRemoteConnectionPayload,
36
+ CodexRemoteConnectionApiKeyLoginPayload,
37
+ CodexRemoteConnectionResponse,
38
+ CodexRemoteConnectionTestResponse,
39
+ CodexRemoteConnectionsResponse,
40
+ CodexThreadCreateRequest,
41
+ CodexThreadCreateResponse,
42
+ CodexThreadNameRequest,
43
+ CodexThreadStateResponse,
44
+ CodexWorkspaceResponse,
45
+ )
46
+
47
+
48
+ def _codex_manager(state: State) -> CodexIpcManager:
49
+ manager = getattr(state, "codex_ipc_manager", None)
50
+ if manager is None:
51
+ raise RuntimeError("Codex IPC manager is not configured.")
52
+ return cast(CodexIpcManager, manager)
53
+
54
+
55
+ def _auth_service(state: State) -> AuthService:
56
+ auth_service = getattr(state, "auth_service", None)
57
+ if auth_service is None:
58
+ raise RuntimeError("Auth service is not configured.")
59
+ return cast(AuthService, auth_service)
60
+
61
+
62
+ def _filesystem_roots() -> list[CodexFilesystemEntry]:
63
+ if os.name == "nt":
64
+ return [
65
+ CodexFilesystemEntry(
66
+ name=f"{letter}:",
67
+ path=str(Path(f"{letter}:/")),
68
+ kind="directory",
69
+ readable=True,
70
+ )
71
+ for letter in string.ascii_uppercase
72
+ if Path(f"{letter}:/").exists()
73
+ ]
74
+ return [
75
+ CodexFilesystemEntry(
76
+ name="/",
77
+ path=str(Path("/")),
78
+ kind="directory",
79
+ readable=True,
80
+ )
81
+ ]
82
+
83
+
84
+ def _entry_from_dir_entry(entry: os.DirEntry[str]) -> CodexFilesystemEntry:
85
+ path = Path(entry.path)
86
+ kind: CodexFilesystemEntryKind = "other"
87
+ readable = True
88
+ try:
89
+ if entry.is_dir(follow_symlinks=False):
90
+ kind = "directory"
91
+ try:
92
+ os.scandir(entry.path).close()
93
+ except OSError:
94
+ readable = False
95
+ elif entry.is_file(follow_symlinks=False):
96
+ kind = "file"
97
+ else:
98
+ kind = "other"
99
+ except OSError:
100
+ readable = False
101
+ return CodexFilesystemEntry(
102
+ name=entry.name,
103
+ path=str(path),
104
+ kind=kind,
105
+ extension=path.suffix.lower() if kind == "file" else "",
106
+ readable=readable,
107
+ )
108
+
109
+
110
+ def _sort_filesystem_entries(
111
+ entries: list[CodexFilesystemEntry],
112
+ ) -> list[CodexFilesystemEntry]:
113
+ return sorted(
114
+ entries,
115
+ key=lambda entry: (
116
+ 0 if entry.kind == "directory" else 1 if entry.kind == "file" else 2,
117
+ entry.name.casefold(),
118
+ ),
119
+ )
120
+
121
+
122
+ def _list_host_filesystem(path: str | None) -> CodexFilesystemResponse:
123
+ requested_path = Path(path).expanduser() if path else Path.cwd()
124
+ try:
125
+ directory = requested_path.resolve()
126
+ except OSError as exc:
127
+ raise HTTPException(
128
+ status_code=HTTP_400_BAD_REQUEST,
129
+ detail=f"Unable to resolve path: {requested_path}",
130
+ ) from exc
131
+
132
+ if not directory.exists():
133
+ raise HTTPException(
134
+ status_code=HTTP_404_NOT_FOUND,
135
+ detail=f"Path not found: {directory}",
136
+ )
137
+ if not directory.is_dir():
138
+ raise HTTPException(
139
+ status_code=HTTP_400_BAD_REQUEST,
140
+ detail=f"Path is not a directory: {directory}",
141
+ )
142
+
143
+ try:
144
+ with os.scandir(directory) as iterator:
145
+ entries = [_entry_from_dir_entry(entry) for entry in iterator]
146
+ except PermissionError as exc:
147
+ raise HTTPException(
148
+ status_code=HTTP_403_FORBIDDEN,
149
+ detail=f"Permission denied: {directory}",
150
+ ) from exc
151
+ except OSError as exc:
152
+ raise HTTPException(
153
+ status_code=HTTP_400_BAD_REQUEST,
154
+ detail=f"Unable to read directory: {directory}",
155
+ ) from exc
156
+
157
+ parent_path = None if directory.parent == directory else str(directory.parent)
158
+ return CodexFilesystemResponse(
159
+ path=str(directory),
160
+ parent_path=parent_path,
161
+ roots=_filesystem_roots(),
162
+ entries=_sort_filesystem_entries(entries),
163
+ )
164
+
165
+
166
+ def _host_image_response(path: str, *, download: bool = False) -> File:
167
+ requested_path = Path(path).expanduser()
168
+ try:
169
+ image_path = requested_path.resolve(strict=True)
170
+ except OSError as exc:
171
+ raise HTTPException(
172
+ status_code=HTTP_404_NOT_FOUND,
173
+ detail=f"Image not found: {requested_path}",
174
+ ) from exc
175
+
176
+ if not image_path.is_file():
177
+ raise HTTPException(
178
+ status_code=HTTP_400_BAD_REQUEST,
179
+ detail=f"Path is not a file: {image_path}",
180
+ )
181
+
182
+ media_type, _ = mimetypes.guess_type(image_path.name)
183
+ allowed_image_types = {
184
+ "image/png",
185
+ "image/jpeg",
186
+ "image/gif",
187
+ "image/webp",
188
+ "image/bmp",
189
+ }
190
+ if media_type not in allowed_image_types:
191
+ raise HTTPException(
192
+ status_code=HTTP_400_BAD_REQUEST,
193
+ detail=f"Path is not a supported image: {image_path}",
194
+ )
195
+
196
+ return File(
197
+ path=image_path,
198
+ filename=image_path.name,
199
+ media_type=media_type,
200
+ content_disposition_type="attachment" if download else "inline",
201
+ )
202
+
203
+
204
+ class CodexController(Controller):
205
+ path = "/codex"
206
+ ws_command_factory = CodexWsCommandStrategyFactory()
207
+
208
+ @get("/workspace")
209
+ async def get_workspace(self, state: State) -> CodexWorkspaceResponse:
210
+ return await _codex_manager(state).workspace()
211
+
212
+ @post("/projects")
213
+ async def create_project(
214
+ self,
215
+ data: CodexProjectPayload,
216
+ state: State,
217
+ ) -> CodexProjectDefinition:
218
+ try:
219
+ return _codex_manager(state).config_service.save_codex_project(data)
220
+ except ValueError as exc:
221
+ raise HTTPException(
222
+ status_code=HTTP_400_BAD_REQUEST,
223
+ detail=str(exc),
224
+ ) from exc
225
+
226
+ @post("/projects/{project_id:str}/delete")
227
+ async def delete_project(
228
+ self,
229
+ project_id: str,
230
+ state: State,
231
+ ) -> dict[str, bool]:
232
+ try:
233
+ _codex_manager(state).config_service.delete_codex_project(project_id)
234
+ except ValueError as exc:
235
+ raise HTTPException(
236
+ status_code=HTTP_404_NOT_FOUND,
237
+ detail=str(exc),
238
+ ) from exc
239
+ return {"ok": True}
240
+
241
+ @get("/remote-connections")
242
+ async def get_remote_connections(
243
+ self,
244
+ state: State,
245
+ ) -> CodexRemoteConnectionsResponse:
246
+ return _codex_manager(state).remote_connections()
247
+
248
+ @post("/remote-connections")
249
+ async def create_remote_connection(
250
+ self,
251
+ data: CodexRemoteConnectionPayload,
252
+ state: State,
253
+ ) -> CodexRemoteConnectionResponse:
254
+ manager = _codex_manager(state)
255
+ previous_active_id = (
256
+ manager.config_service.load_web_settings().codex.active_remote_connection_id
257
+ )
258
+ connection = manager.config_service.save_codex_remote_connection(data)
259
+ current_active_id = (
260
+ manager.config_service.load_web_settings().codex.active_remote_connection_id
261
+ )
262
+ if current_active_id != previous_active_id:
263
+ await manager.activate_remote_connection(current_active_id)
264
+ return CodexRemoteConnectionResponse(connection=connection)
265
+
266
+ @put("/remote-connections/{connection_id:str}")
267
+ async def update_remote_connection(
268
+ self,
269
+ connection_id: str,
270
+ data: CodexRemoteConnectionPayload,
271
+ state: State,
272
+ ) -> CodexRemoteConnectionResponse:
273
+ manager = _codex_manager(state)
274
+ previous_active_id = (
275
+ manager.config_service.load_web_settings().codex.active_remote_connection_id
276
+ )
277
+ connection = manager.config_service.save_codex_remote_connection(
278
+ data,
279
+ connection_id=connection_id,
280
+ )
281
+ current_active_id = (
282
+ manager.config_service.load_web_settings().codex.active_remote_connection_id
283
+ )
284
+ await manager.restart_remote_connection(connection.id)
285
+ if current_active_id != previous_active_id:
286
+ await manager.activate_remote_connection(current_active_id)
287
+ return CodexRemoteConnectionResponse(connection=connection)
288
+
289
+ @post("/remote-connections/{connection_id:str}/delete")
290
+ async def delete_remote_connection(
291
+ self,
292
+ connection_id: str,
293
+ state: State,
294
+ ) -> dict[str, bool]:
295
+ manager = _codex_manager(state)
296
+ was_active = (
297
+ manager.config_service.load_web_settings().codex.active_remote_connection_id
298
+ == connection_id
299
+ )
300
+ await manager.disconnect_remote_connection(connection_id)
301
+ manager.config_service.delete_codex_remote_connection(connection_id)
302
+ if was_active:
303
+ await manager.activate_remote_connection("")
304
+ return {"ok": True}
305
+
306
+ @post("/remote-connections/{connection_id:str}/activate")
307
+ async def activate_remote_connection(
308
+ self,
309
+ connection_id: str,
310
+ state: State,
311
+ ) -> dict[str, bool]:
312
+ await _codex_manager(state).activate_remote_connection(connection_id)
313
+ return {"ok": True}
314
+
315
+ @post("/remote-connections/activate-local")
316
+ async def activate_local_connection(self, state: State) -> dict[str, bool]:
317
+ await _codex_manager(state).activate_remote_connection("")
318
+ return {"ok": True}
319
+
320
+ @post("/remote-connections/{connection_id:str}/restart")
321
+ async def restart_remote_connection(
322
+ self,
323
+ connection_id: str,
324
+ state: State,
325
+ ) -> dict[str, bool]:
326
+ await _codex_manager(state).restart_remote_connection(connection_id)
327
+ return {"ok": True}
328
+
329
+ @post("/remote-connections/{connection_id:str}/install")
330
+ async def install_remote_codex(
331
+ self,
332
+ connection_id: str,
333
+ state: State,
334
+ ) -> CodexRemoteConnectionTestResponse:
335
+ return await _codex_manager(state).install_remote_codex(connection_id)
336
+
337
+ @post("/remote-connections/{connection_id:str}/login-api-key")
338
+ async def login_remote_api_key(
339
+ self,
340
+ connection_id: str,
341
+ data: CodexRemoteConnectionApiKeyLoginPayload,
342
+ state: State,
343
+ ) -> CodexRemoteConnectionTestResponse:
344
+ return await _codex_manager(state).login_remote_api_key(
345
+ connection_id,
346
+ data.api_key,
347
+ )
348
+
349
+ @post("/remote-connections/{connection_id:str}/test")
350
+ async def test_remote_connection(
351
+ self,
352
+ connection_id: str,
353
+ state: State,
354
+ ) -> CodexRemoteConnectionTestResponse:
355
+ return await _codex_manager(state).test_remote_connection(connection_id)
356
+
357
+ @get("/filesystem")
358
+ async def get_filesystem(
359
+ self,
360
+ state: State,
361
+ path: str | None = None,
362
+ host_id: str = "local",
363
+ ) -> CodexFilesystemResponse:
364
+ if host_id and host_id != "local":
365
+ try:
366
+ return await _codex_manager(state).list_filesystem(
367
+ host_id=host_id,
368
+ path=path,
369
+ )
370
+ except (RuntimeError, ValueError) as exc:
371
+ raise HTTPException(
372
+ status_code=HTTP_400_BAD_REQUEST,
373
+ detail=str(exc),
374
+ ) from exc
375
+ return _list_host_filesystem(path)
376
+
377
+ @get("/image")
378
+ async def get_image(self, path: str, download: bool = False) -> File:
379
+ return _host_image_response(path, download=download)
380
+
381
+ @get("/threads/{thread_id:str}/state")
382
+ async def get_thread_state(
383
+ self,
384
+ thread_id: str,
385
+ state: State,
386
+ host_id: str | None = None,
387
+ ) -> CodexThreadStateResponse:
388
+ manager = _codex_manager(state)
389
+ thread_state = await manager.get_thread_state(thread_id, host_id=host_id)
390
+ if thread_state is None:
391
+ raise HTTPException(
392
+ status_code=HTTP_404_NOT_FOUND,
393
+ detail="Codex thread state not found.",
394
+ )
395
+ return CodexThreadStateResponse(thread_id=thread_id, state=thread_state)
396
+
397
+ @post("/threads")
398
+ async def create_thread(
399
+ self,
400
+ data: CodexThreadCreateRequest,
401
+ state: State,
402
+ ) -> CodexThreadCreateResponse:
403
+ payload = await _codex_manager(state).start_thread(
404
+ project_path=data.project_path,
405
+ host_id=data.host_id,
406
+ )
407
+ return CodexThreadCreateResponse(
408
+ thread_id=str(payload["thread_id"]),
409
+ host_id=str(payload.get("host_id") or "local"),
410
+ state=payload.get("state")
411
+ if isinstance(payload.get("state"), dict)
412
+ else None,
413
+ )
414
+
415
+ @post("/threads/{thread_id:str}/archive")
416
+ async def archive_thread(
417
+ self,
418
+ thread_id: str,
419
+ state: State,
420
+ ) -> ArchiveCodexSessionResponse:
421
+ await _codex_manager(state).archive_thread(thread_id)
422
+ return ArchiveCodexSessionResponse(thread_id=thread_id)
423
+
424
+ @post("/threads/{thread_id:str}/unarchive")
425
+ async def unarchive_thread(
426
+ self,
427
+ thread_id: str,
428
+ state: State,
429
+ ) -> dict[str, bool]:
430
+ await _codex_manager(state).unarchive_thread(thread_id)
431
+ return {"ok": True}
432
+
433
+ @put("/threads/{thread_id:str}/name")
434
+ async def rename_thread(
435
+ self,
436
+ thread_id: str,
437
+ data: CodexThreadNameRequest,
438
+ state: State,
439
+ ) -> dict[str, bool]:
440
+ if not data.name:
441
+ raise HTTPException(
442
+ status_code=HTTP_400_BAD_REQUEST,
443
+ detail="name is required.",
444
+ )
445
+ await _codex_manager(state).rename_thread(thread_id, data.name)
446
+ return {"ok": True}
447
+
448
+ @websocket("/ws")
449
+ async def websocket_handler(self, socket: WebSocket) -> None:
450
+ manager = _codex_manager(socket.app.state)
451
+ await socket.accept()
452
+ if not _auth_service(socket.app.state).is_codex_websocket_authorized(socket):
453
+ await socket.send_json(
454
+ {
455
+ "type": "error",
456
+ "code": "unauthorized",
457
+ "message": "Authentication or a valid Codex embed token is required.",
458
+ }
459
+ )
460
+ await socket.close(code=1008, reason="Codex WebSocket unauthorized.")
461
+ return
462
+
463
+ outbox: CodexSubscriberQueue = asyncio.Queue()
464
+ subscribed_thread_ids: set[str] = set()
465
+
466
+ await outbox.put(
467
+ {
468
+ "type": "connection_ready",
469
+ "payload": {"ok": True},
470
+ }
471
+ )
472
+
473
+ receive_task: asyncio.Task[Any] = asyncio.create_task(socket.receive_json())
474
+ send_task: asyncio.Task[dict[str, Any]] = asyncio.create_task(outbox.get())
475
+
476
+ try:
477
+ while True:
478
+ done, _pending = await asyncio.wait(
479
+ {receive_task, send_task},
480
+ return_when=asyncio.FIRST_COMPLETED,
481
+ )
482
+
483
+ if receive_task in done:
484
+ incoming = receive_task.result()
485
+ receive_task = asyncio.create_task(socket.receive_json())
486
+ await self._handle_ws_message(
487
+ manager=manager,
488
+ message=incoming,
489
+ outbox=outbox,
490
+ subscribed_thread_ids=subscribed_thread_ids,
491
+ )
492
+
493
+ if send_task in done:
494
+ outbound = send_task.result()
495
+ await socket.send_json(outbound)
496
+ send_task = asyncio.create_task(outbox.get())
497
+ except WebSocketDisconnect:
498
+ pass
499
+ finally:
500
+ for task in (receive_task, send_task):
501
+ if task.done():
502
+ continue
503
+ task.cancel()
504
+ with contextlib.suppress(asyncio.CancelledError):
505
+ await task
506
+ for thread_id in subscribed_thread_ids:
507
+ await manager.unsubscribe(thread_id, outbox)
508
+
509
+ async def _handle_ws_message(
510
+ self,
511
+ *,
512
+ manager: CodexIpcManager,
513
+ message: Any,
514
+ outbox: CodexSubscriberQueue,
515
+ subscribed_thread_ids: set[str],
516
+ ) -> None:
517
+ request_id = None
518
+ try:
519
+ if not isinstance(message, dict):
520
+ raise ValueError("WebSocket message must be an object.")
521
+ request_id = message.get("id")
522
+ if not isinstance(request_id, str) or not request_id:
523
+ request_id = None
524
+ message_type = message.get("type")
525
+ if not isinstance(message_type, str) or not message_type:
526
+ raise ValueError("WebSocket message type is required.")
527
+ payload = message.get("payload")
528
+ if not isinstance(payload, dict):
529
+ payload = {}
530
+
531
+ result = await self._execute_ws_command(
532
+ manager=manager,
533
+ message_type=message_type,
534
+ payload=payload,
535
+ outbox=outbox,
536
+ subscribed_thread_ids=subscribed_thread_ids,
537
+ )
538
+ await outbox.put(
539
+ {
540
+ "id": request_id,
541
+ "type": "ack",
542
+ "ok": True,
543
+ "payload": result,
544
+ }
545
+ )
546
+ except Exception as exc: # noqa: BLE001
547
+ await outbox.put(
548
+ {
549
+ "id": request_id,
550
+ "type": "error",
551
+ "code": "bad_request",
552
+ "message": str(exc),
553
+ }
554
+ )
555
+
556
+ async def _execute_ws_command(
557
+ self,
558
+ *,
559
+ manager: CodexIpcManager,
560
+ message_type: str,
561
+ payload: dict[str, Any],
562
+ outbox: CodexSubscriberQueue,
563
+ subscribed_thread_ids: set[str],
564
+ ) -> dict[str, Any]:
565
+ strategy = self.ws_command_factory.get(message_type)
566
+ return await strategy.execute(
567
+ CodexWsCommandContext(
568
+ manager=manager,
569
+ payload=payload,
570
+ outbox=outbox,
571
+ subscribed_thread_ids=subscribed_thread_ids,
572
+ )
573
+ )