Spaces:
Sleeping
Sleeping
| #!/usr/bin/env python3 | |
| """ | |
| THEVOIDKERNEL Portable AI Chat Server | |
| ======================= | |
| A zero-dependency Python HTTP server that: | |
| 1. Serves the FastChatUI.html web interface | |
| 2. Saves/loads chat history as JSON files on the USB drive | |
| 3. Proxies all Ollama API requests (eliminates CORS issues) | |
| Works on Windows, macOS, and Linux without installing anything. | |
| """ | |
| import http.server | |
| import json | |
| import os | |
| import sys | |
| import urllib.request | |
| import urllib.error | |
| import threading | |
| import webbrowser | |
| import time | |
| import platform | |
| import ctypes | |
| import ctypes.util | |
| from urllib.parse import urlparse | |
| # Optional: psutil for hardware stats (graceful fallback to native APIs if not installed) | |
| try: | |
| import psutil | |
| HAS_PSUTIL = True | |
| except ImportError: | |
| HAS_PSUTIL = False | |
| # ── Configuration ────────────────────────────────────────────── | |
| CHAT_SERVER_PORT = 3333 | |
| OLLAMA_HOST = "http://127.0.0.1:11434" | |
| LLAMA_CPP_MODE = "--llama-cpp" in sys.argv | |
| if LLAMA_CPP_MODE: | |
| OLLAMA_HOST = "http://127.0.0.1:8080" | |
| # Always resolve paths relative to THIS script's location (the USB drive) | |
| SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) | |
| CHATS_DIR = os.path.join(SCRIPT_DIR, "chat_data") | |
| CHATS_FILE = os.path.join(CHATS_DIR, "chats.json") | |
| SETTINGS_FILE = os.path.join(CHATS_DIR, "settings.json") | |
| HTML_FILE = os.path.join(SCRIPT_DIR, "FastChatUI.html") | |
| # ── Pure-Python Hardware Stats (no psutil needed) ────────────── | |
| _cpu_times_last = None # (idle, total) from previous sample | |
| def _get_hw_stats(): | |
| """Return (cpu_percent, ram_percent) using only stdlib / ctypes.""" | |
| global _cpu_times_last # must be at top of function, before any branch uses it | |
| if HAS_PSUTIL: | |
| cpu = round(psutil.cpu_percent(interval=0.25), 1) | |
| ram = round(psutil.virtual_memory().percent, 1) | |
| return cpu, ram | |
| plat = platform.system() | |
| # ── Windows ────────────────────────────────────────────────── | |
| if plat == "Windows": | |
| # RAM via GlobalMemoryStatusEx | |
| class MEMORYSTATUSEX(ctypes.Structure): | |
| _fields_ = [ | |
| ("dwLength", ctypes.c_ulong), | |
| ("dwMemoryLoad", ctypes.c_ulong), | |
| ("ullTotalPhys", ctypes.c_ulonglong), | |
| ("ullAvailPhys", ctypes.c_ulonglong), | |
| ("ullTotalPageFile", ctypes.c_ulonglong), | |
| ("ullAvailPageFile", ctypes.c_ulonglong), | |
| ("ullTotalVirtual", ctypes.c_ulonglong), | |
| ("ullAvailVirtual", ctypes.c_ulonglong), | |
| ("ullAvailExtendedVirtual", ctypes.c_ulonglong), | |
| ] | |
| msx = MEMORYSTATUSEX() | |
| msx.dwLength = ctypes.sizeof(msx) | |
| ctypes.windll.kernel32.GlobalMemoryStatusEx(ctypes.byref(msx)) | |
| ram = float(msx.dwMemoryLoad) | |
| # CPU via GetSystemTimes (idle/kernel/user tick counts) | |
| FILETIME = ctypes.c_ulonglong | |
| idle, kern, user = FILETIME(), FILETIME(), FILETIME() | |
| ctypes.windll.kernel32.GetSystemTimes( | |
| ctypes.byref(idle), ctypes.byref(kern), ctypes.byref(user)) | |
| idle_v = idle.value | |
| total_v = kern.value + user.value | |
| if _cpu_times_last is None: | |
| # First call — sleep briefly and sample again | |
| time.sleep(0.25) | |
| idle2, kern2, user2 = FILETIME(), FILETIME(), FILETIME() | |
| ctypes.windll.kernel32.GetSystemTimes( | |
| ctypes.byref(idle2), ctypes.byref(kern2), ctypes.byref(user2)) | |
| d_idle = idle2.value - idle_v | |
| d_total = (kern2.value + user2.value) - total_v | |
| _cpu_times_last = (idle2.value, kern2.value + user2.value) | |
| else: | |
| prev_idle, prev_total = _cpu_times_last | |
| d_idle = idle_v - prev_idle | |
| d_total = total_v - prev_total | |
| _cpu_times_last = (idle_v, total_v) | |
| cpu = round((1.0 - d_idle / max(d_total, 1)) * 100.0, 1) | |
| cpu = max(0.0, min(100.0, cpu)) | |
| return cpu, ram | |
| # ── Linux ───────────────────────────────────────────────────── | |
| elif plat == "Linux": | |
| # RAM | |
| ram = 0.0 | |
| try: | |
| with open("/proc/meminfo") as f: | |
| mem = {} | |
| for line in f: | |
| parts = line.split() | |
| if len(parts) >= 2: | |
| mem[parts[0].rstrip(":")] = int(parts[1]) | |
| total = mem.get("MemTotal", 1) | |
| avail = mem.get("MemAvailable", total) | |
| ram = round((1 - avail / total) * 100, 1) | |
| except Exception: | |
| pass | |
| # CPU via /proc/stat delta | |
| cpu = 0.0 | |
| try: | |
| def read_cpu(): | |
| with open("/proc/stat") as f: | |
| parts = f.readline().split() | |
| vals = [int(x) for x in parts[1:]] | |
| idle = vals[3] | |
| total = sum(vals) | |
| return idle, total | |
| if _cpu_times_last is None: | |
| i1, t1 = read_cpu() | |
| time.sleep(0.25) | |
| i2, t2 = read_cpu() | |
| else: | |
| i1, t1 = _cpu_times_last | |
| i2, t2 = read_cpu() | |
| _cpu_times_last = (i2, t2) | |
| d_idle = i2 - i1 | |
| d_total = t2 - t1 | |
| cpu = round((1 - d_idle / max(d_total, 1)) * 100, 1) | |
| except Exception: | |
| pass | |
| return cpu, ram | |
| # ── macOS ───────────────────────────────────────────────────── | |
| else: | |
| # User requested to skip macOS usage to avoid any potential permission/execution issues | |
| cpu = 0.0 | |
| ram = 0.0 | |
| return cpu, ram | |
| def ensure_data_dir(): | |
| """Create the chat_data folder on the USB if it doesn't exist.""" | |
| os.makedirs(CHATS_DIR, exist_ok=True) | |
| if not os.path.exists(CHATS_FILE): | |
| with open(CHATS_FILE, "w", encoding="utf-8") as f: | |
| json.dump([], f) | |
| if not os.path.exists(SETTINGS_FILE): | |
| with open(SETTINGS_FILE, "w", encoding="utf-8") as f: | |
| json.dump({"systemPrompt": "", "temperature": 0.7}, f) | |
| class ChatHandler(http.server.BaseHTTPRequestHandler): | |
| """Handles all HTTP requests for the Portable AI Chat.""" | |
| def log_message(self, format, *args): | |
| """Print all requests for easy debugging.""" | |
| msg = format % args | |
| ts = time.strftime("%H:%M:%S") | |
| # Colour-code by status: errors red, warnings yellow, ok green | |
| if "404" in msg or "500" in msg or "502" in msg: | |
| prefix = " \033[91m[ERR]\033[0m" | |
| elif "200" in msg or "204" in msg: | |
| prefix = " \033[92m[ OK]\033[0m" | |
| else: | |
| prefix = " \033[93m[---]\033[0m" | |
| print(f"{prefix} {ts} {msg}") | |
| # ── CORS headers ─────────────────────────────────────────── | |
| def _cors_headers(self): | |
| self.send_header("Access-Control-Allow-Origin", "*") | |
| self.send_header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") | |
| self.send_header("Access-Control-Allow-Headers", "Content-Type, Authorization") | |
| def do_OPTIONS(self): | |
| """Handle CORS preflight.""" | |
| self.send_response(204) | |
| self._cors_headers() | |
| self.end_headers() | |
| # ── Routing ──────────────────────────────────────────────── | |
| def do_GET(self): | |
| path = urlparse(self.path).path | |
| # Serve the main UI | |
| if path == "/" or path == "/index.html": | |
| self._serve_html() | |
| # Chat data API | |
| elif path == "/api/chats": | |
| self._get_chats() | |
| # Settings API | |
| elif path == "/api/settings": | |
| self._get_settings() | |
| # Hardware stats API | |
| elif path == "/api/stats": | |
| self._get_stats() | |
| # Proxy Ollama API | |
| elif path.startswith("/ollama/"): | |
| self._proxy_ollama("GET") | |
| else: | |
| # Try serving static files from SCRIPT_DIR | |
| self._serve_static(path) | |
| def do_POST(self): | |
| path = urlparse(self.path).path | |
| if path == "/api/chats": | |
| self._save_chats() | |
| elif path == "/api/settings": | |
| self._save_settings() | |
| # Proxy Ollama API | |
| elif path.startswith("/ollama/"): | |
| self._proxy_ollama("POST") | |
| else: | |
| self.send_response(404) | |
| self._cors_headers() | |
| self.end_headers() | |
| def do_DELETE(self): | |
| path = urlparse(self.path).path | |
| if path.startswith("/ollama/"): | |
| self._proxy_ollama("DELETE") | |
| else: | |
| self.send_response(404) | |
| self._cors_headers() | |
| self.end_headers() | |
| # ── Serve HTML ───────────────────────────────────────────── | |
| def _serve_html(self): | |
| try: | |
| with open(HTML_FILE, "rb") as f: | |
| content = f.read() | |
| self.send_response(200) | |
| self.send_header("Content-Type", "text/html; charset=utf-8") | |
| self._cors_headers() | |
| self.end_headers() | |
| self.wfile.write(content) | |
| except FileNotFoundError: | |
| self.send_response(404) | |
| self.end_headers() | |
| self.wfile.write(b"FastChatUI.html not found.") | |
| def _serve_static(self, path): | |
| """Serve static files (CSS, JS, images) from SCRIPT_DIR.""" | |
| safe_path = os.path.normpath(path.lstrip("/")) | |
| full_path = os.path.join(SCRIPT_DIR, safe_path) | |
| # Security: don't allow path traversal | |
| if not full_path.startswith(SCRIPT_DIR): | |
| self.send_response(403) | |
| self.end_headers() | |
| return | |
| if os.path.isfile(full_path): | |
| ext = os.path.splitext(full_path)[1].lower() | |
| mime_types = { | |
| ".html": "text/html", ".css": "text/css", ".js": "application/javascript", | |
| ".json": "application/json", ".png": "image/png", ".jpg": "image/jpeg", | |
| ".svg": "image/svg+xml", ".ico": "image/x-icon" | |
| } | |
| content_type = mime_types.get(ext, "application/octet-stream") | |
| with open(full_path, "rb") as f: | |
| content = f.read() | |
| self.send_response(200) | |
| self.send_header("Content-Type", content_type) | |
| self._cors_headers() | |
| self.end_headers() | |
| self.wfile.write(content) | |
| else: | |
| self.send_response(404) | |
| self.end_headers() | |
| # ── Chat Persistence ─────────────────────────────────────── | |
| def _get_chats(self): | |
| try: | |
| with open(CHATS_FILE, "r", encoding="utf-8") as f: | |
| data = f.read() | |
| except (FileNotFoundError, json.JSONDecodeError): | |
| data = "[]" | |
| self.send_response(200) | |
| self.send_header("Content-Type", "application/json") | |
| self._cors_headers() | |
| self.end_headers() | |
| self.wfile.write(data.encode("utf-8")) | |
| def _save_chats(self): | |
| length = int(self.headers.get("Content-Length", 0)) | |
| body = self.rfile.read(length) | |
| try: | |
| chats = json.loads(body) | |
| with open(CHATS_FILE, "w", encoding="utf-8") as f: | |
| json.dump(chats, f, ensure_ascii=False, indent=2) | |
| self.send_response(200) | |
| self.send_header("Content-Type", "application/json") | |
| self._cors_headers() | |
| self.end_headers() | |
| self.wfile.write(json.dumps({"ok": True}).encode()) | |
| except Exception as e: | |
| self.send_response(500) | |
| self._cors_headers() | |
| self.end_headers() | |
| self.wfile.write(json.dumps({"error": str(e)}).encode()) | |
| def _get_settings(self): | |
| try: | |
| with open(SETTINGS_FILE, "r", encoding="utf-8") as f: | |
| data = f.read() | |
| except (FileNotFoundError, json.JSONDecodeError): | |
| data = "{}" | |
| self.send_response(200) | |
| self.send_header("Content-Type", "application/json") | |
| self._cors_headers() | |
| self.end_headers() | |
| self.wfile.write(data.encode("utf-8")) | |
| def _save_settings(self): | |
| length = int(self.headers.get("Content-Length", 0)) | |
| body = self.rfile.read(length) | |
| try: | |
| settings = json.loads(body) | |
| with open(SETTINGS_FILE, "w", encoding="utf-8") as f: | |
| json.dump(settings, f, ensure_ascii=False, indent=2) | |
| self.send_response(200) | |
| self.send_header("Content-Type", "application/json") | |
| self._cors_headers() | |
| self.end_headers() | |
| self.wfile.write(json.dumps({"ok": True}).encode()) | |
| except Exception as e: | |
| self.send_response(500) | |
| self._cors_headers() | |
| self.end_headers() | |
| self.wfile.write(json.dumps({"error": str(e)}).encode()) | |
| # ── Hardware Stats ───────────────────────────────────────── | |
| def _get_stats(self): | |
| """Return CPU % and RAM % as JSON. Works with no external packages.""" | |
| try: | |
| cpu, ram = _get_hw_stats() | |
| data = json.dumps({"cpu_percent": cpu, "ram_percent": ram, "has_psutil": HAS_PSUTIL}) | |
| self.send_response(200) | |
| self.send_header("Content-Type", "application/json") | |
| self._cors_headers() | |
| self.end_headers() | |
| self.wfile.write(data.encode()) | |
| except Exception as e: | |
| self.send_response(500) | |
| self._cors_headers() | |
| self.end_headers() | |
| self.wfile.write(json.dumps({"error": str(e)}).encode()) | |
| # ── Ollama Proxy (streaming-aware) ───────────────────────── | |
| def _proxy_ollama(self, method): | |
| """ | |
| Proxy requests from /ollama/* to the local Ollama engine. | |
| Supports streaming responses for /api/chat and /api/generate. | |
| """ | |
| # Strip the /ollama prefix to get the real Ollama path | |
| ollama_path = self.path[len("/ollama"):] | |
| target_url = OLLAMA_HOST + ollama_path | |
| # Read request body if present | |
| body = None | |
| content_length = int(self.headers.get("Content-Length", 0)) | |
| if content_length > 0: | |
| body = self.rfile.read(content_length) | |
| try: | |
| # Handle fake /api/tags for llama.cpp mode | |
| if LLAMA_CPP_MODE and ollama_path == "/api/tags": | |
| self.send_response(200) | |
| self.send_header("Content-Type", "application/json") | |
| self._cors_headers() | |
| self.end_headers() | |
| self.wfile.write(json.dumps({"models":[{"name": "local-llama-model"}]}).encode()) | |
| return | |
| if LLAMA_CPP_MODE and ollama_path == "/api/chat": | |
| # Translate Ollama payload -> OpenAI payload for llama-server | |
| ollama_req = json.loads(body) if body else {} | |
| openai_req = { | |
| "messages": ollama_req.get("messages", []), | |
| "stream": True, | |
| "temperature": ollama_req.get("options", {}).get("temperature", 0.7) | |
| } | |
| target_url = OLLAMA_HOST + "/v1/chat/completions" | |
| body = json.dumps(openai_req).encode() | |
| req = urllib.request.Request( | |
| target_url, | |
| data=body, | |
| method=method, | |
| headers={"Content-Type": self.headers.get("Content-Type", "application/json")} | |
| ) | |
| # Optional: pass Authorization header if present | |
| if "Authorization" in self.headers: | |
| req.add_header("Authorization", self.headers.get("Authorization")) | |
| response = urllib.request.urlopen(req, timeout=600) | |
| # Send response headers | |
| self.send_response(response.status) | |
| is_stream = ("/api/chat" in ollama_path or "/api/generate" in ollama_path) | |
| for header, value in response.getheaders(): | |
| lower = header.lower() | |
| if lower not in ("transfer-encoding", "connection", "content-length"): | |
| self.send_header(header, value) | |
| self._cors_headers() | |
| self.end_headers() | |
| # Stream the response in chunks | |
| while True: | |
| chunk = response.read(4096) | |
| if not chunk: | |
| break | |
| # If bridging llama.cpp SSE to Ollama JSONL | |
| if LLAMA_CPP_MODE and is_stream: | |
| text = chunk.decode(errors="ignore") | |
| lines = text.split("\n") | |
| for line in lines: | |
| if line.startswith("data: "): | |
| data = line[6:].strip() | |
| if data == "[DONE]": | |
| break | |
| try: | |
| j = json.loads(data) | |
| if "choices" in j and len(j["choices"]) > 0: | |
| delta = j["choices"][0].get("delta", {}) | |
| out = { | |
| "message": {"role": "assistant", "content": delta.get("content", "")}, | |
| "done": False | |
| } | |
| self.wfile.write((json.dumps(out) + "\n").encode()) | |
| self.wfile.flush() | |
| except: | |
| pass | |
| else: | |
| self.wfile.write(chunk) | |
| if is_stream: | |
| self.wfile.flush() | |
| except urllib.error.HTTPError as e: | |
| self.send_response(e.code) | |
| self._cors_headers() | |
| self.end_headers() | |
| try: | |
| self.wfile.write(e.read()) | |
| except: | |
| pass | |
| except urllib.error.URLError as e: | |
| self.send_response(502) | |
| self._cors_headers() | |
| self.end_headers() | |
| msg = json.dumps({"error": f"Cannot reach Ollama engine: {str(e.reason)}"}) | |
| self.wfile.write(msg.encode()) | |
| except Exception as e: | |
| self.send_response(500) | |
| self._cors_headers() | |
| self.end_headers() | |
| self.wfile.write(json.dumps({"error": str(e)}).encode()) | |
| class ThreadedHTTPServer(http.server.HTTPServer): | |
| """Handle each request in a new thread for concurrent streaming.""" | |
| def process_request(self, request, client_address): | |
| thread = threading.Thread(target=self._handle, args=(request, client_address)) | |
| thread.daemon = True | |
| thread.start() | |
| def _handle(self, request, client_address): | |
| try: | |
| self.finish_request(request, client_address) | |
| except Exception: | |
| self.handle_error(request, client_address) | |
| finally: | |
| self.shutdown_request(request) | |
| def open_browser_delayed(): | |
| """Open the browser after a short delay to ensure server is ready.""" | |
| time.sleep(1.0) | |
| webbrowser.open(f"http://localhost:{CHAT_SERVER_PORT}") | |
| def main(): | |
| ensure_data_dir() | |
| # Try to find the local LAN IP | |
| local_ip = "127.0.0.1" | |
| try: | |
| import socket | |
| s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) | |
| s.connect(("8.8.8.8", 80)) | |
| local_ip = s.getsockname()[0] | |
| s.close() | |
| except Exception: | |
| pass | |
| print() | |
| print("=" * 55) | |
| print(" Portable AI — Chat Server") | |
| print("=" * 55) | |
| print() | |
| print(f" Local Access: http://localhost:{CHAT_SERVER_PORT}") | |
| print(f" Network Access: http://{local_ip}:{CHAT_SERVER_PORT} <-- Use this on phone/other PC!") | |
| print(f" Ollama/Llama Proxy: {OLLAMA_HOST}") | |
| if LLAMA_CPP_MODE: | |
| print(" Running in LLAMA_CPP_MODE (Translating API requests)") | |
| print() | |
| print(" All chats auto-save to the USB drive!") | |
| print(" Press Ctrl+C to shut down.") | |
| print() | |
| print("-" * 55) | |
| server = ThreadedHTTPServer(("0.0.0.0", CHAT_SERVER_PORT), ChatHandler) | |
| # Open browser in background thread | |
| if "--no-browser" not in sys.argv: | |
| threading.Thread(target=open_browser_delayed, daemon=True).start() | |
| try: | |
| server.serve_forever() | |
| except KeyboardInterrupt: | |
| print("\n Shutting down chat server...") | |
| server.shutdown() | |
| print(" Goodbye!") | |
| if __name__ == "__main__": | |
| main() | |