From ab28e06b0c3936699d6b3d38098aa3c7273a48e8 Mon Sep 17 00:00:00 2001 From: Adrian Darian Date: Fri, 10 Oct 2025 21:00:28 -0700 Subject: [PATCH 01/50] Implement intelligent chunking for large repository wiki generation and add XML structure prompt --- api/api.py | 242 ++++++++++++++++++++++++++++++++--- api/prompts.py | 51 ++++++++ api/websocket_wiki.py | 290 ++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 549 insertions(+), 34 deletions(-) diff --git a/api/api.py b/api/api.py index d40e73f96..90cb023c8 100644 --- a/api/api.py +++ b/api/api.py @@ -9,6 +9,8 @@ from pydantic import BaseModel, Field import google.generativeai as genai import asyncio +from collections import defaultdict +import fnmatch # Configure logging from api.logging_config import setup_logging @@ -18,11 +20,167 @@ # Initialize FastAPI app -app = FastAPI( - title="Streaming API", - description="API for streaming chat completions" +app = FastAPI() + +# Configure CORS +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], # In production, specify your frontend domain + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], ) + +# Pydantic models for wiki pages +class WikiPage(BaseModel): + id: str + title: str + content: str + related_pages: List[str] = [] + + +# ============================================================================ +# INTELLIGENT FILE CHUNKING SYSTEM +# ============================================================================ + +def should_exclude_dir(dir_name: str, excluded_patterns: List[str]) -> bool: + """Check if directory should be excluded based on patterns.""" + # Always exclude hidden directories and common build/cache dirs + if dir_name.startswith('.'): + return True + if dir_name in ['__pycache__', 'node_modules', '.venv', 'venv', 'env', + 'image-cache', 'dist', 'build', 'target', 'out']: + return True + + # Check against user-defined patterns + for pattern in excluded_patterns: + pattern_clean = pattern.strip('./').rstrip('/') + if fnmatch.fnmatch(dir_name, pattern_clean): + return True + return False + + +def should_exclude_file(file_name: str, excluded_patterns: List[str]) -> bool: + """Check if file should be excluded based on patterns.""" + # Always exclude hidden files and common files + if file_name.startswith('.') or file_name == '__init__.py' or file_name == '.DS_Store': + return True + + # Check against user-defined patterns + for pattern in excluded_patterns: + if fnmatch.fnmatch(file_name, pattern): + return True + return False + + +def collect_all_files(path: str, config: Dict) -> List[str]: + """ + Collect ALL files from repository respecting include/exclude patterns. + + Args: + path: Root directory path + config: Configuration with excluded_dirs and excluded_files + + Returns: + List of relative file paths + """ + all_files = [] + excluded_dirs = config.get('excluded_dirs', []) + excluded_files = config.get('excluded_files', []) + + logger.info(f"Collecting files from {path}") + logger.info(f"Excluded dirs: {len(excluded_dirs)} patterns") + logger.info(f"Excluded files: {len(excluded_files)} patterns") + + for root, dirs, files in os.walk(path): + # Filter directories in-place + dirs[:] = [d for d in dirs if not should_exclude_dir(d, excluded_dirs)] + + for file in files: + if not should_exclude_file(file, excluded_files): + rel_dir = os.path.relpath(root, path) + rel_file = os.path.join(rel_dir, file) if rel_dir != '.' else file + all_files.append(rel_file) + + logger.info(f"Collected {len(all_files)} files after filtering") + return all_files + + +def group_files_by_directory(files: List[str]) -> Dict[str, List[str]]: + """Group files by their parent directory.""" + by_dir = defaultdict(list) + + for file_path in files: + dir_name = os.path.dirname(file_path) + if not dir_name: + dir_name = "root" + by_dir[dir_name].append(file_path) + + return dict(by_dir) + + +def create_file_chunks(files: List[str], max_files_per_chunk: int = 500) -> List[Dict[str, Any]]: + """ + Create intelligent chunks of files grouped by directory. + + Args: + files: List of all file paths + max_files_per_chunk: Maximum files per chunk + + Returns: + List of chunk dictionaries with metadata + """ + # Group by directory + by_dir = group_files_by_directory(files) + + chunks = [] + current_chunk_files = [] + current_chunk_dirs = [] + + for dir_name, dir_files in sorted(by_dir.items()): + # If adding this directory exceeds limit, save current chunk and start new one + if current_chunk_files and len(current_chunk_files) + len(dir_files) > max_files_per_chunk: + chunks.append({ + 'files': current_chunk_files[:], + 'directories': current_chunk_dirs[:], + 'file_count': len(current_chunk_files) + }) + current_chunk_files = [] + current_chunk_dirs = [] + + current_chunk_files.extend(dir_files) + current_chunk_dirs.append(dir_name) + + # Add final chunk if it has files + if current_chunk_files: + chunks.append({ + 'files': current_chunk_files, + 'directories': current_chunk_dirs, + 'file_count': len(current_chunk_files) + }) + + logger.info(f"Created {len(chunks)} chunks from {len(files)} files") + for i, chunk in enumerate(chunks): + logger.info(f" Chunk {i+1}: {chunk['file_count']} files across {len(chunk['directories'])} directories") + + return chunks + + +def format_chunk_as_tree(chunk: Dict[str, Any]) -> str: + """Format a chunk of files as a tree string.""" + files = chunk['files'] + tree_lines = sorted(files) + + # Add chunk metadata + chunk_info = f"# Chunk contains {len(files)} files from {len(chunk['directories'])} directories\n" + chunk_info += f"# Directories: {', '.join(chunk['directories'][:5])}" + if len(chunk['directories']) > 5: + chunk_info += f" ... and {len(chunk['directories']) - 5} more" + chunk_info += "\n\n" + + return chunk_info + '\n'.join(tree_lines) + # Configure CORS app.add_middleware( CORSMiddleware, @@ -273,8 +431,19 @@ async def export_wiki(request: WikiExportRequest): raise HTTPException(status_code=500, detail=error_msg) @app.get("/local_repo/structure") -async def get_local_repo_structure(path: str = Query(None, description="Path to local repository")): - """Return the file tree and README content for a local repository.""" +async def get_local_repo_structure( + path: str = Query(None, description="Path to local repository"), + chunk_size: int = Query(500, description="Maximum files per chunk"), + return_chunks: bool = Query(False, description="Return chunked structure for large repos") +): + """ + Return the file tree and README content for a local repository. + + Now supports intelligent chunking for large repositories: + - Collects ALL files respecting include/exclude patterns + - Groups files by directory + - Returns chunks if repository is large + """ if not path: return JSONResponse( status_code=400, @@ -288,30 +457,63 @@ async def get_local_repo_structure(path: str = Query(None, description="Path to ) try: - logger.info(f"Processing local repository at: {path}") - file_tree_lines = [] + logger.info(f"Processing local repository at: {path} (chunk_size={chunk_size}, return_chunks={return_chunks})") + + # Load configuration from repo.json + from api.config import load_repo_config + config_data = load_repo_config() + file_filters = config_data.get('file_filters', {}) + + # Collect ALL files respecting patterns + all_files = collect_all_files(path, file_filters) + + # Find README.md (case-insensitive) readme_content = "" - for root, dirs, files in os.walk(path): - # Exclude hidden dirs/files and virtual envs - dirs[:] = [d for d in dirs if not d.startswith('.') and d != '__pycache__' and d != 'node_modules' and d != '.venv'] for file in files: - if file.startswith('.') or file == '__init__.py' or file == '.DS_Store': - continue - rel_dir = os.path.relpath(root, path) - rel_file = os.path.join(rel_dir, file) if rel_dir != '.' else file - file_tree_lines.append(rel_file) - # Find README.md (case-insensitive) if file.lower() == 'readme.md' and not readme_content: try: with open(os.path.join(root, file), 'r', encoding='utf-8') as f: readme_content = f.read() + break except Exception as e: logger.warning(f"Could not read README.md: {str(e)}") - readme_content = "" - - file_tree_str = '\n'.join(sorted(file_tree_lines)) - return {"file_tree": file_tree_str, "readme": readme_content} + if readme_content: + break + + # Decide whether to chunk based on repository size + total_files = len(all_files) + logger.info(f"Total files collected: {total_files}") + + if return_chunks or total_files > chunk_size: + # Create intelligent chunks + chunks = create_file_chunks(all_files, max_files_per_chunk=chunk_size) + + return { + "chunked": True, + "total_files": total_files, + "chunk_count": len(chunks), + "chunks": [ + { + "chunk_id": i, + "file_count": chunk['file_count'], + "directories": chunk['directories'], + "file_tree": format_chunk_as_tree(chunk) + } + for i, chunk in enumerate(chunks) + ], + "readme": readme_content + } + else: + # Small repo, return as single tree + file_tree_str = '\n'.join(sorted(all_files)) + return { + "chunked": False, + "total_files": total_files, + "file_tree": file_tree_str, + "readme": readme_content + } + except Exception as e: logger.error(f"Error processing local repository: {str(e)}") return JSONResponse( diff --git a/api/prompts.py b/api/prompts.py index 61ef0a4d3..fcc41c9e8 100644 --- a/api/prompts.py +++ b/api/prompts.py @@ -1,5 +1,56 @@ """Module containing all prompts used in the DeepWiki project.""" +# System prompt for XML Wiki Structure Generation +WIKI_STRUCTURE_SYSTEM_PROMPT = r""" +You are an expert code analyst tasked with analyzing a repository and creating a structured wiki outline. + +CRITICAL XML FORMATTING INSTRUCTIONS: +- You MUST return ONLY valid XML with NO additional text before or after +- DO NOT wrap the XML in markdown code blocks (no ``` or ```xml) +- DO NOT include any explanation or commentary +- Start directly with and end with +- Ensure all XML tags are properly closed +- Use proper XML escaping for special characters (& < > " ') + +XML STRUCTURE REQUIREMENTS: +- The root element must be +- Include a element for the wiki title +- Include a <description> element for the repository description +- For comprehensive mode: Include a <sections> element containing section hierarchies +- Include a <pages> element containing all wiki pages +- Each page must have: id, title, description, importance, relevant_files, related_pages + +Example XML structure (comprehensive mode): +<wiki_structure> + <title>Repository Wiki + A comprehensive guide + +
+ Overview + + page-1 + +
+
+ + + Introduction + Overview of the project + high + + README.md + + + page-2 + + section-1 + + +
+ +IMPORTANT: Your entire response must be valid XML. Do not include any text outside the tags. +""" + # System prompt for RAG RAG_SYSTEM_PROMPT = r""" You are a code assistant which answers user questions on a Github Repo. diff --git a/api/websocket_wiki.py b/api/websocket_wiki.py index 2a7cce9e3..df5ae79e0 100644 --- a/api/websocket_wiki.py +++ b/api/websocket_wiki.py @@ -69,8 +69,9 @@ async def handle_websocket_chat(websocket: WebSocket): tokens = count_tokens(last_message.content, request.provider == "ollama") logger.info(f"Request size: {tokens} tokens") if tokens > 8000: - logger.warning(f"Request exceeds recommended token limit ({tokens} > 7500)") + logger.warning(f"Request exceeds recommended token limit ({tokens} > 8000)") input_too_large = True + logger.info("Input is large - RAG retrieval will be used to reduce context size") # Create a new RAG instance for this request try: @@ -178,11 +179,19 @@ async def handle_websocket_chat(websocket: WebSocket): # Get the query from the last message query = last_message.content - # Only retrieve documents if input is not too large + # Use RAG retrieval to get relevant context + # RAG is ALWAYS used when we have embedded documents available + # For large inputs (>8K tokens), RAG is ESSENTIAL to reduce context to manageable size + # For small inputs, RAG still helps focus on most relevant content context_text = "" retrieved_documents = None - if not input_too_large: + # Always attempt RAG retrieval when retriever is prepared + logger.info(f"Checking RAG availability: request_rag={request_rag}, type={type(request_rag)}") + use_rag = request_rag is not None + logger.info(f"use_rag={use_rag}") + + if use_rag: try: # If filePath exists, modify the query for RAG to focus on the file rag_query = query @@ -194,8 +203,15 @@ async def handle_websocket_chat(websocket: WebSocket): # Try to perform RAG retrieval try: # This will use the actual RAG implementation + logger.info(f"Calling RAG with query: {rag_query[:100]}...") retrieved_documents = request_rag(rag_query, language=request.language) - + logger.info(f"RAG returned: {type(retrieved_documents)}, length: {len(retrieved_documents) if retrieved_documents else 0}") + + if retrieved_documents and len(retrieved_documents) > 0: + logger.info(f"First result type: {type(retrieved_documents[0])}") + if hasattr(retrieved_documents[0], 'documents'): + logger.info(f"Documents found: {len(retrieved_documents[0].documents)}") + if retrieved_documents and retrieved_documents[0].documents: # Format context for the prompt in a more structured way documents = retrieved_documents[0].documents @@ -243,8 +259,20 @@ async def handle_websocket_chat(websocket: WebSocket): supported_langs = configs["lang_config"]["supported_languages"] language_name = supported_langs.get(language_code, "English") + # Detect if this is a wiki structure generation request + is_structure_generation = "create a wiki structure" in query.lower() or \ + ("analyze this github repository" in query.lower() and + "wiki structure" in query.lower()) + + if is_structure_generation: + logger.info("Detected wiki structure generation request - will use XML format") + # Create system prompt - if is_deep_research: + if is_structure_generation: + # Import the XML structure prompt + from api.prompts import WIKI_STRUCTURE_SYSTEM_PROMPT + system_prompt = WIKI_STRUCTURE_SYSTEM_PROMPT + elif is_deep_research: # Check if this is the first iteration is_first_iteration = research_iteration == 1 @@ -426,10 +454,15 @@ async def handle_websocket_chat(websocket: WebSocket): prompt += f"\n{query}\n\n\nAssistant: " + logger.info(f"About to get model config for provider={request.provider}, model={request.model}") model_config = get_model_config(request.provider, request.model)["model_kwargs"] + logger.info(f"Got model_config: {model_config}") if request.provider == "ollama": prompt += " /no_think" + + import sys + print("===== ENTERING OLLAMA BLOCK =====", file=sys.stderr, flush=True) model = OllamaClient() model_kwargs = { @@ -441,12 +474,21 @@ async def handle_websocket_chat(websocket: WebSocket): "num_ctx": model_config["num_ctx"] } } + print(f"===== model_kwargs CREATED: {model_kwargs} =====", file=sys.stderr, flush=True) + logger.info(f"Created model_kwargs for Ollama: {model_kwargs}") api_kwargs = model.convert_inputs_to_api_kwargs( input=prompt, model_kwargs=model_kwargs, model_type=ModelType.LLM ) + print(f"===== api_kwargs BEFORE fix: {api_kwargs} =====", file=sys.stderr, flush=True) + + # WORKAROUND: Force the model name in api_kwargs as convert_inputs_to_api_kwargs seems to override it + logger.info(f"api_kwargs BEFORE fix: {api_kwargs}") + api_kwargs["model"] = model_config["model"] + print(f"===== api_kwargs AFTER fix: {api_kwargs} =====", file=sys.stderr, flush=True) + logger.info(f"api_kwargs AFTER forcing model to {model_config['model']}: {api_kwargs}") elif request.provider == "openrouter": logger.info(f"Using OpenRouter with model: {request.model}") @@ -544,12 +586,63 @@ async def handle_websocket_chat(websocket: WebSocket): if request.provider == "ollama": # Get the response and handle it properly using the previously created api_kwargs response = await model.acall(api_kwargs=api_kwargs, model_type=ModelType.LLM) - # Handle streaming response from Ollama - async for chunk in response: - text = getattr(chunk, 'response', None) or getattr(chunk, 'text', None) or str(chunk) - if text and not text.startswith('model=') and not text.startswith('created_at='): - text = text.replace('', '').replace('', '') - await websocket.send_text(text) + + # For structure generation, accumulate the full response before sending + if is_structure_generation: + logger.info("Accumulating full response for wiki structure generation") + full_response = "" + chunk_count = 0 + async for chunk in response: + chunk_count += 1 + # Try different ways to extract text from Ollama ChatResponse + text = None + if hasattr(chunk, 'message') and hasattr(chunk.message, 'content'): + text = chunk.message.content + elif hasattr(chunk, 'response'): + text = chunk.response + elif hasattr(chunk, 'text'): + text = chunk.text + else: + text = str(chunk) + + logger.info(f"Chunk {chunk_count}: type={type(chunk)}, text_len={len(text) if text else 0}, starts_with={text[:50] if text else 'None'}") + if text and not text.startswith('model=') and not text.startswith('created_at='): + text = text.replace('', '').replace('', '') + full_response += text + logger.info(f"Added to full_response, new length: {len(full_response)}") + + # Strip markdown code blocks if present + cleaned_response = full_response.strip() + if cleaned_response.startswith('```xml'): + cleaned_response = cleaned_response[6:] # Remove ```xml + elif cleaned_response.startswith('```'): + cleaned_response = cleaned_response[3:] # Remove ``` + if cleaned_response.endswith('```'): + cleaned_response = cleaned_response[:-3] # Remove trailing ``` + cleaned_response = cleaned_response.strip() + + # Send the complete response at once + logger.info(f"Total chunks processed: {chunk_count}, Sending complete XML structure ({len(cleaned_response)} chars)") + logger.info(f"First 500 chars of response: {cleaned_response[:500]}") + await websocket.send_text(cleaned_response) + else: + # Handle streaming response from Ollama for regular chat + async for chunk in response: + # Try different ways to extract text from Ollama ChatResponse + text = None + if hasattr(chunk, 'message') and hasattr(chunk.message, 'content'): + text = chunk.message.content + elif hasattr(chunk, 'response'): + text = chunk.response + elif hasattr(chunk, 'text'): + text = chunk.text + else: + text = str(chunk) + + if text and not text.startswith('model=') and not text.startswith('created_at='): + text = text.replace('', '').replace('', '') + await websocket.send_text(text) + # Explicitly close the WebSocket connection after the response is complete await websocket.close() elif request.provider == "openrouter": @@ -557,9 +650,22 @@ async def handle_websocket_chat(websocket: WebSocket): # Get the response and handle it properly using the previously created api_kwargs logger.info("Making OpenRouter API call") response = await model.acall(api_kwargs=api_kwargs, model_type=ModelType.LLM) - # Handle streaming response from OpenRouter - async for chunk in response: - await websocket.send_text(chunk) + + # For structure generation, accumulate the full response before sending + if is_structure_generation: + logger.info("Accumulating full response for wiki structure generation") + full_response = "" + async for chunk in response: + full_response += chunk + + # Send the complete response at once + logger.info(f"Sending complete XML structure ({len(full_response)} chars)") + await websocket.send_text(full_response) + else: + # Handle streaming response from OpenRouter for regular chat + async for chunk in response: + await websocket.send_text(chunk) + # Explicitly close the WebSocket connection after the response is complete await websocket.close() except Exception as e_openrouter: @@ -767,3 +873,159 @@ async def handle_websocket_chat(websocket: WebSocket): await websocket.close() except: pass + + +# ============================================================================ +# CHUNKED WIKI GENERATION FOR LARGE REPOSITORIES +# ============================================================================ + +async def process_wiki_chunk( + chunk_data: Dict[str, Any], + chunk_id: int, + total_chunks: int, + request: ChatCompletionRequest, + readme_content: str +) -> str: + """ + Process a single chunk of files to generate partial wiki structure. + + Args: + chunk_data: Dictionary with chunk info (files, directories, file_count) + chunk_id: Index of this chunk + total_chunks: Total number of chunks + request: Original chat completion request + readme_content: README content for context + + Returns: + XML string with partial wiki structure for this chunk + """ + logger.info(f"Processing chunk {chunk_id + 1}/{total_chunks} with {chunk_data['file_count']} files") + + # Create focused query for this chunk + chunk_dirs = chunk_data.get('directories', []) + chunk_query = f"""Analyze chunk {chunk_id + 1} of {total_chunks} for this repository. + +This chunk contains {chunk_data['file_count']} files from these directories: {', '.join(chunk_dirs[:10])} + +Generate a partial wiki structure for ONLY the files in this chunk. Focus on: +1. Identifying the purpose of these files/directories +2. Their role in the overall system +3. How they relate to each other + +Return the result in the same XML format, but only include pages relevant to this chunk. + + +{chunk_data.get('file_tree', '')} + + + +{readme_content} +""" + + # TODO: Actually call the model with RAG for this chunk + # For now, return a placeholder + return f"Processing chunk {chunk_id + 1}" + + +def merge_wiki_structures(partial_wikis: List[str]) -> str: + """ + Merge multiple partial wiki structures into a single cohesive wiki. + + Args: + partial_wikis: List of XML strings, each containing partial wiki structure + + Returns: + Combined XML wiki structure + """ + logger.info(f"Merging {len(partial_wikis)} partial wiki structures") + + # TODO: Implement intelligent merging logic: + # 1. Parse each XML partial wiki + # 2. Deduplicate pages + # 3. Merge sections that overlap + # 4. Organize into coherent hierarchy + # 5. Generate final combined XML + + # For now, concatenate placeholders + merged = "\n" + merged += " Merged Wiki\n" + merged += " Combined from multiple chunks\n" + merged += " \n" + for i, partial in enumerate(partial_wikis): + merged += f" \n" + merged += f" {partial}\n" + merged += " \n" + merged += "" + + return merged + + +async def handle_chunked_wiki_generation( + websocket: WebSocket, + repo_path: str, + request: ChatCompletionRequest +) -> None: + """ + Handle wiki generation for large repositories using chunked processing. + + This function: + 1. Fetches repository structure with chunking enabled + 2. Processes each chunk separately with RAG + 3. Merges partial results into final wiki structure + 4. Sends progress updates via WebSocket + + Args: + websocket: Active WebSocket connection + repo_path: Path to the repository + request: Original chat completion request + """ + try: + logger.info(f"Starting chunked wiki generation for {repo_path}") + await websocket.send_text("šŸ”„ Analyzing large repository structure...\n") + + # Fetch repository info with chunking enabled + import httpx + async with httpx.AsyncClient() as client: + response = await client.get( + f"http://localhost:8001/local_repo/structure", + params={"path": repo_path, "return_chunks": True, "chunk_size": 500} + ) + repo_info = response.json() + + if not repo_info.get('chunked'): + # Small repo, process normally + await websocket.send_text("Repository is small enough to process in one go.\n") + return + + chunks = repo_info.get('chunks', []) + readme = repo_info.get('readme', '') + total_files = repo_info.get('total_files', 0) + + await websocket.send_text(f"šŸ“Š Repository has {total_files} files split into {len(chunks)} chunks\n") + await websocket.send_text("šŸ” Processing each chunk with RAG...\n\n") + + partial_wikis = [] + for i, chunk in enumerate(chunks): + await websocket.send_text(f"ā³ Chunk {i + 1}/{len(chunks)}: {chunk['file_count']} files from {len(chunk['directories'])} directories\n") + + # Process this chunk + partial_wiki = await process_wiki_chunk(chunk, i, len(chunks), request, readme) + partial_wikis.append(partial_wiki) + + await websocket.send_text(f"āœ… Completed chunk {i + 1}/{len(chunks)}\n\n") + + await websocket.send_text("šŸ”— Merging all chunks into final wiki structure...\n") + + # Merge all partial wikis + final_wiki = merge_wiki_structures(partial_wikis) + + await websocket.send_text("\nšŸ“ Final wiki structure:\n\n") + await websocket.send_text(final_wiki) + + logger.info("Chunked wiki generation completed successfully") + + except Exception as e: + logger.error(f"Error in chunked wiki generation: {str(e)}") + await websocket.send_text(f"\nāŒ Error: {str(e)}\n") + finally: + await websocket.close() From b0e85565544b204b4d7d5994cffe90dc5803a71b Mon Sep 17 00:00:00 2001 From: Adrian Darian Date: Fri, 10 Oct 2025 23:04:01 -0700 Subject: [PATCH 02/50] Enhance file collection and response handling in WebSocket API - Refactor `collect_all_files` to return README content alongside file paths. - Introduce `handle_response_stream` to streamline response processing for different providers. - Update WebSocket handling to utilize the new response handling function, reducing code duplication. - Improve logging for better traceability during file collection and response streaming. --- api/api.py | 88 ++++--- api/websocket_wiki.py | 565 ++++++++++++++++++++++++++++++++---------- 2 files changed, 493 insertions(+), 160 deletions(-) diff --git a/api/api.py b/api/api.py index 90cb023c8..5683f3bd8 100644 --- a/api/api.py +++ b/api/api.py @@ -14,6 +14,7 @@ # Configure logging from api.logging_config import setup_logging +from api.config import load_repo_config setup_logging() logger = logging.getLogger(__name__) @@ -74,18 +75,20 @@ def should_exclude_file(file_name: str, excluded_patterns: List[str]) -> bool: return False -def collect_all_files(path: str, config: Dict) -> List[str]: +def collect_all_files(path: str, config: Dict) -> tuple[List[str], str]: """ Collect ALL files from repository respecting include/exclude patterns. + Also finds and reads README.md during the same walk. Args: path: Root directory path config: Configuration with excluded_dirs and excluded_files Returns: - List of relative file paths + Tuple of (list of relative file paths, README content string) """ all_files = [] + readme_content = "" excluded_dirs = config.get('excluded_dirs', []) excluded_files = config.get('excluded_files', []) @@ -102,9 +105,18 @@ def collect_all_files(path: str, config: Dict) -> List[str]: rel_dir = os.path.relpath(root, path) rel_file = os.path.join(rel_dir, file) if rel_dir != '.' else file all_files.append(rel_file) + + # Find README.md (case-insensitive) during the same walk + if file.lower() == 'readme.md' and not readme_content: + try: + with open(os.path.join(root, file), 'r', encoding='utf-8') as f: + readme_content = f.read() + logger.info(f"Found README.md at: {rel_file}") + except Exception as e: + logger.warning(f"Could not read README.md at {rel_file}: {str(e)}") logger.info(f"Collected {len(all_files)} files after filtering") - return all_files + return all_files, readme_content def group_files_by_directory(files: List[str]) -> Dict[str, List[str]]: @@ -123,6 +135,7 @@ def group_files_by_directory(files: List[str]) -> Dict[str, List[str]]: def create_file_chunks(files: List[str], max_files_per_chunk: int = 500) -> List[Dict[str, Any]]: """ Create intelligent chunks of files grouped by directory. + Ensures no chunk exceeds max_files_per_chunk by splitting large directories. Args: files: List of all file paths @@ -139,18 +152,42 @@ def create_file_chunks(files: List[str], max_files_per_chunk: int = 500) -> List current_chunk_dirs = [] for dir_name, dir_files in sorted(by_dir.items()): - # If adding this directory exceeds limit, save current chunk and start new one - if current_chunk_files and len(current_chunk_files) + len(dir_files) > max_files_per_chunk: - chunks.append({ - 'files': current_chunk_files[:], - 'directories': current_chunk_dirs[:], - 'file_count': len(current_chunk_files) - }) - current_chunk_files = [] - current_chunk_dirs = [] - - current_chunk_files.extend(dir_files) - current_chunk_dirs.append(dir_name) + # Handle large directories that exceed max_files_per_chunk on their own + if len(dir_files) > max_files_per_chunk: + # First, save current chunk if it has files + if current_chunk_files: + chunks.append({ + 'files': current_chunk_files[:], + 'directories': current_chunk_dirs[:], + 'file_count': len(current_chunk_files) + }) + current_chunk_files = [] + current_chunk_dirs = [] + + # Split large directory across multiple chunks + logger.warning(f"Directory '{dir_name}' has {len(dir_files)} files, splitting across multiple chunks") + for i in range(0, len(dir_files), max_files_per_chunk): + chunk_slice = dir_files[i:i + max_files_per_chunk] + chunks.append({ + 'files': chunk_slice, + 'directories': [f"{dir_name} (part {i//max_files_per_chunk + 1})"], + 'file_count': len(chunk_slice) + }) + else: + # Normal case: check if adding this directory would exceed limit + if current_chunk_files and len(current_chunk_files) + len(dir_files) > max_files_per_chunk: + # Save current chunk and start new one + chunks.append({ + 'files': current_chunk_files[:], + 'directories': current_chunk_dirs[:], + 'file_count': len(current_chunk_files) + }) + current_chunk_files = [] + current_chunk_dirs = [] + + # Add directory to current chunk + current_chunk_files.extend(dir_files) + current_chunk_dirs.append(dir_name) # Add final chunk if it has files if current_chunk_files: @@ -459,27 +496,12 @@ async def get_local_repo_structure( try: logger.info(f"Processing local repository at: {path} (chunk_size={chunk_size}, return_chunks={return_chunks})") - # Load configuration from repo.json - from api.config import load_repo_config + # Load configuration from repo.json (imported at the top) config_data = load_repo_config() file_filters = config_data.get('file_filters', {}) - # Collect ALL files respecting patterns - all_files = collect_all_files(path, file_filters) - - # Find README.md (case-insensitive) - readme_content = "" - for root, dirs, files in os.walk(path): - for file in files: - if file.lower() == 'readme.md' and not readme_content: - try: - with open(os.path.join(root, file), 'r', encoding='utf-8') as f: - readme_content = f.read() - break - except Exception as e: - logger.warning(f"Could not read README.md: {str(e)}") - if readme_content: - break + # Collect ALL files respecting patterns and find README in one pass + all_files, readme_content = collect_all_files(path, file_filters) # Decide whether to chunk based on repository size total_files = len(all_files) diff --git a/api/websocket_wiki.py b/api/websocket_wiki.py index df5ae79e0..906ce0c5b 100644 --- a/api/websocket_wiki.py +++ b/api/websocket_wiki.py @@ -9,12 +9,14 @@ from fastapi import WebSocket, WebSocketDisconnect, HTTPException from pydantic import BaseModel, Field +from api.api import get_local_repo_structure from api.config import get_model_config, configs, OPENROUTER_API_KEY, OPENAI_API_KEY from api.data_pipeline import count_tokens, get_file_content from api.openai_client import OpenAIClient from api.openrouter_client import OpenRouterClient from api.azureai_client import AzureAIClient from api.dashscope_client import DashscopeClient +from api.prompts import WIKI_STRUCTURE_SYSTEM_PROMPT from api.rag import RAG # Configure logging @@ -49,6 +51,135 @@ class ChatCompletionRequest(BaseModel): included_dirs: Optional[str] = Field(None, description="Comma-separated list of directories to include exclusively") included_files: Optional[str] = Field(None, description="Comma-separated list of file patterns to include exclusively") + +async def handle_response_stream( + response, + websocket: WebSocket, + is_structure_generation: bool, + provider: str +) -> None: + """ + Handle streaming or accumulated response based on context. + + This helper function eliminates code duplication between different providers + by handling both streaming (for chat) and accumulating (for wiki structure) modes. + + Args: + response: Async iterator from the model's response + websocket: Active WebSocket connection + is_structure_generation: If True, accumulate full response before sending + provider: Provider name for provider-specific text extraction + """ + if is_structure_generation: + # Accumulate full response for wiki structure generation + logger.info("Accumulating full response for wiki structure generation") + full_response = "" + chunk_count = 0 + + async for chunk in response: + chunk_count += 1 + + # Extract text based on provider and response format + text = None + if provider == "ollama": + # Ollama-specific extraction + if hasattr(chunk, 'message') and hasattr(chunk.message, 'content'): + text = chunk.message.content + elif hasattr(chunk, 'response'): + text = chunk.response + elif hasattr(chunk, 'text'): + text = chunk.text + else: + text = str(chunk) + + logger.info(f"Chunk {chunk_count}: type={type(chunk)}, text_len={len(text) if text else 0}, starts_with={text[:50] if text else 'None'}") + + # Filter out metadata chunks + if text and not text.startswith('model=') and not text.startswith('created_at='): + text = text.replace('', '').replace('', '') + full_response += text + logger.info(f"Added to full_response, new length: {len(full_response)}") + elif provider in ["openai", "azure"]: + # OpenAI/Azure-style responses with choices and delta + choices = getattr(chunk, "choices", []) + if len(choices) > 0: + delta = getattr(choices[0], "delta", None) + if delta is not None: + text = getattr(delta, "content", None) + if text: + full_response += text + else: + # OpenRouter and other providers - simpler text extraction + if isinstance(chunk, str): + text = chunk + elif hasattr(chunk, 'content'): + text = chunk.content + elif hasattr(chunk, 'text'): + text = chunk.text + else: + text = str(chunk) + + if text: + full_response += text + + # Strip markdown code blocks if present + cleaned_response = full_response.strip() + if cleaned_response.startswith('```xml'): + cleaned_response = cleaned_response[6:] # Remove ```xml + elif cleaned_response.startswith('```'): + cleaned_response = cleaned_response[3:] # Remove ``` + if cleaned_response.endswith('```'): + cleaned_response = cleaned_response[:-3] # Remove trailing ``` + cleaned_response = cleaned_response.strip() + + # Send the complete response at once + logger.info(f"Total chunks processed: {chunk_count}, Sending complete XML structure ({len(cleaned_response)} chars)") + logger.info(f"First 500 chars of response: {cleaned_response[:500]}") + await websocket.send_text(cleaned_response) + else: + # Stream response chunks as they arrive for regular chat + async for chunk in response: + # Extract text based on provider + text = None + if provider == "ollama": + # Ollama-specific extraction + if hasattr(chunk, 'message') and hasattr(chunk.message, 'content'): + text = chunk.message.content + elif hasattr(chunk, 'response'): + text = chunk.response + elif hasattr(chunk, 'text'): + text = chunk.text + else: + text = str(chunk) + + # Filter out metadata chunks and remove thinking tags + if text and not text.startswith('model=') and not text.startswith('created_at='): + text = text.replace('', '').replace('', '') + await websocket.send_text(text) + elif provider in ["openai", "azure"]: + # OpenAI/Azure-style responses with choices and delta + choices = getattr(chunk, "choices", []) + if len(choices) > 0: + delta = getattr(choices[0], "delta", None) + if delta is not None: + text = getattr(delta, "content", None) + if text: + await websocket.send_text(text) + else: + # OpenRouter and other providers + if isinstance(chunk, str): + text = chunk + elif hasattr(chunk, 'content'): + text = chunk.content + elif hasattr(chunk, 'text'): + text = chunk.text + else: + text = str(chunk) + + if text: + await websocket.send_text(text) + + async def handle_websocket_chat(websocket: WebSocket): """ Handle WebSocket connection for chat completions. @@ -269,8 +400,7 @@ async def handle_websocket_chat(websocket: WebSocket): # Create system prompt if is_structure_generation: - # Import the XML structure prompt - from api.prompts import WIKI_STRUCTURE_SYSTEM_PROMPT + # Use the XML structure prompt imported at the top system_prompt = WIKI_STRUCTURE_SYSTEM_PROMPT elif is_deep_research: # Check if this is the first iteration @@ -461,8 +591,7 @@ async def handle_websocket_chat(websocket: WebSocket): if request.provider == "ollama": prompt += " /no_think" - import sys - print("===== ENTERING OLLAMA BLOCK =====", file=sys.stderr, flush=True) + logger.debug("Entering Ollama provider block") model = OllamaClient() model_kwargs = { @@ -474,21 +603,18 @@ async def handle_websocket_chat(websocket: WebSocket): "num_ctx": model_config["num_ctx"] } } - print(f"===== model_kwargs CREATED: {model_kwargs} =====", file=sys.stderr, flush=True) - logger.info(f"Created model_kwargs for Ollama: {model_kwargs}") + logger.debug(f"Created model_kwargs for Ollama: {model_kwargs}") api_kwargs = model.convert_inputs_to_api_kwargs( input=prompt, model_kwargs=model_kwargs, model_type=ModelType.LLM ) - print(f"===== api_kwargs BEFORE fix: {api_kwargs} =====", file=sys.stderr, flush=True) + logger.debug(f"api_kwargs before model fix: {api_kwargs}") # WORKAROUND: Force the model name in api_kwargs as convert_inputs_to_api_kwargs seems to override it - logger.info(f"api_kwargs BEFORE fix: {api_kwargs}") api_kwargs["model"] = model_config["model"] - print(f"===== api_kwargs AFTER fix: {api_kwargs} =====", file=sys.stderr, flush=True) - logger.info(f"api_kwargs AFTER forcing model to {model_config['model']}: {api_kwargs}") + logger.debug(f"api_kwargs after forcing model to {model_config['model']}: {api_kwargs}") elif request.provider == "openrouter": logger.info(f"Using OpenRouter with model: {request.model}") @@ -587,61 +713,13 @@ async def handle_websocket_chat(websocket: WebSocket): # Get the response and handle it properly using the previously created api_kwargs response = await model.acall(api_kwargs=api_kwargs, model_type=ModelType.LLM) - # For structure generation, accumulate the full response before sending - if is_structure_generation: - logger.info("Accumulating full response for wiki structure generation") - full_response = "" - chunk_count = 0 - async for chunk in response: - chunk_count += 1 - # Try different ways to extract text from Ollama ChatResponse - text = None - if hasattr(chunk, 'message') and hasattr(chunk.message, 'content'): - text = chunk.message.content - elif hasattr(chunk, 'response'): - text = chunk.response - elif hasattr(chunk, 'text'): - text = chunk.text - else: - text = str(chunk) - - logger.info(f"Chunk {chunk_count}: type={type(chunk)}, text_len={len(text) if text else 0}, starts_with={text[:50] if text else 'None'}") - if text and not text.startswith('model=') and not text.startswith('created_at='): - text = text.replace('', '').replace('', '') - full_response += text - logger.info(f"Added to full_response, new length: {len(full_response)}") - - # Strip markdown code blocks if present - cleaned_response = full_response.strip() - if cleaned_response.startswith('```xml'): - cleaned_response = cleaned_response[6:] # Remove ```xml - elif cleaned_response.startswith('```'): - cleaned_response = cleaned_response[3:] # Remove ``` - if cleaned_response.endswith('```'): - cleaned_response = cleaned_response[:-3] # Remove trailing ``` - cleaned_response = cleaned_response.strip() - - # Send the complete response at once - logger.info(f"Total chunks processed: {chunk_count}, Sending complete XML structure ({len(cleaned_response)} chars)") - logger.info(f"First 500 chars of response: {cleaned_response[:500]}") - await websocket.send_text(cleaned_response) - else: - # Handle streaming response from Ollama for regular chat - async for chunk in response: - # Try different ways to extract text from Ollama ChatResponse - text = None - if hasattr(chunk, 'message') and hasattr(chunk.message, 'content'): - text = chunk.message.content - elif hasattr(chunk, 'response'): - text = chunk.response - elif hasattr(chunk, 'text'): - text = chunk.text - else: - text = str(chunk) - - if text and not text.startswith('model=') and not text.startswith('created_at='): - text = text.replace('', '').replace('', '') - await websocket.send_text(text) + # Use shared helper to handle streaming or accumulation + await handle_response_stream( + response=response, + websocket=websocket, + is_structure_generation=is_structure_generation, + provider="ollama" + ) # Explicitly close the WebSocket connection after the response is complete await websocket.close() @@ -651,20 +729,13 @@ async def handle_websocket_chat(websocket: WebSocket): logger.info("Making OpenRouter API call") response = await model.acall(api_kwargs=api_kwargs, model_type=ModelType.LLM) - # For structure generation, accumulate the full response before sending - if is_structure_generation: - logger.info("Accumulating full response for wiki structure generation") - full_response = "" - async for chunk in response: - full_response += chunk - - # Send the complete response at once - logger.info(f"Sending complete XML structure ({len(full_response)} chars)") - await websocket.send_text(full_response) - else: - # Handle streaming response from OpenRouter for regular chat - async for chunk in response: - await websocket.send_text(chunk) + # Use shared helper to handle streaming or accumulation + await handle_response_stream( + response=response, + websocket=websocket, + is_structure_generation=is_structure_generation, + provider="openrouter" + ) # Explicitly close the WebSocket connection after the response is complete await websocket.close() @@ -679,15 +750,15 @@ async def handle_websocket_chat(websocket: WebSocket): # Get the response and handle it properly using the previously created api_kwargs logger.info("Making Openai API call") response = await model.acall(api_kwargs=api_kwargs, model_type=ModelType.LLM) - # Handle streaming response from Openai - async for chunk in response: - choices = getattr(chunk, "choices", []) - if len(choices) > 0: - delta = getattr(choices[0], "delta", None) - if delta is not None: - text = getattr(delta, "content", None) - if text is not None: - await websocket.send_text(text) + + # Use shared helper to handle streaming or accumulation + await handle_response_stream( + response=response, + websocket=websocket, + is_structure_generation=is_structure_generation, + provider="openai" + ) + # Explicitly close the WebSocket connection after the response is complete await websocket.close() except Exception as e_openai: @@ -701,15 +772,15 @@ async def handle_websocket_chat(websocket: WebSocket): # Get the response and handle it properly using the previously created api_kwargs logger.info("Making Azure AI API call") response = await model.acall(api_kwargs=api_kwargs, model_type=ModelType.LLM) - # Handle streaming response from Azure AI - async for chunk in response: - choices = getattr(chunk, "choices", []) - if len(choices) > 0: - delta = getattr(choices[0], "delta", None) - if delta is not None: - text = getattr(delta, "content", None) - if text is not None: - await websocket.send_text(text) + + # Use shared helper to handle streaming or accumulation + await handle_response_stream( + response=response, + websocket=websocket, + is_structure_generation=is_structure_generation, + provider="azure" + ) + # Explicitly close the WebSocket connection after the response is complete await websocket.close() except Exception as e_azure: @@ -922,9 +993,150 @@ async def process_wiki_chunk( {readme_content} """ - # TODO: Actually call the model with RAG for this chunk - # For now, return a placeholder - return f"Processing chunk {chunk_id + 1}" + try: + # Use RAG to get relevant context for this chunk's files + retrieved_documents = None + if request_rag: + try: + # Create a focused query for RAG retrieval based on chunk directories + rag_query = f"Information about: {', '.join(chunk_dirs[:5])}" + logger.info(f"RAG query for chunk {chunk_id + 1}: {rag_query}") + + retrieved_documents = request_rag(rag_query, language="en") + + if retrieved_documents and retrieved_documents[0].documents: + documents = retrieved_documents[0].documents + logger.info(f"Retrieved {len(documents)} documents for chunk {chunk_id + 1}") + + # Group documents by file path + docs_by_file = {} + for doc in documents: + file_path = doc.meta_data.get('file_path', 'unknown') + if file_path not in docs_by_file: + docs_by_file[file_path] = [] + docs_by_file[file_path].append(doc) + + # Add context to query + context_parts = [] + for file_path, docs in docs_by_file.items(): + header = f"## File Path: {file_path}\n\n" + content = "\n\n".join([doc.text for doc in docs]) + context_parts.append(f"{header}{content}") + + context_text = "\n\n" + "-" * 10 + "\n\n".join(context_parts) + chunk_query = f"\n{context_text}\n\n\n{chunk_query}" + + except Exception as e: + logger.warning(f"RAG retrieval failed for chunk {chunk_id + 1}: {str(e)}") + + # Get model configuration + model_config = get_model_config(request.provider, request.model)["model_kwargs"] + + # Initialize the appropriate model client based on provider + if request.provider == "ollama": + from api.ollama_patch import OllamaClient + model = OllamaClient() + model_kwargs = { + "model": model_config["model"], + "stream": False, # Non-streaming for chunk processing + "options": { + "temperature": model_config["temperature"], + "top_p": model_config["top_p"], + "num_ctx": model_config["num_ctx"] + } + } + api_kwargs = model.convert_inputs_to_api_kwargs( + input=chunk_query, + model_kwargs=model_kwargs, + model_type=ModelType.LLM + ) + api_kwargs["model"] = model_config["model"] + + elif request.provider == "openrouter": + from api.openrouter_client import OpenRouterClient + model = OpenRouterClient() + model_kwargs = { + "model": request.model, + "stream": False, + "temperature": model_config["temperature"] + } + if "top_p" in model_config: + model_kwargs["top_p"] = model_config["top_p"] + api_kwargs = model.convert_inputs_to_api_kwargs( + input=chunk_query, + model_kwargs=model_kwargs, + model_type=ModelType.LLM + ) + + elif request.provider == "openai": + from api.openai_client import OpenAIClient + model = OpenAIClient() + model_kwargs = { + "model": request.model, + "stream": False, + "temperature": model_config["temperature"] + } + if "top_p" in model_config: + model_kwargs["top_p"] = model_config["top_p"] + api_kwargs = model.convert_inputs_to_api_kwargs( + input=chunk_query, + model_kwargs=model_kwargs, + model_type=ModelType.LLM + ) + else: + # Fallback: use OpenAI-compatible endpoint + from api.openai_client import OpenAIClient + model = OpenAIClient() + model_kwargs = { + "model": request.model, + "stream": False, + "temperature": model_config.get("temperature", 0.7) + } + api_kwargs = model.convert_inputs_to_api_kwargs( + input=chunk_query, + model_kwargs=model_kwargs, + model_type=ModelType.LLM + ) + + # Call the model synchronously for chunk processing + import asyncio + response = asyncio.create_task(model.acall(api_kwargs=api_kwargs, model_type=ModelType.LLM)) + result = await response + + # Collect the response + full_response = "" + async for chunk in result: + # Extract text based on response format + if hasattr(chunk, 'message') and hasattr(chunk.message, 'content'): + text = chunk.message.content + elif hasattr(chunk, 'response'): + text = chunk.response + elif hasattr(chunk, 'text'): + text = chunk.text + else: + text = str(chunk) + + if text: + full_response += text + + # Strip markdown code blocks if present + cleaned_response = full_response.strip() + if cleaned_response.startswith('```xml'): + cleaned_response = cleaned_response[6:] + elif cleaned_response.startswith('```'): + cleaned_response = cleaned_response[3:] + if cleaned_response.endswith('```'): + cleaned_response = cleaned_response[:-3] + + logger.info(f"Chunk {chunk_id + 1} processed successfully, response length: {len(cleaned_response)}") + return cleaned_response.strip() + + except Exception as e: + logger.error(f"Error processing chunk {chunk_id + 1}: {str(e)}") + # Return a minimal valid XML structure on error + return f""" + Error processing chunk {chunk_id + 1}: {str(e)} +""" def merge_wiki_structures(partial_wikis: List[str]) -> str: @@ -939,25 +1151,126 @@ def merge_wiki_structures(partial_wikis: List[str]) -> str: """ logger.info(f"Merging {len(partial_wikis)} partial wiki structures") - # TODO: Implement intelligent merging logic: - # 1. Parse each XML partial wiki - # 2. Deduplicate pages - # 3. Merge sections that overlap - # 4. Organize into coherent hierarchy - # 5. Generate final combined XML - - # For now, concatenate placeholders - merged = "\n" - merged += " Merged Wiki\n" - merged += " Combined from multiple chunks\n" - merged += " \n" - for i, partial in enumerate(partial_wikis): - merged += f" \n" - merged += f" {partial}\n" - merged += " \n" - merged += "" - - return merged + try: + from xml.etree import ElementTree as ET + + # Parse all partial wikis + all_pages = [] + all_sections = [] + titles = [] + descriptions = [] + + for i, partial_xml in enumerate(partial_wikis): + try: + # Parse the XML + root = ET.fromstring(partial_xml) + + # Extract title and description + title_elem = root.find('title') + if title_elem is not None and title_elem.text: + titles.append(title_elem.text) + + desc_elem = root.find('description') + if desc_elem is not None and desc_elem.text: + descriptions.append(desc_elem.text) + + # Extract pages + pages_elem = root.find('pages') + if pages_elem is not None: + for page in pages_elem.findall('page'): + # Add chunk information to page + page.set('source_chunk', str(i + 1)) + all_pages.append(page) + + # Extract sections if present + sections_elem = root.find('sections') + if sections_elem is not None: + for section in sections_elem.findall('section'): + section.set('source_chunk', str(i + 1)) + all_sections.append(section) + + except ET.ParseError as e: + logger.error(f"Failed to parse partial wiki {i + 1}: {str(e)}") + continue + + # Deduplicate pages by ID + unique_pages = {} + for page in all_pages: + page_id = page.get('id', f'page-{len(unique_pages) + 1}') + if page_id not in unique_pages: + unique_pages[page_id] = page + else: + # Merge information if duplicate found + logger.debug(f"Duplicate page ID found: {page_id}, keeping first occurrence") + + # Build merged structure + merged_root = ET.Element('wiki_structure') + + # Use first non-empty title or generate one + title_elem = ET.SubElement(merged_root, 'title') + title_elem.text = titles[0] if titles else "Repository Documentation" + + # Combine descriptions + desc_elem = ET.SubElement(merged_root, 'description') + if descriptions: + desc_elem.text = descriptions[0] # Use first description as primary + else: + desc_elem.text = "Comprehensive documentation generated from repository analysis" + + # Add sections if any were found + if all_sections: + sections_container = ET.SubElement(merged_root, 'sections') + for section in all_sections: + sections_container.append(section) + + # Add all unique pages + pages_container = ET.SubElement(merged_root, 'pages') + for page_id, page in unique_pages.items(): + pages_container.append(page) + + # Convert back to string with proper formatting + xml_string = ET.tostring(merged_root, encoding='unicode', method='xml') + + logger.info(f"Successfully merged {len(partial_wikis)} wikis into {len(unique_pages)} unique pages") + return xml_string + + except Exception as e: + logger.error(f"Error merging wiki structures: {str(e)}") + + # Fallback: Simple concatenation with wrapper + merged = "\n" + merged += " Repository Documentation\n" + merged += " Combined documentation from multiple repository sections\n" + merged += " \n" + + # Try to extract individual pages from each partial + page_counter = 1 + for i, partial in enumerate(partial_wikis): + try: + # Simple extraction of page elements + if ']*>.*?', partial, re.DOTALL) + for page in pages: + # Ensure page has an ID + if 'id=' not in page: + page = page.replace('\n' + merged += f' Chunk {i+1} Processing Note\n' + merged += f' Could not merge chunk {i+1} properly\n' + merged += f' low\n' + merged += f' \n' + + merged += " \n" + merged += "" + + return merged async def handle_chunked_wiki_generation( @@ -983,14 +1296,12 @@ async def handle_chunked_wiki_generation( logger.info(f"Starting chunked wiki generation for {repo_path}") await websocket.send_text("šŸ”„ Analyzing large repository structure...\n") - # Fetch repository info with chunking enabled - import httpx - async with httpx.AsyncClient() as client: - response = await client.get( - f"http://localhost:8001/local_repo/structure", - params={"path": repo_path, "return_chunks": True, "chunk_size": 500} - ) - repo_info = response.json() + # Call get_local_repo_structure directly instead of making HTTP request + repo_info = await get_local_repo_structure( + path=repo_path, + return_chunks=True, + chunk_size=500 + ) if not repo_info.get('chunked'): # Small repo, process normally From 7f522439ebf962c5b45f787487c6f6f34bb50980 Mon Sep 17 00:00:00 2001 From: dariana_roche Date: Tue, 14 Oct 2025 01:35:38 -0700 Subject: [PATCH 03/50] feat: Implement AST-based chunking for enhanced code understanding - Added ASTChunker class for semantic chunking of code files. - Integrated AST chunking with existing adalflow pipeline via ASTTextSplitter. - Created configuration for AST chunking in embedder.ast.json. - Updated data pipeline to support AST chunking based on configuration. - Developed enable_ast.py script to toggle AST chunking on and off. - Enhanced logging for chunking statistics and errors. - Added support for various programming languages in AST chunking. - Updated docker-compose to allow enabling AST chunking during build. --- Dockerfile | 11 + api/ast_chunker.py | 528 +++++++++++++++++++++++++++++++++++ api/ast_integration.py | 301 ++++++++++++++++++++ api/config/embedder.ast.json | 48 ++++ api/data_pipeline.py | 16 +- api/enable_ast.py | 123 ++++++++ api/enhanced_pipeline.py | 324 +++++++++++++++++++++ docker-compose.yml | 4 + 8 files changed, 1354 insertions(+), 1 deletion(-) create mode 100644 api/ast_chunker.py create mode 100644 api/ast_integration.py create mode 100644 api/config/embedder.ast.json create mode 100644 api/enable_ast.py create mode 100644 api/enhanced_pipeline.py diff --git a/Dockerfile b/Dockerfile index ab818ef54..6b384e8d0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,6 +2,8 @@ # Build argument for custom certificates directory ARG CUSTOM_CERT_DIR="certs" +# Build argument for AST chunking (default to false) +ARG AST_CHUNKING=false FROM node:20-alpine3.22 AS node_base @@ -67,6 +69,15 @@ ENV PATH="/opt/venv/bin:$PATH" COPY --from=py_deps /opt/venv /opt/venv COPY api/ ./api/ +# Configure AST chunking based on build argument +RUN if [ "$AST_CHUNKING" = "true" ]; then \ + echo "šŸš€ Enabling AST chunking during build..."; \ + cd /app/api && python enable_ast.py enable; \ + else \ + echo "šŸ“ Using default text chunking..."; \ + cd /app/api && python enable_ast.py disable; \ + fi + # Copy Node app COPY --from=node_builder /app/public ./public COPY --from=node_builder /app/.next/standalone ./ diff --git a/api/ast_chunker.py b/api/ast_chunker.py new file mode 100644 index 000000000..eb78023b2 --- /dev/null +++ b/api/ast_chunker.py @@ -0,0 +1,528 @@ +""" +AST-based chunking for code files using LlamaIndex-inspired approach. +This provides semantic chunking that respects code structure. +""" + +import ast +import logging +from typing import List, Dict, Any, Optional, Union +from pathlib import Path +from dataclasses import dataclass + +# Use a simple token counter if tiktoken is not available +try: + import tiktoken + TIKTOKEN_AVAILABLE = True +except ImportError: + TIKTOKEN_AVAILABLE = False + +logger = logging.getLogger(__name__) + +@dataclass +class CodeChunk: + """Represents a semantically meaningful chunk of code.""" + content: str + chunk_type: str # 'function', 'class', 'module', 'import_block', 'comment_block' + name: Optional[str] = None # function/class name + start_line: int = 0 + end_line: int = 0 + file_path: str = "" + dependencies: List[str] = None # imported modules/functions this chunk depends on + + def __post_init__(self): + if self.dependencies is None: + self.dependencies = [] + + +class ASTChunker: + """AST-based code chunker that creates semantically meaningful chunks.""" + + def __init__(self, + max_chunk_size: int = 2000, + min_chunk_size: int = 100, + overlap_lines: int = 5, + preserve_structure: bool = True): + """ + Initialize AST chunker. + + Args: + max_chunk_size: Maximum tokens per chunk + min_chunk_size: Minimum tokens per chunk + overlap_lines: Lines of overlap between chunks + preserve_structure: Whether to keep related code together + """ + self.max_chunk_size = max_chunk_size + self.min_chunk_size = min_chunk_size + self.overlap_lines = overlap_lines + self.preserve_structure = preserve_structure + + # Initialize token encoder with fallback + if TIKTOKEN_AVAILABLE: + self.encoding = tiktoken.get_encoding("cl100k_base") + else: + # Simple fallback: approximate 4 chars per token + self.encoding = None + + def chunk_file(self, file_path: str, content: str) -> List[CodeChunk]: + """Chunk a single file based on its type and structure.""" + file_ext = Path(file_path).suffix.lower() + + # Route to appropriate chunker based on file type + if file_ext == '.py': + return self._chunk_python(file_path, content) + elif file_ext in ['.js', '.ts', '.jsx', '.tsx']: + return self._chunk_javascript(file_path, content) + elif file_ext in ['.java', '.kt']: + return self._chunk_java_kotlin(file_path, content) + elif file_ext in ['.cpp', '.cc', '.cxx', '.c', '.h', '.hpp']: + return self._chunk_cpp(file_path, content) + elif file_ext in ['.rs']: + return self._chunk_rust(file_path, content) + elif file_ext in ['.go']: + return self._chunk_go(file_path, content) + elif file_ext in ['.md', '.rst', '.txt']: + return self._chunk_markdown(file_path, content) + elif file_ext in ['.json', '.yaml', '.yml', '.toml']: + return self._chunk_config(file_path, content) + else: + # Fall back to text-based chunking + return self._chunk_text(file_path, content) + + def _chunk_python(self, file_path: str, content: str) -> List[CodeChunk]: + """Chunk Python code using AST analysis.""" + chunks = [] + + try: + tree = ast.parse(content) + lines = content.split('\n') + + # Group imports at the top + imports = [] + other_nodes = [] + + for node in ast.walk(tree): + if isinstance(node, (ast.Import, ast.ImportFrom)): + imports.append(node) + elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): + other_nodes.append(node) + + # Create import chunk if imports exist + if imports: + import_lines = [] + for imp in imports: + if hasattr(imp, 'lineno'): + import_lines.extend(range(imp.lineno - 1, + getattr(imp, 'end_lineno', imp.lineno))) + + if import_lines: + import_content = '\n'.join(lines[min(import_lines):max(import_lines)+1]) + chunks.append(CodeChunk( + content=import_content, + chunk_type='import_block', + start_line=min(import_lines) + 1, + end_line=max(import_lines) + 1, + file_path=file_path + )) + + # Process classes and functions + for node in other_nodes: + if isinstance(node, ast.ClassDef): + chunk = self._extract_class_chunk(node, lines, file_path) + if chunk: + chunks.append(chunk) + elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + chunk = self._extract_function_chunk(node, lines, file_path) + if chunk: + chunks.append(chunk) + + # Handle module-level code + module_code = self._extract_module_level_code(tree, lines, file_path) + if module_code: + chunks.extend(module_code) + + except SyntaxError as e: + logger.warning(f"Could not parse Python file {file_path}: {e}") + # Fall back to text chunking + return self._chunk_text(file_path, content) + + return self._optimize_chunks(chunks) + + def _extract_class_chunk(self, node: ast.ClassDef, + lines: List[str], file_path: str) -> Optional[CodeChunk]: + """Extract a class and its methods as a chunk.""" + start_line = node.lineno - 1 + end_line = getattr(node, 'end_lineno', node.lineno) - 1 + + class_content = '\n'.join(lines[start_line:end_line + 1]) + + # Check if chunk is too large, split methods if needed + token_count = self._count_tokens(class_content) + + if token_count > self.max_chunk_size: + # Split into method chunks + method_chunks = [] + for item in node.body: + if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)): + method_chunk = self._extract_function_chunk(item, lines, file_path) + if method_chunk: + method_chunks.append(method_chunk) + return method_chunks if method_chunks else None + + # Extract dependencies (imports used in class) + dependencies = self._extract_dependencies(node) + + return CodeChunk( + content=class_content, + chunk_type='class', + name=node.name, + start_line=start_line + 1, + end_line=end_line + 1, + file_path=file_path, + dependencies=dependencies + ) + + def _extract_function_chunk(self, node: Union[ast.FunctionDef, ast.AsyncFunctionDef], + lines: List[str], file_path: str) -> Optional[CodeChunk]: + """Extract a function as a chunk.""" + start_line = node.lineno - 1 + end_line = getattr(node, 'end_lineno', node.lineno) - 1 + + function_content = '\n'.join(lines[start_line:end_line + 1]) + + # Extract dependencies + dependencies = self._extract_dependencies(node) + + return CodeChunk( + content=function_content, + chunk_type='function', + name=node.name, + start_line=start_line + 1, + end_line=end_line + 1, + file_path=file_path, + dependencies=dependencies + ) + + def _extract_dependencies(self, node) -> List[str]: + """Extract dependencies (imported names) used in this node.""" + dependencies = [] + + for child in ast.walk(node): + if isinstance(child, ast.Name): + dependencies.append(child.id) + elif isinstance(child, ast.Attribute): + # Handle module.function calls + if isinstance(child.value, ast.Name): + dependencies.append(f"{child.value.id}.{child.attr}") + + return list(set(dependencies)) # Remove duplicates + + def _extract_module_level_code(self, tree: ast.AST, + lines: List[str], file_path: str) -> List[CodeChunk]: + """Extract module-level code that's not in classes or functions.""" + chunks = [] + + # Find lines not covered by classes/functions + covered_lines = set() + + for node in ast.walk(tree): + if isinstance(node, (ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef, + ast.Import, ast.ImportFrom)): + start = node.lineno - 1 + end = getattr(node, 'end_lineno', node.lineno) - 1 + covered_lines.update(range(start, end + 1)) + + # Group uncovered lines into chunks + uncovered_lines = [] + for i, line in enumerate(lines): + if i not in covered_lines and line.strip(): + uncovered_lines.append((i, line)) + + if uncovered_lines: + # Group consecutive lines + current_chunk_lines = [] + current_start = None + + for line_num, line in uncovered_lines: + if current_start is None: + current_start = line_num + current_chunk_lines = [line] + elif line_num == current_start + len(current_chunk_lines): + current_chunk_lines.append(line) + else: + # Gap found, save current chunk and start new one + if current_chunk_lines: + chunk_content = '\n'.join(current_chunk_lines) + if self._count_tokens(chunk_content) > self.min_chunk_size: + chunks.append(CodeChunk( + content=chunk_content, + chunk_type='module', + start_line=current_start + 1, + end_line=current_start + len(current_chunk_lines), + file_path=file_path + )) + + current_start = line_num + current_chunk_lines = [line] + + # Add final chunk + if current_chunk_lines: + chunk_content = '\n'.join(current_chunk_lines) + if self._count_tokens(chunk_content) > self.min_chunk_size: + chunks.append(CodeChunk( + content=chunk_content, + chunk_type='module', + start_line=current_start + 1, + end_line=current_start + len(current_chunk_lines), + file_path=file_path + )) + + return chunks + + def _chunk_javascript(self, file_path: str, content: str) -> List[CodeChunk]: + """Chunk JavaScript/TypeScript using regex patterns (simplified AST).""" + # This is a simplified implementation + # For production, you'd want to use a proper JS/TS parser like babel or esprima + chunks = [] + lines = content.split('\n') + + # Find function and class boundaries using regex + import re + + function_pattern = r'^(export\s+)?(async\s+)?function\s+(\w+)|^(export\s+)?const\s+(\w+)\s*=\s*(async\s+)?\(' + class_pattern = r'^(export\s+)?class\s+(\w+)' + + current_chunk = [] + current_type = 'module' + current_name = None + brace_count = 0 + in_function = False + + for i, line in enumerate(lines): + if re.match(function_pattern, line.strip()): + # Start of function + if current_chunk: + chunks.append(self._create_js_chunk( + current_chunk, current_type, current_name, file_path + )) + current_chunk = [line] + current_type = 'function' + match = re.match(function_pattern, line.strip()) + current_name = match.group(3) or match.group(5) + brace_count = line.count('{') - line.count('}') + in_function = True + elif re.match(class_pattern, line.strip()): + # Start of class + if current_chunk: + chunks.append(self._create_js_chunk( + current_chunk, current_type, current_name, file_path + )) + current_chunk = [line] + current_type = 'class' + match = re.match(class_pattern, line.strip()) + current_name = match.group(2) + brace_count = line.count('{') - line.count('}') + in_function = True + else: + current_chunk.append(line) + if in_function: + brace_count += line.count('{') - line.count('}') + if brace_count <= 0: + # End of function/class + chunks.append(self._create_js_chunk( + current_chunk, current_type, current_name, file_path + )) + current_chunk = [] + current_type = 'module' + current_name = None + in_function = False + + # Add remaining content + if current_chunk: + chunks.append(self._create_js_chunk( + current_chunk, current_type, current_name, file_path + )) + + return self._optimize_chunks(chunks) + + def _create_js_chunk(self, lines: List[str], chunk_type: str, + name: Optional[str], file_path: str) -> CodeChunk: + """Create a JavaScript chunk from lines.""" + content = '\n'.join(lines) + return CodeChunk( + content=content, + chunk_type=chunk_type, + name=name, + file_path=file_path + ) + + def _chunk_markdown(self, file_path: str, content: str) -> List[CodeChunk]: + """Chunk Markdown by headers and sections.""" + chunks = [] + lines = content.split('\n') + + current_chunk = [] + current_header = None + header_level = 0 + + for line in lines: + if line.startswith('#'): + # Header found + if current_chunk: + chunks.append(CodeChunk( + content='\n'.join(current_chunk), + chunk_type='section', + name=current_header, + file_path=file_path + )) + + current_header = line.strip('#').strip() + header_level = len(line) - len(line.lstrip('#')) + current_chunk = [line] + else: + current_chunk.append(line) + + # Add final chunk + if current_chunk: + chunks.append(CodeChunk( + content='\n'.join(current_chunk), + chunk_type='section', + name=current_header, + file_path=file_path + )) + + return chunks + + def _chunk_config(self, file_path: str, content: str) -> List[CodeChunk]: + """Chunk configuration files as single units (they're usually coherent).""" + return [CodeChunk( + content=content, + chunk_type='config', + name=Path(file_path).name, + file_path=file_path + )] + + def _chunk_text(self, file_path: str, content: str) -> List[CodeChunk]: + """Fall back to simple text chunking.""" + chunks = [] + lines = content.split('\n') + + current_chunk = [] + current_tokens = 0 + + for line in lines: + line_tokens = self._count_tokens(line) + + if current_tokens + line_tokens > self.max_chunk_size and current_chunk: + chunks.append(CodeChunk( + content='\n'.join(current_chunk), + chunk_type='text', + file_path=file_path + )) + current_chunk = [] + current_tokens = 0 + + current_chunk.append(line) + current_tokens += line_tokens + + if current_chunk: + chunks.append(CodeChunk( + content='\n'.join(current_chunk), + chunk_type='text', + file_path=file_path + )) + + return chunks + + def _optimize_chunks(self, chunks: List[CodeChunk]) -> List[CodeChunk]: + """Optimize chunks by merging small ones and splitting large ones.""" + optimized = [] + + for chunk in chunks: + token_count = self._count_tokens(chunk.content) + + if token_count > self.max_chunk_size: + # Split large chunk + split_chunks = self._split_large_chunk(chunk) + optimized.extend(split_chunks) + elif token_count < self.min_chunk_size and optimized: + # Merge with previous chunk if it won't exceed max size + prev_chunk = optimized[-1] + prev_tokens = self._count_tokens(prev_chunk.content) + + if prev_tokens + token_count <= self.max_chunk_size: + # Merge chunks + merged_content = f"{prev_chunk.content}\n\n{chunk.content}" + optimized[-1] = CodeChunk( + content=merged_content, + chunk_type='merged', + file_path=chunk.file_path, + dependencies=list(set(prev_chunk.dependencies + chunk.dependencies)) + ) + else: + optimized.append(chunk) + else: + optimized.append(chunk) + + return optimized + + def _split_large_chunk(self, chunk: CodeChunk) -> List[CodeChunk]: + """Split a chunk that's too large.""" + lines = chunk.content.split('\n') + split_chunks = [] + + current_lines = [] + current_tokens = 0 + + for line in lines: + line_tokens = self._count_tokens(line) + + if current_tokens + line_tokens > self.max_chunk_size and current_lines: + split_chunks.append(CodeChunk( + content='\n'.join(current_lines), + chunk_type=f"{chunk.chunk_type}_split", + name=f"{chunk.name}_part_{len(split_chunks) + 1}" if chunk.name else None, + file_path=chunk.file_path, + dependencies=chunk.dependencies + )) + current_lines = [] + current_tokens = 0 + + current_lines.append(line) + current_tokens += line_tokens + + if current_lines: + split_chunks.append(CodeChunk( + content='\n'.join(current_lines), + chunk_type=f"{chunk.chunk_type}_split", + name=f"{chunk.name}_part_{len(split_chunks) + 1}" if chunk.name else None, + file_path=chunk.file_path, + dependencies=chunk.dependencies + )) + + return split_chunks + + def _count_tokens(self, text: str) -> int: + """Count tokens in text with fallback for when tiktoken is not available.""" + if TIKTOKEN_AVAILABLE and self.encoding: + return len(self.encoding.encode(text)) + else: + # Simple approximation: 4 characters per token + return len(text) // 4 + + # Placeholder methods for other languages + def _chunk_java_kotlin(self, file_path: str, content: str) -> List[CodeChunk]: + """Chunk Java/Kotlin code.""" + # Simplified implementation - in production, use proper parsers + return self._chunk_text(file_path, content) + + def _chunk_cpp(self, file_path: str, content: str) -> List[CodeChunk]: + """Chunk C++ code.""" + return self._chunk_text(file_path, content) + + def _chunk_rust(self, file_path: str, content: str) -> List[CodeChunk]: + """Chunk Rust code.""" + return self._chunk_text(file_path, content) + + def _chunk_go(self, file_path: str, content: str) -> List[CodeChunk]: + """Chunk Go code.""" + return self._chunk_text(file_path, content) \ No newline at end of file diff --git a/api/ast_integration.py b/api/ast_integration.py new file mode 100644 index 000000000..7b9c53042 --- /dev/null +++ b/api/ast_integration.py @@ -0,0 +1,301 @@ +""" +Integration layer for AST chunking with existing data pipeline. +This bridges the AST chunker with the current adalflow TextSplitter interface. +""" + +from typing import List, Dict, Any, Union +import logging +from pathlib import Path + +from adalflow.core.component import Component +from adalflow.core.document import Document + +from .ast_chunker import ASTChunker, CodeChunk + +logger = logging.getLogger(__name__) + + +class ASTTextSplitter(Component): + """ + AST-aware text splitter that integrates with adalflow pipeline. + Provides both AST chunking for code files and fallback text chunking. + """ + + def __init__(self, + split_by: str = "ast", + chunk_size: int = 2000, + chunk_overlap: int = 100, + min_chunk_size: int = 100, + overlap_lines: int = 5, + preserve_structure: bool = True, + fallback_to_text: bool = True): + """ + Initialize AST text splitter. + + Args: + split_by: Splitting strategy - "ast", "word", "character" + chunk_size: Maximum tokens per chunk + chunk_overlap: Token overlap between chunks (for text mode) + min_chunk_size: Minimum tokens per chunk + overlap_lines: Lines of overlap for AST chunks + preserve_structure: Whether to keep code structures together + fallback_to_text: Fall back to text splitting for non-code files + """ + super().__init__() + self.split_by = split_by + self.chunk_size = chunk_size + self.chunk_overlap = chunk_overlap + self.min_chunk_size = min_chunk_size + self.overlap_lines = overlap_lines + self.preserve_structure = preserve_structure + self.fallback_to_text = fallback_to_text + + # Initialize AST chunker + self.ast_chunker = ASTChunker( + max_chunk_size=chunk_size, + min_chunk_size=min_chunk_size, + overlap_lines=overlap_lines, + preserve_structure=preserve_structure + ) + + # Initialize text chunker for fallback + if fallback_to_text: + from adalflow.core.text_splitter import TextSplitter + self.text_splitter = TextSplitter( + split_by="word" if split_by == "ast" else split_by, + chunk_size=chunk_size, + chunk_overlap=chunk_overlap + ) + + def call(self, documents: List[Document]) -> List[Document]: + """ + Split documents using AST-aware chunking. + + Args: + documents: Input documents to split + + Returns: + List of document chunks + """ + result_docs = [] + + for doc in documents: + try: + if self.split_by == "ast": + chunks = self._ast_split_document(doc) + else: + # Use traditional text splitting + chunks = self.text_splitter.call([doc]) + + result_docs.extend(chunks) + + except Exception as e: + logger.error(f"Error splitting document {doc.id}: {e}") + if self.fallback_to_text and hasattr(self, 'text_splitter'): + # Fallback to text splitting + chunks = self.text_splitter.call([doc]) + result_docs.extend(chunks) + else: + # Keep original document if all else fails + result_docs.append(doc) + + return result_docs + + def _ast_split_document(self, document: Document) -> List[Document]: + """Split a single document using AST chunking.""" + # Extract file path from document metadata + file_path = document.meta_data.get('file_path', '') + if not file_path: + file_path = document.meta_data.get('source', 'unknown') + + # Use AST chunker + code_chunks = self.ast_chunker.chunk_file(file_path, document.text) + + # Convert CodeChunk objects to Document objects + result_docs = [] + for i, chunk in enumerate(code_chunks): + # Create enhanced metadata + enhanced_metadata = document.meta_data.copy() + enhanced_metadata.update({ + 'chunk_id': i, + 'chunk_type': chunk.chunk_type, + 'chunk_name': chunk.name, + 'start_line': chunk.start_line, + 'end_line': chunk.end_line, + 'dependencies': chunk.dependencies, + 'file_path': chunk.file_path, + 'original_doc_id': document.id + }) + + # Create new document + chunk_doc = Document( + text=chunk.content, + id=f"{document.id}_chunk_{i}", + meta_data=enhanced_metadata + ) + + result_docs.append(chunk_doc) + + return result_docs + + def get_chunk_metadata(self, chunk_doc: Document) -> Dict[str, Any]: + """Extract chunk-specific metadata for enhanced retrieval.""" + return { + 'chunk_type': chunk_doc.meta_data.get('chunk_type', 'unknown'), + 'chunk_name': chunk_doc.meta_data.get('chunk_name'), + 'start_line': chunk_doc.meta_data.get('start_line', 0), + 'end_line': chunk_doc.meta_data.get('end_line', 0), + 'dependencies': chunk_doc.meta_data.get('dependencies', []), + 'file_path': chunk_doc.meta_data.get('file_path', ''), + 'language': self._detect_language(chunk_doc.meta_data.get('file_path', '')) + } + + def _detect_language(self, file_path: str) -> str: + """Detect programming language from file extension.""" + ext = Path(file_path).suffix.lower() + + language_map = { + '.py': 'python', + '.js': 'javascript', + '.ts': 'typescript', + '.jsx': 'javascript', + '.tsx': 'typescript', + '.java': 'java', + '.kt': 'kotlin', + '.cpp': 'cpp', + '.cc': 'cpp', + '.cxx': 'cpp', + '.c': 'c', + '.h': 'c', + '.hpp': 'cpp', + '.rs': 'rust', + '.go': 'go', + '.rb': 'ruby', + '.php': 'php', + '.cs': 'csharp', + '.swift': 'swift', + '.md': 'markdown', + '.rst': 'restructuredtext', + '.json': 'json', + '.yaml': 'yaml', + '.yml': 'yaml', + '.toml': 'toml', + '.xml': 'xml', + '.html': 'html', + '.css': 'css', + '.scss': 'scss', + '.sass': 'sass' + } + + return language_map.get(ext, 'text') + + +class EnhancedRAGRetriever: + """Enhanced retriever that uses AST chunk metadata for better results.""" + + def __init__(self, base_retriever): + """Initialize with base retriever (FAISS, etc.).""" + self.base_retriever = base_retriever + + def retrieve(self, query: str, top_k: int = 5, + filter_by_type: List[str] = None, + prefer_functions: bool = False, + prefer_classes: bool = False) -> List[Document]: + """ + Enhanced retrieval with AST-aware filtering. + + Args: + query: Search query + top_k: Number of results to return + filter_by_type: Filter by chunk types (e.g., ['function', 'class']) + prefer_functions: Boost function chunks in results + prefer_classes: Boost class chunks in results + """ + # Get base results + base_results = self.base_retriever.retrieve(query, top_k * 2) # Get more for filtering + + # Apply AST-aware filtering and ranking + filtered_results = [] + + for doc in base_results: + chunk_type = doc.meta_data.get('chunk_type', 'unknown') + + # Apply type filter + if filter_by_type and chunk_type not in filter_by_type: + continue + + # Calculate boost score + boost_score = 1.0 + if prefer_functions and chunk_type == 'function': + boost_score = 1.5 + elif prefer_classes and chunk_type == 'class': + boost_score = 1.5 + + # Add boost to similarity score if available + if hasattr(doc, 'similarity_score'): + doc.similarity_score *= boost_score + + filtered_results.append(doc) + + # Sort by similarity score and return top_k + if hasattr(filtered_results[0], 'similarity_score'): + filtered_results.sort(key=lambda x: x.similarity_score, reverse=True) + + return filtered_results[:top_k] + + def retrieve_related_code(self, query: str, top_k: int = 5) -> Dict[str, List[Document]]: + """ + Retrieve related code organized by type. + + Returns: + Dictionary with keys: 'functions', 'classes', 'imports', 'modules' + """ + all_results = self.base_retriever.retrieve(query, top_k * 4) + + organized_results = { + 'functions': [], + 'classes': [], + 'imports': [], + 'modules': [], + 'other': [] + } + + for doc in all_results: + chunk_type = doc.meta_data.get('chunk_type', 'other') + + if chunk_type == 'function': + organized_results['functions'].append(doc) + elif chunk_type == 'class': + organized_results['classes'].append(doc) + elif chunk_type == 'import_block': + organized_results['imports'].append(doc) + elif chunk_type == 'module': + organized_results['modules'].append(doc) + else: + organized_results['other'].append(doc) + + # Limit each category + for key in organized_results: + organized_results[key] = organized_results[key][:top_k//4 + 1] + + return organized_results + + +def create_ast_config() -> Dict[str, Any]: + """Create default AST chunking configuration.""" + return { + "text_splitter": { + "split_by": "ast", + "chunk_size": 2000, + "chunk_overlap": 100, + "min_chunk_size": 100, + "overlap_lines": 5, + "preserve_structure": True, + "fallback_to_text": True + }, + "retrieval": { + "prefer_functions": True, + "prefer_classes": True, + "boost_code_chunks": 1.5 + } + } \ No newline at end of file diff --git a/api/config/embedder.ast.json b/api/config/embedder.ast.json new file mode 100644 index 000000000..050992cf3 --- /dev/null +++ b/api/config/embedder.ast.json @@ -0,0 +1,48 @@ +{ + "text_splitter": { + "split_by": "ast", + "chunk_size": 2000, + "chunk_overlap": 100, + "min_chunk_size": 100, + "overlap_lines": 5, + "preserve_structure": true, + "fallback_to_text": true + }, + "retrieval": { + "prefer_functions": true, + "prefer_classes": true, + "boost_code_chunks": 1.5 + }, + "language_support": { + "python": { + "enabled": true, + "max_function_size": 1500, + "max_class_size": 3000, + "preserve_docstrings": true + }, + "javascript": { + "enabled": true, + "max_function_size": 1500, + "parse_jsx": true + }, + "typescript": { + "enabled": true, + "max_function_size": 1500, + "parse_tsx": true + }, + "markdown": { + "enabled": true, + "split_by_headers": true, + "max_section_size": 2000 + }, + "config_files": { + "enabled": true, + "treat_as_single_chunk": true + } + }, + "fallback": { + "split_by": "word", + "chunk_size": 350, + "chunk_overlap": 100 + } +} \ No newline at end of file diff --git a/api/data_pipeline.py b/api/data_pipeline.py index fcea34ced..81ce9c6b2 100644 --- a/api/data_pipeline.py +++ b/api/data_pipeline.py @@ -393,7 +393,21 @@ def prepare_data_pipeline(embedder_type: str = None, is_ollama_embedder: bool = if embedder_type is None: embedder_type = get_embedder_type() - splitter = TextSplitter(**configs["text_splitter"]) + # Choose splitter based on configuration + split_by = configs.get("text_splitter", {}).get("split_by", "word") + if split_by == "ast": + # Use AST splitter for better code understanding + try: + from .ast_integration import ASTTextSplitter + splitter = ASTTextSplitter(**configs["text_splitter"]) + print("šŸš€ Using AST-based chunking for better code understanding") + except ImportError as e: + print(f"āš ļø AST chunking not available, falling back to text: {e}") + splitter = TextSplitter(**configs["text_splitter"]) + else: + # Use traditional text splitter + splitter = TextSplitter(**configs["text_splitter"]) + embedder_config = get_embedder_config() embedder = get_embedder(embedder_type=embedder_type) diff --git a/api/enable_ast.py b/api/enable_ast.py new file mode 100644 index 000000000..7f9d9d760 --- /dev/null +++ b/api/enable_ast.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python3 +""" +Simple script to enable/disable AST chunking in DeepWiki. +""" + +import json +import shutil +import os +import sys + +def enable_ast_chunking(): + """Enable AST-based chunking.""" + embedder_config = "config/embedder.json" + ast_config = "config/embedder.ast.json" + backup_config = "config/embedder.json.backup" + + # Check if AST config exists + if not os.path.exists(ast_config): + print(f"āŒ AST config not found: {ast_config}") + return False + + # Backup current config + if os.path.exists(embedder_config): + shutil.copy2(embedder_config, backup_config) + print(f"āœ… Backed up current config to {backup_config}") + + # Copy AST config to active config + shutil.copy2(ast_config, embedder_config) + print(f"āœ… Enabled AST chunking (copied {ast_config} → {embedder_config})") + + # Verify the switch + with open(embedder_config, 'r') as f: + config = json.load(f) + split_by = config.get('text_splitter', {}).get('split_by', 'unknown') + print(f"āœ… Current chunking mode: {split_by}") + + return True + +def disable_ast_chunking(): + """Disable AST chunking and restore previous config.""" + embedder_config = "config/embedder.json" + backup_config = "config/embedder.json.backup" + + if os.path.exists(backup_config): + shutil.copy2(backup_config, embedder_config) + print(f"āœ… Restored previous config from {backup_config}") + else: + # Create default text config + default_config = { + "embedder_ollama": { + "client_class": "OllamaClient", + "model_kwargs": { + "model": "nomic-embed-text" + } + }, + "retriever": { + "top_k": 20 + }, + "text_splitter": { + "split_by": "word", + "chunk_size": 350, + "chunk_overlap": 100 + } + } + + with open(embedder_config, 'w') as f: + json.dump(default_config, f, indent=2) + + print(f"āœ… Created default text chunking config") + + # Verify the switch + with open(embedder_config, 'r') as f: + config = json.load(f) + split_by = config.get('text_splitter', {}).get('split_by', 'unknown') + print(f"āœ… Current chunking mode: {split_by}") + +def check_status(): + """Check current chunking status.""" + embedder_config = "config/embedder.json" + + if not os.path.exists(embedder_config): + print("āŒ No embedder config found") + return + + with open(embedder_config, 'r') as f: + config = json.load(f) + split_by = config.get('text_splitter', {}).get('split_by', 'word') + chunk_size = config.get('text_splitter', {}).get('chunk_size', 0) + + print(f"\nšŸ“Š Current Configuration:") + print(f" Chunking mode: {split_by}") + print(f" Chunk size: {chunk_size}") + + if split_by == "ast": + print(" Status: šŸš€ AST chunking ENABLED") + print(" Benefits: Semantic code understanding, function/class boundaries preserved") + else: + print(" Status: šŸ“ Traditional text chunking") + print(" Note: Consider enabling AST chunking for better code understanding") + +def main(): + if len(sys.argv) < 2: + print("Usage: python enable_ast.py [enable|disable|status]") + print("\nCommands:") + print(" enable - Enable AST-based chunking") + print(" disable - Disable AST chunking (restore text chunking)") + print(" status - Show current chunking status") + sys.exit(1) + + command = sys.argv[1].lower() + + if command == "enable": + enable_ast_chunking() + elif command == "disable": + disable_ast_chunking() + elif command == "status": + check_status() + else: + print(f"Unknown command: {command}") + sys.exit(1) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/api/enhanced_pipeline.py b/api/enhanced_pipeline.py new file mode 100644 index 000000000..fd050caf5 --- /dev/null +++ b/api/enhanced_pipeline.py @@ -0,0 +1,324 @@ +""" +Enhanced data pipeline with AST chunking support. +This patch integrates AST chunking into the existing adalflow pipeline. +""" + +import os +from typing import List, Dict, Any, Optional +import logging + +from adalflow.core.document import Document +from adalflow.core.db import LocalDB +import adalflow as adal + +from .ast_integration import ASTTextSplitter, EnhancedRAGRetriever +from .config import get_embedder_config, get_embedder_type +from .data_pipeline import get_embedder, OllamaDocumentProcessor, ToEmbeddings + +logger = logging.getLogger(__name__) + + +def prepare_enhanced_data_pipeline(embedder_type: str = None, + is_ollama_embedder: bool = None, + use_ast_chunking: bool = True) -> adal.Sequential: + """ + Prepare enhanced data pipeline with optional AST chunking support. + + Args: + embedder_type: The embedder type ('openai', 'google', 'ollama') + is_ollama_embedder: DEPRECATED. Use embedder_type instead + use_ast_chunking: Whether to use AST-based chunking + + Returns: + Sequential pipeline with splitter and embedder + """ + # Load configuration + configs = get_embedder_config() + + # Handle legacy parameter + if is_ollama_embedder is not None: + embedder_type = 'ollama' if is_ollama_embedder else None + + # Determine embedder type if not specified + if embedder_type is None: + embedder_type = get_embedder_type() + + # Choose splitter based on configuration + if use_ast_chunking and configs.get("text_splitter", {}).get("split_by") == "ast": + # Use AST splitter + splitter_config = configs["text_splitter"] + splitter = ASTTextSplitter(**splitter_config) + logger.info("Using AST-based text splitting") + else: + # Use traditional text splitter + from adalflow.core.text_splitter import TextSplitter + splitter = TextSplitter(**configs["text_splitter"]) + logger.info("Using traditional text splitting") + + # Get embedder configuration and instance + embedder_config = get_embedder_config() + embedder = get_embedder(embedder_type=embedder_type) + + # Choose appropriate processor based on embedder type + if embedder_type == 'ollama': + # Use Ollama document processor for single-document processing + embedder_transformer = OllamaDocumentProcessor(embedder=embedder) + else: + # Use batch processing for OpenAI and Google embedders + batch_size = embedder_config.get("batch_size", 500) + embedder_transformer = ToEmbeddings( + embedder=embedder, batch_size=batch_size + ) + + # Create sequential pipeline + data_transformer = adal.Sequential( + splitter, embedder_transformer + ) + + return data_transformer + + +def transform_documents_and_save_to_enhanced_db( + documents: List[Document], + db_path: str, + embedder_type: str = None, + is_ollama_embedder: bool = None, + use_ast_chunking: bool = None +) -> LocalDB: + """ + Enhanced document transformation with AST chunking support. + + Args: + documents: List of Document objects + db_path: Path to the local database file + embedder_type: The embedder type ('openai', 'google', 'ollama') + is_ollama_embedder: DEPRECATED. Use embedder_type instead + use_ast_chunking: Whether to use AST chunking (auto-detected if None) + + Returns: + LocalDB instance with processed documents + """ + # Auto-detect AST chunking preference if not specified + if use_ast_chunking is None: + configs = get_embedder_config() + use_ast_chunking = configs.get("text_splitter", {}).get("split_by") == "ast" + + # Get the enhanced data transformer + data_transformer = prepare_enhanced_data_pipeline( + embedder_type=embedder_type, + is_ollama_embedder=is_ollama_embedder, + use_ast_chunking=use_ast_chunking + ) + + # Save the documents to a local database + db = LocalDB() + db.register_transformer(transformer=data_transformer, key="split_and_embed") + db.load(documents) + db.transform(key="split_and_embed") + + # Ensure directory exists and save + os.makedirs(os.path.dirname(db_path), exist_ok=True) + db.save_state(filepath=db_path) + + # Log chunking statistics + _log_chunking_stats(db, use_ast_chunking) + + return db + + +def _log_chunking_stats(db: LocalDB, used_ast: bool): + """Log statistics about the chunking process.""" + try: + documents = db.get_transformed_data("split_and_embed") + if not documents: + return + + total_chunks = len(documents) + + if used_ast: + # Analyze AST chunk types + chunk_types = {} + languages = {} + + for doc in documents: + chunk_type = doc.meta_data.get('chunk_type', 'unknown') + language = doc.meta_data.get('language', 'unknown') + + chunk_types[chunk_type] = chunk_types.get(chunk_type, 0) + 1 + languages[language] = languages.get(language, 0) + 1 + + logger.info(f"AST chunking created {total_chunks} chunks") + logger.info(f"Chunk types: {dict(chunk_types)}") + logger.info(f"Languages: {dict(languages)}") + + # Log function and class statistics + function_chunks = chunk_types.get('function', 0) + class_chunks = chunk_types.get('class', 0) + + if function_chunks > 0: + logger.info(f"Found {function_chunks} function chunks") + if class_chunks > 0: + logger.info(f"Found {class_chunks} class chunks") + else: + logger.info(f"Traditional chunking created {total_chunks} chunks") + + except Exception as e: + logger.error(f"Error logging chunking stats: {e}") + + +def create_enhanced_retriever(db: LocalDB, + embedder_type: str = None) -> EnhancedRAGRetriever: + """ + Create an enhanced retriever with AST-aware capabilities. + + Args: + db: LocalDB instance with processed documents + embedder_type: The embedder type used + + Returns: + EnhancedRAGRetriever instance + """ + # Get base retriever from database + base_retriever = db.get_retriever() # This would need to be implemented in LocalDB + + # Create enhanced retriever + enhanced_retriever = EnhancedRAGRetriever(base_retriever) + + return enhanced_retriever + + +# Configuration utilities +def switch_to_ast_chunking(): + """Switch the system to use AST chunking.""" + import json + import shutil + + # Backup current config + embedder_config_path = "api/config/embedder.json" + backup_path = f"{embedder_config_path}.backup" + + if os.path.exists(embedder_config_path): + shutil.copy2(embedder_config_path, backup_path) + logger.info(f"Backed up current config to {backup_path}") + + # Copy AST config + ast_config_path = "api/config/embedder.ast.json" + if os.path.exists(ast_config_path): + shutil.copy2(ast_config_path, embedder_config_path) + logger.info("Switched to AST chunking configuration") + else: + logger.error(f"AST config file not found: {ast_config_path}") + + +def switch_to_text_chunking(): + """Switch back to traditional text chunking.""" + import json + + embedder_config_path = "api/config/embedder.json" + backup_path = f"{embedder_config_path}.backup" + + if os.path.exists(backup_path): + shutil.copy2(backup_path, embedder_config_path) + logger.info("Switched back to text chunking configuration") + else: + # Create default text config + default_config = { + "text_splitter": { + "split_by": "word", + "chunk_size": 350, + "chunk_overlap": 100 + } + } + + with open(embedder_config_path, 'w') as f: + json.dump(default_config, f, indent=2) + + logger.info("Created default text chunking configuration") + + +def get_chunking_mode() -> str: + """Get current chunking mode.""" + try: + configs = get_embedder_config() + split_by = configs.get("text_splitter", {}).get("split_by", "word") + return "ast" if split_by == "ast" else "text" + except Exception: + return "text" # Default fallback + + +# Example usage for testing +def test_ast_chunking(): + """Test function to demonstrate AST chunking capabilities.""" + + # Sample Python code for testing + sample_code = ''' +import os +import sys +from typing import List, Dict + +class DataProcessor: + """A class for processing data.""" + + def __init__(self, config: Dict): + self.config = config + self.data = [] + + def load_data(self, file_path: str) -> List[Dict]: + """Load data from file.""" + with open(file_path, 'r') as f: + return json.load(f) + + def process_data(self, data: List[Dict]) -> List[Dict]: + """Process the loaded data.""" + processed = [] + for item in data: + processed_item = self._process_item(item) + processed.append(processed_item) + return processed + + def _process_item(self, item: Dict) -> Dict: + """Process a single item.""" + return {**item, 'processed': True} + +def main(): + """Main function.""" + processor = DataProcessor({'debug': True}) + data = processor.load_data('data.json') + result = processor.process_data(data) + print(f"Processed {len(result)} items") + +if __name__ == "__main__": + main() +''' + + # Create test document + test_doc = Document( + text=sample_code, + id="test_python_file", + meta_data={'file_path': 'test.py'} + ) + + # Test AST chunking + from .ast_integration import ASTTextSplitter + + ast_splitter = ASTTextSplitter( + split_by="ast", + chunk_size=2000, + min_chunk_size=100 + ) + + chunks = ast_splitter.call([test_doc]) + + print(f"\nAST Chunking Results:") + print(f"Created {len(chunks)} chunks from test file") + + for i, chunk in enumerate(chunks): + chunk_type = chunk.meta_data.get('chunk_type', 'unknown') + chunk_name = chunk.meta_data.get('chunk_name', 'unnamed') + print(f"\nChunk {i+1}: {chunk_type} - {chunk_name}") + print(f"Lines {chunk.meta_data.get('start_line', 0)}-{chunk.meta_data.get('end_line', 0)}") + print(f"Content preview: {chunk.text[:100]}...") + + +if __name__ == "__main__": + test_ast_chunking() \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 15f9cdb62..bf4707270 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,6 +3,8 @@ services: build: context: . dockerfile: Dockerfile + args: + AST_CHUNKING: ${AST_CHUNKING:-false} # Set to 'true' to enable AST chunking during build ports: - "${PORT:-8001}:${PORT:-8001}" # API port - "3000:3000" # Next.js port @@ -27,3 +29,5 @@ services: timeout: 10s retries: 3 start_period: 30s + extra_hosts: + - "host.docker.internal:host-gateway" # Allow access to host machine from Docker From 03d9e5b582d082028e1ba00e9f4b4e535cb6c306 Mon Sep 17 00:00:00 2001 From: dariana_roche Date: Tue, 14 Oct 2025 02:15:24 -0700 Subject: [PATCH 04/50] feat: Enhance AST chunking by preserving embedder settings and update docker-compose for config mounting --- api/config/embedder.ast.json | 9 +++++++++ api/enable_ast.py | 24 +++++++++++++++++++++--- docker-compose.yml | 1 + 3 files changed, 31 insertions(+), 3 deletions(-) diff --git a/api/config/embedder.ast.json b/api/config/embedder.ast.json index 050992cf3..90940bf19 100644 --- a/api/config/embedder.ast.json +++ b/api/config/embedder.ast.json @@ -1,4 +1,13 @@ { + "embedder_ollama": { + "client_class": "OllamaClient", + "model_kwargs": { + "model": "nomic-embed-text" + } + }, + "retriever": { + "top_k": 20 + }, "text_splitter": { "split_by": "ast", "chunk_size": 2000, diff --git a/api/enable_ast.py b/api/enable_ast.py index 7f9d9d760..74328a1b7 100644 --- a/api/enable_ast.py +++ b/api/enable_ast.py @@ -23,10 +23,28 @@ def enable_ast_chunking(): if os.path.exists(embedder_config): shutil.copy2(embedder_config, backup_config) print(f"āœ… Backed up current config to {backup_config}") + + # Load current config to preserve embedder settings + with open(embedder_config, 'r') as f: + current_config = json.load(f) + else: + current_config = {} + + # Load AST config + with open(ast_config, 'r') as f: + ast_config_data = json.load(f) + + # Merge embedder settings from current config into AST config + if 'embedder_ollama' in current_config: + ast_config_data['embedder_ollama'] = current_config['embedder_ollama'] + if 'retriever' in current_config: + ast_config_data['retriever'] = current_config['retriever'] + + # Write merged config + with open(embedder_config, 'w') as f: + json.dump(ast_config_data, f, indent=2) - # Copy AST config to active config - shutil.copy2(ast_config, embedder_config) - print(f"āœ… Enabled AST chunking (copied {ast_config} → {embedder_config})") + print(f"āœ… Enabled AST chunking with preserved embedder settings") # Verify the switch with open(embedder_config, 'r') as f: diff --git a/docker-compose.yml b/docker-compose.yml index bf4707270..f8c9a07b0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,6 +19,7 @@ services: volumes: - ~/.adalflow:/root/.adalflow # Persist repository and embedding data - ./api/logs:/app/api/logs # Persist log files across container restarts + - ./api/config:/app/api/config:ro # Mount config files (read-only) # Resource limits for docker-compose up (not Swarm mode) mem_limit: 6g mem_reservation: 2g From 79dac6ea4c4751b2fbb51ab049e6de01d2ff6014 Mon Sep 17 00:00:00 2001 From: Daniel Frey Date: Sat, 27 Dec 2025 04:47:33 +0100 Subject: [PATCH 05/50] fix: improve embedding diagnostics and handle invalid db (#423) Co-authored-by: danielfrey63 --- api/data_pipeline.py | 35 +++++++++++++- api/google_embedder_client.py | 91 +++++++++++++++++++++++------------ 2 files changed, 93 insertions(+), 33 deletions(-) diff --git a/api/data_pipeline.py b/api/data_pipeline.py index 3a11065f8..5e1f5fa47 100644 --- a/api/data_pipeline.py +++ b/api/data_pipeline.py @@ -847,6 +847,21 @@ def prepare_db_index(self, embedder_type: str = None, is_ollama_embedder: bool = Returns: List[Document]: List of Document objects """ + def _embedding_vector_length(doc: Document) -> int: + vector = getattr(doc, "vector", None) + if vector is None: + return 0 + try: + if hasattr(vector, "shape"): + if len(vector.shape) == 0: + return 0 + return int(vector.shape[-1]) + if hasattr(vector, "__len__"): + return int(len(vector)) + except Exception: + return 0 + return 0 + # Handle backward compatibility if embedder_type is None and is_ollama_embedder is not None: embedder_type = 'ollama' if is_ollama_embedder else None @@ -857,8 +872,24 @@ def prepare_db_index(self, embedder_type: str = None, is_ollama_embedder: bool = self.db = LocalDB.load_state(self.repo_paths["save_db_file"]) documents = self.db.get_transformed_data(key="split_and_embed") if documents: - logger.info(f"Loaded {len(documents)} documents from existing database") - return documents + lengths = [_embedding_vector_length(doc) for doc in documents] + non_empty = sum(1 for n in lengths if n > 0) + empty = len(lengths) - non_empty + sample_sizes = sorted({n for n in lengths if n > 0})[:3] + logger.info( + "Loaded %s documents from existing database (embeddings: %s non-empty, %s empty; sample_dims=%s)", + len(documents), + non_empty, + empty, + sample_sizes, + ) + + if non_empty == 0: + logger.warning( + "Existing database contains no usable embeddings. Rebuilding embeddings..." + ) + else: + return documents except Exception as e: logger.error(f"Error loading existing database: {e}") # Continue to create a new database diff --git a/api/google_embedder_client.py b/api/google_embedder_client.py index b604fd8ed..dd15dd146 100644 --- a/api/google_embedder_client.py +++ b/api/google_embedder_client.py @@ -88,42 +88,62 @@ def parse_embedding_response(self, response) -> EmbedderOutput: from adalflow.core.types import Embedding embedding_data = [] + + def _extract_embedding_value(obj): + if obj is None: + return None + if isinstance(obj, dict): + if "embedding" in obj: + return obj.get("embedding") + if "embeddings" in obj: + return obj.get("embeddings") + if hasattr(obj, "embedding"): + return getattr(obj, "embedding") + if hasattr(obj, "embeddings"): + return getattr(obj, "embeddings") + for method_name in ("model_dump", "to_dict", "dict"): + if hasattr(obj, method_name): + try: + dumped = getattr(obj, method_name)() + if isinstance(dumped, dict): + if "embedding" in dumped: + return dumped.get("embedding") + if "embeddings" in dumped: + return dumped.get("embeddings") + except Exception: + pass + return None - if isinstance(response, dict): - if 'embedding' in response: - embedding_value = response['embedding'] - if isinstance(embedding_value, list) and len(embedding_value) > 0: - # Check if it's a single embedding (list of floats) or batch (list of lists) - if isinstance(embedding_value[0], (int, float)): - # Single embedding response: {'embedding': [float, ...]} - embedding_data = [Embedding(embedding=embedding_value, index=0)] - else: - # Batch embeddings response: {'embedding': [[float, ...], [float, ...], ...]} - embedding_data = [ - Embedding(embedding=emb_list, index=i) - for i, emb_list in enumerate(embedding_value) - ] - else: - log.warning(f"Empty or invalid embedding data: {embedding_value}") - embedding_data = [] - elif 'embeddings' in response: - # Alternative batch format: {'embeddings': [{'embedding': [float, ...]}, ...]} + embedding_value = _extract_embedding_value(response) + if embedding_value is None: + log.warning("Unexpected embedding response type/structure: %s", type(response)) + embedding_data = [] + elif isinstance(embedding_value, list) and len(embedding_value) > 0: + if isinstance(embedding_value[0], (int, float)): + embedding_data = [Embedding(embedding=embedding_value, index=0)] + elif isinstance(embedding_value[0], list): embedding_data = [ - Embedding(embedding=item['embedding'], index=i) - for i, item in enumerate(response['embeddings']) + Embedding(embedding=emb_list, index=i) + for i, emb_list in enumerate(embedding_value) + if isinstance(emb_list, list) and len(emb_list) > 0 ] else: - log.warning(f"Unexpected response structure: {response.keys()}") - embedding_data = [] - elif hasattr(response, 'embeddings'): - # Custom batch response object from our implementation - embedding_data = [ - Embedding(embedding=emb, index=i) - for i, emb in enumerate(response.embeddings) - ] + extracted = [] + for item in embedding_value: + item_emb = _extract_embedding_value(item) + if isinstance(item_emb, list) and len(item_emb) > 0: + extracted.append(item_emb) + embedding_data = [ + Embedding(embedding=emb_list, index=i) + for i, emb_list in enumerate(extracted) + ] else: - log.warning(f"Unexpected response type: {type(response)}") + log.warning("Empty or invalid embedding data parsed from response") embedding_data = [] + + if embedding_data: + first_dim = len(embedding_data[0].embedding) if embedding_data[0].embedding is not None else 0 + log.info("Parsed %s embedding(s) (dim=%s)", len(embedding_data), first_dim) return EmbedderOutput( data=embedding_data, @@ -201,7 +221,16 @@ def call(self, api_kwargs: Dict = {}, model_type: ModelType = ModelType.UNDEFINE if model_type != ModelType.EMBEDDER: raise ValueError(f"GoogleEmbedderClient only supports EMBEDDER model type") - log.info(f"Google AI Embeddings API kwargs: {api_kwargs}") + safe_log_kwargs = {k: v for k, v in api_kwargs.items() if k not in {"content", "contents"}} + if "content" in api_kwargs: + safe_log_kwargs["content_chars"] = len(str(api_kwargs.get("content", ""))) + if "contents" in api_kwargs: + try: + contents = api_kwargs.get("contents") + safe_log_kwargs["contents_count"] = len(contents) if hasattr(contents, "__len__") else None + except Exception: + safe_log_kwargs["contents_count"] = None + log.info("Google AI Embeddings call kwargs (sanitized): %s", safe_log_kwargs) try: # Use embed_content for single text or batch embedding From 8f610c0cfd7288e70119c415c90238170fb63421 Mon Sep 17 00:00:00 2001 From: Shinnosuke Sawada-Dazai Date: Sat, 27 Dec 2025 12:48:49 +0900 Subject: [PATCH 06/50] feat(bedrock): support more models (#420) * feat(bedrock): support regional model provider names * fix: make temperature and top_p optional for Bedrock models Some models such as Claude Haiku 4.5 do not support setting both temperature and top_p at the same time. * Apply suggestions from code review Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> * refactor: add comments to clarify model provider extraction logic * Update api/bedrock_client.py Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --------- Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- api/bedrock_client.py | 15 +++++++++++---- api/websocket_wiki.py | 6 ++++-- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/api/bedrock_client.py b/api/bedrock_client.py index aa702f0a8..fa0b5097b 100644 --- a/api/bedrock_client.py +++ b/api/bedrock_client.py @@ -164,14 +164,21 @@ def _get_model_provider(self, model_id: str) -> str: """Extract the provider from the model ID. Args: - model_id: The model ID, e.g., "anthropic.claude-3-sonnet-20240229-v1:0" + model_id: The model inference ID, e.g., "anthropic.claude-3-sonnet-20240229-v1:0", "global.anthropic.claude-sonnet-4-5-20250929-v1:0", or "global.cohere.embed-v4:0" Returns: The provider name, e.g., "anthropic" """ - if "." in model_id: - return model_id.split(".")[0] - return "amazon" # Default provider + seg = model_id.split(".") + if len(seg) >= 3: + # regional format + return seg[1] + elif len(seg) == 2: + # non-regional format + return seg[0] + else: + # Default to Amazon if format is unexpected + return "amazon" def _format_prompt_for_provider(self, provider: str, prompt: str, messages=None) -> Dict[str, Any]: """Format the prompt according to the provider's requirements. diff --git a/api/websocket_wiki.py b/api/websocket_wiki.py index a6acac50a..5bd0c9ff2 100644 --- a/api/websocket_wiki.py +++ b/api/websocket_wiki.py @@ -515,10 +515,12 @@ async def handle_websocket_chat(websocket: WebSocket): model = BedrockClient() model_kwargs = { "model": request.model, - "temperature": model_config["temperature"], - "top_p": model_config["top_p"], } + for key in ["temperature", "top_p"]: + if key in model_config: + model_kwargs[key] = model_config[key] + api_kwargs = model.convert_inputs_to_api_kwargs( input=prompt, model_kwargs=model_kwargs, From d48f5bcf2db94c114bdaf8335819a56e2f2e765e Mon Sep 17 00:00:00 2001 From: Daniel Frey Date: Sat, 3 Jan 2026 22:20:15 +0100 Subject: [PATCH 07/50] docs: fix api install instructions and project structure (#433) Co-authored-by: danielfrey63 --- README.md | 3 ++- api/README.md | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 101f5ceb4..da2e21e7e 100644 --- a/README.md +++ b/README.md @@ -172,7 +172,8 @@ deepwiki/ │ ā”œā”€ā”€ api.py # FastAPI implementation │ ā”œā”€ā”€ rag.py # Retrieval Augmented Generation │ ā”œā”€ā”€ data_pipeline.py # Data processing utilities -│ └── requirements.txt # Python dependencies +│ ā”œā”€ā”€ pyproject.toml # Python dependencies (Poetry) +│ └── poetry.lock # Locked Python dependency versions │ ā”œā”€ā”€ src/ # Frontend Next.js app │ ā”œā”€ā”€ app/ # Next.js app directory diff --git a/api/README.md b/api/README.md index 3172037af..65e82a2dc 100644 --- a/api/README.md +++ b/api/README.md @@ -16,7 +16,7 @@ This is the backend API for DeepWiki, providing smart code analysis and AI-power ```bash # From the project root -python -m pip install poetry==2.0.1 && poetry install +python -m pip install poetry==2.0.1 && poetry install -C api ``` ### Step 2: Set Up Environment Variables From 43ed8a22309c0eaa920ff26980d254934e5b021f Mon Sep 17 00:00:00 2001 From: Sheing Date: Mon, 12 Jan 2026 16:57:05 -0600 Subject: [PATCH 08/50] Remove emoji from Screenshots header in README.md (#449) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index da2e21e7e..1c017aed8 100644 --- a/README.md +++ b/README.md @@ -602,7 +602,7 @@ DeepResearch takes repository analysis to the next level with a multi-turn resea To use DeepResearch, simply toggle the "Deep Research" switch in the Ask interface before submitting your question. -## šŸ“± Screenshots +## Screenshots ![DeepWiki Main Interface](screenshots/Interface.png) *The main interface of DeepWiki* From 73ace1a4d909cd4fa0d2ecf650c38ba565103a02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BD=95=E5=98=89=E4=BC=9F?= <54312752+loon-hejw@users.noreply.github.com> Date: Fri, 23 Jan 2026 14:56:38 +0800 Subject: [PATCH 09/50] Refactor response handling in websocket_wiki.py (#459) --- api/websocket_wiki.py | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/api/websocket_wiki.py b/api/websocket_wiki.py index 5bd0c9ff2..27bf0d71d 100644 --- a/api/websocket_wiki.py +++ b/api/websocket_wiki.py @@ -578,10 +578,28 @@ async def handle_websocket_chat(websocket: WebSocket): response = await model.acall(api_kwargs=api_kwargs, model_type=ModelType.LLM) # Handle streaming response from Ollama async for chunk in response: - text = getattr(chunk, 'response', None) or getattr(chunk, 'text', None) or str(chunk) - if text and not text.startswith('model=') and not text.startswith('created_at='): - text = text.replace('', '').replace('', '') - await websocket.send_text(text) + text = None + if isinstance(chunk, dict): + text = chunk.get("message", {}).get("content") if isinstance(chunk.get("message"), dict) else chunk.get("message") + else: + message = getattr(chunk, "message", None) + if message is not None: + if isinstance(message, dict): + text = message.get("content") + else: + text = getattr(message, "content", None) + + if not text: + text = getattr(chunk, 'response', None) or getattr(chunk, 'text', None) + + if not text and hasattr(chunk, "__dict__"): + message = chunk.__dict__.get("message") + if isinstance(message, dict): + text = message.get("content") + + if isinstance(text, str) and text and not text.startswith('model=') and not text.startswith('created_at='): + clean_text = text.replace('', '').replace('', '') + await websocket.send_text(clean_text) # Explicitly close the WebSocket connection after the response is complete await websocket.close() elif request.provider == "openrouter": From bc1e12af4c4b78e9914fd6b53be925863bce911a Mon Sep 17 00:00:00 2001 From: "Sheing [ASYNCFUNC]" Date: Sun, 25 Jan 2026 15:04:08 -0600 Subject: [PATCH 10/50] New Chapter. Announce the shift of primary development focus to AsyncReview. --- README.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/README.md b/README.md index 1c017aed8..ba8fd1a88 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,12 @@ + +### āš ļø Announcement: Shifting focus to AsyncReview +--- + +**IMPORTANT UPDATE** DeepWiki-Open maintenance is ongoing, but primary active development is moving to **[AsyncReview](https://github.com/AsyncFuncAI/AsyncReview/)**. Thank you for the support on this project; please join me in the new repository for this year's primary effort. + +--- +--- + # DeepWiki-Open ![DeepWiki Banner](screenshots/Deepwiki.png) From 4b3f14097a94fe95a7676d527c75820a877dedf2 Mon Sep 17 00:00:00 2001 From: A-baoYang Date: Thu, 26 Feb 2026 03:15:51 +0800 Subject: [PATCH 11/50] fix: Docker build font timeout and deprecated Google embedding model (#477) 1. Replace next/font/google with runtime tags for Google Fonts loading, eliminating the build-time network dependency that caused ETIMEDOUT failures during Docker build. 2. Migrate Google embedding model from deprecated text-embedding-004 to gemini-embedding-001 (deprecated Jan 14, 2026 on v1beta API). 3. Fix dict mutation bug in GoogleEmbedderClient.call() where api_kwargs.pop("contents") destroyed retry state, causing backoff retries to fail with a misleading error. Co-authored-by: Claude Opus 4.6 --- api/config/embedder.json | 2 +- api/google_embedder_client.py | 12 +++++++----- src/app/globals.css | 7 +++++-- src/app/layout.tsx | 33 +++++++++------------------------ 4 files changed, 22 insertions(+), 32 deletions(-) diff --git a/api/config/embedder.json b/api/config/embedder.json index 0101ac083..e64cfdcbf 100644 --- a/api/config/embedder.json +++ b/api/config/embedder.json @@ -18,7 +18,7 @@ "client_class": "GoogleEmbedderClient", "batch_size": 100, "model_kwargs": { - "model": "text-embedding-004", + "model": "gemini-embedding-001", "task_type": "SEMANTIC_SIMILARITY" } }, diff --git a/api/google_embedder_client.py b/api/google_embedder_client.py index dd15dd146..af1b08a92 100644 --- a/api/google_embedder_client.py +++ b/api/google_embedder_client.py @@ -39,7 +39,7 @@ class GoogleEmbedderClient(ModelClient): embedder = adal.Embedder( model_client=client, model_kwargs={ - "model": "text-embedding-004", + "model": "gemini-embedding-001", "task_type": "SEMANTIC_SIMILARITY" } ) @@ -47,7 +47,7 @@ class GoogleEmbedderClient(ModelClient): References: - Google AI Embeddings: https://ai.google.dev/gemini-api/docs/embeddings - - Available models: text-embedding-004, embedding-001 + - Available models: gemini-embedding-001 """ def __init__( @@ -199,7 +199,7 @@ def convert_inputs_to_api_kwargs( # Set default model if not provided if "model" not in final_model_kwargs: - final_model_kwargs["model"] = "text-embedding-004" + final_model_kwargs["model"] = "gemini-embedding-001" return final_model_kwargs @@ -239,8 +239,10 @@ def call(self, api_kwargs: Dict = {}, model_type: ModelType = ModelType.UNDEFINE response = genai.embed_content(**api_kwargs) elif "contents" in api_kwargs: # Batch embedding - Google AI supports batch natively - contents = api_kwargs.pop("contents") - response = genai.embed_content(content=contents, **api_kwargs) + # Copy to avoid mutating the original dict (needed for retries) + kwargs = api_kwargs.copy() + contents = kwargs.pop("contents") + response = genai.embed_content(content=contents, **kwargs) else: raise ValueError("Either 'content' or 'contents' must be provided") diff --git a/src/app/globals.css b/src/app/globals.css index d0648fa6e..cf1918510 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -39,8 +39,11 @@ html[data-theme='dark'] { @theme inline { --color-background: var(--background); --color-foreground: var(--foreground); - --font-sans: var(--font-geist-sans); - --font-mono: var(--font-geist-mono); + --font-sans: 'Noto Sans JP', sans-serif; + --font-mono: 'Geist Mono', monospace; + --font-geist-sans: 'Noto Sans JP', sans-serif; + --font-geist-mono: 'Geist Mono', monospace; + --font-serif-jp: 'Noto Serif JP', serif; } body { diff --git a/src/app/layout.tsx b/src/app/layout.tsx index c848b3ae7..429d4e34a 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,29 +1,8 @@ import type { Metadata } from "next"; -import { Noto_Sans_JP, Noto_Serif_JP, Geist_Mono } from "next/font/google"; import "./globals.css"; import { ThemeProvider } from "next-themes"; import { LanguageProvider } from "@/contexts/LanguageContext"; -// Japanese-friendly fonts -const notoSansJP = Noto_Sans_JP({ - variable: "--font-geist-sans", - subsets: ["latin"], - weight: ["400", "500", "700"], - display: "swap", -}); - -const notoSerifJP = Noto_Serif_JP({ - variable: "--font-serif-jp", - subsets: ["latin"], - weight: ["400", "500", "700"], - display: "swap", -}); - -const geistMono = Geist_Mono({ - variable: "--font-geist-mono", - subsets: ["latin"], -}); - export const metadata: Metadata = { title: "Deepwiki Open Source | Sheing Ng", description: "Created by Sheing Ng", @@ -36,9 +15,15 @@ export default function RootLayout({ }>) { return ( - + + + + + + {children} From ce98ab291419a66c3f1537ec4e42b0b829f4f874 Mon Sep 17 00:00:00 2001 From: Sense_wang <167664334+haosenwang1018@users.noreply.github.com> Date: Thu, 26 Feb 2026 03:16:30 +0800 Subject: [PATCH 12/50] fix: replace bare except clauses with Exception in rag and websocket (#476) Bare except catches BaseException including SystemExit and KeyboardInterrupt. Replace with Exception to allow clean shutdown. - api/rag.py: error logging for embedding size debug info - api/websocket_wiki.py: WebSocket cleanup on error Co-authored-by: haosenwang1018 --- api/rag.py | 2 +- api/websocket_wiki.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/api/rag.py b/api/rag.py index 6908a9315..b85962b4f 100644 --- a/api/rag.py +++ b/api/rag.py @@ -408,7 +408,7 @@ def prepare_retriever(self, repo_url_or_path: str, type: str = "github", access_ else: size = "unknown" sizes.append(f"doc_{i}: {size}") - except: + except Exception: sizes.append(f"doc_{i}: error") logger.error(f"Sample embedding sizes: {', '.join(sizes)}") raise diff --git a/api/websocket_wiki.py b/api/websocket_wiki.py index 27bf0d71d..d1a6b1bd5 100644 --- a/api/websocket_wiki.py +++ b/api/websocket_wiki.py @@ -911,5 +911,5 @@ async def handle_websocket_chat(websocket: WebSocket): try: await websocket.send_text(f"Error: {str(e)}") await websocket.close() - except: + except Exception: pass From 00adb8c876af056cff9e144cdcf2eb204edb7aed Mon Sep 17 00:00:00 2001 From: "M. Mansour" Date: Wed, 4 Mar 2026 13:53:26 -0500 Subject: [PATCH 13/50] fix: Updating/fixing ollama url, now with zst compression (#481) * fix: Updating/fixing ollama url, now with zst compression * Adding some improvements - to ensure ollama installation pipeline fails if any command within it fails --------- Co-authored-by: M. Mansour <3020010+marazik@users.noreply.github.com> --- Dockerfile-ollama-local | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/Dockerfile-ollama-local b/Dockerfile-ollama-local index ab0f5fc0c..fd2597fc5 100644 --- a/Dockerfile-ollama-local +++ b/Dockerfile-ollama-local @@ -25,8 +25,9 @@ RUN python -m pip install poetry==2.0.1 --no-cache-dir && \ poetry cache clear --all . FROM python:3.11-slim AS ollama_base -RUN apt-get update && apt-get install -y \ - curl +RUN apt-get update && apt-get install -y --no-install-recommends \ + curl zstd && rm -rf /var/lib/apt/lists/* + # Detect architecture and download appropriate Ollama version # ARG TARGETARCH can be set at build time with --build-arg TARGETARCH=arm64 or TARGETARCH=amd64 ARG TARGETARCH=arm64 @@ -41,9 +42,9 @@ RUN OLLAMA_ARCH="" && \ echo "Error: Unsupported architecture '$TARGETARCH'. Supported architectures are 'arm64' and 'amd64'." >&2 && \ exit 1; \ fi && \ - curl -L "https://ollama.com/download/ollama-linux-${OLLAMA_ARCH}.tgz" -o ollama.tgz && \ - tar -C /usr -xzf ollama.tgz && \ - rm ollama.tgz + (set -o pipefail; \ + curl -fL "https://ollama.com/download/ollama-linux-${OLLAMA_ARCH}.tar.zst" \ + | zstd -d | tar -x -C /usr) RUN ollama serve > /dev/null 2>&1 & \ sleep 20 && \ From 4c6a1f7899aea57e200359912cc7c1c83c6b8937 Mon Sep 17 00:00:00 2001 From: "M. Mansour" Date: Thu, 5 Mar 2026 13:50:21 -0500 Subject: [PATCH 14/50] Fix running Ollama in docker. Fixing embedder configuration and Ollama instructions using Docker (#484) Co-authored-by: M. Mansour <3020010+marazik@users.noreply.github.com> --- Ollama-instruction.md | 14 ++++++++++---- api/config/embedder.ollama.json.bak | 6 ++++++ 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/Ollama-instruction.md b/Ollama-instruction.md index 2c5572c43..f5332dac3 100644 --- a/Ollama-instruction.md +++ b/Ollama-instruction.md @@ -80,8 +80,14 @@ npm run dev ## Alternative using Dockerfile -1. Build the docker image `docker build -f Dockerfile-ollama-local -t deepwiki:ollama-local .` -2. Run the container: +1. Configure the Local Embedder for Ollama: +``` +cp api/config/embedder.ollama.json.bak api/config/embedder.json +# overwrite api/config/embedder.json? (y/n [n]) y +``` + +2. Build the docker image `docker build -f Dockerfile-ollama-local -t deepwiki:ollama-local .` +3. Run the container: ```bash # For regular use docker run -p 3000:3000 -p 8001:8001 --name deepwiki \ @@ -97,9 +103,9 @@ npm run dev deepwiki:ollama-local ``` -3. When using local repositories in the interface: use `/app/local-repos/repo-name` as the local repository path. +4. When using local repositories in the interface: use `/app/local-repos/repo-name` as the local repository path. -4. Open http://localhost:3000 in your browser +5. Open http://localhost:3000 in your browser Note: For Apple Silicon Macs, the Dockerfile automatically uses ARM64 binaries for better performance. diff --git a/api/config/embedder.ollama.json.bak b/api/config/embedder.ollama.json.bak index 9d316496e..9124f5a1a 100644 --- a/api/config/embedder.ollama.json.bak +++ b/api/config/embedder.ollama.json.bak @@ -1,4 +1,10 @@ { + "embedder_ollama": { + "client_class": "OllamaClient", + "model_kwargs": { + "model": "nomic-embed-text" + } + }, "embedder": { "client_class": "OllamaClient", "model_kwargs": { From 05591ee7160fcc8eeb4d976d10ce566a16c2eb94 Mon Sep 17 00:00:00 2001 From: MuleiSY Date: Sat, 11 Apr 2026 02:30:32 +0800 Subject: [PATCH 15/50] feat: add LaTeX math formula rendering support (#499) --- package-lock.json | 221 +++++++++++++++++++++++++++++++++++- package.json | 2 + src/components/Markdown.tsx | 7 +- 3 files changed, 227 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index db8e9c6d0..1e1dfe9a5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,8 +17,10 @@ "react-icons": "^5.5.0", "react-markdown": "^10.1.0", "react-syntax-highlighter": "^15.6.1", + "rehype-katex": "^7.0.1", "rehype-raw": "^7.0.0", "remark-gfm": "^4.0.1", + "remark-math": "^6.0.0", "svg-pan-zoom": "^3.6.2" }, "devDependencies": { @@ -1780,6 +1782,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/katex": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@types/katex/-/katex-0.16.8.tgz", + "integrity": "sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==", + "license": "MIT" + }, "node_modules/@types/mdast": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", @@ -1809,6 +1817,7 @@ "version": "19.1.11", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.11.tgz", "integrity": "sha512-lr3jdBw/BGj49Eps7EvqlUaoeA0xpj3pc0RoJkHpYaCHkVK7i28dKyImLQb3JVlqs3aYSXf7qYuWOW/fgZnTXQ==", + "dev": true, "license": "MIT", "dependencies": { "csstype": "^3.0.2" @@ -3041,6 +3050,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "dev": true, "license": "MIT" }, "node_modules/cytoscape": { @@ -4880,6 +4890,85 @@ "node": ">= 0.4" } }, + "node_modules/hast-util-from-dom": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/hast-util-from-dom/-/hast-util-from-dom-5.0.1.tgz", + "integrity": "sha512-N+LqofjR2zuzTjCPzyDUdSshy4Ma6li7p/c3pA78uTwzFgENbgbUrm2ugwsOdcjI1muO+o6Dgzp9p8WHtn/39Q==", + "license": "ISC", + "dependencies": { + "@types/hast": "^3.0.0", + "hastscript": "^9.0.0", + "web-namespaces": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-from-dom/node_modules/hast-util-parse-selector": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz", + "integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-from-dom/node_modules/hastscript": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-9.0.1.tgz", + "integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-parse-selector": "^4.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-from-html": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hast-util-from-html/-/hast-util-from-html-2.0.3.tgz", + "integrity": "sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "devlop": "^1.1.0", + "hast-util-from-parse5": "^8.0.0", + "parse5": "^7.0.0", + "vfile": "^6.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-from-html-isomorphic": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/hast-util-from-html-isomorphic/-/hast-util-from-html-isomorphic-2.0.0.tgz", + "integrity": "sha512-zJfpXq44yff2hmE0XmwEOzdWin5xwH+QIhMLOScpX91e/NSGPsAzNCvLQDIEPyO2TXi+lBmU6hjLIhV8MwP2kw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-from-dom": "^5.0.0", + "hast-util-from-html": "^2.0.0", + "unist-util-remove-position": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hast-util-from-parse5": { "version": "8.0.3", "resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-8.0.3.tgz", @@ -4930,6 +5019,19 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/hast-util-is-element": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-is-element/-/hast-util-is-element-3.0.0.tgz", + "integrity": "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hast-util-parse-selector": { "version": "2.2.5", "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-2.2.5.tgz", @@ -5021,6 +5123,22 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/hast-util-to-text": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/hast-util-to-text/-/hast-util-to-text-4.0.2.tgz", + "integrity": "sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "hast-util-is-element": "^3.0.0", + "unist-util-find-after": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hast-util-whitespace": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", @@ -6420,6 +6538,25 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/mdast-util-math": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-math/-/mdast-util-math-3.0.0.tgz", + "integrity": "sha512-Tl9GBNeG/AhJnQM221bJR2HPvLOSnLE/T9cJI9tlc6zwQk2nPk/4f0cHkOdEixQPC/j8UtKDdITswvLAy1OZ1w==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "longest-streak": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.1.0", + "unist-util-remove-position": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/mdast-util-mdx-expression": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", @@ -6777,6 +6914,25 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/micromark-extension-math": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-math/-/micromark-extension-math-3.1.0.tgz", + "integrity": "sha512-lvEqd+fHjATVs+2v/8kg9i5Q0AP2k85H0WUOwpIVvUML8BapsMvh1XAogmQjOCsLpoKRCVQqEkQBB3NhVBcsOg==", + "license": "MIT", + "dependencies": { + "@types/katex": "^0.16.0", + "devlop": "^1.0.0", + "katex": "^0.16.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/micromark-factory-destination": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", @@ -8130,6 +8286,25 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/rehype-katex": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/rehype-katex/-/rehype-katex-7.0.1.tgz", + "integrity": "sha512-OiM2wrZ/wuhKkigASodFoo8wimG3H12LWQaH8qSPVJn9apWKFSH3YOCtbKpBorTVw/eI7cuT21XBbvwEswbIOA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/katex": "^0.16.0", + "hast-util-from-html-isomorphic": "^2.0.0", + "hast-util-to-text": "^4.0.0", + "katex": "^0.16.0", + "unist-util-visit-parents": "^6.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/rehype-raw": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/rehype-raw/-/rehype-raw-7.0.0.tgz", @@ -8163,6 +8338,22 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/remark-math": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/remark-math/-/remark-math-6.0.0.tgz", + "integrity": "sha512-MMqgnP74Igy+S3WwnhQ7kqGlEerTETXMvJhrUzDikVZ2/uogJCb+WHUg97hK9/jcfc0dkD73s3LN8zU49cTEtA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-math": "^3.0.0", + "micromark-extension-math": "^3.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/remark-parse": { "version": "11.0.0", "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", @@ -9131,7 +9322,7 @@ "version": "5.9.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -9192,6 +9383,20 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/unist-util-find-after": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-find-after/-/unist-util-find-after-5.0.0.tgz", + "integrity": "sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/unist-util-is": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.0.tgz", @@ -9218,6 +9423,20 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/unist-util-remove-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-remove-position/-/unist-util-remove-position-5.0.0.tgz", + "integrity": "sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-visit": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/unist-util-stringify-position": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", diff --git a/package.json b/package.json index 7fb2b9cf2..0e74c5ac9 100644 --- a/package.json +++ b/package.json @@ -18,8 +18,10 @@ "react-icons": "^5.5.0", "react-markdown": "^10.1.0", "react-syntax-highlighter": "^15.6.1", + "rehype-katex": "^7.0.1", "rehype-raw": "^7.0.0", "remark-gfm": "^4.0.1", + "remark-math": "^6.0.0", "svg-pan-zoom": "^3.6.2" }, "devDependencies": { diff --git a/src/components/Markdown.tsx b/src/components/Markdown.tsx index 69dcfe8f7..8e78db95f 100644 --- a/src/components/Markdown.tsx +++ b/src/components/Markdown.tsx @@ -1,10 +1,13 @@ import React from 'react'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; +import remarkMath from 'remark-math'; import rehypeRaw from 'rehype-raw'; +import rehypeKatex from 'rehype-katex'; import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; import { tomorrow } from 'react-syntax-highlighter/dist/cjs/styles/prism'; import Mermaid from './Mermaid'; +import 'katex/dist/katex.min.css'; interface MarkdownProps { content: string; @@ -195,8 +198,8 @@ const Markdown: React.FC = ({ content }) => { return (
{content} From e8b6f1ebebf00ad8a530b1ee59cb1fd13b492e90 Mon Sep 17 00:00:00 2001 From: Octopus Date: Wed, 22 Apr 2026 03:21:01 +0800 Subject: [PATCH 16/50] fix: update Bitbucket clone auth to use API token scheme (#509) * fix: update Bitbucket clone auth to use API token scheme Bitbucket is deprecating app passwords (EOL June 2026) and new users can no longer create them. The current code uses the `x-token-auth` scheme, which only works for app passwords. Bitbucket HTTP access tokens require the `x-bitbucket-api-token-auth` scheme. Update the clone URL format for Bitbucket repositories to use `x-bitbucket-api-token-auth`, which is compatible with the current Bitbucket API token authentication method. Fixes #444 * fix(bitbucket): keep x-token-auth for app passwords, switch on prefix Bitbucket app passwords are still supported until June 2026 and use the x-token-auth scheme. The earlier patch hard-coded x-bitbucket-api-token-auth which broke existing app-password users. Detect the new HTTP access token format by its 'ATCTT' prefix and pick the matching scheme; fall back to x-token-auth for everything else. --------- Co-authored-by: octo-patch --- api/data_pipeline.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/api/data_pipeline.py b/api/data_pipeline.py index 5e1f5fa47..f98068651 100644 --- a/api/data_pipeline.py +++ b/api/data_pipeline.py @@ -116,8 +116,16 @@ def download_repo(repo_url: str, local_path: str, repo_type: str = None, access_ # Format: https://oauth2:{token}@gitlab.com/owner/repo.git clone_url = urlunparse((parsed.scheme, f"oauth2:{encoded_token}@{parsed.netloc}", parsed.path, '', '', '')) elif repo_type == "bitbucket": - # Format: https://x-token-auth:{token}@bitbucket.org/owner/repo.git - clone_url = urlunparse((parsed.scheme, f"x-token-auth:{encoded_token}@{parsed.netloc}", parsed.path, '', '', '')) + # Bitbucket has two token formats with different auth schemes: + # - HTTP access tokens (prefix "ATCTT") use x-bitbucket-api-token-auth + # - App passwords (deprecated, EOL June 2026) use x-token-auth + # Detect by token prefix so existing app password users keep working. + if access_token.startswith("ATCTT"): + auth_scheme = "x-bitbucket-api-token-auth" + else: + auth_scheme = "x-token-auth" + # Format: https://{auth_scheme}:{token}@bitbucket.org/owner/repo.git + clone_url = urlunparse((parsed.scheme, f"{auth_scheme}:{encoded_token}@{parsed.netloc}", parsed.path, '', '', '')) logger.info("Using access token for authentication") From 5b43df5464eae557e973d8bccc94d0a82d43bfc7 Mon Sep 17 00:00:00 2001 From: "Sheing [ASYNCFUNC]" Date: Tue, 21 Apr 2026 14:33:44 -0500 Subject: [PATCH 17/50] Deepwiki is coming back Removed announcement about shifting focus to AsyncReview. --- README.md | 9 --------- 1 file changed, 9 deletions(-) diff --git a/README.md b/README.md index ba8fd1a88..1c017aed8 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,3 @@ - -### āš ļø Announcement: Shifting focus to AsyncReview ---- - -**IMPORTANT UPDATE** DeepWiki-Open maintenance is ongoing, but primary active development is moving to **[AsyncReview](https://github.com/AsyncFuncAI/AsyncReview/)**. Thank you for the support on this project; please join me in the new repository for this year's primary effort. - ---- ---- - # DeepWiki-Open ![DeepWiki Banner](screenshots/Deepwiki.png) From 04e7c134cf857c8c2954193f6cce3730521bc656 Mon Sep 17 00:00:00 2001 From: "Sheing [ASYNCFUNC]" Date: Fri, 15 May 2026 23:35:14 -0500 Subject: [PATCH 18/50] Add Deepwiki-Open 2.0 section to README Added a section for Deepwiki-Open 2.0 with signup link. --- README.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/README.md b/README.md index 1c017aed8..0f3b50cac 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,16 @@ [English](./README.md) | [简体中文](./README.zh.md) | [繁體中文](./README.zh-tw.md) | [ę—„ęœ¬čŖž](./README.ja.md) | [EspaƱol](./README.es.md) | [ķ•œźµ­ģ–“](./README.kr.md) | [Tiįŗæng Việt](./README.vi.md) | [PortuguĆŖs Brasileiro](./README.pt-br.md) | [FranƧais](./README.fr.md) | [Русский](./README.ru.md) +## Deepwiki-Open 2.0 COMING SOON + +- **Signup at** https://grok-wiki.com for early access + + + +https://github.com/user-attachments/assets/48d1e60a-eb91-4c05-a5a8-3624ffb79fb1 + + + ## ✨ Features - **Instant Documentation**: Turn any GitHub, GitLab or BitBucket repo into a wiki in seconds From bba5b6c54fbc38ff9d285b368d65fb99ea91f65f Mon Sep 17 00:00:00 2001 From: "M. Mansour" Date: Thu, 21 May 2026 20:05:09 -0400 Subject: [PATCH 19/50] New Feature: Add optional LiteLLM Docker Compose setup for local LLM gateway (#526) * feature(docker): add optional LiteLLM docker compose setup * Using env. variables for passwords, with a default value * Removing the hardcoding of TARGETARCH --------- Co-authored-by: M. Mansour <3020010+marazik@users.noreply.github.com> --- Dockerfile-litellm | 122 +++++++++++++++++++++++++++++++++++++ docker-compose-litellm.env | 7 +++ docker-compose-litellm.yml | 70 +++++++++++++++++++++ litellm-config.yml | 26 ++++++++ 4 files changed, 225 insertions(+) create mode 100644 Dockerfile-litellm create mode 100644 docker-compose-litellm.env create mode 100644 docker-compose-litellm.yml create mode 100644 litellm-config.yml diff --git a/Dockerfile-litellm b/Dockerfile-litellm new file mode 100644 index 000000000..e44198aba --- /dev/null +++ b/Dockerfile-litellm @@ -0,0 +1,122 @@ +# syntax=docker/dockerfile:1-labs + +FROM node:20-alpine AS node_base + +FROM node_base AS node_deps +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci --legacy-peer-deps + +FROM node_base AS node_builder +WORKDIR /app +COPY --from=node_deps /app/node_modules ./node_modules +COPY --exclude=./api . . +RUN NODE_ENV=production npm run build + +FROM python:3.11-slim AS py_deps +WORKDIR /api +COPY api/pyproject.toml . +COPY api/poetry.lock . +RUN python -m pip install poetry==2.0.1 --no-cache-dir && \ + poetry config virtualenvs.create true --local && \ + poetry config virtualenvs.in-project true --local && \ + poetry config virtualenvs.options.always-copy --local true && \ + POETRY_MAX_WORKERS=10 poetry install --no-interaction --no-ansi --only main && \ + poetry cache clear --all . + +FROM python:3.11-slim AS ollama_base +RUN apt-get update && apt-get install -y --no-install-recommends \ + curl zstd && rm -rf /var/lib/apt/lists/* + +# Detect architecture and download appropriate Ollama version +# ARG TARGETARCH can be set at build time with --build-arg TARGETARCH=arm64 or TARGETARCH=amd64 +ARG TARGETARCH +RUN OLLAMA_ARCH="" && \ + if [ "$TARGETARCH" = "arm64" ]; then \ + echo "Building for ARM64 architecture." && \ + OLLAMA_ARCH="arm64"; \ + elif [ "$TARGETARCH" = "amd64" ]; then \ + echo "Building for AMD64 architecture." && \ + OLLAMA_ARCH="amd64"; \ + else \ + echo "Error: Unsupported architecture '$TARGETARCH'. Supported architectures are 'arm64' and 'amd64'." >&2 && \ + exit 1; \ + fi && \ + (set -o pipefail; \ + curl -fL "https://ollama.com/download/ollama-linux-${OLLAMA_ARCH}.tar.zst" \ + | zstd -d | tar -x -C /usr) + +RUN ollama serve > /dev/null 2>&1 & \ + sleep 20 && \ + ollama pull nomic-embed-text && \ + ollama pull qwen3:1.7b + +# Use Python 3.11 as final image +FROM python:3.11-slim + +# Set working directory +WORKDIR /app + +# Install Node.js and npm +RUN apt-get update && apt-get install -y \ + curl \ + gnupg \ + git \ + ca-certificates \ + && mkdir -p /etc/apt/keyrings \ + && curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \ + && echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_20.x nodistro main" | tee /etc/apt/sources.list.d/nodesource.list \ + && apt-get update \ + && apt-get install -y nodejs \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +ENV PATH="/opt/venv/bin:$PATH" + +# Copy Python dependencies +COPY --from=py_deps /api/.venv /opt/venv +COPY api/ ./api/ + +# Copy Node app +COPY --from=node_builder /app/public ./public +COPY --from=node_builder /app/.next/standalone ./ +COPY --from=node_builder /app/.next/static ./.next/static +COPY --from=ollama_base /usr/bin/ollama /usr/local/bin/ +COPY --from=ollama_base /root/.ollama /root/.ollama + +# Expose the port the app runs on +EXPOSE ${PORT:-8001} 3000 + +# Create a script to run both backend and frontend +RUN echo '#!/bin/bash\n\ +# Start ollama serve in background\n\ +ollama serve > /dev/null 2>&1 &\n\ +\n\ +# Load environment variables from .env file if it exists\n\ +if [ -f .env ]; then\n\ + export $(grep -v "^#" .env | xargs -r)\n\ +fi\n\ +\n\ +# Check for required environment variables\n\ +if [ -z "$OPENAI_API_KEY" ] || [ -z "$GOOGLE_API_KEY" ]; then\n\ + echo "Warning: OPENAI_API_KEY and/or GOOGLE_API_KEY environment variables are not set."\n\ + echo "These are required for DeepWiki to function properly."\n\ + echo "You can provide them via a mounted .env file or as environment variables when running the container."\n\ +fi\n\ +\n\ +# Start the API server in the background with the configured port\n\ +python -m api.main --port ${PORT:-8001} &\n\ +PORT=3000 HOSTNAME=0.0.0.0 node server.js &\n\ +wait -n\n\ +exit $?' > /app/start.sh && chmod +x /app/start.sh + +# Set environment variables +ENV PORT=8001 +ENV NODE_ENV=production +ENV SERVER_BASE_URL=http://localhost:${PORT:-8001} + +# Create empty .env file (will be overridden if one exists at runtime) +RUN touch .env + +# Command to run the application +CMD ["/app/start.sh"] diff --git a/docker-compose-litellm.env b/docker-compose-litellm.env new file mode 100644 index 000000000..0a246ec4e --- /dev/null +++ b/docker-compose-litellm.env @@ -0,0 +1,7 @@ +# Same Compose network http://litellm:4000 +# LiteLLM on host OS http://host.docker.internal:4000 +# LiteLLM on another server http://server-ip:4000 +LITELLM_BASE_URL=http://litellm:4000 +# Use env. variable LITELLM_API_KEY +# But if it doesn't exist, default to `sk-1234` +LITELLM_API_KEY=${LITELLM_API_KEY:-sk-1234} \ No newline at end of file diff --git a/docker-compose-litellm.yml b/docker-compose-litellm.yml new file mode 100644 index 000000000..6f90878a2 --- /dev/null +++ b/docker-compose-litellm.yml @@ -0,0 +1,70 @@ +services: + db: + image: postgres:16 + environment: + POSTGRES_DB: litellm + POSTGRES_USER: litellm + # Use env. variable LITELLM_DB_PASSWORD + # otherwise default to litellm_password + POSTGRES_PASSWORD: ${LITELLM_DB_PASSWORD:-litellm_password} + ports: + - "5432:5432" + volumes: + - deepwiki_litellm_db:/var/lib/postgresql/data + + litellm: + image: ghcr.io/berriai/litellm:v1.83.10-stable + ports: + - "4000:4000" + volumes: + - ./litellm-config.yml:/app/config.yaml + environment: + # Use env. variable LITELLM_MASTER_KEY + # otherwise default to sk-1234 + - LITELLM_MASTER_KEY=${LITELLM_MASTER_KEY:-sk-1234} + # Using from litellm-config.yml + #environment: + # DATABASE_URL: postgres://litellm:litellm_password@db:5432/litellm + command: [ "--config", "/app/config.yaml", "--port", "4000" ] + extra_hosts: + - "host.docker.internal:host-gateway" + depends_on: + - db + + deepwiki: + image: deepwiki-litellm # Using image name to show it depends on LiteLLM + build: + context: . + dockerfile: Dockerfile-litellm # ← Using Dockerfile that depends on LiteLLM + ports: + - "${PORT:-8001}:${PORT:-8001}" # API port + - "3000:3000" # Next.js port + env_file: + - docker-compose-litellm.env + environment: + - PORT=${PORT:-8001} + - NODE_ENV=production + - SERVER_BASE_URL=http://localhost:${PORT:-8001} + - LOG_LEVEL=${LOG_LEVEL:-INFO} + - LOG_FILE_PATH=${LOG_FILE_PATH:-api/logs/application.log} + # For LiteLLM + - LITELLM_BASE_URL=${LITELLM_BASE_URL} + - LITELLM_API_KEY=${LITELLM_API_KEY} + volumes: + - ~/.adalflow:/root/.adalflow # Persist repository and embedding data + - ./api/logs:/app/api/logs # Persist log files across container restarts + # Resource limits for docker-compose up (not Swarm mode) + mem_limit: 6g + mem_reservation: 2g + # Health check configuration + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:${PORT:-8001}/health"] + interval: 60s + timeout: 10s + retries: 3 + start_period: 30s + depends_on: + - litellm + +volumes: + deepwiki_litellm_db: \ No newline at end of file diff --git a/litellm-config.yml b/litellm-config.yml new file mode 100644 index 000000000..004bccdea --- /dev/null +++ b/litellm-config.yml @@ -0,0 +1,26 @@ +model_list: + # ----------------------- + # Chat / Generation models + # ----------------------- + - model_name: qwen3:1.7b #Can be named anything - qwen3-1.7b + litellm_params: + model: ollama/qwen3:1.7b + api_base: http://host.docker.internal:11434 + api_key: "not-needed" + + # ----------------------- + # Embedding models + # ----------------------- + - model_name: nomic-embed-text + litellm_params: + model: ollama/nomic-embed-text + api_base: http://host.docker.internal:11434 + api_key: "not-needed" + +# This overrides the one in the docker-compose file +general_settings: + database_url: postgres://litellm:litellm_password@db:5432/litellm + +litellm_settings: + set_verbose: true + drop_params: true From 56669d8eaf45a006a4531266651a938127f03239 Mon Sep 17 00:00:00 2001 From: "Sheing [ASYNCFUNC]" Date: Thu, 21 May 2026 19:09:18 -0500 Subject: [PATCH 20/50] Grok Wiki is now Live! Grok Wiki is the new chapter of Deepwiki-Open 2.0 --- README.md | 634 +----------------------------------------------------- 1 file changed, 4 insertions(+), 630 deletions(-) diff --git a/README.md b/README.md index 0f3b50cac..8abbe6740 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# DeepWiki-Open +# DeepWiki-Open (Grok-Wiki) -![DeepWiki Banner](screenshots/Deepwiki.png) +grok-wiki **DeepWiki** is my own implementation attempt of DeepWiki, automatically creates beautiful, interactive wikis for any GitHub, GitLab, or BitBucket repository! Just enter a repo name, and DeepWiki will: @@ -16,9 +16,9 @@ [English](./README.md) | [简体中文](./README.zh.md) | [繁體中文](./README.zh-tw.md) | [ę—„ęœ¬čŖž](./README.ja.md) | [EspaƱol](./README.es.md) | [ķ•œźµ­ģ–“](./README.kr.md) | [Tiįŗæng Việt](./README.vi.md) | [PortuguĆŖs Brasileiro](./README.pt-br.md) | [FranƧais](./README.fr.md) | [Русский](./README.ru.md) -## Deepwiki-Open 2.0 COMING SOON +## Deepwiki-Open 2.0 (Grok Wiki is now live) -- **Signup at** https://grok-wiki.com for early access +- **Download at** https://grok-wiki.com @@ -26,632 +26,6 @@ https://github.com/user-attachments/assets/48d1e60a-eb91-4c05-a5a8-3624ffb79fb1 -## ✨ Features - -- **Instant Documentation**: Turn any GitHub, GitLab or BitBucket repo into a wiki in seconds -- **Private Repository Support**: Securely access private repositories with personal access tokens -- **Smart Analysis**: AI-powered understanding of code structure and relationships -- **Beautiful Diagrams**: Automatic Mermaid diagrams to visualize architecture and data flow -- **Easy Navigation**: Simple, intuitive interface to explore the wiki -- **Ask Feature**: Chat with your repository using RAG-powered AI to get accurate answers -- **DeepResearch**: Multi-turn research process that thoroughly investigates complex topics -- **Multiple Model Providers**: Support for Google Gemini, OpenAI, OpenRouter, and local Ollama models -- **Flexible Embeddings**: Choose between OpenAI, Google AI, or local Ollama embeddings for optimal performance - -## šŸš€ Quick Start (Super Easy!) - -### Option 1: Using Docker - -```bash -# Clone the repository -git clone https://github.com/AsyncFuncAI/deepwiki-open.git -cd deepwiki-open - -# Create a .env file with your API keys -echo "GOOGLE_API_KEY=your_google_api_key" > .env -echo "OPENAI_API_KEY=your_openai_api_key" >> .env -# Optional: Use Google AI embeddings instead of OpenAI (recommended if using Google models) -echo "DEEPWIKI_EMBEDDER_TYPE=google" >> .env -# Optional: Add OpenRouter API key if you want to use OpenRouter models -echo "OPENROUTER_API_KEY=your_openrouter_api_key" >> .env -# Optional: Add Ollama host if not local. defaults to http://localhost:11434 -echo "OLLAMA_HOST=your_ollama_host" >> .env -# Optional: Add Azure API key, endpoint and version if you want to use azure openai models -echo "AZURE_OPENAI_API_KEY=your_azure_openai_api_key" >> .env -echo "AZURE_OPENAI_ENDPOINT=your_azure_openai_endpoint" >> .env -echo "AZURE_OPENAI_VERSION=your_azure_openai_version" >> .env -# Run with Docker Compose -docker-compose up -``` - -For detailed instructions on using DeepWiki with Ollama and Docker, see [Ollama Instructions](Ollama-instruction.md). - -> šŸ’” **Where to get these keys:** -> - Get a Google API key from [Google AI Studio](https://makersuite.google.com/app/apikey) -> - Get an OpenAI API key from [OpenAI Platform](https://platform.openai.com/api-keys) -> - Get Azure OpenAI credentials from [Azure Portal](https://portal.azure.com/) - create an Azure OpenAI resource and get the API key, endpoint, and API version - -### Option 2: Manual Setup (Recommended) - -#### Step 1: Set Up Your API Keys - -Create a `.env` file in the project root with these keys: - -``` -GOOGLE_API_KEY=your_google_api_key -OPENAI_API_KEY=your_openai_api_key -# Optional: Use Google AI embeddings (recommended if using Google models) -DEEPWIKI_EMBEDDER_TYPE=google -# Optional: Add this if you want to use OpenRouter models -OPENROUTER_API_KEY=your_openrouter_api_key -# Optional: Add this if you want to use Azure OpenAI models -AZURE_OPENAI_API_KEY=your_azure_openai_api_key -AZURE_OPENAI_ENDPOINT=your_azure_openai_endpoint -AZURE_OPENAI_VERSION=your_azure_openai_version -# Optional: Add Ollama host if not local. default: http://localhost:11434 -OLLAMA_HOST=your_ollama_host -``` - -#### Step 2: Start the Backend - -```bash -# Install Python dependencies -python -m pip install poetry==2.0.1 && poetry install -C api - -# Start the API server -python -m api.main -``` - -#### Step 3: Start the Frontend - -```bash -# Install JavaScript dependencies -npm install -# or -yarn install - -# Start the web app -npm run dev -# or -yarn dev -``` - -#### Step 4: Use DeepWiki! - -1. Open [http://localhost:3000](http://localhost:3000) in your browser -2. Enter a GitHub, GitLab, or Bitbucket repository (like `https://github.com/openai/codex`, `https://github.com/microsoft/autogen`, `https://gitlab.com/gitlab-org/gitlab`, or `https://bitbucket.org/redradish/atlassian_app_versions`) -3. For private repositories, click "+ Add access tokens" and enter your GitHub or GitLab personal access token -4. Click "Generate Wiki" and watch the magic happen! - -## šŸ” How It Works - -DeepWiki uses AI to: - -1. Clone and analyze the GitHub, GitLab, or Bitbucket repository (including private repos with token authentication) -2. Create embeddings of the code for smart retrieval -3. Generate documentation with context-aware AI (using Google Gemini, OpenAI, OpenRouter, Azure OpenAI, or local Ollama models) -4. Create visual diagrams to explain code relationships -5. Organize everything into a structured wiki -6. Enable intelligent Q&A with the repository through the Ask feature -7. Provide in-depth research capabilities with DeepResearch - -```mermaid -graph TD - A[User inputs GitHub/GitLab/Bitbucket repo] --> AA{Private repo?} - AA -->|Yes| AB[Add access token] - AA -->|No| B[Clone Repository] - AB --> B - B --> C[Analyze Code Structure] - C --> D[Create Code Embeddings] - - D --> M{Select Model Provider} - M -->|Google Gemini| E1[Generate with Gemini] - M -->|OpenAI| E2[Generate with OpenAI] - M -->|OpenRouter| E3[Generate with OpenRouter] - M -->|Local Ollama| E4[Generate with Ollama] - M -->|Azure| E5[Generate with Azure] - - E1 --> E[Generate Documentation] - E2 --> E - E3 --> E - E4 --> E - E5 --> E - - D --> F[Create Visual Diagrams] - E --> G[Organize as Wiki] - F --> G - G --> H[Interactive DeepWiki] - - classDef process stroke-width:2px; - classDef data stroke-width:2px; - classDef result stroke-width:2px; - classDef decision stroke-width:2px; - - class A,D data; - class AA,M decision; - class B,C,E,F,G,AB,E1,E2,E3,E4,E5 process; - class H result; -``` - -## šŸ› ļø Project Structure - -``` -deepwiki/ -ā”œā”€ā”€ api/ # Backend API server -│ ā”œā”€ā”€ main.py # API entry point -│ ā”œā”€ā”€ api.py # FastAPI implementation -│ ā”œā”€ā”€ rag.py # Retrieval Augmented Generation -│ ā”œā”€ā”€ data_pipeline.py # Data processing utilities -│ ā”œā”€ā”€ pyproject.toml # Python dependencies (Poetry) -│ └── poetry.lock # Locked Python dependency versions -│ -ā”œā”€ā”€ src/ # Frontend Next.js app -│ ā”œā”€ā”€ app/ # Next.js app directory -│ │ └── page.tsx # Main application page -│ └── components/ # React components -│ └── Mermaid.tsx # Mermaid diagram renderer -│ -ā”œā”€ā”€ public/ # Static assets -ā”œā”€ā”€ package.json # JavaScript dependencies -└── .env # Environment variables (create this) -``` - -## šŸ¤– Provider-Based Model Selection System - -DeepWiki now implements a flexible provider-based model selection system supporting multiple LLM providers: - -### Supported Providers and Models - -- **Google**: Default `gemini-2.5-flash`, also supports `gemini-2.5-flash-lite`, `gemini-2.5-pro`, etc. -- **OpenAI**: Default `gpt-5-nano`, also supports `gpt-5`, `4o`, etc. -- **OpenRouter**: Access to multiple models via a unified API, including Claude, Llama, Mistral, etc. -- **Azure OpenAI**: Default `gpt-4o`, also supports `o4-mini`, etc. -- **Ollama**: Support for locally running open-source models like `llama3` - -### Environment Variables - -Each provider requires its corresponding API key environment variables: - -``` -# API Keys -GOOGLE_API_KEY=your_google_api_key # Required for Google Gemini models -OPENAI_API_KEY=your_openai_api_key # Required for OpenAI models -OPENROUTER_API_KEY=your_openrouter_api_key # Required for OpenRouter models -AZURE_OPENAI_API_KEY=your_azure_openai_api_key #Required for Azure OpenAI models -AZURE_OPENAI_ENDPOINT=your_azure_openai_endpoint #Required for Azure OpenAI models -AZURE_OPENAI_VERSION=your_azure_openai_version #Required for Azure OpenAI models - -# OpenAI API Base URL Configuration -OPENAI_BASE_URL=https://custom-api-endpoint.com/v1 # Optional, for custom OpenAI API endpoints - -# Ollama host -OLLAMA_HOST=your_ollama_host # Optional, if Ollama is not local. default: http://localhost:11434 - -# Configuration Directory -DEEPWIKI_CONFIG_DIR=/path/to/custom/config/dir # Optional, for custom config file location -``` - -### Configuration Files - -DeepWiki uses JSON configuration files to manage various aspects of the system: - -1. **`generator.json`**: Configuration for text generation models - - Defines available model providers (Google, OpenAI, OpenRouter, Azure, Ollama) - - Specifies default and available models for each provider - - Contains model-specific parameters like temperature and top_p - -2. **`embedder.json`**: Configuration for embedding models and text processing - - Defines embedding models for vector storage - - Contains retriever configuration for RAG - - Specifies text splitter settings for document chunking - -3. **`repo.json`**: Configuration for repository handling - - Contains file filters to exclude certain files and directories - - Defines repository size limits and processing rules - -By default, these files are located in the `api/config/` directory. You can customize their location using the `DEEPWIKI_CONFIG_DIR` environment variable. - -### Custom Model Selection for Service Providers - -The custom model selection feature is specifically designed for service providers who need to: - -- You can offer multiple AI model choices to users within your organization -- You can quickly adapt to the rapidly evolving LLM landscape without code changes -- You can support specialized or fine-tuned models that aren't in the predefined list - -Service providers can implement their model offerings by selecting from the predefined options or entering custom model identifiers in the frontend interface. - -### Base URL Configuration for Enterprise Private Channels - -The OpenAI Client's base_url configuration is designed primarily for enterprise users with private API channels. This feature: - -- Enables connection to private or enterprise-specific API endpoints -- Allows organizations to use their own self-hosted or custom-deployed LLM services -- Supports integration with third-party OpenAI API-compatible services - -**Coming Soon**: In future updates, DeepWiki will support a mode where users need to provide their own API keys in requests. This will allow enterprise customers with private channels to use their existing API arrangements without sharing credentials with the DeepWiki deployment. - -## 🧩 Using OpenAI-Compatible Embedding Models (e.g., Alibaba Qwen) - -If you want to use embedding models compatible with the OpenAI API (such as Alibaba Qwen), follow these steps: - -1. Replace the contents of `api/config/embedder.json` with those from `api/config/embedder_openai_compatible.json`. -2. In your project root `.env` file, set the relevant environment variables, for example: - ``` - OPENAI_API_KEY=your_api_key - OPENAI_BASE_URL=your_openai_compatible_endpoint - ``` -3. The program will automatically substitute placeholders in embedder.json with the values from your environment variables. - -This allows you to seamlessly switch to any OpenAI-compatible embedding service without code changes. - -## 🧠 Using Google AI Embeddings - -DeepWiki now supports Google AI's latest embedding models as an alternative to OpenAI embeddings. This provides better integration when you're already using Google Gemini models for text generation. - -### Features - -- **Latest Model**: Uses Google's `text-embedding-004` model -- **Same API Key**: Uses your existing `GOOGLE_API_KEY` (no additional setup required) -- **Better Integration**: Optimized for use with Google Gemini text generation models -- **Task-Specific**: Supports semantic similarity, retrieval, and classification tasks -- **Batch Processing**: Efficient processing of multiple texts - -### How to Enable Google AI Embeddings - -**Option 1: Environment Variable (Recommended)** - -Set the embedder type in your `.env` file: - -```bash -# Your existing Google API key -GOOGLE_API_KEY=your_google_api_key - -# Enable Google AI embeddings -DEEPWIKI_EMBEDDER_TYPE=google -``` - -**Option 2: Docker Environment** - -```bash -docker run -p 8001:8001 -p 3000:3000 \ - -e GOOGLE_API_KEY=your_google_api_key \ - -e DEEPWIKI_EMBEDDER_TYPE=google \ - -v ~/.adalflow:/root/.adalflow \ - ghcr.io/asyncfuncai/deepwiki-open:latest -``` - -**Option 3: Docker Compose** - -Add to your `.env` file: - -```bash -GOOGLE_API_KEY=your_google_api_key -DEEPWIKI_EMBEDDER_TYPE=google -``` - -Then run: - -```bash -docker-compose up -``` - -### Available Embedder Types - -| Type | Description | API Key Required | Notes | -|------|-------------|------------------|-------| -| `openai` | OpenAI embeddings (default) | `OPENAI_API_KEY` | Uses `text-embedding-3-small` model | -| `google` | Google AI embeddings | `GOOGLE_API_KEY` | Uses `text-embedding-004` model | -| `ollama` | Local Ollama embeddings | None | Requires local Ollama installation | - -### Why Use Google AI Embeddings? - -- **Consistency**: If you're using Google Gemini for text generation, using Google embeddings provides better semantic consistency -- **Performance**: Google's latest embedding model offers excellent performance for retrieval tasks -- **Cost**: Competitive pricing compared to OpenAI -- **No Additional Setup**: Uses the same API key as your text generation models - -### Switching Between Embedders - -You can easily switch between different embedding providers: - -```bash -# Use OpenAI embeddings (default) -export DEEPWIKI_EMBEDDER_TYPE=openai - -# Use Google AI embeddings -export DEEPWIKI_EMBEDDER_TYPE=google - -# Use local Ollama embeddings -export DEEPWIKI_EMBEDDER_TYPE=ollama -``` - -**Note**: When switching embedders, you may need to regenerate your repository embeddings as different models produce different vector spaces. - -### Logging - -DeepWiki uses Python's built-in `logging` module for diagnostic output. You can configure the verbosity and log file destination via environment variables: - -| Variable | Description | Default | -|-----------------|--------------------------------------------------------------------|------------------------------| -| `LOG_LEVEL` | Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL). | INFO | -| `LOG_FILE_PATH` | Path to the log file. If set, logs will be written to this file. | `api/logs/application.log` | - -To enable debug logging and direct logs to a custom file: -```bash -export LOG_LEVEL=DEBUG -export LOG_FILE_PATH=./debug.log -python -m api.main -``` -Or with Docker Compose: -```bash -LOG_LEVEL=DEBUG LOG_FILE_PATH=./debug.log docker-compose up -``` - -When running with Docker Compose, the container's `api/logs` directory is bind-mounted to `./api/logs` on your host (see the `volumes` section in `docker-compose.yml`), ensuring log files persist across restarts. - -Alternatively, you can store these settings in your `.env` file: - -```bash -LOG_LEVEL=DEBUG -LOG_FILE_PATH=./debug.log -``` -Then simply run: - -```bash -docker-compose up -``` - -**Logging Path Security Considerations:** In production environments, ensure the `api/logs` directory and any custom log file path are secured with appropriate filesystem permissions and access controls. The application enforces that `LOG_FILE_PATH` resides within the project's `api/logs` directory to prevent path traversal or unauthorized writes. - -## šŸ› ļø Advanced Setup - -### Environment Variables - -| Variable | Description | Required | Note | -|----------------------|--------------------------------------------------------------|----------|----------------------------------------------------------------------------------------------------------| -| `GOOGLE_API_KEY` | Google Gemini API key for AI generation and embeddings | No | Required for Google Gemini models and Google AI embeddings -| `OPENAI_API_KEY` | OpenAI API key for embeddings and models | Conditional | Required if using OpenAI embeddings or models | -| `OPENROUTER_API_KEY` | OpenRouter API key for alternative models | No | Required only if you want to use OpenRouter models | -| `AWS_ACCESS_KEY_ID` | AWS access key ID for Bedrock | No | Required for Bedrock if not using instance/role-based credentials | -| `AWS_SECRET_ACCESS_KEY` | AWS secret access key for Bedrock | No | Required for Bedrock if not using instance/role-based credentials | -| `AWS_SESSION_TOKEN` | AWS session token for Bedrock (STS) | No | Required when using temporary credentials | -| `AWS_REGION` | AWS region for Bedrock (default: `us-east-1`) | No | Used by Bedrock client | -| `AWS_ROLE_ARN` | AWS role ARN to assume for Bedrock | No | If set, the Bedrock client will call STS AssumeRole | -| `AZURE_OPENAI_API_KEY` | Azure OpenAI API key | No | Required only if you want to use Azure OpenAI models | -| `AZURE_OPENAI_ENDPOINT` | Azure OpenAI endpoint | No | Required only if you want to use Azure OpenAI models | -| `AZURE_OPENAI_VERSION` | Azure OpenAI version | No | Required only if you want to use Azure OpenAI models | -| `OLLAMA_HOST` | Ollama Host (default: http://localhost:11434) | No | Required only if you want to use external Ollama server | -| `DEEPWIKI_EMBEDDER_TYPE` | Embedder type: `openai`, `google`, `ollama`, or `bedrock` (default: `openai`) | No | Controls which embedding provider to use | -| `PORT` | Port for the API server (default: 8001) | No | If you host API and frontend on the same machine, make sure change port of `SERVER_BASE_URL` accordingly | -| `SERVER_BASE_URL` | Base URL for the API server (default: http://localhost:8001) | No | -| `DEEPWIKI_AUTH_MODE` | Set to `true` or `1` to enable authorization mode. | No | Defaults to `false`. If enabled, `DEEPWIKI_AUTH_CODE` is required. | -| `DEEPWIKI_AUTH_CODE` | The secret code required for wiki generation when `DEEPWIKI_AUTH_MODE` is enabled. | No | Only used if `DEEPWIKI_AUTH_MODE` is `true` or `1`. | - -**API Key Requirements:** -- If using `DEEPWIKI_EMBEDDER_TYPE=openai` (default): `OPENAI_API_KEY` is required -- If using `DEEPWIKI_EMBEDDER_TYPE=google`: `GOOGLE_API_KEY` is required -- If using `DEEPWIKI_EMBEDDER_TYPE=ollama`: No API key required (local processing) -- If using `DEEPWIKI_EMBEDDER_TYPE=bedrock`: AWS credentials (or role-based credentials) are required - -Other API keys are only required when configuring and using models from the corresponding providers. - -## Authorization Mode - -DeepWiki can be configured to run in an authorization mode, where wiki generation requires a valid authorization code. This is useful if you want to control who can use the generation feature. -Restricts frontend initiation and protects cache deletion, but doesn't fully prevent backend generation if API endpoints are hit directly. - -To enable authorization mode, set the following environment variables: - -- `DEEPWIKI_AUTH_MODE`: Set this to `true` or `1`. When enabled, the frontend will display an input field for the authorization code. -- `DEEPWIKI_AUTH_CODE`: Set this to the desired secret code. Restricts frontend initiation and protects cache deletion, but doesn't fully prevent backend generation if API endpoints are hit directly. - -If `DEEPWIKI_AUTH_MODE` is not set or is set to `false` (or any other value than `true`/`1`), the authorization feature will be disabled, and no code will be required. - -### Docker Setup - -You can use Docker to run DeepWiki: - -#### Running the Container - -```bash -# Pull the image from GitHub Container Registry -docker pull ghcr.io/asyncfuncai/deepwiki-open:latest - -# Run the container with environment variables -docker run -p 8001:8001 -p 3000:3000 \ - -e GOOGLE_API_KEY=your_google_api_key \ - -e OPENAI_API_KEY=your_openai_api_key \ - -e OPENROUTER_API_KEY=your_openrouter_api_key \ - -e OLLAMA_HOST=your_ollama_host \ - -e AZURE_OPENAI_API_KEY=your_azure_openai_api_key \ - -e AZURE_OPENAI_ENDPOINT=your_azure_openai_endpoint \ - -e AZURE_OPENAI_VERSION=your_azure_openai_version \ - - -v ~/.adalflow:/root/.adalflow \ - ghcr.io/asyncfuncai/deepwiki-open:latest -``` - -This command also mounts `~/.adalflow` on your host to `/root/.adalflow` in the container. This path is used to store: -- Cloned repositories (`~/.adalflow/repos/`) -- Their embeddings and indexes (`~/.adalflow/databases/`) -- Cached generated wiki content (`~/.adalflow/wikicache/`) - -This ensures that your data persists even if the container is stopped or removed. - -Or use the provided `docker-compose.yml` file: - -```bash -# Edit the .env file with your API keys first -docker-compose up -``` - -(The `docker-compose.yml` file is pre-configured to mount `~/.adalflow` for data persistence, similar to the `docker run` command above.) - -#### Using a .env file with Docker - -You can also mount a .env file to the container: - -```bash -# Create a .env file with your API keys -echo "GOOGLE_API_KEY=your_google_api_key" > .env -echo "OPENAI_API_KEY=your_openai_api_key" >> .env -echo "OPENROUTER_API_KEY=your_openrouter_api_key" >> .env -echo "AZURE_OPENAI_API_KEY=your_azure_openai_api_key" >> .env -echo "AZURE_OPENAI_ENDPOINT=your_azure_openai_endpoint" >> .env -echo "AZURE_OPENAI_VERSION=your_azure_openai_version" >>.env -echo "OLLAMA_HOST=your_ollama_host" >> .env - -# Run the container with the .env file mounted -docker run -p 8001:8001 -p 3000:3000 \ - -v $(pwd)/.env:/app/.env \ - -v ~/.adalflow:/root/.adalflow \ - ghcr.io/asyncfuncai/deepwiki-open:latest -``` - -This command also mounts `~/.adalflow` on your host to `/root/.adalflow` in the container. This path is used to store: -- Cloned repositories (`~/.adalflow/repos/`) -- Their embeddings and indexes (`~/.adalflow/databases/`) -- Cached generated wiki content (`~/.adalflow/wikicache/`) - -This ensures that your data persists even if the container is stopped or removed. - -#### Building the Docker image locally - -If you want to build the Docker image locally: - -```bash -# Clone the repository -git clone https://github.com/AsyncFuncAI/deepwiki-open.git -cd deepwiki-open - -# Build the Docker image -docker build -t deepwiki-open . - -# Run the container -docker run -p 8001:8001 -p 3000:3000 \ - -e GOOGLE_API_KEY=your_google_api_key \ - -e OPENAI_API_KEY=your_openai_api_key \ - -e OPENROUTER_API_KEY=your_openrouter_api_key \ - -e AZURE_OPENAI_API_KEY=your_azure_openai_api_key \ - -e AZURE_OPENAI_ENDPOINT=your_azure_openai_endpoint \ - -e AZURE_OPENAI_VERSION=your_azure_openai_version \ - -e OLLAMA_HOST=your_ollama_host \ - deepwiki-open -``` - -#### Using Self-Signed Certificates in Docker - -If you're in an environment that uses self-signed certificates, you can include them in the Docker build: - -1. Create a directory for your certificates (default is `certs` in your project root) -2. Copy your `.crt` or `.pem` certificate files into this directory -3. Build the Docker image: - -```bash -# Build with default certificates directory (certs) -docker build . - -# Or build with a custom certificates directory -docker build --build-arg CUSTOM_CERT_DIR=my-custom-certs . -``` - -### API Server Details - -The API server provides: -- Repository cloning and indexing -- RAG (Retrieval Augmented Generation) -- Streaming chat completions - -For more details, see the [API README](./api/README.md). - -## šŸ”Œ OpenRouter Integration - -DeepWiki now supports [OpenRouter](https://openrouter.ai/) as a model provider, giving you access to hundreds of AI models through a single API: - -- **Multiple Model Options**: Access models from OpenAI, Anthropic, Google, Meta, Mistral, and more -- **Simple Configuration**: Just add your OpenRouter API key and select the model you want to use -- **Cost Efficiency**: Choose models that fit your budget and performance needs -- **Easy Switching**: Toggle between different models without changing your code - -### How to Use OpenRouter with DeepWiki - -1. **Get an API Key**: Sign up at [OpenRouter](https://openrouter.ai/) and get your API key -2. **Add to Environment**: Add `OPENROUTER_API_KEY=your_key` to your `.env` file -3. **Enable in UI**: Check the "Use OpenRouter API" option on the homepage -4. **Select Model**: Choose from popular models like GPT-4o, Claude 3.5 Sonnet, Gemini 2.0, and more - -OpenRouter is particularly useful if you want to: -- Try different models without signing up for multiple services -- Access models that might be restricted in your region -- Compare performance across different model providers -- Optimize for cost vs. performance based on your needs - -## šŸ¤– Ask & DeepResearch Features - -### Ask Feature - -The Ask feature allows you to chat with your repository using Retrieval Augmented Generation (RAG): - -- **Context-Aware Responses**: Get accurate answers based on the actual code in your repository -- **RAG-Powered**: The system retrieves relevant code snippets to provide grounded responses -- **Real-Time Streaming**: See responses as they're generated for a more interactive experience -- **Conversation History**: The system maintains context between questions for more coherent interactions - -### DeepResearch Feature - -DeepResearch takes repository analysis to the next level with a multi-turn research process: - -- **In-Depth Investigation**: Thoroughly explores complex topics through multiple research iterations -- **Structured Process**: Follows a clear research plan with updates and a comprehensive conclusion -- **Automatic Continuation**: The AI automatically continues research until reaching a conclusion (up to 5 iterations) -- **Research Stages**: - 1. **Research Plan**: Outlines the approach and initial findings - 2. **Research Updates**: Builds on previous iterations with new insights - 3. **Final Conclusion**: Provides a comprehensive answer based on all iterations - -To use DeepResearch, simply toggle the "Deep Research" switch in the Ask interface before submitting your question. - -## Screenshots - -![DeepWiki Main Interface](screenshots/Interface.png) -*The main interface of DeepWiki* - -![Private Repository Support](screenshots/privaterepo.png) -*Access private repositories with personal access tokens* - -![DeepResearch Feature](screenshots/DeepResearch.png) -*DeepResearch conducts multi-turn investigations for complex topics* - -### Demo Video - -[![DeepWiki Demo Video](https://img.youtube.com/vi/zGANs8US8B4/0.jpg)](https://youtu.be/zGANs8US8B4) - -*Watch DeepWiki in action!* - -## ā“ Troubleshooting - -### API Key Issues -- **"Missing environment variables"**: Make sure your `.env` file is in the project root and contains the required API keys -- **"API key not valid"**: Check that you've copied the full key correctly with no extra spaces -- **"OpenRouter API error"**: Verify your OpenRouter API key is valid and has sufficient credits -- **"Azure OpenAI API error"**: Verify your Azure OpenAI credentials (API key, endpoint, and version) are correct and the service is properly deployed - -### Connection Problems -- **"Cannot connect to API server"**: Make sure the API server is running on port 8001 -- **"CORS error"**: The API is configured to allow all origins, but if you're having issues, try running both frontend and backend on the same machine - -### Generation Issues -- **"Error generating wiki"**: For very large repositories, try a smaller one first -- **"Invalid repository format"**: Make sure you're using a valid GitHub, GitLab or Bitbucket URL format -- **"Could not fetch repository structure"**: For private repositories, ensure you've entered a valid personal access token with appropriate permissions -- **"Diagram rendering error"**: The app will automatically try to fix broken diagrams - -### Common Solutions -1. **Restart both servers**: Sometimes a simple restart fixes most issues -2. **Check console logs**: Open browser developer tools to see any JavaScript errors -3. **Check API logs**: Look at the terminal where the API is running for Python errors - ## šŸ¤ Contributing Contributions are welcome! Feel free to: From 892b0cceee7594b5dffd3eb1119554d22bd57219 Mon Sep 17 00:00:00 2001 From: halfcrazy Date: Tue, 2 Jun 2026 14:39:21 +0800 Subject: [PATCH 21/50] exclude golang vendor dir (#531) --- api/config/repo.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/api/config/repo.json b/api/config/repo.json index 52c31503b..b1afea014 100644 --- a/api/config/repo.json +++ b/api/config/repo.json @@ -11,7 +11,8 @@ "./.git/", "./.svn/", "./.hg/", - "./.bzr/" + "./.bzr/", + "./vendor/" ], "excluded_files": [ "yarn.lock", From 21e70337182deff74ee5f43c875e9193d45a4214 Mon Sep 17 00:00:00 2001 From: halfcrazy Date: Tue, 2 Jun 2026 14:39:59 +0800 Subject: [PATCH 22/50] Preload tiktoken encoding cache in Docker images (#528) Set TIKTOKEN_CACHE_DIR and warm cl100k_base during image build so runtime token counting does not need to download encoding data in offline environments. --- Dockerfile | 3 +++ Dockerfile-litellm | 3 +++ Dockerfile-ollama-local | 3 +++ 3 files changed, 9 insertions(+) diff --git a/Dockerfile b/Dockerfile index 0f4e5b9d3..273494830 100644 --- a/Dockerfile +++ b/Dockerfile @@ -66,9 +66,12 @@ RUN if [ -n "${CUSTOM_CERT_DIR}" ]; then \ fi ENV PATH="/opt/venv/bin:$PATH" +ENV TIKTOKEN_CACHE_DIR=/opt/tiktoken_cache/ # Copy Python dependencies COPY --from=py_deps /api/.venv /opt/venv +RUN mkdir -p "$TIKTOKEN_CACHE_DIR" && \ + python -c "import tiktoken; tiktoken.get_encoding('cl100k_base')" COPY api/ ./api/ # Copy Node app diff --git a/Dockerfile-litellm b/Dockerfile-litellm index e44198aba..6692b2d76 100644 --- a/Dockerfile-litellm +++ b/Dockerfile-litellm @@ -72,9 +72,12 @@ RUN apt-get update && apt-get install -y \ && rm -rf /var/lib/apt/lists/* ENV PATH="/opt/venv/bin:$PATH" +ENV TIKTOKEN_CACHE_DIR=/opt/tiktoken_cache/ # Copy Python dependencies COPY --from=py_deps /api/.venv /opt/venv +RUN mkdir -p "$TIKTOKEN_CACHE_DIR" && \ + python -c "import tiktoken; tiktoken.get_encoding('cl100k_base')" COPY api/ ./api/ # Copy Node app diff --git a/Dockerfile-ollama-local b/Dockerfile-ollama-local index fd2597fc5..98d047440 100644 --- a/Dockerfile-ollama-local +++ b/Dockerfile-ollama-local @@ -71,9 +71,12 @@ RUN apt-get update && apt-get install -y \ && rm -rf /var/lib/apt/lists/* ENV PATH="/opt/venv/bin:$PATH" +ENV TIKTOKEN_CACHE_DIR=/opt/tiktoken_cache/ # Copy Python dependencies COPY --from=py_deps /api/.venv /opt/venv +RUN mkdir -p "$TIKTOKEN_CACHE_DIR" && \ + python -c "import tiktoken; tiktoken.get_encoding('cl100k_base')" COPY api/ ./api/ # Copy Node app From 16f35a0fc0284e99b7963bbf4e8585e9957e2fe1 Mon Sep 17 00:00:00 2001 From: "M. Mansour" Date: Wed, 3 Jun 2026 03:23:18 -0400 Subject: [PATCH 23/50] Feature: Introduce LiteLLM client for multi-provider model routing (#529) * Add LiteLLM client abstraction and updating config.py * Integrate LiteLLM into websocket pipeline * Removing not needed commented code in websocket_wiki.py * Addressing review comments by overriding __init__ --------- Co-authored-by: M. Mansour <3020010+marazik@users.noreply.github.com> --- api/config.py | 8 ++++- api/litellm_client.py | 67 +++++++++++++++++++++++++++++++++++++++ api/websocket_wiki.py | 74 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 148 insertions(+), 1 deletion(-) create mode 100644 api/litellm_client.py diff --git a/api/config.py b/api/config.py index 49dfcf7b0..1d6a5874c 100644 --- a/api/config.py +++ b/api/config.py @@ -8,6 +8,7 @@ logger = logging.getLogger(__name__) from api.openai_client import OpenAIClient +from api.litellm_client import LiteLLMClient from api.openrouter_client import OpenRouterClient from api.bedrock_client import BedrockClient from api.google_embedder_client import GoogleEmbedderClient @@ -17,6 +18,7 @@ # Get API keys from environment variables OPENAI_API_KEY = os.environ.get('OPENAI_API_KEY') +LITELLM_API_KEY = os.environ.get('LITELLM_API_KEY') GOOGLE_API_KEY = os.environ.get('GOOGLE_API_KEY') OPENROUTER_API_KEY = os.environ.get('OPENROUTER_API_KEY') AWS_ACCESS_KEY_ID = os.environ.get('AWS_ACCESS_KEY_ID') @@ -28,6 +30,8 @@ # Set keys in environment (in case they're needed elsewhere in the code) if OPENAI_API_KEY: os.environ["OPENAI_API_KEY"] = OPENAI_API_KEY +if LITELLM_API_KEY: + os.environ["LITELLM_API_KEY"] = LITELLM_API_KEY if GOOGLE_API_KEY: os.environ["GOOGLE_API_KEY"] = GOOGLE_API_KEY if OPENROUTER_API_KEY: @@ -59,6 +63,7 @@ "GoogleGenAIClient": GoogleGenAIClient, "GoogleEmbedderClient": GoogleEmbedderClient, "OpenAIClient": OpenAIClient, + "LiteLLMClient" : LiteLLMClient, "OpenRouterClient": OpenRouterClient, "OllamaClient": OllamaClient, "BedrockClient": BedrockClient, @@ -131,10 +136,11 @@ def load_generator_config(): if provider_config.get("client_class") in CLIENT_CLASSES: provider_config["model_client"] = CLIENT_CLASSES[provider_config["client_class"]] # Fall back to default mapping based on provider_id - elif provider_id in ["google", "openai", "openrouter", "ollama", "bedrock", "azure", "dashscope"]: + elif provider_id in ["google", "openai", "openrouter", "ollama", "bedrock", "azure", "dashscope", "litellm"]: default_map = { "google": GoogleGenAIClient, "openai": OpenAIClient, + "litellm": LiteLLMClient, "openrouter": OpenRouterClient, "ollama": OllamaClient, "bedrock": BedrockClient, diff --git a/api/litellm_client.py b/api/litellm_client.py new file mode 100644 index 000000000..439adced6 --- /dev/null +++ b/api/litellm_client.py @@ -0,0 +1,67 @@ +import os +from typing import Optional, Callable +from openai import AsyncOpenAI, OpenAI + +from api.openai_client import OpenAIClient + + +class LiteLLMClient(OpenAIClient): + """ + LiteLLM OpenAI-compatible client. + + LiteLLM exposes an OpenAI-compatible API surface, so we can + reuse almost all OpenAIClient behavior while overriding only + the client initialization. + + Expected environment variables: + + LITELLM_BASE_URL=http://litellm:4000 + LITELLM_API_KEY=sk-1234 + + Example model names: + openai/gpt-4o + anthropic/claude-3-5-sonnet + gemini/gemini-2.5-pro + ollama/llama3 + """ + + def __init__( + self, + api_key: Optional[str] = None, + chat_completion_parser: Optional[Callable] = None, + input_type: str = "text", + base_url: Optional[str] = None, + env_base_url_name: str = "LITELLM_BASE_URL", + env_api_key_name: str = "LITELLM_API_KEY", + ): + resolved_base_url = base_url or os.getenv(env_base_url_name, "http://localhost:4000") + if not resolved_base_url.endswith("/v1"): + resolved_base_url = f"{resolved_base_url.rstrip('/')}/v1" + super().__init__( + api_key=api_key, + chat_completion_parser=chat_completion_parser, + input_type=input_type, + base_url=resolved_base_url, + env_base_url_name=env_base_url_name, + env_api_key_name=env_api_key_name, + ) + + def init_sync_client(self): + """ + Initialize synchronous LiteLLM OpenAI-compatible client. + """ + api_key = self._api_key or os.getenv(self._env_api_key_name, "dummy") + return OpenAI( + api_key=api_key, + base_url=self.base_url, + ) + + def init_async_client(self): + """ + Initialize asynchronous LiteLLM OpenAI-compatible client. + """ + api_key = self._api_key or os.getenv(self._env_api_key_name, "dummy") + return AsyncOpenAI( + api_key=api_key, + base_url=self.base_url, + ) diff --git a/api/websocket_wiki.py b/api/websocket_wiki.py index d1a6b1bd5..7064f54b4 100644 --- a/api/websocket_wiki.py +++ b/api/websocket_wiki.py @@ -14,12 +14,14 @@ configs, OPENROUTER_API_KEY, OPENAI_API_KEY, + LITELLM_API_KEY, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, ) from api.data_pipeline import count_tokens, get_file_content from api.bedrock_client import BedrockClient from api.openai_client import OpenAIClient +from api.litellm_client import LiteLLMClient from api.openrouter_client import OpenRouterClient from api.azureai_client import AzureAIClient from api.dashscope_client import DashscopeClient @@ -500,6 +502,30 @@ async def handle_websocket_chat(websocket: WebSocket): if "top_p" in model_config: model_kwargs["top_p"] = model_config["top_p"] + api_kwargs = model.convert_inputs_to_api_kwargs( + input=prompt, + model_kwargs=model_kwargs, + model_type=ModelType.LLM + ) + elif request.provider == "litellm": + logger.info(f"Using Openai protocol with model on LiteLLM: {request.model}") + + # Check if an API key is set for Litellm + if not LITELLM_API_KEY: + logger.warning("LITELLM_API_KEY not configured, but continuing with request") + # We'll let the OpenAIClient handle this and return an error message + + # Initialize LiteLLM client + model = LiteLLMClient() + model_kwargs = { + "model": request.model, + "stream": True, + "temperature": model_config["temperature"] + } + # Only add top_p if it exists in the model config + if "top_p" in model_config: + model_kwargs["top_p"] = model_config["top_p"] + api_kwargs = model.convert_inputs_to_api_kwargs( input=prompt, model_kwargs=model_kwargs, @@ -640,6 +666,28 @@ async def handle_websocket_chat(websocket: WebSocket): await websocket.send_text(error_msg) # Close the WebSocket connection after sending the error message await websocket.close() + elif request.provider == "litellm": + try: + # Get the response and handle it properly using the previously created api_kwargs + logger.info("Making LiteLLM API call") + response = await model.acall(api_kwargs=api_kwargs, model_type=ModelType.LLM) + # Handle streaming response from LiteLLM + async for chunk in response: + choices = getattr(chunk, "choices", []) + if len(choices) > 0: + delta = getattr(choices[0], "delta", None) + if delta is not None: + text = getattr(delta, "content", None) + if text is not None: + await websocket.send_text(text) + # Explicitly close the WebSocket connection after the response is complete + await websocket.close() + except Exception as e_litellm: + logger.error(f"Error with LiteLLM API: {str(e_litellm)}") + error_msg = f"\nError with LiteLLM API: {str(e_litellm)}\n\nPlease check that you have set the LITELLM_API_KEY environment variable with a valid API key." + await websocket.send_text(error_msg) + # Close the WebSocket connection after sending the error message + await websocket.close() elif request.provider == "bedrock": try: logger.info("Making AWS Bedrock API call") @@ -793,6 +841,32 @@ async def handle_websocket_chat(websocket: WebSocket): logger.error(f"Error with Openai API fallback: {str(e_fallback)}") error_msg = f"\nError with Openai API fallback: {str(e_fallback)}\n\nPlease check that you have set the OPENAI_API_KEY environment variable with a valid API key." await websocket.send_text(error_msg) + elif request.provider == "litellm": + try: + # Create new api_kwargs with the simplified prompt + fallback_api_kwargs = model.convert_inputs_to_api_kwargs( + input=simplified_prompt, + model_kwargs=model_kwargs, + model_type=ModelType.LLM + ) + + # Get the response using the simplified prompt + logger.info("Making fallback LiteLLM API call") + fallback_response = await model.acall(api_kwargs=fallback_api_kwargs, model_type=ModelType.LLM) + + # Handle streaming fallback_response from LiteLLM + async for chunk in fallback_response: + choices = getattr(chunk, "choices", []) + if len(choices) > 0: + delta = getattr(choices[0], "delta", None) + if delta is not None: + text = getattr(delta, "content", None) + if text is not None: + await websocket.send_text(text) + except Exception as e_fallback: + logger.error(f"Error with LiteLLM API fallback: {str(e_fallback)}") + error_msg = f"\nError with LiteLLM API fallback: {str(e_fallback)}\n\nPlease check that you have set the LITELLM_API_KEY environment variable with a valid API key." + await websocket.send_text(error_msg) elif request.provider == "bedrock": try: fallback_api_kwargs = model.convert_inputs_to_api_kwargs( From 34ebfec05b59304896f59300ed7f222808b952ab Mon Sep 17 00:00:00 2001 From: GdoongMathew Date: Fri, 17 Jul 2026 13:46:30 +0800 Subject: [PATCH 24/50] Fixing OllamaClient.acall return type (#541) * fix: ollama return type with `message` field. * fix: ollama return type with `message` field. * change from assert to raise RuntimeError --- api/simple_chat.py | 32 +++++++++++++++++++++++++------- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/api/simple_chat.py b/api/simple_chat.py index 41a184ed8..e93199397 100644 --- a/api/simple_chat.py +++ b/api/simple_chat.py @@ -1,6 +1,7 @@ import logging import os -from typing import List, Optional +from collections.abc import AsyncIterator +from typing import List, Optional, TYPE_CHECKING from urllib.parse import unquote import google.generativeai as genai @@ -29,6 +30,9 @@ # Configure logging from api.logging_config import setup_logging +if TYPE_CHECKING: + from ollama import ChatResponse + setup_logging() logger = logging.getLogger(__name__) @@ -465,11 +469,18 @@ async def response_stream(): try: if request.provider == "ollama": # Get the response and handle it properly using the previously created api_kwargs - response = await model.acall(api_kwargs=api_kwargs, model_type=ModelType.LLM) + response: "AsyncIterator[ChatResponse]" = await model.acall( + api_kwargs=api_kwargs, + model_type=ModelType.LLM, + ) # Handle streaming response from Ollama async for chunk in response: - text = getattr(chunk, 'response', None) or getattr(chunk, 'text', None) or str(chunk) - if text and not text.startswith('model=') and not text.startswith('created_at='): + if not hasattr(chunk, "message"): + raise RuntimeError( + "`message` field not found in response. Wrong ollama-python version probably.", + ) + text = chunk.message.content + if text: text = text.replace('', '').replace('', '') yield text elif request.provider == "openrouter": @@ -588,12 +599,19 @@ async def response_stream(): ) # Get the response using the simplified prompt - fallback_response = await model.acall(api_kwargs=fallback_api_kwargs, model_type=ModelType.LLM) + fallback_response: "AsyncIterator[ChatResponse]" = await model.acall( + api_kwargs=fallback_api_kwargs, + model_type=ModelType.LLM, + ) # Handle streaming fallback_response from Ollama async for chunk in fallback_response: - text = getattr(chunk, 'response', None) or getattr(chunk, 'text', None) or str(chunk) - if text and not text.startswith('model=') and not text.startswith('created_at='): + if not hasattr(chunk, "message"): + raise RuntimeError( + "`message` field not found in response. Wrong ollama-python version probably.", + ) + text = chunk.message.content + if text: text = text.replace('', '').replace('', '') yield text elif request.provider == "openrouter": From b84c0b2a0de7ad7cd6c4313833c2642536e6c67e Mon Sep 17 00:00:00 2001 From: GdoongMathew Date: Sun, 19 Jul 2026 14:27:35 +0800 Subject: [PATCH 25/50] centralize prompt (#548) * centralize prompt (related to #306) * fix prompt --- api/prompts.py | 2 +- api/simple_chat.py | 1 - api/websocket_wiki.py | 162 ++++++++---------------------------------- 3 files changed, 32 insertions(+), 133 deletions(-) diff --git a/api/prompts.py b/api/prompts.py index 61ef0a4d3..7c6641553 100644 --- a/api/prompts.py +++ b/api/prompts.py @@ -131,7 +131,7 @@ - Your response MUST build on previous research iterations - do not repeat information already covered - Identify gaps or areas that need further exploration related to this specific topic - Focus on one specific aspect that needs deeper investigation in this iteration -- Start your response with "## Research Update {{research_iteration}}" +- Start your response with "## Research Update {research_iteration}" - Clearly explain what you're investigating in this iteration - Provide new insights that weren't covered in previous iterations - If this is iteration 3, prepare for a final conclusion in the next iteration diff --git a/api/simple_chat.py b/api/simple_chat.py index e93199397..48204c9ba 100644 --- a/api/simple_chat.py +++ b/api/simple_chat.py @@ -273,7 +273,6 @@ async def chat_completions_stream(request: ChatCompletionRequest): repo_type=repo_type, repo_url=repo_url, repo_name=repo_name, - research_iteration=research_iteration, language_name=language_name ) else: diff --git a/api/websocket_wiki.py b/api/websocket_wiki.py index 7064f54b4..67b1ba525 100644 --- a/api/websocket_wiki.py +++ b/api/websocket_wiki.py @@ -26,6 +26,12 @@ from api.azureai_client import AzureAIClient from api.dashscope_client import DashscopeClient from api.rag import RAG +from api.prompts import ( + DEEP_RESEARCH_FIRST_ITERATION_PROMPT, + DEEP_RESEARCH_FINAL_ITERATION_PROMPT, + DEEP_RESEARCH_INTERMEDIATE_ITERATION_PROMPT, + SIMPLE_CHAT_SYSTEM_PROMPT, +) # Configure logging from api.logging_config import setup_logging @@ -265,140 +271,34 @@ async def handle_websocket_chat(websocket: WebSocket): is_final_iteration = research_iteration >= 5 if is_first_iteration: - system_prompt = f""" -You are an expert code analyst examining the {repo_type} repository: {repo_url} ({repo_name}). -You are conducting a multi-turn Deep Research process to thoroughly investigate the specific topic in the user's query. -Your goal is to provide detailed, focused information EXCLUSIVELY about this topic. -IMPORTANT:You MUST respond in {language_name} language. - - - -- This is the first iteration of a multi-turn research process focused EXCLUSIVELY on the user's query -- Start your response with "## Research Plan" -- Outline your approach to investigating this specific topic -- If the topic is about a specific file or feature (like "Dockerfile"), focus ONLY on that file or feature -- Clearly state the specific topic you're researching to maintain focus throughout all iterations -- Identify the key aspects you'll need to research -- Provide initial findings based on the information available -- End with "## Next Steps" indicating what you'll investigate in the next iteration -- Do NOT provide a final conclusion yet - this is just the beginning of the research -- Do NOT include general repository information unless directly relevant to the query -- Focus EXCLUSIVELY on the specific topic being researched - do not drift to related topics -- Your research MUST directly address the original question -- NEVER respond with just "Continue the research" as an answer - always provide substantive research findings -- Remember that this topic will be maintained across all research iterations - - -""" + system_prompt = DEEP_RESEARCH_FIRST_ITERATION_PROMPT.format( + repo_type=repo_type, + repo_url=repo_url, + repo_name=repo_name, + language_name=language_name + ) elif is_final_iteration: - system_prompt = f""" -You are an expert code analyst examining the {repo_type} repository: {repo_url} ({repo_name}). -You are in the final iteration of a Deep Research process focused EXCLUSIVELY on the latest user query. -Your goal is to synthesize all previous findings and provide a comprehensive conclusion that directly addresses this specific topic and ONLY this topic. -IMPORTANT:You MUST respond in {language_name} language. - - - -- This is the final iteration of the research process -- CAREFULLY review the entire conversation history to understand all previous findings -- Synthesize ALL findings from previous iterations into a comprehensive conclusion -- Start with "## Final Conclusion" -- Your conclusion MUST directly address the original question -- Stay STRICTLY focused on the specific topic - do not drift to related topics -- Include specific code references and implementation details related to the topic -- Highlight the most important discoveries and insights about this specific functionality -- Provide a complete and definitive answer to the original question -- Do NOT include general repository information unless directly relevant to the query -- Focus exclusively on the specific topic being researched -- NEVER respond with "Continue the research" as an answer - always provide a complete conclusion -- If the topic is about a specific file or feature (like "Dockerfile"), focus ONLY on that file or feature -- Ensure your conclusion builds on and references key findings from previous iterations - - -""" + system_prompt = DEEP_RESEARCH_FINAL_ITERATION_PROMPT.format( + repo_type=repo_type, + repo_url=repo_url, + repo_name=repo_name, + language_name=language_name + ) else: - system_prompt = f""" -You are an expert code analyst examining the {repo_type} repository: {repo_url} ({repo_name}). -You are currently in iteration {research_iteration} of a Deep Research process focused EXCLUSIVELY on the latest user query. -Your goal is to build upon previous research iterations and go deeper into this specific topic without deviating from it. -IMPORTANT:You MUST respond in {language_name} language. - - - -- CAREFULLY review the conversation history to understand what has been researched so far -- Your response MUST build on previous research iterations - do not repeat information already covered -- Identify gaps or areas that need further exploration related to this specific topic -- Focus on one specific aspect that needs deeper investigation in this iteration -- Start your response with "## Research Update {research_iteration}" -- Clearly explain what you're investigating in this iteration -- Provide new insights that weren't covered in previous iterations -- If this is iteration 3, prepare for a final conclusion in the next iteration -- Do NOT include general repository information unless directly relevant to the query -- Focus EXCLUSIVELY on the specific topic being researched - do not drift to related topics -- If the topic is about a specific file or feature (like "Dockerfile"), focus ONLY on that file or feature -- NEVER respond with just "Continue the research" as an answer - always provide substantive research findings -- Your research MUST directly address the original question -- Maintain continuity with previous research iterations - this is a continuous investigation - - -""" + system_prompt = DEEP_RESEARCH_INTERMEDIATE_ITERATION_PROMPT.format( + repo_type=repo_type, + repo_url=repo_url, + repo_name=repo_name, + research_iteration=research_iteration, + language_name=language_name + ) else: - system_prompt = f""" -You are an expert code analyst examining the {repo_type} repository: {repo_url} ({repo_name}). -You provide direct, concise, and accurate information about code repositories. -You NEVER start responses with markdown headers or code fences. -IMPORTANT:You MUST respond in {language_name} language. - - - -- Answer the user's question directly without ANY preamble or filler phrases -- DO NOT include any rationale, explanation, or extra comments. -- Strictly base answers ONLY on existing code or documents -- DO NOT speculate or invent citations. -- DO NOT start with preambles like "Okay, here's a breakdown" or "Here's an explanation" -- DO NOT start with markdown headers like "## Analysis of..." or any file path references -- DO NOT start with ```markdown code fences -- DO NOT end your response with ``` closing fences -- DO NOT start by repeating or acknowledging the question -- JUST START with the direct answer to the question - - -```markdown -## Analysis of `adalflow/adalflow/datasets/gsm8k.py` - -This file contains... -``` - - -- Format your response with proper markdown including headings, lists, and code blocks WITHIN your answer -- For code analysis, organize your response with clear sections -- Think step by step and structure your answer logically -- Start with the most relevant information that directly addresses the user's query -- Be precise and technical when discussing code -- Your response language should be in the same language as the user's query - - -""" + system_prompt = SIMPLE_CHAT_SYSTEM_PROMPT.format( + repo_type=repo_type, + repo_url=repo_url, + repo_name=repo_name, + language_name=language_name + ) # Fetch file content if provided file_content = "" From c015a1f211a5e30461aa87f1b2054431c7d35994 Mon Sep 17 00:00:00 2001 From: GdoongMathew Date: Sun, 19 Jul 2026 14:28:02 +0800 Subject: [PATCH 26/50] patch: patch adalflow OllamaClient to support batch embedding api. (#547) * patch: patch adalflow OllamaClient to support batch embedding api. related to https://github.com/SylphAI-Inc/AdalFlow/pull/496 * remove `OllamaDocumentProcessor` --- api/config.py | 3 +- api/data_pipeline.py | 15 ++--- api/ollama_client.py | 131 +++++++++++++++++++++++++++++++++++++++++++ api/ollama_patch.py | 51 ----------------- api/rag.py | 19 +------ 5 files changed, 138 insertions(+), 81 deletions(-) create mode 100644 api/ollama_client.py diff --git a/api/config.py b/api/config.py index 1d6a5874c..4cafcdbc7 100644 --- a/api/config.py +++ b/api/config.py @@ -14,7 +14,8 @@ from api.google_embedder_client import GoogleEmbedderClient from api.azureai_client import AzureAIClient from api.dashscope_client import DashscopeClient -from adalflow import GoogleGenAIClient, OllamaClient +from api.ollama_client import OllamaClient +from adalflow import GoogleGenAIClient # Get API keys from environment variables OPENAI_API_KEY = os.environ.get('OPENAI_API_KEY') diff --git a/api/data_pipeline.py b/api/data_pipeline.py index f98068651..9958f9262 100644 --- a/api/data_pipeline.py +++ b/api/data_pipeline.py @@ -11,7 +11,6 @@ from adalflow.utils import get_adalflow_default_root_path from adalflow.core.db import LocalDB from api.config import configs, DEFAULT_EXCLUDED_DIRS, DEFAULT_EXCLUDED_FILES -from api.ollama_patch import OllamaDocumentProcessor from urllib.parse import urlparse, urlunparse, quote import requests from requests.exceptions import RequestException @@ -415,16 +414,10 @@ def prepare_data_pipeline(embedder_type: str = None, is_ollama_embedder: bool = embedder = get_embedder(embedder_type=embedder_type) - # Choose appropriate processor based on embedder type - if embedder_type == 'ollama': - # Use Ollama document processor for single-document processing - embedder_transformer = OllamaDocumentProcessor(embedder=embedder) - else: - # Use batch processing for OpenAI and Google embedders - batch_size = embedder_config.get("batch_size", 500) - embedder_transformer = ToEmbeddings( - embedder=embedder, batch_size=batch_size - ) + batch_size = embedder_config.get("batch_size", 500) + embedder_transformer = ToEmbeddings( + embedder=embedder, batch_size=batch_size + ) data_transformer = adal.Sequential( splitter, embedder_transformer diff --git a/api/ollama_client.py b/api/ollama_client.py new file mode 100644 index 000000000..2418e313d --- /dev/null +++ b/api/ollama_client.py @@ -0,0 +1,131 @@ +# This file exists to patch the adalflow OllamaClient to support ollama batch embedding api `embed` + +from typing import Dict, Optional, Any + +from adalflow.components.model_client.ollama_client import ( + OllamaClient, + RequestError, + ResponseError, + log, +) +from adalflow.core.types import EmbedderOutput, Embedding + +from adalflow.core import ModelType + +import backoff + + +def convert_inputs_to_api_kwargs( + self, + input: Optional[Any] = None, + model_kwargs: Dict = {}, + model_type: ModelType = ModelType.UNDEFINED, +) -> Dict: + self.generate = False + final_model_kwargs = model_kwargs.copy() + if model_type == ModelType.EMBEDDER: + final_model_kwargs["input"] = input + return final_model_kwargs + elif model_type == ModelType.LLM: + if input is not None and input != "": + # check if "generate" is in model_kwargs, and if it is set to True, then we use generate api + if model_kwargs.get("generate", False): + final_model_kwargs["prompt"] = input + self.generate = True + else: + # for chat api, we need to convert the input to a message format + # if the input is a string, we create a message with role "user" + if isinstance(input, str): + input = [{"role": "user", "content": input}] + final_model_kwargs["messages"] = input + elif not isinstance(input, list): + raise ValueError("Input must be a string or a list of messages") + # if the input is a list of messages, we use it as is + return final_model_kwargs + else: + raise ValueError("Input must be text") + else: + raise ValueError(f"model_type {model_type} is not supported") + + +@backoff.on_exception( + backoff.expo, + (RequestError, ResponseError), + max_tries=5, +) +async def acall( + self, + api_kwargs: dict = None, + model_type: ModelType = ModelType.UNDEFINED, +): + if self.async_client is None: + self.init_async_client() + if self.async_client is None: + raise RuntimeError( + "Async client is not initialized" + ) + api_kwargs = api_kwargs or {} + if model_type == ModelType.EMBEDDER: + return await self.async_client.embed(**api_kwargs) + if model_type == ModelType.LLM: # in default we use chat + # create a message from the input + if "generate" in api_kwargs and api_kwargs["generate"]: + # remove generate from api_kwargs + api_kwargs.pop("generate") + return await self.async_client.generate(**api_kwargs) + else: + return await self.async_client.chat(**api_kwargs) + else: + raise ValueError(f"model_type {model_type} is not supported") + + + +@backoff.on_exception( + backoff.expo, + (RequestError, ResponseError), + max_tries=5, +) +def call( + self, + api_kwargs: dict = None, + model_type: ModelType = ModelType.UNDEFINED, +): + api_kwargs = api_kwargs or {} + if not self.sync_client: + self.init_sync_client() + if self.sync_client is None: + raise RuntimeError("Sync client is not initialized") + + if model_type == ModelType.EMBEDDER: + return self.sync_client.embed(**api_kwargs) + if model_type == ModelType.LLM: + if "generate" in api_kwargs and api_kwargs["generate"]: + # remove generate from api_kwargs + api_kwargs.pop("generate") + return self.sync_client.generate(**api_kwargs) + else: + return self.sync_client.chat(**api_kwargs) + else: + raise ValueError(f"model_type {model_type} is not supported") + + +def parse_embedding_response( + self, response: Dict[str, list[float]] +) -> EmbedderOutput: + r"""Parse the embedding response to a structure AdalFlow components can understand. + Pull the embedding from response['embedding'] and store it Embedding dataclass + """ + try: + return EmbedderOutput(data=[ + Embedding(embedding=emb, index=i) + for i, emb in enumerate(response["embeddings"]) + ]) + except Exception as e: + log.error(f"Error parsing the embedding response: {e}") + return EmbedderOutput(data=[], error=str(e), raw_response=response) + +OllamaClient.convert_inputs_to_api_kwargs = convert_inputs_to_api_kwargs +OllamaClient.parse_embedding_response = parse_embedding_response +OllamaClient.call = call +OllamaClient.acall = acall + diff --git a/api/ollama_patch.py b/api/ollama_patch.py index fb985dfaf..bb6a77294 100644 --- a/api/ollama_patch.py +++ b/api/ollama_patch.py @@ -1,10 +1,4 @@ -from typing import Sequence, List -from copy import deepcopy -from tqdm import tqdm import logging -import adalflow as adal -from adalflow.core.types import Document -from adalflow.core.component import DataComponent import requests import os @@ -58,48 +52,3 @@ def check_ollama_model_exists(model_name: str, ollama_host: str = None) -> bool: except Exception as e: logger.warning(f"Error checking Ollama model availability: {e}") return False - -class OllamaDocumentProcessor(DataComponent): - """ - Process documents for Ollama embeddings by processing one document at a time. - Adalflow Ollama Client does not support batch embedding, so we need to process each document individually. - """ - def __init__(self, embedder: adal.Embedder) -> None: - super().__init__() - self.embedder = embedder - - def __call__(self, documents: Sequence[Document]) -> Sequence[Document]: - output = deepcopy(documents) - logger.info(f"Processing {len(output)} documents individually for Ollama embeddings") - - successful_docs = [] - expected_embedding_size = None - - for i, doc in enumerate(tqdm(output, desc="Processing documents for Ollama embeddings")): - try: - # Get embedding for a single document - result = self.embedder(input=doc.text) - if result.data and len(result.data) > 0: - embedding = result.data[0].embedding - - # Validate embedding size consistency - if expected_embedding_size is None: - expected_embedding_size = len(embedding) - logger.info(f"Expected embedding size set to: {expected_embedding_size}") - elif len(embedding) != expected_embedding_size: - file_path = getattr(doc, 'meta_data', {}).get('file_path', f'document_{i}') - logger.warning(f"Document '{file_path}' has inconsistent embedding size {len(embedding)} != {expected_embedding_size}, skipping") - continue - - # Assign the embedding to the document - output[i].vector = embedding - successful_docs.append(output[i]) - else: - file_path = getattr(doc, 'meta_data', {}).get('file_path', f'document_{i}') - logger.warning(f"Failed to get embedding for document '{file_path}', skipping") - except Exception as e: - file_path = getattr(doc, 'meta_data', {}).get('file_path', f'document_{i}') - logger.error(f"Error processing document '{file_path}': {e}, skipping") - - logger.info(f"Successfully processed {len(successful_docs)}/{len(output)} documents with consistent embeddings") - return successful_docs \ No newline at end of file diff --git a/api/rag.py b/api/rag.py index b85962b4f..3c0352fbe 100644 --- a/api/rag.py +++ b/api/rag.py @@ -189,22 +189,6 @@ def __init__(self, provider="google", model=None, use_s3: bool = False): # noqa # Initialize components self.memory = Memory() self.embedder = get_embedder(embedder_type=self.embedder_type) - - self_weakref = weakref.ref(self) - # Patch: ensure query embedding is always single string for Ollama - def single_string_embedder(query): - # Accepts either a string or a list, always returns embedding for a single string - if isinstance(query, list): - if len(query) != 1: - raise ValueError("Ollama embedder only supports a single string") - query = query[0] - instance = self_weakref() - assert instance is not None, "RAG instance is no longer available, but the query embedder was called." - return instance.embedder(input=query) - - # Use single string embedder for Ollama, regular embedder for others - self.query_embedder = single_string_embedder if self.is_ollama_embedder else self.embedder - self.initialize_db_manager() # Set up the output parser @@ -381,10 +365,9 @@ def prepare_retriever(self, repo_url_or_path: str, type: str = "github", access_ try: # Use the appropriate embedder for retrieval - retrieve_embedder = self.query_embedder if self.is_ollama_embedder else self.embedder self.retriever = FAISSRetriever( **configs["retriever"], - embedder=retrieve_embedder, + embedder=self.embedder, documents=self.transformed_docs, document_map_func=lambda doc: doc.vector, ) From 87362e950afdaa2b0cc86f4cd82702fd85774a6f Mon Sep 17 00:00:00 2001 From: GdoongMathew Date: Sun, 19 Jul 2026 14:28:55 +0800 Subject: [PATCH 27/50] refactor(api): replace provider if-else chains in simple_chat with a ChatStreamer (#546) * refactor chat streaming * update `_is_token_limit_error` logic * test: add chat provider unittests * move `_is_token_limit_error` to module level * remove unnecessary TYPE_CHECKING * add EOF * update prompt function * refactor `websocket_wiki.py` using `ChatStreamer` * remove unnecessary imports * add litellm test * update kwargs in `GoogleGenerativeChatStreamer` * revert merge * revert merge --- api/chat/__init__.py | 20 ++ api/chat/_prompts.py | 34 +++ api/chat/_stream.py | 315 +++++++++++++++++++++ api/simple_chat.py | 511 ++++----------------------------- api/websocket_wiki.py | 604 ++++------------------------------------ tests/unit/test_chat.py | 44 +++ 6 files changed, 521 insertions(+), 1007 deletions(-) create mode 100644 api/chat/__init__.py create mode 100644 api/chat/_prompts.py create mode 100644 api/chat/_stream.py create mode 100644 tests/unit/test_chat.py diff --git a/api/chat/__init__.py b/api/chat/__init__.py new file mode 100644 index 000000000..57008ffc1 --- /dev/null +++ b/api/chat/__init__.py @@ -0,0 +1,20 @@ +from api.chat._stream import ChatStreamer +from api.chat._prompts import prompt_builder + + +def is_token_limit_error(exc: Exception) -> bool: + error_message = str(exc).lower() + return any( + k in error_message for k in ( + "maximum context length", + "token limit", + "too many tokens", + ) + ) + + +__all__ = [ + "ChatStreamer", + "prompt_builder", + "is_token_limit_error", +] diff --git a/api/chat/_prompts.py b/api/chat/_prompts.py new file mode 100644 index 000000000..bef5254d6 --- /dev/null +++ b/api/chat/_prompts.py @@ -0,0 +1,34 @@ +from api.logging_config import setup_logging +from logging import getLogger + +setup_logging() +logger = getLogger(__name__) + + +def prompt_builder( + system_prompt: str, + query: str, + conversation_history: str | None = None, + file_path: str | None = None, + file_content: str | None = None, + context: str = "", + simplify: bool = False, +) -> str: + prompt = f"/no_think {system_prompt}\n\n" + if conversation_history: + prompt += f"\n{conversation_history}\n\n" + + if file_path and file_content: + prompt += f"\n{file_content}\n\n\n" + + if not simplify: + if context.strip(): + context_prompt = f"\n{context}\n\n\n" + else: + # Add a note that we're skipping RAG due to size constraints or because it's the isolated API + logger.info("No context available from RAG") + context_prompt = "Answering without retrieval augmentation.\n\n" + else: + context_prompt = "Answering without retrieval augmentation due to input size constraints.\n\n" + + return prompt + context_prompt + f"\n{query}\n\n\nAssistant: " diff --git a/api/chat/_stream.py b/api/chat/_stream.py new file mode 100644 index 000000000..94251d162 --- /dev/null +++ b/api/chat/_stream.py @@ -0,0 +1,315 @@ +import logging +from abc import abstractmethod, ABC + +from typing import TYPE_CHECKING + +from collections.abc import AsyncIterator + +from adalflow.core.types import ModelType +from api.config import ( + OPENROUTER_API_KEY, + OPENAI_API_KEY, + AWS_ACCESS_KEY_ID, + AWS_SECRET_ACCESS_KEY, + LITELLM_API_KEY, +) + +if TYPE_CHECKING: + from ollama import ChatResponse + from openai.types.chat import ChatCompletionChunk + from openai import AsyncStream + from api.openai_client import OpenAIClient + +MODEL_CFG = dict[str, str | int | float] + +# Configure logging +from api.logging_config import setup_logging + +setup_logging() +logger = logging.getLogger(__name__) + + +class ChatStreamer(ABC): + _registry: dict[str, type["ChatStreamer"]] = {} + provider: str + error_hint: str | None = None + + def __init_subclass__(cls, **kwargs) -> None: + super().__init_subclass__(**kwargs) + if provider := getattr(cls, "provider", None): + ChatStreamer._registry[provider] = cls + + @classmethod + def create(cls, *, provider: str, model: str | None = None, model_config: MODEL_CFG) -> "ChatStreamer": + model = model or model_config.get("model") + logger.info("Using %s with model: %s", provider, model) + registered = ChatStreamer._registry.get(provider, None) + if registered: + return registered(model=model, model_config=model_config) + raise RuntimeError(f"Provider {provider} not registered") + + @abstractmethod + def respond_stream(self, prompt: str) -> AsyncIterator[str]: + raise NotImplementedError(f"{type(self).__name__} does not implement `respond_stream`") + + +class OllamaChatStreamer(ChatStreamer): + provider = "ollama" + + def __init__(self, *, model: str, model_config: MODEL_CFG): + from adalflow.components.model_client.ollama_client import OllamaClient + + self.client = OllamaClient() + self.model_kwargs = { + "model": model, + "stream": True, + "options": { + "temperature": model_config["temperature"], + "top_p": model_config["top_p"], + "num_ctx": model_config["num_ctx"] + } + } + + logger.debug(f"Prompting Ollama with kwargs: {self.model_kwargs}") + + async def respond_stream(self, prompt: str) -> AsyncIterator[str]: + api_kwargs = self.client.convert_inputs_to_api_kwargs( + input=prompt + " /no_think", # todo I think this could be added into model kwargs? + model_kwargs=self.model_kwargs, + model_type=ModelType.LLM, + ) + + response: "AsyncIterator[ChatResponse]" = await self.client.acall( + api_kwargs=api_kwargs, + model_type=ModelType.LLM, + ) + + async for chunk in response: + if not hasattr(chunk, "message"): + raise RuntimeError( + "`message` field not found in response. Wrong ollama-python version probably.", + ) + text = chunk.message.content + if text: + text = text.replace('', '').replace('', '') + yield text + + +class OpenRouterChatStreamer(ChatStreamer): + provider = "openrouter" + error_hint = ( + "Please check that you have set the OPENROUTER_API_KEY " + "environment variable with a valid API key." + ) + + def __init__(self, *, model: str, model_config: MODEL_CFG): + if not OPENROUTER_API_KEY: + logger.warning("OPENROUTER_API_KEY not configured, but continuing with request") + # We'll let the OpenRouterClient handle this and return a friendly error message + from api.openrouter_client import OpenRouterClient + + self.client = OpenRouterClient() + self.model_kwargs = { + "model": model, + "stream": True, + "temperature": model_config["temperature"] + } + if "top_k" in model_config: + self.model_kwargs["top_k"] = model_config["top_k"] + + async def respond_stream(self, prompt: str) -> AsyncIterator[str]: + api_kwargs = self.client.convert_inputs_to_api_kwargs( + input=prompt, + model_kwargs=self.model_kwargs, + model_type=ModelType.LLM, + ) + async for chunk in await self.client.acall( + api_kwargs=api_kwargs, + model_type=ModelType.LLM, + ): + yield chunk + + +class _OpenAICompatStreamer(ChatStreamer): + client: "OpenAIClient" + model_kwargs: dict + + def __init__(self, *, model: str, model_config: MODEL_CFG): + self.client = self._build_client() + self.model_kwargs = { + "model": model, + "stream": True, + "temperature": model_config["temperature"] + } + # Only add top_p if it exists in the model config + if "top_p" in model_config: + self.model_kwargs["top_p"] = model_config["top_p"] + + @abstractmethod + def _build_client(self) -> "OpenAIClient": + raise NotImplementedError( + f"{type(self).__name__} must return an `OpenAIClient` instance" + ) + + async def respond_stream(self, prompt: str) -> AsyncIterator[str]: + api_kwargs = self.client.convert_inputs_to_api_kwargs( + input=prompt, + model_kwargs=self.model_kwargs, + model_type=ModelType.LLM + ) + response: "AsyncStream[ChatCompletionChunk]" = await self.client.acall( + api_kwargs=api_kwargs, + model_type=ModelType.LLM, + ) + + async for chunk in response: + if ( + chunk.choices and + chunk.choices[0].delta is not None and + chunk.choices[0].delta.content is not None + ): + yield chunk.choices[0].delta.content + + +class OpenAIChatStreamer(_OpenAICompatStreamer): + provider = "openai" + error_hint = ( + "Please check that you have set the OPENAI_API_KEY " + "environment variable with a valid API key." + ) + + def __init__(self, *, model: str, model_config: MODEL_CFG): + if not OPENAI_API_KEY: + logger.warning("OPENAI_API_KEY not configured, but continuing with request") + + super().__init__(model=model, model_config=model_config) + + def _build_client(self): + from api.openai_client import OpenAIClient + return OpenAIClient() + + +class AzureChatStreamer(_OpenAICompatStreamer): + provider = "azure" + error_hint = ( + "Please check that you have set the AZURE_OPENAI_API_KEY, " + "AZURE_OPENAI_ENDPOINT, and AZURE_OPENAI_VERSION " + "environment variables with valid values." + ) + + def _build_client(self): + from api.azureai_client import AzureAIClient + return AzureAIClient() + + +class LiteLLMChatStreamer(_OpenAICompatStreamer): + provider = "litellm" + error_hint = ( + "Please check that you have set the LITELLM_API_KEY " + "environment variable with a valid API key." + ) + + def __init__(self, *, model: str, model_config: MODEL_CFG): + if not LITELLM_API_KEY: + logger.warning("LITELLM_API_KEY not configured, but continuing with request") + # We'll let the OpenAIClient handle this and return an error message + + super().__init__(model=model, model_config=model_config) + + def _build_client(self): + from api.litellm_client import LiteLLMClient + return LiteLLMClient() + + +class BedrockChatStreamer(ChatStreamer): + provider = "bedrock" + error_hint = ( + "Please check that you have set the AWS_ACCESS_KEY_ID " + "and AWS_SECRET_ACCESS_KEY environment variables with valid credentials." + ) + + def __init__(self, *, model: str, model_config: MODEL_CFG): + if not AWS_ACCESS_KEY_ID or not AWS_SECRET_ACCESS_KEY: + logger.warning("AWS_ACCESS_KEY_ID or AWS_SECRET_ACCESS_KEY not configured, but continuing with request") + # We'll let the BedrockClient handle this and return an error message + from api.bedrock_client import BedrockClient + + self.client = BedrockClient() + self.model_kwargs = {"model": model} + + for key in ( + "temperature", + "top_p", + ): + if key in model_config: + self.model_kwargs[key] = model_config[key] + + async def respond_stream(self, prompt: str) -> AsyncIterator[str]: + api_kwargs = self.client.convert_inputs_to_api_kwargs( + input=prompt, + model_kwargs=self.model_kwargs, + model_type=ModelType.LLM, + ) + response = await self.client.acall( + api_kwargs=api_kwargs, + model_type=ModelType.LLM, + ) + if not isinstance(response, str): + response = str(response) + yield response + + +class DashScopeChatStreamer(ChatStreamer): + provider = "dashscope" + error_hint = ( + "Please check that you have set the DASHSCOPE_API_KEY (and optionally " + "DASHSCOPE_WORKSPACE_ID) environment variables with valid values." + ) + + def __init__(self, *, model: str, model_config: MODEL_CFG): + from api.dashscope_client import DashscopeClient + + self.client = DashscopeClient() + self.model_kwargs = { + "model": model, + "stream": True, + "temperature": model_config["temperature"], + "top_p": model_config["top_p"], + } + + async def respond_stream(self, prompt: str) -> AsyncIterator[str]: + api_kwargs = self.client.convert_inputs_to_api_kwargs( + input=prompt, + model_kwargs=self.model_kwargs, + model_type=ModelType.LLM, + ) + response = await self.client.acall( + api_kwargs=api_kwargs, + model_type=ModelType.LLM, + ) + async for text in response: + if text: + yield text + + +class GoogleGenerativeChatStreamer(ChatStreamer): + provider = "google" + + def __init__(self, *, model: str, model_config: MODEL_CFG): + import google.generativeai as genai + from google.generativeai.types import GenerationConfig + + self.client = genai.GenerativeModel( + model_name=model, + generation_config=GenerationConfig( + temperature=model_config.get("temperature"), + top_p=model_config.get("top_p"), + top_k=model_config.get("top_k"), + ) + ) + + async def respond_stream(self, prompt: str) -> AsyncIterator[str]: + response = await self.client.generate_content_async(prompt, stream=True) + async for chunk in response: + if hasattr(chunk, "text"): + yield chunk.text diff --git a/api/simple_chat.py b/api/simple_chat.py index 48204c9ba..9076f71ab 100644 --- a/api/simple_chat.py +++ b/api/simple_chat.py @@ -1,24 +1,16 @@ import logging -import os -from collections.abc import AsyncIterator -from typing import List, Optional, TYPE_CHECKING +from typing import List, Optional, Callable from urllib.parse import unquote +from functools import partial -import google.generativeai as genai -from adalflow.components.model_client.ollama_client import OllamaClient -from adalflow.core.types import ModelType from fastapi import FastAPI, HTTPException from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import StreamingResponse from pydantic import BaseModel, Field -from api.config import get_model_config, configs, OPENROUTER_API_KEY, OPENAI_API_KEY, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY +from api.chat import ChatStreamer, prompt_builder, is_token_limit_error +from api.config import get_model_config, configs from api.data_pipeline import count_tokens, get_file_content -from api.openai_client import OpenAIClient -from api.openrouter_client import OpenRouterClient -from api.bedrock_client import BedrockClient -from api.azureai_client import AzureAIClient -from api.dashscope_client import DashscopeClient from api.rag import RAG from api.prompts import ( DEEP_RESEARCH_FIRST_ITERATION_PROMPT, @@ -30,9 +22,6 @@ # Configure logging from api.logging_config import setup_logging -if TYPE_CHECKING: - from ollama import ChatResponse - setup_logging() logger = logging.getLogger(__name__) @@ -77,6 +66,7 @@ class ChatCompletionRequest(BaseModel): included_dirs: Optional[str] = Field(None, description="Comma-separated list of directories to include exclusively") included_files: Optional[str] = Field(None, description="Comma-separated list of file patterns to include exclusively") + @app.post("/chat/completions/stream") async def chat_completions_stream(request: ChatCompletionRequest): """Stream a chat completion response directly using Google Generative AI""" @@ -307,453 +297,65 @@ async def chat_completions_stream(request: ChatCompletionRequest): if not isinstance(turn_id, int) and hasattr(turn, 'user_query') and hasattr(turn, 'assistant_response'): conversation_history += f"\n{turn.user_query.query_str}\n{turn.assistant_response.response_str}\n\n" - # Create the prompt with context - prompt = f"/no_think {system_prompt}\n\n" - - if conversation_history: - prompt += f"\n{conversation_history}\n\n" - - # Check if filePath is provided and fetch file content if it exists - if file_content: - # Add file content to the prompt after conversation history - prompt += f"\n{file_content}\n\n\n" - - # Only include context if it's not empty - CONTEXT_START = "" - CONTEXT_END = "" - if context_text.strip(): - prompt += f"{CONTEXT_START}\n{context_text}\n{CONTEXT_END}\n\n" - else: - # Add a note that we're skipping RAG due to size constraints or because it's the isolated API - logger.info("No context available from RAG") - prompt += "Answering without retrieval augmentation.\n\n" - - prompt += f"\n{query}\n\n\nAssistant: " - - model_config = get_model_config(request.provider, request.model)["model_kwargs"] - - if request.provider == "ollama": - prompt += " /no_think" - - model = OllamaClient() - model_kwargs = { - "model": model_config["model"], - "stream": True, - "options": { - "temperature": model_config["temperature"], - "top_p": model_config["top_p"], - "num_ctx": model_config["num_ctx"] - } - } - - api_kwargs = model.convert_inputs_to_api_kwargs( - input=prompt, - model_kwargs=model_kwargs, - model_type=ModelType.LLM - ) - elif request.provider == "openrouter": - logger.info(f"Using OpenRouter with model: {request.model}") - - # Check if OpenRouter API key is set - if not OPENROUTER_API_KEY: - logger.warning("OPENROUTER_API_KEY not configured, but continuing with request") - # We'll let the OpenRouterClient handle this and return a friendly error message - - model = OpenRouterClient() - model_kwargs = { - "model": request.model, - "stream": True, - "temperature": model_config["temperature"] - } - # Only add top_p if it exists in the model config - if "top_p" in model_config: - model_kwargs["top_p"] = model_config["top_p"] - - api_kwargs = model.convert_inputs_to_api_kwargs( - input=prompt, - model_kwargs=model_kwargs, - model_type=ModelType.LLM - ) - elif request.provider == "openai": - logger.info(f"Using Openai protocol with model: {request.model}") - - # Check if an API key is set for Openai - if not OPENAI_API_KEY: - logger.warning("OPENAI_API_KEY not configured, but continuing with request") - # We'll let the OpenAIClient handle this and return an error message - - # Initialize Openai client - model = OpenAIClient() - model_kwargs = { - "model": request.model, - "stream": True, - "temperature": model_config["temperature"] - } - # Only add top_p if it exists in the model config - if "top_p" in model_config: - model_kwargs["top_p"] = model_config["top_p"] - - api_kwargs = model.convert_inputs_to_api_kwargs( - input=prompt, - model_kwargs=model_kwargs, - model_type=ModelType.LLM - ) - elif request.provider == "bedrock": - logger.info(f"Using AWS Bedrock with model: {request.model}") - - # Check if AWS credentials are set - if not AWS_ACCESS_KEY_ID or not AWS_SECRET_ACCESS_KEY: - logger.warning("AWS_ACCESS_KEY_ID or AWS_SECRET_ACCESS_KEY not configured, but continuing with request") - # We'll let the BedrockClient handle this and return an error message - - # Initialize Bedrock client - model = BedrockClient() - model_kwargs = { - "model": request.model, - "temperature": model_config["temperature"], - "top_p": model_config["top_p"] - } - - api_kwargs = model.convert_inputs_to_api_kwargs( - input=prompt, - model_kwargs=model_kwargs, - model_type=ModelType.LLM - ) - elif request.provider == "azure": - logger.info(f"Using Azure AI with model: {request.model}") - - # Initialize Azure AI client - model = AzureAIClient() - model_kwargs = { - "model": request.model, - "stream": True, - "temperature": model_config["temperature"], - "top_p": model_config["top_p"] - } - - api_kwargs = model.convert_inputs_to_api_kwargs( - input=prompt, - model_kwargs=model_kwargs, - model_type=ModelType.LLM - ) - elif request.provider == "dashscope": - logger.info(f"Using Dashscope with model: {request.model}") - - model = DashscopeClient() - model_kwargs = { - "model": request.model, - "stream": True, - "temperature": model_config["temperature"], - "top_p": model_config["top_p"], - } - - api_kwargs = model.convert_inputs_to_api_kwargs( - input=prompt, - model_kwargs=model_kwargs, - model_type=ModelType.LLM, - ) - else: - # Initialize Google Generative AI model (default provider) - model = genai.GenerativeModel( - model_name=model_config["model"], - generation_config={ - "temperature": model_config["temperature"], - "top_p": model_config["top_p"], - "top_k": model_config["top_k"], - }, - ) - - # Create a streaming response - async def response_stream(): + async def stream_and_fallback( + streamer: ChatStreamer, + prompt_func: Callable[[], str], + simplified_prompt_func: Callable[[], str], + ): try: - if request.provider == "ollama": - # Get the response and handle it properly using the previously created api_kwargs - response: "AsyncIterator[ChatResponse]" = await model.acall( - api_kwargs=api_kwargs, - model_type=ModelType.LLM, - ) - # Handle streaming response from Ollama - async for chunk in response: - if not hasattr(chunk, "message"): - raise RuntimeError( - "`message` field not found in response. Wrong ollama-python version probably.", - ) - text = chunk.message.content - if text: - text = text.replace('', '').replace('', '') - yield text - elif request.provider == "openrouter": + async for chunk in streamer.respond_stream(prompt_func()): + yield chunk + except Exception as e: + if is_token_limit_error(e): + logger.warning("Token limit exceeded, retrying without context") try: - # Get the response and handle it properly using the previously created api_kwargs - logger.info("Making OpenRouter API call") - response = await model.acall(api_kwargs=api_kwargs, model_type=ModelType.LLM) - # Handle streaming response from OpenRouter - async for chunk in response: + async for chunk in streamer.respond_stream(simplified_prompt_func()): yield chunk - except Exception as e_openrouter: - logger.error(f"Error with OpenRouter API: {str(e_openrouter)}") - yield f"\nError with OpenRouter API: {str(e_openrouter)}\n\nPlease check that you have set the OPENROUTER_API_KEY environment variable with a valid API key." - elif request.provider == "openai": - try: - # Get the response and handle it properly using the previously created api_kwargs - logger.info("Making Openai API call") - response = await model.acall(api_kwargs=api_kwargs, model_type=ModelType.LLM) - # Handle streaming response from Openai - async for chunk in response: - choices = getattr(chunk, "choices", []) - if len(choices) > 0: - delta = getattr(choices[0], "delta", None) - if delta is not None: - text = getattr(delta, "content", None) - if text is not None: - yield text - except Exception as e_openai: - logger.error(f"Error with Openai API: {str(e_openai)}") - yield f"\nError with Openai API: {str(e_openai)}\n\nPlease check that you have set the OPENAI_API_KEY environment variable with a valid API key." - elif request.provider == "bedrock": - try: - # Get the response and handle it properly using the previously created api_kwargs - logger.info("Making AWS Bedrock API call") - response = await model.acall(api_kwargs=api_kwargs, model_type=ModelType.LLM) - # Handle response from Bedrock (not streaming yet) - if isinstance(response, str): - yield response - else: - # Try to extract text from the response - yield str(response) - except Exception as e_bedrock: - logger.error(f"Error with AWS Bedrock API: {str(e_bedrock)}") - yield f"\nError with AWS Bedrock API: {str(e_bedrock)}\n\nPlease check that you have set the AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY environment variables with valid credentials." - elif request.provider == "azure": - try: - # Get the response and handle it properly using the previously created api_kwargs - logger.info("Making Azure AI API call") - response = await model.acall(api_kwargs=api_kwargs, model_type=ModelType.LLM) - # Handle streaming response from Azure AI - async for chunk in response: - choices = getattr(chunk, "choices", []) - if len(choices) > 0: - delta = getattr(choices[0], "delta", None) - if delta is not None: - text = getattr(delta, "content", None) - if text is not None: - yield text - except Exception as e_azure: - logger.error(f"Error with Azure AI API: {str(e_azure)}") - yield f"\nError with Azure AI API: {str(e_azure)}\n\nPlease check that you have set the AZURE_OPENAI_API_KEY, AZURE_OPENAI_ENDPOINT, and AZURE_OPENAI_VERSION environment variables with valid values." - elif request.provider == "dashscope": - try: - logger.info("Making Dashscope API call") - response = await model.acall( - api_kwargs=api_kwargs, model_type=ModelType.LLM - ) - # DashscopeClient.acall with stream=True returns an async - # generator of text chunks - async for text in response: - if text: - yield text - except Exception as e_dashscope: - logger.error(f"Error with Dashscope API: {str(e_dashscope)}") + except Exception as e2: + logger.error("Error in fallback streaming response: %s", str(e2)) yield ( - f"\nError with Dashscope API: {str(e_dashscope)}\n\n" - "Please check that you have set the DASHSCOPE_API_KEY (and optionally " - "DASHSCOPE_WORKSPACE_ID) environment variables with valid values." + f"\nI apologize, but your request is too large for me to process. " + f"Please try a shorter query or break it into smaller parts." ) else: - # Google Generative AI (default provider) - response = model.generate_content(prompt, stream=True) - for chunk in response: - if hasattr(chunk, "text"): - yield chunk.text - - except Exception as e_outer: - logger.error(f"Error in streaming response: {str(e_outer)}") - error_message = str(e_outer) - - # Check for token limit errors - if "maximum context length" in error_message or "token limit" in error_message or "too many tokens" in error_message: - # If we hit a token limit error, try again without context - logger.warning("Token limit exceeded, retrying without context") - try: - # Create a simplified prompt without context - simplified_prompt = f"/no_think {system_prompt}\n\n" - if conversation_history: - simplified_prompt += f"\n{conversation_history}\n\n" - - # Include file content in the fallback prompt if it was retrieved - if request.filePath and file_content: - simplified_prompt += f"\n{file_content}\n\n\n" - - simplified_prompt += "Answering without retrieval augmentation due to input size constraints.\n\n" - simplified_prompt += f"\n{query}\n\n\nAssistant: " - - if request.provider == "ollama": - simplified_prompt += " /no_think" - - # Create new api_kwargs with the simplified prompt - fallback_api_kwargs = model.convert_inputs_to_api_kwargs( - input=simplified_prompt, - model_kwargs=model_kwargs, - model_type=ModelType.LLM - ) - - # Get the response using the simplified prompt - fallback_response: "AsyncIterator[ChatResponse]" = await model.acall( - api_kwargs=fallback_api_kwargs, - model_type=ModelType.LLM, - ) - - # Handle streaming fallback_response from Ollama - async for chunk in fallback_response: - if not hasattr(chunk, "message"): - raise RuntimeError( - "`message` field not found in response. Wrong ollama-python version probably.", - ) - text = chunk.message.content - if text: - text = text.replace('', '').replace('', '') - yield text - elif request.provider == "openrouter": - try: - # Create new api_kwargs with the simplified prompt - fallback_api_kwargs = model.convert_inputs_to_api_kwargs( - input=simplified_prompt, - model_kwargs=model_kwargs, - model_type=ModelType.LLM - ) - - # Get the response using the simplified prompt - logger.info("Making fallback OpenRouter API call") - fallback_response = await model.acall(api_kwargs=fallback_api_kwargs, model_type=ModelType.LLM) - - # Handle streaming fallback_response from OpenRouter - async for chunk in fallback_response: - yield chunk - except Exception as e_fallback: - logger.error(f"Error with OpenRouter API fallback: {str(e_fallback)}") - yield f"\nError with OpenRouter API fallback: {str(e_fallback)}\n\nPlease check that you have set the OPENROUTER_API_KEY environment variable with a valid API key." - elif request.provider == "openai": - try: - # Create new api_kwargs with the simplified prompt - fallback_api_kwargs = model.convert_inputs_to_api_kwargs( - input=simplified_prompt, - model_kwargs=model_kwargs, - model_type=ModelType.LLM - ) - - # Get the response using the simplified prompt - logger.info("Making fallback Openai API call") - fallback_response = await model.acall(api_kwargs=fallback_api_kwargs, model_type=ModelType.LLM) - - # Handle streaming fallback_response from Openai - async for chunk in fallback_response: - text = chunk if isinstance(chunk, str) else getattr(chunk, 'text', str(chunk)) - yield text - except Exception as e_fallback: - logger.error(f"Error with Openai API fallback: {str(e_fallback)}") - yield f"\nError with Openai API fallback: {str(e_fallback)}\n\nPlease check that you have set the OPENAI_API_KEY environment variable with a valid API key." - elif request.provider == "bedrock": - try: - # Create new api_kwargs with the simplified prompt - fallback_api_kwargs = model.convert_inputs_to_api_kwargs( - input=simplified_prompt, - model_kwargs=model_kwargs, - model_type=ModelType.LLM - ) - - # Get the response using the simplified prompt - logger.info("Making fallback AWS Bedrock API call") - fallback_response = await model.acall(api_kwargs=fallback_api_kwargs, model_type=ModelType.LLM) - - # Handle response from Bedrock - if isinstance(fallback_response, str): - yield fallback_response - else: - # Try to extract text from the response - yield str(fallback_response) - except Exception as e_fallback: - logger.error(f"Error with AWS Bedrock API fallback: {str(e_fallback)}") - yield f"\nError with AWS Bedrock API fallback: {str(e_fallback)}\n\nPlease check that you have set the AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY environment variables with valid credentials." - elif request.provider == "azure": - try: - # Create new api_kwargs with the simplified prompt - fallback_api_kwargs = model.convert_inputs_to_api_kwargs( - input=simplified_prompt, - model_kwargs=model_kwargs, - model_type=ModelType.LLM - ) - - # Get the response using the simplified prompt - logger.info("Making fallback Azure AI API call") - fallback_response = await model.acall(api_kwargs=fallback_api_kwargs, model_type=ModelType.LLM) - - # Handle streaming fallback response from Azure AI - async for chunk in fallback_response: - choices = getattr(chunk, "choices", []) - if len(choices) > 0: - delta = getattr(choices[0], "delta", None) - if delta is not None: - text = getattr(delta, "content", None) - if text is not None: - yield text - except Exception as e_fallback: - logger.error(f"Error with Azure AI API fallback: {str(e_fallback)}") - yield f"\nError with Azure AI API fallback: {str(e_fallback)}\n\nPlease check that you have set the AZURE_OPENAI_API_KEY, AZURE_OPENAI_ENDPOINT, and AZURE_OPENAI_VERSION environment variables with valid values." - elif request.provider == "dashscope": - try: - # Create new api_kwargs with the simplified prompt - fallback_api_kwargs = model.convert_inputs_to_api_kwargs( - input=simplified_prompt, - model_kwargs=model_kwargs, - model_type=ModelType.LLM, - ) - - logger.info("Making fallback Dashscope API call") - fallback_response = await model.acall( - api_kwargs=fallback_api_kwargs, model_type=ModelType.LLM - ) - - # DashscopeClient.acall (stream=True) returns an async - # generator of text chunks - async for text in fallback_response: - if text: - yield text - except Exception as e_fallback: - logger.error( - f"Error with Dashscope API fallback: {str(e_fallback)}" - ) - yield ( - f"\nError with Dashscope API fallback: {str(e_fallback)}\n\n" - "Please check that you have set the DASHSCOPE_API_KEY (and optionally " - "DASHSCOPE_WORKSPACE_ID) environment variables with valid values." - ) - else: - # Google Generative AI fallback (default provider) - model_config = get_model_config(request.provider, request.model) - fallback_model = genai.GenerativeModel( - model_name=model_config["model_kwargs"]["model"], - generation_config={ - "temperature": model_config["model_kwargs"].get("temperature", 0.7), - "top_p": model_config["model_kwargs"].get("top_p", 0.8), - "top_k": model_config["model_kwargs"].get("top_k", 40), - }, - ) - - fallback_response = fallback_model.generate_content( - simplified_prompt, stream=True - ) - for chunk in fallback_response: - if hasattr(chunk, "text"): - yield chunk.text - except Exception as e2: - logger.error(f"Error in fallback streaming response: {str(e2)}") - yield f"\nI apologize, but your request is too large for me to process. Please try a shorter query or break it into smaller parts." - else: - # For other errors, return the error message - yield f"\nError: {error_message}" + error_str = f"Error with {streamer.provider} API: {e}" + logger.error(error_str, exc_info=True) + if streamer.error_hint: + error_str += f"\n\n{streamer.error_hint}" + yield "\n" + error_str + + model_config = get_model_config(request.provider, request.model)["model_kwargs"] + chat_streamer = ChatStreamer.create( + provider=request.provider, + model=request.model, + model_config=model_config, + ) + prompt_kwargs = dict( + system_prompt=system_prompt, + query=query, + conversation_history=conversation_history, + file_path=request.filePath, + file_content=file_content, + context=context_text, + ) + + prompt_func = partial( + prompt_builder, + **prompt_kwargs, + simplify=False, + ) + simplified_prompt_func = partial( + prompt_builder, + **prompt_kwargs, + simplify=True, + ) # Return streaming response - return StreamingResponse(response_stream(), media_type="text/event-stream") + return StreamingResponse(stream_and_fallback( + streamer=chat_streamer, + prompt_func=prompt_func, + simplified_prompt_func=simplified_prompt_func, + ), media_type="text/event-stream") except HTTPException: raise @@ -762,6 +364,7 @@ async def response_stream(): logger.error(error_msg) raise HTTPException(status_code=500, detail=error_msg) + @app.get("/") async def root(): """Root endpoint to check if the API is running""" diff --git a/api/websocket_wiki.py b/api/websocket_wiki.py index 67b1ba525..2d7e45b71 100644 --- a/api/websocket_wiki.py +++ b/api/websocket_wiki.py @@ -1,30 +1,18 @@ import logging -import os -from typing import List, Optional, Dict, Any +from collections.abc import AsyncIterator, Callable +from typing import List, Optional from urllib.parse import unquote +from functools import partial -import google.generativeai as genai -from adalflow.components.model_client.ollama_client import OllamaClient -from adalflow.core.types import ModelType -from fastapi import WebSocket, WebSocketDisconnect, HTTPException +from fastapi import WebSocket, WebSocketDisconnect from pydantic import BaseModel, Field +from api.chat import ChatStreamer, prompt_builder, is_token_limit_error from api.config import ( get_model_config, configs, - OPENROUTER_API_KEY, - OPENAI_API_KEY, - LITELLM_API_KEY, - AWS_ACCESS_KEY_ID, - AWS_SECRET_ACCESS_KEY, ) from api.data_pipeline import count_tokens, get_file_content -from api.bedrock_client import BedrockClient -from api.openai_client import OpenAIClient -from api.litellm_client import LiteLLMClient -from api.openrouter_client import OpenRouterClient -from api.azureai_client import AzureAIClient -from api.dashscope_client import DashscopeClient from api.rag import RAG from api.prompts import ( DEEP_RESEARCH_FIRST_ITERATION_PROMPT, @@ -339,544 +327,54 @@ async def handle_websocket_chat(websocket: WebSocket): prompt += f"\n{query}\n\n\nAssistant: " - model_config = get_model_config(request.provider, request.model)["model_kwargs"] - - if request.provider == "ollama": - prompt += " /no_think" - - model = OllamaClient() - model_kwargs = { - "model": model_config["model"], - "stream": True, - "options": { - "temperature": model_config["temperature"], - "top_p": model_config["top_p"], - "num_ctx": model_config["num_ctx"] - } - } - - api_kwargs = model.convert_inputs_to_api_kwargs( - input=prompt, - model_kwargs=model_kwargs, - model_type=ModelType.LLM - ) - elif request.provider == "openrouter": - logger.info(f"Using OpenRouter with model: {request.model}") - - # Check if OpenRouter API key is set - if not OPENROUTER_API_KEY: - logger.warning("OPENROUTER_API_KEY not configured, but continuing with request") - # We'll let the OpenRouterClient handle this and return a friendly error message - - model = OpenRouterClient() - model_kwargs = { - "model": request.model, - "stream": True, - "temperature": model_config["temperature"] - } - # Only add top_p if it exists in the model config - if "top_p" in model_config: - model_kwargs["top_p"] = model_config["top_p"] - - api_kwargs = model.convert_inputs_to_api_kwargs( - input=prompt, - model_kwargs=model_kwargs, - model_type=ModelType.LLM - ) - elif request.provider == "openai": - logger.info(f"Using Openai protocol with model: {request.model}") - - # Check if an API key is set for Openai - if not OPENAI_API_KEY: - logger.warning("OPENAI_API_KEY not configured, but continuing with request") - # We'll let the OpenAIClient handle this and return an error message - - # Initialize Openai client - model = OpenAIClient() - model_kwargs = { - "model": request.model, - "stream": True, - "temperature": model_config["temperature"] - } - # Only add top_p if it exists in the model config - if "top_p" in model_config: - model_kwargs["top_p"] = model_config["top_p"] - - api_kwargs = model.convert_inputs_to_api_kwargs( - input=prompt, - model_kwargs=model_kwargs, - model_type=ModelType.LLM - ) - elif request.provider == "litellm": - logger.info(f"Using Openai protocol with model on LiteLLM: {request.model}") - - # Check if an API key is set for Litellm - if not LITELLM_API_KEY: - logger.warning("LITELLM_API_KEY not configured, but continuing with request") - # We'll let the OpenAIClient handle this and return an error message - - # Initialize LiteLLM client - model = LiteLLMClient() - model_kwargs = { - "model": request.model, - "stream": True, - "temperature": model_config["temperature"] - } - # Only add top_p if it exists in the model config - if "top_p" in model_config: - model_kwargs["top_p"] = model_config["top_p"] - - api_kwargs = model.convert_inputs_to_api_kwargs( - input=prompt, - model_kwargs=model_kwargs, - model_type=ModelType.LLM - ) - elif request.provider == "bedrock": - logger.info(f"Using AWS Bedrock with model: {request.model}") - - if not AWS_ACCESS_KEY_ID or not AWS_SECRET_ACCESS_KEY: - logger.warning( - "AWS_ACCESS_KEY_ID or AWS_SECRET_ACCESS_KEY not configured, but continuing with request") - - model = BedrockClient() - model_kwargs = { - "model": request.model, - } - - for key in ["temperature", "top_p"]: - if key in model_config: - model_kwargs[key] = model_config[key] - - api_kwargs = model.convert_inputs_to_api_kwargs( - input=prompt, - model_kwargs=model_kwargs, - model_type=ModelType.LLM - ) - elif request.provider == "azure": - logger.info(f"Using Azure AI with model: {request.model}") - - # Initialize Azure AI client - model = AzureAIClient() - model_kwargs = { - "model": request.model, - "stream": True, - "temperature": model_config["temperature"], - "top_p": model_config["top_p"] - } - - api_kwargs = model.convert_inputs_to_api_kwargs( - input=prompt, - model_kwargs=model_kwargs, - model_type=ModelType.LLM - ) - elif request.provider == "dashscope": - logger.info(f"Using Dashscope with model: {request.model}") - - # Initialize Dashscope client - model = DashscopeClient() - model_kwargs = { - "model": request.model, - "stream": True, - "temperature": model_config["temperature"], - "top_p": model_config["top_p"] - } - - api_kwargs = model.convert_inputs_to_api_kwargs( - input=prompt, - model_kwargs=model_kwargs, - model_type=ModelType.LLM - ) - else: - # Initialize Google Generative AI model - model = genai.GenerativeModel( - model_name=model_config["model"], - generation_config={ - "temperature": model_config["temperature"], - "top_p": model_config["top_p"], - "top_k": model_config["top_k"] - } - ) - - # Process the response based on the provider - try: - if request.provider == "ollama": - # Get the response and handle it properly using the previously created api_kwargs - response = await model.acall(api_kwargs=api_kwargs, model_type=ModelType.LLM) - # Handle streaming response from Ollama - async for chunk in response: - text = None - if isinstance(chunk, dict): - text = chunk.get("message", {}).get("content") if isinstance(chunk.get("message"), dict) else chunk.get("message") - else: - message = getattr(chunk, "message", None) - if message is not None: - if isinstance(message, dict): - text = message.get("content") - else: - text = getattr(message, "content", None) - - if not text: - text = getattr(chunk, 'response', None) or getattr(chunk, 'text', None) - - if not text and hasattr(chunk, "__dict__"): - message = chunk.__dict__.get("message") - if isinstance(message, dict): - text = message.get("content") - - if isinstance(text, str) and text and not text.startswith('model=') and not text.startswith('created_at='): - clean_text = text.replace('', '').replace('', '') - await websocket.send_text(clean_text) - # Explicitly close the WebSocket connection after the response is complete - await websocket.close() - elif request.provider == "openrouter": - try: - # Get the response and handle it properly using the previously created api_kwargs - logger.info("Making OpenRouter API call") - response = await model.acall(api_kwargs=api_kwargs, model_type=ModelType.LLM) - # Handle streaming response from OpenRouter - async for chunk in response: - await websocket.send_text(chunk) - # Explicitly close the WebSocket connection after the response is complete - await websocket.close() - except Exception as e_openrouter: - logger.error(f"Error with OpenRouter API: {str(e_openrouter)}") - error_msg = f"\nError with OpenRouter API: {str(e_openrouter)}\n\nPlease check that you have set the OPENROUTER_API_KEY environment variable with a valid API key." - await websocket.send_text(error_msg) - # Close the WebSocket connection after sending the error message - await websocket.close() - elif request.provider == "openai": - try: - # Get the response and handle it properly using the previously created api_kwargs - logger.info("Making Openai API call") - response = await model.acall(api_kwargs=api_kwargs, model_type=ModelType.LLM) - # Handle streaming response from Openai - async for chunk in response: - choices = getattr(chunk, "choices", []) - if len(choices) > 0: - delta = getattr(choices[0], "delta", None) - if delta is not None: - text = getattr(delta, "content", None) - if text is not None: - await websocket.send_text(text) - # Explicitly close the WebSocket connection after the response is complete - await websocket.close() - except Exception as e_openai: - logger.error(f"Error with Openai API: {str(e_openai)}") - error_msg = f"\nError with Openai API: {str(e_openai)}\n\nPlease check that you have set the OPENAI_API_KEY environment variable with a valid API key." - await websocket.send_text(error_msg) - # Close the WebSocket connection after sending the error message - await websocket.close() - elif request.provider == "litellm": - try: - # Get the response and handle it properly using the previously created api_kwargs - logger.info("Making LiteLLM API call") - response = await model.acall(api_kwargs=api_kwargs, model_type=ModelType.LLM) - # Handle streaming response from LiteLLM - async for chunk in response: - choices = getattr(chunk, "choices", []) - if len(choices) > 0: - delta = getattr(choices[0], "delta", None) - if delta is not None: - text = getattr(delta, "content", None) - if text is not None: - await websocket.send_text(text) - # Explicitly close the WebSocket connection after the response is complete - await websocket.close() - except Exception as e_litellm: - logger.error(f"Error with LiteLLM API: {str(e_litellm)}") - error_msg = f"\nError with LiteLLM API: {str(e_litellm)}\n\nPlease check that you have set the LITELLM_API_KEY environment variable with a valid API key." - await websocket.send_text(error_msg) - # Close the WebSocket connection after sending the error message - await websocket.close() - elif request.provider == "bedrock": - try: - logger.info("Making AWS Bedrock API call") - response = await model.acall(api_kwargs=api_kwargs, model_type=ModelType.LLM) - if isinstance(response, str): - await websocket.send_text(response) - else: - await websocket.send_text(str(response)) - await websocket.close() - except Exception as e_bedrock: - logger.error(f"Error with AWS Bedrock API: {str(e_bedrock)}") - error_msg = ( - f"\nError with AWS Bedrock API: {str(e_bedrock)}\n\n" - "Please check that you have set the AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY " - "environment variables with valid credentials." - ) - await websocket.send_text(error_msg) - await websocket.close() - elif request.provider == "azure": - try: - # Get the response and handle it properly using the previously created api_kwargs - logger.info("Making Azure AI API call") - response = await model.acall(api_kwargs=api_kwargs, model_type=ModelType.LLM) - # Handle streaming response from Azure AI - async for chunk in response: - choices = getattr(chunk, "choices", []) - if len(choices) > 0: - delta = getattr(choices[0], "delta", None) - if delta is not None: - text = getattr(delta, "content", None) - if text is not None: - await websocket.send_text(text) - # Explicitly close the WebSocket connection after the response is complete - await websocket.close() - except Exception as e_azure: - logger.error(f"Error with Azure AI API: {str(e_azure)}") - error_msg = f"\nError with Azure AI API: {str(e_azure)}\n\nPlease check that you have set the AZURE_OPENAI_API_KEY, AZURE_OPENAI_ENDPOINT, and AZURE_OPENAI_VERSION environment variables with valid values." - await websocket.send_text(error_msg) - # Close the WebSocket connection after sending the error message - await websocket.close() - elif request.provider == "dashscope": - try: - # Get the response and handle it properly using the previously created api_kwargs - logger.info("Making Dashscope API call") - response = await model.acall( - api_kwargs=api_kwargs, model_type=ModelType.LLM - ) - # DashscopeClient.acall with stream=True returns an async - # generator of plain text chunks - async for text in response: - if text: - await websocket.send_text(text) - # Explicitly close the WebSocket connection after the response is complete - await websocket.close() - except Exception as e_dashscope: - logger.error(f"Error with Dashscope API: {str(e_dashscope)}") - error_msg = ( - f"\nError with Dashscope API: {str(e_dashscope)}\n\n" - "Please check that you have set the DASHSCOPE_API_KEY (and optionally " - "DASHSCOPE_WORKSPACE_ID) environment variables with valid values." - ) - await websocket.send_text(error_msg) - # Close the WebSocket connection after sending the error message - await websocket.close() - else: - # Google Generative AI (default provider) - response = model.generate_content(prompt, stream=True) - for chunk in response: - if hasattr(chunk, 'text'): - await websocket.send_text(chunk.text) - await websocket.close() - - except Exception as e_outer: - logger.error(f"Error in streaming response: {str(e_outer)}") - error_message = str(e_outer) - - # Check for token limit errors - if "maximum context length" in error_message or "token limit" in error_message or "too many tokens" in error_message: - # If we hit a token limit error, try again without context - logger.warning("Token limit exceeded, retrying without context") - try: - # Create a simplified prompt without context - simplified_prompt = f"/no_think {system_prompt}\n\n" - if conversation_history: - simplified_prompt += f"\n{conversation_history}\n\n" - - # Include file content in the fallback prompt if it was retrieved - if request.filePath and file_content: - simplified_prompt += f"\n{file_content}\n\n\n" - - simplified_prompt += "Answering without retrieval augmentation due to input size constraints.\n\n" - simplified_prompt += f"\n{query}\n\n\nAssistant: " - - if request.provider == "ollama": - simplified_prompt += " /no_think" - - # Create new api_kwargs with the simplified prompt - fallback_api_kwargs = model.convert_inputs_to_api_kwargs( - input=simplified_prompt, - model_kwargs=model_kwargs, - model_type=ModelType.LLM - ) - - # Get the response using the simplified prompt - fallback_response = await model.acall(api_kwargs=fallback_api_kwargs, model_type=ModelType.LLM) - - # Handle streaming fallback_response from Ollama - async for chunk in fallback_response: - text = getattr(chunk, 'response', None) or getattr(chunk, 'text', None) or str(chunk) - if text and not text.startswith('model=') and not text.startswith('created_at='): - text = text.replace('', '').replace('', '') - await websocket.send_text(text) - elif request.provider == "openrouter": - try: - # Create new api_kwargs with the simplified prompt - fallback_api_kwargs = model.convert_inputs_to_api_kwargs( - input=simplified_prompt, - model_kwargs=model_kwargs, - model_type=ModelType.LLM - ) - - # Get the response using the simplified prompt - logger.info("Making fallback OpenRouter API call") - fallback_response = await model.acall(api_kwargs=fallback_api_kwargs, model_type=ModelType.LLM) - - # Handle streaming fallback_response from OpenRouter - async for chunk in fallback_response: - await websocket.send_text(chunk) - except Exception as e_fallback: - logger.error(f"Error with OpenRouter API fallback: {str(e_fallback)}") - error_msg = f"\nError with OpenRouter API fallback: {str(e_fallback)}\n\nPlease check that you have set the OPENROUTER_API_KEY environment variable with a valid API key." - await websocket.send_text(error_msg) - elif request.provider == "openai": - try: - # Create new api_kwargs with the simplified prompt - fallback_api_kwargs = model.convert_inputs_to_api_kwargs( - input=simplified_prompt, - model_kwargs=model_kwargs, - model_type=ModelType.LLM - ) - - # Get the response using the simplified prompt - logger.info("Making fallback Openai API call") - fallback_response = await model.acall(api_kwargs=fallback_api_kwargs, model_type=ModelType.LLM) - - # Handle streaming fallback_response from Openai - async for chunk in fallback_response: - text = chunk if isinstance(chunk, str) else getattr(chunk, 'text', str(chunk)) - await websocket.send_text(text) - except Exception as e_fallback: - logger.error(f"Error with Openai API fallback: {str(e_fallback)}") - error_msg = f"\nError with Openai API fallback: {str(e_fallback)}\n\nPlease check that you have set the OPENAI_API_KEY environment variable with a valid API key." - await websocket.send_text(error_msg) - elif request.provider == "litellm": - try: - # Create new api_kwargs with the simplified prompt - fallback_api_kwargs = model.convert_inputs_to_api_kwargs( - input=simplified_prompt, - model_kwargs=model_kwargs, - model_type=ModelType.LLM - ) - - # Get the response using the simplified prompt - logger.info("Making fallback LiteLLM API call") - fallback_response = await model.acall(api_kwargs=fallback_api_kwargs, model_type=ModelType.LLM) - - # Handle streaming fallback_response from LiteLLM - async for chunk in fallback_response: - choices = getattr(chunk, "choices", []) - if len(choices) > 0: - delta = getattr(choices[0], "delta", None) - if delta is not None: - text = getattr(delta, "content", None) - if text is not None: - await websocket.send_text(text) - except Exception as e_fallback: - logger.error(f"Error with LiteLLM API fallback: {str(e_fallback)}") - error_msg = f"\nError with LiteLLM API fallback: {str(e_fallback)}\n\nPlease check that you have set the LITELLM_API_KEY environment variable with a valid API key." - await websocket.send_text(error_msg) - elif request.provider == "bedrock": - try: - fallback_api_kwargs = model.convert_inputs_to_api_kwargs( - input=simplified_prompt, - model_kwargs=model_kwargs, - model_type=ModelType.LLM, - ) - - logger.info("Making fallback AWS Bedrock API call") - fallback_response = await model.acall( - api_kwargs=fallback_api_kwargs, model_type=ModelType.LLM - ) - - if isinstance(fallback_response, str): - await websocket.send_text(fallback_response) - else: - await websocket.send_text(str(fallback_response)) - except Exception as e_fallback: - logger.error( - f"Error with AWS Bedrock API fallback: {str(e_fallback)}" - ) - error_msg = ( - f"\nError with AWS Bedrock API fallback: {str(e_fallback)}\n\n" - "Please check that you have set the AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY " - "environment variables with valid credentials." - ) - await websocket.send_text(error_msg) - elif request.provider == "azure": - try: - # Create new api_kwargs with the simplified prompt - fallback_api_kwargs = model.convert_inputs_to_api_kwargs( - input=simplified_prompt, - model_kwargs=model_kwargs, - model_type=ModelType.LLM - ) - - # Get the response using the simplified prompt - logger.info("Making fallback Azure AI API call") - fallback_response = await model.acall(api_kwargs=fallback_api_kwargs, model_type=ModelType.LLM) - - # Handle streaming fallback response from Azure AI - async for chunk in fallback_response: - choices = getattr(chunk, "choices", []) - if len(choices) > 0: - delta = getattr(choices[0], "delta", None) - if delta is not None: - text = getattr(delta, "content", None) - if text is not None: - await websocket.send_text(text) - except Exception as e_fallback: - logger.error(f"Error with Azure AI API fallback: {str(e_fallback)}") - error_msg = f"\nError with Azure AI API fallback: {str(e_fallback)}\n\nPlease check that you have set the AZURE_OPENAI_API_KEY, AZURE_OPENAI_ENDPOINT, and AZURE_OPENAI_VERSION environment variables with valid values." - await websocket.send_text(error_msg) - elif request.provider == "dashscope": - try: - # Create new api_kwargs with the simplified prompt - fallback_api_kwargs = model.convert_inputs_to_api_kwargs( - input=simplified_prompt, - model_kwargs=model_kwargs, - model_type=ModelType.LLM, - ) - - logger.info("Making fallback Dashscope API call") - fallback_response = await model.acall( - api_kwargs=fallback_api_kwargs, model_type=ModelType.LLM - ) - - # DashscopeClient.acall (stream=True) returns an async - # generator of text chunks - async for text in fallback_response: - if text: - await websocket.send_text(text) - except Exception as e_fallback: - logger.error( - f"Error with Dashscope API fallback: {str(e_fallback)}" - ) - error_msg = ( - f"\nError with Dashscope API fallback: {str(e_fallback)}\n\n" - "Please check that you have set the DASHSCOPE_API_KEY (and optionally " - "DASHSCOPE_WORKSPACE_ID) environment variables with valid values." - ) - await websocket.send_text(error_msg) - else: - # Google Generative AI fallback (default provider) - model_config = get_model_config(request.provider, request.model) - fallback_model = genai.GenerativeModel( - model_name=model_config["model_kwargs"]["model"], - generation_config={ - "temperature": model_config["model_kwargs"].get("temperature", 0.7), - "top_p": model_config["model_kwargs"].get("top_p", 0.8), - "top_k": model_config["model_kwargs"].get("top_k", 40), - }, + async def stream_and_fallback( + streamer: ChatStreamer, + prompt_func: Callable[[], str], + simplified_prompt_func: Callable[[], str], + ) -> AsyncIterator[str]: + try: + async for chunk in streamer.respond_stream(prompt_func()): + yield chunk + except Exception as e: + if is_token_limit_error(e): + logger.warning("Token limit exceeded, retrying without context") + try: + async for chunk in streamer.respond_stream(simplified_prompt_func()): + yield chunk + except Exception as e2: + logger.error("Error in fallback streaming response: %s", e2) + yield ( + "\nI apologize, but your request is too large for me to process. " + "Please try a shorter query or break it into smaller parts." ) + else: + msg = f"Error with {streamer.provider} API: {e}" + logger.error(msg, exc_info=True) + if streamer.error_hint: + msg += f"\n\n{streamer.error_hint}" + yield "\n" + msg - fallback_response = fallback_model.generate_content( - simplified_prompt, stream=True - ) - for chunk in fallback_response: - if hasattr(chunk, "text"): - await websocket.send_text(chunk.text) - except Exception as e2: - logger.error(f"Error in fallback streaming response: {str(e2)}") - await websocket.send_text(f"\nI apologize, but your request is too large for me to process. Please try a shorter query or break it into smaller parts.") - # Close the WebSocket connection after sending the error message - await websocket.close() - else: - # For other errors, return the error message - await websocket.send_text(f"\nError: {error_message}") - # Close the WebSocket connection after sending the error message - await websocket.close() + model_config = get_model_config(request.provider, request.model)["model_kwargs"] + chat_streamer = ChatStreamer.create( + provider=request.provider, + model=request.model, + model_config=model_config, + ) + + prompt_kwargs = dict( + system_prompt=system_prompt, + query=query, + conversation_history=conversation_history, + file_path=request.filePath, + file_content=file_content, + context=context_text, + ) + prompt_func = partial(prompt_builder, **prompt_kwargs, simplify=False) + simplified_prompt_func = partial(prompt_builder, **prompt_kwargs, simplify=True) + + async for chunk in stream_and_fallback(chat_streamer, prompt_func, simplified_prompt_func): + await websocket.send_text(chunk) + await websocket.close() except WebSocketDisconnect: logger.info("WebSocket disconnected") diff --git a/tests/unit/test_chat.py b/tests/unit/test_chat.py new file mode 100644 index 000000000..5089189ee --- /dev/null +++ b/tests/unit/test_chat.py @@ -0,0 +1,44 @@ +import pytest +from api.chat import ChatStreamer +from api.chat._stream import ( + OllamaChatStreamer, + OpenRouterChatStreamer, + OpenAIChatStreamer, + AzureChatStreamer, + BedrockChatStreamer, + DashScopeChatStreamer, + GoogleGenerativeChatStreamer, + LiteLLMChatStreamer, +) + +@pytest.mark.parametrize("provider, expected", [ + ("ollama", OllamaChatStreamer), + ("openrouter", OpenRouterChatStreamer), + ("openai", OpenAIChatStreamer), + ("azure", AzureChatStreamer), + ("bedrock", BedrockChatStreamer), + ("dashscope", DashScopeChatStreamer), + ("google", GoogleGenerativeChatStreamer), + ("litellm", LiteLLMChatStreamer), +]) +def test_every_provider_is_registered(provider, expected): + assert ChatStreamer._registry[provider] is expected + +@pytest.mark.parametrize("provider, expected", [ + ("ollama", OllamaChatStreamer), + ("openrouter", OpenRouterChatStreamer), + ("openai", OpenAIChatStreamer), + ("azure", AzureChatStreamer), + ("bedrock", BedrockChatStreamer), + ("dashscope", DashScopeChatStreamer), + ("google", GoogleGenerativeChatStreamer), + ("litellm", LiteLLMChatStreamer), +]) +def test_create_returns_correct_subclass(monkeypatch, provider, expected): + monkeypatch.setattr(expected, "__init__", lambda self, **kw: None) + s = ChatStreamer.create(provider=provider, model="m", model_config={"model": "m"}) + assert isinstance(s, expected) + +def test_create_unknown_provider_raises(): + with pytest.raises(RuntimeError, match="not registered"): + ChatStreamer.create(provider="nope", model=None, model_config={}) From 6e9af597ae30ad92b9f885906dac12ca775a6823 Mon Sep 17 00:00:00 2001 From: GdoongMathew Date: Mon, 20 Jul 2026 12:06:43 +0800 Subject: [PATCH 28/50] update `input_too_large` threshold (#545) * update `input_too_large` threshold update `input_too_large` threshold (#2) (cherry picked from commit 149c6eba2624ca6e10c79ac1360a10f929003227) * update `input_too_large` threshold --- api/simple_chat.py | 6 +++--- api/websocket_wiki.py | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/api/simple_chat.py b/api/simple_chat.py index 9076f71ab..749d2ebe6 100644 --- a/api/simple_chat.py +++ b/api/simple_chat.py @@ -11,7 +11,7 @@ from api.chat import ChatStreamer, prompt_builder, is_token_limit_error from api.config import get_model_config, configs from api.data_pipeline import count_tokens, get_file_content -from api.rag import RAG +from api.rag import RAG, MAX_INPUT_TOKENS from api.prompts import ( DEEP_RESEARCH_FIRST_ITERATION_PROMPT, DEEP_RESEARCH_FINAL_ITERATION_PROMPT, @@ -78,8 +78,8 @@ async def chat_completions_stream(request: ChatCompletionRequest): if hasattr(last_message, 'content') and last_message.content: tokens = count_tokens(last_message.content, request.provider == "ollama") logger.info(f"Request size: {tokens} tokens") - if tokens > 8000: - logger.warning(f"Request exceeds recommended token limit ({tokens} > 7500)") + if tokens > MAX_INPUT_TOKENS: + logger.warning(f"Request exceeds recommended token limit ({tokens} > {MAX_INPUT_TOKENS})") input_too_large = True # Create a new RAG instance for this request diff --git a/api/websocket_wiki.py b/api/websocket_wiki.py index 2d7e45b71..2b7d17b85 100644 --- a/api/websocket_wiki.py +++ b/api/websocket_wiki.py @@ -13,7 +13,7 @@ configs, ) from api.data_pipeline import count_tokens, get_file_content -from api.rag import RAG +from api.rag import RAG, MAX_INPUT_TOKENS from api.prompts import ( DEEP_RESEARCH_FIRST_ITERATION_PROMPT, DEEP_RESEARCH_FINAL_ITERATION_PROMPT, @@ -75,8 +75,8 @@ async def handle_websocket_chat(websocket: WebSocket): if hasattr(last_message, 'content') and last_message.content: tokens = count_tokens(last_message.content, request.provider == "ollama") logger.info(f"Request size: {tokens} tokens") - if tokens > 8000: - logger.warning(f"Request exceeds recommended token limit ({tokens} > 7500)") + if tokens > MAX_INPUT_TOKENS: + logger.warning(f"Request exceeds recommended token limit ({tokens} > {MAX_INPUT_TOKENS})") input_too_large = True # Create a new RAG instance for this request From ee481ed2b67a9cfe2bf8d0366915ad32b871e171 Mon Sep 17 00:00:00 2001 From: GdoongMathew Date: Mon, 20 Jul 2026 12:06:53 +0800 Subject: [PATCH 29/50] update `check_ollama_model_exists` using ollama.list api. (#549) * update `check_ollama_model_exists` using ollama.list api. * fix exception type * fix import scope * fix catching exception --- api/ollama_patch.py | 31 +++++++++++++------------------ 1 file changed, 13 insertions(+), 18 deletions(-) diff --git a/api/ollama_patch.py b/api/ollama_patch.py index bb6a77294..cc04b7317 100644 --- a/api/ollama_patch.py +++ b/api/ollama_patch.py @@ -1,5 +1,5 @@ import logging -import requests + import os # Configure logging @@ -23,30 +23,25 @@ def check_ollama_model_exists(model_name: str, ollama_host: str = None) -> bool: Returns: bool: True if model exists, False otherwise """ + import ollama + import httpx if ollama_host is None: ollama_host = os.getenv("OLLAMA_HOST", "http://localhost:11434") - try: # Remove /api prefix if present and add it back if ollama_host.endswith('/api'): ollama_host = ollama_host[:-4] - - response = requests.get(f"{ollama_host}/api/tags", timeout=5) - if response.status_code == 200: - models_data = response.json() - available_models = [model.get('name', '').split(':')[0] for model in models_data.get('models', [])] - model_base_name = model_name.split(':')[0] # Remove tag if present - - is_available = model_base_name in available_models - if is_available: - logger.info(f"Ollama model '{model_name}' is available") - else: - logger.warning(f"Ollama model '{model_name}' is not available. Available models: {available_models}") - return is_available + ret: ollama.ListResponse = ollama.Client(host=ollama_host, timeout=5).list() + is_available = any(model_name == model.model for model in ret.models) + if is_available: + logger.info("Ollama model '%s' is available", model_name) else: - logger.warning(f"Could not check Ollama models, status code: {response.status_code}") - return False - except requests.exceptions.RequestException as e: + logger.warning( + "Ollama model '%s' is not available. Available models: %s. ", + model_name, + str([model.model for model in ret.models])) + return is_available + except (httpx.ConnectTimeout, ConnectionError) as e: logger.warning(f"Could not connect to Ollama to check models: {e}") return False except Exception as e: From 88a47bf9277d52026b1f05f869c3ff622331cc8b Mon Sep 17 00:00:00 2001 From: GdoongMathew Date: Mon, 20 Jul 2026 12:07:08 +0800 Subject: [PATCH 30/50] Simplify dirs and files parsing in `ChatCompletionRequest` (#550) * use field_validator to simplify dirs and files parsing in `ChatCompletionRequest` * Apply suggestions from code review Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> * prevent empty string * unify chat model in `websocket_wiki` and `simple_chat` * import cleanup --------- Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- api/chat_model.py | 57 ++++++++++++++++++++++++++++++++++++++++ api/simple_chat.py | 59 ++++++++++++----------------------------- api/websocket_wiki.py | 61 +++++++++++-------------------------------- 3 files changed, 88 insertions(+), 89 deletions(-) create mode 100644 api/chat_model.py diff --git a/api/chat_model.py b/api/chat_model.py new file mode 100644 index 000000000..f7cbf2fc4 --- /dev/null +++ b/api/chat_model.py @@ -0,0 +1,57 @@ +from typing import Optional, List, Literal +from urllib.parse import unquote + +from pydantic import BaseModel, Field, field_validator + +# Models for the API +class ChatMessage(BaseModel): + role: str # 'user' or 'assistant' + content: str + +class ChatCompletionRequest(BaseModel): + """ + Model for requesting a chat completion. + """ + repo_url: str = Field(..., description="URL of the repository to query") + messages: List[ChatMessage] = Field(..., description="List of chat messages") + filePath: Optional[str] = Field(None, description="Optional path to a file in the repository to include in the prompt") + token: Optional[str] = Field(None, description="Personal access token for private repositories") + type: Optional[Literal["github", "gitlab", "bitbucket"]] = Field( + "github", + description="Type of repository (e.g., 'github', 'gitlab', 'bitbucket')", + ) + + # model parameters + provider: str = Field("google", description="Model provider (google, openai, openrouter, ollama, bedrock, azure, dashscope)") + model: Optional[str] = Field(None, description="Model name for the specified provider") + + language: Optional[str] = Field("en", description="Language for content generation (e.g., 'en', 'ja', 'zh', 'es', 'kr', 'vi')") + excluded_dirs: List[str] = Field( + default_factory=list, + description="List or newline-separated string of directories to exclude from processing", + ) + excluded_files: List[str] = Field( + default_factory=list, + description="List or newline-separated string of file patterns to exclude from processing", + ) + included_dirs: List[str] = Field( + default_factory=list, + description="List or newline-separated string of directories to include exclusively", + ) + included_files: List[str] = Field( + default_factory=list, + description="List or newline-separated string of file patterns to include exclusively", + ) + + @field_validator( + "excluded_dirs", + "excluded_files", + "included_dirs", + "included_files", + mode="before", + ) + @classmethod + def validate_path(cls, value: list[str] | str) -> list[str]: + if isinstance(value, str): + value = [unquote(path) for path in value.strip().split("\n") if path] + return value diff --git a/api/simple_chat.py b/api/simple_chat.py index 749d2ebe6..7db123ec1 100644 --- a/api/simple_chat.py +++ b/api/simple_chat.py @@ -1,12 +1,10 @@ import logging -from typing import List, Optional, Callable -from urllib.parse import unquote +from typing import Callable from functools import partial from fastapi import FastAPI, HTTPException from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import StreamingResponse -from pydantic import BaseModel, Field from api.chat import ChatStreamer, prompt_builder, is_token_limit_error from api.config import get_model_config, configs @@ -18,6 +16,7 @@ DEEP_RESEARCH_INTERMEDIATE_ITERATION_PROMPT, SIMPLE_CHAT_SYSTEM_PROMPT ) +from api.chat_model import ChatCompletionRequest # Configure logging from api.logging_config import setup_logging @@ -41,31 +40,6 @@ allow_headers=["*"], # Allows all headers ) -# Models for the API -class ChatMessage(BaseModel): - role: str # 'user' or 'assistant' - content: str - -class ChatCompletionRequest(BaseModel): - """ - Model for requesting a chat completion. - """ - repo_url: str = Field(..., description="URL of the repository to query") - messages: List[ChatMessage] = Field(..., description="List of chat messages") - filePath: Optional[str] = Field(None, description="Optional path to a file in the repository to include in the prompt") - token: Optional[str] = Field(None, description="Personal access token for private repositories") - type: Optional[str] = Field("github", description="Type of repository (e.g., 'github', 'gitlab', 'bitbucket')") - - # model parameters - provider: str = Field("google", description="Model provider (google, openai, openrouter, ollama, bedrock, azure, dashscope)") - model: Optional[str] = Field(None, description="Model name for the specified provider") - - language: Optional[str] = Field("en", description="Language for content generation (e.g., 'en', 'ja', 'zh', 'es', 'kr', 'vi')") - excluded_dirs: Optional[str] = Field(None, description="Comma-separated list of directories to exclude from processing") - excluded_files: Optional[str] = Field(None, description="Comma-separated list of file patterns to exclude from processing") - included_dirs: Optional[str] = Field(None, description="Comma-separated list of directories to include exclusively") - included_files: Optional[str] = Field(None, description="Comma-separated list of file patterns to include exclusively") - @app.post("/chat/completions/stream") async def chat_completions_stream(request: ChatCompletionRequest): @@ -87,25 +61,24 @@ async def chat_completions_stream(request: ChatCompletionRequest): request_rag = RAG(provider=request.provider, model=request.model) # Extract custom file filter parameters if provided - excluded_dirs = None - excluded_files = None - included_dirs = None - included_files = None - if request.excluded_dirs: - excluded_dirs = [unquote(dir_path) for dir_path in request.excluded_dirs.split('\n') if dir_path.strip()] - logger.info(f"Using custom excluded directories: {excluded_dirs}") + logger.info(f"Using custom excluded directories: {request.excluded_dirs}") if request.excluded_files: - excluded_files = [unquote(file_pattern) for file_pattern in request.excluded_files.split('\n') if file_pattern.strip()] - logger.info(f"Using custom excluded files: {excluded_files}") + logger.info(f"Using custom excluded files: {request.excluded_files}") if request.included_dirs: - included_dirs = [unquote(dir_path) for dir_path in request.included_dirs.split('\n') if dir_path.strip()] - logger.info(f"Using custom included directories: {included_dirs}") + logger.info(f"Using custom included directories: {request.included_dirs}") if request.included_files: - included_files = [unquote(file_pattern) for file_pattern in request.included_files.split('\n') if file_pattern.strip()] - logger.info(f"Using custom included files: {included_files}") - - request_rag.prepare_retriever(request.repo_url, request.type, request.token, excluded_dirs, excluded_files, included_dirs, included_files) + logger.info(f"Using custom included files: {request.included_files}") + + request_rag.prepare_retriever( + request.repo_url, + request.type, + request.token, + excluded_dirs=request.excluded_dirs, + excluded_files=request.excluded_files, + included_dirs=request.included_dirs, + included_files=request.included_files, + ) logger.info(f"Retriever prepared for {request.repo_url}") except ValueError as e: if "No valid documents with embeddings found" in str(e): diff --git a/api/websocket_wiki.py b/api/websocket_wiki.py index 2b7d17b85..32e37f7d9 100644 --- a/api/websocket_wiki.py +++ b/api/websocket_wiki.py @@ -1,11 +1,8 @@ import logging from collections.abc import AsyncIterator, Callable -from typing import List, Optional -from urllib.parse import unquote from functools import partial from fastapi import WebSocket, WebSocketDisconnect -from pydantic import BaseModel, Field from api.chat import ChatStreamer, prompt_builder, is_token_limit_error from api.config import ( @@ -20,6 +17,7 @@ DEEP_RESEARCH_INTERMEDIATE_ITERATION_PROMPT, SIMPLE_CHAT_SYSTEM_PROMPT, ) +from api.chat_model import ChatCompletionRequest # Configure logging from api.logging_config import setup_logging @@ -28,34 +26,6 @@ logger = logging.getLogger(__name__) -# Models for the API -class ChatMessage(BaseModel): - role: str # 'user' or 'assistant' - content: str - -class ChatCompletionRequest(BaseModel): - """ - Model for requesting a chat completion. - """ - repo_url: str = Field(..., description="URL of the repository to query") - messages: List[ChatMessage] = Field(..., description="List of chat messages") - filePath: Optional[str] = Field(None, description="Optional path to a file in the repository to include in the prompt") - token: Optional[str] = Field(None, description="Personal access token for private repositories") - type: Optional[str] = Field("github", description="Type of repository (e.g., 'github', 'gitlab', 'bitbucket')") - - # model parameters - provider: str = Field( - "google", - description="Model provider (google, openai, openrouter, ollama, bedrock, azure, dashscope)", - ) - model: Optional[str] = Field(None, description="Model name for the specified provider") - - language: Optional[str] = Field("en", description="Language for content generation (e.g., 'en', 'ja', 'zh', 'es', 'kr', 'vi')") - excluded_dirs: Optional[str] = Field(None, description="Comma-separated list of directories to exclude from processing") - excluded_files: Optional[str] = Field(None, description="Comma-separated list of file patterns to exclude from processing") - included_dirs: Optional[str] = Field(None, description="Comma-separated list of directories to include exclusively") - included_files: Optional[str] = Field(None, description="Comma-separated list of file patterns to include exclusively") - async def handle_websocket_chat(websocket: WebSocket): """ Handle WebSocket connection for chat completions. @@ -84,25 +54,24 @@ async def handle_websocket_chat(websocket: WebSocket): request_rag = RAG(provider=request.provider, model=request.model) # Extract custom file filter parameters if provided - excluded_dirs = None - excluded_files = None - included_dirs = None - included_files = None - if request.excluded_dirs: - excluded_dirs = [unquote(dir_path) for dir_path in request.excluded_dirs.split('\n') if dir_path.strip()] - logger.info(f"Using custom excluded directories: {excluded_dirs}") + logger.info(f"Using custom excluded directories: {request.excluded_dirs}") if request.excluded_files: - excluded_files = [unquote(file_pattern) for file_pattern in request.excluded_files.split('\n') if file_pattern.strip()] - logger.info(f"Using custom excluded files: {excluded_files}") + logger.info(f"Using custom excluded files: {request.excluded_files}") if request.included_dirs: - included_dirs = [unquote(dir_path) for dir_path in request.included_dirs.split('\n') if dir_path.strip()] - logger.info(f"Using custom included directories: {included_dirs}") + logger.info(f"Using custom included directories: {request.included_dirs}") if request.included_files: - included_files = [unquote(file_pattern) for file_pattern in request.included_files.split('\n') if file_pattern.strip()] - logger.info(f"Using custom included files: {included_files}") - - request_rag.prepare_retriever(request.repo_url, request.type, request.token, excluded_dirs, excluded_files, included_dirs, included_files) + logger.info(f"Using custom included files: {request.included_files}") + + request_rag.prepare_retriever( + request.repo_url, + request.type, + request.token, + excluded_dirs=request.excluded_dirs, + excluded_files=request.excluded_files, + included_dirs=request.included_dirs, + included_files=request.included_files, + ) logger.info(f"Retriever prepared for {request.repo_url}") except ValueError as e: if "No valid documents with embeddings found" in str(e): From 16e8813dc95f0f996b4a628ad578fb19625e4e5f Mon Sep 17 00:00:00 2001 From: GdoongMathew Date: Sun, 26 Jul 2026 05:05:54 +0800 Subject: [PATCH 31/50] Move all `ModelClient` classes into `client` module. (#551) * Refactor/chat streamer (#1) * refactor chat streaming * update `_is_token_limit_error` logic * test: add chat provider unittests * move `_is_token_limit_error` to module level * remove unnecessary TYPE_CHECKING * add EOF * update prompt function * update `input_too_large` threshold (#2) * patch: patch adalflow OllamaClient to support batch embedding api. (#3) related to https://github.com/SylphAI-Inc/AdalFlow/pull/496 * Revert "patch: patch adalflow OllamaClient to support batch embedding api. (#3)" (#4) This reverts commit 6bdc4e8ddb53a0ca705550b5eda17395bab34d83. * Feat/ollama batch embed patch (#5) * patch: patch adalflow OllamaClient to support batch embedding api. related to https://github.com/SylphAI-Inc/AdalFlow/pull/496 * remove `OllamaDocumentProcessor` * Refactor/chat streamer (#6) * refactor chat streaming * update `_is_token_limit_error` logic * test: add chat provider unittests * move `_is_token_limit_error` to module level * remove unnecessary TYPE_CHECKING * add EOF * update prompt function * refactor `websocket_wiki.py` using `ChatStreamer` * remove unnecessary imports * add litellm test * update kwargs in `GoogleGenerativeChatStreamer` * fix: fix simple_chat format * refactor and add clients module (#7) * centralize prompt (related to #306) * centralize prompt (related to #306) (#8) * fix prompt * update `check_ollama_model_exists` using ollama.list api. * fix exception type * fix import scope * fix catching exception * remove dangling file. * use field_validator to simplify dirs and files parsing in `ChatCompletionRequest` * Apply suggestions from code review Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> * prevent empty string * unify chat model in `websocket_wiki` and `simple_chat` * import cleanup * import documentations * add EOF * change azureai client import path in `_stream` module. * follwing pep8. --------- Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- api/azureai_client.py | 488 -------------- api/chat/_stream.py | 14 +- api/clients/__init__.py | 27 + api/{bedrock_client.py => clients/bedrock.py} | 0 .../dashscope.py} | 0 .../google_embedder.py} | 0 api/{litellm_client.py => clients/litellm.py} | 2 +- api/{ollama_client.py => clients/ollama.py} | 0 .../openrouter.py} | 0 api/config.py | 20 +- api/openai_client.py | 629 ------------------ 11 files changed, 46 insertions(+), 1134 deletions(-) delete mode 100644 api/azureai_client.py create mode 100644 api/clients/__init__.py rename api/{bedrock_client.py => clients/bedrock.py} (100%) rename api/{dashscope_client.py => clients/dashscope.py} (100%) rename api/{google_embedder_client.py => clients/google_embedder.py} (100%) rename api/{litellm_client.py => clients/litellm.py} (96%) rename api/{ollama_client.py => clients/ollama.py} (100%) rename api/{openrouter_client.py => clients/openrouter.py} (100%) delete mode 100644 api/openai_client.py diff --git a/api/azureai_client.py b/api/azureai_client.py deleted file mode 100644 index 948e86c30..000000000 --- a/api/azureai_client.py +++ /dev/null @@ -1,488 +0,0 @@ -"""AzureOpenAI ModelClient integration.""" - -import os -from typing import ( - Dict, - Sequence, - Optional, - List, - Any, - TypeVar, - Callable, - Generator, - Union, - Literal, -) -import re - -import logging -import backoff - -# optional import -from adalflow.utils.lazy_import import safe_import, OptionalPackages - -import sys - -openai = safe_import(OptionalPackages.OPENAI.value[0], OptionalPackages.OPENAI.value[1]) -# Importing all Azure packages together -azure_modules = safe_import( - OptionalPackages.AZURE.value[0], # List of package names - OptionalPackages.AZURE.value[1], # Error message -) -# Manually add each module to sys.modules to make them available globally as if imported normally -azure_module_names = OptionalPackages.AZURE.value[0] -for name, module in zip(azure_module_names, azure_modules): - sys.modules[name] = module - -# Use the modules as if they were imported normally -from azure.identity import DefaultAzureCredential, get_bearer_token_provider - -# from azure.core.credentials import AccessToken -from openai import AzureOpenAI, AsyncAzureOpenAI, Stream -from openai import ( - APITimeoutError, - InternalServerError, - RateLimitError, - UnprocessableEntityError, - BadRequestError, -) -from openai.types import ( - Completion, - CreateEmbeddingResponse, -) -from openai.types.chat import ChatCompletionChunk, ChatCompletion - -from adalflow.core.model_client import ModelClient -from adalflow.core.types import ( - ModelType, - EmbedderOutput, - TokenLogProb, - CompletionUsage, - GeneratorOutput, -) -from adalflow.components.model_client.utils import parse_embedding_response - -log = logging.getLogger(__name__) -T = TypeVar("T") - - -__all__ = ["AzureAIClient"] - -# TODO: this overlaps with openai client largely, might need to refactor to subclass openai client to simplify the code - - -# completion parsing functions and you can combine them into one singple chat completion parser -def get_first_message_content(completion: ChatCompletion) -> str: - r"""When we only need the content of the first message. - It is the default parser for chat completion.""" - return completion.choices[0].message.content - - -# def _get_chat_completion_usage(completion: ChatCompletion) -> OpenAICompletionUsage: -# return completion.usage - - -def parse_stream_response(completion: ChatCompletionChunk) -> str: - r"""Parse the response of the stream API.""" - return completion.choices[0].delta.content - - -def handle_streaming_response(generator: Stream[ChatCompletionChunk]): - r"""Handle the streaming response.""" - for completion in generator: - log.debug(f"Raw chunk completion: {completion}") - parsed_content = parse_stream_response(completion) - yield parsed_content - - -def get_all_messages_content(completion: ChatCompletion) -> List[str]: - r"""When the n > 1, get all the messages content.""" - return [c.message.content for c in completion.choices] - - -def get_probabilities(completion: ChatCompletion) -> List[List[TokenLogProb]]: - r"""Get the probabilities of each token in the completion.""" - log_probs = [] - for c in completion.choices: - content = c.logprobs.content - print(content) - log_probs_for_choice = [] - for openai_token_logprob in content: - token = openai_token_logprob.token - logprob = openai_token_logprob.logprob - log_probs_for_choice.append(TokenLogProb(token=token, logprob=logprob)) - log_probs.append(log_probs_for_choice) - return log_probs - - -class AzureAIClient(ModelClient): - __doc__ = r""" - A client wrapper for interacting with Azure OpenAI's API. - - This class provides support for both embedding and chat completion API calls. - Users can use this class to simplify their interactions with Azure OpenAI models - through the `Embedder` and `Generator` components. - - **Initialization:** - - You can initialize the `AzureAIClient` with either an API key or Azure Active Directory (AAD) token - authentication. It is recommended to set environment variables for sensitive data like API keys. - - Args: - api_key (Optional[str]): Azure OpenAI API key. Default is None. - api_version (Optional[str]): API version to use. Default is None. - azure_endpoint (Optional[str]): Azure OpenAI endpoint URL. Default is None. - credential (Optional[DefaultAzureCredential]): Azure AD credential for token-based authentication. Default is None. - chat_completion_parser (Callable[[Completion], Any]): Function to parse chat completions. Default is `get_first_message_content`. - input_type (Literal["text", "messages"]): Format for input, either "text" or "messages". Default is "text". - - **Setup Instructions:** - - - **Using API Key:** - Set up the following environment variables: - ```bash - export AZURE_OPENAI_API_KEY="your_api_key" - export AZURE_OPENAI_ENDPOINT="your_endpoint" - export AZURE_OPENAI_VERSION="your_version" - ``` - - - **Using Azure AD Token:** - Ensure you have configured Azure AD credentials. The `DefaultAzureCredential` will automatically use your configured credentials. - - **Example Usage:** - - .. code-block:: python - - from azure.identity import DefaultAzureCredential - from your_module import AzureAIClient # Adjust import based on your module name - - # Initialize with API key - client = AzureAIClient( - api_key="your_api_key", - api_version="2023-05-15", - azure_endpoint="https://your-endpoint.openai.azure.com/" - ) - - # Or initialize with Azure AD token - client = AzureAIClient( - api_version="2023-05-15", - azure_endpoint="https://your-endpoint.openai.azure.com/", - credential=DefaultAzureCredential() - ) - - # Example call to the chat completion API - api_kwargs = { - "model": "gpt-3.5-turbo", - "messages": [{"role": "user", "content": "What is the meaning of life?"}], - "stream": True - } - response = client.call(api_kwargs=api_kwargs, model_type=ModelType.LLM) - - for chunk in response: - print(chunk) - - - **Notes:** - - Ensure that the API key or credentials are correctly set up and accessible to avoid authentication errors. - - Use `chat_completion_parser` to define how to extract and handle the chat completion responses. - - The `input_type` parameter determines how input is formatted for the API call. - - **References:** - - [Azure OpenAI API Documentation](https://learn.microsoft.com/en-us/azure/ai-services/openai/overview) - - [OpenAI API Documentation](https://platform.openai.com/docs/guides/text-generation) - """ - - def __init__( - self, - api_key: Optional[str] = None, - api_version: Optional[str] = None, - azure_endpoint: Optional[str] = None, - credential: Optional[DefaultAzureCredential] = None, - chat_completion_parser: Callable[[Completion], Any] = None, - input_type: Literal["text", "messages"] = "text", - ): - r"""It is recommended to set the API_KEY into the environment variable instead of passing it as an argument. - - - Initializes the Azure OpenAI client with either API key or AAD token authentication. - - Args: - api_key: Azure OpenAI API key. - api_version: Azure OpenAI API version. - azure_endpoint: Azure OpenAI endpoint. - credential: Azure AD credential for token-based authentication. - chat_completion_parser: Function to parse chat completions. - input_type: Input format, either "text" or "messages". - - """ - super().__init__() - - # added api_type azure for azure Ai - self.api_type = "azure" - self._api_key = api_key - self._apiversion = api_version - self._azure_endpoint = azure_endpoint - self._credential = credential - self.sync_client = self.init_sync_client() - self.async_client = None # only initialize if the async call is called - self.chat_completion_parser = ( - chat_completion_parser or get_first_message_content - ) - self._input_type = input_type - - def init_sync_client(self): - api_key = self._api_key or os.getenv("AZURE_OPENAI_API_KEY") - azure_endpoint = self._azure_endpoint or os.getenv("AZURE_OPENAI_ENDPOINT") - api_version = self._apiversion or os.getenv("AZURE_OPENAI_VERSION") - # credential = self._credential or DefaultAzureCredential - if not azure_endpoint: - raise ValueError("Environment variable AZURE_OPENAI_ENDPOINT must be set") - if not api_version: - raise ValueError("Environment variable AZURE_OPENAI_VERSION must be set") - - if api_key: - return AzureOpenAI( - api_key=api_key, azure_endpoint=azure_endpoint, api_version=api_version - ) - elif self._credential: - # credential = DefaultAzureCredential() - token_provider = get_bearer_token_provider( - DefaultAzureCredential(), "https://cognitiveservices.azure.com/.default" - ) - return AzureOpenAI( - azure_ad_token_provider=token_provider, - azure_endpoint=azure_endpoint, - api_version=api_version, - ) - else: - raise ValueError( - "Environment variable AZURE_OPENAI_API_KEY must be set or credential must be provided" - ) - - def init_async_client(self): - api_key = self._api_key or os.getenv("AZURE_OPENAI_API_KEY") - azure_endpoint = self._azure_endpoint or os.getenv("AZURE_OPENAI_ENDPOINT") - api_version = self._apiversion or os.getenv("AZURE_OPENAI_VERSION") - # credential = self._credential or DefaultAzureCredential() - if not azure_endpoint: - raise ValueError("Environment variable AZURE_OPENAI_ENDPOINT must be set") - if not api_version: - raise ValueError("Environment variable AZURE_OPENAI_VERSION must be set") - - if api_key: - return AsyncAzureOpenAI( - api_key=api_key, azure_endpoint=azure_endpoint, api_version=api_version - ) - elif self._credential: - # credential = DefaultAzureCredential() - token_provider = get_bearer_token_provider( - DefaultAzureCredential(), "https://cognitiveservices.azure.com/.default" - ) - return AsyncAzureOpenAI( - azure_ad_token_provider=token_provider, - azure_endpoint=azure_endpoint, - api_version=api_version, - ) - else: - raise ValueError( - "Environment variable AZURE_OPENAI_API_KEY must be set or credential must be provided" - ) - - # def _parse_chat_completion(self, completion: ChatCompletion) -> "GeneratorOutput": - # # TODO: raw output it is better to save the whole completion as a source of truth instead of just the message - # try: - # data = self.chat_completion_parser(completion) - # usage = self.track_completion_usage(completion) - # return GeneratorOutput( - # data=data, error=None, raw_response=str(data), usage=usage - # ) - # except Exception as e: - # log.error(f"Error parsing the completion: {e}") - # return GeneratorOutput(data=None, error=str(e), raw_response=completion) - - def parse_chat_completion( - self, - completion: Union[ChatCompletion, Generator[ChatCompletionChunk, None, None]], - ) -> "GeneratorOutput": - """Parse the completion, and put it into the raw_response.""" - log.debug(f"completion: {completion}, parser: {self.chat_completion_parser}") - try: - data = self.chat_completion_parser(completion) - usage = self.track_completion_usage(completion) - return GeneratorOutput( - data=None, error=None, raw_response=data, usage=usage - ) - except Exception as e: - log.error(f"Error parsing the completion: {e}") - return GeneratorOutput(data=None, error=str(e), raw_response=completion) - - def track_completion_usage( - self, - completion: Union[ChatCompletion, Generator[ChatCompletionChunk, None, None]], - ) -> CompletionUsage: - if isinstance(completion, ChatCompletion): - usage: CompletionUsage = CompletionUsage( - completion_tokens=completion.usage.completion_tokens, - prompt_tokens=completion.usage.prompt_tokens, - total_tokens=completion.usage.total_tokens, - ) - return usage - else: - raise NotImplementedError( - "streaming completion usage tracking is not implemented" - ) - - def parse_embedding_response( - self, response: CreateEmbeddingResponse - ) -> EmbedderOutput: - r"""Parse the embedding response to a structure AdalFlow components can understand. - - Should be called in ``Embedder``. - """ - try: - return parse_embedding_response(response) - except Exception as e: - log.error(f"Error parsing the embedding response: {e}") - return EmbedderOutput(data=[], error=str(e), raw_response=response) - - def convert_inputs_to_api_kwargs( - self, - input: Optional[Any] = None, - model_kwargs: Dict = {}, - model_type: ModelType = ModelType.UNDEFINED, - ) -> Dict: - r""" - Specify the API input type and output api_kwargs that will be used in _call and _acall methods. - Convert the Component's standard input, and system_input(chat model) and model_kwargs into API-specific format - """ - - final_model_kwargs = model_kwargs.copy() - if model_type == ModelType.EMBEDDER: - if isinstance(input, str): - input = [input] - # convert input to input - if not isinstance(input, Sequence): - raise TypeError("input must be a sequence of text") - final_model_kwargs["input"] = input - elif model_type == ModelType.LLM: - # convert input to messages - messages: List[Dict[str, str]] = [] - - if self._input_type == "messages": - system_start_tag = "" - system_end_tag = "" - user_start_tag = "" - user_end_tag = "" - pattern = f"{system_start_tag}(.*?){system_end_tag}{user_start_tag}(.*?){user_end_tag}" - # Compile the regular expression - regex = re.compile(pattern) - # Match the pattern - match = regex.search(input) - system_prompt, input_str = None, None - - if match: - system_prompt = match.group(1) - input_str = match.group(2) - - else: - print("No match found.") - if system_prompt and input_str: - messages.append({"role": "system", "content": system_prompt}) - messages.append({"role": "user", "content": input_str}) - if len(messages) == 0: - messages.append({"role": "system", "content": input}) - final_model_kwargs["messages"] = messages - else: - raise ValueError(f"model_type {model_type} is not supported") - return final_model_kwargs - - @backoff.on_exception( - backoff.expo, - ( - APITimeoutError, - InternalServerError, - RateLimitError, - UnprocessableEntityError, - BadRequestError, - ), - max_time=5, - ) - def call(self, api_kwargs: Dict = {}, model_type: ModelType = ModelType.UNDEFINED): - """ - kwargs is the combined input and model_kwargs. Support streaming call. - """ - log.info(f"api_kwargs: {api_kwargs}") - if model_type == ModelType.EMBEDDER: - return self.sync_client.embeddings.create(**api_kwargs) - elif model_type == ModelType.LLM: - if "stream" in api_kwargs and api_kwargs.get("stream", False): - log.debug("streaming call") - self.chat_completion_parser = handle_streaming_response - return self.sync_client.chat.completions.create(**api_kwargs) - return self.sync_client.chat.completions.create(**api_kwargs) - else: - raise ValueError(f"model_type {model_type} is not supported") - - @backoff.on_exception( - backoff.expo, - ( - APITimeoutError, - InternalServerError, - RateLimitError, - UnprocessableEntityError, - BadRequestError, - ), - max_time=5, - ) - async def acall( - self, api_kwargs: Dict = {}, model_type: ModelType = ModelType.UNDEFINED - ): - """ - kwargs is the combined input and model_kwargs - """ - if self.async_client is None: - self.async_client = self.init_async_client() - if model_type == ModelType.EMBEDDER: - return await self.async_client.embeddings.create(**api_kwargs) - elif model_type == ModelType.LLM: - return await self.async_client.chat.completions.create(**api_kwargs) - else: - raise ValueError(f"model_type {model_type} is not supported") - - @classmethod - def from_dict(cls: type[T], data: Dict[str, Any]) -> T: - obj = super().from_dict(data) - # recreate the existing clients - obj.sync_client = obj.init_sync_client() - obj.async_client = obj.init_async_client() - return obj - - def to_dict(self) -> Dict[str, Any]: - r"""Convert the component to a dictionary.""" - # TODO: not exclude but save yes or no for recreating the clients - exclude = [ - "sync_client", - "async_client", - ] # unserializable object - output = super().to_dict(exclude=exclude) - return output - - -# if __name__ == "__main__": -# from adalflow.core import Generator -# from adalflow.utils import setup_env, get_logger - -# log = get_logger(level="DEBUG") - -# setup_env() -# prompt_kwargs = {"input_str": "What is the meaning of life?"} - -# gen = Generator( -# model_client=OpenAIClient(), -# model_kwargs={"model": "gpt-3.5-turbo", "stream": True}, -# ) -# gen_response = gen(prompt_kwargs) -# print(f"gen_response: {gen_response}") - -# for genout in gen_response.data: -# print(f"genout: {genout}") \ No newline at end of file diff --git a/api/chat/_stream.py b/api/chat/_stream.py index 94251d162..14525769f 100644 --- a/api/chat/_stream.py +++ b/api/chat/_stream.py @@ -18,7 +18,7 @@ from ollama import ChatResponse from openai.types.chat import ChatCompletionChunk from openai import AsyncStream - from api.openai_client import OpenAIClient + from api.clients import OpenAIClient MODEL_CFG = dict[str, str | int | float] @@ -106,7 +106,7 @@ def __init__(self, *, model: str, model_config: MODEL_CFG): if not OPENROUTER_API_KEY: logger.warning("OPENROUTER_API_KEY not configured, but continuing with request") # We'll let the OpenRouterClient handle this and return a friendly error message - from api.openrouter_client import OpenRouterClient + from api.clients import OpenRouterClient self.client = OpenRouterClient() self.model_kwargs = { @@ -185,7 +185,7 @@ def __init__(self, *, model: str, model_config: MODEL_CFG): super().__init__(model=model, model_config=model_config) def _build_client(self): - from api.openai_client import OpenAIClient + from api.clients import OpenAIClient return OpenAIClient() @@ -198,7 +198,7 @@ class AzureChatStreamer(_OpenAICompatStreamer): ) def _build_client(self): - from api.azureai_client import AzureAIClient + from api.clients import AzureAIClient return AzureAIClient() @@ -217,7 +217,7 @@ def __init__(self, *, model: str, model_config: MODEL_CFG): super().__init__(model=model, model_config=model_config) def _build_client(self): - from api.litellm_client import LiteLLMClient + from api.clients import LiteLLMClient return LiteLLMClient() @@ -232,7 +232,7 @@ def __init__(self, *, model: str, model_config: MODEL_CFG): if not AWS_ACCESS_KEY_ID or not AWS_SECRET_ACCESS_KEY: logger.warning("AWS_ACCESS_KEY_ID or AWS_SECRET_ACCESS_KEY not configured, but continuing with request") # We'll let the BedrockClient handle this and return an error message - from api.bedrock_client import BedrockClient + from api.clients import BedrockClient self.client = BedrockClient() self.model_kwargs = {"model": model} @@ -267,7 +267,7 @@ class DashScopeChatStreamer(ChatStreamer): ) def __init__(self, *, model: str, model_config: MODEL_CFG): - from api.dashscope_client import DashscopeClient + from api.clients import DashscopeClient self.client = DashscopeClient() self.model_kwargs = { diff --git a/api/clients/__init__.py b/api/clients/__init__.py new file mode 100644 index 000000000..59661eba1 --- /dev/null +++ b/api/clients/__init__.py @@ -0,0 +1,27 @@ +"""Unify Adalflow compatible clients to here. +Any patches and additional clients could be applied or imported in this module. +""" + +from adalflow.components.model_client import ( + AzureAIClient, + OpenAIClient, + GoogleGenAIClient, +) +from .bedrock import BedrockClient +from .dashscope import DashscopeClient +from .google_embedder import GoogleEmbedderClient +from .litellm import LiteLLMClient +from .ollama import OllamaClient +from .openrouter import OpenRouterClient + +__all__ = [ + "AzureAIClient", + "BedrockClient", + "DashscopeClient", + "GoogleEmbedderClient", + "GoogleGenAIClient", + "LiteLLMClient", + "OllamaClient", + "OpenAIClient", + "OpenRouterClient", +] diff --git a/api/bedrock_client.py b/api/clients/bedrock.py similarity index 100% rename from api/bedrock_client.py rename to api/clients/bedrock.py diff --git a/api/dashscope_client.py b/api/clients/dashscope.py similarity index 100% rename from api/dashscope_client.py rename to api/clients/dashscope.py diff --git a/api/google_embedder_client.py b/api/clients/google_embedder.py similarity index 100% rename from api/google_embedder_client.py rename to api/clients/google_embedder.py diff --git a/api/litellm_client.py b/api/clients/litellm.py similarity index 96% rename from api/litellm_client.py rename to api/clients/litellm.py index 439adced6..1c814d11f 100644 --- a/api/litellm_client.py +++ b/api/clients/litellm.py @@ -2,7 +2,7 @@ from typing import Optional, Callable from openai import AsyncOpenAI, OpenAI -from api.openai_client import OpenAIClient +from adalflow.components.model_client.openai_client import OpenAIClient class LiteLLMClient(OpenAIClient): diff --git a/api/ollama_client.py b/api/clients/ollama.py similarity index 100% rename from api/ollama_client.py rename to api/clients/ollama.py diff --git a/api/openrouter_client.py b/api/clients/openrouter.py similarity index 100% rename from api/openrouter_client.py rename to api/clients/openrouter.py diff --git a/api/config.py b/api/config.py index 4cafcdbc7..bd55b7247 100644 --- a/api/config.py +++ b/api/config.py @@ -7,15 +7,17 @@ logger = logging.getLogger(__name__) -from api.openai_client import OpenAIClient -from api.litellm_client import LiteLLMClient -from api.openrouter_client import OpenRouterClient -from api.bedrock_client import BedrockClient -from api.google_embedder_client import GoogleEmbedderClient -from api.azureai_client import AzureAIClient -from api.dashscope_client import DashscopeClient -from api.ollama_client import OllamaClient -from adalflow import GoogleGenAIClient +from api.clients import ( + AzureAIClient, + BedrockClient, + DashscopeClient, + GoogleEmbedderClient, + GoogleGenAIClient, + LiteLLMClient, + OllamaClient, + OpenAIClient, + OpenRouterClient, +) # Get API keys from environment variables OPENAI_API_KEY = os.environ.get('OPENAI_API_KEY') diff --git a/api/openai_client.py b/api/openai_client.py deleted file mode 100644 index bc75ed586..000000000 --- a/api/openai_client.py +++ /dev/null @@ -1,629 +0,0 @@ -"""OpenAI ModelClient integration.""" - -import os -import base64 -from typing import ( - Dict, - Sequence, - Optional, - List, - Any, - TypeVar, - Callable, - Generator, - Union, - Literal, -) -import re - -import logging -import backoff - -# optional import -from adalflow.utils.lazy_import import safe_import, OptionalPackages -from openai.types.chat.chat_completion import Choice - -openai = safe_import(OptionalPackages.OPENAI.value[0], OptionalPackages.OPENAI.value[1]) - -from openai import OpenAI, AsyncOpenAI, Stream -from openai import ( - APITimeoutError, - InternalServerError, - RateLimitError, - UnprocessableEntityError, - BadRequestError, -) -from openai.types import ( - Completion, - CreateEmbeddingResponse, - Image, -) -from openai.types.chat import ChatCompletionChunk, ChatCompletion, ChatCompletionMessage - -from adalflow.core.model_client import ModelClient -from adalflow.core.types import ( - ModelType, - EmbedderOutput, - TokenLogProb, - CompletionUsage, - GeneratorOutput, -) -from adalflow.components.model_client.utils import parse_embedding_response - -log = logging.getLogger(__name__) -T = TypeVar("T") - - -# completion parsing functions and you can combine them into one singple chat completion parser -def get_first_message_content(completion: ChatCompletion) -> str: - r"""When we only need the content of the first message. - It is the default parser for chat completion.""" - log.debug(f"raw completion: {completion}") - return completion.choices[0].message.content - - -# def _get_chat_completion_usage(completion: ChatCompletion) -> OpenAICompletionUsage: -# return completion.usage - - -# A simple heuristic to estimate token count for estimating number of tokens in a Streaming response -def estimate_token_count(text: str) -> int: - """ - Estimate the token count of a given text. - - Args: - text (str): The text to estimate token count for. - - Returns: - int: Estimated token count. - """ - # Split the text into tokens using spaces as a simple heuristic - tokens = text.split() - - # Return the number of tokens - return len(tokens) - - -def parse_stream_response(completion: ChatCompletionChunk) -> str: - r"""Parse the response of the stream API.""" - return completion.choices[0].delta.content - - -def handle_streaming_response(generator: Stream[ChatCompletionChunk]): - r"""Handle the streaming response.""" - for completion in generator: - log.debug(f"Raw chunk completion: {completion}") - parsed_content = parse_stream_response(completion) - yield parsed_content - - -def get_all_messages_content(completion: ChatCompletion) -> List[str]: - r"""When the n > 1, get all the messages content.""" - return [c.message.content for c in completion.choices] - - -def get_probabilities(completion: ChatCompletion) -> List[List[TokenLogProb]]: - r"""Get the probabilities of each token in the completion.""" - log_probs = [] - for c in completion.choices: - content = c.logprobs.content - print(content) - log_probs_for_choice = [] - for openai_token_logprob in content: - token = openai_token_logprob.token - logprob = openai_token_logprob.logprob - log_probs_for_choice.append(TokenLogProb(token=token, logprob=logprob)) - log_probs.append(log_probs_for_choice) - return log_probs - - -class OpenAIClient(ModelClient): - __doc__ = r"""A component wrapper for the OpenAI API client. - - Supports both embedding and chat completion APIs, including multimodal capabilities. - - Users can: - 1. Simplify use of ``Embedder`` and ``Generator`` components by passing `OpenAIClient()` as the `model_client`. - 2. Use this as a reference to create their own API client or extend this class by copying and modifying the code. - - Note: - We recommend avoiding `response_format` to enforce output data type or `tools` and `tool_choice` in `model_kwargs` when calling the API. - OpenAI's internal formatting and added prompts are unknown. Instead: - - Use :ref:`OutputParser` for response parsing and formatting. - - For multimodal inputs, provide images in `model_kwargs["images"]` as a path, URL, or list of them. - The model must support vision capabilities (e.g., `gpt-4o`, `gpt-4o-mini`, `o1`, `o1-mini`). - - For image generation, use `model_type=ModelType.IMAGE_GENERATION` and provide: - - model: `"dall-e-3"` or `"dall-e-2"` - - prompt: Text description of the image to generate - - size: `"1024x1024"`, `"1024x1792"`, or `"1792x1024"` for DALL-E 3; `"256x256"`, `"512x512"`, or `"1024x1024"` for DALL-E 2 - - quality: `"standard"` or `"hd"` (DALL-E 3 only) - - n: Number of images to generate (1 for DALL-E 3, 1-10 for DALL-E 2) - - response_format: `"url"` or `"b64_json"` - - Args: - api_key (Optional[str], optional): OpenAI API key. Defaults to `None`. - chat_completion_parser (Callable[[Completion], Any], optional): A function to parse the chat completion into a `str`. Defaults to `None`. - The default parser is `get_first_message_content`. - base_url (str): The API base URL to use when initializing the client. - Defaults to `"https://api.openai.com"`, but can be customized for third-party API providers or self-hosted models. - env_api_key_name (str): The environment variable name for the API key. Defaults to `"OPENAI_API_KEY"`. - - References: - - OpenAI API Overview: https://platform.openai.com/docs/introduction - - Embeddings Guide: https://platform.openai.com/docs/guides/embeddings - - Chat Completion Models: https://platform.openai.com/docs/guides/text-generation - - Vision Models: https://platform.openai.com/docs/guides/vision - - Image Generation: https://platform.openai.com/docs/guides/images - """ - - def __init__( - self, - api_key: Optional[str] = None, - chat_completion_parser: Callable[[Completion], Any] = None, - input_type: Literal["text", "messages"] = "text", - base_url: Optional[str] = None, - env_base_url_name: str = "OPENAI_BASE_URL", - env_api_key_name: str = "OPENAI_API_KEY", - ): - r"""It is recommended to set the OPENAI_API_KEY environment variable instead of passing it as an argument. - - Args: - api_key (Optional[str], optional): OpenAI API key. Defaults to None. - base_url (str): The API base URL to use when initializing the client. - env_api_key_name (str): The environment variable name for the API key. Defaults to `"OPENAI_API_KEY"`. - """ - super().__init__() - self._api_key = api_key - self._env_api_key_name = env_api_key_name - self._env_base_url_name = env_base_url_name - self.base_url = base_url or os.getenv(self._env_base_url_name, "https://api.openai.com/v1") - self.sync_client = self.init_sync_client() - self.async_client = None # only initialize if the async call is called - self.chat_completion_parser = ( - chat_completion_parser or get_first_message_content - ) - self._input_type = input_type - self._api_kwargs = {} # add api kwargs when the OpenAI Client is called - - def init_sync_client(self): - api_key = self._api_key or os.getenv(self._env_api_key_name) - if not api_key: - raise ValueError( - f"Environment variable {self._env_api_key_name} must be set" - ) - return OpenAI(api_key=api_key, base_url=self.base_url) - - def init_async_client(self): - api_key = self._api_key or os.getenv(self._env_api_key_name) - if not api_key: - raise ValueError( - f"Environment variable {self._env_api_key_name} must be set" - ) - return AsyncOpenAI(api_key=api_key, base_url=self.base_url) - - # def _parse_chat_completion(self, completion: ChatCompletion) -> "GeneratorOutput": - # # TODO: raw output it is better to save the whole completion as a source of truth instead of just the message - # try: - # data = self.chat_completion_parser(completion) - # usage = self.track_completion_usage(completion) - # return GeneratorOutput( - # data=data, error=None, raw_response=str(data), usage=usage - # ) - # except Exception as e: - # log.error(f"Error parsing the completion: {e}") - # return GeneratorOutput(data=None, error=str(e), raw_response=completion) - - def parse_chat_completion( - self, - completion: Union[ChatCompletion, Generator[ChatCompletionChunk, None, None]], - ) -> "GeneratorOutput": - """Parse the completion, and put it into the raw_response.""" - log.debug(f"completion: {completion}, parser: {self.chat_completion_parser}") - try: - data = self.chat_completion_parser(completion) - except Exception as e: - log.error(f"Error parsing the completion: {e}") - return GeneratorOutput(data=None, error=str(e), raw_response=completion) - - try: - usage = self.track_completion_usage(completion) - return GeneratorOutput( - data=None, error=None, raw_response=data, usage=usage - ) - except Exception as e: - log.error(f"Error tracking the completion usage: {e}") - return GeneratorOutput(data=None, error=str(e), raw_response=data) - - def track_completion_usage( - self, - completion: Union[ChatCompletion, Generator[ChatCompletionChunk, None, None]], - ) -> CompletionUsage: - - try: - usage: CompletionUsage = CompletionUsage( - completion_tokens=completion.usage.completion_tokens, - prompt_tokens=completion.usage.prompt_tokens, - total_tokens=completion.usage.total_tokens, - ) - return usage - except Exception as e: - log.error(f"Error tracking the completion usage: {e}") - return CompletionUsage( - completion_tokens=None, prompt_tokens=None, total_tokens=None - ) - - def parse_embedding_response( - self, response: CreateEmbeddingResponse - ) -> EmbedderOutput: - r"""Parse the embedding response to a structure Adalflow components can understand. - - Should be called in ``Embedder``. - """ - try: - return parse_embedding_response(response) - except Exception as e: - log.error(f"Error parsing the embedding response: {e}") - return EmbedderOutput(data=[], error=str(e), raw_response=response) - - def convert_inputs_to_api_kwargs( - self, - input: Optional[Any] = None, - model_kwargs: Dict = {}, - model_type: ModelType = ModelType.UNDEFINED, - ) -> Dict: - r""" - Specify the API input type and output api_kwargs that will be used in _call and _acall methods. - Convert the Component's standard input, and system_input(chat model) and model_kwargs into API-specific format. - For multimodal inputs, images can be provided in model_kwargs["images"] as a string path, URL, or list of them. - The model specified in model_kwargs["model"] must support multimodal capabilities when using images. - - Args: - input: The input text or messages to process - model_kwargs: Additional parameters including: - - images: Optional image source(s) as path, URL, or list of them - - detail: Image detail level ('auto', 'low', or 'high'), defaults to 'auto' - - model: The model to use (must support multimodal inputs if images are provided) - model_type: The type of model (EMBEDDER or LLM) - - Returns: - Dict: API-specific kwargs for the model call - """ - - final_model_kwargs = model_kwargs.copy() - if model_type == ModelType.EMBEDDER: - if isinstance(input, str): - input = [input] - # convert input to input - if not isinstance(input, Sequence): - raise TypeError("input must be a sequence of text") - final_model_kwargs["input"] = input - elif model_type == ModelType.LLM: - # convert input to messages - messages: List[Dict[str, str]] = [] - images = final_model_kwargs.pop("images", None) - detail = final_model_kwargs.pop("detail", "auto") - - if self._input_type == "messages": - system_start_tag = "" - system_end_tag = "" - user_start_tag = "" - user_end_tag = "" - - # new regex pattern to ignore special characters such as \n - pattern = ( - rf"{system_start_tag}\s*(.*?)\s*{system_end_tag}\s*" - rf"{user_start_tag}\s*(.*?)\s*{user_end_tag}" - ) - - # Compile the regular expression - - # re.DOTALL is to allow . to match newline so that (.*?) does not match in a single line - regex = re.compile(pattern, re.DOTALL) - # Match the pattern - match = regex.match(input) - system_prompt, input_str = None, None - - if match: - system_prompt = match.group(1) - input_str = match.group(2) - else: - print("No match found.") - if system_prompt and input_str: - messages.append({"role": "system", "content": system_prompt}) - if images: - content = [{"type": "text", "text": input_str}] - if isinstance(images, (str, dict)): - images = [images] - for img in images: - content.append(self._prepare_image_content(img, detail)) - messages.append({"role": "user", "content": content}) - else: - messages.append({"role": "user", "content": input_str}) - if len(messages) == 0: - if images: - content = [{"type": "text", "text": input}] - if isinstance(images, (str, dict)): - images = [images] - for img in images: - content.append(self._prepare_image_content(img, detail)) - messages.append({"role": "user", "content": content}) - else: - messages.append({"role": "user", "content": input}) - final_model_kwargs["messages"] = messages - elif model_type == ModelType.IMAGE_GENERATION: - # For image generation, input is the prompt - final_model_kwargs["prompt"] = input - # Ensure model is specified - if "model" not in final_model_kwargs: - raise ValueError("model must be specified for image generation") - # Set defaults for DALL-E 3 if not specified - final_model_kwargs["size"] = final_model_kwargs.get("size", "1024x1024") - final_model_kwargs["quality"] = final_model_kwargs.get( - "quality", "standard" - ) - final_model_kwargs["n"] = final_model_kwargs.get("n", 1) - final_model_kwargs["response_format"] = final_model_kwargs.get( - "response_format", "url" - ) - - # Handle image edits and variations - image = final_model_kwargs.get("image") - if isinstance(image, str) and os.path.isfile(image): - final_model_kwargs["image"] = self._encode_image(image) - - mask = final_model_kwargs.get("mask") - if isinstance(mask, str) and os.path.isfile(mask): - final_model_kwargs["mask"] = self._encode_image(mask) - else: - raise ValueError(f"model_type {model_type} is not supported") - - return final_model_kwargs - - def parse_image_generation_response(self, response: List[Image]) -> GeneratorOutput: - """Parse the image generation response into a GeneratorOutput.""" - try: - # Extract URLs or base64 data from the response - data = [img.url or img.b64_json for img in response] - # For single image responses, unwrap from list - if len(data) == 1: - data = data[0] - return GeneratorOutput( - data=data, - raw_response=str(response), - ) - except Exception as e: - log.error(f"Error parsing image generation response: {e}") - return GeneratorOutput(data=None, error=str(e), raw_response=str(response)) - - @backoff.on_exception( - backoff.expo, - ( - APITimeoutError, - InternalServerError, - RateLimitError, - UnprocessableEntityError, - BadRequestError, - ), - max_time=5, - ) - def call(self, api_kwargs: Dict = {}, model_type: ModelType = ModelType.UNDEFINED): - """ - kwargs is the combined input and model_kwargs. Support streaming call. - """ - log.info(f"api_kwargs: {api_kwargs}") - self._api_kwargs = api_kwargs - if model_type == ModelType.EMBEDDER: - return self.sync_client.embeddings.create(**api_kwargs) - elif model_type == ModelType.LLM: - if "stream" in api_kwargs and api_kwargs.get("stream", False): - log.debug("streaming call") - self.chat_completion_parser = handle_streaming_response - return self.sync_client.chat.completions.create(**api_kwargs) - else: - log.debug("non-streaming call converted to streaming") - # Make a copy of api_kwargs to avoid modifying the original - streaming_kwargs = api_kwargs.copy() - streaming_kwargs["stream"] = True - - # Get streaming response - stream_response = self.sync_client.chat.completions.create(**streaming_kwargs) - - # Accumulate all content from the stream - accumulated_content = "" - id = "" - model = "" - created = 0 - for chunk in stream_response: - id = getattr(chunk, "id", None) or id - model = getattr(chunk, "model", None) or model - created = getattr(chunk, "created", 0) or created - choices = getattr(chunk, "choices", []) - if len(choices) > 0: - delta = getattr(choices[0], "delta", None) - if delta is not None: - text = getattr(delta, "content", None) - if text is not None: - accumulated_content += text or "" - # Return the mock completion object that will be processed by the chat_completion_parser - return ChatCompletion( - id = id, - model=model, - created=created, - object="chat.completion", - choices=[Choice( - index=0, - finish_reason="stop", - message=ChatCompletionMessage(content=accumulated_content, role="assistant") - )] - ) - elif model_type == ModelType.IMAGE_GENERATION: - # Determine which image API to call based on the presence of image/mask - if "image" in api_kwargs: - if "mask" in api_kwargs: - # Image edit - response = self.sync_client.images.edit(**api_kwargs) - else: - # Image variation - response = self.sync_client.images.create_variation(**api_kwargs) - else: - # Image generation - response = self.sync_client.images.generate(**api_kwargs) - return response.data - else: - raise ValueError(f"model_type {model_type} is not supported") - - @backoff.on_exception( - backoff.expo, - ( - APITimeoutError, - InternalServerError, - RateLimitError, - UnprocessableEntityError, - BadRequestError, - ), - max_time=5, - ) - async def acall( - self, api_kwargs: Dict = {}, model_type: ModelType = ModelType.UNDEFINED - ): - """ - kwargs is the combined input and model_kwargs - """ - # store the api kwargs in the client - self._api_kwargs = api_kwargs - if self.async_client is None: - self.async_client = self.init_async_client() - if model_type == ModelType.EMBEDDER: - return await self.async_client.embeddings.create(**api_kwargs) - elif model_type == ModelType.LLM: - return await self.async_client.chat.completions.create(**api_kwargs) - elif model_type == ModelType.IMAGE_GENERATION: - # Determine which image API to call based on the presence of image/mask - if "image" in api_kwargs: - if "mask" in api_kwargs: - # Image edit - response = await self.async_client.images.edit(**api_kwargs) - else: - # Image variation - response = await self.async_client.images.create_variation( - **api_kwargs - ) - else: - # Image generation - response = await self.async_client.images.generate(**api_kwargs) - return response.data - else: - raise ValueError(f"model_type {model_type} is not supported") - - @classmethod - def from_dict(cls: type[T], data: Dict[str, Any]) -> T: - obj = super().from_dict(data) - # recreate the existing clients - obj.sync_client = obj.init_sync_client() - obj.async_client = obj.init_async_client() - return obj - - def to_dict(self) -> Dict[str, Any]: - r"""Convert the component to a dictionary.""" - # TODO: not exclude but save yes or no for recreating the clients - exclude = [ - "sync_client", - "async_client", - ] # unserializable object - output = super().to_dict(exclude=exclude) - return output - - def _encode_image(self, image_path: str) -> str: - """Encode image to base64 string. - - Args: - image_path: Path to image file. - - Returns: - Base64 encoded image string. - - Raises: - ValueError: If the file cannot be read or doesn't exist. - """ - try: - with open(image_path, "rb") as image_file: - return base64.b64encode(image_file.read()).decode("utf-8") - except FileNotFoundError: - raise ValueError(f"Image file not found: {image_path}") - except PermissionError: - raise ValueError(f"Permission denied when reading image file: {image_path}") - except Exception as e: - raise ValueError(f"Error encoding image {image_path}: {str(e)}") - - def _prepare_image_content( - self, image_source: Union[str, Dict[str, Any]], detail: str = "auto" - ) -> Dict[str, Any]: - """Prepare image content for API request. - - Args: - image_source: Either a path to local image or a URL. - detail: Image detail level ('auto', 'low', or 'high'). - - Returns: - Formatted image content for API request. - """ - if isinstance(image_source, str): - if image_source.startswith(("http://", "https://")): - return { - "type": "image_url", - "image_url": {"url": image_source, "detail": detail}, - } - else: - base64_image = self._encode_image(image_source) - return { - "type": "image_url", - "image_url": { - "url": f"data:image/jpeg;base64,{base64_image}", - "detail": detail, - }, - } - return image_source - - -# Example usage: -if __name__ == "__main__": - from adalflow.core import Generator - from adalflow.utils import setup_env - - # log = get_logger(level="DEBUG") - - setup_env() - prompt_kwargs = {"input_str": "What is the meaning of life?"} - - gen = Generator( - model_client=OpenAIClient(), - model_kwargs={"model": "gpt-4o", "stream": False}, - ) - gen_response = gen(prompt_kwargs) - print(f"gen_response: {gen_response}") - - # for genout in gen_response.data: - # print(f"genout: {genout}") - - # test that to_dict and from_dict works - # model_client = OpenAIClient() - # model_client_dict = model_client.to_dict() - # from_dict_model_client = OpenAIClient.from_dict(model_client_dict) - # assert model_client_dict == from_dict_model_client.to_dict() - - -if __name__ == "__main__": - import adalflow as adal - - # setup env or pass the api_key - from adalflow.utils import setup_env - - setup_env() - - openai_llm = adal.Generator( - model_client=OpenAIClient(), model_kwargs={"model": "gpt-4o"} - ) - resopnse = openai_llm(prompt_kwargs={"input_str": "What is LLM?"}) - print(resopnse) From 6bebdaad0f30f0355bba6a44799757826451cf65 Mon Sep 17 00:00:00 2001 From: GdoongMathew Date: Sun, 26 Jul 2026 05:06:13 +0800 Subject: [PATCH 32/50] Replace [DEEP RESEARCH] content tag with explicit `mode` and `research_iteration` fields (#552) * using specific `mode` field to determine `deep_research` mode or normal conversation mode. * add explicit `research_iteration` from the frontend. --- api/chat_model.py | 8 ++++++++ api/simple_chat.py | 23 ++++++----------------- api/websocket_wiki.py | 23 ++++++----------------- src/components/Ask.tsx | 25 +++++++++++++++++++------ src/utils/websocketClient.ts | 2 ++ 5 files changed, 41 insertions(+), 40 deletions(-) diff --git a/api/chat_model.py b/api/chat_model.py index f7cbf2fc4..1d3c17b05 100644 --- a/api/chat_model.py +++ b/api/chat_model.py @@ -7,6 +7,7 @@ class ChatMessage(BaseModel): role: str # 'user' or 'assistant' content: str + mode: Literal["normal", "deep_research"] = Field(default="normal") class ChatCompletionRequest(BaseModel): """ @@ -26,6 +27,13 @@ class ChatCompletionRequest(BaseModel): model: Optional[str] = Field(None, description="Model name for the specified provider") language: Optional[str] = Field("en", description="Language for content generation (e.g., 'en', 'ja', 'zh', 'es', 'kr', 'vi')") + + research_iteration: int = Field( + default=1, + ge=1, + description="Current deep research iteration (1-based). Only used when the request is in deep_research mode.", + ) + excluded_dirs: List[str] = Field( default_factory=list, description="List or newline-separated string of directories to exclude from processing", diff --git a/api/simple_chat.py b/api/simple_chat.py index 7db123ec1..9c4ae413a 100644 --- a/api/simple_chat.py +++ b/api/simple_chat.py @@ -116,22 +116,11 @@ async def chat_completions_stream(request: ChatCompletionRequest): ) # Check if this is a Deep Research request - is_deep_research = False - research_iteration = 1 - - # Process messages to detect Deep Research requests - for msg in request.messages: - if hasattr(msg, 'content') and msg.content and "[DEEP RESEARCH]" in msg.content: - is_deep_research = True - # Only remove the tag from the last message - if msg == request.messages[-1]: - # Remove the Deep Research tag - msg.content = msg.content.replace("[DEEP RESEARCH]", "").strip() + is_deep_research = last_message.mode == "deep_research" # Count research iterations if this is a Deep Research request if is_deep_research: - research_iteration = sum(1 for msg in request.messages if msg.role == 'assistant') + 1 - logger.info(f"Deep Research request detected - iteration {research_iteration}") + logger.info("Deep Research request detected - iteration %d",request.research_iteration) # Check if this is a continuation request if "continue" in last_message.content.lower() and "research" in last_message.content.lower(): @@ -139,7 +128,7 @@ async def chat_completions_stream(request: ChatCompletionRequest): original_topic = None for msg in request.messages: if msg.role == "user" and "continue" not in msg.content.lower(): - original_topic = msg.content.replace("[DEEP RESEARCH]", "").strip() + original_topic = msg.content.strip() logger.info(f"Found original research topic: {original_topic}") break @@ -219,10 +208,10 @@ async def chat_completions_stream(request: ChatCompletionRequest): # Create system prompt if is_deep_research: # Check if this is the first iteration - is_first_iteration = research_iteration == 1 + is_first_iteration = request.research_iteration == 1 # Check if this is the final iteration - is_final_iteration = research_iteration >= 5 + is_final_iteration = request.research_iteration >= 5 if is_first_iteration: system_prompt = DEEP_RESEARCH_FIRST_ITERATION_PROMPT.format( @@ -243,7 +232,7 @@ async def chat_completions_stream(request: ChatCompletionRequest): repo_type=repo_type, repo_url=repo_url, repo_name=repo_name, - research_iteration=research_iteration, + research_iteration=request.research_iteration, language_name=language_name ) else: diff --git a/api/websocket_wiki.py b/api/websocket_wiki.py index 32e37f7d9..d7ddfda2c 100644 --- a/api/websocket_wiki.py +++ b/api/websocket_wiki.py @@ -119,22 +119,11 @@ async def handle_websocket_chat(websocket: WebSocket): ) # Check if this is a Deep Research request - is_deep_research = False - research_iteration = 1 - - # Process messages to detect Deep Research requests - for msg in request.messages: - if hasattr(msg, 'content') and msg.content and "[DEEP RESEARCH]" in msg.content: - is_deep_research = True - # Only remove the tag from the last message - if msg == request.messages[-1]: - # Remove the Deep Research tag - msg.content = msg.content.replace("[DEEP RESEARCH]", "").strip() + is_deep_research = last_message.mode == "deep_research" # Count research iterations if this is a Deep Research request if is_deep_research: - research_iteration = sum(1 for msg in request.messages if msg.role == 'assistant') + 1 - logger.info(f"Deep Research request detected - iteration {research_iteration}") + logger.info("Deep Research request detected - iteration %d", request.research_iteration) # Check if this is a continuation request if "continue" in last_message.content.lower() and "research" in last_message.content.lower(): @@ -142,7 +131,7 @@ async def handle_websocket_chat(websocket: WebSocket): original_topic = None for msg in request.messages: if msg.role == "user" and "continue" not in msg.content.lower(): - original_topic = msg.content.replace("[DEEP RESEARCH]", "").strip() + original_topic = msg.content.strip() logger.info(f"Found original research topic: {original_topic}") break @@ -222,10 +211,10 @@ async def handle_websocket_chat(websocket: WebSocket): # Create system prompt if is_deep_research: # Check if this is the first iteration - is_first_iteration = research_iteration == 1 + is_first_iteration = request.research_iteration == 1 # Check if this is the final iteration - is_final_iteration = research_iteration >= 5 + is_final_iteration = request.research_iteration >= 5 if is_first_iteration: system_prompt = DEEP_RESEARCH_FIRST_ITERATION_PROMPT.format( @@ -246,7 +235,7 @@ async def handle_websocket_chat(websocket: WebSocket): repo_type=repo_type, repo_url=repo_url, repo_name=repo_name, - research_iteration=research_iteration, + research_iteration=request.research_iteration, language_name=language_name ) else: diff --git a/src/components/Ask.tsx b/src/components/Ask.tsx index 056afcd23..e91f0b066 100644 --- a/src/components/Ask.tsx +++ b/src/components/Ask.tsx @@ -24,6 +24,7 @@ interface Provider { interface Message { role: 'user' | 'assistant' | 'system'; content: string; + mode?: 'normal' | 'deep_research'; } interface ResearchStage { @@ -299,7 +300,8 @@ const Ask: React.FC = ({ }, { role: 'user', - content: '[DEEP RESEARCH] Continue the research' + content: 'Continue the research', + mode: 'deep_research' } ]; @@ -317,10 +319,15 @@ const Ask: React.FC = ({ const requestBody: ChatCompletionRequest = { repo_url: getRepoUrl(repoInfo), type: repoInfo.type, - messages: newHistory.map(msg => ({ role: msg.role as 'user' | 'assistant', content: msg.content })), + messages: newHistory.map(msg => ({ + role: msg.role as 'user' | 'assistant', + content: msg.content, + mode: msg.mode ?? 'normal', + })), provider: selectedProvider, model: isCustomSelectedModel ? customSelectedModel : selectedModel, - language: language + language: language, + research_iteration: newIteration }; // Add tokens if available @@ -548,7 +555,8 @@ const Ask: React.FC = ({ // Create initial message const initialMessage: Message = { role: 'user', - content: deepResearch ? `[DEEP RESEARCH] ${question}` : question + content: question, + mode: deepResearch ? 'deep_research' : 'normal' }; // Set initial conversation history @@ -559,10 +567,15 @@ const Ask: React.FC = ({ const requestBody: ChatCompletionRequest = { repo_url: getRepoUrl(repoInfo), type: repoInfo.type, - messages: newHistory.map(msg => ({ role: msg.role as 'user' | 'assistant', content: msg.content })), + messages: newHistory.map(msg => ({ + role: msg.role as 'user' | 'assistant', + content: msg.content, + mode: msg.mode ?? 'normal' + })), provider: selectedProvider, model: isCustomSelectedModel ? customSelectedModel : selectedModel, - language: language + language: language, + research_iteration: deepResearch ? 1 : undefined }; // Add tokens if available diff --git a/src/utils/websocketClient.ts b/src/utils/websocketClient.ts index b29c1b104..f3b22a8a1 100644 --- a/src/utils/websocketClient.ts +++ b/src/utils/websocketClient.ts @@ -17,6 +17,7 @@ const getWebSocketUrl = () => { export interface ChatMessage { role: 'user' | 'assistant' | 'system'; content: string; + mode?: 'normal' | 'deep_research'; } export interface ChatCompletionRequest { @@ -28,6 +29,7 @@ export interface ChatCompletionRequest { provider?: string; model?: string; language?: string; + research_iteration?: number; excluded_dirs?: string; excluded_files?: string; } From 6b4cdea70c84480b0981a12878da94d10d45e2c2 Mon Sep 17 00:00:00 2001 From: GdoongMathew Date: Sun, 26 Jul 2026 05:06:36 +0800 Subject: [PATCH 33/50] deps: update requests version to fix CVE. (fix #537) (#553) --- api/poetry.lock | 74 +++++++++++++++++++++++----------------------- api/pyproject.toml | 2 +- 2 files changed, 38 insertions(+), 38 deletions(-) diff --git a/api/poetry.lock b/api/poetry.lock index a2446bba9..afc9b1de9 100644 --- a/api/poetry.lock +++ b/api/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.0.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.4.1 and should not be changed by hand. [[package]] name = "adalflow" @@ -37,7 +37,7 @@ faiss-cpu = ["faiss-cpu (>=1.8.0)"] google-generativeai = ["google-generativeai (>=0.7.2)"] groq = ["groq (>=0.9.0)"] lancedb = ["lancedb (>=0.5.2)"] -mcp = ["mcp (>=1.9.4,<2.0.0)"] +mcp = ["mcp (>=1.9.4,<2.0.0) ; python_version >= \"3.10\""] ollama = ["ollama (>=0.2.1)"] openai = ["openai (>=1.97.1)"] pgvector = ["pgvector (>=0.3.1)"] @@ -197,7 +197,7 @@ propcache = ">=0.2.0" yarl = ">=1.17.0,<2.0" [package.extras] -speedups = ["Brotli", "aiodns (>=3.3.0)", "backports.zstd", "brotlicffi"] +speedups = ["Brotli ; platform_python_implementation == \"CPython\"", "aiodns (>=3.3.0)", "backports.zstd ; platform_python_implementation == \"CPython\" and python_version < \"3.14\"", "brotlicffi ; platform_python_implementation != \"CPython\""] [[package]] name = "aiosignal" @@ -261,8 +261,8 @@ files = [ [package.extras] doc = ["sphinx", "sphinxcontrib-trio"] -test = ["black", "coverage", "flake8", "flake8-2020", "flake8-bugbear", "mypy", "pytest", "pytest-cov"] -typetest = ["mypy", "pyright", "typing-extensions"] +test = ["black ; implementation_name == \"cpython\"", "coverage", "flake8", "flake8-2020", "flake8-bugbear", "mypy ; implementation_name == \"cpython\"", "pytest", "pytest-cov"] +typetest = ["mypy ; implementation_name == \"cpython\"", "pyright", "typing-extensions"] [[package]] name = "attrs" @@ -705,7 +705,7 @@ files = [ ] [package.dependencies] -cffi = {version = ">=2.0.0", markers = "python_full_version >= \"3.9\" and platform_python_implementation != \"PyPy\""} +cffi = {version = ">=2.0.0", markers = "python_full_version >= \"3.9.0\" and platform_python_implementation != \"PyPy\""} [package.extras] docs = ["sphinx (>=5.3.0)", "sphinx-inline-tabs", "sphinx-rtd-theme (>=3.0.0)"] @@ -968,13 +968,13 @@ files = [ ] [package.dependencies] -google-api-core = {version = ">=1.34.1,<2.0.dev0 || >=2.11.dev0,<3.0.0dev", extras = ["grpc"]} -google-auth = ">=2.14.1,<2.24.0 || >2.24.0,<2.25.0 || >2.25.0,<3.0.0dev" +google-api-core = {version = ">=1.34.1,<2.0.dev0 || >=2.11.dev0,<3.0.0.dev0", extras = ["grpc"]} +google-auth = ">=2.14.1,<2.24.0 || >2.24.0,<2.25.0 || >2.25.0,<3.0.0.dev0" proto-plus = [ - {version = ">=1.25.0,<2.0.0dev", markers = "python_version >= \"3.13\""}, - {version = ">=1.22.3,<2.0.0dev", markers = "python_version < \"3.13\""}, + {version = ">=1.22.3,<2.0.0.dev0"}, + {version = ">=1.25.0,<2.0.0.dev0", markers = "python_version >= \"3.13\""}, ] -protobuf = ">=3.20.2,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<6.0.0dev" +protobuf = ">=3.20.2,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<6.0.0.dev0" [[package]] name = "google-api-core" @@ -1000,7 +1000,7 @@ requests = ">=2.18.0,<3.0.0" [package.extras] async-rest = ["google-auth[aiohttp] (>=2.35.0,<3.0.0)"] -grpc = ["grpcio (>=1.33.2,<2.0.0)", "grpcio (>=1.49.1,<2.0.0)", "grpcio-status (>=1.33.2,<2.0.0)", "grpcio-status (>=1.49.1,<2.0.0)"] +grpc = ["grpcio (>=1.33.2,<2.0.0)", "grpcio (>=1.49.1,<2.0.0) ; python_version >= \"3.11\"", "grpcio-status (>=1.33.2,<2.0.0)", "grpcio-status (>=1.49.1,<2.0.0) ; python_version >= \"3.11\""] grpcgcp = ["grpcio-gcp (>=0.2.2,<1.0.0)"] grpcio-gcp = ["grpcio-gcp (>=0.2.2,<1.0.0)"] @@ -1011,7 +1011,7 @@ description = "Google API client core library" optional = false python-versions = ">=3.7" groups = ["main"] -markers = "python_version < \"3.14\"" +markers = "python_version <= \"3.13\"" files = [ {file = "google_api_core-2.26.0-py3-none-any.whl", hash = "sha256:2b204bd0da2c81f918e3582c48458e24c11771f987f6258e6e227212af78f3ed"}, {file = "google_api_core-2.26.0.tar.gz", hash = "sha256:e6e6d78bd6cf757f4aee41dcc85b07f485fbb069d5daa3afb126defba1e91a62"}, @@ -1021,17 +1021,17 @@ files = [ google-auth = ">=2.14.1,<3.0.0" googleapis-common-protos = ">=1.56.2,<2.0.0" grpcio = {version = ">=1.49.1,<2.0.0", optional = true, markers = "python_version >= \"3.11\" and extra == \"grpc\" and python_version < \"3.14\""} -grpcio-status = {version = ">=1.49.1,<2.0.0", optional = true, markers = "python_version >= \"3.11\" and extra == \"grpc\" and python_version < \"3.14\""} +grpcio-status = {version = ">=1.49.1,<2.0.0", optional = true, markers = "python_version >= \"3.11\" and extra == \"grpc\""} proto-plus = [ - {version = ">=1.25.0,<2.0.0", markers = "python_version >= \"3.13\""}, {version = ">=1.22.3,<2.0.0", markers = "python_version < \"3.13\""}, + {version = ">=1.25.0,<2.0.0", markers = "python_version >= \"3.13\""}, ] protobuf = ">=3.19.5,<3.20.0 || >3.20.0,<3.20.1 || >3.20.1,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<7.0.0" requests = ">=2.18.0,<3.0.0" [package.extras] async-rest = ["google-auth[aiohttp] (>=2.35.0,<3.0.0)"] -grpc = ["grpcio (>=1.33.2,<2.0.0)", "grpcio (>=1.49.1,<2.0.0)", "grpcio (>=1.75.1,<2.0.0)", "grpcio-status (>=1.33.2,<2.0.0)", "grpcio-status (>=1.49.1,<2.0.0)", "grpcio-status (>=1.75.1,<2.0.0)"] +grpc = ["grpcio (>=1.33.2,<2.0.0)", "grpcio (>=1.49.1,<2.0.0) ; python_version >= \"3.11\"", "grpcio (>=1.75.1,<2.0.0) ; python_version >= \"3.14\"", "grpcio-status (>=1.33.2,<2.0.0)", "grpcio-status (>=1.49.1,<2.0.0) ; python_version >= \"3.11\"", "grpcio-status (>=1.75.1,<2.0.0) ; python_version >= \"3.14\""] grpcgcp = ["grpcio-gcp (>=0.2.2,<1.0.0)"] grpcio-gcp = ["grpcio-gcp (>=0.2.2,<1.0.0)"] @@ -1074,11 +1074,11 @@ rsa = ">=3.1.4,<5" [package.extras] aiohttp = ["aiohttp (>=3.6.2,<4.0.0)", "requests (>=2.20.0,<3.0.0)"] enterprise-cert = ["cryptography", "pyopenssl"] -pyjwt = ["cryptography (<39.0.0)", "cryptography (>=38.0.3)", "pyjwt (>=2.0)"] -pyopenssl = ["cryptography (<39.0.0)", "cryptography (>=38.0.3)", "pyopenssl (>=20.0.0)"] +pyjwt = ["cryptography (<39.0.0) ; python_version < \"3.8\"", "cryptography (>=38.0.3)", "pyjwt (>=2.0)"] +pyopenssl = ["cryptography (<39.0.0) ; python_version < \"3.8\"", "cryptography (>=38.0.3)", "pyopenssl (>=20.0.0)"] reauth = ["pyu2f (>=0.1.5)"] requests = ["requests (>=2.20.0,<3.0.0)"] -testing = ["aiohttp (<3.10.0)", "aiohttp (>=3.6.2,<4.0.0)", "aioresponses", "cryptography (<39.0.0)", "cryptography (<39.0.0)", "cryptography (>=38.0.3)", "cryptography (>=38.0.3)", "flask", "freezegun", "grpcio", "mock", "oauth2client", "packaging", "pyjwt (>=2.0)", "pyopenssl (<24.3.0)", "pyopenssl (>=20.0.0)", "pytest", "pytest-asyncio", "pytest-cov", "pytest-localserver", "pyu2f (>=0.1.5)", "requests (>=2.20.0,<3.0.0)", "responses", "urllib3"] +testing = ["aiohttp (<3.10.0)", "aiohttp (>=3.6.2,<4.0.0)", "aioresponses", "cryptography (<39.0.0) ; python_version < \"3.8\"", "cryptography (<39.0.0) ; python_version < \"3.8\"", "cryptography (>=38.0.3)", "cryptography (>=38.0.3)", "flask", "freezegun", "grpcio", "mock", "oauth2client", "packaging", "pyjwt (>=2.0)", "pyopenssl (<24.3.0)", "pyopenssl (>=20.0.0)", "pytest", "pytest-asyncio", "pytest-cov", "pytest-localserver", "pyu2f (>=0.1.5)", "requests (>=2.20.0,<3.0.0)", "responses", "urllib3"] urllib3 = ["packaging", "urllib3"] [[package]] @@ -1231,7 +1231,7 @@ files = [ [package.dependencies] googleapis-common-protos = ">=1.5.5" grpcio = ">=1.71.2" -protobuf = ">=5.26.1,<6.0dev" +protobuf = ">=5.26.1,<6.0.dev0" [[package]] name = "h11" @@ -1354,7 +1354,7 @@ httpcore = "==1.*" idna = "*" [package.extras] -brotli = ["brotli", "brotlicffi"] +brotli = ["brotli ; platform_python_implementation == \"CPython\"", "brotlicffi ; platform_python_implementation != \"CPython\""] cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] http2 = ["h2 (>=3,<5)"] socks = ["socksio (==1.*)"] @@ -1675,7 +1675,7 @@ PyJWT = {version = ">=1.0.0,<3", extras = ["crypto"]} requests = ">=2.0.0,<3" [package.extras] -broker = ["pymsalruntime (>=0.14,<0.19)", "pymsalruntime (>=0.17,<0.19)", "pymsalruntime (>=0.18,<0.19)"] +broker = ["pymsalruntime (>=0.14,<0.19) ; python_version >= \"3.6\" and platform_system == \"Windows\"", "pymsalruntime (>=0.17,<0.19) ; python_version >= \"3.8\" and platform_system == \"Darwin\"", "pymsalruntime (>=0.18,<0.19) ; python_version >= \"3.8\" and platform_system == \"Linux\""] [[package]] name = "msal-extensions" @@ -2250,7 +2250,7 @@ typing-inspection = ">=0.4.2" [package.extras] email = ["email-validator (>=2.0.0)"] -timezone = ["tzdata"] +timezone = ["tzdata ; python_version >= \"3.9\" and platform_system == \"Windows\""] [[package]] name = "pydantic-core" @@ -2715,25 +2715,25 @@ files = [ [[package]] name = "requests" -version = "2.32.5" +version = "2.34.2" description = "Python HTTP for Humans." optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["main"] files = [ - {file = "requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6"}, - {file = "requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf"}, + {file = "requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0"}, + {file = "requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed"}, ] [package.dependencies] -certifi = ">=2017.4.17" +certifi = ">=2023.5.7" charset_normalizer = ">=2,<4" idna = ">=2.5,<4" -urllib3 = ">=1.21.1,<3" +urllib3 = ">=1.26,<3" [package.extras] socks = ["PySocks (>=1.5.6,!=1.5.7)"] -use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] +use-chardet-on-py3 = ["chardet (>=3.0.2,<8)"] [[package]] name = "rsa" @@ -2763,10 +2763,10 @@ files = [ ] [package.dependencies] -botocore = ">=1.37.4,<2.0a.0" +botocore = ">=1.37.4,<2.0a0" [package.extras] -crt = ["botocore[crt] (>=1.37.4,<2.0a.0)"] +crt = ["botocore[crt] (>=1.37.4,<2.0a0)"] [[package]] name = "six" @@ -2959,7 +2959,7 @@ files = [ ] [package.extras] -brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] +brotli = ["brotli (>=1.0.9) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; platform_python_implementation != \"CPython\""] h2 = ["h2 (>=4,<5)"] socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] zstd = ["zstandard (>=0.18.0)"] @@ -2983,12 +2983,12 @@ h11 = ">=0.8" httptools = {version = ">=0.6.3", optional = true, markers = "extra == \"standard\""} python-dotenv = {version = ">=0.13", optional = true, markers = "extra == \"standard\""} pyyaml = {version = ">=5.1", optional = true, markers = "extra == \"standard\""} -uvloop = {version = ">=0.15.1", optional = true, markers = "(sys_platform != \"win32\" and sys_platform != \"cygwin\") and platform_python_implementation != \"PyPy\" and extra == \"standard\""} +uvloop = {version = ">=0.15.1", optional = true, markers = "sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\" and extra == \"standard\""} watchfiles = {version = ">=0.13", optional = true, markers = "extra == \"standard\""} websockets = {version = ">=10.4", optional = true, markers = "extra == \"standard\""} [package.extras] -standard = ["colorama (>=0.4)", "httptools (>=0.6.3)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.15.1)", "watchfiles (>=0.13)", "websockets (>=10.4)"] +standard = ["colorama (>=0.4) ; sys_platform == \"win32\"", "httptools (>=0.6.3)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.15.1) ; sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\"", "watchfiles (>=0.13)", "websockets (>=10.4)"] [[package]] name = "uvloop" @@ -2997,7 +2997,7 @@ description = "Fast implementation of asyncio event loop on top of libuv" optional = false python-versions = ">=3.8.1" groups = ["main"] -markers = "(sys_platform != \"win32\" and sys_platform != \"cygwin\") and platform_python_implementation != \"PyPy\"" +markers = "sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\"" files = [ {file = "uvloop-0.22.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ef6f0d4cc8a9fa1f6a910230cd53545d9a14479311e87e3cb225495952eb672c"}, {file = "uvloop-0.22.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7cd375a12b71d33d46af85a3343b35d98e8116134ba404bd657b3b1d15988792"}, @@ -3404,4 +3404,4 @@ propcache = ">=0.2.1" [metadata] lock-version = "2.1" python-versions = "^3.11" -content-hash = "b558e94d5d8bdcc4273f47c52c8bfa6f4e003df0cf754f56340b8b98283d4a8d" +content-hash = "6ba5b45f26d528b886a8c74acebcdc4241bb7ff19474c040244f446bb6d9e784" diff --git a/api/pyproject.toml b/api/pyproject.toml index 09760f8b1..1dcdef7dc 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -18,7 +18,7 @@ adalflow = ">=0.1.0" numpy = ">=1.24.0" faiss-cpu = ">=1.7.4" langid = ">=1.1.6" -requests = ">=2.28.0" +requests = "^2.34.2" jinja2 = ">=3.1.2" python-dotenv = ">=1.0.0" openai = ">=1.76.2" From 95f78b523469d7e0d7b5bfd92bc9d5d80176df52 Mon Sep 17 00:00:00 2001 From: GdoongMathew Date: Sun, 26 Jul 2026 05:06:53 +0800 Subject: [PATCH 34/50] fix callers of `count_tokens`, pass provider string directly (#554) --- api/simple_chat.py | 2 +- api/websocket_wiki.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/api/simple_chat.py b/api/simple_chat.py index 9c4ae413a..0eb4cf661 100644 --- a/api/simple_chat.py +++ b/api/simple_chat.py @@ -50,7 +50,7 @@ async def chat_completions_stream(request: ChatCompletionRequest): if request.messages and len(request.messages) > 0: last_message = request.messages[-1] if hasattr(last_message, 'content') and last_message.content: - tokens = count_tokens(last_message.content, request.provider == "ollama") + tokens = count_tokens(last_message.content, embedder_type=request.provider) logger.info(f"Request size: {tokens} tokens") if tokens > MAX_INPUT_TOKENS: logger.warning(f"Request exceeds recommended token limit ({tokens} > {MAX_INPUT_TOKENS})") diff --git a/api/websocket_wiki.py b/api/websocket_wiki.py index d7ddfda2c..adf351397 100644 --- a/api/websocket_wiki.py +++ b/api/websocket_wiki.py @@ -43,7 +43,7 @@ async def handle_websocket_chat(websocket: WebSocket): if request.messages and len(request.messages) > 0: last_message = request.messages[-1] if hasattr(last_message, 'content') and last_message.content: - tokens = count_tokens(last_message.content, request.provider == "ollama") + tokens = count_tokens(last_message.content, embedder_type=request.provider) logger.info(f"Request size: {tokens} tokens") if tokens > MAX_INPUT_TOKENS: logger.warning(f"Request exceeds recommended token limit ({tokens} > {MAX_INPUT_TOKENS})") From bd89a133ddb017ffdaf00d5eba32f4e888216351 Mon Sep 17 00:00:00 2001 From: GdoongMathew Date: Sun, 26 Jul 2026 05:07:18 +0800 Subject: [PATCH 35/50] cleaning up prompt generation code in websocket_wiki. (#555) --- api/websocket_wiki.py | 23 ----------------------- 1 file changed, 23 deletions(-) diff --git a/api/websocket_wiki.py b/api/websocket_wiki.py index adf351397..3921ed7e0 100644 --- a/api/websocket_wiki.py +++ b/api/websocket_wiki.py @@ -262,29 +262,6 @@ async def handle_websocket_chat(websocket: WebSocket): if not isinstance(turn_id, int) and hasattr(turn, 'user_query') and hasattr(turn, 'assistant_response'): conversation_history += f"\n{turn.user_query.query_str}\n{turn.assistant_response.response_str}\n\n" - # Create the prompt with context - prompt = f"/no_think {system_prompt}\n\n" - - if conversation_history: - prompt += f"\n{conversation_history}\n\n" - - # Check if filePath is provided and fetch file content if it exists - if file_content: - # Add file content to the prompt after conversation history - prompt += f"\n{file_content}\n\n\n" - - # Only include context if it's not empty - CONTEXT_START = "" - CONTEXT_END = "" - if context_text.strip(): - prompt += f"{CONTEXT_START}\n{context_text}\n{CONTEXT_END}\n\n" - else: - # Add a note that we're skipping RAG due to size constraints or because it's the isolated API - logger.info("No context available from RAG") - prompt += "Answering without retrieval augmentation.\n\n" - - prompt += f"\n{query}\n\n\nAssistant: " - async def stream_and_fallback( streamer: ChatStreamer, prompt_func: Callable[[], str], From 15a21eaa52d7b95b05c512baca907ae5d33f8364 Mon Sep 17 00:00:00 2001 From: GdoongMathew Date: Sun, 26 Jul 2026 05:07:34 +0800 Subject: [PATCH 36/50] Fix: add back default `local` repo type in `ChatCompletionRequest` (#556) * add back default `local` repo type * Apply suggestion from @GdoongMathew --- api/chat_model.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api/chat_model.py b/api/chat_model.py index 1d3c17b05..4af8a03d8 100644 --- a/api/chat_model.py +++ b/api/chat_model.py @@ -17,9 +17,9 @@ class ChatCompletionRequest(BaseModel): messages: List[ChatMessage] = Field(..., description="List of chat messages") filePath: Optional[str] = Field(None, description="Optional path to a file in the repository to include in the prompt") token: Optional[str] = Field(None, description="Personal access token for private repositories") - type: Optional[Literal["github", "gitlab", "bitbucket"]] = Field( + type: Optional[Literal["local", "github", "gitlab", "bitbucket"]] = Field( "github", - description="Type of repository (e.g., 'github', 'gitlab', 'bitbucket')", + description="Type of repository (e.g., 'local', 'github', 'gitlab', 'bitbucket')", ) # model parameters From 000f36f7f6b5cb5c965536c42983af776e472bce Mon Sep 17 00:00:00 2001 From: GdoongMathew Date: Sun, 26 Jul 2026 05:07:48 +0800 Subject: [PATCH 37/50] Make RAG preparation and retrieval non-blocking (#557) * refactor rag, `simple_chat` and `websocket_wiki` to perform non-blocking rag retrieval * lazy init asyncio Semaphore instance * change assert type --- api/rag.py | 61 +++++++++++++++++++++++++++++++++++++++++-- api/simple_chat.py | 20 +++++++++++--- api/websocket_wiki.py | 19 +++++++++++--- 3 files changed, 90 insertions(+), 10 deletions(-) diff --git a/api/rag.py b/api/rag.py index 3c0352fbe..9a440697e 100644 --- a/api/rag.py +++ b/api/rag.py @@ -1,6 +1,6 @@ import logging -import weakref -import re +import asyncio +import os from dataclasses import dataclass from typing import Any, List, Tuple, Dict from uuid import uuid4 @@ -48,6 +48,20 @@ def append_dialog_turn(self, dialog_turn): # Maximum token limit for embedding models MAX_INPUT_TOKENS = 7500 # Safe threshold below 8192 token limit +# Maximum concurrent RAG preparing count +_RAG_PREPARE_SEMAPHORE: asyncio.Semaphore | None = None + + +def _get_rag_semaphore() -> asyncio.Semaphore: + global _RAG_PREPARE_SEMAPHORE + if _RAG_PREPARE_SEMAPHORE is None: + _RAG_PREPARE_SEMAPHORE = asyncio.Semaphore( + int(os.environ.get("DEEPWIKI_MAX_CONCURRENT_RAG", "4")) + ) + assert isinstance(_RAG_PREPARE_SEMAPHORE, asyncio.Semaphore) + return _RAG_PREPARE_SEMAPHORE + + class Memory(adal.core.component.DataComponent): """Simple conversation management with a list of dialog turns.""" @@ -396,6 +410,44 @@ def prepare_retriever(self, repo_url_or_path: str, type: str = "github", access_ logger.error(f"Sample embedding sizes: {', '.join(sizes)}") raise + async def aprepare_retriever( + self, + repo_url_or_path: str, + type: str = "github", + access_token: str | None = None, + excluded_dirs: list[str] | None = None, + excluded_files: list[str] | None = None, + included_dirs: list[str] | None = None, + included_files: list[str] | None = None, + ): + """Async version of the original `prepare_retriever`. + + Reuse the synchronous `prepare_retriever` implementation, but runs it in + a worker thread via `asyncio.to_thread` so that blocking operations (such + as git.clone, file io, embedding calls) do not stall the outer event loop. + Concurrency is bounded by a module-level semaphore, set by system variable + 'DEEPWIKI_MAX_CONCURRENT_RAG'. + + Args: + repo_url_or_path: URL or local path to the repository + access_token: Optional access token for private repositories + excluded_dirs: Optional list of directories to exclude from processing + excluded_files: Optional list of file patterns to exclude from processing + included_dirs: Optional list of directories to include exclusively + included_files: Optional list of file patterns to include exclusively + """ + async with _get_rag_semaphore(): + return await asyncio.to_thread( + self.prepare_retriever, + repo_url_or_path, + type=type, + access_token=access_token, + excluded_dirs=excluded_dirs, + excluded_files=excluded_files, + included_dirs=included_dirs, + included_files=included_files, + ) + def call(self, query: str, language: str = "en") -> Tuple[List]: """ Process a query using RAG. @@ -426,3 +478,8 @@ def call(self, query: str, language: str = "en") -> Tuple[List]: answer=f"I apologize, but I encountered an error while processing your question. Please try again or rephrase your question." ) return error_response, [] + + async def acall(self, query: str, language: str = "en") -> tuple[list]: + """Async version of the original `call` method. + """ + return await asyncio.to_thread(self.call, query, language) diff --git a/api/simple_chat.py b/api/simple_chat.py index 0eb4cf661..a6e7e8aae 100644 --- a/api/simple_chat.py +++ b/api/simple_chat.py @@ -1,3 +1,4 @@ +import asyncio import logging from typing import Callable from functools import partial @@ -58,7 +59,12 @@ async def chat_completions_stream(request: ChatCompletionRequest): # Create a new RAG instance for this request try: - request_rag = RAG(provider=request.provider, model=request.model) + # async do RAG initialization + request_rag = await asyncio.to_thread( + RAG, + provider=request.provider, + model=request.model, + ) # Extract custom file filter parameters if provided if request.excluded_dirs: @@ -70,7 +76,7 @@ async def chat_completions_stream(request: ChatCompletionRequest): if request.included_files: logger.info(f"Using custom included files: {request.included_files}") - request_rag.prepare_retriever( + await request_rag.aprepare_retriever( request.repo_url, request.type, request.token, @@ -156,7 +162,7 @@ async def chat_completions_stream(request: ChatCompletionRequest): # Try to perform RAG retrieval try: # This will use the actual RAG implementation - retrieved_documents = request_rag(rag_query, language=request.language) + retrieved_documents = await request_rag.acall(rag_query, language=request.language) if retrieved_documents and retrieved_documents[0].documents: # Format context for the prompt in a more structured way @@ -247,7 +253,13 @@ async def chat_completions_stream(request: ChatCompletionRequest): file_content = "" if request.filePath: try: - file_content = get_file_content(request.repo_url, request.filePath, request.type, request.token) + file_content = await asyncio.to_thread( + get_file_content, + repo_url=request.repo_url, + file_path=request.filePath, + repo_type=request.type, + access_token=request.token, + ) logger.info(f"Successfully retrieved content for file: {request.filePath}") except Exception as e: logger.error(f"Error retrieving file content: {str(e)}") diff --git a/api/websocket_wiki.py b/api/websocket_wiki.py index 3921ed7e0..8006dee80 100644 --- a/api/websocket_wiki.py +++ b/api/websocket_wiki.py @@ -1,3 +1,4 @@ +import asyncio import logging from collections.abc import AsyncIterator, Callable from functools import partial @@ -51,7 +52,11 @@ async def handle_websocket_chat(websocket: WebSocket): # Create a new RAG instance for this request try: - request_rag = RAG(provider=request.provider, model=request.model) + request_rag = await asyncio.to_thread( + RAG, + provider=request.provider, + model=request.model, + ) # Extract custom file filter parameters if provided if request.excluded_dirs: @@ -63,7 +68,7 @@ async def handle_websocket_chat(websocket: WebSocket): if request.included_files: logger.info(f"Using custom included files: {request.included_files}") - request_rag.prepare_retriever( + await request_rag.aprepare_retriever( request.repo_url, request.type, request.token, @@ -159,7 +164,7 @@ async def handle_websocket_chat(websocket: WebSocket): # Try to perform RAG retrieval try: # This will use the actual RAG implementation - retrieved_documents = request_rag(rag_query, language=request.language) + retrieved_documents = await request_rag.acall(rag_query, language=request.language) if retrieved_documents and retrieved_documents[0].documents: # Format context for the prompt in a more structured way @@ -250,7 +255,13 @@ async def handle_websocket_chat(websocket: WebSocket): file_content = "" if request.filePath: try: - file_content = get_file_content(request.repo_url, request.filePath, request.type, request.token) + file_content = await asyncio.to_thread( + get_file_content, + repo_url=request.repo_url, + file_path=request.filePath, + repo_type=request.type, + access_token=request.token, + ) logger.info(f"Successfully retrieved content for file: {request.filePath}") except Exception as e: logger.error(f"Error retrieving file content: {str(e)}") From 535dc16653c2adeba1f08ff67a08a3e45bfd6c05 Mon Sep 17 00:00:00 2001 From: GdoongMathew Date: Sun, 26 Jul 2026 05:08:08 +0800 Subject: [PATCH 38/50] remove unused `generator` instance from RAG class (#558) --- api/rag.py | 36 ------------------------------------ 1 file changed, 36 deletions(-) diff --git a/api/rag.py b/api/rag.py index 9a440697e..157ed3460 100644 --- a/api/rag.py +++ b/api/rag.py @@ -205,42 +205,6 @@ def __init__(self, provider="google", model=None, use_s3: bool = False): # noqa self.embedder = get_embedder(embedder_type=self.embedder_type) self.initialize_db_manager() - # Set up the output parser - data_parser = adal.DataClassParser(data_class=RAGAnswer, return_data_class=True) - - # Format instructions to ensure proper output structure - format_instructions = data_parser.get_output_format_str() + """ - -IMPORTANT FORMATTING RULES: -1. DO NOT include your thinking or reasoning process in the output -2. Provide only the final, polished answer -3. DO NOT include ```markdown fences at the beginning or end of your answer -4. DO NOT wrap your response in any kind of fences -5. Start your response directly with the content -6. The content will already be rendered as markdown -7. Do not use backslashes before special characters like [ ] { } in your answer -8. When listing tags or similar items, write them as plain text without escape characters -9. For pipe characters (|) in text, write them directly without escaping them""" - - # Get model configuration based on provider and model - from api.config import get_model_config - generator_config = get_model_config(self.provider, self.model) - - # Set up the main generator - self.generator = adal.Generator( - template=RAG_TEMPLATE, - prompt_kwargs={ - "output_format_str": format_instructions, - "conversation_history": self.memory(), - "system_prompt": system_prompt, - "contexts": None, - }, - model_client=generator_config["model_client"](), - model_kwargs=generator_config["model_kwargs"], - output_processors=data_parser, - ) - - def initialize_db_manager(self): """Initialize the database manager with local storage""" self.db_manager = DatabaseManager() From 6023db8538aa00aeca4f1bdd0a35da252a62b3c5 Mon Sep 17 00:00:00 2001 From: GdoongMathew Date: Sun, 26 Jul 2026 05:08:22 +0800 Subject: [PATCH 39/50] refactor: remove duplicated document processing. (#559) --- api/data_pipeline.py | 118 +++++++++++++++++-------------------------- 1 file changed, 47 insertions(+), 71 deletions(-) diff --git a/api/data_pipeline.py b/api/data_pipeline.py index 9958f9262..16d15a84d 100644 --- a/api/data_pipeline.py +++ b/api/data_pipeline.py @@ -8,6 +8,7 @@ import logging import base64 import glob +from pathlib import Path from adalflow.utils import get_adalflow_default_root_path from adalflow.core.db import LocalDB from api.config import configs, DEFAULT_EXCLUDED_DIRS, DEFAULT_EXCLUDED_FILES @@ -191,20 +192,18 @@ def read_all_documents(path: str, embedder_type: str = None, is_ollama_embedder: doc_extensions = [".md", ".txt", ".rst", ".json", ".yaml", ".yml"] # Determine filtering mode: inclusion or exclusion - use_inclusion_mode = (included_dirs is not None and len(included_dirs) > 0) or (included_files is not None and len(included_files) > 0) + use_inclusion_mode = bool(included_dirs or included_files) if use_inclusion_mode: # Inclusion mode: only process specified directories and files - final_included_dirs = set(included_dirs) if included_dirs else set() - final_included_files = set(included_files) if included_files else set() + included_dirs = list(set(included_dirs)) if included_dirs else list() + included_files = list(set(included_files)) if included_files else list() logger.info(f"Using inclusion mode") - logger.info(f"Included directories: {list(final_included_dirs)}") - logger.info(f"Included files: {list(final_included_files)}") + logger.info(f"Included directories: {included_dirs}") + logger.info(f"Included files: {included_files}") # Convert to lists for processing - included_dirs = list(final_included_dirs) - included_files = list(final_included_files) excluded_dirs = [] excluded_files = [] else: @@ -308,78 +307,55 @@ def should_process_file(file_path: str, use_inclusion: bool, included_dirs: List return not is_excluded - # Process code files first - for ext in code_extensions: - files = glob.glob(f"{path}/**/*{ext}", recursive=True) - for file_path in files: - # Check if file should be processed based on inclusion/exclusion rules - if not should_process_file(file_path, use_inclusion_mode, included_dirs, included_files, excluded_dirs, excluded_files): - continue - + for file_path in filter( + lambda p: should_process_file( + p, + use_inclusion=use_inclusion_mode, + included_dirs=included_dirs, + included_files=included_files, + excluded_dirs=excluded_dirs, + excluded_files=excluded_files, + ), + filter( + lambda p: p.suffix.lower() in code_extensions + doc_extensions, + Path(path).rglob(pattern="**/*"), + ), + ): try: with open(file_path, "r", encoding="utf-8") as f: content = f.read() - relative_path = os.path.relpath(file_path, path) - - # Determine if this is an implementation file + relative_path = os.path.relpath(file_path, path) + + # Check token count + token_count = count_tokens(content, embedder_type) + if token_count > MAX_EMBEDDING_TOKENS * 10: + logger.warning(f"Skipping large file {relative_path}: Token count ({token_count}) exceeds limit") + continue + + file_ext = file_path.suffix.lower() + is_code = file_ext in code_extensions + # Determine if this is an implementation file + if is_code: is_implementation = ( not relative_path.startswith("test_") and not relative_path.startswith("app_") and "test" not in relative_path.lower() ) - - # Check token count - token_count = count_tokens(content, embedder_type) - if token_count > MAX_EMBEDDING_TOKENS * 10: - logger.warning(f"Skipping large file {relative_path}: Token count ({token_count}) exceeds limit") - continue - - doc = Document( - text=content, - meta_data={ - "file_path": relative_path, - "type": ext[1:], - "is_code": True, - "is_implementation": is_implementation, - "title": relative_path, - "token_count": token_count, - }, - ) - documents.append(doc) - except Exception as e: - logger.error(f"Error reading {file_path}: {e}") - - # Then process documentation files - for ext in doc_extensions: - files = glob.glob(f"{path}/**/*{ext}", recursive=True) - for file_path in files: - # Check if file should be processed based on inclusion/exclusion rules - if not should_process_file(file_path, use_inclusion_mode, included_dirs, included_files, excluded_dirs, excluded_files): - continue - - try: - with open(file_path, "r", encoding="utf-8") as f: - content = f.read() - relative_path = os.path.relpath(file_path, path) - - # Check token count - token_count = count_tokens(content, embedder_type) - if token_count > MAX_EMBEDDING_TOKENS: - logger.warning(f"Skipping large file {relative_path}: Token count ({token_count}) exceeds limit") - continue - - doc = Document( - text=content, - meta_data={ - "file_path": relative_path, - "type": ext[1:], - "is_code": False, - "is_implementation": False, - "title": relative_path, - "token_count": token_count, - }, - ) - documents.append(doc) + else: + is_implementation = False + + doc = Document( + text=content, + meta_data={ + "file_path": relative_path, + "type": file_ext, + "is_code": is_code, + "is_implementation": is_implementation, + "title": relative_path, + "token_count": token_count, + }, + ) + documents.append(doc) except Exception as e: logger.error(f"Error reading {file_path}: {e}") From a562c789e72f211333042b360e42d479e8ed8791 Mon Sep 17 00:00:00 2001 From: GdoongMathew Date: Sun, 26 Jul 2026 05:08:34 +0800 Subject: [PATCH 40/50] move all `websocket.close()` in finally scope. (#560) --- api/websocket_wiki.py | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/api/websocket_wiki.py b/api/websocket_wiki.py index 8006dee80..d72efc2b5 100644 --- a/api/websocket_wiki.py +++ b/api/websocket_wiki.py @@ -82,12 +82,10 @@ async def handle_websocket_chat(websocket: WebSocket): if "No valid documents with embeddings found" in str(e): logger.error(f"No valid embeddings found: {str(e)}") await websocket.send_text("Error: No valid document embeddings found. This may be due to embedding size inconsistencies or API errors during document processing. Please try again or check your repository content.") - await websocket.close() return else: logger.error(f"ValueError preparing retriever: {str(e)}") await websocket.send_text(f"Error preparing retriever: {str(e)}") - await websocket.close() return except Exception as e: logger.error(f"Error preparing retriever: {str(e)}") @@ -96,19 +94,16 @@ async def handle_websocket_chat(websocket: WebSocket): await websocket.send_text("Error: Inconsistent embedding sizes detected. Some documents may have failed to embed properly. Please try again.") else: await websocket.send_text(f"Error preparing retriever: {str(e)}") - await websocket.close() return # Validate request if not request.messages or len(request.messages) == 0: await websocket.send_text("Error: No messages provided") - await websocket.close() return last_message = request.messages[-1] if last_message.role != "user": await websocket.send_text("Error: Last message must be from the user") - await websocket.close() return # Process previous messages to build conversation history @@ -320,7 +315,6 @@ async def stream_and_fallback( async for chunk in stream_and_fallback(chat_streamer, prompt_func, simplified_prompt_func): await websocket.send_text(chunk) - await websocket.close() except WebSocketDisconnect: logger.info("WebSocket disconnected") @@ -328,6 +322,7 @@ async def stream_and_fallback( logger.error(f"Error in WebSocket handler: {str(e)}") try: await websocket.send_text(f"Error: {str(e)}") - await websocket.close() except Exception: pass + finally: + await websocket.close() From b5e766699b7f0ec5294426af53f5924d9058218b Mon Sep 17 00:00:00 2001 From: GdoongMathew Date: Thu, 30 Jul 2026 14:15:03 +0800 Subject: [PATCH 41/50] Feat/codemap pr (#565) * Refactor/chat streamer (#1) * refactor chat streaming * update `_is_token_limit_error` logic * test: add chat provider unittests * move `_is_token_limit_error` to module level * remove unnecessary TYPE_CHECKING * add EOF * update prompt function * update `input_too_large` threshold (#2) * patch: patch adalflow OllamaClient to support batch embedding api. (#3) related to https://github.com/SylphAI-Inc/AdalFlow/pull/496 * Revert "patch: patch adalflow OllamaClient to support batch embedding api. (#3)" (#4) This reverts commit 6bdc4e8ddb53a0ca705550b5eda17395bab34d83. * Feat/ollama batch embed patch (#5) * patch: patch adalflow OllamaClient to support batch embedding api. related to https://github.com/SylphAI-Inc/AdalFlow/pull/496 * remove `OllamaDocumentProcessor` * Refactor/chat streamer (#6) * refactor chat streaming * update `_is_token_limit_error` logic * test: add chat provider unittests * move `_is_token_limit_error` to module level * remove unnecessary TYPE_CHECKING * add EOF * update prompt function * refactor `websocket_wiki.py` using `ChatStreamer` * remove unnecessary imports * add litellm test * update kwargs in `GoogleGenerativeChatStreamer` * fix: fix simple_chat format * refactor and add clients module (#7) * centralize prompt (related to #306) * centralize prompt (related to #306) (#8) * fix prompt * update `check_ollama_model_exists` using ollama.list api. * fix exception type * fix import scope * fix catching exception * remove dangling file. * use field_validator to simplify dirs and files parsing in `ChatCompletionRequest` * Apply suggestions from code review Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> * prevent empty string * unify chat model in `websocket_wiki` and `simple_chat` * import cleanup * import documentations * add EOF * change azureai client import path in `_stream` module. * follwing pep8. * using specific `mode` field to determine `deep_research` mode or normal conversation mode. * add explicit `research_iteration` from the frontend. * add conversation history rendering in frontend. (claude code generated) * prevent showing `websocket error` when sending chat to server. * deps: update requests version to fix CVE. (fix #537) * fix callers of `count_tokens`, pass provider string directly * cleaning up prompt generation code in websocket_wiki. * add back default `local` repo type * refactor rag, `simple_chat` and `websocket_wiki` to perform non-blocking rag retrieval * lazy init asyncio Semaphore instance * change assert type * remove unused `generator` instance from RAG class * refactor: remove duplicated document processing. * add AnthropicBedrockClient * add anthropic package * update `CLIENT_CLASSES` mapping * move all `websocket.close()` in finally scope. (#10) * refactor: use `acall` implemenation in `AnthropicChatStreamer` * docs: add docstring. * update docstring * update type annotation * refactor: add data schema for all backend pydantic models * refactor: logging mechanism * refactor: logging cleanup * refactor: backend structure * fixing endpoints * add back configuring GOOGLE_API_KEY, and remove import app, simple_chat and websocket_wiki * rag cleaning up * wiki resource refactoring * rename wiki api function, and refactor rag * remove ollama_patch * add schema tests * refactoring structure * use `websocket` decorator instead * implement async load and save for BaseModel type * refactor RAG document validation * simplify `Memory.call` dialog_turn dict generate * feat: add codemap generation * feat: add codemap generation * update codemap generation progress indicator * change ths mode toggle to use dropdown. (ai generated) * extend height page (ai suggestion) * change codeviewer position and triggering * hide modal's close button when codeviewer is opened. (ai generated) * include `codemap` generation into `clear conversation` triggering condition. (ai generated) * reformat codemap generated section, split conversation and codemap link to share the same page without overlapping each other. * fix conversation sequence bug when mixing `codemap` with other modes * fix: fix rag return type, removing `RAGAnswer` dataclass object. * removing logging api key in logger. * rename `utils` to `services` * update type annotation of `asave` and `aload`. * refactor `excluded` and `included` mechanism, and fix `_should_process_file` * fix `_should_process_file` * pre-commit fix * update readme. (ai generated for other languages) * Add screenshot * add codemap screenshot in readme * update tests * Fix/huge repo timeout (#11) * refactor: add Repo type * add repo schema * fix: fix import path * add prepare_index in frontend. (ai generated) * update preparing indexing in different languages. (ai generated) * update preparing indexing in different languages. (ai generated) * update preparing indexing in different languages. (ai generated) * add warning if repo's index is not pre-built * refactor schema, create `RepoRequestBase` to minimize duplicated repo fields (#12) * Fix/frontend wiki prompt (#13) * fix frontend parsing generated wiki. * fix frontend parsing generated wiki. * fix ruff isort * update logging config and mechanism * fix: fix language detection (#14) --------- Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- .github/workflows/docker-build-push.yml | 14 +- .pre-commit-config.yaml | 71 + Ollama-instruction.md | 2 +- README.es.md | 5 +- README.fr.md | 6 +- README.ja.md | 5 +- README.kr.md | 5 +- README.md | 1 + README.pt-br.md | 6 +- README.ru.md | 4 + README.vi.md | 5 +- README.zh-tw.md | 15 +- README.zh.md | 5 +- api/api.py | 634 ---- api/chat/__init__.py | 5 +- api/chat/_prompts.py | 30 +- api/chat/_stream.py | 129 +- api/chat_model.py | 65 - api/clients/__init__.py | 5 +- api/clients/anthropic.py | 183 + api/clients/bedrock.py | 193 +- api/clients/dashscope.py | 566 +-- api/clients/google_embedder.py | 111 +- api/clients/litellm.py | 8 +- api/clients/ollama.py | 55 +- api/clients/openrouter.py | 317 +- api/config.py | 254 +- api/config/repo.json | 262 +- api/data_pipeline.py | 905 ----- api/logger.py | 107 + api/logging_config.py | 85 - api/main.py | 96 +- api/ollama_patch.py | 49 - api/poetry.lock | 50 +- api/prompts.py | 74 + api/pyproject.toml | 1 + api/rag.py | 449 --- api/rag/__init__.py | 8 + api/rag/pipeline.py | 617 ++++ api/rag/rag.py | 385 ++ api/repository.py | 516 +++ api/routers/__init__.py | 0 api/routers/auth.py | 22 + api/routers/chat.py | 116 + api/routers/codemap.py | 68 + api/routers/repo.py | 78 + api/routers/system.py | 84 + api/routers/wiki.py | 228 ++ api/schemas/__init__.py | 49 + api/schemas/auth.py | 5 + api/schemas/base.py | 44 + api/schemas/chat.py | 29 + api/schemas/codemap.py | 62 + api/schemas/io.py | 20 + api/schemas/models.py | 36 + api/schemas/repo.py | 16 + api/schemas/wiki.py | 90 + api/services/__init__.py | 0 api/services/codemap.py | 312 ++ api/services/research.py | 334 ++ api/services/wiki.py | 232 ++ api/simple_chat.py | 345 -- api/tools/embedder.py | 58 - api/websocket_wiki.py | 328 -- docker-compose-litellm.env | 2 +- docker-compose-litellm.yml | 2 +- package-lock.json | 3663 +++++++++++--------- package.json | 10 +- pytest.ini | 2 +- run.sh | 2 +- screenshots/Codemap.png | Bin 0 -> 197283 bytes src/app/[owner]/[repo]/page.tsx | 309 +- src/app/[owner]/[repo]/workshop/page.tsx | 2 +- src/app/api/auth/status/route.ts | 4 +- src/app/api/auth/validate/route.ts | 6 +- src/app/api/chat/stream/route.ts | 2 +- src/app/api/models/config/route.ts | 2 +- src/app/api/repo/prepare/route.ts | 74 + src/app/api/wiki/projects/route.ts | 2 +- src/app/page.tsx | 2 +- src/app/wiki/projects/page.tsx | 2 +- src/components/Ask.tsx | 844 ++++- src/components/CodeMap.tsx | 158 + src/components/CodeViewer.tsx | 147 + src/components/Markdown.tsx | 2 +- src/components/Mermaid.tsx | 2 +- src/components/ModelSelectionModal.tsx | 6 +- src/components/ProcessedProjects.tsx | 10 +- src/components/TokenInput.tsx | 2 +- src/components/WikiTreeView.tsx | 2 +- src/components/WikiTypeSelector.tsx | 2 +- src/contexts/LanguageContext.tsx | 47 +- src/i18n.ts | 2 +- src/messages/en.json | 1 + src/messages/es.json | 1 + src/messages/fr.json | 2 +- src/messages/ja.json | 1 + src/messages/kr.json | 1 + src/messages/pt-br.json | 1 + src/messages/ru.json | 1 + src/messages/vi.json | 3 +- src/messages/zh-tw.json | 3 +- src/messages/zh.json | 1 + src/types/repoinfo.tsx | 2 +- src/types/wiki/wikipage.tsx | 2 +- src/types/wiki/wikistructure.tsx | 2 +- src/utils/getRepoUrl.tsx | 2 +- src/utils/prepareRepo.ts | 106 + src/utils/urlDecoder.tsx | 2 +- src/utils/websocketClient.ts | 158 +- tailwind.config.js | 2 +- test/__init__.py | 1 - test/test_extract_repo_name.py | 118 - tests/README.md | 2 +- tests/__init__.py | 2 +- tests/api/__init__.py | 2 +- tests/api/test_api.py | 38 +- tests/backend/rag/test_rag.py | 37 + tests/backend/schemas/test_chat.py | 25 + tests/backend/schemas/test_wiki.py | 57 + tests/integration/__init__.py | 2 +- tests/integration/test_full_integration.py | 103 +- tests/run_tests.py | 79 +- tests/test_extract_repo_name.py | 85 + tests/unit/__init__.py | 2 +- tests/unit/test_all_embedders.py | 339 +- tests/unit/test_chat.py | 59 +- tests/unit/test_google_embedder.py | 127 +- yarn.lock | 3445 +++++++++--------- 129 files changed, 10963 insertions(+), 8020 deletions(-) create mode 100644 .pre-commit-config.yaml delete mode 100644 api/api.py delete mode 100644 api/chat_model.py create mode 100644 api/clients/anthropic.py delete mode 100644 api/data_pipeline.py create mode 100644 api/logger.py delete mode 100644 api/logging_config.py delete mode 100644 api/ollama_patch.py delete mode 100644 api/rag.py create mode 100644 api/rag/__init__.py create mode 100644 api/rag/pipeline.py create mode 100644 api/rag/rag.py create mode 100644 api/repository.py create mode 100644 api/routers/__init__.py create mode 100644 api/routers/auth.py create mode 100644 api/routers/chat.py create mode 100644 api/routers/codemap.py create mode 100644 api/routers/repo.py create mode 100644 api/routers/system.py create mode 100644 api/routers/wiki.py create mode 100644 api/schemas/__init__.py create mode 100644 api/schemas/auth.py create mode 100644 api/schemas/base.py create mode 100644 api/schemas/chat.py create mode 100644 api/schemas/codemap.py create mode 100644 api/schemas/io.py create mode 100644 api/schemas/models.py create mode 100644 api/schemas/repo.py create mode 100644 api/schemas/wiki.py create mode 100644 api/services/__init__.py create mode 100644 api/services/codemap.py create mode 100644 api/services/research.py create mode 100644 api/services/wiki.py delete mode 100644 api/simple_chat.py delete mode 100644 api/tools/embedder.py delete mode 100644 api/websocket_wiki.py create mode 100644 screenshots/Codemap.png create mode 100644 src/app/api/repo/prepare/route.ts create mode 100644 src/components/CodeMap.tsx create mode 100644 src/components/CodeViewer.tsx create mode 100644 src/utils/prepareRepo.ts delete mode 100644 test/__init__.py delete mode 100644 test/test_extract_repo_name.py create mode 100644 tests/backend/rag/test_rag.py create mode 100644 tests/backend/schemas/test_chat.py create mode 100644 tests/backend/schemas/test_wiki.py create mode 100644 tests/test_extract_repo_name.py diff --git a/.github/workflows/docker-build-push.yml b/.github/workflows/docker-build-push.yml index 50b8ef2f9..fcea26923 100644 --- a/.github/workflows/docker-build-push.yml +++ b/.github/workflows/docker-build-push.yml @@ -11,13 +11,13 @@ on: env: REGISTRY: ghcr.io IMAGE_NAME: ${{ github.repository }} - + concurrency: # This concurrency group ensures that only one job in the group runs at a time. # If a new job is triggered, the previous one will be canceled. group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true - + jobs: build-and-push: strategy: @@ -39,7 +39,7 @@ jobs: platform=${{ matrix.platform }} echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV echo "GHCR_IMAGE=ghcr.io/${GITHUB_REPOSITORY@L}" >> $GITHUB_ENV - + - name: Checkout repository uses: actions/checkout@v4 @@ -75,21 +75,21 @@ jobs: outputs: type=image,name=${{ env.GHCR_IMAGE }},push-by-digest=true,name-canonical=true,push=${{ github.event_name != 'pull_request' }},oci-mediatypes=true cache-from: type=gha,scope=${{ github.repository }}-${{ github.ref_name }}-${{ matrix.platform }} cache-to: type=gha,mode=max,scope=${{ github.repository }}-${{ github.ref_name }}-${{ matrix.platform }} - + - name: Export digest run: | mkdir -p /tmp/digests digest="${{ steps.build.outputs.digest }}" touch "/tmp/digests/${digest#sha256:}" - - name: Upload digest + - name: Upload digest uses: actions/upload-artifact@v4 with: name: digests-${{ env.PLATFORM_PAIR }} path: /tmp/digests/* if-no-files-found: error retention-days: 1 - + merge: name: merge Docker manifests runs-on: ubuntu-latest @@ -105,7 +105,7 @@ jobs: id: prepare run: | echo "GHCR_IMAGE=ghcr.io/${GITHUB_REPOSITORY@L}" >> $GITHUB_ENV - + - name: Download digests uses: actions/download-artifact@v4 with: diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 000000000..9a4e4eba5 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,71 @@ +# Pre-commit hooks for deepwiki-open (Next.js frontend + FastAPI backend) +# +# Setup: +# pipx install pre-commit # or: pip install pre-commit +# pre-commit install # install the git hook +# pre-commit run --all-files # run once across the whole repo +# +# Update pinned versions later with: pre-commit autoupdate + +default_language_version: + python: python3.12 + +# Never touch generated / vendored / log / backup files. +exclude: | + (?x)^( + .*\.bak| + api/logs/.*| + .*/__pycache__/.*| + public/.*\.svg| + yarn\.lock| + package-lock\.json| + uv\.lock| + api/poetry\.lock + )$ + +repos: + # --------------------------------------------------------------------------- + # General hygiene + # --------------------------------------------------------------------------- + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: trailing-whitespace + args: [--markdown-linebreak-ext=md] + - id: end-of-file-fixer + - id: mixed-line-ending + args: [--fix=lf] + - id: check-merge-conflict + - id: check-case-conflict + - id: check-added-large-files + args: [--maxkb=1024] + - id: check-yaml + - id: check-toml + - id: check-json + # .vscode/*.json and tsconfig.json are JSONC (allow comments) -> skip + exclude: ^(\.vscode/|tsconfig\.json$) + - id: detect-private-key + + # --------------------------------------------------------------------------- + # Secret scanning + # Blocks commits that contain tokens, keys, or credentials. + # --------------------------------------------------------------------------- + - repo: https://github.com/gitleaks/gitleaks + rev: v8.21.2 + hooks: + - id: gitleaks + + # --------------------------------------------------------------------------- + # Python — ruff replaces flake8 + isort + black (lint + format in one tool). + # Scoped to the backend and test dirs only. + # --------------------------------------------------------------------------- + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.8.6 + hooks: + - id: ruff + name: ruff (lint + autofix) + args: [--fix] + files: ^(api|test)/.*\.py$ # disable tests folders for now + - id: ruff-format + name: ruff (format) + files: ^(api|test)/.*\.py$ diff --git a/Ollama-instruction.md b/Ollama-instruction.md index f5332dac3..5b90faba0 100644 --- a/Ollama-instruction.md +++ b/Ollama-instruction.md @@ -94,7 +94,7 @@ cp api/config/embedder.ollama.json.bak api/config/embedder.json -v ~/.adalflow:/root/.adalflow \ -e OLLAMA_HOST=your_ollama_host \ deepwiki:ollama-local - + # For local repository analysis docker run -p 3000:3000 -p 8001:8001 --name deepwiki \ -v ~/.adalflow:/root/.adalflow \ diff --git a/README.es.md b/README.es.md index d7a3f71a0..2ef6dca14 100644 --- a/README.es.md +++ b/README.es.md @@ -8,6 +8,7 @@ 2. GenerarĆ” documentación completa 3. CrearĆ” diagramas visuales para explicar cómo funciona todo 4. OrganizarĆ” todo en una wiki fĆ”cil de navegar +5. GenerarĆ” un mapa de código (codemap) para recorridos guiados centrados en el código [!["Buy Me A Coffee"](https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png)](https://buymeacoffee.com/sheing) @@ -289,6 +290,9 @@ Para usar Investigación Profunda, simplemente activa el interruptor "Investigac ![Función de Investigación Profunda](screenshots/DeepResearch.png) *Investigación Profunda realiza investigaciones de mĆŗltiples turnos para temas complejos* +![Codemap](screenshots/Codemap.png) +*Codemap* + ### Video de Demostración [![Video de Demostración de DeepWiki](https://img.youtube.com/vi/zGANs8US8B4/0.jpg)](https://youtu.be/zGANs8US8B4) @@ -331,4 +335,3 @@ Este proyecto estĆ” licenciado bajo la Licencia MIT - consulta el archivo [LICEN ## ⭐ Historial de Estrellas [![GrĆ”fico de Historial de Estrellas](https://api.star-history.com/svg?repos=AsyncFuncAI/deepwiki-open&type=Date)](https://star-history.com/#AsyncFuncAI/deepwiki-open&Date) - diff --git a/README.fr.md b/README.fr.md index 248ca5624..d71f30944 100644 --- a/README.fr.md +++ b/README.fr.md @@ -9,6 +9,7 @@ 2. GĆ©nĆØre une documentation complĆØte 3. CrĆ©e des diagrammes visuels pour expliquer le fonctionnement 4. Organise le tout dans un wiki facile Ć  naviguer +5. GĆ©nĆØre une codemap pour des visites guidĆ©es centrĆ©es sur le code [!["Buy Me A Coffee"](https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png)](https://buymeacoffee.com/sheing) [![Tip in Crypto](https://tip.md/badge.svg)](https://tip.md/sng-asyncfunc) @@ -514,6 +515,9 @@ Pour utiliser DeepResearch, activez simplement le commutateur "Deep Research" da ![FonctionnalitĆ© DeepResearch](screenshots/DeepResearch.png) *DeepResearch effectue des recherches en plusieurs Ć©tapes pour des sujets complexes* +![Codemap](screenshots/Codemap.png) +*Codemap* + ### VidĆ©o de dĆ©monstration [![VidĆ©o de dĆ©mo DeepWiki](https://img.youtube.com/vi/zGANs8US8B4/0.jpg)](https://youtu.be/zGANs8US8B4) @@ -560,5 +564,3 @@ Projet sous licence MIT – Voir le fichier [LICENSE](LICENSE). ## ⭐ Historique des stars [![Historique des stars](https://api.star-history.com/svg?repos=AsyncFuncAI/deepwiki-open&type=Date)](https://star-history.com/#AsyncFuncAI/deepwiki-open&Date) - - diff --git a/README.ja.md b/README.ja.md index bb9e42027..b427fb521 100644 --- a/README.ja.md +++ b/README.ja.md @@ -8,6 +8,7 @@ 2. åŒ…ę‹¬ēš„ćŖćƒ‰ć‚­ćƒ„ćƒ”ćƒ³ćƒˆć‚’ē”Ÿęˆ 3. ć™ć¹ć¦ć®ä»•ēµ„ćæć‚’čŖ¬ę˜Žć™ć‚‹č¦–č¦šēš„ćŖå›³ć‚’ä½œęˆ 4. ć™ć¹ć¦ć‚’ē°”å˜ć«é–²č¦§ć§ćć‚‹ Wiki に敓理 +5. ć‚³ćƒ¼ćƒ‰äø­åæƒć®ć‚¬ć‚¤ćƒ‰ćƒ„ć‚¢ćƒ¼ć®ćŸć‚ć®ć‚³ćƒ¼ćƒ‰ćƒžćƒƒćƒ—ć‚’ē”Ÿęˆ [!["Buy Me A Coffee"](https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png)](https://buymeacoffee.com/sheing) @@ -397,6 +398,9 @@ _å€‹äŗŗć‚¢ć‚Æć‚»ć‚¹ćƒˆćƒ¼ć‚Æćƒ³ć‚’ä½æē”Øć—ćŸćƒ—ćƒ©ć‚¤ćƒ™ćƒ¼ćƒˆćƒŖćƒć‚øćƒˆćƒŖ ![詳瓰調査機能](screenshots/DeepResearch.png) _č©³ē“°čŖæęŸ»ćÆč¤‡é›‘ćŖćƒˆćƒ”ćƒƒć‚Æć«åÆ¾ć—ć¦å¤šę®µéšŽć®čŖæęŸ»ć‚’å®Ÿę–½_ +![Codemap](screenshots/Codemap.png) +*Codemap* + ### ćƒ‡ćƒ¢ćƒ“ćƒ‡ć‚Ŗ [![DeepWikićƒ‡ćƒ¢ćƒ“ćƒ‡ć‚Ŗ](https://img.youtube.com/vi/zGANs8US8B4/0.jpg)](https://youtu.be/zGANs8US8B4) @@ -444,4 +448,3 @@ _DeepWiki ć®å‹•ä½œć‚’č¦‹ć‚‹ļ¼_ ## ⭐ ć‚¹ć‚æćƒ¼å±„ę­“ [![ć‚¹ć‚æćƒ¼å±„ę­“ćƒćƒ£ćƒ¼ćƒˆ](https://api.star-history.com/svg?repos=AsyncFuncAI/deepwiki-open&type=Date)](https://star-history.com/#AsyncFuncAI/deepwiki-open&Date) - diff --git a/README.kr.md b/README.kr.md index c3c61b655..1fea276fb 100644 --- a/README.kr.md +++ b/README.kr.md @@ -8,6 +8,7 @@ 2. ķ¬ź“„ģ ģø ė¬øģ„œ ģƒģ„± 3. ėŖØė“  ģž‘ė™ ė°©ģ‹ģ„ ģ„¤ėŖ…ķ•˜ėŠ” ģ‹œź°ģ  ė‹¤ģ“ģ–“ź·øėžØ ģƒģ„± 4. ģ“ė„¼ ģ‰½ź²Œ ķƒģƒ‰ķ•  수 ģžˆėŠ” ģœ„ķ‚¤ė”œ 정리 +5. ģ½”ė“œ ģ¤‘ģ‹¬ģ˜ ź°€ģ“ė“œ ķˆ¬ģ–“ė„¼ ģœ„ķ•œ ģ½”ė“œė§µ ģƒģ„± [!["Buy Me A Coffee"](https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png)](https://buymeacoffee.com/sheing) @@ -385,6 +386,9 @@ DeepResearch넼 ģ‚¬ģš©ķ•˜ė ¤ė©“ 질문 제출 ģ „ Ask ģøķ„°ķŽ˜ģ“ģŠ¤ģ—ģ„œ "Dee ![DeepResearch Feature](screenshots/DeepResearch.png) *DeepResearchėŠ” ė³µģž”ķ•œ ģ£¼ģ œģ— ėŒ€ķ•“ 다중 ķ„“ 씰사넼 ģˆ˜ķ–‰* +![Codemap](screenshots/Codemap.png) +*Codemap* + ### ė°ėŖØ 영상 [![DeepWiki Demo Video](https://img.youtube.com/vi/zGANs8US8B4/0.jpg)](https://youtu.be/zGANs8US8B4) @@ -427,4 +431,3 @@ DeepResearch넼 ģ‚¬ģš©ķ•˜ė ¤ė©“ 질문 제출 ģ „ Ask ģøķ„°ķŽ˜ģ“ģŠ¤ģ—ģ„œ "Dee ## ⭐ ģŠ¤ķƒ€ ķžˆģŠ¤ķ† ė¦¬ [![Star History Chart](https://api.star-history.com/svg?repos=AsyncFuncAI/deepwiki-open&type=Date)](https://star-history.com/#AsyncFuncAI/deepwiki-open&Date) - diff --git a/README.md b/README.md index 8abbe6740..806c0f0d4 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ 2. Generate comprehensive documentation 3. Create visual diagrams to explain how everything works 4. Organize it all into an easy-to-navigate wiki +5. Generate codemap for code-centric guided tours [!["Buy Me A Coffee"](https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png)](https://buymeacoffee.com/sheing) [![Tip in Crypto](https://tip.md/badge.svg)](https://tip.md/sng-asyncfunc) diff --git a/README.pt-br.md b/README.pt-br.md index eee4044ca..acc133588 100644 --- a/README.pt-br.md +++ b/README.pt-br.md @@ -8,6 +8,7 @@ 2. Gerar documentação abrangente 3. Criar diagramas visuais para explicar como tudo funciona 4. Organizar tudo em uma wiki fĆ”cil de navegar +5. Gerar um codemap para tours guiados centrados no código [!["Buy Me A Coffee"](https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png)](https://buymeacoffee.com/sheing) [![Tip in Crypto](https://tip.md/badge.svg)](https://tip.md/sng-asyncfunc) @@ -297,7 +298,7 @@ docker-compose up | VariĆ”vel | Descrição | Obrigatória | Observação | |----------------------|--------------------------------------------------------------|----------|----------------------------------------------------------------------------------------------------------| -| `GOOGLE_API_KEY` | Chave API Google Gemini para geração com IA | NĆ£o | NecessĆ”ria apenas se vocĆŖ quiser usar modelos Google Gemini +| `GOOGLE_API_KEY` | Chave API Google Gemini para geração com IA | NĆ£o | NecessĆ”ria apenas se vocĆŖ quiser usar modelos Google Gemini | `OPENAI_API_KEY` | Chave API OpenAI para embeddings | Sim | Nota: Isso Ć© necessĆ”rio mesmo se vocĆŖ nĆ£o estiver usando modelos OpenAI, pois Ć© usado para embeddings. | | `OPENROUTER_API_KEY` | Chave API OpenRouter para modelos alternativos | NĆ£o | NecessĆ”ria apenas se vocĆŖ quiser usar modelos OpenRouter | | `OLLAMA_HOST` | Host Ollama (padrĆ£o: http://localhost:11434) | NĆ£o | NecessĆ”ria apenas se vocĆŖ quiser usar servidor Ollama externo | @@ -466,6 +467,9 @@ Para usar o DeepResearch, simplesmente alterne o interruptor "Pesquisa Aprofunda ![Recurso DeepResearch](screenshots/DeepResearch.png) *DeepResearch conduz investigaƧƵes em vĆ”rias etapas para tópicos complexos* +![Codemap](screenshots/Codemap.png) +*Codemap* + ### VĆ­deo de Demonstração [![VĆ­deo de Demonstração do DeepWiki](https://img.youtube.com/vi/zGANs8US8B4/0.jpg)](https://youtu.be/zGANs8US8B4) diff --git a/README.ru.md b/README.ru.md index 01c5c9dcf..316f93eeb 100644 --- a/README.ru.md +++ b/README.ru.md @@ -8,6 +8,7 @@ 2. Š“ŠµŠ½ŠµŃ€Š°Ń†ŠøŃŽ полноценной Š“Š¾ŠŗŃƒŠ¼ŠµŠ½Ń‚Š°Ń†ŠøŠø 3. ŠŸŠ¾ŃŃ‚Ń€Š¾ŠµŠ½ŠøŠµ Š²ŠøŠ·ŃƒŠ°Š»ŃŒŠ½Ń‹Ń… Гиаграмм, Š¾Š±ŃŠŃŃŠ½ŃŃŽŃ‰ŠøŃ… Ń€Š°Š±Š¾Ń‚Ńƒ системы 4. ŠžŃ€Š³Š°Š½ŠøŠ·Š°Ń†ŠøŃŽ всего в ŃƒŠ“Š¾Š±Š½ŃƒŃŽ Šø ŃŃ‚Ń€ŃƒŠŗŃ‚ŃƒŃ€ŠøŃ€Š¾Š²Š°Š½Š½ŃƒŃŽ вики +5. ДозГание карты коГа (codemap) Š“Š»Ń ŠæŠ¾ŃˆŠ°Š³Š¾Š²Ń‹Ń… ŃŠŗŃŠŗŃƒŃ€ŃŠøŠ¹ по коГу [!["ŠšŃƒŠæŠøŃ‚ŃŒ мне кофе"](https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png)](https://buymeacoffee.com/sheing) [![ŠŸŠ¾Š“Š“ŠµŃ€Š¶Š°Ń‚ŃŒ в ŠŗŃ€ŠøŠæŃ‚Š¾Š²Š°Š»ŃŽŃ‚Šµ](https://tip.md/badge.svg)](https://tip.md/sng-asyncfunc) @@ -461,6 +462,9 @@ docker build --build-arg CUSTOM_CERT_DIR=certs . ![DeepResearch](screenshots/DeepResearch.png) *DeepResearch Š°Š½Š°Š»ŠøŠ·ŠøŃ€ŃƒŠµŃ‚ сложные темы* +![Codemap](screenshots/Codemap.png) +*Codemap* + ### ВиГео-Гемо [![ВиГео](https://img.youtube.com/vi/zGANs8US8B4/0.jpg)](https://youtu.be/zGANs8US8B4) diff --git a/README.vi.md b/README.vi.md index 38bd54fd0..e8c022598 100644 --- a/README.vi.md +++ b/README.vi.md @@ -8,6 +8,7 @@ 2. Tįŗ”o tĆ i liệu đầy đủ vĆ  chi tiįŗæt 3. Tįŗ”o sĘ” đồ trį»±c quan Ä‘į»ƒ giįŗ£i thĆ­ch cĆ”ch mį»i thứ hoįŗ”t động 4. SįŗÆp xįŗæp tįŗ„t cįŗ£ documents thĆ nh mį»™t wiki dį»… hiểu +5. Tįŗ”o codemap cho cĆ”c chuyįŗæn tham quan có hướng dįŗ«n tįŗ­p trung vĆ o mĆ£ nguồn [!["Buy Me A Coffee"](https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png)](https://buymeacoffee.com/sheing) @@ -377,6 +378,9 @@ DeepResearch nĆ¢ng tįŗ§m phĆ¢n tĆ­ch kho mĆ£ vį»›i quy trƬnh nghiện cứu nhi ![TĆ­nh năng DeepResearch](screenshots/DeepResearch.png) *DeepResearch thį»±c hiện nghiện cứu nhiểu vòng cho cĆ”c chį»§ đề phức tįŗ”p* +![Codemap](screenshots/Codemap.png) +*Codemap* + ### Demo Video [![DeepWiki Demo Video](https://img.youtube.com/vi/zGANs8US8B4/0.jpg)](https://youtu.be/zGANs8US8B4) @@ -420,4 +424,3 @@ Dį»± Ć”n nĆ y được cįŗ„p phĆ©p theo Giįŗ„y phĆ©p MIT - xem file [LICENSE](LI ## ⭐ Lịch sį»­ [![Biểu đồ lịch sį»­](https://api.star-history.com/svg?repos=AsyncFuncAI/deepwiki-open&type=Date)](https://star-history.com/#AsyncFuncAI/deepwiki-open&Date) - diff --git a/README.zh-tw.md b/README.zh-tw.md index cc29aefcb..2a736b2c4 100644 --- a/README.zh-tw.md +++ b/README.zh-tw.md @@ -8,6 +8,7 @@ 2. ē”¢ē”Ÿå…Øé¢ēš„ę–‡ä»¶ 3. å»ŗē«‹č¦–č¦ŗåŒ–åœ–č”Øč§£é‡‹äø€åˆ‡å¦‚ä½•é‹ä½œ 4. å°‡ę‰€ęœ‰å…§å®¹ę•“ē†ęˆę˜“ę–¼å°Žč¦½ēš„ Wiki +5. ē”¢ē”ŸēØ‹å¼ē¢¼åœ°åœ–ļ¼Œęä¾›ä»„ēØ‹å¼ē¢¼ē‚ŗäø­åæƒēš„å¼•å°Žå¼å°Žč¦½ [!["Buy Me A Coffee"](https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png)](https://buymeacoffee.com/sheing) [![Tip in Crypto](https://tip.md/badge.svg)](https://tip.md/sng-asyncfunc) @@ -304,7 +305,7 @@ docker-compose up | č®Šę•ø | čŖŖę˜Ž | åæ…éœ€ | 備註 | |----------------------|--------------------------------------------------------------|----------|----------------------------------------------------------------------------------------------------------| -| `GOOGLE_API_KEY` | Google Gemini API é‡‘é‘°ļ¼Œē”Øę–¼ AI ē”¢ē”Ÿ | 否 | åŖęœ‰åœØę‚Øęƒ³ä½æē”Ø Google Gemini ęØ”åž‹ę™‚ę‰éœ€č¦ +| `GOOGLE_API_KEY` | Google Gemini API é‡‘é‘°ļ¼Œē”Øę–¼ AI ē”¢ē”Ÿ | 否 | åŖęœ‰åœØę‚Øęƒ³ä½æē”Ø Google Gemini ęØ”åž‹ę™‚ę‰éœ€č¦ | `OPENAI_API_KEY` | OpenAI API é‡‘é‘°ļ¼Œē”Øę–¼åµŒå…„ | 是 | å‚™čØ»ļ¼šå³ä½æę‚Øäøä½æē”Ø OpenAI ęØ”åž‹ļ¼Œé€™å€‹ä¹Ÿę˜Æåæ…éœ€ēš„ļ¼Œå› ē‚ŗå®ƒē”Øę–¼åµŒå…„ | | `OPENROUTER_API_KEY` | OpenRouter API é‡‘é‘°ļ¼Œē”Øę–¼ę›æä»£ęØ”åž‹ | 否 | åŖęœ‰åœØę‚Øęƒ³ä½æē”Ø OpenRouter ęØ”åž‹ę™‚ę‰éœ€č¦ | | `OLLAMA_HOST` | Ollama 主機(預設:http://localhost:11434) | 否 | åŖęœ‰åœØę‚Øęƒ³ä½æē”Øå¤–éƒØ Ollama ä¼ŗęœå™Øę™‚ę‰éœ€č¦ | @@ -466,16 +467,16 @@ OpenRouter ē‰¹åˆ„é©ē”Øę–¼ä»„äø‹ęƒ…ę³ļ¼š ## šŸ“± čž¢å¹•ęˆŖåœ– ### äø»é é¢ -![äø»é é¢](screenshots/home.png) +![äø»é é¢](./screenshots/Interface.png) ### Wiki 頁面 -![Wiki 頁面](screenshots/wiki-page.png) - -### ęå•åŠŸčƒ½ -![ęå•åŠŸčƒ½](screenshots/ask.png) +![Wiki 頁面](screenshots/Deepwiki.png) ### 深度研究 -![深度研究](screenshots/deep-research.png) +![深度研究](screenshots/DeepResearch.png) + +### Codemap +![Codemap](screenshots/Codemap.png) ### 展示影片 diff --git a/README.zh.md b/README.zh.md index ac7dc2db9..247be2910 100644 --- a/README.zh.md +++ b/README.zh.md @@ -8,6 +8,7 @@ 2. ē”Ÿęˆå…Øé¢ēš„ę–‡ę”£ 3. åˆ›å»ŗåÆč§†åŒ–å›¾č”Øč§£é‡Šäø€åˆ‡å¦‚ä½•čæä½œ 4. å°†ę‰€ęœ‰å†…å®¹ę•“ē†ęˆę˜“äŗŽåÆ¼čˆŖēš„Wiki +5. ē”Ÿęˆä»£ē åœ°å›¾ļ¼Œęä¾›ä»„ä»£ē äøŗäø­åæƒēš„å¼•åÆ¼å¼åÆ¼č§ˆ [!["Buy Me A Coffee"](https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png)](https://buymeacoffee.com/sheing) @@ -207,6 +208,9 @@ deepwiki/ ![ę·±åŗ¦ē ”ē©¶åŠŸčƒ½](screenshots/DeepResearch.png) *ę·±åŗ¦ē ”ē©¶äøŗå¤ę‚äø»é¢˜čæ›č”Œå¤šč½®č°ƒęŸ„* +![Codemap](screenshots/Codemap.png) +*ä»£ē åœ°å›¾* + ### 演示视频 [![DeepWiki演示视频](https://img.youtube.com/vi/zGANs8US8B4/0.jpg)](https://youtu.be/zGANs8US8B4) @@ -382,4 +386,3 @@ OpenAI å®¢ęˆ·ē«Æēš„ base_url é…ē½®äø»č¦äøŗę‹„ęœ‰ē§ęœ‰ API ęø é“ēš„ä¼äøšē”Ø 3. ēØ‹åŗä¼šč‡ŖåŠØē”ØēŽÆå¢ƒå˜é‡ēš„å€¼ę›æę¢ embedder.json é‡Œēš„å ä½ē¬¦ć€‚ čæ™ę ·å³åÆę— ē¼åˆ‡ę¢åˆ° OpenAI å…¼å®¹ēš„ embedding ęœåŠ”ļ¼Œę— éœ€äæ®ę”¹ä»£ē ć€‚ - diff --git a/api/api.py b/api/api.py deleted file mode 100644 index d40e73f96..000000000 --- a/api/api.py +++ /dev/null @@ -1,634 +0,0 @@ -import os -import logging -from fastapi import FastAPI, HTTPException, Query, Request, WebSocket -from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import JSONResponse, Response -from typing import List, Optional, Dict, Any, Literal -import json -from datetime import datetime -from pydantic import BaseModel, Field -import google.generativeai as genai -import asyncio - -# Configure logging -from api.logging_config import setup_logging - -setup_logging() -logger = logging.getLogger(__name__) - - -# Initialize FastAPI app -app = FastAPI( - title="Streaming API", - description="API for streaming chat completions" -) - -# Configure CORS -app.add_middleware( - CORSMiddleware, - allow_origins=["*"], # Allows all origins - allow_credentials=True, - allow_methods=["*"], # Allows all methods - allow_headers=["*"], # Allows all headers -) - -# Helper function to get adalflow root path -def get_adalflow_default_root_path(): - return os.path.expanduser(os.path.join("~", ".adalflow")) - -# --- Pydantic Models --- -class WikiPage(BaseModel): - """ - Model for a wiki page. - """ - id: str - title: str - content: str - filePaths: List[str] - importance: str # Should ideally be Literal['high', 'medium', 'low'] - relatedPages: List[str] - -class ProcessedProjectEntry(BaseModel): - id: str # Filename - owner: str - repo: str - name: str # owner/repo - repo_type: str # Renamed from type to repo_type for clarity with existing models - submittedAt: int # Timestamp - language: str # Extracted from filename - -class RepoInfo(BaseModel): - owner: str - repo: str - type: str - token: Optional[str] = None - localPath: Optional[str] = None - repoUrl: Optional[str] = None - - -class WikiSection(BaseModel): - """ - Model for the wiki sections. - """ - id: str - title: str - pages: List[str] - subsections: Optional[List[str]] = None - - -class WikiStructureModel(BaseModel): - """ - Model for the overall wiki structure. - """ - id: str - title: str - description: str - pages: List[WikiPage] - sections: Optional[List[WikiSection]] = None - rootSections: Optional[List[str]] = None - -class WikiCacheData(BaseModel): - """ - Model for the data to be stored in the wiki cache. - """ - wiki_structure: WikiStructureModel - generated_pages: Dict[str, WikiPage] - repo_url: Optional[str] = None #compatible for old cache - repo: Optional[RepoInfo] = None - provider: Optional[str] = None - model: Optional[str] = None - -class WikiCacheRequest(BaseModel): - """ - Model for the request body when saving wiki cache. - """ - repo: RepoInfo - language: str - wiki_structure: WikiStructureModel - generated_pages: Dict[str, WikiPage] - provider: str - model: str - -class WikiExportRequest(BaseModel): - """ - Model for requesting a wiki export. - """ - repo_url: str = Field(..., description="URL of the repository") - pages: List[WikiPage] = Field(..., description="List of wiki pages to export") - format: Literal["markdown", "json"] = Field(..., description="Export format (markdown or json)") - -# --- Model Configuration Models --- -class Model(BaseModel): - """ - Model for LLM model configuration - """ - id: str = Field(..., description="Model identifier") - name: str = Field(..., description="Display name for the model") - -class Provider(BaseModel): - """ - Model for LLM provider configuration - """ - id: str = Field(..., description="Provider identifier") - name: str = Field(..., description="Display name for the provider") - models: List[Model] = Field(..., description="List of available models for this provider") - supportsCustomModel: Optional[bool] = Field(False, description="Whether this provider supports custom models") - -class ModelConfig(BaseModel): - """ - Model for the entire model configuration - """ - providers: List[Provider] = Field(..., description="List of available model providers") - defaultProvider: str = Field(..., description="ID of the default provider") - -class AuthorizationConfig(BaseModel): - code: str = Field(..., description="Authorization code") - -from api.config import configs, WIKI_AUTH_MODE, WIKI_AUTH_CODE - -@app.get("/lang/config") -async def get_lang_config(): - return configs["lang_config"] - -@app.get("/auth/status") -async def get_auth_status(): - """ - Check if authentication is required for the wiki. - """ - return {"auth_required": WIKI_AUTH_MODE} - -@app.post("/auth/validate") -async def validate_auth_code(request: AuthorizationConfig): - """ - Check authorization code. - """ - return {"success": WIKI_AUTH_CODE == request.code} - -@app.get("/models/config", response_model=ModelConfig) -async def get_model_config(): - """ - Get available model providers and their models. - - This endpoint returns the configuration of available model providers and their - respective models that can be used throughout the application. - - Returns: - ModelConfig: A configuration object containing providers and their models - """ - try: - logger.info("Fetching model configurations") - - # Create providers from the config file - providers = [] - default_provider = configs.get("default_provider", "google") - - # Add provider configuration based on config.py - for provider_id, provider_config in configs["providers"].items(): - models = [] - # Add models from config - for model_id in provider_config["models"].keys(): - # Get a more user-friendly display name if possible - models.append(Model(id=model_id, name=model_id)) - - # Add provider with its models - providers.append( - Provider( - id=provider_id, - name=f"{provider_id.capitalize()}", - supportsCustomModel=provider_config.get("supportsCustomModel", False), - models=models - ) - ) - - # Create and return the full configuration - config = ModelConfig( - providers=providers, - defaultProvider=default_provider - ) - return config - - except Exception as e: - logger.error(f"Error creating model configuration: {str(e)}") - # Return some default configuration in case of error - return ModelConfig( - providers=[ - Provider( - id="google", - name="Google", - supportsCustomModel=True, - models=[ - Model(id="gemini-2.5-flash", name="Gemini 2.5 Flash") - ] - ) - ], - defaultProvider="google" - ) - -@app.post("/export/wiki") -async def export_wiki(request: WikiExportRequest): - """ - Export wiki content as Markdown or JSON. - - Args: - request: The export request containing wiki pages and format - - Returns: - A downloadable file in the requested format - """ - try: - logger.info(f"Exporting wiki for {request.repo_url} in {request.format} format") - - # Extract repository name from URL for the filename - repo_parts = request.repo_url.rstrip('/').split('/') - repo_name = repo_parts[-1] if len(repo_parts) > 0 else "wiki" - - # Get current timestamp for the filename - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - - if request.format == "markdown": - # Generate Markdown content - content = generate_markdown_export(request.repo_url, request.pages) - filename = f"{repo_name}_wiki_{timestamp}.md" - media_type = "text/markdown" - else: # JSON format - # Generate JSON content - content = generate_json_export(request.repo_url, request.pages) - filename = f"{repo_name}_wiki_{timestamp}.json" - media_type = "application/json" - - # Create response with appropriate headers for file download - response = Response( - content=content, - media_type=media_type, - headers={ - "Content-Disposition": f"attachment; filename={filename}" - } - ) - - return response - - except Exception as e: - error_msg = f"Error exporting wiki: {str(e)}" - logger.error(error_msg) - raise HTTPException(status_code=500, detail=error_msg) - -@app.get("/local_repo/structure") -async def get_local_repo_structure(path: str = Query(None, description="Path to local repository")): - """Return the file tree and README content for a local repository.""" - if not path: - return JSONResponse( - status_code=400, - content={"error": "No path provided. Please provide a 'path' query parameter."} - ) - - if not os.path.isdir(path): - return JSONResponse( - status_code=404, - content={"error": f"Directory not found: {path}"} - ) - - try: - logger.info(f"Processing local repository at: {path}") - file_tree_lines = [] - readme_content = "" - - for root, dirs, files in os.walk(path): - # Exclude hidden dirs/files and virtual envs - dirs[:] = [d for d in dirs if not d.startswith('.') and d != '__pycache__' and d != 'node_modules' and d != '.venv'] - for file in files: - if file.startswith('.') or file == '__init__.py' or file == '.DS_Store': - continue - rel_dir = os.path.relpath(root, path) - rel_file = os.path.join(rel_dir, file) if rel_dir != '.' else file - file_tree_lines.append(rel_file) - # Find README.md (case-insensitive) - if file.lower() == 'readme.md' and not readme_content: - try: - with open(os.path.join(root, file), 'r', encoding='utf-8') as f: - readme_content = f.read() - except Exception as e: - logger.warning(f"Could not read README.md: {str(e)}") - readme_content = "" - - file_tree_str = '\n'.join(sorted(file_tree_lines)) - return {"file_tree": file_tree_str, "readme": readme_content} - except Exception as e: - logger.error(f"Error processing local repository: {str(e)}") - return JSONResponse( - status_code=500, - content={"error": f"Error processing local repository: {str(e)}"} - ) - -def generate_markdown_export(repo_url: str, pages: List[WikiPage]) -> str: - """ - Generate Markdown export of wiki pages. - - Args: - repo_url: The repository URL - pages: List of wiki pages - - Returns: - Markdown content as string - """ - # Start with metadata - markdown = f"# Wiki Documentation for {repo_url}\n\n" - markdown += f"Generated on: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n\n" - - # Add table of contents - markdown += "## Table of Contents\n\n" - for page in pages: - markdown += f"- [{page.title}](#{page.id})\n" - markdown += "\n" - - # Add each page - for page in pages: - markdown += f"\n\n" - markdown += f"## {page.title}\n\n" - - - - # Add related pages - if page.relatedPages and len(page.relatedPages) > 0: - markdown += "### Related Pages\n\n" - related_titles = [] - for related_id in page.relatedPages: - # Find the title of the related page - related_page = next((p for p in pages if p.id == related_id), None) - if related_page: - related_titles.append(f"[{related_page.title}](#{related_id})") - - if related_titles: - markdown += "Related topics: " + ", ".join(related_titles) + "\n\n" - - # Add page content - markdown += f"{page.content}\n\n" - markdown += "---\n\n" - - return markdown - -def generate_json_export(repo_url: str, pages: List[WikiPage]) -> str: - """ - Generate JSON export of wiki pages. - - Args: - repo_url: The repository URL - pages: List of wiki pages - - Returns: - JSON content as string - """ - # Create a dictionary with metadata and pages - export_data = { - "metadata": { - "repository": repo_url, - "generated_at": datetime.now().isoformat(), - "page_count": len(pages) - }, - "pages": [page.model_dump() for page in pages] - } - - # Convert to JSON string with pretty formatting - return json.dumps(export_data, indent=2) - -# Import the simplified chat implementation -from api.simple_chat import chat_completions_stream -from api.websocket_wiki import handle_websocket_chat - -# Add the chat_completions_stream endpoint to the main app -app.add_api_route("/chat/completions/stream", chat_completions_stream, methods=["POST"]) - -# Add the WebSocket endpoint -app.add_websocket_route("/ws/chat", handle_websocket_chat) - -# --- Wiki Cache Helper Functions --- - -WIKI_CACHE_DIR = os.path.join(get_adalflow_default_root_path(), "wikicache") -os.makedirs(WIKI_CACHE_DIR, exist_ok=True) - -def get_wiki_cache_path(owner: str, repo: str, repo_type: str, language: str) -> str: - """Generates the file path for a given wiki cache.""" - filename = f"deepwiki_cache_{repo_type}_{owner}_{repo}_{language}.json" - return os.path.join(WIKI_CACHE_DIR, filename) - -async def read_wiki_cache(owner: str, repo: str, repo_type: str, language: str) -> Optional[WikiCacheData]: - """Reads wiki cache data from the file system.""" - cache_path = get_wiki_cache_path(owner, repo, repo_type, language) - if os.path.exists(cache_path): - try: - with open(cache_path, 'r', encoding='utf-8') as f: - data = json.load(f) - return WikiCacheData(**data) - except Exception as e: - logger.error(f"Error reading wiki cache from {cache_path}: {e}") - return None - return None - -async def save_wiki_cache(data: WikiCacheRequest) -> bool: - """Saves wiki cache data to the file system.""" - cache_path = get_wiki_cache_path(data.repo.owner, data.repo.repo, data.repo.type, data.language) - logger.info(f"Attempting to save wiki cache. Path: {cache_path}") - try: - payload = WikiCacheData( - wiki_structure=data.wiki_structure, - generated_pages=data.generated_pages, - repo=data.repo, - provider=data.provider, - model=data.model - ) - # Log size of data to be cached for debugging (avoid logging full content if large) - try: - payload_json = payload.model_dump_json() - payload_size = len(payload_json.encode('utf-8')) - logger.info(f"Payload prepared for caching. Size: {payload_size} bytes.") - except Exception as ser_e: - logger.warning(f"Could not serialize payload for size logging: {ser_e}") - - - logger.info(f"Writing cache file to: {cache_path}") - with open(cache_path, 'w', encoding='utf-8') as f: - json.dump(payload.model_dump(), f, indent=2) - logger.info(f"Wiki cache successfully saved to {cache_path}") - return True - except IOError as e: - logger.error(f"IOError saving wiki cache to {cache_path}: {e.strerror} (errno: {e.errno})", exc_info=True) - return False - except Exception as e: - logger.error(f"Unexpected error saving wiki cache to {cache_path}: {e}", exc_info=True) - return False - -# --- Wiki Cache API Endpoints --- - -@app.get("/api/wiki_cache", response_model=Optional[WikiCacheData]) -async def get_cached_wiki( - owner: str = Query(..., description="Repository owner"), - repo: str = Query(..., description="Repository name"), - repo_type: str = Query(..., description="Repository type (e.g., github, gitlab)"), - language: str = Query(..., description="Language of the wiki content") -): - """ - Retrieves cached wiki data (structure and generated pages) for a repository. - """ - # Language validation - supported_langs = configs["lang_config"]["supported_languages"] - if not supported_langs.__contains__(language): - language = configs["lang_config"]["default"] - - logger.info(f"Attempting to retrieve wiki cache for {owner}/{repo} ({repo_type}), lang: {language}") - cached_data = await read_wiki_cache(owner, repo, repo_type, language) - if cached_data: - return cached_data - else: - # Return 200 with null body if not found, as frontend expects this behavior - # Or, raise HTTPException(status_code=404, detail="Wiki cache not found") if preferred - logger.info(f"Wiki cache not found for {owner}/{repo} ({repo_type}), lang: {language}") - return None - -@app.post("/api/wiki_cache") -async def store_wiki_cache(request_data: WikiCacheRequest): - """ - Stores generated wiki data (structure and pages) to the server-side cache. - """ - # Language validation - supported_langs = configs["lang_config"]["supported_languages"] - - if not supported_langs.__contains__(request_data.language): - request_data.language = configs["lang_config"]["default"] - - logger.info(f"Attempting to save wiki cache for {request_data.repo.owner}/{request_data.repo.repo} ({request_data.repo.type}), lang: {request_data.language}") - success = await save_wiki_cache(request_data) - if success: - return {"message": "Wiki cache saved successfully"} - else: - raise HTTPException(status_code=500, detail="Failed to save wiki cache") - -@app.delete("/api/wiki_cache") -async def delete_wiki_cache( - owner: str = Query(..., description="Repository owner"), - repo: str = Query(..., description="Repository name"), - repo_type: str = Query(..., description="Repository type (e.g., github, gitlab)"), - language: str = Query(..., description="Language of the wiki content"), - authorization_code: Optional[str] = Query(None, description="Authorization code") -): - """ - Deletes a specific wiki cache from the file system. - """ - # Language validation - supported_langs = configs["lang_config"]["supported_languages"] - if not supported_langs.__contains__(language): - raise HTTPException(status_code=400, detail="Language is not supported") - - if WIKI_AUTH_MODE: - logger.info("check the authorization code") - if not authorization_code or WIKI_AUTH_CODE != authorization_code: - raise HTTPException(status_code=401, detail="Authorization code is invalid") - - logger.info(f"Attempting to delete wiki cache for {owner}/{repo} ({repo_type}), lang: {language}") - cache_path = get_wiki_cache_path(owner, repo, repo_type, language) - - if os.path.exists(cache_path): - try: - os.remove(cache_path) - logger.info(f"Successfully deleted wiki cache: {cache_path}") - return {"message": f"Wiki cache for {owner}/{repo} ({language}) deleted successfully"} - except Exception as e: - logger.error(f"Error deleting wiki cache {cache_path}: {e}") - raise HTTPException(status_code=500, detail=f"Failed to delete wiki cache: {str(e)}") - else: - logger.warning(f"Wiki cache not found, cannot delete: {cache_path}") - raise HTTPException(status_code=404, detail="Wiki cache not found") - -@app.get("/health") -async def health_check(): - """Health check endpoint for Docker and monitoring""" - return { - "status": "healthy", - "timestamp": datetime.now().isoformat(), - "service": "deepwiki-api" - } - -@app.get("/") -async def root(): - """Root endpoint to check if the API is running and list available endpoints dynamically.""" - # Collect routes dynamically from the FastAPI app - endpoints = {} - for route in app.routes: - if hasattr(route, "methods") and hasattr(route, "path"): - # Skip docs and static routes - if route.path in ["/openapi.json", "/docs", "/redoc", "/favicon.ico"]: - continue - # Group endpoints by first path segment - path_parts = route.path.strip("/").split("/") - group = path_parts[0].capitalize() if path_parts[0] else "Root" - method_list = list(route.methods - {"HEAD", "OPTIONS"}) - for method in method_list: - endpoints.setdefault(group, []).append(f"{method} {route.path}") - - # Optionally, sort endpoints for readability - for group in endpoints: - endpoints[group].sort() - - return { - "message": "Welcome to Streaming API", - "version": "1.0.0", - "endpoints": endpoints - } - -# --- Processed Projects Endpoint --- (New Endpoint) -@app.get("/api/processed_projects", response_model=List[ProcessedProjectEntry]) -async def get_processed_projects(): - """ - Lists all processed projects found in the wiki cache directory. - Projects are identified by files named like: deepwiki_cache_{repo_type}_{owner}_{repo}_{language}.json - """ - project_entries: List[ProcessedProjectEntry] = [] - # WIKI_CACHE_DIR is already defined globally in the file - - try: - if not os.path.exists(WIKI_CACHE_DIR): - logger.info(f"Cache directory {WIKI_CACHE_DIR} not found. Returning empty list.") - return [] - - logger.info(f"Scanning for project cache files in: {WIKI_CACHE_DIR}") - filenames = await asyncio.to_thread(os.listdir, WIKI_CACHE_DIR) # Use asyncio.to_thread for os.listdir - - for filename in filenames: - if filename.startswith("deepwiki_cache_") and filename.endswith(".json"): - file_path = os.path.join(WIKI_CACHE_DIR, filename) - try: - stats = await asyncio.to_thread(os.stat, file_path) # Use asyncio.to_thread for os.stat - parts = filename.replace("deepwiki_cache_", "").replace(".json", "").split('_') - - # Expecting repo_type_owner_repo_language - # Example: deepwiki_cache_github_AsyncFuncAI_deepwiki-open_en.json - # parts = [github, AsyncFuncAI, deepwiki-open, en] - if len(parts) >= 4: - repo_type = parts[0] - owner = parts[1] - language = parts[-1] # language is the last part - repo = "_".join(parts[2:-1]) # repo can contain underscores - - project_entries.append( - ProcessedProjectEntry( - id=filename, - owner=owner, - repo=repo, - name=f"{owner}/{repo}", - repo_type=repo_type, - submittedAt=int(stats.st_mtime * 1000), # Convert to milliseconds - language=language - ) - ) - else: - logger.warning(f"Could not parse project details from filename: {filename}") - except Exception as e: - logger.error(f"Error processing file {file_path}: {e}") - continue # Skip this file on error - - # Sort by most recent first - project_entries.sort(key=lambda p: p.submittedAt, reverse=True) - logger.info(f"Found {len(project_entries)} processed project entries.") - return project_entries - - except Exception as e: - logger.error(f"Error listing processed projects from {WIKI_CACHE_DIR}: {e}", exc_info=True) - raise HTTPException(status_code=500, detail="Failed to list processed projects from server cache.") diff --git a/api/chat/__init__.py b/api/chat/__init__.py index 57008ffc1..b95caee4e 100644 --- a/api/chat/__init__.py +++ b/api/chat/__init__.py @@ -1,11 +1,12 @@ -from api.chat._stream import ChatStreamer from api.chat._prompts import prompt_builder +from api.chat._stream import ChatStreamer def is_token_limit_error(exc: Exception) -> bool: error_message = str(exc).lower() return any( - k in error_message for k in ( + k in error_message + for k in ( "maximum context length", "token limit", "too many tokens", diff --git a/api/chat/_prompts.py b/api/chat/_prompts.py index bef5254d6..a95c9cecf 100644 --- a/api/chat/_prompts.py +++ b/api/chat/_prompts.py @@ -1,25 +1,25 @@ -from api.logging_config import setup_logging -from logging import getLogger +from api.logger import get_logger -setup_logging() -logger = getLogger(__name__) +logger = get_logger("chat") def prompt_builder( - system_prompt: str, - query: str, - conversation_history: str | None = None, - file_path: str | None = None, - file_content: str | None = None, - context: str = "", - simplify: bool = False, + system_prompt: str, + query: str, + conversation_history: str | None = None, + file_path: str | None = None, + file_content: str | None = None, + context: str = "", + simplify: bool = False, ) -> str: prompt = f"/no_think {system_prompt}\n\n" if conversation_history: - prompt += f"\n{conversation_history}\n\n" + prompt += ( + f"\n{conversation_history}\n\n" + ) if file_path and file_content: - prompt += f"\n{file_content}\n\n\n" + prompt += f'\n{file_content}\n\n\n' if not simplify: if context.strip(): @@ -27,7 +27,9 @@ def prompt_builder( else: # Add a note that we're skipping RAG due to size constraints or because it's the isolated API logger.info("No context available from RAG") - context_prompt = "Answering without retrieval augmentation.\n\n" + context_prompt = ( + "Answering without retrieval augmentation.\n\n" + ) else: context_prompt = "Answering without retrieval augmentation due to input size constraints.\n\n" diff --git a/api/chat/_stream.py b/api/chat/_stream.py index 14525769f..dcb7f52ab 100644 --- a/api/chat/_stream.py +++ b/api/chat/_stream.py @@ -1,32 +1,29 @@ -import logging -from abc import abstractmethod, ABC - -from typing import TYPE_CHECKING - +from abc import ABC, abstractmethod from collections.abc import AsyncIterator +from typing import TYPE_CHECKING from adalflow.core.types import ModelType + from api.config import ( - OPENROUTER_API_KEY, - OPENAI_API_KEY, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, + GOOGLE_API_KEY, LITELLM_API_KEY, + OPENAI_API_KEY, + OPENROUTER_API_KEY, ) +from api.logger import get_logger if TYPE_CHECKING: from ollama import ChatResponse - from openai.types.chat import ChatCompletionChunk from openai import AsyncStream + from openai.types.chat import ChatCompletionChunk + from api.clients import OpenAIClient MODEL_CFG = dict[str, str | int | float] -# Configure logging -from api.logging_config import setup_logging - -setup_logging() -logger = logging.getLogger(__name__) +logger = get_logger("chat") class ChatStreamer(ABC): @@ -40,7 +37,9 @@ def __init_subclass__(cls, **kwargs) -> None: ChatStreamer._registry[provider] = cls @classmethod - def create(cls, *, provider: str, model: str | None = None, model_config: MODEL_CFG) -> "ChatStreamer": + def create( + cls, *, provider: str, model: str | None = None, model_config: MODEL_CFG + ) -> "ChatStreamer": model = model or model_config.get("model") logger.info("Using %s with model: %s", provider, model) registered = ChatStreamer._registry.get(provider, None) @@ -50,7 +49,9 @@ def create(cls, *, provider: str, model: str | None = None, model_config: MODEL_ @abstractmethod def respond_stream(self, prompt: str) -> AsyncIterator[str]: - raise NotImplementedError(f"{type(self).__name__} does not implement `respond_stream`") + raise NotImplementedError( + f"{type(self).__name__} does not implement `respond_stream`" + ) class OllamaChatStreamer(ChatStreamer): @@ -66,15 +67,16 @@ def __init__(self, *, model: str, model_config: MODEL_CFG): "options": { "temperature": model_config["temperature"], "top_p": model_config["top_p"], - "num_ctx": model_config["num_ctx"] - } + "num_ctx": model_config["num_ctx"], + }, } logger.debug(f"Prompting Ollama with kwargs: {self.model_kwargs}") async def respond_stream(self, prompt: str) -> AsyncIterator[str]: api_kwargs = self.client.convert_inputs_to_api_kwargs( - input=prompt + " /no_think", # todo I think this could be added into model kwargs? + input=prompt + + " /no_think", # todo I think this could be added into model kwargs? model_kwargs=self.model_kwargs, model_type=ModelType.LLM, ) @@ -91,7 +93,7 @@ async def respond_stream(self, prompt: str) -> AsyncIterator[str]: ) text = chunk.message.content if text: - text = text.replace('', '').replace('', '') + text = text.replace("", "").replace("", "") yield text @@ -104,7 +106,9 @@ class OpenRouterChatStreamer(ChatStreamer): def __init__(self, *, model: str, model_config: MODEL_CFG): if not OPENROUTER_API_KEY: - logger.warning("OPENROUTER_API_KEY not configured, but continuing with request") + logger.warning( + "OPENROUTER_API_KEY not configured, but continuing with request" + ) # We'll let the OpenRouterClient handle this and return a friendly error message from api.clients import OpenRouterClient @@ -112,7 +116,7 @@ def __init__(self, *, model: str, model_config: MODEL_CFG): self.model_kwargs = { "model": model, "stream": True, - "temperature": model_config["temperature"] + "temperature": model_config["temperature"], } if "top_k" in model_config: self.model_kwargs["top_k"] = model_config["top_k"] @@ -124,8 +128,8 @@ async def respond_stream(self, prompt: str) -> AsyncIterator[str]: model_type=ModelType.LLM, ) async for chunk in await self.client.acall( - api_kwargs=api_kwargs, - model_type=ModelType.LLM, + api_kwargs=api_kwargs, + model_type=ModelType.LLM, ): yield chunk @@ -139,7 +143,7 @@ def __init__(self, *, model: str, model_config: MODEL_CFG): self.model_kwargs = { "model": model, "stream": True, - "temperature": model_config["temperature"] + "temperature": model_config["temperature"], } # Only add top_p if it exists in the model config if "top_p" in model_config: @@ -153,9 +157,7 @@ def _build_client(self) -> "OpenAIClient": async def respond_stream(self, prompt: str) -> AsyncIterator[str]: api_kwargs = self.client.convert_inputs_to_api_kwargs( - input=prompt, - model_kwargs=self.model_kwargs, - model_type=ModelType.LLM + input=prompt, model_kwargs=self.model_kwargs, model_type=ModelType.LLM ) response: "AsyncStream[ChatCompletionChunk]" = await self.client.acall( api_kwargs=api_kwargs, @@ -164,9 +166,9 @@ async def respond_stream(self, prompt: str) -> AsyncIterator[str]: async for chunk in response: if ( - chunk.choices and - chunk.choices[0].delta is not None and - chunk.choices[0].delta.content is not None + chunk.choices + and chunk.choices[0].delta is not None + and chunk.choices[0].delta.content is not None ): yield chunk.choices[0].delta.content @@ -186,6 +188,7 @@ def __init__(self, *, model: str, model_config: MODEL_CFG): def _build_client(self): from api.clients import OpenAIClient + return OpenAIClient() @@ -199,6 +202,7 @@ class AzureChatStreamer(_OpenAICompatStreamer): def _build_client(self): from api.clients import AzureAIClient + return AzureAIClient() @@ -211,13 +215,16 @@ class LiteLLMChatStreamer(_OpenAICompatStreamer): def __init__(self, *, model: str, model_config: MODEL_CFG): if not LITELLM_API_KEY: - logger.warning("LITELLM_API_KEY not configured, but continuing with request") + logger.warning( + "LITELLM_API_KEY not configured, but continuing with request" + ) # We'll let the OpenAIClient handle this and return an error message super().__init__(model=model, model_config=model_config) def _build_client(self): from api.clients import LiteLLMClient + return LiteLLMClient() @@ -230,7 +237,9 @@ class BedrockChatStreamer(ChatStreamer): def __init__(self, *, model: str, model_config: MODEL_CFG): if not AWS_ACCESS_KEY_ID or not AWS_SECRET_ACCESS_KEY: - logger.warning("AWS_ACCESS_KEY_ID or AWS_SECRET_ACCESS_KEY not configured, but continuing with request") + logger.warning( + "AWS_ACCESS_KEY_ID or AWS_SECRET_ACCESS_KEY not configured, but continuing with request" + ) # We'll let the BedrockClient handle this and return an error message from api.clients import BedrockClient @@ -238,8 +247,8 @@ def __init__(self, *, model: str, model_config: MODEL_CFG): self.model_kwargs = {"model": model} for key in ( - "temperature", - "top_p", + "temperature", + "top_p", ): if key in model_config: self.model_kwargs[key] = model_config[key] @@ -299,13 +308,15 @@ def __init__(self, *, model: str, model_config: MODEL_CFG): import google.generativeai as genai from google.generativeai.types import GenerationConfig + genai.configure(api_key=GOOGLE_API_KEY) + self.client = genai.GenerativeModel( model_name=model, generation_config=GenerationConfig( temperature=model_config.get("temperature"), top_p=model_config.get("top_p"), top_k=model_config.get("top_k"), - ) + ), ) async def respond_stream(self, prompt: str) -> AsyncIterator[str]: @@ -313,3 +324,51 @@ async def respond_stream(self, prompt: str) -> AsyncIterator[str]: async for chunk in response: if hasattr(chunk, "text"): yield chunk.text + + +class AnthropicChatStreamer(ChatStreamer): + provider = "anthropic" + + def __init__(self, *, model: str, model_config: MODEL_CFG): + from api.config import ( + AWS_ACCESS_KEY_ID, + AWS_REGION, + AWS_SECRET_ACCESS_KEY, + AWS_SESSION_TOKEN, + ) + + from ..clients.anthropic import AnthropicBedrockClient + + self.client = AnthropicBedrockClient( + aws_access_key_id=AWS_ACCESS_KEY_ID, + aws_session_token=AWS_SESSION_TOKEN, + aws_secret_access_key=AWS_SECRET_ACCESS_KEY, + aws_region=AWS_REGION, + ) + + self.model_kwargs = { + "model": model, + "stream": True, + "max_tokens": model_config["max_tokens"], # max_tokens must exist + } + for key in ( + "temperature", + "top_p", + ): + if key in model_config: + self.model_kwargs[key] = model_config[key] + + async def respond_stream(self, prompt: str) -> AsyncIterator[str]: + api_kwargs = self.client.convert_inputs_to_api_kwargs( + input=prompt, + model_kwargs=self.model_kwargs, + model_type=ModelType.LLM, + ) + + response = await self.client.acall( + api_kwargs=api_kwargs, + model_type=ModelType.LLM, + ) + async for chunk in response: + if chunk.type == "content_block_delta" and chunk.delta.type == "text_delta": + yield chunk.delta.text diff --git a/api/chat_model.py b/api/chat_model.py deleted file mode 100644 index 4af8a03d8..000000000 --- a/api/chat_model.py +++ /dev/null @@ -1,65 +0,0 @@ -from typing import Optional, List, Literal -from urllib.parse import unquote - -from pydantic import BaseModel, Field, field_validator - -# Models for the API -class ChatMessage(BaseModel): - role: str # 'user' or 'assistant' - content: str - mode: Literal["normal", "deep_research"] = Field(default="normal") - -class ChatCompletionRequest(BaseModel): - """ - Model for requesting a chat completion. - """ - repo_url: str = Field(..., description="URL of the repository to query") - messages: List[ChatMessage] = Field(..., description="List of chat messages") - filePath: Optional[str] = Field(None, description="Optional path to a file in the repository to include in the prompt") - token: Optional[str] = Field(None, description="Personal access token for private repositories") - type: Optional[Literal["local", "github", "gitlab", "bitbucket"]] = Field( - "github", - description="Type of repository (e.g., 'local', 'github', 'gitlab', 'bitbucket')", - ) - - # model parameters - provider: str = Field("google", description="Model provider (google, openai, openrouter, ollama, bedrock, azure, dashscope)") - model: Optional[str] = Field(None, description="Model name for the specified provider") - - language: Optional[str] = Field("en", description="Language for content generation (e.g., 'en', 'ja', 'zh', 'es', 'kr', 'vi')") - - research_iteration: int = Field( - default=1, - ge=1, - description="Current deep research iteration (1-based). Only used when the request is in deep_research mode.", - ) - - excluded_dirs: List[str] = Field( - default_factory=list, - description="List or newline-separated string of directories to exclude from processing", - ) - excluded_files: List[str] = Field( - default_factory=list, - description="List or newline-separated string of file patterns to exclude from processing", - ) - included_dirs: List[str] = Field( - default_factory=list, - description="List or newline-separated string of directories to include exclusively", - ) - included_files: List[str] = Field( - default_factory=list, - description="List or newline-separated string of file patterns to include exclusively", - ) - - @field_validator( - "excluded_dirs", - "excluded_files", - "included_dirs", - "included_files", - mode="before", - ) - @classmethod - def validate_path(cls, value: list[str] | str) -> list[str]: - if isinstance(value, str): - value = [unquote(path) for path in value.strip().split("\n") if path] - return value diff --git a/api/clients/__init__.py b/api/clients/__init__.py index 59661eba1..d68b77cea 100644 --- a/api/clients/__init__.py +++ b/api/clients/__init__.py @@ -4,9 +4,11 @@ from adalflow.components.model_client import ( AzureAIClient, - OpenAIClient, GoogleGenAIClient, + OpenAIClient, ) + +from .anthropic import AnthropicBedrockClient from .bedrock import BedrockClient from .dashscope import DashscopeClient from .google_embedder import GoogleEmbedderClient @@ -15,6 +17,7 @@ from .openrouter import OpenRouterClient __all__ = [ + "AnthropicBedrockClient", "AzureAIClient", "BedrockClient", "DashscopeClient", diff --git a/api/clients/anthropic.py b/api/clients/anthropic.py new file mode 100644 index 000000000..45eb366b8 --- /dev/null +++ b/api/clients/anthropic.py @@ -0,0 +1,183 @@ +from typing import Any + +import backoff +from adalflow.core.model_client import ModelClient +from adalflow.core.types import ModelType +from anthropic import ( + APITimeoutError, + BadRequestError, + InternalServerError, + RateLimitError, + UnprocessableEntityError, +) + + +class AnthropicBedrockClient(ModelClient): + def __init__( + self, + aws_access_key_id: str | None = None, + aws_secret_access_key: str | None = None, + aws_session_token: str | None = None, + aws_region: str | None = None, + **kwargs, + ): + """A client wrapper for interacting with Anthropic Bedrock API. + + This class currently only provides chat completion API calls. + + Parameters + ---------- + aws_access_key_id: str, optional. + AWS access key ID. Defaults to None. + aws_secret_access_key: str, optional. + AWS secret access key. Defaults to None. + aws_session_token: str, optional. + AWS session token. Defaults to None. + aws_region: str, optional. + AWS region. Defaults to None. + + Examples + -------- + .. code-block:: python + + from api.clients import AnthropicBedrockClient + from adalflow.core.types import ModelType + client = AnthropicBedrockClient() + + # chat completion API + api_kwargs = client.convert_inputs_to_api_kwargs( + inputs="Hello World!", + model_kwargs={"max_tokens": 2048}, + model_type=ModelType.LLM, + ) + + # synchronous API call + response = client.call(**api_kwargs, model_type=ModelType.LLM) + + # asynchronous API call + response = await client.acall(**api_kwargs, model_type=ModelType.LLM) + + References + ---------- + - [AWS Bedrock API Documentation](https://platform.claude.com/docs/zh-TW/build-with-claude/claude-in-amazon-bedrock#making-your-first-request) + + """ + super().__init__() + self._aws_client_kwargs = dict( + aws_access_key=aws_access_key_id, + aws_secret_key=aws_secret_access_key, + aws_session_token=aws_session_token, + aws_region=aws_region, + ) + + def init_sync_client(self): + from anthropic import AnthropicBedrock + + return AnthropicBedrock(**self._aws_client_kwargs) + + def init_async_client(self): + from anthropic import AsyncAnthropicBedrock + + return AsyncAnthropicBedrock(**self._aws_client_kwargs) + + @property + def async_client(self): + if getattr(self, "_async_client", None) is None: + self._async_client = self.init_async_client() + return self._async_client + + @async_client.setter + def async_client(self, value): + from anthropic import AsyncAnthropicBedrock + + if value is None and isinstance( + getattr(self, "_async_client", None), AsyncAnthropicBedrock + ): + self.async_client.close() + self._async_client = value + + @property + def sync_client(self): + if getattr(self, "_sync_client", None) is None: + self._sync_client = self.init_sync_client() + return self._sync_client + + @sync_client.setter + def sync_client(self, value): + from anthropic import AnthropicBedrock + + if value is None and isinstance( + getattr(self, "_sync_client", None), AnthropicBedrock + ): + self.sync_client.close() + self._sync_client = value + + def convert_inputs_to_api_kwargs( + self, + input: Any = None, + model_kwargs: dict | None = None, + model_type: ModelType = ModelType.UNDEFINED, + ) -> dict[str, Any]: + final_model_kwargs = model_kwargs.copy() if model_kwargs else {} + if model_type == ModelType.LLM: + if isinstance(input, str): + input = [{"role": "user", "content": input}] + elif not isinstance(input, list): + raise ValueError( + f"input must be a string or a list or messages, get {type(input).__name__}" + ) + final_model_kwargs["messages"] = input + return final_model_kwargs + + raise ValueError(f"model_type {model_type} is not supported") + + @backoff.on_exception( + backoff.expo, + ( + APITimeoutError, + InternalServerError, + RateLimitError, + ), + max_time=5, + ) + def call( + self, api_kwargs: dict | None = None, model_type: ModelType | None = None + ) -> Any: + api_kwargs = api_kwargs or {} + if model_type != ModelType.LLM: + raise ValueError(f"model_type {model_type} is not supported") + + if "model" not in api_kwargs: + raise ValueError("must provide 'model' parameter in api_kwargs") + + return self.sync_client.messages.create(**api_kwargs) + + @backoff.on_exception( + backoff.expo, + ( + APITimeoutError, + InternalServerError, + RateLimitError, + UnprocessableEntityError, + BadRequestError, + ), + max_time=5, + ) + async def acall( + self, api_kwargs: dict | None = None, model_type: ModelType | None = None + ) -> Any: + api_kwargs = api_kwargs or {} + if model_type != ModelType.LLM: + raise ValueError(f"model_type {model_type} is not supported") + + if "model" not in api_kwargs: + raise ValueError("must provide 'model' parameter in api_kwargs") + + return await self.async_client.messages.create(**api_kwargs) + + def to_dict(self, exclude: list[str] | None = None) -> dict[str, Any]: + return self._aws_client_kwargs + + @classmethod + def from_dict(cls, data: dict[str, Any]): + return cls(**data) diff --git a/api/clients/bedrock.py b/api/clients/bedrock.py index fa0b5097b..e74659eb1 100644 --- a/api/clients/bedrock.py +++ b/api/clients/bedrock.py @@ -1,21 +1,19 @@ """AWS Bedrock ModelClient integration.""" -import os import json -import logging +from typing import Any, Dict, List, Optional, Sequence + +import backoff import boto3 import botocore -import backoff -from typing import Dict, Any, Optional, List, Generator, Union, AsyncGenerator, Sequence - from adalflow.core.model_client import ModelClient -from adalflow.core.types import ModelType, GeneratorOutput, EmbedderOutput +from adalflow.core.types import EmbedderOutput, ModelType # Configure logging -from api.logging_config import setup_logging +from api.logger import get_logger + +log = get_logger(__name__) -setup_logging() -log = logging.getLogger(__name__) class BedrockClient(ModelClient): __doc__ = r"""A component wrapper for the AWS Bedrock API client. @@ -43,10 +41,10 @@ def __init__( aws_region: Optional[str] = None, aws_role_arn: Optional[str] = None, *args, - **kwargs + **kwargs, ) -> None: """Initialize the AWS Bedrock client. - + Args: aws_access_key_id: AWS access key ID. If not provided, will use environment variable AWS_ACCESS_KEY_ID. aws_secret_access_key: AWS secret access key. If not provided, will use environment variable AWS_SECRET_ACCESS_KEY. @@ -57,10 +55,10 @@ def __init__( super().__init__(*args, **kwargs) from api.config import ( AWS_ACCESS_KEY_ID, - AWS_SECRET_ACCESS_KEY, - AWS_SESSION_TOKEN, AWS_REGION, AWS_ROLE_ARN, + AWS_SECRET_ACCESS_KEY, + AWS_SESSION_TOKEN, ) self.aws_access_key_id = aws_access_key_id or AWS_ACCESS_KEY_ID @@ -68,7 +66,7 @@ def __init__( self.aws_session_token = aws_session_token or AWS_SESSION_TOKEN self.aws_region = aws_region or AWS_REGION or "us-east-1" self.aws_role_arn = aws_role_arn or AWS_ROLE_ARN - + self.sync_client = self.init_sync_client() self.async_client = None # Initialize async client only when needed @@ -94,10 +92,10 @@ def __getstate__(self): """ state = self.__dict__.copy() # Remove the unpicklable client instances - if 'sync_client' in state: - del state['sync_client'] - if 'async_client' in state: - del state['async_client'] + if "sync_client" in state: + del state["sync_client"] + if "async_client" in state: + del state["async_client"] return state def __setstate__(self, state): @@ -118,34 +116,32 @@ def init_sync_client(self): aws_access_key_id=self.aws_access_key_id, aws_secret_access_key=self.aws_secret_access_key, aws_session_token=self.aws_session_token, - region_name=self.aws_region + region_name=self.aws_region, ) - + # If a role ARN is provided, assume that role if self.aws_role_arn: - sts_client = session.client('sts') + sts_client = session.client("sts") assumed_role = sts_client.assume_role( - RoleArn=self.aws_role_arn, - RoleSessionName="DeepWikiBedrockSession" + RoleArn=self.aws_role_arn, RoleSessionName="DeepWikiBedrockSession" ) - credentials = assumed_role['Credentials'] - + credentials = assumed_role["Credentials"] + # Create a new session with the assumed role credentials session = boto3.Session( - aws_access_key_id=credentials['AccessKeyId'], - aws_secret_access_key=credentials['SecretAccessKey'], - aws_session_token=credentials['SessionToken'], - region_name=self.aws_region + aws_access_key_id=credentials["AccessKeyId"], + aws_secret_access_key=credentials["SecretAccessKey"], + aws_session_token=credentials["SessionToken"], + region_name=self.aws_region, ) - + # Create the Bedrock client bedrock_runtime = session.client( - service_name='bedrock-runtime', - region_name=self.aws_region + service_name="bedrock-runtime", region_name=self.aws_region ) - + return bedrock_runtime - + except Exception as e: log.error(f"Error initializing AWS Bedrock client: {str(e)}") # Return None to indicate initialization failure @@ -153,7 +149,7 @@ def init_sync_client(self): def init_async_client(self): """Initialize the asynchronous AWS Bedrock client. - + Note: boto3 doesn't have native async support, so we'll use the sync client in async methods and handle async behavior at a higher level. """ @@ -162,10 +158,10 @@ def init_async_client(self): def _get_model_provider(self, model_id: str) -> str: """Extract the provider from the model ID. - + Args: model_id: The model inference ID, e.g., "anthropic.claude-3-sonnet-20240229-v1:0", "global.anthropic.claude-sonnet-4-5-20250929-v1:0", or "global.cohere.embed-v4:0" - + Returns: The provider name, e.g., "anthropic" """ @@ -180,14 +176,16 @@ def _get_model_provider(self, model_id: str) -> str: # Default to Amazon if format is unexpected return "amazon" - def _format_prompt_for_provider(self, provider: str, prompt: str, messages=None) -> Dict[str, Any]: + def _format_prompt_for_provider( + self, provider: str, prompt: str, messages=None + ) -> Dict[str, Any]: """Format the prompt according to the provider's requirements. - + Args: provider: The provider name, e.g., "anthropic" prompt: The prompt text messages: Optional list of messages for chat models - + Returns: A dictionary with the formatted prompt """ @@ -198,14 +196,18 @@ def _format_prompt_for_provider(self, provider: str, prompt: str, messages=None) formatted_messages = [] for msg in messages: role = "user" if msg.get("role") == "user" else "assistant" - formatted_messages.append({ - "role": role, - "content": [{"type": "text", "text": msg.get("content", "")}] - }) + formatted_messages.append( + { + "role": role, + "content": [ + {"type": "text", "text": msg.get("content", "")} + ], + } + ) return { "anthropic_version": "bedrock-2023-05-31", "messages": formatted_messages, - "max_tokens": 4096 + "max_tokens": 4096, } else: # Format as a single prompt @@ -214,7 +216,7 @@ def _format_prompt_for_provider(self, provider: str, prompt: str, messages=None) "messages": [ {"role": "user", "content": [{"type": "text", "text": prompt}]} ], - "max_tokens": 4096 + "max_tokens": 4096, } elif provider == "amazon": # Format for Amazon Titan models @@ -224,24 +226,19 @@ def _format_prompt_for_provider(self, provider: str, prompt: str, messages=None) "maxTokenCount": 4096, "stopSequences": [], "temperature": 0.7, - "topP": 0.8 - } + "topP": 0.8, + }, } elif provider == "cohere": # Format for Cohere models - return { - "prompt": prompt, - "max_tokens": 4096, - "temperature": 0.7, - "p": 0.8 - } + return {"prompt": prompt, "max_tokens": 4096, "temperature": 0.7, "p": 0.8} elif provider == "ai21": # Format for AI21 models return { "prompt": prompt, "maxTokens": 4096, "temperature": 0.7, - "topP": 0.8 + "topP": 0.8, } else: # Default format @@ -249,11 +246,11 @@ def _format_prompt_for_provider(self, provider: str, prompt: str, messages=None) def _extract_response_text(self, provider: str, response: Dict[str, Any]) -> str: """Extract the generated text from the response. - + Args: provider: The provider name, e.g., "anthropic" response: The response from the Bedrock API - + Returns: The generated text """ @@ -283,15 +280,20 @@ def parse_embedding_response(self, response: Any) -> EmbedderOutput: if isinstance(response, dict) and "embeddings" in response: embeddings = response.get("embeddings") or [] embedding_data = [ - Embedding(embedding=emb, index=i) for i, emb in enumerate(embeddings) + Embedding(embedding=emb, index=i) + for i, emb in enumerate(embeddings) ] elif isinstance(response, dict) and "embedding" in response: emb = response.get("embedding") or [] embedding_data = [Embedding(embedding=emb, index=0)] else: - raise ValueError(f"Unexpected embedding response type: {type(response)}") + raise ValueError( + f"Unexpected embedding response type: {type(response)}" + ) - return EmbedderOutput(data=embedding_data, error=None, raw_response=response) + return EmbedderOutput( + data=embedding_data, error=None, raw_response=response + ) except Exception as e: log.error(f"Error parsing Bedrock embedding response: {e}") return EmbedderOutput(data=[], error=str(e), raw_response=response) @@ -304,35 +306,39 @@ def parse_embedding_response(self, response: Any) -> EmbedderOutput: def call(self, api_kwargs: Dict = None, model_type: ModelType = None) -> Any: """Make a synchronous call to the AWS Bedrock API.""" api_kwargs = api_kwargs or {} - + # Check if client is initialized if not self.sync_client: error_msg = "AWS Bedrock client not initialized. Check your AWS credentials and region." log.error(error_msg) return error_msg - + if model_type == ModelType.LLM: - model_id = api_kwargs.get("model", "anthropic.claude-3-sonnet-20240229-v1:0") + model_id = api_kwargs.get( + "model", "anthropic.claude-3-sonnet-20240229-v1:0" + ) provider = self._get_model_provider(model_id) - + # Get the prompt from api_kwargs prompt = api_kwargs.get("input", "") messages = api_kwargs.get("messages") - + # Format the prompt according to the provider request_body = self._format_prompt_for_provider(provider, prompt, messages) - + # Add model parameters if provided if "temperature" in api_kwargs: if provider == "anthropic": request_body["temperature"] = api_kwargs["temperature"] elif provider == "amazon": - request_body["textGenerationConfig"]["temperature"] = api_kwargs["temperature"] + request_body["textGenerationConfig"]["temperature"] = api_kwargs[ + "temperature" + ] elif provider == "cohere": request_body["temperature"] = api_kwargs["temperature"] elif provider == "ai21": request_body["temperature"] = api_kwargs["temperature"] - + if "top_p" in api_kwargs: if provider == "anthropic": request_body["top_p"] = api_kwargs["top_p"] @@ -342,25 +348,22 @@ def call(self, api_kwargs: Dict = None, model_type: ModelType = None) -> Any: request_body["p"] = api_kwargs["top_p"] elif provider == "ai21": request_body["topP"] = api_kwargs["top_p"] - + # Convert request body to JSON body = json.dumps(request_body) - + try: # Make the API call - response = self.sync_client.invoke_model( - modelId=model_id, - body=body - ) - + response = self.sync_client.invoke_model(modelId=model_id, body=body) + # Parse the response response_body = json.loads(response["body"].read()) - + # Extract the generated text generated_text = self._extract_response_text(provider, response_body) - + return generated_text - + except Exception as e: log.error(f"Error calling AWS Bedrock API: {str(e)}") return f"Error: {str(e)}" @@ -400,7 +403,9 @@ def call(self, api_kwargs: Dict = None, model_type: ModelType = None) -> Any: emb = response_body.get("embedding") if emb is None: - raise ValueError(f"Embedding not found in response: {response_body}") + raise ValueError( + f"Embedding not found in response: {response_body}" + ) embeddings.append(emb) elif provider == "cohere": @@ -426,12 +431,18 @@ def call(self, api_kwargs: Dict = None, model_type: ModelType = None) -> Any: elif isinstance(batch_embeddings, dict) and "float" in batch_embeddings: embeddings = batch_embeddings["float"] else: - raise ValueError(f"Embeddings not found in response: {response_body}") + raise ValueError( + f"Embeddings not found in response: {response_body}" + ) else: - raise NotImplementedError(f"Embedding provider '{provider}' is not supported by the Bedrock client.") + raise NotImplementedError( + f"Embedding provider '{provider}' is not supported by the Bedrock client." + ) return {"embeddings": embeddings, "raw_responses": raw_responses} else: - raise ValueError(f"Model type {model_type} is not supported by AWS Bedrock client") + raise ValueError( + f"Model type {model_type} is not supported by AWS Bedrock client" + ) async def acall(self, api_kwargs: Dict = None, model_type: ModelType = None) -> Any: """Make an asynchronous call to the AWS Bedrock API.""" @@ -445,17 +456,19 @@ def convert_inputs_to_api_kwargs( """Convert inputs to API kwargs for AWS Bedrock.""" model_kwargs = model_kwargs or {} api_kwargs = {} - + if model_type == ModelType.LLM: - api_kwargs["model"] = model_kwargs.get("model", "anthropic.claude-3-sonnet-20240229-v1:0") + api_kwargs["model"] = model_kwargs.get( + "model", "anthropic.claude-3-sonnet-20240229-v1:0" + ) api_kwargs["input"] = input - + # Add model parameters if "temperature" in model_kwargs: api_kwargs["temperature"] = model_kwargs["temperature"] if "top_p" in model_kwargs: api_kwargs["top_p"] = model_kwargs["top_p"] - + return api_kwargs elif model_type == ModelType.EMBEDDER: if isinstance(input, str): @@ -465,9 +478,13 @@ def convert_inputs_to_api_kwargs( else: raise TypeError("input must be a string or sequence of strings") - api_kwargs["model"] = model_kwargs.get("model", "amazon.titan-embed-text-v2:0") + api_kwargs["model"] = model_kwargs.get( + "model", "amazon.titan-embed-text-v2:0" + ) api_kwargs["input"] = inputs api_kwargs["model_kwargs"] = model_kwargs return api_kwargs else: - raise ValueError(f"Model type {model_type} is not supported by AWS Bedrock client") + raise ValueError( + f"Model type {model_type} is not supported by AWS Bedrock client" + ) diff --git a/api/clients/dashscope.py b/api/clients/dashscope.py index 5c9174431..a9ec66102 100644 --- a/api/clients/dashscope.py +++ b/api/clients/dashscope.py @@ -2,80 +2,81 @@ import os import pickle +from copy import deepcopy from typing import ( - Dict, - Optional, Any, Callable, + Dict, Generator, - Union, - Literal, List, - Sequence, + Literal, + Optional, + Union, ) -import logging import backoff -from copy import deepcopy + +from adalflow.utils.lazy_import import OptionalPackages, safe_import from tqdm import tqdm -# optional import -from adalflow.utils.lazy_import import safe_import, OptionalPackages +import adalflow.core.functional as F +from adalflow.components.model_client.utils import parse_embedding_response +from adalflow.core.component import DataComponent +from adalflow.core.embedder import ( + BatchEmbedderInputType, + BatchEmbedderOutputType, +) +from adalflow.core.model_client import ModelClient +from adalflow.core.types import ( + CompletionUsage, + Document, + EmbedderInputType, + EmbedderOutput, + EmbedderOutputType, + Embedding, + GeneratorOutput, + ModelType, +) + +# ruff: noqa: E402 openai = safe_import(OptionalPackages.OPENAI.value[0], OptionalPackages.OPENAI.value[1]) +# ruff: noqa: E402 -from openai import OpenAI, AsyncOpenAI, Stream from openai import ( APITimeoutError, + AsyncOpenAI, + BadRequestError, InternalServerError, + OpenAI, RateLimitError, + Stream, UnprocessableEntityError, - BadRequestError, ) from openai.types import ( Completion, CreateEmbeddingResponse, ) -from openai.types.chat import ChatCompletionChunk, ChatCompletion - -from adalflow.core.model_client import ModelClient -from adalflow.core.types import ( - ModelType, - EmbedderOutput, - CompletionUsage, - GeneratorOutput, - Document, - Embedding, - EmbedderOutputType, - EmbedderInputType, -) -from adalflow.core.component import DataComponent -from adalflow.core.embedder import ( - BatchEmbedderOutputType, - BatchEmbedderInputType, -) -import adalflow.core.functional as F -from adalflow.components.model_client.utils import parse_embedding_response +from openai.types.chat import ChatCompletion, ChatCompletionChunk -from api.logging_config import setup_logging +from api.logger import get_logger -# # Disable tqdm progress bars -# os.environ["TQDM_DISABLE"] = "1" +log = get_logger(__name__) -setup_logging() -log = logging.getLogger(__name__) def get_first_message_content(completion: ChatCompletion) -> str: """When we only need the content of the first message.""" log.info(f"šŸ” get_first_message_content called with: {type(completion)}") log.debug(f"raw completion: {completion}") - + try: - if hasattr(completion, 'choices') and len(completion.choices) > 0: + if hasattr(completion, "choices") and len(completion.choices) > 0: choice = completion.choices[0] - if hasattr(choice, 'message') and hasattr(choice.message, 'content'): + if hasattr(choice, "message") and hasattr(choice.message, "content"): content = choice.message.content - log.info(f"āœ… Successfully extracted content: {type(content)}, length: {len(content) if content else 0}") + log.info( + f"āœ… Successfully extracted content: {type(content)}, length: {len(content) if content else 0}" + ) return content else: log.error("āŒ Choice doesn't have message.content") @@ -105,7 +106,7 @@ class DashscopeClient(ModelClient): """A component wrapper for the Dashscope (Alibaba Cloud) API client. Dashscope provides access to Alibaba Cloud's Qwen and other models through an OpenAI-compatible API. - + Args: api_key (Optional[str], optional): Dashscope API key. Defaults to None. workspace_id (Optional[str], optional): Dashscope workspace ID. Defaults to None. @@ -134,10 +135,12 @@ def __init__( self._env_api_key_name = env_api_key_name self._env_workspace_id_name = env_workspace_id_name self._env_base_url_name = env_base_url_name - self.base_url = base_url or os.getenv(self._env_base_url_name, "https://dashscope.aliyuncs.com/compatible-mode/v1") + self.base_url = base_url or os.getenv( + self._env_base_url_name, "https://dashscope.aliyuncs.com/compatible-mode/v1" + ) self.sync_client = self.init_sync_client() self.async_client = None - + # Force use of get_first_message_content to ensure string output self.chat_completion_parser = get_first_message_content self._input_type = input_type @@ -146,52 +149,54 @@ def __init__( def _prepare_client_config(self): """ Private helper method to prepare client configuration. - + Returns: tuple: (api_key, workspace_id, base_url) for client initialization - + Raises: ValueError: If API key is not provided """ api_key = self._api_key or os.getenv(self._env_api_key_name) workspace_id = self._workspace_id or os.getenv(self._env_workspace_id_name) - + if not api_key: raise ValueError( f"Environment variable {self._env_api_key_name} must be set" ) - + if not workspace_id: - log.warning(f"Environment variable {self._env_workspace_id_name} not set. Some features may not work properly.") - + log.warning( + f"Environment variable {self._env_workspace_id_name} not set. Some features may not work properly." + ) + # For Dashscope, we need to include the workspace ID in the base URL if provided base_url = self.base_url if workspace_id: # Add workspace ID to headers or URL as required by Dashscope base_url = f"{self.base_url.rstrip('/')}" - + return api_key, workspace_id, base_url def init_sync_client(self): api_key, workspace_id, base_url = self._prepare_client_config() - + client = OpenAI(api_key=api_key, base_url=base_url) - + # Store workspace_id for later use in requests if workspace_id: client._workspace_id = workspace_id - + return client def init_async_client(self): api_key, workspace_id, base_url = self._prepare_client_config() - + client = AsyncOpenAI(api_key=api_key, base_url=base_url) - + # Store workspace_id for later use in requests if workspace_id: client._workspace_id = workspace_id - + return client def parse_chat_completion( @@ -203,17 +208,18 @@ def parse_chat_completion( # If the completion is already a GeneratorOutput, return it directly (prevent recursion) if isinstance(completion, GeneratorOutput): return completion - + # Check if it's a ChatCompletion object (non-streaming response) - if hasattr(completion, 'choices') and hasattr(completion, 'usage'): + if hasattr(completion, "choices") and hasattr(completion, "usage"): # ALWAYS extract the string content directly try: # Direct extraction of message content - if (hasattr(completion, 'choices') and - len(completion.choices) > 0 and - hasattr(completion.choices[0], 'message') and - hasattr(completion.choices[0].message, 'content')): - + if ( + hasattr(completion, "choices") + and len(completion.choices) > 0 + and hasattr(completion.choices[0], "message") + and hasattr(completion.choices[0].message, "content") + ): content = completion.choices[0].message.content if isinstance(content, str): parsed_data = content @@ -222,11 +228,11 @@ def parse_chat_completion( else: # Fallback: convert entire completion to string parsed_data = str(completion) - - except Exception as e: + + except Exception: # Ultimate fallback parsed_data = str(completion) - + return GeneratorOutput( data=parsed_data, usage=CompletionUsage( @@ -244,12 +250,12 @@ def parse_chat_completion( if chunk.choices[0].delta.content: content_parts.append(chunk.choices[0].delta.content) # Try to get usage info from the last chunk - if hasattr(chunk, 'usage') and chunk.usage: + if hasattr(chunk, "usage") and chunk.usage: usage_info = chunk.usage - + # Join all content parts into a single string - full_content = ''.join(content_parts) - + full_content = "".join(content_parts) + # Create usage object usage = None if usage_info: @@ -258,11 +264,9 @@ def parse_chat_completion( prompt_tokens=usage_info.prompt_tokens, total_tokens=usage_info.total_tokens, ) - + return GeneratorOutput( - data=full_content, - usage=usage, - raw_response="streaming" + data=full_content, usage=usage, raw_response="streaming" ) except Exception as e: log.error(f"Error parsing completion: {e}") @@ -293,9 +297,11 @@ def parse_embedding_response( if result.data: log.info(f"šŸ” Number of embeddings: {len(result.data)}") if len(result.data) > 0: - log.info(f"šŸ” First embedding length: {len(result.data[0].embedding) if hasattr(result.data[0], 'embedding') else 'N/A'}") + log.info( + f"šŸ” First embedding length: {len(result.data[0].embedding) if hasattr(result.data[0], 'embedding') else 'N/A'}" + ) else: - log.warning(f"šŸ” No embedding data found in result") + log.warning("šŸ” No embedding data found in result") return result except Exception as e: log.error(f"šŸ” Error parsing DashScope embedding response: {e}") @@ -310,7 +316,7 @@ def convert_inputs_to_api_kwargs( ) -> Dict: """Convert inputs to API kwargs.""" final_model_kwargs = model_kwargs.copy() - + if model_type == ModelType.LLM: messages = [] if isinstance(input, str): @@ -319,22 +325,21 @@ def convert_inputs_to_api_kwargs( messages = input else: raise ValueError(f"Unsupported input type: {type(input)}") - - api_kwargs = { - "messages": messages, - **final_model_kwargs - } - + + api_kwargs = {"messages": messages, **final_model_kwargs} + # Add workspace ID to headers if available - workspace_id = getattr(self.sync_client, '_workspace_id', None) or getattr(self.async_client, '_workspace_id', None) + workspace_id = getattr(self.sync_client, "_workspace_id", None) or getattr( + self.async_client, "_workspace_id", None + ) if workspace_id: # Dashscope may require workspace ID in headers - if 'extra_headers' not in api_kwargs: - api_kwargs['extra_headers'] = {} - api_kwargs['extra_headers']['X-DashScope-WorkSpace'] = workspace_id - + if "extra_headers" not in api_kwargs: + api_kwargs["extra_headers"] = {} + api_kwargs["extra_headers"]["X-DashScope-WorkSpace"] = workspace_id + return api_kwargs - + elif model_type == ModelType.EMBEDDER: # Convert Documents to text strings for embedding processed_input = input @@ -342,7 +347,7 @@ def convert_inputs_to_api_kwargs( # Extract text from Document objects processed_input = [] for item in input: - if hasattr(item, 'text'): + if hasattr(item, "text"): # It's a Document object, extract text processed_input.append(item.text) elif isinstance(item, str): @@ -351,7 +356,7 @@ def convert_inputs_to_api_kwargs( else: # Try to convert to string processed_input.append(str(item)) - elif hasattr(input, 'text'): + elif hasattr(input, "text"): # Single Document object processed_input = input.text elif isinstance(input, str): @@ -360,19 +365,18 @@ def convert_inputs_to_api_kwargs( else: # Convert to string as fallback processed_input = str(input) - - api_kwargs = { - "input": processed_input, - **final_model_kwargs - } - + + api_kwargs = {"input": processed_input, **final_model_kwargs} + # Add workspace ID to headers if available - workspace_id = getattr(self.sync_client, '_workspace_id', None) or getattr(self.async_client, '_workspace_id', None) + workspace_id = getattr(self.sync_client, "_workspace_id", None) or getattr( + self.async_client, "_workspace_id", None + ) if workspace_id: - if 'extra_headers' not in api_kwargs: - api_kwargs['extra_headers'] = {} - api_kwargs['extra_headers']['X-DashScope-WorkSpace'] = workspace_id - + if "extra_headers" not in api_kwargs: + api_kwargs["extra_headers"] = {} + api_kwargs["extra_headers"]["X-DashScope-WorkSpace"] = workspace_id + return api_kwargs else: raise ValueError(f"model_type {model_type} is not supported") @@ -399,7 +403,7 @@ def call(self, api_kwargs: Dict = {}, model_type: ModelType = ModelType.UNDEFINE api_kwargs["extra_body"] = extra_body completion = self.sync_client.chat.completions.create(**api_kwargs) - + if api_kwargs.get("stream", False): return handle_streaming_response(completion) else: @@ -407,15 +411,17 @@ def call(self, api_kwargs: Dict = {}, model_type: ModelType = ModelType.UNDEFINE elif model_type == ModelType.EMBEDDER: # Extract input texts from api_kwargs texts = api_kwargs.get("input", []) - + if not texts: log.warning("😭 No input texts provided") - return EmbedderOutput(data=[], error="No input texts provided", raw_response=None) - + return EmbedderOutput( + data=[], error="No input texts provided", raw_response=None + ) + # Ensure texts is a list if isinstance(texts, str): texts = [texts] - + # Filter out empty or None texts - following HuggingFace client pattern valid_texts = [] valid_indices = [] @@ -424,37 +430,57 @@ def call(self, api_kwargs: Dict = {}, model_type: ModelType = ModelType.UNDEFINE valid_texts.append(text) valid_indices.append(i) else: - log.warning(f"šŸ” Skipping empty or invalid text at index {i}: type={type(text)}, length={len(text) if hasattr(text, '__len__') else 'N/A'}, repr={repr(text)[:100]}") - + log.warning( + f"šŸ” Skipping empty or invalid text at index {i}: type={type(text)}, length={len(text) if hasattr(text, '__len__') else 'N/A'}, repr={repr(text)[:100]}" + ) + if not valid_texts: log.error("😭 No valid texts found after filtering") - return EmbedderOutput(data=[], error="No valid texts found after filtering", raw_response=None) - + return EmbedderOutput( + data=[], + error="No valid texts found after filtering", + raw_response=None, + ) + if len(valid_texts) != len(texts): filtered_count = len(texts) - len(valid_texts) - log.warning(f"šŸ” Filtered out {filtered_count} empty/invalid texts out of {len(texts)} total texts") - + log.warning( + f"šŸ” Filtered out {filtered_count} empty/invalid texts out of {len(texts)} total texts" + ) + # Create modified api_kwargs with only valid texts filtered_api_kwargs = api_kwargs.copy() filtered_api_kwargs["input"] = valid_texts - - log.info(f"šŸ” DashScope embedding API call with {len(valid_texts)} valid texts out of {len(texts)} total") - + + log.info( + f"šŸ” DashScope embedding API call with {len(valid_texts)} valid texts out of {len(texts)} total" + ) + try: response = self.sync_client.embeddings.create(**filtered_api_kwargs) - log.info(f"šŸ” DashScope API call successful, response type: {type(response)}") + log.info( + f"šŸ” DashScope API call successful, response type: {type(response)}" + ) result = self.parse_embedding_response(response) - + # If we filtered texts, we need to create embeddings for the original indices if len(valid_texts) != len(texts): - log.info(f"šŸ” Creating embeddings for {len(texts)} original positions") - + log.info( + f"šŸ” Creating embeddings for {len(texts)} original positions" + ) + # Get the correct embedding dimension from the first valid embedding - embedding_dim = None # Must be determined from a successful response - if result.data and len(result.data) > 0 and hasattr(result.data[0], 'embedding'): + embedding_dim = ( + None # Must be determined from a successful response + ) + if ( + result.data + and len(result.data) > 0 + and hasattr(result.data[0], "embedding") + ): embedding_dim = len(result.data[0].embedding) log.info(f"šŸ” Using embedding dimension: {embedding_dim}") - + final_data = [] valid_idx = 0 for i in range(len(texts)): @@ -464,20 +490,23 @@ def call(self, api_kwargs: Dict = {}, model_type: ModelType = ModelType.UNDEFINE valid_idx += 1 else: # Create zero embedding for filtered texts with correct dimension - log.warning(f"šŸ” Creating zero embedding for filtered text at index {i}") - final_data.append(Embedding( - embedding=[0.0] * embedding_dim, # Use correct embedding dimension - index=i - )) - + log.warning( + f"šŸ” Creating zero embedding for filtered text at index {i}" + ) + final_data.append( + Embedding( + embedding=[0.0] + * embedding_dim, # Use correct embedding dimension + index=i, + ) + ) + result = EmbedderOutput( - data=final_data, - error=None, - raw_response=result.raw_response + data=final_data, error=None, raw_response=result.raw_response ) - + return result - + except Exception as e: log.error(f"šŸ” DashScope API call failed: {e}") return EmbedderOutput(data=[], error=str(e), raw_response=None) @@ -533,15 +562,17 @@ async def async_stream_generator(): elif model_type == ModelType.EMBEDDER: # Extract input texts from api_kwargs texts = api_kwargs.get("input", []) - + if not texts: log.warning("😭 No input texts provided") - return EmbedderOutput(data=[], error="No input texts provided", raw_response=None) - + return EmbedderOutput( + data=[], error="No input texts provided", raw_response=None + ) + # Ensure texts is a list if isinstance(texts, str): texts = [texts] - + # Filter out empty or None texts - following HuggingFace client pattern valid_texts = [] valid_indices = [] @@ -550,37 +581,57 @@ async def async_stream_generator(): valid_texts.append(text) valid_indices.append(i) else: - log.warning(f"šŸ” Skipping empty or invalid text at index {i}: type={type(text)}, length={len(text) if hasattr(text, '__len__') else 'N/A'}, repr={repr(text)[:100]}") - + log.warning( + f"šŸ” Skipping empty or invalid text at index {i}: type={type(text)}, length={len(text) if hasattr(text, '__len__') else 'N/A'}, repr={repr(text)[:100]}" + ) + if not valid_texts: log.error("😭 No valid texts found after filtering") - return EmbedderOutput(data=[], error="No valid texts found after filtering", raw_response=None) - + return EmbedderOutput( + data=[], + error="No valid texts found after filtering", + raw_response=None, + ) + if len(valid_texts) != len(texts): filtered_count = len(texts) - len(valid_texts) - log.warning(f"šŸ” Filtered out {filtered_count} empty/invalid texts out of {len(texts)} total texts") - + log.warning( + f"šŸ” Filtered out {filtered_count} empty/invalid texts out of {len(texts)} total texts" + ) + # Create modified api_kwargs with only valid texts filtered_api_kwargs = api_kwargs.copy() filtered_api_kwargs["input"] = valid_texts - - log.info(f"šŸ” DashScope async embedding API call with {len(valid_texts)} valid texts out of {len(texts)} total") - + + log.info( + f"šŸ” DashScope async embedding API call with {len(valid_texts)} valid texts out of {len(texts)} total" + ) + try: - response = await self.async_client.embeddings.create(**filtered_api_kwargs) - log.info(f"šŸ” DashScope async API call successful, response type: {type(response)}") + response = await self.async_client.embeddings.create( + **filtered_api_kwargs + ) + log.info( + f"šŸ” DashScope async API call successful, response type: {type(response)}" + ) result = self.parse_embedding_response(response) - + # If we filtered texts, we need to create embeddings for the original indices if len(valid_texts) != len(texts): - log.info(f"šŸ” Creating embeddings for {len(texts)} original positions") - + log.info( + f"šŸ” Creating embeddings for {len(texts)} original positions" + ) + # Get the correct embedding dimension from the first valid embedding embedding_dim = 256 # Default fallback based on config - if result.data and len(result.data) > 0 and hasattr(result.data[0], 'embedding'): + if ( + result.data + and len(result.data) > 0 + and hasattr(result.data[0], "embedding") + ): embedding_dim = len(result.data[0].embedding) log.info(f"šŸ” Using embedding dimension: {embedding_dim}") - + final_data = [] valid_idx = 0 for i in range(len(texts)): @@ -590,20 +641,23 @@ async def async_stream_generator(): valid_idx += 1 else: # Create zero embedding for filtered texts with correct dimension - log.warning(f"šŸ” Creating zero embedding for filtered text at index {i}") - final_data.append(Embedding( - embedding=[0.0] * embedding_dim, # Use correct embedding dimension - index=i - )) - + log.warning( + f"šŸ” Creating zero embedding for filtered text at index {i}" + ) + final_data.append( + Embedding( + embedding=[0.0] + * embedding_dim, # Use correct embedding dimension + index=i, + ) + ) + result = EmbedderOutput( - data=final_data, - error=None, - raw_response=result.raw_response + data=final_data, error=None, raw_response=result.raw_response ) - + return result - + except Exception as e: log.error(f"šŸ” DashScope async API call failed: {e}") return EmbedderOutput(data=[], error=str(e), raw_response=None) @@ -631,10 +685,10 @@ def __getstate__(self): """ state = self.__dict__.copy() # Remove the unpicklable client instances - if 'sync_client' in state: - del state['sync_client'] - if 'async_client' in state: - del state['async_client'] + if "sync_client" in state: + del state["sync_client"] + if "async_client" in state: + del state["async_client"] return state def __setstate__(self, state): @@ -669,7 +723,6 @@ def __init__( model_kwargs: Dict[str, Any] = {}, output_processors: Optional[DataComponent] = None, ) -> None: - super().__init__(model_kwargs=model_kwargs) if not isinstance(model_kwargs, Dict): raise TypeError( @@ -732,163 +785,204 @@ async def acall( def _compose_model_kwargs(self, **model_kwargs) -> Dict[str, object]: return F.compose_model_kwargs(self.model_kwargs, model_kwargs) + # Batch Embedding Components for DashScope class DashScopeBatchEmbedder(DataComponent): """Batch embedder specifically designed for DashScope API""" - def __init__(self, embedder, batch_size: int = 100, embedding_cache_file_name: str = "default") -> None: + def __init__( + self, + embedder, + batch_size: int = 100, + embedding_cache_file_name: str = "default", + ) -> None: super().__init__(batch_size=batch_size) self.embedder = embedder self.batch_size = batch_size if self.batch_size > 25: - log.warning(f"DashScope batch embedder initialization, batch size: {self.batch_size}, note that DashScope batch embedding size cannot exceed 25, automatically set to 25") + log.warning( + f"DashScope batch embedder initialization, batch size: {self.batch_size}, note that DashScope batch embedding size cannot exceed 25, automatically set to 25" + ) self.batch_size = 25 - self.cache_path = f'./embedding_cache/{embedding_cache_file_name}_{self.embedder.__class__.__name__}_dashscope_embeddings.pkl' + self.cache_path = f"./embedding_cache/{embedding_cache_file_name}_{self.embedder.__class__.__name__}_dashscope_embeddings.pkl" def call( - self, input: BatchEmbedderInputType, model_kwargs: Optional[Dict] = {}, force_recreate: bool = False + self, + input: BatchEmbedderInputType, + model_kwargs: Optional[Dict] = {}, + force_recreate: bool = False, ) -> BatchEmbedderOutputType: """ Batch call to DashScope embedder - + Args: input: List of input texts model_kwargs: Model parameters force_recreate: Whether to force recreation - + Returns: Batch embedding output """ # Check cache first - + if not force_recreate and os.path.exists(self.cache_path): try: - with open(self.cache_path, 'rb') as f: + with open(self.cache_path, "rb") as f: embeddings = pickle.load(f) - log.info(f"Loaded cached DashScope embeddings from: {self.cache_path}") + log.info( + f"Loaded cached DashScope embeddings from: {self.cache_path}" + ) return embeddings except Exception as e: - log.warning(f"Failed to load cache file {self.cache_path}: {e}, proceeding with fresh embedding") - + log.warning( + f"Failed to load cache file {self.cache_path}: {e}, proceeding with fresh embedding" + ) + if isinstance(input, str): input = [input] - + n = len(input) embeddings: List[EmbedderOutput] = [] - - log.info(f"Starting DashScope batch embedding processing, total {n} texts, batch size: {self.batch_size}") - + + log.info( + f"Starting DashScope batch embedding processing, total {n} texts, batch size: {self.batch_size}" + ) + for i in tqdm( range(0, n, self.batch_size), desc="DashScope batch embedding", disable=False, ): batch_input = input[i : min(i + self.batch_size, n)] - + try: # Use correct calling method: directly call embedder instance batch_output = self.embedder( input=batch_input, model_kwargs=model_kwargs ) embeddings.append(batch_output) - + # Validate batch output if batch_output.error: - log.error(f"Batch {i//self.batch_size + 1} embedding failed: {batch_output.error}") + log.error( + f"Batch {i//self.batch_size + 1} embedding failed: {batch_output.error}" + ) elif batch_output.data: - log.debug(f"Batch {i//self.batch_size + 1} successfully generated {len(batch_output.data)} embedding vectors") + log.debug( + f"Batch {i//self.batch_size + 1} successfully generated {len(batch_output.data)} embedding vectors" + ) else: - log.warning(f"Batch {i//self.batch_size + 1} returned no embedding data") - + log.warning( + f"Batch {i//self.batch_size + 1} returned no embedding data" + ) + except Exception as e: log.error(f"Batch {i//self.batch_size + 1} processing exception: {e}") # Create error embedding output - error_output = EmbedderOutput( - data=[], - error=str(e), - raw_response=None - ) + error_output = EmbedderOutput(data=[], error=str(e), raw_response=None) embeddings.append(error_output) - - log.info(f"DashScope batch embedding completed, processed {len(embeddings)} batches") - + + log.info( + f"DashScope batch embedding completed, processed {len(embeddings)} batches" + ) + # Save to cache try: - if not os.path.exists('./embedding_cache'): - os.makedirs('./embedding_cache') - with open(self.cache_path, 'wb') as f: + if not os.path.exists("./embedding_cache"): + os.makedirs("./embedding_cache") + with open(self.cache_path, "wb") as f: pickle.dump(embeddings, f) log.info(f"Saved DashScope embeddings cache to: {self.cache_path}") except Exception as e: log.warning(f"Failed to save cache to {self.cache_path}: {e}") - + return embeddings - - def __call__(self, input: BatchEmbedderInputType, model_kwargs: Optional[Dict] = {}, force_recreate: bool = False) -> BatchEmbedderOutputType: + + def __call__( + self, + input: BatchEmbedderInputType, + model_kwargs: Optional[Dict] = {}, + force_recreate: bool = False, + ) -> BatchEmbedderOutputType: """ Call operator interface, delegates to call method """ - return self.call(input=input, model_kwargs=model_kwargs, force_recreate=force_recreate) + return self.call( + input=input, model_kwargs=model_kwargs, force_recreate=force_recreate + ) class DashScopeToEmbeddings(DataComponent): """Component that converts document sequences to embedding vector sequences, specifically optimized for DashScope API""" - def __init__(self, embedder, batch_size: int = 100, force_recreate_db: bool = False, embedding_cache_file_name: str = "default") -> None: + def __init__( + self, + embedder, + batch_size: int = 100, + force_recreate_db: bool = False, + embedding_cache_file_name: str = "default", + ) -> None: super().__init__(batch_size=batch_size) self.embedder = embedder self.batch_size = batch_size - self.batch_embedder = DashScopeBatchEmbedder(embedder=embedder, batch_size=batch_size, embedding_cache_file_name=embedding_cache_file_name) + self.batch_embedder = DashScopeBatchEmbedder( + embedder=embedder, + batch_size=batch_size, + embedding_cache_file_name=embedding_cache_file_name, + ) self.force_recreate_db = force_recreate_db def __call__(self, input: List[Document]) -> List[Document]: """ Process list of documents, generating embedding vectors for each document - + Args: input: List of input documents - + Returns: List of documents containing embedding vectors """ output = deepcopy(input) - + # Convert to text list embedder_input: List[str] = [chunk.text for chunk in output] - + log.info(f"Starting to process embeddings for {len(embedder_input)} documents") - + # Batch process embeddings outputs: List[EmbedderOutput] = self.batch_embedder( - input=embedder_input, - force_recreate=self.force_recreate_db + input=embedder_input, force_recreate=self.force_recreate_db ) - + # Validate output total_embeddings = 0 error_batches = 0 - + for batch_output in outputs: if batch_output.error: error_batches += 1 log.error(f"Found error batch: {batch_output.error}") elif batch_output.data: total_embeddings += len(batch_output.data) - - log.info(f"Embedding statistics: total {total_embeddings} valid embeddings, {error_batches} error batches") - + + log.info( + f"Embedding statistics: total {total_embeddings} valid embeddings, {error_batches} error batches" + ) + # Assign embedding vectors back to documents doc_idx = 0 for batch_idx, batch_output in tqdm( - enumerate(outputs), + enumerate(outputs), desc="Assigning embedding vectors to documents", - disable=False + disable=False, ): if batch_output.error: # Create empty vectors for documents in error batches batch_size_actual = min(self.batch_size, len(output) - doc_idx) - log.warning(f"Creating empty vectors for {batch_size_actual} documents in batch {batch_idx}") - + log.warning( + f"Creating empty vectors for {batch_size_actual} documents in batch {batch_idx}" + ) + for i in range(batch_size_actual): if doc_idx < len(output): output[doc_idx].vector = [] @@ -897,33 +991,37 @@ def __call__(self, input: List[Document]) -> List[Document]: # Assign normal embedding vectors for embedding in batch_output.data: if doc_idx < len(output): - if hasattr(embedding, 'embedding'): + if hasattr(embedding, "embedding"): output[doc_idx].vector = embedding.embedding else: - log.warning(f"Invalid embedding format for document {doc_idx}") + log.warning( + f"Invalid embedding format for document {doc_idx}" + ) output[doc_idx].vector = [] doc_idx += 1 - + # Validate results valid_count = 0 empty_count = 0 - + for doc in output: - if hasattr(doc, 'vector') and doc.vector and len(doc.vector) > 0: + if hasattr(doc, "vector") and doc.vector and len(doc.vector) > 0: valid_count += 1 else: empty_count += 1 - - log.info(f"Embedding results: {valid_count} valid vectors, {empty_count} empty vectors") - + + log.info( + f"Embedding results: {valid_count} valid vectors, {empty_count} empty vectors" + ) + if valid_count == 0: log.error("āŒ All documents have empty embedding vectors!") elif empty_count > 0: log.warning(f"āš ļø Found {empty_count} empty embedding vectors") else: log.info("āœ… All documents successfully generated embedding vectors") - + return output def _extra_repr(self) -> str: - return f"batch_size={self.batch_size}" \ No newline at end of file + return f"batch_size={self.batch_size}" diff --git a/api/clients/google_embedder.py b/api/clients/google_embedder.py index af1b08a92..f729b356a 100644 --- a/api/clients/google_embedder.py +++ b/api/clients/google_embedder.py @@ -1,20 +1,25 @@ """Google AI Embeddings ModelClient integration.""" import os -import logging -import backoff -from typing import Dict, Any, Optional, List, Sequence +from typing import Any, Dict, Optional, Sequence +import backoff +from adalflow.utils.lazy_import import safe_import, OptionalPackages from adalflow.core.model_client import ModelClient -from adalflow.core.types import ModelType, EmbedderOutput +from adalflow.core.types import EmbedderOutput, ModelType + +# ruff: noqa: E402 +google = safe_import( + OptionalPackages.GOOGLE_GENERATIVEAI.value[0], + OptionalPackages.GOOGLE_GENERATIVEAI.value[1], +) +# ruff: noqa: E402 + +from google import generativeai as genai -try: - import google.generativeai as genai - from google.generativeai.types.text_types import EmbeddingDict, BatchEmbeddingDict -except ImportError: - raise ImportError("google-generativeai is required. Install it with 'pip install google-generativeai'") +from api.logger import get_logger -log = logging.getLogger(__name__) +log = get_logger(__name__) class GoogleEmbedderClient(ModelClient): @@ -56,7 +61,7 @@ def __init__( env_api_key_name: str = "GOOGLE_API_KEY", ): """Initialize Google AI Embeddings client. - + Args: api_key: Google AI API key. If not provided, uses environment variable. env_api_key_name: Name of environment variable containing API key. @@ -77,16 +82,16 @@ def _initialize_client(self): def parse_embedding_response(self, response) -> EmbedderOutput: """Parse Google AI embedding response to EmbedderOutput format. - + Args: response: Google AI embedding response (EmbeddingDict or BatchEmbeddingDict) - + Returns: EmbedderOutput with parsed embeddings """ try: from adalflow.core.types import Embedding - + embedding_data = [] def _extract_embedding_value(obj): @@ -113,10 +118,12 @@ def _extract_embedding_value(obj): except Exception: pass return None - + embedding_value = _extract_embedding_value(response) if embedding_value is None: - log.warning("Unexpected embedding response type/structure: %s", type(response)) + log.warning( + "Unexpected embedding response type/structure: %s", type(response) + ) embedding_data = [] elif isinstance(embedding_value, list) and len(embedding_value) > 0: if isinstance(embedding_value[0], (int, float)): @@ -142,21 +149,21 @@ def _extract_embedding_value(obj): embedding_data = [] if embedding_data: - first_dim = len(embedding_data[0].embedding) if embedding_data[0].embedding is not None else 0 - log.info("Parsed %s embedding(s) (dim=%s)", len(embedding_data), first_dim) - + first_dim = ( + len(embedding_data[0].embedding) + if embedding_data[0].embedding is not None + else 0 + ) + log.info( + "Parsed %s embedding(s) (dim=%s)", len(embedding_data), first_dim + ) + return EmbedderOutput( - data=embedding_data, - error=None, - raw_response=response + data=embedding_data, error=None, raw_response=response ) except Exception as e: log.error(f"Error parsing Google AI embedding response: {e}") - return EmbedderOutput( - data=[], - error=str(e), - raw_response=response - ) + return EmbedderOutput(data=[], error=str(e), raw_response=response) def convert_inputs_to_api_kwargs( self, @@ -165,18 +172,20 @@ def convert_inputs_to_api_kwargs( model_type: ModelType = ModelType.UNDEFINED, ) -> Dict: """Convert inputs to Google AI API format. - + Args: input: Text input(s) to embed model_kwargs: Model parameters including model name and task_type model_type: Should be ModelType.EMBEDDER for this client - + Returns: Dict: API kwargs for Google AI embedding call """ if model_type != ModelType.EMBEDDER: - raise ValueError(f"GoogleEmbedderClient only supports EMBEDDER model type, got {model_type}") - + raise ValueError( + f"GoogleEmbedderClient only supports EMBEDDER model type, got {model_type}" + ) + # Ensure input is a list if isinstance(input, str): content = [input] @@ -184,23 +193,23 @@ def convert_inputs_to_api_kwargs( content = list(input) else: raise TypeError("input must be a string or sequence of strings") - + final_model_kwargs = model_kwargs.copy() - + # Handle single vs batch embedding if len(content) == 1: final_model_kwargs["content"] = content[0] else: final_model_kwargs["contents"] = content - + # Set default task type if not provided if "task_type" not in final_model_kwargs: final_model_kwargs["task_type"] = "SEMANTIC_SIMILARITY" - + # Set default model if not provided if "model" not in final_model_kwargs: final_model_kwargs["model"] = "gemini-embedding-001" - + return final_model_kwargs @backoff.on_exception( @@ -210,28 +219,32 @@ def convert_inputs_to_api_kwargs( ) def call(self, api_kwargs: Dict = {}, model_type: ModelType = ModelType.UNDEFINED): """Call Google AI embedding API. - + Args: api_kwargs: API parameters model_type: Should be ModelType.EMBEDDER - + Returns: Google AI embedding response """ if model_type != ModelType.EMBEDDER: - raise ValueError(f"GoogleEmbedderClient only supports EMBEDDER model type") - - safe_log_kwargs = {k: v for k, v in api_kwargs.items() if k not in {"content", "contents"}} + raise ValueError("GoogleEmbedderClient only supports EMBEDDER model type") + + safe_log_kwargs = { + k: v for k, v in api_kwargs.items() if k not in {"content", "contents"} + } if "content" in api_kwargs: safe_log_kwargs["content_chars"] = len(str(api_kwargs.get("content", ""))) if "contents" in api_kwargs: try: contents = api_kwargs.get("contents") - safe_log_kwargs["contents_count"] = len(contents) if hasattr(contents, "__len__") else None + safe_log_kwargs["contents_count"] = ( + len(contents) if hasattr(contents, "__len__") else None + ) except Exception: safe_log_kwargs["contents_count"] = None log.info("Google AI Embeddings call kwargs (sanitized): %s", safe_log_kwargs) - + try: # Use embed_content for single text or batch embedding if "content" in api_kwargs: @@ -245,18 +258,20 @@ def call(self, api_kwargs: Dict = {}, model_type: ModelType = ModelType.UNDEFINE response = genai.embed_content(content=contents, **kwargs) else: raise ValueError("Either 'content' or 'contents' must be provided") - + return response - + except Exception as e: log.error(f"Error calling Google AI Embeddings API: {e}") raise - async def acall(self, api_kwargs: Dict = {}, model_type: ModelType = ModelType.UNDEFINED): + async def acall( + self, api_kwargs: Dict = {}, model_type: ModelType = ModelType.UNDEFINED + ): """Async call to Google AI embedding API. - + Note: Google AI Python client doesn't have async support yet, so this falls back to synchronous call. """ # Google AI client doesn't have async support yet - return self.call(api_kwargs, model_type) \ No newline at end of file + return self.call(api_kwargs, model_type) diff --git a/api/clients/litellm.py b/api/clients/litellm.py index 1c814d11f..21d533853 100644 --- a/api/clients/litellm.py +++ b/api/clients/litellm.py @@ -1,8 +1,8 @@ import os -from typing import Optional, Callable -from openai import AsyncOpenAI, OpenAI +from typing import Callable, Optional from adalflow.components.model_client.openai_client import OpenAIClient +from openai import AsyncOpenAI, OpenAI class LiteLLMClient(OpenAIClient): @@ -34,7 +34,9 @@ def __init__( env_base_url_name: str = "LITELLM_BASE_URL", env_api_key_name: str = "LITELLM_API_KEY", ): - resolved_base_url = base_url or os.getenv(env_base_url_name, "http://localhost:4000") + resolved_base_url = base_url or os.getenv( + env_base_url_name, "http://localhost:4000" + ) if not resolved_base_url.endswith("/v1"): resolved_base_url = f"{resolved_base_url.rstrip('/')}/v1" super().__init__( diff --git a/api/clients/ollama.py b/api/clients/ollama.py index 2418e313d..b1833191c 100644 --- a/api/clients/ollama.py +++ b/api/clients/ollama.py @@ -1,25 +1,23 @@ # This file exists to patch the adalflow OllamaClient to support ollama batch embedding api `embed` -from typing import Dict, Optional, Any +from typing import Any, Dict, Optional +import backoff from adalflow.components.model_client.ollama_client import ( OllamaClient, RequestError, ResponseError, log, ) -from adalflow.core.types import EmbedderOutput, Embedding - from adalflow.core import ModelType - -import backoff +from adalflow.core.types import EmbedderOutput, Embedding def convert_inputs_to_api_kwargs( - self, - input: Optional[Any] = None, - model_kwargs: Dict = {}, - model_type: ModelType = ModelType.UNDEFINED, + self, + input: Optional[Any] = None, + model_kwargs: Dict = {}, + model_type: ModelType = ModelType.UNDEFINED, ) -> Dict: self.generate = False final_model_kwargs = model_kwargs.copy() @@ -49,21 +47,19 @@ def convert_inputs_to_api_kwargs( @backoff.on_exception( - backoff.expo, - (RequestError, ResponseError), - max_tries=5, + backoff.expo, + (RequestError, ResponseError), + max_tries=5, ) async def acall( - self, - api_kwargs: dict = None, - model_type: ModelType = ModelType.UNDEFINED, + self, + api_kwargs: dict = None, + model_type: ModelType = ModelType.UNDEFINED, ): if self.async_client is None: self.init_async_client() if self.async_client is None: - raise RuntimeError( - "Async client is not initialized" - ) + raise RuntimeError("Async client is not initialized") api_kwargs = api_kwargs or {} if model_type == ModelType.EMBEDDER: return await self.async_client.embed(**api_kwargs) @@ -79,16 +75,15 @@ async def acall( raise ValueError(f"model_type {model_type} is not supported") - @backoff.on_exception( backoff.expo, (RequestError, ResponseError), max_tries=5, ) def call( - self, - api_kwargs: dict = None, - model_type: ModelType = ModelType.UNDEFINED, + self, + api_kwargs: dict = None, + model_type: ModelType = ModelType.UNDEFINED, ): api_kwargs = api_kwargs or {} if not self.sync_client: @@ -109,23 +104,23 @@ def call( raise ValueError(f"model_type {model_type} is not supported") -def parse_embedding_response( - self, response: Dict[str, list[float]] -) -> EmbedderOutput: +def parse_embedding_response(self, response: Dict[str, list[float]]) -> EmbedderOutput: r"""Parse the embedding response to a structure AdalFlow components can understand. Pull the embedding from response['embedding'] and store it Embedding dataclass """ try: - return EmbedderOutput(data=[ - Embedding(embedding=emb, index=i) - for i, emb in enumerate(response["embeddings"]) - ]) + return EmbedderOutput( + data=[ + Embedding(embedding=emb, index=i) + for i, emb in enumerate(response["embeddings"]) + ] + ) except Exception as e: log.error(f"Error parsing the embedding response: {e}") return EmbedderOutput(data=[], error=str(e), raw_response=response) + OllamaClient.convert_inputs_to_api_kwargs = convert_inputs_to_api_kwargs OllamaClient.parse_embedding_response = parse_embedding_response OllamaClient.call = call OllamaClient.acall = acall - diff --git a/api/clients/openrouter.py b/api/clients/openrouter.py index b71c47744..56fbde86a 100644 --- a/api/clients/openrouter.py +++ b/api/clients/openrouter.py @@ -1,20 +1,21 @@ """OpenRouter ModelClient integration.""" -from typing import Dict, Sequence, Optional, Any, List -import logging import json -import aiohttp -import requests -from requests.exceptions import RequestException, Timeout +from typing import Any, Dict +import aiohttp from adalflow.core.model_client import ModelClient from adalflow.core.types import ( CompletionUsage, - ModelType, GeneratorOutput, + ModelType, ) +from requests.exceptions import RequestException + +from api.logger import get_logger + +log = get_logger(__name__) -log = logging.getLogger(__name__) class OpenRouterClient(ModelClient): __doc__ = r"""A component wrapper for the OpenRouter API client. @@ -45,28 +46,24 @@ def __init__(self, *args, **kwargs) -> None: def init_sync_client(self): """Initialize the synchronous OpenRouter client.""" from api.config import OPENROUTER_API_KEY + api_key = OPENROUTER_API_KEY if not api_key: log.warning("OPENROUTER_API_KEY not configured") # OpenRouter doesn't have a dedicated client library, so we'll use requests directly - return { - "api_key": api_key, - "base_url": "https://openrouter.ai/api/v1" - } + return {"api_key": api_key, "base_url": "https://openrouter.ai/api/v1"} def init_async_client(self): """Initialize the asynchronous OpenRouter client.""" from api.config import OPENROUTER_API_KEY + api_key = OPENROUTER_API_KEY if not api_key: log.warning("OPENROUTER_API_KEY not configured") # For async, we'll use aiohttp - return { - "api_key": api_key, - "base_url": "https://openrouter.ai/api/v1" - } + return {"api_key": api_key, "base_url": "https://openrouter.ai/api/v1"} def convert_inputs_to_api_kwargs( self, input: Any, model_kwargs: Dict = None, model_type: ModelType = None @@ -81,18 +78,19 @@ def convert_inputs_to_api_kwargs( # Convert input to messages format if it's a string if isinstance(input, str): messages = [{"role": "user", "content": input}] - elif isinstance(input, list) and all(isinstance(msg, dict) for msg in input): + elif isinstance(input, list) and all( + isinstance(msg, dict) for msg in input + ): messages = input else: - raise ValueError(f"Unsupported input format for OpenRouter: {type(input)}") + raise ValueError( + f"Unsupported input format for OpenRouter: {type(input)}" + ) # For debugging log.info(f"Messages for OpenRouter: {messages}") - api_kwargs = { - "messages": messages, - **model_kwargs - } + api_kwargs = {"messages": messages, **model_kwargs} # Ensure model is specified if "model" not in api_kwargs: @@ -104,7 +102,9 @@ def convert_inputs_to_api_kwargs( # OpenRouter doesn't support embeddings directly # We could potentially use a specific model through OpenRouter for embeddings # but for now, we'll raise an error - raise NotImplementedError("OpenRouter client does not support embeddings yet") + raise NotImplementedError( + "OpenRouter client does not support embeddings yet" + ) else: raise ValueError(f"Unsupported model type: {model_type}") @@ -118,10 +118,12 @@ async def acall(self, api_kwargs: Dict = None, model_type: ModelType = None) -> if not self.async_client.get("api_key"): error_msg = "OPENROUTER_API_KEY not configured. Please set this environment variable to use OpenRouter." log.error(error_msg) + # Instead of raising an exception, return a generator that yields the error message # This allows the error to be displayed to the user in the streaming response async def error_generator(): yield error_msg + return error_generator() api_kwargs = api_kwargs or {} @@ -132,7 +134,7 @@ async def error_generator(): "Authorization": f"Bearer {self.async_client['api_key']}", "Content-Type": "application/json", "HTTP-Referer": "https://github.com/AsyncFuncAI/deepwiki-open", # Optional - "X-Title": "DeepWiki" # Optional + "X-Title": "DeepWiki", # Optional } # Always use non-streaming mode for OpenRouter @@ -140,8 +142,9 @@ async def error_generator(): # Make the API call try: - log.info(f"Making async OpenRouter API call to {self.async_client['base_url']}/chat/completions") - log.info(f"Request headers: {headers}") + log.info( + f"Making async OpenRouter API call to {self.async_client['base_url']}/chat/completions" + ) log.info(f"Request body: {api_kwargs}") async with aiohttp.ClientSession() as session: @@ -150,15 +153,18 @@ async def error_generator(): f"{self.async_client['base_url']}/chat/completions", headers=headers, json=api_kwargs, - timeout=60 + timeout=60, ) as response: if response.status != 200: error_text = await response.text() - log.error(f"OpenRouter API error ({response.status}): {error_text}") + log.error( + f"OpenRouter API error ({response.status}): {error_text}" + ) # Return a generator that yields the error message async def error_response_generator(): yield f"OpenRouter API error ({response.status}): {error_text}" + return error_response_generator() # Get the full response @@ -169,12 +175,18 @@ async def error_response_generator(): async def content_generator(): if "choices" in data and len(data["choices"]) > 0: choice = data["choices"][0] - if "message" in choice and "content" in choice["message"]: + if ( + "message" in choice + and "content" in choice["message"] + ): content = choice["message"]["content"] log.info("Successfully retrieved response") # Check if the content is XML and ensure it's properly formatted - if content.strip().startswith("<") and ">" in content: + if ( + content.strip().startswith("<") + and ">" in content + ): # It's likely XML, let's make sure it's properly formatted try: # Extract the XML content @@ -182,11 +194,17 @@ async def content_generator(): # Check if it's a wiki_structure XML if "" in xml_content: - log.info("Found wiki_structure XML, ensuring proper format") + log.info( + "Found wiki_structure XML, ensuring proper format" + ) # Extract just the wiki_structure XML import re - wiki_match = re.search(r'[\s\S]*?<\/wiki_structure>', xml_content) + + wiki_match = re.search( + r"[\s\S]*?<\/wiki_structure>", + xml_content, + ) if wiki_match: # Get the raw XML raw_xml = wiki_match.group(0) @@ -201,26 +219,52 @@ async def content_generator(): fixed_xml = clean_xml # Replace & with & if not already part of an entity - fixed_xml = re.sub(r'&(?!amp;|lt;|gt;|apos;|quot;)', '&', fixed_xml) + fixed_xml = re.sub( + r"&(?!amp;|lt;|gt;|apos;|quot;)", + "&", + fixed_xml, + ) # Fix other common XML issues - fixed_xml = fixed_xml.replace('', '>') + fixed_xml = ( + fixed_xml.replace( + "", ">") + ) # Try to parse the fixed XML - from xml.dom.minidom import parseString + from xml.dom.minidom import ( + parseString, + ) + dom = parseString(fixed_xml) # Get the pretty-printed XML with proper indentation - pretty_xml = dom.toprettyxml() + pretty_xml = ( + dom.toprettyxml() + ) # Remove XML declaration - if pretty_xml.startswith('')+2:].strip() - - log.info(f"Extracted and validated XML: {pretty_xml[:100]}...") + if pretty_xml.startswith( + "" + ) + + 2 : + ].strip() + + log.info( + f"Extracted and validated XML: {pretty_xml[:100]}..." + ) yield pretty_xml - except Exception as xml_parse_error: - log.warning(f"XML validation failed: {str(xml_parse_error)}, using raw XML") + except ( + Exception + ) as xml_parse_error: + log.warning( + f"XML validation failed: {str(xml_parse_error)}, using raw XML" + ) # If XML validation fails, try a more aggressive approach try: @@ -228,61 +272,116 @@ async def content_generator(): import re # Extract the basic structure - structure_match = re.search(r'(.*?)', clean_xml, re.DOTALL) + structure_match = re.search( + r"(.*?)", + clean_xml, + re.DOTALL, + ) if structure_match: - structure = structure_match.group(1).strip() + structure = structure_match.group( + 1 + ).strip() # Rebuild a clean XML structure clean_structure = "\n" # Extract title - title_match = re.search(r'(.*?)', structure, re.DOTALL) + title_match = re.search( + r"(.*?)", + structure, + re.DOTALL, + ) if title_match: - title = title_match.group(1).strip() + title = title_match.group( + 1 + ).strip() clean_structure += f" {title}\n" # Extract description - desc_match = re.search(r'(.*?)', structure, re.DOTALL) + desc_match = re.search( + r"(.*?)", + structure, + re.DOTALL, + ) if desc_match: - desc = desc_match.group(1).strip() + desc = desc_match.group( + 1 + ).strip() clean_structure += f" {desc}\n" # Add pages section - clean_structure += " \n" + clean_structure += ( + " \n" + ) # Extract pages - pages = re.findall(r'(.*?)', structure, re.DOTALL) - for page_id, page_content in pages: + pages = re.findall( + r'(.*?)', + structure, + re.DOTALL, + ) + for ( + page_id, + page_content, + ) in pages: clean_structure += f' \n' # Extract page title - page_title_match = re.search(r'(.*?)', page_content, re.DOTALL) + page_title_match = re.search( + r"(.*?)", + page_content, + re.DOTALL, + ) if page_title_match: - page_title = page_title_match.group(1).strip() + page_title = page_title_match.group( + 1 + ).strip() clean_structure += f" {page_title}\n" # Extract page description - page_desc_match = re.search(r'(.*?)', page_content, re.DOTALL) + page_desc_match = re.search( + r"(.*?)", + page_content, + re.DOTALL, + ) if page_desc_match: - page_desc = page_desc_match.group(1).strip() + page_desc = page_desc_match.group( + 1 + ).strip() clean_structure += f" {page_desc}\n" # Extract importance - importance_match = re.search(r'(.*?)', page_content, re.DOTALL) + importance_match = re.search( + r"(.*?)", + page_content, + re.DOTALL, + ) if importance_match: - importance = importance_match.group(1).strip() + importance = importance_match.group( + 1 + ).strip() clean_structure += f" {importance}\n" # Extract relevant files clean_structure += " \n" - file_paths = re.findall(r'(.*?)', page_content, re.DOTALL) - for file_path in file_paths: + file_paths = re.findall( + r"(.*?)", + page_content, + re.DOTALL, + ) + for ( + file_path + ) in file_paths: clean_structure += f" {file_path.strip()}\n" clean_structure += " \n" # Extract related pages clean_structure += " \n" - related_pages = re.findall(r'(.*?)', page_content, re.DOTALL) + related_pages = re.findall( + r"(.*?)", + page_content, + re.DOTALL, + ) for related in related_pages: clean_structure += f" {related.strip()}\n" clean_structure += " \n" @@ -291,23 +390,35 @@ async def content_generator(): clean_structure += " \n" - log.info("Successfully rebuilt clean XML structure") + log.info( + "Successfully rebuilt clean XML structure" + ) yield clean_structure else: - log.warning("Could not extract wiki structure, using raw XML") + log.warning( + "Could not extract wiki structure, using raw XML" + ) yield clean_xml - except Exception as rebuild_error: - log.warning(f"Failed to rebuild XML: {str(rebuild_error)}, using raw XML") + except ( + Exception + ) as rebuild_error: + log.warning( + f"Failed to rebuild XML: {str(rebuild_error)}, using raw XML" + ) yield clean_xml else: # If we can't extract it, just yield the original content - log.warning("Could not extract wiki_structure XML, yielding original content") + log.warning( + "Could not extract wiki_structure XML, yielding original content" + ) yield xml_content else: # For other XML content, just yield it as is yield content except Exception as xml_error: - log.error(f"Error processing XML content: {str(xml_error)}") + log.error( + f"Error processing XML content: {str(xml_error)}" + ) yield content else: # Not XML, just yield the content @@ -322,11 +433,14 @@ async def content_generator(): return content_generator() except aiohttp.ClientError as e: e_client = e - log.error(f"Connection error with OpenRouter API: {str(e_client)}") + log.error( + f"Connection error with OpenRouter API: {str(e_client)}" + ) # Return a generator that yields the error message async def connection_error_generator(): yield f"Connection error with OpenRouter API: {str(e_client)}. Please check your internet connection and that the OpenRouter API is accessible." + return connection_error_generator() except RequestException as e: @@ -336,15 +450,19 @@ async def connection_error_generator(): # Return a generator that yields the error message async def request_error_generator(): yield f"Error calling OpenRouter API: {str(e_req)}" + return request_error_generator() except Exception as e: e_unexp = e - log.error(f"Unexpected error calling OpenRouter API asynchronously: {str(e_unexp)}") + log.error( + f"Unexpected error calling OpenRouter API asynchronously: {str(e_unexp)}" + ) # Return a generator that yields the error message async def unexpected_error_generator(): yield f"Unexpected error calling OpenRouter API: {str(e_unexp)}" + return unexpected_error_generator() else: @@ -354,6 +472,7 @@ async def unexpected_error_generator(): # Return a generator that yields the error message async def model_type_error_generator(): yield error_msg + return model_type_error_generator() def _process_completion_response(self, data: Dict) -> GeneratorOutput: @@ -370,7 +489,9 @@ def _process_completion_response(self, data: Dict) -> GeneratorOutput: elif "text" in choice: content = choice.get("text", "") else: - raise ValueError(f"Unexpected response format from OpenRouter: {choice}") + raise ValueError( + f"Unexpected response format from OpenRouter: {choice}" + ) # Extract usage information if available usage = None @@ -378,15 +499,11 @@ def _process_completion_response(self, data: Dict) -> GeneratorOutput: usage = CompletionUsage( prompt_tokens=data["usage"].get("prompt_tokens", 0), completion_tokens=data["usage"].get("completion_tokens", 0), - total_tokens=data["usage"].get("total_tokens", 0) + total_tokens=data["usage"].get("total_tokens", 0), ) # Create and return the GeneratorOutput - return GeneratorOutput( - data=content, - usage=usage, - raw_response=data - ) + return GeneratorOutput(data=content, usage=usage, raw_response=data) except Exception as e_proc: log.error(f"Error processing OpenRouter completion response: {str(e_proc)}") @@ -404,8 +521,8 @@ def _process_streaming_response(self, response): buffer += chunk # Process complete lines in the buffer - while '\n' in buffer: - line, buffer = buffer.split('\n', 1) + while "\n" in buffer: + line, buffer = buffer.split("\n", 1) line = line.strip() if not line: @@ -414,7 +531,7 @@ def _process_streaming_response(self, response): log.debug(f"Processing line: {line}") # Skip SSE comments (lines starting with :) - if line.startswith(':'): + if line.startswith(":"): log.debug(f"Skipping SSE comment: {line}") continue @@ -431,18 +548,29 @@ def _process_streaming_response(self, response): log.debug(f"Parsed JSON data: {data_obj}") # Extract content from delta - if "choices" in data_obj and len(data_obj["choices"]) > 0: + if ( + "choices" in data_obj + and len(data_obj["choices"]) > 0 + ): choice = data_obj["choices"][0] - if "delta" in choice and "content" in choice["delta"] and choice["delta"]["content"]: + if ( + "delta" in choice + and "content" in choice["delta"] + and choice["delta"]["content"] + ): content = choice["delta"]["content"] log.debug(f"Yielding delta content: {content}") yield content elif "text" in choice: - log.debug(f"Yielding text content: {choice['text']}") + log.debug( + f"Yielding text content: {choice['text']}" + ) yield choice["text"] else: - log.debug(f"No content found in choice: {choice}") + log.debug( + f"No content found in choice: {choice}" + ) else: log.debug(f"No choices found in data: {data_obj}") @@ -465,15 +593,15 @@ async def _process_async_streaming_response(self, response): try: # Convert bytes to string and add to buffer if isinstance(chunk, bytes): - chunk_str = chunk.decode('utf-8') + chunk_str = chunk.decode("utf-8") else: chunk_str = str(chunk) buffer += chunk_str # Process complete lines in the buffer - while '\n' in buffer: - line, buffer = buffer.split('\n', 1) + while "\n" in buffer: + line, buffer = buffer.split("\n", 1) line = line.strip() if not line: @@ -482,7 +610,7 @@ async def _process_async_streaming_response(self, response): log.debug(f"Processing line: {line}") # Skip SSE comments (lines starting with :) - if line.startswith(':'): + if line.startswith(":"): log.debug(f"Skipping SSE comment: {line}") continue @@ -499,18 +627,29 @@ async def _process_async_streaming_response(self, response): log.debug(f"Parsed JSON data: {data_obj}") # Extract content from delta - if "choices" in data_obj and len(data_obj["choices"]) > 0: + if ( + "choices" in data_obj + and len(data_obj["choices"]) > 0 + ): choice = data_obj["choices"][0] - if "delta" in choice and "content" in choice["delta"] and choice["delta"]["content"]: + if ( + "delta" in choice + and "content" in choice["delta"] + and choice["delta"]["content"] + ): content = choice["delta"]["content"] log.debug(f"Yielding delta content: {content}") yield content elif "text" in choice: - log.debug(f"Yielding text content: {choice['text']}") + log.debug( + f"Yielding text content: {choice['text']}" + ) yield choice["text"] else: - log.debug(f"No content found in choice: {choice}") + log.debug( + f"No content found in choice: {choice}" + ) else: log.debug(f"No choices found in data: {data_obj}") diff --git a/api/config.py b/api/config.py index bd55b7247..d6f9232a7 100644 --- a/api/config.py +++ b/api/config.py @@ -1,13 +1,11 @@ -import os import json -import logging +import os import re from pathlib import Path -from typing import List, Union, Dict, Any - -logger = logging.getLogger(__name__) +from typing import TYPE_CHECKING, Any, Dict, List, Union from api.clients import ( + AnthropicBedrockClient, AzureAIClient, BedrockClient, DashscopeClient, @@ -18,17 +16,25 @@ OpenAIClient, OpenRouterClient, ) +from api.logger import get_logger + +if TYPE_CHECKING: + from adalflow import Embedder + + +logger = get_logger(__name__) + # Get API keys from environment variables -OPENAI_API_KEY = os.environ.get('OPENAI_API_KEY') -LITELLM_API_KEY = os.environ.get('LITELLM_API_KEY') -GOOGLE_API_KEY = os.environ.get('GOOGLE_API_KEY') -OPENROUTER_API_KEY = os.environ.get('OPENROUTER_API_KEY') -AWS_ACCESS_KEY_ID = os.environ.get('AWS_ACCESS_KEY_ID') -AWS_SECRET_ACCESS_KEY = os.environ.get('AWS_SECRET_ACCESS_KEY') -AWS_SESSION_TOKEN = os.environ.get('AWS_SESSION_TOKEN') -AWS_REGION = os.environ.get('AWS_REGION') -AWS_ROLE_ARN = os.environ.get('AWS_ROLE_ARN') +OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY") +LITELLM_API_KEY = os.environ.get("LITELLM_API_KEY") +GOOGLE_API_KEY = os.environ.get("GOOGLE_API_KEY") +OPENROUTER_API_KEY = os.environ.get("OPENROUTER_API_KEY") +AWS_ACCESS_KEY_ID = os.environ.get("AWS_ACCESS_KEY_ID") +AWS_SECRET_ACCESS_KEY = os.environ.get("AWS_SECRET_ACCESS_KEY") +AWS_SESSION_TOKEN = os.environ.get("AWS_SESSION_TOKEN") +AWS_REGION = os.environ.get("AWS_REGION") +AWS_ROLE_ARN = os.environ.get("AWS_ROLE_ARN") # Set keys in environment (in case they're needed elsewhere in the code) if OPENAI_API_KEY: @@ -51,30 +57,47 @@ os.environ["AWS_ROLE_ARN"] = AWS_ROLE_ARN # Wiki authentication settings -raw_auth_mode = os.environ.get('DEEPWIKI_AUTH_MODE', 'False') -WIKI_AUTH_MODE = raw_auth_mode.lower() in ['true', '1', 't'] -WIKI_AUTH_CODE = os.environ.get('DEEPWIKI_AUTH_CODE', '') +raw_auth_mode = os.environ.get("DEEPWIKI_AUTH_MODE", "False") +WIKI_AUTH_MODE = raw_auth_mode.lower() in ["true", "1", "t"] +WIKI_AUTH_CODE = os.environ.get("DEEPWIKI_AUTH_CODE", "") # Embedder settings -EMBEDDER_TYPE = os.environ.get('DEEPWIKI_EMBEDDER_TYPE', 'openai').lower() +EMBEDDER_TYPE = os.environ.get("DEEPWIKI_EMBEDDER_TYPE", "openai").lower() # Get configuration directory from environment variable, or use default if not set -CONFIG_DIR = os.environ.get('DEEPWIKI_CONFIG_DIR', None) +CONFIG_DIR = os.environ.get("DEEPWIKI_CONFIG_DIR", None) # Client class mapping CLIENT_CLASSES = { - "GoogleGenAIClient": GoogleGenAIClient, - "GoogleEmbedderClient": GoogleEmbedderClient, - "OpenAIClient": OpenAIClient, - "LiteLLMClient" : LiteLLMClient, - "OpenRouterClient": OpenRouterClient, - "OllamaClient": OllamaClient, - "BedrockClient": BedrockClient, - "AzureAIClient": AzureAIClient, - "DashscopeClient": DashscopeClient + GoogleGenAIClient.__name__: GoogleGenAIClient, + GoogleEmbedderClient.__name__: GoogleEmbedderClient, + OpenAIClient.__name__: OpenAIClient, + LiteLLMClient.__name__: LiteLLMClient, + OpenRouterClient.__name__: OpenRouterClient, + OllamaClient.__name__: OllamaClient, + BedrockClient.__name__: BedrockClient, + AzureAIClient.__name__: AzureAIClient, + DashscopeClient.__name__: DashscopeClient, + AnthropicBedrockClient.__name__: AnthropicBedrockClient, +} + + +_DEFAULT_PROVIDER_MAP = { + "google": GoogleGenAIClient, + "openai": OpenAIClient, + "litellm": LiteLLMClient, + "openrouter": OpenRouterClient, + "ollama": OllamaClient, + "bedrock": BedrockClient, + "azure": AzureAIClient, + "dashscope": DashscopeClient, + "anthropic": AnthropicBedrockClient, } -def replace_env_placeholders(config: Union[Dict[str, Any], List[Any], str, Any]) -> Union[Dict[str, Any], List[Any], str, Any]: + +def replace_env_placeholders( + config: Union[Dict[str, Any], List[Any], str, Any], +) -> Union[Dict[str, Any], List[Any], str, Any]: """ Recursively replace placeholders like "${ENV_VAR}" in string values within a nested configuration structure (dicts, lists, strings) @@ -104,6 +127,7 @@ def replacer(match: re.Match[str]) -> str: # Handles numbers, booleans, None, etc. return config + # Load JSON configuration file def load_json_config(filename): try: @@ -120,7 +144,7 @@ def load_json_config(filename): logger.warning(f"Configuration file {config_path} does not exist") return {} - with open(config_path, 'r', encoding='utf-8') as f: + with open(config_path, "r", encoding="utf-8") as f: config = json.load(f) config = replace_env_placeholders(config) return config @@ -128,6 +152,7 @@ def load_json_config(filename): logger.error(f"Error loading configuration file {filename}: {str(e)}") return {} + # Load generator model configuration def load_generator_config(): generator_config = load_json_config("generator.json") @@ -137,25 +162,18 @@ def load_generator_config(): for provider_id, provider_config in generator_config["providers"].items(): # Try to set client class from client_class if provider_config.get("client_class") in CLIENT_CLASSES: - provider_config["model_client"] = CLIENT_CLASSES[provider_config["client_class"]] + provider_config["model_client"] = CLIENT_CLASSES[ + provider_config["client_class"] + ] # Fall back to default mapping based on provider_id - elif provider_id in ["google", "openai", "openrouter", "ollama", "bedrock", "azure", "dashscope", "litellm"]: - default_map = { - "google": GoogleGenAIClient, - "openai": OpenAIClient, - "litellm": LiteLLMClient, - "openrouter": OpenRouterClient, - "ollama": OllamaClient, - "bedrock": BedrockClient, - "azure": AzureAIClient, - "dashscope": DashscopeClient - } - provider_config["model_client"] = default_map[provider_id] + elif provider_id in _DEFAULT_PROVIDER_MAP: + provider_config["model_client"] = _DEFAULT_PROVIDER_MAP[provider_id] else: logger.warning(f"Unknown provider or client class: {provider_id}") return generator_config + # Load embedder configuration def load_embedder_config(): embedder_config = load_json_config("embedder.json") @@ -169,6 +187,7 @@ def load_embedder_config(): return embedder_config + def get_embedder_config(): """ Get the current embedder configuration based on DEEPWIKI_EMBEDDER_TYPE. @@ -177,15 +196,16 @@ def get_embedder_config(): dict: The embedder configuration with model_client resolved """ embedder_type = EMBEDDER_TYPE - if embedder_type == 'bedrock' and 'embedder_bedrock' in configs: + if embedder_type == "bedrock" and "embedder_bedrock" in configs: return configs.get("embedder_bedrock", {}) - elif embedder_type == 'google' and 'embedder_google' in configs: + elif embedder_type == "google" and "embedder_google" in configs: return configs.get("embedder_google", {}) - elif embedder_type == 'ollama' and 'embedder_ollama' in configs: + elif embedder_type == "ollama" and "embedder_ollama" in configs: return configs.get("embedder_ollama", {}) else: return configs.get("embedder", {}) + def is_ollama_embedder(): """ Check if the current embedder configuration uses OllamaClient. @@ -206,6 +226,7 @@ def is_ollama_embedder(): client_class = embedder_config.get("client_class", "") return client_class == "OllamaClient" + def is_google_embedder(): """ Check if the current embedder configuration uses GoogleEmbedderClient. @@ -226,6 +247,7 @@ def is_google_embedder(): client_class = embedder_config.get("client_class", "") return client_class == "GoogleEmbedderClient" + def is_bedrock_embedder(): """ Check if the current embedder configuration uses BedrockClient. @@ -244,26 +266,29 @@ def is_bedrock_embedder(): client_class = embedder_config.get("client_class", "") return client_class == "BedrockClient" + def get_embedder_type(): """ Get the current embedder type based on configuration. - + Returns: str: 'bedrock', 'ollama', 'google', or 'openai' (default) """ if is_bedrock_embedder(): - return 'bedrock' + return "bedrock" elif is_ollama_embedder(): - return 'ollama' + return "ollama" elif is_google_embedder(): - return 'google' + return "google" else: - return 'openai' + return "openai" + # Load repository and file filters configuration def load_repo_config(): return load_json_config("repo.json") + # Load language configuration def load_lang_config(): default_config = { @@ -277,62 +302,26 @@ def load_lang_config(): "vi": "Vietnamese (Tiįŗæng Việt)", "pt-br": "Brazilian Portuguese (PortuguĆŖs Brasileiro)", "fr": "FranƧais (French)", - "ru": "Русский (Russian)" + "ru": "Русский (Russian)", }, - "default": "en" + "default": "en", } - loaded_config = load_json_config("lang.json") # Let load_json_config handle path and loading + loaded_config = load_json_config( + "lang.json" + ) # Let load_json_config handle path and loading if not loaded_config: return default_config if "supported_languages" not in loaded_config or "default" not in loaded_config: - logger.warning("Language configuration file 'lang.json' is malformed. Using default language configuration.") + logger.warning( + "Language configuration file 'lang.json' is malformed. Using default language configuration." + ) return default_config return loaded_config -# Default excluded directories and files -DEFAULT_EXCLUDED_DIRS: List[str] = [ - # Virtual environments and package managers - "./.venv/", "./venv/", "./env/", "./virtualenv/", - "./node_modules/", "./bower_components/", "./jspm_packages/", - # Version control - "./.git/", "./.svn/", "./.hg/", "./.bzr/", - # Cache and compiled files - "./__pycache__/", "./.pytest_cache/", "./.mypy_cache/", "./.ruff_cache/", "./.coverage/", - # Build and distribution - "./dist/", "./build/", "./out/", "./target/", "./bin/", "./obj/", - # Documentation - "./docs/", "./_docs/", "./site-docs/", "./_site/", - # IDE specific - "./.idea/", "./.vscode/", "./.vs/", "./.eclipse/", "./.settings/", - # Logs and temporary files - "./logs/", "./log/", "./tmp/", "./temp/", -] - -DEFAULT_EXCLUDED_FILES: List[str] = [ - "yarn.lock", "pnpm-lock.yaml", "npm-shrinkwrap.json", "poetry.lock", - "Pipfile.lock", "requirements.txt.lock", "Cargo.lock", "composer.lock", - ".lock", ".DS_Store", "Thumbs.db", "desktop.ini", "*.lnk", ".env", - ".env.*", "*.env", "*.cfg", "*.ini", ".flaskenv", ".gitignore", - ".gitattributes", ".gitmodules", ".github", ".gitlab-ci.yml", - ".prettierrc", ".eslintrc", ".eslintignore", ".stylelintrc", - ".editorconfig", ".jshintrc", ".pylintrc", ".flake8", "mypy.ini", - "pyproject.toml", "tsconfig.json", "webpack.config.js", "babel.config.js", - "rollup.config.js", "jest.config.js", "karma.conf.js", "vite.config.js", - "next.config.js", "*.min.js", "*.min.css", "*.bundle.js", "*.bundle.css", - "*.map", "*.gz", "*.zip", "*.tar", "*.tgz", "*.rar", "*.7z", "*.iso", - "*.dmg", "*.img", "*.msix", "*.appx", "*.appxbundle", "*.xap", "*.ipa", - "*.deb", "*.rpm", "*.msi", "*.exe", "*.dll", "*.so", "*.dylib", "*.o", - "*.obj", "*.jar", "*.war", "*.ear", "*.jsm", "*.class", "*.pyc", "*.pyd", - "*.pyo", "__pycache__", "*.a", "*.lib", "*.lo", "*.la", "*.slo", "*.dSYM", - "*.egg", "*.egg-info", "*.dist-info", "*.eggs", "node_modules", - "bower_components", "jspm_packages", "lib-cov", "coverage", "htmlcov", - ".nyc_output", ".tox", "dist", "build", "bld", "out", "bin", "target", - "packages/*/dist", "packages/*/build", ".output" -] # Initialize empty configuration configs = {} @@ -350,13 +339,20 @@ def load_lang_config(): # Update embedder configuration if embedder_config: - for key in ["embedder", "embedder_ollama", "embedder_google", "embedder_bedrock", "retriever", "text_splitter"]: + for key in [ + "embedder", + "embedder_ollama", + "embedder_google", + "embedder_bedrock", + "retriever", + "text_splitter", + ]: if key in embedder_config: configs[key] = embedder_config[key] # Update repository configuration if repo_config: - for key in ["file_filters", "repository"]: + for key in ["file_filters", "repository", "code_extensions", "doc_extensions"]: if key in repo_config: configs[key] = repo_config[key] @@ -419,3 +415,67 @@ def get_model_config(provider="google", model=None): result["model_kwargs"] = {"model": model, **model_params} return result + + +def get_embedder( + is_local_ollama: bool = False, + use_google_embedder: bool = False, + embedder_type: str = None, +) -> "Embedder": + """Get embedder based on configuration or parameters. + + Args: + is_local_ollama: Legacy parameter for Ollama embedder + use_google_embedder: Legacy parameter for Google embedder + embedder_type: Direct specification of embedder type ('ollama', 'google', 'bedrock', 'openai') + + Returns: + adal.Embedder: Configured embedder instance + """ + # Determine which embedder config to use + from adalflow import Embedder + + if embedder_type: + if embedder_type == "ollama": + embedder_config = configs["embedder_ollama"] + elif embedder_type == "google": + embedder_config = configs["embedder_google"] + elif embedder_type == "bedrock": + embedder_config = configs["embedder_bedrock"] + else: # default to openai + embedder_config = configs["embedder"] + elif is_local_ollama: + embedder_config = configs["embedder_ollama"] + elif use_google_embedder: + embedder_config = configs["embedder_google"] + else: + # Auto-detect based on current configuration + current_type = get_embedder_type() + if current_type == "bedrock": + embedder_config = configs["embedder_bedrock"] + elif current_type == "ollama": + embedder_config = configs["embedder_ollama"] + elif current_type == "google": + embedder_config = configs["embedder_google"] + else: + embedder_config = configs["embedder"] + + # --- Initialize Embedder --- + model_client_class = embedder_config["model_client"] + if "initialize_kwargs" in embedder_config: + model_client = model_client_class(**embedder_config["initialize_kwargs"]) + else: + model_client = model_client_class() + + # Create embedder with basic parameters + embedder_kwargs = { + "model_client": model_client, + "model_kwargs": embedder_config["model_kwargs"], + } + + embedder = Embedder(**embedder_kwargs) + + # Set batch_size as an attribute if available (not a constructor parameter) + if "batch_size" in embedder_config: + embedder.batch_size = embedder_config["batch_size"] + return embedder diff --git a/api/config/repo.json b/api/config/repo.json index b1afea014..362371e2d 100644 --- a/api/config/repo.json +++ b/api/config/repo.json @@ -1,128 +1,172 @@ { "file_filters": { "excluded_dirs": [ - "./.venv/", - "./venv/", - "./env/", + "./.venv/", + "./venv/", + "./env/", "./virtualenv/", - "./node_modules/", - "./bower_components/", + "./node_modules/", + "./bower_components/", "./jspm_packages/", - "./.git/", - "./.svn/", - "./.hg/", + "./.git/", + "./.svn/", + "./.hg/", "./.bzr/", - "./vendor/" + "./vendor/", + "./__pycache__/", + "./.pytest_cache/", + "./.mypy_cache/", + "./.ruff_cache/", + "./.coverage/", + "./dist/", + "./build/", + "./out/", + "./target/", + "./bin/", + "./obj/", + "./docs/", + "./_docs/", + "./site-docs/", + "./_site/", + "./.idea/", + "./.vscode/", + "./.vs/", + "./.eclipse/", + "./.settings/", + "./logs/", + "./log/", + "./tmp/", + "./temp/" ], "excluded_files": [ - "yarn.lock", - "pnpm-lock.yaml", - "npm-shrinkwrap.json", + "yarn.lock", + "pnpm-lock.yaml", + "npm-shrinkwrap.json", "poetry.lock", - "Pipfile.lock", - "requirements.txt.lock", - "Cargo.lock", + "Pipfile.lock", + "requirements.txt.lock", + "Cargo.lock", "composer.lock", - ".lock", - ".DS_Store", - "Thumbs.db", - "desktop.ini", - "*.lnk", - ".env", - ".env.*", - "*.env", - "*.cfg", - "*.ini", - ".flaskenv", - ".gitignore", - ".gitattributes", - ".gitmodules", - ".github", - ".gitlab-ci.yml", - ".prettierrc", - ".eslintrc", - ".eslintignore", - ".stylelintrc", - ".editorconfig", - ".jshintrc", - ".pylintrc", - ".flake8", - "mypy.ini", - "pyproject.toml", - "tsconfig.json", - "webpack.config.js", - "babel.config.js", - "rollup.config.js", - "jest.config.js", - "karma.conf.js", - "vite.config.js", - "next.config.js", - "*.min.js", - "*.min.css", - "*.bundle.js", - "*.bundle.css", - "*.map", - "*.gz", - "*.zip", - "*.tar", - "*.tgz", - "*.rar", - "*.7z", - "*.iso", - "*.dmg", - "*.img", - "*.msix", - "*.appx", - "*.appxbundle", - "*.xap", - "*.ipa", - "*.deb", - "*.rpm", - "*.msi", - "*.exe", - "*.dll", - "*.so", - "*.dylib", - "*.o", - "*.obj", - "*.jar", - "*.war", - "*.ear", - "*.jsm", - "*.class", - "*.pyc", - "*.pyd", - "*.pyo", - "__pycache__", - "*.a", - "*.lib", - "*.lo", - "*.la", - "*.slo", + ".lock", + ".DS_Store", + "Thumbs.db", + "desktop.ini", + "*.lnk", + ".env", + ".env.*", + "*.env", + "*.cfg", + "*.ini", + ".flaskenv", + ".gitignore", + ".gitattributes", + ".gitmodules", + ".github", + ".gitlab-ci.yml", + ".prettierrc", + ".eslintrc", + ".eslintignore", + ".stylelintrc", + ".editorconfig", + ".jshintrc", + ".pylintrc", + ".flake8", + "mypy.ini", + "pyproject.toml", + "tsconfig.json", + "webpack.config.js", + "babel.config.js", + "rollup.config.js", + "jest.config.js", + "karma.conf.js", + "vite.config.js", + "next.config.js", + "*.min.js", + "*.min.css", + "*.bundle.js", + "*.bundle.css", + "*.map", + "*.gz", + "*.zip", + "*.tar", + "*.tgz", + "*.rar", + "*.7z", + "*.iso", + "*.dmg", + "*.img", + "*.msix", + "*.appx", + "*.appxbundle", + "*.xap", + "*.ipa", + "*.deb", + "*.rpm", + "*.msi", + "*.exe", + "*.dll", + "*.so", + "*.dylib", + "*.o", + "*.obj", + "*.jar", + "*.war", + "*.ear", + "*.jsm", + "*.class", + "*.pyc", + "*.pyd", + "*.pyo", + "__pycache__", + "*.a", + "*.lib", + "*.lo", + "*.la", + "*.slo", "*.dSYM", - "*.egg", - "*.egg-info", - "*.dist-info", - "*.eggs", + "*.egg", + "*.egg-info", + "*.dist-info", + "*.eggs", "node_modules", - "bower_components", - "jspm_packages", - "lib-cov", - "coverage", - "htmlcov", - ".nyc_output", - ".tox", - "dist", - "build", - "bld", - "out", - "bin", + "bower_components", + "jspm_packages", + "lib-cov", + "coverage", + "htmlcov", + ".nyc_output", + ".tox", + "dist", + "build", + "bld", + "out", + "bin", "target", - "packages/*/dist", - "packages/*/build", + "packages/*/dist", + "packages/*/build", ".output" ] }, + "code_extensions": [ + ".py", + ".js", + ".ts", + ".java", + ".cpp", + ".c", + ".h", + ".hpp", + ".go", + ".rs", + ".jsx", + ".tsx", + ".html", + ".css", + ".php", + ".swift", + ".cs" + ], + "doc_extensions": [".md", ".txt", ".rst", ".json", ".yaml", ".yml"], "repository": { "max_size_mb": 50000 } diff --git a/api/data_pipeline.py b/api/data_pipeline.py deleted file mode 100644 index 16d15a84d..000000000 --- a/api/data_pipeline.py +++ /dev/null @@ -1,905 +0,0 @@ -import adalflow as adal -from adalflow.core.types import Document, List -from adalflow.components.data_process import TextSplitter, ToEmbeddings -import os -import subprocess -import json -import tiktoken -import logging -import base64 -import glob -from pathlib import Path -from adalflow.utils import get_adalflow_default_root_path -from adalflow.core.db import LocalDB -from api.config import configs, DEFAULT_EXCLUDED_DIRS, DEFAULT_EXCLUDED_FILES -from urllib.parse import urlparse, urlunparse, quote -import requests -from requests.exceptions import RequestException - -from api.tools.embedder import get_embedder - -# Configure logging -logger = logging.getLogger(__name__) - -# Maximum token limit for OpenAI embedding models -MAX_EMBEDDING_TOKENS = 8192 - -def count_tokens(text: str, embedder_type: str = None, is_ollama_embedder: bool = None) -> int: - """ - Count the number of tokens in a text string using tiktoken. - - Args: - text (str): The text to count tokens for. - embedder_type (str, optional): The embedder type ('openai', 'google', 'ollama', 'bedrock'). - If None, will be determined from configuration. - is_ollama_embedder (bool, optional): DEPRECATED. Use embedder_type instead. - If None, will be determined from configuration. - - Returns: - int: The number of tokens in the text. - """ - try: - # Handle backward compatibility - if embedder_type is None and is_ollama_embedder is not None: - embedder_type = 'ollama' if is_ollama_embedder else None - - # Determine embedder type if not specified - if embedder_type is None: - from api.config import get_embedder_type - embedder_type = get_embedder_type() - - # Choose encoding based on embedder type - if embedder_type == 'ollama': - # Ollama typically uses cl100k_base encoding - encoding = tiktoken.get_encoding("cl100k_base") - elif embedder_type == 'google': - # Google uses similar tokenization to GPT models for rough estimation - encoding = tiktoken.get_encoding("cl100k_base") - elif embedder_type == 'bedrock': - # Bedrock embedding models vary; use a common GPT-like encoding for rough estimation - encoding = tiktoken.get_encoding("cl100k_base") - else: # OpenAI or default - # Use OpenAI embedding model encoding - encoding = tiktoken.encoding_for_model("text-embedding-3-small") - - return len(encoding.encode(text)) - except Exception as e: - # Fallback to a simple approximation if tiktoken fails - logger.warning(f"Error counting tokens with tiktoken: {e}") - # Rough approximation: 4 characters per token - return len(text) // 4 - -def download_repo(repo_url: str, local_path: str, repo_type: str = None, access_token: str = None) -> str: - """ - Downloads a Git repository (GitHub, GitLab, or Bitbucket) to a specified local path. - - Args: - repo_type(str): Type of repository - repo_url (str): The URL of the Git repository to clone. - local_path (str): The local directory where the repository will be cloned. - access_token (str, optional): Access token for private repositories. - - Returns: - str: The output message from the `git` command. - """ - try: - # Check if Git is installed - logger.info(f"Preparing to clone repository to {local_path}") - subprocess.run( - ["git", "--version"], - check=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - - # Check if repository already exists - if os.path.exists(local_path) and os.listdir(local_path): - # Directory exists and is not empty - logger.warning(f"Repository already exists at {local_path}. Using existing repository.") - return f"Using existing repository at {local_path}" - - # Ensure the local path exists - os.makedirs(local_path, exist_ok=True) - - # Prepare the clone URL with access token if provided - clone_url = repo_url - if access_token: - parsed = urlparse(repo_url) - # URL-encode the token to handle special characters - encoded_token = quote(access_token, safe='') - # Determine the repository type and format the URL accordingly - if repo_type == "github": - # Format: https://{token}@{domain}/owner/repo.git - # Works for both github.com and enterprise GitHub domains - clone_url = urlunparse((parsed.scheme, f"{encoded_token}@{parsed.netloc}", parsed.path, '', '', '')) - elif repo_type == "gitlab": - # Format: https://oauth2:{token}@gitlab.com/owner/repo.git - clone_url = urlunparse((parsed.scheme, f"oauth2:{encoded_token}@{parsed.netloc}", parsed.path, '', '', '')) - elif repo_type == "bitbucket": - # Bitbucket has two token formats with different auth schemes: - # - HTTP access tokens (prefix "ATCTT") use x-bitbucket-api-token-auth - # - App passwords (deprecated, EOL June 2026) use x-token-auth - # Detect by token prefix so existing app password users keep working. - if access_token.startswith("ATCTT"): - auth_scheme = "x-bitbucket-api-token-auth" - else: - auth_scheme = "x-token-auth" - # Format: https://{auth_scheme}:{token}@bitbucket.org/owner/repo.git - clone_url = urlunparse((parsed.scheme, f"{auth_scheme}:{encoded_token}@{parsed.netloc}", parsed.path, '', '', '')) - - logger.info("Using access token for authentication") - - # Clone the repository - logger.info(f"Cloning repository from {repo_url} to {local_path}") - # We use repo_url in the log to avoid exposing the token in logs - result = subprocess.run( - ["git", "clone", "--depth=1", "--single-branch", clone_url, local_path], - check=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - - logger.info("Repository cloned successfully") - return result.stdout.decode("utf-8") - - except subprocess.CalledProcessError as e: - error_msg = e.stderr.decode('utf-8') - # Sanitize error message to remove any tokens (both raw and URL-encoded) - if access_token: - # Remove raw token - error_msg = error_msg.replace(access_token, "***TOKEN***") - # Also remove URL-encoded token to prevent leaking encoded version - encoded_token = quote(access_token, safe='') - error_msg = error_msg.replace(encoded_token, "***TOKEN***") - raise ValueError(f"Error during cloning: {error_msg}") - except Exception as e: - raise ValueError(f"An unexpected error occurred: {str(e)}") - -# Alias for backward compatibility -download_github_repo = download_repo - -def read_all_documents(path: str, embedder_type: str = None, is_ollama_embedder: bool = None, - excluded_dirs: List[str] = None, excluded_files: List[str] = None, - included_dirs: List[str] = None, included_files: List[str] = None): - """ - Recursively reads all documents in a directory and its subdirectories. - - Args: - path (str): The root directory path. - embedder_type (str, optional): The embedder type ('openai', 'google', 'ollama'). - If None, will be determined from configuration. - is_ollama_embedder (bool, optional): DEPRECATED. Use embedder_type instead. - If None, will be determined from configuration. - excluded_dirs (List[str], optional): List of directories to exclude from processing. - Overrides the default configuration if provided. - excluded_files (List[str], optional): List of file patterns to exclude from processing. - Overrides the default configuration if provided. - included_dirs (List[str], optional): List of directories to include exclusively. - When provided, only files in these directories will be processed. - included_files (List[str], optional): List of file patterns to include exclusively. - When provided, only files matching these patterns will be processed. - - Returns: - list: A list of Document objects with metadata. - """ - # Handle backward compatibility - if embedder_type is None and is_ollama_embedder is not None: - embedder_type = 'ollama' if is_ollama_embedder else None - documents = [] - # File extensions to look for, prioritizing code files - code_extensions = [".py", ".js", ".ts", ".java", ".cpp", ".c", ".h", ".hpp", ".go", ".rs", - ".jsx", ".tsx", ".html", ".css", ".php", ".swift", ".cs"] - doc_extensions = [".md", ".txt", ".rst", ".json", ".yaml", ".yml"] - - # Determine filtering mode: inclusion or exclusion - use_inclusion_mode = bool(included_dirs or included_files) - - if use_inclusion_mode: - # Inclusion mode: only process specified directories and files - included_dirs = list(set(included_dirs)) if included_dirs else list() - included_files = list(set(included_files)) if included_files else list() - - logger.info(f"Using inclusion mode") - logger.info(f"Included directories: {included_dirs}") - logger.info(f"Included files: {included_files}") - - # Convert to lists for processing - excluded_dirs = [] - excluded_files = [] - else: - # Exclusion mode: use default exclusions plus any additional ones - final_excluded_dirs = set(DEFAULT_EXCLUDED_DIRS) - final_excluded_files = set(DEFAULT_EXCLUDED_FILES) - - # Add any additional excluded directories from config - if "file_filters" in configs and "excluded_dirs" in configs["file_filters"]: - final_excluded_dirs.update(configs["file_filters"]["excluded_dirs"]) - - # Add any additional excluded files from config - if "file_filters" in configs and "excluded_files" in configs["file_filters"]: - final_excluded_files.update(configs["file_filters"]["excluded_files"]) - - # Add any explicitly provided excluded directories and files - if excluded_dirs is not None: - final_excluded_dirs.update(excluded_dirs) - - if excluded_files is not None: - final_excluded_files.update(excluded_files) - - # Convert back to lists for compatibility - excluded_dirs = list(final_excluded_dirs) - excluded_files = list(final_excluded_files) - included_dirs = [] - included_files = [] - - logger.info(f"Using exclusion mode") - logger.info(f"Excluded directories: {excluded_dirs}") - logger.info(f"Excluded files: {excluded_files}") - - logger.info(f"Reading documents from {path}") - - def should_process_file(file_path: str, use_inclusion: bool, included_dirs: List[str], included_files: List[str], - excluded_dirs: List[str], excluded_files: List[str]) -> bool: - """ - Determine if a file should be processed based on inclusion/exclusion rules. - - Args: - file_path (str): The file path to check - use_inclusion (bool): Whether to use inclusion mode - included_dirs (List[str]): List of directories to include - included_files (List[str]): List of files to include - excluded_dirs (List[str]): List of directories to exclude - excluded_files (List[str]): List of files to exclude - - Returns: - bool: True if the file should be processed, False otherwise - """ - file_path_parts = os.path.normpath(file_path).split(os.sep) - file_name = os.path.basename(file_path) - - if use_inclusion: - # Inclusion mode: file must be in included directories or match included files - is_included = False - - # Check if file is in an included directory - if included_dirs: - for included in included_dirs: - clean_included = included.strip("./").rstrip("/") - if clean_included in file_path_parts: - is_included = True - break - - # Check if file matches included file patterns - if not is_included and included_files: - for included_file in included_files: - if file_name == included_file or file_name.endswith(included_file): - is_included = True - break - - # If no inclusion rules are specified for a category, allow all files from that category - if not included_dirs and not included_files: - is_included = True - elif not included_dirs and included_files: - # Only file patterns specified, allow all directories - pass # is_included is already set based on file patterns - elif included_dirs and not included_files: - # Only directory patterns specified, allow all files in included directories - pass # is_included is already set based on directory patterns - - return is_included - else: - # Exclusion mode: file must not be in excluded directories or match excluded files - is_excluded = False - - # Check if file is in an excluded directory - for excluded in excluded_dirs: - clean_excluded = excluded.strip("./").rstrip("/") - if clean_excluded in file_path_parts: - is_excluded = True - break - - # Check if file matches excluded file patterns - if not is_excluded: - for excluded_file in excluded_files: - if file_name == excluded_file: - is_excluded = True - break - - return not is_excluded - - for file_path in filter( - lambda p: should_process_file( - p, - use_inclusion=use_inclusion_mode, - included_dirs=included_dirs, - included_files=included_files, - excluded_dirs=excluded_dirs, - excluded_files=excluded_files, - ), - filter( - lambda p: p.suffix.lower() in code_extensions + doc_extensions, - Path(path).rglob(pattern="**/*"), - ), - ): - try: - with open(file_path, "r", encoding="utf-8") as f: - content = f.read() - relative_path = os.path.relpath(file_path, path) - - # Check token count - token_count = count_tokens(content, embedder_type) - if token_count > MAX_EMBEDDING_TOKENS * 10: - logger.warning(f"Skipping large file {relative_path}: Token count ({token_count}) exceeds limit") - continue - - file_ext = file_path.suffix.lower() - is_code = file_ext in code_extensions - # Determine if this is an implementation file - if is_code: - is_implementation = ( - not relative_path.startswith("test_") - and not relative_path.startswith("app_") - and "test" not in relative_path.lower() - ) - else: - is_implementation = False - - doc = Document( - text=content, - meta_data={ - "file_path": relative_path, - "type": file_ext, - "is_code": is_code, - "is_implementation": is_implementation, - "title": relative_path, - "token_count": token_count, - }, - ) - documents.append(doc) - except Exception as e: - logger.error(f"Error reading {file_path}: {e}") - - logger.info(f"Found {len(documents)} documents") - return documents - -def prepare_data_pipeline(embedder_type: str = None, is_ollama_embedder: bool = None): - """ - Creates and returns the data transformation pipeline. - - Args: - embedder_type (str, optional): The embedder type ('openai', 'google', 'ollama'). - If None, will be determined from configuration. - is_ollama_embedder (bool, optional): DEPRECATED. Use embedder_type instead. - If None, will be determined from configuration. - - Returns: - adal.Sequential: The data transformation pipeline - """ - from api.config import get_embedder_config, get_embedder_type - - # Handle backward compatibility - if embedder_type is None and is_ollama_embedder is not None: - embedder_type = 'ollama' if is_ollama_embedder else None - - # Determine embedder type if not specified - if embedder_type is None: - embedder_type = get_embedder_type() - - splitter = TextSplitter(**configs["text_splitter"]) - embedder_config = get_embedder_config() - - embedder = get_embedder(embedder_type=embedder_type) - - batch_size = embedder_config.get("batch_size", 500) - embedder_transformer = ToEmbeddings( - embedder=embedder, batch_size=batch_size - ) - - data_transformer = adal.Sequential( - splitter, embedder_transformer - ) # sequential will chain together splitter and embedder - return data_transformer - -def transform_documents_and_save_to_db( - documents: List[Document], db_path: str, embedder_type: str = None, is_ollama_embedder: bool = None -) -> LocalDB: - """ - Transforms a list of documents and saves them to a local database. - - Args: - documents (list): A list of `Document` objects. - db_path (str): The path to the local database file. - embedder_type (str, optional): The embedder type ('openai', 'google', 'ollama'). - If None, will be determined from configuration. - is_ollama_embedder (bool, optional): DEPRECATED. Use embedder_type instead. - If None, will be determined from configuration. - """ - # Get the data transformer - data_transformer = prepare_data_pipeline(embedder_type, is_ollama_embedder) - - # Save the documents to a local database - db = LocalDB() - db.register_transformer(transformer=data_transformer, key="split_and_embed") - db.load(documents) - db.transform(key="split_and_embed") - os.makedirs(os.path.dirname(db_path), exist_ok=True) - db.save_state(filepath=db_path) - return db - -def get_github_file_content(repo_url: str, file_path: str, access_token: str = None) -> str: - """ - Retrieves the content of a file from a GitHub repository using the GitHub API. - Supports both public GitHub (github.com) and GitHub Enterprise (custom domains). - - Args: - repo_url (str): The URL of the GitHub repository - (e.g., "https://github.com/username/repo" or "https://github.company.com/username/repo") - file_path (str): The path to the file within the repository (e.g., "src/main.py") - access_token (str, optional): GitHub personal access token for private repositories - - Returns: - str: The content of the file as a string - - Raises: - ValueError: If the file cannot be fetched or if the URL is not a valid GitHub URL - """ - try: - # Parse the repository URL to support both github.com and enterprise GitHub - parsed_url = urlparse(repo_url) - if not parsed_url.scheme or not parsed_url.netloc: - raise ValueError("Not a valid GitHub repository URL") - - # Check if it's a GitHub-like URL structure - path_parts = parsed_url.path.strip('/').split('/') - if len(path_parts) < 2: - raise ValueError("Invalid GitHub URL format - expected format: https://domain/owner/repo") - - owner = path_parts[-2] - repo = path_parts[-1].replace(".git", "") - - # Determine the API base URL - if parsed_url.netloc == "github.com": - # Public GitHub - api_base = "https://api.github.com" - else: - # GitHub Enterprise - API is typically at https://domain/api/v3/ - api_base = f"{parsed_url.scheme}://{parsed_url.netloc}/api/v3" - - # Use GitHub API to get file content - # The API endpoint for getting file content is: /repos/{owner}/{repo}/contents/{path} - api_url = f"{api_base}/repos/{owner}/{repo}/contents/{file_path}" - - # Fetch file content from GitHub API - headers = {} - if access_token: - headers["Authorization"] = f"token {access_token}" - logger.info(f"Fetching file content from GitHub API: {api_url}") - try: - response = requests.get(api_url, headers=headers) - response.raise_for_status() - except RequestException as e: - raise ValueError(f"Error fetching file content: {e}") - try: - content_data = response.json() - except json.JSONDecodeError: - raise ValueError("Invalid response from GitHub API") - - # Check if we got an error response - if "message" in content_data and "documentation_url" in content_data: - raise ValueError(f"GitHub API error: {content_data['message']}") - - # GitHub API returns file content as base64 encoded string - if "content" in content_data and "encoding" in content_data: - if content_data["encoding"] == "base64": - # The content might be split into lines, so join them first - content_base64 = content_data["content"].replace("\n", "") - content = base64.b64decode(content_base64).decode("utf-8") - return content - else: - raise ValueError(f"Unexpected encoding: {content_data['encoding']}") - else: - raise ValueError("File content not found in GitHub API response") - - except Exception as e: - raise ValueError(f"Failed to get file content: {str(e)}") - -def get_gitlab_file_content(repo_url: str, file_path: str, access_token: str = None) -> str: - """ - Retrieves the content of a file from a GitLab repository (cloud or self-hosted). - - Args: - repo_url (str): The GitLab repo URL (e.g., "https://gitlab.com/username/repo" or "http://localhost/group/project") - file_path (str): File path within the repository (e.g., "src/main.py") - access_token (str, optional): GitLab personal access token - - Returns: - str: File content - - Raises: - ValueError: If anything fails - """ - try: - # Parse and validate the URL - parsed_url = urlparse(repo_url) - if not parsed_url.scheme or not parsed_url.netloc: - raise ValueError("Not a valid GitLab repository URL") - - gitlab_domain = f"{parsed_url.scheme}://{parsed_url.netloc}" - if parsed_url.port not in (None, 80, 443): - gitlab_domain += f":{parsed_url.port}" - path_parts = parsed_url.path.strip("/").split("/") - if len(path_parts) < 2: - raise ValueError("Invalid GitLab URL format — expected something like https://gitlab.domain.com/group/project") - - # Build project path and encode for API - project_path = "/".join(path_parts).replace(".git", "") - encoded_project_path = quote(project_path, safe='') - - # Encode file path - encoded_file_path = quote(file_path, safe='') - - # Try to get the default branch from the project info - default_branch = None - try: - project_info_url = f"{gitlab_domain}/api/v4/projects/{encoded_project_path}" - project_headers = {} - if access_token: - project_headers["PRIVATE-TOKEN"] = access_token - - project_response = requests.get(project_info_url, headers=project_headers) - if project_response.status_code == 200: - project_data = project_response.json() - default_branch = project_data.get('default_branch', 'main') - logger.info(f"Found default branch: {default_branch}") - else: - logger.warning(f"Could not fetch project info, using 'main' as default branch") - default_branch = 'main' - except Exception as e: - logger.warning(f"Error fetching project info: {e}, using 'main' as default branch") - default_branch = 'main' - - api_url = f"{gitlab_domain}/api/v4/projects/{encoded_project_path}/repository/files/{encoded_file_path}/raw?ref={default_branch}" - # Fetch file content from GitLab API - headers = {} - if access_token: - headers["PRIVATE-TOKEN"] = access_token - logger.info(f"Fetching file content from GitLab API: {api_url}") - try: - response = requests.get(api_url, headers=headers) - response.raise_for_status() - content = response.text - except RequestException as e: - raise ValueError(f"Error fetching file content: {e}") - - # Check for GitLab error response (JSON instead of raw file) - if content.startswith("{") and '"message":' in content: - try: - error_data = json.loads(content) - if "message" in error_data: - raise ValueError(f"GitLab API error: {error_data['message']}") - except json.JSONDecodeError: - pass - - return content - - except Exception as e: - raise ValueError(f"Failed to get file content: {str(e)}") - -def get_bitbucket_file_content(repo_url: str, file_path: str, access_token: str = None) -> str: - """ - Retrieves the content of a file from a Bitbucket repository using the Bitbucket API. - - Args: - repo_url (str): The URL of the Bitbucket repository (e.g., "https://bitbucket.org/username/repo") - file_path (str): The path to the file within the repository (e.g., "src/main.py") - access_token (str, optional): Bitbucket personal access token for private repositories - - Returns: - str: The content of the file as a string - """ - try: - # Extract owner and repo name from Bitbucket URL - if not (repo_url.startswith("https://bitbucket.org/") or repo_url.startswith("http://bitbucket.org/")): - raise ValueError("Not a valid Bitbucket repository URL") - - parts = repo_url.rstrip('/').split('/') - if len(parts) < 5: - raise ValueError("Invalid Bitbucket URL format") - - owner = parts[-2] - repo = parts[-1].replace(".git", "") - - # Try to get the default branch from the repository info - default_branch = None - try: - repo_info_url = f"https://api.bitbucket.org/2.0/repositories/{owner}/{repo}" - repo_headers = {} - if access_token: - repo_headers["Authorization"] = f"Bearer {access_token}" - - repo_response = requests.get(repo_info_url, headers=repo_headers) - if repo_response.status_code == 200: - repo_data = repo_response.json() - default_branch = repo_data.get('mainbranch', {}).get('name', 'main') - logger.info(f"Found default branch: {default_branch}") - else: - logger.warning(f"Could not fetch repository info, using 'main' as default branch") - default_branch = 'main' - except Exception as e: - logger.warning(f"Error fetching repository info: {e}, using 'main' as default branch") - default_branch = 'main' - - # Use Bitbucket API to get file content - # The API endpoint for getting file content is: /2.0/repositories/{owner}/{repo}/src/{branch}/{path} - api_url = f"https://api.bitbucket.org/2.0/repositories/{owner}/{repo}/src/{default_branch}/{file_path}" - - # Fetch file content from Bitbucket API - headers = {} - if access_token: - headers["Authorization"] = f"Bearer {access_token}" - logger.info(f"Fetching file content from Bitbucket API: {api_url}") - try: - response = requests.get(api_url, headers=headers) - if response.status_code == 200: - content = response.text - elif response.status_code == 404: - raise ValueError("File not found on Bitbucket. Please check the file path and repository.") - elif response.status_code == 401: - raise ValueError("Unauthorized access to Bitbucket. Please check your access token.") - elif response.status_code == 403: - raise ValueError("Forbidden access to Bitbucket. You might not have permission to access this file.") - elif response.status_code == 500: - raise ValueError("Internal server error on Bitbucket. Please try again later.") - else: - response.raise_for_status() - content = response.text - return content - except RequestException as e: - raise ValueError(f"Error fetching file content: {e}") - - except Exception as e: - raise ValueError(f"Failed to get file content: {str(e)}") - - -def get_file_content(repo_url: str, file_path: str, repo_type: str = None, access_token: str = None) -> str: - """ - Retrieves the content of a file from a Git repository (GitHub or GitLab). - - Args: - repo_type (str): Type of repository - repo_url (str): The URL of the repository - file_path (str): The path to the file within the repository - access_token (str, optional): Access token for private repositories - - Returns: - str: The content of the file as a string - - Raises: - ValueError: If the file cannot be fetched or if the URL is not valid - """ - if repo_type == "github": - return get_github_file_content(repo_url, file_path, access_token) - elif repo_type == "gitlab": - return get_gitlab_file_content(repo_url, file_path, access_token) - elif repo_type == "bitbucket": - return get_bitbucket_file_content(repo_url, file_path, access_token) - else: - raise ValueError("Unsupported repository type. Only GitHub, GitLab, and Bitbucket are supported.") - -class DatabaseManager: - """ - Manages the creation, loading, transformation, and persistence of LocalDB instances. - """ - - def __init__(self): - self.db = None - self.repo_url_or_path = None - self.repo_paths = None - - def prepare_database(self, repo_url_or_path: str, repo_type: str = None, access_token: str = None, - embedder_type: str = None, is_ollama_embedder: bool = None, - excluded_dirs: List[str] = None, excluded_files: List[str] = None, - included_dirs: List[str] = None, included_files: List[str] = None) -> List[Document]: - """ - Create a new database from the repository. - - Args: - repo_type(str): Type of repository - repo_url_or_path (str): The URL or local path of the repository - access_token (str, optional): Access token for private repositories - embedder_type (str, optional): Embedder type to use ('openai', 'google', 'ollama'). - If None, will be determined from configuration. - is_ollama_embedder (bool, optional): DEPRECATED. Use embedder_type instead. - If None, will be determined from configuration. - excluded_dirs (List[str], optional): List of directories to exclude from processing - excluded_files (List[str], optional): List of file patterns to exclude from processing - included_dirs (List[str], optional): List of directories to include exclusively - included_files (List[str], optional): List of file patterns to include exclusively - - Returns: - List[Document]: List of Document objects - """ - # Handle backward compatibility - if embedder_type is None and is_ollama_embedder is not None: - embedder_type = 'ollama' if is_ollama_embedder else None - - self.reset_database() - self._create_repo(repo_url_or_path, repo_type, access_token) - return self.prepare_db_index(embedder_type=embedder_type, excluded_dirs=excluded_dirs, excluded_files=excluded_files, - included_dirs=included_dirs, included_files=included_files) - - def reset_database(self): - """ - Reset the database to its initial state. - """ - self.db = None - self.repo_url_or_path = None - self.repo_paths = None - - def _extract_repo_name_from_url(self, repo_url_or_path: str, repo_type: str) -> str: - # Extract owner and repo name to create unique identifier - url_parts = repo_url_or_path.rstrip('/').split('/') - - if repo_type in ["github", "gitlab", "bitbucket"] and len(url_parts) >= 5: - # GitHub URL format: https://github.com/owner/repo - # GitLab URL format: https://gitlab.com/owner/repo or https://gitlab.com/group/subgroup/repo - # Bitbucket URL format: https://bitbucket.org/owner/repo - owner = url_parts[-2] - repo = url_parts[-1].replace(".git", "") - repo_name = f"{owner}_{repo}" - else: - repo_name = url_parts[-1].replace(".git", "") - return repo_name - - def _create_repo(self, repo_url_or_path: str, repo_type: str = None, access_token: str = None) -> None: - """ - Download and prepare all paths. - Paths: - ~/.adalflow/repos/{owner}_{repo_name} (for url, local path will be the same) - ~/.adalflow/databases/{owner}_{repo_name}.pkl - - Args: - repo_type(str): Type of repository - repo_url_or_path (str): The URL or local path of the repository - access_token (str, optional): Access token for private repositories - """ - logger.info(f"Preparing repo storage for {repo_url_or_path}...") - - try: - # Strip whitespace to handle URLs with leading/trailing spaces - repo_url_or_path = repo_url_or_path.strip() - - root_path = get_adalflow_default_root_path() - - os.makedirs(root_path, exist_ok=True) - # url - if repo_url_or_path.startswith("https://") or repo_url_or_path.startswith("http://"): - # Extract the repository name from the URL - repo_name = self._extract_repo_name_from_url(repo_url_or_path, repo_type) - logger.info(f"Extracted repo name: {repo_name}") - - save_repo_dir = os.path.join(root_path, "repos", repo_name) - - # Check if the repository directory already exists and is not empty - if not (os.path.exists(save_repo_dir) and os.listdir(save_repo_dir)): - # Only download if the repository doesn't exist or is empty - download_repo(repo_url_or_path, save_repo_dir, repo_type, access_token) - else: - logger.info(f"Repository already exists at {save_repo_dir}. Using existing repository.") - else: # local path - repo_name = os.path.basename(repo_url_or_path) - save_repo_dir = repo_url_or_path - - save_db_file = os.path.join(root_path, "databases", f"{repo_name}.pkl") - os.makedirs(save_repo_dir, exist_ok=True) - os.makedirs(os.path.dirname(save_db_file), exist_ok=True) - - self.repo_paths = { - "save_repo_dir": save_repo_dir, - "save_db_file": save_db_file, - } - self.repo_url_or_path = repo_url_or_path - logger.info(f"Repo paths: {self.repo_paths}") - - except Exception as e: - logger.error(f"Failed to create repository structure: {e}") - raise - - def prepare_db_index(self, embedder_type: str = None, is_ollama_embedder: bool = None, - excluded_dirs: List[str] = None, excluded_files: List[str] = None, - included_dirs: List[str] = None, included_files: List[str] = None) -> List[Document]: - """ - Prepare the indexed database for the repository. - - Args: - embedder_type (str, optional): Embedder type to use ('openai', 'google', 'ollama'). - If None, will be determined from configuration. - is_ollama_embedder (bool, optional): DEPRECATED. Use embedder_type instead. - If None, will be determined from configuration. - excluded_dirs (List[str], optional): List of directories to exclude from processing - excluded_files (List[str], optional): List of file patterns to exclude from processing - included_dirs (List[str], optional): List of directories to include exclusively - included_files (List[str], optional): List of file patterns to include exclusively - - Returns: - List[Document]: List of Document objects - """ - def _embedding_vector_length(doc: Document) -> int: - vector = getattr(doc, "vector", None) - if vector is None: - return 0 - try: - if hasattr(vector, "shape"): - if len(vector.shape) == 0: - return 0 - return int(vector.shape[-1]) - if hasattr(vector, "__len__"): - return int(len(vector)) - except Exception: - return 0 - return 0 - - # Handle backward compatibility - if embedder_type is None and is_ollama_embedder is not None: - embedder_type = 'ollama' if is_ollama_embedder else None - # check the database - if self.repo_paths and os.path.exists(self.repo_paths["save_db_file"]): - logger.info("Loading existing database...") - try: - self.db = LocalDB.load_state(self.repo_paths["save_db_file"]) - documents = self.db.get_transformed_data(key="split_and_embed") - if documents: - lengths = [_embedding_vector_length(doc) for doc in documents] - non_empty = sum(1 for n in lengths if n > 0) - empty = len(lengths) - non_empty - sample_sizes = sorted({n for n in lengths if n > 0})[:3] - logger.info( - "Loaded %s documents from existing database (embeddings: %s non-empty, %s empty; sample_dims=%s)", - len(documents), - non_empty, - empty, - sample_sizes, - ) - - if non_empty == 0: - logger.warning( - "Existing database contains no usable embeddings. Rebuilding embeddings..." - ) - else: - return documents - except Exception as e: - logger.error(f"Error loading existing database: {e}") - # Continue to create a new database - - # prepare the database - logger.info("Creating new database...") - documents = read_all_documents( - self.repo_paths["save_repo_dir"], - embedder_type=embedder_type, - excluded_dirs=excluded_dirs, - excluded_files=excluded_files, - included_dirs=included_dirs, - included_files=included_files - ) - self.db = transform_documents_and_save_to_db( - documents, self.repo_paths["save_db_file"], embedder_type=embedder_type - ) - logger.info(f"Total documents: {len(documents)}") - transformed_docs = self.db.get_transformed_data(key="split_and_embed") - logger.info(f"Total transformed documents: {len(transformed_docs)}") - return transformed_docs - - def prepare_retriever(self, repo_url_or_path: str, repo_type: str = None, access_token: str = None): - """ - Prepare the retriever for a repository. - This is a compatibility method for the isolated API. - - Args: - repo_type(str): Type of repository - repo_url_or_path (str): The URL or local path of the repository - access_token (str, optional): Access token for private repositories - - Returns: - List[Document]: List of Document objects - """ - return self.prepare_database(repo_url_or_path, repo_type, access_token) diff --git a/api/logger.py b/api/logger.py new file mode 100644 index 000000000..52b289f16 --- /dev/null +++ b/api/logger.py @@ -0,0 +1,107 @@ +import json +import logging +import logging.config +import os +from pathlib import Path +from typing import Any + +__all__ = ["get_logger", "setup_logging"] + + +def get_logger(name: str | None = None) -> logging.Logger: + """Get a logger instance with a specific name in `deepwiki` namespace. + + Parameters + ---------- + name: Optional[str] + The name of the logger. default=None. + + Returns + ------- + logging.Logger + A logger instance with the specific name. + + """ + logger = logging.getLogger("deepwiki") + if name: + logger = logger.getChild(name) + + return logger + + +def _default_log_config( + path: str | None = None, + max_bytes: int = 10485760, # 10MB + backup_count: int = 5, +) -> dict[str, Any]: + handlers: dict[str, dict[str, str | int]] = { + "stdout": { + "class": "logging.StreamHandler", + "level": "INFO", + "formatter": "default", + "stream": "ext://sys.stdout", + }, + } + if path is not None: + log_dir = os.path.dirname(path) + if log_dir: + os.makedirs(log_dir, exist_ok=True) + + handlers["ps_file"] = { + "class": "logging.handlers.RotatingFileHandler", + "level": "INFO", + "formatter": "default", + "filename": path, + "maxBytes": max_bytes, + "backupCount": backup_count, + "encoding": "utf-8", + } + loggers = { + "deepwiki": { + "handlers": list(handlers.keys()), + "level": "INFO", + "propagate": False, + }, + } + + return { + "version": 1, + "disable_existing_loggers": False, + "formatters": { + "default": { + "format": "%(asctime)s - %(levelname)s - %(name)s - %(filename)s:%(lineno)d - %(message)s", + }, + }, + "handlers": handlers, + "loggers": loggers, + } + + +def _ensure_handler_dirs(log_cfg: dict[str, Any]) -> None: + """Create parent directories for any file-based handlers in the config.""" + for handler in log_cfg.get("handlers", {}).values(): + if filename := handler.get("filename"): + log_dir = os.path.dirname(filename) + if log_dir: + os.makedirs(log_dir, exist_ok=True) + + +def setup_logging() -> None: + cfg_path = os.path.join(os.getcwd(), "log_cfg.json") + if os.path.isfile(cfg_path): + print(f"loading config from {cfg_path}") + with open(cfg_path, "r") as f: + log_cfg = json.load(f) + _ensure_handler_dirs(log_cfg) + else: + log_file = os.getenv( + "LOG_FILE_PATH", + (Path(__file__).parent / "logs" / "application.log").as_posix(), + ) + log_cfg = _default_log_config( + log_file, + max_bytes=int(os.getenv("LOG_MAX_SIZE", "10")) * 1024 * 1024, + backup_count=int(os.getenv("LOG_BACKUP_COUNT", "5")), + ) + + logging.config.dictConfig(log_cfg) diff --git a/api/logging_config.py b/api/logging_config.py deleted file mode 100644 index d2726c626..000000000 --- a/api/logging_config.py +++ /dev/null @@ -1,85 +0,0 @@ -import logging -import os -from pathlib import Path -from logging.handlers import RotatingFileHandler - - -class IgnoreLogChangeDetectedFilter(logging.Filter): - def filter(self, record: logging.LogRecord): - return "Detected file change in" not in record.getMessage() - - -def setup_logging(format: str = None): - """ - Configure logging for the application with log rotation. - - Environment variables: - LOG_LEVEL: Log level (default: INFO) - LOG_FILE_PATH: Path to log file (default: logs/application.log) - LOG_MAX_SIZE: Max size in MB before rotating (default: 10MB) - LOG_BACKUP_COUNT: Number of backup files to keep (default: 5) - - Ensures log directory exists, prevents path traversal, and configures - both rotating file and console handlers. - """ - # Determine log directory and default file path - base_dir = Path(__file__).parent - log_dir = base_dir / "logs" - log_dir.mkdir(parents=True, exist_ok=True) - default_log_file = log_dir / "application.log" - - # Get log level from environment - log_level_str = os.environ.get("LOG_LEVEL", "INFO").upper() - log_level = getattr(logging, log_level_str, logging.INFO) - - # Get log file path - log_file_path = Path(os.environ.get("LOG_FILE_PATH", str(default_log_file))) - - # Secure path check: must be inside logs/ directory - log_dir_resolved = log_dir.resolve() - resolved_path = log_file_path.resolve() - if not str(resolved_path).startswith(str(log_dir_resolved) + os.sep): - raise ValueError(f"LOG_FILE_PATH '{log_file_path}' is outside the trusted log directory '{log_dir_resolved}'") - - # Ensure parent directories exist - resolved_path.parent.mkdir(parents=True, exist_ok=True) - - # Get max log file size (default: 10MB) - try: - max_mb = int(os.environ.get("LOG_MAX_SIZE", 10)) # 10MB default - max_bytes = max_mb * 1024 * 1024 - except (TypeError, ValueError): - max_bytes = 10 * 1024 * 1024 # fallback to 10MB on error - - # Get backup count (default: 5) - try: - backup_count = int(os.environ.get("LOG_BACKUP_COUNT", 5)) - except ValueError: - backup_count = 5 - - # Configure format - log_format = format or "%(asctime)s - %(levelname)s - %(name)s - %(filename)s:%(lineno)d - %(message)s" - - # Create handlers - file_handler = RotatingFileHandler(resolved_path, maxBytes=max_bytes, backupCount=backup_count, encoding="utf-8") - console_handler = logging.StreamHandler() - - # Set format for both handlers - formatter = logging.Formatter(log_format) - file_handler.setFormatter(formatter) - console_handler.setFormatter(formatter) - - # Add filter to suppress "Detected file change" messages - file_handler.addFilter(IgnoreLogChangeDetectedFilter()) - console_handler.addFilter(IgnoreLogChangeDetectedFilter()) - - # Apply logging configuration - logging.basicConfig(level=log_level, handlers=[file_handler, console_handler], force=True) - - # Log configuration info - logger = logging.getLogger(__name__) - logger.debug( - f"Logging configured: level={log_level_str}, " - f"file={resolved_path}, max_size={max_bytes} bytes, " - f"backup_count={backup_count}" - ) diff --git a/api/main.py b/api/main.py index fe083f550..1b024874d 100644 --- a/api/main.py +++ b/api/main.py @@ -1,32 +1,37 @@ -import os -import sys import logging +import os + from dotenv import load_dotenv +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +import uvicorn # Load environment variables from .env file +# ruff: noqa: E402 load_dotenv() +# ruff: noqa: E402 -from api.logging_config import setup_logging +from api.logger import get_logger, setup_logging +from api.routers import auth, chat, codemap, repo, system, wiki # Configure logging setup_logging() -logger = logging.getLogger(__name__) +logger = get_logger(__name__) # Configure watchfiles logger to show file paths watchfiles_logger = logging.getLogger("watchfiles.main") watchfiles_logger.setLevel(logging.DEBUG) # Enable DEBUG to see file paths -# Add the current directory to the path so we can import the api package -sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) - # Apply watchfiles monkey patch BEFORE uvicorn import is_development = os.environ.get("NODE_ENV") != "production" if is_development: import watchfiles + current_dir = os.path.dirname(os.path.abspath(__file__)) logs_dir = os.path.join(current_dir, "logs") - + original_watch = watchfiles.watch + def patched_watch(*args, **kwargs): # Only watch the api directory but exclude logs subdirectory # Instead of watching the entire api directory, watch specific subdirectories @@ -37,42 +42,77 @@ def patched_watch(*args, **kwargs): api_subdirs.append(item_path) elif os.path.isfile(item_path) and item.endswith(".py"): api_subdirs.append(item_path) - + return original_watch(*api_subdirs, **kwargs) + watchfiles.watch = patched_watch -import uvicorn +app = FastAPI( + title="Streaming API", + description="API for streaming chat completions and wiki generation", + version="0.1.0", +) + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) -# Check for required environment variables -required_env_vars = ['GOOGLE_API_KEY', 'OPENAI_API_KEY'] -missing_vars = [var for var in required_env_vars if not os.environ.get(var)] -if missing_vars: - logger.warning(f"Missing environment variables: {', '.join(missing_vars)}") - logger.warning("Some functionality may not work correctly without these variables.") +for module in ( + system, + auth, + repo, + wiki, + chat, + codemap, +): + app.include_router(module.router) -# Configure Google Generative AI -import google.generativeai as genai -from api.config import GOOGLE_API_KEY -if GOOGLE_API_KEY: - genai.configure(api_key=GOOGLE_API_KEY) -else: - logger.warning("GOOGLE_API_KEY not configured") +@app.get("/") +async def root(): + """Root endpoint to check if the API is running and list available endpoints dynamically.""" + # Collect routes dynamically from the FastAPI app + endpoints = {} + for route in app.routes: + if hasattr(route, "methods") and hasattr(route, "path"): + # Skip docs and static routes + if route.path in ["/openapi.json", "/docs", "/redoc", "/favicon.ico"]: + continue + # Group endpoints by first path segment + path_parts = route.path.strip("/").split("/") + group = path_parts[0].capitalize() if path_parts[0] else "Root" + method_list = list(route.methods - {"HEAD", "OPTIONS"}) + for method in method_list: + endpoints.setdefault(group, []).append(f"{method} {route.path}") + + # Optionally, sort endpoints for readability + for group in endpoints: + endpoints[group].sort() + + return { + "message": "Welcome to Streaming API", + "version": "1.0.0", + "endpoints": endpoints, + } + if __name__ == "__main__": # Get port from environment variable or use default port = int(os.environ.get("PORT", 8001)) - # Import the app here to ensure environment variables are set first - from api.api import app - logger.info(f"Starting Streaming API on port {port}") # Run the FastAPI app with uvicorn uvicorn.run( - "api.api:app", + "api.main:app", host="0.0.0.0", port=port, reload=is_development, - reload_excludes=["**/logs/*", "**/__pycache__/*", "**/*.pyc"] if is_development else None, + reload_excludes=["**/logs/*", "**/__pycache__/*", "**/*.pyc"] + if is_development + else None, ) diff --git a/api/ollama_patch.py b/api/ollama_patch.py deleted file mode 100644 index cc04b7317..000000000 --- a/api/ollama_patch.py +++ /dev/null @@ -1,49 +0,0 @@ -import logging - -import os - -# Configure logging -from api.logging_config import setup_logging - -setup_logging() -logger = logging.getLogger(__name__) - -class OllamaModelNotFoundError(Exception): - """Custom exception for when Ollama model is not found""" - pass - -def check_ollama_model_exists(model_name: str, ollama_host: str = None) -> bool: - """ - Check if an Ollama model exists before attempting to use it. - - Args: - model_name: Name of the model to check - ollama_host: Ollama host URL, defaults to localhost:11434 - - Returns: - bool: True if model exists, False otherwise - """ - import ollama - import httpx - if ollama_host is None: - ollama_host = os.getenv("OLLAMA_HOST", "http://localhost:11434") - try: - # Remove /api prefix if present and add it back - if ollama_host.endswith('/api'): - ollama_host = ollama_host[:-4] - ret: ollama.ListResponse = ollama.Client(host=ollama_host, timeout=5).list() - is_available = any(model_name == model.model for model in ret.models) - if is_available: - logger.info("Ollama model '%s' is available", model_name) - else: - logger.warning( - "Ollama model '%s' is not available. Available models: %s. ", - model_name, - str([model.model for model in ret.models])) - return is_available - except (httpx.ConnectTimeout, ConnectionError) as e: - logger.warning(f"Could not connect to Ollama to check models: {e}") - return False - except Exception as e: - logger.warning(f"Error checking Ollama model availability: {e}") - return False diff --git a/api/poetry.lock b/api/poetry.lock index afc9b1de9..153ecd4a5 100644 --- a/api/poetry.lock +++ b/api/poetry.lock @@ -227,6 +227,37 @@ files = [ {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, ] +[[package]] +name = "anthropic" +version = "0.117.1" +description = "The official Python library for the anthropic API" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "anthropic-0.117.1-py3-none-any.whl", hash = "sha256:7ab79e2c33c3dbde54c5800c4d535821e1c6d80bdf7de83ae1c0328bae563b76"}, + {file = "anthropic-0.117.1.tar.gz", hash = "sha256:e015a2d5b99fdf0aecac246e24ddb40d7bf93f9fe5b198771fab1559fb0101b9"}, +] + +[package.dependencies] +anyio = ">=3.5.0,<5" +distro = ">=1.7.0,<2" +docstring-parser = ">=0.15,<1" +httpx = ">=0.25.0,<1" +jiter = ">=0.4.0,<1" +pydantic = ">=1.9.0,<3" +sniffio = "*" +typing-extensions = ">=4.14,<5" + +[package.extras] +aiohttp = ["aiohttp", "httpx-aiohttp (>=0.1.9,<1)"] +aws = ["boto3 (>=1.28.57)", "botocore (>=1.31.57)"] +bedrock = ["boto3 (>=1.28.57)", "botocore (>=1.31.57)"] +google-cloud = ["google-auth[requests] (>=2,<3)"] +mcp = ["mcp (>=1.0) ; python_version >= \"3.10\""] +vertex = ["google-auth[requests] (>=2,<3)"] +webhooks = ["standardwebhooks (>=1.0.1,<2)"] + [[package]] name = "anyio" version = "4.11.0" @@ -741,6 +772,23 @@ files = [ {file = "distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed"}, ] +[[package]] +name = "docstring-parser" +version = "0.18.0" +description = "Parse Python docstrings in reST, Google and Numpydoc format" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "docstring_parser-0.18.0-py3-none-any.whl", hash = "sha256:b3fcbed555c47d8479be0796ef7e19c2670d428d72e96da63f3a40122860374b"}, + {file = "docstring_parser-0.18.0.tar.gz", hash = "sha256:292510982205c12b1248696f44959db3cdd1740237a968ea1e2e7a900eeb2015"}, +] + +[package.extras] +dev = ["pre-commit (>=2.16.0) ; python_version >= \"3.9\"", "pydoctor (>=25.4.0)", "pytest"] +docs = ["pydoctor (>=25.4.0)"] +test = ["pytest"] + [[package]] name = "faiss-cpu" version = "1.11.0.post1" @@ -3404,4 +3452,4 @@ propcache = ">=0.2.1" [metadata] lock-version = "2.1" python-versions = "^3.11" -content-hash = "6ba5b45f26d528b886a8c74acebcdc4241bb7ff19474c040244f446bb6d9e784" +content-hash = "86ee62820be3180e14bcd1bb48607994e24ba6648e13a346b8cee26fcdc89a06" diff --git a/api/prompts.py b/api/prompts.py index 7c6641553..f126fa128 100644 --- a/api/prompts.py +++ b/api/prompts.py @@ -189,3 +189,77 @@ - When showing code, include line numbers and file paths when relevant - Use markdown formatting to improve readability """ + + +# Codemap generation — phase 1: analyze the code and produce the codemap skeleton. +CODEMAP_SKELETON_PROMPT = """ +You are an expert code analyst building a "codemap" for the {repo_type} repository: {repo_url} ({repo_name}). +A codemap is a structured, step-by-step guide that answers a usage/how-to question, where every +step is grounded in REAL source code from the repository. +IMPORTANT: All human-readable text (titles, labels, summary) MUST be written in {language_name} language. + + + +Using ONLY the code provided in ..., produce a codemap that +answers the user's question. Organise the answer as numbered sections (1, 2, 3...), each containing +ordered sub-steps (1a, 1b, 1c...). Every sub-step must reference the real source it comes from. + + + +- You may ONLY cite files that appear in the context as a "## File Path: " header. Never invent a path. +- Each chunk in the context is prefixed with a "[lines A-B]" marker. Use those numbers to fill start_line/end_line. +- The "snippet" field MUST be copied VERBATIM from the context (an exact substring). Do not paraphrase it. +- The "code" field is a short example snippet illustrating the step; keep it minimal and runnable-looking. +- If the context does not contain enough to answer, produce fewer sections rather than fabricating. + + + +Output ONLY a single JSON object, no markdown fences, no commentary before or after. Shape: +{{ + "title": "", + "summary": "<1-3 sentence intro; you may reference steps like [1a] [2a]>", + "sections": [ + {{ + "id": "1", + "title": "
", + "guide": "", + "diagram": "", + "steps": [ + {{ + "id": "1a", + "label": "", + "code": "", + "citation": {{ + "file_path": "", + "start_line": , + "end_line": , + "snippet": "" + }} + }} + ] + }} + ] +}} +Leave every "guide" and "diagram" field as an empty string "" in this phase. +""" + + +# Codemap generation — phase 2: fill in prose guides and mermaid diagrams. +CODEMAP_ENRICH_PROMPT = """ +You are enriching an existing codemap skeleton for the {repo_type} repository: {repo_url} ({repo_name}). +IMPORTANT: All prose MUST be written in {language_name} language. + + + +You are given a codemap JSON skeleton (in ) and the original source context. +For EACH section, write: +- "guide": a concise prose explanation (2-4 sentences) of what the section accomplishes. +- "diagram": a valid Mermaid diagram source string (e.g. a "graph LR" or "flowchart TD") that + illustrates the flow of that section. Use only Mermaid syntax; do NOT wrap it in ```mermaid fences. +Keep every other field (title, summary, steps, citations, ids) EXACTLY as given. Do not add or remove steps. + + + +Output ONLY the complete updated JSON object with the same shape as the skeleton, now with +"guide" and "diagram" filled for each section. No markdown fences, no commentary. +""" diff --git a/api/pyproject.toml b/api/pyproject.toml index 1dcdef7dc..ad1f5d32c 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -28,6 +28,7 @@ boto3 = ">=1.34.0" websockets = ">=11.0.3" azure-identity = ">=1.12.0" azure-core = ">=1.24.0" +anthropic = "^0.117.1" [build-system] diff --git a/api/rag.py b/api/rag.py deleted file mode 100644 index 157ed3460..000000000 --- a/api/rag.py +++ /dev/null @@ -1,449 +0,0 @@ -import logging -import asyncio -import os -from dataclasses import dataclass -from typing import Any, List, Tuple, Dict -from uuid import uuid4 - -import adalflow as adal - -from api.tools.embedder import get_embedder -from api.prompts import RAG_SYSTEM_PROMPT as system_prompt, RAG_TEMPLATE - -# Create our own implementation of the conversation classes -@dataclass -class UserQuery: - query_str: str - -@dataclass -class AssistantResponse: - response_str: str - -@dataclass -class DialogTurn: - id: str - user_query: UserQuery - assistant_response: AssistantResponse - -class CustomConversation: - """Custom implementation of Conversation to fix the list assignment index out of range error""" - - def __init__(self): - self.dialog_turns = [] - - def append_dialog_turn(self, dialog_turn): - """Safely append a dialog turn to the conversation""" - if not hasattr(self, 'dialog_turns'): - self.dialog_turns = [] - self.dialog_turns.append(dialog_turn) - -# Import other adalflow components -from adalflow.components.retriever.faiss_retriever import FAISSRetriever -from api.config import configs -from api.data_pipeline import DatabaseManager - -# Configure logging -logger = logging.getLogger(__name__) - -# Maximum token limit for embedding models -MAX_INPUT_TOKENS = 7500 # Safe threshold below 8192 token limit - -# Maximum concurrent RAG preparing count -_RAG_PREPARE_SEMAPHORE: asyncio.Semaphore | None = None - - -def _get_rag_semaphore() -> asyncio.Semaphore: - global _RAG_PREPARE_SEMAPHORE - if _RAG_PREPARE_SEMAPHORE is None: - _RAG_PREPARE_SEMAPHORE = asyncio.Semaphore( - int(os.environ.get("DEEPWIKI_MAX_CONCURRENT_RAG", "4")) - ) - assert isinstance(_RAG_PREPARE_SEMAPHORE, asyncio.Semaphore) - return _RAG_PREPARE_SEMAPHORE - - -class Memory(adal.core.component.DataComponent): - """Simple conversation management with a list of dialog turns.""" - - def __init__(self): - super().__init__() - # Use our custom implementation instead of the original Conversation class - self.current_conversation = CustomConversation() - - def call(self) -> Dict: - """Return the conversation history as a dictionary.""" - all_dialog_turns = {} - try: - # Check if dialog_turns exists and is a list - if hasattr(self.current_conversation, 'dialog_turns'): - if self.current_conversation.dialog_turns: - logger.info(f"Memory content: {len(self.current_conversation.dialog_turns)} turns") - for i, turn in enumerate(self.current_conversation.dialog_turns): - if hasattr(turn, 'id') and turn.id is not None: - all_dialog_turns[turn.id] = turn - logger.info(f"Added turn {i+1} with ID {turn.id} to memory") - else: - logger.warning(f"Skipping invalid turn object in memory: {turn}") - else: - logger.info("Dialog turns list exists but is empty") - else: - logger.info("No dialog_turns attribute in current_conversation") - # Try to initialize it - self.current_conversation.dialog_turns = [] - except Exception as e: - logger.error(f"Error accessing dialog turns: {str(e)}") - # Try to recover - try: - self.current_conversation = CustomConversation() - logger.info("Recovered by creating new conversation") - except Exception as e2: - logger.error(f"Failed to recover: {str(e2)}") - - logger.info(f"Returning {len(all_dialog_turns)} dialog turns from memory") - return all_dialog_turns - - def add_dialog_turn(self, user_query: str, assistant_response: str) -> bool: - """ - Add a dialog turn to the conversation history. - - Args: - user_query: The user's query - assistant_response: The assistant's response - - Returns: - bool: True if successful, False otherwise - """ - try: - # Create a new dialog turn using our custom implementation - dialog_turn = DialogTurn( - id=str(uuid4()), - user_query=UserQuery(query_str=user_query), - assistant_response=AssistantResponse(response_str=assistant_response), - ) - - # Make sure the current_conversation has the append_dialog_turn method - if not hasattr(self.current_conversation, 'append_dialog_turn'): - logger.warning("current_conversation does not have append_dialog_turn method, creating new one") - # Initialize a new conversation if needed - self.current_conversation = CustomConversation() - - # Ensure dialog_turns exists - if not hasattr(self.current_conversation, 'dialog_turns'): - logger.warning("dialog_turns not found, initializing empty list") - self.current_conversation.dialog_turns = [] - - # Safely append the dialog turn - self.current_conversation.dialog_turns.append(dialog_turn) - logger.info(f"Successfully added dialog turn, now have {len(self.current_conversation.dialog_turns)} turns") - return True - - except Exception as e: - logger.error(f"Error adding dialog turn: {str(e)}") - # Try to recover by creating a new conversation - try: - self.current_conversation = CustomConversation() - dialog_turn = DialogTurn( - id=str(uuid4()), - user_query=UserQuery(query_str=user_query), - assistant_response=AssistantResponse(response_str=assistant_response), - ) - self.current_conversation.dialog_turns.append(dialog_turn) - logger.info("Recovered from error by creating new conversation") - return True - except Exception as e2: - logger.error(f"Failed to recover from error: {str(e2)}") - return False - - -from dataclasses import dataclass, field - -@dataclass -class RAGAnswer(adal.DataClass): - rationale: str = field(default="", metadata={"desc": "Chain of thoughts for the answer."}) - answer: str = field(default="", metadata={"desc": "Answer to the user query, formatted in markdown for beautiful rendering with react-markdown. DO NOT include ``` triple backticks fences at the beginning or end of your answer."}) - - __output_fields__ = ["rationale", "answer"] - -class RAG(adal.Component): - """RAG with one repo. - If you want to load a new repos, call prepare_retriever(repo_url_or_path) first.""" - - def __init__(self, provider="google", model=None, use_s3: bool = False): # noqa: F841 - use_s3 is kept for compatibility - """ - Initialize the RAG component. - - Args: - provider: Model provider to use (google, openai, openrouter, ollama) - model: Model name to use with the provider - use_s3: Whether to use S3 for database storage (default: False) - """ - super().__init__() - - self.provider = provider - self.model = model - - # Import the helper functions - from api.config import get_embedder_config, get_embedder_type - - # Determine embedder type based on current configuration - self.embedder_type = get_embedder_type() - self.is_ollama_embedder = (self.embedder_type == 'ollama') # Backward compatibility - - # Check if Ollama model exists before proceeding - if self.is_ollama_embedder: - from api.ollama_patch import check_ollama_model_exists - from api.config import get_embedder_config - - embedder_config = get_embedder_config() - if embedder_config and embedder_config.get("model_kwargs", {}).get("model"): - model_name = embedder_config["model_kwargs"]["model"] - if not check_ollama_model_exists(model_name): - raise Exception(f"Ollama model '{model_name}' not found. Please run 'ollama pull {model_name}' to install it.") - - # Initialize components - self.memory = Memory() - self.embedder = get_embedder(embedder_type=self.embedder_type) - self.initialize_db_manager() - - def initialize_db_manager(self): - """Initialize the database manager with local storage""" - self.db_manager = DatabaseManager() - self.transformed_docs = [] - - def _validate_and_filter_embeddings(self, documents: List) -> List: - """ - Validate embeddings and filter out documents with invalid or mismatched embedding sizes. - - Args: - documents: List of documents with embeddings - - Returns: - List of documents with valid embeddings of consistent size - """ - if not documents: - logger.warning("No documents provided for embedding validation") - return [] - - valid_documents = [] - embedding_sizes = {} - - # First pass: collect all embedding sizes and count occurrences - for i, doc in enumerate(documents): - if not hasattr(doc, 'vector') or doc.vector is None: - logger.warning(f"Document {i} has no embedding vector, skipping") - continue - - try: - if isinstance(doc.vector, list): - embedding_size = len(doc.vector) - elif hasattr(doc.vector, 'shape'): - embedding_size = doc.vector.shape[0] if len(doc.vector.shape) == 1 else doc.vector.shape[-1] - elif hasattr(doc.vector, '__len__'): - embedding_size = len(doc.vector) - else: - logger.warning(f"Document {i} has invalid embedding vector type: {type(doc.vector)}, skipping") - continue - - if embedding_size == 0: - logger.warning(f"Document {i} has empty embedding vector, skipping") - continue - - embedding_sizes[embedding_size] = embedding_sizes.get(embedding_size, 0) + 1 - - except Exception as e: - logger.warning(f"Error checking embedding size for document {i}: {str(e)}, skipping") - continue - - if not embedding_sizes: - logger.error("No valid embeddings found in any documents") - return [] - - # Find the most common embedding size (this should be the correct one) - target_size = max(embedding_sizes.keys(), key=lambda k: embedding_sizes[k]) - logger.info(f"Target embedding size: {target_size} (found in {embedding_sizes[target_size]} documents)") - - # Log all embedding sizes found - for size, count in embedding_sizes.items(): - if size != target_size: - logger.warning(f"Found {count} documents with incorrect embedding size {size}, will be filtered out") - - # Second pass: filter documents with the target embedding size - for i, doc in enumerate(documents): - if not hasattr(doc, 'vector') or doc.vector is None: - continue - - try: - if isinstance(doc.vector, list): - embedding_size = len(doc.vector) - elif hasattr(doc.vector, 'shape'): - embedding_size = doc.vector.shape[0] if len(doc.vector.shape) == 1 else doc.vector.shape[-1] - elif hasattr(doc.vector, '__len__'): - embedding_size = len(doc.vector) - else: - continue - - if embedding_size == target_size: - valid_documents.append(doc) - else: - # Log which document is being filtered out - file_path = getattr(doc, 'meta_data', {}).get('file_path', f'document_{i}') - logger.warning(f"Filtering out document '{file_path}' due to embedding size mismatch: {embedding_size} != {target_size}") - - except Exception as e: - file_path = getattr(doc, 'meta_data', {}).get('file_path', f'document_{i}') - logger.warning(f"Error validating embedding for document '{file_path}': {str(e)}, skipping") - continue - - logger.info(f"Embedding validation complete: {len(valid_documents)}/{len(documents)} documents have valid embeddings") - - if len(valid_documents) == 0: - logger.error("No documents with valid embeddings remain after filtering") - elif len(valid_documents) < len(documents): - filtered_count = len(documents) - len(valid_documents) - logger.warning(f"Filtered out {filtered_count} documents due to embedding issues") - - return valid_documents - - def prepare_retriever(self, repo_url_or_path: str, type: str = "github", access_token: str = None, - excluded_dirs: List[str] = None, excluded_files: List[str] = None, - included_dirs: List[str] = None, included_files: List[str] = None): - """ - Prepare the retriever for a repository. - Will load database from local storage if available. - - Args: - repo_url_or_path: URL or local path to the repository - access_token: Optional access token for private repositories - excluded_dirs: Optional list of directories to exclude from processing - excluded_files: Optional list of file patterns to exclude from processing - included_dirs: Optional list of directories to include exclusively - included_files: Optional list of file patterns to include exclusively - """ - self.initialize_db_manager() - self.repo_url_or_path = repo_url_or_path - self.transformed_docs = self.db_manager.prepare_database( - repo_url_or_path, - type, - access_token, - embedder_type=self.embedder_type, - excluded_dirs=excluded_dirs, - excluded_files=excluded_files, - included_dirs=included_dirs, - included_files=included_files - ) - logger.info(f"Loaded {len(self.transformed_docs)} documents for retrieval") - - # Validate and filter embeddings to ensure consistent sizes - self.transformed_docs = self._validate_and_filter_embeddings(self.transformed_docs) - - if not self.transformed_docs: - raise ValueError("No valid documents with embeddings found. Cannot create retriever.") - - logger.info(f"Using {len(self.transformed_docs)} documents with valid embeddings for retrieval") - - try: - # Use the appropriate embedder for retrieval - self.retriever = FAISSRetriever( - **configs["retriever"], - embedder=self.embedder, - documents=self.transformed_docs, - document_map_func=lambda doc: doc.vector, - ) - logger.info("FAISS retriever created successfully") - except Exception as e: - logger.error(f"Error creating FAISS retriever: {str(e)}") - # Try to provide more specific error information - if "All embeddings should be of the same size" in str(e): - logger.error("Embedding size validation failed. This suggests there are still inconsistent embedding sizes.") - # Log embedding sizes for debugging - sizes = [] - for i, doc in enumerate(self.transformed_docs[:10]): # Check first 10 docs - if hasattr(doc, 'vector') and doc.vector is not None: - try: - if isinstance(doc.vector, list): - size = len(doc.vector) - elif hasattr(doc.vector, 'shape'): - size = doc.vector.shape[0] if len(doc.vector.shape) == 1 else doc.vector.shape[-1] - elif hasattr(doc.vector, '__len__'): - size = len(doc.vector) - else: - size = "unknown" - sizes.append(f"doc_{i}: {size}") - except Exception: - sizes.append(f"doc_{i}: error") - logger.error(f"Sample embedding sizes: {', '.join(sizes)}") - raise - - async def aprepare_retriever( - self, - repo_url_or_path: str, - type: str = "github", - access_token: str | None = None, - excluded_dirs: list[str] | None = None, - excluded_files: list[str] | None = None, - included_dirs: list[str] | None = None, - included_files: list[str] | None = None, - ): - """Async version of the original `prepare_retriever`. - - Reuse the synchronous `prepare_retriever` implementation, but runs it in - a worker thread via `asyncio.to_thread` so that blocking operations (such - as git.clone, file io, embedding calls) do not stall the outer event loop. - Concurrency is bounded by a module-level semaphore, set by system variable - 'DEEPWIKI_MAX_CONCURRENT_RAG'. - - Args: - repo_url_or_path: URL or local path to the repository - access_token: Optional access token for private repositories - excluded_dirs: Optional list of directories to exclude from processing - excluded_files: Optional list of file patterns to exclude from processing - included_dirs: Optional list of directories to include exclusively - included_files: Optional list of file patterns to include exclusively - """ - async with _get_rag_semaphore(): - return await asyncio.to_thread( - self.prepare_retriever, - repo_url_or_path, - type=type, - access_token=access_token, - excluded_dirs=excluded_dirs, - excluded_files=excluded_files, - included_dirs=included_dirs, - included_files=included_files, - ) - - def call(self, query: str, language: str = "en") -> Tuple[List]: - """ - Process a query using RAG. - - Args: - query: The user's query - - Returns: - Tuple of (RAGAnswer, retrieved_documents) - """ - try: - retrieved_documents = self.retriever(query) - - # Fill in the documents - retrieved_documents[0].documents = [ - self.transformed_docs[doc_index] - for doc_index in retrieved_documents[0].doc_indices - ] - - return retrieved_documents - - except Exception as e: - logger.error(f"Error in RAG call: {str(e)}") - - # Create error response - error_response = RAGAnswer( - rationale="Error occurred while processing the query.", - answer=f"I apologize, but I encountered an error while processing your question. Please try again or rephrase your question." - ) - return error_response, [] - - async def acall(self, query: str, language: str = "en") -> tuple[list]: - """Async version of the original `call` method. - """ - return await asyncio.to_thread(self.call, query, language) diff --git a/api/rag/__init__.py b/api/rag/__init__.py new file mode 100644 index 000000000..a4ccfc9dd --- /dev/null +++ b/api/rag/__init__.py @@ -0,0 +1,8 @@ +from api.rag.pipeline import count_tokens, repo_index_exist +from api.rag.rag import RAG + +__all__ = [ + "RAG", + "count_tokens", + "repo_index_exist", +] diff --git a/api/rag/pipeline.py b/api/rag/pipeline.py new file mode 100644 index 000000000..ab9a34e5c --- /dev/null +++ b/api/rag/pipeline.py @@ -0,0 +1,617 @@ +import os +from pathlib import Path + +import adalflow as adal +import tiktoken +from adalflow.components.data_process import TextSplitter, ToEmbeddings +from adalflow.core.db import LocalDB +from adalflow.core.types import Document, List + +from api.config import ( + configs, + get_embedder, +) +from api.logger import get_logger +from api.repository import Repo + +logger = get_logger(__name__) + +# Maximum token limit for OpenAI embedding models +MAX_EMBEDDING_TOKENS = 8192 + + +def count_tokens( + text: str, embedder_type: str = None, is_ollama_embedder: bool = None +) -> int: + """ + Count the number of tokens in a text string using tiktoken. + + Args: + text (str): The text to count tokens for. + embedder_type (str, optional): The embedder type ('openai', 'google', 'ollama', 'bedrock'). + If None, will be determined from configuration. + is_ollama_embedder (bool, optional): DEPRECATED. Use embedder_type instead. + If None, will be determined from configuration. + + Returns: + int: The number of tokens in the text. + """ + try: + # Handle backward compatibility + if embedder_type is None and is_ollama_embedder is not None: + embedder_type = "ollama" if is_ollama_embedder else None + + # Determine embedder type if not specified + if embedder_type is None: + from api.config import get_embedder_type + + embedder_type = get_embedder_type() + + # Choose encoding based on embedder type + if embedder_type == "ollama": + # Ollama typically uses cl100k_base encoding + encoding = tiktoken.get_encoding("cl100k_base") + elif embedder_type == "google": + # Google uses similar tokenization to GPT models for rough estimation + encoding = tiktoken.get_encoding("cl100k_base") + elif embedder_type == "bedrock": + # Bedrock embedding models vary; use a common GPT-like encoding for rough estimation + encoding = tiktoken.get_encoding("cl100k_base") + else: # OpenAI or default + # Use OpenAI embedding model encoding + encoding = tiktoken.encoding_for_model("text-embedding-3-small") + + return len(encoding.encode(text)) + except Exception as e: + # Fallback to a simple approximation if tiktoken fails + logger.warning(f"Error counting tokens with tiktoken: {e}") + # Rough approximation: 4 characters per token + return len(text) // 4 + + +def _should_process_file( + file_path: Path, + use_inclusion: bool, + included_dirs: list[str] | None, + included_files: list[str] | None, + excluded_dirs: list[str] | None, + excluded_files: list[str] | None, +) -> bool: + """ + Determine if a file should be processed based on inclusion/exclusion rules. + + Args: + file_path (str): The file path to check + use_inclusion (bool): Whether to use inclusion mode + included_dirs (List[str]): List of directories to include + included_files (List[str]): List of files to include + excluded_dirs (List[str]): List of directories to exclude + excluded_files (List[str]): List of files to exclude + + Returns: + bool: True if the file should be processed, False otherwise + """ + if isinstance(file_path, str): + file_path = Path(file_path) + file_path_parts = file_path.resolve().parts + file_name = file_path_parts[-1] + + if use_inclusion: + # Inclusion mode: file must be in included directories or match included files + is_included = False + + # Check if file is in an included directory + if included_dirs: + for included in included_dirs: + clean_included = included.removeprefix("./").rstrip("/") + if clean_included in file_path_parts: + is_included = True + break + + # Check if file matches included file patterns + if not is_included and included_files: + for included_file in included_files: + if file_name == included_file or file_name.endswith(included_file): + is_included = True + break + + # If no inclusion rules are specified for a category, allow all files from that category + if not included_dirs and not included_files: + is_included = True + elif not included_dirs and included_files: + # Only file patterns specified, allow all directories + pass # is_included is already set based on file patterns + elif included_dirs and not included_files: + # Only directory patterns specified, allow all files in included directories + pass # is_included is already set based on directory patterns + + return is_included + else: + # Exclusion mode: file must not be in excluded directories or match excluded files + is_excluded = False + + # Check if file is in an excluded directory + if excluded_dirs: + for excluded in excluded_dirs: + clean_excluded = excluded.removeprefix("./").rstrip("/") + if clean_excluded in file_path_parts: + is_excluded = True + break + + # Check if file matches excluded file patterns + if not is_excluded and excluded_files: + for excluded_file in excluded_files: + if file_name == excluded_file: + is_excluded = True + break + + return not is_excluded + + +def read_all_documents( + path: str, + embedder_type: str = None, + is_ollama_embedder: bool = None, + excluded_dirs: list[str] | None = None, + excluded_files: list[str] | None = None, + included_dirs: list[str] | None = None, + included_files: list[str] | None = None, +): + """ + Recursively reads all documents in a directory and its subdirectories. + + Args: + path (str): The root directory path. + embedder_type (str, optional): The embedder type ('openai', 'google', 'ollama'). + If None, will be determined from configuration. + is_ollama_embedder (bool, optional): DEPRECATED. Use embedder_type instead. + If None, will be determined from configuration. + excluded_dirs (List[str], optional): List of directories to exclude from processing. + Overrides the default configuration if provided. + excluded_files (List[str], optional): List of file patterns to exclude from processing. + Overrides the default configuration if provided. + included_dirs (List[str], optional): List of directories to include exclusively. + When provided, only files in these directories will be processed. + included_files (List[str], optional): List of file patterns to include exclusively. + When provided, only files matching these patterns will be processed. + + Returns: + list: A list of Document objects with metadata. + """ + # Handle backward compatibility + if embedder_type is None and is_ollama_embedder is not None: + embedder_type = "ollama" if is_ollama_embedder else None + documents = [] + # File extensions to look for, prioritizing code files + code_extensions = configs.get("code_extensions", []) + doc_extensions = configs.get("doc_extensions", []) + + # Determine filtering mode: inclusion or exclusion + use_inclusion_mode = bool(included_dirs or included_files) + + if use_inclusion_mode: + # Inclusion mode: only process specified directories and files + included_dirs = list(set(included_dirs)) if included_dirs else list() + included_files = list(set(included_files)) if included_files else list() + + logger.info("Using inclusion mode") + logger.info(f"Included directories: {included_dirs}") + logger.info(f"Included files: {included_files}") + + # Convert to lists for processing + excluded_dirs = [] + excluded_files = [] + else: + # Exclusion mode: use default exclusions plus any additional ones + file_filters = configs.get("file_filters", {}) + final_excluded_dirs: set[str] = set(file_filters.get("excluded_dirs", [])) + final_excluded_files: set[str] = set(file_filters.get("excluded_files", [])) + + # Add any explicitly provided excluded directories and files + if excluded_dirs is not None: + final_excluded_dirs.update(excluded_dirs) + + if excluded_files is not None: + final_excluded_files.update(excluded_files) + + # Convert back to lists for compatibility + excluded_dirs = list(final_excluded_dirs) + excluded_files = list(final_excluded_files) + included_dirs = [] + included_files = [] + + logger.info("Using exclusion mode") + logger.info(f"Excluded directories: {excluded_dirs}") + logger.info(f"Excluded files: {excluded_files}") + + logger.info(f"Reading documents from {path}") + + for file_path in filter( + lambda p: _should_process_file( + p, + use_inclusion=use_inclusion_mode, + included_dirs=included_dirs, + included_files=included_files, + excluded_dirs=excluded_dirs, + excluded_files=excluded_files, + ), + filter( + lambda p: p.suffix.lower() in code_extensions + doc_extensions, + Path(path).rglob(pattern="**/*"), + ), + ): + try: + with open(file_path, "r", encoding="utf-8") as f: + content = f.read() + relative_path = os.path.relpath(file_path, path) + + # Check token count + token_count = count_tokens(content, embedder_type) + if token_count > MAX_EMBEDDING_TOKENS * 10: + logger.warning( + f"Skipping large file {relative_path}: Token count ({token_count}) exceeds limit" + ) + continue + + file_ext = file_path.suffix.lower() + is_code = file_ext in code_extensions + # Determine if this is an implementation file + if is_code: + is_implementation = ( + not relative_path.startswith("test_") + and not relative_path.startswith("app_") + and "test" not in relative_path.lower() + ) + else: + is_implementation = False + + doc = Document( + text=content, + meta_data={ + "file_path": relative_path, + "type": file_ext, + "is_code": is_code, + "is_implementation": is_implementation, + "title": relative_path, + "token_count": token_count, + }, + ) + documents.append(doc) + except Exception as e: + logger.error(f"Error reading {file_path}: {e}") + + logger.info(f"Found {len(documents)} documents") + return documents + + +def get_repo_db(repo: Repo) -> str: + if not repo.root_path: + raise ValueError(f"Repo root path is empty: {repo}") + save_db_file = os.path.join(repo.root_path, "databases", f"{repo.name}.pkl") + return save_db_file + + +def repo_index_exist(repo: Repo) -> bool: + return os.path.exists(get_repo_db(repo)) + + +class LineTrackingTextSplitter(TextSplitter): + """TextSplitter that annotates each chunk with its 1-based start/end line. + + adalflow's ``TextSplitter`` deep-copies the parent ``meta_data`` once and shares + that single dict across every chunk of the same document, so we first give each + chunk its own copy before writing per-chunk line numbers. Token chunks are exact + substrings of the parent text, so each chunk is located with an order-preserving + substring search to compute its line range. + """ + + def call(self, documents): + parent_text = {doc.id: (doc.text or "") for doc in documents} + split_docs = super().call(documents) + + chunks_by_parent = {} + for chunk in split_docs: + chunks_by_parent.setdefault(chunk.parent_doc_id, []).append(chunk) + + for parent_id, chunks in chunks_by_parent.items(): + text = parent_text.get(parent_id, "") + cursor = 0 + for chunk in sorted(chunks, key=lambda c: c.order): + # Each chunk needs its own meta_data copy (the parent shares one dict). + chunk.meta_data = dict(chunk.meta_data or {}) + pos = text.find(chunk.text, cursor) + if pos == -1: + pos = text.find(chunk.text) # fall back to a global search + if pos == -1: + continue # leave line numbers unset if the chunk can't be located + start_line = text.count("\n", 0, pos) + 1 + end_line = start_line + chunk.text.count("\n") + chunk.meta_data["start_line"] = start_line + chunk.meta_data["end_line"] = end_line + cursor = pos + 1 + return split_docs + + +def prepare_data_pipeline(embedder_type: str = None, is_ollama_embedder: bool = None): + """ + Creates and returns the data transformation pipeline. + + Args: + embedder_type (str, optional): The embedder type ('openai', 'google', 'ollama'). + If None, will be determined from configuration. + is_ollama_embedder (bool, optional): DEPRECATED. Use embedder_type instead. + If None, will be determined from configuration. + + Returns: + adal.Sequential: The data transformation pipeline + """ + from api.config import get_embedder_config, get_embedder_type + + # Handle backward compatibility + if embedder_type is None and is_ollama_embedder is not None: + embedder_type = "ollama" if is_ollama_embedder else None + + # Determine embedder type if not specified + if embedder_type is None: + embedder_type = get_embedder_type() + + splitter = LineTrackingTextSplitter(**configs["text_splitter"]) + embedder_config = get_embedder_config() + + embedder = get_embedder(embedder_type=embedder_type) + + batch_size = embedder_config.get("batch_size", 500) + embedder_transformer = ToEmbeddings(embedder=embedder, batch_size=batch_size) + + data_transformer = adal.Sequential( + splitter, embedder_transformer + ) # sequential will chain together splitter and embedder + return data_transformer + + +def transform_documents_and_save_to_db( + documents: List[Document], + db_path: str, + embedder_type: str = None, + is_ollama_embedder: bool = None, +) -> LocalDB: + """ + Transforms a list of documents and saves them to a local database. + + Args: + documents (list): A list of `Document` objects. + db_path (str): The path to the local database file. + embedder_type (str, optional): The embedder type ('openai', 'google', 'ollama'). + If None, will be determined from configuration. + is_ollama_embedder (bool, optional): DEPRECATED. Use embedder_type instead. + If None, will be determined from configuration. + """ + # Get the data transformer + data_transformer = prepare_data_pipeline(embedder_type, is_ollama_embedder) + + # Save the documents to a local database + db = LocalDB() + db.register_transformer(transformer=data_transformer, key="split_and_embed") + db.load(documents) + db.transform(key="split_and_embed") + os.makedirs(os.path.dirname(db_path), exist_ok=True) + db.save_state(filepath=db_path) + return db + + +class DatabaseManager: + """ + Manages the creation, loading, transformation, and persistence of LocalDB instances. + """ + + def __init__(self): + self.db = None + self.repo_url_or_path = None + self.repo_paths = None + + def prepare_database( + self, + repo_url_or_path: str, + repo_type: str = None, + access_token: str = None, + embedder_type: str = None, + is_ollama_embedder: bool = None, + excluded_dirs: List[str] = None, + excluded_files: List[str] = None, + included_dirs: List[str] = None, + included_files: List[str] = None, + ) -> List[Document]: + """ + Create a new database from the repository. + + Args: + repo_type(str): Type of repository + repo_url_or_path (str): The URL or local path of the repository + access_token (str, optional): Access token for private repositories + embedder_type (str, optional): Embedder type to use ('openai', 'google', 'ollama'). + If None, will be determined from configuration. + is_ollama_embedder (bool, optional): DEPRECATED. Use embedder_type instead. + If None, will be determined from configuration. + excluded_dirs (List[str], optional): List of directories to exclude from processing + excluded_files (List[str], optional): List of file patterns to exclude from processing + included_dirs (List[str], optional): List of directories to include exclusively + included_files (List[str], optional): List of file patterns to include exclusively + + Returns: + List[Document]: List of Document objects + """ + # Handle backward compatibility + if embedder_type is None and is_ollama_embedder is not None: + embedder_type = "ollama" if is_ollama_embedder else None + + self.reset_database() + self._create_repo(repo_url_or_path, repo_type, access_token) + return self.prepare_db_index( + embedder_type=embedder_type, + excluded_dirs=excluded_dirs, + excluded_files=excluded_files, + included_dirs=included_dirs, + included_files=included_files, + ) + + def reset_database(self): + """ + Reset the database to its initial state. + """ + self.db = None + self.repo_url_or_path = None + self.repo_paths = None + + def _create_repo( + self, repo_url_or_path: str, repo_type: str = None, access_token: str = None + ) -> None: + """ + Download and prepare all paths. + Paths: + ~/.adalflow/repos/{owner}_{repo_name} (for url, local path will be the same) + ~/.adalflow/databases/{owner}_{repo_name}.pkl + + Args: + repo_type(str): Type of repository + repo_url_or_path (str): The URL or local path of the repository + access_token (str, optional): Access token for private repositories + """ + logger.info(f"Preparing repo storage for {repo_url_or_path}...") + + try: + # Strip whitespace to handle URLs with leading/trailing spaces + repo_url_or_path = repo_url_or_path.strip() + repo = Repo( + repo_url=repo_url_or_path, + repo_type=repo_type, + access_token=access_token, + ) + logger.info(f"Extracted repo name: {repo.name}") + if not repo.downloaded: + repo.download() + else: + logger.info( + f"Repository already exists at {repo.save_path}. Using existing repository." + ) + + save_db_file = get_repo_db(repo) + os.makedirs(os.path.dirname(save_db_file), exist_ok=True) + + self.repo_paths = { + "save_repo_dir": repo.save_path, + "save_db_file": save_db_file, + } + self.repo_url_or_path = repo_url_or_path + logger.info(f"Repo paths: {self.repo_paths}") + + except Exception as e: + logger.error(f"Failed to create repository structure: {e}") + raise + + def prepare_db_index( + self, + embedder_type: str = None, + is_ollama_embedder: bool = None, + excluded_dirs: List[str] = None, + excluded_files: List[str] = None, + included_dirs: List[str] = None, + included_files: List[str] = None, + ) -> List[Document]: + """ + Prepare the indexed database for the repository. + + Args: + embedder_type (str, optional): Embedder type to use ('openai', 'google', 'ollama'). + If None, will be determined from configuration. + is_ollama_embedder (bool, optional): DEPRECATED. Use embedder_type instead. + If None, will be determined from configuration. + excluded_dirs (List[str], optional): List of directories to exclude from processing + excluded_files (List[str], optional): List of file patterns to exclude from processing + included_dirs (List[str], optional): List of directories to include exclusively + included_files (List[str], optional): List of file patterns to include exclusively + + Returns: + List[Document]: List of Document objects + """ + + def _embedding_vector_length(doc: Document) -> int: + vector = getattr(doc, "vector", None) + if vector is None: + return 0 + try: + if hasattr(vector, "shape"): + if len(vector.shape) == 0: + return 0 + return int(vector.shape[-1]) + if hasattr(vector, "__len__"): + return int(len(vector)) + except Exception: + return 0 + return 0 + + # Handle backward compatibility + if embedder_type is None and is_ollama_embedder is not None: + embedder_type = "ollama" if is_ollama_embedder else None + # check the database + if self.repo_paths and os.path.exists(self.repo_paths["save_db_file"]): + logger.info("Loading existing database...") + try: + self.db = LocalDB.load_state(self.repo_paths["save_db_file"]) + documents = self.db.get_transformed_data(key="split_and_embed") + if documents: + lengths = [_embedding_vector_length(doc) for doc in documents] + non_empty = sum(1 for n in lengths if n > 0) + empty = len(lengths) - non_empty + sample_sizes = sorted({n for n in lengths if n > 0})[:3] + logger.info( + "Loaded %s documents from existing database (embeddings: %s non-empty, %s empty; sample_dims=%s)", + len(documents), + non_empty, + empty, + sample_sizes, + ) + + if non_empty == 0: + logger.warning( + "Existing database contains no usable embeddings. Rebuilding embeddings..." + ) + else: + return documents + except Exception as e: + logger.error(f"Error loading existing database: {e}") + # Continue to create a new database + + # prepare the database + logger.info("Creating new database...") + documents = read_all_documents( + self.repo_paths["save_repo_dir"], + embedder_type=embedder_type, + excluded_dirs=excluded_dirs, + excluded_files=excluded_files, + included_dirs=included_dirs, + included_files=included_files, + ) + self.db = transform_documents_and_save_to_db( + documents, self.repo_paths["save_db_file"], embedder_type=embedder_type + ) + logger.info(f"Total documents: {len(documents)}") + transformed_docs = self.db.get_transformed_data(key="split_and_embed") + logger.info(f"Total transformed documents: {len(transformed_docs)}") + return transformed_docs + + def prepare_retriever( + self, repo_url_or_path: str, repo_type: str = None, access_token: str = None + ): + """ + Prepare the retriever for a repository. + This is a compatibility method for the isolated API. + + Args: + repo_type(str): Type of repository + repo_url_or_path (str): The URL or local path of the repository + access_token (str, optional): Access token for private repositories + + Returns: + List[Document]: List of Document objects + """ + return self.prepare_database(repo_url_or_path, repo_type, access_token) diff --git a/api/rag/rag.py b/api/rag/rag.py new file mode 100644 index 000000000..c9aa57d5a --- /dev/null +++ b/api/rag/rag.py @@ -0,0 +1,385 @@ +import asyncio +import os +from collections import defaultdict +from collections.abc import Sized +from uuid import uuid4 + +import adalflow as adal +from adalflow.components.retriever.faiss_retriever import FAISSRetriever +from adalflow.core.types import ( + AssistantResponse, + DialogTurn, + Document, + RetrieverOutput, + UserQuery, +) + +from api.config import configs, get_embedder +from api.logger import get_logger +from api.rag.pipeline import DatabaseManager + +logger = get_logger(__name__) + +# Maximum concurrent RAG preparing count +_RAG_PREPARE_SEMAPHORE: asyncio.Semaphore | None = None + + +def _get_rag_semaphore() -> asyncio.Semaphore: + global _RAG_PREPARE_SEMAPHORE + if _RAG_PREPARE_SEMAPHORE is None: + _RAG_PREPARE_SEMAPHORE = asyncio.Semaphore( + int(os.environ.get("DEEPWIKI_MAX_CONCURRENT_RAG", "4")) + ) + assert isinstance(_RAG_PREPARE_SEMAPHORE, asyncio.Semaphore) + return _RAG_PREPARE_SEMAPHORE + + +def check_ollama_model_exists(model_name: str, ollama_host: str | None = None) -> bool: + """ + Check if an Ollama model exists before attempting to use it. + + Args: + model_name: Name of the model to check + ollama_host: Ollama host URL, defaults to localhost:11434 + + Returns: + bool: True if model exists, False otherwise + """ + import httpx + import ollama + + if ollama_host is None: + ollama_host = os.getenv("OLLAMA_HOST", "http://localhost:11434") + try: + # Remove /api prefix if present and add it back + ollama_host = ollama_host.removesuffix("/api") + ret: ollama.ListResponse = ollama.Client(host=ollama_host, timeout=5).list() + is_available = any(model_name == model.model for model in ret.models) + if is_available: + logger.info("Ollama model '%s' is available", model_name) + else: + logger.warning( + "Ollama model '%s' is not available. Available models: %s. ", + model_name, + str([model.model for model in ret.models]), + ) + return is_available + except (httpx.ConnectTimeout, ConnectionError) as e: + logger.warning(f"Could not connect to Ollama to check models: {e}") + return False + except Exception as e: + logger.warning(f"Error checking Ollama model availability: {e}") + return False + + +class CustomConversation(list[DialogTurn]): + """Custom implementation of Conversation to fix the list assignment index out of range error""" + + +class Memory(adal.core.component.DataComponent): + """Simple conversation management with a list of dialog turns.""" + + def __init__(self): + super().__init__() + # Use our custom implementation instead of the original Conversation class + self.current_conversation = CustomConversation() + + def call(self) -> dict: + """Return the conversation history as a dictionary.""" + all_dialog_turns = { + dialog_turn.id: dialog_turn for dialog_turn in self.current_conversation + } + logger.info(f"Returning {len(all_dialog_turns)} dialog turns from memory") + return all_dialog_turns + + def add_dialog_turn(self, user_query: str, assistant_response: str) -> None: + """ + Add a dialog turn to the conversation history. + + Args: + user_query: The user's query + assistant_response: The assistant's response + + """ + # Create a new dialog turn using our custom implementation + dialog_turn = DialogTurn( + id=str(uuid4()), + user_query=UserQuery(query_str=user_query), + assistant_response=AssistantResponse(response_str=assistant_response), + ) + + # Safely append the dialog turn + self.current_conversation.append(dialog_turn) + logger.info( + "Successfully added dialog turn, now have %d turns", + len(self.current_conversation), + ) + + +def _get_document_vector_size(document: Document) -> int | None: + if hasattr(document.vector, "shape"): + embedding_size = ( + document.vector.shape[0] + if len(document.vector.shape) == 1 + else document.vector.shape[-1] + ) + elif isinstance(document.vector, Sized): + embedding_size = len(document.vector) + else: + embedding_size = None + + return embedding_size + + +class RAG(adal.Component): + """RAG with one repo. + If you want to load a new repos, call prepare_retriever(repo_url_or_path) first.""" + + def __init__(self, provider="google", model=None, use_s3: bool = False): # noqa: F841 - use_s3 is kept for compatibility + """ + Initialize the RAG component. + + Args: + provider: Model provider to use (google, openai, openrouter, ollama) + model: Model name to use with the provider + use_s3: Whether to use S3 for database storage (default: False) + """ + super().__init__() + + self.provider = provider + self.model = model + + # Import the helper functions + from api.config import get_embedder_type + + # Determine embedder type based on current configuration + self.embedder_type = get_embedder_type() + self.is_ollama_embedder = ( + self.embedder_type == "ollama" + ) # Backward compatibility + + # Check if Ollama model exists before proceeding + if self.is_ollama_embedder: + from api.config import get_embedder_config + + embedder_config = get_embedder_config() + if embedder_config and embedder_config.get("model_kwargs", {}).get("model"): + model_name = embedder_config["model_kwargs"]["model"] + if not check_ollama_model_exists(model_name): + raise ValueError( + f"Ollama model '{model_name}' not found. Please run 'ollama pull {model_name}' to install it." + ) + + # Initialize components + self.memory = Memory() + self.embedder = get_embedder(embedder_type=self.embedder_type) + self.initialize_db_manager() + + def initialize_db_manager(self): + """Initialize the database manager with local storage""" + self.db_manager = DatabaseManager() + self.transformed_docs = [] + + @staticmethod + def _validate_and_filter_embeddings(documents: list[Document]) -> list: + """ + Validate embeddings and filter out documents with invalid or mismatched embedding sizes. + + Args: + documents: List of documents with embeddings + + Returns: + List of documents with valid embeddings of consistent size + """ + if not documents: + logger.warning("No documents provided for embedding validation") + return [] + + docs_embeddings = defaultdict(list) + + for doc, embed_size in filter( + lambda x: isinstance(x[0], Document) and bool(x[1]), + ((x, _get_document_vector_size(x)) for x in documents), + ): + docs_embeddings[embed_size].append(doc) + if not docs_embeddings: + logger.error("No valid embeddings found in any documents") + return [] + + target_size = max(docs_embeddings, key=lambda x: len(docs_embeddings[x])) + logger.info( + "Target embedding size: %s (found in %s documents)", + target_size, + len(docs_embeddings[target_size]), + ) + + valid_documents = docs_embeddings.pop(target_size) + + if docs_embeddings: + for embed_size, docs_list in docs_embeddings.items(): + logger.warning( + "Found %s documents with incorrect embedding size %s, will be filtered out.", + len(docs_list), + str(embed_size), + ) + + if not valid_documents: + logger.warning( + "No documents with valid embeddings remained after filtering" + ) + else: + logger.info( + "Embedding validation complete: %d/%d documents have valid embeddings.", + len(valid_documents), + len(documents), + ) + return valid_documents + + def prepare_retriever( + self, + repo_url_or_path: str, + type: str = "github", + access_token: str | None = None, + excluded_dirs: list[str] | None = None, + excluded_files: list[str] | None = None, + included_dirs: list[str] | None = None, + included_files: list[str] | None = None, + ): + """ + Prepare the retriever for a repository. + Will load database from local storage if available. + + Args: + repo_url_or_path: URL or local path to the repository + access_token: Optional access token for private repositories + excluded_dirs: Optional list of directories to exclude from processing + excluded_files: Optional list of file patterns to exclude from processing + included_dirs: Optional list of directories to include exclusively + included_files: Optional list of file patterns to include exclusively + """ + self.initialize_db_manager() + self.repo_url_or_path = repo_url_or_path + self.transformed_docs = self.db_manager.prepare_database( + repo_url_or_path, + type, + access_token, + embedder_type=self.embedder_type, + excluded_dirs=excluded_dirs, + excluded_files=excluded_files, + included_dirs=included_dirs, + included_files=included_files, + ) + logger.info(f"Loaded {len(self.transformed_docs)} documents for retrieval") + + # Validate and filter embeddings to ensure consistent sizes + self.transformed_docs = self._validate_and_filter_embeddings( + self.transformed_docs + ) + + if not self.transformed_docs: + raise ValueError( + "No valid documents with embeddings found. Cannot create retriever." + ) + + logger.info( + f"Using {len(self.transformed_docs)} documents with valid embeddings for retrieval" + ) + + try: + # Use the appropriate embedder for retrieval + self.retriever = FAISSRetriever( + **configs["retriever"], + embedder=self.embedder, + documents=self.transformed_docs, + document_map_func=lambda doc: doc.vector, + ) + logger.info("FAISS retriever created successfully") + except Exception as e: + logger.error(f"Error creating FAISS retriever: {str(e)}") + # Try to provide more specific error information + if "All embeddings should be of the same size" in str(e): + logger.error( + "Embedding size validation failed. This suggests there are still inconsistent embedding sizes." + ) + # Log embedding sizes for debugging + sizes = [] + for i, doc in enumerate( + self.transformed_docs[:10] + ): # Check first 10 docs + if hasattr(doc, "vector") and doc.vector is not None: + try: + size = _get_document_vector_size(doc) or "unknown" + sizes.append(f"doc_{i}: {size}") + except Exception: + sizes.append(f"doc_{i}: error") + logger.error(f"Sample embedding sizes: {', '.join(sizes)}") + raise + + async def aprepare_retriever( + self, + repo_url_or_path: str, + type: str = "github", + access_token: str | None = None, + excluded_dirs: list[str] | None = None, + excluded_files: list[str] | None = None, + included_dirs: list[str] | None = None, + included_files: list[str] | None = None, + ): + """Async version of the original `prepare_retriever`. + + Reuse the synchronous `prepare_retriever` implementation, but runs it in + a worker thread via `asyncio.to_thread` so that blocking operations (such + as git.clone, file io, embedding calls) do not stall the outer event loop. + Concurrency is bounded by a module-level semaphore, set by system variable + 'DEEPWIKI_MAX_CONCURRENT_RAG'. + + Args: + repo_url_or_path: URL or local path to the repository + access_token: Optional access token for private repositories + excluded_dirs: Optional list of directories to exclude from processing + excluded_files: Optional list of file patterns to exclude from processing + included_dirs: Optional list of directories to include exclusively + included_files: Optional list of file patterns to include exclusively + """ + async with _get_rag_semaphore(): + return await asyncio.to_thread( + self.prepare_retriever, + repo_url_or_path, + type=type, + access_token=access_token, + excluded_dirs=excluded_dirs, + excluded_files=excluded_files, + included_dirs=included_dirs, + included_files=included_files, + ) + + def call( + self, query: str | list[str], language: str = "en" + ) -> list[RetrieverOutput]: + """ + Process a query using RAG. + + Args: + query: The user's query + + Returns: + list of RetrieverOutput. + """ + try: + retrieved_documents = self.retriever(query) + + # Fill in the documents + retrieved_documents[0].documents = [ + self.transformed_docs[doc_index] + for doc_index in retrieved_documents[0].doc_indices + ] + + return retrieved_documents + + except Exception: + logger.exception("Error in RAG call, returning empty list") + return [] + + async def acall(self, query: str, language: str = "en") -> list[RetrieverOutput]: + """Async version of the original `call` method.""" + return await asyncio.to_thread(self.call, query, language) diff --git a/api/repository.py b/api/repository.py new file mode 100644 index 000000000..e56c633b8 --- /dev/null +++ b/api/repository.py @@ -0,0 +1,516 @@ +import base64 +import json +import os +import subprocess +from urllib.parse import quote, urlparse, urlunparse + +import requests +from adalflow.utils import get_adalflow_default_root_path +from requests.exceptions import RequestException + +from api.logger import get_logger + +logger = get_logger(__name__) + + +CLONE_REPO_ROOT = os.path.join(get_adalflow_default_root_path(), "repo") + + +def _get_github_file_content( + repo_url: str, file_path: str, access_token: str = None +) -> str: + """ + Retrieves the content of a file from a GitHub repository using the GitHub API. + Supports both public GitHub (github.com) and GitHub Enterprise (custom domains). + + Args: + repo_url (str): The URL of the GitHub repository + (e.g., "https://github.com/username/repo" or "https://github.company.com/username/repo") + file_path (str): The path to the file within the repository (e.g., "src/main.py") + access_token (str, optional): GitHub personal access token for private repositories + + Returns: + str: The content of the file as a string + + Raises: + ValueError: If the file cannot be fetched or if the URL is not a valid GitHub URL + """ + try: + # Parse the repository URL to support both github.com and enterprise GitHub + parsed_url = urlparse(repo_url) + if not parsed_url.scheme or not parsed_url.netloc: + raise ValueError("Not a valid GitHub repository URL") + + # Check if it's a GitHub-like URL structure + path_parts = parsed_url.path.strip("/").split("/") + if len(path_parts) < 2: + raise ValueError( + "Invalid GitHub URL format - expected format: https://domain/owner/repo" + ) + + owner = path_parts[-2] + repo = path_parts[-1].replace(".git", "") + + # Determine the API base URL + if parsed_url.netloc == "github.com": + # Public GitHub + api_base = "https://api.github.com" + else: + # GitHub Enterprise - API is typically at https://domain/api/v3/ + api_base = f"{parsed_url.scheme}://{parsed_url.netloc}/api/v3" + + # Use GitHub API to get file content + # The API endpoint for getting file content is: /repos/{owner}/{repo}/contents/{path} + api_url = f"{api_base}/repos/{owner}/{repo}/contents/{file_path}" + + # Fetch file content from GitHub API + headers = {} + if access_token: + headers["Authorization"] = f"token {access_token}" + logger.info(f"Fetching file content from GitHub API: {api_url}") + try: + response = requests.get(api_url, headers=headers) + response.raise_for_status() + except RequestException as e: + raise ValueError(f"Error fetching file content: {e}") + try: + content_data = response.json() + except json.JSONDecodeError: + raise ValueError("Invalid response from GitHub API") + + # Check if we got an error response + if "message" in content_data and "documentation_url" in content_data: + raise ValueError(f"GitHub API error: {content_data['message']}") + + # GitHub API returns file content as base64 encoded string + if "content" in content_data and "encoding" in content_data: + if content_data["encoding"] == "base64": + # The content might be split into lines, so join them first + content_base64 = content_data["content"].replace("\n", "") + content = base64.b64decode(content_base64).decode("utf-8") + return content + else: + raise ValueError(f"Unexpected encoding: {content_data['encoding']}") + else: + raise ValueError("File content not found in GitHub API response") + + except Exception as e: + raise ValueError(f"Failed to get file content: {str(e)}") + + +def _get_gitlab_file_content( + repo_url: str, file_path: str, access_token: str = None +) -> str: + """ + Retrieves the content of a file from a GitLab repository (cloud or self-hosted). + + Args: + repo_url (str): The GitLab repo URL (e.g., "https://gitlab.com/username/repo" or "http://localhost/group/project") + file_path (str): File path within the repository (e.g., "src/main.py") + access_token (str, optional): GitLab personal access token + + Returns: + str: File content + + Raises: + ValueError: If anything fails + """ + try: + # Parse and validate the URL + parsed_url = urlparse(repo_url) + if not parsed_url.scheme or not parsed_url.netloc: + raise ValueError("Not a valid GitLab repository URL") + + gitlab_domain = f"{parsed_url.scheme}://{parsed_url.netloc}" + if parsed_url.port not in (None, 80, 443): + gitlab_domain += f":{parsed_url.port}" + path_parts = parsed_url.path.strip("/").split("/") + if len(path_parts) < 2: + raise ValueError( + "Invalid GitLab URL format — expected something like https://gitlab.domain.com/group/project" + ) + + # Build project path and encode for API + project_path = "/".join(path_parts).replace(".git", "") + encoded_project_path = quote(project_path, safe="") + + # Encode file path + encoded_file_path = quote(file_path, safe="") + + # Try to get the default branch from the project info + default_branch = None + try: + project_info_url = f"{gitlab_domain}/api/v4/projects/{encoded_project_path}" + project_headers = {} + if access_token: + project_headers["PRIVATE-TOKEN"] = access_token + + project_response = requests.get(project_info_url, headers=project_headers) + if project_response.status_code == 200: + project_data = project_response.json() + default_branch = project_data.get("default_branch", "main") + logger.info(f"Found default branch: {default_branch}") + else: + logger.warning( + "Could not fetch project info, using 'main' as default branch" + ) + default_branch = "main" + except Exception as e: + logger.warning( + f"Error fetching project info: {e}, using 'main' as default branch" + ) + default_branch = "main" + + api_url = f"{gitlab_domain}/api/v4/projects/{encoded_project_path}/repository/files/{encoded_file_path}/raw?ref={default_branch}" + # Fetch file content from GitLab API + headers = {} + if access_token: + headers["PRIVATE-TOKEN"] = access_token + logger.info(f"Fetching file content from GitLab API: {api_url}") + try: + response = requests.get(api_url, headers=headers) + response.raise_for_status() + content = response.text + except RequestException as e: + raise ValueError(f"Error fetching file content: {e}") + + # Check for GitLab error response (JSON instead of raw file) + if content.startswith("{") and '"message":' in content: + try: + error_data = json.loads(content) + if "message" in error_data: + raise ValueError(f"GitLab API error: {error_data['message']}") + except json.JSONDecodeError: + pass + + return content + + except Exception as e: + raise ValueError(f"Failed to get file content: {str(e)}") + + +def _get_bitbucket_file_content( + repo_url: str, file_path: str, access_token: str = None +) -> str: + """ + Retrieves the content of a file from a Bitbucket repository using the Bitbucket API. + + Args: + repo_url (str): The URL of the Bitbucket repository (e.g., "https://bitbucket.org/username/repo") + file_path (str): The path to the file within the repository (e.g., "src/main.py") + access_token (str, optional): Bitbucket personal access token for private repositories + + Returns: + str: The content of the file as a string + """ + try: + # Extract owner and repo name from Bitbucket URL + if not ( + repo_url.startswith("https://bitbucket.org/") + or repo_url.startswith("http://bitbucket.org/") + ): + raise ValueError("Not a valid Bitbucket repository URL") + + parts = repo_url.rstrip("/").split("/") + if len(parts) < 5: + raise ValueError("Invalid Bitbucket URL format") + + owner = parts[-2] + repo = parts[-1].replace(".git", "") + + # Try to get the default branch from the repository info + default_branch = None + try: + repo_info_url = f"https://api.bitbucket.org/2.0/repositories/{owner}/{repo}" + repo_headers = {} + if access_token: + repo_headers["Authorization"] = f"Bearer {access_token}" + + repo_response = requests.get(repo_info_url, headers=repo_headers) + if repo_response.status_code == 200: + repo_data = repo_response.json() + default_branch = repo_data.get("mainbranch", {}).get("name", "main") + logger.info(f"Found default branch: {default_branch}") + else: + logger.warning( + "Could not fetch repository info, using 'main' as default branch" + ) + default_branch = "main" + except Exception as e: + logger.warning( + f"Error fetching repository info: {e}, using 'main' as default branch" + ) + default_branch = "main" + + # Use Bitbucket API to get file content + # The API endpoint for getting file content is: /2.0/repositories/{owner}/{repo}/src/{branch}/{path} + api_url = f"https://api.bitbucket.org/2.0/repositories/{owner}/{repo}/src/{default_branch}/{file_path}" + + # Fetch file content from Bitbucket API + headers = {} + if access_token: + headers["Authorization"] = f"Bearer {access_token}" + logger.info(f"Fetching file content from Bitbucket API: {api_url}") + try: + response = requests.get(api_url, headers=headers) + if response.status_code == 200: + content = response.text + elif response.status_code == 404: + raise ValueError( + "File not found on Bitbucket. Please check the file path and repository." + ) + elif response.status_code == 401: + raise ValueError( + "Unauthorized access to Bitbucket. Please check your access token." + ) + elif response.status_code == 403: + raise ValueError( + "Forbidden access to Bitbucket. You might not have permission to access this file." + ) + elif response.status_code == 500: + raise ValueError( + "Internal server error on Bitbucket. Please try again later." + ) + else: + response.raise_for_status() + content = response.text + return content + except RequestException as e: + raise ValueError(f"Error fetching file content: {e}") + + except Exception as e: + raise ValueError(f"Failed to get file content: {str(e)}") + + +def get_repo_content( + repo_url: str, file_path: str, repo_type: str = None, access_token: str = None +) -> str: + """ + Retrieves the content of a file from a Git repository (GitHub or GitLab). + + Args: + repo_type (str): Type of repository + repo_url (str): The URL of the repository + file_path (str): The path to the file within the repository + access_token (str, optional): Access token for private repositories + + Returns: + str: The content of the file as a string + + Raises: + ValueError: If the file cannot be fetched or if the URL is not valid + """ + if repo_type == "github": + return _get_github_file_content(repo_url, file_path, access_token) + elif repo_type == "gitlab": + return _get_gitlab_file_content(repo_url, file_path, access_token) + elif repo_type == "bitbucket": + return _get_bitbucket_file_content(repo_url, file_path, access_token) + else: + raise ValueError( + "Unsupported repository type. Only GitHub, GitLab, and Bitbucket are supported." + ) + + +def download_repo( + repo_url: str, local_path: str, repo_type: str = None, access_token: str = None +) -> str: + """ + Downloads a Git repository (GitHub, GitLab, or Bitbucket) to a specified local path. + + Args: + repo_type(str): Type of repository + repo_url (str): The URL of the Git repository to clone. + local_path (str): The local directory where the repository will be cloned. + access_token (str, optional): Access token for private repositories. + + Returns: + str: The output message from the `git` command. + """ + try: + # Check if Git is installed + logger.info(f"Preparing to clone repository to {local_path}") + subprocess.run( + ["git", "--version"], + check=True, + capture_output=True, + ) + + # Check if repository already exists + if os.path.exists(local_path) and os.listdir(local_path): + # Directory exists and is not empty + logger.warning( + f"Repository already exists at {local_path}. Using existing repository." + ) + return f"Using existing repository at {local_path}" + + # Ensure the local path exists + os.makedirs(local_path, exist_ok=True) + + # Prepare the clone URL with access token if provided + clone_url = repo_url + if access_token: + parsed = urlparse(repo_url) + # URL-encode the token to handle special characters + encoded_token = quote(access_token, safe="") + # Determine the repository type and format the URL accordingly + if repo_type == "github": + # Format: https://{token}@{domain}/owner/repo.git + # Works for both github.com and enterprise GitHub domains + clone_url = urlunparse( + ( + parsed.scheme, + f"{encoded_token}@{parsed.netloc}", + parsed.path, + "", + "", + "", + ) + ) + elif repo_type == "gitlab": + # Format: https://oauth2:{token}@gitlab.com/owner/repo.git + clone_url = urlunparse( + ( + parsed.scheme, + f"oauth2:{encoded_token}@{parsed.netloc}", + parsed.path, + "", + "", + "", + ) + ) + elif repo_type == "bitbucket": + # Bitbucket has two token formats with different auth schemes: + # - HTTP access tokens (prefix "ATCTT") use x-bitbucket-api-token-auth + # - App passwords (deprecated, EOL June 2026) use x-token-auth + # Detect by token prefix so existing app password users keep working. + if access_token.startswith("ATCTT"): + auth_scheme = "x-bitbucket-api-token-auth" + else: + auth_scheme = "x-token-auth" + # Format: https://{auth_scheme}:{token}@bitbucket.org/owner/repo.git + clone_url = urlunparse( + ( + parsed.scheme, + f"{auth_scheme}:{encoded_token}@{parsed.netloc}", + parsed.path, + "", + "", + "", + ) + ) + + logger.info("Using access token for authentication") + + # Clone the repository + logger.info(f"Cloning repository from {repo_url} to {local_path}") + # We use repo_url in the log to avoid exposing the token in logs + result = subprocess.run( + ["git", "clone", "--depth=1", "--single-branch", clone_url, local_path], + check=True, + capture_output=True, + ) + + logger.info("Repository cloned successfully") + return result.stdout.decode("utf-8") + + except subprocess.CalledProcessError as e: + error_msg = e.stderr.decode("utf-8") + # Sanitize error message to remove any tokens (both raw and URL-encoded) + if access_token: + # Remove raw token + error_msg = error_msg.replace(access_token, "***TOKEN***") + # Also remove URL-encoded token to prevent leaking encoded version + encoded_token = quote(access_token, safe="") + error_msg = error_msg.replace(encoded_token, "***TOKEN***") + raise ValueError(f"Error during cloning: {error_msg}") + except Exception as e: + raise ValueError(f"An unexpected error occurred: {str(e)}") + + +def _path_is_url(path: str) -> bool: + """Check if the given path is a URL, or local path string. + + Parameters + ---------- + path: str + The path to be checked + + Returns + ------- + bool. True if is a URL, False otherwise + """ + try: + result = urlparse(path) + return result.scheme in {"http", "https", "ftp"} and bool(result.netloc) + except Exception: + return False + + +class Repo: + def __init__( + self, + repo_url: str, + repo_type: str | None, + root_path: str = CLONE_REPO_ROOT, + access_token: str | None = None, + ): + """ + + Parameters + ---------- + repo_url + repo_type + root_path + access_token : str, optional + The access token to use when cloning repository from a private git service. + """ + self.repo_url = repo_url + self.repo_type = repo_type + + os.makedirs(root_path, exist_ok=True) + self.root_path = root_path + self.access_token = access_token + + @property + def name(self): + return self._extract_repo_name(self.repo_url, repo_type=self.repo_type) + + @property + def is_local(self) -> bool: + return not _path_is_url(self.repo_url) + + @staticmethod + def _extract_repo_name(repo_url: str, repo_type: str | None) -> str: + if _path_is_url(repo_url): + url_parts = repo_url.rstrip("/").split("/") + if repo_type in ["github", "gitlab", "bitbucket"] and len(url_parts) >= 5: + # GitHub URL format: https://github.com/owner/repo + # GitLab URL format: https://gitlab.com/owner/repo or https://gitlab.com/group/subgroup/repo + # Bitbucket URL format: https://bitbucket.org/owner/repo + owner = url_parts[-2] + repo = url_parts[-1].replace(".git", "") + repo_name = f"{owner}_{repo}" + else: + repo_name = url_parts[-1].replace(".git", "") + else: + # This is a local repository + repo_name = os.path.basename(repo_url) + return repo_name + + def download(self, force: bool = False) -> None: + if force or (not self.downloaded and not self.is_local): + os.makedirs(self.save_path, exist_ok=True) + download_repo( + self.repo_url, self.save_path, self.repo_type, self.access_token + ) + + @property + def save_path(self) -> str: + if self.is_local: + return self.repo_url + return os.path.join(self.root_path, self.name) + + @property + def downloaded(self) -> bool: + return os.path.exists(self.save_path) and bool(os.listdir(self.save_path)) diff --git a/api/routers/__init__.py b/api/routers/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/api/routers/auth.py b/api/routers/auth.py new file mode 100644 index 000000000..9607e2317 --- /dev/null +++ b/api/routers/auth.py @@ -0,0 +1,22 @@ +from fastapi import APIRouter + +from api.config import WIKI_AUTH_CODE, WIKI_AUTH_MODE +from api.schemas import AuthorizationConfig + +router = APIRouter(prefix="/auth", tags=["auth"]) + + +@router.get("/status") +async def get_auth_status(): + """ + Check if authentication is required for the wiki. + """ + return {"auth_required": WIKI_AUTH_MODE} + + +@router.post("/validate") +async def validate_auth_code(request: AuthorizationConfig): + """ + Check authorization code. + """ + return {"success": WIKI_AUTH_CODE == request.code} diff --git a/api/routers/chat.py b/api/routers/chat.py new file mode 100644 index 000000000..b33a92529 --- /dev/null +++ b/api/routers/chat.py @@ -0,0 +1,116 @@ +from fastapi import APIRouter, HTTPException, WebSocket, WebSocketDisconnect +from fastapi.responses import StreamingResponse +from fastapi.websockets import WebSocketState + +from api.logger import get_logger +from api.schemas import ChatCompletionRequest +from api.services.research import RepoNotIndexedError, research_chat + +logger = get_logger(__name__) + + +router = APIRouter(tags=["chat"]) + + +async def _send_if_connect(websocket: WebSocket, msg: str): + if websocket.application_state == WebSocketState.CONNECTED: + await websocket.send_text(msg) + + +@router.websocket("/ws/chat") +async def handle_websocket_chat(websocket: WebSocket): + """ + Handle WebSocket connection for chat completions. + This replaces the HTTP streaming endpoint with a WebSocket connection. + """ + await websocket.accept() + try: + request = ChatCompletionRequest(**await websocket.receive_json()) + if not request.messages or len(request.messages) == 0: + await websocket.send_text("Error: No messages provided") + return + + last_message = request.messages[-1] + if last_message.role != "user": + await websocket.send_text("Error: Last message must be from the user") + return + + async for chunk in await research_chat(request): + await websocket.send_text(chunk) + + except WebSocketDisconnect: + logger.info("WebSocket disconnected") + except RepoNotIndexedError as e: + await _send_if_connect(websocket, str(e)) + except ValueError as e: + if "No valid documents with embeddings found" in str(e): + txt_message = "Error: No valid document embeddings found. This may be due to embedding size inconsistencies or API errors during document processing. Please try again or check your repository content." + else: + txt_message = f"Error preparing retriever: {str(e)}" + try: + await _send_if_connect(websocket, txt_message) + except Exception: + pass + except Exception as e: + # Check for specific embedding-related errors + if "All embeddings should be of the same size" in str(e): + txt_message = "Error: Inconsistent embedding sizes detected. Some documents may have failed to embed properly. Please try again." + else: + txt_message = f"Error preparing retriever: {str(e)}" + try: + await _send_if_connect(websocket, txt_message) + except Exception: + pass + finally: + if websocket.application_state == WebSocketState.CONNECTED: + await websocket.close() + + +@router.post("/chat/completions/stream") +async def chat_completions_stream(request: ChatCompletionRequest): + """Stream a chat completion response directly using Google Generative AI""" # Validate request + if not request.messages or len(request.messages) == 0: + raise HTTPException(status_code=400, detail="No messages provided") + + last_message = request.messages[-1] + if last_message.role != "user": + raise HTTPException( + status_code=400, detail="Last message must be from the user" + ) + + try: + async_respond = await research_chat(request=request) + + except RepoNotIndexedError as e: + raise HTTPException(status_code=425, detail=str(e)) + except ValueError as e: + if "No valid documents with embeddings found" in str(e): + raise HTTPException( + status_code=500, + detail="No valid document embeddings found. This may be due to embedding size inconsistencies or API errors during document processing. Please try again or check your repository content.", + ) + else: + raise HTTPException( + status_code=500, detail=f"Error preparing retriever: {str(e)}" + ) + except Exception as e: + if "All embeddings should be of the same size" in str(e): + raise HTTPException( + status_code=500, + detail="Inconsistent embedding sizes detected. Some documents may have failed to embed properly. Please try again.", + ) + else: + raise HTTPException( + status_code=500, detail=f"Error preparing retriever: {str(e)}" + ) + + try: + return StreamingResponse( + async_respond, + media_type="text/event-stream", + ) + + except Exception as e_handler: + error_msg = f"Error in streaming chat completion: {str(e_handler)}" + logger.error(error_msg) + raise HTTPException(status_code=500, detail=error_msg) diff --git a/api/routers/codemap.py b/api/routers/codemap.py new file mode 100644 index 000000000..ca4321db6 --- /dev/null +++ b/api/routers/codemap.py @@ -0,0 +1,68 @@ +from fastapi import APIRouter, HTTPException, Query, WebSocket, WebSocketDisconnect +from fastapi.responses import StreamingResponse +from fastapi.websockets import WebSocketState + +from api.logger import get_logger +from api.schemas import CodeMapRequest +from api.services.codemap import generate_codemap, read_repo_file + +logger = get_logger(__name__) + +router = APIRouter(tags=["codemap"]) + + +@router.websocket("/ws/codemap") +async def handle_websocket_codemap(websocket: WebSocket): + """Stream codemap generation events (NDJSON) over a WebSocket.""" + await websocket.accept() + try: + request = CodeMapRequest(**await websocket.receive_json()) + async for event in generate_codemap(request): + if websocket.application_state != WebSocketState.CONNECTED: + break + await websocket.send_text(event) + except WebSocketDisconnect: + logger.info("Codemap WebSocket disconnected") + except Exception as e: # noqa: BLE001 + logger.error("Codemap generation error: %s", str(e), exc_info=True) + if websocket.application_state == WebSocketState.CONNECTED: + import json + + await websocket.send_text( + json.dumps({"type": "error", "message": str(e)}) + "\n" + ) + finally: + if websocket.application_state == WebSocketState.CONNECTED: + await websocket.close() + + +@router.post("/codemap/stream") +async def codemap_stream(request: CodeMapRequest): + """HTTP fallback: stream codemap generation events as NDJSON.""" + try: + return StreamingResponse( + generate_codemap(request), + media_type="application/x-ndjson", + ) + except Exception as e: # noqa: BLE001 + logger.error("Codemap generation error: %s", str(e), exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/codemap/file") +async def codemap_file( + repo_url: str = Query(..., description="Repository URL or local path"), + file_path: str = Query(..., description="Repository-relative file path"), + type: str = Query("github", description="Repository type"), +): + """Return the full content of a file from the cloned/local repository.""" + try: + content = read_repo_file(repo_url, type, file_path) + return {"file_path": file_path, "content": content} + except FileNotFoundError: + raise HTTPException(status_code=404, detail=f"File not found: {file_path}") + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: # noqa: BLE001 + logger.error("Error reading repo file: %s", str(e), exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) diff --git a/api/routers/repo.py b/api/routers/repo.py new file mode 100644 index 000000000..ea79e9db9 --- /dev/null +++ b/api/routers/repo.py @@ -0,0 +1,78 @@ +import asyncio +import json + +from fastapi import APIRouter, Query +from fastapi.responses import StreamingResponse + +from api.logger import get_logger +from api.rag import repo_index_exist +from api.repository import Repo +from api.schemas import RepoPrepareRequest +from api.services.research import prepare_repo_index as prepare_index + +logger = get_logger(__name__) + +router = APIRouter(prefix="/repo", tags=["repo"]) + + +# Heartbeat cadence MUST stay below the frontend proxy / undici bodyTimeout +# (undici default = 300s). 10s keeps the connection alive with wide margin. +_HEARTBEAT_INTERVAL_SEC = 10 + + +@router.post("/prepare") +async def prepare_repo_index(request: RepoPrepareRequest): + async def event_stream(): + # Fast path: cache already warm -> return immediately. + if repo_index_exist(Repo(repo_url=request.repo_url, repo_type=request.type)): + yield "event: ready\ndata: already indexed\n\n" + yield "event: done\ndata: ok\n\n" + return + + # First byte -> response headers are flushed now (kills the timeout). + yield ": indexing-start\n\n" + + task = asyncio.create_task(prepare_index(request)) + elapsed = 0 + # Wait on the task, but wake up every interval to emit a heartbeat. + while not task.done(): + try: + # shield: a heartbeat timeout must NOT cancel the indexing task. + await asyncio.wait_for( + asyncio.shield(task), timeout=_HEARTBEAT_INTERVAL_SEC + ) + except asyncio.TimeoutError: + elapsed += _HEARTBEAT_INTERVAL_SEC + yield f"event: progress\ndata: {json.dumps({'elapsed_sec': elapsed})}\n\n" + except Exception: + # Task raised; break out and report via task.exception() below. + break + + exc = task.exception() + if exc is not None: + logger.error("Repo indexing failed for %s: %s", request.repo_url, exc) + yield f"event: error\ndata: {json.dumps({'error': str(exc)})}\n\n" + else: + logger.info("Repo indexing complete for %s", request.repo_url) + yield "event: done\ndata: ok\n\n" + + return StreamingResponse( + event_stream(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache, no-transform", + "X-Accel-Buffering": "no", # disable proxy buffering (nginx etc.) + }, + ) + + +@router.get("/index/status") +async def repo_index_status( + repo_url: str = Query(..., description="Repository URL or local path"), + type: str = Query("github", description="Repository type"), +): + """Cheap readiness probe: is the embedding index already built on disk? + + Frontend can poll this instead of holding the /repo/prepare stream open. + """ + return {"ready": repo_index_exist(Repo(repo_url=repo_url, repo_type=type))} diff --git a/api/routers/system.py b/api/routers/system.py new file mode 100644 index 000000000..5883f2dab --- /dev/null +++ b/api/routers/system.py @@ -0,0 +1,84 @@ +from datetime import datetime + +from fastapi import APIRouter + +from api.config import configs +from api.logger import get_logger +from api.schemas import Model, ModelConfig, Provider + +logger = get_logger(__name__) + +router = APIRouter(tags=["system"]) + + +@router.get("/health") +async def health_check(): + """Health check endpoint for Docker and monitoring""" + return { + "status": "healthy", + "timestamp": datetime.now().isoformat(), + "service": "deepwiki-api", + } + + +@router.get("/lang/config") +async def lang_config(): + return configs["lang_config"] + + +@router.get("/models/config", response_model=ModelConfig) +async def get_model_config(): + """ + Get available model providers and their models. + + This endpoint returns the configuration of available model providers and their + respective models that can be used throughout the application. + + Returns: + ModelConfig: A configuration object containing providers and their models + """ + try: + logger.info("Fetching model configurations") + + # Create providers from the config file + providers = [] + default_provider = configs.get("default_provider", "google") + + # Add provider configuration based on config.py + for provider_id, provider_config in configs["providers"].items(): + models = [] + # Add models from config + for model_id in provider_config["models"].keys(): + # Get a more user-friendly display name if possible + models.append(Model(id=model_id, name=model_id)) + + # Add provider with its models + providers.append( + Provider( + id=provider_id, + name=f"{provider_id.capitalize()}", + supportsCustomModel=provider_config.get( + "supportsCustomModel", False + ), + models=models, + ) + ) + + # Create and return the full configuration + config = ModelConfig(providers=providers, defaultProvider=default_provider) + return config + + except Exception as e: + logger.error(f"Error creating model configuration: {str(e)}") + # Return some default configuration in case of error + return ModelConfig( + providers=[ + Provider( + id="google", + name="Google", + supportsCustomModel=True, + models=[Model(id="gemini-2.5-flash", name="Gemini 2.5 Flash")], + ) + ], + defaultProvider="google", + ) diff --git a/api/routers/wiki.py b/api/routers/wiki.py new file mode 100644 index 000000000..cf655e667 --- /dev/null +++ b/api/routers/wiki.py @@ -0,0 +1,228 @@ +import os +from datetime import datetime +from typing import Optional + +from fastapi import APIRouter, HTTPException, Query +from fastapi.responses import JSONResponse, Response + +from api.config import WIKI_AUTH_CODE, WIKI_AUTH_MODE, configs +from api.logger import get_logger +from api.schemas import ( + ProcessedProjectEntry, + WikiCacheData, + WikiCacheRequest, + WikiExportRequest, +) +from api.services.wiki import ( + delete_wiki_cache, + export_wiki, + list_processed_projects, + read_wiki_cache, + save_wiki_cache, +) + +logger = get_logger(__name__) + +router = APIRouter(tags=["wiki"]) + + +@router.post("/export/wiki") +async def post_export_wiki(request: WikiExportRequest): + """ + Export wiki content as Markdown or JSON. + + Args: + request: The export request containing wiki pages and format + + Returns: + A downloadable file in the requested format + """ + logger.info(f"Exporting wiki for {request.repo_url} in {request.format} format") + + # Extract repository name from URL for the filename + repo_parts = request.repo_url.rstrip("/").split("/") + repo_name = repo_parts[-1] if len(repo_parts) > 0 else "wiki" + + timestamp = datetime.now() + + content = export_wiki( + request.repo_url, + pages=request.pages, + format=request.format, + timestamp=timestamp, + ) + filename = f"{repo_name}_wiki_{timestamp.strftime('%Y%m%d_%H%M%S')}" + + if request.format == "markdown": + # Generate Markdown content + filename += ".md" + media_type = "text/markdown" + else: # JSON format + # Generate JSON content + filename += ".json" + media_type = "application/json" + + # Create response with appropriate headers for file download + return Response( + content=content, + media_type=media_type, + headers={"Content-Disposition": f"attachment; filename={filename}"}, + ) + + +@router.get("/local_repo/structure") +async def get_local_repo_structure( + path: str = Query(None, description="Path to local repository"), +): + """Return the file tree and README content for a local repository.""" + if not path: + return JSONResponse( + status_code=400, + content={ + "error": "No path provided. Please provide a 'path' query parameter." + }, + ) + + if not os.path.isdir(path): + return JSONResponse( + status_code=404, content={"error": f"Directory not found: {path}"} + ) + + try: + logger.info(f"Processing local repository at: {path}") + file_tree_lines = [] + readme_content = "" + + for root, dirs, files in os.walk(path): + # Exclude hidden dirs/files and virtual envs + dirs[:] = [ + d + for d in dirs + if not d.startswith(".") + and d != "__pycache__" + and d != "node_modules" + and d != ".venv" + ] + for file in files: + if file.startswith(".") or file == "__init__.py" or file == ".DS_Store": + continue + rel_dir = os.path.relpath(root, path) + rel_file = os.path.join(rel_dir, file) if rel_dir != "." else file + file_tree_lines.append(rel_file) + # Find README.md (case-insensitive) + if file.lower() == "readme.md" and not readme_content: + try: + with open(os.path.join(root, file), "r", encoding="utf-8") as f: + readme_content = f.read() + except Exception as e: + logger.warning(f"Could not read README.md: {str(e)}") + readme_content = "" + + file_tree_str = "\n".join(sorted(file_tree_lines)) + return {"file_tree": file_tree_str, "readme": readme_content} + except Exception as e: + logger.error(f"Error processing local repository: {str(e)}") + return JSONResponse( + status_code=500, + content={"error": f"Error processing local repository: {str(e)}"}, + ) + + +@router.get("/api/wiki_cache", response_model=Optional[WikiCacheData]) +async def read_wiki( + owner: str = Query(..., description="Repository owner"), + repo: str = Query(..., description="Repository name"), + repo_type: str = Query(..., description="Repository type (e.g., github, gitlab)"), + language: str = Query(..., description="Language of the wiki content"), +): + """Retrieve cached wiki data (structure and generated pages) for a repository.""" + supported_langs = configs["lang_config"]["supported_languages"] + if language not in supported_langs: + language = configs["lang_config"]["default"] + + logger.info( + f"Attempting to retrieve wiki cache for {owner}/{repo} ({repo_type}), lang: {language}" + ) + cached_data = await read_wiki_cache(owner, repo, repo_type, language) + if cached_data: + return cached_data + # Return 200 with null body if not found (frontend expects this behavior) + logger.info( + f"Wiki cache not found for {owner}/{repo} ({repo_type}), lang: {language}" + ) + return None + + +@router.post("/api/wiki_cache") +async def save_wiki(request_data: WikiCacheRequest): + """ + Stores generated wiki data (structure and pages) to the server-side cache. + """ + # Language validation + supported_langs = configs["lang_config"]["supported_languages"] + + if request_data.language not in supported_langs: + request_data.language = configs["lang_config"]["default"] + + logger.info( + f"Attempting to save wiki cache for {request_data.repo.owner}/{request_data.repo.repo} ({request_data.repo.type}), lang: {request_data.language}" + ) + success = await save_wiki_cache(request_data) + if success: + return {"message": "Wiki cache saved successfully"} + else: + raise HTTPException(status_code=500, detail="Failed to save wiki cache") + + +@router.delete("/api/wiki_cache") +async def delete_wiki( + owner: str = Query(..., description="Repository owner"), + repo: str = Query(..., description="Repository name"), + repo_type: str = Query(..., description="Repository type (e.g., github, gitlab)"), + language: str = Query(..., description="Language of the wiki content"), + authorization_code: Optional[str] = Query(None, description="Authorization code"), +): + """ + Deletes a specific wiki cache from the file system. + """ + # Language validation + supported_langs = configs["lang_config"]["supported_languages"] + if language not in supported_langs: + raise HTTPException(status_code=400, detail="Language is not supported") + + if WIKI_AUTH_MODE: + logger.info("check the authorization code") + if not authorization_code or WIKI_AUTH_CODE != authorization_code: + raise HTTPException(status_code=401, detail="Authorization code is invalid") + + logger.info( + f"Attempting to delete wiki cache for {owner}/{repo} ({repo_type}), lang: {language}" + ) + + try: + deleted = await delete_wiki_cache(owner, repo, repo_type, language) + except Exception as e: + raise HTTPException( + status_code=500, detail=f"Failed to delete wiki cache: {str(e)}" + ) + + if deleted: + return { + "message": f"Wiki cache for {owner}/{repo} ({language}) deleted successfully" + } + raise HTTPException(status_code=404, detail="Wiki cache not found") + + +@router.get("/api/processed_projects", response_model=list[ProcessedProjectEntry]) +async def get_processed_projects(): + """ + Lists all processed projects found in the wiki cache directory. + Projects are identified by files named like: deepwiki_cache_{repo_type}_{owner}_{repo}_{language}.json + """ + try: + return await list_processed_projects() + except Exception: + raise HTTPException( + status_code=500, + detail="Failed to list processed projects from server cache.", + ) diff --git a/api/schemas/__init__.py b/api/schemas/__init__.py new file mode 100644 index 000000000..b00c85aa8 --- /dev/null +++ b/api/schemas/__init__.py @@ -0,0 +1,49 @@ +from api.schemas.auth import AuthorizationConfig +from api.schemas.chat import ChatCompletionRequest +from api.schemas.codemap import ( + CodeMap, + CodeMapCitation, + CodeMapRequest, + CodeMapSection, + CodeMapStep, +) +from api.schemas.io import aload, asave +from api.schemas.models import ( + Model, + ModelConfig, + Provider, +) +from api.schemas.repo import RepoInfo, RepoPrepareRequest +from api.schemas.wiki import ( + ProcessedProjectEntry, + WikiCacheData, + WikiCacheRequest, + WikiExportRequest, + WikiPage, + WikiSection, + WikiStructureModel, +) + +__all__ = [ + "AuthorizationConfig", + "ChatCompletionRequest", + "CodeMap", + "CodeMapCitation", + "CodeMapRequest", + "CodeMapSection", + "CodeMapStep", + "Model", + "ModelConfig", + "ProcessedProjectEntry", + "Provider", + "RepoInfo", + "RepoPrepareRequest", + "WikiCacheData", + "WikiCacheRequest", + "WikiExportRequest", + "WikiPage", + "WikiSection", + "WikiStructureModel", + "aload", + "asave", +] diff --git a/api/schemas/auth.py b/api/schemas/auth.py new file mode 100644 index 000000000..57609c46a --- /dev/null +++ b/api/schemas/auth.py @@ -0,0 +1,5 @@ +from pydantic import BaseModel, Field + + +class AuthorizationConfig(BaseModel): + code: str = Field(..., description="Authorization code") diff --git a/api/schemas/base.py b/api/schemas/base.py new file mode 100644 index 000000000..b9632a023 --- /dev/null +++ b/api/schemas/base.py @@ -0,0 +1,44 @@ +from typing import Literal +from urllib.parse import unquote + +from pydantic import BaseModel, Field, field_validator + +RepoType = Literal["local", "github", "gitlab", "bitbucket"] + + +class RepoRequestBase(BaseModel): + repo_url: str = Field(..., description="URL or local path of the repository") + type: RepoType | None = Field("github", description="Repository type") + token: str | None = Field(None, description="PAT for private repositories") + provider: str = Field("google", description="Model provider") + model: str | None = Field(None, description="Model name for the provider") + language: str | None = Field("en", description="Language for content generation") + excluded_dirs: list[str] = Field( + default_factory=list, + description="List or newline-separated string of directories to exclude from processing", + ) + excluded_files: list[str] = Field( + default_factory=list, + description="List or newline-separated string of file patterns to exclude from processing", + ) + included_dirs: list[str] = Field( + default_factory=list, + description="List or newline-separated string of directories to include exclusively", + ) + included_files: list[str] = Field( + default_factory=list, + description="List or newline-separated string of file patterns to include exclusively", + ) + + @field_validator( + "excluded_dirs", + "excluded_files", + "included_dirs", + "included_files", + mode="before", + ) + @classmethod + def validate_path(cls, value: list[str] | str) -> list[str]: + if isinstance(value, str): + value = [unquote(p) for p in value.strip().split("\n") if p] + return value diff --git a/api/schemas/chat.py b/api/schemas/chat.py new file mode 100644 index 000000000..c1be4ce6a --- /dev/null +++ b/api/schemas/chat.py @@ -0,0 +1,29 @@ +from typing import Literal + +from pydantic import BaseModel, Field + +from api.schemas.base import RepoRequestBase + + +# Models for the API +class ChatMessage(BaseModel): + role: str # 'user' or 'assistant' + content: str + mode: Literal["normal", "deep_research"] = Field(default="normal") + + +class ChatCompletionRequest(RepoRequestBase): + """ + Model for requesting a chat completion. + """ + + messages: list[ChatMessage] = Field(..., description="List of chat messages") + filePath: str | None = Field( + None, + description="Optional path to a file in the repository to include in the prompt", + ) + research_iteration: int = Field( + default=1, + ge=1, + description="Current deep research iteration (1-based). Only used when the request is in deep_research mode.", + ) diff --git a/api/schemas/codemap.py b/api/schemas/codemap.py new file mode 100644 index 000000000..2e3e7cf86 --- /dev/null +++ b/api/schemas/codemap.py @@ -0,0 +1,62 @@ +from pydantic import BaseModel, Field + +from api.schemas.base import RepoRequestBase + + +class CodeMapCitation(BaseModel): + """A grounded reference from a codemap step back to real source code.""" + + file_path: str = Field( + ..., description="Repository-relative path of the source file" + ) + start_line: int | None = Field( + None, description="1-based start line of the cited range in the source file" + ) + end_line: int | None = Field( + None, description="1-based end line of the cited range in the source file" + ) + snippet: str = Field( + "", + description="Verbatim excerpt copied from the source, used to locate/verify the range", + ) + + +class CodeMapStep(BaseModel): + """A single actionable sub-step (e.g. 1a, 1b) within a section.""" + + id: str = Field(..., description="Human-facing id such as '1a', '1b', '2a'") + label: str = Field(..., description="Short title of the step") + code: str = Field("", description="Example code snippet illustrating the step") + citation: CodeMapCitation | None = Field( + None, description="Where this step's code comes from in the repository" + ) + + +class CodeMapSection(BaseModel): + """A top-level numbered section grouping related steps.""" + + id: str = Field(..., description="Section id such as '1', '2'") + title: str = Field(..., description="Section title") + guide: str = Field( + "", description="Prose guide for the section (filled in phase 2)" + ) + diagram: str = Field( + "", description="Mermaid diagram source for the section (filled in phase 2)" + ) + steps: list[CodeMapStep] = Field(default_factory=list) + + +class CodeMap(BaseModel): + """The full codemap produced for a user's question.""" + + title: str = Field(..., description="Overall codemap title") + summary: str = Field( + "", description="Introductory summary, may contain [1a][2a] citation markers" + ) + sections: list[CodeMapSection] = Field(default_factory=list) + + +class CodeMapRequest(RepoRequestBase): + """Request to generate a codemap for a repository question.""" + + question: str = Field(..., description="The user's how-to / usage question") diff --git a/api/schemas/io.py b/api/schemas/io.py new file mode 100644 index 000000000..ab539a110 --- /dev/null +++ b/api/schemas/io.py @@ -0,0 +1,20 @@ +from typing import TypeVar + +import anyio +from pydantic import BaseModel + +_M = TypeVar("_M", bound=BaseModel) + + +async def asave(model: BaseModel, path: str, *, encoding: str = "utf-8"): + """Asynchronous serialize and save a model""" + + async with await anyio.open_file(path, mode="w", encoding=encoding) as file: + await file.write(model.model_dump_json()) + + +async def aload(model: type[_M], path: str, *, encoding: str = "utf-8") -> _M: + """Asynchronous deserialize and load a model""" + + async with await anyio.open_file(path, mode="r", encoding=encoding) as file: + return model.model_validate_json(await file.read()) diff --git a/api/schemas/models.py b/api/schemas/models.py new file mode 100644 index 000000000..d05e51dab --- /dev/null +++ b/api/schemas/models.py @@ -0,0 +1,36 @@ +from pydantic import BaseModel, Field + + +class Model(BaseModel): + """ + Model for LLM model configuration + """ + + id: str = Field(..., description="Model identifier") + name: str = Field(..., description="Display name for the model") + + +class Provider(BaseModel): + """ + Model for LLM provider configuration + """ + + id: str = Field(..., description="Provider identifier") + name: str = Field(..., description="Display name for the provider") + models: list[Model] = Field( + ..., description="List of available models for this provider" + ) + supportsCustomModel: bool = Field( + False, description="Whether this provider supports custom models" + ) + + +class ModelConfig(BaseModel): + """ + Model for the entire model configuration + """ + + providers: list[Provider] = Field( + ..., description="List of available model providers" + ) + defaultProvider: str = Field(..., description="ID of the default provider") diff --git a/api/schemas/repo.py b/api/schemas/repo.py new file mode 100644 index 000000000..a47dcd0f6 --- /dev/null +++ b/api/schemas/repo.py @@ -0,0 +1,16 @@ +from pydantic import BaseModel + +from api.schemas.base import RepoRequestBase + + +class RepoPrepareRequest(RepoRequestBase): + """Request body for POST /repo/prepare (index warming). No chat messages.""" + + +class RepoInfo(BaseModel): + owner: str + repo: str + type: str + token: str | None = None + localPath: str | None = None + repoUrl: str | None = None diff --git a/api/schemas/wiki.py b/api/schemas/wiki.py new file mode 100644 index 000000000..8ffb6bfa2 --- /dev/null +++ b/api/schemas/wiki.py @@ -0,0 +1,90 @@ +from typing import Literal + +from pydantic import BaseModel, Field + +from api.schemas.repo import RepoInfo + + +class WikiPage(BaseModel): + """ + Model for a wiki page. + """ + + id: str + title: str + content: str + filePaths: list[str] + importance: str # Should ideally be Literal['high', 'medium', 'low'] + relatedPages: list[str] + + +class WikiSection(BaseModel): + """ + Model for the wiki sections. + """ + + id: str + title: str + pages: list[str] + subsections: list[str] | None = None + + +class WikiStructureModel(BaseModel): + """ + Model for the overall wiki structure. + """ + + id: str + title: str + description: str + pages: list[WikiPage] + sections: list[WikiSection] | None = None + rootSections: list[str] | None = None + + +class WikiCacheData(BaseModel): + """ + Model for the data to be stored in the wiki cache. + """ + + wiki_structure: WikiStructureModel + generated_pages: dict[str, WikiPage] + repo_url: str | None = None # compatible for old cache + repo: RepoInfo | None = None + provider: str | None = None + model: str | None = None + + +class WikiCacheRequest(BaseModel): + """ + Model for the request body when saving wiki cache. + """ + + repo: RepoInfo + language: str + wiki_structure: WikiStructureModel + generated_pages: dict[str, WikiPage] + provider: str + model: str + + +class WikiExportRequest(BaseModel): + """ + Model for requesting a wiki export. + """ + + repo_url: str = Field(..., description="URL of the repository") + pages: list[WikiPage] = Field(..., description="List of wiki pages to export") + format: Literal["markdown", "json"] = Field( + ..., description="Export format (markdown or json)" + ) + + +class ProcessedProjectEntry(BaseModel): + id: str # Filename + owner: str + repo: str + name: str # owner/repo + repo_type: str # Renamed from type to repo_type for clarity with existing models + submittedAt: int # Timestamp + language: str # Extracted from filename diff --git a/api/services/__init__.py b/api/services/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/api/services/codemap.py b/api/services/codemap.py new file mode 100644 index 000000000..8c36a313b --- /dev/null +++ b/api/services/codemap.py @@ -0,0 +1,312 @@ +"""Codemap generation pipeline. + +Produces a structured, source-grounded "codemap" for a user's usage/how-to question +using a two-call LLM flow: + + 1. skeleton — analyze code + generate the initial codemap (sections, steps, citations) + 2. enrich — fill in per-section prose guides and mermaid diagrams + +Results are streamed to the client as newline-delimited JSON (NDJSON) events so the UI +can render the three-phase progress (analyzing -> initial codemap -> diagrams/guides). +""" + +import asyncio +import json +import os +import re +from collections.abc import AsyncIterator, Callable + +from api.chat import ChatStreamer, prompt_builder +from api.config import get_model_config +from api.logger import get_logger +from api.prompts import CODEMAP_ENRICH_PROMPT, CODEMAP_SKELETON_PROMPT +from api.rag import RAG +from api.schemas import CodeMap, CodeMapRequest + +logger = get_logger(__name__) + + +def _event(**payload) -> str: + """Serialize one NDJSON event line.""" + return json.dumps(payload, ensure_ascii=False) + "\n" + + +def _phase(phase: str, status: str, **extra) -> str: + return _event(type="phase", phase=phase, status=status, **extra) + + +async def _collect_stream(streamer: ChatStreamer, prompt: str) -> str: + """Run a streaming completion to the end and return the full text.""" + parts: list[str] = [] + async for chunk in streamer.respond_stream(prompt): + parts.append(chunk) + return "".join(parts) + + +async def _generate_json( + streamer_factory: Callable[[], ChatStreamer], + prompt: str, + attempts: int = 3, +) -> dict: + """Collect a completion and parse JSON, retrying on malformed output. + + A fresh streamer is used per attempt so nondeterministic JSON glitches from + smaller local models get another chance to come out valid. + """ + last_error: Exception | None = None + for attempt in range(1, attempts + 1): + raw = await _collect_stream(streamer_factory(), prompt) + try: + return _extract_json(raw) + except Exception as e: # noqa: BLE001 + last_error = e + logger.warning("JSON parse attempt %d/%d failed: %s", attempt, attempts, e) + raise ValueError( + f"Model did not return valid JSON after {attempts} attempts: {last_error}" + ) + + +def _repair_json(candidate: str) -> str: + """Fix a few common LLM JSON glitches (trailing commas, stray quote+space + before a key such as ``" "id":``).""" + repaired = re.sub(r",\s*([}\]])", r"\1", candidate) # trailing commas + repaired = re.sub(r'"\s+"(\w+)"\s*:', r'"\1":', repaired) # `" "key":` -> `"key":` + return repaired + + +def _extract_json(text: str) -> dict: + """Best-effort extraction of a single JSON object from model output. + + Handles ```json fences and leading/trailing prose by scanning for the first + balanced top-level object, with a light repair pass for common glitches. + """ + if not text: + raise ValueError("Empty model response") + + # Strip common code fences. + cleaned = text.strip() + if cleaned.startswith("```"): + # remove opening fence (```json or ```) and trailing fence + cleaned = cleaned.split("\n", 1)[-1] + if cleaned.rstrip().endswith("```"): + cleaned = cleaned.rstrip()[:-3] + + # Isolate the first balanced top-level object (ignoring braces inside strings). + start = cleaned.find("{") + if start == -1: + raise ValueError("No JSON object found in model response") + depth = 0 + in_str = False + escape = False + candidate = cleaned[start:] + for i in range(start, len(cleaned)): + ch = cleaned[i] + if in_str: + if escape: + escape = False + elif ch == "\\": + escape = True + elif ch == '"': + in_str = False + continue + if ch == '"': + in_str = True + elif ch == "{": + depth += 1 + elif ch == "}": + depth -= 1 + if depth == 0: + candidate = cleaned[start : i + 1] + break + + try: + return json.loads(candidate) + except json.JSONDecodeError: + return json.loads(_repair_json(candidate)) # may still raise -> caller retries + + +def _format_context(documents: list) -> str: + """Group retrieved chunks by file and annotate each with its real line range.""" + docs_by_file: dict[str, list] = {} + for doc in documents: + file_path = doc.meta_data.get("file_path", "unknown") + docs_by_file.setdefault(file_path, []).append(doc) + + context_parts = [] + for file_path, docs in docs_by_file.items(): + chunk_texts = [] + for doc in docs: + start_line = doc.meta_data.get("start_line") + end_line = doc.meta_data.get("end_line") + if start_line and end_line: + chunk_texts.append(f"[lines {start_line}-{end_line}]\n{doc.text}") + else: + chunk_texts.append(doc.text) + header = f"## File Path: {file_path}\n\n" + context_parts.append(header + "\n\n".join(chunk_texts)) + + return "\n\n" + ("-" * 10) + "\n\n".join(context_parts) + + +def local_repo_dir(repo_url: str, repo_type: str | None) -> str: + """Resolve the on-disk directory of a cloned/local repository.""" + from adalflow.utils import get_adalflow_default_root_path + + repo_url = repo_url.strip() + if repo_url.startswith("http://") or repo_url.startswith("https://"): + parts = repo_url.rstrip("/").split("/") + if repo_type in ("github", "gitlab", "bitbucket") and len(parts) >= 5: + name = f"{parts[-2]}_{parts[-1].replace('.git', '')}" + else: + name = parts[-1].replace(".git", "") + return os.path.join(get_adalflow_default_root_path(), "repos", name) + return repo_url # already a local path + + +def read_repo_file(repo_url: str, repo_type: str | None, file_path: str) -> str: + """Read a file from the cloned/local repository, guarding against traversal.""" + repo_dir = os.path.realpath(local_repo_dir(repo_url, repo_type)) + target = os.path.realpath(os.path.join(repo_dir, file_path)) + if os.path.commonpath([repo_dir, target]) != repo_dir: + raise ValueError("Resolved path escapes the repository directory") + if not os.path.isfile(target): + raise FileNotFoundError(file_path) + with open(target, "r", encoding="utf-8", errors="replace") as f: + return f.read() + + +def _locate_snippet(text: str, snippet: str) -> tuple[int, int] | None: + """Find the 1-based line range of ``snippet`` inside ``text``. + + LLM-provided line numbers are unreliable, but the snippet is copied verbatim, + so the true location is recovered by searching the real file. + """ + snippet = snippet.strip("\n") + if not snippet: + return None + pos = text.find(snippet) + if pos != -1: + start = text.count("\n", 0, pos) + 1 + return start, start + snippet.count("\n") + # Fallback: anchor on the first non-blank line of the snippet. + first = next((ln.strip() for ln in snippet.splitlines() if ln.strip()), "") + if first: + idx = text.find(first) + if idx != -1: + start = text.count("\n", 0, idx) + 1 + return start, start + snippet.count("\n") + return None + + +def _ground_citations(codemap: CodeMap, repo_dir: str) -> None: + """Overwrite each citation's line range with the true snippet location in the + cloned source file, so the code viewer highlights the right lines.""" + file_cache: dict[str, str | None] = {} + for section in codemap.sections: + for step in section.steps: + cit = step.citation + if not cit or not cit.snippet or not cit.file_path: + continue + if cit.file_path not in file_cache: + path = os.path.join(repo_dir, cit.file_path) + try: + with open(path, "r", encoding="utf-8") as f: + file_cache[cit.file_path] = f.read() + except (OSError, UnicodeDecodeError): + file_cache[cit.file_path] = None + text = file_cache[cit.file_path] + if not text: + continue + loc = _locate_snippet(text, cit.snippet) + if loc: + cit.start_line, cit.end_line = loc + + +async def generate_codemap(request: CodeMapRequest) -> AsyncIterator[str]: + """Generate a codemap, yielding NDJSON progress + result events.""" + repo_url = request.repo_url + repo_name = repo_url.rstrip("/").split("/")[-1] if "/" in repo_url else repo_url + repo_type = request.type + + prompt_fmt = { + "repo_type": repo_type, + "repo_url": repo_url, + "repo_name": repo_name, + "language_name": request.language or "en", + } + + # ---- Phase 1a: analyzing code (RAG retrieval) --------------------------------- + yield _phase("analyzing", "start") + rag = await asyncio.to_thread(RAG, provider=request.provider, model=request.model) + await rag.aprepare_retriever( + request.repo_url, + request.type, + request.token, + excluded_files=request.excluded_files, + excluded_dirs=request.excluded_dirs, + included_files=request.included_files, + included_dirs=request.included_dirs, + ) + retrieved = await rag.acall(request.question, language=request.language or "en") + documents = retrieved[0].documents if retrieved and retrieved[0].documents else [] + logger.info("Codemap retrieval returned %d chunks", len(documents)) + context_text = _format_context(documents) if documents else "" + yield _phase("analyzing", "done", chunk_count=len(documents)) + + model_config = get_model_config(request.provider, request.model)["model_kwargs"] + + def _new_streamer() -> ChatStreamer: + return ChatStreamer.create( + provider=request.provider, + model=request.model, + model_config=model_config, + ) + + # ---- Phase 1b: initial codemap skeleton --------------------------------------- + yield _phase("initial_codemap", "start") + skeleton_prompt = prompt_builder( + system_prompt=CODEMAP_SKELETON_PROMPT.format(**prompt_fmt), + query=request.question, + context=context_text, + ) + try: + skeleton = CodeMap.model_validate( + await _generate_json(_new_streamer, skeleton_prompt) + ) + except Exception as e: # noqa: BLE001 + logger.error("Failed to parse codemap skeleton: %s", e) + yield _event(type="error", stage="initial_codemap", message=str(e)) + return + yield _phase("initial_codemap", "done", section_count=len(skeleton.sections)) + + # ---- Phase 2: diagrams and guides --------------------------------------------- + yield _phase("diagrams", "start") + enrich_query = ( + f"{request.question}\n\n\n" + f"{skeleton.model_dump_json()}\n" + ) + enrich_prompt = prompt_builder( + system_prompt=CODEMAP_ENRICH_PROMPT.format(**prompt_fmt), + query=enrich_query, + context=context_text, + ) + final = skeleton + try: + final = CodeMap.model_validate( + await _generate_json(_new_streamer, enrich_prompt, attempts=2) + ) + yield _phase("diagrams", "done") + except Exception as e: # noqa: BLE001 + # Non-fatal: fall back to the skeleton (no diagrams/guides) so the user + # still gets a usable codemap. + logger.warning("Diagram/guide enrichment failed, using skeleton: %s", e) + yield _phase("diagrams", "done", degraded=True) + + # Ground citation line numbers against the real source (LLM line numbers are + # guesses; the verbatim snippet is authoritative). + repo_dir = (getattr(rag.db_manager, "repo_paths", None) or {}).get("save_repo_dir") + if repo_dir: + _ground_citations(final, repo_dir) + + yield _event(type="codemap", data=final.model_dump()) + yield _event(type="done") diff --git a/api/services/research.py b/api/services/research.py new file mode 100644 index 000000000..66a709d4e --- /dev/null +++ b/api/services/research.py @@ -0,0 +1,334 @@ +import asyncio +from collections.abc import AsyncIterator, Callable +from functools import partial + +from api.chat import ChatStreamer, is_token_limit_error, prompt_builder +from api.config import configs, get_model_config +from api.logger import get_logger +from api.prompts import ( + DEEP_RESEARCH_FINAL_ITERATION_PROMPT, + DEEP_RESEARCH_FIRST_ITERATION_PROMPT, + DEEP_RESEARCH_INTERMEDIATE_ITERATION_PROMPT, + SIMPLE_CHAT_SYSTEM_PROMPT, +) +from api.rag import RAG, count_tokens, repo_index_exist +from api.repository import Repo, get_repo_content +from api.schemas import ChatCompletionRequest, RepoPrepareRequest + +logger = get_logger(__name__) + + +# Maximum token limit for embedding models +MAX_INPUT_TOKENS = 7500 # Safe threshold below 8192 token limit + + +class RepoNotIndexedError(ValueError): + """Raised when a chat request arrives before the repo has been indexed.""" + + +async def prepare_repo_index( + request: ChatCompletionRequest | RepoPrepareRequest, +) -> RAG: + rag = await asyncio.to_thread( + RAG, + provider=request.provider, + model=request.model, + ) + # Extract custom file filter parameters if provided + if request.excluded_dirs: + logger.info(f"Using custom excluded directories: {request.excluded_dirs}") + if request.excluded_files: + logger.info(f"Using custom excluded files: {request.excluded_files}") + if request.included_dirs: + logger.info(f"Using custom included directories: {request.included_dirs}") + if request.included_files: + logger.info(f"Using custom included files: {request.included_files}") + + await rag.aprepare_retriever( + request.repo_url, + request.type, + request.token, + excluded_files=request.excluded_files, + excluded_dirs=request.excluded_dirs, + included_files=request.included_files, + included_dirs=request.included_dirs, + ) + return rag + + +async def research_chat( + request: ChatCompletionRequest, +) -> AsyncIterator[str]: + input_too_large = False + if request.messages and len(request.messages) > 0: + last_message = request.messages[-1] + if hasattr(last_message, "content") and last_message.content: + tokens = count_tokens(last_message.content, embedder_type=request.provider) + logger.info(f"Request size: {tokens} tokens") + if tokens > MAX_INPUT_TOKENS: + logger.warning( + f"Request exceeds recommended token limit ({tokens} > {MAX_INPUT_TOKENS})" + ) + input_too_large = True + + repo = Repo(repo_url=request.repo_url, repo_type=request.type) + if not repo_index_exist(repo=repo): + logger.warning( + "Repo %s is not indexed yet. Call `/repo/prepare` first if encounter Timeout", + repo.name, + ) + + try: + rag = await prepare_repo_index(request=request) + logger.info("Retriever prepared for %s", request.repo_url) + + except ValueError as e: + if "No valid documents with embeddings found" in str(e): + logger.error(f"No valid embeddings found: {str(e)}") + raise e + else: + logger.error("ValueError preparing retriever: %s", str(e)) + raise e + except Exception as e: + logger.error("Error preparing retriever: %s", str(e)) + raise e + + if not request.messages: + raise ValueError("No messages provided") + + last_message = request.messages[-1] + is_deep_research = last_message.mode == "deep_research" + + if last_message.role != "user": + raise ValueError("Last message must be from the user") + + # Process previous messages to build conversation history + for i in range(0, len(request.messages) - 1, 2): + if i + 1 < len(request.messages): + user_msg = request.messages[i] + assistant_msg = request.messages[i + 1] + + if user_msg.role == "user" and assistant_msg.role == "assistant": + rag.memory.add_dialog_turn( + user_query=user_msg.content, + assistant_response=assistant_msg.content, + ) + + if is_deep_research: + logger.info( + "Deep Research request detected - iteration %d", request.research_iteration + ) + + # Check if this is a continuation request + if ( + "continue" in last_message.content.lower() + and "research" in last_message.content.lower() + ): + # Find the original topic from the first user message + original_topic = None + for msg in request.messages: + if msg.role == "user" and "continue" not in msg.content.lower(): + original_topic = msg.content.strip() + logger.info(f"Found original research topic: {original_topic}") + break + + if original_topic: + # Replace the continuation message with the original topic + last_message.content = original_topic + logger.info(f"Using original topic for research: {original_topic}") + + # Get the query from the last message + query = last_message.content + + # Only retrieve documents if input is not too large + context_text = "" + + if not input_too_large: + try: + # If filePath exists, modify the query for RAG to focus on the file + rag_query = query + if request.filePath: + # Use the file path to get relevant context about the file + rag_query = f"Contexts related to {request.filePath}" + logger.info(f"Modified RAG query to focus on file: {request.filePath}") + + # Try to perform RAG retrieval + try: + # This will use the actual RAG implementation + retrieved_documents = await rag.acall( + rag_query, language=request.language + ) + + if retrieved_documents and retrieved_documents[0].documents: + # Format context for the prompt in a more structured way + documents = retrieved_documents[0].documents + logger.info(f"Retrieved {len(documents)} documents") + + # Group documents by file path + docs_by_file = {} + for doc in documents: + file_path = doc.meta_data.get("file_path", "unknown") + if file_path not in docs_by_file: + docs_by_file[file_path] = [] + docs_by_file[file_path].append(doc) + + # Format context text with file path grouping + context_parts = [] + for file_path, docs in docs_by_file.items(): + # Add file header with metadata + header = f"## File Path: {file_path}\n\n" + # Add document content, annotating each chunk with its + # real line range when available so the model can cite lines. + chunk_texts = [] + for doc in docs: + start_line = doc.meta_data.get("start_line") + end_line = doc.meta_data.get("end_line") + if start_line and end_line: + chunk_texts.append( + f"[lines {start_line}-{end_line}]\n{doc.text}" + ) + else: + chunk_texts.append(doc.text) + content = "\n\n".join(chunk_texts) + + context_parts.append(f"{header}{content}") + + # Join all parts with clear separation + context_text = "\n\n" + "-" * 10 + "\n\n".join(context_parts) + else: + logger.warning("No documents retrieved from RAG") + except Exception as e: + logger.error(f"Error in RAG retrieval: {str(e)}") + except Exception as e: + logger.error(f"Error retrieving documents: {str(e)}") + context_text = "" + + # Get repository information + repo_url = request.repo_url + repo_name = repo_url.split("/")[-1] if "/" in repo_url else repo_url + + # Determine repository type + repo_type = request.type + + # Get language information + language_code = request.language or configs["lang_config"]["default"] + supported_langs = configs["lang_config"]["supported_languages"] + language_name = supported_langs.get(language_code, "English") + + # Create system prompt + if is_deep_research: + # Check if this is the first iteration + is_first_iteration = request.research_iteration == 1 + + # Check if this is the final iteration + is_final_iteration = request.research_iteration >= 5 + + if is_first_iteration: + system_prompt = DEEP_RESEARCH_FIRST_ITERATION_PROMPT.format( + repo_type=repo_type, + repo_url=repo_url, + repo_name=repo_name, + language_name=language_name, + ) + elif is_final_iteration: + system_prompt = DEEP_RESEARCH_FINAL_ITERATION_PROMPT.format( + repo_type=repo_type, + repo_url=repo_url, + repo_name=repo_name, + language_name=language_name, + ) + else: + system_prompt = DEEP_RESEARCH_INTERMEDIATE_ITERATION_PROMPT.format( + repo_type=repo_type, + repo_url=repo_url, + repo_name=repo_name, + research_iteration=request.research_iteration, + language_name=language_name, + ) + else: + system_prompt = SIMPLE_CHAT_SYSTEM_PROMPT.format( + repo_type=repo_type, + repo_url=repo_url, + repo_name=repo_name, + language_name=language_name, + ) + + # Fetch file content if provided + file_content = "" + if request.filePath: + try: + file_content = await asyncio.to_thread( + get_repo_content, + repo_url=request.repo_url, + file_path=request.filePath, + repo_type=request.type, + access_token=request.token, + ) + logger.info(f"Successfully retrieved content for file: {request.filePath}") + except Exception as e: + logger.error(f"Error retrieving file content: {str(e)}") + # Continue without file content if there's an error + + # Format conversation history + conversation_history = "" + for turn_id, turn in rag.memory().items(): + if ( + not isinstance(turn_id, int) + and hasattr(turn, "user_query") + and hasattr(turn, "assistant_response") + ): + conversation_history += f"\n{turn.user_query.query_str}\n{turn.assistant_response.response_str}\n\n" + + async def stream_and_fallback( + streamer: ChatStreamer, + prompt_func: Callable[[], str], + simplified_prompt_func: Callable[[], str], + ) -> AsyncIterator[str]: + try: + async for chunk in streamer.respond_stream(prompt_func()): + yield chunk + except Exception as e: + if is_token_limit_error(e): + logger.warning("Token limit exceeded, retrying without context") + try: + async for chunk in streamer.respond_stream( + simplified_prompt_func() + ): + yield chunk + except Exception as e2: + logger.error("Error in fallback streaming response: %s", str(e2)) + yield ( + "\nI apologize, but your request is too large for me to process. " + "Please try a shorter query or break it into smaller parts." + ) + else: + error_str = f"Error with {streamer.provider} API: {e}" + logger.error(error_str, exc_info=True) + if streamer.error_hint: + error_str += f"\n\n{streamer.error_hint}" + yield "\n" + error_str + + model_config = get_model_config(request.provider, request.model)["model_kwargs"] + chat_streamer = ChatStreamer.create( + provider=request.provider, + model=request.model, + model_config=model_config, + ) + + prompt_kwargs = { + "system_prompt": system_prompt, + "query": query, + "conversation_history": conversation_history, + "file_path": request.filePath, + "file_content": file_content, + "context": context_text, + } + + prompt_func = partial(prompt_builder, **prompt_kwargs, simplify=False) + simplified_prompt_func = partial(prompt_builder, **prompt_kwargs, simplify=True) + + return stream_and_fallback( + streamer=chat_streamer, + prompt_func=prompt_func, + simplified_prompt_func=simplified_prompt_func, + ) diff --git a/api/services/wiki.py b/api/services/wiki.py new file mode 100644 index 000000000..0d7ae434b --- /dev/null +++ b/api/services/wiki.py @@ -0,0 +1,232 @@ +import asyncio +import json +import os +from datetime import datetime +from typing import Literal + +from api.logger import get_logger +from api.schemas import ( + ProcessedProjectEntry, + WikiCacheData, + WikiCacheRequest, + WikiPage, + aload, + asave, +) + +logger = get_logger(__name__) + + +# Helper function to get adalflow root path +def get_adalflow_default_root_path(): + return os.path.expanduser(os.path.join("~", ".adalflow")) + + +WIKI_CACHE_DIR = os.path.join(get_adalflow_default_root_path(), "wikicache") +os.makedirs(WIKI_CACHE_DIR, exist_ok=True) + + +def get_wiki_cache_path(owner: str, repo: str, repo_type: str, language: str) -> str: + """Generates the file path for a given wiki cache.""" + filename = f"deepwiki_cache_{repo_type}_{owner}_{repo}_{language}.json" + return os.path.join(WIKI_CACHE_DIR, filename) + + +async def read_wiki_cache( + owner: str, repo: str, repo_type: str, language: str +) -> WikiCacheData | None: + """Reads wiki cache data from the file system.""" + cache_path = get_wiki_cache_path(owner, repo, repo_type, language) + if not os.path.exists(cache_path): + return None + try: + return await aload(WikiCacheData, cache_path, encoding="utf-8") + except Exception: + logger.exception("Error reading wiki cache from %s", cache_path) + return None + + +async def save_wiki_cache(data: WikiCacheRequest) -> bool: + """Saves wiki cache data to the file system.""" + cache_path = get_wiki_cache_path( + data.repo.owner, data.repo.repo, data.repo.type, data.language + ) + logger.info(f"Attempting to save wiki cache. Path: {cache_path}") + try: + wiki_cache = WikiCacheData( + wiki_structure=data.wiki_structure, + generated_pages=data.generated_pages, + repo=data.repo, + provider=data.provider, + model=data.model, + ) + await asave(wiki_cache, cache_path, encoding="utf-8") + logger.info(f"Wiki cache successfully saved to {cache_path}") + return True + except OSError: + logger.exception("IOError saving wiki cache to %s", cache_path) + return False + except Exception: + logger.exception("Unexpected error saving wiki cache to %s", cache_path) + return False + + +async def delete_wiki_cache(owner: str, repo: str, repo_type: str, language: str): + cache_path = get_wiki_cache_path( + owner, + repo, + repo_type, + language, + ) + + if not os.path.exists(cache_path): + logger.warning("Wiki cache not found, cannot delete: %s", cache_path) + return False + + os.remove(cache_path) + logger.info("Successfully deleted wiki cache: %s", cache_path) + return True + + +async def list_processed_projects() -> list[ProcessedProjectEntry]: + project_entries: list[ProcessedProjectEntry] = [] + + if not os.path.exists(WIKI_CACHE_DIR): + logger.info( + f"Cache directory {WIKI_CACHE_DIR} not found. Returning empty list." + ) + return [] + + logger.info(f"Scanning for project cache files in: {WIKI_CACHE_DIR}") + filenames = await asyncio.to_thread(os.listdir, WIKI_CACHE_DIR) + + for filename in filenames: + if filename.startswith("deepwiki_cache_") and filename.endswith(".json"): + file_path = os.path.join(WIKI_CACHE_DIR, filename) + try: + stats = await asyncio.to_thread(os.stat, file_path) + parts = ( + filename.replace("deepwiki_cache_", "") + .replace(".json", "") + .split("_") + ) + # Expecting repo_type_owner_repo_language + if len(parts) >= 4: + repo_type = parts[0] + owner = parts[1] + language = parts[-1] + repo = "_".join(parts[2:-1]) # repo can contain underscores + project_entries.append( + ProcessedProjectEntry( + id=filename, + owner=owner, + repo=repo, + name=f"{owner}/{repo}", + repo_type=repo_type, + submittedAt=int(stats.st_mtime * 1000), + language=language, + ) + ) + else: + logger.warning( + f"Could not parse project details from filename: {filename}" + ) + except Exception as e: + logger.error(f"Error processing file {file_path}: {e}") + continue + + project_entries.sort(key=lambda p: p.submittedAt, reverse=True) + logger.info(f"Found {len(project_entries)} processed project entries.") + return project_entries + + +def _generate_json_export( + repo_url: str, pages: list[WikiPage], timestamp: datetime +) -> str: + """ + Generate JSON export of wiki pages. + + Args: + repo_url: The repository URL + pages: List of wiki pages + + Returns: + JSON content as string + """ + # Create a dictionary with metadata and pages + export_data = { + "metadata": { + "repository": repo_url, + "generated_at": timestamp.isoformat(), + "page_count": len(pages), + }, + "pages": [page.model_dump() for page in pages], + } + + # Convert to JSON string with pretty formatting + return json.dumps(export_data, indent=2) + + +def _generate_markdown_export( + repo_url: str, pages: list[WikiPage], timestamp: datetime +) -> str: + """ + Generate Markdown export of wiki pages. + + Args: + repo_url: The repository URL + pages: List of wiki pages + + Returns: + Markdown content as string + """ + # Start with metadata + markdown = f"# Wiki Documentation for {repo_url}\n\n" + markdown += f"Generated on: {timestamp.strftime('%Y-%m-%d %H:%M:%S')}\n\n" + + # Add table of contents + markdown += "## Table of Contents\n\n" + for page in pages: + markdown += f"- [{page.title}](#{page.id})\n" + markdown += "\n" + + # Add each page + for page in pages: + markdown += f"\n\n" + markdown += f"## {page.title}\n\n" + + # Add related pages + if page.relatedPages and len(page.relatedPages) > 0: + markdown += "### Related Pages\n\n" + related_titles = [] + for related_id in page.relatedPages: + # Find the title of the related page + related_page = next((p for p in pages if p.id == related_id), None) + if related_page: + related_titles.append(f"[{related_page.title}](#{related_id})") + + if related_titles: + markdown += "Related topics: " + ", ".join(related_titles) + "\n\n" + + # Add page content + markdown += f"{page.content}\n\n" + markdown += "---\n\n" + + return markdown + + +def export_wiki( + repo_url: str, + pages: list[WikiPage], + format: Literal["json", "markdown"], + timestamp: datetime | None = None, +) -> str: + dt = timestamp or datetime.now() + if format == "json": + return _generate_json_export(repo_url, pages, timestamp=dt) + elif format == "markdown": + return _generate_markdown_export(repo_url, pages, timestamp=dt) + else: + raise NotImplementedError( + f"Exporting wiki to format {format} is not supported. Must be one of 'markdown' or 'json'.", + ) diff --git a/api/simple_chat.py b/api/simple_chat.py deleted file mode 100644 index a6e7e8aae..000000000 --- a/api/simple_chat.py +++ /dev/null @@ -1,345 +0,0 @@ -import asyncio -import logging -from typing import Callable -from functools import partial - -from fastapi import FastAPI, HTTPException -from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import StreamingResponse - -from api.chat import ChatStreamer, prompt_builder, is_token_limit_error -from api.config import get_model_config, configs -from api.data_pipeline import count_tokens, get_file_content -from api.rag import RAG, MAX_INPUT_TOKENS -from api.prompts import ( - DEEP_RESEARCH_FIRST_ITERATION_PROMPT, - DEEP_RESEARCH_FINAL_ITERATION_PROMPT, - DEEP_RESEARCH_INTERMEDIATE_ITERATION_PROMPT, - SIMPLE_CHAT_SYSTEM_PROMPT -) -from api.chat_model import ChatCompletionRequest - -# Configure logging -from api.logging_config import setup_logging - -setup_logging() -logger = logging.getLogger(__name__) - - -# Initialize FastAPI app -app = FastAPI( - title="Simple Chat API", - description="Simplified API for streaming chat completions" -) - -# Configure CORS -app.add_middleware( - CORSMiddleware, - allow_origins=["*"], # Allows all origins - allow_credentials=True, - allow_methods=["*"], # Allows all methods - allow_headers=["*"], # Allows all headers -) - - -@app.post("/chat/completions/stream") -async def chat_completions_stream(request: ChatCompletionRequest): - """Stream a chat completion response directly using Google Generative AI""" - try: - # Check if request contains very large input - input_too_large = False - if request.messages and len(request.messages) > 0: - last_message = request.messages[-1] - if hasattr(last_message, 'content') and last_message.content: - tokens = count_tokens(last_message.content, embedder_type=request.provider) - logger.info(f"Request size: {tokens} tokens") - if tokens > MAX_INPUT_TOKENS: - logger.warning(f"Request exceeds recommended token limit ({tokens} > {MAX_INPUT_TOKENS})") - input_too_large = True - - # Create a new RAG instance for this request - try: - # async do RAG initialization - request_rag = await asyncio.to_thread( - RAG, - provider=request.provider, - model=request.model, - ) - - # Extract custom file filter parameters if provided - if request.excluded_dirs: - logger.info(f"Using custom excluded directories: {request.excluded_dirs}") - if request.excluded_files: - logger.info(f"Using custom excluded files: {request.excluded_files}") - if request.included_dirs: - logger.info(f"Using custom included directories: {request.included_dirs}") - if request.included_files: - logger.info(f"Using custom included files: {request.included_files}") - - await request_rag.aprepare_retriever( - request.repo_url, - request.type, - request.token, - excluded_dirs=request.excluded_dirs, - excluded_files=request.excluded_files, - included_dirs=request.included_dirs, - included_files=request.included_files, - ) - logger.info(f"Retriever prepared for {request.repo_url}") - except ValueError as e: - if "No valid documents with embeddings found" in str(e): - logger.error(f"No valid embeddings found: {str(e)}") - raise HTTPException(status_code=500, detail="No valid document embeddings found. This may be due to embedding size inconsistencies or API errors during document processing. Please try again or check your repository content.") - else: - logger.error(f"ValueError preparing retriever: {str(e)}") - raise HTTPException(status_code=500, detail=f"Error preparing retriever: {str(e)}") - except Exception as e: - logger.error(f"Error preparing retriever: {str(e)}") - # Check for specific embedding-related errors - if "All embeddings should be of the same size" in str(e): - raise HTTPException(status_code=500, detail="Inconsistent embedding sizes detected. Some documents may have failed to embed properly. Please try again.") - else: - raise HTTPException(status_code=500, detail=f"Error preparing retriever: {str(e)}") - - # Validate request - if not request.messages or len(request.messages) == 0: - raise HTTPException(status_code=400, detail="No messages provided") - - last_message = request.messages[-1] - if last_message.role != "user": - raise HTTPException(status_code=400, detail="Last message must be from the user") - - # Process previous messages to build conversation history - for i in range(0, len(request.messages) - 1, 2): - if i + 1 < len(request.messages): - user_msg = request.messages[i] - assistant_msg = request.messages[i + 1] - - if user_msg.role == "user" and assistant_msg.role == "assistant": - request_rag.memory.add_dialog_turn( - user_query=user_msg.content, - assistant_response=assistant_msg.content - ) - - # Check if this is a Deep Research request - is_deep_research = last_message.mode == "deep_research" - - # Count research iterations if this is a Deep Research request - if is_deep_research: - logger.info("Deep Research request detected - iteration %d",request.research_iteration) - - # Check if this is a continuation request - if "continue" in last_message.content.lower() and "research" in last_message.content.lower(): - # Find the original topic from the first user message - original_topic = None - for msg in request.messages: - if msg.role == "user" and "continue" not in msg.content.lower(): - original_topic = msg.content.strip() - logger.info(f"Found original research topic: {original_topic}") - break - - if original_topic: - # Replace the continuation message with the original topic - last_message.content = original_topic - logger.info(f"Using original topic for research: {original_topic}") - - # Get the query from the last message - query = last_message.content - - # Only retrieve documents if input is not too large - context_text = "" - retrieved_documents = None - - if not input_too_large: - try: - # If filePath exists, modify the query for RAG to focus on the file - rag_query = query - if request.filePath: - # Use the file path to get relevant context about the file - rag_query = f"Contexts related to {request.filePath}" - logger.info(f"Modified RAG query to focus on file: {request.filePath}") - - # Try to perform RAG retrieval - try: - # This will use the actual RAG implementation - retrieved_documents = await request_rag.acall(rag_query, language=request.language) - - if retrieved_documents and retrieved_documents[0].documents: - # Format context for the prompt in a more structured way - documents = retrieved_documents[0].documents - logger.info(f"Retrieved {len(documents)} documents") - - # Group documents by file path - docs_by_file = {} - for doc in documents: - file_path = doc.meta_data.get('file_path', 'unknown') - if file_path not in docs_by_file: - docs_by_file[file_path] = [] - docs_by_file[file_path].append(doc) - - # Format context text with file path grouping - context_parts = [] - for file_path, docs in docs_by_file.items(): - # Add file header with metadata - header = f"## File Path: {file_path}\n\n" - # Add document content - content = "\n\n".join([doc.text for doc in docs]) - - context_parts.append(f"{header}{content}") - - # Join all parts with clear separation - context_text = "\n\n" + "-" * 10 + "\n\n".join(context_parts) - else: - logger.warning("No documents retrieved from RAG") - except Exception as e: - logger.error(f"Error in RAG retrieval: {str(e)}") - # Continue without RAG if there's an error - - except Exception as e: - logger.error(f"Error retrieving documents: {str(e)}") - context_text = "" - - # Get repository information - repo_url = request.repo_url - repo_name = repo_url.split("/")[-1] if "/" in repo_url else repo_url - - # Determine repository type - repo_type = request.type - - # Get language information - language_code = request.language or configs["lang_config"]["default"] - supported_langs = configs["lang_config"]["supported_languages"] - language_name = supported_langs.get(language_code, "English") - - # Create system prompt - if is_deep_research: - # Check if this is the first iteration - is_first_iteration = request.research_iteration == 1 - - # Check if this is the final iteration - is_final_iteration = request.research_iteration >= 5 - - if is_first_iteration: - system_prompt = DEEP_RESEARCH_FIRST_ITERATION_PROMPT.format( - repo_type=repo_type, - repo_url=repo_url, - repo_name=repo_name, - language_name=language_name - ) - elif is_final_iteration: - system_prompt = DEEP_RESEARCH_FINAL_ITERATION_PROMPT.format( - repo_type=repo_type, - repo_url=repo_url, - repo_name=repo_name, - language_name=language_name - ) - else: - system_prompt = DEEP_RESEARCH_INTERMEDIATE_ITERATION_PROMPT.format( - repo_type=repo_type, - repo_url=repo_url, - repo_name=repo_name, - research_iteration=request.research_iteration, - language_name=language_name - ) - else: - system_prompt = SIMPLE_CHAT_SYSTEM_PROMPT.format( - repo_type=repo_type, - repo_url=repo_url, - repo_name=repo_name, - language_name=language_name - ) - - # Fetch file content if provided - file_content = "" - if request.filePath: - try: - file_content = await asyncio.to_thread( - get_file_content, - repo_url=request.repo_url, - file_path=request.filePath, - repo_type=request.type, - access_token=request.token, - ) - logger.info(f"Successfully retrieved content for file: {request.filePath}") - except Exception as e: - logger.error(f"Error retrieving file content: {str(e)}") - # Continue without file content if there's an error - - # Format conversation history - conversation_history = "" - for turn_id, turn in request_rag.memory().items(): - if not isinstance(turn_id, int) and hasattr(turn, 'user_query') and hasattr(turn, 'assistant_response'): - conversation_history += f"\n{turn.user_query.query_str}\n{turn.assistant_response.response_str}\n\n" - - async def stream_and_fallback( - streamer: ChatStreamer, - prompt_func: Callable[[], str], - simplified_prompt_func: Callable[[], str], - ): - try: - async for chunk in streamer.respond_stream(prompt_func()): - yield chunk - except Exception as e: - if is_token_limit_error(e): - logger.warning("Token limit exceeded, retrying without context") - try: - async for chunk in streamer.respond_stream(simplified_prompt_func()): - yield chunk - except Exception as e2: - logger.error("Error in fallback streaming response: %s", str(e2)) - yield ( - f"\nI apologize, but your request is too large for me to process. " - f"Please try a shorter query or break it into smaller parts." - ) - else: - error_str = f"Error with {streamer.provider} API: {e}" - logger.error(error_str, exc_info=True) - if streamer.error_hint: - error_str += f"\n\n{streamer.error_hint}" - yield "\n" + error_str - - model_config = get_model_config(request.provider, request.model)["model_kwargs"] - chat_streamer = ChatStreamer.create( - provider=request.provider, - model=request.model, - model_config=model_config, - ) - prompt_kwargs = dict( - system_prompt=system_prompt, - query=query, - conversation_history=conversation_history, - file_path=request.filePath, - file_content=file_content, - context=context_text, - ) - - prompt_func = partial( - prompt_builder, - **prompt_kwargs, - simplify=False, - ) - simplified_prompt_func = partial( - prompt_builder, - **prompt_kwargs, - simplify=True, - ) - - # Return streaming response - return StreamingResponse(stream_and_fallback( - streamer=chat_streamer, - prompt_func=prompt_func, - simplified_prompt_func=simplified_prompt_func, - ), media_type="text/event-stream") - - except HTTPException: - raise - except Exception as e_handler: - error_msg = f"Error in streaming chat completion: {str(e_handler)}" - logger.error(error_msg) - raise HTTPException(status_code=500, detail=error_msg) - - -@app.get("/") -async def root(): - """Root endpoint to check if the API is running""" - return {"status": "API is running", "message": "Navigate to /docs for API documentation"} diff --git a/api/tools/embedder.py b/api/tools/embedder.py deleted file mode 100644 index 050d63547..000000000 --- a/api/tools/embedder.py +++ /dev/null @@ -1,58 +0,0 @@ -import adalflow as adal - -from api.config import configs, get_embedder_type - - -def get_embedder(is_local_ollama: bool = False, use_google_embedder: bool = False, embedder_type: str = None) -> adal.Embedder: - """Get embedder based on configuration or parameters. - - Args: - is_local_ollama: Legacy parameter for Ollama embedder - use_google_embedder: Legacy parameter for Google embedder - embedder_type: Direct specification of embedder type ('ollama', 'google', 'bedrock', 'openai') - - Returns: - adal.Embedder: Configured embedder instance - """ - # Determine which embedder config to use - if embedder_type: - if embedder_type == 'ollama': - embedder_config = configs["embedder_ollama"] - elif embedder_type == 'google': - embedder_config = configs["embedder_google"] - elif embedder_type == 'bedrock': - embedder_config = configs["embedder_bedrock"] - else: # default to openai - embedder_config = configs["embedder"] - elif is_local_ollama: - embedder_config = configs["embedder_ollama"] - elif use_google_embedder: - embedder_config = configs["embedder_google"] - else: - # Auto-detect based on current configuration - current_type = get_embedder_type() - if current_type == 'bedrock': - embedder_config = configs["embedder_bedrock"] - elif current_type == 'ollama': - embedder_config = configs["embedder_ollama"] - elif current_type == 'google': - embedder_config = configs["embedder_google"] - else: - embedder_config = configs["embedder"] - - # --- Initialize Embedder --- - model_client_class = embedder_config["model_client"] - if "initialize_kwargs" in embedder_config: - model_client = model_client_class(**embedder_config["initialize_kwargs"]) - else: - model_client = model_client_class() - - # Create embedder with basic parameters - embedder_kwargs = {"model_client": model_client, "model_kwargs": embedder_config["model_kwargs"]} - - embedder = adal.Embedder(**embedder_kwargs) - - # Set batch_size as an attribute if available (not a constructor parameter) - if "batch_size" in embedder_config: - embedder.batch_size = embedder_config["batch_size"] - return embedder diff --git a/api/websocket_wiki.py b/api/websocket_wiki.py deleted file mode 100644 index d72efc2b5..000000000 --- a/api/websocket_wiki.py +++ /dev/null @@ -1,328 +0,0 @@ -import asyncio -import logging -from collections.abc import AsyncIterator, Callable -from functools import partial - -from fastapi import WebSocket, WebSocketDisconnect - -from api.chat import ChatStreamer, prompt_builder, is_token_limit_error -from api.config import ( - get_model_config, - configs, -) -from api.data_pipeline import count_tokens, get_file_content -from api.rag import RAG, MAX_INPUT_TOKENS -from api.prompts import ( - DEEP_RESEARCH_FIRST_ITERATION_PROMPT, - DEEP_RESEARCH_FINAL_ITERATION_PROMPT, - DEEP_RESEARCH_INTERMEDIATE_ITERATION_PROMPT, - SIMPLE_CHAT_SYSTEM_PROMPT, -) -from api.chat_model import ChatCompletionRequest - -# Configure logging -from api.logging_config import setup_logging - -setup_logging() -logger = logging.getLogger(__name__) - - -async def handle_websocket_chat(websocket: WebSocket): - """ - Handle WebSocket connection for chat completions. - This replaces the HTTP streaming endpoint with a WebSocket connection. - """ - await websocket.accept() - - try: - # Receive and parse the request data - request_data = await websocket.receive_json() - request = ChatCompletionRequest(**request_data) - - # Check if request contains very large input - input_too_large = False - if request.messages and len(request.messages) > 0: - last_message = request.messages[-1] - if hasattr(last_message, 'content') and last_message.content: - tokens = count_tokens(last_message.content, embedder_type=request.provider) - logger.info(f"Request size: {tokens} tokens") - if tokens > MAX_INPUT_TOKENS: - logger.warning(f"Request exceeds recommended token limit ({tokens} > {MAX_INPUT_TOKENS})") - input_too_large = True - - # Create a new RAG instance for this request - try: - request_rag = await asyncio.to_thread( - RAG, - provider=request.provider, - model=request.model, - ) - - # Extract custom file filter parameters if provided - if request.excluded_dirs: - logger.info(f"Using custom excluded directories: {request.excluded_dirs}") - if request.excluded_files: - logger.info(f"Using custom excluded files: {request.excluded_files}") - if request.included_dirs: - logger.info(f"Using custom included directories: {request.included_dirs}") - if request.included_files: - logger.info(f"Using custom included files: {request.included_files}") - - await request_rag.aprepare_retriever( - request.repo_url, - request.type, - request.token, - excluded_dirs=request.excluded_dirs, - excluded_files=request.excluded_files, - included_dirs=request.included_dirs, - included_files=request.included_files, - ) - logger.info(f"Retriever prepared for {request.repo_url}") - except ValueError as e: - if "No valid documents with embeddings found" in str(e): - logger.error(f"No valid embeddings found: {str(e)}") - await websocket.send_text("Error: No valid document embeddings found. This may be due to embedding size inconsistencies or API errors during document processing. Please try again or check your repository content.") - return - else: - logger.error(f"ValueError preparing retriever: {str(e)}") - await websocket.send_text(f"Error preparing retriever: {str(e)}") - return - except Exception as e: - logger.error(f"Error preparing retriever: {str(e)}") - # Check for specific embedding-related errors - if "All embeddings should be of the same size" in str(e): - await websocket.send_text("Error: Inconsistent embedding sizes detected. Some documents may have failed to embed properly. Please try again.") - else: - await websocket.send_text(f"Error preparing retriever: {str(e)}") - return - - # Validate request - if not request.messages or len(request.messages) == 0: - await websocket.send_text("Error: No messages provided") - return - - last_message = request.messages[-1] - if last_message.role != "user": - await websocket.send_text("Error: Last message must be from the user") - return - - # Process previous messages to build conversation history - for i in range(0, len(request.messages) - 1, 2): - if i + 1 < len(request.messages): - user_msg = request.messages[i] - assistant_msg = request.messages[i + 1] - - if user_msg.role == "user" and assistant_msg.role == "assistant": - request_rag.memory.add_dialog_turn( - user_query=user_msg.content, - assistant_response=assistant_msg.content - ) - - # Check if this is a Deep Research request - is_deep_research = last_message.mode == "deep_research" - - # Count research iterations if this is a Deep Research request - if is_deep_research: - logger.info("Deep Research request detected - iteration %d", request.research_iteration) - - # Check if this is a continuation request - if "continue" in last_message.content.lower() and "research" in last_message.content.lower(): - # Find the original topic from the first user message - original_topic = None - for msg in request.messages: - if msg.role == "user" and "continue" not in msg.content.lower(): - original_topic = msg.content.strip() - logger.info(f"Found original research topic: {original_topic}") - break - - if original_topic: - # Replace the continuation message with the original topic - last_message.content = original_topic - logger.info(f"Using original topic for research: {original_topic}") - - # Get the query from the last message - query = last_message.content - - # Only retrieve documents if input is not too large - context_text = "" - retrieved_documents = None - - if not input_too_large: - try: - # If filePath exists, modify the query for RAG to focus on the file - rag_query = query - if request.filePath: - # Use the file path to get relevant context about the file - rag_query = f"Contexts related to {request.filePath}" - logger.info(f"Modified RAG query to focus on file: {request.filePath}") - - # Try to perform RAG retrieval - try: - # This will use the actual RAG implementation - retrieved_documents = await request_rag.acall(rag_query, language=request.language) - - if retrieved_documents and retrieved_documents[0].documents: - # Format context for the prompt in a more structured way - documents = retrieved_documents[0].documents - logger.info(f"Retrieved {len(documents)} documents") - - # Group documents by file path - docs_by_file = {} - for doc in documents: - file_path = doc.meta_data.get('file_path', 'unknown') - if file_path not in docs_by_file: - docs_by_file[file_path] = [] - docs_by_file[file_path].append(doc) - - # Format context text with file path grouping - context_parts = [] - for file_path, docs in docs_by_file.items(): - # Add file header with metadata - header = f"## File Path: {file_path}\n\n" - # Add document content - content = "\n\n".join([doc.text for doc in docs]) - - context_parts.append(f"{header}{content}") - - # Join all parts with clear separation - context_text = "\n\n" + "-" * 10 + "\n\n".join(context_parts) - else: - logger.warning("No documents retrieved from RAG") - except Exception as e: - logger.error(f"Error in RAG retrieval: {str(e)}") - # Continue without RAG if there's an error - - except Exception as e: - logger.error(f"Error retrieving documents: {str(e)}") - context_text = "" - - # Get repository information - repo_url = request.repo_url - repo_name = repo_url.split("/")[-1] if "/" in repo_url else repo_url - - # Determine repository type - repo_type = request.type - - # Get language information - language_code = request.language or configs["lang_config"]["default"] - supported_langs = configs["lang_config"]["supported_languages"] - language_name = supported_langs.get(language_code, "English") - - # Create system prompt - if is_deep_research: - # Check if this is the first iteration - is_first_iteration = request.research_iteration == 1 - - # Check if this is the final iteration - is_final_iteration = request.research_iteration >= 5 - - if is_first_iteration: - system_prompt = DEEP_RESEARCH_FIRST_ITERATION_PROMPT.format( - repo_type=repo_type, - repo_url=repo_url, - repo_name=repo_name, - language_name=language_name - ) - elif is_final_iteration: - system_prompt = DEEP_RESEARCH_FINAL_ITERATION_PROMPT.format( - repo_type=repo_type, - repo_url=repo_url, - repo_name=repo_name, - language_name=language_name - ) - else: - system_prompt = DEEP_RESEARCH_INTERMEDIATE_ITERATION_PROMPT.format( - repo_type=repo_type, - repo_url=repo_url, - repo_name=repo_name, - research_iteration=request.research_iteration, - language_name=language_name - ) - else: - system_prompt = SIMPLE_CHAT_SYSTEM_PROMPT.format( - repo_type=repo_type, - repo_url=repo_url, - repo_name=repo_name, - language_name=language_name - ) - - # Fetch file content if provided - file_content = "" - if request.filePath: - try: - file_content = await asyncio.to_thread( - get_file_content, - repo_url=request.repo_url, - file_path=request.filePath, - repo_type=request.type, - access_token=request.token, - ) - logger.info(f"Successfully retrieved content for file: {request.filePath}") - except Exception as e: - logger.error(f"Error retrieving file content: {str(e)}") - # Continue without file content if there's an error - - # Format conversation history - conversation_history = "" - for turn_id, turn in request_rag.memory().items(): - if not isinstance(turn_id, int) and hasattr(turn, 'user_query') and hasattr(turn, 'assistant_response'): - conversation_history += f"\n{turn.user_query.query_str}\n{turn.assistant_response.response_str}\n\n" - - async def stream_and_fallback( - streamer: ChatStreamer, - prompt_func: Callable[[], str], - simplified_prompt_func: Callable[[], str], - ) -> AsyncIterator[str]: - try: - async for chunk in streamer.respond_stream(prompt_func()): - yield chunk - except Exception as e: - if is_token_limit_error(e): - logger.warning("Token limit exceeded, retrying without context") - try: - async for chunk in streamer.respond_stream(simplified_prompt_func()): - yield chunk - except Exception as e2: - logger.error("Error in fallback streaming response: %s", e2) - yield ( - "\nI apologize, but your request is too large for me to process. " - "Please try a shorter query or break it into smaller parts." - ) - else: - msg = f"Error with {streamer.provider} API: {e}" - logger.error(msg, exc_info=True) - if streamer.error_hint: - msg += f"\n\n{streamer.error_hint}" - yield "\n" + msg - - model_config = get_model_config(request.provider, request.model)["model_kwargs"] - chat_streamer = ChatStreamer.create( - provider=request.provider, - model=request.model, - model_config=model_config, - ) - - prompt_kwargs = dict( - system_prompt=system_prompt, - query=query, - conversation_history=conversation_history, - file_path=request.filePath, - file_content=file_content, - context=context_text, - ) - prompt_func = partial(prompt_builder, **prompt_kwargs, simplify=False) - simplified_prompt_func = partial(prompt_builder, **prompt_kwargs, simplify=True) - - async for chunk in stream_and_fallback(chat_streamer, prompt_func, simplified_prompt_func): - await websocket.send_text(chunk) - - except WebSocketDisconnect: - logger.info("WebSocket disconnected") - except Exception as e: - logger.error(f"Error in WebSocket handler: {str(e)}") - try: - await websocket.send_text(f"Error: {str(e)}") - except Exception: - pass - finally: - await websocket.close() diff --git a/docker-compose-litellm.env b/docker-compose-litellm.env index 0a246ec4e..f6e8f53a8 100644 --- a/docker-compose-litellm.env +++ b/docker-compose-litellm.env @@ -4,4 +4,4 @@ LITELLM_BASE_URL=http://litellm:4000 # Use env. variable LITELLM_API_KEY # But if it doesn't exist, default to `sk-1234` -LITELLM_API_KEY=${LITELLM_API_KEY:-sk-1234} \ No newline at end of file +LITELLM_API_KEY=${LITELLM_API_KEY:-sk-1234} diff --git a/docker-compose-litellm.yml b/docker-compose-litellm.yml index 6f90878a2..b28d5e405 100644 --- a/docker-compose-litellm.yml +++ b/docker-compose-litellm.yml @@ -67,4 +67,4 @@ services: - litellm volumes: - deepwiki_litellm_db: \ No newline at end of file + deepwiki_litellm_db: diff --git a/package-lock.json b/package-lock.json index 1e1dfe9a5..c63a1fcc0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,14 +9,14 @@ "version": "0.1.0", "dependencies": { "mermaid": "^11.4.1", - "next": "15.3.1", + "next": "^15.5.22", "next-intl": "^4.1.0", "next-themes": "^0.4.6", "react": "^19.0.0", "react-dom": "^19.0.0", "react-icons": "^5.5.0", "react-markdown": "^10.1.0", - "react-syntax-highlighter": "^15.6.1", + "react-syntax-highlighter": "^16.1.1", "rehype-katex": "^7.0.1", "rehype-raw": "^7.0.0", "remark-gfm": "^4.0.1", @@ -24,14 +24,14 @@ "svg-pan-zoom": "^3.6.2" }, "devDependencies": { - "@eslint/eslintrc": "^3", + "@eslint/eslintrc": "^0.1.0", "@tailwindcss/postcss": "^4", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", "@types/react-syntax-highlighter": "^15.5.13", - "eslint": "^9", - "eslint-config-next": "15.3.1", + "eslint": "^10.8.0", + "eslint-config-next": "^16.2.12", "tailwindcss": "^4", "typescript": "^5" } @@ -53,7 +53,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@antfu/install-pkg/-/install-pkg-1.1.0.tgz", "integrity": "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==", - "license": "MIT", "dependencies": { "package-manager-detector": "^1.3.0", "tinyexec": "^1.0.1" @@ -62,108 +61,301 @@ "url": "https://github.com/sponsors/antfu" } }, - "node_modules/@antfu/utils": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/@antfu/utils/-/utils-8.1.1.tgz", - "integrity": "sha512-Mex9nXf9vR6AhcXmMrlz/HVgYYZpVGJ6YlPgwl7UnaFpnshXs6EK/oa5Gpf3CzENMjkvEx2tQtntGnb7UtSTOQ==", - "license": "MIT", + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, "funding": { - "url": "https://github.com/sponsors/antfu" + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" } }, "node_modules/@babel/runtime": { - "version": "7.28.3", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.3.tgz", - "integrity": "sha512-9uIQ10o0WGdpP6GDhXcdOJPJuDgFtIDtN/9+ArJQ2NAfAmiuhTQdzkaTGR33v43GYS2UrSA0eX2pPPHoFVvpxA==", - "license": "MIT", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", "engines": { "node": ">=6.9.0" } }, - "node_modules/@braintree/sanitize-url": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-7.1.1.tgz", - "integrity": "sha512-i1L7noDNxtFyL5DmZafWy1wRVhGehQmzZaz1HiN5e7iylJMSZR7ekOV7NsIqa5qBldlLrsKv4HbgFUVlQrz8Mw==", - "license": "MIT" + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } }, - "node_modules/@chevrotain/cst-dts-gen": { - "version": "11.0.3", - "resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-11.0.3.tgz", - "integrity": "sha512-BvIKpRLeS/8UbfxXxgC33xOumsacaeCKAjAeLyOn7Pcp95HiRbrpl14S+9vaZLolnbssPIUuiUd8IvgkRyt6NQ==", - "license": "Apache-2.0", + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, "dependencies": { - "@chevrotain/gast": "11.0.3", - "@chevrotain/types": "11.0.3", - "lodash-es": "4.17.21" + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" } }, - "node_modules/@chevrotain/gast": { - "version": "11.0.3", - "resolved": "https://registry.npmjs.org/@chevrotain/gast/-/gast-11.0.3.tgz", - "integrity": "sha512-+qNfcoNk70PyS/uxmj3li5NiECO+2YKZZQMbmjTqRI3Qchu8Hig/Q9vgkHpI3alNjr7M+a2St5pw5w5F6NL5/Q==", - "license": "Apache-2.0", + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, "dependencies": { - "@chevrotain/types": "11.0.3", - "lodash-es": "4.17.21" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" } }, - "node_modules/@chevrotain/regexp-to-ast": { - "version": "11.0.3", - "resolved": "https://registry.npmjs.org/@chevrotain/regexp-to-ast/-/regexp-to-ast-11.0.3.tgz", - "integrity": "sha512-1fMHaBZxLFvWI067AVbGJav1eRY7N8DDvYCTwGBiE/ytKBgP8azTdgyrKyWZ9Mfh09eHWb5PgTSO8wi7U824RA==", - "license": "Apache-2.0" + "node_modules/@braintree/sanitize-url": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-7.1.2.tgz", + "integrity": "sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==" }, "node_modules/@chevrotain/types": { - "version": "11.0.3", - "resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-11.0.3.tgz", - "integrity": "sha512-gsiM3G8b58kZC2HaWR50gu6Y1440cHiJ+i3JUvcp/35JchYejb2+5MVeJK0iKThYpAa/P2PYFV4hoi44HD+aHQ==", - "license": "Apache-2.0" - }, - "node_modules/@chevrotain/utils": { - "version": "11.0.3", - "resolved": "https://registry.npmjs.org/@chevrotain/utils/-/utils-11.0.3.tgz", - "integrity": "sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ==", - "license": "Apache-2.0" + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-11.1.2.tgz", + "integrity": "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==" }, "node_modules/@emnapi/core": { - "version": "1.4.5", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.4.5.tgz", - "integrity": "sha512-XsLw1dEOpkSX/WucdqUhPWP7hDxSvZiY+fsUC14h+FtQ2Ifni4znbBt8punRX+Uj2JG/uDb8nEHVKvrVlvdZ5Q==", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", "dev": true, - "license": "MIT", "optional": true, "dependencies": { - "@emnapi/wasi-threads": "1.0.4", + "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "node_modules/@emnapi/runtime": { - "version": "1.4.5", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.4.5.tgz", - "integrity": "sha512-++LApOtY0pEEz1zrd9vy1/zXVaVJJ/EbAF3u0fXIzPJEDtnITsBGbbK0EkM72amhl/R5b+5xx0Y/QhcVOpuulg==", - "license": "MIT", + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", "optional": true, "dependencies": { "tslib": "^2.4.0" } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.0.4.tgz", - "integrity": "sha512-PJR+bOmMOPH8AtcTGAyYNiuJ3/Fcoj2XN/gBEWzDIKh254XO+mM9XoXHk5GNEhodxeMznbg7BlRojVbKN+gC6g==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", "dev": true, - "license": "MIT", "optional": true, "dependencies": { "tslib": "^2.4.0" } }, "node_modules/@eslint-community/eslint-utils": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz", - "integrity": "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==", + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", "dev": true, - "license": "MIT", "dependencies": { "eslint-visitor-keys": "^3.4.3" }, @@ -191,172 +383,110 @@ } }, "node_modules/@eslint-community/regexpp": { - "version": "4.12.1", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", - "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", "dev": true, - "license": "MIT", "engines": { "node": "^12.0.0 || ^14.0.0 || >=16.0.0" } }, "node_modules/@eslint/config-array": { - "version": "0.21.0", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.0.tgz", - "integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==", + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", "dev": true, - "license": "Apache-2.0", "dependencies": { - "@eslint/object-schema": "^2.1.6", + "@eslint/object-schema": "^3.0.5", "debug": "^4.3.1", - "minimatch": "^3.1.2" + "minimatch": "^10.2.4" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@eslint/config-helpers": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.3.1.tgz", - "integrity": "sha512-xR93k9WhrDYpXHORXpxVL5oHj3Era7wo6k/Wd8/IsQNnZUTzkGS29lyn3nAT05v6ltUuTFVCCYDEGfy2Or/sPA==", + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", + "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", "dev": true, - "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@eslint/core": { - "version": "0.15.2", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.2.tgz", - "integrity": "sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", "dev": true, - "license": "Apache-2.0", "dependencies": { "@types/json-schema": "^7.0.15" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@eslint/eslintrc": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", - "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-0.1.0.tgz", + "integrity": "sha512-bfL5365QSCmH6cPeFT7Ywclj8C7LiF7sO6mUGzZhtAMV7iID1Euq6740u/SRi4C80NOnVz/CEfK8/HO+nCAPJg==", "dev": true, - "license": "MIT", "dependencies": { "ajv": "^6.12.4", - "debug": "^4.3.2", - "espree": "^10.0.1", - "globals": "^14.0.0", - "ignore": "^5.2.0", + "debug": "^4.1.1", "import-fresh": "^3.2.1", - "js-yaml": "^4.1.0", - "minimatch": "^3.1.2", "strip-json-comments": "^3.1.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint/js": { - "version": "9.34.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.34.0.tgz", - "integrity": "sha512-EoyvqQnBNsV1CWaEJ559rxXL4c8V92gxirbawSmVUOWXlsRxxQXl6LmCpdUblgxgSkDIqKnhzba2SjRTI/A5Rw==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://eslint.org/donate" } }, "node_modules/@eslint/object-schema": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz", - "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", "dev": true, - "license": "Apache-2.0", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@eslint/plugin-kit": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.5.tgz", - "integrity": "sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==", + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", + "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", "dev": true, - "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.15.2", + "@eslint/core": "^1.2.1", "levn": "^0.4.1" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@formatjs/ecma402-abstract": { - "version": "2.3.4", - "resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-2.3.4.tgz", - "integrity": "sha512-qrycXDeaORzIqNhBOx0btnhpD1c+/qFIHAN9znofuMJX6QBwtbrmlpWfD4oiUUD2vJUOIYFA/gYtg2KAMGG7sA==", - "license": "MIT", - "dependencies": { - "@formatjs/fast-memoize": "2.2.7", - "@formatjs/intl-localematcher": "0.6.1", - "decimal.js": "^10.4.3", - "tslib": "^2.8.0" - } - }, - "node_modules/@formatjs/ecma402-abstract/node_modules/@formatjs/intl-localematcher": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.6.1.tgz", - "integrity": "sha512-ePEgLgVCqi2BBFnTMWPfIghu6FkbZnnBVhO2sSxvLfrdFw7wCHAHiDoM2h4NRgjbaY7+B7HgOLZGkK187pZTZg==", - "license": "MIT", - "dependencies": { - "tslib": "^2.8.0" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@formatjs/fast-memoize": { - "version": "2.2.7", - "resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-2.2.7.tgz", - "integrity": "sha512-Yabmi9nSvyOMrlSeGGWDiH7rf3a7sIwplbvo/dlz9WCIjzIQAfy1RMf4S0X3yG724n5Ghu2GmEl5NJIV6O9sZQ==", - "license": "MIT", - "dependencies": { - "tslib": "^2.8.0" - } + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-3.1.7.tgz", + "integrity": "sha512-zXfhLpvA6T7+efdt9JLbBwZ00tT7NsBMDVnDu8rpHeNNv8KfRZAMo2gkG0k9lK/Nzc//3kJ9pImsfuJxk3KhUA==" }, "node_modules/@formatjs/icu-messageformat-parser": { - "version": "2.11.2", - "resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-2.11.2.tgz", - "integrity": "sha512-AfiMi5NOSo2TQImsYAg8UYddsNJ/vUEv/HaNqiFjnI3ZFfWihUtD5QtuX6kHl8+H+d3qvnE/3HZrfzgdWpsLNA==", - "license": "MIT", + "version": "3.5.15", + "resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-3.5.15.tgz", + "integrity": "sha512-5o4grXKotAB3JqQuisLApHG43g17N+paoRTa92Jiz35Zvfemq0cVf4EDvuxyHAzmsJji7igaEowicLO/VmfJ8Q==", "dependencies": { - "@formatjs/ecma402-abstract": "2.3.4", - "@formatjs/icu-skeleton-parser": "1.8.14", - "tslib": "^2.8.0" + "@formatjs/icu-skeleton-parser": "2.1.11" } }, "node_modules/@formatjs/icu-skeleton-parser": { - "version": "1.8.14", - "resolved": "https://registry.npmjs.org/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-1.8.14.tgz", - "integrity": "sha512-i4q4V4qslThK4Ig8SxyD76cp3+QJ3sAqr7f6q9VVfeGtxG9OhiAk3y9XF6Q41OymsKzsGQ6OQQoJNY4/lI8TcQ==", - "license": "MIT", - "dependencies": { - "@formatjs/ecma402-abstract": "2.3.4", - "tslib": "^2.8.0" - } + "version": "2.1.11", + "resolved": "https://registry.npmjs.org/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-2.1.11.tgz", + "integrity": "sha512-j8cUmOJzVgkHuS0QiQ6ga76UIoLOFSAMWhs7aZJztH3aAdCOAE6vpC8KVvFB4cU10ON0y2/5oOVmPJ43s2lTwA==" }, "node_modules/@formatjs/intl-localematcher": { - "version": "0.5.10", - "resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.5.10.tgz", - "integrity": "sha512-af3qATX+m4Rnd9+wHcjJ4w2ijq+rAVP3CCinJQvFv1kgSu1W6jypUmvleJxcewdxmutM8dmIRZFxO/IQBZmP2Q==", - "license": "MIT", + "version": "0.8.13", + "resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.8.13.tgz", + "integrity": "sha512-kHEAFOkeJSPNi7c5PaKaRjxcBrJwzzt81ifUu+8uve1EDW/VJl83KsxmqgqNZLzcFEhSliZGvx3+pk/RH0IOmg==", "dependencies": { - "tslib": "2" + "@formatjs/fast-memoize": "3.1.7" } }, "node_modules/@humanfs/core": { @@ -428,45 +558,34 @@ "node_modules/@iconify/types": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz", - "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==", - "license": "MIT" + "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==" }, "node_modules/@iconify/utils": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@iconify/utils/-/utils-2.3.0.tgz", - "integrity": "sha512-GmQ78prtwYW6EtzXRU1rY+KwOKfz32PD7iJh6Iyqw68GiKuoZ2A6pRtzWONz5VQJbp50mEjXh/7NkumtrAgRKA==", - "license": "MIT", + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/@iconify/utils/-/utils-3.1.4.tgz", + "integrity": "sha512-b1S7B1k9ohZ+iNTi2ATxbRYG9fTrJmUT0rc46bvVnNxqNRGW7dyo/vRREwyniI5IRN2RSJHDcm+s3BjWrSAjHw==", "dependencies": { - "@antfu/install-pkg": "^1.0.0", - "@antfu/utils": "^8.1.0", + "@antfu/install-pkg": "^1.1.0", "@iconify/types": "^2.0.0", - "debug": "^4.4.0", - "globals": "^15.14.0", - "kolorist": "^1.8.0", - "local-pkg": "^1.0.0", - "mlly": "^1.7.4" + "import-meta-resolve": "^4.2.0" } }, - "node_modules/@iconify/utils/node_modules/globals": { - "version": "15.15.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz", - "integrity": "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==", - "license": "MIT", + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "optional": true, "engines": { "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/@img/sharp-darwin-arm64": { - "version": "0.34.3", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.3.tgz", - "integrity": "sha512-ryFMfvxxpQRsgZJqBd4wsttYQbCxsJksrv9Lw/v798JcQ8+w84mBWuXwl+TT0WJ/WrYOLaYpwQXi3sA9nTIaIg==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", "cpu": [ "arm64" ], - "license": "Apache-2.0", "optional": true, "os": [ "darwin" @@ -478,17 +597,16 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.2.0" + "@img/sharp-libvips-darwin-arm64": "1.2.4" } }, "node_modules/@img/sharp-darwin-x64": { - "version": "0.34.3", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.3.tgz", - "integrity": "sha512-yHpJYynROAj12TA6qil58hmPmAwxKKC7reUqtGLzsOHfP7/rniNGTL8tjWX6L3CTV4+5P4ypcS7Pp+7OB+8ihA==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", "cpu": [ "x64" ], - "license": "Apache-2.0", "optional": true, "os": [ "darwin" @@ -500,17 +618,16 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.2.0" + "@img/sharp-libvips-darwin-x64": "1.2.4" } }, "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.0.tgz", - "integrity": "sha512-sBZmpwmxqwlqG9ueWFXtockhsxefaV6O84BMOrhtg/YqbTaRdqDE7hxraVE3y6gVM4eExmfzW4a8el9ArLeEiQ==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", "cpu": [ "arm64" ], - "license": "LGPL-3.0-or-later", "optional": true, "os": [ "darwin" @@ -520,13 +637,12 @@ } }, "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.0.tgz", - "integrity": "sha512-M64XVuL94OgiNHa5/m2YvEQI5q2cl9d/wk0qFTDVXcYzi43lxuiFTftMR1tOnFQovVXNZJ5TURSDK2pNe9Yzqg==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", "cpu": [ "x64" ], - "license": "LGPL-3.0-or-later", "optional": true, "os": [ "darwin" @@ -536,13 +652,12 @@ } }, "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.0.tgz", - "integrity": "sha512-mWd2uWvDtL/nvIzThLq3fr2nnGfyr/XMXlq8ZJ9WMR6PXijHlC3ksp0IpuhK6bougvQrchUAfzRLnbsen0Cqvw==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", "cpu": [ "arm" ], - "license": "LGPL-3.0-or-later", "optional": true, "os": [ "linux" @@ -552,13 +667,12 @@ } }, "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.0.tgz", - "integrity": "sha512-RXwd0CgG+uPRX5YYrkzKyalt2OJYRiJQ8ED/fi1tq9WQW2jsQIn0tqrlR5l5dr/rjqq6AHAxURhj2DVjyQWSOA==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", "cpu": [ "arm64" ], - "license": "LGPL-3.0-or-later", "optional": true, "os": [ "linux" @@ -568,13 +682,27 @@ } }, "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.0.tgz", - "integrity": "sha512-Xod/7KaDDHkYu2phxxfeEPXfVXFKx70EAFZ0qyUdOjCcxbjqyJOEUpDe6RIyaunGxT34Anf9ue/wuWOqBW2WcQ==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", "cpu": [ "ppc64" ], - "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], "optional": true, "os": [ "linux" @@ -584,13 +712,12 @@ } }, "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.0.tgz", - "integrity": "sha512-eMKfzDxLGT8mnmPJTNMcjfO33fLiTDsrMlUVcp6b96ETbnJmd4uvZxVJSKPQfS+odwfVaGifhsB07J1LynFehw==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", "cpu": [ "s390x" ], - "license": "LGPL-3.0-or-later", "optional": true, "os": [ "linux" @@ -600,13 +727,12 @@ } }, "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.0.tgz", - "integrity": "sha512-ZW3FPWIc7K1sH9E3nxIGB3y3dZkpJlMnkk7z5tu1nSkBoCgw2nSRTFHI5pB/3CQaJM0pdzMF3paf9ckKMSE9Tg==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", "cpu": [ "x64" ], - "license": "LGPL-3.0-or-later", "optional": true, "os": [ "linux" @@ -616,13 +742,12 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.0.tgz", - "integrity": "sha512-UG+LqQJbf5VJ8NWJ5Z3tdIe/HXjuIdo4JeVNADXBFuG7z9zjoegpzzGIyV5zQKi4zaJjnAd2+g2nna8TZvuW9Q==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", "cpu": [ "arm64" ], - "license": "LGPL-3.0-or-later", "optional": true, "os": [ "linux" @@ -632,13 +757,12 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.0.tgz", - "integrity": "sha512-SRYOLR7CXPgNze8akZwjoGBoN1ThNZoqpOgfnOxmWsklTGVfJiGJoC/Lod7aNMGA1jSsKWM1+HRX43OP6p9+6Q==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", "cpu": [ "x64" ], - "license": "LGPL-3.0-or-later", "optional": true, "os": [ "linux" @@ -648,13 +772,12 @@ } }, "node_modules/@img/sharp-linux-arm": { - "version": "0.34.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.3.tgz", - "integrity": "sha512-oBK9l+h6KBN0i3dC8rYntLiVfW8D8wH+NPNT3O/WBHeW0OQWCjfWksLUaPidsrDKpJgXp3G3/hkmhptAW0I3+A==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", "cpu": [ "arm" ], - "license": "Apache-2.0", "optional": true, "os": [ "linux" @@ -666,17 +789,16 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.2.0" + "@img/sharp-libvips-linux-arm": "1.2.4" } }, "node_modules/@img/sharp-linux-arm64": { - "version": "0.34.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.3.tgz", - "integrity": "sha512-QdrKe3EvQrqwkDrtuTIjI0bu6YEJHTgEeqdzI3uWJOH6G1O8Nl1iEeVYRGdj1h5I21CqxSvQp1Yv7xeU3ZewbA==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", "cpu": [ "arm64" ], - "license": "Apache-2.0", "optional": true, "os": [ "linux" @@ -688,17 +810,16 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.2.0" + "@img/sharp-libvips-linux-arm64": "1.2.4" } }, "node_modules/@img/sharp-linux-ppc64": { - "version": "0.34.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.3.tgz", - "integrity": "sha512-GLtbLQMCNC5nxuImPR2+RgrviwKwVql28FWZIW1zWruy6zLgA5/x2ZXk3mxj58X/tszVF69KK0Is83V8YgWhLA==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", "cpu": [ "ppc64" ], - "license": "Apache-2.0", "optional": true, "os": [ "linux" @@ -710,17 +831,16 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.2.0" + "@img/sharp-libvips-linux-ppc64": "1.2.4" } }, - "node_modules/@img/sharp-linux-s390x": { - "version": "0.34.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.3.tgz", - "integrity": "sha512-3gahT+A6c4cdc2edhsLHmIOXMb17ltffJlxR0aC2VPZfwKoTGZec6u5GrFgdR7ciJSsHT27BD3TIuGcuRT0KmQ==", + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", "cpu": [ - "s390x" + "riscv64" ], - "license": "Apache-2.0", "optional": true, "os": [ "linux" @@ -732,17 +852,16 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.2.0" + "@img/sharp-libvips-linux-riscv64": "1.2.4" } }, - "node_modules/@img/sharp-linux-x64": { - "version": "0.34.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.3.tgz", - "integrity": "sha512-8kYso8d806ypnSq3/Ly0QEw90V5ZoHh10yH0HnrzOCr6DKAPI6QVHvwleqMkVQ0m+fc7EH8ah0BB0QPuWY6zJQ==", + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", "cpu": [ - "x64" + "s390x" ], - "license": "Apache-2.0", "optional": true, "os": [ "linux" @@ -754,17 +873,37 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.2.0" + "@img/sharp-libvips-linux-s390x": "1.2.4" } }, - "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.34.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.3.tgz", - "integrity": "sha512-vAjbHDlr4izEiXM1OTggpCcPg9tn4YriK5vAjowJsHwdBIdx0fYRsURkxLG2RLm9gyBq66gwtWI8Gx0/ov+JKQ==", + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", "cpu": [ - "arm64" + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" ], - "license": "Apache-2.0", "optional": true, "os": [ "linux" @@ -776,17 +915,16 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.2.0" + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" } }, "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.34.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.3.tgz", - "integrity": "sha512-gCWUn9547K5bwvOn9l5XGAEjVTTRji4aPTqLzGXHvIr6bIDZKNTA34seMPgM0WmSf+RYBH411VavCejp3PkOeQ==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", "cpu": [ "x64" ], - "license": "Apache-2.0", "optional": true, "os": [ "linux" @@ -798,20 +936,19 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.2.0" + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" } }, "node_modules/@img/sharp-wasm32": { - "version": "0.34.3", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.3.tgz", - "integrity": "sha512-+CyRcpagHMGteySaWos8IbnXcHgfDn7pO2fiC2slJxvNq9gDipYBN42/RagzctVRKgxATmfqOSulgZv5e1RdMg==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", "cpu": [ "wasm32" ], - "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, "dependencies": { - "@emnapi/runtime": "^1.4.4" + "@emnapi/runtime": "^1.7.0" }, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" @@ -821,13 +958,12 @@ } }, "node_modules/@img/sharp-win32-arm64": { - "version": "0.34.3", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.3.tgz", - "integrity": "sha512-MjnHPnbqMXNC2UgeLJtX4XqoVHHlZNd+nPt1kRPmj63wURegwBhZlApELdtxM2OIZDRv/DFtLcNhVbd1z8GYXQ==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", "cpu": [ "arm64" ], - "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ "win32" @@ -840,13 +976,12 @@ } }, "node_modules/@img/sharp-win32-ia32": { - "version": "0.34.3", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.3.tgz", - "integrity": "sha512-xuCdhH44WxuXgOM714hn4amodJMZl3OEvf0GVTm0BEyMeA2to+8HEdRPShH0SLYptJY1uBw+SCFP9WVQi1Q/cw==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", "cpu": [ "ia32" ], - "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ "win32" @@ -859,13 +994,12 @@ } }, "node_modules/@img/sharp-win32-x64": { - "version": "0.34.3", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.3.tgz", - "integrity": "sha512-OWwz05d++TxzLEv4VnsTz5CmZ6mI6S05sfQGEMrNrQcOEERbX46332IvE7pO/EUiw7jUrrS40z/M7kPyjfl04g==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", "cpu": [ "x64" ], - "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ "win32" @@ -941,12 +1075,11 @@ } }, "node_modules/@mermaid-js/parser": { - "version": "0.6.2", - "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-0.6.2.tgz", - "integrity": "sha512-+PO02uGF6L6Cs0Bw8RpGhikVvMWEysfAyl27qTlroUB8jSWr1lL0Sf6zi78ZxlSnmgSY2AMMKVgghnN9jTtwkQ==", - "license": "MIT", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.2.0.tgz", + "integrity": "sha512-oYPyv8A4As1yH5Bx+04iQEQxXuIQDe0GKCNSRgao6z8AM9jixXIfP0vsppRLvGf+nKIOb9/LdpWA4YuJiVvESA==", "dependencies": { - "langium": "3.3.1" + "@chevrotain/types": "~11.1.2" } }, "node_modules/@napi-rs/wasm-runtime": { @@ -963,29 +1096,26 @@ } }, "node_modules/@next/env": { - "version": "15.3.1", - "resolved": "https://registry.npmjs.org/@next/env/-/env-15.3.1.tgz", - "integrity": "sha512-cwK27QdzrMblHSn9DZRV+DQscHXRuJv6MydlJRpFSqJWZrTYMLzKDeyueJNN9MGd8NNiUKzDQADAf+dMLXX7YQ==", - "license": "MIT" + "version": "15.5.22", + "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.22.tgz", + "integrity": "sha512-O5BlKb3KtsHkvO0gjjV66PuJnAgCtIEIzwkt50HRAHsQkU1t77eksIXSZV84/WMtZJjWrnDUPKHVRi0D62nSAA==" }, "node_modules/@next/eslint-plugin-next": { - "version": "15.3.1", - "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-15.3.1.tgz", - "integrity": "sha512-oEs4dsfM6iyER3jTzMm4kDSbrQJq8wZw5fmT6fg2V3SMo+kgG+cShzLfEV20senZzv8VF+puNLheiGPlBGsv2A==", + "version": "16.2.12", + "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.2.12.tgz", + "integrity": "sha512-uF2z/qAK2q7B5/6CpnFcBRX6jOq5iCO+Uqh1UkJhXljX1JwLarLYhhoJadO6dPb6moTprOKewMXheBcbIoSbug==", "dev": true, - "license": "MIT", "dependencies": { "fast-glob": "3.3.1" } }, "node_modules/@next/swc-darwin-arm64": { - "version": "15.3.1", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.3.1.tgz", - "integrity": "sha512-hjDw4f4/nla+6wysBL07z52Gs55Gttp5Bsk5/8AncQLJoisvTBP0pRIBK/B16/KqQyH+uN4Ww8KkcAqJODYH3w==", + "version": "15.5.22", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.22.tgz", + "integrity": "sha512-/VISwtffSg8+fVvBbXdglsvruCsdbBC4dG25iU6xascKVqfQKsj/OtjGnOEkIS7pX5GB9e9/r5QprpicsGL3gw==", "cpu": [ "arm64" ], - "license": "MIT", "optional": true, "os": [ "darwin" @@ -995,13 +1125,12 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "15.3.1", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.3.1.tgz", - "integrity": "sha512-q+aw+cJ2ooVYdCEqZVk+T4Ni10jF6Fo5DfpEV51OupMaV5XL6pf3GCzrk6kSSZBsMKZtVC1Zm/xaNBFpA6bJ2g==", + "version": "15.5.22", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.22.tgz", + "integrity": "sha512-NiA9ve8hbiuhG/Q17a2mZDRVxMTtg3rTOgjLnDaLlE+AEPAQlkkuKrfePEbeOrgYmX0U2KGX4EVEn09hXU5GlQ==", "cpu": [ "x64" ], - "license": "MIT", "optional": true, "os": [ "darwin" @@ -1011,13 +1140,12 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "15.3.1", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.3.1.tgz", - "integrity": "sha512-wBQ+jGUI3N0QZyWmmvRHjXjTWFy8o+zPFLSOyAyGFI94oJi+kK/LIZFJXeykvgXUk1NLDAEFDZw/NVINhdk9FQ==", + "version": "15.5.22", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.22.tgz", + "integrity": "sha512-vAPa9vltW+UW/KWtjXeSUFgV3wb1x9d/BeyC6WFI6eBpL0D2f70oGwtOp6193mNW3qusrpgBzMQferPf+Zh8Dw==", "cpu": [ "arm64" ], - "license": "MIT", "optional": true, "os": [ "linux" @@ -1027,13 +1155,12 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "15.3.1", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.3.1.tgz", - "integrity": "sha512-IIxXEXRti/AulO9lWRHiCpUUR8AR/ZYLPALgiIg/9ENzMzLn3l0NSxVdva7R/VDcuSEBo0eGVCe3evSIHNz0Hg==", + "version": "15.5.22", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.22.tgz", + "integrity": "sha512-iknK80pWlNDnkdSr13bd8mMuG3Z2oTxODwsZHvuMY7caMk77+rBLdHVWsy8v2EVa3ZojJ/+wJX5fnq8va6Gv8A==", "cpu": [ "arm64" ], - "license": "MIT", "optional": true, "os": [ "linux" @@ -1043,13 +1170,12 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "15.3.1", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.3.1.tgz", - "integrity": "sha512-bfI4AMhySJbyXQIKH5rmLJ5/BP7bPwuxauTvVEiJ/ADoddaA9fgyNNCcsbu9SlqfHDoZmfI6g2EjzLwbsVTr5A==", + "version": "15.5.22", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.22.tgz", + "integrity": "sha512-penuEdkwU2OOAiS+n4LE8T/VIoCfAI01QcLZTJ2xc3+l4Q22L/DzURocmI2LU1b+8BMQoLAP1Sze3uYAZT05Bg==", "cpu": [ "x64" ], - "license": "MIT", "optional": true, "os": [ "linux" @@ -1059,13 +1185,12 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "15.3.1", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.3.1.tgz", - "integrity": "sha512-FeAbR7FYMWR+Z+M5iSGytVryKHiAsc0x3Nc3J+FD5NVbD5Mqz7fTSy8CYliXinn7T26nDMbpExRUI/4ekTvoiA==", + "version": "15.5.22", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.22.tgz", + "integrity": "sha512-ZM0BKJm3FZ+guG6WT6PcyOLtp6paZ5tngcJC/uUKvLW4Y0TQnnVi1+UGdo8Q6Yxp5gaS82pmC1rD/oFlhkWB3g==", "cpu": [ "x64" ], - "license": "MIT", "optional": true, "os": [ "linux" @@ -1075,13 +1200,12 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "15.3.1", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.3.1.tgz", - "integrity": "sha512-yP7FueWjphQEPpJQ2oKmshk/ppOt+0/bB8JC8svPUZNy0Pi3KbPx2Llkzv1p8CoQa+D2wknINlJpHf3vtChVBw==", + "version": "15.5.22", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.22.tgz", + "integrity": "sha512-rY/YaumrZaS0//94BnHLF5VSRp0GFUO4GvXNuoCBb0cGSci96yO+p1JaNL2aq9YZAYv9cuZRziV02x5IQH/wjg==", "cpu": [ "arm64" ], - "license": "MIT", "optional": true, "os": [ "win32" @@ -1091,13 +1215,12 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "15.3.1", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.3.1.tgz", - "integrity": "sha512-3PMvF2zRJAifcRNni9uMk/gulWfWS+qVI/pagd+4yLF5bcXPZPPH2xlYRYOsUjmCJOXSTAC2PjRzbhsRzR2fDQ==", + "version": "15.5.22", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.22.tgz", + "integrity": "sha512-s5IA4cyrbR2XK/5NWcu5dp8CfPBiKME+UhvNperia7uQybEgg5+LIhGMiY37WQE4rcI4owsDcU4IVUjLoTuDkA==", "cpu": [ "x64" ], - "license": "MIT", "optional": true, "os": [ "win32" @@ -1141,38 +1264,480 @@ "fastq": "^1.6.0" }, "engines": { - "node": ">= 8" + "node": ">= 8" + } + }, + "node_modules/@nolyfill/is-core-module": { + "version": "1.0.39", + "resolved": "https://registry.npmjs.org/@nolyfill/is-core-module/-/is-core-module-1.0.39.tgz", + "integrity": "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==", + "dev": true, + "engines": { + "node": ">=12.4.0" + } + }, + "node_modules/@parcel/watcher": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.6.0.tgz", + "integrity": "sha512-7FNeNl8NCE7aINx7WXiKQrPYZWC/hvrTsmk6zmxbI7LTXE7hVek/n8AfVgpe2y82zl3w0HvCHN0bVKMBoJcC0w==", + "hasInstallScript": true, + "dependencies": { + "detect-libc": "^2.0.3", + "is-glob": "^4.0.3", + "node-addon-api": "^7.0.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.6.0", + "@parcel/watcher-darwin-arm64": "2.6.0", + "@parcel/watcher-darwin-x64": "2.6.0", + "@parcel/watcher-freebsd-x64": "2.6.0", + "@parcel/watcher-linux-arm-glibc": "2.6.0", + "@parcel/watcher-linux-arm-musl": "2.6.0", + "@parcel/watcher-linux-arm64-glibc": "2.6.0", + "@parcel/watcher-linux-arm64-musl": "2.6.0", + "@parcel/watcher-linux-x64-glibc": "2.6.0", + "@parcel/watcher-linux-x64-musl": "2.6.0", + "@parcel/watcher-win32-arm64": "2.6.0", + "@parcel/watcher-win32-x64": "2.6.0" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.6.0.tgz", + "integrity": "sha512-trgpLSCKRC/huFjXX/Smh+0sWe4+YtKfktIToiMl59ghz7z+qkH6kMvNnUbLyRs9N11t8l4svSCs1+5B3rOAhA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.6.0.tgz", + "integrity": "sha512-Y3QV0gl7Q1zbfueunkWIERICbEojQFCgpyG7YqOGNFLsckXyI1xu9mAIUpKY9QBYzBtSkN8dBPwd3yiAO9ovMw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.6.0.tgz", + "integrity": "sha512-Ohv6OpzhUfKYD7Beb8kDvG0jbIxORCYY1JRdZnaBtnjjkJxgD7ZVL0nw2sCYd0yTMKTvz3nnTnOF3cDifK+kvw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.6.0.tgz", + "integrity": "sha512-5HmXvDgs8VK+74jF9y9/2FE3/OnlcKmc56tjmSrEuZjpSZOGL+fvAu+HKJBdPs9uwoP2hE6TlSUpXZ/C5jUFmQ==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.6.0.tgz", + "integrity": "sha512-Ps/hui3A+vMbjdqlqAowK2ZL8+BO8dBjxeWXj6npTBs3jx4wWmbPpaLuqwrQrSqIVMCnpWo238bJ1U37GhQOYg==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.6.0.tgz", + "integrity": "sha512-9c6AUHgHoG+IY88MRIHupztQiQnrbqHYQjkM2btA+Bf/wQnQMuiD0Wfk1EVv3TlNT3x41uU71rn6E4xh/+zvkw==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.6.0.tgz", + "integrity": "sha512-yHRqS2owEXe6Hic9z6Mh1ECsCd+ODVOGvZDyciqRd21+v+o+DnXMOrw50DSpIG2sb8GPEaPPmfeCAWKPJdq46g==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.6.0.tgz", + "integrity": "sha512-WhB2e/V7rqdHHWZusBSPuy5Ei8S6lSz6FE5TKKQz5h3a0O+C+mhY7vxU9b/stqvMb8beLnPY82ZrFTLKs+SrKA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.6.0.tgz", + "integrity": "sha512-ulGE6x6Oz6iAwg75T8YQSoguBWasniIbX+QWpaYPcCnDOpdWX3k+4xbEYPZVLxOuoJI+svJJPD3sEj8G7lrQ3A==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.6.0.tgz", + "integrity": "sha512-tkBYKt7YQrjIJWYDnto2YgO8MRkjlMTSNoRHzsXinBqbLdeOM3L32wPZJvIZxqaLMfSlS/4sUjH/6STVP/XDLw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.6.0.tgz", + "integrity": "sha512-gIZAP23jaHjGWasY/TY6yL7NHFClf0Ga7FN+iINvk+KN94rhm94lYZhFsbYFNcA04/onvGD9kKmiJLJB2HbNwQ==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.6.0.tgz", + "integrity": "sha512-cA+/pXV2YkfxlIcXOQ5fSWqAzzPyD78/x5qbK/I0vUkrlYHA8TIz+MXjAbGouguKVSI4bOmkTSJ1/poVSsgt+A==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/@rtsao/scc": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", + "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", + "dev": true + }, + "node_modules/@schummar/icu-type-parser": { + "version": "1.21.5", + "resolved": "https://registry.npmjs.org/@schummar/icu-type-parser/-/icu-type-parser-1.21.5.tgz", + "integrity": "sha512-bXHSaW5jRTmke9Vd0h5P7BtWZG9Znqb8gSDxZnxaGSJnGwPLDPfS+3g0BKzeWqzgZPsIVZkM7m2tbo18cm5HBw==" + }, + "node_modules/@swc/core-darwin-arm64": { + "version": "1.15.46", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.46.tgz", + "integrity": "sha512-IsISIT22EfktVJrlvIpnAxG2u/A9aob9l99HMlx80x72WlFmFPk1V3UhkEzx86eJP8hw049KTFv/RISho2cq2Q==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-darwin-x64": { + "version": "1.15.46", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.15.46.tgz", + "integrity": "sha512-4Tj4ppVIPCmUMpmGFiGtyEriwLyJ+yi/US4WfBrP/ok8COGddDZXLEzQETnKyK46mjvr1v0jevrS23zjoff7vA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm-gnueabihf": { + "version": "1.15.46", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.46.tgz", + "integrity": "sha512-i8tUGnNjyOgMmfmgFSg4aeJLQoFyfpIHK5FjpQAwpRyQIqEUB2w1e8zIDQzY1WhOxx8NoS1S5iUL813Un4Sf5A==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm64-gnu": { + "version": "1.15.46", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.46.tgz", + "integrity": "sha512-c0OnhqzdhfOvv6qhNCcByepB+sNYOGZyhtr2Qa6ZCHvAWTYhSRw4j/u92Stue9PbZ/6q74b9nHzi76+kVzqQHQ==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm64-musl": { + "version": "1.15.46", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.46.tgz", + "integrity": "sha512-imyRpNEcUzFQFV2LE4jL68ErvmKEuZCbvZru77iQREunJ+bR4i658cupTgtG1mLYM3F1Tzy3Sb9xYb02KghWTg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-ppc64-gnu": { + "version": "1.15.46", + "resolved": "https://registry.npmjs.org/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.15.46.tgz", + "integrity": "sha512-ctEfcl/HcUeomK33cbySiHZm98GEDIxTm1EkpBsYCiHxElYBzvTXVeuQT2YwbUXn9XCrjiw4ipyUNk33k26qRg==", + "cpu": [ + "ppc64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-s390x-gnu": { + "version": "1.15.46", + "resolved": "https://registry.npmjs.org/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.15.46.tgz", + "integrity": "sha512-DxlMdnt84TtRVTv7WL/thWyz9+QU8QZNNoAP9rrk0P68LziuhfePp8MjQ44zIprpTHTsEwyziIuGUUN5iSC1bQ==", + "cpu": [ + "s390x" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-x64-gnu": { + "version": "1.15.46", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.46.tgz", + "integrity": "sha512-SKxI7J6t90XPl8hRUqtJi9NfGdunN/E/vZMc7Bc0figeRdOPDBT+Tm8g7cx9xM0T0mewh2l+8dewa3Am27/P+A==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-x64-musl": { + "version": "1.15.46", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.46.tgz", + "integrity": "sha512-qj9T6B7bosI0VEsrWOVXZN1OXxS8Tp63ywyrLxNdOycnUtLdkgYcoBsN5y8ImnDDsnwrEWZOy1e+J4xSe7mA3Q==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-arm64-msvc": { + "version": "1.15.46", + "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.46.tgz", + "integrity": "sha512-8p7l4c3LU+eA5g9Et1JPhNeMC1oQwXTGU+uah8DPIBX7YXzqswvaBtyKVmXefVGi/DJU1x3YJsc3mbAp9aWzSQ==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" } }, - "node_modules/@nolyfill/is-core-module": { - "version": "1.0.39", - "resolved": "https://registry.npmjs.org/@nolyfill/is-core-module/-/is-core-module-1.0.39.tgz", - "integrity": "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==", - "dev": true, - "license": "MIT", + "node_modules/@swc/core-win32-ia32-msvc": { + "version": "1.15.46", + "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.46.tgz", + "integrity": "sha512-tUEnfr3Bn9u6FOjUb3PN9p+09qZC2j+wNDLKHzXXZn22rqGcUqR/ohCRSS+nG9B9+X+U+3FewNEHJkTmdIvMjQ==", + "cpu": [ + "ia32" + ], + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">=12.4.0" + "node": ">=10" } }, - "node_modules/@rtsao/scc": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", - "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", - "dev": true, - "license": "MIT" - }, - "node_modules/@rushstack/eslint-patch": { - "version": "1.12.0", - "resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.12.0.tgz", - "integrity": "sha512-5EwMtOqvJMMa3HbmxLlF74e+3/HhwBTMcvt3nqVJgGCozO6hzIPOBlwm8mGVNR9SN2IJpxSnlxczyDjcn7qIyw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@schummar/icu-type-parser": { - "version": "1.21.5", - "resolved": "https://registry.npmjs.org/@schummar/icu-type-parser/-/icu-type-parser-1.21.5.tgz", - "integrity": "sha512-bXHSaW5jRTmke9Vd0h5P7BtWZG9Znqb8gSDxZnxaGSJnGwPLDPfS+3g0BKzeWqzgZPsIVZkM7m2tbo18cm5HBw==", - "license": "MIT" + "node_modules/@swc/core-win32-x64-msvc": { + "version": "1.15.46", + "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.46.tgz", + "integrity": "sha512-Vux7UDzBJYQggSuPfcl2w9iu+IJpgpRCxHzgCaVkELnAXAE4XZMOTX9HNcaNiwfeIDqdu2rkr69RuDm6wY8neA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } }, "node_modules/@swc/counter": { "version": "0.1.3", @@ -1189,6 +1754,14 @@ "tslib": "^2.8.0" } }, + "node_modules/@swc/types": { + "version": "0.1.27", + "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.27.tgz", + "integrity": "sha512-K6h3iUlqeM946U4sXFYeahefR1YBbXJvko+hv8WS8/0BNJ4OHiHRywMnQUJCqkR7Y9+hqQ1TvEpiKqUhz7NEFg==", + "dependencies": { + "@swc/counter": "^0.1.3" + } + }, "node_modules/@tailwindcss/node": { "version": "4.1.12", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.12.tgz", @@ -1466,11 +2039,10 @@ } }, "node_modules/@tybys/wasm-util": { - "version": "0.10.0", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.0.tgz", - "integrity": "sha512-VyyPYFlOMNylG45GoAe0xDoLwWuowvf92F9kySqzYh8vmYm7D2u4iUJKa1tOUpS70Ku13ASrOkS4ScXFsTaCNQ==", + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", "dev": true, - "license": "MIT", "optional": true, "dependencies": { "tslib": "^2.4.0" @@ -1738,6 +2310,12 @@ "@types/ms": "*" } }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -1772,15 +2350,13 @@ "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/@types/json5": { "version": "0.0.29", "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/@types/katex": { "version": "0.16.8", @@ -1813,11 +2389,15 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/prismjs": { + "version": "1.26.6", + "resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.6.tgz", + "integrity": "sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw==" + }, "node_modules/@types/react": { "version": "19.1.11", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.11.tgz", "integrity": "sha512-lr3jdBw/BGj49Eps7EvqlUaoeA0xpj3pc0RoJkHpYaCHkVK7i28dKyImLQb3JVlqs3aYSXf7qYuWOW/fgZnTXQ==", - "dev": true, "license": "MIT", "dependencies": { "csstype": "^3.0.2" @@ -1847,7 +2427,6 @@ "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", - "license": "MIT", "optional": true }, "node_modules/@types/unist": { @@ -1857,21 +2436,19 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.40.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.40.0.tgz", - "integrity": "sha512-w/EboPlBwnmOBtRbiOvzjD+wdiZdgFeo17lkltrtn7X37vagKKWJABvyfsJXTlHe6XBzugmYgd4A4nW+k8Mixw==", + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.65.0.tgz", + "integrity": "sha512-IEgob78X12rHpUmtcwFsXhZdVGJtwTVP8FiCLZkR6GlYVrl2PcuB+KhCE5BlVC/eQpQnu8WXRtkHZuPar+gCRA==", "dev": true, - "license": "MIT", "dependencies": { - "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.40.0", - "@typescript-eslint/type-utils": "8.40.0", - "@typescript-eslint/utils": "8.40.0", - "@typescript-eslint/visitor-keys": "8.40.0", - "graphemer": "^1.4.0", - "ignore": "^7.0.0", + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.65.0", + "@typescript-eslint/type-utils": "8.65.0", + "@typescript-eslint/utils": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0", + "ignore": "^7.0.5", "natural-compare": "^1.4.0", - "ts-api-utils": "^2.1.0" + "ts-api-utils": "^2.5.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1881,33 +2458,31 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.40.0", - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" + "@typescript-eslint/parser": "^8.65.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", - "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", + "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", "dev": true, - "license": "MIT", "engines": { "node": ">= 4" } }, "node_modules/@typescript-eslint/parser": { - "version": "8.40.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.40.0.tgz", - "integrity": "sha512-jCNyAuXx8dr5KJMkecGmZ8KI61KBUhkCob+SD+C+I5+Y1FWI2Y3QmY4/cxMCC5WAsZqoEtEETVhUiUMIGCf6Bw==", + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.65.0.tgz", + "integrity": "sha512-CZ4nMxWwgu1HEEFNkeaCptra9QCtkmKdgf3sWh1rl1trIhmxLilgTV4cwcbQ4wemnT4sWQN8CaKOmdYx+g2gMA==", "dev": true, - "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.40.0", - "@typescript-eslint/types": "8.40.0", - "@typescript-eslint/typescript-estree": "8.40.0", - "@typescript-eslint/visitor-keys": "8.40.0", - "debug": "^4.3.4" + "@typescript-eslint/scope-manager": "8.65.0", + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/typescript-estree": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0", + "debug": "^4.4.3" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1917,20 +2492,19 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.40.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.40.0.tgz", - "integrity": "sha512-/A89vz7Wf5DEXsGVvcGdYKbVM9F7DyFXj52lNYUDS1L9yJfqjW/fIp5PgMuEJL/KeqVTe2QSbXAGUZljDUpArw==", + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.65.0.tgz", + "integrity": "sha512-SxnPhbTsGahizDgbu7oqFH/xVtzIqMd/s+WtnSxNxJZJpLbdT5IPdzg8EZxO3+PoKahXmwJLeNQOpKJb3/bi7Q==", "dev": true, - "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.40.0", - "@typescript-eslint/types": "^8.40.0", - "debug": "^4.3.4" + "@typescript-eslint/tsconfig-utils": "^8.65.0", + "@typescript-eslint/types": "^8.65.0", + "debug": "^4.4.3" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1940,18 +2514,17 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.40.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.40.0.tgz", - "integrity": "sha512-y9ObStCcdCiZKzwqsE8CcpyuVMwRouJbbSrNuThDpv16dFAj429IkM6LNb1dZ2m7hK5fHyzNcErZf7CEeKXR4w==", + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.65.0.tgz", + "integrity": "sha512-Esbl8OSYiVxBokYgWPf7VVWg/BE798wXhimnn9ML9Pt5qoDf8bfQlgjlKXR/k98+AcNzlLKYrpCcrcuZ9DZLgg==", "dev": true, - "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.40.0", - "@typescript-eslint/visitor-keys": "8.40.0" + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1962,11 +2535,10 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.40.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.40.0.tgz", - "integrity": "sha512-jtMytmUaG9d/9kqSl/W3E3xaWESo4hFDxAIHGVW/WKKtQhesnRIJSAJO6XckluuJ6KDB5woD1EiqknriCtAmcw==", + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.65.0.tgz", + "integrity": "sha512-j6GzGqCiRdA7Qhur2VVmKZAkBLfnHFQfx4TaJGL9RMveZqCo48jSHHO0DTgizEnGhtWnqmbtCUSrqSkdiY/0Hg==", "dev": true, - "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -1975,21 +2547,20 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.40.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.40.0.tgz", - "integrity": "sha512-eE60cK4KzAc6ZrzlJnflXdrMqOBaugeukWICO2rB0KNvwdIMaEaYiywwHMzA1qFpTxrLhN9Lp4E/00EgWcD3Ow==", + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.65.0.tgz", + "integrity": "sha512-YjaZ7PRI5qY7ax2L3PbvX0rRyGtipAReCWs0mhhDBHjH/vl0g0BonaGXrKdKpMbIIsMIwDgbk/xzkBTyAltS5g==", "dev": true, - "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.40.0", - "@typescript-eslint/typescript-estree": "8.40.0", - "@typescript-eslint/utils": "8.40.0", - "debug": "^4.3.4", - "ts-api-utils": "^2.1.0" + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/typescript-estree": "8.65.0", + "@typescript-eslint/utils": "8.65.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1999,16 +2570,15 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/types": { - "version": "8.40.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.40.0.tgz", - "integrity": "sha512-ETdbFlgbAmXHyFPwqUIYrfc12ArvpBhEVgGAxVYSwli26dn8Ko+lIo4Su9vI9ykTZdJn+vJprs/0eZU0YMAEQg==", + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.65.0.tgz", + "integrity": "sha512-JSSwWNy+H0E/01jJEM+hrX6N0OFDzFzeIhHFSAS01tlVaevpG8cFyYRPhS5yjGOvBUx3sqQHVMjCL1CAZZMxBg==", "dev": true, - "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -2018,22 +2588,20 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.40.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.40.0.tgz", - "integrity": "sha512-k1z9+GJReVVOkc1WfVKs1vBrR5MIKKbdAjDTPvIK3L8De6KbFfPFt6BKpdkdk7rZS2GtC/m6yI5MYX+UsuvVYQ==", + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.65.0.tgz", + "integrity": "sha512-JboAE2swaYt4tb1fHhHTABE2K+OLy09XfcTbhnk4Pw96f9dd2e9iYsJ28gBggHlo5z5x1rkyWvcPoTuNTd4oGg==", "dev": true, - "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.40.0", - "@typescript-eslint/tsconfig-utils": "8.40.0", - "@typescript-eslint/types": "8.40.0", - "@typescript-eslint/visitor-keys": "8.40.0", - "debug": "^4.3.4", - "fast-glob": "^3.3.2", - "is-glob": "^4.0.3", - "minimatch": "^9.0.4", - "semver": "^7.6.0", - "ts-api-utils": "^2.1.0" + "@typescript-eslint/project-service": "8.65.0", + "@typescript-eslint/tsconfig-utils": "8.65.0", + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2043,76 +2611,19 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/fast-glob": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", - "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.8" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/utils": { - "version": "8.40.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.40.0.tgz", - "integrity": "sha512-Cgzi2MXSZyAUOY+BFwGs17s7ad/7L+gKt6Y8rAVVWS+7o6wrjeFN4nVfTpbE25MNcxyJ+iYUXflbs2xR9h4UBg==", + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.65.0.tgz", + "integrity": "sha512-gXiwIHsYreboxeJucHKPvgwl7dXt50mF8s1/c00cP/WoVTyWKFdtfhRWwZiXYFU5H2O8vVoSLNrexFZjYS/SGA==", "dev": true, - "license": "MIT", "dependencies": { - "@eslint-community/eslint-utils": "^4.7.0", - "@typescript-eslint/scope-manager": "8.40.0", - "@typescript-eslint/types": "8.40.0", - "@typescript-eslint/typescript-estree": "8.40.0" + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.65.0", + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/typescript-estree": "8.65.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2122,19 +2633,18 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.40.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.40.0.tgz", - "integrity": "sha512-8CZ47QwalyRjsypfwnbI3hKy5gJDPmrkLjkgMxhi0+DZZ2QNx2naS6/hWoVYUHU7LU2zleF68V9miaVZvhFfTA==", + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.65.0.tgz", + "integrity": "sha512-8C71BQkGjiMmXtop7pHVJu1l2NNShFdkCyD6a2ezzs5vU/L3LRtb69EtcteFwz0mYMPzIgOw0n6OV4VBUWZd7A==", "dev": true, - "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.40.0", - "eslint-visitor-keys": "^4.2.1" + "@typescript-eslint/types": "8.65.0", + "eslint-visitor-keys": "^5.0.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2151,279 +2661,338 @@ "license": "ISC" }, "node_modules/@unrs/resolver-binding-android-arm-eabi": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz", - "integrity": "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.12.2.tgz", + "integrity": "sha512-g5T90pqg1bo/7mytQx6F4iBNC0Wsh9cu+z9veDbFjc7HjpesJFWD7QMS0NGStXM075+7dJPPVvBbpZlnrdpi/w==", "cpu": [ "arm" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "android" ] }, "node_modules/@unrs/resolver-binding-android-arm64": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz", - "integrity": "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.12.2.tgz", + "integrity": "sha512-YGCRZv/9GLhwmz6mYDeTsm/92BAyR28l6c2ReweVW5pWgfsitWLY8upvfRlGdoyD8HjeTHSYJWyZGD4KJA/nFQ==", "cpu": [ "arm64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "android" ] }, "node_modules/@unrs/resolver-binding-darwin-arm64": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.11.1.tgz", - "integrity": "sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.12.2.tgz", + "integrity": "sha512-u9DiNT1auQMO20A9SyTuG3wUgQWB9Z7KjAg0uFuCDR1FsAY8A0CG2S6JpHS1xwm/w1G08bjXZDcyOCjv1WAm2w==", "cpu": [ "arm64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "darwin" ] }, "node_modules/@unrs/resolver-binding-darwin-x64": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz", - "integrity": "sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.12.2.tgz", + "integrity": "sha512-f7rPLi/T1HVKZu/u6t87lroib16n8vrSzcyxI7lg4BGO9UF26KhQL44sd9eOUgrTYhvRXtWOIZT5PejdPyJfUA==", "cpu": [ "x64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "darwin" ] }, "node_modules/@unrs/resolver-binding-freebsd-x64": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.11.1.tgz", - "integrity": "sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.12.2.tgz", + "integrity": "sha512-BpcOjWCJub6nRZUS2zA20pmLvjtqAtGejETaIyRLiZiQf++cbrjltLA5NN/xaXfqeOBOSlMFbemIl5/S5tljmg==", "cpu": [ "x64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "freebsd" ] }, "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.11.1.tgz", - "integrity": "sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.12.2.tgz", + "integrity": "sha512-vZTDvdSISZjJx66OzJqtsOhzifbqRjbmI1Mnu49fQDwog5GtDI4QidRiEAYbZCRj9C8YZEW+3ZjqsyS9GR4k2A==", "cpu": [ "arm" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.11.1.tgz", - "integrity": "sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.12.2.tgz", + "integrity": "sha512-BiPI+IrIlwcW4nLLMM21+B1dFPzd55yAVgVGrdgDjNef+ch03GdxrcyaIz8X9SsQirh/kCQ7mviyWlMxdh2D7g==", "cpu": [ "arm" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.11.1.tgz", - "integrity": "sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.12.2.tgz", + "integrity": "sha512-zJc0H99FEPoFfSrNpa91HYfxzfAJCr502oxNK1cfdC9hlaFI43RT+JFCann9JUgZmLzzntChHyn13Sgn9ljHNg==", "cpu": [ "arm64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@unrs/resolver-binding-linux-arm64-musl": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.11.1.tgz", - "integrity": "sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.12.2.tgz", + "integrity": "sha512-KQ3Lki6l+Pz1k/eBipN41ES+YUK30beLGb9YqcB1O542cyLCNE6GaxrfcY3T6EezmGGk84wb5XyO9loTM9tkcA==", "cpu": [ "arm64" ], "dev": true, - "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-loong64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-loong64-gnu/-/resolver-binding-linux-loong64-gnu-1.12.2.tgz", + "integrity": "sha512-3SJGEh1DborhG6pyxvhPzCT4bbSIVihsvgJc13P1bHG7KLdNDaF9T3gsTwFc7Jw/5Y5/iWOjkEx7Zy0NvCGX3Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-loong64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-loong64-musl/-/resolver-binding-linux-loong64-musl-1.12.2.tgz", + "integrity": "sha512-jiuG/Obbel7uw1PwHNFfrkiKhLAF6mnyZ6aWlOAVN9WqKm8v0OFGnciJIHu8+CMvXLQ8AD51LPzAoUfT21D5Ew==", + "cpu": [ + "loong64" + ], + "dev": true, "optional": true, "os": [ "linux" ] }, "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.11.1.tgz", - "integrity": "sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.12.2.tgz", + "integrity": "sha512-q7xRvVpmcfeL+LlZg8Pbbo6QaTZwDU5BaGZbwfhkEsXJn3Was8xYfE0RBH266xZt0rM6B7i8xAYIvjthuUIWHg==", "cpu": [ "ppc64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.11.1.tgz", - "integrity": "sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.12.2.tgz", + "integrity": "sha512-0CVdx6lcnT3Q9inOH8tsMIOJ6ImndllMjqJHg8RLVdB7Vq4SfkEXl9mCSsVNuNA4MCYycRicCUxPCabVHJRr6A==", "cpu": [ "riscv64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.11.1.tgz", - "integrity": "sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.12.2.tgz", + "integrity": "sha512-iOwlRo9vnp6R6ohHQS11n0NnfdXx/omhkocmIfaPRpQhKZ+3BDMkkdRVh53qjkFkpPddf+FETA28NwGN7l5l+w==", "cpu": [ "riscv64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.11.1.tgz", - "integrity": "sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.12.2.tgz", + "integrity": "sha512-HYJtLfXq94q8iZNFT1lknx258wlkkWhZeUXJRqzKBBUJ00CvZ+N33zgbCqimLjsyw5Va6uUxhVa12mI+kaveEw==", "cpu": [ "s390x" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@unrs/resolver-binding-linux-x64-gnu": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.11.1.tgz", - "integrity": "sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.12.2.tgz", + "integrity": "sha512-mPsUhunKKDih5O96Y6enDQyHc1SqBPlY1E/SfMWDM3EdJ95Z9CArPeCVwCCqbP45ljvivdEk8Fxn+SIb1rDAJQ==", "cpu": [ "x64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@unrs/resolver-binding-linux-x64-musl": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.11.1.tgz", - "integrity": "sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.12.2.tgz", + "integrity": "sha512-azrt6+5ydLd8Vt210AAFis/lZevSfPw93EJRIJG+xPu4WCJ8K0kppCTpMyLPcKT7H15M4Jnt2tMp5bOvCkRC6A==", "cpu": [ "x64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" ] }, + "node_modules/@unrs/resolver-binding-openharmony-arm64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-openharmony-arm64/-/resolver-binding-openharmony-arm64-1.12.2.tgz", + "integrity": "sha512-YZ9hP4O0X9PQb8eO980qmLNGH4zT3I9+SZTdt0Pr0YyuGQhYKoOZkV02VzrzyOZJ5xIJ3UFIenKkUkGg8GjgWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ] + }, "node_modules/@unrs/resolver-binding-wasm32-wasi": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.11.1.tgz", - "integrity": "sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.12.2.tgz", + "integrity": "sha512-tYFDIkMxSflfEc/h92ZWNsZlHSwgimbNHSO3PL2JWQHfCuC2q316jMyYU9TIWZsFK2bQwyK5VAdYgn8ygPj69A==", "cpu": [ "wasm32" ], "dev": true, - "license": "MIT", "optional": true, "dependencies": { - "@napi-rs/wasm-runtime": "^0.2.11" + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" }, "engines": { "node": ">=14.0.0" } }, + "node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.11.1.tgz", - "integrity": "sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.12.2.tgz", + "integrity": "sha512-qzNyg3xL0VPQmCaUh+N5jSitce6k+uCBfMDesWRnlULOZaqUkaJ0ybdT+UqlAWJoQjuqfIU/0Ptx9bteN4D82g==", "cpu": [ "arm64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "win32" ] }, "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.1.tgz", - "integrity": "sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.12.2.tgz", + "integrity": "sha512-WD9sY00OfpHVGfsnHZoA8jVT+esS/Bg8z8jzxp5BnDCjjwsuKsPQrzswwpFy4J1AUJbXPRfkpcX0mXrzeXW79g==", "cpu": [ "ia32" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "win32" ] }, "node_modules/@unrs/resolver-binding-win32-x64-msvc": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz", - "integrity": "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.12.2.tgz", + "integrity": "sha512-nAB74NfSNKknqQ1RrYj6uz8FcXEomu/MATJZxh/x+BArzN2U3JbOYC0APYzUIGhVY3m5hRxA8VPNdPBoG8txlA==", "cpu": [ "x64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "win32" ] }, + "node_modules/@upsetjs/venn.js": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@upsetjs/venn.js/-/venn.js-2.0.0.tgz", + "integrity": "sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==", + "optionalDependencies": { + "d3-selection": "^3.0.0", + "d3-transition": "^3.0.1" + } + }, "node_modules/acorn": { - "version": "8.15.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", - "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", - "license": "MIT", + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "dev": true, "bin": { "acorn": "bin/acorn" }, @@ -2436,17 +3005,15 @@ "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", "dev": true, - "license": "MIT", "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "dev": true, - "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -2458,35 +3025,11 @@ "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "license": "Python-2.0" - }, "node_modules/aria-query": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", "dev": true, - "license": "Apache-2.0", "engines": { "node": ">= 0.4" } @@ -2536,7 +3079,6 @@ "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==", "dev": true, - "license": "MIT", "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", @@ -2557,7 +3099,6 @@ "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz", "integrity": "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==", "dev": true, - "license": "MIT", "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.4", @@ -2617,7 +3158,6 @@ "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz", "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==", "dev": true, - "license": "MIT", "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", @@ -2655,8 +3195,7 @@ "version": "0.0.8", "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", "integrity": "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/async-function": { "version": "1.0.0", @@ -2685,11 +3224,10 @@ } }, "node_modules/axe-core": { - "version": "4.10.3", - "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.10.3.tgz", - "integrity": "sha512-Xm7bpRXnDSX2YE2YFfBk2FnF0ep6tmG7xPh8iHee8MIcrgq762Nkce856dYtJYLkuIoYZvGfTs/PbZhideTcEg==", + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.12.1.tgz", + "integrity": "sha512-s7iGf5GaVMxEG0ENN9x+xTr7GFZCb1ZP/1uATUpCEK2X78nDB3RwbtFCo9pGAf9ru+VwoQ464DkaLEeRM08wJA==", "dev": true, - "license": "MPL-2.0", "engines": { "node": ">=4" } @@ -2699,7 +3237,6 @@ "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", "dev": true, - "license": "Apache-2.0", "engines": { "node": ">= 0.4" } @@ -2718,18 +3255,39 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.4.tgz", + "integrity": "sha512-s4+sLr9mZ/CyqeRritFeYV/Zx73OAtmaHn6kkBS1XRoJn1hrg3xIDUcpicAEX68tkcIN0iBCgti31C8zxtkhsQ==", "dev": true, - "license": "MIT" + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } }, "node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", + "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", "dev": true, - "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/brace-expansion/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "engines": { + "node": "18 || 20 || >=22" } }, "node_modules/braces": { @@ -2745,27 +3303,48 @@ "node": ">=8" } }, - "node_modules/busboy": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", - "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "node_modules/browserslist": { + "version": "4.28.7", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz", + "integrity": "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], "dependencies": { - "streamsearch": "^1.1.0" + "baseline-browser-mapping": "^2.10.44", + "caniuse-lite": "^1.0.30001806", + "electron-to-chromium": "^1.5.393", + "node-releases": "^2.0.51", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" }, "engines": { - "node": ">=10.16.0" + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, "node_modules/call-bind": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", - "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", "dev": true, - "license": "MIT", "dependencies": { - "call-bind-apply-helpers": "^1.0.0", - "es-define-property": "^1.0.0", - "get-intrinsic": "^1.2.4", + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", "set-function-length": "^1.2.2" }, "engines": { @@ -2817,9 +3396,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001737", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001737.tgz", - "integrity": "sha512-BiloLiXtQNrY5UyF0+1nSJLXUENuhka2pzy2Fx5pGxqavdrxSCW4U6Pn/PoG3Efspi2frRbHpBV2XsrPE6EDlw==", + "version": "1.0.30001806", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz", + "integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==", "funding": [ { "type": "opencollective", @@ -2833,34 +3412,16 @@ "type": "github", "url": "https://github.com/sponsors/ai" } - ], - "license": "CC-BY-4.0" + ] }, "node_modules/ccount": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", - "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" + "type": "github", + "url": "https://github.com/sponsors/wooorm" } }, "node_modules/character-entities": { @@ -2903,32 +3464,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/chevrotain": { - "version": "11.0.3", - "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-11.0.3.tgz", - "integrity": "sha512-ci2iJH6LeIkvP9eJW6gpueU8cnZhv85ELY8w8WiFtNjMHA5ad6pQLaJo9mEly/9qUyCpvqX8/POVUTf18/HFdw==", - "license": "Apache-2.0", - "dependencies": { - "@chevrotain/cst-dts-gen": "11.0.3", - "@chevrotain/gast": "11.0.3", - "@chevrotain/regexp-to-ast": "11.0.3", - "@chevrotain/types": "11.0.3", - "@chevrotain/utils": "11.0.3", - "lodash-es": "4.17.21" - } - }, - "node_modules/chevrotain-allstar": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/chevrotain-allstar/-/chevrotain-allstar-0.3.1.tgz", - "integrity": "sha512-b7g+y9A0v4mxCW1qUhf3BSVPg+/NvGErk/dOkrDaHA0nQIQGAtrOjlX//9OQtRlSCy+x9rfB5N8yC71lH1nvMw==", - "license": "MIT", - "dependencies": { - "lodash-es": "^4.17.21" - }, - "peerDependencies": { - "chevrotain": "^11.0.0" - } - }, "node_modules/chownr": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", @@ -2945,51 +3480,6 @@ "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", "license": "MIT" }, - "node_modules/color": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", - "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", - "license": "MIT", - "optional": true, - "dependencies": { - "color-convert": "^2.0.1", - "color-string": "^1.9.0" - }, - "engines": { - "node": ">=12.5.0" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/color-string": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", - "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", - "license": "MIT", - "optional": true, - "dependencies": { - "color-name": "^1.0.0", - "simple-swizzle": "^0.2.2" - } - }, "node_modules/comma-separated-tokens": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", @@ -3004,7 +3494,6 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", - "license": "MIT", "engines": { "node": ">= 10" } @@ -3013,14 +3502,13 @@ "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, - "license": "MIT" + "dev": true }, - "node_modules/confbox": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.2.tgz", - "integrity": "sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ==", - "license": "MIT" + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true }, "node_modules/cose-base": { "version": "1.0.3", @@ -3050,14 +3538,12 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "dev": true, "license": "MIT" }, "node_modules/cytoscape": { - "version": "3.33.1", - "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.1.tgz", - "integrity": "sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ==", - "license": "MIT", + "version": "3.34.0", + "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.34.0.tgz", + "integrity": "sha512-62rNSrioXw93uliKFBwjukeQyeWwH2PqDrTac31r2P6464u3AUvTk0xS4LVvT251g7IgkFunrI48ZEZGjywSOg==", "engines": { "node": ">=0.10" } @@ -3105,7 +3591,6 @@ "version": "7.9.0", "resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz", "integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==", - "license": "ISC", "dependencies": { "d3-array": "3", "d3-axis": "3", @@ -3146,7 +3631,6 @@ "version": "3.2.4", "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", - "license": "ISC", "dependencies": { "internmap": "1 - 2" }, @@ -3158,7 +3642,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz", "integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==", - "license": "ISC", "engines": { "node": ">=12" } @@ -3167,7 +3650,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz", "integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==", - "license": "ISC", "dependencies": { "d3-dispatch": "1 - 3", "d3-drag": "2 - 3", @@ -3183,7 +3665,6 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz", "integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==", - "license": "ISC", "dependencies": { "d3-path": "1 - 3" }, @@ -3195,7 +3676,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", - "license": "ISC", "engines": { "node": ">=12" } @@ -3204,7 +3684,6 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz", "integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==", - "license": "ISC", "dependencies": { "d3-array": "^3.2.0" }, @@ -3216,7 +3695,6 @@ "version": "6.0.4", "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz", "integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==", - "license": "ISC", "dependencies": { "delaunator": "5" }, @@ -3228,7 +3706,6 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", - "license": "ISC", "engines": { "node": ">=12" } @@ -3237,7 +3714,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", - "license": "ISC", "dependencies": { "d3-dispatch": "1 - 3", "d3-selection": "3" @@ -3250,7 +3726,6 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz", "integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==", - "license": "ISC", "dependencies": { "commander": "7", "iconv-lite": "0.6", @@ -3275,7 +3750,6 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", - "license": "BSD-3-Clause", "engines": { "node": ">=12" } @@ -3284,7 +3758,6 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz", "integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==", - "license": "ISC", "dependencies": { "d3-dsv": "1 - 3" }, @@ -3296,7 +3769,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz", "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==", - "license": "ISC", "dependencies": { "d3-dispatch": "1 - 3", "d3-quadtree": "1 - 3", @@ -3307,10 +3779,9 @@ } }, "node_modules/d3-format": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz", - "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==", - "license": "ISC", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", "engines": { "node": ">=12" } @@ -3319,7 +3790,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz", "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==", - "license": "ISC", "dependencies": { "d3-array": "2.5.0 - 3" }, @@ -3331,7 +3801,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz", "integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==", - "license": "ISC", "engines": { "node": ">=12" } @@ -3340,7 +3809,6 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", - "license": "ISC", "dependencies": { "d3-color": "1 - 3" }, @@ -3352,7 +3820,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", - "license": "ISC", "engines": { "node": ">=12" } @@ -3361,7 +3828,6 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz", "integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==", - "license": "ISC", "engines": { "node": ">=12" } @@ -3370,7 +3836,6 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz", "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==", - "license": "ISC", "engines": { "node": ">=12" } @@ -3379,7 +3844,6 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz", "integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==", - "license": "ISC", "engines": { "node": ">=12" } @@ -3428,7 +3892,6 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", - "license": "ISC", "dependencies": { "d3-array": "2.10.0 - 3", "d3-format": "1 - 3", @@ -3444,7 +3907,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==", - "license": "ISC", "dependencies": { "d3-color": "1 - 3", "d3-interpolate": "1 - 3" @@ -3457,7 +3919,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", - "license": "ISC", "engines": { "node": ">=12" } @@ -3466,7 +3927,6 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", - "license": "ISC", "dependencies": { "d3-path": "^3.1.0" }, @@ -3478,7 +3938,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", - "license": "ISC", "dependencies": { "d3-array": "2 - 3" }, @@ -3490,7 +3949,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", - "license": "ISC", "dependencies": { "d3-time": "1 - 3" }, @@ -3502,7 +3960,6 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", - "license": "ISC", "engines": { "node": ">=12" } @@ -3511,7 +3968,6 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", - "license": "ISC", "dependencies": { "d3-color": "1 - 3", "d3-dispatch": "1 - 3", @@ -3530,7 +3986,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", - "license": "ISC", "dependencies": { "d3-dispatch": "1 - 3", "d3-drag": "2 - 3", @@ -3543,10 +3998,9 @@ } }, "node_modules/dagre-d3-es": { - "version": "7.0.11", - "resolved": "https://registry.npmjs.org/dagre-d3-es/-/dagre-d3-es-7.0.11.tgz", - "integrity": "sha512-tvlJLyQf834SylNKax8Wkzco/1ias1OPw8DcUMDE7oUIoSEW25riQVuiu/0OWEFqT0cxHT3Pa9/D82Jr47IONw==", - "license": "MIT", + "version": "7.0.14", + "resolved": "https://registry.npmjs.org/dagre-d3-es/-/dagre-d3-es-7.0.14.tgz", + "integrity": "sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg==", "dependencies": { "d3": "^7.9.0", "lodash-es": "^4.17.21" @@ -3556,8 +4010,7 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==", - "dev": true, - "license": "BSD-2-Clause" + "dev": true }, "node_modules/data-view-buffer": { "version": "1.0.2", @@ -3614,16 +4067,14 @@ } }, "node_modules/dayjs": { - "version": "1.11.13", - "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz", - "integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==", - "license": "MIT" + "version": "1.11.21", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz", + "integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==" }, "node_modules/debug": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", - "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", - "license": "MIT", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "dependencies": { "ms": "^2.1.3" }, @@ -3636,12 +4087,6 @@ } } }, - "node_modules/decimal.js": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", - "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", - "license": "MIT" - }, "node_modules/decode-named-character-reference": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.2.0.tgz", @@ -3699,10 +4144,9 @@ } }, "node_modules/delaunator": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.0.1.tgz", - "integrity": "sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==", - "license": "ISC", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.1.0.tgz", + "integrity": "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==", "dependencies": { "robust-predicates": "^3.0.2" } @@ -3717,11 +4161,9 @@ } }, "node_modules/detect-libc": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", - "integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==", - "devOptional": true, - "license": "Apache-2.0", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "engines": { "node": ">=8" } @@ -3753,10 +4195,9 @@ } }, "node_modules/dompurify": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.6.tgz", - "integrity": "sha512-/2GogDQlohXPZe6D6NOgQvXLPSYBqIWMnZ8zzOhn09REE4eyAzb+Hed3jhoM9OkuaJ8P6ZGTTVWQKAi8ieIzfQ==", - "license": "(MPL-2.0 OR Apache-2.0)", + "version": "3.4.12", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.12.tgz", + "integrity": "sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==", "optionalDependencies": { "@types/trusted-types": "^2.0.7" } @@ -3776,12 +4217,17 @@ "node": ">= 0.4" } }, + "node_modules/electron-to-chromium": { + "version": "1.5.396", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.396.tgz", + "integrity": "sha512-yHiw2Y3C3H9U6TMbOfoWK/BPreiOPXRfTWPBwQBoZG6/8TB6eOPnsy5oaRYuatR7Fw2SJ4kKforgufeo7fq0EQ==", + "dev": true + }, "node_modules/emoji-regex": { "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/enhanced-resolve": { "version": "5.18.3", @@ -3810,11 +4256,10 @@ } }, "node_modules/es-abstract": { - "version": "1.24.0", - "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.0.tgz", - "integrity": "sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg==", + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz", + "integrity": "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==", "dev": true, - "license": "MIT", "dependencies": { "array-buffer-byte-length": "^1.0.2", "arraybuffer.prototype.slice": "^1.0.4", @@ -3899,28 +4344,27 @@ } }, "node_modules/es-iterator-helpers": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.2.1.tgz", - "integrity": "sha512-uDn+FE1yrDzyC0pCo961B2IHbdM8y/ACZsKD4dG6WqrjV53BADjwa7D+1aom2rsNVfLyDgU/eigvlJGJ08OQ4w==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.4.0.tgz", + "integrity": "sha512-c/A0P0oxkACDc+cKWw8evLXK83oBKgn0qPOqCYT4x9uolpCIJAcYvJC9QYKNDRPsTeGyCrQ326jrvgZWdCdK5Q==", "dev": true, - "license": "MIT", "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", "define-properties": "^1.2.1", - "es-abstract": "^1.23.6", + "es-abstract": "^1.24.2", "es-errors": "^1.3.0", - "es-set-tostringtag": "^2.0.3", + "es-set-tostringtag": "^2.1.0", "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.6", + "get-intrinsic": "^1.3.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "internal-slot": "^1.1.0", - "iterator.prototype": "^1.1.4", - "safe-array-concat": "^1.1.3" + "iterator.prototype": "^1.1.5", + "math-intrinsics": "^1.1.0" }, "engines": { "node": ">= 0.4" @@ -3986,6 +4430,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/es-toolkit": { + "version": "1.50.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.50.0.tgz", + "integrity": "sha512-OyZKhUVvEep9ITEiwHn8GKnMRQIVqoSIX7WnRbkWgJkllCujilqP2rD0u979tkl8wqyc8ICwlc1UBVv/Sl1G6w==" + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -4000,34 +4458,29 @@ } }, "node_modules/eslint": { - "version": "9.34.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.34.0.tgz", - "integrity": "sha512-RNCHRX5EwdrESy3Jc9o8ie8Bog+PeYvvSR8sDGoZxNFTvZ4dlxUB3WzQ3bQMztFrSRODGrLLj8g6OFuGY/aiQg==", + "version": "10.8.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.8.0.tgz", + "integrity": "sha512-nuKKvN+oIBO0koN7Tm7dlkmnkc21mtt0QJLwAKzjLq14y6lRTdVG36MZHJ8eQHwdJMwZbQNMlPOYedMq/oVJvQ==", "dev": true, - "license": "MIT", "dependencies": { - "@eslint-community/eslint-utils": "^4.2.0", - "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.21.0", - "@eslint/config-helpers": "^0.3.1", - "@eslint/core": "^0.15.2", - "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.34.0", - "@eslint/plugin-kit": "^0.3.5", + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.7.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.2", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", - "@types/json-schema": "^7.0.15", - "ajv": "^6.12.4", - "chalk": "^4.0.0", + "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^8.4.0", - "eslint-visitor-keys": "^4.2.1", - "espree": "^10.4.0", - "esquery": "^1.5.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", @@ -4037,8 +4490,7 @@ "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.1.2", + "minimatch": "^10.2.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, @@ -4046,7 +4498,7 @@ "eslint": "bin/eslint.js" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://eslint.org/donate" @@ -4061,25 +4513,23 @@ } }, "node_modules/eslint-config-next": { - "version": "15.3.1", - "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-15.3.1.tgz", - "integrity": "sha512-GnmyVd9TE/Ihe3RrvcafFhXErErtr2jS0JDeCSp3vWvy86AXwHsRBt0E3MqP/m8ACS1ivcsi5uaqjbhsG18qKw==", + "version": "16.2.12", + "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.2.12.tgz", + "integrity": "sha512-iaaf4vvKo5h2LBdGt0JuRv7t0Ysqr9FMCiFxbptDg8LqOE//mIKR80DdpOnSVM7qjLH3jT8P0aFiwXxBEGZRXw==", "dev": true, - "license": "MIT", "dependencies": { - "@next/eslint-plugin-next": "15.3.1", - "@rushstack/eslint-patch": "^1.10.3", - "@typescript-eslint/eslint-plugin": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", - "@typescript-eslint/parser": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", + "@next/eslint-plugin-next": "16.2.12", "eslint-import-resolver-node": "^0.3.6", "eslint-import-resolver-typescript": "^3.5.2", - "eslint-plugin-import": "^2.31.0", + "eslint-plugin-import": "^2.32.0", "eslint-plugin-jsx-a11y": "^6.10.0", "eslint-plugin-react": "^7.37.0", - "eslint-plugin-react-hooks": "^5.0.0" + "eslint-plugin-react-hooks": "^7.0.0", + "globals": "16.4.0", + "typescript-eslint": "^8.46.0" }, "peerDependencies": { - "eslint": "^7.23.0 || ^8.0.0 || ^9.0.0", + "eslint": ">=9.0.0", "typescript": ">=3.3.1" }, "peerDependenciesMeta": { @@ -4115,7 +4565,6 @@ "resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-3.10.1.tgz", "integrity": "sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ==", "dev": true, - "license": "ISC", "dependencies": { "@nolyfill/is-core-module": "1.0.39", "debug": "^4.4.0", @@ -4146,11 +4595,10 @@ } }, "node_modules/eslint-module-utils": { - "version": "2.12.1", - "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz", - "integrity": "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==", + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.14.0.tgz", + "integrity": "sha512-W2WCRZ9Dqntd+2u8jJcVMV2PKulc6RdLgUUoh/yQr3uB6lo/ZOeGx11sv60/8S4QFFKNslAlWhr9u0Ef7ZW6Ig==", "dev": true, - "license": "MIT", "dependencies": { "debug": "^3.2.7" }, @@ -4168,7 +4616,6 @@ "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", "dev": true, - "license": "MIT", "dependencies": { "ms": "^2.1.1" } @@ -4178,7 +4625,6 @@ "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, - "license": "MIT", "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -4207,22 +4653,42 @@ "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" } }, + "node_modules/eslint-plugin-import/node_modules/brace-expansion": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, "node_modules/eslint-plugin-import/node_modules/debug": { "version": "3.2.7", "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", "dev": true, - "license": "MIT", "dependencies": { "ms": "^2.1.1" } }, + "node_modules/eslint-plugin-import/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/eslint-plugin-import/node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true, - "license": "ISC", "bin": { "semver": "bin/semver.js" } @@ -4232,7 +4698,6 @@ "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.10.2.tgz", "integrity": "sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==", "dev": true, - "license": "MIT", "dependencies": { "aria-query": "^5.3.2", "array-includes": "^3.1.8", @@ -4257,12 +4722,33 @@ "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9" } }, + "node_modules/eslint-plugin-jsx-a11y/node_modules/brace-expansion": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint-plugin-jsx-a11y/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/eslint-plugin-react": { "version": "7.37.5", "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==", "dev": true, - "license": "MIT", "dependencies": { "array-includes": "^3.1.8", "array.prototype.findlast": "^1.2.5", @@ -4291,32 +4777,65 @@ } }, "node_modules/eslint-plugin-react-hooks": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz", - "integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", + "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==", "dev": true, - "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, "engines": { - "node": ">=10" + "node": ">=18" }, "peerDependencies": { - "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/eslint-plugin-react/node_modules/brace-expansion": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint-plugin-react/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" } }, "node_modules/eslint-plugin-react/node_modules/resolve": { - "version": "2.0.0-next.5", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz", - "integrity": "sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==", + "version": "2.0.0-next.7", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz", + "integrity": "sha512-tqt+NBWwyaMgw3zDsnygx4CByWjQEJHOPMdslYhppaQSJUtL/D4JO9CcBBlhPoI8lz9oJIDXkwXfhF4aWqP8xQ==", "dev": true, - "license": "MIT", "dependencies": { - "is-core-module": "^2.13.0", + "es-errors": "^1.3.0", + "is-core-module": "^2.16.2", + "node-exports-info": "^1.6.0", + "object-keys": "^1.1.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" }, + "engines": { + "node": ">= 0.4" + }, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -4326,65 +4845,62 @@ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true, - "license": "ISC", "bin": { "semver": "bin/semver.js" } }, "node_modules/eslint-scope": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", - "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", "dev": true, - "license": "BSD-2-Clause", "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" } }, "node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", "dev": true, - "license": "Apache-2.0", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" } }, "node_modules/espree": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", - "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", "dev": true, - "license": "BSD-2-Clause", "dependencies": { - "acorn": "^8.15.0", + "acorn": "^8.16.0", "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.2.1" + "eslint-visitor-keys": "^5.0.1" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" } }, "node_modules/esquery": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", - "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", "dev": true, - "license": "BSD-3-Clause", "dependencies": { "estraverse": "^5.1.0" }, @@ -4397,7 +4913,6 @@ "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", "dev": true, - "license": "BSD-2-Clause", "dependencies": { "estraverse": "^5.2.0" }, @@ -4435,12 +4950,6 @@ "node": ">=0.10.0" } }, - "node_modules/exsolve": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.7.tgz", - "integrity": "sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw==", - "license": "MIT" - }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -4459,7 +4968,6 @@ "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz", "integrity": "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==", "dev": true, - "license": "MIT", "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", @@ -4476,7 +4984,6 @@ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", "dev": true, - "license": "ISC", "dependencies": { "is-glob": "^4.0.1" }, @@ -4579,11 +5086,10 @@ } }, "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", - "dev": true, - "license": "ISC" + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.3.tgz", + "integrity": "sha512-/zipXxyO6rGvuNGDiULY9MvEGSkb2gaG4GGH4ygMi0ZZzyMHdUZBmntJmx5x1G2VuPytCwGN4xsJP6cw+sK+vQ==", + "dev": true }, "node_modules/for-each": { "version": "0.3.5", @@ -4650,6 +5156,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -4708,11 +5223,10 @@ } }, "node_modules/get-tsconfig": { - "version": "4.10.1", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.10.1.tgz", - "integrity": "sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ==", + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", + "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", "dev": true, - "license": "MIT", "dependencies": { "resolve-pkg-maps": "^1.0.0" }, @@ -4734,11 +5248,10 @@ } }, "node_modules/globals": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", - "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "version": "16.4.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.4.0.tgz", + "integrity": "sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw==", "dev": true, - "license": "MIT", "engines": { "node": ">=18" }, @@ -4783,13 +5296,6 @@ "dev": true, "license": "ISC" }, - "node_modules/graphemer": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", - "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", - "dev": true, - "license": "MIT" - }, "node_modules/hachure-fill": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/hachure-fill/-/hachure-fill-0.5.2.tgz", @@ -4809,16 +5315,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/has-property-descriptors": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", @@ -4878,57 +5374,26 @@ } }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "dev": true, - "license": "MIT", "dependencies": { "function-bind": "^1.1.2" }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/hast-util-from-dom": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/hast-util-from-dom/-/hast-util-from-dom-5.0.1.tgz", - "integrity": "sha512-N+LqofjR2zuzTjCPzyDUdSshy4Ma6li7p/c3pA78uTwzFgENbgbUrm2ugwsOdcjI1muO+o6Dgzp9p8WHtn/39Q==", - "license": "ISC", - "dependencies": { - "@types/hast": "^3.0.0", - "hastscript": "^9.0.0", - "web-namespaces": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hast-util-from-dom/node_modules/hast-util-parse-selector": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz", - "integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" + "engines": { + "node": ">= 0.4" } }, - "node_modules/hast-util-from-dom/node_modules/hastscript": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-9.0.1.tgz", - "integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==", - "license": "MIT", + "node_modules/hast-util-from-dom": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/hast-util-from-dom/-/hast-util-from-dom-5.0.1.tgz", + "integrity": "sha512-N+LqofjR2zuzTjCPzyDUdSshy4Ma6li7p/c3pA78uTwzFgENbgbUrm2ugwsOdcjI1muO+o6Dgzp9p8WHtn/39Q==", + "license": "ISC", "dependencies": { "@types/hast": "^3.0.0", - "comma-separated-tokens": "^2.0.0", - "hast-util-parse-selector": "^4.0.0", - "property-information": "^7.0.0", - "space-separated-tokens": "^2.0.0" + "hastscript": "^9.0.0", + "web-namespaces": "^2.0.0" }, "funding": { "type": "opencollective", @@ -4989,36 +5454,6 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/hast-util-from-parse5/node_modules/hast-util-parse-selector": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz", - "integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hast-util-from-parse5/node_modules/hastscript": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-9.0.1.tgz", - "integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "comma-separated-tokens": "^2.0.0", - "hast-util-parse-selector": "^4.0.0", - "property-information": "^7.0.0", - "space-separated-tokens": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, "node_modules/hast-util-is-element": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/hast-util-is-element/-/hast-util-is-element-3.0.0.tgz", @@ -5033,10 +5468,12 @@ } }, "node_modules/hast-util-parse-selector": { - "version": "2.2.5", - "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-2.2.5.tgz", - "integrity": "sha512-7j6mrk/qqkSehsM92wQjdIgWM2/BW61u/53G6xmC8i1OmEdKLHbk419QKQUjz6LglWsfqoiHmyMRkP1BGjecNQ==", - "license": "MIT", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz", + "integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==", + "dependencies": { + "@types/hast": "^3.0.0" + }, "funding": { "type": "opencollective", "url": "https://opencollective.com/unified" @@ -5153,68 +5590,34 @@ } }, "node_modules/hastscript": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-6.0.0.tgz", - "integrity": "sha512-nDM6bvd7lIqDUiYEiu5Sl/+6ReP0BMk/2f4U/Rooccxkj0P5nm+acM5PrGJ/t5I8qPGiqZSE6hVAwZEdZIvP4w==", - "license": "MIT", + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-9.0.1.tgz", + "integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==", "dependencies": { - "@types/hast": "^2.0.0", - "comma-separated-tokens": "^1.0.0", - "hast-util-parse-selector": "^2.0.0", - "property-information": "^5.0.0", - "space-separated-tokens": "^1.0.0" + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-parse-selector": "^4.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/unified" } }, - "node_modules/hastscript/node_modules/@types/hast": { - "version": "2.3.10", - "resolved": "https://registry.npmjs.org/@types/hast/-/hast-2.3.10.tgz", - "integrity": "sha512-McWspRw8xx8J9HurkVBfYj0xKoE25tOFlHGdx4MJ5xORQrMGZNqJhVQWaIbm6Oyla5kYOXtDiopzKRJzEOkwJw==", - "license": "MIT", - "dependencies": { - "@types/unist": "^2" - } - }, - "node_modules/hastscript/node_modules/@types/unist": { - "version": "2.0.11", - "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", - "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", - "license": "MIT" - }, - "node_modules/hastscript/node_modules/comma-separated-tokens": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-1.0.8.tgz", - "integrity": "sha512-GHuDRO12Sypu2cV70d1dkA2EUmXHgntrzbpvOB+Qy+49ypNfGgFQIC2fhhXbnyrJRynDCAARsT7Ou0M6hirpfw==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true }, - "node_modules/hastscript/node_modules/property-information": { - "version": "5.6.0", - "resolved": "https://registry.npmjs.org/property-information/-/property-information-5.6.0.tgz", - "integrity": "sha512-YUHSPk+A30YPv+0Qf8i9Mbfe/C0hdPXk1s1jPVToV8pk8BQtpw10ct89Eo7OWkutrwqvT0eicAxlOg3dOAu8JA==", - "license": "MIT", + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, "dependencies": { - "xtend": "^4.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/hastscript/node_modules/space-separated-tokens": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-1.1.5.tgz", - "integrity": "sha512-q/JSVd1Lptzhf5bkYm4ob4iWPjx0KiRe3sRFBNrVqbJkFaBm5vbbowy1mymoPNLRa52+oadOhJ+K49wsSeSjTA==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" + "hermes-estree": "0.25.1" } }, "node_modules/highlight.js": { @@ -5256,7 +5659,6 @@ "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" }, @@ -5264,6 +5666,20 @@ "node": ">=0.10.0" } }, + "node_modules/icu-minify": { + "version": "4.13.4", + "resolved": "https://registry.npmjs.org/icu-minify/-/icu-minify-4.13.4.tgz", + "integrity": "sha512-yK6HyPLGlQjqm8fTKtnBpM77z7vl7JdDBN2EXLvmgAu/b7XaOHWZb73M3ISl9ahBTehBv7RYeqqWSHfk1v2YcA==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/amannn" + } + ], + "dependencies": { + "@formatjs/icu-messageformat-parser": "^3.4.0" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -5291,6 +5707,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/import-meta-resolve": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", + "integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", @@ -5326,21 +5751,17 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", - "license": "ISC", "engines": { "node": ">=12" } }, "node_modules/intl-messageformat": { - "version": "10.7.16", - "resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-10.7.16.tgz", - "integrity": "sha512-UmdmHUmp5CIKKjSoE10la5yfU+AYJAaiYLsodbjL4lji83JNvgOQUjGaGhGrpFCb0Uh7sl7qfP1IyILa8Z40ug==", - "license": "BSD-3-Clause", + "version": "11.2.12", + "resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-11.2.12.tgz", + "integrity": "sha512-KW70Xxfcvy7vV3qODfvShWkFDPMqKDAa4N+hSyVBWGNtVhTUFYaqlD/l88DaYPKiVcPP4rPQ3qnH7i5K82Mg7g==", "dependencies": { - "@formatjs/ecma402-abstract": "2.3.4", - "@formatjs/fast-memoize": "2.2.7", - "@formatjs/icu-messageformat-parser": "2.11.2", - "tslib": "^2.8.0" + "@formatjs/fast-memoize": "3.1.7", + "@formatjs/icu-messageformat-parser": "3.5.15" } }, "node_modules/is-alphabetical": { @@ -5385,13 +5806,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-arrayish": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz", - "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==", - "license": "MIT", - "optional": true - }, "node_modules/is-async-function": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", @@ -5450,7 +5864,6 @@ "resolved": "https://registry.npmjs.org/is-bun-module/-/is-bun-module-2.0.0.tgz", "integrity": "sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==", "dev": true, - "license": "MIT", "dependencies": { "semver": "^7.7.1" } @@ -5469,13 +5882,12 @@ } }, "node_modules/is-core-module": { - "version": "2.16.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", - "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", "dev": true, - "license": "MIT", "dependencies": { - "hasown": "^2.0.2" + "hasown": "^2.0.3" }, "engines": { "node": ">= 0.4" @@ -5533,7 +5945,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -5578,7 +5989,6 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, "license": "MIT", "dependencies": { "is-extglob": "^2.1.1" @@ -5826,7 +6236,6 @@ "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", "integrity": "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==", "dev": true, - "license": "MIT", "dependencies": { "define-data-property": "^1.1.4", "es-object-atoms": "^1.0.0", @@ -5853,20 +6262,18 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, - "license": "MIT" + "dev": true }, - "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", "dev": true, - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, "bin": { - "js-yaml": "bin/js-yaml.js" + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" } }, "node_modules/json-buffer": { @@ -5891,16 +6298,15 @@ "license": "MIT" }, "node_modules/json5": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", - "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", "dev": true, - "license": "MIT", - "dependencies": { - "minimist": "^1.2.0" - }, "bin": { "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" } }, "node_modules/jsx-ast-utils": { @@ -5920,14 +6326,13 @@ } }, "node_modules/katex": { - "version": "0.16.22", - "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.22.tgz", - "integrity": "sha512-XCHRdUw4lf3SKBaJe4EvgqIuWwkPSo9XoeO8GjQW94Bp7TWv9hNhzZjZ+OH9yf1UmLygb7DIT5GSFQiyt16zYg==", + "version": "0.16.47", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz", + "integrity": "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==", "funding": [ "https://opencollective.com/katex", "https://github.com/sponsors/katex" ], - "license": "MIT", "dependencies": { "commander": "^8.3.0" }, @@ -5959,41 +6364,17 @@ "resolved": "https://registry.npmjs.org/khroma/-/khroma-2.1.0.tgz", "integrity": "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==" }, - "node_modules/kolorist": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/kolorist/-/kolorist-1.8.0.tgz", - "integrity": "sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==", - "license": "MIT" - }, - "node_modules/langium": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/langium/-/langium-3.3.1.tgz", - "integrity": "sha512-QJv/h939gDpvT+9SiLVlY7tZC3xB2qK57v0J04Sh9wpMb6MP1q8gB21L3WIo8T5P1MSMg3Ep14L7KkDCFG3y4w==", - "license": "MIT", - "dependencies": { - "chevrotain": "~11.0.3", - "chevrotain-allstar": "~0.3.0", - "vscode-languageserver": "~9.0.1", - "vscode-languageserver-textdocument": "~1.0.11", - "vscode-uri": "~3.0.8" - }, - "engines": { - "node": ">=16.0.0" - } - }, "node_modules/language-subtag-registry": { "version": "0.3.23", "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz", "integrity": "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==", - "dev": true, - "license": "CC0-1.0" + "dev": true }, "node_modules/language-tags": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/language-tags/-/language-tags-1.0.9.tgz", "integrity": "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==", "dev": true, - "license": "MIT", "dependencies": { "language-subtag-registry": "^0.3.20" }, @@ -6260,23 +6641,6 @@ "url": "https://opencollective.com/parcel" } }, - "node_modules/local-pkg": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.1.2.tgz", - "integrity": "sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==", - "license": "MIT", - "dependencies": { - "mlly": "^1.7.4", - "pkg-types": "^2.3.0", - "quansync": "^0.2.11" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -6294,17 +6658,9 @@ } }, "node_modules/lodash-es": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", - "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==", - "license": "MIT" - }, - "node_modules/lodash.merge": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "dev": true, - "license": "MIT" + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==" }, "node_modules/longest-streak": { "version": "3.1.0", @@ -6321,7 +6677,6 @@ "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", "dev": true, - "license": "MIT", "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, @@ -6343,6 +6698,21 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lru-cache/node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true + }, "node_modules/magic-string": { "version": "0.30.18", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.18.tgz", @@ -6364,10 +6734,9 @@ } }, "node_modules/marked": { - "version": "16.2.0", - "resolved": "https://registry.npmjs.org/marked/-/marked-16.2.0.tgz", - "integrity": "sha512-LbbTuye+0dWRz2TS9KJ7wsnD4KAtpj0MVkWc90XvBa6AslXsT0hTBVH5k32pcSyHH1fst9XEFJunXHktVy0zlg==", - "license": "MIT", + "version": "16.4.2", + "resolved": "https://registry.npmjs.org/marked/-/marked-16.4.2.tgz", + "integrity": "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==", "bin": { "marked": "bin/marked.js" }, @@ -6632,10 +7001,9 @@ } }, "node_modules/mdast-util-to-hast": { - "version": "13.2.0", - "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.0.tgz", - "integrity": "sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA==", - "license": "MIT", + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", @@ -6697,31 +7065,31 @@ } }, "node_modules/mermaid": { - "version": "11.10.1", - "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.10.1.tgz", - "integrity": "sha512-0PdeADVWURz7VMAX0+MiMcgfxFKY4aweSGsjgFihe3XlMKNqmai/cugMrqTd3WNHM93V+K+AZL6Wu6tB5HmxRw==", - "license": "MIT", + "version": "11.16.0", + "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.16.0.tgz", + "integrity": "sha512-Zvm3kbstgdpvIJPPItlL7fppIZ3kibvc1oZIGxdvk9t6UFz6flv+Jw7FtRGKwfcI8OckmH04LqG6LlS6X4B1pA==", "dependencies": { - "@braintree/sanitize-url": "^7.0.4", - "@iconify/utils": "^2.1.33", - "@mermaid-js/parser": "^0.6.2", + "@braintree/sanitize-url": "^7.1.2", + "@iconify/utils": "^3.0.2", + "@mermaid-js/parser": "^1.2.0", "@types/d3": "^7.4.3", - "cytoscape": "^3.29.3", + "@upsetjs/venn.js": "^2.0.0", + "cytoscape": "^3.33.3", "cytoscape-cose-bilkent": "^4.1.0", "cytoscape-fcose": "^2.2.0", "d3": "^7.9.0", "d3-sankey": "^0.12.3", - "dagre-d3-es": "7.0.11", - "dayjs": "^1.11.13", - "dompurify": "^3.2.5", - "katex": "^0.16.22", + "dagre-d3-es": "7.0.14", + "dayjs": "^1.11.20", + "dompurify": "^3.3.3", + "es-toolkit": "^1.45.1", + "katex": "^0.16.45", "khroma": "^2.1.0", - "lodash-es": "^4.17.21", - "marked": "^16.0.0", + "marked": "^16.3.0", "roughjs": "^4.6.6", "stylis": "^4.3.6", "ts-dedent": "^2.2.0", - "uuid": "^11.1.0" + "uuid": "^11.1.0 || ^12 || ^13 || ^14.0.0" } }, "node_modules/micromark": { @@ -7321,16 +7689,18 @@ } }, "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", "dev": true, - "license": "ISC", "dependencies": { - "brace-expansion": "^1.1.7" + "brace-expansion": "^5.0.5" }, "engines": { - "node": "*" + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/minimist": { @@ -7338,7 +7708,6 @@ "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", "dev": true, - "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -7354,11 +7723,10 @@ } }, "node_modules/minizlib": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.0.2.tgz", - "integrity": "sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", "dev": true, - "license": "MIT", "dependencies": { "minipass": "^7.1.2" }, @@ -7366,51 +7734,6 @@ "node": ">= 18" } }, - "node_modules/mkdirp": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz", - "integrity": "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==", - "dev": true, - "license": "MIT", - "bin": { - "mkdirp": "dist/cjs/src/bin.js" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/mlly": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.0.tgz", - "integrity": "sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==", - "license": "MIT", - "dependencies": { - "acorn": "^8.15.0", - "pathe": "^2.0.3", - "pkg-types": "^1.3.1", - "ufo": "^1.6.1" - } - }, - "node_modules/mlly/node_modules/confbox": { - "version": "0.1.8", - "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", - "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", - "license": "MIT" - }, - "node_modules/mlly/node_modules/pkg-types": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", - "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", - "license": "MIT", - "dependencies": { - "confbox": "^0.1.8", - "mlly": "^1.7.4", - "pathe": "^2.0.1" - } - }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -7418,16 +7741,15 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", "funding": [ { "type": "github", "url": "https://github.com/sponsors/ai" } ], - "license": "MIT", "bin": { "nanoid": "bin/nanoid.cjs" }, @@ -7436,11 +7758,10 @@ } }, "node_modules/napi-postinstall": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.3.tgz", - "integrity": "sha512-uTp172LLXSxuSYHv/kou+f6KW3SMppU9ivthaVTXian9sOt3XM/zHYHpRZiLgQoxeWfYUnslNWQHF1+G71xcow==", + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", + "integrity": "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==", "dev": true, - "license": "MIT", "bin": { "napi-postinstall": "lib/cli.js" }, @@ -7468,15 +7789,12 @@ } }, "node_modules/next": { - "version": "15.3.1", - "resolved": "https://registry.npmjs.org/next/-/next-15.3.1.tgz", - "integrity": "sha512-8+dDV0xNLOgHlyBxP1GwHGVaNXsmp+2NhZEYrXr24GWLHtt27YrBPbPuHvzlhi7kZNYjeJNR93IF5zfFu5UL0g==", - "license": "MIT", + "version": "15.5.22", + "resolved": "https://registry.npmjs.org/next/-/next-15.5.22.tgz", + "integrity": "sha512-mrtal1sRxO4YrlDS98sDuIvGZivKbFix8w7oAL9ZynfOgc3cADQOQgvwtMooc18Qr8bKzvQAcHwHZ0mbJ7zcfQ==", "dependencies": { - "@next/env": "15.3.1", - "@swc/counter": "0.1.3", + "@next/env": "15.5.22", "@swc/helpers": "0.5.15", - "busboy": "1.6.0", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" @@ -7488,19 +7806,19 @@ "node": "^18.18.0 || ^19.8.0 || >= 20.0.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "15.3.1", - "@next/swc-darwin-x64": "15.3.1", - "@next/swc-linux-arm64-gnu": "15.3.1", - "@next/swc-linux-arm64-musl": "15.3.1", - "@next/swc-linux-x64-gnu": "15.3.1", - "@next/swc-linux-x64-musl": "15.3.1", - "@next/swc-win32-arm64-msvc": "15.3.1", - "@next/swc-win32-x64-msvc": "15.3.1", - "sharp": "^0.34.1" + "@next/swc-darwin-arm64": "15.5.22", + "@next/swc-darwin-x64": "15.5.22", + "@next/swc-linux-arm64-gnu": "15.5.22", + "@next/swc-linux-arm64-musl": "15.5.22", + "@next/swc-linux-x64-gnu": "15.5.22", + "@next/swc-linux-x64-musl": "15.5.22", + "@next/swc-win32-arm64-msvc": "15.5.22", + "@next/swc-win32-x64-msvc": "15.5.22", + "sharp": "^0.34.3" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", - "@playwright/test": "^1.41.2", + "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", @@ -7522,25 +7840,28 @@ } }, "node_modules/next-intl": { - "version": "4.3.5", - "resolved": "https://registry.npmjs.org/next-intl/-/next-intl-4.3.5.tgz", - "integrity": "sha512-tT3SltfpPOCAQ9kVNr+8t6FUtVf8G0WFlJcVc8zj4WCMfuF8XFk4gZCN/MtjgDgkUISw5aKamOClJB4EsV95WQ==", + "version": "4.13.4", + "resolved": "https://registry.npmjs.org/next-intl/-/next-intl-4.13.4.tgz", + "integrity": "sha512-jhPAT0u0lahIK6E4gVdZAehugWCosBhLG8sV7xMzgSVoJpxHObP+Fiu+z2FfkEW0XPPtr7uEXoUlLEfhxhNMTg==", "funding": [ { "type": "individual", "url": "https://github.com/sponsors/amannn" } ], - "license": "MIT", "dependencies": { - "@formatjs/intl-localematcher": "^0.5.4", + "@formatjs/intl-localematcher": "^0.8.1", + "@parcel/watcher": "^2.4.1", + "@swc/core": "^1.15.2", + "icu-minify": "^4.13.4", "negotiator": "^1.0.0", - "use-intl": "^4.3.5" + "next-intl-swc-plugin-extractor": "^4.13.4", + "po-parser": "^2.1.1", + "use-intl": "^4.13.4" }, "peerDependencies": { - "next": "^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0", - "typescript": "^5.0.0" + "next": "^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0 || ^16.0.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0" }, "peerDependenciesMeta": { "typescript": { @@ -7548,6 +7869,60 @@ } } }, + "node_modules/next-intl-swc-plugin-extractor": { + "version": "4.13.4", + "resolved": "https://registry.npmjs.org/next-intl-swc-plugin-extractor/-/next-intl-swc-plugin-extractor-4.13.4.tgz", + "integrity": "sha512-uN1+NMUYbG6YkO3q+rjc2bvAPX9nQ23owemvHJAyW0pRbQjVDwvNhmrV5qaak0oQc/9okbK17KLT49AoMGhVEQ==" + }, + "node_modules/next-intl/node_modules/@swc/core": { + "version": "1.15.46", + "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.46.tgz", + "integrity": "sha512-Ri3em2mBpq3h2zSPliCYl63otDGqek8PPEfv2nWgRQEbZ/VBCNyypVTVQ6cEbTCXBhy+WE2T3fQb08moIyuYaw==", + "hasInstallScript": true, + "dependencies": { + "@swc/counter": "^0.1.3", + "@swc/types": "^0.1.27" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/swc" + }, + "optionalDependencies": { + "@swc/core-darwin-arm64": "1.15.46", + "@swc/core-darwin-x64": "1.15.46", + "@swc/core-linux-arm-gnueabihf": "1.15.46", + "@swc/core-linux-arm64-gnu": "1.15.46", + "@swc/core-linux-arm64-musl": "1.15.46", + "@swc/core-linux-ppc64-gnu": "1.15.46", + "@swc/core-linux-s390x-gnu": "1.15.46", + "@swc/core-linux-x64-gnu": "1.15.46", + "@swc/core-linux-x64-musl": "1.15.46", + "@swc/core-win32-arm64-msvc": "1.15.46", + "@swc/core-win32-ia32-msvc": "1.15.46", + "@swc/core-win32-x64-msvc": "1.15.46" + }, + "peerDependencies": { + "@swc/helpers": ">=0.5.17" + }, + "peerDependenciesMeta": { + "@swc/helpers": { + "optional": true + } + } + }, + "node_modules/next-intl/node_modules/@swc/helpers": { + "version": "0.5.23", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", + "integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.8.0" + } + }, "node_modules/next-themes": { "version": "0.4.6", "resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz", @@ -7583,7 +7958,48 @@ "source-map-js": "^1.0.2" }, "engines": { - "node": "^10 || ^12 || >=14" + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==" + }, + "node_modules/node-exports-info": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.2.tgz", + "integrity": "sha512-kXs9Go0cah0qHVV2v389IXQLdLCeE1xfFtjOAF+iobu0OIoG1pje8At2vMHyaPMiPMnG/LWP50twML21eMcAag==", + "dev": true, + "dependencies": { + "array.prototype.flatmap": "^1.3.3", + "es-errors": "^1.3.0", + "object.entries": "^1.1.9", + "semver": "^6.3.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/node-exports-info/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/node-releases": { + "version": "2.0.51", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz", + "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==", + "dev": true, + "engines": { + "node": ">=18" } }, "node_modules/object-assign": { @@ -7591,7 +8007,6 @@ "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", "dev": true, - "license": "MIT", "engines": { "node": ">=0.10.0" } @@ -7645,7 +8060,6 @@ "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz", "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", "dev": true, - "license": "MIT", "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.4", @@ -7680,7 +8094,6 @@ "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.3.tgz", "integrity": "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==", "dev": true, - "license": "MIT", "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", @@ -7778,10 +8191,9 @@ } }, "node_modules/package-manager-detector": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.3.0.tgz", - "integrity": "sha512-ZsEbbZORsyHuO00lY1kV3/t72yp6Ysay6Pd17ZAlNGuGwmWDLCJxFpRs0IzfXfj1o4icJOkUEioexFHzyPurSQ==", - "license": "MIT" + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.8.0.tgz", + "integrity": "sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A==" }, "node_modules/parent-module": { "version": "1.0.1", @@ -7866,12 +8278,6 @@ "dev": true, "license": "MIT" }, - "node_modules/pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "license": "MIT" - }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -7879,11 +8285,10 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, - "license": "MIT", "engines": { "node": ">=8.6" }, @@ -7891,16 +8296,10 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/pkg-types": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz", - "integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==", - "license": "MIT", - "dependencies": { - "confbox": "^0.2.2", - "exsolve": "^1.0.7", - "pathe": "^2.0.3" - } + "node_modules/po-parser": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/po-parser/-/po-parser-2.1.1.tgz", + "integrity": "sha512-ECF4zHLbUItpUgE3OTtLKlPjeBN+fKEczj2zYjDfCGOzicNs0GK3Vg2IoAYwx7LH/XYw43fZQP6xnZ4TkNxSLQ==" }, "node_modules/points-on-curve": { "version": "0.2.0", @@ -7929,9 +8328,9 @@ } }, "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "version": "8.5.23", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", + "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==", "dev": true, "funding": [ { @@ -7947,9 +8346,8 @@ "url": "https://github.com/sponsors/ai" } ], - "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -7981,7 +8379,6 @@ "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", "dev": true, - "license": "MIT", "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", @@ -8008,22 +8405,6 @@ "node": ">=6" } }, - "node_modules/quansync": { - "version": "0.2.11", - "resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz", - "integrity": "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/antfu" - }, - { - "type": "individual", - "url": "https://github.com/sponsors/sxzz" - } - ], - "license": "MIT" - }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -8079,8 +8460,7 @@ "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/react-markdown": { "version": "10.1.0", @@ -8110,17 +8490,19 @@ } }, "node_modules/react-syntax-highlighter": { - "version": "15.6.3", - "resolved": "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-15.6.3.tgz", - "integrity": "sha512-HebdyA9r20hgmA0q8RyRJ4c/vB4E6KL2HeWb5MNjU3iJEiT2w9jfU2RJsmI6f3Cy3SGE5tm0AIkBzM/E7e9/lQ==", - "license": "MIT", + "version": "16.1.1", + "resolved": "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-16.1.1.tgz", + "integrity": "sha512-PjVawBGy80C6YbC5DDZJeUjBmC7skaoEUdvfFQediQHgCL7aKyVHe57SaJGfQsloGDac+gCpTfRdtxzWWKmCXA==", "dependencies": { - "@babel/runtime": "^7.3.1", + "@babel/runtime": "^7.28.4", "highlight.js": "^10.4.1", "highlightjs-vue": "^1.0.0", "lowlight": "^1.17.0", "prismjs": "^1.30.0", - "refractor": "^3.6.0" + "refractor": "^5.0.0" + }, + "engines": { + "node": ">= 16.20.2" }, "peerDependencies": { "react": ">= 0.14.0" @@ -8150,121 +8532,20 @@ } }, "node_modules/refractor": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/refractor/-/refractor-3.6.0.tgz", - "integrity": "sha512-MY9W41IOWxxk31o+YvFCNyNzdkc9M20NoZK5vq6jkv4I/uh2zkWcfudj0Q1fovjUQJrNewS9NMzeTtqPf+n5EA==", - "license": "MIT", - "dependencies": { - "hastscript": "^6.0.0", - "parse-entities": "^2.0.0", - "prismjs": "~1.27.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/refractor/node_modules/character-entities": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-1.2.4.tgz", - "integrity": "sha512-iBMyeEHxfVnIakwOuDXpVkc54HijNgCyQB2w0VfGQThle6NXn50zU6V/u+LDhxHcDUPojn6Kpga3PTAD8W1bQw==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/refractor/node_modules/character-entities-legacy": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-1.1.4.tgz", - "integrity": "sha512-3Xnr+7ZFS1uxeiUDvV02wQ+QDbc55o97tIV5zHScSPJpcLm/r0DFPcoY3tYRp+VZukxuMeKgXYmsXQHO05zQeA==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/refractor/node_modules/character-reference-invalid": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-1.1.4.tgz", - "integrity": "sha512-mKKUkUbhPpQlCOfIuZkvSEgktjPFIsZKRRbC6KWVEMvlzblj3i3asQv5ODsrwt0N3pHAEvjP8KTQPHkp0+6jOg==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/refractor/node_modules/is-alphabetical": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-1.0.4.tgz", - "integrity": "sha512-DwzsA04LQ10FHTZuL0/grVDk4rFoVH1pjAToYwBrHSxcrBIGQuXrQMtD5U1b0U2XVgKZCTLLP8u2Qxqhy3l2Vg==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/refractor/node_modules/is-alphanumerical": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-1.0.4.tgz", - "integrity": "sha512-UzoZUr+XfVz3t3v4KyGEniVL9BDRoQtY7tOyrRybkVNjDFWyo1yhXNGrrBTQxp3ib9BLAWs7k2YKBQsFRkZG9A==", - "license": "MIT", - "dependencies": { - "is-alphabetical": "^1.0.0", - "is-decimal": "^1.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/refractor/node_modules/is-decimal": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-1.0.4.tgz", - "integrity": "sha512-RGdriMmQQvZ2aqaQq3awNA6dCGtKpiDFcOzrTWrDAT2MiWrKQVPmxLGHl7Y2nNu6led0kEyoX0enY0qXYsv9zw==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/refractor/node_modules/is-hexadecimal": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-1.0.4.tgz", - "integrity": "sha512-gyPJuv83bHMpocVYoqof5VDiZveEoGoFL8m3BXNb2VW8Xs+rz9kqO8LOQ5DH6EsuvilT1ApazU0pyl+ytbPtlw==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/refractor/node_modules/parse-entities": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-2.0.0.tgz", - "integrity": "sha512-kkywGpCcRYhqQIchaWqZ875wzpS/bMKhz5HnN3p7wveJTkTtyAB/AlnS0f8DFSqYW1T82t6yEAkEcB+A1I3MbQ==", - "license": "MIT", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/refractor/-/refractor-5.0.0.tgz", + "integrity": "sha512-QXOrHQF5jOpjjLfiNk5GFnWhRXvxjUVnlFxkeDmewR5sXkr3iM46Zo+CnRR8B+MDVqkULW4EcLVcRBNOPXHosw==", "dependencies": { - "character-entities": "^1.0.0", - "character-entities-legacy": "^1.0.0", - "character-reference-invalid": "^1.0.0", - "is-alphanumerical": "^1.0.0", - "is-decimal": "^1.0.0", - "is-hexadecimal": "^1.0.0" + "@types/hast": "^3.0.0", + "@types/prismjs": "^1.0.0", + "hastscript": "^9.0.0", + "parse-entities": "^4.0.0" }, "funding": { "type": "github", "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/refractor/node_modules/prismjs": { - "version": "1.27.0", - "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.27.0.tgz", - "integrity": "sha512-t13BGPUlFDR7wRB5kQDG4jjl7XeuH6jbJGt11JHPL96qwsEHNX2+68tFXqc1/k+/jALsbSWJKUOT/hcYAZ5LkA==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/regexp.prototype.flags": { "version": "1.5.4", "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", @@ -8438,7 +8719,6 @@ "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", "dev": true, - "license": "MIT", "funding": { "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } @@ -8455,10 +8735,9 @@ } }, "node_modules/robust-predicates": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.2.tgz", - "integrity": "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==", - "license": "Unlicense" + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz", + "integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==" }, "node_modules/roughjs": { "version": "4.6.6", @@ -8499,8 +8778,7 @@ "node_modules/rw": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", - "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", - "license": "BSD-3-Clause" + "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==" }, "node_modules/safe-array-concat": { "version": "1.1.3", @@ -8560,8 +8838,7 @@ "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "license": "MIT" + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" }, "node_modules/scheduler": { "version": "0.26.0", @@ -8570,11 +8847,10 @@ "license": "MIT" }, "node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "devOptional": true, - "license": "ISC", "bin": { "semver": "bin/semver.js" }, @@ -8632,16 +8908,15 @@ } }, "node_modules/sharp": { - "version": "0.34.3", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.3.tgz", - "integrity": "sha512-eX2IQ6nFohW4DbvHIOLRB3MHFpYqaqvXd3Tp5e/T/dSH83fxaNJQRvDMhASmkNTsNTVF2/OOopzRCt7xokgPfg==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", "hasInstallScript": true, - "license": "Apache-2.0", "optional": true, "dependencies": { - "color": "^4.2.3", - "detect-libc": "^2.0.4", - "semver": "^7.7.2" + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" }, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" @@ -8650,28 +8925,30 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.34.3", - "@img/sharp-darwin-x64": "0.34.3", - "@img/sharp-libvips-darwin-arm64": "1.2.0", - "@img/sharp-libvips-darwin-x64": "1.2.0", - "@img/sharp-libvips-linux-arm": "1.2.0", - "@img/sharp-libvips-linux-arm64": "1.2.0", - "@img/sharp-libvips-linux-ppc64": "1.2.0", - "@img/sharp-libvips-linux-s390x": "1.2.0", - "@img/sharp-libvips-linux-x64": "1.2.0", - "@img/sharp-libvips-linuxmusl-arm64": "1.2.0", - "@img/sharp-libvips-linuxmusl-x64": "1.2.0", - "@img/sharp-linux-arm": "0.34.3", - "@img/sharp-linux-arm64": "0.34.3", - "@img/sharp-linux-ppc64": "0.34.3", - "@img/sharp-linux-s390x": "0.34.3", - "@img/sharp-linux-x64": "0.34.3", - "@img/sharp-linuxmusl-arm64": "0.34.3", - "@img/sharp-linuxmusl-x64": "0.34.3", - "@img/sharp-wasm32": "0.34.3", - "@img/sharp-win32-arm64": "0.34.3", - "@img/sharp-win32-ia32": "0.34.3", - "@img/sharp-win32-x64": "0.34.3" + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" } }, "node_modules/shebang-command": { @@ -8773,16 +9050,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/simple-swizzle": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", - "integrity": "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==", - "license": "MIT", - "optional": true, - "dependencies": { - "is-arrayish": "^0.3.1" - } - }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -8806,8 +9073,7 @@ "version": "0.0.5", "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", "integrity": "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", @@ -8823,20 +9089,11 @@ "node": ">= 0.4" } }, - "node_modules/streamsearch": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", - "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", - "engines": { - "node": ">=10.0.0" - } - }, "node_modules/string.prototype.includes": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", "integrity": "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==", "dev": true, - "license": "MIT", "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", @@ -8851,7 +9108,6 @@ "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", "integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==", "dev": true, - "license": "MIT", "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", @@ -8879,7 +9135,6 @@ "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz", "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==", "dev": true, - "license": "MIT", "dependencies": { "define-properties": "^1.1.3", "es-abstract": "^1.17.5" @@ -8963,7 +9218,6 @@ "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", "dev": true, - "license": "MIT", "engines": { "node": ">=4" } @@ -9028,19 +9282,6 @@ "integrity": "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==", "license": "MIT" }, - "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/supports-preserve-symlinks-flag": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", @@ -9082,17 +9323,15 @@ } }, "node_modules/tar": { - "version": "7.4.3", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.4.3.tgz", - "integrity": "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==", + "version": "7.5.22", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz", + "integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==", "dev": true, - "license": "ISC", "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", - "minizlib": "^3.0.1", - "mkdirp": "^3.0.1", + "minizlib": "^3.1.0", "yallist": "^5.0.0" }, "engines": { @@ -9100,20 +9339,21 @@ } }, "node_modules/tinyexec": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.1.tgz", - "integrity": "sha512-5uC6DDlmeqiOwCPmK9jMSdOuZTh8bU39Ys6yidB+UTt5hfZUPGAypSgFRiEp+jbi9qH40BLDvy85jIU88wKSqw==", - "license": "MIT" + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "engines": { + "node": ">=18" + } }, "node_modules/tinyglobby": { - "version": "0.2.14", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz", - "integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dev": true, - "license": "MIT", "dependencies": { - "fdir": "^6.4.4", - "picomatch": "^4.0.2" + "fdir": "^6.5.0", + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" @@ -9127,7 +9367,6 @@ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "dev": true, - "license": "MIT", "engines": { "node": ">=12.0.0" }, @@ -9141,11 +9380,10 @@ } }, "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, - "license": "MIT", "engines": { "node": ">=12" }, @@ -9187,11 +9425,10 @@ } }, "node_modules/ts-api-utils": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", - "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", "dev": true, - "license": "MIT", "engines": { "node": ">=18.12" }, @@ -9213,7 +9450,6 @@ "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==", "dev": true, - "license": "MIT", "dependencies": { "@types/json5": "^0.0.29", "json5": "^1.0.2", @@ -9221,6 +9457,18 @@ "strip-bom": "^3.0.0" } }, + "node_modules/tsconfig-paths/node_modules/json5": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", + "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", + "dev": true, + "dependencies": { + "minimist": "^1.2.0" + }, + "bin": { + "json5": "lib/cli.js" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -9332,11 +9580,28 @@ "node": ">=14.17" } }, - "node_modules/ufo": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.1.tgz", - "integrity": "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==", - "license": "MIT" + "node_modules/typescript-eslint": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.65.0.tgz", + "integrity": "sha512-/ggrHAwyjENDusvyxbuqxAC2dTnZg/Z8F+fgQtYIz+L6n/9HfSlEZcFGV/NsMNa6CkGk0xUjUAFwC0vHOflvIA==", + "dev": true, + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.65.0", + "@typescript-eslint/parser": "8.65.0", + "@typescript-eslint/typescript-estree": "8.65.0", + "@typescript-eslint/utils": "8.65.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } }, "node_modules/unbox-primitive": { "version": "1.1.0", @@ -9480,38 +9745,70 @@ } }, "node_modules/unrs-resolver": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz", - "integrity": "sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.12.2.tgz", + "integrity": "sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ==", "dev": true, "hasInstallScript": true, - "license": "MIT", "dependencies": { - "napi-postinstall": "^0.3.0" + "napi-postinstall": "^0.3.4" }, "funding": { "url": "https://opencollective.com/unrs-resolver" }, "optionalDependencies": { - "@unrs/resolver-binding-android-arm-eabi": "1.11.1", - "@unrs/resolver-binding-android-arm64": "1.11.1", - "@unrs/resolver-binding-darwin-arm64": "1.11.1", - "@unrs/resolver-binding-darwin-x64": "1.11.1", - "@unrs/resolver-binding-freebsd-x64": "1.11.1", - "@unrs/resolver-binding-linux-arm-gnueabihf": "1.11.1", - "@unrs/resolver-binding-linux-arm-musleabihf": "1.11.1", - "@unrs/resolver-binding-linux-arm64-gnu": "1.11.1", - "@unrs/resolver-binding-linux-arm64-musl": "1.11.1", - "@unrs/resolver-binding-linux-ppc64-gnu": "1.11.1", - "@unrs/resolver-binding-linux-riscv64-gnu": "1.11.1", - "@unrs/resolver-binding-linux-riscv64-musl": "1.11.1", - "@unrs/resolver-binding-linux-s390x-gnu": "1.11.1", - "@unrs/resolver-binding-linux-x64-gnu": "1.11.1", - "@unrs/resolver-binding-linux-x64-musl": "1.11.1", - "@unrs/resolver-binding-wasm32-wasi": "1.11.1", - "@unrs/resolver-binding-win32-arm64-msvc": "1.11.1", - "@unrs/resolver-binding-win32-ia32-msvc": "1.11.1", - "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" + "@unrs/resolver-binding-android-arm-eabi": "1.12.2", + "@unrs/resolver-binding-android-arm64": "1.12.2", + "@unrs/resolver-binding-darwin-arm64": "1.12.2", + "@unrs/resolver-binding-darwin-x64": "1.12.2", + "@unrs/resolver-binding-freebsd-x64": "1.12.2", + "@unrs/resolver-binding-linux-arm-gnueabihf": "1.12.2", + "@unrs/resolver-binding-linux-arm-musleabihf": "1.12.2", + "@unrs/resolver-binding-linux-arm64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-arm64-musl": "1.12.2", + "@unrs/resolver-binding-linux-loong64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-loong64-musl": "1.12.2", + "@unrs/resolver-binding-linux-ppc64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-riscv64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-riscv64-musl": "1.12.2", + "@unrs/resolver-binding-linux-s390x-gnu": "1.12.2", + "@unrs/resolver-binding-linux-x64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-x64-musl": "1.12.2", + "@unrs/resolver-binding-openharmony-arm64": "1.12.2", + "@unrs/resolver-binding-wasm32-wasi": "1.12.2", + "@unrs/resolver-binding-win32-arm64-msvc": "1.12.2", + "@unrs/resolver-binding-win32-ia32-msvc": "1.12.2", + "@unrs/resolver-binding-win32-x64-msvc": "1.12.2" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" } }, "node_modules/uri-js": { @@ -9525,30 +9822,35 @@ } }, "node_modules/use-intl": { - "version": "4.3.5", - "resolved": "https://registry.npmjs.org/use-intl/-/use-intl-4.3.5.tgz", - "integrity": "sha512-qyL1TZNesVbzj/75ZbYsi+xzNSiFqp5rIVsiAN0JT8rPMSjX0/3KQz76aJIrngI1/wIQdVYFVdImWh5yAv+dWA==", - "license": "MIT", + "version": "4.13.4", + "resolved": "https://registry.npmjs.org/use-intl/-/use-intl-4.13.4.tgz", + "integrity": "sha512-wRhU5zyPNgu845++EJ8ckQsi89b22QUop7NlGxNXpsnKSwEJr7WErAkdAYeVQgFTmDWsa8e2NI1e14XbWz9Ecw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/amannn" + } + ], "dependencies": { - "@formatjs/fast-memoize": "^2.2.0", + "@formatjs/fast-memoize": "^3.1.0", "@schummar/icu-type-parser": "1.21.5", - "intl-messageformat": "^10.5.14" + "icu-minify": "^4.13.4", + "intl-messageformat": "^11.1.0" }, "peerDependencies": { "react": "^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0" } }, "node_modules/uuid": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", - "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.1.tgz", + "integrity": "sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==", "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" ], - "license": "MIT", "bin": { - "uuid": "dist/esm/bin/uuid" + "uuid": "dist-node/bin/uuid" } }, "node_modules/vfile": { @@ -9593,55 +9895,6 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/vscode-jsonrpc": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz", - "integrity": "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==", - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/vscode-languageserver": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/vscode-languageserver/-/vscode-languageserver-9.0.1.tgz", - "integrity": "sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g==", - "license": "MIT", - "dependencies": { - "vscode-languageserver-protocol": "3.17.5" - }, - "bin": { - "installServerIntoExtension": "bin/installServerIntoExtension" - } - }, - "node_modules/vscode-languageserver-protocol": { - "version": "3.17.5", - "resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz", - "integrity": "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==", - "license": "MIT", - "dependencies": { - "vscode-jsonrpc": "8.2.0", - "vscode-languageserver-types": "3.17.5" - } - }, - "node_modules/vscode-languageserver-textdocument": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.12.tgz", - "integrity": "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==", - "license": "MIT" - }, - "node_modules/vscode-languageserver-types": { - "version": "3.17.5", - "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz", - "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==", - "license": "MIT" - }, - "node_modules/vscode-uri": { - "version": "3.0.8", - "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.0.8.tgz", - "integrity": "sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw==", - "license": "MIT" - }, "node_modules/web-namespaces": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz", @@ -9767,15 +10020,6 @@ "node": ">=0.10.0" } }, - "node_modules/xtend": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", - "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", - "license": "MIT", - "engines": { - "node": ">=0.4" - } - }, "node_modules/yallist": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", @@ -9799,6 +10043,27 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + }, "node_modules/zwitch": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", diff --git a/package.json b/package.json index 0e74c5ac9..1d84e49c3 100644 --- a/package.json +++ b/package.json @@ -10,14 +10,14 @@ }, "dependencies": { "mermaid": "^11.4.1", - "next": "15.3.1", + "next": "^15.5.22", "next-intl": "^4.1.0", "next-themes": "^0.4.6", "react": "^19.0.0", "react-dom": "^19.0.0", "react-icons": "^5.5.0", "react-markdown": "^10.1.0", - "react-syntax-highlighter": "^15.6.1", + "react-syntax-highlighter": "^16.1.1", "rehype-katex": "^7.0.1", "rehype-raw": "^7.0.0", "remark-gfm": "^4.0.1", @@ -25,14 +25,14 @@ "svg-pan-zoom": "^3.6.2" }, "devDependencies": { - "@eslint/eslintrc": "^3", + "@eslint/eslintrc": "^0.1.0", "@tailwindcss/postcss": "^4", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", "@types/react-syntax-highlighter": "^15.5.13", - "eslint": "^9", - "eslint-config-next": "15.3.1", + "eslint": "^10.8.0", + "eslint-config-next": "^16.2.12", "tailwindcss": "^4", "typescript": "^5" }, diff --git a/pytest.ini b/pytest.ini index 3395d3288..1b8bc661e 100644 --- a/pytest.ini +++ b/pytest.ini @@ -3,7 +3,7 @@ testpaths = test python_files = test_*.py *_test.py python_classes = Test* python_functions = test_* -addopts = +addopts = -v --strict-markers --disable-warnings diff --git a/run.sh b/run.sh index c2b523807..24c82f415 100644 --- a/run.sh +++ b/run.sh @@ -1 +1 @@ -uv run -m api.main \ No newline at end of file +uv run -m api.main diff --git a/screenshots/Codemap.png b/screenshots/Codemap.png new file mode 100644 index 0000000000000000000000000000000000000000..470d6035d633e51a501ead1e86063e3d3839ab71 GIT binary patch literal 197283 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 z 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 void } | null>(null); + // Code viewer drawer (rendered at modal level so its header isn't clipped by + // the Ask panel's scroll container). Opened by codemap citation clicks. + const [codeViewerOpen, setCodeViewerOpen] = useState(false); + const [codeViewerTarget, setCodeViewerTarget] = useState(null); + const [codeViewerFiles, setCodeViewerFiles] = useState([]); + const openCodeViewer = useCallback((target: CodeTarget, files: string[]) => { + setCodeViewerFiles(files); + setCodeViewerTarget(target); + setCodeViewerOpen(true); + }, []); + // Authentication state const [authRequired, setAuthRequired] = useState(false); const [authCode, setAuthCode] = useState(''); @@ -298,7 +311,7 @@ export default function RepoWikiPage() { try { const url = new URL(repoUrl); const hostname = url.hostname; - + if (hostname === 'github.com' || hostname.includes('github')) { // GitHub URL format: https://github.com/owner/repo/blob/branch/path return `${repoUrl}/blob/${defaultBranch}/${filePath}`; @@ -317,6 +330,150 @@ export default function RepoWikiPage() { return filePath; }, [effectiveRepoInfo, defaultBranch]); + // Post-process the LLM-generated wiki markdown to fix two recurring format + // issues that the prompt alone cannot reliably prevent: + // 1. Normalize the leading "Relevant source files"

block so it + // always uses the exact, program-generated markdown. The model often + // drops the links (rendering plain text) or appends bogus line numbers. + // 2. Resolve empty citation links `[file.ext:10-20]()` to real repository + // URLs so they render as clickable links instead of dead ones. + const postProcessWikiContent = useCallback((content: string, filePaths: string[]): string => { + let processed = content; + + // Escape the characters that would otherwise break a Markdown link label. + // File paths such as Next.js dynamic routes (src/app/[owner]/[repo]/page.tsx) + // contain '[' / ']' and MUST be escaped, or Markdown parses them as nested + // links and the citation renders as garbage. + const escapeLabel = (s: string) => s.replace(/([[\]])/g, '\\$1'); + + // Build the host-specific line anchor for an already-resolved file URL. + // GitHub: #L10-L20 (single: #L10) + // GitLab: #L10-20 (single: #L10) + // Bitbucket: #lines-10:20 (single: #lines-10) + // Detect the host from the hostname only (mirroring generateFileUrl) so a + // repo/owner name that happens to contain another vendor's name in the URL + // path cannot cause a misclassification. + const lineAnchor = (url: string, start: string, end?: string): string => { + let hostname = ''; + try { + hostname = new URL(url).hostname; + } catch { + hostname = ''; + } + if (hostname.includes('github')) return end ? `#L${start}-L${end}` : `#L${start}`; + if (hostname.includes('gitlab')) return end ? `#L${start}-${end}` : `#L${start}`; + if (hostname.includes('bitbucket')) return end ? `#lines-${start}:${end}` : `#lines-${start}`; + return ''; + }; + + // 1. Rebuild the
block from the known file list. + if (filePaths.length > 0) { + const detailsBlock = `
+Relevant source files + +The following files were used as context for generating this wiki page: + +${filePaths.map(path => `- [${escapeLabel(path)}](${generateFileUrl(path)})`).join('\n')} +
`; + + const detailsRegex = /
\s*\s*Relevant source files\s*<\/summary>[\s\S]*?<\/details>/i; + if (detailsRegex.test(processed)) { + // Replace whatever the model produced, in place, with the canonical block. + processed = processed.replace(detailsRegex, detailsBlock); + } else { + // The model omitted the block entirely; prepend the canonical one. + processed = `${detailsBlock}\n\n${processed}`; + } + } + + // 2. Resolve empty citation links `[path/to/file.ext:10-20]()` -> real URL. + // Match ONLY against the known source-file paths (longest first, so the + // most specific path wins). This is far safer than a generic `[...]()` + // regex: malformed or nested brackets in the model output can no longer + // be swallowed into a bogus label/URL, and paths containing '[' / ']' + // are matched literally instead of tripping the Markdown parser. + if (filePaths.length > 0) { + const alternation = [...filePaths] + .sort((a, b) => b.length - a.length) + .map(p => p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) + .join('|'); + const citationRegex = new RegExp(`\\[(${alternation})(?::(\\d+)(?:-(\\d+))?)?\\]\\(\\)`, 'g'); + processed = processed.replace(citationRegex, (match, path: string, start: string, end: string) => { + const url = generateFileUrl(path); + // generateFileUrl returns the bare path for local repos / unresolved + // hosts; in that case there is no web URL to link to, so leave it as-is. + if (url === path) { + return match; + } + const linePart = start ? (end ? `:${start}-${end}` : `:${start}`) : ''; + const anchor = start ? lineAnchor(url, start, end) : ''; + return `[${escapeLabel(path)}${linePart}](${url}${anchor})`; + }); + } + + // 3. Resolve any REMAINING empty citation links that look like a repository + // file path but were not in this page's filePaths. The model frequently + // cites additional files it read (e.g. accelerator_connector.py) that were + // never in the assigned list, leaving them as dead `[path:10-20]()` links. + // The path token forbids brackets/whitespace/parens, so nested-bracket + // edge cases are still avoided; bracketed paths (e.g. Next.js dynamic + // routes) are already handled by the filePaths pass above. + const genericCitationRegex = + /\[([^[\]\s()]+?\.[A-Za-z0-9]+)(?::(\d+)(?:-(\d+))?)?\]\(\)/g; + processed = processed.replace( + genericCitationRegex, + (match, path: string, start: string, end: string) => { + const url = generateFileUrl(path); + if (url === path) { + return match; // local repo / unresolved host -> leave as-is + } + const linePart = start ? (end ? `:${start}-${end}` : `:${start}`) : ''; + const anchor = start ? lineAnchor(url, start, end) : ''; + return `[${escapeLabel(path)}${linePart}](${url}${anchor})`; + }, + ); + + // 4. Resolve citations where the model put a "Sources:" prefix INSIDE the + // bracket and/or used a bare filename instead of the full repo path, e.g. + // `[Sources: fit_loop.py:56-104]()`. The bare name is mapped back to a + // full path via this page's filePaths (by basename); the "Sources:" label + // is moved outside the link to match the normal `Sources: [file](url)` + // format. Unknown bare names are left untouched. + if (filePaths.length > 0) { + const byBasename = new Map(); + for (const p of filePaths) { + const base = p.split('/').pop() ?? p; + if (!byBasename.has(base)) byBasename.set(base, p); + } + const prefixedCitationRegex = + /\[(Sources?|Source):\s*([^[\]\s():]+?)(?::(\d+)(?:-(\d+))?)?\]\(\)/gi; + processed = processed.replace( + prefixedCitationRegex, + (match, prefix: string, token: string, start: string, end: string) => { + // token may already be a full path, or a bare basename to look up. + const fullPath = token.includes('/') ? token : byBasename.get(token); + if (!fullPath) { + return match; // unknown bare filename -> leave as-is + } + const url = generateFileUrl(fullPath); + if (url === fullPath) { + return match; // local repo / unresolved host + } + const linePart = start ? (end ? `:${start}-${end}` : `:${start}`) : ''; + const anchor = start ? lineAnchor(url, start, end) : ''; + return `${prefix}: [${escapeLabel(fullPath)}${linePart}](${url}${anchor})`; + }, + ); + } + + // 5. Strip a redundant empty "()" left immediately after a completed link. + // The model sometimes emits `[path](https://…)()` — a real link followed + // by the citation template's empty parens — which renders a stray "()". + processed = processed.replace(/(\]\([^)\s]+\))\(\)/g, '$1'); + + return processed; + }, [generateFileUrl]); + // Memoize repo info to avoid triggering updates in callbacks // Add useEffect to handle scroll reset @@ -426,11 +583,11 @@ You will be given: CRITICAL STARTING INSTRUCTION: The very first thing on the page MUST be a \`
\` block listing ALL the \`[RELEVANT_SOURCE_FILES]\` you used to generate the content. There MUST be AT LEAST 5 source files listed - if fewer were provided, you MUST find additional related files to include. -Format it exactly like this: +Do not provide any acknowledgements, disclaimers, apologies, or any other preface before the \`
\` block. JUST START with the \`
\` block. +Format the block EXACTLY like the following template, reproducing it verbatim (do not add line numbers, do not convert the links to plain text, do not add any other text):
Relevant source files -Remember, do not provide any acknowledgements, disclaimers, apologies, or any other preface before the \`
\` block. JUST START with the \`
\` block. The following files were used as context for generating this wiki page: ${filePaths.map(path => `- [${path}](${generateFileUrl(path)})`).join('\n')} @@ -497,7 +654,12 @@ Based ONLY on the content of the \`[RELEVANT_SOURCE_FILES]\`: 6. **Source Citations (EXTREMELY IMPORTANT):** * For EVERY piece of significant information, explanation, diagram, table entry, or code snippet, you MUST cite the specific source file(s) and relevant line numbers from which the information was derived. * Place citations at the end of the paragraph, under the diagram/table, or after the code snippet. - * Use the exact format: \`Sources: [filename.ext:start_line-end_line]()\` for a range, or \`Sources: [filename.ext:line_number]()\` for a single line. Multiple files can be cited: \`Sources: [file1.ext:1-10](), [file2.ext:5](), [dir/file3.ext]()\` (if the whole file is relevant and line numbers are not applicable or too broad). + * Use the EXACT format below, and ALWAYS use the FULL repository-relative path exactly as it appears in the "Relevant source files" list above — NEVER a bare filename (e.g. use \`src/lightning/pytorch/loops/fit_loop.py\`, not \`fit_loop.py\`): + * Range: \`Sources: [src/full/path/file.ext:start_line-end_line]()\` + * Single line: \`Sources: [src/full/path/file.ext:line_number]()\` + * Multiple files: \`Sources: [src/full/path/a.ext:1-10](), [src/full/path/b.ext:5](), [src/full/path/c.ext]()\` (omit line numbers when the whole file is relevant). + * The word \`Sources:\` MUST be placed BEFORE the opening bracket, never inside it (write \`Sources: [path]()\`, NOT \`[Sources: path]()\`). + * Leave the parentheses \`()\` EMPTY — they are resolved into real links automatically. Do not put a URL inside them. * If an entire section is overwhelmingly based on one or two files, you can cite them under the section heading in addition to more specific citations within the section. * IMPORTANT: You MUST cite AT LEAST 5 different source files throughout the wiki page to ensure comprehensive coverage. @@ -513,7 +675,7 @@ IMPORTANT: Generate the content in ${language === 'en' ? 'English' : language === 'zh-tw' ? 'Traditional Chinese (繁體中文)' : language === 'es' ? 'Spanish (EspaƱol)' : language === 'kr' ? 'Korean (ķ•œźµ­ģ–“)' : - language === 'vi' ? 'Vietnamese (Tiįŗæng Việt)' : + language === 'vi' ? 'Vietnamese (Tiįŗæng Việt)' : language === "pt-br" ? "Brazilian Portuguese (PortuguĆŖs Brasileiro)" : language === "fr" ? "FranƧais (French)" : language === "ru" ? "Русский (Russian)" : @@ -644,6 +806,9 @@ Remember: // Clean up markdown delimiters content = content.replace(/^```markdown\s*/i, '').replace(/```\s*$/i, ''); + // Normalize the
block and resolve empty citation links. + content = postProcessWikiContent(content, filePaths); + console.log(`Received content for ${page.title}, length: ${content.length} characters`); // Store the FINAL generated content @@ -678,7 +843,7 @@ Remember: setLoadingMessage(undefined); // Clear specific loading message } }); - }, [generatedPages, currentToken, effectiveRepoInfo, selectedProviderState, selectedModelState, isCustomSelectedModelState, customSelectedModelState, modelExcludedDirs, modelExcludedFiles, language, activeContentRequests, generateFileUrl]); + }, [generatedPages, currentToken, effectiveRepoInfo, selectedProviderState, selectedModelState, isCustomSelectedModelState, customSelectedModelState, modelExcludedDirs, modelExcludedFiles, language, activeContentRequests, generateFileUrl, postProcessWikiContent]); // Determine the wiki structure from repository data const determineWikiStructure = useCallback(async (fileTree: string, readme: string, owner: string, repo: string) => { @@ -950,6 +1115,11 @@ IMPORTANT: let xmlText = xmlMatch[0]; xmlText = xmlText.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ''); + // Escape bare ampersands that are not part of a valid XML entity. A single + // unescaped '&' (very common in LLM-generated titles/descriptions such as + // "Frontend & Backend") makes strict text/xml parsing fail with a + // , which would otherwise drop the whole structure. + xmlText = xmlText.replace(/&(?!(?:amp|lt|gt|quot|apos|#\d+|#x[0-9a-fA-F]+);)/g, '&'); // Try parsing with DOMParser const parser = new DOMParser(); const xmlDoc = parser.parseFromString(xmlText, "text/xml"); @@ -983,10 +1153,6 @@ IMPORTANT: // Parse pages using DOM pages = []; - if (parseError && (!pagesEls || pagesEls.length === 0)) { - console.warn('DOM parsing failed, trying regex fallback'); - } - pagesEls.forEach(pageEl => { const id = pageEl.getAttribute('id') || `page-${pages.length + 1}`; const titleEl = pageEl.querySelector('title'); @@ -1019,6 +1185,28 @@ IMPORTANT: }); }); + // Regex fallback: strict text/xml parsing can still fail (or yield no + // nodes) on malformed LLM output. Recover pages directly from the + // raw XML text so a parse hiccup does not produce an empty wiki. + if (pages.length === 0) { + console.warn('DOM parsing yielded no pages; using regex fallback'); + const pageBlocks = xmlText.match(//g) || []; + pages = pageBlocks.map((block, i) => { + const pid = block.match(/([\s\S]*?)<\/title>/)?.[1]?.trim() ?? ''; + const imp = block.match(/([\s\S]*?)<\/importance>/)?.[1]?.trim(); + const importance: 'high' | 'medium' | 'low' = + imp === 'high' ? 'high' : imp === 'low' ? 'low' : 'medium'; + const filePaths = Array.from( + block.matchAll(/([\s\S]*?)<\/file_path>/g), + ).map(m => m[1].trim()).filter(Boolean); + const relatedPages = Array.from( + block.matchAll(/([\s\S]*?)<\/related>/g), + ).map(m => m[1].trim()).filter(Boolean); + return { id: pid, title: ptitle, content: '', filePaths, importance, relatedPages }; + }); + } + // Extract sections if they exist in the XML const sections: WikiSection[] = []; const rootSections: string[] = []; @@ -1218,16 +1406,16 @@ IMPORTANT: if (!repoUrl) { return 'https://api.github.com'; // Default to public GitHub } - + try { const url = new URL(repoUrl); const hostname = url.hostname; - + // If it's the public GitHub, use the standard API URL if (hostname === 'github.com') { return 'https://api.github.com'; } - + // For GitHub Enterprise, use the enterprise API URL format // GitHub Enterprise API URL format: https://github.company.com/api/v3 return `${url.protocol}//${hostname}/api/v3`; @@ -1243,7 +1431,7 @@ IMPORTANT: const repoInfoResponse = await fetch(`${githubApiBaseUrl}/repos/${owner}/${repo}`, { headers: createGithubHeaders(currentToken) }); - + if (repoInfoResponse.ok) { const repoData = await repoInfoResponse.json(); defaultBranchLocal = repoData.default_branch; @@ -1256,7 +1444,7 @@ IMPORTANT: } // Create list of branches to try, prioritizing the actual default branch - const branchesToTry = defaultBranchLocal + const branchesToTry = defaultBranchLocal ? [defaultBranchLocal, 'main', 'master'].filter((branch, index, arr) => arr.indexOf(branch) === index) : ['main', 'master']; @@ -1353,7 +1541,7 @@ IMPORTANT: // Step 2: Paginate to fetch full file tree let page = 1; let morePages = true; - + while (morePages) { const apiUrl = `${projectInfoUrl}/repository/tree?recursive=true&per_page=100&page=${page}`; const response = await fetch(apiUrl, { headers }); @@ -1480,6 +1668,40 @@ IMPORTANT: } } + // Warm the backend embedding index BEFORE the first chat call. This moves + // the slow, one-time cold embedding to a dedicated streaming endpoint (with + // progress) so determineWikiStructure hits a warm cache instead of blocking + // long enough to trigger a proxy headers timeout. No-op if already indexed. + const preparingIndexMsg = messages.loading?.preparingIndex || 'Preparing repository index...'; + try { + const prepareBody: Record = { + repo_url: getRepoUrl(effectiveRepoInfo), + type: effectiveRepoInfo.type, + }; + addTokensToRequestBody( + prepareBody, + currentToken, + effectiveRepoInfo.type, + selectedProviderState, + selectedModelState, + isCustomSelectedModelState, + customSelectedModelState, + language, + modelExcludedDirs, + modelExcludedFiles, + modelIncludedDirs, + modelIncludedFiles, + ); + setLoadingMessage(preparingIndexMsg); + await prepareRepoIndex(prepareBody, ({ elapsedSec }) => { + setLoadingMessage(elapsedSec ? `${preparingIndexMsg} (${elapsedSec}s)` : preparingIndexMsg); + }); + } catch (prepareError) { + // Non-fatal: determineWikiStructure will build the index on demand as a + // fallback (slower). Log and continue so a prepare hiccup never blocks wiki generation. + console.warn('Repository index prepare failed; continuing with on-demand build:', prepareError); + } + // Now determine the wiki structure await determineWikiStructure(fileTreeData, readmeContent, owner, repo); @@ -1492,7 +1714,7 @@ IMPORTANT: // Reset the request in progress flag setRequestInProgress(false); } - }, [owner, repo, determineWikiStructure, currentToken, effectiveRepoInfo, requestInProgress, messages.loading]); + }, [owner, repo, determineWikiStructure, currentToken, effectiveRepoInfo, requestInProgress, messages.loading, selectedProviderState, selectedModelState, isCustomSelectedModelState, customSelectedModelState, language, modelExcludedDirs, modelExcludedFiles, modelIncludedDirs, modelIncludedFiles]); // Function to export wiki content const exportWiki = useCallback(async (format: 'markdown' | 'json') => { @@ -1844,7 +2066,7 @@ IMPORTANT: setGeneratedPages(cachedData.generated_pages); setCurrentPageId(cachedStructure.pages.length > 0 ? cachedStructure.pages[0].id : undefined); setIsLoading(false); - setEmbeddingError(false); + setEmbeddingError(false); setLoadingMessage(undefined); cacheLoadedSuccessfully.current = true; return; // Exit if cache is successfully loaded @@ -2207,7 +2429,7 @@ IMPORTANT: {/* Floating Chat Button */} {!isLoading && wikiStructure && (
-
- (askComponentRef.current = ref)} - /> + + {/* Split layout: conversation on the left, code viewer on the right. + When no codemap citation needs a viewer, the conversation uses the + full width (same as fast / deep-research modes). ~6:4 when open. */} +
+
+ (askComponentRef.current = ref)} + onOpenCodeViewer={openCodeViewer} + onCloseCodeViewer={() => setCodeViewerOpen(false)} + /> +
+ {codeViewerOpen && ( +
+ + setCodeViewerTarget({ file_path: f, start_line: null, end_line: null, snippet: '' }) + } + /> +
+ )}
diff --git a/src/app/[owner]/[repo]/workshop/page.tsx b/src/app/[owner]/[repo]/workshop/page.tsx index 7a1e29c75..83abe29c8 100644 --- a/src/app/[owner]/[repo]/workshop/page.tsx +++ b/src/app/[owner]/[repo]/workshop/page.tsx @@ -301,7 +301,7 @@ Make the workshop content in ${language === 'en' ? 'English' : language === 'zh-tw' ? 'Traditional Chinese (繁體中文)' : language === 'es' ? 'Spanish (EspaƱol)' : language === 'kr' ? 'Korean (ķ•œźµ­ģ–“)' : - language === 'vi' ? 'Vietnamese (Tiįŗæng Việt)' : + language === 'vi' ? 'Vietnamese (Tiįŗæng Việt)' : language === "pt-br" ? "Brazilian Portuguese (PortuguĆŖs Brasileiro)" : language === "fr" ? "FranƧais (French)" : language === "ru" ? "Русский (Russian)" : diff --git a/src/app/api/auth/status/route.ts b/src/app/api/auth/status/route.ts index 601bb30b2..5bb7c6666 100644 --- a/src/app/api/auth/status/route.ts +++ b/src/app/api/auth/status/route.ts @@ -11,14 +11,14 @@ export async function GET() { 'Content-Type': 'application/json', }, }); - + if (!response.ok) { return NextResponse.json( { error: `Backend server returned ${response.status}` }, { status: response.status } ); } - + const data = await response.json(); return NextResponse.json(data); } catch (error) { diff --git a/src/app/api/auth/validate/route.ts b/src/app/api/auth/validate/route.ts index 0a9b7c0a8..8c82c928b 100644 --- a/src/app/api/auth/validate/route.ts +++ b/src/app/api/auth/validate/route.ts @@ -5,7 +5,7 @@ const TARGET_SERVER_BASE_URL = process.env.SERVER_BASE_URL || 'http://localhost: export async function POST(request: NextRequest) { try { const body = await request.json(); - + // Forward the request to the backend API const response = await fetch(`${TARGET_SERVER_BASE_URL}/auth/validate`, { method: 'POST', @@ -14,14 +14,14 @@ export async function POST(request: NextRequest) { }, body: JSON.stringify(body), }); - + if (!response.ok) { return NextResponse.json( { error: `Backend server returned ${response.status}` }, { status: response.status } ); } - + const data = await response.json(); return NextResponse.json(data); } catch (error) { diff --git a/src/app/api/chat/stream/route.ts b/src/app/api/chat/stream/route.ts index 544c5d1eb..9d3afb3ae 100644 --- a/src/app/api/chat/stream/route.ts +++ b/src/app/api/chat/stream/route.ts @@ -110,4 +110,4 @@ export async function OPTIONS() { 'Access-Control-Allow-Headers': 'Content-Type, Authorization', // Adjust as per client's request headers }, }); -} \ No newline at end of file +} diff --git a/src/app/api/models/config/route.ts b/src/app/api/models/config/route.ts index 802df2217..01a57c793 100644 --- a/src/app/api/models/config/route.ts +++ b/src/app/api/models/config/route.ts @@ -27,7 +27,7 @@ export async function GET() { const modelConfig = await backendResponse.json(); return NextResponse.json(modelConfig); } catch (error) { - console.error('Error fetching model configurations:', error); + console.error('Error fetching model configurations:', error); return new NextResponse(JSON.stringify({ error: error }), { status: 500, headers: { 'Content-Type': 'application/json' }, diff --git a/src/app/api/repo/prepare/route.ts b/src/app/api/repo/prepare/route.ts new file mode 100644 index 000000000..42ad43f89 --- /dev/null +++ b/src/app/api/repo/prepare/route.ts @@ -0,0 +1,74 @@ +import { NextRequest, NextResponse } from 'next/server'; + +// Proxy for the backend's repository-indexing endpoint. +// +// The backend streams Server-Sent Events (a heartbeat/progress line at least +// every ~10s), so this route just pipes the byte stream straight through. No +// custom fetch timeout is needed: the first byte (headers) arrives immediately +// and heartbeats keep the body well within undici's default 300s timeouts. +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +const TARGET_SERVER_BASE_URL = process.env.SERVER_BASE_URL || 'http://localhost:8001'; + +export async function POST(req: NextRequest) { + try { + const requestBody = await req.json(); + const targetUrl = `${TARGET_SERVER_BASE_URL}/repo/prepare`; + + const backendResponse = await fetch(targetUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'text/event-stream', + }, + body: JSON.stringify(requestBody), + }); + + if (!backendResponse.ok || !backendResponse.body) { + const errorBody = await backendResponse.text().catch(() => ''); + return new NextResponse(errorBody || 'Failed to start repository indexing', { + status: backendResponse.status || 500, + }); + } + + // Pipe the SSE stream from the backend to the client. + const stream = new ReadableStream({ + async start(controller) { + const reader = backendResponse.body!.getReader(); + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + controller.enqueue(value); + } + } catch (error) { + console.error('Error reading from backend prepare stream in proxy:', error); + controller.error(error); + } finally { + controller.close(); + reader.releaseLock(); + } + }, + cancel(reason) { + console.log('Client cancelled repo prepare stream:', reason); + }, + }); + + return new NextResponse(stream, { + status: 200, + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache, no-transform', + 'X-Accel-Buffering': 'no', + }, + }); + } catch (error) { + console.error('Error in API proxy route (/api/repo/prepare):', error); + const errorMessage = error instanceof Error ? error.message : 'Internal Server Error in proxy'; + return new NextResponse(JSON.stringify({ error: errorMessage }), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }); + } +} diff --git a/src/app/api/wiki/projects/route.ts b/src/app/api/wiki/projects/route.ts index f87ed35d9..1c2d3d948 100644 --- a/src/app/api/wiki/projects/route.ts +++ b/src/app/api/wiki/projects/route.ts @@ -101,4 +101,4 @@ export async function DELETE(request: Request) { const message = error instanceof Error ? error.message : 'An unknown error occurred'; return NextResponse.json({ error: `Failed to delete project: ${message}` }, { status: 500 }); } -} \ No newline at end of file +} diff --git a/src/app/page.tsx b/src/app/page.tsx index 9e05a2ef9..5be90c925 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -622,4 +622,4 @@ export default function Home() { ); -} \ No newline at end of file +} diff --git a/src/app/wiki/projects/page.tsx b/src/app/wiki/projects/page.tsx index 751286c8d..75522ecba 100644 --- a/src/app/wiki/projects/page.tsx +++ b/src/app/wiki/projects/page.tsx @@ -16,4 +16,4 @@ export default function WikiProjectsPage() { /> ); -} \ No newline at end of file +} diff --git a/src/components/Ask.tsx b/src/components/Ask.tsx index e91f0b066..3f09de38d 100644 --- a/src/components/Ask.tsx +++ b/src/components/Ask.tsx @@ -1,13 +1,44 @@ 'use client'; import React, {useState, useRef, useEffect} from 'react'; -import {FaChevronLeft, FaChevronRight } from 'react-icons/fa'; +import { FaChevronLeft, FaChevronRight, FaBolt, FaMicroscope, FaBook, FaChevronDown, FaCheck } from 'react-icons/fa'; import Markdown from './Markdown'; +import CodeMap, { PhaseStatus } from './CodeMap'; +import type { CodeTarget } from './CodeViewer'; import { useLanguage } from '@/contexts/LanguageContext'; import RepoInfo from '@/types/repoinfo'; import getRepoUrl from '@/utils/getRepoUrl'; import ModelSelectionModal from './ModelSelectionModal'; -import { createChatWebSocket, closeWebSocket, ChatCompletionRequest } from '@/utils/websocketClient'; +import { + createChatWebSocket, + closeWebSocket, + ChatCompletionRequest, + createCodemapWebSocket, + CodemapRequest, + CodemapData, + CodemapCitation, + CodemapPhase, +} from '@/utils/websocketClient'; + +const DONE_PHASES: Record = { + analyzing: 'done', + initial_codemap: 'done', + diagrams: 'done', +}; +const IDLE_PHASES: Record = { + analyzing: 'pending', + initial_codemap: 'pending', + diagrams: 'pending', +}; + +// Chat mode selector options (replaces the standalone Deep Research / Codemap toggles). +type ChatMode = 'fast' | 'deep_research' | 'codemap'; + +const MODES: { id: ChatMode; label: string; description: string; icon: React.ReactNode }[] = [ + { id: 'fast', label: 'Fast', description: 'Quick and responsive answers', icon: }, + { id: 'deep_research', label: 'Deep Research', description: 'More thorough investigation', icon: }, + { id: 'codemap', label: 'Codemap', description: 'Structured explanation grounded in code', icon: }, +]; interface Model { id: string; @@ -34,6 +65,32 @@ interface ResearchStage { type: 'plan' | 'update' | 'conclusion'; } +// A finished exchange kept in the conversation log so previous +// question/answer pairs are preserved instead of being overwritten. +// A committed chat (normal / deep-research) exchange. +interface ChatTurn { + kind: 'chat'; + question: string; + response: string; + mode: 'normal' | 'deep_research'; + // For deep research turns, every iteration/stage is preserved so the + // committed section can still browse the full multi-turn investigation. + researchStages?: ResearchStage[]; + // Index of the stage currently shown for this turn. + stageIndex?: number; +} + +// A committed codemap exchange. +interface CodemapTurn { + kind: 'codemap'; + question: string; + data: CodemapData; +} + +// Unified, ordered conversation entry — chat and codemap turns share one list +// so they render in the true chronological order they were asked. +type ConversationTurn = ChatTurn | CodemapTurn; + interface AskProps { repoInfo: RepoInfo; provider?: string; @@ -42,6 +99,12 @@ interface AskProps { customModel?: string; language?: string; onRef?: (ref: { clearConversation: () => void }) => void; + // Notifies the parent to open the right-side code viewer (rendered at the + // modal level so it isn't clipped by this component's scroll container). + onOpenCodeViewer?: (target: CodeTarget, files: string[]) => void; + // Notifies the parent to close the code viewer (e.g. non-codemap query, + // codemap with no citations, or conversation cleared). + onCloseCodeViewer?: () => void; } const Ask: React.FC = ({ @@ -51,13 +114,25 @@ const Ask: React.FC = ({ isCustomModel = false, customModel = '', language = 'en', - onRef + onRef, + onOpenCodeViewer, + onCloseCodeViewer, }) => { const [question, setQuestion] = useState(''); const [response, setResponse] = useState(''); const [isLoading, setIsLoading] = useState(false); const [deepResearch, setDeepResearch] = useState(false); + // Codemap state (a parallel flow to the normal/deep-research chat). + const [codemapMode, setCodemapMode] = useState(false); + const [modeMenuOpen, setModeMenuOpen] = useState(false); + const [codemapActive, setCodemapActive] = useState(false); // a codemap is generating + const [codemapQuestion, setCodemapQuestion] = useState(''); + const [codemapData, setCodemapData] = useState(null); + const [codemapError, setCodemapError] = useState(null); + const [codemapPhaseStatus, setCodemapPhaseStatus] = + useState>(IDLE_PHASES); + // Model selection state const [selectedProvider, setSelectedProvider] = useState(provider); const [selectedModel, setSelectedModel] = useState(model); @@ -75,8 +150,13 @@ const Ask: React.FC = ({ const [conversationHistory, setConversationHistory] = useState([]); const [researchIteration, setResearchIteration] = useState(0); const [researchComplete, setResearchComplete] = useState(false); + // Completed exchanges rendered as stacked sections above the live response. + const [conversationTurns, setConversationTurns] = useState([]); + // The question that produced the currently-streaming response. + const [currentQuestion, setCurrentQuestion] = useState(''); const inputRef = useRef(null); const responseRef = useRef(null); + const conversationEndRef = useRef(null); const providerRef = useRef(provider); const modelRef = useRef(model); @@ -94,12 +174,15 @@ const Ask: React.FC = ({ } }, [onRef]); - // Scroll to bottom of response when it changes + // Keep the newest content in view as the conversation grows or streams. useEffect(() => { if (responseRef.current) { responseRef.current.scrollTop = responseRef.current.scrollHeight; } - }, [response]); + if (conversationEndRef.current) { + conversationEndRef.current.scrollIntoView({ behavior: 'smooth', block: 'end' }); + } + }, [response, conversationTurns]); // Close WebSocket when component unmounts useEffect(() => { @@ -153,16 +236,40 @@ const Ask: React.FC = ({ setQuestion(''); setResponse(''); setConversationHistory([]); + setConversationTurns([]); + setCurrentQuestion(''); setResearchIteration(0); setResearchComplete(false); setResearchStages([]); setCurrentStageIndex(0); + // Codemap resets + setCodemapActive(false); + setCodemapQuestion(''); + setCodemapData(null); + setCodemapError(null); + setCodemapPhaseStatus(IDLE_PHASES); + onCloseCodeViewer?.(); if (inputRef.current) { inputRef.current.focus(); } }; - const downloadresponse = () =>{ - const blob = new Blob([response], { type: 'text/markdown' }); + + // Build the message list for previously completed turns so follow-up + // questions carry the earlier conversation as context. + const buildHistoryFromTurns = (): Message[] => + conversationTurns.flatMap(turn => + turn.kind === 'codemap' + ? [ + { role: 'user' as const, content: turn.question, mode: 'normal' as const }, + { role: 'assistant' as const, content: turn.data.summary || turn.data.title }, + ] + : [ + { role: 'user' as const, content: turn.question, mode: turn.mode }, + { role: 'assistant' as const, content: turn.response }, + ] + ); + const downloadresponse = (content: string = response) =>{ + const blob = new Blob([content], { type: 'text/markdown' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; @@ -277,6 +384,9 @@ const Ask: React.FC = ({ // WebSocket reference const webSocketRef = useRef(null); + // True while an HTTP fallback is running, so the WebSocket close handler + // doesn't also commit a (partial/errored) turn. + const fallbackActiveRef = useRef(false); // Function to continue research automatically const continueResearch = async () => { @@ -378,10 +488,12 @@ const Ask: React.FC = ({ setResponse(prev => prev + '\n\nError: WebSocket connection failed. Falling back to HTTP...'); // Fallback to HTTP if WebSocket fails - fallbackToHttp(requestBody); + fallbackActiveRef.current = true; + fallbackToHttp(requestBody, currentQuestion, 'deep_research'); }, // Close handler () => { + if (fallbackActiveRef.current) return; // Check if research is complete when the WebSocket closes const isComplete = checkIfResearchComplete(fullResponse); @@ -410,7 +522,11 @@ const Ask: React.FC = ({ }; // Fallback to HTTP if WebSocket fails - const fallbackToHttp = async (requestBody: ChatCompletionRequest) => { + const fallbackToHttp = async ( + requestBody: ChatCompletionRequest, + committedQuestion: string, + mode: 'normal' | 'deep_research' + ) => { try { // Make the API call using HTTP const apiResponse = await fetch(`/api/chat/stream`, { @@ -445,7 +561,7 @@ const Ask: React.FC = ({ // Extract research stage if this is a deep research response if (deepResearch) { - const stage = extractResearchStage(fullResponse, researchIteration); + const stage = extractResearchStage(fullResponse, requestBody.research_iteration ?? 1); if (stage) { // Add the stage to the research stages setResearchStages(prev => { @@ -462,26 +578,40 @@ const Ask: React.FC = ({ } } - // Check if research is complete - const isComplete = checkIfResearchComplete(fullResponse); - - // Force completion after a maximum number of iterations (5) - const forceComplete = researchIteration >= 5; - - if (forceComplete && !isComplete) { - // If we're forcing completion, append a comprehensive conclusion to the response - const completionNote = "\n\n## Final Conclusion\nAfter multiple iterations of deep research, we've gathered significant insights about this topic. This concludes our investigation process, having reached the maximum number of research iterations. The findings presented across all iterations collectively form our comprehensive answer to the original question."; - fullResponse += completionNote; - setResponse(fullResponse); - setResearchComplete(true); + if (mode === 'normal') { + // Normal mode: append the finished answer to the conversation log. + commitTurn(committedQuestion, fullResponse, 'normal'); } else { - setResearchComplete(isComplete); + // Deep research: mirror the WebSocket close handlers so the iteration + // counter advances and the auto-continue loop keeps running even when + // every request falls back to HTTP (the WebSocket never connects). + const iter = requestBody.research_iteration ?? 1; + if (iter === 1) { + // Iteration 1 is the research plan — always advance into the + // subsequent iterations. Advancing here (after the stream finishes) + // means the next turn captures this real answer, not a stale note. + setResearchComplete(false); + setResearchIteration(1); + } else if (iter >= 5) { + // Force a conclusion once the maximum iteration count is reached. + const isComplete = checkIfResearchComplete(fullResponse); + if (!isComplete) { + const completionNote = "\n\n## Final Conclusion\nAfter multiple iterations of deep research, we've gathered significant insights about this topic. This concludes our investigation process, having reached the maximum number of research iterations. The findings presented across all iterations collectively form our comprehensive answer to the original question."; + fullResponse += completionNote; + setResponse(fullResponse); + } + setResearchComplete(true); + } else { + // Intermediate iterations: stop only if the model signalled completion. + setResearchComplete(checkIfResearchComplete(fullResponse)); + } } } catch (error) { console.error('Error during HTTP fallback:', error); setResponse(prev => prev + '\n\nError: Failed to get a response. Please try again.'); setResearchComplete(true); } finally { + fallbackActiveRef.current = false; setIsLoading(false); } }; @@ -489,7 +619,9 @@ const Ask: React.FC = ({ // Effect to continue research when response is updated useEffect(() => { if (deepResearch && response && !isLoading && !researchComplete) { - const isComplete = checkIfResearchComplete(response); + // Iteration 1 is the plan and is never "complete"; only apply the + // completion heuristic from the second iteration onward. + const isComplete = researchIteration >= 2 && checkIfResearchComplete(response); if (isComplete) { setResearchComplete(true); } else if (researchIteration > 0 && researchIteration < 5) { @@ -504,6 +636,18 @@ const Ask: React.FC = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [response, isLoading, deepResearch, researchComplete, researchIteration]); + // When a deep research process finishes, move the final answer into the + // conversation log so it is preserved as its own section. + useEffect(() => { + if (deepResearch && researchComplete && !isLoading && response && currentQuestion) { + commitTurn(currentQuestion, response, 'deep_research', researchStages); + setResearchIteration(0); + setResearchComplete(false); + fallbackActiveRef.current = false; + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [researchComplete, isLoading, researchStages]); + // Effect to update research stages when the response changes useEffect(() => { if (deepResearch && response && !isLoading) { @@ -541,26 +685,152 @@ const Ask: React.FC = ({ if (!question.trim() || isLoading) return; + if (codemapMode) { + handleCodemapAsk(); + return; + } handleConfirmAsk(); }; + // Unique set of files cited across a codemap (used as viewer tabs). + const collectCodemapFiles = (data: CodemapData): string[] => { + const files = new Set(); + data.sections.forEach((s) => + s.steps.forEach((st) => { + if (st.citation?.file_path) files.add(st.citation.file_path); + }) + ); + return Array.from(files); + }; + + // First step citation across all sections (used to auto-open the viewer). + const firstCitation = (data: CodemapData): CodemapCitation | null => { + for (const section of data.sections) { + for (const step of section.steps) { + if (step.citation?.file_path) return step.citation; + } + } + return null; + }; + + // Ask the parent to open the right-side viewer at a citation's grounded range. + const handleCitationClick = (citation: CodemapCitation, data: CodemapData | null) => { + const files = data ? collectCodemapFiles(data) : [citation.file_path]; + onOpenCodeViewer?.( + { + file_path: citation.file_path, + start_line: citation.start_line, + end_line: citation.end_line, + snippet: citation.snippet, + }, + files + ); + }; + + // Generate a codemap over the WebSocket NDJSON stream. + const handleCodemapAsk = () => { + const askedQuestion = question; + setIsLoading(true); + setQuestion(''); + setCodemapActive(true); + setCodemapData(null); + setCodemapError(null); + setCodemapQuestion(askedQuestion); + setCodemapPhaseStatus({ analyzing: 'active', initial_codemap: 'pending', diagrams: 'pending' }); + // Full-width progress while generating; the viewer auto-opens only once a + // codemap with citations arrives. + onCloseCodeViewer?.(); + + const request: CodemapRequest = { + repo_url: getRepoUrl(repoInfo), + question: askedQuestion, + type: repoInfo.type, + provider: selectedProvider, + model: isCustomSelectedModel ? customSelectedModel : selectedModel, + language, + }; + if (repoInfo?.token) request.token = repoInfo.token; + + closeWebSocket(webSocketRef.current); + let finalData: CodemapData | null = null; + + webSocketRef.current = createCodemapWebSocket(request, { + onEvent: (evt) => { + if (evt.type === 'phase') { + setCodemapPhaseStatus((prev) => ({ + ...prev, + [evt.phase]: evt.status === 'done' ? 'done' : 'active', + })); + } else if (evt.type === 'codemap') { + finalData = evt.data; + setCodemapData(evt.data); + // Auto-open the code viewer at the first citation, if any. + const first = firstCitation(evt.data); + if (first) { + onOpenCodeViewer?.( + { + file_path: first.file_path, + start_line: first.start_line, + end_line: first.end_line, + snippet: first.snippet, + }, + collectCodemapFiles(evt.data) + ); + } + } else if (evt.type === 'error') { + setCodemapError(evt.message); + } + }, + onError: () => { + setCodemapError('Connection failed. Please try again.'); + setIsLoading(false); + }, + onClose: () => { + setIsLoading(false); + setCodemapActive(false); + if (finalData) { + setConversationTurns((prev) => [ + ...prev, + { kind: 'codemap', question: askedQuestion, data: finalData! }, + ]); + setCodemapData(null); + setCodemapQuestion(''); + setCodemapPhaseStatus(IDLE_PHASES); + } + }, + }); + }; + // Handle confirm and send request const handleConfirmAsk = async () => { + // Capture the question for this turn and clear the input so the user + // can immediately type a follow-up. + const askedQuestion = question; + const askedMode: 'normal' | 'deep_research' = deepResearch ? 'deep_research' : 'normal'; + + fallbackActiveRef.current = false; setIsLoading(true); setResponse(''); + setCurrentQuestion(askedQuestion); + setQuestion(''); setResearchIteration(0); setResearchComplete(false); + setResearchStages([]); + setCurrentStageIndex(0); + // Non-codemap answers use the full width, so make sure the viewer is closed. + onCloseCodeViewer?.(); try { // Create initial message const initialMessage: Message = { role: 'user', - content: question, - mode: deepResearch ? 'deep_research' : 'normal' + content: askedQuestion, + mode: askedMode }; - // Set initial conversation history - const newHistory: Message[] = [initialMessage]; + // Include previously completed turns so the backend keeps context, + // then append the new question. + const newHistory: Message[] = [...buildHistoryFromTurns(), initialMessage]; setConversationHistory(newHistory); // Prepare request body @@ -612,20 +882,23 @@ const Ask: React.FC = ({ setResponse(prev => prev + '\n\nError: WebSocket connection failed. Falling back to HTTP...'); // Fallback to HTTP if WebSocket fails - fallbackToHttp(requestBody); + fallbackActiveRef.current = true; + fallbackToHttp(requestBody, askedQuestion, askedMode); }, // Close handler () => { + if (fallbackActiveRef.current) return; // If deep research is enabled, check if we should continue if (deepResearch) { - const isComplete = checkIfResearchComplete(fullResponse); - setResearchComplete(isComplete); - - // If not complete, start the research process - if (!isComplete) { - setResearchIteration(1); - // The continueResearch function will be triggered by the useEffect - } + // Iteration 1 is the initial research plan — always continue into + // the deeper iterations instead of letting the completion + // heuristic (which can false-positive on the plan text) stop here. + setResearchComplete(false); + setResearchIteration(1); + // The continueResearch function will be triggered by the useEffect. + } else { + // Normal mode: the answer is final, so append it to the log. + commitTurn(askedQuestion, fullResponse, 'normal'); } setIsLoading(false); @@ -639,6 +912,57 @@ const Ask: React.FC = ({ } }; + // Move a finished exchange out of the live response area and into the + // persistent conversation log. + const commitTurn = ( + turnQuestion: string, + turnResponse: string, + mode: 'normal' | 'deep_research', + stages: ResearchStage[] = [] + ) => { + if (!turnQuestion && !turnResponse) return; + + // For deep research, make sure the final answer is always present as the + // last stage (stage extraction can miss it, e.g. on forced completion). + let finalStages = stages; + if (mode === 'deep_research') { + const last = stages[stages.length - 1]; + if (turnResponse && (!last || last.content !== turnResponse)) { + finalStages = [ + ...stages, + { + title: 'Final Result', + content: turnResponse, + iteration: stages.length + 1, + type: 'conclusion', + }, + ]; + } + } + + setConversationTurns(prev => [...prev, { + kind: 'chat', + question: turnQuestion, + response: turnResponse, + mode, + researchStages: finalStages, + stageIndex: finalStages.length > 0 ? finalStages.length - 1 : 0, + }]); + setResponse(''); + setCurrentQuestion(''); + setResearchStages([]); + setCurrentStageIndex(0); + }; + + // Switch which research stage is shown for a committed deep research turn. + const setTurnStage = (turnIndex: number, stageIndex: number) => { + setConversationTurns(prev => + prev.map((turn, i) => + i === turnIndex && turn.kind === 'chat' ? { ...turn, stageIndex } : turn + ) + ); + }; + const [buttonWidth, setButtonWidth] = useState(0); const buttonRef = useRef(null); @@ -651,170 +975,196 @@ const Ask: React.FC = ({ }, [messages.ask?.askButton, isLoading]); return ( -
-
-
- {/* Model selection button */} - -
+
+ {/* Conversation log: previous turns are preserved and new results + are appended below as their own sections. */} +
+ {conversationTurns.map((turn, idx) => { + // Codemap turn: render the codemap card in its chronological position. + if (turn.kind === 'codemap') { + return ( +
+
+
+ {turn.question} +
+
+ handleCitationClick(c, turn.data)} + /> +
+ ); + } - {/* Question input */} -
-
- setQuestion(e.target.value)} - placeholder={messages.ask?.placeholder || 'What would you like to know about this codebase?'} - className="block w-full rounded-md border border-[var(--border-color)] bg-[var(--input-bg)] text-[var(--foreground)] px-5 py-3.5 text-base shadow-sm focus:border-[var(--accent-primary)] focus:ring-2 focus:ring-[var(--accent-primary)]/30 focus:outline-none transition-all" - style={{ paddingRight: `${buttonWidth + 24}px` }} - disabled={isLoading} - /> - -
+ // Chat turn (normal / deep research). + // Deep research turns keep every iteration; show the selected one + // and expose navigation. Normal turns just show their response. + const stages = turn.researchStages ?? []; + const hasStages = turn.mode === 'deep_research' && stages.length > 0; + const activeStage = hasStages + ? Math.min(turn.stageIndex ?? stages.length - 1, stages.length - 1) + : 0; + const displayContent = hasStages ? stages[activeStage].content : turn.response; + + return ( +
+ {/* User question */} +
+
+ {turn.question} +
+
- {/* Deep Research toggle */} -
-
-