BuddAI/frontend/index.html
JamesTheGiblet fc603464ee 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.
2025-12-30 00:15:54 +00:00

843 lines
No EOL
45 KiB
HTML
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" href="/favicon.ico">
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
<link rel="apple-touch-icon" sizes="192x192" href="/favicon-192x192.png">
<!-- 'theme-color' is not supported by Firefox, Firefox for Android, Opera.
It is used by Chrome and some Android browsers for UI theming. -->
<!-- <meta name="theme-color" content="#1e1e1e"> -->
<!-- For Safari on iOS -->
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<title>BuddAI Web</title>
<script src="https://unpkg.com/react@18/umd/react.development.js"></script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
<link id="hljs-theme" rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/atom-one-dark.min.css">
<style>
:root {
--bg-color: #1e1e1e;
--text-color: #d4d4d4;
--header-bg: #252526;
--border-color: #3e3e3e;
--input-bg: #3c3c3c;
--user-msg-bg: #007acc;
--user-msg-text: white;
--assistant-msg-bg: #2d2d2d;
--btn-bg: #0e639c;
--btn-hover: #1177bb;
--code-bg: #111;
--code-border: #444;
--code-text: #9cdcfe;
}
body.light-mode {
--bg-color: #ffffff;
--text-color: #333333;
--header-bg: #f3f3f3;
--border-color: #e1e1e1;
--input-bg: #ffffff;
--user-msg-bg: #0078d4;
--user-msg-text: white;
--assistant-msg-bg: #f4f4f4;
--btn-bg: #0078d4;
--btn-hover: #106ebe;
--code-bg: #f6f8fa;
--code-border: #d1d5da;
--code-text: #24292e;
}
.sidebar-left { width: 260px; background: var(--header-bg); border-right: 1px solid var(--border-color); display: flex; flex-direction: column; flex-shrink: 0; transition: width 0.3s, opacity 0.3s; overflow: hidden; }
.sidebar-left.collapsed { width: 0; opacity: 0; border: none; }
.session-list { flex: 1; overflow-y: auto; }
.session-item { padding: 12px 15px; cursor: pointer; border-bottom: 1px solid var(--border-color); font-size: 0.85em; color: var(--text-color); transition: background 0.2s; display: flex; justify-content: space-between; align-items: center; }
.session-info { flex: 1; overflow: hidden; }
.rename-input { width: 100%; background: var(--input-bg); color: var(--text-color); border: 1px solid var(--btn-bg); padding: 4px; border-radius: 3px; font-family: inherit; }
.edit-icon, .delete-icon { opacity: 0; cursor: pointer; padding: 4px; font-size: 1.1em; margin-left: 2px; }
.session-item:hover .edit-icon, .session-item:hover .delete-icon { opacity: 0.5; }
.edit-icon:hover, .delete-icon:hover { opacity: 1; }
.delete-icon:hover { color: #ff4444; }
.session-item:hover { background: var(--bg-color); }
.session-item.active { background: var(--btn-bg); color: white; border-color: var(--btn-bg); }
.new-chat-btn { margin: 15px; padding: 10px; background: var(--btn-bg); color: white; border: none; border-radius: 4px; cursor: pointer; font-weight: bold; }
.new-chat-btn:hover { background: var(--btn-hover); }
.session-date { font-weight: bold; display: block; margin-bottom: 4px; }
.session-id { font-size: 0.8em; opacity: 0.6; font-family: monospace; }
body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; background: var(--bg-color); color: var(--text-color); margin: 0; display: flex; justify-content: center; height: 100vh; transition: background 0.3s, color 0.3s; }
#root { width: 100%; max-width: 100%; display: flex; flex-direction: column; height: 100%; }
.main-layout { display: flex; flex: 1; overflow: hidden; }
.chat-section { flex: 1; display: flex; flex-direction: column; min-width: 0; }
.chat-container { flex: 1; overflow-y: auto; padding: 20px; display: flex; flex-direction: column; gap: 15px; }
.side-panel { width: 45%; border-left: 1px solid var(--border-color); display: flex; flex-direction: column; background: var(--bg-color); }
.side-header { padding: 10px 15px; background: var(--header-bg); border-bottom: 1px solid var(--border-color); display: flex; justify-content: space-between; align-items: center; font-size: 0.9em; }
.side-content { flex: 1; overflow: auto; background: var(--code-bg); position: relative; }
.side-content pre { margin: 0; padding: 15px; min-height: 100%; box-sizing: border-box; }
.message { padding: 15px; border-radius: 8px; max-width: 85%; line-height: 1.5; }
.timestamp { font-size: 0.75em; opacity: 0.7; margin-bottom: 4px; display: block; font-family: monospace; }
.user { align-self: flex-end; background: var(--user-msg-bg); color: var(--user-msg-text); }
.assistant { align-self: flex-start; background: var(--assistant-msg-bg); border: 1px solid var(--border-color); }
.input-area { padding: 20px; background: var(--header-bg); border-top: 1px solid var(--border-color); display: flex; gap: 10px; }
input { flex: 1; padding: 12px; border-radius: 4px; border: 1px solid var(--border-color); background: var(--input-bg); color: var(--text-color); outline: none; }
button { padding: 12px 24px; background: var(--btn-bg); color: white; border: none; border-radius: 4px; cursor: pointer; font-weight: bold; }
button:hover { background: var(--btn-hover); }
.stop-btn { background: #d32f2f; }
.stop-btn:hover { background: #b71c1c; }
.header { padding: 15px 20px; background: var(--header-bg); border-bottom: 1px solid var(--border-color); display: flex; justify-content: space-between; align-items: center; }
.clear-btn { background: transparent; border: 1px solid var(--border-color); color: var(--text-color); padding: 5px 12px; font-size: 0.8em; cursor: pointer; border-radius: 4px; opacity: 0.8; }
.clear-btn:hover { background: var(--border-color); opacity: 1; }
.status-badge { font-size: 0.7rem; padding: 2px 8px; border-radius: 10px; margin-left: 10px; text-transform: uppercase; font-weight: bold; letter-spacing: 0.5px; }
.online { color: #4caf50; border: 1px solid #4caf50; background: rgba(76, 175, 80, 0.1); }
.offline { color: #f44336; border: 1px solid #f44336; background: rgba(244, 67, 54, 0.1); }
.connecting { color: #ff9800; border: 1px solid #ff9800; background: rgba(255, 152, 0, 0.1); }
.suggestions { margin-top: 10px; display: flex; flex-wrap: wrap; gap: 8px; }
.suggestion-pill { background: var(--bg-color); border: 1px solid var(--btn-bg); color: var(--btn-bg); padding: 6px 12px; border-radius: 15px; font-size: 0.85em; cursor: pointer; transition: all 0.2s; }
.suggestion-pill:hover { background: var(--btn-bg); color: white; }
/* Code Blocks */
.code-wrapper { position: relative; margin: 10px 0; border: 1px solid var(--code-border); border-radius: 6px; overflow: hidden; }
.code-header { display: flex; justify-content: space-between; align-items: center; background: var(--header-bg); padding: 5px 10px; font-size: 0.8em; border-bottom: 1px solid var(--border-color); color: var(--text-color); opacity: 0.8; }
.copy-code-btn { background: transparent; border: 1px solid var(--border-color); color: var(--text-color); padding: 2px 8px; font-size: 0.9em; cursor: pointer; border-radius: 3px; }
.copy-code-btn:hover { background: var(--border-color); }
pre { background: var(--code-bg); padding: 15px; margin: 0; overflow-x: auto; }
code { font-family: 'Consolas', 'Courier New', monospace; color: var(--code-text); }
p { margin: 0 0 10px 0; }
@keyframes flame-flicker {
0% { transform: scale(1); filter: drop-shadow(0 0 2px #ff9800); }
50% { transform: scale(1.1) rotate(-2deg); filter: drop-shadow(0 0 6px #ff4500); }
100% { transform: scale(1) rotate(2deg); filter: drop-shadow(0 0 2px #ff9800); }
}
.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>
<div id="root"></div>
<script type="text/babel">
// Configure Marked for Code Copy
const renderer = new marked.Renderer();
renderer.code = (code, language) => {
// Handle Marked v12+ signature where first arg is a token object
if (typeof code === 'object' && code !== null && code.text) {
language = code.lang;
code = code.text;
}
const validLang = (language && hljs.getLanguage(language)) ? language : 'plaintext';
let highlighted = code;
try {
const result = hljs.highlight(code, { language: validLang });
highlighted = result.value || code;
} catch (e) { /* fallback */ }
return `<div class="code-wrapper">
<div class="code-header">
<span>${language || 'text'}</span>
<div style="display:flex; gap:5px;">
<button class="copy-code-btn" onclick="window.copyToClipboard(this)">Copy</button>
<button class="copy-code-btn" onclick="window.sendToSidebar(this)">Sidebar</button>
</div>
</div>
<pre><code class="hljs ${validLang}">${highlighted}</code></pre>
</div>`;
};
marked.setOptions({ renderer });
window.copyToClipboard = (btn) => {
const wrapper = btn.closest('.code-wrapper');
const code = wrapper.querySelector('code').innerText;
navigator.clipboard.writeText(code).then(() => {
const original = btn.innerText;
btn.innerText = 'Copied!';
setTimeout(() => btn.innerText = original, 2000);
});
};
window.sendToSidebar = (btn) => {
const wrapper = btn.closest('.code-wrapper');
const code = wrapper.querySelector('code').innerText;
if (window.updateSidebar) window.updateSidebar(code);
};
window.downloadCode = (content) => {
if (!content) return;
if (typeof content !== 'string') return;
let ext = 'txt';
// Heuristic for Arduino
if (content.includes('void setup()') && content.includes('void loop()')) {
ext = 'ino';
} else {
try {
const result = hljs.highlightAuto(content);
if (result.language) {
const map = {
python: 'py', javascript: 'js', cpp: 'cpp', c: 'c',
arduino: 'ino', html: 'html', css: 'css',
csharp: 'cs', java: 'java', bash: 'sh', json: 'json',
markdown: 'md', sql: 'sql'
};
ext = map[result.language] || result.language;
}
} catch (e) {}
}
const blob = new Blob([content], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `buddai_generated.${ext}`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
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([]);
const [currentSessionId, setCurrentSessionId] = useState(null);
const [showSidebar, setShowSidebar] = useState(true);
const [editingSession, setEditingSession] = useState(null);
const [renameText, setRenameText] = useState("");
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);
const scrollToBottom = () => {
endRef.current?.scrollIntoView({ behavior: "smooth" });
};
useEffect(() => {
window.updateSidebar = (code) => {
setSidebarContent(code);
setIsSidebarOpen(true);
};
document.body.className = theme === 'light' ? 'light-mode' : '';
const hljsTheme = document.getElementById('hljs-theme');
if (hljsTheme) {
hljsTheme.href = theme === 'light'
? "https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/atom-one-light.min.css"
: "https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/atom-one-dark.min.css";
}
}, [theme]);
useEffect(() => {
scrollToBottom();
}, [history]);
const fetchSessions = () => fetch("/api/sessions").then(res => res.json()).then(data => setSessions(data.sessions || [])).catch(console.error);
useEffect(() => {
// Check System Status
const checkStatus = async () => {
try {
const res = await fetch("/");
setStatus(res.ok ? "online" : "offline");
} catch {
setStatus("offline");
}
};
checkStatus();
const timer = setInterval(checkStatus, 10000);
fetchSessions();
// Load History
fetch("/api/history")
.then(res => res.json())
.then(data => {
if (data.history) {
setHistory(data.history.filter(m => m.role === 'user' || m.role === 'assistant'));
}
})
.catch(console.error);
return () => clearInterval(timer);
}, []);
useEffect(() => {
// Initialize WebSocket
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/api/ws/chat`;
socketRef.current = new WebSocket(wsUrl);
return () => {
if (socketRef.current) socketRef.current.close();
};
}, []);
const handleRename = async (e) => {
if (e.key === 'Enter') {
await fetch("/api/session/rename", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ session_id: editingSession, title: renameText })
});
setEditingSession(null);
fetchSessions();
}
};
const handleDeleteSession = async (e, sessionId) => {
e.stopPropagation();
if (!window.confirm("Delete this chat?")) return;
await fetch("/api/session/delete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ session_id: sessionId })
});
if (sessionId === currentSessionId) {
setHistory([]);
setCurrentSessionId(null);
}
fetchSessions();
};
const handleFeedback = async (messageId, positive) => {
if (!messageId || feedbackGiven[messageId]) return;
setFeedbackGiven(prev => ({ ...prev, [messageId]: positive ? 'positive' : 'negative' }));
try {
await fetch("/api/feedback", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message_id: messageId, positive: positive })
});
} catch (e) {
console.error("Feedback submission failed", e);
// Revert UI on failure
setFeedbackGiven(prev => { const n = {...prev}; delete n[messageId]; return n; });
}
};
const loadSession = async (sessionId) => {
setLoading(true);
try {
const res = await fetch("/api/session/load", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ session_id: sessionId })
});
const data = await res.json();
setHistory(data.history || []);
setCurrentSessionId(data.session_id);
} catch (e) { console.error(e); }
setLoading(false);
};
const startNewSession = async () => {
const res = await fetch("/api/session/new", { method: "POST" });
const data = await res.json();
setCurrentSessionId(data.session_id);
setHistory([]);
fetchSessions();
};
const sendMessage = async (textOverride = null) => {
const msgText = typeof textOverride === 'string' ? textOverride : input;
if (!msgText.trim()) return;
const userMsg = { role: "user", content: msgText, timestamp: new Date().toISOString() };
setHistory(prev => [...prev, userMsg]);
if (!textOverride) setInput("");
setLoading(true);
// Use WebSocket if available
if (socketRef.current && socketRef.current.readyState === WebSocket.OPEN) {
// Add placeholder for streaming response
setHistory(prev => [...prev, { role: "assistant", content: "" }]);
socketRef.current.send(JSON.stringify({
message: msgText,
forge_mode: forgeMode,
user_id: "default" // In a real app, get from auth context
}));
socketRef.current.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === 'token') {
setHistory(prev => {
const newHistory = [...prev];
const lastMsg = newHistory[newHistory.length - 1];
if (lastMsg.role === 'assistant') {
lastMsg.content += data.content;
}
return newHistory;
});
} else if (data.type === 'end') {
setLoading(false);
setHistory(prev => {
const newHistory = [...prev];
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;
});
if (!currentSessionId) fetchSessions();
}
};
} else {
// Fallback to HTTP
try {
const res = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message: msgText, forge_mode: forgeMode })
});
const data = await res.json();
setHistory(prev => [...prev, { role: "assistant", content: data.response, id: data.message_id }]);
if (!currentSessionId) fetchSessions();
} catch (err) {
setHistory(prev => [...prev, { role: "assistant", content: "Error connecting to BuddAI server." }]);
}
setLoading(false);
}
};
const stopGeneration = () => {
if (abortControllerRef.current) abortControllerRef.current.abort();
};
const handleFileUpload = async (e) => {
const file = e.target.files[0];
if (!file) return;
const formData = new FormData();
formData.append("file", file);
setLoading(true);
setHistory(prev => [...prev, { role: "assistant", content: `📥 Uploading and indexing **${file.name}**...` }]);
try {
const res = await fetch("/api/upload", { method: "POST", body: formData });
const data = await res.json();
setHistory(prev => [...prev, { role: "assistant", content: data.message }]);
} catch (err) {
setHistory(prev => [...prev, { role: "assistant", content: "❌ Upload failed." }]);
}
setLoading(false);
e.target.value = null;
};
const parseContent = (content) => {
const parts = content.split("\n\nPROACTIVE: > ");
const text = parts[0];
let suggestions = [];
if (parts.length > 1) {
// Split "1. Suggestion 2. Suggestion" patterns
suggestions = parts[1].split(/\d+\.\s/).map(s => s.trim()).filter(s => s);
}
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'}}>
<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>
<div style={{display:'flex', gap:'10px'}}>
<input
type="file"
id="upload-input"
style={{display:'none'}}
onChange={handleFileUpload}
/>
<button className="clear-btn" onClick={() => document.getElementById('upload-input').click()}>📂 Upload</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={() => 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)}
style={{background: 'var(--input-bg)', color: 'var(--text-color)', border: '1px solid var(--border-color)', padding: '5px', borderRadius: '4px', fontSize: '0.8em'}}>
<option value="1">Aggressive (Combat)</option>
<option value="2">Balanced (Standard)</option>
<option value="3">Graceful (Smooth)</option>
</select>
<button className="clear-btn" onClick={() => setHistory([])}>Clear</button>
</div>
</div>
<div className="main-layout">
<div className={`sidebar-left ${!showSidebar ? 'collapsed' : ''}`}>
<button className="new-chat-btn" onClick={startNewSession}>+ New Chat</button>
<div className="session-list">
{sessions.map(s => (
<div key={s.id} className={`session-item ${s.id === currentSessionId ? 'active' : ''}`} onClick={() => loadSession(s.id)}>
{editingSession === s.id ? (
<input
className="rename-input"
value={renameText}
onChange={e => setRenameText(e.target.value)}
onKeyDown={handleRename}
onBlur={() => setEditingSession(null)}
autoFocus
onClick={e => e.stopPropagation()}
/>
) : (
<>
<div className="session-info">
<span className="session-date">{s.title || (new Date(s.date).toLocaleDateString() + ' ' + new Date(s.date).toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'}))}</span>
{!s.title && <span className="session-id">{s.id}</span>}
</div>
<div style={{display:'flex'}}>
<span className="edit-icon" onClick={(e) => {
e.stopPropagation();
setEditingSession(s.id);
setRenameText(s.title || "");
}}></span>
<span className="delete-icon" onClick={(e) => handleDeleteSession(e, s.id)}>🗑</span>
</div>
</>
)}
</div>
))}
</div>
</div>
<div className="chat-section">
<div className="chat-container">
{history.length === 0 && <div style={{textAlign: 'center', marginTop: '50px', color: '#666'}}><p>Ready to build.</p></div>}
{history.map((msg, i) => {
const { text, suggestions } = msg.role === 'assistant' ? parseContent(msg.content) : { text: msg.content, suggestions: [] };
const timeStr = msg.timestamp ? new Date(msg.timestamp).toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'}) : '';
return (
<div key={i} className={`message ${msg.role}`}>
{timeStr && <span className="timestamp">{timeStr}</span>}
<div dangerouslySetInnerHTML={{ __html: marked.parse(text) }} />
{suggestions.length > 0 && (
<div className="suggestions">
{suggestions.map((s, idx) => (
<div key={idx} className="suggestion-pill" onClick={() => sendMessage(s)}>{s}</div>
))}
</div>
)}
{msg.role === 'assistant' && msg.id && !loading && (
<div className="feedback-btns">
<button
className={`feedback-btn ${feedbackGiven[msg.id] === 'positive' ? 'selected' : ''}`}
onClick={() => handleFeedback(msg.id, true)}
disabled={!!feedbackGiven[msg.id]}
title="Good response"
>👍</button>
<button
className={`feedback-btn ${feedbackGiven[msg.id] === 'negative' ? 'selected' : ''}`}
onClick={() => handleFeedback(msg.id, false)}
disabled={!!feedbackGiven[msg.id]}
title="Bad response"
>👎</button>
</div>
)}
</div>
);
})}
{loading && <div className="message assistant"><span className="loading-flame">🔥</span></div>}
<div ref={endRef} />
</div>
<div className="input-area">
<input
ref={inputRef}
value={input}
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
/>
{loading ? (
<button className="stop-btn" onClick={stopGeneration}>Stop</button>
) : (
<button onClick={() => sendMessage()}>Send</button>
)}
</div>
</div>
{isSidebarOpen && (
<div className="side-panel">
<div className="side-header">
<span>Code Workspace</span>
<div style={{display: 'flex', gap: '5px'}}>
<button className="copy-code-btn" onClick={() => setSidebarContent("")}>Clear</button>
<button className="copy-code-btn" onClick={() => navigator.clipboard.writeText(sidebarContent)}>Copy</button>
<button className="copy-code-btn" onClick={() => window.downloadCode(sidebarContent)}>Download</button>
</div>
</div>
<div className="side-content">
<pre><code className="hljs" dangerouslySetInnerHTML={{__html: sidebarContent ? hljs.highlightAuto(sidebarContent).value : '// Select code from chat to view here'}} /></pre>
</div>
</div>
)}
</div>
</>
);
}
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<App />);
</script>
</body>
</html>