feat: add AI chat command with PPQ.ai support

Implements an `ai` command that provides:
- AI provider configuration (API key, base URL, models)
- Conversation management with persistent storage
- Real-time streaming responses
- Full-height collapsible sidebar for conversation list
- Model selection per conversation

Architecture:
- Dexie tables: aiProviders, aiConversations, aiMessages
- AI service with OpenAI-compatible API support
- Components: AIViewer, AIChat, AISettings, AIConversationList
- Command parser: ai, ai new, ai settings, ai <conversation-id>

Supports PPQ.ai's endpoints:
- POST /chat/completions for streaming chat
- GET /v1/models for available models
This commit is contained in:
Claude
2026-01-16 22:15:29 +00:00
parent 97f18de358
commit 49ae5f0fd7
14 changed files with 1915 additions and 0 deletions

View File

@@ -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 = (
<AIViewer
view={window.props.view}
conversationId={window.props.conversationId}
/>
);
break;
default:
content = (
<div className="p-4 text-muted-foreground">

View File

@@ -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 (
<div className={`flex gap-3 px-4 py-3 ${isUser ? "bg-muted/30" : ""}`}>
<div
className={`flex-shrink-0 size-7 rounded-full flex items-center justify-center ${
isUser ? "bg-primary text-primary-foreground" : "bg-muted"
}`}
>
{isUser ? <User className="size-4" /> : <Bot className="size-4" />}
</div>
<div className="flex-1 min-w-0 space-y-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">
{isUser ? "You" : "Assistant"}
</span>
<span className="text-xs text-muted-foreground">
{formatTime(message.timestamp)}
</span>
{message.isStreaming && (
<Loader2 className="size-3 animate-spin text-muted-foreground" />
)}
</div>
<AIMessageContent
content={message.content}
isStreaming={message.isStreaming}
/>
</div>
</div>
);
});
export function AIChat({ conversation, onConversationUpdate }: AIChatProps) {
const [messages, setMessages] = useState<AIMessage[]>([]);
const [input, setInput] = useState("");
const [isSending, setIsSending] = useState(false);
const [error, setError] = useState<string | null>(null);
const virtuosoRef = useRef<VirtuosoHandle>(null);
const inputRef = useRef<HTMLTextAreaElement>(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<HTMLTextAreaElement>) => {
setInput(e.target.value);
// Auto-resize
const textarea = e.target;
textarea.style.height = "auto";
textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`;
},
[],
);
return (
<div className="flex h-full flex-col">
{/* Message Timeline */}
<div className="flex-1 overflow-hidden">
{messagesWithMarkers.length > 0 ? (
<Virtuoso
ref={virtuosoRef}
data={messagesWithMarkers}
initialTopMostItemIndex={messagesWithMarkers.length - 1}
followOutput="smooth"
alignToBottom
itemContent={(_index, item) => {
if (item.type === "day-marker") {
return (
<div className="flex justify-center py-2">
<span className="text-[10px] text-muted-foreground bg-muted px-2 py-0.5 rounded">
{item.data}
</span>
</div>
);
}
return <MessageItem message={item.data} />;
}}
style={{ height: "100%" }}
/>
) : (
<div className="flex h-full items-center justify-center text-muted-foreground">
<p className="text-sm">Start a conversation...</p>
</div>
)}
</div>
{/* Error Message */}
{error && (
<div className="mx-4 mb-2 flex items-center gap-2 rounded bg-destructive/10 px-3 py-2 text-sm text-destructive">
<AlertCircle className="size-4" />
<span className="flex-1">{error}</span>
<Button
variant="ghost"
size="sm"
className="h-6 px-2"
onClick={() => setError(null)}
>
Dismiss
</Button>
</div>
)}
{/* Input */}
<div className="border-t p-3">
<div className="flex gap-2">
<textarea
ref={inputRef}
value={input}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
placeholder="Type a message..."
disabled={isSending}
rows={1}
className="flex-1 resize-none rounded border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary disabled:opacity-50"
style={{ minHeight: "40px", maxHeight: "200px" }}
/>
<Button
onClick={handleSend}
disabled={!input.trim() || isSending}
size="sm"
className="self-end"
>
{isSending ? <Loader2 className="size-4 animate-spin" /> : "Send"}
</Button>
</div>
<p className="mt-1 text-xs text-muted-foreground">
Press Enter to send, Shift+Enter for new line
</p>
</div>
</div>
);
}

View File

@@ -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<string, AIConversation[]> {
const groups = new Map<string, AIConversation[]>();
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 (
<SidebarMenuItem>
<SidebarMenuButton
isActive={isActive}
onClick={onSelect}
className="group/item pr-8"
>
<MessageSquare className="size-4 shrink-0" />
<span className="truncate">{conversation.title}</span>
</SidebarMenuButton>
<Button
variant="ghost"
size="icon"
className="absolute right-1 top-1/2 -translate-y-1/2 size-6 opacity-0 group-hover/item:opacity-100 hover:text-destructive"
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
>
<Trash2 className="size-3" />
</Button>
</SidebarMenuItem>
);
});
export function AIConversationList({
conversations,
activeConversation,
onSelect,
onDelete,
}: AIConversationListProps) {
const grouped = useMemo(() => groupByDate(conversations), [conversations]);
if (conversations.length === 0) {
return (
<div className="flex flex-col items-center justify-center h-32 text-muted-foreground text-sm">
<MessageSquare className="size-8 mb-2 opacity-50" />
<p>No conversations yet</p>
</div>
);
}
// 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 (
<SidebarGroup key={groupName}>
<SidebarGroupLabel>{groupName}</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{items.map((conv) => (
<ConversationItem
key={conv.id}
conversation={conv}
isActive={activeConversation?.id === conv.id}
onSelect={() => onSelect(conv)}
onDelete={() => onDelete(conv.id)}
/>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
);
})}
</>
);
}

View File

@@ -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 ? (
<span className="text-muted-foreground animate-pulse">Thinking...</span>
) : null;
}
return (
<div
className={cn(
"prose prose-sm dark:prose-invert max-w-none",
isStreaming && "animate-pulse-subtle",
)}
>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
// Custom code block styling
pre: ({ children }) => (
<pre className="overflow-x-auto rounded bg-muted p-3 text-sm">
{children}
</pre>
),
code: ({ className, children, ...props }) => {
const isInline = !className;
if (isInline) {
return (
<code
className="rounded bg-muted px-1.5 py-0.5 text-sm"
{...props}
>
{children}
</code>
);
}
return (
<code className={className} {...props}>
{children}
</code>
);
},
// Links
a: ({ href, children }) => (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
{children}
</a>
),
// Lists
ul: ({ children }) => (
<ul className="list-disc pl-4 space-y-1">{children}</ul>
),
ol: ({ children }) => (
<ol className="list-decimal pl-4 space-y-1">{children}</ol>
),
// Paragraphs
p: ({ children }) => <p className="mb-2 last:mb-0">{children}</p>,
// Headings
h1: ({ children }) => (
<h1 className="text-lg font-bold mb-2">{children}</h1>
),
h2: ({ children }) => (
<h2 className="text-base font-bold mb-2">{children}</h2>
),
h3: ({ children }) => (
<h3 className="text-sm font-bold mb-1">{children}</h3>
),
// Blockquotes
blockquote: ({ children }) => (
<blockquote className="border-l-2 border-muted-foreground/30 pl-3 italic text-muted-foreground">
{children}
</blockquote>
),
// Tables
table: ({ children }) => (
<div className="overflow-x-auto">
<table className="min-w-full border-collapse text-sm">
{children}
</table>
</div>
),
th: ({ children }) => (
<th className="border border-border px-2 py-1 text-left font-medium bg-muted">
{children}
</th>
),
td: ({ children }) => (
<td className="border border-border px-2 py-1">{children}</td>
),
}}
>
{content}
</ReactMarkdown>
</div>
);
});

View File

@@ -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 (
<span className="text-xs text-muted-foreground">No models available</span>
);
}
// Truncate long model names for display
const displayModel =
selectedModel.length > 30
? selectedModel.slice(0, 27) + "..."
: selectedModel;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-7 gap-1 text-xs font-mono"
>
{displayModel || "Select model"}
<ChevronDown className="size-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="max-h-64 overflow-auto">
{models.map((model) => (
<DropdownMenuItem
key={model}
onClick={() => onSelect(model)}
className={`font-mono text-xs ${selectedModel === model ? "bg-muted" : ""}`}
>
{model}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -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 (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton>
<Server className="size-4" />
<span className="truncate">
{activeProvider?.name || "Select Provider"}
</span>
<ChevronDown className="size-4 ml-auto" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-48">
{providers.map((provider) => (
<DropdownMenuItem
key={provider.id}
onClick={() => onSelect(provider)}
className={activeProvider?.id === provider.id ? "bg-muted" : ""}
>
{provider.name}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -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<string[]>(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<string | null>(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 (
<div className="flex flex-col h-full p-4 overflow-auto">
<div className="max-w-md mx-auto w-full space-y-6">
<div>
<h2 className="text-lg font-semibold">
{provider ? "Edit Provider" : "Add AI Provider"}
</h2>
<p className="text-sm text-muted-foreground">
Configure an OpenAI-compatible AI provider like PPQ.ai
</p>
</div>
{/* Error message */}
{error && (
<div className="rounded bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</div>
)}
{/* Name */}
<div className="space-y-2">
<label htmlFor="name" className="text-sm font-medium">
Provider Name
</label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="PPQ.ai"
/>
</div>
{/* Base URL */}
<div className="space-y-2">
<label htmlFor="baseUrl" className="text-sm font-medium">
API Base URL
</label>
<Input
id="baseUrl"
value={baseUrl}
onChange={(e) => setBaseUrl(e.target.value)}
placeholder="https://api.ppq.ai"
/>
<p className="text-xs text-muted-foreground">
Base URL for the OpenAI-compatible API
</p>
</div>
{/* API Key */}
<div className="space-y-2">
<label htmlFor="apiKey" className="text-sm font-medium">
API Key
</label>
<Input
id="apiKey"
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder="sk-..."
/>
</div>
{/* Test Connection */}
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={handleTestConnection}
disabled={isTesting || !apiKey}
>
{isTesting ? (
<Loader2 className="size-4 mr-2 animate-spin" />
) : testResult === "success" ? (
<Check className="size-4 mr-2 text-green-500" />
) : testResult === "error" ? (
<X className="size-4 mr-2 text-destructive" />
) : null}
Test Connection
</Button>
<Button
variant="outline"
size="sm"
onClick={handleFetchModels}
disabled={isFetchingModels || !apiKey}
>
{isFetchingModels ? (
<Loader2 className="size-4 mr-2 animate-spin" />
) : (
<RefreshCw className="size-4 mr-2" />
)}
Fetch Models
</Button>
</div>
{/* Models */}
{models.length > 0 && (
<div className="space-y-2">
<label htmlFor="defaultModel" className="text-sm font-medium">
Default Model
</label>
<select
id="defaultModel"
value={defaultModel}
onChange={(e) => setDefaultModel(e.target.value)}
className="w-full h-9 rounded border bg-background px-3 text-sm focus:outline-none focus:ring-1 focus:ring-primary"
>
{models.map((model) => (
<option key={model} value={model}>
{model}
</option>
))}
</select>
<p className="text-xs text-muted-foreground">
{models.length} models available
</p>
</div>
)}
{/* Actions */}
<div className="flex gap-2 pt-4">
<Button
onClick={handleSave}
disabled={isSaving || !name || !baseUrl || !apiKey}
>
{isSaving && <Loader2 className="size-4 mr-2 animate-spin" />}
Save Provider
</Button>
{onCancel && (
<Button variant="outline" onClick={onCancel}>
Cancel
</Button>
)}
{provider && (
<Button
variant="destructive"
onClick={handleDelete}
className="ml-auto"
>
<Trash2 className="size-4 mr-2" />
Delete
</Button>
)}
</div>
</div>
</div>
);
}

View File

@@ -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<AIProvider[]>([]);
const [activeProvider, setActiveProvider] = useState<AIProvider | null>(null);
const [conversations, setConversations] = useState<AIConversation[]>([]);
const [activeConversation, setActiveConversation] =
useState<AIConversation | null>(null);
const [selectedModel, setSelectedModel] = useState<string>("");
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 (
<div className="flex h-full flex-col">
<AISettings onSaved={handleProviderSaved} />
</div>
);
}
return (
<SidebarProvider defaultOpen={true}>
<div className="flex h-full w-full">
{/* Sidebar */}
<AIViewerSidebar
providers={providers}
activeProvider={activeProvider}
conversations={conversations}
activeConversation={activeConversation}
onProviderChange={handleProviderChange}
onSelectConversation={handleSelectConversation}
onDeleteConversation={handleDeleteConversation}
onNewChat={handleNewChat}
onOpenSettings={() => setView("settings")}
/>
{/* Main Content */}
<div className="flex-1 flex flex-col min-w-0 h-full">
{/* Header */}
<AIViewerHeader
activeProvider={activeProvider}
selectedModel={selectedModel}
onModelChange={handleModelChange}
onOpenSettings={() => setView("settings")}
/>
{/* Content */}
<div className="flex-1 min-h-0 overflow-hidden">
{view === "settings" ? (
<AISettings
provider={activeProvider || undefined}
onSaved={handleProviderSaved}
onCancel={() => setView("list")}
/>
) : view === "chat" && activeConversation ? (
<AIChat
conversation={activeConversation}
onConversationUpdate={handleConversationUpdate}
/>
) : (
<div className="flex h-full items-center justify-center text-muted-foreground">
<div className="text-center">
<MessageSquare className="size-12 mx-auto mb-4 opacity-50" />
<p className="text-sm">
Select a conversation or start a new chat
</p>
<Button
variant="outline"
size="sm"
className="mt-4"
onClick={handleNewChat}
disabled={!activeProvider}
>
<Plus className="size-4 mr-2" />
New Chat
</Button>
</div>
</div>
)}
</div>
</div>
</div>
</SidebarProvider>
);
}
// 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 (
<Sidebar collapsible="offcanvas" className="border-r">
<SidebarHeader className="border-b">
<div className="flex items-center justify-between">
<Button
variant="ghost"
size="sm"
className="flex-1 justify-start gap-2"
onClick={onNewChat}
disabled={!activeProvider}
>
<Plus className="size-4" />
{!isCollapsed && "New Chat"}
</Button>
</div>
</SidebarHeader>
<SidebarContent>
<AIConversationList
conversations={conversations}
activeConversation={activeConversation}
onSelect={onSelectConversation}
onDelete={onDeleteConversation}
/>
</SidebarContent>
<SidebarFooter className="border-t">
<SidebarMenu>
{providers.length > 1 && (
<SidebarMenuItem>
<AIProviderSelector
providers={providers}
activeProvider={activeProvider}
onSelect={onProviderChange}
/>
</SidebarMenuItem>
)}
<SidebarMenuItem>
<SidebarMenuButton onClick={onOpenSettings}>
<Settings className="size-4" />
<span>Settings</span>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton onClick={toggleSidebar}>
{isCollapsed ? (
<PanelLeft className="size-4" />
) : (
<PanelLeftClose className="size-4" />
)}
<span>{isCollapsed ? "Expand" : "Collapse"}</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarFooter>
</Sidebar>
);
}
// Header Component
function AIViewerHeader({
activeProvider,
selectedModel,
onModelChange,
onOpenSettings,
}: {
activeProvider: AIProvider | null;
selectedModel: string;
onModelChange: (model: string) => void;
onOpenSettings: () => void;
}) {
return (
<div className="flex items-center gap-2 border-b px-3 py-1.5">
{activeProvider && (
<>
<span className="text-sm font-medium text-muted-foreground">
{activeProvider.name}
</span>
<AIModelSelector
models={activeProvider.models}
selectedModel={selectedModel}
onSelect={onModelChange}
/>
</>
)}
<div className="flex-1" />
<Button
variant="ghost"
size="icon"
className="size-7"
onClick={onOpenSettings}
>
<Settings className="size-4" />
</Button>
</div>
);
}

View File

@@ -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";

46
src/lib/ai-parser.ts Normal file
View File

@@ -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 <uuid> -> { view: "chat", conversationId: "<uuid>" }
*/
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" };
}

379
src/services/ai-service.ts Normal file
View File

@@ -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<AIProvider[]> {
return db.aiProviders.orderBy("createdAt").toArray();
},
/**
* Get a specific provider by ID
*/
async getProvider(id: string): Promise<AIProvider | undefined> {
return db.aiProviders.get(id);
},
/**
* Save a new or update an existing provider
*/
async saveProvider(
provider: Omit<AIProvider, "id" | "createdAt"> & { id?: string },
): Promise<AIProvider> {
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<void> {
// 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<string[]> {
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<boolean> {
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<AIConversation[]> {
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<AIConversation | undefined> {
return db.aiConversations.get(id);
},
/**
* Create a new conversation
*/
async createConversation(
providerId: string,
model: string,
title?: string,
): Promise<AIConversation> {
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<Pick<AIConversation, "title" | "model">>,
): Promise<void> {
await db.aiConversations.update(id, {
...updates,
updatedAt: Date.now(),
});
},
/**
* Delete a conversation and all its messages
*/
async deleteConversation(id: string): Promise<void> {
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<AIMessage[]> {
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<AIMessage> {
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<Pick<AIMessage, "content" | "isStreaming">>,
): Promise<void> {
await db.aiMessages.update(id, updates);
},
/**
* Delete a message
*/
async deleteMessage(id: string): Promise<void> {
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<string, void, unknown> {
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<AIMessage> {
// 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;

View File

@@ -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<CachedBlossomServerList>;
spells!: Table<LocalSpell>;
spellbooks!: Table<LocalSpellbook>;
aiProviders!: Table<AIProvider>;
aiConversations!: Table<AIConversation>;
aiMessages!: Table<AIMessage>;
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",
});
}
}

View File

@@ -21,6 +21,7 @@ export type AppId =
| "spells"
| "spellbooks"
| "blossom"
| "ai"
| "win";
export interface WindowInstance {

View File

@@ -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<string, ManPageEntry> = {
},
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: "<conversation-id>",
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" },
},
};