From 19d1e89029bf056ab192cf4c9df3a58ad3f75515 Mon Sep 17 00:00:00 2001 From: Will Chen Date: Tue, 29 Apr 2025 15:36:32 -0700 Subject: [PATCH] Create Upload Chat Session help feature (#48) --- src/components/HelpDialog.tsx | 312 +++++++++++++++++++++++++++- src/ipc/handlers/debug_handlers.ts | 194 +++++++++++------ src/ipc/handlers/upload_handlers.ts | 59 ++++++ src/ipc/ipc_client.ts | 31 ++- src/ipc/ipc_host.ts | 2 + src/ipc/ipc_types.ts | 7 + src/preload.ts | 2 + 7 files changed, 536 insertions(+), 71 deletions(-) create mode 100644 src/ipc/handlers/upload_handlers.ts diff --git a/src/components/HelpDialog.tsx b/src/components/HelpDialog.tsx index 4cc70aa..aa4f31a 100644 --- a/src/components/HelpDialog.tsx +++ b/src/components/HelpDialog.tsx @@ -7,9 +7,21 @@ import { DialogFooter, } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; -import { BookOpenIcon, BugIcon } from "lucide-react"; +import { + BookOpenIcon, + BugIcon, + UploadIcon, + ChevronLeftIcon, + CheckIcon, + XIcon, + FileIcon, +} from "lucide-react"; import { IpcClient } from "@/ipc/ipc_client"; -import { useState } from "react"; +import { useState, useEffect } from "react"; +import { useAtomValue } from "jotai"; +import { selectedChatIdAtom } from "@/atoms/chatAtoms"; +import { ChatLogsData } from "@/ipc/ipc_types"; +import { showError } from "@/lib/toast"; interface HelpDialogProps { isOpen: boolean; @@ -18,6 +30,34 @@ interface HelpDialogProps { export function HelpDialog({ isOpen, onClose }: HelpDialogProps) { const [isLoading, setIsLoading] = useState(false); + const [isUploading, setIsUploading] = useState(false); + const [reviewMode, setReviewMode] = useState(false); + const [chatLogsData, setChatLogsData] = useState(null); + const [uploadComplete, setUploadComplete] = useState(false); + const [sessionId, setSessionId] = useState(""); + const selectedChatId = useAtomValue(selectedChatIdAtom); + + // Function to reset all dialog state + const resetDialogState = () => { + setIsLoading(false); + setIsUploading(false); + setReviewMode(false); + setChatLogsData(null); + setUploadComplete(false); + setSessionId(""); + }; + + // Reset state when dialog closes or reopens + useEffect(() => { + if (!isOpen) { + resetDialogState(); + } + }, [isOpen]); + + // Wrap the original onClose to also reset state + const handleClose = () => { + onClose(); + }; const handleReportBug = async () => { setIsLoading(true); @@ -71,15 +111,261 @@ ${debugInfo.logs.slice(-3_500) || "No logs available"} } }; + const handleUploadChatSession = async () => { + if (!selectedChatId) { + alert("Please select a chat first"); + return; + } + + setIsUploading(true); + try { + // Get chat logs (includes debug info, chat data, and codebase) + const chatLogs = await IpcClient.getInstance().getChatLogs( + selectedChatId + ); + + // Store data for review and switch to review mode + setChatLogsData(chatLogs); + setReviewMode(true); + } catch (error) { + console.error("Failed to upload chat session:", error); + alert( + "Failed to upload chat session. Please try again or report manually." + ); + } finally { + setIsUploading(false); + } + }; + + const handleSubmitChatLogs = async () => { + if (!chatLogsData) return; + + setIsUploading(true); + try { + // Prepare data for upload + const chatLogsJson = { + systemInfo: chatLogsData.debugInfo, + chat: chatLogsData.chat, + codebaseSnippet: chatLogsData.codebase, + }; + + // Get signed URL + const response = await fetch( + "https://upload-logs.dyad.sh/generate-upload-url", + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + extension: "json", + contentType: "application/json", + }), + } + ); + + if (!response.ok) { + showError(`Failed to get upload URL: ${response.statusText}`); + throw new Error(`Failed to get upload URL: ${response.statusText}`); + } + + const { uploadUrl, filename } = await response.json(); + + // Upload to the signed URL using IPC + const uploadResult = await IpcClient.getInstance().uploadToSignedUrl( + uploadUrl, + "application/json", + chatLogsJson + ); + + if (!uploadResult.success) { + throw new Error(`Failed to upload logs: ${uploadResult.error}`); + } + + // Extract session ID (filename without extension) + const sessionId = filename.replace(".json", ""); + setSessionId(sessionId); + setUploadComplete(true); + setReviewMode(false); + } catch (error) { + console.error("Failed to upload chat logs:", error); + alert("Failed to upload chat logs. Please try again."); + } finally { + setIsUploading(false); + } + }; + + const handleCancelReview = () => { + setReviewMode(false); + setChatLogsData(null); + }; + + const handleOpenGitHubIssue = () => { + // Create a GitHub issue with the session ID + const issueBody = ` +## Support Request +Session ID: ${sessionId} + +## Issue Description + + +## Expected Behavior + + +## Actual Behavior + +`; + + const encodedBody = encodeURIComponent(issueBody); + const encodedTitle = encodeURIComponent("[session report] "); + const githubIssueUrl = `https://github.com/dyad-sh/dyad/issues/new?title=${encodedTitle}&labels=support&body=${encodedBody}`; + + IpcClient.getInstance().openExternalUrl(githubIssueUrl); + handleClose(); + }; + + if (uploadComplete) { + return ( + + + + Upload Complete + +
+
+ +
+

+ Chat Logs Uploaded Successfully +

+
+ { + try { + await navigator.clipboard.writeText(sessionId); + } catch (err) { + console.error("Failed to copy session ID:", err); + } + }} + /> + {sessionId} +
+

+ Please open a GitHub issue so we can follow-up with you on this + issue. +

+
+ + + +
+
+ ); + } + + if (reviewMode && chatLogsData) { + return ( + + + + + + OK to upload chat session? + + + + Please review the information that will be submitted. Your chat + messages, system information, and a snapshot of your codebase will + be included. + + +
+
+

Chat Messages

+
+ {chatLogsData.chat.messages.map((msg, index) => ( +
+ + {msg.role === "user" ? "You" : "Assistant"}:{" "} + + {msg.content} +
+ ))} +
+
+ +
+

Codebase Snapshot

+
+ {chatLogsData.codebase} +
+
+ +
+

Logs

+
+ {chatLogsData.debugInfo.logs} +
+
+ +
+

System Information

+
+

Dyad Version: {chatLogsData.debugInfo.dyadVersion}

+

Platform: {chatLogsData.debugInfo.platform}

+

Architecture: {chatLogsData.debugInfo.architecture}

+

+ Node Version:{" "} + {chatLogsData.debugInfo.nodeVersion || "Not available"} +

+
+
+
+ +
+ + +
+
+
+ ); + } + return ( - + Need help with Dyad? - If you need assistance or want to report an issue, here are some - resources: + If you need help or want to report an issue, here are some options:
@@ -98,6 +384,7 @@ ${debugInfo.logs.slice(-3_500) || "No logs available"} Get help with common questions and issues.

+
+
+ +

+ Share chat logs and code for troubleshooting. Data is used only to + resolve your issue and auto-deleted after a limited time. +

+
diff --git a/src/ipc/handlers/debug_handlers.ts b/src/ipc/handlers/debug_handlers.ts index 10bcdd6..e58c9a4 100644 --- a/src/ipc/handlers/debug_handlers.ts +++ b/src/ipc/handlers/debug_handlers.ts @@ -1,92 +1,156 @@ import { ipcMain, app } from "electron"; import { platform, arch } from "os"; -import { SystemDebugInfo } from "../ipc_types"; +import { SystemDebugInfo, ChatLogsData } from "../ipc_types"; import { readSettings } from "../../main/settings"; import { execSync } from "child_process"; import log from "electron-log"; import path from "path"; import fs from "fs"; import { runShellCommand } from "../utils/runShellCommand"; +import { extractCodebase } from "../../utils/codebase"; +import { db } from "../../db"; +import { chats, apps } from "../../db/schema"; +import { eq } from "drizzle-orm"; +import { getDyadAppPath } from "../../paths/paths"; + +// Shared function to get system debug info +async function getSystemDebugInfo(): Promise { + console.log("Getting system debug info"); + + // Get Node.js and pnpm versions + let nodeVersion: string | null = null; + let pnpmVersion: string | null = null; + let nodePath: string | null = null; + try { + nodeVersion = await runShellCommand("node --version"); + } catch (err) { + console.error("Failed to get Node.js version:", err); + } + + try { + pnpmVersion = await runShellCommand("pnpm --version"); + } catch (err) { + console.error("Failed to get pnpm version:", err); + } + + try { + if (platform() === "win32") { + nodePath = await runShellCommand("where.exe node"); + } else { + nodePath = await runShellCommand("which node"); + } + } catch (err) { + console.error("Failed to get node path:", err); + } + + // Get Dyad version from package.json + const packageJsonPath = path.resolve(__dirname, "..", "..", "package.json"); + let dyadVersion = "unknown"; + try { + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")); + dyadVersion = packageJson.version; + } catch (err) { + console.error("Failed to read package.json:", err); + } + + // Get telemetry info from settings + const settings = readSettings(); + const telemetryId = settings.telemetryUserId || "unknown"; + + // Get logs from electron-log + let logs = ""; + try { + const logPath = log.transports.file.getFile().path; + if (fs.existsSync(logPath)) { + const logContent = fs.readFileSync(logPath, "utf8"); + const logLines = logContent.split("\n"); + logs = logLines.slice(-100).join("\n"); + } + } catch (err) { + console.error("Failed to read log file:", err); + logs = `Error reading logs: ${err}`; + } + + return { + nodeVersion, + pnpmVersion, + nodePath, + telemetryId, + telemetryConsent: settings.telemetryConsent || "unknown", + telemetryUrl: "https://us.i.posthog.com", // Hardcoded from renderer.tsx + dyadVersion, + platform: process.platform, + architecture: arch(), + logs, + }; +} export function registerDebugHandlers() { ipcMain.handle( "get-system-debug-info", async (): Promise => { console.log("IPC: get-system-debug-info called"); + return getSystemDebugInfo(); + } + ); - // Get Node.js and pnpm versions - let nodeVersion: string | null = null; - let pnpmVersion: string | null = null; - let nodePath: string | null = null; - try { - nodeVersion = await runShellCommand("node --version"); - } catch (err) { - console.error("Failed to get Node.js version:", err); - } + ipcMain.handle( + "get-chat-logs", + async (_, chatId: number): Promise => { + console.log(`IPC: get-chat-logs called for chat ${chatId}`); try { - pnpmVersion = await runShellCommand("pnpm --version"); - } catch (err) { - console.error("Failed to get pnpm version:", err); - } + // Get system debug info using the shared function + const debugInfo = await getSystemDebugInfo(); - try { - if (platform() === "win32") { - nodePath = await runShellCommand("where.exe node"); - } else { - nodePath = await runShellCommand("which node"); + // Get chat data from database + const chatRecord = await db.query.chats.findFirst({ + where: eq(chats.id, chatId), + with: { + messages: { + orderBy: (messages, { asc }) => [asc(messages.createdAt)], + }, + }, + }); + + if (!chatRecord) { + throw new Error(`Chat with ID ${chatId} not found`); } - } catch (err) { - console.error("Failed to get node path:", err); - } - // Get Dyad version from package.json - const packageJsonPath = path.resolve( - __dirname, - "..", - "..", - "package.json" - ); - let dyadVersion = "unknown"; - try { - const packageJson = JSON.parse( - fs.readFileSync(packageJsonPath, "utf8") - ); - dyadVersion = packageJson.version; - } catch (err) { - console.error("Failed to read package.json:", err); - } + // Format the chat to match the Chat interface + const chat = { + id: chatRecord.id, + title: chatRecord.title || "Untitled Chat", + messages: chatRecord.messages.map((msg) => ({ + id: msg.id, + role: msg.role, + content: msg.content, + approvalState: msg.approvalState, + })), + }; - // Get telemetry info from settings - const settings = readSettings(); - const telemetryId = settings.telemetryUserId || "unknown"; + // Get app data from database + const app = await db.query.apps.findFirst({ + where: eq(apps.id, chatRecord.appId), + }); - // Get logs from electron-log - let logs = ""; - try { - const logPath = log.transports.file.getFile().path; - if (fs.existsSync(logPath)) { - const logContent = fs.readFileSync(logPath, "utf8"); - const logLines = logContent.split("\n"); - logs = logLines.slice(-100).join("\n"); + if (!app) { + throw new Error(`App with ID ${chatRecord.appId} not found`); } - } catch (err) { - console.error("Failed to read log file:", err); - logs = `Error reading logs: ${err}`; - } - return { - nodeVersion, - pnpmVersion, - nodePath, - telemetryId, - telemetryConsent: settings.telemetryConsent || "unknown", - telemetryUrl: "https://us.i.posthog.com", // Hardcoded from renderer.tsx - dyadVersion, - platform: process.platform, - architecture: arch(), - logs, - }; + // Extract codebase + const appPath = getDyadAppPath(app.path); + const codebase = await extractCodebase(appPath); + + return { + debugInfo, + chat, + codebase, + }; + } catch (error) { + console.error(`Error in get-chat-logs:`, error); + throw error; + } } ); diff --git a/src/ipc/handlers/upload_handlers.ts b/src/ipc/handlers/upload_handlers.ts new file mode 100644 index 0000000..0b63855 --- /dev/null +++ b/src/ipc/handlers/upload_handlers.ts @@ -0,0 +1,59 @@ +import { ipcMain } from "electron"; +import log from "electron-log"; +import fetch from "node-fetch"; + +const logger = log.scope("upload_handlers"); + +interface UploadToSignedUrlParams { + url: string; + contentType: string; + data: any; +} + +export function registerUploadHandlers() { + ipcMain.handle( + "upload-to-signed-url", + async (_, params: UploadToSignedUrlParams) => { + const { url, contentType, data } = params; + logger.debug("IPC: upload-to-signed-url called"); + + try { + // Validate the signed URL + if (!url || typeof url !== "string" || !url.startsWith("https://")) { + throw new Error("Invalid signed URL provided"); + } + + // Validate content type + if (!contentType || typeof contentType !== "string") { + throw new Error("Invalid content type provided"); + } + + // Perform the upload to the signed URL + const response = await fetch(url, { + method: "PUT", + headers: { + "Content-Type": contentType, + }, + body: JSON.stringify(data), + }); + + if (!response.ok) { + throw new Error( + `Upload failed with status ${response.status}: ${response.statusText}` + ); + } + + logger.debug("Successfully uploaded data to signed URL"); + return { success: true }; + } catch (error) { + logger.error("Failed to upload to signed URL:", error); + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } + } + ); + + logger.debug("Registered upload IPC handlers"); +} diff --git a/src/ipc/ipc_client.ts b/src/ipc/ipc_client.ts index 369c27d..3c63c40 100644 --- a/src/ipc/ipc_client.ts +++ b/src/ipc/ipc_client.ts @@ -21,6 +21,7 @@ import type { LocalModelListResponse, TokenCountParams, TokenCountResult, + ChatLogsData, } from "./ipc_types"; import type { CodeProposal, ProposalResult } from "@/lib/schemas"; import { showError } from "@/lib/toast"; @@ -749,7 +750,35 @@ export class IpcClient { public async getSystemDebugInfo(): Promise { try { const data = await this.ipcRenderer.invoke("get-system-debug-info"); - return data; + return data as SystemDebugInfo; + } catch (error) { + showError(error); + throw error; + } + } + + public async getChatLogs(chatId: number): Promise { + try { + const data = await this.ipcRenderer.invoke("get-chat-logs", chatId); + return data as ChatLogsData; + } catch (error) { + showError(error); + throw error; + } + } + + public async uploadToSignedUrl( + url: string, + contentType: string, + data: any + ): Promise<{ success: boolean; error?: string }> { + try { + const result = await this.ipcRenderer.invoke("upload-to-signed-url", { + url, + contentType, + data, + }); + return result as { success: boolean; error?: string }; } catch (error) { showError(error); throw error; diff --git a/src/ipc/ipc_host.ts b/src/ipc/ipc_host.ts index 6328f06..1575ccb 100644 --- a/src/ipc/ipc_host.ts +++ b/src/ipc/ipc_host.ts @@ -12,6 +12,7 @@ import { registerSupabaseHandlers } from "./handlers/supabase_handlers"; import { registerLocalModelHandlers } from "./handlers/local_model_handlers"; import { registerTokenCountHandlers } from "./handlers/token_count_handlers"; import { registerWindowHandlers } from "./handlers/window_handlers"; +import { registerUploadHandlers } from "./handlers/upload_handlers"; export function registerIpcHandlers() { // Register all IPC handlers by category @@ -29,4 +30,5 @@ export function registerIpcHandlers() { registerLocalModelHandlers(); registerTokenCountHandlers(); registerWindowHandlers(); + registerUploadHandlers(); } diff --git a/src/ipc/ipc_types.ts b/src/ipc/ipc_types.ts index a69e0c1..4e03e66 100644 --- a/src/ipc/ipc_types.ts +++ b/src/ipc/ipc_types.ts @@ -106,6 +106,7 @@ export interface TokenCountParams { chatId: number; input: string; } + export interface TokenCountResult { totalTokens: number; messageHistoryTokens: number; @@ -114,3 +115,9 @@ export interface TokenCountResult { systemPromptTokens: number; contextWindow: number; } + +export interface ChatLogsData { + debugInfo: SystemDebugInfo; + chat: Chat; + codebase: string; +} diff --git a/src/preload.ts b/src/preload.ts index 008f514..9471fdd 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -14,6 +14,7 @@ const validInvokeChannels = [ "create-app", "get-chat", "get-chats", + "get-chat-logs", "list-apps", "get-app", "edit-app-file", @@ -53,6 +54,7 @@ const validInvokeChannels = [ "window:maximize", "window:close", "window:get-platform", + "upload-to-signed-url", ] as const; // Add valid receive channels