| |
| """ |
| app.py — Project File Aggregator for LLM Input (with Gradio UI) |
| |
| اضافه شده: پشتیبانی از فایلهای ZIP به عنوان ورودی |
| """ |
|
|
| import hashlib |
| import tempfile |
| import zipfile |
| from pathlib import Path |
| from datetime import datetime |
| from concurrent.futures import ThreadPoolExecutor |
| import os |
| import shutil |
|
|
| import gradio as gr |
|
|
| |
| |
| |
|
|
| IGNORE_DIRS_DEFAULT = ( |
| ".git,.idea,.vscode,__pycache__,.pytest_cache,node_modules,venv,.venv," |
| "env,build,dist,.next,.cache,site-packages,.mypy_cache,target,.gradle,bin,obj" |
| ) |
|
|
| IGNORE_EXTENSIONS = { |
| ".exe", ".dll", ".so", ".dylib", ".png", ".jpg", ".jpeg", ".gif", ".ico", |
| ".svg", ".webp", ".zip", ".rar", ".7z", ".tar", ".gz", ".pdf", ".mp4", |
| ".mp3", ".wav", ".mov", ".woff", ".woff2", ".ttf", ".eot", ".pyc", |
| ".class", ".jar", ".db", ".sqlite3", ".lock", ".bin", ".dat", |
| } |
|
|
| LANGUAGE_MAP = { |
| ".py": "Python", ".js": "JavaScript", ".jsx": "JavaScript (JSX)", |
| ".ts": "TypeScript", ".tsx": "TypeScript (TSX)", ".html": "HTML", |
| ".css": "CSS", ".scss": "SCSS", ".json": "JSON", ".yaml": "YAML", |
| ".yml": "YAML", ".toml": "TOML", ".xml": "XML", ".md": "Markdown", |
| ".sql": "SQL", ".java": "Java", ".go": "Go", ".rb": "Ruby", |
| ".php": "PHP", ".c": "C", ".cpp": "C++", ".cs": "C#", ".swift": "Swift", |
| ".kt": "Kotlin", ".sh": "Shell", ".txt": "Text", ".env": "Env", |
| ".ini": "INI", ".cfg": "Config", ".rst": "reStructuredText", |
| } |
|
|
| EXTENSION_CATEGORIES = { |
| "Python": {".py"}, |
| "JS / TypeScript": {".js", ".jsx", ".ts", ".tsx"}, |
| "Web (HTML/CSS)": {".html", ".css", ".scss"}, |
| "Config / Data (JSON, YAML, ...)": {".json", ".yaml", ".yml", ".toml", ".ini", ".cfg", ".xml", ".env"}, |
| "Documentation (Markdown/Text)": {".md", ".txt", ".rst"}, |
| "Other Languages (Java, Go, C++, ...)": {".java", ".go", ".rb", ".php", ".c", ".cpp", ".cs", ".swift", ".kt", ".sql", ".sh"}, |
| } |
|
|
| CHARS_PER_TOKEN = 4.0 |
|
|
|
|
| |
| |
| |
|
|
| def should_ignore_dir(dir_name: str, extra_ignore_dirs: set) -> bool: |
| return dir_name in extra_ignore_dirs or dir_name.startswith(".") |
|
|
|
|
| |
| |
| |
|
|
| def is_zip_file(file_path: str) -> bool: |
| """بررسی میکند که آیا فایل یک ZIP معتبر است""" |
| try: |
| return zipfile.is_zipfile(file_path) |
| except: |
| return False |
|
|
|
|
| def process_zip_input(zip_path: str, output_dir: str = None) -> Path: |
| """ |
| فایل ZIP را استخراج کرده و مسیر پوشه استخراج شده را برمیگرداند |
| اگر output_dir مشخص نشده باشد، از tempfile استفاده میکند |
| """ |
| zip_path = Path(zip_path) |
| |
| |
| if output_dir is None: |
| temp_dir = tempfile.mkdtemp(prefix="context_llm_zip_") |
| output_dir = Path(temp_dir) |
| else: |
| output_dir = Path(output_dir) |
| output_dir.mkdir(parents=True, exist_ok=True) |
| |
| |
| try: |
| with zipfile.ZipFile(zip_path, 'r') as zip_ref: |
| |
| for member in zip_ref.namelist(): |
| |
| member_path = (output_dir / member).resolve() |
| if not str(member_path).startswith(str(output_dir.resolve())): |
| raise Exception(f"فایل مخرب در ZIP: {member}") |
| |
| zip_ref.extractall(output_dir) |
| return output_dir |
| except Exception as e: |
| |
| if output_dir is not None and str(output_dir).startswith(tempfile.gettempdir()): |
| shutil.rmtree(output_dir, ignore_errors=True) |
| raise Exception(f"خطا در استخراج ZIP: {str(e)}") |
|
|
|
|
| def cleanup_temp_dir(temp_dir: Path): |
| """پاک کردن دایرکتوری موقت (اگر از tempfile ایجاد شده باشد)""" |
| if temp_dir is not None and str(temp_dir).startswith(tempfile.gettempdir()): |
| try: |
| shutil.rmtree(temp_dir, ignore_errors=True) |
| except: |
| pass |
|
|
|
|
| |
| |
| |
|
|
| def scan_directory( |
| root_path: Path, |
| ignore_dirs: set = None, |
| max_file_size_mb: int = 5, |
| include_hidden: bool = False, |
| use_gitignore: bool = False, |
| is_temp_dir: bool = False |
| ) -> tuple[list, list, int, list]: |
| """ |
| اسکن دایرکتوری و جمعآوری فایلها |
| برمیگرداند: (file_infos, root_name, total_files, category_counts) |
| """ |
| if ignore_dirs is None: |
| ignore_dirs = set(IGNORE_DIRS_DEFAULT.split(",")) |
|
|
| root_path = Path(root_path) |
| if not root_path.exists(): |
| raise FileNotFoundError(f"مسیر وجود ندارد: {root_path}") |
|
|
| |
| if root_path.is_file() and is_zip_file(str(root_path)): |
| |
| temp_dir = process_zip_input(str(root_path)) |
| is_temp_dir = True |
| root_path = temp_dir |
| root_name = root_path.name |
| elif root_path.is_file(): |
| |
| root_name = root_path.name |
| |
| temp_dir = tempfile.mkdtemp(prefix="context_llm_single_") |
| shutil.copy2(root_path, Path(temp_dir) / root_name) |
| root_path = Path(temp_dir) / root_name |
| is_temp_dir = True |
| else: |
| root_name = root_path.name |
| is_temp_dir = False |
|
|
| files = [] |
| total_size = 0 |
| total_files = 0 |
| category_counts = {cat: 0 for cat in EXTENSION_CATEGORIES} |
|
|
| for item in root_path.rglob("*"): |
| if item.is_dir(): |
| |
| if should_ignore_dir(item.name, ignore_dirs) or item.name.startswith("."): |
| continue |
| continue |
|
|
| |
| ext = item.suffix.lower() |
| if ext in IGNORE_EXTENSIONS: |
| continue |
|
|
| |
| if not include_hidden and item.name.startswith("."): |
| continue |
|
|
| |
| if item.stat().st_size > max_file_size_mb * 1024 * 1024: |
| continue |
|
|
| |
| category = "Other" |
| for cat, exts in EXTENSION_CATEGORIES.items(): |
| if ext in exts: |
| category = cat |
| break |
|
|
| category_counts[category] = category_counts.get(category, 0) + 1 |
|
|
| |
| try: |
| content = item.read_text(encoding="utf-8", errors="ignore") |
| except Exception: |
| |
| continue |
|
|
| |
| rel_path = str(item.relative_to(root_path)) |
|
|
| files.append({ |
| "path": rel_path, |
| "content": content, |
| "ext": ext, |
| "language": LANGUAGE_MAP.get(ext, "Unknown"), |
| "category": category, |
| "size": item.stat().st_size, |
| }) |
| total_files += 1 |
|
|
| return files, root_name, total_files, category_counts, is_temp_dir |
|
|
|
|
| |
| |
| |
|
|
| def chunk_file_content(file_info: dict, chunk_size_tokens: int = 4000, overlap_tokens: int = 200) -> list: |
| """Split file content into chunks based on token estimation.""" |
| content = file_info["content"] |
| |
| estimated_tokens = len(content) / CHARS_PER_TOKEN |
| if estimated_tokens <= chunk_size_tokens: |
| return [content] |
|
|
| |
| chunk_chars = int(chunk_size_tokens * CHARS_PER_TOKEN) |
| overlap_chars = int(overlap_tokens * CHARS_PER_TOKEN) |
|
|
| chunks = [] |
| start = 0 |
| while start < len(content): |
| end = min(start + chunk_chars, len(content)) |
| |
| if end < len(content): |
| |
| newline_pos = content.rfind("\n", start, end) |
| if newline_pos != -1 and newline_pos > start: |
| end = newline_pos + 1 |
| else: |
| |
| space_pos = content.rfind(" ", start, end) |
| if space_pos != -1 and space_pos > start: |
| end = space_pos + 1 |
|
|
| chunks.append(content[start:end]) |
| start = end - overlap_chars |
| if start < 0: |
| start = 0 |
|
|
| return chunks |
|
|
|
|
| def estimate_tokens(text: str) -> int: |
| """Estimate token count based on character count.""" |
| return int(len(text) / CHARS_PER_TOKEN) |
|
|
|
|
| |
| |
| |
|
|
| def build_context( |
| input_path: str, |
| ignore_dirs: str = None, |
| max_file_size_mb: int = 5, |
| chunk_size_tokens: int = 4000, |
| overlap_tokens: int = 200, |
| max_total_tokens: int = 100000, |
| include_hidden: bool = False, |
| use_gitignore: bool = False, |
| include_hash: bool = True, |
| include_tree: bool = True, |
| ) -> tuple[str, str, dict]: |
| """ |
| Build aggregated context for LLM input. |
| برمیگرداند: (context_text, context_hash, stats) |
| """ |
| |
| if ignore_dirs: |
| ignore_set = set(ignore_dirs.split(",")) |
| else: |
| ignore_set = set(IGNORE_DIRS_DEFAULT.split(",")) |
|
|
| |
| root_path = Path(input_path) |
| is_temp = False |
| |
| try: |
| files, root_name, total_files, category_counts, is_temp = scan_directory( |
| root_path=root_path, |
| ignore_dirs=ignore_set, |
| max_file_size_mb=max_file_size_mb, |
| include_hidden=include_hidden, |
| use_gitignore=use_gitignore, |
| ) |
| except Exception as e: |
| return f"❌ خطا: {str(e)}", "", {} |
|
|
| if not files: |
| return "⚠️ هیچ فایل متنی قابل خواندنی در مسیر مشخص شده یافت نشد.", "", {} |
|
|
| |
| files.sort(key=lambda x: (x["category"], x["path"])) |
|
|
| |
| context_parts = [] |
| context_parts.append(f"# Project Context: {root_name}") |
| context_parts.append(f"# Generated: {datetime.now().isoformat()}") |
| context_parts.append(f"# Total files: {len(files)}") |
| context_parts.append("") |
|
|
| |
| if include_tree: |
| tree = build_tree_structure(files) |
| context_parts.append("## Directory Structure") |
| context_parts.append("```") |
| context_parts.append(tree) |
| context_parts.append("```") |
| context_parts.append("") |
|
|
| total_est_tokens = 0 |
| file_chunks = [] |
|
|
| for file_info in files: |
| chunks = chunk_file_content( |
| file_info, |
| chunk_size_tokens=chunk_size_tokens, |
| overlap_tokens=overlap_tokens, |
| ) |
|
|
| for i, chunk in enumerate(chunks): |
| |
| chunk_tokens = estimate_tokens(chunk) |
|
|
| |
| if total_est_tokens + chunk_tokens > max_total_tokens: |
| |
| remaining = max_total_tokens - total_est_tokens |
| if remaining > 0: |
| |
| char_limit = int(remaining * CHARS_PER_TOKEN) |
| truncated = chunk[:char_limit] |
| header = f"### {file_info['path']} [{file_info['language']}] (chunk {i+1}/{len(chunks)}) — TRUNCATED\n\n" |
| context_parts.append(header + truncated) |
| context_parts.append("\n\n⚠️ **Context truncated due to token limit**") |
| context_parts.append(f"Limit: {max_total_tokens:,} tokens") |
| break |
|
|
| |
| chunk_header = f"### {file_info['path']} [{file_info['language']}] (chunk {i+1}/{len(chunks)})\n\n" |
| context_parts.append(chunk_header + chunk) |
| total_est_tokens += chunk_tokens + estimate_tokens(chunk_header) |
|
|
| else: |
| |
| continue |
| |
| break |
|
|
| context_text = "\n\n".join(context_parts) |
|
|
| |
| if include_hash: |
| context_hash = hashlib.md5(context_text.encode()).hexdigest() |
| else: |
| context_hash = "" |
|
|
| |
| stats = { |
| "files": len(files), |
| "total_tokens": total_est_tokens, |
| "categories": category_counts, |
| "chunk_size": chunk_size_tokens, |
| "overlap": overlap_tokens, |
| "max_tokens": max_total_tokens, |
| "truncated": total_est_tokens >= max_total_tokens, |
| } |
|
|
| |
| if is_temp and root_path: |
| cleanup_temp_dir(root_path.parent if root_path.is_file() else root_path) |
|
|
| return context_text, context_hash, stats |
|
|
|
|
| def build_tree_structure(files: list) -> str: |
| """Build a simple tree structure from file paths.""" |
| tree = {} |
| for file_info in files: |
| parts = Path(file_info["path"]).parts |
| current = tree |
| for part in parts[:-1]: |
| current = current.setdefault(part, {}) |
| current[parts[-1]] = None |
|
|
| def render_tree(node, prefix=""): |
| lines = [] |
| items = sorted(node.items()) |
| for i, (key, value) in enumerate(items): |
| is_last = (i == len(items) - 1) |
| if value is None: |
| lines.append(f"{prefix}{'└── ' if is_last else '├── '}{key}") |
| else: |
| lines.append(f"{prefix}{'└── ' if is_last else '├── '}{key}/") |
| lines.extend(render_tree( |
| value, |
| prefix + (" " if is_last else "│ ") |
| )) |
| return lines |
|
|
| return "\n".join(render_tree(tree)) if tree else "(empty)" |
|
|
|
|
| |
| |
| |
|
|
| def create_ui(): |
| with gr.Blocks(title="Context LLM Builder", theme=gr.themes.Soft()) as demo: |
| gr.Markdown(""" |
| # 📚 Context LLM Builder |
| |
| این ابزار فایلهای یک پروژه را اسکن کرده و آنها را بهصورت یک متن یکپارچه |
| برای ارسال به مدلهای زبانی بزرگ (LLM) آماده میکند. |
| |
| **✅ پشتیبانی از ورودیها:** |
| - **پوشه** (کل پروژه) |
| - **فایل ZIP** (استخراج خودکار در حافظه) |
| - **فایل تکی** (مانند یک فایل پایتون) |
| """) |
|
|
| with gr.Row(): |
| with gr.Column(scale=1): |
| input_path = gr.Textbox( |
| label="مسیر پوشه / فایل ZIP / فایل تکی", |
| placeholder="/path/to/your/project یا /path/to/file.zip", |
| value=".", |
| ) |
|
|
| with gr.Accordion("⚙️ تنظیمات پیشرفته", open=False): |
| ignore_dirs = gr.Textbox( |
| label="پوشههای نادیده گرفته شده (با کاما جدا کنید)", |
| value=IGNORE_DIRS_DEFAULT, |
| ) |
| max_file_size_mb = gr.Slider( |
| label="حداکثر حجم فایل (MB)", |
| minimum=1, |
| maximum=50, |
| value=5, |
| step=1, |
| ) |
| chunk_size_tokens = gr.Slider( |
| label="حجم هر تکه (توکن)", |
| minimum=500, |
| maximum=16000, |
| value=4000, |
| step=100, |
| ) |
| overlap_tokens = gr.Slider( |
| label="همپوشانی بین تکهها (توکن)", |
| minimum=0, |
| maximum=1000, |
| value=200, |
| step=50, |
| ) |
| max_total_tokens = gr.Slider( |
| label="حداکثر توکن کل خروجی", |
| minimum=10000, |
| maximum=200000, |
| value=100000, |
| step=1000, |
| ) |
| include_hidden = gr.Checkbox( |
| label="شامل فایلهای پنهان (.bashrc و ...)", |
| value=False, |
| ) |
| include_hash = gr.Checkbox( |
| label="شامل هش (MD5) متن خروجی", |
| value=True, |
| ) |
| include_tree = gr.Checkbox( |
| label="شامل ساختار دایرکتوری", |
| value=True, |
| ) |
|
|
| build_btn = gr.Button("🚀 ساخت کانتکست", variant="primary", size="lg") |
|
|
| with gr.Column(scale=2): |
| status = gr.Markdown("⬅️ تنظیمات را وارد کرده و دکمه را بزنید") |
| with gr.Row(): |
| with gr.Column(scale=3): |
| output_text = gr.Textbox( |
| label="📄 متن کانتکست", |
| lines=30, |
| max_lines=50, |
| interactive=False, |
| ) |
| with gr.Column(scale=1): |
| hash_text = gr.Textbox( |
| label="🔑 هش (MD5)", |
| lines=1, |
| interactive=False, |
| max_lines=1, |
| ) |
| stats_json = gr.JSON( |
| label="📊 آمار", |
| value={}, |
| ) |
|
|
| with gr.Row(): |
| copy_btn = gr.Button("📋 کپی متن", variant="secondary", size="sm") |
| download_btn = gr.DownloadButton("💾 دانلود فایل", variant="secondary", size="sm") |
|
|
| |
| build_btn.click( |
| fn=build_context, |
| inputs=[ |
| input_path, |
| ignore_dirs, |
| max_file_size_mb, |
| chunk_size_tokens, |
| overlap_tokens, |
| max_total_tokens, |
| include_hidden, |
| include_hash, |
| include_tree, |
| ], |
| outputs=[output_text, hash_text, stats_json], |
| ) |
|
|
| |
| copy_btn.click( |
| fn=lambda x: x, |
| inputs=[output_text], |
| outputs=[], |
| js="(text) => { navigator.clipboard.writeText(text); return text; }", |
| ) |
|
|
| |
| def download_file(text): |
| if not text: |
| return None |
| temp = tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) |
| temp.write(text) |
| temp.close() |
| return temp.name |
|
|
| download_btn.click( |
| fn=download_file, |
| inputs=[output_text], |
| outputs=[gr.File(label="📥 دانلود فایل")], |
| ) |
|
|
| return demo |
|
|
| if __name__ == "__main__": |
| demo = create_ui() |
| demo.launch(server_name="0.0.0.0", server_port=7860, share=False) |