asterioskryos commited on
Commit
a9e2f7f
·
0 Parent(s):

OpenCode Dashboard

Browse files
Files changed (4) hide show
  1. .dockerignore +5 -0
  2. README.md +15 -0
  3. main.py +324 -0
  4. static/index.html +431 -0
.dockerignore ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ .git
2
+ .gitignore
3
+ __pycache__
4
+ *.pyc
5
+ .DS_Store
README.md ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: OpenCode Dashboard
3
+ emoji: 🤖
4
+ colorFrom: indigo
5
+ colorTo: purple
6
+ sdk: docker
7
+ app_port: 7860
8
+ pinned: false
9
+ ---
10
+
11
+ # OpenCode Dashboard
12
+
13
+ Multi-project orchestrator for Hugging Face Spaces.
14
+
15
+ Set `HF_TOKEN` as a Space secret to enable Spaces management.
main.py ADDED
@@ -0,0 +1,324 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ import os
6
+ import uuid
7
+ from collections.abc import AsyncIterator
8
+ from dataclasses import dataclass
9
+ from datetime import datetime, timezone
10
+ from enum import Enum
11
+ from typing import Any
12
+
13
+ from fastapi import FastAPI, HTTPException
14
+ from fastapi.responses import HTMLResponse, StreamingResponse
15
+ from pydantic import BaseModel
16
+
17
+
18
+ # ── Types ─────────────────────────────────────────────────────
19
+
20
+ class State(str, Enum):
21
+ CREATED = "created"
22
+ PLANNING = "planning"
23
+ BUILDING = "building"
24
+ RUNNING = "running"
25
+ DONE = "done"
26
+ FAILED = "failed"
27
+
28
+
29
+ @dataclass
30
+ class Entry:
31
+ id: str
32
+ name: str
33
+ goal: str
34
+ state: State = State.CREATED
35
+ created: str = ""
36
+ error: str | None = None
37
+ space_id: str = ""
38
+ space_url: str = ""
39
+
40
+
41
+ # ── Event Bus ─────────────────────────────────────────────────
42
+
43
+ class Bus:
44
+ def __init__(self) -> None:
45
+ self._qs: list[asyncio.Queue[bytes]] = []
46
+
47
+ def sub(self) -> asyncio.Queue[bytes]:
48
+ q: asyncio.Queue[bytes] = asyncio.Queue(500)
49
+ self._qs.append(q)
50
+ return q
51
+
52
+ def unsub(self, q: asyncio.Queue[bytes]) -> None:
53
+ if q in self._qs:
54
+ self._qs.remove(q)
55
+
56
+ async def pub(self, kind: str, payload: dict[str, Any]) -> None:
57
+ msg = json.dumps({"cat": kind, "payload": payload, "ts": datetime.now(timezone.utc).isoformat()}).encode()
58
+ for q in self._qs:
59
+ try:
60
+ q.put_nowait(msg)
61
+ except asyncio.QueueFull:
62
+ pass
63
+
64
+ async def stream(self) -> AsyncIterator[bytes]:
65
+ q = self.sub()
66
+ try:
67
+ while True:
68
+ try:
69
+ yield b"data: " + await asyncio.wait_for(q.get(), 30) + b"\n\n"
70
+ except asyncio.TimeoutError:
71
+ yield b": keep\n\n"
72
+ finally:
73
+ self.unsub(q)
74
+
75
+
76
+ bus = Bus()
77
+
78
+
79
+ # ── Store ─────────────────────────────────────────────────────
80
+
81
+ class Store:
82
+ def __init__(self) -> None:
83
+ self._data: dict[str, Entry] = {}
84
+
85
+ async def add(self, goal: str, name: str | None = None) -> Entry:
86
+ e = Entry(
87
+ id=uuid.uuid4().hex[:12],
88
+ name=name or goal[:30],
89
+ goal=goal,
90
+ created=datetime.now(timezone.utc).isoformat(),
91
+ )
92
+ self._data[e.id] = e
93
+ await bus.pub("project.created", {"id": e.id, "goal": goal})
94
+ return e
95
+
96
+ def get(self, eid: str) -> Entry | None:
97
+ return self._data.get(eid)
98
+
99
+ def all(self) -> list[Entry]:
100
+ return list(self._data.values())
101
+
102
+ async def drop(self, eid: str) -> bool:
103
+ e = self._data.pop(eid, None)
104
+ if e:
105
+ await bus.pub("notification", {"message": f"Deleted {e.name}"})
106
+ return True
107
+ return False
108
+
109
+
110
+ store = Store()
111
+
112
+
113
+ # ── HF Manager ────────────────────────────────────────────────
114
+
115
+ class HF:
116
+ def __init__(self) -> None:
117
+ self._token: str | None = None
118
+ self._user: str | None = None
119
+ self._ok = False
120
+
121
+ async def setup(self) -> None:
122
+ self._token = os.environ.get("HF_TOKEN")
123
+ if not self._token:
124
+ await bus.pub("notification", {"message": "Set HF_TOKEN to enable Spaces"})
125
+ return
126
+ try:
127
+ import httpx
128
+ async with httpx.AsyncClient() as c:
129
+ r = await c.get("https://huggingface.co/api/whoami", headers={"Authorization": f"Bearer {self._token}"})
130
+ if r.is_success:
131
+ self._user = r.json().get("name", "")
132
+ self._ok = True
133
+ await bus.pub("notification", {"message": f"HF: {self._user}"})
134
+ except Exception:
135
+ pass
136
+
137
+ @property
138
+ def ready(self) -> bool:
139
+ return self._ok
140
+
141
+ @property
142
+ def user(self) -> str | None:
143
+ return self._user
144
+
145
+ async def create(self, name: str, secrets: dict[str, str] | None = None) -> dict[str, Any]:
146
+ if not self._ok:
147
+ return {"error": "no hf token"}
148
+ try:
149
+ import httpx
150
+ sn = name.lower().replace(" ", "-").replace("_", "-")
151
+ rid = f"{self._user}/{sn}"
152
+ async with httpx.AsyncClient() as c:
153
+ r = await c.post(
154
+ "https://huggingface.co/api/repos/create",
155
+ headers={"Authorization": f"Bearer {self._token}", "Content-Type": "application/json"},
156
+ json={"name": sn, "type": "space", "sdk": "docker"},
157
+ )
158
+ if not r.is_success:
159
+ return {"error": r.text[:200]}
160
+ if secrets:
161
+ for k, v in secrets.items():
162
+ await c.post(
163
+ f"https://huggingface.co/api/spaces/{rid}/secrets",
164
+ headers={"Authorization": f"Bearer {self._token}", "Content-Type": "application/json"},
165
+ json={"key": k, "value": v},
166
+ )
167
+ await bus.pub("notification", {"message": f"Space ready: {rid}"})
168
+ return {"space": rid, "url": f"https://huggingface.co/spaces/{rid}"}
169
+ except Exception as e:
170
+ return {"error": str(e)}
171
+
172
+ async def delete(self, name: str) -> dict[str, Any]:
173
+ if not self._ok:
174
+ return {"error": "no hf token"}
175
+ try:
176
+ import httpx
177
+ rid = f"{self._user}/{name}" if "/" not in name else name
178
+ async with httpx.AsyncClient() as c:
179
+ await c.delete(
180
+ f"https://huggingface.co/api/repos/{rid}",
181
+ headers={"Authorization": f"Bearer {self._token}"},
182
+ )
183
+ return {"ok": True}
184
+ except Exception as e:
185
+ return {"error": str(e)}
186
+
187
+ async def status(self) -> dict[str, Any]:
188
+ return {"ok": self._ok, "user": self._user or ""}
189
+
190
+
191
+ hf = HF()
192
+
193
+
194
+ # ── FastAPI ───────────────────────────────────────────────────
195
+
196
+ app = FastAPI(title="OpenCode")
197
+
198
+
199
+ @app.on_event("startup")
200
+ async def boot() -> None:
201
+ await hf.setup()
202
+
203
+
204
+ class ChatIn(BaseModel):
205
+ message: str
206
+
207
+ class CreateIn(BaseModel):
208
+ goal: str
209
+ name: str | None = None
210
+
211
+ class SettingsIn(BaseModel):
212
+ provider: str = ""
213
+ api_key: str = ""
214
+ model: str = ""
215
+
216
+ class SpaceIn(BaseModel):
217
+ name: str
218
+ secrets: dict[str, str] | None = None
219
+
220
+
221
+ @app.get("/")
222
+ async def root():
223
+ return HTMLResponse(open("static/index.html").read())
224
+
225
+
226
+ @app.get("/api/events")
227
+ async def events():
228
+ return StreamingResponse(bus.stream(), media_type="text/event-stream")
229
+
230
+
231
+ @app.post("/api/chat")
232
+ async def chat(body: ChatIn):
233
+ m = body.message.lower()
234
+
235
+ if m.startswith("create ") or m.startswith("build "):
236
+ goal = m[7:] if m.startswith("create ") else m[6:]
237
+ e = await store.add(goal)
238
+ return {"type": "created", "id": e.id, "goal": goal}
239
+
240
+ if m.startswith("delete "):
241
+ eid = m[7:].strip()
242
+ await store.drop(eid)
243
+ return {"type": "info", "message": f"Removed {eid}"}
244
+
245
+ return {"type": "info", "message": "Try: create <goal> or build <goal>"}
246
+
247
+
248
+ @app.get("/api/projects")
249
+ async def list_all():
250
+ return [
251
+ {"id": e.id, "name": e.name, "goal": e.goal, "status": e.state.value,
252
+ "created": e.created, "error": e.error, "space_url": e.space_url}
253
+ for e in store.all()
254
+ ]
255
+
256
+
257
+ @app.post("/api/projects")
258
+ async def new_project(body: CreateIn):
259
+ e = await store.add(body.goal, body.name)
260
+ sp = None
261
+ if hf.ready:
262
+ try:
263
+ sp = await hf.create(
264
+ body.name or f"oc-{e.id}",
265
+ {"HF_TOKEN": os.environ.get("HF_TOKEN", ""), "PROJECT": e.id},
266
+ )
267
+ if "error" not in sp:
268
+ e.space_id = sp["space"]
269
+ e.space_url = sp["url"]
270
+ except Exception:
271
+ pass
272
+ return {"id": e.id, "goal": e.goal, "space": sp}
273
+
274
+
275
+ @app.delete("/api/projects/{eid}")
276
+ async def delete_entry(eid: str):
277
+ e = store.get(eid)
278
+ if not e:
279
+ raise HTTPException(404)
280
+ if e.space_id and hf.ready:
281
+ await hf.delete(e.space_id)
282
+ await store.drop(eid)
283
+ return {"ok": True}
284
+
285
+
286
+ @app.get("/api/hf/status")
287
+ async def hf_stat():
288
+ return await hf.status()
289
+
290
+
291
+ @app.post("/api/hf/create")
292
+ async def hf_mk(body: SpaceIn):
293
+ r = await hf.create(body.name, body.secrets)
294
+ if "error" in r:
295
+ raise HTTPException(400, r["error"])
296
+ return r
297
+
298
+
299
+ @app.delete("/api/hf/space/{name}")
300
+ async def hf_rm(name: str):
301
+ r = await hf.delete(name)
302
+ if "error" in r:
303
+ raise HTTPException(400, r["error"])
304
+ return r
305
+
306
+
307
+ @app.post("/api/settings")
308
+ async def set_cfg(body: SettingsIn):
309
+ if body.api_key:
310
+ os.environ["OC_KEY"] = body.api_key
311
+ if body.provider:
312
+ os.environ["OC_PROVIDER"] = body.provider
313
+ if body.model:
314
+ os.environ["OC_MODEL"] = body.model
315
+ return {"ok": True}
316
+
317
+
318
+ @app.get("/api/settings")
319
+ async def get_cfg():
320
+ return {
321
+ "provider": os.environ.get("OC_PROVIDER", "openrouter"),
322
+ "model": os.environ.get("OC_MODEL", "gpt-4o"),
323
+ "token_set": bool(os.environ.get("HF_TOKEN")),
324
+ }
static/index.html ADDED
@@ -0,0 +1,431 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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.0">
6
+ <title>OpenCode</title>
7
+ <style>
8
+ *{margin:0;padding:0;box-sizing:border-box}
9
+ body{font-family:system-ui,-apple-system,sans-serif;background:#0b1120;color:#e2e8f0;height:100vh;display:flex;overflow:hidden}
10
+
11
+ /* ── Sticky Left Bar ── */
12
+ .sbar{width:42px;min-width:42px;background:#080e1a;border-right:1px solid #1e293b;display:flex;flex-direction:column;align-items:center;padding:6px 0;z-index:20}
13
+ .sbar .spc{flex:1}
14
+ .sbar button{width:32px;height:32px;border:none;border-radius:6px;background:transparent;color:#475569;cursor:pointer;font-size:16px;display:flex;align-items:center;justify-content:center;margin:2px 0}
15
+ .sbar button:hover{background:#1e293b;color:#e2e8f0}
16
+
17
+ /* ── Main ── */
18
+ .main{flex:1;display:flex;overflow:hidden}
19
+
20
+ /* ── Dashboard ── */
21
+ #dash{display:flex;width:100%;height:100%}
22
+ .dl{width:280px;min-width:280px;border-right:1px solid #1e293b;display:flex;flex-direction:column;background:#0b1120}
23
+ .dlh{padding:14px 16px;border-bottom:1px solid #1e293b;display:flex;justify-content:space-between;align-items:center}
24
+ .dlh h2{font-size:12px;font-weight:600;color:#64748b;text-transform:uppercase;letter-spacing:.6px}
25
+ .dlh button{padding:5px 12px;border:none;border-radius:5px;background:#6366f1;color:#fff;cursor:pointer;font-size:12px;font-weight:600}
26
+ .dlh button:hover{background:#4f46e5}
27
+ .pl{flex:1;overflow-y:auto;padding:8px}
28
+ .pi{padding:9px 12px;border-radius:6px;cursor:pointer;margin-bottom:3px;position:relative}
29
+ .pi:hover{background:#131d31}
30
+ .pi.act{background:#131d31;border-left:2px solid #6366f1}
31
+ .pi .nm{font-size:13px;font-weight:500}
32
+ .pi .st{font-size:11px;margin-top:1px}
33
+ .pi .st.s0{color:#64748b}
34
+ .pi .st.s1{color:#fbbf24}
35
+ .pi .st.s2{color:#60a5fa}
36
+ .pi .st.s3{color:#22d3ee}
37
+ .pi .st.s4{color:#34d399}
38
+ .pi .st.s5{color:#f87171}
39
+ .pi .del{position:absolute;top:6px;right:6px;background:none;border:none;color:#ef4444;cursor:pointer;font-size:13px;opacity:0;padding:2px 4px;border-radius:3px}
40
+ .pi:hover .del{opacity:1}
41
+ .pi .del:hover{background:rgba(239,68,68,.15)}
42
+
43
+ .dr{flex:1;display:flex;align-items:center;justify-content:center}
44
+ .hero{text-align:center}
45
+ .hero .ic{width:80px;height:80px;margin:0 auto 20px;background:#131d31;border:2px solid #1e293b;border-radius:16px;display:flex;align-items:center;justify-content:center;font-size:32px;color:#6366f1}
46
+ .hero h1{font-size:22px;font-weight:700;margin-bottom:6px}
47
+ .hero p{color:#64748b;font-size:13px;margin-bottom:8px}
48
+ .hero .ln{color:#6366f1;font-size:12px;font-family:monospace}
49
+
50
+ /* ── Project View ── */
51
+ #pview{display:none;width:100%;height:100%}
52
+ .pvi{display:flex;width:100%;height:100%}
53
+ .sp{width:260px;min-width:260px;border-right:1px solid #1e293b;display:flex;flex-direction:column;background:#0b1120;transition:margin-left .2s,width .2s,min-width .2s}
54
+ .sp.c{ margin-left:-260px;width:0;min-width:0;border-right:none}
55
+ .sph{padding:10px 14px;border-bottom:1px solid #1e293b;display:flex;justify-content:space-between;align-items:center}
56
+ .sph h3{font-size:11px;font-weight:600;color:#64748b;text-transform:uppercase;letter-spacing:.5px}
57
+ .sph button{background:none;border:none;color:#475569;cursor:pointer;font-size:15px;padding:2px 6px;border-radius:4px}
58
+ .sph button:hover{color:#e2e8f0;background:#1e293b}
59
+ .sl{flex:1;overflow-y:auto;padding:6px}
60
+ .si{padding:7px 10px;border-radius:5px;cursor:pointer;font-size:12px;color:#64748b;margin-bottom:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
61
+ .si:hover{background:#131d31;color:#e2e8f0}
62
+ .si.act{background:#131d31;color:#e2e8f0;border-left:2px solid #6366f1}
63
+
64
+ .ca{flex:1;display:flex;flex-direction:column;min-width:0}
65
+ .ct{padding:8px 14px;border-bottom:1px solid #1e293b;display:flex;align-items:center;gap:6px;background:#0b1120}
66
+ .ct button{background:none;border:none;color:#64748b;cursor:pointer;font-size:12px;padding:4px 8px;border-radius:5px;display:flex;align-items:center;gap:4px}
67
+ .ct button:hover{color:#e2e8f0;background:#131d31}
68
+ .ct .ttl{font-size:12px;font-weight:600;color:#94a3b8;margin-left:2px}
69
+
70
+ .cm{flex:1;overflow-y:auto;padding:14px;display:flex;flex-direction:column;gap:10px}
71
+ .msg{max-width:78%;padding:9px 13px;border-radius:10px;font-size:13px;line-height:1.5;white-space:pre-wrap}
72
+ .msg.u{align-self:flex-end;background:#6366f1;color:#fff;border-bottom-right-radius:3px}
73
+ .msg.a{align-self:flex-start;background:#131d31;color:#e2e8f0;border-bottom-left-radius:3px}
74
+ .msg.s{align-self:center;background:transparent;color:#475569;font-size:11px}
75
+
76
+ .ci{padding:10px 14px;border-top:1px solid #1e293b;background:#0b1120}
77
+ .cir{display:flex;gap:8px}
78
+ .cir input{flex:1;padding:9px 13px;border:1px solid #1e293b;border-radius:7px;background:#0b1120;color:#e2e8f0;font-size:13px;outline:none}
79
+ .cir input:focus{border-color:#6366f1}
80
+ .cir button{padding:9px 18px;border:none;border-radius:7px;background:#6366f1;color:#fff;cursor:pointer;font-weight:600;font-size:12px}
81
+ .cir button:hover{background:#4f46e5}
82
+
83
+ /* ── Notification ── */
84
+ #nbar{display:none;position:fixed;top:0;left:42px;right:0;background:#1e293b;border-bottom:1px solid #fbbf24;padding:7px 14px;z-index:100;font-size:12px;align-items:center;gap:8px}
85
+ #nbar.s{display:flex}
86
+ #nbar .nc{color:#fbbf24}
87
+ #nbar .nx{margin-left:auto;background:none;border:none;color:#475569;cursor:pointer;font-size:15px;padding:2px 6px;border-radius:4px}
88
+ #nbar .nx:hover{color:#e2e8f0;background:#334155}
89
+
90
+ /* ── Overlays ── */
91
+ .ov{display:none;position:fixed;top:0;left:42px;right:0;bottom:0;z-index:150}
92
+ .ov.s{display:block}
93
+ .ov .bg{position:absolute;inset:0;background:rgba(0,0,0,.45)}
94
+ .ov .pnl{position:absolute;top:0;left:0;bottom:0;width:380px;background:#131d31;border-right:1px solid #1e293b;padding:18px;overflow-y:auto}
95
+ .ov .pnl h2{font-size:14px;margin-bottom:14px;display:flex;align-items:center;gap:8px}
96
+ .ov .pnl h2 button{margin-left:auto;background:none;border:none;color:#475569;cursor:pointer;font-size:17px;padding:2px 6px;border-radius:4px}
97
+ .sec{margin-bottom:16px}
98
+ .sec h3{font-size:11px;color:#64748b;margin-bottom:6px;text-transform:uppercase;letter-spacing:.5px}
99
+ .sr{display:flex;justify-content:space-between;align-items:center;padding:6px 0;border-bottom:1px solid #0b1120;font-size:12px}
100
+ .sr .vl{color:#64748b;font-family:monospace;font-size:11px}
101
+ .sr select,.sr input{padding:4px 8px;border:1px solid #1e293b;border-radius:4px;background:#0b1120;color:#e2e8f0;font-size:11px;outline:none}
102
+ .sr select:focus,.sr input:focus{border-color:#6366f1}
103
+ .hi{padding:8px 0;border-bottom:1px solid #0b1120}
104
+ .hi h4{font-size:13px;margin-bottom:3px}
105
+ .hi p{font-size:12px;color:#64748b;line-height:1.5}
106
+
107
+ /* ── Modals ── */
108
+ .mod{display:none;position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:200;align-items:center;justify-content:center}
109
+ .mod.s{display:flex}
110
+ .mod .bx{background:#131d31;border:1px solid #1e293b;border-radius:10px;padding:20px;width:420px;max-height:75vh;overflow-y:auto}
111
+ .mod .bx h2{font-size:15px;margin-bottom:14px}
112
+ .fg{margin-bottom:10px}
113
+ .fg label{display:block;font-size:11px;color:#64748b;margin-bottom:3px}
114
+ .fg input,.fg textarea{width:100%;padding:7px 11px;border:1px solid #1e293b;border-radius:5px;background:#0b1120;color:#e2e8f0;font-size:12px;outline:none}
115
+ .fg textarea{resize:vertical;min-height:50px;font-family:inherit}
116
+ .fg input:focus,.fg textarea:focus{border-color:#6366f1}
117
+ .ma{display:flex;gap:8px;justify-content:flex-end;margin-top:14px}
118
+ .ma button{padding:7px 14px;border:none;border-radius:5px;cursor:pointer;font-weight:600;font-size:12px}
119
+ .bp{background:#6366f1;color:#fff}
120
+ .bp:hover{background:#4f46e5}
121
+ .bs{background:#1e293b;color:#e2e8f0}
122
+ .bs:hover{background:#334155}
123
+ .bd{background:#ef4444;color:#fff}
124
+ .bd:hover{background:#dc2626}
125
+
126
+ ::-webkit-scrollbar{width:5px}
127
+ ::-webkit-scrollbar-track{background:transparent}
128
+ ::-webkit-scrollbar-thumb{background:#1e293b;border-radius:3px}
129
+ </style>
130
+ </head>
131
+ <body>
132
+
133
+ <div id="nbar"><span class="nc">&#9888;</span><span id="ntxt"></span><button class="nx" onclick="hiden()">&times;</button></div>
134
+
135
+ <div class="sbar">
136
+ <div class="spc"></div>
137
+ <button onclick="tset()" title="Settings">&#9881;</button>
138
+ <button onclick="thelp()" title="Help">&#10067;</button>
139
+ </div>
140
+
141
+ <div class="main">
142
+
143
+ <!-- Dashboard -->
144
+ <div id="dash">
145
+ <div class="dl">
146
+ <div class="dlh"><h2>Projects</h2><button onclick="mcreate()">+ New</button></div>
147
+ <div class="pl" id="pl"></div>
148
+ </div>
149
+ <div class="dr">
150
+ <div class="hero">
151
+ <div class="ic">&#9670;</div>
152
+ <h1>OpenCode</h1>
153
+ <p>Create or open a project to start</p>
154
+ <div class="ln" id="hln">&#9654; Ready</div>
155
+ </div>
156
+ </div>
157
+ </div>
158
+
159
+ <!-- Project View -->
160
+ <div id="pview">
161
+ <div class="pvi">
162
+ <div class="sp" id="sp">
163
+ <div class="sph"><h3>Sessions</h3><button onclick="nsess()">+</button></div>
164
+ <div class="sl" id="sl"></div>
165
+ </div>
166
+ <div class="ca">
167
+ <div class="ct">
168
+ <button onclick="bdash()">&#8592; Dashboard</button>
169
+ <button onclick="tsess()" title="Toggle Sessions">&#9776;</button>
170
+ <span class="ttl" id="cttl">Project</span>
171
+ </div>
172
+ <div class="cm" id="cm"></div>
173
+ <div class="ci">
174
+ <div class="cir">
175
+ <input id="ci" placeholder="Type a message..." onkeydown="if(event.key==='Enter')send()">
176
+ <button onclick="send()">Send</button>
177
+ </div>
178
+ </div>
179
+ </div>
180
+ </div>
181
+ </div>
182
+
183
+ </div><!-- /main -->
184
+
185
+ <!-- Settings -->
186
+ <div class="ov" id="setov">
187
+ <div class="bg" onclick="tset()"></div>
188
+ <div class="pnl">
189
+ <h2>Settings <button onclick="tset()">&times;</button></h2>
190
+ <div class="sec">
191
+ <h3>Provider</h3>
192
+ <div class="sr"><span>Provider</span><select id="sprov" onchange="sv()"><option value="openrouter">OpenRouter</option><option value="nvidia">NVIDIA</option><option value="gemini">Gemini</option><option value="custom">Custom</option></select></div>
193
+ <div class="sr"><span>API Key</span><input type="password" id="sapi" placeholder="sk-..." style="width:170px" onchange="sv()"></div>
194
+ <div class="sr"><span>Model</span><select id="smod" onchange="sv()"><option value="gpt-4o">GPT-4o</option><option value="gpt-4o-mini">GPT-4o Mini</option><option value="claude-3.5-sonnet">Claude 3.5 Sonnet</option><option value="gemini-2.0-flash">Gemini 2.0 Flash</option></select></div>
195
+ </div>
196
+ <div class="sec">
197
+ <h3>Hugging Face</h3>
198
+ <div class="sr"><span>Token</span><span class="vl" id="hftok">-</span></div>
199
+ <div class="sr"><span>User</span><span class="vl" id="hfus">-</span></div>
200
+ </div>
201
+ </div>
202
+ </div>
203
+
204
+ <!-- Help -->
205
+ <div class="ov" id="hlpov">
206
+ <div class="bg" onclick="thelp()"></div>
207
+ <div class="pnl">
208
+ <h2>Help <button onclick="thelp()">&times;</button></h2>
209
+ <div class="hi"><h4>Getting Started</h4><p>Click "+ New" to create a project. Select it to open the chat. Type messages or commands to interact.</p></div>
210
+ <div class="hi"><h4>Commands</h4><p><b>create &lt;goal&gt;</b> or <b>build &lt;goal&gt;</b> - create a project from chat<br><b>delete &lt;id&gt;</b> - remove a project</p></div>
211
+ <div class="hi"><h4>Spaces</h4><p>Each project can have a Hugging Face Space. Set HF_TOKEN in Space secrets to enable this.</p></div>
212
+ <div class="hi"><h4>Settings</h4><p>Configure provider, API key, and model in the Settings panel. These apply across all projects.</p></div>
213
+ </div>
214
+ </div>
215
+
216
+ <!-- Create Modal -->
217
+ <div class="mod" id="cmod">
218
+ <div class="bx">
219
+ <h2>New Project</h2>
220
+ <div class="fg"><label>Goal</label><textarea id="cg" rows="2" placeholder="What do you want to build?"></textarea></div>
221
+ <div class="fg"><label>Name (optional)</label><input id="cn" placeholder="my-project"></div>
222
+ <div class="ma"><button class="bs" onclick="mcreatec()">Cancel</button><button class="bp" onclick="mk()">Create</button></div>
223
+ </div>
224
+ </div>
225
+
226
+ <!-- Delete Modal -->
227
+ <div class="mod" id="dmod">
228
+ <div class="bx">
229
+ <h2>Delete Project</h2>
230
+ <p style="font-size:13px;color:#64748b;margin-bottom:14px">This will also delete the associated HF Space.</p>
231
+ <div class="ma"><button class="bs" onclick="deldc()">Cancel</button><button class="bd" id="deldb" onclick="deld()">Delete</button></div>
232
+ </div>
233
+ </div>
234
+
235
+ <script>
236
+ // ── State ──
237
+ let cur = null
238
+ let cses = null
239
+ let sess = []
240
+ let msgs = []
241
+ let sc = false
242
+ let items = []
243
+ let dtar = null
244
+
245
+ // ── SSE ──
246
+ new EventSource('/api/events').onmessage = e => {
247
+ try {
248
+ const d = JSON.parse(e.data)
249
+ if (['notification','project.created','project.error','project.done'].includes(d.cat)) {
250
+ notif(d.payload?.message || d.cat)
251
+ }
252
+ } catch(_) {}
253
+ }
254
+
255
+ // ── Notifications ──
256
+ function notif(t) {
257
+ document.getElementById('ntxt').textContent = t
258
+ document.getElementById('nbar').classList.add('s')
259
+ }
260
+ function hiden() { document.getElementById('nbar').classList.remove('s') }
261
+
262
+ // ── Views ──
263
+ function dash() {
264
+ document.getElementById('dash').style.display = 'flex'
265
+ document.getElementById('pview').style.display = 'none'
266
+ cur = null
267
+ load()
268
+ }
269
+
270
+ function pview(id) {
271
+ cur = id
272
+ document.getElementById('dash').style.display = 'none'
273
+ document.getElementById('pview').style.display = 'flex'
274
+ const e = items.find(x => x.id === id)
275
+ document.getElementById('cttl').textContent = e ? e.goal : 'Project'
276
+ sess = [{id:'d',name:'Session 1'}]
277
+ cses = 'd'
278
+ msgs = []
279
+ rsess()
280
+ rmsgs()
281
+ addm('Agent ready. Type a message.', 's')
282
+ }
283
+ function bdash() { dash() }
284
+
285
+ // ── Projects ──
286
+ async function load() {
287
+ try {
288
+ const r = await fetch('/api/projects')
289
+ items = await r.json()
290
+ rlist()
291
+ } catch(_) {}
292
+ }
293
+
294
+ function rlist() {
295
+ const el = document.getElementById('pl')
296
+ if (!items.length) {
297
+ el.innerHTML = '<div style="color:#475569;font-size:12px;padding:16px;text-align:center">No projects yet.</div>'
298
+ return
299
+ }
300
+ const st = {created:'s0',planning:'s1',building:'s2',running:'s3',done:'s4',failed:'s5'}
301
+ el.innerHTML = items.map(e => `
302
+ <div class="pi ${e.id===cur?'act':''}" onclick="openp('${e.id}')">
303
+ <div class="nm">${esc(e.goal)}</div>
304
+ <div class="st ${st[e.status]||'s0'}">${e.status}${e.space_url?' \u2197':''}</div>
305
+ <button class="del" onclick="event.stopPropagation();deldm('${e.id}')">&#128465;</button>
306
+ </div>
307
+ `).join('')
308
+ const h = document.getElementById('hln')
309
+ if (items.length) h.textContent = items.length + ' project' + (items.length>1?'s':'') + ' \u2022 Ready'
310
+ else h.textContent = '\u25b6 Ready'
311
+ }
312
+
313
+ function openp(id) { pview(id) }
314
+
315
+ function mcreate() { document.getElementById('cmod').classList.add('s'); document.getElementById('cg').value=''; document.getElementById('cn').value='' }
316
+ function mcreatec() { document.getElementById('cmod').classList.remove('s') }
317
+
318
+ async function mk() {
319
+ const g = document.getElementById('cg').value.trim()
320
+ const n = document.getElementById('cn').value.trim()
321
+ if (!g) return
322
+ mcreatec()
323
+ try {
324
+ await fetch('/api/projects',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({goal:g,name:n||undefined})})
325
+ notif('Created: ' + g)
326
+ load()
327
+ } catch(_) {}
328
+ }
329
+
330
+ function deldm(id) { dtar=id; document.getElementById('dmod').classList.add('s') }
331
+ function deldc() { document.getElementById('dmod').classList.remove('s'); dtar=null }
332
+ async function deld() {
333
+ if (!dtar) return
334
+ deldc()
335
+ try {
336
+ await fetch('/api/projects/'+dtar,{method:'DELETE'})
337
+ if (cur===dtar) dash()
338
+ load()
339
+ } catch(_) {}
340
+ }
341
+
342
+ // ── Sessions ──
343
+ function rsess() {
344
+ document.getElementById('sl').innerHTML = sess.map(s =>
345
+ `<div class="si ${s.id===cses?'act':''}" onclick="ssel('${s.id}')">${esc(s.name)}</div>`
346
+ ).join('')
347
+ }
348
+
349
+ function ssel(id) {
350
+ cses = id
351
+ rsess()
352
+ msgs = []
353
+ rmsgs()
354
+ addm('Switched session.', 's')
355
+ }
356
+
357
+ function nsess() {
358
+ const id = 's'+Date.now().toString(36)
359
+ sess.push({id,name:'Session '+sess.length})
360
+ cses = id
361
+ rsess()
362
+ msgs = []
363
+ rmsgs()
364
+ addm('New session.', 's')
365
+ }
366
+
367
+ function tsess() {
368
+ sc = !sc
369
+ document.getElementById('sp').classList.toggle('c',sc)
370
+ }
371
+
372
+ // ── Chat ──
373
+ function addm(t, ty='a') { msgs.push({t,ty}); rmsgs() }
374
+ function rmsgs() {
375
+ const el = document.getElementById('cm')
376
+ el.innerHTML = msgs.map(m => `<div class="msg ${m.ty==='u'?'u':m.ty==='s'?'s':'a'}">${esc(m.t)}</div>`).join('')
377
+ el.scrollTop = el.scrollHeight
378
+ }
379
+
380
+ async function send() {
381
+ const inp = document.getElementById('ci')
382
+ const m = inp.value.trim()
383
+ if (!m) return
384
+ inp.value = ''
385
+ addm(m, 'u')
386
+ try {
387
+ const r = await fetch('/api/chat',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({message:m})})
388
+ const d = await r.json()
389
+ if (d.type==='created') {
390
+ addm('Project created: ' + d.goal, 's')
391
+ load()
392
+ } else {
393
+ addm(d.message || JSON.stringify(d), 'a')
394
+ }
395
+ } catch(e) { addm('Error: '+e.message, 'a') }
396
+ }
397
+
398
+ // ── Settings ──
399
+ function tset() {
400
+ const o = document.getElementById('setov')
401
+ o.classList.toggle('s')
402
+ if (o.classList.contains('s')) refreshSettings()
403
+ }
404
+ async function refreshSettings() {
405
+ try {
406
+ const r = await fetch('/api/settings')
407
+ const d = await r.json()
408
+ document.getElementById('hftok').textContent = d.token_set ? 'Set' : 'Not set'
409
+ const s = await fetch('/api/hf/status').then(r=>r.json()).catch(()=>({}))
410
+ document.getElementById('hfus').textContent = s.user || '-'
411
+ } catch(_) {}
412
+ }
413
+ function sv() {
414
+ fetch('/api/settings',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({
415
+ provider:document.getElementById('sprov').value,
416
+ api_key:document.getElementById('sapi').value,
417
+ model:document.getElementById('smod').value
418
+ })})
419
+ }
420
+
421
+ // ── Help ──
422
+ function thelp() { document.getElementById('hlpov').classList.toggle('s') }
423
+
424
+ // ── Util ──
425
+ function esc(s) { const d=document.createElement('div'); d.textContent=s; return d.innerHTML }
426
+
427
+ // ── Init ──
428
+ dash()
429
+ </script>
430
+ </body>
431
+ </html>