Smart files context (#184)

This commit is contained in:
Will Chen
2025-05-16 22:21:45 -07:00
committed by GitHub
parent 2455c554ee
commit f9f33596bd
8 changed files with 224 additions and 31 deletions

View File

@@ -27,6 +27,12 @@ export function ProModeSelector() {
}); });
}; };
const toggleSmartContext = () => {
updateSettings({
enableProSmartFilesContextMode: !settings?.enableProSmartFilesContextMode,
});
};
if (!settings?.enableDyadPro) { if (!settings?.enableDyadPro) {
return null; return null;
} }
@@ -105,6 +111,30 @@ export function ProModeSelector() {
onCheckedChange={toggleLazyEdits} onCheckedChange={toggleLazyEdits}
/> />
</div> </div>
<div className="flex items-center justify-between">
<div className="space-y-2">
<Label htmlFor="smart-context">Smart Context</Label>
<div className="flex items-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-4 w-4 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent side="right" className="max-w-72">
Improve efficiency and save credits working on large
codebases.
</TooltipContent>
</Tooltip>
<p className="text-xs text-muted-foreground max-w-55">
Automatically detects the most relevant files for your chat
</p>
</div>
</div>
<Switch
id="smart-context"
checked={Boolean(settings?.enableProSmartFilesContextMode)}
onCheckedChange={toggleSmartContext}
/>
</div>
</div> </div>
</PopoverContent> </PopoverContent>
</Popover> </Popover>

View File

@@ -0,0 +1,117 @@
import React, { useState, useEffect } from "react";
import { ChevronUp, ChevronDown, Code2, FileText } from "lucide-react";
import { CustomTagState } from "./stateTypes";
interface DyadCodebaseContextProps {
children: React.ReactNode;
node?: {
properties?: {
files?: string;
state?: CustomTagState;
};
};
}
export const DyadCodebaseContext: React.FC<DyadCodebaseContextProps> = ({
node,
}) => {
const state = node?.properties?.state as CustomTagState;
const inProgress = state === "pending";
const [isExpanded, setIsExpanded] = useState(inProgress);
const files = node?.properties?.files?.split(",") || [];
// Collapse when transitioning from in-progress to not-in-progress
useEffect(() => {
if (!inProgress && isExpanded) {
setIsExpanded(false);
}
}, [inProgress]);
return (
<div
className={`relative bg-(--background-lightest) dark:bg-zinc-900 hover:bg-(--background-lighter) rounded-lg px-4 py-2 border my-2 cursor-pointer ${
inProgress ? "border-blue-500" : "border-border"
}`}
onClick={() => setIsExpanded(!isExpanded)}
role="button"
aria-expanded={isExpanded}
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setIsExpanded(!isExpanded);
}
}}
>
{/* Top-left label badge */}
<div
className="absolute top-2 left-2 flex items-center gap-1 px-2 py-0.5 rounded text-xs font-semibold text-blue-500 bg-white dark:bg-zinc-900"
style={{ zIndex: 1 }}
>
<Code2 size={16} className="text-blue-500" />
<span>Codebase Context</span>
</div>
{/* File count when collapsed */}
{files.length > 0 && (
<div className="absolute top-2 left-40 flex items-center">
<span className="px-1.5 py-0.5 bg-gray-100 dark:bg-zinc-800 text-xs rounded text-gray-600 dark:text-gray-300">
Using {files.length} file{files.length !== 1 ? "s" : ""}
</span>
</div>
)}
{/* Indicator icon */}
<div className="absolute top-2 right-2 p-1 text-gray-500">
{isExpanded ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
</div>
{/* Main content with smooth transition */}
<div
className="pt-6 overflow-hidden transition-all duration-300 ease-in-out"
style={{
maxHeight: isExpanded ? "1000px" : "0px",
opacity: isExpanded ? 1 : 0,
marginBottom: isExpanded ? "0" : "-6px", // Compensate for padding
}}
>
{/* File list when expanded */}
{files.length > 0 && (
<div className="mb-3">
<div className="flex flex-wrap gap-2 mt-2">
{files.map((file, index) => {
const filePath = file.trim();
const fileName = filePath.split("/").pop() || filePath;
const pathPart =
filePath.substring(0, filePath.length - fileName.length) ||
"";
return (
<div
key={index}
className="px-2 py-1 bg-gray-100 dark:bg-zinc-800 rounded-lg"
>
<div className="flex items-center gap-1.5">
<FileText
size={14}
className="text-gray-500 dark:text-gray-400 flex-shrink-0"
/>
<div className="text-sm font-medium text-gray-700 dark:text-gray-300">
{fileName}
</div>
</div>
{pathPart && (
<div className="text-xs text-gray-500 dark:text-gray-400 ml-5">
{pathPart}
</div>
)}
</div>
);
})}
</div>
</div>
)}
</div>
</div>
);
};

View File

@@ -8,6 +8,7 @@ import { DyadAddDependency } from "./DyadAddDependency";
import { DyadExecuteSql } from "./DyadExecuteSql"; import { DyadExecuteSql } from "./DyadExecuteSql";
import { DyadAddIntegration } from "./DyadAddIntegration"; import { DyadAddIntegration } from "./DyadAddIntegration";
import { DyadEdit } from "./DyadEdit"; import { DyadEdit } from "./DyadEdit";
import { DyadCodebaseContext } from "./DyadCodebaseContext";
import { CodeHighlight } from "./CodeHighlight"; import { CodeHighlight } from "./CodeHighlight";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { isStreamingAtom } from "@/atoms/chatAtoms"; import { isStreamingAtom } from "@/atoms/chatAtoms";
@@ -117,6 +118,7 @@ function preprocessUnclosedTags(content: string): {
"dyad-output", "dyad-output",
"dyad-chat-summary", "dyad-chat-summary",
"dyad-edit", "dyad-edit",
"dyad-codebase-context",
]; ];
let processedContent = content; let processedContent = content;
@@ -180,6 +182,7 @@ function parseCustomTags(content: string): ContentPiece[] {
"dyad-output", "dyad-output",
"dyad-chat-summary", "dyad-chat-summary",
"dyad-edit", "dyad-edit",
"dyad-codebase-context",
]; ];
const tagPattern = new RegExp( const tagPattern = new RegExp(
@@ -362,6 +365,20 @@ function renderCustomTag(
</DyadEdit> </DyadEdit>
); );
case "dyad-codebase-context":
return (
<DyadCodebaseContext
node={{
properties: {
files: attributes.files || "",
state: getState({ isStreaming, inProgress }),
},
}}
>
{content}
</DyadCodebaseContext>
);
case "dyad-output": case "dyad-output":
return ( return (
<DyadOutput <DyadOutput

View File

@@ -236,11 +236,8 @@ export function registerChatStreamHandlers() {
"estimated tokens", "estimated tokens",
codebaseInfo.length / 4, codebaseInfo.length / 4,
); );
const { modelClient, backupModelClients } = await getModelClient( const { modelClient, backupModelClients, isEngineEnabled } =
settings.selectedModel, await getModelClient(settings.selectedModel, settings, files);
settings,
files,
);
// Prepare message history for the AI // Prepare message history for the AI
const messageHistory = updatedChat.messages.map((message) => ({ const messageHistory = updatedChat.messages.map((message) => ({
@@ -328,15 +325,22 @@ This conversation includes one or more image attachments. When the user uploads
`; `;
} }
const codebasePrefix = isEngineEnabled
? // No codebase prefix if engine is set, we will take of it there.
[]
: ([
{
role: "user",
content: "This is my codebase. " + codebaseInfo,
},
{
role: "assistant",
content: "OK, got it. I'm ready to help",
},
] as const);
let chatMessages: CoreMessage[] = [ let chatMessages: CoreMessage[] = [
{ ...codebasePrefix,
role: "user",
content: "This is my codebase. " + codebaseInfo,
},
{
role: "assistant",
content: "OK, got it. I'm ready to help",
},
...limitedMessageHistory.map((msg) => ({ ...limitedMessageHistory.map((msg) => ({
role: msg.role as "user" | "assistant" | "system", role: msg.role as "user" | "assistant" | "system",
content: msg.content, content: msg.content,

View File

@@ -50,6 +50,7 @@ export async function getModelClient(
): Promise<{ ): Promise<{
modelClient: ModelClient; modelClient: ModelClient;
backupModelClients: ModelClient[]; backupModelClients: ModelClient[];
isEngineEnabled?: boolean;
}> { }> {
const allProviders = await getLanguageModelProviders(); const allProviders = await getLanguageModelProviders();
@@ -103,9 +104,12 @@ export async function getModelClient(
// so we do a nullish and not a truthy check here. // so we do a nullish and not a truthy check here.
if (providerConfig.gatewayPrefix != null || dyadLocalEngine) { if (providerConfig.gatewayPrefix != null || dyadLocalEngine) {
const languageModel = await findLanguageModel(model); const languageModel = await findLanguageModel(model);
const engineProMode =
settings.enableProSmartFilesContextMode ||
settings.enableProLazyEditsMode;
// Currently engine is only used for turbo edits. // Currently engine is only used for turbo edits.
const isEngineEnabled = Boolean( const isEngineEnabled = Boolean(
settings.enableProLazyEditsMode && engineProMode &&
languageModel?.type === "cloud" && languageModel?.type === "cloud" &&
languageModel?.supportsTurboEdits, languageModel?.supportsTurboEdits,
); );
@@ -113,6 +117,10 @@ export async function getModelClient(
? createDyadEngine({ ? createDyadEngine({
apiKey: dyadApiKey, apiKey: dyadApiKey,
baseURL: dyadLocalEngine ?? "https://engine.dyad.sh/v1", baseURL: dyadLocalEngine ?? "https://engine.dyad.sh/v1",
dyadOptions: {
enableLazyEdits: settings.enableProLazyEditsMode,
enableSmartFilesContext: settings.enableProSmartFilesContextMode,
},
}) })
: createOpenAICompatible({ : createOpenAICompatible({
name: "dyad-gateway", name: "dyad-gateway",
@@ -126,7 +134,7 @@ export async function getModelClient(
const autoModelClient = { const autoModelClient = {
model: provider( model: provider(
`${providerConfig.gatewayPrefix || ""}${modelName}`, `${providerConfig.gatewayPrefix || ""}${modelName}`,
settings.enableProLazyEditsMode engineProMode
? { ? {
files, files,
} }
@@ -161,6 +169,7 @@ export async function getModelClient(
providerConfig, providerConfig,
).modelClient, ).modelClient,
backupModelClients: [autoModelClient], backupModelClients: [autoModelClient],
isEngineEnabled,
}; };
} else { } else {
return { return {

View File

@@ -41,6 +41,11 @@ Custom fetch implementation. You can use it as a middleware to intercept request
or to provide a custom fetch implementation for e.g. testing. or to provide a custom fetch implementation for e.g. testing.
*/ */
fetch?: FetchFunction; fetch?: FetchFunction;
dyadOptions: {
enableLazyEdits?: boolean;
enableSmartFilesContext?: boolean;
};
} }
export interface DyadEngineProvider { export interface DyadEngineProvider {
@@ -62,7 +67,7 @@ Creates a chat model for text generation.
} }
export function createDyadEngine( export function createDyadEngine(
options: ExampleProviderSettings = {}, options: ExampleProviderSettings,
): DyadEngineProvider { ): DyadEngineProvider {
const baseURL = withoutTrailingSlash( const baseURL = withoutTrailingSlash(
options.baseURL ?? "https://api.example.com/v1", options.baseURL ?? "https://api.example.com/v1",
@@ -124,7 +129,9 @@ export function createDyadEngine(
if (files?.length) { if (files?.length) {
parsedBody.dyad_options = { parsedBody.dyad_options = {
files, files,
enable_lazy_edits: true, enable_lazy_edits: options.dyadOptions.enableLazyEdits,
enable_smart_files_context:
options.dyadOptions.enableSmartFilesContext,
}; };
} }

View File

@@ -120,6 +120,7 @@ export const UserSettingsSchema = z.object({
maxChatTurnsInContext: z.number().optional(), maxChatTurnsInContext: z.number().optional(),
enableProSaverMode: z.boolean().optional(), enableProSaverMode: z.boolean().optional(),
enableProLazyEditsMode: z.boolean().optional(), enableProLazyEditsMode: z.boolean().optional(),
enableProSmartFilesContextMode: z.boolean().optional(),
// DEPRECATED. // DEPRECATED.
runtimeMode: RuntimeModeSchema.optional(), runtimeMode: RuntimeModeSchema.optional(),
}); });

View File

@@ -223,6 +223,21 @@ async function collectFiles(dir: string, baseDir: string): Promise<string[]> {
return files; return files;
} }
// Skip large configuration files or generated code (just include the path)
function isOmittedFile(relativePath: string): boolean {
return (
relativePath.includes(path.join("src", "components", "ui")) ||
relativePath.includes("eslint.config") ||
relativePath.includes("tsconfig.json") ||
relativePath.includes("package-lock.json") ||
// These should already be excluded based on file type, but
// just in case, we'll redact the contents here.
relativePath.includes(".env")
);
}
const OMITTED_FILE_CONTENT = "// Contents omitted for brevity";
/** /**
* Format a file for inclusion in the codebase extract * Format a file for inclusion in the codebase extract
*/ */
@@ -230,18 +245,9 @@ async function formatFile(filePath: string, baseDir: string): Promise<string> {
try { try {
const relativePath = path.relative(baseDir, filePath); const relativePath = path.relative(baseDir, filePath);
// Skip large configuration files or generated code (just include the path) if (isOmittedFile(relativePath)) {
if (
relativePath.includes(path.join("src", "components", "ui")) ||
relativePath.includes("eslint.config") ||
relativePath.includes("tsconfig.json") ||
relativePath.includes("package-lock.json") ||
// These should already be excluded based on file type, but
// just in case, we'll redact the contents here.
relativePath.includes(".env")
) {
return `<dyad-file path="${relativePath}"> return `<dyad-file path="${relativePath}">
// Contents omitted for brevity ${OMITTED_FILE_CONTENT}
</dyad-file> </dyad-file>
`; `;
@@ -305,11 +311,13 @@ export async function extractCodebase(appPath: string): Promise<{
// Get raw content for the files array // Get raw content for the files array
const relativePath = path.relative(appPath, file); const relativePath = path.relative(appPath, file);
const rawContent = await readFileWithCache(file); const fileContent = isOmittedFile(relativePath)
if (rawContent !== null) { ? OMITTED_FILE_CONTENT
: await readFileWithCache(file);
if (fileContent !== null) {
filesArray.push({ filesArray.push({
path: relativePath, path: relativePath,
content: rawContent, content: fileContent,
}); });
} }