Unverified 提交 30415638 authored 作者: Will Chen's avatar Will Chen 提交者: GitHub

Finish incomplete dyad write (#475)

Fixes #452 Fixes #456 Fixes #195
上级 fa29488b
BEGIN
<dyad-write path="src/integrations/supabase/client.ts" description="Creating a supabase client.">
$$SUPABASE_CLIENT_CODE$$
</dyad-write>
END
START OF MESSAGE
<dyad-write path="src/new-file.ts" description="this file will be partially written">
const a = "[[STRING_TO_BE_FINISHED]]
import { test } from "./helpers/test_helper";
test("partial message is resumed", async ({ po }) => {
await po.setUp({ autoApprove: true });
await po.importApp("minimal");
await po.sendPrompt("tc=partial-write");
// This is a special test case which triggers a dump.
await po.snapshotServerDump("all-messages");
await po.snapshotMessages({ replaceDumpPath: true });
await po.snapshotAppFiles();
});
- paragraph: /Generate an AI_RULES\.md file for this app\. Describe the tech stack in 5-\d+ bullet points and describe clear rules about what libraries to use for what\./
- img
- text: file1.txt
- img
- text: file1.txt
- paragraph: More EOM
- img
- text: Approved
- paragraph: tc=partial-write
- paragraph: START OF MESSAGE
- img
- text: new-file.ts
- img
- text: "src/new-file.ts Summary: this file will be partially written"
- paragraph: "[[dyad-dump-path=*]]"
- img
- text: Approved
- button "Undo":
- img
- button "Retry":
- img
\ No newline at end of file
=== .gitignore ===
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
=== file1.txt ===
A file (2)
=== index.html ===
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>dyad-generated-app</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
=== package.json ===
{
"name": "vite_react_shadcn_ts",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"build:dev": "vite build --mode development",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/node": "^22.5.5",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react-swc": "^3.9.0",
"typescript": "^5.5.3",
"vite": "^6.3.4"
},
"packageManager": "<scrubbed>"
}
=== src/App.tsx ===
const App = () => <div>Minimal imported app</div>;
export default App;
=== src/main.tsx ===
import { createRoot } from "react-dom/client";
import App from "./App.tsx";
createRoot(document.getElementById("root")!).render(<App />);
=== src/new-file.ts ===
const a = "[[STRING_TO_BE_FINISHED]]
[[STRING_IS_FINISHED]]";
=== src/vite-env.d.ts ===
/// <reference types="vite/client" />
=== tsconfig.app.json ===
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": false,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noImplicitAny": false,
"noFallthroughCasesInSwitch": false,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"]
}
=== tsconfig.json ===
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
],
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
"noImplicitAny": false,
"noUnusedParameters": false,
"skipLibCheck": true,
"allowJs": true,
"noUnusedLocals": false,
"strictNullChecks": false
}
}
=== tsconfig.node.json ===
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}
=== vite.config.ts ===
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react-swc";
import path from "path";
export default defineConfig(() => ({
server: {
host: "::",
port: 8080,
},
plugins: [react()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
}));
=== .gitignore ===
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
=== file1.txt ===
A file (2)
=== index.html ===
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>dyad-generated-app</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
=== package.json ===
{
"name": "vite_react_shadcn_ts",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"build:dev": "vite build --mode development",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/node": "^22.5.5",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react-swc": "^3.9.0",
"typescript": "^5.5.3",
"vite": "^6.3.4"
},
"packageManager": "<scrubbed>"
}
=== src/App.tsx ===
const App = () => <div>Minimal imported app</div>;
export default App;
=== src/integrations/supabase/client.ts ===
// This file is automatically generated. Do not edit it directly.
import { createClient } from '@supabase/supabase-js';
const SUPABASE_URL = "https://fake-project-id.supabase.co";
const SUPABASE_PUBLISHABLE_KEY = "test-publishable-key";
// Import the supabase client like this:
// import { supabase } from "@/integrations/supabase/client";
export const supabase = createClient(SUPABASE_URL, SUPABASE_PUBLISHABLE_KEY);
=== src/main.tsx ===
import { createRoot } from "react-dom/client";
import App from "./App.tsx";
createRoot(document.getElementById("root")!).render(<App />);
=== src/vite-env.d.ts ===
/// <reference types="vite/client" />
=== tsconfig.app.json ===
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": false,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noImplicitAny": false,
"noFallthroughCasesInSwitch": false,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"]
}
=== tsconfig.json ===
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
],
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
"noImplicitAny": false,
"noUnusedParameters": false,
"skipLibCheck": true,
"allowJs": true,
"noUnusedLocals": false,
"strictNullChecks": false
}
}
=== tsconfig.node.json ===
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}
=== vite.config.ts ===
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react-swc";
import path from "path";
export default defineConfig(() => ({
server: {
host: "::",
port: 8080,
},
plugins: [react()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
}));
import { testSkipIfWindows } from "./helpers/test_helper";
testSkipIfWindows("supabase client is generated", async ({ po }) => {
await po.setUp({ autoApprove: true });
await po.importApp("minimal");
await po.sendPrompt("tc=add-supabase");
// Connect to Supabase
await po.page.getByText("Set up supabase").click();
await po.clickConnectSupabaseButton();
await po.clickBackButton();
await po.sendPrompt("tc=generate-supabase-client");
await po.snapshotAppFiles();
});
......@@ -6,7 +6,10 @@ import {
processFullResponseActions,
getDyadAddDependencyTags,
} from "../ipc/processors/response_processor";
import { removeDyadTags } from "../ipc/handlers/chat_stream_handlers";
import {
removeDyadTags,
hasUnclosedDyadWrite,
} from "../ipc/handlers/chat_stream_handlers";
import fs from "node:fs";
import git from "isomorphic-git";
import { db } from "../db";
......@@ -1040,7 +1043,7 @@ const component = <Component />;
it("should handle dyad tags with special characters in content", () => {
const text = `<dyad-write path="file.js">
const regex = /<div[^>]*>.*?<\/div>/g;
const regex = /<div[^>]*>.*?</div>/g;
const special = "Special chars: @#$%^&*()[]{}|\\";
</dyad-write>`;
const result = removeDyadTags(text);
......@@ -1059,3 +1062,145 @@ const special = "Special chars: @#$%^&*()[]{}|\\";
expect(result).toBe("Before After");
});
});
describe("hasUnclosedDyadWrite", () => {
it("should return false when there are no dyad-write tags", () => {
const text = "This is just regular text without any dyad tags.";
const result = hasUnclosedDyadWrite(text);
expect(result).toBe(false);
});
it("should return false when dyad-write tag is properly closed", () => {
const text = `<dyad-write path="src/file.js">console.log('hello');</dyad-write>`;
const result = hasUnclosedDyadWrite(text);
expect(result).toBe(false);
});
it("should return true when dyad-write tag is not closed", () => {
const text = `<dyad-write path="src/file.js">console.log('hello');`;
const result = hasUnclosedDyadWrite(text);
expect(result).toBe(true);
});
it("should return false when dyad-write tag with attributes is properly closed", () => {
const text = `<dyad-write path="src/file.js" description="A test file">console.log('hello');</dyad-write>`;
const result = hasUnclosedDyadWrite(text);
expect(result).toBe(false);
});
it("should return true when dyad-write tag with attributes is not closed", () => {
const text = `<dyad-write path="src/file.js" description="A test file">console.log('hello');`;
const result = hasUnclosedDyadWrite(text);
expect(result).toBe(true);
});
it("should return false when there are multiple closed dyad-write tags", () => {
const text = `<dyad-write path="src/file1.js">code1</dyad-write>
Some text in between
<dyad-write path="src/file2.js">code2</dyad-write>`;
const result = hasUnclosedDyadWrite(text);
expect(result).toBe(false);
});
it("should return true when the last dyad-write tag is unclosed", () => {
const text = `<dyad-write path="src/file1.js">code1</dyad-write>
Some text in between
<dyad-write path="src/file2.js">code2`;
const result = hasUnclosedDyadWrite(text);
expect(result).toBe(true);
});
it("should return false when first tag is unclosed but last tag is closed", () => {
const text = `<dyad-write path="src/file1.js">code1
Some text in between
<dyad-write path="src/file2.js">code2</dyad-write>`;
const result = hasUnclosedDyadWrite(text);
expect(result).toBe(false);
});
it("should handle multiline content correctly", () => {
const text = `<dyad-write path="src/component.tsx" description="React component">
import React from 'react';
const Component = () => {
return (
<div>
<h1>Hello World</h1>
</div>
);
};
export default Component;
</dyad-write>`;
const result = hasUnclosedDyadWrite(text);
expect(result).toBe(false);
});
it("should handle multiline unclosed content correctly", () => {
const text = `<dyad-write path="src/component.tsx" description="React component">
import React from 'react';
const Component = () => {
return (
<div>
<h1>Hello World</h1>
</div>
);
};
export default Component;`;
const result = hasUnclosedDyadWrite(text);
expect(result).toBe(true);
});
it("should handle complex attributes correctly", () => {
const text = `<dyad-write path="src/file.js" description="File with quotes and special chars" version="1.0" author="test">
const message = "Hello 'world'";
const regex = /<div[^>]*>/g;
</dyad-write>`;
const result = hasUnclosedDyadWrite(text);
expect(result).toBe(false);
});
it("should handle text before and after dyad-write tags", () => {
const text = `Some text before the tag
<dyad-write path="src/file.js">console.log('hello');</dyad-write>
Some text after the tag`;
const result = hasUnclosedDyadWrite(text);
expect(result).toBe(false);
});
it("should handle unclosed tag with text after", () => {
const text = `Some text before the tag
<dyad-write path="src/file.js">console.log('hello');
Some text after the unclosed tag`;
const result = hasUnclosedDyadWrite(text);
expect(result).toBe(true);
});
it("should handle empty dyad-write tags", () => {
const text = `<dyad-write path="src/file.js"></dyad-write>`;
const result = hasUnclosedDyadWrite(text);
expect(result).toBe(false);
});
it("should handle unclosed empty dyad-write tags", () => {
const text = `<dyad-write path="src/file.js">`;
const result = hasUnclosedDyadWrite(text);
expect(result).toBe(true);
});
it("should focus on the last opening tag when there are mixed states", () => {
const text = `<dyad-write path="src/file1.js">completed content</dyad-write>
<dyad-write path="src/file2.js">unclosed content
<dyad-write path="src/file3.js">final content</dyad-write>`;
const result = hasUnclosedDyadWrite(text);
expect(result).toBe(false);
});
it("should handle tags with special characters in attributes", () => {
const text = `<dyad-write path="src/file-name_with.special@chars.js" description="File with special chars in path">content</dyad-write>`;
const result = hasUnclosedDyadWrite(text);
expect(result).toBe(false);
});
});
......@@ -451,41 +451,86 @@ This conversation includes one or more image attachments. When the user uploads
];
}
// When calling streamText, the messages need to be properly formatted for mixed content
const { fullStream } = streamText({
maxTokens: await getMaxTokens(settings.selectedModel),
temperature: 0,
maxRetries: 2,
model: modelClient.model,
providerOptions: {
"dyad-gateway": getExtraProviderOptions(
modelClient.builtinProviderId,
),
google: {
thinkingConfig: {
includeThoughts: true,
},
} satisfies GoogleGenerativeAIProviderOptions,
},
system: systemPrompt,
messages: chatMessages.filter((m) => m.content),
onError: (error: any) => {
logger.error("Error streaming text:", error);
let errorMessage = (error as any)?.error?.message;
const responseBody = error?.error?.responseBody;
if (errorMessage && responseBody) {
errorMessage += "\n\nDetails: " + responseBody;
}
const message = errorMessage || JSON.stringify(error);
event.sender.send(
"chat:response:error",
`Sorry, there was an error from the AI: ${message}`,
const simpleStreamText = async ({
chatMessages,
}: {
chatMessages: CoreMessage[];
}) => {
return streamText({
maxTokens: await getMaxTokens(settings.selectedModel),
temperature: 0,
maxRetries: 2,
model: modelClient.model,
providerOptions: {
"dyad-gateway": getExtraProviderOptions(
modelClient.builtinProviderId,
),
google: {
thinkingConfig: {
includeThoughts: true,
},
} satisfies GoogleGenerativeAIProviderOptions,
},
system: systemPrompt,
messages: chatMessages.filter((m) => m.content),
onError: (error: any) => {
logger.error("Error streaming text:", error);
let errorMessage = (error as any)?.error?.message;
const responseBody = error?.error?.responseBody;
if (errorMessage && responseBody) {
errorMessage += "\n\nDetails: " + responseBody;
}
const message = errorMessage || JSON.stringify(error);
event.sender.send(
"chat:response:error",
`Sorry, there was an error from the AI: ${message}`,
);
// Clean up the abort controller
activeStreams.delete(req.chatId);
},
abortSignal: abortController.signal,
});
};
const processResponseChunkUpdate = async ({
fullResponse,
}: {
fullResponse: string;
}) => {
if (
fullResponse.includes("$$SUPABASE_CLIENT_CODE$$") &&
updatedChat.app?.supabaseProjectId
) {
const supabaseClientCode = await getSupabaseClientCode({
projectId: updatedChat.app?.supabaseProjectId,
});
fullResponse = fullResponse.replace(
"$$SUPABASE_CLIENT_CODE$$",
supabaseClientCode,
);
// Clean up the abort controller
activeStreams.delete(req.chatId);
},
abortSignal: abortController.signal,
});
}
// Store the current partial response
partialResponses.set(req.chatId, fullResponse);
// Update the placeholder assistant message content in the messages array
const currentMessages = [...updatedChat.messages];
if (
currentMessages.length > 0 &&
currentMessages[currentMessages.length - 1].role === "assistant"
) {
currentMessages[currentMessages.length - 1].content = fullResponse;
}
// Update the assistant message in the database
safeSend(event.sender, "chat:response:chunk", {
chatId: req.chatId,
messages: currentMessages,
});
return fullResponse;
};
// When calling streamText, the messages need to be properly formatted for mixed content
const { fullStream } = await simpleStreamText({ chatMessages });
// Process the stream as before
let inThinkingBlock = false;
......@@ -520,36 +565,8 @@ This conversation includes one or more image attachments. When the user uploads
fullResponse += chunk;
fullResponse = cleanFullResponse(fullResponse);
if (
fullResponse.includes("$$SUPABASE_CLIENT_CODE$$") &&
updatedChat.app?.supabaseProjectId
) {
const supabaseClientCode = await getSupabaseClientCode({
projectId: updatedChat.app?.supabaseProjectId,
});
fullResponse = fullResponse.replace(
"$$SUPABASE_CLIENT_CODE$$",
supabaseClientCode,
);
}
// Store the current partial response
partialResponses.set(req.chatId, fullResponse);
// Update the placeholder assistant message content in the messages array
const currentMessages = [...updatedChat.messages];
if (
currentMessages.length > 0 &&
currentMessages[currentMessages.length - 1].role === "assistant"
) {
currentMessages[currentMessages.length - 1].content =
fullResponse;
}
// Update the assistant message in the database
safeSend(event.sender, "chat:response:chunk", {
chatId: req.chatId,
messages: currentMessages,
fullResponse = await processResponseChunkUpdate({
fullResponse,
});
// If the stream was aborted, exit early
......@@ -558,6 +575,45 @@ This conversation includes one or more image attachments. When the user uploads
break;
}
}
if (
!abortController.signal.aborted &&
settings.selectedChatMode !== "ask" &&
hasUnclosedDyadWrite(fullResponse)
) {
let continuationAttempts = 0;
while (
hasUnclosedDyadWrite(fullResponse) &&
continuationAttempts < 2 &&
!abortController.signal.aborted
) {
logger.warn(
`Received unclosed dyad-write tag, attempting to continue, attempt #${continuationAttempts + 1}`,
);
continuationAttempts++;
const { fullStream: contStream } = await simpleStreamText({
// Build messages: replay history then pre-fill assistant with current partial.
chatMessages: [
...chatMessages,
{ role: "assistant", content: fullResponse },
],
});
for await (const part of contStream) {
// If the stream was aborted, exit early
if (abortController.signal.aborted) {
logger.log(`Stream for chat ${req.chatId} was aborted`);
break;
}
if (part.type !== "text-delta") continue; // ignore reasoning for continuation
fullResponse += part.textDelta;
fullResponse = cleanFullResponse(fullResponse);
fullResponse = await processResponseChunkUpdate({
fullResponse,
});
}
}
}
} catch (streamError) {
// Check if this was an abort error
if (abortController.signal.aborted) {
......@@ -832,3 +888,25 @@ export function removeDyadTags(text: string): string {
const dyadRegex = /<dyad-[^>]*>[\s\S]*?<\/dyad-[^>]*>/g;
return text.replace(dyadRegex, "").trim();
}
export function hasUnclosedDyadWrite(text: string): boolean {
// Find the last opening dyad-write tag
const openRegex = /<dyad-write[^>]*>/g;
let lastOpenIndex = -1;
let match;
while ((match = openRegex.exec(text)) !== null) {
lastOpenIndex = match.index;
}
// If no opening tag found, there's nothing unclosed
if (lastOpenIndex === -1) {
return false;
}
// Look for a closing tag after the last opening tag
const textAfterLastOpen = text.substring(lastOpenIndex);
const hasClosingTag = /<\/dyad-write>/.test(textAfterLastOpen);
return !hasClosingTag;
}
......@@ -64,35 +64,7 @@ export default Index;
)
: lastMessage.content.includes("[dump]"))
) {
const timestamp = Date.now();
const generatedDir = path.join(__dirname, "generated");
// Create generated directory if it doesn't exist
if (!fs.existsSync(generatedDir)) {
fs.mkdirSync(generatedDir, { recursive: true });
}
const dumpFilePath = path.join(generatedDir, `${timestamp}.json`);
try {
fs.writeFileSync(
dumpFilePath,
JSON.stringify(
{
body: req.body,
headers: { authorization: req.headers["authorization"] },
},
null,
2,
).replace(/\r\n/g, "\n"),
"utf-8",
);
console.log(`* Dumped messages to: ${dumpFilePath}`);
messageContent = `[[dyad-dump-path=${dumpFilePath}]]`;
} catch (error) {
console.error(`* Error writing dump file: ${error}`);
messageContent = `Error: Could not write dump file: ${error}`;
}
messageContent = generateDump(req);
}
if (lastMessage && lastMessage.content === "[increment]") {
......@@ -133,6 +105,16 @@ export default Index;
}
}
if (
lastMessage &&
lastMessage.content &&
typeof lastMessage.content === "string" &&
lastMessage.content.trim().endsWith("[[STRING_TO_BE_FINISHED]]")
) {
messageContent = `[[STRING_IS_FINISHED]]";</dyad-write>\nFinished writing file.`;
messageContent += "\n\n" + generateDump(req);
}
// Non-streaming response
if (!stream) {
return res.json({
......@@ -183,3 +165,35 @@ export default Index;
}
}, 10);
};
function generateDump(req: Request) {
const timestamp = Date.now();
const generatedDir = path.join(__dirname, "generated");
// Create generated directory if it doesn't exist
if (!fs.existsSync(generatedDir)) {
fs.mkdirSync(generatedDir, { recursive: true });
}
const dumpFilePath = path.join(generatedDir, `${timestamp}.json`);
try {
fs.writeFileSync(
dumpFilePath,
JSON.stringify(
{
body: req.body,
headers: { authorization: req.headers["authorization"] },
},
null,
2,
).replace(/\r\n/g, "\n"),
"utf-8",
);
console.log(`* Dumped messages to: ${dumpFilePath}`);
return `[[dyad-dump-path=${dumpFilePath}]]`;
} catch (error) {
console.error(`* Error writing dump file: ${error}`);
return `Error: Could not write dump file: ${error}`;
}
}
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论