pycodex-ws 0.4.8__tar.gz → 0.5.0__tar.gz
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_ws-0.5.0/PKG-INFO +121 -0
- pycodex_ws-0.5.0/README.md +110 -0
- {pycodex_ws-0.4.8 → pycodex_ws-0.5.0}/pycodex_router/index.html +11 -1
- {pycodex_ws-0.4.8 → pycodex_ws-0.5.0}/pyproject.toml +2 -2
- {pycodex_ws-0.4.8 → pycodex_ws-0.5.0}/workspace_server/app.py +46 -8
- {pycodex_ws-0.4.8 → pycodex_ws-0.5.0}/workspace_server/content_views.mjs +1 -1
- pycodex_ws-0.5.0/workspace_server/customization.py +80 -0
- {pycodex_ws-0.4.8 → pycodex_ws-0.5.0}/workspace_server/frontend/content_views.mjs +3 -1
- {pycodex_ws-0.4.8 → pycodex_ws-0.5.0}/workspace_server/workspace.html +475 -571
- {pycodex_ws-0.4.8 → pycodex_ws-0.5.0}/workspace_server/workspaces.html +11 -1
- pycodex_ws-0.4.8/PKG-INFO +0 -166
- pycodex_ws-0.4.8/README.md +0 -155
- {pycodex_ws-0.4.8 → pycodex_ws-0.5.0}/.gitignore +0 -0
- {pycodex_ws-0.4.8 → pycodex_ws-0.5.0}/LICENSE +0 -0
- {pycodex_ws-0.4.8 → pycodex_ws-0.5.0}/pycodex_router/__init__.py +0 -0
- {pycodex_ws-0.4.8 → pycodex_ws-0.5.0}/pycodex_router/__main__.py +0 -0
- {pycodex_ws-0.4.8 → pycodex_ws-0.5.0}/pycodex_router/browser.py +0 -0
- {pycodex_ws-0.4.8 → pycodex_ws-0.5.0}/pycodex_router/client.py +0 -0
- {pycodex_ws-0.4.8 → pycodex_ws-0.5.0}/pycodex_router/protocol.py +0 -0
- {pycodex_ws-0.4.8 → pycodex_ws-0.5.0}/pycodex_router/server.py +0 -0
- {pycodex_ws-0.4.8 → pycodex_ws-0.5.0}/pycodex_ws/__init__.py +0 -0
- {pycodex_ws-0.4.8 → pycodex_ws-0.5.0}/pycodex_ws/feishu_card.py +0 -0
- {pycodex_ws-0.4.8 → pycodex_ws-0.5.0}/pycodex_ws/feishu_link.py +0 -0
- {pycodex_ws-0.4.8 → pycodex_ws-0.5.0}/pycodex_ws/feishu_oauth.py +0 -0
- {pycodex_ws-0.4.8 → pycodex_ws-0.5.0}/workspace_server/__init__.py +0 -0
- {pycodex_ws-0.4.8 → pycodex_ws-0.5.0}/workspace_server/__main__.py +0 -0
- {pycodex_ws-0.4.8 → pycodex_ws-0.5.0}/workspace_server/agent_access.py +0 -0
- {pycodex_ws-0.4.8 → pycodex_ws-0.5.0}/workspace_server/content_views.LICENSE.txt +0 -0
- {pycodex_ws-0.4.8 → pycodex_ws-0.5.0}/workspace_server/content_views.css +0 -0
- {pycodex_ws-0.4.8 → pycodex_ws-0.5.0}/workspace_server/editor.css +0 -0
- {pycodex_ws-0.4.8 → pycodex_ws-0.5.0}/workspace_server/editor.mjs +0 -0
- {pycodex_ws-0.4.8 → pycodex_ws-0.5.0}/workspace_server/frontend/.gitignore +0 -0
- {pycodex_ws-0.4.8 → pycodex_ws-0.5.0}/workspace_server/frontend/build.mjs +0 -0
- {pycodex_ws-0.4.8 → pycodex_ws-0.5.0}/workspace_server/frontend/composer.mjs +0 -0
- {pycodex_ws-0.4.8 → pycodex_ws-0.5.0}/workspace_server/frontend/content_views.css +0 -0
- {pycodex_ws-0.4.8 → pycodex_ws-0.5.0}/workspace_server/frontend/editor.css +0 -0
- {pycodex_ws-0.4.8 → pycodex_ws-0.5.0}/workspace_server/frontend/editor.mjs +0 -0
- {pycodex_ws-0.4.8 → pycodex_ws-0.5.0}/workspace_server/frontend/editor_context.mjs +0 -0
- {pycodex_ws-0.4.8 → pycodex_ws-0.5.0}/workspace_server/frontend/package-lock.json +0 -0
- {pycodex_ws-0.4.8 → pycodex_ws-0.5.0}/workspace_server/frontend/package.json +0 -0
- {pycodex_ws-0.4.8 → pycodex_ws-0.5.0}/workspace_server/frontend/touch_selection.mjs +0 -0
- {pycodex_ws-0.4.8 → pycodex_ws-0.5.0}/workspace_server/frontend/view_labels.mjs +0 -0
- {pycodex_ws-0.4.8 → pycodex_ws-0.5.0}/workspace_server/owned_process.py +0 -0
- {pycodex_ws-0.4.8 → pycodex_ws-0.5.0}/workspace_server/runtime_info.py +0 -0
- {pycodex_ws-0.4.8 → pycodex_ws-0.5.0}/workspace_server/workspace_views.py +0 -0
- {pycodex_ws-0.4.8 → pycodex_ws-0.5.0}/workspace_server/workspaces.py +0 -0
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: pycodex-ws
|
|
3
|
+
Version: 0.5.0
|
|
4
|
+
Summary: Browser workspace and optional Feishu integration for python-codex
|
|
5
|
+
License-File: LICENSE
|
|
6
|
+
Requires-Python: >=3.7
|
|
7
|
+
Requires-Dist: pillow>=9.0.0
|
|
8
|
+
Requires-Dist: python-codex==0.5.0
|
|
9
|
+
Requires-Dist: websockets<16,>=11
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
|
|
12
|
+
# pycodex-ws
|
|
13
|
+
|
|
14
|
+
Browser conversations, a live HTML Canvas, read-only file/diff viewers and
|
|
15
|
+
optional Feishu integration for
|
|
16
|
+
[`python-codex`](https://pypi.org/project/python-codex/).
|
|
17
|
+
Requires Python 3.7+ and installs the matching core version.
|
|
18
|
+
|
|
19
|
+
## Quick start
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
python3 -m pip install pycodex-ws
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Create `workspaces.json`:
|
|
26
|
+
|
|
27
|
+
```json
|
|
28
|
+
{
|
|
29
|
+
"workspaces": [
|
|
30
|
+
{"id": "demo", "work_dir": ".", "state_path": "demo", "enable_board": true}
|
|
31
|
+
]
|
|
32
|
+
}
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pycodex-ws --listen 127.0.0.1:6007 --workspace-config workspaces.json
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Open `http://127.0.0.1:6007`. Use `python3 -m workspace_server` if the command
|
|
40
|
+
is not on `PATH`. Keep stdin open while the server runs; EOF initiates shutdown.
|
|
41
|
+
Model configuration comes from `~/.codex/config.toml`.
|
|
42
|
+
|
|
43
|
+
## Conversations and views
|
|
44
|
+
|
|
45
|
+
The session sidebar starts collapsed. Hover to preview it, click to keep it
|
|
46
|
+
open, and click the toggle or empty space again to close it. Expansion narrows
|
|
47
|
+
the conversation while keeping the board fixed. On phones, use the header
|
|
48
|
+
buttons to switch between chat and board.
|
|
49
|
+
|
|
50
|
+
Use the expanded sidebar to create, rename or close sessions. Status dots show
|
|
51
|
+
running sessions and unread replies. `/cd <directory>` changes the current
|
|
52
|
+
session's working directory while idle; `/model`, `/resume`, `/compact` and
|
|
53
|
+
other core session commands are also available.
|
|
54
|
+
|
|
55
|
+
Click Markdown links to open files and comparisons, for example
|
|
56
|
+
`[source](app.py:42)` or `[changes](diff:HEAD:app.py)`. In a code or diff view,
|
|
57
|
+
select text and choose **Insert into chat** to add editable context to the draft.
|
|
58
|
+
Click a context block to edit or remove it before sending.
|
|
59
|
+
See [view links and supported formats](../../docs/RUNTIME.md#workspace-content-views).
|
|
60
|
+
|
|
61
|
+
When using a router, its directory groups workspaces by device and lists unread
|
|
62
|
+
workspaces at the top. Device handles reorder the list; pencil buttons edit
|
|
63
|
+
device names.
|
|
64
|
+
|
|
65
|
+
The [VS Code extension](extensions/vscode/README.md) uses this backend and is
|
|
66
|
+
distributed separately as a VSIX.
|
|
67
|
+
|
|
68
|
+
## Customize the interface
|
|
69
|
+
|
|
70
|
+
Click the gear button to start a customization conversation with the Agent.
|
|
71
|
+
Custom files live in the server user's `~/.pycodex/frontend/`:
|
|
72
|
+
|
|
73
|
+
- `custom.css` and `custom.js`: optional styles and an ES module, loaded automatically.
|
|
74
|
+
- `workspace.html`: optional full-page override; otherwise the packaged page is used.
|
|
75
|
+
- Other files: available at workspace-relative `frontend/<path>` URLs.
|
|
76
|
+
|
|
77
|
+
Refresh to apply changes and keep the selected conversation. Open the workspace
|
|
78
|
+
with `?builtin=1` to bypass all customizations; click “Re-enable customizations”
|
|
79
|
+
to return. Changes apply to all workspaces for that OS user.
|
|
80
|
+
See the [customization contract](../../docs/RUNTIME.md#frontend-customization).
|
|
81
|
+
|
|
82
|
+
## Storage
|
|
83
|
+
|
|
84
|
+
Paths in `workspaces.json` resolve from the configuration directory.
|
|
85
|
+
`state_path` is a unique filename prefix: the example saves sessions in
|
|
86
|
+
`demo.pycodex-ws.json`, file views in `demo.pycodex-views.json`, and Canvas in
|
|
87
|
+
`demo.html`. Suffixes are appended to the complete prefix, including any dots.
|
|
88
|
+
|
|
89
|
+
Set `enable_board` to `false` to disable Canvas while retaining conversations
|
|
90
|
+
and file views. Session directories and history survive server restarts;
|
|
91
|
+
changing a session's directory does not move Canvas or workspace state files.
|
|
92
|
+
Configuration and restoration details are in the [runtime guide](../../docs/RUNTIME.md).
|
|
93
|
+
|
|
94
|
+
## Feishu
|
|
95
|
+
|
|
96
|
+
Install the optional SDK in the workspace's Python environment, then authorize:
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
python3 -m pip install lark-oapi
|
|
100
|
+
python3 -m pycodex_ws.feishu_oauth
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Authorization reads app credentials from `~/.codex/.env` and stores the refresh
|
|
104
|
+
token in `~/.codex/.feishu_refresh_token`. In a workspace session, use
|
|
105
|
+
`/link <feishu-email|open_id|chat_id>` to connect a card and `/unlink` to disconnect.
|
|
106
|
+
Other workspace features work without the SDK.
|
|
107
|
+
|
|
108
|
+
## Development
|
|
109
|
+
|
|
110
|
+
From the repository root:
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
uv sync --dev
|
|
114
|
+
uv run pycodex-ws --help
|
|
115
|
+
uv run --dev pytest packages/pycodex-ws/tests
|
|
116
|
+
uv build packages/pycodex-ws
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Python integrations use `pycodex_ws.build_runtime(agent)` to include workspace
|
|
120
|
+
commands. See [runtime contracts](../../docs/RUNTIME.md) and
|
|
121
|
+
[test documentation](../../tests/TESTS.md).
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# pycodex-ws
|
|
2
|
+
|
|
3
|
+
Browser conversations, a live HTML Canvas, read-only file/diff viewers and
|
|
4
|
+
optional Feishu integration for
|
|
5
|
+
[`python-codex`](https://pypi.org/project/python-codex/).
|
|
6
|
+
Requires Python 3.7+ and installs the matching core version.
|
|
7
|
+
|
|
8
|
+
## Quick start
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
python3 -m pip install pycodex-ws
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Create `workspaces.json`:
|
|
15
|
+
|
|
16
|
+
```json
|
|
17
|
+
{
|
|
18
|
+
"workspaces": [
|
|
19
|
+
{"id": "demo", "work_dir": ".", "state_path": "demo", "enable_board": true}
|
|
20
|
+
]
|
|
21
|
+
}
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pycodex-ws --listen 127.0.0.1:6007 --workspace-config workspaces.json
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Open `http://127.0.0.1:6007`. Use `python3 -m workspace_server` if the command
|
|
29
|
+
is not on `PATH`. Keep stdin open while the server runs; EOF initiates shutdown.
|
|
30
|
+
Model configuration comes from `~/.codex/config.toml`.
|
|
31
|
+
|
|
32
|
+
## Conversations and views
|
|
33
|
+
|
|
34
|
+
The session sidebar starts collapsed. Hover to preview it, click to keep it
|
|
35
|
+
open, and click the toggle or empty space again to close it. Expansion narrows
|
|
36
|
+
the conversation while keeping the board fixed. On phones, use the header
|
|
37
|
+
buttons to switch between chat and board.
|
|
38
|
+
|
|
39
|
+
Use the expanded sidebar to create, rename or close sessions. Status dots show
|
|
40
|
+
running sessions and unread replies. `/cd <directory>` changes the current
|
|
41
|
+
session's working directory while idle; `/model`, `/resume`, `/compact` and
|
|
42
|
+
other core session commands are also available.
|
|
43
|
+
|
|
44
|
+
Click Markdown links to open files and comparisons, for example
|
|
45
|
+
`[source](app.py:42)` or `[changes](diff:HEAD:app.py)`. In a code or diff view,
|
|
46
|
+
select text and choose **Insert into chat** to add editable context to the draft.
|
|
47
|
+
Click a context block to edit or remove it before sending.
|
|
48
|
+
See [view links and supported formats](../../docs/RUNTIME.md#workspace-content-views).
|
|
49
|
+
|
|
50
|
+
When using a router, its directory groups workspaces by device and lists unread
|
|
51
|
+
workspaces at the top. Device handles reorder the list; pencil buttons edit
|
|
52
|
+
device names.
|
|
53
|
+
|
|
54
|
+
The [VS Code extension](extensions/vscode/README.md) uses this backend and is
|
|
55
|
+
distributed separately as a VSIX.
|
|
56
|
+
|
|
57
|
+
## Customize the interface
|
|
58
|
+
|
|
59
|
+
Click the gear button to start a customization conversation with the Agent.
|
|
60
|
+
Custom files live in the server user's `~/.pycodex/frontend/`:
|
|
61
|
+
|
|
62
|
+
- `custom.css` and `custom.js`: optional styles and an ES module, loaded automatically.
|
|
63
|
+
- `workspace.html`: optional full-page override; otherwise the packaged page is used.
|
|
64
|
+
- Other files: available at workspace-relative `frontend/<path>` URLs.
|
|
65
|
+
|
|
66
|
+
Refresh to apply changes and keep the selected conversation. Open the workspace
|
|
67
|
+
with `?builtin=1` to bypass all customizations; click “Re-enable customizations”
|
|
68
|
+
to return. Changes apply to all workspaces for that OS user.
|
|
69
|
+
See the [customization contract](../../docs/RUNTIME.md#frontend-customization).
|
|
70
|
+
|
|
71
|
+
## Storage
|
|
72
|
+
|
|
73
|
+
Paths in `workspaces.json` resolve from the configuration directory.
|
|
74
|
+
`state_path` is a unique filename prefix: the example saves sessions in
|
|
75
|
+
`demo.pycodex-ws.json`, file views in `demo.pycodex-views.json`, and Canvas in
|
|
76
|
+
`demo.html`. Suffixes are appended to the complete prefix, including any dots.
|
|
77
|
+
|
|
78
|
+
Set `enable_board` to `false` to disable Canvas while retaining conversations
|
|
79
|
+
and file views. Session directories and history survive server restarts;
|
|
80
|
+
changing a session's directory does not move Canvas or workspace state files.
|
|
81
|
+
Configuration and restoration details are in the [runtime guide](../../docs/RUNTIME.md).
|
|
82
|
+
|
|
83
|
+
## Feishu
|
|
84
|
+
|
|
85
|
+
Install the optional SDK in the workspace's Python environment, then authorize:
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
python3 -m pip install lark-oapi
|
|
89
|
+
python3 -m pycodex_ws.feishu_oauth
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Authorization reads app credentials from `~/.codex/.env` and stores the refresh
|
|
93
|
+
token in `~/.codex/.feishu_refresh_token`. In a workspace session, use
|
|
94
|
+
`/link <feishu-email|open_id|chat_id>` to connect a card and `/unlink` to disconnect.
|
|
95
|
+
Other workspace features work without the SDK.
|
|
96
|
+
|
|
97
|
+
## Development
|
|
98
|
+
|
|
99
|
+
From the repository root:
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
uv sync --dev
|
|
103
|
+
uv run pycodex-ws --help
|
|
104
|
+
uv run --dev pytest packages/pycodex-ws/tests
|
|
105
|
+
uv build packages/pycodex-ws
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Python integrations use `pycodex_ws.build_runtime(agent)` to include workspace
|
|
109
|
+
commands. See [runtime contracts](../../docs/RUNTIME.md) and
|
|
110
|
+
[test documentation](../../tests/TESTS.md).
|
|
@@ -19,6 +19,8 @@
|
|
|
19
19
|
--danger: #b54739;
|
|
20
20
|
--unread-bg: #fff7f4;
|
|
21
21
|
--unread-line: #ead0c8;
|
|
22
|
+
--breathing-duration: 1.2s;
|
|
23
|
+
--breathing-ease: cubic-bezier(0.4, 0, 0.2, 1);
|
|
22
24
|
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
23
25
|
}
|
|
24
26
|
@media (prefers-color-scheme: dark) {
|
|
@@ -97,7 +99,7 @@
|
|
|
97
99
|
.activity-badge[hidden] { display: none; }
|
|
98
100
|
.workspace-group-name .device-working { margin-left: 8px; white-space: nowrap; }
|
|
99
101
|
.activity-badge[data-state="working"] { color: var(--green); background: var(--green-soft); }
|
|
100
|
-
.activity-badge[data-state="working"]::before { content: ""; width: 5px; height: 5px; border-radius: 50%; background: currentColor; animation: spinner-pulse
|
|
102
|
+
.activity-badge[data-state="working"]::before { content: ""; width: 5px; height: 5px; border-radius: 50%; background: currentColor; animation: spinner-pulse var(--breathing-duration) var(--breathing-ease) infinite; }
|
|
101
103
|
@keyframes spinner-pulse {
|
|
102
104
|
0%, 100% { opacity: .45; transform: scale(.82); }
|
|
103
105
|
50% { opacity: 1; transform: scale(1.08); }
|
|
@@ -643,6 +645,14 @@
|
|
|
643
645
|
document.getElementById("activity").textContent = running ? `${running} working` : "";
|
|
644
646
|
}
|
|
645
647
|
|
|
648
|
+
document.addEventListener("animationstart", event => {
|
|
649
|
+
if (event.animationName !== "spinner-pulse") return;
|
|
650
|
+
// Include pseudo-elements and anchor new indicators to the page's breathing phase.
|
|
651
|
+
for (const animation of event.target.getAnimations({subtree: true})) {
|
|
652
|
+
if (animation.animationName === event.animationName) animation.startTime = 0;
|
|
653
|
+
}
|
|
654
|
+
});
|
|
655
|
+
|
|
646
656
|
async function refresh() {
|
|
647
657
|
if (refreshing) return;
|
|
648
658
|
refreshing = true;
|
|
@@ -4,12 +4,12 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "pycodex-ws"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.5.0"
|
|
8
8
|
description = "Browser workspace and optional Feishu integration for python-codex"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.7"
|
|
11
11
|
dependencies = [
|
|
12
|
-
"python-codex==0.
|
|
12
|
+
"python-codex==0.5.0",
|
|
13
13
|
"pillow>=9.0.0",
|
|
14
14
|
"websockets>=11,<16",
|
|
15
15
|
]
|
|
@@ -68,6 +68,7 @@ from pycodex.events import (
|
|
|
68
68
|
)
|
|
69
69
|
from pycodex.model import DEFAULT_CODEX_CONFIG_PATH
|
|
70
70
|
from pycodex.utils import uuid7_string
|
|
71
|
+
from pycodex.utils.compat import importlib_metadata
|
|
71
72
|
from pycodex.utils.event_helpers import (
|
|
72
73
|
completed_history,
|
|
73
74
|
shorten_title,
|
|
@@ -76,6 +77,11 @@ from pycodex.utils.session_persist import load_display_history
|
|
|
76
77
|
from pycodex_ws import build_runtime
|
|
77
78
|
from pycodex_router.protocol import trusted_agent_request
|
|
78
79
|
|
|
80
|
+
from .customization import (
|
|
81
|
+
frontend_asset_response,
|
|
82
|
+
frontend_directory,
|
|
83
|
+
load_workspace_template,
|
|
84
|
+
)
|
|
79
85
|
from .runtime_info import runtime_info_prompt
|
|
80
86
|
from .workspace_views import WorkspaceViewOpener
|
|
81
87
|
from .workspaces import (
|
|
@@ -950,6 +956,7 @@ def create_multi_workspace_app(
|
|
|
950
956
|
entry.definition.board_path,
|
|
951
957
|
title=entry.definition.workspace_id,
|
|
952
958
|
work_dir=entry.definition.work_dir,
|
|
959
|
+
builtin=request.query_params.get("builtin") == "1",
|
|
953
960
|
)
|
|
954
961
|
)
|
|
955
962
|
|
|
@@ -996,7 +1003,11 @@ def create_multi_workspace_app(
|
|
|
996
1003
|
|
|
997
1004
|
async def workspace_new_session(request: Request) -> JSONResponse:
|
|
998
1005
|
entry = _workspace_entry_or_404(registry, request.path_params["workspace_id"])
|
|
999
|
-
return await _new_session_response(entry.manager)
|
|
1006
|
+
return await _new_session_response(entry.manager, request)
|
|
1007
|
+
|
|
1008
|
+
def workspace_frontend_asset(request: Request) -> Response:
|
|
1009
|
+
_workspace_entry_or_404(registry, request.path_params["workspace_id"])
|
|
1010
|
+
return frontend_asset_response(request.path_params["asset_path"])
|
|
1000
1011
|
|
|
1001
1012
|
async def workspace_delete_session(request: Request) -> JSONResponse:
|
|
1002
1013
|
entry = _workspace_entry_or_404(registry, request.path_params["workspace_id"])
|
|
@@ -1082,6 +1093,10 @@ def create_multi_workspace_app(
|
|
|
1082
1093
|
),
|
|
1083
1094
|
Route("/w/{workspace_id}/api/sessions", workspace_sessions, methods=["GET"]),
|
|
1084
1095
|
Route("/w/{workspace_id}/api/sessions", workspace_new_session, methods=["POST"]),
|
|
1096
|
+
Route(
|
|
1097
|
+
"/w/{workspace_id}/frontend/{asset_path:path}",
|
|
1098
|
+
workspace_frontend_asset, methods=["GET", "HEAD"],
|
|
1099
|
+
),
|
|
1085
1100
|
Route(
|
|
1086
1101
|
"/w/{workspace_id}/api/sessions/{session_id}",
|
|
1087
1102
|
workspace_delete_session, methods=["DELETE"],
|
|
@@ -1368,7 +1383,10 @@ def _install_workspace_routes(
|
|
|
1368
1383
|
view_asset_cache = {}
|
|
1369
1384
|
|
|
1370
1385
|
async def index(request: Request) -> HTMLResponse:
|
|
1371
|
-
return _html_response(_render_workspace_shell(
|
|
1386
|
+
return _html_response(_render_workspace_shell(
|
|
1387
|
+
board_path, work_dir=work_dir,
|
|
1388
|
+
builtin=request.query_params.get("builtin") == "1",
|
|
1389
|
+
))
|
|
1372
1390
|
|
|
1373
1391
|
async def favicon(request: Request) -> Response:
|
|
1374
1392
|
return Response(status_code=204)
|
|
@@ -1404,7 +1422,10 @@ def _install_workspace_routes(
|
|
|
1404
1422
|
return _sessions_response(manager)
|
|
1405
1423
|
|
|
1406
1424
|
async def new_session(request: Request) -> JSONResponse:
|
|
1407
|
-
return await _new_session_response(manager)
|
|
1425
|
+
return await _new_session_response(manager, request)
|
|
1426
|
+
|
|
1427
|
+
def frontend_asset(request: Request) -> Response:
|
|
1428
|
+
return frontend_asset_response(request.path_params["asset_path"])
|
|
1408
1429
|
|
|
1409
1430
|
async def delete_session(request: Request) -> JSONResponse:
|
|
1410
1431
|
return await _delete_session_response(manager, request.path_params["session_id"])
|
|
@@ -1449,6 +1470,7 @@ def _install_workspace_routes(
|
|
|
1449
1470
|
Route("/ws/session", websocket_backend_hint, methods=["GET"]),
|
|
1450
1471
|
Route("/api/sessions", sessions, methods=["GET"]),
|
|
1451
1472
|
Route("/api/sessions", new_session, methods=["POST"]),
|
|
1473
|
+
Route("/frontend/{asset_path:path}", frontend_asset, methods=["GET", "HEAD"]),
|
|
1452
1474
|
Route("/api/sessions/{session_id}", delete_session, methods=["DELETE"]),
|
|
1453
1475
|
Route("/api/sessions/{session_id}/read", read_session, methods=["POST"]),
|
|
1454
1476
|
Route("/api/session", session, methods=["GET"]),
|
|
@@ -1619,15 +1641,27 @@ def _read_session_response(
|
|
|
1619
1641
|
return _sessions_response(manager)
|
|
1620
1642
|
|
|
1621
1643
|
|
|
1622
|
-
async def _new_session_response(
|
|
1623
|
-
|
|
1644
|
+
async def _new_session_response(
|
|
1645
|
+
manager: "WorkspaceSessionManager", request: Request,
|
|
1646
|
+
) -> JSONResponse:
|
|
1647
|
+
payload = await _json_object_body(request) if await request.body() else {}
|
|
1648
|
+
title = payload.get("title", "")
|
|
1649
|
+
prompt = payload.get("prompt", "")
|
|
1650
|
+
if not isinstance(title, str) or not isinstance(prompt, str):
|
|
1651
|
+
raise HTTPException(status_code=422, detail="title and prompt must be strings")
|
|
1652
|
+
session_id = await manager.create_session(title=title)
|
|
1653
|
+
result = (
|
|
1654
|
+
await manager.get(session_id).submit(prompt, sender="web")
|
|
1655
|
+
if prompt else {"ok": True}
|
|
1656
|
+
)
|
|
1624
1657
|
return JSONResponse(
|
|
1625
1658
|
{
|
|
1626
|
-
|
|
1659
|
+
**result,
|
|
1627
1660
|
"session_id": session_id,
|
|
1628
1661
|
"sessions": manager.list_sessions(),
|
|
1629
1662
|
"snapshot": session_snapshot(manager.get(session_id)),
|
|
1630
|
-
}
|
|
1663
|
+
},
|
|
1664
|
+
status_code=200 if result["ok"] else 400,
|
|
1631
1665
|
)
|
|
1632
1666
|
|
|
1633
1667
|
|
|
@@ -2034,17 +2068,21 @@ def _render_workspace_shell(
|
|
|
2034
2068
|
board_path: "typing.Union[Path, None]",
|
|
2035
2069
|
title: "typing.Union[str, None]" = None,
|
|
2036
2070
|
work_dir: "typing.Union[Path, None]" = None,
|
|
2071
|
+
builtin: bool = False,
|
|
2037
2072
|
) -> str:
|
|
2038
2073
|
board_label = str(board_path) if board_path is not None else "No board"
|
|
2039
2074
|
cwd_label = str(work_dir or Path.cwd())
|
|
2040
2075
|
page_title = str(title or "pycodex workspace")
|
|
2041
|
-
template = (
|
|
2076
|
+
template = load_workspace_template(builtin)
|
|
2042
2077
|
return (
|
|
2043
2078
|
template.replace("__WORKSPACE_TITLE__", html.escape(page_title))
|
|
2044
2079
|
.replace("__BOARD_LABEL__", html.escape(board_label))
|
|
2045
2080
|
.replace("__CWD_LABEL__", html.escape(cwd_label))
|
|
2046
2081
|
.replace("__ENABLE_BOARD__", "true" if board_path is not None else "false")
|
|
2047
2082
|
.replace("__BOARD_URL__", "board" if board_path is not None else "about:blank")
|
|
2083
|
+
.replace("__FRONTEND_DIRECTORY__", html.escape(str(frontend_directory())))
|
|
2084
|
+
.replace("__PACKAGE_DIRECTORY__", html.escape(str(Path(__file__).resolve().parent)))
|
|
2085
|
+
.replace("__PACKAGE_VERSION__", html.escape(importlib_metadata.version("pycodex-ws")))
|
|
2048
2086
|
)
|
|
2049
2087
|
|
|
2050
2088
|
|
|
@@ -26,6 +26,6 @@ Click to edit context; Backspace / Delete to remove`,this.element.append(n,docum
|
|
|
26
26
|
type: "workspace-preview-ready", generation,
|
|
27
27
|
}, "*"));
|
|
28
28
|
})();`,e.head.prepend(s);let n=e.doctype?new this.window.XMLSerializer().serializeToString(e.doctype)+`
|
|
29
|
-
`:"";this.frame.srcdoc=n+e.documentElement.outerHTML,this.generation=i,this.source=t}capture(){return this.position}destroy(){this.window.removeEventListener("message",this.receive)}},P=class{constructor({content:t,renderMarkdown:e,loadEditor:i=null,onInsertContext:s=null,createHtmlPreview:n=(o,r)=>new O(o,r)}){this.content=t,this.document=t.ownerDocument,this.renderMarkdown=e,this.onInsertContext=s,this.loadEditor=i||(()=>this.loadEditorAssets()),this.editorAssets=null,this.createHtmlPreview=n,this.activeId=null,this.editor=null,this.htmlPreview=null,this.generation=0,this.remaining=new Set,this.editorStates=new Map,this.previews=new Map,this.scrollPositions=new Map,this.sourceViews=new Set,this.pendingSelection=null,this.selectionRevision=0}loadEditorAssets(){if(!this.editorAssets){let t=this.document.createElement("link");t.rel="stylesheet",t.href=new URL("./editor.css",import.meta.url).href;let e=new Promise((i,s)=>{t.addEventListener("load",i,{once:!0}),t.addEventListener("error",()=>s(new Error("Editor stylesheet request failed.")),{once:!0})});this.document.head.
|
|
29
|
+
`:"";this.frame.srcdoc=n+e.documentElement.outerHTML,this.generation=i,this.source=t}capture(){return this.position}destroy(){this.window.removeEventListener("message",this.receive)}},P=class{constructor({content:t,renderMarkdown:e,loadEditor:i=null,onInsertContext:s=null,createHtmlPreview:n=(o,r)=>new O(o,r)}){this.content=t,this.document=t.ownerDocument,this.renderMarkdown=e,this.onInsertContext=s,this.loadEditor=i||(()=>this.loadEditorAssets()),this.editorAssets=null,this.createHtmlPreview=n,this.activeId=null,this.editor=null,this.htmlPreview=null,this.generation=0,this.remaining=new Set,this.editorStates=new Map,this.previews=new Map,this.scrollPositions=new Map,this.sourceViews=new Set,this.pendingSelection=null,this.selectionRevision=0}loadEditorAssets(){if(!this.editorAssets){let t=this.document.createElement("link");t.rel="stylesheet",t.href=new URL("./editor.css",import.meta.url).href;let e=new Promise((i,s)=>{t.addEventListener("load",i,{once:!0}),t.addEventListener("error",()=>s(new Error("Editor stylesheet request failed.")),{once:!0})});this.document.head.insertBefore(t,this.document.head.querySelector("[data-pycodex-custom-style]")),this.editorAssets=Promise.all([import("./editor.mjs"),e]).then(([i])=>i).catch(i=>{throw t.remove(),this.editorAssets=null,i})}return this.editorAssets}retain(t){this.remaining=new Set(t);for(let e of[this.editorStates,this.previews,this.scrollPositions,this.sourceViews])for(let i of e.keys())this.remaining.has(i)||e.delete(i)}clear(t=""){this.generation+=1,this.editor?(this.remaining.has(this.activeId)&&this.editorStates.set(this.activeId,this.editor.capture()),this.editor.destroy(),this.editor=null):this.htmlPreview?(this.remaining.has(this.activeId)&&this.scrollPositions.set(this.activeId,this.htmlPreview.capture()),this.htmlPreview.destroy(),this.htmlPreview=null):this.remaining.has(this.activeId)&&this.previews.has(this.activeId)&&this.scrollPositions.set(this.activeId,[this.content.scrollLeft,this.content.scrollTop]),this.activeId=null,this.content.classList.remove("code-view"),this.content.classList.remove("html-view"),this.content.textContent="",t&&this.content.appendChild(y(this.document,"div","content-placeholder",t))}async show(t,e,i=null,s=null){i&&e.revision<i.contentRevision&&(i=null),this.pendingSelection=i,s==="preview"?this.sourceViews.delete(t):(i||s==="source")&&this.sourceViews.add(t);let n=e.documents[0],o=e.kind==="file"&&!this.sourceViews.has(t),r=o&&/\.(md|markdown)$/i.test(n.label)&&this.renderMarkdown,d=o&&/\.html?$/i.test(n.label);if(this.activeId===t&&this.htmlPreview&&d)return this.htmlPreview.update(n.text);if(this.activeId===t&&this.editor&&!r&&!d){let m=this.generation;try{await this.editor.update(e),m===this.generation&&await this.applySelection(t,i)}catch(v){if(m===this.generation)throw v}return}this.clear(),this.activeId=t;let h=this.generation;if(e.kind==="diff"||!n.image_url&&!r&&!d){this.content.classList.add("code-view"),this.content.appendChild(y(this.document,"div","content-placeholder","Loading code viewer\u2026"));try{let m=await this.loadEditor();if(h!==this.generation)return;this.content.textContent="";let v=y(this.document,"div","workspace-editor");this.content.appendChild(v);let g=this.onInsertContext&&(E=>{h===this.generation&&this.activeId===t&&this.onInsertContext(E)}),S=await m.createSnapshotEditor(v,e,this.editorStates.get(t),g);if(h!==this.generation){S.destroy();return}this.editor=S}catch(m){if(h!==this.generation)return;this.content.textContent="",this.content.appendChild(y(this.document,"div","content-placeholder","Unable to load code viewer: "+m.message));return}await this.applySelection(t,i);return}if(d)return this.content.classList.add("html-view"),this.htmlPreview=this.createHtmlPreview(this.content,this.scrollPositions.get(t)),this.htmlPreview.update(n.text);if(!this.previews.has(t)){let m=y(this.document,"div","snapshot"),v=y(this.document,"div","snapshot-heading");if(v.appendChild(y(this.document,"span","snapshot-label",n.label)),v.appendChild(y(this.document,"span","snapshot-stamp","Read only")),m.appendChild(v),n.image_url){let g=y(this.document,"img","snapshot-image");g.alt=n.label,m.appendChild(g)}else m.appendChild(y(this.document,"div","snapshot-markdown markdown"));this.previews.set(t,m)}this.content.appendChild(this.previews.get(t));let u=this.content.querySelector(".snapshot-image");if(u&&u.src!==n.image_url&&(u.src=n.image_url),r){let m=this.content.querySelector(".snapshot-markdown");n.source&&m.setAttribute("data-src-path",n.source.path),this.renderMarkdown(m,n.text)}let[p,k]=this.scrollPositions.get(t)||[0,0];this.content.scrollLeft=p,this.content.scrollTop=k}async applySelection(t,e){if(!e||e.revision<=this.selectionRevision)return;let i=this.editor,s=()=>this.activeId===t&&this.editor===i&&this.pendingSelection===e&&e.revision>this.selectionRevision;s()&&await i.revealLine(e.document,e.line,s)&&(this.selectionRevision=e.revision)}};function F({tabs:c,frame:t,content:e,status:i,request:s,renderMarkdown:n,onInsertContext:o,enableBoard:r=!0}){let d=c.ownerDocument,h=new P({content:e,renderMarkdown:n,onInsertContext:o}),u,p=null,k=!1,m=null,v=null,g;function S(a){let C=d.getElementById("content-tab-"+a);C&&(C.focus({preventScroll:!0}),C.scrollIntoView({block:"nearest",inline:"nearest"}))}function E(a){let C=a.snapshot?a.snapshot.documents.map(l=>l.error):[];i.textContent=[a.error,...C].filter(Boolean).join("; ")||(a.openRequest?"Opening view\u2026":""),i.title=i.textContent,i.hidden=!i.textContent;let R=new Map(Array.from(c.children,l=>[l.dataset.viewId,l])),J=(r?[{id:"board",title:"Canvas",kind:"board"}]:[]).concat(a.views),T=U(a.views);T.set("board",{name:"Canvas",description:""}),J.forEach((l,I)=>{let f=R.get(l.id);if(!f){f=y(d,"div","tab content-tab"),f.dataset.viewId=l.id,f.id="content-tab-"+l.id,f.setAttribute("role","tab"),f.setAttribute("aria-controls",l.id==="board"?t.id:e.id);let D=y(d,"span","tab-label content-tab-label");if(D.appendChild(y(d,"span","content-tab-name")),D.appendChild(y(d,"span","content-tab-description")),f.appendChild(D),f.addEventListener("click",()=>g.select(l.id)),f.addEventListener("keydown",w=>{if(w.target!==f)return;let b=(r?["board"]:[]).concat(g.views.map($=>$.id)),j=b.indexOf(l.id),x=null;w.key==="ArrowRight"&&(x=b[(j+1)%b.length]),w.key==="ArrowLeft"&&(x=b[(j+b.length-1)%b.length]),w.key==="Home"&&(x=b[0]),w.key==="End"&&(x=b[b.length-1]),(w.key==="Enter"||w.key===" ")&&(x=l.id),w.key==="Delete"&&l.id!=="board"&&(w.preventDefault(),g.close(l.id)),x!==null&&(w.preventDefault(),g.select(x),d.getElementById("content-tab-"+x).focus())}),l.id!=="board"){let w=y(d,"button","tab-close content-tab-close");w.type="button",w.title="Close tab",w.setAttribute("aria-label","Close "+l.title),w.innerHTML='<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" aria-hidden="true"><path d="M4 4l8 8M12 4l-8 8"/></svg>',w.addEventListener("click",b=>{b.stopPropagation(),g.close(l.id)}),f.appendChild(w)}}R.delete(l.id);let L=T.get(l.id);f.querySelector(".content-tab-name").textContent=L.name;let V=f.querySelector(".content-tab-description");V.textContent=L.description,V.hidden=!L.description,f.className="tab content-tab"+(l.id===a.activeId?" active":""),f.tabIndex=l.id===a.activeId?0:-1,f.title=l.id==="board"?c.dataset.boardTitle:l.title+(l.live?`
|
|
30
30
|
Read only \xB7 follows file changes \xB7 `:`
|
|
31
31
|
Read-only snapshot \xB7 `)+new Date(l.created_at).toLocaleString(),f.setAttribute("aria-selected",String(l.id===a.activeId)),f.setAttribute("aria-label",l.title);let A=f.querySelector(".content-tab-close");A&&(A.disabled=a.closing.has(l.id),A.setAttribute("aria-label","Close "+l.title)),c.children[I]!==f&&c.insertBefore(f,c.children[I]||null)}),R.forEach(l=>l.remove()),h.retain(a.views.map(l=>l.id));let q=a.activeId==="board",z=u!==a.activeId,B=z||p!==a.snapshot||k!==a.loading||m!==a.selection||v!==a.displayMode;if(q&&B&&h.clear(),t.hidden=!q,e.hidden=q,!!B&&(u=a.activeId,p=a.snapshot,k=a.loading,m=a.selection,v=a.displayMode,a.activeId?e.setAttribute("aria-labelledby","content-tab-"+a.activeId):e.removeAttribute("aria-labelledby"),e.setAttribute("aria-busy",String(a.loading)),!q)){if(a.snapshot){let l=a.activeId,I=a.snapshot;h.show(l,I,a.selection,a.displayMode).catch(f=>{u!==l||p!==I||(p=null,a.error="Unable to update view: "+f.message,i.textContent=i.title=a.error,i.hidden=!1)})}else h.clear(a.loading?"Loading view\u2026":a.activeId?"View unavailable.":"Open a file link to view it here.");z&&S(a.activeId)}}return g=new N({request:s,enableBoard:r,onChange:E,onSelect:a=>{e.closest(".workspace").dataset.mobilePane="board",S(a)}}),E(g),{start(){g.start(),E(g)},stop(){g.stop(),h.clear(),u=null},poll(){return g.poll()},open(a,C,R){return g.open(a,C,R)}}}export{M as Composer,N as ContentTabs,P as SnapshotDisplay,X as isWorkspaceLink,F as mountContentViews};
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Workspace frontend template and static resource loading."""
|
|
2
|
+
|
|
3
|
+
import mimetypes
|
|
4
|
+
from html.parser import HTMLParser
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from starlette.exceptions import HTTPException
|
|
8
|
+
from starlette.responses import FileResponse
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class _AssetInsertions(HTMLParser):
|
|
12
|
+
def __init__(self, template: str, additions: dict) -> None:
|
|
13
|
+
super().__init__()
|
|
14
|
+
self.lines = template.splitlines(keepends=True)
|
|
15
|
+
self.additions = additions
|
|
16
|
+
self.positions = []
|
|
17
|
+
|
|
18
|
+
def handle_endtag(self, tag: str) -> None:
|
|
19
|
+
markup = self.additions.pop(tag, None)
|
|
20
|
+
if markup is not None:
|
|
21
|
+
line, column = self.getpos()
|
|
22
|
+
offset = sum(len(row) for row in self.lines[:line - 1]) + column
|
|
23
|
+
self.positions.append((offset, markup))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def frontend_directory() -> Path:
|
|
27
|
+
return Path.home() / ".pycodex" / "frontend"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def frontend_file(name: str) -> Path:
|
|
31
|
+
directory = frontend_directory().resolve()
|
|
32
|
+
path = (directory / name).resolve()
|
|
33
|
+
try:
|
|
34
|
+
path.relative_to(directory)
|
|
35
|
+
except ValueError:
|
|
36
|
+
raise HTTPException(status_code=404, detail="frontend file not found")
|
|
37
|
+
return path
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def load_workspace_template(builtin: bool = False) -> str:
|
|
41
|
+
packaged = Path(__file__).with_name("workspace.html")
|
|
42
|
+
if builtin:
|
|
43
|
+
return packaged.read_text(encoding="utf-8")
|
|
44
|
+
try:
|
|
45
|
+
template = frontend_file("workspace.html").read_text(
|
|
46
|
+
encoding="utf-8", errors="replace"
|
|
47
|
+
)
|
|
48
|
+
except FileNotFoundError:
|
|
49
|
+
template = packaged.read_text(encoding="utf-8")
|
|
50
|
+
additions = {}
|
|
51
|
+
for name, closing_tag, markup in [
|
|
52
|
+
("custom.css", "head", '<link rel="stylesheet" href="frontend/custom.css" data-pycodex-custom-style>'),
|
|
53
|
+
("custom.js", "body", '<script type="module" src="frontend/custom.js"></script>'),
|
|
54
|
+
]:
|
|
55
|
+
try:
|
|
56
|
+
frontend_file(name).stat()
|
|
57
|
+
except FileNotFoundError:
|
|
58
|
+
continue
|
|
59
|
+
additions[closing_tag] = markup
|
|
60
|
+
if additions:
|
|
61
|
+
parser = _AssetInsertions(template, additions)
|
|
62
|
+
parser.feed(template)
|
|
63
|
+
for offset, markup in reversed(parser.positions):
|
|
64
|
+
template = template[:offset] + markup + "\n" + template[offset:]
|
|
65
|
+
return template
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def frontend_asset_response(name: str) -> FileResponse:
|
|
69
|
+
path = frontend_file(name)
|
|
70
|
+
if not path.is_file():
|
|
71
|
+
raise HTTPException(status_code=404, detail="frontend file not found")
|
|
72
|
+
media_type = (
|
|
73
|
+
"text/javascript" if path.suffix.lower() in {".js", ".mjs"}
|
|
74
|
+
else mimetypes.guess_type(str(path))[0] or "application/octet-stream"
|
|
75
|
+
)
|
|
76
|
+
return FileResponse(
|
|
77
|
+
str(path),
|
|
78
|
+
media_type=media_type,
|
|
79
|
+
headers={"Cache-Control": "no-store", "X-Content-Type-Options": "nosniff"},
|
|
80
|
+
)
|
|
@@ -328,7 +328,9 @@ export class SnapshotDisplay {
|
|
|
328
328
|
stylesheet.addEventListener("error",
|
|
329
329
|
() => reject(new Error("Editor stylesheet request failed.")), {once: true});
|
|
330
330
|
});
|
|
331
|
-
this.document.head.
|
|
331
|
+
this.document.head.insertBefore(
|
|
332
|
+
stylesheet, this.document.head.querySelector("[data-pycodex-custom-style]")
|
|
333
|
+
);
|
|
332
334
|
this.editorAssets = Promise.all([import("./editor.mjs"), styles])
|
|
333
335
|
.then(([module]) => module)
|
|
334
336
|
.catch(error => {
|