From a47a13d21d15b57ee9d8e7658fa0ba8efd0d6cfa Mon Sep 17 00:00:00 2001 From: Lftobs Date: Tue, 1 Sep 2026 03:18:29 +0100 Subject: [PATCH] feat(ai): multi-provider build failure diagnosis and resolution assistant --- apps/api/src/ai/__tests__/ai.test.ts | 217 +++++++ apps/api/src/ai/diagnose.ts | 155 +++++ apps/api/src/ai/index.ts | 4 + apps/api/src/ai/prompt.ts | 90 +++ apps/api/src/ai/providers/claude.ts | 42 ++ apps/api/src/ai/providers/gemini.ts | 51 ++ apps/api/src/ai/providers/grok.ts | 40 ++ apps/api/src/ai/providers/openai.ts | 41 ++ apps/api/src/ai/test-connection.ts | 48 ++ apps/api/src/ai/types.ts | 38 ++ apps/api/src/api/deployments/ai.ts | 43 ++ apps/api/src/api/deployments/index.ts | 203 +------ apps/api/src/api/deployments/logs.ts | 202 +++++++ apps/api/src/api/settings/index.ts | 36 ++ .../0004_add_ai_settings_and_diagnoses.sql | 38 ++ apps/api/src/db/migrations/meta/_journal.json | 7 + apps/api/src/db/repo/ai-settings.ts | 244 ++++++++ apps/api/src/db/repo/index.ts | 7 + apps/api/src/db/schema.ts | 39 ++ apps/api/src/db/test-helper.ts | 17 +- apps/web/src/api/ai.ts | 46 ++ apps/web/src/api/client.ts | 197 +------ apps/web/src/api/core.ts | 42 ++ apps/web/src/api/settings.ts | 65 +++ .../project/deployments/AiBuildFixDialog.tsx | 432 ++++++++++++++ .../project/deployments/DeploymentsTab.tsx | 327 +++-------- .../deployments/deployment-history.tsx | 40 +- .../project/deployments/deployment-logs.tsx | 155 +++-- .../settings/AiIntegrationSection.tsx | 335 +++++++++++ .../components/settings/ApiKeysSection.tsx | 102 ++++ .../settings/GithubIntegrationSection.tsx | 86 +++ .../components/settings/ServersSection.tsx | 253 ++++++++ .../src/components/settings/SmtpSection.tsx | 100 ++++ .../settings/ai/ProviderConfigCard.tsx | 100 ++++ apps/web/src/routes/Settings.tsx | 545 +----------------- apps/web/src/types/index.ts | 47 ++ 36 files changed, 3233 insertions(+), 1201 deletions(-) create mode 100644 apps/api/src/ai/__tests__/ai.test.ts create mode 100644 apps/api/src/ai/diagnose.ts create mode 100644 apps/api/src/ai/index.ts create mode 100644 apps/api/src/ai/prompt.ts create mode 100644 apps/api/src/ai/providers/claude.ts create mode 100644 apps/api/src/ai/providers/gemini.ts create mode 100644 apps/api/src/ai/providers/grok.ts create mode 100644 apps/api/src/ai/providers/openai.ts create mode 100644 apps/api/src/ai/test-connection.ts create mode 100644 apps/api/src/ai/types.ts create mode 100644 apps/api/src/api/deployments/ai.ts create mode 100644 apps/api/src/api/deployments/logs.ts create mode 100644 apps/api/src/db/migrations/0004_add_ai_settings_and_diagnoses.sql create mode 100644 apps/api/src/db/repo/ai-settings.ts create mode 100644 apps/web/src/api/ai.ts create mode 100644 apps/web/src/api/core.ts create mode 100644 apps/web/src/api/settings.ts create mode 100644 apps/web/src/components/project/deployments/AiBuildFixDialog.tsx create mode 100644 apps/web/src/components/settings/AiIntegrationSection.tsx create mode 100644 apps/web/src/components/settings/ApiKeysSection.tsx create mode 100644 apps/web/src/components/settings/GithubIntegrationSection.tsx create mode 100644 apps/web/src/components/settings/ServersSection.tsx create mode 100644 apps/web/src/components/settings/SmtpSection.tsx create mode 100644 apps/web/src/components/settings/ai/ProviderConfigCard.tsx diff --git a/apps/api/src/ai/__tests__/ai.test.ts b/apps/api/src/ai/__tests__/ai.test.ts new file mode 100644 index 0000000..b0705b0 --- /dev/null +++ b/apps/api/src/ai/__tests__/ai.test.ts @@ -0,0 +1,217 @@ +import { describe, it, expect, beforeEach, afterEach, mock } from "bun:test"; +import { extractBuildErrorContext, SYSTEM_PROMPT } from "../prompt"; +import { resolveProviderConfig, diagnoseDeploymentFailure } from "../diagnose"; +import { testAiConnection } from "../test-connection"; +import { callOpenAi } from "../providers/openai"; +import { callGemini } from "../providers/gemini"; +import { callGrok } from "../providers/grok"; +import { callClaude } from "../providers/claude"; + +describe("AI Build Failure Analysis", () => { + const originalFetch = globalThis.fetch; + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + describe("extractBuildErrorContext", () => { + it("extracts context and strips ANSI escape sequences", () => { + const result = extractBuildErrorContext({ + deploymentId: "dep-1", + projectName: "my-web-app", + projectType: "web", + buildType: "railpack", + sourceType: "git", + branch: "main", + failureReason: "Build exited with code 1", + logs: [ + { sequence: 1, stage: "build", message: "\u001b[32m[info]\u001b[0m Installing dependencies..." }, + { sequence: 2, stage: "build", message: "\u001b[31mError: Cannot find module 'pg'\u001b[0m" }, + { sequence: 3, stage: "build", message: "Command failed: bun build" }, + ], + }); + + expect(result).toContain("Project: my-web-app"); + expect(result).toContain("Build Strategy: railpack"); + expect(result).toContain("Source: git (branch: main)"); + expect(result).toContain("Failure Reason: Build exited with code 1"); + expect(result).toContain("Error: Cannot find module 'pg'"); + expect(result).not.toContain("\u001b[31m"); + }); + }); + + describe("resolveProviderConfig", () => { + it("throws when no API key is provided or configured", async () => { + await expect(resolveProviderConfig("openai", undefined, undefined)).rejects.toThrow( + "API key not configured for AI provider 'openai'", + ); + }); + + it("accepts explicit API key and custom model", async () => { + const config = await resolveProviderConfig("openai", "gpt-4o", "sk-test-123"); + expect(config.provider).toBe("openai"); + expect(config.model).toBe("gpt-4o"); + expect(config.apiKey).toBe("sk-test-123"); + }); + + it("uses default model when model is omitted", async () => { + const config = await resolveProviderConfig("gemini", undefined, "gemini-key-123"); + expect(config.provider).toBe("gemini"); + expect(config.model).toBe("gemini-2.0-flash"); + expect(config.apiKey).toBe("gemini-key-123"); + }); + }); + + describe("Provider HTTP Clients", () => { + it("calls OpenAI chat completions correctly", async () => { + globalThis.fetch = mock(async (url: any, opts: any) => { + expect(url.toString()).toBe("https://api.openai.com/v1/chat/completions"); + const body = JSON.parse(opts.body); + expect(body.model).toBe("gpt-4o-mini"); + expect(body.response_format).toEqual({ type: "json_object" }); + return new Response(JSON.stringify({ + choices: [{ message: { content: JSON.stringify({ summary: "Missing pg module" }) } }], + }), { status: 200 }); + }); + + const response = await callOpenAi({ + apiKey: "test-openai-key", + model: "gpt-4o-mini", + systemPrompt: "sys", + userPrompt: "user", + }); + + expect(response).toContain("Missing pg module"); + }); + + it("calls Gemini generateContent correctly", async () => { + globalThis.fetch = mock(async (url: any, opts: any) => { + expect(url.toString()).toContain("generativelanguage.googleapis.com"); + expect(url.toString()).toContain("key=test-gemini-key"); + const body = JSON.parse(opts.body); + expect(body.generationConfig.responseMimeType).toBe("application/json"); + return new Response(JSON.stringify({ + candidates: [{ content: { parts: [{ text: JSON.stringify({ summary: "Missing pg in Gemini" }) }] } }], + }), { status: 200 }); + }); + + const response = await callGemini({ + apiKey: "test-gemini-key", + model: "gemini-2.0-flash", + systemPrompt: "sys", + userPrompt: "user", + }); + + expect(response).toContain("Missing pg in Gemini"); + }); + + it("calls Grok completions correctly", async () => { + globalThis.fetch = mock(async (url: any, opts: any) => { + expect(url.toString()).toBe("https://api.x.ai/v1/chat/completions"); + expect(opts.headers.Authorization).toBe("Bearer test-grok-key"); + return new Response(JSON.stringify({ + choices: [{ message: { content: JSON.stringify({ summary: "Grok diagnosis" }) } }], + }), { status: 200 }); + }); + + const response = await callGrok({ + apiKey: "test-grok-key", + model: "grok-2-latest", + systemPrompt: "sys", + userPrompt: "user", + }); + + expect(response).toContain("Grok diagnosis"); + }); + + it("calls Claude messages correctly", async () => { + globalThis.fetch = mock(async (url: any, opts: any) => { + expect(url.toString()).toBe("https://api.anthropic.com/v1/messages"); + expect(opts.headers["x-api-key"]).toBe("test-claude-key"); + expect(opts.headers["anthropic-version"]).toBe("2023-06-01"); + return new Response(JSON.stringify({ + content: [{ text: JSON.stringify({ summary: "Claude diagnosis" }) }], + }), { status: 200 }); + }); + + const response = await callClaude({ + apiKey: "test-claude-key", + model: "claude-3-5-sonnet-20241022", + systemPrompt: "sys", + userPrompt: "user", + }); + + expect(response).toContain("Claude diagnosis"); + }); + }); + + describe("testAiConnection", () => { + it("returns ok=true when ping succeeds", async () => { + globalThis.fetch = mock(async () => { + return new Response(JSON.stringify({ + choices: [{ message: { content: "OK" } }], + }), { status: 200 }); + }); + + const result = await testAiConnection("openai", "sk-test", "gpt-4o-mini"); + expect(result.ok).toBe(true); + expect(result.provider).toBe("openai"); + expect(result.message).toContain("Successfully connected"); + }); + + it("returns ok=false when provider API errors", async () => { + globalThis.fetch = mock(async () => { + return new Response(JSON.stringify({ + error: { message: "Invalid API key provided" }, + }), { status: 401 }); + }); + + const result = await testAiConnection("openai", "sk-invalid", "gpt-4o-mini"); + expect(result.ok).toBe(false); + expect(result.message).toContain("Invalid API key"); + }); + }); + + describe("diagnoseDeploymentFailure", () => { + it("parses valid structured JSON diagnosis correctly", async () => { + globalThis.fetch = mock(async () => { + return new Response(JSON.stringify({ + choices: [{ + message: { + content: JSON.stringify({ + summary: "TypeScript compilation error", + rootCause: "Type 'string' is not assignable to type 'number'", + explanation: "File src/index.ts has a type error on line 42", + suggestedFixes: [ + { + title: "Cast or parse to number", + description: "Use Number(value) instead of raw string", + actionType: "code", + snippet: "const id = Number(rawId);", + }, + ], + }), + }, + }], + }), { status: 200 }); + }); + + const { resolveProviderConfig } = await import("../diagnose"); + const { callOpenAi } = await import("../providers/openai"); + + const config = await resolveProviderConfig("openai", "gpt-4o-mini", "sk-mock-key"); + const raw = await callOpenAi({ + apiKey: config.apiKey, + model: config.model, + systemPrompt: "sys", + userPrompt: "user", + }); + + const parsed = JSON.parse(raw); + expect(parsed.summary).toBe("TypeScript compilation error"); + expect(parsed.rootCause).toContain("Type 'string'"); + expect(parsed.suggestedFixes.length).toBe(1); + expect(parsed.suggestedFixes[0].snippet).toBe("const id = Number(rawId);"); + }); + }); +}); diff --git a/apps/api/src/ai/diagnose.ts b/apps/api/src/ai/diagnose.ts new file mode 100644 index 0000000..b070c74 --- /dev/null +++ b/apps/api/src/ai/diagnose.ts @@ -0,0 +1,155 @@ +import type { AiDiagnoseOptions, AiDiagnosisResult, AiProvider } from "./types"; +import { getAiSettings, saveAiDiagnosis } from "../db/repo/ai-settings"; +import { getDeploymentById, getLogs } from "../db/repo/deployments"; +import { getProjectById } from "../db/repo/projects"; +import { SYSTEM_PROMPT, extractBuildErrorContext } from "./prompt"; +import { callOpenAi } from "./providers/openai"; +import { callGemini } from "./providers/gemini"; +import { callGrok } from "./providers/grok"; +import { callClaude } from "./providers/claude"; + +const DEFAULT_MODELS: Record = { + openai: "gpt-4o-mini", + gemini: "gemini-2.0-flash", + grok: "grok-2-latest", + claude: "claude-3-5-sonnet-20241022", +}; + +export async function resolveProviderConfig( + providerReq?: AiProvider, + modelReq?: string, + apiKeyReq?: string, +): Promise<{ provider: AiProvider; model: string; apiKey: string }> { + const settings = await getAiSettings(); + const provider: AiProvider = providerReq || settings.defaultProvider || "openai"; + + let apiKey = apiKeyReq; + let defaultModel = DEFAULT_MODELS[provider]; + + if (provider === "openai") { + apiKey = apiKey || settings.openaiApiKey || process.env.OPENAI_API_KEY; + defaultModel = settings.openaiModel || DEFAULT_MODELS.openai; + } else if (provider === "gemini") { + apiKey = apiKey || settings.geminiApiKey || process.env.GEMINI_API_KEY; + defaultModel = settings.geminiModel || DEFAULT_MODELS.gemini; + } else if (provider === "grok") { + apiKey = apiKey || settings.grokApiKey || process.env.GROK_API_KEY || process.env.XAI_API_KEY; + defaultModel = settings.grokModel || DEFAULT_MODELS.grok; + } else if (provider === "claude") { + apiKey = apiKey || settings.claudeApiKey || process.env.ANTHROPIC_API_KEY || process.env.CLAUDE_API_KEY; + defaultModel = settings.claudeModel || DEFAULT_MODELS.claude; + } + + if (!apiKey) { + throw new Error(`API key not configured for AI provider '${provider}'. Please configure it in Settings or provide an API key.`); + } + + const model = modelReq || defaultModel; + return { provider, model, apiKey }; +} + +function parseAiResponse(raw: string, provider: AiProvider, model: string): AiDiagnosisResult { + let cleaned = raw.trim(); + const jsonBlockMatch = cleaned.match(/```(?:json)?\s*([\s\S]*?)\s*```/); + if (jsonBlockMatch) { + cleaned = jsonBlockMatch[1].trim(); + } + + try { + const parsed = JSON.parse(cleaned); + const summary = String(parsed.summary || "Build/deployment failed"); + const rootCause = String(parsed.rootCause || parsed.root_cause || "Error occurred during execution"); + const explanation = String(parsed.explanation || "See logs for details"); + const rawFixes = Array.isArray(parsed.suggestedFixes || parsed.suggested_fixes) ? (parsed.suggestedFixes || parsed.suggested_fixes) : []; + + const suggestedFixes = rawFixes.map((f: any) => ({ + title: String(f.title || "Fix instruction"), + description: String(f.description || ""), + actionType: (f.actionType || f.action_type || "code") as any, + snippet: f.snippet ? String(f.snippet) : undefined, + })); + + return { + provider, + model, + summary, + rootCause, + explanation, + suggestedFixes, + rawResponse: raw, + }; + } catch { + return { + provider, + model, + summary: "Build failure analyzed", + rootCause: "Review the raw AI diagnosis below for details", + explanation: raw, + suggestedFixes: [], + rawResponse: raw, + }; + } +} + +export async function diagnoseDeploymentFailure(options: AiDiagnoseOptions): Promise { + const deployment = await getDeploymentById(options.deploymentId); + if (!deployment) { + throw new Error(`Deployment ${options.deploymentId} not found`); + } + + const project = deployment.projectId ? await getProjectById(deployment.projectId) : null; + const rawLogs = await getLogs(options.deploymentId); + + const errorContext = extractBuildErrorContext({ + deploymentId: deployment.id, + projectName: project?.name, + projectType: project?.projectType, + buildType: project?.buildType, + sourceType: deployment.sourceType, + branch: deployment.branch, + failureReason: deployment.failureReason, + logs: rawLogs, + }); + + const userPrompt = [ + options.customPrompt ? `User Question / Instructions: ${options.customPrompt}\n` : "", + "Please analyze the following deployment build failure and provide a structured diagnosis:", + errorContext, + ].filter(Boolean).join("\n"); + + const { provider, model, apiKey } = await resolveProviderConfig( + options.provider, + options.model, + options.apiKey, + ); + + let rawOutput = ""; + if (provider === "openai") { + rawOutput = await callOpenAi({ apiKey, model, systemPrompt: SYSTEM_PROMPT, userPrompt }); + } else if (provider === "gemini") { + rawOutput = await callGemini({ apiKey, model, systemPrompt: SYSTEM_PROMPT, userPrompt }); + } else if (provider === "grok") { + rawOutput = await callGrok({ apiKey, model, systemPrompt: SYSTEM_PROMPT, userPrompt }); + } else if (provider === "claude") { + rawOutput = await callClaude({ apiKey, model, systemPrompt: SYSTEM_PROMPT, userPrompt }); + } else { + throw new Error(`Unsupported provider: ${provider}`); + } + + const diagnosis = parseAiResponse(rawOutput, provider, model); + + await saveAiDiagnosis({ + deploymentId: deployment.id, + provider: diagnosis.provider, + model: diagnosis.model, + summary: diagnosis.summary, + rootCause: diagnosis.rootCause, + explanation: diagnosis.explanation, + suggestedFixes: diagnosis.suggestedFixes, + rawResponse: rawOutput, + }).catch((err) => { + console.error("[AI Diagnose] Failed to save diagnosis record:", err); + }); + + return diagnosis; +} diff --git a/apps/api/src/ai/index.ts b/apps/api/src/ai/index.ts new file mode 100644 index 0000000..1a329f2 --- /dev/null +++ b/apps/api/src/ai/index.ts @@ -0,0 +1,4 @@ +export * from "./types"; +export * from "./prompt"; +export * from "./diagnose"; +export * from "./test-connection"; diff --git a/apps/api/src/ai/prompt.ts b/apps/api/src/ai/prompt.ts new file mode 100644 index 0000000..c2ea96a --- /dev/null +++ b/apps/api/src/ai/prompt.ts @@ -0,0 +1,90 @@ +function stripAnsi(str: string): string { + let s = str.replace(/[\u001b\u009b]\[[\d;]*[A-Za-z]/g, ""); + s = s.replace(/\[(\d+;)*\d*m/g, ""); + return s; +} + +export interface DeploymentLogContext { + deploymentId: string; + projectName?: string; + projectType?: string; + buildType?: string; + sourceType?: string; + branch?: string | null; + failureReason?: string | null; + logs: Array<{ sequence: number; stage: string; message: string }>; +} + +export function extractBuildErrorContext(ctx: DeploymentLogContext): string { + const cleanLogs = ctx.logs.map((l) => ({ + stage: l.stage, + message: stripAnsi(l.message).trim(), + })).filter((l) => l.message.length > 0); + + const errorKeywords = [ + "error", "failed", "failure", "fatal", "exception", "cannot find module", + "exit code", "err!", "command failed", "syntaxerror", "typeerror", + "referenceerror", "not found", "permission denied", "build failed", + ]; + + let relevantLogs = cleanLogs; + if (cleanLogs.length > 120) { + const errorIndices: number[] = []; + cleanLogs.forEach((l, idx) => { + const lower = l.message.toLowerCase(); + if (errorKeywords.some((kw) => lower.includes(kw))) { + errorIndices.push(idx); + } + }); + + if (errorIndices.length > 0) { + const firstError = Math.max(0, errorIndices[0] - 15); + const lastError = Math.min(cleanLogs.length, errorIndices[errorIndices.length - 1] + 20); + const windowLogs = cleanLogs.slice(firstError, lastError); + if (windowLogs.length < 50) { + relevantLogs = cleanLogs.slice(-100); + } else { + relevantLogs = windowLogs; + } + } else { + relevantLogs = cleanLogs.slice(-100); + } + } + + const logLines = relevantLogs.map((l) => `[${l.stage}] ${l.message}`).join("\n"); + + return [ + `Project: ${ctx.projectName || "Unknown"}`, + `Project Type: ${ctx.projectType || "web"}`, + `Build Strategy: ${ctx.buildType || "railpack"}`, + `Source: ${ctx.sourceType || "git"}${ctx.branch ? ` (branch: ${ctx.branch})` : ""}`, + ctx.failureReason ? `Failure Reason: ${ctx.failureReason}` : "", + "", + "--- BUILD & DEPLOYMENT LOGS ---", + logLines || "(No logs recorded)", + ].filter(Boolean).join("\n"); +} + +export const SYSTEM_PROMPT = `You are an expert DevOps, Docker, BuildKit, Railpack, and Cloud Deployment specialist for Dequel (a modern self-hosted deployment platform). +Your task is to analyze build, compilation, packaging, and container startup failures and provide clear, precise, and actionable diagnosis to the developer. + +CRITICAL INSTRUCTIONS: +1. Identify the exact root cause from the logs (e.g. missing dependency, Node/Bun version incompatibility, wrong build script, missing environment variable, port binding error, syntax/type error, Dockerfile instruction issue). +2. Pinpoint the exact file, line number, package, or command if identifiable from the logs. +3. Provide step-by-step resolution instructions with copy-pasteable code snippets, command line commands, or configuration fixes. +4. Respond in valid JSON format matching this schema: +{ + "summary": "1-sentence executive summary of what failed", + "rootCause": "Clear explanation of the exact failure mechanism", + "explanation": "Detailed explanation of why this happened in this build environment and how Dequel/Docker ran into it", + "suggestedFixes": [ + { + "title": "Clear action title (e.g., Add missing dependency to package.json)", + "description": "Step-by-step description of what to do", + "actionType": "command" | "code" | "config" | "env", + "snippet": "bun add pg\n# or\nnpm install pg" + } + ] +} + +DO NOT include any text outside the JSON block. Return valid JSON only.`; diff --git a/apps/api/src/ai/providers/claude.ts b/apps/api/src/ai/providers/claude.ts new file mode 100644 index 0000000..96f0998 --- /dev/null +++ b/apps/api/src/ai/providers/claude.ts @@ -0,0 +1,42 @@ +import type { ProviderCallParams } from "../types"; + +export async function callClaude(params: ProviderCallParams): Promise { + const url = "https://api.anthropic.com/v1/messages"; + const body = { + model: params.model || "claude-3-5-sonnet-20241022", + max_tokens: 4096, + system: params.systemPrompt, + messages: [ + { role: "user", content: params.userPrompt }, + ], + temperature: 0.2, + }; + + const res = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-api-key": params.apiKey, + "anthropic-version": "2023-06-01", + }, + body: JSON.stringify(body), + }); + + if (!res.ok) { + const errorText = await res.text(); + let errorMsg = `Claude API error (${res.status})`; + try { + const errObj = JSON.parse(errorText); + if (errObj.error?.message) errorMsg = `Claude: ${errObj.error.message}`; + } catch {} + throw new Error(errorMsg); + } + + const data = (await res.json()) as any; + const content = data.content?.[0]?.text; + if (!content) { + throw new Error("Claude returned an empty response"); + } + + return content; +} diff --git a/apps/api/src/ai/providers/gemini.ts b/apps/api/src/ai/providers/gemini.ts new file mode 100644 index 0000000..bfe44d5 --- /dev/null +++ b/apps/api/src/ai/providers/gemini.ts @@ -0,0 +1,51 @@ +import type { ProviderCallParams } from "../types"; + +export async function callGemini(params: ProviderCallParams): Promise { + const model = params.model || "gemini-2.0-flash"; + const url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${encodeURIComponent(params.apiKey)}`; + + const body = { + system_instruction: { + parts: [{ text: params.systemPrompt }], + }, + contents: [ + { + role: "user", + parts: [{ text: params.userPrompt }], + }, + ], + generationConfig: { + temperature: 0.2, + responseMimeType: "application/json", + }, + }; + + const res = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }); + + if (!res.ok) { + const errorText = await res.text(); + let errorMsg = `Gemini API error (${res.status})`; + try { + const errObj = JSON.parse(errorText); + if (errObj.error?.message) errorMsg = `Gemini: ${errObj.error.message}`; + } catch {} + throw new Error(errorMsg); + } + + const data = (await res.json()) as any; + const candidate = data.candidates?.[0]; + const part = candidate?.content?.parts?.[0]; + const content = part?.text; + + if (!content) { + throw new Error("Gemini returned an empty response"); + } + + return content; +} diff --git a/apps/api/src/ai/providers/grok.ts b/apps/api/src/ai/providers/grok.ts new file mode 100644 index 0000000..68bc79e --- /dev/null +++ b/apps/api/src/ai/providers/grok.ts @@ -0,0 +1,40 @@ +import type { ProviderCallParams } from "../types"; + +export async function callGrok(params: ProviderCallParams): Promise { + const url = "https://api.x.ai/v1/chat/completions"; + const body = { + model: params.model || "grok-2-latest", + messages: [ + { role: "system", content: params.systemPrompt }, + { role: "user", content: params.userPrompt }, + ], + temperature: 0.2, + }; + + const res = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${params.apiKey}`, + }, + body: JSON.stringify(body), + }); + + if (!res.ok) { + const errorText = await res.text(); + let errorMsg = `Grok API error (${res.status})`; + try { + const errObj = JSON.parse(errorText); + if (errObj.error?.message) errorMsg = `Grok: ${errObj.error.message}`; + } catch {} + throw new Error(errorMsg); + } + + const data = (await res.json()) as any; + const content = data.choices?.[0]?.message?.content; + if (!content) { + throw new Error("Grok returned an empty response"); + } + + return content; +} diff --git a/apps/api/src/ai/providers/openai.ts b/apps/api/src/ai/providers/openai.ts new file mode 100644 index 0000000..6e3c813 --- /dev/null +++ b/apps/api/src/ai/providers/openai.ts @@ -0,0 +1,41 @@ +import type { ProviderCallParams } from "../types"; + +export async function callOpenAi(params: ProviderCallParams): Promise { + const url = "https://api.openai.com/v1/chat/completions"; + const body = { + model: params.model || "gpt-4o-mini", + messages: [ + { role: "system", content: params.systemPrompt }, + { role: "user", content: params.userPrompt }, + ], + temperature: 0.2, + response_format: { type: "json_object" }, + }; + + const res = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${params.apiKey}`, + }, + body: JSON.stringify(body), + }); + + if (!res.ok) { + const errorText = await res.text(); + let errorMsg = `OpenAI API error (${res.status})`; + try { + const errObj = JSON.parse(errorText); + if (errObj.error?.message) errorMsg = `OpenAI: ${errObj.error.message}`; + } catch {} + throw new Error(errorMsg); + } + + const data = (await res.json()) as any; + const content = data.choices?.[0]?.message?.content; + if (!content) { + throw new Error("OpenAI returned an empty response"); + } + + return content; +} diff --git a/apps/api/src/ai/test-connection.ts b/apps/api/src/ai/test-connection.ts new file mode 100644 index 0000000..0240cbc --- /dev/null +++ b/apps/api/src/ai/test-connection.ts @@ -0,0 +1,48 @@ +import type { AiProvider } from "./types"; +import { resolveProviderConfig } from "./diagnose"; +import { callOpenAi } from "./providers/openai"; +import { callGemini } from "./providers/gemini"; +import { callGrok } from "./providers/grok"; +import { callClaude } from "./providers/claude"; + +export async function testAiConnection( + providerReq: AiProvider, + apiKeyReq?: string, + modelReq?: string, +): Promise<{ ok: boolean; message: string; provider: AiProvider; model: string }> { + const { provider, model, apiKey } = await resolveProviderConfig( + providerReq, + modelReq, + apiKeyReq, + ); + + const systemPrompt = "You are a test ping responder. Respond with the single word: OK."; + const userPrompt = "Ping test"; + + try { + let output = ""; + if (provider === "openai") { + output = await callOpenAi({ apiKey, model, systemPrompt, userPrompt }); + } else if (provider === "gemini") { + output = await callGemini({ apiKey, model, systemPrompt, userPrompt }); + } else if (provider === "grok") { + output = await callGrok({ apiKey, model, systemPrompt, userPrompt }); + } else if (provider === "claude") { + output = await callClaude({ apiKey, model, systemPrompt, userPrompt }); + } + + return { + ok: true, + message: `Successfully connected to ${provider} (${model})`, + provider, + model, + }; + } catch (err: any) { + return { + ok: false, + message: err.message || `Failed to connect to ${provider}`, + provider, + model, + }; + } +} diff --git a/apps/api/src/ai/types.ts b/apps/api/src/ai/types.ts new file mode 100644 index 0000000..4c76684 --- /dev/null +++ b/apps/api/src/ai/types.ts @@ -0,0 +1,38 @@ +export type AiProvider = "openai" | "gemini" | "grok" | "claude"; + +export interface AiFixSuggestion { + title: string; + description: string; + actionType?: "command" | "code" | "config" | "env"; + snippet?: string; +} + +export interface AiDiagnosisResult { + provider: AiProvider; + model: string; + summary: string; + rootCause: string; + explanation: string; + suggestedFixes: AiFixSuggestion[]; + rawResponse?: string; +} + +export interface AiDiagnoseOptions { + deploymentId: string; + provider?: AiProvider; + model?: string; + apiKey?: string; + customPrompt?: string; +} + +export interface AiProviderConfig { + apiKey: string; + model: string; +} + +export interface ProviderCallParams { + apiKey: string; + model: string; + systemPrompt: string; + userPrompt: string; +} diff --git a/apps/api/src/api/deployments/ai.ts b/apps/api/src/api/deployments/ai.ts new file mode 100644 index 0000000..c14a526 --- /dev/null +++ b/apps/api/src/api/deployments/ai.ts @@ -0,0 +1,43 @@ +import { Elysia } from "elysia"; +import { getDeploymentById, getLatestAiDiagnosis } from "../../db/repo"; +import { diagnoseDeploymentFailure } from "../../ai"; +import { ok, fail } from "../response"; + +export const deploymentAiRoutes = new Elysia() + .post( + "/deployments/:id/ai-diagnose", + async ({ params: { id }, body, set }: any) => { + const deployment = await getDeploymentById(id); + if (!deployment) { + set.status = 404; + return fail("Deployment not found"); + } + + try { + const result = await diagnoseDeploymentFailure({ + deploymentId: id, + provider: body?.provider, + model: body?.model, + apiKey: body?.apiKey, + customPrompt: body?.customPrompt, + }); + return ok(result); + } catch (err: any) { + set.status = 400; + return fail(err.message || "Failed to analyze build failure with AI"); + } + }, + ) + .get( + "/deployments/:id/ai-diagnosis", + async ({ params: { id }, set }) => { + const deployment = await getDeploymentById(id); + if (!deployment) { + set.status = 404; + return fail("Deployment not found"); + } + + const diagnosis = await getLatestAiDiagnosis(id); + return ok(diagnosis); + }, + ); diff --git a/apps/api/src/api/deployments/index.ts b/apps/api/src/api/deployments/index.ts index 9f57f1f..e8d779c 100644 --- a/apps/api/src/api/deployments/index.ts +++ b/apps/api/src/api/deployments/index.ts @@ -17,6 +17,8 @@ import { executorFor } from "../../executors/dispatch"; import { queueRemoteDeployment, validateRemoteDeployment } from "../../agents/deployments"; import { isPrivateGitUrl } from "../../utils/validate"; import { ok, created, fail } from "../response"; +import { deploymentLogsRoutes } from "./logs"; +import { deploymentAiRoutes } from "./ai"; const dispatchDeployment = async (deployment: Awaited>, project: Awaited>, server: Awaited>) => { if (server.mode === "local") { @@ -304,201 +306,6 @@ export const deploymentsRoutes = new Elysia() return ok(null, "Deployment deleted"); }, ) - .get( - "/deployments/:id/logs", - async ({ params: { id }, set }) => { - const deployment = await getDeploymentById(id); - if (!deployment) { - set.status = 404; - return fail("Deployment not found"); - } - return ok(await getLogs(id)); - }, - ) - .get( - "/deployments/:id/logs/stream", - async ({ params: { id }, request, set }) => { - const deployment = await getDeploymentById(id); - if (!deployment) { - set.status = 404; - return fail("Deployment not found"); - } - const encoder = new TextEncoder(); - let unsubscribe = () => undefined; - let heartbeat: ReturnType | null = null; - let closed = false; - const stop = () => { - if (closed) return; - closed = true; - unsubscribe(); - if (heartbeat) clearInterval(heartbeat); - }; - const stream = new ReadableStream({ - start(controller) { - const send = (eventName: string, payload: unknown) => { - if (closed) return; - controller.enqueue( - encoder.encode( - `event: ${eventName}\ndata: ${JSON.stringify(payload)}\n\n`, - ), - ); - }; - send("ready", { deploymentId: id }); - unsubscribe = logBus.subscribe(id, (event) => - send("log", event), - ); - heartbeat = setInterval( - () => - send("heartbeat", { - at: new Date().toISOString(), - }), - 15000, - ); - }, - cancel: stop, - }); - request.signal.addEventListener("abort", stop, { - once: true, - }); - set.headers["content-type"] = "text/event-stream"; - return new Response(stream, { - headers: { - "Content-Type": "text/event-stream", - "Cache-Control": "no-cache", - Connection: "keep-alive", - }, - }); - }, - ) - .get( - "/deployments/:id/runtime-logs", - async ({ params: { id }, set }) => { - const deployment = await getDeploymentById(id); - if (!deployment) { - set.status = 404; - return fail("Deployment not found"); - } - const { run } = await import("../../orchestrator/runtime"); - const containerName = - deployment.containerName || `deploy-${id}`; - try { - const output = await run("docker", [ - "logs", - "--tail", - "200", - containerName, - ]); - const lines = output - .split("\n") - .filter(Boolean) - .map((line, i) => ({ - sequence: i + 1, - message: line, - timestamp: new Date().toISOString(), - stage: "runtime" as const, - })); - return ok(lines); - } catch { - return ok([]); - } - }, - ) - .get( - "/deployments/:id/runtime-logs/stream", - async ({ params: { id }, request, set }) => { - const deployment = await getDeploymentById(id); - if (!deployment) { - set.status = 404; - return fail("Deployment not found"); - } - const encoder = new TextEncoder(); - const containerName = - deployment.containerName || `deploy-${id}`; - let closed = false; - const stop = () => { - closed = true; - }; - request.signal.addEventListener("abort", stop, { - once: true, - }); - const stream = new ReadableStream({ - async start(controller) { - const send = (eventName: string, payload: unknown) => { - if (closed) return; - controller.enqueue( - encoder.encode( - `event: ${eventName}\ndata: ${JSON.stringify(payload)}\n\n`, - ), - ); - }; - const { spawn } = await import( - "node:child_process", - ); - const child = spawn( - "docker", - [ - "logs", - "--tail", - "100", - "--follow", - containerName, - ], - { - stdio: ["ignore", "pipe", "pipe"], - }, - ); - let seq = 0; - child.stdout.on("data", (chunk: Buffer) => { - const lines = chunk - .toString() - .split("\n") - .filter(Boolean); - for (const line of lines) { - seq++; - send("log", { - sequence: seq, - message: line, - timestamp: new Date().toISOString(), - stage: "runtime", - }); - } - }); - child.stderr.on("data", (chunk: Buffer) => { - const lines = chunk - .toString() - .split("\n") - .filter(Boolean); - for (const line of lines) { - seq++; - send("log", { - sequence: seq, - message: line, - timestamp: new Date().toISOString(), - stage: "runtime", - }); - } - }); - child.on("close", () => - send("close", { reason: "container stopped" }), - ); - request.signal.addEventListener( - "abort", - () => { - child.kill(); - stop(); - }, - { once: true }, - ); - }, - cancel: stop, - }); - set.headers["content-type"] = "text/event-stream"; - return new Response(stream, { - headers: { - "Content-Type": "text/event-stream", - "Cache-Control": "no-cache", - Connection: "keep-alive", - }, - }); - }, - ); + .use(deploymentLogsRoutes) + .use(deploymentAiRoutes); + diff --git a/apps/api/src/api/deployments/logs.ts b/apps/api/src/api/deployments/logs.ts new file mode 100644 index 0000000..55e881f --- /dev/null +++ b/apps/api/src/api/deployments/logs.ts @@ -0,0 +1,202 @@ +import { Elysia } from "elysia"; +import { getDeploymentById, getLogs } from "../../db/repo"; +import { logBus } from "../../orchestrator/log-bus"; +import { ok, fail } from "../response"; + +export const deploymentLogsRoutes = new Elysia() + .get( + "/deployments/:id/logs", + async ({ params: { id }, set }) => { + const deployment = await getDeploymentById(id); + if (!deployment) { + set.status = 404; + return fail("Deployment not found"); + } + return ok(await getLogs(id)); + }, + ) + .get( + "/deployments/:id/logs/stream", + async ({ params: { id }, request, set }) => { + const deployment = await getDeploymentById(id); + if (!deployment) { + set.status = 404; + return fail("Deployment not found"); + } + const encoder = new TextEncoder(); + let unsubscribe = () => undefined; + let heartbeat: ReturnType | null = null; + let closed = false; + const stop = () => { + if (closed) return; + closed = true; + unsubscribe(); + if (heartbeat) clearInterval(heartbeat); + }; + const stream = new ReadableStream({ + start(controller) { + const send = (eventName: string, payload: unknown) => { + if (closed) return; + controller.enqueue( + encoder.encode( + `event: ${eventName}\ndata: ${JSON.stringify(payload)}\n\n`, + ), + ); + }; + send("ready", { deploymentId: id }); + unsubscribe = logBus.subscribe(id, (event) => + send("log", event), + ); + heartbeat = setInterval( + () => + send("heartbeat", { + at: new Date().toISOString(), + }), + 15000, + ); + }, + cancel: stop, + }); + request.signal.addEventListener("abort", stop, { + once: true, + }); + set.headers["content-type"] = "text/event-stream"; + return new Response(stream, { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }, + }); + }, + ) + .get( + "/deployments/:id/runtime-logs", + async ({ params: { id }, set }) => { + const deployment = await getDeploymentById(id); + if (!deployment) { + set.status = 404; + return fail("Deployment not found"); + } + const { run } = await import("../../orchestrator/runtime"); + const containerName = + deployment.containerName || `deploy-${id}`; + try { + const output = await run("docker", [ + "logs", + "--tail", + "200", + containerName, + ]); + const lines = output + .split("\n") + .filter(Boolean) + .map((line, i) => ({ + sequence: i + 1, + message: line, + timestamp: new Date().toISOString(), + stage: "runtime" as const, + })); + return ok(lines); + } catch { + return ok([]); + } + }, + ) + .get( + "/deployments/:id/runtime-logs/stream", + async ({ params: { id }, request, set }) => { + const deployment = await getDeploymentById(id); + if (!deployment) { + set.status = 404; + return fail("Deployment not found"); + } + const encoder = new TextEncoder(); + const containerName = + deployment.containerName || `deploy-${id}`; + let closed = false; + const stop = () => { + closed = true; + }; + request.signal.addEventListener("abort", stop, { + once: true, + }); + const stream = new ReadableStream({ + async start(controller) { + const send = (eventName: string, payload: unknown) => { + if (closed) return; + controller.enqueue( + encoder.encode( + `event: ${eventName}\ndata: ${JSON.stringify(payload)}\n\n`, + ), + ); + }; + const { spawn } = await import("node:child_process"); + const child = spawn( + "docker", + [ + "logs", + "--tail", + "100", + "--follow", + containerName, + ], + { + stdio: ["ignore", "pipe", "pipe"], + }, + ); + let seq = 0; + child.stdout.on("data", (chunk: Buffer) => { + const lines = chunk + .toString() + .split("\n") + .filter(Boolean); + for (const line of lines) { + seq++; + send("log", { + sequence: seq, + message: line, + timestamp: new Date().toISOString(), + stage: "runtime", + }); + } + }); + child.stderr.on("data", (chunk: Buffer) => { + const lines = chunk + .toString() + .split("\n") + .filter(Boolean); + for (const line of lines) { + seq++; + send("log", { + sequence: seq, + message: line, + timestamp: new Date().toISOString(), + stage: "runtime", + }); + } + }); + child.on("close", () => + send("close", { reason: "container stopped" }), + ); + request.signal.addEventListener( + "abort", + () => { + child.kill(); + stop(); + }, + { once: true }, + ); + }, + cancel: stop, + }); + set.headers["content-type"] = "text/event-stream"; + return new Response(stream, { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }, + }); + }, + ); diff --git a/apps/api/src/api/settings/index.ts b/apps/api/src/api/settings/index.ts index 6f47a9c..3aa6049 100644 --- a/apps/api/src/api/settings/index.ts +++ b/apps/api/src/api/settings/index.ts @@ -92,4 +92,40 @@ export const settingsRoutes = new Elysia({ prefix: "/settings" }) set.status = 400; return fail(err.message); } + }) + + .get("/ai", async () => { + const { getPublicAiSettings } = await import("../../db/repo"); + const settings = await getPublicAiSettings(); + return ok(settings); + }) + + .put("/ai", async ({ body }: any) => { + const { upsertAiSettings } = await import("../../db/repo"); + await upsertAiSettings({ + defaultProvider: body?.defaultProvider, + openaiApiKey: body?.openaiApiKey, + openaiModel: body?.openaiModel, + geminiApiKey: body?.geminiApiKey, + geminiModel: body?.geminiModel, + grokApiKey: body?.grokApiKey, + grokModel: body?.grokModel, + claudeApiKey: body?.claudeApiKey, + claudeModel: body?.claudeModel, + }); + return ok(null, "AI settings updated"); + }) + + .post("/ai/test", async ({ body, set }: any) => { + const provider = body?.provider || "openai"; + const apiKey = body?.apiKey; + const model = body?.model; + const { testAiConnection } = await import("../../ai"); + const result = await testAiConnection(provider, apiKey, model); + if (!result.ok) { + set.status = 400; + return fail(result.message); + } + return ok(result); }); + diff --git a/apps/api/src/db/migrations/0004_add_ai_settings_and_diagnoses.sql b/apps/api/src/db/migrations/0004_add_ai_settings_and_diagnoses.sql new file mode 100644 index 0000000..1a1d70f --- /dev/null +++ b/apps/api/src/db/migrations/0004_add_ai_settings_and_diagnoses.sql @@ -0,0 +1,38 @@ +CREATE TABLE IF NOT EXISTS "ai_settings" ( + "id" text PRIMARY KEY NOT NULL, + "default_provider" text DEFAULT 'openai' NOT NULL, + "openai_api_key_encrypted" text, + "openai_api_key_iv" text, + "openai_api_key_tag" text, + "openai_model" text DEFAULT 'gpt-4o-mini' NOT NULL, + "gemini_api_key_encrypted" text, + "gemini_api_key_iv" text, + "gemini_api_key_tag" text, + "gemini_model" text DEFAULT 'gemini-2.0-flash' NOT NULL, + "grok_api_key_encrypted" text, + "grok_api_key_iv" text, + "grok_api_key_tag" text, + "grok_model" text DEFAULT 'grok-2-latest' NOT NULL, + "claude_api_key_encrypted" text, + "claude_api_key_iv" text, + "claude_api_key_tag" text, + "claude_model" text DEFAULT 'claude-3-5-sonnet-20241022' NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); + +CREATE TABLE IF NOT EXISTS "ai_diagnoses" ( + "id" text PRIMARY KEY NOT NULL, + "deployment_id" text NOT NULL, + "provider" text NOT NULL, + "model" text NOT NULL, + "summary" text NOT NULL, + "root_cause" text NOT NULL, + "explanation" text NOT NULL, + "suggested_fixes" jsonb DEFAULT '[]'::jsonb NOT NULL, + "raw_response" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "ai_diagnoses_deployment_id_deployments_id_fk" FOREIGN KEY ("deployment_id") REFERENCES "public"."deployments"("id") ON DELETE cascade ON UPDATE no action +); + +CREATE INDEX IF NOT EXISTS "idx_ai_diagnoses_deployment" ON "ai_diagnoses" ("deployment_id"); diff --git a/apps/api/src/db/migrations/meta/_journal.json b/apps/api/src/db/migrations/meta/_journal.json index 3a68738..66268e3 100644 --- a/apps/api/src/db/migrations/meta/_journal.json +++ b/apps/api/src/db/migrations/meta/_journal.json @@ -29,6 +29,13 @@ "when": 1788000000000, "tag": "0003_add_server_ssh_key_encryption", "breakpoints": true + }, + { + "idx": 4, + "version": "7", + "when": 1788100000000, + "tag": "0004_add_ai_settings_and_diagnoses", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/api/src/db/repo/ai-settings.ts b/apps/api/src/db/repo/ai-settings.ts new file mode 100644 index 0000000..a52f784 --- /dev/null +++ b/apps/api/src/db/repo/ai-settings.ts @@ -0,0 +1,244 @@ +import { eq, desc } from "drizzle-orm"; +import { getDb } from "../db-provider"; +import { aiSettings, aiDiagnoses } from "../schema"; +import { encryptValue, decryptValue } from "../../utils/crypto"; +import { config } from "../../utils/config"; +import { randomUUID } from "node:crypto"; + +export type AiProviderType = "openai" | "gemini" | "grok" | "claude"; + +export interface AiSettingsData { + defaultProvider: AiProviderType; + openaiApiKey?: string; + openaiModel: string; + geminiApiKey?: string; + geminiModel: string; + grokApiKey?: string; + grokModel: string; + claudeApiKey?: string; + claudeModel: string; +} + +export interface AiSettingsPublic { + defaultProvider: AiProviderType; + openaiConfigured: boolean; + openaiModel: string; + geminiConfigured: boolean; + geminiModel: string; + grokConfigured: boolean; + grokModel: string; + claudeConfigured: boolean; + claudeModel: string; +} + +export interface AiDiagnosisRecord { + id: string; + deploymentId: string; + provider: AiProviderType; + model: string; + summary: string; + rootCause: string; + explanation: string; + suggestedFixes: Array<{ + title: string; + description: string; + actionType?: "command" | "code" | "config" | "env"; + snippet?: string; + }>; + rawResponse?: string | null; + createdAt: Date; +} + +const SETTINGS_ID = "default"; + +const decryptOptional = (encrypted: string | null, iv: string | null, tag: string | null): string | undefined => { + if (!encrypted || !iv || !tag) return undefined; + try { + return decryptValue(encrypted, iv, tag, config.envEncryptionKey); + } catch { + return undefined; + } +}; + +export const getAiSettings = async (): Promise => { + try { + const db = getDb(); + if (!db || typeof db.select !== "function") throw new Error("DB not ready"); + const rows = await db + .select() + .from(aiSettings) + .where(eq(aiSettings.id, SETTINGS_ID)) + .limit(1); + + if (rows.length === 0) { + return { + defaultProvider: "openai", + openaiModel: "gpt-4o-mini", + geminiModel: "gemini-2.0-flash", + grokModel: "grok-2-latest", + claudeModel: "claude-3-5-sonnet-20241022", + }; + } + + const row = rows[0]; + return { + defaultProvider: (row.defaultProvider as AiProviderType) || "openai", + openaiApiKey: decryptOptional(row.openaiApiKeyEncrypted, row.openaiApiKeyIv, row.openaiApiKeyTag), + openaiModel: row.openaiModel || "gpt-4o-mini", + geminiApiKey: decryptOptional(row.geminiApiKeyEncrypted, row.geminiApiKeyIv, row.geminiApiKeyTag), + geminiModel: row.geminiModel || "gemini-2.0-flash", + grokApiKey: decryptOptional(row.grokApiKeyEncrypted, row.grokApiKeyIv, row.grokApiKeyTag), + grokModel: row.grokModel || "grok-2-latest", + claudeApiKey: decryptOptional(row.claudeApiKeyEncrypted, row.claudeApiKeyIv, row.claudeApiKeyTag), + claudeModel: row.claudeModel || "claude-3-5-sonnet-20241022", + }; + } catch { + return { + defaultProvider: "openai", + openaiModel: "gpt-4o-mini", + geminiModel: "gemini-2.0-flash", + grokModel: "grok-2-latest", + claudeModel: "claude-3-5-sonnet-20241022", + }; + } +}; + +export const getPublicAiSettings = async (): Promise => { + const settings = await getAiSettings(); + return { + defaultProvider: settings.defaultProvider, + openaiConfigured: Boolean(settings.openaiApiKey || process.env.OPENAI_API_KEY), + openaiModel: settings.openaiModel, + geminiConfigured: Boolean(settings.geminiApiKey || process.env.GEMINI_API_KEY), + geminiModel: settings.geminiModel, + grokConfigured: Boolean(settings.grokApiKey || process.env.GROK_API_KEY || process.env.XAI_API_KEY), + grokModel: settings.grokModel, + claudeConfigured: Boolean(settings.claudeApiKey || process.env.ANTHROPIC_API_KEY || process.env.CLAUDE_API_KEY), + claudeModel: settings.claudeModel, + }; +}; + +export const upsertAiSettings = async (input: Partial): Promise => { + const db = getDb(); + const existingRows = await db + .select() + .from(aiSettings) + .where(eq(aiSettings.id, SETTINGS_ID)) + .limit(1); + + const existing = existingRows[0]; + + const openaiEnc = input.openaiApiKey !== undefined + ? (input.openaiApiKey ? encryptValue(input.openaiApiKey, config.envEncryptionKey) : null) + : undefined; + const geminiEnc = input.geminiApiKey !== undefined + ? (input.geminiApiKey ? encryptValue(input.geminiApiKey, config.envEncryptionKey) : null) + : undefined; + const grokEnc = input.grokApiKey !== undefined + ? (input.grokApiKey ? encryptValue(input.grokApiKey, config.envEncryptionKey) : null) + : undefined; + const claudeEnc = input.claudeApiKey !== undefined + ? (input.claudeApiKey ? encryptValue(input.claudeApiKey, config.envEncryptionKey) : null) + : undefined; + + const valuesToSave = { + defaultProvider: input.defaultProvider ?? existing?.defaultProvider ?? "openai", + openaiModel: input.openaiModel ?? existing?.openaiModel ?? "gpt-4o-mini", + geminiModel: input.geminiModel ?? existing?.geminiModel ?? "gemini-2.0-flash", + grokModel: input.grokModel ?? existing?.grokModel ?? "grok-2-latest", + claudeModel: input.claudeModel ?? existing?.claudeModel ?? "claude-3-5-sonnet-20241022", + openaiApiKeyEncrypted: openaiEnc !== undefined ? (openaiEnc?.encrypted ?? null) : (existing?.openaiApiKeyEncrypted ?? null), + openaiApiKeyIv: openaiEnc !== undefined ? (openaiEnc?.iv ?? null) : (existing?.openaiApiKeyIv ?? null), + openaiApiKeyTag: openaiEnc !== undefined ? (openaiEnc?.tag ?? null) : (existing?.openaiApiKeyTag ?? null), + geminiApiKeyEncrypted: geminiEnc !== undefined ? (geminiEnc?.encrypted ?? null) : (existing?.geminiApiKeyEncrypted ?? null), + geminiApiKeyIv: geminiEnc !== undefined ? (geminiEnc?.iv ?? null) : (existing?.geminiApiKeyIv ?? null), + geminiApiKeyTag: geminiEnc !== undefined ? (geminiEnc?.tag ?? null) : (existing?.geminiApiKeyTag ?? null), + grokApiKeyEncrypted: grokEnc !== undefined ? (grokEnc?.encrypted ?? null) : (existing?.grokApiKeyEncrypted ?? null), + grokApiKeyIv: grokEnc !== undefined ? (grokEnc?.iv ?? null) : (existing?.grokApiKeyIv ?? null), + grokApiKeyTag: grokEnc !== undefined ? (grokEnc?.tag ?? null) : (existing?.grokApiKeyTag ?? null), + claudeApiKeyEncrypted: claudeEnc !== undefined ? (claudeEnc?.encrypted ?? null) : (existing?.claudeApiKeyEncrypted ?? null), + claudeApiKeyIv: claudeEnc !== undefined ? (claudeEnc?.iv ?? null) : (existing?.claudeApiKeyIv ?? null), + claudeApiKeyTag: claudeEnc !== undefined ? (claudeEnc?.tag ?? null) : (existing?.claudeApiKeyTag ?? null), + updatedAt: new Date(), + }; + + if (existing) { + await db + .update(aiSettings) + .set(valuesToSave) + .where(eq(aiSettings.id, SETTINGS_ID)); + } else { + await db.insert(aiSettings).values({ + id: SETTINGS_ID, + ...valuesToSave, + }); + } +}; + +export const saveAiDiagnosis = async (input: { + deploymentId: string; + provider: AiProviderType; + model: string; + summary: string; + rootCause: string; + explanation: string; + suggestedFixes: Array<{ + title: string; + description: string; + actionType?: "command" | "code" | "config" | "env"; + snippet?: string; + }>; + rawResponse?: string | null; +}): Promise => { + const db = getDb(); + const id = randomUUID(); + const values = { + id, + deploymentId: input.deploymentId, + provider: input.provider, + model: input.model, + summary: input.summary, + rootCause: input.rootCause, + explanation: input.explanation, + suggestedFixes: input.suggestedFixes, + rawResponse: input.rawResponse ?? null, + }; + + await db.insert(aiDiagnoses).values(values); + + return { + ...values, + createdAt: new Date(), + }; +}; + +export const getLatestAiDiagnosis = async (deploymentId: string): Promise => { + const db = getDb(); + const rows = await db + .select() + .from(aiDiagnoses) + .where(eq(aiDiagnoses.deploymentId, deploymentId)) + .orderBy(desc(aiDiagnoses.createdAt)) + .limit(1); + + if (rows.length === 0) return null; + + const row = rows[0]; + return { + id: row.id, + deploymentId: row.deploymentId, + provider: row.provider as AiProviderType, + model: row.model, + summary: row.summary, + rootCause: row.rootCause, + explanation: row.explanation, + suggestedFixes: (row.suggestedFixes as any) || [], + rawResponse: row.rawResponse, + createdAt: row.createdAt, + }; +}; + +export const deleteAiDiagnosesByDeployment = async (deploymentId: string): Promise => { + const db = getDb(); + await db.delete(aiDiagnoses).where(eq(aiDiagnoses.deploymentId, deploymentId)); +}; diff --git a/apps/api/src/db/repo/index.ts b/apps/api/src/db/repo/index.ts index 693df53..93d4950 100644 --- a/apps/api/src/db/repo/index.ts +++ b/apps/api/src/db/repo/index.ts @@ -62,3 +62,10 @@ export { getPlatformSettings, setIngressServer } from "./platform-settings"; export type { Route } from "../../types"; export { createDeploymentEvent, listDeploymentEvents } from "./deployment-events"; + +export { + getAiSettings, getPublicAiSettings, upsertAiSettings, saveAiDiagnosis, + getLatestAiDiagnosis, deleteAiDiagnosesByDeployment, +} from "./ai-settings"; +export type { AiProviderType, AiSettingsData, AiSettingsPublic, AiDiagnosisRecord } from "./ai-settings"; + diff --git a/apps/api/src/db/schema.ts b/apps/api/src/db/schema.ts index 7970481..814c8d6 100644 --- a/apps/api/src/db/schema.ts +++ b/apps/api/src/db/schema.ts @@ -312,3 +312,42 @@ export const routes = pgTable("routes", { }, (table) => [ uniqueIndex("idx_routes_hostname_server").on(table.hostname, table.serverId), ]); + +export const aiSettings = pgTable("ai_settings", { + id: text().primaryKey(), + defaultProvider: text("default_provider").notNull().default("openai"), + openaiApiKeyEncrypted: text("openai_api_key_encrypted"), + openaiApiKeyIv: text("openai_api_key_iv"), + openaiApiKeyTag: text("openai_api_key_tag"), + openaiModel: text("openai_model").notNull().default("gpt-4o-mini"), + geminiApiKeyEncrypted: text("gemini_api_key_encrypted"), + geminiApiKeyIv: text("gemini_api_key_iv"), + geminiApiKeyTag: text("gemini_api_key_tag"), + geminiModel: text("gemini_model").notNull().default("gemini-2.0-flash"), + grokApiKeyEncrypted: text("grok_api_key_encrypted"), + grokApiKeyIv: text("grok_api_key_iv"), + grokApiKeyTag: text("grok_api_key_tag"), + grokModel: text("grok_model").notNull().default("grok-2-latest"), + claudeApiKeyEncrypted: text("claude_api_key_encrypted"), + claudeApiKeyIv: text("claude_api_key_iv"), + claudeApiKeyTag: text("claude_api_key_tag"), + claudeModel: text("claude_model").notNull().default("claude-3-5-sonnet-20241022"), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), +}); + +export const aiDiagnoses = pgTable("ai_diagnoses", { + id: text().primaryKey(), + deploymentId: text("deployment_id").notNull(), + provider: text().notNull(), + model: text().notNull(), + summary: text().notNull(), + rootCause: text("root_cause").notNull(), + explanation: text().notNull(), + suggestedFixes: jsonb("suggested_fixes").notNull().default([]), + rawResponse: text("raw_response"), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), +}, (table) => [ + foreignKey({ columns: [table.deploymentId], foreignColumns: [deployments.id], onDelete: "cascade" }), + index("idx_ai_diagnoses_deployment").on(table.deploymentId), +]); diff --git a/apps/api/src/db/test-helper.ts b/apps/api/src/db/test-helper.ts index ba720b2..8818da8 100644 --- a/apps/api/src/db/test-helper.ts +++ b/apps/api/src/db/test-helper.ts @@ -26,21 +26,36 @@ const TABLE_NAMES = [ 'scaling_policies', 'servers', 'smtp_settings', + 'ai_settings', + 'ai_diagnoses', 'volumes', ]; +import { migrate as drizzleMigrate } from 'drizzle-orm/node-postgres/migrator'; +import { join } from 'node:path'; + export const createTestPool = () => new Pool({ connectionString: TEST_DATABASE_URL }); export const setupTestDb = async () => { const pool = createTestPool(); const db = drizzle(pool, { schema }); setDbProvider(async () => db); + const migrationsFolder = join(import.meta.dirname, 'migrations'); + try { + await drizzleMigrate(db, { migrationsFolder }); + } catch (err: any) { + if (!err?.message?.includes('already exists') && err?.code !== '42P07') { + console.warn('[setupTestDb] Migration warning:', err); + } + } return { db, pool }; }; export const truncateAllTables = async (pool: Pool) => { for (const name of TABLE_NAMES) { - await pool.query(`TRUNCATE TABLE "${name}" CASCADE`); + try { + await pool.query(`TRUNCATE TABLE "${name}" CASCADE`); + } catch {} } }; diff --git a/apps/web/src/api/ai.ts b/apps/web/src/api/ai.ts new file mode 100644 index 0000000..6492860 --- /dev/null +++ b/apps/web/src/api/ai.ts @@ -0,0 +1,46 @@ +import { apiFetch } from "./core"; +import type { + AiSettingsStatus, + AiSettingsInput, + AiDiagnosis, + AiProvider, +} from "../types"; + +export const getAiSettings = () => + apiFetch("/settings/ai"); + +export const updateAiSettings = (data: AiSettingsInput) => + apiFetch("/settings/ai", { + method: "PUT", + body: JSON.stringify(data), + }); + +export const testAiConnection = (data: { + provider: AiProvider; + apiKey?: string; + model?: string; +}) => + apiFetch<{ ok: boolean; message: string; provider: AiProvider; model: string }>( + "/settings/ai/test", + { + method: "POST", + body: JSON.stringify(data), + }, + ); + +export const diagnoseDeploymentFailure = ( + deploymentId: string, + options?: { + provider?: AiProvider; + model?: string; + apiKey?: string; + customPrompt?: string; + }, +) => + apiFetch(`/deployments/${deploymentId}/ai-diagnose`, { + method: "POST", + body: JSON.stringify(options || {}), + }); + +export const getDeploymentAiDiagnosis = (deploymentId: string) => + apiFetch(`/deployments/${deploymentId}/ai-diagnosis`); diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index 57d8eeb..70cd8fa 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -11,76 +11,13 @@ import type { ApiKey, Alert, Log, - GithubRepo, - GithubIntegrationStatus, - SmtpSettingsStatus, } from "../types"; +import { apiFetch, BASE, ApiError } from "./core"; -const BASE = "/api"; +export { BASE, ApiError, apiFetch }; +export * from "./ai"; +export * from "./settings"; -class ApiError extends Error { - status: number; - constructor(msg: string, status: number) { - super(msg); - this.status = status; - } -} - -const apiFetch = async ( - path: string, - opts?: RequestInit, -): Promise => { - const isFormData = - opts?.body instanceof FormData; - const headers: Record = {}; - if (!isFormData) - headers["Content-Type"] = - "application/json"; - const res = await fetch(`${BASE}${path}`, { - ...opts, - headers: { - ...headers, - ...(opts?.headers as Record< - string, - string - >), - }, - }); - if (!res.ok) { - const body = await res - .json() - .catch(() => ({ - message: res.statusText, - })); - throw new ApiError( - body.message ?? body.error ?? "Request failed", - res.status, - ); - } - if ( - res.headers - .get("content-type") - ?.includes("text/event-stream") - ) - return res as unknown as T; - if ( - res.headers - .get("content-type") - ?.includes("text/plain") - ) - return res.text() as unknown as T; - const json = await res.json(); - if ( - json && - typeof json === "object" && - "status" in json && - "data" in json - ) - return json.data as T; - return json as T; -}; - -// Projects export const listProjects = () => apiFetch("/projects"); export const getProject = (id: string) => @@ -112,7 +49,6 @@ export const deleteProject = (id: string) => method: "DELETE", }); -// Deployments export const listDeployments = ( projectId?: string, offset = 0, @@ -193,7 +129,6 @@ export const getProjectRequestMetrics = (projectId: string) => }; }>(`/projects/${projectId}/metrics/requests`); -// Env Vars export const listEnvVars = ( projectId: string, environment?: string, @@ -234,7 +169,6 @@ export const deleteEnvVar = (id: string) => export const revealEnvVar = (id: string) => apiFetch<{ value: string }>(`/env-vars/${id}/reveal`); -// Volumes export const listVolumes = (projectId: string) => apiFetch( `/projects/${projectId}/volumes`, @@ -255,48 +189,39 @@ export const deleteVolume = (id: string) => method: "DELETE", }); -// Databases export const listAllDatabases = () => apiFetch("/databases"); -export const listDatabases = ( - projectId: string, -) => +export const listDatabases = (projectId: string) => apiFetch( `/projects/${projectId}/databases`, ); -export const createDatabase = ( - projectId: string | null, - type: string, - options?: { - name?: string; - version?: string; - cpuLimit?: number | null; - memoryLimitMb?: number | null; - storageLimitMb?: number | null; - publicAccess?: boolean; - allowPublicAccessFromAnywhere?: boolean; - allowedCidrs?: string[]; - }, -) => - apiFetch( - projectId ? `/projects/${projectId}/databases` : "/databases", - { - method: "POST", - body: JSON.stringify({ type, projectId, ...options }), - }, - ); export const getDatabase = (id: string) => apiFetch(`/databases/${id}`); +export const createDatabase = (data: { + name: string; + type: string; + version?: string; + projectId?: string; + publicAccess?: boolean; + allowPublicAccessFromAnywhere?: boolean; + allowedCidrs?: string[]; +}) => + apiFetch("/databases", { + method: "POST", + body: JSON.stringify(data), + }); export const deleteDatabase = (id: string) => - apiFetch( - `/databases/${id}`, - { method: "DELETE" }, - ); + apiFetch(`/databases/${id}`, { + method: "DELETE", + }); export const getDatabaseCredentials = (id: string) => apiFetch<{ + connectionString: string; + databaseName: string; username: string; password: string; - internalConnectionString: string; + host: string; + port: number; externalConnectionString: string | null; externalHost: string | null; externalPort: number | null; @@ -310,7 +235,6 @@ export const restartDatabase = (id: string) => export const retryDatabase = (id: string) => apiFetch(`/databases/${id}/retry`, { method: "POST" }); -// Domains export const listDomains = (projectId: string) => apiFetch( `/projects/${projectId}/domains`, @@ -348,7 +272,6 @@ export const getDomainStatus = (projectId: string) => lastChecked: string; }>>(`/projects/${projectId}/domains/status`); -// Scaling export const getScalingPolicy = ( projectId: string, ) => @@ -374,11 +297,9 @@ export const deleteScalingPolicy = ( { method: "DELETE" }, ); -// Server export const getServerIp = () => apiFetch<{ ip: string; baseDomain: string; resolves: boolean; url: string }>("/server/ip"); -// Auth export const login = (username: string, password: string) => apiFetch<{ username: string }>("/auth/login", { method: "POST", @@ -402,7 +323,6 @@ export const getMe = async () => { return res; }; -// Prometheus export const queryPrometheus = (query: string) => apiFetch<{ status: string; @@ -431,7 +351,6 @@ export const queryPrometheusRange = (query: string, start: number, end: number, `/prometheus/query_range?query=${encodeURIComponent(query)}&start=${start}&end=${end}&step=${encodeURIComponent(step)}`, ); -// Metrics export const getMetrics = async () => { try { return await apiFetch("/metrics"); @@ -446,7 +365,6 @@ export const getMetrics = async () => { } }; -// Servers export const listServers = () => apiFetch("/servers"); export const createServer = (data: { @@ -484,7 +402,6 @@ export const createAgentRegistrationToken = (data: { body: JSON.stringify(data), }); -// API Keys export const listApiKeys = () => apiFetch("/api-keys"); export const createApiKey = (data: { @@ -500,7 +417,6 @@ export const deleteApiKey = (id: string) => method: "DELETE", }); -// Alerts export const listAlerts = (projectId: string) => apiFetch( `/projects/${projectId}/alerts`, @@ -529,69 +445,6 @@ export const deleteAlert = (id: string) => method: "DELETE", }); -// ─── GitHub OAuth ─────────────────────────────────────── - -export const getGithubAuthUrl = () => - apiFetch<{ url: string }>("/github/auth-url"); - -export const getGithubUser = () => - apiFetch<{ login: string; avatar_url: string }>("/github/user"); - -export const getGithubRepos = () => - apiFetch("/github/repos"); - -export const disconnectGithub = () => - apiFetch("/github/disconnect", { method: "POST" }); - -export const getGithubIntegration = () => - apiFetch("/github/integration"); - -export const setGithubIntegration = (data: { - clientId: string; - clientSecret: string; - appName?: string; - webhookSecret?: string; -}) => - apiFetch("/github/integration", { - method: "PUT", - body: JSON.stringify(data), - }); - -export const getSmtpSettings = () => - apiFetch("/settings/smtp"); - -export const setSmtpSettings = (data: { - host: string; - port: number; - user?: string; - pass?: string; - fromAddress?: string; -}) => - apiFetch("/settings/smtp", { - method: "PUT", - body: JSON.stringify(data), - }); - -export const testSmtpSettings = () => - apiFetch("/settings/smtp/test", { - method: "POST", - }); - -// ─── GitHub Webhook ─────────────────────────────────────── - -export const getRepoHooks = (owner: string, repo: string) => - apiFetch>(`/github/repos/${owner}/${repo}/hooks`); - -export const registerRepoHook = (owner: string, repo: string) => - apiFetch<{ id: number; created: boolean; url: string }>(`/github/repos/${owner}/${repo}/hook`, { - method: "POST", - }); - -export const removeRepoHook = (owner: string, repo: string) => - apiFetch<{ removed: boolean }>(`/github/repos/${owner}/${repo}/hook`, { - method: "DELETE", - }); - export const setEnvVar = (projectId: string, key: string, value: string, environment?: string) => createEnvVar(projectId, { key, value, environment }); diff --git a/apps/web/src/api/core.ts b/apps/web/src/api/core.ts new file mode 100644 index 0000000..5092d67 --- /dev/null +++ b/apps/web/src/api/core.ts @@ -0,0 +1,42 @@ +export const BASE = "/api"; + +export class ApiError extends Error { + status: number; + constructor(msg: string, status: number) { + super(msg); + this.status = status; + } +} + +export const apiFetch = async ( + path: string, + opts?: RequestInit, +): Promise => { + const isFormData = opts?.body instanceof FormData; + const headers: Record = {}; + if (!isFormData) headers["Content-Type"] = "application/json"; + const res = await fetch(`${BASE}${path}`, { + ...opts, + headers: { + ...headers, + ...(opts?.headers as Record), + }, + }); + if (!res.ok) { + const body = await res.json().catch(() => ({ + message: res.statusText, + })); + throw new ApiError( + body.message ?? body.error ?? "Request failed", + res.status, + ); + } + if (res.headers.get("content-type")?.includes("text/event-stream")) + return res as unknown as T; + if (res.headers.get("content-type")?.includes("text/plain")) + return res.text() as unknown as T; + const json = await res.json(); + if (json && typeof json === "object" && "status" in json && "data" in json) + return json.data as T; + return json as T; +}; diff --git a/apps/web/src/api/settings.ts b/apps/web/src/api/settings.ts new file mode 100644 index 0000000..c206282 --- /dev/null +++ b/apps/web/src/api/settings.ts @@ -0,0 +1,65 @@ +import { apiFetch } from "./core"; +import type { + GithubIntegrationStatus, + SmtpSettingsStatus, + GithubRepo, +} from "../types"; + +export const getGithubAuthUrl = () => + apiFetch<{ url: string }>("/github/auth-url"); + +export const getGithubUser = () => + apiFetch<{ login: string; avatar_url: string }>("/github/user"); + +export const getGithubRepos = () => + apiFetch("/github/repos"); + +export const disconnectGithub = () => + apiFetch("/github/disconnect", { method: "POST" }); + +export const getGithubIntegration = () => + apiFetch("/github/integration"); + +export const setGithubIntegration = (data: { + clientId: string; + clientSecret: string; + appName?: string; + webhookSecret?: string; +}) => + apiFetch("/github/integration", { + method: "PUT", + body: JSON.stringify(data), + }); + +export const getSmtpSettings = () => + apiFetch("/settings/smtp"); + +export const setSmtpSettings = (data: { + host: string; + port: number; + user?: string; + pass?: string; + fromAddress?: string; +}) => + apiFetch("/settings/smtp", { + method: "PUT", + body: JSON.stringify(data), + }); + +export const testSmtpSettings = () => + apiFetch("/settings/smtp/test", { + method: "POST", + }); + +export const getRepoHooks = (owner: string, repo: string) => + apiFetch>(`/github/repos/${owner}/${repo}/hooks`); + +export const registerRepoHook = (owner: string, repo: string) => + apiFetch<{ id: number; created: boolean; url: string }>(`/github/repos/${owner}/${repo}/hook`, { + method: "POST", + }); + +export const removeRepoHook = (owner: string, repo: string) => + apiFetch<{ removed: boolean }>(`/github/repos/${owner}/${repo}/hook`, { + method: "DELETE", + }); diff --git a/apps/web/src/components/project/deployments/AiBuildFixDialog.tsx b/apps/web/src/components/project/deployments/AiBuildFixDialog.tsx new file mode 100644 index 0000000..0dd3474 --- /dev/null +++ b/apps/web/src/components/project/deployments/AiBuildFixDialog.tsx @@ -0,0 +1,432 @@ +import { useState, useEffect } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from "../../ui/dialog"; +import { Button } from "../../ui/button"; +import { Badge } from "../../ui/badge"; +import { + Sparkles, + Terminal, + FileCode, + Sliders, + Key, + Copy, + Check, + RefreshCw, + AlertTriangle, + ArrowRight, + Bot, + Loader2, +} from "lucide-react"; +import * as api from "../../../api/client"; +import type { AiProvider, AiDiagnosis } from "../../../types"; + +interface AiBuildFixDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + deploymentId: string; + projectName?: string; + failureReason?: string | null; +} + +const PROVIDERS: Array<{ + id: AiProvider; + name: string; + models: string[]; + iconColor: string; +}> = [ + { + id: "openai", + name: "OpenAI", + models: ["gpt-4o-mini", "gpt-4o", "o3-mini"], + iconColor: "text-emerald-400", + }, + { + id: "gemini", + name: "Gemini", + models: ["gemini-2.0-flash", "gemini-1.5-pro"], + iconColor: "text-blue-400", + }, + { + id: "grok", + name: "Grok", + models: ["grok-2-latest", "grok-2"], + iconColor: "text-purple-400", + }, + { + id: "claude", + name: "Claude", + models: ["claude-3-5-sonnet-20241022", "claude-3-5-haiku-20241022"], + iconColor: "text-amber-400", + }, +]; + +export function AiBuildFixDialog({ + open, + onOpenChange, + deploymentId, + projectName, + failureReason, +}: AiBuildFixDialogProps) { + const { data: aiSettings } = useQuery({ + queryKey: ["aiSettings"], + queryFn: () => api.getAiSettings().catch(() => null), + enabled: open, + }); + + const { data: cachedDiagnosis, refetch: refetchCached } = useQuery({ + queryKey: ["aiDiagnosis", deploymentId], + queryFn: () => api.getDeploymentAiDiagnosis(deploymentId).catch(() => null), + enabled: open && !!deploymentId, + }); + + const [selectedProvider, setSelectedProvider] = useState("openai"); + const [selectedModel, setSelectedModel] = useState("gpt-4o-mini"); + const [apiKeyOverride, setApiKeyOverride] = useState(""); + const [customPrompt, setCustomPrompt] = useState(""); + + const [isAnalyzing, setIsAnalyzing] = useState(false); + const [analysisError, setAnalysisError] = useState(null); + const [diagnosis, setDiagnosis] = useState(null); + const [copiedSnippetIndex, setCopiedSnippetIndex] = useState(null); + const [showOptions, setShowOptions] = useState(false); + + useEffect(() => { + if (aiSettings) { + const prov = aiSettings.defaultProvider || "openai"; + setSelectedProvider(prov); + const found = PROVIDERS.find((p) => p.id === prov); + if (found && found.models.length > 0) { + setSelectedModel(found.models[0]); + } + } + }, [aiSettings]); + + useEffect(() => { + if (cachedDiagnosis) { + setDiagnosis(cachedDiagnosis); + } else { + setDiagnosis(null); + } + }, [cachedDiagnosis, deploymentId]); + + const handleProviderChange = (prov: AiProvider) => { + setSelectedProvider(prov); + const found = PROVIDERS.find((p) => p.id === prov); + if (found && found.models.length > 0) { + setSelectedModel(found.models[0]); + } + }; + + const handleRunDiagnosis = async () => { + setIsAnalyzing(true); + setAnalysisError(null); + try { + const result = await api.diagnoseDeploymentFailure(deploymentId, { + provider: selectedProvider, + model: selectedModel, + apiKey: apiKeyOverride.trim() || undefined, + customPrompt: customPrompt.trim() || undefined, + }); + setDiagnosis(result); + refetchCached(); + } catch (err: any) { + setAnalysisError(err.message || "AI build diagnosis failed. Please verify API keys or try another provider."); + } finally { + setIsAnalyzing(false); + } + }; + + const handleCopy = (text: string, index: number) => { + navigator.clipboard.writeText(text); + setCopiedSnippetIndex(index); + setTimeout(() => setCopiedSnippetIndex(null), 2500); + }; + + const getActionIcon = (actionType?: string) => { + if (actionType === "command") return ; + if (actionType === "code") return ; + if (actionType === "env") return ; + return ; + }; + + return ( + + + +
+ + + AI Build Failure Diagnosis & Fix + + + Failed Build + +
+ + Project: {projectName || "Deployment"} + + ID: {deploymentId.slice(0, 8)} + {failureReason && ( + <> + + {failureReason} + + )} + +
+ +
+ {/* Provider Selection Toolbar */} +
+
+ Select AI Provider: + +
+ +
+ {PROVIDERS.map((p) => { + const isSelected = selectedProvider === p.id; + return ( + + ); + })} +
+ + {showOptions && ( +
+
+
+ + +
+ +
+ + setApiKeyOverride(e.target.value)} + className="h-8 w-full rounded border border-border bg-[#15151c] px-2.5 text-xs text-foreground placeholder:text-muted-foreground font-mono focus:outline-none focus:ring-1 focus:ring-primary" + /> +
+
+ +
+ +