Unverified 提交 26c65ed3 authored 作者: Mohamed Aziz Mejri's avatar Mohamed Aziz Mejri 提交者: GitHub

improving file attachement handling (#2757)

closes #2576 <!-- devin-review-badge-begin --> --- <a href="https://app.devin.ai/review/dyad-sh/dyad/pull/2757" target="_blank"> <picture> <source media="(prefers-color-scheme: dark)" srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1"> <img src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1" alt="Open with Devin"> </picture> </a> <!-- devin-review-badge-end --> --------- Co-authored-by: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
上级 374bebce
...@@ -105,9 +105,7 @@ test("attach image - chat - upload to codebase", async ({ po }) => { ...@@ -105,9 +105,7 @@ test("attach image - chat - upload to codebase", async ({ po }) => {
await po.sendPrompt("[[UPLOAD_IMAGE_TO_CODEBASE]]"); await po.sendPrompt("[[UPLOAD_IMAGE_TO_CODEBASE]]");
// Wait for the uploaded file card to render before snapshotting // Wait for the uploaded file card to render before snapshotting
await expect( await expect(po.page.getByText("file.png", { exact: true })).toBeVisible();
po.page.getByRole("button", { name: /file\.png/ }),
).toBeVisible();
await po.snapshotServerDump("last-message", { name: "upload-to-codebase" }); await po.snapshotServerDump("last-message", { name: "upload-to-codebase" });
await po.snapshotMessages({ replaceDumpPath: true }); await po.snapshotMessages({ replaceDumpPath: true });
......
...@@ -2,28 +2,27 @@ import type { LocalAgentFixture } from "../../../../testing/fake-llm-server/loca ...@@ -2,28 +2,27 @@ import type { LocalAgentFixture } from "../../../../testing/fake-llm-server/loca
/** /**
* Test fixture for file upload to codebase in local-agent mode. * Test fixture for file upload to codebase in local-agent mode.
* The AI receives a file upload ID (DYAD_ATTACHMENT_0) and uses the write_file tool * The AI receives a .dyad/media file path and uses the copy_file tool
* to write the uploaded file to the codebase. The file_upload_utils should resolve * to copy the uploaded file into the codebase.
* the attachment ID to the actual file content.
*/ */
export const fixture: LocalAgentFixture = { export const fixture: LocalAgentFixture = {
description: "Upload file to codebase using write_file tool", description: "Upload file to codebase using copy_file tool",
turns: [ turns: [
{ {
text: "I'll upload your file to the codebase.", text: "I'll upload your file to the codebase.",
toolCalls: [ toolCalls: [
{ {
name: "write_file", name: "copy_file",
args: { args: {
path: "assets/uploaded-file.png", from: "{{ATTACHMENT_PATH}}",
content: "DYAD_ATTACHMENT_0", to: "assets/uploaded-file.png",
description: "Upload file to codebase", description: "Copy uploaded file to codebase",
}, },
}, },
], ],
}, },
{ {
text: "I've successfully uploaded your file to assets/uploaded-file.png in the codebase.", text: "I've successfully copied your file to assets/uploaded-file.png in the codebase.",
}, },
], ],
}; };
...@@ -29,6 +29,16 @@ export function prettifyDump( ...@@ -29,6 +29,16 @@ export function prettifyDump(
.map((message) => { .map((message) => {
const content = Array.isArray(message.content) const content = Array.isArray(message.content)
? JSON.stringify(message.content) ? JSON.stringify(message.content)
// Normalize attachment paths (dynamic MD5 hashes in .dyad/media)
.replace(
/path: [^"]*?[/\\]{1,2}\.dyad[/\\]{1,2}media[/\\]{1,2}[a-f0-9]+\.\w+/g,
"path: [[ATTACHMENT_PATH]]",
)
// Also normalize .dyad/media paths in escaped attribute format
.replace(
/[/\\]{1,2}\.dyad[/\\]{1,2}media[/\\]{1,2}[a-f0-9]{6,}\.\w+/g,
"/.dyad/media/[[ATTACHMENT_HASH]]",
)
: message.content : message.content
.replace(BUILD_SYSTEM_PREFIX, "\n${BUILD_SYSTEM_PREFIX}") .replace(BUILD_SYSTEM_PREFIX, "\n${BUILD_SYSTEM_PREFIX}")
.replace(BUILD_SYSTEM_POSTFIX, "${BUILD_SYSTEM_POSTFIX}") .replace(BUILD_SYSTEM_POSTFIX, "${BUILD_SYSTEM_POSTFIX}")
......
...@@ -7,8 +7,8 @@ import { testSkipIfWindows } from "./helpers/test_helper"; ...@@ -7,8 +7,8 @@ import { testSkipIfWindows } from "./helpers/test_helper";
* Test for file upload to codebase in local-agent mode. * Test for file upload to codebase in local-agent mode.
* *
* This tests that when a file is uploaded with "upload to codebase" mode, * This tests that when a file is uploaded with "upload to codebase" mode,
* the local agent's write_file tool correctly resolves the file ID * the local agent's copy_file tool correctly copies the temp file
* (e.g., DYAD_ATTACHMENT_0) to the actual uploaded file content. * into the codebase at the destination path.
*/ */
testSkipIfWindows("local-agent - upload file to codebase", async ({ po }) => { testSkipIfWindows("local-agent - upload file to codebase", async ({ po }) => {
await po.setUpDyadPro({ localAgent: true }); await po.setUpDyadPro({ localAgent: true });
......
...@@ -18,17 +18,13 @@ ...@@ -18,17 +18,13 @@
- img - img
- text: wrote 1 file(s) - text: wrote 1 file(s)
- paragraph: "[[UPLOAD_IMAGE_TO_CODEBASE]]" - paragraph: "[[UPLOAD_IMAGE_TO_CODEBASE]]"
- paragraph: "Attachments:" - 'button "Expand image: logo.png"':
- paragraph: "File to upload to codebase: logo.png (file id: DYAD_ATTACHMENT_0)" - img "logo.png"
- paragraph: Uploading image to codebase - paragraph: Uploading image to codebase
- 'button "file.png new/image/file.png Edit Summary: Uploaded image to codebase"': - img
- img - text: file.png Copy
- text: "" - img
- button "Edit": - text: "Copied To: new/image/file.png Uploaded image to codebase"
- img
- text: ""
- img
- text: ""
- paragraph: "[[dyad-dump-path=*]]" - paragraph: "[[dyad-dump-path=*]]"
- button "Copy": - button "Copy":
- img - img
...@@ -39,7 +35,7 @@ ...@@ -39,7 +35,7 @@
- img - img
- text: less than a minute ago - text: less than a minute ago
- img - img
- text: wrote 1 file(s) - text: wrote 1 file(s) + extra files edited outside of Dyad
- button "Undo": - button "Undo":
- img - img
- text: "" - text: ""
......
- paragraph: basic - paragraph: basic
- button "file1.txt file1.txt Edit" - button "file1.txt file1.txt Edit":
- img
- text: ""
- button "Edit":
- img
- text: ""
- img
- paragraph: More EOM - paragraph: More EOM
- button "Copy":
- img
- img - img
- text: Approved - text: Approved
- img
- text: test-model
- img
- text: less than a minute ago
- img
- text: wrote 1 file(s)
- paragraph: "[dump]" - paragraph: "[dump]"
- paragraph: "Attachments:" - 'button "Expand image: logo.png"':
- list: - img "logo.png"
- listitem: logo.png (image/png)
- paragraph: "[[dyad-dump-path=*]]" - paragraph: "[[dyad-dump-path=*]]"
- button "Copy":
- img
- img - img
- text: Approved - text: Approved
- img
- text: test-model
- img
- text: less than a minute ago
- button "Undo":
- img
- text: ""
- button "Retry": - button "Retry":
- img - img
- text: ""
\ No newline at end of file
- paragraph: "[dump]" - paragraph: "[dump]"
- paragraph: "Attachments:" - 'button "Expand image: logo.png"':
- list: - img "logo.png"
- listitem: logo.png (image/png)
- paragraph: "[[dyad-dump-path=*]]" - paragraph: "[[dyad-dump-path=*]]"
- button "Copy":
- img
- img
- text: test-model
- img
- text: less than a minute ago
- button "Undo":
- img
- text: ""
- button "Retry": - button "Retry":
- img - img
- text: ""
\ No newline at end of file
- paragraph: basic - paragraph: basic
- button "file1.txt file1.txt Edit" - button "file1.txt file1.txt Edit":
- img
- text: ""
- button "Edit":
- img
- text: ""
- img
- paragraph: More EOM - paragraph: More EOM
- button "Copy":
- img
- img - img
- text: Approved - text: Approved
- img
- text: test-model
- img
- text: less than a minute ago
- img
- text: wrote 1 file(s)
- paragraph: "[dump]" - paragraph: "[dump]"
- paragraph: "Attachments:" - 'button "Expand image: logo.png"':
- list: - img "logo.png"
- listitem: logo.png (image/png)
- paragraph: "[[dyad-dump-path=*]]" - paragraph: "[[dyad-dump-path=*]]"
- button "Copy":
- img
- img - img
- text: Approved - text: Approved
- img
- text: test-model
- img
- text: less than a minute ago
- button "Undo":
- img
- text: ""
- button "Retry": - button "Retry":
- img - img
- text: ""
\ No newline at end of file
=== ===
role: user role: user
message: [{"type":"text","text":"[[UPLOAD_IMAGE_TO_CODEBASE]]\n\nAttachments:\n\n\nFile to upload to codebase: logo.png (file id: DYAD_ATTACHMENT_0)"},{"type":"image_url","image_url":{"url":"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABGdBTUEAALGPC/xhBQAAACBjSFJNAAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAAAhGVYSWZNTQAqAAAACAAFARIAAwAAAAEAAQAAARoABQAAAAEAAABKARsABQAAAAEAAABSASgAAwAAAAEAAgAAh2kABAAAAAEAAABaAAAAAAAAAEgAAAABAAAASAAAAAEAA6ABAAMAAAABAAEAAKACAAQAAAABAAAAEKADAAQAAAABAAAAEAAAAADHbxzxAAAACXBIWXMAAAsTAAALEwEAmpwYAAABWWlUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iWE1QIENvcmUgNi4wLjAiPgogICA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPgogICAgICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIgogICAgICAgICAgICB4bWxuczp0aWZmPSJodHRwOi8vbnMuYWRvYmUuY29tL3RpZmYvMS4wLyI+CiAgICAgICAgIDx0aWZmOk9yaWVudGF0aW9uPjE8L3RpZmY6T3JpZW50YXRpb24+CiAgICAgIDwvcmRmOkRlc2NyaXB0aW9uPgogICA8L3JkZjpSREY+CjwveDp4bXBtZXRhPgoZXuEHAAACbElEQVQ4EY1TTWgTQRR+M7NpQoKmPVTPQi/qoaDWQAsxIigqRS/JzYNHxVz0oAcrG6hQoV68ePNQECUpeBFBIqQiVBrbaqvgoXjRSzVJf8zfzu7MG2eWbGywFN8e3ux73/fNm/dmAP7TFCgSQHeurSC4t1eEAFET4+WjzPIq5AX5ZURMjO5GNEk7VbJKKWU9Or8WBg2cvPRxlLXiX7arVtFWNjVkzSX/VJBPK0YKRMIciI6477kjhsDrAxfJoebVUwd0bl1vBD0ChpzR5JkrKzGvjum2B8MtrphLRXGTY5RKCaioi7wr/lcgID+//Omsu4EzERg4GIIoWAygxrezwOvNhmiAoCrkCtZtqF9BPp33d86nl46jw173aWKtXWtzWi21kELDgzOo+mJCCRBIFIowBr3rOQK4bDJC90PVrf5Ayi/efDP62QBvn14Y5m0sKogOCoWy/nvL55syqOl4ppCRT6+9GxAKjgmtLYg3ldVkM4Hs0Fr4QSmxwi28T1gUHE+J9rpGdozqRvoWcmKWUMpyEXWH2IYJiq0KtQYr/qgRWc3T4lJ/IbNVx/xmmCrMXJ+ML3+wZP+Jmre5MN8/NVYoFKTB2XbJ+vYyPi9l/4hLq+XZpZMJ0BxzP3z1XGpO99qUDg897VFGEkd+3lm9lVy8e2NsceL7q/iqwvCIohIYU9MGm+pwuuOwbUVtm+D0uXIO5b57noxCWzJoSQJNIaAhm8BxMze7PGbboLFA/El0BYxqcJTJC+Vkg5PrLU4PO1KBg/jVo87jZ++Tb4PSDX5XMxdq14QOpvfI9XDMxTKPKQim9DqtY8H/Tv8HGFE+AZtzYdAAAAAASUVORK5CYII="}}] message: [{"type":"text","text":"[[UPLOAD_IMAGE_TO_CODEBASE]]\n\nAttachments:\n\n\nFile to upload to codebase: \"logo.png\" (path: [[ATTACHMENT_PATH]])\nUse the copy_file tool (or <dyad-copy> tag) to copy this file into the codebase at the appropriate location."},{"type":"image_url","image_url":{"url":"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABGdBTUEAALGPC/xhBQAAACBjSFJNAAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAAAhGVYSWZNTQAqAAAACAAFARIAAwAAAAEAAQAAARoABQAAAAEAAABKARsABQAAAAEAAABSASgAAwAAAAEAAgAAh2kABAAAAAEAAABaAAAAAAAAAEgAAAABAAAASAAAAAEAA6ABAAMAAAABAAEAAKACAAQAAAABAAAAEKADAAQAAAABAAAAEAAAAADHbxzxAAAACXBIWXMAAAsTAAALEwEAmpwYAAABWWlUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iWE1QIENvcmUgNi4wLjAiPgogICA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPgogICAgICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIgogICAgICAgICAgICB4bWxuczp0aWZmPSJodHRwOi8vbnMuYWRvYmUuY29tL3RpZmYvMS4wLyI+CiAgICAgICAgIDx0aWZmOk9yaWVudGF0aW9uPjE8L3RpZmY6T3JpZW50YXRpb24+CiAgICAgIDwvcmRmOkRlc2NyaXB0aW9uPgogICA8L3JkZjpSREY+CjwveDp4bXBtZXRhPgoZXuEHAAACbElEQVQ4EY1TTWgTQRR+M7NpQoKmPVTPQi/qoaDWQAsxIigqRS/JzYNHxVz0oAcrG6hQoV68ePNQECUpeBFBIqQiVBrbaqvgoXjRSzVJf8zfzu7MG2eWbGywFN8e3ux73/fNm/dmAP7TFCgSQHeurSC4t1eEAFET4+WjzPIq5AX5ZURMjO5GNEk7VbJKKWU9Or8WBg2cvPRxlLXiX7arVtFWNjVkzSX/VJBPK0YKRMIciI6477kjhsDrAxfJoebVUwd0bl1vBD0ChpzR5JkrKzGvjum2B8MtrphLRXGTY5RKCaioi7wr/lcgID+//Omsu4EzERg4GIIoWAygxrezwOvNhmiAoCrkCtZtqF9BPp33d86nl46jw173aWKtXWtzWi21kELDgzOo+mJCCRBIFIowBr3rOQK4bDJC90PVrf5Ayi/efDP62QBvn14Y5m0sKogOCoWy/nvL55syqOl4ppCRT6+9GxAKjgmtLYg3ldVkM4Hs0Fr4QSmxwi28T1gUHE+J9rpGdozqRvoWcmKWUMpyEXWH2IYJiq0KtQYr/qgRWc3T4lJ/IbNVx/xmmCrMXJ+ML3+wZP+Jmre5MN8/NVYoFKTB2XbJ+vYyPi9l/4hLq+XZpZMJ0BxzP3z1XGpO99qUDg897VFGEkd+3lm9lVy8e2NsceL7q/iqwvCIohIYU9MGm+pwuuOwbUVtm+D0uXIO5b57noxCWzJoSQJNIaAhm8BxMze7PGbboLFA/El0BYxqcJTJC+Vkg5PrLU4PO1KBg/jVo87jZ++Tb4PSDX5XMxdq14QOpvfI9XDMxTKPKQim9DqtY8H/Tv8HGFE+AZtzYdAAAAAASUVORK5CYII="}}]
\ No newline at end of file \ No newline at end of file
...@@ -125,6 +125,34 @@ ...@@ -125,6 +125,34 @@
"additionalProperties": false "additionalProperties": false
} }
}, },
{
"type": "function",
"name": "copy_file",
"description": "Copy a file from one location to another. Can copy uploaded attachment files (from .dyad/media) into the codebase, or copy files within the codebase.",
"parameters": {
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"from": {
"type": "string",
"description": "The source file path (can be a .dyad/media path or a path relative to the app root)"
},
"to": {
"type": "string",
"description": "The destination file path relative to the app root"
},
"description": {
"description": "Brief description of why the file is being copied",
"type": "string"
}
},
"required": [
"from",
"to"
],
"additionalProperties": false
}
},
{ {
"type": "function", "type": "function",
"name": "delete_file", "name": "delete_file",
......
...@@ -113,6 +113,36 @@ ...@@ -113,6 +113,36 @@
} }
} }
}, },
{
"type": "function",
"function": {
"name": "copy_file",
"description": "Copy a file from one location to another. Can copy uploaded attachment files (from .dyad/media) into the codebase, or copy files within the codebase.",
"parameters": {
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"from": {
"type": "string",
"description": "The source file path (can be a .dyad/media path or a path relative to the app root)"
},
"to": {
"type": "string",
"description": "The destination file path relative to the app root"
},
"description": {
"description": "Brief description of why the file is being copied",
"type": "string"
}
},
"required": [
"from",
"to"
],
"additionalProperties": false
}
}
},
{ {
"type": "function", "type": "function",
"function": { "function": {
......
...@@ -19,18 +19,14 @@ ...@@ -19,18 +19,14 @@
- img - img
- text: "" - text: ""
- paragraph: tc=local-agent/upload-to-codebase - paragraph: tc=local-agent/upload-to-codebase
- paragraph: "Attachments:" - 'button "Expand image: logo.png"':
- paragraph: "File to upload to codebase: logo.png (file id: DYAD_ATTACHMENT_0)" - img "logo.png"
- paragraph: I'll upload your file to the codebase. - paragraph: I'll upload your file to the codebase.
- 'button "uploaded-file.png assets/uploaded-file.png Edit Summary: Upload file to codebase"': - img
- img - text: uploaded-file.png Copy
- text: "" - img
- button "Edit": - text: "Copied To: assets/uploaded-file.png Copy uploaded file to codebase"
- img - paragraph: I've successfully copied your file to assets/uploaded-file.png in the codebase.
- text: ""
- img
- text: ""
- paragraph: I've successfully uploaded your file to assets/uploaded-file.png in the codebase.
- button "Copy": - button "Copy":
- img - img
- img - img
......
...@@ -3,6 +3,7 @@ import { ...@@ -3,6 +3,7 @@ import {
DyadMarkdownParser, DyadMarkdownParser,
VanillaMarkdownParser, VanillaMarkdownParser,
} from "./DyadMarkdownParser"; } from "./DyadMarkdownParser";
import { DyadAttachment, type AttachmentSize } from "./DyadAttachment";
import { useStreamChat } from "@/hooks/useStreamChat"; import { useStreamChat } from "@/hooks/useStreamChat";
import { StreamingLoadingAnimation } from "./StreamingLoadingAnimation"; import { StreamingLoadingAnimation } from "./StreamingLoadingAnimation";
import { import {
...@@ -26,6 +27,51 @@ import { ...@@ -26,6 +27,51 @@ import {
TooltipTrigger, TooltipTrigger,
TooltipContent, TooltipContent,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { unescapeXmlAttr } from "../../../shared/xmlEscape";
/** Extract <dyad-attachment> tags from message content and return parsed attachment data. */
function extractAttachments(content: string): {
name: string;
type: string;
url: string;
path: string;
attachmentType: string;
}[] {
const tagRegex = /<dyad-attachment\s+([^>]*)><\/dyad-attachment>/g;
const attrRegex = /([\w-]+)="([^"]*)"/g;
const results: {
name: string;
type: string;
url: string;
path: string;
attachmentType: string;
}[] = [];
let match;
while ((match = tagRegex.exec(content)) !== null) {
const attrs: Record<string, string> = {};
attrRegex.lastIndex = 0;
let attrMatch;
while ((attrMatch = attrRegex.exec(match[1])) !== null) {
attrs[attrMatch[1]] = unescapeXmlAttr(attrMatch[2]);
}
results.push({
name: attrs.name || "",
type: attrs.type || "",
url: attrs.url || "",
path: attrs.path || "",
attachmentType: attrs["attachment-type"] || "chat-context",
});
}
return results;
}
/** Strip <dyad-attachment> tags from user message content. */
function stripAttachmentInfo(content: string): string {
return content
.replace(/<dyad-attachment\s+[^>]*><\/dyad-attachment>/g, "")
.trim();
}
interface ChatMessageProps { interface ChatMessageProps {
message: Message; message: Message;
...@@ -83,11 +129,21 @@ const ChatMessage = ({ message, isLastMessage }: ChatMessageProps) => { ...@@ -83,11 +129,21 @@ const ChatMessage = ({ message, isLastMessage }: ChatMessageProps) => {
} }
}; };
const userTextContent =
message.role === "user" ? stripAttachmentInfo(message.content) : "";
const attachments =
message.role === "user" ? extractAttachments(message.content) : [];
const hasUserText = userTextContent.length > 0;
const attachmentSize: AttachmentSize =
attachments.length === 1 ? "lg" : attachments.length <= 3 ? "md" : "sm";
return ( return (
<div <div
className={`flex ${message.role === "assistant" ? "justify-start" : "justify-end"}`} className={`flex ${message.role === "assistant" ? "justify-start" : "justify-end"}`}
> >
<div className={`mt-2 w-full max-w-3xl mx-auto group`}> <div className={`mt-2 w-full max-w-3xl mx-auto group`}>
{/* Show message box for assistant messages or user messages with text */}
{(message.role === "assistant" || hasUserText) && (
<div <div
className={`rounded-lg p-2 ${ className={`rounded-lg p-2 ${
message.role === "assistant" ? "" : "ml-24 bg-(--sidebar-accent)" message.role === "assistant" ? "" : "ml-24 bg-(--sidebar-accent)"
...@@ -111,15 +167,19 @@ const ChatMessage = ({ message, isLastMessage }: ChatMessageProps) => { ...@@ -111,15 +167,19 @@ const ChatMessage = ({ message, isLastMessage }: ChatMessageProps) => {
)} )}
</> </>
) : ( ) : (
<VanillaMarkdownParser content={message.content} /> <VanillaMarkdownParser content={userTextContent} />
)} )}
</div> </div>
)} )}
{(message.role === "assistant" && message.content && !isStreaming) || {(message.role === "assistant" &&
message.content &&
!isStreaming) ||
message.approvalState ? ( message.approvalState ? (
<div <div
className={`mt-2 flex items-center ${ className={`mt-2 flex items-center ${
message.role === "assistant" && message.content && !isStreaming message.role === "assistant" &&
message.content &&
!isStreaming
? "justify-between" ? "justify-between"
: "" : ""
} text-xs`} } text-xs`}
...@@ -176,6 +236,27 @@ const ChatMessage = ({ message, isLastMessage }: ChatMessageProps) => { ...@@ -176,6 +236,27 @@ const ChatMessage = ({ message, isLastMessage }: ChatMessageProps) => {
</div> </div>
) : null} ) : null}
</div> </div>
)}
{/* Render attachments outside the message box */}
{attachments.length > 0 && (
<div className="mt-2 ml-24 flex flex-wrap gap-2 justify-end">
{attachments.map((att, i) => (
<DyadAttachment
key={i}
size={attachmentSize}
node={{
properties: {
name: att.name,
type: att.type,
url: att.url,
path: att.path,
attachmentType: att.attachmentType,
},
}}
/>
))}
</div>
)}
{/* Timestamp and commit info for assistant messages - only visible on hover */} {/* Timestamp and commit info for assistant messages - only visible on hover */}
{message.role === "assistant" && message.createdAt && ( {message.role === "assistant" && message.createdAt && (
<div className="mt-1 flex flex-wrap items-center justify-start space-x-2 text-xs text-gray-500 dark:text-gray-400 "> <div className="mt-1 flex flex-wrap items-center justify-start space-x-2 text-xs text-gray-500 dark:text-gray-400 ">
......
import type React from "react";
import { useEffect, useRef, useState } from "react";
import { FileText, Image, X, ExternalLink } from "lucide-react";
import { DyadCard, DyadCardHeader, DyadBadge } from "./DyadCardPrimitives";
import { ipc } from "@/ipc/types";
import { toast } from "sonner";
export type AttachmentSize = "sm" | "md" | "lg";
const SIZE_CLASSES: Record<AttachmentSize, string> = {
sm: "size-20",
md: "size-24",
lg: "size-40",
};
interface DyadAttachmentProps {
size?: AttachmentSize;
node?: {
properties?: {
name?: string;
type?: string;
url?: string;
path?: string;
attachmentType?: string;
};
};
}
async function openFile(filePath: string) {
if (filePath) {
try {
await ipc.system.openFilePath(filePath);
} catch {
toast.error("Could not open file. It may have been moved or deleted.");
}
}
}
export const DyadAttachment: React.FC<DyadAttachmentProps> = ({
node,
size = "md",
}) => {
const name = node?.properties?.name || "Untitled";
const type = node?.properties?.type || "";
const url = node?.properties?.url || "";
const filePath = node?.properties?.path || "";
const attachmentType = node?.properties?.attachmentType || "chat-context";
const isImage = type.startsWith("image/");
const accentColor =
attachmentType === "upload-to-codebase" ? "blue" : "green";
const [imageError, setImageError] = useState(false);
const [isExpanded, setIsExpanded] = useState(false);
// Reset error state when the image URL changes (e.g., new attachment rendered)
useEffect(() => {
setImageError(false);
}, [url]);
const closeButtonRef = useRef<HTMLButtonElement>(null);
// Lock body scroll and auto-focus close button when lightbox opens
useEffect(() => {
if (!isExpanded) return;
document.body.style.overflow = "hidden";
closeButtonRef.current?.focus();
return () => {
document.body.style.overflow = "";
};
}, [isExpanded]);
if (isImage && !imageError && url) {
return (
<>
<div
className={`relative ${SIZE_CLASSES[size]} rounded-lg overflow-hidden border border-border/60 cursor-pointer hover:brightness-90 transition-all`}
onClick={() => setIsExpanded(true)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setIsExpanded(true);
}
}}
role="button"
tabIndex={0}
aria-label={`Expand image: ${name}`}
title={name}
>
<img
src={url}
alt={name}
className="size-full object-cover"
onError={() => setImageError(true)}
/>
</div>
{isExpanded && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70"
onClick={() => setIsExpanded(false)}
onKeyDown={(e) => {
if (e.key === "Escape") {
setIsExpanded(false);
}
}}
role="dialog"
aria-modal="true"
aria-label={`Expanded image: ${name}`}
>
<div className="absolute top-4 right-4 flex items-center gap-2">
{filePath && (
<button
className="p-2 rounded-full bg-white/10 hover:bg-white/20 text-white cursor-pointer transition-colors"
onClick={(e) => {
e.stopPropagation();
openFile(filePath);
}}
title="Open file"
aria-label="Open file"
>
<ExternalLink size={20} />
</button>
)}
<button
ref={closeButtonRef}
className="p-2 rounded-full bg-white/10 hover:bg-white/20 text-white cursor-pointer transition-colors"
onClick={() => setIsExpanded(false)}
aria-label="Close"
>
<X size={20} />
</button>
</div>
<img
src={url}
alt={name}
className="max-w-[90vw] max-h-[90vh] object-contain rounded-lg"
onClick={(e) => e.stopPropagation()}
/>
</div>
)}
</>
);
}
// Non-image files or image load error fallback
return (
<DyadCard
accentColor={accentColor}
onClick={filePath ? () => openFile(filePath) : undefined}
>
<DyadCardHeader
icon={isImage ? <Image size={15} /> : <FileText size={15} />}
accentColor={accentColor}
>
<span className="font-medium text-sm text-foreground truncate">
{imageError ? "Image unavailable" : name}
</span>
<DyadBadge color={accentColor}>
{attachmentType === "upload-to-codebase" ? "Upload" : "Context"}
</DyadBadge>
{filePath && (
<ExternalLink
size={14}
className="ml-auto text-muted-foreground shrink-0"
aria-hidden
/>
)}
</DyadCardHeader>
</DyadCard>
);
};
import type React from "react";
import type { ReactNode } from "react";
import { Copy } from "lucide-react";
import {
DyadCard,
DyadCardHeader,
DyadBadge,
DyadFilePath,
DyadDescription,
DyadStateIndicator,
} from "./DyadCardPrimitives";
import { CustomTagState } from "./stateTypes";
interface DyadCopyProps {
children?: ReactNode;
node?: any;
}
export const DyadCopy: React.FC<DyadCopyProps> = ({ children, node }) => {
const from = node?.properties?.from || "";
const to = node?.properties?.to || "";
const description = node?.properties?.description || "";
const state = node?.properties?.state as CustomTagState;
const toFileName = to ? to.split("/").pop() : "";
// Hide the "From" line for temp attachment paths (absolute paths) since they
// show cryptic hash filenames that mean nothing to the user.
const isTempAttachment =
/^(\/|[A-Za-z]:\\)/.test(from) || from.includes(".dyad/media/");
return (
<DyadCard accentColor="teal" state={state}>
<DyadCardHeader icon={<Copy size={15} />} accentColor="teal">
{toFileName && (
<span className="font-medium text-sm text-foreground truncate">
{toFileName}
</span>
)}
<DyadBadge color="teal">Copy</DyadBadge>
<span className="ml-auto">
{state === "pending" && (
<DyadStateIndicator state="pending" pendingLabel="Copying..." />
)}
{state === "aborted" && (
<DyadStateIndicator state="aborted" abortedLabel="Did not finish" />
)}
{state === "finished" && (
<DyadStateIndicator state="finished" finishedLabel="Copied" />
)}
</span>
</DyadCardHeader>
{from && !isTempAttachment && <DyadFilePath path={`From: ${from}`} />}
{to && <DyadFilePath path={`To: ${to}`} />}
{description && <DyadDescription>{description}</DyadDescription>}
{children && <DyadDescription>{children}</DyadDescription>}
</DyadCard>
);
};
...@@ -4,6 +4,7 @@ import remarkGfm from "remark-gfm"; ...@@ -4,6 +4,7 @@ import remarkGfm from "remark-gfm";
import { DyadWrite } from "./DyadWrite"; import { DyadWrite } from "./DyadWrite";
import { DyadRename } from "./DyadRename"; import { DyadRename } from "./DyadRename";
import { DyadCopy } from "./DyadCopy";
import { DyadDelete } from "./DyadDelete"; import { DyadDelete } from "./DyadDelete";
import { DyadAddDependency } from "./DyadAddDependency"; import { DyadAddDependency } from "./DyadAddDependency";
import { DyadExecuteSql } from "./DyadExecuteSql"; import { DyadExecuteSql } from "./DyadExecuteSql";
...@@ -74,6 +75,7 @@ const DYAD_CUSTOM_TAGS = [ ...@@ -74,6 +75,7 @@ const DYAD_CUSTOM_TAGS = [
"dyad-supabase-project-info", "dyad-supabase-project-info",
"dyad-status", "dyad-status",
"dyad-compaction", "dyad-compaction",
"dyad-copy",
// Plan mode tags // Plan mode tags
"dyad-write-plan", "dyad-write-plan",
"dyad-exit-plan", "dyad-exit-plan",
...@@ -461,6 +463,22 @@ function renderCustomTag( ...@@ -461,6 +463,22 @@ function renderCustomTag(
</DyadRename> </DyadRename>
); );
case "dyad-copy":
return (
<DyadCopy
node={{
properties: {
from: attributes.from || "",
to: attributes.to || "",
description: attributes.description || "",
state: getState({ isStreaming, inProgress }),
},
}}
>
{content}
</DyadCopy>
);
case "dyad-delete": case "dyad-delete":
return ( return (
<DyadDelete <DyadDelete
......
...@@ -7,7 +7,7 @@ ...@@ -7,7 +7,7 @@
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import log from "electron-log"; import log from "electron-log";
import { ensureDyadGitignored } from "@/ipc/handlers/planUtils"; import { ensureDyadGitignored } from "@/ipc/handlers/gitignoreUtils";
const logger = log.scope("compaction_storage"); const logger = log.scope("compaction_storage");
......
import fs from "node:fs";
import path from "node:path";
/**
* Ensures the given entries are listed in the project's `.gitignore`.
* Creates `.gitignore` if it doesn't exist.
*/
async function ensureGitignored(
appPath: string,
entries: string[],
): Promise<void> {
const gitignorePath = path.join(appPath, ".gitignore");
let content = "";
try {
content = await fs.promises.readFile(gitignorePath, "utf-8");
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
// .gitignore doesn't exist yet — will be created below
}
const lines = content.split(/\r?\n/);
const missing = entries.filter(
(entry) =>
!lines.some(
(line) =>
line.trim() === entry || line.trim() === entry.replace(/\/$/, ""),
),
);
if (missing.length === 0) return;
const suffix = content.length > 0 && !content.endsWith("\n") ? "\n" : "";
await fs.promises.writeFile(
gitignorePath,
content + suffix + missing.map((e) => e + "\n").join(""),
"utf-8",
);
}
/**
* Ensures `.dyad/` is listed in the project's `.gitignore`.
* Creates `.gitignore` if it doesn't exist.
*/
export async function ensureDyadGitignored(appPath: string): Promise<void> {
await ensureGitignored(appPath, [".dyad/"]);
}
import fs from "node:fs";
import path from "node:path";
/**
* Ensures `.dyad/` is listed in the project's `.gitignore`.
* Creates `.gitignore` if it doesn't exist.
*/
export async function ensureDyadGitignored(appPath: string): Promise<void> {
const gitignorePath = path.join(appPath, ".gitignore");
let content = "";
try {
content = await fs.promises.readFile(gitignorePath, "utf-8");
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
// .gitignore doesn't exist yet — will be created below
}
// Check if .dyad or .dyad/ is already ignored
const lines = content.split(/\r?\n/);
const alreadyIgnored = lines.some(
(line) => line.trim() === ".dyad" || line.trim() === ".dyad/",
);
if (alreadyIgnored) return;
// Append .dyad/ to the end, ensuring a leading newline if file has content
const suffix = content.length > 0 && !content.endsWith("\n") ? "\n" : "";
await fs.promises.writeFile(
gitignorePath,
content + suffix + ".dyad/\n",
"utf-8",
);
}
export function slugify(text: string): string { export function slugify(text: string): string {
const result = text const result = text
.toLowerCase() .toLowerCase()
......
...@@ -13,8 +13,8 @@ import { ...@@ -13,8 +13,8 @@ import {
buildFrontmatter, buildFrontmatter,
validatePlanId, validatePlanId,
parsePlanFile, parsePlanFile,
ensureDyadGitignored,
} from "./planUtils"; } from "./planUtils";
import { ensureDyadGitignored } from "./gitignoreUtils";
const logger = log.scope("plan_handlers"); const logger = log.scope("plan_handlers");
......
import { shell } from "electron"; import { shell } from "electron";
import log from "electron-log"; import log from "electron-log";
import path from "node:path";
import { createLoggedHandler } from "./safe_handle"; import { createLoggedHandler } from "./safe_handle";
import { IS_TEST_BUILD } from "../utils/test_utils"; import { IS_TEST_BUILD } from "../utils/test_utils";
import { isFileWithinAnyDyadMediaDir } from "../utils/media_path_utils";
const logger = log.scope("shell_handlers"); const logger = log.scope("shell_handlers");
const handle = createLoggedHandler(logger); const handle = createLoggedHandler(logger);
// Only allow opening files with known safe media extensions via shell.openPath.
// This prevents execution of arbitrary executables even if they reside under a
// .dyad/media directory.
const ALLOWED_MEDIA_EXTENSIONS = new Set([
".png",
".jpg",
".jpeg",
".gif",
".webp",
".svg",
".bmp",
".ico",
".pdf",
".txt",
".md",
".csv",
".json",
".xml",
".mp3",
".mp4",
".wav",
".ogg",
".webm",
]);
export function registerShellHandlers() { export function registerShellHandlers() {
handle("open-external-url", async (_event, url: string) => { handle("open-external-url", async (_event, url: string) => {
if (!url) { if (!url) {
...@@ -32,4 +59,35 @@ export function registerShellHandlers() { ...@@ -32,4 +59,35 @@ export function registerShellHandlers() {
shell.showItemInFolder(fullPath); shell.showItemInFolder(fullPath);
logger.debug("Showed item in folder:", fullPath); logger.debug("Showed item in folder:", fullPath);
}); });
handle("open-file-path", async (_event, fullPath: string) => {
if (!fullPath) {
throw new Error("No file path provided.");
}
// Security: only allow opening files within .dyad/media subdirectories.
// The dyad-apps tree contains AI-generated code, so opening arbitrary files
// there via shell.openPath could execute malicious executables.
// App paths may be under the default dyad-apps base directory (normal) or
// at an external location (imported with skipCopy).
if (!isFileWithinAnyDyadMediaDir(fullPath)) {
throw new Error("Can only open files within .dyad/media directories.");
}
const resolvedPath = path.resolve(fullPath);
// Defense-in-depth: only allow known media file extensions
const ext = path.extname(resolvedPath).toLowerCase();
if (!ALLOWED_MEDIA_EXTENSIONS.has(ext)) {
throw new Error(
`File type '${ext}' is not allowed. Only media files can be opened.`,
);
}
const result = await shell.openPath(resolvedPath);
if (result) {
// shell.openPath returns an error string if it fails, empty string on success
throw new Error(`Failed to open file: ${result}`);
}
logger.debug("Opened file:", resolvedPath);
});
} }
...@@ -36,12 +36,11 @@ import { ...@@ -36,12 +36,11 @@ import {
getDyadAddDependencyTags, getDyadAddDependencyTags,
getDyadExecuteSqlTags, getDyadExecuteSqlTags,
getDyadSearchReplaceTags, getDyadSearchReplaceTags,
getDyadCopyTags,
} from "../utils/dyad_tag_parser"; } from "../utils/dyad_tag_parser";
import { applySearchReplace } from "../../pro/main/ipc/processors/search_replace_processor"; import { applySearchReplace } from "../../pro/main/ipc/processors/search_replace_processor";
import { storeDbTimestampAtCurrentVersion } from "../utils/neon_timestamp_utils"; import { storeDbTimestampAtCurrentVersion } from "../utils/neon_timestamp_utils";
import { executeCopyFile } from "../utils/copy_file_utils";
import { FileUploadsState } from "../utils/file_uploads_state";
const readFile = fs.promises.readFile; const readFile = fs.promises.readFile;
const logger = log.scope("response_processor"); const logger = log.scope("response_processor");
...@@ -110,9 +109,6 @@ export async function processFullResponseActions( ...@@ -110,9 +109,6 @@ export async function processFullResponseActions(
extraFiles?: string[]; extraFiles?: string[];
extraFilesError?: string; extraFilesError?: string;
}> { }> {
const fileUploadsState = FileUploadsState.getInstance();
const fileUploadsMap = fileUploadsState.getFileUploadsForChat(chatId);
fileUploadsState.clear(chatId);
logger.log("processFullResponseActions for chatId", chatId); logger.log("processFullResponseActions for chatId", chatId);
// Get the app associated with the chat // Get the app associated with the chat
const chatWithApp = await db.query.chats.findFirst({ const chatWithApp = await db.query.chats.findFirst({
...@@ -344,7 +340,11 @@ export async function processFullResponseActions( ...@@ -344,7 +340,11 @@ export async function processFullResponseActions(
} }
} }
// Deploy renamed function (skip if shared modules changed - will be handled later) // Deploy renamed function (skip if shared modules changed - will be handled later)
if (isServerFunction(tag.to) && !sharedModulesChanged) { if (
chatWithApp.app.supabaseProjectId &&
isServerFunction(tag.to) &&
!sharedModulesChanged
) {
try { try {
await deploySupabaseFunction({ await deploySupabaseFunction({
supabaseProjectId: chatWithApp.app.supabaseProjectId!, supabaseProjectId: chatWithApp.app.supabaseProjectId!,
...@@ -416,39 +416,48 @@ export async function processFullResponseActions( ...@@ -416,39 +416,48 @@ export async function processFullResponseActions(
} }
} }
// Process all file writes // Process all file copies
for (const tag of dyadWriteTags) { const dyadCopyTags = getDyadCopyTags(fullResponse);
const filePath = tag.path; for (const tag of dyadCopyTags) {
let content: string | Buffer = tag.content; try {
const fullFilePath = safeJoin(appPath, filePath); const result = await executeCopyFile({
from: tag.from,
to: tag.to,
appPath,
supabaseProjectId: chatWithApp.app.supabaseProjectId,
supabaseOrganizationSlug: chatWithApp.app.supabaseOrganizationSlug,
isSharedModulesChanged: sharedModulesChanged,
});
// Track if this is a shared module writtenFiles.push(tag.to);
if (isSharedServerModule(filePath)) {
if (result.sharedModuleChanged) {
sharedModulesChanged = true; sharedModulesChanged = true;
} }
// Check if content (stripped of whitespace) exactly matches a file ID and replace with actual file content if (result.deployError) {
if (fileUploadsMap) { errors.push({
const trimmedContent = tag.content.trim(); message: `Failed to deploy Supabase function after copy: ${tag.to}`,
const fileInfo = fileUploadsMap.get(trimmedContent); error: result.deployError,
if (fileInfo) { });
try { }
const fileContent = await readFile(fileInfo.filePath);
content = fileContent;
logger.log(
`Replaced file ID ${trimmedContent} with content from ${fileInfo.originalName}`,
);
} catch (error) { } catch (error) {
logger.error(
`Failed to read uploaded file ${fileInfo.originalName}:`,
error,
);
errors.push({ errors.push({
message: `Failed to read uploaded file: ${fileInfo.originalName}`, message: `Failed to copy ${tag.from} to ${tag.to}`,
error: error, error: error,
}); });
} }
} }
// Process all file writes
for (const tag of dyadWriteTags) {
const filePath = tag.path;
const content = tag.content;
const fullFilePath = safeJoin(appPath, filePath);
// Track if this is a shared module
if (isSharedServerModule(filePath)) {
sharedModulesChanged = true;
} }
// Ensure directory exists // Ensure directory exists
...@@ -461,6 +470,7 @@ export async function processFullResponseActions( ...@@ -461,6 +470,7 @@ export async function processFullResponseActions(
writtenFiles.push(filePath); writtenFiles.push(filePath);
// Deploy individual function (skip if shared modules changed - will be handled later) // Deploy individual function (skip if shared modules changed - will be handled later)
if ( if (
chatWithApp.app.supabaseProjectId &&
isServerFunction(filePath) && isServerFunction(filePath) &&
typeof content === "string" && typeof content === "string" &&
!sharedModulesChanged !sharedModulesChanged
......
...@@ -181,6 +181,12 @@ export const systemContracts = { ...@@ -181,6 +181,12 @@ export const systemContracts = {
output: z.void(), output: z.void(),
}), }),
openFilePath: defineContract({
channel: "open-file-path",
input: z.string(),
output: z.void(),
}),
// Session // Session
clearSessionData: defineContract({ clearSessionData: defineContract({
channel: "clear-session-data", channel: "clear-session-data",
......
import fs from "node:fs";
import path from "node:path";
import log from "electron-log";
import { safeJoin } from "./path_utils";
import { gitAdd } from "./git_utils";
import { isWithinDyadMediaDir } from "./media_path_utils";
import { deploySupabaseFunction } from "../../supabase_admin/supabase_management_client";
import {
isServerFunction,
isSharedServerModule,
extractFunctionNameFromPath,
} from "../../supabase_admin/supabase_utils";
const logger = log.scope("copy_file_utils");
export interface CopyFileResult {
/** Whether the destination is a shared server module */
sharedModuleChanged: boolean;
/** Error from Supabase function deployment, if any */
deployError?: unknown;
}
/**
* Copy a file within a Dyad app, with security validation, git staging,
* and optional Supabase function deployment.
*
* @throws Error if an absolute source path is outside the app's .dyad/media directory.
* Relative paths are resolved within the app root (consistent with write_file access).
* @throws Error if the source file does not exist
*/
export async function executeCopyFile({
from,
to,
appPath,
supabaseProjectId,
supabaseOrganizationSlug,
isSharedModulesChanged,
}: {
from: string;
to: string;
appPath: string;
supabaseProjectId?: string | null;
supabaseOrganizationSlug?: string | null;
isSharedModulesChanged?: boolean;
}): Promise<CopyFileResult> {
// Resolve the source path: allow both .dyad/media paths and app-relative paths
let fromFullPath: string;
if (path.isAbsolute(from)) {
// Security: only allow absolute paths within the app's .dyad/media directory
if (!isWithinDyadMediaDir(from, appPath)) {
throw new Error(
`Absolute source paths are only allowed within the .dyad/media directory`,
);
}
fromFullPath = path.resolve(from);
} else {
fromFullPath = safeJoin(appPath, from);
}
const toFullPath = safeJoin(appPath, to);
if (!fs.existsSync(fromFullPath)) {
throw new Error(`Source file does not exist: ${from}`);
}
// Track if this involves shared modules
const sharedModuleChanged = isSharedServerModule(to);
// Ensure destination directory exists
const dirPath = path.dirname(toFullPath);
fs.mkdirSync(dirPath, { recursive: true });
// Copy the file
fs.copyFileSync(fromFullPath, toFullPath);
logger.log(`Successfully copied file: ${fromFullPath} -> ${toFullPath}`);
// Add to git
await gitAdd({ path: appPath, filepath: to });
// Deploy Supabase function if applicable
const effectiveSharedModulesChanged =
isSharedModulesChanged || sharedModuleChanged;
let deployError: unknown;
if (
supabaseProjectId &&
isServerFunction(to) &&
!effectiveSharedModulesChanged
) {
try {
await deploySupabaseFunction({
supabaseProjectId,
functionName: extractFunctionNameFromPath(to),
appPath,
organizationSlug: supabaseOrganizationSlug ?? null,
});
} catch (error) {
logger.error("Failed to deploy Supabase function after copy:", error);
deployError = error;
}
}
return {
sharedModuleChanged,
deployError,
};
}
...@@ -67,6 +67,43 @@ export function getDyadRenameTags(fullResponse: string): { ...@@ -67,6 +67,43 @@ export function getDyadRenameTags(fullResponse: string): {
return tags; return tags;
} }
export function getDyadCopyTags(fullResponse: string): {
from: string;
to: string;
description?: string;
}[] {
const dyadCopyRegex = /<dyad-copy([^>]*?)(?:>([\s\S]*?)<\/dyad-copy>|\/>)/gi;
const fromRegex = /from="([^"]+)"/;
const toRegex = /to="([^"]+)"/;
const descriptionRegex = /description="([^"]+)"/;
let match;
const tags: { from: string; to: string; description?: string }[] = [];
while ((match = dyadCopyRegex.exec(fullResponse)) !== null) {
const attrs = match[1];
const fromMatch = fromRegex.exec(attrs);
const toMatch = toRegex.exec(attrs);
const descriptionMatch = descriptionRegex.exec(attrs);
if (fromMatch?.[1] && toMatch?.[1]) {
tags.push({
from: normalizePath(unescapeXmlAttr(fromMatch[1])),
to: normalizePath(unescapeXmlAttr(toMatch[1])),
description: descriptionMatch?.[1]
? unescapeXmlAttr(descriptionMatch[1])
: undefined,
});
} else {
logger.warn(
"Found <dyad-copy> tag without valid 'from' or 'to' attributes:",
match[0],
);
}
}
return tags;
}
export function getDyadDeleteTags(fullResponse: string): string[] { export function getDyadDeleteTags(fullResponse: string): string[] {
const dyadDeleteRegex = const dyadDeleteRegex =
/<dyad-delete path="([^"]+)"[^>]*>([\s\S]*?)<\/dyad-delete>/g; /<dyad-delete path="([^"]+)"[^>]*>([\s\S]*?)<\/dyad-delete>/g;
......
import log from "electron-log";
const logger = log.scope("file_uploads_state");
export interface FileUploadInfo {
filePath: string;
originalName: string;
}
export class FileUploadsState {
private static instance: FileUploadsState;
// Map of chatId -> (fileId -> fileInfo)
private uploadsByChat = new Map<number, Map<string, FileUploadInfo>>();
private constructor() {}
public static getInstance(): FileUploadsState {
if (!FileUploadsState.instance) {
FileUploadsState.instance = new FileUploadsState();
}
return FileUploadsState.instance;
}
/**
* Ensure a map exists for a chatId
*/
private ensureChat(chatId: number): Map<string, FileUploadInfo> {
let map = this.uploadsByChat.get(chatId);
if (!map) {
map = new Map<string, FileUploadInfo>();
this.uploadsByChat.set(chatId, map);
}
return map;
}
/**
* Add a file upload mapping to a specific chat
*/
public addFileUpload(
{ chatId, fileId }: { chatId: number; fileId: string },
fileInfo: FileUploadInfo,
): void {
const map = this.ensureChat(chatId);
map.set(fileId, fileInfo);
logger.log(
`Added file upload for chat ${chatId}: ${fileId} -> ${fileInfo.originalName}`,
);
}
/**
* Get a copy of the file uploads map for a specific chat
*/
public getFileUploadsForChat(chatId: number): Map<string, FileUploadInfo> {
const map = this.uploadsByChat.get(chatId);
return new Map(map ?? []);
}
// Removed getCurrentChatId(): no longer applicable in per-chat state
/**
* Clear state for a specific chat
*/
public clear(chatId: number): void {
this.uploadsByChat.delete(chatId);
logger.debug(`Cleared file uploads state for chat ${chatId}`);
}
/**
* Clear all uploads (primarily for tests or full reset)
*/
public clearAll(): void {
this.uploadsByChat.clear();
logger.debug("Cleared all file uploads state");
}
}
import path from "node:path";
/**
* The subdirectory within each app where uploaded media files are stored.
*/
export const DYAD_MEDIA_DIR_NAME = ".dyad/media";
/**
* Check if an absolute path falls within the app's .dyad/media directory.
* Used to validate that file copy operations only read from the allowed media dir.
*/
export function isWithinDyadMediaDir(
absPath: string,
appPath: string,
): boolean {
const resolved = path.resolve(absPath);
const resolvedMediaDir = path.resolve(
path.join(appPath, DYAD_MEDIA_DIR_NAME),
);
const relativePath = path.relative(resolvedMediaDir, resolved);
return !relativePath.startsWith("..") && !path.isAbsolute(relativePath);
}
/**
* Check if an absolute path is a file inside a .dyad/media directory
* (without requiring a known app path). Validates by finding consecutive
* ".dyad" + "media" path segments with at least one segment (filename) after,
* then confirms the resolved path doesn't escape via ".." traversal.
*/
export function isFileWithinAnyDyadMediaDir(absPath: string): boolean {
const resolved = path.resolve(absPath);
const segments = resolved.split(path.sep);
let mediaIdx = -1;
for (let i = 0; i < segments.length - 2; i++) {
if (segments[i] === ".dyad" && segments[i + 1] === "media") {
mediaIdx = i + 1;
break;
}
}
if (mediaIdx === -1) {
return false;
}
const mediaDirPath = segments.slice(0, mediaIdx + 1).join(path.sep);
const relativePath = path.relative(mediaDirPath, resolved);
return !relativePath.startsWith("..") && !path.isAbsolute(relativePath);
}
import { app, BrowserWindow, dialog, Menu } from "electron"; import { app, BrowserWindow, dialog, Menu, protocol, net } from "electron";
import * as path from "node:path"; import * as path from "node:path";
import { registerIpcHandlers } from "./ipc/ipc_host"; import { registerIpcHandlers } from "./ipc/ipc_host";
import dotenv from "dotenv"; import dotenv from "dotenv";
...@@ -31,7 +31,11 @@ import { ...@@ -31,7 +31,11 @@ import {
import { cleanupOldAiMessagesJson } from "./pro/main/ipc/handlers/local_agent/ai_messages_cleanup"; import { cleanupOldAiMessagesJson } from "./pro/main/ipc/handlers/local_agent/ai_messages_cleanup";
import fs from "fs"; import fs from "fs";
import { gitAddSafeDirectory } from "./ipc/utils/git_utils"; import { gitAddSafeDirectory } from "./ipc/utils/git_utils";
import { getDyadAppsBaseDirectory } from "./paths/paths"; import { getDyadAppsBaseDirectory, getDyadAppPath } from "./paths/paths";
import {
DYAD_MEDIA_DIR_NAME,
isWithinDyadMediaDir,
} from "./ipc/utils/media_path_utils";
log.errorHandler.startCatching(); log.errorHandler.startCatching();
log.eventLogger.startLogging(); log.eventLogger.startLogging();
...@@ -120,6 +124,45 @@ export async function onReady() { ...@@ -120,6 +124,45 @@ export async function onReady() {
// Start performance monitoring // Start performance monitoring
startPerformanceMonitoring(); startPerformanceMonitoring();
// Handle dyad-media:// protocol requests to serve persistent media files.
protocol.handle("dyad-media", async (request) => {
const url = new URL(request.url);
// Format: dyad-media://media/{app-path}/.dyad/media/{filename}
// Uses a fixed hostname to avoid URL hostname normalization (lowercasing).
// The app-path segment is URI-encoded, so split on "/" before decoding
// to correctly handle absolute paths (which contain encoded slashes).
const pathSegments = url.pathname.slice(1).split("/");
if (
pathSegments.length < 4 ||
pathSegments[1] !== ".dyad" ||
pathSegments[2] !== "media"
) {
return new Response("Forbidden", { status: 403 });
}
const appPathRaw = decodeURIComponent(pathSegments[0]);
const filename = decodeURIComponent(pathSegments.slice(3).join("/"));
// Resolve the app directory, handling both relative names and absolute
// paths from imported apps (skipCopy).
const appPath = getDyadAppPath(appPathRaw);
const mediaDir = path.resolve(path.join(appPath, DYAD_MEDIA_DIR_NAME));
const resolvedPath = path.resolve(path.join(mediaDir, filename));
// Security: ensure the resolved path stays within the app's .dyad/media directory
if (!isWithinDyadMediaDir(resolvedPath, appPath)) {
return new Response("Forbidden", { status: 403 });
}
try {
return await net.fetch(
require("node:url").pathToFileURL(resolvedPath).href,
);
} catch {
return new Response("Not Found", { status: 404 });
}
});
await onFirstRunMaybe(settings); await onFirstRunMaybe(settings);
createWindow(); createWindow();
createApplicationMenu(); createApplicationMenu();
...@@ -376,6 +419,20 @@ const createApplicationMenu = () => { ...@@ -376,6 +419,20 @@ const createApplicationMenu = () => {
Menu.setApplicationMenu(appMenu); Menu.setApplicationMenu(appMenu);
}; };
// Register dyad-media:// protocol for serving persistent media attachments.
// Must be called before app.whenReady().
protocol.registerSchemesAsPrivileged([
{
scheme: "dyad-media",
privileges: {
standard: true,
secure: true,
supportFetchAPI: true,
stream: true,
},
},
]);
// Skip singleton lock for E2E test builds to allow parallel test execution. // Skip singleton lock for E2E test builds to allow parallel test execution.
// Deep link handling still works via the 'open-url' event registered below. // Deep link handling still works via the 'open-url' event registered below.
// The 'second-instance' handler is intentionally omitted since it requires the singleton lock. // The 'second-instance' handler is intentionally omitted since it requires the singleton lock.
......
...@@ -9,6 +9,7 @@ import { readSettings, writeSettings } from "@/main/settings"; ...@@ -9,6 +9,7 @@ import { readSettings, writeSettings } from "@/main/settings";
import { writeFileTool } from "./tools/write_file"; import { writeFileTool } from "./tools/write_file";
import { deleteFileTool } from "./tools/delete_file"; import { deleteFileTool } from "./tools/delete_file";
import { renameFileTool } from "./tools/rename_file"; import { renameFileTool } from "./tools/rename_file";
import { copyFileTool } from "./tools/copy_file";
import { addDependencyTool } from "./tools/add_dependency"; import { addDependencyTool } from "./tools/add_dependency";
import { executeSqlTool } from "./tools/execute_sql"; import { executeSqlTool } from "./tools/execute_sql";
...@@ -47,6 +48,7 @@ export const TOOL_DEFINITIONS: readonly ToolDefinition[] = [ ...@@ -47,6 +48,7 @@ export const TOOL_DEFINITIONS: readonly ToolDefinition[] = [
writeFileTool, writeFileTool,
editFileTool, editFileTool,
searchReplaceTool, searchReplaceTool,
copyFileTool,
deleteFileTool, deleteFileTool,
renameFileTool, renameFileTool,
addDependencyTool, addDependencyTool,
......
import { z } from "zod";
import { ToolDefinition, AgentContext, escapeXmlAttr } from "./types";
import { executeCopyFile } from "@/ipc/utils/copy_file_utils";
const copyFileSchema = z.object({
from: z
.string()
.describe(
"The source file path (can be a .dyad/media path or a path relative to the app root)",
),
to: z.string().describe("The destination file path relative to the app root"),
description: z
.string()
.optional()
.describe("Brief description of why the file is being copied"),
});
export const copyFileTool: ToolDefinition<z.infer<typeof copyFileSchema>> = {
name: "copy_file",
description:
"Copy a file from one location to another. Can copy uploaded attachment files (from .dyad/media) into the codebase, or copy files within the codebase.",
inputSchema: copyFileSchema,
defaultConsent: "always",
modifiesState: true,
getConsentPreview: (args) => `Copy ${args.from} to ${args.to}`,
buildXml: (args, _isComplete) => {
if (!args.from || !args.to) return undefined;
return `<dyad-copy from="${escapeXmlAttr(args.from)}" to="${escapeXmlAttr(args.to)}" description="${escapeXmlAttr(args.description ?? "")}"></dyad-copy>`;
},
execute: async (args, ctx: AgentContext) => {
const result = await executeCopyFile({
from: args.from,
to: args.to,
appPath: ctx.appPath,
supabaseProjectId: ctx.supabaseProjectId,
supabaseOrganizationSlug: ctx.supabaseOrganizationSlug,
isSharedModulesChanged: ctx.isSharedModulesChanged,
});
if (result.sharedModuleChanged) {
ctx.isSharedModulesChanged = true;
}
if (result.deployError) {
return `File copied, but failed to deploy Supabase function: ${result.deployError}`;
}
return `Successfully copied ${args.from} to ${args.to}`;
},
};
/**
* Shared utility for resolving file upload IDs to actual file content.
* When users upload files with "upload-to-codebase" attachment type,
* the LLM receives a file ID (e.g., DYAD_ATTACHMENT_0) instead of the raw content.
* This utility replaces those IDs with the actual file content when writing files.
*/
import fs from "node:fs";
import log from "electron-log";
import {
FileUploadsState,
FileUploadInfo,
} from "@/ipc/utils/file_uploads_state";
const readFile = fs.promises.readFile;
const logger = log.scope("file_upload_utils");
export interface ResolveFileUploadResult {
/** The resolved content (either the original content or the uploaded file's content) */
content: string | Buffer;
/** Whether a file upload ID was replaced */
wasReplaced: boolean;
/** Info about the file that was replaced (if any) */
fileInfo?: FileUploadInfo;
}
/**
* Resolves file upload IDs in content to actual file content.
*
* If the content (stripped of whitespace) exactly matches a file upload ID
* (e.g., "DYAD_ATTACHMENT_0"), this function reads the actual uploaded file
* and returns its content. Otherwise, returns the original content unchanged.
*
* @param content - The content to check for file upload IDs
* @param chatId - The chat ID to look up file uploads for
* @returns The resolved content and metadata about the replacement
*/
export async function resolveFileUploadContent(
content: string,
chatId: number,
): Promise<ResolveFileUploadResult> {
const fileUploadsState = FileUploadsState.getInstance();
const fileUploadsMap = fileUploadsState.getFileUploadsForChat(chatId);
if (fileUploadsMap.size === 0) {
return { content, wasReplaced: false };
}
const trimmedContent = content.trim();
const fileInfo = fileUploadsMap.get(trimmedContent);
if (!fileInfo) {
return { content, wasReplaced: false };
}
try {
const fileContent = await readFile(fileInfo.filePath);
logger.log(
`Replaced file ID ${trimmedContent} with content from ${fileInfo.originalName}`,
);
return {
content: fileContent,
wasReplaced: true,
fileInfo,
};
} catch (error) {
logger.error(
`Failed to read uploaded file ${fileInfo.originalName}:`,
error,
);
throw new Error(
`Failed to read uploaded file: ${fileInfo.originalName}. ${error}`,
);
}
}
...@@ -9,8 +9,6 @@ import { ...@@ -9,8 +9,6 @@ import {
isServerFunction, isServerFunction,
isSharedServerModule, isSharedServerModule,
} from "../../../../../../supabase_admin/supabase_utils"; } from "../../../../../../supabase_admin/supabase_utils";
import { resolveFileUploadContent } from "./file_upload_utils";
const logger = log.scope("write_file"); const logger = log.scope("write_file");
const writeFileSchema = z.object({ const writeFileSchema = z.object({
...@@ -49,16 +47,12 @@ export const writeFileTool: ToolDefinition<z.infer<typeof writeFileSchema>> = { ...@@ -49,16 +47,12 @@ export const writeFileTool: ToolDefinition<z.infer<typeof writeFileSchema>> = {
ctx.isSharedModulesChanged = true; ctx.isSharedModulesChanged = true;
} }
// Resolve file upload IDs to actual content
const resolved = await resolveFileUploadContent(args.content, ctx.chatId);
const contentToWrite = resolved.content;
// Ensure directory exists // Ensure directory exists
const dirPath = path.dirname(fullFilePath); const dirPath = path.dirname(fullFilePath);
fs.mkdirSync(dirPath, { recursive: true }); fs.mkdirSync(dirPath, { recursive: true });
// Write file content // Write file content
fs.writeFileSync(fullFilePath, contentToWrite); fs.writeFileSync(fullFilePath, args.content);
logger.log(`Successfully wrote file: ${fullFilePath}`); logger.log(`Successfully wrote file: ${fullFilePath}`);
// Deploy Supabase function if applicable // Deploy Supabase function if applicable
......
...@@ -92,10 +92,11 @@ export const createChatCompletionHandler = ...@@ -92,10 +92,11 @@ export const createChatCompletionHandler =
// Check for upload image to codebase using lastUserMessage (which already handles both string and array content) // Check for upload image to codebase using lastUserMessage (which already handles both string and array content)
if (userTextContent.includes("[[UPLOAD_IMAGE_TO_CODEBASE]]")) { if (userTextContent.includes("[[UPLOAD_IMAGE_TO_CODEBASE]]")) {
// Extract the attachment path from the user message (format: "path: /path/to/app/.dyad/media/...")
const pathMatch = userTextContent.match(/\(path: ([^\s)]+)\)/);
const attachmentPath = pathMatch?.[1] ?? ".dyad/media/unknown.png";
messageContent = `Uploading image to codebase messageContent = `Uploading image to codebase
<dyad-write path="new/image/file.png" description="Uploaded image to codebase"> <dyad-copy from="${attachmentPath}" to="new/image/file.png" description="Uploaded image to codebase"></dyad-copy>
DYAD_ATTACHMENT_0
</dyad-write>
`; `;
messageContent += "\n\n" + generateDump(req); messageContent += "\n\n" + generateDump(req);
} }
......
...@@ -91,6 +91,27 @@ function countToolResultRounds(messages: any[]): number { ...@@ -91,6 +91,27 @@ function countToolResultRounds(messages: any[]): number {
return rounds; return rounds;
} }
/**
* Extract the attachment path from the last user message.
* The user message format includes: "path: /path/to/app/.dyad/media/hash.png"
*/
function extractAttachmentPath(messages: any[]): string | null {
// Search from the end to find the most recent user message with an attachment path
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i];
if (msg?.role !== "user") continue;
const text = Array.isArray(msg.content)
? msg.content.find((p: any) => p.type === "text")?.text
: typeof msg.content === "string"
? msg.content
: null;
if (!text) continue;
const match = text.match(/\(path: ([^\s)]+)\)/);
if (match) return match[1];
}
return null;
}
/** /**
* Load a fixture file dynamically * Load a fixture file dynamically
* Tries .ts first (for dev mode with ts-node), then .js * Tries .ts first (for dev mode with ts-node), then .js
...@@ -363,7 +384,7 @@ export async function handleLocalAgentFixture( ...@@ -363,7 +384,7 @@ export async function handleLocalAgentFixture(
return; return;
} }
const turn = turns[turnIndex]; let turn = turns[turnIndex];
console.log( console.log(
`[local-agent] Executing pass ${passIndex}, turn ${turnIndex}:`, `[local-agent] Executing pass ${passIndex}, turn ${turnIndex}:`,
{ {
...@@ -372,6 +393,26 @@ export async function handleLocalAgentFixture( ...@@ -372,6 +393,26 @@ export async function handleLocalAgentFixture(
}, },
); );
// Replace {{ATTACHMENT_PATH}} placeholders in tool call args
// with the actual path extracted from the user message
if (turn.toolCalls) {
const attachmentPath = extractAttachmentPath(messages);
if (attachmentPath) {
turn = {
...turn,
toolCalls: turn.toolCalls.map((tc) => ({
...tc,
args: JSON.parse(
JSON.stringify(tc.args).replace(
/\{\{ATTACHMENT_PATH\}\}/g,
JSON.stringify(attachmentPath).slice(1, -1),
),
),
})),
};
}
}
// If this turn has tool calls, stream them // If this turn has tool calls, stream them
if (turn.toolCalls && turn.toolCalls.length > 0) { if (turn.toolCalls && turn.toolCalls.length > 0) {
await streamToolCallResponse(res, turn); await streamToolCallResponse(res, turn);
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论