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; }
|
.loading-flame { font-size: 24px; animation: flame-flicker 0.6s infinite; display: inline-block; }
|
||||||
.hljs { background: transparent !important; padding: 0 !important; }
|
.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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
@ -198,6 +233,166 @@
|
||||||
|
|
||||||
const { useState, useEffect, useRef } = React;
|
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() {
|
function App() {
|
||||||
const [history, setHistory] = useState([]);
|
const [history, setHistory] = useState([]);
|
||||||
const [sessions, setSessions] = useState([]);
|
const [sessions, setSessions] = useState([]);
|
||||||
|
|
@ -208,11 +403,15 @@
|
||||||
const [feedbackGiven, setFeedbackGiven] = useState({});
|
const [feedbackGiven, setFeedbackGiven] = useState({});
|
||||||
const [input, setInput] = useState("");
|
const [input, setInput] = useState("");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [isConfused, setIsConfused] = useState(false);
|
||||||
|
const [isHappy, setIsHappy] = useState(false);
|
||||||
const [status, setStatus] = useState("connecting");
|
const [status, setStatus] = useState("connecting");
|
||||||
const [forgeMode, setForgeMode] = useState("2");
|
const [forgeMode, setForgeMode] = useState("2");
|
||||||
const [theme, setTheme] = useState("dark");
|
const [theme, setTheme] = useState("dark");
|
||||||
const [sidebarContent, setSidebarContent] = useState("");
|
const [sidebarContent, setSidebarContent] = useState("");
|
||||||
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
|
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
|
||||||
|
const [caretPos, setCaretPos] = useState(null);
|
||||||
|
const inputRef = useRef(null);
|
||||||
const endRef = useRef(null);
|
const endRef = useRef(null);
|
||||||
const abortControllerRef = useRef(null);
|
const abortControllerRef = useRef(null);
|
||||||
const socketRef = useRef(null);
|
const socketRef = useRef(null);
|
||||||
|
|
@ -387,6 +586,7 @@
|
||||||
const lastMsg = newHistory[newHistory.length - 1];
|
const lastMsg = newHistory[newHistory.length - 1];
|
||||||
if (lastMsg && lastMsg.role === 'assistant') {
|
if (lastMsg && lastMsg.role === 'assistant') {
|
||||||
lastMsg.id = data.message_id;
|
lastMsg.id = data.message_id;
|
||||||
|
if (!lastMsg.content) lastMsg.content = "*(No response received - Check Ollama connection)*";
|
||||||
}
|
}
|
||||||
return newHistory;
|
return newHistory;
|
||||||
});
|
});
|
||||||
|
|
@ -447,11 +647,48 @@
|
||||||
return { text, suggestions };
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="header">
|
<div className="header">
|
||||||
<div style={{display:'flex', alignItems:'center'}}>
|
<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>
|
<h3 style={{margin:0}}>BuddAI v3.2</h3>
|
||||||
<span className={`status-badge ${status}`}>{status}</span>
|
<span className={`status-badge ${status}`}>{status}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -466,6 +703,7 @@
|
||||||
<button className="clear-btn" onClick={() => setShowSidebar(!showSidebar)}>{showSidebar ? 'Hide History' : 'Show History'}</button>
|
<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={() => 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={() => setTheme(t => t === 'dark' ? 'light' : 'dark')}>{theme === 'dark' ? '☀️' : '🌙'}</button>
|
||||||
|
<button className="clear-btn" onClick={handleResetGpu} title="Unload models from VRAM">⚡ Reset GPU</button>
|
||||||
<select
|
<select
|
||||||
value={forgeMode}
|
value={forgeMode}
|
||||||
onChange={(e) => setForgeMode(e.target.value)}
|
onChange={(e) => setForgeMode(e.target.value)}
|
||||||
|
|
@ -554,9 +792,20 @@
|
||||||
</div>
|
</div>
|
||||||
<div className="input-area">
|
<div className="input-area">
|
||||||
<input
|
<input
|
||||||
|
ref={inputRef}
|
||||||
value={input}
|
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()}
|
onKeyPress={e => e.key === 'Enter' && sendMessage()}
|
||||||
|
onKeyUp={updateCaret}
|
||||||
|
onClick={updateCaret}
|
||||||
|
onFocus={updateCaret}
|
||||||
|
onBlur={() => setCaretPos(null)}
|
||||||
placeholder="Ask BuddAI to build something..."
|
placeholder="Ask BuddAI to build something..."
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ License: MIT
|
||||||
import sys
|
import sys
|
||||||
import os
|
import os
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
@ -22,6 +23,13 @@ import shutil
|
||||||
import queue
|
import queue
|
||||||
import socket
|
import socket
|
||||||
import argparse
|
import argparse
|
||||||
|
import io
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
try:
|
||||||
|
import qrcode
|
||||||
|
except ImportError:
|
||||||
|
qrcode = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import psutil
|
import psutil
|
||||||
|
|
@ -30,10 +38,10 @@ except ImportError:
|
||||||
|
|
||||||
# Server dependencies
|
# Server dependencies
|
||||||
try:
|
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.middleware.cors import CORSMiddleware
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.responses import FileResponse, HTMLResponse
|
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
import uvicorn
|
import uvicorn
|
||||||
SERVER_AVAILABLE = True
|
SERVER_AVAILABLE = True
|
||||||
|
|
@ -41,7 +49,7 @@ except ImportError:
|
||||||
SERVER_AVAILABLE = False
|
SERVER_AVAILABLE = False
|
||||||
|
|
||||||
# Configuration
|
# 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"))
|
OLLAMA_PORT = int(os.getenv("OLLAMA_PORT", "11434"))
|
||||||
DATA_DIR = Path(__file__).parent / "data"
|
DATA_DIR = Path(__file__).parent / "data"
|
||||||
DB_PATH = DATA_DIR / "conversations.db"
|
DB_PATH = DATA_DIR / "conversations.db"
|
||||||
|
|
@ -592,7 +600,8 @@ class BuddAI:
|
||||||
simple_triggers = [
|
simple_triggers = [
|
||||||
"what is", "what's", "who is", "who's", "when is",
|
"what is", "what's", "who is", "who's", "when is",
|
||||||
"how do i", "can you explain", "tell me about",
|
"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
|
# 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]]:
|
def call_model(self, model_name: str, message: str, stream: bool = False) -> Union[str, Generator[str, None, None]]:
|
||||||
"""Call specified model"""
|
"""Call specified model"""
|
||||||
conn = None
|
|
||||||
try:
|
try:
|
||||||
current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||||
user_context = self.get_user_status()
|
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],
|
"model": MODELS[model_name],
|
||||||
"messages": messages,
|
"messages": messages,
|
||||||
"stream": stream,
|
"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"}
|
headers = {"Content-Type": "application/json"}
|
||||||
json_body = json.dumps(body)
|
json_body = json.dumps(body)
|
||||||
|
|
||||||
conn.request("POST", "/api/chat", json_body, headers)
|
# Retry logic for connection stability
|
||||||
response = conn.getresponse()
|
# 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)
|
||||||
|
|
||||||
if stream:
|
conn = OLLAMA_POOL.get_connection()
|
||||||
return self._stream_response(response, conn)
|
conn.request("POST", "/api/chat", json_body, headers)
|
||||||
|
response = conn.getresponse()
|
||||||
|
|
||||||
if response.status == 200:
|
if stream:
|
||||||
data = json.loads(response.read().decode('utf-8'))
|
if response.status != 200:
|
||||||
OLLAMA_POOL.return_connection(conn)
|
error_text = response.read().decode('utf-8')
|
||||||
return data.get("message", {}).get("content", "No response")
|
conn.close()
|
||||||
else:
|
|
||||||
conn.close()
|
# GPU OOM Detection -> CPU Fallback
|
||||||
return f"Error: {response.status}"
|
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:
|
except Exception as e:
|
||||||
if conn:
|
|
||||||
conn.close()
|
|
||||||
return f"Error: {str(e)}"
|
return f"Error: {str(e)}"
|
||||||
|
|
||||||
def _stream_response(self, response, conn) -> Generator[str, None, None]:
|
def _stream_response(self, response, conn) -> Generator[str, None, None]:
|
||||||
"""Yield chunks from HTTP response"""
|
"""Yield chunks from HTTP response"""
|
||||||
fully_consumed = False
|
fully_consumed = False
|
||||||
|
has_content = False
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
line = response.readline()
|
line = response.readline()
|
||||||
|
|
@ -771,17 +836,24 @@ float applyForge(float current, float target, float k) {{ return target + (curre
|
||||||
data = json.loads(line.decode('utf-8'))
|
data = json.loads(line.decode('utf-8'))
|
||||||
if "message" in data:
|
if "message" in data:
|
||||||
content = data["message"].get("content", "")
|
content = data["message"].get("content", "")
|
||||||
if content: yield content
|
if content:
|
||||||
|
has_content = True
|
||||||
|
yield content
|
||||||
if data.get("done"):
|
if data.get("done"):
|
||||||
fully_consumed = True
|
fully_consumed = True
|
||||||
break
|
break
|
||||||
except: pass
|
except: pass
|
||||||
|
except Exception as e:
|
||||||
|
yield f"\n[Stream Error: {str(e)}]"
|
||||||
finally:
|
finally:
|
||||||
if fully_consumed:
|
if fully_consumed:
|
||||||
OLLAMA_POOL.return_connection(conn)
|
OLLAMA_POOL.return_connection(conn)
|
||||||
else:
|
else:
|
||||||
conn.close()
|
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:
|
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"""
|
"""Execute build plan step by step"""
|
||||||
print(f"\n🔨 MODULAR BUILD MODE")
|
print(f"\n🔨 MODULAR BUILD MODE")
|
||||||
|
|
@ -1006,6 +1078,15 @@ float applyForge(float current, float target, float k) {{ return target + (curre
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
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]]:
|
def load_session(self, session_id: str) -> List[Dict[str, str]]:
|
||||||
"""Load a specific session context"""
|
"""Load a specific session context"""
|
||||||
conn = sqlite3.connect(DB_PATH)
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
|
@ -1035,6 +1116,113 @@ float applyForge(float current, float target, float k) {{ return target + (curre
|
||||||
self.context_messages = []
|
self.context_messages = []
|
||||||
return self.session_id
|
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:
|
def run(self) -> None:
|
||||||
"""Main loop"""
|
"""Main loop"""
|
||||||
try:
|
try:
|
||||||
|
|
@ -1063,6 +1251,7 @@ float applyForge(float current, float target, float k) {{ return target + (curre
|
||||||
print("/balanced - Use balanced model")
|
print("/balanced - Use balanced model")
|
||||||
print("/index <path> - Index local repositories")
|
print("/index <path> - Index local repositories")
|
||||||
print("/scan - Scan style signature (V3.0)")
|
print("/scan - Scan style signature (V3.0)")
|
||||||
|
print("/save - Export chat to Markdown")
|
||||||
print("/help - This message")
|
print("/help - This message")
|
||||||
print("exit - End session\n")
|
print("exit - End session\n")
|
||||||
continue
|
continue
|
||||||
|
|
@ -1076,6 +1265,12 @@ float applyForge(float current, float target, float k) {{ return target + (curre
|
||||||
elif cmd == '/scan':
|
elif cmd == '/scan':
|
||||||
self.scan_style_signature()
|
self.scan_style_signature()
|
||||||
continue
|
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:
|
else:
|
||||||
print("\nUnknown command. Type /help")
|
print("\nUnknown command. Type /help")
|
||||||
continue
|
continue
|
||||||
|
|
@ -1119,6 +1314,9 @@ if SERVER_AVAILABLE:
|
||||||
message_id: int
|
message_id: int
|
||||||
positive: bool
|
positive: bool
|
||||||
|
|
||||||
|
class ResetGpuRequest(BaseModel):
|
||||||
|
pass
|
||||||
|
|
||||||
# Multi-user support
|
# Multi-user support
|
||||||
class BuddAIManager:
|
class BuddAIManager:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
|
|
@ -1137,10 +1335,45 @@ if SERVER_AVAILABLE:
|
||||||
app.mount("/web", StaticFiles(directory=frontend_path, html=True), name="web")
|
app.mount("/web", StaticFiles(directory=frontend_path, html=True), name="web")
|
||||||
|
|
||||||
@app.get("/", response_class=HTMLResponse)
|
@app.get("/", response_class=HTMLResponse)
|
||||||
async def root():
|
async def root(request: Request):
|
||||||
server_buddai = buddai_manager.get_instance("default")
|
server_buddai = buddai_manager.get_instance("default")
|
||||||
status = server_buddai.get_user_status()
|
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
|
# System Stats
|
||||||
mem_usage = "N/A"
|
mem_usage = "N/A"
|
||||||
if psutil:
|
if psutil:
|
||||||
|
|
@ -1156,7 +1389,7 @@ if SERVER_AVAILABLE:
|
||||||
return f"""
|
return f"""
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<title>BuddAI API</title>
|
<title>BuddAI API (Dev Mode)</title>
|
||||||
<link rel="icon" href="/favicon.ico">
|
<link rel="icon" href="/favicon.ico">
|
||||||
<style>
|
<style>
|
||||||
body {{
|
body {{
|
||||||
|
|
@ -1260,6 +1493,8 @@ if SERVER_AVAILABLE:
|
||||||
<a href="/web">Launch Web UI</a>
|
<a href="/web">Launch Web UI</a>
|
||||||
<a href="/docs" class="secondary">API Docs</a>
|
<a href="/docs" class="secondary">API Docs</a>
|
||||||
</div>
|
</div>
|
||||||
|
{ip_section}
|
||||||
|
{qr_section}
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
@ -1351,6 +1586,36 @@ if SERVER_AVAILABLE:
|
||||||
server_buddai.record_feedback(req.message_id, req.positive)
|
server_buddai.record_feedback(req.message_id, req.positive)
|
||||||
return {"status": "success"}
|
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")
|
@app.get("/api/history")
|
||||||
async def history_endpoint(user_id: str = Header("default")):
|
async def history_endpoint(user_id: str = Header("default")):
|
||||||
server_buddai = buddai_manager.get_instance(user_id)
|
server_buddai = buddai_manager.get_instance(user_id)
|
||||||
|
|
@ -1379,6 +1644,41 @@ if SERVER_AVAILABLE:
|
||||||
server_buddai.delete_session(req.session_id)
|
server_buddai.delete_session(req.session_id)
|
||||||
return {"status": "success"}
|
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")
|
@app.post("/api/session/new")
|
||||||
async def new_session_endpoint(user_id: str = Header("default")):
|
async def new_session_endpoint(user_id: str = Header("default")):
|
||||||
server_buddai = buddai_manager.get_instance(user_id)
|
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 = http.client.HTTPConnection(OLLAMA_HOST, OLLAMA_PORT, timeout=5)
|
||||||
conn.request("GET", "/api/tags")
|
conn.request("GET", "/api/tags")
|
||||||
response = conn.getresponse()
|
response = conn.getresponse()
|
||||||
conn.close()
|
if response.status == 200:
|
||||||
return response.status == 200
|
data = json.loads(response.read().decode('utf-8'))
|
||||||
except:
|
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
|
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:
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||||
try:
|
try:
|
||||||
s.bind(('0.0.0.0', port))
|
s.bind((host, port))
|
||||||
return True
|
return True
|
||||||
except socket.error:
|
except socket.error:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
if not check_ollama():
|
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)
|
sys.exit(1)
|
||||||
|
|
||||||
parser = argparse.ArgumentParser(description="BuddAI Executive")
|
parser = argparse.ArgumentParser(description="BuddAI Executive")
|
||||||
parser.add_argument("--server", action="store_true", help="Run in server mode")
|
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("--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()
|
args = parser.parse_args()
|
||||||
|
|
||||||
if args.server:
|
if args.server:
|
||||||
if SERVER_AVAILABLE:
|
if SERVER_AVAILABLE:
|
||||||
port = args.port
|
port = args.port
|
||||||
if not is_port_available(port):
|
if not is_port_available(port, args.host):
|
||||||
print(f"⚠️ Port {port} is in use.")
|
print(f"⚠️ Port {port} is in use.")
|
||||||
for i in range(1, 11):
|
for i in range(1, 11):
|
||||||
if is_port_available(port + i):
|
if is_port_available(port + i, args.host):
|
||||||
port += i
|
port += i
|
||||||
print(f"🔄 Switching to available port: {port}")
|
print(f"🔄 Switching to available port: {port}")
|
||||||
break
|
break
|
||||||
|
|
@ -1471,8 +1781,19 @@ def main() -> None:
|
||||||
print(f"❌ Could not find available port in range {args.port}-{args.port+10}")
|
print(f"❌ Could not find available port in range {args.port}-{args.port+10}")
|
||||||
sys.exit(1)
|
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}...")
|
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:
|
else:
|
||||||
print("❌ Server dependencies missing. Install: pip install fastapi uvicorn aiofiles python-multipart")
|
print("❌ Server dependencies missing. Install: pip install fastapi uvicorn aiofiles python-multipart")
|
||||||
else:
|
else:
|
||||||
BIN
ngrok.exe
Normal file
BIN
ngrok.exe
Normal file
Binary file not shown.
|
|
@ -1,6 +1,8 @@
|
||||||
fastapi>=0.68.0
|
fastapi
|
||||||
uvicorn>=0.15.0
|
uvicorn[standard]
|
||||||
python-multipart>=0.0.5
|
python-multipart
|
||||||
psutil>=5.8.0
|
psutil
|
||||||
aiofiles>=0.7.0
|
aiofiles
|
||||||
requests>=2.26.0
|
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