Parse proposal from latest chat message (w/o security parts)

This commit is contained in:
Will Chen
2025-04-18 11:29:31 -07:00
parent 4e0f93d21c
commit 4a158417df
7 changed files with 353 additions and 68 deletions

View File

@@ -0,0 +1 @@
ALTER TABLE `messages` ADD `approval_state` text;

View File

@@ -0,0 +1,199 @@
{
"version": "6",
"dialect": "sqlite",
"id": "0803dac6-46b8-4e22-8397-4840e614d6c9",
"prevId": "1a0ffcb3-606d-4b03-81b7-7c585555a548",
"tables": {
"apps": {
"name": "apps",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"path": {
"name": "path",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"github_org": {
"name": "github_org",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"github_repo": {
"name": "github_repo",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"chats": {
"name": "chats",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"app_id": {
"name": "app_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {},
"foreignKeys": {
"chats_app_id_apps_id_fk": {
"name": "chats_app_id_apps_id_fk",
"tableFrom": "chats",
"tableTo": "apps",
"columnsFrom": [
"app_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"messages": {
"name": "messages",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"chat_id": {
"name": "chat_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"content": {
"name": "content",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"approval_state": {
"name": "approval_state",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {},
"foreignKeys": {
"messages_chat_id_chats_id_fk": {
"name": "messages_chat_id_chats_id_fk",
"tableFrom": "messages",
"tableTo": "chats",
"columnsFrom": [
"chat_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -8,6 +8,13 @@
"when": 1744692127560, "when": 1744692127560,
"tag": "0000_nebulous_proemial_gods", "tag": "0000_nebulous_proemial_gods",
"breakpoints": true "breakpoints": true
},
{
"idx": 1,
"version": "6",
"when": 1744999922420,
"tag": "0001_hesitant_roland_deschain",
"breakpoints": true
} }
] ]
} }

View File

@@ -220,7 +220,7 @@ function ChatInputActions({
) : ( ) : (
<ChevronDown size={16} className="mr-1" /> <ChevronDown size={16} className="mr-1" />
)} )}
Review: {proposal.title} {proposal.title}
</button> </button>
{proposal.securityRisks.length > 0 && ( {proposal.securityRisks.length > 0 && (
<span className="bg-red-100 text-red-700 text-xs font-medium px-2 py-0.5 rounded-full"> <span className="bg-red-100 text-red-700 text-xs font-medium px-2 py-0.5 rounded-full">
@@ -269,6 +269,7 @@ function ChatInputActions({
{isDetailsVisible && ( {isDetailsVisible && (
<div className="p-3 border-t border-border bg-muted/50 text-sm"> <div className="p-3 border-t border-border bg-muted/50 text-sm">
{!!proposal.securityRisks.length && (
<div className="mb-3"> <div className="mb-3">
<h4 className="font-semibold mb-1">Security Risks</h4> <h4 className="font-semibold mb-1">Security Risks</h4>
<ul className="space-y-1"> <ul className="space-y-1">
@@ -293,6 +294,7 @@ function ChatInputActions({
))} ))}
</ul> </ul>
</div> </div>
)}
<div> <div>
<h4 className="font-semibold mb-1">Files Changed</h4> <h4 className="font-semibold mb-1">Files Changed</h4>

View File

@@ -35,7 +35,7 @@ export const messages = sqliteTable("messages", {
role: text("role", { enum: ["user", "assistant"] }).notNull(), role: text("role", { enum: ["user", "assistant"] }).notNull(),
content: text("content").notNull(), content: text("content").notNull(),
approvalState: text("approval_state", { approvalState: text("approval_state", {
enum: ["approved", "rejected", "pending"], enum: ["approved", "rejected"],
}), }),
createdAt: integer("created_at", { mode: "timestamp" }) createdAt: integer("created_at", { mode: "timestamp" })
.notNull() .notNull()

View File

@@ -1,43 +1,86 @@
import { ipcMain, type IpcMainInvokeEvent } from "electron"; import { ipcMain, type IpcMainInvokeEvent } from "electron";
import type { Proposal } from "@/lib/schemas"; import type { Proposal } from "@/lib/schemas";
import { db } from "../../db";
import { messages } from "../../db/schema";
import { desc, eq, and } from "drizzle-orm";
import path from "node:path"; // Import path for basename
// Import tag parsers
import {
getDyadChatSummaryTag,
getDyadWriteTags,
} from "../processors/response_processor";
// Placeholder Proposal data // Placeholder Proposal data (can be removed or kept for reference)
const placeholderProposal: Proposal = { // const placeholderProposal: Proposal = { ... };
title: "Review: Example Refactoring (from IPC)",
securityRisks: [ // Type guard for the parsed proposal structure
{ interface ParsedProposal {
type: "warning", title: string;
title: "Potential XSS Vulnerability", files: string[];
description: "User input is directly rendered without sanitization.", }
},
{ function isParsedProposal(obj: any): obj is ParsedProposal {
type: "danger", return (
title: "Hardcoded API Key", obj &&
description: "API key found in plain text in configuration file.", typeof obj === "object" &&
}, typeof obj.title === "string" &&
], Array.isArray(obj.files) &&
filesChanged: [ obj.files.every((file: any) => typeof file === "string")
{ );
name: "ChatInput.tsx", }
path: "src/components/chat/ChatInput.tsx",
summary: "Added review actions and details section.",
},
{
name: "api.ts",
path: "src/lib/api.ts",
summary: "Refactored API call structure.",
},
],
};
const getProposalHandler = async ( const getProposalHandler = async (
_event: IpcMainInvokeEvent, _event: IpcMainInvokeEvent,
{ chatId }: { chatId: number } { chatId }: { chatId: number }
): Promise<Proposal> => { ): Promise<Proposal | null> => {
console.log(`IPC: get-proposal called for chatId: ${chatId}`); console.log(`IPC: get-proposal called for chatId: ${chatId}`);
// Simulate async operation
await new Promise((resolve) => setTimeout(resolve, 500)); // 500ms delay try {
return placeholderProposal; // Find the latest ASSISTANT message for the chat
const latestAssistantMessage = await db.query.messages.findFirst({
where: and(eq(messages.chatId, chatId), eq(messages.role, "assistant")),
orderBy: [desc(messages.createdAt)],
columns: {
content: true, // Fetch the content to parse
},
});
if (latestAssistantMessage?.content) {
console.log("Found latest assistant message, parsing content...");
const messageContent = latestAssistantMessage.content;
// Parse tags directly from message content
const proposalTitle = getDyadChatSummaryTag(messageContent);
const proposalFiles = getDyadWriteTags(messageContent); // Gets { path: string, content: string }[]
// Check if we have enough information to create a proposal
if (proposalTitle || proposalFiles.length > 0) {
const proposal: Proposal = {
// Use parsed title or a default title if summary tag is missing but write tags exist
title: proposalTitle ?? "Proposed File Changes",
securityRisks: [], // Keep empty
filesChanged: proposalFiles.map((tag) => ({
name: path.basename(tag.path),
path: tag.path,
summary: tag.description ?? "(no change summary found)", // Generic summary
})),
};
console.log("Generated proposal on the fly:", proposal);
return proposal;
} else {
console.log(
"No relevant tags found in the latest assistant message content."
);
return null; // No proposal could be generated
}
} else {
console.log(`No assistant message found for chatId: ${chatId}`);
return null; // No message found
}
} catch (error) {
console.error(`Error processing proposal for chatId ${chatId}:`, error);
return null; // Indicate DB or processing error
}
}; };
// Function to register proposal-related handlers // Function to register proposal-related handlers

View File

@@ -11,20 +11,42 @@ import { getGitAuthor } from "../utils/git_author";
export function getDyadWriteTags(fullResponse: string): { export function getDyadWriteTags(fullResponse: string): {
path: string; path: string;
content: string; content: string;
description?: string;
}[] { }[] {
const dyadWriteRegex = const dyadWriteRegex = /<dyad-write([^>]*)>([\s\S]*?)<\/dyad-write>/gi;
/<dyad-write path="([^"]+)"[^>]*>([\s\S]*?)<\/dyad-write>/g; const pathRegex = /path="([^"]+)"/;
const descriptionRegex = /description="([^"]+)"/;
let match; let match;
const tags: { path: string; content: string }[] = []; const tags: { path: string; content: string; description?: string }[] = [];
while ((match = dyadWriteRegex.exec(fullResponse)) !== null) { while ((match = dyadWriteRegex.exec(fullResponse)) !== null) {
const content = match[2].trim().split("\n"); const attributesString = match[1];
if (content[0].startsWith("```")) { let content = match[2].trim();
content.shift();
const pathMatch = pathRegex.exec(attributesString);
const descriptionMatch = descriptionRegex.exec(attributesString);
if (pathMatch && pathMatch[1]) {
const path = pathMatch[1];
const description = descriptionMatch?.[1];
const contentLines = content.split("\n");
if (contentLines[0]?.startsWith("```")) {
contentLines.shift();
} }
if (content[content.length - 1].startsWith("```")) { if (contentLines[contentLines.length - 1]?.startsWith("```")) {
content.pop(); contentLines.pop();
}
content = contentLines.join("\n");
tags.push({ path, content, description });
} else {
console.warn(
"Found <dyad-write> tag without a valid 'path' attribute:",
match[0]
);
} }
tags.push({ path: match[1], content: content.join("\n") });
} }
return tags; return tags;
} }
@@ -65,6 +87,16 @@ export function getDyadAddDependencyTags(fullResponse: string): string[] {
return packages; return packages;
} }
export function getDyadChatSummaryTag(fullResponse: string): string | null {
const dyadChatSummaryRegex =
/<dyad-chat-summary>([\s\S]*?)<\/dyad-chat-summary>/g;
const match = dyadChatSummaryRegex.exec(fullResponse);
if (match && match[1]) {
return match[1].trim();
}
return null;
}
export async function processFullResponseActions( export async function processFullResponseActions(
fullResponse: string, fullResponse: string,
chatId: number, chatId: number,
@@ -206,6 +238,7 @@ export async function processFullResponseActions(
if (deletedFiles.length > 0) if (deletedFiles.length > 0)
changes.push(`deleted ${deletedFiles.length} file(s)`); changes.push(`deleted ${deletedFiles.length} file(s)`);
// Use chat summary, if provided, or default for commit message
await git.commit({ await git.commit({
fs, fs,
dir: appPath, dir: appPath,