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 @@ +
+ {% if status and status.total_chunks == 0 %} +

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:

+ +
diff --git a/coderag/surfaces/templates/_results.html b/coderag/surfaces/templates/_results.html new file mode 100644 index 0000000..d2ca925 --- /dev/null +++ b/coderag/surfaces/templates/_results.html @@ -0,0 +1,13 @@ + diff --git a/coderag/surfaces/templates/_sidebar.html b/coderag/surfaces/templates/_sidebar.html new file mode 100644 index 0000000..82f1006 --- /dev/null +++ b/coderag/surfaces/templates/_sidebar.html @@ -0,0 +1,22 @@ +

Index

+{% if status %} +
+
Files
{{ status.total_files }}
+
Chunks
{{ status.total_chunks }}
+
Vectors
{{ status.vectors }}
+
+

Model {{ status.model }}

+

Index {{ status.index_type }}

+

Root {{ status.watched_dir }}

+{% else %} +

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 }}

{% endif %} diff --git a/coderag/surfaces/templates/base.html b/coderag/surfaces/templates/base.html new file mode 100644 index 0000000..97ab931 --- /dev/null +++ b/coderag/surfaces/templates/base.html @@ -0,0 +1,30 @@ + + + + + + {% block title %}CodeRAG{% endblock %} + + + + +
+ 🔎 CodeRAG + +
+
+ +
+ {% if demo %} +
🧪 Demo mode — {{ demo_left }}/{{ demo_max }} AI answers left this session ({{ demo_cooldown }}s between). Search is unlimited.
+ {% endif %} + {% block content %}{% endblock %} +
+
+ + + diff --git a/coderag/surfaces/templates/browse.html b/coderag/surfaces/templates/browse.html new file mode 100644 index 0000000..84e8dbc --- /dev/null +++ b/coderag/surfaces/templates/browse.html @@ -0,0 +1,19 @@ +{% extends "base.html" %} +{% block title %}Browse · CodeRAG{% endblock %} +{% block content %} +

Indexed files ({{ files | length }})

+
+ + +
+{% if files %} + +{% else %} +

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 %} +
+

{{ path }}

+ ← Browse +
+
{{ code_html | safe }}
+{% endblock %} diff --git a/coderag/surfaces/templates/index.html b/coderag/surfaces/templates/index.html new file mode 100644 index 0000000..578fa23 --- /dev/null +++ b/coderag/surfaces/templates/index.html @@ -0,0 +1,65 @@ +{% extends "base.html" %} +{% block content %} + + +{% if q %} + {% if hits %} + {% if llm_ok %} +
+ + ✨ Generate answer + + +
+ {% endif %} +

{{ hits | length }} result{{ '' if hits | length == 1 else 's' }}

+ {% include "_results.html" %} + {% else %} +

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 %} +

Status

+{% if flash %}

{{ flash }}

{% endif %} +{% if status_items %} + + + {% for key, value in status_items %} + + {% endfor %} + +
{{ key }}{{ value }}
+{% else %} +

Index unavailable — check the server logs.

+{% endif %} + +

Answers (LLM)

+{% if llm_ok %} +

Configured: {{ llm_label }}

+{% else %} +

Not configured — retrieved code still works without it. Set + OPENAI_API_KEY / OPENAI_BASE_URL or ANTHROPIC_API_KEY.

+{% endif %} + +

Reindex

+{% if demo %} +

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.

+{% endif %} +{% endblock %} diff --git a/coderag/surfaces/webui.py b/coderag/surfaces/webui.py new file mode 100644 index 0000000..f0d6c2d --- /dev/null +++ b/coderag/surfaces/webui.py @@ -0,0 +1,478 @@ +"""Server-rendered web UI for CodeRAG (optional ``[ui]`` extra). + +A small FastAPI + Jinja2 app over the :class:`coderag.api.CodeRAG` facade: a search box +with language/kind/path filters, results shown with ``path:line`` citations and similarity +scores, an in-browser file viewer with syntax highlighting (Pygments), a file browser, +index status, a one-click reindex, and an optional streamed LLM answer. + +It is **server-rendered and progressively enhanced**: every page works with JavaScript +disabled (plain forms and links), and a tiny first-party ``app.js`` adds answer streaming +and small niceties. There is no CDN or runtime network dependency, so the UI stays as +local-first as the rest of CodeRAG. + +Thin by design (see AGENTS.md): all real work is delegated to the facade — no engine logic +lives here. Launched via ``coderag ui`` (which runs uvicorn on this app). + +Note: like the HTTP API, this surface can read indexed source. It is meant for local/trusted +use; front it with auth (and TLS) before exposing it beyond localhost. + +Note: this module intentionally does NOT ``from __future__ import annotations`` — FastAPI +resolves route type hints via ``get_type_hints`` against the module globals, but the +request/response classes are imported lazily inside ``create_ui_app``; eager (non-stringized) +annotations let them resolve against that local scope instead. +""" + +import logging +import secrets +import threading +import time +from pathlib import Path +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple +from urllib.parse import quote, urlencode + +from pygments import highlight +from pygments.formatters import HtmlFormatter + +from coderag.types import SearchHit + +if TYPE_CHECKING: + from fastapi import FastAPI + + from coderag.api import CodeRAG + from coderag.config import Config + from coderag.retrieval.search import HybridSearcher + +logger = logging.getLogger(__name__) + +_BASE_DIR = Path(__file__).parent +# A light Pygments theme; chrome colours live in static/app.css. +_PYGMENTS_STYLE = "default" +# Cookie that ties a browser to its (soft) demo-mode answer quota. +_DEMO_COOKIE = "coderag_demo" + +# Shown on the empty search page to get people started. +_EXAMPLES: Tuple[str, ...] = ( + "where is retry/backoff handled?", + "how is the FAISS index persisted?", + "where are duplicate vectors removed on file change?", + "how does incremental indexing skip unchanged files?", + "token validation", +) + + +# --- rendering helpers (pure; no engine logic) --- + + +def _lexer(language: Optional[str], filename: Optional[str], code: str) -> Any: + """Best-effort Pygments lexer: by language name, then filename, then plain text.""" + from pygments.lexers import TextLexer, get_lexer_by_name, get_lexer_for_filename + from pygments.util import ClassNotFound + + if language: + try: + return get_lexer_by_name(language) + except ClassNotFound: + pass + if filename: + try: + return get_lexer_for_filename(filename, code) + except ClassNotFound: + pass + return TextLexer() + + +def _highlight( + code: str, + *, + language: Optional[str] = None, + filename: Optional[str] = None, + start_line: int = 1, + hl_lines: Optional[List[int]] = None, +) -> str: + """Render ``code`` to highlighted HTML with a line-number gutter. + + ``start_line`` makes the gutter show real file line numbers (so a chunk's numbers + match its citation); ``hl_lines`` emphasises a 1-based range (used by the file view to + spotlight the cited lines). + """ + formatter = HtmlFormatter( + cssclass="highlight", + linenos="table", + linenostart=start_line, + hl_lines=hl_lines or [], + lineanchors="L", + anchorlinenos=bool(hl_lines), + ) + return highlight(code, _lexer(language, filename, code), formatter) + + +def _hit_views(hits: List[SearchHit]) -> List[Dict[str, Any]]: + """Flatten hits into template-friendly dicts (template stays logic-light).""" + return [ + { + "location": h.location, + "path_q": quote(h.path, safe=""), + "symbol": h.symbol, + "kind": h.kind, + "start_line": h.start_line, + "end_line": h.end_line, + "similarity": h.similarity, + "code_html": _highlight( + h.text, language=h.language, start_line=h.start_line + ), + } + for h in hits + ] + + +def _llm_status(config: "Config") -> Tuple[bool, str]: + """Whether an answer backend is usable, plus a human label — drives the answer UI.""" + provider = config.llm_provider.lower() + if provider == "openai": + ok = bool(config.openai_api_key or config.openai_base_url) + return ok, f"OpenAI · {config.chat_model}" + if provider == "anthropic": + return bool(config.anthropic_api_key), f"Anthropic · {config.anthropic_model}" + return False, provider + + +# --- search helpers (apply live retrieval settings + filters over the facade) --- + + +def _searcher_for(cr: "CodeRAG", dense: float, lexical: float) -> "HybridSearcher": + """The facade's searcher, or an ad-hoc one when weights are tuned live. + + Reuses the already-loaded provider/store/vectors so changing weights never reloads + the index — only the cheap fusion weighting differs. + """ + if dense == cr.config.dense_weight and lexical == cr.config.lexical_weight: + return cr.searcher + from coderag.retrieval.search import HybridSearcher + + cfg = cr.config.with_overrides(dense_weight=dense, lexical_weight=lexical) + return HybridSearcher(cfg, cr.provider, cr.store, cr.vectors) + + +def _apply_filters( + hits: List[SearchHit], + langs: List[str], + kinds: List[str], + path: Optional[str], +) -> List[SearchHit]: + out = hits + if langs: + wanted = set(langs) + out = [h for h in out if h.language in wanted] + if kinds: + wanted = set(kinds) + out = [h for h in out if h.kind in wanted] + if path: + needle = path.lower() + out = [h for h in out if needle in h.path.lower()] + return out + + +def _run_search( + cr: "CodeRAG", + query: str, + k: int, + *, + dense: float, + lexical: float, + langs: List[str], + kinds: List[str], + path: Optional[str], +) -> List[SearchHit]: + """Search, then post-filter. Fetches extra candidates when filters are active. + + ``search`` has no server-side filtering, so to keep filtered results useful we pull a + larger candidate set and narrow it down to ``k`` here. + """ + filtering = bool(langs or kinds or path) + fetch = max(k, 50) if filtering else k + hits = _searcher_for(cr, dense, lexical).search(query, fetch) + return _apply_filters(hits, langs, kinds, path)[:k] + + +# --- app factory --- + + +def create_ui_app(cr: "CodeRAG") -> "FastAPI": + from fastapi import FastAPI, HTTPException, Query, Request + from fastapi.responses import ( + HTMLResponse, + RedirectResponse, + Response, + StreamingResponse, + ) + from fastapi.staticfiles import StaticFiles + from fastapi.templating import Jinja2Templates + + templates = Jinja2Templates(directory=str(_BASE_DIR / "templates")) + pygments_css = HtmlFormatter(style=_PYGMENTS_STYLE).get_style_defs(".highlight") + # Serialize reindexing so concurrent clicks can't race the single-writer store. + index_lock = threading.Lock() + + # Demo mode: soft, per-browser-session LLM-answer quota. The stateless server + # gets the session from a cookie; the table is in-memory (a process restart + # resets quotas — fine for a public demo, pair with an Ollama concurrency cap). + demo = cr.config.demo_mode + demo_sessions: Dict[str, Dict[str, Any]] = {} + demo_lock = threading.Lock() + + def demo_session(request: Request) -> "tuple[str, Optional[str]]": + """Return ``(sid, fresh_cookie)`` for the demo quota. + + ``fresh_cookie`` is a newly-minted token to Set-Cookie for first-time visitors, + or None for browsers that already carry one. The client's cookie value is used + only as the in-memory quota key and is never written back into a Set-Cookie + header — so no user-supplied input is ever reflected into a cookie. + """ + existing = request.cookies.get(_DEMO_COOKIE) + if existing: + return existing, None + minted = secrets.token_urlsafe(16) + return minted, minted + + def demo_remaining(sid: str) -> int: + with demo_lock: + used = demo_sessions.get(sid, {}).get("used", 0) + return max(0, cr.config.demo_max_answers - used) + + def demo_gate(sid: str, query: str) -> "tuple[bool, str]": + """Charge one answer per *new* question against the session quota. + + Re-asking the same question doesn't re-charge or re-trigger the cooldown + (mirrors the Streamlit demo). The limit is soft — a browser that drops its + cookie starts fresh. + """ + with demo_lock: + # Bound memory on a public endpoint: reset the soft table if it balloons. + if len(demo_sessions) > 50_000: + demo_sessions.clear() + s = demo_sessions.setdefault( + sid, {"used": 0, "last_ts": 0.0, "last_query": None} + ) + if s["last_query"] == query: + return True, "" + if s["used"] >= cr.config.demo_max_answers: + return False, ( + f"Demo answer limit reached ({cr.config.demo_max_answers} this " + "session). Reload to reset, or self-host CodeRAG. Search stays open." + ) + wait = cr.config.demo_cooldown_seconds - (time.monotonic() - s["last_ts"]) + if wait > 0: + return False, f"Please wait {int(wait) + 1}s before the next answer." + s["used"] += 1 + s["last_ts"] = time.monotonic() + s["last_query"] = query + return True, "" + + def apply_demo_cookie(resp: Response, fresh_cookie: Optional[str]) -> None: + # Only ever writes a freshly-minted token, never client-supplied input. + if fresh_cookie is not None: + resp.set_cookie( + _DEMO_COOKIE, + fresh_cookie, + max_age=604800, + httponly=True, + samesite="lax", + ) + + app = FastAPI(title="CodeRAG UI", version="1.0.0") + app.mount( + "/static", StaticFiles(directory=str(_BASE_DIR / "static")), name="static" + ) + + def base_ctx(request: Request) -> Dict[str, Any]: + """Context every page needs: index status, answer backend, demo banner.""" + try: + status: Optional[Dict[str, Any]] = cr.status() + except Exception as exc: # noqa: BLE001 - surface a broken index, don't crash + logger.warning("status() failed: %s", exc) + status = None + ok, label = _llm_status(cr.config) + ctx: Dict[str, Any] = { + "status": status, + "llm_ok": ok, + "llm_label": label, + "llm_base_url": (status or {}).get("llm_base_url") or "", + "demo": demo, + } + if demo: + ctx.update( + { + "demo_left": demo_remaining( + request.cookies.get(_DEMO_COOKIE) or "" + ), + "demo_max": cr.config.demo_max_answers, + "demo_cooldown": cr.config.demo_cooldown_seconds, + } + ) + return ctx + + @app.get("/", response_class=HTMLResponse) + def home( + request: Request, + q: Optional[str] = Query(default=None), + k: int = Query(default=8, ge=1, le=50), + path: Optional[str] = Query(default=None), + dense: float = Query(default=1.0, ge=0.0), + lexical: float = Query(default=1.0, ge=0.0), + ) -> Response: + # Repeated checkbox params (?lang=a&lang=b) are read straight off the request, + # keeping the typed signature free of mutable-container Query defaults. + langs = request.query_params.getlist("lang") + kinds = request.query_params.getlist("kind") + # Demo mode caps how many results a public visitor can pull. + k_max = 8 if demo else 50 + k = min(k, k_max) + ctx = base_ctx(request) + ctx.update( + { + "active": "search", + "q": q, + "k": k, + "k_max": k_max, + "f_lang": langs, + "f_kind": kinds, + "f_path": path, + "dense": dense, + "lexical": lexical, + "languages": cr.store.distinct_languages(), + "kinds": cr.store.distinct_kinds(), + "examples": _EXAMPLES, + } + ) + if q and q.strip(): + hits = _run_search( + cr, + q.strip(), + k, + dense=dense, + lexical=lexical, + langs=langs, + kinds=kinds, + path=path, + ) + ctx["hits"] = _hit_views(hits) + ctx["answer_qs"] = urlencode({"q": q.strip(), "k": k}) + resp = templates.TemplateResponse(request, "index.html", ctx) + if demo: + _, fresh_cookie = demo_session(request) + apply_demo_cookie(resp, fresh_cookie) + return resp + + @app.get("/file", response_class=HTMLResponse) + def file_view( + request: Request, + path: str = Query(...), + start: Optional[int] = Query(default=None, ge=1), + end: Optional[int] = Query(default=None, ge=1), + ) -> Response: + try: + text = cr.get_file(path) + except (ValueError, FileNotFoundError) as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + hl = list(range(start, (end or start) + 1)) if start else None + code_html = _highlight(text, filename=path, start_line=1, hl_lines=hl) + ctx = base_ctx(request) + ctx.update({"active": "browse", "path": path, "code_html": code_html}) + return templates.TemplateResponse(request, "file.html", ctx) + + @app.get("/browse", response_class=HTMLResponse) + def browse(request: Request, path: Optional[str] = Query(default=None)) -> Response: + files = sorted(cr.store.all_file_paths()) + if path: + needle = path.lower() + files = [f for f in files if needle in f.lower()] + ctx = base_ctx(request) + ctx.update({"active": "browse", "files": files, "f_path": path}) + return templates.TemplateResponse(request, "browse.html", ctx) + + @app.get("/status", response_class=HTMLResponse) + def status_page( + request: Request, flash: Optional[str] = Query(default=None) + ) -> Response: + ctx = base_ctx(request) + status = ctx["status"] or {} + ctx.update( + { + "active": "status", + "flash": flash, + "status_items": list(status.items()), + } + ) + return templates.TemplateResponse(request, "status.html", ctx) + + @app.get("/answer") + def answer( + request: Request, + q: str = Query(...), + k: int = Query(default=8, ge=1, le=50), + ) -> Response: + from coderag.llm import stream_answer + + fresh_cookie: Optional[str] = None + if demo: + sid, fresh_cookie = demo_session(request) + ok, msg = demo_gate(sid, q) + if not ok: + blocked = Response(msg, media_type="text/plain; charset=utf-8") + apply_demo_cookie(blocked, fresh_cookie) + return blocked + k = min(k, 8) + + def gen() -> Any: + try: + yield from stream_answer(cr, q, k) + except RuntimeError as exc: + # Log the detail server-side; show the user a static, safe notice + # rather than echoing exception text into the response. + logger.info("LLM answer unavailable: %s", exc) + yield ( + "[LLM answer unavailable — no answer backend is configured. " + "Set OPENAI_API_KEY / OPENAI_BASE_URL or ANTHROPIC_API_KEY.]" + ) + + resp = StreamingResponse(gen(), media_type="text/plain; charset=utf-8") + apply_demo_cookie(resp, fresh_cookie) + return resp + + @app.post("/reindex") + def reindex() -> RedirectResponse: + if demo: + # Reindex is an admin action — disabled on the public demo. + msg = "Reindex is disabled in demo mode." + return RedirectResponse("/status?flash=" + quote(msg), status_code=303) + if not index_lock.acquire(blocking=False): + msg = "Reindex already in progress." + return RedirectResponse("/status?flash=" + quote(msg), status_code=303) + try: + stats = cr.index() + finally: + index_lock.release() + msg = ( + f"Reindexed: {stats.files_indexed} file(s) updated, " + f"{stats.total_chunks} chunks total." + ) + return RedirectResponse("/status?flash=" + quote(msg), status_code=303) + + @app.get("/assets/pygments.css") + def pygments_stylesheet() -> Response: + return Response(pygments_css, media_type="text/css") + + @app.get("/healthz") + def healthz() -> Dict[str, str]: + """Liveness/readiness probe target (container HEALTHCHECK, k8s probes).""" + return {"status": "ok"} + + return app + + +def run_ui(cr: "CodeRAG", host: str = "127.0.0.1", port: int = 8501) -> None: + import uvicorn + + # Warm the index/provider so the first request isn't slow. + cr.status() + uvicorn.run(create_ui_app(cr), host=host, port=port) diff --git a/deploy/README.md b/deploy/README.md index ed26441..702d812 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -1,7 +1,7 @@ # ☸️ Deploying CodeRAG on Kubernetes A Helm chart that self-hosts the CodeRAG **HTTP/REST API** (and, optionally, the -**Streamlit UI**) on any Kubernetes cluster, with a persistent index, a git-sourced +**web UI**) on any Kubernetes cluster, with a persistent index, a git-sourced workspace, scheduled re-indexing, and sensible security defaults. > **Don't use Helm?** Every example below works with plain `kubectl` too — just pipe @@ -142,7 +142,7 @@ Full list with comments: [`values.yaml`](helm/coderag/values.yaml). The most-use | `server.service.type` | `ClusterIP` | `ClusterIP` · `NodePort` · `LoadBalancer`. | | `index.initJob.enabled` | `true` | Build the index automatically on install/upgrade. | | `index.cronjob.enabled` | `false` | Recurring reindex (`index.cronjob.schedule`). | -| `ui.enabled` | `false` | Also deploy the Streamlit UI (independent instance). | +| `ui.enabled` | `false` | Also deploy the web UI (independent instance). | | `ingress.enabled` | `false` | Expose via an Ingress (**add TLS + auth** — the API has none). | | `resources` (`server.*`, `ui.*`) | see values | CPU/memory requests & limits. | diff --git a/deploy/helm/coderag/Chart.yaml b/deploy/helm/coderag/Chart.yaml index 4205e90..012747a 100644 --- a/deploy/helm/coderag/Chart.yaml +++ b/deploy/helm/coderag/Chart.yaml @@ -2,7 +2,7 @@ apiVersion: v2 name: coderag description: >- Standalone, local-first semantic code-search engine. Self-host the HTTP/REST - API (and optional Streamlit UI) on Kubernetes with a persistent index, a git + API (and optional web UI) on Kubernetes with a persistent index, a git workspace, and scheduled re-indexing. type: application diff --git a/deploy/helm/coderag/templates/NOTES.txt b/deploy/helm/coderag/templates/NOTES.txt index a1908bb..db7a6bf 100644 --- a/deploy/helm/coderag/templates/NOTES.txt +++ b/deploy/helm/coderag/templates/NOTES.txt @@ -43,7 +43,7 @@ Build the index once the server is ready: {{- if .Values.ui.enabled }} -Web UI (Streamlit) +Web UI ------------------ kubectl --namespace {{ .Release.Namespace }} port-forward svc/{{ include "coderag.fullname" . }}-ui 8501:{{ .Values.ui.service.port }} # then open http://127.0.0.1:8501 (use the in-app "Reindex" button to build its index) diff --git a/deploy/helm/coderag/templates/_helpers.tpl b/deploy/helm/coderag/templates/_helpers.tpl index 4774aab..0ab59fc 100644 --- a/deploy/helm/coderag/templates/_helpers.tpl +++ b/deploy/helm/coderag/templates/_helpers.tpl @@ -259,7 +259,7 @@ Pod volumes for a writer (server/ui). Call with (dict "ctx" . "component" "serve {{- end }} - name: tmp emptyDir: {} -# Writable HOME so stray ~/.cache, ~/.config, and ~/.streamlit writes succeed under a +# Writable HOME so stray ~/.cache and ~/.config writes succeed under a # read-only root filesystem (Kubernetes does not set HOME from the image's passwd entry). - name: home emptyDir: {} diff --git a/deploy/helm/coderag/templates/networkpolicy.yaml b/deploy/helm/coderag/templates/networkpolicy.yaml index ce29ded..f3ecf9f 100644 --- a/deploy/helm/coderag/templates/networkpolicy.yaml +++ b/deploy/helm/coderag/templates/networkpolicy.yaml @@ -34,7 +34,7 @@ spec: {{- include "coderag.selectorLabels" . | nindent 14 }} app.kubernetes.io/component: reindex {{- if .Values.ui.enabled }} - # The Streamlit UI (when deployed) may query the server's API. + # The web UI (when deployed) may query the server's API. - podSelector: matchLabels: {{- include "coderag.selectorLabels" . | nindent 14 }} diff --git a/deploy/helm/coderag/templates/ui-deployment.yaml b/deploy/helm/coderag/templates/ui-deployment.yaml index 3bb442d..14f9ad7 100644 --- a/deploy/helm/coderag/templates/ui-deployment.yaml +++ b/deploy/helm/coderag/templates/ui-deployment.yaml @@ -54,7 +54,9 @@ spec: containerPort: {{ .Values.ui.containerPort }} protocol: TCP env: - - name: STREAMLIT_SERVER_PORT + - name: CODERAG_UI_HOST + value: "0.0.0.0" + - name: CODERAG_UI_PORT value: {{ .Values.ui.containerPort | quote }} - name: HOME value: /home/coderag @@ -65,20 +67,20 @@ spec: {{- include "coderag.envFrom" . | nindent 12 }} startupProbe: httpGet: - path: /_stcore/health + path: /healthz port: http periodSeconds: {{ .Values.ui.startupProbe.periodSeconds }} failureThreshold: {{ .Values.ui.startupProbe.failureThreshold }} readinessProbe: httpGet: - path: /_stcore/health + path: /healthz port: http periodSeconds: 10 timeoutSeconds: 5 failureThreshold: 3 livenessProbe: httpGet: - path: /_stcore/health + path: /healthz port: http periodSeconds: 30 timeoutSeconds: 5 diff --git a/deploy/helm/coderag/values.yaml b/deploy/helm/coderag/values.yaml index bd68e18..1e6f5ed 100644 --- a/deploy/helm/coderag/values.yaml +++ b/deploy/helm/coderag/values.yaml @@ -19,7 +19,7 @@ image: # -- Image tag. Empty defaults to the rolling `beta` channel. Pin to an immutable # `sha-` tag for reproducible deploys. tag: "" - # -- Suffix appended to `tag` for the Streamlit UI image (published as `:beta-ui`). + # -- Suffix appended to `tag` for the web UI image (published as `:beta-ui`). uiSuffix: "-ui" pullPolicy: IfNotPresent # -- Names of pre-created docker-registry Secrets for pulling private images. @@ -173,7 +173,7 @@ server: # -- Extra env vars (list of {name,value|valueFrom}) for the server container. extraEnv: [] -# --- Streamlit UI (optional; runs as an INDEPENDENT instance with its own index) --- +# --- Web UI (optional; runs as an INDEPENDENT instance with its own index) --- # The UI bundles the engine and writes its own index, so when enabled it gets a # separate data volume and workspace. Build its index with the in-app "Reindex" # button. For a shared, server-maintained index, prefer the server + an ingress. diff --git a/pyproject.toml b/pyproject.toml index c667e82..c423393 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,7 +34,10 @@ server = [ "uvicorn[standard]>=0.29,<1", ] ui = [ - "streamlit>=1.38,<2", + "fastapi>=0.110,<1", + "uvicorn[standard]>=0.29,<1", + "jinja2>=3.1,<4", + "pygments>=2.17,<3", ] openai = [ "openai>=1.44,<2", @@ -45,7 +48,8 @@ anthropic = [ all = [ "fastapi>=0.110,<1", "uvicorn[standard]>=0.29,<1", - "streamlit>=1.38,<2", + "jinja2>=3.1,<4", + "pygments>=2.17,<3", "openai>=1.44,<2", "anthropic>=0.40,<1", ] @@ -64,6 +68,10 @@ coderag = "coderag.surfaces.cli:main" [tool.setuptools.packages.find] include = ["coderag*"] +[tool.setuptools.package-data] +# Ship the web UI's templates and static assets inside the wheel. +"coderag.surfaces" = ["templates/*.html", "static/*.css", "static/*.js"] + [tool.pytest.ini_options] markers = [ "integration: tests that download models or hit the network (deselected in CI)", diff --git a/tests/test_webui.py b/tests/test_webui.py new file mode 100644 index 0000000..fa897c1 --- /dev/null +++ b/tests/test_webui.py @@ -0,0 +1,168 @@ +"""Tests for the server-rendered web UI surface (optional ``[ui]`` extra). + +Offline and deterministic via the ``fake`` provider, exercised headlessly with FastAPI's +``TestClient`` — the same pattern as the HTTP API tests. Skipped when the UI/engine extras +aren't installed. +""" + +from __future__ import annotations + +import pytest + +pytest.importorskip("faiss") +pytest.importorskip("fastapi") +pytest.importorskip("jinja2") +pytest.importorskip("pygments") + +from fastapi.testclient import TestClient # noqa: E402 + +from coderag.api import CodeRAG # noqa: E402 +from coderag.config import Config # noqa: E402 +from coderag.surfaces.webui import create_ui_app # noqa: E402 +from tests.conftest import write # noqa: E402 + + +@pytest.fixture +def ui(tmp_path): + repo = tmp_path / "repo" + store = tmp_path / "store" + write(repo / "auth.py", "def authenticate(token):\n return token == 'ok'\n") + write(repo / "util.py", "class Helper:\n def run(self):\n return 42\n") + cr = CodeRAG(Config(provider="fake", watched_dir=repo, store_dir=store)) + cr.index() + return cr, TestClient(create_ui_app(cr)) + + +def test_home_empty_state_lists_examples(ui): + _, client = ui + r = client.get("/") + assert r.status_code == 200 + assert "CodeRAG" in r.text + assert "