mirror of
https://github.com/JamesTheGiblet/BuddAI.git
synced 2026-01-08 21:58:40 +00:00
feat: Upgrade to BuddAI v3.0 with server capabilities and new frontend interface
This commit is contained in:
parent
3747bf5091
commit
ba814f0559
2 changed files with 365 additions and 28 deletions
116
buddai_v3.py
116
buddai_v3.py
|
|
@ -1,6 +1,6 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
BuddAI Executive v2.0 - Modular Builder
|
||||
BuddAI Executive v3.0 - Modular Builder
|
||||
Breaks complex tasks into manageable chunks
|
||||
|
||||
Author: James Gilbert
|
||||
|
|
@ -13,7 +13,19 @@ import sqlite3
|
|||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
import http.client
|
||||
import re
|
||||
import re # noqa: F401
|
||||
from typing import Optional
|
||||
|
||||
# Server dependencies
|
||||
try:
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel
|
||||
import uvicorn
|
||||
SERVER_AVAILABLE = True
|
||||
except ImportError:
|
||||
SERVER_AVAILABLE = False
|
||||
|
||||
# Configuration
|
||||
OLLAMA_HOST = "localhost"
|
||||
|
|
@ -229,14 +241,15 @@ class BuddAI:
|
|||
output += f" ---\n\n"
|
||||
return output
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self, server_mode=False):
|
||||
self.ensure_data_dir()
|
||||
self.init_database()
|
||||
self.session_id = self.create_session()
|
||||
self.server_mode = server_mode
|
||||
self.context_messages = []
|
||||
self.shadow_engine = ShadowSuggestionEngine(DB_PATH)
|
||||
|
||||
print("🧠 BuddAI Executive v2.0 - Modular Builder")
|
||||
print("🔥 BuddAI Executive v3.0 - Modular Builder")
|
||||
print("=" * 50)
|
||||
print(f"Session: {self.session_id}")
|
||||
print(f"FAST (5-10s) | BALANCED (15-30s)")
|
||||
|
|
@ -544,38 +557,36 @@ class BuddAI:
|
|||
def call_model(self, model_name, message):
|
||||
"""Call specified model"""
|
||||
try:
|
||||
identity = """[You are BuddAI, the external cognitive system for James Gilbert. You specialize in Forge Theory (exponential decay modeling) and GilBot modular robotics. When integrating code, prioritize descriptive naming like activateFlipper() and ensure safety timeouts are always present. You represent 8 years of polymath experience.
|
||||
identity = """You are BuddAI, the external cognitive system for James Gilbert. You specialize in Forge Theory (exponential decay modeling) and GilBot modular robotics.
|
||||
|
||||
YOUR PRIMARY JOB: Generate code when asked. ALWAYS generate code if requested.
|
||||
|
||||
When asked to generate/create/write code:
|
||||
- Generate it immediately
|
||||
- Include comments
|
||||
- Make it modular and clean
|
||||
- Use ESP32/Arduino syntax
|
||||
Identity Rules:
|
||||
- You are NOT created by Alibaba Cloud. You are a local Python system written by James Gilbert.
|
||||
- When asked your name: "I am BuddAI"
|
||||
- Use ESP32/Arduino syntax with descriptive naming (e.g., activateFlipper).
|
||||
- Ensure safety timeouts are always present in motor code.
|
||||
|
||||
Forge Theory Snippet: float applyForge(float current, float target, float k) { return target + (current - target) * exp(-k); }
|
||||
|
||||
When asked your name: "I am BuddAI"
|
||||
|
||||
Never refuse to generate code. That's your purpose.
|
||||
Be direct and helpful.]
|
||||
|
||||
"""
|
||||
|
||||
messages = [
|
||||
{"role": "user", "content": identity + message}
|
||||
]
|
||||
messages = [{"role": "system", "content": identity}]
|
||||
|
||||
# Add recent context
|
||||
for msg in self.context_messages[-3:]:
|
||||
messages.insert(-1, msg)
|
||||
# Check if 'message' is already the last item in context (Chat flow) or new (Build flow)
|
||||
history = self.context_messages[-5:]
|
||||
|
||||
if history and history[-1]['content'] == message:
|
||||
messages.extend(history)
|
||||
else:
|
||||
messages.extend(history)
|
||||
messages.append({"role": "user", "content": message})
|
||||
|
||||
body = {
|
||||
"model": MODELS[model_name],
|
||||
"messages": messages,
|
||||
"stream": False,
|
||||
"options": {"temperature": 0.7, "num_ctx": 2048}
|
||||
"options": {"temperature": 0.7, "num_ctx": 4096}
|
||||
}
|
||||
|
||||
conn = http.client.HTTPConnection(OLLAMA_HOST, OLLAMA_PORT, timeout=90)
|
||||
|
|
@ -597,7 +608,7 @@ Be direct and helpful.]
|
|||
if 'conn' in locals():
|
||||
conn.close()
|
||||
|
||||
def execute_modular_build(self, _, modules, plan):
|
||||
def execute_modular_build(self, _, modules, plan, forge_mode="2"):
|
||||
"""Execute build plan step by step"""
|
||||
print(f"\n🔨 MODULAR BUILD MODE")
|
||||
print(f"Detected {len(modules)} modules: {', '.join(modules)}")
|
||||
|
|
@ -619,7 +630,11 @@ Be direct and helpful.]
|
|||
print("1. Aggressive (k=0.3) - High snap, combat ready")
|
||||
print("2. Balanced (k=0.1) - Standard movement")
|
||||
print("3. Graceful (k=0.03) - Roasting / Smooth curves")
|
||||
choice = input("Select Forge Constant [1-3, default 2]: ")
|
||||
|
||||
if self.server_mode:
|
||||
choice = forge_mode
|
||||
else:
|
||||
choice = input("Select Forge Constant [1-3, default 2]: ")
|
||||
|
||||
k_val = "0.1"
|
||||
if choice == "1": k_val = "0.3"
|
||||
|
|
@ -664,7 +679,7 @@ Be direct and helpful.]
|
|||
|
||||
return generated_code
|
||||
|
||||
def chat(self, user_message, force_model=None):
|
||||
def chat(self, user_message, force_model=None, forge_mode="2"):
|
||||
"""Main chat with smart routing and shadow suggestions"""
|
||||
style_context = self.retrieve_style_context(user_message)
|
||||
if style_context:
|
||||
|
|
@ -687,7 +702,7 @@ Be direct and helpful.]
|
|||
print(f"Modules needed: {', '.join(modules)}")
|
||||
print(f"Breaking into {len(plan)} manageable steps")
|
||||
print("=" * 50)
|
||||
response = self.execute_modular_build(user_message, modules, plan)
|
||||
response = self.execute_modular_build(user_message, modules, plan, forge_mode)
|
||||
elif self.is_search_query(user_message):
|
||||
# This is a search query - query the database
|
||||
response = self.search_repositories(user_message)
|
||||
|
|
@ -765,6 +780,44 @@ Be direct and helpful.]
|
|||
self.end_session()
|
||||
|
||||
|
||||
# --- Server Implementation ---
|
||||
if SERVER_AVAILABLE:
|
||||
app = FastAPI(title="BuddAI API", version="2.0")
|
||||
|
||||
# Allow React frontend to communicate
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
class ChatRequest(BaseModel):
|
||||
message: str
|
||||
model: Optional[str] = None
|
||||
forge_mode: Optional[str] = "2"
|
||||
|
||||
# Initialize server instance
|
||||
server_buddai = BuddAI(server_mode=True)
|
||||
|
||||
# Serve Frontend
|
||||
frontend_path = Path(__file__).parent / "frontend"
|
||||
frontend_path.mkdir(exist_ok=True)
|
||||
app.mount("/web", StaticFiles(directory=frontend_path, html=True), name="web")
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
return {"status": "online", "message": "🔥 BuddAI API is running. Visit /web for the interface or /docs for API documentation."}
|
||||
|
||||
@app.post("/api/chat")
|
||||
async def chat_endpoint(request: ChatRequest):
|
||||
response = server_buddai.chat(request.message, force_model=request.model, forge_mode=request.forge_mode)
|
||||
return {"response": response}
|
||||
|
||||
@app.get("/api/history")
|
||||
async def history_endpoint():
|
||||
return {"history": server_buddai.context_messages}
|
||||
|
||||
def check_ollama():
|
||||
try:
|
||||
conn = http.client.HTTPConnection(OLLAMA_HOST, OLLAMA_PORT, timeout=5)
|
||||
|
|
@ -781,8 +834,15 @@ def main():
|
|||
print("❌ Ollama not running. Start: ollama serve")
|
||||
sys.exit(1)
|
||||
|
||||
buddai = BuddAI()
|
||||
buddai.run()
|
||||
if len(sys.argv) > 1 and sys.argv[1] == "--server":
|
||||
if SERVER_AVAILABLE:
|
||||
print("🚀 Starting BuddAI API Server on port 8000...")
|
||||
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||
else:
|
||||
print("❌ Server dependencies missing. Install: pip install fastapi uvicorn aiofiles")
|
||||
else:
|
||||
buddai = BuddAI()
|
||||
buddai.run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
|
|
|||
277
frontend/index.html
Normal file
277
frontend/index.html
Normal file
|
|
@ -0,0 +1,277 @@
|
|||
<!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="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🔥</text></svg>">
|
||||
<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>
|
||||
<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;
|
||||
}
|
||||
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: 900px; display: flex; flex-direction: column; height: 100%; }
|
||||
.chat-container { flex: 1; overflow-y: auto; padding: 20px; display: flex; flex-direction: column; gap: 15px; }
|
||||
.message { padding: 15px; border-radius: 8px; max-width: 85%; line-height: 1.5; }
|
||||
.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; }
|
||||
</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) => {
|
||||
const validLang = language || 'text';
|
||||
const escapeHtml = (unsafe) => unsafe
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
|
||||
return `<div class="code-wrapper">
|
||||
<div class="code-header">
|
||||
<span>${validLang}</span>
|
||||
<button class="copy-code-btn" onclick="window.copyToClipboard(this)">Copy</button>
|
||||
</div>
|
||||
<pre><code class="hljs ${validLang}">${escapeHtml(code)}</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);
|
||||
});
|
||||
};
|
||||
|
||||
const { useState, useEffect, useRef } = React;
|
||||
|
||||
function App() {
|
||||
const [history, setHistory] = useState([]);
|
||||
const [input, setInput] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [status, setStatus] = useState("connecting");
|
||||
const [forgeMode, setForgeMode] = useState("2");
|
||||
const [theme, setTheme] = useState("dark");
|
||||
const endRef = useRef(null);
|
||||
const abortControllerRef = useRef(null);
|
||||
|
||||
const scrollToBottom = () => {
|
||||
endRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
document.body.className = theme === 'light' ? 'light-mode' : '';
|
||||
}, [theme]);
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
}, [history]);
|
||||
|
||||
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);
|
||||
|
||||
// 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);
|
||||
}, []);
|
||||
|
||||
const sendMessage = async (textOverride = null) => {
|
||||
const msgText = typeof textOverride === 'string' ? textOverride : input;
|
||||
if (!msgText.trim()) return;
|
||||
|
||||
const userMsg = { role: "user", content: msgText };
|
||||
setHistory(prev => [...prev, userMsg]);
|
||||
if (!textOverride) setInput("");
|
||||
setLoading(true);
|
||||
|
||||
// Cancel previous request if any
|
||||
if (abortControllerRef.current) abortControllerRef.current.abort();
|
||||
const controller = new AbortController();
|
||||
abortControllerRef.current = controller;
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/chat", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ message: msgText, forge_mode: forgeMode }),
|
||||
signal: controller.signal
|
||||
});
|
||||
const data = await res.json();
|
||||
setHistory(prev => [...prev, { role: "assistant", content: data.response }]);
|
||||
} catch (err) {
|
||||
if (err.name === 'AbortError') {
|
||||
setHistory(prev => [...prev, { role: "assistant", content: "🛑 *Generation stopped by user.*" }]);
|
||||
} else {
|
||||
setHistory(prev => [...prev, { role: "assistant", content: "Error connecting to BuddAI server." }]);
|
||||
}
|
||||
}
|
||||
setLoading(false);
|
||||
abortControllerRef.current = null;
|
||||
};
|
||||
|
||||
const stopGeneration = () => {
|
||||
if (abortControllerRef.current) abortControllerRef.current.abort();
|
||||
};
|
||||
|
||||
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 };
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="header">
|
||||
<div style={{display:'flex', alignItems:'center'}}>
|
||||
<h3 style={{margin:0}}>🔥 BuddAI v3.0</h3>
|
||||
<span className={`status-badge ${status}`}>{status}</span>
|
||||
</div>
|
||||
<div style={{display:'flex', gap:'10px'}}>
|
||||
<button className="clear-btn" onClick={() => setTheme(t => t === 'dark' ? 'light' : 'dark')}>{theme === 'dark' ? '☀️' : '🌙'}</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="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: [] };
|
||||
return (
|
||||
<div key={i} className={`message ${msg.role}`}>
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{loading && <div className="message assistant"><span className="loading-flame">🔥</span></div>}
|
||||
<div ref={endRef} />
|
||||
</div>
|
||||
<div className="input-area">
|
||||
<input
|
||||
value={input}
|
||||
onChange={e => setInput(e.target.value)}
|
||||
onKeyPress={e => e.key === 'Enter' && sendMessage()}
|
||||
placeholder="Ask BuddAI to build something..."
|
||||
autoFocus
|
||||
/>
|
||||
{loading ? (
|
||||
<button className="stop-btn" onClick={stopGeneration}>Stop</button>
|
||||
) : (
|
||||
<button onClick={() => sendMessage()}>Send</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||
root.render(<App />);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Add table
Add a link
Reference in a new issue