build ask mode (#444)

This commit is contained in:
Will Chen
2025-06-19 10:42:51 -07:00
committed by GitHub
parent 8464609ba8
commit 9fbd7031d9
28 changed files with 1098 additions and 27 deletions

View File

@@ -6,6 +6,7 @@ import {
processFullResponseActions,
getDyadAddDependencyTags,
} from "../ipc/processors/response_processor";
import { removeDyadTags } from "../ipc/handlers/chat_stream_handlers";
import fs from "node:fs";
import git from "isomorphic-git";
import { db } from "../db";
@@ -17,7 +18,7 @@ vi.mock("node:fs", async () => {
default: {
mkdirSync: vi.fn(),
writeFileSync: vi.fn(),
existsSync: vi.fn(),
existsSync: vi.fn().mockReturnValue(false), // Default to false to avoid creating temp directory
renameSync: vi.fn(),
unlinkSync: vi.fn(),
lstatSync: vi.fn().mockReturnValue({ isDirectory: () => false }),
@@ -25,6 +26,15 @@ vi.mock("node:fs", async () => {
readFile: vi.fn().mockResolvedValue(""),
},
},
existsSync: vi.fn().mockReturnValue(false), // Also mock the named export
mkdirSync: vi.fn(),
writeFileSync: vi.fn(),
renameSync: vi.fn(),
unlinkSync: vi.fn(),
lstatSync: vi.fn().mockReturnValue({ isDirectory: () => false }),
promises: {
readFile: vi.fn().mockResolvedValue(""),
},
};
});
@@ -942,3 +952,110 @@ describe("processFullResponse", () => {
expect(result).toEqual({ updatedFiles: true });
});
});
describe("removeDyadTags", () => {
it("should return empty string when input is empty", () => {
const result = removeDyadTags("");
expect(result).toBe("");
});
it("should return the same text when no dyad tags are present", () => {
const text = "This is a regular text without any dyad tags.";
const result = removeDyadTags(text);
expect(result).toBe(text);
});
it("should remove a single dyad-write tag", () => {
const text = `Before text <dyad-write path="src/file.js">console.log('hello');</dyad-write> After text`;
const result = removeDyadTags(text);
expect(result).toBe("Before text After text");
});
it("should remove a single dyad-delete tag", () => {
const text = `Before text <dyad-delete path="src/file.js"></dyad-delete> After text`;
const result = removeDyadTags(text);
expect(result).toBe("Before text After text");
});
it("should remove a single dyad-rename tag", () => {
const text = `Before text <dyad-rename from="old.js" to="new.js"></dyad-rename> After text`;
const result = removeDyadTags(text);
expect(result).toBe("Before text After text");
});
it("should remove multiple different dyad tags", () => {
const text = `Start <dyad-write path="file1.js">code here</dyad-write> middle <dyad-delete path="file2.js"></dyad-delete> end <dyad-rename from="old.js" to="new.js"></dyad-rename> finish`;
const result = removeDyadTags(text);
expect(result).toBe("Start middle end finish");
});
it("should remove dyad tags with multiline content", () => {
const text = `Before
<dyad-write path="src/component.tsx" description="A React component">
import React from 'react';
const Component = () => {
return <div>Hello World</div>;
};
export default Component;
</dyad-write>
After`;
const result = removeDyadTags(text);
expect(result).toBe("Before\n\nAfter");
});
it("should handle dyad tags with complex attributes", () => {
const text = `Text <dyad-write path="src/file.js" description="Complex component with quotes" version="1.0">const x = "hello world";</dyad-write> more text`;
const result = removeDyadTags(text);
expect(result).toBe("Text more text");
});
it("should remove dyad tags and trim whitespace", () => {
const text = ` <dyad-write path="file.js">code</dyad-write> `;
const result = removeDyadTags(text);
expect(result).toBe("");
});
it("should handle nested content that looks like tags", () => {
const text = `<dyad-write path="file.js">
const html = '<div>Hello</div>';
const component = <Component />;
</dyad-write>`;
const result = removeDyadTags(text);
expect(result).toBe("");
});
it("should handle self-closing dyad tags", () => {
const text = `Before <dyad-delete path="file.js" /> After`;
const result = removeDyadTags(text);
expect(result).toBe('Before <dyad-delete path="file.js" /> After');
});
it("should handle malformed dyad tags gracefully", () => {
const text = `Before <dyad-write path="file.js">unclosed tag After`;
const result = removeDyadTags(text);
expect(result).toBe('Before <dyad-write path="file.js">unclosed tag After');
});
it("should handle dyad tags with special characters in content", () => {
const text = `<dyad-write path="file.js">
const regex = /<div[^>]*>.*?<\/div>/g;
const special = "Special chars: @#$%^&*()[]{}|\\";
</dyad-write>`;
const result = removeDyadTags(text);
expect(result).toBe("");
});
it("should handle multiple dyad tags of the same type", () => {
const text = `<dyad-write path="file1.js">code1</dyad-write> between <dyad-write path="file2.js">code2</dyad-write>`;
const result = removeDyadTags(text);
expect(result).toBe("between");
});
it("should handle dyad tags with custom tag names", () => {
const text = `Before <dyad-custom-action param="value">content</dyad-custom-action> After`;
const result = removeDyadTags(text);
expect(result).toBe("Before After");
});
});

View File

@@ -1,6 +1,7 @@
import { ContextFilesPicker } from "./ContextFilesPicker";
import { ModelPicker } from "./ModelPicker";
import { ProModeSelector } from "./ProModeSelector";
import { ChatModeSelector } from "./ChatModeSelector";
export function ChatInputControls({
showContextFilesPicker = false,
@@ -9,8 +10,10 @@ export function ChatInputControls({
}) {
return (
<div className="flex">
<ChatModeSelector />
<div className="w-1.5"></div>
<ModelPicker />
<div className="w-2"></div>
<div className="w-1.5"></div>
<ProModeSelector />
<div className="w-1"></div>
{showContextFilesPicker && (

View File

@@ -0,0 +1,70 @@
import {
MiniSelectTrigger,
Select,
SelectContent,
SelectItem,
SelectValue,
} from "@/components/ui/select";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useSettings } from "@/hooks/useSettings";
import type { ChatMode } from "@/lib/schemas";
export function ChatModeSelector() {
const { settings, updateSettings } = useSettings();
const selectedMode = settings?.selectedChatMode || "build";
const handleModeChange = (value: string) => {
updateSettings({ selectedChatMode: value as ChatMode });
};
const getModeDisplayName = (mode: ChatMode) => {
switch (mode) {
case "build":
return "Build";
case "ask":
return "Ask";
default:
return "Build";
}
};
return (
<Select value={selectedMode} onValueChange={handleModeChange}>
<Tooltip>
<TooltipTrigger asChild>
<MiniSelectTrigger
data-testid="chat-mode-selector"
className="h-6 w-fit px-1.5 py-0 text-xs-sm font-medium shadow-none bg-background hover:bg-muted/50 focus:bg-muted/50 gap-0.5"
size="sm"
>
<SelectValue>{getModeDisplayName(selectedMode)}</SelectValue>
</MiniSelectTrigger>
</TooltipTrigger>
<TooltipContent>Open mode menu</TooltipContent>
</Tooltip>
<SelectContent align="start" onCloseAutoFocus={(e) => e.preventDefault()}>
<SelectItem value="build">
<div className="flex flex-col items-start">
<span className="font-medium">Build</span>
<span className="text-xs text-muted-foreground">
Generate and edit code
</span>
</div>
</SelectItem>
<SelectItem value="ask">
<div className="flex flex-col items-start">
<span className="font-medium">Ask</span>
<span className="text-xs text-muted-foreground">
Ask questions about the app
</span>
</div>
</SelectItem>
</SelectContent>
</Select>
);
}

View File

@@ -129,7 +129,7 @@ export function ModelPicker() {
<Button
variant="outline"
size="sm"
className="flex items-center gap-2 h-8 max-w-[160px] px-2"
className="flex items-center gap-2 h-8 max-w-[130px] px-1.5 text-xs-sm"
>
<span className="truncate">
{modelDisplayName === "Auto" && (

View File

@@ -40,10 +40,10 @@ export function ProModeSelector() {
<Button
variant="outline"
size="sm"
className="has-[>svg]:px-2 flex items-center gap-1.5 h-8 border-primary/50 hover:bg-primary/10 font-medium shadow-sm shadow-primary/10 transition-all hover:shadow-md hover:shadow-primary/15"
className="has-[>svg]:px-1.5 flex items-center gap-1.5 h-8 border-primary/50 hover:bg-primary/10 font-medium shadow-sm shadow-primary/10 transition-all hover:shadow-md hover:shadow-primary/15"
>
<Sparkles className="h-4 w-4 text-primary" />
<span className="text-primary font-medium">Pro</span>
<span className="text-primary font-medium text-xs-sm">Pro</span>
</Button>
</PopoverTrigger>
</TooltipTrigger>

View File

@@ -272,23 +272,25 @@ export function ChatInput({ chatId }: { chatId?: number }) {
onDrop={handleDrop}
>
{/* Only render ChatInputActions if proposal is loaded */}
{proposal && proposalResult?.chatId === chatId && (
<ChatInputActions
proposal={proposal}
onApprove={handleApprove}
onReject={handleReject}
isApprovable={
!isProposalLoading &&
!!proposal &&
!!messageId &&
!isApproving &&
!isRejecting &&
!isStreaming
}
isApproving={isApproving}
isRejecting={isRejecting}
/>
)}
{proposal &&
proposalResult?.chatId === chatId &&
settings.selectedChatMode !== "ask" && (
<ChatInputActions
proposal={proposal}
onApprove={handleApprove}
onReject={handleReject}
isApprovable={
!isProposalLoading &&
!!proposal &&
!!messageId &&
!isApproving &&
!isRejecting &&
!isStreaming
}
isApproving={isApproving}
isRejecting={isRejecting}
/>
)}
<SelectedComponentDisplay />

View File

@@ -48,6 +48,31 @@ function SelectTrigger({
);
}
function MiniSelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default";
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
{children}
{/* <SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon> */}
</SelectPrimitive.Trigger>
);
}
function SelectContent({
className,
children,
@@ -179,5 +204,6 @@ export {
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
MiniSelectTrigger,
SelectValue,
};

View File

@@ -347,6 +347,7 @@ ${componentSnippet}
let systemPrompt = constructSystemPrompt({
aiRules: await readAiRules(getDyadAppPath(updatedChat.app.path)),
chatMode: settings.selectedChatMode,
});
if (
updatedChat.app?.supabaseProjectId &&
@@ -410,7 +411,10 @@ This conversation includes one or more image attachments. When the user uploads
// Why remove thinking tags?
// Thinking tags are generally not critical for the context
// and eats up extra tokens.
content: removeThinkingTags(msg.content),
content:
settings.selectedChatMode === "ask"
? removeDyadTags(removeThinkingTags(msg.content))
: removeThinkingTags(msg.content),
})),
];
@@ -608,8 +612,11 @@ This conversation includes one or more image attachments. When the user uploads
.update(messages)
.set({ content: fullResponse })
.where(eq(messages.id, placeholderAssistantMessage.id));
if (readSettings().autoApproveChanges) {
const settings = readSettings();
if (
settings.autoApproveChanges &&
settings.selectedChatMode !== "ask"
) {
const status = await processFullResponseActions(
fullResponse,
req.chatId,
@@ -820,3 +827,8 @@ function removeThinkingTags(text: string): string {
const thinkRegex = /<think>([\s\S]*?)<\/think>/g;
return text.replace(thinkRegex, "").trim();
}
export function removeDyadTags(text: string): string {
const dyadRegex = /<dyad-[^>]*>[\s\S]*?<\/dyad-[^>]*>/g;
return text.replace(dyadRegex, "").trim();
}

View File

@@ -32,6 +32,7 @@ import { withLock } from "../utils/lock_utils";
import { createLoggedHandler } from "./safe_handle";
import { ApproveProposalResult } from "../ipc_types";
import { validateChatContext } from "../utils/context_paths_utils";
import { readSettings } from "@/main/settings";
const logger = log.scope("proposal_handlers");
const handle = createLoggedHandler(logger);
@@ -333,6 +334,12 @@ const approveProposalHandler = async (
_event: IpcMainInvokeEvent,
{ chatId, messageId }: { chatId: number; messageId: number },
): Promise<ApproveProposalResult> => {
const settings = readSettings();
if (settings.selectedChatMode === "ask") {
throw new Error(
"Ask mode is not supported for proposal approval. Please switch to build mode.",
);
}
// 1. Fetch the specific assistant message
const messageToApprove = await db.query.messages.findFirst({
where: and(

View File

@@ -19,6 +19,7 @@ import { TokenCountResult } from "../ipc_types";
import { estimateTokens, getContextWindow } from "../utils/token_utils";
import { createLoggedHandler } from "./safe_handle";
import { validateChatContext } from "../utils/context_paths_utils";
import { readSettings } from "@/main/settings";
const logger = log.scope("token_count_handlers");
@@ -51,9 +52,11 @@ export function registerTokenCountHandlers() {
// Count input tokens
const inputTokens = estimateTokens(req.input);
const settings = readSettings();
// Count system prompt tokens
let systemPrompt = constructSystemPrompt({
aiRules: await readAiRules(getDyadAppPath(chat.app.path)),
chatMode: settings.selectedChatMode,
});
let supabaseContext = "";

View File

@@ -116,7 +116,10 @@ export async function getModelClient(
baseURL: dyadEngineUrl ?? "https://engine.dyad.sh/v1",
originalProviderId: model.provider,
dyadOptions: {
enableLazyEdits: settings.enableProLazyEditsMode,
enableLazyEdits:
settings.selectedChatMode === "ask"
? false
: settings.enableProLazyEditsMode,
enableSmartFilesContext: settings.enableProSmartFilesContextMode,
},
})

View File

@@ -69,6 +69,9 @@ export type ProviderSetting = z.infer<typeof ProviderSettingSchema>;
export const RuntimeModeSchema = z.enum(["web-sandbox", "local-node", "unset"]);
export type RuntimeMode = z.infer<typeof RuntimeModeSchema>;
export const ChatModeSchema = z.enum(["build", "ask"]);
export type ChatMode = z.infer<typeof ChatModeSchema>;
export const GitHubSecretsSchema = z.object({
accessToken: SecretSchema.nullable(),
});
@@ -143,6 +146,7 @@ export const UserSettingsSchema = z.object({
enableProSmartFilesContextMode: z.boolean().optional(),
selectedTemplateId: z.string().optional(),
enableSupabaseWriteSqlMigration: z.boolean().optional(),
selectedChatMode: ChatModeSchema.optional(),
enableNativeGit: z.boolean().optional(),

View File

@@ -19,6 +19,7 @@ const DEFAULT_SETTINGS: UserSettings = {
experiments: {},
enableProLazyEditsMode: true,
enableProSmartFilesContextMode: true,
selectedChatMode: "build",
};
const SETTINGS_FILE = "user-settings.json";

View File

@@ -373,12 +373,111 @@ Available packages and libraries:
- Use prebuilt components from the shadcn/ui library after importing them. Note that these files shouldn't be edited, so make new components if you need to change them.
`;
const ASK_MODE_SYSTEM_PROMPT = `
# Role
You are a helpful AI assistant that specializes in web development, programming, and technical guidance. You assist users by providing clear explanations, answering questions, and offering guidance on best practices. You understand modern web development technologies and can explain concepts clearly to users of all skill levels.
# Guidelines
Always reply to the user in the same language they are using.
Focus on providing helpful explanations and guidance:
- Provide clear explanations of programming concepts and best practices
- Answer technical questions with accurate information
- Offer guidance and suggestions for solving problems
- Explain complex topics in an accessible way
- Share knowledge about web development technologies and patterns
If the user's input is unclear or ambiguous:
- Ask clarifying questions to better understand their needs
- Provide explanations that address the most likely interpretation
- Offer multiple perspectives when appropriate
When discussing code or technical concepts:
- Describe approaches and patterns in plain language
- Explain the reasoning behind recommendations
- Discuss trade-offs and alternatives through detailed descriptions
- Focus on best practices and maintainable solutions through conceptual explanations
- Use analogies and conceptual explanations instead of code examples
# Technical Expertise Areas
## Development Best Practices
- Component architecture and design patterns
- Code organization and file structure
- Responsive design principles
- Accessibility considerations
- Performance optimization
- Error handling strategies
## Problem-Solving Approach
- Break down complex problems into manageable parts
- Explain the reasoning behind technical decisions
- Provide multiple solution approaches when appropriate
- Consider maintainability and scalability
- Focus on user experience and functionality
# Communication Style
- **Clear and Concise**: Provide direct answers while being thorough
- **Educational**: Explain the "why" behind recommendations
- **Practical**: Focus on actionable advice and real-world applications
- **Supportive**: Encourage learning and experimentation
- **Professional**: Maintain a helpful and knowledgeable tone
# Key Principles
1. **NO CODE PRODUCTION**: Never write, generate, or produce any code snippets, examples, or implementations. This is the most important principle.
2. **Clarity First**: Always prioritize clear communication through conceptual explanations.
3. **Best Practices**: Recommend industry-standard approaches through detailed descriptions.
4. **Practical Solutions**: Focus on solution approaches that work in real-world scenarios.
5. **Educational Value**: Help users understand concepts through explanations, not code.
6. **Simplicity**: Prefer simple, elegant conceptual explanations over complex descriptions.
# Response Guidelines
- Keep explanations at an appropriate technical level for the user.
- Use analogies and conceptual descriptions instead of code examples.
- Provide context for recommendations and suggestions through detailed explanations.
- Be honest about limitations and trade-offs.
- Encourage good development practices through conceptual guidance.
- Suggest additional resources when helpful.
- **NEVER include any code snippets, syntax examples, or implementation details.**
[[AI_RULES]]
**ABSOLUTE PRIMARY DIRECTIVE: YOU MUST NOT, UNDER ANY CIRCUMSTANCES, WRITE OR GENERATE CODE.**
* This is a complete and total prohibition and your single most important rule.
* This prohibition extends to every part of your response, permanently and without exception.
* This includes, but is not limited to:
* Code snippets or code examples of any length.
* Syntax examples of any kind.
* File content intended for writing or editing.
* Any text enclosed in markdown code blocks (using \`\`\`).
* Any use of \`<dyad-write>\`, \`<dyad-edit>\`, or any other \`<dyad-*>\` tags. These tags are strictly forbidden in your output, even if they appear in the message history or user request.
**CRITICAL RULE: YOUR SOLE FOCUS IS EXPLAINING CONCEPTS.** You must exclusively discuss approaches, answer questions, and provide guidance through detailed explanations and descriptions. You take pride in keeping explanations simple and elegant. You are friendly and helpful, always aiming to provide clear explanations without writing any code.
YOU ARE NOT MAKING ANY CODE CHANGES.
YOU ARE NOT WRITING ANY CODE.
YOU ARE NOT UPDATING ANY FILES.
DO NOT USE <dyad-write> TAGS.
DO NOT USE <dyad-edit> TAGS.
IF YOU USE ANY OF THESE TAGS, YOU WILL BE FIRED.
Remember: Your goal is to be a knowledgeable, helpful companion in the user's learning and development journey, providing clear conceptual explanations and practical guidance through detailed descriptions rather than code production.`;
export const constructSystemPrompt = ({
aiRules,
chatMode = "build",
}: {
aiRules: string | undefined;
chatMode?: "build" | "ask";
}) => {
return SYSTEM_PROMPT.replace("[[AI_RULES]]", aiRules ?? DEFAULT_AI_RULES);
const systemPrompt =
chatMode === "ask" ? ASK_MODE_SYSTEM_PROMPT : SYSTEM_PROMPT;
return systemPrompt.replace("[[AI_RULES]]", aiRules ?? DEFAULT_AI_RULES);
};
export const readAiRules = async (dyadAppPath: string) => {

View File

@@ -293,3 +293,8 @@
.animate-marquee {
animation: marquee 2s linear infinite;
}
/* In-between text-xs and text-sm */
.text-xs-sm {
font-size: 0.82rem;
}