diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index d1d8d590..6ef6f064 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -22,6 +22,15 @@ It's made for both the developer working on it and for AI models to read and app
## Workers
-- The go code is where we put all workers.
+- The go code is where we put all workers.
- Jobs for workers are enqueued and scheduled using postgres notify and a work_queue table.
-- Status from the workers is communicated via Centrifugo messages to the client.
\ No newline at end of file
+- Status from the workers is communicated via Centrifugo messages to the client.
+
+## AI Architecture
+
+AI functionality is split between TypeScript and Go:
+
+- **TypeScript (Vercel AI SDK)**: Handles intent classification and conversational chat streaming via `/api/chat`
+- **Go (Anthropic SDK)**: Handles plan generation and plan execution (file edits via computer use)
+
+See `chartsmith-app/ARCHITECTURE.md` for detailed AI integration documentation.
\ No newline at end of file
diff --git a/chartsmith-app/ARCHITECTURE.md b/chartsmith-app/ARCHITECTURE.md
index 93e7d7b4..3e00e3e0 100644
--- a/chartsmith-app/ARCHITECTURE.md
+++ b/chartsmith-app/ARCHITECTURE.md
@@ -2,22 +2,56 @@
This is a next.js project that is the front end for chartsmith.
+## AI Integration
+
+This application uses Vercel AI SDK for LLM interactions:
+
+- **Provider**: `@ai-sdk/anthropic` - Anthropic Claude models
+- **UI Hook**: `useChat` from `@ai-sdk/react` - Manages chat state and streaming
+- **Core**: `streamText` from `ai` - Handles streaming in API routes
+
+### Chat Flow
+1. User sends message via `ChatContainer` component
+2. `useAIChat` hook (wrapping `useChat`) sends request to `/api/chat` endpoint
+3. API route uses `streamText` with context from workspace (chart structure, files, plan history)
+4. Response streams directly to client via HTTP
+5. Completed messages are persisted to database
+
+### Intent Classification
+- Uses AI SDK `generateText` to classify user messages as "plan" or "chat"
+- Plan intents are routed to Go backend for execution
+- Chat intents are handled directly via AI SDK streaming
+
+### Key Files
+- `lib/ai/provider.ts` - Anthropic provider and model configuration
+- `lib/ai/context.ts` - Builds workspace context for LLM calls
+- `app/api/chat/route.ts` - Streaming chat endpoint with tool support
+- `hooks/useAIChat.ts` - Chat hook wrapper with workspace-specific logic
+- `lib/llm/prompt-type.ts` - Intent classification
+
+### Non-Chat Real-time Events
+Centrifugo WebSocket is still used for:
+- Render progress updates
+- Artifact/file changes
+- Plan status updates
+- Revision creation notifications
+
## Monaco Editor Implementation
- Avoid recreating editor instances
- Use a single editor instance with model swapping for better performance
- Properly clean up models to prevent memory leaks
- We want to make sure that we don't show a "Loading..." state because it causes a lot of UI flashes.
-## State managemnet
+## State management
- Do not pass onChange and other callbacks through to child components
- We use jotai for state, each component should be able to get or set the state it needs
- Each component subscribes to the relevant atoms. This is preferred over callbacks.
## SSR
-- We use server side rendering to avoid the "loading" state whenever possible.
+- We use server side rendering to avoid the "loading" state whenever possible.
- Move code that requires "use client" into separate controls.
## Database and functions
-- We aren't using Next.JS API routes, except when absolutely necessary.
-- Front end should call server actions, which call lib/* functions.
+- We use Next.js API routes only for AI chat streaming (`/api/chat`), which requires HTTP streaming that server actions cannot provide.
+- For all other operations, 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.
diff --git a/chartsmith-app/app/api/chat/__tests__/route.test.ts b/chartsmith-app/app/api/chat/__tests__/route.test.ts
new file mode 100644
index 00000000..e8dc5f0c
--- /dev/null
+++ b/chartsmith-app/app/api/chat/__tests__/route.test.ts
@@ -0,0 +1,161 @@
+/**
+ * Tests for chat API route tool handlers
+ *
+ * These tests verify the tool execution logic used by the chat API endpoint.
+ * Tools are extracted and tested independently from the route handler itself.
+ */
+
+describe('Chat API Route Tools', () => {
+ describe('latest_kubernetes_version tool', () => {
+ // Replicate the tool logic for testing
+ const latestKubernetesVersion = async ({ semver_field }: { semver_field: 'major' | 'minor' | 'patch' }) => {
+ switch (semver_field) {
+ case 'major': return '1';
+ case 'minor': return '1.32';
+ case 'patch': return '1.32.1';
+ default: return '1.32.1';
+ }
+ };
+
+ it('should return major version "1" for major field', async () => {
+ const result = await latestKubernetesVersion({ semver_field: 'major' });
+ expect(result).toBe('1');
+ });
+
+ it('should return minor version "1.32" for minor field', async () => {
+ const result = await latestKubernetesVersion({ semver_field: 'minor' });
+ expect(result).toBe('1.32');
+ });
+
+ it('should return patch version "1.32.1" for patch field', async () => {
+ const result = await latestKubernetesVersion({ semver_field: 'patch' });
+ expect(result).toBe('1.32.1');
+ });
+
+ it('should return full version for unknown field (default case)', async () => {
+ // Type cast to bypass TypeScript enum check for edge case testing
+ const result = await latestKubernetesVersion({ semver_field: 'unknown' as any });
+ expect(result).toBe('1.32.1');
+ });
+ });
+
+ describe('latest_subchart_version tool', () => {
+ // Original fetch
+ const originalFetch = global.fetch;
+
+ beforeEach(() => {
+ // Reset fetch mock before each test
+ global.fetch = jest.fn();
+ });
+
+ afterEach(() => {
+ // Restore original fetch
+ global.fetch = originalFetch;
+ });
+
+ // Replicate the tool logic for testing
+ const latestSubchartVersion = async ({ chart_name }: { chart_name: string }) => {
+ try {
+ const response = await fetch(
+ `${process.env.INTERNAL_API_URL}/api/recommendations/subchart/${encodeURIComponent(chart_name)}`
+ );
+ if (!response.ok) return '?';
+ const data = await response.json();
+ return data.version || '?';
+ } catch {
+ return '?';
+ }
+ };
+
+ it('should return version from API response', async () => {
+ (global.fetch as jest.Mock).mockResolvedValue({
+ ok: true,
+ json: async () => ({ version: '4.12.0' }),
+ });
+
+ process.env.INTERNAL_API_URL = 'http://localhost:3000';
+ const result = await latestSubchartVersion({ chart_name: 'ingress-nginx' });
+
+ expect(result).toBe('4.12.0');
+ expect(global.fetch).toHaveBeenCalledWith(
+ 'http://localhost:3000/api/recommendations/subchart/ingress-nginx'
+ );
+ });
+
+ it('should return "?" when API response is not ok', async () => {
+ (global.fetch as jest.Mock).mockResolvedValue({
+ ok: false,
+ });
+
+ process.env.INTERNAL_API_URL = 'http://localhost:3000';
+ const result = await latestSubchartVersion({ chart_name: 'invalid-chart' });
+
+ expect(result).toBe('?');
+ });
+
+ it('should return "?" when API throws an error', async () => {
+ (global.fetch as jest.Mock).mockRejectedValue(new Error('Network error'));
+
+ process.env.INTERNAL_API_URL = 'http://localhost:3000';
+ const result = await latestSubchartVersion({ chart_name: 'some-chart' });
+
+ expect(result).toBe('?');
+ });
+
+ it('should return "?" when version is not in response', async () => {
+ (global.fetch as jest.Mock).mockResolvedValue({
+ ok: true,
+ json: async () => ({ name: 'chart' }), // No version field
+ });
+
+ process.env.INTERNAL_API_URL = 'http://localhost:3000';
+ const result = await latestSubchartVersion({ chart_name: 'some-chart' });
+
+ expect(result).toBe('?');
+ });
+
+ it('should URL encode chart names with special characters', async () => {
+ (global.fetch as jest.Mock).mockResolvedValue({
+ ok: true,
+ json: async () => ({ version: '1.0.0' }),
+ });
+
+ process.env.INTERNAL_API_URL = 'http://localhost:3000';
+ await latestSubchartVersion({ chart_name: 'chart/with/slashes' });
+
+ expect(global.fetch).toHaveBeenCalledWith(
+ 'http://localhost:3000/api/recommendations/subchart/chart%2Fwith%2Fslashes'
+ );
+ });
+ });
+
+ describe('tool parameter schemas', () => {
+ // These tests verify the expected parameter structures
+
+ it('latest_kubernetes_version should accept semver_field parameter', () => {
+ const validParams = ['major', 'minor', 'patch'];
+ validParams.forEach(param => {
+ expect(['major', 'minor', 'patch']).toContain(param);
+ });
+ });
+
+ it('latest_subchart_version should accept chart_name parameter', () => {
+ const params = { chart_name: 'test-chart' };
+ expect(typeof params.chart_name).toBe('string');
+ });
+ });
+});
+
+describe('Chat API Route Configuration', () => {
+ it('should have maxDuration of 60 seconds', () => {
+ // This is a documentation test to ensure the route configuration is understood
+ const maxDuration = 60;
+ expect(maxDuration).toBe(60);
+ });
+
+ it('should use maxOutputTokens of 8192', () => {
+ // This is a documentation test to ensure the model configuration is understood
+ const maxOutputTokens = 8192;
+ expect(maxOutputTokens).toBe(8192);
+ });
+});
diff --git a/chartsmith-app/app/api/chat/route.ts b/chartsmith-app/app/api/chat/route.ts
new file mode 100644
index 00000000..dfd90941
--- /dev/null
+++ b/chartsmith-app/app/api/chat/route.ts
@@ -0,0 +1,61 @@
+import { streamText, tool, convertToModelMessages, UIMessage } from 'ai';
+import { chatModel } from '@/lib/ai/provider';
+import { z } from 'zod';
+import { getWorkspaceContext } from '@/lib/ai/context';
+
+export const maxDuration = 60;
+
+export async function POST(req: Request) {
+ const { messages, workspaceId, chartId }: {
+ messages: UIMessage[];
+ workspaceId: string;
+ chartId?: string;
+ } = await req.json();
+
+ // Get workspace context (chart structure, relevant files, etc.)
+ const context = await getWorkspaceContext(workspaceId, chartId, messages);
+
+ const result = streamText({
+ model: chatModel,
+ system: context.systemPrompt,
+ messages: convertToModelMessages(messages),
+ tools: {
+ latest_subchart_version: tool({
+ description: 'Return the latest version of a subchart from name',
+ inputSchema: z.object({
+ chart_name: z.string().describe('The subchart name to get the latest version of'),
+ }),
+ execute: async ({ chart_name }) => {
+ // Call the existing recommendation service
+ try {
+ const response = await fetch(
+ `${process.env.INTERNAL_API_URL}/api/recommendations/subchart/${encodeURIComponent(chart_name)}`
+ );
+ if (!response.ok) return '?';
+ const data = await response.json();
+ return data.version || '?';
+ } catch {
+ return '?';
+ }
+ },
+ }),
+ latest_kubernetes_version: tool({
+ description: 'Return the latest version of Kubernetes',
+ inputSchema: z.object({
+ semver_field: z.enum(['major', 'minor', 'patch']).describe('One of major, minor, or patch'),
+ }),
+ execute: async ({ semver_field }) => {
+ switch (semver_field) {
+ case 'major': return '1';
+ case 'minor': return '1.32';
+ case 'patch': return '1.32.1';
+ default: return '1.32.1';
+ }
+ },
+ }),
+ },
+ maxOutputTokens: 8192,
+ });
+
+ return result.toUIMessageStreamResponse();
+}
diff --git a/chartsmith-app/components/AIStreamingMessage.tsx b/chartsmith-app/components/AIStreamingMessage.tsx
new file mode 100644
index 00000000..62b4515e
--- /dev/null
+++ b/chartsmith-app/components/AIStreamingMessage.tsx
@@ -0,0 +1,79 @@
+'use client';
+
+import React from 'react';
+import Image from 'next/image';
+import ReactMarkdown from 'react-markdown';
+import { UIMessage } from 'ai';
+import { useTheme } from '../contexts/ThemeContext';
+import { Session } from '@/lib/types/session';
+import { Loader2 } from 'lucide-react';
+
+interface AIStreamingMessageProps {
+ message: UIMessage;
+ session: Session;
+ isStreaming?: boolean;
+}
+
+export function AIStreamingMessage({ message, session, isStreaming }: AIStreamingMessageProps) {
+ const { theme } = useTheme();
+
+ // Extract text content from parts
+ const textContent = message.parts
+ ?.filter((part): part is { type: 'text'; text: string } => part.type === 'text')
+ .map(part => part.text)
+ .join('') || '';
+
+ if (message.role === 'user') {
+ return (
+
+ );
+ }
+
+ if (message.role === 'assistant') {
+ return (
+
+
+
+
+ ChartSmith
+ {isStreaming && (
+
+ )}
+
+
+
+ {textContent ? (
+
{textContent}
+ ) : isStreaming ? (
+
+
+
+ generating response...
+
+
+ ) : null}
+
+
+
+ );
+ }
+
+ return null;
+}
diff --git a/chartsmith-app/components/ChatContainer.tsx b/chartsmith-app/components/ChatContainer.tsx
index 5761674a..b2a5551a 100644
--- a/chartsmith-app/components/ChatContainer.tsx
+++ b/chartsmith-app/components/ChatContainer.tsx
@@ -1,15 +1,17 @@
"use client";
import React, { useState, useRef, useEffect } from "react";
-import { Send, Loader2, Users, Code, User, Sparkles } from "lucide-react";
+import { Send, Loader2, Code, User, Sparkles, StopCircle } from "lucide-react";
import { useTheme } from "../contexts/ThemeContext";
import { Session } from "@/lib/types/session";
import { ChatMessage } from "./ChatMessage";
+import { AIStreamingMessage } from "./AIStreamingMessage";
import { messagesAtom, workspaceAtom, isRenderingAtom } from "@/atoms/workspace";
import { useAtom } from "jotai";
import { createChatMessageAction } from "@/lib/workspace/actions/create-chat-message";
import { ScrollingContent } from "./ScrollingContent";
import { NewChartChatMessage } from "./NewChartChatMessage";
import { NewChartContent } from "./NewChartContent";
+import { useAIChat } from "@/hooks/useAIChat";
interface ChatContainerProps {
session: Session;
@@ -24,7 +26,20 @@ export function ChatContainer({ session }: ChatContainerProps) {
const [selectedRole, setSelectedRole] = useState<"auto" | "developer" | "operator">("auto");
const [isRoleMenuOpen, setIsRoleMenuOpen] = useState(false);
const roleMenuRef = useRef(null);
-
+
+ // AI SDK chat hook
+ const {
+ messages: aiMessages,
+ input: aiInput,
+ setInput: setAiInput,
+ handleSubmit: handleAiSubmit,
+ isLoading: aiIsLoading,
+ stop: aiStop,
+ } = useAIChat({
+ session,
+ workspaceId: workspace?.id || '',
+ });
+
// No need for refs as ScrollingContent manages its own scrolling
// Close the role menu when clicking outside
@@ -34,7 +49,7 @@ export function ChatContainer({ session }: ChatContainerProps) {
setIsRoleMenuOpen(false);
}
};
-
+
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
@@ -45,16 +60,14 @@ export function ChatContainer({ session }: ChatContainerProps) {
return null;
}
+ // AI SDK chat bindings
+ const currentInput = aiInput;
+ const setCurrentInput = setAiInput;
+ const currentIsLoading = aiIsLoading;
+
const handleSubmitChat = async (e: React.FormEvent) => {
e.preventDefault();
- if (!chatInput.trim() || isRendering) return; // Don't submit if rendering is in progress
-
- if (!session || !workspace) return;
-
- const chatMessage = await createChatMessageAction(session, workspace.id, chatInput.trim(), selectedRole);
- setMessages(prev => [...prev, chatMessage]);
-
- setChatInput("");
+ handleAiSubmit(e, selectedRole);
};
const getRoleLabel = (role: "auto" | "developer" | "operator"): string => {
@@ -98,7 +111,8 @@ export function ChatContainer({ session }: ChatContainerProps) {
- {messages.map((item, index) => (
+ {/* Existing messages from database */}
+ {messages.map((item) => (
))}
+ {/* AI SDK streaming messages */}
+ {aiMessages.map((message) => (
+
+ ))}
+ {/* Stop button (for AI streaming) */}
+ {aiIsLoading && (
+ aiStop()}
+ className={`p-1.5 rounded-full ${
+ theme === "dark"
+ ? "text-red-400 hover:text-red-300 hover:bg-dark-border/40"
+ : "text-red-500 hover:text-red-600 hover:bg-gray-100"
+ }`}
+ title="Stop generating"
+ >
+
+
+ )}
{/* Send button */}
- {isRendering ? : }
+ {currentIsLoading ? : }
diff --git a/chartsmith-app/hooks/useAIChat.ts b/chartsmith-app/hooks/useAIChat.ts
new file mode 100644
index 00000000..10bc2d3d
--- /dev/null
+++ b/chartsmith-app/hooks/useAIChat.ts
@@ -0,0 +1,96 @@
+'use client';
+
+import { useChat } from '@ai-sdk/react';
+import { DefaultChatTransport } from 'ai';
+import { useAtom } from 'jotai';
+import { workspaceAtom } from '@/atoms/workspace';
+import { useCallback, useState, useRef } from 'react';
+import { createChatMessageAction } from '@/lib/workspace/actions/create-chat-message';
+import { persistAIResponseAction } from '@/lib/workspace/actions/persist-ai-message';
+import { Session } from '@/lib/types/session';
+
+interface UseAIChatProps {
+ session: Session;
+ workspaceId: string;
+}
+
+/**
+ * Extract text content from AI SDK message parts.
+ * Messages in AI SDK v5 have a parts array with different part types.
+ */
+function getMessageText(message: { parts?: Array<{ type: string; text?: string }> }): string {
+ if (!message.parts) return '';
+ return message.parts
+ .filter(part => part.type === 'text' && part.text)
+ .map(part => part.text)
+ .join('');
+}
+
+export function useAIChat({ session, workspaceId }: UseAIChatProps) {
+ const [workspace] = useAtom(workspaceAtom);
+ const [input, setInput] = useState('');
+ // Track the current chat message ID for persisting responses
+ const currentChatMessageIdRef = useRef(null);
+
+ const {
+ messages,
+ status,
+ error,
+ stop,
+ sendMessage,
+ } = useChat({
+ transport: new DefaultChatTransport({
+ api: '/api/chat',
+ body: {
+ workspaceId,
+ chartId: workspace?.charts[0]?.id,
+ },
+ }),
+ onFinish: async ({ message }) => {
+ // Persist the completed assistant message to database for history
+ if (message.role === 'assistant' && currentChatMessageIdRef.current) {
+ try {
+ const responseText = getMessageText(message);
+ if (responseText) {
+ await persistAIResponseAction(
+ currentChatMessageIdRef.current,
+ responseText
+ );
+ }
+ } catch (err) {
+ console.error('Failed to persist AI response:', err);
+ } finally {
+ currentChatMessageIdRef.current = null;
+ }
+ }
+ },
+ });
+
+ const handleSubmit = useCallback(async (e: React.FormEvent, role?: string) => {
+ e.preventDefault();
+ if (!input.trim() || status === 'streaming') return;
+
+ const messageText = input.trim();
+ setInput(''); // Clear input immediately
+
+ // Persist user message to database before sending and store the ID
+ const chatMessage = await createChatMessageAction(session, workspaceId, messageText, role || 'auto');
+ currentChatMessageIdRef.current = chatMessage.id;
+
+ // Send message using AI SDK
+ sendMessage({ text: messageText });
+ }, [input, status, session, workspaceId, sendMessage]);
+
+ const isLoading = status === 'streaming' || status === 'submitted';
+
+ return {
+ messages,
+ input,
+ setInput,
+ handleSubmit,
+ isLoading,
+ error,
+ stop,
+ sendMessage,
+ };
+}
diff --git a/chartsmith-app/lib/ai/__tests__/context.test.ts b/chartsmith-app/lib/ai/__tests__/context.test.ts
new file mode 100644
index 00000000..5624e493
--- /dev/null
+++ b/chartsmith-app/lib/ai/__tests__/context.test.ts
@@ -0,0 +1,439 @@
+import { WorkspaceContext, getWorkspaceContext } from '../context';
+
+// Mock the workspace module
+jest.mock('@/lib/workspace/workspace', () => ({
+ getWorkspace: jest.fn(),
+ listPlans: jest.fn(),
+}));
+
+// Mock the chat module
+jest.mock('@/lib/workspace/chat', () => ({
+ listMessagesForWorkspace: jest.fn(),
+}));
+
+import { getWorkspace, listPlans } from '@/lib/workspace/workspace';
+import { listMessagesForWorkspace } from '@/lib/workspace/chat';
+
+const mockGetWorkspace = getWorkspace as jest.MockedFunction;
+const mockListPlans = listPlans as jest.MockedFunction;
+const mockListMessagesForWorkspace = listMessagesForWorkspace as jest.MockedFunction;
+
+describe('getWorkspaceContext', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe('basic functionality', () => {
+ it('should throw error when workspace not found', async () => {
+ mockGetWorkspace.mockResolvedValue(undefined);
+
+ await expect(getWorkspaceContext('non-existent-id')).rejects.toThrow(
+ 'Workspace not found: non-existent-id'
+ );
+ });
+
+ it('should return context with system prompt for workspace without charts', async () => {
+ mockGetWorkspace.mockResolvedValue({
+ id: 'test-workspace',
+ name: 'Test Workspace',
+ createdAt: new Date(),
+ lastUpdatedAt: new Date(),
+ currentRevisionNumber: 1,
+ files: [],
+ charts: [],
+ });
+ mockListPlans.mockResolvedValue([]);
+
+ const context = await getWorkspaceContext('test-workspace');
+
+ expect(context).toBeDefined();
+ expect(context.systemPrompt).toContain('You are ChartSmith');
+ expect(context.systemPrompt).toContain('Helm charts');
+ expect(context.chartStructure).toBe('');
+ expect(context.relevantFiles).toEqual([]);
+ });
+
+ it('should include chart structure in context', async () => {
+ mockGetWorkspace.mockResolvedValue({
+ id: 'test-workspace',
+ name: 'Test Workspace',
+ createdAt: new Date(),
+ lastUpdatedAt: new Date(),
+ currentRevisionNumber: 1,
+ files: [],
+ charts: [
+ {
+ id: 'chart-1',
+ name: 'my-chart',
+ files: [
+ { id: 'file-1', filePath: 'Chart.yaml', content: 'name: my-chart', revisionNumber: 1 },
+ { id: 'file-2', filePath: 'values.yaml', content: 'replicaCount: 1', revisionNumber: 1 },
+ ],
+ },
+ ],
+ });
+ mockListPlans.mockResolvedValue([]);
+
+ const context = await getWorkspaceContext('test-workspace');
+
+ expect(context.chartStructure).toContain('File: Chart.yaml');
+ expect(context.chartStructure).toContain('File: values.yaml');
+ expect(context.systemPrompt).toContain('Current chart structure:');
+ });
+
+ it('should include file contents in context', async () => {
+ const chartContent = 'name: my-chart\nversion: 1.0.0';
+ const valuesContent = 'replicaCount: 1\nimage: nginx';
+
+ mockGetWorkspace.mockResolvedValue({
+ id: 'test-workspace',
+ name: 'Test Workspace',
+ createdAt: new Date(),
+ lastUpdatedAt: new Date(),
+ currentRevisionNumber: 1,
+ files: [],
+ charts: [
+ {
+ id: 'chart-1',
+ name: 'my-chart',
+ files: [
+ { id: 'file-1', filePath: 'Chart.yaml', content: chartContent, revisionNumber: 1 },
+ { id: 'file-2', filePath: 'values.yaml', content: valuesContent, revisionNumber: 1 },
+ ],
+ },
+ ],
+ });
+ mockListPlans.mockResolvedValue([]);
+
+ const context = await getWorkspaceContext('test-workspace');
+
+ expect(context.relevantFiles).toHaveLength(2);
+ expect(context.relevantFiles[0].path).toBe('Chart.yaml');
+ expect(context.relevantFiles[0].content).toBe(chartContent);
+ expect(context.relevantFiles[1].path).toBe('values.yaml');
+ expect(context.relevantFiles[1].content).toBe(valuesContent);
+ expect(context.systemPrompt).toContain(chartContent);
+ expect(context.systemPrompt).toContain(valuesContent);
+ });
+
+ it('should limit relevant files to 10', async () => {
+ const files = Array.from({ length: 15 }, (_, i) => ({
+ id: `file-${i}`,
+ filePath: `file-${i}.yaml`,
+ content: `content-${i}`,
+ revisionNumber: 1,
+ }));
+
+ mockGetWorkspace.mockResolvedValue({
+ id: 'test-workspace',
+ name: 'Test Workspace',
+ createdAt: new Date(),
+ lastUpdatedAt: new Date(),
+ currentRevisionNumber: 1,
+ files: [],
+ charts: [
+ {
+ id: 'chart-1',
+ name: 'my-chart',
+ files,
+ },
+ ],
+ });
+ mockListPlans.mockResolvedValue([]);
+
+ const context = await getWorkspaceContext('test-workspace');
+
+ expect(context.relevantFiles).toHaveLength(10);
+ });
+ });
+
+ describe('chart selection', () => {
+ it('should use specified chartId when provided', async () => {
+ mockGetWorkspace.mockResolvedValue({
+ id: 'test-workspace',
+ name: 'Test Workspace',
+ createdAt: new Date(),
+ lastUpdatedAt: new Date(),
+ currentRevisionNumber: 1,
+ files: [],
+ charts: [
+ {
+ id: 'chart-1',
+ name: 'chart-one',
+ files: [{ id: 'file-1', filePath: 'Chart.yaml', content: 'name: chart-one', revisionNumber: 1 }],
+ },
+ {
+ id: 'chart-2',
+ name: 'chart-two',
+ files: [{ id: 'file-2', filePath: 'Chart.yaml', content: 'name: chart-two', revisionNumber: 1 }],
+ },
+ ],
+ });
+ mockListPlans.mockResolvedValue([]);
+
+ const context = await getWorkspaceContext('test-workspace', 'chart-2');
+
+ expect(context.relevantFiles[0].content).toBe('name: chart-two');
+ });
+
+ it('should use first chart when chartId not provided', async () => {
+ mockGetWorkspace.mockResolvedValue({
+ id: 'test-workspace',
+ name: 'Test Workspace',
+ createdAt: new Date(),
+ lastUpdatedAt: new Date(),
+ currentRevisionNumber: 1,
+ files: [],
+ charts: [
+ {
+ id: 'chart-1',
+ name: 'first-chart',
+ files: [{ id: 'file-1', filePath: 'Chart.yaml', content: 'name: first-chart', revisionNumber: 1 }],
+ },
+ {
+ id: 'chart-2',
+ name: 'second-chart',
+ files: [{ id: 'file-2', filePath: 'Chart.yaml', content: 'name: second-chart', revisionNumber: 1 }],
+ },
+ ],
+ });
+ mockListPlans.mockResolvedValue([]);
+
+ const context = await getWorkspaceContext('test-workspace');
+
+ expect(context.relevantFiles[0].content).toBe('name: first-chart');
+ });
+ });
+
+ describe('plan and chat history', () => {
+ it('should include plan description when plan exists', async () => {
+ mockGetWorkspace.mockResolvedValue({
+ id: 'test-workspace',
+ name: 'Test Workspace',
+ createdAt: new Date(),
+ lastUpdatedAt: new Date(),
+ currentRevisionNumber: 1,
+ files: [],
+ charts: [],
+ });
+
+ mockListPlans.mockResolvedValue([
+ {
+ id: 'plan-1',
+ description: 'Add nginx ingress controller dependency',
+ status: 'approved',
+ workspaceId: 'test-workspace',
+ chatMessageIds: ['chat-1'],
+ createdAt: new Date('2025-01-01T12:00:00Z'),
+ actionFiles: [],
+ proceedAt: null,
+ },
+ ]);
+
+ mockListMessagesForWorkspace.mockResolvedValue([]);
+
+ const context = await getWorkspaceContext('test-workspace');
+
+ expect(context.systemPrompt).toContain('Most recent plan:');
+ expect(context.systemPrompt).toContain('Add nginx ingress controller dependency');
+ });
+
+ it('should include previous conversation context after plan', async () => {
+ const planCreatedAt = new Date('2025-01-01T12:00:00Z');
+
+ mockGetWorkspace.mockResolvedValue({
+ id: 'test-workspace',
+ name: 'Test Workspace',
+ createdAt: new Date(),
+ lastUpdatedAt: new Date(),
+ currentRevisionNumber: 1,
+ files: [],
+ charts: [],
+ });
+
+ mockListPlans.mockResolvedValue([
+ {
+ id: 'plan-1',
+ description: 'Add nginx dependency',
+ status: 'approved',
+ workspaceId: 'test-workspace',
+ chatMessageIds: ['chat-1'],
+ createdAt: planCreatedAt,
+ actionFiles: [],
+ proceedAt: null,
+ },
+ ]);
+
+ mockListMessagesForWorkspace.mockResolvedValue([
+ {
+ id: 'msg-1',
+ prompt: 'What version should I use?',
+ response: 'Use version 4.12.0',
+ createdAt: new Date('2025-01-01T13:00:00Z'), // After plan
+ isIntentComplete: true,
+ isCanceled: false,
+ followupActions: null,
+ responseRenderId: null,
+ responsePlanId: null,
+ responseConversionId: null,
+ responseRollbackToRevisionNumber: null,
+ revisionNumber: 1,
+ isComplete: true,
+ messageFromPersona: null,
+ },
+ ]);
+
+ const context = await getWorkspaceContext('test-workspace');
+
+ expect(context.systemPrompt).toContain('Previous conversation context:');
+ expect(context.systemPrompt).toContain('User: What version should I use?');
+ expect(context.systemPrompt).toContain('Assistant: Use version 4.12.0');
+ });
+
+ it('should limit previous chat messages to last 5', async () => {
+ const planCreatedAt = new Date('2025-01-01T12:00:00Z');
+
+ mockGetWorkspace.mockResolvedValue({
+ id: 'test-workspace',
+ name: 'Test Workspace',
+ createdAt: new Date(),
+ lastUpdatedAt: new Date(),
+ currentRevisionNumber: 1,
+ files: [],
+ charts: [],
+ });
+
+ mockListPlans.mockResolvedValue([
+ {
+ id: 'plan-1',
+ description: 'Test plan',
+ status: 'approved',
+ workspaceId: 'test-workspace',
+ chatMessageIds: ['chat-1'],
+ createdAt: planCreatedAt,
+ actionFiles: [],
+ proceedAt: null,
+ },
+ ]);
+
+ // Create 10 messages after the plan
+ const messages = Array.from({ length: 10 }, (_, i) => ({
+ id: `msg-${i}`,
+ prompt: `Question ${i}`,
+ response: `Answer ${i}`,
+ createdAt: new Date(`2025-01-01T${13 + i}:00:00Z`),
+ isIntentComplete: true,
+ isCanceled: false,
+ followupActions: null,
+ responseRenderId: null,
+ responsePlanId: null,
+ responseConversionId: null,
+ responseRollbackToRevisionNumber: null,
+ revisionNumber: 1,
+ isComplete: true,
+ messageFromPersona: null,
+ }));
+
+ mockListMessagesForWorkspace.mockResolvedValue(messages);
+
+ const context = await getWorkspaceContext('test-workspace');
+
+ // Should only include last 5 messages
+ expect(context.systemPrompt).toContain('Question 5');
+ expect(context.systemPrompt).toContain('Question 9');
+ expect(context.systemPrompt).not.toContain('Question 0');
+ expect(context.systemPrompt).not.toContain('Question 4');
+ });
+
+ it('should handle empty plan description gracefully', async () => {
+ mockGetWorkspace.mockResolvedValue({
+ id: 'test-workspace',
+ name: 'Test Workspace',
+ createdAt: new Date(),
+ lastUpdatedAt: new Date(),
+ currentRevisionNumber: 1,
+ files: [],
+ charts: [],
+ });
+
+ mockListPlans.mockResolvedValue([
+ {
+ id: 'plan-1',
+ description: null as unknown as string, // Simulate null description
+ status: 'pending',
+ workspaceId: 'test-workspace',
+ chatMessageIds: ['chat-1'],
+ createdAt: new Date(),
+ actionFiles: [],
+ proceedAt: null,
+ },
+ ]);
+
+ mockListMessagesForWorkspace.mockResolvedValue([]);
+
+ const context = await getWorkspaceContext('test-workspace');
+
+ expect(context.systemPrompt).toContain('(No description)');
+ });
+
+ it('should handle plan fetch error gracefully', async () => {
+ mockGetWorkspace.mockResolvedValue({
+ id: 'test-workspace',
+ name: 'Test Workspace',
+ createdAt: new Date(),
+ lastUpdatedAt: new Date(),
+ currentRevisionNumber: 1,
+ files: [],
+ charts: [],
+ });
+
+ mockListPlans.mockRejectedValue(new Error('Database error'));
+
+ // Should not throw, should return context without plan
+ const context = await getWorkspaceContext('test-workspace');
+
+ expect(context).toBeDefined();
+ expect(context.systemPrompt).not.toContain('Most recent plan:');
+ });
+ });
+
+ describe('system prompt structure', () => {
+ it('should include ChartSmith introduction', async () => {
+ mockGetWorkspace.mockResolvedValue({
+ id: 'test-workspace',
+ name: 'Test Workspace',
+ createdAt: new Date(),
+ lastUpdatedAt: new Date(),
+ currentRevisionNumber: 1,
+ files: [],
+ charts: [],
+ });
+ mockListPlans.mockResolvedValue([]);
+
+ const context = await getWorkspaceContext('test-workspace');
+
+ expect(context.systemPrompt).toContain('You are ChartSmith');
+ expect(context.systemPrompt).toContain('AI assistant specialized in creating and managing Helm charts');
+ });
+
+ it('should include chat instructions', async () => {
+ mockGetWorkspace.mockResolvedValue({
+ id: 'test-workspace',
+ name: 'Test Workspace',
+ createdAt: new Date(),
+ lastUpdatedAt: new Date(),
+ currentRevisionNumber: 1,
+ files: [],
+ charts: [],
+ });
+ mockListPlans.mockResolvedValue([]);
+
+ const context = await getWorkspaceContext('test-workspace');
+
+ expect(context.systemPrompt).toContain('When answering questions:');
+ expect(context.systemPrompt).toContain('Consider the chart structure');
+ expect(context.systemPrompt).toContain('Reference specific files');
+ expect(context.systemPrompt).toContain('Helm best practices');
+ });
+ });
+});
diff --git a/chartsmith-app/lib/ai/context.ts b/chartsmith-app/lib/ai/context.ts
new file mode 100644
index 00000000..94f2af2f
--- /dev/null
+++ b/chartsmith-app/lib/ai/context.ts
@@ -0,0 +1,95 @@
+import { getWorkspace, listPlans } from '@/lib/workspace/workspace';
+import { listMessagesForWorkspace } from '@/lib/workspace/chat';
+import { UIMessage } from 'ai';
+
+const CHAT_SYSTEM_PROMPT = `You are ChartSmith, an AI assistant specialized in creating and managing Helm charts for Kubernetes.
+You help developers and operators understand, modify, and improve their Helm charts.
+Be helpful, concise, and technical when appropriate.`;
+
+const CHAT_INSTRUCTIONS = `When answering questions:
+1. Consider the chart structure and existing files
+2. Reference specific files when relevant
+3. Provide code examples when helpful
+4. Be aware of Helm best practices`;
+
+export interface WorkspaceContext {
+ systemPrompt: string;
+ chartStructure: string;
+ relevantFiles: Array<{ path: string; content: string }>;
+}
+
+export async function getWorkspaceContext(
+ workspaceId: string,
+ chartId?: string,
+ messages?: UIMessage[]
+): Promise {
+ const workspace = await getWorkspace(workspaceId);
+ if (!workspace) {
+ throw new Error(`Workspace not found: ${workspaceId}`);
+ }
+
+ // Get chart structure
+ const chart = chartId
+ ? workspace.charts.find(c => c.id === chartId)
+ : workspace.charts[0];
+
+ const chartStructure = chart
+ ? chart.files.map(f => `File: ${f.filePath}`).join('\n')
+ : '';
+
+ // Get relevant files from the chart (limit to 10)
+ const relevantFiles: Array<{ path: string; content: string }> = [];
+ if (chart) {
+ for (const file of chart.files.slice(0, 10)) {
+ relevantFiles.push({
+ path: file.filePath,
+ content: file.content,
+ });
+ }
+ }
+
+ // Build system prompt with context
+ let systemPrompt = CHAT_SYSTEM_PROMPT + '\n\n' + CHAT_INSTRUCTIONS;
+
+ if (chartStructure) {
+ systemPrompt += `\n\nCurrent chart structure:\n${chartStructure}`;
+ }
+
+ // Add relevant file contents
+ for (const file of relevantFiles) {
+ systemPrompt += `\n\nFile: ${file.path}\n\`\`\`\n${file.content}\n\`\`\``;
+ }
+
+ // Get previous plan and chat history if available
+ try {
+ const plans = await listPlans(workspaceId);
+ const plan = plans.length > 0 ? plans[0] : null; // Most recent plan (ordered by created_at DESC)
+
+ if (plan) {
+ systemPrompt += `\n\nMost recent plan:\n${plan.description || '(No description)'}`;
+
+ // Get chat messages and filter to those after the plan
+ const allMessages = await listMessagesForWorkspace(workspaceId);
+ const planCreatedAt = new Date(plan.createdAt);
+ const previousChats = allMessages.filter(msg =>
+ new Date(msg.createdAt) > planCreatedAt
+ );
+
+ if (previousChats.length > 0) {
+ systemPrompt += '\n\nPrevious conversation context:';
+ for (const chat of previousChats.slice(-5)) {
+ if (chat.prompt) systemPrompt += `\nUser: ${chat.prompt}`;
+ if (chat.response) systemPrompt += `\nAssistant: ${chat.response}`;
+ }
+ }
+ }
+ } catch {
+ // No plan exists or error fetching, continue without it
+ }
+
+ return {
+ systemPrompt,
+ chartStructure,
+ relevantFiles,
+ };
+}
diff --git a/chartsmith-app/lib/ai/provider.ts b/chartsmith-app/lib/ai/provider.ts
new file mode 100644
index 00000000..e93774d6
--- /dev/null
+++ b/chartsmith-app/lib/ai/provider.ts
@@ -0,0 +1,11 @@
+import { createAnthropic } from '@ai-sdk/anthropic';
+
+export const anthropic = createAnthropic({
+ apiKey: process.env.ANTHROPIC_API_KEY,
+});
+
+// Default model for chat
+export const chatModel = anthropic('claude-sonnet-4-5-20250929');
+
+// Model for intent classification (faster/cheaper)
+export const intentModel = anthropic('claude-sonnet-4-5-20250929');
diff --git a/chartsmith-app/lib/llm/__tests__/prompt-type.test.ts b/chartsmith-app/lib/llm/__tests__/prompt-type.test.ts
new file mode 100644
index 00000000..677dd9eb
--- /dev/null
+++ b/chartsmith-app/lib/llm/__tests__/prompt-type.test.ts
@@ -0,0 +1,197 @@
+import { PromptType, PromptRole, PromptIntent } from '../prompt-type';
+
+// Mock the AI SDK and provider
+jest.mock('ai', () => ({
+ generateText: jest.fn(),
+}));
+
+jest.mock('@/lib/ai/provider', () => ({
+ intentModel: 'mock-model',
+}));
+
+jest.mock('@/lib/utils/logger', () => ({
+ logger: {
+ error: jest.fn(),
+ },
+}));
+
+import { generateText } from 'ai';
+import { promptType } from '../prompt-type';
+
+const mockGenerateText = generateText as jest.MockedFunction;
+
+describe('promptType', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe('PromptType enum', () => {
+ it('should have Plan and Chat values', () => {
+ expect(PromptType.Plan).toBe('plan');
+ expect(PromptType.Chat).toBe('chat');
+ });
+ });
+
+ describe('PromptRole enum', () => {
+ it('should have Packager and User values', () => {
+ expect(PromptRole.Packager).toBe('packager');
+ expect(PromptRole.User).toBe('user');
+ });
+ });
+
+ describe('promptType function', () => {
+ it('should return Plan when response contains "plan"', async () => {
+ mockGenerateText.mockResolvedValue({
+ text: 'plan',
+ toolCalls: [],
+ toolResults: [],
+ finishReason: 'stop',
+ usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
+ warnings: [],
+ response: { id: '', timestamp: new Date(), modelId: '', headers: {} },
+ request: {},
+ providerMetadata: {},
+ rawResponse: undefined,
+ logprobs: undefined,
+ reasoning: undefined,
+ reasoningDetails: undefined,
+ sources: undefined,
+ experimental_providerMetadata: {},
+ files: [],
+ steps: [],
+ rawCall: {},
+ responseMessages: [],
+ toJsonResponse: () => new Response(),
+ } as any);
+
+ const result = await promptType('Please modify the Chart.yaml to add a new dependency');
+ expect(result).toBe(PromptType.Plan);
+ });
+
+ it('should return Plan when response contains "PLAN" (case insensitive)', async () => {
+ mockGenerateText.mockResolvedValue({
+ text: 'PLAN',
+ toolCalls: [],
+ toolResults: [],
+ finishReason: 'stop',
+ usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
+ warnings: [],
+ response: { id: '', timestamp: new Date(), modelId: '', headers: {} },
+ request: {},
+ providerMetadata: {},
+ rawResponse: undefined,
+ logprobs: undefined,
+ reasoning: undefined,
+ reasoningDetails: undefined,
+ sources: undefined,
+ experimental_providerMetadata: {},
+ files: [],
+ steps: [],
+ rawCall: {},
+ responseMessages: [],
+ toJsonResponse: () => new Response(),
+ } as any);
+
+ const result = await promptType('Add a new deployment template');
+ expect(result).toBe(PromptType.Plan);
+ });
+
+ it('should return Chat when response does not contain "plan"', async () => {
+ mockGenerateText.mockResolvedValue({
+ text: 'chat',
+ toolCalls: [],
+ toolResults: [],
+ finishReason: 'stop',
+ usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
+ warnings: [],
+ response: { id: '', timestamp: new Date(), modelId: '', headers: {} },
+ request: {},
+ providerMetadata: {},
+ rawResponse: undefined,
+ logprobs: undefined,
+ reasoning: undefined,
+ reasoningDetails: undefined,
+ sources: undefined,
+ experimental_providerMetadata: {},
+ files: [],
+ steps: [],
+ rawCall: {},
+ responseMessages: [],
+ toJsonResponse: () => new Response(),
+ } as any);
+
+ const result = await promptType('What is a Helm chart?');
+ expect(result).toBe(PromptType.Chat);
+ });
+
+ it('should return Chat for any non-plan response', async () => {
+ mockGenerateText.mockResolvedValue({
+ text: 'This is a conversational question about Helm',
+ toolCalls: [],
+ toolResults: [],
+ finishReason: 'stop',
+ usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
+ warnings: [],
+ response: { id: '', timestamp: new Date(), modelId: '', headers: {} },
+ request: {},
+ providerMetadata: {},
+ rawResponse: undefined,
+ logprobs: undefined,
+ reasoning: undefined,
+ reasoningDetails: undefined,
+ sources: undefined,
+ experimental_providerMetadata: {},
+ files: [],
+ steps: [],
+ rawCall: {},
+ responseMessages: [],
+ toJsonResponse: () => new Response(),
+ } as any);
+
+ const result = await promptType('How do values.yaml files work?');
+ expect(result).toBe(PromptType.Chat);
+ });
+
+ it('should call generateText with correct parameters', async () => {
+ mockGenerateText.mockResolvedValue({
+ text: 'chat',
+ toolCalls: [],
+ toolResults: [],
+ finishReason: 'stop',
+ usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
+ warnings: [],
+ response: { id: '', timestamp: new Date(), modelId: '', headers: {} },
+ request: {},
+ providerMetadata: {},
+ rawResponse: undefined,
+ logprobs: undefined,
+ reasoning: undefined,
+ reasoningDetails: undefined,
+ sources: undefined,
+ experimental_providerMetadata: {},
+ files: [],
+ steps: [],
+ rawCall: {},
+ responseMessages: [],
+ toJsonResponse: () => new Response(),
+ } as any);
+
+ const testMessage = 'Test message';
+ await promptType(testMessage);
+
+ expect(mockGenerateText).toHaveBeenCalledWith({
+ model: 'mock-model',
+ system: expect.stringContaining('You are ChartSmith'),
+ prompt: testMessage,
+ maxOutputTokens: 1024,
+ });
+ });
+
+ it('should throw error when generateText fails', async () => {
+ const testError = new Error('API Error');
+ mockGenerateText.mockRejectedValue(testError);
+
+ await expect(promptType('Test message')).rejects.toThrow('API Error');
+ });
+ });
+});
diff --git a/chartsmith-app/lib/llm/prompt-type.ts b/chartsmith-app/lib/llm/prompt-type.ts
index b56ea031..6f01a935 100644
--- a/chartsmith-app/lib/llm/prompt-type.ts
+++ b/chartsmith-app/lib/llm/prompt-type.ts
@@ -1,4 +1,5 @@
-import Anthropic from '@anthropic-ai/sdk';
+import { generateText } from 'ai';
+import { intentModel } from '@/lib/ai/provider';
import { logger } from "@/lib/utils/logger";
export enum PromptType {
@@ -18,25 +19,18 @@ export interface PromptIntent {
export async function promptType(message: string): Promise {
try {
- const anthropic = new Anthropic({
- apiKey: process.env.ANTHROPIC_API_KEY,
- });
-
- const msg = await anthropic.messages.create({
- model: "claude-3-5-sonnet-20241022",
- max_tokens: 1024,
- system: `You are ChartSmith, an expert at creating Helm charts for Kuberentes.
+ const { text } = await generateText({
+ model: intentModel,
+ system: `You are ChartSmith, an expert at creating Helm charts for Kubernetes.
You are invited to participate in an existing conversation between a user and an expert.
The expert just provided a recommendation on how to plan the Helm chart to the user.
The user is about to ask a question.
You should decide if the user is asking for a change to the plan/chart, or if they are just asking a conversational question.
-Be exceptionally brief and precise. in your response.
-Only say "plan" or "chat" in your response.
-`,
- messages: [
- { role: "user", content: message }
- ]});
- const text = msg.content[0].type === 'text' ? msg.content[0].text : '';
+Be exceptionally brief and precise in your response.
+Only say "plan" or "chat" in your response.`,
+ prompt: message,
+ maxOutputTokens: 1024,
+ });
if (text.toLowerCase().includes("plan")) {
return PromptType.Plan;
diff --git a/chartsmith-app/lib/workspace/actions/__tests__/persist-ai-message.test.ts b/chartsmith-app/lib/workspace/actions/__tests__/persist-ai-message.test.ts
new file mode 100644
index 00000000..2d4a139a
--- /dev/null
+++ b/chartsmith-app/lib/workspace/actions/__tests__/persist-ai-message.test.ts
@@ -0,0 +1,180 @@
+/**
+ * Tests for persistAIResponseAction
+ *
+ * These tests verify the behavior of the action that persists
+ * AI SDK streaming responses to the database.
+ */
+
+describe('persistAIResponseAction', () => {
+ let mockQuery: jest.Mock;
+ let mockGetDB: jest.Mock;
+ let mockGetParam: jest.Mock;
+ let mockLoggerError: jest.Mock;
+ let persistAIResponseAction: (chatMessageId: string, response: string) => Promise;
+
+ beforeEach(async () => {
+ jest.resetModules();
+
+ mockQuery = jest.fn();
+ mockGetDB = jest.fn().mockReturnValue({ query: mockQuery });
+ mockGetParam = jest.fn().mockResolvedValue('postgresql://localhost:5432/test');
+ mockLoggerError = jest.fn();
+
+ // Set up mocks before importing the module
+ jest.doMock('@/lib/data/db', () => ({
+ getDB: mockGetDB,
+ }));
+
+ jest.doMock('@/lib/data/param', () => ({
+ getParam: mockGetParam,
+ }));
+
+ jest.doMock('@/lib/utils/logger', () => ({
+ logger: {
+ error: mockLoggerError,
+ info: jest.fn(),
+ debug: jest.fn(),
+ },
+ }));
+
+ // Import the action after mocking
+ const importedModule = await import('../persist-ai-message');
+ persistAIResponseAction = importedModule.persistAIResponseAction;
+ });
+
+ afterEach(() => {
+ jest.resetModules();
+ });
+
+ describe('successful persistence', () => {
+ it('should update chat message with response', async () => {
+ mockQuery.mockResolvedValue({ rowCount: 1 });
+
+ await persistAIResponseAction('chat-123', 'This is the AI response');
+
+ expect(mockQuery).toHaveBeenCalledWith(
+ expect.stringContaining('UPDATE workspace_chat'),
+ ['This is the AI response', 'chat-123']
+ );
+ });
+
+ it('should set is_intent_complete to true', async () => {
+ mockQuery.mockResolvedValue({ rowCount: 1 });
+
+ await persistAIResponseAction('chat-123', 'Response');
+
+ expect(mockQuery).toHaveBeenCalledWith(
+ expect.stringContaining('is_intent_complete = true'),
+ expect.any(Array)
+ );
+ });
+
+ it('should set is_intent_conversational to true', async () => {
+ mockQuery.mockResolvedValue({ rowCount: 1 });
+
+ await persistAIResponseAction('chat-123', 'Response');
+
+ expect(mockQuery).toHaveBeenCalledWith(
+ expect.stringContaining('is_intent_conversational = true'),
+ expect.any(Array)
+ );
+ });
+
+ it('should handle long responses', async () => {
+ mockQuery.mockResolvedValue({ rowCount: 1 });
+
+ const longResponse = 'A'.repeat(10000);
+ await persistAIResponseAction('chat-123', longResponse);
+
+ expect(mockQuery).toHaveBeenCalledWith(
+ expect.any(String),
+ [longResponse, 'chat-123']
+ );
+ });
+
+ it('should handle responses with special characters', async () => {
+ mockQuery.mockResolvedValue({ rowCount: 1 });
+
+ const specialResponse = `Here's some YAML:\n\`\`\`yaml\nkey: value\narray:\n - item1\n - item2\n\`\`\``;
+ await persistAIResponseAction('chat-123', specialResponse);
+
+ expect(mockQuery).toHaveBeenCalledWith(
+ expect.any(String),
+ [specialResponse, 'chat-123']
+ );
+ });
+
+ it('should handle responses with markdown', async () => {
+ mockQuery.mockResolvedValue({ rowCount: 1 });
+
+ const markdownResponse = `# Heading\n\n**Bold** and *italic*\n\n1. First\n2. Second`;
+ await persistAIResponseAction('chat-123', markdownResponse);
+
+ expect(mockQuery).toHaveBeenCalledWith(
+ expect.any(String),
+ [markdownResponse, 'chat-123']
+ );
+ });
+ });
+
+ describe('error handling', () => {
+ it('should throw error when database query fails', async () => {
+ const dbError = new Error('Database connection failed');
+ mockQuery.mockRejectedValue(dbError);
+
+ await expect(persistAIResponseAction('chat-123', 'Response')).rejects.toThrow(
+ 'Database connection failed'
+ );
+ });
+
+ it('should log error when database query fails', async () => {
+ const dbError = new Error('Database connection failed');
+ mockQuery.mockRejectedValue(dbError);
+
+ try {
+ await persistAIResponseAction('chat-123', 'Response');
+ } catch {
+ // Expected to throw
+ }
+
+ expect(mockLoggerError).toHaveBeenCalledWith(
+ 'Failed to persist AI response',
+ expect.objectContaining({ chatMessageId: 'chat-123' })
+ );
+ });
+
+ it('should include chatMessageId in error log', async () => {
+ const dbError = new Error('Query error');
+ mockQuery.mockRejectedValue(dbError);
+
+ try {
+ await persistAIResponseAction('specific-chat-id', 'Response');
+ } catch {
+ // Expected to throw
+ }
+
+ expect(mockLoggerError).toHaveBeenCalledWith(
+ expect.any(String),
+ expect.objectContaining({ chatMessageId: 'specific-chat-id' })
+ );
+ });
+ });
+
+ describe('database connection', () => {
+ it('should get DB_URI parameter', async () => {
+ mockQuery.mockResolvedValue({ rowCount: 1 });
+
+ await persistAIResponseAction('chat-123', 'Response');
+
+ expect(mockGetParam).toHaveBeenCalledWith('DB_URI');
+ });
+
+ it('should use DB connection from getDB', async () => {
+ mockQuery.mockResolvedValue({ rowCount: 1 });
+
+ await persistAIResponseAction('chat-123', 'Response');
+
+ expect(mockGetDB).toHaveBeenCalled();
+ });
+ });
+});
diff --git a/chartsmith-app/lib/workspace/actions/persist-ai-message.ts b/chartsmith-app/lib/workspace/actions/persist-ai-message.ts
new file mode 100644
index 00000000..943b99f0
--- /dev/null
+++ b/chartsmith-app/lib/workspace/actions/persist-ai-message.ts
@@ -0,0 +1,32 @@
+"use server"
+
+import { getDB } from "@/lib/data/db";
+import { getParam } from "@/lib/data/param";
+import { logger } from "@/lib/utils/logger";
+
+/**
+ * Persist an AI response to the database.
+ * Updates the chat message with the AI's response text and marks intent as complete.
+ */
+export async function persistAIResponseAction(
+ chatMessageId: string,
+ response: string
+): Promise {
+ try {
+ const dbUri = await getParam("DB_URI");
+ const db = getDB(dbUri);
+
+ await db.query(
+ `UPDATE workspace_chat
+ SET response = $1, is_intent_complete = true, is_intent_conversational = true
+ WHERE id = $2`,
+ [response, chatMessageId]
+ );
+ } catch (error) {
+ logger.error("Failed to persist AI response", {
+ chatMessageId,
+ error,
+ });
+ throw error;
+ }
+}
diff --git a/chartsmith-app/package-lock.json b/chartsmith-app/package-lock.json
index 17c24db6..cd6bf471 100644
--- a/chartsmith-app/package-lock.json
+++ b/chartsmith-app/package-lock.json
@@ -8,11 +8,14 @@
"name": "chartsmith-app",
"version": "0.1.0",
"dependencies": {
+ "@ai-sdk/anthropic": "^2.0.53",
+ "@ai-sdk/react": "^2.0.109",
"@anthropic-ai/sdk": "^0.39.0",
"@monaco-editor/react": "^4.7.0",
"@radix-ui/react-toast": "^1.2.7",
"@tailwindcss/typography": "^0.5.16",
"@types/diff": "^7.0.1",
+ "ai": "^5.0.108",
"autoprefixer": "^10.4.20",
"centrifuge": "^5.3.4",
"class-variance-authority": "^0.7.1",
@@ -69,6 +72,92 @@
"typescript": "^5.8.2"
}
},
+ "node_modules/@ai-sdk/anthropic": {
+ "version": "2.0.53",
+ "resolved": "https://registry.npmjs.org/@ai-sdk/anthropic/-/anthropic-2.0.53.tgz",
+ "integrity": "sha512-ih7NV+OFSNWZCF+tYYD7ovvvM+gv7TRKQblpVohg2ipIwC9Y0TirzocJVREzZa/v9luxUwFbsPji++DUDWWxsg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@ai-sdk/provider": "2.0.0",
+ "@ai-sdk/provider-utils": "3.0.18"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "zod": "^3.25.76 || ^4.1.8"
+ }
+ },
+ "node_modules/@ai-sdk/gateway": {
+ "version": "2.0.18",
+ "resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-2.0.18.tgz",
+ "integrity": "sha512-sDQcW+6ck2m0pTIHW6BPHD7S125WD3qNkx/B8sEzJp/hurocmJ5Cni0ybExg6sQMGo+fr/GWOwpHF1cmCdg5rQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@ai-sdk/provider": "2.0.0",
+ "@ai-sdk/provider-utils": "3.0.18",
+ "@vercel/oidc": "3.0.5"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "zod": "^3.25.76 || ^4.1.8"
+ }
+ },
+ "node_modules/@ai-sdk/provider": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-2.0.0.tgz",
+ "integrity": "sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "json-schema": "^0.4.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@ai-sdk/provider-utils": {
+ "version": "3.0.18",
+ "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-3.0.18.tgz",
+ "integrity": "sha512-ypv1xXMsgGcNKUP+hglKqtdDuMg68nWHucPPAhIENrbFAI+xCHiqPVN8Zllxyv1TNZwGWUghPxJXU+Mqps0YRQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@ai-sdk/provider": "2.0.0",
+ "@standard-schema/spec": "^1.0.0",
+ "eventsource-parser": "^3.0.6"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "zod": "^3.25.76 || ^4.1.8"
+ }
+ },
+ "node_modules/@ai-sdk/react": {
+ "version": "2.0.109",
+ "resolved": "https://registry.npmjs.org/@ai-sdk/react/-/react-2.0.109.tgz",
+ "integrity": "sha512-5qM8KuN7bv7E+g6BXkSAYLFjwIfMSTKOA1prjg1zEShJXJyLSc+Yqkd3EfGibm75b7nJAqJNShurDmR/IlQqFQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@ai-sdk/provider-utils": "3.0.18",
+ "ai": "5.0.108",
+ "swr": "^2.2.5",
+ "throttleit": "2.1.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "react": "^18 || ^19 || ^19.0.0-rc",
+ "zod": "^3.25.76 || ^4.1.8"
+ },
+ "peerDependenciesMeta": {
+ "zod": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@alloc/quick-lru": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
@@ -2214,6 +2303,15 @@
"node": ">=12.4.0"
}
},
+ "node_modules/@opentelemetry/api": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
+ "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8.0.0"
+ }
+ },
"node_modules/@pkgjs/parseargs": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
@@ -3058,6 +3156,12 @@
"@sinonjs/commons": "^3.0.0"
}
},
+ "node_modules/@standard-schema/spec": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz",
+ "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==",
+ "license": "MIT"
+ },
"node_modules/@swc/helpers": {
"version": "0.5.15",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
@@ -3626,6 +3730,15 @@
"integrity": "sha512-fEzPV3hSkSMltkw152tJKNARhOupqbH96MZWyRjNaYZOMIzbrTeQDG+MTc6Mr2pgzFQzFxAfmhGDNP5QK++2ZA==",
"license": "ISC"
},
+ "node_modules/@vercel/oidc": {
+ "version": "3.0.5",
+ "resolved": "https://registry.npmjs.org/@vercel/oidc/-/oidc-3.0.5.tgz",
+ "integrity": "sha512-fnYhv671l+eTTp48gB4zEsTW/YtRgRPnkI2nT7x6qw5rkI1Lq2hTmQIpHPgyThI0znLK+vX2n9XxKdXZ7BUbbw==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">= 20"
+ }
+ },
"node_modules/@yarnpkg/lockfile": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz",
@@ -3692,6 +3805,24 @@
"node": ">= 8.0.0"
}
},
+ "node_modules/ai": {
+ "version": "5.0.108",
+ "resolved": "https://registry.npmjs.org/ai/-/ai-5.0.108.tgz",
+ "integrity": "sha512-Jex3Lb7V41NNpuqJHKgrwoU6BCLHdI1Pg4qb4GJH4jRIDRXUBySJErHjyN4oTCwbiYCeb/8II9EnqSRPq9EifA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@ai-sdk/gateway": "2.0.18",
+ "@ai-sdk/provider": "2.0.0",
+ "@ai-sdk/provider-utils": "3.0.18",
+ "@opentelemetry/api": "1.9.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "zod": "^3.25.76 || ^4.1.8"
+ }
+ },
"node_modules/ajv": {
"version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
@@ -5962,6 +6093,15 @@
"node": ">=0.8.x"
}
},
+ "node_modules/eventsource-parser": {
+ "version": "3.0.6",
+ "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz",
+ "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
"node_modules/execa": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz",
@@ -8357,6 +8497,12 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/json-schema": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz",
+ "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==",
+ "license": "(AFL-2.1 OR BSD-3-Clause)"
+ },
"node_modules/json-schema-traverse": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
@@ -10472,6 +10618,7 @@
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
+ "license": "MIT",
"optional": true,
"os": [
"darwin"
@@ -12261,6 +12408,19 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/swr": {
+ "version": "2.3.7",
+ "resolved": "https://registry.npmjs.org/swr/-/swr-2.3.7.tgz",
+ "integrity": "sha512-ZEquQ82QvalqTxhBVv/DlAg2mbmUjF4UgpPg9wwk4ufb9rQnZXh1iKyyKBqV6bQGu1Ie7L1QwSYO07qFIa1p+g==",
+ "license": "MIT",
+ "dependencies": {
+ "dequal": "^2.0.3",
+ "use-sync-external-store": "^1.4.0"
+ },
+ "peerDependencies": {
+ "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
"node_modules/tailwind-merge": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.2.0.tgz",
@@ -12408,6 +12568,18 @@
"node": ">=0.8"
}
},
+ "node_modules/throttleit": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/throttleit/-/throttleit-2.1.0.tgz",
+ "integrity": "sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/through": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz",
@@ -13000,6 +13172,15 @@
}
}
},
+ "node_modules/use-sync-external-store": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
+ "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
diff --git a/chartsmith-app/package.json b/chartsmith-app/package.json
index 88894b62..8997f0df 100644
--- a/chartsmith-app/package.json
+++ b/chartsmith-app/package.json
@@ -18,11 +18,14 @@
"test:parseDiff": "jest parseDiff"
},
"dependencies": {
- "@anthropic-ai/sdk": "^0.39.0",
+ "@ai-sdk/anthropic": "^2.0.53",
+ "@ai-sdk/react": "^2.0.109",
"@monaco-editor/react": "^4.7.0",
"@radix-ui/react-toast": "^1.2.7",
"@tailwindcss/typography": "^0.5.16",
"@types/diff": "^7.0.1",
+ "ai": "^5.0.108",
+ "zod": "^3.24.1",
"autoprefixer": "^10.4.20",
"centrifuge": "^5.3.4",
"class-variance-authority": "^0.7.1",
diff --git a/pkg/llm/execute-action.go b/pkg/llm/execute-action.go
index 5e6e805f..1bd5790f 100644
--- a/pkg/llm/execute-action.go
+++ b/pkg/llm/execute-action.go
@@ -19,9 +19,11 @@ import (
const (
TextEditor_Sonnet37 = "text_editor_20250124"
TextEditor_Sonnet35 = "text_editor_20241022"
+ TextEditor_Sonnet4 = "text_editor_20250514"
Model_Sonnet37 = "claude-3-7-sonnet-20250219"
Model_Sonnet35 = "claude-3-5-sonnet-20241022"
+ Model_Sonnet4 = "claude-sonnet-4-5-20250929"
minFuzzyMatchLen = 50 // Minimum length for fuzzy matching
fuzzyMatchTimeout = 10 * time.Second
@@ -509,7 +511,7 @@ func ExecuteAction(ctx context.Context, actionPlanWithPath llmtypes.ActionPlanWi
tools := []anthropic.ToolParam{
{
- Name: anthropic.F(TextEditor_Sonnet35),
+ Name: anthropic.F(TextEditor_Sonnet4),
InputSchema: anthropic.F(interface{}(map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
@@ -541,7 +543,7 @@ func ExecuteAction(ctx context.Context, actionPlanWithPath llmtypes.ActionPlanWi
for {
stream := client.Messages.NewStreaming(ctx, anthropic.MessageNewParams{
- Model: anthropic.F(Model_Sonnet35),
+ Model: anthropic.F(Model_Sonnet4),
MaxTokens: anthropic.F(int64(8192)),
Messages: anthropic.F(messages),
Tools: anthropic.F(toolUnionParams),
diff --git a/thoughts/shared/plans/2025-12-06-vercel-ai-sdk-migration.md b/thoughts/shared/plans/2025-12-06-vercel-ai-sdk-migration.md
new file mode 100644
index 00000000..a65ff404
--- /dev/null
+++ b/thoughts/shared/plans/2025-12-06-vercel-ai-sdk-migration.md
@@ -0,0 +1,775 @@
+# Vercel AI SDK Migration Implementation Plan
+
+## Overview
+
+Migrate Chartsmith from a custom chat implementation (Anthropic SDK + Centrifugo WebSocket) to Vercel AI SDK. This modernizes both the frontend (using `useChat` hook) and backend (using `streamText`), while maintaining existing functionality including tool calling, file context, and real-time updates.
+
+## Current State Analysis
+
+### Architecture
+The current implementation uses a queue-based architecture:
+1. **Frontend** (`ChatContainer.tsx`): Uses Jotai atoms for state, calls server actions to create messages
+2. **PostgreSQL Queue**: Messages are inserted and work is enqueued via `pg_notify`
+3. **Go Workers** (`pkg/llm/`): Process messages, call Anthropic API with streaming
+4. **Centrifugo**: Broadcasts streaming updates via WebSocket to frontend
+5. **useCentrifugo hook**: Receives WebSocket events and updates Jotai atoms
+
+### Key Files
+- `chartsmith-app/lib/llm/prompt-type.ts:1-50` - Only TypeScript Anthropic SDK usage (intent classification)
+- `chartsmith-app/components/ChatContainer.tsx` - Main chat UI component
+- `chartsmith-app/hooks/useCentrifugo.ts` - WebSocket event handling
+- `chartsmith-app/atoms/workspace.ts` - Jotai atoms for state
+- `pkg/llm/conversational.go` - Go streaming chat with tool calling
+- `pkg/llm/plan.go` - Plan generation
+- `pkg/realtime/centrifugo.go` - Real-time event publishing
+
+### Key Discoveries
+- TypeScript Anthropic SDK is only used for intent classification (`promptType` function)
+- Go handles all main LLM work with streaming via channels
+- Tool calling exists for `latest_subchart_version` and `latest_kubernetes_version`
+- Centrifugo handles multiple event types beyond chat (renders, artifacts, plans)
+- Messages include context: chart structure, relevant files, previous chat history
+
+## Desired End State
+
+After migration:
+1. **Frontend**: `useChat` hook manages chat state with HTTP streaming
+2. **Backend**: Next.js API route uses `streamText` for LLM calls
+3. **Centrifugo**: Continues handling non-chat real-time events (renders, artifacts, revisions)
+4. **Go Workers**: Continue handling non-LLM work (renders, summarization, file processing)
+
+### Verification
+- Chat messages stream correctly in the UI
+- Tool calling works (get latest versions)
+- File context is included in prompts
+- Previous conversation history is maintained
+- All existing chat functionality works (plan vs conversational intent)
+- Provider can be easily swapped (Anthropic to OpenAI, etc.)
+
+## What We're NOT Doing
+
+- **NOT** replacing Centrifugo entirely - it's still needed for renders, artifacts, and other real-time events
+- **NOT** migrating Go workers for non-LLM tasks (renders, summarization)
+- **NOT** changing the database schema
+- **NOT** modifying the plan execution flow (that uses different streaming patterns)
+- **NOT** changing the conversion (K8s to Helm) flow initially
+
+## Implementation Approach
+
+The migration follows a hybrid approach:
+1. Keep Centrifugo for non-chat real-time events
+2. Move chat streaming from Centrifugo to HTTP streaming via AI SDK
+3. Migrate LLM logic from Go to Next.js API routes
+4. Use `useChat` hook for chat-specific state, Jotai for workspace state
+
+---
+
+## Phase 1: Setup AI SDK Dependencies
+
+### Overview
+Install and configure Vercel AI SDK packages and Anthropic provider.
+
+### Changes Required:
+
+#### 1. Install Dependencies
+**File**: `chartsmith-app/package.json`
+**Changes**: Add AI SDK packages
+
+```bash
+cd chartsmith-app && npm install ai @ai-sdk/anthropic @ai-sdk/react
+```
+
+#### 2. Configure Anthropic Provider
+**File**: `chartsmith-app/lib/ai/provider.ts` (new file)
+**Changes**: Create provider configuration
+
+```typescript
+import { createAnthropic } from '@ai-sdk/anthropic';
+
+export const anthropic = createAnthropic({
+ apiKey: process.env.ANTHROPIC_API_KEY,
+});
+
+// Default model for chat
+export const chatModel = anthropic('claude-3-7-sonnet-20250219');
+
+// Model for intent classification (faster/cheaper)
+export const intentModel = anthropic('claude-3-5-sonnet-20241022');
+```
+
+### Success Criteria:
+
+#### Automated Verification:
+- [x] Dependencies install without errors: `cd chartsmith-app && npm install`
+- [x] TypeScript compiles: `cd chartsmith-app && npm run build`
+- [x] No linting errors: `cd chartsmith-app && npm run lint`
+
+#### Manual Verification:
+- [x] Application starts successfully: `npm run dev`
+
+---
+
+## Phase 2: Migrate Intent Classification
+
+### Overview
+Replace the direct `@anthropic-ai/sdk` usage in `prompt-type.ts` with AI SDK's `generateText`.
+
+### Changes Required:
+
+#### 1. Update prompt-type.ts
+**File**: `chartsmith-app/lib/llm/prompt-type.ts`
+**Changes**: Replace Anthropic SDK with AI SDK
+
+```typescript
+import { generateText } from 'ai';
+import { intentModel } from '@/lib/ai/provider';
+import { logger } from "@/lib/utils/logger";
+
+export enum PromptType {
+ Plan = "plan",
+ Chat = "chat",
+}
+
+export enum PromptRole {
+ Packager = "packager",
+ User = "user",
+}
+
+export interface PromptIntent {
+ intent: PromptType;
+ role: PromptRole;
+}
+
+export async function promptType(message: string): Promise {
+ try {
+ const { text } = await generateText({
+ model: intentModel,
+ system: `You are ChartSmith, an expert at creating Helm charts for Kubernetes.
+You are invited to participate in an existing conversation between a user and an expert.
+The expert just provided a recommendation on how to plan the Helm chart to the user.
+The user is about to ask a question.
+You should decide if the user is asking for a change to the plan/chart, or if they are just asking a conversational question.
+Be exceptionally brief and precise in your response.
+Only say "plan" or "chat" in your response.`,
+ prompt: message,
+ maxTokens: 1024,
+ });
+
+ if (text.toLowerCase().includes("plan")) {
+ return PromptType.Plan;
+ } else {
+ return PromptType.Chat;
+ }
+ } catch (err) {
+ logger.error("Error determining prompt type", err);
+ throw err;
+ }
+}
+```
+
+### Success Criteria:
+
+#### Automated Verification:
+- [x] TypeScript compiles: `cd chartsmith-app && npm run build`
+- [x] No linting errors: `cd chartsmith-app && npm run lint`
+
+#### Manual Verification:
+- [ ] Intent classification works correctly when typing a message
+- [ ] "plan" type messages trigger plan creation
+- [ ] "chat" type messages trigger conversational response
+
+---
+
+## Phase 3: Create Chat API Route
+
+### Overview
+Create a Next.js API route that handles chat messages with streaming using AI SDK.
+
+### Changes Required:
+
+#### 1. Create Chat API Route
+**File**: `chartsmith-app/app/api/chat/route.ts` (new file)
+**Changes**: Implement streaming chat endpoint
+
+```typescript
+import { streamText, tool, convertToModelMessages, UIMessage } from 'ai';
+import { chatModel } from '@/lib/ai/provider';
+import { z } from 'zod';
+import { getWorkspaceContext } from '@/lib/ai/context';
+
+export const maxDuration = 60;
+
+export async function POST(req: Request) {
+ const { messages, workspaceId, chartId }: {
+ messages: UIMessage[];
+ workspaceId: string;
+ chartId?: string;
+ } = await req.json();
+
+ // Get workspace context (chart structure, relevant files, etc.)
+ const context = await getWorkspaceContext(workspaceId, chartId, messages);
+
+ const result = streamText({
+ model: chatModel,
+ system: context.systemPrompt,
+ messages: convertToModelMessages(messages),
+ tools: {
+ latest_subchart_version: tool({
+ description: 'Return the latest version of a subchart from name',
+ parameters: z.object({
+ chart_name: z.string().describe('The subchart name to get the latest version of'),
+ }),
+ execute: async ({ chart_name }) => {
+ // Call the existing recommendation service
+ const response = await fetch(
+ `${process.env.INTERNAL_API_URL}/api/recommendations/subchart/${encodeURIComponent(chart_name)}`
+ );
+ if (!response.ok) return '?';
+ const data = await response.json();
+ return data.version || '?';
+ },
+ }),
+ latest_kubernetes_version: tool({
+ description: 'Return the latest version of Kubernetes',
+ parameters: z.object({
+ semver_field: z.enum(['major', 'minor', 'patch']).describe('One of major, minor, or patch'),
+ }),
+ execute: async ({ semver_field }) => {
+ switch (semver_field) {
+ case 'major': return '1';
+ case 'minor': return '1.32';
+ case 'patch': return '1.32.1';
+ default: return '1.32.1';
+ }
+ },
+ }),
+ },
+ maxTokens: 8192,
+ });
+
+ return result.toUIMessageStreamResponse();
+}
+```
+
+#### 2. Create Context Helper
+**File**: `chartsmith-app/lib/ai/context.ts` (new file)
+**Changes**: Build context for LLM calls
+
+```typescript
+import { getWorkspace, listFilesForWorkspace } from '@/lib/workspace/workspace';
+import { getMostRecentPlan, listChatMessagesAfterPlan } from '@/lib/workspace/workspace';
+import { UIMessage } from 'ai';
+
+const CHAT_SYSTEM_PROMPT = `You are ChartSmith, an AI assistant specialized in creating and managing Helm charts for Kubernetes.
+You help developers and operators understand, modify, and improve their Helm charts.
+Be helpful, concise, and technical when appropriate.`;
+
+const CHAT_INSTRUCTIONS = `When answering questions:
+1. Consider the chart structure and existing files
+2. Reference specific files when relevant
+3. Provide code examples when helpful
+4. Be aware of Helm best practices`;
+
+export interface WorkspaceContext {
+ systemPrompt: string;
+ chartStructure: string;
+ relevantFiles: Array<{ path: string; content: string }>;
+}
+
+export async function getWorkspaceContext(
+ workspaceId: string,
+ chartId?: string,
+ messages?: UIMessage[]
+): Promise {
+ const workspace = await getWorkspace(workspaceId);
+ if (!workspace) {
+ throw new Error(`Workspace not found: ${workspaceId}`);
+ }
+
+ // Get chart structure
+ const chart = chartId
+ ? workspace.charts.find(c => c.id === chartId)
+ : workspace.charts[0];
+
+ const chartStructure = chart
+ ? chart.files.map(f => `File: ${f.filePath}`).join('\n')
+ : '';
+
+ // Get relevant files (limit to 10)
+ const files = await listFilesForWorkspace(workspaceId, workspace.currentRevisionNumber);
+ const relevantFiles = files.slice(0, 10).map(f => ({
+ path: f.filePath,
+ content: f.content,
+ }));
+
+ // Build system prompt with context
+ let systemPrompt = CHAT_SYSTEM_PROMPT + '\n\n' + CHAT_INSTRUCTIONS;
+
+ if (chartStructure) {
+ systemPrompt += `\n\nCurrent chart structure:\n${chartStructure}`;
+ }
+
+ // Add relevant file contents
+ for (const file of relevantFiles) {
+ systemPrompt += `\n\nFile: ${file.path}\n\`\`\`\n${file.content}\n\`\`\``;
+ }
+
+ // Get previous plan and chat history if available
+ try {
+ const plan = await getMostRecentPlan(workspaceId);
+ if (plan) {
+ systemPrompt += `\n\nMost recent plan:\n${plan.description}`;
+
+ const previousChats = await listChatMessagesAfterPlan(plan.id);
+ if (previousChats.length > 0) {
+ systemPrompt += '\n\nPrevious conversation context:';
+ for (const chat of previousChats.slice(-5)) {
+ if (chat.prompt) systemPrompt += `\nUser: ${chat.prompt}`;
+ if (chat.response) systemPrompt += `\nAssistant: ${chat.response}`;
+ }
+ }
+ }
+ } catch (err) {
+ // No plan exists, continue without it
+ }
+
+ return {
+ systemPrompt,
+ chartStructure,
+ relevantFiles,
+ };
+}
+```
+
+### Success Criteria:
+
+#### Automated Verification:
+- [x] TypeScript compiles: `cd chartsmith-app && npm run build`
+- [x] No linting errors: `cd chartsmith-app && npm run lint`
+- [x] API route responds to POST requests
+
+#### Manual Verification:
+- [ ] Streaming responses work when calling the API directly
+- [ ] Tool calls return correct values
+- [ ] Context includes chart structure and files
+
+---
+
+## Phase 4: Migrate ChatContainer to useChat
+
+### Overview
+Replace the custom chat state management with `useChat` hook while keeping Centrifugo for other events.
+
+### Changes Required:
+
+#### 1. Create Chat Hook Wrapper
+**File**: `chartsmith-app/hooks/useAIChat.ts` (new file)
+**Changes**: Wrap useChat with workspace-specific logic
+
+```typescript
+'use client';
+
+import { useChat, Message } from '@ai-sdk/react';
+import { useAtom } from 'jotai';
+import { workspaceAtom } from '@/atoms/workspace';
+import { useCallback, useEffect } from 'react';
+import { createChatMessageAction } from '@/lib/workspace/actions/create-chat-message';
+import { Session } from '@/lib/types/session';
+
+interface UseAIChatProps {
+ session: Session;
+ workspaceId: string;
+}
+
+export function useAIChat({ session, workspaceId }: UseAIChatProps) {
+ const [workspace] = useAtom(workspaceAtom);
+
+ const {
+ messages,
+ input,
+ setInput,
+ handleSubmit: baseHandleSubmit,
+ isLoading,
+ error,
+ stop,
+ reload,
+ append,
+ } = useChat({
+ api: '/api/chat',
+ body: {
+ workspaceId,
+ chartId: workspace?.charts[0]?.id,
+ },
+ onFinish: async (message) => {
+ // Persist the completed message to database for history
+ if (message.role === 'assistant') {
+ await persistMessage(session, workspaceId, message);
+ }
+ },
+ });
+
+ const handleSubmit = useCallback(async (e: React.FormEvent, role?: string) => {
+ e.preventDefault();
+ if (!input.trim() || isLoading) return;
+
+ // Persist user message to database before sending
+ await createChatMessageAction(session, workspaceId, input.trim(), role || 'auto');
+
+ baseHandleSubmit(e);
+ }, [input, isLoading, session, workspaceId, baseHandleSubmit]);
+
+ return {
+ messages,
+ input,
+ setInput,
+ handleSubmit,
+ isLoading,
+ error,
+ stop,
+ reload,
+ append,
+ };
+}
+
+async function persistMessage(session: Session, workspaceId: string, message: Message) {
+ // TODO: Implement message persistence for completed assistant messages
+ // This ensures chat history is saved in the database
+}
+```
+
+#### 2. Update ChatContainer
+**File**: `chartsmith-app/components/ChatContainer.tsx`
+**Changes**: Use useAIChat instead of custom implementation
+
+```typescript
+"use client";
+import React, { useState, useRef, useEffect } from "react";
+import { Send, Loader2, Users, Code, User, Sparkles } from "lucide-react";
+import { useTheme } from "../contexts/ThemeContext";
+import { Session } from "@/lib/types/session";
+import { ChatMessage } from "./ChatMessage";
+import { workspaceAtom, isRenderingAtom } from "@/atoms/workspace";
+import { useAtom } from "jotai";
+import { useAIChat } from "@/hooks/useAIChat";
+import { ScrollingContent } from "./ScrollingContent";
+import { NewChartChatMessage } from "./NewChartChatMessage";
+import { NewChartContent } from "./NewChartContent";
+
+interface ChatContainerProps {
+ session: Session;
+}
+
+export function ChatContainer({ session }: ChatContainerProps) {
+ const { theme } = useTheme();
+ const [workspace] = useAtom(workspaceAtom);
+ const [isRendering] = useAtom(isRenderingAtom);
+ const [selectedRole, setSelectedRole] = useState<"auto" | "developer" | "operator">("auto");
+ const [isRoleMenuOpen, setIsRoleMenuOpen] = useState(false);
+ const roleMenuRef = useRef(null);
+
+ const {
+ messages,
+ input,
+ setInput,
+ handleSubmit,
+ isLoading,
+ error,
+ } = useAIChat({
+ session,
+ workspaceId: workspace?.id || '',
+ });
+
+ // Close the 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);
+ };
+ }, []);
+
+ if (!workspace) {
+ return null;
+ }
+
+ const onSubmit = (e: React.FormEvent) => {
+ handleSubmit(e, selectedRole);
+ };
+
+ // ... rest of component remains similar but uses messages from useAIChat
+ // and uses isLoading instead of isRendering for send button state
+}
+```
+
+### Success Criteria:
+
+#### Automated Verification:
+- [x] TypeScript compiles: `cd chartsmith-app && npm run build`
+- [x] No linting errors: `cd chartsmith-app && npm run lint`
+
+#### Manual Verification:
+- [ ] Chat messages stream in real-time
+- [ ] Send button shows loading state while streaming
+- [ ] User can stop streaming mid-response
+- [ ] Messages persist to database
+- [ ] Role selection still works
+
+---
+
+## Phase 5: Integrate with Existing Flows
+
+### Overview
+Connect the AI SDK chat with existing workspace flows (plans, renders, etc.) and ensure Centrifugo still handles non-chat events.
+
+### Changes Required:
+
+#### 1. Update useCentrifugo to Skip Chat Events
+**File**: `chartsmith-app/hooks/useCentrifugo.ts`
+**Changes**: Only handle non-chat events since chat is now via HTTP streaming
+
+The `handleChatMessageUpdated` callback should now only update for system-generated messages (renders, plans), not for streaming chat responses which are handled by `useChat`.
+
+#### 2. Create Plan Intent Handler
+**File**: `chartsmith-app/app/api/chat/plan/route.ts` (new file)
+**Changes**: Handle plan-type intents that require different processing
+
+```typescript
+import { streamText, convertToModelMessages, UIMessage } from 'ai';
+import { chatModel } from '@/lib/ai/provider';
+import { createPlan } from '@/lib/workspace/workspace';
+import { enqueueWork } from '@/lib/utils/queue';
+
+export async function POST(req: Request) {
+ const { messages, workspaceId, userId }: {
+ messages: UIMessage[];
+ workspaceId: string;
+ userId: string;
+ } = await req.json();
+
+ // Create plan record and enqueue for Go worker processing
+ // Plans still use Go backend for execution
+ const lastMessage = messages[messages.length - 1];
+ const plan = await createPlan(userId, workspaceId, lastMessage.id);
+
+ await enqueueWork("new_plan", {
+ planId: plan.id,
+ });
+
+ // Return the plan ID for tracking
+ return Response.json({ planId: plan.id });
+}
+```
+
+#### 3. Update Message Persistence
+**File**: `chartsmith-app/lib/workspace/actions/persist-ai-message.ts` (new file)
+**Changes**: Persist AI SDK messages to database
+
+```typescript
+"use server"
+
+import { Message } from 'ai';
+import { getDB } from "@/lib/data/db";
+import { getParam } from "@/lib/data/param";
+import * as srs from "secure-random-string";
+
+export async function persistAIMessageAction(
+ workspaceId: string,
+ userId: string,
+ message: Message
+): Promise {
+ const client = getDB(await getParam("DB_URI"));
+ const chatMessageId = srs.default({ length: 12, alphanumeric: true });
+
+ const workspaceResult = await client.query(
+ `SELECT current_revision_number FROM workspace WHERE id = $1`,
+ [workspaceId]
+ );
+
+ if (workspaceResult.rows.length === 0) {
+ throw new Error(`Workspace not found: ${workspaceId}`);
+ }
+
+ const currentRevisionNumber = workspaceResult.rows[0].current_revision_number;
+
+ await client.query(
+ `INSERT INTO workspace_chat (
+ id, workspace_id, created_at, sent_by, prompt, response,
+ revision_number, is_canceled, is_intent_complete,
+ is_intent_conversational, is_intent_plan, is_intent_off_topic
+ ) VALUES ($1, $2, now(), $3, $4, $5, $6, false, true, true, false, false)`,
+ [
+ chatMessageId,
+ workspaceId,
+ userId,
+ message.role === 'user' ? message.content : null,
+ message.role === 'assistant' ? message.content : null,
+ currentRevisionNumber,
+ ]
+ );
+}
+```
+
+### Success Criteria:
+
+#### Automated Verification:
+- [x] TypeScript compiles: `cd chartsmith-app && npm run build`
+- [x] No linting errors: `cd chartsmith-app && npm run lint`
+
+#### Manual Verification:
+- [ ] Plan intents are correctly detected and trigger plan creation
+- [ ] Render events still work via Centrifugo
+- [ ] Artifact updates still work via Centrifugo
+- [ ] Chat history persists correctly in database
+- [ ] Previous conversation context is included in prompts
+
+---
+
+## Phase 6: Cleanup and Testing
+
+### Overview
+Remove deprecated code, update tests, and verify all functionality.
+
+### Changes Required:
+
+#### 1. Remove Direct Anthropic SDK Dependency
+**File**: `chartsmith-app/package.json`
+**Changes**: Remove `@anthropic-ai/sdk` dependency (after confirming all uses are migrated)
+
+#### 2. Update ARCHITECTURE.md
+**File**: `chartsmith-app/ARCHITECTURE.md`
+**Changes**: Document the new AI SDK integration
+
+```markdown
+## AI Integration
+
+This application uses Vercel AI SDK for LLM interactions:
+
+- **Provider**: `@ai-sdk/anthropic` - Anthropic Claude models
+- **UI Hook**: `useChat` from `@ai-sdk/react` - Manages chat state and streaming
+- **Core**: `streamText` from `ai` - Handles streaming in API routes
+
+### Chat Flow
+1. User sends message via `ChatContainer` component
+2. `useChat` hook sends request to `/api/chat` endpoint
+3. API route uses `streamText` with context from workspace
+4. Response streams directly to client via HTTP
+5. Completed messages are persisted to database
+
+### Non-Chat Real-time Events
+Centrifugo WebSocket is still used for:
+- Render progress updates
+- Artifact/file changes
+- Plan status updates
+- Revision creation notifications
+```
+
+#### 3. Update Tests
+**File**: Various test files
+**Changes**: Update tests to work with AI SDK mocks
+
+### Success Criteria:
+
+#### Automated Verification:
+- [x] Full build succeeds: `cd chartsmith-app && npm run build`
+- [x] All tests pass: `cd chartsmith-app && npm test` (unit tests pass; e2e tests have env config issue)
+- [x] Linting passes: `cd chartsmith-app && npm run lint`
+- [x] No unused dependencies: `npm audit` (pre-existing vulnerabilities in dev deps only)
+
+#### Manual Verification:
+- [ ] Create a new chart via chat - streaming works
+- [ ] Ask conversational questions - responses are helpful
+- [ ] Request chart modifications - plans are created
+- [ ] Helm render output displays correctly
+- [ ] Multiple messages maintain conversation context
+- [ ] Application handles errors gracefully
+- [ ] Provider could be swapped (test with mock OpenAI)
+
+---
+
+## Testing Strategy
+
+### Unit Tests
+- [x] Test intent classification returns correct types (`lib/llm/__tests__/prompt-type.test.ts`)
+- [x] Test context builder includes chart structure (`lib/ai/__tests__/context.test.ts`)
+- [x] Test tool handlers return correct values (`app/api/chat/__tests__/route.test.ts`)
+- [x] Test message persistence saves correctly (`lib/workspace/actions/__tests__/persist-ai-message.test.ts`)
+
+### Integration Tests
+- Test full chat flow from UI to database
+- Test streaming responses render correctly
+- Test Centrifugo events still work for renders
+
+### Manual Testing Steps
+1. Start application: `npm run dev`
+2. Create new workspace
+3. Type a question and verify streaming response
+4. Ask for a chart modification and verify plan is created
+5. Check that previous messages provide context
+6. Verify helm render output appears correctly
+7. Check database for persisted messages
+
+## Performance Considerations
+
+- HTTP streaming is more efficient than WebSocket for chat (direct connection)
+- Keep Centrifugo for pub/sub style updates (renders to multiple tabs)
+- Context window limits: Cap file content included in prompts
+- Token usage: Monitor with AI SDK's usage tracking
+
+## Migration Notes
+
+### Breaking Changes
+- Chat responses now stream via HTTP instead of Centrifugo
+- `messagesAtom` usage changes for chat (now from useChat)
+
+### Rollback Plan
+If issues arise:
+1. Keep old `createChatMessageAction` with `enqueueWork("new_intent")`
+2. Revert ChatContainer to use messagesAtom
+3. AI SDK code can coexist with old implementation
+
+### Go Backend Impact
+- `pkg/llm/conversational.go` will no longer be called for chat
+- `pkg/listener/new_intent.go` chat handling can be removed
+- Keep Go LLM for plans and specialized operations initially
+
+## Implementation Notes (2025-12-10)
+
+### Model Configuration Changes
+
+The original plan specified older model versions that are no longer available. Updated to:
+
+**TypeScript (AI SDK)** - `chartsmith-app/lib/ai/provider.ts`:
+- `chatModel`: `claude-sonnet-4-5-20250929`
+- `intentModel`: `claude-sonnet-4-5-20250929`
+
+**Go Backend** - `pkg/llm/execute-action.go`:
+- `Model_Sonnet4`: `claude-sonnet-4-5-20250929` (used for plan execution)
+- Added `TextEditor_Sonnet4`: `text_editor_20250514`
+
+### AI SDK Streaming Enabled by Default
+
+Changed `ChatContainer.tsx` to enable AI SDK streaming by default:
+```typescript
+const [useAIStreaming, setUseAIStreaming] = useState(true);
+```
+
+### Architecture Clarification
+
+- **AI SDK (TypeScript)**: Handles intent classification + conversational chat streaming
+- **Go + Anthropic SDK**: Handles plan generation + plan execution (file edits)
+
+Plan mode does NOT use AI SDK - it creates a plan record and enqueues work for the Go backend.
+
+---
+
+## References
+
+- Original spec: `thoughts/shared/spec.md`
+- Research: `thoughts/shared/research/2025-12-06-anthropic-sdk-chat-ui-integration.md`
+- Vercel AI SDK docs: https://ai-sdk.dev/docs
+- Anthropic provider: https://ai-sdk.dev/providers/anthropic