diff --git a/app/components/chat/ToolCall.tsx b/app/components/chat/ToolCall.tsx index 4d64a79cc..c78a2de0a 100644 --- a/app/components/chat/ToolCall.tsx +++ b/app/components/chat/ToolCall.tsx @@ -21,6 +21,7 @@ import { npmInstallToolParameters } from '~/lib/runtime/npmInstallTool'; import { loggingSafeParse } from '~/lib/zodUtil'; import { deployToolParameters } from '~/lib/runtime/deployTool'; import type { ZodError } from 'zod'; +import { getRelativePath } from '~/lib/stores/files'; export const ToolCall = memo((props: { partId: PartId; toolCallId: string }) => { const { partId, toolCallId } = props; @@ -382,7 +383,7 @@ function toolTitle(invocation: ConvexToolInvocation): React.ReactNode { extra = ` (lines ${start} - ${endName})`; } if (args.success) { - renderedPath = args.data.path || '/home/project'; + renderedPath = getRelativePath(args.data.path) || '/home/project'; } return (
@@ -418,9 +419,9 @@ function toolTitle(invocation: ConvexToolInvocation): React.ReactNode { } else if (invocation.result?.startsWith('Error:')) { if ( // This is a hack, but `npx convex dev` prints this out when the typecheck fails - invocation.result.includes('To ignore failing typecheck') || + invocation.result.includes('[ConvexTypecheck]') || // this is a bigger hack! TypeScript fails with error codes like TS - invocation.result.includes('Error TS') + invocation.result.includes('[FrontendTypecheck]') ) { return (
diff --git a/app/lib/runtime/action-runner.ts b/app/lib/runtime/action-runner.ts index 8542f0387..4dafba574 100644 --- a/app/lib/runtime/action-runner.ts +++ b/app/lib/runtime/action-runner.ts @@ -372,31 +372,80 @@ export class ActionRunner { case 'deploy': { const container = await this.#webcontainer; await waitForContainerBootState(ContainerBootState.READY); - const convexProc = await container.spawn('sh', [ - '-c', - 'convex dev --once && tsc --noEmit -p tsconfig.app.json', - ]); - action.abortSignal.addEventListener('abort', () => { - convexProc.kill(); - }); + result = ''; + + const convexTypecheckProc = await container.spawn('npx', ['tsc', '-p', 'convex']); + let abortListener: () => void = () => { + convexTypecheckProc.kill(); + }; + action.abortSignal.addEventListener('abort', abortListener); - const { output, exitCode } = await streamOutput(convexProc, { + const { output: convexTypecheckOutput, exitCode: convexTypecheckExitCode } = await streamOutput( + convexTypecheckProc, + { + onOutput: (output) => { + this.terminalOutput.set(output); + }, + debounceMs: 50, + }, + ); + const cleanedTypecheckOutput = cleanConvexOutput(convexTypecheckOutput); + if (convexTypecheckExitCode !== 0) { + throw new Error( + `[ConvexTypecheck] Failed with exit code ${convexTypecheckExitCode}: ${cleanedTypecheckOutput}`, + ); + } + result += cleanedTypecheckOutput + '\n\n'; + action.abortSignal.removeEventListener('abort', abortListener); + + const convexDevProc = await container.spawn('npx', ['convex', 'dev', '--once', '--typecheck', 'disable']); + abortListener = () => { + convexDevProc.kill(); + }; + action.abortSignal.addEventListener('abort', abortListener); + + const { output: convexDevOutput, exitCode: convexDevExitCode } = await streamOutput(convexDevProc, { onOutput: (output) => { - this.terminalOutput.set(output); + this.terminalOutput.set(result + output); }, debounceMs: 50, }); - const cleanedOutput = cleanConvexOutput(output); - if (exitCode !== 0) { - throw new Error(`Convex failed with exit code ${exitCode}: ${cleanedOutput}`); + const cleanedDevOutput = cleanConvexOutput(convexDevOutput); + if (convexDevExitCode !== 0) { + throw new Error(`[ConvexDev] Failed with exit code ${convexDevExitCode}: ${cleanedDevOutput}`); + } + result += cleanedDevOutput + '\n\n'; + action.abortSignal.removeEventListener('abort', abortListener); + + const frontendTypecheckProc = await container.spawn('npx', ['tsc', '--noEmit', '-p', 'tsconfig.app.json']); + abortListener = () => { + frontendTypecheckProc.kill(); + }; + action.abortSignal.addEventListener('abort', abortListener); + + const { output: frontendTypecheckOutput, exitCode: frontendTypecheckExitCode } = await streamOutput( + frontendTypecheckProc, + { + onOutput: (output) => { + this.terminalOutput.set(result + output); + }, + debounceMs: 50, + }, + ); + const cleanedFrontendTypecheckOutput = cleanConvexOutput(frontendTypecheckOutput); + if (frontendTypecheckExitCode !== 0) { + throw new Error( + `[FrontendTypecheck] Failed with exit code ${frontendTypecheckExitCode}: ${cleanedFrontendTypecheckOutput}`, + ); } - result = cleanedOutput; + result += cleanedFrontendTypecheckOutput + '\n\n'; + action.abortSignal.removeEventListener('abort', abortListener); // Start the default preview if it’s not already running if (!workbenchStore.isDefaultPreviewRunning()) { const shell = this.#shellTerminal(); await shell.startCommand('npx vite --open'); - result += '\n\nDev server started successfully!'; + result += 'Dev server started successfully!'; } break; diff --git a/convex/openaiProxy.ts b/convex/openaiProxy.ts index df0cfe82b..fdb6da866 100644 --- a/convex/openaiProxy.ts +++ b/convex/openaiProxy.ts @@ -1,115 +1,121 @@ -import { ConvexError, v } from "convex/values"; -import { httpAction, internalMutation, mutation } from "./_generated/server"; -import { getCurrentMember } from "./sessions"; -import { internal } from "./_generated/api"; +import { ConvexError, v } from 'convex/values'; +import { httpAction, internalMutation, mutation } from './_generated/server'; +import { getCurrentMember } from './sessions'; +import { internal } from './_generated/api'; export const openaiProxy = httpAction(async (ctx, req) => { - if (!openaiProxyEnabled()) { - return new Response("Convex OpenAI proxy is disabled.", { status: 400 }); - } - if (!process.env.OPENAI_API_KEY) { - throw new Error("OPENAI_API_KEY is not set"); - } - const headers = new Headers(req.headers); - const authHeader = headers.get("Authorization"); - if (!authHeader) { - return new Response("Unauthorized", { status: 401 }); - } - if (!authHeader.startsWith("Bearer ")) { - return new Response("Invalid authorization header", { status: 401 }); - } - const token = authHeader.slice(7); - const result = await ctx.runMutation(internal.openaiProxy.decrementToken, { token }); - if (!result.success) { - return new Response(result.error, { status: 401 }); - } + if (!openaiProxyEnabled()) { + return new Response('Convex OpenAI proxy is disabled.', { status: 400 }); + } + if (!process.env.OPENAI_API_KEY) { + throw new Error('OPENAI_API_KEY is not set'); + } + const headers = new Headers(req.headers); + const authHeader = headers.get('Authorization'); + if (!authHeader) { + return new Response('Unauthorized', { status: 401 }); + } + if (!authHeader.startsWith('Bearer ')) { + return new Response('Invalid authorization header', { status: 401 }); + } + const token = authHeader.slice(7); + const result = await ctx.runMutation(internal.openaiProxy.decrementToken, { token }); + if (!result.success) { + return new Response(result.error, { status: 401 }); + } - let body: any; - try { - body = await req.json(); - } catch (error) { - return new Response("Invalid request body", { status: 400 }); - } - if (body.model != 'gpt-4o-mini') { - return new Response("Only gpt-4o-mini is supported", { status: 400 }); - } + let body: any; + try { + body = await req.json(); + } catch (error) { + return new Response('Invalid request body', { status: 400 }); + } + if (body.model != 'gpt-4o-mini') { + return new Response('Only gpt-4o-mini is supported', { status: 400 }); + } - const response = await fetch("https://api.openai.com/v1/chat/completions", { - method: "POST", - headers: { - "Content-Type": "application/json", - "Authorization": `Bearer ${process.env.OPENAI_API_KEY}`, - }, - body: JSON.stringify(body), - }); - return response; + const response = await fetch('https://api.openai.com/v1/chat/completions', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${process.env.OPENAI_API_KEY}`, + }, + body: JSON.stringify(body), + }); + return response; }); export const issueOpenAIToken = mutation({ - handler: async (ctx) => { - if (!openaiProxyEnabled()) { - return null; - } - const member = await getCurrentMember(ctx); - if (!member) { - console.error("Not authorized", member) - return null; - } - const existing = await ctx.db.query('memberOpenAITokens') - .withIndex('byMemberId', (q) => q.eq('memberId', member._id)) - .unique(); - if (existing) { - return existing.token; - } - const token = crypto.randomUUID(); - await ctx.db.insert('memberOpenAITokens', { - memberId: member._id, - token, - requestsRemaining: included4oMiniRequests(), - lastUsedTime: 0, - }); - return token; + handler: async (ctx) => { + if (!openaiProxyEnabled()) { + return null; + } + const member = await getCurrentMember(ctx); + if (!member) { + console.error('Not authorized', member); + return null; } -}) + const existing = await ctx.db + .query('memberOpenAITokens') + .withIndex('byMemberId', (q) => q.eq('memberId', member._id)) + .unique(); + if (existing) { + return existing.token; + } + const token = crypto.randomUUID(); + await ctx.db.insert('memberOpenAITokens', { + memberId: member._id, + token, + requestsRemaining: included4oMiniRequests(), + lastUsedTime: 0, + }); + return token; + }, +}); export const decrementToken = internalMutation({ - args: { - token: v.string(), - }, - handler: async (ctx, args) => { - if (!openaiProxyEnabled()) { - return { success: false, error: "Convex OpenAI proxy is disabled."}; - } - const token = await ctx.db.query('memberOpenAITokens') - .withIndex('byToken', (q) => q.eq('token', args.token)) - .unique(); - if (!token) { - return { success: false, error: "Invalid OPENAI_API_TOKEN" }; - } - if (token.requestsRemaining <= 0) { - return { success: false, error: "Convex OPENAI_API_TOKEN has no requests remaining. Go sign up for an OpenAI API key at https://platform.openai.com and update your app to use that." }; - } - await ctx.db.patch(token._id, { - requestsRemaining: token.requestsRemaining - 1, - lastUsedTime: Date.now(), - }); - return { success: true }; + args: { + token: v.string(), + }, + handler: async (ctx, args) => { + if (!openaiProxyEnabled()) { + return { success: false, error: 'Convex OpenAI proxy is disabled.' }; + } + const token = await ctx.db + .query('memberOpenAITokens') + .withIndex('byToken', (q) => q.eq('token', args.token)) + .unique(); + if (!token) { + return { success: false, error: 'Invalid OPENAI_API_TOKEN' }; } -}) + if (token.requestsRemaining <= 0) { + return { + success: false, + error: + 'Convex OPENAI_API_TOKEN has no requests remaining. Go sign up for an OpenAI API key at https://platform.openai.com and update your app to use that.', + }; + } + await ctx.db.patch(token._id, { + requestsRemaining: token.requestsRemaining - 1, + lastUsedTime: Date.now(), + }); + return { success: true }; + }, +}); // Cost per gpt-4o-mini request (2025-04-09): // 16384 max output tokens @ $0.6/1M // 128K max input tokens @ $0.15/1M // => ~$0.03 per request. function included4oMiniRequests() { - const fromEnv = process.env.OPENAI_PROXY_INCLUDED_REQUESTS; - if (!fromEnv) { - return 100; - } - return Number(fromEnv); + const fromEnv = process.env.OPENAI_PROXY_INCLUDED_REQUESTS; + if (!fromEnv) { + return 100; + } + return Number(fromEnv); } function openaiProxyEnabled() { - const fromEnv = process.env.OPENAI_PROXY_ENABLED; - return fromEnv && fromEnv == "1"; -} \ No newline at end of file + const fromEnv = process.env.OPENAI_PROXY_ENABLED; + return fromEnv && fromEnv == '1'; +}