KiWA001 commited on
Commit
2a87f4c
Β·
1 Parent(s): ff06652

feat: Implement OpenCode Terminal Portal

Browse files
Files changed (8) hide show
  1. .gitignore +2 -0
  2. admin_router.py +233 -0
  3. config.py +13 -0
  4. opencode_terminal.py +326 -0
  5. package-lock.json +171 -0
  6. package.json +24 -0
  7. provider_state.py +18 -0
  8. static/qaz.html +419 -192
.gitignore CHANGED
@@ -8,3 +8,5 @@ KAI_API_DOCKER/
8
  zai_captured.json
9
  .vscode/
10
  debug_screenshots/
 
 
 
8
  zai_captured.json
9
  .vscode/
10
  debug_screenshots/
11
+ node_modules/
12
+ .opencode/
admin_router.py CHANGED
@@ -1129,3 +1129,236 @@ async def test_custom_proxy():
1129
  raise
1130
  except Exception as e:
1131
  raise HTTPException(status_code=500, detail=str(e))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1129
  raise
1130
  except Exception as e:
1131
  raise HTTPException(status_code=500, detail=str(e))
1132
+
1133
+
1134
+ # --- Saved Proxies Management ---
1135
+
1136
+ class ProxyCreateRequest(BaseModel):
1137
+ name: Optional[str] = None
1138
+ ip: str
1139
+ port: int
1140
+ protocol: str = "http"
1141
+ username: Optional[str] = None
1142
+ password: Optional[str] = None
1143
+ country: Optional[str] = None
1144
+ city: Optional[str] = None
1145
+ notes: Optional[str] = None
1146
+
1147
+ @router.get("/proxies")
1148
+ async def list_proxies():
1149
+ """Get all saved proxies from Supabase."""
1150
+ try:
1151
+ from db import get_supabase
1152
+
1153
+ supabase = get_supabase()
1154
+ if not supabase:
1155
+ return {"proxies": []}
1156
+
1157
+ res = supabase.table("kaiapi_proxies").select("*").order("created_at", desc=True).execute()
1158
+ return {"proxies": res.data or []}
1159
+ except Exception as e:
1160
+ logger.error(f"Failed to list proxies: {e}")
1161
+ return {"proxies": []}
1162
+
1163
+ @router.post("/proxies")
1164
+ async def create_proxy(req: ProxyCreateRequest):
1165
+ """Add a new proxy to Supabase."""
1166
+ try:
1167
+ from db import get_supabase
1168
+
1169
+ supabase = get_supabase()
1170
+ if not supabase:
1171
+ raise HTTPException(status_code=503, detail="Database unavailable")
1172
+
1173
+ proxy_data = {
1174
+ "name": req.name,
1175
+ "ip": req.ip,
1176
+ "port": req.port,
1177
+ "protocol": req.protocol,
1178
+ "username": req.username,
1179
+ "password": req.password,
1180
+ "country": req.country,
1181
+ "city": req.city,
1182
+ "notes": req.notes,
1183
+ "is_active": False
1184
+ }
1185
+
1186
+ res = supabase.table("kaiapi_proxies").insert(proxy_data).execute()
1187
+
1188
+ return {
1189
+ "status": "success",
1190
+ "message": "Proxy saved",
1191
+ "proxy": res.data[0] if res.data else None
1192
+ }
1193
+ except HTTPException:
1194
+ raise
1195
+ except Exception as e:
1196
+ raise HTTPException(status_code=500, detail=str(e))
1197
+
1198
+ @router.post("/proxies/{proxy_id}/activate")
1199
+ async def activate_proxy(proxy_id: int):
1200
+ """Activate a saved proxy."""
1201
+ try:
1202
+ from db import get_supabase
1203
+ from proxy_manager import get_proxy_manager
1204
+
1205
+ supabase = get_supabase()
1206
+ if not supabase:
1207
+ raise HTTPException(status_code=503, detail="Database unavailable")
1208
+
1209
+ res = supabase.table("kaiapi_proxies").select("*").eq("id", proxy_id).execute()
1210
+ if not res.data:
1211
+ raise HTTPException(status_code=404, detail="Proxy not found")
1212
+
1213
+ proxy = res.data[0]
1214
+
1215
+ # Deactivate all first
1216
+ supabase.table("kaiapi_proxies").update({"is_active": False}).neq("id", proxy_id).execute()
1217
+
1218
+ # Activate this one
1219
+ supabase.table("kaiapi_proxies").update({"is_active": True}).eq("id", proxy_id).execute()
1220
+
1221
+ # Set as current
1222
+ proxy_mgr = get_proxy_manager()
1223
+ proxy_str = f"{proxy['protocol']}://{proxy['ip']}:{proxy['port']}"
1224
+ proxy_mgr.set_custom_proxy(proxy_str, proxy.get("username"), proxy.get("password"))
1225
+
1226
+ return {"status": "success", "message": f"Proxy activated"}
1227
+ except HTTPException:
1228
+ raise
1229
+ except Exception as e:
1230
+ raise HTTPException(status_code=500, detail=str(e))
1231
+
1232
+ @router.delete("/proxies/{proxy_id}")
1233
+ async def delete_proxy(proxy_id: int):
1234
+ """Delete a saved proxy."""
1235
+ try:
1236
+ from db import get_supabase
1237
+
1238
+ supabase = get_supabase()
1239
+ if not supabase:
1240
+ raise HTTPException(status_code=503, detail="Database unavailable")
1241
+
1242
+ supabase.table("kaiapi_proxies").delete().eq("id", proxy_id).execute()
1243
+
1244
+ return {"status": "success", "message": "Proxy deleted"}
1245
+ except HTTPException:
1246
+ raise
1247
+ except Exception as e:
1248
+ raise HTTPException(status_code=500, detail=str(e))
1249
+
1250
+
1251
+ # --- OpenCode Terminal Portal ---
1252
+
1253
+ class TerminalInput(BaseModel):
1254
+ text: str
1255
+
1256
+ class TerminalKey(BaseModel):
1257
+ key: str
1258
+
1259
+ @router.post("/terminal/start")
1260
+ async def start_terminal(req: dict):
1261
+ """Start OpenCode terminal session."""
1262
+ try:
1263
+ from opencode_terminal import get_terminal_manager
1264
+
1265
+ model = req.get("model", "kimi-k2.5-free")
1266
+ manager = get_terminal_manager()
1267
+ portal = manager.get_portal(model)
1268
+
1269
+ if portal.is_running():
1270
+ return {
1271
+ "status": "already_running",
1272
+ "model": model,
1273
+ "message": f"Terminal for {model} is already running"
1274
+ }
1275
+
1276
+ await portal.initialize()
1277
+
1278
+ return {
1279
+ "status": "success",
1280
+ "model": model,
1281
+ "message": f"Terminal started for {model}"
1282
+ }
1283
+ except Exception as e:
1284
+ raise HTTPException(status_code=500, detail=str(e))
1285
+
1286
+ @router.get("/terminal/output")
1287
+ async def get_terminal_output(model: str = "kimi-k2.5-free", lines: int = 100):
1288
+ """Get recent terminal output."""
1289
+ try:
1290
+ from opencode_terminal import get_terminal_manager
1291
+
1292
+ manager = get_terminal_manager()
1293
+ portal = manager.get_portal(model)
1294
+
1295
+ if not portal.is_running():
1296
+ return {"lines": [], "status": "stopped"}
1297
+
1298
+ output_lines = portal.get_output(max_lines=lines)
1299
+
1300
+ # Format for frontend
1301
+ formatted_lines = [
1302
+ {"type": stream, "content": line}
1303
+ for stream, line in output_lines
1304
+ ]
1305
+
1306
+ return {
1307
+ "lines": formatted_lines,
1308
+ "status": "running"
1309
+ }
1310
+ except Exception as e:
1311
+ return {"lines": [], "error": str(e), "status": "error"}
1312
+
1313
+ @router.post("/terminal/input")
1314
+ async def send_terminal_input(req: TerminalInput, model: str = "kimi-k2.5-free"):
1315
+ """Send text input to terminal."""
1316
+ try:
1317
+ from opencode_terminal import get_terminal_manager
1318
+
1319
+ manager = get_terminal_manager()
1320
+ portal = manager.get_portal(model)
1321
+
1322
+ if not portal.is_running():
1323
+ raise HTTPException(status_code=400, detail="Terminal not running")
1324
+
1325
+ success = await portal.send_input(req.text)
1326
+
1327
+ return {"status": "success" if success else "error"}
1328
+ except Exception as e:
1329
+ raise HTTPException(status_code=500, detail=str(e))
1330
+
1331
+ @router.post("/terminal/key")
1332
+ async def send_terminal_key(req: TerminalKey, model: str = "kimi-k2.5-free"):
1333
+ """Send special key to terminal."""
1334
+ try:
1335
+ from opencode_terminal import get_terminal_manager
1336
+
1337
+ manager = get_terminal_manager()
1338
+ portal = manager.get_portal(model)
1339
+
1340
+ if not portal.is_running():
1341
+ raise HTTPException(status_code=400, detail="Terminal not running")
1342
+
1343
+ success = await portal.send_key(req.key)
1344
+
1345
+ return {"status": "success" if success else "error"}
1346
+ except Exception as e:
1347
+ raise HTTPException(status_code=500, detail=str(e))
1348
+
1349
+ @router.post("/terminal/close")
1350
+ async def close_terminal(req: dict):
1351
+ """Close terminal session."""
1352
+ try:
1353
+ from opencode_terminal import get_terminal_manager
1354
+
1355
+ model = req.get("model", "kimi-k2.5-free")
1356
+ manager = get_terminal_manager()
1357
+ portal = manager.get_portal(model)
1358
+
1359
+ await portal.close()
1360
+
1361
+ return {"status": "success", "message": "Terminal closed"}
1362
+ except Exception as e:
1363
+ raise HTTPException(status_code=500, detail=str(e))
1364
+
config.py CHANGED
@@ -31,6 +31,12 @@ MODEL_RANKING = [
31
  ("g4f-gpt-3.5-turbo", "g4f", "gpt-3.5-turbo"),
32
  ("g4f-claude-3-haiku", "g4f", "claude-3-haiku"),
33
  ("g4f-mixtral-8x7b", "g4f", "mixtral-8x7b"),
 
 
 
 
 
 
34
  ]
35
 
36
  # Request timeout in seconds per individual attempt
@@ -72,6 +78,7 @@ PROVIDERS = {
72
  "huggingchat": {"enabled": True, "name": "HuggingChat", "type": "browser"},
73
  "copilot": {"enabled": False, "name": "Microsoft Copilot", "type": "browser"},
74
  "chatgpt": {"enabled": False, "name": "ChatGPT", "type": "browser"},
 
75
  }
76
 
77
  # API Keys
@@ -100,4 +107,10 @@ PROVIDER_MODELS = {
100
  "pollinations-chickytutor",
101
  "pollinations-midijourney",
102
  ],
 
 
 
 
 
 
103
  }
 
31
  ("g4f-gpt-3.5-turbo", "g4f", "gpt-3.5-turbo"),
32
  ("g4f-claude-3-haiku", "g4f", "claude-3-haiku"),
33
  ("g4f-mixtral-8x7b", "g4f", "mixtral-8x7b"),
34
+
35
+ # Tier 4 β€” OpenCode Terminal Models (Free)
36
+ ("opencode-kimi-k2.5-free", "opencode", "kimi-k2.5-free"),
37
+ ("opencode-minimax-m2.5-free", "opencode", "minimax-m2.5-free"),
38
+ ("opencode-big-pickle", "opencode", "big-pickle"),
39
+ ("opencode-glm-4.7", "opencode", "glm-4.7"),
40
  ]
41
 
42
  # Request timeout in seconds per individual attempt
 
78
  "huggingchat": {"enabled": True, "name": "HuggingChat", "type": "browser"},
79
  "copilot": {"enabled": False, "name": "Microsoft Copilot", "type": "browser"},
80
  "chatgpt": {"enabled": False, "name": "ChatGPT", "type": "browser"},
81
+ "opencode": {"enabled": False, "name": "OpenCode Terminal", "type": "terminal"},
82
  }
83
 
84
  # API Keys
 
107
  "pollinations-chickytutor",
108
  "pollinations-midijourney",
109
  ],
110
+ "opencode": [
111
+ "opencode-kimi-k2.5-free",
112
+ "opencode-minimax-m2.5-free",
113
+ "opencode-big-pickle",
114
+ "opencode-glm-4.7",
115
+ ],
116
  }
opencode_terminal.py ADDED
@@ -0,0 +1,326 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ OpenCode Terminal Portal
3
+ -------------------------
4
+ Manages OpenCode terminal TUI as a provider.
5
+ Supports free models: Kimi K2.5 Free, MiniMax M2.5 Free, Big Pickle, GLM 4.7
6
+ """
7
+
8
+ import asyncio
9
+ import logging
10
+ import subprocess
11
+ import os
12
+ import json
13
+ from typing import Optional, Dict, Any, Callable
14
+ from dataclasses import dataclass
15
+ from datetime import datetime
16
+ import threading
17
+ import queue
18
+
19
+ logger = logging.getLogger("kai_api.terminal_portal")
20
+
21
+
22
+ @dataclass
23
+ class TerminalConfig:
24
+ """Configuration for OpenCode terminal."""
25
+ name: str
26
+ model: str # e.g., "kimi-k2.5-free", "minimax-m2.5-free"
27
+ project_dir: str = "."
28
+ config_path: str = ".opencode/config.json"
29
+
30
+
31
+ # Free models configuration
32
+ OPENCODE_MODELS = {
33
+ "kimi-k2.5-free": {
34
+ "name": "Kimi K2.5 Free",
35
+ "provider": "opencode-zen",
36
+ "description": "Moonshot AI's Kimi K2.5 - Free tier"
37
+ },
38
+ "minimax-m2.5-free": {
39
+ "name": "MiniMax M2.5 Free",
40
+ "provider": "opencode-zen",
41
+ "description": "MiniMax M2.5 - Free tier"
42
+ },
43
+ "big-pickle": {
44
+ "name": "Big Pickle",
45
+ "provider": "opencode-zen",
46
+ "description": "Stealth model - Free"
47
+ },
48
+ "glm-4.7": {
49
+ "name": "GLM 4.7",
50
+ "provider": "opencode-zen",
51
+ "description": "GLM 4.7 - Free tier"
52
+ }
53
+ }
54
+
55
+
56
+ class OpenCodeTerminalPortal:
57
+ """Manages OpenCode terminal TUI session."""
58
+
59
+ def __init__(self, config: TerminalConfig):
60
+ self.config = config
61
+ self.process: Optional[subprocess.Popen] = None
62
+ self.is_initialized = False
63
+ self.output_queue = queue.Queue()
64
+ self.input_queue = queue.Queue()
65
+ self.output_thread: Optional[threading.Thread] = None
66
+ self.on_output_callback: Optional[Callable] = None
67
+ self.screenshot_path = f"/tmp/opencode_{config.model}.png"
68
+ self.last_activity = None
69
+ self._keyboard_active = False
70
+
71
+ async def initialize(self):
72
+ """Initialize OpenCode terminal session."""
73
+ if self.is_initialized:
74
+ return
75
+
76
+ try:
77
+ logger.info(f"πŸš€ Starting OpenCode terminal with model: {self.config.model}")
78
+
79
+ # Ensure config directory exists
80
+ os.makedirs(".opencode", exist_ok=True)
81
+
82
+ # Create config file for this model
83
+ config_data = {
84
+ "$schema": "https://opencode.ai/config.json",
85
+ "theme": "opencode",
86
+ "provider": {
87
+ "opencode-zen": {
88
+ "npm": "@ai-sdk/openai-compatible",
89
+ "options": {
90
+ "baseURL": "https://opencode.ai/zen/v1"
91
+ },
92
+ "models": {
93
+ model: {"name": info["name"]}
94
+ for model, info in OPENCODE_MODELS.items()
95
+ }
96
+ }
97
+ },
98
+ "model": f"opencode-zen/{self.config.model}",
99
+ "autoshare": False,
100
+ "autoupdate": True
101
+ }
102
+
103
+ with open(self.config.config_path, 'w') as f:
104
+ json.dump(config_data, f, indent=2)
105
+
106
+ # Start OpenCode process
107
+ env = os.environ.copy()
108
+ env['OPENCODE_CONFIG'] = os.path.abspath(self.config.config_path)
109
+
110
+ self.process = subprocess.Popen(
111
+ ['npx', 'opencode-ai'],
112
+ cwd=self.config.project_dir,
113
+ env=env,
114
+ stdin=subprocess.PIPE,
115
+ stdout=subprocess.PIPE,
116
+ stderr=subprocess.PIPE,
117
+ text=True,
118
+ bufsize=1
119
+ )
120
+
121
+ # Start output reading thread
122
+ self.output_thread = threading.Thread(target=self._read_output)
123
+ self.output_thread.daemon = True
124
+ self.output_thread.start()
125
+
126
+ self.is_initialized = True
127
+ logger.info(f"βœ… OpenCode terminal ready with {self.config.model}")
128
+
129
+ await asyncio.sleep(2) # Give it time to start
130
+ await self.take_screenshot()
131
+
132
+ except Exception as e:
133
+ logger.error(f"Failed to initialize OpenCode terminal: {e}")
134
+ raise
135
+
136
+ def _read_output(self):
137
+ """Read output from OpenCode process in background thread."""
138
+ try:
139
+ while self.process and self.process.poll() is None:
140
+ # Read line from stdout
141
+ line = self.process.stdout.readline()
142
+ if line:
143
+ self.output_queue.put(('stdout', line))
144
+ if self.on_output_callback:
145
+ asyncio.create_task(self.on_output_callback('stdout', line))
146
+
147
+ # Check stderr
148
+ import select
149
+ if self.process.stderr in select.select([self.process.stderr], [], [], 0)[0]:
150
+ err_line = self.process.stderr.readline()
151
+ if err_line:
152
+ self.output_queue.put(('stderr', err_line))
153
+ except Exception as e:
154
+ logger.error(f"Error reading output: {e}")
155
+
156
+ async def send_input(self, text: str):
157
+ """Send text input to OpenCode."""
158
+ if not self.process or not self.is_initialized:
159
+ return False
160
+
161
+ try:
162
+ self.process.stdin.write(text + '\n')
163
+ self.process.stdin.flush()
164
+ self.last_activity = datetime.now()
165
+ return True
166
+ except Exception as e:
167
+ logger.error(f"Error sending input: {e}")
168
+ return False
169
+
170
+ async def send_key(self, key: str):
171
+ """Send a special key to OpenCode (e.g., 'ctrl+c', 'enter', 'tab')."""
172
+ if not self.process or not self.is_initialized:
173
+ return False
174
+
175
+ try:
176
+ # Map common keys
177
+ key_map = {
178
+ 'Enter': '\n',
179
+ 'Tab': '\t',
180
+ 'Escape': '\x1b',
181
+ 'Backspace': '\x7f',
182
+ 'Delete': '\x1b[3~',
183
+ 'ArrowUp': '\x1b[A',
184
+ 'ArrowDown': '\x1b[B',
185
+ 'ArrowLeft': '\x1b[D',
186
+ 'ArrowRight': '\x1b[C',
187
+ }
188
+
189
+ char = key_map.get(key, key)
190
+ self.process.stdin.write(char)
191
+ self.process.stdin.flush()
192
+ return True
193
+ except Exception as e:
194
+ logger.error(f"Error sending key: {e}")
195
+ return False
196
+
197
+ async def execute_command(self, command: str):
198
+ """Execute an OpenCode command (e.g., '/models', '/connect')."""
199
+ return await self.send_input(command)
200
+
201
+ async def take_screenshot(self) -> str:
202
+ """Take a screenshot of the terminal (if supported by terminal emulator)."""
203
+ # For now, we'll create a text-based representation
204
+ # In production, you could use a terminal emulator that supports screenshots
205
+ try:
206
+ # Get recent output
207
+ output_lines = []
208
+ while not self.output_queue.empty() and len(output_lines) < 50:
209
+ try:
210
+ stream, line = self.output_queue.get_nowait()
211
+ output_lines.append(line)
212
+ except queue.Empty:
213
+ break
214
+
215
+ # Create a simple text screenshot
216
+ screenshot_text = "\n".join(output_lines[-25:]) if output_lines else "Terminal ready..."
217
+
218
+ # Save to file
219
+ with open(self.screenshot_path, 'w') as f:
220
+ f.write(screenshot_text)
221
+
222
+ return self.screenshot_path
223
+ except Exception as e:
224
+ logger.error(f"Screenshot failed: {e}")
225
+ return ""
226
+
227
+ def get_output(self, max_lines: int = 100) -> list:
228
+ """Get recent output lines."""
229
+ lines = []
230
+ temp_queue = queue.Queue()
231
+
232
+ # Get lines from queue without removing them permanently
233
+ while not self.output_queue.empty() and len(lines) < max_lines:
234
+ try:
235
+ item = self.output_queue.get_nowait()
236
+ lines.append(item)
237
+ temp_queue.put(item)
238
+ except queue.Empty:
239
+ break
240
+
241
+ # Put them back
242
+ while not temp_queue.empty():
243
+ self.output_queue.put(temp_queue.get())
244
+
245
+ return lines
246
+
247
+ def set_keyboard_active(self, active: bool):
248
+ """Enable/disable keyboard input capture."""
249
+ self._keyboard_active = active
250
+ logger.info(f"Keyboard {'activated' if active else 'deactivated'} for OpenCode")
251
+
252
+ def is_keyboard_active(self) -> bool:
253
+ """Check if keyboard is active."""
254
+ return self._keyboard_active
255
+
256
+ def is_running(self) -> bool:
257
+ """Check if OpenCode is running."""
258
+ if not self.process:
259
+ return False
260
+ return self.process.poll() is None
261
+
262
+ async def close(self):
263
+ """Close the OpenCode terminal."""
264
+ try:
265
+ if self.process:
266
+ # Send exit command
267
+ try:
268
+ self.process.stdin.write('/exit\n')
269
+ self.process.stdin.flush()
270
+ await asyncio.sleep(1)
271
+ except:
272
+ pass
273
+
274
+ # Kill if still running
275
+ if self.process.poll() is None:
276
+ self.process.terminate()
277
+ await asyncio.sleep(2)
278
+ if self.process.poll() is None:
279
+ self.process.kill()
280
+
281
+ self.process = None
282
+
283
+ self.is_initialized = False
284
+ logger.info("OpenCode terminal closed")
285
+
286
+ except Exception as e:
287
+ logger.error(f"Error closing OpenCode: {e}")
288
+
289
+
290
+ class TerminalPortalManager:
291
+ """Manages multiple OpenCode terminal portals."""
292
+
293
+ def __init__(self):
294
+ self.portals: Dict[str, OpenCodeTerminalPortal] = {}
295
+
296
+ def get_portal(self, model: str) -> OpenCodeTerminalPortal:
297
+ """Get or create a portal for a specific model."""
298
+ if model not in self.portals:
299
+ if model not in OPENCODE_MODELS:
300
+ raise ValueError(f"Unknown OpenCode model: {model}")
301
+
302
+ config = TerminalConfig(
303
+ name=OPENCODE_MODELS[model]["name"],
304
+ model=model,
305
+ config_path=f".opencode/config_{model}.json"
306
+ )
307
+ self.portals[model] = OpenCodeTerminalPortal(config)
308
+
309
+ return self.portals[model]
310
+
311
+ def get_available_models(self) -> Dict[str, Dict]:
312
+ """Get all available OpenCode models."""
313
+ return OPENCODE_MODELS.copy()
314
+
315
+ async def close_all(self):
316
+ """Close all terminal portals."""
317
+ for portal in self.portals.values():
318
+ await portal.close()
319
+
320
+
321
+ # Global instance
322
+ _terminal_manager = TerminalPortalManager()
323
+
324
+ def get_terminal_manager() -> TerminalPortalManager:
325
+ """Get the global terminal manager."""
326
+ return _terminal_manager
package-lock.json ADDED
@@ -0,0 +1,171 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "kai_api",
3
+ "version": "1.0.0",
4
+ "lockfileVersion": 3,
5
+ "requires": true,
6
+ "packages": {
7
+ "": {
8
+ "name": "kai_api",
9
+ "version": "1.0.0",
10
+ "license": "ISC",
11
+ "dependencies": {
12
+ "opencode-ai": "^1.2.4"
13
+ }
14
+ },
15
+ "node_modules/opencode-ai": {
16
+ "version": "1.2.4",
17
+ "resolved": "https://registry.npmjs.org/opencode-ai/-/opencode-ai-1.2.4.tgz",
18
+ "integrity": "sha512-ZBC09OXhvGrgTOJoGWlj/Og+LMwStDlNad4t1ZctGr63vAYu8J3HrjIRrKrIH13Awd8yb+Nlbome9kSGRCyTgA==",
19
+ "hasInstallScript": true,
20
+ "license": "MIT",
21
+ "bin": {
22
+ "opencode": "bin/opencode"
23
+ },
24
+ "optionalDependencies": {
25
+ "opencode-darwin-arm64": "1.2.4",
26
+ "opencode-darwin-x64": "1.2.4",
27
+ "opencode-darwin-x64-baseline": "1.2.4",
28
+ "opencode-linux-arm64": "1.2.4",
29
+ "opencode-linux-arm64-musl": "1.2.4",
30
+ "opencode-linux-x64": "1.2.4",
31
+ "opencode-linux-x64-baseline": "1.2.4",
32
+ "opencode-linux-x64-baseline-musl": "1.2.4",
33
+ "opencode-linux-x64-musl": "1.2.4",
34
+ "opencode-windows-x64": "1.2.4",
35
+ "opencode-windows-x64-baseline": "1.2.4"
36
+ }
37
+ },
38
+ "node_modules/opencode-darwin-arm64": {
39
+ "version": "1.2.4",
40
+ "resolved": "https://registry.npmjs.org/opencode-darwin-arm64/-/opencode-darwin-arm64-1.2.4.tgz",
41
+ "integrity": "sha512-D0c6jIHNoA7EjUWgLTrR4ILvOqCThKpZTh9keUk3UXcMVpVC/yLCFxbwocz3qchBx8BJsYdh2S9YA59x8Mf4KQ==",
42
+ "cpu": [
43
+ "arm64"
44
+ ],
45
+ "optional": true,
46
+ "os": [
47
+ "darwin"
48
+ ]
49
+ },
50
+ "node_modules/opencode-darwin-x64": {
51
+ "version": "1.2.4",
52
+ "resolved": "https://registry.npmjs.org/opencode-darwin-x64/-/opencode-darwin-x64-1.2.4.tgz",
53
+ "integrity": "sha512-rJekyjC5JkMCCQnl1d6+Q8/16KVgRv4MvBW22l4n5gukxDfgzyCJ9yrd+Py9oMTElwRPjToN0M972gFx4Ubvzw==",
54
+ "cpu": [
55
+ "x64"
56
+ ],
57
+ "optional": true,
58
+ "os": [
59
+ "darwin"
60
+ ]
61
+ },
62
+ "node_modules/opencode-darwin-x64-baseline": {
63
+ "version": "1.2.4",
64
+ "resolved": "https://registry.npmjs.org/opencode-darwin-x64-baseline/-/opencode-darwin-x64-baseline-1.2.4.tgz",
65
+ "integrity": "sha512-/vSFe+DDSWC7i3rb/Nv8MYgGxrVWqm+CK2ikLkVSKdOdRm3I6EnRBmrp7idayechpMmuwsibJfvJgTU26iY1ug==",
66
+ "cpu": [
67
+ "x64"
68
+ ],
69
+ "optional": true,
70
+ "os": [
71
+ "darwin"
72
+ ]
73
+ },
74
+ "node_modules/opencode-linux-arm64": {
75
+ "version": "1.2.4",
76
+ "resolved": "https://registry.npmjs.org/opencode-linux-arm64/-/opencode-linux-arm64-1.2.4.tgz",
77
+ "integrity": "sha512-7fIAUxA0L1TsQjUy0/DR48N56DMKxi/5dzeuJkTkrBu1niwoYnkU7ZFS4eyIvBJ2ukNZ9WE1EHnPf12JrzEReQ==",
78
+ "cpu": [
79
+ "arm64"
80
+ ],
81
+ "optional": true,
82
+ "os": [
83
+ "linux"
84
+ ]
85
+ },
86
+ "node_modules/opencode-linux-arm64-musl": {
87
+ "version": "1.2.4",
88
+ "resolved": "https://registry.npmjs.org/opencode-linux-arm64-musl/-/opencode-linux-arm64-musl-1.2.4.tgz",
89
+ "integrity": "sha512-Y8yLaKWHv4iVuRbgfYne0BNYhZcXD07dV6MD6YbZ0im8uxwNR+l2eOc6ZYJFyMnt8yAC96nhqgfN6h9GpFQnzQ==",
90
+ "cpu": [
91
+ "arm64"
92
+ ],
93
+ "optional": true,
94
+ "os": [
95
+ "linux"
96
+ ]
97
+ },
98
+ "node_modules/opencode-linux-x64": {
99
+ "version": "1.2.4",
100
+ "resolved": "https://registry.npmjs.org/opencode-linux-x64/-/opencode-linux-x64-1.2.4.tgz",
101
+ "integrity": "sha512-G+JEHu8FfBYl9LCjAdF+AyrZxPrtpBxwEbj/v+h+9y4rYBtYHDD+4eIKUJP9pHjELcvpX70mGJoZ08DFr3XQhA==",
102
+ "cpu": [
103
+ "x64"
104
+ ],
105
+ "optional": true,
106
+ "os": [
107
+ "linux"
108
+ ]
109
+ },
110
+ "node_modules/opencode-linux-x64-baseline": {
111
+ "version": "1.2.4",
112
+ "resolved": "https://registry.npmjs.org/opencode-linux-x64-baseline/-/opencode-linux-x64-baseline-1.2.4.tgz",
113
+ "integrity": "sha512-P3eVKVopEtA7lc2PdmlMvM11ReLmJQh3KrgMogzV6AUHr6raU3eGRkwu6pvXYTrQ8cT9LmVrgeTIT+XmVfyrOg==",
114
+ "cpu": [
115
+ "x64"
116
+ ],
117
+ "optional": true,
118
+ "os": [
119
+ "linux"
120
+ ]
121
+ },
122
+ "node_modules/opencode-linux-x64-baseline-musl": {
123
+ "version": "1.2.4",
124
+ "resolved": "https://registry.npmjs.org/opencode-linux-x64-baseline-musl/-/opencode-linux-x64-baseline-musl-1.2.4.tgz",
125
+ "integrity": "sha512-ezEzQsZIjfJ6uJT2WegaX5XuPooHQGpJAoOcYolNtwavOYOxq9VmG3qvMHHWVlGazNWobevo9hKCoE3IBffETw==",
126
+ "cpu": [
127
+ "x64"
128
+ ],
129
+ "optional": true,
130
+ "os": [
131
+ "linux"
132
+ ]
133
+ },
134
+ "node_modules/opencode-linux-x64-musl": {
135
+ "version": "1.2.4",
136
+ "resolved": "https://registry.npmjs.org/opencode-linux-x64-musl/-/opencode-linux-x64-musl-1.2.4.tgz",
137
+ "integrity": "sha512-OCJyM4cN5ld2LkJEsNM5fEAPb2146JC0KCtsJ0v7zULyW2kSwDedK0hBerQYUcknY5QGfiQqmtSMqWXSckNLKw==",
138
+ "cpu": [
139
+ "x64"
140
+ ],
141
+ "optional": true,
142
+ "os": [
143
+ "linux"
144
+ ]
145
+ },
146
+ "node_modules/opencode-windows-x64": {
147
+ "version": "1.2.4",
148
+ "resolved": "https://registry.npmjs.org/opencode-windows-x64/-/opencode-windows-x64-1.2.4.tgz",
149
+ "integrity": "sha512-t8D+xl+Djqy0N0eV66FYzvOKGZ9xNe8HAJAD6/QeYD5AJ3turd8+M0F24M8oaDITU00GuZ0A3E80Xqv1s2/gWA==",
150
+ "cpu": [
151
+ "x64"
152
+ ],
153
+ "optional": true,
154
+ "os": [
155
+ "win32"
156
+ ]
157
+ },
158
+ "node_modules/opencode-windows-x64-baseline": {
159
+ "version": "1.2.4",
160
+ "resolved": "https://registry.npmjs.org/opencode-windows-x64-baseline/-/opencode-windows-x64-baseline-1.2.4.tgz",
161
+ "integrity": "sha512-fgI5EkoyS6Kc4wOOCPGnqG+az4h98RIUInGLsxv8bq5j3WOLfXKwEQuFEUWL+JiOcTb7lTcEYcBnoPzYU4yr8w==",
162
+ "cpu": [
163
+ "x64"
164
+ ],
165
+ "optional": true,
166
+ "os": [
167
+ "win32"
168
+ ]
169
+ }
170
+ }
171
+ }
package.json ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "kai_api",
3
+ "version": "1.0.0",
4
+ "description": "--- title: KAI API Gateway emoji: πŸ¦€ colorFrom: indigo colorTo: purple sdk: docker pinned: false app_port: 7860 ---",
5
+ "main": "index.js",
6
+ "scripts": {
7
+ "test": "echo \"Error: no test specified\" && exit 1"
8
+ },
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/KiWA001/kai-api-gateway.git"
12
+ },
13
+ "keywords": [],
14
+ "author": "",
15
+ "license": "ISC",
16
+ "type": "commonjs",
17
+ "bugs": {
18
+ "url": "https://github.com/KiWA001/kai-api-gateway/issues"
19
+ },
20
+ "homepage": "https://github.com/KiWA001/kai-api-gateway#readme",
21
+ "dependencies": {
22
+ "opencode-ai": "^1.2.4"
23
+ }
24
+ }
provider_state.py CHANGED
@@ -36,6 +36,7 @@ class ProviderStateManager:
36
 
37
  if res.data:
38
  # Load existing states
 
39
  for row in res.data:
40
  provider_id = row.get("provider_id")
41
  if provider_id:
@@ -44,6 +45,23 @@ class ProviderStateManager:
44
  "name": row.get("name", provider_id),
45
  "type": row.get("type", "api"),
46
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
47
  logger.info(f"βœ… Loaded {len(self._providers)} provider states from Supabase")
48
  else:
49
  # Initialize with defaults
 
36
 
37
  if res.data:
38
  # Load existing states
39
+ existing_ids = set()
40
  for row in res.data:
41
  provider_id = row.get("provider_id")
42
  if provider_id:
 
45
  "name": row.get("name", provider_id),
46
  "type": row.get("type", "api"),
47
  }
48
+ existing_ids.add(provider_id)
49
+
50
+ # Add any new providers from PROVIDERS that aren't in DB
51
+ for provider_id, config in PROVIDERS.items():
52
+ if provider_id not in existing_ids:
53
+ try:
54
+ supabase.table(TABLE_NAME).insert({
55
+ "provider_id": provider_id,
56
+ "enabled": config["enabled"],
57
+ "name": config["name"],
58
+ "type": config["type"]
59
+ }).execute()
60
+ self._providers[provider_id] = config.copy()
61
+ logger.info(f"βœ… Added new provider: {provider_id}")
62
+ except Exception as e:
63
+ logger.warning(f"Could not add provider {provider_id}: {e}")
64
+
65
  logger.info(f"βœ… Loaded {len(self._providers)} provider states from Supabase")
66
  else:
67
  # Initialize with defaults
static/qaz.html CHANGED
@@ -1,5 +1,6 @@
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, maximum-scale=1.0, user-scalable=no">
@@ -9,7 +10,7 @@
9
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
10
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
11
  <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
12
-
13
  <style>
14
  :root {
15
  --bg: #09090b;
@@ -25,18 +26,18 @@
25
  --warning: #f59e0b;
26
  --info: #3b82f6;
27
  }
28
-
29
  * {
30
  margin: 0;
31
  padding: 0;
32
  box-sizing: border-box;
33
  -webkit-tap-highlight-color: transparent;
34
  }
35
-
36
  html {
37
  font-size: 16px;
38
  }
39
-
40
  body {
41
  background: var(--bg);
42
  color: var(--text);
@@ -45,21 +46,21 @@
45
  line-height: 1.5;
46
  overflow-x: hidden;
47
  }
48
-
49
  /* Mobile-First Base Styles */
50
  .app-container {
51
  max-width: 100%;
52
  margin: 0 auto;
53
  padding: 12px;
54
  }
55
-
56
  @media (min-width: 768px) {
57
  .app-container {
58
  max-width: 1200px;
59
  padding: 20px;
60
  }
61
  }
62
-
63
  /* Header */
64
  .app-header {
65
  display: flex;
@@ -69,7 +70,7 @@
69
  border-bottom: 1px solid var(--border);
70
  margin-bottom: 20px;
71
  }
72
-
73
  .app-header h1 {
74
  font-size: 1.5rem;
75
  font-weight: 700;
@@ -78,28 +79,28 @@
78
  -webkit-text-fill-color: transparent;
79
  background-clip: text;
80
  }
81
-
82
  .header-actions {
83
  display: flex;
84
  gap: 8px;
85
  }
86
-
87
  @media (max-width: 480px) {
88
  .app-header h1 {
89
  font-size: 1.25rem;
90
  }
91
-
92
  .header-actions {
93
  flex-direction: column;
94
  gap: 4px;
95
  }
96
-
97
  .header-actions button {
98
  padding: 6px 12px;
99
  font-size: 12px;
100
  }
101
  }
102
-
103
  /* Cards */
104
  .card {
105
  background: var(--surface);
@@ -108,33 +109,33 @@
108
  padding: 16px;
109
  margin-bottom: 16px;
110
  }
111
-
112
  @media (min-width: 768px) {
113
  .card {
114
  padding: 24px;
115
  margin-bottom: 24px;
116
  }
117
  }
118
-
119
  .card-header {
120
  display: flex;
121
  justify-content: space-between;
122
  align-items: center;
123
  margin-bottom: 16px;
124
  }
125
-
126
  .card-title {
127
  font-size: 1.125rem;
128
  font-weight: 600;
129
  color: var(--text);
130
  }
131
-
132
  .card-subtitle {
133
  font-size: 0.875rem;
134
  color: var(--text-secondary);
135
  margin-top: 4px;
136
  }
137
-
138
  /* Buttons */
139
  .btn {
140
  display: inline-flex;
@@ -151,53 +152,53 @@
151
  white-space: nowrap;
152
  min-height: 40px;
153
  }
154
-
155
  .btn:active {
156
  transform: scale(0.98);
157
  }
158
-
159
  .btn-primary {
160
  background: var(--accent);
161
  color: white;
162
  }
163
-
164
  .btn-primary:hover {
165
  background: var(--accent-hover);
166
  }
167
-
168
  .btn-secondary {
169
  background: var(--surface-hover);
170
  color: var(--text);
171
  border: 1px solid var(--border);
172
  }
173
-
174
  .btn-secondary:hover {
175
  background: var(--border);
176
  }
177
-
178
  .btn-success {
179
  background: var(--success);
180
  color: white;
181
  }
182
-
183
  .btn-danger {
184
  background: transparent;
185
  color: var(--error);
186
  border: 1px solid var(--error);
187
  }
188
-
189
  .btn-icon {
190
  padding: 10px;
191
  min-width: 40px;
192
  }
193
-
194
  /* Input Fields */
195
  .input-group {
196
  display: flex;
197
  gap: 8px;
198
  flex-wrap: wrap;
199
  }
200
-
201
  .input-field {
202
  flex: 1;
203
  min-width: 200px;
@@ -209,16 +210,16 @@
209
  font-size: 0.9375rem;
210
  transition: border-color 0.2s ease;
211
  }
212
-
213
  .input-field:focus {
214
  outline: none;
215
  border-color: var(--accent);
216
  }
217
-
218
  .input-field::placeholder {
219
  color: var(--text-secondary);
220
  }
221
-
222
  /* Select */
223
  .select-field {
224
  padding: 12px 16px;
@@ -230,7 +231,7 @@
230
  cursor: pointer;
231
  min-width: 180px;
232
  }
233
-
234
  /* Browser Section */
235
  .browser-container {
236
  background: var(--bg);
@@ -238,7 +239,7 @@
238
  border-radius: 12px;
239
  overflow: hidden;
240
  }
241
-
242
  .browser-toolbar {
243
  display: flex;
244
  align-items: center;
@@ -248,14 +249,14 @@
248
  border-bottom: 1px solid var(--border);
249
  flex-wrap: wrap;
250
  }
251
-
252
  @media (max-width: 480px) {
253
  .browser-toolbar {
254
  padding: 8px;
255
  gap: 6px;
256
  }
257
  }
258
-
259
  .browser-nav-btn {
260
  width: 36px;
261
  height: 36px;
@@ -269,18 +270,18 @@
269
  border-radius: 8px;
270
  cursor: pointer;
271
  }
272
-
273
  .browser-nav-btn:active {
274
  background: var(--surface-hover);
275
  }
276
-
277
  .browser-address-bar {
278
  flex: 1;
279
  display: flex;
280
  gap: 6px;
281
  min-width: 200px;
282
  }
283
-
284
  .browser-address-input {
285
  flex: 1;
286
  padding: 8px 12px;
@@ -291,25 +292,39 @@
291
  font-size: 0.875rem;
292
  font-family: monospace;
293
  }
294
-
295
  .browser-address-input:focus {
296
  outline: none;
297
  border-color: var(--accent);
298
  }
299
-
300
  .browser-stream-container {
301
  position: relative;
302
  background: #000;
303
  aspect-ratio: 16/10;
304
  overflow: hidden;
305
  }
306
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
307
  .browser-stream {
308
  width: 100%;
309
  height: 100%;
310
  object-fit: contain;
311
  }
312
-
313
  .browser-overlay {
314
  position: absolute;
315
  top: 0;
@@ -318,7 +333,7 @@
318
  bottom: 0;
319
  cursor: crosshair;
320
  }
321
-
322
  .browser-loading {
323
  position: absolute;
324
  top: 50%;
@@ -330,7 +345,7 @@
330
  gap: 12px;
331
  color: var(--text);
332
  }
333
-
334
  .spinner {
335
  width: 40px;
336
  height: 40px;
@@ -339,11 +354,13 @@
339
  border-radius: 50%;
340
  animation: spin 1s linear infinite;
341
  }
342
-
343
  @keyframes spin {
344
- to { transform: rotate(360deg); }
 
 
345
  }
346
-
347
  .browser-status-bar {
348
  display: flex;
349
  justify-content: space-between;
@@ -354,14 +371,14 @@
354
  font-size: 0.75rem;
355
  color: var(--text-secondary);
356
  }
357
-
358
  .browser-controls {
359
  display: flex;
360
  gap: 8px;
361
  padding: 12px;
362
  flex-wrap: wrap;
363
  }
364
-
365
  /* Video Quality Selector */
366
  .quality-selector {
367
  display: flex;
@@ -371,7 +388,7 @@
371
  background: var(--bg);
372
  border-radius: 8px;
373
  }
374
-
375
  .quality-btn {
376
  padding: 4px 10px;
377
  border: 1px solid var(--border);
@@ -381,13 +398,13 @@
381
  font-size: 0.75rem;
382
  cursor: pointer;
383
  }
384
-
385
  .quality-btn.active {
386
  background: var(--accent);
387
  color: white;
388
  border-color: var(--accent);
389
  }
390
-
391
  /* On-Screen Keyboard */
392
  .osk-container {
393
  background: var(--surface);
@@ -397,14 +414,14 @@
397
  bottom: 0;
398
  z-index: 100;
399
  }
400
-
401
  .osk-row {
402
  display: flex;
403
  justify-content: center;
404
  gap: 4px;
405
  margin-bottom: 6px;
406
  }
407
-
408
  .osk-key {
409
  min-width: 32px;
410
  height: 42px;
@@ -422,21 +439,21 @@
422
  flex: 1;
423
  max-width: 50px;
424
  }
425
-
426
  .osk-key:active {
427
  background: var(--accent);
428
  }
429
-
430
  .osk-key.wide {
431
  flex: 2;
432
  max-width: 80px;
433
  }
434
-
435
  .osk-key.extra-wide {
436
  flex: 3;
437
  max-width: 120px;
438
  }
439
-
440
  @media (min-width: 768px) {
441
  .osk-key {
442
  min-width: 40px;
@@ -444,13 +461,13 @@
444
  font-size: 1rem;
445
  }
446
  }
447
-
448
  /* Provider Toggle Cards */
449
  .provider-grid {
450
  display: grid;
451
  gap: 12px;
452
  }
453
-
454
  .provider-card {
455
  display: flex;
456
  justify-content: space-between;
@@ -460,32 +477,32 @@
460
  border: 1px solid var(--border);
461
  border-radius: 12px;
462
  }
463
-
464
  .provider-info h3 {
465
  font-size: 1rem;
466
  font-weight: 600;
467
  margin-bottom: 4px;
468
  }
469
-
470
  .provider-info span {
471
  font-size: 0.75rem;
472
  color: var(--text-secondary);
473
  text-transform: uppercase;
474
  }
475
-
476
  /* Toggle Switch */
477
  .toggle-switch {
478
  position: relative;
479
  width: 52px;
480
  height: 28px;
481
  }
482
-
483
  .toggle-switch input {
484
  opacity: 0;
485
  width: 0;
486
  height: 0;
487
  }
488
-
489
  .toggle-slider {
490
  position: absolute;
491
  cursor: pointer;
@@ -497,7 +514,7 @@
497
  transition: .3s;
498
  border-radius: 28px;
499
  }
500
-
501
  .toggle-slider:before {
502
  position: absolute;
503
  content: "";
@@ -509,15 +526,15 @@
509
  transition: .3s;
510
  border-radius: 50%;
511
  }
512
-
513
- input:checked + .toggle-slider {
514
  background-color: var(--success);
515
  }
516
-
517
- input:checked + .toggle-slider:before {
518
  transform: translateX(24px);
519
  }
520
-
521
  /* Tables */
522
  .table-container {
523
  overflow-x: auto;
@@ -525,14 +542,14 @@
525
  margin: 0 -16px;
526
  padding: 0 16px;
527
  }
528
-
529
  table {
530
  width: 100%;
531
  border-collapse: collapse;
532
  font-size: 0.875rem;
533
  min-width: 600px;
534
  }
535
-
536
  th {
537
  text-align: left;
538
  padding: 12px;
@@ -541,25 +558,25 @@
541
  border-bottom: 1px solid var(--border);
542
  white-space: nowrap;
543
  }
544
-
545
  td {
546
  padding: 12px;
547
  border-bottom: 1px solid var(--border);
548
  }
549
-
550
  /* Stats Grid */
551
  .stats-grid {
552
  display: grid;
553
  grid-template-columns: repeat(2, 1fr);
554
  gap: 12px;
555
  }
556
-
557
  @media (min-width: 640px) {
558
  .stats-grid {
559
  grid-template-columns: repeat(4, 1fr);
560
  }
561
  }
562
-
563
  .stat-card {
564
  background: var(--bg);
565
  border: 1px solid var(--border);
@@ -567,40 +584,40 @@
567
  padding: 16px;
568
  text-align: center;
569
  }
570
-
571
  .stat-value {
572
  font-size: 1.75rem;
573
  font-weight: 700;
574
  color: var(--text);
575
  margin-bottom: 4px;
576
  }
577
-
578
  .stat-label {
579
  font-size: 0.75rem;
580
  color: var(--text-secondary);
581
  text-transform: uppercase;
582
  letter-spacing: 0.5px;
583
  }
584
-
585
  /* Progress Bar */
586
  .progress-container {
587
  margin-top: 8px;
588
  }
589
-
590
  .progress-bar {
591
  height: 6px;
592
  background: var(--surface-hover);
593
  border-radius: 3px;
594
  overflow: hidden;
595
  }
596
-
597
  .progress-fill {
598
  height: 100%;
599
  background: var(--success);
600
  border-radius: 3px;
601
  transition: width 0.3s ease;
602
  }
603
-
604
  /* Connection Status */
605
  .connection-status {
606
  display: flex;
@@ -608,30 +625,37 @@
608
  gap: 6px;
609
  font-size: 0.875rem;
610
  }
611
-
612
  .status-dot {
613
  width: 8px;
614
  height: 8px;
615
  border-radius: 50%;
616
  background: var(--text-secondary);
617
  }
618
-
619
  .status-dot.connected {
620
  background: var(--success);
621
  box-shadow: 0 0 8px var(--success);
622
  }
623
-
624
  .status-dot.streaming {
625
  background: var(--accent);
626
  box-shadow: 0 0 8px var(--accent);
627
  animation: pulse 1.5s infinite;
628
  }
629
-
630
  @keyframes pulse {
631
- 0%, 100% { opacity: 1; }
632
- 50% { opacity: 0.5; }
 
 
 
 
 
 
 
633
  }
634
-
635
  /* Modal for messages */
636
  .modal-backdrop {
637
  position: fixed;
@@ -639,18 +663,18 @@
639
  left: 0;
640
  right: 0;
641
  bottom: 0;
642
- background: rgba(0,0,0,0.8);
643
  display: none;
644
  align-items: center;
645
  justify-content: center;
646
  z-index: 1000;
647
  padding: 20px;
648
  }
649
-
650
  .modal-backdrop.active {
651
  display: flex;
652
  }
653
-
654
  .modal-content {
655
  background: var(--surface);
656
  border: 1px solid var(--border);
@@ -660,27 +684,28 @@
660
  width: 100%;
661
  text-align: center;
662
  }
663
-
664
  /* Scrollbar styling */
665
  ::-webkit-scrollbar {
666
  width: 8px;
667
  height: 8px;
668
  }
669
-
670
  ::-webkit-scrollbar-track {
671
  background: var(--bg);
672
  }
673
-
674
  ::-webkit-scrollbar-thumb {
675
  background: var(--border);
676
  border-radius: 4px;
677
  }
678
-
679
  ::-webkit-scrollbar-thumb:hover {
680
  background: var(--surface-hover);
681
  }
682
  </style>
683
  </head>
 
684
  <body>
685
  <div class="app-container">
686
  <!-- Header -->
@@ -715,7 +740,9 @@
715
  </tr>
716
  </thead>
717
  <tbody id="keys-list">
718
- <tr><td colspan="5" style="text-align:center;padding:20px;">Loading...</td></tr>
 
 
719
  </tbody>
720
  </table>
721
  </div>
@@ -728,7 +755,8 @@
728
  <input type="text" id="lookup-token" class="input-field" placeholder="Enter API key (sk-...)">
729
  <button class="btn btn-primary" onclick="lookupToken()">Check</button>
730
  </div>
731
- <div id="lookup-result" style="display:none;margin-top:16px;padding:16px;background:var(--bg);border-radius:10px;">
 
732
  <div style="display:flex;justify-content:space-between;margin-bottom:12px;">
733
  <span id="lookup-name" style="font-weight:600;">-</span>
734
  <span id="lookup-status">-</span>
@@ -758,17 +786,22 @@
758
  <span id="browser-status-text">Offline</span>
759
  </div>
760
  </div>
761
-
762
  <div class="input-group">
763
  <select id="portal-provider-select" class="select-field" onchange="onProviderChange()">
764
  <option value="">Select Provider...</option>
765
- <option value="copilot">πŸ€– Copilot</option>
766
- <option value="huggingchat">πŸ€— HuggingChat</option>
767
- <option value="chatgpt">πŸ’¬ ChatGPT</option>
768
- <option value="gemini">✨ Gemini</option>
769
- <option value="zai">🌟 Z.ai</option>
 
 
 
 
 
770
  </select>
771
- <button class="btn btn-primary" id="portal-start-btn" onclick="startBrowser()">Connect</button>
772
  </div>
773
  </div>
774
 
@@ -779,26 +812,32 @@
779
  <button class="browser-nav-btn" onclick="browserGoBack()" title="Back">←</button>
780
  <button class="browser-nav-btn" onclick="browserGoForward()" title="Forward">β†’</button>
781
  <button class="browser-nav-btn" onclick="browserRefresh()" title="Refresh">↻</button>
782
-
783
  <div class="browser-address-bar">
784
- <input type="text" id="browser-address" class="browser-address-input" placeholder="Enter URL..." onkeypress="if(event.key==='Enter')browserNavigate()">
 
785
  <button class="btn btn-primary btn-icon" onclick="browserNavigate()">Go</button>
786
  </div>
787
-
788
  <div class="quality-selector">
789
  <span style="font-size:0.75rem;color:var(--text-secondary);">Quality:</span>
790
- <button class="quality-btn active" onclick="setStreamQuality('low')" id="quality-low">Low</button>
791
- <button class="quality-btn" onclick="setStreamQuality('medium')" id="quality-medium">Med</button>
 
 
792
  <button class="quality-btn" onclick="setStreamQuality('high')" id="quality-high">High</button>
793
  </div>
794
-
795
  <button class="btn btn-danger btn-icon" onclick="closeBrowser()">βœ•</button>
796
  </div>
797
 
798
  <!-- Video Stream -->
799
  <div class="browser-stream-container">
800
- <img id="browser-stream" class="browser-stream" src="" alt="Browser Stream" onclick="handleStreamClick(event)" style="cursor: crosshair;">
801
-
 
 
 
802
  <div class="browser-loading" id="browser-loading" style="display:none;">
803
  <div class="spinner"></div>
804
  <span>Connecting...</span>
@@ -814,7 +853,9 @@
814
  <!-- Message Input -->
815
  <div style="padding:12px;border-top:1px solid var(--border);">
816
  <div class="input-group">
817
- <input type="text" id="browser-message" class="input-field" placeholder="Type message and press Enter..." onkeypress="if(event.key==='Enter')sendMessage()">
 
 
818
  <button class="btn btn-success" onclick="sendMessage()">Send</button>
819
  <button class="btn btn-secondary" onclick="toggleKeyboard()">⌨️ Keyboard</button>
820
  </div>
@@ -883,8 +924,10 @@
883
  <div style="display:flex;flex-direction:column;gap:10px;">
884
  <input type="text" id="proxy-input" class="input-field" placeholder="ip:port or http://ip:port">
885
  <div style="display:flex;gap:10px;">
886
- <input type="text" id="proxy-username" class="input-field" placeholder="Username (optional)" style="flex:1;">
887
- <input type="password" id="proxy-password" class="input-field" placeholder="Password (optional)" style="flex:1;">
 
 
888
  </div>
889
  <div style="display:flex;gap:10px;">
890
  <button class="btn btn-primary" onclick="setProxy()" style="flex:1;">Set Proxy</button>
@@ -892,7 +935,21 @@
892
  <button class="btn btn-success" onclick="testProxy()" style="flex:1;">Test</button>
893
  </div>
894
  </div>
895
- <div id="proxy-status" style="margin-top:12px;font-size:0.875rem;color:var(--text-secondary);display:none;"></div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
896
  </div>
897
  </div>
898
 
@@ -917,16 +974,16 @@
917
  const res = await fetch('/qaz/keys');
918
  const keys = await res.json();
919
  const tbody = document.getElementById('keys-list');
920
-
921
  if (keys.length === 0) {
922
  tbody.innerHTML = '<tr><td colspan="5" style="text-align:center;padding:20px;color:var(--text-secondary);">No keys found</td></tr>';
923
  return;
924
  }
925
-
926
  tbody.innerHTML = keys.map(key => {
927
  const percent = key.limit_tokens > 0 ? Math.round((key.usage_tokens / key.limit_tokens) * 100) : 0;
928
  const color = percent > 90 ? 'var(--error)' : percent > 50 ? 'var(--warning)' : 'var(--success)';
929
-
930
  return `
931
  <tr>
932
  <td style="font-weight:500;">${key.name}</td>
@@ -954,14 +1011,14 @@
954
  async function createKey() {
955
  const name = prompt('Enter name for new API key:');
956
  if (!name) return;
957
-
958
  try {
959
  const res = await fetch('/qaz/keys', {
960
  method: 'POST',
961
  headers: { 'Content-Type': 'application/json' },
962
  body: JSON.stringify({ name, limit_tokens: 1000000 })
963
  });
964
-
965
  if (res.ok) {
966
  const key = await res.json();
967
  alert(`βœ… Key created!\n\nToken: ${key.token}\n\nSave this now!`);
@@ -986,21 +1043,21 @@
986
  async function lookupToken() {
987
  const token = document.getElementById('lookup-token').value.trim();
988
  if (!token) return;
989
-
990
  try {
991
  const res = await fetch('/qaz/keys/lookup', {
992
  method: 'POST',
993
  headers: { 'Content-Type': 'application/json' },
994
  body: JSON.stringify({ token })
995
  });
996
-
997
  if (!res.ok) throw new Error('Key not found');
998
-
999
  const data = await res.json();
1000
  const percent = Math.round((data.usage_tokens / data.limit_tokens) * 100);
1001
  const remaining = data.limit_tokens - data.usage_tokens;
1002
  const color = percent > 90 ? 'var(--error)' : percent > 50 ? 'var(--warning)' : 'var(--success)';
1003
-
1004
  document.getElementById('lookup-result').style.display = 'block';
1005
  document.getElementById('lookup-name').textContent = data.name;
1006
  document.getElementById('lookup-status').innerHTML = `<span style="color:${data.is_active ? 'var(--success)' : 'var(--error)'}">${data.is_active ? '● Active' : '● Inactive'}</span>`;
@@ -1018,10 +1075,10 @@
1018
  try {
1019
  const res = await fetch('/qaz/portal/status');
1020
  const data = await res.json();
1021
-
1022
  const active = data.providers.filter(p => p.is_running);
1023
  const container = document.getElementById('active-browsers');
1024
-
1025
  if (active.length > 0) {
1026
  container.style.display = 'block';
1027
  container.innerHTML = `
@@ -1045,30 +1102,23 @@
1045
  currentProvider = document.getElementById('portal-provider-select').value;
1046
  }
1047
 
1048
- async function startBrowser() {
1049
  const provider = document.getElementById('portal-provider-select').value;
1050
  if (!provider) {
1051
  alert('Select a provider first');
1052
  return;
1053
  }
1054
-
1055
  const btn = document.getElementById('portal-start-btn');
1056
  btn.disabled = true;
1057
  btn.textContent = 'Connecting...';
1058
-
1059
  try {
1060
- const res = await fetch('/qaz/portal/start', {
1061
- method: 'POST',
1062
- headers: { 'Content-Type': 'application/json' },
1063
- body: JSON.stringify({ provider })
1064
- });
1065
-
1066
- const data = await res.json();
1067
- if (data.status === 'success' || data.status === 'already_running') {
1068
- currentProvider = provider;
1069
- showBrowserInterface();
1070
- startStream();
1071
- checkBrowserStatus();
1072
  }
1073
  } catch (e) {
1074
  alert('Error: ' + e.message);
@@ -1078,25 +1128,117 @@
1078
  }
1079
  }
1080
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1081
  function showBrowserInterface() {
1082
  document.getElementById('browser-interface').style.display = 'block';
 
 
 
 
 
 
 
1083
  document.getElementById('browser-status-dot').classList.add('connected');
1084
  document.getElementById('browser-status-text').textContent = 'Connected';
1085
  }
1086
 
1087
  function startStream() {
1088
  if (streamInterval) clearInterval(streamInterval);
1089
-
1090
  const img = document.getElementById('browser-stream');
1091
  const quality = streamQuality === 'low' ? 0.5 : streamQuality === 'medium' ? 0.75 : 1;
1092
  let frameCount = 0;
1093
-
1094
  // Preload image to prevent flickering
1095
  const updateFrame = () => {
1096
  if (!currentProvider) return;
1097
-
1098
  const newSrc = `/qaz/portal/${currentProvider}/screenshot?t=${Date.now()}&quality=${quality}&frame=${frameCount++}`;
1099
-
1100
  // Create a new image to preload
1101
  const preloadImg = new Image();
1102
  preloadImg.onload = () => {
@@ -1108,11 +1250,11 @@
1108
  };
1109
  preloadImg.src = newSrc;
1110
  };
1111
-
1112
  // Update every 1000ms (1 second) for better performance
1113
  streamInterval = setInterval(updateFrame, 1000);
1114
  updateFrame();
1115
-
1116
  document.getElementById('browser-status-dot').classList.add('streaming');
1117
  console.log('Stream started for provider:', currentProvider);
1118
  }
@@ -1121,7 +1263,7 @@
1121
  streamQuality = quality;
1122
  document.querySelectorAll('.quality-btn').forEach(btn => btn.classList.remove('active'));
1123
  document.getElementById(`quality-${quality}`).classList.add('active');
1124
-
1125
  // Restart stream with new quality
1126
  if (currentProvider) {
1127
  startStream();
@@ -1148,9 +1290,9 @@
1148
  if (!currentProvider) return;
1149
  let url = document.getElementById('browser-address').value.trim();
1150
  if (!url) return;
1151
-
1152
  if (!url.startsWith('http')) url = 'https://' + url;
1153
-
1154
  await fetch(`/qaz/portal/${currentProvider}/navigate`, {
1155
  method: 'POST',
1156
  headers: { 'Content-Type': 'application/json' },
@@ -1163,16 +1305,16 @@
1163
  console.log('No provider selected');
1164
  return;
1165
  }
1166
-
1167
  const rect = event.target.getBoundingClientRect();
1168
  const scaleX = 1280 / rect.width;
1169
  const scaleY = 800 / rect.height;
1170
  const x = Math.round((event.clientX - rect.left) * scaleX);
1171
  const y = Math.round((event.clientY - rect.top) * scaleY);
1172
-
1173
  console.log(`Click at ${x}, ${y} on ${currentProvider}`);
1174
  document.getElementById('browser-coords').textContent = `Click: ${x}, ${y}`;
1175
-
1176
  // Visual feedback
1177
  const img = event.target;
1178
  const feedback = document.createElement('div');
@@ -1191,7 +1333,7 @@
1191
  `;
1192
  img.parentElement.appendChild(feedback);
1193
  setTimeout(() => feedback.remove(), 500);
1194
-
1195
  // Send click to backend
1196
  fetch('/qaz/portal/action', {
1197
  method: 'POST',
@@ -1215,16 +1357,28 @@
1215
 
1216
  async function sendMessage() {
1217
  if (!currentProvider) return;
1218
- const msg = document.getElementById('browser-message').value.trim();
1219
- if (!msg) return;
1220
-
1221
- await fetch(`/qaz/portal/${currentProvider}/send`, {
1222
- method: 'POST',
1223
- headers: { 'Content-Type': 'application/json' },
1224
- body: JSON.stringify({ message: msg })
1225
- });
1226
-
1227
- document.getElementById('browser-message').value = '';
 
 
 
 
 
 
 
 
 
 
 
 
1228
  }
1229
 
1230
  function toggleKeyboard() {
@@ -1235,7 +1389,7 @@
1235
  // On-Screen Keyboard
1236
  function oskType(char) {
1237
  if (char === '123') return; // Switch to numbers (not implemented yet)
1238
-
1239
  const input = document.getElementById('browser-message');
1240
  if (oskShift && char.length === 1 && char.match(/[a-z]/i)) {
1241
  char = char.toUpperCase();
@@ -1263,50 +1417,58 @@
1263
  async function closeBrowser() {
1264
  if (!currentProvider) return;
1265
  if (!confirm('Close browser?')) return;
1266
-
1267
- await fetch(`/qaz/portal/${currentProvider}/close`, { method: 'POST' });
1268
-
 
 
 
 
 
 
 
 
1269
  if (streamInterval) {
1270
  clearInterval(streamInterval);
1271
  streamInterval = null;
1272
  }
1273
-
1274
  document.getElementById('browser-interface').style.display = 'none';
1275
  document.getElementById('browser-status-dot').classList.remove('connected', 'streaming');
1276
  document.getElementById('browser-status-text').textContent = 'Offline';
1277
  document.getElementById('osk').style.display = 'none';
1278
  currentProvider = null;
1279
-
1280
  checkBrowserStatus();
1281
  }
1282
 
1283
  // Provider Toggles
1284
  async function loadProviders() {
1285
  const container = document.getElementById('providers-list');
1286
-
1287
  try {
1288
  console.log('Loading providers...');
1289
  const res = await fetch('/qaz/providers');
1290
-
1291
  if (!res.ok) {
1292
  const errorText = await res.text();
1293
  throw new Error(`HTTP ${res.status}: ${errorText}`);
1294
  }
1295
-
1296
  const data = await res.json();
1297
  console.log('Providers loaded:', data);
1298
-
1299
  if (!data.providers || data.providers.length === 0) {
1300
  container.innerHTML = '<div style="padding:20px;text-align:center;color:var(--text-secondary);">No providers configured. Please initialize the database.</div>';
1301
  return;
1302
  }
1303
-
1304
  // Sort providers: enabled first, then by name
1305
  const sortedProviders = data.providers.sort((a, b) => {
1306
  if (a.enabled !== b.enabled) return b.enabled - a.enabled;
1307
  return a.name.localeCompare(b.name);
1308
  });
1309
-
1310
  container.innerHTML = sortedProviders.map(p => `
1311
  <div class="provider-card" style="background:${p.enabled ? 'var(--surface)' : 'var(--bg)'};border-left:4px solid ${p.enabled ? 'var(--success)' : 'var(--border)'};">
1312
  <div class="provider-info">
@@ -1331,7 +1493,7 @@
1331
  event.preventDefault();
1332
  event.stopPropagation();
1333
  }
1334
-
1335
  try {
1336
  console.log(`Toggling provider ${id} to ${enabled}`);
1337
  const res = await fetch('/qaz/providers/toggle', {
@@ -1339,18 +1501,18 @@
1339
  headers: { 'Content-Type': 'application/json' },
1340
  body: JSON.stringify({ provider_id: id, enabled })
1341
  });
1342
-
1343
  if (!res.ok) {
1344
  const error = await res.json();
1345
  throw new Error(error.detail || 'Failed to toggle');
1346
  }
1347
-
1348
  const data = await res.json();
1349
  console.log('Toggle success:', data);
1350
-
1351
  // Show success feedback
1352
  showProxyStatus(`Provider ${enabled ? 'enabled' : 'disabled'}`);
1353
-
1354
  } catch (e) {
1355
  console.error('Toggle error:', e);
1356
  alert('Error: ' + e.message);
@@ -1366,17 +1528,17 @@
1366
  alert('Please enter a proxy address');
1367
  return;
1368
  }
1369
-
1370
  const username = document.getElementById('proxy-username').value.trim() || null;
1371
  const password = document.getElementById('proxy-password').value || null;
1372
-
1373
  try {
1374
  const res = await fetch('/qaz/proxy/set', {
1375
  method: 'POST',
1376
  headers: { 'Content-Type': 'application/json' },
1377
  body: JSON.stringify({ proxy, username, password })
1378
  });
1379
-
1380
  const data = await res.json();
1381
  if (data.status === 'success') {
1382
  showProxyStatus(`βœ… Proxy set: ${data.proxy}${data.has_auth ? ' (with auth)' : ''}`);
@@ -1402,7 +1564,7 @@
1402
  showProxyStatus('πŸ§ͺ Testing proxy...');
1403
  const res = await fetch('/qaz/proxy/test', { method: 'POST' });
1404
  const data = await res.json();
1405
-
1406
  if (data.is_working) {
1407
  showProxyStatus(`βœ… Proxy working! Response time: ${data.response_time}`);
1408
  } else {
@@ -1424,7 +1586,7 @@
1424
  try {
1425
  const res = await fetch('/qaz/proxy/status');
1426
  const data = await res.json();
1427
-
1428
  if (data.enabled) {
1429
  document.getElementById('proxy-input').value = data.proxy;
1430
  if (data.username) {
@@ -1437,12 +1599,76 @@
1437
  }
1438
  }
1439
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1440
  // Admin Actions
1441
  async function testAllAI() {
1442
  const btn = event.target;
1443
  btn.disabled = true;
1444
  btn.textContent = 'Testing...';
1445
-
1446
  try {
1447
  const res = await fetch('/qaz/test_all', { method: 'POST' });
1448
  const results = await res.json();
@@ -1458,7 +1684,7 @@
1458
 
1459
  async function clearStats() {
1460
  if (!confirm('Clear all statistics?')) return;
1461
-
1462
  try {
1463
  await fetch('/qaz/clear_stats', { method: 'POST' });
1464
  alert('Statistics cleared');
@@ -1476,4 +1702,5 @@
1476
  }
1477
  </script>
1478
  </body>
1479
- </html>
 
 
1
  <!DOCTYPE html>
2
  <html lang="en">
3
+
4
  <head>
5
  <meta charset="UTF-8">
6
  <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
 
10
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
11
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
12
  <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
13
+
14
  <style>
15
  :root {
16
  --bg: #09090b;
 
26
  --warning: #f59e0b;
27
  --info: #3b82f6;
28
  }
29
+
30
  * {
31
  margin: 0;
32
  padding: 0;
33
  box-sizing: border-box;
34
  -webkit-tap-highlight-color: transparent;
35
  }
36
+
37
  html {
38
  font-size: 16px;
39
  }
40
+
41
  body {
42
  background: var(--bg);
43
  color: var(--text);
 
46
  line-height: 1.5;
47
  overflow-x: hidden;
48
  }
49
+
50
  /* Mobile-First Base Styles */
51
  .app-container {
52
  max-width: 100%;
53
  margin: 0 auto;
54
  padding: 12px;
55
  }
56
+
57
  @media (min-width: 768px) {
58
  .app-container {
59
  max-width: 1200px;
60
  padding: 20px;
61
  }
62
  }
63
+
64
  /* Header */
65
  .app-header {
66
  display: flex;
 
70
  border-bottom: 1px solid var(--border);
71
  margin-bottom: 20px;
72
  }
73
+
74
  .app-header h1 {
75
  font-size: 1.5rem;
76
  font-weight: 700;
 
79
  -webkit-text-fill-color: transparent;
80
  background-clip: text;
81
  }
82
+
83
  .header-actions {
84
  display: flex;
85
  gap: 8px;
86
  }
87
+
88
  @media (max-width: 480px) {
89
  .app-header h1 {
90
  font-size: 1.25rem;
91
  }
92
+
93
  .header-actions {
94
  flex-direction: column;
95
  gap: 4px;
96
  }
97
+
98
  .header-actions button {
99
  padding: 6px 12px;
100
  font-size: 12px;
101
  }
102
  }
103
+
104
  /* Cards */
105
  .card {
106
  background: var(--surface);
 
109
  padding: 16px;
110
  margin-bottom: 16px;
111
  }
112
+
113
  @media (min-width: 768px) {
114
  .card {
115
  padding: 24px;
116
  margin-bottom: 24px;
117
  }
118
  }
119
+
120
  .card-header {
121
  display: flex;
122
  justify-content: space-between;
123
  align-items: center;
124
  margin-bottom: 16px;
125
  }
126
+
127
  .card-title {
128
  font-size: 1.125rem;
129
  font-weight: 600;
130
  color: var(--text);
131
  }
132
+
133
  .card-subtitle {
134
  font-size: 0.875rem;
135
  color: var(--text-secondary);
136
  margin-top: 4px;
137
  }
138
+
139
  /* Buttons */
140
  .btn {
141
  display: inline-flex;
 
152
  white-space: nowrap;
153
  min-height: 40px;
154
  }
155
+
156
  .btn:active {
157
  transform: scale(0.98);
158
  }
159
+
160
  .btn-primary {
161
  background: var(--accent);
162
  color: white;
163
  }
164
+
165
  .btn-primary:hover {
166
  background: var(--accent-hover);
167
  }
168
+
169
  .btn-secondary {
170
  background: var(--surface-hover);
171
  color: var(--text);
172
  border: 1px solid var(--border);
173
  }
174
+
175
  .btn-secondary:hover {
176
  background: var(--border);
177
  }
178
+
179
  .btn-success {
180
  background: var(--success);
181
  color: white;
182
  }
183
+
184
  .btn-danger {
185
  background: transparent;
186
  color: var(--error);
187
  border: 1px solid var(--error);
188
  }
189
+
190
  .btn-icon {
191
  padding: 10px;
192
  min-width: 40px;
193
  }
194
+
195
  /* Input Fields */
196
  .input-group {
197
  display: flex;
198
  gap: 8px;
199
  flex-wrap: wrap;
200
  }
201
+
202
  .input-field {
203
  flex: 1;
204
  min-width: 200px;
 
210
  font-size: 0.9375rem;
211
  transition: border-color 0.2s ease;
212
  }
213
+
214
  .input-field:focus {
215
  outline: none;
216
  border-color: var(--accent);
217
  }
218
+
219
  .input-field::placeholder {
220
  color: var(--text-secondary);
221
  }
222
+
223
  /* Select */
224
  .select-field {
225
  padding: 12px 16px;
 
231
  cursor: pointer;
232
  min-width: 180px;
233
  }
234
+
235
  /* Browser Section */
236
  .browser-container {
237
  background: var(--bg);
 
239
  border-radius: 12px;
240
  overflow: hidden;
241
  }
242
+
243
  .browser-toolbar {
244
  display: flex;
245
  align-items: center;
 
249
  border-bottom: 1px solid var(--border);
250
  flex-wrap: wrap;
251
  }
252
+
253
  @media (max-width: 480px) {
254
  .browser-toolbar {
255
  padding: 8px;
256
  gap: 6px;
257
  }
258
  }
259
+
260
  .browser-nav-btn {
261
  width: 36px;
262
  height: 36px;
 
270
  border-radius: 8px;
271
  cursor: pointer;
272
  }
273
+
274
  .browser-nav-btn:active {
275
  background: var(--surface-hover);
276
  }
277
+
278
  .browser-address-bar {
279
  flex: 1;
280
  display: flex;
281
  gap: 6px;
282
  min-width: 200px;
283
  }
284
+
285
  .browser-address-input {
286
  flex: 1;
287
  padding: 8px 12px;
 
292
  font-size: 0.875rem;
293
  font-family: monospace;
294
  }
295
+
296
  .browser-address-input:focus {
297
  outline: none;
298
  border-color: var(--accent);
299
  }
300
+
301
  .browser-stream-container {
302
  position: relative;
303
  background: #000;
304
  aspect-ratio: 16/10;
305
  overflow: hidden;
306
  }
307
+
308
+ .terminal-view {
309
+ width: 100%;
310
+ height: 100%;
311
+ background: #0c0c0c;
312
+ color: #22c55e;
313
+ font-family: 'Menlo', 'Monaco', 'Courier New', monospace;
314
+ padding: 12px;
315
+ font-size: 0.85rem;
316
+ overflow-y: auto;
317
+ white-space: pre-wrap;
318
+ display: none;
319
+ text-align: left;
320
+ }
321
+
322
  .browser-stream {
323
  width: 100%;
324
  height: 100%;
325
  object-fit: contain;
326
  }
327
+
328
  .browser-overlay {
329
  position: absolute;
330
  top: 0;
 
333
  bottom: 0;
334
  cursor: crosshair;
335
  }
336
+
337
  .browser-loading {
338
  position: absolute;
339
  top: 50%;
 
345
  gap: 12px;
346
  color: var(--text);
347
  }
348
+
349
  .spinner {
350
  width: 40px;
351
  height: 40px;
 
354
  border-radius: 50%;
355
  animation: spin 1s linear infinite;
356
  }
357
+
358
  @keyframes spin {
359
+ to {
360
+ transform: rotate(360deg);
361
+ }
362
  }
363
+
364
  .browser-status-bar {
365
  display: flex;
366
  justify-content: space-between;
 
371
  font-size: 0.75rem;
372
  color: var(--text-secondary);
373
  }
374
+
375
  .browser-controls {
376
  display: flex;
377
  gap: 8px;
378
  padding: 12px;
379
  flex-wrap: wrap;
380
  }
381
+
382
  /* Video Quality Selector */
383
  .quality-selector {
384
  display: flex;
 
388
  background: var(--bg);
389
  border-radius: 8px;
390
  }
391
+
392
  .quality-btn {
393
  padding: 4px 10px;
394
  border: 1px solid var(--border);
 
398
  font-size: 0.75rem;
399
  cursor: pointer;
400
  }
401
+
402
  .quality-btn.active {
403
  background: var(--accent);
404
  color: white;
405
  border-color: var(--accent);
406
  }
407
+
408
  /* On-Screen Keyboard */
409
  .osk-container {
410
  background: var(--surface);
 
414
  bottom: 0;
415
  z-index: 100;
416
  }
417
+
418
  .osk-row {
419
  display: flex;
420
  justify-content: center;
421
  gap: 4px;
422
  margin-bottom: 6px;
423
  }
424
+
425
  .osk-key {
426
  min-width: 32px;
427
  height: 42px;
 
439
  flex: 1;
440
  max-width: 50px;
441
  }
442
+
443
  .osk-key:active {
444
  background: var(--accent);
445
  }
446
+
447
  .osk-key.wide {
448
  flex: 2;
449
  max-width: 80px;
450
  }
451
+
452
  .osk-key.extra-wide {
453
  flex: 3;
454
  max-width: 120px;
455
  }
456
+
457
  @media (min-width: 768px) {
458
  .osk-key {
459
  min-width: 40px;
 
461
  font-size: 1rem;
462
  }
463
  }
464
+
465
  /* Provider Toggle Cards */
466
  .provider-grid {
467
  display: grid;
468
  gap: 12px;
469
  }
470
+
471
  .provider-card {
472
  display: flex;
473
  justify-content: space-between;
 
477
  border: 1px solid var(--border);
478
  border-radius: 12px;
479
  }
480
+
481
  .provider-info h3 {
482
  font-size: 1rem;
483
  font-weight: 600;
484
  margin-bottom: 4px;
485
  }
486
+
487
  .provider-info span {
488
  font-size: 0.75rem;
489
  color: var(--text-secondary);
490
  text-transform: uppercase;
491
  }
492
+
493
  /* Toggle Switch */
494
  .toggle-switch {
495
  position: relative;
496
  width: 52px;
497
  height: 28px;
498
  }
499
+
500
  .toggle-switch input {
501
  opacity: 0;
502
  width: 0;
503
  height: 0;
504
  }
505
+
506
  .toggle-slider {
507
  position: absolute;
508
  cursor: pointer;
 
514
  transition: .3s;
515
  border-radius: 28px;
516
  }
517
+
518
  .toggle-slider:before {
519
  position: absolute;
520
  content: "";
 
526
  transition: .3s;
527
  border-radius: 50%;
528
  }
529
+
530
+ input:checked+.toggle-slider {
531
  background-color: var(--success);
532
  }
533
+
534
+ input:checked+.toggle-slider:before {
535
  transform: translateX(24px);
536
  }
537
+
538
  /* Tables */
539
  .table-container {
540
  overflow-x: auto;
 
542
  margin: 0 -16px;
543
  padding: 0 16px;
544
  }
545
+
546
  table {
547
  width: 100%;
548
  border-collapse: collapse;
549
  font-size: 0.875rem;
550
  min-width: 600px;
551
  }
552
+
553
  th {
554
  text-align: left;
555
  padding: 12px;
 
558
  border-bottom: 1px solid var(--border);
559
  white-space: nowrap;
560
  }
561
+
562
  td {
563
  padding: 12px;
564
  border-bottom: 1px solid var(--border);
565
  }
566
+
567
  /* Stats Grid */
568
  .stats-grid {
569
  display: grid;
570
  grid-template-columns: repeat(2, 1fr);
571
  gap: 12px;
572
  }
573
+
574
  @media (min-width: 640px) {
575
  .stats-grid {
576
  grid-template-columns: repeat(4, 1fr);
577
  }
578
  }
579
+
580
  .stat-card {
581
  background: var(--bg);
582
  border: 1px solid var(--border);
 
584
  padding: 16px;
585
  text-align: center;
586
  }
587
+
588
  .stat-value {
589
  font-size: 1.75rem;
590
  font-weight: 700;
591
  color: var(--text);
592
  margin-bottom: 4px;
593
  }
594
+
595
  .stat-label {
596
  font-size: 0.75rem;
597
  color: var(--text-secondary);
598
  text-transform: uppercase;
599
  letter-spacing: 0.5px;
600
  }
601
+
602
  /* Progress Bar */
603
  .progress-container {
604
  margin-top: 8px;
605
  }
606
+
607
  .progress-bar {
608
  height: 6px;
609
  background: var(--surface-hover);
610
  border-radius: 3px;
611
  overflow: hidden;
612
  }
613
+
614
  .progress-fill {
615
  height: 100%;
616
  background: var(--success);
617
  border-radius: 3px;
618
  transition: width 0.3s ease;
619
  }
620
+
621
  /* Connection Status */
622
  .connection-status {
623
  display: flex;
 
625
  gap: 6px;
626
  font-size: 0.875rem;
627
  }
628
+
629
  .status-dot {
630
  width: 8px;
631
  height: 8px;
632
  border-radius: 50%;
633
  background: var(--text-secondary);
634
  }
635
+
636
  .status-dot.connected {
637
  background: var(--success);
638
  box-shadow: 0 0 8px var(--success);
639
  }
640
+
641
  .status-dot.streaming {
642
  background: var(--accent);
643
  box-shadow: 0 0 8px var(--accent);
644
  animation: pulse 1.5s infinite;
645
  }
646
+
647
  @keyframes pulse {
648
+
649
+ 0%,
650
+ 100% {
651
+ opacity: 1;
652
+ }
653
+
654
+ 50% {
655
+ opacity: 0.5;
656
+ }
657
  }
658
+
659
  /* Modal for messages */
660
  .modal-backdrop {
661
  position: fixed;
 
663
  left: 0;
664
  right: 0;
665
  bottom: 0;
666
+ background: rgba(0, 0, 0, 0.8);
667
  display: none;
668
  align-items: center;
669
  justify-content: center;
670
  z-index: 1000;
671
  padding: 20px;
672
  }
673
+
674
  .modal-backdrop.active {
675
  display: flex;
676
  }
677
+
678
  .modal-content {
679
  background: var(--surface);
680
  border: 1px solid var(--border);
 
684
  width: 100%;
685
  text-align: center;
686
  }
687
+
688
  /* Scrollbar styling */
689
  ::-webkit-scrollbar {
690
  width: 8px;
691
  height: 8px;
692
  }
693
+
694
  ::-webkit-scrollbar-track {
695
  background: var(--bg);
696
  }
697
+
698
  ::-webkit-scrollbar-thumb {
699
  background: var(--border);
700
  border-radius: 4px;
701
  }
702
+
703
  ::-webkit-scrollbar-thumb:hover {
704
  background: var(--surface-hover);
705
  }
706
  </style>
707
  </head>
708
+
709
  <body>
710
  <div class="app-container">
711
  <!-- Header -->
 
740
  </tr>
741
  </thead>
742
  <tbody id="keys-list">
743
+ <tr>
744
+ <td colspan="5" style="text-align:center;padding:20px;">Loading...</td>
745
+ </tr>
746
  </tbody>
747
  </table>
748
  </div>
 
755
  <input type="text" id="lookup-token" class="input-field" placeholder="Enter API key (sk-...)">
756
  <button class="btn btn-primary" onclick="lookupToken()">Check</button>
757
  </div>
758
+ <div id="lookup-result"
759
+ style="display:none;margin-top:16px;padding:16px;background:var(--bg);border-radius:10px;">
760
  <div style="display:flex;justify-content:space-between;margin-bottom:12px;">
761
  <span id="lookup-name" style="font-weight:600;">-</span>
762
  <span id="lookup-status">-</span>
 
786
  <span id="browser-status-text">Offline</span>
787
  </div>
788
  </div>
789
+
790
  <div class="input-group">
791
  <select id="portal-provider-select" class="select-field" onchange="onProviderChange()">
792
  <option value="">Select Provider...</option>
793
+ <optgroup label="Browser">
794
+ <option value="copilot">πŸ€– Copilot</option>
795
+ <option value="huggingchat">πŸ€— HuggingChat</option>
796
+ <option value="chatgpt">πŸ’¬ ChatGPT</option>
797
+ <option value="gemini">✨ Gemini</option>
798
+ <option value="zai">🌟 Z.ai</option>
799
+ </optgroup>
800
+ <optgroup label="Terminal">
801
+ <option value="opencode">πŸ–₯️ OpenCode (Kimi K2.5 Free)</option>
802
+ </optgroup>
803
  </select>
804
+ <button class="btn btn-primary" id="portal-start-btn" onclick="startPortal()">Connect</button>
805
  </div>
806
  </div>
807
 
 
812
  <button class="browser-nav-btn" onclick="browserGoBack()" title="Back">←</button>
813
  <button class="browser-nav-btn" onclick="browserGoForward()" title="Forward">β†’</button>
814
  <button class="browser-nav-btn" onclick="browserRefresh()" title="Refresh">↻</button>
815
+
816
  <div class="browser-address-bar">
817
+ <input type="text" id="browser-address" class="browser-address-input" placeholder="Enter URL..."
818
+ onkeypress="if(event.key==='Enter')browserNavigate()">
819
  <button class="btn btn-primary btn-icon" onclick="browserNavigate()">Go</button>
820
  </div>
821
+
822
  <div class="quality-selector">
823
  <span style="font-size:0.75rem;color:var(--text-secondary);">Quality:</span>
824
+ <button class="quality-btn active" onclick="setStreamQuality('low')"
825
+ id="quality-low">Low</button>
826
+ <button class="quality-btn" onclick="setStreamQuality('medium')"
827
+ id="quality-medium">Med</button>
828
  <button class="quality-btn" onclick="setStreamQuality('high')" id="quality-high">High</button>
829
  </div>
830
+
831
  <button class="btn btn-danger btn-icon" onclick="closeBrowser()">βœ•</button>
832
  </div>
833
 
834
  <!-- Video Stream -->
835
  <div class="browser-stream-container">
836
+ <img id="browser-stream" class="browser-stream" src="" alt="Browser Stream"
837
+ onclick="handleStreamClick(event)" style="cursor: crosshair;">
838
+ <div id="terminal-view" class="terminal-view"
839
+ onclick="document.getElementById('browser-message').focus()"></div>
840
+
841
  <div class="browser-loading" id="browser-loading" style="display:none;">
842
  <div class="spinner"></div>
843
  <span>Connecting...</span>
 
853
  <!-- Message Input -->
854
  <div style="padding:12px;border-top:1px solid var(--border);">
855
  <div class="input-group">
856
+ <input type="text" id="browser-message" class="input-field"
857
+ placeholder="Type message and press Enter..."
858
+ onkeypress="if(event.key==='Enter')sendMessage()">
859
  <button class="btn btn-success" onclick="sendMessage()">Send</button>
860
  <button class="btn btn-secondary" onclick="toggleKeyboard()">⌨️ Keyboard</button>
861
  </div>
 
924
  <div style="display:flex;flex-direction:column;gap:10px;">
925
  <input type="text" id="proxy-input" class="input-field" placeholder="ip:port or http://ip:port">
926
  <div style="display:flex;gap:10px;">
927
+ <input type="text" id="proxy-username" class="input-field" placeholder="Username (optional)"
928
+ style="flex:1;">
929
+ <input type="password" id="proxy-password" class="input-field" placeholder="Password (optional)"
930
+ style="flex:1;">
931
  </div>
932
  <div style="display:flex;gap:10px;">
933
  <button class="btn btn-primary" onclick="setProxy()" style="flex:1;">Set Proxy</button>
 
935
  <button class="btn btn-success" onclick="testProxy()" style="flex:1;">Test</button>
936
  </div>
937
  </div>
938
+ <div id="proxy-status" style="margin-top:12px;font-size:0.875rem;color:var(--text-secondary);display:none;">
939
+ </div>
940
+ </div>
941
+
942
+ <!-- Saved Proxies/IPs -->
943
+ <div class="card">
944
+ <div class="card-header" style="margin-bottom:12px;">
945
+ <div class="card-title">Saved Proxy IPs</div>
946
+ <button class="btn btn-primary" onclick="loadSavedProxies()" style="font-size:12px;padding:6px 12px;">πŸ”„
947
+ Refresh</button>
948
+ </div>
949
+ <div id="saved-proxies-list" style="display:flex;flex-direction:column;gap:8px;">
950
+ <div style="padding:20px;text-align:center;color:var(--text-secondary);">Click refresh to load saved
951
+ proxies</div>
952
+ </div>
953
  </div>
954
  </div>
955
 
 
974
  const res = await fetch('/qaz/keys');
975
  const keys = await res.json();
976
  const tbody = document.getElementById('keys-list');
977
+
978
  if (keys.length === 0) {
979
  tbody.innerHTML = '<tr><td colspan="5" style="text-align:center;padding:20px;color:var(--text-secondary);">No keys found</td></tr>';
980
  return;
981
  }
982
+
983
  tbody.innerHTML = keys.map(key => {
984
  const percent = key.limit_tokens > 0 ? Math.round((key.usage_tokens / key.limit_tokens) * 100) : 0;
985
  const color = percent > 90 ? 'var(--error)' : percent > 50 ? 'var(--warning)' : 'var(--success)';
986
+
987
  return `
988
  <tr>
989
  <td style="font-weight:500;">${key.name}</td>
 
1011
  async function createKey() {
1012
  const name = prompt('Enter name for new API key:');
1013
  if (!name) return;
1014
+
1015
  try {
1016
  const res = await fetch('/qaz/keys', {
1017
  method: 'POST',
1018
  headers: { 'Content-Type': 'application/json' },
1019
  body: JSON.stringify({ name, limit_tokens: 1000000 })
1020
  });
1021
+
1022
  if (res.ok) {
1023
  const key = await res.json();
1024
  alert(`βœ… Key created!\n\nToken: ${key.token}\n\nSave this now!`);
 
1043
  async function lookupToken() {
1044
  const token = document.getElementById('lookup-token').value.trim();
1045
  if (!token) return;
1046
+
1047
  try {
1048
  const res = await fetch('/qaz/keys/lookup', {
1049
  method: 'POST',
1050
  headers: { 'Content-Type': 'application/json' },
1051
  body: JSON.stringify({ token })
1052
  });
1053
+
1054
  if (!res.ok) throw new Error('Key not found');
1055
+
1056
  const data = await res.json();
1057
  const percent = Math.round((data.usage_tokens / data.limit_tokens) * 100);
1058
  const remaining = data.limit_tokens - data.usage_tokens;
1059
  const color = percent > 90 ? 'var(--error)' : percent > 50 ? 'var(--warning)' : 'var(--success)';
1060
+
1061
  document.getElementById('lookup-result').style.display = 'block';
1062
  document.getElementById('lookup-name').textContent = data.name;
1063
  document.getElementById('lookup-status').innerHTML = `<span style="color:${data.is_active ? 'var(--success)' : 'var(--error)'}">${data.is_active ? '● Active' : '● Inactive'}</span>`;
 
1075
  try {
1076
  const res = await fetch('/qaz/portal/status');
1077
  const data = await res.json();
1078
+
1079
  const active = data.providers.filter(p => p.is_running);
1080
  const container = document.getElementById('active-browsers');
1081
+
1082
  if (active.length > 0) {
1083
  container.style.display = 'block';
1084
  container.innerHTML = `
 
1102
  currentProvider = document.getElementById('portal-provider-select').value;
1103
  }
1104
 
1105
+ async function startPortal() {
1106
  const provider = document.getElementById('portal-provider-select').value;
1107
  if (!provider) {
1108
  alert('Select a provider first');
1109
  return;
1110
  }
1111
+
1112
  const btn = document.getElementById('portal-start-btn');
1113
  btn.disabled = true;
1114
  btn.textContent = 'Connecting...';
1115
+
1116
  try {
1117
+ // Check if it's a terminal provider
1118
+ if (provider === 'opencode') {
1119
+ await startTerminalPortal(provider);
1120
+ } else {
1121
+ await startBrowserPortal(provider);
 
 
 
 
 
 
 
1122
  }
1123
  } catch (e) {
1124
  alert('Error: ' + e.message);
 
1128
  }
1129
  }
1130
 
1131
+ async function startBrowserPortal(provider) {
1132
+ const res = await fetch('/qaz/portal/start', {
1133
+ method: 'POST',
1134
+ headers: { 'Content-Type': 'application/json' },
1135
+ body: JSON.stringify({ provider })
1136
+ });
1137
+
1138
+ const data = await res.json();
1139
+ if (data.status === 'success' || data.status === 'already_running') {
1140
+ currentProvider = provider;
1141
+ showBrowserInterface();
1142
+ startStream();
1143
+ checkBrowserStatus();
1144
+ }
1145
+ }
1146
+
1147
+ async function startTerminalPortal(provider) {
1148
+ // provider is 'opencode', but backend expects model name
1149
+ // For now, hardcode to default free model or get from select if we expand logic
1150
+ const model = 'kimi-k2.5-free';
1151
+
1152
+ const res = await fetch('/qaz/terminal/start', {
1153
+ method: 'POST',
1154
+ headers: { 'Content-Type': 'application/json' },
1155
+ body: JSON.stringify({ model })
1156
+ });
1157
+
1158
+ const data = await res.json();
1159
+ if (data.status === 'success' || data.status === 'already_running') {
1160
+ currentProvider = provider;
1161
+ showTerminalInterface();
1162
+ startTerminalStream(model);
1163
+ checkBrowserStatus();
1164
+ }
1165
+ }
1166
+
1167
+ function showTerminalInterface() {
1168
+ document.getElementById('browser-interface').style.display = 'block';
1169
+ document.getElementById('browser-status-dot').classList.add('connected');
1170
+ document.getElementById('browser-status-text').textContent = 'Terminal Active';
1171
+
1172
+ // Hide image stream, show terminal
1173
+ document.getElementById('browser-stream').style.display = 'none';
1174
+ const term = document.getElementById('terminal-view');
1175
+ term.style.display = 'block';
1176
+ term.innerHTML = 'Connecting to OpenCode...';
1177
+
1178
+ // Update controls for terminal
1179
+ document.querySelector('.browser-address-bar').style.visibility = 'hidden';
1180
+ document.querySelector('.quality-selector').style.visibility = 'hidden';
1181
+ document.getElementById('browser-page-title').textContent = 'OpenCode Terminal';
1182
+ document.getElementById('browser-coords').textContent = 'Type commands below';
1183
+ }
1184
+
1185
+ function startTerminalStream(model) {
1186
+ if (streamInterval) clearInterval(streamInterval);
1187
+
1188
+ const term = document.getElementById('terminal-view');
1189
+
1190
+ streamInterval = setInterval(async () => {
1191
+ if (!currentProvider) return;
1192
+
1193
+ try {
1194
+ const res = await fetch(`/qaz/terminal/output?model=${model}&lines=50`);
1195
+ const data = await res.json();
1196
+
1197
+ if (data.status === 'running') {
1198
+ // Clear and rebuild terminal output
1199
+ // In a real app we might append, but simplistic polling replaces for now
1200
+ // or better: keep local buffer
1201
+ const text = data.lines.map(l => l.content).join('');
1202
+ term.textContent = text;
1203
+ term.scrollTop = term.scrollHeight; // Auto-scroll
1204
+
1205
+ document.getElementById('browser-status-dot').classList.add('streaming');
1206
+ } else if (data.status === 'stopped') {
1207
+ term.textContent += '\n[Process stopped]';
1208
+ clearInterval(streamInterval);
1209
+ }
1210
+ } catch (e) {
1211
+ console.error('Terminal poll error:', e);
1212
+ }
1213
+ }, 1000);
1214
+ }
1215
+
1216
  function showBrowserInterface() {
1217
  document.getElementById('browser-interface').style.display = 'block';
1218
+
1219
+ // Reset to browser mode
1220
+ document.getElementById('browser-stream').style.display = 'block';
1221
+ document.getElementById('terminal-view').style.display = 'none';
1222
+ document.querySelector('.browser-address-bar').style.visibility = 'visible';
1223
+ document.querySelector('.quality-selector').style.visibility = 'visible';
1224
+
1225
  document.getElementById('browser-status-dot').classList.add('connected');
1226
  document.getElementById('browser-status-text').textContent = 'Connected';
1227
  }
1228
 
1229
  function startStream() {
1230
  if (streamInterval) clearInterval(streamInterval);
1231
+
1232
  const img = document.getElementById('browser-stream');
1233
  const quality = streamQuality === 'low' ? 0.5 : streamQuality === 'medium' ? 0.75 : 1;
1234
  let frameCount = 0;
1235
+
1236
  // Preload image to prevent flickering
1237
  const updateFrame = () => {
1238
  if (!currentProvider) return;
1239
+
1240
  const newSrc = `/qaz/portal/${currentProvider}/screenshot?t=${Date.now()}&quality=${quality}&frame=${frameCount++}`;
1241
+
1242
  // Create a new image to preload
1243
  const preloadImg = new Image();
1244
  preloadImg.onload = () => {
 
1250
  };
1251
  preloadImg.src = newSrc;
1252
  };
1253
+
1254
  // Update every 1000ms (1 second) for better performance
1255
  streamInterval = setInterval(updateFrame, 1000);
1256
  updateFrame();
1257
+
1258
  document.getElementById('browser-status-dot').classList.add('streaming');
1259
  console.log('Stream started for provider:', currentProvider);
1260
  }
 
1263
  streamQuality = quality;
1264
  document.querySelectorAll('.quality-btn').forEach(btn => btn.classList.remove('active'));
1265
  document.getElementById(`quality-${quality}`).classList.add('active');
1266
+
1267
  // Restart stream with new quality
1268
  if (currentProvider) {
1269
  startStream();
 
1290
  if (!currentProvider) return;
1291
  let url = document.getElementById('browser-address').value.trim();
1292
  if (!url) return;
1293
+
1294
  if (!url.startsWith('http')) url = 'https://' + url;
1295
+
1296
  await fetch(`/qaz/portal/${currentProvider}/navigate`, {
1297
  method: 'POST',
1298
  headers: { 'Content-Type': 'application/json' },
 
1305
  console.log('No provider selected');
1306
  return;
1307
  }
1308
+
1309
  const rect = event.target.getBoundingClientRect();
1310
  const scaleX = 1280 / rect.width;
1311
  const scaleY = 800 / rect.height;
1312
  const x = Math.round((event.clientX - rect.left) * scaleX);
1313
  const y = Math.round((event.clientY - rect.top) * scaleY);
1314
+
1315
  console.log(`Click at ${x}, ${y} on ${currentProvider}`);
1316
  document.getElementById('browser-coords').textContent = `Click: ${x}, ${y}`;
1317
+
1318
  // Visual feedback
1319
  const img = event.target;
1320
  const feedback = document.createElement('div');
 
1333
  `;
1334
  img.parentElement.appendChild(feedback);
1335
  setTimeout(() => feedback.remove(), 500);
1336
+
1337
  // Send click to backend
1338
  fetch('/qaz/portal/action', {
1339
  method: 'POST',
 
1357
 
1358
  async function sendMessage() {
1359
  if (!currentProvider) return;
1360
+ const msgInput = document.getElementById('browser-message');
1361
+ const msg = msgInput.value; // Allow empty lines or spaces
1362
+ // if (!msg) return; // Allow sending enter
1363
+
1364
+ if (currentProvider === 'opencode') {
1365
+ // Send to terminal
1366
+ await fetch('/qaz/terminal/input', {
1367
+ method: 'POST',
1368
+ headers: { 'Content-Type': 'application/json' },
1369
+ body: JSON.stringify({ text: msg })
1370
+ });
1371
+ } else {
1372
+ // Send to browser portal
1373
+ if (!msg.trim()) return;
1374
+ await fetch(`/qaz/portal/${currentProvider}/send`, {
1375
+ method: 'POST',
1376
+ headers: { 'Content-Type': 'application/json' },
1377
+ body: JSON.stringify({ message: msg })
1378
+ });
1379
+ }
1380
+
1381
+ msgInput.value = '';
1382
  }
1383
 
1384
  function toggleKeyboard() {
 
1389
  // On-Screen Keyboard
1390
  function oskType(char) {
1391
  if (char === '123') return; // Switch to numbers (not implemented yet)
1392
+
1393
  const input = document.getElementById('browser-message');
1394
  if (oskShift && char.length === 1 && char.match(/[a-z]/i)) {
1395
  char = char.toUpperCase();
 
1417
  async function closeBrowser() {
1418
  if (!currentProvider) return;
1419
  if (!confirm('Close browser?')) return;
1420
+
1421
+ if (currentProvider === 'opencode') {
1422
+ await fetch('/qaz/terminal/close', {
1423
+ method: 'POST',
1424
+ headers: { 'Content-Type': 'application/json' },
1425
+ body: JSON.stringify({ model: 'kimi-k2.5-free' })
1426
+ });
1427
+ } else {
1428
+ await fetch(`/qaz/portal/${currentProvider}/close`, { method: 'POST' });
1429
+ }
1430
+
1431
  if (streamInterval) {
1432
  clearInterval(streamInterval);
1433
  streamInterval = null;
1434
  }
1435
+
1436
  document.getElementById('browser-interface').style.display = 'none';
1437
  document.getElementById('browser-status-dot').classList.remove('connected', 'streaming');
1438
  document.getElementById('browser-status-text').textContent = 'Offline';
1439
  document.getElementById('osk').style.display = 'none';
1440
  currentProvider = null;
1441
+
1442
  checkBrowserStatus();
1443
  }
1444
 
1445
  // Provider Toggles
1446
  async function loadProviders() {
1447
  const container = document.getElementById('providers-list');
1448
+
1449
  try {
1450
  console.log('Loading providers...');
1451
  const res = await fetch('/qaz/providers');
1452
+
1453
  if (!res.ok) {
1454
  const errorText = await res.text();
1455
  throw new Error(`HTTP ${res.status}: ${errorText}`);
1456
  }
1457
+
1458
  const data = await res.json();
1459
  console.log('Providers loaded:', data);
1460
+
1461
  if (!data.providers || data.providers.length === 0) {
1462
  container.innerHTML = '<div style="padding:20px;text-align:center;color:var(--text-secondary);">No providers configured. Please initialize the database.</div>';
1463
  return;
1464
  }
1465
+
1466
  // Sort providers: enabled first, then by name
1467
  const sortedProviders = data.providers.sort((a, b) => {
1468
  if (a.enabled !== b.enabled) return b.enabled - a.enabled;
1469
  return a.name.localeCompare(b.name);
1470
  });
1471
+
1472
  container.innerHTML = sortedProviders.map(p => `
1473
  <div class="provider-card" style="background:${p.enabled ? 'var(--surface)' : 'var(--bg)'};border-left:4px solid ${p.enabled ? 'var(--success)' : 'var(--border)'};">
1474
  <div class="provider-info">
 
1493
  event.preventDefault();
1494
  event.stopPropagation();
1495
  }
1496
+
1497
  try {
1498
  console.log(`Toggling provider ${id} to ${enabled}`);
1499
  const res = await fetch('/qaz/providers/toggle', {
 
1501
  headers: { 'Content-Type': 'application/json' },
1502
  body: JSON.stringify({ provider_id: id, enabled })
1503
  });
1504
+
1505
  if (!res.ok) {
1506
  const error = await res.json();
1507
  throw new Error(error.detail || 'Failed to toggle');
1508
  }
1509
+
1510
  const data = await res.json();
1511
  console.log('Toggle success:', data);
1512
+
1513
  // Show success feedback
1514
  showProxyStatus(`Provider ${enabled ? 'enabled' : 'disabled'}`);
1515
+
1516
  } catch (e) {
1517
  console.error('Toggle error:', e);
1518
  alert('Error: ' + e.message);
 
1528
  alert('Please enter a proxy address');
1529
  return;
1530
  }
1531
+
1532
  const username = document.getElementById('proxy-username').value.trim() || null;
1533
  const password = document.getElementById('proxy-password').value || null;
1534
+
1535
  try {
1536
  const res = await fetch('/qaz/proxy/set', {
1537
  method: 'POST',
1538
  headers: { 'Content-Type': 'application/json' },
1539
  body: JSON.stringify({ proxy, username, password })
1540
  });
1541
+
1542
  const data = await res.json();
1543
  if (data.status === 'success') {
1544
  showProxyStatus(`βœ… Proxy set: ${data.proxy}${data.has_auth ? ' (with auth)' : ''}`);
 
1564
  showProxyStatus('πŸ§ͺ Testing proxy...');
1565
  const res = await fetch('/qaz/proxy/test', { method: 'POST' });
1566
  const data = await res.json();
1567
+
1568
  if (data.is_working) {
1569
  showProxyStatus(`βœ… Proxy working! Response time: ${data.response_time}`);
1570
  } else {
 
1586
  try {
1587
  const res = await fetch('/qaz/proxy/status');
1588
  const data = await res.json();
1589
+
1590
  if (data.enabled) {
1591
  document.getElementById('proxy-input').value = data.proxy;
1592
  if (data.username) {
 
1599
  }
1600
  }
1601
 
1602
+ // Saved Proxies Management
1603
+ async function loadSavedProxies() {
1604
+ const container = document.getElementById('saved-proxies-list');
1605
+ container.innerHTML = '<div style="padding:20px;text-align:center;"><div class="spinner" style="width:30px;height:30px;"></div><p>Loading proxies...</p></div>';
1606
+
1607
+ try {
1608
+ const res = await fetch('/qaz/proxies');
1609
+ if (!res.ok) throw new Error('Failed to load proxies');
1610
+
1611
+ const data = await res.json();
1612
+
1613
+ if (!data.proxies || data.proxies.length === 0) {
1614
+ container.innerHTML = '<div style="padding:20px;text-align:center;color:var(--text-secondary);">No saved proxies yet. Add one above.</div>';
1615
+ return;
1616
+ }
1617
+
1618
+ container.innerHTML = data.proxies.map(p => `
1619
+ <div class="provider-card" style="border-left:4px solid ${p.is_active ? 'var(--success)' : 'var(--border)'};padding:12px;">
1620
+ <div style="flex:1;">
1621
+ <div style="font-weight:600;">${p.name || p.ip + ':' + p.port}</div>
1622
+ <div style="font-size:0.75rem;color:var(--text-secondary);">
1623
+ ${p.protocol}://${p.ip}:${p.port} ${p.country ? 'β€’ ' + p.country : ''}
1624
+ ${p.is_working === true ? 'β€’ βœ… Working' : p.is_working === false ? 'β€’ ❌ Failed' : ''}
1625
+ </div>
1626
+ </div>
1627
+ <div style="display:flex;gap:8px;">
1628
+ <button class="btn ${p.is_active ? 'btn-success' : 'btn-secondary'}" onclick="activateProxy(${p.id})" style="padding:6px 12px;font-size:12px;">
1629
+ ${p.is_active ? 'Active' : 'Activate'}
1630
+ </button>
1631
+ <button class="btn btn-danger" onclick="deleteProxy(${p.id})" style="padding:6px 12px;font-size:12px;">
1632
+ πŸ—‘οΈ
1633
+ </button>
1634
+ </div>
1635
+ </div>
1636
+ `).join('');
1637
+ } catch (e) {
1638
+ container.innerHTML = `<div style="padding:20px;text-align:center;color:var(--error);">Error: ${e.message}</div>`;
1639
+ }
1640
+ }
1641
+
1642
+ async function activateProxy(id) {
1643
+ try {
1644
+ const res = await fetch(`/qaz/proxies/${id}/activate`, { method: 'POST' });
1645
+ if (!res.ok) throw new Error('Failed to activate');
1646
+ loadSavedProxies();
1647
+ showProxyStatus('βœ… Proxy activated');
1648
+ } catch (e) {
1649
+ alert('Error: ' + e.message);
1650
+ }
1651
+ }
1652
+
1653
+ async function deleteProxy(id) {
1654
+ if (!confirm('Delete this proxy?')) return;
1655
+
1656
+ try {
1657
+ const res = await fetch(`/qaz/proxies/${id}`, { method: 'DELETE' });
1658
+ if (!res.ok) throw new Error('Failed to delete');
1659
+ loadSavedProxies();
1660
+ showProxyStatus('πŸ—‘οΈ Proxy deleted');
1661
+ } catch (e) {
1662
+ alert('Error: ' + e.message);
1663
+ }
1664
+ }
1665
+
1666
  // Admin Actions
1667
  async function testAllAI() {
1668
  const btn = event.target;
1669
  btn.disabled = true;
1670
  btn.textContent = 'Testing...';
1671
+
1672
  try {
1673
  const res = await fetch('/qaz/test_all', { method: 'POST' });
1674
  const results = await res.json();
 
1684
 
1685
  async function clearStats() {
1686
  if (!confirm('Clear all statistics?')) return;
1687
+
1688
  try {
1689
  await fetch('/qaz/clear_stats', { method: 'POST' });
1690
  alert('Statistics cleared');
 
1702
  }
1703
  </script>
1704
  </body>
1705
+
1706
+ </html>