diff --git a/src/components/WindowRenderer.tsx b/src/components/WindowRenderer.tsx
index e43055e..4af99e3 100644
--- a/src/components/WindowRenderer.tsx
+++ b/src/components/WindowRenderer.tsx
@@ -43,6 +43,9 @@ const BlossomViewer = lazy(() =>
import("./BlossomViewer").then((m) => ({ default: m.BlossomViewer })),
);
const CountViewer = lazy(() => import("./CountViewer"));
+const AIViewer = lazy(() =>
+ import("./ai/AIViewer").then((m) => ({ default: m.AIViewer })),
+);
// Loading fallback component
function ViewerLoading() {
@@ -220,6 +223,14 @@ export function WindowRenderer({ window, onClose }: WindowRendererProps) {
/>
);
break;
+ case "ai":
+ content = (
+
+ );
+ break;
default:
content = (
diff --git a/src/components/ai/AIChat.tsx b/src/components/ai/AIChat.tsx
new file mode 100644
index 0000000..389d843
--- /dev/null
+++ b/src/components/ai/AIChat.tsx
@@ -0,0 +1,337 @@
+/**
+ * AIChat - Conversation view with message timeline and input
+ */
+
+import { useState, useEffect, useRef, useCallback, memo } from "react";
+import { Virtuoso, VirtuosoHandle } from "react-virtuoso";
+import { Loader2, User, Bot, AlertCircle } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { aiService } from "@/services/ai-service";
+import type { AIConversation, AIMessage } from "@/services/db";
+import { AIMessageContent } from "./AIMessageContent";
+
+interface AIChatProps {
+ conversation: AIConversation;
+ onConversationUpdate?: (conversation: AIConversation) => void;
+}
+
+/**
+ * Format timestamp for display
+ */
+function formatTime(timestamp: number): string {
+ return new Date(timestamp).toLocaleTimeString(undefined, {
+ hour: "numeric",
+ minute: "2-digit",
+ });
+}
+
+/**
+ * Format date for day markers
+ */
+function formatDayMarker(timestamp: number): string {
+ const date = new Date(timestamp);
+ const today = new Date();
+ const yesterday = new Date(today);
+ yesterday.setDate(yesterday.getDate() - 1);
+
+ const dateOnly = new Date(
+ date.getFullYear(),
+ date.getMonth(),
+ date.getDate(),
+ );
+ const todayOnly = new Date(
+ today.getFullYear(),
+ today.getMonth(),
+ today.getDate(),
+ );
+ const yesterdayOnly = new Date(
+ yesterday.getFullYear(),
+ yesterday.getMonth(),
+ yesterday.getDate(),
+ );
+
+ if (dateOnly.getTime() === todayOnly.getTime()) return "Today";
+ if (dateOnly.getTime() === yesterdayOnly.getTime()) return "Yesterday";
+
+ return date.toLocaleDateString(undefined, { month: "short", day: "numeric" });
+}
+
+/**
+ * Check if two timestamps are on different days
+ */
+function isDifferentDay(t1: number, t2: number): boolean {
+ const d1 = new Date(t1);
+ const d2 = new Date(t2);
+ return (
+ d1.getFullYear() !== d2.getFullYear() ||
+ d1.getMonth() !== d2.getMonth() ||
+ d1.getDate() !== d2.getDate()
+ );
+}
+
+/**
+ * Single message component
+ */
+const MessageItem = memo(function MessageItem({
+ message,
+}: {
+ message: AIMessage;
+}) {
+ const isUser = message.role === "user";
+
+ return (
+
+
+ {isUser ? : }
+
+
+
+
+ {isUser ? "You" : "Assistant"}
+
+
+ {formatTime(message.timestamp)}
+
+ {message.isStreaming && (
+
+ )}
+
+
+
+
+ );
+});
+
+export function AIChat({ conversation, onConversationUpdate }: AIChatProps) {
+ const [messages, setMessages] = useState
([]);
+ const [input, setInput] = useState("");
+ const [isSending, setIsSending] = useState(false);
+ const [error, setError] = useState(null);
+ const virtuosoRef = useRef(null);
+ const inputRef = useRef(null);
+
+ // Load messages on conversation change
+ useEffect(() => {
+ loadMessages();
+ }, [conversation.id]);
+
+ const loadMessages = useCallback(async () => {
+ const loaded = await aiService.getMessages(conversation.id);
+ setMessages(loaded);
+ }, [conversation.id]);
+
+ // Poll for updates when streaming
+ useEffect(() => {
+ const hasStreaming = messages.some((m) => m.isStreaming);
+ if (!hasStreaming) return;
+
+ const interval = setInterval(async () => {
+ const loaded = await aiService.getMessages(conversation.id);
+ setMessages(loaded);
+
+ // Check if still streaming
+ const stillStreaming = loaded.some((m) => m.isStreaming);
+ if (!stillStreaming) {
+ clearInterval(interval);
+ }
+ }, 100);
+
+ return () => clearInterval(interval);
+ }, [messages, conversation.id]);
+
+ // Process messages for day markers
+ const messagesWithMarkers = messages.reduce<
+ Array<
+ | { type: "message"; data: AIMessage }
+ | { type: "day-marker"; data: string; timestamp: number }
+ >
+ >((acc, message, index) => {
+ if (index === 0) {
+ acc.push({
+ type: "day-marker",
+ data: formatDayMarker(message.timestamp),
+ timestamp: message.timestamp,
+ });
+ } else {
+ const prev = messages[index - 1];
+ if (isDifferentDay(prev.timestamp, message.timestamp)) {
+ acc.push({
+ type: "day-marker",
+ data: formatDayMarker(message.timestamp),
+ timestamp: message.timestamp,
+ });
+ }
+ }
+ acc.push({ type: "message", data: message });
+ return acc;
+ }, []);
+
+ const handleSend = useCallback(async () => {
+ if (!input.trim() || isSending) return;
+
+ const content = input.trim();
+ setInput("");
+ setError(null);
+ setIsSending(true);
+
+ // Optimistically add user message
+ const userMessage: AIMessage = {
+ id: crypto.randomUUID(),
+ conversationId: conversation.id,
+ role: "user",
+ content,
+ timestamp: Date.now(),
+ };
+ setMessages((prev) => [...prev, userMessage]);
+
+ try {
+ await aiService.sendMessage(
+ conversation.id,
+ content,
+ (_chunk, fullContent) => {
+ // Update messages as chunks arrive
+ setMessages((prev) => {
+ const last = prev[prev.length - 1];
+ if (last?.role === "assistant" && last.isStreaming) {
+ return [...prev.slice(0, -1), { ...last, content: fullContent }];
+ }
+ // Create new assistant message
+ return [
+ ...prev,
+ {
+ id: crypto.randomUUID(),
+ conversationId: conversation.id,
+ role: "assistant",
+ content: fullContent,
+ timestamp: Date.now(),
+ isStreaming: true,
+ },
+ ];
+ });
+ },
+ );
+
+ // Reload messages to get final state
+ await loadMessages();
+
+ // Notify parent of update (for title changes)
+ const updatedConv = await aiService.getConversation(conversation.id);
+ if (updatedConv && onConversationUpdate) {
+ onConversationUpdate(updatedConv);
+ }
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Failed to send message");
+ // Reload messages to get actual state
+ await loadMessages();
+ } finally {
+ setIsSending(false);
+ }
+ }, [input, isSending, conversation.id, loadMessages, onConversationUpdate]);
+
+ const handleKeyDown = useCallback(
+ (e: React.KeyboardEvent) => {
+ if (e.key === "Enter" && !e.shiftKey) {
+ e.preventDefault();
+ handleSend();
+ }
+ },
+ [handleSend],
+ );
+
+ // Auto-resize textarea
+ const handleInputChange = useCallback(
+ (e: React.ChangeEvent) => {
+ setInput(e.target.value);
+ // Auto-resize
+ const textarea = e.target;
+ textarea.style.height = "auto";
+ textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`;
+ },
+ [],
+ );
+
+ return (
+
+ {/* Message Timeline */}
+
+ {messagesWithMarkers.length > 0 ? (
+
{
+ if (item.type === "day-marker") {
+ return (
+
+
+ {item.data}
+
+
+ );
+ }
+ return ;
+ }}
+ style={{ height: "100%" }}
+ />
+ ) : (
+
+
Start a conversation...
+
+ )}
+
+
+ {/* Error Message */}
+ {error && (
+
+
+
{error}
+
+
+ )}
+
+ {/* Input */}
+
+
+
+
+
+
+ Press Enter to send, Shift+Enter for new line
+
+
+
+ );
+}
diff --git a/src/components/ai/AIConversationList.tsx b/src/components/ai/AIConversationList.tsx
new file mode 100644
index 0000000..b51f78b
--- /dev/null
+++ b/src/components/ai/AIConversationList.tsx
@@ -0,0 +1,160 @@
+/**
+ * AIConversationList - Displays list of conversations grouped by date
+ */
+
+import { memo, useMemo } from "react";
+import { MessageSquare, Trash2 } from "lucide-react";
+import {
+ SidebarGroup,
+ SidebarGroupLabel,
+ SidebarGroupContent,
+ SidebarMenu,
+ SidebarMenuItem,
+ SidebarMenuButton,
+} from "@/components/ui/sidebar";
+import { Button } from "@/components/ui/button";
+import type { AIConversation } from "@/services/db";
+
+interface AIConversationListProps {
+ conversations: AIConversation[];
+ activeConversation: AIConversation | null;
+ onSelect: (conversation: AIConversation) => void;
+ onDelete: (id: string) => void;
+}
+
+/**
+ * Group conversations by date
+ */
+function groupByDate(
+ conversations: AIConversation[],
+): Map {
+ const groups = new Map();
+ const now = new Date();
+ const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
+ const yesterday = new Date(today);
+ yesterday.setDate(yesterday.getDate() - 1);
+ const lastWeek = new Date(today);
+ lastWeek.setDate(lastWeek.getDate() - 7);
+ const lastMonth = new Date(today);
+ lastMonth.setMonth(lastMonth.getMonth() - 1);
+
+ for (const conv of conversations) {
+ const date = new Date(conv.updatedAt);
+ const dateOnly = new Date(
+ date.getFullYear(),
+ date.getMonth(),
+ date.getDate(),
+ );
+
+ let group: string;
+ if (dateOnly.getTime() >= today.getTime()) {
+ group = "Today";
+ } else if (dateOnly.getTime() >= yesterday.getTime()) {
+ group = "Yesterday";
+ } else if (dateOnly.getTime() >= lastWeek.getTime()) {
+ group = "Last 7 days";
+ } else if (dateOnly.getTime() >= lastMonth.getTime()) {
+ group = "Last 30 days";
+ } else {
+ group = "Older";
+ }
+
+ if (!groups.has(group)) {
+ groups.set(group, []);
+ }
+ groups.get(group)!.push(conv);
+ }
+
+ return groups;
+}
+
+const ConversationItem = memo(function ConversationItem({
+ conversation,
+ isActive,
+ onSelect,
+ onDelete,
+}: {
+ conversation: AIConversation;
+ isActive: boolean;
+ onSelect: () => void;
+ onDelete: () => void;
+}) {
+ return (
+
+
+
+ {conversation.title}
+
+
+
+ );
+});
+
+export function AIConversationList({
+ conversations,
+ activeConversation,
+ onSelect,
+ onDelete,
+}: AIConversationListProps) {
+ const grouped = useMemo(() => groupByDate(conversations), [conversations]);
+
+ if (conversations.length === 0) {
+ return (
+
+
+
No conversations yet
+
+ );
+ }
+
+ // Order of groups to display
+ const groupOrder = [
+ "Today",
+ "Yesterday",
+ "Last 7 days",
+ "Last 30 days",
+ "Older",
+ ];
+
+ return (
+ <>
+ {groupOrder.map((groupName) => {
+ const items = grouped.get(groupName);
+ if (!items || items.length === 0) return null;
+
+ return (
+
+ {groupName}
+
+
+ {items.map((conv) => (
+ onSelect(conv)}
+ onDelete={() => onDelete(conv.id)}
+ />
+ ))}
+
+
+
+ );
+ })}
+ >
+ );
+}
diff --git a/src/components/ai/AIMessageContent.tsx b/src/components/ai/AIMessageContent.tsx
new file mode 100644
index 0000000..23004a3
--- /dev/null
+++ b/src/components/ai/AIMessageContent.tsx
@@ -0,0 +1,117 @@
+/**
+ * AIMessageContent - Renders AI message content with markdown support
+ */
+
+import { memo } from "react";
+import ReactMarkdown from "react-markdown";
+import remarkGfm from "remark-gfm";
+import { cn } from "@/lib/utils";
+
+interface AIMessageContentProps {
+ content: string;
+ isStreaming?: boolean;
+}
+
+export const AIMessageContent = memo(function AIMessageContent({
+ content,
+ isStreaming,
+}: AIMessageContentProps) {
+ if (!content) {
+ return isStreaming ? (
+ Thinking...
+ ) : null;
+ }
+
+ return (
+
+
(
+
+ {children}
+
+ ),
+ code: ({ className, children, ...props }) => {
+ const isInline = !className;
+ if (isInline) {
+ return (
+
+ {children}
+
+ );
+ }
+ return (
+
+ {children}
+
+ );
+ },
+ // Links
+ a: ({ href, children }) => (
+
+ {children}
+
+ ),
+ // Lists
+ ul: ({ children }) => (
+
+ ),
+ ol: ({ children }) => (
+ {children}
+ ),
+ // Paragraphs
+ p: ({ children }) => {children}
,
+ // Headings
+ h1: ({ children }) => (
+ {children}
+ ),
+ h2: ({ children }) => (
+ {children}
+ ),
+ h3: ({ children }) => (
+ {children}
+ ),
+ // Blockquotes
+ blockquote: ({ children }) => (
+
+ {children}
+
+ ),
+ // Tables
+ table: ({ children }) => (
+
+ ),
+ th: ({ children }) => (
+
+ {children}
+ |
+ ),
+ td: ({ children }) => (
+ {children} |
+ ),
+ }}
+ >
+ {content}
+
+
+ );
+});
diff --git a/src/components/ai/AIModelSelector.tsx b/src/components/ai/AIModelSelector.tsx
new file mode 100644
index 0000000..a407165
--- /dev/null
+++ b/src/components/ai/AIModelSelector.tsx
@@ -0,0 +1,62 @@
+/**
+ * AIModelSelector - Dropdown to select AI model
+ */
+
+import { ChevronDown } from "lucide-react";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { Button } from "@/components/ui/button";
+
+interface AIModelSelectorProps {
+ models: string[];
+ selectedModel: string;
+ onSelect: (model: string) => void;
+}
+
+export function AIModelSelector({
+ models,
+ selectedModel,
+ onSelect,
+}: AIModelSelectorProps) {
+ if (models.length === 0) {
+ return (
+ No models available
+ );
+ }
+
+ // Truncate long model names for display
+ const displayModel =
+ selectedModel.length > 30
+ ? selectedModel.slice(0, 27) + "..."
+ : selectedModel;
+
+ return (
+
+
+
+
+
+ {models.map((model) => (
+ onSelect(model)}
+ className={`font-mono text-xs ${selectedModel === model ? "bg-muted" : ""}`}
+ >
+ {model}
+
+ ))}
+
+
+ );
+}
diff --git a/src/components/ai/AIProviderSelector.tsx b/src/components/ai/AIProviderSelector.tsx
new file mode 100644
index 0000000..7279f21
--- /dev/null
+++ b/src/components/ai/AIProviderSelector.tsx
@@ -0,0 +1,54 @@
+/**
+ * AIProviderSelector - Dropdown to select between multiple AI providers
+ */
+
+import { ChevronDown, Server } from "lucide-react";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { SidebarMenuButton } from "@/components/ui/sidebar";
+import type { AIProvider } from "@/services/db";
+
+interface AIProviderSelectorProps {
+ providers: AIProvider[];
+ activeProvider: AIProvider | null;
+ onSelect: (provider: AIProvider) => void;
+}
+
+export function AIProviderSelector({
+ providers,
+ activeProvider,
+ onSelect,
+}: AIProviderSelectorProps) {
+ if (providers.length <= 1) {
+ return null;
+ }
+
+ return (
+
+
+
+
+
+ {activeProvider?.name || "Select Provider"}
+
+
+
+
+
+ {providers.map((provider) => (
+ onSelect(provider)}
+ className={activeProvider?.id === provider.id ? "bg-muted" : ""}
+ >
+ {provider.name}
+
+ ))}
+
+
+ );
+}
diff --git a/src/components/ai/AISettings.tsx b/src/components/ai/AISettings.tsx
new file mode 100644
index 0000000..c512c89
--- /dev/null
+++ b/src/components/ai/AISettings.tsx
@@ -0,0 +1,301 @@
+/**
+ * AISettings - Provider configuration component
+ */
+
+import { useState, useCallback, useEffect } from "react";
+import { Loader2, Trash2, Check, X, RefreshCw } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { aiService, DEFAULT_PPQ_PROVIDER } from "@/services/ai-service";
+import type { AIProvider } from "@/services/db";
+
+interface AISettingsProps {
+ provider?: AIProvider;
+ onSaved?: () => void;
+ onCancel?: () => void;
+}
+
+export function AISettings({ provider, onSaved, onCancel }: AISettingsProps) {
+ // Form state
+ const [name, setName] = useState(provider?.name || DEFAULT_PPQ_PROVIDER.name);
+ const [baseUrl, setBaseUrl] = useState(
+ provider?.baseUrl || DEFAULT_PPQ_PROVIDER.baseUrl,
+ );
+ const [apiKey, setApiKey] = useState(provider?.apiKey || "");
+ const [defaultModel, setDefaultModel] = useState(
+ provider?.defaultModel || DEFAULT_PPQ_PROVIDER.defaultModel || "",
+ );
+ const [models, setModels] = useState(provider?.models || []);
+
+ // UI state
+ const [isTesting, setIsTesting] = useState(false);
+ const [isFetchingModels, setIsFetchingModels] = useState(false);
+ const [isSaving, setIsSaving] = useState(false);
+ const [testResult, setTestResult] = useState<"success" | "error" | null>(
+ null,
+ );
+ const [error, setError] = useState(null);
+
+ // Reset form when provider changes
+ useEffect(() => {
+ if (provider) {
+ setName(provider.name);
+ setBaseUrl(provider.baseUrl);
+ setApiKey(provider.apiKey);
+ setDefaultModel(provider.defaultModel || "");
+ setModels(provider.models);
+ }
+ }, [provider]);
+
+ const handleTestConnection = useCallback(async () => {
+ if (!apiKey) {
+ setError("API key is required");
+ return;
+ }
+
+ setIsTesting(true);
+ setTestResult(null);
+ setError(null);
+
+ try {
+ const tempProvider: AIProvider = {
+ id: provider?.id || "temp",
+ name,
+ baseUrl,
+ apiKey,
+ models: [],
+ createdAt: Date.now(),
+ };
+
+ const success = await aiService.testConnection(tempProvider);
+ setTestResult(success ? "success" : "error");
+ if (!success) {
+ setError("Connection failed. Check your API key and URL.");
+ }
+ } catch (err) {
+ setTestResult("error");
+ setError(err instanceof Error ? err.message : "Connection failed");
+ } finally {
+ setIsTesting(false);
+ }
+ }, [apiKey, baseUrl, name, provider?.id]);
+
+ const handleFetchModels = useCallback(async () => {
+ if (!apiKey) {
+ setError("API key is required");
+ return;
+ }
+
+ setIsFetchingModels(true);
+ setError(null);
+
+ try {
+ const tempProvider: AIProvider = {
+ id: provider?.id || "temp",
+ name,
+ baseUrl,
+ apiKey,
+ models: [],
+ createdAt: Date.now(),
+ };
+
+ const fetchedModels = await aiService.fetchModels(tempProvider);
+ setModels(fetchedModels);
+ if (fetchedModels.length > 0 && !defaultModel) {
+ setDefaultModel(fetchedModels[0]);
+ }
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Failed to fetch models");
+ } finally {
+ setIsFetchingModels(false);
+ }
+ }, [apiKey, baseUrl, name, provider?.id, defaultModel]);
+
+ const handleSave = useCallback(async () => {
+ if (!name || !baseUrl || !apiKey) {
+ setError("Name, URL, and API key are required");
+ return;
+ }
+
+ setIsSaving(true);
+ setError(null);
+
+ try {
+ await aiService.saveProvider({
+ id: provider?.id,
+ name,
+ baseUrl,
+ apiKey,
+ models,
+ defaultModel: defaultModel || undefined,
+ });
+ onSaved?.();
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Failed to save provider");
+ } finally {
+ setIsSaving(false);
+ }
+ }, [name, baseUrl, apiKey, models, defaultModel, provider?.id, onSaved]);
+
+ const handleDelete = useCallback(async () => {
+ if (!provider?.id) return;
+
+ if (!confirm("Delete this provider and all its conversations?")) return;
+
+ try {
+ await aiService.deleteProvider(provider.id);
+ onSaved?.();
+ } catch (err) {
+ setError(
+ err instanceof Error ? err.message : "Failed to delete provider",
+ );
+ }
+ }, [provider?.id, onSaved]);
+
+ return (
+
+
+
+
+ {provider ? "Edit Provider" : "Add AI Provider"}
+
+
+ Configure an OpenAI-compatible AI provider like PPQ.ai
+
+
+
+ {/* Error message */}
+ {error && (
+
+ {error}
+
+ )}
+
+ {/* Name */}
+
+
+ setName(e.target.value)}
+ placeholder="PPQ.ai"
+ />
+
+
+ {/* Base URL */}
+
+
+
setBaseUrl(e.target.value)}
+ placeholder="https://api.ppq.ai"
+ />
+
+ Base URL for the OpenAI-compatible API
+
+
+
+ {/* API Key */}
+
+
+ setApiKey(e.target.value)}
+ placeholder="sk-..."
+ />
+
+
+ {/* Test Connection */}
+
+
+
+
+
+ {/* Models */}
+ {models.length > 0 && (
+
+
+
+
+ {models.length} models available
+
+
+ )}
+
+ {/* Actions */}
+
+
+ {onCancel && (
+
+ )}
+ {provider && (
+
+ )}
+
+
+
+ );
+}
diff --git a/src/components/ai/AIViewer.tsx b/src/components/ai/AIViewer.tsx
new file mode 100644
index 0000000..d4243bc
--- /dev/null
+++ b/src/components/ai/AIViewer.tsx
@@ -0,0 +1,357 @@
+/**
+ * AIViewer - Main AI chat interface with conversation sidebar
+ *
+ * Provides a chat interface for AI providers like PPQ.ai
+ */
+
+import { useState, useEffect, useCallback } from "react";
+import {
+ Settings,
+ Plus,
+ MessageSquare,
+ PanelLeftClose,
+ PanelLeft,
+} from "lucide-react";
+import { Button } from "@/components/ui/button";
+import {
+ Sidebar,
+ SidebarProvider,
+ SidebarContent,
+ SidebarHeader,
+ SidebarFooter,
+ SidebarMenu,
+ SidebarMenuItem,
+ SidebarMenuButton,
+ useSidebar,
+} from "@/components/ui/sidebar";
+import { AIChat } from "./AIChat";
+import { AISettings } from "./AISettings";
+import { AIConversationList } from "./AIConversationList";
+import { AIProviderSelector } from "./AIProviderSelector";
+import { AIModelSelector } from "./AIModelSelector";
+import { aiService } from "@/services/ai-service";
+import type { AIProvider, AIConversation } from "@/services/db";
+
+export interface AIViewerProps {
+ view?: "list" | "chat" | "settings";
+ conversationId?: string | null;
+}
+
+export function AIViewer({
+ view: initialView = "list",
+ conversationId: initialConversationId,
+}: AIViewerProps) {
+ // State
+ const [view, setView] = useState<"list" | "chat" | "settings">(initialView);
+ const [providers, setProviders] = useState([]);
+ const [activeProvider, setActiveProvider] = useState(null);
+ const [conversations, setConversations] = useState([]);
+ const [activeConversation, setActiveConversation] =
+ useState(null);
+ const [selectedModel, setSelectedModel] = useState("");
+ const [isLoading, setIsLoading] = useState(true);
+
+ // Load providers and conversations on mount
+ useEffect(() => {
+ loadData();
+ }, []);
+
+ // Load initial conversation if provided
+ useEffect(() => {
+ if (initialConversationId && conversations.length > 0) {
+ const conv = conversations.find((c) => c.id === initialConversationId);
+ if (conv) {
+ setActiveConversation(conv);
+ setView("chat");
+ }
+ }
+ }, [initialConversationId, conversations]);
+
+ const loadData = useCallback(async () => {
+ setIsLoading(true);
+ try {
+ const loadedProviders = await aiService.getProviders();
+ setProviders(loadedProviders);
+
+ if (loadedProviders.length > 0) {
+ const provider = loadedProviders[0];
+ setActiveProvider(provider);
+ setSelectedModel(provider.defaultModel || provider.models[0] || "");
+
+ const loadedConversations = await aiService.getConversations(
+ provider.id,
+ );
+ setConversations(loadedConversations);
+ }
+ } finally {
+ setIsLoading(false);
+ }
+ }, []);
+
+ const handleProviderChange = useCallback(async (provider: AIProvider) => {
+ setActiveProvider(provider);
+ setSelectedModel(provider.defaultModel || provider.models[0] || "");
+ const loadedConversations = await aiService.getConversations(provider.id);
+ setConversations(loadedConversations);
+ setActiveConversation(null);
+ setView("list");
+ }, []);
+
+ const handleNewChat = useCallback(async () => {
+ if (!activeProvider || !selectedModel) return;
+
+ const conversation = await aiService.createConversation(
+ activeProvider.id,
+ selectedModel,
+ );
+ setConversations((prev) => [conversation, ...prev]);
+ setActiveConversation(conversation);
+ setView("chat");
+ }, [activeProvider, selectedModel]);
+
+ const handleSelectConversation = useCallback(
+ (conversation: AIConversation) => {
+ setActiveConversation(conversation);
+ setSelectedModel(conversation.model);
+ setView("chat");
+ },
+ [],
+ );
+
+ const handleDeleteConversation = useCallback(
+ async (id: string) => {
+ await aiService.deleteConversation(id);
+ setConversations((prev) => prev.filter((c) => c.id !== id));
+ if (activeConversation?.id === id) {
+ setActiveConversation(null);
+ setView("list");
+ }
+ },
+ [activeConversation],
+ );
+
+ const handleConversationUpdate = useCallback(
+ (conversation: AIConversation) => {
+ setConversations((prev) =>
+ prev.map((c) => (c.id === conversation.id ? conversation : c)),
+ );
+ if (activeConversation?.id === conversation.id) {
+ setActiveConversation(conversation);
+ }
+ },
+ [activeConversation],
+ );
+
+ const handleProviderSaved = useCallback(async () => {
+ await loadData();
+ setView("list");
+ }, [loadData]);
+
+ const handleModelChange = useCallback(
+ async (model: string) => {
+ setSelectedModel(model);
+ if (activeConversation) {
+ await aiService.updateConversation(activeConversation.id, { model });
+ setActiveConversation((prev) => (prev ? { ...prev, model } : null));
+ }
+ },
+ [activeConversation],
+ );
+
+ // No providers configured - show settings
+ if (!isLoading && providers.length === 0) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+ {/* Sidebar */}
+
setView("settings")}
+ />
+
+ {/* Main Content */}
+
+ {/* Header */}
+
setView("settings")}
+ />
+
+ {/* Content */}
+
+ {view === "settings" ? (
+
setView("list")}
+ />
+ ) : view === "chat" && activeConversation ? (
+
+ ) : (
+
+
+
+
+ Select a conversation or start a new chat
+
+
+
+
+ )}
+
+
+
+
+ );
+}
+
+// Sidebar Component
+function AIViewerSidebar({
+ providers,
+ activeProvider,
+ conversations,
+ activeConversation,
+ onProviderChange,
+ onSelectConversation,
+ onDeleteConversation,
+ onNewChat,
+ onOpenSettings,
+}: {
+ providers: AIProvider[];
+ activeProvider: AIProvider | null;
+ conversations: AIConversation[];
+ activeConversation: AIConversation | null;
+ onProviderChange: (provider: AIProvider) => void;
+ onSelectConversation: (conversation: AIConversation) => void;
+ onDeleteConversation: (id: string) => void;
+ onNewChat: () => void;
+ onOpenSettings: () => void;
+}) {
+ const { state, toggleSidebar } = useSidebar();
+ const isCollapsed = state === "collapsed";
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {providers.length > 1 && (
+
+
+
+ )}
+
+
+
+ Settings
+
+
+
+
+ {isCollapsed ? (
+
+ ) : (
+
+ )}
+ {isCollapsed ? "Expand" : "Collapse"}
+
+
+
+
+
+ );
+}
+
+// Header Component
+function AIViewerHeader({
+ activeProvider,
+ selectedModel,
+ onModelChange,
+ onOpenSettings,
+}: {
+ activeProvider: AIProvider | null;
+ selectedModel: string;
+ onModelChange: (model: string) => void;
+ onOpenSettings: () => void;
+}) {
+ return (
+
+ {activeProvider && (
+ <>
+
+ {activeProvider.name}
+
+
+ >
+ )}
+
+
+
+ );
+}
diff --git a/src/components/ai/index.ts b/src/components/ai/index.ts
new file mode 100644
index 0000000..fe80de5
--- /dev/null
+++ b/src/components/ai/index.ts
@@ -0,0 +1,7 @@
+export { AIViewer } from "./AIViewer";
+export { AIChat } from "./AIChat";
+export { AISettings } from "./AISettings";
+export { AIConversationList } from "./AIConversationList";
+export { AIProviderSelector } from "./AIProviderSelector";
+export { AIModelSelector } from "./AIModelSelector";
+export { AIMessageContent } from "./AIMessageContent";
diff --git a/src/lib/ai-parser.ts b/src/lib/ai-parser.ts
new file mode 100644
index 0000000..223868f
--- /dev/null
+++ b/src/lib/ai-parser.ts
@@ -0,0 +1,46 @@
+/**
+ * AI Command Parser
+ *
+ * Parses arguments for the `ai` command
+ */
+
+export interface AICommandResult {
+ view: "list" | "chat" | "settings";
+ conversationId?: string | null;
+}
+
+/**
+ * Parse AI command arguments
+ *
+ * @example
+ * ai -> { view: "list" }
+ * ai new -> { view: "chat", conversationId: null }
+ * ai settings -> { view: "settings" }
+ * ai -> { view: "chat", conversationId: "" }
+ */
+export function parseAICommand(args: string[]): AICommandResult {
+ if (args.length === 0) {
+ return { view: "list" };
+ }
+
+ const arg = args[0].toLowerCase();
+
+ if (arg === "new") {
+ return { view: "chat", conversationId: null };
+ }
+
+ if (arg === "settings") {
+ return { view: "settings" };
+ }
+
+ // Assume it's a conversation ID
+ // UUID format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
+ const uuidRegex =
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
+ if (uuidRegex.test(args[0])) {
+ return { view: "chat", conversationId: args[0] };
+ }
+
+ // Unknown argument, default to list
+ return { view: "list" };
+}
diff --git a/src/services/ai-service.ts b/src/services/ai-service.ts
new file mode 100644
index 0000000..d870e6a
--- /dev/null
+++ b/src/services/ai-service.ts
@@ -0,0 +1,379 @@
+/**
+ * AI Service - Handles AI provider management and chat completions
+ *
+ * Supports OpenAI-compatible APIs like PPQ.ai
+ */
+
+import db, { type AIProvider, type AIConversation, type AIMessage } from "./db";
+
+/**
+ * Default PPQ.ai provider configuration
+ */
+export const DEFAULT_PPQ_PROVIDER: Omit<
+ AIProvider,
+ "id" | "apiKey" | "createdAt"
+> = {
+ name: "PPQ.ai",
+ baseUrl: "https://api.ppq.ai",
+ models: [],
+ defaultModel: "claude-sonnet-4-20250514",
+};
+
+/**
+ * Generate a unique ID
+ */
+function generateId(): string {
+ return crypto.randomUUID();
+}
+
+/**
+ * AI Service singleton
+ */
+export const aiService = {
+ // ==================== Provider Management ====================
+
+ /**
+ * Get all configured AI providers
+ */
+ async getProviders(): Promise {
+ return db.aiProviders.orderBy("createdAt").toArray();
+ },
+
+ /**
+ * Get a specific provider by ID
+ */
+ async getProvider(id: string): Promise {
+ return db.aiProviders.get(id);
+ },
+
+ /**
+ * Save a new or update an existing provider
+ */
+ async saveProvider(
+ provider: Omit & { id?: string },
+ ): Promise {
+ const now = Date.now();
+ const savedProvider: AIProvider = {
+ ...provider,
+ id: provider.id || generateId(),
+ createdAt: now,
+ };
+ await db.aiProviders.put(savedProvider);
+ return savedProvider;
+ },
+
+ /**
+ * Delete a provider and all its conversations
+ */
+ async deleteProvider(id: string): Promise {
+ // Delete all conversations for this provider
+ const conversations = await db.aiConversations
+ .where("providerId")
+ .equals(id)
+ .toArray();
+
+ for (const conv of conversations) {
+ await db.aiMessages.where("conversationId").equals(conv.id).delete();
+ }
+ await db.aiConversations.where("providerId").equals(id).delete();
+ await db.aiProviders.delete(id);
+ },
+
+ /**
+ * Fetch available models from a provider
+ */
+ async fetchModels(provider: AIProvider): Promise {
+ const response = await fetch(`${provider.baseUrl}/v1/models`, {
+ headers: {
+ Authorization: `Bearer ${provider.apiKey}`,
+ },
+ });
+
+ if (!response.ok) {
+ throw new Error(`Failed to fetch models: ${response.statusText}`);
+ }
+
+ const data = await response.json();
+ // OpenAI format: { data: [{ id: "model-name" }, ...] }
+ return data.data?.map((m: { id: string }) => m.id) || [];
+ },
+
+ /**
+ * Test connection to a provider
+ */
+ async testConnection(provider: AIProvider): Promise {
+ try {
+ await this.fetchModels(provider);
+ return true;
+ } catch {
+ return false;
+ }
+ },
+
+ // ==================== Conversation Management ====================
+
+ /**
+ * Get all conversations for a provider, sorted by most recent
+ */
+ async getConversations(providerId?: string): Promise {
+ if (providerId) {
+ return db.aiConversations
+ .where("providerId")
+ .equals(providerId)
+ .reverse()
+ .sortBy("updatedAt");
+ }
+ return db.aiConversations.orderBy("updatedAt").reverse().toArray();
+ },
+
+ /**
+ * Get a specific conversation by ID
+ */
+ async getConversation(id: string): Promise {
+ return db.aiConversations.get(id);
+ },
+
+ /**
+ * Create a new conversation
+ */
+ async createConversation(
+ providerId: string,
+ model: string,
+ title?: string,
+ ): Promise {
+ const now = Date.now();
+ const conversation: AIConversation = {
+ id: generateId(),
+ providerId,
+ model,
+ title: title || "New Chat",
+ createdAt: now,
+ updatedAt: now,
+ };
+ await db.aiConversations.add(conversation);
+ return conversation;
+ },
+
+ /**
+ * Update conversation (e.g., title, model)
+ */
+ async updateConversation(
+ id: string,
+ updates: Partial>,
+ ): Promise {
+ await db.aiConversations.update(id, {
+ ...updates,
+ updatedAt: Date.now(),
+ });
+ },
+
+ /**
+ * Delete a conversation and all its messages
+ */
+ async deleteConversation(id: string): Promise {
+ await db.aiMessages.where("conversationId").equals(id).delete();
+ await db.aiConversations.delete(id);
+ },
+
+ // ==================== Message Management ====================
+
+ /**
+ * Get all messages for a conversation
+ */
+ async getMessages(conversationId: string): Promise {
+ return db.aiMessages
+ .where("conversationId")
+ .equals(conversationId)
+ .sortBy("timestamp");
+ },
+
+ /**
+ * Add a message to a conversation
+ */
+ async addMessage(
+ conversationId: string,
+ role: "user" | "assistant",
+ content: string,
+ isStreaming = false,
+ ): Promise {
+ const message: AIMessage = {
+ id: generateId(),
+ conversationId,
+ role,
+ content,
+ timestamp: Date.now(),
+ isStreaming,
+ };
+ await db.aiMessages.add(message);
+
+ // Update conversation's updatedAt
+ await db.aiConversations.update(conversationId, {
+ updatedAt: Date.now(),
+ });
+
+ return message;
+ },
+
+ /**
+ * Update a message (for streaming)
+ */
+ async updateMessage(
+ id: string,
+ updates: Partial>,
+ ): Promise {
+ await db.aiMessages.update(id, updates);
+ },
+
+ /**
+ * Delete a message
+ */
+ async deleteMessage(id: string): Promise {
+ await db.aiMessages.delete(id);
+ },
+
+ // ==================== Chat Completions ====================
+
+ /**
+ * Stream a chat completion from an AI provider
+ * Returns an async iterator that yields content chunks
+ */
+ async *streamChat(
+ provider: AIProvider,
+ messages: { role: string; content: string }[],
+ model: string,
+ ): AsyncGenerator {
+ const response = await fetch(`${provider.baseUrl}/chat/completions`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${provider.apiKey}`,
+ },
+ body: JSON.stringify({
+ model,
+ messages,
+ stream: true,
+ }),
+ });
+
+ if (!response.ok) {
+ const error = await response.text();
+ throw new Error(`Chat completion failed: ${error}`);
+ }
+
+ if (!response.body) {
+ throw new Error("No response body");
+ }
+
+ const reader = response.body.getReader();
+ const decoder = new TextDecoder();
+ let buffer = "";
+
+ try {
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+
+ buffer += decoder.decode(value, { stream: true });
+ const lines = buffer.split("\n");
+ buffer = lines.pop() || "";
+
+ for (const line of lines) {
+ const trimmed = line.trim();
+ if (!trimmed || !trimmed.startsWith("data: ")) continue;
+
+ const data = trimmed.slice(6);
+ if (data === "[DONE]") return;
+
+ try {
+ const parsed = JSON.parse(data);
+ const content = parsed.choices?.[0]?.delta?.content;
+ if (content) {
+ yield content;
+ }
+ } catch {
+ // Skip invalid JSON lines
+ }
+ }
+ }
+ } finally {
+ reader.releaseLock();
+ }
+ },
+
+ /**
+ * Send a message and stream the response
+ * Handles message persistence automatically
+ */
+ async sendMessage(
+ conversationId: string,
+ content: string,
+ onChunk?: (chunk: string, fullContent: string) => void,
+ ): Promise {
+ // Get conversation and provider
+ const conversation = await this.getConversation(conversationId);
+ if (!conversation) {
+ throw new Error("Conversation not found");
+ }
+
+ const provider = await this.getProvider(conversation.providerId);
+ if (!provider) {
+ throw new Error("Provider not found");
+ }
+
+ // Add user message
+ await this.addMessage(conversationId, "user", content);
+
+ // Get all messages for context
+ const messages = await this.getMessages(conversationId);
+ const chatMessages = messages.map((m) => ({
+ role: m.role,
+ content: m.content,
+ }));
+
+ // Create assistant message placeholder
+ const assistantMessage = await this.addMessage(
+ conversationId,
+ "assistant",
+ "",
+ true,
+ );
+
+ // Stream the response
+ let fullContent = "";
+ try {
+ for await (const chunk of this.streamChat(
+ provider,
+ chatMessages,
+ conversation.model,
+ )) {
+ fullContent += chunk;
+ await this.updateMessage(assistantMessage.id, {
+ content: fullContent,
+ });
+ onChunk?.(chunk, fullContent);
+ }
+
+ // Mark as complete
+ await this.updateMessage(assistantMessage.id, {
+ isStreaming: false,
+ });
+
+ // Auto-generate title from first message if still default
+ if (conversation.title === "New Chat" && messages.length <= 1) {
+ const title = content.slice(0, 50) + (content.length > 50 ? "..." : "");
+ await this.updateConversation(conversationId, { title });
+ }
+
+ return { ...assistantMessage, content: fullContent, isStreaming: false };
+ } catch (error) {
+ // On error, update message with error state
+ await this.updateMessage(assistantMessage.id, {
+ content: fullContent || "Error: Failed to get response",
+ isStreaming: false,
+ });
+ throw error;
+ }
+ },
+};
+
+export default aiService;
diff --git a/src/services/db.ts b/src/services/db.ts
index 6e9ee79..d6216d6 100644
--- a/src/services/db.ts
+++ b/src/services/db.ts
@@ -61,6 +61,35 @@ export interface CachedBlossomServerList {
updatedAt: number;
}
+// AI Provider and Conversation types
+export interface AIProvider {
+ id: string;
+ name: string;
+ baseUrl: string;
+ apiKey: string;
+ models: string[];
+ defaultModel?: string;
+ createdAt: number;
+}
+
+export interface AIConversation {
+ id: string;
+ providerId: string;
+ model: string;
+ title: string;
+ createdAt: number;
+ updatedAt: number;
+}
+
+export interface AIMessage {
+ id: string;
+ conversationId: string;
+ role: "user" | "assistant";
+ content: string;
+ timestamp: number;
+ isStreaming?: boolean;
+}
+
export interface LocalSpell {
id: string; // UUID for local-only spells, or event ID for published spells
alias?: string; // Optional local-only quick name (e.g., "btc")
@@ -98,6 +127,9 @@ class GrimoireDb extends Dexie {
blossomServers!: Table;
spells!: Table;
spellbooks!: Table;
+ aiProviders!: Table;
+ aiConversations!: Table;
+ aiMessages!: Table;
constructor(name: string) {
super(name);
@@ -333,6 +365,23 @@ class GrimoireDb extends Dexie {
spells: "&id, alias, createdAt, isPublished, deletedAt",
spellbooks: "&id, slug, title, createdAt, isPublished, deletedAt",
});
+
+ // Version 16: Add AI provider and conversation storage
+ this.version(16).stores({
+ profiles: "&pubkey",
+ nip05: "&nip05",
+ nips: "&id",
+ relayInfo: "&url",
+ relayAuthPreferences: "&url",
+ relayLists: "&pubkey, updatedAt",
+ relayLiveness: "&url",
+ blossomServers: "&pubkey, updatedAt",
+ spells: "&id, alias, createdAt, isPublished, deletedAt",
+ spellbooks: "&id, slug, title, createdAt, isPublished, deletedAt",
+ aiProviders: "&id, createdAt",
+ aiConversations: "&id, providerId, updatedAt",
+ aiMessages: "&id, conversationId, timestamp",
+ });
}
}
diff --git a/src/types/app.ts b/src/types/app.ts
index 09a1148..4a6b8d8 100644
--- a/src/types/app.ts
+++ b/src/types/app.ts
@@ -21,6 +21,7 @@ export type AppId =
| "spells"
| "spellbooks"
| "blossom"
+ | "ai"
| "win";
export interface WindowInstance {
diff --git a/src/types/man.ts b/src/types/man.ts
index c566eed..a390dfc 100644
--- a/src/types/man.ts
+++ b/src/types/man.ts
@@ -8,6 +8,7 @@ import { parseRelayCommand } from "@/lib/relay-parser";
import { resolveNip05Batch } from "@/lib/nip05";
import { parseChatCommand } from "@/lib/chat-parser";
import { parseBlossomCommand } from "@/lib/blossom-parser";
+import { parseAICommand } from "@/lib/ai-parser";
export interface ManPageEntry {
name: string;
@@ -700,4 +701,37 @@ export const manPages: Record = {
},
defaultProps: { subcommand: "servers" },
},
+ ai: {
+ name: "ai",
+ section: "1",
+ synopsis: "ai [subcommand]",
+ description:
+ "Chat with AI models using OpenAI-compatible providers like PPQ.ai. Manage conversations, configure providers, and interact with language models.",
+ options: [
+ {
+ flag: "new",
+ description: "Start a new conversation",
+ },
+ {
+ flag: "settings",
+ description: "Configure AI providers (API keys, models, etc.)",
+ },
+ {
+ flag: "",
+ description: "Open a specific conversation by its UUID",
+ },
+ ],
+ examples: [
+ "ai List conversations",
+ "ai new Start a new chat",
+ "ai settings Configure AI providers",
+ ],
+ seeAlso: ["chat"],
+ appId: "ai",
+ category: "System",
+ argParser: (args: string[]) => {
+ return parseAICommand(args);
+ },
+ defaultProps: { view: "list" },
+ },
};