Spaces:
Paused
Paused
OpenCode Integration commited on
Commit ·
ceeb8d2
1
Parent(s): b1069c6
revert: remove custom frontend SPA, restore opencode's native web UI
Browse files- DELIVERABLES.md +0 -41
- Dockerfile +0 -1
- backend/app/main.py +93 -91
- frontend/chat.js +0 -20
- frontend/components.js +0 -587
- frontend/http.js +0 -185
- frontend/index.html +0 -30
- frontend/store.js +0 -158
- frontend/ui.js +0 -285
DELIVERABLES.md
DELETED
|
@@ -1,41 +0,0 @@
|
|
| 1 |
-
# OpenCode-Serve — Final Working State
|
| 2 |
-
|
| 3 |
-
## Architecture
|
| 4 |
-
```
|
| 5 |
-
HF Load Balancer (HTTP/2)
|
| 6 |
-
↓
|
| 7 |
-
uvicorn gateway :7860 (HF-exposed)
|
| 8 |
-
├─ /terminal/* → ttyd :7681 (embedded bash)
|
| 9 |
-
├─ / → redirect HTML (auto-load latest session)
|
| 10 |
-
├─ /server/<base> (bare) → redirect HTML
|
| 11 |
-
└─ /* → opencode serve :4096 (chat API + SPA)
|
| 12 |
-
```
|
| 13 |
-
|
| 14 |
-
## Key Files
|
| 15 |
-
- `Dockerfile` — debian:bookworm-slim + python3 + opencode + ttyd + uvicorn/httpx
|
| 16 |
-
- `entrypoint.sh` — starts ttyd, opencode serve, uvicorn gateway
|
| 17 |
-
- `backend/app/main.py` — FastAPI gateway with proxy + redirect logic
|
| 18 |
-
- `cleaner.py` — SQLite self-healing daemon (from opencode-cli)
|
| 19 |
-
|
| 20 |
-
## Routes Verified
|
| 21 |
-
| Route | Behavior | Status |
|
| 22 |
-
|-------|----------|--------|
|
| 23 |
-
| `GET /` | Redirect HTML → auto-load latest session | ✅ |
|
| 24 |
-
| `GET /server/<base>` | Redirect HTML → auto-load latest session | ✅ |
|
| 25 |
-
| `GET /server/<base>/session/<id>` | Chat UI (opencode SPA) | ✅ |
|
| 26 |
-
| `GET /api/session` | Proxy → opencode (50 sessions) | ✅ |
|
| 27 |
-
| `GET /global/health` | Proxy → opencode | ✅ |
|
| 28 |
-
| `GET /terminal/` | Proxy → ttyd (embedded bash) | ✅ |
|
| 29 |
-
| `GET /_healthz` | Gateway health check | ✅ |
|
| 30 |
-
|
| 31 |
-
## URLs
|
| 32 |
-
- **Space**: https://jishnupg-opencode-serve.hf.space
|
| 33 |
-
- **Chat**: `https://jishnupg-opencode-serve.hf.space/server/<base64(SPACE_URL)>/session/<session_id>`
|
| 34 |
-
- **Terminal**: https://jishnupg-opencode-serve.hf.space/terminal/
|
| 35 |
-
- **API**: https://jishnupg-opencode-serve.hf.space/api/session
|
| 36 |
-
|
| 37 |
-
## Key Learnings
|
| 38 |
-
1. **Previous proxy failures (HTTP/2 INTERNAL_ERROR)**: Caused by using `StreamingResponse` for ALL responses. Fixed by buffering normal responses and only streaming SSE/chunked.
|
| 39 |
-
2. **ttyd `--base-path`**: Doesn't work as expected. Fixed by stripping `/terminal` prefix in proxy.
|
| 40 |
-
3. **`PYTHONPATH=/app`**: Required because entrypoint `cd`s to `/projects/default`.
|
| 41 |
-
4. **HF only exposes one port (7860)**: Must proxy everything through a single gateway.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Dockerfile
CHANGED
|
@@ -33,7 +33,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
| 33 |
RUN mkdir -p /projects/default
|
| 34 |
|
| 35 |
COPY backend/ /app/backend/
|
| 36 |
-
COPY frontend/ /app/frontend/
|
| 37 |
COPY cleaner.py /cleaner.py
|
| 38 |
COPY entrypoint.sh /entrypoint.sh
|
| 39 |
RUN chmod +x /entrypoint.sh
|
|
|
|
| 33 |
RUN mkdir -p /projects/default
|
| 34 |
|
| 35 |
COPY backend/ /app/backend/
|
|
|
|
| 36 |
COPY cleaner.py /cleaner.py
|
| 37 |
COPY entrypoint.sh /entrypoint.sh
|
| 38 |
RUN chmod +x /entrypoint.sh
|
backend/app/main.py
CHANGED
|
@@ -1,59 +1,80 @@
|
|
| 1 |
-
"""
|
| 2 |
|
| 3 |
Routes:
|
| 4 |
-
/
|
| 5 |
-
/
|
| 6 |
-
/
|
| 7 |
-
/
|
| 8 |
-
/* -> opencode serve on :4096 (passthrough)
|
| 9 |
"""
|
| 10 |
from __future__ import annotations
|
| 11 |
|
| 12 |
import asyncio
|
| 13 |
-
import mimetypes
|
| 14 |
import os
|
| 15 |
-
from pathlib import Path
|
| 16 |
from urllib.parse import urlencode
|
| 17 |
|
| 18 |
import httpx
|
| 19 |
from fastapi import FastAPI, Request, WebSocket
|
| 20 |
-
from fastapi.responses import HTMLResponse, JSONResponse,
|
| 21 |
|
| 22 |
-
OC_HOST
|
| 23 |
-
OC_PORT
|
| 24 |
TTYD_PORT = int(os.environ.get("TTYD_PORT", "7681"))
|
| 25 |
|
| 26 |
-
FRONTEND_DIR = Path("/app/frontend")
|
| 27 |
-
|
| 28 |
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
|
| 29 |
|
| 30 |
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
|
| 40 |
|
| 41 |
def _is_terminal(path: str) -> bool:
|
| 42 |
return path == "/terminal" or path.startswith("/terminal/")
|
| 43 |
|
| 44 |
|
| 45 |
-
def _is_frontend_asset(path: str) -> bool:
|
| 46 |
-
if path.startswith("assets/"):
|
| 47 |
-
return True
|
| 48 |
-
if path in ("favicon-96x96-v3.png", "favicon-v3.svg", "favicon-v3.ico",
|
| 49 |
-
"apple-touch-icon-v3.png", "site.webmanifest", "social-share.png"):
|
| 50 |
-
return True
|
| 51 |
-
return False
|
| 52 |
-
|
| 53 |
-
|
| 54 |
def _needs_redirect(path: str) -> bool:
|
| 55 |
if not path or path in ("/", ""):
|
| 56 |
return True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
return False
|
| 58 |
|
| 59 |
|
|
@@ -70,42 +91,43 @@ async def _forward(request: Request, port: int, path: str):
|
|
| 70 |
headers[k] = v
|
| 71 |
|
| 72 |
body = await request.body()
|
|
|
|
| 73 |
|
| 74 |
-
|
| 75 |
-
try:
|
| 76 |
-
up = await client.send(
|
| 77 |
-
client.build_request(method=request.method, url=url, headers=headers, content=body if body else None),
|
| 78 |
-
stream=True,
|
| 79 |
-
)
|
| 80 |
-
except (httpx.ConnectError, httpx.ConnectTimeout, httpx.HTTPError) as exc:
|
| 81 |
-
await client.aclose()
|
| 82 |
-
return Response(status_code=502, content=f"upstream {port} unreachable: {exc}".encode())
|
| 83 |
-
|
| 84 |
-
resp_headers = {}
|
| 85 |
-
for k, v in up.headers.items():
|
| 86 |
-
kl = k.lower()
|
| 87 |
-
if kl in ("content-length", "content-encoding", "transfer-encoding", "connection"):
|
| 88 |
-
continue
|
| 89 |
-
resp_headers[k] = v
|
| 90 |
-
|
| 91 |
-
ct = up.headers.get("content-type", "application/octet-stream")
|
| 92 |
-
|
| 93 |
-
async def relay():
|
| 94 |
try:
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 109 |
|
| 110 |
|
| 111 |
@app.get("/_healthz", include_in_schema=False)
|
|
@@ -115,34 +137,19 @@ async def healthz():
|
|
| 115 |
|
| 116 |
@app.api_route(
|
| 117 |
"/{path:path}",
|
| 118 |
-
methods=["GET",
|
| 119 |
include_in_schema=False,
|
| 120 |
)
|
| 121 |
async def main_route(request: Request, path: str):
|
| 122 |
full = "/" + path
|
| 123 |
-
|
| 124 |
-
# Terminal proxy
|
| 125 |
if _is_terminal(full):
|
| 126 |
-
|
|
|
|
| 127 |
if not fwd_path or fwd_path[0] != "/":
|
| 128 |
fwd_path = "/" + fwd_path
|
| 129 |
return await _forward(request, TTYD_PORT, fwd_path)
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
if _is_frontend_asset(path):
|
| 133 |
-
name = path.split("/", 1)[-1] if "/" in path else path
|
| 134 |
-
data = _read_frontend(name)
|
| 135 |
-
if data is not None:
|
| 136 |
-
ct, _ = mimetypes.guess_type(name)
|
| 137 |
-
return Response(content=data, media_type=ct or "application/octet-stream")
|
| 138 |
-
|
| 139 |
-
# Frontend SPA: serve index.html for / or /server/ paths
|
| 140 |
-
if _needs_redirect(full) or (path.startswith("server") and not any(
|
| 141 |
-
seg in path.split("/") for seg in ("session", "message", "event", "prompt")
|
| 142 |
-
)):
|
| 143 |
-
return Response(content=_INDEX_HTML, media_type="text/html")
|
| 144 |
-
|
| 145 |
-
# Everything else -> opencode upstream
|
| 146 |
return await _forward(request, OC_PORT, path)
|
| 147 |
|
| 148 |
|
|
@@ -150,16 +157,13 @@ async def main_route(request: Request, path: str):
|
|
| 150 |
async def ws_route(websocket: WebSocket, path: str):
|
| 151 |
full = "/" + path
|
| 152 |
target_port = TTYD_PORT if _is_terminal(full) else OC_PORT
|
| 153 |
-
target_url
|
| 154 |
if websocket.query_params:
|
| 155 |
target_url += "?" + urlencode(websocket.query_params)
|
| 156 |
-
|
| 157 |
import websockets as ws_lib
|
| 158 |
-
|
| 159 |
await websocket.accept()
|
| 160 |
try:
|
| 161 |
-
async with ws_lib.connect(target_url, max_size=32
|
| 162 |
-
|
| 163 |
async def c2s():
|
| 164 |
try:
|
| 165 |
while True:
|
|
@@ -172,7 +176,6 @@ async def ws_route(websocket: WebSocket, path: str):
|
|
| 172 |
await upstream.send(msg["bytes"])
|
| 173 |
except Exception:
|
| 174 |
return
|
| 175 |
-
|
| 176 |
async def s2c():
|
| 177 |
try:
|
| 178 |
async for raw in upstream:
|
|
@@ -182,7 +185,6 @@ async def ws_route(websocket: WebSocket, path: str):
|
|
| 182 |
await websocket.send_bytes(raw)
|
| 183 |
except Exception:
|
| 184 |
return
|
| 185 |
-
|
| 186 |
await asyncio.gather(c2s(), s2c())
|
| 187 |
except Exception:
|
| 188 |
try:
|
|
|
|
| 1 |
+
"""Terminal-on-top-of-opencode gateway.
|
| 2 |
|
| 3 |
Routes:
|
| 4 |
+
/terminal/* -> ttyd on :7681 (internal, terminal)
|
| 5 |
+
/ -> redirect HTML (auto-load latest session)
|
| 6 |
+
/server/<base> (bare, no session id) -> redirect HTML
|
| 7 |
+
/* -> opencode serve on :4096 (passthrough)
|
|
|
|
| 8 |
"""
|
| 9 |
from __future__ import annotations
|
| 10 |
|
| 11 |
import asyncio
|
|
|
|
| 12 |
import os
|
|
|
|
| 13 |
from urllib.parse import urlencode
|
| 14 |
|
| 15 |
import httpx
|
| 16 |
from fastapi import FastAPI, Request, WebSocket
|
| 17 |
+
from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse, Response
|
| 18 |
|
| 19 |
+
OC_HOST = "127.0.0.1"
|
| 20 |
+
OC_PORT = int(os.environ.get("OPENCODE_PORT", "4096"))
|
| 21 |
TTYD_PORT = int(os.environ.get("TTYD_PORT", "7681"))
|
| 22 |
|
|
|
|
|
|
|
| 23 |
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
|
| 24 |
|
| 25 |
|
| 26 |
+
_REDIRECT_HTML = r"""<!DOCTYPE html>
|
| 27 |
+
<html lang="en">
|
| 28 |
+
<head>
|
| 29 |
+
<meta charset="UTF-8">
|
| 30 |
+
<title>OpenCode</title>
|
| 31 |
+
<style>
|
| 32 |
+
*{margin:0;padding:0;box-sizing:border-box}
|
| 33 |
+
body{background:#0d1117;color:#c9d1d9;font-family:'JetBrains Mono',Menlo,monospace;
|
| 34 |
+
display:flex;align-items:center;justify-content:center;height:100vh}
|
| 35 |
+
.box{background:#161b22;padding:28px 36px;border:1px solid #30363d;border-radius:8px;
|
| 36 |
+
max-width:560px;text-align:center}
|
| 37 |
+
h2{color:#58a6ff;font-size:13px;letter-spacing:.06em;text-transform:uppercase;margin:0 0 14px}
|
| 38 |
+
pre{color:#c9d1d9;font-size:12px;line-height:1.6;text-align:left;
|
| 39 |
+
white-space:pre-wrap;word-break:break-all;background:transparent;margin:0}
|
| 40 |
+
a{color:#22c55e;text-decoration:none}a:hover{text-decoration:underline}
|
| 41 |
+
</style>
|
| 42 |
+
</head>
|
| 43 |
+
<body>
|
| 44 |
+
<div class="box">
|
| 45 |
+
<h2>OpenCode</h2>
|
| 46 |
+
<pre id="s">loading…</pre>
|
| 47 |
+
</div>
|
| 48 |
+
<script>
|
| 49 |
+
(async function(){
|
| 50 |
+
var s=document.getElementById("s"),h=location.protocol+"//"+location.host;
|
| 51 |
+
try{
|
| 52 |
+
var r=await fetch("/api/session");
|
| 53 |
+
if(!r.ok)throw new Error(r.status+" "+r.statusText);
|
| 54 |
+
var d=await r.json(),arr=(d&&d.data)||[];
|
| 55 |
+
if(!arr.length){s.innerHTML='no sessions yet — <a href="/server/">open dashboard</a>';return}
|
| 56 |
+
var sid=arr[0].id,dest="/server/"+btoa(h)+"/session/"+encodeURIComponent(sid);
|
| 57 |
+
s.innerHTML='<a href="'+dest+'">opening session…</a>';
|
| 58 |
+
location.replace(dest);
|
| 59 |
+
}catch(e){s.innerHTML="error: "+e.message+"<br>try <a href='/server/'>/server/</a>"}
|
| 60 |
+
})();
|
| 61 |
+
</script>
|
| 62 |
+
</body>
|
| 63 |
+
</html>"""
|
| 64 |
|
| 65 |
|
| 66 |
def _is_terminal(path: str) -> bool:
|
| 67 |
return path == "/terminal" or path.startswith("/terminal/")
|
| 68 |
|
| 69 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
def _needs_redirect(path: str) -> bool:
|
| 71 |
if not path or path in ("/", ""):
|
| 72 |
return True
|
| 73 |
+
if path.startswith("/server"):
|
| 74 |
+
parts = [p for p in path.split("/") if p]
|
| 75 |
+
# /server, /server/, /server/<base> -- no session id present
|
| 76 |
+
if len(parts) < 4:
|
| 77 |
+
return True
|
| 78 |
return False
|
| 79 |
|
| 80 |
|
|
|
|
| 91 |
headers[k] = v
|
| 92 |
|
| 93 |
body = await request.body()
|
| 94 |
+
timeout = httpx.Timeout(600.0, connect=10.0)
|
| 95 |
|
| 96 |
+
async with httpx.AsyncClient(timeout=timeout, follow_redirects=False) as client:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
try:
|
| 98 |
+
up = await client.request(
|
| 99 |
+
method=request.method,
|
| 100 |
+
url=url,
|
| 101 |
+
headers=headers,
|
| 102 |
+
content=body if body else None,
|
| 103 |
+
)
|
| 104 |
+
except (httpx.ConnectError, httpx.ConnectTimeout, httpx.HTTPError) as exc:
|
| 105 |
+
return Response(status_code=502, content=f"upstream {port} unreachable: {exc}".encode())
|
| 106 |
+
|
| 107 |
+
# Build response headers
|
| 108 |
+
resp_headers = {}
|
| 109 |
+
for k, v in up.headers.items():
|
| 110 |
+
kl = k.lower()
|
| 111 |
+
if kl in ("content-length", "content-encoding", "transfer-encoding", "connection"):
|
| 112 |
+
continue
|
| 113 |
+
resp_headers[k] = v
|
| 114 |
+
|
| 115 |
+
ct = up.headers.get("content-type", "application/octet-stream")
|
| 116 |
+
|
| 117 |
+
# For SSE / streaming: relay raw bytes
|
| 118 |
+
if "text/event-stream" in ct or "chunked" in up.headers.get("transfer-encoding", ""):
|
| 119 |
+
async def relay():
|
| 120 |
+
try:
|
| 121 |
+
async for chunk in up.aiter_bytes():
|
| 122 |
+
yield chunk
|
| 123 |
+
finally:
|
| 124 |
+
await up.aclose()
|
| 125 |
+
return StreamingResponse(relay(), status_code=up.status_code, headers=resp_headers, media_type=ct)
|
| 126 |
+
|
| 127 |
+
# Buffer entire response (most responses are small JSON or HTML)
|
| 128 |
+
content = up.content
|
| 129 |
+
await up.aclose()
|
| 130 |
+
return Response(status_code=up.status_code, content=content, headers=resp_headers, media_type=ct)
|
| 131 |
|
| 132 |
|
| 133 |
@app.get("/_healthz", include_in_schema=False)
|
|
|
|
| 137 |
|
| 138 |
@app.api_route(
|
| 139 |
"/{path:path}",
|
| 140 |
+
methods=["GET","POST","PUT","DELETE","PATCH","HEAD","OPTIONS"],
|
| 141 |
include_in_schema=False,
|
| 142 |
)
|
| 143 |
async def main_route(request: Request, path: str):
|
| 144 |
full = "/" + path
|
|
|
|
|
|
|
| 145 |
if _is_terminal(full):
|
| 146 |
+
# Strip /terminal prefix when forwarding to ttyd
|
| 147 |
+
fwd_path = path[len("terminal"):] # "terminal/foo" -> "/foo"
|
| 148 |
if not fwd_path or fwd_path[0] != "/":
|
| 149 |
fwd_path = "/" + fwd_path
|
| 150 |
return await _forward(request, TTYD_PORT, fwd_path)
|
| 151 |
+
if _needs_redirect(full):
|
| 152 |
+
return HTMLResponse(content=_REDIRECT_HTML, status_code=200)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 153 |
return await _forward(request, OC_PORT, path)
|
| 154 |
|
| 155 |
|
|
|
|
| 157 |
async def ws_route(websocket: WebSocket, path: str):
|
| 158 |
full = "/" + path
|
| 159 |
target_port = TTYD_PORT if _is_terminal(full) else OC_PORT
|
| 160 |
+
target_url = f"ws://{OC_HOST}:{target_port}/{path}"
|
| 161 |
if websocket.query_params:
|
| 162 |
target_url += "?" + urlencode(websocket.query_params)
|
|
|
|
| 163 |
import websockets as ws_lib
|
|
|
|
| 164 |
await websocket.accept()
|
| 165 |
try:
|
| 166 |
+
async with ws_lib.connect(target_url, max_size=32*1024*1024) as upstream:
|
|
|
|
| 167 |
async def c2s():
|
| 168 |
try:
|
| 169 |
while True:
|
|
|
|
| 176 |
await upstream.send(msg["bytes"])
|
| 177 |
except Exception:
|
| 178 |
return
|
|
|
|
| 179 |
async def s2c():
|
| 180 |
try:
|
| 181 |
async for raw in upstream:
|
|
|
|
| 185 |
await websocket.send_bytes(raw)
|
| 186 |
except Exception:
|
| 187 |
return
|
|
|
|
| 188 |
await asyncio.gather(c2s(), s2c())
|
| 189 |
except Exception:
|
| 190 |
try:
|
frontend/chat.js
DELETED
|
@@ -1,20 +0,0 @@
|
|
| 1 |
-
// OpenCode-Serve SPA
|
| 2 |
-
// Provides a ChatGPT-style sidebar with full session management backed by
|
| 3 |
-
// opencode's existing /api/session and /api/session/{id}/message APIs.
|
| 4 |
-
//
|
| 5 |
-
// Architecture:
|
| 6 |
-
// - Vanilla ES modules, no build step
|
| 7 |
-
// - Single-page app that consumes opencode's HTTP API verbatim
|
| 8 |
-
// - localStorage holds UI state only: pinnedIds, archivedIds, drawerCollapsed,
|
| 9 |
-
// lastSessionId, draftMessage. Authoritative session data always comes from
|
| 10 |
-
// /api/session so that nothing is duplicated.
|
| 11 |
-
// - SSE on /api/event streams real-time updates for sessions and messages.
|
| 12 |
-
// - Title auto-generation is sent through opencode's existing session APIs.
|
| 13 |
-
//
|
| 14 |
-
// All UI state is intentionally local; opencode remains the source of truth
|
| 15 |
-
// for session lifecycle.
|
| 16 |
-
|
| 17 |
-
import { mount } from "./components.js";
|
| 18 |
-
import { openOverlay } from "./ui.js";
|
| 19 |
-
|
| 20 |
-
mount();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
frontend/components.js
DELETED
|
@@ -1,587 +0,0 @@
|
|
| 1 |
-
/* components.js — sidebar + chat shell. Everything lives here. */
|
| 2 |
-
|
| 3 |
-
import {
|
| 4 |
-
listSessions, getSession, createSession, deleteSessions, renameSession,
|
| 5 |
-
getMessages, sendMessage, sessionStatus, abortSession,
|
| 6 |
-
listProviders, listAgents, openEventStream, partText,
|
| 7 |
-
} from "./http.js";
|
| 8 |
-
import {
|
| 9 |
-
h, clear, debounce, formatTimeAgo, groupByDate, setOverlay, openOverlay,
|
| 10 |
-
promptInput, confirmAction, snackbar,
|
| 11 |
-
} from "./ui.js";
|
| 12 |
-
import {
|
| 13 |
-
getPinnedIds, getArchivedIds, getLastSessionId, setLastSessionId,
|
| 14 |
-
getDrawerCollapsed, setDrawerCollapsed,
|
| 15 |
-
getSelectedModel, setSelectedModel, getSelectedAgent, setSelectedAgent,
|
| 16 |
-
getDraft, setDraft, togglePin, toggleArchive,
|
| 17 |
-
refreshSessions, getFilteredSessions, subscribe,
|
| 18 |
-
upsertSessionFromEvent, updateSessionFromEvent,
|
| 19 |
-
} from "./store.js";
|
| 20 |
-
|
| 21 |
-
// ─── state ───
|
| 22 |
-
let _currentSessionId = null;
|
| 23 |
-
let _abortCtrl = null;
|
| 24 |
-
let _sidebarOpen = window.innerWidth > 860;
|
| 25 |
-
let _search = "";
|
| 26 |
-
let _showArchived = false;
|
| 27 |
-
let _sending = false;
|
| 28 |
-
let _agents = [];
|
| 29 |
-
let _providers = [];
|
| 30 |
-
let _currentAgent = null;
|
| 31 |
-
let _currentModel = null;
|
| 32 |
-
let _sse = null;
|
| 33 |
-
|
| 34 |
-
// ─── root DOM references (created in mount) ───
|
| 35 |
-
let _root;
|
| 36 |
-
let _sidebar;
|
| 37 |
-
let _chat;
|
| 38 |
-
let _msgContainer;
|
| 39 |
-
let _inputBox;
|
| 40 |
-
let _titleEl;
|
| 41 |
-
|
| 42 |
-
// ─── sidebar ───
|
| 43 |
-
function renderSidebar() {
|
| 44 |
-
clear(_sidebar);
|
| 45 |
-
const collapsed = getDrawerCollapsed();
|
| 46 |
-
_sidebar.style.width = collapsed ? "0px" : "";
|
| 47 |
-
_sidebar.style.minWidth = collapsed ? "0px" : "";
|
| 48 |
-
_sidebar.style.overflow = collapsed ? "hidden" : "";
|
| 49 |
-
_sidebar.style.padding = collapsed ? "0" : "";
|
| 50 |
-
|
| 51 |
-
if (collapsed) {
|
| 52 |
-
const btn = h("button", { title: "Open sidebar", style: { padding: "10px", fontSize: "18px", color: "var(--fg-2)" }, text: "\u2630" });
|
| 53 |
-
btn.addEventListener("click", () => { setDrawerCollapsed(false); renderSidebar(); });
|
| 54 |
-
_sidebar.appendChild(btn);
|
| 55 |
-
return;
|
| 56 |
-
}
|
| 57 |
-
|
| 58 |
-
// Header
|
| 59 |
-
const header = h("div", { style: { display: "flex", justifyContent: "space-between", alignItems: "center", padding: "8px 12px", borderBottom: "1px solid var(--border)" } });
|
| 60 |
-
const collapseBtn = h("button", { title: "Collapse sidebar", style: { padding: "6px 10px", color: "var(--fg-3)", fontSize: "14px" }, text: "\u25c0" });
|
| 61 |
-
collapseBtn.addEventListener("click", () => { setDrawerCollapsed(true); renderSidebar(); });
|
| 62 |
-
const newBtn = h("button", { style: { padding: "6px 14px", background: "var(--accent)", color: "#0a0a0a", borderRadius: "6px", fontWeight: "600", fontSize: "13px" }, text: "New Chat" });
|
| 63 |
-
newBtn.addEventListener("click", newChat);
|
| 64 |
-
header.append(collapseBtn, h("div", { style: { flex: "1" } }), newBtn);
|
| 65 |
-
_sidebar.appendChild(header);
|
| 66 |
-
|
| 67 |
-
// Search
|
| 68 |
-
const searchWrap = h("div", { style: { padding: "10px 12px 6px" } });
|
| 69 |
-
const searchInput = h("input", {
|
| 70 |
-
placeholder: "Search chats...",
|
| 71 |
-
value: _search,
|
| 72 |
-
style: { width: "100%", padding: "8px 10px", background: "var(--bg-2)", border: "1px solid var(--border)", borderRadius: "6px", fontSize: "13px" },
|
| 73 |
-
});
|
| 74 |
-
searchInput.addEventListener("input", (e) => { _search = e.target.value; renderSessionList(); });
|
| 75 |
-
searchWrap.appendChild(searchInput);
|
| 76 |
-
_sidebar.appendChild(searchWrap);
|
| 77 |
-
|
| 78 |
-
// Toggle archived
|
| 79 |
-
const toggleRow = h("div", { style: { padding: "4px 12px", display: "flex", gap: "8px" } });
|
| 80 |
-
const archiveBtn = h("button", {
|
| 81 |
-
style: { padding: "4px 10px", fontSize: "12px", borderRadius: "4px", color: _showArchived ? "var(--accent)" : "var(--fg-3)", border: _showArchived ? "1px solid var(--accent)" : "1px solid var(--border)" },
|
| 82 |
-
text: "Archived",
|
| 83 |
-
});
|
| 84 |
-
archiveBtn.addEventListener("click", () => { _showArchived = !_showArchived; renderSidebar(); });
|
| 85 |
-
toggleRow.appendChild(archiveBtn);
|
| 86 |
-
_sidebar.appendChild(toggleRow);
|
| 87 |
-
|
| 88 |
-
// Session list
|
| 89 |
-
const listWrap = h("div", { style: { flex: "1", overflow: "auto", padding: "0 6px" } });
|
| 90 |
-
_sidebar.appendChild(listWrap);
|
| 91 |
-
|
| 92 |
-
// Re-render just the list part
|
| 93 |
-
_renderSessionListInto(listWrap);
|
| 94 |
-
}
|
| 95 |
-
|
| 96 |
-
function renderSessionList() {
|
| 97 |
-
// Find the list container and re-render
|
| 98 |
-
if (!_sidebar) return;
|
| 99 |
-
const listWrap = _sidebar.querySelector("div:last-child");
|
| 100 |
-
if (!listWrap) return;
|
| 101 |
-
_renderSessionListInto(listWrap);
|
| 102 |
-
}
|
| 103 |
-
|
| 104 |
-
function _renderSessionListInto(container) {
|
| 105 |
-
clear(container);
|
| 106 |
-
const sessions = getFilteredSessions({ search: _search, showArchived: _showArchived });
|
| 107 |
-
if (!sessions.length) {
|
| 108 |
-
container.appendChild(h("div", { style: { padding: "24px 12px", textAlign: "center", color: "var(--fg-3)", fontSize: "13px" }, text: _search ? "No matches" : "No chats yet" }));
|
| 109 |
-
return;
|
| 110 |
-
}
|
| 111 |
-
|
| 112 |
-
const groups = groupByDate(sessions);
|
| 113 |
-
for (const [label, items] of Object.entries(groups)) {
|
| 114 |
-
if (!items.length) continue;
|
| 115 |
-
const section = h("div", { style: { marginBottom: "8px" } });
|
| 116 |
-
const heading = h("div", { style: { padding: "8px 12px 4px", fontSize: "11px", fontWeight: "600", color: "var(--fg-3)", textTransform: "uppercase", letterSpacing: "0.05em" }, text: label });
|
| 117 |
-
section.appendChild(heading);
|
| 118 |
-
for (const s of items) {
|
| 119 |
-
section.appendChild(renderSessionItem(s));
|
| 120 |
-
}
|
| 121 |
-
container.appendChild(section);
|
| 122 |
-
}
|
| 123 |
-
}
|
| 124 |
-
|
| 125 |
-
function renderSessionItem(s) {
|
| 126 |
-
const pinned = getPinnedIds().has(s.id);
|
| 127 |
-
const active = s.id === _currentSessionId;
|
| 128 |
-
const archived = getArchivedIds().has(s.id);
|
| 129 |
-
const preview = s.__preview || s.title || "New chat";
|
| 130 |
-
const modelLabel = (s.model && s.model.id) ? s.model.id.split("/").pop() : "";
|
| 131 |
-
|
| 132 |
-
const row = h("div", {
|
| 133 |
-
style: {
|
| 134 |
-
padding: "10px 12px",
|
| 135 |
-
borderRadius: "6px",
|
| 136 |
-
cursor: "pointer",
|
| 137 |
-
background: active ? "var(--bg-3)" : "transparent",
|
| 138 |
-
marginBottom: "2px",
|
| 139 |
-
position: "relative",
|
| 140 |
-
},
|
| 141 |
-
"data-sid": s.id,
|
| 142 |
-
});
|
| 143 |
-
row.addEventListener("click", () => openSession(s.id));
|
| 144 |
-
|
| 145 |
-
// Hover effects
|
| 146 |
-
row.addEventListener("mouseenter", () => { if (!active) row.style.background = "var(--bg-2)"; });
|
| 147 |
-
row.addEventListener("mouseleave", () => { if (!active) row.style.background = "transparent"; });
|
| 148 |
-
|
| 149 |
-
const titleRow = h("div", { style: { display: "flex", alignItems: "center", gap: "6px" } });
|
| 150 |
-
if (pinned) titleRow.appendChild(h("span", { style: { fontSize: "11px", color: "var(--accent)" }, text: "\u{1F4CC}" }));
|
| 151 |
-
titleRow.appendChild(h("span", {
|
| 152 |
-
style: { flex: "1", fontSize: "13px", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", fontWeight: active ? "600" : "400" },
|
| 153 |
-
text: preview,
|
| 154 |
-
}));
|
| 155 |
-
|
| 156 |
-
// Actions menu button (on hover)
|
| 157 |
-
const actionsWrap = h("div", { style: { display: "flex", gap: "2px", opacity: "0", transition: "opacity .12s" } });
|
| 158 |
-
row.addEventListener("mouseenter", () => { actionsWrap.style.opacity = "1"; });
|
| 159 |
-
row.addEventListener("mouseleave", () => { actionsWrap.style.opacity = "0"; });
|
| 160 |
-
|
| 161 |
-
const pinBtn = h("button", { title: pinned ? "Unpin" : "Pin", style: { padding: "2px 5px", fontSize: "12px", color: "var(--fg-3)" }, text: pinned ? "\u{1F4CC}" : "\u{1F4CE}" });
|
| 162 |
-
pinBtn.addEventListener("click", (e) => { e.stopPropagation(); togglePin(s.id); renderSessionList(); });
|
| 163 |
-
const renameBtn = h("button", { title: "Rename", style: { padding: "2px 5px", fontSize: "12px", color: "var(--fg-3)" }, text: "\u270F\uFE0F" });
|
| 164 |
-
renameBtn.addEventListener("click", (e) => { e.stopPropagation(); renameSessionAction(s); });
|
| 165 |
-
const archiveBtn2 = h("button", { title: archived ? "Restore" : "Archive", style: { padding: "2px 5px", fontSize: "12px", color: "var(--fg-3)" }, text: archived ? "\u21A9\uFE0F" : "\u{1F4E6}" });
|
| 166 |
-
archiveBtn2.addEventListener("click", (e) => { e.stopPropagation(); toggleArchive(s.id); renderSessionList(); });
|
| 167 |
-
const deleteBtn = h("button", { title: "Delete", style: { padding: "2px 5px", fontSize: "12px", color: "var(--danger)" }, text: "\u{1F5D1}\uFE0F" });
|
| 168 |
-
deleteBtn.addEventListener("click", (e) => { e.stopPropagation(); deleteSessionAction(s); });
|
| 169 |
-
actionsWrap.append(pinBtn, renameBtn, archiveBtn2, deleteBtn);
|
| 170 |
-
|
| 171 |
-
titleRow.appendChild(actionsWrap);
|
| 172 |
-
row.appendChild(titleRow);
|
| 173 |
-
|
| 174 |
-
// Meta line
|
| 175 |
-
const meta = h("div", { style: { display: "flex", alignItems: "center", gap: "6px", marginTop: "3px", fontSize: "11px", color: "var(--fg-3)" } });
|
| 176 |
-
meta.appendChild(h("span", { text: formatTimeAgo(s.time && s.time.updated) }));
|
| 177 |
-
if (modelLabel) meta.appendChild(h("span", { text: modelLabel, style: { padding: "1px 5px", background: "var(--bg-3)", borderRadius: "3px" } }));
|
| 178 |
-
row.appendChild(meta);
|
| 179 |
-
|
| 180 |
-
return row;
|
| 181 |
-
}
|
| 182 |
-
|
| 183 |
-
// ─── session actions ───
|
| 184 |
-
async function newChat() {
|
| 185 |
-
try {
|
| 186 |
-
const s = await createSession();
|
| 187 |
-
const id = s && s.id;
|
| 188 |
-
if (id) {
|
| 189 |
-
await openSession(id);
|
| 190 |
-
renderSidebar();
|
| 191 |
-
}
|
| 192 |
-
} catch (e) {
|
| 193 |
-
snackbar("Failed to create chat", "error");
|
| 194 |
-
}
|
| 195 |
-
}
|
| 196 |
-
|
| 197 |
-
async function openSession(id) {
|
| 198 |
-
if (_abortCtrl) { _abortCtrl.abort(); _abortCtrl = null; }
|
| 199 |
-
_sending = false;
|
| 200 |
-
_currentSessionId = id;
|
| 201 |
-
setLastSessionId(id);
|
| 202 |
-
renderSidebar();
|
| 203 |
-
renderChat();
|
| 204 |
-
// Load messages
|
| 205 |
-
await loadMessages(id);
|
| 206 |
-
}
|
| 207 |
-
|
| 208 |
-
async function loadMessages(id) {
|
| 209 |
-
if (!_msgContainer) return;
|
| 210 |
-
clear(_msgContainer);
|
| 211 |
-
_msgContainer.appendChild(h("div", { class: "skeleton", style: { height: "60px", marginBottom: "8px" } }));
|
| 212 |
-
_msgContainer.appendChild(h("div", { class: "skeleton", style: { height: "40px", width: "70%", marginBottom: "8px" } }));
|
| 213 |
-
|
| 214 |
-
try {
|
| 215 |
-
const { messages } = await getMessages(id);
|
| 216 |
-
clear(_msgContainer);
|
| 217 |
-
if (!messages.length) {
|
| 218 |
-
_msgContainer.appendChild(h("div", {
|
| 219 |
-
style: { padding: "40px 20px", textAlign: "center", color: "var(--fg-3)" },
|
| 220 |
-
html: '<div style="font-size:36px;margin-bottom:12px">💬</div><div style="font-size:14px">Send a message to start the conversation</div>',
|
| 221 |
-
}));
|
| 222 |
-
return;
|
| 223 |
-
}
|
| 224 |
-
for (const m of messages) {
|
| 225 |
-
renderMessage(m);
|
| 226 |
-
}
|
| 227 |
-
_msgContainer.scrollTop = _msgContainer.scrollHeight;
|
| 228 |
-
} catch (e) {
|
| 229 |
-
clear(_msgContainer);
|
| 230 |
-
_msgContainer.appendChild(h("div", { style: { padding: "40px", textAlign: "center", color: "var(--danger)" }, text: "Failed to load messages" }));
|
| 231 |
-
}
|
| 232 |
-
}
|
| 233 |
-
|
| 234 |
-
function renderMessage(m) {
|
| 235 |
-
const role = (m.role || "user").toLowerCase();
|
| 236 |
-
const isUser = role === "user";
|
| 237 |
-
const wrap = h("div", {
|
| 238 |
-
style: {
|
| 239 |
-
padding: "16px 20px",
|
| 240 |
-
borderBottom: "1px solid var(--border)",
|
| 241 |
-
background: isUser ? "transparent" : "var(--bg-1)",
|
| 242 |
-
maxWidth: "100%",
|
| 243 |
-
},
|
| 244 |
-
});
|
| 245 |
-
const label = h("div", {
|
| 246 |
-
style: { display: "flex", alignItems: "center", gap: "8px", marginBottom: "8px", fontSize: "12px", fontWeight: "600", color: isUser ? "var(--fg-2)" : "var(--accent)" },
|
| 247 |
-
text: isUser ? "You" : "Assistant",
|
| 248 |
-
});
|
| 249 |
-
wrap.appendChild(label);
|
| 250 |
-
|
| 251 |
-
const content = h("div", {
|
| 252 |
-
style: { fontSize: "14px", lineHeight: "1.7", whiteSpace: "pre-wrap", wordBreak: "break-word" },
|
| 253 |
-
});
|
| 254 |
-
|
| 255 |
-
// Render parts
|
| 256 |
-
if (m.parts && m.parts.length) {
|
| 257 |
-
for (const p of m.parts) {
|
| 258 |
-
if (p.type === "text") {
|
| 259 |
-
content.appendChild(h("div", { text: p.text || "" }));
|
| 260 |
-
} else if (p.type === "reasoning") {
|
| 261 |
-
// Collapsible reasoning
|
| 262 |
-
const details = h("details", { style: { marginBottom: "8px", color: "var(--fg-3)", fontSize: "12px" } });
|
| 263 |
-
details.appendChild(h("summary", { text: "Reasoning", style: { cursor: "pointer", marginBottom: "4px" } }));
|
| 264 |
-
details.appendChild(h("div", { text: p.text || "", style: { whiteSpace: "pre-wrap", padding: "8px", background: "var(--bg-2)", borderRadius: "4px" } }));
|
| 265 |
-
content.appendChild(details);
|
| 266 |
-
} else if (p.type === "tool-invocation") {
|
| 267 |
-
const toolWrap = h("div", { style: { margin: "8px 0", padding: "8px 12px", background: "var(--bg-2)", borderRadius: "6px", border: "1px solid var(--border)", fontSize: "12px" } });
|
| 268 |
-
toolWrap.appendChild(h("div", { style: { fontWeight: "600", marginBottom: "4px", color: "var(--fg-2)" }, text: p.toolInvocation && p.toolInvocation.toolName ? p.toolInvocation.toolName : "Tool" }));
|
| 269 |
-
if (p.toolInvocation && p.toolInvocation.state === "result") {
|
| 270 |
-
toolWrap.appendChild(h("div", { text: String((p.toolInvocation.result || "").slice(0, 500)), style: { color: "var(--fg-3)" } }));
|
| 271 |
-
}
|
| 272 |
-
content.appendChild(toolWrap);
|
| 273 |
-
}
|
| 274 |
-
}
|
| 275 |
-
} else if (m.content) {
|
| 276 |
-
// Fallback: plain content string
|
| 277 |
-
if (typeof m.content === "string") {
|
| 278 |
-
content.appendChild(h("div", { text: m.content }));
|
| 279 |
-
} else if (Array.isArray(m.content)) {
|
| 280 |
-
for (const part of m.content) {
|
| 281 |
-
if (typeof part === "string") content.appendChild(h("div", { text: part }));
|
| 282 |
-
else if (part && part.text) content.appendChild(h("div", { text: part.text }));
|
| 283 |
-
}
|
| 284 |
-
}
|
| 285 |
-
}
|
| 286 |
-
wrap.appendChild(content);
|
| 287 |
-
_msgContainer.appendChild(wrap);
|
| 288 |
-
}
|
| 289 |
-
|
| 290 |
-
// ─── chat view ───
|
| 291 |
-
function renderChat() {
|
| 292 |
-
clear(_chat);
|
| 293 |
-
if (!_currentSessionId) {
|
| 294 |
-
_chat.appendChild(h("div", {
|
| 295 |
-
style: { display: "flex", alignItems: "center", justifyContent: "center", height: "100%", color: "var(--fg-3)" },
|
| 296 |
-
html: '<div style="text-align:center"><div style="font-size:48px;margin-bottom:16px">👋</div><div style="font-size:16px;margin-bottom:8px">Welcome to OpenCode</div><div style="font-size:13px">Select a chat or start a new one</div></div>',
|
| 297 |
-
}));
|
| 298 |
-
return;
|
| 299 |
-
}
|
| 300 |
-
|
| 301 |
-
// Header
|
| 302 |
-
const hdr = h("div", { style: { display: "flex", alignItems: "center", gap: "10px", padding: "10px 16px", borderBottom: "1px solid var(--border)", background: "var(--bg-1)" } });
|
| 303 |
-
const menuBtn = h("button", { title: "Toggle sidebar", style: { padding: "6px 10px", color: "var(--fg-2)", fontSize: "16px" }, text: "\u2630" });
|
| 304 |
-
menuBtn.addEventListener("click", () => { _sidebarOpen = !_sidebarOpen; _sidebar.style.display = _sidebarOpen ? "" : "none"; });
|
| 305 |
-
_titleEl = h("span", { style: { flex: "1", fontSize: "14px", fontWeight: "600", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }, text: "Loading..." });
|
| 306 |
-
const statusEl = h("span", { style: { fontSize: "11px", color: "var(--fg-3)" } });
|
| 307 |
-
const abortBtn = h("button", { title: "Stop generating", style: { padding: "4px 10px", borderRadius: "4px", background: "var(--danger)", color: "#fff", fontSize: "12px", display: "none" }, text: "Stop" });
|
| 308 |
-
abortBtn.addEventListener("click", () => { if (_abortCtrl) _abortCtrl.abort(); });
|
| 309 |
-
hdr.append(menuBtn, _titleEl, statusEl, abortBtn);
|
| 310 |
-
_chat.appendChild(hdr);
|
| 311 |
-
|
| 312 |
-
// Messages container
|
| 313 |
-
_msgContainer = h("div", { style: { flex: "1", overflow: "auto" } });
|
| 314 |
-
_chat.appendChild(_msgContainer);
|
| 315 |
-
|
| 316 |
-
// Input area
|
| 317 |
-
const inputArea = h("div", { style: { padding: "12px 16px", borderTop: "1px solid var(--border)", background: "var(--bg-1)" } });
|
| 318 |
-
|
| 319 |
-
// Model/Agent selector row
|
| 320 |
-
const selectorRow = h("div", { style: { display: "flex", gap: "8px", marginBottom: "8px", alignItems: "center" } });
|
| 321 |
-
const agentSelect = h("select", { style: { padding: "4px 8px", background: "var(--bg-2)", border: "1px solid var(--border)", borderRadius: "4px", fontSize: "12px", color: "var(--fg-2)" } });
|
| 322 |
-
agentSelect.appendChild(h("option", { value: "", text: "Agent: build" }));
|
| 323 |
-
for (const a of _agents) {
|
| 324 |
-
const opt = h("option", { value: a.id || a, text: a.id || a });
|
| 325 |
-
if ((getSelectedAgent() || "build") === (a.id || a)) opt.selected = true;
|
| 326 |
-
agentSelect.appendChild(opt);
|
| 327 |
-
}
|
| 328 |
-
agentSelect.addEventListener("change", () => setSelectedAgent(agentSelect.value));
|
| 329 |
-
selectorRow.appendChild(agentSelect);
|
| 330 |
-
inputArea.appendChild(selectorRow);
|
| 331 |
-
|
| 332 |
-
// Text input
|
| 333 |
-
const inputWrap = h("div", { style: { display: "flex", gap: "8px", alignItems: "flex-end" } });
|
| 334 |
-
_inputBox = h("textarea", {
|
| 335 |
-
placeholder: "Message...",
|
| 336 |
-
rows: "1",
|
| 337 |
-
style: { flex: "1", padding: "10px 14px", background: "var(--bg-2)", border: "1px solid var(--border)", borderRadius: "8px", fontSize: "14px", resize: "none", maxHeight: "180px", lineHeight: "1.5", minHeight: "42px" },
|
| 338 |
-
});
|
| 339 |
-
|
| 340 |
-
// Auto-resize textarea
|
| 341 |
-
_inputBox.addEventListener("input", () => {
|
| 342 |
-
_inputBox.style.height = "auto";
|
| 343 |
-
_inputBox.style.height = Math.min(_inputBox.scrollHeight, 180) + "px";
|
| 344 |
-
});
|
| 345 |
-
|
| 346 |
-
// Ctrl+Enter to send
|
| 347 |
-
_inputBox.addEventListener("keydown", (e) => {
|
| 348 |
-
if (e.key === "Enter" && !e.shiftKey) {
|
| 349 |
-
e.preventDefault();
|
| 350 |
-
submitMessage();
|
| 351 |
-
}
|
| 352 |
-
});
|
| 353 |
-
|
| 354 |
-
// Restore draft
|
| 355 |
-
if (_currentSessionId) {
|
| 356 |
-
const draft = getDraft(_currentSessionId);
|
| 357 |
-
if (draft) _inputBox.value = draft;
|
| 358 |
-
}
|
| 359 |
-
|
| 360 |
-
// Save draft on input
|
| 361 |
-
_inputBox.addEventListener("input", () => {
|
| 362 |
-
if (_currentSessionId) setDraft(_currentSessionId, _inputBox.value);
|
| 363 |
-
});
|
| 364 |
-
|
| 365 |
-
const sendBtn = h("button", { title: "Send (Enter)", style: { padding: "10px 16px", background: "var(--accent)", color: "#0a0a0a", borderRadius: "8px", fontWeight: "600", fontSize: "14px", height: "42px" }, text: "Send" });
|
| 366 |
-
sendBtn.addEventListener("click", submitMessage);
|
| 367 |
-
|
| 368 |
-
inputWrap.append(_inputBox, sendBtn);
|
| 369 |
-
inputArea.appendChild(inputWrap);
|
| 370 |
-
_chat.appendChild(inputArea);
|
| 371 |
-
|
| 372 |
-
// Load title from session data
|
| 373 |
-
loadSessionTitle();
|
| 374 |
-
}
|
| 375 |
-
|
| 376 |
-
async function loadSessionTitle() {
|
| 377 |
-
if (!_currentSessionId || !_titleEl) return;
|
| 378 |
-
try {
|
| 379 |
-
const s = await getSession(_currentSessionId);
|
| 380 |
-
if (s) {
|
| 381 |
-
_titleEl.textContent = s.title || "Untitled";
|
| 382 |
-
// Update sidebar to reflect new title
|
| 383 |
-
renderSessionList();
|
| 384 |
-
}
|
| 385 |
-
} catch {
|
| 386 |
-
_titleEl.textContent = "Chat";
|
| 387 |
-
}
|
| 388 |
-
}
|
| 389 |
-
|
| 390 |
-
async function submitMessage() {
|
| 391 |
-
if (!_inputBox || !_currentSessionId || _sending) return;
|
| 392 |
-
const text = _inputBox.value.trim();
|
| 393 |
-
if (!text) return;
|
| 394 |
-
|
| 395 |
-
_sending = true;
|
| 396 |
-
const agent = getSelectedAgent() || "build";
|
| 397 |
-
_inputBox.value = "";
|
| 398 |
-
setDraft(_currentSessionId, "");
|
| 399 |
-
|
| 400 |
-
// Append user message optimistically
|
| 401 |
-
renderMessage({ role: "user", parts: [{ type: "text", text }], content: text });
|
| 402 |
-
_msgContainer.scrollTop = _msgContainer.scrollHeight;
|
| 403 |
-
|
| 404 |
-
// Create assistant placeholder
|
| 405 |
-
const assistantEl = h("div", {
|
| 406 |
-
style: { padding: "16px 20px", borderBottom: "1px solid var(--border)", background: "var(--bg-1)" },
|
| 407 |
-
});
|
| 408 |
-
assistantEl.appendChild(h("div", { style: { display: "flex", alignItems: "center", gap: "8px", marginBottom: "8px", fontSize: "12px", fontWeight: "600", color: "var(--accent)" }, text: "Assistant" }));
|
| 409 |
-
const contentEl = h("div", { style: { fontSize: "14px", lineHeight: "1.7", whiteSpace: "pre-wrap", wordBreak: "break-word", color: "var(--fg-3)" }, text: "Thinking..." });
|
| 410 |
-
assistantEl.appendChild(contentEl);
|
| 411 |
-
_msgContainer.appendChild(assistantEl);
|
| 412 |
-
_msgContainer.scrollTop = _msgContainer.scrollHeight;
|
| 413 |
-
|
| 414 |
-
// Find abort button and show it
|
| 415 |
-
const abortBtn = _chat.querySelector("button[title='Stop generating']");
|
| 416 |
-
if (abortBtn) abortBtn.style.display = "";
|
| 417 |
-
|
| 418 |
-
_abortCtrl = new AbortController();
|
| 419 |
-
|
| 420 |
-
try {
|
| 421 |
-
const parts = [{ type: "text", text }];
|
| 422 |
-
const resp = await sendMessage(_currentSessionId, parts, { agent, signal: _abortCtrl.signal });
|
| 423 |
-
if (resp && resp.ok && resp.data) {
|
| 424 |
-
// Render full response from resp.data
|
| 425 |
-
clear(contentEl);
|
| 426 |
-
const data = resp.data;
|
| 427 |
-
if (data.parts && data.parts.length) {
|
| 428 |
-
for (const p of data.parts) {
|
| 429 |
-
if (p.type === "text") contentEl.appendChild(h("div", { text: p.text || "" }));
|
| 430 |
-
else if (p.type === "reasoning") {
|
| 431 |
-
const d = h("details", { style: { marginBottom: "8px", color: "var(--fg-3)", fontSize: "12px" } });
|
| 432 |
-
d.appendChild(h("summary", { text: "Reasoning", style: { cursor: "pointer", marginBottom: "4px" } }));
|
| 433 |
-
d.appendChild(h("div", { text: p.text || "", style: { whiteSpace: "pre-wrap", padding: "8px", background: "var(--bg-2)", borderRadius: "4px" } }));
|
| 434 |
-
contentEl.appendChild(d);
|
| 435 |
-
}
|
| 436 |
-
}
|
| 437 |
-
} else if (data.content) {
|
| 438 |
-
if (typeof data.content === "string") contentEl.textContent = data.content;
|
| 439 |
-
else contentEl.textContent = JSON.stringify(data.content);
|
| 440 |
-
}
|
| 441 |
-
contentEl.style.color = "";
|
| 442 |
-
} else if (resp && resp.data) {
|
| 443 |
-
// Error or unexpected format
|
| 444 |
-
clear(contentEl);
|
| 445 |
-
contentEl.textContent = typeof resp.data === "string" ? resp.data : JSON.stringify(resp.data);
|
| 446 |
-
contentEl.style.color = "var(--danger)";
|
| 447 |
-
}
|
| 448 |
-
} catch (e) {
|
| 449 |
-
if (e.name === "AbortError") {
|
| 450 |
-
clear(contentEl);
|
| 451 |
-
contentEl.textContent = "(stopped)";
|
| 452 |
-
contentEl.style.color = "var(--fg-3)";
|
| 453 |
-
} else {
|
| 454 |
-
clear(contentEl);
|
| 455 |
-
contentEl.textContent = "Error: " + (e.message || "Unknown error");
|
| 456 |
-
contentEl.style.color = "var(--danger)";
|
| 457 |
-
}
|
| 458 |
-
} finally {
|
| 459 |
-
_sending = false;
|
| 460 |
-
_abortCtrl = null;
|
| 461 |
-
if (abortBtn) abortBtn.style.display = "none";
|
| 462 |
-
_msgContainer.scrollTop = _msgContainer.scrollHeight;
|
| 463 |
-
// Refresh title in sidebar
|
| 464 |
-
renderSessionList();
|
| 465 |
-
}
|
| 466 |
-
}
|
| 467 |
-
|
| 468 |
-
// ─── session actions ───
|
| 469 |
-
async function renameSessionAction(s) {
|
| 470 |
-
const title = await promptInput("Rename chat", "Enter new title", s.title || "");
|
| 471 |
-
if (title && title !== s.title) {
|
| 472 |
-
try {
|
| 473 |
-
await renameSession(s.id, title);
|
| 474 |
-
renderSessionList();
|
| 475 |
-
if (s.id === _currentSessionId && _titleEl) _titleEl.textContent = title;
|
| 476 |
-
snackbar("Renamed");
|
| 477 |
-
} catch (e) {
|
| 478 |
-
snackbar("Rename failed", "error");
|
| 479 |
-
}
|
| 480 |
-
}
|
| 481 |
-
}
|
| 482 |
-
|
| 483 |
-
async function deleteSessionAction(s) {
|
| 484 |
-
const ok = await confirmAction("Delete chat?", `Delete "${s.title || "Untitled"}"? This cannot be undone.`, { okText: "Delete", danger: true });
|
| 485 |
-
if (!ok) return;
|
| 486 |
-
try {
|
| 487 |
-
await deleteSessions([s.id]);
|
| 488 |
-
snackbar("Deleted");
|
| 489 |
-
if (_currentSessionId === s.id) {
|
| 490 |
-
_currentSessionId = null;
|
| 491 |
-
setLastSessionId(null);
|
| 492 |
-
renderChat();
|
| 493 |
-
}
|
| 494 |
-
renderSessionList();
|
| 495 |
-
} catch (e) {
|
| 496 |
-
snackbar("Delete failed", "error");
|
| 497 |
-
}
|
| 498 |
-
}
|
| 499 |
-
|
| 500 |
-
// ─── SSE ───
|
| 501 |
-
function startSSE() {
|
| 502 |
-
if (_sse) _sse.close();
|
| 503 |
-
_sse = openEventStream((evt) => {
|
| 504 |
-
const t = evt && evt.type;
|
| 505 |
-
if (t === "session.updated" || t === "session.created") {
|
| 506 |
-
updateSessionFromEvent(evt);
|
| 507 |
-
// Reload title if it's current session
|
| 508 |
-
if (_currentSessionId && evt.data && ((evt.data.id || evt.aggregateID) === _currentSessionId)) {
|
| 509 |
-
loadSessionTitle();
|
| 510 |
-
}
|
| 511 |
-
} else if (t === "session.deleted") {
|
| 512 |
-
const id = evt.data && evt.data.id;
|
| 513 |
-
if (id) {
|
| 514 |
-
const sessions = getFilteredSessions();
|
| 515 |
-
// Just refresh
|
| 516 |
-
refreshSessions().then(() => renderSessionList());
|
| 517 |
-
}
|
| 518 |
-
} else if (t === "message.updated" || t === "message.part.updated") {
|
| 519 |
-
// Could stream partial messages, but for now we do full response
|
| 520 |
-
}
|
| 521 |
-
}, (err) => {
|
| 522 |
-
// SSE error — will auto-retry inside openEventStream
|
| 523 |
-
});
|
| 524 |
-
}
|
| 525 |
-
|
| 526 |
-
// ─── boot ───
|
| 527 |
-
export async function mount() {
|
| 528 |
-
_root = document.getElementById("root");
|
| 529 |
-
clear(_root);
|
| 530 |
-
|
| 531 |
-
// Add overlay and snackbar containers
|
| 532 |
-
const overlayHost = h("div", { id: "overlay", style: { display: "none" } });
|
| 533 |
-
const snackHost = h("div", { id: "snackbars", style: { position: "fixed", bottom: "16px", right: "16px", zIndex: "9999", display: "flex", flexDirection: "column", gap: "8px" } });
|
| 534 |
-
|
| 535 |
-
// Layout
|
| 536 |
-
const layout = h("div", { style: { display: "flex", height: "100vh", width: "100vw" } });
|
| 537 |
-
|
| 538 |
-
// Sidebar
|
| 539 |
-
_sidebar = h("div", {
|
| 540 |
-
id: "sidebar",
|
| 541 |
-
style: {
|
| 542 |
-
width: "280px",
|
| 543 |
-
minWidth: "280px",
|
| 544 |
-
background: "var(--bg)",
|
| 545 |
-
borderRight: "1px solid var(--border)",
|
| 546 |
-
display: "flex",
|
| 547 |
-
flexDirection: "column",
|
| 548 |
-
overflow: "hidden",
|
| 549 |
-
transition: "width .15s",
|
| 550 |
-
},
|
| 551 |
-
});
|
| 552 |
-
|
| 553 |
-
// Main chat
|
| 554 |
-
_chat = h("div", { id: "chat", style: { flex: "1", display: "flex", flexDirection: "column", minWidth: "0" } });
|
| 555 |
-
|
| 556 |
-
layout.append(_sidebar, _chat);
|
| 557 |
-
_root.append(layout, overlayHost, snackHost);
|
| 558 |
-
|
| 559 |
-
// Load initial data
|
| 560 |
-
await refreshSessions();
|
| 561 |
-
renderSidebar();
|
| 562 |
-
renderChat();
|
| 563 |
-
|
| 564 |
-
// Open last session or show welcome
|
| 565 |
-
const lastId = getLastSessionId();
|
| 566 |
-
const sessions = getFilteredSessions();
|
| 567 |
-
if (lastId && sessions.find(s => s.id === lastId)) {
|
| 568 |
-
await openSession(lastId);
|
| 569 |
-
} else if (sessions.length) {
|
| 570 |
-
// Auto-open the most recent
|
| 571 |
-
await openSession(sessions[0].id);
|
| 572 |
-
}
|
| 573 |
-
|
| 574 |
-
// Start SSE
|
| 575 |
-
startSSE();
|
| 576 |
-
|
| 577 |
-
// Periodic refresh (every 30s) to catch any missed updates
|
| 578 |
-
setInterval(async () => {
|
| 579 |
-
await refreshSessions();
|
| 580 |
-
renderSessionList();
|
| 581 |
-
}, 30000);
|
| 582 |
-
|
| 583 |
-
// Subscribe to store changes
|
| 584 |
-
subscribe(() => {
|
| 585 |
-
renderSessionList();
|
| 586 |
-
});
|
| 587 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
frontend/http.js
DELETED
|
@@ -1,185 +0,0 @@
|
|
| 1 |
-
/* http.js — thin fetch helpers around opencode's API */
|
| 2 |
-
|
| 3 |
-
const H = {
|
| 4 |
-
json: { "content-type": "application/json", accept: "application/json" },
|
| 5 |
-
};
|
| 6 |
-
|
| 7 |
-
export async function api(path, opts = {}) {
|
| 8 |
-
const r = await fetch(path, {
|
| 9 |
-
method: opts.method || "GET",
|
| 10 |
-
headers: { ...H.json, ...(opts.headers || {}) },
|
| 11 |
-
body: opts.body ? JSON.stringify(opts.body) : undefined,
|
| 12 |
-
signal: opts.signal,
|
| 13 |
-
});
|
| 14 |
-
const text = await r.text();
|
| 15 |
-
let data = null;
|
| 16 |
-
if (text) {
|
| 17 |
-
try {
|
| 18 |
-
data = JSON.parse(text);
|
| 19 |
-
} catch {
|
| 20 |
-
data = text;
|
| 21 |
-
}
|
| 22 |
-
}
|
| 23 |
-
return { ok: r.ok, status: r.status, data, headers: r.headers };
|
| 24 |
-
}
|
| 25 |
-
|
| 26 |
-
export async function listSessions(workspace) {
|
| 27 |
-
const q = workspace ? `?directory=${encodeURIComponent(workspace)}` : "";
|
| 28 |
-
const r = await api(`/api/session${q}`);
|
| 29 |
-
return r.ok ? (r.data && r.data.data) || [] : [];
|
| 30 |
-
}
|
| 31 |
-
|
| 32 |
-
export async function getSession(id) {
|
| 33 |
-
const r = await api(`/api/session/${encodeURIComponent(id)}`);
|
| 34 |
-
return r.ok ? r.data : null;
|
| 35 |
-
}
|
| 36 |
-
|
| 37 |
-
export async function createSession(body = {}) {
|
| 38 |
-
const r = await api("/api/session", { method: "POST", body });
|
| 39 |
-
return r.ok ? r.data : null;
|
| 40 |
-
}
|
| 41 |
-
|
| 42 |
-
export async function deleteSessions(ids) {
|
| 43 |
-
if (!ids || !ids.length) return { ok: true };
|
| 44 |
-
const r = await api("/api/session/delete", { method: "POST", body: { ids } });
|
| 45 |
-
return r;
|
| 46 |
-
}
|
| 47 |
-
|
| 48 |
-
export async function renameSession(id, title) {
|
| 49 |
-
try {
|
| 50 |
-
const r = await api(`/api/session/${id}`, {
|
| 51 |
-
method: "PATCH",
|
| 52 |
-
body: { title },
|
| 53 |
-
});
|
| 54 |
-
if (r.ok) return r.data;
|
| 55 |
-
} catch {}
|
| 56 |
-
// Fallback for older opencode builds that use body title
|
| 57 |
-
const r = await api(`/api/session/${id}`, {
|
| 58 |
-
method: "PATCH",
|
| 59 |
-
body: { title },
|
| 60 |
-
});
|
| 61 |
-
return r.ok ? r.data : null;
|
| 62 |
-
}
|
| 63 |
-
|
| 64 |
-
export async function shareSession(id) {
|
| 65 |
-
const r = await api(`/api/session/${id}/share`, { method: "POST" });
|
| 66 |
-
return r.ok ? r.data : null;
|
| 67 |
-
}
|
| 68 |
-
|
| 69 |
-
export async function initSession(id) {
|
| 70 |
-
const r = await api(`/api/session/${id}/init`, {
|
| 71 |
-
method: "POST",
|
| 72 |
-
body: { messageID: "" },
|
| 73 |
-
});
|
| 74 |
-
return r.ok ? r.data : null;
|
| 75 |
-
}
|
| 76 |
-
|
| 77 |
-
export async function getMessages(id, cursor, signal) {
|
| 78 |
-
const q = new URLSearchParams();
|
| 79 |
-
if (cursor) q.set("cursor", cursor);
|
| 80 |
-
const qstr = q.toString();
|
| 81 |
-
const r = await fetch(`/api/session/${encodeURIComponent(id)}/message${qstr ? "?" + qstr : ""}`, {
|
| 82 |
-
headers: { accept: "application/json" },
|
| 83 |
-
signal,
|
| 84 |
-
});
|
| 85 |
-
if (!r.ok) return { messages: [], hasMore: false };
|
| 86 |
-
const json = await r.json();
|
| 87 |
-
return {
|
| 88 |
-
messages: (json && json.data) || [],
|
| 89 |
-
cursor: json && json.cursor,
|
| 90 |
-
};
|
| 91 |
-
}
|
| 92 |
-
|
| 93 |
-
export async function sendMessage(id, parts, opts = {}) {
|
| 94 |
-
return api(`/api/session/${encodeURIComponent(id)}/message`, {
|
| 95 |
-
method: "POST",
|
| 96 |
-
body: { parts, agent: opts.agent || "build" },
|
| 97 |
-
signal: opts.signal,
|
| 98 |
-
});
|
| 99 |
-
}
|
| 100 |
-
|
| 101 |
-
export async function sessionStatus(id) {
|
| 102 |
-
const r = await api(`/api/session/${encodeURIComponent(id)}/status`);
|
| 103 |
-
return r.ok ? r.data : null;
|
| 104 |
-
}
|
| 105 |
-
|
| 106 |
-
export async function abortSession(id) {
|
| 107 |
-
return api(`/api/session/${encodeURIComponent(id)}/abort`, {
|
| 108 |
-
method: "POST",
|
| 109 |
-
});
|
| 110 |
-
}
|
| 111 |
-
|
| 112 |
-
export async function abortSessionStatus(id) {
|
| 113 |
-
return api(`/api/session/${encodeURIComponent(id)}/abort`, {
|
| 114 |
-
method: "POST",
|
| 115 |
-
body: {},
|
| 116 |
-
});
|
| 117 |
-
}
|
| 118 |
-
|
| 119 |
-
export async function listProviders() {
|
| 120 |
-
const r = await api("/api/provider");
|
| 121 |
-
return r.ok ? (r.data && r.data.data) || [] : [];
|
| 122 |
-
}
|
| 123 |
-
|
| 124 |
-
export async function listAgents() {
|
| 125 |
-
const r = await api("/api/agent");
|
| 126 |
-
return r.ok ? (r.data && r.data.data) || [] : [];
|
| 127 |
-
}
|
| 128 |
-
|
| 129 |
-
export function openEventStream(onEvent, onError, signal) {
|
| 130 |
-
// opencode's SSE endpoint is /api/event; it streams session lifecycle and
|
| 131 |
-
// message progress events. We keep the connection alive and rebind handlers
|
| 132 |
-
// on disconnect so transient network blips do not break the UI.
|
| 133 |
-
let es = null;
|
| 134 |
-
let retry = 0;
|
| 135 |
-
let stopped = false;
|
| 136 |
-
|
| 137 |
-
async function connect() {
|
| 138 |
-
if (stopped) return;
|
| 139 |
-
try {
|
| 140 |
-
es = new EventSource("/api/event");
|
| 141 |
-
es.onmessage = (e) => {
|
| 142 |
-
retry = 0;
|
| 143 |
-
try {
|
| 144 |
-
const data = JSON.parse(e.data);
|
| 145 |
-
onEvent && onEvent(data);
|
| 146 |
-
} catch (err) {
|
| 147 |
-
onError && onError(err);
|
| 148 |
-
}
|
| 149 |
-
};
|
| 150 |
-
es.onerror = () => {
|
| 151 |
-
if (es) {
|
| 152 |
-
es.close();
|
| 153 |
-
es = null;
|
| 154 |
-
}
|
| 155 |
-
if (stopped) return;
|
| 156 |
-
retry = Math.min(retry + 1, 6);
|
| 157 |
-
const delay = 500 * Math.pow(2, retry);
|
| 158 |
-
setTimeout(connect, delay);
|
| 159 |
-
onError && onError(new Error("sse disconnected"));
|
| 160 |
-
};
|
| 161 |
-
} catch (err) {
|
| 162 |
-
onError && onError(err);
|
| 163 |
-
setTimeout(connect, 1000);
|
| 164 |
-
}
|
| 165 |
-
}
|
| 166 |
-
|
| 167 |
-
connect();
|
| 168 |
-
|
| 169 |
-
return {
|
| 170 |
-
close() {
|
| 171 |
-
stopped = true;
|
| 172 |
-
if (es) {
|
| 173 |
-
es.close();
|
| 174 |
-
es = null;
|
| 175 |
-
}
|
| 176 |
-
},
|
| 177 |
-
};
|
| 178 |
-
}
|
| 179 |
-
|
| 180 |
-
export function partText(p) {
|
| 181 |
-
if (!p) return "";
|
| 182 |
-
if (p.type === "text") return p.text || "";
|
| 183 |
-
if (p.type === "reasoning") return p.text || "";
|
| 184 |
-
return "";
|
| 185 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
frontend/index.html
DELETED
|
@@ -1,30 +0,0 @@
|
|
| 1 |
-
<!doctype html>
|
| 2 |
-
<html lang="en">
|
| 3 |
-
<head>
|
| 4 |
-
<meta charset="utf-8">
|
| 5 |
-
<meta name="viewport" content="width=device-width, initial-scale=1">
|
| 6 |
-
<title>OpenCode</title>
|
| 7 |
-
<link rel="icon" href="/favicon-96x96-v3.png" sizes="96x96">
|
| 8 |
-
<style>
|
| 9 |
-
*,*::before,*::after{box-sizing:border-box}
|
| 10 |
-
html,body,#root{height:100%;margin:0;padding:0}
|
| 11 |
-
body{font-family:-apple-system,BlinkMacSystemFont,"Inter","Segoe UI",sans-serif;background:#0a0a0a;color:#ececec;overflow:hidden;-webkit-font-smoothing:antialiased}
|
| 12 |
-
a{color:inherit;text-decoration:none}
|
| 13 |
-
button{font:inherit;color:inherit;cursor:pointer;background:none;border:none;padding:0}
|
| 14 |
-
input,textarea{font:inherit;color:inherit;background:none;border:none;outline:none}
|
| 15 |
-
::-webkit-scrollbar{width:8px;height:8px}
|
| 16 |
-
::-webkit-scrollbar-thumb{background:#2a2a2a;border-radius:8px}
|
| 17 |
-
::-webkit-scrollbar-thumb:hover{background:#3a3a3a}
|
| 18 |
-
::-webkit-scrollbar-track{background:transparent}
|
| 19 |
-
:root{--bg:#0a0a0a;--bg-1:#171717;--bg-2:#1f1f1f;--bg-3:#2a2a2a;--fg:#ececec;--fg-2:#a0a0a0;--fg-3:#6e6e6e;--accent:#10a37f;--danger:#ef4146;--warn:#f0b232;--border:#2a2a2a;--border-hi:#3a3a3a;--radius:8px;--shadow:0 4px 12px rgba(0,0,0,0.4)}
|
| 20 |
-
.skeleton{background:linear-gradient(90deg,#1f1f1f 0%,#2a2a2a 50%,#1f1f1f 100%);background-size:200% 100%;animation:s 1.4s infinite;border-radius:6px}
|
| 21 |
-
@keyframes s{0%{background-position:200% 0}100%{background-position:-200% 0}}
|
| 22 |
-
.fade-in{animation:fade .18s ease-out}
|
| 23 |
-
@keyframes fade{from{opacity:0;transform:translateY(2px)}to{opacity:1;transform:translateY(0)}}
|
| 24 |
-
</style>
|
| 25 |
-
</head>
|
| 26 |
-
<body>
|
| 27 |
-
<div id="root"><div style="display:flex;align-items:center;justify-content:center;height:100%;color:#6e6e6e">loading\u2026</div></div>
|
| 28 |
-
<script type="module" src="/assets/chat.js"></script>
|
| 29 |
-
</body>
|
| 30 |
-
</html>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
frontend/store.js
DELETED
|
@@ -1,158 +0,0 @@
|
|
| 1 |
-
/* store.js — session state + localStorage UI metadata */
|
| 2 |
-
|
| 3 |
-
import { listSessions, renameSession as apiRename, deleteSessions as apiDelete } from "./http.js";
|
| 4 |
-
|
| 5 |
-
const LS_KEY = "oc_ui_meta";
|
| 6 |
-
const LS_LAST = "oc_last_session";
|
| 7 |
-
|
| 8 |
-
// UI-only metadata that lives in localStorage
|
| 9 |
-
let _meta = {};
|
| 10 |
-
try {
|
| 11 |
-
_meta = JSON.parse(localStorage.getItem(LS_KEY) || "{}");
|
| 12 |
-
} catch {
|
| 13 |
-
_meta = {};
|
| 14 |
-
}
|
| 15 |
-
|
| 16 |
-
function saveMeta() {
|
| 17 |
-
localStorage.setItem(LS_KEY, JSON.stringify(_meta));
|
| 18 |
-
}
|
| 19 |
-
|
| 20 |
-
export function getPinnedIds() { return new Set(_meta.pinned || []); }
|
| 21 |
-
export function getArchivedIds() { return new Set(_meta.archived || []); }
|
| 22 |
-
export function getFavorites() { return new Set(_meta.favorites || []); }
|
| 23 |
-
export function getLastSessionId() { return _meta.lastSessionId || localStorage.getItem(LS_LAST) || null; }
|
| 24 |
-
export function setLastSessionId(id) { _meta.lastSessionId = id; localStorage.setItem(LS_LAST, id || ""); saveMeta(); }
|
| 25 |
-
export function getDrawerCollapsed() { return !!_meta.drawerCollapsed; }
|
| 26 |
-
export function setDrawerCollapsed(v) { _meta.drawerCollapsed = v; saveMeta(); }
|
| 27 |
-
export function getSelectedModel() { return _meta.selectedModel || null; }
|
| 28 |
-
export function setSelectedModel(v) { _meta.selectedModel = v; saveMeta(); }
|
| 29 |
-
export function getSelectedAgent() { return _meta.selectedAgent || null; }
|
| 30 |
-
export function setSelectedAgent(v) { _meta.selectedAgent = v; saveMeta(); }
|
| 31 |
-
export function getDraft(sessionId) { return (_meta.drafts || {})[sessionId] || ""; }
|
| 32 |
-
export function setDraft(sessionId, text) {
|
| 33 |
-
if (!_meta.drafts) _meta.drafts = {};
|
| 34 |
-
if (text) _meta.drafts[sessionId] = text;
|
| 35 |
-
else delete _meta.drafts[sessionId];
|
| 36 |
-
saveMeta();
|
| 37 |
-
}
|
| 38 |
-
|
| 39 |
-
export function togglePin(id) {
|
| 40 |
-
const s = getPinnedIds();
|
| 41 |
-
if (s.has(id)) s.delete(id); else s.add(id);
|
| 42 |
-
_meta.pinned = [...s];
|
| 43 |
-
saveMeta();
|
| 44 |
-
return s.has(id);
|
| 45 |
-
}
|
| 46 |
-
|
| 47 |
-
export function toggleArchive(id) {
|
| 48 |
-
const s = getArchivedIds();
|
| 49 |
-
if (s.has(id)) s.delete(id); else s.add(id);
|
| 50 |
-
_meta.archived = [...s];
|
| 51 |
-
saveMeta();
|
| 52 |
-
return s.has(id);
|
| 53 |
-
}
|
| 54 |
-
|
| 55 |
-
export function toggleFavorite(id) {
|
| 56 |
-
const s = getFavorites();
|
| 57 |
-
if (s.has(id)) s.delete(id); else s.add(id);
|
| 58 |
-
_meta.favorites = [...s];
|
| 59 |
-
saveMeta();
|
| 60 |
-
return s.has(id);
|
| 61 |
-
}
|
| 62 |
-
|
| 63 |
-
// Live session list
|
| 64 |
-
let _sessions = [];
|
| 65 |
-
let _listeners = new Set();
|
| 66 |
-
let _loading = false;
|
| 67 |
-
let _error = null;
|
| 68 |
-
|
| 69 |
-
export function getSessions() { return _sessions; }
|
| 70 |
-
export function subscribe(fn) { _listeners.add(fn); return () => _listeners.delete(fn); }
|
| 71 |
-
function emit() { for (const fn of _listeners) fn(_sessions); }
|
| 72 |
-
|
| 73 |
-
export function getFilteredSessions({ search = "", showArchived = false } = {}) {
|
| 74 |
-
const pinned = getPinnedIds();
|
| 75 |
-
const archived = getArchivedIds();
|
| 76 |
-
let list = _sessions;
|
| 77 |
-
|
| 78 |
-
if (!showArchived) {
|
| 79 |
-
list = list.filter(s => !archived.has(s.id));
|
| 80 |
-
}
|
| 81 |
-
|
| 82 |
-
if (search) {
|
| 83 |
-
const q = search.toLowerCase();
|
| 84 |
-
list = list.filter(s => (s.title || "").toLowerCase().includes(q));
|
| 85 |
-
}
|
| 86 |
-
|
| 87 |
-
// Sort: pinned first, then by updated time
|
| 88 |
-
list = [...list].sort((a, b) => {
|
| 89 |
-
const pa = pinned.has(a.id) ? 1 : 0;
|
| 90 |
-
const pb = pinned.has(b.id) ? 1 : 0;
|
| 91 |
-
if (pa !== pb) return pb - pa;
|
| 92 |
-
const ua = (a.time && a.time.updated) || 0;
|
| 93 |
-
const ub = (b.time && b.time.updated) || 0;
|
| 94 |
-
return ub - ua;
|
| 95 |
-
});
|
| 96 |
-
|
| 97 |
-
return list;
|
| 98 |
-
}
|
| 99 |
-
|
| 100 |
-
export async function refreshSessions() {
|
| 101 |
-
_loading = true;
|
| 102 |
-
_error = null;
|
| 103 |
-
try {
|
| 104 |
-
_sessions = await listSessions();
|
| 105 |
-
} catch (e) {
|
| 106 |
-
_error = e;
|
| 107 |
-
}
|
| 108 |
-
_loading = false;
|
| 109 |
-
emit();
|
| 110 |
-
return _sessions;
|
| 111 |
-
}
|
| 112 |
-
|
| 113 |
-
export async function renameSession(id, title) {
|
| 114 |
-
await apiRename(id, title);
|
| 115 |
-
const s = _sessions.find(x => x.id === id);
|
| 116 |
-
if (s) s.title = title;
|
| 117 |
-
emit();
|
| 118 |
-
}
|
| 119 |
-
|
| 120 |
-
export async function deleteSession(id) {
|
| 121 |
-
await apiDelete([id]);
|
| 122 |
-
_sessions = _sessions.filter(s => s.id !== id);
|
| 123 |
-
emit();
|
| 124 |
-
}
|
| 125 |
-
|
| 126 |
-
export function upsertSessionFromEvent(evt) {
|
| 127 |
-
if (!evt || !evt.data) return;
|
| 128 |
-
const d = evt.data;
|
| 129 |
-
const existing = _sessions.find(s => s.id === d.id);
|
| 130 |
-
if (existing) {
|
| 131 |
-
Object.assign(existing, d);
|
| 132 |
-
} else {
|
| 133 |
-
_sessions.unshift(d);
|
| 134 |
-
}
|
| 135 |
-
emit();
|
| 136 |
-
}
|
| 137 |
-
|
| 138 |
-
export function updateSessionFromEvent(evt) {
|
| 139 |
-
if (!evt || !evt.data) return;
|
| 140 |
-
const id = (evt.data && evt.data.id) || (evt.aggregateID);
|
| 141 |
-
if (!id) return;
|
| 142 |
-
const s = _sessions.find(x => x.id === id);
|
| 143 |
-
if (s) {
|
| 144 |
-
// Merge data from event (session.updated events carry full info)
|
| 145 |
-
const info = evt.data.info || evt.data;
|
| 146 |
-
if (info) Object.assign(s, info);
|
| 147 |
-
}
|
| 148 |
-
emit();
|
| 149 |
-
}
|
| 150 |
-
|
| 151 |
-
// Title auto-generation
|
| 152 |
-
let _titleTimers = {};
|
| 153 |
-
export function scheduleTitleGeneration(sessionId, userMessage) {
|
| 154 |
-
if (_titleTimers[sessionId]) return;
|
| 155 |
-
// Fire-and-forget: send a small message to trigger title, then check back
|
| 156 |
-
// Actually we just let opencode's own title system handle this.
|
| 157 |
-
// If the title is still "New session", we can let the user rename.
|
| 158 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
frontend/ui.js
DELETED
|
@@ -1,285 +0,0 @@
|
|
| 1 |
-
/* ui.js — lightweight DOM helpers. No external runtime. */
|
| 2 |
-
|
| 3 |
-
export function h(tag, props = {}, children = []) {
|
| 4 |
-
const el = document.createElement(tag);
|
| 5 |
-
for (const k in props) {
|
| 6 |
-
const v = props[k];
|
| 7 |
-
if (k === "style" && typeof v === "object") Object.assign(el.style, v);
|
| 8 |
-
else if (k === "class") el.className = v;
|
| 9 |
-
else if (k === "html") el.innerHTML = v;
|
| 10 |
-
else if (k === "text") el.textContent = v;
|
| 11 |
-
else if (k.startsWith("on") && typeof v === "function")
|
| 12 |
-
el.addEventListener(k.slice(2).toLowerCase(), v);
|
| 13 |
-
else if (v != null) el.setAttribute(k, v);
|
| 14 |
-
}
|
| 15 |
-
for (const c of [].concat(children)) {
|
| 16 |
-
if (c == null) continue;
|
| 17 |
-
el.append(c.nodeType ? c : document.createTextNode(String(c)));
|
| 18 |
-
}
|
| 19 |
-
return el;
|
| 20 |
-
}
|
| 21 |
-
|
| 22 |
-
export function clear(el) {
|
| 23 |
-
while (el.firstChild) el.removeChild(el.firstChild);
|
| 24 |
-
}
|
| 25 |
-
|
| 26 |
-
export function el(id) {
|
| 27 |
-
return document.getElementById(id);
|
| 28 |
-
}
|
| 29 |
-
|
| 30 |
-
let _uid = 0;
|
| 31 |
-
export function uid(prefix = "id") {
|
| 32 |
-
_uid += 1;
|
| 33 |
-
return `${prefix}-${_uid}`;
|
| 34 |
-
}
|
| 35 |
-
|
| 36 |
-
export function debounce(fn, ms = 200) {
|
| 37 |
-
let t;
|
| 38 |
-
return (...args) => {
|
| 39 |
-
clearTimeout(t);
|
| 40 |
-
t = setTimeout(() => fn(...args), ms);
|
| 41 |
-
};
|
| 42 |
-
}
|
| 43 |
-
|
| 44 |
-
export function formatTimeAgo(ts) {
|
| 45 |
-
if (!ts) return "";
|
| 46 |
-
const d = new Date(ts);
|
| 47 |
-
const now = Date.now();
|
| 48 |
-
const diff = now - ts;
|
| 49 |
-
const s = Math.floor(diff / 1000);
|
| 50 |
-
if (s < 60) return "just now";
|
| 51 |
-
const m = Math.floor(s / 60);
|
| 52 |
-
if (m < 60) return `${m}m`;
|
| 53 |
-
const h = Math.floor(m / 60);
|
| 54 |
-
if (h < 24) return `${h}h`;
|
| 55 |
-
const day = Math.floor(h / 24);
|
| 56 |
-
if (day === 0) return "today";
|
| 57 |
-
if (day === 1) return "yesterday";
|
| 58 |
-
if (day < 7) return `${day}d`;
|
| 59 |
-
if (day < 30) return `${Math.floor(day / 7)}w`;
|
| 60 |
-
return d.toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
| 61 |
-
}
|
| 62 |
-
|
| 63 |
-
export function groupByDate(sessions) {
|
| 64 |
-
const now = Date.now();
|
| 65 |
-
const today = new Date();
|
| 66 |
-
today.setHours(0, 0, 0, 0);
|
| 67 |
-
const yesterday = today.getTime() - 86400000;
|
| 68 |
-
const week = today.getTime() - 7 * 86400000;
|
| 69 |
-
const month = today.getTime() - 30 * 86400000;
|
| 70 |
-
|
| 71 |
-
const groups = { Today: [], Yesterday: [], "Previous 7 Days": [], "Previous 30 Days": [], Older: [] };
|
| 72 |
-
for (const s of sessions) {
|
| 73 |
-
const updated = s.time && s.time.updated ? s.time.updated : 0;
|
| 74 |
-
if (updated >= today.getTime()) groups.Today.push(s);
|
| 75 |
-
else if (updated >= yesterday) groups.Yesterday.push(s);
|
| 76 |
-
else if (updated >= week) groups["Previous 7 Days"].push(s);
|
| 77 |
-
else if (updated >= month) groups["Previous 30 Days"].push(s);
|
| 78 |
-
else groups.Older.push(s);
|
| 79 |
-
}
|
| 80 |
-
for (const k in groups) {
|
| 81 |
-
groups[k].sort((a, b) => {
|
| 82 |
-
const ua = a.time && a.time.updated ? a.time.updated : 0;
|
| 83 |
-
const ub = b.time && b.time.updated ? b.time.updated : 0;
|
| 84 |
-
return ub - ua;
|
| 85 |
-
});
|
| 86 |
-
}
|
| 87 |
-
return groups;
|
| 88 |
-
}
|
| 89 |
-
|
| 90 |
-
export function pickPreview(s) {
|
| 91 |
-
// The session list itself rarely carries last-message content; we use title
|
| 92 |
-
// as a fallback preview. When caller already has a preview, prefer that.
|
| 93 |
-
return s.__preview || s.title || "New chat";
|
| 94 |
-
}
|
| 95 |
-
|
| 96 |
-
export function setOverlay(node) {
|
| 97 |
-
const host = document.getElementById("overlay");
|
| 98 |
-
if (!host) return;
|
| 99 |
-
while (host.firstChild) host.removeChild(host.firstChild);
|
| 100 |
-
host.appendChild(node || document.createTextNode(""));
|
| 101 |
-
host.style.display = node ? "flex" : "none";
|
| 102 |
-
}
|
| 103 |
-
|
| 104 |
-
export function openOverlay(build, onClose) {
|
| 105 |
-
const host = document.createElement("div");
|
| 106 |
-
host.className = "modal-backdrop fade-in";
|
| 107 |
-
Object.assign(host.style, {
|
| 108 |
-
position: "fixed",
|
| 109 |
-
inset: "0",
|
| 110 |
-
background: "rgba(0,0,0,0.55)",
|
| 111 |
-
display: "flex",
|
| 112 |
-
alignItems: "center",
|
| 113 |
-
justifyContent: "center",
|
| 114 |
-
zIndex: "9999",
|
| 115 |
-
});
|
| 116 |
-
const close = () => {
|
| 117 |
-
document.body.removeChild(host);
|
| 118 |
-
document.removeEventListener("keydown", esc);
|
| 119 |
-
onClose && onClose();
|
| 120 |
-
};
|
| 121 |
-
const esc = (e) => {
|
| 122 |
-
if (e.key === "Escape") close();
|
| 123 |
-
};
|
| 124 |
-
host.addEventListener("click", (e) => {
|
| 125 |
-
if (e.target === host) close();
|
| 126 |
-
});
|
| 127 |
-
document.addEventListener("keydown", esc);
|
| 128 |
-
document.body.appendChild(host);
|
| 129 |
-
build(host, close);
|
| 130 |
-
return close;
|
| 131 |
-
}
|
| 132 |
-
|
| 133 |
-
export function snackbar(text, kind = "info") {
|
| 134 |
-
const host = document.getElementById("snackbars") || (() => {
|
| 135 |
-
const n = document.createElement("div");
|
| 136 |
-
n.id = "snackbars";
|
| 137 |
-
Object.assign(n.style, {
|
| 138 |
-
position: "fixed",
|
| 139 |
-
bottom: "16px",
|
| 140 |
-
right: "16px",
|
| 141 |
-
zIndex: "9999",
|
| 142 |
-
display: "flex",
|
| 143 |
-
flexDirection: "column",
|
| 144 |
-
gap: "8px",
|
| 145 |
-
});
|
| 146 |
-
document.body.appendChild(n);
|
| 147 |
-
return n;
|
| 148 |
-
})();
|
| 149 |
-
const node = document.createElement("div");
|
| 150 |
-
node.textContent = text;
|
| 151 |
-
Object.assign(node.style, {
|
| 152 |
-
padding: "8px 14px",
|
| 153 |
-
background: kind === "error" ? "#3a1a1a" : kind === "warn" ? "#3a2e1a" : "#1a2a23",
|
| 154 |
-
border: `1px solid ${kind === "error" ? "#5a2a2a" : kind === "warn" ? "#5a4a1a" : "#1f3a2c"}`,
|
| 155 |
-
borderRadius: "6px",
|
| 156 |
-
color: "#ececec",
|
| 157 |
-
fontSize: "13px",
|
| 158 |
-
boxShadow: "var(--shadow)",
|
| 159 |
-
animation: "fade .2s ease-out",
|
| 160 |
-
});
|
| 161 |
-
host.appendChild(node);
|
| 162 |
-
setTimeout(() => {
|
| 163 |
-
node.style.opacity = "0";
|
| 164 |
-
setTimeout(() => node.remove(), 300);
|
| 165 |
-
}, 2600);
|
| 166 |
-
}
|
| 167 |
-
|
| 168 |
-
export function promptInput(title, placeholder = "", initial = "") {
|
| 169 |
-
return new Promise((resolve) => {
|
| 170 |
-
openOverlay((host, close) => {
|
| 171 |
-
const wrap = document.createElement("div");
|
| 172 |
-
Object.assign(wrap.style, {
|
| 173 |
-
background: "#171717",
|
| 174 |
-
border: "1px solid var(--border-hi)",
|
| 175 |
-
borderRadius: "10px",
|
| 176 |
-
padding: "20px",
|
| 177 |
-
width: "420px",
|
| 178 |
-
maxWidth: "90vw",
|
| 179 |
-
display: "flex",
|
| 180 |
-
flexDirection: "column",
|
| 181 |
-
gap: "12px",
|
| 182 |
-
boxShadow: "var(--shadow)",
|
| 183 |
-
});
|
| 184 |
-
const t = document.createElement("div");
|
| 185 |
-
t.textContent = title;
|
| 186 |
-
t.style.fontSize = "15px";
|
| 187 |
-
t.style.fontWeight = "600";
|
| 188 |
-
wrap.appendChild(t);
|
| 189 |
-
const input = document.createElement("input");
|
| 190 |
-
input.value = initial || "";
|
| 191 |
-
input.placeholder = placeholder;
|
| 192 |
-
Object.assign(input.style, {
|
| 193 |
-
padding: "10px 12px",
|
| 194 |
-
background: "#0a0a0a",
|
| 195 |
-
border: "1px solid var(--border)",
|
| 196 |
-
borderRadius: "6px",
|
| 197 |
-
fontSize: "14px",
|
| 198 |
-
width: "100%",
|
| 199 |
-
});
|
| 200 |
-
input.addEventListener("keydown", (e) => {
|
| 201 |
-
if (e.key === "Enter") {
|
| 202 |
-
close();
|
| 203 |
-
resolve(input.value.trim() || null);
|
| 204 |
-
}
|
| 205 |
-
if (e.key === "Escape") {
|
| 206 |
-
close();
|
| 207 |
-
resolve(null);
|
| 208 |
-
}
|
| 209 |
-
});
|
| 210 |
-
wrap.appendChild(input);
|
| 211 |
-
const row = document.createElement("div");
|
| 212 |
-
Object.assign(row.style, { display: "flex", justifyContent: "flex-end", gap: "8px" });
|
| 213 |
-
const cancel = document.createElement("button");
|
| 214 |
-
cancel.textContent = "Cancel";
|
| 215 |
-
Object.assign(cancel.style, {
|
| 216 |
-
padding: "8px 14px",
|
| 217 |
-
borderRadius: "6px",
|
| 218 |
-
color: "var(--fg-2)",
|
| 219 |
-
});
|
| 220 |
-
cancel.addEventListener("click", () => { close(); resolve(null); });
|
| 221 |
-
const ok = document.createElement("button");
|
| 222 |
-
ok.textContent = "OK";
|
| 223 |
-
ok.className = "btn-primary";
|
| 224 |
-
Object.assign(ok.style, {
|
| 225 |
-
padding: "8px 14px",
|
| 226 |
-
borderRadius: "6px",
|
| 227 |
-
background: "var(--accent)",
|
| 228 |
-
color: "#0a0a0a",
|
| 229 |
-
fontWeight: "600",
|
| 230 |
-
});
|
| 231 |
-
ok.addEventListener("click", () => { close(); resolve(input.value.trim() || null); });
|
| 232 |
-
row.append(cancel, ok);
|
| 233 |
-
wrap.appendChild(row);
|
| 234 |
-
host.appendChild(wrap);
|
| 235 |
-
setTimeout(() => input.focus(), 50);
|
| 236 |
-
});
|
| 237 |
-
});
|
| 238 |
-
}
|
| 239 |
-
|
| 240 |
-
export function confirmAction(title, body, opts = {}) {
|
| 241 |
-
return new Promise((resolve) => {
|
| 242 |
-
openOverlay((host, close) => {
|
| 243 |
-
const wrap = document.createElement("div");
|
| 244 |
-
Object.assign(wrap.style, {
|
| 245 |
-
background: "#171717",
|
| 246 |
-
border: "1px solid var(--border-hi)",
|
| 247 |
-
borderRadius: "10px",
|
| 248 |
-
padding: "20px",
|
| 249 |
-
width: "420px",
|
| 250 |
-
maxWidth: "90vw",
|
| 251 |
-
display: "flex",
|
| 252 |
-
flexDirection: "column",
|
| 253 |
-
gap: "12px",
|
| 254 |
-
});
|
| 255 |
-
const t = document.createElement("div");
|
| 256 |
-
t.textContent = title;
|
| 257 |
-
t.style.fontSize = "15px";
|
| 258 |
-
t.style.fontWeight = "600";
|
| 259 |
-
wrap.appendChild(t);
|
| 260 |
-
const p = document.createElement("div");
|
| 261 |
-
p.textContent = body;
|
| 262 |
-
Object.assign(p.style, { color: "var(--fg-2)", fontSize: "13px", lineHeight: "1.5" });
|
| 263 |
-
wrap.appendChild(p);
|
| 264 |
-
const row = document.createElement("div");
|
| 265 |
-
Object.assign(row.style, { display: "flex", justifyContent: "flex-end", gap: "8px" });
|
| 266 |
-
const cancel = document.createElement("button");
|
| 267 |
-
cancel.textContent = opts.cancelText || "Cancel";
|
| 268 |
-
Object.assign(cancel.style, { padding: "8px 14px", color: "var(--fg-2)" });
|
| 269 |
-
cancel.addEventListener("click", () => { close(); resolve(false); });
|
| 270 |
-
const ok = document.createElement("button");
|
| 271 |
-
ok.textContent = opts.okText || "Confirm";
|
| 272 |
-
Object.assign(ok.style, {
|
| 273 |
-
padding: "8px 14px",
|
| 274 |
-
borderRadius: "6px",
|
| 275 |
-
background: opts.danger ? "var(--danger)" : "var(--accent)",
|
| 276 |
-
color: "#0a0a0a",
|
| 277 |
-
fontWeight: "600",
|
| 278 |
-
});
|
| 279 |
-
ok.addEventListener("click", () => { close(); resolve(true); });
|
| 280 |
-
row.append(cancel, ok);
|
| 281 |
-
wrap.appendChild(row);
|
| 282 |
-
host.appendChild(wrap);
|
| 283 |
-
});
|
| 284 |
-
});
|
| 285 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|