-
Notifications
You must be signed in to change notification settings - Fork 0
feat: VRL AI conversational chat panel #96
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
15 commits
Select commit
Hold shift + click to select a range
650765d
feat: add componentKey and vrlCode fields for VRL AI conversations
TerrifiedBug e7e387a
feat: add VRL suggestion types, parser, and apply logic
TerrifiedBug 32c0485
feat: add VRL chat system prompt for structured JSON responses
TerrifiedBug 8e3182b
feat: add VRL chat SSE API endpoint with conversation persistence
TerrifiedBug 7923441
feat: add tRPC procedures for VRL conversation CRUD
TerrifiedBug 5503feb
feat: add useVrlAiConversation hook for VRL chat state management
TerrifiedBug 1bc3993
feat: add VRL suggestion card component
TerrifiedBug 6d1aff6
feat: add VRL AI message bubble component
TerrifiedBug 795fe93
feat: add VRL AI slide-out panel component
TerrifiedBug e06447e
feat: integrate VRL AI slide-out panel into editor dialog
TerrifiedBug a4530c5
chore: remove deprecated ai-input.tsx and fix lint warning
TerrifiedBug 39c66c0
fix: prevent double-apply of insert_code suggestions and use replaceAll
TerrifiedBug b039b7b
fix: optimistically update applied suggestion state in local messages
TerrifiedBug 10aac0d
fix: use actionable count for Apply Selected button and reuse existin…
TerrifiedBug 664f8fe
fix: disable New button during streaming and guard abort-path enqueue
TerrifiedBug File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
8 changes: 8 additions & 0 deletions
8
prisma/migrations/20260311030000_add_vrl_conversation_fields/migration.sql
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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"); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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", | ||
| }, | ||
| }); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.