From 7d12b960e3ad77be4da09d709b70521d205de78f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Jan 2026 21:53:48 +0000 Subject: [PATCH] feat: add local LLM chat with WebLLM and PPQ.ai support Implements browser-based AI chat functionality: - WebLLM provider for local inference via WebGPU - Downloads and caches models in IndexedDB - Runs inference in web worker to keep UI responsive - Curated list of recommended models (SmolLM2, Llama 3.2, Phi 3.5, etc.) - PPQ.ai provider for cloud-based inference - OpenAI-compatible API with Lightning payments - Dynamic model list fetching with caching - Provider manager for coordinating multiple providers - Dexie tables for persisting provider instances and conversations - Model list caching with 1-hour TTL - AIViewer component with sidebar pattern - Conversation history in resizable sidebar (mobile: sheet) - Model selector for WebLLM with download progress - Streaming chat responses - Auto-generated conversation titles - New `ai` command to launch the interface https://claude.ai/code/session_01HqtD9R33oqfB14Gu1V5wHC --- package-lock.json | 23 + package.json | 1 + src/components/AIViewer.tsx | 833 +++++++++++++++++++++++++++ src/components/WindowRenderer.tsx | 6 + src/hooks/useLLM.ts | 332 +++++++++++ src/services/db.ts | 22 + src/services/llm/ppq-provider.ts | 186 ++++++ src/services/llm/provider-manager.ts | 246 ++++++++ src/services/llm/providers.ts | 39 ++ src/services/llm/webllm-provider.ts | 273 +++++++++ src/services/llm/webllm-worker.ts | 10 + src/types/app.ts | 3 +- src/types/llm.ts | 147 +++++ src/types/man.ts | 12 + 14 files changed, 2132 insertions(+), 1 deletion(-) create mode 100644 src/components/AIViewer.tsx create mode 100644 src/hooks/useLLM.ts create mode 100644 src/services/llm/ppq-provider.ts create mode 100644 src/services/llm/provider-manager.ts create mode 100644 src/services/llm/providers.ts create mode 100644 src/services/llm/webllm-provider.ts create mode 100644 src/services/llm/webllm-worker.ts create mode 100644 src/types/llm.ts diff --git a/package-lock.json b/package-lock.json index b5615be..33383ed 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "MIT", "dependencies": { "@fiatjaf/git-natural-api": "npm:@jsr/fiatjaf__git-natural-api@^0.2.3", + "@mlc-ai/web-llm": "^0.2.80", "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-avatar": "^1.1.11", "@radix-ui/react-checkbox": "^1.3.3", @@ -2172,6 +2173,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@mlc-ai/web-llm": { + "version": "0.2.80", + "resolved": "https://registry.npmjs.org/@mlc-ai/web-llm/-/web-llm-0.2.80.tgz", + "integrity": "sha512-Hwy1OCsK5cOU4nKr2wIJ2qA1g595PENtO5f2d9Wd/GgFsj5X04uxfaaJfqED8eFAJOpQpn/DirogdEY/yp5jQg==", + "license": "Apache-2.0", + "dependencies": { + "loglevel": "^1.9.1" + } + }, "node_modules/@noble/ciphers": { "version": "0.5.3", "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-0.5.3.tgz", @@ -9248,6 +9258,19 @@ "dev": true, "license": "MIT" }, + "node_modules/loglevel": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.2.tgz", + "integrity": "sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + }, + "funding": { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/loglevel" + } + }, "node_modules/longest-streak": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", diff --git a/package.json b/package.json index 4cf5d98..a624962 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ }, "dependencies": { "@fiatjaf/git-natural-api": "npm:@jsr/fiatjaf__git-natural-api@^0.2.3", + "@mlc-ai/web-llm": "^0.2.80", "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-avatar": "^1.1.11", "@radix-ui/react-checkbox": "^1.3.3", diff --git a/src/components/AIViewer.tsx b/src/components/AIViewer.tsx new file mode 100644 index 0000000..029bc2b --- /dev/null +++ b/src/components/AIViewer.tsx @@ -0,0 +1,833 @@ +/** + * AIViewer - Local LLM Chat Interface + * + * Provides a chat interface for local (WebLLM) and remote (PPQ) LLM providers. + * Uses sidebar pattern from GroupListViewer for conversation history. + */ + +import { useState, useEffect, useCallback, useRef, memo } from "react"; +import { + Loader2, + PanelLeft, + Plus, + Send, + Square, + Trash2, + Settings, + Download, + Check, + ExternalLink, +} from "lucide-react"; +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import { Textarea } from "@/components/ui/textarea"; +import { Progress } from "@/components/ui/progress"; +import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import * as VisuallyHidden from "@radix-ui/react-visually-hidden"; +import { + useLLMProviders, + useLLMModels, + useWebLLMStatus, + useLLMConversations, + useLLMConversation, + useLLMChat, +} from "@/hooks/useLLM"; +import { formatTimestamp } from "@/hooks/useLocale"; +import type { LLMMessage, LLMModel, LLMProviderInstance } from "@/types/llm"; + +const MOBILE_BREAKPOINT = 768; + +function useIsMobile() { + const [isMobile, setIsMobile] = useState(undefined); + + useEffect(() => { + const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`); + const onChange = () => setIsMobile(window.innerWidth < MOBILE_BREAKPOINT); + mql.addEventListener("change", onChange); + setIsMobile(window.innerWidth < MOBILE_BREAKPOINT); + return () => mql.removeEventListener("change", onChange); + }, []); + + return isMobile; +} + +// ───────────────────────────────────────────────────────────── +// Message Component +// ───────────────────────────────────────────────────────────── + +const MessageBubble = memo(function MessageBubble({ + message, +}: { + message: LLMMessage; +}) { + const isUser = message.role === "user"; + + return ( +
+
+
{message.content}
+
+
+ ); +}); + +// ───────────────────────────────────────────────────────────── +// Conversation List Item +// ───────────────────────────────────────────────────────────── + +const ConversationItem = memo(function ConversationItem({ + conversation, + isSelected, + onClick, + onDelete, +}: { + conversation: { id: string; title: string; updatedAt: number }; + isSelected: boolean; + onClick: () => void; + onDelete: () => void; +}) { + return ( +
+
+
{conversation.title}
+
+ {formatTimestamp(conversation.updatedAt / 1000, "relative")} +
+
+ +
+ ); +}); + +// ───────────────────────────────────────────────────────────── +// Model Selector (for WebLLM) +// ───────────────────────────────────────────────────────────── + +function WebLLMModelSelector({ + models, + onSelect, + onDownload, + isLoading, + loadingProgress, + loadingText, +}: { + models: LLMModel[]; + onSelect: (modelId: string) => void; + onDownload: (modelId: string) => void; + isLoading: boolean; + loadingProgress: number; + loadingText: string; +}) { + if (isLoading) { + return ( +
+ + +
+ {loadingText} +
+
+ ); + } + + return ( +
+
Select a model to load
+ {models.map((model) => ( +
+
+
{model.name}
+
+ {model.downloadSize} + {model.description && ` - ${model.description}`} +
+
+ {model.isDownloaded ? ( + + ) : ( + + )} +
+ ))} +
+ ); +} + +// ───────────────────────────────────────────────────────────── +// Provider Setup Dialog +// ───────────────────────────────────────────────────────────── + +function ProviderSetupDialog({ + open, + onOpenChange, + onSave, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + onSave: (instance: Omit) => void; +}) { + const { configs } = useLLMProviders(); + const [providerId, setProviderId] = useState("webllm"); + const [name, setName] = useState(""); + const [apiKey, setApiKey] = useState(""); + + const selectedConfig = configs.find((c) => c.id === providerId); + + const handleSave = () => { + onSave({ + providerId, + name: name || selectedConfig?.name || providerId, + apiKey: selectedConfig?.requiresApiKey ? apiKey : undefined, + enabled: true, + }); + onOpenChange(false); + setName(""); + setApiKey(""); + }; + + return ( + + + + Add Provider + + +
+
+ + +
+ +
+ + setName(e.target.value)} + placeholder={selectedConfig?.name} + /> +
+ + {selectedConfig?.requiresApiKey && ( +
+
+ + {selectedConfig.apiKeyUrl && ( + + Get API Key + + )} +
+ setApiKey(e.target.value)} + placeholder="sk-..." + /> +
+ )} +
+ + + + + +
+
+ ); +} + +// ───────────────────────────────────────────────────────────── +// Chat Panel +// ───────────────────────────────────────────────────────────── + +function ChatPanel({ + conversationId, + providerInstanceId, + modelId, + onConversationCreated, +}: { + conversationId: string | null; + providerInstanceId: string; + modelId: string; + onConversationCreated: (id: string) => void; +}) { + const { conversation, addMessage, updateLastMessage } = + useLLMConversation(conversationId); + const { createConversation } = useLLMConversations(); + const { isGenerating, sendMessage, cancel } = useLLMChat(); + + const [input, setInput] = useState(""); + const [streamingContent, setStreamingContent] = useState(""); + const messagesEndRef = useRef(null); + const textareaRef = useRef(null); + + // Auto-scroll to bottom + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [conversation?.messages, streamingContent]); + + // Focus textarea on mount + useEffect(() => { + textareaRef.current?.focus(); + }, [conversationId]); + + const handleSend = async () => { + if (!input.trim() || isGenerating) return; + + const userContent = input.trim(); + setInput(""); + + // Create conversation if needed + let activeConversationId = conversationId; + if (!activeConversationId) { + activeConversationId = await createConversation( + providerInstanceId, + modelId, + ); + onConversationCreated(activeConversationId); + } + + // Add user message + const userMessage = await addMessage({ + role: "user", + content: userContent, + }); + if (!userMessage) return; + + // Get all messages for context + const conv = await ( + await import("@/services/db") + ).default.llmConversations.get(activeConversationId); + if (!conv) return; + + // Add placeholder assistant message + await addMessage({ role: "assistant", content: "" }); + + // Stream response + setStreamingContent(""); + + let fullContent = ""; + await sendMessage( + providerInstanceId, + modelId, + conv.messages, + (token) => { + fullContent += token; + setStreamingContent(fullContent); + }, + async () => { + await updateLastMessage(fullContent); + setStreamingContent(""); + }, + async (error) => { + await updateLastMessage(`Error: ${error}`); + setStreamingContent(""); + }, + ); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleSend(); + } + }; + + const messages = conversation?.messages ?? []; + const displayMessages = + streamingContent && messages.length > 0 + ? [ + ...messages.slice(0, -1), + { ...messages[messages.length - 1], content: streamingContent }, + ] + : messages; + + return ( +
+ {/* Messages */} + +
+ {displayMessages.length === 0 ? ( +
+ Start a conversation +
+ ) : ( + displayMessages.map((msg) => ( + + )) + )} +
+
+ + + {/* Input */} +
+
+