diff --git a/.dockerignore b/.dockerignore deleted file mode 100644 index 7909635..0000000 --- a/.dockerignore +++ /dev/null @@ -1,10 +0,0 @@ -data/ -__pycache__/ -*.pyc -*.pyo -*.pyd -.git -.gitignore -tests/ -venv/ -env/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 883e7bd..0000000 --- a/Dockerfile +++ /dev/null @@ -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"] \ No newline at end of file diff --git a/REMOTE_ACCESS_LOG.md b/REMOTE_ACCESS_LOG.md new file mode 100644 index 0000000..fbc2c7b --- /dev/null +++ b/REMOTE_ACCESS_LOG.md @@ -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. diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index aefe3de..0000000 --- a/docker-compose.yml +++ /dev/null @@ -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" \ No newline at end of file diff --git a/frontend/index.html b/frontend/index.html index 06ab0fc..663c15b 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -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); } + } @@ -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 ( +
+ {isSleeping && ( +
+
z
+
z
+
z
+
+ )} +
+
+
+ ); + }; + + 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 ( +
+
+ RAM +
+
+
+
+
+ CPU +
+
+
+
+
+ ); + }; + 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 ( <>
- BuddAI + +

BuddAI v3.2

{status}
@@ -466,6 +703,7 @@ + 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 /> diff --git a/buddai_v3.2.py b/main.py similarity index 78% rename from buddai_v3.2.py rename to main.py index 3a2ae03..c1f83e5 100644 --- a/buddai_v3.2.py +++ b/main.py @@ -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 - 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""" +
+

{label}

+

{host}

+
+ """ + + qr_section = f""" +
+

Scan to Connect

+ +
+ """ + # System Stats mem_usage = "N/A" if psutil: @@ -1156,7 +1389,7 @@ if SERVER_AVAILABLE: return f""" - BuddAI API + BuddAI API (Dev Mode)