mirror of
https://github.com/JamesTheGiblet/BuddAI.git
synced 2026-01-08 21:58:40 +00:00
- 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.
843 lines
No EOL
45 KiB
HTML
843 lines
No EOL
45 KiB
HTML
<!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> |