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.
- open_codex_ui-0.1.4.dist-info/METADATA +249 -0
- open_codex_ui-0.1.4.dist-info/RECORD +40 -0
- open_codex_ui-0.1.4.dist-info/WHEEL +5 -0
- open_codex_ui-0.1.4.dist-info/entry_points.txt +7 -0
- open_codex_ui-0.1.4.dist-info/top_level.txt +1 -0
- yier_web/__init__.py +6 -0
- yier_web/app.py +223 -0
- yier_web/auth.py +269 -0
- yier_web/cli.py +172 -0
- yier_web/codex/__init__.py +3 -0
- yier_web/codex/ipc_manager.py +1761 -0
- yier_web/codex/session_events.py +110 -0
- yier_web/codex/ws_commands.py +299 -0
- yier_web/config.py +651 -0
- yier_web/directory_picker.py +93 -0
- yier_web/event_stream.py +29 -0
- yier_web/frontend.py +204 -0
- yier_web/routes/__init__.py +17 -0
- yier_web/routes/codex.py +573 -0
- yier_web/routes/core.py +281 -0
- yier_web/schemas.py +534 -0
- yier_web/static/assets/CodexEmbedView-CN_-Mhe2.js +1 -0
- yier_web/static/assets/CodexView-wpI61iXa.js +606 -0
- yier_web/static/assets/LoginView-CELCom2O.js +101 -0
- yier_web/static/assets/api-CeihACIV.js +1099 -0
- yier_web/static/assets/index-BdFqJ-Kl.css +1 -0
- yier_web/static/assets/index-CjVNk6ja.js +183 -0
- yier_web/static/assets/index-mSBvq1p8.js +79 -0
- yier_web/static/assets/open-codex-ui-icon-DKJ1ZKj4.svg +99 -0
- yier_web/static/assets/primeicons-C6QP2o4f.woff2 +0 -0
- yier_web/static/assets/primeicons-DMOk5skT.eot +0 -0
- yier_web/static/assets/primeicons-Dr5RGzOO.svg +345 -0
- yier_web/static/assets/primeicons-MpK4pl85.ttf +0 -0
- yier_web/static/assets/primeicons-WjwUDZjB.woff +0 -0
- yier_web/static/assets/useCodexWorkspace-6QenDzvb.css +1 -0
- yier_web/static/assets/useCodexWorkspace-D1rCOO1x.js +1128 -0
- yier_web/static/brand/open-codex-ui-logo.svg +85 -0
- yier_web/static/favicon.ico +0 -0
- yier_web/static/favicon.svg +99 -0
- yier_web/static/index.html +24 -0
yier_web/routes/codex.py
ADDED
|
@@ -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
|
+
)
|