Spaces:
Running
Running
add devstral and react updates
Browse files- Dockerfile +1 -0
- backend_api.py +94 -40
- backend_deploy.py +117 -0
- backend_models.py +2 -2
- backend_search_replace.py +214 -0
Dockerfile
CHANGED
|
@@ -59,6 +59,7 @@ COPY --chown=user:user backend_docs_manager.py .
|
|
| 59 |
COPY --chown=user:user backend_prompts.py .
|
| 60 |
COPY --chown=user:user backend_parsers.py .
|
| 61 |
COPY --chown=user:user backend_deploy.py .
|
|
|
|
| 62 |
COPY --chown=user:user project_importer.py .
|
| 63 |
COPY --chown=user:user app.py .
|
| 64 |
|
|
|
|
| 59 |
COPY --chown=user:user backend_prompts.py .
|
| 60 |
COPY --chown=user:user backend_parsers.py .
|
| 61 |
COPY --chown=user:user backend_deploy.py .
|
| 62 |
+
COPY --chown=user:user backend_search_replace.py .
|
| 63 |
COPY --chown=user:user project_importer.py .
|
| 64 |
COPY --chown=user:user app.py .
|
| 65 |
|
backend_api.py
CHANGED
|
@@ -99,7 +99,8 @@ def get_cached_client(model_id: str, provider: str = "auto"):
|
|
| 99 |
|
| 100 |
# Define models and languages here to avoid importing Gradio UI
|
| 101 |
AVAILABLE_MODELS = [
|
| 102 |
-
{"name": "
|
|
|
|
| 103 |
{"name": "DeepSeek V3.2", "id": "deepseek-ai/DeepSeek-V3.2-Exp", "description": "DeepSeek V3.2 Experimental - Fast model for code generation via HuggingFace Router with Novita provider", "supports_images": False},
|
| 104 |
{"name": "DeepSeek R1", "id": "deepseek-ai/DeepSeek-R1-0528", "description": "DeepSeek R1 model for code generation", "supports_images": False},
|
| 105 |
{"name": "Gemini 3.0 Pro", "id": "gemini-3.0-pro", "description": "Google Gemini 3.0 Pro via Poe with advanced reasoning", "supports_images": False},
|
|
@@ -199,7 +200,7 @@ async def startup_event():
|
|
| 199 |
class CodeGenerationRequest(BaseModel):
|
| 200 |
query: str
|
| 201 |
language: str = "html"
|
| 202 |
-
model_id: str = "
|
| 203 |
provider: str = "auto"
|
| 204 |
history: List[List[str]] = []
|
| 205 |
agent_mode: bool = False
|
|
@@ -842,12 +843,63 @@ async def generate_code(
|
|
| 842 |
try:
|
| 843 |
# Handle Mistral models with different API
|
| 844 |
if is_mistral_model(selected_model_id):
|
| 845 |
-
print("[Generate] Using Mistral SDK")
|
| 846 |
-
|
| 847 |
-
|
| 848 |
-
|
| 849 |
-
|
| 850 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 851 |
|
| 852 |
# All other models use OpenAI-compatible API
|
| 853 |
else:
|
|
@@ -862,40 +914,42 @@ async def generate_code(
|
|
| 862 |
chunk_count = 0
|
| 863 |
is_mistral = is_mistral_model(selected_model_id)
|
| 864 |
|
| 865 |
-
#
|
| 866 |
-
|
| 867 |
-
|
| 868 |
-
|
| 869 |
-
|
| 870 |
-
# Mistral format: chunk.data.choices[0].delta.content
|
| 871 |
-
try:
|
| 872 |
-
if chunk.data and chunk.data.choices and chunk.data.choices[0].delta.content:
|
| 873 |
-
chunk_content = chunk.data.choices[0].delta.content
|
| 874 |
-
except (AttributeError, IndexError):
|
| 875 |
-
continue
|
| 876 |
-
else:
|
| 877 |
-
# OpenAI format: chunk.choices[0].delta.content
|
| 878 |
-
try:
|
| 879 |
-
if chunk.choices and chunk.choices[0].delta.content:
|
| 880 |
-
chunk_content = chunk.choices[0].delta.content
|
| 881 |
-
except (AttributeError, IndexError):
|
| 882 |
-
continue
|
| 883 |
-
|
| 884 |
-
if chunk_content:
|
| 885 |
-
generated_code += chunk_content
|
| 886 |
-
chunk_count += 1
|
| 887 |
|
| 888 |
-
|
| 889 |
-
|
| 890 |
-
|
| 891 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 892 |
|
| 893 |
-
|
| 894 |
-
|
| 895 |
-
|
| 896 |
-
|
| 897 |
-
|
| 898 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 899 |
|
| 900 |
# Clean up generated code (remove LLM explanatory text and markdown)
|
| 901 |
generated_code = cleanup_generated_code(generated_code, language)
|
|
|
|
| 99 |
|
| 100 |
# Define models and languages here to avoid importing Gradio UI
|
| 101 |
AVAILABLE_MODELS = [
|
| 102 |
+
{"name": "Devstral Medium 2512", "id": "devstral-medium-2512", "description": "Mistral Devstral Medium 2512 - Expert code generation model via Mistral Conversations API (Default)", "supports_images": False},
|
| 103 |
+
{"name": "GLM-4.6V 👁️", "id": "zai-org/GLM-4.6V:zai-org", "description": "GLM-4.6V vision model - supports image uploads for visual understanding", "supports_images": True},
|
| 104 |
{"name": "DeepSeek V3.2", "id": "deepseek-ai/DeepSeek-V3.2-Exp", "description": "DeepSeek V3.2 Experimental - Fast model for code generation via HuggingFace Router with Novita provider", "supports_images": False},
|
| 105 |
{"name": "DeepSeek R1", "id": "deepseek-ai/DeepSeek-R1-0528", "description": "DeepSeek R1 model for code generation", "supports_images": False},
|
| 106 |
{"name": "Gemini 3.0 Pro", "id": "gemini-3.0-pro", "description": "Google Gemini 3.0 Pro via Poe with advanced reasoning", "supports_images": False},
|
|
|
|
| 200 |
class CodeGenerationRequest(BaseModel):
|
| 201 |
query: str
|
| 202 |
language: str = "html"
|
| 203 |
+
model_id: str = "devstral-medium-2512"
|
| 204 |
provider: str = "auto"
|
| 205 |
history: List[List[str]] = []
|
| 206 |
agent_mode: bool = False
|
|
|
|
| 843 |
try:
|
| 844 |
# Handle Mistral models with different API
|
| 845 |
if is_mistral_model(selected_model_id):
|
| 846 |
+
print(f"[Generate] Using Mistral SDK for {selected_model_id}")
|
| 847 |
+
|
| 848 |
+
# devstral-medium-2512 uses the beta Conversations API
|
| 849 |
+
if selected_model_id == "devstral-medium-2512":
|
| 850 |
+
# Convert messages to inputs format for Conversations API
|
| 851 |
+
# Extract system instruction from messages
|
| 852 |
+
instructions = ""
|
| 853 |
+
inputs = []
|
| 854 |
+
for msg in messages:
|
| 855 |
+
if msg["role"] == "system":
|
| 856 |
+
instructions = msg["content"]
|
| 857 |
+
else:
|
| 858 |
+
inputs.append({
|
| 859 |
+
"role": msg["role"],
|
| 860 |
+
"content": msg["content"]
|
| 861 |
+
})
|
| 862 |
+
|
| 863 |
+
# Use beta Conversations API
|
| 864 |
+
response = client.beta.conversations.start(
|
| 865 |
+
inputs=inputs,
|
| 866 |
+
model=actual_model_id,
|
| 867 |
+
instructions=instructions,
|
| 868 |
+
completion_args={
|
| 869 |
+
"temperature": 0.7,
|
| 870 |
+
"max_tokens": 10000,
|
| 871 |
+
"top_p": 1
|
| 872 |
+
},
|
| 873 |
+
tools=[],
|
| 874 |
+
)
|
| 875 |
+
|
| 876 |
+
# For non-streaming response, yield the complete content
|
| 877 |
+
# Note: Conversations API might not support streaming in the same way
|
| 878 |
+
# We'll yield the complete response as chunks for consistency
|
| 879 |
+
full_response = str(response)
|
| 880 |
+
generated_code = full_response
|
| 881 |
+
|
| 882 |
+
# Yield in chunks to maintain consistency with streaming API
|
| 883 |
+
chunk_size = 100
|
| 884 |
+
for i in range(0, len(full_response), chunk_size):
|
| 885 |
+
chunk_content = full_response[i:i+chunk_size]
|
| 886 |
+
event_data = json.dumps({
|
| 887 |
+
"type": "chunk",
|
| 888 |
+
"content": chunk_content
|
| 889 |
+
})
|
| 890 |
+
yield f"data: {event_data}\\n\\n"
|
| 891 |
+
await asyncio.sleep(0)
|
| 892 |
+
|
| 893 |
+
# Skip the normal streaming loop
|
| 894 |
+
stream = None
|
| 895 |
+
else:
|
| 896 |
+
# Other Mistral models use the standard chat.stream API
|
| 897 |
+
stream = client.chat.stream(
|
| 898 |
+
model=actual_model_id,
|
| 899 |
+
messages=messages,
|
| 900 |
+
max_tokens=10000
|
| 901 |
+
)
|
| 902 |
+
|
| 903 |
|
| 904 |
# All other models use OpenAI-compatible API
|
| 905 |
else:
|
|
|
|
| 914 |
chunk_count = 0
|
| 915 |
is_mistral = is_mistral_model(selected_model_id)
|
| 916 |
|
| 917 |
+
# Only process stream if it exists (not None for Conversations API)
|
| 918 |
+
if stream:
|
| 919 |
+
# Optimized chunk processing - reduce attribute lookups
|
| 920 |
+
for chunk in stream:
|
| 921 |
+
chunk_content = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 922 |
|
| 923 |
+
if is_mistral:
|
| 924 |
+
# Mistral format: chunk.data.choices[0].delta.content
|
| 925 |
+
try:
|
| 926 |
+
if chunk.data and chunk.data.choices and chunk.data.choices[0].delta.content:
|
| 927 |
+
chunk_content = chunk.data.choices[0].delta.content
|
| 928 |
+
except (AttributeError, IndexError):
|
| 929 |
+
continue
|
| 930 |
+
else:
|
| 931 |
+
# OpenAI format: chunk.choices[0].delta.content
|
| 932 |
+
try:
|
| 933 |
+
if chunk.choices and chunk.choices[0].delta.content:
|
| 934 |
+
chunk_content = chunk.choices[0].delta.content
|
| 935 |
+
except (AttributeError, IndexError):
|
| 936 |
+
continue
|
| 937 |
|
| 938 |
+
if chunk_content:
|
| 939 |
+
generated_code += chunk_content
|
| 940 |
+
chunk_count += 1
|
| 941 |
+
|
| 942 |
+
# Send chunk immediately - optimized JSON serialization
|
| 943 |
+
# Only yield control every 5 chunks to reduce overhead
|
| 944 |
+
if chunk_count % 5 == 0:
|
| 945 |
+
await asyncio.sleep(0)
|
| 946 |
+
|
| 947 |
+
# Build event data efficiently
|
| 948 |
+
event_data = json.dumps({
|
| 949 |
+
"type": "chunk",
|
| 950 |
+
"content": chunk_content
|
| 951 |
+
})
|
| 952 |
+
yield f"data: {event_data}\n\n"
|
| 953 |
|
| 954 |
# Clean up generated code (remove LLM explanatory text and markdown)
|
| 955 |
generated_code = cleanup_generated_code(generated_code, language)
|
backend_deploy.py
CHANGED
|
@@ -555,6 +555,123 @@ def deploy_to_huggingface_space(
|
|
| 555 |
print(f"[Deploy] language: {language}")
|
| 556 |
print(f"[Deploy] ============================================")
|
| 557 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 558 |
# For Gradio space updates (import/redesign), update .py files and upload all new files
|
| 559 |
if is_update and language == "gradio":
|
| 560 |
print(f"[Deploy] Gradio space update - updating .py files and uploading any new files")
|
|
|
|
| 555 |
print(f"[Deploy] language: {language}")
|
| 556 |
print(f"[Deploy] ============================================")
|
| 557 |
|
| 558 |
+
# For React space updates (followup changes), handle SEARCH/REPLACE blocks
|
| 559 |
+
if is_update and language == "react":
|
| 560 |
+
print(f"[Deploy] React space update - checking for search/replace blocks")
|
| 561 |
+
|
| 562 |
+
# Import search/replace utilities
|
| 563 |
+
from backend_search_replace import has_search_replace_blocks, parse_file_specific_changes, apply_search_replace_changes
|
| 564 |
+
from huggingface_hub import hf_hub_download
|
| 565 |
+
|
| 566 |
+
# Check if code contains search/replace blocks
|
| 567 |
+
if has_search_replace_blocks(code):
|
| 568 |
+
print(f"[Deploy] Detected SEARCH/REPLACE blocks - applying targeted changes")
|
| 569 |
+
|
| 570 |
+
# Parse file-specific changes from code
|
| 571 |
+
file_changes = parse_file_specific_changes(code)
|
| 572 |
+
|
| 573 |
+
# Download existing files from the space
|
| 574 |
+
try:
|
| 575 |
+
print(f"[Deploy] Downloading existing files from space: {existing_repo_id}")
|
| 576 |
+
|
| 577 |
+
# Get list of files in the space
|
| 578 |
+
space_files = api.list_repo_files(repo_id=existing_repo_id, repo_type="space")
|
| 579 |
+
print(f"[Deploy] Found {len(space_files)} files in space: {space_files}")
|
| 580 |
+
|
| 581 |
+
# Download relevant files (React/Next.js files)
|
| 582 |
+
react_file_patterns = ['.js', '.jsx', '.ts', '.tsx', '.css', '.json', 'Dockerfile']
|
| 583 |
+
existing_files = {}
|
| 584 |
+
|
| 585 |
+
for file_path in space_files:
|
| 586 |
+
# Skip non-code files
|
| 587 |
+
if any(file_path.endswith(ext) or ext in file_path for ext in react_file_patterns):
|
| 588 |
+
try:
|
| 589 |
+
downloaded_path = hf_hub_download(
|
| 590 |
+
repo_id=existing_repo_id,
|
| 591 |
+
filename=file_path,
|
| 592 |
+
repo_type="space",
|
| 593 |
+
token=token
|
| 594 |
+
)
|
| 595 |
+
with open(downloaded_path, 'r', encoding='utf-8') as f:
|
| 596 |
+
existing_files[file_path] = f.read()
|
| 597 |
+
print(f"[Deploy] Downloaded: {file_path} ({len(existing_files[file_path])} chars)")
|
| 598 |
+
except Exception as e:
|
| 599 |
+
print(f"[Deploy] Warning: Could not download {file_path}: {e}")
|
| 600 |
+
|
| 601 |
+
if not existing_files:
|
| 602 |
+
print(f"[Deploy] Warning: No React files found in space, falling back to full deployment")
|
| 603 |
+
else:
|
| 604 |
+
# Apply search/replace changes to the appropriate files
|
| 605 |
+
updated_files = []
|
| 606 |
+
|
| 607 |
+
# Check if changes are file-specific or global
|
| 608 |
+
if "__all__" in file_changes:
|
| 609 |
+
# Global changes - try to apply to all files
|
| 610 |
+
changes_text = file_changes["__all__"]
|
| 611 |
+
print(f"[Deploy] Applying global search/replace changes")
|
| 612 |
+
|
| 613 |
+
# Try to apply to each file
|
| 614 |
+
for file_path, original_content in existing_files.items():
|
| 615 |
+
modified_content = apply_search_replace_changes(original_content, changes_text)
|
| 616 |
+
if modified_content != original_content:
|
| 617 |
+
print(f"[Deploy] Modified {file_path}")
|
| 618 |
+
success, msg = update_space_file(
|
| 619 |
+
repo_id=existing_repo_id,
|
| 620 |
+
file_path=file_path,
|
| 621 |
+
content=modified_content,
|
| 622 |
+
token=token,
|
| 623 |
+
commit_message=commit_message or f"Update {file_path} from anycoder"
|
| 624 |
+
)
|
| 625 |
+
if success:
|
| 626 |
+
updated_files.append(file_path)
|
| 627 |
+
else:
|
| 628 |
+
print(f"[Deploy] Warning: Failed to update {file_path}: {msg}")
|
| 629 |
+
else:
|
| 630 |
+
# File-specific changes
|
| 631 |
+
for filename, changes_text in file_changes.items():
|
| 632 |
+
# Find the file in existing files (handle both with/without directory prefix)
|
| 633 |
+
matching_file = None
|
| 634 |
+
for file_path in existing_files.keys():
|
| 635 |
+
if file_path == filename or file_path.endswith('/' + filename):
|
| 636 |
+
matching_file = file_path
|
| 637 |
+
break
|
| 638 |
+
|
| 639 |
+
if matching_file:
|
| 640 |
+
original_content = existing_files[matching_file]
|
| 641 |
+
modified_content = apply_search_replace_changes(original_content, changes_text)
|
| 642 |
+
|
| 643 |
+
print(f"[Deploy] Applying changes to {matching_file}")
|
| 644 |
+
success, msg = update_space_file(
|
| 645 |
+
repo_id=existing_repo_id,
|
| 646 |
+
file_path=matching_file,
|
| 647 |
+
content=modified_content,
|
| 648 |
+
token=token,
|
| 649 |
+
commit_message=commit_message or f"Update {matching_file} from anycoder"
|
| 650 |
+
)
|
| 651 |
+
|
| 652 |
+
if success:
|
| 653 |
+
updated_files.append(matching_file)
|
| 654 |
+
else:
|
| 655 |
+
print(f"[Deploy] Warning: Failed to update {matching_file}: {msg}")
|
| 656 |
+
else:
|
| 657 |
+
print(f"[Deploy] Warning: File {filename} not found in space")
|
| 658 |
+
|
| 659 |
+
if updated_files:
|
| 660 |
+
space_url = f"https://huggingface.co/spaces/{existing_repo_id}"
|
| 661 |
+
files_list = ", ".join(updated_files)
|
| 662 |
+
return True, f"✅ Updated {len(updated_files)} file(s): {files_list}! View at: {space_url}", space_url
|
| 663 |
+
else:
|
| 664 |
+
return False, "No files were updated", None
|
| 665 |
+
|
| 666 |
+
except Exception as e:
|
| 667 |
+
print(f"[Deploy] Error applying search/replace changes: {e}")
|
| 668 |
+
import traceback
|
| 669 |
+
traceback.print_exc()
|
| 670 |
+
# Fall through to normal deployment
|
| 671 |
+
else:
|
| 672 |
+
print(f"[Deploy] No SEARCH/REPLACE blocks detected, proceeding with full file update")
|
| 673 |
+
# Fall through to normal React deployment below
|
| 674 |
+
|
| 675 |
# For Gradio space updates (import/redesign), update .py files and upload all new files
|
| 676 |
if is_update and language == "gradio":
|
| 677 |
print(f"[Deploy] Gradio space update - updating .py files and uploading any new files")
|
backend_models.py
CHANGED
|
@@ -149,7 +149,7 @@ def get_inference_client(model_id: str, provider: str = "auto"):
|
|
| 149 |
base_url="https://api.stepfun.com/v1"
|
| 150 |
)
|
| 151 |
|
| 152 |
-
elif model_id == "codestral-2508" or model_id == "mistral-medium-2508":
|
| 153 |
# Use Mistral client for Mistral models
|
| 154 |
return Mistral(api_key=os.getenv("MISTRAL_API_KEY"))
|
| 155 |
|
|
@@ -327,5 +327,5 @@ def is_native_sdk_model(model_id: str) -> bool:
|
|
| 327 |
|
| 328 |
def is_mistral_model(model_id: str) -> bool:
|
| 329 |
"""Check if model uses Mistral SDK"""
|
| 330 |
-
return model_id in ["codestral-2508", "mistral-medium-2508"]
|
| 331 |
|
|
|
|
| 149 |
base_url="https://api.stepfun.com/v1"
|
| 150 |
)
|
| 151 |
|
| 152 |
+
elif model_id == "codestral-2508" or model_id == "mistral-medium-2508" or model_id == "devstral-medium-2512":
|
| 153 |
# Use Mistral client for Mistral models
|
| 154 |
return Mistral(api_key=os.getenv("MISTRAL_API_KEY"))
|
| 155 |
|
|
|
|
| 327 |
|
| 328 |
def is_mistral_model(model_id: str) -> bool:
|
| 329 |
"""Check if model uses Mistral SDK"""
|
| 330 |
+
return model_id in ["codestral-2508", "mistral-medium-2508", "devstral-medium-2512"]
|
| 331 |
|
backend_search_replace.py
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Search/Replace utilities for applying targeted code changes.
|
| 3 |
+
Extracted from anycoder_app/parsers.py for use in backend.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
# Search/Replace block markers
|
| 7 |
+
SEARCH_START = "\u003c\u003c\u003c\u003c\u003c\u003c\u003c SEARCH"
|
| 8 |
+
DIVIDER = "======="
|
| 9 |
+
REPLACE_END = "\u003e\u003e\u003e\u003e\u003e\u003e\u003e REPLACE"
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def apply_search_replace_changes(original_content: str, changes_text: str) -> str:
|
| 13 |
+
"""Apply search/replace changes to content (HTML, Python, JS, CSS, etc.)
|
| 14 |
+
|
| 15 |
+
Args:
|
| 16 |
+
original_content: The original file content to modify
|
| 17 |
+
changes_text: Text containing SEARCH/REPLACE blocks
|
| 18 |
+
|
| 19 |
+
Returns:
|
| 20 |
+
Modified content with all search/replace blocks applied
|
| 21 |
+
"""
|
| 22 |
+
if not changes_text.strip():
|
| 23 |
+
return original_content
|
| 24 |
+
|
| 25 |
+
# If the model didn't use the block markers, try a CSS-rule fallback where
|
| 26 |
+
# provided blocks like `.selector { ... }` replace matching CSS rules.
|
| 27 |
+
if (SEARCH_START not in changes_text) and (DIVIDER not in changes_text) and (REPLACE_END not in changes_text):
|
| 28 |
+
try:
|
| 29 |
+
import re # Local import to avoid global side effects
|
| 30 |
+
updated_content = original_content
|
| 31 |
+
replaced_any_rule = False
|
| 32 |
+
# Find CSS-like rule blocks in the changes_text
|
| 33 |
+
# This is a conservative matcher that looks for `selector { ... }`
|
| 34 |
+
css_blocks = re.findall(r"([^{]+)\{([\s\S]*?)\}", changes_text, flags=re.MULTILINE)
|
| 35 |
+
for selector_raw, body_raw in css_blocks:
|
| 36 |
+
selector = selector_raw.strip()
|
| 37 |
+
body = body_raw.strip()
|
| 38 |
+
if not selector:
|
| 39 |
+
continue
|
| 40 |
+
# Build a regex to find the existing rule for this selector
|
| 41 |
+
# Capture opening `{` and closing `}` to preserve them; replace inner body.
|
| 42 |
+
pattern = re.compile(rf"({re.escape(selector)}\s*\{{)([\s\S]*?)(\}})")
|
| 43 |
+
def _replace_rule(match):
|
| 44 |
+
nonlocal replaced_any_rule
|
| 45 |
+
replaced_any_rule = True
|
| 46 |
+
prefix, existing_body, suffix = match.groups()
|
| 47 |
+
# Preserve indentation of the existing first body line if present
|
| 48 |
+
first_line_indent = ""
|
| 49 |
+
for line in existing_body.splitlines():
|
| 50 |
+
stripped = line.lstrip(" \t")
|
| 51 |
+
if stripped:
|
| 52 |
+
first_line_indent = line[: len(line) - len(stripped)]
|
| 53 |
+
break
|
| 54 |
+
# Re-indent provided body with the detected indent
|
| 55 |
+
if body:
|
| 56 |
+
new_body_lines = [first_line_indent + line if line.strip() else line for line in body.splitlines()]
|
| 57 |
+
new_body_text = "\n" + "\n".join(new_body_lines) + "\n"
|
| 58 |
+
else:
|
| 59 |
+
new_body_text = existing_body # If empty body provided, keep existing
|
| 60 |
+
return f"{prefix}{new_body_text}{suffix}"
|
| 61 |
+
updated_content, num_subs = pattern.subn(_replace_rule, updated_content, count=1)
|
| 62 |
+
if replaced_any_rule:
|
| 63 |
+
return updated_content
|
| 64 |
+
except Exception:
|
| 65 |
+
# Fallback silently to the standard block-based application
|
| 66 |
+
pass
|
| 67 |
+
|
| 68 |
+
# Split the changes text into individual search/replace blocks
|
| 69 |
+
blocks = []
|
| 70 |
+
current_block = ""
|
| 71 |
+
lines = changes_text.split('\n')
|
| 72 |
+
|
| 73 |
+
for line in lines:
|
| 74 |
+
if line.strip() == SEARCH_START:
|
| 75 |
+
if current_block.strip():
|
| 76 |
+
blocks.append(current_block.strip())
|
| 77 |
+
current_block = line + '\n'
|
| 78 |
+
elif line.strip() == REPLACE_END:
|
| 79 |
+
current_block += line + '\n'
|
| 80 |
+
blocks.append(current_block.strip())
|
| 81 |
+
current_block = ""
|
| 82 |
+
else:
|
| 83 |
+
current_block += line + '\n'
|
| 84 |
+
|
| 85 |
+
if current_block.strip():
|
| 86 |
+
blocks.append(current_block.strip())
|
| 87 |
+
|
| 88 |
+
modified_content = original_content
|
| 89 |
+
|
| 90 |
+
for block in blocks:
|
| 91 |
+
if not block.strip():
|
| 92 |
+
continue
|
| 93 |
+
|
| 94 |
+
# Parse the search/replace block
|
| 95 |
+
lines = block.split('\n')
|
| 96 |
+
search_lines = []
|
| 97 |
+
replace_lines = []
|
| 98 |
+
in_search = False
|
| 99 |
+
in_replace = False
|
| 100 |
+
|
| 101 |
+
for line in lines:
|
| 102 |
+
if line.strip() == SEARCH_START:
|
| 103 |
+
in_search = True
|
| 104 |
+
in_replace = False
|
| 105 |
+
elif line.strip() == DIVIDER:
|
| 106 |
+
in_search = False
|
| 107 |
+
in_replace = True
|
| 108 |
+
elif line.strip() == REPLACE_END:
|
| 109 |
+
in_replace = False
|
| 110 |
+
elif in_search:
|
| 111 |
+
search_lines.append(line)
|
| 112 |
+
elif in_replace:
|
| 113 |
+
replace_lines.append(line)
|
| 114 |
+
|
| 115 |
+
# Apply the search/replace
|
| 116 |
+
if search_lines:
|
| 117 |
+
search_text = '\n'.join(search_lines).strip()
|
| 118 |
+
replace_text = '\n'.join(replace_lines).strip()
|
| 119 |
+
|
| 120 |
+
if search_text in modified_content:
|
| 121 |
+
modified_content = modified_content.replace(search_text, replace_text)
|
| 122 |
+
else:
|
| 123 |
+
# If exact block match fails, attempt a CSS-rule fallback using the replace_text
|
| 124 |
+
try:
|
| 125 |
+
import re
|
| 126 |
+
updated_content = modified_content
|
| 127 |
+
replaced_any_rule = False
|
| 128 |
+
css_blocks = re.findall(r"([^{]+)\{([\s\S]*?)\}", replace_text, flags=re.MULTILINE)
|
| 129 |
+
for selector_raw, body_raw in css_blocks:
|
| 130 |
+
selector = selector_raw.strip()
|
| 131 |
+
body = body_raw.strip()
|
| 132 |
+
if not selector:
|
| 133 |
+
continue
|
| 134 |
+
pattern = re.compile(rf"({re.escape(selector)}\s*\{{)([\s\S]*?)(\}})")
|
| 135 |
+
def _replace_rule(match):
|
| 136 |
+
nonlocal replaced_any_rule
|
| 137 |
+
replaced_any_rule = True
|
| 138 |
+
prefix, existing_body, suffix = match.groups()
|
| 139 |
+
first_line_indent = ""
|
| 140 |
+
for line in existing_body.splitlines():
|
| 141 |
+
stripped = line.lstrip(" \t")
|
| 142 |
+
if stripped:
|
| 143 |
+
first_line_indent = line[: len(line) - len(stripped)]
|
| 144 |
+
break
|
| 145 |
+
if body:
|
| 146 |
+
new_body_lines = [first_line_indent + line if line.strip() else line for line in body.splitlines()]
|
| 147 |
+
new_body_text = "\n" + "\n".join(new_body_lines) + "\n"
|
| 148 |
+
else:
|
| 149 |
+
new_body_text = existing_body
|
| 150 |
+
return f"{prefix}{new_body_text}{suffix}"
|
| 151 |
+
updated_content, num_subs = pattern.subn(_replace_rule, updated_content, count=1)
|
| 152 |
+
if replaced_any_rule:
|
| 153 |
+
modified_content = updated_content
|
| 154 |
+
else:
|
| 155 |
+
print(f"[Search/Replace] Warning: Search text not found in content: {search_text[:100]}...")
|
| 156 |
+
except Exception:
|
| 157 |
+
print(f"[Search/Replace] Warning: Search text not found in content: {search_text[:100]}...")
|
| 158 |
+
|
| 159 |
+
return modified_content
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
def has_search_replace_blocks(text: str) -> bool:
|
| 163 |
+
"""Check if text contains SEARCH/REPLACE block markers.
|
| 164 |
+
|
| 165 |
+
Args:
|
| 166 |
+
text: Text to check
|
| 167 |
+
|
| 168 |
+
Returns:
|
| 169 |
+
True if text contains search/replace markers, False otherwise
|
| 170 |
+
"""
|
| 171 |
+
return (SEARCH_START in text) and (DIVIDER in text) and (REPLACE_END in text)
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
def parse_file_specific_changes(changes_text: str) -> dict:
|
| 175 |
+
"""Parse changes that specify which files to modify.
|
| 176 |
+
|
| 177 |
+
Looks for patterns like:
|
| 178 |
+
=== components/Header.jsx ===
|
| 179 |
+
\u003c\u003c\u003c\u003c\u003c\u003c\u003c SEARCH
|
| 180 |
+
...
|
| 181 |
+
|
| 182 |
+
Returns:
|
| 183 |
+
Dict mapping filename -> search/replace changes for that file
|
| 184 |
+
"""
|
| 185 |
+
import re
|
| 186 |
+
|
| 187 |
+
file_changes = {}
|
| 188 |
+
|
| 189 |
+
# Pattern to match file sections: === filename ===
|
| 190 |
+
file_pattern = re.compile(r"^===\s+([^\n=]+?)\s+===\s*$", re.MULTILINE)
|
| 191 |
+
|
| 192 |
+
# Find all file sections
|
| 193 |
+
matches = list(file_pattern.finditer(changes_text))
|
| 194 |
+
|
| 195 |
+
if not matches:
|
| 196 |
+
# No file-specific sections, treat entire text as changes
|
| 197 |
+
return {"__all__": changes_text}
|
| 198 |
+
|
| 199 |
+
for i, match in enumerate(matches):
|
| 200 |
+
filename = match.group(1).strip()
|
| 201 |
+
start_pos = match.end()
|
| 202 |
+
|
| 203 |
+
# Find the end of this file's section (start of next file or end of text)
|
| 204 |
+
if i + 1 < len(matches):
|
| 205 |
+
end_pos = matches[i + 1].start()
|
| 206 |
+
else:
|
| 207 |
+
end_pos = len(changes_text)
|
| 208 |
+
|
| 209 |
+
file_content = changes_text[start_pos:end_pos].strip()
|
| 210 |
+
|
| 211 |
+
if file_content:
|
| 212 |
+
file_changes[filename] = file_content
|
| 213 |
+
|
| 214 |
+
return file_changes
|