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()) 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..165d59d --- /dev/null +++ b/src/app/api/ai/vrl-chat/route.ts @@ -0,0 +1,246 @@ +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) { + // Reuse existing conversation for this pipeline + component if one exists + const existing = await prisma.aiConversation.findFirst({ + where: { pipelineId: body.pipelineId, componentKey: body.componentKey }, + orderBy: { createdAt: "desc" }, + select: { id: true }, + }); + if (existing) { + conversationId = existing.id; + } else { + 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) { + if (!request.signal.aborted) { + 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", + }, + }); +} diff --git a/src/components/flow/detail-panel.tsx b/src/components/flow/detail-panel.tsx index 4aea047..5ca252f 100644 --- a/src/components/flow/detail-panel.tsx +++ b/src/components/flow/detail-panel.tsx @@ -461,6 +461,7 @@ export function DetailPanel({ pipelineId, isDeployed }: DetailPanelProps) { onChange={(v) => handleConfigChange({ ...config, source: v })} sourceTypes={upstream.sourceTypes} pipelineId={pipelineId} + componentKey={componentKey} upstreamSourceKeys={upstream.sourceKeys} /> @@ -475,6 +476,7 @@ export function DetailPanel({ pipelineId, isDeployed }: DetailPanelProps) { onChange={(v) => handleConfigChange({ ...config, condition: v })} sourceTypes={upstream.sourceTypes} pipelineId={pipelineId} + componentKey={componentKey} upstreamSourceKeys={upstream.sourceKeys} /> @@ -505,6 +507,7 @@ export function DetailPanel({ pipelineId, isDeployed }: DetailPanelProps) { height="120px" sourceTypes={upstream.sourceTypes} pipelineId={pipelineId} + componentKey={componentKey} upstreamSourceKeys={upstream.sourceKeys} /> diff --git a/src/components/vrl-editor/ai-input.tsx b/src/components/vrl-editor/ai-input.tsx deleted file mode 100644 index f32d7fe..0000000 --- a/src/components/vrl-editor/ai-input.tsx +++ /dev/null @@ -1,186 +0,0 @@ -// src/components/vrl-editor/ai-input.tsx -"use client"; - -import { useState, useRef, useCallback } from "react"; -import { Loader2, RotateCcw, Plus, Replace } from "lucide-react"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { useTeamStore } from "@/stores/team-store"; - -interface AiInputProps { - currentCode: string; - fields?: { name: string; type: string }[]; - componentType?: string; - sourceTypes?: string[]; - onInsert: (code: string) => void; - onReplace: (code: string) => void; -} - -export function AiInput({ - currentCode, - fields, - componentType, - sourceTypes, - onInsert, - onReplace, -}: AiInputProps) { - const [prompt, setPrompt] = useState(""); - const [result, setResult] = useState(""); - const [isStreaming, setIsStreaming] = useState(false); - const [error, setError] = useState(null); - const abortRef = useRef(null); - const selectedTeamId = useTeamStore((s) => s.selectedTeamId); - - const handleSubmit = useCallback( - async (e?: React.FormEvent) => { - e?.preventDefault(); - if (!prompt.trim() || !selectedTeamId || isStreaming) return; - - setIsStreaming(true); - setResult(""); - setError(null); - - abortRef.current = new AbortController(); - - try { - const response = await fetch("/api/ai/vrl", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - teamId: selectedTeamId, - prompt: prompt.trim(), - currentCode, - fields, - componentType, - sourceTypes, - }), - 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.done) break; - if (data.error) throw new Error(data.error); - if (data.token) { - setResult((prev) => prev + data.token); - } - } catch (parseErr) { - if (parseErr instanceof Error && parseErr.message !== "Unexpected end of JSON input") { - throw parseErr; - } - } - } - } - } 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; - } - }, - [prompt, selectedTeamId, currentCode, fields, componentType, sourceTypes, isStreaming], - ); - - const handleCancel = () => { - abortRef.current?.abort(); - }; - - const handleRegenerate = () => { - setResult(""); - handleSubmit(); - }; - - return ( -
-
- setPrompt(e.target.value)} - placeholder="Describe what you want the VRL to do..." - disabled={isStreaming} - className="text-sm" - /> - {isStreaming ? ( - - ) : ( - - )} -
- - {error && ( -
- {error} -
- )} - - {(result || isStreaming) && ( -
-
- {result || ( - - - Generating... - - )} -
- {!isStreaming && result && ( -
- - - -
- )} -
- )} -
- ); -} 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..52cf5b1 --- /dev/null +++ b/src/components/vrl-editor/vrl-ai-message.tsx @@ -0,0 +1,165 @@ +"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 } 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 actionableSelectedSuggestions = selectedSuggestions.filter( + (s) => suggestionStatuses.get(s.id) === "actionable", + ); + + 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 = () => { + const applicableSelected = selectedSuggestions.filter( + (s) => suggestionStatuses.get(s.id) === "actionable", + ); + if (applicableSelected.length > 0) { + onApplySelected(message.id, applicableSelected); + } + }; + + 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 && ( +
+ + +
+ )} +
+
+ ); +} 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..3d8cc4d --- /dev/null +++ b/src/components/vrl-editor/vrl-ai-panel.tsx @@ -0,0 +1,187 @@ +"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 */} +
+
+