pycodex-ws 0.4.8__tar.gz → 0.5.1__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.1/PKG-INFO +121 -0
- pycodex_ws-0.5.1/README.md +110 -0
- {pycodex_ws-0.4.8 → pycodex_ws-0.5.1}/pycodex_router/index.html +11 -1
- {pycodex_ws-0.4.8 → pycodex_ws-0.5.1}/pycodex_ws/feishu_card.py +2 -2
- {pycodex_ws-0.4.8 → pycodex_ws-0.5.1}/pycodex_ws/feishu_link.py +1 -1
- {pycodex_ws-0.4.8 → pycodex_ws-0.5.1}/pyproject.toml +2 -2
- {pycodex_ws-0.4.8 → pycodex_ws-0.5.1}/workspace_server/app.py +54 -8
- pycodex_ws-0.5.1/workspace_server/content_views.css +1 -0
- pycodex_ws-0.5.1/workspace_server/content_views.mjs +32 -0
- pycodex_ws-0.5.1/workspace_server/customization.py +80 -0
- {pycodex_ws-0.4.8 → pycodex_ws-0.5.1}/workspace_server/frontend/composer.mjs +28 -2
- {pycodex_ws-0.4.8 → pycodex_ws-0.5.1}/workspace_server/frontend/content_views.css +27 -3
- {pycodex_ws-0.4.8 → pycodex_ws-0.5.1}/workspace_server/frontend/content_views.mjs +41 -3
- pycodex_ws-0.5.1/workspace_server/frontend/prompt_history.mjs +85 -0
- {pycodex_ws-0.4.8 → pycodex_ws-0.5.1}/workspace_server/workspace.html +501 -576
- {pycodex_ws-0.4.8 → pycodex_ws-0.5.1}/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/workspace_server/content_views.css +0 -1
- pycodex_ws-0.4.8/workspace_server/content_views.mjs +0 -31
- {pycodex_ws-0.4.8 → pycodex_ws-0.5.1}/.gitignore +0 -0
- {pycodex_ws-0.4.8 → pycodex_ws-0.5.1}/LICENSE +0 -0
- {pycodex_ws-0.4.8 → pycodex_ws-0.5.1}/pycodex_router/__init__.py +0 -0
- {pycodex_ws-0.4.8 → pycodex_ws-0.5.1}/pycodex_router/__main__.py +0 -0
- {pycodex_ws-0.4.8 → pycodex_ws-0.5.1}/pycodex_router/browser.py +0 -0
- {pycodex_ws-0.4.8 → pycodex_ws-0.5.1}/pycodex_router/client.py +0 -0
- {pycodex_ws-0.4.8 → pycodex_ws-0.5.1}/pycodex_router/protocol.py +0 -0
- {pycodex_ws-0.4.8 → pycodex_ws-0.5.1}/pycodex_router/server.py +0 -0
- {pycodex_ws-0.4.8 → pycodex_ws-0.5.1}/pycodex_ws/__init__.py +0 -0
- {pycodex_ws-0.4.8 → pycodex_ws-0.5.1}/pycodex_ws/feishu_oauth.py +0 -0
- {pycodex_ws-0.4.8 → pycodex_ws-0.5.1}/workspace_server/__init__.py +0 -0
- {pycodex_ws-0.4.8 → pycodex_ws-0.5.1}/workspace_server/__main__.py +0 -0
- {pycodex_ws-0.4.8 → pycodex_ws-0.5.1}/workspace_server/agent_access.py +0 -0
- {pycodex_ws-0.4.8 → pycodex_ws-0.5.1}/workspace_server/content_views.LICENSE.txt +0 -0
- {pycodex_ws-0.4.8 → pycodex_ws-0.5.1}/workspace_server/editor.css +0 -0
- {pycodex_ws-0.4.8 → pycodex_ws-0.5.1}/workspace_server/editor.mjs +0 -0
- {pycodex_ws-0.4.8 → pycodex_ws-0.5.1}/workspace_server/frontend/.gitignore +0 -0
- {pycodex_ws-0.4.8 → pycodex_ws-0.5.1}/workspace_server/frontend/build.mjs +0 -0
- {pycodex_ws-0.4.8 → pycodex_ws-0.5.1}/workspace_server/frontend/editor.css +0 -0
- {pycodex_ws-0.4.8 → pycodex_ws-0.5.1}/workspace_server/frontend/editor.mjs +0 -0
- {pycodex_ws-0.4.8 → pycodex_ws-0.5.1}/workspace_server/frontend/editor_context.mjs +0 -0
- {pycodex_ws-0.4.8 → pycodex_ws-0.5.1}/workspace_server/frontend/package-lock.json +0 -0
- {pycodex_ws-0.4.8 → pycodex_ws-0.5.1}/workspace_server/frontend/package.json +0 -0
- {pycodex_ws-0.4.8 → pycodex_ws-0.5.1}/workspace_server/frontend/touch_selection.mjs +0 -0
- {pycodex_ws-0.4.8 → pycodex_ws-0.5.1}/workspace_server/frontend/view_labels.mjs +0 -0
- {pycodex_ws-0.4.8 → pycodex_ws-0.5.1}/workspace_server/owned_process.py +0 -0
- {pycodex_ws-0.4.8 → pycodex_ws-0.5.1}/workspace_server/runtime_info.py +0 -0
- {pycodex_ws-0.4.8 → pycodex_ws-0.5.1}/workspace_server/workspace_views.py +0 -0
- {pycodex_ws-0.4.8 → pycodex_ws-0.5.1}/workspace_server/workspaces.py +0 -0
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: pycodex-ws
|
|
3
|
+
Version: 0.5.1
|
|
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.1
|
|
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;
|
|
@@ -357,9 +357,9 @@ class PycodexCard:
|
|
|
357
357
|
or getattr(action_payload, "input_value", None)
|
|
358
358
|
or value.get("input_value")
|
|
359
359
|
or ""
|
|
360
|
-
)
|
|
360
|
+
)
|
|
361
361
|
if not prompt and form_values:
|
|
362
|
-
prompt = str(next(iter(form_values.values())))
|
|
362
|
+
prompt = str(next(iter(form_values.values())))
|
|
363
363
|
action = str(
|
|
364
364
|
value.get("action") or getattr(action_payload, "name", None) or "send"
|
|
365
365
|
).strip()
|
|
@@ -105,7 +105,7 @@ class PycodexRuntimeLink:
|
|
|
105
105
|
"content": "Unsupported action: {0}".format(action.get("action")),
|
|
106
106
|
}
|
|
107
107
|
}
|
|
108
|
-
prompt = str(action.get("prompt") or "")
|
|
108
|
+
prompt = str(action.get("prompt") or "")
|
|
109
109
|
future = asyncio.run_coroutine_threadsafe(
|
|
110
110
|
self.runtime.submit_input(
|
|
111
111
|
prompt, sender=str(action.get("sender") or "feishu")
|
|
@@ -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.1"
|
|
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.1",
|
|
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 (
|
|
@@ -391,6 +397,9 @@ class WebSessionView:
|
|
|
391
397
|
active["submission_id"],
|
|
392
398
|
)
|
|
393
399
|
)
|
|
400
|
+
self._turns_by_turn_id[active["turn_id"]]["model_errors"] = list(
|
|
401
|
+
active.get("model_errors", [])
|
|
402
|
+
)
|
|
394
403
|
elif event.reason == "title":
|
|
395
404
|
self.set_session_title(state["title"])
|
|
396
405
|
else:
|
|
@@ -458,6 +467,10 @@ class WebSessionView:
|
|
|
458
467
|
turn["_thinking_active"] = True
|
|
459
468
|
return
|
|
460
469
|
|
|
470
|
+
if isinstance(event, StreamErrorEvent):
|
|
471
|
+
turn.setdefault("model_errors", []).append(event.detail())
|
|
472
|
+
return
|
|
473
|
+
|
|
461
474
|
if isinstance(event, ToolStartedEvent):
|
|
462
475
|
turn["status"] = "tool"
|
|
463
476
|
turn["tool_name"] = event.call.name
|
|
@@ -950,6 +963,7 @@ def create_multi_workspace_app(
|
|
|
950
963
|
entry.definition.board_path,
|
|
951
964
|
title=entry.definition.workspace_id,
|
|
952
965
|
work_dir=entry.definition.work_dir,
|
|
966
|
+
builtin=request.query_params.get("builtin") == "1",
|
|
953
967
|
)
|
|
954
968
|
)
|
|
955
969
|
|
|
@@ -996,7 +1010,11 @@ def create_multi_workspace_app(
|
|
|
996
1010
|
|
|
997
1011
|
async def workspace_new_session(request: Request) -> JSONResponse:
|
|
998
1012
|
entry = _workspace_entry_or_404(registry, request.path_params["workspace_id"])
|
|
999
|
-
return await _new_session_response(entry.manager)
|
|
1013
|
+
return await _new_session_response(entry.manager, request)
|
|
1014
|
+
|
|
1015
|
+
def workspace_frontend_asset(request: Request) -> Response:
|
|
1016
|
+
_workspace_entry_or_404(registry, request.path_params["workspace_id"])
|
|
1017
|
+
return frontend_asset_response(request.path_params["asset_path"])
|
|
1000
1018
|
|
|
1001
1019
|
async def workspace_delete_session(request: Request) -> JSONResponse:
|
|
1002
1020
|
entry = _workspace_entry_or_404(registry, request.path_params["workspace_id"])
|
|
@@ -1082,6 +1100,10 @@ def create_multi_workspace_app(
|
|
|
1082
1100
|
),
|
|
1083
1101
|
Route("/w/{workspace_id}/api/sessions", workspace_sessions, methods=["GET"]),
|
|
1084
1102
|
Route("/w/{workspace_id}/api/sessions", workspace_new_session, methods=["POST"]),
|
|
1103
|
+
Route(
|
|
1104
|
+
"/w/{workspace_id}/frontend/{asset_path:path}",
|
|
1105
|
+
workspace_frontend_asset, methods=["GET", "HEAD"],
|
|
1106
|
+
),
|
|
1085
1107
|
Route(
|
|
1086
1108
|
"/w/{workspace_id}/api/sessions/{session_id}",
|
|
1087
1109
|
workspace_delete_session, methods=["DELETE"],
|
|
@@ -1368,7 +1390,10 @@ def _install_workspace_routes(
|
|
|
1368
1390
|
view_asset_cache = {}
|
|
1369
1391
|
|
|
1370
1392
|
async def index(request: Request) -> HTMLResponse:
|
|
1371
|
-
return _html_response(_render_workspace_shell(
|
|
1393
|
+
return _html_response(_render_workspace_shell(
|
|
1394
|
+
board_path, work_dir=work_dir,
|
|
1395
|
+
builtin=request.query_params.get("builtin") == "1",
|
|
1396
|
+
))
|
|
1372
1397
|
|
|
1373
1398
|
async def favicon(request: Request) -> Response:
|
|
1374
1399
|
return Response(status_code=204)
|
|
@@ -1404,7 +1429,10 @@ def _install_workspace_routes(
|
|
|
1404
1429
|
return _sessions_response(manager)
|
|
1405
1430
|
|
|
1406
1431
|
async def new_session(request: Request) -> JSONResponse:
|
|
1407
|
-
return await _new_session_response(manager)
|
|
1432
|
+
return await _new_session_response(manager, request)
|
|
1433
|
+
|
|
1434
|
+
def frontend_asset(request: Request) -> Response:
|
|
1435
|
+
return frontend_asset_response(request.path_params["asset_path"])
|
|
1408
1436
|
|
|
1409
1437
|
async def delete_session(request: Request) -> JSONResponse:
|
|
1410
1438
|
return await _delete_session_response(manager, request.path_params["session_id"])
|
|
@@ -1449,6 +1477,7 @@ def _install_workspace_routes(
|
|
|
1449
1477
|
Route("/ws/session", websocket_backend_hint, methods=["GET"]),
|
|
1450
1478
|
Route("/api/sessions", sessions, methods=["GET"]),
|
|
1451
1479
|
Route("/api/sessions", new_session, methods=["POST"]),
|
|
1480
|
+
Route("/frontend/{asset_path:path}", frontend_asset, methods=["GET", "HEAD"]),
|
|
1452
1481
|
Route("/api/sessions/{session_id}", delete_session, methods=["DELETE"]),
|
|
1453
1482
|
Route("/api/sessions/{session_id}/read", read_session, methods=["POST"]),
|
|
1454
1483
|
Route("/api/session", session, methods=["GET"]),
|
|
@@ -1619,15 +1648,27 @@ def _read_session_response(
|
|
|
1619
1648
|
return _sessions_response(manager)
|
|
1620
1649
|
|
|
1621
1650
|
|
|
1622
|
-
async def _new_session_response(
|
|
1623
|
-
|
|
1651
|
+
async def _new_session_response(
|
|
1652
|
+
manager: "WorkspaceSessionManager", request: Request,
|
|
1653
|
+
) -> JSONResponse:
|
|
1654
|
+
payload = await _json_object_body(request) if await request.body() else {}
|
|
1655
|
+
title = payload.get("title", "")
|
|
1656
|
+
prompt = payload.get("prompt", "")
|
|
1657
|
+
if not isinstance(title, str) or not isinstance(prompt, str):
|
|
1658
|
+
raise HTTPException(status_code=422, detail="title and prompt must be strings")
|
|
1659
|
+
session_id = await manager.create_session(title=title)
|
|
1660
|
+
result = (
|
|
1661
|
+
await manager.get(session_id).submit(prompt, sender="web")
|
|
1662
|
+
if prompt else {"ok": True}
|
|
1663
|
+
)
|
|
1624
1664
|
return JSONResponse(
|
|
1625
1665
|
{
|
|
1626
|
-
|
|
1666
|
+
**result,
|
|
1627
1667
|
"session_id": session_id,
|
|
1628
1668
|
"sessions": manager.list_sessions(),
|
|
1629
1669
|
"snapshot": session_snapshot(manager.get(session_id)),
|
|
1630
|
-
}
|
|
1670
|
+
},
|
|
1671
|
+
status_code=200 if result["ok"] else 400,
|
|
1631
1672
|
)
|
|
1632
1673
|
|
|
1633
1674
|
|
|
@@ -1781,6 +1822,7 @@ def _public_turn(turn: "typing.Dict[str, object]") -> "typing.Dict[str, object]"
|
|
|
1781
1822
|
"queue": turn.get("queue", ""),
|
|
1782
1823
|
"sender": turn.get("sender", ""),
|
|
1783
1824
|
"kind": turn.get("kind", "assistant"),
|
|
1825
|
+
"model_errors": turn.get("model_errors", []),
|
|
1784
1826
|
}
|
|
1785
1827
|
),
|
|
1786
1828
|
)
|
|
@@ -2034,17 +2076,21 @@ def _render_workspace_shell(
|
|
|
2034
2076
|
board_path: "typing.Union[Path, None]",
|
|
2035
2077
|
title: "typing.Union[str, None]" = None,
|
|
2036
2078
|
work_dir: "typing.Union[Path, None]" = None,
|
|
2079
|
+
builtin: bool = False,
|
|
2037
2080
|
) -> str:
|
|
2038
2081
|
board_label = str(board_path) if board_path is not None else "No board"
|
|
2039
2082
|
cwd_label = str(work_dir or Path.cwd())
|
|
2040
2083
|
page_title = str(title or "pycodex workspace")
|
|
2041
|
-
template = (
|
|
2084
|
+
template = load_workspace_template(builtin)
|
|
2042
2085
|
return (
|
|
2043
2086
|
template.replace("__WORKSPACE_TITLE__", html.escape(page_title))
|
|
2044
2087
|
.replace("__BOARD_LABEL__", html.escape(board_label))
|
|
2045
2088
|
.replace("__CWD_LABEL__", html.escape(cwd_label))
|
|
2046
2089
|
.replace("__ENABLE_BOARD__", "true" if board_path is not None else "false")
|
|
2047
2090
|
.replace("__BOARD_URL__", "board" if board_path is not None else "about:blank")
|
|
2091
|
+
.replace("__FRONTEND_DIRECTORY__", html.escape(str(frontend_directory())))
|
|
2092
|
+
.replace("__PACKAGE_DIRECTORY__", html.escape(str(Path(__file__).resolve().parent)))
|
|
2093
|
+
.replace("__PACKAGE_VERSION__", html.escape(importlib_metadata.version("pycodex-ws")))
|
|
2048
2094
|
)
|
|
2049
2095
|
|
|
2050
2096
|
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
.content-tabs{flex:1}.tab.content-tab{flex:0 0 auto}.tab.content-tab:first-child{min-width:0;padding-right:12px}.content-tab:focus-visible{outline:2px solid var(--accent);outline-offset:-1px}.content-tab-label{display:flex;align-items:baseline;gap:6px}.content-tab-name{flex:0 0 auto;max-width:100%;overflow:hidden;text-overflow:ellipsis}.content-tab-description{flex:0 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;font-size:11px;font-weight:400;opacity:.75}.content-status{max-width:50%;font-size:11px;color:var(--danger);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.content-mode-toggle{flex:0 0 auto;padding:4px 8px;border:0;border-radius:var(--radius-sm);background:transparent;color:var(--accent);font-size:11px;white-space:nowrap;cursor:pointer}.content-mode-toggle:hover{background:var(--accent-soft)}.content-mode-toggle:focus-visible{outline:2px solid var(--accent);outline-offset:1px}.content-area{position:relative;min-width:0;min-height:0;overflow:hidden}.content-area>[hidden]{display:none}.content-view{width:100%;height:100%;overflow:auto;background:var(--board-chrome);color:var(--ink)}.content-view.code-view,.content-view.html-view{overflow:hidden}.snapshot-html{display:block;width:100%;height:100%;border:0;background:#fff}.snapshot{min-height:100%}.snapshot-heading{display:flex;align-items:center;gap:8px;min-height:42px;padding:6px 16px;border-bottom:1px solid var(--line);font-size:12px}.markdown-heading{position:sticky;top:0;z-index:1;background:var(--board-chrome)}.snapshot-label{min-width:0;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;overflow-wrap:anywhere}.snapshot-stamp{flex:0 0 auto;margin-left:auto;color:var(--muted);font-size:11px;white-space:nowrap}.snapshot-markdown{max-width:900px;padding:24px 32px;margin:0 auto}.snapshot-image{display:block;max-width:calc(100% - 40px);height:auto;margin:20px auto}.content-placeholder{padding:40px 24px;text-align:center;color:var(--muted)}@media(max-width:760px){.snapshot-heading{flex-wrap:wrap;gap:4px}.snapshot-stamp{margin-left:0}.markdown-heading{padding-right:48px}.markdown-heading .snapshot-label{flex:1 0 100%}.markdown-heading .snapshot-stamp{margin-left:auto}.snapshot-markdown{padding:20px}}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// Generated by workspace_server/frontend/build.mjs. See content_views.LICENSE.txt.
|
|
2
|
+
function j(l){function t(n){let r=n.lastIndexOf("/");return r<0?".":n.slice(0,r)||"/"}let e=new Map,i=l.map(n=>{let o=(n.kind==="diff"?n.title.split(" <-> "):[n.title]).map(c=>{let h=/^(?:([0-9a-f]{40}|[0-9a-f]{64})|.+ \(([0-9a-f]{40}|[0-9a-f]{64})\)):(.+)$/i.exec(c),u=h?h[3]:c,p=u.slice(u.lastIndexOf("/")+1);return e.has(p)||e.set(p,new Set),e.get(p).add(u),{path:u,name:p,revision:h?(h[1]||h[2]).slice(0,7):null}});return{view:n,sources:o}}),s=new Map;for(let n of e.values()){if(n.size<2)continue;let r=Array.from(n),o=r.map(c=>t(c).split("/"));r.forEach((c,h)=>{let u=1;for(;u<o[h].length&&o.some((p,S)=>S!==h&&p.slice(-u).join("/")===o[h].slice(-u).join("/"));)u+=1;s.set(c,o[h].slice(-u).join("/")||"/")})}return new Map(i.map(({view:n,sources:r})=>{let o=r.every(p=>p.path===r[0].path),c=o?r[0].name:r.map(p=>p.name).join(" \u2194 "),h="";o?h=s.get(r[0].path)||"":r.some(p=>s.has(p.path))&&(h=r.map(p=>s.get(p.path)||t(p.path)).join(" \u2194 "));let u=r.some(p=>p.revision)?r.map(p=>p.revision||"working tree").join(" \u2194 "):n.kind==="diff"&&o?"diff":"";return[n.id,{name:c,description:[h,u].filter(Boolean).join(" \xB7 ")}]}))}var E=class l{constructor(t,e,i,s,n,r=null){this.element=t,this.changed=e,this.submit=i,this.editContext=s,this.error=n,this.recallInput=r,this.recalledParts=null,this.composing=!1,this.selection={start:0,end:0},this.undo=[],this.redo=[],this.previous=this.read(),t.addEventListener("input",o=>{o.isComposing||this.record(),this.changed()}),t.addEventListener("compositionstart",()=>{this.composing=!0}),t.addEventListener("compositionend",()=>{this.composing=!1,this.record(),this.changed()}),document.addEventListener("selectionchange",()=>{this.captureSelection()&&this.changed()}),t.addEventListener("keydown",o=>this.keydown(o)),t.addEventListener("beforeinput",o=>{o.inputType==="historyUndo"||o.inputType==="historyRedo"?(o.preventDefault(),this.history(o.inputType==="historyUndo")):o.inputType.startsWith("format")&&o.preventDefault()}),t.addEventListener("click",o=>{let c=o.target.closest(".context-token");c&&this.editContext(JSON.parse(c.dataset.context))}),t.addEventListener("mousedown",o=>{o.target.closest(".context-token")&&o.preventDefault()}),t.addEventListener("copy",o=>this.copy(o,!1)),t.addEventListener("cut",o=>this.copy(o,!0)),t.addEventListener("paste",o=>{o.preventDefault(),this.paste(o.clipboardData)}),t.addEventListener("dragover",o=>o.preventDefault()),t.addEventListener("drop",o=>{o.preventDefault();let c=document.caretRangeFromPoint(o.clientX,o.clientY);if(c&&t.contains(c.startContainer)){let h=window.getSelection();h.removeAllRanges(),h.addRange(c),this.captureSelection()}this.paste(o.dataTransfer)})}static size(t){return t.reduce((e,i)=>e+(i.type==="context"?1:i.text.length),0)}static text(t){return t.map(e=>e.text).join("")}static slice(t,e,i){let s=[],n=0;for(let r of t){let o=l.size([r]);e<n+o&&i>n&&s.push(r.type==="context"?{...r}:{type:"text",text:r.text.slice(Math.max(0,e-n),i-n)}),n+=o}return s}parts(t=this.element){let e=[],i=n=>{if(!n)return;let r=e.at(-1);(r==null?void 0:r.type)==="text"?r.text+=n:e.push({type:"text",text:n})},s=n=>{var r;for(let o of n.childNodes)o.nodeType===Node.TEXT_NODE?i(o.data):(r=o.dataset)!=null&&r.context?e.push(JSON.parse(o.dataset.context)):o.nodeName==="BR"?!o.hasAttribute("data-tail")&&n.childNodes.length>1&&i(`
|
|
3
|
+
`):(["DIV","P"].includes(o.nodeName)&&e.length&&!e.at(-1).text.endsWith(`
|
|
4
|
+
`)&&i(`
|
|
5
|
+
`),s(o))};return s(t),e}captureSelection(){if(document.activeElement!==this.element)return!1;let t=window.getSelection();if(!t.rangeCount)return!1;let e=t.getRangeAt(0);if(!this.element.contains(e.startContainer)||!this.element.contains(e.endContainer))return!1;let i=document.createRange();i.selectNodeContents(this.element),i.setEnd(e.startContainer,e.startOffset);let s=l.size(this.parts(i.cloneContents()));return this.selection={start:s,end:s+l.size(this.parts(e.cloneContents()))},!0}read(){return this.captureSelection(),{parts:this.parts(),selection:{...this.selection}}}set(t){this.recalledParts=null,this.undo=[],this.redo=[],this.render(t),document.activeElement===this.element&&this.focus(),this.previous=this.read()}render(t){let e=t.parts||[];this.element.replaceChildren();for(let s of e)if(s.type==="text")this.element.append(document.createTextNode(s.text));else{let n=document.createElement("span");n.className="context-token",n.contentEditable="false",n.dataset.context=JSON.stringify(s),n.textContent=s.label,n.title=s.label+`
|
|
6
|
+
Click to edit context; Backspace / Delete to remove`,this.element.append(n,document.createTextNode(""))}if(!e.length||e.at(-1).type==="text"&&e.at(-1).text.endsWith(`
|
|
7
|
+
`)){let s=document.createElement("br");s.dataset.tail="",this.element.append(s)}let i=l.size(e);this.selection=t.selection||{start:i,end:i},this.selection={start:Math.min(this.selection.start,i),end:Math.min(this.selection.end,i)},this.element.classList.toggle("empty",!e.length)}focus(){this.element.focus({preventScroll:!0});let t=s=>{let n=r=>{var o;for(let c=0;c<r.childNodes.length;c++){let h=r.childNodes[c];if(h.nodeType===Node.TEXT_NODE){if(s<=h.length)return[h,s];s-=h.length}else if((o=h.dataset)!=null&&o.context){if(s===0)return[r,c];if(s-=1,s===0)return[r,c+1]}else if(h.nodeName==="BR"){if(s===0)return[r,c];h.hasAttribute("data-tail")||(s-=1)}else{let u=n(h);if(u)return u}}};return n(this.element)||[this.element,this.element.childNodes.length]},e=document.createRange();e.setStart(...t(this.selection.start)),e.setEnd(...t(this.selection.end));let i=window.getSelection();i.removeAllRanges(),i.addRange(e)}record(){let t=this.read();JSON.stringify(t.parts)!==JSON.stringify(this.previous.parts)&&(this.undo.push(this.previous),this.undo.length>40&&this.undo.shift(),this.redo=[],this.previous=t),this.element.classList.toggle("empty",!t.parts.length)}replace(t,e,i){let s=this.read(),n=[...l.slice(s.parts,0,t),...i,...l.slice(s.parts,e,l.size(s.parts))],r=t+l.size(i);this.render({parts:n,selection:{start:r,end:r}}),this.focus(),this.record(),this.changed()}insert(t){this.captureSelection(),this.replace(this.selection.start,this.selection.end,t.map(e=>({...e,type:"context",id:crypto.getRandomValues(new Uint32Array(4)).join("-")})))}updateContext(t,e){for(let i of this.element.querySelectorAll(".context-token")){let s=JSON.parse(i.dataset.context);s.id===t&&(i.dataset.context=JSON.stringify({...s,text:e}))}this.record(),this.changed()}removeContext(t){let e=0;for(let i of this.parts()){if(i.type==="context"&&i.id===t){this.replace(e,e+1,[]);return}e+=l.size([i])}}history(t){let e=t?this.undo:this.redo;e.length&&((t?this.redo:this.undo).push(this.read()),this.render(e.pop()),this.focus(),this.previous=this.read(),this.changed())}keydown(t){var i;if(t.isComposing||this.composing||t.keyCode===229)return;let e=t.ctrlKey||t.metaKey;if(this.recallInput&&["ArrowUp","ArrowDown"].includes(t.key)&&!e&&!t.altKey&&!t.shiftKey){let s=this.read();if(s.selection.start===s.selection.end){let n=s.selection.start,r=l.text(s.parts),o=t.key==="ArrowUp",c=l.text(l.slice(s.parts,0,n)),h=l.text(l.slice(s.parts,n,l.size(s.parts)));if(this.recalledParts===JSON.stringify(s.parts)||!r||!(o?c:h).includes(`
|
|
8
|
+
`)){let p=this.recallInput(o,s);p&&(t.preventDefault(),this.render(p),this.recalledParts=JSON.stringify(p.parts),this.focus(),this.record(),this.changed())}}}else if(e&&(t.key.toLowerCase()==="z"||t.key.toLowerCase()==="y"))t.preventDefault(),this.history(t.key.toLowerCase()==="z"&&!t.shiftKey);else if(t.key==="Enter")t.preventDefault(),t.shiftKey?(this.captureSelection(),this.replace(this.selection.start,this.selection.end,[{type:"text",text:`
|
|
9
|
+
`}])):this.submit();else if(["Backspace","Delete"].includes(t.key)&&!e&&!t.altKey){this.captureSelection();let{start:s,end:n}=this.selection,r=t.key==="Backspace"?s-1:s;s===n&&((i=l.slice(this.parts(),r,r+1)[0])==null?void 0:i.type)==="context"&&(t.preventDefault(),this.replace(r,r+1,[]))}}copy(t,e){if(!this.captureSelection()||this.selection.start===this.selection.end)return;let i=l.slice(this.parts(),this.selection.start,this.selection.end);t.clipboardData.setData("text/plain",l.text(i)),t.clipboardData.setData("application/x-pycodex-parts",JSON.stringify(i)),t.preventDefault(),e&&this.replace(this.selection.start,this.selection.end,[])}paste(t){try{let e=t.getData("application/x-pycodex-parts"),i=e?JSON.parse(e):[{type:"text",text:t.getData("text/plain")}];if(!Array.isArray(i)||i.some(s=>!s||typeof s.text!="string"||!["text","context"].includes(s.type)||s.type==="context"&&typeof s.label!="string"))throw new Error("Invalid context format in clipboard.");this.captureSelection(),this.replace(this.selection.start,this.selection.end,i.map(s=>s.type==="context"?{...s,id:crypto.getRandomValues(new Uint32Array(4)).join("-")}:s))}catch(e){this.error(e.message)}}};function W(l){let t=l.trim().match(/^<(clock_tick|exec_command_completed)>\s*([\s\S]*?)\s*<\/\1>$/);if(!t)return!1;try{let e=JSON.parse(t[2]);return e!==null&&typeof e=="object"&&!Array.isArray(e)}catch(e){return!1}}var D=class{constructor(){this.entries=[],this.seen=new Set,this.pending=[],this.index=null,this.draft=null}remember(t){let e=t.parts.map(i=>({...i}));E.text(e).trim()&&(this.append({parts:e}),this.pending.push(E.text(e).trim()),this.pending.length>100&&this.pending.shift(),this.reset())}append(t){this.entries.push(t),this.entries.length>100&&(this.entries.shift(),this.index!==null&&(this.index=Math.max(0,this.index-1)))}update(t){for(let e of t||[]){let i=e.submission_id||e.turn_id;if(!i||this.seen.has(i))continue;this.seen.add(i);let s=e.prompt;if(!s||!s.trim())continue;let n=this.pending.indexOf(s.trim());if(n!==-1){this.pending.splice(n,1);continue}e.kind==="compaction"||W(s)||!["web","cli","feishu","resume","user"].includes(e.sender)||this.append({parts:[{type:"text",text:s}]})}}reset(){this.index=null,this.draft=null}recall(t,e){if(this.index!==null&&JSON.stringify(e.parts)!==JSON.stringify(this.entries[this.index].parts)&&this.reset(),t){if(!this.entries.length||this.index===0)return null;this.index===null&&(this.draft=e,this.index=this.entries.length),this.index-=1}else{if(this.index===null)return null;if(this.index+=1,this.index>=this.entries.length){let i=this.draft;return this.reset(),i}}return{parts:this.entries[this.index].parts.map(i=>({...i}))}}};function Q(l,t=!0){if(typeof l!="string"||!l||l!==l.trim()||/[\x00-\x1f\x7f]/.test(l))return!1;if(/^(?:diff:|git:|workspace:\/\/canvas\/?$)/i.test(l))return!0;if(!t||/^(?:https?|ftps?|mailto|tel|sms|javascript|vbscript|data|blob|about|file):/i.test(l))return!1;let e=l.split("#");if(e.length>2||e.length===2&&!/^L-?\d+$/i.test(e[1]))return!1;let i=e[0].replace(/:-?\d+$/,"");return!!i&&!i.startsWith("//")&&!i.includes("?")&&(!/^[a-z][a-z\d+.-]*:/i.test(i)||/^[a-z]:(?:[/\\]|%2f|%5c)/i.test(i))}var O=class{constructor({request:t,onChange:e,onSelect:i,selected:s=null,enableBoard:n=!0}){this.request=t,this.onChange=e,this.onSelect=i,this.enableBoard=n,this.views=[],this.activeId=n?"board":null,this.snapshot=null,this.selection=null,this.displayMode=null,this.loading=!1,this.error="",this.closing=new Set,this.cache=new Map,this.initialSelection=s,this.initialized=!1,this.mutation=0,this.pollRequest=null,this.contentRequest=null,this.openRequest=null,this.timer=null}start(){this.timer===null&&(this.poll(),this.timer=setInterval(()=>this.poll(),2e3))}stop(){clearInterval(this.timer),this.timer=null,this.cancelOpen(),this.pollRequest&&this.pollRequest.abort(),this.contentRequest&&this.contentRequest.abort(),this.pollRequest=null,this.contentRequest=null,this.loading=!1}async poll(){if(this.pollRequest||this.openRequest)return;let t=new AbortController,e=this.mutation;this.pollRequest=t;try{let i=await this.request("api/views",{signal:t.signal});if(this.pollRequest!==t||e!==this.mutation)return;this.updateViews(i.views);let s=new Set(this.views.map(r=>r.id));this.error="";let n=this.activeId;this.initialized||(n=this.initialSelection||this.defaultId(),this.initialized=!0),(n==="board"&&!this.enableBoard||n!==null&&n!=="board"&&!s.has(n))&&(n=this.defaultId()),n!==this.activeId||n!==null&&n!=="board"&&!this.snapshot&&!this.loading?this.select(n):(n!==null&&n!=="board"&&!this.cache.has(n)&&!this.contentRequest&&this.load(n),this.onChange(this))}catch(i){this.pollRequest===t&&e===this.mutation&&i.name!=="AbortError"&&(this.error="Unable to load tabs: "+i.message,this.onChange(this))}finally{this.pollRequest===t&&(this.pollRequest=null)}}defaultId(){return this.enableBoard?"board":this.views.length?this.views[0].id:null}updateViews(t){this.views=t;let e=new Map(t.map(i=>[i.id,i.revision]));for(let[i,s]of this.cache)(!e.has(i)||s.revision<e.get(i))&&this.cache.delete(i)}cancelOpen(){this.openRequest&&this.openRequest.abort(),this.openRequest=null}async open(t,e,i){if(this.cancelOpen(),/^workspace:\/\/canvas\/?$/i.test(t)){if(!this.enableBoard)throw new Error("Canvas is disabled for this workspace.");await this.select("board");return}let s=new AbortController;this.openRequest=s,this.mutation+=1,this.error="",this.onChange(this);try{let n=await this.request("api/views/open",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({href:t,src_path:e,session_id:i}),signal:s.signal});if(this.openRequest!==s)return;this.mutation+=1,this.updateViews(n.views);let r=n.view;return this.cache.set(r.id,r),this.initialized=!0,this.openRequest=null,this.select(r.id,r.selection?{...r.selection,revision:r.reveal_revision,contentRevision:r.revision}:null,r.selection?"source":"preview")}catch(n){if(this.openRequest!==s||n.name==="AbortError")return;throw this.error="Unable to open view: "+n.message,n}finally{this.openRequest===s&&(this.openRequest=null,this.onChange(this))}}async select(t,e=null,i=null){if(t==="board"&&!this.enableBoard||t!==null&&t!=="board"&&!this.views.some(n=>n.id===t))return;this.cancelOpen();let s=t===this.activeId?this.snapshot:null;this.contentRequest&&this.contentRequest.abort(),this.contentRequest=null,this.activeId=t,this.selection=e,this.displayMode=i,this.initialized||(this.initialSelection=t),this.snapshot=this.cache.get(t)||s,this.loading=t!==null&&t!=="board"&&!this.snapshot,this.error="",this.onSelect(t),this.onChange(this),!(t===null||t==="board"||this.cache.has(t))&&await this.load(t)}setDisplayMode(t){!this.snapshot||this.activeId===null||this.activeId==="board"||!["source","preview"].includes(t)||(this.cancelOpen(),this.selection=null,this.displayMode=t,this.onChange(this))}async load(t){let e=new AbortController;this.contentRequest=e;try{let i=await this.request("api/views/"+encodeURIComponent(t),{signal:e.signal});if(this.contentRequest!==e||this.activeId!==t)return;let s=this.views.find(n=>n.id===t);if(!s||i.revision<s.revision)return;this.cache.set(t,i),this.snapshot=i,this.error=""}catch(i){if(this.contentRequest!==e||this.activeId!==t||i.name==="AbortError")return;this.error="Unable to load view: "+i.message}finally{this.contentRequest===e&&(this.contentRequest=null,this.loading=!1,this.onChange(this))}}async close(t){if(!(t===null||t==="board"||this.closing.has(t))){this.cancelOpen(),this.closing.add(t),this.mutation+=1,this.onChange(this);try{await this.request("api/views/"+encodeURIComponent(t),{method:"DELETE"}),this.mutation+=1;let e=this.views.findIndex(i=>i.id===t);this.views=this.views.filter(i=>i.id!==t),this.cache.delete(t),this.activeId===t&&await this.select(e>0?this.views[e-1].id:this.defaultId())}catch(e){this.error="Unable to close tab: "+e.message}finally{this.closing.delete(t),this.onChange(this)}}}};function g(l,t,e,i){let s=l.createElement(t);return s.className=e,i!==void 0&&(s.textContent=i),s}var P=class{constructor(t,e=[0,0]){this.document=t.ownerDocument,this.window=this.document.defaultView,this.position=e,this.generation=0,this.frame=g(this.document,"iframe","snapshot-html"),this.frame.title="HTML preview",this.receive=i=>{if(i.source!==this.frame.contentWindow)return;let s=i.data;!s||s.generation!==this.generation||(s.type==="workspace-preview-ready"?this.frame.contentWindow.postMessage({type:"workspace-preview-restore",generation:this.generation,position:this.position},"*"):s.type==="workspace-preview-scroll"&&Array.isArray(s.position)&&s.position.length===2&&s.position.every(Number.isFinite)&&(this.position=s.position))},this.window.addEventListener("message",this.receive),t.appendChild(this.frame)}update(t){if(t===this.source)return;let e=new this.window.DOMParser().parseFromString(t,"text/html"),i=this.generation+1,s=e.createElement("script");s.textContent=`(() => {
|
|
10
|
+
const generation = ${i};
|
|
11
|
+
let restored = false;
|
|
12
|
+
function report() {
|
|
13
|
+
if (restored) parent.postMessage({
|
|
14
|
+
type: "workspace-preview-scroll", generation, position: [scrollX, scrollY],
|
|
15
|
+
}, "*");
|
|
16
|
+
}
|
|
17
|
+
addEventListener("message", event => {
|
|
18
|
+
const message = event.data;
|
|
19
|
+
if (event.source !== parent || !message ||
|
|
20
|
+
message.type !== "workspace-preview-restore" || message.generation !== generation) return;
|
|
21
|
+
scrollTo(message.position[0], message.position[1]);
|
|
22
|
+
restored = true;
|
|
23
|
+
report();
|
|
24
|
+
});
|
|
25
|
+
addEventListener("scroll", report, {passive: true});
|
|
26
|
+
addEventListener("load", () => parent.postMessage({
|
|
27
|
+
type: "workspace-preview-ready", generation,
|
|
28
|
+
}, "*"));
|
|
29
|
+
})();`,e.head.prepend(s);let n=e.doctype?new this.window.XMLSerializer().serializeToString(e.doctype)+`
|
|
30
|
+
`:"";this.frame.srcdoc=n+e.documentElement.outerHTML,this.generation=i,this.source=t}capture(){return this.position}destroy(){this.window.removeEventListener("message",this.receive)}},T=class{constructor({content:t,renderMarkdown:e,loadEditor:i=null,onInsertContext:s=null,onChangeMode:n=null,createHtmlPreview:r=(o,c)=>new P(o,c)}){this.content=t,this.document=t.ownerDocument,this.renderMarkdown=e,this.onInsertContext=s,this.onChangeMode=n,this.loadEditor=i||(()=>this.loadEditorAssets()),this.editorAssets=null,this.createHtmlPreview=r,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(g(this.document,"div","content-placeholder",t))}addModeToggle(t,e,i=null){if(!this.onChangeMode)return;let s=g(this.document,"button","content-mode-toggle",e?"Show preview":"Show source");s.type="button",s.title=e?"Show rendered Markdown":"Show Markdown source",s.setAttribute("aria-label",s.textContent),s.addEventListener("click",()=>this.onChangeMode(e?"preview":"source")),t.insertBefore(s,i)}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],r=e.kind==="file"&&!this.sourceViews.has(t),o=r&&/\.(md|markdown)$/i.test(n.label)&&this.renderMarkdown,c=r&&/\.html?$/i.test(n.label);if(this.activeId===t&&this.htmlPreview&&c)return this.htmlPreview.update(n.text);if(this.activeId===t&&this.editor&&!o&&!c){let w=this.generation;try{await this.editor.update(e),w===this.generation&&await this.applySelection(t,i)}catch(v){if(w===this.generation)throw v}return}this.clear(),this.activeId=t;let h=this.generation;if(e.kind==="diff"||!n.image_url&&!o&&!c){this.content.classList.add("code-view"),this.content.appendChild(g(this.document,"div","content-placeholder","Loading code viewer\u2026"));try{let w=await this.loadEditor();if(h!==this.generation)return;this.content.textContent="";let v=g(this.document,"div","workspace-editor");this.content.appendChild(v);let m=this.onInsertContext&&(x=>{h===this.generation&&this.activeId===t&&this.onInsertContext(x)}),I=await w.createSnapshotEditor(v,e,this.editorStates.get(t),m);if(h!==this.generation){I.destroy();return}if(this.editor=I,this.onChangeMode&&this.renderMarkdown&&e.kind==="file"&&/\.(md|markdown)$/i.test(n.label)){let x=v.querySelector(".editor-toolbar"),a=x.querySelector(".editor-actions");x.insertBefore(g(this.document,"span","snapshot-stamp","Read only"),a),this.addModeToggle(x,!0,a)}}catch(w){if(h!==this.generation)return;this.content.textContent="",this.content.appendChild(g(this.document,"div","content-placeholder","Unable to load code viewer: "+w.message));return}await this.applySelection(t,i);return}if(c)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 w=g(this.document,"div","snapshot"),v=g(this.document,"div","snapshot-heading");if(v.appendChild(g(this.document,"span","snapshot-label",n.label)),v.appendChild(g(this.document,"span","snapshot-stamp","Read only")),o&&this.onChangeMode&&(v.classList.add("markdown-heading"),this.addModeToggle(v,!1)),w.appendChild(v),n.image_url){let m=g(this.document,"img","snapshot-image");m.alt=n.label,w.appendChild(m)}else w.appendChild(g(this.document,"div","snapshot-markdown markdown"));this.previews.set(t,w)}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),o){let w=this.content.querySelector(".snapshot-markdown");n.source&&w.setAttribute("data-src-path",n.source.path),this.renderMarkdown(w,n.text)}let[p,S]=this.scrollPositions.get(t)||[0,0];this.content.scrollLeft=p,this.content.scrollTop=S}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 Z({tabs:l,frame:t,content:e,status:i,request:s,renderMarkdown:n,onInsertContext:r,enableBoard:o=!0}){let c=l.ownerDocument,h=new T({content:e,renderMarkdown:n,onInsertContext:r,onChangeMode:a=>m.setDisplayMode(a)}),u,p=null,S=!1,w=null,v=null,m;function I(a){let k=c.getElementById("content-tab-"+a);k&&(k.focus({preventScroll:!0}),k.scrollIntoView({block:"nearest",inline:"nearest"}))}function x(a){let k=a.snapshot?a.snapshot.documents.map(d=>d.error):[];i.textContent=[a.error,...k].filter(Boolean).join("; ")||(a.openRequest?"Opening view\u2026":""),i.title=i.textContent,i.hidden=!i.textContent;let R=new Map(Array.from(l.children,d=>[d.dataset.viewId,d])),$=(o?[{id:"board",title:"Canvas",kind:"board"}]:[]).concat(a.views),z=j(a.views);z.set("board",{name:"Canvas",description:""}),$.forEach((d,q)=>{let f=R.get(d.id);if(!f){f=g(c,"div","tab content-tab"),f.dataset.viewId=d.id,f.id="content-tab-"+d.id,f.setAttribute("role","tab"),f.setAttribute("aria-controls",d.id==="board"?t.id:e.id);let N=g(c,"span","tab-label content-tab-label");if(N.appendChild(g(c,"span","content-tab-name")),N.appendChild(g(c,"span","content-tab-description")),f.appendChild(N),f.addEventListener("click",()=>m.select(d.id)),f.addEventListener("keydown",y=>{if(y.target!==f)return;let b=(o?["board"]:[]).concat(m.views.map(K=>K.id)),V=b.indexOf(d.id),C=null;y.key==="ArrowRight"&&(C=b[(V+1)%b.length]),y.key==="ArrowLeft"&&(C=b[(V+b.length-1)%b.length]),y.key==="Home"&&(C=b[0]),y.key==="End"&&(C=b[b.length-1]),(y.key==="Enter"||y.key===" ")&&(C=d.id),y.key==="Delete"&&d.id!=="board"&&(y.preventDefault(),m.close(d.id)),C!==null&&(y.preventDefault(),m.select(C),c.getElementById("content-tab-"+C).focus())}),d.id!=="board"){let y=g(c,"button","tab-close content-tab-close");y.type="button",y.title="Close tab",y.setAttribute("aria-label","Close "+d.title),y.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>',y.addEventListener("click",b=>{b.stopPropagation(),m.close(d.id)}),f.appendChild(y)}}R.delete(d.id);let A=z.get(d.id);f.querySelector(".content-tab-name").textContent=A.name;let U=f.querySelector(".content-tab-description");U.textContent=A.description,U.hidden=!A.description,f.className="tab content-tab"+(d.id===a.activeId?" active":""),f.tabIndex=d.id===a.activeId?0:-1,f.title=d.id==="board"?l.dataset.boardTitle:d.title+(d.live?`
|
|
31
|
+
Read only \xB7 follows file changes \xB7 `:`
|
|
32
|
+
Read-only snapshot \xB7 `)+new Date(d.created_at).toLocaleString(),f.setAttribute("aria-selected",String(d.id===a.activeId)),f.setAttribute("aria-label",d.title);let M=f.querySelector(".content-tab-close");M&&(M.disabled=a.closing.has(d.id),M.setAttribute("aria-label","Close "+d.title)),l.children[q]!==f&&l.insertBefore(f,l.children[q]||null)}),R.forEach(d=>d.remove()),h.retain(a.views.map(d=>d.id));let L=a.activeId==="board",B=u!==a.activeId,J=B||p!==a.snapshot||S!==a.loading||w!==a.selection||v!==a.displayMode;if(L&&J&&h.clear(),t.hidden=!L,e.hidden=L,!!J&&(u=a.activeId,p=a.snapshot,S=a.loading,w=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)),!L)){if(a.snapshot){let d=a.activeId,q=a.snapshot;h.show(d,q,a.selection,a.displayMode).catch(f=>{u!==d||p!==q||(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.");B&&I(a.activeId)}}return m=new O({request:s,enableBoard:o,onChange:x,onSelect:a=>{e.closest(".workspace").dataset.mobilePane="board",I(a)}}),x(m),{start(){m.start(),x(m)},stop(){m.stop(),h.clear(),u=null},poll(){return m.poll()},open(a,k,R){return m.open(a,k,R)}}}export{E as Composer,O as ContentTabs,D as PromptHistory,T as SnapshotDisplay,Q as isWorkspaceLink,Z 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
|
+
)
|