diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md deleted file mode 100644 index d1d8d590..00000000 --- a/ARCHITECTURE.md +++ /dev/null @@ -1,27 +0,0 @@ -# Architecture - -This file exists to describe the principles of this code architecture. -It's made for both the developer working on it and for AI models to read and apply when making changes. - -## Key Architecture Principles -- The Frontend is a NextJS application in chartsmith-app -- The's a single worker, written in go, run with `make run-worker`. -- We have a Postres/pgvector database and Centrifugo for realtime notifications. -- The intent is to keep this system design and avoid new databases, queues, components. Simplicity matters. - -## API Design Principles -- Prefer consolidated data endpoints over granular ones to minimize API calls and database load -- Structure API routes using Next.js's file-based routing with resource-oriented paths -- Implement consistent authentication and error handling patterns across endpoints -- Return complete data objects rather than fragments to reduce follow-up requests -- Prioritize server-side data processing over client-side assembly of multiple API calls - - -# Subprojects -- See chartsmith-app/ARCHITECTURE.md for the architecture principles for the front end. - - -## 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 diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index f9479fff..00000000 --- a/CLAUDE.md +++ /dev/null @@ -1,12 +0,0 @@ -See the following files for details: - -ARCHITECTURE.md: Our core design principles -chartsmith-app/ARCHITECTURE.md: Our design principles for the frontend - -CONTRIBUTING.md: How to run and test this project -chartsmith-app/CONTRIBUTING.md: How to run and test the frontend - -- `/chart.go:106:2: declared and not used: repoUrl -pkg/workspace/chart.go:169:41: cannot use conn (variable of type *pgxpool.Conn) as *pgxpool.Pool value in argument to updatePublishStatus -pkg/workspace/chart.go:178:40: cannot use conn (variable of type *pgxpool.Conn) as *pgxpool.Pool value in argument to updatePublishStatus -make: *** [build] Error 1` \ No newline at end of file diff --git a/chartsmith-app/ARCHITECTURE.md b/chartsmith-app/ARCHITECTURE.md deleted file mode 100644 index 93e7d7b4..00000000 --- a/chartsmith-app/ARCHITECTURE.md +++ /dev/null @@ -1,23 +0,0 @@ -# Architecture and Design for Chartsmith-app - -This is a next.js project that is the front end for chartsmith. - -## 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 -- 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. -- 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. -- 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/CLAUDE.md b/chartsmith-app/CLAUDE.md deleted file mode 100644 index 5f5fdb75..00000000 --- a/chartsmith-app/CLAUDE.md +++ /dev/null @@ -1,4 +0,0 @@ -See the following files for details: - -ARCHITECTURE.md: Our core design principles -CONTRIBUTING.md: How to run and test this project diff --git a/chartsmith-app/CONTRIBUTING.md b/chartsmith-app/CONTRIBUTING.md deleted file mode 100644 index 69f18694..00000000 --- a/chartsmith-app/CONTRIBUTING.md +++ /dev/null @@ -1,19 +0,0 @@ -# Contributing to chartsmith-app - -## Commands -- Build/start: `npm run dev` - Starts Next.js development server -- Lint: `npm run lint` - Run ESLint -- Typecheck: `npm run typecheck` - Check TypeScript types -- Test: `npm test` - Run Jest tests -- Single test: `npm test -- -t "test name"` - Run a specific test - -## Code Style -- **Imports**: Group imports by type (React, components, utils, types) -- **Components**: Use functional components with React hooks -- **TypeScript**: Use explicit typing, avoid `any` -- **State Management**: Use Jotai for global state -- **Naming**: PascalCase for components, camelCase for variables/functions -- **Styling**: Use Tailwind CSS with descriptive class names -- **Error Handling**: Use try/catch blocks with consistent error logging -- **File Organization**: Group related components in folders -- **Editor**: Monaco editor instances should be carefully managed to prevent memory leaks diff --git a/chartsmith-app/app/api/chat/conversational/route.ts b/chartsmith-app/app/api/chat/conversational/route.ts new file mode 100644 index 00000000..84601be5 --- /dev/null +++ b/chartsmith-app/app/api/chat/conversational/route.ts @@ -0,0 +1,291 @@ +/** + * Vercel AI SDK Chat API Route + * + * This demonstrates how to migrate the conversational chat from Go to Next.js using Vercel AI SDK. + * This is a complete implementation showing all key features from pkg/llm/conversational.go + * + * Key features demonstrated: + * - Vercel AI SDK streamText() for streaming responses + * - Tool calling (latest_subchart_version, latest_kubernetes_version) + * - System prompts preservation + * - Context injection (chart structure, relevant files) + * - Centrifugo real-time publishing + * - Database integration for message persistence + */ + +import { streamText, tool } from 'ai'; +import { createAnthropic } from '@ai-sdk/anthropic'; +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; +import { getChatMessage, getWorkspace } from '@/lib/workspace/workspace'; +import { appendChatMessageResponse, clearChatMessageResponse, markChatMessageComplete, getWorkspaceIdForChatMessage } from '@/lib/workspace/chat-helpers'; +import { publishChatMessageUpdate } from '@/lib/realtime/centrifugo-publish'; +import { getChartStructure, chooseRelevantFiles, getPreviousChatHistory } from '@/lib/workspace/context'; +import { getLatestSubchartVersion } from '@/lib/recommendations/subchart'; + +export const runtime = 'nodejs'; +export const maxDuration = 300; // 5 minutes + +// System prompts from pkg/llm/system.go +const CHAT_SYSTEM_PROMPT = `You are ChartSmith, an expert AI assistant and a highly skilled senior software developer specializing in the creation, improvement, and maintenance of Helm charts. + Your primary responsibility is to help users transform, refine, and optimize Helm charts based on a variety of inputs, including: + +- Existing Helm charts that need adjustments, improvements, or best-practice refinements. + +Your guidance should be exhaustive, thorough, and precisely tailored to the user's needs. +Always ensure that your output is a valid, production-ready Helm chart setup adhering to Helm best practices. +If the user provides partial information (e.g., a single Deployment manifest, a partial Chart.yaml, or just an image and port configuration), you must integrate it into a coherent chart. +Requests will always be based on a existing Helm chart and you must incorporate modifications while preserving and improving the chart's structure (do not rewrite the chart for each request). + +Below are guidelines and constraints you must always follow: + + + - Focus exclusively on tasks related to Helm charts and Kubernetes manifests. Do not address topics outside of Kubernetes, Helm, or their associated configurations. + - Assume a standard Kubernetes environment, where Helm is available. + - Do not assume any external services (e.g., cloud-hosted registries or databases) unless the user's scenario explicitly includes them. + - Do not rely on installing arbitrary tools; you are guiding and generating Helm chart files and commands only. + - Incorporate changes into the most recent version of files. Make sure to provide complete updated file contents. + + + + - Use 2 spaces for indentation in all YAML files. + - Ensure YAML and Helm templates are valid, syntactically correct, and adhere to Kubernetes resource definitions. + - Use proper Helm templating expressions ({{ ... }}) where appropriate. For example, parameterize image tags, resource counts, ports, and labels. + - Keep the chart well-structured and maintainable. + + + + - Use only valid Markdown for your responses unless required by the instructions below. + - Do not use HTML elements. + - Communicate in plain Markdown. Inside these tags, produce only the required YAML, shell commands, or file contents. + + +NEVER use the word "artifact" in your final messages to the user. + + + - You will be asked to answer a question. + - You will be given the question and the context of the question. + - You will be given the current chat history. + - You will be asked to answer the question based on the context and the chat history. + - You can provide small examples of code, but just use markdown. +`; + +const CHAT_INSTRUCTIONS = `- You will be asked to answer a question. +- You will be given the question and the context of the question. +- You will be given the current chat history. +- You will be asked to answer the question based on the context and the chat history. +- You can be technical in your response and include inline code snippets identifed with Markdown when appropriate. +- Never use the tag in your response.`; + +export async function POST(req: NextRequest) { + const startTime = Date.now(); + let chatMessageId: string | undefined; + + try { + // Validate Authorization header using dedicated internal API token + // This prevents leaking the Anthropic API key via request headers + const authHeader = req.headers.get('authorization'); + if (!authHeader || !authHeader.startsWith('Bearer ')) { + console.error('[CHAT API] Missing or invalid Authorization header'); + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const token = authHeader.substring(7); // Remove 'Bearer ' prefix + const expectedToken = process.env.INTERNAL_API_TOKEN; + + if (!expectedToken) { + console.error('[CHAT API] INTERNAL_API_TOKEN not configured'); + return NextResponse.json({ error: 'Server misconfigured' }, { status: 500 }); + } + + if (token !== expectedToken) { + console.error('[CHAT API] Invalid Bearer token'); + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + // Get chat message ID from request + const body = await req.json(); + chatMessageId = body.chatMessageId; + + console.log(`[CHAT API] Starting request for chatMessageId=${chatMessageId}`); + + if (!chatMessageId) { + console.error('[CHAT API] Missing chatMessageId in request'); + return NextResponse.json({ error: 'chatMessageId is required' }, { status: 400 }); + } + + // Fetch the chat message from database + console.log(`[CHAT API] Fetching chat message from database...`); + const chatMessage = await getChatMessage(chatMessageId); + if (!chatMessage) { + console.error(`[CHAT API] Chat message not found: ${chatMessageId}`); + return NextResponse.json({ error: 'Chat message not found' }, { status: 404 }); + } + console.log(`[CHAT API] Found chat message. Prompt: "${chatMessage.prompt.substring(0, 100)}..."`); + + // Get workspace ID and user ID for Centrifugo publishing + const workspaceId = await getWorkspaceIdForChatMessage(chatMessageId); + const userId = chatMessage.userId; + + if (!userId) { + console.error(`[CHAT API] Chat message missing userId: ${chatMessageId}`); + return NextResponse.json({ error: 'Chat message missing userId' }, { status: 400 }); + } + + console.log(`[CHAT API] workspaceId=${workspaceId}, userId=${userId}`); + + // Clear any existing response to prevent duplication on retry + await clearChatMessageResponse(chatMessageId); + console.log(`[CHAT API] Cleared existing response`); + + // Initialize Anthropic with Vercel AI SDK + const anthropic = createAnthropic({ + apiKey: process.env.ANTHROPIC_API_KEY, + }); + console.log(`[CHAT API] Initialized Anthropic client`); + + // Define tools (from pkg/llm/conversational.go) + const 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 }) => { + try { + const version = await getLatestSubchartVersion(chart_name); + return version; + } catch (error) { + console.error(`Failed to get subchart version for ${chart_name}:`, error); + return 'unknown'; + } + }, + }), + 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'; + } + }, + }), + }; + + // Get workspace and chart context + console.log(`[CHAT API] Loading workspace context...`); + const workspace = await getWorkspace(workspaceId); + if (!workspace) { + console.error(`[CHAT API] Workspace not found: ${workspaceId}`); + return NextResponse.json({ error: 'Workspace not found' }, { status: 404 }); + } + + const chartStructure = await getChartStructure(workspace); + const relevantFiles = await chooseRelevantFiles(workspace, chatMessage.prompt, undefined, 10); + const chatHistory = await getPreviousChatHistory(workspaceId, chatMessageId); + console.log(`[CHAT API] Context loaded: ${relevantFiles.length} files, ${chatHistory.length} history messages`); + + // Build system prompt (includes instructions and chart context) + const systemPrompt = `${CHAT_SYSTEM_PROMPT} + +${CHAT_INSTRUCTIONS} + +I am working on a Helm chart that has the following structure: ${chartStructure} + +${relevantFiles.map(file => `File: ${file.filePath}, Content: ${file.content}`).join('\n\n')}`; + + // Build messages array (conversation history + current message) + const messages = [ + // Add conversation history + ...chatHistory, + // User's current message + { role: 'user' as const, content: chatMessage.prompt }, + ]; + + console.log(`[CHAT API] Built system prompt and ${messages.length} messages, calling streamText()...`); + + let chunkCount = 0; + let totalChars = 0; + + // Stream the response using Vercel AI SDK + const result = streamText({ + model: anthropic('claude-3-5-sonnet-20241022'), + system: systemPrompt, + messages, + tools, + onChunk: async ({ chunk }) => { + chunkCount++; + console.log(`[CHAT API] onChunk called (chunk #${chunkCount}): type=${chunk.type}`); + + // Handle text delta chunks + if (chunk.type === 'text-delta') { + const textChunk = chunk.text; + totalChars += textChunk.length; + console.log(`[CHAT API] text-delta chunk: ${textChunk.length} chars (total: ${totalChars})`); + + try { + // 1. Append to database + await appendChatMessageResponse(chatMessageId!, textChunk); + console.log(`[CHAT API] Saved chunk to database`); + + // 2. Publish to Centrifugo for real-time updates + await publishChatMessageUpdate(workspaceId, userId, chatMessageId!, textChunk, false); + console.log(`[CHAT API] Published chunk to Centrifugo`); + } catch (err) { + console.error(`[CHAT API] Error processing chunk:`, err); + throw err; + } + } + }, + onFinish: async () => { + console.log(`[CHAT API] onFinish called. Total chunks: ${chunkCount}, total chars: ${totalChars}`); + + try { + // Mark message as complete + await markChatMessageComplete(chatMessageId!); + console.log(`[CHAT API] Marked message complete in database`); + + // Publish final completion event + await publishChatMessageUpdate(workspaceId, userId, chatMessageId!, '', true); + console.log(`[CHAT API] Published completion to Centrifugo`); + } catch (err) { + console.error(`[CHAT API] Error in onFinish:`, err); + throw err; + } + }, + }); + + console.log(`[CHAT API] Waiting for streamText to complete...`); + + // Wait for completion + const fullText = await result.text; + + const duration = Date.now() - startTime; + console.log(`[CHAT API] Completed successfully in ${duration}ms. Response length: ${fullText.length} chars`); + + return NextResponse.json({ success: true }); + + } catch (error) { + const duration = Date.now() - startTime; + console.error(`[CHAT API] Error after ${duration}ms:`, error); + console.error(`[CHAT API] Error stack:`, error instanceof Error ? error.stack : 'No stack trace'); + + return NextResponse.json( + { + error: 'Internal Server Error', + details: error instanceof Error ? error.message : String(error), + chatMessageId + }, + { status: 500 } + ); + } +} diff --git a/chartsmith-app/components/types.ts b/chartsmith-app/components/types.ts index f4a12fed..a6ba4d3a 100644 --- a/chartsmith-app/components/types.ts +++ b/chartsmith-app/components/types.ts @@ -107,6 +107,10 @@ export interface CentrifugoMessageData { status?: string; completedAt?: string; isAutorender?: boolean; + // Conversational chat streaming fields + id?: string; + chunk?: string; + isComplete?: boolean; } export interface RawRevision { diff --git a/chartsmith-app/hooks/useCentrifugo.ts b/chartsmith-app/hooks/useCentrifugo.ts index 2fcc7fd8..ddab030a 100644 --- a/chartsmith-app/hooks/useCentrifugo.ts +++ b/chartsmith-app/hooks/useCentrifugo.ts @@ -46,6 +46,10 @@ export function useCentrifugo({ const [isReconnecting, setIsReconnecting] = useState(false); + // Buffer for chunks that arrive before the message is loaded + // Store both chunks and completion state + const pendingChunksRef = useRef>(new Map()); + const [workspace, setWorkspace] = useAtom(workspaceAtom) const [, setRenders] = useAtom(rendersAtom) const [, setMessages] = useAtom(messagesAtom) @@ -80,13 +84,69 @@ export function useCentrifugo({ setWorkspace(freshWorkspace); const updatedMessages = await getWorkspaceMessagesAction(session, revision.workspaceId); - setMessages(updatedMessages); + + // Apply any buffered chunks to newly loaded messages + const messagesWithBuffered = updatedMessages.map(msg => { + const buffered = pendingChunksRef.current.get(msg.id); + if (buffered) { + console.log(`[Centrifugo] Applying buffered chunks to message ${msg.id}: ${buffered.chunks.length} chars`); + pendingChunksRef.current.delete(msg.id); + return { + ...msg, + response: (msg.response || '') + buffered.chunks, + isComplete: buffered.isComplete, + isIntentComplete: buffered.isComplete, + }; + } + return msg; + }); + + setMessages(messagesWithBuffered); setChartsBeforeApplyingContentPending([]); } }, [session, setMessages, setWorkspace]); const handleChatMessageUpdated = useCallback((data: CentrifugoMessageData) => { + // Handle conversational chat streaming format (new Vercel AI SDK format) + if (data.id && data.chunk !== undefined) { + setMessages(prev => { + const newMessages = [...prev]; + const index = newMessages.findIndex(m => m.id === data.id); + + if (index >= 0) { + const existingMessage = newMessages[index]; + + // Simply append the new chunk + // Don't apply pending chunks here - they're already in the DB response + const updatedResponse = (existingMessage.response || '') + data.chunk; + + newMessages[index] = { + ...existingMessage, + response: updatedResponse, + isComplete: data.isComplete || false, + isIntentComplete: data.isComplete || false, + }; + + // Clear any pending chunks since message is now in state and being updated + if (data.id && pendingChunksRef.current.has(data.id)) { + pendingChunksRef.current.delete(data.id); + } + } else if (data.id) { + // Message not yet in state - buffer the chunk AND completion state + console.warn(`[Centrifugo] Buffering chunk for message not yet in state: ${data.id}`); + const existingBuffer = pendingChunksRef.current.get(data.id) || { chunks: '', isComplete: false }; + pendingChunksRef.current.set(data.id, { + chunks: existingBuffer.chunks + (data.chunk || ''), + isComplete: data.isComplete || existingBuffer.isComplete + }); + } + return newMessages; + }); + return; + } + + // Handle legacy chat message format if (!data.chatMessage) return; const chatMessage = data.chatMessage; diff --git a/chartsmith-app/lib/llm/prompt-type.ts b/chartsmith-app/lib/llm/prompt-type.ts index b56ea031..44720570 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 { createAnthropic } from '@ai-sdk/anthropic'; import { logger } from "@/lib/utils/logger"; export enum PromptType { @@ -18,13 +19,12 @@ export interface PromptIntent { export async function promptType(message: string): Promise { try { - const anthropic = new Anthropic({ + const anthropic = createAnthropic({ apiKey: process.env.ANTHROPIC_API_KEY, }); - const msg = await anthropic.messages.create({ - model: "claude-3-5-sonnet-20241022", - max_tokens: 1024, + const { text } = await generateText({ + model: anthropic('claude-3-5-sonnet-20241022'), system: `You are ChartSmith, an expert at creating Helm charts for Kuberentes. 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. @@ -33,10 +33,8 @@ You should decide if the user is asking for a change to the plan/chart, or if th 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 : ''; + prompt: message, + }); if (text.toLowerCase().includes("plan")) { return PromptType.Plan; diff --git a/chartsmith-app/lib/realtime/centrifugo-publish.ts b/chartsmith-app/lib/realtime/centrifugo-publish.ts new file mode 100644 index 00000000..8a45afb8 --- /dev/null +++ b/chartsmith-app/lib/realtime/centrifugo-publish.ts @@ -0,0 +1,106 @@ +import { logger } from "../utils/logger"; +import { getParam } from "../data/param"; +import { getDB } from "../data/db"; +import * as srs from "secure-random-string"; + +interface CentrifugoPublishData { + eventType: string; + data: any; +} + +/** + * Publishes a message to Centrifugo and stores it for replay + */ +export async function publishToCentrifugo( + workspaceId: string, + userId: string, + event: CentrifugoPublishData +): Promise { + try { + const centrifugoApiUrl = process.env.CENTRIFUGO_API_URL || "http://localhost:8000/api"; + const centrifugoApiKey = process.env.CENTRIFUGO_API_KEY; + + if (!centrifugoApiKey) { + throw new Error("CENTRIFUGO_API_KEY is not set"); + } + + const channel = `${workspaceId}#${userId}`; + + // Flatten the event data to match what the frontend expects + // Frontend expects: { eventType: "...", ...otherFields } + const messageData = { + eventType: event.eventType, + workspaceId, + ...event.data, + }; + + // Publish to Centrifugo + const response = await fetch(`${centrifugoApiUrl}/publish`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-API-Key": centrifugoApiKey, + }, + body: JSON.stringify({ + channel, + data: messageData, + }), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Centrifugo publish failed: ${response.status} ${errorText}`); + } + + // Store for replay + await storeReplayEvent(userId, messageData); + + logger.debug("Published to Centrifugo", { channel, eventType: event.eventType }); + } catch (err) { + logger.error("Failed to publish to Centrifugo", { err, workspaceId, userId }); + // Don't throw - we don't want to break the chat flow if Centrifugo is down + } +} + +/** + * Stores an event for replay when clients reconnect + */ +async function storeReplayEvent(userId: string, messageData: any): Promise { + try { + const db = getDB(await getParam("DB_URI")); + const id = srs.default({ length: 12, alphanumeric: true }); + + await db.query( + `INSERT INTO realtime_replay (id, user_id, message_data, created_at) VALUES ($1, $2, $3, NOW())`, + [id, userId, JSON.stringify(messageData)] + ); + + // Clean up old replay events (older than 10 seconds) + await db.query( + `DELETE FROM realtime_replay WHERE created_at < NOW() - INTERVAL '10 seconds'` + ); + } catch (err) { + logger.error("Failed to store replay event", { err }); + // Don't throw + } +} + +/** + * Publishes a chat message update event + */ +export async function publishChatMessageUpdate( + workspaceId: string, + userId: string, + chatMessageId: string, + chunk: string, + isComplete: boolean +): Promise { + await publishToCentrifugo(workspaceId, userId, { + eventType: "chatmessage-updated", + data: { + id: chatMessageId, + chunk, + isComplete, + }, + }); +} diff --git a/chartsmith-app/lib/recommendations/subchart.ts b/chartsmith-app/lib/recommendations/subchart.ts new file mode 100644 index 00000000..e481d2ba --- /dev/null +++ b/chartsmith-app/lib/recommendations/subchart.ts @@ -0,0 +1,92 @@ +// Subchart version lookup utilities +// Ported from pkg/recommendations/subchart.go + +interface ArtifactHubPackage { + name: string; + version: string; + app_version: string; +} + +interface ArtifactHubResponse { + packages: ArtifactHubPackage[]; +} + +// Override map for pinned versions +const subchartVersion: Record = { + // Add specific version overrides here if needed +}; + +// Cache for Replicated subchart version +let replicatedSubchartVersion = '0.0.0'; +let replicatedSubchartVersionNextFetch = new Date(); + +export async function getLatestSubchartVersion(chartName: string): Promise { + // Check override map first + if (subchartVersion[chartName]) { + return subchartVersion[chartName]; + } + + // Special handling for Replicated charts + if (chartName.toLowerCase().includes('replicated')) { + return getReplicatedSubchartVersion(); + } + + // Search Artifact Hub + const bestChart = await searchArtifactHubForChart(chartName); + if (!bestChart) { + throw new Error('No artifact hub package found'); + } + + return bestChart.version; +} + +async function searchArtifactHubForChart(chartName: string): Promise { + const encodedChartName = encodeURIComponent(chartName); + const url = `https://artifacthub.io/api/v1/packages/search?offset=0&limit=20&facets=false&ts_query_web=${encodedChartName}&kind=0&deprecated=false&sort=relevance`; + + const response = await fetch(url, { + headers: { + 'User-Agent': 'chartsmith/1.0', + 'Accept': 'application/json', + }, + }); + + if (!response.ok) { + throw new Error(`Failed to search Artifact Hub: ${response.statusText}`); + } + + const data: ArtifactHubResponse = await response.json(); + + if (data.packages.length === 0) { + return null; + } + + return data.packages[0]; +} + +async function getReplicatedSubchartVersion(): Promise { + // Return cached version if still valid + if (replicatedSubchartVersionNextFetch > new Date()) { + return replicatedSubchartVersion; + } + + // Fetch latest release from GitHub + const response = await fetch('https://api.github.com/repos/replicatedhq/replicated-sdk/releases/latest', { + headers: { + 'User-Agent': 'chartsmith/1.0', + 'Accept': 'application/json', + }, + }); + + if (!response.ok) { + throw new Error(`Failed to fetch Replicated version: ${response.statusText}`); + } + + const release = await response.json(); + replicatedSubchartVersion = release.tag_name; + + // Cache for 45 minutes + replicatedSubchartVersionNextFetch = new Date(Date.now() + 45 * 60 * 1000); + + return replicatedSubchartVersion; +} diff --git a/chartsmith-app/lib/suppress-monaco-errors.ts b/chartsmith-app/lib/suppress-monaco-errors.ts new file mode 100644 index 00000000..2b94697f --- /dev/null +++ b/chartsmith-app/lib/suppress-monaco-errors.ts @@ -0,0 +1,60 @@ +// Suppress Monaco Editor's harmless "TextModel got disposed" errors +if (typeof window !== 'undefined') { + // Intercept console.error + const originalConsoleError = console.error; + console.error = (...args: any[]) => { + const firstArg = args[0]; + const errorMessage = typeof firstArg === 'string' ? firstArg : firstArg?.message || ''; + + // Suppress Monaco disposal errors + if (errorMessage.includes('TextModel got disposed') || + errorMessage.includes('DiffEditorWidget')) { + return; + } + originalConsoleError.apply(console, args); + }; + + // Intercept console.warn as Monaco sometimes logs as warnings + const originalConsoleWarn = console.warn; + console.warn = (...args: any[]) => { + const firstArg = args[0]; + const errorMessage = typeof firstArg === 'string' ? firstArg : firstArg?.message || ''; + + if (errorMessage.includes('TextModel got disposed') || + errorMessage.includes('DiffEditorWidget')) { + return; + } + originalConsoleWarn.apply(console, args); + }; + + // Override window.onerror to catch uncaught errors + const originalOnError = window.onerror; + window.onerror = (message, source, lineno, colno, error) => { + const errorMessage = typeof message === 'string' ? message : error?.message || ''; + if (errorMessage.includes('TextModel got disposed') || + errorMessage.includes('DiffEditorWidget')) { + return true; // Prevent default error handling + } + if (originalOnError) { + return originalOnError(message, source, lineno, colno, error); + } + return false; + }; + + // Override unhandledrejection for promise-based errors + const originalOnUnhandledRejection = window.onunhandledrejection; + window.onunhandledrejection = (event) => { + const errorMessage = event.reason?.message || event.reason || ''; + if (typeof errorMessage === 'string' && + (errorMessage.includes('TextModel got disposed') || + errorMessage.includes('DiffEditorWidget'))) { + event.preventDefault(); + return; + } + if (originalOnUnhandledRejection) { + originalOnUnhandledRejection.call(window, event); + } + }; +} + +export {}; diff --git a/chartsmith-app/lib/types/workspace.ts b/chartsmith-app/lib/types/workspace.ts index 9e0d11e1..7a184e30 100644 --- a/chartsmith-app/lib/types/workspace.ts +++ b/chartsmith-app/lib/types/workspace.ts @@ -1,5 +1,4 @@ import { Message } from "@/components/types"; -import { ChatMessageFromPersona } from "../workspace/workspace"; export interface Workspace { id: string; @@ -135,7 +134,7 @@ export interface ChatMessage { isApplying?: boolean; isIgnored?: boolean; planId?: string; - messageFromPersona?: ChatMessageFromPersona; + messageFromPersona?: string; } export interface FollowupAction { diff --git a/chartsmith-app/lib/workspace/actions/create-chat-message.ts b/chartsmith-app/lib/workspace/actions/create-chat-message.ts index 302e08fa..ae68e4e5 100644 --- a/chartsmith-app/lib/workspace/actions/create-chat-message.ts +++ b/chartsmith-app/lib/workspace/actions/create-chat-message.ts @@ -5,5 +5,11 @@ import { ChatMessageFromPersona, createChatMessage } from "../workspace"; import { ChatMessage } from "@/lib/types/workspace"; export async function createChatMessageAction(session: Session, workspaceId: string, message: string, messageFromPersona: string): Promise { - return await createChatMessage(session.user.id, workspaceId, { prompt: message, messageFromPersona: messageFromPersona as ChatMessageFromPersona }); + // Let intent classification determine whether this is a plan or conversational message + // Don't force NON_PLAN intent - the worker will classify appropriately + return await createChatMessage(session.user.id, workspaceId, { + prompt: message, + messageFromPersona: messageFromPersona as ChatMessageFromPersona, + knownIntent: undefined + }); } diff --git a/chartsmith-app/lib/workspace/actions/delete-workspace.ts b/chartsmith-app/lib/workspace/actions/delete-workspace.ts index 07043308..e9d3bdfd 100644 --- a/chartsmith-app/lib/workspace/actions/delete-workspace.ts +++ b/chartsmith-app/lib/workspace/actions/delete-workspace.ts @@ -2,9 +2,12 @@ import { Session } from "@/lib/types/session"; import { AppError } from "@/lib/utils/error"; +import { deleteWorkspace } from "../workspace"; export async function deleteWorkspaceAction(session: Session, workspaceId: string): Promise { if (!session?.user?.id) { throw new AppError("Unauthorized", "UNAUTHORIZED"); } + + await deleteWorkspace(workspaceId, session.user.id); } diff --git a/chartsmith-app/lib/workspace/actions/process-ai-chat.ts b/chartsmith-app/lib/workspace/actions/process-ai-chat.ts new file mode 100644 index 00000000..bc7ab187 --- /dev/null +++ b/chartsmith-app/lib/workspace/actions/process-ai-chat.ts @@ -0,0 +1,41 @@ +'use server'; + +import { logger } from "@/lib/utils/logger"; + +/** + * Processes a chat message using the Vercel AI SDK API route + * This is called by the work queue handler when a new_ai_sdk_chat event is received + */ +export async function processAIChatMessage(chatMessageId: string): Promise { + try { + const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'; + + // Use dedicated internal API token for worker->Next.js authentication + // This is separate from ANTHROPIC_API_KEY to avoid leaking the LLM key + const internalToken = process.env.INTERNAL_API_TOKEN; + + if (!internalToken) { + throw new Error('INTERNAL_API_TOKEN not set in environment'); + } + + const response = await fetch(`${appUrl}/api/chat/conversational`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${internalToken}`, + }, + body: JSON.stringify({ chatMessageId }), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Failed to process chat: ${response.statusText} - ${error}`); + } + + const result = await response.json(); + logger.info('Chat message processed successfully', { chatMessageId, result }); + } catch (err) { + logger.error('Failed to process AI chat message', { err, chatMessageId }); + throw err; + } +} diff --git a/chartsmith-app/lib/workspace/archive.ts b/chartsmith-app/lib/workspace/archive.ts index 9b55eb02..f27cc089 100644 --- a/chartsmith-app/lib/workspace/archive.ts +++ b/chartsmith-app/lib/workspace/archive.ts @@ -6,8 +6,8 @@ import * as path from "node:path"; import * as os from "node:os"; import * as tar from 'tar'; import gunzip from 'gunzip-maybe'; -import fetch from 'node-fetch'; import yaml from 'yaml'; +import { Readable } from 'node:stream'; export async function getFilesFromBytes(bytes: ArrayBuffer, fileName: string): Promise { const id = srs.default({ length: 12, alphanumeric: true }); @@ -234,7 +234,13 @@ async function downloadChartArchiveFromURL(url: string): Promise { await fs.mkdir(extractPath); return new Promise((resolve, reject) => { - response.body.pipe(gunzip()) + if (!response.body) { + reject(new Error('Response body is null')); + return; + } + // Convert Web ReadableStream to Node.js Readable stream + const nodeStream = Readable.fromWeb(response.body as any); + nodeStream.pipe(gunzip()) .pipe(tar.extract({ cwd: extractPath })) .on('finish', () => resolve(extractPath)) .on('error', reject); diff --git a/chartsmith-app/lib/workspace/chat-helpers.ts b/chartsmith-app/lib/workspace/chat-helpers.ts new file mode 100644 index 00000000..fa953c69 --- /dev/null +++ b/chartsmith-app/lib/workspace/chat-helpers.ts @@ -0,0 +1,92 @@ +import { getDB } from "../data/db"; +import { getParam } from "../data/param"; +import { logger } from "../utils/logger"; + +/** + * Appends a chunk of text to a chat message's response + * + * Uses a row-level lock (FOR UPDATE) to prevent concurrent chunk appends from + * overwriting each other. This ensures chunks are appended in order even when + * multiple onChunk callbacks run concurrently. + */ +export async function appendChatMessageResponse(chatMessageId: string, chunk: string): Promise { + try { + const db = getDB(await getParam("DB_URI")); + + // Use a transaction with row-level locking to prevent concurrent updates + const client = await db.connect(); + try { + await client.query('BEGIN'); + + // Lock the row to prevent concurrent updates + const lockQuery = `SELECT response FROM workspace_chat WHERE id = $1 FOR UPDATE`; + const result = await client.query(lockQuery, [chatMessageId]); + + if (result.rows.length === 0) { + throw new Error(`Chat message not found: ${chatMessageId}`); + } + + // Append the chunk + const updateQuery = `UPDATE workspace_chat SET response = COALESCE(response, '') || $1 WHERE id = $2`; + await client.query(updateQuery, [chunk, chatMessageId]); + + await client.query('COMMIT'); + } catch (err) { + await client.query('ROLLBACK'); + throw err; + } finally { + client.release(); + } + } catch (err) { + logger.error("Failed to append chat message response", { err, chatMessageId }); + throw err; + } +} + +/** + * Clears the response field for a chat message + * This prevents duplication when a job is retried + */ +export async function clearChatMessageResponse(chatMessageId: string): Promise { + try { + const db = getDB(await getParam("DB_URI")); + const query = `UPDATE workspace_chat SET response = NULL, is_intent_complete = false WHERE id = $1`; + await db.query(query, [chatMessageId]); + } catch (err) { + logger.error("Failed to clear chat message response", { err, chatMessageId }); + throw err; + } +} + +/** + * Marks a chat message as complete + */ +export async function markChatMessageComplete(chatMessageId: string): Promise { + try { + const db = getDB(await getParam("DB_URI")); + const query = `UPDATE workspace_chat SET is_intent_complete = true WHERE id = $1`; + await db.query(query, [chatMessageId]); + } catch (err) { + logger.error("Failed to mark chat message complete", { err, chatMessageId }); + throw err; + } +} + +/** + * Gets the workspace ID for a chat message + */ +export async function getWorkspaceIdForChatMessage(chatMessageId: string): Promise { + try { + const db = getDB(await getParam("DB_URI")); + const result = await db.query(`SELECT workspace_id FROM workspace_chat WHERE id = $1`, [chatMessageId]); + + if (result.rows.length === 0) { + throw new Error(`Chat message not found: ${chatMessageId}`); + } + + return result.rows[0].workspace_id; + } catch (err) { + logger.error("Failed to get workspace ID for chat message", { err, chatMessageId }); + throw err; + } +} diff --git a/chartsmith-app/lib/workspace/context.ts b/chartsmith-app/lib/workspace/context.ts new file mode 100644 index 00000000..9340f84f --- /dev/null +++ b/chartsmith-app/lib/workspace/context.ts @@ -0,0 +1,275 @@ +import { getDB } from "../data/db"; +import { getParam } from "../data/param"; +import { logger } from "../utils/logger"; +import { Workspace, WorkspaceFile, Chart, ChatMessage } from "../types/workspace"; +import { getWorkspace } from "./workspace"; + +interface RelevantFile { + file: WorkspaceFile; + similarity: number; +} + +/** + * Gets the chart structure as a string listing all files + * Ported from pkg/llm/conversational.go:236-242 + */ +export async function getChartStructure(workspace: Workspace): Promise { + try { + let structure = ''; + + for (const chart of workspace.charts) { + for (const file of chart.files) { + structure += `File: ${file.filePath}\n`; + } + } + + return structure; + } catch (err) { + logger.error("Failed to get chart structure", { err }); + throw err; + } +} + +/** + * Chooses relevant files for a chat message using embeddings and vector search + * Ported from pkg/workspace/context.go - ChooseRelevantFilesForChatMessage + */ +export async function chooseRelevantFiles( + workspace: Workspace, + prompt: string, + chartId?: string, + maxFiles: number = 10 +): Promise { + try { + const db = getDB(await getParam("DB_URI")); + + // Get embeddings for the prompt using Voyage AI + const embeddings = await getEmbeddings(prompt); + + const fileMap = new Map(); + + // Always include Chart.yaml if it exists (match nested paths too) + const chartYamlResult = await db.query( + `SELECT id, revision_number, file_path, content FROM workspace_file + WHERE workspace_id = $1 AND revision_number = $2 + AND (file_path = 'Chart.yaml' OR file_path LIKE '%/Chart.yaml') + LIMIT 1`, + [workspace.id, workspace.currentRevisionNumber] + ); + + if (chartYamlResult.rows.length > 0) { + const chartYaml = chartYamlResult.rows[0]; + fileMap.set(chartYaml.id, { + file: { + id: chartYaml.id, + revisionNumber: chartYaml.revision_number, + filePath: chartYaml.file_path, + content: chartYaml.content, + }, + similarity: 1.0, + }); + } + + // Always include values.yaml if it exists (match nested paths too) + const valuesYamlResult = await db.query( + `SELECT id, revision_number, file_path, content FROM workspace_file + WHERE workspace_id = $1 AND revision_number = $2 + AND (file_path = 'values.yaml' OR file_path LIKE '%/values.yaml') + LIMIT 1`, + [workspace.id, workspace.currentRevisionNumber] + ); + + if (valuesYamlResult.rows.length > 0) { + const valuesYaml = valuesYamlResult.rows[0]; + fileMap.set(valuesYaml.id, { + file: { + id: valuesYaml.id, + revisionNumber: valuesYaml.revision_number, + filePath: valuesYaml.file_path, + content: valuesYaml.content, + }, + similarity: 1.0, + }); + } + + // Query files with embeddings and calculate cosine similarity + // Using pgvector's <=> operator for cosine distance + // Cast the embeddings array to vector type for proper pgvector operation + const query = ` + WITH similarities AS ( + SELECT + id, + revision_number, + file_path, + content, + embeddings, + 1 - (embeddings <=> $1::vector) as similarity + FROM workspace_file + WHERE workspace_id = $2 + AND revision_number = $3 + AND embeddings IS NOT NULL + ) + SELECT + id, + revision_number, + file_path, + content, + similarity + FROM similarities + ORDER BY similarity DESC + `; + + const result = await db.query(query, [JSON.stringify(embeddings), workspace.id, workspace.currentRevisionNumber]); + + const extensionsWithHighSimilarity = ['.yaml', '.yml', '.tpl']; + + for (const row of result.rows) { + // Skip if already in map with similarity 1.0 (pre-inserted Chart.yaml or values.yaml) + const existing = fileMap.get(row.id); + if (existing && existing.similarity === 1.0) { + continue; + } + + let similarity = row.similarity; + + // Reduce similarity for non-template files + const ext = row.file_path.substring(row.file_path.lastIndexOf('.')); + if (!extensionsWithHighSimilarity.includes(ext)) { + similarity = similarity - 0.25; + } + + // Force high similarity for Chart.yaml and values.yaml (any path) + if (row.file_path.endsWith('/Chart.yaml') || row.file_path === 'Chart.yaml' || + row.file_path.endsWith('/values.yaml') || row.file_path === 'values.yaml') { + similarity = 1.0; + } + + fileMap.set(row.id, { + file: { + id: row.id, + revisionNumber: row.revision_number, + filePath: row.file_path, + content: row.content, + }, + similarity, + }); + } + + // Convert to array and sort by similarity + const sorted = Array.from(fileMap.values()).sort((a, b) => b.similarity - a.similarity); + + // Limit to maxFiles + const limited = sorted.slice(0, maxFiles); + + return limited.map(item => item.file); + } catch (err) { + logger.error("Failed to choose relevant files", { err }); + // Fallback: return first maxFiles files + const allFiles = workspace.charts.flatMap(c => c.files); + return allFiles.slice(0, maxFiles); + } +} + +/** + * Gets embeddings for text using Voyage AI + */ +async function getEmbeddings(text: string): Promise { + try { + const voyageApiKey = process.env.VOYAGE_API_KEY; + if (!voyageApiKey) { + throw new Error("VOYAGE_API_KEY not set"); + } + + const response = await fetch('https://api.voyageai.com/v1/embeddings', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${voyageApiKey}`, + }, + body: JSON.stringify({ + input: text, + model: 'voyage-3', + }), + }); + + if (!response.ok) { + throw new Error(`Voyage API error: ${response.statusText}`); + } + + const data = await response.json(); + return data.data[0].embedding; + } catch (err) { + logger.error("Failed to get embeddings", { err }); + throw err; + } +} + +/** + * Gets previous chat history after the most recent plan + * Ported from pkg/llm/conversational.go:75-94 + */ +export async function getPreviousChatHistory( + workspaceId: string, + currentMessageId: string +): Promise> { + try { + const db = getDB(await getParam("DB_URI")); + + // Get the most recent plan + const planResult = await db.query( + `SELECT id, description, created_at FROM workspace_plan + WHERE workspace_id = $1 AND status != 'ignored' + ORDER BY created_at DESC LIMIT 1`, + [workspaceId] + ); + + if (planResult.rows.length === 0) { + // No plan found, return empty history + return []; + } + + const plan = planResult.rows[0]; + + // Get all chat messages after the plan was created + const messagesResult = await db.query( + `SELECT id, prompt, response, created_at FROM workspace_chat + WHERE workspace_id = $1 + AND created_at > $2 + AND id != $3 + AND prompt IS NOT NULL + ORDER BY created_at ASC`, + [workspaceId, plan.created_at, currentMessageId] + ); + + const history: Array<{ role: 'user' | 'assistant', content: string }> = []; + + // Add the plan description first + if (plan.description) { + history.push({ + role: 'assistant', + content: plan.description, + }); + } + + // Add all subsequent messages + for (const msg of messagesResult.rows) { + if (msg.prompt) { + history.push({ + role: 'user', + content: msg.prompt, + }); + } + if (msg.response) { + history.push({ + role: 'assistant', + content: msg.response, + }); + } + } + + return history; + } catch (err) { + logger.error("Failed to get previous chat history", { err }); + return []; + } +} diff --git a/chartsmith-app/lib/workspace/workspace.ts b/chartsmith-app/lib/workspace/workspace.ts index 01eacf62..15c2bc77 100644 --- a/chartsmith-app/lib/workspace/workspace.ts +++ b/chartsmith-app/lib/workspace/workspace.ts @@ -239,7 +239,11 @@ export async function createChatMessage(userId: string, workspaceId: string, par additionalFiles: params.additionalFiles, }); } else if (params.knownIntent === ChatMessageIntent.NON_PLAN) { - await client.query(`SELECT pg_notify('new_nonplan_chat_message', $1)`, [chatMessageId]); + // Enqueue work for Next.js API route to handle conversational chat with Vercel AI SDK + await enqueueWork("new_ai_sdk_chat", { + chatMessageId, + workspaceId, + }); } else if (params.knownIntent === ChatMessageIntent.RENDER) { await renderWorkspace(workspaceId, chatMessageId); } else if (params.knownIntent === ChatMessageIntent.CONVERT_K8S_TO_HELM) { @@ -468,7 +472,7 @@ export async function createPlan(userId: string, workspaceId: string, chatMessag } } -export async function getChatMessage(chatMessageId: string): Promise { +export async function getChatMessage(chatMessageId: string): Promise { try { const db = getDB(await getParam("DB_URI")); @@ -485,12 +489,19 @@ export async function getChatMessage(chatMessageId: string): Promise { + logger.info("Deleting workspace", { workspaceId, userId }); + const db = getDB(await getParam("DB_URI")); + + try { + // Verify the workspace belongs to the user + const workspaceResult = await db.query( + `SELECT created_by_user_id FROM workspace WHERE id = $1`, + [workspaceId] + ); + + if (workspaceResult.rows.length === 0) { + throw new Error("Workspace not found"); + } + + if (workspaceResult.rows[0].created_by_user_id !== userId) { + throw new Error("Unauthorized"); + } + + // Delete the workspace (cascade will handle related records) + await db.query(`DELETE FROM workspace WHERE id = $1`, [workspaceId]); + + logger.info("Workspace deleted successfully", { workspaceId }); + } catch (err) { + logger.error("Failed to delete workspace", { err }); + throw err; + } +} diff --git a/chartsmith-app/middleware.ts b/chartsmith-app/middleware.ts index 3e051920..c5277c11 100644 --- a/chartsmith-app/middleware.ts +++ b/chartsmith-app/middleware.ts @@ -16,13 +16,14 @@ const publicPaths = [ '/images', ]; -// API paths that can use token-based auth +// API paths that can use token-based auth // (these will be handled in their respective routes) const tokenAuthPaths = [ '/api/auth/status', '/api/upload-chart', '/api/workspace', - '/api/push' + '/api/push', + '/api/chat/conversational' // Allow worker to call conversational chat API ]; // This function can be marked `async` if using `await` inside diff --git a/chartsmith-app/package-lock.json b/chartsmith-app/package-lock.json index f6378ea6..cb730402 100644 --- a/chartsmith-app/package-lock.json +++ b/chartsmith-app/package-lock.json @@ -8,13 +8,14 @@ "name": "chartsmith-app", "version": "0.1.0", "dependencies": { - "@anthropic-ai/sdk": "^0.39.0", + "@ai-sdk/anthropic": "^2.0.56", "@monaco-editor/react": "^4.7.0", - "@radix-ui/react-toast": "^1.2.15", - "@tailwindcss/typography": "^0.5.19", - "@types/diff": "^8.0.0", - "autoprefixer": "^10.4.22", - "centrifuge": "^5.5.2", + "@radix-ui/react-toast": "^1.2.7", + "@tailwindcss/typography": "^0.5.16", + "@types/diff": "^7.0.1", + "ai": "^5.0.113", + "autoprefixer": "^10.4.20", + "centrifuge": "^5.3.4", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.0.4", @@ -38,9 +39,10 @@ "react-hotkeys-hook": "^4.6.1", "react-markdown": "^10.1.0", "secure-random-string": "^1.1.4", - "tailwind-merge": "^3.4.0", - "tar": "^7.5.2", - "unified-diff": "^5.0.0" + "tailwind-merge": "^3.0.2", + "tar": "^7.4.3", + "unified-diff": "^5.0.0", + "zod": "^4.1.13" }, "devDependencies": { "@eslint/eslintrc": "^3.3.3", @@ -69,6 +71,68 @@ "typescript": "^5.9.3" } }, + "node_modules/@ai-sdk/anthropic": { + "version": "2.0.56", + "resolved": "https://registry.npmjs.org/@ai-sdk/anthropic/-/anthropic-2.0.56.tgz", + "integrity": "sha512-XHJKu0Yvfu9SPzRfsAFESa+9T7f2YJY6TxykKMfRsAwpeWAiX/Gbx5J5uM15AzYC3Rw8tVP3oH+j7jEivENirQ==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "2.0.0", + "@ai-sdk/provider-utils": "3.0.19" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/gateway": { + "version": "2.0.21", + "resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-2.0.21.tgz", + "integrity": "sha512-BwV7DU/lAm3Xn6iyyvZdWgVxgLu3SNXzl5y57gMvkW4nGhAOV5269IrJzQwGt03bb107sa6H6uJwWxc77zXoGA==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "2.0.0", + "@ai-sdk/provider-utils": "3.0.19", + "@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.19", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-3.0.19.tgz", + "integrity": "sha512-W41Wc9/jbUVXVwCN/7bWa4IKe8MtxO3EyA0Hfhx6grnmiYlCvpI8neSYWFE0zScXJkgA/YK3BRybzgyiXuu6JA==", + "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/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", @@ -96,34 +160,6 @@ "node": ">=6.0.0" } }, - "node_modules/@anthropic-ai/sdk": { - "version": "0.39.0", - "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.39.0.tgz", - "integrity": "sha512-eMyDIPRZbt1CCLErRCi3exlAvNkBtRe+kW5vvJyef93PmNr/clstYgHhtvmkxN82nlKgzyGPCyGxrm0JQ1ZIdg==", - "dependencies": { - "@types/node": "^18.11.18", - "@types/node-fetch": "^2.6.4", - "abort-controller": "^3.0.0", - "agentkeepalive": "^4.2.1", - "form-data-encoder": "1.7.2", - "formdata-node": "^4.3.2", - "node-fetch": "^2.6.7" - } - }, - "node_modules/@anthropic-ai/sdk/node_modules/@types/node": { - "version": "18.19.86", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.86.tgz", - "integrity": "sha512-fifKayi175wLyKyc5qUfyENhQ1dCNI1UNjp653d8kuYcPQN5JhX3dGuP/XmvPTg/xRBn1VTLpbmi+H/Mr7tLfQ==", - "dependencies": { - "undici-types": "~5.26.4" - } - }, - "node_modules/@anthropic-ai/sdk/node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "license": "MIT" - }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -2228,6 +2264,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", @@ -3129,6 +3174,18 @@ "@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/counter": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", + "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", + "license": "Apache-2.0" + }, "node_modules/@swc/helpers": { "version": "0.5.15", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", @@ -3413,16 +3470,6 @@ "undici-types": "~6.21.0" } }, - "node_modules/@types/node-fetch": { - "version": "2.6.12", - "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.12.tgz", - "integrity": "sha512-8nneRWKCg3rMtF69nLQJnOYUcbafYeFSjqkw3jCRLsqkWFlHaoQrr5mXmofFGOx3DKn7UfmBMyov8ySvLRVldA==", - "license": "MIT", - "dependencies": { - "@types/node": "*", - "form-data": "^4.0.0" - } - }, "node_modules/@types/pg": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.16.0.tgz", @@ -3774,24 +3821,21 @@ "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", "integrity": "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==", "license": "BSD-2-Clause" }, - "node_modules/abort-controller": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", - "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", - "license": "MIT", - "dependencies": { - "event-target-shim": "^5.0.0" - }, - "engines": { - "node": ">=6.5" - } - }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -3828,16 +3872,22 @@ "node": ">=0.4.0" } }, - "node_modules/agentkeepalive": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz", - "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==", - "license": "MIT", + "node_modules/ai": { + "version": "5.0.113", + "resolved": "https://registry.npmjs.org/ai/-/ai-5.0.113.tgz", + "integrity": "sha512-26vivpSO/mzZj0k1Si2IpsFspp26ttQICHRySQiMrtWcRd5mnJMX2a8sG28vmZ38C+JUn1cWmfZrsLMxkSMw9g==", + "license": "Apache-2.0", "dependencies": { - "humanize-ms": "^1.2.1" + "@ai-sdk/gateway": "2.0.21", + "@ai-sdk/provider": "2.0.0", + "@ai-sdk/provider-utils": "3.0.19", + "@opentelemetry/api": "1.9.0" }, "engines": { - "node": ">= 8.0.0" + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" } }, "node_modules/ajv": { @@ -4122,12 +4172,22 @@ "dev": true, "license": "MIT" }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "dev": true, "license": "MIT" }, + "node_modules/at-least-node": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", + "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", + "license": "ISC", + "engines": { + "node": ">= 4.0.0" + } + }, "node_modules/autoprefixer": { "version": "10.4.22", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.22.tgz", @@ -4897,16 +4957,14 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "license": "MIT" }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "license": "MIT", + "node_modules/color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "optional": true, "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" } }, "node_modules/comma-separated-tokens": { @@ -5169,15 +5227,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -6091,15 +6140,6 @@ "through": "~2.3.1" } }, - "node_modules/event-target-shim": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", - "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/events": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", @@ -6109,6 +6149,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", @@ -6360,41 +6409,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", - "license": "MIT", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/form-data-encoder": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-1.7.2.tgz", - "integrity": "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==", - "license": "MIT" - }, - "node_modules/formdata-node": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/formdata-node/-/formdata-node-4.4.1.tgz", - "integrity": "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==", - "license": "MIT", - "dependencies": { - "node-domexception": "1.0.0", - "web-streams-polyfill": "4.0.0-beta.3" - }, - "engines": { - "node": ">= 12.20" - } - }, "node_modules/fraction.js": { "version": "5.3.4", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", @@ -6976,15 +6990,6 @@ "node": ">=10.17.0" } }, - "node_modules/humanize-ms": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", - "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", - "license": "MIT", - "dependencies": { - "ms": "^2.0.0" - } - }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -8501,6 +8506,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", @@ -9536,27 +9547,6 @@ "node": ">=8.6" } }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/mimic-fn": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", @@ -9750,45 +9740,6 @@ "node": "^10 || ^12 || >=14" } }, - "node_modules/node-domexception": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", - "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/jimmywarting" - }, - { - "type": "github", - "url": "https://paypal.me/jimmywarting" - } - ], - "license": "MIT", - "engines": { - "node": ">=10.5.0" - } - }, - "node_modules/node-fetch": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", - "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", - "license": "MIT", - "dependencies": { - "whatwg-url": "^5.0.0" - }, - "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } - } - }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -12509,12 +12460,6 @@ "node": ">=8.0" } }, - "node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", - "license": "MIT" - }, "node_modules/trim-lines": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", @@ -13154,31 +13099,6 @@ "makeerror": "1.0.12" } }, - "node_modules/web-streams-polyfill": { - "version": "4.0.0-beta.3", - "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz", - "integrity": "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==", - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, - "node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", - "license": "BSD-2-Clause" - }, - "node_modules/whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "license": "MIT", - "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } - }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -13558,6 +13478,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/zod": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.13.tgz", + "integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/zwitch": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", diff --git a/chartsmith-app/package.json b/chartsmith-app/package.json index e6b2acb9..24ab4660 100644 --- a/chartsmith-app/package.json +++ b/chartsmith-app/package.json @@ -2,6 +2,9 @@ "name": "chartsmith-app", "version": "0.1.0", "private": true, + "engines": { + "node": ">=20.0.0" + }, "scripts": { "dev": "next dev --turbopack", "clean-dev": "rm -rf .next && rm -rf node_modules/.cache && next dev --turbopack", @@ -18,13 +21,14 @@ "test:parseDiff": "jest parseDiff" }, "dependencies": { - "@anthropic-ai/sdk": "^0.39.0", + "@ai-sdk/anthropic": "^2.0.56", "@monaco-editor/react": "^4.7.0", - "@radix-ui/react-toast": "^1.2.15", - "@tailwindcss/typography": "^0.5.19", - "@types/diff": "^8.0.0", - "autoprefixer": "^10.4.22", - "centrifuge": "^5.5.2", + "@radix-ui/react-toast": "^1.2.7", + "@tailwindcss/typography": "^0.5.16", + "@types/diff": "^7.0.1", + "ai": "^5.0.113", + "autoprefixer": "^10.4.20", + "centrifuge": "^5.3.4", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.0.4", @@ -48,9 +52,10 @@ "react-hotkeys-hook": "^4.6.1", "react-markdown": "^10.1.0", "secure-random-string": "^1.1.4", - "tailwind-merge": "^3.4.0", - "tar": "^7.5.2", - "unified-diff": "^5.0.0" + "tailwind-merge": "^3.0.2", + "tar": "^7.4.3", + "unified-diff": "^5.0.0", + "zod": "^4.1.13" }, "overrides": { "form-data": ">=4.0.5" diff --git a/pkg/listener/ai-sdk-chat.go b/pkg/listener/ai-sdk-chat.go new file mode 100644 index 00000000..9b8fa99d --- /dev/null +++ b/pkg/listener/ai-sdk-chat.go @@ -0,0 +1,81 @@ +package listener + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "time" + + "github.com/replicatedhq/chartsmith/pkg/logger" + "go.uber.org/zap" +) + +type AIChatPayload struct { + ChatMessageID string `json:"chatMessageId"` + WorkspaceID string `json:"workspaceId"` +} + +func handleNewAISDKChatNotification(ctx context.Context, payload string) error { + logger.Debug("Handling new AI SDK chat notification", zap.String("payload", payload)) + + var p AIChatPayload + if err := json.Unmarshal([]byte(payload), &p); err != nil { + return fmt.Errorf("failed to unmarshal payload: %w", err) + } + + if p.ChatMessageID == "" { + return fmt.Errorf("chatMessageId is required") + } + + // Call the Next.js API route + appURL := os.Getenv("NEXT_PUBLIC_APP_URL") + if appURL == "" { + appURL = "http://localhost:3000" + } + + apiURL := fmt.Sprintf("%s/api/chat/conversational", appURL) + + requestBody, err := json.Marshal(map[string]string{ + "chatMessageId": p.ChatMessageID, + }) + if err != nil { + return fmt.Errorf("failed to marshal request: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, "POST", apiURL, bytes.NewBuffer(requestBody)) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + + // Use dedicated internal API token for authentication + // This is separate from ANTHROPIC_API_KEY to avoid exposing the LLM key + internalToken := os.Getenv("INTERNAL_API_TOKEN") + if internalToken == "" { + return fmt.Errorf("INTERNAL_API_TOKEN not set in environment") + } + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", internalToken)) + + // Set timeout slightly longer than Next.js route maxDuration (5 minutes) + client := &http.Client{ + Timeout: 6 * time.Minute, + } + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("failed to call API route: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("API route returned error: %d - %s", resp.StatusCode, string(body)) + } + + logger.Info("Successfully processed AI SDK chat message", zap.String("chatMessageId", p.ChatMessageID)) + return nil +} diff --git a/pkg/listener/start.go b/pkg/listener/start.go index dbfa8d14..d20c43ad 100644 --- a/pkg/listener/start.go +++ b/pkg/listener/start.go @@ -44,6 +44,16 @@ func StartListeners(ctx context.Context) error { return nil }, nil) + // Handler for Vercel AI SDK chat (Next.js API route) + // Match Next.js route maxDuration of 5 minutes to prevent premature re-queueing + l.AddHandler(ctx, "new_ai_sdk_chat", 5, time.Minute*5, func(notification *pgconn.Notification) error { + if err := handleNewAISDKChatNotification(ctx, notification.Payload); err != nil { + logger.Error(fmt.Errorf("failed to handle new AI SDK chat notification: %w", err)) + return fmt.Errorf("failed to handle new AI SDK chat notification: %w", err) + } + return nil + }, nil) + l.AddHandler(ctx, "execute_plan", 5, time.Second*10, func(notification *pgconn.Notification) error { if err := handleExecutePlanNotification(ctx, notification.Payload); err != nil { logger.Error(fmt.Errorf("failed to handle execute plan notification: %w", err))