voodoo-framework 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- voodoo/__init__.py +51 -0
- voodoo/agent.py +37 -0
- voodoo/api.py +143 -0
- voodoo/cli.py +250 -0
- voodoo/components.py +107 -0
- voodoo/config.py +51 -0
- voodoo/core.py +236 -0
- voodoo/data.py +179 -0
- voodoo/i18n.py +107 -0
- voodoo/mcp.py +141 -0
- voodoo/queue.py +61 -0
- voodoo/status.py +131 -0
- voodoo/storage.py +85 -0
- voodoo/telemetry.py +157 -0
- voodoo/theme.py +112 -0
- voodoo_framework-1.0.0.dist-info/METADATA +23 -0
- voodoo_framework-1.0.0.dist-info/RECORD +20 -0
- voodoo_framework-1.0.0.dist-info/WHEEL +5 -0
- voodoo_framework-1.0.0.dist-info/entry_points.txt +2 -0
- voodoo_framework-1.0.0.dist-info/top_level.txt +1 -0
voodoo/__init__.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
from .core import create_app, register_event, ws_manager
|
|
2
|
+
from .components import Div, Button, Input, Card, Text, Heading, ChatBox, Table
|
|
3
|
+
from .data import BaseModel, on_insert, on_update, rls_policy, get_db
|
|
4
|
+
from .queue import queue, enqueue
|
|
5
|
+
from .agent import Agent
|
|
6
|
+
from .api import api
|
|
7
|
+
from .storage import storage
|
|
8
|
+
from .mcp import mcp, MCPClient
|
|
9
|
+
from .status import ServiceStatus
|
|
10
|
+
from .telemetry import trace, TelemetryMiddleware, telemetry_store
|
|
11
|
+
from .config import config
|
|
12
|
+
from .theme import Theme, ThemeColors, set_theme, default_theme
|
|
13
|
+
from .i18n import _, I18n, i18n_instance
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"create_app",
|
|
17
|
+
"register_event",
|
|
18
|
+
"ws_manager",
|
|
19
|
+
"Div",
|
|
20
|
+
"Button",
|
|
21
|
+
"Input",
|
|
22
|
+
"Card",
|
|
23
|
+
"Text",
|
|
24
|
+
"Heading",
|
|
25
|
+
"ChatBox",
|
|
26
|
+
"Table",
|
|
27
|
+
"BaseModel",
|
|
28
|
+
"on_insert",
|
|
29
|
+
"on_update",
|
|
30
|
+
"rls_policy",
|
|
31
|
+
"get_db",
|
|
32
|
+
"queue",
|
|
33
|
+
"enqueue",
|
|
34
|
+
"Agent",
|
|
35
|
+
"api",
|
|
36
|
+
"storage",
|
|
37
|
+
"mcp",
|
|
38
|
+
"MCPClient",
|
|
39
|
+
"ServiceStatus",
|
|
40
|
+
"trace",
|
|
41
|
+
"TelemetryMiddleware",
|
|
42
|
+
"telemetry_store",
|
|
43
|
+
"config",
|
|
44
|
+
"Theme",
|
|
45
|
+
"ThemeColors",
|
|
46
|
+
"set_theme",
|
|
47
|
+
"default_theme",
|
|
48
|
+
"_",
|
|
49
|
+
"I18n",
|
|
50
|
+
"i18n_instance"
|
|
51
|
+
]
|
voodoo/agent.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from typing import AsyncGenerator, List, Dict
|
|
3
|
+
|
|
4
|
+
class Agent:
|
|
5
|
+
def __init__(self, system_prompt: str = "You are a helpful AI assistant."):
|
|
6
|
+
self.system_prompt = system_prompt
|
|
7
|
+
self.history: List[Dict[str, str]] = [
|
|
8
|
+
{"role": "system", "content": system_prompt}
|
|
9
|
+
]
|
|
10
|
+
|
|
11
|
+
async def stream(self, prompt: str) -> AsyncGenerator[str, None]:
|
|
12
|
+
from voodoo.telemetry import telemetry_store
|
|
13
|
+
self.history.append({"role": "user", "content": prompt})
|
|
14
|
+
|
|
15
|
+
# In a real scenario, this would connect to OpenAI/Anthropic etc.
|
|
16
|
+
# For this standalone framework, we'll simulate a streaming response
|
|
17
|
+
# or use an actual API if configured.
|
|
18
|
+
|
|
19
|
+
# Simulating a stream of tokens:
|
|
20
|
+
words = f"This is an AI response to: '{prompt}'. In a real app, this streams from an LLM. Here are some more tokens to simulate streaming.".split(" ")
|
|
21
|
+
|
|
22
|
+
telemetry_store.record_agent_tokens(len(prompt.split()) + len(words))
|
|
23
|
+
|
|
24
|
+
full_response = ""
|
|
25
|
+
for word in words:
|
|
26
|
+
await asyncio.sleep(0.1) # Simulate network latency
|
|
27
|
+
token = word + " "
|
|
28
|
+
full_response += token
|
|
29
|
+
yield token
|
|
30
|
+
|
|
31
|
+
self.history.append({"role": "assistant", "content": full_response.strip()})
|
|
32
|
+
|
|
33
|
+
async def run(self, prompt: str) -> str:
|
|
34
|
+
response = ""
|
|
35
|
+
async for chunk in self.stream(prompt):
|
|
36
|
+
response += chunk
|
|
37
|
+
return response
|
voodoo/api.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
from typing import Any, Callable, Dict, List, Type, Optional, Union, get_type_hints
|
|
3
|
+
from starlette.requests import Request
|
|
4
|
+
from starlette.responses import JSONResponse, HTMLResponse, Response
|
|
5
|
+
from starlette.routing import Route
|
|
6
|
+
from pydantic import BaseModel, create_model
|
|
7
|
+
|
|
8
|
+
class API:
|
|
9
|
+
def __init__(self):
|
|
10
|
+
self.routes: List[Route] = []
|
|
11
|
+
self.paths: Dict[str, Dict[str, Any]] = {}
|
|
12
|
+
|
|
13
|
+
# Add docs routes
|
|
14
|
+
self.routes.append(Route("/openapi.json", self._openapi_schema, methods=["GET"]))
|
|
15
|
+
self.routes.append(Route("/docs", self._swagger_ui, methods=["GET"]))
|
|
16
|
+
self.routes.append(Route("/redoc", self._redoc_ui, methods=["GET"]))
|
|
17
|
+
|
|
18
|
+
def _openapi_schema(self, request: Request):
|
|
19
|
+
schema = {
|
|
20
|
+
"openapi": "3.0.2",
|
|
21
|
+
"info": {"title": "Voodoo API", "version": "1.0.0"},
|
|
22
|
+
"paths": self.paths,
|
|
23
|
+
"components": {"schemas": {}}
|
|
24
|
+
}
|
|
25
|
+
return JSONResponse(schema)
|
|
26
|
+
|
|
27
|
+
def _swagger_ui(self, request: Request):
|
|
28
|
+
html = """
|
|
29
|
+
<!DOCTYPE html>
|
|
30
|
+
<html>
|
|
31
|
+
<head>
|
|
32
|
+
<title>Swagger UI</title>
|
|
33
|
+
<link rel="stylesheet" type="text/css" href="https://unpkg.com/swagger-ui-dist@5.0.0/swagger-ui.css" />
|
|
34
|
+
</head>
|
|
35
|
+
<body>
|
|
36
|
+
<div id="swagger-ui"></div>
|
|
37
|
+
<script src="https://unpkg.com/swagger-ui-dist@5.0.0/swagger-ui-bundle.js"></script>
|
|
38
|
+
<script>
|
|
39
|
+
window.onload = () => {
|
|
40
|
+
window.ui = SwaggerUIBundle({
|
|
41
|
+
url: '/openapi.json',
|
|
42
|
+
dom_id: '#swagger-ui',
|
|
43
|
+
});
|
|
44
|
+
};
|
|
45
|
+
</script>
|
|
46
|
+
</body>
|
|
47
|
+
</html>
|
|
48
|
+
"""
|
|
49
|
+
return HTMLResponse(html)
|
|
50
|
+
|
|
51
|
+
def _redoc_ui(self, request: Request):
|
|
52
|
+
html = """
|
|
53
|
+
<!DOCTYPE html>
|
|
54
|
+
<html>
|
|
55
|
+
<head>
|
|
56
|
+
<title>ReDoc</title>
|
|
57
|
+
</head>
|
|
58
|
+
<body>
|
|
59
|
+
<redoc spec-url='/openapi.json'></redoc>
|
|
60
|
+
<script src="https://unpkg.com/redoc@2.0.0-rc.53/bundles/redoc.standalone.js"></script>
|
|
61
|
+
</body>
|
|
62
|
+
</html>
|
|
63
|
+
"""
|
|
64
|
+
return HTMLResponse(html)
|
|
65
|
+
|
|
66
|
+
def _add_route(self, path: str, method: str, func: Callable):
|
|
67
|
+
# Register in OpenAPI paths
|
|
68
|
+
if path not in self.paths:
|
|
69
|
+
self.paths[path] = {}
|
|
70
|
+
|
|
71
|
+
self.paths[path][method.lower()] = {
|
|
72
|
+
"summary": func.__name__.replace("_", " ").title(),
|
|
73
|
+
"responses": {
|
|
74
|
+
"200": {"description": "Successful Response"}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async def endpoint(request: Request):
|
|
79
|
+
sig = inspect.signature(func)
|
|
80
|
+
kwargs = {}
|
|
81
|
+
|
|
82
|
+
for name, param in sig.parameters.items():
|
|
83
|
+
if param.annotation is Request:
|
|
84
|
+
kwargs[name] = request
|
|
85
|
+
elif inspect.isclass(param.annotation) and issubclass(param.annotation, BaseModel):
|
|
86
|
+
# Parse JSON body using Pydantic
|
|
87
|
+
body = await request.json()
|
|
88
|
+
kwargs[name] = param.annotation(**body)
|
|
89
|
+
else:
|
|
90
|
+
# Path or Query param
|
|
91
|
+
if name in request.path_params:
|
|
92
|
+
val = request.path_params[name]
|
|
93
|
+
kwargs[name] = param.annotation(val) if param.annotation != inspect._empty else val
|
|
94
|
+
elif name in request.query_params:
|
|
95
|
+
val = request.query_params[name]
|
|
96
|
+
kwargs[name] = param.annotation(val) if param.annotation != inspect._empty else val
|
|
97
|
+
|
|
98
|
+
if inspect.iscoroutinefunction(func):
|
|
99
|
+
res = await func(**kwargs)
|
|
100
|
+
else:
|
|
101
|
+
res = func(**kwargs)
|
|
102
|
+
|
|
103
|
+
# Serialize response
|
|
104
|
+
if isinstance(res, Response):
|
|
105
|
+
return res
|
|
106
|
+
elif isinstance(res, BaseModel):
|
|
107
|
+
return JSONResponse(res.model_dump())
|
|
108
|
+
elif isinstance(res, list) and len(res) > 0 and isinstance(res[0], BaseModel):
|
|
109
|
+
return JSONResponse([r.model_dump() for r in res])
|
|
110
|
+
elif hasattr(res, "__dict__"): # simple object serialization fallback
|
|
111
|
+
return JSONResponse(res.__dict__)
|
|
112
|
+
|
|
113
|
+
return JSONResponse(res)
|
|
114
|
+
|
|
115
|
+
# Convert FastAPI/Starlette style path params {id} to Starlette path syntax
|
|
116
|
+
# Actually, Starlette uses {id} or {id:int}, so it's compatible.
|
|
117
|
+
self.routes.append(Route(path, endpoint, methods=[method]))
|
|
118
|
+
|
|
119
|
+
def get(self, path: str):
|
|
120
|
+
def decorator(func: Callable):
|
|
121
|
+
self._add_route(path, "GET", func)
|
|
122
|
+
return func
|
|
123
|
+
return decorator
|
|
124
|
+
|
|
125
|
+
def post(self, path: str):
|
|
126
|
+
def decorator(func: Callable):
|
|
127
|
+
self._add_route(path, "POST", func)
|
|
128
|
+
return func
|
|
129
|
+
return decorator
|
|
130
|
+
|
|
131
|
+
def put(self, path: str):
|
|
132
|
+
def decorator(func: Callable):
|
|
133
|
+
self._add_route(path, "PUT", func)
|
|
134
|
+
return func
|
|
135
|
+
return decorator
|
|
136
|
+
|
|
137
|
+
def delete(self, path: str):
|
|
138
|
+
def decorator(func: Callable):
|
|
139
|
+
self._add_route(path, "DELETE", func)
|
|
140
|
+
return func
|
|
141
|
+
return decorator
|
|
142
|
+
|
|
143
|
+
api = API()
|
voodoo/cli.py
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
import time
|
|
4
|
+
import asyncio
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
from rich.progress import Progress, SpinnerColumn, TextColumn
|
|
10
|
+
from rich.panel import Panel
|
|
11
|
+
from rich.markdown import Markdown
|
|
12
|
+
|
|
13
|
+
# We initialize the Typer app
|
|
14
|
+
app = typer.Typer(
|
|
15
|
+
help="🔮 Voodoo Framework CLI - Fast, Animated, AI-Powered",
|
|
16
|
+
no_args_is_help=True,
|
|
17
|
+
add_completion=False,
|
|
18
|
+
)
|
|
19
|
+
console = Console()
|
|
20
|
+
|
|
21
|
+
@app.command()
|
|
22
|
+
def new(
|
|
23
|
+
project_name: str,
|
|
24
|
+
template: str = typer.Option("helderperez-dev/voodoo-templates", "--template", "-t", help="GitHub repository URL or 'user/repo' to use as a template"),
|
|
25
|
+
variant: str = typer.Option("default", "--variant", "-v", help="Specific template variant inside the repository"),
|
|
26
|
+
):
|
|
27
|
+
"""
|
|
28
|
+
Scaffold a new Voodoo project or clone a community template.
|
|
29
|
+
"""
|
|
30
|
+
console.print(Panel.fit(f"Creating new Voodoo project: [bold cyan]{project_name}[/bold cyan]", border_style="cyan"))
|
|
31
|
+
|
|
32
|
+
project_dir = Path(project_name)
|
|
33
|
+
if project_dir.exists():
|
|
34
|
+
console.print(f"[bold red]Error:[/bold red] Directory '{project_name}' already exists.")
|
|
35
|
+
raise typer.Exit(1)
|
|
36
|
+
|
|
37
|
+
with Progress(
|
|
38
|
+
SpinnerColumn(),
|
|
39
|
+
TextColumn("[progress.description]{task.description}"),
|
|
40
|
+
transient=True,
|
|
41
|
+
) as progress:
|
|
42
|
+
if template:
|
|
43
|
+
task = progress.add_task(description=f"Cloning [cyan]{variant}[/cyan] template from [cyan]{template}[/cyan]...", total=None)
|
|
44
|
+
|
|
45
|
+
# Resolve URL
|
|
46
|
+
if template.startswith("http://") or template.startswith("https://") or template.startswith("git@") or template.startswith("/") or template.startswith("file://"):
|
|
47
|
+
repo_url = template
|
|
48
|
+
elif len(template.split("/")) == 2:
|
|
49
|
+
repo_url = f"https://github.com/{template}.git"
|
|
50
|
+
else:
|
|
51
|
+
console.print("\n[bold red]Error:[/bold red] Template must be a valid Git URL, local path, or 'user/repo'.")
|
|
52
|
+
raise typer.Exit(1)
|
|
53
|
+
|
|
54
|
+
import subprocess
|
|
55
|
+
import shutil
|
|
56
|
+
import tempfile
|
|
57
|
+
|
|
58
|
+
fallback_to_offline = False
|
|
59
|
+
|
|
60
|
+
try:
|
|
61
|
+
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
62
|
+
subprocess.run(
|
|
63
|
+
["git", "clone", "--depth", "1", repo_url, tmp_dir],
|
|
64
|
+
check=True,
|
|
65
|
+
stdout=subprocess.PIPE,
|
|
66
|
+
stderr=subprocess.PIPE
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
variant_path = Path(tmp_dir) / variant
|
|
70
|
+
|
|
71
|
+
if not variant_path.exists() or not variant_path.is_dir():
|
|
72
|
+
# If variant doesn't exist, check if the repo root itself is the template
|
|
73
|
+
if variant == "default" and not (Path(tmp_dir) / "default").exists():
|
|
74
|
+
variant_path = Path(tmp_dir)
|
|
75
|
+
else:
|
|
76
|
+
console.print(f"\n[bold red]Error:[/bold red] Variant '{variant}' not found in template repository.")
|
|
77
|
+
raise typer.Exit(1)
|
|
78
|
+
|
|
79
|
+
# Copy the template files over to the project directory
|
|
80
|
+
shutil.copytree(variant_path, project_dir, dirs_exist_ok=True)
|
|
81
|
+
|
|
82
|
+
except subprocess.CalledProcessError as e:
|
|
83
|
+
console.print(f"\n[bold yellow]Warning:[/bold yellow] Failed to clone template from {repo_url}")
|
|
84
|
+
console.print("[yellow]Falling back to offline default scaffolding...[/yellow]")
|
|
85
|
+
fallback_to_offline = True
|
|
86
|
+
|
|
87
|
+
if not fallback_to_offline:
|
|
88
|
+
# Remove the .git folder so the user starts with a clean slate
|
|
89
|
+
if (project_dir / ".git").exists():
|
|
90
|
+
shutil.rmtree(project_dir / ".git", ignore_errors=True)
|
|
91
|
+
|
|
92
|
+
if not (project_dir / ".data").exists():
|
|
93
|
+
os.makedirs(project_dir / ".data", exist_ok=True)
|
|
94
|
+
|
|
95
|
+
if not template or fallback_to_offline:
|
|
96
|
+
task = progress.add_task(description="Scaffolding offline project structure...", total=None)
|
|
97
|
+
|
|
98
|
+
# Simulate quick but visible animation
|
|
99
|
+
time.sleep(0.5)
|
|
100
|
+
|
|
101
|
+
os.makedirs(project_dir)
|
|
102
|
+
os.makedirs(project_dir / "app")
|
|
103
|
+
os.makedirs(project_dir / ".data")
|
|
104
|
+
|
|
105
|
+
progress.update(task, description="Writing base configuration...")
|
|
106
|
+
time.sleep(0.5)
|
|
107
|
+
|
|
108
|
+
(project_dir / ".env").write_text("VOODOO_DB_PATH=.data/voodoo.db\n")
|
|
109
|
+
(project_dir / "pyproject.toml").write_text(f"""[project]
|
|
110
|
+
name = "{project_name}"
|
|
111
|
+
version = "0.1.0"
|
|
112
|
+
dependencies = [
|
|
113
|
+
"voodoo-framework"
|
|
114
|
+
]
|
|
115
|
+
""")
|
|
116
|
+
|
|
117
|
+
progress.update(task, description="Generating entry point...")
|
|
118
|
+
time.sleep(0.5)
|
|
119
|
+
|
|
120
|
+
(project_dir / "app" / "page.py").write_text("""from voodoo.components import Div, Heading, Text
|
|
121
|
+
|
|
122
|
+
def page(request):
|
|
123
|
+
\"\"\"
|
|
124
|
+
A minimal single-page application.
|
|
125
|
+
Voodoo's router will automatically map app/page.py to the root "/" route.
|
|
126
|
+
\"\"\"
|
|
127
|
+
return Div(
|
|
128
|
+
Heading("Hello, Voodoo! 🪄", level=1, className="text-5xl font-bold text-center mt-32 tracking-tight"),
|
|
129
|
+
Div(Text("Welcome to your new Voodoo app."), className="text-center text-[var(--color-text-muted)] mt-6 text-lg"),
|
|
130
|
+
className="min-h-screen bg-[var(--color-background)] text-[var(--color-text)]"
|
|
131
|
+
)
|
|
132
|
+
""")
|
|
133
|
+
|
|
134
|
+
(project_dir / "main.py").write_text("""import uvicorn
|
|
135
|
+
from voodoo.core import create_app
|
|
136
|
+
from voodoo.config import config
|
|
137
|
+
|
|
138
|
+
# Voodoo automatically looks for the "app" folder in the current working directory
|
|
139
|
+
app = create_app()
|
|
140
|
+
|
|
141
|
+
if __name__ == "__main__":
|
|
142
|
+
uvicorn.run("main:app", host=config.host, port=config.port, reload=True, ws_max_size=16777216, ws_max_queue=32)
|
|
143
|
+
""")
|
|
144
|
+
|
|
145
|
+
console.print("[bold green]✓ Project scaffolded successfully![/bold green]")
|
|
146
|
+
console.print(f"\nNext steps:\n [cyan]cd {project_name}[/cyan]\n [cyan]voodoo dev[/cyan]\n")
|
|
147
|
+
|
|
148
|
+
@app.command()
|
|
149
|
+
def dev(
|
|
150
|
+
app_str: str = typer.Argument("main:app", help="App instance to run (e.g., main:app)"),
|
|
151
|
+
port: int = typer.Option(8000, help="Port to run the server on"),
|
|
152
|
+
):
|
|
153
|
+
"""
|
|
154
|
+
Start the Voodoo development server.
|
|
155
|
+
"""
|
|
156
|
+
module_name = app_str.split(":")[0]
|
|
157
|
+
module_path = Path(module_name.replace(".", "/") + ".py")
|
|
158
|
+
module_dir = Path(module_name.replace(".", "/"))
|
|
159
|
+
|
|
160
|
+
if not module_path.exists() and not (module_dir.is_dir() and (module_dir / "__init__.py").exists()):
|
|
161
|
+
console.print(f"\n[bold red]Error:[/bold red] Could not find module [yellow]{module_name}[/yellow].")
|
|
162
|
+
console.print("Are you sure you are inside a Voodoo project directory?")
|
|
163
|
+
console.print("To start a new project, run: [bold cyan]voodoo new <project_name>[/bold cyan]\n")
|
|
164
|
+
raise typer.Exit(1)
|
|
165
|
+
|
|
166
|
+
console.print(Panel.fit(f"Starting Voodoo Server on port [bold yellow]{port}[/bold yellow]", border_style="yellow"))
|
|
167
|
+
|
|
168
|
+
# We use a subprocess to run uvicorn
|
|
169
|
+
import subprocess
|
|
170
|
+
|
|
171
|
+
try:
|
|
172
|
+
# We let uvicorn take over the terminal output
|
|
173
|
+
subprocess.run(["uvicorn", app_str, "--reload", "--port", str(port)])
|
|
174
|
+
except KeyboardInterrupt:
|
|
175
|
+
console.print("\n[bold red]Server stopped.[/bold red]")
|
|
176
|
+
|
|
177
|
+
@app.command()
|
|
178
|
+
def generate(
|
|
179
|
+
component: str = typer.Argument(..., help="Component type (e.g., agent, resource, tool)"),
|
|
180
|
+
description: str = typer.Argument(..., help="What should the AI generate?"),
|
|
181
|
+
):
|
|
182
|
+
"""
|
|
183
|
+
AI-powered generation of Voodoo components using LLMs.
|
|
184
|
+
"""
|
|
185
|
+
from openai import AsyncOpenAI
|
|
186
|
+
|
|
187
|
+
# Check for API keys (support OpenRouter or OpenAI)
|
|
188
|
+
api_key = os.getenv("OPENROUTER_API_KEY") or os.getenv("OPENAI_API_KEY")
|
|
189
|
+
base_url = "https://openrouter.ai/api/v1" if os.getenv("OPENROUTER_API_KEY") else None
|
|
190
|
+
|
|
191
|
+
if not api_key:
|
|
192
|
+
console.print("[bold red]Error:[/bold red] Neither OPENROUTER_API_KEY nor OPENAI_API_KEY is set in the environment.")
|
|
193
|
+
raise typer.Exit(1)
|
|
194
|
+
|
|
195
|
+
client = AsyncOpenAI(api_key=api_key, base_url=base_url)
|
|
196
|
+
model = "openai/gpt-4o" if base_url else "gpt-4o"
|
|
197
|
+
|
|
198
|
+
async def _generate():
|
|
199
|
+
with Progress(
|
|
200
|
+
SpinnerColumn(),
|
|
201
|
+
TextColumn("[progress.description]{task.description}"),
|
|
202
|
+
transient=True,
|
|
203
|
+
) as progress:
|
|
204
|
+
progress.add_task(description=f"AI is thinking about your [bold magenta]{component}[/bold magenta]...", total=None)
|
|
205
|
+
|
|
206
|
+
prompt = f"""
|
|
207
|
+
You are an expert Voodoo Framework developer. Voodoo is a modern Python framework built on Starlette and Pydantic.
|
|
208
|
+
Generate a Voodoo `{component}` based on this description: "{description}".
|
|
209
|
+
|
|
210
|
+
Only output the raw Python code. Do not include markdown code blocks (no ```python).
|
|
211
|
+
Do not include explanations. Just the raw code.
|
|
212
|
+
"""
|
|
213
|
+
|
|
214
|
+
try:
|
|
215
|
+
response = await client.chat.completions.create(
|
|
216
|
+
model=model,
|
|
217
|
+
messages=[{"role": "user", "content": prompt}],
|
|
218
|
+
temperature=0.2
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
code = response.choices[0].message.content.strip()
|
|
222
|
+
# Clean up if the model accidentally included markdown blocks
|
|
223
|
+
if code.startswith("```python"):
|
|
224
|
+
code = code[9:]
|
|
225
|
+
if code.startswith("```"):
|
|
226
|
+
code = code[3:]
|
|
227
|
+
if code.endswith("```"):
|
|
228
|
+
code = code[:-3]
|
|
229
|
+
|
|
230
|
+
return code.strip()
|
|
231
|
+
|
|
232
|
+
except Exception as e:
|
|
233
|
+
console.print(f"[bold red]Failed to generate code:[/bold red] {e}")
|
|
234
|
+
raise typer.Exit(1)
|
|
235
|
+
|
|
236
|
+
# Run async function
|
|
237
|
+
code = asyncio.run(_generate())
|
|
238
|
+
|
|
239
|
+
# Save the file
|
|
240
|
+
filename = f"{component}_{int(time.time())}.py"
|
|
241
|
+
Path(filename).write_text(code + "\n")
|
|
242
|
+
|
|
243
|
+
console.print(f"[bold green]✓ Generated {component} successfully![/bold green]")
|
|
244
|
+
console.print(f"Saved to: [bold cyan]{filename}[/bold cyan]")
|
|
245
|
+
|
|
246
|
+
# Show preview
|
|
247
|
+
console.print(Panel(code, title=f"Preview: {filename}", border_style="green"))
|
|
248
|
+
|
|
249
|
+
if __name__ == "__main__":
|
|
250
|
+
app()
|
voodoo/components.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import uuid
|
|
2
|
+
from typing import Any, List
|
|
3
|
+
|
|
4
|
+
class Component:
|
|
5
|
+
tag = "div"
|
|
6
|
+
|
|
7
|
+
def __init__(self, *children, id=None, **kwargs):
|
|
8
|
+
self.id = id or f"vd-{uuid.uuid4().hex[:8]}"
|
|
9
|
+
self.children = children
|
|
10
|
+
self.attributes = kwargs
|
|
11
|
+
|
|
12
|
+
def render(self) -> str:
|
|
13
|
+
attrs = [f'id="{self.id}"']
|
|
14
|
+
for k, v in self.attributes.items():
|
|
15
|
+
k = k.replace("_", "-")
|
|
16
|
+
if k == "className":
|
|
17
|
+
k = "class"
|
|
18
|
+
if v is not None and v is not False:
|
|
19
|
+
if v is True:
|
|
20
|
+
attrs.append(f'{k}')
|
|
21
|
+
else:
|
|
22
|
+
attrs.append(f'{k}="{v}"')
|
|
23
|
+
|
|
24
|
+
attr_str = " " + " ".join(attrs) if attrs else ""
|
|
25
|
+
|
|
26
|
+
rendered_children = ""
|
|
27
|
+
for child in self.children:
|
|
28
|
+
if isinstance(child, Component):
|
|
29
|
+
rendered_children += child.render()
|
|
30
|
+
else:
|
|
31
|
+
rendered_children += str(child)
|
|
32
|
+
|
|
33
|
+
# Self closing tags
|
|
34
|
+
if self.tag in ["input", "img", "br", "hr"]:
|
|
35
|
+
return f"<{self.tag}{attr_str} />"
|
|
36
|
+
|
|
37
|
+
return f"<{self.tag}{attr_str}>{rendered_children}</{self.tag}>"
|
|
38
|
+
|
|
39
|
+
class Div(Component):
|
|
40
|
+
tag = "div"
|
|
41
|
+
|
|
42
|
+
class Button(Component):
|
|
43
|
+
tag = "button"
|
|
44
|
+
|
|
45
|
+
def __init__(self, *children, on_click=None, **kwargs):
|
|
46
|
+
if on_click:
|
|
47
|
+
kwargs["onclick"] = f"voodoo.sendEvent('{on_click}', this.id, this.value)"
|
|
48
|
+
super().__init__(*children, **kwargs)
|
|
49
|
+
|
|
50
|
+
class Input(Component):
|
|
51
|
+
tag = "input"
|
|
52
|
+
|
|
53
|
+
def __init__(self, *children, on_change=None, **kwargs):
|
|
54
|
+
if on_change:
|
|
55
|
+
kwargs["onchange"] = f"voodoo.sendEvent('{on_change}', this.id, this.value)"
|
|
56
|
+
super().__init__(*children, **kwargs)
|
|
57
|
+
|
|
58
|
+
class Card(Component):
|
|
59
|
+
tag = "div"
|
|
60
|
+
def __init__(self, *children, **kwargs):
|
|
61
|
+
classes = kwargs.get("className", "")
|
|
62
|
+
# Remove default conflicting classes if user provides background/border
|
|
63
|
+
default_bg = "bg-[var(--color-surface)]" if "bg-" not in classes else ""
|
|
64
|
+
default_border = "border border-[var(--color-border)]" if "border" not in classes else ""
|
|
65
|
+
kwargs["className"] = f"{default_bg} {default_border} rounded-xl p-6 shadow-xl {classes}".strip()
|
|
66
|
+
super().__init__(*children, **kwargs)
|
|
67
|
+
|
|
68
|
+
class Text(Component):
|
|
69
|
+
tag = "span"
|
|
70
|
+
|
|
71
|
+
class Heading(Component):
|
|
72
|
+
tag = "h1"
|
|
73
|
+
def __init__(self, *children, level=1, **kwargs):
|
|
74
|
+
self.tag = f"h{level}"
|
|
75
|
+
super().__init__(*children, **kwargs)
|
|
76
|
+
|
|
77
|
+
class ChatBox(Component):
|
|
78
|
+
tag = "div"
|
|
79
|
+
def __init__(self, *children, **kwargs):
|
|
80
|
+
classes = kwargs.get("className", "")
|
|
81
|
+
kwargs["className"] = f"flex flex-col space-y-2 overflow-y-auto {classes}".strip()
|
|
82
|
+
super().__init__(*children, **kwargs)
|
|
83
|
+
|
|
84
|
+
class Table(Component):
|
|
85
|
+
tag = "table"
|
|
86
|
+
def __init__(self, headers: List[str], rows: List[List[Any]], **kwargs):
|
|
87
|
+
super().__init__(**kwargs)
|
|
88
|
+
self.headers = headers
|
|
89
|
+
self.rows = rows
|
|
90
|
+
|
|
91
|
+
def render(self) -> str:
|
|
92
|
+
th_cells = "".join(f'<th class="px-6 py-4 text-left text-xs font-medium text-[var(--color-text-muted)] uppercase tracking-wider">{h}</th>' for h in self.headers)
|
|
93
|
+
thead = f"<thead class='bg-[var(--color-surface)] border-b border-[var(--color-border)]'><tr>{th_cells}</tr></thead>"
|
|
94
|
+
tbody_rows = []
|
|
95
|
+
for row in self.rows:
|
|
96
|
+
tds = "".join(f'<td class="px-6 py-4 whitespace-nowrap text-sm text-[var(--color-text)]">{cell}</td>' for cell in row)
|
|
97
|
+
tbody_rows.append(f"<tr class='border-b border-[var(--color-border)] hover:bg-[var(--color-surface)] transition-colors'>{tds}</tr>")
|
|
98
|
+
tbody = f"<tbody>{''.join(tbody_rows)}</tbody>"
|
|
99
|
+
|
|
100
|
+
attrs = [f'id="{self.id}"']
|
|
101
|
+
for k, v in self.attributes.items():
|
|
102
|
+
if k == "className":
|
|
103
|
+
k = "class"
|
|
104
|
+
attrs.append(f'{k}="{v}"')
|
|
105
|
+
|
|
106
|
+
attr_str = " " + " ".join(attrs) if attrs else ""
|
|
107
|
+
return f"<table{attr_str}>{thead}{tbody}</table>"
|
voodoo/config.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import yaml
|
|
3
|
+
from typing import Any, Dict, Optional
|
|
4
|
+
from pydantic import BaseModel, Field
|
|
5
|
+
from dotenv import load_dotenv
|
|
6
|
+
|
|
7
|
+
# Load .env variables first
|
|
8
|
+
load_dotenv()
|
|
9
|
+
|
|
10
|
+
class VoodooConfig(BaseModel):
|
|
11
|
+
"""Core configuration for the Voodoo framework."""
|
|
12
|
+
env: str = Field(default_factory=lambda: os.getenv("VOODOO_ENV", "development"))
|
|
13
|
+
db_path: str = Field(default_factory=lambda: os.getenv("VOODOO_DB_PATH", ".data/voodoo.db"))
|
|
14
|
+
storage_dir: str = Field(default_factory=lambda: os.getenv("VOODOO_STORAGE_DIR", "storage"))
|
|
15
|
+
port: int = Field(default_factory=lambda: int(os.getenv("VOODOO_PORT", "8000")))
|
|
16
|
+
host: str = Field(default_factory=lambda: os.getenv("VOODOO_HOST", "0.0.0.0"))
|
|
17
|
+
extra: Dict[str, Any] = Field(default_factory=dict)
|
|
18
|
+
|
|
19
|
+
def load_yaml_config(file_path: str = "voodoo.yaml") -> Dict[str, Any]:
|
|
20
|
+
"""Loads configuration from a YAML file if it exists."""
|
|
21
|
+
if os.path.exists(file_path):
|
|
22
|
+
with open(file_path, "r") as f:
|
|
23
|
+
try:
|
|
24
|
+
return yaml.safe_load(f) or {}
|
|
25
|
+
except yaml.YAMLError as e:
|
|
26
|
+
print(f"Error parsing {file_path}: {e}")
|
|
27
|
+
return {}
|
|
28
|
+
|
|
29
|
+
def get_config() -> VoodooConfig:
|
|
30
|
+
"""Gets the merged configuration from env vars and YAML."""
|
|
31
|
+
yaml_data = load_yaml_config()
|
|
32
|
+
|
|
33
|
+
# We allow yaml to override or extend defaults
|
|
34
|
+
# but environment variables usually take precedence in production.
|
|
35
|
+
# For this simple implementation, we'll merge them.
|
|
36
|
+
config_args = {}
|
|
37
|
+
|
|
38
|
+
# Add mapped YAML fields if they match our known fields
|
|
39
|
+
if "env" in yaml_data: config_args["env"] = yaml_data["env"]
|
|
40
|
+
if "db_path" in yaml_data: config_args["db_path"] = yaml_data["db_path"]
|
|
41
|
+
if "storage_dir" in yaml_data: config_args["storage_dir"] = yaml_data["storage_dir"]
|
|
42
|
+
if "port" in yaml_data: config_args["port"] = yaml_data["port"]
|
|
43
|
+
if "host" in yaml_data: config_args["host"] = yaml_data["host"]
|
|
44
|
+
|
|
45
|
+
# Store any extra custom configuration
|
|
46
|
+
config_args["extra"] = {k: v for k, v in yaml_data.items() if k not in config_args}
|
|
47
|
+
|
|
48
|
+
return VoodooConfig(**config_args)
|
|
49
|
+
|
|
50
|
+
# Global config instance
|
|
51
|
+
config = get_config()
|