R-Kentaren commited on
Commit
0235f63
Β·
verified Β·
1 Parent(s): e26c426

rewrite: use gradio.Server + @app .api() + custom HTML (proper Gradio 6.x pattern)

Browse files
Files changed (2) hide show
  1. index.html +135 -240
  2. sonicoder/server/__init__.py +206 -235
index.html CHANGED
@@ -4,11 +4,6 @@
4
  <meta charset="UTF-8">
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
  <title>SoniCoder v2 β€” AI Code Agent</title>
7
- <!-- Gradio JS Client β€” talks to @app.api() endpoints through Gradio's queue -->
8
- <script type="module">
9
- import { Client } from "https://cdn.jsdelivr.net/npm/@gradio/client/dist/index.min.js";
10
- window.__gradio_client = { Client };
11
- </script>
12
  <style>
13
  :root {
14
  --bg-primary: #0f0f13;
@@ -34,14 +29,12 @@ window.__gradio_client = { Client };
34
  * { margin:0; padding:0; box-sizing:border-box; }
35
  body { font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background:var(--bg-primary); color:var(--text-primary); height:100vh; overflow:hidden; }
36
 
37
- /* Layout */
38
  .app { display:flex; height:100vh; }
39
  .sidebar { width:280px; background:var(--bg-secondary); border-right:1px solid var(--border); display:flex; flex-direction:column; flex-shrink:0; }
40
  .main { flex:1; display:flex; flex-direction:column; min-width:0; }
41
  .workspace-panel { width:300px; background:var(--bg-secondary); border-left:1px solid var(--border); display:flex; flex-direction:column; flex-shrink:0; transition:width 0.3s; overflow:hidden; }
42
  .workspace-panel.collapsed { width:0; border-left:none; }
43
 
44
- /* Sidebar */
45
  .sidebar-header { padding:20px; border-bottom:1px solid var(--border); }
46
  .logo { font-size:20px; font-weight:700; background:linear-gradient(135deg, #7c6ef0, #a78bfa, #60a5fa); -webkit-background-clip:text; -webkit-text-fill-color:transparent; }
47
  .logo-sub { font-size:11px; color:var(--text-muted); margin-top:2px; letter-spacing:1px; text-transform:uppercase; }
@@ -62,11 +55,9 @@ window.__gradio_client = { Client };
62
  .sidebar-section h3 { font-size:11px; color:var(--text-muted); text-transform:uppercase; letter-spacing:0.5px; margin-bottom:12px; }
63
  .sidebar-info { font-size:12px; color:var(--text-secondary); line-height:1.6; }
64
  .sidebar-info code { background:var(--bg-tertiary); padding:2px 6px; border-radius:4px; font-size:11px; color:var(--accent); }
65
-
66
  .new-chat-btn { margin:16px 20px; padding:10px; background:var(--accent); color:white; border:none; border-radius:var(--radius-sm); font-size:13px; font-weight:600; cursor:pointer; transition:all 0.2s; }
67
  .new-chat-btn:hover { background:var(--accent-hover); transform:translateY(-1px); }
68
 
69
- /* Chat Area */
70
  .chat-header { padding:16px 24px; border-bottom:1px solid var(--border); display:flex; align-items:center; justify-content:space-between; }
71
  .chat-header h2 { font-size:15px; font-weight:600; }
72
  .header-actions { display:flex; gap:8px; }
@@ -95,7 +86,6 @@ window.__gradio_client = { Client };
95
  .message-body ul, .message-body ol { padding-left:20px; margin:8px 0; }
96
  .message-body li { margin:4px 0; }
97
 
98
- /* Tool Calls */
99
  .tool-call { background:var(--bg-tertiary); border:1px solid var(--border); border-radius:var(--radius-sm); margin:12px 0; padding:12px 16px; }
100
  .tool-call-header { display:flex; align-items:center; gap:8px; margin-bottom:8px; }
101
  .tool-icon { font-size:14px; }
@@ -108,11 +98,8 @@ window.__gradio_client = { Client };
108
  .tool-result { font-size:12px; background:var(--bg-primary); border-radius:6px; padding:8px 12px; max-height:300px; overflow-y:auto; white-space:pre-wrap; word-break:break-all; font-family:'JetBrains Mono',monospace; line-height:1.5; color:var(--text-secondary); }
109
  .tool-result::-webkit-scrollbar { width:4px; }
110
  .tool-result::-webkit-scrollbar-thumb { background:var(--border); border-radius:2px; }
111
-
112
- /* Iteration Badge */
113
  .iteration-badge { display:inline-flex; align-items:center; gap:4px; font-size:11px; color:var(--text-muted); background:var(--bg-tertiary); padding:2px 10px; border-radius:10px; margin-bottom:8px; }
114
 
115
- /* Input Area */
116
  .chat-input { padding:16px 24px 24px; border-top:1px solid var(--border); }
117
  .input-wrapper { display:flex; align-items:flex-end; gap:12px; background:var(--bg-tertiary); border:1px solid var(--border); border-radius:var(--radius); padding:12px 16px; transition:border-color 0.2s; }
118
  .input-wrapper:focus-within { border-color:var(--accent); }
@@ -124,34 +111,22 @@ window.__gradio_client = { Client };
124
  .stop-btn { padding:8px 20px; background:var(--error); color:white; border:none; border-radius:var(--radius-sm); font-size:13px; font-weight:600; cursor:pointer; }
125
  .input-hint { font-size:11px; color:var(--text-muted); margin-top:8px; text-align:center; }
126
 
127
- /* Workspace Panel */
128
  .ws-header { padding:16px; border-bottom:1px solid var(--border); display:flex; align-items:center; justify-content:space-between; }
129
  .ws-header h3 { font-size:13px; font-weight:600; }
130
  .ws-tree { flex:1; overflow-y:auto; padding:12px 16px; font-family:'JetBrains Mono',monospace; font-size:12px; line-height:1.8; color:var(--text-secondary); white-space:pre; }
131
  .ws-tree::-webkit-scrollbar { width:4px; }
132
  .ws-tree::-webkit-scrollbar-thumb { background:var(--border); border-radius:2px; }
133
 
134
- /* Typing indicator */
135
  .typing-indicator { display:flex; gap:4px; padding:8px 0; }
136
  .typing-dot { width:6px; height:6px; border-radius:50%; background:var(--accent); animation:typingBounce 1.4s infinite; }
137
  .typing-dot:nth-child(2) { animation-delay:0.2s; }
138
  .typing-dot:nth-child(3) { animation-delay:0.4s; }
139
  @keyframes typingBounce { 0%,60%,100%{transform:translateY(0)} 30%{transform:translateY(-6px)} }
140
 
141
- /* Example prompts */
142
  .examples { padding:16px 20px; }
143
  .example-btn { display:block; width:100%; text-align:left; padding:10px 12px; background:var(--bg-tertiary); color:var(--text-secondary); border:1px solid var(--border); border-radius:var(--radius-sm); font-size:12px; margin-bottom:8px; cursor:pointer; transition:all 0.2s; line-height:1.4; }
144
  .example-btn:hover { background:var(--bg-hover); color:var(--text-primary); border-color:var(--accent); }
145
 
146
- /* Loading overlay */
147
- .loading-overlay { position:fixed; inset:0; background:var(--bg-primary); display:flex; align-items:center; justify-content:center; z-index:9999; transition:opacity 0.3s; }
148
- .loading-overlay.hidden { opacity:0; pointer-events:none; }
149
- .loading-spinner { text-align:center; }
150
- .loading-spinner .spinner { width:32px; height:32px; border:3px solid var(--border); border-top-color:var(--accent); border-radius:50%; animation:spin 0.8s linear infinite; margin:0 auto 12px; }
151
- @keyframes spin { to{transform:rotate(360deg)} }
152
- .loading-spinner p { color:var(--text-secondary); font-size:13px; }
153
-
154
- /* Responsive */
155
  @media (max-width:900px) {
156
  .sidebar { display:none; }
157
  .workspace-panel { display:none; }
@@ -159,42 +134,27 @@ window.__gradio_client = { Client };
159
  </style>
160
  </head>
161
  <body>
162
-
163
- <!-- Loading overlay while Gradio client connects -->
164
- <div class="loading-overlay" id="loadingOverlay">
165
- <div class="loading-spinner">
166
- <div class="spinner"></div>
167
- <p>Connecting to SoniCoder...</p>
168
- </div>
169
- </div>
170
-
171
  <div class="app">
172
- <!-- Sidebar -->
173
  <aside class="sidebar">
174
  <div class="sidebar-header">
175
  <div class="logo">SoniCoder v2</div>
176
  <div class="logo-sub">AI Code Agent</div>
177
  </div>
178
-
179
  <div class="model-selector">
180
  <label>Model</label>
181
  <select id="modelSelect"></select>
182
  </div>
183
-
184
  <div class="model-status">
185
  <span class="status-dot unknown" id="statusDot"></span>
186
- <span class="status-text" id="statusText">Connecting...</span>
187
  </div>
188
-
189
  <button class="new-chat-btn" id="newChatBtn">+ New Chat</button>
190
-
191
  <div class="sidebar-section">
192
  <h3>Quick Start</h3>
193
  <div class="sidebar-info">
194
- Ask me to build anything β€” a web app, API, script, or full-stack project. I'll create the files and verify they work.
195
  </div>
196
  </div>
197
-
198
  <div class="examples" id="examplesPanel">
199
  <div class="example-btn" data-example="Build a Flask REST API with user CRUD endpoints">Build a Flask REST API with user CRUD endpoints</div>
200
  <div class="example-btn" data-example="Create a React todo app with localStorage persistence">Create a React todo app with localStorage persistence</div>
@@ -204,7 +164,6 @@ window.__gradio_client = { Client };
204
  </div>
205
  </aside>
206
 
207
- <!-- Main Chat -->
208
  <div class="main">
209
  <div class="chat-header">
210
  <h2>Chat</h2>
@@ -213,7 +172,6 @@ window.__gradio_client = { Client };
213
  <button class="header-btn" id="refreshWsBtn">Refresh</button>
214
  </div>
215
  </div>
216
-
217
  <div class="chat-messages" id="chatMessages">
218
  <div class="message" id="welcomeMsg" style="text-align:center; padding:60px 20px;">
219
  <div style="font-size:28px; margin-bottom:12px; opacity:0.6;">SoniCoder v2</div>
@@ -222,7 +180,6 @@ window.__gradio_client = { Client };
222
  </div>
223
  </div>
224
  </div>
225
-
226
  <div class="chat-input">
227
  <div class="input-wrapper">
228
  <textarea id="chatInput" placeholder="Describe what you want to build..." rows="1"></textarea>
@@ -232,148 +189,120 @@ window.__gradio_client = { Client };
232
  </div>
233
  </div>
234
 
235
- <!-- Workspace Panel -->
236
  <div class="workspace-panel" id="workspacePanel">
237
- <div class="ws-header">
238
- <h3>Workspace</h3>
239
- </div>
240
  <div class="ws-tree" id="wsTree">Loading...</div>
241
  </div>
242
  </div>
243
 
244
- <script type="module">
245
- // ── Gradio JS Client ──────────────────────────────────────────────────
246
- // All non-streaming API calls go through @gradio/client β†’ Gradio queue.
247
- // Only the streaming /chat endpoint uses raw fetch (FastAPI StreamingResponse).
248
-
249
- import { Client } from "https://cdn.jsdelivr.net/npm/@gradio/client/dist/index.min.js";
250
-
251
- let client = null; // Gradio JS Client instance
252
- let sessionId = '';
253
- let isStreaming = false;
254
- let currentToolCalls = {};
255
-
256
- // ── Init: connect Gradio client, then load UI ────────────��───────────
257
-
258
- async function init() {
259
- try {
260
- client = await Client.connect(window.location.origin);
261
- console.log('Gradio client connected');
262
-
263
- // Load config + model list
264
- await loadConfig();
265
- // Start polling model status
266
- pollModelStatus();
267
- // Load workspace tree
268
- await refreshWorkspace();
269
- // Hide loading overlay
270
- document.getElementById('loadingOverlay').classList.add('hidden');
271
- } catch (e) {
272
- console.error('Init failed:', e);
273
- document.querySelector('.loading-spinner p').textContent =
274
- 'Connection failed. Retrying...';
275
- setTimeout(init, 3000);
276
- }
277
- }
278
 
279
- // ── Config: load model list via @app.api("get_config") ────────────────
280
 
281
  async function loadConfig() {
282
- const result = await client.predict('/get_config', {});
283
- // @app.api() wraps return in result.data[0]
284
- const config = JSON.parse(result.data[0]);
285
-
286
- const select = document.getElementById('modelSelect');
287
- select.innerHTML = '';
288
- (config.models || []).forEach(m => {
289
- const opt = document.createElement('option');
290
- opt.value = m.key;
291
- opt.textContent = `${m.name} (${m.size_gb}GB)`;
292
- if (m.key === config.default_model) opt.selected = true;
293
- select.appendChild(opt);
294
- });
295
-
296
- select.addEventListener('change', () => switchModel(select.value));
297
  }
298
 
299
- // ── Model status: poll via @app.api("model_status") ──────────────────
300
-
301
  async function pollModelStatus() {
302
  try {
303
- const result = await client.predict('/model_status', {});
304
- const status = JSON.parse(result.data[0]);
305
- const dot = document.getElementById('statusDot');
306
- const text = document.getElementById('statusText');
307
-
308
  dot.className = 'status-dot ' + (status.status || 'unknown');
309
- const names = { ready:'Ready', loading:'Loading...', error:'Error', unknown:'Unknown' };
310
  text.textContent = status.model_name || names[status.status] || 'Unknown';
311
-
312
- if (status.status === 'loading') {
313
- setTimeout(pollModelStatus, 3000);
314
- }
315
- } catch (e) {
316
- setTimeout(pollModelStatus, 5000);
317
- }
318
  }
319
 
320
- // ── Switch model: @app.api("switch_model") ───────────────────────────
321
-
322
  async function switchModel(key) {
323
  try {
324
- await client.predict('/switch_model', { model_key: key });
 
 
 
 
325
  document.getElementById('statusDot').className = 'status-dot loading';
326
  document.getElementById('statusText').textContent = 'Switching...';
327
  setTimeout(pollModelStatus, 2000);
328
  } catch (e) { console.error(e); }
329
  }
330
 
331
- // ── Workspace tree: @app.api("workspace_tree") ───────────────────────
332
-
333
  async function refreshWorkspace() {
334
  try {
335
- const result = await client.predict('/workspace_tree', { max_depth: 3 });
336
- const data = JSON.parse(result.data[0]);
337
  document.getElementById('wsTree').textContent = data.tree || '(empty)';
338
  } catch (e) { console.error(e); }
339
  }
340
 
341
- // ── Reset session: @app.api("reset_session") ─────────────────────────
342
-
343
  async function resetSession() {
344
  if (sessionId) {
345
  try {
346
- await client.predict('/reset_session', { session_id: sessionId });
 
 
 
 
347
  } catch (e) { /* ignore */ }
348
  }
349
  sessionId = '';
350
- document.getElementById('chatMessages').innerHTML = `
351
- <div class="message" id="welcomeMsg" style="text-align:center; padding:60px 20px;">
352
- <div style="font-size:28px; margin-bottom:12px; opacity:0.6;">SoniCoder v2</div>
353
- <div style="color:var(--text-secondary); font-size:14px;">New session started. What would you like to build?</div>
354
- </div>`;
355
  }
356
 
357
  // ── Message Rendering ────────────────────────────────────────────────
358
 
359
- function appendMessage(role, content, extra = {}) {
360
- const container = document.getElementById('chatMessages');
361
- const div = document.createElement('div');
 
362
  div.className = 'message';
363
  div.id = extra.msgId || '';
364
 
365
- const time = new Date().toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'});
366
- const avatarClass = role === 'user' ? 'user' : 'agent';
367
- const avatarText = role === 'user' ? 'Y' : 'SC';
 
368
 
369
- div.innerHTML = `
370
- <div class="message-header">
371
- <div class="message-avatar ${avatarClass}">${avatarText}</div>
372
- <span class="message-role">${role === 'user' ? 'You' : 'SoniCoder'}</span>
373
- <span class="message-time">${time}</span>
374
- ${extra.iteration ? `<span class="iteration-badge">Iteration ${extra.iteration}</span>` : ''}
375
- </div>
376
- <div class="message-body">${formatContent(content)}</div>`;
377
 
378
  container.appendChild(div);
379
  container.scrollTop = container.scrollHeight;
@@ -382,8 +311,8 @@ function appendMessage(role, content, extra = {}) {
382
 
383
  function formatContent(text) {
384
  if (!text) return '';
385
- text = text.replace(/```(\w*)\n([\s\S]*?)```/g, (_, lang, code) => {
386
- return `<pre><code class="language-${lang}">${escapeHtml(code.trim())}</code></pre>`;
387
  });
388
  text = text.replace(/`([^`]+)`/g, '<code>$1</code>');
389
  text = text.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
@@ -394,7 +323,7 @@ function formatContent(text) {
394
  }
395
 
396
  function escapeHtml(text) {
397
- const d = document.createElement('div');
398
  d.textContent = text;
399
  return d.innerHTML;
400
  }
@@ -402,19 +331,19 @@ function escapeHtml(text) {
402
  // ── Tool Call UI ─────────────────────────────────────────────────────
403
 
404
  function addToolCall(toolName, args) {
405
- const container = document.getElementById('chatMessages');
406
- const id = 'tc_' + Date.now();
407
- const div = document.createElement('div');
408
  div.className = 'tool-call';
409
  div.id = id;
410
- div.innerHTML = `
411
- <div class="tool-call-header">
412
- <span class="tool-icon">&#9889;</span>
413
- <span class="tool-name">${escapeHtml(toolName)}</span>
414
- <span class="tool-status running">Running</span>
415
- </div>
416
- <div class="tool-args">${formatToolArgs(args)}</div>
417
- <div class="tool-result" style="display:none;"></div>`;
418
  container.appendChild(div);
419
  container.scrollTop = container.scrollHeight;
420
  currentToolCalls[id] = div;
@@ -422,10 +351,10 @@ function addToolCall(toolName, args) {
422
  }
423
 
424
  function updateToolCall(id, success, output) {
425
- const div = currentToolCalls[id];
426
  if (!div) return;
427
- const status = div.querySelector('.tool-status');
428
- const result = div.querySelector('.tool-result');
429
  status.className = 'tool-status ' + (success ? 'success' : 'error');
430
  status.textContent = success ? 'Done' : 'Error';
431
  if (output) {
@@ -438,57 +367,52 @@ function updateToolCall(id, success, output) {
438
 
439
  function formatToolArgs(args) {
440
  if (!args) return '';
441
- const lines = Object.entries(args).map(([k, v]) => {
442
- const val = typeof v === 'string' ? (v.length > 60 ? v.slice(0, 60) + '...' : v) : JSON.stringify(v);
443
- return `${k}: ${val}`;
 
444
  });
445
  return escapeHtml(lines.join('\n'));
446
  }
447
 
448
  // ── Streaming UI ─────────────────────────────────────────────────────
449
 
450
- let currentStreamDiv = null;
451
-
452
  function startStreaming() {
453
- // Remove welcome message if present
454
- const welcome = document.getElementById('welcomeMsg');
455
  if (welcome) welcome.remove();
456
-
457
  currentStreamDiv = appendMessage('agent', '', {});
458
- const body = currentStreamDiv.querySelector('.message-body');
459
  body.innerHTML = '<div class="typing-indicator"><div class="typing-dot"></div><div class="typing-dot"></div><div class="typing-dot"></div></div>';
460
  }
461
 
462
  function updateStreaming(text) {
463
  if (!currentStreamDiv) return;
464
- const body = currentStreamDiv.querySelector('.message-body');
465
- body.innerHTML = formatContent(text) ||
466
- '<div class="typing-indicator"><div class="typing-dot"></div><div class="typing-dot"></div><div class="typing-dot"></div></div>';
467
  document.getElementById('chatMessages').scrollTop =
468
  document.getElementById('chatMessages').scrollHeight;
469
  }
470
 
471
  function finalizeStream(text) {
472
  if (!currentStreamDiv) return;
473
- const body = currentStreamDiv.querySelector('.message-body');
474
  body.innerHTML = formatContent(text);
475
  currentStreamDiv = null;
476
  }
477
 
478
- // ── Send Message ─────────────────────────────────────────────────────
479
- // Uses raw fetch to POST /chat (FastAPI StreamingResponse).
480
- // This is NOT @app.api() β€” it's a raw FastAPI route for SSE streaming.
481
 
482
  async function sendMessage() {
483
- const input = document.getElementById('chatInput');
484
- const message = input.value.trim();
485
  if (!message || isStreaming) return;
486
 
487
  input.value = '';
488
  input.style.height = 'auto';
489
  isStreaming = true;
490
 
491
- const sendBtn = document.getElementById('sendBtn');
492
  sendBtn.textContent = 'Stop';
493
  sendBtn.className = 'stop-btn';
494
 
@@ -496,93 +420,76 @@ async function sendMessage() {
496
  startStreaming();
497
 
498
  try {
499
- const resp = await fetch('/chat', {
500
  method: 'POST',
501
  headers: { 'Content-Type': 'application/json' },
502
- body: JSON.stringify({
503
- message: message,
504
- session_id: sessionId,
505
- }),
506
  });
507
 
508
- if (!resp.ok) {
509
- throw new Error(`HTTP ${resp.status}: ${resp.statusText}`);
510
- }
511
 
512
- const reader = resp.body.getReader();
513
- const decoder = new TextDecoder();
514
- let buffer = '';
515
 
516
  while (true) {
517
- const { done, value } = await reader.read();
518
- if (done) break;
519
-
520
- buffer += decoder.decode(value, { stream: true });
521
- const lines = buffer.split('\n');
522
  buffer = lines.pop() || '';
523
 
524
- for (const line of lines) {
 
525
  if (!line.trim()) continue;
526
  try {
527
- const event = JSON.parse(line);
528
-
529
  switch (event.type) {
530
  case 'streaming':
531
- const display = (event.content || '').replace(/```tool[\s\S]*?```/g, '').trim();
532
  updateStreaming(display);
533
  break;
534
-
535
  case 'tool_call':
536
  addToolCall(event.tool, event.args);
537
  break;
538
-
539
  case 'tool_result':
540
- const tcIds = Object.keys(currentToolCalls).reverse();
541
- for (const tcId of tcIds) {
542
- const tcDiv = currentToolCalls[tcId];
543
  if (tcDiv && tcDiv.querySelector('.tool-status.running')) {
544
- updateToolCall(tcId, event.result.success, event.result.output || event.result.error);
545
  break;
546
  }
547
  }
548
  break;
549
-
550
  case 'status':
551
  if (event.iteration && currentStreamDiv) {
552
- const header = currentStreamDiv.querySelector('.message-header');
553
  if (header) {
554
- let badge = header.querySelector('.iteration-badge');
555
  if (!badge) {
556
  badge = document.createElement('span');
557
  badge.className = 'iteration-badge';
558
  header.appendChild(badge);
559
  }
560
- badge.textContent = `Iteration ${event.iteration}/${event.max_iterations}`;
561
  }
562
  }
563
- if (event.session_id && !sessionId) {
564
- sessionId = event.session_id;
565
- }
566
  break;
567
-
568
  case 'complete':
569
  finalizeStream(event.content || 'Done.');
570
- if (event.iterations) {
571
- setTimeout(refreshWorkspace, 500);
572
- }
573
  break;
574
-
575
  case 'error':
576
- finalizeStream(`Error: ${event.error}`);
577
  break;
578
  }
579
- } catch (e) { /* skip malformed lines */ }
580
  }
581
  }
582
  } catch (e) {
583
- if (e.name !== 'AbortError') {
584
- finalizeStream(`Connection error: ${e.message}`);
585
- }
586
  }
587
 
588
  isStreaming = false;
@@ -590,39 +497,27 @@ async function sendMessage() {
590
  sendBtn.className = 'send-btn';
591
  }
592
 
593
- // ── Wire up event listeners ──────────────────────────────────────────
594
 
595
  document.getElementById('sendBtn').addEventListener('click', sendMessage);
596
-
597
- document.getElementById('chatInput').addEventListener('keydown', (e) => {
598
- if (e.key === 'Enter' && !e.shiftKey) {
599
- e.preventDefault();
600
- sendMessage();
601
- }
602
  });
603
-
604
  document.getElementById('chatInput').addEventListener('input', function() {
605
  this.style.height = 'auto';
606
  this.style.height = Math.min(this.scrollHeight, 200) + 'px';
607
  });
608
-
609
  document.getElementById('newChatBtn').addEventListener('click', resetSession);
610
-
611
- document.getElementById('toggleWsBtn').addEventListener('click', () => {
612
  document.getElementById('workspacePanel').classList.toggle('collapsed');
613
  });
614
-
615
  document.getElementById('refreshWsBtn').addEventListener('click', refreshWorkspace);
616
-
617
- document.getElementById('examplesPanel').addEventListener('click', (e) => {
618
- const btn = e.target.closest('.example-btn');
619
  if (!btn) return;
620
  document.getElementById('chatInput').value = btn.dataset.example;
621
  sendMessage();
622
  });
623
-
624
- // ── Boot ─────────────────────────────────────────────────────────────
625
- init();
626
  </script>
627
  </body>
628
  </html>
 
4
  <meta charset="UTF-8">
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
  <title>SoniCoder v2 β€” AI Code Agent</title>
 
 
 
 
 
7
  <style>
8
  :root {
9
  --bg-primary: #0f0f13;
 
29
  * { margin:0; padding:0; box-sizing:border-box; }
30
  body { font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background:var(--bg-primary); color:var(--text-primary); height:100vh; overflow:hidden; }
31
 
 
32
  .app { display:flex; height:100vh; }
33
  .sidebar { width:280px; background:var(--bg-secondary); border-right:1px solid var(--border); display:flex; flex-direction:column; flex-shrink:0; }
34
  .main { flex:1; display:flex; flex-direction:column; min-width:0; }
35
  .workspace-panel { width:300px; background:var(--bg-secondary); border-left:1px solid var(--border); display:flex; flex-direction:column; flex-shrink:0; transition:width 0.3s; overflow:hidden; }
36
  .workspace-panel.collapsed { width:0; border-left:none; }
37
 
 
38
  .sidebar-header { padding:20px; border-bottom:1px solid var(--border); }
39
  .logo { font-size:20px; font-weight:700; background:linear-gradient(135deg, #7c6ef0, #a78bfa, #60a5fa); -webkit-background-clip:text; -webkit-text-fill-color:transparent; }
40
  .logo-sub { font-size:11px; color:var(--text-muted); margin-top:2px; letter-spacing:1px; text-transform:uppercase; }
 
55
  .sidebar-section h3 { font-size:11px; color:var(--text-muted); text-transform:uppercase; letter-spacing:0.5px; margin-bottom:12px; }
56
  .sidebar-info { font-size:12px; color:var(--text-secondary); line-height:1.6; }
57
  .sidebar-info code { background:var(--bg-tertiary); padding:2px 6px; border-radius:4px; font-size:11px; color:var(--accent); }
 
58
  .new-chat-btn { margin:16px 20px; padding:10px; background:var(--accent); color:white; border:none; border-radius:var(--radius-sm); font-size:13px; font-weight:600; cursor:pointer; transition:all 0.2s; }
59
  .new-chat-btn:hover { background:var(--accent-hover); transform:translateY(-1px); }
60
 
 
61
  .chat-header { padding:16px 24px; border-bottom:1px solid var(--border); display:flex; align-items:center; justify-content:space-between; }
62
  .chat-header h2 { font-size:15px; font-weight:600; }
63
  .header-actions { display:flex; gap:8px; }
 
86
  .message-body ul, .message-body ol { padding-left:20px; margin:8px 0; }
87
  .message-body li { margin:4px 0; }
88
 
 
89
  .tool-call { background:var(--bg-tertiary); border:1px solid var(--border); border-radius:var(--radius-sm); margin:12px 0; padding:12px 16px; }
90
  .tool-call-header { display:flex; align-items:center; gap:8px; margin-bottom:8px; }
91
  .tool-icon { font-size:14px; }
 
98
  .tool-result { font-size:12px; background:var(--bg-primary); border-radius:6px; padding:8px 12px; max-height:300px; overflow-y:auto; white-space:pre-wrap; word-break:break-all; font-family:'JetBrains Mono',monospace; line-height:1.5; color:var(--text-secondary); }
99
  .tool-result::-webkit-scrollbar { width:4px; }
100
  .tool-result::-webkit-scrollbar-thumb { background:var(--border); border-radius:2px; }
 
 
101
  .iteration-badge { display:inline-flex; align-items:center; gap:4px; font-size:11px; color:var(--text-muted); background:var(--bg-tertiary); padding:2px 10px; border-radius:10px; margin-bottom:8px; }
102
 
 
103
  .chat-input { padding:16px 24px 24px; border-top:1px solid var(--border); }
104
  .input-wrapper { display:flex; align-items:flex-end; gap:12px; background:var(--bg-tertiary); border:1px solid var(--border); border-radius:var(--radius); padding:12px 16px; transition:border-color 0.2s; }
105
  .input-wrapper:focus-within { border-color:var(--accent); }
 
111
  .stop-btn { padding:8px 20px; background:var(--error); color:white; border:none; border-radius:var(--radius-sm); font-size:13px; font-weight:600; cursor:pointer; }
112
  .input-hint { font-size:11px; color:var(--text-muted); margin-top:8px; text-align:center; }
113
 
 
114
  .ws-header { padding:16px; border-bottom:1px solid var(--border); display:flex; align-items:center; justify-content:space-between; }
115
  .ws-header h3 { font-size:13px; font-weight:600; }
116
  .ws-tree { flex:1; overflow-y:auto; padding:12px 16px; font-family:'JetBrains Mono',monospace; font-size:12px; line-height:1.8; color:var(--text-secondary); white-space:pre; }
117
  .ws-tree::-webkit-scrollbar { width:4px; }
118
  .ws-tree::-webkit-scrollbar-thumb { background:var(--border); border-radius:2px; }
119
 
 
120
  .typing-indicator { display:flex; gap:4px; padding:8px 0; }
121
  .typing-dot { width:6px; height:6px; border-radius:50%; background:var(--accent); animation:typingBounce 1.4s infinite; }
122
  .typing-dot:nth-child(2) { animation-delay:0.2s; }
123
  .typing-dot:nth-child(3) { animation-delay:0.4s; }
124
  @keyframes typingBounce { 0%,60%,100%{transform:translateY(0)} 30%{transform:translateY(-6px)} }
125
 
 
126
  .examples { padding:16px 20px; }
127
  .example-btn { display:block; width:100%; text-align:left; padding:10px 12px; background:var(--bg-tertiary); color:var(--text-secondary); border:1px solid var(--border); border-radius:var(--radius-sm); font-size:12px; margin-bottom:8px; cursor:pointer; transition:all 0.2s; line-height:1.4; }
128
  .example-btn:hover { background:var(--bg-hover); color:var(--text-primary); border-color:var(--accent); }
129
 
 
 
 
 
 
 
 
 
 
130
  @media (max-width:900px) {
131
  .sidebar { display:none; }
132
  .workspace-panel { display:none; }
 
134
  </style>
135
  </head>
136
  <body>
 
 
 
 
 
 
 
 
 
137
  <div class="app">
 
138
  <aside class="sidebar">
139
  <div class="sidebar-header">
140
  <div class="logo">SoniCoder v2</div>
141
  <div class="logo-sub">AI Code Agent</div>
142
  </div>
 
143
  <div class="model-selector">
144
  <label>Model</label>
145
  <select id="modelSelect"></select>
146
  </div>
 
147
  <div class="model-status">
148
  <span class="status-dot unknown" id="statusDot"></span>
149
+ <span class="status-text" id="statusText">Loading...</span>
150
  </div>
 
151
  <button class="new-chat-btn" id="newChatBtn">+ New Chat</button>
 
152
  <div class="sidebar-section">
153
  <h3>Quick Start</h3>
154
  <div class="sidebar-info">
155
+ Ask me to build anything &mdash; a web app, API, script, or full-stack project. I'll create the files and verify they work.
156
  </div>
157
  </div>
 
158
  <div class="examples" id="examplesPanel">
159
  <div class="example-btn" data-example="Build a Flask REST API with user CRUD endpoints">Build a Flask REST API with user CRUD endpoints</div>
160
  <div class="example-btn" data-example="Create a React todo app with localStorage persistence">Create a React todo app with localStorage persistence</div>
 
164
  </div>
165
  </aside>
166
 
 
167
  <div class="main">
168
  <div class="chat-header">
169
  <h2>Chat</h2>
 
172
  <button class="header-btn" id="refreshWsBtn">Refresh</button>
173
  </div>
174
  </div>
 
175
  <div class="chat-messages" id="chatMessages">
176
  <div class="message" id="welcomeMsg" style="text-align:center; padding:60px 20px;">
177
  <div style="font-size:28px; margin-bottom:12px; opacity:0.6;">SoniCoder v2</div>
 
180
  </div>
181
  </div>
182
  </div>
 
183
  <div class="chat-input">
184
  <div class="input-wrapper">
185
  <textarea id="chatInput" placeholder="Describe what you want to build..." rows="1"></textarea>
 
189
  </div>
190
  </div>
191
 
 
192
  <div class="workspace-panel" id="workspacePanel">
193
+ <div class="ws-header"><h3>Workspace</h3></div>
 
 
194
  <div class="ws-tree" id="wsTree">Loading...</div>
195
  </div>
196
  </div>
197
 
198
+ <script>
199
+ // ── State ──────────────────────────────────────────────────────────────
200
+ var sessionId = '';
201
+ var isStreaming = false;
202
+ var currentToolCalls = {};
203
+ var currentStreamDiv = null;
204
+
205
+ // ── Init ──────────────────────────────────────────────────────────────
206
+ document.addEventListener('DOMContentLoaded', function() {
207
+ loadConfig();
208
+ pollModelStatus();
209
+ refreshWorkspace();
210
+ });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
211
 
212
+ // ── API calls (plain fetch to FastAPI routes on gradio.Server) ────────
213
 
214
  async function loadConfig() {
215
+ try {
216
+ var resp = await fetch('/api/get_config');
217
+ var config = await resp.json();
218
+
219
+ var select = document.getElementById('modelSelect');
220
+ select.innerHTML = '';
221
+ (config.models || []).forEach(function(m) {
222
+ var opt = document.createElement('option');
223
+ opt.value = m.key;
224
+ opt.textContent = m.name + ' (' + m.size_gb + 'GB)';
225
+ if (m.key === config.default_model) opt.selected = true;
226
+ select.appendChild(opt);
227
+ });
228
+ select.addEventListener('change', function() { switchModel(select.value); });
229
+ } catch (e) { console.error('Config load failed:', e); }
230
  }
231
 
 
 
232
  async function pollModelStatus() {
233
  try {
234
+ var resp = await fetch('/api/model_status');
235
+ var status = await resp.json();
236
+ var dot = document.getElementById('statusDot');
237
+ var text = document.getElementById('statusText');
 
238
  dot.className = 'status-dot ' + (status.status || 'unknown');
239
+ var names = { ready:'Ready', loading:'Loading...', error:'Error', unknown:'Unknown' };
240
  text.textContent = status.model_name || names[status.status] || 'Unknown';
241
+ if (status.status === 'loading') setTimeout(pollModelStatus, 3000);
242
+ } catch (e) { setTimeout(pollModelStatus, 5000); }
 
 
 
 
 
243
  }
244
 
 
 
245
  async function switchModel(key) {
246
  try {
247
+ await fetch('/api/switch_model', {
248
+ method: 'POST',
249
+ headers: { 'Content-Type': 'application/json' },
250
+ body: JSON.stringify({ model_key: key }),
251
+ });
252
  document.getElementById('statusDot').className = 'status-dot loading';
253
  document.getElementById('statusText').textContent = 'Switching...';
254
  setTimeout(pollModelStatus, 2000);
255
  } catch (e) { console.error(e); }
256
  }
257
 
 
 
258
  async function refreshWorkspace() {
259
  try {
260
+ var resp = await fetch('/api/workspace_tree?max_depth=3');
261
+ var data = await resp.json();
262
  document.getElementById('wsTree').textContent = data.tree || '(empty)';
263
  } catch (e) { console.error(e); }
264
  }
265
 
 
 
266
  async function resetSession() {
267
  if (sessionId) {
268
  try {
269
+ await fetch('/api/reset_session', {
270
+ method: 'POST',
271
+ headers: { 'Content-Type': 'application/json' },
272
+ body: JSON.stringify({ session_id: sessionId }),
273
+ });
274
  } catch (e) { /* ignore */ }
275
  }
276
  sessionId = '';
277
+ document.getElementById('chatMessages').innerHTML =
278
+ '<div class="message" id="welcomeMsg" style="text-align:center; padding:60px 20px;">' +
279
+ '<div style="font-size:28px; margin-bottom:12px; opacity:0.6;">SoniCoder v2</div>' +
280
+ '<div style="color:var(--text-secondary); font-size:14px;">New session started. What would you like to build?</div>' +
281
+ '</div>';
282
  }
283
 
284
  // ── Message Rendering ────────────────────────────────────────────────
285
 
286
+ function appendMessage(role, content, extra) {
287
+ extra = extra || {};
288
+ var container = document.getElementById('chatMessages');
289
+ var div = document.createElement('div');
290
  div.className = 'message';
291
  div.id = extra.msgId || '';
292
 
293
+ var time = new Date().toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'});
294
+ var avatarClass = role === 'user' ? 'user' : 'agent';
295
+ var avatarText = role === 'user' ? 'Y' : 'SC';
296
+ var iterationHtml = extra.iteration ? '<span class="iteration-badge">Iteration ' + extra.iteration + '</span>' : '';
297
 
298
+ div.innerHTML =
299
+ '<div class="message-header">' +
300
+ '<div class="message-avatar ' + avatarClass + '">' + avatarText + '</div>' +
301
+ '<span class="message-role">' + (role === 'user' ? 'You' : 'SoniCoder') + '</span>' +
302
+ '<span class="message-time">' + time + '</span>' +
303
+ iterationHtml +
304
+ '</div>' +
305
+ '<div class="message-body">' + formatContent(content) + '</div>';
306
 
307
  container.appendChild(div);
308
  container.scrollTop = container.scrollHeight;
 
311
 
312
  function formatContent(text) {
313
  if (!text) return '';
314
+ text = text.replace(/```(\w*)\n([\s\S]*?)```/g, function(_, lang, code) {
315
+ return '<pre><code class="language-' + lang + '">' + escapeHtml(code.trim()) + '</code></pre>';
316
  });
317
  text = text.replace(/`([^`]+)`/g, '<code>$1</code>');
318
  text = text.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
 
323
  }
324
 
325
  function escapeHtml(text) {
326
+ var d = document.createElement('div');
327
  d.textContent = text;
328
  return d.innerHTML;
329
  }
 
331
  // ── Tool Call UI ─────────────────────────────────────────────────────
332
 
333
  function addToolCall(toolName, args) {
334
+ var container = document.getElementById('chatMessages');
335
+ var id = 'tc_' + Date.now();
336
+ var div = document.createElement('div');
337
  div.className = 'tool-call';
338
  div.id = id;
339
+ div.innerHTML =
340
+ '<div class="tool-call-header">' +
341
+ '<span class="tool-icon">&#9889;</span>' +
342
+ '<span class="tool-name">' + escapeHtml(toolName) + '</span>' +
343
+ '<span class="tool-status running">Running</span>' +
344
+ '</div>' +
345
+ '<div class="tool-args">' + formatToolArgs(args) + '</div>' +
346
+ '<div class="tool-result" style="display:none;"></div>';
347
  container.appendChild(div);
348
  container.scrollTop = container.scrollHeight;
349
  currentToolCalls[id] = div;
 
351
  }
352
 
353
  function updateToolCall(id, success, output) {
354
+ var div = currentToolCalls[id];
355
  if (!div) return;
356
+ var status = div.querySelector('.tool-status');
357
+ var result = div.querySelector('.tool-result');
358
  status.className = 'tool-status ' + (success ? 'success' : 'error');
359
  status.textContent = success ? 'Done' : 'Error';
360
  if (output) {
 
367
 
368
  function formatToolArgs(args) {
369
  if (!args) return '';
370
+ var lines = Object.entries(args).map(function(entry) {
371
+ var k = entry[0], v = entry[1];
372
+ var val = typeof v === 'string' ? (v.length > 60 ? v.slice(0, 60) + '...' : v) : JSON.stringify(v);
373
+ return k + ': ' + val;
374
  });
375
  return escapeHtml(lines.join('\n'));
376
  }
377
 
378
  // ── Streaming UI ─────────────────────────────────────────────────────
379
 
 
 
380
  function startStreaming() {
381
+ var welcome = document.getElementById('welcomeMsg');
 
382
  if (welcome) welcome.remove();
 
383
  currentStreamDiv = appendMessage('agent', '', {});
384
+ var body = currentStreamDiv.querySelector('.message-body');
385
  body.innerHTML = '<div class="typing-indicator"><div class="typing-dot"></div><div class="typing-dot"></div><div class="typing-dot"></div></div>';
386
  }
387
 
388
  function updateStreaming(text) {
389
  if (!currentStreamDiv) return;
390
+ var body = currentStreamDiv.querySelector('.message-body');
391
+ var formatted = formatContent(text);
392
+ body.innerHTML = formatted || '<div class="typing-indicator"><div class="typing-dot"></div><div class="typing-dot"></div><div class="typing-dot"></div></div>';
393
  document.getElementById('chatMessages').scrollTop =
394
  document.getElementById('chatMessages').scrollHeight;
395
  }
396
 
397
  function finalizeStream(text) {
398
  if (!currentStreamDiv) return;
399
+ var body = currentStreamDiv.querySelector('.message-body');
400
  body.innerHTML = formatContent(text);
401
  currentStreamDiv = null;
402
  }
403
 
404
+ // ── Send Message (POST /chat β†’ StreamingResponse) ────────────────────
 
 
405
 
406
  async function sendMessage() {
407
+ var input = document.getElementById('chatInput');
408
+ var message = input.value.trim();
409
  if (!message || isStreaming) return;
410
 
411
  input.value = '';
412
  input.style.height = 'auto';
413
  isStreaming = true;
414
 
415
+ var sendBtn = document.getElementById('sendBtn');
416
  sendBtn.textContent = 'Stop';
417
  sendBtn.className = 'stop-btn';
418
 
 
420
  startStreaming();
421
 
422
  try {
423
+ var resp = await fetch('/chat', {
424
  method: 'POST',
425
  headers: { 'Content-Type': 'application/json' },
426
+ body: JSON.stringify({ message: message, session_id: sessionId }),
 
 
 
427
  });
428
 
429
+ if (!resp.ok) throw new Error('HTTP ' + resp.status);
 
 
430
 
431
+ var reader = resp.body.getReader();
432
+ var decoder = new TextDecoder();
433
+ var buffer = '';
434
 
435
  while (true) {
436
+ var result = await reader.read();
437
+ if (result.done) break;
438
+ buffer += decoder.decode(result.value, { stream: true });
439
+ var lines = buffer.split('\n');
 
440
  buffer = lines.pop() || '';
441
 
442
+ for (var i = 0; i < lines.length; i++) {
443
+ var line = lines[i];
444
  if (!line.trim()) continue;
445
  try {
446
+ var event = JSON.parse(line);
 
447
  switch (event.type) {
448
  case 'streaming':
449
+ var display = (event.content || '').replace(/```tool[\s\S]*?```/g, '').trim();
450
  updateStreaming(display);
451
  break;
 
452
  case 'tool_call':
453
  addToolCall(event.tool, event.args);
454
  break;
 
455
  case 'tool_result':
456
+ var tcIds = Object.keys(currentToolCalls).reverse();
457
+ for (var j = 0; j < tcIds.length; j++) {
458
+ var tcDiv = currentToolCalls[tcIds[j]];
459
  if (tcDiv && tcDiv.querySelector('.tool-status.running')) {
460
+ updateToolCall(tcIds[j], event.result.success, event.result.output || event.result.error);
461
  break;
462
  }
463
  }
464
  break;
 
465
  case 'status':
466
  if (event.iteration && currentStreamDiv) {
467
+ var header = currentStreamDiv.querySelector('.message-header');
468
  if (header) {
469
+ var badge = header.querySelector('.iteration-badge');
470
  if (!badge) {
471
  badge = document.createElement('span');
472
  badge.className = 'iteration-badge';
473
  header.appendChild(badge);
474
  }
475
+ badge.textContent = 'Iteration ' + event.iteration + '/' + event.max_iterations;
476
  }
477
  }
478
+ if (event.session_id && !sessionId) sessionId = event.session_id;
 
 
479
  break;
 
480
  case 'complete':
481
  finalizeStream(event.content || 'Done.');
482
+ if (event.iterations) setTimeout(refreshWorkspace, 500);
 
 
483
  break;
 
484
  case 'error':
485
+ finalizeStream('Error: ' + event.error);
486
  break;
487
  }
488
+ } catch (e) { /* skip malformed */ }
489
  }
490
  }
491
  } catch (e) {
492
+ if (e.name !== 'AbortError') finalizeStream('Connection error: ' + e.message);
 
 
493
  }
494
 
495
  isStreaming = false;
 
497
  sendBtn.className = 'send-btn';
498
  }
499
 
500
+ // ── Event Listeners ──────────────────────────────────────────────────
501
 
502
  document.getElementById('sendBtn').addEventListener('click', sendMessage);
503
+ document.getElementById('chatInput').addEventListener('keydown', function(e) {
504
+ if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); }
 
 
 
 
505
  });
 
506
  document.getElementById('chatInput').addEventListener('input', function() {
507
  this.style.height = 'auto';
508
  this.style.height = Math.min(this.scrollHeight, 200) + 'px';
509
  });
 
510
  document.getElementById('newChatBtn').addEventListener('click', resetSession);
511
+ document.getElementById('toggleWsBtn').addEventListener('click', function() {
 
512
  document.getElementById('workspacePanel').classList.toggle('collapsed');
513
  });
 
514
  document.getElementById('refreshWsBtn').addEventListener('click', refreshWorkspace);
515
+ document.getElementById('examplesPanel').addEventListener('click', function(e) {
516
+ var btn = e.target.closest('.example-btn');
 
517
  if (!btn) return;
518
  document.getElementById('chatInput').value = btn.dataset.example;
519
  sendMessage();
520
  });
 
 
 
521
  </script>
522
  </body>
523
  </html>
sonicoder/server/__init__.py CHANGED
@@ -3,24 +3,23 @@
3
  Blog: https://huggingface.co/blog/introducing-gradio-server
4
 
5
  Pattern:
6
- - from gradio import Server (NOT gradio.Server())
7
- - @app.api(name="...") for backend endpoints β†’ gets Gradio queuing + concurrency
8
- - @app.post("/chat") raw FastAPI route for SSE streaming
9
- - @app.get("/", response_class=HTMLResponse) serves index.html from project root
10
- - Frontend uses @gradio/client JS for non-streaming, raw fetch for streaming
11
  """
12
 
13
  from __future__ import annotations
14
 
15
  import json
16
  import logging
17
- import os
18
  import uuid
19
  from pathlib import Path
20
  from typing import Any, Generator
21
 
22
  from fastapi import Request
23
- from fastapi.responses import HTMLResponse, StreamingResponse
24
 
25
  from ..config import (
26
  Settings, get_available_models, load_settings,
@@ -75,243 +74,229 @@ def _get_or_create_session(session_id: str = "") -> tuple[str, AgentSession]:
75
  return session_id, _sessions[session_id]
76
 
77
 
78
- # ── API Handlers (pure functions, called by @app.api or raw routes) ────
79
 
80
- def api_chat(
81
- message: str,
82
- session_id: str = "",
83
- image_url: str | None = None,
84
- ) -> Generator[str, None, None]:
85
- """Main chat endpoint β€” streams events as newline-delimited JSON."""
86
- if not message or not message.strip():
87
- yield json.dumps({"type": "error", "error": "Empty message"})
88
- return
89
 
90
- sid, session = _get_or_create_session(session_id)
91
 
92
- try:
93
- for event in session.run(message, session_id=sid, image_url=image_url):
94
- yield json.dumps(event, ensure_ascii=False)
95
- except Exception as e:
96
- logger.error(f"Chat error: {e}", exc_info=True)
97
- yield json.dumps({"type": "error", "error": str(e)})
98
 
99
 
100
- def api_model_status() -> str:
101
- return json.dumps(get_model_status())
102
-
103
-
104
- def api_switch_model(model_key: str) -> str:
105
- switch_model_fn(model_key)
106
- return json.dumps({"status": "switching", "model": model_key})
107
-
108
-
109
- def api_list_models() -> str:
110
- models = get_available_models()
111
- current = get_model_status().get("model_key", "")
112
- result = []
113
- for key, config in models.items():
114
- result.append({
115
- "key": key,
116
- "name": config.name,
117
- "model_type": config.model_type,
118
- "size_gb": config.size_gb,
119
- "description": config.description,
120
- "current": key == current,
121
- })
122
- return json.dumps(result, ensure_ascii=False)
123
-
124
-
125
- def api_workspace_tree(max_depth: int = 3) -> str:
126
- tree = []
127
-
128
- def walk(path: Path, prefix: str = "", depth: int = 0):
129
- if depth >= max_depth:
130
- return
131
- try:
132
- items = sorted(path.iterdir(), key=lambda p: (not p.is_dir(), p.name.lower()))
133
- except PermissionError:
134
- return
135
- skip = {".git", "node_modules", "__pycache__", ".venv", "venv", "dist", "build", ".sonicoder"}
136
- for i, item in enumerate(items):
137
- if item.name.startswith(".") or item.name in skip:
138
- continue
139
- is_last = i == len(items) - 1
140
- connector = "└── " if is_last else "β”œβ”€β”€ "
141
- if item.is_dir():
142
- tree.append(f"{prefix}{connector}{item.name}/")
143
- extension = " " if is_last else "β”‚ "
144
- walk(item, prefix + extension, depth + 1)
145
- else:
146
- tree.append(f"{prefix}{connector}{item.name}")
147
-
148
- if WORKSPACE_ROOT.exists():
149
- walk(WORKSPACE_ROOT)
150
-
151
- return json.dumps({
152
- "tree": "\n".join(tree) if tree else "(empty workspace)",
153
- "root": str(WORKSPACE_ROOT),
154
- })
155
-
156
-
157
- def api_workspace_read(path: str, offset: int = 1, limit: int = 500) -> str:
158
- from ..tools.fs import _resolve_safe
159
- try:
160
- resolved = _resolve_safe(path)
161
- if not resolved.exists():
162
- return json.dumps({"error": f"File not found: {path}"})
163
- content = resolved.read_text(encoding="utf-8", errors="replace")
164
- lines = content.splitlines()
165
- total = len(lines)
166
- start = max(0, offset - 1)
167
- end = min(total, start + limit)
168
- selected = lines[start:end]
169
- numbered = [f" {i + start + 1:>6}\t{line}" for i, line in enumerate(selected)]
170
- return json.dumps({
171
- "content": "\n".join(numbered),
172
- "total_lines": total,
173
- "truncated": end < total,
174
- })
175
- except Exception as e:
176
- return json.dumps({"error": str(e)})
177
-
178
-
179
- def api_workspace_write(path: str, content: str) -> str:
180
- from ..tools.fs import _resolve_safe
181
- try:
182
- resolved = _resolve_safe(path)
183
- resolved.parent.mkdir(parents=True, exist_ok=True)
184
- resolved.write_text(content, encoding="utf-8")
185
- return json.dumps({"success": True, "path": path})
186
- except Exception as e:
187
- return json.dumps({"error": str(e)})
188
-
189
-
190
- def api_workspace_bash(command: str) -> str:
191
- from ..tools.bash import BashBuilder
192
- builder = BashBuilder()
193
- errors = builder.validate({"command": command})
194
- if errors:
195
- return json.dumps({"error": "; ".join(errors)})
196
- invocation = builder.create_invocation({"command": command})
197
- result = invocation.execute()
198
- return json.dumps(result.to_dict())
199
-
200
-
201
- def api_web_search(query: str) -> str:
202
- from ..tools.web import WebSearchBuilder
203
- builder = WebSearchBuilder()
204
- invocation = builder.create_invocation({"query": query})
205
- result = invocation.execute()
206
- return json.dumps(result.to_dict(), ensure_ascii=False)
207
-
208
-
209
- def api_mcp_status() -> str:
210
- return json.dumps(get_mcp_manager().get_status())
211
-
212
-
213
- def api_policy_status() -> str:
214
- engine = get_policy_engine()
215
- return json.dumps({"mode": engine.approval_mode, "rules": engine.get_rules()})
216
-
217
-
218
- def api_reset_session(session_id: str = "") -> str:
219
- if session_id in _sessions:
220
- _sessions[session_id].clear_history()
221
- return json.dumps({"success": True})
222
-
223
-
224
- def api_get_config() -> str:
225
- models = get_available_models()
226
- model_list = []
227
- for key, config in models.items():
228
- model_list.append({
229
- "key": key,
230
- "name": config.name,
231
- "model_type": config.model_type,
232
- "size_gb": config.size_gb,
233
- "description": config.description,
234
- })
235
- return json.dumps({
236
- "models": model_list,
237
- "default_model": _get_settings().default_model,
238
- "workspace": str(WORKSPACE_ROOT),
239
- "version": "2.0.0",
240
- }, ensure_ascii=False)
241
-
242
-
243
- # ── Build App (gradio.Server) ─────────────────────────────────────────
244
 
245
  def create_app():
246
  """Create and return a gradio.Server with custom HTML frontend.
247
 
248
- Follows the blog pattern exactly:
249
- https://huggingface.co/blog/introducing-gradio-server
250
-
251
- - @app.api(name="...") for backend endpoints (gets Gradio queuing + concurrency)
252
- - @app.post("/chat") raw FastAPI StreamingResponse for SSE streaming
253
- - @app.get("/") serves index.html from project root
254
- - Frontend uses @gradio/client for non-streaming, raw fetch for streaming
255
  """
256
  from gradio import Server
257
 
258
  app = Server()
259
 
260
- # ── Backend API endpoints via @app.api() (Gradio queuing) ─────────
261
-
262
- @app.api(name="get_config")
263
- def get_config():
264
- return api_get_config()
265
-
266
- @app.api(name="model_status")
267
- def model_status():
268
- return api_model_status()
269
-
270
- @app.api(name="switch_model")
271
- def switch_model_api(model_key: str):
272
- return api_switch_model(model_key)
273
-
274
- @app.api(name="list_models")
275
- def list_models():
276
- return api_list_models()
277
 
278
- @app.api(name="workspace_tree")
279
- def workspace_tree(max_depth: int = 3):
280
- return api_workspace_tree(max_depth)
281
-
282
- @app.api(name="workspace_read")
283
- def workspace_read(path: str, offset: int = 1, limit: int = 500):
284
- return api_workspace_read(path, offset, limit)
285
-
286
- @app.api(name="workspace_write")
287
- def workspace_write(path: str, content: str):
288
- return api_workspace_write(path, content)
289
-
290
- @app.api(name="workspace_bash")
291
- def workspace_bash(command: str):
292
- return api_workspace_bash(command)
293
 
294
- @app.api(name="web_search")
295
- def web_search(query: str):
296
- return api_web_search(query)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
297
 
298
- @app.api(name="mcp_status")
299
- def mcp_status():
300
- return api_mcp_status()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
301
 
302
- @app.api(name="policy_status")
303
- def policy_status():
304
- return api_policy_status()
305
 
306
- @app.api(name="reset_session")
307
- def reset_session(session_id: str = ""):
308
- return api_reset_session(session_id)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
309
 
310
- # ── Streaming chat: raw FastAPI route (SSE) ───────────────────────
311
 
312
  @app.post("/chat")
313
  async def chat_stream(request: Request):
314
- """Streaming chat via raw FastAPI β€” returns newline-delimited JSON."""
315
  try:
316
  body = await request.json()
317
  except Exception:
@@ -348,18 +333,4 @@ def create_app():
348
  },
349
  )
350
 
351
- # ── Serve custom HTML frontend from project root ──────────────────
352
-
353
- @app.get("/", response_class=HTMLResponse)
354
- async def homepage():
355
- """Serve the custom SPA frontend β€” index.html at project root."""
356
- html_path = Path(__file__).parent.parent.parent / "index.html"
357
- if not html_path.exists():
358
- return HTMLResponse(
359
- "<h1>SoniCoder v2</h1><p>index.html not found at project root</p>",
360
- status_code=500,
361
- )
362
- with open(html_path, "r", encoding="utf-8") as f:
363
- return f.read()
364
-
365
  return app
 
3
  Blog: https://huggingface.co/blog/introducing-gradio-server
4
 
5
  Pattern:
6
+ - from gradio import Server β†’ app = Server()
7
+ - @app.get("/") serves index.html from project root
8
+ - All API endpoints use raw FastAPI routes (Server extends FastAPI)
9
+ - Streaming chat uses StreamingResponse (text/event-stream)
10
+ - Frontend uses plain fetch() β€” no @gradio/client needed
11
  """
12
 
13
  from __future__ import annotations
14
 
15
  import json
16
  import logging
 
17
  import uuid
18
  from pathlib import Path
19
  from typing import Any, Generator
20
 
21
  from fastapi import Request
22
+ from fastapi.responses import HTMLResponse, StreamingResponse, JSONResponse
23
 
24
  from ..config import (
25
  Settings, get_available_models, load_settings,
 
74
  return session_id, _sessions[session_id]
75
 
76
 
77
+ # ── JSON helpers ───────────────────────────────────────────────────────
78
 
79
+ def _json(data) -> JSONResponse:
80
+ return JSONResponse(content=data if isinstance(data, dict | list) else json.loads(data))
 
 
 
 
 
 
 
81
 
 
82
 
83
+ def _json_str(data) -> JSONResponse:
84
+ return JSONResponse(content=json.loads(data) if isinstance(data, str) else data)
 
 
 
 
85
 
86
 
87
+ # ── Create App ────────────────────────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
88
 
89
  def create_app():
90
  """Create and return a gradio.Server with custom HTML frontend.
91
 
92
+ Blog: https://huggingface.co/blog/introducing-gradio-server
93
+ Server extends FastAPI, so standard @app.get/post routes work.
94
+ @app.get("/") serves index.html from project root.
 
 
 
 
95
  """
96
  from gradio import Server
97
 
98
  app = Server()
99
 
100
+ # ── Serve index.html from project root ─────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
101
 
102
+ @app.get("/", response_class=HTMLResponse)
103
+ async def homepage():
104
+ html_path = Path(__file__).parent.parent.parent / "index.html"
105
+ if not html_path.exists():
106
+ return HTMLResponse(
107
+ "<h1>SoniCoder v2</h1><p>index.html not found at project root</p>",
108
+ status_code=500,
109
+ )
110
+ with open(html_path, "r", encoding="utf-8") as f:
111
+ return f.read()
 
 
 
 
 
112
 
113
+ # ── Config ─────────────────────────────────────────────────────────
114
+
115
+ @app.get("/api/get_config")
116
+ async def get_config():
117
+ models = get_available_models()
118
+ model_list = []
119
+ for key, config in models.items():
120
+ model_list.append({
121
+ "key": key,
122
+ "name": config.name,
123
+ "model_type": config.model_type,
124
+ "size_gb": config.size_gb,
125
+ "description": config.description,
126
+ })
127
+ return JSONResponse(content={
128
+ "models": model_list,
129
+ "default_model": _get_settings().default_model,
130
+ "workspace": str(WORKSPACE_ROOT),
131
+ "version": "2.0.0",
132
+ })
133
 
134
+ # ── Model status ───────────────────────────────────────────────────
135
+
136
+ @app.get("/api/model_status")
137
+ async def model_status():
138
+ return _json(get_model_status())
139
+
140
+ # ── Switch model ───────────────────────────────────────────────────
141
+
142
+ @app.post("/api/switch_model")
143
+ async def switch_model(request: Request):
144
+ body = await request.json()
145
+ model_key = body.get("model_key", "")
146
+ switch_model_fn(model_key)
147
+ return JSONResponse(content={"status": "switching", "model": model_key})
148
+
149
+ # ── List models ────────────────────────────────────────────────────
150
+
151
+ @app.get("/api/list_models")
152
+ async def list_models():
153
+ models = get_available_models()
154
+ current = get_model_status().get("model_key", "")
155
+ result = []
156
+ for key, config in models.items():
157
+ result.append({
158
+ "key": key,
159
+ "name": config.name,
160
+ "model_type": config.model_type,
161
+ "size_gb": config.size_gb,
162
+ "description": config.description,
163
+ "current": key == current,
164
+ })
165
+ return JSONResponse(content=result)
166
+
167
+ # ── Workspace tree ─────────────────────────────────────────────────
168
+
169
+ @app.get("/api/workspace_tree")
170
+ async def workspace_tree(max_depth: int = 3):
171
+ tree = []
172
+
173
+ def walk(path: Path, prefix: str = "", depth: int = 0):
174
+ if depth >= max_depth:
175
+ return
176
+ try:
177
+ items = sorted(path.iterdir(), key=lambda p: (not p.is_dir(), p.name.lower()))
178
+ except PermissionError:
179
+ return
180
+ skip = {".git", "node_modules", "__pycache__", ".venv", "venv", "dist", "build", ".sonicoder"}
181
+ for i, item in enumerate(items):
182
+ if item.name.startswith(".") or item.name in skip:
183
+ continue
184
+ is_last = i == len(items) - 1
185
+ connector = "\u2514\u2500\u2500 " if is_last else "\u251c\u2500\u2500 "
186
+ if item.is_dir():
187
+ tree.append(f"{prefix}{connector}{item.name}/")
188
+ extension = " " if is_last else "\u2502 "
189
+ walk(item, prefix + extension, depth + 1)
190
+ else:
191
+ tree.append(f"{prefix}{connector}{item.name}")
192
+
193
+ if WORKSPACE_ROOT.exists():
194
+ walk(WORKSPACE_ROOT)
195
+
196
+ return JSONResponse(content={
197
+ "tree": "\n".join(tree) if tree else "(empty workspace)",
198
+ "root": str(WORKSPACE_ROOT),
199
+ })
200
 
201
+ # ── Workspace read ─────────────────────────────────────────────────
 
 
202
 
203
+ @app.post("/api/workspace_read")
204
+ async def workspace_read(request: Request):
205
+ body = await request.json()
206
+ path = body.get("path", "")
207
+ offset = body.get("offset", 1)
208
+ limit = body.get("limit", 500)
209
+ from ..tools.fs import _resolve_safe
210
+ try:
211
+ resolved = _resolve_safe(path)
212
+ if not resolved.exists():
213
+ return JSONResponse(content={"error": f"File not found: {path}"})
214
+ content = resolved.read_text(encoding="utf-8", errors="replace")
215
+ lines = content.splitlines()
216
+ total = len(lines)
217
+ start = max(0, offset - 1)
218
+ end = min(total, start + limit)
219
+ selected = lines[start:end]
220
+ numbered = [f" {i + start + 1:>6}\t{line}" for i, line in enumerate(selected)]
221
+ return JSONResponse(content={
222
+ "content": "\n".join(numbered),
223
+ "total_lines": total,
224
+ "truncated": end < total,
225
+ })
226
+ except Exception as e:
227
+ return JSONResponse(content={"error": str(e)})
228
+
229
+ # ── Workspace write ────────────────────────────────────────────────
230
+
231
+ @app.post("/api/workspace_write")
232
+ async def workspace_write(request: Request):
233
+ body = await request.json()
234
+ path = body.get("path", "")
235
+ content = body.get("content", "")
236
+ from ..tools.fs import _resolve_safe
237
+ try:
238
+ resolved = _resolve_safe(path)
239
+ resolved.parent.mkdir(parents=True, exist_ok=True)
240
+ resolved.write_text(content, encoding="utf-8")
241
+ return JSONResponse(content={"success": True, "path": path})
242
+ except Exception as e:
243
+ return JSONResponse(content={"error": str(e)})
244
+
245
+ # ── Workspace bash ─────────────────────────────────────────────────
246
+
247
+ @app.post("/api/workspace_bash")
248
+ async def workspace_bash(request: Request):
249
+ body = await request.json()
250
+ command = body.get("command", "")
251
+ from ..tools.bash import BashBuilder
252
+ builder = BashBuilder()
253
+ errors = builder.validate({"command": command})
254
+ if errors:
255
+ return JSONResponse(content={"error": "; ".join(errors)})
256
+ invocation = builder.create_invocation({"command": command})
257
+ result = invocation.execute()
258
+ return JSONResponse(content=result.to_dict())
259
+
260
+ # ── Web search ─────────────────────────────────────────────────────
261
+
262
+ @app.post("/api/web_search")
263
+ async def web_search(request: Request):
264
+ body = await request.json()
265
+ query = body.get("query", "")
266
+ from ..tools.web import WebSearchBuilder
267
+ builder = WebSearchBuilder()
268
+ invocation = builder.create_invocation({"query": query})
269
+ result = invocation.execute()
270
+ return JSONResponse(content=result.to_dict())
271
+
272
+ # ── MCP status ─────────────────────────────────────────────────────
273
+
274
+ @app.get("/api/mcp_status")
275
+ async def mcp_status():
276
+ return _json(get_mcp_manager().get_status())
277
+
278
+ # ── Policy status ──────────────────────────────────────────────────
279
+
280
+ @app.get("/api/policy_status")
281
+ async def policy_status():
282
+ engine = get_policy_engine()
283
+ return JSONResponse(content={"mode": engine.approval_mode, "rules": engine.get_rules()})
284
+
285
+ # ── Reset session ──────────────────────────────────────────────────
286
+
287
+ @app.post("/api/reset_session")
288
+ async def reset_session(request: Request):
289
+ body = await request.json()
290
+ session_id = body.get("session_id", "")
291
+ if session_id in _sessions:
292
+ _sessions[session_id].clear_history()
293
+ return JSONResponse(content={"success": True})
294
 
295
+ # ── Streaming chat ─────────────────────────────────────────────────
296
 
297
  @app.post("/chat")
298
  async def chat_stream(request: Request):
299
+ """Streaming chat β€” returns newline-delimited JSON via StreamingResponse."""
300
  try:
301
  body = await request.json()
302
  except Exception:
 
333
  },
334
  )
335
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
336
  return app