mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-11 16:07:15 +02:00
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:
@@ -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">
|
||||
|
||||
337
src/components/ai/AIChat.tsx
Normal file
337
src/components/ai/AIChat.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
160
src/components/ai/AIConversationList.tsx
Normal file
160
src/components/ai/AIConversationList.tsx
Normal 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>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
117
src/components/ai/AIMessageContent.tsx
Normal file
117
src/components/ai/AIMessageContent.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
62
src/components/ai/AIModelSelector.tsx
Normal file
62
src/components/ai/AIModelSelector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
54
src/components/ai/AIProviderSelector.tsx
Normal file
54
src/components/ai/AIProviderSelector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
301
src/components/ai/AISettings.tsx
Normal file
301
src/components/ai/AISettings.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
357
src/components/ai/AIViewer.tsx
Normal file
357
src/components/ai/AIViewer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
7
src/components/ai/index.ts
Normal file
7
src/components/ai/index.ts
Normal 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
46
src/lib/ai-parser.ts
Normal 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
379
src/services/ai-service.ts
Normal 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;
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ export type AppId =
|
||||
| "spells"
|
||||
| "spellbooks"
|
||||
| "blossom"
|
||||
| "ai"
|
||||
| "win";
|
||||
|
||||
export interface WindowInstance {
|
||||
|
||||
@@ -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" },
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user