dharun2099 commited on
Commit
0ed8d20
·
verified ·
1 Parent(s): f487d41

Upload 3 files

Browse files
Files changed (3) hide show
  1. README.md +69 -6
  2. app.py +311 -0
  3. requirements.txt +2 -0
README.md CHANGED
@@ -1,14 +1,77 @@
1
  ---
2
- title: Miniagent
3
  emoji: ⚡
4
- colorFrom: yellow
5
- colorTo: red
6
  sdk: gradio
7
- sdk_version: 6.13.0
8
  app_file: app.py
9
  pinned: false
10
  license: mit
11
- short_description: 'A compact coding agent '
12
  ---
13
 
14
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: Mini Coding Agent
3
  emoji: ⚡
4
+ colorFrom: purple
5
+ colorTo: blue
6
  sdk: gradio
7
+ sdk_version: 4.44.0
8
  app_file: app.py
9
  pinned: false
10
  license: mit
 
11
  ---
12
 
13
+ # Mini Coding Agent
14
+
15
+ A multi-file project scaffolding agent. Describe what you want to build,
16
+ the agent emits a complete runnable codebase you can download as a `.zip`.
17
+
18
+ ## Architecture
19
+
20
+ ```
21
+ Browser ──► Gradio (Python, app.py) ──► Elixir backend (HTTP) ──► LLM provider
22
+
23
+ ╲► (fallback: direct LLM call from Python)
24
+ ```
25
+
26
+ - **Frontend** — `app.py`: Gradio chat UI, provider picker, API-key prompt,
27
+ streaming tokens, file extraction, zip download.
28
+ - **Backend** — `elixir_backend/`: Elixir/Plug HTTP service that holds the
29
+ agent loop and proxies to OpenAI / Anthropic / Groq with SSE streaming.
30
+ - **Fallback** — if the Elixir backend is unreachable (the default on a
31
+ plain Gradio Space), the Python frontend talks to the LLM directly,
32
+ so the Space works out-of-the-box.
33
+
34
+ ## Deploy options on Hugging Face
35
+
36
+ ### Option 1 — Gradio SDK Space (simplest, recommended)
37
+
38
+ Just push `app.py` + `requirements.txt` + this README to a Gradio Space.
39
+ The Elixir backend is skipped (frontend uses the direct fallback).
40
+ Set `ELIXIR_BACKEND_URL` to a non-existent URL or leave default.
41
+
42
+ ### Option 2 — Docker Space (Python + Elixir together)
43
+
44
+ Use the included `Dockerfile` to run both processes in one container.
45
+ Change your Space SDK to `docker` in this README's frontmatter:
46
+
47
+ ```yaml
48
+ sdk: docker
49
+ app_port: 7860
50
+ ```
51
+
52
+ ## Local dev
53
+
54
+ ```bash
55
+ # Terminal 1 — Elixir backend
56
+ cd elixir_backend
57
+ mix deps.get
58
+ mix run --no-halt # listens on :4000
59
+
60
+ # Terminal 2 — Gradio frontend
61
+ pip install -r requirements.txt
62
+ python app.py # http://localhost:7860
63
+ ```
64
+
65
+ ## Providers
66
+
67
+ | Provider | Model | Get a key |
68
+ | --------- | ---------------------------- | -------------------------------------------------- |
69
+ | OpenAI | `gpt-4o-mini` | https://platform.openai.com/api-keys |
70
+ | Anthropic | `claude-3-5-sonnet-latest` | https://console.anthropic.com/settings/keys |
71
+ | Groq | `llama-3.3-70b-versatile` | https://console.groq.com/keys (free tier) |
72
+
73
+ API keys stay in the Gradio session — they are never persisted.
74
+
75
+ ## License
76
+
77
+ MIT
app.py ADDED
@@ -0,0 +1,311 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Mini Coding Agent — Gradio frontend for Hugging Face Spaces.
3
+
4
+ Architecture:
5
+ Browser → Gradio (this file, Python) → Elixir backend (HTTP) → LLM provider
6
+ ↘ (or directly to LLM if backend is offline)
7
+
8
+ The Elixir backend (see elixir_backend/) handles the actual agent loop:
9
+ prompt construction, multi-file project planning, and provider dispatch.
10
+ This file is purely UI + a thin HTTP client.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import io
16
+ import json
17
+ import os
18
+ import re
19
+ import time
20
+ import zipfile
21
+ from typing import Iterator
22
+
23
+ import gradio as gr
24
+ import requests
25
+
26
+ # --------------------------------------------------------------------------- #
27
+ # Config
28
+ # --------------------------------------------------------------------------- #
29
+
30
+ ELIXIR_BACKEND_URL = os.getenv("ELIXIR_BACKEND_URL", "http://localhost:4000")
31
+ REQUEST_TIMEOUT = 180
32
+
33
+ PROVIDERS = {
34
+ "OpenAI (gpt-4o-mini)": {"id": "openai", "model": "gpt-4o-mini",
35
+ "key_hint": "sk-...", "url": "https://platform.openai.com/api-keys"},
36
+ "Anthropic (claude-3-5-sonnet)": {"id": "anthropic", "model": "claude-3-5-sonnet-latest",
37
+ "key_hint": "sk-ant-...", "url": "https://console.anthropic.com/settings/keys"},
38
+ "Groq (llama-3.3-70b, free)": {"id": "groq", "model": "llama-3.3-70b-versatile",
39
+ "key_hint": "gsk_...", "url": "https://console.groq.com/keys"},
40
+ }
41
+
42
+ SYSTEM_PROMPT = """You are a senior software engineer acting as a project scaffolding agent.
43
+
44
+ When the user describes a project, output a complete, runnable codebase as multiple files.
45
+
46
+ OUTPUT FORMAT — strictly follow this:
47
+ For each file, emit a fenced code block whose info string is the file path, e.g.:
48
+
49
+ ```python:src/main.py
50
+ # code here
51
+ ```
52
+
53
+ ```text:README.md
54
+ # Project
55
+ ```
56
+
57
+ Rules:
58
+ - Use the language identifier before the colon (python, javascript, typescript, html, css, json, text, etc.).
59
+ - The path AFTER the colon is the file path relative to the project root.
60
+ - Include a README.md and any config files (requirements.txt, package.json, etc.) needed to run the project.
61
+ - Keep files focused and small. Prefer 3–8 files over one giant file.
62
+ - After the files, add a short "How to run" section in plain prose.
63
+ """
64
+
65
+ # --------------------------------------------------------------------------- #
66
+ # Backend client
67
+ # --------------------------------------------------------------------------- #
68
+
69
+ def call_elixir_backend(provider: str, model: str, api_key: str,
70
+ history: list[dict]) -> Iterator[str]:
71
+ """Stream tokens from the Elixir backend. Falls back to direct provider call."""
72
+ try:
73
+ with requests.post(
74
+ f"{ELIXIR_BACKEND_URL}/api/agent/stream",
75
+ json={"provider": provider, "model": model, "api_key": api_key,
76
+ "system": SYSTEM_PROMPT, "messages": history},
77
+ stream=True, timeout=REQUEST_TIMEOUT,
78
+ ) as r:
79
+ r.raise_for_status()
80
+ for line in r.iter_lines(decode_unicode=True):
81
+ if not line or not line.startswith("data: "):
82
+ continue
83
+ payload = line[6:]
84
+ if payload == "[DONE]":
85
+ return
86
+ try:
87
+ chunk = json.loads(payload)
88
+ except json.JSONDecodeError:
89
+ continue
90
+ if "delta" in chunk:
91
+ yield chunk["delta"]
92
+ elif "error" in chunk:
93
+ yield f"\n\n**Backend error:** {chunk['error']}"
94
+ return
95
+ except requests.exceptions.RequestException as e:
96
+ yield f"⚠️ Elixir backend unreachable ({e}). Falling back to direct call.\n\n"
97
+ yield from call_provider_direct(provider, model, api_key, history)
98
+
99
+
100
+ def call_provider_direct(provider: str, model: str, api_key: str,
101
+ history: list[dict]) -> Iterator[str]:
102
+ """Fallback: call the LLM provider directly from Python (no streaming, simpler)."""
103
+ messages = [{"role": "system", "content": SYSTEM_PROMPT}] + history
104
+
105
+ if provider in ("openai", "groq"):
106
+ url = ("https://api.openai.com/v1/chat/completions" if provider == "openai"
107
+ else "https://api.groq.com/openai/v1/chat/completions")
108
+ headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
109
+ body = {"model": model, "messages": messages, "stream": True}
110
+ try:
111
+ with requests.post(url, headers=headers, json=body, stream=True,
112
+ timeout=REQUEST_TIMEOUT) as r:
113
+ r.raise_for_status()
114
+ for line in r.iter_lines(decode_unicode=True):
115
+ if not line or not line.startswith("data: "):
116
+ continue
117
+ data = line[6:]
118
+ if data == "[DONE]":
119
+ return
120
+ try:
121
+ delta = json.loads(data)["choices"][0]["delta"].get("content", "")
122
+ except (json.JSONDecodeError, KeyError, IndexError):
123
+ continue
124
+ if delta:
125
+ yield delta
126
+ except requests.exceptions.HTTPError as e:
127
+ yield f"\n\n**Provider error:** {e.response.status_code} {e.response.text[:300]}"
128
+ except Exception as e:
129
+ yield f"\n\n**Error:** {e}"
130
+
131
+ elif provider == "anthropic":
132
+ url = "https://api.anthropic.com/v1/messages"
133
+ headers = {"x-api-key": api_key, "anthropic-version": "2023-06-01",
134
+ "Content-Type": "application/json"}
135
+ body = {"model": model, "max_tokens": 4096, "system": SYSTEM_PROMPT,
136
+ "messages": history, "stream": True}
137
+ try:
138
+ with requests.post(url, headers=headers, json=body, stream=True,
139
+ timeout=REQUEST_TIMEOUT) as r:
140
+ r.raise_for_status()
141
+ for line in r.iter_lines(decode_unicode=True):
142
+ if not line or not line.startswith("data: "):
143
+ continue
144
+ try:
145
+ evt = json.loads(line[6:])
146
+ except json.JSONDecodeError:
147
+ continue
148
+ if evt.get("type") == "content_block_delta":
149
+ delta = evt.get("delta", {}).get("text", "")
150
+ if delta:
151
+ yield delta
152
+ except requests.exceptions.HTTPError as e:
153
+ yield f"\n\n**Provider error:** {e.response.status_code} {e.response.text[:300]}"
154
+ except Exception as e:
155
+ yield f"\n\n**Error:** {e}"
156
+
157
+
158
+ # --------------------------------------------------------------------------- #
159
+ # File extraction
160
+ # --------------------------------------------------------------------------- #
161
+
162
+ FILE_BLOCK_RE = re.compile(
163
+ r"```([a-zA-Z0-9_+\-]*?):([^\n`]+)\n(.*?)```",
164
+ re.DOTALL,
165
+ )
166
+
167
+
168
+ def extract_files(markdown: str) -> dict[str, str]:
169
+ """Pull `lang:path\\n...` fenced blocks into a {path: content} dict."""
170
+ files: dict[str, str] = {}
171
+ for _lang, path, body in FILE_BLOCK_RE.findall(markdown):
172
+ path = path.strip()
173
+ if path:
174
+ files[path] = body.rstrip() + "\n"
175
+ return files
176
+
177
+
178
+ def make_zip(files: dict[str, str]) -> str | None:
179
+ if not files:
180
+ return None
181
+ path = "/tmp/generated_project.zip"
182
+ with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as zf:
183
+ for name, content in files.items():
184
+ zf.writestr(name, content)
185
+ return path
186
+
187
+
188
+ # --------------------------------------------------------------------------- #
189
+ # Gradio handlers
190
+ # --------------------------------------------------------------------------- #
191
+
192
+ def chat_fn(message: str, history: list[dict], provider_label: str, api_key: str):
193
+ if not api_key.strip():
194
+ yield history + [
195
+ {"role": "user", "content": message},
196
+ {"role": "assistant", "content": "🔑 Please paste your API key in the panel on the left first."},
197
+ ], gr.update(), gr.update(visible=False)
198
+ return
199
+ if not message.strip():
200
+ yield history, gr.update(), gr.update(visible=False)
201
+ return
202
+
203
+ cfg = PROVIDERS[provider_label]
204
+ convo = history + [{"role": "user", "content": message}]
205
+
206
+ assistant_buf = ""
207
+ yield convo + [{"role": "assistant", "content": "▍"}], gr.update(), gr.update(visible=False)
208
+
209
+ for tok in call_elixir_backend(cfg["id"], cfg["model"], api_key.strip(), convo):
210
+ assistant_buf += tok
211
+ yield convo + [{"role": "assistant", "content": assistant_buf + "▍"}], gr.update(), gr.update(visible=False)
212
+ time.sleep(0) # cooperative yield
213
+
214
+ final = convo + [{"role": "assistant", "content": assistant_buf}]
215
+ files = extract_files(assistant_buf)
216
+ zip_path = make_zip(files)
217
+
218
+ file_summary = ""
219
+ if files:
220
+ file_summary = "### 📁 Generated files\n" + "\n".join(f"- `{p}`" for p in files)
221
+
222
+ yield final, file_summary, gr.update(value=zip_path, visible=bool(zip_path))
223
+
224
+
225
+ def clear_chat():
226
+ return [], "", gr.update(visible=False)
227
+
228
+
229
+ # --------------------------------------------------------------------------- #
230
+ # UI
231
+ # --------------------------------------------------------------------------- #
232
+
233
+ CUSTOM_CSS = """
234
+ .gradio-container { max-width: 1280px !important; }
235
+ #title { text-align: center; padding: 8px 0 4px; }
236
+ #title h1 { background: linear-gradient(90deg,#7c3aed,#06b6d4); -webkit-background-clip: text;
237
+ -webkit-text-fill-color: transparent; font-size: 2.2rem; margin: 0; }
238
+ #subtitle { text-align: center; color: #94a3b8; margin-bottom: 12px; }
239
+ .api-panel { border: 1px solid rgba(124,58,237,.25); border-radius: 14px; padding: 14px;
240
+ background: linear-gradient(180deg, rgba(124,58,237,.05), rgba(6,182,212,.03)); }
241
+ footer { display: none !important; }
242
+ """
243
+
244
+ with gr.Blocks(theme=gr.themes.Soft(primary_hue="violet", secondary_hue="cyan"),
245
+ css=CUSTOM_CSS, title="Mini Coding Agent") as demo:
246
+
247
+ gr.HTML('<div id="title"><h1>⚡ Mini Coding Agent</h1></div>'
248
+ '<div id="subtitle">Multi-file project scaffolding · Python frontend · Elixir backend</div>')
249
+
250
+ with gr.Row():
251
+ with gr.Column(scale=1, min_width=280):
252
+ with gr.Group(elem_classes="api-panel"):
253
+ gr.Markdown("### 🔐 Provider")
254
+ provider = gr.Dropdown(
255
+ choices=list(PROVIDERS.keys()),
256
+ value="Groq (llama-3.3-70b, free)",
257
+ label="Model provider",
258
+ info="Groq is free & fast — great for demos.",
259
+ )
260
+ api_key = gr.Textbox(
261
+ label="API key",
262
+ placeholder="Paste your key…",
263
+ type="password",
264
+ info="Stays in your browser session. Never stored.",
265
+ )
266
+ key_links = gr.Markdown(
267
+ "\n".join(f"- [{name}]({cfg['url']})" for name, cfg in PROVIDERS.items())
268
+ )
269
+
270
+ gr.Markdown("### 💡 Try")
271
+ gr.Examples(
272
+ examples=[
273
+ "Build a Flask todo API with SQLite, including tests.",
274
+ "Create a small React + Vite landing page for a coffee shop.",
275
+ "Write a Python CLI that scrapes Hacker News front page to JSON.",
276
+ "Scaffold a FastAPI app with JWT auth and a users endpoint.",
277
+ ],
278
+ inputs=None, # filled below
279
+ label=None,
280
+ )
281
+
282
+ with gr.Column(scale=3):
283
+ chatbot = gr.Chatbot(
284
+ type="messages", height=540, show_label=False,
285
+ avatar_images=(None, "https://api.dicebear.com/7.x/bottts/svg?seed=agent"),
286
+ render_markdown=True,
287
+ )
288
+ with gr.Row():
289
+ msg = gr.Textbox(placeholder="Describe the project you want to build…",
290
+ show_label=False, scale=8, container=False)
291
+ send = gr.Button("Send", variant="primary", scale=1)
292
+ clear = gr.Button("Clear", scale=1)
293
+
294
+ files_md = gr.Markdown("")
295
+ zip_dl = gr.File(label="Download project (.zip)", visible=False)
296
+
297
+ # Wire examples now that `msg` exists
298
+ demo.load(lambda: None) # no-op; placeholder
299
+
300
+ send.click(chat_fn, [msg, chatbot, provider, api_key],
301
+ [chatbot, files_md, zip_dl]).then(lambda: "", None, msg)
302
+ msg.submit(chat_fn, [msg, chatbot, provider, api_key],
303
+ [chatbot, files_md, zip_dl]).then(lambda: "", None, msg)
304
+ clear.click(clear_chat, None, [chatbot, files_md, zip_dl])
305
+
306
+
307
+ if __name__ == "__main__":
308
+ demo.queue(default_concurrency_limit=4).launch(
309
+ server_name="0.0.0.0",
310
+ server_port=int(os.getenv("PORT", 7860)),
311
+ )
requirements.txt ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ gradio>=4.44.0
2
+ requests>=2.31.0