python-codex 0.2.1__py3-none-any.whl → 0.2.3__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.
- pycodex/cli.py +21 -13
- pycodex/context.py +4 -1
- pycodex/feishu_link.py +32 -14
- pycodex/interactive_session.py +5 -1
- {python_codex-0.2.1.dist-info → python_codex-0.2.3.dist-info}/METADATA +21 -4
- {python_codex-0.2.1.dist-info → python_codex-0.2.3.dist-info}/RECORD +14 -12
- workspace_server/__init__.py +19 -3
- workspace_server/app.py +591 -369
- workspace_server/workspace.html +377 -32
- workspace_server/workspaces.html +551 -0
- workspace_server/workspaces.py +561 -0
- {python_codex-0.2.1.dist-info → python_codex-0.2.3.dist-info}/WHEEL +0 -0
- {python_codex-0.2.1.dist-info → python_codex-0.2.3.dist-info}/entry_points.txt +0 -0
- {python_codex-0.2.1.dist-info → python_codex-0.2.3.dist-info}/licenses/LICENSE +0 -0
workspace_server/app.py
CHANGED
|
@@ -3,9 +3,8 @@ import asyncio
|
|
|
3
3
|
import html
|
|
4
4
|
import json
|
|
5
5
|
import os
|
|
6
|
+
import secrets
|
|
6
7
|
import threading
|
|
7
|
-
import tempfile
|
|
8
|
-
from uuid import uuid4
|
|
9
8
|
from dataclasses import asdict, is_dataclass
|
|
10
9
|
try:
|
|
11
10
|
from contextlib import asynccontextmanager
|
|
@@ -13,8 +12,8 @@ except ImportError: # pragma: no cover - Python 3.6 compatibility
|
|
|
13
12
|
asynccontextmanager = None
|
|
14
13
|
from pathlib import Path
|
|
15
14
|
|
|
16
|
-
from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect
|
|
17
|
-
from fastapi.responses import HTMLResponse, JSONResponse, Response
|
|
15
|
+
from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect
|
|
16
|
+
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, Response
|
|
18
17
|
|
|
19
18
|
from pycodex.cli import build_agent, build_cli_queue, build_model, configure_loguru
|
|
20
19
|
from pycodex.interactive_session import run_interactive_session
|
|
@@ -25,7 +24,20 @@ from pycodex.utils.session_persist import (
|
|
|
25
24
|
load_resumed_session_path,
|
|
26
25
|
)
|
|
27
26
|
from pycodex.utils import uuid7_string
|
|
28
|
-
from pycodex.utils.visualize import
|
|
27
|
+
from pycodex.utils.visualize import (
|
|
28
|
+
IDLE_LISTENING_STATUS,
|
|
29
|
+
percent_of_context_window_remaining,
|
|
30
|
+
shorten_title,
|
|
31
|
+
tool_summary,
|
|
32
|
+
)
|
|
33
|
+
from .workspaces import (
|
|
34
|
+
WorkspaceDefinition,
|
|
35
|
+
WorkspaceEntry,
|
|
36
|
+
WorkspaceRegistry,
|
|
37
|
+
WorkspaceSessionManager,
|
|
38
|
+
load_workspace_definitions,
|
|
39
|
+
session_snapshot,
|
|
40
|
+
)
|
|
29
41
|
import typing
|
|
30
42
|
|
|
31
43
|
|
|
@@ -40,55 +52,6 @@ JSONValue = typing.Union[
|
|
|
40
52
|
]
|
|
41
53
|
|
|
42
54
|
|
|
43
|
-
class WorkspaceStateStore:
|
|
44
|
-
def __init__(self, board_path: "typing.Union[Path, None]") -> None:
|
|
45
|
-
self.path = None if board_path is None else board_path.with_suffix(".pycodex-ws.json")
|
|
46
|
-
|
|
47
|
-
def load_tabs(self) -> "typing.List[typing.Dict[str, str]]":
|
|
48
|
-
if self.path is None or not self.path.is_file():
|
|
49
|
-
return []
|
|
50
|
-
|
|
51
|
-
try:
|
|
52
|
-
payload = json.loads(
|
|
53
|
-
self.path.read_text(encoding="utf-8", errors="replace") or "{}"
|
|
54
|
-
)
|
|
55
|
-
except (OSError, ValueError):
|
|
56
|
-
return []
|
|
57
|
-
|
|
58
|
-
tabs = payload.get("tabs") if isinstance(payload, dict) else None
|
|
59
|
-
if not isinstance(tabs, list):
|
|
60
|
-
return []
|
|
61
|
-
|
|
62
|
-
result = []
|
|
63
|
-
for tab in tabs:
|
|
64
|
-
if not isinstance(tab, dict):
|
|
65
|
-
continue
|
|
66
|
-
title = str(tab.get("title") or "").strip()
|
|
67
|
-
rollout_path = str(tab.get("rollout_path") or "").strip()
|
|
68
|
-
if title or rollout_path:
|
|
69
|
-
result.append({"title": title, "rollout_path": rollout_path})
|
|
70
|
-
return result
|
|
71
|
-
|
|
72
|
-
def save_tabs(self, tabs: "typing.Iterable[typing.Dict[str, str]]") -> None:
|
|
73
|
-
if self.path is None:
|
|
74
|
-
return
|
|
75
|
-
|
|
76
|
-
state_tabs = [
|
|
77
|
-
{
|
|
78
|
-
"title": str(tab.get("title") or ""),
|
|
79
|
-
"rollout_path": str(tab.get("rollout_path") or ""),
|
|
80
|
-
}
|
|
81
|
-
for tab in tabs
|
|
82
|
-
]
|
|
83
|
-
payload = json.dumps(
|
|
84
|
-
{"version": 1, "tabs": state_tabs},
|
|
85
|
-
ensure_ascii=False,
|
|
86
|
-
indent=2,
|
|
87
|
-
) + "\n"
|
|
88
|
-
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
89
|
-
self.path.write_text(payload, encoding="utf-8")
|
|
90
|
-
|
|
91
|
-
|
|
92
55
|
def build_parser() -> "argparse.ArgumentParser":
|
|
93
56
|
parser = argparse.ArgumentParser(
|
|
94
57
|
prog="pycodex-ws",
|
|
@@ -100,9 +63,17 @@ def build_parser() -> "argparse.ArgumentParser":
|
|
|
100
63
|
help="Bind address as host:port, for example 0.0.0.0:6007.",
|
|
101
64
|
)
|
|
102
65
|
parser.add_argument(
|
|
103
|
-
"--
|
|
66
|
+
"--workspace-config",
|
|
67
|
+
default="./workspaces.json",
|
|
68
|
+
help=(
|
|
69
|
+
"Optional JSON file listing workspaces. Each entry needs `id`, "
|
|
70
|
+
"`board`, and `work_dir`; routes are served under /w/<id>/."
|
|
71
|
+
),
|
|
72
|
+
)
|
|
73
|
+
parser.add_argument(
|
|
74
|
+
"--password",
|
|
104
75
|
default=None,
|
|
105
|
-
help="Optional
|
|
76
|
+
help="Optional password required to open the workspace server.",
|
|
106
77
|
)
|
|
107
78
|
parser.add_argument(
|
|
108
79
|
"--config",
|
|
@@ -141,32 +112,24 @@ def build_parser() -> "argparse.ArgumentParser":
|
|
|
141
112
|
return parser
|
|
142
113
|
|
|
143
114
|
|
|
144
|
-
def
|
|
145
|
-
target: str,
|
|
146
|
-
board: "typing.Union[str, None]" = None,
|
|
147
|
-
) -> "typing.Tuple[str, int, typing.Union[Path, None]]":
|
|
115
|
+
def parse_listen(target: str) -> "typing.Tuple[str, int]":
|
|
148
116
|
target_text = str(target or "").strip() or "127.0.0.1:6007"
|
|
149
|
-
board_text = board
|
|
150
|
-
if "+" in target_text:
|
|
151
|
-
target_text, suffix = target_text.split("+", 1)
|
|
152
|
-
if board_text is None:
|
|
153
|
-
board_text = suffix
|
|
154
117
|
if ":" not in target_text:
|
|
155
|
-
raise ValueError("workspace target must look like host:port")
|
|
118
|
+
raise ValueError("workspace listen target must look like host:port")
|
|
156
119
|
host, port_text = target_text.rsplit(":", 1)
|
|
157
120
|
host = host.strip() or "127.0.0.1"
|
|
158
121
|
try:
|
|
159
122
|
port = int(port_text)
|
|
160
123
|
except ValueError as exc:
|
|
161
124
|
raise ValueError("workspace port must be an integer") from exc
|
|
162
|
-
|
|
163
|
-
return host, port, board_path
|
|
125
|
+
return host, port
|
|
164
126
|
|
|
165
127
|
|
|
166
128
|
SessionFactory = typing.Callable[[], object]
|
|
167
129
|
ThreadedSessionFactory = typing.Callable[[], "WorkspaceInteractiveSession"]
|
|
168
130
|
SESSION_CLOSE_TIMEOUT_SECONDS = 2.0
|
|
169
131
|
SPINNER_STATUS_PREVIEW_LIMIT = 180
|
|
132
|
+
AUTH_COOKIE_NAME = "pycodex_ws_auth"
|
|
170
133
|
|
|
171
134
|
|
|
172
135
|
class WebSessionView:
|
|
@@ -180,6 +143,8 @@ class WebSessionView:
|
|
|
180
143
|
self._title = ""
|
|
181
144
|
self._spinner_status = ""
|
|
182
145
|
self._stream_buffer = ""
|
|
146
|
+
self._context_window_tokens: "typing.Union[int, None]" = None
|
|
147
|
+
self._context_remaining_percent: "typing.Union[int, None]" = None
|
|
183
148
|
self._closed = False
|
|
184
149
|
self._server_loop: "typing.Union[asyncio.AbstractEventLoop, None]" = None
|
|
185
150
|
self._worker_loop: "typing.Union[asyncio.AbstractEventLoop, None]" = None
|
|
@@ -347,7 +312,11 @@ class WebSessionView:
|
|
|
347
312
|
self,
|
|
348
313
|
context_window_tokens: "typing.Union[int, None]",
|
|
349
314
|
) -> None:
|
|
350
|
-
|
|
315
|
+
with self._lock:
|
|
316
|
+
self._context_window_tokens = context_window_tokens
|
|
317
|
+
self._context_remaining_percent = (
|
|
318
|
+
100 if context_window_tokens is not None else None
|
|
319
|
+
)
|
|
351
320
|
|
|
352
321
|
def subscribe(self) -> "asyncio.Queue":
|
|
353
322
|
queue: "asyncio.Queue" = asyncio.Queue()
|
|
@@ -386,6 +355,7 @@ class WebSessionView:
|
|
|
386
355
|
"spinner": self._spinner_status,
|
|
387
356
|
"model": "pycodex",
|
|
388
357
|
"title": self._title,
|
|
358
|
+
"context_remaining_percent": self._context_remaining_percent,
|
|
389
359
|
"turns": [_public_turn(turn) for turn in self._turns[-80:]],
|
|
390
360
|
}
|
|
391
361
|
|
|
@@ -396,6 +366,8 @@ class WebSessionView:
|
|
|
396
366
|
"spinner": self._spinner_status,
|
|
397
367
|
"title": self._title,
|
|
398
368
|
"turn_count": len(self._turns),
|
|
369
|
+
"last_assistant": _last_assistant_text(self._turns),
|
|
370
|
+
"context_remaining_percent": self._context_remaining_percent,
|
|
399
371
|
}
|
|
400
372
|
|
|
401
373
|
def _apply_runtime_event(self, event: "AgentEvent") -> None:
|
|
@@ -403,6 +375,9 @@ class WebSessionView:
|
|
|
403
375
|
payload = getattr(event, "payload", {})
|
|
404
376
|
if not isinstance(payload, dict):
|
|
405
377
|
payload = {}
|
|
378
|
+
if kind == "token_count":
|
|
379
|
+
self._update_context_window(payload.get("usage"))
|
|
380
|
+
return
|
|
406
381
|
turn_id = str(payload.get("turn_id") or getattr(event, "turn_id", "") or "")
|
|
407
382
|
submission_id = str(payload.get("submission_id") or turn_id or "")
|
|
408
383
|
turn = self._turns_by_submission_id.get(submission_id)
|
|
@@ -475,6 +450,22 @@ class WebSessionView:
|
|
|
475
450
|
turn["status"] = "interrupted"
|
|
476
451
|
self._stream_buffer = ""
|
|
477
452
|
|
|
453
|
+
def _update_context_window(self, usage: "object") -> None:
|
|
454
|
+
if self._context_window_tokens is None:
|
|
455
|
+
return
|
|
456
|
+
if not isinstance(usage, dict):
|
|
457
|
+
self._context_remaining_percent = None
|
|
458
|
+
return
|
|
459
|
+
try:
|
|
460
|
+
total_tokens = int(usage["total_tokens"])
|
|
461
|
+
except (KeyError, TypeError, ValueError):
|
|
462
|
+
self._context_remaining_percent = None
|
|
463
|
+
return
|
|
464
|
+
self._context_remaining_percent = percent_of_context_window_remaining(
|
|
465
|
+
total_tokens,
|
|
466
|
+
self._context_window_tokens,
|
|
467
|
+
)
|
|
468
|
+
|
|
478
469
|
def _apply_spinner_event(
|
|
479
470
|
self,
|
|
480
471
|
kind: str,
|
|
@@ -837,179 +828,307 @@ class ThreadedWorkspaceInteractiveSession:
|
|
|
837
828
|
await asyncio.wrap_future(future)
|
|
838
829
|
|
|
839
830
|
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
state_tabs = self._state_store.load_tabs()
|
|
856
|
-
if not state_tabs:
|
|
857
|
-
await self.create_session()
|
|
858
|
-
return
|
|
859
|
-
for tab in state_tabs:
|
|
860
|
-
await self.create_session(
|
|
861
|
-
title=str(tab.get("title") or ""),
|
|
862
|
-
rollout_path=str(tab.get("rollout_path") or ""),
|
|
863
|
-
)
|
|
831
|
+
def create_app(
|
|
832
|
+
session_source: "typing.Union[WorkspaceSessionManager, SessionFactory]",
|
|
833
|
+
board_path: "typing.Union[Path, None]",
|
|
834
|
+
password: "typing.Union[str, None]" = None,
|
|
835
|
+
) -> FastAPI:
|
|
836
|
+
manager = (
|
|
837
|
+
session_source
|
|
838
|
+
if isinstance(session_source, WorkspaceSessionManager)
|
|
839
|
+
else WorkspaceSessionManager(session_source, board_path)
|
|
840
|
+
)
|
|
841
|
+
app = _create_lifespan_app(manager.start, manager.close)
|
|
842
|
+
auth_token = _install_auth(app, password)
|
|
843
|
+
_install_workspace_routes(app, manager, board_path)
|
|
844
|
+
app.state.workspace_auth_token = auth_token
|
|
845
|
+
return app
|
|
864
846
|
|
|
865
|
-
async def close(self) -> None:
|
|
866
|
-
sessions = list(self._sessions.values())
|
|
867
|
-
watchers = list(self._state_watchers.values())
|
|
868
|
-
self._sessions.clear()
|
|
869
|
-
self._session_order = []
|
|
870
|
-
self._state_watchers.clear()
|
|
871
|
-
self._persisted_titles.clear()
|
|
872
|
-
for watcher in watchers:
|
|
873
|
-
watcher.cancel()
|
|
874
|
-
if watchers:
|
|
875
|
-
await asyncio.gather(*watchers, return_exceptions=True)
|
|
876
|
-
for session in sessions:
|
|
877
|
-
await session.close()
|
|
878
|
-
|
|
879
|
-
async def create_session(
|
|
880
|
-
self,
|
|
881
|
-
title: str = "",
|
|
882
|
-
rollout_path: str = "",
|
|
883
|
-
) -> str:
|
|
884
|
-
async with self._lock:
|
|
885
|
-
session_id = uuid7_string()
|
|
886
|
-
session = self._session_factory()
|
|
887
|
-
await session.start()
|
|
888
847
|
|
|
889
|
-
|
|
890
|
-
|
|
848
|
+
def create_multi_workspace_app(
|
|
849
|
+
registry: 'WorkspaceRegistry',
|
|
850
|
+
password: "typing.Union[str, None]" = None,
|
|
851
|
+
) -> FastAPI:
|
|
852
|
+
app = _create_lifespan_app(registry.start, registry.close)
|
|
853
|
+
auth_token = _install_auth(app, password)
|
|
891
854
|
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
855
|
+
@app.get("/")
|
|
856
|
+
async def index() -> Response:
|
|
857
|
+
return _html_response(_render_workspaces_manager_shell())
|
|
858
|
+
|
|
859
|
+
@app.get("/favicon.ico")
|
|
860
|
+
async def favicon() -> Response:
|
|
861
|
+
return Response(status_code=204)
|
|
862
|
+
|
|
863
|
+
@app.get("/api/workspaces")
|
|
864
|
+
async def workspaces() -> JSONResponse:
|
|
865
|
+
return JSONResponse({"workspaces": registry.list_workspaces()})
|
|
866
|
+
|
|
867
|
+
@app.post("/api/workspaces")
|
|
868
|
+
async def add_workspace(payload: "typing.Dict[str, object]") -> JSONResponse:
|
|
869
|
+
try:
|
|
870
|
+
entry = await registry.add_workspace(
|
|
871
|
+
str(payload.get("name") or ""),
|
|
872
|
+
work_dir=str(payload.get("dir") or "./"),
|
|
873
|
+
board=(
|
|
874
|
+
None
|
|
875
|
+
if payload.get("board") in (None, "")
|
|
876
|
+
else str(payload.get("board"))
|
|
877
|
+
),
|
|
899
878
|
)
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
item for item in self._session_order if item != session_id
|
|
913
|
-
]
|
|
914
|
-
if watcher is not None:
|
|
915
|
-
watcher.cancel()
|
|
916
|
-
await asyncio.gather(watcher, return_exceptions=True)
|
|
917
|
-
await session.close()
|
|
918
|
-
self.persist_workspace_state()
|
|
919
|
-
|
|
920
|
-
async def _watch_session_title(self, session_id: str, session) -> None:
|
|
921
|
-
subscriber = session.subscribe()
|
|
879
|
+
except ValueError as exc:
|
|
880
|
+
return JSONResponse({"ok": False, "error": str(exc)}, status_code=400)
|
|
881
|
+
return JSONResponse(
|
|
882
|
+
{
|
|
883
|
+
"ok": True,
|
|
884
|
+
"workspace": entry.to_dict(),
|
|
885
|
+
"workspaces": registry.list_workspaces(),
|
|
886
|
+
}
|
|
887
|
+
)
|
|
888
|
+
|
|
889
|
+
@app.delete("/api/workspaces/{workspace_id}")
|
|
890
|
+
async def delete_workspace(workspace_id: str) -> JSONResponse:
|
|
922
891
|
try:
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
892
|
+
await registry.delete_workspace(workspace_id)
|
|
893
|
+
except KeyError:
|
|
894
|
+
raise HTTPException(status_code=404, detail="workspace not found")
|
|
895
|
+
return JSONResponse({"ok": True, "workspaces": registry.list_workspaces()})
|
|
896
|
+
|
|
897
|
+
@app.api_route("/w/{workspace_id}", methods=["GET", "HEAD"])
|
|
898
|
+
async def workspace_index_redirect(workspace_id: str) -> RedirectResponse:
|
|
899
|
+
_workspace_entry_or_404(registry, workspace_id)
|
|
900
|
+
return RedirectResponse(url="/w/{0}/".format(workspace_id), status_code=307)
|
|
901
|
+
|
|
902
|
+
@app.api_route("/w/{workspace_id}/", methods=["GET", "HEAD"])
|
|
903
|
+
async def workspace_index(workspace_id: str) -> HTMLResponse:
|
|
904
|
+
entry = _workspace_entry_or_404(registry, workspace_id)
|
|
905
|
+
return _html_response(
|
|
906
|
+
_render_workspace_shell(
|
|
907
|
+
entry.definition.board_path,
|
|
908
|
+
title=entry.definition.workspace_id,
|
|
909
|
+
work_dir=entry.definition.work_dir,
|
|
910
|
+
)
|
|
911
|
+
)
|
|
936
912
|
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
913
|
+
@app.api_route("/w/{workspace_id}/board", methods=["GET", "HEAD"])
|
|
914
|
+
async def workspace_board(workspace_id: str) -> Response:
|
|
915
|
+
entry = _workspace_entry_or_404(registry, workspace_id)
|
|
916
|
+
return _board_response(entry.definition.board_path)
|
|
917
|
+
|
|
918
|
+
@app.get("/w/{workspace_id}/api/board")
|
|
919
|
+
async def workspace_board_status(workspace_id: str) -> JSONResponse:
|
|
920
|
+
entry = _workspace_entry_or_404(registry, workspace_id)
|
|
921
|
+
return _board_status_response(entry.definition.board_path)
|
|
922
|
+
|
|
923
|
+
@app.get("/w/{workspace_id}/ws/session")
|
|
924
|
+
async def workspace_websocket_backend_hint(workspace_id: str) -> JSONResponse:
|
|
925
|
+
_workspace_entry_or_404(registry, workspace_id)
|
|
926
|
+
return _websocket_backend_hint_response()
|
|
927
|
+
|
|
928
|
+
@app.get("/w/{workspace_id}/api/sessions")
|
|
929
|
+
async def workspace_sessions(workspace_id: str) -> JSONResponse:
|
|
930
|
+
entry = _workspace_entry_or_404(registry, workspace_id)
|
|
931
|
+
return _sessions_response(entry.manager)
|
|
932
|
+
|
|
933
|
+
@app.post("/w/{workspace_id}/api/sessions")
|
|
934
|
+
async def workspace_new_session(workspace_id: str) -> JSONResponse:
|
|
935
|
+
entry = _workspace_entry_or_404(registry, workspace_id)
|
|
936
|
+
return await _new_session_response(entry.manager)
|
|
937
|
+
|
|
938
|
+
@app.delete("/w/{workspace_id}/api/sessions/{session_id}")
|
|
939
|
+
async def workspace_delete_session(workspace_id: str, session_id: str) -> JSONResponse:
|
|
940
|
+
entry = _workspace_entry_or_404(registry, workspace_id)
|
|
941
|
+
return await _delete_session_response(entry.manager, session_id)
|
|
942
|
+
|
|
943
|
+
@app.get("/w/{workspace_id}/api/session")
|
|
944
|
+
async def workspace_session(
|
|
945
|
+
workspace_id: str,
|
|
946
|
+
session_id: "typing.Union[str, None]" = None,
|
|
947
|
+
) -> JSONResponse:
|
|
948
|
+
entry = _workspace_entry_or_404(registry, workspace_id)
|
|
949
|
+
return _session_response(entry.manager, session_id)
|
|
950
|
+
|
|
951
|
+
@app.post("/w/{workspace_id}/api/session/message")
|
|
952
|
+
async def workspace_message(
|
|
953
|
+
workspace_id: str,
|
|
954
|
+
payload: "typing.Dict[str, object]",
|
|
955
|
+
) -> JSONResponse:
|
|
956
|
+
entry = _workspace_entry_or_404(registry, workspace_id)
|
|
957
|
+
return await _message_response(entry.manager, payload)
|
|
950
958
|
|
|
951
|
-
|
|
952
|
-
|
|
959
|
+
@app.websocket("/w/{workspace_id}/ws/session")
|
|
960
|
+
async def workspace_websocket_session(workspace_id: str, websocket: WebSocket) -> None:
|
|
961
|
+
if not _auth_cookie_matches(auth_token, websocket.cookies.get(AUTH_COOKIE_NAME)):
|
|
962
|
+
await websocket.close(code=1008)
|
|
963
|
+
return
|
|
953
964
|
try:
|
|
954
|
-
|
|
955
|
-
except KeyError:
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
965
|
+
entry = registry.get(workspace_id)
|
|
966
|
+
except (KeyError, ValueError):
|
|
967
|
+
await websocket.close(code=1008)
|
|
968
|
+
return
|
|
969
|
+
await _websocket_session_handler(entry.manager, websocket)
|
|
970
|
+
|
|
971
|
+
return app
|
|
972
|
+
|
|
973
|
+
|
|
974
|
+
def _install_auth(app: FastAPI, password: "typing.Union[str, None]") -> str:
|
|
975
|
+
password_text = str(password or "")
|
|
976
|
+
if not password_text:
|
|
977
|
+
return ""
|
|
978
|
+
token = secrets.token_urlsafe(32)
|
|
979
|
+
|
|
980
|
+
@app.middleware("http")
|
|
981
|
+
async def auth_middleware(request: Request, call_next):
|
|
982
|
+
path = request.url.path
|
|
983
|
+
if path in {"/favicon.ico", "/login"} or _auth_cookie_matches(
|
|
984
|
+
token,
|
|
985
|
+
request.cookies.get(AUTH_COOKIE_NAME),
|
|
986
|
+
):
|
|
987
|
+
return await call_next(request)
|
|
988
|
+
if path.startswith("/api/") or "/api/" in path:
|
|
989
|
+
return JSONResponse(
|
|
990
|
+
{"ok": False, "error": "authentication required"},
|
|
991
|
+
status_code=401,
|
|
978
992
|
)
|
|
979
|
-
return
|
|
993
|
+
return RedirectResponse(url="/login", status_code=303)
|
|
994
|
+
|
|
995
|
+
@app.get("/login")
|
|
996
|
+
async def login_page() -> HTMLResponse:
|
|
997
|
+
return _html_response(_render_login_shell())
|
|
998
|
+
|
|
999
|
+
@app.post("/login")
|
|
1000
|
+
async def login(payload: "typing.Dict[str, object]") -> JSONResponse:
|
|
1001
|
+
if not secrets.compare_digest(str(payload.get("password") or ""), password_text):
|
|
1002
|
+
return JSONResponse(
|
|
1003
|
+
{"ok": False, "error": "invalid password"},
|
|
1004
|
+
status_code=401,
|
|
1005
|
+
)
|
|
1006
|
+
response = JSONResponse({"ok": True})
|
|
1007
|
+
response.set_cookie(
|
|
1008
|
+
AUTH_COOKIE_NAME,
|
|
1009
|
+
token,
|
|
1010
|
+
httponly=True,
|
|
1011
|
+
samesite="lax",
|
|
1012
|
+
)
|
|
1013
|
+
return response
|
|
980
1014
|
|
|
1015
|
+
return token
|
|
981
1016
|
|
|
982
|
-
def create_app(
|
|
983
|
-
session_source: "typing.Union[WorkspaceSessionManager, SessionFactory]",
|
|
984
|
-
board_path: "typing.Union[Path, None]",
|
|
985
|
-
) -> FastAPI:
|
|
986
|
-
manager = (
|
|
987
|
-
session_source
|
|
988
|
-
if isinstance(session_source, WorkspaceSessionManager)
|
|
989
|
-
else WorkspaceSessionManager(session_source, board_path)
|
|
990
|
-
)
|
|
991
1017
|
|
|
1018
|
+
def _auth_cookie_matches(token: str, cookie: "typing.Union[str, None]") -> bool:
|
|
1019
|
+
if not token:
|
|
1020
|
+
return True
|
|
1021
|
+
return bool(cookie) and secrets.compare_digest(str(cookie), token)
|
|
1022
|
+
|
|
1023
|
+
|
|
1024
|
+
def _render_login_shell() -> str:
|
|
1025
|
+
return """<!doctype html>
|
|
1026
|
+
<html>
|
|
1027
|
+
<head>
|
|
1028
|
+
<meta charset="utf-8">
|
|
1029
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
1030
|
+
<title>pycodex login</title>
|
|
1031
|
+
<style>
|
|
1032
|
+
:root {
|
|
1033
|
+
color-scheme: light dark;
|
|
1034
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
1035
|
+
}
|
|
1036
|
+
body {
|
|
1037
|
+
min-height: 100vh;
|
|
1038
|
+
margin: 0;
|
|
1039
|
+
display: grid;
|
|
1040
|
+
place-items: center;
|
|
1041
|
+
background: Canvas;
|
|
1042
|
+
color: CanvasText;
|
|
1043
|
+
}
|
|
1044
|
+
form {
|
|
1045
|
+
width: min(360px, calc(100vw - 32px));
|
|
1046
|
+
display: grid;
|
|
1047
|
+
gap: 12px;
|
|
1048
|
+
}
|
|
1049
|
+
h1 {
|
|
1050
|
+
margin: 0;
|
|
1051
|
+
font-size: 22px;
|
|
1052
|
+
}
|
|
1053
|
+
input, button {
|
|
1054
|
+
min-height: 38px;
|
|
1055
|
+
border-radius: 7px;
|
|
1056
|
+
border: 1px solid color-mix(in srgb, CanvasText 18%, Canvas 82%);
|
|
1057
|
+
padding: 8px 10px;
|
|
1058
|
+
font: inherit;
|
|
1059
|
+
}
|
|
1060
|
+
button {
|
|
1061
|
+
cursor: pointer;
|
|
1062
|
+
}
|
|
1063
|
+
.status {
|
|
1064
|
+
min-height: 20px;
|
|
1065
|
+
color: #b42318;
|
|
1066
|
+
font-size: 13px;
|
|
1067
|
+
}
|
|
1068
|
+
</style>
|
|
1069
|
+
</head>
|
|
1070
|
+
<body>
|
|
1071
|
+
<form id="loginForm">
|
|
1072
|
+
<h1>pycodex workspace</h1>
|
|
1073
|
+
<input id="passwordInput" type="password" autocomplete="current-password" autofocus>
|
|
1074
|
+
<button type="submit">Open</button>
|
|
1075
|
+
<div id="status" class="status" role="status"></div>
|
|
1076
|
+
</form>
|
|
1077
|
+
<script>
|
|
1078
|
+
const form = document.getElementById("loginForm");
|
|
1079
|
+
const passwordInput = document.getElementById("passwordInput");
|
|
1080
|
+
const statusEl = document.getElementById("status");
|
|
1081
|
+
form.addEventListener("submit", async function(event) {
|
|
1082
|
+
event.preventDefault();
|
|
1083
|
+
statusEl.textContent = "";
|
|
1084
|
+
const response = await fetch("login", {
|
|
1085
|
+
method: "POST",
|
|
1086
|
+
headers: {"Content-Type": "application/json"},
|
|
1087
|
+
body: JSON.stringify({password: passwordInput.value}),
|
|
1088
|
+
});
|
|
1089
|
+
if (response.ok) {
|
|
1090
|
+
window.location.href = "/";
|
|
1091
|
+
return;
|
|
1092
|
+
}
|
|
1093
|
+
statusEl.textContent = "Invalid password";
|
|
1094
|
+
});
|
|
1095
|
+
</script>
|
|
1096
|
+
</body>
|
|
1097
|
+
</html>"""
|
|
1098
|
+
|
|
1099
|
+
|
|
1100
|
+
def _create_lifespan_app(
|
|
1101
|
+
start: "typing.Callable[[], typing.Awaitable[None]]",
|
|
1102
|
+
close: "typing.Callable[[], typing.Awaitable[None]]",
|
|
1103
|
+
) -> FastAPI:
|
|
992
1104
|
if asynccontextmanager is not None:
|
|
993
1105
|
@asynccontextmanager
|
|
994
1106
|
async def lifespan(_app):
|
|
995
|
-
await
|
|
1107
|
+
await start()
|
|
996
1108
|
try:
|
|
997
1109
|
yield
|
|
998
1110
|
finally:
|
|
999
|
-
await
|
|
1111
|
+
await close()
|
|
1112
|
+
|
|
1113
|
+
return FastAPI(lifespan=lifespan)
|
|
1114
|
+
|
|
1115
|
+
app = FastAPI()
|
|
1000
1116
|
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1117
|
+
@app.on_event("startup")
|
|
1118
|
+
async def startup() -> None:
|
|
1119
|
+
await start()
|
|
1004
1120
|
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1121
|
+
@app.on_event("shutdown")
|
|
1122
|
+
async def shutdown() -> None:
|
|
1123
|
+
await close()
|
|
1124
|
+
return app
|
|
1008
1125
|
|
|
1009
|
-
@app.on_event("shutdown")
|
|
1010
|
-
async def shutdown() -> None:
|
|
1011
|
-
await manager.close()
|
|
1012
1126
|
|
|
1127
|
+
def _install_workspace_routes(
|
|
1128
|
+
app: FastAPI,
|
|
1129
|
+
manager: WorkspaceSessionManager,
|
|
1130
|
+
board_path: "typing.Union[Path, None]",
|
|
1131
|
+
) -> None:
|
|
1013
1132
|
@app.get("/")
|
|
1014
1133
|
async def index() -> HTMLResponse:
|
|
1015
1134
|
return _html_response(_render_workspace_shell(board_path))
|
|
@@ -1022,163 +1141,189 @@ def create_app(
|
|
|
1022
1141
|
async def board() -> Response:
|
|
1023
1142
|
return _board_response(board_path)
|
|
1024
1143
|
|
|
1025
|
-
@app.api_route("/{path_name}", methods=["GET", "HEAD"])
|
|
1026
|
-
async def legacy_board_path(path_name: str) -> Response:
|
|
1027
|
-
if board_path is not None and path_name == board_path.name:
|
|
1028
|
-
return _board_response(board_path)
|
|
1029
|
-
raise HTTPException(status_code=404, detail="not found")
|
|
1030
|
-
|
|
1031
1144
|
@app.get("/api/board")
|
|
1032
1145
|
async def board_status() -> JSONResponse:
|
|
1033
|
-
|
|
1034
|
-
return JSONResponse({"exists": False})
|
|
1035
|
-
stat = board_path.stat()
|
|
1036
|
-
return JSONResponse(
|
|
1037
|
-
{
|
|
1038
|
-
"exists": True,
|
|
1039
|
-
"path": str(board_path),
|
|
1040
|
-
"mtime_ns": stat.st_mtime_ns,
|
|
1041
|
-
"size": stat.st_size,
|
|
1042
|
-
}
|
|
1043
|
-
)
|
|
1146
|
+
return _board_status_response(board_path)
|
|
1044
1147
|
|
|
1045
1148
|
@app.get("/ws/session")
|
|
1046
1149
|
async def websocket_backend_hint() -> JSONResponse:
|
|
1047
|
-
return
|
|
1048
|
-
{
|
|
1049
|
-
"error": "websocket backend is unavailable; HTTP polling is active",
|
|
1050
|
-
},
|
|
1051
|
-
status_code=426,
|
|
1052
|
-
)
|
|
1053
|
-
|
|
1054
|
-
def _board_response(board_path: "typing.Union[Path, None]") -> Response:
|
|
1055
|
-
if board_path is None:
|
|
1056
|
-
return _html_response(_render_empty_board())
|
|
1057
|
-
if not board_path.is_file():
|
|
1058
|
-
return _html_response(_render_missing_board(board_path))
|
|
1059
|
-
return _html_response(board_path.read_text(encoding="utf-8", errors="replace"))
|
|
1150
|
+
return _websocket_backend_hint_response()
|
|
1060
1151
|
|
|
1061
1152
|
@app.get("/api/sessions")
|
|
1062
1153
|
async def sessions() -> JSONResponse:
|
|
1063
|
-
return
|
|
1154
|
+
return _sessions_response(manager)
|
|
1064
1155
|
|
|
1065
1156
|
@app.post("/api/sessions")
|
|
1066
1157
|
async def new_session() -> JSONResponse:
|
|
1067
|
-
|
|
1068
|
-
return JSONResponse(
|
|
1069
|
-
{
|
|
1070
|
-
"ok": True,
|
|
1071
|
-
"session_id": session_id,
|
|
1072
|
-
"sessions": manager.list_sessions(),
|
|
1073
|
-
"snapshot": _session_snapshot(manager.get(session_id)),
|
|
1074
|
-
}
|
|
1075
|
-
)
|
|
1158
|
+
return await _new_session_response(manager)
|
|
1076
1159
|
|
|
1077
1160
|
@app.delete("/api/sessions/{session_id}")
|
|
1078
1161
|
async def delete_session(session_id: str) -> JSONResponse:
|
|
1079
|
-
|
|
1080
|
-
await manager.close_session(session_id)
|
|
1081
|
-
except KeyError:
|
|
1082
|
-
raise HTTPException(status_code=404, detail="session not found")
|
|
1083
|
-
except ValueError as exc:
|
|
1084
|
-
return JSONResponse({"ok": False, "error": str(exc)}, status_code=400)
|
|
1085
|
-
return JSONResponse({"ok": True, "sessions": manager.list_sessions()})
|
|
1162
|
+
return await _delete_session_response(manager, session_id)
|
|
1086
1163
|
|
|
1087
1164
|
@app.get("/api/session")
|
|
1088
1165
|
async def session(
|
|
1089
1166
|
session_id: "typing.Union[str, None]" = None,
|
|
1090
1167
|
) -> JSONResponse:
|
|
1091
|
-
|
|
1092
|
-
resolved_id = manager.resolve_session_id(session_id)
|
|
1093
|
-
link = manager.get(resolved_id)
|
|
1094
|
-
except KeyError:
|
|
1095
|
-
raise HTTPException(status_code=404, detail="session not found")
|
|
1096
|
-
return JSONResponse(
|
|
1097
|
-
{
|
|
1098
|
-
"session_id": resolved_id,
|
|
1099
|
-
"sessions": manager.list_sessions(),
|
|
1100
|
-
"snapshot": _session_snapshot(link),
|
|
1101
|
-
}
|
|
1102
|
-
)
|
|
1168
|
+
return _session_response(manager, session_id)
|
|
1103
1169
|
|
|
1104
1170
|
@app.post("/api/session/message")
|
|
1105
1171
|
async def message(
|
|
1106
1172
|
payload: "typing.Dict[str, object]",
|
|
1107
1173
|
) -> JSONResponse:
|
|
1108
|
-
|
|
1109
|
-
try:
|
|
1110
|
-
link = manager.get(session_id or None)
|
|
1111
|
-
except KeyError:
|
|
1112
|
-
raise HTTPException(status_code=404, detail="session not found")
|
|
1113
|
-
result = await link.submit(str(payload.get("prompt") or ""))
|
|
1114
|
-
if isinstance(result, dict):
|
|
1115
|
-
result.setdefault("sessions", manager.list_sessions())
|
|
1116
|
-
status = 200 if result.get("ok") else 400
|
|
1117
|
-
return JSONResponse(result, status_code=status)
|
|
1174
|
+
return await _message_response(manager, payload)
|
|
1118
1175
|
|
|
1119
1176
|
@app.websocket("/ws/session")
|
|
1120
1177
|
async def websocket_session(websocket: WebSocket) -> None:
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
try:
|
|
1124
|
-
link = manager.get(session_id or None)
|
|
1125
|
-
except KeyError:
|
|
1178
|
+
auth_token = typing.cast(str, app.state.workspace_auth_token)
|
|
1179
|
+
if not _auth_cookie_matches(auth_token, websocket.cookies.get(AUTH_COOKIE_NAME)):
|
|
1126
1180
|
await websocket.close(code=1008)
|
|
1127
1181
|
return
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
except KeyError:
|
|
1144
|
-
await websocket.send_json(
|
|
1145
|
-
{"type": "error", "error": "session not found"}
|
|
1146
|
-
)
|
|
1147
|
-
continue
|
|
1148
|
-
result = await target_link.submit(
|
|
1149
|
-
str(payload.get("prompt") or ""),
|
|
1150
|
-
sender=str(payload.get("sender") or "web"),
|
|
1151
|
-
)
|
|
1152
|
-
await websocket.send_json({"type": "send_result", "result": result})
|
|
1153
|
-
elif action == "ping":
|
|
1154
|
-
await websocket.send_json({"type": "pong"})
|
|
1155
|
-
else:
|
|
1156
|
-
await websocket.send_json({"type": "error", "error": "unknown action"})
|
|
1157
|
-
except WebSocketDisconnect:
|
|
1158
|
-
pass
|
|
1159
|
-
finally:
|
|
1160
|
-
link.unsubscribe(subscriber)
|
|
1161
|
-
sender.cancel()
|
|
1162
|
-
await asyncio.gather(sender, return_exceptions=True)
|
|
1182
|
+
await _websocket_session_handler(manager, websocket)
|
|
1183
|
+
|
|
1184
|
+
|
|
1185
|
+
def _board_status_response(board_path: "typing.Union[Path, None]") -> JSONResponse:
|
|
1186
|
+
if board_path is None or not board_path.is_file():
|
|
1187
|
+
return JSONResponse({"exists": False})
|
|
1188
|
+
stat = board_path.stat()
|
|
1189
|
+
return JSONResponse(
|
|
1190
|
+
{
|
|
1191
|
+
"exists": True,
|
|
1192
|
+
"path": str(board_path),
|
|
1193
|
+
"mtime_ns": stat.st_mtime_ns,
|
|
1194
|
+
"size": stat.st_size,
|
|
1195
|
+
}
|
|
1196
|
+
)
|
|
1163
1197
|
|
|
1164
|
-
return app
|
|
1165
1198
|
|
|
1199
|
+
def _websocket_backend_hint_response() -> JSONResponse:
|
|
1200
|
+
return JSONResponse(
|
|
1201
|
+
{
|
|
1202
|
+
"error": "websocket backend is unavailable; HTTP polling is active",
|
|
1203
|
+
},
|
|
1204
|
+
status_code=426,
|
|
1205
|
+
)
|
|
1166
1206
|
|
|
1167
|
-
def _session_snapshot(session) -> "typing.Dict[str, object]":
|
|
1168
|
-
return typing.cast("typing.Dict[str, object]", session.snapshot())
|
|
1169
1207
|
|
|
1208
|
+
def _sessions_response(manager: 'WorkspaceSessionManager') -> JSONResponse:
|
|
1209
|
+
return JSONResponse({"sessions": manager.list_sessions()})
|
|
1170
1210
|
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1211
|
+
|
|
1212
|
+
async def _new_session_response(manager: 'WorkspaceSessionManager') -> JSONResponse:
|
|
1213
|
+
session_id = await manager.create_session()
|
|
1214
|
+
return JSONResponse(
|
|
1215
|
+
{
|
|
1216
|
+
"ok": True,
|
|
1217
|
+
"session_id": session_id,
|
|
1218
|
+
"sessions": manager.list_sessions(),
|
|
1219
|
+
"snapshot": session_snapshot(manager.get(session_id)),
|
|
1220
|
+
}
|
|
1221
|
+
)
|
|
1222
|
+
|
|
1223
|
+
|
|
1224
|
+
async def _delete_session_response(
|
|
1225
|
+
manager: 'WorkspaceSessionManager',
|
|
1226
|
+
session_id: str,
|
|
1227
|
+
) -> JSONResponse:
|
|
1228
|
+
try:
|
|
1229
|
+
await manager.close_session(session_id)
|
|
1230
|
+
except KeyError:
|
|
1231
|
+
raise HTTPException(status_code=404, detail="session not found")
|
|
1232
|
+
except ValueError as exc:
|
|
1233
|
+
return JSONResponse({"ok": False, "error": str(exc)}, status_code=400)
|
|
1234
|
+
return JSONResponse({"ok": True, "sessions": manager.list_sessions()})
|
|
1235
|
+
|
|
1236
|
+
|
|
1237
|
+
def _session_response(
|
|
1238
|
+
manager: 'WorkspaceSessionManager',
|
|
1239
|
+
session_id: "typing.Union[str, None]" = None,
|
|
1240
|
+
) -> JSONResponse:
|
|
1241
|
+
try:
|
|
1242
|
+
resolved_id = manager.resolve_session_id(session_id)
|
|
1243
|
+
link = manager.get(resolved_id)
|
|
1244
|
+
except KeyError:
|
|
1245
|
+
raise HTTPException(status_code=404, detail="session not found")
|
|
1246
|
+
return JSONResponse(
|
|
1247
|
+
{
|
|
1248
|
+
"session_id": resolved_id,
|
|
1249
|
+
"sessions": manager.list_sessions(),
|
|
1250
|
+
"snapshot": session_snapshot(link),
|
|
1251
|
+
}
|
|
1252
|
+
)
|
|
1253
|
+
|
|
1254
|
+
|
|
1255
|
+
async def _message_response(
|
|
1256
|
+
manager: 'WorkspaceSessionManager',
|
|
1257
|
+
payload: "typing.Dict[str, object]",
|
|
1258
|
+
) -> JSONResponse:
|
|
1259
|
+
session_id = str(payload.get("session_id") or "")
|
|
1260
|
+
try:
|
|
1261
|
+
link = manager.get(session_id or None)
|
|
1262
|
+
except KeyError:
|
|
1263
|
+
raise HTTPException(status_code=404, detail="session not found")
|
|
1264
|
+
result = await link.submit(str(payload.get("prompt") or ""))
|
|
1265
|
+
if isinstance(result, dict):
|
|
1266
|
+
result.setdefault("sessions", manager.list_sessions())
|
|
1267
|
+
status = 200 if result.get("ok") else 400
|
|
1268
|
+
return JSONResponse(result, status_code=status)
|
|
1269
|
+
|
|
1270
|
+
|
|
1271
|
+
async def _websocket_session_handler(
|
|
1272
|
+
manager: 'WorkspaceSessionManager',
|
|
1273
|
+
websocket: WebSocket,
|
|
1274
|
+
) -> None:
|
|
1275
|
+
await websocket.accept()
|
|
1276
|
+
session_id = str(websocket.query_params.get("session_id") or "")
|
|
1277
|
+
try:
|
|
1278
|
+
link = manager.get(session_id or None)
|
|
1279
|
+
except KeyError:
|
|
1280
|
+
await websocket.close(code=1008)
|
|
1281
|
+
return
|
|
1282
|
+
subscriber = link.subscribe()
|
|
1283
|
+
sender = asyncio.create_task(_send_ws_events(websocket, subscriber))
|
|
1284
|
+
try:
|
|
1285
|
+
while True:
|
|
1286
|
+
data = await websocket.receive_text()
|
|
1287
|
+
try:
|
|
1288
|
+
payload = json.loads(data)
|
|
1289
|
+
except ValueError:
|
|
1290
|
+
await websocket.send_json({"type": "error", "error": "invalid json"})
|
|
1291
|
+
continue
|
|
1292
|
+
action = str(payload.get("type") or payload.get("action") or "")
|
|
1293
|
+
if action == "send":
|
|
1294
|
+
target_session_id = str(payload.get("session_id") or session_id or "")
|
|
1295
|
+
try:
|
|
1296
|
+
target_link = manager.get(target_session_id or None)
|
|
1297
|
+
except KeyError:
|
|
1298
|
+
await websocket.send_json(
|
|
1299
|
+
{"type": "error", "error": "session not found"}
|
|
1300
|
+
)
|
|
1301
|
+
continue
|
|
1302
|
+
result = await target_link.submit(
|
|
1303
|
+
str(payload.get("prompt") or ""),
|
|
1304
|
+
sender=str(payload.get("sender") or "web"),
|
|
1305
|
+
)
|
|
1306
|
+
await websocket.send_json({"type": "send_result", "result": result})
|
|
1307
|
+
elif action == "ping":
|
|
1308
|
+
await websocket.send_json({"type": "pong"})
|
|
1309
|
+
else:
|
|
1310
|
+
await websocket.send_json({"type": "error", "error": "unknown action"})
|
|
1311
|
+
except WebSocketDisconnect:
|
|
1312
|
+
pass
|
|
1313
|
+
finally:
|
|
1314
|
+
link.unsubscribe(subscriber)
|
|
1315
|
+
sender.cancel()
|
|
1316
|
+
await asyncio.gather(sender, return_exceptions=True)
|
|
1317
|
+
|
|
1318
|
+
|
|
1319
|
+
def _last_assistant_text(turns: "typing.Iterable[typing.Dict[str, object]]") -> str:
|
|
1320
|
+
for turn in reversed(list(turns)):
|
|
1321
|
+
if str(turn.get("kind") or "assistant") == "control":
|
|
1322
|
+
continue
|
|
1323
|
+
response = str(turn.get("response") or "").strip()
|
|
1324
|
+
if response:
|
|
1325
|
+
return response
|
|
1326
|
+
return ""
|
|
1182
1327
|
|
|
1183
1328
|
|
|
1184
1329
|
def _public_turn(turn: "typing.Dict[str, object]") -> "typing.Dict[str, object]":
|
|
@@ -1235,15 +1380,70 @@ async def _send_ws_events(websocket: WebSocket, subscriber: "asyncio.Queue") ->
|
|
|
1235
1380
|
await websocket.send_json(event)
|
|
1236
1381
|
|
|
1237
1382
|
|
|
1383
|
+
def _board_response(board_path: "typing.Union[Path, None]") -> Response:
|
|
1384
|
+
if board_path is None:
|
|
1385
|
+
return _html_response(_render_empty_board())
|
|
1386
|
+
if not board_path.is_file():
|
|
1387
|
+
return _html_response(_render_missing_board(board_path))
|
|
1388
|
+
return _html_response(board_path.read_text(encoding="utf-8", errors="replace"))
|
|
1389
|
+
|
|
1390
|
+
|
|
1391
|
+
def _workspace_entry_or_404(
|
|
1392
|
+
registry: 'WorkspaceRegistry',
|
|
1393
|
+
workspace_id: str,
|
|
1394
|
+
) -> 'WorkspaceEntry':
|
|
1395
|
+
try:
|
|
1396
|
+
return registry.get(workspace_id)
|
|
1397
|
+
except (KeyError, ValueError):
|
|
1398
|
+
raise HTTPException(status_code=404, detail="workspace not found")
|
|
1399
|
+
|
|
1400
|
+
|
|
1238
1401
|
def run_serve_cli(args: "argparse.Namespace") -> int:
|
|
1239
1402
|
import uvicorn
|
|
1240
1403
|
|
|
1241
|
-
|
|
1242
|
-
host, port, board_path = parse_target(args.listen, board_arg)
|
|
1243
|
-
if board_path is not None and not board_path.parent.is_dir():
|
|
1244
|
-
raise ValueError("board parent directory does not exist: {0}".format(board_path.parent))
|
|
1245
|
-
|
|
1404
|
+
host, port = parse_listen(args.listen)
|
|
1246
1405
|
configure_loguru()
|
|
1406
|
+
|
|
1407
|
+
definitions = load_workspace_definitions(args.workspace_config)
|
|
1408
|
+
entries = [
|
|
1409
|
+
_build_workspace_entry(definition, args)
|
|
1410
|
+
for definition in definitions
|
|
1411
|
+
]
|
|
1412
|
+
registry = WorkspaceRegistry(
|
|
1413
|
+
entries,
|
|
1414
|
+
config_path=args.workspace_config,
|
|
1415
|
+
entry_factory=lambda definition, persist_callback: _build_workspace_entry(
|
|
1416
|
+
definition,
|
|
1417
|
+
args,
|
|
1418
|
+
persist_callback,
|
|
1419
|
+
),
|
|
1420
|
+
)
|
|
1421
|
+
app = create_multi_workspace_app(registry, password=args.password)
|
|
1422
|
+
|
|
1423
|
+
print(
|
|
1424
|
+
"pycodex workspace listening on http://{0}:{1}".format(host, port),
|
|
1425
|
+
flush=True,
|
|
1426
|
+
)
|
|
1427
|
+
for definition in definitions:
|
|
1428
|
+
print(
|
|
1429
|
+
"workspace {0}: board={1} work_dir={2} url=http://{3}:{4}/w/{0}/".format(
|
|
1430
|
+
definition.workspace_id,
|
|
1431
|
+
definition.board_path or "",
|
|
1432
|
+
definition.work_dir,
|
|
1433
|
+
host,
|
|
1434
|
+
port,
|
|
1435
|
+
),
|
|
1436
|
+
flush=True,
|
|
1437
|
+
)
|
|
1438
|
+
uvicorn.run(app, host=host, port=port, loop="asyncio")
|
|
1439
|
+
return 0
|
|
1440
|
+
|
|
1441
|
+
|
|
1442
|
+
def _build_workspace_entry(
|
|
1443
|
+
definition: 'WorkspaceDefinition',
|
|
1444
|
+
args: "argparse.Namespace",
|
|
1445
|
+
persist_callback: "typing.Union[typing.Callable[[], None], None]" = None,
|
|
1446
|
+
) -> 'WorkspaceEntry':
|
|
1247
1447
|
def build_session() -> "WorkspaceInteractiveSession":
|
|
1248
1448
|
model = build_model(
|
|
1249
1449
|
config_path=args.config,
|
|
@@ -1260,8 +1460,11 @@ def run_serve_cli(args: "argparse.Namespace") -> int:
|
|
|
1260
1460
|
system_prompt=args.system_prompt,
|
|
1261
1461
|
session_mode="tui",
|
|
1262
1462
|
extra_contextual_user_messages=(
|
|
1263
|
-
[_board_context_text(board_path)]
|
|
1463
|
+
[_board_context_text(definition.board_path, definition.work_dir)]
|
|
1464
|
+
if definition.board_path is not None
|
|
1465
|
+
else []
|
|
1264
1466
|
),
|
|
1467
|
+
cwd=definition.work_dir,
|
|
1265
1468
|
)
|
|
1266
1469
|
return WorkspaceInteractiveSession(
|
|
1267
1470
|
build_cli_queue(agent),
|
|
@@ -1271,33 +1474,37 @@ def run_serve_cli(args: "argparse.Namespace") -> int:
|
|
|
1271
1474
|
def session_factory() -> "ThreadedWorkspaceInteractiveSession":
|
|
1272
1475
|
return ThreadedWorkspaceInteractiveSession(build_session, asyncio.get_running_loop())
|
|
1273
1476
|
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1477
|
+
return WorkspaceEntry(
|
|
1478
|
+
definition=definition,
|
|
1479
|
+
manager=WorkspaceSessionManager(
|
|
1480
|
+
session_factory,
|
|
1481
|
+
definition.board_path,
|
|
1482
|
+
persist_callback=persist_callback,
|
|
1483
|
+
),
|
|
1278
1484
|
)
|
|
1279
|
-
if board_path is not None:
|
|
1280
|
-
print("board: {0}".format(board_path), flush=True)
|
|
1281
|
-
uvicorn.run(app, host=host, port=port, loop="asyncio")
|
|
1282
|
-
return 0
|
|
1283
1485
|
|
|
1284
1486
|
|
|
1285
|
-
def _board_context_text(
|
|
1487
|
+
def _board_context_text(
|
|
1488
|
+
board_path: Path,
|
|
1489
|
+
work_dir: "typing.Union[Path, None]" = None,
|
|
1490
|
+
) -> str:
|
|
1286
1491
|
return (
|
|
1287
1492
|
"Current workspace board file: {0}. "
|
|
1288
1493
|
"Changes you make to this file are shown to the user in real time. "
|
|
1289
1494
|
"You can create or modify this file anytime."
|
|
1290
|
-
).format(_format_board_path_for_prompt(board_path))
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
def _default_board_path() -> Path:
|
|
1294
|
-
return Path(tempfile.gettempdir()) / "pcws-{0}.html".format(uuid4().hex[:8])
|
|
1495
|
+
).format(_format_board_path_for_prompt(board_path, work_dir=work_dir))
|
|
1295
1496
|
|
|
1296
1497
|
|
|
1297
|
-
def _format_board_path_for_prompt(
|
|
1498
|
+
def _format_board_path_for_prompt(
|
|
1499
|
+
board_path: Path,
|
|
1500
|
+
work_dir: "typing.Union[Path, None]" = None,
|
|
1501
|
+
) -> str:
|
|
1298
1502
|
resolved = board_path.resolve()
|
|
1299
1503
|
try:
|
|
1300
|
-
relative = os.path.relpath(
|
|
1504
|
+
relative = os.path.relpath(
|
|
1505
|
+
str(resolved),
|
|
1506
|
+
str(Path(work_dir or Path.cwd()).resolve()),
|
|
1507
|
+
)
|
|
1301
1508
|
except ValueError:
|
|
1302
1509
|
return str(resolved)
|
|
1303
1510
|
if relative == ".":
|
|
@@ -1307,20 +1514,35 @@ def _format_board_path_for_prompt(board_path: Path) -> str:
|
|
|
1307
1514
|
return "./{0}".format(relative)
|
|
1308
1515
|
|
|
1309
1516
|
|
|
1310
|
-
def _render_workspace_shell(
|
|
1517
|
+
def _render_workspace_shell(
|
|
1518
|
+
board_path: "typing.Union[Path, None]",
|
|
1519
|
+
title: "typing.Union[str, None]" = None,
|
|
1520
|
+
work_dir: "typing.Union[Path, None]" = None,
|
|
1521
|
+
) -> str:
|
|
1311
1522
|
board_label = str(board_path) if board_path is not None else "No board"
|
|
1523
|
+
cwd_label = str(work_dir or Path.cwd())
|
|
1524
|
+
page_title = str(title or "pycodex workspace")
|
|
1312
1525
|
template = (Path(__file__).with_name("workspace.html")).read_text(
|
|
1313
1526
|
encoding="utf-8"
|
|
1314
1527
|
)
|
|
1315
|
-
return
|
|
1528
|
+
return (
|
|
1529
|
+
template
|
|
1530
|
+
.replace("__WORKSPACE_TITLE__", html.escape(page_title))
|
|
1531
|
+
.replace("__BOARD_LABEL__", html.escape(board_label))
|
|
1532
|
+
.replace("__CWD_LABEL__", html.escape(cwd_label))
|
|
1533
|
+
)
|
|
1534
|
+
|
|
1535
|
+
|
|
1536
|
+
def _render_workspaces_manager_shell() -> str:
|
|
1537
|
+
return (Path(__file__).with_name("workspaces.html")).read_text(encoding="utf-8")
|
|
1316
1538
|
|
|
1317
1539
|
|
|
1318
1540
|
def _render_empty_board() -> str:
|
|
1319
1541
|
return """<!doctype html>
|
|
1320
1542
|
<html><head><meta charset="utf-8"><title>No board</title></head>
|
|
1321
1543
|
<body style="font:14px -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;padding:24px">
|
|
1322
|
-
<h1>No board
|
|
1323
|
-
<p>
|
|
1544
|
+
<h1>No board</h1>
|
|
1545
|
+
<p>Add a workspace from the workspaces manager page.</p>
|
|
1324
1546
|
</body></html>"""
|
|
1325
1547
|
|
|
1326
1548
|
|