gemcode 0.4.26__py3-none-any.whl → 0.4.27__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- gemcode/web/preview_api.py +162 -12
- gemcode/web/server.py +1 -1
- gemcode/web/sse_adapter.py +6 -1
- {gemcode-0.4.26.dist-info → gemcode-0.4.27.dist-info}/METADATA +1 -1
- {gemcode-0.4.26.dist-info → gemcode-0.4.27.dist-info}/RECORD +9 -9
- {gemcode-0.4.26.dist-info → gemcode-0.4.27.dist-info}/WHEEL +0 -0
- {gemcode-0.4.26.dist-info → gemcode-0.4.27.dist-info}/entry_points.txt +0 -0
- {gemcode-0.4.26.dist-info → gemcode-0.4.27.dist-info}/licenses/LICENSE +0 -0
- {gemcode-0.4.26.dist-info → gemcode-0.4.27.dist-info}/top_level.txt +0 -0
gemcode/web/preview_api.py
CHANGED
|
@@ -2,11 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import atexit
|
|
5
6
|
import socket
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
import threading
|
|
10
|
+
import time
|
|
6
11
|
from http.client import HTTPConnection
|
|
12
|
+
from pathlib import Path
|
|
7
13
|
from typing import Any
|
|
8
14
|
from urllib.error import URLError
|
|
9
|
-
from urllib.parse import unquote
|
|
15
|
+
from urllib.parse import quote, unquote
|
|
10
16
|
from urllib.request import Request, urlopen
|
|
11
17
|
|
|
12
18
|
# Common dev-server ports (GemCode web UI uses 3002; API 3001)
|
|
@@ -22,8 +28,13 @@ COMMON_DEV_PORTS = (
|
|
|
22
28
|
5000,
|
|
23
29
|
4321,
|
|
24
30
|
8888,
|
|
31
|
+
5500,
|
|
25
32
|
)
|
|
26
33
|
|
|
34
|
+
# When nothing listens, auto-start ``python -m http.server`` for static HTML previews.
|
|
35
|
+
# Skip framework ports (Vite/Next) so we do not mask a missing ``npm run dev``.
|
|
36
|
+
STATIC_AUTOSTART_PORTS = frozenset({8000, 8080, 5500, 8888, 5000, 4321})
|
|
37
|
+
|
|
27
38
|
_STRIP_RESPONSE_HEADERS = frozenset(
|
|
28
39
|
{
|
|
29
40
|
"transfer-encoding",
|
|
@@ -36,6 +47,9 @@ _STRIP_RESPONSE_HEADERS = frozenset(
|
|
|
36
47
|
}
|
|
37
48
|
)
|
|
38
49
|
|
|
50
|
+
_server_lock = threading.Lock()
|
|
51
|
+
_managed_servers: dict[int, subprocess.Popen[bytes]] = {}
|
|
52
|
+
|
|
39
53
|
|
|
40
54
|
def _port_open(host: str, port: int, *, timeout: float = 0.2) -> bool:
|
|
41
55
|
try:
|
|
@@ -109,15 +123,111 @@ def _inject_base_href(html: str, base_path: str) -> str:
|
|
|
109
123
|
return base_tag + html
|
|
110
124
|
|
|
111
125
|
|
|
126
|
+
def _proxy_base_for_path(port: int, request_path: str) -> str:
|
|
127
|
+
"""``/api/preview/proxy/{port}/…/`` directory for relative assets."""
|
|
128
|
+
path = request_path.split("?", 1)[0] or "/"
|
|
129
|
+
if path.endswith("/"):
|
|
130
|
+
dir_path = path
|
|
131
|
+
elif "/" in path:
|
|
132
|
+
dir_path = path.rsplit("/", 1)[0] + "/"
|
|
133
|
+
else:
|
|
134
|
+
dir_path = "/"
|
|
135
|
+
if not dir_path.startswith("/"):
|
|
136
|
+
dir_path = "/" + dir_path
|
|
137
|
+
# Encode each segment so spaces in folder names work in <base href>
|
|
138
|
+
parts = [quote(seg, safe="") for seg in dir_path.split("/") if seg]
|
|
139
|
+
encoded = "/" + "/".join(parts) + ("/" if parts or dir_path.endswith("/") else "")
|
|
140
|
+
if encoded == "//" or encoded == "":
|
|
141
|
+
encoded = "/"
|
|
142
|
+
elif not encoded.endswith("/"):
|
|
143
|
+
encoded += "/"
|
|
144
|
+
return f"/api/preview/proxy/{port}{encoded}"
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _cleanup_managed_servers() -> None:
|
|
148
|
+
with _server_lock:
|
|
149
|
+
for proc in _managed_servers.values():
|
|
150
|
+
try:
|
|
151
|
+
proc.terminate()
|
|
152
|
+
except Exception:
|
|
153
|
+
pass
|
|
154
|
+
_managed_servers.clear()
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
atexit.register(_cleanup_managed_servers)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def ensure_workspace_static_server(port: int, root: Path) -> tuple[bool, str | None]:
|
|
161
|
+
"""
|
|
162
|
+
Ensure something accepts TCP on ``127.0.0.1:port``.
|
|
163
|
+
|
|
164
|
+
If the port is free and eligible, start ``python -m http.server`` in ``root``.
|
|
165
|
+
Returns (ok, note).
|
|
166
|
+
"""
|
|
167
|
+
if _port_open("127.0.0.1", port, timeout=0.35):
|
|
168
|
+
return True, None
|
|
169
|
+
if port not in STATIC_AUTOSTART_PORTS:
|
|
170
|
+
return False, (
|
|
171
|
+
f"No server on :{port}. Start your app in the workspace "
|
|
172
|
+
f"(e.g. npm run dev) or use a static port like 8000."
|
|
173
|
+
)
|
|
174
|
+
root = root.expanduser().resolve()
|
|
175
|
+
if not root.is_dir():
|
|
176
|
+
return False, f"Workspace root is not a directory: {root}"
|
|
177
|
+
|
|
178
|
+
with _server_lock:
|
|
179
|
+
if _port_open("127.0.0.1", port, timeout=0.35):
|
|
180
|
+
return True, None
|
|
181
|
+
existing = _managed_servers.get(port)
|
|
182
|
+
if existing is not None and existing.poll() is None:
|
|
183
|
+
for _ in range(25):
|
|
184
|
+
if _port_open("127.0.0.1", port, timeout=0.2):
|
|
185
|
+
return True, "managed"
|
|
186
|
+
if existing.poll() is not None:
|
|
187
|
+
break
|
|
188
|
+
time.sleep(0.08)
|
|
189
|
+
try:
|
|
190
|
+
proc = subprocess.Popen(
|
|
191
|
+
[
|
|
192
|
+
sys.executable,
|
|
193
|
+
"-m",
|
|
194
|
+
"http.server",
|
|
195
|
+
str(port),
|
|
196
|
+
"--bind",
|
|
197
|
+
"127.0.0.1",
|
|
198
|
+
],
|
|
199
|
+
cwd=str(root),
|
|
200
|
+
stdout=subprocess.DEVNULL,
|
|
201
|
+
stderr=subprocess.DEVNULL,
|
|
202
|
+
start_new_session=True,
|
|
203
|
+
)
|
|
204
|
+
except OSError as exc:
|
|
205
|
+
return False, f"Could not start static preview server: {exc}"
|
|
206
|
+
_managed_servers[port] = proc
|
|
207
|
+
for _ in range(30):
|
|
208
|
+
if _port_open("127.0.0.1", port, timeout=0.2):
|
|
209
|
+
return True, "started"
|
|
210
|
+
if proc.poll() is not None:
|
|
211
|
+
_managed_servers.pop(port, None)
|
|
212
|
+
return False, "Static preview server exited immediately (port may be busy)."
|
|
213
|
+
time.sleep(0.08)
|
|
214
|
+
return False, "Timed out waiting for static preview server."
|
|
215
|
+
|
|
216
|
+
|
|
112
217
|
def handle_preview_proxy(
|
|
113
218
|
port: int,
|
|
114
219
|
sub_path: str = "/",
|
|
115
220
|
*,
|
|
116
221
|
query: str = "",
|
|
222
|
+
workspace_root: str | Path | None = None,
|
|
117
223
|
) -> tuple[int, bytes, dict[str, str]]:
|
|
118
224
|
"""
|
|
119
225
|
Reverse-proxy HTTP from 127.0.0.1:{port} (on this process / tenant pod).
|
|
120
226
|
|
|
227
|
+
If nothing is listening and ``port`` is a static preview port, auto-start
|
|
228
|
+
``python -m http.server`` in the workspace so HTML apps work without a
|
|
229
|
+
manual ``bash`` step (hosted GKE).
|
|
230
|
+
|
|
121
231
|
Returns (status, body, response_headers).
|
|
122
232
|
"""
|
|
123
233
|
if not isinstance(port, int) or port < 1 or port > 65535:
|
|
@@ -151,28 +261,68 @@ def handle_preview_proxy(
|
|
|
151
261
|
qs = (query or "").lstrip("?")
|
|
152
262
|
request_path = path if not qs else f"{path}?{qs}"
|
|
153
263
|
|
|
154
|
-
|
|
264
|
+
# Encode path for the upstream request (spaces in folder names).
|
|
265
|
+
upstream_path = "/" + "/".join(quote(seg, safe="") for seg in parts)
|
|
266
|
+
if path.endswith("/") and upstream_path != "/":
|
|
267
|
+
upstream_path += "/"
|
|
268
|
+
if not qs:
|
|
269
|
+
upstream_request = upstream_path
|
|
270
|
+
else:
|
|
271
|
+
upstream_request = f"{upstream_path}?{qs}"
|
|
272
|
+
|
|
273
|
+
root: Path | None = None
|
|
274
|
+
if workspace_root:
|
|
275
|
+
try:
|
|
276
|
+
root = Path(workspace_root).expanduser().resolve()
|
|
277
|
+
except OSError:
|
|
278
|
+
root = None
|
|
279
|
+
|
|
280
|
+
def _fetch() -> tuple[int, bytes, str, list[tuple[str, str]]]:
|
|
155
281
|
conn = HTTPConnection("127.0.0.1", port, timeout=20.0)
|
|
156
282
|
conn.request(
|
|
157
283
|
"GET",
|
|
158
|
-
|
|
284
|
+
upstream_request,
|
|
159
285
|
headers={"User-Agent": "GemCode-Web-Preview/1.0", "Accept": "*/*"},
|
|
160
286
|
)
|
|
161
287
|
resp = conn.getresponse()
|
|
162
288
|
body = resp.read()
|
|
163
289
|
ctype = (resp.getheader("Content-Type") or "").lower()
|
|
164
|
-
|
|
165
|
-
for key, value in resp.getheaders():
|
|
166
|
-
if key.lower() in _STRIP_RESPONSE_HEADERS:
|
|
167
|
-
continue
|
|
168
|
-
headers[key] = value
|
|
290
|
+
headers_list = resp.getheaders()
|
|
169
291
|
status = int(resp.status)
|
|
170
292
|
conn.close()
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
293
|
+
return status, body, ctype, headers_list
|
|
294
|
+
|
|
295
|
+
try:
|
|
296
|
+
status, body, ctype, headers_list = _fetch()
|
|
297
|
+
except OSError as first_exc:
|
|
298
|
+
note: str | None = None
|
|
299
|
+
if root is not None:
|
|
300
|
+
ok, note = ensure_workspace_static_server(port, root)
|
|
301
|
+
if ok:
|
|
302
|
+
try:
|
|
303
|
+
time.sleep(0.05)
|
|
304
|
+
status, body, ctype, headers_list = _fetch()
|
|
305
|
+
except OSError as second_exc:
|
|
306
|
+
msg = (
|
|
307
|
+
f'{{"error":"Preview upstream unreachable after starting static server: '
|
|
308
|
+
f'{type(second_exc).__name__}: {second_exc}"}}'
|
|
309
|
+
)
|
|
310
|
+
return 502, msg.encode(), {"Content-Type": "application/json; charset=utf-8"}
|
|
311
|
+
else:
|
|
312
|
+
detail = note or str(first_exc)
|
|
313
|
+
msg = f'{{"error":"Preview upstream unreachable: {detail}"}}'
|
|
314
|
+
return 502, msg.encode(), {"Content-Type": "application/json; charset=utf-8"}
|
|
315
|
+
else:
|
|
316
|
+
msg = f'{{"error":"Preview upstream unreachable: {type(first_exc).__name__}: {first_exc}"}}'
|
|
317
|
+
return 502, msg.encode(), {"Content-Type": "application/json; charset=utf-8"}
|
|
318
|
+
|
|
319
|
+
headers: dict[str, str] = {}
|
|
320
|
+
for key, value in headers_list:
|
|
321
|
+
if key.lower() in _STRIP_RESPONSE_HEADERS:
|
|
322
|
+
continue
|
|
323
|
+
headers[key] = value
|
|
174
324
|
|
|
175
|
-
proxy_base =
|
|
325
|
+
proxy_base = _proxy_base_for_path(port, path)
|
|
176
326
|
if "text/html" in ctype:
|
|
177
327
|
try:
|
|
178
328
|
text = body.decode("utf-8", errors="replace")
|
gemcode/web/server.py
CHANGED
|
@@ -318,7 +318,7 @@ def _build_handler(project_root: str) -> type[BaseHTTPRequestHandler]:
|
|
|
318
318
|
else:
|
|
319
319
|
port, sub_path = parsed_proxy
|
|
320
320
|
status, body, hdrs = handle_preview_proxy(
|
|
321
|
-
port, sub_path, query=parsed.query or ""
|
|
321
|
+
port, sub_path, query=parsed.query or "", workspace_root=root
|
|
322
322
|
)
|
|
323
323
|
except Exception as exc:
|
|
324
324
|
status = 500
|
gemcode/web/sse_adapter.py
CHANGED
|
@@ -512,10 +512,15 @@ def _inject_web_code_context(cfg: GemCodeConfig, prompt: str, req: dict[str, Any
|
|
|
512
512
|
else:
|
|
513
513
|
lines.append(
|
|
514
514
|
"**Web UI permissions — interactive.** Shell and mutating tools pause for an "
|
|
515
|
-
"**inline Yes/No card in the chat
|
|
515
|
+
"**inline Yes/No card in the chat**. "
|
|
516
516
|
"Do **not** invent Approve/Deny dialog text or tell the user to open a popup — "
|
|
517
517
|
"wait silently; after they tap Yes or No the turn continues."
|
|
518
518
|
)
|
|
519
|
+
lines.append(
|
|
520
|
+
"**Web preview:** For static HTML/CSS/JS, prefer `http://localhost:8000/...` links. "
|
|
521
|
+
"GemCode will auto-serve the workspace on common static ports (8000/8080/…) when "
|
|
522
|
+
"nothing is listening. For Vite/Next (3000/5173), start the real dev server with bash first."
|
|
523
|
+
)
|
|
519
524
|
|
|
520
525
|
if mode == "agents":
|
|
521
526
|
lines.extend(
|
|
@@ -130,23 +130,23 @@ gemcode/web/customize_api.py,sha256=Et1HY49lleZlCBywsBg9n61KFReqTB0wGQrDmhrNEf4,
|
|
|
130
130
|
gemcode/web/files_api.py,sha256=sdlSd3gj3TF_qrH2xYy70CTWMXAIzqouWe6uDkMrroo,7724
|
|
131
131
|
gemcode/web/fleet_api.py,sha256=4lR7lWqpMojaAL7_c6rjjA5hMFnnUgpI1T561RvM-3w,19276
|
|
132
132
|
gemcode/web/hitl_bridge.py,sha256=08rzUmVDiY6eAsMXZ-oE-XLPK5gk-wAGIFxDY2QlobA,5465
|
|
133
|
-
gemcode/web/preview_api.py,sha256=
|
|
133
|
+
gemcode/web/preview_api.py,sha256=mE6izq2TZfrBmiLAJRaZlyKxWYlv6b6SQfh7HoS_E18,10795
|
|
134
134
|
gemcode/web/project_root.py,sha256=rMKsp2va2z1p9CI-NKzbdJq2hyKXtNzDSLyTk-tV8Ws,1936
|
|
135
135
|
gemcode/web/runtime_api.py,sha256=BezPpbNnj85aXiL5r7aXGcA3FFubadY7lHtskaNPgMc,12935
|
|
136
136
|
gemcode/web/serve_bind.py,sha256=Q6odfJuoTw8hIn3Hq8jcLe6EvRB1_wj4klrOBjKdTiE,3097
|
|
137
137
|
gemcode/web/serve_state.py,sha256=wTHaZ4CGjZYOxRMlKmFUEMpFoBPnci20GLbMjxGdGG0,6265
|
|
138
|
-
gemcode/web/server.py,sha256=
|
|
138
|
+
gemcode/web/server.py,sha256=firEgHj9jbouOc-NYeC4QdXQE6pwH15RhBrAynS4DC8,27719
|
|
139
139
|
gemcode/web/sessions_api.py,sha256=4qudnYLiqIbUixon1PsV8yfBf6tu6llWLc_ESTFK61g,2008
|
|
140
|
-
gemcode/web/sse_adapter.py,sha256=
|
|
140
|
+
gemcode/web/sse_adapter.py,sha256=zZA2-EXz3dmmFe98F4IWQYDYgqz378yrSYiEilKo_dE,44283
|
|
141
141
|
gemcode/web/terminal_api.py,sha256=2FWU0XQ6CVUFW_JJiVukFPdj_sgsYa_y7IQb9brxCZw,3329
|
|
142
142
|
gemcode/web/terminal_repl.py,sha256=fQt895g0qcr6VBhXfv_5b_bsC5zHT5-MO0ysBdgi2Fg,3886
|
|
143
143
|
gemcode/web/ui_chat_store.py,sha256=sThUOcWC-svx-e2pt88ZazPrWelN2JbqeDicU7s5iUg,2377
|
|
144
144
|
gemcode/web/web_config_api.py,sha256=1kmk1vSu5z63loddTNBHVsisiYy4CfyVIF93TYCgEqs,2264
|
|
145
145
|
gemcode/web/web_sse_compat.py,sha256=9A2s-GI7El7AotJqhO263FrLwppCXXkdydZ5EiOQbao,504
|
|
146
146
|
gemcode/web/workspace_panel_api.py,sha256=uZS11ovlZtc1IJDnOA776Z6IndC0WovcBbmPMnZyIHs,14122
|
|
147
|
-
gemcode-0.4.
|
|
148
|
-
gemcode-0.4.
|
|
149
|
-
gemcode-0.4.
|
|
150
|
-
gemcode-0.4.
|
|
151
|
-
gemcode-0.4.
|
|
152
|
-
gemcode-0.4.
|
|
147
|
+
gemcode-0.4.27.dist-info/licenses/LICENSE,sha256=TD4524qn-W8Z07GTDnag-9jJPFutFZNB0a1WbMHPC54,8388
|
|
148
|
+
gemcode-0.4.27.dist-info/METADATA,sha256=eWom0RlkM1PHdaeudIplBgMh1v02guvCd4Hmz2xk81A,27378
|
|
149
|
+
gemcode-0.4.27.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
150
|
+
gemcode-0.4.27.dist-info/entry_points.txt,sha256=cZdLTLDiHbks7OSUCuxCh66dCWeQdpLR8BozoqfEjV4,45
|
|
151
|
+
gemcode-0.4.27.dist-info/top_level.txt,sha256=UYrjULLBY2bcgK6KI6flomJWmsbDXu7n0rvW2SWFrbo,8
|
|
152
|
+
gemcode-0.4.27.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|