diff --git a/api/config.py b/api/config.py index d6f9232a7..ab9877271 100644 --- a/api/config.py +++ b/api/config.py @@ -417,6 +417,105 @@ def get_model_config(provider="google", model=None): return result +def _should_process_file( + file_path: Path, + use_inclusion: bool, + included_dirs: list[str], + included_files: list[str], + excluded_dirs: list[str], + excluded_files: list[str], +) -> bool: + """Decide if a file passes the include/exclude rules (moved from rag.pipeline + so the tree listing and the RAG indexer share one implementation).""" + 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: + is_included = False + if included_dirs: + for included in included_dirs: + clean_included = included.removeprefix("./").rstrip("/") + if clean_included in file_path_parts: + is_included = True + break + 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 not included_dirs and not included_files: + is_included = True + return is_included + + is_excluded = False + if excluded_dirs: + for excluded in excluded_dirs: + clean_excluded = excluded.removeprefix("./").rstrip("/") + if clean_excluded in file_path_parts: + is_excluded = True + break + 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 iterate_files( + root_dir: str, + excluded_dirs: list[str] | None = None, + excluded_files: list[str] | None = None, + included_dirs: list[str] | None = None, + included_files: list[str] | None = None, +) -> list[str]: + """Walk ``root_dir`` and return repo-relative paths of the files worth + processing, using the SAME rules the RAG indexer uses so the wiki-structure + file tree matches what actually gets indexed: + + * restrict to the configured code/doc extensions; + * exclusion mode: config ``file_filters`` excluded_dirs/files UNION the + request-provided excluded_dirs/files; + * inclusion mode (when included_dirs/files are given): only those. + """ + use_inclusion = bool(included_dirs or included_files) + if use_inclusion: + inc_dirs = list(set(included_dirs or [])) + inc_files = list(set(included_files or [])) + exc_dirs: list[str] = [] + exc_files: list[str] = [] + else: + file_filters = configs.get("file_filters", {}) + exc_dir_set = set(file_filters.get("excluded_dirs", [])) + exc_file_set = set(file_filters.get("excluded_files", [])) + if excluded_dirs: + exc_dir_set.update(excluded_dirs) + if excluded_files: + exc_file_set.update(excluded_files) + exc_dirs = list(exc_dir_set) + exc_files = list(exc_file_set) + inc_dirs = [] + inc_files = [] + + extensions = tuple( + configs.get("code_extensions", []) + configs.get("doc_extensions", []) + ) + + results: list[str] = [] + for p in Path(root_dir).rglob("*"): + if not p.is_file(): + continue + if extensions and p.suffix.lower() not in extensions: + continue + if _should_process_file( + p, use_inclusion, inc_dirs, inc_files, exc_dirs, exc_files + ): + results.append(os.path.relpath(p, root_dir).replace(os.sep, "/")) + return results + + def get_embedder( is_local_ollama: bool = False, use_google_embedder: bool = False, diff --git a/api/rag/pipeline.py b/api/rag/pipeline.py index ab9a34e5c..83b63783c 100644 --- a/api/rag/pipeline.py +++ b/api/rag/pipeline.py @@ -10,6 +10,7 @@ from api.config import ( configs, get_embedder, + iterate_files, ) from api.logger import get_logger from api.repository import Repo @@ -69,85 +70,6 @@ def count_tokens( 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, @@ -182,68 +104,22 @@ def read_all_documents( 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="**/*"), - ), + # Single source of truth for which files to process (see config.iterate_files). + for relative_path in iterate_files( + path, + excluded_dirs=excluded_dirs, + excluded_files=excluded_files, + included_dirs=included_dirs, + included_files=included_files, ): + file_path = Path(path) / relative_path 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) diff --git a/api/repository.py b/api/repository.py index e56c633b8..a4b4fc0dc 100644 --- a/api/repository.py +++ b/api/repository.py @@ -5,15 +5,15 @@ 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 +from api.utils import deepwiki_root logger = get_logger(__name__) -CLONE_REPO_ROOT = os.path.join(get_adalflow_default_root_path(), "repo") +CLONE_REPO_ROOT = os.path.join(deepwiki_root(), "repo") def _get_github_file_content( diff --git a/api/routers/wiki.py b/api/routers/wiki.py index cf655e667..fd66f4b74 100644 --- a/api/routers/wiki.py +++ b/api/routers/wiki.py @@ -1,24 +1,32 @@ +import asyncio import os from datetime import datetime -from typing import Optional +from typing import Optional, Literal from fastapi import APIRouter, HTTPException, Query -from fastapi.responses import JSONResponse, Response +from fastapi.responses import JSONResponse, Response, StreamingResponse 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, + WikiTaskSummary, + WikiTaskRequest, + WikiTaskSubmitResult, + WikiTaskStatus, + TaskStatus, ) from api.services.wiki import ( delete_wiki_cache, export_wiki, + generate_repo_wiki, list_processed_projects, + list_wiki_cache, read_wiki_cache, - save_wiki_cache, + registry, + WikiTask, ) logger = get_logger(__name__) @@ -153,27 +161,6 @@ async def read_wiki( 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"), @@ -226,3 +213,90 @@ async def get_processed_projects(): status_code=500, detail="Failed to list processed projects from server cache.", ) + + +@router.post("/wiki/tasks", response_model=WikiTaskSubmitResult) +async def submit_wiki_task(request: WikiTaskRequest): + """Submit a repo for index + wiki generation (get-or-create; SPEC.md §6). + + Returns one of: created (new task), joined (an active task for the repo + already exists), or from_cache (this variant is already generated). + """ + + return await registry.submit( + WikiTask.from_wiki_request(request), async_func=generate_repo_wiki + ) + + +@router.get( + "/wiki/tasks", + response_model=list[WikiTaskSummary], +) +async def list_wiki_tasks( + status: Literal["active", "completed", None] = Query( + None, description="active | completed | (omit for completed + queued)" + ), +): + """List tasks. + + Omit `status` for the homepage list: completed projects first, then queued + tasks (by submission time) last. + """ + + active = [ + task.to_summary() + for task in sorted( + registry.active(), + key=lambda task: task.submitted_at, + ) + ] + if status == "active": + return active + completed = await list_wiki_cache() + if status == "completed": + return completed + return completed + active + + +@router.get("/wiki/tasks/{task_id}", response_model=WikiTaskStatus) +async def get_wiki_task(task_id: str): + """Single task status + progress (SPEC.md §9). 404 once the task is gone — + the frontend then falls back to the wiki cache.""" + task = registry.get(task_id) + if task is None: + raise HTTPException(status_code=404, detail="Task not found") + return task.to_status() + + +@router.get("/wiki/tasks/{task_id}/stream") +async def stream_wiki_task(task_id: str): + """SSE progress stream: `progress` events until a terminal `done`/`error`.""" + if registry.get(task_id) is None: + raise HTTPException(status_code=404, detail="Task not found") + + async def event_stream(): + while True: + task = registry.get(task_id) + if task is None: + yield 'event: error\ndata: {"error": "task no longer available"}\n\n' + return + + # we use wiki task status, so that frontend could show the current processing pages. + payload = task.to_status().model_dump_json() + if task.status == TaskStatus.COMPLETED: + yield f"event: done\ndata: {payload}\n\n" + return + if task.status == TaskStatus.FAILED: + yield f"event: error\ndata: {payload}\n\n" + return + yield f"event: progress\ndata: {payload}\n\n" + await asyncio.sleep(1) + + return StreamingResponse( + event_stream(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache, no-transform", + "X-Accel-Buffering": "no", + }, + ) diff --git a/api/schemas/__init__.py b/api/schemas/__init__.py index b00c85aa8..aaa136b00 100644 --- a/api/schemas/__init__.py +++ b/api/schemas/__init__.py @@ -1,5 +1,5 @@ from api.schemas.auth import AuthorizationConfig -from api.schemas.chat import ChatCompletionRequest +from api.schemas.chat import ChatCompletionRequest, ChatMessage from api.schemas.codemap import ( CodeMap, CodeMapCitation, @@ -13,7 +13,13 @@ ModelConfig, Provider, ) -from api.schemas.repo import RepoInfo, RepoPrepareRequest +from api.schemas.repo import ( + RepoInfo, + RepoPrepareRequest, + WikiTaskRequest, + WikiTaskSubmitResult, + TaskStatus, +) from api.schemas.wiki import ( ProcessedProjectEntry, WikiCacheData, @@ -22,11 +28,14 @@ WikiPage, WikiSection, WikiStructureModel, + WikiTaskSummary, + WikiTaskStatus, ) __all__ = [ "AuthorizationConfig", "ChatCompletionRequest", + "ChatMessage", "CodeMap", "CodeMapCitation", "CodeMapRequest", @@ -44,6 +53,11 @@ "WikiPage", "WikiSection", "WikiStructureModel", + "WikiTaskRequest", + "WikiTaskSummary", + "WikiTaskStatus", + "WikiTaskSubmitResult", + "TaskStatus", "aload", "asave", ] diff --git a/api/schemas/base.py b/api/schemas/base.py index b9632a023..ea99ea8e8 100644 --- a/api/schemas/base.py +++ b/api/schemas/base.py @@ -8,11 +8,11 @@ class RepoRequestBase(BaseModel): repo_url: str = Field(..., description="URL or local path of the repository") - type: RepoType | None = Field("github", description="Repository type") + type: RepoType = 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") + language: str = 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", diff --git a/api/schemas/repo.py b/api/schemas/repo.py index a47dcd0f6..757140072 100644 --- a/api/schemas/repo.py +++ b/api/schemas/repo.py @@ -1,4 +1,5 @@ -from pydantic import BaseModel +from enum import Enum +from pydantic import BaseModel, Field, field_validator from api.schemas.base import RepoRequestBase @@ -7,6 +8,48 @@ class RepoPrepareRequest(RepoRequestBase): """Request body for POST /repo/prepare (index warming). No chat messages.""" +class WikiTaskRequest(RepoRequestBase): + """Request body for POST /wiki/tasks, submitting a wiki-generation task.""" + + owner: str + repo: str + comprehensive: bool = Field(True, description="Comprehensive vs concise wiki") + + @property + def repo_key(self) -> str: + return f"{self.type}_{self.owner}_{self.repo}" + + +class TaskStatus(str, Enum): + PENDING = "pending" + INDEXING = "indexing" + DETERMINING_STRUCTURE = "determining_structure" + GENERATING = "generating" + COMPLETED = "completed" + FAILED = "failed" + + def is_terminal(self): + return self in (TaskStatus.COMPLETED, TaskStatus.FAILED) + + +class WikiTaskSubmitResult(BaseModel): + task_id: str + status: TaskStatus | str + created: bool = False + joined: bool = False + from_cache: bool = False + + @field_validator( + "status", + mode="before", + ) + @classmethod + def _status_validate(cls, value): + if isinstance(value, str): + return TaskStatus(value.lower()) + return value + + class RepoInfo(BaseModel): owner: str repo: str diff --git a/api/schemas/wiki.py b/api/schemas/wiki.py index 8ffb6bfa2..baf86f0ca 100644 --- a/api/schemas/wiki.py +++ b/api/schemas/wiki.py @@ -1,8 +1,8 @@ from typing import Literal -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field, computed_field -from api.schemas.repo import RepoInfo +from api.schemas.repo import RepoInfo, TaskStatus class WikiPage(BaseModel): @@ -88,3 +88,37 @@ class ProcessedProjectEntry(BaseModel): repo_type: str # Renamed from type to repo_type for clarity with existing models submittedAt: int # Timestamp language: str # Extracted from filename + + +class WikiTaskSummary(BaseModel): + """Client-facing status of a wiki-generation task (SPEC.md §9). + + Serialization target for WikiTask.to_status(); never carries the token. + """ + + # Canonical field is snake_case `submitted_at` (also the serialized/wire + # name). `submittedAt` is accepted on input via a validation alias for + # backward compatibility during the transition; populate_by_name keeps the + # field name usable too. + model_config = ConfigDict(populate_by_name=True) + + id: str + owner: str + repo: str + repo_type: str + language: str + status: TaskStatus + pages_done: int = Field(default=0, ge=0) + pages_total: int = Field(default=0, ge=0) + current_page_ids: list[str] = Field(default_factory=list) + error: str | None = None + submitted_at: int = Field(..., ge=0, validation_alias="submittedAt") + + @computed_field + @property + def name(self) -> str: + return f"{self.owner}/{self.repo}" + + +class WikiTaskStatus(WikiTaskSummary): + wiki_structure: WikiStructureModel | None = None diff --git a/api/services/research.py b/api/services/research.py index 66a709d4e..55d88eec4 100644 --- a/api/services/research.py +++ b/api/services/research.py @@ -13,7 +13,8 @@ ) from api.rag import RAG, count_tokens, repo_index_exist from api.repository import Repo, get_repo_content -from api.schemas import ChatCompletionRequest, RepoPrepareRequest +from api.schemas.base import RepoRequestBase +from api.schemas import ChatCompletionRequest logger = get_logger(__name__) @@ -27,7 +28,7 @@ class RepoNotIndexedError(ValueError): async def prepare_repo_index( - request: ChatCompletionRequest | RepoPrepareRequest, + request: RepoRequestBase, ) -> RAG: rag = await asyncio.to_thread( RAG, diff --git a/api/services/wiki/__init__.py b/api/services/wiki/__init__.py new file mode 100644 index 000000000..26a91733d --- /dev/null +++ b/api/services/wiki/__init__.py @@ -0,0 +1,30 @@ +from api.services.wiki.io import ( + export_wiki, + save_wiki_cache, + get_wiki_cache_path, + wiki_cache_exists, + read_wiki_cache, + delete_wiki_cache, + list_wiki_cache, + list_processed_projects, +) + +from api.services.wiki.tasks import ( + WikiTask, + registry, + generate_repo_wiki, +) + +__all__ = [ + "export_wiki", + "save_wiki_cache", + "get_wiki_cache_path", + "wiki_cache_exists", + "read_wiki_cache", + "delete_wiki_cache", + "list_wiki_cache", + "list_processed_projects", + "WikiTask", + "registry", + "generate_repo_wiki", +] diff --git a/api/services/wiki/content.py b/api/services/wiki/content.py new file mode 100644 index 000000000..f14bb5705 --- /dev/null +++ b/api/services/wiki/content.py @@ -0,0 +1,160 @@ +"""Citation/link post-processing for LLM-generated wiki markdown. + +Python port of the frontend `postProcessWikiContent` (src/app/[owner]/[repo]/ +page.tsx). Turns the various empty-parenthesis citation forms the model emits +into real repository links, and normalizes the "Relevant source files" +
block. Pure functions — unit-tested in test_wiki_content.py. + +""" + +import re +from dataclasses import dataclass +from urllib.parse import urlparse + + +@dataclass +class RepoUrlContext: + """Everything needed to turn a repo-relative path into a web URL.""" + + type: str # 'local' | 'github' | 'gitlab' | 'bitbucket' + repo_url: str | None + default_branch: str + + +def generate_file_url(file_path: str, ctx: RepoUrlContext) -> str: + """Build a host-specific web URL for a repository-relative file path. + + Returns the bare path unchanged for local repos or unresolved hosts. + """ + if ctx.type == "local" or not ctx.repo_url: + return file_path + try: + hostname = (urlparse(ctx.repo_url).hostname or "").lower() + except ValueError: + return file_path + if "github" in hostname: + return f"{ctx.repo_url}/blob/{ctx.default_branch}/{file_path}" + if "gitlab" in hostname: + return f"{ctx.repo_url}/-/blob/{ctx.default_branch}/{file_path}" + if "bitbucket" in hostname: + return f"{ctx.repo_url}/src/{ctx.default_branch}/{file_path}" + return file_path + + +def _escape_label(s: str) -> str: + """Backslash-escape '[' / ']' so paths render as plain Markdown link labels.""" + return re.sub(r"([\[\]])", r"\\\1", s) + + +def _line_anchor(url: str, start: str | None, end: str | None) -> str: + """Host-specific line anchor for an already-resolved file URL.""" + if not start: + return "" + try: + hostname = (urlparse(url).hostname or "").lower() + except ValueError: + hostname = "" + if "github" in hostname: + return f"#L{start}-L{end}" if end else f"#L{start}" + if "gitlab" in hostname: + return f"#L{start}-{end}" if end else f"#L{start}" + if "bitbucket" in hostname: + return f"#lines-{start}:{end}" if end else f"#lines-{start}" + return "" + + +def _citation_link( + path: str, start: str | None, end: str | None, ctx: RepoUrlContext +) -> str | None: + """Resolve `path[:start[-end]]` to a Markdown link, or None if unresolvable.""" + url = generate_file_url(path, ctx) + if url == path: # local repo / unresolved host -> no web URL + return None + line_part = (f":{start}-{end}" if end else f":{start}") if start else "" + anchor = _line_anchor(url, start, end) + return f"[{_escape_label(path)}{line_part}]({url}{anchor})" + + +_DETAILS_RE = re.compile( + r"
\s*\s*Relevant source files\s*[\s\S]*?
", + re.IGNORECASE, +) +# 3. Generic: any `[repo/path.ext:line]()` (files not in filePaths). +_GENERIC_RE = re.compile(r"\[([^\[\]\s()]+?\.[A-Za-z0-9]+)(?::(\d+)(?:-(\d+))?)?\]\(\)") +# 4. `[Sources: path:line]()` — prefix inside the bracket and/or a bare filename. +_PREFIXED_RE = re.compile( + r"\[(Sources?|Source):\s*([^\[\]\s():]+?)(?::(\d+)(?:-(\d+))?)?\]\(\)", + re.IGNORECASE, +) +# 5. Redundant empty "()" left immediately after a completed link. +_STRAY_PARENS_RE = re.compile(r"(\]\([^)\s]+\))\(\)") + + +def post_process_wiki_content( + content: str, file_paths: list[str], ctx: RepoUrlContext +) -> str: + """Normalize the
block and resolve the citation forms into links.""" + processed = content + + # 1. Rebuild the
block from the known file list. + if file_paths: + links = "\n".join( + f"- [{_escape_label(p)}]({generate_file_url(p, ctx)})" for p in file_paths + ) + details_block = ( + "
\n" + "Relevant source files\n\n" + "The following files were used as context for generating this wiki page:\n\n" + f"{links}\n" + "
" + ) + if _DETAILS_RE.search(processed): + processed = _DETAILS_RE.sub(lambda _m: details_block, processed) + else: + processed = f"{details_block}\n\n{processed}" + + # 2. Resolve empty citations against the known filePaths (longest first). + if file_paths: + alternation = "|".join( + re.escape(p) for p in sorted(file_paths, key=len, reverse=True) + ) + citation_re = re.compile( + r"\[(" + alternation + r")(?::(\d+)(?:-(\d+))?)?\]\(\)" + ) + + def _repl_known(m: re.Match) -> str: + link = _citation_link(m.group(1), m.group(2), m.group(3), ctx) + return link if link is not None else m.group(0) + + processed = citation_re.sub(_repl_known, processed) + + # 3. Resolve any remaining file-path-looking empty citations. + def _repl_generic(m: re.Match) -> str: + link = _citation_link(m.group(1), m.group(2), m.group(3), ctx) + return link if link is not None else m.group(0) + + processed = _GENERIC_RE.sub(_repl_generic, processed) + + # 4. Resolve `[Sources: barename:line]()` via basename lookup. + if file_paths: + by_basename: dict[str, str] = {} + for p in file_paths: + base = p.rsplit("/", 1)[-1] + by_basename.setdefault(base, p) + + def _repl_prefixed(m: re.Match) -> str: + prefix, token, start, end = m.group(1), m.group(2), m.group(3), m.group(4) + full_path = token if "/" in token else by_basename.get(token) + if not full_path: + return m.group(0) + link = _citation_link(full_path, start, end, ctx) + if link is None: + return m.group(0) + return f"{prefix}: {link}" + + processed = _PREFIXED_RE.sub(_repl_prefixed, processed) + + # 5. Strip a redundant empty "()" after a completed link. + processed = _STRAY_PARENS_RE.sub(r"\1", processed) + + return processed diff --git a/api/services/wiki.py b/api/services/wiki/io.py similarity index 68% rename from api/services/wiki.py rename to api/services/wiki/io.py index 0d7ae434b..653e070c8 100644 --- a/api/services/wiki.py +++ b/api/services/wiki/io.py @@ -6,39 +6,42 @@ from api.logger import get_logger from api.schemas import ( + TaskStatus, ProcessedProjectEntry, WikiCacheData, - WikiCacheRequest, + WikiTaskSummary, WikiPage, aload, asave, ) +from api.utils import deepwiki_root 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") +WIKI_CACHE_DIR = os.path.join(deepwiki_root(), "wikicache") os.makedirs(WIKI_CACHE_DIR, exist_ok=True) +WIKI_PREFIX = "deepwiki_cache_" 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" + filename = f"{WIKI_PREFIX}{repo_type}_{owner}_{repo}_{language}.json" return os.path.join(WIKI_CACHE_DIR, filename) +def wiki_cache_exists(owner: str, repo: str, repo_type: str, language: str) -> bool: + return os.path.exists( + get_wiki_cache_path(owner, repo=repo, repo_type=repo_type, language=language) + ) + + 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): + if not wiki_cache_exists(owner, repo, repo_type, language): return None + cache_path = get_wiki_cache_path(owner, repo, repo_type, language) try: return await aload(WikiCacheData, cache_path, encoding="utf-8") except Exception: @@ -46,20 +49,18 @@ async def read_wiki_cache( return None -async def save_wiki_cache(data: WikiCacheRequest) -> bool: +async def save_wiki_cache( + owner: str, repo: str, repo_type: str, language: str, wiki_cache: WikiCacheData +) -> 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 + owner=owner, + repo=repo, + repo_type=repo_type, + language=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 @@ -88,9 +89,7 @@ async def delete_wiki_cache(owner: str, repo: str, repo_type: str, language: str return True -async def list_processed_projects() -> list[ProcessedProjectEntry]: - project_entries: list[ProcessedProjectEntry] = [] - +async def list_wiki_cache() -> list[WikiTaskSummary]: if not os.path.exists(WIKI_CACHE_DIR): logger.info( f"Cache directory {WIKI_CACHE_DIR} not found. Returning empty list." @@ -98,45 +97,49 @@ async def list_processed_projects() -> list[ProcessedProjectEntry]: 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("_") + entries = [] + for filename in await asyncio.to_thread(os.listdir, WIKI_CACHE_DIR): + if not (filename.startswith(WIKI_PREFIX) and filename.endswith(".json")): + continue + file_path = os.path.join(WIKI_CACHE_DIR, filename) + try: + stats = await asyncio.to_thread(os.stat, file_path) + repo_type, owner, *repo, language = ( + os.path.splitext(filename)[0].removeprefix(WIKI_PREFIX).split("_") + ) + entries.append( + WikiTaskSummary( + id=filename, + owner=owner, + repo="_".join(repo), + repo_type=repo_type, + language=language, + submitted_at=int(stats.st_mtime * 1000), + status=TaskStatus.COMPLETED, ) - # 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 + ) + except Exception: + logger.exception("Error processing file %s", file_path, exc_info=True) + + logger.info("Found %d processed project entries.", len(entries)) + return entries + + +async def list_processed_projects() -> list[ProcessedProjectEntry]: + project_entries: list[ProcessedProjectEntry] = [ + ProcessedProjectEntry( + id=wiki.id, + owner=wiki.owner, + repo=wiki.repo, + name=wiki.name, + repo_type=wiki.repo_type, + submittedAt=wiki.submitted_at, + language=wiki.language, + ) + for wiki in await list_wiki_cache() + ] project_entries.sort(key=lambda p: p.submittedAt, reverse=True) - logger.info(f"Found {len(project_entries)} processed project entries.") return project_entries diff --git a/api/services/wiki/prompts.py b/api/services/wiki/prompts.py new file mode 100644 index 000000000..f62daed82 --- /dev/null +++ b/api/services/wiki/prompts.py @@ -0,0 +1,260 @@ +"""Prompt builders for backend wiki generation. + +Ported verbatim from the frontend page.tsx prompts (page + structure), so the +backend produces the same output. +""" + +LANGUAGE_NAMES: dict[str, str] = { + "en": "English", + "ja": "Japanese (日本語)", + "zh": "Mandarin Chinese (中文)", + "zh-tw": "Traditional Chinese (繁體中文)", + "es": "Spanish (Español)", + "kr": "Korean (한국어)", + "vi": "Vietnamese (Tiếng Việt)", + "pt-br": "Brazilian Portuguese (Português Brasileiro)", + "fr": "Français (French)", + "ru": "Русский (Russian)", +} + + +def language_name(language: str) -> str: + return LANGUAGE_NAMES.get(language, "English") + + +def build_page_prompt(title: str, file_links: str, language: str) -> str: + """Prompt for generating a single wiki page (port of generatePageContent). + + `file_links` is the pre-built markdown list of ``- [path](url)`` lines that + seeds the required
block. + """ + return f"""You are an expert technical writer and software architect. +Your task is to generate a comprehensive and accurate technical wiki page in Markdown format about a specific feature, system, or module within a given software project. + +You will be given: +1. The "[WIKI_PAGE_TOPIC]" for the page you need to create. +2. A list of "[RELEVANT_SOURCE_FILES]" from the project that you MUST use as the sole basis for the content. You have access to the full content of these files. You MUST use AT LEAST 5 relevant source files for comprehensive coverage - if fewer are provided, search for additional related files in the codebase. + +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. +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 + +The following files were used as context for generating this wiki page: + +{file_links} + +
+ +Immediately after the `
` block, the main title of the page should be a H1 Markdown heading: `# {title}`. + +Based ONLY on the content of the `[RELEVANT_SOURCE_FILES]`: + +1. **Introduction:** Start with a concise introduction (1-2 paragraphs) explaining the purpose, scope, and high-level overview of "{title}" within the context of the overall project. If relevant, and if information is available in the provided files, link to other potential wiki pages using the format `[Link Text](#page-anchor-or-id)`. + +2. **Detailed Sections:** Break down "{title}" into logical sections using H2 (`##`) and H3 (`###`) Markdown headings. For each section: + * Explain the architecture, components, data flow, or logic relevant to the section's focus, as evidenced in the source files. + * Identify key functions, classes, data structures, API endpoints, or configuration elements pertinent to that section. + +3. **Mermaid Diagrams:** + * EXTENSIVELY use Mermaid diagrams (e.g., `flowchart TD`, `sequenceDiagram`, `classDiagram`, `erDiagram`, `graph TD`) to visually represent architectures, flows, relationships, and schemas found in the source files. + * Ensure diagrams are accurate and directly derived from information in the `[RELEVANT_SOURCE_FILES]`. + * Provide a brief explanation before or after each diagram to give context. + * CRITICAL: All diagrams MUST follow strict vertical orientation: + - Use "graph TD" (top-down) directive for flow diagrams + - NEVER use "graph LR" (left-right) + - Maximum node width should be 3-4 words + - For sequence diagrams: + - Start with "sequenceDiagram" directive on its own line + - Define ALL participants at the beginning using "participant" keyword + - Optionally specify participant types: actor, boundary, control, entity, database, collections, queue + - Use descriptive but concise participant names, or use aliases: "participant A as Alice" + - Use the correct Mermaid arrow syntax (8 types available): + - -> solid line without arrow (rarely used) + - --> dotted line without arrow (rarely used) + - ->> solid line with arrowhead (most common for requests/calls) + - -->> dotted line with arrowhead (most common for responses/returns) + - ->x solid line with X at end (failed/error message) + - -->x dotted line with X at end (failed/error response) + - -) solid line with open arrow (async message, fire-and-forget) + - --) dotted line with open arrow (async response) + - Examples: A->>B: Request, B-->>A: Response, A->xB: Error, A-)B: Async event + - Use +/- suffix for activation boxes: A->>+B: Start (activates B), B-->>-A: End (deactivates B) + - Group related participants using "box": box GroupName ... end + - Use structural elements for complex flows: + - loop LoopText ... end (for iterations) + - alt ConditionText ... else ... end (for conditionals) + - opt OptionalText ... end (for optional flows) + - par ParallelText ... and ... end (for parallel actions) + - critical CriticalText ... option ... end (for critical regions) + - break BreakText ... end (for breaking flows/exceptions) + - Add notes for clarification: "Note over A,B: Description", "Note right of A: Detail" + - Use autonumber directive to add sequence numbers to messages + - NEVER use flowchart-style labels like A--|label|-->B. Always use a colon for labels: A->>B: My Label + +4. **Tables:** + * Use Markdown tables to summarize information such as: + * Key features or components and their descriptions. + * API endpoint parameters, types, and descriptions. + * Configuration options, their types, and default values. + * Data model fields, types, constraints, and descriptions. + +5. **Code Snippets (ENTIRELY OPTIONAL):** + * Include short, relevant code snippets (e.g., Python, Java, JavaScript, SQL, JSON, YAML) directly from the `[RELEVANT_SOURCE_FILES]` to illustrate key implementation details, data structures, or configurations. + * Ensure snippets are well-formatted within Markdown code blocks with appropriate language identifiers. + +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 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. + +7. **Technical Accuracy:** All information must be derived SOLELY from the `[RELEVANT_SOURCE_FILES]`. Do not infer, invent, or use external knowledge about similar systems or common practices unless it's directly supported by the provided code. If information is not present in the provided files, do not include it or explicitly state its absence if crucial to the topic. + +8. **Clarity and Conciseness:** Use clear, professional, and concise technical language suitable for other developers working on or learning about the project. Avoid unnecessary jargon, but use correct technical terms where appropriate. + +9. **Conclusion/Summary:** End with a brief summary paragraph if appropriate for "{title}", reiterating the key aspects covered and their significance within the project. + +IMPORTANT: Generate the content in {language_name(language)} language. + +Remember: +- Ground every claim in the provided source files. +- Prioritize accuracy and direct representation of the code's functionality and structure. +- Structure the document logically for easy understanding by other developers. +""" + + +_COMPREHENSIVE_STRUCTURE = """ +Create a structured wiki with the following main sections: +- Overview (general information about the project) +- System Architecture (how the system is designed) +- Core Features (key functionality) +- Data Management/Flow: If applicable, how data is stored, processed, accessed, and managed (e.g., database schema, data pipelines, state management). +- Frontend Components (UI elements, if applicable.) +- Backend Systems (server-side components) +- Model Integration (AI model connections) +- Deployment/Infrastructure (how to deploy, what's the infrastructure like) +- Extensibility and Customization: If the project architecture supports it, explain how to extend or customize its functionality (e.g., plugins, theming, custom modules, hooks). + +Each section should contain relevant pages. For example, the "Frontend Components" section might include pages for "Home Page", "Repository Wiki Page", "Ask Component", etc. + +Return your analysis in the following XML format: + + + [Overall title for the wiki] + [Brief description of the repository] + +
+ [Section title] + + page-1 + page-2 + + + section-2 + +
+ +
+ + + [Page title] + [Brief description of what this page will cover] + high|medium|low + + [Path to a relevant file] + + + + page-2 + + + section-1 + + + +
+""" + +_CONCISE_STRUCTURE = """ +Return your analysis in the following XML format: + + + [Overall title for the wiki] + [Brief description of the repository] + + + [Page title] + [Brief description of what this page will cover] + high|medium|low + + [Path to a relevant file] + + + + page-2 + + + + + + +""" + + +def build_structure_prompt( + owner: str, + repo: str, + file_tree: str, + readme: str, + comprehensive: bool, + language: str, +) -> str: + """Prompt for determining the wiki structure (port of determineWikiStructure).""" + structure_format = _COMPREHENSIVE_STRUCTURE if comprehensive else _CONCISE_STRUCTURE + page_count = "8-12" if comprehensive else "4-6" + kind = "comprehensive" if comprehensive else "concise" + return f"""Analyze this GitHub repository {owner}/{repo} and create a wiki structure for it. + +1. The complete file tree of the project: + +{file_tree} + + +2. The README file of the project: + +{readme} + + +I want to create a wiki for this repository. Determine the most logical structure for a wiki based on the repository's content. + +IMPORTANT: The wiki content will be generated in {language_name(language)} language. + +When designing the wiki structure, include pages that would benefit from visual diagrams, such as: +- Architecture overviews +- Data flow descriptions +- Component relationships +- Process workflows +- State machines +- Class hierarchies +{structure_format} +IMPORTANT FORMATTING INSTRUCTIONS: +- Return ONLY the valid XML structure specified above +- DO NOT wrap the XML in markdown code blocks (no ``` or ```xml) +- DO NOT include any explanation text before or after the XML +- Ensure the XML is properly formatted and valid +- Start directly with and end with + +IMPORTANT: +1. Create {page_count} pages that would make a {kind} wiki for this repository +2. Each page should focus on a specific aspect of the codebase (e.g., architecture, key features, setup) +3. The relevant_files should be actual files from the repository that would be used to generate that page +4. Return ONLY valid XML with the structure specified above, with no markdown code block delimiters""" diff --git a/api/services/wiki/structure.py b/api/services/wiki/structure.py new file mode 100644 index 000000000..af1fb70c7 --- /dev/null +++ b/api/services/wiki/structure.py @@ -0,0 +1,194 @@ +"""Helpers for `determine_structure`: read the cloned repo's file tree, detect +its default branch, and parse the LLM's XML wiki-structure response. + +Ported from the frontend fetchRepositoryStructure + determineWikiStructure +(clone-walk instead of provider REST APIs). +""" + +import os +import re +import subprocess +import xml.etree.ElementTree as ET + +from api.logger import get_logger +from api.config import iterate_files +from api.schemas import WikiPage, WikiSection, WikiStructureModel + +logger = get_logger(__name__) + + +def read_repo_file_tree( + path: str, + included_files: list[str] | None = None, + included_dirs: list[str] | None = None, + excluded_files: list[str] | None = None, + excluded_dirs: list[str] | None = None, +) -> tuple[list[str], str]: + """Walk a cloned/local repo dir → (file list, README.md text).""" + + files = iterate_files( + root_dir=path, + included_files=included_files, + included_dirs=included_dirs, + excluded_dirs=excluded_dirs, + excluded_files=excluded_files, + ) + + readme = "" + + for file in sorted(files, key=lambda x: len(x)): + if os.path.splitext(file)[0].lower().endswith("readme"): + try: + with open(os.path.join(path, file), encoding="utf-8") as f: + readme = f.read() + except OSError as e: + logger.warning("Could not read README.md: %s", e) + readme = "" + break + return files, readme + + +def detect_default_branch(path: str) -> str: + """Return the checked-out branch of a local git repo, or 'main' if unknown.""" + try: + result = subprocess.run( + ["git", "-C", path, "rev-parse", "--abbrev-ref", "HEAD"], + capture_output=True, + text=True, + check=True, + ) + return result.stdout.strip() or "main" + except (subprocess.SubprocessError, OSError): + return "main" + + +def _normalize_importance(value: str | None) -> str: + v = (value or "").strip().lower() + return v if v in ("high", "medium", "low") else "medium" + + +def _page_from_element(el: ET.Element, index: int) -> WikiPage: + return WikiPage( + id=el.get("id") or f"page-{index + 1}", + title=(el.findtext("title") or "").strip(), + content="", + filePaths=[ + e.text.strip() for e in el.iter("file_path") if e.text and e.text.strip() + ], + importance=_normalize_importance(el.findtext("importance")), + relatedPages=[ + e.text.strip() for e in el.iter("related") if e.text and e.text.strip() + ], + ) + + +def _pages_via_regex(xml_text: str) -> list[WikiPage]: + """Fallback when strict XML parsing fails or yields no pages.""" + pages: list[WikiPage] = [] + for i, block in enumerate(re.findall(r"", xml_text)): + pid = re.search(r'([\s\S]*?)", block) + importance = re.search(r"([\s\S]*?)", block) + file_paths = [ + m.strip() + for m in re.findall(r"([\s\S]*?)", block) + if m.strip() + ] + related = [ + m.strip() + for m in re.findall(r"([\s\S]*?)", block) + if m.strip() + ] + pages.append( + WikiPage( + id=pid.group(1) if pid else f"page-{i + 1}", + title=title.group(1).strip() if title else "", + content="", + filePaths=file_paths, + importance=_normalize_importance( + importance.group(1) if importance else None + ), + relatedPages=related, + ) + ) + return pages + + +def _parse_sections(root: ET.Element) -> tuple[list[WikiSection], list[str]]: + sections: list[WikiSection] = [] + referenced: set[str] = set() + for i, el in enumerate(root.iter("section")): + sid = el.get("id") or f"section-{i + 1}" + subs = [ + e.text.strip() for e in el.iter("section_ref") if e.text and e.text.strip() + ] + sections.append( + WikiSection( + id=sid, + title=(el.findtext("title") or "").strip(), + pages=[ + e.text.strip() + for e in el.iter("page_ref") + if e.text and e.text.strip() + ], + subsections=subs or None, + ) + ) + referenced.update(subs) + root_sections = [s.id for s in sections if s.id not in referenced] + return sections, root_sections + + +def parse_wiki_structure(text: str, comprehensive: bool) -> WikiStructureModel: + """Parse the LLM's XML response into a WikiStructureModel. + + Robust against the model's usual malformations: strips markdown fences and + control chars, escapes bare ``&`` (a single one breaks strict XML), and + falls back to regex page extraction if strict parsing fails or finds no + pages. Raises ValueError if no block is present at all. + """ + text = re.sub(r"^```(?:xml)?\s*", "", text.strip(), flags=re.IGNORECASE) + text = re.sub(r"```\s*$", "", text) + + match = re.search(r"[\s\S]*?", text) + if not match: + raise ValueError("No valid XML found in response") + xml_text = match.group(0) + + # Strip control chars, then escape bare '&' that are not valid XML entities. + xml_text = re.sub(r"[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]", "", xml_text) + xml_text = re.sub( + r"&(?!(?:amp|lt|gt|quot|apos|#\d+|#x[0-9a-fA-F]+);)", "&", xml_text + ) + + root: ET.Element | None = None + try: + root = ET.fromstring(xml_text) + except ET.ParseError as e: + logger.warning("Strict XML parse failed, using regex fallback: %s", e) + + title = (root.findtext("title") if root is not None else None) or "" + description = (root.findtext("description") if root is not None else None) or "" + + pages = ( + [_page_from_element(el, i) for i, el in enumerate(root.iter("page"))] + if root is not None + else [] + ) + if not pages: + logger.warning("XML parsing yielded no pages; using regex fallback") + pages = _pages_via_regex(xml_text) + + sections: list[WikiSection] = [] + root_sections: list[str] = [] + if comprehensive and root is not None: + sections, root_sections = _parse_sections(root) + + return WikiStructureModel( + id="wiki", + title=title.strip(), + description=description.strip(), + pages=pages, + sections=sections, + rootSections=root_sections, + ) diff --git a/api/services/wiki/tasks.py b/api/services/wiki/tasks.py new file mode 100644 index 000000000..351bec138 --- /dev/null +++ b/api/services/wiki/tasks.py @@ -0,0 +1,404 @@ +import os +import re +import asyncio +from typing import Callable, Any +from collections.abc import Coroutine +import time +from pydantic import BaseModel, Field, computed_field, ConfigDict + +from api.utils import deepwiki_root +from api.schemas import ( + ChatMessage, + ChatCompletionRequest, + WikiCacheData, + WikiTaskRequest, + WikiStructureModel, + WikiTaskStatus, + WikiTaskSubmitResult, + WikiTaskSummary, + WikiPage, + RepoInfo, + TaskStatus, +) +from api.repository import Repo +from api.rag import repo_index_exist +from api.services.research import prepare_repo_index, research_chat +from api.services.wiki import ( + save_wiki_cache, + wiki_cache_exists, +) +from api.services.wiki.content import ( + RepoUrlContext, + generate_file_url, + post_process_wiki_content, +) + +from api.services.wiki.structure import ( + detect_default_branch, + read_repo_file_tree, + parse_wiki_structure, +) + +from api.services.wiki.prompts import ( + build_page_prompt, + build_structure_prompt, +) + +from api.logger import get_logger + +logger = get_logger(__name__) + + +def _env_int(name, default: int) -> int: + try: + return int(os.environ.get(name, default)) + except (TypeError, ValueError): + return default + + +WIKI_CACHE_DIR = os.path.join(deepwiki_root(), "wikicache") +os.makedirs(WIKI_CACHE_DIR, exist_ok=True) +# Concurrent repo tasks (the "pool size"). Default: half the CPU cores, min 1. +MAX_CONCURRENT_WIKI_TASKS = _env_int( + "DEEPWIKI_MAX_CONCURRENT_WIKI_TASKS", max(1, (os.cpu_count() or 2) // 2) +) +# Concurrent page generations within a single task (1 == sequential, as today). +WIKI_PAGE_CONCURRENCY = _env_int("DEEPWIKI_WIKI_PAGE_CONCURRENCY", 1) +# Retries per page for transient errors before falling back to an error placeholder. +WIKI_PAGE_RETRIES = _env_int("DEEPWIKI_WIKI_PAGE_RETRIES", 2) +# How long a terminal (COMPLETED/FAILED) task lingers in the registry. +WIKI_TASK_TTL_SECONDS = _env_int("DEEPWIKI_WIKI_TASK_TTL_SECONDS", 300) + + +class WikiTask(BaseModel): + """In-memory runtime state for one repo's generation task.""" + + model_config = ConfigDict(arbitrary_types_allowed=True) + + request: WikiTaskRequest + status: TaskStatus = TaskStatus.PENDING + pages_done: int = 0 + current_page_ids: list[str] = Field(default_factory=list) + wiki_structure: WikiStructureModel | None = None + default_branch: str = "main" # set by determine_structure; used for file URLs + error: str | None = None + submitted_at: int = Field(default_factory=lambda: int(time.time() * 1000)) + task: asyncio.Task | None = Field(default=None, repr=False) + + @computed_field + @property + def pages_total(self) -> int: + if self.wiki_structure is not None: + return len(self.wiki_structure.pages) + return 0 + + @classmethod + def from_wiki_request(cls, request: WikiTaskRequest) -> "WikiTask": + return cls( + request=request, + ) + + @property + def repo_key(self) -> str: + return self.request.repo_key + + def to_status(self) -> WikiTaskStatus: + """Client-facing status (SPEC.md §9). Never exposes the token.""" + r = self.request + return WikiTaskStatus( + id=self.repo_key, + owner=r.owner, + repo=r.repo, + repo_type=r.type, + language=r.language, + status=self.status, + pages_done=self.pages_done, + pages_total=self.pages_total, + current_page_ids=self.current_page_ids, + wiki_structure=self.wiki_structure, + error=self.error, + submitted_at=self.submitted_at, + ) + + def to_summary(self) -> WikiTaskSummary: + r = self.request + return WikiTaskSummary( + id=self.repo_key, + owner=r.owner, + repo=r.repo, + repo_type=r.type, + language=r.language, + status=self.status, + pages_done=self.pages_done, + pages_total=self.pages_total, + current_page_ids=self.current_page_ids, + error=self.error, + submitted_at=self.submitted_at, + ) + + +class TaskRegistry: + _tasks: dict[str, WikiTask] + _lock: asyncio.Lock + _semaphore: asyncio.Semaphore + + def __init__(self, max_concurrent: int = MAX_CONCURRENT_WIKI_TASKS): + self._tasks = {} + self._lock = asyncio.Lock() + self._semaphore = asyncio.Semaphore(max_concurrent) + + def get(self, id: str) -> WikiTask | None: + return self._tasks.get(id) + + def active(self) -> list[WikiTask]: + return [w for w in self._tasks.values() if not w.status.is_terminal()] + + async def remove(self, id: str) -> WikiTask | None: + async with self._lock: + task = self._tasks.pop(id, None) + return task + + async def submit( + self, + task: WikiTask, + async_func: Callable[[WikiTask], Coroutine[Any, Any, bool]], + ) -> WikiTaskSubmitResult: + key = task.repo_key + async with self._lock: + exist_task = self.get(key) + if exist_task and not exist_task.status.is_terminal(): + return WikiTaskSubmitResult( + task_id=key, + status=exist_task.status, + joined=True, + ) + + if wiki_cache_exists( + owner=task.request.owner, + repo=task.request.repo, + repo_type=task.request.type, + language=task.request.language, + ): + return WikiTaskSubmitResult( + task_id=key, + status=TaskStatus.COMPLETED, + from_cache=True, + ) + + task.task = asyncio.create_task(self._run(task, async_func)) + self._tasks[key] = task + return WikiTaskSubmitResult(task_id=key, status=task.status, created=True) + + async def _run( + self, task: WikiTask, func: Callable[[WikiTask], Coroutine[Any, Any, bool]] + ) -> None: + async with self._semaphore: + await func(task) + + self._schedule_remove(task) + + def _schedule_remove(self, task: WikiTask) -> None: + async def remove() -> None: + await asyncio.sleep(WIKI_TASK_TTL_SECONDS) + if self.get(task.repo_key) is task and task.status.is_terminal(): + await self.remove(task.repo_key) + + asyncio.create_task(remove()) + + +registry = TaskRegistry() + + +async def generate_repo_wiki(task: WikiTask) -> None: + """Drive one task through the state machine (SPEC.md §7).""" + r = task.request + try: + repo = Repo(r.repo_url, r.type, access_token=r.token) + + # Req 1.1: build the index only if it does not already exist. + if not repo_index_exist(repo): + task.status = TaskStatus.INDEXING + logger.info("Indexing %s", task.repo_key) + await prepare_repo_index(r) + + # Req 1.2 + no-persistence: index present -> (re)generate the whole wiki. + task.status = TaskStatus.DETERMINING_STRUCTURE + logger.info("Determining structure for %s", task.repo_key) + structure = await _determine_structure(task) + task.wiki_structure = structure + + task.status = TaskStatus.GENERATING + pages = await _generate_pages(task, structure) + + await _save(task, pages) + task.status = TaskStatus.COMPLETED + logger.info("Wiki task completed for %s", task.repo_key) + except Exception as e: + task.status = TaskStatus.FAILED + task.error = str(e) + logger.exception("Wiki task failed for %s", task.repo_key) + + +async def _save( + task: WikiTask, + pages: dict[str, WikiPage], +) -> None: + assert task.wiki_structure is not None + await save_wiki_cache( + owner=task.request.owner, + repo=task.request.repo, + repo_type=task.request.type, + language=task.request.language, + wiki_cache=WikiCacheData( + wiki_structure=task.wiki_structure, + generated_pages=pages, + repo=RepoInfo( + owner=task.request.owner, + repo=task.request.repo, + type=task.request.type, + token=None, # remove token from cache file + repoUrl=task.request.repo_url, + ), + provider=task.request.provider, + model=task.request.model, + ), + ) + + +async def _generate_page_with_retry(task: WikiTask, page: WikiPage) -> WikiPage: + last_error: Exception | None = None + for attempt in range(WIKI_PAGE_RETRIES + 1): + try: + return await _generate_page(task, page) + except Exception as e: # noqa: BLE001 - transient vs permanent handled by retry budget + last_error = e + logger.warning( + "Page %s failed (attempt %d/%d): %s", + page.id, + attempt + 1, + WIKI_PAGE_RETRIES + 1, + e, + ) + # Give up: return an error-placeholder page so the wiki still completes. + return page.model_copy( + update={"content": f"Error generating content: {last_error}"} + ) + + +async def _generate_pages( + task: WikiTask, structure: WikiStructureModel +) -> dict[str, WikiPage]: + """Generate every page with bounded concurrency + per-page retry. + + A page that keeps failing gets an error-placeholder instead of failing the + whole task (SPEC.md §7.1), matching the current frontend behavior. + """ + sema = asyncio.Semaphore(max(1, WIKI_PAGE_CONCURRENCY)) + pages: dict[str, WikiPage] = {} + + async def one(page: WikiPage) -> None: + async with sema: + task.current_page_ids.append(page.id) + try: + pages[page.id] = await _generate_page_with_retry(task, page) + finally: + try: + task.current_page_ids.remove(page.id) + except ValueError: + pass + task.pages_done += 1 + + await asyncio.gather(*(one(page) for page in structure.pages)) + return pages + + +async def _determine_structure(task: WikiTask) -> WikiStructureModel: + """Determine the wiki structure (port of determineWikiStructure). + + Reads the file tree + README from the local clone (already present after + indexing), asks the LLM for the structure, and parses the XML. Fail-fast: + raising here marks the task FAILED (§7.1). + """ + r = task.request + repo = Repo(r.repo_url, r.type, access_token=r.token) + if not repo.is_local and not repo.downloaded: + await asyncio.to_thread(repo.download) + + task.default_branch = await asyncio.to_thread(detect_default_branch, repo.save_path) + file_tree, readme = await asyncio.to_thread( + read_repo_file_tree, + repo.save_path, + r.excluded_dirs, + r.excluded_files, + r.included_dirs, + r.included_files, + ) + + prompt = build_structure_prompt( + r.owner, r.repo, file_tree, readme, r.comprehensive, r.language + ) + chat_request = ChatCompletionRequest( + repo_url=r.repo_url, + type=r.type, + token=r.token, + provider=r.provider, + model=r.model, + language=r.language, + excluded_dirs=r.excluded_dirs, + excluded_files=r.excluded_files, + included_dirs=r.included_dirs, + included_files=r.included_files, + messages=[ChatMessage(role="user", content=prompt)], + ) + + text = "" + async for chunk in await research_chat(chat_request): + text += chunk + + return parse_wiki_structure(text, comprehensive=r.comprehensive) + + +def _strip_markdown_fences(content: str) -> str: + """Remove a leading ```markdown fence and a trailing ``` if the model wrapped + the whole page in a code block (port of the frontend cleanup).""" + content = re.sub(r"^```markdown\s*", "", content, flags=re.IGNORECASE) + content = re.sub(r"```\s*$", "", content) + return content + + +async def _generate_page(task: WikiTask, page: WikiPage) -> WikiPage: + """Generate one wiki page: build the prompt, stream from the LLM (reusing the + RAG chat pipeline), strip fences, and resolve citations. + + Port of the frontend `generatePageContent` + `postProcessWikiContent`. + """ + r = task.request + ctx = RepoUrlContext( + type=r.type, repo_url=r.repo_url, default_branch=task.default_branch + ) + file_links = "\n".join( + f"- [{p}]({generate_file_url(p, ctx)})" for p in page.filePaths + ) + prompt = build_page_prompt(page.title, file_links, r.language) + + chat_request = ChatCompletionRequest( + repo_url=r.repo_url, + type=r.type, + token=r.token, + provider=r.provider, + model=r.model, + language=r.language, + excluded_dirs=r.excluded_dirs, + excluded_files=r.excluded_files, + included_dirs=r.included_dirs, + included_files=r.included_files, + messages=[ChatMessage(role="user", content=prompt)], + ) + + content = "" + async for chunk in await research_chat(chat_request): + content += chunk + + content = _strip_markdown_fences(content) + content = post_process_wiki_content(content, list(page.filePaths), ctx) + return page.model_copy(update={"content": content}) diff --git a/api/utils.py b/api/utils.py new file mode 100644 index 000000000..a4143b2a5 --- /dev/null +++ b/api/utils.py @@ -0,0 +1,10 @@ +import os + +from adalflow.utils import get_adalflow_default_root_path + + +def deepwiki_root() -> str: + path = get_adalflow_default_root_path() + if not os.path.exists(path): + os.makedirs(path, exist_ok=True) + return path diff --git a/pytest.ini b/pytest.ini index 1b8bc661e..ca811cd8a 100644 --- a/pytest.ini +++ b/pytest.ini @@ -8,6 +8,7 @@ addopts = --strict-markers --disable-warnings --tb=short + --p no:logging markers = unit: Unit tests integration: Integration tests diff --git a/src/app/[owner]/[repo]/page.tsx b/src/app/[owner]/[repo]/page.tsx index bdedcc663..61e1d2fb8 100644 --- a/src/app/[owner]/[repo]/page.tsx +++ b/src/app/[owner]/[repo]/page.tsx @@ -10,8 +10,12 @@ import WikiTreeView from '@/components/WikiTreeView'; import { useLanguage } from '@/contexts/LanguageContext'; import { RepoInfo } from '@/types/repoinfo'; import getRepoUrl from '@/utils/getRepoUrl'; -import { prepareRepoIndex } from '@/utils/prepareRepo'; -import { extractUrlDomain, extractUrlPath } from '@/utils/urlDecoder'; +import { + submitWikiTask, + subscribeWikiTask, + type WikiTaskStatusDto, + type WikiTaskStructureDto, +} from '@/utils/wikiTask'; import Link from 'next/link'; import { useParams, useSearchParams } from 'next/navigation'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; @@ -94,88 +98,6 @@ const getCacheKey = (owner: string, repo: string, repoType: string, language: st return `deepwiki_cache_${repoType}_${owner}_${repo}_${language}_${isComprehensive ? 'comprehensive' : 'concise'}`; }; -// Helper function to add tokens and other parameters to request body -const addTokensToRequestBody = ( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - requestBody: Record, - token: string, - repoType: string, - provider: string = '', - model: string = '', - isCustomModel: boolean = false, - customModel: string = '', - language: string = 'en', - excludedDirs?: string, - excludedFiles?: string, - includedDirs?: string, - includedFiles?: string -): void => { - if (token !== '') { - requestBody.token = token; - } - - // Add provider-based model selection parameters - requestBody.provider = provider; - requestBody.model = model; - if (isCustomModel && customModel) { - requestBody.custom_model = customModel; - } - - requestBody.language = language; - - // Add file filter parameters if provided - if (excludedDirs) { - requestBody.excluded_dirs = excludedDirs; - } - if (excludedFiles) { - requestBody.excluded_files = excludedFiles; - } - if (includedDirs) { - requestBody.included_dirs = includedDirs; - } - if (includedFiles) { - requestBody.included_files = includedFiles; - } - -}; - -const createGithubHeaders = (githubToken: string): HeadersInit => { - const headers: HeadersInit = { - 'Accept': 'application/vnd.github.v3+json' - }; - - if (githubToken) { - headers['Authorization'] = `Bearer ${githubToken}`; - } - - return headers; -}; - -const createGitlabHeaders = (gitlabToken: string): HeadersInit => { - const headers: HeadersInit = { - 'Content-Type': 'application/json', - }; - - if (gitlabToken) { - headers['PRIVATE-TOKEN'] = gitlabToken; - } - - return headers; -}; - -const createBitbucketHeaders = (bitbucketToken: string): HeadersInit => { - const headers: HeadersInit = { - 'Content-Type': 'application/json', - }; - - if (bitbucketToken) { - headers['Authorization'] = `Bearer ${bitbucketToken}`; - } - - return headers; -}; - - export default function RepoWikiPage() { // Get route parameters and search params const params = useParams(); @@ -236,12 +158,17 @@ export default function RepoWikiPage() { const [pagesInProgress, setPagesInProgress] = useState(new Set()); const [isExporting, setIsExporting] = useState(false); const [exportError, setExportError] = useState(null); - const [originalMarkdown, setOriginalMarkdown] = useState>({}); - const [requestInProgress, setRequestInProgress] = useState(false); const [currentToken, setCurrentToken] = useState(token); // Track current effective token const [effectiveRepoInfo, setEffectiveRepoInfo] = useState(repoInfo); // Track effective repo info with cached data const [embeddingError, setEmbeddingError] = useState(false); + // Backend-driven task progress (SPEC.md). Populated from the SSE stream while + // the backend generates the wiki; drives the progress bar + processing list. + const [generationProgress, setGenerationProgress] = useState(null); + // Unsubscribe handle for the active SSE task stream, so we can close it on + // unmount / refresh and avoid leaking connections. + const taskUnsubRef = useRef void)>(null); + // Model selection state variables const [selectedProviderState, setSelectedProviderState] = useState(providerParam); const [selectedModelState, setSelectedModelState] = useState(modelParam); @@ -261,12 +188,6 @@ export default function RepoWikiPage() { // Wiki type state - default to comprehensive view const isComprehensiveParam = searchParams.get('comprehensive') !== 'false'; const [isComprehensiveView, setIsComprehensiveView] = useState(isComprehensiveParam); - // Using useRef for activeContentRequests to maintain a single instance across renders - // This map tracks which pages are currently being processed to prevent duplicate requests - // Note: In a multi-threaded environment, additional synchronization would be needed, - // but in React's single-threaded model, this is safe as long as we set the flag before any async operations - const activeContentRequests = useRef(new Map()).current; - const [structureRequestInProgress, setStructureRequestInProgress] = useState(false); // Create a flag to track if data was loaded from cache to prevent immediate re-save const cacheLoadedSuccessfully = useRef(false); @@ -293,187 +214,6 @@ export default function RepoWikiPage() { const [authCode, setAuthCode] = useState(''); const [isAuthLoading, setIsAuthLoading] = useState(true); - // Default branch state - const [defaultBranch, setDefaultBranch] = useState('main'); - - // Helper function to generate proper repository file URLs - const generateFileUrl = useCallback((filePath: string): string => { - if (effectiveRepoInfo.type === 'local') { - // For local repositories, we can't generate web URLs - return filePath; - } - - const repoUrl = effectiveRepoInfo.repoUrl; - if (!repoUrl) { - return filePath; - } - - 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}`; - } else if (hostname === 'gitlab.com' || hostname.includes('gitlab')) { - // GitLab URL format: https://gitlab.com/owner/repo/-/blob/branch/path - return `${repoUrl}/-/blob/${defaultBranch}/${filePath}`; - } else if (hostname === 'bitbucket.org' || hostname.includes('bitbucket')) { - // Bitbucket URL format: https://bitbucket.org/owner/repo/src/branch/path - return `${repoUrl}/src/${defaultBranch}/${filePath}`; - } - } catch (error) { - console.warn('Error generating file URL:', error); - } - - // Fallback to just the file path - 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 @@ -526,1195 +266,314 @@ ${filePaths.map(path => `- [${escapeLabel(path)}](${generateFileUrl(path)})`).jo fetchAuthStatus(); }, []); - // Generate content for a wiki page - const generatePageContent = useCallback(async (page: WikiPage, owner: string, repo: string) => { - return new Promise(async (resolve) => { - try { - // Skip if content already exists - if (generatedPages[page.id]?.content) { - resolve(); - return; - } - - // Skip if this page is already being processed - // Use a synchronized pattern to avoid race conditions - if (activeContentRequests.get(page.id)) { - console.log(`Page ${page.id} (${page.title}) is already being processed, skipping duplicate call`); - resolve(); - return; - } - - // Mark this page as being processed immediately to prevent race conditions - // This ensures that if multiple calls happen nearly simultaneously, only one proceeds - activeContentRequests.set(page.id, true); - - // Validate repo info - if (!owner || !repo) { - throw new Error('Invalid repository information. Owner and repo name are required.'); - } - - // Mark page as in progress - setPagesInProgress(prev => new Set(prev).add(page.id)); - // Don't set loading message for individual pages during queue processing - - const filePaths = page.filePaths; - - // Store the initially generated content BEFORE rendering/potential modification - setGeneratedPages(prev => ({ - ...prev, - [page.id]: { ...page, content: 'Loading...' } // Placeholder - })); - setOriginalMarkdown(prev => ({ ...prev, [page.id]: '' })); // Clear previous original - - // Make API call to generate page content - console.log(`Starting content generation for page: ${page.title}`); - - // Get repository URL - const repoUrl = getRepoUrl(effectiveRepoInfo); - - // Create the prompt content - simplified to avoid message dialogs - const promptContent = -`You are an expert technical writer and software architect. -Your task is to generate a comprehensive and accurate technical wiki page in Markdown format about a specific feature, system, or module within a given software project. - -You will be given: -1. The "[WIKI_PAGE_TOPIC]" for the page you need to create. -2. A list of "[RELEVANT_SOURCE_FILES]" from the project that you MUST use as the sole basis for the content. You have access to the full content of these files. You MUST use AT LEAST 5 relevant source files for comprehensive coverage - if fewer are provided, search for additional related files in the codebase. - -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. -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 - -The following files were used as context for generating this wiki page: - -${filePaths.map(path => `- [${path}](${generateFileUrl(path)})`).join('\n')} - -
- -Immediately after the \`
\` block, the main title of the page should be a H1 Markdown heading: \`# ${page.title}\`. - -Based ONLY on the content of the \`[RELEVANT_SOURCE_FILES]\`: - -1. **Introduction:** Start with a concise introduction (1-2 paragraphs) explaining the purpose, scope, and high-level overview of "${page.title}" within the context of the overall project. If relevant, and if information is available in the provided files, link to other potential wiki pages using the format \`[Link Text](#page-anchor-or-id)\`. - -2. **Detailed Sections:** Break down "${page.title}" into logical sections using H2 (\`##\`) and H3 (\`###\`) Markdown headings. For each section: - * Explain the architecture, components, data flow, or logic relevant to the section's focus, as evidenced in the source files. - * Identify key functions, classes, data structures, API endpoints, or configuration elements pertinent to that section. - -3. **Mermaid Diagrams:** - * EXTENSIVELY use Mermaid diagrams (e.g., \`flowchart TD\`, \`sequenceDiagram\`, \`classDiagram\`, \`erDiagram\`, \`graph TD\`) to visually represent architectures, flows, relationships, and schemas found in the source files. - * Ensure diagrams are accurate and directly derived from information in the \`[RELEVANT_SOURCE_FILES]\`. - * Provide a brief explanation before or after each diagram to give context. - * CRITICAL: All diagrams MUST follow strict vertical orientation: - - Use "graph TD" (top-down) directive for flow diagrams - - NEVER use "graph LR" (left-right) - - Maximum node width should be 3-4 words - - For sequence diagrams: - - Start with "sequenceDiagram" directive on its own line - - Define ALL participants at the beginning using "participant" keyword - - Optionally specify participant types: actor, boundary, control, entity, database, collections, queue - - Use descriptive but concise participant names, or use aliases: "participant A as Alice" - - Use the correct Mermaid arrow syntax (8 types available): - - -> solid line without arrow (rarely used) - - --> dotted line without arrow (rarely used) - - ->> solid line with arrowhead (most common for requests/calls) - - -->> dotted line with arrowhead (most common for responses/returns) - - ->x solid line with X at end (failed/error message) - - -->x dotted line with X at end (failed/error response) - - -) solid line with open arrow (async message, fire-and-forget) - - --) dotted line with open arrow (async response) - - Examples: A->>B: Request, B-->>A: Response, A->xB: Error, A-)B: Async event - - Use +/- suffix for activation boxes: A->>+B: Start (activates B), B-->>-A: End (deactivates B) - - Group related participants using "box": box GroupName ... end - - Use structural elements for complex flows: - - loop LoopText ... end (for iterations) - - alt ConditionText ... else ... end (for conditionals) - - opt OptionalText ... end (for optional flows) - - par ParallelText ... and ... end (for parallel actions) - - critical CriticalText ... option ... end (for critical regions) - - break BreakText ... end (for breaking flows/exceptions) - - Add notes for clarification: "Note over A,B: Description", "Note right of A: Detail" - - Use autonumber directive to add sequence numbers to messages - - NEVER use flowchart-style labels like A--|label|-->B. Always use a colon for labels: A->>B: My Label - -4. **Tables:** - * Use Markdown tables to summarize information such as: - * Key features or components and their descriptions. - * API endpoint parameters, types, and descriptions. - * Configuration options, their types, and default values. - * Data model fields, types, constraints, and descriptions. - -5. **Code Snippets (ENTIRELY OPTIONAL):** - * Include short, relevant code snippets (e.g., Python, Java, JavaScript, SQL, JSON, YAML) directly from the \`[RELEVANT_SOURCE_FILES]\` to illustrate key implementation details, data structures, or configurations. - * Ensure snippets are well-formatted within Markdown code blocks with appropriate language identifiers. - -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 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. - -7. **Technical Accuracy:** All information must be derived SOLELY from the \`[RELEVANT_SOURCE_FILES]\`. Do not infer, invent, or use external knowledge about similar systems or common practices unless it's directly supported by the provided code. If information is not present in the provided files, do not include it or explicitly state its absence if crucial to the topic. - -8. **Clarity and Conciseness:** Use clear, professional, and concise technical language suitable for other developers working on or learning about the project. Avoid unnecessary jargon, but use correct technical terms where appropriate. - -9. **Conclusion/Summary:** End with a brief summary paragraph if appropriate for "${page.title}", reiterating the key aspects covered and their significance within the project. - -IMPORTANT: Generate the content in ${language === 'en' ? 'English' : - language === 'ja' ? 'Japanese (日本語)' : - language === 'zh' ? 'Mandarin Chinese (中文)' : - language === 'zh-tw' ? 'Traditional Chinese (繁體中文)' : - language === 'es' ? 'Spanish (Español)' : - language === 'kr' ? 'Korean (한국어)' : - language === 'vi' ? 'Vietnamese (Tiếng Việt)' : - language === "pt-br" ? "Brazilian Portuguese (Português Brasileiro)" : - language === "fr" ? "Français (French)" : - language === "ru" ? "Русский (Russian)" : - 'English'} language. - -Remember: -- Ground every claim in the provided source files. -- Prioritize accuracy and direct representation of the code's functionality and structure. -- Structure the document logically for easy understanding by other developers. -`; - - // Prepare request body - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const requestBody: Record = { - repo_url: repoUrl, - type: effectiveRepoInfo.type, - messages: [{ - role: 'user', - content: promptContent - }] - }; - - // Add tokens if available - addTokensToRequestBody(requestBody, currentToken, effectiveRepoInfo.type, selectedProviderState, selectedModelState, isCustomSelectedModelState, customSelectedModelState, language, modelExcludedDirs, modelExcludedFiles, modelIncludedDirs, modelIncludedFiles); - - // Use WebSocket for communication - let content = ''; - - try { - // Create WebSocket URL from the server base URL - const serverBaseUrl = process.env.SERVER_BASE_URL || 'http://localhost:8001'; - const wsBaseUrl = serverBaseUrl.replace(/^http/, 'ws')? serverBaseUrl.replace(/^https/, 'wss'): serverBaseUrl.replace(/^http/, 'ws'); - const wsUrl = `${wsBaseUrl}/ws/chat`; - - // Create a new WebSocket connection - const ws = new WebSocket(wsUrl); - - // Create a promise that resolves when the WebSocket connection is complete - await new Promise((resolve, reject) => { - // Set up event handlers - ws.onopen = () => { - console.log(`WebSocket connection established for page: ${page.title}`); - // Send the request as JSON - ws.send(JSON.stringify(requestBody)); - resolve(); - }; - - ws.onerror = (error) => { - console.error('WebSocket error:', error); - reject(new Error('WebSocket connection failed')); - }; - - // If the connection doesn't open within 5 seconds, fall back to HTTP - const timeout = setTimeout(() => { - reject(new Error('WebSocket connection timeout')); - }, 5000); - - // Clear the timeout if the connection opens successfully - ws.onopen = () => { - clearTimeout(timeout); - console.log(`WebSocket connection established for page: ${page.title}`); - // Send the request as JSON - ws.send(JSON.stringify(requestBody)); - resolve(); - }; - }); - - // Create a promise that resolves when the WebSocket response is complete - await new Promise((resolve, reject) => { - // Handle incoming messages - ws.onmessage = (event) => { - content += event.data; - }; - - // Handle WebSocket close - ws.onclose = () => { - console.log(`WebSocket connection closed for page: ${page.title}`); - resolve(); - }; - - // Handle WebSocket errors - ws.onerror = (error) => { - console.error('WebSocket error during message reception:', error); - reject(new Error('WebSocket error during message reception')); - }; - }); - } catch (wsError) { - console.error('WebSocket error, falling back to HTTP:', wsError); - - // Fall back to HTTP if WebSocket fails - const response = await fetch(`/api/chat/stream`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(requestBody) - }); - - if (!response.ok) { - const errorText = await response.text().catch(() => 'No error details available'); - console.error(`API error (${response.status}): ${errorText}`); - throw new Error(`Error generating page content: ${response.status} - ${response.statusText}`); - } - - // Process the response - content = ''; - const reader = response.body?.getReader(); - const decoder = new TextDecoder(); - - if (!reader) { - throw new Error('Failed to get response reader'); - } - - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - content += decoder.decode(value, { stream: true }); - } - // Ensure final decoding - content += decoder.decode(); - } catch (readError) { - console.error('Error reading stream:', readError); - throw new Error('Error processing response stream'); - } - } - - // 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 - const updatedPage = { ...page, content }; - setGeneratedPages(prev => ({ ...prev, [page.id]: updatedPage })); - // Store this as the original for potential mermaid retries - setOriginalMarkdown(prev => ({ ...prev, [page.id]: content })); - - resolve(); - } catch (err) { - console.error(`Error generating content for page ${page.id}:`, err); - const errorMessage = err instanceof Error ? err.message : 'Unknown error'; - // Update page state to show error - setGeneratedPages(prev => ({ - ...prev, - [page.id]: { ...page, content: `Error generating content: ${errorMessage}` } - })); - setError(`Failed to generate content for ${page.title}.`); - resolve(); // Resolve even on error to unblock queue - } finally { - // Clear the processing flag for this page - // This must happen in the finally block to ensure the flag is cleared - // even if an error occurs during processing - activeContentRequests.delete(page.id); - - // Mark page as done - setPagesInProgress(prev => { - const next = new Set(prev); - next.delete(page.id); - return next; - }); - setLoadingMessage(undefined); // Clear specific loading message - } - }); - }, [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) => { - if (!owner || !repo) { - setError('Invalid repository information. Owner and repo name are required.'); - setIsLoading(false); - setEmbeddingError(false); // Reset embedding error state - return; - } - - // Skip if structure request is already in progress - if (structureRequestInProgress) { - console.log('Wiki structure determination already in progress, skipping duplicate call'); - return; - } - + // Load a completed wiki from the server-side cache and render it. Returns true + // when a valid cache was found and applied, false otherwise (caller then + // submits a generation task). This is the render path for both the initial + // page load and the SSE `done` handler. + const loadWikiFromServerCache = useCallback(async (): Promise => { + setLoadingMessage(messages.loading?.fetchingCache || 'Checking for cached wiki...'); try { - setStructureRequestInProgress(true); - setLoadingMessage(messages.loading?.determiningStructure || 'Determining wiki structure...'); - - // Get repository URL - const repoUrl = getRepoUrl(effectiveRepoInfo); - - // Prepare request body - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const requestBody: Record = { - repo_url: repoUrl, - type: effectiveRepoInfo.type, - messages: [{ - role: 'user', -content: `Analyze this GitHub repository ${owner}/${repo} and create a wiki structure for it. - -1. The complete file tree of the project: - -${fileTree} - - -2. The README file of the project: - -${readme} - - -I want to create a wiki for this repository. Determine the most logical structure for a wiki based on the repository's content. - -IMPORTANT: The wiki content will be generated in ${language === 'en' ? 'English' : - language === 'ja' ? 'Japanese (日本語)' : - language === 'zh' ? 'Mandarin Chinese (中文)' : - language === 'zh-tw' ? 'Traditional Chinese (繁體中文)' : - language === 'es' ? 'Spanish (Español)' : - language === 'kr' ? 'Korean (한国語)' : - language === 'vi' ? 'Vietnamese (Tiếng Việt)' : - language === "pt-br" ? "Brazilian Portuguese (Português Brasileiro)" : - language === "fr" ? "Français (French)" : - language === "ru" ? "Русский (Russian)" : - 'English'} language. - -When designing the wiki structure, include pages that would benefit from visual diagrams, such as: -- Architecture overviews -- Data flow descriptions -- Component relationships -- Process workflows -- State machines -- Class hierarchies - -${isComprehensiveView ? ` -Create a structured wiki with the following main sections: -- Overview (general information about the project) -- System Architecture (how the system is designed) -- Core Features (key functionality) -- Data Management/Flow: If applicable, how data is stored, processed, accessed, and managed (e.g., database schema, data pipelines, state management). -- Frontend Components (UI elements, if applicable.) -- Backend Systems (server-side components) -- Model Integration (AI model connections) -- Deployment/Infrastructure (how to deploy, what's the infrastructure like) -- Extensibility and Customization: If the project architecture supports it, explain how to extend or customize its functionality (e.g., plugins, theming, custom modules, hooks). - -Each section should contain relevant pages. For example, the "Frontend Components" section might include pages for "Home Page", "Repository Wiki Page", "Ask Component", etc. - -Return your analysis in the following XML format: - - - [Overall title for the wiki] - [Brief description of the repository] - -
- [Section title] - - page-1 - page-2 - - - section-2 - -
- -
- - - [Page title] - [Brief description of what this page will cover] - high|medium|low - - [Path to a relevant file] - - - - page-2 - - - section-1 - - - -
-` : ` -Return your analysis in the following XML format: - - - [Overall title for the wiki] - [Brief description of the repository] - - - [Page title] - [Brief description of what this page will cover] - high|medium|low - - [Path to a relevant file] - - - - page-2 - - - - - - -`} - -IMPORTANT FORMATTING INSTRUCTIONS: -- Return ONLY the valid XML structure specified above -- DO NOT wrap the XML in markdown code blocks (no \`\`\` or \`\`\`xml) -- DO NOT include any explanation text before or after the XML -- Ensure the XML is properly formatted and valid -- Start directly with and end with - -IMPORTANT: -1. Create ${isComprehensiveView ? '8-12' : '4-6'} pages that would make a ${isComprehensiveView ? 'comprehensive' : 'concise'} wiki for this repository -2. Each page should focus on a specific aspect of the codebase (e.g., architecture, key features, setup) -3. The relevant_files should be actual files from the repository that would be used to generate that page -4. Return ONLY valid XML with the structure specified above, with no markdown code block delimiters` - }] - }; - - // Add tokens if available - addTokensToRequestBody(requestBody, currentToken, effectiveRepoInfo.type, selectedProviderState, selectedModelState, isCustomSelectedModelState, customSelectedModelState, language, modelExcludedDirs, modelExcludedFiles, modelIncludedDirs, modelIncludedFiles); - - // Use WebSocket for communication - let responseText = ''; - - try { - // Create WebSocket URL from the server base URL - const serverBaseUrl = process.env.SERVER_BASE_URL || 'http://localhost:8001'; - const wsBaseUrl = serverBaseUrl.replace(/^http/, 'ws')? serverBaseUrl.replace(/^https/, 'wss'): serverBaseUrl.replace(/^http/, 'ws'); - const wsUrl = `${wsBaseUrl}/ws/chat`; - - // Create a new WebSocket connection - const ws = new WebSocket(wsUrl); - - // Create a promise that resolves when the WebSocket connection is complete - await new Promise((resolve, reject) => { - // Set up event handlers - ws.onopen = () => { - console.log('WebSocket connection established for wiki structure'); - // Send the request as JSON - ws.send(JSON.stringify(requestBody)); - resolve(); - }; - - ws.onerror = (error) => { - console.error('WebSocket error:', error); - reject(new Error('WebSocket connection failed')); - }; - - // If the connection doesn't open within 5 seconds, fall back to HTTP - const timeout = setTimeout(() => { - reject(new Error('WebSocket connection timeout')); - }, 5000); - - // Clear the timeout if the connection opens successfully - ws.onopen = () => { - clearTimeout(timeout); - console.log('WebSocket connection established for wiki structure'); - // Send the request as JSON - ws.send(JSON.stringify(requestBody)); - resolve(); - }; - }); - - // Create a promise that resolves when the WebSocket response is complete - await new Promise((resolve, reject) => { - // Handle incoming messages - ws.onmessage = (event) => { - responseText += event.data; - }; - - // Handle WebSocket close - ws.onclose = () => { - console.log('WebSocket connection closed for wiki structure'); - resolve(); - }; - - // Handle WebSocket errors - ws.onerror = (error) => { - console.error('WebSocket error during message reception:', error); - reject(new Error('WebSocket error during message reception')); - }; - }); - } catch (wsError) { - console.error('WebSocket error, falling back to HTTP:', wsError); - - // Fall back to HTTP if WebSocket fails - const response = await fetch(`/api/chat/stream`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(requestBody) - }); - - if (!response.ok) { - throw new Error(`Error determining wiki structure: ${response.status}`); - } - - // Process the response - responseText = ''; - const reader = response.body?.getReader(); - const decoder = new TextDecoder(); - - if (!reader) { - throw new Error('Failed to get response reader'); - } + const params = new URLSearchParams({ + owner: effectiveRepoInfo.owner, + repo: effectiveRepoInfo.repo, + repo_type: effectiveRepoInfo.type, + language: language, + comprehensive: isComprehensiveView.toString(), + }); + const response = await fetch(`/api/wiki_cache?${params.toString()}`); - while (true) { - const { done, value } = await reader.read(); - if (done) break; - responseText += decoder.decode(value, { stream: true }); - } + if (!response.ok) { + console.error('Error fetching wiki cache from server:', response.status, await response.text()); + return false; } - if(responseText.includes('Error preparing retriever: Environment variable OPENAI_API_KEY must be set')) { - setEmbeddingError(true); - throw new Error('OPENAI_API_KEY environment variable is not set. Please configure your OpenAI API key.'); - } - - if(responseText.includes('Ollama model') && responseText.includes('not found')) { - setEmbeddingError(true); - throw new Error('The specified Ollama embedding model was not found. Please ensure the model is installed locally or select a different embedding model in the configuration.'); - } - - // Clean up markdown delimiters - responseText = responseText.replace(/^```(?:xml)?\s*/i, '').replace(/```\s*$/i, ''); - - // Extract wiki structure from response - const xmlMatch = responseText.match(/[\s\S]*?<\/wiki_structure>/m); - if (!xmlMatch) { - throw new Error('No valid XML found in response'); + const cachedData = await response.json(); // Returns null if no cache + if (!(cachedData && cachedData.wiki_structure && cachedData.generated_pages && Object.keys(cachedData.generated_pages).length > 0)) { + console.log('No valid wiki data in server cache or cache is empty.'); + return false; } - 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"); - - // Check for parsing errors - const parseError = xmlDoc.querySelector('parsererror'); - if (parseError) { - // Log the first few elements to see what was parsed - const elements = xmlDoc.querySelectorAll('*'); - if (elements.length > 0) { - console.log('First 5 element names:', - Array.from(elements).slice(0, 5).map(el => el.nodeName).join(', ')); - } - - // We'll continue anyway since the XML might still be usable + console.log('Using server-cached wiki data'); + if (cachedData.model) { + setSelectedModelState(cachedData.model); + } + if (cachedData.provider) { + setSelectedProviderState(cachedData.provider); } - // Extract wiki structure - let title = ''; - let description = ''; - let pages: WikiPage[] = []; - - // Try using DOM parsing first - const titleEl = xmlDoc.querySelector('title'); - const descriptionEl = xmlDoc.querySelector('description'); - const pagesEls = xmlDoc.querySelectorAll('page'); - - title = titleEl ? titleEl.textContent || '' : ''; - description = descriptionEl ? descriptionEl.textContent || '' : ''; - - // Parse pages using DOM - pages = []; - - pagesEls.forEach(pageEl => { - const id = pageEl.getAttribute('id') || `page-${pages.length + 1}`; - const titleEl = pageEl.querySelector('title'); - const importanceEl = pageEl.querySelector('importance'); - const filePathEls = pageEl.querySelectorAll('file_path'); - const relatedEls = pageEl.querySelectorAll('related'); - - const title = titleEl ? titleEl.textContent || '' : ''; - const importance = importanceEl ? - (importanceEl.textContent === 'high' ? 'high' : - importanceEl.textContent === 'medium' ? 'medium' : 'low') : 'medium'; - - const filePaths: string[] = []; - filePathEls.forEach(el => { - if (el.textContent) filePaths.push(el.textContent); - }); - - const relatedPages: string[] = []; - relatedEls.forEach(el => { - if (el.textContent) relatedPages.push(el.textContent); - }); + // Update repoInfo + if (cachedData.repo) { + setEffectiveRepoInfo(cachedData.repo); + } else if (cachedData.repo_url && !effectiveRepoInfo.repoUrl) { + const updatedRepoInfo = { ...effectiveRepoInfo, repoUrl: cachedData.repo_url }; + setEffectiveRepoInfo(updatedRepoInfo); // Update effective repo info state + console.log('Using cached repo_url:', cachedData.repo_url); + } - pages.push({ - id, - title, - content: '', // Will be generated later - filePaths, - importance, - relatedPages - }); - }); + // Ensure the cached structure has sections and rootSections + const cachedStructure = { + ...cachedData.wiki_structure, + sections: cachedData.wiki_structure.sections || [], + rootSections: cachedData.wiki_structure.rootSections || [] + }; - // 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 }; + // If sections or rootSections are missing, create intelligent ones based on page titles + if (!cachedStructure.sections.length || !cachedStructure.rootSections.length) { + const pages = cachedStructure.pages; + const sections: WikiSection[] = []; + const rootSections: string[] = []; + + // Group pages by common prefixes or categories + const pageClusters = new Map(); + + // Define common categories that might appear in page titles + const categories = [ + { id: 'overview', title: 'Overview', keywords: ['overview', 'introduction', 'about'] }, + { id: 'architecture', title: 'Architecture', keywords: ['architecture', 'structure', 'design', 'system'] }, + { id: 'features', title: 'Core Features', keywords: ['feature', 'functionality', 'core'] }, + { id: 'components', title: 'Components', keywords: ['component', 'module', 'widget'] }, + { id: 'api', title: 'API', keywords: ['api', 'endpoint', 'service', 'server'] }, + { id: 'data', title: 'Data Flow', keywords: ['data', 'flow', 'pipeline', 'storage'] }, + { id: 'models', title: 'Models', keywords: ['model', 'ai', 'ml', 'integration'] }, + { id: 'ui', title: 'User Interface', keywords: ['ui', 'interface', 'frontend', 'page'] }, + { id: 'setup', title: 'Setup & Configuration', keywords: ['setup', 'config', 'installation', 'deploy'] } + ]; + + // Initialize clusters with empty arrays + categories.forEach(category => { + pageClusters.set(category.id, []); }); - } - - // Extract sections if they exist in the XML - const sections: WikiSection[] = []; - const rootSections: string[] = []; - // Try to parse sections if we're in comprehensive view - if (isComprehensiveView) { - const sectionsEls = xmlDoc.querySelectorAll('section'); + // Add an "Other" category for pages that don't match any category + pageClusters.set('other', []); - if (sectionsEls && sectionsEls.length > 0) { - // Process sections - sectionsEls.forEach(sectionEl => { - const id = sectionEl.getAttribute('id') || `section-${sections.length + 1}`; - const titleEl = sectionEl.querySelector('title'); - const pageRefEls = sectionEl.querySelectorAll('page_ref'); - const sectionRefEls = sectionEl.querySelectorAll('section_ref'); + // Assign pages to categories based on title keywords + pages.forEach((page: WikiPage) => { + const title = page.title.toLowerCase(); + let assigned = false; - const title = titleEl ? titleEl.textContent || '' : ''; - const pages: string[] = []; - const subsections: string[] = []; + // Try to find a matching category + for (const category of categories) { + if (category.keywords.some(keyword => title.includes(keyword))) { + pageClusters.get(category.id)?.push(page); + assigned = true; + break; + } + } - pageRefEls.forEach(el => { - if (el.textContent) pages.push(el.textContent); - }); + // If no category matched, put in "Other" + if (!assigned) { + pageClusters.get('other')?.push(page); + } + }); - sectionRefEls.forEach(el => { - if (el.textContent) subsections.push(el.textContent); - }); + // Create sections for non-empty categories + for (const [categoryId, categoryPages] of pageClusters.entries()) { + if (categoryPages.length > 0) { + const category = categories.find(c => c.id === categoryId) || + { id: categoryId, title: categoryId === 'other' ? 'Other' : categoryId.charAt(0).toUpperCase() + categoryId.slice(1) }; + const sectionId = `section-${categoryId}`; sections.push({ - id, - title, - pages, - subsections: subsections.length > 0 ? subsections : undefined + id: sectionId, + title: category.title, + pages: categoryPages.map((p: WikiPage) => p.id) }); + rootSections.push(sectionId); - // Check if this is a root section (not referenced by any other section) - let isReferenced = false; - sectionsEls.forEach(otherSection => { - const otherSectionRefs = otherSection.querySelectorAll('section_ref'); - otherSectionRefs.forEach(ref => { - if (ref.textContent === id) { - isReferenced = true; - } - }); + // Update page parentId + categoryPages.forEach((page: WikiPage) => { + page.parentId = sectionId; }); - - if (!isReferenced) { - rootSections.push(id); - } - }); + } } - } - // Create wiki structure - const wikiStructure: WikiStructure = { - id: 'wiki', - title, - description, - pages, - sections, - rootSections - }; + // If we still have no sections (unlikely), fall back to importance-based grouping + if (sections.length === 0) { + const highImportancePages = pages.filter((p: WikiPage) => p.importance === 'high').map((p: WikiPage) => p.id); + const mediumImportancePages = pages.filter((p: WikiPage) => p.importance === 'medium').map((p: WikiPage) => p.id); + const lowImportancePages = pages.filter((p: WikiPage) => p.importance === 'low').map((p: WikiPage) => p.id); - setWikiStructure(wikiStructure); - setCurrentPageId(pages.length > 0 ? pages[0].id : undefined); - - // Start generating content for all pages with controlled concurrency - if (pages.length > 0) { - // Mark all pages as in progress - const initialInProgress = new Set(pages.map(p => p.id)); - setPagesInProgress(initialInProgress); - - console.log(`Starting generation for ${pages.length} pages with controlled concurrency`); - - // Maximum concurrent requests - const MAX_CONCURRENT = 1; - - // Create a queue of pages - const queue = [...pages]; - let activeRequests = 0; - - // Function to process next items in queue - const processQueue = () => { - // Process as many items as we can up to our concurrency limit - while (queue.length > 0 && activeRequests < MAX_CONCURRENT) { - const page = queue.shift(); - if (page) { - activeRequests++; - console.log(`Starting page ${page.title} (${activeRequests} active, ${queue.length} remaining)`); - - // Start generating content for this page - generatePageContent(page, owner, repo) - .finally(() => { - // When done (success or error), decrement active count and process more - activeRequests--; - console.log(`Finished page ${page.title} (${activeRequests} active, ${queue.length} remaining)`); - - // Check if all work is done (queue empty and no active requests) - if (queue.length === 0 && activeRequests === 0) { - console.log("All page generation tasks completed."); - setIsLoading(false); - setLoadingMessage(undefined); - } else { - // Only process more if there are items remaining and we're under capacity - if (queue.length > 0 && activeRequests < MAX_CONCURRENT) { - processQueue(); - } - } - }); - } + if (highImportancePages.length > 0) { + sections.push({ id: 'section-high', title: 'Core Components', pages: highImportancePages }); + rootSections.push('section-high'); } - - // Additional check: If the queue started empty or becomes empty and no requests were started/active - if (queue.length === 0 && activeRequests === 0 && pages.length > 0 && pagesInProgress.size === 0) { - // This handles the case where the queue might finish before the finally blocks fully update activeRequests - // or if the initial queue was processed very quickly - console.log("Queue empty and no active requests after loop, ensuring loading is false."); - setIsLoading(false); - setLoadingMessage(undefined); - } else if (pages.length === 0) { - // Handle case where there were no pages to begin with - setIsLoading(false); - setLoadingMessage(undefined); + if (mediumImportancePages.length > 0) { + sections.push({ id: 'section-medium', title: 'Key Features', pages: mediumImportancePages }); + rootSections.push('section-medium'); } - }; + if (lowImportancePages.length > 0) { + sections.push({ id: 'section-low', title: 'Additional Information', pages: lowImportancePages }); + rootSections.push('section-low'); + } + } - // Start processing the queue - processQueue(); - } else { - // Set loading to false if there were no pages found - setIsLoading(false); - setLoadingMessage(undefined); + cachedStructure.sections = sections; + cachedStructure.rootSections = rootSections; } - } catch (error) { - console.error('Error determining wiki structure:', error); + setWikiStructure(cachedStructure); + setGeneratedPages(cachedData.generated_pages); + setCurrentPageId(cachedStructure.pages.length > 0 ? cachedStructure.pages[0].id : undefined); + setGenerationProgress(null); setIsLoading(false); - setError(error instanceof Error ? error.message : 'An unknown error occurred'); + setEmbeddingError(false); setLoadingMessage(undefined); - } finally { - setStructureRequestInProgress(false); - } - }, [generatePageContent, currentToken, effectiveRepoInfo, pagesInProgress.size, structureRequestInProgress, selectedProviderState, selectedModelState, isCustomSelectedModelState, customSelectedModelState, modelExcludedDirs, modelExcludedFiles, language, messages.loading, isComprehensiveView]); - - // Fetch repository structure using GitHub or GitLab API - const fetchRepositoryStructure = useCallback(async () => { - // If a request is already in progress, don't start another one - if (requestInProgress) { - console.log('Repository fetch already in progress, skipping duplicate call'); - return; + cacheLoadedSuccessfully.current = true; + return true; + } catch (error) { + console.error('Error loading from server cache:', error); + return false; } + }, [effectiveRepoInfo, language, isComprehensiveView, messages.loading?.fetchingCache]); + + // Map a backend task structure to the local WikiStructure shape used by the + // progress UI (importance coercion + default sections/rootSections). + const toWikiStructure = useCallback((s: WikiTaskStructureDto): WikiStructure => ({ + id: s.id, + title: s.title, + description: s.description, + pages: s.pages.map(p => ({ + id: p.id, + title: p.title, + content: p.content, + filePaths: p.filePaths, + importance: p.importance === 'high' ? 'high' : p.importance === 'low' ? 'low' : 'medium', + relatedPages: p.relatedPages, + })), + sections: [], + rootSections: [], + }), []); + + // Submit a backend wiki-generation task and follow it to completion via SSE. + // The browser no longer orchestrates indexing / structure / page generation; + // it only submits, streams progress, and loads the finished wiki from cache. + const startGeneration = useCallback(async () => { + // Tear down any previous stream before starting a new one. + taskUnsubRef.current?.(); + taskUnsubRef.current = null; - // Reset previous state setWikiStructure(undefined); setCurrentPageId(undefined); setGeneratedPages({}); setPagesInProgress(new Set()); + setGenerationProgress(null); setError(null); - setEmbeddingError(false); // Reset embedding error state - - try { - // Set the request in progress flag - setRequestInProgress(true); - - // Update loading state - setIsLoading(true); - setLoadingMessage(messages.loading?.fetchingStructure || 'Fetching repository structure...'); - - let fileTreeData = ''; - let readmeContent = ''; - - if (effectiveRepoInfo.type === 'local' && effectiveRepoInfo.localPath) { - try { - const response = await fetch(`/local_repo/structure?path=${encodeURIComponent(effectiveRepoInfo.localPath)}`); - - if (!response.ok) { - const errorData = await response.text(); - throw new Error(`Local repository API error (${response.status}): ${errorData}`); - } - - const data = await response.json(); - fileTreeData = data.file_tree; - readmeContent = data.readme; - // For local repos, we can't determine the actual branch, so use 'main' as default - setDefaultBranch('main'); - } catch (err) { - throw err; - } - } else if (effectiveRepoInfo.type === 'github') { - // GitHub API approach - // Try to get the tree data for common branch names - let treeData = null; - let apiErrorDetails = ''; - - // Determine the GitHub API base URL based on the repository URL - const getGithubApiUrl = (repoUrl: string | null): string => { - 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`; - } catch { - return 'https://api.github.com'; // Fallback to public GitHub if URL parsing fails - } - }; - - const githubApiBaseUrl = getGithubApiUrl(effectiveRepoInfo.repoUrl); - // First, try to get the default branch from the repository info - let defaultBranchLocal = null; - try { - const repoInfoResponse = await fetch(`${githubApiBaseUrl}/repos/${owner}/${repo}`, { - headers: createGithubHeaders(currentToken) - }); - - if (repoInfoResponse.ok) { - const repoData = await repoInfoResponse.json(); - defaultBranchLocal = repoData.default_branch; - console.log(`Found default branch: ${defaultBranchLocal}`); - // Store the default branch in state - setDefaultBranch(defaultBranchLocal || 'main'); - } - } catch (err) { - console.warn('Could not fetch repository info for default branch:', err); - } - - // Create list of branches to try, prioritizing the actual default branch - const branchesToTry = defaultBranchLocal - ? [defaultBranchLocal, 'main', 'master'].filter((branch, index, arr) => arr.indexOf(branch) === index) - : ['main', 'master']; - - for (const branch of branchesToTry) { - const apiUrl = `${githubApiBaseUrl}/repos/${owner}/${repo}/git/trees/${branch}?recursive=1`; - const headers = createGithubHeaders(currentToken); - - console.log(`Fetching repository structure from branch: ${branch}`); - try { - const response = await fetch(apiUrl, { - headers - }); - - if (response.ok) { - treeData = await response.json(); - console.log('Successfully fetched repository structure'); - break; - } else { - const errorData = await response.text(); - apiErrorDetails = `Status: ${response.status}, Response: ${errorData}`; - console.error(`Error fetching repository structure: ${apiErrorDetails}`); - } - } catch (err) { - console.error(`Network error fetching branch ${branch}:`, err); - } - } - - if (!treeData || !treeData.tree) { - if (apiErrorDetails) { - throw new Error(`Could not fetch repository structure. API Error: ${apiErrorDetails}`); - } else { - throw new Error('Could not fetch repository structure. Repository might not exist, be empty or private.'); - } - } + setEmbeddingError(false); + setIsLoading(true); + cacheLoadedSuccessfully.current = false; + setLoadingMessage(messages.loading?.initializing || 'Initializing wiki generation...'); - // Convert tree data to a string representation - fileTreeData = treeData.tree - .filter((item: { type: string; path: string }) => item.type === 'blob') - .map((item: { type: string; path: string }) => item.path) - .join('\n'); - - // Try to fetch README.md content - try { - const headers = createGithubHeaders(currentToken); - - const readmeResponse = await fetch(`${githubApiBaseUrl}/repos/${owner}/${repo}/readme`, { - headers - }); - - if (readmeResponse.ok) { - const readmeData = await readmeResponse.json(); - readmeContent = atob(readmeData.content); - } else { - console.warn(`Could not fetch README.md, status: ${readmeResponse.status}`); - } - } catch (err) { - console.warn('Could not fetch README.md, continuing with empty README', err); - } + const messageForStatus = (status: string): string => { + switch (status) { + case 'pending': + case 'indexing': + return messages.loading?.preparingIndex || 'Preparing repository index...'; + case 'determining_structure': + return messages.loading?.determiningStructure || 'Determining wiki structure...'; + case 'generating': + return messages.loading?.generatingPages || messages.common?.loading || 'Generating wiki pages...'; + default: + return messages.common?.loading || 'Loading...'; } - else if (effectiveRepoInfo.type === 'gitlab') { - // GitLab API approach - const projectPath = extractUrlPath(effectiveRepoInfo.repoUrl ?? '')?.replace(/\.git$/, '') || `${owner}/${repo}`; - const projectDomain = extractUrlDomain(effectiveRepoInfo.repoUrl ?? "https://gitlab.com"); - const encodedProjectPath = encodeURIComponent(projectPath); - - const headers = createGitlabHeaders(currentToken); - - /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ - const filesData: any[] = []; - - try { - // Step 1: Get project info to determine default branch - let projectInfoUrl: string; - let defaultBranchLocal = 'main'; // fallback - try { - const validatedUrl = new URL(projectDomain ?? ''); // Validate domain - projectInfoUrl = `${validatedUrl.origin}/api/v4/projects/${encodedProjectPath}`; - } catch (err) { - throw new Error(`Invalid project domain URL: ${projectDomain}`); - } - const projectInfoRes = await fetch(projectInfoUrl, { headers }); - - if (!projectInfoRes.ok) { - const errorData = await projectInfoRes.text(); - throw new Error(`GitLab project info error: Status ${projectInfoRes.status}, Response: ${errorData}`); - } - - const projectInfo = await projectInfoRes.json(); - defaultBranchLocal = projectInfo.default_branch || 'main'; - console.log(`Found GitLab default branch: ${defaultBranchLocal}`); - // Store the default branch in state - setDefaultBranch(defaultBranchLocal); - - // 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 }); - - if (!response.ok) { - const errorData = await response.text(); - throw new Error(`Error fetching GitLab repository structure (page ${page}): ${errorData}`); - } - - const pageData = await response.json(); - filesData.push(...pageData); - - const nextPage = response.headers.get('x-next-page'); - morePages = !!nextPage; - page = nextPage ? parseInt(nextPage, 10) : page + 1; - } + }; - if (!Array.isArray(filesData) || filesData.length === 0) { - throw new Error('Could not fetch repository structure. Repository might be empty or inaccessible.'); - } + try { + const model = isCustomSelectedModelState ? customSelectedModelState : selectedModelState; + const result = await submitWikiTask({ + repo_url: getRepoUrl(effectiveRepoInfo), + type: effectiveRepoInfo.type, + owner: effectiveRepoInfo.owner, + repo: effectiveRepoInfo.repo, + comprehensive: isComprehensiveView, + token: currentToken || undefined, + provider: selectedProviderState || undefined, + model: model || undefined, + language, + excluded_dirs: modelExcludedDirs || undefined, + excluded_files: modelExcludedFiles || undefined, + included_dirs: modelIncludedDirs || undefined, + included_files: modelIncludedFiles || undefined, + }); - // Step 3: Format file paths - fileTreeData = filesData - .filter((item: { type: string; path: string }) => item.type === 'blob') - .map((item: { type: string; path: string }) => item.path) - .join('\n'); - - // Step 4: Try to fetch README.md content - const readmeUrl = `${projectInfoUrl}/repository/files/README.md/raw`; - try { - const readmeResponse = await fetch(readmeUrl, { headers }); - if (readmeResponse.ok) { - readmeContent = await readmeResponse.text(); - console.log('Successfully fetched GitLab README.md'); - } else { - console.warn(`Could not fetch GitLab README.md status: ${readmeResponse.status}`); - } - } catch (err) { - console.warn(`Error fetching GitLab README.md:`, err); - } - } catch (err) { - console.error("Error during GitLab repository tree retrieval:", err); - throw err; + // The variant is already generated: load it straight from the cache. + if (result.from_cache) { + const ok = await loadWikiFromServerCache(); + if (!ok) { + setError('Wiki reported as cached but could not be loaded from the server.'); + setIsLoading(false); + setLoadingMessage(undefined); } + return; } - else if (effectiveRepoInfo.type === 'bitbucket') { - // Bitbucket API approach - const repoPath = extractUrlPath(effectiveRepoInfo.repoUrl ?? '') ?? `${owner}/${repo}`; - const encodedRepoPath = encodeURIComponent(repoPath); - - // Try to get the file tree for common branch names - let filesData = null; - let apiErrorDetails = ''; - let defaultBranchLocal = ''; - const headers = createBitbucketHeaders(currentToken); - - // First get project info to determine default branch - const projectInfoUrl = `https://api.bitbucket.org/2.0/repositories/${encodedRepoPath}`; - try { - const response = await fetch(projectInfoUrl, { headers }); - - const responseText = await response.text(); - - if (response.ok) { - const projectData = JSON.parse(responseText); - defaultBranchLocal = projectData.mainbranch.name; - // Store the default branch in state - setDefaultBranch(defaultBranchLocal); - - const apiUrl = `https://api.bitbucket.org/2.0/repositories/${encodedRepoPath}/src/${defaultBranchLocal}/?recursive=true&per_page=100`; - try { - const response = await fetch(apiUrl, { - headers - }); - - const structureResponseText = await response.text(); - - if (response.ok) { - filesData = JSON.parse(structureResponseText); - } else { - const errorData = structureResponseText; - apiErrorDetails = `Status: ${response.status}, Response: ${errorData}`; - } - } catch (err) { - console.error(`Network error fetching Bitbucket branch ${defaultBranchLocal}:`, err); - } - } else { - const errorData = responseText; - apiErrorDetails = `Status: ${response.status}, Response: ${errorData}`; - } - } catch (err) { - console.error("Network error fetching Bitbucket project info:", err); - } - if (!filesData || !Array.isArray(filesData.values) || filesData.values.length === 0) { - if (apiErrorDetails) { - throw new Error(`Could not fetch repository structure. Bitbucket API Error: ${apiErrorDetails}`); - } else { - throw new Error('Could not fetch repository structure. Repository might not exist, be empty or private.'); - } + // Otherwise follow the (new or joined) task via its SSE progress stream. + const applyStatus = (status: WikiTaskStatusDto) => { + setGenerationProgress(status); + setLoadingMessage(messageForStatus(status.status)); + if (status.wiki_structure) { + const structure = toWikiStructure(status.wiki_structure); + setWikiStructure(prev => prev ?? structure); + setCurrentPageId(prev => prev ?? (structure.pages[0]?.id)); } + }; - // Convert files data to a string representation - fileTreeData = filesData.values - .filter((item: { type: string; path: string }) => item.type === 'commit_file') - .map((item: { type: string; path: string }) => item.path) - .join('\n'); - - // Try to fetch README.md content - try { - const headers = createBitbucketHeaders(currentToken); - - const readmeResponse = await fetch(`https://api.bitbucket.org/2.0/repositories/${encodedRepoPath}/src/${defaultBranchLocal}/README.md`, { - headers - }); - - if (readmeResponse.ok) { - readmeContent = await readmeResponse.text(); - } else { - console.warn(`Could not fetch Bitbucket README.md, status: ${readmeResponse.status}`); + taskUnsubRef.current = subscribeWikiTask(result.task_id, { + onProgress: applyStatus, + onDone: async (status) => { + if (status) setGenerationProgress(status); + const ok = await loadWikiFromServerCache(); + if (!ok) { + setError('Wiki generation finished but its result could not be loaded.'); + setIsLoading(false); + setLoadingMessage(undefined); } - } catch (err) { - console.warn('Could not fetch Bitbucket README.md, continuing with empty README', err); - } - } - - // 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); - - } catch (error) { - console.error('Error fetching repository structure:', error); + }, + onError: async (message) => { + // The task may have completed and expired (TTL) before we could read a + // terminal event; try the cache before surfacing an error. + const ok = await loadWikiFromServerCache(); + if (ok) return; + if (message.toLowerCase().includes('ollama') && message.toLowerCase().includes('not found')) { + setEmbeddingError(true); + } + setError(message); + setIsLoading(false); + setLoadingMessage(undefined); + }, + }); + } catch (err) { + console.error('Error starting wiki generation:', err); + setError(err instanceof Error ? err.message : 'An unknown error occurred'); setIsLoading(false); - setError(error instanceof Error ? error.message : 'An unknown error occurred'); setLoadingMessage(undefined); - } finally { - // Reset the request in progress flag - setRequestInProgress(false); } - }, [owner, repo, determineWikiStructure, currentToken, effectiveRepoInfo, requestInProgress, messages.loading, selectedProviderState, selectedModelState, isCustomSelectedModelState, customSelectedModelState, language, modelExcludedDirs, modelExcludedFiles, modelIncludedDirs, modelIncludedFiles]); + }, [ + effectiveRepoInfo, + currentToken, + selectedProviderState, + selectedModelState, + isCustomSelectedModelState, + customSelectedModelState, + language, + isComprehensiveView, + modelExcludedDirs, + modelExcludedFiles, + modelIncludedDirs, + modelIncludedFiles, + messages.loading, + messages.common?.loading, + loadWikiFromServerCache, + toWikiStructure, + ]); + + // Close the SSE task stream when the component unmounts. + useEffect(() => { + return () => { + taskUnsubRef.current?.(); + taskUnsubRef.current = null; + }; + }, []); // Function to export wiki content const exportWiki = useCallback(async (format: 'markdown' | 'json') => { @@ -1882,31 +741,10 @@ IMPORTANT: cacheLoadedSuccessfully.current = false; effectRan.current = false; // Allow the main data loading useEffect to run again - // Reset all state - setWikiStructure(undefined); - setCurrentPageId(undefined); - setGeneratedPages({}); - setPagesInProgress(new Set()); - setError(null); - setEmbeddingError(false); // Reset embedding error state - setIsLoading(true); // Set loading state for refresh - setLoadingMessage(messages.loading?.initializing || 'Initializing wiki generation...'); - - // Clear any in-progress requests for page content - activeContentRequests.clear(); - // Reset flags related to request processing if they are component-wide - setStructureRequestInProgress(false); // Assuming this flag should be reset - setRequestInProgress(false); // Assuming this flag should be reset - - // Explicitly trigger the data loading process again by re-invoking what the main useEffect does. - // This will first attempt to load from (now hopefully non-existent or soon-to-be-overwritten) server cache, - // then proceed to fetchRepositoryStructure if needed. - // To ensure fetchRepositoryStructure is called if cache is somehow still there or to force a full refresh: - // One option is to directly call fetchRepositoryStructure() if force refresh means bypassing cache check. - // For now, we rely on the standard loadData flow initiated by resetting effectRan and dependencies. - // This will re-trigger the main data loading useEffect. - // No direct call to fetchRepositoryStructure here, let the useEffect handle it based on effectRan.current = false. - }, [effectiveRepoInfo, language, messages.loading, activeContentRequests, selectedProviderState, selectedModelState, isCustomSelectedModelState, customSelectedModelState, modelExcludedDirs, modelExcludedFiles, isComprehensiveView, authCode, authRequired]); + // The server cache was just cleared, so submit a fresh generation task + // directly. startGeneration() resets all wiki/progress/error state itself. + await startGeneration(); + }, [effectiveRepoInfo, language, messages.loading, selectedProviderState, selectedModelState, isCustomSelectedModelState, customSelectedModelState, modelExcludedDirs, modelExcludedFiles, isComprehensiveView, authCode, authRequired, startGeneration]); // Start wiki generation when component mounts useEffect(() => { @@ -1914,177 +752,13 @@ IMPORTANT: effectRan.current = true; // Set to true immediately to prevent re-entry due to StrictMode const loadData = async () => { - // Try loading from server-side cache first - setLoadingMessage(messages.loading?.fetchingCache || 'Checking for cached wiki...'); - try { - const params = new URLSearchParams({ - owner: effectiveRepoInfo.owner, - repo: effectiveRepoInfo.repo, - repo_type: effectiveRepoInfo.type, - language: language, - comprehensive: isComprehensiveView.toString(), - }); - const response = await fetch(`/api/wiki_cache?${params.toString()}`); - - if (response.ok) { - const cachedData = await response.json(); // Returns null if no cache - if (cachedData && cachedData.wiki_structure && cachedData.generated_pages && Object.keys(cachedData.generated_pages).length > 0) { - console.log('Using server-cached wiki data'); - if(cachedData.model) { - setSelectedModelState(cachedData.model); - } - if(cachedData.provider) { - setSelectedProviderState(cachedData.provider); - } - - // Update repoInfo - if(cachedData.repo) { - setEffectiveRepoInfo(cachedData.repo); - } else if (cachedData.repo_url && !effectiveRepoInfo.repoUrl) { - const updatedRepoInfo = { ...effectiveRepoInfo, repoUrl: cachedData.repo_url }; - setEffectiveRepoInfo(updatedRepoInfo); // Update effective repo info state - console.log('Using cached repo_url:', cachedData.repo_url); - } - - // Ensure the cached structure has sections and rootSections - const cachedStructure = { - ...cachedData.wiki_structure, - sections: cachedData.wiki_structure.sections || [], - rootSections: cachedData.wiki_structure.rootSections || [] - }; - - // If sections or rootSections are missing, create intelligent ones based on page titles - if (!cachedStructure.sections.length || !cachedStructure.rootSections.length) { - const pages = cachedStructure.pages; - const sections: WikiSection[] = []; - const rootSections: string[] = []; - - // Group pages by common prefixes or categories - const pageClusters = new Map(); - - // Define common categories that might appear in page titles - const categories = [ - { id: 'overview', title: 'Overview', keywords: ['overview', 'introduction', 'about'] }, - { id: 'architecture', title: 'Architecture', keywords: ['architecture', 'structure', 'design', 'system'] }, - { id: 'features', title: 'Core Features', keywords: ['feature', 'functionality', 'core'] }, - { id: 'components', title: 'Components', keywords: ['component', 'module', 'widget'] }, - { id: 'api', title: 'API', keywords: ['api', 'endpoint', 'service', 'server'] }, - { id: 'data', title: 'Data Flow', keywords: ['data', 'flow', 'pipeline', 'storage'] }, - { id: 'models', title: 'Models', keywords: ['model', 'ai', 'ml', 'integration'] }, - { id: 'ui', title: 'User Interface', keywords: ['ui', 'interface', 'frontend', 'page'] }, - { id: 'setup', title: 'Setup & Configuration', keywords: ['setup', 'config', 'installation', 'deploy'] } - ]; - - // Initialize clusters with empty arrays - categories.forEach(category => { - pageClusters.set(category.id, []); - }); - - // Add an "Other" category for pages that don't match any category - pageClusters.set('other', []); - - // Assign pages to categories based on title keywords - pages.forEach((page: WikiPage) => { - const title = page.title.toLowerCase(); - let assigned = false; - - // Try to find a matching category - for (const category of categories) { - if (category.keywords.some(keyword => title.includes(keyword))) { - pageClusters.get(category.id)?.push(page); - assigned = true; - break; - } - } - - // If no category matched, put in "Other" - if (!assigned) { - pageClusters.get('other')?.push(page); - } - }); - - // Create sections for non-empty categories - for (const [categoryId, categoryPages] of pageClusters.entries()) { - if (categoryPages.length > 0) { - const category = categories.find(c => c.id === categoryId) || - { id: categoryId, title: categoryId === 'other' ? 'Other' : categoryId.charAt(0).toUpperCase() + categoryId.slice(1) }; - - const sectionId = `section-${categoryId}`; - sections.push({ - id: sectionId, - title: category.title, - pages: categoryPages.map((p: WikiPage) => p.id) - }); - rootSections.push(sectionId); - - // Update page parentId - categoryPages.forEach((page: WikiPage) => { - page.parentId = sectionId; - }); - } - } - - // If we still have no sections (unlikely), fall back to importance-based grouping - if (sections.length === 0) { - const highImportancePages = pages.filter((p: WikiPage) => p.importance === 'high').map((p: WikiPage) => p.id); - const mediumImportancePages = pages.filter((p: WikiPage) => p.importance === 'medium').map((p: WikiPage) => p.id); - const lowImportancePages = pages.filter((p: WikiPage) => p.importance === 'low').map((p: WikiPage) => p.id); - - if (highImportancePages.length > 0) { - sections.push({ - id: 'section-high', - title: 'Core Components', - pages: highImportancePages - }); - rootSections.push('section-high'); - } - - if (mediumImportancePages.length > 0) { - sections.push({ - id: 'section-medium', - title: 'Key Features', - pages: mediumImportancePages - }); - rootSections.push('section-medium'); - } - - if (lowImportancePages.length > 0) { - sections.push({ - id: 'section-low', - title: 'Additional Information', - pages: lowImportancePages - }); - rootSections.push('section-low'); - } - } - - cachedStructure.sections = sections; - cachedStructure.rootSections = rootSections; - } - - setWikiStructure(cachedStructure); - setGeneratedPages(cachedData.generated_pages); - setCurrentPageId(cachedStructure.pages.length > 0 ? cachedStructure.pages[0].id : undefined); - setIsLoading(false); - setEmbeddingError(false); - setLoadingMessage(undefined); - cacheLoadedSuccessfully.current = true; - return; // Exit if cache is successfully loaded - } else { - console.log('No valid wiki data in server cache or cache is empty.'); - } - } else { - // Log error but proceed to fetch structure, as cache is optional - console.error('Error fetching wiki cache from server:', response.status, await response.text()); - } - } catch (error) { - console.error('Error loading from server cache:', error); - // Proceed to fetch structure if cache loading fails + // Try the server-side wiki cache first; if there is nothing to render, + // submit a backend generation task and follow it via SSE. + const loaded = await loadWikiFromServerCache(); + if (loaded) { + return; } - - // If we reached here, either there was no cache, it was invalid, or an error occurred - // Proceed to fetch repository structure - fetchRepositoryStructure(); + await startGeneration(); }; loadData(); @@ -2095,7 +769,7 @@ IMPORTANT: // Clean up function for this effect is not strictly necessary for loadData, // but keeping the main unmount cleanup in the other useEffect - }, [effectiveRepoInfo, effectiveRepoInfo.owner, effectiveRepoInfo.repo, effectiveRepoInfo.type, language, fetchRepositoryStructure, messages.loading?.fetchingCache, isComprehensiveView]); + }, [effectiveRepoInfo.owner, effectiveRepoInfo.repo, effectiveRepoInfo.type, language, isComprehensiveView, loadWikiFromServerCache, startGeneration]); // Save wiki to server-side cache when generation is complete useEffect(() => { @@ -2160,6 +834,23 @@ IMPORTANT: const [isModelSelectionModalOpen, setIsModelSelectionModalOpen] = useState(false); + // Progress figures for the loading UI. Prefer the backend task progress + // (SPEC.md: pages_done / pages_total + currently-processing page ids); fall + // back to the local in-progress set for backward compatibility. + const progressTotal = generationProgress?.pages_total || wikiStructure?.pages.length || 0; + const progressDone = generationProgress + ? generationProgress.pages_done + : (wikiStructure ? wikiStructure.pages.length - pagesInProgress.size : 0); + // Pages still to come, in structure order (backend generates them in order). + // With per-page concurrency 1 the backend only reports a single in-flight id, + // so we surface the remaining backlog (done count onward) to keep the old + // "currently processing" list showing several upcoming titles. + const processingPageIds = generationProgress + ? (wikiStructure + ? wikiStructure.pages.slice(generationProgress.pages_done).map(p => p.id) + : generationProgress.current_page_ids) + : Array.from(pagesInProgress); + return (
@@ -2191,44 +882,44 @@ IMPORTANT:

{/* Progress bar for page generation */} - {wikiStructure && ( + {wikiStructure && progressTotal > 0 && (

{language === 'ja' - ? `${wikiStructure.pages.length}ページ中${wikiStructure.pages.length - pagesInProgress.size}ページ完了` + ? `${progressTotal}ページ中${progressDone}ページ完了` : messages.repoPage?.pagesCompleted ? messages.repoPage.pagesCompleted - .replace('{completed}', (wikiStructure.pages.length - pagesInProgress.size).toString()) - .replace('{total}', wikiStructure.pages.length.toString()) - : `${wikiStructure.pages.length - pagesInProgress.size} of ${wikiStructure.pages.length} pages completed`} + .replace('{completed}', progressDone.toString()) + .replace('{total}', progressTotal.toString()) + : `${progressDone} of ${progressTotal} pages completed`}

{/* Show list of in-progress pages */} - {pagesInProgress.size > 0 && ( + {processingPageIds.length > 0 && (

{messages.repoPage?.currentlyProcessing || 'Currently processing:'}

    - {Array.from(pagesInProgress).slice(0, 3).map(pageId => { + {processingPageIds.slice(0, 3).map(pageId => { const page = wikiStructure.pages.find(p => p.id === pageId); return page ?
  • {page.title}
  • : null; })} - {pagesInProgress.size > 3 && ( + {processingPageIds.length > 3 && (
  • {language === 'ja' - ? `...他に${pagesInProgress.size - 3}ページ` + ? `...他に${processingPageIds.length - 3}ページ` : messages.repoPage?.andMorePages - ? messages.repoPage.andMorePages.replace('{count}', (pagesInProgress.size - 3).toString()) - : `...and ${pagesInProgress.size - 3} more`} + ? messages.repoPage.andMorePages.replace('{count}', (processingPageIds.length - 3).toString()) + : `...and ${processingPageIds.length - 3} more`}
  • )}
diff --git a/src/app/api/wiki/tasks/[task_id]/route.ts b/src/app/api/wiki/tasks/[task_id]/route.ts new file mode 100644 index 000000000..14a7abea6 --- /dev/null +++ b/src/app/api/wiki/tasks/[task_id]/route.ts @@ -0,0 +1,26 @@ +import { NextRequest, NextResponse } from 'next/server'; + +const TARGET_SERVER_BASE_URL = process.env.SERVER_BASE_URL || 'http://localhost:8001'; + +// GET /api/wiki/tasks/:task_id -> single task status (404 once the task is gone). +export async function GET( + _req: NextRequest, + { params }: { params: Promise<{ task_id: string }> }, +) { + const { task_id } = await params; + try { + const res = await fetch( + `${TARGET_SERVER_BASE_URL}/wiki/tasks/${encodeURIComponent(task_id)}`, + ); + return new NextResponse(await res.text(), { + status: res.status, + headers: { 'Content-Type': 'application/json' }, + }); + } catch (error) { + console.error('Error in /api/wiki/tasks/[task_id] GET proxy:', error); + return new NextResponse(JSON.stringify({ error: 'Failed to fetch wiki task' }), { + status: 502, + headers: { 'Content-Type': 'application/json' }, + }); + } +} diff --git a/src/app/api/wiki/tasks/[task_id]/stream/route.ts b/src/app/api/wiki/tasks/[task_id]/stream/route.ts new file mode 100644 index 000000000..a4685e95d --- /dev/null +++ b/src/app/api/wiki/tasks/[task_id]/stream/route.ts @@ -0,0 +1,40 @@ +import { NextRequest, NextResponse } from 'next/server'; + +const TARGET_SERVER_BASE_URL = process.env.SERVER_BASE_URL || 'http://localhost:8001'; + +// GET /api/wiki/tasks/:task_id/stream -> proxy the backend SSE progress stream. +export async function GET( + _req: NextRequest, + { params }: { params: Promise<{ task_id: string }> }, +) { + const { task_id } = await params; + try { + const upstream = await fetch( + `${TARGET_SERVER_BASE_URL}/wiki/tasks/${encodeURIComponent(task_id)}/stream`, + { headers: { Accept: 'text/event-stream' } }, + ); + + if (!upstream.ok || !upstream.body) { + return new NextResponse(await upstream.text(), { + status: upstream.status || 502, + headers: { 'Content-Type': 'application/json' }, + }); + } + + return new NextResponse(upstream.body, { + status: 200, + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache, no-transform', + Connection: 'keep-alive', + 'X-Accel-Buffering': 'no', + }, + }); + } catch (error) { + console.error('Error in /api/wiki/tasks/[task_id]/stream GET proxy:', error); + return new NextResponse(JSON.stringify({ error: 'Failed to open task stream' }), { + status: 502, + headers: { 'Content-Type': 'application/json' }, + }); + } +} diff --git a/src/app/api/wiki/tasks/route.ts b/src/app/api/wiki/tasks/route.ts new file mode 100644 index 000000000..ebde614c1 --- /dev/null +++ b/src/app/api/wiki/tasks/route.ts @@ -0,0 +1,40 @@ +import { NextRequest, NextResponse } from 'next/server'; + +// Proxy for the backend wiki-task endpoints. +const TARGET_SERVER_BASE_URL = process.env.SERVER_BASE_URL || 'http://localhost:8001'; + +function json(text: string, status: number): NextResponse { + return new NextResponse(text, { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +// POST /api/wiki/tasks -> submit (get-or-create) a wiki-generation task. +export async function POST(req: NextRequest) { + try { + const body = await req.json(); + const res = await fetch(`${TARGET_SERVER_BASE_URL}/wiki/tasks`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + return json(await res.text(), res.status); + } catch (error) { + console.error('Error in /api/wiki/tasks POST proxy:', error); + return json(JSON.stringify({ error: 'Failed to submit wiki task' }), 502); + } +} + +// GET /api/wiki/tasks[?status=active|completed] -> list tasks. +export async function GET(req: NextRequest) { + try { + const status = req.nextUrl.searchParams.get('status'); + const qs = status ? `?status=${encodeURIComponent(status)}` : ''; + const res = await fetch(`${TARGET_SERVER_BASE_URL}/wiki/tasks${qs}`); + return json(await res.text(), res.status); + } catch (error) { + console.error('Error in /api/wiki/tasks GET proxy:', error); + return json(JSON.stringify({ error: 'Failed to list wiki tasks' }), 502); + } +} diff --git a/src/components/ProcessedProjects.tsx b/src/components/ProcessedProjects.tsx index 4fbed2451..d802ac581 100644 --- a/src/components/ProcessedProjects.tsx +++ b/src/components/ProcessedProjects.tsx @@ -3,6 +3,7 @@ import React, { useState, useEffect, useMemo } from 'react'; import Link from 'next/link'; import { FaTimes, FaTh, FaList } from 'react-icons/fa'; +import { listWikiTasks } from '@/utils/wikiTask'; // Interface should match the structure from the API interface ProcessedProject { @@ -13,6 +14,9 @@ interface ProcessedProject { repo_type: string; submittedAt: number; language: string; + // Present for the merged tasks list: 'completed' for cached wikis, otherwise + // a queued/in-progress task (pending | indexing | determining_structure | generating). + status?: string; } interface ProcessedProjectsProps { @@ -41,6 +45,7 @@ export default function ProcessedProjects({ noProjects: 'No projects found in the server cache. The cache might be empty or the server encountered an issue.', noSearchResults: 'No projects match your search criteria.', processedOn: 'Processed on:', + inProgress: 'In progress', loadingProjects: 'Loading projects...', errorLoading: 'Error loading projects:', backToHome: 'Back to Home' @@ -58,15 +63,20 @@ export default function ProcessedProjects({ setIsLoading(true); setError(null); try { - const response = await fetch('/api/wiki/projects'); - if (!response.ok) { - throw new Error(`Failed to fetch projects: ${response.statusText}`); - } - const data = await response.json(); - if (data.error) { - throw new Error(data.error); - } - setProjects(data as ProcessedProject[]); + // Merged list: completed wikis first, then queued/in-progress tasks last. + const data = await listWikiTasks(); + setProjects( + data.map(task => ({ + id: task.id, + owner: task.owner, + repo: task.repo, + name: task.name, + repo_type: task.repo_type, + language: task.language, + submittedAt: task.submitted_at, + status: task.status, + })), + ); } catch (e: unknown) { console.error("Failed to load projects from API:", e); const message = e instanceof Error ? e.message : "An unknown error occurred."; @@ -196,14 +206,16 @@ export default function ProcessedProjects({ {filteredProjects.map((project) => ( viewMode === 'card' ? (
- + {(!project.status || project.status === 'completed') && ( + + )} {project.language} + {project.status && project.status !== 'completed' && ( + + {t('inProgress')} + + )}

{t('processedOn')} {new Date(project.submittedAt).toLocaleDateString()} diff --git a/src/hooks/useProcessedProjects.ts b/src/hooks/useProcessedProjects.ts index 72acf78dc..11bb747a9 100644 --- a/src/hooks/useProcessedProjects.ts +++ b/src/hooks/useProcessedProjects.ts @@ -1,4 +1,5 @@ import { useState, useEffect } from 'react'; +import { listWikiTasks } from '@/utils/wikiTask'; interface ProcessedProject { id: string; @@ -8,6 +9,7 @@ interface ProcessedProject { repo_type: string; submittedAt: number; language: string; + status?: string; } export function useProcessedProjects() { @@ -20,15 +22,20 @@ export function useProcessedProjects() { setIsLoading(true); setError(null); try { - const response = await fetch('/api/wiki/projects'); - if (!response.ok) { - throw new Error(`Failed to fetch projects: ${response.statusText}`); - } - const data = await response.json(); - if (data.error) { - throw new Error(data.error); - } - setProjects(data as ProcessedProject[]); + // Merged list: completed wikis first, then queued/in-progress tasks last. + const data = await listWikiTasks(); + setProjects( + data.map(task => ({ + id: task.id, + owner: task.owner, + repo: task.repo, + name: task.name, + repo_type: task.repo_type, + language: task.language, + submittedAt: task.submitted_at, + status: task.status, + })), + ); } catch (e: unknown) { console.error("Failed to load projects from API:", e); const message = e instanceof Error ? e.message : "An unknown error occurred."; diff --git a/src/messages/en.json b/src/messages/en.json index 46abc2654..d42edc7d4 100644 --- a/src/messages/en.json +++ b/src/messages/en.json @@ -133,6 +133,7 @@ "noProjects": "No projects found in the server cache. The cache might be empty or the server encountered an issue.", "noSearchResults": "No projects match your search criteria.", "processedOn": "Processed on:", + "inProgress": "In progress", "loadingProjects": "Loading projects...", "errorLoading": "Error loading projects:", "backToHome": "Back to Home", diff --git a/src/messages/es.json b/src/messages/es.json index 76d092f44..d2fb5282a 100644 --- a/src/messages/es.json +++ b/src/messages/es.json @@ -124,6 +124,7 @@ "noProjects": "No se encontraron proyectos en la caché del servidor. La caché podría estar vacía o el servidor encontró un problema.", "noSearchResults": "Ningún proyecto coincide con sus criterios de búsqueda.", "processedOn": "Procesado el:", + "inProgress": "En progreso", "loadingProjects": "Cargando proyectos...", "errorLoading": "Error al cargar proyectos:", "backToHome": "Volver al Inicio", diff --git a/src/messages/fr.json b/src/messages/fr.json index 216979c88..a4a528393 100644 --- a/src/messages/fr.json +++ b/src/messages/fr.json @@ -133,6 +133,7 @@ "noProjects": "Aucun projet trouvé dans le cache du serveur. Le cache est peut-être vide ou le serveur a rencontré un problème.", "noSearchResults": "Aucun projet ne correspond à vos critères de recherche.", "processedOn": "Traité le :", + "inProgress": "En cours", "loadingProjects": "Chargement des projets...", "errorLoading": "Erreur lors du chargement des projets :", "backToHome": "Retour à l’accueil", diff --git a/src/messages/ja.json b/src/messages/ja.json index 2eea43cc4..28ea9460a 100644 --- a/src/messages/ja.json +++ b/src/messages/ja.json @@ -124,6 +124,7 @@ "noProjects": "サーバーキャッシュにプロジェクトが見つかりません。キャッシュが空であるか、サーバーで問題が発生した可能性があります。", "noSearchResults": "検索条件に一致するプロジェクトがありません。", "processedOn": "処理日時:", + "inProgress": "処理中", "loadingProjects": "プロジェクトを読み込み中...", "errorLoading": "プロジェクトの読み込みエラー:", "backToHome": "ホームに戻る", diff --git a/src/messages/kr.json b/src/messages/kr.json index 757aeb9ac..1b23f645e 100644 --- a/src/messages/kr.json +++ b/src/messages/kr.json @@ -124,6 +124,7 @@ "noProjects": "서버 캐시에서 프로젝트를 찾을 수 없습니다. 캐시가 비어있거나 서버에 문제가 발생했을 수 있습니다.", "noSearchResults": "검색 조건에 맞는 프로젝트가 없습니다.", "processedOn": "처리 날짜:", + "inProgress": "진행 중", "loadingProjects": "프로젝트 로딩 중...", "errorLoading": "프로젝트 로딩 오류:", "backToHome": "홈으로 돌아가기", diff --git a/src/messages/pt-br.json b/src/messages/pt-br.json index 35d6125ff..0d9699456 100644 --- a/src/messages/pt-br.json +++ b/src/messages/pt-br.json @@ -133,6 +133,7 @@ "noProjects": "Nenhum projeto encontrado no cache do servidor. O cache pode estar vazio ou o servidor encontrou um problema.", "noSearchResults": "Nenhum projeto corresponde aos seus critérios de pesquisa.", "processedOn": "Processado em:", + "inProgress": "Em andamento", "loadingProjects": "Carregando projetos...", "errorLoading": "Erro ao carregar projetos:", "backToHome": "Voltar ao Início", diff --git a/src/messages/ru.json b/src/messages/ru.json index c5e76e90d..17823a8bd 100644 --- a/src/messages/ru.json +++ b/src/messages/ru.json @@ -133,6 +133,7 @@ "noProjects": "На сервере не найдено проектов. Кеш может быть пуст или сервер столкнулся с проблемой.", "noSearchResults": "По вашему запросу проектов не найдено.", "processedOn": "Обработано:", + "inProgress": "В процессе", "loadingProjects": "Загрузка проектов...", "errorLoading": "Ошибка загрузки проектов:", "backToHome": "Назад на главную", diff --git a/src/messages/vi.json b/src/messages/vi.json index 6b0de2b7b..4d43d955e 100644 --- a/src/messages/vi.json +++ b/src/messages/vi.json @@ -124,6 +124,7 @@ "noProjects": "Không tìm thấy dự án nào trong bộ nhớ đệm máy chủ. Bộ nhớ đệm có thể trống hoặc máy chủ gặp sự cố.", "noSearchResults": "Không có dự án nào phù hợp với tiêu chí tìm kiếm của bạn.", "processedOn": "Xử lý vào:", + "inProgress": "Đang xử lý", "loadingProjects": "Đang tải dự án...", "errorLoading": "Lỗi khi tải dự án:", "backToHome": "Về trang chủ", diff --git a/src/messages/zh-tw.json b/src/messages/zh-tw.json index 28211255e..8c18f9af5 100644 --- a/src/messages/zh-tw.json +++ b/src/messages/zh-tw.json @@ -122,6 +122,7 @@ "noProjects": "伺服器快取中未找到專案。快取可能為空或伺服器遇到問題。", "noSearchResults": "沒有專案符合您的搜尋條件。", "processedOn": "處理時間:", + "inProgress": "處理中", "loadingProjects": "正在載入專案...", "errorLoading": "載入專案時發生錯誤:", "backToHome": "返回首頁", diff --git a/src/messages/zh.json b/src/messages/zh.json index 1a6deaaf0..b0b60bea3 100644 --- a/src/messages/zh.json +++ b/src/messages/zh.json @@ -124,6 +124,7 @@ "noProjects": "服务器缓存中未找到项目。缓存可能为空或服务器遇到问题。", "noSearchResults": "没有项目符合您的搜索条件。", "processedOn": "处理时间:", + "inProgress": "处理中", "loadingProjects": "正在加载项目...", "errorLoading": "加载项目时出错:", "backToHome": "返回首页", diff --git a/src/utils/wikiTask.ts b/src/utils/wikiTask.ts new file mode 100644 index 000000000..638411650 --- /dev/null +++ b/src/utils/wikiTask.ts @@ -0,0 +1,188 @@ +// Client helpers for the backend-driven wiki-generation Task model. +// +// The backend owns index + wiki generation as an asyncio Task (SPEC.md). The +// browser only: +// 1. submits a task (get-or-create, deduped per repo), then +// 2. subscribes to an SSE progress stream until a terminal done/error, then +// 3. loads the finished wiki from the server cache (handled by the caller). +// +// Wire field names match the backend exactly (snake_case, e.g. `submitted_at`). + +export type TaskStatusValue = + | 'pending' + | 'indexing' + | 'determining_structure' + | 'generating' + | 'completed' + | 'failed'; + +export interface WikiTaskSubmitRequest { + repo_url: string; + type: string; + owner: string; + repo: string; + comprehensive?: boolean; + token?: string; + provider?: string; + model?: string; + language?: string; + excluded_dirs?: string; + excluded_files?: string; + included_dirs?: string; + included_files?: string; +} + +export interface WikiTaskSubmitResult { + task_id: string; + status: TaskStatusValue | string; + created: boolean; + joined: boolean; + from_cache: boolean; +} + +export interface WikiTaskPageDto { + id: string; + title: string; + content: string; + filePaths: string[]; + importance: string; + relatedPages: string[]; +} + +export interface WikiTaskStructureDto { + id: string; + title: string; + description: string; + pages: WikiTaskPageDto[]; + sections?: unknown[] | null; + rootSections?: string[] | null; +} + +export interface WikiTaskSummaryDto { + id: string; + owner: string; + repo: string; + repo_type: string; + language: string; + status: TaskStatusValue | string; + pages_done: number; + pages_total: number; + current_page_ids: string[]; + error?: string | null; + submitted_at: number; + name: string; +} + +export interface WikiTaskStatusDto extends WikiTaskSummaryDto { + wiki_structure?: WikiTaskStructureDto | null; +} + +export interface SubscribeWikiTaskHandlers { + onProgress?: (status: WikiTaskStatusDto) => void; + onDone?: (status: WikiTaskStatusDto | null) => void; + onError?: (message: string) => void; +} + +// Drop undefined/empty-string values so we never send them to the backend. +function clean(req: WikiTaskSubmitRequest): Record { + const out: Record = {}; + for (const [k, v] of Object.entries(req)) { + if (v === undefined || v === null) continue; + if (typeof v === 'string' && v === '') continue; + out[k] = v; + } + return out; +} + +// Submit a repo for index + wiki generation (get-or-create; deduped per repo). +export async function submitWikiTask( + req: WikiTaskSubmitRequest, +): Promise { + const res = await fetch('/api/wiki/tasks', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(clean(req)), + }); + if (!res.ok) { + const detail = await res.text().catch(() => ''); + throw new Error(`Failed to submit wiki task (${res.status}): ${detail}`); + } + return res.json(); +} + +// List tasks. Omit `status` for the homepage list (completed first, queued last). +export async function listWikiTasks( + status?: 'active' | 'completed', +): Promise { + const qs = status ? `?status=${encodeURIComponent(status)}` : ''; + const res = await fetch(`/api/wiki/tasks${qs}`); + if (!res.ok) { + throw new Error(`Failed to list wiki tasks (${res.status})`); + } + return res.json(); +} + +// Single task status. Returns null on 404 (task is gone -> caller falls back to cache). +export async function getWikiTask( + taskId: string, +): Promise { + const res = await fetch(`/api/wiki/tasks/${encodeURIComponent(taskId)}`); + if (res.status === 404) { + return null; + } + if (!res.ok) { + throw new Error(`Failed to fetch wiki task (${res.status})`); + } + return res.json(); +} + +// Subscribe to the SSE progress stream. Returns an unsubscribe function. +// +// The backend emits three named events: `progress` (repeated), `done`, `error`. +// EventSource routes a server-sent `event: error` frame to the same listener as +// a connection-level error, so we distinguish them by the presence of `data`. +export function subscribeWikiTask( + taskId: string, + handlers: SubscribeWikiTaskHandlers, +): () => void { + const es = new EventSource( + `/api/wiki/tasks/${encodeURIComponent(taskId)}/stream`, + ); + + const parse = (data: string): WikiTaskStatusDto | null => { + try { + return JSON.parse(data) as WikiTaskStatusDto; + } catch { + return null; + } + }; + + es.addEventListener('progress', (e) => { + const status = parse((e as MessageEvent).data); + if (status) handlers.onProgress?.(status); + }); + + es.addEventListener('done', (e) => { + const status = parse((e as MessageEvent).data); + es.close(); + handlers.onDone?.(status); + }); + + es.addEventListener('error', (e) => { + const me = e as MessageEvent; + // A server-sent `event: error` frame carries a JSON payload in `data`. + if (me && typeof me.data === 'string' && me.data) { + const status = parse(me.data); + es.close(); + handlers.onError?.(status?.error || 'Wiki generation failed'); + return; + } + // Otherwise this is a connection-level error. EventSource auto-retries while + // the connection is still open; only surface a failure once it has closed. + if (es.readyState === EventSource.CLOSED) { + handlers.onError?.('Task stream connection lost'); + } + }); + + return () => es.close(); +} diff --git a/tests/backend/routers/test_wiki_tasks_api.py b/tests/backend/routers/test_wiki_tasks_api.py new file mode 100644 index 000000000..0882c7e43 --- /dev/null +++ b/tests/backend/routers/test_wiki_tasks_api.py @@ -0,0 +1,279 @@ +import json +import time + +import pytest +from fastapi.testclient import TestClient + +import api.routers.wiki as wiki_router +import api.services.wiki.tasks as wt +from api.schemas import WikiPage, WikiStructureModel, WikiTaskRequest +from api.services.wiki.tasks import TaskStatus, WikiTask + + +@pytest.fixture(autouse=True) +def _clear_registry(): + wt.registry._tasks.clear() + yield + wt.registry._tasks.clear() + + +def _structure() -> WikiStructureModel: + return WikiStructureModel( + id="wiki", + title="T", + description="D", + pages=[ + WikiPage( + id="page-1", + title="P1", + content="", + filePaths=[], + importance="high", + relatedPages=[], + ) + ], + ) + + +def _patch_stubs(monkeypatch): + monkeypatch.setattr(wt, "wiki_cache_exists", lambda *p, **kwargs: False) + monkeypatch.setattr(wt, "repo_index_exist", lambda repo: True) # skip indexing + monkeypatch.setattr(wt, "WIKI_TASK_TTL_SECONDS", 5) + + async def fake_determine(task): + return _structure() + + async def fake_generate(task, page): + return page.model_copy(update={"content": "ok"}) + + async def fake_save(task, pages): + pass + + monkeypatch.setattr(wt, "_determine_structure", fake_determine) + monkeypatch.setattr(wt, "_generate_page", fake_generate) + monkeypatch.setattr(wt, "_save", fake_save) + + +def test_submit_then_progress_to_completed(monkeypatch): + _patch_stubs(monkeypatch) + from api.main import app + + with TestClient(app) as client: + body = { + "owner": "o", + "repo": "r", + "type": "github", + "repo_url": "https://github.com/o/r", + "language": "en", + } + r = client.post("/wiki/tasks", json=body) + assert r.status_code == 200, r.text + data = r.json() + assert data["created"] is True and data["status"] == "pending" + task_id = data["task_id"] + assert task_id == "github_o_r" + + for _ in range(50): + g = client.get(f"/wiki/tasks/{task_id}") + if g.status_code == 200 and g.json()["status"] == "completed": + break + time.sleep(0.1) + else: + pytest.fail("task did not reach completed") + + done = client.get(f"/wiki/tasks/{task_id}").json() + assert done["pages_total"] == 1 and done["pages_done"] == 1 + assert "token" not in done + + +def test_submit_twice_joins(monkeypatch): + _patch_stubs(monkeypatch) + # make generation block so the first task stays active for the second submit + started = {"go": False} + + async def slow_generate(task, page): + while not started["go"]: + import asyncio + + await asyncio.sleep(0.02) + return page.model_copy(update={"content": "ok"}) + + monkeypatch.setattr(wt, "_generate_page", slow_generate) + from api.main import app + + with TestClient(app) as client: + body = {"owner": "o", "repo": "r", "type": "github", "repo_url": "https://github.com/o/r"} + r1 = client.post("/wiki/tasks", json=body).json() + # second submit (different language) must JOIN the active task + r2 = client.post("/wiki/tasks", json={**body, "language": "ja"}).json() + assert r2["joined"] is True and r2["created"] is False + assert r2["task_id"] == r1["task_id"] + started["go"] = True + + +def test_list_and_unknown(monkeypatch): + from api.main import app + + with TestClient(app) as client: + assert client.get("/wiki/tasks").status_code == 200 + assert isinstance(client.get("/wiki/tasks").json(), list) + assert client.get("/wiki/tasks?status=active").json() == [] + assert client.get("/wiki/tasks/nope_nope_nope").status_code == 404 + + +def test_list_summary_omits_wiki_structure(monkeypatch): + _patch_stubs(monkeypatch) + gate = {"go": False} + + async def slow_generate(task, page): + import asyncio + + while not gate["go"]: + await asyncio.sleep(0.02) + return page.model_copy(update={"content": "ok"}) + + monkeypatch.setattr(wt, "_generate_page", slow_generate) + from api.main import app + + with TestClient(app) as client: + body = {"owner": "o", "repo": "r", "type": "github", "repo_url": "https://github.com/o/r"} + tid = client.post("/wiki/tasks", json=body).json()["task_id"] + + entry = None + for _ in range(50): + lst = client.get("/wiki/tasks?status=active").json() + if lst: + entry = lst[0] + break + time.sleep(0.05) + assert entry is not None, "task never appeared in the active list" + + # list uses WikiTaskSummary -> no wiki_structure field at all + assert "wiki_structure" not in entry + assert {"id", "status", "pages_done", "pages_total", "submitted_at"} <= set(entry) + + # single endpoint uses WikiTaskStatus -> wiki_structure field present + single = client.get(f"/wiki/tasks/{tid}").json() + assert "wiki_structure" in single + + gate["go"] = True + + +# --------------------------------------------------------------------------- # +# GET /wiki/tasks/{task_id}/stream (SSE) +# --------------------------------------------------------------------------- # +def _sse_first_data(text: str) -> dict: + """Return the first `data:` frame in an SSE body, parsed as JSON.""" + line = next(l for l in text.splitlines() if l.startswith("data:")) + return json.loads(line[len("data:"):].strip()) + + +def test_stream_unknown_task_returns_404(): + from api.main import app + + with TestClient(app) as client: + assert client.get("/wiki/tasks/does_not_exist/stream").status_code == 404 + + +def test_stream_completed_emits_done_event(monkeypatch): + _patch_stubs(monkeypatch) + from api.main import app + + with TestClient(app) as client: + body = {"owner": "o", "repo": "r", "type": "github", "repo_url": "https://github.com/o/r"} + tid = client.post("/wiki/tasks", json=body).json()["task_id"] + + # Let the task finish first; a terminal task makes the stream emit a + # single `done` frame and return immediately (no hanging read). + for _ in range(50): + if client.get(f"/wiki/tasks/{tid}").json()["status"] == "completed": + break + time.sleep(0.1) + else: + pytest.fail("task did not complete") + + r = client.get(f"/wiki/tasks/{tid}/stream") + assert r.status_code == 200 + assert r.headers["content-type"].startswith("text/event-stream") + assert "event: done" in r.text + + payload = _sse_first_data(r.text) + assert payload["status"] == "completed" + assert payload["pages_done"] == payload["pages_total"] == 1 + assert "token" not in payload # token must never be streamed + + +def test_stream_failed_emits_error_event(monkeypatch): + _patch_stubs(monkeypatch) + + async def boom(task): + raise RuntimeError("structure boom") + + monkeypatch.setattr(wt, "_determine_structure", boom) + from api.main import app + + with TestClient(app) as client: + body = {"owner": "o", "repo": "r", "type": "github", "repo_url": "https://github.com/o/r"} + tid = client.post("/wiki/tasks", json=body).json()["task_id"] + + for _ in range(50): + if client.get(f"/wiki/tasks/{tid}").json()["status"] == "failed": + break + time.sleep(0.1) + else: + pytest.fail("task did not fail") + + r = client.get(f"/wiki/tasks/{tid}/stream") + assert r.status_code == 200 + assert "event: error" in r.text + + payload = _sse_first_data(r.text) + assert payload["status"] == "failed" + assert "structure boom" in (payload["error"] or "") + + +@pytest.mark.asyncio +async def test_stream_emits_progress_then_done(monkeypatch): + # Starlette's TestClient buffers a streaming response to completion before + # returning, so it cannot observe an intermediate `progress` frame while a + # task is still running. Drive the endpoint's SSE generator directly instead. + + # Collapse the 1s inter-frame delay so the test is fast + deterministic. + async def _no_sleep(*_a, **_k): + return + + monkeypatch.setattr(wiki_router.asyncio, "sleep", _no_sleep) + + task = WikiTask.from_wiki_request( + WikiTaskRequest( + owner="o", repo="r", type="github", repo_url="https://github.com/o/r" + ) + ) + task.status = TaskStatus.GENERATING + task.wiki_structure = _structure() # 1 page -> pages_total == 1 + task.current_page_ids = ["page-1"] + wt.registry._tasks[task.repo_key] = task + + resp = await wiki_router.stream_wiki_task(task.repo_key) + frames = resp.body_iterator + + # 1st frame: a progress event carrying the in-flight page id. + first = await frames.__anext__() + assert "event: progress" in first + progress = json.loads(first.split("data:", 1)[1].strip()) + assert progress["status"] == "generating" + assert progress["current_page_ids"] == ["page-1"] + assert "token" not in progress + + # Flip to terminal; the next frame must be the `done` event, then the + # generator returns (StopAsyncIteration). + task.status = TaskStatus.COMPLETED + task.pages_done = 1 + second = await frames.__anext__() + assert "event: done" in second + done = json.loads(second.split("data:", 1)[1].strip()) + assert done["status"] == "completed" + assert done["pages_done"] == done["pages_total"] == 1 + + with pytest.raises(StopAsyncIteration): + await frames.__anext__() diff --git a/tests/backend/services/test_wiki_content.py b/tests/backend/services/test_wiki_content.py new file mode 100644 index 000000000..33f92183b --- /dev/null +++ b/tests/backend/services/test_wiki_content.py @@ -0,0 +1,100 @@ +from api.services.wiki.content import ( + RepoUrlContext, + generate_file_url, + post_process_wiki_content, +) + +GITHUB = RepoUrlContext( + type="github", + repo_url="https://github.com/AsyncFuncAI/deepwiki-open", + default_branch="main", +) +BASE = "https://github.com/AsyncFuncAI/deepwiki-open/blob/main" + + +# --------------------------------------------------------------------------- # +# generate_file_url +# --------------------------------------------------------------------------- # +def test_generate_file_url_github(): + assert generate_file_url("README.md", GITHUB) == f"{BASE}/README.md" + + +def test_generate_file_url_gitlab(): + ctx = RepoUrlContext("gitlab", "https://gitlab.com/o/r", "dev") + assert generate_file_url("a/b.py", ctx) == "https://gitlab.com/o/r/-/blob/dev/a/b.py" + + +def test_generate_file_url_bitbucket(): + ctx = RepoUrlContext("bitbucket", "https://bitbucket.org/o/r", "main") + assert generate_file_url("a/b.py", ctx) == "https://bitbucket.org/o/r/src/main/a/b.py" + + +def test_generate_file_url_local_returns_bare_path(): + ctx = RepoUrlContext("local", None, "main") + assert generate_file_url("a/b.py", ctx) == "a/b.py" + + +# --------------------------------------------------------------------------- # +# post_process_wiki_content +# --------------------------------------------------------------------------- # +def test_resolves_citation_for_file_in_filepaths(): # case 1 + out = post_process_wiki_content("text [README.md:1-27]().", ["README.md"], GITHUB) + assert f"[README.md:1-27]({BASE}/README.md#L1-L27)" in out + assert "]()" not in out + + +def test_resolves_generic_citation_not_in_filepaths(): # case 2 + path = "src/i18n.ts" + out = post_process_wiki_content( + f"see [{path}:67-111]().", ["src/utils/getRepoUrl.tsx"], GITHUB + ) + assert f"[{path}:67-111]({BASE}/{path}#L67-L111)" in out + assert "]()" not in out + + +def test_strips_redundant_empty_parens_after_link(): # case 3 + path = "src/app/page.tsx" + text = f"x [{path}]({BASE}/{path})()" + assert post_process_wiki_content(text, [], GITHUB) == f"x [{path}]({BASE}/{path})" + + +def test_resolves_sources_prefix_bare_filename(): # variant 2 + full = "src/i18n.ts" + out = post_process_wiki_content("flow [Sources: i18n.ts:1-10]().", [full], GITHUB) + assert f"Sources: [{full}:1-10]({BASE}/{full}#L1-L10)" in out + assert "]()" not in out + + +def test_unknown_bare_filename_left_untouched(): + # not_exist.tsx not a basename of any filePath -> the citation stays unresolved. + # (The

block is still prepended because filePaths is non-empty.) + text = "[Sources: not_exist.tsx:1-47]()" + out = post_process_wiki_content(text, ["src/app/page.tsx"], GITHUB) + assert "[Sources: not_exist.tsx:1-47]()" in out + + +def test_rebuilds_details_block_when_missing(): + out = post_process_wiki_content("# Title", ["README.md"], GITHUB) + assert out.startswith("
") + assert f"[README.md]({BASE}/README.md)" in out + + +def test_local_repo_citations_not_resolved(): + ctx = RepoUrlContext("local", None, "main") + assert "[a/b.py:1-2]()" in post_process_wiki_content("[a/b.py:1-2]()", ["a/b.py"], ctx) + + +def test_escapes_brackets_in_dynamic_route_path(): + path = "src/app/[owner]/[repo]/page.tsx" + out = post_process_wiki_content(f"[{path}:10]()", [path], GITHUB) + assert "src/app/\\[owner\\]/\\[repo\\]/page.tsx:10](" in out + + +def test_single_line_citation(): + out = post_process_wiki_content("[README.md:15]()", ["README.md"], GITHUB) + assert f"[README.md:15]({BASE}/README.md#L15)" in out + + +def test_whole_file_citation_no_lines(): + out = post_process_wiki_content("[README.md]()", ["README.md"], GITHUB) + assert f"[README.md]({BASE}/README.md)" in out diff --git a/tests/backend/services/test_wiki_structure.py b/tests/backend/services/test_wiki_structure.py new file mode 100644 index 000000000..0b6a74bc3 --- /dev/null +++ b/tests/backend/services/test_wiki_structure.py @@ -0,0 +1,108 @@ +import pytest + +from api.services.wiki.structure import ( + detect_default_branch, + parse_wiki_structure, + read_repo_file_tree, +) + +COMPREHENSIVE_XML = """ + + My Wiki + A description + +
+ Overview + page-1 + section-2 +
+
+ Architecture + page-2 +
+
+ + + Intro + high + README.md + page-2 + + + Arch + medium + src/a.py + + +
+""" + + +def test_parse_comprehensive(): + s = parse_wiki_structure(COMPREHENSIVE_XML, comprehensive=True) + assert s.title == "My Wiki" + assert s.description == "A description" + assert [p.id for p in s.pages] == ["page-1", "page-2"] + assert s.pages[0].filePaths == ["README.md"] + assert s.pages[0].relatedPages == ["page-2"] + assert s.pages[0].importance == "high" + assert {sec.id for sec in s.sections} == {"section-1", "section-2"} + # section-2 is referenced by section-1 -> only section-1 is a root section + assert s.rootSections == ["section-1"] + + +def test_parse_concise_ignores_sections(): + xml = """Wd + Plow + a.py + """ + s = parse_wiki_structure(xml, comprehensive=False) + assert len(s.pages) == 1 and s.pages[0].importance == "low" + assert s.sections == [] + assert s.rootSections == [] + + +def test_parse_escapes_bare_ampersand(): + xml = """Frontend & Backendd + Phigh + a.py""" + s = parse_wiki_structure(xml, comprehensive=False) + assert s.title == "Frontend & Backend" # bare & was escaped then decoded back + assert len(s.pages) == 1 + + +def test_parse_regex_fallback_on_malformed_xml(): + # Mismatched makes strict XML parsing fail -> regex page extraction. + xml = """ + Broken</oops> + <pages><page id="page-1"><title>P1high + a.py + """ + s = parse_wiki_structure(xml, comprehensive=False) + assert [p.id for p in s.pages] == ["page-1"] + assert s.pages[0].filePaths == ["a.py"] + + +def test_parse_no_structure_raises(): + with pytest.raises(ValueError): + parse_wiki_structure("no xml here", comprehensive=False) + + +def test_read_repo_file_tree(tmp_path, exclude_test_config): + (tmp_path / "README.md").write_text("hello readme", encoding="utf-8") + (tmp_path / "src").mkdir() + (tmp_path / "src" / "a.py").write_text("x", encoding="utf-8") + (tmp_path / ".hidden").write_text("h", encoding="utf-8") + (tmp_path / "__pycache__").mkdir() + (tmp_path / "__pycache__" / "junk.pyc").write_text("j", encoding="utf-8") + + entries, readme = read_repo_file_tree(str(tmp_path)) + assert "README.md" in entries + assert "src/a.py" in entries + assert ".hidden" not in entries + assert not any("__pycache__" in e for e in entries) + assert readme == "hello readme" + + +def test_detect_default_branch_non_git_dir(tmp_path): + assert detect_default_branch(str(tmp_path)) == "main" diff --git a/tests/backend/services/test_wiki_task.py b/tests/backend/services/test_wiki_task.py new file mode 100644 index 000000000..937926d37 --- /dev/null +++ b/tests/backend/services/test_wiki_task.py @@ -0,0 +1,266 @@ +import asyncio + +import pytest + +import api.services.wiki.tasks as wt +from api.schemas import WikiPage, WikiStructureModel, WikiTaskRequest +from api.services.wiki.tasks import ( + TaskRegistry, + TaskStatus, + WikiTask, + generate_repo_wiki, +) + +pytestmark = pytest.mark.asyncio + + +def _req(**kw) -> WikiTask: + d = dict( + owner="o", + repo="r", + type="github", + repo_url="https://github.com/o/r", + ) + d.update(kw) + return WikiTask.from_wiki_request(WikiTaskRequest(**d)) + + +def _structure(n: int = 2) -> WikiStructureModel: + pages = [ + WikiPage( + id=f"page-{i}", + title=f"P{i}", + content="", + filePaths=[], + importance="high", + relatedPages=[], + ) + for i in range(1, n + 1) + ] + return WikiStructureModel(id="wiki", title="T", description="D", pages=pages) + + +async def _wait_active(reg: TaskRegistry, task_id: str) -> None: + for _ in range(50): + t = reg.get(task_id) + if t and t.status != TaskStatus.PENDING: + return + await asyncio.sleep(0) + + +# --------------------------------------------------------------------------- # +# submit() branches +# --------------------------------------------------------------------------- # +async def test_submit_creates_and_completes(monkeypatch): + reg = TaskRegistry() + monkeypatch.setattr(wt, "WIKI_TASK_TTL_SECONDS", 0) + monkeypatch.setattr(wt, "wiki_cache_exists", lambda **p: False) + monkeypatch.setattr(wt, "repo_index_exist", lambda repo: True) # skip indexing + + structure = _structure(2) + saved: dict = {} + + async def fake_determine(task): + return structure + + async def fake_generate(task, page): + return page.model_copy(update={"content": f"ok:{page.id}"}) + + async def fake_save(task, pages): + saved["pages"] = pages + + monkeypatch.setattr(wt, "_determine_structure", fake_determine) + monkeypatch.setattr(wt, "_generate_page", fake_generate) + monkeypatch.setattr(wt, "_save", fake_save) + + res = await reg.submit(_req(), async_func=generate_repo_wiki) + assert res.created and res.status == TaskStatus.PENDING + + task = reg.get(res.task_id) + await task.task # wait for the worker to finish + + assert task.status == TaskStatus.COMPLETED + assert task.pages_total == 2 and task.pages_done == 2 + assert saved["pages"]["page-1"].content == "ok:page-1" + + +async def test_submit_joins_active_task(monkeypatch): + reg = TaskRegistry(max_concurrent=2) + monkeypatch.setattr(wt, "WIKI_TASK_TTL_SECONDS", 0) + monkeypatch.setattr(wt, "wiki_cache_exists", lambda **p: False) + + gate = asyncio.Event() + + async def blocking_run(task: WikiTask) -> None: + task.status = TaskStatus.GENERATING + await gate.wait() + task.status = TaskStatus.COMPLETED + + + r1 = await reg.submit(_req(), blocking_run) + assert r1.created + await _wait_active(reg, r1.task_id) + + r2 = await reg.submit(_req(language="ja"), blocking_run) # different settings, same repo + assert r2.joined and not r2.created + assert r2.task_id == r1.task_id + + gate.set() + await reg.get(r1.task_id).task + + +async def test_submit_serves_cache(monkeypatch): + reg = TaskRegistry() + monkeypatch.setattr(wt, "wiki_cache_exists", lambda **p: True) + + res = await reg.submit(_req(), generate_repo_wiki) + assert res.from_cache and not res.created + assert res.status == TaskStatus.COMPLETED + assert reg.get(res.task_id) is None # no task was created + + +# --------------------------------------------------------------------------- # +# run_task() state machine +# --------------------------------------------------------------------------- # +async def test_page_failure_yields_placeholder_but_completes(monkeypatch): + monkeypatch.setattr(wt, "repo_index_exist", lambda repo: True) + monkeypatch.setattr(wt, "WIKI_PAGE_RETRIES", 1) + + saved: dict = {} + + async def fake_determine(task): + return _structure(1) + + async def failing_generate(task, page): + raise RuntimeError("boom") + + async def fake_save(task, pages): + saved.update(pages) + + monkeypatch.setattr(wt, "_determine_structure", fake_determine) + monkeypatch.setattr(wt, "_generate_page", failing_generate) + monkeypatch.setattr(wt, "_save", fake_save) + + task = _req() + await generate_repo_wiki(task) + + assert task.status == TaskStatus.COMPLETED # one bad page must not fail the task + assert "Error generating content: boom" in saved["page-1"].content + + +async def test_determine_structure_failure_fails_task(monkeypatch): + monkeypatch.setattr(wt, "repo_index_exist", lambda repo: True) + + async def boom(task): + raise RuntimeError("no structure") + + monkeypatch.setattr(wt, "_determine_structure", boom) + + task = _req() + await generate_repo_wiki(task) + + assert task.status == TaskStatus.FAILED + assert "no structure" in (task.error or "") + + +# --------------------------------------------------------------------------- # +# generate_page (real implementation) +# --------------------------------------------------------------------------- # +async def test_generate_page_strips_fences_and_resolves_citations(monkeypatch): + async def fake_research_chat(request): + async def gen(): + yield "```markdown\n" + yield "# P1\n\nExplained here [README.md:1-2]().\n" + yield "```" + + return gen() + + monkeypatch.setattr(wt, "research_chat", fake_research_chat) + + page = WikiPage( + id="page-1", + title="P1", + content="", + filePaths=["README.md"], + importance="high", + relatedPages=[], + ) + out = await wt._generate_page(_req(), page) + + # leading ```markdown fence stripped + assert "```markdown" not in out.content + #
block rebuilt from filePaths + assert out.content.startswith("
") + # empty citation resolved to a real GitHub URL with line anchor + assert ( + "[README.md:1-2](https://github.com/o/r/blob/main/README.md#L1-L2)" + in out.content + ) + + +# --------------------------------------------------------------------------- # +# determine_structure + full run_task (real content steps, patched boundaries) +# --------------------------------------------------------------------------- # +class _FakeRepo: + def __init__(self, *a, **k): + self.is_local = True + self.downloaded = True + self.save_path = "." + + +_STRUCT_XML = ( + "Td" + 'P1high' + "README.md" + "" +) + + +async def test_run_task_end_to_end(monkeypatch): + monkeypatch.setattr(wt, "WIKI_TASK_TTL_SECONDS", 0) + monkeypatch.setattr(wt, "repo_index_exist", lambda repo: True) + monkeypatch.setattr(wt, "Repo", _FakeRepo) + monkeypatch.setattr(wt, "detect_default_branch", lambda p: "main") + monkeypatch.setattr( + wt, "read_repo_file_tree", lambda p, *a, **k: ("README.md\nsrc/a.py", "readme") + ) + + async def fake_research(request): + # structure prompt contains ""; page prompt does not. + is_structure = "" in request.messages[-1].content + body = _STRUCT_XML if is_structure else "# P1\n\nExplained [README.md:1]()." + + async def gen(): + yield body + + return gen() + + monkeypatch.setattr(wt, "research_chat", fake_research) + + saved: dict = {} + + async def fake_save(task, pages): + saved["pages"] = pages + + monkeypatch.setattr(wt, "_save", fake_save) + + task = _req(comprehensive=False) + await generate_repo_wiki(task) + + assert task.status == TaskStatus.COMPLETED + assert task.default_branch == "main" + assert task.pages_total == 1 and task.pages_done == 1 + content = saved["pages"]["page-1"].content + assert content.startswith("
") + assert "[README.md:1](https://github.com/o/r/blob/main/README.md#L1)" in content + + +# --------------------------------------------------------------------------- # +# serialization +# --------------------------------------------------------------------------- # +async def test_public_dict_hides_token(): + task = _req(token="SECRET") + d = task.to_status().model_dump() + assert "token" not in d + assert "SECRET" not in str(d) + assert d["name"] == "o/r" and d["status"] == "pending" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 000000000..53d76fa38 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,20 @@ +import pytest + +@pytest.fixture +def exclude_test_config(monkeypatch): + from api.config import configs + + original_excluded_dirs: list[str] = configs["file_filters"]["excluded_dirs"].copy() + original_excluded_dirs.remove("./temp/") + original_excluded_dirs.remove("./tmp/") + original_excluded_files = configs["file_filters"]["excluded_files"] + + monkeypatch.setitem( + configs, + name="file_filters", + value={ + "excluded_dirs": original_excluded_dirs, + "excluded_files": original_excluded_files, + }, + ) + yield configs diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 000000000..a4987bcd3 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,62 @@ +import pytest + +from api.config import iterate_files + + +def make_repo(root): + (root / "README.md").write_text("") + (root / "CHANGELOG.md").write_text("") + (root / "yarn.lock").write_text("") # excluded + + folder = root / "folder" + folder.mkdir(exist_ok=True) + (folder / ".lock").write_text("") # excluded + (folder / "code.py").write_text("") + + ex_folder = root / ".venv" + ex_folder.mkdir(exist_ok=True) + (ex_folder / "file.txt").write_text("") + (ex_folder / ".gitignore").write_text("") + + + +def test_iterate_files_default_exclusive_mode(exclude_test_config, tmp_path): + make_repo(tmp_path) + + files = set(iterate_files(root_dir=str(tmp_path))) + assert files == { + "README.md", + "CHANGELOG.md", + "folder/code.py", + } + +@pytest.mark.parametrize( + "included_dirs", + [ + ["folder"], + ["./folder"], + ] +) +def test_iterate_files_included_dirs(exclude_test_config, tmp_path, included_dirs): + make_repo(tmp_path) + files = set(iterate_files(root_dir=str(tmp_path), included_dirs=included_dirs)) + assert files == {"folder/code.py"} + + +def test_iterate_files_included_files(exclude_test_config, tmp_path): + make_repo(tmp_path) + files = set(iterate_files(root_dir=str(tmp_path), included_files=["README.md"])) + assert files == {"README.md"} + + +@pytest.mark.parametrize( + "excluded_dirs", + [ + ["folder"], + ["./folder"], + ] +) +def test_iterate_files_excluded_dirs(exclude_test_config, tmp_path, excluded_dirs): + make_repo(tmp_path) + files = set(iterate_files(root_dir=str(tmp_path), excluded_dirs=excluded_dirs)) + assert files == {"README.md", "CHANGELOG.md"}