mirror of
https://github.com/JamesTheGiblet/BuddAI.git
synced 2026-01-08 21:58:40 +00:00
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:
parent
93c9395c6f
commit
fc603464ee
9 changed files with 851 additions and 88 deletions
|
|
@ -1,10 +0,0 @@
|
|||
data/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
.git
|
||||
.gitignore
|
||||
tests/
|
||||
venv/
|
||||
env/
|
||||
21
Dockerfile
21
Dockerfile
|
|
@ -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
69
REMOTE_ACCESS_LOG.md
Normal 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.
|
||||
|
|
@ -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"
|
||||
|
|
@ -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
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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
BIN
ngrok.exe
Normal file
Binary file not shown.
|
|
@ -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
167
run_buddai.ps1
Normal 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"
|
||||
Loading…
Add table
Add a link
Reference in a new issue