Spaces:
Sleeping
Sleeping
Commit ·
a9e2f7f
0
Parent(s):
OpenCode Dashboard
Browse files- .dockerignore +5 -0
- README.md +15 -0
- main.py +324 -0
- 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">⚠</span><span id="ntxt"></span><button class="nx" onclick="hiden()">×</button></div>
|
| 134 |
+
|
| 135 |
+
<div class="sbar">
|
| 136 |
+
<div class="spc"></div>
|
| 137 |
+
<button onclick="tset()" title="Settings">⚙</button>
|
| 138 |
+
<button onclick="thelp()" title="Help">❓</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">◆</div>
|
| 152 |
+
<h1>OpenCode</h1>
|
| 153 |
+
<p>Create or open a project to start</p>
|
| 154 |
+
<div class="ln" id="hln">▶ 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()">← Dashboard</button>
|
| 169 |
+
<button onclick="tsess()" title="Toggle Sessions">☰</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()">×</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()">×</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 <goal></b> or <b>build <goal></b> - create a project from chat<br><b>delete <id></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}')">🗑</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>
|