Spaces:
Running
Running
Commit
·
3e802a5
1
Parent(s):
37e3b47
allset
Browse files- .gitignore +7 -0
- Dockerfile +16 -0
- codescribe/__init__.py +0 -0
- codescribe/cli.py +77 -0
- codescribe/config.py +46 -0
- codescribe/llm_handler.py +123 -0
- codescribe/orchestrator.py +173 -0
- codescribe/parser.py +52 -0
- codescribe/readme_generator.py +270 -0
- codescribe/scanner.py +57 -0
- codescribe/updater.py +88 -0
- requirements.txt +16 -0
- run.py +5 -0
- server/__init__.py +0 -0
- server/main.py +275 -0
- server/tasks.py +146 -0
- static/index.html +183 -0
- static/script.js +541 -0
- static/style.css +406 -0
.gitignore
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
example_project/
|
| 2 |
+
__pycache__/
|
| 3 |
+
*/__pycache__
|
| 4 |
+
*.pyc
|
| 5 |
+
env/
|
| 6 |
+
.env
|
| 7 |
+
static/script.js.txt
|
Dockerfile
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Read the doc: https://huggingface.co/docs/hub/spaces-sdks-docker
|
| 2 |
+
# you will also find guides on how best to write your Dockerfile
|
| 3 |
+
|
| 4 |
+
FROM python:3.9
|
| 5 |
+
|
| 6 |
+
RUN useradd -m -u 1000 user
|
| 7 |
+
USER user
|
| 8 |
+
ENV PATH="/home/user/.local/bin:$PATH"
|
| 9 |
+
|
| 10 |
+
WORKDIR /app
|
| 11 |
+
|
| 12 |
+
COPY --chown=user ./requirements.txt requirements.txt
|
| 13 |
+
RUN pip install --no-cache-dir --upgrade -r requirements.txt
|
| 14 |
+
|
| 15 |
+
COPY --chown=user . /app
|
| 16 |
+
CMD ["python", "./run.py"]
|
codescribe/__init__.py
ADDED
|
File without changes
|
codescribe/cli.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import click
|
| 2 |
+
from .config import load_config
|
| 3 |
+
from .llm_handler import LLMHandler
|
| 4 |
+
from .orchestrator import DocstringOrchestrator
|
| 5 |
+
from .readme_generator import ReadmeGenerator
|
| 6 |
+
|
| 7 |
+
@click.group(context_settings=dict(help_option_names=['-h', '--help']))
|
| 8 |
+
@click.pass_context
|
| 9 |
+
def cli(ctx):
|
| 10 |
+
"""
|
| 11 |
+
CodeScribe AI: A tool for AI-assisted project documentation.
|
| 12 |
+
|
| 13 |
+
Choose a command below (e.g., 'docstrings', 'readmes') and provide its options.
|
| 14 |
+
"""
|
| 15 |
+
# This parent command now only handles setup common to all subcommands.
|
| 16 |
+
ctx.ensure_object(dict)
|
| 17 |
+
try:
|
| 18 |
+
config = load_config()
|
| 19 |
+
if not config.api_keys:
|
| 20 |
+
raise click.UsageError("No API keys found in the .env file. Please create one.")
|
| 21 |
+
|
| 22 |
+
ctx.obj['LLM_HANDLER'] = LLMHandler(config.api_keys)
|
| 23 |
+
click.echo(f"Initialized with {len(config.api_keys)} API keys.")
|
| 24 |
+
|
| 25 |
+
except Exception as e:
|
| 26 |
+
raise click.ClickException(f"Initialization failed: {e}")
|
| 27 |
+
|
| 28 |
+
@cli.command()
|
| 29 |
+
@click.option('--path', required=True, help='The local path or Git URL of the project.')
|
| 30 |
+
@click.option('--desc', required=True, help='A short description of the project.')
|
| 31 |
+
@click.option('--exclude', multiple=True, help='Directory/regex pattern to exclude. Can be used multiple times.')
|
| 32 |
+
@click.pass_context
|
| 33 |
+
def docstrings(ctx, path, desc, exclude):
|
| 34 |
+
"""Generates Python docstrings for all files."""
|
| 35 |
+
click.echo("\n--- Starting Docstring Generation ---")
|
| 36 |
+
llm_handler = ctx.obj['LLM_HANDLER']
|
| 37 |
+
|
| 38 |
+
orchestrator = DocstringOrchestrator(
|
| 39 |
+
path_or_url=path,
|
| 40 |
+
description=desc,
|
| 41 |
+
exclude=list(exclude),
|
| 42 |
+
llm_handler=llm_handler
|
| 43 |
+
)
|
| 44 |
+
try:
|
| 45 |
+
orchestrator.run()
|
| 46 |
+
click.secho("Successfully generated all docstrings.", fg="green")
|
| 47 |
+
except Exception as e:
|
| 48 |
+
click.secho(f"An error occurred during docstring generation: {e}", fg="red")
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
@cli.command()
|
| 52 |
+
@click.option('--path', required=True, help='The local path or Git URL of the project.')
|
| 53 |
+
@click.option('--desc', required=True, help='A short description of the project.')
|
| 54 |
+
@click.option('--exclude', multiple=True, help='Directory/regex pattern to exclude. Can be used multiple times.')
|
| 55 |
+
@click.pass_context
|
| 56 |
+
def readmes(ctx, path, desc, exclude):
|
| 57 |
+
"""Generates README.md files for each directory."""
|
| 58 |
+
click.echo("\n--- Starting README Generation ---")
|
| 59 |
+
llm_handler = ctx.obj['LLM_HANDLER']
|
| 60 |
+
|
| 61 |
+
generator = ReadmeGenerator(
|
| 62 |
+
path_or_url=path,
|
| 63 |
+
description=desc,
|
| 64 |
+
exclude=list(exclude),
|
| 65 |
+
llm_handler=llm_handler
|
| 66 |
+
)
|
| 67 |
+
try:
|
| 68 |
+
generator.run()
|
| 69 |
+
click.secho("Successfully generated all README files.", fg="green")
|
| 70 |
+
except Exception as e:
|
| 71 |
+
click.secho(f"An error occurred during README generation: {e}", fg="red")
|
| 72 |
+
|
| 73 |
+
def main():
|
| 74 |
+
cli(obj={})
|
| 75 |
+
|
| 76 |
+
if __name__ == '__main__':
|
| 77 |
+
main()
|
codescribe/config.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from dataclasses import dataclass, field
|
| 3 |
+
from dotenv import load_dotenv
|
| 4 |
+
from typing import List
|
| 5 |
+
|
| 6 |
+
@dataclass
|
| 7 |
+
class APIKey:
|
| 8 |
+
provider: str
|
| 9 |
+
key: str
|
| 10 |
+
model: str
|
| 11 |
+
|
| 12 |
+
def __repr__(self):
|
| 13 |
+
return f"APIKey(provider='{self.provider}', model='{self.model}')"
|
| 14 |
+
|
| 15 |
+
@dataclass
|
| 16 |
+
class Config:
|
| 17 |
+
api_keys: List[APIKey] = field(default_factory=list)
|
| 18 |
+
|
| 19 |
+
def load_config() -> Config:
|
| 20 |
+
"""Loads API keys from .env file into a Config object."""
|
| 21 |
+
load_dotenv()
|
| 22 |
+
|
| 23 |
+
config = Config()
|
| 24 |
+
|
| 25 |
+
# Load Groq keys (GROQ_API_KEY_1, GRO-API_KEY_2, etc.)
|
| 26 |
+
i = 1
|
| 27 |
+
while True:
|
| 28 |
+
key = os.getenv(f"GROQ_API_KEY_{i}")
|
| 29 |
+
if not key:
|
| 30 |
+
break
|
| 31 |
+
config.api_keys.append(APIKey(provider="groq", key=key, model="llama3-70b-8192"))
|
| 32 |
+
i += 1
|
| 33 |
+
|
| 34 |
+
# Load Gemini keys (GEMINI_API_KEY_1, etc.)
|
| 35 |
+
i = 1
|
| 36 |
+
while True:
|
| 37 |
+
key = os.getenv(f"GEMINI_API_KEY_{i}")
|
| 38 |
+
if not key:
|
| 39 |
+
break
|
| 40 |
+
config.api_keys.append(APIKey(provider="gemini", key=key, model="gemini-1.5-flash"))
|
| 41 |
+
i += 1
|
| 42 |
+
|
| 43 |
+
if not config.api_keys:
|
| 44 |
+
print("Warning: No API keys found in .env file. Please create a .env file with GROQ_API_KEY_1 or GEMINI_API_KEY_1.")
|
| 45 |
+
|
| 46 |
+
return config
|
codescribe/llm_handler.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import time
|
| 2 |
+
import json
|
| 3 |
+
from typing import Dict, List, Callable
|
| 4 |
+
import google.generativeai as genai
|
| 5 |
+
from groq import Groq, RateLimitError
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
from .config import APIKey
|
| 10 |
+
|
| 11 |
+
def no_op_callback(message: str):
|
| 12 |
+
print(message)
|
| 13 |
+
|
| 14 |
+
class LLMHandler:
|
| 15 |
+
def __init__(self, api_keys: List[APIKey], progress_callback: Callable[[str], None] = no_op_callback):
|
| 16 |
+
self.clients = []
|
| 17 |
+
self.progress_callback = progress_callback # NEW
|
| 18 |
+
for key in api_keys:
|
| 19 |
+
if key.provider == "groq":
|
| 20 |
+
self.clients.append({
|
| 21 |
+
"provider": "groq",
|
| 22 |
+
"client": Groq(api_key=key.key),
|
| 23 |
+
"model": key.model,
|
| 24 |
+
"id": f"groq_{key.key[-4:]}"
|
| 25 |
+
})
|
| 26 |
+
elif key.provider == "gemini":
|
| 27 |
+
genai.configure(api_key=key.key)
|
| 28 |
+
self.clients.append({
|
| 29 |
+
"provider": "gemini",
|
| 30 |
+
"client": genai.GenerativeModel(key.model),
|
| 31 |
+
"model": key.model,
|
| 32 |
+
"id": f"gemini_{key.key[-4:]}"
|
| 33 |
+
})
|
| 34 |
+
|
| 35 |
+
self.cooldowns: Dict[str, float] = {}
|
| 36 |
+
self.cooldown_period = 30 # 30 seconds
|
| 37 |
+
|
| 38 |
+
def generate_documentation(self, prompt: str) -> Dict:
|
| 39 |
+
"""
|
| 40 |
+
Tries to generate documentation using available clients, handling rate limits and failovers.
|
| 41 |
+
"""
|
| 42 |
+
if not self.clients:
|
| 43 |
+
raise ValueError("No LLM clients configured.")
|
| 44 |
+
|
| 45 |
+
for client_info in self.clients:
|
| 46 |
+
client_id = client_info["id"]
|
| 47 |
+
|
| 48 |
+
# Check if the client is on cooldown
|
| 49 |
+
if client_id in self.cooldowns:
|
| 50 |
+
if time.time() - self.cooldowns[client_id] < self.cooldown_period:
|
| 51 |
+
self.progress_callback(f"Skipping {client_id} (on cooldown).")
|
| 52 |
+
continue
|
| 53 |
+
else:
|
| 54 |
+
# Cooldown has expired
|
| 55 |
+
del self.cooldowns[client_id]
|
| 56 |
+
|
| 57 |
+
try:
|
| 58 |
+
self.progress_callback(f"Attempting to generate docs with {client_id} ({client_info['model']})...")
|
| 59 |
+
if client_info["provider"] == "groq":
|
| 60 |
+
response = client_info["client"].chat.completions.create(
|
| 61 |
+
messages=[{"role": "user", "content": prompt}],
|
| 62 |
+
model=client_info["model"],
|
| 63 |
+
temperature=0.1,
|
| 64 |
+
response_format={"type": "json_object"},
|
| 65 |
+
)
|
| 66 |
+
content = response.choices[0].message.content
|
| 67 |
+
|
| 68 |
+
elif client_info["provider"] == "gemini":
|
| 69 |
+
response = client_info["client"].generate_content(prompt)
|
| 70 |
+
# Gemini might wrap JSON in ```json ... ```
|
| 71 |
+
content = response.text.strip().replace("```json", "").replace("```", "").strip()
|
| 72 |
+
|
| 73 |
+
return json.loads(content)
|
| 74 |
+
|
| 75 |
+
except RateLimitError:
|
| 76 |
+
self.progress_callback(f"Rate limit hit for {client_id}. Placing it on a {self.cooldown_period}s cooldown.")
|
| 77 |
+
self.cooldowns[client_id] = time.time()
|
| 78 |
+
continue
|
| 79 |
+
except Exception as e:
|
| 80 |
+
self.progress_callback(f"An error occurred with {client_id}: {e}. Trying next client.")
|
| 81 |
+
continue
|
| 82 |
+
|
| 83 |
+
raise RuntimeError("Failed to generate documentation from all available LLM providers.")
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def generate_text_response(self, prompt: str) -> str:
|
| 87 |
+
"""
|
| 88 |
+
Generates a plain text response from LLMs, handling failovers.
|
| 89 |
+
"""
|
| 90 |
+
if not self.clients:
|
| 91 |
+
raise ValueError("No LLM clients configured.")
|
| 92 |
+
|
| 93 |
+
for client_info in self.clients:
|
| 94 |
+
client_id = client_info["id"]
|
| 95 |
+
if client_id in self.cooldowns and time.time() - self.cooldowns[client_id] < self.cooldown_period:
|
| 96 |
+
self.progress_callback(f"Skipping {client_id} (on cooldown).")
|
| 97 |
+
continue
|
| 98 |
+
elif client_id in self.cooldowns:
|
| 99 |
+
del self.cooldowns[client_id]
|
| 100 |
+
|
| 101 |
+
try:
|
| 102 |
+
self.progress_callback(f"Attempting to generate text with {client_id} ({client_info['model']})...")
|
| 103 |
+
if client_info["provider"] == "groq":
|
| 104 |
+
response = client_info["client"].chat.completions.create(
|
| 105 |
+
messages=[{"role": "user", "content": prompt}],
|
| 106 |
+
model=client_info["model"],
|
| 107 |
+
temperature=0.2,
|
| 108 |
+
)
|
| 109 |
+
return response.choices[0].message.content
|
| 110 |
+
|
| 111 |
+
elif client_info["provider"] == "gemini":
|
| 112 |
+
response = client_info["client"].generate_content(prompt)
|
| 113 |
+
return response.text.strip()
|
| 114 |
+
|
| 115 |
+
except RateLimitError:
|
| 116 |
+
self.progress_callback(f"Rate limit hit for {client_id}. Placing it on a {self.cooldown_period}s cooldown.")
|
| 117 |
+
self.cooldowns[client_id] = time.time()
|
| 118 |
+
continue
|
| 119 |
+
except Exception as e:
|
| 120 |
+
self.progress_callback(f"An error occurred with {client_id}: {e}. Trying next client.")
|
| 121 |
+
continue
|
| 122 |
+
|
| 123 |
+
raise RuntimeError("Failed to generate text response from all available LLM providers.")
|
codescribe/orchestrator.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import shutil
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
from typing import List, Callable, Dict
|
| 4 |
+
import networkx as nx
|
| 5 |
+
import json
|
| 6 |
+
from collections import defaultdict
|
| 7 |
+
|
| 8 |
+
from . import scanner, parser, updater
|
| 9 |
+
from .llm_handler import LLMHandler
|
| 10 |
+
|
| 11 |
+
COMBINED_DOCSTRING_PROMPT_TEMPLATE = """
|
| 12 |
+
SYSTEM: You are an expert programmer writing high-quality, comprehensive Python docstrings in reStructuredText (reST) format. Your output MUST be a single JSON object.
|
| 13 |
+
|
| 14 |
+
USER:
|
| 15 |
+
Project Description:
|
| 16 |
+
\"\"\"
|
| 17 |
+
{project_description}
|
| 18 |
+
\"\"\"
|
| 19 |
+
|
| 20 |
+
---
|
| 21 |
+
CONTEXT FROM DEPENDENCIES:
|
| 22 |
+
This file depends on other modules. Here is their documentation for context:
|
| 23 |
+
|
| 24 |
+
{dependency_context}
|
| 25 |
+
---
|
| 26 |
+
|
| 27 |
+
DOCUMENT THE FOLLOWING SOURCE FILE:
|
| 28 |
+
|
| 29 |
+
File Path: `{file_path}`
|
| 30 |
+
|
| 31 |
+
```python
|
| 32 |
+
{file_content}
|
| 33 |
+
```
|
| 34 |
+
INSTRUCTIONS:
|
| 35 |
+
Provide a single JSON object as your response.
|
| 36 |
+
1. The JSON object MUST have a special key `\"__module__\"`. The value for this key should be a concise, single-paragraph docstring that summarizes the purpose of the entire file.
|
| 37 |
+
2. The other keys in the JSON object should be the function or class names (e.g., "my_function", "MyClass", "MyClass.my_method").
|
| 38 |
+
3. The values for these other keys should be their complete docstrings.
|
| 39 |
+
4. Do NOT include the original code in your response. Only generate the JSON containing the docstrings.
|
| 40 |
+
"""
|
| 41 |
+
|
| 42 |
+
# --- UNCHANGED ---
|
| 43 |
+
PACKAGE_INIT_PROMPT_TEMPLATE = """
|
| 44 |
+
SYSTEM: You are an expert programmer writing a high-level, one-paragraph summary for a Python package. This summary will be the main docstring for the package's `__init__.py` file.
|
| 45 |
+
|
| 46 |
+
USER:
|
| 47 |
+
Project Description:
|
| 48 |
+
\"\"\"
|
| 49 |
+
{project_description}
|
| 50 |
+
\"\"\"
|
| 51 |
+
|
| 52 |
+
You are writing the docstring for the `__init__.py` of the `{package_name}` package.
|
| 53 |
+
|
| 54 |
+
This package contains the following modules. Their summaries are provided below:
|
| 55 |
+
{module_summaries}
|
| 56 |
+
|
| 57 |
+
INSTRUCTIONS:
|
| 58 |
+
Write a concise, single-paragraph docstring that summarizes the overall purpose and responsibility of the `{package_name}` package, based on the modules it contains. This docstring will be placed in the `__init__.py` file.
|
| 59 |
+
"""
|
| 60 |
+
|
| 61 |
+
def no_op_callback(event: str, data: dict):
|
| 62 |
+
print(f"{event}: {json.dumps(data, indent=2)}")
|
| 63 |
+
|
| 64 |
+
class DocstringOrchestrator:
|
| 65 |
+
# --- UNCHANGED ---
|
| 66 |
+
def __init__(self, path_or_url: str, description: str, exclude: List[str], llm_handler: LLMHandler, progress_callback: Callable[[str, dict], None] = no_op_callback, repo_full_name: str = None):
|
| 67 |
+
self.path_or_url = path_or_url
|
| 68 |
+
self.description = description
|
| 69 |
+
self.exclude = exclude
|
| 70 |
+
self.llm_handler = llm_handler
|
| 71 |
+
self.progress_callback = progress_callback
|
| 72 |
+
self.project_path = None
|
| 73 |
+
self.is_temp_dir = path_or_url.startswith("http")
|
| 74 |
+
self.repo_full_name = repo_full_name
|
| 75 |
+
|
| 76 |
+
def llm_log_wrapper(message: str):
|
| 77 |
+
self.progress_callback("log", {"message": message})
|
| 78 |
+
|
| 79 |
+
self.llm_handler.progress_callback = llm_log_wrapper
|
| 80 |
+
|
| 81 |
+
# --- MODIFIED: The run() method is refactored ---
|
| 82 |
+
def run(self):
|
| 83 |
+
def log_to_ui(message: str):
|
| 84 |
+
self.progress_callback("log", {"message": message})
|
| 85 |
+
|
| 86 |
+
try:
|
| 87 |
+
self.project_path = scanner.get_project_path(self.path_or_url, log_callback=log_to_ui)
|
| 88 |
+
|
| 89 |
+
self.progress_callback("phase", {"id": "scan", "name": "Scanning Project", "status": "in-progress"})
|
| 90 |
+
files = scanner.scan_project(self.project_path, self.exclude)
|
| 91 |
+
log_to_ui(f"Found {len(files)} Python files to document.")
|
| 92 |
+
self.progress_callback("phase", {"id": "scan", "status": "success"})
|
| 93 |
+
|
| 94 |
+
graph = parser.build_dependency_graph(files, self.project_path, log_callback=log_to_ui)
|
| 95 |
+
|
| 96 |
+
self.progress_callback("phase", {"id": "docstrings", "name": "Generating Docstrings", "status": "in-progress"})
|
| 97 |
+
doc_order = list(nx.topological_sort(graph)) if nx.is_directed_acyclic_graph(graph) else list(graph.nodes)
|
| 98 |
+
|
| 99 |
+
# --- COMBINED PASS 1 & 2: Generate all docstrings in a single call per file ---
|
| 100 |
+
documented_context = {}
|
| 101 |
+
module_docstrings = {}
|
| 102 |
+
for file_path in doc_order:
|
| 103 |
+
rel_path = file_path.relative_to(self.project_path).as_posix()
|
| 104 |
+
# Use a single subtask for the entire file documentation process
|
| 105 |
+
self.progress_callback("subtask", {"parentId": "docstrings", "listId": "docstring-file-list", "id": f"doc-{rel_path}", "name": f"Documenting {rel_path}", "status": "in-progress"})
|
| 106 |
+
|
| 107 |
+
deps = graph.predecessors(file_path)
|
| 108 |
+
dep_context_str = "\n".join([f"File: `{dep.relative_to(self.project_path)}`\n{json.dumps(documented_context.get(dep, {}), indent=2)}\n" for dep in deps]) or "No internal dependencies have been documented yet."
|
| 109 |
+
|
| 110 |
+
with open(file_path, "r", encoding="utf-8") as f:
|
| 111 |
+
file_content = f.read()
|
| 112 |
+
|
| 113 |
+
# Use the new combined prompt
|
| 114 |
+
prompt = COMBINED_DOCSTRING_PROMPT_TEMPLATE.format(project_description=self.description, dependency_context=dep_context_str, file_path=rel_path, file_content=file_content)
|
| 115 |
+
|
| 116 |
+
try:
|
| 117 |
+
# Single LLM call to get all docstrings for the file
|
| 118 |
+
combined_docs = self.llm_handler.generate_documentation(prompt)
|
| 119 |
+
|
| 120 |
+
# Separate the module docstring from the function/class docstrings
|
| 121 |
+
module_summary = combined_docs.pop("__module__", None)
|
| 122 |
+
function_class_docs = combined_docs # The remainder of the dict
|
| 123 |
+
|
| 124 |
+
# Update the file with function/class docstrings
|
| 125 |
+
updater.update_file_with_docstrings(file_path, function_class_docs, log_callback=log_to_ui)
|
| 126 |
+
|
| 127 |
+
# Update the file with the module-level docstring if it was generated
|
| 128 |
+
if module_summary:
|
| 129 |
+
updater.update_module_docstring(file_path, module_summary, log_callback=log_to_ui)
|
| 130 |
+
module_docstrings[file_path] = module_summary
|
| 131 |
+
|
| 132 |
+
self.progress_callback("subtask", {"parentId": "docstrings", "id": f"doc-{rel_path}", "status": "success"})
|
| 133 |
+
documented_context[file_path] = function_class_docs # Store for dependency context
|
| 134 |
+
except Exception as e:
|
| 135 |
+
log_to_ui(f"Error processing docstrings for {rel_path}: {e}")
|
| 136 |
+
self.progress_callback("subtask", {"parentId": "docstrings", "id": f"doc-{rel_path}", "status": "error"})
|
| 137 |
+
|
| 138 |
+
# --- PASS 3: Generate __init__.py Docstrings for Packages (Unchanged) ---
|
| 139 |
+
packages = defaultdict(list)
|
| 140 |
+
for file_path, docstring in module_docstrings.items():
|
| 141 |
+
if file_path.name != "__init__.py":
|
| 142 |
+
packages[file_path.parent].append(f"- `{file_path.name}`: {docstring}")
|
| 143 |
+
|
| 144 |
+
for package_path, summaries in packages.items():
|
| 145 |
+
rel_path = package_path.relative_to(self.project_path).as_posix()
|
| 146 |
+
init_file = package_path / "__init__.py"
|
| 147 |
+
self.progress_callback("subtask", {"parentId": "docstrings", "listId": "docstring-package-list", "id": f"pkg-{rel_path}", "name": f"Package summary for {rel_path}", "status": "in-progress"})
|
| 148 |
+
|
| 149 |
+
try:
|
| 150 |
+
is_root_package = (package_path == self.project_path)
|
| 151 |
+
package_name = self.repo_full_name if is_root_package and self.repo_full_name else package_path.name
|
| 152 |
+
|
| 153 |
+
prompt = PACKAGE_INIT_PROMPT_TEMPLATE.format(
|
| 154 |
+
project_description=self.description,
|
| 155 |
+
package_name=package_name,
|
| 156 |
+
module_summaries="\n".join(summaries)
|
| 157 |
+
)
|
| 158 |
+
package_summary = self.llm_handler.generate_text_response(prompt).strip().strip('"""').strip("'''").strip()
|
| 159 |
+
|
| 160 |
+
if not init_file.exists():
|
| 161 |
+
init_file.touch()
|
| 162 |
+
|
| 163 |
+
updater.update_module_docstring(init_file, package_summary, log_callback=log_to_ui)
|
| 164 |
+
self.progress_callback("subtask", {"parentId": "docstrings", "id": f"pkg-{rel_path}", "status": "success"})
|
| 165 |
+
except Exception as e:
|
| 166 |
+
log_to_ui(f"Error generating package docstring for {rel_path}: {e}")
|
| 167 |
+
self.progress_callback("subtask", {"parentId": "docstrings", "id": f"pkg-{rel_path}", "status": "error"})
|
| 168 |
+
|
| 169 |
+
self.progress_callback("phase", {"id": "docstrings", "status": "success"})
|
| 170 |
+
|
| 171 |
+
finally:
|
| 172 |
+
if self.is_temp_dir and self.project_path and self.project_path.exists():
|
| 173 |
+
shutil.rmtree(self.project_path, ignore_errors=True)
|
codescribe/parser.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
import ast
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
from typing import List, Set, Callable # Add Callable
|
| 5 |
+
import networkx as nx
|
| 6 |
+
|
| 7 |
+
def resolve_import_path(current_file: Path, module_name: str, level: int, project_root: Path) -> Path | None:
|
| 8 |
+
# ... (function content remains unchanged) ...
|
| 9 |
+
if level > 0: # Relative import
|
| 10 |
+
base_path = current_file.parent
|
| 11 |
+
for _ in range(level - 1):
|
| 12 |
+
base_path = base_path.parent
|
| 13 |
+
|
| 14 |
+
module_path = base_path / Path(*module_name.split('.'))
|
| 15 |
+
else: # Absolute import
|
| 16 |
+
module_path = project_root / Path(*module_name.split('.'))
|
| 17 |
+
|
| 18 |
+
# Try to find the file (.py) or package (__init__.py)
|
| 19 |
+
if module_path.with_suffix(".py").exists():
|
| 20 |
+
return module_path.with_suffix(".py")
|
| 21 |
+
if (module_path / "__init__.py").exists():
|
| 22 |
+
return module_path / "__init__.py"
|
| 23 |
+
|
| 24 |
+
return None
|
| 25 |
+
|
| 26 |
+
def build_dependency_graph(file_paths: List[Path], project_root: Path, log_callback: Callable[[str], None] = print) -> nx.DiGraph:
|
| 27 |
+
"""
|
| 28 |
+
Builds a dependency graph from a list of Python files.
|
| 29 |
+
Uses a callback for logging warnings.
|
| 30 |
+
"""
|
| 31 |
+
graph = nx.DiGraph()
|
| 32 |
+
path_map = {p.stem: p for p in file_paths} # Simplified mapping
|
| 33 |
+
|
| 34 |
+
for file_path in file_paths:
|
| 35 |
+
graph.add_node(file_path)
|
| 36 |
+
try:
|
| 37 |
+
with open(file_path, "r", encoding="utf-8") as f:
|
| 38 |
+
content = f.read()
|
| 39 |
+
tree = ast.parse(content)
|
| 40 |
+
|
| 41 |
+
for node in ast.walk(tree):
|
| 42 |
+
if isinstance(node, ast.ImportFrom):
|
| 43 |
+
if node.module:
|
| 44 |
+
dep_path = resolve_import_path(file_path, node.module, node.level, project_root)
|
| 45 |
+
if dep_path and dep_path in file_paths:
|
| 46 |
+
graph.add_edge(file_path, dep_path)
|
| 47 |
+
|
| 48 |
+
except Exception as e:
|
| 49 |
+
# Use the callback instead of print
|
| 50 |
+
log_callback(f"Warning: Could not parse {file_path.name} for dependencies. Skipping. Error: {e}")
|
| 51 |
+
|
| 52 |
+
return graph
|
codescribe/readme_generator.py
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# codescribe/readme_generator.py
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
import shutil
|
| 5 |
+
import ast
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
from typing import List, Callable
|
| 8 |
+
|
| 9 |
+
from . import scanner
|
| 10 |
+
from .llm_handler import LLMHandler
|
| 11 |
+
|
| 12 |
+
# Prompt templates remain unchanged.
|
| 13 |
+
SUBDIR_PROMPT_TEMPLATE = """
|
| 14 |
+
SYSTEM: You are an expert technical writer creating a README.md file for a specific directory within a larger project. Your tone should be informative and concise.
|
| 15 |
+
|
| 16 |
+
USER:
|
| 17 |
+
You are generating a `README.md` for the directory: `{current_dir_relative}`
|
| 18 |
+
|
| 19 |
+
The overall project description is:
|
| 20 |
+
"{project_description}"
|
| 21 |
+
|
| 22 |
+
---
|
| 23 |
+
This directory contains the following source code files. Use them to describe the specific purpose of this directory:
|
| 24 |
+
{file_summaries}
|
| 25 |
+
---
|
| 26 |
+
|
| 27 |
+
This directory also contains the following subdirectories. Use their `README.md` content (provided below) to summarize their roles:
|
| 28 |
+
{subdirectory_readmes}
|
| 29 |
+
---
|
| 30 |
+
|
| 31 |
+
TASK:
|
| 32 |
+
Write a `README.md` for the `{current_dir_relative}` directory.
|
| 33 |
+
- Start with a heading (e.g., `# Directory: {dir_name}`).
|
| 34 |
+
- Briefly explain the purpose of this directory based on the files it contains.
|
| 35 |
+
- If there are subdirectories, provide a section summarizing what each one does, using the context from their READMEs.
|
| 36 |
+
- Use clear Markdown formatting. Do not describe the entire project; focus ONLY on the contents and role of THIS directory.
|
| 37 |
+
"""
|
| 38 |
+
|
| 39 |
+
ROOT_PROMPT_TEMPLATE = """
|
| 40 |
+
SYSTEM: You are an expert technical writer creating the main `README.md` for an entire software project. Your tone should be welcoming and comprehensive.
|
| 41 |
+
|
| 42 |
+
USER:
|
| 43 |
+
You are generating the main `README.md` for a project.
|
| 44 |
+
|
| 45 |
+
The user-provided project description is:
|
| 46 |
+
"{project_description}"
|
| 47 |
+
|
| 48 |
+
---
|
| 49 |
+
The project's root directory contains the following source code files:
|
| 50 |
+
{file_summaries}
|
| 51 |
+
---
|
| 52 |
+
|
| 53 |
+
The project has the following main subdirectories. Use their `README.md` content (provided below) to describe the overall structure of the project:
|
| 54 |
+
{subdirectory_readmes}
|
| 55 |
+
---
|
| 56 |
+
|
| 57 |
+
TASK:
|
| 58 |
+
Write a comprehensive `README.md` for the entire project. Structure it with the following sections:
|
| 59 |
+
- A main title (`# Project: {project_name}`).
|
| 60 |
+
- **Overview**: A slightly more detailed version of the user's description, enhanced with context from the files and subdirectories.
|
| 61 |
+
- **Project Structure**: A description of the key directories and their roles, using the information from the subdirectory READMEs.
|
| 62 |
+
- **Key Features**: Infer and list the key features of the project based on all the provided context.
|
| 63 |
+
"""
|
| 64 |
+
|
| 65 |
+
UPDATE_SUBDIR_PROMPT_TEMPLATE = """
|
| 66 |
+
SYSTEM: You are an expert technical writer updating a README.md file for a specific directory. Your tone should be informative and concise. A user has provided a note with instructions.
|
| 67 |
+
|
| 68 |
+
USER:
|
| 69 |
+
You are updating the `README.md` for the directory: `{current_dir_relative}`
|
| 70 |
+
|
| 71 |
+
The user-provided note with instructions for this update is:
|
| 72 |
+
"{user_note}"
|
| 73 |
+
|
| 74 |
+
The overall project description is:
|
| 75 |
+
"{project_description}"
|
| 76 |
+
---
|
| 77 |
+
This directory contains the following source code files. Use them to describe the specific purpose of this directory:
|
| 78 |
+
{file_summaries}
|
| 79 |
+
---
|
| 80 |
+
This directory also contains the following subdirectories. Use their `README.md` content (provided below) to summarize their roles:
|
| 81 |
+
{subdirectory_readmes}
|
| 82 |
+
---
|
| 83 |
+
Here is the OLD `README.md` content. You must update it based on the new context and the user's note.
|
| 84 |
+
---
|
| 85 |
+
{existing_readme}
|
| 86 |
+
---
|
| 87 |
+
TASK:
|
| 88 |
+
Rewrite the `README.md` for the `{current_dir_relative}` directory, incorporating the user's note and any new information from the files and subdirectories.
|
| 89 |
+
- Start with a heading (e.g., `# Directory: {dir_name}`).
|
| 90 |
+
- Use the existing content as a base, but modify it as needed.
|
| 91 |
+
- Use clear Markdown formatting. Do not describe the entire project; focus ONLY on the contents and role of THIS directory.
|
| 92 |
+
"""
|
| 93 |
+
|
| 94 |
+
UPDATE_ROOT_PROMPT_TEMPLATE = """
|
| 95 |
+
SYSTEM: You are an expert technical writer updating the main `README.md` for an entire software project. Your tone should be welcoming and comprehensive. A user has provided a note with instructions.
|
| 96 |
+
|
| 97 |
+
USER:
|
| 98 |
+
You are updating the main `README.md` for a project.
|
| 99 |
+
|
| 100 |
+
The user-provided note with instructions for this update is:
|
| 101 |
+
"{user_note}"
|
| 102 |
+
|
| 103 |
+
The user-provided project description is:
|
| 104 |
+
"{project_description}"
|
| 105 |
+
---
|
| 106 |
+
The project's root directory contains the following source code files:
|
| 107 |
+
{file_summaries}
|
| 108 |
+
---
|
| 109 |
+
The project has the following main subdirectories. Use their `README.md` content (provided below) to describe the overall structure of the project:
|
| 110 |
+
{subdirectory_readmes}
|
| 111 |
+
---
|
| 112 |
+
Here is the OLD `README.md` content. You must update it based on the new context and the user's note.
|
| 113 |
+
---
|
| 114 |
+
{existing_readme}
|
| 115 |
+
---
|
| 116 |
+
TASK:
|
| 117 |
+
Rewrite a comprehensive `README.md` for the entire project. Structure it with the following sections, using the old README as a base but incorporating changes based on the user's note and new context.
|
| 118 |
+
- A main title (`# Project: {project_name}`).
|
| 119 |
+
- **Overview**: An updated version of the user's description, enhanced with context.
|
| 120 |
+
- **Project Structure**: A description of the key directories and their roles.
|
| 121 |
+
- **Key Features**: Infer and list key features based on all the provided context.
|
| 122 |
+
"""
|
| 123 |
+
|
| 124 |
+
def no_op_callback(event: str, data: dict):
|
| 125 |
+
pass
|
| 126 |
+
|
| 127 |
+
class ReadmeGenerator:
|
| 128 |
+
def __init__(self, path_or_url: str, description: str, exclude: List[str], llm_handler: LLMHandler, user_note: str = "", repo_full_name="", progress_callback: Callable[[str, dict], None] = no_op_callback):
|
| 129 |
+
self.path_or_url = path_or_url
|
| 130 |
+
self.description = description
|
| 131 |
+
self.exclude = exclude + ["README.md"]
|
| 132 |
+
self.llm_handler = llm_handler
|
| 133 |
+
self.user_note = user_note
|
| 134 |
+
self.progress_callback = progress_callback
|
| 135 |
+
self.project_path = None
|
| 136 |
+
self.is_temp_dir = path_or_url.startswith("http")
|
| 137 |
+
self.repo_full_name = repo_full_name
|
| 138 |
+
|
| 139 |
+
def llm_log_wrapper(message: str):
|
| 140 |
+
self.progress_callback("log", {"message": message})
|
| 141 |
+
|
| 142 |
+
self.llm_handler.progress_callback = llm_log_wrapper
|
| 143 |
+
|
| 144 |
+
def run(self):
|
| 145 |
+
"""Public method to start README generation."""
|
| 146 |
+
try:
|
| 147 |
+
self.project_path = scanner.get_project_path(self.path_or_url)
|
| 148 |
+
self.progress_callback("phase", {"id": "readmes", "name": "Generating READMEs", "status": "in-progress"})
|
| 149 |
+
self.run_with_structured_logging()
|
| 150 |
+
self.progress_callback("phase", {"id": "readmes", "status": "success"})
|
| 151 |
+
except Exception as e:
|
| 152 |
+
self.progress_callback("log", {"message": f"An unexpected error occurred in README generation: {e}"})
|
| 153 |
+
self.progress_callback("phase", {"id": "readmes", "status": "error"})
|
| 154 |
+
raise
|
| 155 |
+
finally:
|
| 156 |
+
if self.is_temp_dir and self.project_path and self.project_path.exists():
|
| 157 |
+
shutil.rmtree(self.project_path, ignore_errors=True)
|
| 158 |
+
|
| 159 |
+
def _summarize_py_file(self, file_path: Path) -> str:
|
| 160 |
+
"""
|
| 161 |
+
Extracts the module-level docstring, or a list of function/class names
|
| 162 |
+
as a fallback, to summarize a Python file.
|
| 163 |
+
"""
|
| 164 |
+
try:
|
| 165 |
+
with open(file_path, "r", encoding="utf-8") as f:
|
| 166 |
+
content = f.read()
|
| 167 |
+
tree = ast.parse(content)
|
| 168 |
+
|
| 169 |
+
# 1. Prioritize the module-level docstring
|
| 170 |
+
docstring = ast.get_docstring(tree)
|
| 171 |
+
if docstring:
|
| 172 |
+
return f"`{file_path.name}`: {docstring.strip().splitlines()[0]}"
|
| 173 |
+
|
| 174 |
+
# 2. Fallback: Find function and class names
|
| 175 |
+
definitions = []
|
| 176 |
+
for node in tree.body:
|
| 177 |
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
|
| 178 |
+
definitions.append(node.name)
|
| 179 |
+
|
| 180 |
+
if definitions:
|
| 181 |
+
summary = f"Contains definitions for: `{', '.join(definitions)}`."
|
| 182 |
+
return f"`{file_path.name}`: {summary}"
|
| 183 |
+
|
| 184 |
+
except Exception as e:
|
| 185 |
+
self.progress_callback("log", {"message": f"Could not parse {file_path.name} for summary: {e}"})
|
| 186 |
+
|
| 187 |
+
# 3. Final fallback
|
| 188 |
+
return f"`{file_path.name}`: A Python source file."
|
| 189 |
+
|
| 190 |
+
def run_with_structured_logging(self):
|
| 191 |
+
"""
|
| 192 |
+
Generates README files for each directory from the bottom up,
|
| 193 |
+
emitting structured events for the UI.
|
| 194 |
+
"""
|
| 195 |
+
if not self.project_path:
|
| 196 |
+
self.project_path = scanner.get_project_path(self.path_or_url)
|
| 197 |
+
|
| 198 |
+
for dir_path, subdir_names, file_names in os.walk(self.project_path, topdown=False):
|
| 199 |
+
current_dir = Path(dir_path)
|
| 200 |
+
|
| 201 |
+
if scanner.is_excluded(current_dir, self.exclude, self.project_path):
|
| 202 |
+
continue
|
| 203 |
+
|
| 204 |
+
rel_path = current_dir.relative_to(self.project_path).as_posix()
|
| 205 |
+
dir_id = rel_path if rel_path != "." else "root"
|
| 206 |
+
dir_name_display = rel_path if rel_path != "." else "Project Root"
|
| 207 |
+
|
| 208 |
+
self.progress_callback("subtask", {"parentId": "readmes", "listId": "readme-dir-list", "id": dir_id, "name": f"Directory: {dir_name_display}", "status": "in-progress"})
|
| 209 |
+
|
| 210 |
+
try:
|
| 211 |
+
file_summaries = self._gather_file_summaries(current_dir, file_names)
|
| 212 |
+
subdirectory_readmes = self._gather_subdirectory_readmes(current_dir, subdir_names)
|
| 213 |
+
|
| 214 |
+
existing_readme_content = None
|
| 215 |
+
existing_readme_path = current_dir / "README.md"
|
| 216 |
+
if existing_readme_path.exists():
|
| 217 |
+
with open(existing_readme_path, "r", encoding="utf-8") as f:
|
| 218 |
+
existing_readme_content = f.read()
|
| 219 |
+
|
| 220 |
+
prompt = self._build_prompt(current_dir, file_summaries, subdirectory_readmes, existing_readme_content)
|
| 221 |
+
generated_content = self.llm_handler.generate_text_response(prompt)
|
| 222 |
+
|
| 223 |
+
with open(current_dir / "README.md", "w", encoding="utf-8") as f:
|
| 224 |
+
f.write(generated_content)
|
| 225 |
+
|
| 226 |
+
self.progress_callback("subtask", {"parentId": "readmes", "id": dir_id, "status": "success"})
|
| 227 |
+
|
| 228 |
+
except Exception as e:
|
| 229 |
+
self.progress_callback("log", {"message": f"Failed to generate README for {dir_name_display}: {e}"})
|
| 230 |
+
self.progress_callback("subtask", {"parentId": "readmes", "id": dir_id, "status": "error"})
|
| 231 |
+
|
| 232 |
+
# ... The rest of the file (_gather_file_summaries, _gather_subdirectory_readmes, _build_prompt) remains unchanged ...
|
| 233 |
+
def _gather_file_summaries(self, current_dir: Path, file_names: List[str]) -> str:
|
| 234 |
+
file_summaries_list = []
|
| 235 |
+
for fname in file_names:
|
| 236 |
+
if fname.endswith(".py"):
|
| 237 |
+
file_path = current_dir / fname
|
| 238 |
+
if not scanner.is_excluded(file_path, self.exclude, self.project_path):
|
| 239 |
+
file_summaries_list.append(self._summarize_py_file(file_path))
|
| 240 |
+
return "\n".join(file_summaries_list) or "No Python source files in this directory."
|
| 241 |
+
|
| 242 |
+
def _gather_subdirectory_readmes(self, current_dir: Path, subdir_names: List[str]) -> str:
|
| 243 |
+
subdir_readmes_list = []
|
| 244 |
+
for sub_name in subdir_names:
|
| 245 |
+
readme_path = current_dir / sub_name / "README.md"
|
| 246 |
+
if readme_path.exists():
|
| 247 |
+
with open(readme_path, "r", encoding="utf-8") as f:
|
| 248 |
+
content = f.read()
|
| 249 |
+
subdir_readmes_list.append(f"--- Subdirectory: `{sub_name}` ---\n{content}\n")
|
| 250 |
+
return "\n".join(subdir_readmes_list) or "No subdirectories with READMEs."
|
| 251 |
+
|
| 252 |
+
def _build_prompt(self, current_dir: Path, file_summaries: str, subdirectory_readmes: str, existing_readme: str | None) -> str:
|
| 253 |
+
is_root = current_dir == self.project_path
|
| 254 |
+
common_args = {
|
| 255 |
+
"project_description": self.description,
|
| 256 |
+
"file_summaries": file_summaries,
|
| 257 |
+
"subdirectory_readmes": subdirectory_readmes,
|
| 258 |
+
"user_note": self.user_note or "No specific instructions provided.",
|
| 259 |
+
}
|
| 260 |
+
|
| 261 |
+
if is_root:
|
| 262 |
+
template = UPDATE_ROOT_PROMPT_TEMPLATE if existing_readme else ROOT_PROMPT_TEMPLATE
|
| 263 |
+
args = {**common_args, "project_name": self.repo_full_name if self.repo_full_name else self.project_path.name}
|
| 264 |
+
if existing_readme: args["existing_readme"] = existing_readme
|
| 265 |
+
else: # is subdirectory
|
| 266 |
+
template = UPDATE_SUBDIR_PROMPT_TEMPLATE if existing_readme else SUBDIR_PROMPT_TEMPLATE
|
| 267 |
+
args = {**common_args, "current_dir_relative": current_dir.relative_to(self.project_path).as_posix(), "dir_name": current_dir.name}
|
| 268 |
+
if existing_readme: args["existing_readme"] = existing_readme
|
| 269 |
+
|
| 270 |
+
return template.format(**args)
|
codescribe/scanner.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
import os
|
| 3 |
+
import re
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
from typing import List, Callable # Add Callable
|
| 6 |
+
import git
|
| 7 |
+
import tempfile
|
| 8 |
+
import shutil
|
| 9 |
+
|
| 10 |
+
# ... is_excluded function is unchanged ...
|
| 11 |
+
def is_excluded(path: Path, exclude_patterns: List[str], project_root: Path) -> bool:
|
| 12 |
+
relative_path_str = path.relative_to(project_root).as_posix()
|
| 13 |
+
if any(part.startswith('.') for part in path.relative_to(project_root).parts):
|
| 14 |
+
return True
|
| 15 |
+
for pattern in exclude_patterns:
|
| 16 |
+
try:
|
| 17 |
+
if re.search(pattern, relative_path_str):
|
| 18 |
+
return True
|
| 19 |
+
except re.error:
|
| 20 |
+
if pattern in relative_path_str:
|
| 21 |
+
return True
|
| 22 |
+
return False
|
| 23 |
+
|
| 24 |
+
# ... scan_project function is unchanged ...
|
| 25 |
+
def scan_project(project_path: Path, exclude_patterns: List[str]) -> List[Path]:
|
| 26 |
+
py_files = []
|
| 27 |
+
for root, dirs, files in os.walk(project_path, topdown=True):
|
| 28 |
+
root_path = Path(root)
|
| 29 |
+
original_dirs = dirs[:]
|
| 30 |
+
dirs[:] = [d for d in original_dirs if not is_excluded(root_path / d, exclude_patterns, project_path)]
|
| 31 |
+
for file in files:
|
| 32 |
+
if file.endswith('.py'):
|
| 33 |
+
file_path = root_path / file
|
| 34 |
+
if not is_excluded(file_path, exclude_patterns, project_path):
|
| 35 |
+
py_files.append(file_path)
|
| 36 |
+
return py_files
|
| 37 |
+
|
| 38 |
+
def get_project_path(path_or_url: str, log_callback: Callable[[str], None] = print) -> Path:
|
| 39 |
+
"""
|
| 40 |
+
Clones a repo if a URL is given, otherwise returns the Path object.
|
| 41 |
+
Uses a callback for logging.
|
| 42 |
+
"""
|
| 43 |
+
if path_or_url.startswith("http") or path_or_url.startswith("git@"):
|
| 44 |
+
temp_dir = tempfile.mkdtemp()
|
| 45 |
+
# Use the callback instead of print
|
| 46 |
+
log_callback(f"Cloning repository {path_or_url} into temporary directory...")
|
| 47 |
+
try:
|
| 48 |
+
git.Repo.clone_from(path_or_url, temp_dir)
|
| 49 |
+
return Path(temp_dir)
|
| 50 |
+
except Exception as e:
|
| 51 |
+
shutil.rmtree(temp_dir)
|
| 52 |
+
raise RuntimeError(f"Failed to clone repository: {e}")
|
| 53 |
+
else:
|
| 54 |
+
path = Path(path_or_url)
|
| 55 |
+
if not path.is_dir():
|
| 56 |
+
raise FileNotFoundError(f"The specified path does not exist or is not a directory: {path}")
|
| 57 |
+
return path
|
codescribe/updater.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# codescribe/updater.py
|
| 2 |
+
|
| 3 |
+
import ast
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
from typing import Dict, Callable
|
| 6 |
+
|
| 7 |
+
# Helper no-op function for default callback
|
| 8 |
+
def _no_op_log(message: str):
|
| 9 |
+
pass
|
| 10 |
+
|
| 11 |
+
class DocstringInserter(ast.NodeTransformer):
|
| 12 |
+
def __init__(self, docstrings: Dict[str, str]):
|
| 13 |
+
self.docstrings = docstrings
|
| 14 |
+
self.current_class = None
|
| 15 |
+
|
| 16 |
+
def visit_ClassDef(self, node: ast.ClassDef) -> ast.ClassDef:
|
| 17 |
+
self.current_class = node.name
|
| 18 |
+
if node.name in self.docstrings:
|
| 19 |
+
self._insert_docstring(node, self.docstrings[node.name])
|
| 20 |
+
self.generic_visit(node)
|
| 21 |
+
self.current_class = None
|
| 22 |
+
return node
|
| 23 |
+
|
| 24 |
+
def visit_FunctionDef(self, node: ast.FunctionDef) -> ast.FunctionDef:
|
| 25 |
+
key = f"{self.current_class}.{node.name}" if self.current_class else node.name
|
| 26 |
+
if key in self.docstrings:
|
| 27 |
+
self._insert_docstring(node, self.docstrings[key])
|
| 28 |
+
return node
|
| 29 |
+
|
| 30 |
+
def _insert_docstring(self, node, docstring_text):
|
| 31 |
+
docstring_node = ast.Expr(value=ast.Constant(value=docstring_text))
|
| 32 |
+
if ast.get_docstring(node):
|
| 33 |
+
node.body[0] = docstring_node
|
| 34 |
+
else:
|
| 35 |
+
node.body.insert(0, docstring_node)
|
| 36 |
+
|
| 37 |
+
def update_file_with_docstrings(file_path: Path, docstrings: Dict[str, str], log_callback: Callable[[str], None] = print):
|
| 38 |
+
"""
|
| 39 |
+
Parses a Python file, inserts docstrings for functions/classes, and overwrites the file.
|
| 40 |
+
"""
|
| 41 |
+
try:
|
| 42 |
+
with open(file_path, "r", encoding="utf-8") as f:
|
| 43 |
+
source_code = f.read()
|
| 44 |
+
|
| 45 |
+
tree = ast.parse(source_code)
|
| 46 |
+
transformer = DocstringInserter(docstrings)
|
| 47 |
+
new_tree = transformer.visit(tree)
|
| 48 |
+
ast.fix_missing_locations(new_tree)
|
| 49 |
+
|
| 50 |
+
new_source_code = ast.unparse(new_tree)
|
| 51 |
+
|
| 52 |
+
with open(file_path, "w", encoding="utf-8") as f:
|
| 53 |
+
f.write(new_source_code)
|
| 54 |
+
log_callback(f"Successfully updated {file_path.name} with new docstrings.")
|
| 55 |
+
|
| 56 |
+
except Exception as e:
|
| 57 |
+
log_callback(f"Error updating file {file_path.name}: {e}")
|
| 58 |
+
|
| 59 |
+
def update_module_docstring(file_path: Path, docstring: str, log_callback: Callable[[str], None] = print):
|
| 60 |
+
"""
|
| 61 |
+
Parses a Python file, adds or replaces the module-level docstring, and overwrites the file.
|
| 62 |
+
"""
|
| 63 |
+
try:
|
| 64 |
+
with open(file_path, "r", encoding="utf-8") as f:
|
| 65 |
+
source_code = f.read()
|
| 66 |
+
|
| 67 |
+
tree = ast.parse(source_code)
|
| 68 |
+
|
| 69 |
+
# Create the new docstring node
|
| 70 |
+
new_docstring_node = ast.Expr(value=ast.Constant(value=docstring))
|
| 71 |
+
|
| 72 |
+
# Check if a module docstring already exists
|
| 73 |
+
if ast.get_docstring(tree):
|
| 74 |
+
# Replace the existing docstring node
|
| 75 |
+
tree.body[0] = new_docstring_node
|
| 76 |
+
else:
|
| 77 |
+
# Insert the new docstring node at the beginning
|
| 78 |
+
tree.body.insert(0, new_docstring_node)
|
| 79 |
+
|
| 80 |
+
ast.fix_missing_locations(tree)
|
| 81 |
+
new_source_code = ast.unparse(tree)
|
| 82 |
+
|
| 83 |
+
with open(file_path, "w", encoding="utf-8") as f:
|
| 84 |
+
f.write(new_source_code)
|
| 85 |
+
log_callback(f"Successfully added/updated module docstring for {file_path.name}.")
|
| 86 |
+
|
| 87 |
+
except Exception as e:
|
| 88 |
+
log_callback(f"Error updating module docstring for {file_path.name}: {e}")
|
requirements.txt
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Existing
|
| 2 |
+
click
|
| 3 |
+
python-dotenv
|
| 4 |
+
google-generativeai
|
| 5 |
+
groq
|
| 6 |
+
networkx
|
| 7 |
+
GitPython
|
| 8 |
+
|
| 9 |
+
# New for Web Server
|
| 10 |
+
fastapi
|
| 11 |
+
uvicorn[standard]
|
| 12 |
+
python-multipart
|
| 13 |
+
aiohttp
|
| 14 |
+
sse-starlette
|
| 15 |
+
PyGithub
|
| 16 |
+
requests
|
run.py
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# run.py
|
| 2 |
+
import uvicorn
|
| 3 |
+
|
| 4 |
+
if __name__ == "__main__":
|
| 5 |
+
uvicorn.run("server.main:app", host="0.0.0.0", port=8000, reload=True)
|
server/__init__.py
ADDED
|
File without changes
|
server/main.py
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
import os
|
| 3 |
+
import tempfile
|
| 4 |
+
import zipfile
|
| 5 |
+
import shutil
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
import requests
|
| 8 |
+
|
| 9 |
+
from fastapi import FastAPI, UploadFile, File, Form, Request, HTTPException
|
| 10 |
+
from fastapi.responses import FileResponse, RedirectResponse, StreamingResponse
|
| 11 |
+
from fastapi.staticfiles import StaticFiles
|
| 12 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 13 |
+
|
| 14 |
+
from typing import List
|
| 15 |
+
from git import Repo
|
| 16 |
+
|
| 17 |
+
from github import Github, GithubException # Github is already imported
|
| 18 |
+
|
| 19 |
+
from .tasks import process_project
|
| 20 |
+
|
| 21 |
+
# Load environment variables
|
| 22 |
+
from dotenv import load_dotenv
|
| 23 |
+
load_dotenv()
|
| 24 |
+
|
| 25 |
+
GITHUB_CLIENT_ID = os.getenv("GITHUB_CLIENT_ID")
|
| 26 |
+
GITHUB_CLIENT_SECRET = os.getenv("GITHUB_CLIENT_SECRET")
|
| 27 |
+
|
| 28 |
+
app = FastAPI()
|
| 29 |
+
|
| 30 |
+
origins = [
|
| 31 |
+
"http://localhost",
|
| 32 |
+
"http://localhost:8000",
|
| 33 |
+
"http://127.0.0.1",
|
| 34 |
+
"http://12_7.0.0.1:8000",
|
| 35 |
+
]
|
| 36 |
+
|
| 37 |
+
app.add_middleware(
|
| 38 |
+
CORSMiddleware,
|
| 39 |
+
allow_origins=origins,
|
| 40 |
+
allow_credentials=True,
|
| 41 |
+
allow_methods=["*"],
|
| 42 |
+
allow_headers=["*"],
|
| 43 |
+
)
|
| 44 |
+
|
| 45 |
+
app.mount("/static", StaticFiles(directory="static"), name="static")
|
| 46 |
+
|
| 47 |
+
@app.get("/")
|
| 48 |
+
async def read_root():
|
| 49 |
+
return FileResponse('static/index.html')
|
| 50 |
+
|
| 51 |
+
@app.get("/login/github")
|
| 52 |
+
async def login_github():
|
| 53 |
+
return RedirectResponse(
|
| 54 |
+
f"https://github.com/login/oauth/authorize?client_id={GITHUB_CLIENT_ID}&scope=repo",
|
| 55 |
+
status_code=302
|
| 56 |
+
)
|
| 57 |
+
|
| 58 |
+
@app.get("/auth/github/callback")
|
| 59 |
+
async def auth_github_callback(code: str, request: Request):
|
| 60 |
+
params = {
|
| 61 |
+
"client_id": GITHUB_CLIENT_ID,
|
| 62 |
+
"client_secret": GITHUB_CLIENT_SECRET,
|
| 63 |
+
"code": code,
|
| 64 |
+
}
|
| 65 |
+
headers = {"Accept": "application/json"}
|
| 66 |
+
base_url = str(request.base_url)
|
| 67 |
+
try:
|
| 68 |
+
response = requests.post("https://github.com/login/oauth/access_token", params=params, headers=headers)
|
| 69 |
+
response.raise_for_status()
|
| 70 |
+
response_json = response.json()
|
| 71 |
+
if "error" in response_json:
|
| 72 |
+
error_description = response_json.get("error_description", "Unknown error.")
|
| 73 |
+
return RedirectResponse(f"{base_url}?error={error_description}")
|
| 74 |
+
token = response_json.get("access_token")
|
| 75 |
+
if not token:
|
| 76 |
+
return RedirectResponse(f"{base_url}?error=Authentication failed, no token received.")
|
| 77 |
+
return RedirectResponse(f"{base_url}?token={token}")
|
| 78 |
+
except requests.exceptions.RequestException as e:
|
| 79 |
+
return RedirectResponse(f"{base_url}?error=Failed to connect to GitHub: {e}")
|
| 80 |
+
|
| 81 |
+
@app.get("/api/github/repos")
|
| 82 |
+
async def get_github_repos(request: Request):
|
| 83 |
+
auth_header = request.headers.get("Authorization")
|
| 84 |
+
if not auth_header or not auth_header.startswith("Bearer "):
|
| 85 |
+
raise HTTPException(status_code=401, detail="Unauthorized")
|
| 86 |
+
token = auth_header.split(" ")[1]
|
| 87 |
+
try:
|
| 88 |
+
g = Github(token)
|
| 89 |
+
user = g.get_user()
|
| 90 |
+
repos = [{"full_name": repo.full_name, "default_branch": repo.default_branch} for repo in user.get_repos(type='owner')]
|
| 91 |
+
return repos
|
| 92 |
+
except Exception as e:
|
| 93 |
+
raise HTTPException(status_code=400, detail=f"Failed to fetch repos: {e}")
|
| 94 |
+
|
| 95 |
+
@app.get("/api/github/branches")
|
| 96 |
+
async def get_github_repo_branches(request: Request, repo_full_name: str):
|
| 97 |
+
auth_header = request.headers.get("Authorization")
|
| 98 |
+
if not auth_header or not auth_header.startswith("Bearer "):
|
| 99 |
+
raise HTTPException(status_code=401, detail="Unauthorized")
|
| 100 |
+
token = auth_header.split(" ")[1]
|
| 101 |
+
try:
|
| 102 |
+
g = Github(token)
|
| 103 |
+
repo = g.get_repo(repo_full_name)
|
| 104 |
+
branches = [branch.name for branch in repo.get_branches()]
|
| 105 |
+
return branches
|
| 106 |
+
except GithubException as e:
|
| 107 |
+
raise HTTPException(status_code=e.status, detail=f"GitHub API error: {e.data.get('message', 'Could not fetch branches.')}")
|
| 108 |
+
except Exception as e:
|
| 109 |
+
raise HTTPException(status_code=500, detail=f"An unexpected error occurred while fetching branches: {e}")
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
@app.get("/api/github/tree")
|
| 113 |
+
async def get_github_repo_tree(request: Request, repo_full_name: str, branch: str):
|
| 114 |
+
auth_header = request.headers.get("Authorization")
|
| 115 |
+
if not auth_header or not auth_header.startswith("Bearer "):
|
| 116 |
+
raise HTTPException(status_code=401, detail="Unauthorized")
|
| 117 |
+
token = auth_header.split(" ")[1]
|
| 118 |
+
temp_dir = tempfile.mkdtemp(prefix="codescribe-tree-")
|
| 119 |
+
try:
|
| 120 |
+
repo_url = f"https://x-access-token:{token}@github.com/{repo_full_name}.git"
|
| 121 |
+
Repo.clone_from(repo_url, temp_dir, branch=branch, depth=1)
|
| 122 |
+
repo_path = Path(temp_dir)
|
| 123 |
+
tree = []
|
| 124 |
+
for root, dirs, files in os.walk(repo_path):
|
| 125 |
+
if '.git' in dirs:
|
| 126 |
+
dirs.remove('.git')
|
| 127 |
+
current_level = tree
|
| 128 |
+
rel_path = Path(root).relative_to(repo_path)
|
| 129 |
+
if str(rel_path) != ".":
|
| 130 |
+
for part in rel_path.parts:
|
| 131 |
+
parent = next((item for item in current_level if item['name'] == part), None)
|
| 132 |
+
if not parent: break
|
| 133 |
+
current_level = parent.get('children', [])
|
| 134 |
+
for d in sorted(dirs):
|
| 135 |
+
current_level.append({'name': d, 'children': []})
|
| 136 |
+
for f in sorted(files):
|
| 137 |
+
current_level.append({'name': f})
|
| 138 |
+
return tree
|
| 139 |
+
except Exception as e:
|
| 140 |
+
raise HTTPException(status_code=500, detail=f"Failed to clone or process repo tree: {e}")
|
| 141 |
+
finally:
|
| 142 |
+
shutil.rmtree(temp_dir, ignore_errors=True)
|
| 143 |
+
|
| 144 |
+
@app.get("/api/github/branch-exists")
|
| 145 |
+
async def check_branch_exists(request: Request, repo_full_name: str, branch_name: str):
|
| 146 |
+
auth_header = request.headers.get("Authorization")
|
| 147 |
+
if not auth_header or not auth_header.startswith("Bearer "):
|
| 148 |
+
raise HTTPException(status_code=401, detail="Unauthorized")
|
| 149 |
+
token = auth_header.split(" ")[1]
|
| 150 |
+
|
| 151 |
+
try:
|
| 152 |
+
g = Github(token)
|
| 153 |
+
repo = g.get_repo(repo_full_name)
|
| 154 |
+
# The get_branch method throws a 404 GithubException if not found
|
| 155 |
+
repo.get_branch(branch=branch_name)
|
| 156 |
+
return {"exists": True}
|
| 157 |
+
except GithubException as e:
|
| 158 |
+
if e.status == 404:
|
| 159 |
+
return {"exists": False}
|
| 160 |
+
# Re-raise for other errors like permissions, repo not found, etc.
|
| 161 |
+
raise HTTPException(status_code=e.status, detail=f"GitHub API error: {e.data.get('message', 'Unknown error')}")
|
| 162 |
+
except Exception as e:
|
| 163 |
+
raise HTTPException(status_code=500, detail=f"An unexpected error occurred: {e}")
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
@app.post("/process-zip")
|
| 167 |
+
async def process_zip_endpoint(
|
| 168 |
+
description: str = Form(...),
|
| 169 |
+
readme_note: str = Form(""),
|
| 170 |
+
zip_file: UploadFile = File(...),
|
| 171 |
+
exclude_patterns: str = Form("")
|
| 172 |
+
):
|
| 173 |
+
exclude_list = [p.strip() for p in exclude_patterns.splitlines() if p.strip()]
|
| 174 |
+
temp_dir = tempfile.mkdtemp(prefix="codescribe-zip-")
|
| 175 |
+
project_path = Path(temp_dir)
|
| 176 |
+
zip_location = project_path / zip_file.filename
|
| 177 |
+
|
| 178 |
+
with open(zip_location, "wb+") as f:
|
| 179 |
+
shutil.copyfileobj(zip_file.file, f)
|
| 180 |
+
with zipfile.ZipFile(zip_location, 'r') as zip_ref:
|
| 181 |
+
zip_ref.extractall(project_path)
|
| 182 |
+
os.remove(zip_location)
|
| 183 |
+
|
| 184 |
+
stream_headers = {
|
| 185 |
+
"Content-Type": "text/plain",
|
| 186 |
+
"Cache-Control": "no-cache",
|
| 187 |
+
"Connection": "keep-alive",
|
| 188 |
+
"X-Accel-Buffering": "no",
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
# Create a placeholder repo name from the zip filename for the orchestrator
|
| 192 |
+
placeholder_repo_name = f"zip-upload/{Path(zip_file.filename).stem}"
|
| 193 |
+
|
| 194 |
+
return StreamingResponse(
|
| 195 |
+
process_project(
|
| 196 |
+
project_path=project_path,
|
| 197 |
+
description=description,
|
| 198 |
+
readme_note=readme_note,
|
| 199 |
+
is_temp=True,
|
| 200 |
+
exclude_list=exclude_list,
|
| 201 |
+
repo_full_name=placeholder_repo_name,
|
| 202 |
+
),
|
| 203 |
+
headers=stream_headers,
|
| 204 |
+
media_type="text/plain"
|
| 205 |
+
)
|
| 206 |
+
|
| 207 |
+
@app.post("/process-github")
|
| 208 |
+
async def process_github_endpoint(request: Request,
|
| 209 |
+
repo_full_name: str = Form(...),
|
| 210 |
+
base_branch: str = Form(...),
|
| 211 |
+
new_branch_name: str = Form(...),
|
| 212 |
+
description: str = Form(...),
|
| 213 |
+
readme_note: str = Form(""),
|
| 214 |
+
exclude_patterns: str = Form(""),
|
| 215 |
+
exclude_paths: List[str] = Form([])
|
| 216 |
+
):
|
| 217 |
+
auth_header = request.headers.get("Authorization")
|
| 218 |
+
if not auth_header or not auth_header.startswith("Bearer "):
|
| 219 |
+
raise HTTPException(status_code=401, detail="Unauthorized")
|
| 220 |
+
token = auth_header.split(" ")[1]
|
| 221 |
+
|
| 222 |
+
# --- Server-side Branch Existence Check (as a fallback) ---
|
| 223 |
+
try:
|
| 224 |
+
g = Github(token)
|
| 225 |
+
repo = g.get_repo(repo_full_name)
|
| 226 |
+
existing_branches = [b.name for b in repo.get_branches()]
|
| 227 |
+
if new_branch_name in existing_branches:
|
| 228 |
+
raise HTTPException(
|
| 229 |
+
status_code=409, # 409 Conflict is appropriate here
|
| 230 |
+
detail=f"Branch '{new_branch_name}' already exists. Please use a different name."
|
| 231 |
+
)
|
| 232 |
+
except GithubException as e:
|
| 233 |
+
raise HTTPException(status_code=404, detail=f"Repository '{repo_full_name}' not found or token lacks permissions: {e}")
|
| 234 |
+
except Exception as e:
|
| 235 |
+
raise HTTPException(status_code=500, detail=f"An error occurred while checking branches: {e}")
|
| 236 |
+
# --- END of check ---
|
| 237 |
+
|
| 238 |
+
regex_list = [p.strip() for p in exclude_patterns.splitlines() if p.strip()]
|
| 239 |
+
exclude_list = regex_list + exclude_paths
|
| 240 |
+
|
| 241 |
+
temp_dir = tempfile.mkdtemp(prefix="codescribe-git-")
|
| 242 |
+
project_path = Path(temp_dir)
|
| 243 |
+
repo_url = f"https://x-access-token:{token}@github.com/{repo_full_name}.git"
|
| 244 |
+
|
| 245 |
+
Repo.clone_from(repo_url, project_path, branch=base_branch)
|
| 246 |
+
|
| 247 |
+
stream_headers = {
|
| 248 |
+
"Content-Type": "text/plain",
|
| 249 |
+
"Cache-Control": "no-cache",
|
| 250 |
+
"Connection": "keep-alive",
|
| 251 |
+
"X-Accel-Buffering": "no",
|
| 252 |
+
}
|
| 253 |
+
|
| 254 |
+
return StreamingResponse(
|
| 255 |
+
process_project(
|
| 256 |
+
project_path=project_path,
|
| 257 |
+
description=description,
|
| 258 |
+
readme_note=readme_note,
|
| 259 |
+
is_temp=True,
|
| 260 |
+
new_branch_name=new_branch_name,
|
| 261 |
+
repo_full_name=repo_full_name,
|
| 262 |
+
github_token=token,
|
| 263 |
+
exclude_list=exclude_list,
|
| 264 |
+
),
|
| 265 |
+
headers=stream_headers,
|
| 266 |
+
media_type="text/plain"
|
| 267 |
+
)
|
| 268 |
+
|
| 269 |
+
@app.get("/download/{file_path}")
|
| 270 |
+
async def download_file(file_path: str):
|
| 271 |
+
temp_dir = tempfile.gettempdir()
|
| 272 |
+
full_path = Path(temp_dir) / file_path
|
| 273 |
+
if not full_path.exists():
|
| 274 |
+
raise HTTPException(status_code=404, detail="File not found or expired.")
|
| 275 |
+
return FileResponse(path=full_path, filename=file_path, media_type='application/zip')
|
server/tasks.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# server/tasks.py
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
import shutil
|
| 5 |
+
import tempfile
|
| 6 |
+
import zipfile
|
| 7 |
+
import json
|
| 8 |
+
import asyncio
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
from typing import AsyncGenerator, List
|
| 11 |
+
|
| 12 |
+
from git import Repo, GitCommandError
|
| 13 |
+
|
| 14 |
+
from codescribe.config import load_config
|
| 15 |
+
from codescribe.llm_handler import LLMHandler
|
| 16 |
+
from codescribe.orchestrator import DocstringOrchestrator
|
| 17 |
+
from codescribe.readme_generator import ReadmeGenerator
|
| 18 |
+
|
| 19 |
+
async def process_project(
|
| 20 |
+
project_path: Path,
|
| 21 |
+
description: str,
|
| 22 |
+
readme_note: str,
|
| 23 |
+
is_temp: bool,
|
| 24 |
+
exclude_list: List[str],
|
| 25 |
+
new_branch_name: str = None,
|
| 26 |
+
repo_full_name: str = None,
|
| 27 |
+
github_token: str = None
|
| 28 |
+
) -> AsyncGenerator[str, None]:
|
| 29 |
+
|
| 30 |
+
loop = asyncio.get_running_loop()
|
| 31 |
+
queue = asyncio.Queue()
|
| 32 |
+
|
| 33 |
+
def emit_event(event: str, data: dict):
|
| 34 |
+
"""A thread-safe way to send events from the worker thread to the async generator."""
|
| 35 |
+
loop.call_soon_threadsafe(queue.put_nowait, {"event": event, "data": data})
|
| 36 |
+
|
| 37 |
+
def _blocking_process():
|
| 38 |
+
"""The main, synchronous processing logic that runs in a separate thread."""
|
| 39 |
+
try:
|
| 40 |
+
# --- Setup ---
|
| 41 |
+
config = load_config()
|
| 42 |
+
llm_handler = LLMHandler(config.api_keys, progress_callback=lambda msg: emit_event("log", {"message": msg}))
|
| 43 |
+
|
| 44 |
+
# --- 1. Docstrings Phase (using the orchestrator) ---
|
| 45 |
+
doc_orchestrator = DocstringOrchestrator(
|
| 46 |
+
path_or_url=str(project_path),
|
| 47 |
+
description=description,
|
| 48 |
+
exclude=exclude_list,
|
| 49 |
+
llm_handler=llm_handler,
|
| 50 |
+
progress_callback=emit_event,
|
| 51 |
+
repo_full_name=repo_full_name # <<< THIS IS THE FIX
|
| 52 |
+
)
|
| 53 |
+
doc_orchestrator.project_path = project_path
|
| 54 |
+
doc_orchestrator.is_temp_dir = False
|
| 55 |
+
doc_orchestrator.run()
|
| 56 |
+
|
| 57 |
+
# --- 2. READMEs Phase (using the generator) ---
|
| 58 |
+
readme_gen = ReadmeGenerator(
|
| 59 |
+
path_or_url=str(project_path),
|
| 60 |
+
description=description,
|
| 61 |
+
exclude=exclude_list,
|
| 62 |
+
llm_handler=llm_handler,
|
| 63 |
+
user_note=readme_note,
|
| 64 |
+
progress_callback=emit_event,
|
| 65 |
+
repo_full_name=repo_full_name
|
| 66 |
+
)
|
| 67 |
+
readme_gen.project_path = project_path
|
| 68 |
+
readme_gen.is_temp_dir = False
|
| 69 |
+
readme_gen.run()
|
| 70 |
+
|
| 71 |
+
# --- 3. Output Phase ---
|
| 72 |
+
emit_event("phase", {"id": "output", "status": "in-progress"})
|
| 73 |
+
if new_branch_name:
|
| 74 |
+
# Git logic remains the same
|
| 75 |
+
emit_event("subtask", {"parentId": "output", "listId": "output-step-list", "id": "git-check", "name": "Checking for changes...", "status": "in-progress"})
|
| 76 |
+
repo = Repo(project_path)
|
| 77 |
+
|
| 78 |
+
if repo.untracked_files:
|
| 79 |
+
repo.git.add(repo.untracked_files)
|
| 80 |
+
|
| 81 |
+
if not repo.is_dirty(untracked_files=True):
|
| 82 |
+
emit_event("subtask", {"parentId": "output", "id": "git-check", "status": "success"})
|
| 83 |
+
emit_event("log", {"message": "No changes were generated by the AI."})
|
| 84 |
+
emit_event("done", {"type": "github", "url": f"https://github.com/{repo_full_name}", "message": "✅ No changes needed. Your repository is up to date."})
|
| 85 |
+
return
|
| 86 |
+
|
| 87 |
+
emit_event("subtask", {"parentId": "output", "id": "git-check", "status": "success"})
|
| 88 |
+
emit_event("subtask", {"parentId": "output", "listId": "output-step-list", "id": "git-push", "name": f"Pushing to branch '{new_branch_name}'...", "status": "in-progress"})
|
| 89 |
+
|
| 90 |
+
try:
|
| 91 |
+
if new_branch_name in repo.heads:
|
| 92 |
+
repo.heads[new_branch_name].checkout()
|
| 93 |
+
else:
|
| 94 |
+
repo.create_head(new_branch_name).checkout()
|
| 95 |
+
|
| 96 |
+
repo.git.add(A=True)
|
| 97 |
+
repo.index.commit("docs: Add AI-generated documentation by CodeScribe")
|
| 98 |
+
|
| 99 |
+
origin = repo.remote(name='origin')
|
| 100 |
+
origin.push(new_branch_name, force=True)
|
| 101 |
+
|
| 102 |
+
pr_url = f"https://github.com/{repo_full_name}/pull/new/{new_branch_name}"
|
| 103 |
+
emit_event("subtask", {"parentId": "output", "id": "git-push", "status": "success"})
|
| 104 |
+
emit_event("done", {"type": "github", "url": pr_url, "message": "✅ Successfully pushed changes!"})
|
| 105 |
+
|
| 106 |
+
except GitCommandError as e:
|
| 107 |
+
emit_event("log", {"message": f"Git Error: {e}"})
|
| 108 |
+
raise RuntimeError(f"Failed to push to GitHub: {e}")
|
| 109 |
+
else:
|
| 110 |
+
# ZIP logic remains the same
|
| 111 |
+
emit_event("subtask", {"parentId": "output", "listId": "output-step-list", "id": "zip-create", "name": "Creating downloadable ZIP file...", "status": "in-progress"})
|
| 112 |
+
|
| 113 |
+
temp_dir = tempfile.gettempdir()
|
| 114 |
+
try:
|
| 115 |
+
contained_dir = next(project_path.iterdir())
|
| 116 |
+
project_name = contained_dir.name
|
| 117 |
+
except StopIteration:
|
| 118 |
+
project_name = "documented-project"
|
| 119 |
+
|
| 120 |
+
zip_filename_base = f"codescribe-docs-{project_name}"
|
| 121 |
+
zip_path_base = Path(temp_dir) / zip_filename_base
|
| 122 |
+
|
| 123 |
+
zip_full_path = shutil.make_archive(str(zip_path_base), 'zip', project_path)
|
| 124 |
+
zip_file_name = Path(zip_full_path).name
|
| 125 |
+
|
| 126 |
+
emit_event("subtask", {"parentId": "output", "id": "zip-create", "status": "success"})
|
| 127 |
+
emit_event("done", {"type": "zip", "download_path": zip_file_name, "message": "✅ Your documented project is ready for download."})
|
| 128 |
+
|
| 129 |
+
emit_event("phase", {"id": "output", "status": "success"})
|
| 130 |
+
|
| 131 |
+
except Exception as e:
|
| 132 |
+
emit_event("error", str(e))
|
| 133 |
+
finally:
|
| 134 |
+
loop.call_soon_threadsafe(queue.put_nowait, None)
|
| 135 |
+
|
| 136 |
+
# Async runner remains the same
|
| 137 |
+
main_task = loop.run_in_executor(None, _blocking_process)
|
| 138 |
+
while True:
|
| 139 |
+
message = await queue.get()
|
| 140 |
+
if message is None:
|
| 141 |
+
break
|
| 142 |
+
json_line = json.dumps({"type": message["event"], "payload": message["data"]})
|
| 143 |
+
yield f"{json_line}\n"
|
| 144 |
+
await main_task
|
| 145 |
+
if is_temp and project_path and project_path.exists():
|
| 146 |
+
shutil.rmtree(project_path, ignore_errors=True)
|
static/index.html
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
| 6 |
+
<title>CodeScribe AI</title>
|
| 7 |
+
<link rel="stylesheet" href="/static/style.css"/>
|
| 8 |
+
<meta name="color-scheme" content="dark light"/>
|
| 9 |
+
</head>
|
| 10 |
+
<body>
|
| 11 |
+
<!-- Decorative Background -->
|
| 12 |
+
<div class="bg">
|
| 13 |
+
<div class="bg-gradient"></div>
|
| 14 |
+
<div class="bg-grid"></div>
|
| 15 |
+
<canvas id="bg-canvas" aria-hidden="true"></canvas>
|
| 16 |
+
</div>
|
| 17 |
+
|
| 18 |
+
<main class="shell">
|
| 19 |
+
<div class="container glass-card" data-animate>
|
| 20 |
+
<header class="app-header">
|
| 21 |
+
<div class="brand">
|
| 22 |
+
<div class="brand-mark" aria-hidden="true">
|
| 23 |
+
<span class="dot dot-1"></span>
|
| 24 |
+
<span class="dot dot-2"></span>
|
| 25 |
+
<span class="dot dot-3"></span>
|
| 26 |
+
</div>
|
| 27 |
+
<h1 class="title text-gradient">CodeScribe AI</h1>
|
| 28 |
+
</div>
|
| 29 |
+
<p class="subtitle">AI-powered project documentation, right in your browser.</p>
|
| 30 |
+
</header>
|
| 31 |
+
|
| 32 |
+
<!-- Auth -->
|
| 33 |
+
<section id="auth-section" class="panel" data-animate>
|
| 34 |
+
<button id="github-login-btn" class="btn btn-primary btn-glow" aria-label="Login with GitHub">
|
| 35 |
+
<span class="btn-inner">
|
| 36 |
+
<svg class="icon" width="20" height="20" viewBox="0 0 16 16" aria-hidden="true"><path fill="currentColor" d="M8 .198a8 8 0 0 0-2.53 15.6c.4.073.547-.173.547-.385v-1.35c-2.226.483-2.695-1.073-2.695-1.073c-.364-.925-.89-1.172-.89-1.172c-.727-.497.055-.487.055-.487c.803.056 1.225.825 1.225.825c.715 1.224 1.874.87 2.33.665c.073-.518.28-.871.508-1.072C4.9 9.83 3 9.232 3 6.58c0-.87.31-1.58.824-2.136c-.083-.203-.357-1.02.078-2.127c0 0 .673-.215 2.206.817c.64-.18 1.325-.27 2.006-.273c.68.003 1.365.093 2.006.273c1.532-1.032 2.204-.817 2.204-.817c.437 1.107.163 1.924.08 2.127C12.69 5 13 5.71 13 6.58c0 2.66-1.902 3.248-3.714 3.419c.287.247.543.736.543 1.484v2.2c0 .214.145.462.55.384A8 8 0 0 0 8 .198"/></svg>
|
| 37 |
+
<span>Login with GitHub</span>
|
| 38 |
+
</span>
|
| 39 |
+
</button>
|
| 40 |
+
</section>
|
| 41 |
+
|
| 42 |
+
<!-- Main Content -->
|
| 43 |
+
<section id="main-content" class="hidden" data-animate>
|
| 44 |
+
<!-- Tabs -->
|
| 45 |
+
<div class="tabs">
|
| 46 |
+
<button id="select-zip-btn" class="tab-btn active" type="button">
|
| 47 |
+
<span>Upload Zip</span>
|
| 48 |
+
</button>
|
| 49 |
+
<button id="select-github-btn" class="tab-btn" type="button">
|
| 50 |
+
<span>Use GitHub Repo</span>
|
| 51 |
+
</button>
|
| 52 |
+
<span class="tab-indicator" aria-hidden="true"></span>
|
| 53 |
+
</div>
|
| 54 |
+
|
| 55 |
+
<!-- Form -->
|
| 56 |
+
<form id="doc-form" class="form">
|
| 57 |
+
GitHub Inputs
|
| 58 |
+
<div id="github-inputs" class="panel">
|
| 59 |
+
<div class="form-row">
|
| 60 |
+
<div class="form-group">
|
| 61 |
+
<label for="repo-select">Select a Repository</label>
|
| 62 |
+
<select id="repo-select" name="repo_full_name" required></select>
|
| 63 |
+
</div>
|
| 64 |
+
<div class="form-group">
|
| 65 |
+
<label for="base-branch-select">Base Branch</label>
|
| 66 |
+
<select id="base-branch-select" name="base_branch" required></select>
|
| 67 |
+
</div>
|
| 68 |
+
</div>
|
| 69 |
+
<div class="form-group">
|
| 70 |
+
<label for="new-branch-input">New Branch Name</label>
|
| 71 |
+
<input id="new-branch-input" type="text" name="new_branch_name" value="docs/codescribe-ai" required/>
|
| 72 |
+
<small id="branch-name-error" class="form-error" aria-live="polite" style="display:none;"></small>
|
| 73 |
+
</div>
|
| 74 |
+
</div>
|
| 75 |
+
|
| 76 |
+
<!-- ZIP Inputs -->
|
| 77 |
+
<div id="zip-inputs" class="panel hidden">
|
| 78 |
+
<div class="form-group">
|
| 79 |
+
<label for="zip-file">Upload Project as .zip</label>
|
| 80 |
+
<input id="zip-file" type="file" name="zip_file" accept=".zip" required/>
|
| 81 |
+
</div>
|
| 82 |
+
</div>
|
| 83 |
+
|
| 84 |
+
<div class="divider"></div>
|
| 85 |
+
|
| 86 |
+
<!-- Common Inputs -->
|
| 87 |
+
<div class="panel">
|
| 88 |
+
<div class="form-group">
|
| 89 |
+
<label for="description">Short Project Description</label>
|
| 90 |
+
<textarea id="description" name="description" rows="3" required placeholder="e.g., A Python library for advanced data analysis and visualization."></textarea>
|
| 91 |
+
</div>
|
| 92 |
+
<div class="form-group">
|
| 93 |
+
<label for="readme-note">Note for README (Optional)</label>
|
| 94 |
+
<textarea id="readme-note" name="readme_note" rows="2" placeholder="e.g., Emphasize the new data processing pipeline. Mention the v2.0 release."></textarea>
|
| 95 |
+
</div>
|
| 96 |
+
</div>
|
| 97 |
+
|
| 98 |
+
<div class="divider"></div>
|
| 99 |
+
|
| 100 |
+
<div class="panel">
|
| 101 |
+
<div class="form-group">
|
| 102 |
+
<label for="exclude-patterns">Exclude (Regex Patterns)</label>
|
| 103 |
+
<textarea id="exclude-patterns" name="exclude_patterns" rows="2" placeholder="e.g., tests/.*, .*/migrations/.*, specific_file.py"></textarea>
|
| 104 |
+
<small>One regex pattern per line. Matches relative paths like 'src/utils/helpers.py'.</small>
|
| 105 |
+
</div>
|
| 106 |
+
|
| 107 |
+
<div id="file-tree-container" class="form-group hidden">
|
| 108 |
+
<label>Exclude Specific Files/Folders</label>
|
| 109 |
+
<div id="file-tree" class="tree"></div>
|
| 110 |
+
</div>
|
| 111 |
+
</div>
|
| 112 |
+
|
| 113 |
+
<div class="form-actions">
|
| 114 |
+
<button id="submit-btn" type="submit" class="btn btn-primary btn-glow">
|
| 115 |
+
<span class="btn-inner">
|
| 116 |
+
<span class="loader" aria-hidden="true"></span>
|
| 117 |
+
<span>Generate Documentation</span>
|
| 118 |
+
</span>
|
| 119 |
+
</button>
|
| 120 |
+
</div>
|
| 121 |
+
</form>
|
| 122 |
+
</section>
|
| 123 |
+
|
| 124 |
+
<!-- Live Progress -->
|
| 125 |
+
<section id="live-progress-view" class="hidden" data-animate>
|
| 126 |
+
<h2 class="section-title">Processing Your Project...</h2>
|
| 127 |
+
<div class="progress-layout">
|
| 128 |
+
<ul id="phase-list" class="timeline">
|
| 129 |
+
<li id="phase-scan" class="phase-item" data-status="pending">
|
| 130 |
+
<span class="status-icon" aria-hidden="true"></span>
|
| 131 |
+
<div class="phase-details">
|
| 132 |
+
<span class="phase-title">1. Scan Project Files</span>
|
| 133 |
+
<ul class="subtask-list" id="scan-file-list"></ul>
|
| 134 |
+
</div>
|
| 135 |
+
</li>
|
| 136 |
+
<li id="phase-docstrings" class="phase-item" data-status="pending">
|
| 137 |
+
<span class="status-icon" aria-hidden="true"></span>
|
| 138 |
+
<div class="phase-details">
|
| 139 |
+
<span class="phase-title">2. Generate Docstrings</span>
|
| 140 |
+
<ul class="subtask-list" id="docstring-file-list"></ul>
|
| 141 |
+
<ul class="subtask-list" id="docstring-module-list"></ul>
|
| 142 |
+
<ul class="subtask-list" id="docstring-package-list"></ul>
|
| 143 |
+
</div>
|
| 144 |
+
</li>
|
| 145 |
+
<li id="phase-readmes" class="phase-item" data-status="pending">
|
| 146 |
+
<span class="status-icon" aria-hidden="true"></span>
|
| 147 |
+
<div class="phase-details">
|
| 148 |
+
<span class="phase-title">3. Generate READMEs</span>
|
| 149 |
+
<ul class="subtask-list" id="readme-dir-list"></ul>
|
| 150 |
+
</div>
|
| 151 |
+
</li>
|
| 152 |
+
<li id="phase-output" class="phase-item" data-status="pending">
|
| 153 |
+
<span class="status-icon" aria-hidden="true"></span>
|
| 154 |
+
<div class="phase-details">
|
| 155 |
+
<span class="phase-title">4. Finalize and Push</span>
|
| 156 |
+
<ul class="subtask-list" id="output-step-list"></ul>
|
| 157 |
+
</div>
|
| 158 |
+
</li>
|
| 159 |
+
</ul>
|
| 160 |
+
|
| 161 |
+
<div id="raw-log-container" class="terminal">
|
| 162 |
+
<div class="terminal-head">
|
| 163 |
+
<span class="dot red" aria-hidden="true"></span>
|
| 164 |
+
<span class="dot yellow" aria-hidden="true"></span>
|
| 165 |
+
<span class="dot green" aria-hidden="true"></span>
|
| 166 |
+
<h4>Live Log Details</h4>
|
| 167 |
+
</div>
|
| 168 |
+
<pre id="log-output" class="terminal-body" aria-live="polite"></pre>
|
| 169 |
+
</div>
|
| 170 |
+
</div>
|
| 171 |
+
</section>
|
| 172 |
+
|
| 173 |
+
<!-- Result -->
|
| 174 |
+
<section id="result-section" class="hidden" data-animate>
|
| 175 |
+
<h2 class="section-title success">Complete!</h2>
|
| 176 |
+
<div id="result-link" class="result-card"></div>
|
| 177 |
+
</section>
|
| 178 |
+
</div>
|
| 179 |
+
</main>
|
| 180 |
+
|
| 181 |
+
<script src="/static/script.js" defer></script>
|
| 182 |
+
</body>
|
| 183 |
+
</html>
|
static/script.js
ADDED
|
@@ -0,0 +1,541 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
document.addEventListener("DOMContentLoaded", () => {
|
| 2 |
+
// --- Constants ---
|
| 3 |
+
const GITHUB_TOKEN_KEY = "github_access_token";
|
| 4 |
+
|
| 5 |
+
// --- DOM Element Cache ---
|
| 6 |
+
const docForm = document.getElementById("doc-form");
|
| 7 |
+
const submitBtn = document.getElementById("submit-btn");
|
| 8 |
+
const authSection = document.getElementById("auth-section");
|
| 9 |
+
const mainContent = document.getElementById("main-content");
|
| 10 |
+
const githubLoginBtn = document.getElementById("github-login-btn");
|
| 11 |
+
const selectZipBtn = document.getElementById("select-zip-btn");
|
| 12 |
+
const selectGithubBtn = document.getElementById("select-github-btn");
|
| 13 |
+
const zipInputs = document.getElementById("zip-inputs");
|
| 14 |
+
const githubInputs = document.getElementById("github-inputs");
|
| 15 |
+
const zipFileInput = document.getElementById("zip-file");
|
| 16 |
+
const repoSelect = document.getElementById("repo-select");
|
| 17 |
+
const baseBranchSelect = document.getElementById("base-branch-select");
|
| 18 |
+
const newBranchInput = document.getElementById("new-branch-input");
|
| 19 |
+
const branchNameError = document.getElementById("branch-name-error");
|
| 20 |
+
const fileTreeContainer = document.getElementById("file-tree-container");
|
| 21 |
+
const fileTree = document.getElementById("file-tree");
|
| 22 |
+
const liveProgressView = document.getElementById("live-progress-view");
|
| 23 |
+
const resultSection = document.getElementById("result-section");
|
| 24 |
+
const resultLink = document.getElementById("result-link");
|
| 25 |
+
const logOutput = document.getElementById("log-output");
|
| 26 |
+
|
| 27 |
+
// --- Helper Functions ---
|
| 28 |
+
const showView = (viewId) => {
|
| 29 |
+
[authSection, mainContent, liveProgressView, resultSection].forEach((el) =>
|
| 30 |
+
el.classList.add("hidden")
|
| 31 |
+
);
|
| 32 |
+
document.getElementById(viewId).classList.remove("hidden");
|
| 33 |
+
};
|
| 34 |
+
const sanitizeForId = (str) =>
|
| 35 |
+
`subtask-${str.replace(/[^a-zA-Z0-9-]/g, "-")}`;
|
| 36 |
+
const resetProgressView = () => {
|
| 37 |
+
document
|
| 38 |
+
.querySelectorAll(".phase-item")
|
| 39 |
+
.forEach((item) => (item.dataset.status = "pending"));
|
| 40 |
+
document
|
| 41 |
+
.querySelectorAll(".subtask-list")
|
| 42 |
+
.forEach((list) => (list.innerHTML = ""));
|
| 43 |
+
logOutput.textContent = "";
|
| 44 |
+
};
|
| 45 |
+
const createTreeHtml = (nodes, pathPrefix = "") => {
|
| 46 |
+
let html = "<ul>";
|
| 47 |
+
nodes.forEach((node) => {
|
| 48 |
+
const fullPath = pathPrefix ? `${pathPrefix}/${node.name}` : node.name;
|
| 49 |
+
const isDir = !!node.children;
|
| 50 |
+
html += `<li><input type="checkbox" name="exclude_paths" value="${fullPath}" id="cb-${fullPath}"> <label for="cb-${fullPath}"><strong>${
|
| 51 |
+
node.name
|
| 52 |
+
}${isDir ? "/" : ""}</strong></label>`;
|
| 53 |
+
if (isDir) html += createTreeHtml(node.children, fullPath);
|
| 54 |
+
html += "</li>";
|
| 55 |
+
});
|
| 56 |
+
html += "</ul>";
|
| 57 |
+
return html;
|
| 58 |
+
};
|
| 59 |
+
|
| 60 |
+
// --- Core Logic (unchanged app behavior) ---
|
| 61 |
+
const checkBranchName = async () => {
|
| 62 |
+
const repoFullName = repoSelect.value;
|
| 63 |
+
const branchName = newBranchInput.value.trim();
|
| 64 |
+
const token = localStorage.getItem(GITHUB_TOKEN_KEY);
|
| 65 |
+
|
| 66 |
+
branchNameError.textContent = "";
|
| 67 |
+
branchNameError.style.display = "none";
|
| 68 |
+
|
| 69 |
+
if (
|
| 70 |
+
!repoFullName ||
|
| 71 |
+
!branchName ||
|
| 72 |
+
!token ||
|
| 73 |
+
!selectGithubBtn.classList.contains("active")
|
| 74 |
+
) {
|
| 75 |
+
submitBtn.disabled = false;
|
| 76 |
+
return;
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
submitBtn.disabled = true;
|
| 80 |
+
submitBtn
|
| 81 |
+
.querySelector(".btn-inner span:last-child")
|
| 82 |
+
?.replaceWith(document.createTextNode("Checking branch..."));
|
| 83 |
+
|
| 84 |
+
try {
|
| 85 |
+
const response = await fetch(
|
| 86 |
+
`/api/github/branch-exists?repo_full_name=${encodeURIComponent(
|
| 87 |
+
repoFullName
|
| 88 |
+
)}&branch_name=${encodeURIComponent(branchName)}`,
|
| 89 |
+
{
|
| 90 |
+
headers: { Authorization: `Bearer ${token}` },
|
| 91 |
+
}
|
| 92 |
+
);
|
| 93 |
+
|
| 94 |
+
if (!response.ok) {
|
| 95 |
+
const errData = await response.json();
|
| 96 |
+
throw new Error(errData.detail || `Server error: ${response.status}`);
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
const data = await response.json();
|
| 100 |
+
if (data.exists) {
|
| 101 |
+
branchNameError.textContent = `Branch '${branchName}' already exists. Please choose another name.`;
|
| 102 |
+
branchNameError.style.display = "block";
|
| 103 |
+
submitBtn.disabled = true;
|
| 104 |
+
} else {
|
| 105 |
+
submitBtn.disabled = false;
|
| 106 |
+
}
|
| 107 |
+
} catch (error) {
|
| 108 |
+
console.error("Error checking branch name:", error);
|
| 109 |
+
branchNameError.textContent = `Could not verify branch name. ${error.message}`;
|
| 110 |
+
branchNameError.style.display = "block";
|
| 111 |
+
submitBtn.disabled = false; // Allow submission, server will catch it if it's a real issue.
|
| 112 |
+
} finally {
|
| 113 |
+
// Restore button text
|
| 114 |
+
const inner = submitBtn.querySelector(".btn-inner");
|
| 115 |
+
if (inner) {
|
| 116 |
+
inner.innerHTML =
|
| 117 |
+
'<span class="loader" aria-hidden="true"></span><span>Generate Documentation</span>';
|
| 118 |
+
}
|
| 119 |
+
}
|
| 120 |
+
};
|
| 121 |
+
|
| 122 |
+
const handleAuth = () => {
|
| 123 |
+
const urlParams = new URLSearchParams(window.location.search);
|
| 124 |
+
const token = urlParams.get("token");
|
| 125 |
+
const error = urlParams.get("error");
|
| 126 |
+
|
| 127 |
+
if (error) alert(`Authentication failed: ${error}`);
|
| 128 |
+
if (token && token !== "None")
|
| 129 |
+
localStorage.setItem(GITHUB_TOKEN_KEY, token);
|
| 130 |
+
window.history.replaceState({}, document.title, "/");
|
| 131 |
+
|
| 132 |
+
if (localStorage.getItem(GITHUB_TOKEN_KEY)) {
|
| 133 |
+
// --- Logged In User Flow ---
|
| 134 |
+
showView("main-content");
|
| 135 |
+
fetchGithubRepos();
|
| 136 |
+
switchMode("github");
|
| 137 |
+
} else {
|
| 138 |
+
// --- Logged Out User Flow (FIXED) ---
|
| 139 |
+
// 1. Always show the main form content, not the separate auth screen.
|
| 140 |
+
// This makes the form, including ZIP upload, always accessible.
|
| 141 |
+
showView("main-content");
|
| 142 |
+
|
| 143 |
+
// 2. The auth section with the login button should be visible.
|
| 144 |
+
// We manually un-hide it because showView is designed to show only one view.
|
| 145 |
+
authSection.classList.remove("hidden");
|
| 146 |
+
|
| 147 |
+
// 3. Default to ZIP mode and disable the GitHub tab.
|
| 148 |
+
switchMode("zip");
|
| 149 |
+
selectGithubBtn.disabled = true;
|
| 150 |
+
}
|
| 151 |
+
};
|
| 152 |
+
|
| 153 |
+
const fetchGithubRepos = async () => {
|
| 154 |
+
const token = localStorage.getItem(GITHUB_TOKEN_KEY);
|
| 155 |
+
if (!token) return;
|
| 156 |
+
|
| 157 |
+
try {
|
| 158 |
+
const response = await fetch("/api/github/repos", {
|
| 159 |
+
headers: { Authorization: `Bearer ${token}` },
|
| 160 |
+
});
|
| 161 |
+
if (response.status === 401) {
|
| 162 |
+
localStorage.removeItem(GITHUB_TOKEN_KEY);
|
| 163 |
+
alert("GitHub session expired. Please log in again.");
|
| 164 |
+
handleAuth();
|
| 165 |
+
return;
|
| 166 |
+
}
|
| 167 |
+
if (!response.ok) throw new Error("Failed to fetch repos");
|
| 168 |
+
|
| 169 |
+
const repos = await response.json();
|
| 170 |
+
repoSelect.innerHTML =
|
| 171 |
+
'<option value="">-- Select a repository --</option>';
|
| 172 |
+
repos.forEach((repo) => {
|
| 173 |
+
const option = document.createElement("option");
|
| 174 |
+
option.value = repo.full_name;
|
| 175 |
+
option.textContent = repo.full_name;
|
| 176 |
+
option.dataset.defaultBranch = repo.default_branch;
|
| 177 |
+
repoSelect.appendChild(option);
|
| 178 |
+
});
|
| 179 |
+
} catch (error) {
|
| 180 |
+
console.error(error);
|
| 181 |
+
}
|
| 182 |
+
};
|
| 183 |
+
|
| 184 |
+
const fetchRepoBranches = async (repoFullName, defaultBranch) => {
|
| 185 |
+
const token = localStorage.getItem(GITHUB_TOKEN_KEY);
|
| 186 |
+
if (!token || !repoFullName) return;
|
| 187 |
+
|
| 188 |
+
baseBranchSelect.innerHTML = "<option>Loading branches...</option>";
|
| 189 |
+
baseBranchSelect.disabled = true;
|
| 190 |
+
|
| 191 |
+
try {
|
| 192 |
+
const response = await fetch(
|
| 193 |
+
`/api/github/branches?repo_full_name=${repoFullName}`,
|
| 194 |
+
{
|
| 195 |
+
headers: { Authorization: `Bearer ${token}` },
|
| 196 |
+
}
|
| 197 |
+
);
|
| 198 |
+
if (!response.ok) throw new Error("Failed to fetch branches");
|
| 199 |
+
const branches = await response.json();
|
| 200 |
+
|
| 201 |
+
baseBranchSelect.innerHTML = "";
|
| 202 |
+
branches.forEach((branchName) => {
|
| 203 |
+
const option = document.createElement("option");
|
| 204 |
+
option.value = branchName;
|
| 205 |
+
option.textContent = branchName;
|
| 206 |
+
if (branchName === defaultBranch) option.selected = true;
|
| 207 |
+
baseBranchSelect.appendChild(option);
|
| 208 |
+
});
|
| 209 |
+
} catch (error) {
|
| 210 |
+
console.error(error);
|
| 211 |
+
baseBranchSelect.innerHTML = `<option>Error loading branches</option>`;
|
| 212 |
+
} finally {
|
| 213 |
+
baseBranchSelect.disabled = false;
|
| 214 |
+
}
|
| 215 |
+
};
|
| 216 |
+
|
| 217 |
+
const fetchAndBuildTree = async (repoFullName, branch) => {
|
| 218 |
+
fileTreeContainer.classList.remove("hidden");
|
| 219 |
+
fileTree.innerHTML = "<em>Loading repository file tree...</em>";
|
| 220 |
+
const token = localStorage.getItem(GITHUB_TOKEN_KEY);
|
| 221 |
+
if (!token || !repoFullName || !branch) return;
|
| 222 |
+
|
| 223 |
+
try {
|
| 224 |
+
const response = await fetch(
|
| 225 |
+
`/api/github/tree?repo_full_name=${repoFullName}&branch=${branch}`,
|
| 226 |
+
{
|
| 227 |
+
headers: { Authorization: `Bearer ${token}` },
|
| 228 |
+
}
|
| 229 |
+
);
|
| 230 |
+
if (!response.ok)
|
| 231 |
+
throw new Error(
|
| 232 |
+
`Failed to fetch file tree (status: ${response.status})`
|
| 233 |
+
);
|
| 234 |
+
const treeData = await response.json();
|
| 235 |
+
fileTree.innerHTML = createTreeHtml(treeData);
|
| 236 |
+
} catch (error) {
|
| 237 |
+
console.error(error);
|
| 238 |
+
fileTree.innerHTML = `<em style="color: #ef4444;">${error.message}</em>`;
|
| 239 |
+
}
|
| 240 |
+
};
|
| 241 |
+
|
| 242 |
+
// --- In script.js ---
|
| 243 |
+
|
| 244 |
+
const switchMode = (mode) => {
|
| 245 |
+
fileTreeContainer.classList.add("hidden");
|
| 246 |
+
if (mode === "github") {
|
| 247 |
+
selectGithubBtn.classList.add("active");
|
| 248 |
+
selectZipBtn.classList.remove("active");
|
| 249 |
+
githubInputs.classList.remove("hidden");
|
| 250 |
+
zipInputs.classList.add("hidden");
|
| 251 |
+
// GitHub Mode: ZIP is not required, GitHub fields are.
|
| 252 |
+
zipFileInput.required = false;
|
| 253 |
+
repoSelect.required = true;
|
| 254 |
+
baseBranchSelect.required = true;
|
| 255 |
+
newBranchInput.required = true;
|
| 256 |
+
if (repoSelect.value)
|
| 257 |
+
fetchAndBuildTree(repoSelect.value, baseBranchSelect.value);
|
| 258 |
+
} else { // This is ZIP mode
|
| 259 |
+
selectZipBtn.classList.add("active");
|
| 260 |
+
selectGithubBtn.classList.remove("active");
|
| 261 |
+
zipInputs.classList.remove("hidden");
|
| 262 |
+
githubInputs.classList.add("hidden");
|
| 263 |
+
// ZIP Mode: ZIP is required, GitHub fields are not.
|
| 264 |
+
zipFileInput.required = true;
|
| 265 |
+
repoSelect.required = false;
|
| 266 |
+
baseBranchSelect.required = false; // <-- FIX: Added this line
|
| 267 |
+
newBranchInput.required = false; // <-- FIX: Added this line
|
| 268 |
+
}
|
| 269 |
+
};
|
| 270 |
+
|
| 271 |
+
const handleFormSubmit = (e) => {
|
| 272 |
+
e.preventDefault();
|
| 273 |
+
resetProgressView();
|
| 274 |
+
showView("live-progress-view");
|
| 275 |
+
submitBtn.disabled = true;
|
| 276 |
+
|
| 277 |
+
const formData = new FormData(docForm);
|
| 278 |
+
const endpoint = selectGithubBtn.classList.contains("active")
|
| 279 |
+
? "/process-github"
|
| 280 |
+
: "/process-zip";
|
| 281 |
+
|
| 282 |
+
const headers = new Headers();
|
| 283 |
+
if (endpoint === "/process-github") {
|
| 284 |
+
headers.append(
|
| 285 |
+
"Authorization",
|
| 286 |
+
`Bearer ${localStorage.getItem(GITHUB_TOKEN_KEY)}`
|
| 287 |
+
);
|
| 288 |
+
}
|
| 289 |
+
|
| 290 |
+
const options = { method: "POST", body: formData, headers: headers };
|
| 291 |
+
|
| 292 |
+
fetch(endpoint, options)
|
| 293 |
+
.then((response) => {
|
| 294 |
+
if (!response.ok) {
|
| 295 |
+
return response.json().then((errData) => {
|
| 296 |
+
throw new Error(
|
| 297 |
+
`Server error: ${response.status} - ${
|
| 298 |
+
errData.detail || "Unknown error"
|
| 299 |
+
}`
|
| 300 |
+
);
|
| 301 |
+
});
|
| 302 |
+
}
|
| 303 |
+
const reader = response.body.getReader();
|
| 304 |
+
const decoder = new TextDecoder();
|
| 305 |
+
let buffer = "";
|
| 306 |
+
|
| 307 |
+
function push() {
|
| 308 |
+
reader
|
| 309 |
+
.read()
|
| 310 |
+
.then(({ done, value }) => {
|
| 311 |
+
if (done) {
|
| 312 |
+
if (buffer) {
|
| 313 |
+
try {
|
| 314 |
+
const json = JSON.parse(buffer);
|
| 315 |
+
handleStreamEvent(json.type, json.payload);
|
| 316 |
+
} catch (e) {
|
| 317 |
+
console.error(
|
| 318 |
+
"Error parsing final buffer chunk:",
|
| 319 |
+
buffer,
|
| 320 |
+
e
|
| 321 |
+
);
|
| 322 |
+
}
|
| 323 |
+
}
|
| 324 |
+
return;
|
| 325 |
+
}
|
| 326 |
+
buffer += decoder.decode(value, { stream: true });
|
| 327 |
+
const lines = buffer.split("\n");
|
| 328 |
+
buffer = lines.pop();
|
| 329 |
+
for (const line of lines) {
|
| 330 |
+
if (line.trim() === "") continue;
|
| 331 |
+
try {
|
| 332 |
+
const json = JSON.parse(line);
|
| 333 |
+
handleStreamEvent(json.type, json.payload);
|
| 334 |
+
} catch (e) {
|
| 335 |
+
console.error("Failed to parse JSON line:", line, e);
|
| 336 |
+
}
|
| 337 |
+
}
|
| 338 |
+
push();
|
| 339 |
+
})
|
| 340 |
+
.catch((err) => {
|
| 341 |
+
console.error("Stream reading error:", err);
|
| 342 |
+
handleStreamEvent("error", `Stream error: ${err.message}`);
|
| 343 |
+
});
|
| 344 |
+
}
|
| 345 |
+
push();
|
| 346 |
+
})
|
| 347 |
+
.catch((err) => handleStreamEvent("error", `${err.message}`));
|
| 348 |
+
};
|
| 349 |
+
|
| 350 |
+
const handleStreamEvent = (type, payload) => {
|
| 351 |
+
switch (type) {
|
| 352 |
+
case "phase": {
|
| 353 |
+
const phaseEl = document.getElementById(`phase-${payload.id}`);
|
| 354 |
+
if (phaseEl) phaseEl.dataset.status = payload.status;
|
| 355 |
+
break;
|
| 356 |
+
}
|
| 357 |
+
case "subtask": {
|
| 358 |
+
const subtaskId = sanitizeForId(payload.id);
|
| 359 |
+
let subtaskEl = document.getElementById(subtaskId);
|
| 360 |
+
const listEl = document.getElementById(payload.listId);
|
| 361 |
+
if (!subtaskEl && listEl) {
|
| 362 |
+
subtaskEl = document.createElement("li");
|
| 363 |
+
subtaskEl.id = subtaskId;
|
| 364 |
+
subtaskEl.textContent = payload.name;
|
| 365 |
+
listEl.appendChild(subtaskEl);
|
| 366 |
+
}
|
| 367 |
+
if (subtaskEl) subtaskEl.dataset.status = payload.status;
|
| 368 |
+
break;
|
| 369 |
+
}
|
| 370 |
+
case "log": {
|
| 371 |
+
logOutput.textContent += payload.message.replace(/\\n/g, "\n") + "\n";
|
| 372 |
+
logOutput.scrollTop = logOutput.scrollHeight;
|
| 373 |
+
break;
|
| 374 |
+
}
|
| 375 |
+
case "error": {
|
| 376 |
+
document
|
| 377 |
+
.querySelectorAll('.phase-item[data-status="in-progress"]')
|
| 378 |
+
.forEach((el) => (el.dataset.status = "error"));
|
| 379 |
+
logOutput.textContent += `\n\n--- ERROR ---\n${payload}\n`;
|
| 380 |
+
submitBtn.disabled = false;
|
| 381 |
+
break;
|
| 382 |
+
}
|
| 383 |
+
case "done": {
|
| 384 |
+
showView("result-section");
|
| 385 |
+
resultLink.innerHTML = `<p>${payload.message}</p>`;
|
| 386 |
+
if (payload.type === "zip") {
|
| 387 |
+
resultLink.innerHTML += `<a href="/download/${payload.download_path}" class="button-link" download>Download ZIP</a>`;
|
| 388 |
+
} else if (payload.url) {
|
| 389 |
+
const linkText = payload.url.includes("/pull/new/")
|
| 390 |
+
? "Create Pull Request"
|
| 391 |
+
: "View Repository";
|
| 392 |
+
resultLink.innerHTML += `<a href="${payload.url}" target="_blank" rel="noopener noreferrer" class="button-link">${linkText}</a>`;
|
| 393 |
+
}
|
| 394 |
+
submitBtn.disabled = false;
|
| 395 |
+
break;
|
| 396 |
+
}
|
| 397 |
+
}
|
| 398 |
+
};
|
| 399 |
+
|
| 400 |
+
// --- Events ---
|
| 401 |
+
githubLoginBtn.addEventListener(
|
| 402 |
+
"click",
|
| 403 |
+
() => (window.location.href = "/login/github")
|
| 404 |
+
);
|
| 405 |
+
selectZipBtn.addEventListener("click", () => switchMode("zip"));
|
| 406 |
+
selectGithubBtn.addEventListener("click", () => switchMode("github"));
|
| 407 |
+
docForm.addEventListener("submit", handleFormSubmit);
|
| 408 |
+
|
| 409 |
+
repoSelect.addEventListener("change", async (e) => {
|
| 410 |
+
const selectedOption = e.target.options[e.target.selectedIndex];
|
| 411 |
+
const repoFullName = selectedOption.value;
|
| 412 |
+
const defaultBranch = selectedOption.dataset.defaultBranch || "";
|
| 413 |
+
|
| 414 |
+
if (repoFullName) {
|
| 415 |
+
await fetchRepoBranches(repoFullName, defaultBranch);
|
| 416 |
+
fetchAndBuildTree(repoFullName, baseBranchSelect.value);
|
| 417 |
+
checkBranchName();
|
| 418 |
+
} else {
|
| 419 |
+
baseBranchSelect.innerHTML = "";
|
| 420 |
+
fileTreeContainer.classList.add("hidden");
|
| 421 |
+
}
|
| 422 |
+
});
|
| 423 |
+
|
| 424 |
+
baseBranchSelect.addEventListener("change", () => {
|
| 425 |
+
fetchAndBuildTree(repoSelect.value, baseBranchSelect.value);
|
| 426 |
+
});
|
| 427 |
+
|
| 428 |
+
newBranchInput.addEventListener("blur", checkBranchName);
|
| 429 |
+
|
| 430 |
+
// Initialize
|
| 431 |
+
handleAuth();
|
| 432 |
+
|
| 433 |
+
// --- UI Enhancements: glow cursor on buttons ---
|
| 434 |
+
document.querySelectorAll(".btn-glow").forEach((btn) => {
|
| 435 |
+
btn.addEventListener("pointermove", (e) => {
|
| 436 |
+
const rect = btn.getBoundingClientRect();
|
| 437 |
+
const x = ((e.clientX - rect.left) / rect.width) * 100;
|
| 438 |
+
const y = ((e.clientY - rect.top) / rect.height) * 100;
|
| 439 |
+
btn.style.setProperty("--x", x + "%");
|
| 440 |
+
btn.style.setProperty("--y", y + "%");
|
| 441 |
+
});
|
| 442 |
+
});
|
| 443 |
+
|
| 444 |
+
// --- Ripple effect for buttons and links ---
|
| 445 |
+
function addRipple(e) {
|
| 446 |
+
const el = e.currentTarget;
|
| 447 |
+
const rect = el.getBoundingClientRect();
|
| 448 |
+
const circle = document.createElement("span");
|
| 449 |
+
circle.className = "ripple";
|
| 450 |
+
circle.style.left = `${e.clientX - rect.left}px`;
|
| 451 |
+
circle.style.top = `${e.clientY - rect.top}px`;
|
| 452 |
+
el.appendChild(circle);
|
| 453 |
+
setTimeout(() => circle.remove(), 600);
|
| 454 |
+
}
|
| 455 |
+
document.querySelectorAll("button.btn, .button-link").forEach((el) => {
|
| 456 |
+
el.addEventListener("click", addRipple);
|
| 457 |
+
});
|
| 458 |
+
|
| 459 |
+
// --- Subtle parallax tilt on hover for panels and phase items ---
|
| 460 |
+
const tiltEls = document.querySelectorAll(
|
| 461 |
+
".panel, .phase-item, .terminal, .glass-card"
|
| 462 |
+
);
|
| 463 |
+
tiltEls.forEach((el) => {
|
| 464 |
+
let enter = false;
|
| 465 |
+
el.addEventListener("pointerenter", () => {
|
| 466 |
+
enter = true;
|
| 467 |
+
});
|
| 468 |
+
el.addEventListener("pointerleave", () => {
|
| 469 |
+
enter = false;
|
| 470 |
+
el.style.transform = "";
|
| 471 |
+
});
|
| 472 |
+
el.addEventListener("pointermove", (e) => {
|
| 473 |
+
if (!enter) return;
|
| 474 |
+
const rect = el.getBoundingClientRect();
|
| 475 |
+
const cx = rect.left + rect.width / 2;
|
| 476 |
+
const cy = rect.top + rect.height / 2;
|
| 477 |
+
const dx = (e.clientX - cx) / rect.width;
|
| 478 |
+
const dy = (e.clientY - cy) / rect.height;
|
| 479 |
+
const max = 6;
|
| 480 |
+
el.style.transform = `rotateX(${(-dy * max).toFixed(2)}deg) rotateY(${(
|
| 481 |
+
dx * max
|
| 482 |
+
).toFixed(2)}deg) translateZ(0)`;
|
| 483 |
+
});
|
| 484 |
+
});
|
| 485 |
+
|
| 486 |
+
// --- Optional: lightweight animated background canvas (respects reduced motion) ---
|
| 487 |
+
const prefersReduced = window.matchMedia(
|
| 488 |
+
"(prefers-reduced-motion: reduce)"
|
| 489 |
+
).matches;
|
| 490 |
+
const canvas = document.getElementById("bg-canvas");
|
| 491 |
+
if (canvas && !prefersReduced) {
|
| 492 |
+
const ctx = canvas.getContext("2d", { alpha: true });
|
| 493 |
+
let w, h, dots;
|
| 494 |
+
function resize() {
|
| 495 |
+
w = canvas.width = window.innerWidth;
|
| 496 |
+
h = canvas.height = window.innerHeight;
|
| 497 |
+
dots = Array.from(
|
| 498 |
+
{ length: Math.min(90, Math.floor((w * h) / 60000)) },
|
| 499 |
+
() => ({
|
| 500 |
+
x: Math.random() * w,
|
| 501 |
+
y: Math.random() * h,
|
| 502 |
+
vx: (Math.random() - 0.5) * 0.4,
|
| 503 |
+
vy: (Math.random() - 0.5) * 0.4,
|
| 504 |
+
})
|
| 505 |
+
);
|
| 506 |
+
}
|
| 507 |
+
function step() {
|
| 508 |
+
ctx.clearRect(0, 0, w, h);
|
| 509 |
+
ctx.fillStyle = "rgba(34, 211, 238, 0.6)";
|
| 510 |
+
const threshold = 120;
|
| 511 |
+
for (let i = 0; i < dots.length; i++) {
|
| 512 |
+
const a = dots[i];
|
| 513 |
+
a.x += a.vx;
|
| 514 |
+
a.y += a.vy;
|
| 515 |
+
if (a.x < 0 || a.x > w) a.vx *= -1;
|
| 516 |
+
if (a.y < 0 || a.y > h) a.vy *= -1;
|
| 517 |
+
ctx.beginPath();
|
| 518 |
+
ctx.arc(a.x, a.y, 1.2, 0, Math.PI * 2);
|
| 519 |
+
ctx.fill();
|
| 520 |
+
for (let j = i + 1; j < dots.length; j++) {
|
| 521 |
+
const b = dots[j];
|
| 522 |
+
const dx = a.x - b.x,
|
| 523 |
+
dy = a.y - b.y;
|
| 524 |
+
const dist = Math.hypot(dx, dy);
|
| 525 |
+
if (dist < threshold) {
|
| 526 |
+
const alpha = (1 - dist / threshold) * 0.2;
|
| 527 |
+
ctx.strokeStyle = `rgba(96,165,250,${alpha})`;
|
| 528 |
+
ctx.beginPath();
|
| 529 |
+
ctx.moveTo(a.x, a.y);
|
| 530 |
+
ctx.lineTo(b.x, b.y);
|
| 531 |
+
ctx.stroke();
|
| 532 |
+
}
|
| 533 |
+
}
|
| 534 |
+
}
|
| 535 |
+
requestAnimationFrame(step);
|
| 536 |
+
}
|
| 537 |
+
window.addEventListener("resize", resize);
|
| 538 |
+
resize();
|
| 539 |
+
step();
|
| 540 |
+
}
|
| 541 |
+
});
|
static/style.css
ADDED
|
@@ -0,0 +1,406 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
:root {
|
| 3 |
+
--bg: #0b1220;
|
| 4 |
+
--bg-2: #0d1528;
|
| 5 |
+
--panel: rgba(16, 24, 40, 0.72);
|
| 6 |
+
--panel-solid: #101828;
|
| 7 |
+
--card: rgba(17, 24, 39, 0.7);
|
| 8 |
+
--border: rgba(148, 163, 184, 0.16);
|
| 9 |
+
--text: #e2e8f0;
|
| 10 |
+
--muted: #94a3b8;
|
| 11 |
+
--primary: #22d3ee; /* cyan */
|
| 12 |
+
--primary-2: #06b6d4;
|
| 13 |
+
--success: #22c55e;
|
| 14 |
+
--danger: #ef4444;
|
| 15 |
+
--warning: #f59e0b;
|
| 16 |
+
--shadow: 0 10px 30px rgba(2, 8, 23, 0.6);
|
| 17 |
+
--radius: 14px;
|
| 18 |
+
--radius-sm: 10px;
|
| 19 |
+
--radius-lg: 20px;
|
| 20 |
+
--ring: 0 0 0 2px rgba(34, 211, 238, 0.35), 0 0 0 6px rgba(34, 211, 238, 0.15);
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
* { box-sizing: border-box; }
|
| 24 |
+
html, body { height: 100%; }
|
| 25 |
+
body {
|
| 26 |
+
margin: 0;
|
| 27 |
+
color: var(--text);
|
| 28 |
+
background: var(--bg);
|
| 29 |
+
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, Apple Color Emoji, Segoe UI Emoji;
|
| 30 |
+
-webkit-font-smoothing: antialiased;
|
| 31 |
+
-moz-osx-font-smoothing: grayscale;
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
/* Background */
|
| 35 |
+
.bg {
|
| 36 |
+
position: fixed; inset: 0; overflow: hidden; z-index: -1;
|
| 37 |
+
}
|
| 38 |
+
.bg-gradient {
|
| 39 |
+
position: absolute; inset: -20%;
|
| 40 |
+
background:
|
| 41 |
+
radial-gradient(60% 60% at 10% 10%, rgba(34,211,238,0.12) 0%, transparent 60%),
|
| 42 |
+
radial-gradient(40% 40% at 90% 20%, rgba(59,130,246,0.12) 0%, transparent 60%),
|
| 43 |
+
radial-gradient(50% 50% at 50% 100%, rgba(16,185,129,0.1) 0%, transparent 60%);
|
| 44 |
+
filter: blur(40px);
|
| 45 |
+
animation: bgShift 20s ease-in-out infinite alternate;
|
| 46 |
+
}
|
| 47 |
+
@keyframes bgShift {
|
| 48 |
+
0% { transform: translateY(-2%) scale(1); }
|
| 49 |
+
100% { transform: translateY(2%) scale(1.04); }
|
| 50 |
+
}
|
| 51 |
+
.bg-grid {
|
| 52 |
+
position: absolute; inset: 0;
|
| 53 |
+
background-image:
|
| 54 |
+
linear-gradient(to right, rgba(148,163,184,0.08) 1px, transparent 1px),
|
| 55 |
+
linear-gradient(to bottom, rgba(148,163,184,0.08) 1px, transparent 1px);
|
| 56 |
+
background-size: 40px 40px, 40px 40px;
|
| 57 |
+
mask-image: radial-gradient(ellipse at center, #000 60%, transparent 100%);
|
| 58 |
+
animation: gridPan 30s linear infinite;
|
| 59 |
+
}
|
| 60 |
+
@keyframes gridPan {
|
| 61 |
+
from { background-position: 0 0, 0 0; }
|
| 62 |
+
to { background-position: 40px 40px, 40px 40px; }
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
/* Layout */
|
| 66 |
+
.shell {
|
| 67 |
+
min-height: 100svh;
|
| 68 |
+
display: grid;
|
| 69 |
+
place-items: center;
|
| 70 |
+
padding: 24px;
|
| 71 |
+
}
|
| 72 |
+
.container {
|
| 73 |
+
width: 100%;
|
| 74 |
+
max-width: 980px;
|
| 75 |
+
background: var(--panel);
|
| 76 |
+
border: 1px solid var(--border);
|
| 77 |
+
border-radius: var(--radius);
|
| 78 |
+
box-shadow: var(--shadow);
|
| 79 |
+
backdrop-filter: saturate(140%) blur(12px);
|
| 80 |
+
padding: 28px;
|
| 81 |
+
}
|
| 82 |
+
.glass-card[data-animate] {
|
| 83 |
+
opacity: 0; transform: translateY(8px);
|
| 84 |
+
animation: fadeInUp 600ms ease forwards;
|
| 85 |
+
}
|
| 86 |
+
[data-animate].hidden { opacity: 0 !important; transform: none; animation: none !important; }
|
| 87 |
+
@keyframes fadeInUp {
|
| 88 |
+
0% { opacity: 0; transform: translateY(10px) scale(.98); }
|
| 89 |
+
100% { opacity: 1; transform: translateY(0) scale(1); }
|
| 90 |
+
}
|
| 91 |
+
.panel {
|
| 92 |
+
background: linear-gradient(180deg, rgba(15,23,42,0.6), rgba(15,23,42,0.35));
|
| 93 |
+
border: 1px solid var(--border);
|
| 94 |
+
border-radius: var(--radius-sm);
|
| 95 |
+
padding: 16px;
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
/* Header */
|
| 99 |
+
.app-header { text-align: center; margin-bottom: 16px; }
|
| 100 |
+
.brand {
|
| 101 |
+
display: flex; align-items: center; justify-content: center; gap: 12px;
|
| 102 |
+
}
|
| 103 |
+
.brand-mark {
|
| 104 |
+
position: relative; width: 36px; height: 36px;
|
| 105 |
+
background: radial-gradient(circle at 30% 30%, var(--primary), transparent 60%),
|
| 106 |
+
radial-gradient(circle at 70% 70%, #60a5fa, transparent 60%);
|
| 107 |
+
border-radius: 12px;
|
| 108 |
+
border: 1px solid var(--border);
|
| 109 |
+
box-shadow: inset 0 0 20px rgba(34,211,238,0.15), 0 10px 20px rgba(2,8,23,0.6);
|
| 110 |
+
overflow: visible;
|
| 111 |
+
}
|
| 112 |
+
.brand-mark .dot {
|
| 113 |
+
position: absolute; width: 6px; height: 6px; border-radius: 50%;
|
| 114 |
+
background: var(--text); opacity: .7; filter: blur(.3px);
|
| 115 |
+
}
|
| 116 |
+
.dot-1 { top: 6px; left: 6px; animation: float 3.6s ease-in-out infinite; }
|
| 117 |
+
.dot-2 { bottom: 8px; right: 8px; animation: float 3.2s 250ms ease-in-out infinite; }
|
| 118 |
+
.dot-3 { top: 8px; right: 6px; animation: float 3.8s 400ms ease-in-out infinite; }
|
| 119 |
+
@keyframes float {
|
| 120 |
+
0%,100% { transform: translateY(0); }
|
| 121 |
+
50% { transform: translateY(-3px); }
|
| 122 |
+
}
|
| 123 |
+
.title {
|
| 124 |
+
margin: 0;
|
| 125 |
+
font-size: clamp(28px, 3.8vw, 40px);
|
| 126 |
+
letter-spacing: -.02em;
|
| 127 |
+
}
|
| 128 |
+
.text-gradient {
|
| 129 |
+
background: linear-gradient(90deg, #e2e8f0 0%, var(--primary) 50%, #60a5fa 100%);
|
| 130 |
+
-webkit-background-clip: text; background-clip: text; color: transparent;
|
| 131 |
+
}
|
| 132 |
+
.subtitle {
|
| 133 |
+
margin: 8px auto 0;
|
| 134 |
+
color: var(--muted);
|
| 135 |
+
max-width: 48ch;
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
/* Tabs */
|
| 139 |
+
.tabs {
|
| 140 |
+
position: relative;
|
| 141 |
+
display: grid;
|
| 142 |
+
grid-template-columns: 1fr 1fr;
|
| 143 |
+
gap: 8px;
|
| 144 |
+
background: rgba(148,163,184,0.08);
|
| 145 |
+
border: 1px solid var(--border);
|
| 146 |
+
border-radius: 999px;
|
| 147 |
+
padding: 6px;
|
| 148 |
+
margin: 18px 0 12px;
|
| 149 |
+
}
|
| 150 |
+
.tab-btn {
|
| 151 |
+
position: relative;
|
| 152 |
+
border: none;
|
| 153 |
+
background: transparent;
|
| 154 |
+
color: var(--muted);
|
| 155 |
+
padding: 10px 14px;
|
| 156 |
+
border-radius: 999px;
|
| 157 |
+
cursor: pointer;
|
| 158 |
+
font-weight: 600;
|
| 159 |
+
transition: color .2s ease;
|
| 160 |
+
z-index: 1;
|
| 161 |
+
}
|
| 162 |
+
.tab-btn.active { color: var(--text); }
|
| 163 |
+
.tab-indicator {
|
| 164 |
+
position: absolute; top: 6px; bottom: 6px; width: calc(50% - 6px);
|
| 165 |
+
background: linear-gradient(180deg, rgba(34,211,238,0.16), rgba(59,130,246,0.12));
|
| 166 |
+
border: 1px solid var(--border);
|
| 167 |
+
border-radius: 999px;
|
| 168 |
+
box-shadow: inset 0 0 0 1px rgba(255,255,255,0.04), 0 6px 18px rgba(2,8,23,0.6);
|
| 169 |
+
transition: transform .35s cubic-bezier(.2,.8,.2,1);
|
| 170 |
+
transform: translateX(0);
|
| 171 |
+
}
|
| 172 |
+
.tab-btn.active ~ .tab-indicator { transform: translateX(0); }
|
| 173 |
+
#select-github-btn.tab-btn.active ~ .tab-indicator { transform: translateX(100%); }
|
| 174 |
+
|
| 175 |
+
/* Form */
|
| 176 |
+
.form { display: grid; gap: 12px; }
|
| 177 |
+
.form-row { display: grid; gap: 12px; grid-template-columns: 1fr; }
|
| 178 |
+
@media (min-width: 700px){ .form-row { grid-template-columns: 1fr 1fr; } }
|
| 179 |
+
|
| 180 |
+
.form-group { display: grid; gap: 8px; }
|
| 181 |
+
label { font-weight: 600; color: var(--text); }
|
| 182 |
+
small { color: var(--muted); }
|
| 183 |
+
|
| 184 |
+
input[type="text"],
|
| 185 |
+
input[type="file"],
|
| 186 |
+
select,
|
| 187 |
+
textarea {
|
| 188 |
+
width: 100%;
|
| 189 |
+
padding: 12px 12px;
|
| 190 |
+
border-radius: 10px;
|
| 191 |
+
border: 1px solid var(--border);
|
| 192 |
+
color: var(--text);
|
| 193 |
+
background: linear-gradient(180deg, rgba(14,22,38,0.9), rgba(14,22,38,0.6));
|
| 194 |
+
outline: none;
|
| 195 |
+
transition: box-shadow .2s ease, border-color .2s ease, transform .08s ease;
|
| 196 |
+
}
|
| 197 |
+
textarea { resize: vertical; min-height: 92px; }
|
| 198 |
+
input:focus, select:focus, textarea:focus {
|
| 199 |
+
border-color: rgba(34,211,238,0.55);
|
| 200 |
+
box-shadow: var(--ring);
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
+
select{background: var(--bg);}
|
| 204 |
+
option {color: var(--primary); background-color: var(--bg-2);}
|
| 205 |
+
|
| 206 |
+
.form-error { color: var(--danger); font-weight: 600; }
|
| 207 |
+
|
| 208 |
+
/* Tree */
|
| 209 |
+
.tree {
|
| 210 |
+
background: linear-gradient(180deg, rgba(14,22,38,0.9), rgba(14,22,38,0.5));
|
| 211 |
+
border: 1px solid var(--border);
|
| 212 |
+
border-radius: 10px;
|
| 213 |
+
padding: 12px;
|
| 214 |
+
max-height: 320px;
|
| 215 |
+
overflow: auto;
|
| 216 |
+
}
|
| 217 |
+
#file-tree ul { list-style: none; padding-left: 1rem; margin: 6px 0; }
|
| 218 |
+
#file-tree label { font-weight: 500; }
|
| 219 |
+
|
| 220 |
+
/* Divider */
|
| 221 |
+
.divider {
|
| 222 |
+
height: 1px; width: 100%;
|
| 223 |
+
background: linear-gradient(90deg, transparent, rgba(148,163,184,0.2), transparent);
|
| 224 |
+
margin: 6px 0;
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
/* Buttons */
|
| 228 |
+
.btn {
|
| 229 |
+
--inset: inset 0 0 0 1px rgba(255,255,255,0.06);
|
| 230 |
+
display: inline-flex; align-items: center; justify-content: center;
|
| 231 |
+
gap: 10px;
|
| 232 |
+
border: none;
|
| 233 |
+
border-radius: 12px;
|
| 234 |
+
padding: 12px 16px;
|
| 235 |
+
font-weight: 700;
|
| 236 |
+
cursor: pointer;
|
| 237 |
+
background: linear-gradient(180deg, #0ea5b7, #0891a6);
|
| 238 |
+
color: white;
|
| 239 |
+
box-shadow: var(--inset), 0 8px 24px rgba(6, 182, 212, 0.25);
|
| 240 |
+
transition: transform .08s ease, box-shadow .2s ease, filter .2s ease;
|
| 241 |
+
}
|
| 242 |
+
.btn:hover { transform: translateY(-1px); filter: brightness(1.05); }
|
| 243 |
+
.btn:active { transform: translateY(0); filter: brightness(.98); }
|
| 244 |
+
.btn:disabled { opacity: .6; cursor: not-allowed; filter: grayscale(.2); }
|
| 245 |
+
.btn-primary { background: linear-gradient(180deg, var(--primary), var(--primary-2)); }
|
| 246 |
+
.btn-inner { display: inline-flex; align-items: center; gap: 10px; }
|
| 247 |
+
.icon { opacity: .9; }
|
| 248 |
+
|
| 249 |
+
/* Button glow + ripple */
|
| 250 |
+
.btn-glow { position: relative; overflow: hidden; }
|
| 251 |
+
.btn-glow::after {
|
| 252 |
+
content: ""; position: absolute; inset: -2px;
|
| 253 |
+
background: radial-gradient(120px 120px at var(--x,50%) var(--y,50%), rgba(255,255,255,0.18), transparent 40%);
|
| 254 |
+
transition: opacity .2s ease;
|
| 255 |
+
opacity: 0; pointer-events: none;
|
| 256 |
+
}
|
| 257 |
+
.btn-glow:hover::after { opacity: 1; }
|
| 258 |
+
.ripple {
|
| 259 |
+
position: absolute; border-radius: 50%; transform: translate(-50%, -50%);
|
| 260 |
+
background: rgba(255,255,255,0.35);
|
| 261 |
+
animation: ripple .6s ease-out forwards;
|
| 262 |
+
pointer-events: none;
|
| 263 |
+
}
|
| 264 |
+
@keyframes ripple {
|
| 265 |
+
from { width: 0; height: 0; opacity: .45; }
|
| 266 |
+
to { width: 360px; height: 360px; opacity: 0; }
|
| 267 |
+
}
|
| 268 |
+
|
| 269 |
+
/* Loader dot */
|
| 270 |
+
.loader {
|
| 271 |
+
width: 0.85em; height: 0.85em; border-radius: 50%;
|
| 272 |
+
background: currentColor; opacity: 0; transform: scale(.6);
|
| 273 |
+
box-shadow: 16px 0 0 currentColor, -16px 0 0 currentColor;
|
| 274 |
+
filter: drop-shadow(0 0 8px rgba(34,211,238,0.5));
|
| 275 |
+
}
|
| 276 |
+
button[disabled] .loader { opacity: .9; animation: pulseDots 1s infinite ease-in-out; }
|
| 277 |
+
@keyframes pulseDots {
|
| 278 |
+
0%, 100% { box-shadow: 16px 0 0 currentColor, -16px 0 0 currentColor; }
|
| 279 |
+
50% { box-shadow: 0 0 0 currentColor, 0 0 0 currentColor; }
|
| 280 |
+
}
|
| 281 |
+
|
| 282 |
+
/* Actions */
|
| 283 |
+
.form-actions { display: flex; justify-content: center; margin-top: 8px; }
|
| 284 |
+
|
| 285 |
+
/* Progress Layout */
|
| 286 |
+
.section-title { margin: 0 0 10px; }
|
| 287 |
+
.section-title.success { color: var(--success); }
|
| 288 |
+
|
| 289 |
+
.progress-layout {
|
| 290 |
+
display: grid; gap: 16px;
|
| 291 |
+
}
|
| 292 |
+
@media (min-width: 960px){
|
| 293 |
+
.progress-layout { grid-template-columns: 1.1fr .9fr; align-items: start; }
|
| 294 |
+
}
|
| 295 |
+
|
| 296 |
+
/* Timeline */
|
| 297 |
+
.timeline {
|
| 298 |
+
list-style: none; margin: 0; padding: 0;
|
| 299 |
+
display: grid; gap: 12px;
|
| 300 |
+
}
|
| 301 |
+
.phase-item {
|
| 302 |
+
display: grid; grid-template-columns: 28px 1fr; gap: 12px;
|
| 303 |
+
background: linear-gradient(180deg, rgba(15,23,42,0.6), rgba(15,23,42,0.35));
|
| 304 |
+
border: 1px solid var(--border);
|
| 305 |
+
border-radius: 12px;
|
| 306 |
+
padding: 12px;
|
| 307 |
+
transition: transform .2s ease, border-color .2s ease, background .3s ease, box-shadow .3s ease;
|
| 308 |
+
will-change: transform;
|
| 309 |
+
}
|
| 310 |
+
.phase-item:hover { transform: translateY(-2px); border-color: rgba(34,211,238,0.35); box-shadow: 0 8px 20px rgba(2,8,23,0.6); }
|
| 311 |
+
.phase-title { font-weight: 700; }
|
| 312 |
+
|
| 313 |
+
.status-icon {
|
| 314 |
+
position: relative; width: 24px; height: 24px; border-radius: 50%;
|
| 315 |
+
background: #334155; align-self: start;
|
| 316 |
+
box-shadow: inset 0 0 0 2px rgba(15,23,42,0.7);
|
| 317 |
+
}
|
| 318 |
+
.phase-item[data-status="pending"] .status-icon { background: #475569; opacity: .8; }
|
| 319 |
+
.phase-item[data-status="in-progress"] .status-icon {
|
| 320 |
+
background: linear-gradient(180deg, #60a5fa, #3b82f6);
|
| 321 |
+
animation: spin 1.2s linear infinite;
|
| 322 |
+
box-shadow: 0 0 0 6px rgba(59,130,246,0.15);
|
| 323 |
+
}
|
| 324 |
+
.phase-item[data-status="success"] .status-icon {
|
| 325 |
+
background: linear-gradient(180deg, #34d399, #22c55e);
|
| 326 |
+
box-shadow: 0 0 0 6px rgba(34,197,94,0.12);
|
| 327 |
+
}
|
| 328 |
+
.phase-item[data-status="error"] .status-icon {
|
| 329 |
+
background: linear-gradient(180deg, #f97373, #ef4444);
|
| 330 |
+
box-shadow: 0 0 0 6px rgba(239,68,68,0.15);
|
| 331 |
+
}
|
| 332 |
+
|
| 333 |
+
.phase-item[data-status="success"] .status-icon::after,
|
| 334 |
+
.phase-item[data-status="error"] .status-icon::after {
|
| 335 |
+
position: absolute; content: "";
|
| 336 |
+
inset: 5px; border-radius: 50%; background: rgba(0,0,0,0.2);
|
| 337 |
+
mask: center / contain no-repeat;
|
| 338 |
+
}
|
| 339 |
+
.phase-item[data-status="success"] .status-icon::after {
|
| 340 |
+
mask-image: radial-gradient(circle at 50% 58%, transparent 56%, black 56.5%),
|
| 341 |
+
linear-gradient(transparent 45%, black 45% 55%, transparent 55%);
|
| 342 |
+
}
|
| 343 |
+
.phase-item[data-status="error"] .status-icon::after {
|
| 344 |
+
mask-image: linear-gradient(transparent 45%, black 45% 55%, transparent 55%),
|
| 345 |
+
linear-gradient(90deg, transparent 45%, black 45% 55%, transparent 55%);
|
| 346 |
+
}
|
| 347 |
+
|
| 348 |
+
@keyframes spin { to { transform: rotate(360deg); } }
|
| 349 |
+
|
| 350 |
+
.subtask-list { list-style: none; padding-left: 0; margin: 8px 0 0; font-size: 14px; color: var(--muted); }
|
| 351 |
+
.subtask-list li { margin: 4px 0; opacity: .8; transition: opacity .2s ease, color .2s ease, text-decoration-color .2s ease; }
|
| 352 |
+
.subtask-list li[data-status="in-progress"] { opacity: 1; color: #cbd5e1; }
|
| 353 |
+
.subtask-list li[data-status="success"] { opacity: 1; color: #93a3b3; text-decoration: line-through; }
|
| 354 |
+
.subtask-list li[data-status="error"] { opacity: 1; color: var(--danger); font-weight: 600; }
|
| 355 |
+
|
| 356 |
+
/* Terminal */
|
| 357 |
+
.terminal {
|
| 358 |
+
background: #0b1020;
|
| 359 |
+
border: 1px solid var(--border);
|
| 360 |
+
border-radius: 12px;
|
| 361 |
+
overflow: hidden;
|
| 362 |
+
box-shadow: inset 0 0 0 1px rgba(255,255,255,0.03);
|
| 363 |
+
}
|
| 364 |
+
.terminal-head {
|
| 365 |
+
display: flex; align-items: center; gap: 8px;
|
| 366 |
+
padding: 8px 10px; background: rgba(255,255,255,0.03);
|
| 367 |
+
border-bottom: 1px solid var(--border);
|
| 368 |
+
}
|
| 369 |
+
.terminal-head h4 { margin: 0 0 0 auto; color: var(--muted); font-weight: 600; }
|
| 370 |
+
.terminal .dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; }
|
| 371 |
+
.terminal .dot.red { background: #ef4444; }
|
| 372 |
+
.terminal .dot.yellow { background: #f59e0b; }
|
| 373 |
+
.terminal .dot.green { background: #22c55e; }
|
| 374 |
+
|
| 375 |
+
.terminal-body {
|
| 376 |
+
margin: 0; padding: 14px; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
|
| 377 |
+
font-size: 13px; color: #cbd5e1; white-space: pre-wrap; word-wrap: break-word;
|
| 378 |
+
max-height: 420px; overflow: auto; line-height: 1.45;
|
| 379 |
+
background-image:
|
| 380 |
+
linear-gradient(rgba(255,255,255,0.025) 50%, transparent 50%);
|
| 381 |
+
background-size: 100% 22px;
|
| 382 |
+
}
|
| 383 |
+
|
| 384 |
+
/* Result */
|
| 385 |
+
.result-card {
|
| 386 |
+
background: linear-gradient(180deg, rgba(16,185,129,0.1), rgba(16,185,129,0.06));
|
| 387 |
+
border: 1px solid rgba(16,185,129,0.3);
|
| 388 |
+
border-radius: 12px;
|
| 389 |
+
padding: 14px;
|
| 390 |
+
}
|
| 391 |
+
.button-link {
|
| 392 |
+
display: inline-flex; align-items: center; gap: 8px;
|
| 393 |
+
text-decoration: none; color: white;
|
| 394 |
+
margin-top: 10px;
|
| 395 |
+
background: linear-gradient(180deg, var(--primary), var(--primary-2));
|
| 396 |
+
padding: 10px 14px; border-radius: 10px; font-weight: 700;
|
| 397 |
+
box-shadow: 0 8px 24px rgba(6, 182, 212, 0.25);
|
| 398 |
+
}
|
| 399 |
+
|
| 400 |
+
/* Utility */
|
| 401 |
+
.hidden { display: none !important; }
|
| 402 |
+
|
| 403 |
+
/* Accessibility: reduced motion */
|
| 404 |
+
@media (prefers-reduced-motion: reduce) {
|
| 405 |
+
* { animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; scroll-behavior: auto !important; }
|
| 406 |
+
}
|