diff --git a/.env.example b/.env.example index 0f3b098..8a4d697 100644 --- a/.env.example +++ b/.env.example @@ -28,6 +28,18 @@ GITLIB_MEILI_API_KEY= # Host port for reaching Meilisearch directly (loopback only). Not needed for normal use. GITLIB_MEILI_PORT=7711 +# --- Chat (Ollama) ----------------------------------------------------------------------------------- +# The "Ask" panel answers questions about your repositories with a model on an Ollama server. Blank means +# http://127.0.0.1:11434, which inside Docker is the container itself, so for a server elsewhere on your network set +# its address, e.g. GITLIB_OLLAMA_HOST=http://192.168.1.50:11434. On that machine Ollama must listen beyond loopback +# (OLLAMA_HOST=0.0.0.0:11434). Ollama has no login: keep it off the internet. +GITLIB_OLLAMA_HOST= +# Model used unless you pick another in the panel, e.g. qwen3:8b. Blank = the first model the server lists. +GITLIB_OLLAMA_MODEL= +# Context window, in tokens, sent with every request. A larger one remembers more but needs more GPU memory (8192 is +# comfortable on a 12 GB card). Ollama reloads a model when this changes between requests, so keep one value. +#GITLIB_OLLAMA_NUM_CTX=8192 + # --- Integrations ------------------------------------------------------------------------------------ # Fine-grained token with read-only "Issues" access is enough. Not included in backups: keep it elsewhere. GITLIB_GITHUB_TOKEN= diff --git a/README.md b/README.md index f944d80..585cd7f 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,40 @@ index = "gitlib_repos" `GITLIB_MEILI_HOST` and `GITLIB_MEILI_API_KEY` override `host` and `api_key`. The web app's search bar is pinned to the top of the Repositories page (press `/` to focus it). +## Chat + +The **Ask** button in the web app's top bar slides out a chat panel on the right. It answers questions about your +repositories with a model on an [Ollama](https://ollama.com/) server, which can be another machine on your network; +nothing is sent anywhere else. Ollama is optional: without it the panel says it cannot reach the server and nothing +else changes. How the options were weighed is in [`docs/ollama-integration.md`](docs/ollama-integration.md). + +- The model sees a capped list of every repository (name, path, technologies, description, favorites) and, on a + repository's page, that repository's notes and the start of its README. It cannot browse files, run commands or + search, so it answers only from what gitlib knows and says so when it does not know. +- Replies stream in as they are written. **Stop** cancels the request on the Ollama server too. +- The conversation lives in the browser tab and survives moving between pages, not a reload. The model is picked from + the panel's list of what the server has installed, and the choice is remembered in that browser. +- Replies are shown as Markdown without images: text a model writes can be shaped by a hostile README, and an image + address can carry data out. +- Long conversations are cut to fit the context window (oldest turns first) before they are sent, because Ollama + would otherwise drop text without saying so. + +Set it in `~/.git-library/config.toml` (all keys optional): + +```toml +[ollama] +enabled = true # false = never contact Ollama +host = "http://127.0.0.1:11434" +model = "" # blank = the first installed model +num_ctx = 8192 # context window, in tokens, sent with every request +``` + +`GITLIB_OLLAMA_HOST`, `GITLIB_OLLAMA_MODEL` and `GITLIB_OLLAMA_NUM_CTX` override them. In Docker, `localhost` is the +container itself, so set `GITLIB_OLLAMA_HOST` in `.env` to the Ollama machine's address, and on that machine start +Ollama with `OLLAMA_HOST=0.0.0.0:11434` so it listens beyond loopback. Ollama has no login of its own and the web app +has none either, so keep both off the internet. On a 12 GB graphics card an 8B-class model with `num_ctx = 8192` is a +reasonable start; check with `ollama ps` on that machine that the model is fully on the GPU. + ## Planning The web app's **Planning** page (`/planning`) is a place to file and track work against any @@ -176,6 +210,7 @@ npm run build -w @gitlib/web && npm run preview -w @gitlib/web # production bu Each missing item on the Community tab has an **Add to planning** button that files a gitlib [planning](#planning) issue for it (stored in gitlib only); once filed, the row links to that issue. - The **Planning** page (`/planning`) holds gitlib's own issues; see [Planning](#planning). +- The **Ask** button opens a chat panel for questions about your repositories; see [Chat](#chat). - Lists every git repo found under your configured scan roots (scanned on first load; use **Rescan** after adding repos). - Add or edit a short **description** per repo. It is stored in the shared SQLite database diff --git a/apps/web/.env.example b/apps/web/.env.example index 41caee9..91b2301 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -3,6 +3,11 @@ GITLIB_GITHUB_TOKEN= # How often (in hours) GitHub community scores are fetched again; 0 = only when you press Refresh. #GITLIB_COMMUNITY_REFRESH_HOURS=24 +# The "Ask" panel's Ollama server (default http://127.0.0.1:11434), the model to use unless you pick another in the +# panel (blank = the first installed), and the context window in tokens sent with every request. +#GITLIB_OLLAMA_HOST=http://192.168.1.50:11434 +#GITLIB_OLLAMA_MODEL=qwen3:8b +#GITLIB_OLLAMA_NUM_CTX=8192 # Contribution graph "Local + GitHub & GitLab": GitHub uses the token above (any read-only token works). # GitLab reads your public contribution calendar; set GITLIB_GITLAB_URL too if you self-host. GITLIB_GITLAB_USERNAME= diff --git a/apps/web/server.mjs b/apps/web/server.mjs index f608102..9409c36 100644 --- a/apps/web/server.mjs +++ b/apps/web/server.mjs @@ -55,12 +55,19 @@ const server = http.createServer(async (req, res) => { const url = new URL(req.url, `http://${req.headers.host ?? "localhost"}`); const hasBody = req.method !== "GET" && req.method !== "HEAD"; + // Tells handlers (request.signal) when the browser went away before the reply finished, so a closed chat + // panel stops the model instead of leaving it generating for nobody. + const disconnected = new AbortController(); + res.on("close", () => { + if (!res.writableFinished) disconnected.abort(); + }); const response = await app.fetch( new Request(url, { method: req.method, headers: req.headers, body: hasBody ? Readable.toWeb(req) : undefined, duplex: "half", + signal: disconnected.signal, }), ); diff --git a/apps/web/src/components/chat-panel.tsx b/apps/web/src/components/chat-panel.tsx new file mode 100644 index 0000000..b00b365 --- /dev/null +++ b/apps/web/src/components/chat-panel.tsx @@ -0,0 +1,294 @@ +import { useMatch } from "@tanstack/react-router"; +import { BookMarked, Library, RefreshCw, Send, Square, Trash2, TriangleAlert } from "lucide-react"; +import { memo, useEffect, useRef, useState, type FormEvent, type KeyboardEvent } from "react"; +import { MarkdownBody } from "#/components/markdown-body"; +import { Button } from "#/components/ui/button"; +import { NativeSelect } from "#/components/ui/native-select"; +import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "#/components/ui/sheet"; +import { Textarea } from "#/components/ui/textarea"; +import { MAX_TURN_CHARS } from "#/lib/chat-protocol"; +import { useChat, type ChatMessageView } from "#/lib/use-chat"; +import { getChatStatus, type ChatStatus } from "#/server/chat"; + +const MODEL_KEY = "gitlib:chat-model"; + +function readStoredModel(): string | null { + try { + return localStorage.getItem(MODEL_KEY); + } catch { + return null; // private windows and blocked storage: the server's default is fine + } +} + +function storeModel(name: string): void { + try { + localStorage.setItem(MODEL_KEY, name); + } catch { + // Not remembered; nothing else depends on it. + } +} + +type StatusState = { state: "loading" } | { state: "ready"; status: ChatStatus } | { state: "failed"; message: string }; + +/** Asks the server whether Ollama answers each time the panel opens, keeping the last answer on screen meanwhile. */ +function useChatStatus(open: boolean) { + const [result, setResult] = useState({ state: "loading" }); + const [attempt, setAttempt] = useState(0); + + useEffect(() => { + if (!open) return; + let cancelled = false; + getChatStatus() + .then((status) => !cancelled && setResult({ state: "ready", status })) + .catch((error: unknown) => { + if (!cancelled) setResult({ state: "failed", message: error instanceof Error ? error.message : "Could not check Ollama." }); + }); + return () => { + cancelled = true; + }; + }, [open, attempt]); + + return { result, retry: () => setAttempt((count) => count + 1) }; +} + +function ScopeChip({ repo }: { repo: string | null }) { + const Icon = repo ? BookMarked : Library; + return ( + + + {repo ? (repo.split("/").pop() ?? repo) : "All repositories"} + + ); +} + +const Message = memo(function Message({ message }: { message: ChatMessageView }) { + if (message.role === "user") { + return ( +
+ {message.content} +
+ ); + } + return ( +
+
+ {message.content ? ( + {message.content} + ) : message.status === "streaming" ? ( + Thinking… + ) : null} + {message.status === "stopped" &&

Stopped.

} + {message.status === "error" && ( +

+ + {message.error} +

+ )} +
+ {message.notice &&

{message.notice}

} + {message.model && message.status !== "streaming" &&

{message.model}

} +
+ ); +}); + +const LIBRARY_QUESTIONS = ["Which of my repos use TypeScript?", "Which repos have no description?", "What are my favorites?"]; +const REPO_QUESTIONS = ["What is this repo for?", "How do I run it?", "Summarize my notes on it."]; + +/** Why the composer is off, or null when chat is ready. */ +function Banner({ result, retry }: { result: StatusState; retry: () => void }) { + let message: string | null = null; + let hint: string | null = null; + let canRetry = true; + + if (result.state === "loading") { + message = "Checking Ollama…"; + canRetry = false; + } else if (result.state === "failed") { + message = result.message; + } else if (!result.status.enabled) { + message = "Chat is turned off. Set `enabled = true` under [ollama] in config.toml to use it."; + canRetry = false; + } else if (!result.status.reachable) { + message = result.status.error; + hint = "Set GITLIB_OLLAMA_HOST to your Ollama server's address. Inside Docker, localhost is the container itself."; + } else { + message = result.status.error; + } + if (!message) return null; + + return ( +
+

{message}

+ {hint &&

{hint}

} + {canRetry && ( + + )} +
+ ); +} + +/** The slide-out chat: questions about your repositories, answered by the model on your Ollama server. */ +export function ChatPanel({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) { + const { result, retry } = useChatStatus(open); + const status = result.state === "ready" ? result.status : null; + // On a repository's page the answers can use its notes and README. + const repo = useMatch({ from: "/repos/$", shouldThrow: false })?.params._splat || null; + + const chat = useChat(); + const [draft, setDraft] = useState(""); + const [chosen, setChosen] = useState(null); + useEffect(() => setChosen(readStoredModel()), []); + const model = status && chosen && status.models.includes(chosen) ? chosen : (status?.defaultModel ?? null); + const ready = !!status && status.enabled && status.reachable && status.models.length > 0 && !!model; + + const inputRef = useRef(null); + const listRef = useRef(null); + const stuckToBottom = useRef(true); + + // Follow the reply as it grows, unless the reader has scrolled up to look at something. + useEffect(() => { + const list = listRef.current; + if (list && stuckToBottom.current) list.scrollTop = list.scrollHeight; + }, [chat.messages, open]); + + function submit(text: string) { + const question = text.trim(); + if (!ready || chat.busy || !question) return; + setDraft(""); + stuckToBottom.current = true; + void chat.send(question, { model, repo }); + } + + function onKeyDown(event: KeyboardEvent) { + if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) { + event.preventDefault(); + submit(draft); + } + } + + const questions = repo ? REPO_QUESTIONS : LIBRARY_QUESTIONS; + + return ( + + event.preventDefault()} + onOpenAutoFocus={(event) => { + event.preventDefault(); + inputRef.current?.focus(); + }} + > + +
+ Ask Gitlib + + +
+ Ask a local model questions about your repositories. + {status && status.models.length > 0 && ( + { + setChosen(event.target.value); + storeModel(event.target.value); + }} + > + {status.models.map((name) => ( + + ))} + + )} +
+ + {!ready && } + +
{ + const list = event.currentTarget; + stuckToBottom.current = list.scrollHeight - list.scrollTop - list.clientHeight < 48; + }} + className="flex-1 space-y-3 overflow-y-auto p-3" + > + {chat.messages.length === 0 && ready && ( +
+

+ {repo ? "Ask about this repository, or about your library." : "Ask about your repositories."} Answers use only what + Gitlib knows: names, technologies, descriptions, notes and READMEs. +

+
+ {questions.map((question) => ( + + ))} +
+
+ )} + {chat.messages.map((message) => ( + + ))} +

+ {chat.busy ? "Waiting for a reply" : chat.messages.length > 0 ? "Reply finished" : ""} +

+
+ +
{ + event.preventDefault(); + submit(draft); + }} + className="border-t p-3" + > +
+