python-codex 0.2.6__py3-none-any.whl → 0.3.0__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/__init__.py +18 -14
- pycodex/agent.py +468 -462
- pycodex/bootstrap.py +417 -0
- pycodex/cli.py +236 -436
- pycodex/compat.py +19 -5
- pycodex/context.py +222 -212
- pycodex/doctor.py +52 -48
- pycodex/events.py +857 -0
- pycodex/feishu_card.py +217 -163
- pycodex/feishu_link.py +43 -83
- pycodex/model.py +329 -252
- pycodex/model_metadata.py +19 -7
- pycodex/portable.py +90 -52
- pycodex/portable_server.py +32 -24
- pycodex/prompts/models.json +235 -803
- pycodex/protocol.py +177 -137
- pycodex/runtime.py +579 -174
- pycodex/runtime_services.py +204 -157
- pycodex/tools/__init__.py +4 -1
- pycodex/tools/apply_patch_tool.py +69 -48
- pycodex/tools/base_tool.py +89 -42
- pycodex/tools/clock_tool.py +201 -0
- pycodex/tools/close_agent_tool.py +2 -2
- pycodex/tools/code_mode_manager.py +77 -64
- pycodex/tools/exec_command_tool.py +26 -11
- pycodex/tools/exec_tool.py +4 -4
- pycodex/tools/grep_files_tool.py +12 -10
- pycodex/tools/ipython_tool.py +10 -13
- pycodex/tools/list_dir_tool.py +13 -9
- pycodex/tools/read_file_tool.py +29 -17
- pycodex/tools/request_permissions_tool.py +15 -5
- pycodex/tools/request_user_input_tool.py +13 -104
- pycodex/tools/resume_agent_tool.py +2 -2
- pycodex/tools/send_input_tool.py +11 -8
- pycodex/tools/shell_command_tool.py +7 -5
- pycodex/tools/shell_tool.py +7 -5
- pycodex/tools/spawn_agent_tool.py +7 -4
- pycodex/tools/unified_exec_manager.py +102 -69
- pycodex/tools/update_plan_tool.py +8 -5
- pycodex/tools/view_image_tool.py +13 -13
- pycodex/tools/wait_agent_tool.py +27 -4
- pycodex/tools/wait_tool.py +5 -4
- pycodex/tools/web_search_tool.py +4 -2
- pycodex/tools/write_stdin_tool.py +12 -11
- pycodex/utils/__init__.py +2 -17
- pycodex/utils/compactor.py +50 -66
- pycodex/utils/debug.py +2 -2
- pycodex/utils/dotenv.py +6 -7
- pycodex/utils/event_helpers.py +190 -0
- pycodex/utils/get_env.py +27 -70
- pycodex/utils/image_utils.py +76 -0
- pycodex/utils/random_ids.py +1 -2
- pycodex/utils/session_persist.py +263 -161
- pycodex/utils/truncation.py +21 -45
- python_codex-0.3.0.dist-info/METADATA +704 -0
- python_codex-0.3.0.dist-info/RECORD +90 -0
- responses_server/__init__.py +1 -5
- responses_server/__main__.py +0 -1
- responses_server/app.py +36 -31
- responses_server/config.py +25 -22
- responses_server/messages_api.py +96 -49
- responses_server/payload_processors.py +25 -19
- responses_server/server.py +11 -11
- responses_server/session_store.py +14 -11
- responses_server/stream_router.py +196 -107
- responses_server/tools/custom_adapter.py +17 -16
- responses_server/tools/web_search.py +39 -36
- responses_server/trajectory_dump.py +51 -13
- workspace_server/__main__.py +0 -1
- workspace_server/app.py +470 -384
- workspace_server/workspace.html +859 -232
- workspace_server/workspaces.html +94 -95
- workspace_server/workspaces.py +168 -100
- pycodex/collaboration.py +0 -20
- pycodex/interactive_session.py +0 -415
- pycodex/prompts/collaboration_default.md +0 -11
- pycodex/prompts/collaboration_plan.md +0 -128
- pycodex/utils/toolcall_visualize.py +0 -713
- pycodex/utils/visualize.py +0 -553
- python_codex-0.2.6.dist-info/METADATA +0 -441
- python_codex-0.2.6.dist-info/RECORD +0 -91
- {python_codex-0.2.6.dist-info → python_codex-0.3.0.dist-info}/WHEEL +0 -0
- {python_codex-0.2.6.dist-info → python_codex-0.3.0.dist-info}/entry_points.txt +0 -0
- {python_codex-0.2.6.dist-info → python_codex-0.3.0.dist-info}/licenses/LICENSE +0 -0
workspace_server/app.py
CHANGED
|
@@ -6,12 +6,15 @@ import mimetypes
|
|
|
6
6
|
import os
|
|
7
7
|
import secrets
|
|
8
8
|
import threading
|
|
9
|
-
from dataclasses import asdict, is_dataclass
|
|
9
|
+
from dataclasses import asdict, fields, is_dataclass
|
|
10
|
+
|
|
10
11
|
try:
|
|
11
12
|
from contextlib import asynccontextmanager
|
|
12
13
|
except ImportError: # pragma: no cover - Python 3.6 compatibility
|
|
13
14
|
asynccontextmanager = None
|
|
15
|
+
import typing
|
|
14
16
|
from pathlib import Path
|
|
17
|
+
from urllib.parse import urlencode
|
|
15
18
|
|
|
16
19
|
from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect
|
|
17
20
|
from fastapi.responses import (
|
|
@@ -22,21 +25,43 @@ from fastapi.responses import (
|
|
|
22
25
|
Response,
|
|
23
26
|
)
|
|
24
27
|
|
|
25
|
-
from pycodex.
|
|
26
|
-
from pycodex.
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
28
|
+
from pycodex.bootstrap import build_agent, build_model, build_runtime, configure_loguru
|
|
29
|
+
from pycodex.events import (
|
|
30
|
+
IDLE_SLEEPING_STATUS,
|
|
31
|
+
AssistantDeltaEvent,
|
|
32
|
+
AutoCompactCompletedEvent,
|
|
33
|
+
AutoCompactFailedEvent,
|
|
34
|
+
AutoCompactStartedEvent,
|
|
35
|
+
CommandCompletedEvent,
|
|
36
|
+
CommandFailedEvent,
|
|
37
|
+
CompactCompletedEvent,
|
|
38
|
+
CompactFailedEvent,
|
|
39
|
+
CompactStartedEvent,
|
|
40
|
+
Event,
|
|
41
|
+
InputQueuedEvent,
|
|
42
|
+
InputRequestedEvent,
|
|
43
|
+
InputResolvedEvent,
|
|
44
|
+
SessionClosedEvent,
|
|
45
|
+
SessionStateEvent,
|
|
46
|
+
StreamErrorEvent,
|
|
47
|
+
TerminalEvent,
|
|
48
|
+
TokenCountEvent,
|
|
49
|
+
ToolCalledEvent,
|
|
50
|
+
ToolCompletedEvent,
|
|
51
|
+
ToolStartedEvent,
|
|
52
|
+
TurnCompletedEvent,
|
|
53
|
+
TurnEvent,
|
|
54
|
+
TurnFailedEvent,
|
|
55
|
+
TurnInterruptedEvent,
|
|
56
|
+
TurnStartedEvent,
|
|
32
57
|
)
|
|
58
|
+
from pycodex.model import DEFAULT_CODEX_CONFIG_PATH
|
|
33
59
|
from pycodex.utils import uuid7_string
|
|
34
|
-
from pycodex.utils.
|
|
35
|
-
|
|
36
|
-
percent_of_context_window_remaining,
|
|
60
|
+
from pycodex.utils.event_helpers import (
|
|
61
|
+
completed_history,
|
|
37
62
|
shorten_title,
|
|
38
|
-
tool_summary,
|
|
39
63
|
)
|
|
64
|
+
|
|
40
65
|
from .workspaces import (
|
|
41
66
|
WorkspaceDefinition,
|
|
42
67
|
WorkspaceEntry,
|
|
@@ -45,8 +70,6 @@ from .workspaces import (
|
|
|
45
70
|
load_workspace_definitions,
|
|
46
71
|
session_snapshot,
|
|
47
72
|
)
|
|
48
|
-
import typing
|
|
49
|
-
|
|
50
73
|
|
|
51
74
|
JSONValue = typing.Union[
|
|
52
75
|
None,
|
|
@@ -93,6 +116,12 @@ def build_parser() -> "argparse.ArgumentParser":
|
|
|
93
116
|
default=None,
|
|
94
117
|
help="Optional base instructions override passed to the model.",
|
|
95
118
|
)
|
|
119
|
+
parser.add_argument(
|
|
120
|
+
"--toolset",
|
|
121
|
+
nargs="*",
|
|
122
|
+
default=None,
|
|
123
|
+
help="Builtin tool names for all sessions; an empty list disables tools.",
|
|
124
|
+
)
|
|
96
125
|
parser.add_argument(
|
|
97
126
|
"--timeout-seconds",
|
|
98
127
|
type=float,
|
|
@@ -134,93 +163,41 @@ def parse_listen(target: str) -> "typing.Tuple[str, int]":
|
|
|
134
163
|
|
|
135
164
|
SessionFactory = typing.Callable[[], object]
|
|
136
165
|
ThreadedSessionFactory = typing.Callable[[], "WorkspaceInteractiveSession"]
|
|
137
|
-
SESSION_CLOSE_TIMEOUT_SECONDS = 2.0
|
|
138
166
|
SPINNER_STATUS_PREVIEW_LIMIT = 180
|
|
139
167
|
AUTH_COOKIE_NAME = "pycodex_ws_auth"
|
|
140
168
|
|
|
141
169
|
|
|
142
170
|
class WebSessionView:
|
|
143
171
|
def __init__(self) -> None:
|
|
144
|
-
self._input_queue: "asyncio.Queue" = asyncio.Queue()
|
|
145
172
|
self._subscribers: "typing.Set[asyncio.Queue]" = set()
|
|
146
173
|
self._events: "typing.List[typing.Dict[str, object]]" = []
|
|
147
174
|
self._turns: "typing.List[typing.Dict[str, object]]" = []
|
|
148
175
|
self._turns_by_submission_id: "typing.Dict[str, typing.Dict[str, object]]" = {}
|
|
149
176
|
self._turns_by_turn_id: "typing.Dict[str, typing.Dict[str, object]]" = {}
|
|
150
177
|
self._title = ""
|
|
178
|
+
self._model = "pycodex"
|
|
179
|
+
self._rollout_path = ""
|
|
180
|
+
self._recorded_rollout_path = ""
|
|
181
|
+
self._input_request = None
|
|
182
|
+
self._accepts_input = True
|
|
151
183
|
self._spinner_status = ""
|
|
152
184
|
self._stream_buffer = ""
|
|
153
|
-
self.
|
|
154
|
-
self.
|
|
155
|
-
self.
|
|
185
|
+
self._max_context_window: "typing.Union[int, None]" = None
|
|
186
|
+
self._auto_compact_token_limit: "typing.Union[int, None]" = None
|
|
187
|
+
self._usage_tokens: "typing.Union[int, None]" = None
|
|
156
188
|
self._server_loop: "typing.Union[asyncio.AbstractEventLoop, None]" = None
|
|
157
|
-
self._worker_loop: "typing.Union[asyncio.AbstractEventLoop, None]" = None
|
|
158
189
|
self._lock = threading.RLock()
|
|
159
190
|
|
|
160
191
|
def attach_server_loop(self, loop: "asyncio.AbstractEventLoop") -> None:
|
|
161
192
|
self._server_loop = loop
|
|
162
193
|
|
|
163
|
-
def
|
|
164
|
-
self._worker_loop = loop
|
|
165
|
-
|
|
166
|
-
async def submit(self, prompt: str) -> "typing.Dict[str, object]":
|
|
167
|
-
prompt = str(prompt or "").strip()
|
|
168
|
-
if not prompt:
|
|
169
|
-
return {"ok": False, "error": "prompt is empty"}
|
|
170
|
-
await self._put_input(prompt)
|
|
171
|
-
await self._publish(
|
|
172
|
-
{
|
|
173
|
-
"type": "input",
|
|
174
|
-
"prompt": prompt,
|
|
175
|
-
"snapshot": self.snapshot(),
|
|
176
|
-
}
|
|
177
|
-
)
|
|
178
|
-
return {"ok": True, "type": "submitted", "snapshot": self.snapshot()}
|
|
179
|
-
|
|
180
|
-
async def _put_input(self, item: object) -> None:
|
|
181
|
-
worker_loop = self._worker_loop
|
|
182
|
-
try:
|
|
183
|
-
running_loop = asyncio.get_running_loop()
|
|
184
|
-
except RuntimeError:
|
|
185
|
-
running_loop = None
|
|
186
|
-
if worker_loop is None or worker_loop is running_loop:
|
|
187
|
-
await self._input_queue.put(item)
|
|
188
|
-
return
|
|
189
|
-
future = asyncio.run_coroutine_threadsafe(self._input_queue.put(item), worker_loop)
|
|
190
|
-
await asyncio.wrap_future(future)
|
|
191
|
-
|
|
192
|
-
async def poll_prompt(self, prompt: "typing.Union[str, None]" = None) -> "typing.Union[str, None]":
|
|
193
|
-
del prompt
|
|
194
|
-
if self._closed and self._input_queue.empty():
|
|
195
|
-
raise EOFError()
|
|
196
|
-
try:
|
|
197
|
-
item = self._input_queue.get_nowait()
|
|
198
|
-
except asyncio.QueueEmpty:
|
|
199
|
-
return None
|
|
200
|
-
if item is None:
|
|
201
|
-
raise EOFError()
|
|
202
|
-
return str(item)
|
|
203
|
-
|
|
204
|
-
async def get_prompt(self, prompt: "typing.Union[str, None]" = None) -> "str":
|
|
205
|
-
if prompt:
|
|
206
|
-
self.write_line(prompt)
|
|
207
|
-
item = await self._input_queue.get()
|
|
208
|
-
if item is None:
|
|
209
|
-
raise EOFError()
|
|
210
|
-
return str(item)
|
|
211
|
-
|
|
212
|
-
def handle_event(self, event: "AgentEvent") -> None:
|
|
194
|
+
def handle_event(self, event: "Event") -> None:
|
|
213
195
|
with self._lock:
|
|
214
196
|
self._apply_runtime_event(event)
|
|
215
|
-
payload =
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
"
|
|
219
|
-
"payload": _json_safe(getattr(event, "payload", {})),
|
|
220
|
-
"snapshot": self.snapshot(),
|
|
221
|
-
}
|
|
222
|
-
if payload["kind"] == "tool_completed":
|
|
223
|
-
payload["summary"] = tool_summary(getattr(event, "payload", {}))
|
|
197
|
+
payload = _event_data(event)
|
|
198
|
+
payload.update({"type": "event", "snapshot": self.snapshot()})
|
|
199
|
+
if isinstance(event, ToolCompletedEvent):
|
|
200
|
+
payload["summary"] = event.visualize()
|
|
224
201
|
self._publish_nowait(payload)
|
|
225
202
|
|
|
226
203
|
def finish_stream(self) -> None:
|
|
@@ -254,23 +231,6 @@ class WebSessionView:
|
|
|
254
231
|
event = {"type": "snapshot", "snapshot": self.snapshot()}
|
|
255
232
|
self._publish_nowait(event)
|
|
256
233
|
|
|
257
|
-
def show_history(self) -> None:
|
|
258
|
-
assistant_turns = [turn for turn in self._turns if turn.get("kind") != "control"]
|
|
259
|
-
if not assistant_turns:
|
|
260
|
-
self.write_line("No history yet.")
|
|
261
|
-
return
|
|
262
|
-
lines = ["Session: {0}".format(self._title or "untitled")]
|
|
263
|
-
for index, turn in enumerate(assistant_turns, start=1):
|
|
264
|
-
prompt = str(turn.get("prompt") or "")
|
|
265
|
-
response = str(turn.get("response") or turn.get("thinking") or "")
|
|
266
|
-
lines.append("[{0}]U> {1}".format(index, prompt))
|
|
267
|
-
if response:
|
|
268
|
-
lines.append("[{0}]A> {1}".format(index, response))
|
|
269
|
-
self.write_line("\n".join(lines))
|
|
270
|
-
|
|
271
|
-
def show_title(self) -> None:
|
|
272
|
-
self.write_line("Session: {0}".format(self._title or "untitled"))
|
|
273
|
-
|
|
274
234
|
def set_session_title(self, title: str) -> None:
|
|
275
235
|
with self._lock:
|
|
276
236
|
self._set_title(title)
|
|
@@ -281,12 +241,6 @@ class WebSessionView:
|
|
|
281
241
|
}
|
|
282
242
|
self._publish_nowait(event)
|
|
283
243
|
|
|
284
|
-
def show_resumed_session(self, title: str) -> None:
|
|
285
|
-
with self._lock:
|
|
286
|
-
self._set_title(title)
|
|
287
|
-
event = {"type": "snapshot", "snapshot": self.snapshot()}
|
|
288
|
-
self._publish_nowait(event)
|
|
289
|
-
|
|
290
244
|
def load_session_history(
|
|
291
245
|
self,
|
|
292
246
|
title: "typing.Union[str, None]",
|
|
@@ -301,7 +255,9 @@ class WebSessionView:
|
|
|
301
255
|
self._events = []
|
|
302
256
|
for prompt, response in history:
|
|
303
257
|
submission_id = uuid7_string()
|
|
304
|
-
turn = self._ensure_turn(
|
|
258
|
+
turn = self._ensure_turn(
|
|
259
|
+
submission_id, submission_id, str(prompt or "")
|
|
260
|
+
)
|
|
305
261
|
turn["response"] = str(response or "")
|
|
306
262
|
turn["status"] = "completed"
|
|
307
263
|
turn["queue"] = "history"
|
|
@@ -309,22 +265,6 @@ class WebSessionView:
|
|
|
309
265
|
event = {"type": "snapshot", "snapshot": self.snapshot()}
|
|
310
266
|
self._publish_nowait(event)
|
|
311
267
|
|
|
312
|
-
def show_steer_queued(self, turn_id: str, prompt: str) -> None:
|
|
313
|
-
del turn_id, prompt
|
|
314
|
-
|
|
315
|
-
def schedule_steer_inserted(self, turn_id: str, prompt: str) -> None:
|
|
316
|
-
del turn_id, prompt
|
|
317
|
-
|
|
318
|
-
def set_context_window_tokens(
|
|
319
|
-
self,
|
|
320
|
-
context_window_tokens: "typing.Union[int, None]",
|
|
321
|
-
) -> None:
|
|
322
|
-
with self._lock:
|
|
323
|
-
self._context_window_tokens = context_window_tokens
|
|
324
|
-
self._context_remaining_percent = (
|
|
325
|
-
100 if context_window_tokens is not None else None
|
|
326
|
-
)
|
|
327
|
-
|
|
328
268
|
def subscribe(self) -> "asyncio.Queue":
|
|
329
269
|
queue: "asyncio.Queue" = asyncio.Queue()
|
|
330
270
|
with self._lock:
|
|
@@ -343,14 +283,8 @@ class WebSessionView:
|
|
|
343
283
|
|
|
344
284
|
def close(self) -> None:
|
|
345
285
|
with self._lock:
|
|
346
|
-
self._closed = True
|
|
347
286
|
subscribers = tuple(self._subscribers)
|
|
348
287
|
self._subscribers.clear()
|
|
349
|
-
worker_loop = self._worker_loop
|
|
350
|
-
if worker_loop is None:
|
|
351
|
-
self._input_queue.put_nowait(None)
|
|
352
|
-
else:
|
|
353
|
-
asyncio.run_coroutine_threadsafe(self._input_queue.put(None), worker_loop)
|
|
354
288
|
self._publish_to_queues(subscribers, None)
|
|
355
289
|
|
|
356
290
|
def snapshot(self) -> "typing.Dict[str, object]":
|
|
@@ -360,58 +294,120 @@ class WebSessionView:
|
|
|
360
294
|
"status": self._spinner_status,
|
|
361
295
|
"status_kind": "spinner" if self._spinner_status else "idle",
|
|
362
296
|
"spinner": self._spinner_status,
|
|
363
|
-
"model":
|
|
297
|
+
"model": self._model,
|
|
298
|
+
"rollout_path": self._rollout_path,
|
|
299
|
+
"recorded_rollout_path": self._recorded_rollout_path,
|
|
300
|
+
"input_request": _json_safe(self._input_request),
|
|
301
|
+
"queued_inputs": [
|
|
302
|
+
{"queue": turn["queue"], "prompt": turn["prompt"]}
|
|
303
|
+
for turn in self._turns_by_submission_id.values()
|
|
304
|
+
if not turn["turn_id"]
|
|
305
|
+
],
|
|
306
|
+
"accepts_input": self._accepts_input,
|
|
364
307
|
"title": self._title,
|
|
365
|
-
|
|
308
|
+
**self._context_usage(),
|
|
366
309
|
"turns": [_public_turn(turn) for turn in self._turns[-80:]],
|
|
367
310
|
}
|
|
368
311
|
|
|
369
312
|
def summary(self) -> "typing.Dict[str, object]":
|
|
370
313
|
with self._lock:
|
|
371
314
|
return {
|
|
315
|
+
"model": self._model,
|
|
372
316
|
"running": bool(self._spinner_status),
|
|
373
317
|
"spinner": self._spinner_status,
|
|
374
318
|
"title": self._title,
|
|
375
319
|
"turn_count": len(self._turns),
|
|
376
320
|
"last_assistant": _last_assistant_text(self._turns),
|
|
377
|
-
|
|
321
|
+
**self._context_usage(),
|
|
378
322
|
}
|
|
379
323
|
|
|
380
|
-
def _apply_runtime_event(self, event: "
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
self.
|
|
324
|
+
def _apply_runtime_event(self, event: "Event") -> None:
|
|
325
|
+
if isinstance(event, SessionStateEvent):
|
|
326
|
+
state = event.state
|
|
327
|
+
self._model = state["model"]
|
|
328
|
+
self._rollout_path = state["rollout_path"]
|
|
329
|
+
self._recorded_rollout_path = state["recorded_rollout_path"]
|
|
330
|
+
self._input_request = state["input_request"]
|
|
331
|
+
self._accepts_input = state["accepts_input"]
|
|
332
|
+
self._max_context_window = state["max_context_window"]
|
|
333
|
+
self._auto_compact_token_limit = state["auto_compact_token_limit"]
|
|
334
|
+
self._usage_tokens = state["usage_tokens"]
|
|
335
|
+
if event.reason in {"attach", "history"}:
|
|
336
|
+
self.load_session_history(state["title"], completed_history(state))
|
|
337
|
+
active = state["active_turn"]
|
|
338
|
+
if active is not None:
|
|
339
|
+
self._apply_runtime_event(
|
|
340
|
+
TurnStartedEvent(
|
|
341
|
+
active["turn_id"],
|
|
342
|
+
tuple(active["user_texts"]),
|
|
343
|
+
active["submission_id"],
|
|
344
|
+
)
|
|
345
|
+
)
|
|
346
|
+
self._apply_runtime_event(
|
|
347
|
+
AssistantDeltaEvent(
|
|
348
|
+
active["assistant_text"],
|
|
349
|
+
active["turn_id"],
|
|
350
|
+
active["submission_id"],
|
|
351
|
+
)
|
|
352
|
+
)
|
|
353
|
+
elif event.reason == "title":
|
|
354
|
+
self.set_session_title(state["title"])
|
|
355
|
+
else:
|
|
356
|
+
self._set_title(state["title"])
|
|
357
|
+
return
|
|
358
|
+
if isinstance(event, CommandCompletedEvent):
|
|
359
|
+
if event.result["kind"] in {"title_changed", "resumed"}:
|
|
360
|
+
return
|
|
361
|
+
message = event.visualize()
|
|
362
|
+
if message:
|
|
363
|
+
self.write_line(message)
|
|
364
|
+
return
|
|
365
|
+
if isinstance(event, CommandFailedEvent):
|
|
366
|
+
self.show_error(event.visualize())
|
|
367
|
+
return
|
|
368
|
+
if isinstance(event, InputRequestedEvent):
|
|
369
|
+
self._input_request = event
|
|
370
|
+
return
|
|
371
|
+
if isinstance(event, InputResolvedEvent):
|
|
372
|
+
self._input_request = None
|
|
387
373
|
return
|
|
388
|
-
|
|
389
|
-
|
|
374
|
+
if isinstance(event, SessionClosedEvent):
|
|
375
|
+
self._accepts_input = False
|
|
376
|
+
self._spinner_status = ""
|
|
377
|
+
return
|
|
378
|
+
if isinstance(event, InputQueuedEvent):
|
|
379
|
+
turn = self._ensure_turn(event.submission_id, "", "")
|
|
380
|
+
turn["prompt"] += ("\n" if turn["prompt"] else "") + event.prompt
|
|
381
|
+
turn["queue"] = event.queue
|
|
382
|
+
turn["sender"] = event.sender
|
|
383
|
+
return
|
|
384
|
+
if isinstance(event, TokenCountEvent):
|
|
385
|
+
self._usage_tokens = int(event.usage["total_tokens"])
|
|
386
|
+
return
|
|
387
|
+
if isinstance(event, (AutoCompactCompletedEvent, CompactCompletedEvent)):
|
|
388
|
+
self._usage_tokens = None
|
|
389
|
+
if not isinstance(event, TurnEvent):
|
|
390
|
+
return
|
|
391
|
+
turn_id = event.turn_id
|
|
392
|
+
submission_id = event.submission_id or turn_id
|
|
390
393
|
turn = self._turns_by_submission_id.get(submission_id)
|
|
391
|
-
if turn is None and turn_id and not submission_id:
|
|
392
|
-
turn = self._turns_by_turn_id.get(turn_id)
|
|
393
394
|
|
|
394
|
-
if
|
|
395
|
-
self._set_spinner_status(kind)
|
|
396
|
-
|
|
397
|
-
str(item) for item in payload.get("user_texts", []) or []
|
|
398
|
-
)
|
|
399
|
-
if not self._title and str(prompt or "").strip():
|
|
400
|
-
self._set_title(shorten_title(str(prompt or "")))
|
|
401
|
-
turn = self._ensure_turn(submission_id, turn_id, str(prompt or ""))
|
|
395
|
+
if isinstance(event, TurnStartedEvent):
|
|
396
|
+
self._set_spinner_status(event.kind)
|
|
397
|
+
turn = self._ensure_turn(submission_id, turn_id, event.visualize())
|
|
402
398
|
turn["status"] = "running"
|
|
403
399
|
turn["thinking"] = ""
|
|
404
400
|
turn["_thinking_active"] = False
|
|
405
401
|
turn["error"] = ""
|
|
406
402
|
return
|
|
407
403
|
|
|
408
|
-
self._apply_spinner_event(
|
|
404
|
+
self._apply_spinner_event(event)
|
|
409
405
|
if turn is None:
|
|
410
406
|
return
|
|
411
407
|
|
|
412
|
-
if
|
|
408
|
+
if isinstance(event, AssistantDeltaEvent):
|
|
413
409
|
turn["status"] = "responding"
|
|
414
|
-
delta =
|
|
410
|
+
delta = event.visualize()
|
|
415
411
|
self._stream_buffer += delta
|
|
416
412
|
if turn.get("_thinking_active"):
|
|
417
413
|
turn["thinking"] = str(turn.get("thinking") or "") + delta
|
|
@@ -420,19 +416,19 @@ class WebSessionView:
|
|
|
420
416
|
turn["_thinking_active"] = True
|
|
421
417
|
return
|
|
422
418
|
|
|
423
|
-
if
|
|
419
|
+
if isinstance(event, ToolStartedEvent):
|
|
424
420
|
turn["status"] = "tool"
|
|
425
|
-
turn["tool_name"] =
|
|
421
|
+
turn["tool_name"] = event.call.name
|
|
426
422
|
turn["_thinking_active"] = False
|
|
427
423
|
return
|
|
428
424
|
|
|
429
|
-
if
|
|
425
|
+
if isinstance(event, ToolCompletedEvent):
|
|
430
426
|
turn["_thinking_active"] = False
|
|
431
427
|
turn["status"] = "running"
|
|
432
428
|
return
|
|
433
429
|
|
|
434
|
-
if
|
|
435
|
-
response =
|
|
430
|
+
if isinstance(event, TurnCompletedEvent):
|
|
431
|
+
response = event.visualize()
|
|
436
432
|
if response:
|
|
437
433
|
turn["response"] = response
|
|
438
434
|
elif turn.get("thinking"):
|
|
@@ -443,89 +439,77 @@ class WebSessionView:
|
|
|
443
439
|
self._stream_buffer = ""
|
|
444
440
|
return
|
|
445
441
|
|
|
446
|
-
if
|
|
442
|
+
if isinstance(event, TurnFailedEvent):
|
|
447
443
|
turn["status"] = "error"
|
|
448
|
-
turn["error"] =
|
|
444
|
+
turn["error"] = event.visualize()
|
|
449
445
|
self._stream_buffer = ""
|
|
450
446
|
return
|
|
451
447
|
|
|
452
|
-
if
|
|
453
|
-
if
|
|
448
|
+
if isinstance(event, TurnInterruptedEvent):
|
|
449
|
+
if event.output_text:
|
|
450
|
+
turn["response"] = event.output_text
|
|
451
|
+
elif turn.get("thinking") and not turn.get("response"):
|
|
454
452
|
turn["response"] = str(turn.get("thinking") or "")
|
|
455
453
|
turn["thinking"] = ""
|
|
456
454
|
turn["_thinking_active"] = False
|
|
457
455
|
turn["status"] = "interrupted"
|
|
458
456
|
self._stream_buffer = ""
|
|
459
457
|
|
|
460
|
-
def
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
458
|
+
def _context_usage(self) -> "typing.Dict[str, object]":
|
|
459
|
+
limit = self._auto_compact_token_limit
|
|
460
|
+
if limit is None:
|
|
461
|
+
limit = self._max_context_window
|
|
462
|
+
if limit is None:
|
|
463
|
+
remaining_percent = None
|
|
464
|
+
elif self._usage_tokens is None:
|
|
465
|
+
remaining_percent = 100
|
|
466
|
+
elif self._usage_tokens >= limit:
|
|
467
|
+
remaining_percent = 0
|
|
468
|
+
else:
|
|
469
|
+
# Round up so zero means the actual threshold has been reached.
|
|
470
|
+
remaining_percent = min(
|
|
471
|
+
100, ((limit - self._usage_tokens) * 100 + limit - 1) // limit
|
|
472
|
+
)
|
|
473
|
+
return {
|
|
474
|
+
"usage_tokens": self._usage_tokens,
|
|
475
|
+
"auto_compact_token_limit": self._auto_compact_token_limit,
|
|
476
|
+
"max_context_window": self._max_context_window,
|
|
477
|
+
"context_remaining_percent": remaining_percent,
|
|
478
|
+
}
|
|
475
479
|
|
|
476
|
-
def _apply_spinner_event(
|
|
477
|
-
|
|
478
|
-
kind: str,
|
|
479
|
-
payload: "typing.Dict[str, object]",
|
|
480
|
-
) -> None:
|
|
481
|
-
if kind == "assistant_delta":
|
|
480
|
+
def _apply_spinner_event(self, event: "TurnEvent") -> None:
|
|
481
|
+
if isinstance(event, AssistantDeltaEvent):
|
|
482
482
|
self._set_spinner_status("talking")
|
|
483
483
|
return
|
|
484
|
-
if
|
|
484
|
+
if isinstance(event, StreamErrorEvent):
|
|
485
485
|
self._set_spinner_status("reconnecting")
|
|
486
486
|
return
|
|
487
|
-
if
|
|
487
|
+
if isinstance(event, (AutoCompactStartedEvent, CompactStartedEvent)):
|
|
488
488
|
self._set_spinner_status("compacting")
|
|
489
489
|
return
|
|
490
|
-
if
|
|
490
|
+
if isinstance(event, AutoCompactCompletedEvent):
|
|
491
491
|
self._set_spinner_status("compacted")
|
|
492
492
|
return
|
|
493
|
-
if
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
shorten_title(
|
|
499
|
-
"calling {0}({1})".format(tool_name, call.arguments),
|
|
500
|
-
limit=SPINNER_STATUS_PREVIEW_LIMIT,
|
|
501
|
-
)
|
|
493
|
+
if isinstance(event, ToolStartedEvent):
|
|
494
|
+
self._set_spinner_status(
|
|
495
|
+
shorten_title(
|
|
496
|
+
event.visualize(),
|
|
497
|
+
limit=SPINNER_STATUS_PREVIEW_LIMIT,
|
|
502
498
|
)
|
|
503
|
-
|
|
504
|
-
self._set_spinner_status("calling {0}".format(tool_name))
|
|
505
|
-
else:
|
|
506
|
-
self._set_spinner_status("calling provider tools")
|
|
507
|
-
return
|
|
508
|
-
if kind == "tool_completed":
|
|
509
|
-
tool_name = str(payload.get("tool_name") or "").strip()
|
|
510
|
-
if tool_name:
|
|
511
|
-
self._set_spinner_status("called {0}".format(tool_name))
|
|
499
|
+
)
|
|
512
500
|
return
|
|
513
|
-
if
|
|
514
|
-
self.
|
|
501
|
+
if isinstance(event, ToolCompletedEvent):
|
|
502
|
+
self._set_spinner_status("called {0}".format(event.call.name))
|
|
515
503
|
return
|
|
516
|
-
if
|
|
517
|
-
self.
|
|
504
|
+
if isinstance(event, TerminalEvent):
|
|
505
|
+
self._set_idle_spinner_status(event)
|
|
518
506
|
|
|
519
507
|
def _set_spinner_status(self, text: "typing.Union[str, None]") -> None:
|
|
520
508
|
self._spinner_status = str(text or "").strip()
|
|
521
509
|
|
|
522
|
-
def _set_idle_spinner_status(self,
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
except (TypeError, ValueError):
|
|
526
|
-
background_work_count = 0
|
|
527
|
-
if background_work_count > 0:
|
|
528
|
-
self._set_spinner_status(IDLE_LISTENING_STATUS)
|
|
510
|
+
def _set_idle_spinner_status(self, event: "TerminalEvent") -> None:
|
|
511
|
+
if (event.background_work_count or 0) > 0:
|
|
512
|
+
self._set_spinner_status(IDLE_SLEEPING_STATUS)
|
|
529
513
|
else:
|
|
530
514
|
self._set_spinner_status("")
|
|
531
515
|
|
|
@@ -536,14 +520,14 @@ class WebSessionView:
|
|
|
536
520
|
prompt: str,
|
|
537
521
|
) -> "typing.Dict[str, object]":
|
|
538
522
|
submission_id = str(submission_id or "").strip()
|
|
539
|
-
turn_id = str(turn_id or
|
|
523
|
+
turn_id = str(turn_id or "").strip()
|
|
540
524
|
turn = self._turns_by_submission_id.get(submission_id)
|
|
541
525
|
if turn is None and turn_id and not submission_id:
|
|
542
526
|
turn = self._turns_by_turn_id.get(turn_id)
|
|
543
527
|
if turn is None:
|
|
544
528
|
turn = {
|
|
545
529
|
"submission_id": submission_id,
|
|
546
|
-
"turn_id":
|
|
530
|
+
"turn_id": "",
|
|
547
531
|
"prompt": prompt,
|
|
548
532
|
"response": "",
|
|
549
533
|
"thinking": "",
|
|
@@ -554,11 +538,13 @@ class WebSessionView:
|
|
|
554
538
|
"queue": "steer",
|
|
555
539
|
"sender": "web",
|
|
556
540
|
}
|
|
557
|
-
self._turns.append(turn)
|
|
558
541
|
if submission_id:
|
|
559
542
|
turn["submission_id"] = submission_id
|
|
560
543
|
self._turns_by_submission_id[submission_id] = turn
|
|
561
544
|
if turn_id:
|
|
545
|
+
if not turn["turn_id"]:
|
|
546
|
+
# Queue admission stays hidden until the turn actually starts.
|
|
547
|
+
self._turns.append(turn)
|
|
562
548
|
turn["turn_id"] = turn_id
|
|
563
549
|
self._turns_by_turn_id[turn_id] = turn
|
|
564
550
|
if prompt:
|
|
@@ -616,58 +602,56 @@ class WebSessionView:
|
|
|
616
602
|
|
|
617
603
|
loop.call_soon_threadsafe(publish)
|
|
618
604
|
|
|
619
|
-
async def _publish(self, event: "typing.Dict[str, object]") -> None:
|
|
620
|
-
self._publish_nowait(event)
|
|
621
|
-
|
|
622
605
|
|
|
623
606
|
class WorkspaceInteractiveSession:
|
|
624
607
|
def __init__(
|
|
625
608
|
self,
|
|
626
|
-
|
|
609
|
+
runtime,
|
|
627
610
|
config_path: "typing.Union[str, None]" = None,
|
|
628
611
|
) -> None:
|
|
629
|
-
self.
|
|
612
|
+
self.runtime = runtime
|
|
630
613
|
self.config_path = config_path
|
|
631
614
|
self.view = WebSessionView()
|
|
632
|
-
self.
|
|
615
|
+
self._frontend_id = None
|
|
633
616
|
|
|
634
617
|
async def start(self) -> "WorkspaceInteractiveSession":
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
self.queue,
|
|
639
|
-
False,
|
|
640
|
-
self.config_path,
|
|
641
|
-
view=self.view,
|
|
642
|
-
show_banner=False,
|
|
643
|
-
)
|
|
644
|
-
)
|
|
618
|
+
await self.runtime.start(self.config_path)
|
|
619
|
+
if self._frontend_id is None:
|
|
620
|
+
self._frontend_id = self.runtime.attach(self.view.handle_event)
|
|
645
621
|
return self
|
|
646
622
|
|
|
647
623
|
async def close(self) -> None:
|
|
648
|
-
self.view.close()
|
|
649
|
-
task = self._task
|
|
650
|
-
if task is None:
|
|
651
|
-
return
|
|
652
624
|
try:
|
|
653
|
-
await
|
|
654
|
-
asyncio.shield(task),
|
|
655
|
-
timeout=SESSION_CLOSE_TIMEOUT_SECONDS,
|
|
656
|
-
)
|
|
657
|
-
except asyncio.TimeoutError:
|
|
658
|
-
cancel_current = getattr(self.queue, "cancel_current", None)
|
|
659
|
-
if callable(cancel_current):
|
|
660
|
-
cancel_current()
|
|
661
|
-
task.cancel()
|
|
662
|
-
await asyncio.gather(task, return_exceptions=True)
|
|
625
|
+
await self.runtime.close()
|
|
663
626
|
finally:
|
|
664
|
-
self.
|
|
627
|
+
self.detach()
|
|
628
|
+
|
|
629
|
+
def detach(self):
|
|
630
|
+
if self._frontend_id is not None:
|
|
631
|
+
self.runtime.detach(self._frontend_id)
|
|
632
|
+
self._frontend_id = None
|
|
633
|
+
self.view.close()
|
|
634
|
+
|
|
635
|
+
async def submit(
|
|
636
|
+
self, prompt: str, sender: str = "web"
|
|
637
|
+
) -> "typing.Dict[str, object]":
|
|
638
|
+
try:
|
|
639
|
+
receipt = await self.runtime.submit_input(prompt, sender)
|
|
640
|
+
except (ValueError, RuntimeError) as exc:
|
|
641
|
+
return {"ok": False, "error": str(exc), "snapshot": self.snapshot()}
|
|
642
|
+
return {
|
|
643
|
+
"ok": True,
|
|
644
|
+
"type": "submitted",
|
|
645
|
+
"submission_id": receipt.submission_id,
|
|
646
|
+
"snapshot": self.snapshot(),
|
|
647
|
+
}
|
|
665
648
|
|
|
666
|
-
async def
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
649
|
+
async def answer_input(self, request_id, answer):
|
|
650
|
+
try:
|
|
651
|
+
self.runtime.answer_input(request_id, answer)
|
|
652
|
+
except ValueError as exc:
|
|
653
|
+
return {"ok": False, "error": str(exc), "snapshot": self.snapshot()}
|
|
654
|
+
return {"ok": True, "type": "answered", "snapshot": self.snapshot()}
|
|
671
655
|
|
|
672
656
|
def subscribe(self) -> "asyncio.Queue":
|
|
673
657
|
return self.view.subscribe()
|
|
@@ -676,34 +660,23 @@ class WorkspaceInteractiveSession:
|
|
|
676
660
|
self.view.unsubscribe(queue)
|
|
677
661
|
|
|
678
662
|
def snapshot(self) -> "typing.Dict[str, object]":
|
|
679
|
-
|
|
680
|
-
agent = getattr(self.queue, "_agent", None)
|
|
681
|
-
snapshot["model"] = getattr(getattr(agent, "_model_client", None), "model", "pycodex")
|
|
682
|
-
return snapshot
|
|
663
|
+
return self.view.snapshot()
|
|
683
664
|
|
|
684
665
|
def summary(self) -> "typing.Dict[str, object]":
|
|
685
|
-
|
|
686
|
-
agent = getattr(self.queue, "_agent", None)
|
|
687
|
-
summary["model"] = getattr(getattr(agent, "_model_client", None), "model", "pycodex")
|
|
688
|
-
return summary
|
|
666
|
+
return self.view.summary()
|
|
689
667
|
|
|
690
668
|
def rollout_path(self) -> str:
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
agent.set_rollout_recorder(SessionRolloutRecorder.resume(resumed["rollout_path"]))
|
|
703
|
-
self.view.load_session_history(
|
|
704
|
-
str(title or resumed["title"]),
|
|
705
|
-
tuple(resumed["turns"]),
|
|
706
|
-
)
|
|
669
|
+
return self.view.snapshot()["rollout_path"]
|
|
670
|
+
|
|
671
|
+
async def restore_from_rollout(
|
|
672
|
+
self, rollout_path: str, title: str = "", fork: bool = False
|
|
673
|
+
) -> None:
|
|
674
|
+
if rollout_path:
|
|
675
|
+
self.runtime.resume(rollout_path, title)
|
|
676
|
+
if fork:
|
|
677
|
+
self.runtime.fork()
|
|
678
|
+
else:
|
|
679
|
+
self.runtime.set_title(title)
|
|
707
680
|
|
|
708
681
|
|
|
709
682
|
class ThreadedWorkspaceInteractiveSession:
|
|
@@ -719,7 +692,6 @@ class ThreadedWorkspaceInteractiveSession:
|
|
|
719
692
|
self._thread: "typing.Union[threading.Thread, None]" = None
|
|
720
693
|
self._worker_loop: "typing.Union[asyncio.AbstractEventLoop, None]" = None
|
|
721
694
|
self._ready = threading.Event()
|
|
722
|
-
self._closed = threading.Event()
|
|
723
695
|
self._startup_error: "typing.Union[BaseException, None]" = None
|
|
724
696
|
self._session: "typing.Union[WorkspaceInteractiveSession, None]" = None
|
|
725
697
|
|
|
@@ -734,66 +706,76 @@ class ThreadedWorkspaceInteractiveSession:
|
|
|
734
706
|
self._thread.start()
|
|
735
707
|
await asyncio.to_thread(self._ready.wait)
|
|
736
708
|
if self._startup_error is not None:
|
|
737
|
-
raise RuntimeError(
|
|
709
|
+
raise RuntimeError(
|
|
710
|
+
"workspace session thread failed to start"
|
|
711
|
+
) from self._startup_error
|
|
738
712
|
return self
|
|
739
713
|
|
|
740
714
|
def _thread_main(self) -> None:
|
|
741
715
|
loop = asyncio.new_event_loop()
|
|
742
716
|
self._worker_loop = loop
|
|
743
|
-
self._view.attach_worker_loop(loop)
|
|
744
717
|
asyncio.set_event_loop(loop)
|
|
745
718
|
try:
|
|
746
719
|
session = self._session_factory()
|
|
747
720
|
session.view = self._view
|
|
748
721
|
self._session = session
|
|
749
|
-
|
|
722
|
+
try:
|
|
723
|
+
loop.run_until_complete(session.start())
|
|
724
|
+
except BaseException:
|
|
725
|
+
loop.run_until_complete(session.close())
|
|
726
|
+
raise
|
|
750
727
|
self._ready.set()
|
|
751
728
|
loop.run_forever()
|
|
752
729
|
except BaseException as exc:
|
|
753
730
|
self._startup_error = exc
|
|
754
731
|
self._ready.set()
|
|
755
732
|
finally:
|
|
756
|
-
session = self._session
|
|
757
|
-
if session is not None:
|
|
758
|
-
try:
|
|
759
|
-
loop.run_until_complete(session.close())
|
|
760
|
-
except BaseException:
|
|
761
|
-
pass
|
|
762
733
|
pending = asyncio.all_tasks(loop)
|
|
763
734
|
for task in pending:
|
|
764
735
|
task.cancel()
|
|
765
736
|
if pending:
|
|
766
|
-
loop.run_until_complete(
|
|
737
|
+
loop.run_until_complete(
|
|
738
|
+
asyncio.gather(*pending, return_exceptions=True)
|
|
739
|
+
)
|
|
767
740
|
asyncio.set_event_loop(None)
|
|
768
741
|
loop.close()
|
|
769
|
-
self._closed.set()
|
|
770
742
|
|
|
771
743
|
async def close(self) -> None:
|
|
772
744
|
session = self._session
|
|
773
745
|
loop = self._worker_loop
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
asyncio.wrap_future(future),
|
|
779
|
-
timeout=SESSION_CLOSE_TIMEOUT_SECONDS + 1.0,
|
|
746
|
+
try:
|
|
747
|
+
if session is not None and loop is not None and loop.is_running():
|
|
748
|
+
future = asyncio.wrap_future(
|
|
749
|
+
asyncio.run_coroutine_threadsafe(session.close(), loop)
|
|
780
750
|
)
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
751
|
+
try:
|
|
752
|
+
await asyncio.shield(future)
|
|
753
|
+
except asyncio.CancelledError:
|
|
754
|
+
await future
|
|
755
|
+
raise
|
|
756
|
+
finally:
|
|
757
|
+
if loop is not None and loop.is_running():
|
|
758
|
+
loop.call_soon_threadsafe(loop.stop)
|
|
759
|
+
thread = self._thread
|
|
760
|
+
if thread is not None:
|
|
761
|
+
await asyncio.to_thread(thread.join)
|
|
762
|
+
self._thread = None
|
|
763
|
+
|
|
764
|
+
async def submit(
|
|
765
|
+
self, prompt: str, sender: str = "web"
|
|
766
|
+
) -> "typing.Dict[str, object]":
|
|
767
|
+
future = asyncio.run_coroutine_threadsafe(
|
|
768
|
+
self._session.submit(prompt, sender),
|
|
769
|
+
self._worker_loop,
|
|
770
|
+
)
|
|
771
|
+
return await asyncio.wrap_future(future)
|
|
772
|
+
|
|
773
|
+
async def answer_input(self, request_id, answer):
|
|
774
|
+
future = asyncio.run_coroutine_threadsafe(
|
|
775
|
+
self._session.answer_input(request_id, answer),
|
|
776
|
+
self._worker_loop,
|
|
777
|
+
)
|
|
778
|
+
return await asyncio.wrap_future(future)
|
|
797
779
|
|
|
798
780
|
def subscribe(self) -> "asyncio.Queue":
|
|
799
781
|
return self._view.subscribe()
|
|
@@ -802,34 +784,26 @@ class ThreadedWorkspaceInteractiveSession:
|
|
|
802
784
|
self._view.unsubscribe(queue)
|
|
803
785
|
|
|
804
786
|
def snapshot(self) -> "typing.Dict[str, object]":
|
|
805
|
-
|
|
806
|
-
session = self._session
|
|
807
|
-
queue = getattr(session, "queue", None)
|
|
808
|
-
agent = getattr(queue, "_agent", None)
|
|
809
|
-
snapshot["model"] = getattr(getattr(agent, "_model_client", None), "model", "pycodex")
|
|
810
|
-
return snapshot
|
|
787
|
+
return self._view.snapshot()
|
|
811
788
|
|
|
812
789
|
def summary(self) -> "typing.Dict[str, object]":
|
|
813
|
-
|
|
814
|
-
session = self._session
|
|
815
|
-
queue = getattr(session, "queue", None)
|
|
816
|
-
agent = getattr(queue, "_agent", None)
|
|
817
|
-
summary["model"] = getattr(getattr(agent, "_model_client", None), "model", "pycodex")
|
|
818
|
-
return summary
|
|
790
|
+
return self._view.summary()
|
|
819
791
|
|
|
820
792
|
def rollout_path(self) -> str:
|
|
821
793
|
if self._session is None:
|
|
822
794
|
return ""
|
|
823
795
|
return self._session.rollout_path()
|
|
824
796
|
|
|
825
|
-
async def restore_from_rollout(
|
|
797
|
+
async def restore_from_rollout(
|
|
798
|
+
self, rollout_path: str, title: str = "", fork: bool = False
|
|
799
|
+
) -> None:
|
|
826
800
|
session = self._session
|
|
827
801
|
loop = self._worker_loop
|
|
828
802
|
if session is None or loop is None:
|
|
829
803
|
return
|
|
830
804
|
|
|
831
805
|
future = asyncio.run_coroutine_threadsafe(
|
|
832
|
-
session.restore_from_rollout(rollout_path, title=title),
|
|
806
|
+
session.restore_from_rollout(rollout_path, title=title, fork=fork),
|
|
833
807
|
loop,
|
|
834
808
|
)
|
|
835
809
|
await asyncio.wrap_future(future)
|
|
@@ -853,7 +827,7 @@ def create_app(
|
|
|
853
827
|
|
|
854
828
|
|
|
855
829
|
def create_multi_workspace_app(
|
|
856
|
-
registry:
|
|
830
|
+
registry: "WorkspaceRegistry",
|
|
857
831
|
password: "typing.Union[str, None]" = None,
|
|
858
832
|
) -> FastAPI:
|
|
859
833
|
app = _create_lifespan_app(registry.start, registry.close)
|
|
@@ -877,11 +851,7 @@ def create_multi_workspace_app(
|
|
|
877
851
|
entry = await registry.add_workspace(
|
|
878
852
|
str(payload.get("name") or ""),
|
|
879
853
|
work_dir=str(payload.get("dir") or "./"),
|
|
880
|
-
board=(
|
|
881
|
-
None
|
|
882
|
-
if payload.get("board") in (None, "")
|
|
883
|
-
else str(payload.get("board"))
|
|
884
|
-
),
|
|
854
|
+
board=payload.get("board"),
|
|
885
855
|
)
|
|
886
856
|
except ValueError as exc:
|
|
887
857
|
return JSONResponse({"ok": False, "error": str(exc)}, status_code=400)
|
|
@@ -943,7 +913,9 @@ def create_multi_workspace_app(
|
|
|
943
913
|
return await _new_session_response(entry.manager)
|
|
944
914
|
|
|
945
915
|
@app.delete("/w/{workspace_id}/api/sessions/{session_id}")
|
|
946
|
-
async def workspace_delete_session(
|
|
916
|
+
async def workspace_delete_session(
|
|
917
|
+
workspace_id: str, session_id: str
|
|
918
|
+
) -> JSONResponse:
|
|
947
919
|
entry = _workspace_entry_or_404(registry, workspace_id)
|
|
948
920
|
return await _delete_session_response(entry.manager, session_id)
|
|
949
921
|
|
|
@@ -964,8 +936,12 @@ def create_multi_workspace_app(
|
|
|
964
936
|
return await _message_response(entry.manager, payload)
|
|
965
937
|
|
|
966
938
|
@app.websocket("/w/{workspace_id}/ws/session")
|
|
967
|
-
async def workspace_websocket_session(
|
|
968
|
-
|
|
939
|
+
async def workspace_websocket_session(
|
|
940
|
+
workspace_id: str, websocket: WebSocket
|
|
941
|
+
) -> None:
|
|
942
|
+
if not _auth_cookie_matches(
|
|
943
|
+
auth_token, websocket.cookies.get(AUTH_COOKIE_NAME)
|
|
944
|
+
):
|
|
969
945
|
await websocket.close(code=1008)
|
|
970
946
|
return
|
|
971
947
|
try:
|
|
@@ -1005,20 +981,35 @@ def _install_auth(app: FastAPI, password: "typing.Union[str, None]") -> str:
|
|
|
1005
981
|
{"ok": False, "error": "authentication required"},
|
|
1006
982
|
status_code=401,
|
|
1007
983
|
)
|
|
1008
|
-
|
|
984
|
+
target = path + ("?" + request.url.query if request.url.query else "")
|
|
985
|
+
return RedirectResponse(
|
|
986
|
+
url="/login?" + urlencode({"next": target}), status_code=303
|
|
987
|
+
)
|
|
1009
988
|
|
|
1010
989
|
@app.get("/login")
|
|
1011
990
|
async def login_page() -> HTMLResponse:
|
|
1012
991
|
return _html_response(_render_login_shell())
|
|
1013
992
|
|
|
1014
993
|
@app.post("/login")
|
|
1015
|
-
async def login(
|
|
1016
|
-
|
|
994
|
+
async def login(
|
|
995
|
+
request: Request, payload: "typing.Dict[str, object]"
|
|
996
|
+
) -> JSONResponse:
|
|
997
|
+
if not secrets.compare_digest(
|
|
998
|
+
str(payload.get("password") or ""), password_text
|
|
999
|
+
):
|
|
1017
1000
|
return JSONResponse(
|
|
1018
1001
|
{"ok": False, "error": "invalid password"},
|
|
1019
1002
|
status_code=401,
|
|
1020
1003
|
)
|
|
1021
|
-
|
|
1004
|
+
target = request.query_params.get("next", "/")
|
|
1005
|
+
if (
|
|
1006
|
+
not target.startswith("/")
|
|
1007
|
+
or target.startswith("//")
|
|
1008
|
+
or "\\" in target
|
|
1009
|
+
or any(ord(char) < 32 for char in target)
|
|
1010
|
+
):
|
|
1011
|
+
target = "/"
|
|
1012
|
+
response = JSONResponse({"ok": True, "redirect": target})
|
|
1022
1013
|
response.set_cookie(
|
|
1023
1014
|
AUTH_COOKIE_NAME,
|
|
1024
1015
|
token,
|
|
@@ -1096,13 +1087,14 @@ def _render_login_shell() -> str:
|
|
|
1096
1087
|
form.addEventListener("submit", async function(event) {
|
|
1097
1088
|
event.preventDefault();
|
|
1098
1089
|
statusEl.textContent = "";
|
|
1099
|
-
const response = await fetch(
|
|
1090
|
+
const response = await fetch(window.location.pathname + window.location.search, {
|
|
1100
1091
|
method: "POST",
|
|
1101
1092
|
headers: {"Content-Type": "application/json"},
|
|
1102
1093
|
body: JSON.stringify({password: passwordInput.value}),
|
|
1103
1094
|
});
|
|
1104
1095
|
if (response.ok) {
|
|
1105
|
-
|
|
1096
|
+
const result = await response.json();
|
|
1097
|
+
window.location.href = result.redirect;
|
|
1106
1098
|
return;
|
|
1107
1099
|
}
|
|
1108
1100
|
statusEl.textContent = "Invalid password";
|
|
@@ -1117,6 +1109,7 @@ def _create_lifespan_app(
|
|
|
1117
1109
|
close: "typing.Callable[[], typing.Awaitable[None]]",
|
|
1118
1110
|
) -> FastAPI:
|
|
1119
1111
|
if asynccontextmanager is not None:
|
|
1112
|
+
|
|
1120
1113
|
@asynccontextmanager
|
|
1121
1114
|
async def lifespan(_app):
|
|
1122
1115
|
await start()
|
|
@@ -1136,6 +1129,7 @@ def _create_lifespan_app(
|
|
|
1136
1129
|
@app.on_event("shutdown")
|
|
1137
1130
|
async def shutdown() -> None:
|
|
1138
1131
|
await close()
|
|
1132
|
+
|
|
1139
1133
|
return app
|
|
1140
1134
|
|
|
1141
1135
|
|
|
@@ -1191,7 +1185,9 @@ def _install_workspace_routes(
|
|
|
1191
1185
|
@app.websocket("/ws/session")
|
|
1192
1186
|
async def websocket_session(websocket: WebSocket) -> None:
|
|
1193
1187
|
auth_token = typing.cast(str, app.state.workspace_auth_token)
|
|
1194
|
-
if not _auth_cookie_matches(
|
|
1188
|
+
if not _auth_cookie_matches(
|
|
1189
|
+
auth_token, websocket.cookies.get(AUTH_COOKIE_NAME)
|
|
1190
|
+
):
|
|
1195
1191
|
await websocket.close(code=1008)
|
|
1196
1192
|
return
|
|
1197
1193
|
await _websocket_session_handler(manager, websocket)
|
|
@@ -1224,11 +1220,11 @@ def _websocket_backend_hint_response() -> JSONResponse:
|
|
|
1224
1220
|
)
|
|
1225
1221
|
|
|
1226
1222
|
|
|
1227
|
-
def _sessions_response(manager:
|
|
1223
|
+
def _sessions_response(manager: "WorkspaceSessionManager") -> JSONResponse:
|
|
1228
1224
|
return JSONResponse({"sessions": manager.list_sessions()})
|
|
1229
1225
|
|
|
1230
1226
|
|
|
1231
|
-
async def _new_session_response(manager:
|
|
1227
|
+
async def _new_session_response(manager: "WorkspaceSessionManager") -> JSONResponse:
|
|
1232
1228
|
session_id = await manager.create_session()
|
|
1233
1229
|
return JSONResponse(
|
|
1234
1230
|
{
|
|
@@ -1241,7 +1237,7 @@ async def _new_session_response(manager: 'WorkspaceSessionManager') -> JSONRespo
|
|
|
1241
1237
|
|
|
1242
1238
|
|
|
1243
1239
|
async def _delete_session_response(
|
|
1244
|
-
manager:
|
|
1240
|
+
manager: "WorkspaceSessionManager",
|
|
1245
1241
|
session_id: str,
|
|
1246
1242
|
) -> JSONResponse:
|
|
1247
1243
|
try:
|
|
@@ -1254,7 +1250,7 @@ async def _delete_session_response(
|
|
|
1254
1250
|
|
|
1255
1251
|
|
|
1256
1252
|
def _session_response(
|
|
1257
|
-
manager:
|
|
1253
|
+
manager: "WorkspaceSessionManager",
|
|
1258
1254
|
session_id: "typing.Union[str, None]" = None,
|
|
1259
1255
|
) -> JSONResponse:
|
|
1260
1256
|
try:
|
|
@@ -1272,7 +1268,7 @@ def _session_response(
|
|
|
1272
1268
|
|
|
1273
1269
|
|
|
1274
1270
|
async def _message_response(
|
|
1275
|
-
manager:
|
|
1271
|
+
manager: "WorkspaceSessionManager",
|
|
1276
1272
|
payload: "typing.Dict[str, object]",
|
|
1277
1273
|
) -> JSONResponse:
|
|
1278
1274
|
session_id = str(payload.get("session_id") or "")
|
|
@@ -1280,7 +1276,13 @@ async def _message_response(
|
|
|
1280
1276
|
link = manager.get(session_id or None)
|
|
1281
1277
|
except KeyError:
|
|
1282
1278
|
raise HTTPException(status_code=404, detail="session not found")
|
|
1283
|
-
|
|
1279
|
+
if "request_id" in payload:
|
|
1280
|
+
result = await link.answer_input(payload["request_id"], payload.get("answer"))
|
|
1281
|
+
else:
|
|
1282
|
+
result = await link.submit(
|
|
1283
|
+
str(payload.get("prompt") or ""),
|
|
1284
|
+
sender=str(payload.get("sender") or "web"),
|
|
1285
|
+
)
|
|
1284
1286
|
if isinstance(result, dict):
|
|
1285
1287
|
result.setdefault("sessions", manager.list_sessions())
|
|
1286
1288
|
status = 200 if result.get("ok") else 400
|
|
@@ -1288,7 +1290,7 @@ async def _message_response(
|
|
|
1288
1290
|
|
|
1289
1291
|
|
|
1290
1292
|
async def _websocket_session_handler(
|
|
1291
|
-
manager:
|
|
1293
|
+
manager: "WorkspaceSessionManager",
|
|
1292
1294
|
websocket: WebSocket,
|
|
1293
1295
|
) -> None:
|
|
1294
1296
|
await websocket.accept()
|
|
@@ -1309,7 +1311,7 @@ async def _websocket_session_handler(
|
|
|
1309
1311
|
await websocket.send_json({"type": "error", "error": "invalid json"})
|
|
1310
1312
|
continue
|
|
1311
1313
|
action = str(payload.get("type") or payload.get("action") or "")
|
|
1312
|
-
if action
|
|
1314
|
+
if action in {"send", "answer"}:
|
|
1313
1315
|
target_session_id = str(payload.get("session_id") or session_id or "")
|
|
1314
1316
|
try:
|
|
1315
1317
|
target_link = manager.get(target_session_id or None)
|
|
@@ -1318,10 +1320,16 @@ async def _websocket_session_handler(
|
|
|
1318
1320
|
{"type": "error", "error": "session not found"}
|
|
1319
1321
|
)
|
|
1320
1322
|
continue
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1323
|
+
if action == "answer":
|
|
1324
|
+
result = await target_link.answer_input(
|
|
1325
|
+
payload.get("request_id"),
|
|
1326
|
+
payload.get("answer"),
|
|
1327
|
+
)
|
|
1328
|
+
else:
|
|
1329
|
+
result = await target_link.submit(
|
|
1330
|
+
str(payload.get("prompt") or ""),
|
|
1331
|
+
sender=str(payload.get("sender") or "web"),
|
|
1332
|
+
)
|
|
1325
1333
|
await websocket.send_json({"type": "send_result", "result": result})
|
|
1326
1334
|
elif action == "ping":
|
|
1327
1335
|
await websocket.send_json({"type": "pong"})
|
|
@@ -1365,6 +1373,58 @@ def _public_turn(turn: "typing.Dict[str, object]") -> "typing.Dict[str, object]"
|
|
|
1365
1373
|
)
|
|
1366
1374
|
|
|
1367
1375
|
|
|
1376
|
+
def _event_data(event: "Event") -> "typing.Dict[str, object]":
|
|
1377
|
+
payload = {
|
|
1378
|
+
item.name: getattr(event, item.name)
|
|
1379
|
+
for item in fields(event)
|
|
1380
|
+
if item.name not in {"turn_id", "submission_id"}
|
|
1381
|
+
}
|
|
1382
|
+
turn_id = ""
|
|
1383
|
+
if isinstance(event, TurnEvent):
|
|
1384
|
+
turn_id = event.turn_id
|
|
1385
|
+
if event.submission_id is not None:
|
|
1386
|
+
payload.update(submission_id=event.submission_id, turn_id=event.turn_id)
|
|
1387
|
+
elif isinstance(
|
|
1388
|
+
event, (CommandCompletedEvent, CommandFailedEvent, InputQueuedEvent)
|
|
1389
|
+
):
|
|
1390
|
+
turn_id = event.submission_id
|
|
1391
|
+
if isinstance(event, InputQueuedEvent):
|
|
1392
|
+
payload["submission_id"] = event.submission_id
|
|
1393
|
+
if isinstance(event, TurnStartedEvent):
|
|
1394
|
+
payload["user_text"] = "\n".join(event.user_texts)
|
|
1395
|
+
if isinstance(event, (ToolStartedEvent, ToolCompletedEvent)):
|
|
1396
|
+
payload.update(tool_name=event.call.name, call_id=event.call.call_id)
|
|
1397
|
+
if isinstance(event, ToolCompletedEvent):
|
|
1398
|
+
payload["is_error"] = event.result.is_error
|
|
1399
|
+
if isinstance(event, ToolCalledEvent):
|
|
1400
|
+
payload = {name: value for name, value in payload.items() if value is not None}
|
|
1401
|
+
if isinstance(
|
|
1402
|
+
event,
|
|
1403
|
+
(
|
|
1404
|
+
CompactStartedEvent,
|
|
1405
|
+
CompactCompletedEvent,
|
|
1406
|
+
CompactFailedEvent,
|
|
1407
|
+
AutoCompactStartedEvent,
|
|
1408
|
+
AutoCompactCompletedEvent,
|
|
1409
|
+
AutoCompactFailedEvent,
|
|
1410
|
+
),
|
|
1411
|
+
):
|
|
1412
|
+
for name in ("total_tokens", "token_limit"):
|
|
1413
|
+
if payload[name] is None:
|
|
1414
|
+
del payload[name]
|
|
1415
|
+
if payload.get("pruned_tool_results") == 0:
|
|
1416
|
+
del payload["pruned_tool_results"]
|
|
1417
|
+
if isinstance(event, (CompactCompletedEvent, AutoCompactCompletedEvent)):
|
|
1418
|
+
payload["summary"] = event.summary
|
|
1419
|
+
if isinstance(event, TerminalEvent) and event.background_work_count is None:
|
|
1420
|
+
del payload["background_work_count"]
|
|
1421
|
+
if isinstance(event, InputRequestedEvent):
|
|
1422
|
+
payload["kind"] = payload.pop("request_kind")
|
|
1423
|
+
payload["text"] = event.visualize()
|
|
1424
|
+
payload = {name: value for name, value in payload.items() if value is not None}
|
|
1425
|
+
return {"kind": event.kind, "turn_id": turn_id, "payload": _json_safe(payload)}
|
|
1426
|
+
|
|
1427
|
+
|
|
1368
1428
|
def _json_safe(value: object) -> "JSONValue":
|
|
1369
1429
|
if value is None or isinstance(value, (bool, int, float, str)):
|
|
1370
1430
|
return value
|
|
@@ -1372,6 +1432,8 @@ def _json_safe(value: object) -> "JSONValue":
|
|
|
1372
1432
|
return [_json_safe(item) for item in value]
|
|
1373
1433
|
if isinstance(value, dict):
|
|
1374
1434
|
return {str(key): _json_safe(item) for key, item in value.items()}
|
|
1435
|
+
if isinstance(value, InputRequestedEvent):
|
|
1436
|
+
return _event_data(value)["payload"]
|
|
1375
1437
|
if is_dataclass(value):
|
|
1376
1438
|
return _json_safe(asdict(value))
|
|
1377
1439
|
try:
|
|
@@ -1420,10 +1482,9 @@ def _board_asset_response(
|
|
|
1420
1482
|
try:
|
|
1421
1483
|
board_directory = board_path.parent.resolve()
|
|
1422
1484
|
resolved_asset = (board_directory / asset_path).resolve()
|
|
1423
|
-
within_board_directory = (
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
)
|
|
1485
|
+
within_board_directory = os.path.commonpath(
|
|
1486
|
+
[str(board_directory), str(resolved_asset)]
|
|
1487
|
+
) == str(board_directory)
|
|
1427
1488
|
except (OSError, RuntimeError, ValueError):
|
|
1428
1489
|
within_board_directory = False
|
|
1429
1490
|
|
|
@@ -1445,9 +1506,9 @@ def _board_asset_response(
|
|
|
1445
1506
|
|
|
1446
1507
|
|
|
1447
1508
|
def _workspace_entry_or_404(
|
|
1448
|
-
registry:
|
|
1509
|
+
registry: "WorkspaceRegistry",
|
|
1449
1510
|
workspace_id: str,
|
|
1450
|
-
) ->
|
|
1511
|
+
) -> "WorkspaceEntry":
|
|
1451
1512
|
try:
|
|
1452
1513
|
return registry.get(workspace_id)
|
|
1453
1514
|
except (KeyError, ValueError):
|
|
@@ -1461,10 +1522,7 @@ def run_serve_cli(args: "argparse.Namespace") -> int:
|
|
|
1461
1522
|
configure_loguru()
|
|
1462
1523
|
|
|
1463
1524
|
definitions = load_workspace_definitions(args.workspace_config)
|
|
1464
|
-
entries = [
|
|
1465
|
-
_build_workspace_entry(definition, args)
|
|
1466
|
-
for definition in definitions
|
|
1467
|
-
]
|
|
1525
|
+
entries = [_build_workspace_entry(definition, args) for definition in definitions]
|
|
1468
1526
|
registry = WorkspaceRegistry(
|
|
1469
1527
|
entries,
|
|
1470
1528
|
config_path=args.workspace_config,
|
|
@@ -1496,10 +1554,10 @@ def run_serve_cli(args: "argparse.Namespace") -> int:
|
|
|
1496
1554
|
|
|
1497
1555
|
|
|
1498
1556
|
def _build_workspace_entry(
|
|
1499
|
-
definition:
|
|
1557
|
+
definition: "WorkspaceDefinition",
|
|
1500
1558
|
args: "argparse.Namespace",
|
|
1501
1559
|
persist_callback: "typing.Union[typing.Callable[[], None], None]" = None,
|
|
1502
|
-
) ->
|
|
1560
|
+
) -> "WorkspaceEntry":
|
|
1503
1561
|
def build_session() -> "WorkspaceInteractiveSession":
|
|
1504
1562
|
model = build_model(
|
|
1505
1563
|
config_path=args.config,
|
|
@@ -1514,21 +1572,23 @@ def _build_workspace_entry(
|
|
|
1514
1572
|
config_path=args.config,
|
|
1515
1573
|
profile=args.profile,
|
|
1516
1574
|
system_prompt=args.system_prompt,
|
|
1517
|
-
session_mode="tui",
|
|
1518
1575
|
extra_contextual_user_messages=(
|
|
1519
1576
|
[_board_context_text(definition.board_path, definition.work_dir)]
|
|
1520
1577
|
if definition.board_path is not None
|
|
1521
1578
|
else []
|
|
1522
1579
|
),
|
|
1523
1580
|
cwd=definition.work_dir,
|
|
1581
|
+
toolset=args.toolset,
|
|
1524
1582
|
)
|
|
1525
1583
|
return WorkspaceInteractiveSession(
|
|
1526
|
-
|
|
1584
|
+
build_runtime(agent),
|
|
1527
1585
|
config_path=args.config,
|
|
1528
1586
|
)
|
|
1529
1587
|
|
|
1530
1588
|
def session_factory() -> "ThreadedWorkspaceInteractiveSession":
|
|
1531
|
-
return ThreadedWorkspaceInteractiveSession(
|
|
1589
|
+
return ThreadedWorkspaceInteractiveSession(
|
|
1590
|
+
build_session, asyncio.get_running_loop()
|
|
1591
|
+
)
|
|
1532
1592
|
|
|
1533
1593
|
return WorkspaceEntry(
|
|
1534
1594
|
definition=definition,
|
|
@@ -1578,12 +1638,9 @@ def _render_workspace_shell(
|
|
|
1578
1638
|
board_label = str(board_path) if board_path is not None else "No board"
|
|
1579
1639
|
cwd_label = str(work_dir or Path.cwd())
|
|
1580
1640
|
page_title = str(title or "pycodex workspace")
|
|
1581
|
-
template = (Path(__file__).with_name("workspace.html")).read_text(
|
|
1582
|
-
encoding="utf-8"
|
|
1583
|
-
)
|
|
1641
|
+
template = (Path(__file__).with_name("workspace.html")).read_text(encoding="utf-8")
|
|
1584
1642
|
return (
|
|
1585
|
-
template
|
|
1586
|
-
.replace("__WORKSPACE_TITLE__", html.escape(page_title))
|
|
1643
|
+
template.replace("__WORKSPACE_TITLE__", html.escape(page_title))
|
|
1587
1644
|
.replace("__BOARD_LABEL__", html.escape(board_label))
|
|
1588
1645
|
.replace("__CWD_LABEL__", html.escape(cwd_label))
|
|
1589
1646
|
)
|
|
@@ -1594,23 +1651,52 @@ def _render_workspaces_manager_shell() -> str:
|
|
|
1594
1651
|
|
|
1595
1652
|
|
|
1596
1653
|
def _render_empty_board() -> str:
|
|
1597
|
-
return
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
</body></html>"""
|
|
1654
|
+
return _render_board_placeholder(
|
|
1655
|
+
"No board",
|
|
1656
|
+
"No board connected",
|
|
1657
|
+
"Add a board from the workspaces page to see your work here.",
|
|
1658
|
+
)
|
|
1603
1659
|
|
|
1604
1660
|
|
|
1605
1661
|
def _render_missing_board(board_path: Path) -> str:
|
|
1606
|
-
|
|
1662
|
+
return _render_board_placeholder(
|
|
1663
|
+
"Board pending",
|
|
1664
|
+
"Your work will appear here",
|
|
1665
|
+
"Ask pycodex to create a page, a report, or a visual. "
|
|
1666
|
+
"This canvas updates as you work.",
|
|
1667
|
+
str(board_path),
|
|
1668
|
+
)
|
|
1669
|
+
|
|
1670
|
+
|
|
1671
|
+
def _render_board_placeholder(
|
|
1672
|
+
title: str, heading: str, description: str, path_label: str = ""
|
|
1673
|
+
) -> str:
|
|
1607
1674
|
return """<!doctype html>
|
|
1608
|
-
<html><head><meta charset="utf-8"
|
|
1609
|
-
<
|
|
1610
|
-
<
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1675
|
+
<html lang="en"><head><meta charset="utf-8">
|
|
1676
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
1677
|
+
<title>{0}</title><style>
|
|
1678
|
+
* {{ box-sizing: border-box; }}
|
|
1679
|
+
body {{ margin: 0; min-height: 100dvh; display: grid; place-items: center;
|
|
1680
|
+
padding: 32px; background: #f8fbfd; color: #172630;
|
|
1681
|
+
font: 14px/1.7 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; }}
|
|
1682
|
+
main {{ max-width: 360px; text-align: center; }}
|
|
1683
|
+
svg {{ width: 40px; height: 40px; color: #aebfc9; margin-bottom: 20px; }}
|
|
1684
|
+
h1 {{ margin: 0 0 12px; font-size: 23px; font-weight: 500; line-height: 1.35;
|
|
1685
|
+
letter-spacing: -0.5px; }}
|
|
1686
|
+
p {{ margin: 0; color: #60727d; }}
|
|
1687
|
+
code {{ display: block; margin-top: 28px; font-size: 11px; color: #60727d;
|
|
1688
|
+
overflow-wrap: anywhere; }}
|
|
1689
|
+
</style></head><body><main>
|
|
1690
|
+
<svg viewBox="0 0 32 32" fill="none" stroke="currentColor" stroke-width="1.25"
|
|
1691
|
+
aria-hidden="true"><rect x="3" y="3" width="26" height="26" rx="5"/>
|
|
1692
|
+
<path d="M3 11h26M11 11v18"/></svg>
|
|
1693
|
+
<h1>{1}</h1><p>{2}</p><code>{3}</code>
|
|
1694
|
+
</main></body></html>""".format(
|
|
1695
|
+
html.escape(title),
|
|
1696
|
+
html.escape(heading),
|
|
1697
|
+
html.escape(description),
|
|
1698
|
+
html.escape(path_label),
|
|
1699
|
+
)
|
|
1614
1700
|
|
|
1615
1701
|
|
|
1616
1702
|
def main(argv: "typing.Union[typing.Sequence[str], None]" = None) -> int:
|