Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 7 additions & 9 deletions app/api/analyses/[id]/run/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,7 @@ import {
getRepositoriesForAnalysis,
updateAnalysisStatus,
createRepoFile,
createBlueprint,
deleteBlueprintsByAnalysis,
replaceBlueprintsForAnalysis,
getBlueprintsByAnalysis,
getSubscriptionByGithubId,
upsertSubscription,
Expand Down Expand Up @@ -200,7 +199,6 @@ export async function POST(

// Update status to scanning
await updateAnalysisStatus(id, 'scanning')
await deleteBlueprintsByAnalysis(id)
send({ status: 'scanning', progress: 10 })

// Fetch file trees from GitHub for each repository
Expand Down Expand Up @@ -430,10 +428,10 @@ For each app blueprint:
.map((bp) => normalizeBlueprint(bp))
.sort((a, b) => getOpportunityScore(b) - getOpportunityScore(a))

for (const bp of rankedBlueprints) {
await createBlueprint({
analysis_id: id,
user_id: user.id,
await replaceBlueprintsForAnalysis(
id,
user.id,
rankedBlueprints.map((bp) => ({
name: bp.name.slice(0, 255),
description: bp.description,
app_type: bp.app_type?.slice(0, 100) ?? null,
Expand All @@ -444,8 +442,8 @@ For each app blueprint:
estimated_effort: getEffortEstimate(bp.complexity, bp.missing_files.length),
technologies: bp.technologies,
ai_explanation: bp.explanation,
})
}
})),
)
}

// Update to complete
Expand Down
22 changes: 21 additions & 1 deletion app/api/app-idea-chat/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
getFilesByRepository,
} from '@/lib/queries'
import { getCurrentUser } from '@/lib/auth'
import { deductCredits, CREDITS } from '@/lib/credits'
import { deductCredits, refundCredits, CREDITS } from '@/lib/credits'
import type { AppIdeaChatResponse, ChatMessage } from '@/lib/app-idea-chat-types'
import { aiConfigErrorMessage, generateWithGateway, isAiConfigured } from '@/lib/ai-gateway'

Expand Down Expand Up @@ -59,6 +59,10 @@ function parseAppIdeaChatResponse(raw: string): AppIdeaChatResponse {
}

export async function POST(request: NextRequest) {
let charged = false
let chargedUserId: string | null = null
let chargedAnalysisId: string | undefined

try {
if (!isAiConfigured()) {
return NextResponse.json({ error: aiConfigErrorMessage() }, { status: 503 })
Expand All @@ -75,6 +79,8 @@ export async function POST(request: NextRequest) {
history?: ChatMessage[]
}
const analysisId = normalizeAnalysisId(rawAnalysisId)
chargedUserId = user.id
chargedAnalysisId = analysisId

if (!message?.trim()) {
return NextResponse.json({ error: 'Message is required' }, { status: 400 })
Expand All @@ -96,6 +102,7 @@ export async function POST(request: NextRequest) {
if (!creditResult.success) {
return NextResponse.json({ error: creditResult.error || 'Insufficient credits' }, { status: 402 })
}
charged = true

let codebaseContext = ''
if (analysisId) {
Expand Down Expand Up @@ -198,10 +205,18 @@ Always respond with valid JSON only (no markdown fences):
try {
parsed = parseAppIdeaChatResponse(raw)
} catch {
await refundCredits(user.id, CREDITS.PATTERN_ANALYZER_COST, 'App Idea Chat failed', { analysisId }).catch((refundError) => {
console.error('[v0] Failed to refund app-idea-chat credits:', refundError)
})
charged = false
return NextResponse.json({ error: 'Failed to parse AI response' }, { status: 500 })
}

if (!parsed.reply?.trim()) {
await refundCredits(user.id, CREDITS.PATTERN_ANALYZER_COST, 'App Idea Chat failed', { analysisId }).catch((refundError) => {
console.error('[v0] Failed to refund app-idea-chat credits:', refundError)
})
charged = false
return NextResponse.json({ error: 'Empty AI response' }, { status: 500 })
}

Expand All @@ -211,6 +226,11 @@ Always respond with valid JSON only (no markdown fences):
followUpQuestions: Array.isArray(parsed.followUpQuestions) ? parsed.followUpQuestions : [],
})
} catch (error) {
if (charged && chargedUserId) {
await refundCredits(chargedUserId, CREDITS.PATTERN_ANALYZER_COST, 'App Idea Chat failed', { analysisId: chargedAnalysisId }).catch((refundError) => {
console.error('[v0] Failed to refund app-idea-chat credits:', refundError)
})
}
const errorMsg = error instanceof Error ? error.message : String(error)
console.error('[v0] app-idea-chat error:', errorMsg)
if (error instanceof Error) {
Expand Down
101 changes: 64 additions & 37 deletions app/api/build-app/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { generateWithGateway } from '@/lib/ai-gateway'
import { getCurrentUser } from '@/lib/auth'
import { getSubscriptionByGithubId, upsertSubscription, type AppBlueprint } from '@/lib/queries'
import { hasProAccess } from '@/lib/pro-access'
import { CREDITS, deductCredits, refundCredits } from '@/lib/credits'

type Platform = 'github' | 'gitlab'

Expand Down Expand Up @@ -56,20 +57,26 @@ Just the file content itself, ready to save.`

/** Build the list of all files to generate */
function getFilesToGenerate(blueprint: BuildAppRequest['blueprint']): Array<{ path: string; purpose: string }> {
const files: Array<{ path: string; purpose: string }> = []
const files = new Map<string, { path: string; purpose: string }>()
const addFile = (path: string, purpose: string) => {
const normalizedPath = path.trim()
if (normalizedPath && !files.has(normalizedPath)) {
files.set(normalizedPath, { path: normalizedPath, purpose })
}
}

// Missing files from the blueprint
for (const f of blueprint.missing_files) {
files.push({ path: f.name, purpose: f.purpose })
addFile(f.name, f.purpose)
}

// Standard project files
files.push({ path: 'README.md', purpose: 'Comprehensive setup, usage, and API documentation' })
files.push({ path: 'package.json', purpose: 'Project dependencies and scripts for the tech stack' })
files.push({ path: '.env.example', purpose: 'All required environment variables with placeholder values' })
files.push({ path: '.gitignore', purpose: 'Gitignore file appropriate for this stack' })
addFile('README.md', 'Comprehensive setup, usage, and API documentation')
addFile('package.json', 'Project dependencies and scripts for the tech stack')
addFile('.env.example', 'All required environment variables with placeholder values')
addFile('.gitignore', 'Gitignore file appropriate for this stack')

return files
return Array.from(files.values())
}

async function createGitHubRepo(
Expand All @@ -88,7 +95,7 @@ async function createGitHubRepo(
body: JSON.stringify({
name: repoName,
description,
private: false,
private: true,
auto_init: false,
}),
})
Expand Down Expand Up @@ -128,7 +135,7 @@ async function pushFileToGitHub(

if (!res.ok) {
const err = (await res.json()) as { message?: string }
console.warn(`[build-app] Failed to push ${path}: ${err.message}`)
throw new Error(`Failed to push ${path}: ${err.message ?? res.statusText}`)
}
}

Expand Down Expand Up @@ -190,17 +197,27 @@ async function pushFileToGitLab(

if (!res.ok) {
const err = (await res.json()) as { message?: string }
console.warn(`[build-app] Failed to push ${path} to GitLab: ${err.message}`)
throw new Error(`Failed to push ${path} to GitLab: ${err.message ?? res.statusText}`)
}
}

export async function POST(request: NextRequest) {
const encoder = new TextEncoder()
const stream = new ReadableStream({
async start(controller) {
let chargedForBuild = false
let refundMetadata: Record<string, unknown> = {}
let userIdForRefund = ''
const send = (data: object) => {
controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`))
}
const refundBuildCredits = async (reason: string) => {
if (!chargedForBuild) return
chargedForBuild = false
await refundCredits(userIdForRefund, CREDITS.BUILD_APP_COST, reason, refundMetadata).catch((refundError) => {
console.error('[build-app] Failed to refund credits:', refundError)
})
}

try {
const user = await getCurrentUser()
Expand Down Expand Up @@ -230,6 +247,23 @@ export async function POST(request: NextRequest) {
}

const cleanRepoName = repoName.trim().replace(/\s+/g, '-').toLowerCase()
refundMetadata = {
platform,
repoName: cleanRepoName,
blueprintName: blueprint.name,
}
userIdForRefund = user.id

const deductResult = await deductCredits(user.id, CREDITS.BUILD_APP_COST, 'build_app', refundMetadata)
if (!deductResult.success) {
send({
step: 'error',
message: deductResult.error ?? 'Insufficient credits for Build This App.',
})
controller.close()
return
}
chargedForBuild = true

// Step 1 — determine files to generate
const filesToGenerate = getFilesToGenerate(blueprint)
Expand All @@ -245,31 +279,22 @@ export async function POST(request: NextRequest) {
let gitlabProjectId: number | null = null
let gitlabBranch = 'main'

try {
if (platform === 'github') {
repoUrl = await createGitHubRepo(
accessToken,
user.github_username,
cleanRepoName,
blueprint.description ?? blueprint.name,
)
} else {
const project = await createGitLabProject(
accessToken,
cleanRepoName,
blueprint.description ?? blueprint.name,
)
repoUrl = project.web_url
gitlabProjectId = project.id
gitlabBranch = project.default_branch || 'main'
}
} catch (e) {
send({
step: 'error',
message: `Could not create repository: ${e instanceof Error ? e.message : String(e)}. Make sure you are connected to ${platform === 'github' ? 'GitHub' : 'GitLab'}.`,
})
controller.close()
return
if (platform === 'github') {
repoUrl = await createGitHubRepo(
accessToken,
user.github_username,
cleanRepoName,
blueprint.description ?? blueprint.name,
)
} else {
const project = await createGitLabProject(
accessToken,
cleanRepoName,
blueprint.description ?? blueprint.name,
)
repoUrl = project.web_url
gitlabProjectId = project.id
gitlabBranch = project.default_branch || 'main'
}

send({ step: 'repo_created', message: 'Repository created. Generating and pushing files…', repoUrl })
Expand All @@ -285,7 +310,7 @@ export async function POST(request: NextRequest) {
content = await generateSingleFile(blueprint, path, purpose, user.id)
} catch (e) {
console.warn(`[build-app] Failed to generate ${path}:`, e)
content = `# Error generating ${path}\n# ${e instanceof Error ? e.message : String(e)}\n`
throw new Error(`Failed to generate ${path}: ${e instanceof Error ? e.message : String(e)}`)
}

// Push to platform
Expand All @@ -312,11 +337,13 @@ export async function POST(request: NextRequest) {
repoUrl,
filesCreated: pushed,
})
chargedForBuild = false
} catch (e) {
console.error('[build-app] unhandled error:', e)
await refundBuildCredits('Build This App failed')
controller.enqueue(
encoder.encode(
`data: ${JSON.stringify({ step: 'error', message: 'An unexpected error occurred.' })}\n\n`,
`data: ${JSON.stringify({ step: 'error', message: e instanceof Error ? e.message : 'An unexpected error occurred.' })}\n\n`,
),
)
} finally {
Expand Down
35 changes: 27 additions & 8 deletions app/api/code-completion/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
batchGenerateCompletions,
} from '@/lib/code-completion'
import { getDb } from '@/lib/db'
import { getCurrentUser } from '@/lib/auth'

const LANG_EXTENSIONS: Record<string, string[]> = {
python: ['.py'],
Expand All @@ -24,18 +25,20 @@ const LANG_EXTENSIONS: Record<string, string[]> = {
shell: ['.sh', '.bash'],
}

async function fetchSnippetsFromDb(language: string): Promise<CodeSnippet[]> {
async function fetchSnippetsFromDb(language: string, userId: string): Promise<CodeSnippet[]> {
try {
const sql = getDb()
const extensions = LANG_EXTENSIONS[language.toLowerCase()] ?? []
if (extensions.length === 0) return []

const rows = await sql`
SELECT id, path, name, extension, ai_summary, reusability_score, exports, imports
FROM repo_files
WHERE extension = ANY(${extensions}::text[])
AND ai_summary IS NOT NULL
ORDER BY reusability_score DESC
SELECT rf.id, rf.path, rf.name, rf.extension, rf.ai_summary, rf.reusability_score, rf.exports, rf.imports
FROM repo_files rf
JOIN repositories r ON r.id = rf.repository_id
WHERE r.user_id = ${userId}
AND rf.extension = ANY(${extensions}::text[])
AND rf.ai_summary IS NOT NULL
ORDER BY rf.reusability_score DESC
LIMIT 20
`

Expand Down Expand Up @@ -63,6 +66,11 @@ async function fetchSnippetsFromDb(language: string): Promise<CodeSnippet[]> {

export async function POST(request: NextRequest) {
try {
const user = await getCurrentUser()
if (!user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}

const body = await request.json()
const { incompleteCode, codebaseSnippets = [], language = 'python' } = body

Expand All @@ -75,7 +83,7 @@ export async function POST(request: NextRequest) {

const snippets: CodeSnippet[] = codebaseSnippets.length > 0
? codebaseSnippets
: await fetchSnippetsFromDb(language)
: await fetchSnippetsFromDb(language, user.id)

const result = await generateRelevanceGuidedCompletion(
incompleteCode,
Expand Down Expand Up @@ -120,6 +128,11 @@ export async function POST(request: NextRequest) {
*/
export async function PUT(request: NextRequest) {
try {
const user = await getCurrentUser()
if (!user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}

const body = await request.json()
const { codeSnippets = [], codebaseSnippets = [], language = 'python' } = body

Expand All @@ -129,10 +142,16 @@ export async function PUT(request: NextRequest) {
{ status: 400 }
)
}
if (codeSnippets.length > 20) {
return NextResponse.json(
{ error: 'A maximum of 20 snippets can be completed at once' },
{ status: 400 }
)
}

const snippets: CodeSnippet[] = codebaseSnippets.length > 0
? codebaseSnippets
: await fetchSnippetsFromDb(language)
: await fetchSnippetsFromDb(language, user.id)

const results = await batchGenerateCompletions(
codeSnippets,
Expand Down
Loading
Loading