From b24c218c7209945037ba4ba36e1431f85e1e77ad Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Jan 2026 22:23:37 +0000 Subject: [PATCH] feat: improve AI interface with markdown, thinking indicator, and providers subcommand - Add 'ai providers' subcommand for provider management - Add markdown rendering for AI responses using react-markdown - Add thinking indicator with brain icon while waiting for response - Handle PPQ 402 Payment Required with 'Insufficient balance' message - Extract AIProvidersViewer to separate component - Move 'Add Provider' button to providers interface only - Fix chat input width to be full width https://claude.ai/code/session_01HqtD9R33oqfB14Gu1V5wHC --- src/components/AIProvidersViewer.tsx | 365 +++++++++++++++++++++++++++ src/components/AIViewer.tsx | 333 +++++++++--------------- src/components/WindowRenderer.tsx | 2 +- src/services/llm/ppq-provider.ts | 8 + src/types/app.ts | 3 +- src/types/man.ts | 23 +- 6 files changed, 515 insertions(+), 219 deletions(-) create mode 100644 src/components/AIProvidersViewer.tsx diff --git a/src/components/AIProvidersViewer.tsx b/src/components/AIProvidersViewer.tsx new file mode 100644 index 0000000..5884ed2 --- /dev/null +++ b/src/components/AIProvidersViewer.tsx @@ -0,0 +1,365 @@ +/** + * AIProvidersViewer - Manage AI Providers + * + * Allows users to add, edit, and remove LLM providers (WebLLM, PPQ.ai). + */ + +import { useState } from "react"; +import { + Plus, + Trash2, + ExternalLink, + Check, + Download, + Loader2, +} from "lucide-react"; +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Progress } from "@/components/ui/progress"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from "@/components/ui/dialog"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { useLLMProviders, useLLMModels, useWebLLMStatus } from "@/hooks/useLLM"; +import type { LLMModel, LLMProviderInstance } from "@/types/llm"; + +// ───────────────────────────────────────────────────────────── +// Add Provider Dialog +// ───────────────────────────────────────────────────────────── + +function AddProviderDialog({ + 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(""); + setProviderId("webllm"); + }; + + return ( + + + + Add Provider + + +
+
+ + +
+ +
+ + setName(e.target.value)} + placeholder={selectedConfig?.name} + /> +
+ + {selectedConfig?.requiresApiKey && ( +
+
+ + {selectedConfig.apiKeyUrl && ( + + Get API Key + + )} +
+ setApiKey(e.target.value)} + placeholder="sk-..." + /> +
+ )} +
+ + + + + +
+
+ ); +} + +// ───────────────────────────────────────────────────────────── +// WebLLM Model Manager +// ───────────────────────────────────────────────────────────── + +function WebLLMModelManager({ instanceId }: { instanceId: string }) { + const { models, loading, refresh } = useLLMModels(instanceId); + const { status, loadModel, deleteModel } = useWebLLMStatus(); + + const isLoading = status.state === "loading"; + const loadedModelId = status.state === "ready" ? status.modelId : null; + + const handleDelete = async (modelId: string) => { + await deleteModel(modelId); + refresh(); + }; + + if (loading) { + return ( +
+ +
+ ); + } + + return ( +
+
+ Available Models +
+ {models.map((model) => ( + loadModel(model.id)} + onDelete={() => handleDelete(model.id)} + /> + ))} +
+ ); +} + +function ModelItem({ + model, + isLoaded, + isLoading, + loadingProgress, + loadingText, + onLoad, + onDelete, +}: { + model: LLMModel; + isLoaded: boolean; + isLoading: boolean; + loadingProgress: number; + loadingText: string; + onLoad: () => void; + onDelete: () => void; +}) { + return ( +
+
+
+ {model.name} + {isLoaded && ( + Active + )} +
+
+ {model.downloadSize} + {model.description && ` - ${model.description}`} +
+ {isLoading && ( +
+ +
+ {loadingText} +
+
+ )} +
+
+ {model.isDownloaded ? ( + <> + {!isLoaded && ( + + )} + + + ) : ( + + )} +
+
+ ); +} + +// ───────────────────────────────────────────────────────────── +// Provider Card +// ───────────────────────────────────────────────────────────── + +function ProviderCard({ + instance, + onDelete, +}: { + instance: LLMProviderInstance; + onDelete: () => void; +}) { + const { configs } = useLLMProviders(); + const config = configs.find((c) => c.id === instance.providerId); + const isWebLLM = instance.providerId === "webllm"; + + return ( +
+
+
+
{instance.name}
+
+ {config?.name || instance.providerId} + {instance.apiKey && " - API key configured"} +
+
+ +
+ + {isWebLLM && } + + {!isWebLLM && config?.topUpUrl && ( + + Top up balance + + )} +
+ ); +} + +// ───────────────────────────────────────────────────────────── +// Main Component +// ───────────────────────────────────────────────────────────── + +export function AIProvidersViewer() { + const { instances, addInstance, removeInstance } = useLLMProviders(); + const [showAddDialog, setShowAddDialog] = useState(false); + + const handleAddProvider = async ( + instance: Omit, + ) => { + await addInstance(instance); + }; + + return ( +
+
+
AI Providers
+ +
+ + + {instances.length === 0 ? ( +
+
+ No providers configured +
+ +
+ ) : ( +
+ {instances.map((instance) => ( + removeInstance(instance.id)} + /> + ))} +
+ )} +
+ + +
+ ); +} diff --git a/src/components/AIViewer.tsx b/src/components/AIViewer.tsx index 029bc2b..f7f3bd1 100644 --- a/src/components/AIViewer.tsx +++ b/src/components/AIViewer.tsx @@ -6,6 +6,8 @@ */ import { useState, useEffect, useCallback, useRef, memo } from "react"; +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; import { Loader2, PanelLeft, @@ -13,10 +15,9 @@ import { Send, Square, Trash2, - Settings, Download, Check, - ExternalLink, + Brain, } from "lucide-react"; import { cn } from "@/lib/utils"; import { Button } from "@/components/ui/button"; @@ -30,15 +31,6 @@ import { 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 { @@ -50,7 +42,8 @@ import { useLLMChat, } from "@/hooks/useLLM"; import { formatTimestamp } from "@/hooks/useLocale"; -import type { LLMMessage, LLMModel, LLMProviderInstance } from "@/types/llm"; +import { AIProvidersViewer } from "./AIProvidersViewer"; +import type { LLMMessage, LLMModel } from "@/types/llm"; const MOBILE_BREAKPOINT = 768; @@ -91,7 +84,81 @@ const MessageBubble = memo(function MessageBubble({ : "bg-muted text-foreground", )} > -
{message.content}
+ {isUser ? ( +
+ {message.content} +
+ ) : ( +
+ ( +

+ ), + code: ({ className, children, ...props }: any) => { + const isBlock = className?.includes("language-"); + if (isBlock) { + return ( +

+                        {children}
+                      
+ ); + } + return ( + + {children} + + ); + }, + pre: ({ children }) => <>{children}, + ul: ({ ...props }) => ( +
    + ), + ol: ({ ...props }) => ( +
      + ), + a: ({ href, children, ...props }) => ( + + {children} + + ), + }} + > + {message.content} + +
+ )} + + + ); +}); + +// ───────────────────────────────────────────────────────────── +// Thinking Indicator +// ───────────────────────────────────────────────────────────── + +const ThinkingIndicator = memo(function ThinkingIndicator() { + return ( +
+
+ + Thinking...
); @@ -142,7 +209,7 @@ const ConversationItem = memo(function ConversationItem({ }); // ───────────────────────────────────────────────────────────── -// Model Selector (for WebLLM) +// Model Selector (for WebLLM when no model loaded) // ───────────────────────────────────────────────────────────── function WebLLMModelSelector({ @@ -217,112 +284,6 @@ function WebLLMModelSelector({ ); } -// ───────────────────────────────────────────────────────────── -// 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 // ───────────────────────────────────────────────────────────── @@ -407,7 +368,7 @@ function ChatPanel({ setStreamingContent(""); }, async (error) => { - await updateLastMessage(`Error: ${error}`); + await updateLastMessage(error); setStreamingContent(""); }, ); @@ -434,20 +395,23 @@ function ChatPanel({ {/* Messages */}
- {displayMessages.length === 0 ? ( + {displayMessages.length === 0 && !isGenerating ? (
Start a conversation
) : ( - displayMessages.map((msg) => ( - - )) + <> + {displayMessages.map((msg) => ( + + ))} + {isGenerating && !streamingContent && } + )}
- {/* Input */} + {/* Input - full width */}