diff --git a/chartsmith-app/ARCHITECTURE.md b/chartsmith-app/ARCHITECTURE.md index 93e7d7b4..e9be5ead 100644 --- a/chartsmith-app/ARCHITECTURE.md +++ b/chartsmith-app/ARCHITECTURE.md @@ -21,3 +21,81 @@ This is a next.js project that is the front end for chartsmith. - We aren't using Next.JS API routes, except when absolutely necessary. - Front end should call server actions, which call lib/* functions. - Database queries are not allowed in the server action. Server actions are just wrappers for which lib functions we expose. + +## AI SDK Integration + +We use the [Vercel AI SDK](https://ai-sdk.dev/docs) for LLM integration with a hybrid architecture: + +### Architecture Overview + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Frontend (Next.js) │ +├──────────────────────────┬──────────────────────────────────┤ +│ AIChatContainer │ ChatContainer │ +│ (AI SDK useChat) │ (Centrifugo for plans/renders) │ +└──────────────────────────┴──────────────────────────────────┘ + │ │ + ▼ ▼ +┌──────────────────────────┐ ┌──────────────────────────────┐ +│ /api/chat │ │ Go Worker │ +│ (AI SDK streamText) │ │ (Anthropic/Groq SDKs) │ +└──────────────────────────┘ └──────────────────────────────┘ +``` + +### Streaming Chat (AI SDK) - Conversational Q&A +- **API Route**: `/api/chat` uses AI SDK Core's `streamText` for real-time streaming +- **Frontend**: `AIChatContainer` component uses `useChat` hook from `@ai-sdk/react` +- **Provider**: Configurable via `LLM_PROVIDER` env var (anthropic, openai) +- **Tools**: `getLatestSubchartVersion`, `getLatestKubernetesVersion` +- **Auto-response**: Automatically responds to unanswered user messages (e.g., from workspace creation) + +### Complex Workflows (Go + Centrifugo) - Plans, Renders, Conversions +- **Plans, Renders, Conversions**: Handled by Go backend worker +- **Realtime updates**: Streamed via Centrifugo pub/sub +- **Intent detection**: Uses Groq in Go backend for speed +- **File modifications**: Go worker handles all chart file changes + +### Key Files +| File | Description | +|------|-------------| +| `lib/llm/provider.ts` | LLM provider configuration and model selection | +| `lib/llm/system-prompts.ts` | System prompts (matches Go `pkg/llm/system.go`) | +| `lib/llm/message-adapter.ts` | Converts between DB messages and AI SDK format | +| `app/api/chat/route.ts` | Streaming API endpoint with tool support | +| `components/AIChatContainer.tsx` | AI SDK chat UI with streaming | +| `hooks/useChartsmithChat.ts` | Custom hook wrapping `useChat` | +| `lib/workspace/actions/save-ai-chat-message.ts` | Persists AI chat messages to DB | + +### Feature Flag +Set `USE_AI_SDK_CHAT=true` in `.env.local` to enable the new AI SDK chat in workspaces. +When disabled (default), workspaces use the existing Centrifugo-based `ChatContainer`. + +### Provider Switching +```bash +# Anthropic (default) +LLM_PROVIDER=anthropic +LLM_MODEL=claude-3-5-sonnet-20241022 + +# OpenAI +LLM_PROVIDER=openai +LLM_MODEL=gpt-4o +OPENAI_API_KEY=sk-... +``` + +### Message Flow +1. User sends message via `AIChatContainer` input +2. `useChat` hook calls `/api/chat` with message history +3. API route builds system prompt based on role (auto/developer/operator) +4. `streamText` streams response from LLM +5. On completion, message is saved to DB via `saveAIChatMessageAction` +6. Tool calls (e.g., chart version lookup) are executed inline + +### Testing +```bash +npm run test:unit # Jest unit tests (message-adapter, etc.) +npm run test:e2e # Playwright API tests +npm run build # TypeScript compilation check +``` + +See `docs/AI_SDK_MIGRATION.md` for full migration details. diff --git a/chartsmith-app/app/api/chat/route.ts b/chartsmith-app/app/api/chat/route.ts new file mode 100644 index 00000000..2e270c99 --- /dev/null +++ b/chartsmith-app/app/api/chat/route.ts @@ -0,0 +1,167 @@ +/** + * AI Chat Streaming API Route + * + * This route uses the Vercel AI SDK to stream chat responses from the LLM. + * It handles conversational messages for the Chartsmith chat interface. + * + * For plan execution, renders, and other complex workflows, + * the Go backend continues to handle those via Centrifugo. + */ + +import { streamText, convertToCoreMessages, CoreMessage } from 'ai'; +import type { Message as UIMessage } from '@ai-sdk/react'; +import { z } from 'zod'; +import { NextRequest } from 'next/server'; +import { cookies } from 'next/headers'; + +import { getModel } from '@/lib/llm/provider'; +import { buildSystemPrompt, ChatRole, ChartContext } from '@/lib/llm/system-prompts'; +import { findSession } from '@/lib/auth/session'; +import { searchArtifactHubCharts } from '@/lib/artifacthub/artifacthub'; + +// Allow streaming responses up to 60 seconds +export const maxDuration = 60; + +/** + * Request body schema for the chat endpoint. + */ +interface ChatRequestBody { + messages: UIMessage[]; + workspaceId?: string; + role?: ChatRole; + chartContext?: ChartContext; +} + +/** + * Validates the user session from cookies. + * Returns the session if valid, null otherwise. + */ +async function getAuthenticatedSession() { + const cookieStore = await cookies(); + const sessionCookie = cookieStore.get('session'); + + if (!sessionCookie?.value) { + return null; + } + + try { + const session = await findSession(sessionCookie.value); + return session; + } catch { + return null; + } +} + +/** + * Searches for the latest version of a subchart from ArtifactHub. + */ +async function getLatestSubchartVersion(chartName: string): Promise { + try { + const results = await searchArtifactHubCharts(chartName); + if (results.length === 0) { + return 'Not found on ArtifactHub'; + } + + // Extract version from URL or return the first result + // The searchArtifactHubCharts returns URLs like https://artifacthub.io/packages/helm/org/name + // We'd need to fetch the actual version from the package details + // For now, return that we found it (the full version lookup would require additional API call) + return `Found: ${results[0]}`; + } catch (error) { + console.error('Error searching ArtifactHub:', error); + return 'Error searching ArtifactHub'; + } +} + +/** + * POST handler for chat streaming. + */ +export async function POST(req: NextRequest) { + try { + // Authenticate the user + const session = await getAuthenticatedSession(); + + if (!session) { + return new Response(JSON.stringify({ error: 'Unauthorized' }), { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }); + } + + // Parse request body + const body: ChatRequestBody = await req.json(); + const { messages, role = 'auto', chartContext } = body; + + if (!messages || !Array.isArray(messages)) { + return new Response(JSON.stringify({ error: 'Messages array is required' }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }); + } + + // Build the system prompt based on role and context + const systemPrompt = buildSystemPrompt(role, chartContext); + + // Convert UI messages to core format for the model + const coreMessages: CoreMessage[] = convertToCoreMessages(messages); + + // Stream the response using AI SDK + const result = streamText({ + model: getModel(), + system: systemPrompt, + messages: coreMessages, + maxTokens: 8192, + tools: { + /** + * Tool to get the latest version of a subchart from ArtifactHub. + * The LLM can use this when users ask about specific chart versions. + */ + getLatestSubchartVersion: { + description: 'Get the latest version of a Helm subchart from ArtifactHub. Use this when the user asks about chart versions or dependencies.', + parameters: z.object({ + chartName: z.string().describe('The name of the subchart to look up (e.g., "redis", "postgresql", "ingress-nginx")'), + }), + execute: async ({ chartName }) => { + return await getLatestSubchartVersion(chartName); + }, + }, + + /** + * Tool to get the latest Kubernetes version information. + */ + getLatestKubernetesVersion: { + description: 'Get the latest version of Kubernetes. Use this when the user asks about Kubernetes version compatibility.', + parameters: z.object({ + semverField: z.enum(['major', 'minor', 'patch']).describe('Which part of the version to return'), + }), + execute: async ({ semverField }) => { + // Current latest Kubernetes versions (as of early 2025) + const versions: Record = { + major: '1', + minor: '1.32', + patch: '1.32.1', + }; + return versions[semverField]; + }, + }, + }, + }); + + // Return the streaming response + return result.toDataStreamResponse(); + } catch (error) { + console.error('Chat API error:', error); + + return new Response( + JSON.stringify({ + error: 'Internal server error', + message: error instanceof Error ? error.message : 'Unknown error', + }), + { + status: 500, + headers: { 'Content-Type': 'application/json' }, + } + ); + } +} + diff --git a/chartsmith-app/app/workspace/[id]/page.tsx b/chartsmith-app/app/workspace/[id]/page.tsx index 1d3e8d02..874b6983 100644 --- a/chartsmith-app/app/workspace/[id]/page.tsx +++ b/chartsmith-app/app/workspace/[id]/page.tsx @@ -39,6 +39,9 @@ export default async function WorkspacePage({ listWorkspaceConversionsAction(session, workspace.id) ]) + // Check if AI SDK chat is enabled via environment variable + const useAISDKChat = process.env.USE_AI_SDK_CHAT === 'true'; + // Pass the initial data as props return ( ); } diff --git a/chartsmith-app/components/AIChatContainer.tsx b/chartsmith-app/components/AIChatContainer.tsx new file mode 100644 index 00000000..f998b41f --- /dev/null +++ b/chartsmith-app/components/AIChatContainer.tsx @@ -0,0 +1,547 @@ +/** + * AI-powered Chat Container using Vercel AI SDK. + * + * This component provides a modern chat experience with real-time streaming + * using the Vercel AI SDK. It integrates with the existing Chartsmith + * architecture while providing improved streaming performance. + * + * Key features: + * - Real-time streaming via AI SDK (not Centrifugo) + * - Role-based prompting (auto/developer/operator) + * - Integration with existing workspace context + * - Fallback to existing ChatMessage components for complex workflows + * + * Use this component for conversational chat. Plans, renders, and other + * complex workflows continue to use the existing ChatContainer + Centrifugo. + */ + +'use client'; + +import React, { useRef, useEffect, useState } from 'react'; +import { Send, Loader2, Users, Code, User, Sparkles, AlertCircle } from 'lucide-react'; +import { useChat, Message as AIMessage } from '@ai-sdk/react'; +import { useAtom } from 'jotai'; +import Image from 'next/image'; +import ReactMarkdown from 'react-markdown'; + +import { useTheme } from '../contexts/ThemeContext'; +import { Session } from '@/lib/types/session'; +import { workspaceAtom, messagesAtom } from '@/atoms/workspace'; +import { ChatRole, ChartContext } from '@/lib/llm/system-prompts'; +import { ScrollingContent } from './ScrollingContent'; +import { toAIMessages } from '@/lib/llm/message-adapter'; +import { saveAIChatMessageAction } from '@/lib/workspace/actions/save-ai-chat-message'; + +interface AIChatContainerProps { + session: Session; + /** + * Initial messages to display (e.g., from database history). + */ + initialMessages?: AIMessage[]; + /** + * Callback when a message is successfully sent and response received. + * Use this to persist messages to the database. + */ + onMessageComplete?: (userMessage: AIMessage, assistantMessage: AIMessage) => void; +} + +/** + * Streaming chat message component. + * Displays messages from the AI SDK with real-time streaming support. + */ +function StreamingMessage({ + message, + session, + theme, + isStreaming, +}: { + message: AIMessage; + session: Session; + theme: string; + isStreaming?: boolean; +}) { + const isUser = message.role === 'user'; + + return ( +
+
+
+
+ {isUser ? ( + {session.user.name} + ) : ( +
+ ChartSmith +
+ )} +
+
+ {isUser ? ( + message.content + ) : ( + <> + {message.content} + {isStreaming && ( + + )} + + )} +
+ + {/* Tool invocations */} + {message.toolInvocations && message.toolInvocations.length > 0 && ( +
+ {message.toolInvocations.map((tool, index) => ( +
+ + {tool.toolName} + {tool.state === 'result' && `: ${JSON.stringify(tool.result)}`} + {tool.state === 'call' && '...'} + +
+ ))} +
+ )} +
+
+
+
+
+ ); +} + +export function AIChatContainer({ + session, + initialMessages = [], + onMessageComplete, +}: AIChatContainerProps) { + const { theme } = useTheme(); + const [workspace] = useAtom(workspaceAtom); + const [existingMessages] = useAtom(messagesAtom); + const [selectedRole, setSelectedRole] = useState('auto'); + const [isRoleMenuOpen, setIsRoleMenuOpen] = useState(false); + const [lastUserInput, setLastUserInput] = useState(''); + const roleMenuRef = useRef(null); + const inputRef = useRef(null); + + // Convert existing database messages to AI SDK format for initial display + const convertedInitialMessages = React.useMemo(() => { + if (initialMessages.length > 0) { + return initialMessages; + } + // Convert existing messages from Jotai atom + return toAIMessages(existingMessages); + }, [initialMessages, existingMessages]); + + // Build chart context from workspace + const chartContext: ChartContext | undefined = React.useMemo(() => { + if (!workspace?.charts?.length) { + return undefined; + } + + const chart = workspace.charts[0]; + + return { + structure: chart.files?.map((f) => `File: ${f.filePath}`).join('\n') || '', + relevantFiles: chart.files?.slice(0, 10).map((f) => ({ + filePath: f.filePath, + content: f.content || '', + })), + }; + }, [workspace]); + + // Configure useChat hook + const { + messages, + input, + setInput, + handleInputChange, + handleSubmit, + isLoading, + error, + stop, + reload, + } = useChat({ + api: '/api/chat', + initialMessages: convertedInitialMessages, + body: { + workspaceId: workspace?.id, + role: selectedRole, + chartContext, + }, + onFinish: async (message: AIMessage) => { + // Save the message to the database + if (workspace?.id && lastUserInput) { + try { + await saveAIChatMessageAction( + session, + workspace.id, + lastUserInput, + message.content + ); + } catch (err) { + console.error('Failed to save AI chat message:', err); + } + } + + // Call the optional callback + if (onMessageComplete) { + const userMessage: AIMessage = { + id: `user-${message.id}`, + role: 'user', + content: lastUserInput, + createdAt: new Date(), + }; + onMessageComplete(userMessage, message); + } + }, + onError: (error: Error) => { + console.error('AI Chat error:', error); + }, + }); + + // Auto-respond to unanswered user messages (e.g., from initial workspace creation) + const hasTriggeredInitialResponse = useRef(false); + useEffect(() => { + // Only trigger once per component mount + if (hasTriggeredInitialResponse.current) return; + + // Check if there are messages and the last one is from the user (unanswered) + if (messages.length > 0 && !isLoading) { + const lastMessage = messages[messages.length - 1]; + if (lastMessage.role === 'user') { + hasTriggeredInitialResponse.current = true; + // Store the user's prompt and trigger reload to get a response + setLastUserInput(lastMessage.content); + reload(); + } + } + }, [messages, isLoading, reload]); + + // Close role menu when clicking outside + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + roleMenuRef.current && + !roleMenuRef.current.contains(event.target as Node) + ) { + setIsRoleMenuOpen(false); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, []); + + // Auto-resize textarea + useEffect(() => { + if (inputRef.current) { + inputRef.current.style.height = 'auto'; + inputRef.current.style.height = inputRef.current.scrollHeight + 'px'; + } + }, [input]); + + const getRoleLabel = (role: ChatRole): string => { + switch (role) { + case 'auto': + return 'Auto-detect'; + case 'developer': + return 'Chart Developer'; + case 'operator': + return 'End User'; + default: + return 'Auto-detect'; + } + }; + + const getRoleIcon = (role: ChatRole) => { + switch (role) { + case 'auto': + return ; + case 'developer': + return ; + case 'operator': + return ; + default: + return ; + } + }; + + const onFormSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (!input.trim() || isLoading) return; + // Track the user input before submitting (for database persistence) + setLastUserInput(input.trim()); + handleSubmit(e); + }; + + if (!workspace) { + return null; + } + + return ( +
+
+ +
+ {messages.map((message, index) => ( + + ))} + + {/* Loading indicator when waiting for first token */} + {isLoading && messages[messages.length - 1]?.role === 'user' && ( +
+
+
+
+
+ ChartSmith is thinking... +
+ +
+
+
+ )} + + {/* Error display */} + {error && ( +
+
+
+ +
+
+ Something went wrong +
+
+ {error.message} +
+ +
+
+
+
+ )} +
+ +
+ + {/* Input area */} +
+
+