From 49ae5f0fd73f7f87b96985da348258d2ebe25f3f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Jan 2026 22:15:29 +0000 Subject: [PATCH] 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 Supports PPQ.ai's endpoints: - POST /chat/completions for streaming chat - GET /v1/models for available models --- src/components/WindowRenderer.tsx | 11 + src/components/ai/AIChat.tsx | 337 ++++++++++++++++++++ src/components/ai/AIConversationList.tsx | 160 ++++++++++ src/components/ai/AIMessageContent.tsx | 117 +++++++ src/components/ai/AIModelSelector.tsx | 62 ++++ src/components/ai/AIProviderSelector.tsx | 54 ++++ src/components/ai/AISettings.tsx | 301 ++++++++++++++++++ src/components/ai/AIViewer.tsx | 357 +++++++++++++++++++++ src/components/ai/index.ts | 7 + src/lib/ai-parser.ts | 46 +++ src/services/ai-service.ts | 379 +++++++++++++++++++++++ src/services/db.ts | 49 +++ src/types/app.ts | 1 + src/types/man.ts | 34 ++ 14 files changed, 1915 insertions(+) create mode 100644 src/components/ai/AIChat.tsx create mode 100644 src/components/ai/AIConversationList.tsx create mode 100644 src/components/ai/AIMessageContent.tsx create mode 100644 src/components/ai/AIModelSelector.tsx create mode 100644 src/components/ai/AIProviderSelector.tsx create mode 100644 src/components/ai/AISettings.tsx create mode 100644 src/components/ai/AIViewer.tsx create mode 100644 src/components/ai/index.ts create mode 100644 src/lib/ai-parser.ts create mode 100644 src/services/ai-service.ts diff --git a/src/components/WindowRenderer.tsx b/src/components/WindowRenderer.tsx index e43055e..4af99e3 100644 --- a/src/components/WindowRenderer.tsx +++ b/src/components/WindowRenderer.tsx @@ -43,6 +43,9 @@ const BlossomViewer = lazy(() => import("./BlossomViewer").then((m) => ({ default: m.BlossomViewer })), ); const CountViewer = lazy(() => import("./CountViewer")); +const AIViewer = lazy(() => + import("./ai/AIViewer").then((m) => ({ default: m.AIViewer })), +); // Loading fallback component function ViewerLoading() { @@ -220,6 +223,14 @@ export function WindowRenderer({ window, onClose }: WindowRendererProps) { /> ); break; + case "ai": + content = ( + + ); + break; default: content = (
diff --git a/src/components/ai/AIChat.tsx b/src/components/ai/AIChat.tsx new file mode 100644 index 0000000..389d843 --- /dev/null +++ b/src/components/ai/AIChat.tsx @@ -0,0 +1,337 @@ +/** + * AIChat - Conversation view with message timeline and input + */ + +import { useState, useEffect, useRef, useCallback, memo } from "react"; +import { Virtuoso, VirtuosoHandle } from "react-virtuoso"; +import { Loader2, User, Bot, AlertCircle } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { aiService } from "@/services/ai-service"; +import type { AIConversation, AIMessage } from "@/services/db"; +import { AIMessageContent } from "./AIMessageContent"; + +interface AIChatProps { + conversation: AIConversation; + onConversationUpdate?: (conversation: AIConversation) => void; +} + +/** + * Format timestamp for display + */ +function formatTime(timestamp: number): string { + return new Date(timestamp).toLocaleTimeString(undefined, { + hour: "numeric", + minute: "2-digit", + }); +} + +/** + * Format date for day markers + */ +function formatDayMarker(timestamp: number): string { + const date = new Date(timestamp); + const today = new Date(); + const yesterday = new Date(today); + yesterday.setDate(yesterday.getDate() - 1); + + const dateOnly = new Date( + date.getFullYear(), + date.getMonth(), + date.getDate(), + ); + const todayOnly = new Date( + today.getFullYear(), + today.getMonth(), + today.getDate(), + ); + const yesterdayOnly = new Date( + yesterday.getFullYear(), + yesterday.getMonth(), + yesterday.getDate(), + ); + + if (dateOnly.getTime() === todayOnly.getTime()) return "Today"; + if (dateOnly.getTime() === yesterdayOnly.getTime()) return "Yesterday"; + + return date.toLocaleDateString(undefined, { month: "short", day: "numeric" }); +} + +/** + * Check if two timestamps are on different days + */ +function isDifferentDay(t1: number, t2: number): boolean { + const d1 = new Date(t1); + const d2 = new Date(t2); + return ( + d1.getFullYear() !== d2.getFullYear() || + d1.getMonth() !== d2.getMonth() || + d1.getDate() !== d2.getDate() + ); +} + +/** + * Single message component + */ +const MessageItem = memo(function MessageItem({ + message, +}: { + message: AIMessage; +}) { + const isUser = message.role === "user"; + + return ( +
+
+ {isUser ? : } +
+
+
+ + {isUser ? "You" : "Assistant"} + + + {formatTime(message.timestamp)} + + {message.isStreaming && ( + + )} +
+ +
+
+ ); +}); + +export function AIChat({ conversation, onConversationUpdate }: AIChatProps) { + const [messages, setMessages] = useState([]); + const [input, setInput] = useState(""); + const [isSending, setIsSending] = useState(false); + const [error, setError] = useState(null); + const virtuosoRef = useRef(null); + const inputRef = useRef(null); + + // Load messages on conversation change + useEffect(() => { + loadMessages(); + }, [conversation.id]); + + const loadMessages = useCallback(async () => { + const loaded = await aiService.getMessages(conversation.id); + setMessages(loaded); + }, [conversation.id]); + + // Poll for updates when streaming + useEffect(() => { + const hasStreaming = messages.some((m) => m.isStreaming); + if (!hasStreaming) return; + + const interval = setInterval(async () => { + const loaded = await aiService.getMessages(conversation.id); + setMessages(loaded); + + // Check if still streaming + const stillStreaming = loaded.some((m) => m.isStreaming); + if (!stillStreaming) { + clearInterval(interval); + } + }, 100); + + return () => clearInterval(interval); + }, [messages, conversation.id]); + + // Process messages for day markers + const messagesWithMarkers = messages.reduce< + Array< + | { type: "message"; data: AIMessage } + | { type: "day-marker"; data: string; timestamp: number } + > + >((acc, message, index) => { + if (index === 0) { + acc.push({ + type: "day-marker", + data: formatDayMarker(message.timestamp), + timestamp: message.timestamp, + }); + } else { + const prev = messages[index - 1]; + if (isDifferentDay(prev.timestamp, message.timestamp)) { + acc.push({ + type: "day-marker", + data: formatDayMarker(message.timestamp), + timestamp: message.timestamp, + }); + } + } + acc.push({ type: "message", data: message }); + return acc; + }, []); + + const handleSend = useCallback(async () => { + if (!input.trim() || isSending) return; + + const content = input.trim(); + setInput(""); + setError(null); + setIsSending(true); + + // Optimistically add user message + const userMessage: AIMessage = { + id: crypto.randomUUID(), + conversationId: conversation.id, + role: "user", + content, + timestamp: Date.now(), + }; + setMessages((prev) => [...prev, userMessage]); + + try { + await aiService.sendMessage( + conversation.id, + content, + (_chunk, fullContent) => { + // Update messages as chunks arrive + setMessages((prev) => { + const last = prev[prev.length - 1]; + if (last?.role === "assistant" && last.isStreaming) { + return [...prev.slice(0, -1), { ...last, content: fullContent }]; + } + // Create new assistant message + return [ + ...prev, + { + id: crypto.randomUUID(), + conversationId: conversation.id, + role: "assistant", + content: fullContent, + timestamp: Date.now(), + isStreaming: true, + }, + ]; + }); + }, + ); + + // Reload messages to get final state + await loadMessages(); + + // Notify parent of update (for title changes) + const updatedConv = await aiService.getConversation(conversation.id); + if (updatedConv && onConversationUpdate) { + onConversationUpdate(updatedConv); + } + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to send message"); + // Reload messages to get actual state + await loadMessages(); + } finally { + setIsSending(false); + } + }, [input, isSending, conversation.id, loadMessages, onConversationUpdate]); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleSend(); + } + }, + [handleSend], + ); + + // Auto-resize textarea + const handleInputChange = useCallback( + (e: React.ChangeEvent) => { + setInput(e.target.value); + // Auto-resize + const textarea = e.target; + textarea.style.height = "auto"; + textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`; + }, + [], + ); + + return ( +
+ {/* Message Timeline */} +
+ {messagesWithMarkers.length > 0 ? ( + { + if (item.type === "day-marker") { + return ( +
+ + {item.data} + +
+ ); + } + return ; + }} + style={{ height: "100%" }} + /> + ) : ( +
+

Start a conversation...

+
+ )} +
+ + {/* Error Message */} + {error && ( +
+ + {error} + +
+ )} + + {/* Input */} +
+
+