Add BuddAI local launcher script and ngrok integration

- Introduced `run_buddai.ps1` to automate the setup and launch of the BuddAI server.
- Implemented checks for Docker and Ollama services, ensuring they are running before starting the server.
- Added model verification and automatic pulling of required AI models.
- Created a Python virtual environment and installed necessary dependencies.
- Configured firewall rules for port 8000 and provided options for remote access via ngrok or Tailscale.
- Enhanced user experience with informative messages and QR code generation for easy access to the server.
- Included logic to determine the best public URL for the server based on available network configurations.
This commit is contained in:
JamesTheGiblet 2025-12-30 00:15:54 +00:00
parent 93c9395c6f
commit fc603464ee
9 changed files with 851 additions and 88 deletions

View file

@ -1,10 +0,0 @@
data/
__pycache__/
*.pyc
*.pyo
*.pyd
.git
.gitignore
tests/
venv/
env/

View file

@ -1,21 +0,0 @@
# Use an official Python runtime as a parent image
FROM python:3.10-slim
# Set the working directory in the container
WORKDIR /app
# Install dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy the current directory contents into the container at /app
COPY . .
# Create data directory structure
RUN mkdir -p data/uploads
# Make port 8000 available to the world outside this container
EXPOSE 8000
# Run the application
CMD ["python", "buddai_v3.2.py", "--server", "--port", "8000"]

69
REMOTE_ACCESS_LOG.md Normal file
View file

@ -0,0 +1,69 @@
# Remote Access Implementation Log
This document records the troubleshooting steps, failures, and solutions implemented to enable remote access (Ngrok & Tailscale) for the BuddAI system.
## 1. Ngrok Execution Failures
### Fail: "The term '.\ngrok' is not recognized"
**Cause:** The script assumed `ngrok.exe` was in the current folder, but it wasn't, or it wasn't in the system PATH.
**Fix:** Updated `run_buddai.ps1` to check both the global PATH (`ngrok`) and the local folder (`.\ngrok.exe`).
### Fail: "Start-Process : The system cannot find the file specified"
**Cause:** PowerShell's `Start-Process` command failed when using a relative path like `.\ngrok.exe`.
**Fix:** Implemented `Resolve-Path` to convert the relative path to an absolute path before execution.
## 2. Tunnel Timing Issues
### Fail: Empty URL returned
**Cause:** The script attempted to fetch the public URL from the Ngrok API immediately after starting the process. The tunnel takes a few seconds to establish.
**Fix:** Added a **retry loop** in PowerShell that polls `http://localhost:4040/api/tunnels` every second for up to 15 seconds.
## 3. Dependency Issues
### Fail: "ModuleNotFoundError: No module named 'PIL'"
**Cause:** The `qrcode` library was added to generate QR codes, but it relies on `pillow` (PIL) for image generation, which was missing.
**Fix:** Added `pillow` to `requirements.txt` and wrapped the import in `main.py` with a `try/except` block to prevent server crashes.
### Fail: PowerShell Parsing Errors
**Cause:** Complex one-liner Python commands inside PowerShell strings caused syntax errors (specifically with parentheses and quoting).
**Fix:** Refactored the Python QR code generation call to be cleaner and safer within the script.
## 4. Network & Firewall
### Fail: "Run as Administrator to enable LAN/VPN access"
**Cause:** Windows Firewall blocks incoming connections to port 8000 by default, preventing LAN and Tailscale access.
**Fix:** Added automatic detection of the missing firewall rule. The script now prompts the user to press 'A' to restart as Administrator and applies the rule automatically using `New-NetFirewallRule`.
### Fail: Tailscale IP Not Detected
**Cause:** The script looked specifically for a network interface named "Tailscale", but on some systems, the adapter name differs.
**Fix:** Added a fallback detection method that scans for any active IPv4 address in the `100.x.x.x` range (Carrier Grade NAT), which Tailscale uses.
## 5. User Experience (UX) Friction
### Fail: Annoying Ngrok Prompt
**Cause:** Users with Tailscale (which is always on) were forced to wait 3 seconds or press a key to skip the Ngrok prompt every time.
**Fix:** Added logic to **auto-detect Tailscale**. If a Tailscale IP is found, the script now automatically skips the Ngrok prompt and defaults to the private VPN URL.
### Fail: "How do I view this on mobile?"
**Cause:** Users had to manually type long IP addresses or URLs into their phone.
**Fix:**
1. Integrated a **QR Code Generator** directly into the Python backend (`/api/utils/qrcode`).
2. Updated the root dashboard (`/`) to dynamically display the active IP (LAN, Tailscale, or Ngrok) and a scannable QR code.
## Final Status
The system now supports three robust access methods:
1. **Local Network (LAN):** Auto-configured via Firewall rules.
2. **Private VPN (Tailscale):** Auto-detected with priority handling.
3. **Public Tunnel (Ngrok):** Optional fallback with secure/public modes.

View file

@ -1,14 +0,0 @@
version: '3.8'
services:
buddai:
build: .
ports:
- "8000:8000"
volumes:
- ./data:/app/data
environment:
- OLLAMA_HOST=host.docker.internal
- OLLAMA_PORT=11434
extra_hosts:
- "host.docker.internal:host-gateway"

View file

@ -112,6 +112,41 @@
}
.loading-flame { font-size: 24px; animation: flame-flicker 0.6s infinite; display: inline-block; }
.hljs { background: transparent !important; padding: 0 !important; }
/* Eyes */
.eyes-container { display: flex; gap: 4px; margin-right: 12px; }
.eye { width: 14px; height: 14px; background: #d4d4d4; border-radius: 50%; position: relative; display: flex; align-items: center; justify-content: center; overflow: hidden; border: 1px solid #1e1e1e; }
.pupil { width: 6px; height: 6px; background: var(--btn-bg); border-radius: 50%; }
/* Sleep Zzz */
.zzz-container { position: absolute; top: -15px; right: -8px; pointer-events: none; }
.zzz { position: absolute; font-family: 'Comic Sans MS', cursive, sans-serif; font-weight: bold; color: #aaa; opacity: 0; animation: float-z 2.5s infinite; }
.zzz:nth-child(1) { animation-delay: 0s; font-size: 10px; right: 5px; }
.zzz:nth-child(2) { animation-delay: 0.8s; font-size: 14px; right: 0px; }
.zzz:nth-child(3) { animation-delay: 1.6s; font-size: 18px; right: -5px; }
@keyframes float-z {
0% { transform: translate(0, 0) rotate(-10deg); opacity: 0; }
25% { opacity: 0.8; }
100% { transform: translate(10px, -20px) rotate(10deg); opacity: 0; }
}
@keyframes shake {
0% { transform: translate(0, 0) rotate(0deg); }
25% { transform: translate(-2px, 1px) rotate(-5deg); }
50% { transform: translate(2px, -1px) rotate(5deg); }
75% { transform: translate(-1px, 2px) rotate(-2deg); }
100% { transform: translate(0, 0) rotate(0deg); }
}
.shake { animation: shake 0.5s ease-in-out; }
@keyframes confused {
0% { transform: translate(0, 0); }
25% { transform: translate(-3px, 1px); }
50% { transform: translate(1px, -3px); }
75% { transform: translate(3px, 2px); }
100% { transform: translate(0, 0); }
}
</style>
</head>
<body>
@ -198,6 +233,166 @@
const { useState, useEffect, useRef } = React;
const Eyes = ({ status, targetPos, loading, confused, happy }) => {
const [pupilPos, setPupilPos] = useState({ x: 0, y: 0 });
const [isBlinking, setIsBlinking] = useState(false);
const [isSleeping, setIsSleeping] = useState(false);
const [isShaking, setIsShaking] = useState(false);
const eyesRef = useRef(null);
const lastActivityRef = useRef(Date.now());
const ignoreWakeRef = useRef(false);
useEffect(() => {
const checkSleep = setInterval(() => {
if (Date.now() - lastActivityRef.current > 5000 && !loading && status === 'online') {
setIsSleeping(true);
}
}, 1000);
return () => clearInterval(checkSleep);
}, [loading, status]);
useEffect(() => {
if (!isSleeping) {
setIsShaking(true);
const t = setTimeout(() => setIsShaking(false), 500);
return () => clearTimeout(t);
}
}, [isSleeping]);
useEffect(() => {
let timeout;
const blink = () => {
setIsBlinking(true);
setTimeout(() => setIsBlinking(false), 150);
timeout = setTimeout(blink, Math.random() * 4000 + 2000);
};
timeout = setTimeout(blink, 3000);
return () => clearTimeout(timeout);
}, []);
useEffect(() => {
if (loading) {
lastActivityRef.current = Date.now();
setIsSleeping(false);
const interval = setInterval(() => {
const t = Date.now() / 100;
setPupilPos({
x: Math.sin(t) * 2.5,
y: Math.sin(t * 2) * 1
});
}, 20);
return () => clearInterval(interval);
}
const updateEyePosition = (targetX, targetY) => {
if (!eyesRef.current) return;
const rekt = eyesRef.current.getBoundingClientRect();
const anchorX = rekt.left + rekt.width / 2;
const anchorY = rekt.top + rekt.height / 2;
const angle = Math.atan2(targetY - anchorY, targetX - anchorX);
const radius = 3;
const x = Math.cos(angle) * radius;
const y = Math.sin(angle) * radius;
setPupilPos({ x, y });
};
if (targetPos) {
lastActivityRef.current = Date.now();
setIsSleeping(false);
updateEyePosition(targetPos.x, targetPos.y);
return;
}
const handleMouseMove = (e) => {
if (ignoreWakeRef.current) return;
lastActivityRef.current = Date.now();
setIsSleeping(false);
updateEyePosition(e.clientX, e.clientY);
};
window.addEventListener('mousemove', handleMouseMove);
return () => window.removeEventListener('mousemove', handleMouseMove);
}, [targetPos, loading]);
const pupilStyle = { transform: `translate(${pupilPos.x}px, ${pupilPos.y}px)`, background: status === 'offline' ? '#ff4444' : undefined, boxShadow: status === 'offline' ? '0 0 4px #ff0000' : undefined };
// Confused overrides
const leftPupilStyle = confused ? { ...pupilStyle, transform: 'none', animation: 'confused 1s infinite reverse' } : pupilStyle;
const rightPupilStyle = confused ? { ...pupilStyle, transform: 'none', animation: 'confused 1.2s infinite' } : pupilStyle;
// Happy overrides (squint)
const eyeTransform = (isBlinking || isSleeping) ? 'scaleY(0.1)' : (happy ? 'scaleY(0.45)' : 'scaleY(1)');
const toggleSleep = (e) => {
e.stopPropagation();
if (isSleeping) {
setIsSleeping(false);
lastActivityRef.current = Date.now();
} else {
setIsSleeping(true);
ignoreWakeRef.current = true;
setTimeout(() => { ignoreWakeRef.current = false; }, 1000);
}
};
return (
<div ref={eyesRef} className={`eyes-container ${isShaking ? 'shake' : ''}`} title="BuddAI is watching (Click to sleep/wake)" style={{position: 'relative', cursor: 'pointer'}} onClick={toggleSleep}>
{isSleeping && (
<div className="zzz-container">
<div className="zzz">z</div>
<div className="zzz">z</div>
<div className="zzz">z</div>
</div>
)}
<div className="eye" style={{ transform: eyeTransform, transition: 'transform 0.1s' }}><div className="pupil" style={leftPupilStyle} /></div>
<div className="eye" style={{ transform: eyeTransform, transition: 'transform 0.1s' }}><div className="pupil" style={rightPupilStyle} /></div>
</div>
);
};
const SystemHealth = () => {
const [stats, setStats] = useState({ memory: 0, cpu: 0 });
useEffect(() => {
const fetchStats = async () => {
try {
const res = await fetch("/api/system/status");
if (res.ok) {
const data = await res.json();
setStats(data);
}
} catch (e) {}
};
fetchStats();
const interval = setInterval(fetchStats, 5000);
return () => clearInterval(interval);
}, []);
const getColor = (val) => {
if (val < 60) return '#4caf50';
if (val < 85) return '#ff9800';
return '#f44336';
};
return (
<div style={{display: 'flex', flexDirection: 'column', gap: '2px', width: '100px', marginRight: '15px'}}>
<div style={{display: 'flex', alignItems: 'center', fontSize: '0.7em', color: '#888'}}>
<span style={{width: '30px'}}>RAM</span>
<div style={{flex: 1, height: '4px', background: '#333', borderRadius: '2px', overflow: 'hidden'}}>
<div style={{width: `${stats.memory}%`, height: '100%', background: getColor(stats.memory), transition: 'width 0.5s'}}></div>
</div>
</div>
<div style={{display: 'flex', alignItems: 'center', fontSize: '0.7em', color: '#888'}}>
<span style={{width: '30px'}}>CPU</span>
<div style={{flex: 1, height: '4px', background: '#333', borderRadius: '2px', overflow: 'hidden'}}>
<div style={{width: `${stats.cpu}%`, height: '100%', background: getColor(stats.cpu), transition: 'width 0.5s'}}></div>
</div>
</div>
</div>
);
};
function App() {
const [history, setHistory] = useState([]);
const [sessions, setSessions] = useState([]);
@ -208,11 +403,15 @@
const [feedbackGiven, setFeedbackGiven] = useState({});
const [input, setInput] = useState("");
const [loading, setLoading] = useState(false);
const [isConfused, setIsConfused] = useState(false);
const [isHappy, setIsHappy] = useState(false);
const [status, setStatus] = useState("connecting");
const [forgeMode, setForgeMode] = useState("2");
const [theme, setTheme] = useState("dark");
const [sidebarContent, setSidebarContent] = useState("");
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
const [caretPos, setCaretPos] = useState(null);
const inputRef = useRef(null);
const endRef = useRef(null);
const abortControllerRef = useRef(null);
const socketRef = useRef(null);
@ -387,6 +586,7 @@
const lastMsg = newHistory[newHistory.length - 1];
if (lastMsg && lastMsg.role === 'assistant') {
lastMsg.id = data.message_id;
if (!lastMsg.content) lastMsg.content = "*(No response received - Check Ollama connection)*";
}
return newHistory;
});
@ -447,11 +647,48 @@
return { text, suggestions };
};
const updateCaret = () => {
const el = inputRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
const style = window.getComputedStyle(el);
const div = document.createElement('div');
div.style.font = style.font;
div.style.padding = style.padding;
div.style.border = style.border;
div.style.fontWeight = style.fontWeight;
div.style.fontSize = style.fontSize;
div.style.fontFamily = style.fontFamily;
div.style.position = 'absolute';
div.style.visibility = 'hidden';
div.style.whiteSpace = 'pre';
div.textContent = el.value.substring(0, el.selectionStart);
document.body.appendChild(div);
const textWidth = div.getBoundingClientRect().width;
document.body.removeChild(div);
const paddingLeft = parseFloat(style.paddingLeft) || 0;
const x = rect.left + paddingLeft + textWidth - el.scrollLeft;
const y = rect.top + (rect.height / 2);
setCaretPos({ x, y });
};
const handleResetGpu = async () => {
setLoading(true);
try {
const res = await fetch("/api/system/reset-gpu", { method: "POST" });
const data = await res.json();
setHistory(prev => [...prev, { role: "assistant", content: data.message }]);
} catch (e) { console.error(e); }
setLoading(false);
};
return (
<>
<div className="header">
<div style={{display:'flex', alignItems:'center'}}>
<img src="/favicon.ico" alt="BuddAI" style={{height: '24px', marginRight: '10px'}} />
<SystemHealth />
<Eyes status={status} targetPos={caretPos} loading={loading} confused={isConfused} happy={isHappy} />
<h3 style={{margin:0}}>BuddAI v3.2</h3>
<span className={`status-badge ${status}`}>{status}</span>
</div>
@ -466,6 +703,7 @@
<button className="clear-btn" onClick={() => setShowSidebar(!showSidebar)}>{showSidebar ? 'Hide History' : 'Show History'}</button>
<button className="clear-btn" onClick={() => setIsSidebarOpen(!isSidebarOpen)}>{isSidebarOpen ? 'Hide Code' : 'Show Code'}</button>
<button className="clear-btn" onClick={() => setTheme(t => t === 'dark' ? 'light' : 'dark')}>{theme === 'dark' ? '☀️' : '🌙'}</button>
<button className="clear-btn" onClick={handleResetGpu} title="Unload models from VRAM">⚡ Reset GPU</button>
<select
value={forgeMode}
onChange={(e) => setForgeMode(e.target.value)}
@ -554,9 +792,20 @@
</div>
<div className="input-area">
<input
ref={inputRef}
value={input}
onChange={e => setInput(e.target.value)}
onChange={e => {
const val = e.target.value;
setInput(val);
setIsConfused(val.includes('???'));
setIsHappy(val.toLowerCase().includes('thanks') || val.toLowerCase().includes('thx'));
setTimeout(updateCaret, 0);
}}
onKeyPress={e => e.key === 'Enter' && sendMessage()}
onKeyUp={updateCaret}
onClick={updateCaret}
onFocus={updateCaret}
onBlur={() => setCaretPos(null)}
placeholder="Ask BuddAI to build something..."
autoFocus
/>

View file

@ -11,6 +11,7 @@ License: MIT
import sys
import os
import json
import logging
import sqlite3
from datetime import datetime
from pathlib import Path
@ -22,6 +23,13 @@ import shutil
import queue
import socket
import argparse
import io
from urllib.parse import urlparse
try:
import qrcode
except ImportError:
qrcode = None
try:
import psutil
@ -30,10 +38,10 @@ except ImportError:
# Server dependencies
try:
from fastapi import FastAPI, UploadFile, File, Header, WebSocket, WebSocketDisconnect
from fastapi import FastAPI, UploadFile, File, Header, WebSocket, WebSocketDisconnect, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse, HTMLResponse
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response
from pydantic import BaseModel
import uvicorn
SERVER_AVAILABLE = True
@ -41,7 +49,7 @@ except ImportError:
SERVER_AVAILABLE = False
# Configuration
OLLAMA_HOST = os.getenv("OLLAMA_HOST", "localhost")
OLLAMA_HOST = os.getenv("OLLAMA_HOST", "127.0.0.1")
OLLAMA_PORT = int(os.getenv("OLLAMA_PORT", "11434"))
DATA_DIR = Path(__file__).parent / "data"
DB_PATH = DATA_DIR / "conversations.db"
@ -592,7 +600,8 @@ class BuddAI:
simple_triggers = [
"what is", "what's", "who is", "who's", "when is",
"how do i", "can you explain", "tell me about",
"what are", "where is"
"what are", "where is", "hi", "hello", "hey",
"good morning", "good evening"
]
# Also check if it's just a question without code keywords
@ -684,7 +693,6 @@ class BuddAI:
def call_model(self, model_name: str, message: str, stream: bool = False) -> Union[str, Generator[str, None, None]]:
"""Call specified model"""
conn = None
try:
current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
user_context = self.get_user_status()
@ -734,35 +742,92 @@ float applyForge(float current, float target, float k) {{ return target + (curre
"model": MODELS[model_name],
"messages": messages,
"stream": stream,
"options": {"temperature": 0.7, "num_ctx": 4096}
"options": {"temperature": 0.7, "num_ctx": 1024} # Default options
}
conn = OLLAMA_POOL.get_connection()
headers = {"Content-Type": "application/json"}
json_body = json.dumps(body)
conn.request("POST", "/api/chat", json_body, headers)
response = conn.getresponse()
if stream:
return self._stream_response(response, conn)
if response.status == 200:
data = json.loads(response.read().decode('utf-8'))
OLLAMA_POOL.return_connection(conn)
return data.get("message", {}).get("content", "No response")
else:
conn.close()
return f"Error: {response.status}"
# Retry logic for connection stability
# Attempts: 0=Normal, 1=Retry/CPU Fallback, 2=Final Retry
for attempt in range(3):
conn = None
try:
# Re-serialize body in case options changed (CPU fallback)
json_body = json.dumps(body)
conn = OLLAMA_POOL.get_connection()
conn.request("POST", "/api/chat", json_body, headers)
response = conn.getresponse()
if stream:
if response.status != 200:
error_text = response.read().decode('utf-8')
conn.close()
# GPU OOM Detection -> CPU Fallback
if "CUDA" in error_text or "buffer" in error_text:
if "num_gpu" not in body["options"]:
print("⚠️ GPU OOM detected. Switching to CPU mode...")
body["options"]["num_gpu"] = 0 # Force CPU
continue # Retry immediately
try:
err_msg = f"Error {response.status}: {json.loads(error_text).get('error', error_text)}"
except:
err_msg = f"Error {response.status}: {error_text}"
if "num_gpu" in body["options"]:
err_msg += "\n\n(⚠️ CPU Mode also failed. System RAM might be full.)"
elif "CUDA" in err_msg or "buffer" in err_msg:
err_msg += "\n\n(⚠️ GPU Out of Memory. Retrying on CPU failed.)"
return (x for x in [err_msg])
return self._stream_response(response, conn)
if response.status == 200:
data = json.loads(response.read().decode('utf-8'))
OLLAMA_POOL.return_connection(conn)
return data.get("message", {}).get("content", "No response")
else:
error_text = response.read().decode('utf-8')
conn.close()
# GPU OOM Detection -> CPU Fallback (Non-stream)
if "CUDA" in error_text or "buffer" in error_text:
if "num_gpu" not in body["options"]:
print("⚠️ GPU OOM detected. Switching to CPU mode...")
body["options"]["num_gpu"] = 0 # Force CPU
continue # Retry immediately
try:
err_msg = f"Error {response.status}: {json.loads(error_text).get('error', error_text)}"
except:
err_msg = f"Error {response.status}: {error_text}"
if "num_gpu" in body["options"]:
err_msg += "\n\n(⚠️ CPU Mode also failed.)"
elif "CUDA" in err_msg or "buffer" in err_msg:
err_msg += "\n\n(⚠️ GPU Out of Memory.)"
return err_msg
except (http.client.NotConnected, BrokenPipeError, ConnectionResetError, socket.timeout) as e:
if conn: conn.close()
if attempt == 2: # Last attempt
return f"Error: Connection failed. {str(e)}"
continue # Retry
except Exception as e:
if conn: conn.close()
return f"Error: {str(e)}"
except Exception as e:
if conn:
conn.close()
return f"Error: {str(e)}"
def _stream_response(self, response, conn) -> Generator[str, None, None]:
"""Yield chunks from HTTP response"""
fully_consumed = False
has_content = False
try:
while True:
line = response.readline()
@ -771,16 +836,23 @@ float applyForge(float current, float target, float k) {{ return target + (curre
data = json.loads(line.decode('utf-8'))
if "message" in data:
content = data["message"].get("content", "")
if content: yield content
if content:
has_content = True
yield content
if data.get("done"):
fully_consumed = True
break
except: pass
except Exception as e:
yield f"\n[Stream Error: {str(e)}]"
finally:
if fully_consumed:
OLLAMA_POOL.return_connection(conn)
else:
conn.close()
if not has_content and not fully_consumed:
yield "\n[Error: Empty response from Ollama. Check if model is loaded.]"
def execute_modular_build(self, _: str, modules: List[str], plan: List[Dict[str, str]], forge_mode: str = "2") -> str:
"""Execute build plan step by step"""
@ -1006,6 +1078,15 @@ float applyForge(float current, float target, float k) {{ return target + (curre
conn.commit()
conn.close()
def clear_current_session(self) -> None:
"""Clear all messages from the current session"""
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
cursor.execute("DELETE FROM messages WHERE session_id = ?", (self.session_id,))
conn.commit()
conn.close()
self.context_messages = []
def load_session(self, session_id: str) -> List[Dict[str, str]]:
"""Load a specific session context"""
conn = sqlite3.connect(DB_PATH)
@ -1035,6 +1116,113 @@ float applyForge(float current, float target, float k) {{ return target + (curre
self.context_messages = []
return self.session_id
def reset_gpu(self) -> str:
"""Force unload models from GPU to free VRAM"""
try:
conn = http.client.HTTPConnection(OLLAMA_HOST, OLLAMA_PORT, timeout=10)
# Unload all known models
for model in MODELS.values():
body = json.dumps({"model": model, "keep_alive": 0})
conn.request("POST", "/api/generate", body)
resp = conn.getresponse()
resp.read() # Consume response
conn.close()
return "✅ GPU Memory Cleared (Models Unloaded)"
except Exception as e:
return f"❌ Error clearing GPU: {str(e)}"
def export_session_to_markdown(self, session_id: str = None) -> str:
"""Export session history to a Markdown file"""
sid = session_id or self.session_id
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
cursor.execute("SELECT role, content, timestamp FROM messages WHERE session_id = ? ORDER BY id ASC", (sid,))
rows = cursor.fetchall()
conn.close()
if not rows:
return "No history found."
filename = f"session_{sid}.md"
filepath = DATA_DIR / filename
with open(filepath, "w", encoding="utf-8") as f:
f.write(f"# BuddAI Session: {sid}\n\n")
for role, content, ts in rows:
f.write(f"### {role.upper()} ({ts})\n\n{content}\n\n---\n\n")
return f"✅ Session exported to: {filepath}"
def get_session_export_data(self, session_id: str = None) -> Dict:
"""Get session data as a dictionary for export"""
sid = session_id or self.session_id
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
cursor.execute("SELECT role, content, timestamp FROM messages WHERE session_id = ? ORDER BY id ASC", (sid,))
rows = cursor.fetchall()
conn.close()
return {
"session_id": sid,
"exported_at": datetime.now().isoformat(),
"messages": [{"role": r, "content": c, "timestamp": t} for r, c, t in rows]
}
def export_session_to_json(self, session_id: str = None) -> str:
"""Export session history to a JSON file"""
data = self.get_session_export_data(session_id)
if not data["messages"]:
return "No history found."
filename = f"session_{data['session_id']}.json"
filepath = DATA_DIR / filename
with open(filepath, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
return f"✅ Session exported to: {filepath}"
def import_session_from_json(self, data: Dict) -> str:
"""Import a session from JSON data"""
session_id = data.get("session_id")
messages = data.get("messages", [])
if not session_id or not messages:
raise ValueError("Invalid session JSON format")
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
# Check if session exists to avoid collision
cursor.execute("SELECT 1 FROM sessions WHERE session_id = ? AND user_id = ?", (session_id, self.user_id))
if cursor.fetchone():
# Generate new ID
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
session_id = f"{session_id}_imp_{timestamp}"
# Determine start time
started_at = datetime.now().isoformat()
if messages and "timestamp" in messages[0]:
started_at = messages[0]["timestamp"]
cursor.execute(
"INSERT INTO sessions (session_id, user_id, started_at, title) VALUES (?, ?, ?, ?)",
(session_id, self.user_id, started_at, f"Imported: {data.get('session_id')}")
)
# Insert messages
for msg in messages:
cursor.execute(
"INSERT INTO messages (session_id, role, content, timestamp) VALUES (?, ?, ?, ?)",
(session_id, msg.get("role"), msg.get("content"), msg.get("timestamp", datetime.now().isoformat()))
)
conn.commit()
conn.close()
return session_id
def run(self) -> None:
"""Main loop"""
try:
@ -1063,6 +1251,7 @@ float applyForge(float current, float target, float k) {{ return target + (curre
print("/balanced - Use balanced model")
print("/index <path> - Index local repositories")
print("/scan - Scan style signature (V3.0)")
print("/save - Export chat to Markdown")
print("/help - This message")
print("exit - End session\n")
continue
@ -1076,6 +1265,12 @@ float applyForge(float current, float target, float k) {{ return target + (curre
elif cmd == '/scan':
self.scan_style_signature()
continue
elif cmd.startswith('/save'):
if 'json' in user_input.lower():
print(self.export_session_to_json())
else:
print(self.export_session_to_markdown())
continue
else:
print("\nUnknown command. Type /help")
continue
@ -1118,6 +1313,9 @@ if SERVER_AVAILABLE:
class FeedbackRequest(BaseModel):
message_id: int
positive: bool
class ResetGpuRequest(BaseModel):
pass
# Multi-user support
class BuddAIManager:
@ -1137,10 +1335,45 @@ if SERVER_AVAILABLE:
app.mount("/web", StaticFiles(directory=frontend_path, html=True), name="web")
@app.get("/", response_class=HTMLResponse)
async def root():
async def root(request: Request):
server_buddai = buddai_manager.get_instance("default")
status = server_buddai.get_user_status()
public_url = getattr(request.app.state, "public_url", "")
qr_section = ""
ip_section = ""
if public_url:
parsed = urlparse(public_url)
host = parsed.hostname
label = "Server Address"
color = "#fff"
if host:
if host.startswith("100."):
label = "Tailscale IP"
color = "#ff79c6" # Magenta
elif host.startswith("192.168.") or host.startswith("10.") or host.startswith("172."):
label = "LAN IP"
color = "#50fa7b" # Green
elif "ngrok" in public_url:
label = "Public Tunnel"
color = "#8be9fd" # Cyan
ip_section = f"""
<div style="margin: 20px 0; text-align: center;">
<p style="margin: 0; font-size: 0.8em; color: #888; text-transform: uppercase; letter-spacing: 1px;">{label}</p>
<h2 style="margin: 5px 0; font-size: 1.8em; color: {color}; font-family: monospace; background: rgba(0,0,0,0.2); padding: 10px; border-radius: 8px; border: 1px solid rgba(255,255,255,0.1);">{host}</h2>
</div>
"""
qr_section = f"""
<div style="margin-top: 20px; text-align: center; background: rgba(255,255,255,0.05); padding: 15px; border-radius: 10px;">
<p style="margin: 0 0 10px 0; font-size: 0.9em; color: #aaa;">Scan to Connect</p>
<img src="/api/utils/qrcode?url={public_url}" style="width: 150px; height: 150px; border-radius: 8px; display: block; margin: 0 auto;">
</div>
"""
# System Stats
mem_usage = "N/A"
if psutil:
@ -1156,7 +1389,7 @@ if SERVER_AVAILABLE:
return f"""
<html>
<head>
<title>BuddAI API</title>
<title>BuddAI API (Dev Mode)</title>
<link rel="icon" href="/favicon.ico">
<style>
body {{
@ -1260,6 +1493,8 @@ if SERVER_AVAILABLE:
<a href="/web">Launch Web UI</a>
<a href="/docs" class="secondary">API Docs</a>
</div>
{ip_section}
{qr_section}
</div>
</body>
</html>
@ -1351,6 +1586,36 @@ if SERVER_AVAILABLE:
server_buddai.record_feedback(req.message_id, req.positive)
return {"status": "success"}
@app.post("/api/system/reset-gpu")
async def reset_gpu_endpoint(user_id: str = Header("default")):
server_buddai = buddai_manager.get_instance(user_id)
result = server_buddai.reset_gpu()
return {"message": result}
@app.get("/api/system/status")
async def system_status_endpoint():
mem_percent = 0
cpu_percent = 0
if psutil:
mem = psutil.virtual_memory()
mem_percent = mem.percent
cpu_percent = psutil.cpu_percent(interval=None)
return {"memory": mem_percent, "cpu": cpu_percent}
@app.get("/api/utils/qrcode")
async def qrcode_endpoint(url: str):
if not qrcode:
return JSONResponse(status_code=501, content={"message": "qrcode module missing"})
try:
img = qrcode.make(url)
buf = io.BytesIO()
img.save(buf, format="PNG")
buf.seek(0)
return Response(content=buf.getvalue(), media_type="image/png")
except Exception as e:
return JSONResponse(status_code=500, content={"message": f"QR Error: {str(e)}. Ensure 'pillow' is installed."})
@app.get("/api/history")
async def history_endpoint(user_id: str = Header("default")):
server_buddai = buddai_manager.get_instance(user_id)
@ -1379,6 +1644,41 @@ if SERVER_AVAILABLE:
server_buddai.delete_session(req.session_id)
return {"status": "success"}
@app.get("/api/session/{session_id}/export/json")
async def export_json_endpoint(session_id: str, user_id: str = Header("default")):
server_buddai = buddai_manager.get_instance(user_id)
data = server_buddai.get_session_export_data(session_id)
return JSONResponse(
content=data,
headers={"Content-Disposition": f"attachment; filename=session_{session_id}.json"}
)
@app.post("/api/session/import")
async def import_session_endpoint(file: UploadFile = File(...), user_id: str = Header("default")):
if not file.filename.lower().endswith('.json'):
return JSONResponse(status_code=400, content={"message": "Invalid file type. Must be JSON."})
content = await file.read()
try:
data = json.loads(content)
except json.JSONDecodeError:
return JSONResponse(status_code=400, content={"message": "Invalid JSON content."})
server_buddai = buddai_manager.get_instance(user_id)
try:
new_session_id = server_buddai.import_session_from_json(data)
return {"status": "success", "session_id": new_session_id, "message": f"Session imported as {new_session_id}"}
except ValueError as e:
return JSONResponse(status_code=400, content={"message": str(e)})
except Exception as e:
return JSONResponse(status_code=500, content={"message": f"Server error: {str(e)}"})
@app.post("/api/session/clear")
async def clear_session_endpoint(user_id: str = Header("default")):
server_buddai = buddai_manager.get_instance(user_id)
server_buddai.clear_current_session()
return {"status": "success"}
@app.post("/api/session/new")
async def new_session_endpoint(user_id: str = Header("default")):
server_buddai = buddai_manager.get_instance(user_id)
@ -1434,36 +1734,46 @@ def check_ollama() -> bool:
conn = http.client.HTTPConnection(OLLAMA_HOST, OLLAMA_PORT, timeout=5)
conn.request("GET", "/api/tags")
response = conn.getresponse()
conn.close()
return response.status == 200
except:
if response.status == 200:
data = json.loads(response.read().decode('utf-8'))
conn.close()
installed_models = [m['name'] for m in data.get('models', [])]
missing = [m for m in MODELS.values() if m not in installed_models]
if missing:
print(f"⚠️ WARNING: Missing models in Ollama: {', '.join(missing)}")
print(f" Run in host terminal: ollama pull {' && ollama pull '.join(missing)}")
return True
return False
except Exception:
return False
def is_port_available(port: int) -> bool:
def is_port_available(port: int, host: str = "0.0.0.0") -> bool:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
try:
s.bind(('0.0.0.0', port))
s.bind((host, port))
return True
except socket.error:
return False
def main() -> None:
if not check_ollama():
print("❌ Ollama not running. Start: ollama serve")
print(f"❌ Ollama not running at {OLLAMA_HOST}:{OLLAMA_PORT}. Ensure it is running and accessible.")
sys.exit(1)
parser = argparse.ArgumentParser(description="BuddAI Executive")
parser.add_argument("--server", action="store_true", help="Run in server mode")
parser.add_argument("--port", type=int, default=8000, help="Port for server mode")
parser.add_argument("--host", type=str, default="0.0.0.0", help="Host IP address")
parser.add_argument("--public-url", type=str, default="", help="Public URL for QR codes")
args = parser.parse_args()
if args.server:
if SERVER_AVAILABLE:
port = args.port
if not is_port_available(port):
if not is_port_available(port, args.host):
print(f"⚠️ Port {port} is in use.")
for i in range(1, 11):
if is_port_available(port + i):
if is_port_available(port + i, args.host):
port += i
print(f"🔄 Switching to available port: {port}")
break
@ -1471,8 +1781,19 @@ def main() -> None:
print(f"❌ Could not find available port in range {args.port}-{args.port+10}")
sys.exit(1)
# Silence health check logs from frontend polling
class EndpointFilter(logging.Filter):
def filter(self, record: logging.LogRecord) -> bool:
msg = record.getMessage()
return "/api/system/status" not in msg and '"GET / HTTP/1.1" 200' not in msg
logging.getLogger("uvicorn.access").addFilter(EndpointFilter())
print(f"🚀 Starting BuddAI API Server on port {port}...")
uvicorn.run(app, host="0.0.0.0", port=port)
if args.public_url:
print(f"🔗 Public Access: {args.public_url}")
app.state.public_url = args.public_url
uvicorn.run(app, host=args.host, port=port)
else:
print("❌ Server dependencies missing. Install: pip install fastapi uvicorn aiofiles python-multipart")
else:

BIN
ngrok.exe Normal file

Binary file not shown.

View file

@ -1,6 +1,8 @@
fastapi>=0.68.0
uvicorn>=0.15.0
python-multipart>=0.0.5
psutil>=5.8.0
aiofiles>=0.7.0
requests>=2.26.0
fastapi
uvicorn[standard]
python-multipart
psutil
aiofiles
websockets
qrcode
pillow

167
run_buddai.ps1 Normal file
View file

@ -0,0 +1,167 @@
Write-Host 'BuddAI Local Launcher' -ForegroundColor Cyan
# Ensure execution happens in the script's directory
Set-Location $PSScriptRoot
# 1. Stop Docker if it's running to free up port 8000
if (Get-Command docker-compose -ErrorAction SilentlyContinue) {
Write-Host 'Ensuring Docker is stopped...' -ForegroundColor Yellow
docker-compose down 2>$null
}
# 2. Check Ollama Status
if (Get-Command ollama -ErrorAction SilentlyContinue) {
if (-not (Get-Process ollama* -ErrorAction SilentlyContinue)) {
Write-Host 'Ollama is not running. Starting...' -ForegroundColor Yellow
Start-Process ollama -ArgumentList "serve" -WindowStyle Hidden
Start-Sleep -Seconds 5
} else {
Write-Host 'Ollama is running.' -ForegroundColor Green
}
}
# 3. Check Models
if (Get-Command ollama -ErrorAction SilentlyContinue) {
Write-Host 'Checking AI models...' -ForegroundColor Green
$models = ollama list | Out-String
$required = @('qwen2.5-coder:1.5b', 'qwen2.5-coder:3b')
foreach ($model in $required) {
if ($models -notmatch [regex]::Escape($model)) {
Write-Host "Model '$model' missing. Pulling (this may take a while)..." -ForegroundColor Yellow
ollama pull $model
}
}
}
# 4. Create Virtual Environment if missing
if (-not (Test-Path 'venv')) {
Write-Host 'Creating Python virtual environment...' -ForegroundColor Green
python -m venv venv
}
# 5. Install Dependencies
Write-Host 'Checking dependencies...' -ForegroundColor Green
# Upgrade pip first to fix potential "Request-sent" or SSL errors
./venv/Scripts/python.exe -m pip install --upgrade pip
./venv/Scripts/python.exe -m pip install -r requirements.txt
if ($LASTEXITCODE -ne 0) {
Write-Host "Dependency installation failed." -ForegroundColor Red
exit
}
# 6. Run Server
Write-Host 'Starting BuddAI Server...' -ForegroundColor Cyan
# Get LAN IP for local network access
$lanIp = (Get-NetIPConfiguration | Where-Object { $_.IPv4DefaultGateway -ne $null -and $_.NetAdapter.Status -eq "Up" }).IPv4Address.IPAddress | Select-Object -First 1
Write-Host " Local PC: http://localhost:8000/web" -ForegroundColor Gray
Write-Host " On Phone: http://$($lanIp):8000/web" -ForegroundColor Green
# Check for Tailscale (Private VPN)
# Try multiple methods to detect Tailscale IP
$tailscaleIp = (Get-NetIPAddress -InterfaceAlias "*Tailscale*" -AddressFamily IPv4 -ErrorAction SilentlyContinue).IPAddress | Select-Object -First 1
if (-not $tailscaleIp) {
# Fallback: Look for any IP in the 100.x.x.x range (Tailscale uses CGNAT 100.64.0.0/10)
$tailscaleIp = (Get-NetIPAddress -AddressFamily IPv4 -ErrorAction SilentlyContinue | Where-Object { $_.IPAddress -like "100.*" -and $_.InterfaceAlias -notlike "*Loopback*" }).IPAddress | Select-Object -First 1
}
if ($tailscaleIp) {
Write-Host " Tailscale: http://$($tailscaleIp):8000/web (Secure VPN)" -ForegroundColor Magenta
} elseif (Get-Service "Tailscale" -ErrorAction SilentlyContinue) {
Write-Host " Tailscale: Installed but not connected. Open app to log in." -ForegroundColor Yellow
} else {
Write-Host " Tailscale: (Optional) Run 'winget install Tailscale.Tailscale' for VPN access" -ForegroundColor DarkGray
}
# Attempt to open Firewall for LAN/VPN access
if (-not (Get-NetFirewallRule -DisplayName "BuddAI Allow Port 8000" -ErrorAction SilentlyContinue)) {
try {
New-NetFirewallRule -DisplayName "BuddAI Allow Port 8000" -Direction Inbound -LocalPort 8000 -Protocol TCP -Action Allow -ErrorAction Stop | Out-Null
Write-Host " [+] Firewall rule added for Port 8000" -ForegroundColor DarkGray
} catch {
Write-Host " [!] Firewall rule missing. Remote access will be blocked." -ForegroundColor Red
Write-Host " [?] Press 'A' to restart as Administrator to fix, or any key to continue..." -NoNewline -ForegroundColor Yellow
if ([Console]::ReadKey($true).Key -eq 'A') {
Start-Process powershell -ArgumentList "-NoProfile -ExecutionPolicy Bypass -File `"$PSCommandPath`"" -Verb RunAs
exit
}
Write-Host ""
}
}
# Check for ngrok (Global or Local)
$ngrokPath = $null
$publicUrl = ""
if (Test-Path ".\ngrok.exe") {
$ngrokPath = (Resolve-Path ".\ngrok.exe").Path
} elseif (Get-Command ngrok -ErrorAction SilentlyContinue) {
$ngrokPath = "ngrok"
}
if ($ngrokPath -and -not $tailscaleIp) {
Write-Host " Remote: Run '$ngrokPath http 8000' for public access" -ForegroundColor DarkGray
Write-Host " [?] Press 'N'/'S' for Ngrok, or any key to skip (3s)..." -NoNewline -ForegroundColor Yellow
$timer = [System.Diagnostics.Stopwatch]::StartNew()
while ($timer.Elapsed.TotalSeconds -lt 3) {
if ([Console]::KeyAvailable) {
$key = [Console]::ReadKey($true).Key
if ($key -eq 'N' -or $key -eq 'S') {
$ngrokArgs = "http 8000"
if ($key -eq 'S') {
$ngrokArgs += " --basic-auth=`"admin:buddai`""
Write-Host "`n Launching Ngrok (Secure: admin/buddai)..." -ForegroundColor Green
} else {
Write-Host "`n Launching Ngrok (Public)..." -ForegroundColor Green
}
Start-Process $ngrokPath -ArgumentList $ngrokArgs -WindowStyle Hidden
# Retry loop to fetch URL (up to 15s)
$url = $null
Write-Host " Waiting for tunnel..." -NoNewline -ForegroundColor DarkGray
for ($i = 0; $i -lt 15; $i++) {
Start-Sleep -Seconds 1
try {
$tunnels = Invoke-RestMethod -Uri "http://localhost:4040/api/tunnels" -ErrorAction Stop
if ($tunnels.tunnels.Count -gt 0) { $url = $tunnels.tunnels[0].public_url; break }
} catch {}
Write-Host "." -NoNewline -ForegroundColor DarkGray
}
Write-Host ""
if ($url) {
$publicUrl = $url
Write-Host " Remote URL: $url" -ForegroundColor Cyan
Write-Host " Scan QR Code:" -ForegroundColor Gray
$qrScript = "import sys, qrcode; qr = qrcode.QRCode(); qr.add_data(sys.argv[1]); qr.print_ascii(invert=True)"
& ./venv/Scripts/python.exe -c $qrScript $url 2>$null
} else {
Write-Host " Ngrok running. Check http://localhost:4040 for URL" -ForegroundColor Yellow
}
} else {
Write-Host "`n Skipping Ngrok..." -ForegroundColor DarkGray
}
break
}
Start-Sleep -Milliseconds 50
}
Write-Host ""
} elseif ($ngrokPath) {
Write-Host " Remote: Ngrok available (Skipped due to Tailscale)" -ForegroundColor DarkGray
} else {
Write-Host " Remote: (Optional) Run 'winget install Ngrok.Ngrok' to enable remote access" -ForegroundColor DarkGray
}
# Determine best URL for the server to know about
if (-not $publicUrl -and $tailscaleIp) {
$publicUrl = "http://$($tailscaleIp):8000/web"
} elseif (-not $publicUrl) {
$publicUrl = "http://$($lanIp):8000/web"
}
Write-Host ' Opening browser...' -ForegroundColor DarkGray
Start-Process 'http://localhost:8000/'
# Use --host 0.0.0.0 to allow connections from other devices
./venv/Scripts/python.exe main.py --server --port 8000 --host 0.0.0.0 --public-url "$publicUrl"