diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index 8bd28e9..d8eecc1 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -39,7 +39,7 @@ jobs: enable-cache: true - name: Install - run: uv pip install --system -e ".[dev,server,openai]" + run: uv pip install --system -e ".[dev,server,ui,openai]" - name: Lint (ruff) run: ruff check . diff --git a/.github/workflows/docker-beta.yml b/.github/workflows/docker-beta.yml index 449f6f8..73dc6c6 100644 --- a/.github/workflows/docker-beta.yml +++ b/.github/workflows/docker-beta.yml @@ -2,7 +2,7 @@ name: Docker (beta) # Build the container images on every PR (validation only, no push) and publish # the beta channel to GHCR on every push to master. Two images are built from one -# Dockerfile: the HTTP API (:beta) and the Streamlit UI (:beta-ui). +# Dockerfile: the HTTP API (:beta) and the web UI (:beta-ui). on: push: branches: [master] @@ -53,7 +53,7 @@ jobs: include: - target: server # HTTP/REST API suffix: "" - - target: ui # Streamlit UI + - target: ui # Web UI suffix: "-ui" steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 diff --git a/.gitignore b/.gitignore index 39bc6ca..e6c7802 100644 --- a/.gitignore +++ b/.gitignore @@ -38,9 +38,6 @@ node_modules/ # Ignore Git directory .git/ -# Ignore Streamlit temporary files -.streamlit/ - # Ignore logs and temporary files *.log *.tmp diff --git a/AGENTS.md b/AGENTS.md index 666a863..e5c5fef 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,7 +8,7 @@ - `coderag/store/`: `sqlite_store.py` (source of truth + FTS5) and `vector_index.py` (FAISS Flat/IVF cache). - `coderag/retrieval/`: Hybrid dense + BM25 search fused with RRF. - `coderag/indexer.py`, `coderag/watch.py`: Incremental indexing and the debounced watcher. -- `coderag/surfaces/`: `cli.py`, `http_api.py` (FastAPI), `streamlit_app.py` — thin adapters over the facade. +- `coderag/surfaces/`: `cli.py`, `http_api.py` (FastAPI), `webui.py` — thin adapters over the facade. - `tests/`: pytest suite (offline by default via the `fake` provider; real model behind `-m integration`). - `example.env` → copy to `.env`; CI lives in `.github/`. diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 9c15ede..99cb751 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -30,7 +30,7 @@ coderag/ │ ├── sqlite_store.py # files/chunks/vectors + FTS5 lexical search │ └── vector_index.py # FaissVectorIndex: Flat (exact) / IVF (scale) ├── retrieval/ # Hybrid search: dense + BM25, fused with RRF -└── surfaces/ # cli.py · http_api.py (FastAPI) · streamlit_app.py +└── surfaces/ # cli.py · http_api.py (FastAPI) · webui.py ``` ### Design invariants (don't break these) diff --git a/Dockerfile b/Dockerfile index d404308..f1d241f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,7 +2,7 @@ # CodeRAG container images — two build targets share one base: # docker build --target server -t coderag . # HTTP/REST API (port 8000) -# docker build --target ui -t coderag-ui . # Streamlit UI (port 8501) +# docker build --target ui -t coderag-ui . # Web UI (port 8501) # Published to GHCR as :beta / :beta-ui by .github/workflows/docker-beta.yml. ARG PYTHON_VERSION=3.12 @@ -47,18 +47,16 @@ HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ ENTRYPOINT ["coderag"] CMD ["serve", "--host", "0.0.0.0", "--port", "8000"] -# ---------- Streamlit UI image ---------- +# ---------- Web UI image ---------- FROM base AS ui # Include the LLM answer backends (openai covers self-hosted OpenAI-compatible # servers like Ollama/vLLM too) so the UI's "Generate LLM answer" works. RUN uv pip install --system --no-cache ".[ui,openai,anthropic]" -# `coderag ui` shells out to `streamlit run`; configure the server via env vars. -ENV STREAMLIT_SERVER_ADDRESS=0.0.0.0 \ - STREAMLIT_SERVER_PORT=8501 \ - STREAMLIT_SERVER_HEADLESS=true \ - STREAMLIT_BROWSER_GATHER_USAGE_STATS=false +# `coderag ui` serves the FastAPI/Jinja UI via uvicorn; host/port come from env. +ENV CODERAG_UI_HOST=0.0.0.0 \ + CODERAG_UI_PORT=8501 USER coderag EXPOSE 8501 HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \ - CMD ["python", "-c", "import sys,urllib.request as u; sys.exit(0 if u.urlopen('http://127.0.0.1:8501/_stcore/health').status==200 else 1)"] + CMD ["python", "-c", "import sys,urllib.request as u; sys.exit(0 if u.urlopen('http://127.0.0.1:8501/healthz').status==200 else 1)"] CMD ["coderag", "ui"] diff --git a/README.md b/README.md index 9e5b68c..cab9c1b 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ codebases**. Use it from the **CLI**, embed it as a **Python library**, self-hos - **Hybrid retrieval.** Dense vector search **+** BM25 keyword search, fused with Reciprocal Rank Fusion. Great at both "what does this *mean*" and exact-identifier lookups. - **Incremental & live.** Content-hashed indexing only re-embeds files that changed; a debounced watcher keeps the index current as you code. No duplicate or stale vectors. - **Built to scale.** Exact `Flat` search for small repos, automatic switch to approximate `IVF` past a threshold so it stays fast at 100k+ chunks. -- **Four surfaces, one engine.** CLI · Python library · HTTP/REST · Streamlit UI — all thin wrappers over the same `CodeRAG` object. +- **Four surfaces, one engine.** CLI · Python library · HTTP/REST · web UI — all thin wrappers over the same `CodeRAG` object. ## 🚀 Quick start @@ -39,7 +39,7 @@ codebases**. Use it from the **CLI**, embed it as a **Python library**, self-hos pip install -e . # core engine (local embeddings included) # optional extras: pip install -e ".[server]" # HTTP/REST API -pip install -e ".[ui]" # Streamlit web UI +pip install -e ".[ui]" # built-in web UI (FastAPI + Jinja + Pygments) pip install -e ".[openai]" # OpenAI (or self-hosted OpenAI-compatible) embeddings / answers pip install -e ".[anthropic]" # Anthropic (Claude) LLM answers pip install -e ".[all]" # everything above @@ -107,9 +107,13 @@ Self-host it once and point any number of custom apps or teammates at a big shar ### Web UI (`coderag ui`) -Streamlit app: search box, retrieved chunks with `path:line` citations and similarity -scores, a one-click **Reindex** button, and an optional streamed LLM answer (when an -OpenAI/Anthropic key or a self-hosted endpoint is configured). +A built-in, server-rendered web UI (FastAPI + Jinja, syntax highlighting via Pygments): +a search box with language/kind/path filters, results with `path:line` citations and +similarity scores, an in-browser **file viewer** (cited lines highlighted), a **file +browser**, index status, a one-click **Reindex**, and an optional streamed LLM answer +(when an OpenAI/Anthropic key or a self-hosted endpoint is configured). It is progressively +enhanced — every page works with JavaScript disabled, and there's no CDN/runtime network +dependency, so it stays local-first. ## 🐳 Docker (beta) @@ -128,7 +132,7 @@ curl "localhost:8000/search?q=where%20is%20retry%20handled&k=5" ``` ```bash -# Streamlit UI on :8501 +# Web UI on :8501 docker run --rm -p 8501:8501 \ -v "$PWD:/workspace:ro" -v coderag-index:/data \ ghcr.io/neverdecel/coderag:beta-ui @@ -237,7 +241,7 @@ Apache License 2.0 — see [LICENSE](LICENSE-2.0.txt). [FAISS](https://github.com/facebookresearch/faiss) · [fastembed](https://github.com/qdrant/fastembed) · [tree-sitter](https://tree-sitter.github.io/tree-sitter/) · [FastAPI](https://fastapi.tiangolo.com/) · -[Streamlit](https://streamlit.io/) · [watchdog](https://github.com/gorakhargosh/watchdog) +[Jinja](https://jinja.palletsprojects.com/) · [Pygments](https://pygments.org/) · [watchdog](https://github.com/gorakhargosh/watchdog) --- diff --git a/coderag/store/sqlite_store.py b/coderag/store/sqlite_store.py index b5a5bb4..8ebafd2 100644 --- a/coderag/store/sqlite_store.py +++ b/coderag/store/sqlite_store.py @@ -118,6 +118,22 @@ def all_file_paths(self) -> List[str]: rows = self._conn.execute("SELECT path FROM files").fetchall() return [r["path"] for r in rows] + def distinct_languages(self) -> List[str]: + """Languages present in the index, sorted — used to populate UI filters.""" + with self._lock: + rows = self._conn.execute( + "SELECT DISTINCT language FROM chunks ORDER BY language" + ).fetchall() + return [r["language"] for r in rows] + + def distinct_kinds(self) -> List[str]: + """Chunk kinds present in the index, sorted — used to populate UI filters.""" + with self._lock: + rows = self._conn.execute( + "SELECT DISTINCT kind FROM chunks ORDER BY kind" + ).fetchall() + return [r["kind"] for r in rows] + def upsert_file( self, path: str, language: str, content_hash: str, mtime: float ) -> int: diff --git a/coderag/surfaces/__init__.py b/coderag/surfaces/__init__.py index dfa6107..6f7a8bb 100644 --- a/coderag/surfaces/__init__.py +++ b/coderag/surfaces/__init__.py @@ -1 +1 @@ -"""User-facing surfaces: CLI, HTTP server, and Streamlit UI — all thin over the facade.""" +"""User-facing surfaces: CLI, HTTP server, and web UI — all thin over the facade.""" diff --git a/coderag/surfaces/cli.py b/coderag/surfaces/cli.py index d5b0014..0aef288 100644 --- a/coderag/surfaces/cli.py +++ b/coderag/surfaces/cli.py @@ -8,6 +8,7 @@ import argparse import json import logging +import os import sys import textwrap from pathlib import Path @@ -113,25 +114,21 @@ def cmd_serve(args: argparse.Namespace) -> int: def cmd_ui(args: argparse.Namespace) -> int: - import subprocess - - app = Path(__file__).with_name("streamlit_app.py") try: - return subprocess.call( - ["streamlit", "run", str(app), "--", *_passthrough(args)] - ) - except FileNotFoundError: - print("Streamlit is not installed. Install with: pip install 'coderag[ui]'") + from coderag.surfaces.webui import run_ui + except ImportError: + print("The web UI needs extra deps. Install with: pip install 'coderag[ui]'") return 1 + cr = CodeRAG(_build_config(args)) + host = args.host or os.getenv("CODERAG_UI_HOST") or "127.0.0.1" + port = args.port if args.port is not None else _env_port("CODERAG_UI_PORT", 8501) + run_ui(cr, host=host, port=port) + return 0 -def _passthrough(args: argparse.Namespace) -> List[str]: - out: List[str] = [] - if getattr(args, "watched_dir", None): - out += ["--watched-dir", str(args.watched_dir)] - if getattr(args, "store_dir", None): - out += ["--store-dir", str(args.store_dir)] - return out +def _env_port(key: str, default: int) -> int: + raw = os.getenv(key) + return int(raw) if raw and raw.isdigit() else default # --- parser --- @@ -197,7 +194,15 @@ def build_parser() -> argparse.ArgumentParser: _add_common(p_serve) p_serve.set_defaults(func=cmd_serve) - p_ui = sub.add_parser("ui", help="Launch the Streamlit web UI.") + p_ui = sub.add_parser("ui", help="Launch the built-in web UI.") + p_ui.add_argument( + "--host", + default=None, + help="Bind address (default 127.0.0.1 / CODERAG_UI_HOST).", + ) + p_ui.add_argument( + "--port", type=int, default=None, help="Port (default 8501 / CODERAG_UI_PORT)." + ) _add_common(p_ui) p_ui.set_defaults(func=cmd_ui) diff --git a/coderag/surfaces/static/app.css b/coderag/surfaces/static/app.css new file mode 100644 index 0000000..97c4684 --- /dev/null +++ b/coderag/surfaces/static/app.css @@ -0,0 +1,172 @@ +:root { + --bg: #f7f8fa; + --panel: #ffffff; + --ink: #1c2330; + --muted: #66707f; + --border: #e3e7ee; + --accent: #2f6df6; + --accent-ink: #ffffff; + --warn-bg: #fff7e6; + --warn-ink: #8a5d00; + --code-bg: #fbfcfe; + --radius: 10px; + --mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; +} + +* { box-sizing: border-box; } + +body { + margin: 0; + font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + color: var(--ink); + background: var(--bg); + line-height: 1.5; +} + +a { color: var(--accent); text-decoration: none; } +a:hover { text-decoration: underline; } +code { font-family: var(--mono); font-size: 0.85em; } + +/* --- top bar --- */ +.topbar { + display: flex; align-items: center; gap: 1.5rem; + padding: 0.7rem 1.25rem; + background: var(--panel); + border-bottom: 1px solid var(--border); + position: sticky; top: 0; z-index: 10; +} +.brand { font-weight: 700; font-size: 1.1rem; color: var(--ink); } +.brand:hover { text-decoration: none; } +.tabs { display: flex; gap: 0.25rem; } +.tabs a { + padding: 0.35rem 0.8rem; border-radius: 8px; color: var(--muted); font-weight: 500; +} +.tabs a:hover { background: var(--bg); text-decoration: none; } +.tabs a.active { color: var(--ink); background: var(--bg); } + +/* --- layout --- */ +.layout { + display: grid; grid-template-columns: 260px minmax(0, 1fr); + gap: 1.5rem; max-width: 1200px; margin: 1.5rem auto; padding: 0 1.25rem; +} +.sidebar { + align-self: start; position: sticky; top: 4.5rem; + background: var(--panel); border: 1px solid var(--border); + border-radius: var(--radius); padding: 1rem 1.1rem; +} +.sidebar h2 { margin: 0 0 0.75rem; font-size: 0.95rem; } +.content { min-width: 0; } + +/* --- sidebar metrics --- */ +.metrics { display: flex; gap: 0.75rem; margin: 0 0 0.75rem; } +.metrics div { flex: 1; text-align: center; background: var(--bg); border-radius: 8px; padding: 0.5rem 0.25rem; } +.metrics dt { font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.03em; color: var(--muted); } +.metrics dd { margin: 0.15rem 0 0; font-size: 1.15rem; font-weight: 700; } +.muted { color: var(--muted); margin: 0.35rem 0; } +.muted code { color: var(--ink); word-break: break-all; } +.small { font-size: 0.82rem; } +.ok { color: #1a7f43; font-weight: 600; } +.off { color: var(--muted); } +.error { color: #b42318; } + +.reindex { margin: 0.75rem 0; } +button, .btn { + font: inherit; cursor: pointer; border: 1px solid var(--accent); + background: var(--accent); color: var(--accent-ink); + padding: 0.45rem 0.9rem; border-radius: 8px; display: inline-block; +} +button:hover, .btn:hover { filter: brightness(0.95); text-decoration: none; } +.reindex button { width: 100%; } +.btn.ghost, .btn.answer-trigger { background: var(--panel); color: var(--accent); } + +/* --- search --- */ +.search { margin-bottom: 1.25rem; } +.search-row { display: flex; gap: 0.6rem; } +.search input[type=search] { + flex: 1; padding: 0.7rem 0.9rem; font-size: 1rem; + border: 1px solid var(--border); border-radius: var(--radius); background: var(--panel); +} +.filters { margin-top: 0.75rem; } +.filters summary { cursor: pointer; color: var(--muted); font-size: 0.9rem; } +.filter-grid { + display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: 0.75rem; margin-top: 0.75rem; +} +.field { display: flex; flex-direction: column; gap: 0.3rem; font-size: 0.85rem; color: var(--muted); } +.field input { padding: 0.4rem 0.5rem; border: 1px solid var(--border); border-radius: 7px; } +fieldset.field { border: 1px solid var(--border); border-radius: 8px; padding: 0.5rem 0.7rem; } +fieldset.field legend { padding: 0 0.3rem; } +.chk { display: flex; align-items: center; gap: 0.35rem; color: var(--ink); } + +/* --- results --- */ +.results-count { font-size: 1rem; color: var(--muted); margin: 0.5rem 0 1rem; } +.results { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 1rem; } +.hit { background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; } +.hit-head { + display: flex; align-items: center; gap: 0.6rem; flex-wrap: wrap; + padding: 0.6rem 0.85rem; border-bottom: 1px solid var(--border); background: var(--bg); +} +.loc { font-family: var(--mono); font-size: 0.85rem; font-weight: 600; } +.sym { font-family: var(--mono); font-size: 0.82rem; color: var(--ink); } +.kind { + font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.03em; + color: var(--muted); border: 1px solid var(--border); border-radius: 6px; padding: 0.05rem 0.4rem; +} +.sim { + margin-left: auto; font-size: 0.78rem; font-weight: 700; color: #fff; + padding: 0.1rem 0.5rem; border-radius: 999px; + background: hsl(calc(var(--sim, 0) * 120) 60% 42%); +} + +/* --- code (Pygments) --- */ +.code { overflow-x: auto; background: var(--code-bg); } +.code .highlight { margin: 0; background: var(--code-bg); } +.code .highlighttable { width: 100%; border-collapse: collapse; } +.code .highlighttable td { vertical-align: top; padding: 0; } +.code .linenos { width: 1%; white-space: nowrap; user-select: none; text-align: right; + color: #aab2bf; padding: 0.6rem 0.6rem 0.6rem 0.85rem; border-right: 1px solid var(--border); } +.code .code, .code td.code { padding: 0; } +.code pre { margin: 0; padding: 0.6rem 0.85rem; font-family: var(--mono); font-size: 0.82rem; } +.code.file pre { font-size: 0.84rem; } + +/* --- answer --- */ +.answer-block { margin: 0 0 1.25rem; } +.answer { + margin-top: 0.75rem; padding: 0.9rem 1rem; background: var(--panel); + border: 1px solid var(--border); border-radius: var(--radius); + white-space: pre-wrap; font-family: var(--mono); font-size: 0.85rem; +} +.answer-trigger.loading { opacity: 0.6; pointer-events: none; } + +/* --- browse / status --- */ +.files { list-style: none; margin: 1rem 0 0; padding: 0; display: flex; flex-direction: column; gap: 0.15rem; } +.files a { font-family: var(--mono); font-size: 0.85rem; display: block; padding: 0.3rem 0.5rem; border-radius: 6px; } +.files a:hover { background: var(--panel); text-decoration: none; } +.browse-filter { display: flex; gap: 0.5rem; margin-top: 1rem; } +.browse-filter input { flex: 1; padding: 0.5rem 0.7rem; border: 1px solid var(--border); border-radius: 8px; } +.file-head { display: flex; align-items: center; justify-content: space-between; gap: 1rem; } +.file-head .path { font-family: var(--mono); font-size: 1rem; word-break: break-all; } +.status { border-collapse: collapse; width: 100%; background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; } +.status th, .status td { text-align: left; padding: 0.5rem 0.85rem; border-bottom: 1px solid var(--border); } +.status th { color: var(--muted); font-weight: 600; width: 34%; } +.flash { background: var(--warn-bg); color: var(--warn-ink); padding: 0.6rem 0.85rem; border-radius: 8px; } + +/* --- demo banner --- */ +.demo-banner { + background: var(--warn-bg); color: var(--warn-ink); border: 1px solid #f0d8a0; + border-radius: var(--radius); padding: 0.6rem 0.85rem; margin-bottom: 1.25rem; + font-size: 0.9rem; +} + +/* --- empty state --- */ +.empty-state { background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); padding: 1.5rem; } +.empty-state .lead { font-size: 1.05rem; margin-top: 0; } +.examples { list-style: none; padding: 0; margin: 0.5rem 0 0; display: flex; flex-wrap: wrap; gap: 0.5rem; } +.examples a { background: var(--bg); border: 1px solid var(--border); border-radius: 999px; padding: 0.3rem 0.8rem; font-size: 0.85rem; } +.warn { color: var(--warn-ink); background: var(--warn-bg); padding: 0.6rem 0.85rem; border-radius: 8px; } +.empty { color: var(--muted); } + +@media (max-width: 760px) { + .layout { grid-template-columns: 1fr; } + .sidebar { position: static; } +} diff --git a/coderag/surfaces/static/app.js b/coderag/surfaces/static/app.js new file mode 100644 index 0000000..2da4978 --- /dev/null +++ b/coderag/surfaces/static/app.js @@ -0,0 +1,51 @@ +// Progressive enhancement only — every page works with this file absent. +// 1) Stream the LLM answer inline instead of navigating to the plain-text endpoint. +// 2) Press "/" to focus the search box. + +(function () { + "use strict"; + + document.addEventListener("click", async function (event) { + const trigger = event.target.closest("[data-stream]"); + if (!trigger) return; + event.preventDefault(); + + const out = document.getElementById("answer"); + if (!out) return; + out.hidden = false; + out.textContent = ""; + trigger.classList.add("loading"); + + try { + const resp = await fetch(trigger.dataset.stream, { + headers: { Accept: "text/plain" }, + }); + if (!resp.ok || !resp.body) { + out.textContent = "Answer request failed (" + resp.status + ")."; + return; + } + const reader = resp.body.getReader(); + const decoder = new TextDecoder(); + for (;;) { + const { value, done } = await reader.read(); + if (done) break; + out.textContent += decoder.decode(value, { stream: true }); + } + } catch (err) { + out.textContent += "\n[stream error: " + err + "]"; + } finally { + trigger.classList.remove("loading"); + } + }); + + document.addEventListener("keydown", function (event) { + if (event.key !== "/" || event.metaKey || event.ctrlKey) return; + const tag = (document.activeElement && document.activeElement.tagName) || ""; + if (/^(INPUT|TEXTAREA|SELECT)$/.test(tag)) return; + const box = document.querySelector('input[name="q"]'); + if (box) { + event.preventDefault(); + box.focus(); + } + }); +})(); diff --git a/coderag/surfaces/streamlit_app.py b/coderag/surfaces/streamlit_app.py deleted file mode 100644 index 63d063d..0000000 --- a/coderag/surfaces/streamlit_app.py +++ /dev/null @@ -1,147 +0,0 @@ -"""Streamlit UI for CodeRAG (optional ``[ui]`` extra). - -Search box + retrieved chunks shown with ``path:line`` citations and similarity scores, an -optional streamed LLM answer, and a sidebar with index status and a reindex button. Launch -via ``coderag ui`` (which runs ``streamlit run`` on this file). -""" - -from __future__ import annotations - -import argparse -import time -from pathlib import Path - -import streamlit as st - -from coderag.api import CodeRAG -from coderag.config import Config - - -def _demo_answer_check(cfg: Config, query: str) -> tuple[bool, str]: - """Gate LLM answers in demo mode. Returns (generate, message). - - Charges one answer against the per-session quota only for a *new* question — - Streamlit reruns the whole script on every widget change, so re-renders of the - same query must not burn the quota or re-trigger the cooldown. The limit is soft - (browser-session state); pair with an Ollama concurrency cap for a hard GPU cap. - """ - ss = st.session_state - if ss.get("demo_last_query") == query: - return True, "" # same question re-rendering — don't re-charge - used = ss.get("demo_used", 0) - if used >= cfg.demo_max_answers: - return False, ( - f"🧪 Demo answer limit reached ({cfg.demo_max_answers} this session). " - "Reload to reset, or self-host CodeRAG for unlimited use. Search stays open." - ) - wait = cfg.demo_cooldown_seconds - (time.monotonic() - ss.get("demo_last_ts", 0.0)) - if wait > 0: - return ( - False, - f"🧪 Please wait {int(wait) + 1}s before the next answer (demo mode).", - ) - ss["demo_used"] = used + 1 - ss["demo_last_ts"] = time.monotonic() - ss["demo_last_query"] = query - return True, "" - - -def _parse_args() -> argparse.Namespace: - parser = argparse.ArgumentParser() - parser.add_argument("--watched-dir") - parser.add_argument("--store-dir") - args, _ = parser.parse_known_args() - return args - - -@st.cache_resource -def get_engine(watched_dir: str | None, store_dir: str | None) -> CodeRAG: - overrides: dict = {} - if watched_dir: - overrides["watched_dir"] = Path(watched_dir) - if store_dir: - overrides["store_dir"] = Path(store_dir) - return CodeRAG(Config.from_env(**overrides)) - - -def main() -> None: - args = _parse_args() - st.set_page_config(page_title="CodeRAG", page_icon="🔎", layout="wide") - st.title("🔎 CodeRAG") - st.caption("Local-first semantic search over your codebase.") - - cr = get_engine(args.watched_dir, args.store_dir) - - demo = cr.config.demo_mode - if demo: - left = max(0, cr.config.demo_max_answers - st.session_state.get("demo_used", 0)) - st.info( - f"🧪 **Demo mode** — {left}/{cr.config.demo_max_answers} AI answers left this " - f"session ({cr.config.demo_cooldown_seconds}s between). Search is unlimited." - ) - - with st.sidebar: - st.header("Index") - try: - status = cr.status() - st.metric("Files", status["total_files"]) - st.metric("Chunks", status["total_chunks"]) - st.write(f"**Embedding:** `{status['model']}`") - st.write(f"**Index:** `{status['index_type']}`") - answer_model = status.get("chat_model") or "—" - st.write( - f"**Answer:** `{status.get('llm_provider', '?')}` · `{answer_model}`" - ) - if status.get("llm_base_url"): - st.write(f"**LLM endpoint:** `{status['llm_base_url']}`") - st.write(f"**Root:** `{status['watched_dir']}`") - except Exception as exc: # noqa: BLE001 - st.error(f"Could not read index: {exc}") - # Reindex is an admin action — hide it in the public demo. - if not demo and st.button("🔄 Reindex"): - with st.spinner("Reindexing..."): - stats = cr.index() - st.success( - f"+{stats.files_indexed} files, {stats.total_chunks} chunks total." - ) - want_answer = st.toggle("Generate LLM answer", value=False) - max_results = 8 if demo else 20 - top_k = st.slider( - "Results", min_value=1, max_value=max_results, value=min(8, max_results) - ) - - query = st.text_input("Search", placeholder="e.g. where is retry/backoff handled?") - if not query: - return - - hits = cr.search(query, top_k=top_k) - if not hits: - st.warning( - "No results. Have you indexed this codebase? Use the Reindex button." - ) - return - - if want_answer: - generate, msg = _demo_answer_check(cr.config, query) if demo else (True, "") - st.subheader("Answer") - if not generate: - st.warning(msg) - else: - from coderag.llm import stream_answer - - try: - st.write_stream(stream_answer(cr, query, top_k)) - except Exception as exc: # noqa: BLE001 - never let an answer error crash the page - st.info(f"LLM answer unavailable: {exc}") - - st.subheader(f"{len(hits)} results") - for hit in hits: - title = f"`{hit.location}`" - if hit.symbol: - title += f" — **{hit.symbol}** ({hit.kind})" - title += f" · sim {hit.similarity:.2f}" - with st.expander(title, expanded=False): - st.code(hit.text, language=hit.language) - - -main() diff --git a/coderag/surfaces/templates/_empty.html b/coderag/surfaces/templates/_empty.html new file mode 100644 index 0000000..83c215d --- /dev/null +++ b/coderag/surfaces/templates/_empty.html @@ -0,0 +1,14 @@ +
The index is empty. Click ↻ Reindex in the sidebar to build it, + then search by meaning — not just keywords.
+ {% else %} +Search your codebase by meaning, not just keywords.
+ {% endif %} +Try one of these:
+Model {{ status.model }}
Index {{ status.index_type }}
Root {{ status.watched_dir }}
Index unavailable — check the server logs.
+{% endif %} +{% if not demo %} + +{% endif %} ++ Answers: {% if llm_ok %}{{ llm_label }}{% else %}not configured{% endif %} +
+{% if llm_base_url %}Endpoint {{ llm_base_url }}
No indexed files{% if f_path %} matching {{ f_path }}{% endif %}.
+{% endif %} +{% endblock %} diff --git a/coderag/surfaces/templates/file.html b/coderag/surfaces/templates/file.html new file mode 100644 index 0000000..54966da --- /dev/null +++ b/coderag/surfaces/templates/file.html @@ -0,0 +1,9 @@ +{% extends "base.html" %} +{% block title %}{{ path }} · CodeRAG{% endblock %} +{% block content %} +No results for {{ q }}. Try broader terms or relax the filters.
+ {% endif %} +{% else %} + {% include "_empty.html" %} +{% endif %} +{% endblock %} diff --git a/coderag/surfaces/templates/status.html b/coderag/surfaces/templates/status.html new file mode 100644 index 0000000..75a8f40 --- /dev/null +++ b/coderag/surfaces/templates/status.html @@ -0,0 +1,34 @@ +{% extends "base.html" %} +{% block title %}Status · CodeRAG{% endblock %} +{% block content %} +{{ flash }}
{% endif %} +{% if status_items %} +| {{ key }} | {{ value }} |
|---|
Index unavailable — check the server logs.
+{% endif %} + +Configured: {{ llm_label }}
+{% else %} +Not configured — retrieved code still works without it. Set
+ OPENAI_API_KEY / OPENAI_BASE_URL or ANTHROPIC_API_KEY.
Disabled in demo mode.
+{% else %} + +Embedding provider/model is configured via CODERAG_* env vars
+ or .env; a model change triggers a full rebuild on the next index.