broadfield-dev commited on
Commit
c105dea
Β·
verified Β·
1 Parent(s): 544ffe5

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +720 -363
app.py CHANGED
@@ -1,3 +1,4 @@
 
1
  import gradio as gr
2
  import re
3
  import json
@@ -6,18 +7,25 @@ import tempfile
6
  import shlex
7
  from huggingface_hub import HfApi
8
 
 
 
 
 
9
  try:
10
  from build_logic import (
11
- create_space as build_logic_create_space,
12
  _get_api_token as build_logic_get_api_token,
13
  whoami as build_logic_whoami,
14
  list_space_files_for_browsing,
15
- get_space_repository_info,
16
  get_space_file_content,
17
- update_space_file,
18
  parse_markdown as build_logic_parse_markdown,
19
- delete_space_file as build_logic_delete_space_file,
20
- get_space_runtime_status
 
 
 
21
  )
22
  print("build_logic.py loaded successfully.")
23
 
@@ -28,8 +36,8 @@ try:
28
  generate_stream
29
  )
30
  print("model_logic.py loaded successfully.")
31
- except ImportError:
32
- print("Warning: Local modules (build_logic.py, model_logic.py) not found. Using dummy functions.")
33
  def get_available_providers(): return ["DummyProvider", "Groq"] # Added Groq for testing
34
  def get_models_for_provider(p):
35
  if p == 'Groq': return ["llama3-8b-8192", "gemma-7b-it"]
@@ -39,58 +47,45 @@ except ImportError:
39
  return "dummy-model"
40
  # The dummy function already accepts the api_key argument ('a')
41
  def generate_stream(p, m, a, msgs):
42
- yield f"Using dummy model. API Key provided: {'Yes' if a else 'No'}. This is a dummy response as local modules were not found."
43
- def build_logic_create_space(*args, **kwargs): return "Error: build_logic not found."
 
44
  def build_logic_get_api_token(key): return (key or os.getenv("HF_TOKEN"), None)
45
  def build_logic_whoami(token): return {"name": "dummy_user"}
46
- def list_space_files_for_browsing(*args): return ([], "Error: build_logic not found.")
47
- def get_space_repository_info(*args): return (None, [], "Error: build_logic not found.")
48
- def get_space_file_content(*args): return ("", "Error: build_logic not found.")
49
- def update_space_file(*args, **kwargs): return "Error: build_logic not found."
50
- def build_logic_parse_markdown(md): return {"files": []}
51
- def build_logic_delete_space_file(*args): return "Error: build_logic not found."
52
- def get_space_runtime_status(*args): return (None, "Error: build_logic not found.")
53
- # --- END: Dummy functions ---
54
-
 
 
 
 
 
 
 
 
55
 
56
- # --- New Feature Functions (can be moved to build_logic.py) ---
57
- def build_logic_set_space_privacy(hf_api_key, repo_id, private: bool):
58
- """Sets the privacy of a Hugging Face Space."""
59
- print(f"[ACTION] Setting privacy for '{repo_id}' to {private}.")
60
- try:
61
- token, err = build_logic_get_api_token(hf_api_key)
62
- if err or not token: return f"Error getting token: {err or 'Token not found.'}"
63
- api = HfApi(token=token)
64
- api.update_repo_visibility(repo_id=repo_id, private=private, repo_type='space')
65
- return f"Successfully set privacy for {repo_id} to {private}."
66
- except Exception as e:
67
- print(f"Error setting privacy: {e}")
68
- return f"Error setting privacy: {e}"
69
-
70
- def build_logic_delete_space(hf_api_key, owner, space_name):
71
- """Deletes an entire Hugging Face Space."""
72
- repo_id = f"{owner}/{space_name}"
73
- print(f"[ACTION] Deleting space '{repo_id}'. THIS IS A DESTRUCTIVE ACTION.")
74
- try:
75
- token, err = build_logic_get_api_token(hf_api_key)
76
- if err or not token: return f"Error getting token: {err or 'Token not found.'}"
77
- api = HfApi(token=token)
78
- api.delete_repo(repo_id=repo_id, repo_type='space')
79
- return f"Successfully deleted space {repo_id}."
80
- except Exception as e:
81
- print(f"Error deleting space: {e}")
82
- return f"Error deleting space: {e}"
83
 
84
 
85
  # --- CORE FIX: Define triple backticks safely to prevent Markdown rendering issues ---
86
  backtick = chr(96)
87
  bbb = f'{backtick}{backtick}{backtick}'
88
 
 
 
89
  parsed_code_blocks_state_cache = []
90
  BOT_ROLE_NAME = "assistant"
91
 
92
  DEFAULT_SYSTEM_PROMPT = f"""You are an expert AI programmer and Hugging Face assistant. Your role is to generate code and file structures based on user requests, or to modify existing code provided by the user.
93
 
 
 
94
  **File and Code Formatting:**
95
  When you provide NEW code for a file, or MODIFIED code for an existing file, use the following format exactly:
96
  ### File: path/to/filename.ext
@@ -103,7 +98,7 @@ If the file is binary, or you cannot show its content, use this format:
103
  ### File: path/to/binaryfile.ext
104
  [Binary file - approximate_size bytes]
105
 
106
- When you provide a project file structure, use this format:
107
  ## File Structure
108
  {bbb}
109
  πŸ“ Root
@@ -116,32 +111,33 @@ When you provide a project file structure, use this format:
116
  - The role name for your responses in the chat history must be '{BOT_ROLE_NAME}'.
117
  - Adhere strictly to these formatting instructions.
118
  - If you update a file, provide the FULL file content again under the same filename.
119
- - Only the latest version of each file mentioned throughout the chat will be used for the final output. The system will merge your changes with the prior state.
120
- - Filenames in the '### File:' line should be clean paths (e.g., 'src/app.py', 'Dockerfile') and should NOT include Markdown backticks.
121
 
122
  **Hugging Face Space Actions:**
123
- To perform direct actions on the Hugging Face Space, use the `HF_ACTION` command. This is a powerful tool to manage the repository programmatically.
124
- The format is `### HF_ACTION: COMMAND arguments...` on a single line.
125
 
126
  Available commands:
127
- - `CREATE_SPACE owner/repo_name --sdk <sdk> --private <true|false>`: Creates a new, empty space. SDK can be gradio, streamlit, docker, or static. Private is optional and defaults to false.
128
  - `DELETE_FILE path/to/file.ext`: Deletes a specific file from the current space.
129
  - `SET_PRIVATE <true|false>`: Sets the privacy for the current space.
130
- - `DELETE_SPACE`: Deletes the entire current space. THIS IS PERMANENT AND REQUIRES CAUTION.
131
 
132
- You can issue multiple actions. For example, to delete a file and then add a new one:
133
- ### HF_ACTION: DELETE_FILE old_app.py
134
- ### File: new_app.py
135
- {bbb}python
136
- # new code
137
- {bbb}
138
- Use these actions when the user's request explicitly calls for them (e.g., "delete the readme file", "make this space private", "create a new private space called my-test-app"). If no code is provided, assist the user with their tasks.
139
  """
140
 
141
- # --- Helper Functions (largely unchanged) ---
 
 
 
142
  def escape_html_for_markdown(text):
143
  if not isinstance(text, str): return ""
144
- return text.replace("&", "&").replace("<", "<").replace(">", ">")
145
 
146
  def _infer_lang_from_filename(filename):
147
  if not filename: return "plaintext"
@@ -170,114 +166,145 @@ def _infer_lang_from_filename(filename):
170
 
171
  def _clean_filename(filename_line_content):
172
  text = filename_line_content.strip()
173
- text = re.sub(r'[`\*_]+', '', text)
174
- path_match = re.match(r'^([\w\-\.\s\/\\]+)', text)
175
- if path_match:
176
- parts = re.split(r'\s*\(', path_match.group(1).strip(), 1)
177
- return parts[0].strip() if parts else ""
178
- backtick_match = re.search(r'`([^`]+)`', text)
179
- if backtick_match:
180
- potential_fn = backtick_match.group(1).strip()
181
- parts = re.split(r'\s*\(|\s{2,}', potential_fn, 1)
182
- cleaned_fn = parts[0].strip() if parts else ""
183
- cleaned_fn = cleaned_fn.strip('`\'":;,')
184
- if cleaned_fn: return cleaned_fn
185
- parts = re.split(r'\s*\(|\s{2,}', text, 1)
186
- filename_candidate = parts[0].strip() if parts else text.strip()
187
- filename_candidate = filename_candidate.strip('`\'":;,')
188
- return filename_candidate if filename_candidate else text.strip()
189
-
190
- def _parse_chat_stream_logic(latest_bot_message_content, existing_files_state=None):
191
- global parsed_code_blocks_state_cache
192
- latest_blocks_dict = {}
193
- if existing_files_state:
194
- for block in existing_files_state:
195
- if not block.get("is_structure_block"):
196
- latest_blocks_dict[block["filename"]] = block.copy()
197
 
198
- results = {"parsed_code_blocks": [], "preview_md": "", "default_selected_filenames": [], "error_message": None}
199
  content = latest_bot_message_content or ""
200
 
201
- file_pattern = re.compile(r"### File:\s*(?P<filename_line>[^\n]+)\n(?:```(?P<lang>[\w\.\-\+]*)\n(?P<code>[\s\S]*?)\n```|(?P<binary_msg>\[Binary file(?: - [^\]]+)?\]))")
202
- structure_pattern = re.compile(r"## File Structure\n```(?:(?P<struct_lang>[\w.-]*)\n)?(?P<structure_code>[\s\S]*?)\n```")
203
 
 
204
  structure_match = structure_pattern.search(content)
205
  if structure_match:
206
- latest_blocks_dict["File Structure (original)"] = {"filename": "File Structure (original)", "language": structure_match.group("struct_lang") or "plaintext", "code": structure_match.group("structure_code").strip(), "is_binary": False, "is_structure_block": True}
207
- else:
208
- existing_structure_block = next((b for b in parsed_code_blocks_state_cache if b.get("is_structure_block")), None)
209
- if existing_structure_block:
210
- latest_blocks_dict["File Structure (original)"] = existing_structure_block.copy()
211
 
212
- current_message_file_blocks = {}
 
213
  for match in file_pattern.finditer(content):
214
  filename = _clean_filename(match.group("filename_line"))
215
- if not filename: continue
 
 
 
216
  lang, code_block, binary_msg = match.group("lang"), match.group("code"), match.group("binary_msg")
 
217
  item_data = {"filename": filename, "is_binary": False, "is_structure_block": False}
 
218
  if code_block is not None:
219
- item_data["code"], item_data["language"] = code_block.strip(), (lang.strip().lower() if lang else _infer_lang_from_filename(filename))
 
 
220
  elif binary_msg is not None:
221
- item_data["code"], item_data["language"], item_data["is_binary"] = binary_msg.strip(), "binary", True
222
- else: continue
223
- current_message_file_blocks[filename] = item_data
224
-
225
- latest_blocks_dict.update(current_message_file_blocks)
226
- current_parsed_blocks = list(latest_blocks_dict.values())
227
- current_parsed_blocks.sort(key=lambda b: (0, b["filename"]) if b.get("is_structure_block") else (1, b["filename"]))
228
- results["parsed_code_blocks"] = current_parsed_blocks
229
- results["default_selected_filenames"] = [b["filename"] for b in current_parsed_blocks if not b.get("is_structure_block")]
230
- return results
 
 
 
 
 
 
 
 
 
 
 
 
 
231
 
232
  def _export_selected_logic(selected_filenames, space_line_name_for_md, parsed_blocks_for_export):
 
233
  results = {"output_str": "", "error_message": None, "download_filepath": None}
234
- exportable_blocks_content = [b for b in parsed_blocks_for_export if not b.get("is_structure_block") and not b.get("is_binary") and not (b.get("code", "").startswith(("[Error loading content:", "[Binary or Skipped file]")))]
235
- binary_blocks_content = [b for b in parsed_blocks_for_export if b.get("is_binary") or b.get("code", "").startswith("[Binary or Skipped file]")]
236
- all_filenames_in_state = sorted(list(set(b["filename"] for b in parsed_blocks_for_export if not b.get("is_structure_block"))))
 
 
237
 
238
- if not all_filenames_in_state and not any(b.get("is_structure_block") for b in parsed_blocks_for_export):
239
- results["output_str"] = f"# Space: {space_line_name_for_md}\n## File Structure\n{bbb}\nπŸ“ Root\n{bbb}\n\n*No files to list in structure or export.*"
 
240
  try:
241
  with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".md", encoding='utf-8') as tmpfile:
242
  tmpfile.write(results["output_str"]); results["download_filepath"] = tmpfile.name
243
- except Exception as e: print(f"Error creating temp file for empty export: {e}")
244
  return results
245
 
246
  output_lines = [f"# Space: {space_line_name_for_md}"]
 
 
247
  structure_block = next((b for b in parsed_blocks_for_export if b.get("is_structure_block")), None)
248
  if structure_block:
249
  output_lines.extend(["## File Structure", bbb, structure_block["code"].strip(), bbb, ""])
250
  else:
 
251
  output_lines.extend(["## File Structure", bbb, "πŸ“ Root"])
252
  if all_filenames_in_state:
253
- for fname in all_filenames_in_state: output_lines.append(f" πŸ“„ {fname}")
254
  output_lines.extend([bbb, ""])
255
 
256
  output_lines.append("Below are the contents of all files in the space:\n")
257
- files_to_export_content = [b for b in exportable_blocks_content if not selected_filenames or b["filename"] in selected_filenames]
258
- binary_error_blocks_to_export = [b for b in binary_blocks_content if not selected_filenames or b["filename"] in selected_filenames]
259
- all_blocks_to_export_content = sorted(files_to_export_content + binary_error_blocks_to_export, key=lambda b: b["filename"])
260
 
261
- exported_content = False
262
- for block in all_blocks_to_export_content:
 
 
 
 
263
  output_lines.append(f"### File: {block['filename']}")
264
- if block.get('is_binary') or block.get("code", "").startswith(("[Binary file", "[Error loading content:", "[Binary or Skipped file]")):
265
- output_lines.append(block.get('code','[Binary or Skipped file]'))
 
 
266
  else:
267
- output_lines.extend([f"{bbb}{block.get('language', 'plaintext') or 'plaintext'}", block.get('code',''), bbb])
268
- output_lines.append(""); exported_content = True
 
 
 
269
 
270
- if not exported_content and not all_filenames_in_state: output_lines.append("*No files in state.*")
271
- elif not exported_content: output_lines.append("*No files with editable content are in the state or selected.*")
 
 
272
 
273
  final_output_str = "\n".join(output_lines)
274
  results["output_str"] = final_output_str
275
  try:
 
276
  with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".md", encoding='utf-8') as tmpfile:
277
- tmpfile.write(final_output_str); results["download_filepath"] = tmpfile.name
 
278
  except Exception as e:
279
- print(f"Error creating temp file: {e}")
280
  results["error_message"] = "Could not prepare file for download."
 
 
281
  return results
282
 
283
  def _convert_gr_history_to_api_messages(system_prompt, gr_history, current_user_message=None):
@@ -285,33 +312,45 @@ def _convert_gr_history_to_api_messages(system_prompt, gr_history, current_user_
285
  for user_msg, bot_msg in gr_history:
286
  if user_msg: messages.append({"role": "user", "content": user_msg})
287
  if bot_msg and isinstance(bot_msg, str): messages.append({"role": BOT_ROLE_NAME, "content": bot_msg})
 
288
  if current_user_message: messages.append({"role": "user", "content": current_user_message})
289
  return messages
290
 
291
  def _generate_ui_outputs_from_cache(owner, space_name):
 
292
  global parsed_code_blocks_state_cache
 
293
  preview_md_val = "*No files in cache to display.*"
294
- formatted_md_val = f"# Space: {owner}/{space_name}\n## File Structure\n{bbb}\nπŸ“ Root\n{bbb}\n\n*No files in cache.*" if owner and space_name else "*Load or define a Space to see its Markdown structure.*"
295
- download_file = None
 
 
 
 
296
 
297
  if parsed_code_blocks_state_cache:
298
  preview_md_lines = ["## Detected/Updated Files & Content (Latest Versions):"]
299
- for block in parsed_code_blocks_state_cache:
300
- preview_md_lines.append(f"\n----\n**File:** `{escape_html_for_markdown(block['filename'])}`")
301
- if block.get('is_structure_block'): preview_md_lines.append(f" (Original File Structure from AI)\n")
302
- elif block.get('is_binary'): preview_md_lines.append(f" (Binary File)\n")
303
- else: preview_md_lines.append(f" (Language: `{block['language']}`)\n")
304
-
305
- content = block.get('code', '')
306
- if block.get('is_binary') or content.startswith(("[Binary file", "[Error loading content:", "[Binary or Skipped file]")):
307
- preview_md_lines.append(f"\n`{escape_html_for_markdown(content)}`\n")
308
- else:
309
- preview_md_lines.append(f"\n{bbb}{block.get('language', 'plaintext') or 'plaintext'}\n{content}\n{bbb}\n")
 
 
 
 
 
 
 
 
310
  preview_md_val = "\n".join(preview_md_lines)
311
- space_line_name = f"{owner}/{space_name}" if owner and space_name else (owner or space_name or "your-space")
312
- export_result = _export_selected_logic(None, space_line_name, parsed_code_blocks_state_cache)
313
- formatted_md_val = export_result["output_str"]
314
- download_file = export_result["download_filepath"]
315
 
316
  return formatted_md_val, preview_md_val, gr.update(value=download_file, interactive=download_file is not None)
317
 
@@ -319,70 +358,140 @@ def _generate_ui_outputs_from_cache(owner, space_name):
319
 
320
  def generate_and_stage_changes(ai_response_content, current_files_state, hf_owner_name, hf_repo_name):
321
  """
322
- Parses AI response, compares with current state, and generates a structured changeset.
323
- Returns the changeset and a markdown summary for display.
324
  """
325
  changeset = []
326
  current_files_dict = {f["filename"]: f for f in current_files_state if not f.get("is_structure_block")}
327
 
328
- # 1. Parse proposed files from AI response
329
- parsing_result = _parse_chat_stream_logic(ai_response_content, existing_files_state=current_files_state)
330
- proposed_files = parsing_result.get("parsed_code_blocks", [])
 
 
 
 
 
331
 
332
- # 2. Parse HF_ACTION commands from AI response
333
- action_pattern = re.compile(r"### HF_ACTION:\s*(?P<command_line>[^\n]+)")
334
  for match in action_pattern.finditer(ai_response_content):
335
  cmd_parts = shlex.split(match.group("command_line").strip())
336
  if not cmd_parts: continue
337
  command, args = cmd_parts[0].upper(), cmd_parts[1:]
338
 
339
  # Add actions to the changeset
340
- if command == "DELETE_FILE" and args:
341
- changeset.append({"type": "DELETE_FILE", "path": args[0]})
342
- elif command == "SET_PRIVATE" and args:
343
- changeset.append({"type": "SET_PRIVACY", "private": args[0].lower() == 'true', "repo_id": f"{hf_owner_name}/{hf_repo_name}"})
344
- elif command == "DELETE_SPACE":
345
- changeset.append({"type": "DELETE_SPACE", "owner": hf_owner_name, "space_name": hf_repo_name})
346
- elif command == "CREATE_SPACE" and args:
347
  repo_id = args[0]
348
  sdk = "gradio" # default
349
  private = False # default
350
- if '--sdk' in args: sdk = args[args.index('--sdk') + 1]
351
- if '--private' in args: private = args[args.index('--private') + 1].lower() == 'true'
 
 
 
 
 
 
352
  changeset.append({"type": "CREATE_SPACE", "repo_id": repo_id, "sdk": sdk, "private": private})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
353
 
354
- # 3. Compare proposed files with current files to determine CREATE/UPDATE
355
- for file_block in proposed_files:
356
- if file_block.get("is_structure_block"): continue
357
 
358
- filename = file_block["filename"]
359
- if filename not in current_files_dict:
360
- changeset.append({"type": "CREATE_FILE", "path": filename, "content": file_block["code"], "lang": file_block["language"]})
361
- elif file_block["code"] != current_files_dict[filename]["code"]:
362
- changeset.append({"type": "UPDATE_FILE", "path": filename, "content": file_block["code"], "lang": file_block["language"]})
 
 
 
 
 
 
363
 
364
  # 4. Format the changeset into a human-readable Markdown string
365
  if not changeset:
366
- return [], "The AI did not propose any specific changes to files or the space.", parsing_result
367
-
368
- md_summary = ["### πŸ“‹ Proposed Changes Plan\n"]
369
- md_summary.append("The AI has proposed the following changes. Please review and confirm.")
370
-
371
- for change in changeset:
372
- if change["type"] == "CREATE_FILE":
373
- md_summary.append(f"- **βž• Create File:** `{change['path']}`")
374
- elif change["type"] == "UPDATE_FILE":
375
- md_summary.append(f"- **πŸ”„ Update File:** `{change['path']}`")
376
- elif change["type"] == "DELETE_FILE":
377
- md_summary.append(f"- **βž– Delete File:** `{change['path']}`")
378
- elif change["type"] == "CREATE_SPACE":
379
- md_summary.append(f"- **πŸš€ Create New Space:** `{change['repo_id']}` (SDK: {change['sdk']}, Private: {change['private']})")
380
- elif change["type"] == "SET_PRIVACY":
381
- md_summary.append(f"- **πŸ”’ Set Privacy:** Set `{change['repo_id']}` to `private={change['private']}`")
382
- elif change["type"] == "DELETE_SPACE":
383
- md_summary.append(f"- **πŸ’₯ DELETE ENTIRE SPACE:** `{change['owner']}/{change['space_name']}` **(DESTRUCTIVE ACTION)**")
384
-
385
- return changeset, "\n".join(md_summary), parsing_result
 
 
 
 
 
 
 
 
 
 
 
 
386
 
387
  # --- Gradio Event Handlers ---
388
 
@@ -390,33 +499,36 @@ def handle_chat_submit(user_message, chat_history, hf_api_key_input, provider_ap
390
  global parsed_code_blocks_state_cache
391
  _chat_msg_in, _chat_hist = "", list(chat_history)
392
 
393
- # UI updates for streaming
394
  yield (
395
  _chat_msg_in, _chat_hist, "Initializing...",
396
- gr.update(), gr.update(), gr.update(interactive=False),
397
- [], gr.update(visible=False), gr.update(visible=False), gr.update(visible=False), gr.update(visible=False)
398
  )
399
 
400
  if not user_message.strip():
401
  yield (
402
  _chat_msg_in, _chat_hist, "Cannot send an empty message.",
403
- gr.update(), gr.update(), gr.update(),
404
- [], gr.update(visible=False), gr.update(visible=False), gr.update(visible=False), gr.update(visible=False)
405
  )
406
  return
407
 
408
  _chat_hist.append((user_message, None))
409
  yield (
410
  _chat_msg_in, _chat_hist, f"Sending to {model_select}...",
411
- gr.update(), gr.update(), gr.update(),
412
- [], gr.update(visible=False), gr.update(visible=False), gr.update(visible=False), gr.update(visible=False)
413
  )
414
 
415
- # Prepare context for the AI
416
  current_sys_prompt = system_prompt.strip() or DEFAULT_SYSTEM_PROMPT
417
- export_result = _export_selected_logic(None, f"{hf_owner_name}/{hf_repo_name}", parsed_code_blocks_state_cache)
418
- current_files_context = f"\n\n## Current Space Context: {hf_owner_name}/{hf_repo_name}\n{export_result['output_str']}"
419
- user_message_with_context = user_message.strip() + "\n" + current_files_context
 
 
 
420
  api_msgs = _convert_gr_history_to_api_messages(current_sys_prompt, _chat_hist[:-1], user_message_with_context)
421
 
422
  try:
@@ -425,43 +537,56 @@ def handle_chat_submit(user_message, chat_history, hf_api_key_input, provider_ap
425
  streamer = generate_stream(provider_select, model_select, provider_api_key_input, api_msgs)
426
  for chunk in streamer:
427
  if chunk is None: continue
428
- if isinstance(chunk, str) and (chunk.startswith("Error:") or chunk.startswith("API HTTP Error")):
429
- full_bot_response_content = chunk; break
430
- full_bot_response_content += str(chunk)
 
 
 
431
  _chat_hist[-1] = (user_message, full_bot_response_content)
432
  yield (
433
  _chat_msg_in, _chat_hist, f"Streaming from {model_select}...",
434
- gr.update(), gr.update(), gr.update(),
435
- [], gr.update(visible=False), gr.update(visible=False), gr.update(visible=False), gr.update(visible=False)
436
  )
437
 
438
- if "Error:" in full_bot_response_content:
 
439
  _status = full_bot_response_content
440
- yield (_chat_msg_in, _chat_hist, _status, gr.update(), gr.update(), gr.update(), [], gr.update(), gr.update(), gr.update(), gr.update())
441
  return
442
 
443
- # Instead of applying, generate and stage changes
444
- _status = "Stream complete. Generating change plan..."
445
- yield (_chat_msg_in, _chat_hist, _status, gr.update(), gr.update(), gr.update(), [], gr.update(), gr.update(), gr.update(), gr.update())
446
 
447
- staged_changeset, summary_md, parsing_res = generate_and_stage_changes(full_bot_response_content, parsed_code_blocks_state_cache, hf_owner_name, hf_repo_name)
 
 
 
 
 
 
 
 
 
 
 
 
448
 
449
- if parsing_res["error_message"]:
450
- _status = f"Parsing Error: {parsing_res['error_message']}"
451
- yield (_chat_msg_in, _chat_hist, _status, gr.update(), gr.update(), gr.update(), [], gr.update(), gr.update(), gr.update(), gr.update())
452
- return
453
 
454
  if not staged_changeset:
455
- _status = summary_md # "No changes proposed" message
456
- # Still update the cache with the AI's *view* of the world, even if no changes.
457
- parsed_code_blocks_state_cache = parsing_res["parsed_code_blocks"]
458
- _formatted, _detected, _download = _generate_ui_outputs_from_cache(hf_owner_name, hf_repo_name)
459
- yield (_chat_msg_in, _chat_hist, _status, _detected, _formatted, _download, [], gr.update(visible=False), gr.update(visible=False), gr.update(visible=False), gr.update(visible=False))
 
 
 
460
  else:
461
  _status = "Change plan generated. Please review and confirm below."
462
  yield (
463
  _chat_msg_in, _chat_hist, _status,
464
- gr.update(), gr.update(), gr.update(),
465
  staged_changeset, # Send changeset to state
466
  gr.update(value=summary_md), # Display summary
467
  gr.update(visible=True), # Show the accordion
@@ -472,94 +597,127 @@ def handle_chat_submit(user_message, chat_history, hf_api_key_input, provider_ap
472
  except Exception as e:
473
  error_msg = f"An unexpected error occurred: {e}"
474
  print(f"Error in handle_chat_submit: {e}")
475
- if _chat_hist: _chat_hist[-1] = (user_message, error_msg)
 
 
 
 
 
 
 
 
 
 
 
 
476
  yield (
477
  _chat_msg_in, _chat_hist, error_msg,
478
- gr.update(), gr.update(), gr.update(),
479
- [], gr.update(visible=False), gr.update(visible=False), gr.update(visible=False), gr.update(visible=False)
 
 
480
  )
481
 
 
482
  def handle_confirm_changes(hf_api_key, owner_name, space_name, changeset):
483
  """Applies the staged changes from the changeset."""
484
  global parsed_code_blocks_state_cache
 
 
 
 
 
 
485
  if not changeset:
486
- return "No changes to apply.", gr.update(), gr.update(), gr.update(), gr.update(visible=False), gr.update(visible=False), gr.update(visible=False), gr.update(visible=False)
487
-
488
- status_messages = []
489
-
490
- # Handle space creation first, as other ops might depend on it
491
- create_space_op = next((c for c in changeset if c['type'] == 'CREATE_SPACE'), None)
492
- if create_space_op:
493
- repo_parts = create_space_op['repo_id'].split('/')
494
- if len(repo_parts) == 2:
495
- owner, repo = repo_parts
496
- # We need to pass the full markdown for creation. Let's build it from the plan.
497
- # This is a simplification; a more robust solution would pass the planned files directly.
498
- # For now, we assume the AI provides file content for the new space.
499
-
500
- planned_files_md = [f"# Space: {create_space_op['repo_id']}"]
501
- for change in changeset:
502
- if change['type'] in ['CREATE_FILE', 'UPDATE_FILE']:
503
- planned_files_md.append(f"### File: {change['path']}\n{bbb}{change.get('lang', 'plaintext')}\n{change['content']}\n{bbb}")
504
-
505
- markdown_for_creation = "\n\n".join(planned_files_md)
506
-
507
- result = build_logic_create_space(
508
- ui_api_token_from_textbox=hf_api_key,
509
- space_name_ui=repo,
510
- owner_ui=owner,
511
- sdk_ui=create_space_op['sdk'],
512
- private=create_space_op['private'],
513
- markdown_input=markdown_for_creation
514
- )
515
- status_messages.append(f"CREATE_SPACE: {result}")
516
- if "Error" in result:
517
- # Stop if space creation failed
518
- final_status = " | ".join(status_messages)
519
- return final_status, gr.update(), gr.update(), gr.update(), gr.update(visible=False), gr.update(visible=False), gr.update(visible=False), []
520
-
521
- # Apply all other changes
522
- for change in changeset:
523
- try:
524
- if change['type'] == 'UPDATE_FILE':
525
- msg = update_space_file(hf_api_key, space_name, owner_name, change['path'], change['content'], f"AI: Update {change['path']}")
526
- status_messages.append(f"UPDATE '{change['path']}': {msg}")
527
- if "Success" in msg:
528
- # Update cache on success
529
- for block in parsed_code_blocks_state_cache:
530
- if block['filename'] == change['path']:
531
- block['code'] = change['content']
532
- break
533
- elif change['type'] == 'CREATE_FILE' and not create_space_op: # Don't re-create if handled by CREATE_SPACE
534
- msg = update_space_file(hf_api_key, space_name, owner_name, change['path'], change['content'], f"AI: Create {change['path']}")
535
- status_messages.append(f"CREATE '{change['path']}': {msg}")
536
- if "Success" in msg:
537
- parsed_code_blocks_state_cache.append({'filename': change['path'], 'code': change['content'], 'language': change['lang'], 'is_binary': False})
538
- elif change['type'] == 'DELETE_FILE':
539
- msg = build_logic_delete_space_file(hf_api_key, space_name, owner_name, change['path'])
540
- status_messages.append(f"DELETE '{change['path']}': {msg}")
541
- if "Success" in msg:
542
- parsed_code_blocks_state_cache = [b for b in parsed_code_blocks_state_cache if b["filename"] != change['path']]
543
- elif change['type'] == 'SET_PRIVACY':
544
- msg = build_logic_set_space_privacy(hf_api_key, change['repo_id'], change['private'])
545
- status_messages.append(f"SET_PRIVACY: {msg}")
546
- elif change['type'] == 'DELETE_SPACE':
547
- msg = build_logic_delete_space(hf_api_key, change['owner'], change['space_name'])
548
- status_messages.append(f"DELETE_SPACE: {msg}")
549
- if "Success" in msg:
550
- parsed_code_blocks_state_cache = [] # Clear everything
551
- except Exception as e:
552
- status_messages.append(f"Error applying {change['type']} for {change.get('path', '')}: {e}")
553
 
554
- final_status = " | ".join(status_messages)
 
 
 
 
555
  _formatted, _detected, _download = _generate_ui_outputs_from_cache(owner_name, space_name)
556
 
557
- # Hide the confirmation UI and clear the state
558
- return final_status, _formatted, _detected, _download, gr.update(visible=False), gr.update(visible=False), gr.update(visible=False), []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
559
 
560
  def handle_cancel_changes():
561
  """Clears the staged changeset and hides the confirmation UI."""
562
- return "Changes cancelled.", [], gr.update(value="*No changes proposed.*"), gr.update(visible=False), gr.update(visible=False), gr.update(visible=False)
 
 
 
 
 
 
 
 
 
563
 
564
  def update_models_dropdown(provider_select):
565
  if not provider_select: return gr.update(choices=[], value=None)
@@ -573,8 +731,20 @@ def handle_load_existing_space(hf_api_key_ui, ui_owner_name, ui_space_name):
573
  _formatted_md_val, _detected_preview_val, _status_val = "*Loading files...*", "*Loading files...*", f"Loading Space: {ui_owner_name}/{ui_space_name}..."
574
  _file_browser_update, _iframe_html_update, _download_btn_update = gr.update(visible=False, choices=[], value=None), gr.update(value=None, visible=False), gr.update(interactive=False, value=None)
575
  _build_status_clear, _edit_status_clear, _runtime_status_clear = "*Build status...*", "*Select a file...*", "*Runtime status...*"
576
- _chat_history_clear = []
577
- outputs = [_formatted_md_val, _detected_preview_val, _status_val, _file_browser_update, gr.update(value=ui_owner_name), gr.update(value=ui_space_name), _iframe_html_update, _download_btn_update, _build_status_clear, _edit_status_clear, _runtime_status_clear, _chat_history_clear]
 
 
 
 
 
 
 
 
 
 
 
 
578
  yield outputs
579
 
580
  owner_to_use = ui_owner_name
@@ -587,7 +757,7 @@ def handle_load_existing_space(hf_api_key_ui, ui_owner_name, ui_space_name):
587
  user_info = build_logic_whoami(token=token)
588
  owner_to_use = user_info.get('name')
589
  if not owner_to_use: raise Exception("Could not find user name from token.")
590
- outputs[4] = gr.update(value=owner_to_use)
591
  _status_val += f" (Auto-detected owner: {owner_to_use})"
592
  except Exception as e:
593
  _status_val = f"Error auto-detecting owner: {e}"; outputs[2] = _status_val; yield outputs; return
@@ -596,50 +766,114 @@ def handle_load_existing_space(hf_api_key_ui, ui_owner_name, ui_space_name):
596
  _status_val = "Error: Owner and Space Name are required."; outputs[2] = _status_val; yield outputs; return
597
 
598
  sdk, file_list, err = get_space_repository_info(hf_api_key_ui, ui_space_name, owner_to_use)
599
- if err and not file_list:
600
- _status_val = f"File List Error: {err}"; parsed_code_blocks_state_cache = []
 
 
 
 
 
 
601
  _formatted, _detected, _download = _generate_ui_outputs_from_cache(owner_to_use, ui_space_name)
602
- outputs[0], outputs[1], outputs[2], outputs[7] = _formatted, _detected, _status_val, _download
 
 
603
  yield outputs; return
604
 
605
- sub_owner = re.sub(r'[^a-z0-9\-]+', '-', owner_to_use.lower()).strip('-') or 'owner'
606
- sub_repo = re.sub(r'[^a-z0-9\-]+', '-', ui_space_name.lower()).strip('-') or 'space'
607
- iframe_url = f"https://{sub_owner}-{sub_repo}{'.static.hf.space' if sdk == 'static' else '.hf.space'}"
608
- outputs[6] = gr.update(value=f'<iframe src="{iframe_url}?__theme=light&embed=true" width="100%" height="500px"></iframe>', visible=True)
609
-
610
  loaded_files = []
611
  for file_path in file_list:
612
  content, err_get = get_space_file_content(hf_api_key_ui, ui_space_name, owner_to_use, file_path)
613
  lang = _infer_lang_from_filename(file_path)
614
- is_binary = lang == "binary" or err_get
615
- code = f"[Error loading content: {err_get}]" if err_get else content
616
  loaded_files.append({"filename": file_path, "code": code, "language": lang, "is_binary": is_binary, "is_structure_block": False})
617
 
 
 
618
  parsed_code_blocks_state_cache = loaded_files
 
619
  _formatted, _detected, _download = _generate_ui_outputs_from_cache(owner_to_use, ui_space_name)
620
- _status_val = f"Successfully loaded {len(file_list)} files from {owner_to_use}/{ui_space_name}."
621
- outputs[0], outputs[1], outputs[2], outputs[7] = _formatted, _detected, _status_val, _download
 
 
622
  outputs[3] = gr.update(visible=True, choices=sorted(file_list or []), value=None)
 
 
 
 
 
 
 
 
 
 
 
623
  yield outputs
624
 
 
 
625
  def handle_build_space_button(hf_api_key_ui, ui_space_name_part, ui_owner_name_part, space_sdk_ui, is_private_ui, formatted_markdown_content):
626
- _build_status, _iframe_html, _file_browser_update = "Starting space build process...", gr.update(value=None, visible=False), gr.update(visible=False, choices=[], value=None)
627
- yield _build_status, _iframe_html, _file_browser_update, gr.update(value=ui_owner_name_part), gr.update(value=ui_space_name_part)
 
 
 
 
 
 
 
628
  if not ui_space_name_part or "/" in ui_space_name_part:
629
  _build_status = f"Build Error: Invalid Space Name '{ui_space_name_part}'."
630
- yield _build_status, _iframe_html, _file_browser_update, gr.update(), gr.update(); return
 
631
 
 
632
  result_message = build_logic_create_space(ui_api_token_from_textbox=hf_api_key_ui, space_name_ui=ui_space_name_part, owner_ui=ui_owner_name_part, sdk_ui=space_sdk_ui, markdown_input=formatted_markdown_content, private=is_private_ui)
633
- _build_status = f"Build Process: {result_message}"
634
 
635
  if "Successfully" in result_message:
636
- sub_owner = re.sub(r'[^a-z0-9\-]+', '-', ui_owner_name_part.lower()).strip('-')
637
- sub_repo = re.sub(r'[^a-z0-9\-]+', '-', ui_space_name_part.lower()).strip('-')
638
- iframe_url = f"https://{sub_owner}-{sub_repo}{'.static.hf.space' if space_sdk_ui == 'static' else '.hf.space'}"
639
- _iframe_html = gr.update(value=f'<iframe src="{iframe_url}?__theme=light&embed=true" width="100%" height="700px"></iframe>', visible=True)
640
- file_list, err = list_space_files_for_browsing(hf_api_key_ui, ui_space_name_part, ui_owner_name_part)
641
- _file_browser_update = gr.update(visible=True, choices=sorted(file_list or []), value=None)
642
- yield _build_status, _iframe_html, _file_browser_update, gr.update(value=ui_owner_name_part), gr.update(value=ui_space_name_part)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
643
 
644
  def handle_load_file_for_editing(hf_api_key_ui, ui_space_name_part, ui_owner_name_part, selected_file_path):
645
  if not selected_file_path:
@@ -648,23 +882,46 @@ def handle_load_file_for_editing(hf_api_key_ui, ui_space_name_part, ui_owner_nam
648
 
649
  content, err = get_space_file_content(hf_api_key_ui, ui_space_name_part, ui_owner_name_part, selected_file_path)
650
  if err:
651
- yield f"Error: {err}", f"Error loading '{selected_file_path}': {err}", "", gr.update(language="plaintext")
 
652
  return
653
 
654
  lang = _infer_lang_from_filename(selected_file_path)
655
  commit_msg = f"Update {selected_file_path}"
656
- yield content, f"Loaded {selected_file_path}", commit_msg, gr.update(language=lang)
657
 
658
  def handle_commit_file_changes(hf_api_key_ui, ui_space_name_part, ui_owner_name_part, file_to_edit_path, edited_content, commit_message):
 
 
 
659
  status_msg = update_space_file(hf_api_key_ui, ui_space_name_part, ui_owner_name_part, file_to_edit_path, edited_content, commit_message)
660
- file_list, _ = list_space_files_for_browsing(hf_api_key_ui, ui_space_name_part, ui_owner_name_part)
661
  global parsed_code_blocks_state_cache
662
  if "Successfully" in status_msg:
663
- # Update cache
 
 
664
  for block in parsed_code_blocks_state_cache:
665
- if block["filename"] == file_to_edit_path:
666
  block["code"] = edited_content
 
 
 
667
  break
 
 
 
 
 
 
 
 
 
 
 
 
 
 
668
  _formatted, _detected, _download = _generate_ui_outputs_from_cache(ui_owner_name_part, ui_space_name_part)
669
  return status_msg, gr.update(choices=sorted(file_list or [])), _formatted, _detected, _download
670
 
@@ -673,26 +930,58 @@ def handle_delete_file(hf_api_key_ui, ui_space_name_part, ui_owner_name_part, fi
673
  return "No file selected to delete.", gr.update(), "", "", "plaintext", gr.update(), gr.update(), gr.update()
674
 
675
  status_msg = build_logic_delete_space_file(hf_api_key_ui, ui_space_name_part, ui_owner_name_part, file_to_delete_path)
676
- file_list, _ = list_space_files_for_browsing(hf_api_key_ui, ui_space_name_part, ui_owner_name_part)
677
  global parsed_code_blocks_state_cache
678
  if "Successfully" in status_msg:
 
679
  parsed_code_blocks_state_cache = [b for b in parsed_code_blocks_state_cache if b["filename"] != file_to_delete_path]
 
 
 
 
 
 
 
 
 
 
 
 
680
  _formatted, _detected, _download = _generate_ui_outputs_from_cache(ui_owner_name_part, ui_space_name_part)
681
- return status_msg, gr.update(choices=sorted(file_list or []), value=None), "", "", "plaintext", _formatted, _detected, _download
 
 
 
 
 
 
 
 
 
682
 
683
  def handle_refresh_space_status(hf_api_key_ui, ui_owner_name, ui_space_name):
684
  if not ui_owner_name or not ui_space_name:
685
- return "Owner and Space Name must be provided to get status."
686
 
687
- status, err = get_space_runtime_status(hf_api_key_ui, ui_space_name, ui_owner_name)
688
  if err: return f"**Error:** {err}"
689
- if not status: return "Could not retrieve status."
690
 
691
  md = f"### Status for {ui_owner_name}/{ui_space_name}\n"
692
- for key, val in status.items():
693
- md += f"- **{key.replace('_', ' ').title()}:** `{val}`\n"
 
 
 
 
 
 
 
 
 
694
  return md
695
 
 
696
  # --- UI Theming and CSS (Unchanged) ---
697
  custom_theme = gr.themes.Base(primary_hue="teal", secondary_hue="purple", neutral_hue="zinc", text_size="sm", spacing_size="md", radius_size="sm", font=["System UI", "sans-serif"])
698
  custom_css = """
@@ -703,39 +992,58 @@ body { background: linear-gradient(to bottom right, #2c3e50, #34495e); color: #e
703
  .gr-button.gr-button-primary { background-color: #1abc9c !important; color: white !important; border-color: #16a085 !important; }
704
  .gr-button.gr-button-secondary { background-color: #9b59b6 !important; color: white !important; border-color: #8e44ad !important; }
705
  .gr-button.gr-button-stop { background-color: #e74c3c !important; color: white !important; border-color: #c0392b !important; }
706
- .gr-markdown { background-color: rgba(44, 62, 80, 0.7) !important; padding: 10px; border-radius: 5px; }
707
  .gr-markdown h1, .gr-markdown h2, .gr-markdown h3, .gr-markdown h4, .gr-markdown h5, .gr-markdown h6 { color: #ecf0f1 !important; border-bottom-color: rgba(189, 195, 199, 0.3) !important; }
708
  .gr-markdown pre code { background-color: rgba(52, 73, 94, 0.95) !important; border-color: rgba(189, 195, 199, 0.3) !important; }
709
  .gr-chatbot { background-color: rgba(44, 62, 80, 0.7) !important; border-color: rgba(189, 195, 199, 0.2) !important; }
710
  .gr-chatbot .message { background-color: rgba(52, 73, 94, 0.9) !important; color: #ecf0f1 !important; border-color: rgba(189, 195, 199, 0.3) !important; }
711
  .gr-chatbot .message.user { background-color: rgba(46, 204, 113, 0.9) !important; color: black !important; }
 
 
 
 
 
712
  """
713
 
 
714
  # --- Gradio UI Definition ---
715
  with gr.Blocks(theme=custom_theme, css=custom_css) as demo:
716
- # State to hold the plan
717
  changeset_state = gr.State([])
 
 
 
 
718
 
719
- gr.Markdown("# πŸ€– AI-Powered Hugging Face Space Builder")
720
  gr.Markdown("Use an AI assistant to create, modify, build, and manage your Hugging Face Spaces directly from this interface.")
721
  gr.Markdown("## ❗ This will cause changes to your huggingface spaces if you give it your Huggingface Key")
722
- gr.Markdown("βš’ Under Development")
 
723
 
724
  with gr.Sidebar():
725
  with gr.Column(scale=1):
726
  with gr.Accordion("βš™οΈ Configuration", open=True):
727
  hf_api_key_input = gr.Textbox(label="Hugging Face Token", type="password", placeholder="hf_... (uses env var HF_TOKEN if empty)")
728
  owner_name_input = gr.Textbox(label="HF Owner Name", placeholder="e.g., your-username")
729
- space_name_input = gr.Textbox(label="HF Space Name", value="my-ai-space")
730
  load_space_button = gr.Button("πŸ”„ Load Existing Space", variant="secondary")
 
 
 
 
 
731
 
732
  with gr.Accordion("πŸ€– AI Model Settings", open=True):
733
  # --- MODIFIED: Set up default provider and model logic on load ---
734
  available_providers = get_available_providers()
735
  default_provider = 'Groq'
736
- # Fallback if 'Groq' is not an option
737
  if default_provider not in available_providers:
738
- default_provider = available_providers[2] if available_providers else None
 
 
 
739
 
740
  # Get initial models and the default model for the selected provider
741
  initial_models = get_models_for_provider(default_provider) if default_provider else []
@@ -747,12 +1055,14 @@ with gr.Blocks(theme=custom_theme, css=custom_css) as demo:
747
  provider_select = gr.Dropdown(
748
  label="AI Provider",
749
  choices=available_providers,
750
- value=default_provider
 
751
  )
752
  model_select = gr.Dropdown(
753
  label="AI Model",
754
  choices=initial_models,
755
- value=initial_model
 
756
  )
757
  # --- END MODIFICATION ---
758
  provider_api_key_input = gr.Textbox(label="Model Provider API Key (Optional)", type="password", placeholder="sk_... (overrides backend settings)")
@@ -766,9 +1076,9 @@ with gr.Blocks(theme=custom_theme, css=custom_css) as demo:
766
  send_chat_button = gr.Button("Send", variant="primary", scale=1)
767
  status_output = gr.Textbox(label="Last Action Status", interactive=False, value="Ready.")
768
 
769
- # Confirmation Accordion
770
- with gr.Accordion("πŸ“ Proposed Changes (Pending Confirmation)", visible=False) as confirm_accordion:
771
- changeset_display = gr.Markdown("No changes proposed.")
772
  with gr.Row():
773
  confirm_button = gr.Button("βœ… Confirm & Apply Changes", variant="primary", visible=False)
774
  cancel_button = gr.Button("❌ Cancel", variant="stop", visible=False)
@@ -777,39 +1087,56 @@ with gr.Blocks(theme=custom_theme, css=custom_css) as demo:
777
  with gr.TabItem("πŸ“ Generated Markdown & Build"):
778
  with gr.Row():
779
  with gr.Column(scale=2):
780
- formatted_space_output_display = gr.Textbox(label="Current Space Definition (Editable)", lines=20, interactive=True, value="*Load or create a space to see its definition.*")
 
781
  download_button = gr.DownloadButton(label="Download .md", interactive=False)
782
  with gr.Column(scale=1):
783
  gr.Markdown("### Build Controls")
784
- space_sdk_select = gr.Dropdown(label="Space SDK", choices=["gradio", "streamlit", "docker", "static"], value="gradio")
785
- space_private_checkbox = gr.Checkbox(label="Make Space Private", value=False)
786
- build_space_button = gr.Button("πŸš€ Build / Update Space from Manual Edit", variant="primary")
787
- build_status_display = gr.Textbox(label="Build Operation Status", interactive=False)
 
 
 
 
788
  refresh_status_button = gr.Button("πŸ”„ Refresh Runtime Status")
789
  space_runtime_status_display = gr.Markdown("*Runtime status will appear here.*")
790
 
791
  with gr.TabItem("πŸ” Files Preview"):
 
792
  detected_files_preview = gr.Markdown(value="*A preview of the latest file versions will appear here.*")
793
 
794
  with gr.TabItem("✏️ Live File Editor & Preview"):
795
  with gr.Row():
796
  with gr.Column(scale=1):
797
  gr.Markdown("### Live Editor")
 
798
  file_browser_dropdown = gr.Dropdown(label="Select File in Space", choices=[], interactive=True)
799
- file_content_editor = gr.Code(label="File Content Editor", language="python", lines=15, interactive=True)
800
- commit_message_input = gr.Textbox(label="Commit Message", placeholder="e.g., Updated app.py")
 
801
  with gr.Row():
802
- update_file_button = gr.Button("Commit Changes", variant="primary")
803
- delete_file_button = gr.Button("πŸ—‘οΈ Delete Selected File", variant="stop")
804
- edit_status_display = gr.Textbox(label="File Edit/Delete Status", interactive=False)
 
805
  with gr.Column(scale=1):
806
  gr.Markdown("### Live Space Preview")
 
807
  space_iframe_display = gr.HTML(value="", visible=True)
808
 
809
  # --- Event Listeners ---
 
 
810
  provider_select.change(update_models_dropdown, inputs=provider_select, outputs=model_select)
811
 
812
- chat_inputs = [chat_message_input, chatbot_display, hf_api_key_input, provider_api_key_input, provider_select, model_select, system_prompt_input, owner_name_input, space_name_input]
 
 
 
 
 
813
  chat_outputs = [
814
  chat_message_input, chatbot_display, status_output,
815
  detected_files_preview, formatted_space_output_display, download_button,
@@ -818,11 +1145,11 @@ with gr.Blocks(theme=custom_theme, css=custom_css) as demo:
818
  send_chat_button.click(handle_chat_submit, inputs=chat_inputs, outputs=chat_outputs)
819
  chat_message_input.submit(handle_chat_submit, inputs=chat_inputs, outputs=chat_outputs)
820
 
821
- # Confirmation Button Listeners
822
  confirm_inputs = [hf_api_key_input, owner_name_input, space_name_input, changeset_state]
823
  confirm_outputs = [
824
  status_output, formatted_space_output_display, detected_files_preview, download_button,
825
- confirm_accordion, confirm_button, cancel_button, changeset_state
826
  ]
827
  confirm_button.click(handle_confirm_changes, inputs=confirm_inputs, outputs=confirm_outputs)
828
 
@@ -832,24 +1159,54 @@ with gr.Blocks(theme=custom_theme, css=custom_css) as demo:
832
  ]
833
  cancel_button.click(handle_cancel_changes, inputs=None, outputs=cancel_outputs)
834
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
835
 
836
- load_space_outputs = [formatted_space_output_display, detected_files_preview, status_output, file_browser_dropdown, owner_name_input, space_name_input, space_iframe_display, download_button, build_status_display, edit_status_display, space_runtime_status_display, chatbot_display]
837
- load_space_button.click(fn=handle_load_existing_space, inputs=[hf_api_key_input, owner_name_input, space_name_input], outputs=load_space_outputs)
838
-
839
- build_outputs = [build_status_display, space_iframe_display, file_browser_dropdown, owner_name_input, space_name_input]
840
- build_inputs = [hf_api_key_input, space_name_input, owner_name_input, space_sdk_select, space_private_checkbox, formatted_space_output_display]
 
 
 
 
 
 
841
  build_space_button.click(fn=handle_build_space_button, inputs=build_inputs, outputs=build_outputs)
842
 
 
 
843
  file_edit_load_outputs = [file_content_editor, edit_status_display, commit_message_input, file_content_editor] # last one updates language
844
  file_browser_dropdown.change(fn=handle_load_file_for_editing, inputs=[hf_api_key_input, space_name_input, owner_name_input, file_browser_dropdown], outputs=file_edit_load_outputs)
845
 
 
846
  commit_file_outputs = [edit_status_display, file_browser_dropdown, formatted_space_output_display, detected_files_preview, download_button]
847
  update_file_button.click(fn=handle_commit_file_changes, inputs=[hf_api_key_input, space_name_input, owner_name_input, file_browser_dropdown, file_content_editor, commit_message_input], outputs=commit_file_outputs)
848
 
849
- delete_file_outputs = [edit_status_display, file_browser_dropdown, file_content_editor, commit_message_input, file_content_editor, formatted_space_output_display, detected_files_preview, download_button]
 
 
 
 
 
850
  delete_file_button.click(fn=handle_delete_file, inputs=[hf_api_key_input, space_name_input, owner_name_input, file_browser_dropdown], outputs=delete_file_outputs)
851
 
 
852
  refresh_status_button.click(fn=handle_refresh_space_status, inputs=[hf_api_key_input, owner_name_input, space_name_input], outputs=[space_runtime_status_display])
853
 
854
  if __name__ == "__main__":
855
- demo.launch(debug=False, mcp_server=True)
 
 
1
+
2
  import gradio as gr
3
  import re
4
  import json
 
7
  import shlex
8
  from huggingface_hub import HfApi
9
 
10
+ # Moved to build_logic.py
11
+ # from build_logic import build_logic_set_space_privacy
12
+ # from build_logic import build_logic_delete_space
13
+
14
  try:
15
  from build_logic import (
16
+ # build_logic_create_space, # This is now handled by apply_staged_changes for AI
17
  _get_api_token as build_logic_get_api_token,
18
  whoami as build_logic_whoami,
19
  list_space_files_for_browsing,
20
+ get_space_repository_info, # Used for initial load and status
21
  get_space_file_content,
22
+ update_space_file, # Keep for manual editing
23
  parse_markdown as build_logic_parse_markdown,
24
+ delete_space_file as build_logic_delete_space_file, # Keep for manual deletion
25
+ get_space_runtime_status,
26
+ apply_staged_changes, # NEW: Main function for applying AI changes
27
+ build_logic_set_space_privacy, # Moved from app.py
28
+ build_logic_delete_space # Moved from app.py
29
  )
30
  print("build_logic.py loaded successfully.")
31
 
 
36
  generate_stream
37
  )
38
  print("model_logic.py loaded successfully.")
39
+ except ImportError as e:
40
+ print(f"Warning: Local modules (build_logic.py, model_logic.py) not found. Using dummy functions. Error: {e}")
41
  def get_available_providers(): return ["DummyProvider", "Groq"] # Added Groq for testing
42
  def get_models_for_provider(p):
43
  if p == 'Groq': return ["llama3-8b-8192", "gemma-7b-it"]
 
47
  return "dummy-model"
48
  # The dummy function already accepts the api_key argument ('a')
49
  def generate_stream(p, m, a, msgs):
50
+ yield f"Using dummy model. API Key provided: {'Yes' if a else 'No'}. This is a dummy response as local modules were not found.\n" + bbb + "text\n### File: dummy.txt\nHello from dummy model!\n" + bbb
51
+ # Dummy build_logic functions
52
+ def build_logic_create_space(*args, **kwargs): return "Error: build_logic not found (Dummy)."
53
  def build_logic_get_api_token(key): return (key or os.getenv("HF_TOKEN"), None)
54
  def build_logic_whoami(token): return {"name": "dummy_user"}
55
+ def list_space_files_for_browsing(*args): return ([], "Error: build_logic not found (Dummy).")
56
+ def get_space_repository_info(*args): return (None, [], "Error: build_logic not found (Dummy).")
57
+ def get_space_file_content(*args): return ("", "Error: build_logic not found (Dummy).")
58
+ def update_space_file(*args, **kwargs): return "Error: build_logic not found (Dummy)."
59
+ def build_logic_parse_markdown(md):
60
+ # Dummy parser attempts to find files for testing
61
+ files = []
62
+ file_pattern = re.compile(r"### File:\s*(?P<filename_line>[^\n]+)\n(?:```(?P<lang>[\w\.\-\+]*)\n(?P<code>[\s\S]*?)\n```|(?P<binary_msg>\[Binary file(?: - [^\]]+)?\]))")
63
+ for match in file_pattern.finditer(md):
64
+ filename = _clean_filename(match.group("filename_line"))
65
+ if filename: files.append({"path": filename, "content": match.group("code") or match.group("binary_msg") or ""})
66
+ return {"repo_name_md": "dummy/space", "owner_md": "dummy", "files": files}
67
+ def build_logic_delete_space_file(*args): return "Error: build_logic not found (Dummy)."
68
+ def get_space_runtime_status(*args): return ({"stage": "DUMMY", "hardware": "dummy", "status": "dummy"}, "Error: build_logic not found (Dummy).")
69
+ def apply_staged_changes(*args, **kwargs): return "Error: apply_staged_changes not found (Dummy).", [], None
70
+ def build_logic_set_space_privacy(*args): return "Error: build_logic_set_space_privacy not found (Dummy)."
71
+ def build_logic_delete_space(*args): return "Error: build_logic_delete_space not found (Dummy)."
72
 
73
+ # --- END: Dummy functions ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
74
 
75
 
76
  # --- CORE FIX: Define triple backticks safely to prevent Markdown rendering issues ---
77
  backtick = chr(96)
78
  bbb = f'{backtick}{backtick}{backtick}'
79
 
80
+ # State variable to hold the *current* representation of the Space's files and structure.
81
+ # This is populated on load and updated by AI outputs or manual edits/deletes.
82
  parsed_code_blocks_state_cache = []
83
  BOT_ROLE_NAME = "assistant"
84
 
85
  DEFAULT_SYSTEM_PROMPT = f"""You are an expert AI programmer and Hugging Face assistant. Your role is to generate code and file structures based on user requests, or to modify existing code provided by the user.
86
 
87
+ You operate in the context of a Hugging Face Space. You can propose file changes and trigger specific actions.
88
+
89
  **File and Code Formatting:**
90
  When you provide NEW code for a file, or MODIFIED code for an existing file, use the following format exactly:
91
  ### File: path/to/filename.ext
 
98
  ### File: path/to/binaryfile.ext
99
  [Binary file - approximate_size bytes]
100
 
101
+ When you provide a project file structure, use this format (for informational purposes, not parsed for changes):
102
  ## File Structure
103
  {bbb}
104
  πŸ“ Root
 
111
  - The role name for your responses in the chat history must be '{BOT_ROLE_NAME}'.
112
  - Adhere strictly to these formatting instructions.
113
  - If you update a file, provide the FULL file content again under the same filename.
114
+ - Only the latest version of each file mentioned throughout the chat will be used for the final output. The system will merge your changes with the prior state before applying.
 
115
 
116
  **Hugging Face Space Actions:**
117
+ To perform direct actions on the Hugging Face Space, use the `### HF_ACTION: COMMAND arguments...` command on a single line.
118
+ The system will parse these actions from your response and present them to the user for confirmation before executing.
119
 
120
  Available commands:
121
+ - `CREATE_SPACE <owner>/<repo_name> --sdk <sdk> --private <true|false>`: Creates a new, empty space. SDK can be gradio, streamlit, docker, or static. Private is optional and defaults to false. Use this ONLY when the user explicitly asks to create a *new* space with a specific name. When using this action, also provide the initial file structure (e.g., app.py, README.md) using `### File:` blocks in the same response. The system will apply these files to the new space.
122
  - `DELETE_FILE path/to/file.ext`: Deletes a specific file from the current space.
123
  - `SET_PRIVATE <true|false>`: Sets the privacy for the current space.
124
+ - `DELETE_SPACE`: Deletes the entire current space. THIS IS PERMANENT AND REQUIRES CAUTION. Only use this if the user explicitly and clearly asks to delete the space.
125
 
126
+ You can issue multiple file updates and action commands in a single response. The system will process all of them into a single change plan.
127
+
128
+ **Current Space Context:**
129
+ You will be provided with the current state of the files in the Space the user is interacting with. Use this information to understand the current project structure and content before proposing changes or actions. This context will appear after the user's message, starting with "## Current Space Context:". Do NOT include this context in your response. Only generate your response based on the user's request and the formatting rules above.
130
+
131
+ If no code or actions are requested, respond conversationally and help the user understand the Space Commander's capabilities.
 
132
  """
133
 
134
+ # --- Helper Functions ---
135
+ # Keep existing helper functions (_infer_lang_from_filename, _clean_filename, etc.)
136
+ # Refine _parse_chat_stream_logic to integrate with the state cache
137
+
138
  def escape_html_for_markdown(text):
139
  if not isinstance(text, str): return ""
140
+ return text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;") # Use &amp; for safety
141
 
142
  def _infer_lang_from_filename(filename):
143
  if not filename: return "plaintext"
 
166
 
167
  def _clean_filename(filename_line_content):
168
  text = filename_line_content.strip()
169
+ # Remove markdown formatting characters aggressively
170
+ text = re.sub(r'[`\*_#]+', '', text).strip()
171
+ # Remove parenthesized descriptions
172
+ text = re.split(r'\s*\(', text, 1)[0].strip()
173
+ # Remove leading/trailing quotes or colons sometimes added by models
174
+ text = text.strip('\'":;,')
175
+ # Ensure it doesn't start with '/'
176
+ text = text.lstrip('/')
177
+ return text
178
+
179
+ def _parse_and_update_state_cache(latest_bot_message_content, current_files_state):
180
+ """
181
+ Parses the latest bot message content for file blocks and updates the
182
+ global state cache with the latest version of each file.
183
+ Returns the updated state cache and a list of filenames proposed in this turn.
184
+ """
185
+ # Start with a dictionary representation of the current state for easy updates
186
+ current_files_dict = {f["filename"]: f.copy() for f in current_files_state if not f.get("is_structure_block")}
187
+ structure_block_state = next((b for b in current_files_state if b.get("is_structure_block")), None)
 
 
 
 
 
188
 
 
189
  content = latest_bot_message_content or ""
190
 
191
+ file_pattern = re.compile(r"### File:\s*(?P<filename_line>[^\n]+)\n(?:```(?P<lang>[\w\.\-\+]*)\n(?P<code>[\s\S]*?)\n```|(?P<binary_msg>\[Binary file(?: - [^\]]+)?\]))", re.MULTILINE)
192
+ structure_pattern = re.compile(r"## File Structure\n```(?:(?P<struct_lang>[\w.-]*)\n)?(?P<structure_code>[\s\S]*?)\n```", re.MULTILINE)
193
 
194
+ # Parse File Structure block if present in the latest message (overwrites previous structure block)
195
  structure_match = structure_pattern.search(content)
196
  if structure_match:
197
+ structure_block_state = {"filename": "File Structure (from AI)", "language": structure_match.group("struct_lang") or "plaintext", "code": structure_match.group("structure_code").strip(), "is_binary": False, "is_structure_block": True}
 
 
 
 
198
 
199
+ current_message_proposed_filenames = []
200
+ # Parse file blocks from the latest message
201
  for match in file_pattern.finditer(content):
202
  filename = _clean_filename(match.group("filename_line"))
203
+ if not filename:
204
+ print(f"Warning: Skipped file block due to empty/invalid filename parsing: '{match.group('filename_line').strip()}'")
205
+ continue # Skip if filename couldn't be parsed
206
+
207
  lang, code_block, binary_msg = match.group("lang"), match.group("code"), match.group("binary_msg")
208
+
209
  item_data = {"filename": filename, "is_binary": False, "is_structure_block": False}
210
+
211
  if code_block is not None:
212
+ item_data["code"] = code_block.strip()
213
+ item_data["language"] = (lang.strip().lower() if lang else _infer_lang_from_filename(filename))
214
+ item_data["is_binary"] = False # Ensure explicit False if it's a code block
215
  elif binary_msg is not None:
216
+ item_data["code"] = binary_msg.strip()
217
+ item_data["language"] = "binary"
218
+ item_data["is_binary"] = True
219
+ else:
220
+ # This case shouldn't be hit with the current regex, but as a safeguard
221
+ print(f"Warning: Skipped file block for '{filename}' due to missing code or binary marker.")
222
+ continue # Skip if content is neither code nor binary marker
223
+
224
+ # Update or add the file in the dictionary state
225
+ current_files_dict[filename] = item_data
226
+ current_message_proposed_filenames.append(filename)
227
+
228
+
229
+ # Convert dictionary back to a list, add structure block if present
230
+ updated_parsed_blocks = list(current_files_dict.values())
231
+ if structure_block_state:
232
+ updated_parsed_blocks.insert(0, structure_block_state) # Add structure block at the beginning
233
+
234
+ # Sort for consistent ordering
235
+ updated_parsed_blocks.sort(key=lambda b: (0, b["filename"]) if b.get("is_structure_block") else (1, b["filename"]))
236
+
237
+ return updated_parsed_blocks, current_message_proposed_filenames
238
+
239
 
240
  def _export_selected_logic(selected_filenames, space_line_name_for_md, parsed_blocks_for_export):
241
+ """Generates the Markdown representation of the space state or selected files."""
242
  results = {"output_str": "", "error_message": None, "download_filepath": None}
243
+ # Only include blocks that are files (not structure) for content export/display
244
+ file_blocks_for_export = [b for b in parsed_blocks_for_export if not b.get("is_structure_block")]
245
+
246
+ # Determine filenames present in the state that can potentially be exported/listed
247
+ all_filenames_in_state = sorted(list(set(b["filename"] for b in file_blocks_for_export)))
248
 
249
+ if not all_filenames_in_state:
250
+ results["output_str"] = f"# Space: {space_line_name_for_md}\n## File Structure\n{bbb}\nπŸ“ Root\n{bbb}\n\n*No files in state to list structure or export.*"
251
+ # Even if no files, create a temp file for download button functionality
252
  try:
253
  with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".md", encoding='utf-8') as tmpfile:
254
  tmpfile.write(results["output_str"]); results["download_filepath"] = tmpfile.name
255
+ except Exception as e: print(f"Error creating temp file for empty state export: {e}")
256
  return results
257
 
258
  output_lines = [f"# Space: {space_line_name_for_md}"]
259
+
260
+ # Add File Structure block if it exists in the state
261
  structure_block = next((b for b in parsed_blocks_for_export if b.get("is_structure_block")), None)
262
  if structure_block:
263
  output_lines.extend(["## File Structure", bbb, structure_block["code"].strip(), bbb, ""])
264
  else:
265
+ # If no AI-generated structure block, create a basic one from file list
266
  output_lines.extend(["## File Structure", bbb, "πŸ“ Root"])
267
  if all_filenames_in_state:
268
+ for fname in all_filenames_in_state: output_lines.append(f" πŸ“„ {fname}") # Basic flattening
269
  output_lines.extend([bbb, ""])
270
 
271
  output_lines.append("Below are the contents of all files in the space:\n")
 
 
 
272
 
273
+ # Filter blocks to export content based on selection
274
+ # If selected_filenames is None or empty, export all file blocks
275
+ blocks_to_export_content = sorted([b for b in file_blocks_for_export if not selected_filenames or b["filename"] in selected_filenames], key=lambda b: b["filename"])
276
+
277
+ exported_content_count = 0
278
+ for block in blocks_to_export_content:
279
  output_lines.append(f"### File: {block['filename']}")
280
+ content = block.get('code', '')
281
+ if block.get('is_binary') or content.startswith(("[Binary file", "[Error loading content:", "[Binary or Skipped file]")):
282
+ # For binary/error placeholders, just print the marker line
283
+ output_lines.append(content)
284
  else:
285
+ # For text content, wrap in code block
286
+ lang = block.get('language', 'plaintext') or 'plaintext' # Ensure language is not None or empty
287
+ output_lines.extend([f"{bbb}{lang}", content, bbb])
288
+ output_lines.append("") # Add blank line after each file block definition
289
+ exported_content_count += 1
290
 
291
+ if not exported_content_count:
292
+ if selected_filenames:
293
+ output_lines.append("*No selected files have editable content in the state.*")
294
+ # else: already handled by the initial check for all_filenames_in_state
295
 
296
  final_output_str = "\n".join(output_lines)
297
  results["output_str"] = final_output_str
298
  try:
299
+ # Create a temporary file for the download button
300
  with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".md", encoding='utf-8') as tmpfile:
301
+ tmpfile.write(final_output_str)
302
+ results["download_filepath"] = tmpfile.name
303
  except Exception as e:
304
+ print(f"Error creating temp file for download: {e}")
305
  results["error_message"] = "Could not prepare file for download."
306
+ results["download_filepath"] = None # Ensure download is disabled on error
307
+
308
  return results
309
 
310
  def _convert_gr_history_to_api_messages(system_prompt, gr_history, current_user_message=None):
 
312
  for user_msg, bot_msg in gr_history:
313
  if user_msg: messages.append({"role": "user", "content": user_msg})
314
  if bot_msg and isinstance(bot_msg, str): messages.append({"role": BOT_ROLE_NAME, "content": bot_msg})
315
+ # Append the current user message last if provided
316
  if current_user_message: messages.append({"role": "user", "content": current_user_message})
317
  return messages
318
 
319
  def _generate_ui_outputs_from_cache(owner, space_name):
320
+ """Generates the Markdown displays and download link from the global state cache."""
321
  global parsed_code_blocks_state_cache
322
+ # Markdown preview displays the *latest* version of each file from the cache
323
  preview_md_val = "*No files in cache to display.*"
324
+ # Formatted markdown is the full representation including structure and all files
325
+ space_line_name = f"{owner}/{space_name}" if owner and space_name else (owner or space_name or "your-space")
326
+ export_result = _export_selected_logic(None, space_line_name, parsed_code_blocks_state_cache)
327
+ formatted_md_val = export_result["output_str"]
328
+ download_file = export_result["download_filepath"]
329
+ formatted_md_val = formatted_md_val or "*Load or define a Space to see its Markdown structure.*" # Fallback text
330
 
331
  if parsed_code_blocks_state_cache:
332
  preview_md_lines = ["## Detected/Updated Files & Content (Latest Versions):"]
333
+ file_blocks_in_cache = sorted([b for b in parsed_code_blocks_state_cache if not b.get("is_structure_block")], key=lambda b: b["filename"])
334
+ structure_block_in_cache = next((b for b in parsed_code_blocks_state_cache if b.get("is_structure_block")), None)
335
+
336
+ if structure_block_in_cache:
337
+ preview_md_lines.append(f"\n----\n**File Structure:** (from AI)\n{bbb}\n{escape_html_for_markdown(structure_block_in_cache['code'].strip())}\n{bbb}\n")
338
+
339
+ if file_blocks_in_cache:
340
+ for block in file_blocks_in_cache:
341
+ preview_md_lines.append(f"\n----\n**File:** `{escape_html_for_markdown(block['filename'])}`")
342
+ if block.get('is_binary'): preview_md_lines.append(f" (Binary File or Skipped)\n")
343
+ else: preview_md_lines.append(f" (Language: `{block['language']}`)\n")
344
+
345
+ content = block.get('code', '')
346
+ if block.get('is_binary') or content.startswith(("[Binary file", "[Error loading content:", "[Binary or Skipped file]")):
347
+ preview_md_lines.append(f"\n`{escape_html_for_markdown(content.strip())}`\n") # Strip for preview markdown
348
+ else:
349
+ # Use 3 backticks for code block in preview
350
+ lang = block.get('language', 'plaintext') or 'plaintext'
351
+ preview_md_lines.append(f"\n{bbb}{lang}\n{content.strip()}\n{bbb}\n") # Strip for preview markdown
352
  preview_md_val = "\n".join(preview_md_lines)
353
+
 
 
 
354
 
355
  return formatted_md_val, preview_md_val, gr.update(value=download_file, interactive=download_file is not None)
356
 
 
358
 
359
  def generate_and_stage_changes(ai_response_content, current_files_state, hf_owner_name, hf_repo_name):
360
  """
361
+ Parses AI response, compares with current state (from cache),
362
+ and generates a structured changeset and a markdown summary.
363
  """
364
  changeset = []
365
  current_files_dict = {f["filename"]: f for f in current_files_state if not f.get("is_structure_block")}
366
 
367
+ # 1. Parse AI response for actions and file blocks
368
+ # Use build_logic_parse_markdown to get file blocks in the AI's desired format
369
+ ai_parsed_md = build_logic_parse_markdown(ai_response_content)
370
+ ai_proposed_files_list = ai_parsed_md.get("files", []) # List of {"path": ..., "content": ...}
371
+
372
+ # Convert AI proposed files list to dict for easier lookup and comparison
373
+ ai_proposed_files_dict = {f["path"]: f for f in ai_proposed_files_list}
374
+
375
 
376
+ # Parse HF_ACTION commands from AI response using regex on the raw content
377
+ action_pattern = re.compile(r"### HF_ACTION:\s*(?P<command_line>[^\n]+)", re.MULTILINE)
378
  for match in action_pattern.finditer(ai_response_content):
379
  cmd_parts = shlex.split(match.group("command_line").strip())
380
  if not cmd_parts: continue
381
  command, args = cmd_parts[0].upper(), cmd_parts[1:]
382
 
383
  # Add actions to the changeset
384
+ if command == "CREATE_SPACE" and args:
385
+ # The AI command specifies the target repo_id
 
 
 
 
 
386
  repo_id = args[0]
387
  sdk = "gradio" # default
388
  private = False # default
389
+ if '--sdk' in args:
390
+ try: sdk = args[args.index('--sdk') + 1]
391
+ except IndexError: print("Warning: CREATE_SPACE --sdk requires an argument.")
392
+ if '--private' in args:
393
+ try: private_str = args[args.index('--private') + 1].lower()
394
+ except IndexError: print("Warning: CREATE_SPACE --private requires an argument.")
395
+ else: private = private_str == 'true'
396
+ # Action includes target repo, sdk, and private setting
397
  changeset.append({"type": "CREATE_SPACE", "repo_id": repo_id, "sdk": sdk, "private": private})
398
+ print(f"Staged CREATE_SPACE action for {repo_id}")
399
+
400
+ elif command == "DELETE_FILE" and args:
401
+ file_path = args[0]
402
+ changeset.append({"type": "DELETE_FILE", "path": file_path})
403
+ print(f"Staged DELETE_FILE action for {file_path}")
404
+ # Note: AI might propose deleting a file it *just* created/updated in the same turn.
405
+ # The application logic handle_confirm_changes should process deletes *before* adds/updates,
406
+ # or the commit operation itself should handle the conflict gracefully (delete then re-add/update).
407
+ # The current `apply_staged_changes` does deletes first, then uploads.
408
+
409
+ elif command == "SET_PRIVATE" and args:
410
+ private = args[0].lower() == 'true'
411
+ # Action applies to the currently loaded space
412
+ changeset.append({"type": "SET_PRIVACY", "private": private, "repo_id": f"{hf_owner_name}/{hf_repo_name}"})
413
+ print(f"Staged SET_PRIVACY action for {hf_owner_name}/{hf_repo_name} to {private}")
414
+
415
+ elif command == "DELETE_SPACE":
416
+ # Action applies to the currently loaded space
417
+ changeset.append({"type": "DELETE_SPACE", "owner": hf_owner_name, "space_name": hf_repo_name})
418
+ print(f"Staged DELETE_SPACE action for {hf_owner_name}/{hf_repo_name}")
419
+ # Add other actions here as needed (e.g., `SET_HARDWARE`, `RESTART_SPACE`)
420
+
421
+ # 3. Compare proposed files from AI with current files to determine CREATE/UPDATE
422
+ # Iterate through files proposed by the AI in this turn
423
+ for file_info in ai_proposed_files_list:
424
+ filename = file_info["path"]
425
+ proposed_content = file_info["content"]
426
+
427
+ if filename in current_files_dict:
428
+ # File exists, check if content changed
429
+ current_content = current_files_dict[filename]["code"]
430
+ if proposed_content != current_content:
431
+ # Check if current file state is a binary/error placeholder before marking as update
432
+ # If current is placeholder and proposed is content, treat as update (content is now known)
433
+ # If both are placeholders or proposed is placeholder, maybe skip or special flag?
434
+ is_current_placeholder = current_content.startswith(("[Binary file", "[Error loading content:", "[Binary or Skipped file]"))
435
+ is_proposed_placeholder = proposed_content.startswith(("[Binary file", "[Error loading content:", "[Binary or Skipped file]"))
436
+
437
+ if not is_proposed_placeholder: # Only stage update if AI provides actual content
438
+ # Determine language for potential new/updated file block representation
439
+ # Use the language if the file was already in cache, otherwise infer from filename
440
+ lang = current_files_dict[filename].get("language") or _infer_lang_from_filename(filename)
441
+ changeset.append({"type": "UPDATE_FILE", "path": filename, "content": proposed_content, "lang": lang})
442
+ print(f"Staged UPDATE_FILE action for {filename}")
443
+ elif is_current_placeholder and is_proposed_placeholder:
444
+ print(f"Skipping staging update for {filename}: both current and proposed content are placeholders.")
445
+ elif not is_current_placeholder and is_proposed_placeholder:
446
+ print(f"Warning: AI proposed placeholder content for existing file {filename}. Staging ignored.")
447
 
 
 
 
448
 
449
+ else:
450
+ # File does not exist, stage as CREATE
451
+ # Only stage creation if AI provides actual content, not just a placeholder
452
+ proposed_content = file_info["content"]
453
+ if not (proposed_content.startswith("[Binary file") or proposed_content.startswith("[Error loading content:") or proposed_content.startswith("[Binary or Skipped file]")):
454
+ lang = _infer_lang_from_filename(filename) # Infer language for a new file
455
+ changeset.append({"type": "CREATE_FILE", "path": filename, "content": proposed_content, "lang": lang})
456
+ print(f"Staged CREATE_FILE action for {filename}")
457
+ else:
458
+ print(f"Skipping staging create for {filename}: Proposed content is a placeholder.")
459
+
460
 
461
  # 4. Format the changeset into a human-readable Markdown string
462
  if not changeset:
463
+ md_summary = ["### πŸ“‹ Proposed Changes Plan", "\nThe AI did not propose any specific changes to files or the space.\n"]
464
+ else:
465
+ md_summary = ["### πŸ“‹ Proposed Changes Plan\n"]
466
+ md_summary.append("The AI has proposed the following changes. Please review and confirm.")
467
+
468
+ # Separate action types for clearer display
469
+ file_changes = [c for c in changeset if c['type'] in ['CREATE_FILE', 'UPDATE_FILE', 'DELETE_FILE']]
470
+ space_actions = [c for c in changeset if c['type'] not in ['CREATE_FILE', 'UPDATE_FILE', 'DELETE_FILE']]
471
+
472
+ if space_actions:
473
+ md_summary.append("\n**Space Actions:**")
474
+ for change in space_actions:
475
+ if change["type"] == "CREATE_SPACE":
476
+ md_summary.append(f"- **πŸš€ Create New Space:** `{change.get('repo_id', '...')}` (SDK: {change.get('sdk', 'gradio')}, Private: {change.get('private', False)})")
477
+ elif change["type"] == "SET_PRIVACY":
478
+ md_summary.append(f"- **πŸ”’ Set Privacy:** Set `{change.get('repo_id', '...')}` to `private={change.get('private', False)}`")
479
+ elif change["type"] == "DELETE_SPACE":
480
+ md_summary.append(f"- **πŸ’₯ DELETE ENTIRE SPACE:** `{change.get('owner', '...')}/{change.get('space_name', '...')}` **(DESTRUCTIVE ACTION)**")
481
+ md_summary.append("") # Add newline after actions
482
+
483
+ if file_changes:
484
+ md_summary.append("**File Changes:**")
485
+ for change in file_changes:
486
+ if change["type"] == "CREATE_FILE":
487
+ md_summary.append(f"- **βž• Create File:** `{change['path']}`")
488
+ elif change["type"] == "UPDATE_FILE":
489
+ md_summary.append(f"- **πŸ”„ Update File:** `{change['path']}`")
490
+ elif change["type"] == "DELETE_FILE":
491
+ md_summary.append(f"- **βž– Delete File:** `{change['path']}`")
492
+
493
+ return changeset, "\n".join(md_summary)
494
+
495
 
496
  # --- Gradio Event Handlers ---
497
 
 
499
  global parsed_code_blocks_state_cache
500
  _chat_msg_in, _chat_hist = "", list(chat_history)
501
 
502
+ # Hide confirmation UI while AI is thinking
503
  yield (
504
  _chat_msg_in, _chat_hist, "Initializing...",
505
+ gr.update(), gr.update(), gr.update(interactive=False), gr.update(value="*No changes proposed.*"), # Clear summary
506
+ [], gr.update(visible=False), gr.update(visible=False), gr.update(visible=False) # Hide confirm UI
507
  )
508
 
509
  if not user_message.strip():
510
  yield (
511
  _chat_msg_in, _chat_hist, "Cannot send an empty message.",
512
+ gr.update(), gr.update(), gr.update(), gr.update(),
513
+ [], gr.update(visible=False), gr.update(visible=False), gr.update(visible=False)
514
  )
515
  return
516
 
517
  _chat_hist.append((user_message, None))
518
  yield (
519
  _chat_msg_in, _chat_hist, f"Sending to {model_select}...",
520
+ gr.update(), gr.update(), gr.update(), gr.update(),
521
+ [], gr.update(visible=False), gr.update(visible=False), gr.update(visible=False)
522
  )
523
 
524
+ # Prepare context for the AI - Export current state to Markdown
525
  current_sys_prompt = system_prompt.strip() or DEFAULT_SYSTEM_PROMPT
526
+ space_id_for_context = f"{hf_owner_name}/{hf_repo_name}" if hf_owner_name and hf_repo_name else "your-space"
527
+ export_result = _export_selected_logic(None, space_id_for_context, parsed_code_blocks_state_cache)
528
+ current_files_context_md = export_result['output_str'] or "*No files currently loaded or defined.*"
529
+ current_files_context = f"\n\n## Current Space Context: {space_id_for_context}\n{current_files_context_md}"
530
+
531
+ user_message_with_context = user_message.strip() + current_files_context
532
  api_msgs = _convert_gr_history_to_api_messages(current_sys_prompt, _chat_hist[:-1], user_message_with_context)
533
 
534
  try:
 
537
  streamer = generate_stream(provider_select, model_select, provider_api_key_input, api_msgs)
538
  for chunk in streamer:
539
  if chunk is None: continue
540
+ if isinstance(chunk, str):
541
+ # Check for error indicators early
542
+ if full_bot_response_content == "" and (chunk.startswith("Error:") or chunk.startswith("API HTTP Error")):
543
+ full_bot_response_content = chunk; break
544
+ full_bot_response_content += str(chunk)
545
+
546
  _chat_hist[-1] = (user_message, full_bot_response_content)
547
  yield (
548
  _chat_msg_in, _chat_hist, f"Streaming from {model_select}...",
549
+ gr.update(), gr.update(), gr.update(), gr.update(),
550
+ [], gr.update(visible=False), gr.update(visible=False), gr.update(visible=False)
551
  )
552
 
553
+ # Handle potential errors from the streamer
554
+ if full_bot_response_content.startswith("Error:") or full_bot_response_content.startswith("API HTTP Error"):
555
  _status = full_bot_response_content
556
+ yield (_chat_msg_in, _chat_hist, _status, gr.update(), gr.update(), gr.update(), gr.update(), [], gr.update(visible=False), gr.update(visible=False), gr.update(visible=False))
557
  return
558
 
559
+ # --- Post-streaming: Parse AI output, update cache, stage changes ---
 
 
560
 
561
+ _status = "Stream complete. Parsing response and staging changes..."
562
+ yield (_chat_msg_in, _chat_hist, _status, gr.update(), gr.update(), gr.update(), gr.update(), [], gr.update(visible=False), gr.update(visible=False), gr.update(visible=False))
563
+
564
+ # 1. Update the state cache based on the *full* AI response
565
+ # This reflects the AI's understanding and proposed file content *in the UI*.
566
+ parsed_code_blocks_state_cache, proposed_filenames_in_turn = _parse_and_update_state_cache(full_bot_response_content, parsed_code_blocks_state_cache)
567
+
568
+ # Regenerate UI previews based on the updated cache
569
+ _formatted, _detected, _download = _generate_ui_outputs_from_cache(hf_owner_name, hf_repo_name)
570
+
571
+ # 2. Generate the changeset and summary based on the AI output and current cache state
572
+ # Pass the updated cache to generate_and_stage_changes for comparison
573
+ staged_changeset, summary_md = generate_and_stage_changes(full_bot_response_content, parsed_code_blocks_state_cache, hf_owner_name, hf_repo_name)
574
 
 
 
 
 
575
 
576
  if not staged_changeset:
577
+ _status = summary_md # Will be "No changes proposed" message
578
+ yield (
579
+ _chat_msg_in, _chat_hist, _status,
580
+ _detected, _formatted, _download,
581
+ [], # Clear changeset state
582
+ gr.update(value=summary_md), # Display summary
583
+ gr.update(visible=False), gr.update(visible=False), gr.update(visible=False) # Hide confirm UI
584
+ )
585
  else:
586
  _status = "Change plan generated. Please review and confirm below."
587
  yield (
588
  _chat_msg_in, _chat_hist, _status,
589
+ _detected, _formatted, _download,
590
  staged_changeset, # Send changeset to state
591
  gr.update(value=summary_md), # Display summary
592
  gr.update(visible=True), # Show the accordion
 
597
  except Exception as e:
598
  error_msg = f"An unexpected error occurred: {e}"
599
  print(f"Error in handle_chat_submit: {e}")
600
+ import traceback
601
+ traceback.print_exc()
602
+ if _chat_hist:
603
+ # Ensure the last message is not None before updating
604
+ if _chat_hist[-1] and _chat_hist[-1][0] == user_message:
605
+ _chat_hist[-1] = (user_message, (full_bot_response_content + "\n\n" if full_bot_response_content and full_bot_response_content != user_message else "") + error_msg)
606
+ else: # Should not happen, but as fallback
607
+ _chat_hist.append((user_message, error_msg))
608
+
609
+
610
+ # Regenerate UI previews based on the updated cache (even if there was an error after streaming)
611
+ _formatted, _detected, _download = _generate_ui_outputs_from_cache(hf_owner_name, hf_repo_name)
612
+
613
  yield (
614
  _chat_msg_in, _chat_hist, error_msg,
615
+ _detected, _formatted, _download,
616
+ [], # Clear changeset state on error
617
+ gr.update(value="*Error occurred, changes plan cleared.*"), # Clear summary display
618
+ gr.update(visible=False), gr.update(visible=False), gr.update(visible=False) # Hide confirm UI
619
  )
620
 
621
+
622
  def handle_confirm_changes(hf_api_key, owner_name, space_name, changeset):
623
  """Applies the staged changes from the changeset."""
624
  global parsed_code_blocks_state_cache
625
+
626
+ # Hide the confirmation UI immediately
627
+ yield "Applying changes...", gr.update(visible=False), gr.update(visible=False), gr.update(visible=False)
628
+ # Keep the summary visible potentially with a loading indicator? Or clear? Let's clear it.
629
+ yield "Applying changes...", gr.update(visible=False), gr.update(visible=False), gr.update(visible=False), gr.update(value="*Applying changes...*")
630
+
631
  if not changeset:
632
+ # This shouldn't happen if the button is hidden, but as a safeguard
633
+ return "No changes to apply.", gr.update(visible=False), gr.update(visible=False), gr.update(visible=False), gr.update(value="No changes were staged.")
634
+
635
+ # Call the build_logic function to apply the changes
636
+ # The build_logic function will return a status message string
637
+ status_message = apply_staged_changes(hf_api_key, owner_name, space_name, changeset)
638
+
639
+ # After applying changes, reload the space state to reflect the actual state on the Hub
640
+ # This is important because the build_logic might fail partially, or the AI's cache might be outdated.
641
+ # Reloading ensures the UI reflects the reality on the Hub.
642
+ _status_reload = f"{status_message} | Reloading Space state..."
643
+ yield _status_reload, gr.update(visible=False), gr.update(visible=False), gr.update(visible=False), gr.update(value="*Reloading Space state...*")
644
+
645
+ # Need to call handle_load_existing_space or similar logic here
646
+ # Let's replicate the core logic from handle_load_existing_space needed to refresh the cache and UI
647
+ # Note: This doesn't update the chat history or other parts of handle_load_existing_space,
648
+ # just the file-related UI elements.
649
+ refreshed_file_list = []
650
+ reload_error = None
651
+ repo_id_for_reload = f"{owner_name}/{space_name}" if owner_name and space_name else None
652
+
653
+ if repo_id_for_reload:
654
+ sdk, file_list, err_list = get_space_repository_info(hf_api_key, space_name, owner_name)
655
+ if err_list:
656
+ reload_error = f"Error reloading file list after changes: {err_list}"
657
+ parsed_code_blocks_state_cache = [] # Clear cache if list fails
658
+ else:
659
+ refreshed_file_list = file_list
660
+ loaded_files = []
661
+ for file_path in refreshed_file_list:
662
+ content, err_get = get_space_file_content(hf_api_key, space_name, owner_name, file_path)
663
+ lang = _infer_lang_from_filename(file_path)
664
+ is_binary = lang == "binary" or err_get # Assume error indicates binary/unreadable
665
+ code = f"[Error loading content: {err_get}]" if err_get else content
666
+ loaded_files.append({"filename": file_path, "code": code, "language": lang, "is_binary": is_binary, "is_structure_block": False})
667
+ parsed_code_blocks_state_cache = loaded_files # Update cache with refreshed state
668
+ # Add back the structure block if it was in the cache before reload (it's AI generated, not from Hub)
669
+ # This might be wrong - the cache should represent the *actual* state + AI's last proposed file changes.
670
+ # Let's keep AI's proposed structure block in the cache until a new one replaces it.
671
+ # But reloading from Hub should overwrite the *file content*. The structure block is separate.
672
+ # If we reload from Hub, the cache should be *only* Hub files + the last AI structure block.
673
+ last_ai_structure_block = next((b for b in parsed_code_blocks_state_cache if b.get("is_structure_block")), None) # Check cache *before* clearing
674
+ if last_ai_structure_block:
675
+ # Find it in the *new* loaded_files list if it exists there (e.g. README.md could be structure?)
676
+ # Or just re-add the AI structure block if it was in the cache previously?
677
+ # Let's stick to the simpler model: AI structure block is just for display/context in the markdown tab,
678
+ # the actual files are what's loaded/applied. Reloading files replaces the file blocks in cache, structure block is kept or removed based on AI output.
679
+ # On reload, we only get actual files from the Hub. The AI structure block is NOT on the hub.
680
+ # So, clearing and adding only Hub files is correct.
681
+ pass # last_ai_structure_block is not added back.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
682
 
683
+ else:
684
+ reload_error = "Cannot reload Space state: Owner or Space Name missing."
685
+
686
+
687
+ # Regenerate UI previews based on the refreshed cache state
688
  _formatted, _detected, _download = _generate_ui_outputs_from_cache(owner_name, space_name)
689
 
690
+ final_overall_status = status_message + (f" | Reload Status: {reload_error}" if reload_error else " | Reload Status: Space state refreshed.")
691
+
692
+ # Clear the changeset state after application attempt
693
+ cleared_changeset = []
694
+
695
+ # Return updated UI elements and hide confirmation UI
696
+ return (
697
+ final_overall_status,
698
+ _formatted,
699
+ _detected,
700
+ _download,
701
+ gr.update(visible=False), # Hide accordion
702
+ gr.update(visible=False), # Hide confirm button
703
+ gr.update(visible=False), # Hide cancel button
704
+ cleared_changeset, # Clear changeset state
705
+ gr.update(value="*No changes proposed.*") # Clear summary display
706
+ )
707
+
708
 
709
  def handle_cancel_changes():
710
  """Clears the staged changeset and hides the confirmation UI."""
711
+ global parsed_code_blocks_state_cache # Cancel doesn't affect the cache state
712
+ return (
713
+ "Changes cancelled.",
714
+ [], # Clear changeset state
715
+ gr.update(value="*No changes proposed.*"), # Clear summary display
716
+ gr.update(visible=False), # Hide accordion
717
+ gr.update(visible=False), # Hide confirm button
718
+ gr.update(visible=False) # Hide cancel button
719
+ )
720
+
721
 
722
  def update_models_dropdown(provider_select):
723
  if not provider_select: return gr.update(choices=[], value=None)
 
731
  _formatted_md_val, _detected_preview_val, _status_val = "*Loading files...*", "*Loading files...*", f"Loading Space: {ui_owner_name}/{ui_space_name}..."
732
  _file_browser_update, _iframe_html_update, _download_btn_update = gr.update(visible=False, choices=[], value=None), gr.update(value=None, visible=False), gr.update(interactive=False, value=None)
733
  _build_status_clear, _edit_status_clear, _runtime_status_clear = "*Build status...*", "*Select a file...*", "*Runtime status...*"
734
+ _chat_history_clear = [] # Don't clear chat history on load
735
+ _changeset_clear = [] # Clear staged changes on load
736
+ _changeset_summary_clear = "*No changes proposed.*" # Clear summary on load
737
+ _confirm_ui_hidden = gr.update(visible=False) # Hide confirm UI on load
738
+
739
+
740
+ # Initial yield to show loading state
741
+ outputs = [
742
+ _formatted_md_val, _detected_preview_val, _status_val, _file_browser_update,
743
+ gr.update(value=ui_owner_name), gr.update(value=ui_space_name), # Update owner/space fields immediately
744
+ _iframe_html_update, _download_btn_update, _build_status_clear,
745
+ _edit_status_clear, _runtime_status_clear, _chat_history_clear,
746
+ _changeset_clear, _changeset_summary_clear, _confirm_ui_hidden, _confirm_ui_hidden, _confirm_ui_hidden # Hide confirmation UI
747
+ ]
748
  yield outputs
749
 
750
  owner_to_use = ui_owner_name
 
757
  user_info = build_logic_whoami(token=token)
758
  owner_to_use = user_info.get('name')
759
  if not owner_to_use: raise Exception("Could not find user name from token.")
760
+ outputs[4] = gr.update(value=owner_to_use) # Update UI owner field
761
  _status_val += f" (Auto-detected owner: {owner_to_use})"
762
  except Exception as e:
763
  _status_val = f"Error auto-detecting owner: {e}"; outputs[2] = _status_val; yield outputs; return
 
766
  _status_val = "Error: Owner and Space Name are required."; outputs[2] = _status_val; yield outputs; return
767
 
768
  sdk, file_list, err = get_space_repository_info(hf_api_key_ui, ui_space_name, owner_to_use)
769
+
770
+ # Always update owner/space inputs even on error, as user entered them
771
+ outputs[4] = gr.update(value=owner_to_use)
772
+ outputs[5] = gr.update(value=ui_space_name)
773
+
774
+ if err:
775
+ _status_val = f"Load Error: {err}"
776
+ parsed_code_blocks_state_cache = [] # Clear cache on load error
777
  _formatted, _detected, _download = _generate_ui_outputs_from_cache(owner_to_use, ui_space_name)
778
+ outputs[0], outputs[1], outputs[2], outputs[7] = _formatted, _detected, _status_val, _download # Update markdown, preview, status, download
779
+ outputs[3] = gr.update(visible=False, choices=[], value=None) # Hide file browser
780
+ outputs[6] = gr.update(value=None, visible=False) # Hide iframe
781
  yield outputs; return
782
 
783
+ # Success case: Populate cache and UI
 
 
 
 
784
  loaded_files = []
785
  for file_path in file_list:
786
  content, err_get = get_space_file_content(hf_api_key_ui, ui_space_name, owner_to_use, file_path)
787
  lang = _infer_lang_from_filename(file_path)
788
+ is_binary = lang == "binary" or (err_get is not None) # err_get will be a string if error
789
+ code = f"[Error loading content: {err_get}]" if err_get else (content or "") # Ensure code is empty string if content is None
790
  loaded_files.append({"filename": file_path, "code": code, "language": lang, "is_binary": is_binary, "is_structure_block": False})
791
 
792
+ # When loading, the cache should only contain the actual files from the Hub.
793
+ # Any previous AI-generated structure block is discarded.
794
  parsed_code_blocks_state_cache = loaded_files
795
+
796
  _formatted, _detected, _download = _generate_ui_outputs_from_cache(owner_to_use, ui_space_name)
797
+ _status_val = f"Successfully loaded {len(file_list)} files from {owner_to_use}/{ui_space_name}. SDK: {sdk or 'unknown'}."
798
+ outputs[0], outputs[1], outputs[2], outputs[7] = _formatted, _detected, _status_val, _download # Update markdown, preview, status, download
799
+
800
+ # Update file browser dropdown
801
  outputs[3] = gr.update(visible=True, choices=sorted(file_list or []), value=None)
802
+
803
+ # Update iframe preview
804
+ if owner_to_use and ui_space_name:
805
+ sub_owner = re.sub(r'[^a-z0-9\-]+', '-', owner_to_use.lower()).strip('-') or 'owner'
806
+ sub_repo = re.sub(r'[^a-z0-9\-]+', '-', ui_space_name.lower()).strip('-') or 'space'
807
+ iframe_url = f"https://{sub_owner}-{sub_repo}{'.static.hf.space' if sdk == 'static' else '.hf.space'}"
808
+ outputs[6] = gr.update(value=f'<iframe src="{iframe_url}?__theme=light&embed=true" width="100%" height="500px"></iframe>', visible=True)
809
+ else:
810
+ outputs[6] = gr.update(value=None, visible=False)
811
+
812
+
813
  yield outputs
814
 
815
+ # This manual build button now uses the formatted_space_output_display content
816
+ # It's separate from the AI-driven apply_staged_changes
817
  def handle_build_space_button(hf_api_key_ui, ui_space_name_part, ui_owner_name_part, space_sdk_ui, is_private_ui, formatted_markdown_content):
818
+ _build_status, _iframe_html, _file_browser_update = "Starting manual space build process...", gr.update(value=None, visible=False), gr.update(visible=False, choices=[], value=None)
819
+ # Also hide confirmation UI and clear state on manual build
820
+ _changeset_clear = []
821
+ _changeset_summary_clear = "*Manual build initiated, changes plan cleared.*"
822
+ _confirm_ui_hidden = gr.update(visible=False)
823
+
824
+ yield (_build_status, _iframe_html, _file_browser_update, gr.update(value=ui_owner_name_part), gr.update(value=ui_space_name_part),
825
+ _changeset_clear, _changeset_summary_clear, _confirm_ui_hidden, _confirm_ui_hidden, _confirm_ui_hidden)
826
+
827
  if not ui_space_name_part or "/" in ui_space_name_part:
828
  _build_status = f"Build Error: Invalid Space Name '{ui_space_name_part}'."
829
+ yield (_build_status, _iframe_html, _file_browser_update, gr.update(), gr.update(),
830
+ gr.update(), gr.update(), gr.update(), gr.update(), gr.update()); return
831
 
832
+ # Use build_logic_create_space directly for manual build
833
  result_message = build_logic_create_space(ui_api_token_from_textbox=hf_api_key_ui, space_name_ui=ui_space_name_part, owner_ui=ui_owner_name_part, sdk_ui=space_sdk_ui, markdown_input=formatted_markdown_content, private=is_private_ui)
834
+ _build_status = f"Manual Build Process: {result_message}"
835
 
836
  if "Successfully" in result_message:
837
+ owner_to_use = ui_owner_name_part # Assume determined correctly by build_logic_create_space
838
+ space_to_use = ui_space_name_part
839
+
840
+ # Attempt to update UI with the new state after successful build
841
+ # This is similar to handle_load_existing_space post-success
842
+ sdk_built, file_list, err_list = get_space_repository_info(hf_api_key_ui, space_to_use, owner_to_use)
843
+ if err_list:
844
+ _build_status += f" | Error reloading file list after build: {err_list}"
845
+ global parsed_code_blocks_state_cache
846
+ parsed_code_blocks_state_cache = [] # Clear cache
847
+ _file_browser_update = gr.update(visible=False, choices=[], value=None)
848
+ _iframe_html = gr.update(value=None, visible=False)
849
+ else:
850
+ global parsed_code_blocks_state_cache
851
+ loaded_files = []
852
+ for file_path in file_list:
853
+ content, err_get = get_space_file_content(hf_api_key_ui, space_to_use, owner_to_use, file_path)
854
+ lang = _infer_lang_from_filename(file_path)
855
+ is_binary = lang == "binary" or (err_get is not None)
856
+ code = f"[Error loading content: {err_get}]" if err_get else (content or "")
857
+ loaded_files.append({"filename": file_path, "code": code, "language": lang, "is_binary": is_binary, "is_structure_block": False})
858
+ parsed_code_blocks_state_cache = loaded_files # Update cache
859
+
860
+ _file_browser_update = gr.update(visible=True, choices=sorted(file_list or []), value=None)
861
+
862
+ sub_owner = re.sub(r'[^a-z0-9\-]+', '-', owner_to_use.lower()).strip('-') or 'owner'
863
+ sub_repo = re.sub(r'[^a-z0-9\-]+', '-', space_to_use.lower()).strip('-') or 'space'
864
+ iframe_url = f"https://{sub_owner}-{sub_repo}{'.static.hf.space' if sdk_built == 'static' else '.hf.space'}"
865
+ _iframe_html = gr.update(value=f'<iframe src="{iframe_url}?__theme=light&embed=true" width="100%" height="700px"></iframe>', visible=True)
866
+
867
+
868
+ # Need to update formatted/detected markdown displays after manual build as well
869
+ _formatted_md, _detected_preview, _download = _generate_ui_outputs_from_cache(ui_owner_name_part, ui_space_name_part)
870
+
871
+
872
+ yield (_build_status, _iframe_html, _file_browser_update, gr.update(value=ui_owner_name_part), gr.update(value=ui_space_name_part),
873
+ _changeset_clear, _changeset_summary_clear, _confirm_ui_hidden, _confirm_ui_hidden, _confirm_ui_hidden,
874
+ _formatted_md, _detected_preview, _download # Include these outputs
875
+ )
876
+
877
 
878
  def handle_load_file_for_editing(hf_api_key_ui, ui_space_name_part, ui_owner_name_part, selected_file_path):
879
  if not selected_file_path:
 
882
 
883
  content, err = get_space_file_content(hf_api_key_ui, ui_space_name_part, ui_owner_name_part, selected_file_path)
884
  if err:
885
+ # If load fails, clear editor and show error
886
+ yield "", f"Error loading '{selected_file_path}': {err}", "", gr.update(language="plaintext")
887
  return
888
 
889
  lang = _infer_lang_from_filename(selected_file_path)
890
  commit_msg = f"Update {selected_file_path}"
891
+ yield content, f"Loaded `{selected_file_path}`", commit_msg, gr.update(language=lang)
892
 
893
  def handle_commit_file_changes(hf_api_key_ui, ui_space_name_part, ui_owner_name_part, file_to_edit_path, edited_content, commit_message):
894
+ if not file_to_edit_path:
895
+ return "Error: No file selected for commit.", gr.update(), gr.update(), gr.update(), gr.update()
896
+
897
  status_msg = update_space_file(hf_api_key_ui, ui_space_name_part, ui_owner_name_part, file_to_edit_path, edited_content, commit_message)
898
+ file_list, _ = list_space_files_for_browsing(hf_api_key_ui, ui_space_name_part, ui_owner_name_part) # Refresh file list dropdown
899
  global parsed_code_blocks_state_cache
900
  if "Successfully" in status_msg:
901
+ # Update cache on success
902
+ # Find the block in the cache and update its content and potentially language
903
+ found = False
904
  for block in parsed_code_blocks_state_cache:
905
+ if block["filename"] == file_to_edit_path and not block.get("is_structure_block"):
906
  block["code"] = edited_content
907
+ block["language"] = _infer_lang_from_filename(file_to_edit_path) # Re-infer language in case extension changed
908
+ block["is_binary"] = False # Assume edited content is text
909
+ found = True
910
  break
911
+ if not found:
912
+ # If the file wasn't in cache (e.g., loaded after AI chat), add it
913
+ parsed_code_blocks_state_cache.append({
914
+ "filename": file_to_edit_path,
915
+ "code": edited_content,
916
+ "language": _infer_lang_from_filename(file_to_edit_path),
917
+ "is_binary": False,
918
+ "is_structure_block": False
919
+ })
920
+ # Re-sort cache
921
+ parsed_code_blocks_state_cache.sort(key=lambda b: (0, b["filename"]) if b.get("is_structure_block") else (1, b["filename"]))
922
+
923
+
924
+ # Regenerate markdown displays from updated cache
925
  _formatted, _detected, _download = _generate_ui_outputs_from_cache(ui_owner_name_part, ui_space_name_part)
926
  return status_msg, gr.update(choices=sorted(file_list or [])), _formatted, _detected, _download
927
 
 
930
  return "No file selected to delete.", gr.update(), "", "", "plaintext", gr.update(), gr.update(), gr.update()
931
 
932
  status_msg = build_logic_delete_space_file(hf_api_key_ui, ui_space_name_part, ui_owner_name_part, file_to_delete_path)
933
+ file_list, _ = list_space_files_for_browsing(hf_api_key_ui, ui_space_name_part, ui_owner_name_part) # Refresh file list dropdown
934
  global parsed_code_blocks_state_cache
935
  if "Successfully" in status_msg:
936
+ # Update cache: remove the deleted file
937
  parsed_code_blocks_state_cache = [b for b in parsed_code_blocks_state_cache if b["filename"] != file_to_delete_path]
938
+ # Clear the editor if the deleted file was currently loaded
939
+ file_content_editor_update = gr.update(value="")
940
+ commit_message_update = gr.update(value="")
941
+ editor_lang_update = gr.update(language="plaintext")
942
+ else:
943
+ # If deletion failed, keep the editor content and status as they were
944
+ file_content_editor_update = gr.update()
945
+ commit_message_update = gr.update()
946
+ editor_lang_update = gr.update()
947
+
948
+
949
+ # Regenerate markdown displays from updated cache
950
  _formatted, _detected, _download = _generate_ui_outputs_from_cache(ui_owner_name_part, ui_space_name_part)
951
+ return (
952
+ status_msg,
953
+ gr.update(choices=sorted(file_list or []), value=None), # Clear selected value after delete
954
+ file_content_editor_update,
955
+ commit_message_update,
956
+ editor_lang_update,
957
+ _formatted,
958
+ _detected,
959
+ _download
960
+ )
961
 
962
  def handle_refresh_space_status(hf_api_key_ui, ui_owner_name, ui_space_name):
963
  if not ui_owner_name or not ui_space_name:
964
+ return "*Owner and Space Name must be provided to get status.*"
965
 
966
+ status_details, err = get_space_runtime_status(hf_api_key_ui, ui_space_name, ui_owner_name)
967
  if err: return f"**Error:** {err}"
968
+ if not status_details: return "*Could not retrieve status details.*"
969
 
970
  md = f"### Status for {ui_owner_name}/{ui_space_name}\n"
971
+ # Display key status details
972
+ md += f"- **Stage:** `{status_details.get('stage', 'N/A')}`\n"
973
+ md += f"- **Status:** `{status_details.get('status', 'N/A')}`\n" # More detailed status
974
+ md += f"- **Hardware:** `{status_details.get('hardware', 'N/A')}`\n"
975
+ requested_hw = status_details.get('requested_hardware')
976
+ if requested_hw: md += f"- **Requested Hardware:** `{requested_hw}`\n"
977
+ error_msg = status_details.get('error_message')
978
+ if error_msg: md += f"- **Error:** `{error_msg}`\n"
979
+ log_link = status_details.get('full_log_link')
980
+ if log_link and log_link != "#": md += f"- [View Full Logs]({log_link})\n"
981
+
982
  return md
983
 
984
+
985
  # --- UI Theming and CSS (Unchanged) ---
986
  custom_theme = gr.themes.Base(primary_hue="teal", secondary_hue="purple", neutral_hue="zinc", text_size="sm", spacing_size="md", radius_size="sm", font=["System UI", "sans-serif"])
987
  custom_css = """
 
992
  .gr-button.gr-button-primary { background-color: #1abc9c !important; color: white !important; border-color: #16a085 !important; }
993
  .gr-button.gr-button-secondary { background-color: #9b59b6 !important; color: white !important; border-color: #8e44ad !important; }
994
  .gr-button.gr-button-stop { background-color: #e74c3c !important; color: white !important; border-color: #c0392b !important; }
995
+ .gr-markdown { background-color: rgba(44, 62, 80, 0.7) !important; padding: 10px; border-radius: 5px; overflow-x: auto; } /* Added overflow-x */
996
  .gr-markdown h1, .gr-markdown h2, .gr-markdown h3, .gr-markdown h4, .gr-markdown h5, .gr-markdown h6 { color: #ecf0f1 !important; border-bottom-color: rgba(189, 195, 199, 0.3) !important; }
997
  .gr-markdown pre code { background-color: rgba(52, 73, 94, 0.95) !important; border-color: rgba(189, 195, 199, 0.3) !important; }
998
  .gr-chatbot { background-color: rgba(44, 62, 80, 0.7) !important; border-color: rgba(189, 195, 199, 0.2) !important; }
999
  .gr-chatbot .message { background-color: rgba(52, 73, 94, 0.9) !important; color: #ecf0f1 !important; border-color: rgba(189, 195, 199, 0.3) !important; }
1000
  .gr-chatbot .message.user { background-color: rgba(46, 204, 113, 0.9) !important; color: black !important; }
1001
+ /* Custom styles for Proposed Changes Accordion */
1002
+ .gradio-container .gr-accordion { border-color: rgba(189, 195, 199, 0.3) !important; }
1003
+ .gradio-container .gr-accordion.closed { background-color: rgba(52, 73, 94, 0.9) !important; }
1004
+ .gradio-container .gr-accordion.open { background-color: rgba(44, 62, 80, 0.8) !important; }
1005
+
1006
  """
1007
 
1008
+
1009
  # --- Gradio UI Definition ---
1010
  with gr.Blocks(theme=custom_theme, css=custom_css) as demo:
1011
+ # State to hold the parsed change plan from the AI
1012
  changeset_state = gr.State([])
1013
+ # State to hold the *current* representation of the Space's files and structure.
1014
+ # This is populated on load and updated by AI outputs or manual edits/deletes.
1015
+ # It's shared globally across handlers that modify/read the Space state.
1016
+ # parsed_code_blocks_state_cache = gr.State([]) # Global variable is simpler for now
1017
 
1018
+ gr.Markdown("# πŸ€– AI-Powered Hugging Face Space Commander")
1019
  gr.Markdown("Use an AI assistant to create, modify, build, and manage your Hugging Face Spaces directly from this interface.")
1020
  gr.Markdown("## ❗ This will cause changes to your huggingface spaces if you give it your Huggingface Key")
1021
+ gr.Markdown("βš’ Under Development - Use with Caution")
1022
+
1023
 
1024
  with gr.Sidebar():
1025
  with gr.Column(scale=1):
1026
  with gr.Accordion("βš™οΈ Configuration", open=True):
1027
  hf_api_key_input = gr.Textbox(label="Hugging Face Token", type="password", placeholder="hf_... (uses env var HF_TOKEN if empty)")
1028
  owner_name_input = gr.Textbox(label="HF Owner Name", placeholder="e.g., your-username")
1029
+ space_name_input = gr.Textbox(label="HF Space Name", value="") # Default to empty
1030
  load_space_button = gr.Button("πŸ”„ Load Existing Space", variant="secondary")
1031
+ gr.Markdown("---") # Separator
1032
+ # Manual Space Actions (outside AI flow, but use same backend)
1033
+ # set_privacy_button = gr.Button("πŸ”’ Toggle Space Privacy", variant="secondary") # Could be checkbox + button
1034
+ # delete_space_button = gr.Button("πŸ’₯ Delete Entire Space", variant="stop") # Needs confirmation modal
1035
+
1036
 
1037
  with gr.Accordion("πŸ€– AI Model Settings", open=True):
1038
  # --- MODIFIED: Set up default provider and model logic on load ---
1039
  available_providers = get_available_providers()
1040
  default_provider = 'Groq'
1041
+ # Fallback if 'Groq' is not an option, or if list is smaller than 3
1042
  if default_provider not in available_providers:
1043
+ default_provider = available_providers[0] if available_providers else None
1044
+ elif len(available_providers) < 3:
1045
+ default_provider = available_providers[0] if available_providers else None
1046
+
1047
 
1048
  # Get initial models and the default model for the selected provider
1049
  initial_models = get_models_for_provider(default_provider) if default_provider else []
 
1055
  provider_select = gr.Dropdown(
1056
  label="AI Provider",
1057
  choices=available_providers,
1058
+ value=default_provider,
1059
+ allow_custom_value=False
1060
  )
1061
  model_select = gr.Dropdown(
1062
  label="AI Model",
1063
  choices=initial_models,
1064
+ value=initial_model,
1065
+ allow_custom_value=False
1066
  )
1067
  # --- END MODIFICATION ---
1068
  provider_api_key_input = gr.Textbox(label="Model Provider API Key (Optional)", type="password", placeholder="sk_... (overrides backend settings)")
 
1076
  send_chat_button = gr.Button("Send", variant="primary", scale=1)
1077
  status_output = gr.Textbox(label="Last Action Status", interactive=False, value="Ready.")
1078
 
1079
+ # Confirmation Accordion - Initially hidden
1080
+ with gr.Accordion("πŸ“ Proposed Changes (Pending Confirmation)", open=False, visible=False) as confirm_accordion:
1081
+ changeset_display = gr.Markdown("*No changes proposed.*")
1082
  with gr.Row():
1083
  confirm_button = gr.Button("βœ… Confirm & Apply Changes", variant="primary", visible=False)
1084
  cancel_button = gr.Button("❌ Cancel", variant="stop", visible=False)
 
1087
  with gr.TabItem("πŸ“ Generated Markdown & Build"):
1088
  with gr.Row():
1089
  with gr.Column(scale=2):
1090
+ # This textbox shows the full markdown representation based on the *current state cache*
1091
+ formatted_space_output_display = gr.Textbox(label="Current Space Definition (Generated Markdown)", lines=20, interactive=True, value="*Load or create a space to see its definition.*")
1092
  download_button = gr.DownloadButton(label="Download .md", interactive=False)
1093
  with gr.Column(scale=1):
1094
  gr.Markdown("### Build Controls")
1095
+ # Manual build controls
1096
+ space_sdk_select = gr.Dropdown(label="Space SDK", choices=["gradio", "streamlit", "docker", "static"], value="gradio", interactive=True)
1097
+ space_private_checkbox = gr.Checkbox(label="Make Space Private", value=False, interactive=True)
1098
+ # Manual build button now builds from the content in `formatted_space_output_display`
1099
+ build_space_button = gr.Button("πŸš€ Build / Update Space from Markdown", variant="primary")
1100
+ build_status_display = gr.Textbox(label="Manual Build/Update Status", interactive=False)
1101
+ gr.Markdown("---") # Separator
1102
+ # Manual status check (uses build_logic)
1103
  refresh_status_button = gr.Button("πŸ”„ Refresh Runtime Status")
1104
  space_runtime_status_display = gr.Markdown("*Runtime status will appear here.*")
1105
 
1106
  with gr.TabItem("πŸ” Files Preview"):
1107
+ # This markdown shows the *latest* version of each file from the cache
1108
  detected_files_preview = gr.Markdown(value="*A preview of the latest file versions will appear here.*")
1109
 
1110
  with gr.TabItem("✏️ Live File Editor & Preview"):
1111
  with gr.Row():
1112
  with gr.Column(scale=1):
1113
  gr.Markdown("### Live Editor")
1114
+ # Dropdown lists files from the current state cache
1115
  file_browser_dropdown = gr.Dropdown(label="Select File in Space", choices=[], interactive=True)
1116
+ # Editor for selected file content
1117
+ file_content_editor = gr.Code(label="File Content Editor", language="python", lines=15, interactive=True, value="")
1118
+ commit_message_input = gr.Textbox(label="Commit Message", placeholder="e.g., Updated app.py", interactive=True, value="")
1119
  with gr.Row():
1120
+ # Manual file actions (use build_logic directly)
1121
+ update_file_button = gr.Button("Commit Changes", variant="primary", interactive=True)
1122
+ delete_file_button = gr.Button("πŸ—‘οΈ Delete Selected File", variant="stop", interactive=True)
1123
+ edit_status_display = gr.Textbox(label="File Edit/Delete Status", interactive=False, value="")
1124
  with gr.Column(scale=1):
1125
  gr.Markdown("### Live Space Preview")
1126
+ # Iframe preview of the space (updates on load and successful build)
1127
  space_iframe_display = gr.HTML(value="", visible=True)
1128
 
1129
  # --- Event Listeners ---
1130
+
1131
+ # Model dropdown update logic
1132
  provider_select.change(update_models_dropdown, inputs=provider_select, outputs=model_select)
1133
 
1134
+ # Chat submission logic
1135
+ chat_inputs = [
1136
+ chat_message_input, chatbot_display, hf_api_key_input,
1137
+ provider_api_key_input, provider_select, model_select, system_prompt_input,
1138
+ owner_name_input, space_name_input # Need current space info for context/actions
1139
+ ]
1140
  chat_outputs = [
1141
  chat_message_input, chatbot_display, status_output,
1142
  detected_files_preview, formatted_space_output_display, download_button,
 
1145
  send_chat_button.click(handle_chat_submit, inputs=chat_inputs, outputs=chat_outputs)
1146
  chat_message_input.submit(handle_chat_submit, inputs=chat_inputs, outputs=chat_outputs)
1147
 
1148
+ # Confirmation Button Listeners for AI-proposed changes
1149
  confirm_inputs = [hf_api_key_input, owner_name_input, space_name_input, changeset_state]
1150
  confirm_outputs = [
1151
  status_output, formatted_space_output_display, detected_files_preview, download_button,
1152
+ confirm_accordion, confirm_button, cancel_button, changeset_state, changeset_display # Also clear summary display
1153
  ]
1154
  confirm_button.click(handle_confirm_changes, inputs=confirm_inputs, outputs=confirm_outputs)
1155
 
 
1159
  ]
1160
  cancel_button.click(handle_cancel_changes, inputs=None, outputs=cancel_outputs)
1161
 
1162
+ # Load Existing Space Button logic
1163
+ load_space_outputs = [
1164
+ formatted_space_output_display, detected_files_preview, status_output,
1165
+ file_browser_dropdown, owner_name_input, space_name_input, # Update these inputs as well
1166
+ space_iframe_display, download_button, build_status_display,
1167
+ edit_status_display, space_runtime_status_display,
1168
+ chatbot_display, # Keep chat history
1169
+ changeset_state, changeset_display, confirm_accordion, confirm_button, cancel_button # Clear and hide confirm UI
1170
+ ]
1171
+ load_space_button.click(
1172
+ fn=handle_load_existing_space,
1173
+ inputs=[hf_api_key_input, owner_name_input, space_name_input],
1174
+ outputs=load_space_outputs
1175
+ )
1176
 
1177
+ # Manual Build Button logic
1178
+ build_outputs = [
1179
+ build_status_display, space_iframe_display, file_browser_dropdown,
1180
+ owner_name_input, space_name_input, # Update inputs based on build result
1181
+ changeset_state, changeset_display, confirm_accordion, confirm_button, cancel_button, # Clear and hide confirm UI
1182
+ formatted_space_output_display, detected_files_preview, download_button # Update markdown displays
1183
+ ]
1184
+ build_inputs = [
1185
+ hf_api_key_input, space_name_input, owner_name_input, space_sdk_select,
1186
+ space_private_checkbox, formatted_space_output_display # Use content from this textbox
1187
+ ]
1188
  build_space_button.click(fn=handle_build_space_button, inputs=build_inputs, outputs=build_outputs)
1189
 
1190
+
1191
+ # Manual File Editor Load logic
1192
  file_edit_load_outputs = [file_content_editor, edit_status_display, commit_message_input, file_content_editor] # last one updates language
1193
  file_browser_dropdown.change(fn=handle_load_file_for_editing, inputs=[hf_api_key_input, space_name_input, owner_name_input, file_browser_dropdown], outputs=file_edit_load_outputs)
1194
 
1195
+ # Manual File Commit logic
1196
  commit_file_outputs = [edit_status_display, file_browser_dropdown, formatted_space_output_display, detected_files_preview, download_button]
1197
  update_file_button.click(fn=handle_commit_file_changes, inputs=[hf_api_key_input, space_name_input, owner_name_input, file_browser_dropdown, file_content_editor, commit_message_input], outputs=commit_file_outputs)
1198
 
1199
+ # Manual File Delete logic
1200
+ delete_file_outputs = [
1201
+ edit_status_display, file_browser_dropdown,
1202
+ file_content_editor, commit_message_input, file_content_editor, # Clear editor fields
1203
+ formatted_space_output_display, detected_files_preview, download_button # Update markdown displays
1204
+ ]
1205
  delete_file_button.click(fn=handle_delete_file, inputs=[hf_api_key_input, space_name_input, owner_name_input, file_browser_dropdown], outputs=delete_file_outputs)
1206
 
1207
+ # Refresh Runtime Status logic
1208
  refresh_status_button.click(fn=handle_refresh_space_status, inputs=[hf_api_key_input, owner_name_input, space_name_input], outputs=[space_runtime_status_display])
1209
 
1210
  if __name__ == "__main__":
1211
+ demo.launch(debug=False, mcp_server=True) # Ensure mcp_server is True for HF Spaces
1212
+ ```