bithub 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
bithub/registry.py ADDED
@@ -0,0 +1,55 @@
1
+ """Model registry — loads and queries the curated model catalog."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from typing import Optional
6
+
7
+ from bithub.config import BITHUB_HOME
8
+
9
+ REGISTRY_PATH = Path(__file__).parent / "registry.json"
10
+ CUSTOM_MODELS_PATH = BITHUB_HOME / "custom_models.json"
11
+
12
+
13
+ def load_registry() -> dict:
14
+ """Load the model registry from disk. Raises on missing/invalid file."""
15
+ with open(REGISTRY_PATH) as f:
16
+ data = json.load(f)
17
+ if "models" not in data:
18
+ raise ValueError(f"Registry {REGISTRY_PATH} missing 'models' key")
19
+ return data
20
+
21
+
22
+ def get_model_info(model_name: str) -> Optional[dict]:
23
+ """Return info dict for a model, checking registry then custom models."""
24
+ registry = load_registry()
25
+ info = registry["models"].get(model_name)
26
+ if info:
27
+ return info
28
+ custom = load_custom_models()
29
+ return custom.get(model_name)
30
+
31
+
32
+ def load_custom_models() -> dict:
33
+ """Load user's custom (directly-pulled) models."""
34
+ if not CUSTOM_MODELS_PATH.exists():
35
+ return {}
36
+ try:
37
+ with open(CUSTOM_MODELS_PATH) as f:
38
+ return json.load(f)
39
+ except (json.JSONDecodeError, OSError):
40
+ return {}
41
+
42
+
43
+ def save_custom_model(name: str, info: dict) -> None:
44
+ """Save a custom model entry to custom_models.json."""
45
+ models = load_custom_models()
46
+ models[name] = info
47
+ BITHUB_HOME.mkdir(parents=True, exist_ok=True)
48
+ with open(CUSTOM_MODELS_PATH, "w") as f:
49
+ json.dump(models, f, indent=2)
50
+
51
+
52
+ def list_available_models() -> dict:
53
+ """Return all models from the registry."""
54
+ registry = load_registry()
55
+ return registry["models"]
bithub/repl.py ADDED
@@ -0,0 +1,203 @@
1
+ """Interactive chat REPL for bithub."""
2
+
3
+ import json
4
+ import sys
5
+ import time
6
+ from typing import List, Optional, Tuple
7
+
8
+ import httpx
9
+ from rich.console import Console
10
+ from rich.markdown import Markdown
11
+
12
+ console = Console()
13
+
14
+
15
+ def is_slash_command(text: str) -> bool:
16
+ """Check if input is a slash command."""
17
+ return bool(text) and text.startswith("/")
18
+
19
+
20
+ def parse_slash_command(text: str) -> Tuple[str, str]:
21
+ """Parse a slash command into (command, argument)."""
22
+ parts = text[1:].split(None, 1)
23
+ cmd = parts[0] if parts else ""
24
+ arg = parts[1] if len(parts) > 1 else ""
25
+ return cmd, arg
26
+
27
+
28
+ class ChatSession:
29
+ """Manages conversation state for the REPL."""
30
+
31
+ def __init__(self, model: str, api_url: str) -> None:
32
+ self.model = model
33
+ self.api_url = api_url.rstrip("/")
34
+ self.messages: List[dict] = []
35
+ self.system_prompt: Optional[str] = None
36
+ self.total_tokens = 0
37
+
38
+ def add_message(self, role: str, content: str) -> None:
39
+ self.messages.append({"role": role, "content": content})
40
+
41
+ def clear(self) -> None:
42
+ self.messages.clear()
43
+ self.total_tokens = 0
44
+
45
+ def set_system_prompt(self, prompt: str) -> None:
46
+ self.system_prompt = prompt
47
+
48
+ def build_payload(self) -> dict:
49
+ msgs: List[dict] = []
50
+ if self.system_prompt:
51
+ msgs.append({"role": "system", "content": self.system_prompt})
52
+ msgs.extend(self.messages)
53
+ return {
54
+ "model": self.model,
55
+ "messages": msgs,
56
+ "stream": True,
57
+ }
58
+
59
+ def export(self) -> str:
60
+ lines = []
61
+ for msg in self.messages:
62
+ lines.append(f"{msg['role']}: {msg['content']}")
63
+ return "\n\n".join(lines)
64
+
65
+ def send_and_stream(self) -> str:
66
+ """Send current conversation to API and stream the response.
67
+
68
+ Returns the full assistant response text.
69
+ """
70
+ payload = self.build_payload()
71
+ url = f"{self.api_url}/v1/chat/completions"
72
+ full_response = ""
73
+
74
+ try:
75
+ with httpx.stream("POST", url, json=payload, timeout=120.0) as response:
76
+ if response.status_code != 200:
77
+ console.print(f"[red]API error: {response.status_code}[/red]")
78
+ return ""
79
+
80
+ for line in response.iter_lines():
81
+ if not line or not line.startswith("data: "):
82
+ continue
83
+ data = line[6:]
84
+ if data == "[DONE]":
85
+ break
86
+ try:
87
+ chunk = json.loads(data)
88
+ delta = chunk.get("choices", [{}])[0].get("delta", {})
89
+ content = delta.get("content", "")
90
+ if content:
91
+ sys.stdout.write(content)
92
+ sys.stdout.flush()
93
+ full_response += content
94
+ except json.JSONDecodeError:
95
+ continue
96
+
97
+ except httpx.ConnectError:
98
+ console.print("[red]Cannot connect to API server.[/red]")
99
+ console.print("Is the server running? Start with: bithub serve <model>")
100
+ except httpx.ReadTimeout:
101
+ console.print("\n[yellow]Response timed out.[/yellow]")
102
+
103
+ sys.stdout.write("\n")
104
+ return full_response
105
+
106
+
107
+ HELP_TEXT = """[bold]Available commands:[/bold]
108
+ /help Show this help
109
+ /clear Clear conversation history
110
+ /system <prompt> Set system prompt
111
+ /model Show current model info
112
+ /export Save conversation to file
113
+ /quit Exit chat
114
+ """
115
+
116
+
117
+ def handle_slash_command(cmd: str, arg: str, session: ChatSession) -> Optional[str]:
118
+ """Handle a slash command. Returns 'quit' to exit, None otherwise."""
119
+ if cmd == "help":
120
+ console.print(HELP_TEXT)
121
+ elif cmd == "clear":
122
+ session.clear()
123
+ console.print("[dim]Conversation cleared.[/dim]")
124
+ elif cmd == "system":
125
+ if not arg:
126
+ if session.system_prompt:
127
+ console.print(f"[dim]Current system prompt: {session.system_prompt}[/dim]")
128
+ else:
129
+ console.print("[dim]No system prompt set. Usage: /system <prompt>[/dim]")
130
+ else:
131
+ session.set_system_prompt(arg)
132
+ console.print("[dim]System prompt set.[/dim]")
133
+ elif cmd == "model":
134
+ console.print(f"[bold]Model:[/bold] {session.model}")
135
+ console.print(f"[bold]API:[/bold] {session.api_url}")
136
+ console.print(f"[bold]Messages:[/bold] {len(session.messages)}")
137
+ console.print(f"[bold]Tokens:[/bold] ~{session.total_tokens}")
138
+ elif cmd == "export":
139
+ filename = arg if arg else f"chat-{int(time.time())}.txt"
140
+ content = session.export()
141
+ if not content:
142
+ console.print("[yellow]Nothing to export.[/yellow]")
143
+ else:
144
+ with open(filename, "w") as f:
145
+ f.write(content)
146
+ console.print(f"[green]Saved to {filename}[/green]")
147
+ elif cmd in ("quit", "exit", "q"):
148
+ return "quit"
149
+ else:
150
+ console.print(f"[yellow]Unknown command: /{cmd}[/yellow]")
151
+ console.print("Type [bold]/help[/bold] for available commands.")
152
+ return None
153
+
154
+
155
+ def start_repl(model: str, api_url: str) -> None:
156
+ """Start the interactive REPL."""
157
+ try:
158
+ from prompt_toolkit import PromptSession
159
+ from prompt_toolkit.history import FileHistory
160
+ from bithub.config import BITHUB_HOME
161
+
162
+ history_file = BITHUB_HOME / "repl_history"
163
+ BITHUB_HOME.mkdir(parents=True, exist_ok=True)
164
+ prompt_session: Optional[PromptSession] = PromptSession(
165
+ history=FileHistory(str(history_file))
166
+ )
167
+ except ImportError:
168
+ prompt_session = None
169
+
170
+ session = ChatSession(model=model, api_url=api_url)
171
+
172
+ console.print(f"[bold green]Chat with {model}[/bold green]")
173
+ console.print(f"[dim]API: {api_url} | Type /help for commands | Ctrl+D to exit[/dim]\n")
174
+
175
+ while True:
176
+ try:
177
+ if prompt_session:
178
+ user_input = prompt_session.prompt(f"[{model}] > ")
179
+ else:
180
+ user_input = input(f"[{model}] > ")
181
+ except (EOFError, KeyboardInterrupt):
182
+ console.print("\n[green]Goodbye![/green]")
183
+ break
184
+
185
+ user_input = user_input.strip()
186
+ if not user_input:
187
+ continue
188
+
189
+ if is_slash_command(user_input):
190
+ cmd, arg = parse_slash_command(user_input)
191
+ result = handle_slash_command(cmd, arg, session)
192
+ if result == "quit":
193
+ console.print("[green]Goodbye![/green]")
194
+ break
195
+ continue
196
+
197
+ # Send message to API
198
+ session.add_message("user", user_input)
199
+ console.print()
200
+ response = session.send_and_stream()
201
+ if response:
202
+ session.add_message("assistant", response)
203
+ console.print()
bithub/server.py ADDED
@@ -0,0 +1,226 @@
1
+ """
2
+ Server — start bithub with an OpenAI-compatible API.
3
+
4
+ Two modes:
5
+ 1. `serve` — starts a FastAPI server that proxies to the bitnet.cpp
6
+ backend, providing /v1/chat/completions and /v1/models.
7
+ 2. `run` — interactive terminal chat via llama-cli.
8
+ """
9
+
10
+ import signal
11
+ import subprocess
12
+ import sys
13
+ import threading
14
+ from pathlib import Path
15
+ from typing import List, Optional
16
+
17
+ import httpx
18
+
19
+ from rich.console import Console
20
+
21
+ from bithub.builder import get_server_binary, get_inference_binary, is_bitnet_cpp_built
22
+ from bithub.config import DEFAULT_HOST, DEFAULT_PORT
23
+ from bithub.downloader import get_model_gguf_path, is_model_downloaded
24
+ from bithub.registry import get_model_info
25
+
26
+ console = Console()
27
+
28
+
29
+ def _preflight_check(model_name: str) -> Path:
30
+ """
31
+ Run common checks before serving or chatting.
32
+ Returns the GGUF path on success, exits on failure.
33
+ """
34
+ if not is_bitnet_cpp_built():
35
+ console.print("[red]bitnet.cpp is not built yet.[/red]")
36
+ console.print("Run [bold]bithub setup[/bold] first to clone and build the engine.")
37
+ raise SystemExit(1)
38
+
39
+ if not is_model_downloaded(model_name):
40
+ console.print(f"[red]Model {model_name} is not downloaded.[/red]")
41
+ console.print(f"Run [bold]bithub pull {model_name}[/bold] first.")
42
+ raise SystemExit(1)
43
+
44
+ gguf_path = get_model_gguf_path(model_name)
45
+ if not gguf_path:
46
+ console.print(f"[red]Could not find GGUF file for {model_name}.[/red]")
47
+ raise SystemExit(1)
48
+
49
+ return gguf_path
50
+
51
+
52
+ def start_server(
53
+ model_names: Optional[List[str]] = None,
54
+ model_name: Optional[str] = None, # backwards compat
55
+ host: str = DEFAULT_HOST,
56
+ port: int = DEFAULT_PORT,
57
+ threads: int = 2,
58
+ context_size: int = 2048,
59
+ lazy: bool = False,
60
+ ) -> None:
61
+ """
62
+ Start the bithub API server with one or more models.
63
+
64
+ This provides OpenAI-compatible endpoints:
65
+ GET /v1/models
66
+ POST /v1/chat/completions (streaming + non-streaming)
67
+ GET /health
68
+
69
+ Args:
70
+ model_names: List of short names from registry
71
+ model_name: Single model name (backwards compat)
72
+ host: Address to bind to
73
+ port: Port to listen on
74
+ threads: Number of CPU threads per model
75
+ context_size: Context window size in tokens
76
+ lazy: If True, only load models on first request
77
+ """
78
+ # Handle both old and new calling conventions
79
+ if model_names is None:
80
+ if model_name:
81
+ model_names = [model_name]
82
+ else:
83
+ console.print("[red]No models specified.[/red]")
84
+ raise SystemExit(1)
85
+
86
+ from bithub.model_manager import ModelManager
87
+ from bithub.api import create_app
88
+ import uvicorn
89
+
90
+ backend_base_port = port + 1
91
+ manager = ModelManager(base_port=backend_base_port, max_models=len(model_names))
92
+
93
+ for name in model_names:
94
+ gguf_path = _preflight_check(name)
95
+ manager.register(name, gguf_path, threads=threads, context_size=context_size)
96
+
97
+ console.print(f"\n[bold green]Starting bithub server[/bold green]")
98
+ for name in model_names:
99
+ info = get_model_info(name)
100
+ display_name = info["name"] if info else name
101
+ console.print(f" Model: {display_name}")
102
+ console.print(f" Address: http://{host}:{port}")
103
+ console.print(f" Threads: {threads} per model")
104
+ if len(model_names) > 1:
105
+ console.print(f" Mode: {'lazy' if lazy else 'eager'} loading")
106
+ console.print()
107
+ console.print("[dim]Press Ctrl+C to stop the server[/dim]\n")
108
+
109
+ app = create_app(
110
+ model_name=model_names[0],
111
+ gguf_path=_preflight_check(model_names[0]),
112
+ manager=manager,
113
+ )
114
+
115
+ try:
116
+ uvicorn.run(app, host=host, port=port, log_level="warning")
117
+ except KeyboardInterrupt:
118
+ console.print("\n[green]Server stopped.[/green]")
119
+
120
+
121
+ def start_background_server(
122
+ model_name: str,
123
+ host: str = "127.0.0.1",
124
+ port: int = 8081,
125
+ threads: int = 2,
126
+ context_size: int = 2048,
127
+ ) -> threading.Thread:
128
+ """Start the API server in a background thread for REPL use."""
129
+ gguf_path = _preflight_check(model_name)
130
+
131
+ from bithub.api import create_app
132
+ import uvicorn
133
+
134
+ backend_port = port + 1
135
+ app = create_app(
136
+ model_name=model_name,
137
+ gguf_path=gguf_path,
138
+ threads=threads,
139
+ context_size=context_size,
140
+ backend_port=backend_port,
141
+ )
142
+
143
+ server_thread = threading.Thread(
144
+ target=uvicorn.run,
145
+ kwargs={"app": app, "host": host, "port": port, "log_level": "error"},
146
+ daemon=True,
147
+ )
148
+ server_thread.start()
149
+ return server_thread
150
+
151
+
152
+ def wait_for_server(url: str, timeout: float = 30.0) -> bool:
153
+ """Wait for the API server to become ready."""
154
+ import time
155
+ start = time.time()
156
+ while time.time() - start < timeout:
157
+ try:
158
+ resp = httpx.get(f"{url}/health", timeout=2.0)
159
+ if resp.status_code == 200:
160
+ return True
161
+ except (httpx.ConnectError, httpx.ReadTimeout):
162
+ pass
163
+ time.sleep(0.5)
164
+ return False
165
+
166
+
167
+ def run_interactive(
168
+ model_name: str,
169
+ threads: int = 2,
170
+ context_size: int = 2048,
171
+ ) -> None:
172
+ """
173
+ Run interactive chat with a model in the terminal.
174
+
175
+ Uses llama-cli in interactive/conversation mode.
176
+
177
+ Args:
178
+ model_name: Short name from registry
179
+ threads: Number of CPU threads
180
+ context_size: Context window size
181
+ """
182
+ gguf_path = _preflight_check(model_name)
183
+
184
+ cli_bin = get_inference_binary()
185
+ if not cli_bin:
186
+ console.print("[red]No inference binary found.[/red]")
187
+ raise SystemExit(1)
188
+
189
+ info = get_model_info(model_name)
190
+ display_name = info["name"] if info else model_name
191
+
192
+ console.print(f"\n[bold green]Chat with {display_name}[/bold green]")
193
+ console.print(f" Using: {gguf_path.name}")
194
+ console.print(f" Threads: {threads}")
195
+ console.print("[dim]Press Ctrl+C to exit[/dim]\n")
196
+
197
+ cmd = [
198
+ str(cli_bin),
199
+ "-m", str(gguf_path),
200
+ "-t", str(threads),
201
+ "-c", str(context_size),
202
+ "--interactive",
203
+ "--color",
204
+ ]
205
+
206
+ try:
207
+ process = subprocess.Popen(cmd)
208
+ process.wait()
209
+ if process.returncode != 0:
210
+ console.print(
211
+ f"\n[red]Process exited with code {process.returncode}.[/red] "
212
+ f"Run [bold]bithub status[/bold] to check your setup."
213
+ )
214
+ except FileNotFoundError:
215
+ console.print(
216
+ "[red]Inference binary not found.[/red] "
217
+ "Run [bold]bithub setup[/bold] to rebuild."
218
+ )
219
+ raise SystemExit(1)
220
+ except KeyboardInterrupt:
221
+ console.print("\n[green]Chat ended.[/green]")
222
+ process.send_signal(signal.SIGTERM)
223
+ try:
224
+ process.wait(timeout=5)
225
+ except subprocess.TimeoutExpired:
226
+ process.kill()
bithub/static/app.js ADDED
@@ -0,0 +1,200 @@
1
+ /* bithub dashboard — single-page app */
2
+ const API_BASE = '';
3
+
4
+ function navigateTo(page) {
5
+ document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
6
+ document.querySelectorAll('.nav-link').forEach(l => l.classList.remove('active'));
7
+ const pageEl = document.getElementById('page-' + page);
8
+ const linkEl = document.querySelector('[data-page="' + page + '"]');
9
+ if (pageEl) pageEl.classList.add('active');
10
+ if (linkEl) linkEl.classList.add('active');
11
+ if (page === 'models') loadModels();
12
+ if (page === 'server') loadStats();
13
+ if (page === 'settings') loadSettings();
14
+ if (page === 'chat') loadModelSelect();
15
+ }
16
+
17
+ function initRouter() {
18
+ window.addEventListener('hashchange', () => {
19
+ const page = location.hash.replace('#/', '') || 'chat';
20
+ navigateTo(page);
21
+ });
22
+ navigateTo(location.hash.replace('#/', '') || 'chat');
23
+ }
24
+
25
+ const chatMessages = [];
26
+ let streaming = false;
27
+
28
+ function loadModelSelect() {
29
+ fetch(API_BASE + '/v1/models').then(r => r.json()).then(data => {
30
+ const select = document.getElementById('model-select');
31
+ if (!select) return;
32
+ const current = select.value;
33
+ select.innerHTML = '';
34
+ (data.data || []).forEach(m => {
35
+ const opt = document.createElement('option');
36
+ opt.value = m.id;
37
+ opt.textContent = m.id + (m.status === 'loaded' ? ' (loaded)' : '');
38
+ select.appendChild(opt);
39
+ });
40
+ if (current) select.value = current;
41
+ }).catch(() => {});
42
+ }
43
+
44
+ function addChatMessage(role, content) {
45
+ chatMessages.push({ role, content });
46
+ renderChat();
47
+ }
48
+
49
+ function renderChat() {
50
+ const container = document.getElementById('chat-messages');
51
+ if (!container) return;
52
+ container.innerHTML = '';
53
+ chatMessages.forEach(msg => {
54
+ const div = document.createElement('div');
55
+ div.className = 'message ' + msg.role;
56
+ div.innerHTML = '<span class="message-role">' + msg.role + '</span>' +
57
+ '<div class="message-content">' + escapeHtml(msg.content) + '</div>';
58
+ container.appendChild(div);
59
+ });
60
+ container.scrollTop = container.scrollHeight;
61
+ }
62
+
63
+ function escapeHtml(str) {
64
+ const div = document.createElement('div');
65
+ div.textContent = str;
66
+ return div.innerHTML;
67
+ }
68
+
69
+ async function sendMessage() {
70
+ const input = document.getElementById('chat-input');
71
+ const model = document.getElementById('model-select');
72
+ if (!input || !model || streaming) return;
73
+ const text = input.value.trim();
74
+ if (!text) return;
75
+ input.value = '';
76
+ addChatMessage('user', text);
77
+ streaming = true;
78
+ const messages = chatMessages.map(m => ({ role: m.role, content: m.content }));
79
+ try {
80
+ const response = await fetch(API_BASE + '/v1/chat/completions', {
81
+ method: 'POST',
82
+ headers: { 'Content-Type': 'application/json' },
83
+ body: JSON.stringify({ model: model.value, messages, stream: true }),
84
+ });
85
+ if (!response.ok) { addChatMessage('assistant', 'Error: ' + response.statusText); streaming = false; return; }
86
+ const reader = response.body.getReader();
87
+ const decoder = new TextDecoder();
88
+ let assistantText = '';
89
+ chatMessages.push({ role: 'assistant', content: '' });
90
+ while (true) {
91
+ const { done, value } = await reader.read();
92
+ if (done) break;
93
+ const chunk = decoder.decode(value, { stream: true });
94
+ for (const line of chunk.split('\n')) {
95
+ if (!line.startsWith('data: ')) continue;
96
+ const data = line.slice(6);
97
+ if (data === '[DONE]') break;
98
+ try {
99
+ const parsed = JSON.parse(data);
100
+ const delta = parsed.choices?.[0]?.delta?.content || '';
101
+ if (delta) {
102
+ assistantText += delta;
103
+ chatMessages[chatMessages.length - 1].content = assistantText;
104
+ renderChat();
105
+ }
106
+ } catch (e) {}
107
+ }
108
+ }
109
+ } catch (err) { addChatMessage('assistant', 'Error: ' + err.message); }
110
+ streaming = false;
111
+ }
112
+
113
+ function loadModels() {
114
+ Promise.all([
115
+ fetch(API_BASE + '/v1/models').then(r => r.json()),
116
+ fetch(API_BASE + '/api/models/downloaded').then(r => r.json()),
117
+ ]).then(([modelsResp, downloaded]) => {
118
+ const container = document.getElementById('models-list');
119
+ if (!container) return;
120
+ container.innerHTML = '';
121
+ const models = modelsResp.data || [];
122
+ models.forEach(m => {
123
+ const dl = downloaded.find(d => d.name === m.id);
124
+ const size = dl ? dl.size_mb + ' MB' : 'N/A';
125
+ const statusClass = m.status === 'loaded' ? 'status-loaded' : 'status-available';
126
+ container.innerHTML +=
127
+ '<div class="model-card"><h3>' + escapeHtml(m.id) + '</h3>' +
128
+ '<div class="meta">Size: ' + size + '</div>' +
129
+ '<div class="status"><span class="status-badge ' + statusClass + '">' + m.status + '</span></div>' +
130
+ (dl ? '<button class="btn btn-danger btn-sm" style="margin-top:12px" onclick="deleteModel(\'' + m.id + '\')">Delete</button>' : '') +
131
+ '</div>';
132
+ });
133
+ if (!models.length) container.innerHTML = '<p style="color:var(--text-secondary)">No models found. Pull one with: bithub pull 2B-4T</p>';
134
+ }).catch(() => {});
135
+ }
136
+
137
+ function deleteModel(name) {
138
+ if (!confirm('Delete model ' + name + '?')) return;
139
+ fetch(API_BASE + '/api/models/' + name, { method: 'DELETE' }).then(r => { if (r.ok) loadModels(); }).catch(() => {});
140
+ }
141
+
142
+ function loadStats() {
143
+ fetch(API_BASE + '/api/stats').then(r => r.json()).then(data => {
144
+ const container = document.getElementById('server-stats');
145
+ if (!container) return;
146
+ container.innerHTML =
147
+ statCard(formatUptime(data.uptime_seconds || 0), 'Uptime') +
148
+ statCard(data.total_requests || 0, 'Requests') +
149
+ statCard(data.models_loaded || 0, 'Models Loaded') +
150
+ statCard(data.models_registered || 0, 'Models Registered');
151
+ }).catch(() => {});
152
+ }
153
+
154
+ function statCard(value, label) {
155
+ return '<div class="stat-card"><div class="stat-value">' + value + '</div><div class="stat-label">' + label + '</div></div>';
156
+ }
157
+
158
+ function formatUptime(s) {
159
+ if (s < 60) return s + 's';
160
+ if (s < 3600) return Math.floor(s / 60) + 'm';
161
+ return Math.floor(s / 3600) + 'h ' + Math.floor((s % 3600) / 60) + 'm';
162
+ }
163
+
164
+ function loadSettings() {
165
+ fetch(API_BASE + '/api/config').then(r => r.json()).then(config => {
166
+ const container = document.getElementById('settings-form');
167
+ if (!container) return;
168
+ container.innerHTML =
169
+ formGroup('Server Port', 'settings-port', config.server?.port || 8080, 'number') +
170
+ formGroup('Server Host', 'settings-host', config.server?.host || '127.0.0.1', 'text') +
171
+ formGroup('Threads', 'settings-threads', config.server?.threads || 4, 'number') +
172
+ formGroup('Min Free GB', 'settings-free-gb', config.download?.min_free_gb || 5, 'number') +
173
+ '<div class="form-group"><label>Theme</label>' +
174
+ '<select id="theme-select" onchange="toggleTheme(this.value)">' +
175
+ '<option value="dark"' + (getTheme() === 'dark' ? ' selected' : '') + '>Dark</option>' +
176
+ '<option value="light"' + (getTheme() === 'light' ? ' selected' : '') + '>Light</option>' +
177
+ '</select></div>';
178
+ }).catch(() => {});
179
+ }
180
+
181
+ function formGroup(label, id, value, type) {
182
+ return '<div class="form-group"><label for="' + id + '">' + label + '</label>' +
183
+ '<input type="' + type + '" id="' + id + '" value="' + value + '" readonly></div>';
184
+ }
185
+
186
+ function getTheme() { return localStorage.getItem('bithub-theme') || 'dark'; }
187
+ function toggleTheme(theme) { document.documentElement.setAttribute('data-theme', theme); localStorage.setItem('bithub-theme', theme); }
188
+
189
+ document.addEventListener('DOMContentLoaded', () => {
190
+ toggleTheme(getTheme());
191
+ initRouter();
192
+ document.getElementById('send-btn')?.addEventListener('click', sendMessage);
193
+ document.getElementById('chat-input')?.addEventListener('keydown', e => {
194
+ if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); }
195
+ });
196
+ document.getElementById('clear-chat')?.addEventListener('click', () => { chatMessages.length = 0; renderChat(); });
197
+ setInterval(() => {
198
+ if (document.getElementById('page-server')?.classList.contains('active')) loadStats();
199
+ }, 10000);
200
+ });