Add local-first QA app for repository content with Gemini AI#2
Conversation
Node.js/Express backend with Claude API integration that indexes all repository files (HTML/MD) and answers questions based on their content. Vanilla JS frontend with session management (localStorage), streaming responses, and restrictive API layer (only repo data access, URL whitelist). https://claude.ai/code/session_01SDQnB7k2EERPaMDhm6xknd
- Replace @anthropic-ai/sdk with @google/genai SDK - Use Gemini 2.5 Flash model with generateContentStream for streaming - Convert message format (assistant→model role mapping) - Replace localStorage with IndexedDB for session persistence - Add indexed object store with updatedAt index - All storage operations now async with proper transaction handling - Update UI branding from Claude to Gemini https://claude.ai/code/session_01SDQnB7k2EERPaMDhm6xknd
Frontend: - Web Components: <session-item> and <chat-message> with Shadow DOM - <template> elements for both components, cloned on instantiation - SessionStore class: single #exec() method replaces 4 identical wrappers - QAApp controller class: all state and DOM refs encapsulated via #private - Event delegation: custom events (session-select, session-delete) bubble from components, handled centrally instead of inline onclick handlers - Markdown renderer: block-aware paragraph wrapping replaces 8 redundant regex cleanup passes - replaceChildren() instead of innerHTML for DOM updates - Semantic HTML: <section>, <header>, <footer>, hidden attribute - crypto.randomUUID() for session IDs - Removed unused CSS variables, deduplicated health check Backend: - Cached repo context with 60s TTL (was: full filesystem walk per request) - Lazy singleton GoogleGenAI instance (was: new per request) - SKIP_DIRS/ALLOWED_EXTS as Sets (was: duplicated array checks) - Extracted validateChatBody() for request validation - Eliminated redundant isAllowedFile double-check in readRepoFile - getRepoContext() unifies indexing + reading (was: 3 separate functions) https://claude.ai/code/session_01SDQnB7k2EERPaMDhm6xknd
There was a problem hiding this comment.
Pull request overview
Adds a self-contained “markdown-qa” local-first Q&A tool that indexes repository files and provides a web UI to chat with Gemini against that indexed context.
Changes:
- Introduces an Express backend that indexes allowed repo files, caches combined context, and streams Gemini responses via SSE.
- Adds a single-page web UI with IndexedDB-backed session management and a custom lightweight Markdown renderer.
- Adds configuration and NPM packaging for running the app locally.
Reviewed changes
Copilot reviewed 5 out of 6 changed files in this pull request and generated 10 comments.
Show a summary per file
| File | Description |
|---|---|
markdown-qa/server.js |
New Express API for file indexing, repo context caching, and SSE chat streaming to Gemini. |
markdown-qa/public/index.html |
New SPA UI: sessions in IndexedDB, chat streaming display, and Markdown rendering. |
markdown-qa/config.json |
App configuration (repo root, allowed extensions, Gemini model + token limits). |
markdown-qa/package.json |
NPM package definition and runtime dependencies for the QA server. |
markdown-qa/package-lock.json |
Dependency lockfile for reproducible installs. |
markdown-qa/.gitignore |
Ignores local dependencies in the app folder. |
Files not reviewed (1)
- markdown-qa/package-lock.json: Language not supported
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // Fenced code blocks | ||
| html = html.replace(/```(\w*)\n([\s\S]*?)```/g, (_, lang, code) => | ||
| `<pre><code class="language-${lang}">${code.trim()}</code></pre>`); | ||
|
|
||
| // Inline code (must come after fenced blocks) | ||
| html = html.replace(/`([^`]+)`/g, '<code>$1</code>'); | ||
|
|
||
| // Bold before italic | ||
| html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>'); | ||
| html = html.replace(/\*(.+?)\*/g, '<em>$1</em>'); |
There was a problem hiding this comment.
The markdown renderer applies bold/italic substitutions after inserting <pre><code>...</code></pre>, so * / ** inside fenced code blocks can be converted into <em>/<strong> tags and corrupt code formatting. Consider protecting fenced code blocks (e.g., replace them with placeholders before other inline transforms, then restore) or performing inline formatting only outside <pre><code> regions.
| html = html.replace(/^[-*] (.+)$/gm, '<li>$1</li>'); | ||
| html = html.replace(/((?:<li>.*<\/li>\n?)+)/g, '<ul>$1</ul>'); | ||
| html = html.replace(/^\d+\. (.+)$/gm, '<li>$1</li>'); |
There was a problem hiding this comment.
Ordered list syntax (1. item) is converted into <li> elements but never wrapped in an <ol>, and the earlier <ul> wrapper regex can wrap numbered items as unordered lists. This produces incorrect HTML structure for ordered lists; consider generating <ol> blocks for numbered items (separately from unordered lists).
| html = html.replace(/^[-*] (.+)$/gm, '<li>$1</li>'); | |
| html = html.replace(/((?:<li>.*<\/li>\n?)+)/g, '<ul>$1</ul>'); | |
| html = html.replace(/^\d+\. (.+)$/gm, '<li>$1</li>'); | |
| // Unordered list items | |
| html = html.replace(/^[-*] (.+)$/gm, '<li class="ul-item">$1</li>'); | |
| // Ordered list items | |
| html = html.replace(/^\d+\. (.+)$/gm, '<li class="ol-item">$1</li>'); | |
| // Wrap consecutive unordered list items in <ul> | |
| html = html.replace(/((?:<li class="ul-item">.*<\/li>\n?)+)/g, '<ul>$1</ul>'); | |
| // Wrap consecutive ordered list items in <ol> | |
| html = html.replace(/((?:<li class="ol-item">.*<\/li>\n?)+)/g, '<ol>$1</ol>'); | |
| // Clean up temporary list item classes | |
| html = html.replace(/<li class="ul-item">/g, '<li>'); | |
| html = html.replace(/<li class="ol-item">/g, '<li>'); |
| @@ -0,0 +1 @@ | |||
| node_modules/ | |||
There was a problem hiding this comment.
Only node_modules/ is ignored. Since the app expects the Gemini API key via environment variables, it’s likely people will create a markdown-qa/.env (or similar) during local setup; adding .env (and optionally *.local, logs) to this .gitignore helps prevent accidental commits of secrets.
| node_modules/ | |
| node_modules/ | |
| # Environment files (may contain secrets like API keys) | |
| .env | |
| .env.* | |
| # Local configuration overrides | |
| *.local | |
| # Logs | |
| *.log |
| res.end(); | ||
| }); | ||
|
|
||
| app.get('*', (_req, res) => { |
There was a problem hiding this comment.
The catch-all app.get('*') will also match unknown /api/... routes and return index.html instead of a 404 JSON, which can be confusing and break API clients. Consider restricting this route to non-API paths (or placing it behind a negative lookahead for /api).
| app.get('*', (_req, res) => { | |
| app.get(/^\/(?!api(\/|$)).*/, (_req, res) => { |
| res.writeHead(200, { | ||
| 'Content-Type': 'text/event-stream', | ||
| 'Cache-Control': 'no-cache', | ||
| 'Connection': 'keep-alive', | ||
| }); | ||
|
|
||
| try { | ||
| const response = await client.models.generateContentStream({ | ||
| model: config.gemini.model, | ||
| contents: geminiContents, | ||
| config: { systemInstruction, maxOutputTokens: config.gemini.maxOutputTokens }, | ||
| }); | ||
|
|
||
| let totalChars = 0; | ||
| for await (const chunk of response) { | ||
| const text = chunk.text; | ||
| if (text) { | ||
| totalChars += text.length; | ||
| res.write(`data: ${JSON.stringify({ type: 'delta', text })}\n\n`); | ||
| } | ||
| } | ||
|
|
||
| res.write(`data: ${JSON.stringify({ type: 'done', usage: { totalChars } })}\n\n`); | ||
| } catch (err) { | ||
| res.write(`data: ${JSON.stringify({ type: 'error', error: err.message })}\n\n`); | ||
| } | ||
|
|
||
| res.end(); |
There was a problem hiding this comment.
The SSE /api/chat handler doesn’t handle client disconnects; if the browser navigates away or closes the connection, the server can keep streaming from Gemini and wasting tokens/work. Add a req.on('close') handler and propagate an AbortController/cancellation signal to generateContentStream, and avoid res.write after the response is closed.
| "dependencies": { | ||
| "@google/genai": "^1.41.0", | ||
| "express": "^4.21.0" | ||
| } |
There was a problem hiding this comment.
@google/genai in the lockfile requires Node >=20.0.0, but package.json doesn’t declare an engines.node requirement. Adding an engines field (and optionally documenting it) helps prevent hard-to-diagnose runtime/install failures on older Node versions.
| app.listen(PORT, () => { | ||
| const { files } = getRepoContext(); | ||
| console.log(`Markdown QA Server running on http://localhost:${PORT}`); |
There was a problem hiding this comment.
app.listen(PORT) binds to all network interfaces by default, which makes /api/files and /api/chat reachable from other machines on the LAN and can leak repository content. Bind to localhost by default (e.g., 127.0.0.1) and/or make the host configurable in config.json so users explicitly opt into remote access.
| app.listen(PORT, () => { | |
| const { files } = getRepoContext(); | |
| console.log(`Markdown QA Server running on http://localhost:${PORT}`); | |
| const HOST = config.host || '127.0.0.1'; | |
| app.listen(PORT, HOST, () => { | |
| const { files } = getRepoContext(); | |
| console.log(`Markdown QA Server running on http://${HOST}:${PORT}`); |
| function getRepoContext() { | ||
| if (Date.now() - contextCache.timestamp < CONTEXT_CACHE_TTL) return contextCache; | ||
|
|
||
| const files = indexRepoFiles(); | ||
| const text = files | ||
| .map(f => { | ||
| try { return `--- File: ${f.path} ---\n${fs.readFileSync(path.resolve(REPO_ROOT, f.path), 'utf-8')}\n`; } | ||
| catch { return null; } | ||
| }) | ||
| .filter(Boolean) | ||
| .join('\n'); | ||
|
|
||
| contextCache = { text, files, timestamp: Date.now() }; | ||
| return contextCache; |
There was a problem hiding this comment.
getRepoContext() concatenates the full contents of every allowed file into a single in-memory string with no total-size cap. On larger repos this can cause high memory usage and also blow past model context limits (leading to errors or truncated prompts). Consider enforcing a maximum total context size (bytes/chars) and/or switching to a retrieval approach (send only the most relevant files/snippets).
| const updated = await this.#store.get(this.#currentId); | ||
| updated.messages.push({ role: 'assistant', content: fullText, usage }); | ||
| updated.updatedAt = new Date().toISOString(); | ||
| await this.#store.put(updated); |
There was a problem hiding this comment.
During streaming completion, the code reloads the session using this.#currentId. If the user switches sessions while streaming, the assistant response can be appended to the wrong session; if the session was deleted, updated can be null and updated.messages.push(...) will throw. Capture the session id at send-time and use it consistently, and guard against missing sessions before mutating.
| this.#el.sessionList.addEventListener('session-select', (e) => this.#switchToSession(e.detail)); | ||
| this.#el.sessionList.addEventListener('session-delete', (e) => this.#deleteSession(e.detail)); |
There was a problem hiding this comment.
Session switching/deletion is still enabled while #streaming is true, which can lead to confusing UI state (and contributes to the wrong-session write bug). Consider disabling session-select/session-delete interactions during streaming, or queueing them until the stream finishes.
| this.#el.sessionList.addEventListener('session-select', (e) => this.#switchToSession(e.detail)); | |
| this.#el.sessionList.addEventListener('session-delete', (e) => this.#deleteSession(e.detail)); | |
| this.#el.sessionList.addEventListener('session-select', (e) => { | |
| if (this.#streaming) return; | |
| this.#switchToSession(e.detail); | |
| }); | |
| this.#el.sessionList.addEventListener('session-delete', (e) => { | |
| if (this.#streaming) return; | |
| this.#deleteSession(e.detail); | |
| }); |
Architecture change: the server no longer reads repo files or calls the
Gemini SDK directly. Instead it spawns a configurable CLI (claude, gemini,
aider, etc.) per request and streams stdout back as SSE. The CLI handles
its own file discovery via grep/find/read tools.
Server (server.js):
- Removed @google/genai, file indexing, context building (~100 lines gone)
- Now just Express + child_process.spawn + SSE piping (~100 lines total)
- buildPrompt() assembles session context + conversation history + question
- Aborts CLI process on client disconnect
- Configurable via config.json cli.command/args/env
Frontend (index.html):
- Added collapsible "Kontext" panel in chat header
- Session context persisted per session in IndexedDB
- Context sent with every message to the CLI backend
- Removed file listing (CLI discovers files itself)
- Generic "Assistent" label instead of "Gemini"
- Welcome screen simplified (no file list needed)
Config:
- Replaced gemini section with cli { command, args, env }
- Removed allowedFileExtensions, maxFileSizeBytes, whitelistedUrls
https://claude.ai/code/session_01SDQnB7k2EERPaMDhm6xknd
- Add '--' separator before prompt argument to prevent flag injection (user input starting with '--' was interpreted as CLI flags) - Bind to 127.0.0.1 instead of 0.0.0.0 (was network-accessible) - Add MAX_CONCURRENT=3 process limit with 429 response - Stop leaking raw stderr to client; log server-side, send generic error - Validate message structure: role must be user/assistant, content must be a non-empty string, sessionContext must be string if present - Set explicit JSON body limit (200kb) https://claude.ai/code/session_01SDQnB7k2EERPaMDhm6xknd
…ordered lists Server: - Fix activeProcesses double-decrement when spawn fails (both 'error' and 'close' events fire). Use a finish() guard so it decrements once. - Also prevents res.write() after res.end() in the same scenario. Frontend: - Capture sessionId at start of sendMessage so the completion handler saves to the correct session even if the user switches mid-stream. - Stop persisting error messages as assistant messages — they were polluting conversation history sent to the CLI on subsequent turns. - Fix ordered lists: numbered items (1. 2. 3.) now get wrapped in <ol> instead of being orphaned <li> elements. - Remove dead code: unused observedAttributes/attributeChangedCallback on SessionItem, unused createdAt field on sessions. https://claude.ai/code/session_01SDQnB7k2EERPaMDhm6xknd
Summary
This PR introduces a complete local-first Q&A application that allows users to ask questions about repository content, powered by Google's Gemini AI. The app features a modern web UI with session management stored in IndexedDB and a Node.js/Express backend that indexes repository files and streams AI responses.
Key Changes
Frontend (index.html): Full-featured single-page application with:
Backend (server.js): Express server with:
Configuration (config.json): Centralized settings for:
Dependencies: Minimal stack with Express.js and @google/genai SDK
Notable Implementation Details
https://claude.ai/code/session_01SDQnB7k2EERPaMDhm6xknd