diff --git a/package.json b/package.json index 0eb3ca3..ed3f6fc 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "scripts": { "clean": "rm -rf out && rm -rf scaffold/node_modules", "start": "electron-forge start", + "dev:engine": "DYAD_LOCAL_ENGINE=http://localhost:8080/v1 npm start", "package": "npm run clean && electron-forge package", "make": "npm run clean && electron-forge make", "publish": "npm run clean && electron-forge publish", diff --git a/src/components/ProModeSelector.tsx b/src/components/ProModeSelector.tsx index c8dca95..047bb62 100644 --- a/src/components/ProModeSelector.tsx +++ b/src/components/ProModeSelector.tsx @@ -20,6 +20,13 @@ export function ProModeSelector() { const toggleSaverMode = () => { updateSettings({ enableProSaverMode: !settings?.enableProSaverMode }); }; + + const toggleLazyEdits = () => { + updateSettings({ + enableProLazyEditsMode: !settings?.enableProLazyEditsMode, + }); + }; + if (!settings?.enableDyadPro) { return null; } @@ -75,6 +82,29 @@ export function ProModeSelector() { onCheckedChange={toggleSaverMode} /> +
+
+ +
+ + + + + + Edits files faster. + + +

+ Makes editing files faster and cheaper. +

+
+
+ +
diff --git a/src/components/chat/DyadEdit.tsx b/src/components/chat/DyadEdit.tsx new file mode 100644 index 0000000..4eb9c5a --- /dev/null +++ b/src/components/chat/DyadEdit.tsx @@ -0,0 +1,110 @@ +import type React from "react"; +import type { ReactNode } from "react"; +import { useState } from "react"; +import { + ChevronsDownUp, + ChevronsUpDown, + Loader, + CircleX, + Rabbit, +} from "lucide-react"; +import { CodeHighlight } from "./CodeHighlight"; +import { CustomTagState } from "./stateTypes"; + +interface DyadEditProps { + children?: ReactNode; + node?: any; + path?: string; + description?: string; +} + +export const DyadEdit: React.FC = ({ + children, + node, + path: pathProp, + description: descriptionProp, +}) => { + const [isContentVisible, setIsContentVisible] = useState(false); + + // Use props directly if provided, otherwise extract from node + const path = pathProp || node?.properties?.path || ""; + const description = descriptionProp || node?.properties?.description || ""; + const state = node?.properties?.state as CustomTagState; + const inProgress = state === "pending"; + const aborted = state === "aborted"; + + // Extract filename from path + const fileName = path ? path.split("/").pop() : ""; + + return ( +
setIsContentVisible(!isContentVisible)} + > +
+
+
+ + + Turbo Edit + +
+ {fileName && ( + + {fileName} + + )} + {inProgress && ( +
+ + Editing... +
+ )} + {aborted && ( +
+ + Did not finish +
+ )} +
+
+ {isContentVisible ? ( + + ) : ( + + )} +
+
+ {path && ( +
+ {path} +
+ )} + {description && ( +
+ Summary: + {description} +
+ )} + {isContentVisible && ( +
+ + {children} + +
+ )} +
+ ); +}; diff --git a/src/components/chat/DyadMarkdownParser.tsx b/src/components/chat/DyadMarkdownParser.tsx index 1e0c9ea..a1e08d8 100644 --- a/src/components/chat/DyadMarkdownParser.tsx +++ b/src/components/chat/DyadMarkdownParser.tsx @@ -7,6 +7,7 @@ import { DyadDelete } from "./DyadDelete"; import { DyadAddDependency } from "./DyadAddDependency"; import { DyadExecuteSql } from "./DyadExecuteSql"; import { DyadAddIntegration } from "./DyadAddIntegration"; +import { DyadEdit } from "./DyadEdit"; import { CodeHighlight } from "./CodeHighlight"; import { useAtomValue } from "jotai"; import { isStreamingAtom } from "@/atoms/chatAtoms"; @@ -115,6 +116,7 @@ function preprocessUnclosedTags(content: string): { "dyad-add-integration", "dyad-output", "dyad-chat-summary", + "dyad-edit", ]; let processedContent = content; @@ -177,6 +179,7 @@ function parseCustomTags(content: string): ContentPiece[] { "dyad-add-integration", "dyad-output", "dyad-chat-summary", + "dyad-edit", ]; const tagPattern = new RegExp( @@ -344,6 +347,21 @@ function renderCustomTag( ); + case "dyad-edit": + return ( + + {content} + + ); + case "dyad-output": return ( ({ diff --git a/src/ipc/handlers/debug_handlers.ts b/src/ipc/handlers/debug_handlers.ts index 26d37cb..1fee9d1 100644 --- a/src/ipc/handlers/debug_handlers.ts +++ b/src/ipc/handlers/debug_handlers.ts @@ -140,7 +140,7 @@ export function registerDebugHandlers() { // Extract codebase const appPath = getDyadAppPath(app.path); - const codebase = await extractCodebase(appPath); + const codebase = (await extractCodebase(appPath)).formattedOutput; return { debugInfo, diff --git a/src/ipc/handlers/proposal_handlers.ts b/src/ipc/handlers/proposal_handlers.ts index 6d94495..3b54f7f 100644 --- a/src/ipc/handlers/proposal_handlers.ts +++ b/src/ipc/handlers/proposal_handlers.ts @@ -92,7 +92,8 @@ async function getCodebaseTokenCount( // Calculate and cache the token count logger.log(`Calculating codebase token count for chatId: ${chatId}`); - const codebase = await extractCodebase(getDyadAppPath(appPath)); + const codebase = (await extractCodebase(getDyadAppPath(appPath))) + .formattedOutput; const tokenCount = estimateTokens(codebase); // Store in cache diff --git a/src/ipc/handlers/token_count_handlers.ts b/src/ipc/handlers/token_count_handlers.ts index 198f4d0..dcfab8e 100644 --- a/src/ipc/handlers/token_count_handlers.ts +++ b/src/ipc/handlers/token_count_handlers.ts @@ -68,7 +68,7 @@ export function registerTokenCountHandlers() { if (chat.app) { const appPath = getDyadAppPath(chat.app.path); - codebaseInfo = await extractCodebase(appPath); + codebaseInfo = (await extractCodebase(appPath)).formattedOutput; codebaseTokens = estimateTokens(codebaseInfo); logger.log( `Extracted codebase information from ${appPath}, tokens: ${codebaseTokens}`, diff --git a/src/ipc/utils/get_model_client.ts b/src/ipc/utils/get_model_client.ts index b8aa146..4de54c9 100644 --- a/src/ipc/utils/get_model_client.ts +++ b/src/ipc/utils/get_model_client.ts @@ -11,6 +11,9 @@ import log from "electron-log"; import { getLanguageModelProviders } from "../shared/language_model_helpers"; import { LanguageModelProvider } from "../ipc_types"; import { llmErrorStore } from "@/main/llm_error_store"; +import { createDyadEngine } from "./llm_engine_provider"; + +const dyadLocalEngine = process.env.DYAD_LOCAL_ENGINE; const AUTO_MODELS = [ { @@ -32,10 +35,16 @@ export interface ModelClient { builtinProviderId?: string; } +interface File { + path: string; + content: string; +} + const logger = log.scope("getModelClient"); export async function getModelClient( model: LargeLanguageModel, settings: UserSettings, + files?: File[], ): Promise<{ modelClient: ModelClient; backupModelClients: ModelClient[]; @@ -65,8 +74,9 @@ export async function getModelClient( { provider: autoModel.provider, name: autoModel.name, - } as LargeLanguageModel, + }, settings, + files, ); } } @@ -85,17 +95,33 @@ export async function getModelClient( // Handle Dyad Pro override if (dyadApiKey && settings.enableDyadPro) { - // Check if the selected provider supports Dyad Pro (has a gateway prefix) - if (providerConfig.gatewayPrefix) { - const provider = createOpenAI({ - apiKey: dyadApiKey, - baseURL: "https://llm-gateway.dyad.sh/v1", - }); - logger.info("Using Dyad Pro API key via Gateway"); + // Check if the selected provider supports Dyad Pro (has a gateway prefix) OR + // we're using local engine. + if (providerConfig.gatewayPrefix || dyadLocalEngine) { + const provider = settings.enableProLazyEditsMode + ? createDyadEngine({ + apiKey: dyadApiKey, + baseURL: dyadLocalEngine ?? "https://engine.dyad.sh/v1", + }) + : createOpenAI({ + apiKey: dyadApiKey, + baseURL: "https://llm-gateway.dyad.sh/v1", + }); + + logger.info( + `Using Dyad Pro API key. engine_enabled=${settings.enableProLazyEditsMode}`, + ); // Do not use free variant (for openrouter). const modelName = model.name.split(":free")[0]; const autoModelClient = { - model: provider(`${providerConfig.gatewayPrefix}${modelName}`), + model: provider( + `${providerConfig.gatewayPrefix || ""}${modelName}`, + settings.enableProLazyEditsMode + ? { + files, + } + : undefined, + ), builtinProviderId: "auto", }; const googleSettings = settings.providerSettings?.google; @@ -235,7 +261,7 @@ function getRegularModelClient( const provider = createOpenAICompatible({ name: providerConfig.id, baseURL: providerConfig.apiBaseUrl, - apiKey: apiKey, + apiKey, }); return { modelClient: { diff --git a/src/ipc/utils/llm_engine_provider.ts b/src/ipc/utils/llm_engine_provider.ts new file mode 100644 index 0000000..c325066 --- /dev/null +++ b/src/ipc/utils/llm_engine_provider.ts @@ -0,0 +1,159 @@ +import { + LanguageModelV1, + LanguageModelV1ObjectGenerationMode, +} from "@ai-sdk/provider"; +import { OpenAICompatibleChatLanguageModel } from "@ai-sdk/openai-compatible"; +import { + FetchFunction, + loadApiKey, + withoutTrailingSlash, +} from "@ai-sdk/provider-utils"; + +import { OpenAICompatibleChatSettings } from "@ai-sdk/openai-compatible"; +import log from "electron-log"; + +const logger = log.scope("llm_engine_provider"); + +export type ExampleChatModelId = string & {}; + +export interface ExampleChatSettings extends OpenAICompatibleChatSettings { + files?: { path: string; content: string }[]; +} +export interface ExampleProviderSettings { + /** +Example API key. +*/ + apiKey?: string; + /** +Base URL for the API calls. +*/ + baseURL?: string; + /** +Custom headers to include in the requests. +*/ + headers?: Record; + /** +Optional custom url query parameters to include in request urls. +*/ + queryParams?: Record; + /** +Custom fetch implementation. You can use it as a middleware to intercept requests, +or to provide a custom fetch implementation for e.g. testing. +*/ + fetch?: FetchFunction; +} + +export interface DyadEngineProvider { + /** +Creates a model for text generation. +*/ + ( + modelId: ExampleChatModelId, + settings?: ExampleChatSettings, + ): LanguageModelV1; + + /** +Creates a chat model for text generation. +*/ + chatModel( + modelId: ExampleChatModelId, + settings?: ExampleChatSettings, + ): LanguageModelV1; +} + +export function createDyadEngine( + options: ExampleProviderSettings = {}, +): DyadEngineProvider { + const baseURL = withoutTrailingSlash( + options.baseURL ?? "https://api.example.com/v1", + ); + const getHeaders = () => ({ + Authorization: `Bearer ${loadApiKey({ + apiKey: options.apiKey, + environmentVariableName: "DYAD_PRO_API_KEY", + description: "Example API key", + })}`, + ...options.headers, + }); + + interface CommonModelConfig { + provider: string; + url: ({ path }: { path: string }) => string; + headers: () => Record; + fetch?: FetchFunction; + } + + const getCommonModelConfig = (modelType: string): CommonModelConfig => ({ + provider: `example.${modelType}`, + url: ({ path }) => { + const url = new URL(`${baseURL}${path}`); + if (options.queryParams) { + url.search = new URLSearchParams(options.queryParams).toString(); + } + return url.toString(); + }, + headers: getHeaders, + fetch: options.fetch, + }); + + const createChatModel = ( + modelId: ExampleChatModelId, + settings: ExampleChatSettings = {}, + ) => { + // Extract files from settings to process them appropriately + const { files, ...restSettings } = settings; + + // Create configuration with file handling + const config = { + ...getCommonModelConfig("chat"), + defaultObjectGenerationMode: + "tool" as LanguageModelV1ObjectGenerationMode, + // Custom fetch implementation that adds files to the request + fetch: files?.length + ? (input: RequestInfo | URL, init?: RequestInit) => { + // Use default fetch if no init or body + if (!init || !init.body || typeof init.body !== "string") { + return (options.fetch || fetch)(input, init); + } + + try { + // Parse the request body to manipulate it + const parsedBody = JSON.parse(init.body); + + // Add files to the request if they exist + if (files?.length) { + parsedBody.dyad_options = { + files, + enable_lazy_edits: true, + }; + } + + // Return modified request with files included + const modifiedInit = { + ...init, + body: JSON.stringify(parsedBody), + }; + + // Use the provided fetch or default fetch + return (options.fetch || fetch)(input, modifiedInit); + } catch (e) { + logger.error("Error parsing request body", e); + // If parsing fails, use original request + return (options.fetch || fetch)(input, init); + } + } + : options.fetch, + }; + + return new OpenAICompatibleChatLanguageModel(modelId, restSettings, config); + }; + + const provider = ( + modelId: ExampleChatModelId, + settings?: ExampleChatSettings, + ) => createChatModel(modelId, settings); + + provider.chatModel = createChatModel; + + return provider; +} diff --git a/src/lib/schemas.ts b/src/lib/schemas.ts index 85ecc99..f9d687e 100644 --- a/src/lib/schemas.ts +++ b/src/lib/schemas.ts @@ -119,6 +119,7 @@ export const UserSettingsSchema = z.object({ lastShownReleaseNotesVersion: z.string().optional(), maxChatTurnsInContext: z.number().optional(), enableProSaverMode: z.boolean().optional(), + enableProLazyEditsMode: z.boolean().optional(), // DEPRECATED. runtimeMode: RuntimeModeSchema.optional(), }); diff --git a/src/utils/codebase.ts b/src/utils/codebase.ts index e7bf1e0..2445fc6 100644 --- a/src/utils/codebase.ts +++ b/src/utils/codebase.ts @@ -275,13 +275,19 @@ ${content} /** * Extract and format codebase files as a string to be included in prompts * @param appPath - Path to the codebase to extract - * @returns A string containing formatted file contents + * @returns Object containing formatted output and individual files */ -export async function extractCodebase(appPath: string): Promise { +export async function extractCodebase(appPath: string): Promise<{ + formattedOutput: string; + files: { path: string; content: string }[]; +}> { try { await fsAsync.access(appPath); } catch { - return `# Error: Directory ${appPath} does not exist or is not accessible`; + return { + formattedOutput: `# Error: Directory ${appPath} does not exist or is not accessible`, + files: [], + }; } const startTime = Date.now(); @@ -292,15 +298,33 @@ export async function extractCodebase(appPath: string): Promise { // This is important for cache-ability. const sortedFiles = await sortFilesByModificationTime(files); - // Format files - let output = ""; - const formatPromises = sortedFiles.map((file) => formatFile(file, appPath)); + // Format files and collect individual file contents + const filesArray: { path: string; content: string }[] = []; + const formatPromises = sortedFiles.map(async (file) => { + const formattedContent = await formatFile(file, appPath); + + // Get raw content for the files array + const relativePath = path.relative(appPath, file); + const rawContent = await readFileWithCache(file); + if (rawContent !== null) { + filesArray.push({ + path: relativePath, + content: rawContent, + }); + } + + return formattedContent; + }); + const formattedFiles = await Promise.all(formatPromises); - output = formattedFiles.join(""); + const formattedOutput = formattedFiles.join(""); const endTime = Date.now(); logger.log("extractCodebase: time taken", endTime - startTime); - return output; + return { + formattedOutput, + files: filesArray, + }; } /**