R-Kentaren commited on
Commit
4412065
·
verified ·
1 Parent(s): 2618b34

Upload folder using huggingface_hub

Browse files
app.py CHANGED
@@ -1,1393 +1,38 @@
1
- """Fullstack Code Builder - Local AI-powered fullstack app generator.
2
 
3
  Uses MiniCPM5-1B for local inference (no external APIs).
4
  Supports generating fullstack applications in any language.
5
  Can push generated projects to HuggingFace Hub.
6
  Web search via Google scraping (no API keys needed).
7
  Gradio app support for Python.
 
 
 
 
 
 
 
 
 
 
 
 
 
8
  """
9
 
10
  from __future__ import annotations
11
 
12
- import html
13
- import json
14
  import logging
15
- import os
16
- import re
17
- import shutil
18
- import subprocess
19
- import sys
20
- import tempfile
21
- import textwrap
22
- import threading
23
- import time
24
- import urllib.parse
25
- import zipfile
26
- from collections.abc import Iterator
27
- from dataclasses import dataclass, field
28
- from pathlib import Path
29
- from typing import Any
30
-
31
- from gradio import Server
32
- from fastapi.responses import HTMLResponse, FileResponse
33
-
34
- APP_TITLE = "Fullstack Code Builder"
35
- MODEL_ID = "openbmb/MiniCPM5-1B"
36
- MODEL_URL = "https://huggingface.co/openbmb/MiniCPM5-1B"
37
-
38
- DEFAULT_TEMPERATURE = 0.6
39
- DEFAULT_MAX_TOKENS = 4096
40
- PY_TIMEOUT_S = 15
41
- GRADIO_TIMEOUT_S = 30
42
- PY_MEM_LIMIT_MB = 1024
43
- MAX_STDIO_CHARS = 16_000
44
- OUTPUT_PNG = "output.png"
45
 
46
- THINKING_BLOCK_RE = re.compile(r"<\s*think\s*>.*?<\s*/\s*think\s*>", re.IGNORECASE | re.DOTALL)
47
- CODE_BLOCK_RE = re.compile(r"```([a-zA-Z0-9_+.#-]*)\s*\n(.*?)```", re.DOTALL)
48
- FILE_BLOCK_RE = re.compile(r"@@FILE:\s*(.+?)@@\s*\n(.*?)(?=@@FILE:|@@END@@)", re.DOTALL)
49
 
50
- logger = logging.getLogger(__name__)
51
  logging.basicConfig(level=logging.INFO)
 
52
 
53
- # ─── Supported Languages & Frameworks ───────────────────────────────────
54
-
55
- LANGUAGE_OPTIONS = [
56
- ("Python", ["Gradio", "Flask", "Django", "FastAPI", "Streamlit", "Plain Python"]),
57
- ("JavaScript", ["React", "Vue.js", "Next.js", "Express.js", "Node.js", "Vanilla JS"]),
58
- ("TypeScript", ["React", "Next.js", "Express.js", "NestJS"]),
59
- ("HTML/CSS/JS", ["Tailwind CSS", "Bootstrap", "Vanilla"]),
60
- ("Java", ["Spring Boot", "Maven", "Gradle"]),
61
- ("Go", ["Gin", "Fiber", "Echo", "Plain Go"]),
62
- ("Rust", ["Actix", "Axum", "Rocket"]),
63
- ("PHP", ["Laravel", "Symfony", "Plain PHP"]),
64
- ("Ruby", ["Rails", "Sinatra"]),
65
- ("C#", ["ASP.NET", "Blazor"]),
66
- ("Swift", ["Vapor", "SwiftUI"]),
67
- ("Kotlin", ["Ktor", "Spring Boot"]),
68
- ]
69
-
70
- LANGUAGE_MAP = {lang: frameworks for lang, frameworks in LANGUAGE_OPTIONS}
71
-
72
- SYSTEM_PROMPT = """You are a fullstack application code generator running locally. You help users build complete, runnable applications in any programming language and framework.
73
-
74
- When the user asks you to build an application:
75
- 1. Generate complete, working code - not snippets or pseudocode
76
- 2. Include all necessary files for the project to run
77
- 3. Add proper error handling and comments
78
- 4. For web apps, make the UI responsive and modern
79
- 5. For Gradio apps, use gradio library and create a complete working app with gr.Interface or gr.Blocks
80
-
81
- FILE OUTPUT FORMAT - IMPORTANT:
82
- When generating multi-file projects, wrap each file in this format:
83
- @@FILE: path/to/file.ext@@
84
- (file content here)
85
- @@FILE: path/to/another/file.ext@@
86
- (another file content here)
87
- @@END@@
88
-
89
- For single-file code, use standard markdown fenced blocks:
90
- ```python for Python
91
- ```html for HTML/CSS/JS
92
- ```javascript for JavaScript
93
- ```typescript for TypeScript
94
- etc.
95
-
96
- When generating web apps with HTML/CSS/JS, return a single self-contained HTML document with all CSS and JavaScript inline. Make the page fully responsive: html/body at margin:0 and 100% width/height, use flexbox/grid layouts, and size any canvas to its container.
97
-
98
- When generating Gradio apps, create a complete app.py with:
99
- - import gradio as gr
100
- - Define the interface using gr.Interface() or gr.Blocks()
101
- - Call iface.launch(server_name="0.0.0.0", server_port=7860) at the end
102
- - Include all necessary processing logic inline
103
-
104
- For Python, prefer standard library or common packages. Do not use network calls, subprocesses, shell commands, or long-running loops in demo code (except Gradio apps which are server-based).
105
-
106
- If web search results are provided in the context, use them to inform your code generation. Incorporate relevant information from the search results into the generated code.
107
- """
108
-
109
- # Curated starter prompts
110
- EXAMPLE_PROMPTS: list[tuple[str, str, str, str]] = [
111
- (
112
- "🎨 Gradio Image Filter",
113
- "Create a Gradio app that lets users upload an image and apply filters like grayscale, blur, sepia, and edge detection using PIL. Show the original and filtered images side by side.",
114
- "Python",
115
- "Gradio",
116
- ),
117
- (
118
- "🤖 Gradio Chat App",
119
- "Build a Gradio chatbot app with gr.Blocks that has a chat interface, a text input, and a send button. Include a simple echo bot that repeats the user's message with a fun twist.",
120
- "Python",
121
- "Gradio",
122
- ),
123
- (
124
- "🌐 React Todo App",
125
- "Build a React todo application with add, delete, mark complete, and filter functionality. Use modern hooks and a clean responsive UI.",
126
- "JavaScript",
127
- "React",
128
- ),
129
- (
130
- "🐍 Flask API",
131
- "Create a Flask REST API for a book library with CRUD operations, in-memory storage, and proper error handling.",
132
- "Python",
133
- "Flask",
134
- ),
135
- (
136
- "🎨 Landing Page",
137
- "Build a modern landing page for a SaaS product with a hero section, features grid, pricing cards, and a footer. Use Tailwind-style CSS.",
138
- "HTML/CSS/JS",
139
- "Vanilla",
140
- ),
141
- (
142
- "📊 Dashboard",
143
- "Create an interactive data dashboard with charts (bar, line, pie), a sidebar navigation, and summary cards. All in a single HTML file.",
144
- "HTML/CSS/JS",
145
- "Vanilla",
146
- ),
147
- ]
148
-
149
-
150
- # ─── Web Search (Google Scraping — No API) ──────────────────────────────
151
-
152
- def web_search_google(query: str, num_results: int = 8) -> list[dict[str, str]]:
153
- """Search Google by scraping the results page. No API key needed.
154
-
155
- Returns a list of dicts with keys: title, url, snippet.
156
- Uses requests with a browser-like User-Agent to avoid captchas.
157
- """
158
- try:
159
- import requests
160
- from bs4 import BeautifulSoup
161
-
162
- encoded_query = urllib.parse.quote_plus(query)
163
- url = f"https://www.google.com/search?q={encoded_query}&num={num_results + 2}&hl=en"
164
-
165
- headers = {
166
- "User-Agent": (
167
- "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
168
- "AppleWebKit/537.36 (KHTML, like Gecko) "
169
- "Chrome/120.0.0.0 Safari/537.36"
170
- ),
171
- "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
172
- "Accept-Language": "en-US,en;q=0.5",
173
- "Accept-Encoding": "gzip, deflate",
174
- "DNT": "1",
175
- "Connection": "keep-alive",
176
- "Upgrade-Insecure-Requests": "1",
177
- }
178
-
179
- resp = requests.get(url, headers=headers, timeout=10, allow_redirects=True)
180
- resp.raise_for_status()
181
-
182
- soup = BeautifulSoup(resp.text, "html.parser")
183
- results: list[dict[str, str]] = []
184
-
185
- # Parse Google search results
186
- # Google uses various class names; we try multiple selectors
187
- for g_div in soup.select("div.g, div[data-sokoban-container], div.yuRUbf"):
188
- title_el = g_div.select_one("h3")
189
- link_el = g_div.select_one("a[href]")
190
- snippet_el = g_div.select_one("div.VwiC3b, span.aCOpRe, div[data-sncf]")
191
-
192
- if not title_el or not link_el:
193
- continue
194
-
195
- href = link_el.get("href", "")
196
- # Google sometimes prefixes URLs; extract the real URL
197
- if href.startswith("/url?q="):
198
- real_url = urllib.parse.parse_qs(urllib.parse.urlparse(href).query).get("q", [href])[0]
199
- elif href.startswith("http"):
200
- real_url = href
201
- else:
202
- continue
203
-
204
- # Skip Google-internal URLs
205
- if "google.com" in real_url or "googleusercontent.com" in real_url:
206
- continue
207
-
208
- title = title_el.get_text(strip=True)
209
- snippet = snippet_el.get_text(strip=True) if snippet_el else ""
210
-
211
- if title and real_url:
212
- results.append({
213
- "title": title,
214
- "url": real_url,
215
- "snippet": snippet,
216
- })
217
-
218
- if len(results) >= num_results:
219
- break
220
-
221
- # Fallback: try parsing from <a> tags with data-ved attribute
222
- if not results:
223
- for a_tag in soup.select("a[data-ved]"):
224
- href = a_tag.get("href", "")
225
- if not href.startswith("http"):
226
- continue
227
- if "google.com" in href:
228
- continue
229
-
230
- title_el = a_tag.select_one("h3, span")
231
- title = title_el.get_text(strip=True) if title_el else a_tag.get_text(strip=True)[:100]
232
- snippet = ""
233
-
234
- if title and href:
235
- results.append({
236
- "title": title,
237
- "url": href,
238
- "snippet": snippet,
239
- })
240
-
241
- if len(results) >= num_results:
242
- break
243
-
244
- logger.info("Web search for '%s' returned %d results", query, len(results))
245
- return results
246
-
247
- except ImportError:
248
- logger.warning("requests or beautifulsoup4 not installed for web search")
249
- return []
250
- except Exception as exc:
251
- logger.exception("Web search failed: %s", exc)
252
- return []
253
-
254
-
255
- def format_search_results(results: list[dict[str, str]]) -> str:
256
- """Format search results into a text block for model context."""
257
- if not results:
258
- return "No search results found."
259
-
260
- parts = ["Here are the web search results for reference:\n"]
261
- for i, r in enumerate(results, 1):
262
- parts.append(f"{i}. {r['title']}")
263
- parts.append(f" URL: {r['url']}")
264
- if r["snippet"]:
265
- parts.append(f" {r['snippet']}")
266
- parts.append("")
267
-
268
- return "\n".join(parts)
269
-
270
-
271
- # ─── Model Loading ──────────────────────────────────────────────────────
272
-
273
- _model = None
274
- _tokenizer = None
275
- _model_loaded = False
276
- _model_loading = False
277
- _load_error: str | None = None
278
-
279
-
280
- def load_model() -> None:
281
- """Load MiniCPM5-1B model and tokenizer locally."""
282
- global _model, _tokenizer, _model_loaded, _model_loading, _load_error
283
-
284
- if _model_loaded or _model_loading:
285
- return
286
-
287
- _model_loading = True
288
- _load_error = None
289
-
290
- try:
291
- from transformers import AutoModelForCausalLM, AutoTokenizer
292
- import torch
293
-
294
- logger.info("Loading MiniCPM5-1B model...")
295
-
296
- dtype = torch.float16 if torch.cuda.is_available() else torch.float32
297
- device_map = "auto" if torch.cuda.is_available() else None
298
-
299
- _tokenizer = AutoTokenizer.from_pretrained(
300
- MODEL_ID,
301
- trust_remote_code=True,
302
- )
303
- _model = AutoModelForCausalLM.from_pretrained(
304
- MODEL_ID,
305
- torch_dtype=dtype,
306
- device_map=device_map,
307
- trust_remote_code=True,
308
- low_cpu_mem_usage=True,
309
- )
310
-
311
- if device_map is None:
312
- _model = _model.to("cpu")
313
-
314
- _model.eval()
315
- _model_loaded = True
316
- logger.info("MiniCPM5-1B model loaded successfully.")
317
-
318
- except Exception as exc:
319
- _load_error = str(exc)
320
- logger.exception("Failed to load model: %s", exc)
321
- finally:
322
- _model_loading = False
323
-
324
-
325
- # Start loading model in background thread
326
- _load_thread = threading.Thread(target=load_model, daemon=True)
327
- _load_thread.start()
328
-
329
-
330
- def get_model_status() -> dict[str, Any]:
331
- """Return current model loading status."""
332
- if _model_loaded:
333
- return {"status": "ready", "message": "Model loaded and ready"}
334
- if _model_loading:
335
- return {"status": "loading", "message": "Model is loading... (this may take a few minutes on first run)"}
336
- if _load_error:
337
- return {"status": "error", "message": f"Model load error: {_load_error}"}
338
- return {"status": "unknown", "message": "Model not initialized"}
339
-
340
-
341
- # ─── Model Inference ────────────────────────────────────────────────────
342
-
343
- def call_model(messages: list[dict[str, Any]], max_new_tokens: int = DEFAULT_MAX_TOKENS) -> Iterator[str]:
344
- """Stream model text using local MiniCPM5-1B."""
345
-
346
- if not _model_loaded:
347
- status = get_model_status()
348
- yield status["message"]
349
- return
350
-
351
- try:
352
- from transformers import TextIteratorStreamer
353
- import torch
354
-
355
- # Build the prompt from messages
356
- prompt_parts = []
357
- for msg in messages:
358
- role = msg.get("role", "user")
359
- content = msg.get("content", "")
360
- if role == "system":
361
- prompt_parts.append(f"System: {content}")
362
- elif role == "user":
363
- prompt_parts.append(f"User: {content}")
364
- elif role == "assistant":
365
- prompt_parts.append(f"Assistant: {content}")
366
- prompt_parts.append("Assistant:")
367
- full_prompt = "\n\n".join(prompt_parts)
368
-
369
- # Tokenize
370
- inputs = _tokenizer(full_prompt, return_tensors="pt", truncation=True, max_length=4096)
371
- if torch.cuda.is_available():
372
- inputs = {k: v.to("cuda") for k, v in inputs.items()}
373
-
374
- # Stream generation
375
- streamer = TextIteratorStreamer(_tokenizer, skip_prompt=True, skip_special_tokens=True)
376
-
377
- generation_kwargs = {
378
- **inputs,
379
- "streamer": streamer,
380
- "max_new_tokens": max_new_tokens,
381
- "temperature": DEFAULT_TEMPERATURE,
382
- "do_sample": True,
383
- "top_p": 0.9,
384
- "repetition_penalty": 1.1,
385
- "pad_token_id": _tokenizer.eos_token_id,
386
- }
387
-
388
- # Run generation in a separate thread
389
- thread = threading.Thread(target=_model.generate, kwargs=generation_kwargs)
390
- thread.start()
391
-
392
- output = ""
393
- for new_text in streamer:
394
- output += new_text
395
- yield output
396
-
397
- thread.join()
398
-
399
- except Exception as exc:
400
- logger.exception("Error during model inference")
401
- yield f"_Error during generation: {exc}_"
402
-
403
-
404
- def call_model_sync(messages: list[dict[str, Any]], max_new_tokens: int = DEFAULT_MAX_TOKENS) -> str:
405
- """Non-streaming model call - returns complete response."""
406
- result = ""
407
- for chunk in call_model(messages, max_new_tokens):
408
- result = chunk
409
- return result
410
-
411
-
412
- # ─── Code Extraction ────────────────────────────────────────────────────
413
-
414
- def _strip_thinking_blocks(text: str) -> str:
415
- return THINKING_BLOCK_RE.sub("", text).strip()
416
-
417
-
418
- def extract_code(response: str) -> tuple[str, str | None]:
419
- """Return the first fenced code block and its language tag."""
420
- visible_response = _strip_thinking_blocks(response)
421
- match = CODE_BLOCK_RE.search(visible_response)
422
- if not match:
423
- return "", None
424
- return match.group(2).strip(), (match.group(1).strip().lower() or None)
425
-
426
-
427
- def extract_multi_file(response: str) -> dict[str, str]:
428
- """Extract multi-file project from @@FILE: format.
429
-
430
- Returns dict of {filepath: content}.
431
- """
432
- files: dict[str, str] = {}
433
- visible = _strip_thinking_blocks(response)
434
-
435
- for match in FILE_BLOCK_RE.finditer(visible):
436
- filepath = match.group(1).strip()
437
- content = match.group(2).strip()
438
- files[filepath] = content
439
-
440
- # Fallback: if no @@FILE: blocks found, extract single code block
441
- if not files:
442
- code, lang = extract_code(response)
443
- if code:
444
- ext_map = {
445
- "python": "main.py", "py": "main.py",
446
- "javascript": "index.js", "js": "index.js",
447
- "typescript": "index.ts", "ts": "index.ts",
448
- "html": "index.html",
449
- "css": "styles.css",
450
- "java": "Main.java",
451
- "go": "main.go",
452
- "rust": "main.rs",
453
- "php": "index.php",
454
- "ruby": "main.rb",
455
- "csharp": "Program.cs",
456
- "swift": "main.swift",
457
- "kotlin": "Main.kt",
458
- }
459
- filename = ext_map.get(lang or "", "code.txt")
460
- files[filename] = code
461
-
462
- return files
463
-
464
-
465
- def _normalize_language(target_language: str | None, fence_lang: str | None) -> str:
466
- """Normalize language name to a canonical form."""
467
- lang = (fence_lang or target_language or "python").lower()
468
- if lang in {"python", "py"}:
469
- return "python"
470
- if lang in {"html", "web", "css"}:
471
- return "web"
472
- if lang in {"javascript", "js"}:
473
- return "javascript"
474
- if lang in {"typescript", "ts"}:
475
- return "typescript"
476
- if lang == "java":
477
- return "java"
478
- if lang == "go":
479
- return "go"
480
- if lang == "rust":
481
- return "rust"
482
- if lang == "php":
483
- return "php"
484
- if lang == "ruby":
485
- return "ruby"
486
- if lang in {"csharp", "c#"}:
487
- return "csharp"
488
- if lang == "swift":
489
- return "swift"
490
- if lang == "kotlin":
491
- return "kotlin"
492
- return lang
493
-
494
-
495
- def _is_gradio_code(code: str) -> bool:
496
- """Detect if Python code is a Gradio app."""
497
- return bool(re.search(r"import\s+gradio|from\s+gradio\s+import|gr\.\s*(Interface|Blocks|TabbedInterface|ChatInterface|App)", code))
498
-
499
-
500
- # ─── Python Execution ───────────────────────────────────────────────────
501
-
502
- @dataclass
503
- class PythonExecutionResult:
504
- stdout: str
505
- stderr: str
506
- image_path: str | None
507
- returncode: int | None
508
- timed_out: bool = False
509
-
510
-
511
- def _apply_subprocess_limits() -> None:
512
- import resource
513
- mem_bytes = PY_MEM_LIMIT_MB * 1024 * 1024
514
- resource.setrlimit(resource.RLIMIT_AS, (mem_bytes, mem_bytes))
515
- resource.setrlimit(resource.RLIMIT_CPU, (PY_TIMEOUT_S, PY_TIMEOUT_S))
516
-
517
-
518
- def _python_runner_source() -> str:
519
- return textwrap.dedent(
520
- f"""
521
- import os
522
- import runpy
523
- import sys
524
- import traceback
525
-
526
- os.environ.setdefault("MPLBACKEND", "Agg")
527
- exit_code = 0
528
- try:
529
- runpy.run_path(os.path.join(os.getcwd(), "user_code.py"), run_name="__main__")
530
- except SystemExit as exc:
531
- code = exc.code
532
- exit_code = code if isinstance(code, int) else 1
533
- except Exception:
534
- traceback.print_exc()
535
- exit_code = 1
536
- finally:
537
- try:
538
- import matplotlib
539
- matplotlib.use("Agg", force=True)
540
- import matplotlib.pyplot as plt
541
- if plt.get_fignums():
542
- plt.savefig(os.environ["OUTPUT_PNG"], bbox_inches="tight")
543
- except ModuleNotFoundError as exc:
544
- if exc.name != "matplotlib":
545
- traceback.print_exc()
546
- except Exception:
547
- traceback.print_exc()
548
-
549
- raise SystemExit(exit_code)
550
- """
551
- ).strip()
552
-
553
-
554
- def _truncate_output(text: str) -> str:
555
- if len(text) <= MAX_STDIO_CHARS:
556
- return text
557
- remaining = len(text) - MAX_STDIO_CHARS
558
- return text[:MAX_STDIO_CHARS] + f"\n\n... truncated {remaining} characters ..."
559
-
560
-
561
- def _decode_timeout_output(value: str | bytes | None) -> str:
562
- if value is None:
563
- return ""
564
- if isinstance(value, bytes):
565
- return value.decode("utf-8", errors="replace")
566
- return value
567
-
568
-
569
- def run_python(code: str) -> PythonExecutionResult:
570
- with tempfile.TemporaryDirectory(prefix="fullstack_run_") as tmp:
571
- workdir = Path(tmp)
572
- runner_path = workdir / "runner.py"
573
- user_path = workdir / "user_code.py"
574
- image_path = workdir / OUTPUT_PNG
575
-
576
- runner_path.write_text(_python_runner_source(), encoding="utf-8")
577
- user_path.write_text(code, encoding="utf-8")
578
-
579
- env = {
580
- "PATH": "/usr/bin:/bin",
581
- "HOME": str(workdir),
582
- "TMPDIR": str(workdir),
583
- "MPLBACKEND": "Agg",
584
- "MPLCONFIGDIR": str(workdir / ".matplotlib"),
585
- "OUTPUT_PNG": str(image_path),
586
- "PYTHONIOENCODING": "utf-8",
587
- "PYTHONNOUSERSITE": "1",
588
- "PYTHONUNBUFFERED": "1",
589
- "LANG": "C.UTF-8",
590
- "OPENBLAS_NUM_THREADS": "1",
591
- "OMP_NUM_THREADS": "1",
592
- "MKL_NUM_THREADS": "1",
593
- "NUMEXPR_NUM_THREADS": "1",
594
- }
595
-
596
- try:
597
- completed = subprocess.run(
598
- [sys.executable, "-I", str(runner_path)],
599
- cwd=workdir,
600
- env=env,
601
- capture_output=True,
602
- text=True,
603
- encoding="utf-8",
604
- errors="replace",
605
- timeout=PY_TIMEOUT_S,
606
- preexec_fn=_apply_subprocess_limits if sys.platform == "linux" else None,
607
- check=False,
608
- )
609
- stdout = _truncate_output(completed.stdout)
610
- stderr = _truncate_output(completed.stderr)
611
-
612
- if completed.returncode and not stderr:
613
- stderr = f"Process exited with status {completed.returncode}."
614
-
615
- saved_image: str | None = None
616
- if image_path.exists() and image_path.stat().st_size > 0:
617
- saved = tempfile.NamedTemporaryFile(
618
- prefix="fullstack_plot_", suffix=".png", delete=False
619
- )
620
- saved.close()
621
- Path(saved.name).write_bytes(image_path.read_bytes())
622
- saved_image = saved.name
623
-
624
- return PythonExecutionResult(
625
- stdout=stdout,
626
- stderr=stderr,
627
- image_path=saved_image,
628
- returncode=completed.returncode,
629
- )
630
- except subprocess.TimeoutExpired as exc:
631
- stdout = _truncate_output(_decode_timeout_output(exc.stdout))
632
- stderr = _truncate_output(_decode_timeout_output(exc.stderr))
633
- timeout_note = f"Timed out after {PY_TIMEOUT_S} seconds; the process was killed."
634
- stderr = f"{stderr}\n{timeout_note}".strip()
635
- return PythonExecutionResult(
636
- stdout=stdout,
637
- stderr=stderr,
638
- image_path=None,
639
- returncode=None,
640
- timed_out=True,
641
- )
642
-
643
-
644
- # ─── Gradio App Runner ─────────────────────────────────────────────────
645
-
646
- # Registry for running Gradio subprocesses
647
- _running_gradio_procs: dict[str, subprocess.Popen] = {}
648
-
649
-
650
- def run_gradio_app(code: str, port: int = 7861) -> dict[str, Any]:
651
- """Launch a Gradio app as a subprocess and return its URL.
652
-
653
- The Gradio app is run on the specified port. We modify the code
654
- to ensure it launches on the correct port and is accessible.
655
- """
656
- # Kill any previously running Gradio app
657
- for pid, proc in list(_running_gradio_procs.items()):
658
- try:
659
- proc.terminate()
660
- proc.wait(timeout=3)
661
- except Exception:
662
- try:
663
- proc.kill()
664
- except Exception:
665
- pass
666
- _running_gradio_procs.clear()
667
-
668
- # Patch the code: ensure launch uses correct server_name and server_port
669
- patched_code = code
670
-
671
- # Replace .launch() with correct params
672
- patched_code = re.sub(
673
- r"(\w+)\.launch\([^)]*\)",
674
- f'\\1.launch(server_name="0.0.0.0", server_port={port}, share=False)',
675
- patched_code,
676
- )
677
-
678
- # If no .launch() found, add one
679
- if ".launch(" not in patched_code:
680
- # Add launch at the end if missing
681
- patched_code += f'\n\nif __name__ == "__main__":\n iface.launch(server_name="0.0.0.0", server_port={port}, share=False)\n'
682
-
683
- with tempfile.TemporaryDirectory(prefix="gradio_app_") as tmp:
684
- app_path = Path(tmp) / "gradio_app.py"
685
- app_path.write_text(patched_code, encoding="utf-8")
686
-
687
- env = {
688
- **os.environ,
689
- "PYTHONUNBUFFERED": "1",
690
- "GRADIO_SERVER_NAME": "0.0.0.0",
691
- "GRADIO_SERVER_PORT": str(port),
692
- }
693
-
694
- try:
695
- proc = subprocess.Popen(
696
- [sys.executable, str(app_path)],
697
- cwd=tmp,
698
- env=env,
699
- stdout=subprocess.PIPE,
700
- stderr=subprocess.PIPE,
701
- text=True,
702
- )
703
-
704
- proc_id = f"gradio_{port}"
705
- _running_gradio_procs[proc_id] = proc
706
-
707
- # Wait a bit for the server to start
708
- import time as _time
709
- _time.sleep(3)
710
-
711
- # Check if process is still running
712
- poll = proc.poll()
713
- if poll is not None:
714
- stdout = proc.stdout.read() if proc.stdout else ""
715
- stderr = proc.stderr.read() if proc.stderr else ""
716
- return {
717
- "success": False,
718
- "url": "",
719
- "message": f"Gradio app exited with code {poll}",
720
- "stdout": stdout[-2000:] if stdout else "",
721
- "stderr": stderr[-2000:] if stderr else "",
722
- }
723
-
724
- gradio_url = f"http://localhost:{port}"
725
- return {
726
- "success": True,
727
- "url": gradio_url,
728
- "message": f"Gradio app running at {gradio_url}",
729
- "port": port,
730
- }
731
-
732
- except Exception as exc:
733
- logger.exception("Failed to launch Gradio app")
734
- return {
735
- "success": False,
736
- "url": "",
737
- "message": f"Failed to launch: {exc}",
738
- }
739
-
740
-
741
- def stop_gradio_app() -> dict[str, Any]:
742
- """Stop any running Gradio app subprocess."""
743
- stopped = 0
744
- for pid, proc in list(_running_gradio_procs.items()):
745
- try:
746
- proc.terminate()
747
- proc.wait(timeout=3)
748
- stopped += 1
749
- except Exception:
750
- try:
751
- proc.kill()
752
- stopped += 1
753
- except Exception:
754
- pass
755
- _running_gradio_procs.clear()
756
-
757
- return {"success": True, "message": f"Stopped {stopped} Gradio app(s)"}
758
-
759
-
760
- # ─── Web Document ───────────────────────────────────────────────────────
761
-
762
- def _web_document(code: str, fence_lang: str | None) -> str:
763
- lang = (fence_lang or "").lower()
764
- if lang in {"javascript", "js"}:
765
- return f"<!doctype html><html><body><script>\n{code}\n</script></body></html>"
766
- if lang == "css":
767
- return f"<!doctype html><html><head><style>\n{code}\n</style></head><body></body></html>"
768
- if re.search(r"<!doctype|<html[\s>]", code, flags=re.IGNORECASE):
769
- return code
770
- return f"<!doctype html><html><head><meta charset='utf-8'></head><body>\n{code}\n</body></html>"
771
-
772
-
773
- def build_iframe(code: str, fence_lang: str | None = None) -> str:
774
- document = _web_document(code, fence_lang)
775
- srcdoc = html.escape(document, quote=True)
776
- return (
777
- '<iframe class="web-frame" '
778
- 'sandbox="allow-scripts" '
779
- 'allow="fullscreen" '
780
- "allowfullscreen "
781
- f'srcdoc="{srcdoc}" '
782
- 'style="width:100%; min-height:680px; border:0; border-radius:14px; '
783
- 'background:white;"></iframe>'
784
- )
785
-
786
-
787
- # ─── Project Packaging ──────────────────────────────────────────────────
788
-
789
- def create_project_zip(files: dict[str, str], project_name: str) -> str:
790
- """Create a ZIP file from extracted project files."""
791
- zip_dir = tempfile.mkdtemp(prefix="fullstack_project_")
792
- zip_path = os.path.join(zip_dir, f"{project_name}.zip")
793
-
794
- with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
795
- for filepath, content in files.items():
796
- zf.writestr(f"{project_name}/{filepath}", content)
797
-
798
- return zip_path
799
-
800
-
801
- # ─── HuggingFace Hub Push ───────────────────────────────────────────────
802
-
803
- def push_to_huggingface(
804
- files: dict[str, str],
805
- project_name: str,
806
- repo_name: str,
807
- hf_token: str,
808
- space_sdk: str = "static",
809
- is_space: bool = True,
810
- ) -> dict[str, Any]:
811
- """Push generated project to HuggingFace Hub."""
812
- try:
813
- from huggingface_hub import HfApi, create_repo
814
-
815
- api = HfApi(token=hf_token)
816
-
817
- if "/" in repo_name:
818
- namespace, name = repo_name.split("/", 1)
819
- else:
820
- user_info = api.whoami()
821
- namespace = user_info["name"]
822
- name = repo_name
823
- repo_name = f"{namespace}/{name}"
824
-
825
- try:
826
- if is_space:
827
- create_repo(
828
- repo_id=repo_name,
829
- repo_type="space",
830
- space_sdk=space_sdk,
831
- token=hf_token,
832
- exist_ok=True,
833
- )
834
- else:
835
- create_repo(
836
- repo_id=repo_name,
837
- repo_type="model",
838
- token=hf_token,
839
- exist_ok=True,
840
- )
841
- except Exception as e:
842
- logger.warning("Repo creation warning: %s", e)
843
-
844
- with tempfile.TemporaryDirectory(prefix="hf_push_") as tmp_dir:
845
- for filepath, content in files.items():
846
- full_path = os.path.join(tmp_dir, filepath)
847
- os.makedirs(os.path.dirname(full_path), exist_ok=True)
848
- Path(full_path).write_text(content, encoding="utf-8")
849
-
850
- # Add README if not present
851
- readme_path = os.path.join(tmp_dir, "README.md")
852
- if not os.path.exists(readme_path):
853
- readme_content = f"""---
854
- title: {name}
855
- emoji: 🚀
856
- colorFrom: blue
857
- colorTo: purple
858
- sdk: {space_sdk}
859
- app_file: app.py
860
- ---
861
-
862
- # {name}
863
-
864
- Generated by Fullstack Code Builder using {MODEL_ID}.
865
- """
866
- Path(readme_path).write_text(readme_content, encoding="utf-8")
867
-
868
- # Add requirements.txt for Python/Gradio projects
869
- req_path = os.path.join(tmp_dir, "requirements.txt")
870
- if not os.path.exists(req_path):
871
- has_python = any(
872
- f.endswith(".py") for f in files.keys()
873
- )
874
- if has_python:
875
- reqs = ["gradio>=4.0.0"]
876
- # Detect common dependencies
877
- all_code = "\n".join(files.values())
878
- if "matplotlib" in all_code:
879
- reqs.append("matplotlib>=3.8")
880
- if "PIL" in all_code or "Pillow" in all_code:
881
- reqs.append("Pillow>=10.0")
882
- if "numpy" in all_code:
883
- reqs.append("numpy>=1.24")
884
- if "pandas" in all_code:
885
- reqs.append("pandas>=2.0")
886
- Path(req_path).write_text("\n".join(reqs) + "\n", encoding="utf-8")
887
-
888
- api.upload_folder(
889
- folder_path=tmp_dir,
890
- repo_id=repo_name,
891
- repo_type="space" if is_space else "model",
892
- token=hf_token,
893
- )
894
-
895
- repo_url = f"https://huggingface.co/{repo_name}"
896
- if is_space:
897
- repo_url = f"https://huggingface.co/spaces/{repo_name}"
898
-
899
- return {
900
- "success": True,
901
- "url": repo_url,
902
- "repo_name": repo_name,
903
- "message": f"Successfully pushed to {repo_url}",
904
- }
905
-
906
- except Exception as exc:
907
- logger.exception("Failed to push to HuggingFace")
908
- return {
909
- "success": False,
910
- "url": "",
911
- "repo_name": repo_name,
912
- "message": f"Failed to push: {str(exc)}",
913
- }
914
-
915
-
916
- # ─── Chat Helpers ───────────────────────────────────────────────────────
917
-
918
- def _chat_history_to_messages(history: list[dict[str, str]]) -> list[dict[str, Any]]:
919
- messages: list[dict[str, Any]] = [{"role": "system", "content": SYSTEM_PROMPT}]
920
- for item in history:
921
- role = item.get("role")
922
- content = str(item.get("content") or "").strip()
923
- if role not in {"user", "assistant"} or not content:
924
- continue
925
- if role == "assistant":
926
- content = _strip_thinking_blocks(content)
927
- messages.append({"role": role, "content": content})
928
- return messages
929
-
930
-
931
- def _clip_context(text: str, limit: int = 4_000) -> str:
932
- if len(text) <= limit:
933
- return text
934
- return text[:limit] + f"\n... truncated {len(text) - limit} characters ..."
935
-
936
-
937
- def _iteration_context(execution_context: dict[str, Any] | None) -> str:
938
- if not execution_context or not execution_context.get("code"):
939
- return ""
940
-
941
- code = _clip_context(str(execution_context.get("code") or ""), 6_000)
942
- target = str(execution_context.get("target") or "code")
943
- fence_lang = str(execution_context.get("fence_lang") or target)
944
- status = str(execution_context.get("status") or "")
945
- stdout = _clip_context(str(execution_context.get("stdout") or ""), 2_000)
946
- stderr = _clip_context(str(execution_context.get("stderr") or ""), 2_000)
947
-
948
- parts = [
949
- "Previous generated code and run result are available for iteration.",
950
- f"Previous target: {target}",
951
- f"Previous status: {status}",
952
- f"Previous code:\n```{fence_lang}\n{code}\n```",
953
- ]
954
- if stdout:
955
- parts.append(f"Previous stdout:\n{stdout}")
956
- if stderr:
957
- parts.append(f"Previous stderr / traceback:\n{stderr}")
958
- parts.append("If the user asks to revise, debug, extend, or explain the prior code, use this context.")
959
- return "\n\n".join(parts)
960
-
961
-
962
- def _targeted_prompt(
963
- prompt: str,
964
- target_language: str,
965
- target_framework: str = "",
966
- execution_context: dict[str, Any] | None = None,
967
- search_context: str = "",
968
- ) -> str:
969
- iteration_context = _iteration_context(execution_context)
970
- context_block = f"\n\n{iteration_context}" if iteration_context else ""
971
-
972
- search_block = ""
973
- if search_context:
974
- search_block = f"\n\n{search_context}\n\nUse the above search results to inform your code generation if relevant."
975
-
976
- framework_hint = f" using {target_framework}" if target_framework else ""
977
-
978
- gradio_hint = ""
979
- if target_framework == "Gradio":
980
- gradio_hint = (
981
- "\n\nIMPORTANT: This is a Gradio app. Create a complete Python script that:\n"
982
- "- Imports gradio as gr\n"
983
- "- Defines the UI using gr.Interface() or gr.Blocks()\n"
984
- "- Includes all processing logic inline\n"
985
- "- Calls .launch(server_name='0.0.0.0', server_port=7860) at the end\n"
986
- "- Uses only standard library + gradio + common packages (PIL, matplotlib, numpy)\n"
987
- "- Make the UI clean, modern, and functional"
988
- )
989
-
990
- return (
991
- f"Target: {target_language}{framework_hint}. Generate a complete, runnable application. "
992
- "If the user asks for a web app, include all HTML/CSS/JS. "
993
- "If they ask for a backend, include the server code and any API definitions. "
994
- "For single-file apps, use a single code block. For multi-file projects, use the @@FILE: format. "
995
- "Make the code complete, working, and well-structured."
996
- f"{gradio_hint}"
997
- f"{search_block}"
998
- f"{context_block}\n\n"
999
- f"User request:\n{prompt}"
1000
- )
1001
-
1002
-
1003
- # ─── Run Extracted Code ────────────────────────────────────────────────
1004
-
1005
- def _run_extracted_code(
1006
- code: str,
1007
- target: str,
1008
- framework: str = "",
1009
- ) -> tuple[str, str, str | None, str, str]:
1010
- """Run extracted code. For Gradio apps, launch as a subprocess server."""
1011
- if target == "python" and _is_gradio_code(code):
1012
- result = run_gradio_app(code)
1013
- if result["success"]:
1014
- return (
1015
- result.get("stdout", ""),
1016
- f"Gradio app running at {result['url']}",
1017
- None,
1018
- f"Gradio running at {result['url']}",
1019
- "success",
1020
- )
1021
- else:
1022
- return (
1023
- result.get("stdout", ""),
1024
- result.get("stderr", result.get("message", "Gradio launch failed")),
1025
- None,
1026
- "Gradio launch failed",
1027
- "error",
1028
- )
1029
-
1030
- if target == "python":
1031
- result = run_python(code)
1032
- if result.timed_out:
1033
- return result.stdout, result.stderr, result.image_path, f"Timed out after {PY_TIMEOUT_S}s", "error"
1034
- if result.returncode:
1035
- return result.stdout, result.stderr, result.image_path, "Finished with errors", "error"
1036
- return result.stdout, result.stderr, result.image_path, "Ran successfully", "success"
1037
-
1038
- return "", "", None, "Preview ready", "success"
1039
-
1040
-
1041
- # ─── Served Files Registry ──────────────────────────────────────────────
1042
-
1043
- _served_files: dict[str, str] = {}
1044
-
1045
- # ─── FastAPI / Gradio Application ───────────────────────────────────────
1046
-
1047
- app = Server()
1048
-
1049
-
1050
- @app.get("/", response_class=HTMLResponse)
1051
- async def homepage():
1052
- html_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "index.html")
1053
- with open(html_path, "r", encoding="utf-8") as f:
1054
- content = f.read()
1055
-
1056
- config = json.dumps({
1057
- "app_title": APP_TITLE,
1058
- "model_id": MODEL_ID,
1059
- "model_url": MODEL_URL,
1060
- "languages": LANGUAGE_OPTIONS,
1061
- "examples": [
1062
- {"label": label, "prompt": prompt, "language": lang, "framework": fw}
1063
- for label, prompt, lang, fw in EXAMPLE_PROMPTS
1064
- ],
1065
- })
1066
- content = content.replace("__RUNTIME_CONFIG__", config)
1067
- return content
1068
-
1069
-
1070
- @app.get("/api/model-status")
1071
- async def model_status_endpoint():
1072
- return get_model_status()
1073
-
1074
-
1075
- @app.get("/images/{filename}")
1076
- async def serve_image(filename: str):
1077
- path = _served_files.get(f"img:{filename}")
1078
- if path and os.path.exists(path):
1079
- return FileResponse(path, media_type="image/png")
1080
- return HTMLResponse("Not found", status_code=404)
1081
-
1082
-
1083
- @app.get("/download/{filename}")
1084
- async def serve_download(filename: str):
1085
- path = _served_files.get(f"dl:{filename}")
1086
- if path and os.path.exists(path):
1087
- return FileResponse(path, filename=filename, media_type="application/octet-stream")
1088
- return HTMLResponse("Not found", status_code=404)
1089
-
1090
-
1091
- @app.api(name="web_search", concurrency_limit=4)
1092
- def handle_web_search(query: str) -> str:
1093
- """Search the web using Google scraping. No API key needed."""
1094
- query = (query or "").strip()
1095
- if not query:
1096
- yield json.dumps({"success": False, "results": [], "message": "Empty search query"})
1097
- return
1098
-
1099
- try:
1100
- results = web_search_google(query, num_results=8)
1101
- formatted = format_search_results(results)
1102
-
1103
- yield json.dumps({
1104
- "success": True,
1105
- "results": results,
1106
- "formatted": formatted,
1107
- "message": f"Found {len(results)} results",
1108
- })
1109
- except Exception as exc:
1110
- logger.exception("Web search failed")
1111
- yield json.dumps({
1112
- "success": False,
1113
- "results": [],
1114
- "message": f"Search failed: {str(exc)}",
1115
- })
1116
-
1117
-
1118
- @app.api(name="chat", concurrency_limit=2)
1119
- def handle_chat(
1120
- prompt: str,
1121
- target_language: str,
1122
- target_framework: str,
1123
- history_json: str,
1124
- exec_context_json: str,
1125
- search_enabled: str = "false",
1126
- ) -> str:
1127
- """Stream chat responses with code execution. Yields JSON strings."""
1128
- history = json.loads(history_json) if history_json else []
1129
- execution_context = json.loads(exec_context_json) if exec_context_json else {}
1130
-
1131
- prompt = (prompt or "").strip()
1132
- if not prompt:
1133
- yield json.dumps({
1134
- "type": "error",
1135
- "status_text": "Enter a prompt to get started.",
1136
- "status_state": "info",
1137
- "history": history,
1138
- "execution": execution_context,
1139
- })
1140
- return
1141
-
1142
- # Check model status
1143
- model_status = get_model_status()
1144
- if model_status["status"] == "loading":
1145
- yield json.dumps({
1146
- "type": "error",
1147
- "status_text": model_status["message"],
1148
- "status_state": "working",
1149
- "history": history,
1150
- "execution": execution_context,
1151
- })
1152
- return
1153
- if model_status["status"] != "ready":
1154
- yield json.dumps({
1155
- "type": "error",
1156
- "status_text": model_status["message"],
1157
- "status_state": "error",
1158
- "history": history,
1159
- "execution": execution_context,
1160
- })
1161
- return
1162
-
1163
- # Add user message and placeholder assistant message
1164
- history = list(history) + [
1165
- {"role": "user", "content": prompt},
1166
- {"role": "assistant", "content": ""},
1167
- ]
1168
- yield json.dumps({
1169
- "type": "status",
1170
- "status_text": "Thinking...",
1171
- "status_state": "working",
1172
- "history": history,
1173
- "execution": execution_context,
1174
- })
1175
-
1176
- # Web search if enabled
1177
- search_context = ""
1178
- if search_enabled.lower() == "true":
1179
- yield json.dumps({
1180
- "type": "status",
1181
- "status_text": "Searching the web...",
1182
- "status_state": "working",
1183
- "history": history,
1184
- "execution": execution_context,
1185
- })
1186
- search_results = web_search_google(prompt, num_results=6)
1187
- if search_results:
1188
- search_context = format_search_results(search_results)
1189
- yield json.dumps({
1190
- "type": "search_results",
1191
- "status_text": f"Found {len(search_results)} results, generating code...",
1192
- "status_state": "working",
1193
- "history": history,
1194
- "execution": execution_context,
1195
- "search_results": search_results,
1196
- })
1197
-
1198
- # Build messages for model
1199
- model_history = list(history[:-1])
1200
- model_history[-1] = {
1201
- "role": "user",
1202
- "content": _targeted_prompt(prompt, target_language, target_framework, execution_context, search_context),
1203
- }
1204
- messages = _chat_history_to_messages(model_history)
1205
-
1206
- final_response = ""
1207
- for partial in call_model(messages):
1208
- final_response = partial
1209
- history[-1]["content"] = partial
1210
- yield json.dumps({
1211
- "type": "streaming",
1212
- "status_text": "Generating...",
1213
- "status_state": "working",
1214
- "history": history,
1215
- "execution": execution_context,
1216
- })
1217
-
1218
- if not final_response:
1219
- history[-1]["content"] = "The model did not return a response."
1220
- yield json.dumps({
1221
- "type": "error",
1222
- "status_text": "No model response.",
1223
- "status_state": "error",
1224
- "history": history,
1225
- "execution": execution_context,
1226
- })
1227
- return
1228
-
1229
- # Extract code from response
1230
- code, fence_lang = extract_code(final_response)
1231
- target = _normalize_language(target_language, fence_lang)
1232
-
1233
- # Also try multi-file extraction
1234
- multi_files = extract_multi_file(final_response)
1235
-
1236
- if not code and not multi_files:
1237
- yield json.dumps({
1238
- "type": "complete",
1239
- "status_text": "Answered without running code.",
1240
- "status_state": "info",
1241
- "history": history,
1242
- "execution": execution_context,
1243
- })
1244
- return
1245
-
1246
- yield json.dumps({
1247
- "type": "status",
1248
- "status_text": "Running...",
1249
- "status_state": "working",
1250
- "history": history,
1251
- "execution": execution_context,
1252
- })
1253
-
1254
- # Execute code
1255
- stdout, stderr, image_path, status_text, status_state = "", "", None, "Preview ready", "success"
1256
- is_gradio = False
1257
- gradio_url = None
1258
-
1259
- if target == "python" and code:
1260
- if _is_gradio_code(code) or target_framework == "Gradio":
1261
- is_gradio = True
1262
- gradio_result = run_gradio_app(code)
1263
- if gradio_result["success"]:
1264
- gradio_url = gradio_result["url"]
1265
- status_text = f"Gradio app running at {gradio_url}"
1266
- status_state = "success"
1267
- stderr = f"Gradio app launched successfully at {gradio_url}"
1268
- else:
1269
- status_text = "Gradio launch failed"
1270
- status_state = "error"
1271
- stderr = gradio_result.get("stderr", gradio_result.get("message", "Launch failed"))
1272
- else:
1273
- stdout, stderr, image_path, status_text, status_state = _run_extracted_code(code, target, target_framework)
1274
-
1275
- # Register image for serving
1276
- image_url = None
1277
- if image_path:
1278
- filename = os.path.basename(image_path)
1279
- _served_files[f"img:{filename}"] = image_path
1280
- image_url = f"/images/{filename}"
1281
-
1282
- # Register code for download
1283
- download_url = None
1284
- project_files = multi_files if multi_files else {}
1285
-
1286
- if project_files:
1287
- project_name = "generated-project"
1288
- zip_path = create_project_zip(project_files, project_name)
1289
- zip_filename = f"{project_name}.zip"
1290
- _served_files[f"dl:{zip_filename}"] = zip_path
1291
- download_url = f"/download/{zip_filename}"
1292
- elif code:
1293
- ext = "py" if target == "python" else "html"
1294
- dl_filename = f"generated.{ext}"
1295
- dl_dir = tempfile.mkdtemp(prefix="fullstack_dl_")
1296
- dl_path = os.path.join(dl_dir, dl_filename)
1297
- Path(dl_path).write_text(code, encoding="utf-8")
1298
- _served_files[f"dl:{dl_filename}"] = dl_path
1299
- download_url = f"/download/{dl_filename}"
1300
-
1301
- # Determine if this is web previewable
1302
- is_web = target in {"web", "javascript", "typescript", "html"} or (fence_lang or "") in {"html", "web"}
1303
- web_code = code if is_web else None
1304
-
1305
- execution_context = {
1306
- "code": code,
1307
- "target": target,
1308
- "fence_lang": fence_lang or target,
1309
- "stdout": stdout,
1310
- "stderr": stderr,
1311
- "image_url": image_url,
1312
- "image_path": image_path,
1313
- "status": status_text,
1314
- "language": fence_lang or target,
1315
- "suggested_tab": "preview" if (image_path or is_web or is_gradio) else "console",
1316
- "download_url": download_url,
1317
- "project_files": project_files,
1318
- "is_web": is_web,
1319
- "web_code": web_code,
1320
- "is_gradio": is_gradio,
1321
- "gradio_url": gradio_url,
1322
- }
1323
-
1324
- yield json.dumps({
1325
- "type": "complete",
1326
- "status_text": status_text,
1327
- "status_state": status_state,
1328
- "history": history,
1329
- "execution": execution_context,
1330
- })
1331
-
1332
-
1333
- @app.api(name="push_hf", concurrency_limit=1)
1334
- def handle_push_hf(
1335
- exec_context_json: str,
1336
- repo_name: str,
1337
- hf_token: str,
1338
- space_sdk: str = "static",
1339
- is_space: str = "true",
1340
- ) -> str:
1341
- """Push generated project to HuggingFace Hub."""
1342
- try:
1343
- execution_context = json.loads(exec_context_json) if exec_context_json else {}
1344
- project_files = execution_context.get("project_files", {})
1345
-
1346
- if not project_files:
1347
- code = execution_context.get("code", "")
1348
- if not code:
1349
- yield json.dumps({
1350
- "success": False,
1351
- "message": "No code to push. Generate some code first.",
1352
- "url": "",
1353
- })
1354
- return
1355
-
1356
- lang = execution_context.get("language", "python")
1357
- is_gradio = execution_context.get("is_gradio", False)
1358
- ext_map = {
1359
- "python": "app.py", "py": "app.py",
1360
- "javascript": "index.js", "js": "index.js",
1361
- "html": "index.html", "web": "index.html",
1362
- "typescript": "index.ts", "ts": "index.ts",
1363
- }
1364
- filename = ext_map.get(lang, "app.py")
1365
- project_files = {filename: code}
1366
-
1367
- # Auto-detect SDK for Gradio apps
1368
- if is_gradio or _is_gradio_code(code):
1369
- space_sdk = "gradio"
1370
-
1371
- project_name = repo_name.split("/")[-1] if "/" in repo_name else repo_name
1372
-
1373
- result = push_to_huggingface(
1374
- files=project_files,
1375
- project_name=project_name,
1376
- repo_name=repo_name,
1377
- hf_token=hf_token,
1378
- space_sdk=space_sdk,
1379
- is_space=is_space.lower() == "true",
1380
- )
1381
-
1382
- yield json.dumps(result)
1383
-
1384
- except Exception as exc:
1385
- logger.exception("Push to HuggingFace failed")
1386
- yield json.dumps({
1387
- "success": False,
1388
- "message": f"Push failed: {str(exc)}",
1389
- "url": "",
1390
- })
1391
-
1392
 
1393
- app.launch(show_error=True)
 
 
 
1
+ """Fullstack Code Builder entry point.
2
 
3
  Uses MiniCPM5-1B for local inference (no external APIs).
4
  Supports generating fullstack applications in any language.
5
  Can push generated projects to HuggingFace Hub.
6
  Web search via Google scraping (no API keys needed).
7
  Gradio app support for Python.
8
+
9
+ Project structure:
10
+ code/
11
+ ├── config/constants.py App constants, language options, system prompt
12
+ ├── model/loader.py Model loading & status
13
+ ├── model/inference.py Streaming model inference
14
+ ├── execution/code_extractor.py Code extraction & language normalization
15
+ ├── execution/python_runner.py Sandboxed Python execution
16
+ ├── execution/gradio_runner.py Gradio app subprocess runner
17
+ ├── websearch/google_scraper.py Google search scraping (no API)
18
+ ├── huggingface/push.py HuggingFace Hub push & ZIP packaging
19
+ ├── server/chat_helpers.py Chat history & prompt building
20
+ └── server/routes.py FastAPI / Gradio server routes
21
  """
22
 
23
  from __future__ import annotations
24
 
 
 
25
  import logging
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
 
27
+ from code.model.loader import start_background_load
28
+ from code.server.routes import get_app
 
29
 
 
30
  logging.basicConfig(level=logging.INFO)
31
+ logger = logging.getLogger(__name__)
32
 
33
+ # Start loading model in background
34
+ start_background_load()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35
 
36
+ # Launch the server
37
+ application = get_app()
38
+ application.launch(show_error=True)
code/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Fullstack Code Builder — modular package."""
code/config/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Configuration and constants."""
code/config/constants.py ADDED
@@ -0,0 +1,132 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Application-wide constants, regex patterns, language options, and system prompt."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ # ─── App Identity ────────────────────────────────────────────────────────
8
+
9
+ APP_TITLE = "Fullstack Code Builder"
10
+ MODEL_ID = "openbmb/MiniCPM5-1B"
11
+ MODEL_URL = "https://huggingface.co/openbmb/MiniCPM5-1B"
12
+
13
+ # ─── Runtime Defaults ───────────────────────────────────────────────────
14
+
15
+ DEFAULT_TEMPERATURE = 0.6
16
+ DEFAULT_MAX_TOKENS = 4096
17
+ PY_TIMEOUT_S = 15
18
+ GRADIO_TIMEOUT_S = 30
19
+ PY_MEM_LIMIT_MB = 1024
20
+ MAX_STDIO_CHARS = 16_000
21
+ OUTPUT_PNG = "output.png"
22
+
23
+ # ─── Regex Patterns ─────────────────────────────────────────────────────
24
+
25
+ THINKING_BLOCK_RE = re.compile(
26
+ r"<\s*think\s*>.*?<\s*/\s*think\s*>", re.IGNORECASE | re.DOTALL
27
+ )
28
+ CODE_BLOCK_RE = re.compile(
29
+ r"```([a-zA-Z0-9_+.#-]*)\s*\n(.*?)```", re.DOTALL
30
+ )
31
+ FILE_BLOCK_RE = re.compile(
32
+ r"@@FILE:\s*(.+?)@@\s*\n(.*?)(?=@@FILE:|@@END@@)", re.DOTALL
33
+ )
34
+
35
+ # ─── Supported Languages & Frameworks ───────────────────────────────────
36
+
37
+ LANGUAGE_OPTIONS: list[tuple[str, list[str]]] = [
38
+ ("Python", ["Gradio", "Flask", "Django", "FastAPI", "Streamlit", "Plain Python"]),
39
+ ("JavaScript", ["React", "Vue.js", "Next.js", "Express.js", "Node.js", "Vanilla JS"]),
40
+ ("TypeScript", ["React", "Next.js", "Express.js", "NestJS"]),
41
+ ("HTML/CSS/JS", ["Tailwind CSS", "Bootstrap", "Vanilla"]),
42
+ ("Java", ["Spring Boot", "Maven", "Gradle"]),
43
+ ("Go", ["Gin", "Fiber", "Echo", "Plain Go"]),
44
+ ("Rust", ["Actix", "Axum", "Rocket"]),
45
+ ("PHP", ["Laravel", "Symfony", "Plain PHP"]),
46
+ ("Ruby", ["Rails", "Sinatra"]),
47
+ ("C#", ["ASP.NET", "Blazor"]),
48
+ ("Swift", ["Vapor", "SwiftUI"]),
49
+ ("Kotlin", ["Ktor", "Spring Boot"]),
50
+ ]
51
+
52
+ LANGUAGE_MAP: dict[str, list[str]] = {lang: frameworks for lang, frameworks in LANGUAGE_OPTIONS}
53
+
54
+ # ─── System Prompt ───────────────────────────────────────────────────────
55
+
56
+ SYSTEM_PROMPT = """You are a fullstack application code generator running locally. You help users build complete, runnable applications in any programming language and framework.
57
+
58
+ When the user asks you to build an application:
59
+ 1. Generate complete, working code - not snippets or pseudocode
60
+ 2. Include all necessary files for the project to run
61
+ 3. Add proper error handling and comments
62
+ 4. For web apps, make the UI responsive and modern
63
+ 5. For Gradio apps, use gradio library and create a complete working app with gr.Interface or gr.Blocks
64
+
65
+ FILE OUTPUT FORMAT - IMPORTANT:
66
+ When generating multi-file projects, wrap each file in this format:
67
+ @@FILE: path/to/file.ext@@
68
+ (file content here)
69
+ @@FILE: path/to/another/file.ext@@
70
+ (another file content here)
71
+ @@END@@
72
+
73
+ For single-file code, use standard markdown fenced blocks:
74
+ ```python for Python
75
+ ```html for HTML/CSS/JS
76
+ ```javascript for JavaScript
77
+ ```typescript for TypeScript
78
+ etc.
79
+
80
+ When generating web apps with HTML/CSS/JS, return a single self-contained HTML document with all CSS and JavaScript inline. Make the page fully responsive: html/body at margin:0 and 100% width/height, use flexbox/grid layouts, and size any canvas to its container.
81
+
82
+ When generating Gradio apps, create a complete app.py with:
83
+ - import gradio as gr
84
+ - Define the interface using gr.Interface() or gr.Blocks()
85
+ - Call iface.launch(server_name="0.0.0.0", server_port=7860) at the end
86
+ - Include all necessary processing logic inline
87
+
88
+ For Python, prefer standard library or common packages. Do not use network calls, subprocesses, shell commands, or long-running loops in demo code (except Gradio apps which are server-based).
89
+
90
+ If web search results are provided in the context, use them to inform your code generation. Incorporate relevant information from the search results into the generated code.
91
+ """
92
+
93
+ # ─── Example Prompts ────────────────────────────────────────────────────
94
+
95
+ EXAMPLE_PROMPTS: list[tuple[str, str, str, str]] = [
96
+ (
97
+ "🎨 Gradio Image Filter",
98
+ "Create a Gradio app that lets users upload an image and apply filters like grayscale, blur, sepia, and edge detection using PIL. Show the original and filtered images side by side.",
99
+ "Python",
100
+ "Gradio",
101
+ ),
102
+ (
103
+ "🤖 Gradio Chat App",
104
+ "Build a Gradio chatbot app with gr.Blocks that has a chat interface, a text input, and a send button. Include a simple echo bot that repeats the user's message with a fun twist.",
105
+ "Python",
106
+ "Gradio",
107
+ ),
108
+ (
109
+ "🌐 React Todo App",
110
+ "Build a React todo application with add, delete, mark complete, and filter functionality. Use modern hooks and a clean responsive UI.",
111
+ "JavaScript",
112
+ "React",
113
+ ),
114
+ (
115
+ "🐍 Flask API",
116
+ "Create a Flask REST API for a book library with CRUD operations, in-memory storage, and proper error handling.",
117
+ "Python",
118
+ "Flask",
119
+ ),
120
+ (
121
+ "🎨 Landing Page",
122
+ "Build a modern landing page for a SaaS product with a hero section, features grid, pricing cards, and a footer. Use Tailwind-style CSS.",
123
+ "HTML/CSS/JS",
124
+ "Vanilla",
125
+ ),
126
+ (
127
+ "📊 Dashboard",
128
+ "Create an interactive data dashboard with charts (bar, line, pie), a sidebar navigation, and summary cards. All in a single HTML file.",
129
+ "HTML/CSS/JS",
130
+ "Vanilla",
131
+ ),
132
+ ]
code/execution/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Code execution engines."""
code/execution/code_extractor.py ADDED
@@ -0,0 +1,137 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Code extraction from model responses.
2
+
3
+ Extracts fenced code blocks and multi-file @@FILE: blocks.
4
+ Normalizes language names and detects Gradio code.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import html
10
+ import re
11
+
12
+ from code.config.constants import (
13
+ CODE_BLOCK_RE,
14
+ FILE_BLOCK_RE,
15
+ THINKING_BLOCK_RE,
16
+ )
17
+
18
+
19
+ def strip_thinking_blocks(text: str) -> str:
20
+ """Remove <think/> blocks from model output."""
21
+ return THINKING_BLOCK_RE.sub("", text).strip()
22
+
23
+
24
+ def extract_code(response: str) -> tuple[str, str | None]:
25
+ """Return the first fenced code block and its language tag."""
26
+ visible_response = strip_thinking_blocks(response)
27
+ match = CODE_BLOCK_RE.search(visible_response)
28
+ if not match:
29
+ return "", None
30
+ return match.group(2).strip(), (match.group(1).strip().lower() or None)
31
+
32
+
33
+ def extract_multi_file(response: str) -> dict[str, str]:
34
+ """Extract multi-file project from @@FILE: format.
35
+
36
+ Returns dict of {filepath: content}.
37
+ """
38
+ files: dict[str, str] = {}
39
+ visible = strip_thinking_blocks(response)
40
+
41
+ for match in FILE_BLOCK_RE.finditer(visible):
42
+ filepath = match.group(1).strip()
43
+ content = match.group(2).strip()
44
+ files[filepath] = content
45
+
46
+ # Fallback: if no @@FILE: blocks found, extract single code block
47
+ if not files:
48
+ code, lang = extract_code(response)
49
+ if code:
50
+ ext_map = {
51
+ "python": "main.py", "py": "main.py",
52
+ "javascript": "index.js", "js": "index.js",
53
+ "typescript": "index.ts", "ts": "index.ts",
54
+ "html": "index.html",
55
+ "css": "styles.css",
56
+ "java": "Main.java",
57
+ "go": "main.go",
58
+ "rust": "main.rs",
59
+ "php": "index.php",
60
+ "ruby": "main.rb",
61
+ "csharp": "Program.cs",
62
+ "swift": "main.swift",
63
+ "kotlin": "Main.kt",
64
+ }
65
+ filename = ext_map.get(lang or "", "code.txt")
66
+ files[filename] = code
67
+
68
+ return files
69
+
70
+
71
+ def normalize_language(target_language: str | None, fence_lang: str | None) -> str:
72
+ """Normalize language name to a canonical form."""
73
+ lang = (fence_lang or target_language or "python").lower()
74
+ if lang in {"python", "py"}:
75
+ return "python"
76
+ if lang in {"html", "web", "css"}:
77
+ return "web"
78
+ if lang in {"javascript", "js"}:
79
+ return "javascript"
80
+ if lang in {"typescript", "ts"}:
81
+ return "typescript"
82
+ if lang == "java":
83
+ return "java"
84
+ if lang == "go":
85
+ return "go"
86
+ if lang == "rust":
87
+ return "rust"
88
+ if lang == "php":
89
+ return "php"
90
+ if lang == "ruby":
91
+ return "ruby"
92
+ if lang in {"csharp", "c#"}:
93
+ return "csharp"
94
+ if lang == "swift":
95
+ return "swift"
96
+ if lang == "kotlin":
97
+ return "kotlin"
98
+ return lang
99
+
100
+
101
+ def is_gradio_code(code: str) -> bool:
102
+ """Detect if Python code is a Gradio app."""
103
+ return bool(
104
+ re.search(
105
+ r"import\s+gradio|from\s+gradio\s+import|gr\.\s*(Interface|Blocks|TabbedInterface|ChatInterface|App)",
106
+ code,
107
+ )
108
+ )
109
+
110
+
111
+ # ─── Web Document / Iframe Builder ─────────────────────────────────────
112
+
113
+ def _web_document(code: str, fence_lang: str | None) -> str:
114
+ """Wrap code in an HTML document if needed."""
115
+ lang = (fence_lang or "").lower()
116
+ if lang in {"javascript", "js"}:
117
+ return f"<!doctype html><html><body><script>\n{code}\n</script></body></html>"
118
+ if lang == "css":
119
+ return f"<!doctype html><html><head><style>\n{code}\n</style></head><body></body></html>"
120
+ if re.search(r"<!doctype|<html[\s>]", code, flags=re.IGNORECASE):
121
+ return code
122
+ return f"<!doctype html><html><head><meta charset='utf-8'></head><body>\n{code}\n</body></html>"
123
+
124
+
125
+ def build_iframe(code: str, fence_lang: str | None = None) -> str:
126
+ """Build a sandboxed iframe HTML string for web preview."""
127
+ document = _web_document(code, fence_lang)
128
+ srcdoc = html.escape(document, quote=True)
129
+ return (
130
+ '<iframe class="web-frame" '
131
+ 'sandbox="allow-scripts" '
132
+ 'allow="fullscreen" '
133
+ "allowfullscreen "
134
+ f'srcdoc="{srcdoc}" '
135
+ 'style="width:100%; min-height:680px; border:0; border-radius:14px; '
136
+ 'background:white;"></iframe>'
137
+ )
code/execution/gradio_runner.py ADDED
@@ -0,0 +1,129 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Gradio app runner — launches Gradio apps as subprocess servers.
2
+
3
+ Manages the lifecycle of Gradio app processes: start, status check, and stop.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import logging
9
+ import os
10
+ import re
11
+ import subprocess
12
+ import sys
13
+ import tempfile
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+ # ─── Registry for running Gradio subprocesses ───────────────────────────
20
+
21
+ _running_gradio_procs: dict[str, subprocess.Popen] = {}
22
+
23
+
24
+ def run_gradio_app(code: str, port: int = 7861) -> dict[str, Any]:
25
+ """Launch a Gradio app as a subprocess and return its URL.
26
+
27
+ The Gradio app is run on the specified port. We modify the code
28
+ to ensure it launches on the correct port and is accessible.
29
+ """
30
+ # Kill any previously running Gradio app
31
+ _stop_all_procs()
32
+
33
+ # Patch the code: ensure launch uses correct server_name and server_port
34
+ patched_code = code
35
+
36
+ # Replace .launch() with correct params
37
+ patched_code = re.sub(
38
+ r"(\w+)\.launch\([^)]*\)",
39
+ f'\\1.launch(server_name="0.0.0.0", server_port={port}, share=False)',
40
+ patched_code,
41
+ )
42
+
43
+ # If no .launch() found, add one
44
+ if ".launch(" not in patched_code:
45
+ patched_code += (
46
+ f'\n\nif __name__ == "__main__":\n'
47
+ f' iface.launch(server_name="0.0.0.0", server_port={port}, share=False)\n'
48
+ )
49
+
50
+ with tempfile.TemporaryDirectory(prefix="gradio_app_") as tmp:
51
+ app_path = Path(tmp) / "gradio_app.py"
52
+ app_path.write_text(patched_code, encoding="utf-8")
53
+
54
+ env = {
55
+ **os.environ,
56
+ "PYTHONUNBUFFERED": "1",
57
+ "GRADIO_SERVER_NAME": "0.0.0.0",
58
+ "GRADIO_SERVER_PORT": str(port),
59
+ }
60
+
61
+ try:
62
+ proc = subprocess.Popen(
63
+ [sys.executable, str(app_path)],
64
+ cwd=tmp,
65
+ env=env,
66
+ stdout=subprocess.PIPE,
67
+ stderr=subprocess.PIPE,
68
+ text=True,
69
+ )
70
+
71
+ proc_id = f"gradio_{port}"
72
+ _running_gradio_procs[proc_id] = proc
73
+
74
+ # Wait a bit for the server to start
75
+ import time as _time
76
+ _time.sleep(3)
77
+
78
+ # Check if process is still running
79
+ poll = proc.poll()
80
+ if poll is not None:
81
+ stdout = proc.stdout.read() if proc.stdout else ""
82
+ stderr = proc.stderr.read() if proc.stderr else ""
83
+ return {
84
+ "success": False,
85
+ "url": "",
86
+ "message": f"Gradio app exited with code {poll}",
87
+ "stdout": stdout[-2000:] if stdout else "",
88
+ "stderr": stderr[-2000:] if stderr else "",
89
+ }
90
+
91
+ gradio_url = f"http://localhost:{port}"
92
+ return {
93
+ "success": True,
94
+ "url": gradio_url,
95
+ "message": f"Gradio app running at {gradio_url}",
96
+ "port": port,
97
+ }
98
+
99
+ except Exception as exc:
100
+ logger.exception("Failed to launch Gradio app")
101
+ return {
102
+ "success": False,
103
+ "url": "",
104
+ "message": f"Failed to launch: {exc}",
105
+ }
106
+
107
+
108
+ def stop_gradio_app() -> dict[str, Any]:
109
+ """Stop any running Gradio app subprocess."""
110
+ stopped = _stop_all_procs()
111
+ return {"success": True, "message": f"Stopped {stopped} Gradio app(s)"}
112
+
113
+
114
+ def _stop_all_procs() -> int:
115
+ """Stop all running Gradio processes. Returns count of stopped procs."""
116
+ stopped = 0
117
+ for pid, proc in list(_running_gradio_procs.items()):
118
+ try:
119
+ proc.terminate()
120
+ proc.wait(timeout=3)
121
+ stopped += 1
122
+ except Exception:
123
+ try:
124
+ proc.kill()
125
+ stopped += 1
126
+ except Exception:
127
+ pass
128
+ _running_gradio_procs.clear()
129
+ return stopped
code/execution/python_runner.py ADDED
@@ -0,0 +1,173 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Sandboxed Python code execution.
2
+
3
+ Runs user Python code in a subprocess with resource limits,
4
+ captures stdout/stderr, and saves matplotlib figures.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import subprocess
11
+ import sys
12
+ import tempfile
13
+ import textwrap
14
+ from dataclasses import dataclass
15
+ from pathlib import Path
16
+
17
+ from code.config.constants import (
18
+ MAX_STDIO_CHARS,
19
+ OUTPUT_PNG,
20
+ PY_MEM_LIMIT_MB,
21
+ PY_TIMEOUT_S,
22
+ )
23
+
24
+
25
+ @dataclass
26
+ class PythonExecutionResult:
27
+ """Result of a sandboxed Python execution."""
28
+ stdout: str
29
+ stderr: str
30
+ image_path: str | None
31
+ returncode: int | None
32
+ timed_out: bool = False
33
+
34
+
35
+ def _apply_subprocess_limits() -> None:
36
+ """Set resource limits for the subprocess (Linux only)."""
37
+ import resource
38
+ mem_bytes = PY_MEM_LIMIT_MB * 1024 * 1024
39
+ resource.setrlimit(resource.RLIMIT_AS, (mem_bytes, mem_bytes))
40
+ resource.setrlimit(resource.RLIMIT_CPU, (PY_TIMEOUT_S, PY_TIMEOUT_S))
41
+
42
+
43
+ def _python_runner_source() -> str:
44
+ """Return the source code of the runner script that wraps user code."""
45
+ return textwrap.dedent(
46
+ f"""
47
+ import os
48
+ import runpy
49
+ import sys
50
+ import traceback
51
+
52
+ os.environ.setdefault("MPLBACKEND", "Agg")
53
+ exit_code = 0
54
+ try:
55
+ runpy.run_path(os.path.join(os.getcwd(), "user_code.py"), run_name="__main__")
56
+ except SystemExit as exc:
57
+ code = exc.code
58
+ exit_code = code if isinstance(code, int) else 1
59
+ except Exception:
60
+ traceback.print_exc()
61
+ exit_code = 1
62
+ finally:
63
+ try:
64
+ import matplotlib
65
+ matplotlib.use("Agg", force=True)
66
+ import matplotlib.pyplot as plt
67
+ if plt.get_fignums():
68
+ plt.savefig(os.environ["OUTPUT_PNG"], bbox_inches="tight")
69
+ except ModuleNotFoundError as exc:
70
+ if exc.name != "matplotlib":
71
+ traceback.print_exc()
72
+ except Exception:
73
+ traceback.print_exc()
74
+
75
+ raise SystemExit(exit_code)
76
+ """
77
+ ).strip()
78
+
79
+
80
+ def _truncate_output(text: str) -> str:
81
+ """Truncate output to MAX_STDIO_CHARS with a note."""
82
+ if len(text) <= MAX_STDIO_CHARS:
83
+ return text
84
+ remaining = len(text) - MAX_STDIO_CHARS
85
+ return text[:MAX_STDIO_CHARS] + f"\n\n... truncated {remaining} characters ..."
86
+
87
+
88
+ def _decode_timeout_output(value: str | bytes | None) -> str:
89
+ """Safely decode subprocess output from timeout exceptions."""
90
+ if value is None:
91
+ return ""
92
+ if isinstance(value, bytes):
93
+ return value.decode("utf-8", errors="replace")
94
+ return value
95
+
96
+
97
+ def run_python(code: str) -> PythonExecutionResult:
98
+ """Execute Python code in a sandboxed subprocess.
99
+
100
+ Returns a PythonExecutionResult with stdout, stderr, image path, and status.
101
+ """
102
+ with tempfile.TemporaryDirectory(prefix="fullstack_run_") as tmp:
103
+ workdir = Path(tmp)
104
+ runner_path = workdir / "runner.py"
105
+ user_path = workdir / "user_code.py"
106
+ image_path = workdir / OUTPUT_PNG
107
+
108
+ runner_path.write_text(_python_runner_source(), encoding="utf-8")
109
+ user_path.write_text(code, encoding="utf-8")
110
+
111
+ env = {
112
+ "PATH": "/usr/bin:/bin",
113
+ "HOME": str(workdir),
114
+ "TMPDIR": str(workdir),
115
+ "MPLBACKEND": "Agg",
116
+ "MPLCONFIGDIR": str(workdir / ".matplotlib"),
117
+ "OUTPUT_PNG": str(image_path),
118
+ "PYTHONIOENCODING": "utf-8",
119
+ "PYTHONNOUSERSITE": "1",
120
+ "PYTHONUNBUFFERED": "1",
121
+ "LANG": "C.UTF-8",
122
+ "OPENBLAS_NUM_THREADS": "1",
123
+ "OMP_NUM_THREADS": "1",
124
+ "MKL_NUM_THREADS": "1",
125
+ "NUMEXPR_NUM_THREADS": "1",
126
+ }
127
+
128
+ try:
129
+ completed = subprocess.run(
130
+ [sys.executable, "-I", str(runner_path)],
131
+ cwd=workdir,
132
+ env=env,
133
+ capture_output=True,
134
+ text=True,
135
+ encoding="utf-8",
136
+ errors="replace",
137
+ timeout=PY_TIMEOUT_S,
138
+ preexec_fn=_apply_subprocess_limits if sys.platform == "linux" else None,
139
+ check=False,
140
+ )
141
+ stdout = _truncate_output(completed.stdout)
142
+ stderr = _truncate_output(completed.stderr)
143
+
144
+ if completed.returncode and not stderr:
145
+ stderr = f"Process exited with status {completed.returncode}."
146
+
147
+ saved_image: str | None = None
148
+ if image_path.exists() and image_path.stat().st_size > 0:
149
+ saved = tempfile.NamedTemporaryFile(
150
+ prefix="fullstack_plot_", suffix=".png", delete=False
151
+ )
152
+ saved.close()
153
+ Path(saved.name).write_bytes(image_path.read_bytes())
154
+ saved_image = saved.name
155
+
156
+ return PythonExecutionResult(
157
+ stdout=stdout,
158
+ stderr=stderr,
159
+ image_path=saved_image,
160
+ returncode=completed.returncode,
161
+ )
162
+ except subprocess.TimeoutExpired as exc:
163
+ stdout = _truncate_output(_decode_timeout_output(exc.stdout))
164
+ stderr = _truncate_output(_decode_timeout_output(exc.stderr))
165
+ timeout_note = f"Timed out after {PY_TIMEOUT_S} seconds; the process was killed."
166
+ stderr = f"{stderr}\n{timeout_note}".strip()
167
+ return PythonExecutionResult(
168
+ stdout=stdout,
169
+ stderr=stderr,
170
+ image_path=None,
171
+ returncode=None,
172
+ timed_out=True,
173
+ )
code/huggingface/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """HuggingFace Hub push."""
code/huggingface/push.py ADDED
@@ -0,0 +1,147 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """HuggingFace Hub push and project ZIP packaging.
2
+
3
+ Creates ZIP archives from extracted project files and pushes
4
+ projects to HuggingFace Spaces or model repos.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ import os
11
+ import tempfile
12
+ import zipfile
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ from code.config.constants import MODEL_ID
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ def create_project_zip(files: dict[str, str], project_name: str) -> str:
22
+ """Create a ZIP file from extracted project files.
23
+
24
+ Returns the path to the created ZIP file.
25
+ """
26
+ zip_dir = tempfile.mkdtemp(prefix="fullstack_project_")
27
+ zip_path = os.path.join(zip_dir, f"{project_name}.zip")
28
+
29
+ with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
30
+ for filepath, content in files.items():
31
+ zf.writestr(f"{project_name}/{filepath}", content)
32
+
33
+ return zip_path
34
+
35
+
36
+ def push_to_huggingface(
37
+ files: dict[str, str],
38
+ project_name: str,
39
+ repo_name: str,
40
+ hf_token: str,
41
+ space_sdk: str = "static",
42
+ is_space: bool = True,
43
+ ) -> dict[str, Any]:
44
+ """Push generated project to HuggingFace Hub.
45
+
46
+ Creates the repo if it doesn't exist, writes all files,
47
+ and adds README.md and requirements.txt as needed.
48
+ """
49
+ try:
50
+ from huggingface_hub import HfApi, create_repo
51
+
52
+ api = HfApi(token=hf_token)
53
+
54
+ if "/" in repo_name:
55
+ namespace, name = repo_name.split("/", 1)
56
+ else:
57
+ user_info = api.whoami()
58
+ namespace = user_info["name"]
59
+ name = repo_name
60
+ repo_name = f"{namespace}/{name}"
61
+
62
+ try:
63
+ if is_space:
64
+ create_repo(
65
+ repo_id=repo_name,
66
+ repo_type="space",
67
+ space_sdk=space_sdk,
68
+ token=hf_token,
69
+ exist_ok=True,
70
+ )
71
+ else:
72
+ create_repo(
73
+ repo_id=repo_name,
74
+ repo_type="model",
75
+ token=hf_token,
76
+ exist_ok=True,
77
+ )
78
+ except Exception as e:
79
+ logger.warning("Repo creation warning: %s", e)
80
+
81
+ with tempfile.TemporaryDirectory(prefix="hf_push_") as tmp_dir:
82
+ for filepath, content in files.items():
83
+ full_path = os.path.join(tmp_dir, filepath)
84
+ os.makedirs(os.path.dirname(full_path), exist_ok=True)
85
+ Path(full_path).write_text(content, encoding="utf-8")
86
+
87
+ # Add README if not present
88
+ readme_path = os.path.join(tmp_dir, "README.md")
89
+ if not os.path.exists(readme_path):
90
+ readme_content = f"""---
91
+ title: {name}
92
+ emoji: 🚀
93
+ colorFrom: blue
94
+ colorTo: purple
95
+ sdk: {space_sdk}
96
+ app_file: app.py
97
+ ---
98
+
99
+ # {name}
100
+
101
+ Generated by Fullstack Code Builder using {MODEL_ID}.
102
+ """
103
+ Path(readme_path).write_text(readme_content, encoding="utf-8")
104
+
105
+ # Add requirements.txt for Python/Gradio projects
106
+ req_path = os.path.join(tmp_dir, "requirements.txt")
107
+ if not os.path.exists(req_path):
108
+ has_python = any(f.endswith(".py") for f in files.keys())
109
+ if has_python:
110
+ reqs = ["gradio>=4.0.0"]
111
+ all_code = "\n".join(files.values())
112
+ if "matplotlib" in all_code:
113
+ reqs.append("matplotlib>=3.8")
114
+ if "PIL" in all_code or "Pillow" in all_code:
115
+ reqs.append("Pillow>=10.0")
116
+ if "numpy" in all_code:
117
+ reqs.append("numpy>=1.24")
118
+ if "pandas" in all_code:
119
+ reqs.append("pandas>=2.0")
120
+ Path(req_path).write_text("\n".join(reqs) + "\n", encoding="utf-8")
121
+
122
+ api.upload_folder(
123
+ folder_path=tmp_dir,
124
+ repo_id=repo_name,
125
+ repo_type="space" if is_space else "model",
126
+ token=hf_token,
127
+ )
128
+
129
+ repo_url = f"https://huggingface.co/{repo_name}"
130
+ if is_space:
131
+ repo_url = f"https://huggingface.co/spaces/{repo_name}"
132
+
133
+ return {
134
+ "success": True,
135
+ "url": repo_url,
136
+ "repo_name": repo_name,
137
+ "message": f"Successfully pushed to {repo_url}",
138
+ }
139
+
140
+ except Exception as exc:
141
+ logger.exception("Failed to push to HuggingFace")
142
+ return {
143
+ "success": False,
144
+ "url": "",
145
+ "repo_name": repo_name,
146
+ "message": f"Failed to push: {str(exc)}",
147
+ }
code/model/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Model loading and inference."""
code/model/inference.py ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Model inference — streaming and synchronous generation.
2
+
3
+ Uses TextIteratorStreamer for real-time token streaming.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import logging
9
+ import threading
10
+ from collections.abc import Iterator
11
+ from typing import Any
12
+
13
+ from code.config.constants import DEFAULT_TEMPERATURE, DEFAULT_MAX_TOKENS
14
+ from code.model.loader import get_model, get_tokenizer, get_model_status, is_model_loaded
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ def call_model(
20
+ messages: list[dict[str, Any]],
21
+ max_new_tokens: int = DEFAULT_MAX_TOKENS,
22
+ ) -> Iterator[str]:
23
+ """Stream model text using local MiniCPM5-1B.
24
+
25
+ Yields progressively longer strings (full text so far).
26
+ """
27
+
28
+ if not is_model_loaded():
29
+ status = get_model_status()
30
+ yield status["message"]
31
+ return
32
+
33
+ model = get_model()
34
+ tokenizer = get_tokenizer()
35
+
36
+ try:
37
+ from transformers import TextIteratorStreamer
38
+ import torch
39
+
40
+ # Build the prompt from messages
41
+ prompt_parts: list[str] = []
42
+ for msg in messages:
43
+ role = msg.get("role", "user")
44
+ content = msg.get("content", "")
45
+ if role == "system":
46
+ prompt_parts.append(f"System: {content}")
47
+ elif role == "user":
48
+ prompt_parts.append(f"User: {content}")
49
+ elif role == "assistant":
50
+ prompt_parts.append(f"Assistant: {content}")
51
+ prompt_parts.append("Assistant:")
52
+ full_prompt = "\n\n".join(prompt_parts)
53
+
54
+ # Tokenize
55
+ inputs = tokenizer(full_prompt, return_tensors="pt", truncation=True, max_length=4096)
56
+ if torch.cuda.is_available():
57
+ inputs = {k: v.to("cuda") for k, v in inputs.items()}
58
+
59
+ # Stream generation
60
+ streamer = TextIteratorStreamer(tokenizer, skip_prompt=True, skip_special_tokens=True)
61
+
62
+ generation_kwargs = {
63
+ **inputs,
64
+ "streamer": streamer,
65
+ "max_new_tokens": max_new_tokens,
66
+ "temperature": DEFAULT_TEMPERATURE,
67
+ "do_sample": True,
68
+ "top_p": 0.9,
69
+ "repetition_penalty": 1.1,
70
+ "pad_token_id": tokenizer.eos_token_id,
71
+ }
72
+
73
+ # Run generation in a separate thread
74
+ thread = threading.Thread(target=model.generate, kwargs=generation_kwargs)
75
+ thread.start()
76
+
77
+ output = ""
78
+ for new_text in streamer:
79
+ output += new_text
80
+ yield output
81
+
82
+ thread.join()
83
+
84
+ except Exception as exc:
85
+ logger.exception("Error during model inference")
86
+ yield f"_Error during generation: {exc}_"
87
+
88
+
89
+ def call_model_sync(
90
+ messages: list[dict[str, Any]],
91
+ max_new_tokens: int = DEFAULT_MAX_TOKENS,
92
+ ) -> str:
93
+ """Non-streaming model call — returns complete response."""
94
+ result = ""
95
+ for chunk in call_model(messages, max_new_tokens):
96
+ result = chunk
97
+ return result
code/model/loader.py ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Model loading and status management.
2
+
3
+ Handles loading MiniCPM5-1B locally using transformers.
4
+ The model is loaded in a background thread on startup.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ import threading
11
+ from typing import Any
12
+
13
+ from code.config.constants import MODEL_ID
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ # ─── Module-level state ─────────────────────────────────────────────────
18
+
19
+ _model = None
20
+ _tokenizer = None
21
+ _model_loaded = False
22
+ _model_loading = False
23
+ _load_error: str | None = None
24
+
25
+
26
+ def load_model() -> None:
27
+ """Load MiniCPM5-1B model and tokenizer locally."""
28
+ global _model, _tokenizer, _model_loaded, _model_loading, _load_error
29
+
30
+ if _model_loaded or _model_loading:
31
+ return
32
+
33
+ _model_loading = True
34
+ _load_error = None
35
+
36
+ try:
37
+ from transformers import AutoModelForCausalLM, AutoTokenizer
38
+ import torch
39
+
40
+ logger.info("Loading MiniCPM5-1B model...")
41
+
42
+ dtype = torch.float16 if torch.cuda.is_available() else torch.float32
43
+ device_map = "auto" if torch.cuda.is_available() else None
44
+
45
+ _tokenizer = AutoTokenizer.from_pretrained(
46
+ MODEL_ID,
47
+ trust_remote_code=True,
48
+ )
49
+ _model = AutoModelForCausalLM.from_pretrained(
50
+ MODEL_ID,
51
+ torch_dtype=dtype,
52
+ device_map=device_map,
53
+ trust_remote_code=True,
54
+ low_cpu_mem_usage=True,
55
+ )
56
+
57
+ if device_map is None:
58
+ _model = _model.to("cpu")
59
+
60
+ _model.eval()
61
+ _model_loaded = True
62
+ logger.info("MiniCPM5-1B model loaded successfully.")
63
+
64
+ except Exception as exc:
65
+ _load_error = str(exc)
66
+ logger.exception("Failed to load model: %s", exc)
67
+ finally:
68
+ _model_loading = False
69
+
70
+
71
+ def start_background_load() -> threading.Thread:
72
+ """Start loading the model in a background daemon thread."""
73
+ thread = threading.Thread(target=load_model, daemon=True)
74
+ thread.start()
75
+ return thread
76
+
77
+
78
+ def get_model_status() -> dict[str, Any]:
79
+ """Return current model loading status."""
80
+ if _model_loaded:
81
+ return {"status": "ready", "message": "Model loaded and ready"}
82
+ if _model_loading:
83
+ return {"status": "loading", "message": "Model is loading... (this may take a few minutes on first run)"}
84
+ if _load_error:
85
+ return {"status": "error", "message": f"Model load error: {_load_error}"}
86
+ return {"status": "unknown", "message": "Model not initialized"}
87
+
88
+
89
+ def get_model():
90
+ """Return the loaded model instance (or None)."""
91
+ return _model
92
+
93
+
94
+ def get_tokenizer():
95
+ """Return the loaded tokenizer instance (or None)."""
96
+ return _tokenizer
97
+
98
+
99
+ def is_model_loaded() -> bool:
100
+ """Return True if the model has been loaded successfully."""
101
+ return _model_loaded
code/server/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """FastAPI / Gradio server routes."""
code/server/chat_helpers.py ADDED
@@ -0,0 +1,110 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Chat helper functions — history conversion, prompt building, iteration context."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from code.config.constants import SYSTEM_PROMPT
8
+ from code.execution.code_extractor import strip_thinking_blocks
9
+
10
+
11
+ def chat_history_to_messages(history: list[dict[str, str]]) -> list[dict[str, Any]]:
12
+ """Convert chat history list to messages format for the model.
13
+
14
+ Prepends the system prompt and strips thinking blocks from
15
+ assistant messages.
16
+ """
17
+ messages: list[dict[str, Any]] = [{"role": "system", "content": SYSTEM_PROMPT}]
18
+ for item in history:
19
+ role = item.get("role")
20
+ content = str(item.get("content") or "").strip()
21
+ if role not in {"user", "assistant"} or not content:
22
+ continue
23
+ if role == "assistant":
24
+ content = strip_thinking_blocks(content)
25
+ messages.append({"role": role, "content": content})
26
+ return messages
27
+
28
+
29
+ def clip_context(text: str, limit: int = 4_000) -> str:
30
+ """Truncate text to a character limit with a note."""
31
+ if len(text) <= limit:
32
+ return text
33
+ return text[:limit] + f"\n... truncated {len(text) - limit} characters ..."
34
+
35
+
36
+ def iteration_context(execution_context: dict[str, Any] | None) -> str:
37
+ """Build a context string from previous execution results.
38
+
39
+ This allows the model to reference prior code, stdout, and stderr
40
+ when the user asks to iterate or debug.
41
+ """
42
+ if not execution_context or not execution_context.get("code"):
43
+ return ""
44
+
45
+ code = clip_context(str(execution_context.get("code") or ""), 6_000)
46
+ target = str(execution_context.get("target") or "code")
47
+ fence_lang = str(execution_context.get("fence_lang") or target)
48
+ status = str(execution_context.get("status") or "")
49
+ stdout = clip_context(str(execution_context.get("stdout") or ""), 2_000)
50
+ stderr = clip_context(str(execution_context.get("stderr") or ""), 2_000)
51
+
52
+ parts = [
53
+ "Previous generated code and run result are available for iteration.",
54
+ f"Previous target: {target}",
55
+ f"Previous status: {status}",
56
+ f"Previous code:\n```{fence_lang}\n{code}\n```",
57
+ ]
58
+ if stdout:
59
+ parts.append(f"Previous stdout:\n{stdout}")
60
+ if stderr:
61
+ parts.append(f"Previous stderr / traceback:\n{stderr}")
62
+ parts.append(
63
+ "If the user asks to revise, debug, extend, or explain the prior code, use this context."
64
+ )
65
+ return "\n\n".join(parts)
66
+
67
+
68
+ def targeted_prompt(
69
+ prompt: str,
70
+ target_language: str,
71
+ target_framework: str = "",
72
+ execution_context: dict[str, Any] | None = None,
73
+ search_context: str = "",
74
+ ) -> str:
75
+ """Build the full user prompt with language, framework, search, and iteration context."""
76
+ iter_ctx = iteration_context(execution_context)
77
+ context_block = f"\n\n{iter_ctx}" if iter_ctx else ""
78
+
79
+ search_block = ""
80
+ if search_context:
81
+ search_block = (
82
+ f"\n\n{search_context}\n\n"
83
+ "Use the above search results to inform your code generation if relevant."
84
+ )
85
+
86
+ framework_hint = f" using {target_framework}" if target_framework else ""
87
+
88
+ gradio_hint = ""
89
+ if target_framework == "Gradio":
90
+ gradio_hint = (
91
+ "\n\nIMPORTANT: This is a Gradio app. Create a complete Python script that:\n"
92
+ "- Imports gradio as gr\n"
93
+ "- Defines the UI using gr.Interface() or gr.Blocks()\n"
94
+ "- Includes all processing logic inline\n"
95
+ "- Calls .launch(server_name='0.0.0.0', server_port=7860) at the end\n"
96
+ "- Uses only standard library + gradio + common packages (PIL, matplotlib, numpy)\n"
97
+ "- Make the UI clean, modern, and functional"
98
+ )
99
+
100
+ return (
101
+ f"Target: {target_language}{framework_hint}. Generate a complete, runnable application. "
102
+ "If the user asks for a web app, include all HTML/CSS/JS. "
103
+ "If they ask for a backend, include the server code and any API definitions. "
104
+ "For single-file apps, use a single code block. For multi-file projects, use the @@FILE: format. "
105
+ "Make the code complete, working, and well-structured."
106
+ f"{gradio_hint}"
107
+ f"{search_block}"
108
+ f"{context_block}\n\n"
109
+ f"User request:\n{prompt}"
110
+ )
code/server/routes.py ADDED
@@ -0,0 +1,428 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """FastAPI / Gradio Server routes.
2
+
3
+ Defines all HTTP and API endpoints:
4
+ - GET / → serves the index.html frontend
5
+ - GET /api/model-status → model loading status
6
+ - GET /images/{f} → serve generated plot images
7
+ - GET /download/{f} → serve project ZIP downloads
8
+ - API web_search → Google search scraping
9
+ - API chat → streaming chat with code execution
10
+ - API push_hf → push to HuggingFace Hub
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import logging
17
+ import os
18
+ import tempfile
19
+ from pathlib import Path
20
+ from typing import Any
21
+
22
+ from fastapi.responses import HTMLResponse, FileResponse
23
+ from gradio import Server
24
+
25
+ from code.config.constants import (
26
+ APP_TITLE,
27
+ EXAMPLE_PROMPTS,
28
+ LANGUAGE_OPTIONS,
29
+ MODEL_ID,
30
+ MODEL_URL,
31
+ PY_TIMEOUT_S,
32
+ )
33
+ from code.execution.code_extractor import (
34
+ build_iframe,
35
+ extract_code,
36
+ extract_multi_file,
37
+ is_gradio_code,
38
+ normalize_language,
39
+ )
40
+ from code.execution.gradio_runner import run_gradio_app, stop_gradio_app
41
+ from code.execution.python_runner import run_python
42
+ from code.huggingface.push import create_project_zip, push_to_huggingface
43
+ from code.model.loader import get_model_status, is_model_loaded
44
+ from code.model.inference import call_model
45
+ from code.server.chat_helpers import chat_history_to_messages, targeted_prompt
46
+ from code.websearch.google_scraper import web_search_google, format_search_results
47
+
48
+ logger = logging.getLogger(__name__)
49
+
50
+ # ─── Served Files Registry ──────────────────────────────────────────────
51
+
52
+ _served_files: dict[str, str] = {}
53
+
54
+ # ─── Server Instance ────────────────────────────────────────────────────
55
+
56
+ app = Server()
57
+
58
+
59
+ # ─── HTTP Routes ────────────────────────────────────────────────────────
60
+
61
+
62
+ @app.get("/", response_class=HTMLResponse)
63
+ async def homepage():
64
+ """Serve the index.html frontend with runtime config injected."""
65
+ html_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", "index.html")
66
+ with open(html_path, "r", encoding="utf-8") as f:
67
+ content = f.read()
68
+
69
+ config = json.dumps({
70
+ "app_title": APP_TITLE,
71
+ "model_id": MODEL_ID,
72
+ "model_url": MODEL_URL,
73
+ "languages": LANGUAGE_OPTIONS,
74
+ "examples": [
75
+ {"label": label, "prompt": prompt, "language": lang, "framework": fw}
76
+ for label, prompt, lang, fw in EXAMPLE_PROMPTS
77
+ ],
78
+ })
79
+ content = content.replace("__RUNTIME_CONFIG__", config)
80
+ return content
81
+
82
+
83
+ @app.get("/api/model-status")
84
+ async def model_status_endpoint():
85
+ """Return the current model loading status."""
86
+ return get_model_status()
87
+
88
+
89
+ @app.get("/images/{filename}")
90
+ async def serve_image(filename: str):
91
+ """Serve a generated plot image by filename."""
92
+ path = _served_files.get(f"img:{filename}")
93
+ if path and os.path.exists(path):
94
+ return FileResponse(path, media_type="image/png")
95
+ return HTMLResponse("Not found", status_code=404)
96
+
97
+
98
+ @app.get("/download/{filename}")
99
+ async def serve_download(filename: str):
100
+ """Serve a project ZIP download by filename."""
101
+ path = _served_files.get(f"dl:{filename}")
102
+ if path and os.path.exists(path):
103
+ return FileResponse(path, filename=filename, media_type="application/octet-stream")
104
+ return HTMLResponse("Not found", status_code=404)
105
+
106
+
107
+ # ─── Gradio API Endpoints ──────────────────────────────────────────────
108
+
109
+
110
+ @app.api(name="web_search", concurrency_limit=4)
111
+ def handle_web_search(query: str) -> str:
112
+ """Search the web using Google scraping. No API key needed."""
113
+ query = (query or "").strip()
114
+ if not query:
115
+ yield json.dumps({"success": False, "results": [], "message": "Empty search query"})
116
+ return
117
+
118
+ try:
119
+ results = web_search_google(query, num_results=8)
120
+ formatted = format_search_results(results)
121
+
122
+ yield json.dumps({
123
+ "success": True,
124
+ "results": results,
125
+ "formatted": formatted,
126
+ "message": f"Found {len(results)} results",
127
+ })
128
+ except Exception as exc:
129
+ logger.exception("Web search failed")
130
+ yield json.dumps({
131
+ "success": False,
132
+ "results": [],
133
+ "message": f"Search failed: {str(exc)}",
134
+ })
135
+
136
+
137
+ @app.api(name="chat", concurrency_limit=2)
138
+ def handle_chat(
139
+ prompt: str,
140
+ target_language: str,
141
+ target_framework: str,
142
+ history_json: str,
143
+ exec_context_json: str,
144
+ search_enabled: str = "false",
145
+ ) -> str:
146
+ """Stream chat responses with code execution. Yields JSON strings."""
147
+ history = json.loads(history_json) if history_json else []
148
+ execution_context = json.loads(exec_context_json) if exec_context_json else {}
149
+
150
+ prompt = (prompt or "").strip()
151
+ if not prompt:
152
+ yield json.dumps({
153
+ "type": "error",
154
+ "status_text": "Enter a prompt to get started.",
155
+ "status_state": "info",
156
+ "history": history,
157
+ "execution": execution_context,
158
+ })
159
+ return
160
+
161
+ # Check model status
162
+ model_status = get_model_status()
163
+ if model_status["status"] == "loading":
164
+ yield json.dumps({
165
+ "type": "error",
166
+ "status_text": model_status["message"],
167
+ "status_state": "working",
168
+ "history": history,
169
+ "execution": execution_context,
170
+ })
171
+ return
172
+ if model_status["status"] != "ready":
173
+ yield json.dumps({
174
+ "type": "error",
175
+ "status_text": model_status["message"],
176
+ "status_state": "error",
177
+ "history": history,
178
+ "execution": execution_context,
179
+ })
180
+ return
181
+
182
+ # Add user message and placeholder assistant message
183
+ history = list(history) + [
184
+ {"role": "user", "content": prompt},
185
+ {"role": "assistant", "content": ""},
186
+ ]
187
+ yield json.dumps({
188
+ "type": "status",
189
+ "status_text": "Thinking...",
190
+ "status_state": "working",
191
+ "history": history,
192
+ "execution": execution_context,
193
+ })
194
+
195
+ # Web search if enabled
196
+ search_context = ""
197
+ if search_enabled.lower() == "true":
198
+ yield json.dumps({
199
+ "type": "status",
200
+ "status_text": "Searching the web...",
201
+ "status_state": "working",
202
+ "history": history,
203
+ "execution": execution_context,
204
+ })
205
+ search_results = web_search_google(prompt, num_results=6)
206
+ if search_results:
207
+ search_context = format_search_results(search_results)
208
+ yield json.dumps({
209
+ "type": "search_results",
210
+ "status_text": f"Found {len(search_results)} results, generating code...",
211
+ "status_state": "working",
212
+ "history": history,
213
+ "execution": execution_context,
214
+ "search_results": search_results,
215
+ })
216
+
217
+ # Build messages for model
218
+ model_history = list(history[:-1])
219
+ model_history[-1] = {
220
+ "role": "user",
221
+ "content": targeted_prompt(
222
+ prompt, target_language, target_framework, execution_context, search_context
223
+ ),
224
+ }
225
+ messages = chat_history_to_messages(model_history)
226
+
227
+ final_response = ""
228
+ for partial in call_model(messages):
229
+ final_response = partial
230
+ history[-1]["content"] = partial
231
+ yield json.dumps({
232
+ "type": "streaming",
233
+ "status_text": "Generating...",
234
+ "status_state": "working",
235
+ "history": history,
236
+ "execution": execution_context,
237
+ })
238
+
239
+ if not final_response:
240
+ history[-1]["content"] = "The model did not return a response."
241
+ yield json.dumps({
242
+ "type": "error",
243
+ "status_text": "No model response.",
244
+ "status_state": "error",
245
+ "history": history,
246
+ "execution": execution_context,
247
+ })
248
+ return
249
+
250
+ # Extract code from response
251
+ code, fence_lang = extract_code(final_response)
252
+ target = normalize_language(target_language, fence_lang)
253
+
254
+ # Also try multi-file extraction
255
+ multi_files = extract_multi_file(final_response)
256
+
257
+ if not code and not multi_files:
258
+ yield json.dumps({
259
+ "type": "complete",
260
+ "status_text": "Answered without running code.",
261
+ "status_state": "info",
262
+ "history": history,
263
+ "execution": execution_context,
264
+ })
265
+ return
266
+
267
+ yield json.dumps({
268
+ "type": "status",
269
+ "status_text": "Running...",
270
+ "status_state": "working",
271
+ "history": history,
272
+ "execution": execution_context,
273
+ })
274
+
275
+ # Execute code
276
+ stdout, stderr, image_path, status_text, status_state = "", "", None, "Preview ready", "success"
277
+ is_gradio = False
278
+ gradio_url = None
279
+
280
+ if target == "python" and code:
281
+ if is_gradio_code(code) or target_framework == "Gradio":
282
+ is_gradio = True
283
+ gradio_result = run_gradio_app(code)
284
+ if gradio_result["success"]:
285
+ gradio_url = gradio_result["url"]
286
+ status_text = f"Gradio app running at {gradio_url}"
287
+ status_state = "success"
288
+ stderr = f"Gradio app launched successfully at {gradio_url}"
289
+ else:
290
+ status_text = "Gradio launch failed"
291
+ status_state = "error"
292
+ stderr = gradio_result.get("stderr", gradio_result.get("message", "Launch failed"))
293
+ else:
294
+ result = run_python(code)
295
+ if result.timed_out:
296
+ stdout, stderr, image_path = result.stdout, result.stderr, result.image_path
297
+ status_text = f"Timed out after {PY_TIMEOUT_S}s"
298
+ status_state = "error"
299
+ elif result.returncode:
300
+ stdout, stderr, image_path = result.stdout, result.stderr, result.image_path
301
+ status_text = "Finished with errors"
302
+ status_state = "error"
303
+ else:
304
+ stdout, stderr, image_path = result.stdout, result.stderr, result.image_path
305
+ status_text = "Ran successfully"
306
+ status_state = "success"
307
+
308
+ # Register image for serving
309
+ image_url = None
310
+ if image_path:
311
+ filename = os.path.basename(image_path)
312
+ _served_files[f"img:{filename}"] = image_path
313
+ image_url = f"/images/{filename}"
314
+
315
+ # Register code for download
316
+ download_url = None
317
+ project_files = multi_files if multi_files else {}
318
+
319
+ if project_files:
320
+ project_name = "generated-project"
321
+ zip_path = create_project_zip(project_files, project_name)
322
+ zip_filename = f"{project_name}.zip"
323
+ _served_files[f"dl:{zip_filename}"] = zip_path
324
+ download_url = f"/download/{zip_filename}"
325
+ elif code:
326
+ ext = "py" if target == "python" else "html"
327
+ dl_filename = f"generated.{ext}"
328
+ dl_dir = tempfile.mkdtemp(prefix="fullstack_dl_")
329
+ dl_path = os.path.join(dl_dir, dl_filename)
330
+ Path(dl_path).write_text(code, encoding="utf-8")
331
+ _served_files[f"dl:{dl_filename}"] = dl_path
332
+ download_url = f"/download/{dl_filename}"
333
+
334
+ # Determine if this is web previewable
335
+ is_web = target in {"web", "javascript", "typescript", "html"} or (fence_lang or "") in {"html", "web"}
336
+ web_code = code if is_web else None
337
+
338
+ execution_context = {
339
+ "code": code,
340
+ "target": target,
341
+ "fence_lang": fence_lang or target,
342
+ "stdout": stdout,
343
+ "stderr": stderr,
344
+ "image_url": image_url,
345
+ "image_path": image_path,
346
+ "status": status_text,
347
+ "language": fence_lang or target,
348
+ "suggested_tab": "preview" if (image_path or is_web or is_gradio) else "console",
349
+ "download_url": download_url,
350
+ "project_files": project_files,
351
+ "is_web": is_web,
352
+ "web_code": web_code,
353
+ "is_gradio": is_gradio,
354
+ "gradio_url": gradio_url,
355
+ }
356
+
357
+ yield json.dumps({
358
+ "type": "complete",
359
+ "status_text": status_text,
360
+ "status_state": status_state,
361
+ "history": history,
362
+ "execution": execution_context,
363
+ })
364
+
365
+
366
+ @app.api(name="push_hf", concurrency_limit=1)
367
+ def handle_push_hf(
368
+ exec_context_json: str,
369
+ repo_name: str,
370
+ hf_token: str,
371
+ space_sdk: str = "static",
372
+ is_space: str = "true",
373
+ ) -> str:
374
+ """Push generated project to HuggingFace Hub."""
375
+ try:
376
+ execution_context = json.loads(exec_context_json) if exec_context_json else {}
377
+ project_files = execution_context.get("project_files", {})
378
+
379
+ if not project_files:
380
+ code = execution_context.get("code", "")
381
+ if not code:
382
+ yield json.dumps({
383
+ "success": False,
384
+ "message": "No code to push. Generate some code first.",
385
+ "url": "",
386
+ })
387
+ return
388
+
389
+ lang = execution_context.get("language", "python")
390
+ is_gradio = execution_context.get("is_gradio", False)
391
+ ext_map = {
392
+ "python": "app.py", "py": "app.py",
393
+ "javascript": "index.js", "js": "index.js",
394
+ "html": "index.html", "web": "index.html",
395
+ "typescript": "index.ts", "ts": "index.ts",
396
+ }
397
+ filename = ext_map.get(lang, "app.py")
398
+ project_files = {filename: code}
399
+
400
+ # Auto-detect SDK for Gradio apps
401
+ if is_gradio or is_gradio_code(code):
402
+ space_sdk = "gradio"
403
+
404
+ project_name = repo_name.split("/")[-1] if "/" in repo_name else repo_name
405
+
406
+ result = push_to_huggingface(
407
+ files=project_files,
408
+ project_name=project_name,
409
+ repo_name=repo_name,
410
+ hf_token=hf_token,
411
+ space_sdk=space_sdk,
412
+ is_space=is_space.lower() == "true",
413
+ )
414
+
415
+ yield json.dumps(result)
416
+
417
+ except Exception as exc:
418
+ logger.exception("Push to HuggingFace failed")
419
+ yield json.dumps({
420
+ "success": False,
421
+ "message": f"Push failed: {str(exc)}",
422
+ "url": "",
423
+ })
424
+
425
+
426
+ def get_app() -> Server:
427
+ """Return the configured Gradio Server app instance."""
428
+ return app
code/websearch/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Web search (Google scraping, no API)."""
code/websearch/google_scraper.py ADDED
@@ -0,0 +1,132 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Web search via Google scraping — no API key needed.
2
+
3
+ Uses requests with a browser-like User-Agent and BeautifulSoup
4
+ to parse Google search result pages.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ import urllib.parse
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ def web_search_google(query: str, num_results: int = 8) -> list[dict[str, str]]:
16
+ """Search Google by scraping the results page. No API key needed.
17
+
18
+ Returns a list of dicts with keys: title, url, snippet.
19
+ Uses requests with a browser-like User-Agent to avoid captchas.
20
+ """
21
+ try:
22
+ import requests
23
+ from bs4 import BeautifulSoup
24
+
25
+ encoded_query = urllib.parse.quote_plus(query)
26
+ url = f"https://www.google.com/search?q={encoded_query}&num={num_results + 2}&hl=en"
27
+
28
+ headers = {
29
+ "User-Agent": (
30
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
31
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
32
+ "Chrome/120.0.0.0 Safari/537.36"
33
+ ),
34
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
35
+ "Accept-Language": "en-US,en;q=0.5",
36
+ "Accept-Encoding": "gzip, deflate",
37
+ "DNT": "1",
38
+ "Connection": "keep-alive",
39
+ "Upgrade-Insecure-Requests": "1",
40
+ }
41
+
42
+ resp = requests.get(url, headers=headers, timeout=10, allow_redirects=True)
43
+ resp.raise_for_status()
44
+
45
+ soup = BeautifulSoup(resp.text, "html.parser")
46
+ results: list[dict[str, str]] = []
47
+
48
+ # Parse Google search results
49
+ for g_div in soup.select("div.g, div[data-sokoban-container], div.yuRUbf"):
50
+ title_el = g_div.select_one("h3")
51
+ link_el = g_div.select_one("a[href]")
52
+ snippet_el = g_div.select_one("div.VwiC3b, span.aCOpRe, div[data-sncf]")
53
+
54
+ if not title_el or not link_el:
55
+ continue
56
+
57
+ href = link_el.get("href", "")
58
+ # Google sometimes prefixes URLs; extract the real URL
59
+ if href.startswith("/url?q="):
60
+ real_url = urllib.parse.parse_qs(
61
+ urllib.parse.urlparse(href).query
62
+ ).get("q", [href])[0]
63
+ elif href.startswith("http"):
64
+ real_url = href
65
+ else:
66
+ continue
67
+
68
+ # Skip Google-internal URLs
69
+ if "google.com" in real_url or "googleusercontent.com" in real_url:
70
+ continue
71
+
72
+ title = title_el.get_text(strip=True)
73
+ snippet = snippet_el.get_text(strip=True) if snippet_el else ""
74
+
75
+ if title and real_url:
76
+ results.append({
77
+ "title": title,
78
+ "url": real_url,
79
+ "snippet": snippet,
80
+ })
81
+
82
+ if len(results) >= num_results:
83
+ break
84
+
85
+ # Fallback: try parsing from <a> tags with data-ved attribute
86
+ if not results:
87
+ for a_tag in soup.select("a[data-ved]"):
88
+ href = a_tag.get("href", "")
89
+ if not href.startswith("http"):
90
+ continue
91
+ if "google.com" in href:
92
+ continue
93
+
94
+ title_el = a_tag.select_one("h3, span")
95
+ title = title_el.get_text(strip=True) if title_el else a_tag.get_text(strip=True)[:100]
96
+ snippet = ""
97
+
98
+ if title and href:
99
+ results.append({
100
+ "title": title,
101
+ "url": href,
102
+ "snippet": snippet,
103
+ })
104
+
105
+ if len(results) >= num_results:
106
+ break
107
+
108
+ logger.info("Web search for '%s' returned %d results", query, len(results))
109
+ return results
110
+
111
+ except ImportError:
112
+ logger.warning("requests or beautifulsoup4 not installed for web search")
113
+ return []
114
+ except Exception as exc:
115
+ logger.exception("Web search failed: %s", exc)
116
+ return []
117
+
118
+
119
+ def format_search_results(results: list[dict[str, str]]) -> str:
120
+ """Format search results into a text block for model context."""
121
+ if not results:
122
+ return "No search results found."
123
+
124
+ parts = ["Here are the web search results for reference:\n"]
125
+ for i, r in enumerate(results, 1):
126
+ parts.append(f"{i}. {r['title']}")
127
+ parts.append(f" URL: {r['url']}")
128
+ if r["snippet"]:
129
+ parts.append(f" {r['snippet']}")
130
+ parts.append("")
131
+
132
+ return "\n".join(parts)