pycodex-ws 0.4.4__tar.gz → 0.4.5__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.4.4/README.md → pycodex_ws-0.4.5/PKG-INFO +24 -0
- pycodex_ws-0.4.4/PKG-INFO → pycodex_ws-0.4.5/README.md +13 -11
- {pycodex_ws-0.4.4 → pycodex_ws-0.4.5}/pycodex_router/index.html +86 -19
- {pycodex_ws-0.4.4 → pycodex_ws-0.4.5}/pycodex_router/server.py +3 -5
- {pycodex_ws-0.4.4 → pycodex_ws-0.4.5}/pyproject.toml +2 -2
- {pycodex_ws-0.4.4 → pycodex_ws-0.4.5}/workspace_server/app.py +46 -9
- {pycodex_ws-0.4.4 → pycodex_ws-0.4.5}/workspace_server/workspace.html +6 -2
- {pycodex_ws-0.4.4 → pycodex_ws-0.4.5}/.gitignore +0 -0
- {pycodex_ws-0.4.4 → pycodex_ws-0.4.5}/LICENSE +0 -0
- {pycodex_ws-0.4.4 → pycodex_ws-0.4.5}/pycodex_router/__init__.py +0 -0
- {pycodex_ws-0.4.4 → pycodex_ws-0.4.5}/pycodex_router/__main__.py +0 -0
- {pycodex_ws-0.4.4 → pycodex_ws-0.4.5}/pycodex_router/browser.py +0 -0
- {pycodex_ws-0.4.4 → pycodex_ws-0.4.5}/pycodex_router/client.py +0 -0
- {pycodex_ws-0.4.4 → pycodex_ws-0.4.5}/pycodex_router/protocol.py +0 -0
- {pycodex_ws-0.4.4 → pycodex_ws-0.4.5}/pycodex_ws/__init__.py +0 -0
- {pycodex_ws-0.4.4 → pycodex_ws-0.4.5}/pycodex_ws/feishu_card.py +0 -0
- {pycodex_ws-0.4.4 → pycodex_ws-0.4.5}/pycodex_ws/feishu_link.py +0 -0
- {pycodex_ws-0.4.4 → pycodex_ws-0.4.5}/pycodex_ws/feishu_oauth.py +0 -0
- {pycodex_ws-0.4.4 → pycodex_ws-0.4.5}/workspace_server/__init__.py +0 -0
- {pycodex_ws-0.4.4 → pycodex_ws-0.4.5}/workspace_server/__main__.py +0 -0
- {pycodex_ws-0.4.4 → pycodex_ws-0.4.5}/workspace_server/content_views.LICENSE.txt +0 -0
- {pycodex_ws-0.4.4 → pycodex_ws-0.4.5}/workspace_server/content_views.css +0 -0
- {pycodex_ws-0.4.4 → pycodex_ws-0.4.5}/workspace_server/content_views.mjs +0 -0
- {pycodex_ws-0.4.4 → pycodex_ws-0.4.5}/workspace_server/editor.css +0 -0
- {pycodex_ws-0.4.4 → pycodex_ws-0.4.5}/workspace_server/editor.mjs +0 -0
- {pycodex_ws-0.4.4 → pycodex_ws-0.4.5}/workspace_server/frontend/.gitignore +0 -0
- {pycodex_ws-0.4.4 → pycodex_ws-0.4.5}/workspace_server/frontend/build.mjs +0 -0
- {pycodex_ws-0.4.4 → pycodex_ws-0.4.5}/workspace_server/frontend/composer.mjs +0 -0
- {pycodex_ws-0.4.4 → pycodex_ws-0.4.5}/workspace_server/frontend/content_views.css +0 -0
- {pycodex_ws-0.4.4 → pycodex_ws-0.4.5}/workspace_server/frontend/content_views.mjs +0 -0
- {pycodex_ws-0.4.4 → pycodex_ws-0.4.5}/workspace_server/frontend/editor.css +0 -0
- {pycodex_ws-0.4.4 → pycodex_ws-0.4.5}/workspace_server/frontend/editor.mjs +0 -0
- {pycodex_ws-0.4.4 → pycodex_ws-0.4.5}/workspace_server/frontend/editor_context.mjs +0 -0
- {pycodex_ws-0.4.4 → pycodex_ws-0.4.5}/workspace_server/frontend/package-lock.json +0 -0
- {pycodex_ws-0.4.4 → pycodex_ws-0.4.5}/workspace_server/frontend/package.json +0 -0
- {pycodex_ws-0.4.4 → pycodex_ws-0.4.5}/workspace_server/frontend/touch_selection.mjs +0 -0
- {pycodex_ws-0.4.4 → pycodex_ws-0.4.5}/workspace_server/frontend/view_labels.mjs +0 -0
- {pycodex_ws-0.4.4 → pycodex_ws-0.4.5}/workspace_server/owned_process.py +0 -0
- {pycodex_ws-0.4.4 → pycodex_ws-0.4.5}/workspace_server/workspace_views.py +0 -0
- {pycodex_ws-0.4.4 → pycodex_ws-0.4.5}/workspace_server/workspaces.html +0 -0
- {pycodex_ws-0.4.4 → pycodex_ws-0.4.5}/workspace_server/workspaces.py +0 -0
|
@@ -1,3 +1,14 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: pycodex-ws
|
|
3
|
+
Version: 0.4.5
|
|
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.4.5
|
|
9
|
+
Requires-Dist: websockets<16,>=11
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
|
|
1
12
|
# pycodex-ws
|
|
2
13
|
|
|
3
14
|
`pycodex-ws` provides the browser workspace, Canvas and file/diff viewer,
|
|
@@ -84,6 +95,19 @@ The resolved `state_path` identifies the workspace; two entries cannot share it.
|
|
|
84
95
|
Creating a workspace through the management page or API without a prefix
|
|
85
96
|
generates a `/tmp/pcws-*` prefix and saves it in the configuration.
|
|
86
97
|
|
|
98
|
+
The router directory collects unread workspace shortcuts and their device names
|
|
99
|
+
in a distinct Unread panel at the top. Read workspaces leave that panel; the
|
|
100
|
+
complete device groups keep their device id and workspace configuration order.
|
|
101
|
+
Device groups start collapsed, retain their expanded state during updates, and
|
|
102
|
+
show Manage and the number of working workspaces in their headings. Click the
|
|
103
|
+
device name to rename it; aliases must be nonblank and contain 1 to 128
|
|
104
|
+
characters. Device headings and the Online devices list share an unread dot
|
|
105
|
+
whenever any of that device's workspaces is unread. Final replies and failed
|
|
106
|
+
turns both trigger shared unread dots; viewing the reply or error marks it as read.
|
|
107
|
+
|
|
108
|
+
Login pages state whether you are logging in to the router or workspace service
|
|
109
|
+
and show the server hostname and access address.
|
|
110
|
+
|
|
87
111
|
`enable_board` defaults to `true`. Setting it to `false` disables the Canvas
|
|
88
112
|
tab, HTML/image/status routes, polling and Canvas context injection. Sessions
|
|
89
113
|
and file views still save and restore from the same prefix, so changing this
|
|
@@ -1,14 +1,3 @@
|
|
|
1
|
-
Metadata-Version: 2.1
|
|
2
|
-
Name: pycodex-ws
|
|
3
|
-
Version: 0.4.4
|
|
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.4.4
|
|
9
|
-
Requires-Dist: websockets<16,>=11
|
|
10
|
-
Description-Content-Type: text/markdown
|
|
11
|
-
|
|
12
1
|
# pycodex-ws
|
|
13
2
|
|
|
14
3
|
`pycodex-ws` provides the browser workspace, Canvas and file/diff viewer,
|
|
@@ -95,6 +84,19 @@ The resolved `state_path` identifies the workspace; two entries cannot share it.
|
|
|
95
84
|
Creating a workspace through the management page or API without a prefix
|
|
96
85
|
generates a `/tmp/pcws-*` prefix and saves it in the configuration.
|
|
97
86
|
|
|
87
|
+
The router directory collects unread workspace shortcuts and their device names
|
|
88
|
+
in a distinct Unread panel at the top. Read workspaces leave that panel; the
|
|
89
|
+
complete device groups keep their device id and workspace configuration order.
|
|
90
|
+
Device groups start collapsed, retain their expanded state during updates, and
|
|
91
|
+
show Manage and the number of working workspaces in their headings. Click the
|
|
92
|
+
device name to rename it; aliases must be nonblank and contain 1 to 128
|
|
93
|
+
characters. Device headings and the Online devices list share an unread dot
|
|
94
|
+
whenever any of that device's workspaces is unread. Final replies and failed
|
|
95
|
+
turns both trigger shared unread dots; viewing the reply or error marks it as read.
|
|
96
|
+
|
|
97
|
+
Login pages state whether you are logging in to the router or workspace service
|
|
98
|
+
and show the server hostname and access address.
|
|
99
|
+
|
|
98
100
|
`enable_board` defaults to `true`. Setting it to `false` disables the Canvas
|
|
99
101
|
tab, HTML/image/status routes, polling and Canvas context injection. Sessions
|
|
100
102
|
and file views still save and restore from the same prefix, so changing this
|
|
@@ -17,6 +17,8 @@
|
|
|
17
17
|
--green: #26734f;
|
|
18
18
|
--green-soft: #eaf5ee;
|
|
19
19
|
--danger: #b54739;
|
|
20
|
+
--unread-bg: #fff7f4;
|
|
21
|
+
--unread-line: #ead0c8;
|
|
20
22
|
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
21
23
|
}
|
|
22
24
|
@media (prefers-color-scheme: dark) {
|
|
@@ -31,12 +33,14 @@
|
|
|
31
33
|
--green: #8ad5ad;
|
|
32
34
|
--green-soft: #233b31;
|
|
33
35
|
--danger: #ffa396;
|
|
36
|
+
--unread-bg: #2b2221;
|
|
37
|
+
--unread-line: #62433d;
|
|
34
38
|
}
|
|
35
39
|
}
|
|
36
40
|
* { box-sizing: border-box; }
|
|
37
41
|
body { margin: 0; min-height: 100vh; background: var(--bg); color: var(--ink); font-size: 13px; }
|
|
38
42
|
a { color: inherit; text-decoration: none; }
|
|
39
|
-
a:focus-visible, .device-label:focus-visible { outline: 2px solid var(--accent); outline-offset: 5px; border-radius: 3px; }
|
|
43
|
+
a:focus-visible, .device-label:focus-visible, .workspace-group-heading:focus-visible { outline: 2px solid var(--accent); outline-offset: 5px; border-radius: 3px; }
|
|
40
44
|
svg { width: 20px; height: 20px; flex: 0 0 auto; fill: none; stroke: currentColor; stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; }
|
|
41
45
|
h1, h2, h3, h4, p { margin: 0; }
|
|
42
46
|
code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
|
@@ -70,7 +74,10 @@
|
|
|
70
74
|
#activity { color: var(--green); font-size: 12px; }
|
|
71
75
|
#workspaces { display: grid; gap: 16px; }
|
|
72
76
|
.workspace-table, .nodes { background: var(--panel); border: 1px solid var(--line); border-radius: 9px; }
|
|
73
|
-
.workspace-group-heading { display: flex; align-items: center; gap: 10px; padding: 12px 14px; }
|
|
77
|
+
.workspace-group-heading { display: flex; align-items: center; gap: 10px; padding: 12px 14px; cursor: pointer; list-style: none; }
|
|
78
|
+
.workspace-group-heading::-webkit-details-marker { display: none; }
|
|
79
|
+
.disclosure-icon { width: 14px; height: 14px; color: var(--muted); }
|
|
80
|
+
.workspace-group[open] .disclosure-icon { transform: rotate(90deg); }
|
|
74
81
|
.workspace-group-name { flex: 1; min-width: 0; font-size: 12px; font-weight: 600; line-height: 1.6; overflow-wrap: anywhere; }
|
|
75
82
|
.workspace-group-heading .count { flex: 0 0 auto; }
|
|
76
83
|
.list-head, .workspace-row { display: grid; grid-template-columns: minmax(94px, .85fr) minmax(120px, 1.85fr) 76px 44px; gap: 12px; align-items: center; padding: 10px 14px; }
|
|
@@ -79,7 +86,16 @@
|
|
|
79
86
|
.workspace-row > * { min-width: 0; }
|
|
80
87
|
.workspace-row:hover { background: var(--soft); }
|
|
81
88
|
.workspace-row:last-child { border-radius: 0 0 9px 9px; }
|
|
89
|
+
#unread-workspaces { background: var(--unread-bg); border-color: var(--unread-line); border-left: 3px solid var(--danger); }
|
|
90
|
+
.unread-heading { display: flex; align-items: center; gap: 9px; padding: 14px; color: var(--danger); }
|
|
91
|
+
.unread-heading svg { width: 17px; height: 17px; }
|
|
92
|
+
.unread-heading h3 { flex: 1; font-size: 13px; font-weight: 600; }
|
|
93
|
+
.unread-heading .count { color: var(--panel); background: var(--danger); }
|
|
94
|
+
#unread-workspaces .workspace-row { border-color: var(--unread-line); }
|
|
95
|
+
#unread-workspaces .workspace-row:hover { background: var(--panel); }
|
|
82
96
|
.activity-badge { display: inline-flex; align-items: center; gap: 5px; padding: 3px 6px; border-radius: 4px; color: var(--muted); background: var(--soft); font-size: 10px; font-weight: 500; }
|
|
97
|
+
.activity-badge[hidden] { display: none; }
|
|
98
|
+
.workspace-group-name .device-working { margin-left: 8px; white-space: nowrap; }
|
|
83
99
|
.activity-badge[data-state="working"] { color: var(--green); background: var(--green-soft); }
|
|
84
100
|
.activity-badge[data-state="working"]::before { content: ""; width: 5px; height: 5px; border-radius: 50%; background: currentColor; animation: spinner-pulse 1.2s cubic-bezier(0.4, 0, 0.2, 1) infinite; }
|
|
85
101
|
@keyframes spinner-pulse {
|
|
@@ -94,7 +110,7 @@
|
|
|
94
110
|
.workspace-title { font-size: 12px; line-height: 1.6; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
95
111
|
.workspace-path, .state-path { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 11px; line-height: 1.8; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
96
112
|
.state-path { color: var(--muted); }
|
|
97
|
-
.session-count { color: var(--muted); font-size: 11px; line-height: 1.7; }
|
|
113
|
+
.session-count { display: block; color: var(--muted); font-size: 11px; line-height: 1.7; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
98
114
|
.open-link, .manage-link { display: inline-flex; align-items: center; justify-content: flex-end; gap: 4px; min-height: 28px; color: var(--accent); font-size: 11px; font-weight: 550; white-space: nowrap; }
|
|
99
115
|
.open-link svg, .manage-link svg { width: 13px; height: 13px; }
|
|
100
116
|
.open-link::after { content: ""; position: absolute; inset: 0; }
|
|
@@ -104,10 +120,11 @@
|
|
|
104
120
|
.device-heading { display: flex; align-items: start; gap: 9px; }
|
|
105
121
|
.device-heading svg { width: 17px; height: 17px; margin-top: 1px; color: var(--muted); }
|
|
106
122
|
.device-name { flex: 1; min-width: 0; font-size: 11px; font-weight: 500; line-height: 1.6; overflow-wrap: anywhere; }
|
|
107
|
-
.device-label { display: block; width: 100%; padding: 0; border: 0; background: none; color: inherit; font: inherit; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; text-align: left; overflow-wrap: anywhere; cursor: text; }
|
|
123
|
+
.device-label { display: inline-block; max-width: 100%; padding: 0; border: 0; background: none; color: inherit; font: inherit; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; text-align: left; overflow-wrap: anywhere; cursor: text; }
|
|
108
124
|
.device-label[hidden] { display: none; }
|
|
109
125
|
.device-alias-input { width: 100%; min-width: 0; padding: 3px 6px; border: 1px solid var(--accent); border-radius: 4px; background: var(--panel); color: var(--ink); font: inherit; outline: none; }
|
|
110
126
|
.online-dot { margin-top: 7px; }
|
|
127
|
+
.device-heading > .unread-dot { margin-top: 7px; }
|
|
111
128
|
.device-footer { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 2px 8px; margin-top: 6px; }
|
|
112
129
|
.device-detail { color: var(--muted); font-size: 10px; line-height: 1.5; }
|
|
113
130
|
.empty { grid-column: 1 / -1; display: grid; justify-items: center; gap: 8px; padding: 32px 20px; text-align: center; }
|
|
@@ -179,17 +196,33 @@
|
|
|
179
196
|
</div>
|
|
180
197
|
</main>
|
|
181
198
|
</div>
|
|
182
|
-
<template id="
|
|
183
|
-
<section class="workspace-table
|
|
184
|
-
<header class="
|
|
185
|
-
<
|
|
199
|
+
<template id="unread-template">
|
|
200
|
+
<section id="unread-workspaces" class="workspace-table" aria-labelledby="unread-heading">
|
|
201
|
+
<header class="unread-heading">
|
|
202
|
+
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M18 8a6 6 0 0 0-12 0c0 7-3 7-3 9h18c0-2-3-2-3-9m-8 12h4"/></svg>
|
|
203
|
+
<h3 id="unread-heading">Unread</h3>
|
|
186
204
|
<span class="count"></span>
|
|
187
205
|
</header>
|
|
206
|
+
<div class="workspace-group-list"></div>
|
|
207
|
+
</section>
|
|
208
|
+
</template>
|
|
209
|
+
<template id="workspace-group-template">
|
|
210
|
+
<details class="workspace-table workspace-group">
|
|
211
|
+
<summary class="workspace-group-heading">
|
|
212
|
+
<svg class="disclosure-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="m9 6 6 6-6 6"/></svg>
|
|
213
|
+
<h3 class="workspace-group-name">
|
|
214
|
+
<button type="button" class="device-label"></button>
|
|
215
|
+
<span class="activity-badge device-working" data-state="working" hidden></span>
|
|
216
|
+
</h3>
|
|
217
|
+
<span class="unread-dot device-unread" hidden role="img" aria-label="Unread workspaces" title="Unread workspaces"></span>
|
|
218
|
+
<span class="count"></span>
|
|
219
|
+
<a class="manage-link">Manage <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12h14m-6-6 6 6-6 6"/></svg></a>
|
|
220
|
+
</summary>
|
|
188
221
|
<div class="list-head" aria-hidden="true">
|
|
189
222
|
<span>Workspace</span><span>Directory / state</span><span>Status</span><span></span>
|
|
190
223
|
</div>
|
|
191
224
|
<div class="workspace-group-list"></div>
|
|
192
|
-
</
|
|
225
|
+
</details>
|
|
193
226
|
</template>
|
|
194
227
|
<template id="workspace-template">
|
|
195
228
|
<article class="workspace-row">
|
|
@@ -213,11 +246,12 @@
|
|
|
213
246
|
<div class="device-heading">
|
|
214
247
|
<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="4" width="18" height="12" rx="2"/><path d="M8 20h8m-4-4v4"/></svg>
|
|
215
248
|
<h3 class="device-name"><button type="button" class="device-label"></button></h3>
|
|
249
|
+
<span class="unread-dot device-unread" hidden role="img" aria-label="Unread workspaces" title="Unread workspaces"></span>
|
|
216
250
|
<span class="online-dot" aria-label="Online"></span>
|
|
217
251
|
</div>
|
|
218
252
|
<div class="device-footer">
|
|
219
253
|
<p class="device-detail"></p>
|
|
220
|
-
<
|
|
254
|
+
<span class="activity-badge device-working" data-state="working" hidden></span>
|
|
221
255
|
</div>
|
|
222
256
|
</article>
|
|
223
257
|
</template>
|
|
@@ -235,6 +269,7 @@
|
|
|
235
269
|
const layout = document.querySelector(".layout");
|
|
236
270
|
const workspaces = document.getElementById("workspaces");
|
|
237
271
|
const nodes = document.getElementById("nodes");
|
|
272
|
+
const unreadTemplate = document.getElementById("unread-template");
|
|
238
273
|
const workspaceGroupTemplate = document.getElementById("workspace-group-template");
|
|
239
274
|
const workspaceTemplate = document.getElementById("workspace-template");
|
|
240
275
|
const deviceTemplate = document.getElementById("device-template");
|
|
@@ -297,9 +332,11 @@
|
|
|
297
332
|
const input = document.createElement("input");
|
|
298
333
|
input.type = "text";
|
|
299
334
|
input.className = "device-alias-input";
|
|
335
|
+
input.minLength = 1;
|
|
300
336
|
input.maxLength = 128;
|
|
337
|
+
input.required = true;
|
|
301
338
|
input.setAttribute("aria-label", "Device alias");
|
|
302
|
-
input.title = "
|
|
339
|
+
input.title = "Use 1 to 128 characters.";
|
|
303
340
|
input.value = node.alias || "";
|
|
304
341
|
input.placeholder = node.name;
|
|
305
342
|
label.hidden = true;
|
|
@@ -312,6 +349,7 @@
|
|
|
312
349
|
input.remove();
|
|
313
350
|
label.hidden = false;
|
|
314
351
|
try {
|
|
352
|
+
if (save && !alias) throw new Error("Device alias must contain at least one character.");
|
|
315
353
|
if (save && alias !== (node.alias || "")) {
|
|
316
354
|
const response = await fetch(`api/nodes/${encodeURIComponent(node.id)}/alias`, {
|
|
317
355
|
method: "POST",
|
|
@@ -346,12 +384,21 @@
|
|
|
346
384
|
}
|
|
347
385
|
|
|
348
386
|
function render(data) {
|
|
387
|
+
const expanded = new Set(Array.from(
|
|
388
|
+
workspaces.querySelectorAll(".workspace-group[open]"), group => group.dataset.nodeId));
|
|
349
389
|
const workspaceList = document.createDocumentFragment();
|
|
390
|
+
const unreadGroup = unreadTemplate.content.firstElementChild.cloneNode(true);
|
|
391
|
+
const unreadList = unreadGroup.querySelector(".workspace-group-list");
|
|
350
392
|
const groups = new Map();
|
|
351
393
|
for (const node of data.nodes) {
|
|
352
394
|
const group = workspaceGroupTemplate.content.firstElementChild.cloneNode(true);
|
|
395
|
+
group.dataset.nodeId = node.id;
|
|
396
|
+
group.open = expanded.has(node.id);
|
|
353
397
|
group.setAttribute("aria-label", node.alias || node.name);
|
|
354
398
|
bindDeviceLabel(group.querySelector(".device-label"), node, data);
|
|
399
|
+
const link = group.querySelector(".manage-link");
|
|
400
|
+
link.href = node.url;
|
|
401
|
+
link.setAttribute("aria-label", `Manage workspaces on ${node.alias || node.name}`);
|
|
355
402
|
groups.set(node.id, {node, group});
|
|
356
403
|
}
|
|
357
404
|
for (const workspace of data.workspaces) {
|
|
@@ -375,17 +422,33 @@
|
|
|
375
422
|
const {node, group} = groups.get(workspace.node_id);
|
|
376
423
|
link.setAttribute("aria-label", `Open ${workspace.id} on ${node.alias || node.name}`);
|
|
377
424
|
group.querySelector(".workspace-group-list").append(card);
|
|
425
|
+
if (workspace.unread) {
|
|
426
|
+
group.querySelector(".device-unread").hidden = false;
|
|
427
|
+
const unreadCard = card.cloneNode(true);
|
|
428
|
+
const detail = unreadCard.querySelector(".session-count");
|
|
429
|
+
detail.textContent = `${node.alias || node.name} · ${detail.textContent}`;
|
|
430
|
+
detail.title = detail.textContent;
|
|
431
|
+
unreadList.append(unreadCard);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
if (unreadList.childElementCount) {
|
|
435
|
+
unreadGroup.querySelector(".count").textContent = countLabel(
|
|
436
|
+
unreadList.childElementCount, "workspace");
|
|
437
|
+
workspaceList.append(unreadGroup);
|
|
378
438
|
}
|
|
379
439
|
for (const {group} of groups.values()) {
|
|
380
440
|
const count = group.querySelector(".workspace-group-list").childElementCount;
|
|
381
|
-
if (!count) continue;
|
|
382
441
|
group.querySelector(".count").textContent = countLabel(count, "workspace");
|
|
442
|
+
if (!count) {
|
|
443
|
+
group.querySelector(".list-head").remove();
|
|
444
|
+
group.querySelector(".workspace-group-list").append(
|
|
445
|
+
empty("No workspaces yet", "Use Manage to create a workspace on this device."));
|
|
446
|
+
}
|
|
383
447
|
workspaceList.append(group);
|
|
384
448
|
}
|
|
385
|
-
if (!data.
|
|
386
|
-
workspaceList.append(empty("No workspaces yet",
|
|
387
|
-
|
|
388
|
-
: "Your workspaces will appear when a device connects."));
|
|
449
|
+
if (!data.nodes.length) {
|
|
450
|
+
workspaceList.append(empty("No workspaces yet",
|
|
451
|
+
"Your workspaces will appear when a device connects."));
|
|
389
452
|
}
|
|
390
453
|
|
|
391
454
|
const nodeList = document.createDocumentFragment();
|
|
@@ -393,11 +456,15 @@
|
|
|
393
456
|
const card = deviceTemplate.content.firstElementChild.cloneNode(true);
|
|
394
457
|
bindDeviceLabel(card.querySelector(".device-label"), node, data);
|
|
395
458
|
const owned = data.workspaces.filter(workspace => workspace.node_id === node.id);
|
|
459
|
+
card.querySelector(".device-unread").hidden = !owned.some(workspace => workspace.unread);
|
|
396
460
|
const sessions = owned.reduce((total, workspace) => total + workspace.session_count, 0);
|
|
397
461
|
card.querySelector(".device-detail").textContent = `${countLabel(owned.length, "workspace")} · ${countLabel(sessions, "session")}`;
|
|
398
|
-
const
|
|
399
|
-
|
|
400
|
-
|
|
462
|
+
const working = owned.filter(workspace => workspace.running).length;
|
|
463
|
+
for (const container of [groups.get(node.id).group, card]) {
|
|
464
|
+
const badge = container.querySelector(".device-working");
|
|
465
|
+
badge.hidden = !working;
|
|
466
|
+
badge.textContent = working ? `${working} working` : "";
|
|
467
|
+
}
|
|
401
468
|
nodeList.append(card);
|
|
402
469
|
}
|
|
403
470
|
if (!data.nodes.length) {
|
|
@@ -63,10 +63,11 @@ def load_registration_key(path):
|
|
|
63
63
|
def normalize_alias(value):
|
|
64
64
|
if (
|
|
65
65
|
not isinstance(value, str)
|
|
66
|
+
or not value.strip()
|
|
66
67
|
or len(value) > 128
|
|
67
68
|
or any(ord(char) < 32 for char in value)
|
|
68
69
|
):
|
|
69
|
-
raise ValueError("Device alias must be a single line of
|
|
70
|
+
raise ValueError("Device alias must be a single line of 1 to 128 characters.")
|
|
70
71
|
value.encode("utf-8")
|
|
71
72
|
return value.strip()
|
|
72
73
|
|
|
@@ -214,10 +215,7 @@ class Router:
|
|
|
214
215
|
except ValueError as exc:
|
|
215
216
|
raise HTTPException(422, str(exc))
|
|
216
217
|
aliases = dict(self.aliases)
|
|
217
|
-
|
|
218
|
-
aliases[node_id] = alias
|
|
219
|
-
else:
|
|
220
|
-
aliases.pop(node_id, None)
|
|
218
|
+
aliases[node_id] = alias
|
|
221
219
|
self._save_aliases(aliases)
|
|
222
220
|
return JSONResponse({"alias": alias})
|
|
223
221
|
|
|
@@ -4,12 +4,12 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "pycodex-ws"
|
|
7
|
-
version = "0.4.
|
|
7
|
+
version = "0.4.5"
|
|
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.4.
|
|
12
|
+
"python-codex==0.4.5",
|
|
13
13
|
"pillow>=9.0.0",
|
|
14
14
|
"websockets>=11,<16",
|
|
15
15
|
]
|
|
@@ -7,6 +7,7 @@ import json
|
|
|
7
7
|
import mimetypes
|
|
8
8
|
import os
|
|
9
9
|
import secrets
|
|
10
|
+
import socket
|
|
10
11
|
import subprocess
|
|
11
12
|
import sys
|
|
12
13
|
import threading
|
|
@@ -479,6 +480,8 @@ class WebSessionView:
|
|
|
479
480
|
if isinstance(event, TurnFailedEvent):
|
|
480
481
|
turn["status"] = "error"
|
|
481
482
|
turn["error"] = event.visualize()
|
|
483
|
+
turn["final_id"] = event.turn_id
|
|
484
|
+
self._last_final_id = event.turn_id
|
|
482
485
|
self._stream_buffer = ""
|
|
483
486
|
return
|
|
484
487
|
|
|
@@ -1132,10 +1135,15 @@ def _install_auth(
|
|
|
1132
1135
|
)
|
|
1133
1136
|
|
|
1134
1137
|
async def login_page(request: Request) -> HTMLResponse:
|
|
1135
|
-
|
|
1138
|
+
address = str(request.url.replace(
|
|
1139
|
+
path=request.scope.get("root_path", "") + "/", query="",
|
|
1140
|
+
))
|
|
1141
|
+
return _html_response(_render_login_shell(
|
|
1142
|
+
login_title, socket.gethostname(), address,
|
|
1143
|
+
))
|
|
1136
1144
|
|
|
1137
1145
|
async def login(request: Request) -> JSONResponse:
|
|
1138
|
-
payload = await _json_object_body(request)
|
|
1146
|
+
payload = await _json_object_body(request, max_bytes=4096)
|
|
1139
1147
|
client_ip = request.client.host if blocked_ips is not None else None
|
|
1140
1148
|
# Another login can block this IP while the request body is arriving.
|
|
1141
1149
|
if blocked_ips is not None and client_ip in blocked_ips:
|
|
@@ -1182,14 +1190,25 @@ def _install_auth(
|
|
|
1182
1190
|
return token
|
|
1183
1191
|
|
|
1184
1192
|
|
|
1185
|
-
async def _json_object_body(
|
|
1193
|
+
async def _json_object_body(
|
|
1194
|
+
request: Request, max_bytes: "typing.Union[int, None]" = None,
|
|
1195
|
+
) -> "typing.Dict[str, object]":
|
|
1186
1196
|
media_type = request.headers.get("content-type", "").split(";", 1)[0].strip().lower()
|
|
1187
1197
|
if media_type != "application/json" and not (
|
|
1188
1198
|
media_type.startswith("application/") and media_type.endswith("+json")
|
|
1189
1199
|
):
|
|
1190
1200
|
raise HTTPException(status_code=422, detail="request body must be JSON")
|
|
1201
|
+
body = bytearray()
|
|
1202
|
+
async for chunk in request.stream():
|
|
1203
|
+
if max_bytes is not None and len(body) + len(chunk) > max_bytes:
|
|
1204
|
+
raise HTTPException(
|
|
1205
|
+
status_code=413,
|
|
1206
|
+
detail="request body exceeds {0} bytes".format(max_bytes),
|
|
1207
|
+
headers={"Connection": "close"},
|
|
1208
|
+
)
|
|
1209
|
+
body.extend(chunk)
|
|
1191
1210
|
try:
|
|
1192
|
-
payload =
|
|
1211
|
+
payload = json.loads(body)
|
|
1193
1212
|
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
1194
1213
|
raise HTTPException(status_code=422, detail="invalid JSON body")
|
|
1195
1214
|
if not isinstance(payload, dict):
|
|
@@ -1203,13 +1222,13 @@ def _auth_cookie_matches(token: str, cookie: "typing.Union[str, None]") -> bool:
|
|
|
1203
1222
|
return bool(cookie) and secrets.compare_digest(str(cookie), token)
|
|
1204
1223
|
|
|
1205
1224
|
|
|
1206
|
-
def _render_login_shell(title: str
|
|
1225
|
+
def _render_login_shell(title: str, hostname: str, address: str) -> str:
|
|
1207
1226
|
return """<!doctype html>
|
|
1208
1227
|
<html>
|
|
1209
1228
|
<head>
|
|
1210
1229
|
<meta charset="utf-8">
|
|
1211
1230
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
1212
|
-
<title>__LOGIN_TITLE__</title>
|
|
1231
|
+
<title>Log in to __LOGIN_TITLE__</title>
|
|
1213
1232
|
<style>
|
|
1214
1233
|
:root {
|
|
1215
1234
|
color-scheme: light dark;
|
|
@@ -1232,6 +1251,12 @@ def _render_login_shell(title: str = "pycodex workspace") -> str:
|
|
|
1232
1251
|
margin: 0;
|
|
1233
1252
|
font-size: 22px;
|
|
1234
1253
|
}
|
|
1254
|
+
.login-target {
|
|
1255
|
+
margin: 0;
|
|
1256
|
+
font-size: 13px;
|
|
1257
|
+
line-height: 1.6;
|
|
1258
|
+
overflow-wrap: anywhere;
|
|
1259
|
+
}
|
|
1235
1260
|
input, button {
|
|
1236
1261
|
min-height: 38px;
|
|
1237
1262
|
border-radius: 7px;
|
|
@@ -1251,10 +1276,11 @@ def _render_login_shell(title: str = "pycodex workspace") -> str:
|
|
|
1251
1276
|
</head>
|
|
1252
1277
|
<body>
|
|
1253
1278
|
<form id="loginForm">
|
|
1254
|
-
<h1>__LOGIN_TITLE__</h1>
|
|
1279
|
+
<h1>Log in to __LOGIN_TITLE__</h1>
|
|
1280
|
+
<p class="login-target">Server: <strong>__LOGIN_HOST__</strong><br><code>__LOGIN_ADDRESS__</code></p>
|
|
1255
1281
|
<label for="passwordInput">Password</label>
|
|
1256
1282
|
<input id="passwordInput" type="password" autocomplete="current-password" autofocus>
|
|
1257
|
-
<button type="submit">
|
|
1283
|
+
<button type="submit">Log in</button>
|
|
1258
1284
|
<div id="status" class="status" role="status"></div>
|
|
1259
1285
|
</form>
|
|
1260
1286
|
<script>
|
|
@@ -1280,7 +1306,9 @@ def _render_login_shell(title: str = "pycodex workspace") -> str:
|
|
|
1280
1306
|
});
|
|
1281
1307
|
</script>
|
|
1282
1308
|
</body>
|
|
1283
|
-
</html>""".replace("__LOGIN_TITLE__", html.escape(title))
|
|
1309
|
+
</html>""".replace("__LOGIN_TITLE__", html.escape(title)).replace(
|
|
1310
|
+
"__LOGIN_HOST__", html.escape(hostname),
|
|
1311
|
+
).replace("__LOGIN_ADDRESS__", html.escape(address))
|
|
1284
1312
|
|
|
1285
1313
|
|
|
1286
1314
|
async def _http_exception_response(request: Request, exc: HTTPException) -> JSONResponse:
|
|
@@ -1637,6 +1665,15 @@ async def _websocket_session_handler(
|
|
|
1637
1665
|
manager: "WorkspaceSessionManager",
|
|
1638
1666
|
websocket: WebSocket,
|
|
1639
1667
|
) -> None:
|
|
1668
|
+
url = websocket.url
|
|
1669
|
+
scheme = "https" if url.scheme == "wss" else "http"
|
|
1670
|
+
authority = url.netloc.lower()
|
|
1671
|
+
if url.port == (443 if scheme == "https" else 80):
|
|
1672
|
+
authority = authority.rsplit(":", 1)[0]
|
|
1673
|
+
# Browsers send Origin; non-browser clients may omit it.
|
|
1674
|
+
if websocket.headers.get("origin") not in (None, scheme + "://" + authority):
|
|
1675
|
+
await websocket.close(code=1008)
|
|
1676
|
+
return
|
|
1640
1677
|
await websocket.accept()
|
|
1641
1678
|
session_id = str(websocket.query_params.get("session_id") or "")
|
|
1642
1679
|
try:
|
|
@@ -2249,11 +2249,15 @@
|
|
|
2249
2249
|
const response = renderEntry(
|
|
2250
2250
|
`${key}:response`, turn.response, isControl ? "control" : "response", !isControl
|
|
2251
2251
|
);
|
|
2252
|
-
response.dataset.finalId = isControl ? "" : (turn.final_id || "");
|
|
2252
|
+
response.dataset.finalId = isControl || turn.error ? "" : (turn.final_id || "");
|
|
2253
2253
|
response.dataset.sessionId = activeSessionId;
|
|
2254
2254
|
}
|
|
2255
2255
|
if (turn.thinking) renderEntry(`${key}:thinking`, turn.thinking, "thinking");
|
|
2256
|
-
if (turn.error)
|
|
2256
|
+
if (turn.error) {
|
|
2257
|
+
const error = renderEntry(`${key}:error`, turn.error, "error");
|
|
2258
|
+
error.dataset.finalId = isControl ? "" : (turn.final_id || "");
|
|
2259
|
+
error.dataset.sessionId = activeSessionId;
|
|
2260
|
+
}
|
|
2257
2261
|
}
|
|
2258
2262
|
|
|
2259
2263
|
async function markVisibleFinalRead() {
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|