Skip to content

Add local-first QA app for repository content with Gemini AI#2

Open
kertal wants to merge 6 commits into
mainfrom
claude/markdown-qa-app-wl3U2
Open

Add local-first QA app for repository content with Gemini AI#2
kertal wants to merge 6 commits into
mainfrom
claude/markdown-qa-app-wl3U2

Conversation

@kertal

@kertal kertal commented Feb 14, 2026

Copy link
Copy Markdown
Owner

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:

    • Responsive sidebar for session management with create/delete functionality
    • Chat interface with real-time streaming message display
    • Markdown rendering with support for code blocks, lists, tables, and formatting
    • IndexedDB-based session persistence (local-first architecture)
    • Web Components for reusable session items and chat messages
    • Auto-expanding textarea input with keyboard shortcuts (Enter to send, Shift+Enter for newline)
    • Status indicators for connection state and streaming status
  • Backend (server.js): Express server with:

    • Secure file indexing with configurable allowed extensions and directory exclusions
    • Repository context caching (60-second TTL) to minimize file I/O
    • Gemini AI integration with streaming response support via Server-Sent Events
    • Request validation for chat messages
    • System prompt that constrains AI to repository content only
    • Lazy initialization of Gemini client with environment variable configuration
  • Configuration (config.json): Centralized settings for:

    • Port, repository root path, and file filtering rules
    • Gemini model selection and token limits
    • Whitelist for external URLs (currently empty for security)
  • Dependencies: Minimal stack with Express.js and @google/genai SDK

Notable Implementation Details

  • Security: Restrictive path validation prevents directory traversal; file access limited to configured extensions and size limits
  • Performance: Repository context cached to avoid repeated file reads; streaming responses prevent large response buffering
  • UX: Session titles auto-generated from first message; editable titles; relative date formatting; auto-scrolling to latest messages
  • Offline Support: File list loads on startup; graceful degradation if server unavailable
  • Markdown Rendering: Custom lightweight parser handles common markdown syntax without external dependencies

https://claude.ai/code/session_01SDQnB7k2EERPaMDhm6xknd

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

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +449 to +458
// 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>');

Copilot AI Feb 14, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread markdown-qa/public/index.html Outdated
Comment on lines +469 to +471
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>');

Copilot AI Feb 14, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
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>');

Copilot uses AI. Check for mistakes.
Comment thread markdown-qa/.gitignore
@@ -0,0 +1 @@
node_modules/

Copilot AI Feb 14, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
node_modules/
node_modules/
# Environment files (may contain secrets like API keys)
.env
.env.*
# Local configuration overrides
*.local
# Logs
*.log

Copilot uses AI. Check for mistakes.
Comment thread markdown-qa/server.js
res.end();
});

app.get('*', (_req, res) => {

Copilot AI Feb 14, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
app.get('*', (_req, res) => {
app.get(/^\/(?!api(\/|$)).*/, (_req, res) => {

Copilot uses AI. Check for mistakes.
Comment thread markdown-qa/server.js Outdated
Comment on lines +137 to +164
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();

Copilot AI Feb 14, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread markdown-qa/package.json
Comment on lines +10 to +13
"dependencies": {
"@google/genai": "^1.41.0",
"express": "^4.21.0"
}

Copilot AI Feb 14, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@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.

Copilot uses AI. Check for mistakes.
Comment thread markdown-qa/server.js Outdated
Comment on lines +172 to +174
app.listen(PORT, () => {
const { files } = getRepoContext();
console.log(`Markdown QA Server running on http://localhost:${PORT}`);

Copilot AI Feb 14, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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}`);

Copilot uses AI. Check for mistakes.
Comment thread markdown-qa/server.js Outdated
Comment on lines +52 to +65
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;

Copilot AI Feb 14, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Comment thread markdown-qa/public/index.html Outdated
Comment on lines +804 to +807
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);

Copilot AI Feb 14, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +636 to +637
this.#el.sessionList.addEventListener('session-select', (e) => this.#switchToSession(e.detail));
this.#el.sessionList.addEventListener('session-delete', (e) => this.#deleteSession(e.detail));

Copilot AI Feb 14, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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);
});

Copilot uses AI. Check for mistakes.
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants