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/__init__.py +3 -0
- bithub/api.py +286 -0
- bithub/builder.py +235 -0
- bithub/cli.py +401 -0
- bithub/config.py +102 -0
- bithub/dashboard_api.py +50 -0
- bithub/downloader.py +362 -0
- bithub/logging_setup.py +42 -0
- bithub/model_manager.py +206 -0
- bithub/registry.json +68 -0
- bithub/registry.py +55 -0
- bithub/repl.py +203 -0
- bithub/server.py +226 -0
- bithub/static/app.js +200 -0
- bithub/static/index.html +51 -0
- bithub/static/style.css +72 -0
- bithub-0.1.0.dist-info/METADATA +175 -0
- bithub-0.1.0.dist-info/RECORD +22 -0
- bithub-0.1.0.dist-info/WHEEL +5 -0
- bithub-0.1.0.dist-info/entry_points.txt +2 -0
- bithub-0.1.0.dist-info/licenses/LICENSE +21 -0
- bithub-0.1.0.dist-info/top_level.txt +1 -0
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
|
+
});
|