From 650765d5e9f4e10dbf2a025109c564e8a9f9c7af Mon Sep 17 00:00:00 2001 From: TerrifiedBug Date: Wed, 11 Mar 2026 13:24:06 +0000 Subject: [PATCH 01/15] feat: add componentKey and vrlCode fields for VRL AI conversations --- .../migration.sql | 8 ++++++++ prisma/schema.prisma | 19 +++++++++++-------- 2 files changed, 19 insertions(+), 8 deletions(-) create mode 100644 prisma/migrations/20260311030000_add_vrl_conversation_fields/migration.sql diff --git a/prisma/migrations/20260311030000_add_vrl_conversation_fields/migration.sql b/prisma/migrations/20260311030000_add_vrl_conversation_fields/migration.sql new file mode 100644 index 0000000..b92f422 --- /dev/null +++ b/prisma/migrations/20260311030000_add_vrl_conversation_fields/migration.sql @@ -0,0 +1,8 @@ +-- AlterTable +ALTER TABLE "AiConversation" ADD COLUMN "componentKey" TEXT; + +-- AlterTable +ALTER TABLE "AiMessage" ADD COLUMN "vrlCode" TEXT; + +-- CreateIndex +CREATE INDEX "AiConversation_pipelineId_componentKey_idx" ON "AiConversation"("pipelineId", "componentKey"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index abbff76..961c589 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -775,16 +775,18 @@ model ServiceAccount { } model AiConversation { - id String @id @default(cuid()) - pipelineId String - pipeline Pipeline @relation(fields: [pipelineId], references: [id], onDelete: Cascade) - createdById String? - createdBy User? @relation("AiConversationCreatedBy", fields: [createdById], references: [id], onDelete: SetNull) - messages AiMessage[] - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(cuid()) + pipelineId String + pipeline Pipeline @relation(fields: [pipelineId], references: [id], onDelete: Cascade) + componentKey String? + createdById String? + createdBy User? @relation("AiConversationCreatedBy", fields: [createdById], references: [id], onDelete: SetNull) + messages AiMessage[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@index([pipelineId, createdAt]) + @@index([pipelineId, componentKey]) } model AiMessage { @@ -795,6 +797,7 @@ model AiMessage { content String suggestions Json? pipelineYaml String? + vrlCode String? createdById String? createdBy User? @relation("AiMessageCreatedBy", fields: [createdById], references: [id], onDelete: SetNull) createdAt DateTime @default(now()) From e7e387abe0c00c3e78a5c12b7682d6ddd540eae4 Mon Sep 17 00:00:00 2001 From: TerrifiedBug Date: Wed, 11 Mar 2026 13:26:34 +0000 Subject: [PATCH 02/15] feat: add VRL suggestion types, parser, and apply logic --- src/lib/ai/vrl-suggestion-types.ts | 106 +++++++++++++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 src/lib/ai/vrl-suggestion-types.ts diff --git a/src/lib/ai/vrl-suggestion-types.ts b/src/lib/ai/vrl-suggestion-types.ts new file mode 100644 index 0000000..f3341d2 --- /dev/null +++ b/src/lib/ai/vrl-suggestion-types.ts @@ -0,0 +1,106 @@ +export interface VrlSuggestion { + id: string; + type: "insert_code" | "replace_code" | "remove_code"; + title: string; + description: string; + priority: "high" | "medium" | "low"; + code: string; + targetCode?: string; + appliedAt?: string; + appliedById?: string; +} + +export interface VrlChatResponse { + summary: string; + suggestions: VrlSuggestion[]; +} + +/** Status of a VRL suggestion in the UI */ +export type VrlSuggestionStatus = "actionable" | "applied" | "outdated"; + +/** + * Parse the streamed AI response as a VrlChatResponse. + * Returns null if the response is not valid JSON or missing required fields. + */ +export function parseVrlChatResponse(raw: string): VrlChatResponse | null { + try { + const parsed = JSON.parse(raw); + if ( + typeof parsed === "object" && + parsed !== null && + typeof parsed.summary === "string" && + Array.isArray(parsed.suggestions) + ) { + return parsed as VrlChatResponse; + } + return null; + } catch { + return null; + } +} + +/** + * Compute the status of each VRL suggestion based on the current editor content. + * + * - insert_code: always actionable (no targetCode to become stale) + * - replace_code / remove_code: actionable if targetCode is found in currentCode, otherwise outdated + * - Any suggestion with appliedAt is marked as applied + */ +export function computeVrlSuggestionStatuses( + suggestions: VrlSuggestion[], + currentCode: string, +): Map { + const statuses = new Map(); + + for (const s of suggestions) { + if (s.appliedAt) { + statuses.set(s.id, "applied"); + continue; + } + + if (s.type === "insert_code") { + statuses.set(s.id, "actionable"); + continue; + } + + // replace_code and remove_code: check if targetCode exists in current editor + if (s.targetCode && currentCode.includes(s.targetCode)) { + statuses.set(s.id, "actionable"); + } else { + statuses.set(s.id, "outdated"); + } + } + + return statuses; +} + +/** + * Apply a single VRL suggestion to the editor content. + * Returns the new code, or null if the suggestion can't be applied (targetCode not found). + */ +export function applyVrlSuggestion( + suggestion: VrlSuggestion, + currentCode: string, +): string | null { + switch (suggestion.type) { + case "insert_code": + return currentCode + ? `${currentCode}\n${suggestion.code}` + : suggestion.code; + + case "replace_code": + if (!suggestion.targetCode || !currentCode.includes(suggestion.targetCode)) { + return null; + } + return currentCode.replace(suggestion.targetCode, suggestion.code); + + case "remove_code": + if (!suggestion.targetCode || !currentCode.includes(suggestion.targetCode)) { + return null; + } + return currentCode.replace(suggestion.targetCode, "").replace(/\n{3,}/g, "\n\n").trim(); + + default: + return null; + } +} From 32c0485c52e293ca6001301cefb5401bc401d48a Mon Sep 17 00:00:00 2001 From: TerrifiedBug Date: Wed, 11 Mar 2026 13:27:56 +0000 Subject: [PATCH 03/15] feat: add VRL chat system prompt for structured JSON responses --- src/lib/ai/prompts.ts | 66 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/src/lib/ai/prompts.ts b/src/lib/ai/prompts.ts index 6652414..f5d945c 100644 --- a/src/lib/ai/prompts.ts +++ b/src/lib/ai/prompts.ts @@ -38,6 +38,72 @@ export function buildVrlSystemPrompt(context: { return parts.join("\n"); } +export function buildVrlChatSystemPrompt(context: { + fields?: { name: string; type: string }[]; + currentCode?: string; + componentType?: string; + sourceTypes?: string[]; +}): string { + const parts: string[] = [ + "You are a VRL (Vector Remap Language) assistant for Vector data pipelines.", + "Analyze the user's VRL code and requests. Return your response as a JSON object.", + "", + "Response format (return ONLY this JSON, no markdown fencing, no extra text):", + JSON.stringify({ + summary: "2-3 sentence analysis or explanation", + suggestions: [ + { + id: "s1", + type: "insert_code", + title: "Short title", + description: "What this does and why", + priority: "high|medium|low", + code: "the VRL code", + targetCode: null, + }, + ], + }, null, 2), + "", + "Suggestion types:", + '- insert_code: Adds new VRL code. Set targetCode to null.', + '- replace_code: Replaces existing VRL. Set targetCode to the EXACT existing code to find and replace.', + '- remove_code: Removes existing VRL. Set targetCode to the EXACT existing code to remove. Set code to empty string.', + "", + "Rules:", + "- Each suggestion needs a unique id (s1, s2, s3...)", + "- For replace_code/remove_code, targetCode MUST be an exact substring of the current VRL code", + "- Focus on: correctness, performance, readability, best practices", + "- Prioritize: high = bug or data loss risk, medium = optimization, low = cleanup", + "- Return valid JSON only. No markdown, no code fences, no commentary outside the JSON.", + "- Even in follow-up messages, always return the full JSON object.", + "- If the user asks a question that doesn't need code changes, return an empty suggestions array with your answer in the summary.", + "", + "=== VRL Function Reference ===", + VRL_REFERENCE, + ]; + + if (context.sourceTypes?.length) { + parts.push("", `Connected source types: ${context.sourceTypes.join(", ")}`); + } + + if (context.componentType) { + parts.push(`Transform component type: ${context.componentType}`); + } + + if (context.fields?.length) { + parts.push("", "Available fields in the event:"); + for (const f of context.fields) { + parts.push(` .${f.name} (${f.type})`); + } + } + + if (context.currentCode?.trim()) { + parts.push("", "Current VRL code in the editor:", "```", context.currentCode, "```"); + } + + return parts.join("\n"); +} + export function buildPipelineSystemPrompt(context: { mode: "generate" | "review"; currentYaml?: string; From 8e3182bf1a4fe969e12c5ff984f42672b2c08edc Mon Sep 17 00:00:00 2001 From: TerrifiedBug Date: Wed, 11 Mar 2026 13:30:19 +0000 Subject: [PATCH 04/15] feat: add VRL chat SSE API endpoint with conversation persistence --- src/app/api/ai/vrl-chat/route.ts | 234 +++++++++++++++++++++++++++++++ 1 file changed, 234 insertions(+) create mode 100644 src/app/api/ai/vrl-chat/route.ts diff --git a/src/app/api/ai/vrl-chat/route.ts b/src/app/api/ai/vrl-chat/route.ts new file mode 100644 index 0000000..02c0fa0 --- /dev/null +++ b/src/app/api/ai/vrl-chat/route.ts @@ -0,0 +1,234 @@ +export const runtime = "nodejs"; + +import { auth } from "@/auth"; +import { prisma } from "@/lib/prisma"; +import { streamCompletion } from "@/server/services/ai"; +import { buildVrlChatSystemPrompt } from "@/lib/ai/prompts"; +import { writeAuditLog } from "@/server/services/audit"; +import type { VrlChatResponse } from "@/lib/ai/vrl-suggestion-types"; +import { Prisma } from "@/generated/prisma"; + +export async function POST(request: Request) { + const session = await auth(); + if (!session?.user?.id) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { + status: 401, + headers: { "Content-Type": "application/json" }, + }); + } + + let body: { + teamId: string; + prompt: string; + currentCode?: string; + fields?: { name: string; type: string }[]; + componentType?: string; + sourceTypes?: string[]; + pipelineId: string; + componentKey: string; + conversationId?: string; + }; + + try { + body = await request.json(); + } catch { + return new Response(JSON.stringify({ error: "Invalid JSON" }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }); + } + + if (!body.teamId || !body.prompt || !body.pipelineId || !body.componentKey) { + return new Response( + JSON.stringify({ error: "teamId, prompt, pipelineId, and componentKey are required" }), + { status: 400, headers: { "Content-Type": "application/json" } }, + ); + } + + // Verify user is at least EDITOR on this team + const membership = await prisma.teamMember.findUnique({ + where: { userId_teamId: { userId: session.user.id, teamId: body.teamId } }, + }); + const user = await prisma.user.findUnique({ + where: { id: session.user.id }, + select: { isSuperAdmin: true }, + }); + + if (!membership && !user?.isSuperAdmin) { + return new Response(JSON.stringify({ error: "Forbidden" }), { + status: 403, + headers: { "Content-Type": "application/json" }, + }); + } + if (membership && membership.role === "VIEWER" && !user?.isSuperAdmin) { + return new Response(JSON.stringify({ error: "EDITOR role required" }), { + status: 403, + headers: { "Content-Type": "application/json" }, + }); + } + + // Verify pipelineId belongs to the team + const pipeline = await prisma.pipeline.findUnique({ + where: { id: body.pipelineId }, + select: { environmentId: true, environment: { select: { teamId: true } } }, + }); + if (!pipeline || pipeline.environment.teamId !== body.teamId) { + return new Response(JSON.stringify({ error: "Pipeline not found" }), { + status: 404, + headers: { "Content-Type": "application/json" }, + }); + } + + // --- Conversation persistence --- + let conversationId = body.conversationId; + let priorMessages: Array<{ role: "user" | "assistant"; content: string }> = []; + + if (!conversationId) { + const conversation = await prisma.aiConversation.create({ + data: { + pipelineId: body.pipelineId, + componentKey: body.componentKey, + createdById: session.user.id, + }, + }); + conversationId = conversation.id; + } else { + // Verify conversationId belongs to this pipeline + component + const existing = await prisma.aiConversation.findUnique({ + where: { id: conversationId }, + select: { pipelineId: true, componentKey: true }, + }); + if ( + !existing || + existing.pipelineId !== body.pipelineId || + existing.componentKey !== body.componentKey + ) { + return new Response(JSON.stringify({ error: "Conversation not found" }), { + status: 404, + headers: { "Content-Type": "application/json" }, + }); + } + } + + // Save user message + await prisma.aiMessage.create({ + data: { + conversationId, + role: "user", + content: body.prompt, + vrlCode: body.currentCode ?? null, + createdById: session.user.id, + }, + }); + + // Get most recent 10 messages (desc) then reverse to chronological order + const history = await prisma.aiMessage.findMany({ + where: { conversationId }, + orderBy: { createdAt: "desc" }, + take: 10, + select: { role: true, content: true }, + }); + history.reverse(); + + // Exclude the message we just saved (last user msg) — it goes as the current prompt + priorMessages = history.slice(0, -1).map((m) => ({ + role: m.role as "user" | "assistant", + content: m.content, + })); + + const messages: Array<{ role: "user" | "assistant"; content: string }> = [ + ...priorMessages, + { role: "user", content: body.prompt }, + ]; + + const systemPrompt = buildVrlChatSystemPrompt({ + fields: body.fields, + currentCode: body.currentCode, + componentType: body.componentType, + sourceTypes: body.sourceTypes, + }); + + const encoder = new TextEncoder(); + let fullResponse = ""; + + const stream = new ReadableStream({ + async start(controller) { + try { + controller.enqueue( + encoder.encode(`data: ${JSON.stringify({ conversationId })}\n\n`), + ); + + await streamCompletion({ + teamId: body.teamId, + systemPrompt, + messages, + onToken: (token) => { + fullResponse += token; + const data = JSON.stringify({ token }); + controller.enqueue(encoder.encode(`data: ${data}\n\n`)); + }, + signal: request.signal, + }); + + // Persist assistant response + let parsedSuggestions = null; + try { + const parsed: VrlChatResponse = JSON.parse(fullResponse); + if (parsed.summary && Array.isArray(parsed.suggestions)) { + parsedSuggestions = parsed.suggestions; + } + } catch { + // Not valid JSON — store as raw text + } + + try { + await prisma.aiMessage.create({ + data: { + conversationId: conversationId!, + role: "assistant", + content: fullResponse, + suggestions: (parsedSuggestions as unknown as Prisma.InputJsonValue) ?? undefined, + vrlCode: body.currentCode ?? null, + createdById: session.user.id, + }, + }); + } catch (err) { + console.error("Failed to persist VRL AI response:", err); + } + + writeAuditLog({ + userId: session.user.id, + action: "pipeline.vrl_ai_chat", + entityType: "Pipeline", + entityId: body.pipelineId, + metadata: { + conversationId, + componentKey: body.componentKey, + suggestionCount: parsedSuggestions?.length ?? 0, + }, + teamId: pipeline.environment.teamId, + environmentId: pipeline.environmentId, + userEmail: session.user.email ?? null, + userName: session.user.name ?? null, + }).catch(() => {}); + + controller.enqueue(encoder.encode(`data: ${JSON.stringify({ done: true })}\n\n`)); + } catch (err) { + const message = err instanceof Error ? err.message : "AI request failed"; + controller.enqueue( + encoder.encode(`data: ${JSON.stringify({ error: message })}\n\n`), + ); + } finally { + controller.close(); + } + }, + }); + + return new Response(stream, { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }, + }); +} From 792344153773783f0f1b32a440bd097ff11ec561 Mon Sep 17 00:00:00 2001 From: TerrifiedBug Date: Wed, 11 Mar 2026 13:31:57 +0000 Subject: [PATCH 05/15] feat: add tRPC procedures for VRL conversation CRUD --- src/server/routers/ai.ts | 68 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 67 insertions(+), 1 deletion(-) diff --git a/src/server/routers/ai.ts b/src/server/routers/ai.ts index f7a4d9e..7c2e113 100644 --- a/src/server/routers/ai.ts +++ b/src/server/routers/ai.ts @@ -11,7 +11,7 @@ export const aiRouter = router({ .use(withTeamAccess("VIEWER")) .query(async ({ input }) => { const conversation = await prisma.aiConversation.findFirst({ - where: { pipelineId: input.pipelineId }, + where: { pipelineId: input.pipelineId, componentKey: null }, orderBy: { createdAt: "desc" }, include: { messages: { @@ -80,6 +80,72 @@ export const aiRouter = router({ data: { suggestions: updatedSuggestions as unknown as Prisma.InputJsonValue }, }); + return { applied: input.suggestionIds.length }; + }); + }), + + getVrlConversation: protectedProcedure + .input(z.object({ pipelineId: z.string(), componentKey: z.string() })) + .use(withTeamAccess("VIEWER")) + .query(async ({ input }) => { + const conversation = await prisma.aiConversation.findFirst({ + where: { + pipelineId: input.pipelineId, + componentKey: input.componentKey, + }, + orderBy: { createdAt: "desc" }, + include: { + messages: { + orderBy: { createdAt: "asc" }, + include: { + createdBy: { select: { id: true, name: true, image: true } }, + }, + }, + }, + }); + return conversation; + }), + + markVrlSuggestionsApplied: protectedProcedure + .input( + z.object({ + pipelineId: z.string(), + conversationId: z.string(), + messageId: z.string(), + suggestionIds: z.array(z.string()), + }), + ) + .use(withTeamAccess("EDITOR")) + .use(withAudit("pipeline.vrl_ai_suggestion_applied", "Pipeline")) + .mutation(async ({ input, ctx }) => { + return prisma.$transaction(async (tx) => { + const message = await tx.aiMessage.findUnique({ + where: { id: input.messageId }, + include: { + conversation: { select: { pipelineId: true, componentKey: true } }, + }, + }); + + if ( + !message || + message.conversationId !== input.conversationId || + message.conversation.pipelineId !== input.pipelineId + ) { + throw new TRPCError({ code: "NOT_FOUND", message: "Message not found in conversation" }); + } + + const suggestions = (message.suggestions as Array>) ?? []; + const updatedSuggestions = suggestions.map((s) => + input.suggestionIds.includes(s.id as string) + ? { ...s, appliedAt: new Date().toISOString(), appliedById: ctx.session.user.id } + : s, + ); + + await tx.aiMessage.update({ + where: { id: input.messageId }, + data: { suggestions: updatedSuggestions as unknown as Prisma.InputJsonValue }, + }); + return { applied: input.suggestionIds.length }; }); }), From 5503febf5549a7805c1336e14b082008794e70f0 Mon Sep 17 00:00:00 2001 From: TerrifiedBug Date: Wed, 11 Mar 2026 13:34:10 +0000 Subject: [PATCH 06/15] feat: add useVrlAiConversation hook for VRL chat state management --- src/hooks/use-vrl-ai-conversation.ts | 290 +++++++++++++++++++++++++++ 1 file changed, 290 insertions(+) create mode 100644 src/hooks/use-vrl-ai-conversation.ts diff --git a/src/hooks/use-vrl-ai-conversation.ts b/src/hooks/use-vrl-ai-conversation.ts new file mode 100644 index 0000000..483bac2 --- /dev/null +++ b/src/hooks/use-vrl-ai-conversation.ts @@ -0,0 +1,290 @@ +"use client"; + +import { useState, useRef, useCallback } from "react"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { useTRPC } from "@/trpc/client"; +import { useTeamStore } from "@/stores/team-store"; +import type { VrlSuggestion } from "@/lib/ai/vrl-suggestion-types"; +import { parseVrlChatResponse } from "@/lib/ai/vrl-suggestion-types"; + +export interface VrlConversationMessage { + id: string; + role: "user" | "assistant"; + content: string; + suggestions?: VrlSuggestion[]; + vrlCode?: string | null; + createdAt: string; + createdBy?: { id: string; name: string | null; image: string | null } | null; +} + +interface UseVrlAiConversationOptions { + pipelineId: string; + componentKey: string; + currentCode?: string; + fields?: { name: string; type: string }[]; + componentType?: string; + sourceTypes?: string[]; +} + +export function useVrlAiConversation({ + pipelineId, + componentKey, + currentCode, + fields, + componentType, + sourceTypes, +}: UseVrlAiConversationOptions) { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + const selectedTeamId = useTeamStore((s) => s.selectedTeamId); + + const [messages, setMessages] = useState([]); + const [conversationId, setConversationId] = useState(null); + const [isStreaming, setIsStreaming] = useState(false); + const [streamingContent, setStreamingContent] = useState(""); + const [error, setError] = useState(null); + const abortRef = useRef(null); + const isNewConversationRef = useRef(false); + + // Load existing conversation + const conversationQuery = useQuery({ + ...trpc.ai.getVrlConversation.queryOptions({ pipelineId, componentKey }), + enabled: !!pipelineId && !!componentKey, + }); + + // Sync loaded conversation into local state + const loadedConversation = conversationQuery.data; + if ( + loadedConversation && + !conversationId && + messages.length === 0 && + !isStreaming && + !isNewConversationRef.current + ) { + setConversationId(loadedConversation.id); + setMessages( + loadedConversation.messages.map((m) => ({ + id: m.id, + role: m.role as "user" | "assistant", + content: m.content, + suggestions: m.suggestions as unknown as VrlSuggestion[] | undefined, + vrlCode: m.vrlCode, + createdAt: + m.createdAt instanceof Date + ? m.createdAt.toISOString() + : String(m.createdAt), + createdBy: m.createdBy, + })), + ); + } + + const markAppliedMutation = useMutation( + trpc.ai.markVrlSuggestionsApplied.mutationOptions({ + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: trpc.ai.getVrlConversation.queryKey({ + pipelineId, + componentKey, + }), + }); + }, + }), + ); + + const sendMessage = useCallback( + async (prompt: string) => { + if (!prompt.trim() || !selectedTeamId || isStreaming) return; + + isNewConversationRef.current = false; + setIsStreaming(true); + setStreamingContent(""); + setError(null); + + // Add optimistic user message + const userMessage: VrlConversationMessage = { + id: `temp-user-${Date.now()}`, + role: "user", + content: prompt.trim(), + vrlCode: currentCode ?? null, + createdAt: new Date().toISOString(), + }; + setMessages((prev) => [...prev, userMessage]); + + abortRef.current = new AbortController(); + let fullResponse = ""; + + try { + const response = await fetch("/api/ai/vrl-chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + teamId: selectedTeamId, + prompt: prompt.trim(), + currentCode, + fields, + componentType, + sourceTypes, + pipelineId, + componentKey, + conversationId, + }), + signal: abortRef.current.signal, + }); + + if (!response.ok) { + const errData = await response + .json() + .catch(() => ({ error: "Request failed" })); + throw new Error(errData.error || `HTTP ${response.status}`); + } + + const reader = response.body?.getReader(); + if (!reader) throw new Error("No response stream"); + + const decoder = new TextDecoder(); + let buffer = ""; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() ?? ""; + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed || !trimmed.startsWith("data: ")) continue; + + try { + const data = JSON.parse(trimmed.slice(6)); + if (data.conversationId) { + setConversationId(data.conversationId); + continue; + } + if (data.done) break; + if (data.error) throw new Error(data.error); + if (data.token) { + fullResponse += data.token; + setStreamingContent(fullResponse); + } + } catch (parseErr) { + if ( + parseErr instanceof Error && + parseErr.message !== "Unexpected end of JSON input" + ) { + throw parseErr; + } + } + } + } + + // Parse the completed response + const parsed = parseVrlChatResponse(fullResponse); + + const assistantMessage: VrlConversationMessage = { + id: `temp-assistant-${Date.now()}`, + role: "assistant", + content: fullResponse, + suggestions: parsed?.suggestions, + vrlCode: currentCode ?? null, + createdAt: new Date().toISOString(), + }; + setMessages((prev) => [...prev, assistantMessage]); + setStreamingContent(""); + + // Refetch to sync local state with server-persisted messages (real IDs) + const refetched = await queryClient.fetchQuery({ + ...trpc.ai.getVrlConversation.queryOptions({ + pipelineId, + componentKey, + }), + staleTime: 0, + }); + if (refetched?.messages) { + setMessages( + refetched.messages.map((m) => ({ + id: m.id, + role: m.role as "user" | "assistant", + content: m.content, + suggestions: m.suggestions as unknown as + | VrlSuggestion[] + | undefined, + vrlCode: m.vrlCode, + createdAt: + m.createdAt instanceof Date + ? m.createdAt.toISOString() + : String(m.createdAt), + createdBy: m.createdBy, + })), + ); + } + } catch (err) { + if (err instanceof Error && err.name === "AbortError") return; + setError(err instanceof Error ? err.message : "AI request failed"); + } finally { + setIsStreaming(false); + abortRef.current = null; + } + }, + [ + selectedTeamId, + isStreaming, + currentCode, + fields, + componentType, + sourceTypes, + pipelineId, + componentKey, + conversationId, + queryClient, + trpc, + ], + ); + + const startNewConversation = useCallback(() => { + isNewConversationRef.current = true; + queryClient.removeQueries({ + queryKey: trpc.ai.getVrlConversation.queryKey({ + pipelineId, + componentKey, + }), + }); + setMessages([]); + setConversationId(null); + setStreamingContent(""); + setError(null); + }, [queryClient, trpc, pipelineId, componentKey]); + + const markSuggestionsApplied = useCallback( + (messageId: string, suggestionIds: string[]) => { + if (!conversationId) return; + if (messageId.startsWith("temp-")) return; + + markAppliedMutation.mutate({ + pipelineId, + conversationId, + messageId, + suggestionIds, + }); + }, + [conversationId, pipelineId, markAppliedMutation], + ); + + const cancelStreaming = useCallback(() => { + abortRef.current?.abort(); + }, []); + + return { + messages, + conversationId, + isStreaming, + streamingContent, + error, + isLoading: conversationQuery.isLoading, + sendMessage, + startNewConversation, + markSuggestionsApplied, + cancelStreaming, + }; +} From 1bc3993b6250ae48d891729a42b6a97ee6b6c5e4 Mon Sep 17 00:00:00 2001 From: TerrifiedBug Date: Wed, 11 Mar 2026 13:35:30 +0000 Subject: [PATCH 07/15] feat: add VRL suggestion card component --- .../vrl-editor/vrl-suggestion-card.tsx | 123 ++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 src/components/vrl-editor/vrl-suggestion-card.tsx diff --git a/src/components/vrl-editor/vrl-suggestion-card.tsx b/src/components/vrl-editor/vrl-suggestion-card.tsx new file mode 100644 index 0000000..41d38fe --- /dev/null +++ b/src/components/vrl-editor/vrl-suggestion-card.tsx @@ -0,0 +1,123 @@ +"use client"; + +import { Checkbox } from "@/components/ui/checkbox"; +import { Badge } from "@/components/ui/badge"; +import { cn } from "@/lib/utils"; +import type { VrlSuggestion, VrlSuggestionStatus } from "@/lib/ai/vrl-suggestion-types"; + +interface VrlSuggestionCardProps { + suggestion: VrlSuggestion; + status: VrlSuggestionStatus; + isSelected: boolean; + onToggle: (id: string) => void; +} + +const TYPE_LABELS: Record = { + insert_code: "Insert", + replace_code: "Replace", + remove_code: "Remove", +}; + +const TYPE_COLORS: Record = { + insert_code: "bg-green-500/15 text-green-700 dark:text-green-400", + replace_code: "bg-amber-500/15 text-amber-700 dark:text-amber-400", + remove_code: "bg-red-500/15 text-red-700 dark:text-red-400", +}; + +const PRIORITY_COLORS: Record = { + high: "bg-red-500/15 text-red-700 dark:text-red-400", + medium: "bg-amber-500/15 text-amber-700 dark:text-amber-400", + low: "bg-green-500/15 text-green-700 dark:text-green-400", +}; + +const STATUS_BADGES: Partial< + Record +> = { + applied: { + label: "Applied", + className: "bg-green-500/15 text-green-700 dark:text-green-400", + }, + outdated: { + label: "Outdated", + className: "bg-amber-500/15 text-amber-700 dark:text-amber-400", + }, +}; + +export function VrlSuggestionCard({ + suggestion, + status, + isSelected, + onToggle, +}: VrlSuggestionCardProps) { + const isDisabled = status === "applied" || status === "outdated"; + const statusBadge = STATUS_BADGES[status]; + + return ( +
+
+ onToggle(suggestion.id)} + className="mt-0.5" + /> + +
+
+ + {suggestion.title} + + + {statusBadge && ( + + {statusBadge.label} + + )} + + + {suggestion.priority} + + + + {TYPE_LABELS[suggestion.type]} + +
+ +

+ {suggestion.description} +

+ + {suggestion.code && ( +
+              {suggestion.code}
+            
+ )} +
+
+
+ ); +} From 6d1aff666026d2b0063cffd58bea50e76a9df00b Mon Sep 17 00:00:00 2001 From: TerrifiedBug Date: Wed, 11 Mar 2026 13:36:59 +0000 Subject: [PATCH 08/15] feat: add VRL AI message bubble component --- src/components/vrl-editor/vrl-ai-message.tsx | 158 +++++++++++++++++++ 1 file changed, 158 insertions(+) create mode 100644 src/components/vrl-editor/vrl-ai-message.tsx diff --git a/src/components/vrl-editor/vrl-ai-message.tsx b/src/components/vrl-editor/vrl-ai-message.tsx new file mode 100644 index 0000000..03f0eaf --- /dev/null +++ b/src/components/vrl-editor/vrl-ai-message.tsx @@ -0,0 +1,158 @@ +"use client"; + +import { useState, useMemo } from "react"; +import { Bot, User } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { VrlSuggestionCard } from "./vrl-suggestion-card"; +import type { VrlSuggestion, VrlSuggestionStatus } from "@/lib/ai/vrl-suggestion-types"; +import { + parseVrlChatResponse, + computeVrlSuggestionStatuses, +} from "@/lib/ai/vrl-suggestion-types"; +import type { VrlConversationMessage } from "@/hooks/use-vrl-ai-conversation"; + +interface VrlAiMessageProps { + message: VrlConversationMessage; + currentCode: string; + onApplySelected: (messageId: string, suggestions: VrlSuggestion[]) => void; +} + +export function VrlAiMessage({ + message, + currentCode, + onApplySelected, +}: VrlAiMessageProps) { + const [selectedIds, setSelectedIds] = useState>(new Set()); + + const suggestions = useMemo( + () => message.suggestions ?? [], + [message.suggestions], + ); + const hasSuggestions = + message.role === "assistant" && suggestions.length > 0; + + // Parse summary from assistant JSON content + const summary = useMemo(() => { + if (message.role !== "assistant") return null; + if (!hasSuggestions) return null; + try { + const parsed = JSON.parse(message.content); + return parsed.summary as string | undefined; + } catch { + return null; + } + }, [message.content, message.role, hasSuggestions]); + + // Compute suggestion statuses based on current editor content + const suggestionStatuses = useMemo( + () => computeVrlSuggestionStatuses(suggestions, currentCode), + [suggestions, currentCode], + ); + + const actionableSuggestions = suggestions.filter( + (s) => suggestionStatuses.get(s.id) === "actionable", + ); + + const selectedSuggestions = suggestions.filter((s) => + selectedIds.has(s.id), + ); + + const handleToggle = (id: string) => { + setSelectedIds((prev) => { + const next = new Set(prev); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + return next; + }); + }; + + const handleApplyAll = () => { + if (actionableSuggestions.length > 0) { + onApplySelected(message.id, actionableSuggestions); + } + }; + + const handleApplySelected = () => { + if (selectedSuggestions.length > 0) { + onApplySelected(message.id, selectedSuggestions); + } + }; + + if (message.role === "user") { + return ( +
+
+ +
+
+

{message.content}

+
+
+ ); + } + + // Assistant message without suggestions — raw text fallback + if (!hasSuggestions) { + // Try to extract summary from raw text + const parsed = parseVrlChatResponse(message.content); + const displayText = parsed?.summary ?? message.content; + + return ( +
+
+ +
+
+
{displayText}
+
+
+ ); + } + + return ( +
+
+ +
+
+ {summary && ( +

{summary}

+ )} + +
+ {suggestions.map((s) => ( + + ))} +
+ + {actionableSuggestions.length > 0 && ( +
+ + +
+ )} +
+
+ ); +} From 795fe93f733fb94c221dffce9f2be777653abbb4 Mon Sep 17 00:00:00 2001 From: TerrifiedBug Date: Wed, 11 Mar 2026 13:38:49 +0000 Subject: [PATCH 09/15] feat: add VRL AI slide-out panel component --- src/components/vrl-editor/vrl-ai-panel.tsx | 186 +++++++++++++++++++++ 1 file changed, 186 insertions(+) create mode 100644 src/components/vrl-editor/vrl-ai-panel.tsx diff --git a/src/components/vrl-editor/vrl-ai-panel.tsx b/src/components/vrl-editor/vrl-ai-panel.tsx new file mode 100644 index 0000000..0384b3c --- /dev/null +++ b/src/components/vrl-editor/vrl-ai-panel.tsx @@ -0,0 +1,186 @@ +"use client"; + +import { useState, useRef, useEffect, useCallback } from "react"; +import { Bot, Loader2, RotateCcw, Send, X } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { VrlAiMessage } from "./vrl-ai-message"; +import { applyVrlSuggestion } from "@/lib/ai/vrl-suggestion-types"; +import type { VrlSuggestion } from "@/lib/ai/vrl-suggestion-types"; +import type { useVrlAiConversation } from "@/hooks/use-vrl-ai-conversation"; + +type ConversationReturn = ReturnType; + +interface VrlAiPanelProps { + conversation: ConversationReturn; + currentCode: string; + onCodeChange: (code: string) => void; + onClose: () => void; +} + +export function VrlAiPanel({ + conversation, + currentCode, + onCodeChange, + onClose, +}: VrlAiPanelProps) { + const [prompt, setPrompt] = useState(""); + const textareaRef = useRef(null); + const messagesEndRef = useRef(null); + + // Auto-scroll to bottom when new messages arrive + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [conversation.messages, conversation.streamingContent]); + + // Auto-grow textarea + useEffect(() => { + const ta = textareaRef.current; + if (!ta) return; + ta.style.height = "auto"; + const maxHeight = 4 * 24; // 4 lines × ~24px line height + ta.style.height = `${Math.min(ta.scrollHeight, maxHeight)}px`; + }, [prompt]); + + const handleSend = useCallback(() => { + if (!prompt.trim() || conversation.isStreaming) return; + conversation.sendMessage(prompt.trim()); + setPrompt(""); + }, [prompt, conversation]); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleSend(); + } + }; + + const handleApplySelected = useCallback( + (messageId: string, suggestions: VrlSuggestion[]) => { + let code = currentCode; + const appliedIds: string[] = []; + + for (const suggestion of suggestions) { + const result = applyVrlSuggestion(suggestion, code); + if (result !== null) { + code = result; + appliedIds.push(suggestion.id); + } + } + + if (appliedIds.length > 0) { + onCodeChange(code); + conversation.markSuggestionsApplied(messageId, appliedIds); + } + }, + [currentCode, onCodeChange, conversation], + ); + + return ( +
+ {/* Header */} +
+
+ + AI Assistant +
+
+ + +
+
+ + {/* Messages */} +
+ {conversation.isLoading && ( +
+ + Loading conversation... +
+ )} + + {conversation.messages.map((msg) => ( + + ))} + + {/* Streaming indicator */} + {conversation.isStreaming && ( +
+
+ +
+
+ {conversation.streamingContent ? ( +
+ {conversation.streamingContent} +
+ ) : ( + + + Thinking... + + )} +
+
+ )} + + {conversation.error && ( +
+ {conversation.error} +
+ )} + +
+
+ + {/* Input */} +
+
+