broadfield-dev commited on
Commit
57ea93b
·
verified ·
1 Parent(s): a5a2bab

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +372 -1119
app.py CHANGED
@@ -1,1127 +1,380 @@
1
- # app.py
2
  import gradio as gr
3
- import re
4
- import json
5
- # Remove direct requests import, will use model_logic
6
- # import requests
7
- import os
8
  import tempfile
9
-
10
- # --- build_logic.py is now a hard requirement ---
11
- from build_logic import (
12
- create_space as build_logic_create_space,
13
- _get_api_token as build_logic_get_api_token, # Keep this for HF Hub token logic
14
- whoami as build_logic_whoami, # Keep this for HF user info
15
- list_space_files_for_browsing,
16
- get_space_repository_info,
17
- get_space_file_content,
18
- update_space_file,
19
- parse_markdown as build_logic_parse_markdown,
20
- delete_space_file as build_logic_delete_space_file,
21
- get_space_runtime_status
22
- )
23
- print("build_logic.py loaded successfully.")
24
-
25
- # --- model_logic.py is now a hard requirement ---
26
- from model_logic import (
27
- get_available_providers,
28
- get_models_for_provider,
29
- get_default_model_for_provider,
30
- get_model_id_from_display_name, # Might not be strictly needed in app.py, but good practice
31
- generate_stream # This is the core function we'll use
32
- )
33
- print("model_logic.py loaded successfully.")
34
- # --- End imports ---
35
-
36
-
37
- bbb = chr(96) * 3
38
- # Declare the global variable at the module level where it's initialized
39
- parsed_code_blocks_state_cache = []
40
- BOT_ROLE_NAME = "assistant"
41
- # Removed GROQ_API_ENDPOINT as it's now in model_logic
42
-
43
-
44
- DEFAULT_SYSTEM_PROMPT = f"""You are an expert AI programmer. Your role is to generate code and file structures based on user requests, or to modify existing code provided by the user.
45
- When you provide NEW code for a file, or MODIFIED code for an existing file, use the following format exactly:
46
- ### File: path/to/filename.ext
47
- (You can add a short, optional, parenthesized description after the filename on the SAME line)
48
- {bbb}language
49
- # Your full code here
50
- {bbb}
51
- If the file is binary, or you cannot show its content, use this format:
52
- ### File: path/to/binaryfile.ext
53
- [Binary file - approximate_size bytes]
54
- When you provide a project file structure, use this format:
55
- ## File Structure
56
- {bbb}
57
- 📁 Root
58
- 📄 file1.py
59
- 📁 subfolder
60
- 📄 file2.js
61
- {bbb}
62
- The role name for your responses in the chat history must be '{BOT_ROLE_NAME}'.
63
- Adhere strictly to these formatting instructions.
64
- If you update a file, provide the FULL file content again under the same filename.
65
- Only the latest version of each file mentioned throughout the chat will be used for the final output.
66
- Filenames in the '### File:' line should be clean paths (e.g., 'src/app.py', 'Dockerfile') and should NOT include Markdown backticks around the filename itself.
67
- If the user provides existing code (e.g., by pasting a Markdown structure), and asks for modifications, ensure your response includes the complete, modified versions of ONLY the files that changed, using the ### File: format. Unchanged files do not need to be repeated by you. The system will merge your changes with the prior state.
68
- If the user asks to delete a file, simply omit it from your next full ### File: list.
69
- If no code is provided, assist the user with their tasks.
70
- """
71
-
72
- # --- Core Utility, Parsing, Export functions (mostly unchanged) ---
73
- def escape_html_for_markdown(text):
74
- if not isinstance(text, str): return ""
75
- # Minimal escaping, expand if needed
76
- return text.replace("&", "&").replace("<", "<").replace(">", ">")
77
-
78
-
79
- def _infer_lang_from_filename(filename):
80
- if not filename: return "plaintext"
81
- if '.' in filename:
82
- ext = filename.split('.')[-1].lower()
83
- mapping = {
84
- 'py': 'python', 'js': 'javascript', 'ts': 'typescript', 'jsx': 'javascript', 'tsx': 'typescript',
85
- 'html': 'html', 'htm': 'html', 'css': 'css', 'scss': 'scss', 'sass': 'sass', 'less': 'less',
86
- 'json': 'json', 'xml': 'xml', 'yaml': 'yaml', 'yml': 'yaml', 'toml': 'toml',
87
- 'md': 'markdown', 'rst': 'rst',
88
- 'sh': 'bash', 'bash': 'bash', 'zsh': 'bash', 'bat': 'batch', 'cmd': 'batch', 'ps1': 'powershell',
89
- 'c': 'c', 'h': 'c', 'cpp': 'cpp', 'hpp': 'cpp', 'cs': 'csharp', 'java': 'java',
90
- 'rb': 'ruby', 'php': 'php', 'go': 'go', 'rs': 'rust', 'swift': 'swift', 'kt': 'kotlin', 'kts': 'kotlin',
91
- 'sql': 'sql', 'dockerfile': 'docker', 'tf': 'terraform', 'hcl': 'terraform',
92
- 'txt': 'plaintext', 'log': 'plaintext', 'ini': 'ini', 'conf': 'plaintext', 'cfg': 'plaintext',
93
- 'csv': 'plaintext', 'tsv': 'plaintext', 'err': 'plaintext',
94
- '.env': 'plaintext', '.gitignore': 'plaintext', '.npmrc': 'plaintext', '.gitattributes': 'plaintext',
95
- 'makefile': 'makefile',
96
- }
97
- return mapping.get(ext, "plaintext")
98
- base_filename = os.path.basename(filename)
99
- if base_filename == 'Dockerfile': return 'docker'
100
- if base_filename == 'Makefile': return 'makefile'
101
- if base_filename.startswith('.'): return 'plaintext'
102
- return "plaintext"
103
-
104
- def _clean_filename(filename_line_content):
105
- text = filename_line_content.strip()
106
- text = re.sub(r'[`\*_]+', '', text) # Remove markdown emphasis characters
107
- # Try to match a valid-looking path first (allow spaces in folder/file names if quoted or part of a segment)
108
- path_match = re.match(r'^([\w\-\.\s\/\\]+)', text) # Adjusted to be more general
109
- if path_match:
110
- # Further clean if it looks like "path/to/file (description)"
111
- # Corrected split index was already correct in the original code, just ensure it's applied
112
- parts = re.split(r'\s*\(', path_match.group(1).strip(), 1)
113
- return parts[0].strip() if parts else ""
114
-
115
- # Fallback for more complex lines, like "### File: `src/app.py` (main application)"
116
- backtick_match = re.search(r'`([^`]+)`', text)
117
- if backtick_match:
118
- potential_fn = backtick_match.group(1).strip()
119
- # Corrected split index was already correct
120
- parts = re.split(r'\s*\(|\s{2,}', potential_fn, 1)
121
- cleaned_fn = parts[0].strip() if parts else ""
122
- cleaned_fn = cleaned_fn.strip('`\'":;,') # Clean common wrapping chars
123
- if cleaned_fn: return cleaned_fn
124
-
125
- # Final fallback
126
- parts = re.split(r'\s*\(|\s{2,}', text, 1)
127
- filename_candidate = parts[0].strip() if parts else text.strip()
128
- filename_candidate = filename_candidate.strip('`\'":;,') # Clean common wrapping chars
129
- return filename_candidate if filename_candidate else text.strip()
130
-
131
-
132
- def _parse_chat_stream_logic(latest_bot_message_content, existing_files_state=None):
133
- """
134
- Parses a single bot message content string to find file blocks and updates the state.
135
- Assumes existing_files_state is the current state *before* this message.
136
- """
137
- # This function takes state as an argument and returns new state, it doesn't need 'global'
138
- latest_blocks_dict = {}
139
- if existing_files_state:
140
- # Copy existing blocks, except for potential structure blocks that might be overwritten
141
- for block in existing_files_state:
142
- if not block.get("is_structure_block"):
143
- latest_blocks_dict[block["filename"]] = block.copy()
144
- # Keep existing structure block for now, it might be replaced below
145
-
146
-
147
- results = {"parsed_code_blocks": [], "preview_md": "", "default_selected_filenames": [], "error_message": None}
148
- content = latest_bot_message_content or ""
149
-
150
- 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(?: - [^\]]+)?\]))")
151
- structure_pattern = re.compile(r"## File Structure\n```(?:(?P<struct_lang>[\w.-]*)\n)?(?P<structure_code>[\s\S]*?)\n```")
152
-
153
- # Process the latest bot message for updates to file blocks
154
- structure_match = structure_pattern.search(content)
155
- if structure_match:
156
- # Add/Overwrite the structure block from the latest response
157
- 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}
158
- else:
159
- # If the latest message *doesn't* have a structure block, keep the previous one if it existed
160
- existing_structure_block = next((b for b in (existing_files_state or []) if b.get("is_structure_block")), None)
161
- if existing_structure_block:
162
- latest_blocks_dict["File Structure (original)"] = existing_structure_block.copy()
163
-
164
-
165
- # Find all file blocks in the latest message
166
- current_message_file_blocks = {}
167
- for match in file_pattern.finditer(content):
168
- filename = _clean_filename(match.group("filename_line"))
169
- if not filename: continue
170
- lang, code_block, binary_msg = match.group("lang"), match.group("code"), match.group("binary_msg")
171
- item_data = {"filename": filename, "is_binary": False, "is_structure_block": False}
172
- if code_block is not None:
173
- item_data["code"], item_data["language"] = code_block.strip(), (lang.strip().lower() if lang else _infer_lang_from_filename(filename))
174
- elif binary_msg is not None:
175
- item_data["code"], item_data["language"], item_data["is_binary"] = binary_msg.strip(), "binary", True
176
- else: continue # Should not happen with the regex
177
- current_message_file_blocks[filename] = item_data
178
-
179
- # Update latest_blocks_dict with blocks from the current message
180
- # Any file mentioned in the latest message replaces its old version
181
- latest_blocks_dict.update(current_message_file_blocks)
182
-
183
-
184
- # Convert dictionary values back to a list
185
- current_parsed_blocks = list(latest_blocks_dict.values())
186
- # Sort: structure block first, then files alphabetically
187
- current_parsed_blocks.sort(key=lambda b: (0, b["filename"]) if b.get("is_structure_block") else (1, b["filename"]))
188
-
189
- # Update the global cache outside this function if needed, or pass it back
190
- # For now, let's return the new state and let the caller update the cache.
191
- results["parsed_code_blocks"] = current_parsed_blocks
192
- results["default_selected_filenames"] = [b["filename"] for b in current_parsed_blocks if not b.get("is_structure_block")]
193
- return results
194
-
195
- def _export_selected_logic(selected_filenames, space_line_name_for_md, parsed_blocks_for_export):
196
- # This function remains largely the same, using the provided parsed_blocks_for_export
197
- # It takes state as an argument and doesn't need 'global'
198
- results = {"output_str": "", "error_message": None, "download_filepath": None}
199
- # Filter out structure blocks for file listing/export content
200
- 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:") or b.get("code", "").startswith("[Binary or Skipped file]"))]
201
- 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]")]
202
-
203
- # Collect all filenames (including binary/error ones) for the structure list
204
- all_filenames_in_state = sorted(list(set(b["filename"] for b in parsed_blocks_for_export if not b.get("is_structure_block"))))
205
-
206
- if not all_filenames_in_state and not any(b.get("is_structure_block") for b in parsed_blocks_for_export):
207
- 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.*"
208
- try:
209
- with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".md", encoding='utf-8') as tmpfile:
210
- tmpfile.write(results["output_str"]); results["download_filepath"] = tmpfile.name
211
- except Exception as e: print(f"Error creating temp file for empty export: {e}")
212
- return results
213
-
214
- output_lines = [f"# Space: {space_line_name_for_md}"]
215
-
216
- # Add File Structure block if it exists in parsed blocks
217
- structure_block = next((b for b in parsed_blocks_for_export if b.get("is_structure_block")), None)
218
- if structure_block:
219
- output_lines.extend(["## File Structure", bbb, structure_block["code"].strip(), bbb, ""])
220
- else:
221
- # If no structure block from AI, generate a simple one from detected files
222
- output_lines.extend(["## File Structure", bbb, "📁 Root"])
223
- if all_filenames_in_state:
224
- for fname in all_filenames_in_state: output_lines.append(f" 📄 {fname}")
225
- output_lines.extend([bbb, ""])
226
-
227
- output_lines.append("Below are the contents of all files in the space:\n")
228
- exported_content = False
229
-
230
- # Determine which files to export content for based on selection or default
231
- # Exportable content blocks only
232
- files_to_export_content = []
233
- if selected_filenames:
234
- files_to_export_content = [b for b in exportable_blocks_content if b["filename"] in selected_filenames]
235
- else:
236
- files_to_export_content = exportable_blocks_content # Export all content blocks by default
237
-
238
- # Add binary/error blocks if they were selected or if exporting all (and they exist)
239
- # Binary/error blocks are listed in the structure, but their *content* is just the marker string
240
- binary_error_blocks_to_export = []
241
- if selected_filenames:
242
- binary_error_blocks_to_export = [b for b in binary_blocks_content if b["filename"] in selected_filenames]
243
- elif binary_blocks_content:
244
- binary_error_blocks_to_export = binary_blocks_content # Include all binary/error if exporting all
245
-
246
- # Combine and sort all blocks whose content/marker should be included
247
- all_blocks_to_export_content = sorted(files_to_export_content + binary_error_blocks_to_export, key=lambda b: b["filename"])
248
-
249
-
250
- for block in all_blocks_to_export_content:
251
- output_lines.append(f"### File: {block['filename']}")
252
- if block.get('is_binary') or block.get("code", "").startswith("[Binary file") or block.get("code", "").startswith("[Error loading content:") or block.get("code", "").startswith("[Binary or Skipped file]"):
253
- # For binary/error placeholders, just add the marker line
254
- output_lines.append(block.get('code','[Binary or Skipped file]'))
255
- else:
256
- # For actual code/text content
257
- output_lines.extend([f"{bbb}{block.get('language', 'plaintext') or 'plaintext'}", block.get('code',''), bbb])
258
- output_lines.append(""); exported_content = True
259
-
260
- if not exported_content and not all_filenames_in_state: output_lines.append("*No files in state.*")
261
- elif not exported_content: output_lines.append("*No files with editable content are in the state or selected.*") # Message updated
262
-
263
- final_output_str = "\n".join(output_lines)
264
- results["output_str"] = final_output_str
265
- try:
266
- with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".md", encoding='utf-8') as tmpfile:
267
- tmpfile.write(final_output_str); results["download_filepath"] = tmpfile.name
268
- except Exception as e: print(f"Error creating temp file: {e}"); results["error_message"] = "Could not prepare file for download."
269
- return results
270
-
271
-
272
- def _convert_gr_history_to_api_messages(system_prompt, gr_history, current_user_message=None):
273
- # This function is fine as is, it produces standard OpenAI format
274
- messages = [{"role": "system", "content": system_prompt}] if system_prompt else []
275
- for user_msg, bot_msg in gr_history:
276
- if user_msg: messages.append({"role": "user", "content": user_msg})
277
- # Ensure bot_msg is not None or empty before adding
278
- if bot_msg and isinstance(bot_msg, str): messages.append({"role": BOT_ROLE_NAME, "content": bot_msg})
279
- if current_user_message: messages.append({"role": "user", "content": current_user_message})
280
- return messages
281
-
282
-
283
- def _generate_ui_outputs_from_cache(owner, space_name):
284
- # Declare global at the top
285
- global parsed_code_blocks_state_cache
286
- # This function remains largely the same, generating UI previews and the export MD
287
- preview_md_val = "*No files in cache to display.*"
288
- 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 or space_name else "*Load or define a Space to see its Markdown structure.*"
289
- download_file = None
290
-
291
- if parsed_code_blocks_state_cache:
292
- preview_md_lines = ["## Detected/Updated Files & Content (Latest Versions):"]
293
- for block in parsed_code_blocks_state_cache:
294
- preview_md_lines.append(f"\n----\n**File:** `{escape_html_for_markdown(block['filename'])}`")
295
- if block.get('is_structure_block'): preview_md_lines.append(f" (Original File Structure from AI)\n")
296
- elif block.get('is_binary'): preview_md_lines.append(f" (Binary File)\n")
297
- elif block.get('language') and block.get('language') != 'binary': preview_md_lines.append(f" (Language: `{block['language']}`)\n")
298
- else: preview_md_lines.append("\n")
299
-
300
- # Handle content display
301
- content = block.get('code', '')
302
- if block.get('is_binary') or content.startswith("["): # Treat errors/skipped as binary for preview display
303
- preview_md_lines.append(f"\n`{escape_html_for_markdown(content)}`\n")
304
- elif block.get('is_structure_block'):
305
- preview_md_lines.append(f"\n{bbb}{block.get('language', 'plaintext') or 'plaintext'}\n{content}\n{bbb}\n")
306
- else:
307
- preview_md_lines.append(f"\n{bbb}{block.get('language', 'plaintext') or 'plaintext'}\n{content}\n{bbb}\n")
308
-
309
-
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
-
313
- # _export_selected_logic handles selecting which files to include in the export MD
314
- # Passing None means export all non-structure/non-binary/non-error content + list all files in structure
315
- export_result = _export_selected_logic(None, space_line_name, parsed_code_blocks_state_cache)
316
- formatted_md_val = export_result["output_str"]
317
- download_file = export_result["download_filepath"]
318
-
319
- return formatted_md_val, preview_md_val, gr.update(value=download_file, interactive=download_file is not None)
320
-
321
-
322
- # --- Refactored Chat Submit Handler ---
323
- def handle_chat_submit(user_message, chat_history, api_key_input, provider_select, model_select, system_prompt, hf_owner_name, hf_repo_name, _current_formatted_markdown):
324
- # Declare global at the top
325
- global parsed_code_blocks_state_cache
326
- _chat_msg_in = ""
327
- _chat_hist = list(chat_history)
328
- _status = "Initializing..."
329
- _detected_files_update, _formatted_output_update, _download_btn_update = gr.update(), gr.update(), gr.update(interactive=False, value=None)
330
-
331
- # --- Before sending to AI: Parse existing files from the current formatted markdown ---
332
- # This ensures the AI is aware of the *current* state including user edits
333
- # or files loaded from HF, before it generates its response.
334
- # Only do this on a new user message
335
- if user_message and _current_formatted_markdown:
336
- try:
337
- parsed_from_md = build_logic_parse_markdown(_current_formatted_markdown)
338
- # Update cache with files parsed from the markdown.
339
- # Structure block from AI is volatile, always prefer structure from latest AI message.
340
- # Files from markdown overwrite any previous file blocks.
341
- new_cache_state = []
342
- # Add structure block from *current cache* if it exists, it will be replaced if the AI provides a new one
343
- existing_structure_block = next((b for b in parsed_code_blocks_state_cache if b.get("is_structure_block")), None)
344
- if existing_structure_block:
345
- new_cache_state.append(existing_structure_block.copy()) # Add copy
346
-
347
-
348
- for f_info in parsed_from_md.get("files", []):
349
- # Only add if it has a path and isn't the structure block representation placeholder
350
- if f_info.get("path") and f_info["path"] != "File Structure (original)":
351
- # Check if it's a binary representation string
352
- is_binary_repr = isinstance(f_info.get("content"), str) and (f_info["content"].startswith("[Binary file") or f_info["content"].startswith("[Error loading content:") or f_info["content"].startswith("[Binary or Skipped file]"))
353
- # Check if a block with this filename already exists in new_cache_state and replace it
354
- found_existing = False
355
- for i, block in enumerate(new_cache_state):
356
- if block["filename"] == f_info["path"] and not block.get("is_structure_block"): # Only replace non-structure blocks
357
- new_cache_state[i] = {
358
- "filename": f_info["path"],
359
- "code": f_info.get("content", ""),
360
- "language": "binary" if is_binary_repr else _infer_lang_from_filename(f_info["path"]),
361
- "is_binary": is_binary_repr,
362
- "is_structure_block": False
363
- }
364
- found_existing = True
365
- break
366
- if not found_existing:
367
- new_cache_state.append({
368
- "filename": f_info["path"],
369
- "code": f_info.get("content", ""),
370
- "language": "binary" if is_binary_repr else _infer_lang_from_filename(f_info["path"]),
371
- "is_binary": is_binary_repr,
372
- "is_structure_block": False
373
- })
374
-
375
- # Sort the updated cache state
376
- new_cache_state.sort(key=lambda b: (0, b["filename"]) if b.get("is_structure_block") else (1, b["filename"]))
377
- parsed_code_blocks_state_cache = new_cache_state # Update global cache
378
-
379
-
380
- except Exception as e:
381
- # Log error but don't block chat submission
382
- print(f"Error parsing formatted markdown before chat submit: {e}")
383
- # Optionally update status: _status = f"Warning: Error parsing current files: {e}. Sending message anyway."
384
-
385
- # --- End of pre-chat parsing ---
386
-
387
-
388
- if not user_message.strip():
389
- _status = "Cannot send an empty message."
390
- yield (_chat_msg_in, _chat_hist, _status, _detected_files_update, _formatted_output_update, _download_btn_update); return
391
- _chat_hist.append((user_message, None)); _status = f"Sending to {model_select} via {provider_select}..."
392
- yield (_chat_msg_in, _chat_hist, _status, _detected_files_update, _formatted_output_update, _download_btn_update)
393
-
394
- # Pass the API key from the UI directly to model_logic
395
- api_key_override = api_key_input
396
- # model_id = get_model_id_from_display_name(provider_select, model_select) # model_logic handles display name to ID
397
-
398
-
399
- current_sys_prompt = system_prompt.strip() or DEFAULT_SYSTEM_PROMPT
400
-
401
- # Include current file contents in the prompt as context for the AI
402
- # This context is built from the *current cache state* (which was just updated from the formatted markdown)
403
- current_files_context = ""
404
- if parsed_code_blocks_state_cache:
405
- current_files_context = "\n\n## Current Files in Space\n"
406
- for block in parsed_code_blocks_state_cache:
407
- if block.get("is_structure_block"):
408
- current_files_context += f"### File: {block['filename']}\n{bbb}\n{block['code']}\n{bbb}\n"
409
- else:
410
- current_files_context += f"### File: {block['filename']}\n"
411
- if block.get("is_binary") or block.get("code", "").startswith("["): # Include binary/error markers
412
- current_files_context += f"{block['code']}\n" # e.g. [Binary file...]
413
- else:
414
- current_files_context += f"{bbb}{block.get('language', 'plaintext') or 'plaintext'}\n{block.get('code','')}\n{bbb}\n"
415
- current_files_context += "\n"
416
-
417
- # Append current file context to the user message
418
- # This combined message structure helps the model understand the context and the expected output format
419
- user_message_with_context = user_message.strip()
420
- if current_files_context.strip():
421
- user_message_with_context = user_message_with_context + current_files_context + "\nBased on the current files above and our chat history, please provide updated file contents using the `### File: ...\n```...\n```\n` format for any files you are creating, modifying, or want to include in the final output. If you are providing a file structure, use the `## File Structure\n```\n...\n```\n` format. Omit files you want to delete from your response."
422
-
423
-
424
- # Convert history to API messages, including the user message with context
425
- api_msgs = _convert_gr_history_to_api_messages(current_sys_prompt, _chat_hist[:-1], user_message_with_context)
426
-
427
- # --- Call the new model_logic streaming function ---
428
  try:
429
- _status = f"Waiting for {model_select} via {provider_select}..."; yield (_chat_msg_in, _chat_hist, _status, _detected_files_update, _formatted_output_update, _download_btn_update)
430
-
431
- # Accumulate the full response content for parsing *after* streaming
432
- full_bot_response_content = ""
433
- error_during_stream = None
434
-
435
- # Generate stream from model_logic
436
- for chunk in generate_stream(provider_select, model_select, api_key_override, api_msgs):
437
- if chunk is None: continue # Skip None chunks if any
438
- if isinstance(chunk, str) and (chunk.startswith("Error: ") or chunk.startswith("API HTTP Error")):
439
- # If an error chunk is received, treat it as the final output and stop
440
- full_bot_response_content = chunk
441
- error_during_stream = chunk
442
- break # Stop processing stream
443
- else:
444
- # Accumulate response and update the last message in chat_hist
445
- full_bot_response_content += str(chunk) # Ensure chunk is string
446
- _chat_hist[-1] = (user_message, full_bot_response_content)
447
- _status = f"Streaming from {model_select}..."
448
- # Yield update immediately after receiving chunk
449
- yield (_chat_msg_in, _chat_hist, _status, _detected_files_update, _formatted_output_update, _download_btn_update)
450
-
451
- # After the stream finishes or breaks
452
- if error_during_stream:
453
- _status = error_during_stream # Set status to the error message
454
- elif full_bot_response_content and not full_bot_response_content.startswith("Error: "): # Only parse if it's not an error message
455
- _status = f"Streaming complete. Processing files from {model_select} response..."
456
-
457
- # Pass the *current state* (updated from markdown at the start)
458
- # and the *latest bot message content* to the parsing logic.
459
- # _parse_chat_stream_logic will merge and update based on the bot's response.
460
- parsing_res = _parse_chat_stream_logic(full_bot_response_content, existing_files_state=parsed_code_blocks_state_cache)
461
-
462
- if parsing_res["error_message"]:
463
- _status = f"Parsing Error: {parsing_res['error_message']}"
464
- # Append parsing error to the bot's response in chat for visibility? Or just status?
465
- # For now, update status and detected files area with error message
466
- _detected_files_update = gr.Markdown(f"## Parsing Error\n`{escape_html_for_markdown(parsing_res['error_message'])}`")
467
- else:
468
- # Update the global cache with the new state returned by the parser
469
- parsed_code_blocks_state_cache = parsing_res["parsed_code_blocks"]
470
-
471
- # Regenerate UI outputs from the *updated* cache
472
- _formatted_output_update, _detected_files_update, _download_btn_update = _generate_ui_outputs_from_cache(hf_owner_name, hf_repo_name)
473
- _status = "Processing complete. Previews updated."
474
  else:
475
- # Handle cases where the stream finished but yielded no content (e.g., filter) or only an error was yielded
476
- if not error_during_stream:
477
- _status = "AI response complete, but returned no content."
478
- # Keep existing previews/markdown if no content was generated to parse or if it was an error message
479
- _formatted_output_update, _detected_files_update, _download_btn_update = _generate_ui_outputs_from_cache(hf_owner_name, hf_repo_name)
480
-
481
-
482
- except Exception as e:
483
- # Catch any errors that occurred *before* or *during* the stream setup/iteration
484
- error_msg = f"An unexpected error occurred during AI generation: {e}"
485
- print(f"Unexpected error in chat submit stream: {e}")
486
- # Update the last chat message with the error
487
- if _chat_hist and len(_chat_hist) > 0 and _chat_hist[-1][1] is None:
488
- _chat_hist[-1] = (_chat_hist[-1][0], error_msg) # Keep user message, add error as bot message
489
- else:
490
- _chat_hist.append((user_message, error_msg)) # Append as a new user/bot turn if structure unexpected
491
- _status = error_msg
492
- # Previews and markdown might not be affected by a generation error, keep existing state
493
- _formatted_output_update, _detected_files_update, _download_btn_update = _generate_ui_outputs_from_cache(hf_owner_name, hf_repo_name)
494
-
495
-
496
- # Final yield to update UI after all processing
497
- yield (_chat_msg_in, _chat_hist, _status, _detected_files_update, _formatted_output_update, _download_btn_update)
498
-
499
-
500
- # --- Handler to update model dropdown based on provider selection ---
501
- def update_models_dropdown(provider_select):
502
- """Updates the model dropdown choices and selects the default model."""
503
- if not provider_select:
504
- return gr.update(choices=[], value=None)
505
- models = get_models_for_provider(provider_select)
506
- default_model = get_default_model_for_provider(provider_select)
507
- # Ensure default is in choices, or pick first, or None
508
- if default_model and default_model in models:
509
- selected_value = default_model
510
- elif models:
511
- selected_value = models[0]
512
- else:
513
- selected_value = None
514
-
515
- return gr.update(choices=models, value=selected_value)
516
-
517
-
518
- # --- Existing handlers for Load, Build, Edit, Delete, Status (Mostly unchanged, just global placement) ---
519
-
520
- def handle_load_existing_space(hf_api_key_ui, ui_owner_name, ui_space_name):
521
- # Declare global at the top
522
- global parsed_code_blocks_state_cache
523
- _formatted_md_val, _detected_preview_val, _status_val = "*Loading files...*", "*Loading files...*", f"Loading Space: {ui_owner_name}/{ui_space_name}..."
524
- _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)
525
- _build_status_clear, _edit_status_clear, _runtime_status_clear = "*Build status will appear here.*", "*Select a file to load or delete.*", "*Space runtime status will appear here after refresh.*"
526
- _chat_history_clear = [] # Clear chat history on loading a new space
527
-
528
- # Yield initial state to update UI
529
- yield (_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)
530
-
531
- owner_to_use, updated_owner_name_val = ui_owner_name, ui_owner_name
532
- error_occurred = False
533
-
534
- if not owner_to_use:
535
- token, token_err = build_logic_get_api_token(hf_api_key_ui)
536
- if token_err or not token:
537
- _status_val = f"Error: {token_err or 'Cannot determine owner from token.'}"; error_occurred = True
538
- else:
539
- try:
540
- user_info = build_logic_whoami(token=token)
541
- if user_info and 'name' in user_info:
542
- owner_to_use, updated_owner_name_val = user_info['name'], user_info['name']; _status_val += f" (Auto-detected owner: {owner_to_use})"
543
- else:
544
- _status_val = "Error: Could not auto-detect owner from token."; error_occurred = True
545
- except Exception as e:
546
- _status_val = f"Error auto-detecting owner: {e}"; error_occurred = True
547
-
548
- if not owner_to_use or not ui_space_name:
549
- if not error_occurred: _status_val = "Error: Owner and Space Name are required."; error_occurred = True
550
-
551
- if error_occurred:
552
- # Yield error state
553
- yield (f"*Error: {_status_val}*", f"*Error: {_status_val}*", _status_val, _file_browser_update, updated_owner_name_val, ui_space_name, _iframe_html_update, _download_btn_update, _build_status_clear, _edit_status_clear, _runtime_status_clear, _chat_history_clear)
554
- parsed_code_blocks_state_cache = [] # Clear cache on error
555
- return # Stop execution
556
-
557
- sdk_for_iframe, file_list, err_list_files = get_space_repository_info(hf_api_key_ui, ui_space_name, owner_to_use)
558
-
559
- # Construct iframe URL early, even if file listing fails
560
- sub_owner = re.sub(r'[^a-z0-9\-]+', '-', owner_to_use.lower()).strip('-') or 'owner' # Fallback owner
561
- sub_repo = re.sub(r'[^a-z0-9\-]+', '-', ui_space_name.lower()).strip('-') or 'space' # Fallback repo
562
- iframe_url = f"https://{sub_owner}-{sub_repo}{'.static.hf.space' if sdk_for_iframe == 'static' else '.hf.space'}"
563
- _iframe_html_update = gr.update(value=f'<iframe src="{iframe_url}?__theme=light&embed=true" width="100%" height="500px" style="border:1px solid #eee; border-radius:8px;"></iframe>', visible=True)
564
-
565
-
566
- if err_list_files and not file_list:
567
- _status_val = f"File List Error: {err_list_files}"
568
- parsed_code_blocks_state_cache = [] # Clear cache on error
569
- _formatted_md_val, _detected_preview_val, _download_btn_update = _generate_ui_outputs_from_cache(owner_to_use, ui_space_name)
570
- _file_browser_update = gr.update(visible=True, choices=[], value="Error loading files") # Update file browser with error state
571
- yield (f"*Error: {err_list_files}*", "*Error loading files*", _status_val, _file_browser_update, updated_owner_name_val, ui_space_name, _iframe_html_update, _download_btn_update, _build_status_clear, _edit_status_clear, _runtime_status_clear, _chat_history_clear)
572
- return # Stop execution
573
-
574
- if not file_list:
575
- _status_val = f"Loaded Space: {owner_to_use}/{ui_space_name}. No files found ({err_list_files or 'Repository is empty'})."
576
- parsed_code_blocks_state_cache = []
577
- _formatted_md_val, _detected_preview_val, _download_btn_update = _generate_ui_outputs_from_cache(owner_to_use, ui_space_name)
578
- _file_browser_update = gr.update(visible=True, choices=[], value="No files found")
579
- yield (_formatted_md_val, _detected_preview_val, _status_val, _file_browser_update, updated_owner_name_val, ui_space_name, _iframe_html_update, _download_btn_update, _build_status_clear, _edit_status_clear, _runtime_status_clear, _chat_history_clear)
580
- return # Stop execution
581
-
582
-
583
- loaded_files_for_cache = [] # Build a list to become the new cache state
584
- _status_val = f"Loading {len(file_list)} files from {owner_to_use}/{ui_space_name} (SDK: {sdk_for_iframe or 'unknown'})...";
585
- # Yield intermediate status while loading files
586
- yield (_formatted_md_val, _detected_preview_val, _status_val, gr.update(visible=True, choices=sorted(file_list or []), value=None), updated_owner_name_val, ui_space_name, _iframe_html_update, _download_btn_update, _build_status_clear, _edit_status_clear, _runtime_status_clear, _chat_history_clear)
587
-
588
-
589
- for file_path in file_list:
590
- # Skip files that are likely binary or not user-editable code/text
591
- # Added more extensions and common non-code files like lock files
592
- _, ext = os.path.splitext(file_path)
593
- if ext.lower() in [".png",".jpg",".jpeg",".gif",".ico",".svg",".pt",".bin",".safetensors",".onnx",".woff",".woff2",".ttf",".eot",".zip",".tar",".gz",". هفت",".pdf",".mp4",".avi",".mov",".mp3",".wav",".ogg"] or \
594
- file_path.startswith(".git") or "/.git/" in file_path or \
595
- file_path in ["requirements.txt", "environment.yml", "setup.py", "Pipfile", "pyproject.toml", "package.json", "yarn.lock", "pnpm-lock.yaml", "poetry.lock"] or \
596
- file_path.endswith(".lock") or \
597
- file_path.startswith("__pycache__/") or "/__pycache__/" in file_path or \
598
- file_path.startswith("node_modules/") or "/node_modules/" in file_path or \
599
- file_path.startswith("venv/") or "/venv/" in file_path or \
600
- file_path.startswith(".venv/") or "/.venv/" in file_path or \
601
- file_path == "README.md" or file_path == "LICENSE": # Optionally skip common non-code files like README/LICENSE
602
- loaded_files_for_cache.append({"filename": file_path, "code": "[Binary or Skipped file]", "language": "binary", "is_binary": True, "is_structure_block": False}); continue
603
-
604
- # Handle potential issues with reading large files or non-utf8 files
605
- try:
606
- content, err_get = get_space_file_content(hf_api_key_ui, ui_space_name, owner_to_use, file_path)
607
- if err_get:
608
- # If there's an error getting content, record it but don't fail the whole load
609
- loaded_files_for_cache.append({"filename": file_path, "code": f"[Error loading content: {err_get}]", "language": _infer_lang_from_filename(file_path), "is_binary": False, "is_structure_block": False})
610
- print(f"Error loading {file_path}: {err_get}");
611
- continue
612
- # If content is successfully loaded
613
- loaded_files_for_cache.append({"filename": file_path, "code": content, "language": _infer_lang_from_filename(file_path), "is_binary": False, "is_structure_block": False})
614
- except Exception as content_ex:
615
- # Catch any other unexpected exceptions during file content fetching
616
- loaded_files_for_cache.append({"filename": file_path, "code": f"[Unexpected error loading content: {content_ex}]", "language": _infer_lang_from_filename(file_path), "is_binary": False, "is_structure_block": False})
617
- print(f"Unexpected error loading {file_path}: {content_ex}")
618
- continue
619
-
620
- # Add a placeholder structure block if none was loaded (AI will generate one later if needed)
621
- # This ensures the cache isn't empty except for files
622
- # structure_block = next((b for b in loaded_files_for_cache if b.get("is_structure_block")), None)
623
- # if not structure_block:
624
- # loaded_files_for_cache.insert(0, {"filename": "File Structure (original)", "code": "📁 Root\n ...\n", "language": "plaintext", "is_binary": False, "is_structure_block": True})
625
-
626
- parsed_code_blocks_state_cache = loaded_files_for_cache
627
- _formatted_md_val, _detected_preview_val, _download_btn_update = _generate_ui_outputs_from_cache(owner_to_use, ui_space_name)
628
- _status_val = f"Successfully loaded Space: {owner_to_use}/{ui_space_name}. Markdown ready. {len(file_list)} files listed."
629
- _file_browser_update = gr.update(visible=True, choices=sorted(file_list or []), value=None) # Use the full file list for the dropdown
630
-
631
- # Final yield with updated state
632
- yield (_formatted_md_val, _detected_preview_val, _status_val, _file_browser_update, updated_owner_name_val, ui_space_name, _iframe_html_update, _download_btn_update, _build_status_clear, _edit_status_clear, _runtime_status_clear, _chat_history_clear)
633
-
634
-
635
- def handle_build_space_button(hf_api_key_ui, ui_space_name_part, ui_owner_name_part, space_sdk_ui, formatted_markdown_content):
636
- # Declare global at the top
637
- global parsed_code_blocks_state_cache
638
- # ... (this function calls build_logic_create_space and refreshes file list)
639
- _build_status, _iframe_html, _file_browser_update = "Starting space build process...", gr.update(value=None, visible=False), gr.update(visible=False, choices=[], value=None)
640
- # Include outputs for owner/space name textboxes in the initial yield
641
- yield _build_status, _iframe_html, _file_browser_update, gr.update(value=ui_owner_name_part), gr.update(value=ui_space_name_part) # Yield initial status
642
- if not ui_space_name_part or "/" in ui_space_name_part: _build_status = f"Build Error: HF Space Name '{ui_space_name_part}' must be repo name only (no '/')."; yield _build_status, _iframe_html, _file_browser_update, gr.update(), gr.update(); return
643
- final_owner_for_build = ui_owner_name_part
644
- if not final_owner_for_build:
645
- token_for_whoami, token_err = build_logic_get_api_token(hf_api_key_ui)
646
- if token_err: _build_status = f"Build Error: {token_err}"; yield _build_status, _iframe_html, _file_browser_update, gr.update(), gr.update(); return
647
- if token_for_whoami:
648
- try:
649
- user_info = build_logic_whoami(token=token_for_whoami)
650
- final_owner_for_build = user_info['name'] if user_info and 'name' in user_info else final_owner_for_build
651
- if not final_owner_for_build: _build_status += "\n(Warning: Could not auto-detect owner from token for build. Please specify.)"
652
- except Exception as e: _build_status += f"\n(Warning: Could not auto-detect owner for build: {e}. Please specify.)"
653
- else: _build_status += "\n(Warning: Owner not specified and no token to auto-detect for build. Please specify owner or provide a token.)"
654
-
655
- if not final_owner_for_build: _build_status = "Build Error: HF Owner Name could not be determined. Please specify it."; yield _build_status, _iframe_html, _file_browser_update, gr.update(), gr.update(); return
656
-
657
- # Before building, parse the markdown to ensure the cache reflects exactly what's being built
658
- # This prevents inconsistencies if the user manually edited the markdown output
659
  try:
660
- parsed_from_md_for_build = build_logic_parse_markdown(formatted_markdown_content)
661
- # Replace the global cache state with the state derived from the markdown being built
662
- parsed_code_blocks_state_cache = []
663
- if parsed_from_md_for_build.get("owner_md"): # Update UI owner/space name if present in MD
664
- ui_owner_name_part = parsed_from_md_for_build["owner_md"] # Use this updated value later
665
- if parsed_from_md_for_build.get("repo_name_md"):
666
- ui_space_name_part = parsed_from_md_for_build["repo_name_md"] # Use this updated value later
667
-
668
- # Rebuild cache from parsed markdown files + structure block
669
- structure_block_md = next((f for f in parsed_from_md_for_build.get("files", []) if f.get("path") == "File Structure (original)"), None)
670
- if structure_block_md:
671
- parsed_code_blocks_state_cache.append({
672
- "filename": structure_block_md["path"],
673
- "code": structure_block_md["content"],
674
- "language": "plaintext", # Markdown parser doesn't detect lang for structure block ```
675
- "is_binary": False,
676
- "is_structure_block": True
677
- })
678
-
679
- for f_info in parsed_from_md_for_build.get("files", []):
680
- if f_info.get("path") and f_info["path"] != "File Structure (original)":
681
- is_binary_repr = isinstance(f_info.get("content"), str) and (f_info["content"].startswith("[Binary file") or f_info["content"].startswith("[Error loading content:") or f_info["content"].startswith("[Binary or Skipped file]"))
682
- parsed_code_blocks_state_cache.append({
683
- "filename": f_info["path"],
684
- "code": f_info.get("content", ""),
685
- "language": "binary" if is_binary_repr else _infer_lang_from_filename(f_info["path"]),
686
- "is_binary": is_binary_repr,
687
- "is_structure_block": False
688
- })
689
- parsed_code_blocks_state_cache.sort(key=lambda b: (0, b["filename"]) if b.get("is_structure_block") else (1, b["filename"]))
690
-
691
- except Exception as e:
692
- _build_status = f"Build Error: Failed to parse Markdown structure before building: {e}";
693
- # Yield error status, including keeping current owner/space name in textboxes
694
- yield _build_status, _iframe_html, _file_browser_update, gr.update(value=ui_owner_name_part), gr.update(value=ui_space_name_part); return # Stop build on parse error
695
-
696
-
697
- result_message = build_logic_create_space(hf_api_key_ui, ui_space_name_part, final_owner_for_build, space_sdk_ui, formatted_markdown_content)
698
- _build_status = f"Build Process: {result_message}"
699
-
700
- # Update UI with owner/space names extracted from markdown if present
701
- owner_name_output = gr.update(value=ui_owner_name_part)
702
- space_name_output = gr.update(value=ui_space_name_part)
703
-
704
- if "Successfully" in result_message:
705
- # Use potentially updated owner/space name from markdown parsing
706
- sub_owner = re.sub(r'[^a-z0-9\-]+', '-', ui_owner_name_part.lower()).strip('-') or 'owner'
707
- sub_repo = re.sub(r'[^a-z0-9\-]+', '-', ui_space_name_part.lower()).strip('-') or 'space'
708
- iframe_url = f"https://{sub_owner}-{sub_repo}{'.static.hf.space' if space_sdk_ui == 'static' else '.hf.space'}"
709
- _iframe_html = gr.update(value=f'<iframe src="{iframe_url}?__theme=light&embed=true" width="100%" height="700px" style="border:1px solid #eee; border-radius:8px;"></iframe>', visible=True)
710
- _build_status += f"\nSpace live at: [Link]({iframe_url}) (Repo: https://huggingface.co/spaces/{ui_owner_name_part}/{ui_space_name_part})"
711
-
712
- # Refresh file list after successful build
713
- file_list, err_list = list_space_files_for_browsing(hf_api_key_ui, ui_space_name_part, ui_owner_name_part)
714
- if err_list: _build_status += f"\nFile list refresh error after build: {err_list}"; _file_browser_update = gr.update(visible=True, choices=sorted(file_list or []), value="Error refreshing files")
715
- else: _file_browser_update = gr.update(visible=True, choices=sorted(file_list or []), value=None if file_list else "No files found")
716
-
717
- # Final yield including potential updates to owner/space name textboxes
718
- yield _build_status, _iframe_html, _file_browser_update, owner_name_output, space_name_output
719
-
720
-
721
- # File editing handlers are okay, just need to ensure they update the cache properly after commit/delete
722
- def handle_load_file_for_editing(hf_api_key_ui, ui_space_name_part, ui_owner_name_part, selected_file_path):
723
- # Declare global at the top (even though it's not modified here, it's good practice if the function *might* interact with it in the future, or just for consistency)
724
- # In this specific function, `global` isn't strictly needed as it only *reads* indirectly via _generate_ui_outputs_from_cache which handles its own global
725
- # Keeping it here for consistency as per the error symptoms observed in similar functions.
726
- global parsed_code_blocks_state_cache # Added global here
727
-
728
- _file_content_val, _edit_status_val, _commit_msg_val, _lang_update = "", "Error: No file selected.", gr.update(value=""), gr.update(language="python") # Reset values
729
- if not selected_file_path or selected_file_path in ["No files found", "Error loading files", "Error refreshing files"]:
730
- yield _file_content_val, "Select a file from the dropdown.", _commit_msg_val, _lang_update # Clear editor and status
731
- return
732
-
733
- owner_to_use = ui_owner_name_part
734
- if not owner_to_use:
735
- token, token_err = build_logic_get_api_token(hf_api_key_ui)
736
- if token_err: _edit_status_val = f"Error: {token_err}"; yield (_file_content_val, _edit_status_val, _commit_msg_val, _lang_update); return
737
- if token:
738
- try:
739
- user_info = build_logic_whoami(token=token); owner_to_use = user_info['name'] if user_info and 'name' in user_info else owner_to_use
740
- if not owner_to_use: _edit_status_val = "Error: Could not auto-detect owner from token."; yield (_file_content_val, _edit_status_val, _commit_msg_val, _lang_update); return
741
- except Exception as e: _edit_status_val = f"Error auto-detecting owner for editing file: {e}"; yield (_file_content_val, _edit_status_val, _commit_msg_val, _lang_update); return
742
- else: _edit_status_val = "Error: HF Owner Name not set and no token to auto-detect."; yield (_file_content_val, _edit_status_val, _commit_msg_val, _lang_update); return
743
-
744
- if not owner_to_use or not ui_space_name_part: _edit_status_val = "Error: HF Owner and/or Space Name is missing."; yield (_file_content_val, _edit_status_val, _commit_msg_val, _lang_update); return
745
-
746
- _edit_status_val = f"Loading {selected_file_path}..."
747
- yield gr.update(value=""), _edit_status_val, gr.update(value=""), gr.update(language="python") # Yield loading state
748
-
749
- content, err = get_space_file_content(hf_api_key_ui, ui_space_name_part, owner_to_use, selected_file_path)
750
-
751
- if err:
752
- _edit_status_val = f"Error loading '{selected_file_path}': {err}"
753
- _commit_msg_val = f"Error loading {selected_file_path}"
754
- _file_content_val = f"Error loading {selected_file_path}:\n{err}"
755
- _lang_update = gr.update(language="python") # Default language for error display
756
- yield _file_content_val, _edit_status_val, _commit_msg_val, _lang_update
757
- return
758
-
759
- _file_content_val = content or ""
760
- _edit_status_val = f"Loaded {selected_file_path} for editing."
761
- _commit_msg_val = f"Update {selected_file_path} via AI Space Editor"
762
- _lang_update = gr.update(language=_infer_lang_from_filename(selected_file_path))
763
-
764
- yield _file_content_val, _edit_status_val, _commit_msg_val, _lang_update
765
-
766
- 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):
767
- # Declare global at the top
768
- global parsed_code_blocks_state_cache
769
- _edit_status_val = "Processing commit..."
770
- # Initialize updates for components that might change
771
- _file_browser_update_val = gr.update() # Will update choices or value
772
- _formatted_md_out = gr.update() # Will update markdown
773
- _detected_preview_out = gr.update() # Will update markdown preview
774
- _download_btn_out = gr.update() # Will update download button
775
-
776
- yield _edit_status_val, _file_browser_update_val, _formatted_md_out, _detected_preview_out, _download_btn_out # Yield initial status
777
-
778
-
779
- if not file_to_edit_path or file_to_edit_path in ["No files found", "Error loading files", "Error refreshing files"]:
780
- _edit_status_val = "Error: No valid file selected for commit.";
781
- yield _edit_status_val, gr.update(), gr.update(), gr.update(), gr.update(); return
782
-
783
- owner_to_use = ui_owner_name_part
784
- if not owner_to_use:
785
- token, token_err = build_logic_get_api_token(hf_api_key_ui)
786
- if token_err: _edit_status_val = f"Error: {token_err}"; yield (_edit_status_val, gr.update(), gr.update(), gr.update(), gr.update()); return
787
- if token:
788
- try:
789
- user_info = build_logic_whoami(token=token); owner_to_use = user_info['name'] if user_info and 'name' in user_info else owner_to_use
790
- if not owner_to_use: _edit_status_val = "Error: Could not auto-detect owner from token."; yield (_edit_status_val, gr.update(), gr.update(), gr.update(), gr.update()); return
791
- except Exception as e: _edit_status_val = f"Error auto-detecting owner for committing file: {e}"; yield (_edit_status_val, gr.update(), gr.update(), gr.update(), gr.update()); return
792
- else: _edit_status_val = "Error: HF Owner Name not set and no token to auto-detect."; yield (_edit_status_val, gr.update(), gr.update(), gr.update(), gr.update()); return
793
-
794
- if not owner_to_use or not ui_space_name_part: _edit_status_val = "Error: HF Owner and/or Space Name is missing."; yield (_edit_status_val, gr.update(), gr.update(), gr.update(), gr.update()); return
795
-
796
- status_msg = update_space_file(hf_api_key_ui, ui_space_name_part, owner_to_use, file_to_edit_path, edited_content, commit_message)
797
- _edit_status_val = status_msg
798
-
799
- if "Successfully updated" in status_msg:
800
- # Update the cache with the new content
801
- found_in_cache = False
802
- for block in parsed_code_blocks_state_cache:
803
- if block["filename"] == file_to_edit_path:
804
- block["code"] = edited_content
805
- block["language"] = _infer_lang_from_filename(file_to_edit_path)
806
- block["is_binary"] = False # Assume user edited text content
807
- block["is_structure_block"] = False # Ensure it's not marked as structure
808
- found_in_cache = True
809
- break
810
- if not found_in_cache:
811
- # If file was added/edited via editor and wasn't in initial load cache (e.g. binary/error placeholder), add/replace it
812
- # First remove any existing placeholder for this file
813
- parsed_code_blocks_state_cache = [b for b in parsed_code_blocks_state_cache if b["filename"] != file_to_edit_path]
814
- # Then add the new text content block
815
- parsed_code_blocks_state_cache.append({
816
- "filename": file_to_edit_path,
817
- "code": edited_content,
818
- "language": _infer_lang_from_filename(file_to_edit_path),
819
- "is_binary": False,
820
- "is_structure_block": False
821
- })
822
- # Re-sort the cache to maintain consistent order
823
- parsed_code_blocks_state_cache.sort(key=lambda b: (0, b["filename"]) if b.get("is_structure_block") else (1, b["filename"]))
824
-
825
- # Regenerate markdown and preview from the updated cache
826
- _formatted_md_out, _detected_preview_out, _download_btn_out = _generate_ui_outputs_from_cache(owner_to_use, ui_space_name_part)
827
-
828
- # Refresh file list choices and keep the current file selected
829
- new_file_list, err_list = list_space_files_for_browsing(hf_api_key_ui, ui_space_name_part, owner_to_use)
830
- if err_list:
831
- _edit_status_val += f"\nFile list refresh error: {err_list}"
832
- _file_browser_update_val = gr.update(choices=sorted(new_file_list or []), value="Error refreshing files")
833
- else:
834
- _file_browser_update_val = gr.update(choices=sorted(new_file_list or []), value=file_to_edit_path) # Keep current file selected
835
-
836
- yield _edit_status_val, _file_browser_update_val, _formatted_md_out, _detected_preview_out, _download_btn_out
837
-
838
-
839
- def handle_delete_file(hf_api_key_ui, ui_space_name_part, ui_owner_name_part, file_to_delete_path):
840
- # Declare global at the top
841
- global parsed_code_blocks_state_cache
842
- _edit_status_val = "Processing deletion..."
843
- # Initialize updates for components that might change/clear
844
- _file_browser_choices_update = gr.update() # Update choices
845
- _file_browser_value_update = None # Clear selected file value
846
- _file_content_editor_update = gr.update(value="") # Clear editor content
847
- _commit_msg_update = gr.update(value="") # Clear commit message
848
- _lang_update = gr.update(language="plaintext") # Reset editor language
849
- _formatted_md_out = gr.update() # Update markdown
850
- _detected_preview_out = gr.update() # Update markdown preview
851
- _download_btn_out = gr.update() # Update download button
852
-
853
-
854
- yield (_edit_status_val, _file_browser_choices_update, _file_browser_value_update, _file_content_editor_update, _commit_msg_update, _lang_update, _formatted_md_out, _detected_preview_out, _download_btn_out) # Yield initial status
855
-
856
-
857
- if not file_to_delete_path or file_to_delete_path in ["No files found", "Error loading files", "Error refreshing files"]:
858
- _edit_status_val = "Error: No valid file selected for deletion.";
859
- yield (_edit_status_val, gr.update(), gr.update(), gr.update(), gr.update(), gr.update(), gr.update(), gr.update(), gr.update()); return
860
-
861
- owner_to_use = ui_owner_name_part
862
- if not owner_to_use:
863
- token, token_err = build_logic_get_api_token(hf_api_key_ui)
864
- if token_err: _edit_status_val = f"API Token Error: {token_err}"; yield (_edit_status_val, gr.update(), gr.update(), gr.update(), gr.update(), gr.update(), gr.update(), gr.update(), gr.update()); return
865
- if token:
866
- try:
867
- user_info = build_logic_whoami(token=token); owner_to_use = user_info['name'] if user_info and 'name' in user_info else owner_to_use
868
- if not owner_to_use: _edit_status_val = "Error: Could not auto-detect owner from token."; yield (_edit_status_val, gr.update(), gr.update(), gr.update(), gr.update(), gr.update(), gr.update(), gr.update(), gr.update()); return
869
- except Exception as e: _edit_status_val = f"Error auto-detecting owner for deleting file: {e}"; yield (_edit_status_val, gr.update(), gr.update(), gr.update(), gr.update(), gr.update(), gr.update(), gr.update(), gr.update()); return
870
- else: _edit_status_val = "Error: HF Token needed to auto-detect owner."; yield (_edit_status_val, gr.update(), gr.update(), gr.update(), gr.update(), gr.update(), gr.update(), gr.update(), gr.update()); return
871
-
872
-
873
- if not owner_to_use or not ui_space_name_part: _edit_status_val = "Error: Owner and Space Name are required."; yield (_edit_status_val, gr.update(), gr.update(), gr.update(), gr.update(), gr.update(), gr.update(), gr.update(), gr.update()); return
874
-
875
- deletion_status_msg = build_logic_delete_space_file(hf_api_key_ui, ui_space_name_part, owner_to_use, file_to_delete_path)
876
- _edit_status_val = deletion_status_msg
877
-
878
- # Always refresh the file list dropdown choices after a delete attempt, successful or not
879
- new_file_list, err_list = list_space_files_for_browsing(hf_api_key_ui, ui_space_name_part, owner_to_use)
880
-
881
- if "Successfully deleted" in deletion_status_msg:
882
- # Remove the file from the cache
883
- parsed_code_blocks_state_cache = [b for b in parsed_code_blocks_state_cache if b["filename"] != file_to_delete_path]
884
-
885
- # Regenerate markdown and preview from the updated cache
886
- _formatted_md_out, _detected_preview_out, _download_btn_out = _generate_ui_outputs_from_cache(owner_to_use, ui_space_name_part)
887
-
888
-
889
- if err_list:
890
- _edit_status_val += f"\nFile list refresh error: {err_list}"
891
- _file_browser_choices_update = gr.update(choices=sorted(new_file_list or []), value="Error refreshing files") # Set value to error state
892
- else:
893
- _file_browser_choices_update = gr.update(choices=sorted(new_file_list or []), value=None) # Clear selection visually and internally
894
-
895
- _file_browser_value_update = None # Explicitly set value to None to clear selection visual
896
-
897
-
898
- else: # If deletion failed
899
- if err_list:
900
- _edit_status_val += f"\nFile list refresh error: {err_list}"
901
- _file_browser_choices_update = gr.update(choices=sorted(new_file_list or []), value="Error refreshing files")
902
- _file_browser_value_update = "Error refreshing files" # Keep error state in value if list failed
903
- else:
904
- # If list refresh succeeded but delete failed, refresh choices and keep the *failed-to-delete* file selected
905
- _file_browser_choices_update = gr.update(choices=sorted(new_file_list or []), value=file_to_delete_path)
906
- _file_browser_value_update = file_to_delete_path # Keep the file selected visually
907
-
908
- # Markdown and preview are not changed if deletion failed, keep current updates as gr.update()
909
- # Regenerate previews to show they are unchanged
910
- _formatted_md_out, _detected_preview_out, _download_btn_out = _generate_ui_outputs_from_cache(owner_to_use, ui_space_name_part)
911
-
912
-
913
- yield (_edit_status_val, _file_browser_choices_update, _file_browser_value_update, _file_content_editor_update, _commit_msg_update, _lang_update, _formatted_md_out, _detected_preview_out, _download_btn_out)
914
-
915
-
916
- # Space status handler is okay
917
- def handle_refresh_space_status(hf_api_key_ui, ui_owner_name, ui_space_name):
918
- # This function doesn't modify the global cache, so no global declaration needed.
919
- # ... (rest of this function is the same)
920
- yield "*Fetching space status...*" # Initial feedback
921
- owner_to_use = ui_owner_name
922
- if not owner_to_use:
923
- token, token_err = build_logic_get_api_token(hf_api_key_ui)
924
- if token_err or not token: yield f"**Error:** {token_err or 'Cannot determine owner.'}"; return
925
- try: user_info = build_logic_whoami(token=token); owner_to_use = user_info['name'] if user_info and 'name' in user_info else owner_to_use
926
- except Exception as e: yield f"**Error auto-detecting owner:** {e}"; return
927
- if not owner_to_use or not ui_space_name: yield "**Error:** Owner and Space Name are required."; return
928
- status_details, error_msg = get_space_runtime_status(hf_api_key_ui, ui_space_name, owner_to_use)
929
- if error_msg: _status_display_md = f"**Error fetching status for {owner_to_use}/{ui_space_name}:**\n\n`{escape_html_for_markdown(error_msg)}`"
930
- elif status_details:
931
- stage, hardware, error, log_link = status_details.get('stage','N/A'), status_details.get('hardware','N/A'), status_details.get('error_message'), status_details.get('full_log_link','#')
932
- md_lines = [f"### Space Status: {owner_to_use}/{ui_space_name}", f"- **Stage:** `{stage}`", f"- **Current Hardware:** `{hardware}`"]
933
- if status_details.get('requested_hardware') and status_details.get('requested_hardware') != hardware: md_lines.append(f"- **Requested Hardware:** `{status_details.get('requested_hardware')}`")
934
- if error: md_lines.append(f"- **Error:** <span style='color:red;'>`{escape_html_for_markdown(error)}`</span>")
935
- md_lines.append(f"- [View Full Logs on Hugging Face]({log_link})")
936
- if status_details.get('raw_data'):
937
- # Add raw data in a collapsible section for debugging
938
- md_lines.append(f"\n<details><summary>Raw Status Data (JSON)</summary>\n\n```json\n{json.dumps(status_details.get('raw_data', {}), indent=2)}\n```\n</details>")
939
-
940
- _status_display_md = "\n".join(md_lines)
941
- else: _status_display_md = "Could not retrieve status details."
942
- yield _status_display_md
943
-
944
-
945
- # Define a custom theme with a dark background and contrasting colors
946
- # And add custom CSS for a background gradient and component styling
947
- custom_theme = gr.themes.Base(
948
- primary_hue="teal", # Teal for primary actions
949
- secondary_hue="purple", # Purple for secondary elements
950
- neutral_hue="zinc", # Zinc for neutral/backgrounds (dark gray)
951
- text_size="sm", # Smaller text size for a denser, professional look
952
- spacing_size="md", # Medium spacing
953
- radius_size="sm", # Small border radius
954
- font=["System UI", "sans-serif"] # Use system font
955
- )
956
-
957
- custom_css = """
958
- body {
959
- background: linear-gradient(to bottom right, #2c3e50, #34495e); /* Dark blue-gray gradient */
960
- color: #ecf0f1; /* Light text color for dark background */
961
- }
962
- /* Adjust main Gradio container background to be transparent to see body gradient */
963
- .gradio-container {
964
- background: transparent !important;
965
- }
966
- /* Adjust component backgrounds for contrast against the body gradient */
967
- .gr-box, .gr-panel, .gr-pill {
968
- background-color: rgba(44, 62, 80, 0.8) !important; /* Slightly lighter transparent dark blue-gray */
969
- border-color: rgba(189, 195, 199, 0.2) !important; /* Light border for contrast */
970
- }
971
- /* Adjust inputs, dropdowns, buttons etc. for visibility */
972
- .gr-textbox, .gr-dropdown, .gr-button, .gr-code, .gr-chat-message {
973
- border-color: rgba(189, 195, 199, 0.3) !important;
974
- background-color: rgba(52, 73, 94, 0.9) !important; /* Slightly different dark blue-gray */
975
- color: #ecf0f1 !important; /* Ensure text is light */
976
- }
977
- .gr-button.gr-button-primary {
978
- background-color: #1abc9c !important; /* Teal from primary_hue */
979
- color: white !important;
980
- border-color: #16a085 !important;
981
- }
982
- .gr-button.gr-button-secondary {
983
- background-color: #9b59b6 !important; /* Purple from secondary_hue */
984
- color: white !important;
985
- border-color: #8e44ad !important;
986
- }
987
- .gr-button.gr-button-stop {
988
- background-color: #e74c3c !important; /* Red for stop/delete */
989
- color: white !important;
990
- border-color: #c0392b !important;
991
- }
992
- /* Adjust markdown backgrounds */
993
- .gr-markdown {
994
- background-color: rgba(44, 62, 80, 0.7) !important; /* Transparent dark background */
995
- padding: 10px; /* Add some padding */
996
- border-radius: 5 al;
997
- }
998
- /* Style markdown headers for better contrast */
999
- .gr-markdown h1, .gr-markdown h2, .gr-markdown h3, .gr-markdown h4, .gr-markdown h5, .gr-markdown h6 {
1000
- color: #ecf0f1 !important; /* Ensure headers are light */
1001
- border-bottom-color: rgba(189, 195, 199, 0.3) !important; /* Light separator */
1002
- }
1003
- /* Style code blocks within markdown */
1004
- .gr-markdown pre code {
1005
- background-color: rgba(52, 73, 94, 0.95) !important; /* Darker code background */
1006
- border-color: rgba(189, 195, 199, 0.3) !important;
1007
- }
1008
- /* Chatbot specific styling */
1009
- .gr-chatbot {
1010
- background-color: rgba(44, 62, 80, 0.7) !important;
1011
- border-color: rgba(189, 195, 199, 0.2) !important;
1012
- }
1013
- .gr-chatbot .message {
1014
- background-color: rgba(52, 73, 94, 0.9) !important; /* Dark background for messages */
1015
- color: #ecf0f1 !important;
1016
- border-color: rgba(189, 195, 199, 0.3) !important;
1017
- }
1018
- .gr-chatbot .message.user {
1019
- background-color: rgba(46, 204, 113, 0.9) !important; /* Greenish background for user messages */
1020
- color: black !important; /* Dark text for green background */
1021
- }
1022
- """
1023
-
1024
-
1025
- # Get initial providers and models for UI setup
1026
- available_providers = get_available_providers()
1027
- default_provider = available_providers[0] if available_providers else None
1028
- initial_models = get_models_for_provider(default_provider) if default_provider else []
1029
- initial_default_model = get_default_model_for_provider(default_provider) if default_provider else None
1030
- # Ensure initial_default_model is in the initial_models list, fallback if not
1031
- if initial_default_model not in initial_models and initial_models:
1032
- initial_default_model = initial_models[0]
1033
-
1034
-
1035
- with gr.Blocks(theme=custom_theme, css=custom_css) as demo:
1036
- gr.Markdown("# 🤖 AI Code & Space Generator")
1037
- gr.Markdown("Configure settings, chat with AI to generate/modify Hugging Face Spaces, then build, preview, and edit.")
1038
- with gr.Row():
1039
- with gr.Sidebar():
1040
- gr.Markdown("## ⚙️ Configuration")
1041
- with gr.Group(): gr.Markdown("### API Keys & Tokens");
1042
- # Single API key input, model_logic decides which env var to check or uses this override
1043
- api_key_input = gr.Textbox(label="AI Provider API Key (Optional Override)", type="password", placeholder="Paste key here or set env var (e.g., GROQ_API_KEY)");
1044
- hf_api_key_input = gr.Textbox(label="Hugging Face Token (for building/loading)", type="password", placeholder="hf_...")
1045
- with gr.Group(): gr.Markdown("### Hugging Face Space"); owner_name_input = gr.Textbox(label="HF Owner Name", placeholder="e.g., your-username"); space_name_input = gr.Textbox(label="HF Space Name", value="my-ai-space", placeholder="e.g., my-cool-app"); space_sdk_select = gr.Dropdown(label="Space SDK", choices=["gradio", "streamlit", "docker", "static"], value="gradio", info="Used for new/build."); load_space_button = gr.Button("🔄 Load Existing Space", variant="secondary", size="sm")
1046
- with gr.Group(): gr.Markdown("### AI Model Settings");
1047
- provider_select = gr.Dropdown(label="AI Provider", choices=available_providers, value=default_provider, info="Select an AI model provider.");
1048
- model_select = gr.Dropdown(label="AI Model", choices=initial_models, value=initial_default_model, info="Select a model.");
1049
- system_prompt_input = gr.Textbox(label="System Prompt", lines=8, value=DEFAULT_SYSTEM_PROMPT, interactive=True)
1050
- with gr.Column(scale=3):
1051
- gr.Markdown("## 💬 AI Chat & Code Generation")
1052
- # Updated chatbot avatar
1053
- chatbot_display = gr.Chatbot(label="AI Chat", height=400, bubble_full_width=False, avatar_images=(None, "https://huggingface.co/datasets/huggingface/badges/resolve/main/huggingface-bot-avatar.svg"))
1054
- with gr.Row(): chat_message_input = gr.Textbox(show_label=False, placeholder="Your Message...", scale=7); send_chat_button = gr.Button("Send", variant="primary", scale=1, size="lg")
1055
- status_output = gr.Textbox(label="Chat/Process Status", interactive=False, lines=1, value="Ready.")
1056
- gr.Markdown("---")
1057
- with gr.Tabs():
1058
- with gr.TabItem("📝 Formatted Space Markdown"): gr.Markdown("Complete Markdown definition for your Space."); formatted_space_output_display = gr.Textbox(label="Current Space Definition", lines=15, interactive=True, show_copy_button=True, value="*Space definition...*"); download_button = gr.DownloadButton(label="Download .md", interactive=False, size="sm")
1059
- with gr.TabItem("🔍 Detected Files Preview"):
1060
- detected_files_preview = gr.Markdown(value="*Files preview...*")
1061
-
1062
- gr.Markdown("---")
1063
- with gr.Tabs():
1064
- with gr.TabItem("🚀 Build & Preview Space"):
1065
- with gr.Row(): build_space_button = gr.Button("Build / Update Space on HF", variant="primary", scale=2); refresh_status_button = gr.Button("🔄 Refresh Space Status", scale=1)
1066
- # Build status outputs also include updating owner/space names in the textboxes
1067
- build_status_display = gr.Textbox(label="Build Operation Status", interactive=False, lines=2, value="*Build status will appear here.*"); gr.Markdown("---"); space_runtime_status_display = gr.Markdown("*Space runtime status will appear here after refresh.*"); gr.Markdown("---"); space_iframe_display = gr.HTML(value="<!-- Space Iframe -->", visible=False)
1068
- with gr.TabItem("✏️ Edit Space Files"):
1069
- gr.Markdown("Select a file to view, edit, or delete. Changes are committed to HF Hub.")
1070
- file_browser_dropdown = gr.Dropdown(label="Select File in Space", choices=[], interactive=True, visible=False, info="Load/build Space first.")
1071
- file_content_editor = gr.Code(label="File Content Editor", language="python", lines=15, interactive=True)
1072
- commit_message_input = gr.Textbox(label="Commit Message", placeholder="e.g., Updated app.py", value="Update via AI Space Editor")
1073
- with gr.Row(): update_file_button = gr.Button("Commit Changes", variant="primary", scale=2); delete_file_button = gr.Button("🗑️ Delete Selected File", variant="stop", scale=1)
1074
- edit_status_display = gr.Textbox(label="File Edit/Delete Status", interactive=False, lines=2, value="*Select file...*")
1075
-
1076
- # --- Event Handlers ---
1077
-
1078
- # Provider dropdown change event to update model dropdown
1079
- provider_select.change(
1080
- fn=update_models_dropdown,
1081
- inputs=provider_select,
1082
- outputs=model_select
1083
- )
1084
-
1085
- # Chat submit handler outputs
1086
- chat_outputs = [chat_message_input, chatbot_display, status_output, detected_files_preview, formatted_space_output_display, download_button]
1087
- # Chat submit handler inputs
1088
- chat_inputs = [chat_message_input, chatbot_display, api_key_input, provider_select, model_select, system_prompt_input, owner_name_input, space_name_input, formatted_space_output_display] # Pass current formatted markdown as context
1089
-
1090
- # Wire chat buttons
1091
- send_chat_button.click(
1092
- fn=handle_chat_submit,
1093
- inputs=chat_inputs,
1094
- outputs=chat_outputs
1095
  )
1096
- chat_message_input.submit( # Allow submitting with Enter key
1097
- fn=handle_chat_submit,
1098
- inputs=chat_inputs,
1099
- outputs=chat_outputs
1100
- )
1101
-
1102
- # Load space outputs include clearing chat history
1103
- 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] # Added chatbot_display
1104
- load_space_button.click(fn=handle_load_existing_space, inputs=[hf_api_key_input, owner_name_input, space_name_input], outputs=load_space_outputs)
1105
-
1106
- # Build outputs now include updating owner/space name textboxes
1107
- build_outputs = [build_status_display, space_iframe_display, file_browser_dropdown, owner_name_input, space_name_input] # Added owner_name_input, space_name_input
1108
- build_space_button.click(fn=handle_build_space_button, inputs=[hf_api_key_input, space_name_input, owner_name_input, space_sdk_select, formatted_space_output_display], outputs=build_outputs)
1109
-
1110
- # File edit load outputs include clearing/setting commit message and language
1111
- file_edit_load_outputs = [file_content_editor, edit_status_display, commit_message_input, file_content_editor]
1112
- 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)
1113
-
1114
- # Commit file outputs include refreshing previews and file browser state
1115
- commit_file_outputs = [edit_status_display, file_browser_dropdown, formatted_space_output_display, detected_files_preview, download_button]
1116
- 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)
1117
-
1118
- # Delete file outputs include refreshing previews, file browser state, and clearing editor
1119
- delete_file_outputs = [edit_status_display, file_browser_dropdown, file_browser_dropdown, file_content_editor, commit_message_input, file_content_editor, formatted_space_output_display, detected_files_preview, download_button] # Two file_browser_dropdown outputs: choices and value
1120
- 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)
1121
-
1122
- # Refresh status handler is okay
1123
- 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])
1124
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1125
 
1126
  if __name__ == "__main__":
1127
- demo.launch(debug=True, share=False)
 
1
+ # keylock/app.py
2
  import gradio as gr
3
+ from PIL import Image, ImageFont
 
 
 
 
4
  import tempfile
5
+ import os
6
+ import json
7
+ import logging
8
+ import traceback
9
+ import base64
10
+ import io
11
+
12
+ from . import core # Use relative import for core module
13
+ from . import __version__ # Import version for footer
14
+
15
+ app_logger = logging.getLogger("keylock_app")
16
+ if not app_logger.hasHandlers(): # Basic logging setup if not configured elsewhere
17
+ handler = logging.StreamHandler()
18
+ formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
19
+ handler.setFormatter(formatter)
20
+ app_logger.addHandler(handler)
21
+ app_logger.setLevel(logging.INFO)
22
+
23
+ # Theming
24
+ try:
25
+ font_family = [gr.themes.GoogleFont("Inter"), "ui-sans-serif", "system-ui", "sans-serif"]
26
+ except AttributeError:
27
+ app_logger.warning("gr.themes.GoogleFont not found. Using fallback fonts. This might be due to Gradio version.")
28
+ font_family = ["ui-sans-serif", "system-ui", "sans-serif"]
29
+
30
+ try:
31
+ blue_color = gr.themes.colors.blue
32
+ sky_color = gr.themes.colors.sky
33
+ slate_color = gr.themes.colors.slate
34
+ cyan_color = gr.themes.colors.cyan
35
+ neutral_color = gr.themes.colors.neutral
36
+ except AttributeError:
37
+ app_logger.warning("gr.themes.colors not found. Using placeholder colors for themes. This might be due to Gradio version.")
38
+ class FallbackColors: # Basic fallback colors
39
+ blue = "blue"; sky = "skyblue"; slate = "slategray"; cyan = "cyan"; neutral = "gray"
40
+ blue_color = FallbackColors.blue
41
+ sky_color = FallbackColors.sky
42
+ slate_color = FallbackColors.slate
43
+ cyan_color = FallbackColors.cyan
44
+ neutral_color = FallbackColors.neutral
45
+
46
+ ICON_EMBED = "➕"
47
+ ICON_EXTRACT = "➖"
48
+
49
+ def pil_to_base64_html(pil_image, max_width_px=None):
50
+ buffered = io.BytesIO(); pil_image.save(buffered, format="PNG")
51
+ img_str = base64.b64encode(buffered.getvalue()).decode()
52
+ style = f"max-width:{max_width_px}px; height:auto; border:1px solid #ccc; display:block; margin-left:auto; margin-right:auto;" if max_width_px else "border:1px solid #ccc; display:block; margin-left:auto; margin-right:auto;"
53
+ return f"<div style='text-align:center;'><img src='data:image/png;base64,{img_str}' alt='Stego Image' style='{style}'/></div>"
54
+
55
+ def gradio_embed_data(kv_string: str, password: str,
56
+ input_image_pil: Image.Image, generate_carrier_flag: bool,
57
+ show_keys_on_image_flag: bool, output_filename_base: str):
58
+ output_html_img_str, status_msg, dl_file_path = None, "An error occurred.", None
59
+ if not password: return None, "Error: Password cannot be empty.", None
60
+ if not kv_string or not kv_string.strip(): return None, "Error: Key-Value data cannot be empty.", None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
61
  try:
62
+ data_dict = core.parse_kv_string_to_dict(kv_string)
63
+ if not data_dict: return None, "Error: Parsed Key-Value data is empty.", None
64
+
65
+ original_format_note = ""
66
+ if generate_carrier_flag or input_image_pil is None:
67
+ carrier_img = core.generate_keylock_carrier_image()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
68
  else:
69
+ carrier_img = input_image_pil.copy()
70
+ if hasattr(input_image_pil, 'format') and input_image_pil.format and input_image_pil.format.upper() != 'PNG':
71
+ original_format_note = (
72
+ f"Input carrier image was format '{input_image_pil.format}'. "
73
+ f"It will be processed and saved as PNG. "
74
+ )
75
+ app_logger.warning(
76
+ f"{original_format_note}If original was lossy (e.g., JPEG), quality is preserved from upload; "
77
+ f"if it had transparency (e.g., GIF), it will be lost during RGB conversion."
78
+ )
79
+
80
+ carrier_img = carrier_img.convert("RGB")
81
+
82
+ keys_for_overlay = list(data_dict.keys()) if show_keys_on_image_flag else None
83
+ overlay_title = "KeyLock: Data Embedded"
84
+
85
+ final_carrier_with_overlay = core.draw_key_list_dropdown_overlay(
86
+ carrier_img,
87
+ keys=keys_for_overlay,
88
+ title=overlay_title
89
+ )
90
+
91
+ serial_data = json.dumps(data_dict).encode('utf-8')
92
+ encrypted_data = core.encrypt_data(serial_data, password)
93
+
94
+ stego_final_img = core.embed_data_in_image(final_carrier_with_overlay, encrypted_data)
95
+ stego_final_img = core.set_pil_image_format_to_png(stego_final_img)
96
+
97
+ fname_base = "".join(c if c.isalnum() or c in ('_','-') else '_' for c in output_filename_base.strip()) or "keylock_img"
98
+ temp_fp = None
99
+ with tempfile.NamedTemporaryFile(prefix=fname_base+"_", suffix=".png", delete=False) as tmp:
100
+ stego_final_img.save(tmp, format="PNG")
101
+ temp_fp = tmp.name
102
+
103
+ output_html_img_str = pil_to_base64_html(stego_final_img, max_width_px=480)
104
+ status_msg = (f"Data embedded into '{os.path.basename(temp_fp)}'.\n"
105
+ f"{original_format_note}"
106
+ f"Image contains visual \"{overlay_title}\" overlay "
107
+ f"{'(with key list)' if show_keys_on_image_flag and keys_for_overlay else ''} "
108
+ f"and your LSB-encoded secret data.\n"
109
+ f"Secrets: {len(serial_data)}B (raw), {len(encrypted_data)}B (encrypted).")
110
+ return output_html_img_str, status_msg, temp_fp
111
+ except ValueError as e: return None, f"Error: {str(e)}", None
112
+ except Exception as e: app_logger.error(f"Embed Error: {e}", exc_info=True); return None, f"Unexpected Error: {str(e)}", None
113
+
114
+ def gradio_extract_data(stego_image_pil: Image.Image, password: str):
115
+ if stego_image_pil is None: return "Error: No image provided.", "Error: No image."
116
+ if not password: return "Error: Password cannot be empty.", "Error: Password required."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
117
  try:
118
+ stego_image_rgb = stego_image_pil.convert("RGB")
119
+ if hasattr(stego_image_pil, 'format') and stego_image_pil.format and stego_image_pil.format.upper() != "PNG":
120
+ app_logger.warning(f"Uploaded image for extraction is format '{stego_image_pil.format}', not PNG. LSB data may be compromised if not the original KeyLock file.")
121
+
122
+ extracted_data = core.extract_data_from_image(stego_image_rgb)
123
+ decrypted_bytes = core.decrypt_data(extracted_data, password)
124
+ try:
125
+ data = json.loads(decrypted_bytes.decode('utf-8'))
126
+ txt, stat = json.dumps(data, indent=2), "Data extracted successfully (JSON)."
127
+ except (json.JSONDecodeError, UnicodeDecodeError):
128
+ try:
129
+ txt = "Decrypted (UTF-8, not JSON):\n"+decrypted_bytes.decode('utf-8')
130
+ stat = "Warning: Decrypted as UTF-8 (not JSON)."
131
+ except UnicodeDecodeError:
132
+ txt = "Decrypted (raw hex, not JSON/UTF-8):\n"+decrypted_bytes.hex()
133
+ stat = "Warning: Decrypted as raw hex."
134
+ return txt, stat
135
+ except ValueError as e: return f"Error: {str(e)}", f"Extraction Failed: {str(e)}"
136
+ except Exception as e: app_logger.error(f"Extract Error: {e}", exc_info=True); return f"Unexpected Error: {str(e)}", f"Error: {str(e)}"
137
+
138
+ def build_interface():
139
+ custom_theme = gr.themes.Base(
140
+ primary_hue="teal",
141
+ secondary_hue="purple",
142
+ neutral_hue="zinc",
143
+ text_size="sm",
144
+ spacing_size="md",
145
+ radius_size="sm",
146
+ font=["System UI", "sans-serif"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
147
  )
148
+ custom_css = """
149
+ body {
150
+ background: linear-gradient(to bottom right, #2c3e50, #34495e);
151
+ color: #ecf0f1;
152
+ }
153
+ .gradio-container {
154
+ background: transparent !important;
155
+ }
156
+ .gr-box, .gr-panel, .gr-pill {
157
+ background-color: rgba(44, 62, 80, 0.8) !important;
158
+ border-color: rgba(189, 195, 199, 0.2) !important;
159
+ }
160
+ .gr-textbox, .gr-dropdown, .gr-button, .gr-code, .gr-chat-message, .gr-image {
161
+ border-color: rgba(189, 195, 199, 0.3) !important;
162
+ background-color: rgba(52, 73, 94, 0.9) !important;
163
+ color: #ecf0f1 !important;
164
+ }
165
+ .gr-button.gr-button-primary {
166
+ background-color: #1abc9c !important;
167
+ color: white !important;
168
+ border-color: #16a085 !important;
169
+ }
170
+ .gr-button.gr-button-secondary {
171
+ background-color: #9b59b6 !important;
172
+ color: white !important;
173
+ border-color: #8e44ad !important;
174
+ }
175
+ .gr-button.gr-button-stop {
176
+ background-color: #e74c3c !important;
177
+ color: white !important;
178
+ border-color: #c0392b !important;
179
+ }
180
+ .gr-markdown {
181
+ background-color: rgba(44, 62, 80, 0.7) !important;
182
+ padding: 10px;
183
+ border-radius: 5px;
184
+ }
185
+ .gr-markdown h1, .gr-markdown h2, .gr-markdown h3, .gr-markdown h4, .gr-markdown h5, .gr-markdown h6 {
186
+ color: #ecf0f1 !important;
187
+ border-bottom-color: rgba(189, 195, 199, 0.3) !important;
188
+ }
189
+ .gr-markdown pre code {
190
+ background-color: rgba(52, 73, 94, 0.95) !important;
191
+ border-color: rgba(189, 195, 199, 0.3) !important;
192
+ }
193
+ .gr-image div img { /* Style for image preview */
194
+ border: 1px solid #ccc;
195
+ background-color: rgba(52, 73, 94, 0.9) !important;
196
+ }
197
+ .gr-file div button { /* Style for file download button */
198
+ background-color: #1abc9c !important;
199
+ color: white !important;
200
+ border: 1px solid #16a085 !important;
201
+ }
202
+ """
203
+ with gr.Blocks(theme=custom_theme, css=custom_css, title=f"KeyLock Steganography v{__version__}") as keylock_app_interface:
204
+ gr.Markdown(f"<div align='center' style='margin-bottom:15px;'><span style='font-size:2.5em;font-weight:bold;'>🔑 KeyLock v{__version__}</span><h2 style='font-size:1.2em;color:#bdc3c7;margin-top:5px;'>Portable API Key Wallet in a PNG</h2></div>")
205
+ gr.HTML("<div align='center' style='margin-bottom:10px;font-size:0.9em;color:#bdc3c7;'>Securely embed and extract API key-value pairs (or any text) within PNG images using LSB steganography and AES-256-GCM encryption.</div>")
206
+ gr.HTML("<div align='center' style='margin-bottom:15px;font-size:0.9em;'><span style='font-weight:bold;'>GitHub: <a href='https://github.com/broadfield-dev/KeyLock-API-Wallet' target='_blank' style='color:#1abc9c;'>KeyLock-API-Wallet</a> | Decoder Module: <a href='https://github.com/broadfield-dev/keylock-decode' target='_blank' style='color:#1abc9c;'>keylock-decode</a></span></div>")
207
+ gr.HTML("<hr style='border-color: rgba(189, 195, 199, 0.2); margin-bottom:25px;'>")
208
+
209
+ with gr.Tabs():
210
+ with gr.TabItem(f"{ICON_EMBED} Embed Data"):
211
+ with gr.Row():
212
+ with gr.Column(scale=2):
213
+ embed_kv_input = gr.Textbox(
214
+ label="Secret Data (Key:Value Pairs, one per line)",
215
+ placeholder="API_KEY_1: your_secret_value_1\nSERVICE_USER = 'user@example.com'\n# Lines starting with # are ignored",
216
+ lines=7,
217
+ info="Enter secrets as Key:Value or Key=Value. Each pair on a new line."
218
+ )
219
+ embed_password_input = gr.Textbox(
220
+ label="Encryption Password",
221
+ type="password",
222
+ placeholder="Enter a strong password",
223
+ info="Required to encrypt data. Keep this safe!"
224
+ )
225
+ embed_output_filename_base = gr.Textbox(
226
+ label="Base Name for Downloaded Stego Image",
227
+ value="keylock_wallet",
228
+ info="'.png' will be appended. e.g., 'my_project_secrets'"
229
+ )
230
+ with gr.Accordion("Carrier Image Options", open=False):
231
+ embed_generate_carrier_checkbox = gr.Checkbox(
232
+ label="Generate new KeyLock Wallet image",
233
+ value=True,
234
+ info="Uncheck to upload your own PNG carrier image."
235
+ )
236
+ embed_input_image_upload = gr.Image(
237
+ label="Upload Your Own PNG Carrier (Optional)",
238
+ type="pil",
239
+ image_mode="RGB",
240
+ sources=["upload"],
241
+ visible=False, # Initially hidden
242
+ show_download_button=False,
243
+ interactive=True
244
+ )
245
+ embed_show_keys_checkbox = gr.Checkbox(
246
+ label="Show list of key names on image overlay",
247
+ value=True,
248
+ info="Displays embedded key names (not values) on the image."
249
+ )
250
+ embed_button = gr.Button("Embed Secrets "+ICON_EMBED, variant="primary")
251
+
252
+ with gr.Column(scale=3):
253
+ gr.Markdown("### Output Image & Status")
254
+ embed_output_status = gr.Textbox(
255
+ label="Embedding Status",
256
+ lines=4,
257
+ interactive=False,
258
+ placeholder="Status messages will appear here..."
259
+ )
260
+ embed_output_image_html = gr.HTML(
261
+ label="Preview of Stego Image (Max 480px width)",
262
+ value="<div style='text-align:center; color:#bdc3c7; padding:20px;'>Image preview will appear here after embedding.</div>"
263
+ )
264
+ embed_download_file = gr.File(
265
+ label="Download Your KeyLock Image (PNG)",
266
+ interactive=False,
267
+ file_count="single"
268
+ )
269
+
270
+ def toggle_carrier_upload(generate_flag):
271
+ return gr.update(visible=not generate_flag)
272
+
273
+ embed_generate_carrier_checkbox.change(
274
+ fn=toggle_carrier_upload,
275
+ inputs=[embed_generate_carrier_checkbox],
276
+ outputs=[embed_input_image_upload]
277
+ )
278
+ embed_button.click(
279
+ fn=gradio_embed_data,
280
+ inputs=[
281
+ embed_kv_input,
282
+ embed_password_input,
283
+ embed_input_image_upload,
284
+ embed_generate_carrier_checkbox,
285
+ embed_show_keys_checkbox,
286
+ embed_output_filename_base
287
+ ],
288
+ outputs=[
289
+ embed_output_image_html,
290
+ embed_output_status,
291
+ embed_download_file
292
+ ]
293
+ )
294
+
295
+ with gr.TabItem(f"{ICON_EXTRACT} Extract Data"):
296
+ with gr.Row():
297
+ with gr.Column(scale=1):
298
+ extract_stego_image_upload = gr.Image(
299
+ label="Upload KeyLock PNG Image",
300
+ type="pil",
301
+ image_mode="RGB",
302
+ sources=["upload"],
303
+ show_download_button=False,
304
+ interactive=True,
305
+ info="Upload the PNG image containing KeyLock data."
306
+ )
307
+ extract_password_input = gr.Textbox(
308
+ label="Decryption Password",
309
+ type="password",
310
+ placeholder="Enter the password used during embedding",
311
+ info="Required to decrypt and extract data."
312
+ )
313
+ extract_button = gr.Button("Extract Secrets "+ICON_EXTRACT, variant="primary")
314
+
315
+ with gr.Column(scale=2):
316
+ gr.Markdown("### Extracted Data & Status")
317
+ extract_output_status = gr.Textbox(
318
+ label="Extraction Status",
319
+ lines=2,
320
+ interactive=False,
321
+ placeholder="Status messages will appear here..."
322
+ )
323
+ extract_output_data = gr.Textbox(
324
+ label="Extracted Secret Data",
325
+ lines=10,
326
+ interactive=False,
327
+ placeholder="Extracted data (usually JSON) will appear here...",
328
+ show_copy_button=True
329
+ )
330
+
331
+ extract_button.click(
332
+ fn=gradio_extract_data,
333
+ inputs=[
334
+ extract_stego_image_upload,
335
+ extract_password_input
336
+ ],
337
+ outputs=[
338
+ extract_output_data,
339
+ extract_output_status
340
+ ]
341
+ )
342
+
343
+ gr.Markdown("<hr style='border-color: rgba(189, 195, 199, 0.1); margin-top: 30px; margin-bottom:10px;'>")
344
+ gr.Markdown(f"<div style='text-align:center; font-size:0.8em; color:#7f8c8d;'>KeyLock-API-Wallet v{__version__}. Use responsibly.</div>")
345
+
346
+ return keylock_app_interface
347
+
348
+ def main():
349
+ app_logger.info(f"Starting KeyLock Gradio Application v{__version__}...")
350
+ try:
351
+ ImageFont.truetype("DejaVuSans.ttf", 10)
352
+ app_logger.info("DejaVuSans font found, PIL font rendering should be good.")
353
+ except IOError:
354
+ try:
355
+ ImageFont.truetype("arial.ttf", 10)
356
+ app_logger.info("Arial font found, PIL font rendering should be good.")
357
+ except IOError:
358
+ app_logger.warning("Common system fonts (DejaVuSans/Arial) not found. PIL might use basic bitmap font if other preferred fonts in core.py are also unavailable. Overlay text quality might be affected.")
359
+
360
+ keylock_app_interface = build_interface()
361
+
362
+ launch_args = {"allowed_paths": [tempfile.gettempdir()]}
363
+
364
+ server_name = os.environ.get('GRADIO_SERVER_NAME')
365
+ server_port = os.environ.get('GRADIO_SERVER_PORT')
366
+
367
+ if server_name:
368
+ launch_args["server_name"] = server_name
369
+ app_logger.info(f"Using server_name from environment: {server_name}")
370
+ if server_port:
371
+ try:
372
+ launch_args["server_port"] = int(server_port)
373
+ app_logger.info(f"Using server_port from environment: {server_port}")
374
+ except ValueError:
375
+ app_logger.warning(f"Invalid GRADIO_SERVER_PORT: {server_port}. Using default.")
376
+
377
+ keylock_app_interface.launch(**launch_args)
378
 
379
  if __name__ == "__main__":
380
+ main()